java面经
面试
企业是如何筛选简历
1、HR先筛选,筛硬条件
学历、院校、经验、年龄、跳槽频率
2、部门负责人筛选
当前项目技术栈
是否符合业务条件
额外加分项:
高可用高并发经验
熟悉基于公有云的开发经验
团队管理的经验
博客,github等项目地址
简历的注意事项
1、职业技能
写职业技能的时候,要给面试官留问问题的空间
把自己学过的技术点,熟练掌握怎么说的技术点,具体的技术点写上去,让他问
2、项目经历
应届生该如何找到合适的练手项目
找项目,学习项目
主要是在Gitee/Github上搜项目
拉取到本地先运行起来
debug跟踪代码逻辑
梳理业务逻辑
删除核心的代码,试试自己能不能独立完成
吃透项目(权限认证)
1、功能实现
业务功能
用户名密码登录、二维码登录、手机短信登录、用户、角色、权限管理和分配
技术方案
RBAC模型、Spring Security、Apache Shiro
2、常见的问题
token刷新问题、加密、解密、XSS防跨站攻击
3、权限系统设计
可扩展性、高可用性、通用性
Java程序员的面试过程
主要是技术面
使用场景
redis
缓存穿透
缓存穿透:查询一个不存在的数据,mysql查询不到的数据也不会直接写入缓存,导致每次请求都查数据库。
解决方案1:缓存空数据
优点:简单
缺点:消耗内存,如果该id有了数据,从缓存里取到的还是空数据,会发生数据不一致问题
解决方案2:布隆过滤器
布隆过滤器
简单说就是把数据id用hash计算3次,得到三个位数,如果有值就记为1。 那么通过布隆过滤器判断id是否有值,就是把用户传入的id用hash计算3次,看一下对应位置是否都是1,如果不是,说明数据不存在。
缺点:存在误判率
可以通过扩大数组来减少误判,但是会增加内存消耗
测试代码
// size 布隆过滤器存储的元素个数,0.05是误判率
boomFilter.tryInit(size,0.05);
缓存击穿
缓存击穿:给某个Key设置了过期时间,key过期的时候,刚好有大量的请求过来,瞬间把数据库压垮
也就是key过期,发请求发现没有,从数据库往redis存数据的这50ms把数据库击垮
解决方案1:互斥锁
流程:如果出现查询不到数据的情况,直接对该数据加锁,然后去请求db,把数据写入缓存,才释放锁,如果这个操作过程中其他线程想要获取该数据,会发现被上了锁,就休眠一会再试,直到缓存命中为止。
优点:可以保证数据的强一致性
缺点:性能比较差,所有线程都需要等到数据写入缓存才能返回数据
解决方案2:逻辑过期
所有数据加个过期字段
{"id":1,"expire":156563269}
流程:如果查询数据,发现数据已经过期,加互斥锁,开启新进程去完成从db中往缓存取数据,修改过期时间等,本进程直接返回过期数据,如果此时还有其他进程也访问该数据,但是发现加了互斥锁,直接返回过期数据。直到新线程把数据写到缓存,释放了互斥锁,线程就可以获取新数据了。
优点:高可用、性能好
缺点:返回的数据不一致
使用建议:看应用场景,如果需要高度一致,比如和钱相关的金融业务,那么必须要用互斥锁,如果为了快速响应,提高用户体验,可以用逻辑过期这种处理方式。
缓存雪崩
缓存雪崩:因为大量的key过期,或者redis直接宕机,导致大量的请求到达数据库,带来了巨大的压力。
解决方案
- 如果是因为大量的key过期,那就在设置过期的时间TTL加个随机值,可以减少key同时过期的概率
- 如果是redis宕机了,那就在部署redis的时候,使用redis集群,比如哨兵模式、三主三从的集群模式,来提高服务的可用性
- 给缓存业务添加降级限流策略,比如nginx或spring cloud gateway去添加限流策略
- 给业务添加多级缓存 ,比如把Guava或Caffeine作为一级缓存,nginx作为二级缓存
双写一致性
概念:修改数据库的同时要更新缓存,让数据库和缓存保持一致
为什么数据库和缓存会不一样呢?
原因
因为线程1把缓存删了,线程2没命中,直接把旧数据读出来写到缓存中,而此时还未把新数据写到数据库,导致缓存里是旧数据
先操作数据库,再删除缓存也会出现脏数据
也就是说线程1未命中查询到的数据库是旧数据,直接写入缓存了,返回数据了,而此时,线程2正把新数据写入数据库呢,线程1没读到新数据
总结一句话,就是不管先删缓存,还是先改数据库,都可能会出现把旧数据缓存到redis,出现脏数据的情况,所以我们需要在修改数据库前后删两次缓存来保证数据库和redis数据的一致性。
解决方案1:延迟双删
为什么第二次删除缓存的时候要延时呢?
因为主数据库要把数据写到从数据库上需要时间,但是因为有延时所以还是会有脏数据。
解决方案2:读操作使用共享锁,写操作使用排他锁
共享锁:读不互斥,写互斥
排他锁:读写都互斥
代码示例
读操作使用共享锁
public Item getById(Integer id) {
// 获取读写锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
// 获取读锁(读写锁中的读操作部分)
RLock readLock = readWriteLock.readLock();
try {
// 上锁
readLock.lock();
System.out.println("readLock...");
// 从 Redis 缓存中尝试获取项
Item item = (Item) redisTemplate.opsForValue().get("item:" + id);
// 如果缓存中有项,则直接返回
if (item != null) {
return item;
}
// 如果缓存中没有项,创建新项
item = new Item(id, "华为手机", "华为手机", 5999.00);
// 将新项存入 Redis 缓存
redisTemplate.opsForValue().set("item:" + id, item);
// 返回新创建的项
return item;
} finally {
// 解锁
readLock.unlock();
}
}
写操作使用排他锁
public void updateById(Integer id) {
// 获取读写锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
// 获取写锁(读写锁中的写操作部分)
RLock writeLock = readWriteLock.writeLock();
try {
// 上锁
writeLock.lock();
System.out.println("writeLock...");
// 模拟更新操作
Item item = new Item(id, "华为手机", "华为手机", 5299.00);
// 模拟延迟操作,可能表示某种复杂的更新过程
try {
Thread.sleep(10000); // 延迟 10 秒
} catch (InterruptedException e) {
e.printStackTrace(); // 捕获并打印中断异常
}
// 删除 Redis 缓存中的项
redisTemplate.delete("item:" + id);
} finally {
// 解锁
writeLock.unlock();
}
}
优点:强一致性
缺点:性能较差
解决方案3:使用MQ异步通知
每次修改数据库,我们都发布消息给MQ,缓存随时监听MQ的变化,如果有新的消息,再更新缓存,在高并发下会有数据不一致的情况,但是我们可以保证最终数据的一致性。
解决方案4:使用Canal异步通知
canal记录了所有数据定义数据操作的语句,不包含查询语句
流程如上,每次修改数据库,我们都把消息发给canal,由canal通知缓存数据变化情况,再更新数据,也能保证数据最终的一致性
持久化
RDB
含义:Redis数据快照,也就是把内存中的所有数据整体记录到磁盘中,如果redis实例故障重启,可以从磁盘中读取快照文件,恢复数据
实操命令
redis-cli #连接redis
save #Redis主进程执行rdb,会阻塞所有命令
bgsave # 开启子进程执行rdb,避免主进程受到影响
redis.conf文件下也有对应的设置
# 900s内,如果至少有1个key被修改,则执行bgsave
save 900 1
save 300 10
save 60 10000
执行原理
执行bgsave命令的时候会把主进程fork得到子进程,子进程和主进程共享同一片物理内存,这时子进程就可以读取内存文件存到rdb中了
这种方式存在脏读的问题,所以fork采用的是copy-on-write技术,也就是让数据加上read-only的锁
当主进程执行读操作时,访问共享内存
当主进程执行写操作时,只能先拷贝一份数据,执行写操作
AOF
含义:可以称为追加文件,redis所处理的每一个写命令都会记录到AOF中,可以看作是命令文件
aof默认是关闭的,需要修改redis.conf配置来开启
# 是否开启AOF功能,默认是no
appendonly yes
# aof文件的名称
appendfilename "appendonly.aof"
# aof命令记录的频率
appendfsync no/always/everysec
配置项 | 刷盘时机 | 优点 | 缺点 |
---|---|---|---|
always | 同步刷盘(每执行一次写命令,立即记录到AOF文件中) | 可靠性高,几乎不丢数据 | 性能较差 |
everysec | 每秒刷盘(每隔1s将缓冲区数据写到AOF文件中) | 性能中 | 最多丢失1s数据 |
no | 操作系统控制(由操作系统决定何时将缓冲区内容写回磁盘) | 性能最好 | 可靠性差,可能丢失大量数据 |
因为AOF是记录命令,所以要比记录数据的RDB大得多,因为AOF会记录对同一个key多次写操作,但只有最后一次才有意义,那么为了减少aof体积,我们可以合并命令,通过bgrewriteaof命令,可以让aof文件执行重写命令,用最少的命令达到相同的效果
redis.conf配置aof触发阈值重写aof
# 文件比上次超多百分之百,也就是上次aop文件两倍触发重写
auto-aof-rewrite-percentage 100
# aop文件体积最小达到多大触发重写
auto-aof-rewrite-min-size 64mb
RDB和AOF对比
RDB | AOF | |
---|---|---|
持久化方式 | 定时对整个内存做快照 | 记录每一次写命令 |
数据完整性 | 不完整,两次备份之间会有数据丢失 | 相对完整,取决于刷盘策略 |
文件大小 | 有压缩,体积小 | 记录文件,体积大 |
宕机恢复速度 | 很快 | 慢(因为体积) |
数据恢复优先级 | 低,因为数据相对不完整 | 高 |
系统占用资源 | 高,大量的cpu和内存 | 低,记录命令的时候只使用IO资源但是aof重写的时候会占用大量的cpu和内存 |
使用场景 | 可以接受数分钟数据丢失,追求更快的启动速度 | 对数据的安全性要求高 |
数据删除策略
惰性删除
设置key的过期时间,当需要该key,再检查是否过期,如果过期,就删掉,没过期,就返回
set name zhangsan 10
get name
(只有key过期才会检查)
优点:对cpu友好
缺点:对内存不友好
定期删除
每隔一段时间,就对key进行检查(从一定数量的数据库抽取一定数量的key),并删除其中的过期key
两种模式
slow模式:默认是10hz,每次不超过25ms,可以通过修改redis.conf的hz选项来调整这个次数
fast模式:两次间隔不低于2ms,每次耗时不超过1ms
优点:可以通过限制操作删除的执行时长和频率来控制对cpu和内存的影响
缺点:难确定删除的执行时长和频率
Redis的过期删除策略:惰性删除+定期删除配合使用
数据淘汰策略
Redis的内存不够的时候,此时向redis中添加新的key,那么,redis会按照你配置的规则将数据删除掉
先说两个算法,LRU、LFU
LRU(least recently used)最近最少使用,当前时间-最后访问时间,这个值越大越优先淘汰,换句话说就是淘汰最长时间没访问的
LFU (least frequently used ) 最少频率使用,会统计每个key的访问频率,值越小淘汰优先级越高
8种策略,nginx.conf中的配置
maxmemory-policy noeviction #默认策略,不淘汰任何key,内存满了不允许写入新数据
数据淘汰策略使用建议
1、如果数据有明显的冷热分区,使用allkeys-lru,把最常用的,热度最高的数据留在缓存里
2、如果数据访问没特点(访问频率差别不大,没有冷热区分),那就用随机策略,allkeys-random
3、如果有置顶需求,可以设置置顶数据不过期,淘汰其他过期时间数据,volatile-lru
4、如果业务中有短时高频访问的数据,可以使用带lfu算法的策略
分布式锁
使用场景
抢券逻辑
问题点
如果不加锁,都扣减库存,可能会使库存变成-1,出现错误
如果在整个流程外面套上下面的锁
synchronized(this){
}
对于单服务器的可以上锁,阻塞其他进程,但是对于多服务器部署(集群部署),处在不同服务器上的线程就没办法上锁,阻塞进程了
在这种情况下,就要用到我们的分布式锁
实现原理
主要是利用setnx命令,setnx是set if not exists(如果不存在)的简写
指令
SET lock value NX EX 10 #添加锁,NX是互斥,EX是设置超时时间
DEL key #释放锁
添加锁命令可以分两步执行吗?也就是我先添加锁再设置过期时间?
最好不要,两条命令,不能保证原子性。
原子性: 指一组操作要么全部执行成功,要么全部失败,而不会在执行过程中出现中间状态 。
分两步会出现的问题
- 锁的有效期不确定:如果第一步成功但第二步失败(例如由于网络问题、系统崩溃等),锁将没有过期时间,这可能导致死锁。
- 并发冲突:如果在两个命令之间另一个客户端尝试获取同一个锁,它可能会成功获取,因为第一个命令执行后锁已经存在,但过期时间还没有设置,这会导致资源争用和冲突。
以上的问题,说明了设置锁的有效时长是必要的。
那么怎么设置有效时长呢?
1、可以根据业务执行时间预估
这个不靠谱,因为可能会因为网络堵塞,网络抖动等原因,导致执行业务的时间变长,业务实际执行时间不好预估
2、给锁续期
简单说,就是再开个线程监控,隔一段时间就给锁续期,等业务执行完,手动释放锁
reddision分布式锁实现锁续期
while循环尝试获取锁,会设置一定次数后再释放锁,这样做既提升了性能,也不会一直占用资源不释放。
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedisDistributedLockExample {
public static void main(String[] args) {
// 创建Redisson配置
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建Redisson客户端
RedissonClient redissonClient = Redisson.create(config);
// 获取锁对象
RLock lock = redissonClient.getLock("snoweelock");
try {
// 尝试获取锁
// 参数分别是,获取锁的最大等待时间,自动释放时间,时间单位
// 注意,如果设置了自动释放时间,那么就不会有看门狗做续期
//boolean isLocked = lock.tryLock(10,30,TimeUnit.SECONS);
boolean isLocked = lock.tryLock(10,TimeUnit.SECONS);
if (isLocked) {
try {
// 获取锁成功,执行业务逻辑
System.out.println("获取锁成功,开始执行业务逻辑...");
// 模拟业务处理
Thread.sleep(5000); // 业务处理时间
} finally {
// 释放锁
lock.unlock();
System.out.println("业务逻辑执行完毕,释放锁。");
}
} else {
// 获取锁失败
System.out.println("获取锁失败,其他线程正在处理。");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 关闭Redisson客户端
redissonClient.shutdown();
}
}
}
加锁,设置过期时间等操作都是通过lua脚本完成的
reddision分布式锁实现可重入
示例代码
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonReentrantLockExample {
public static void main(String[] args) {
// 创建Redisson配置
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建Redisson客户端
RedissonClient redissonClient = Redisson.create(config);
// 获取锁对象
RLock lock = redissonClient.getLock("snoweelock");
try {
// 第一次获取锁
lock.lock();
System.out.println("第一次获取锁成功");
// 模拟业务逻辑(业务逻辑中又加了一次锁)
performBusinessLogic(lock);
} finally {
// 最后一次释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
System.out.println("最后一次释放锁");
}
// 关闭Redisson客户端
redissonClient.shutdown();
}
}
private static void performBusinessLogic(RLock lock) {
try {
// 第二次获取同一个锁
lock.lock();
System.out.println("第二次获取锁成功");
// 模拟业务处理
Thread.sleep(2000); // 业务处理时间
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 第二次释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
System.out.println("第二次释放锁");
}
}
}
}
实现机制
利益hash结构记录线程id和重入次数
每个线程都有自己单独的id,根据线程id判断是否是同一个进程,是的话value+1,解锁后-1,value为0的时候把记录删除
key | field | value |
---|---|---|
snoweelock | thread1 | 1 |
redission分布式锁实现主从一致性
问题
会出现redis主从集群上,主节点宕机前加了锁,某个从节点变主节点,又对相同的业务加了锁,从而导致出现两个线程同时持有一把锁
解决方案一
RedLock(红锁):在创建锁的时候,不止在主节点创建锁,而是在多个redis实例上创建锁(n/2+1也就是超过半数),避免在一个redis实例上加锁
缺点:实现复杂、性能差、运维繁琐
建议采用zookeeper实现的分布式锁
redis实现的分布式锁是ap思想,zookeeper实现的分布式锁是cp思想
在分布式系统中,CAP 定理(也称为布鲁尔定理)描述了分布式数据存储在以下三个特性之间的权衡:
- 一致性(Consistency):每次读取都能返回最新的写入结果。
- 可用性(Availability):每次请求都会收到(成功或失败)响应——但是不保证数据是最新的。
- 分区容忍性(Partition Tolerance):系统即使在部分消息丢失或网络分区的情况下仍能继续运行。
根据 CAP 定理,一个分布式系统最多只能同时满足两个特性,必须对第三个特性做出权衡。Redis 和 Zookeeper 就是基于不同权衡点的典型代表:
Redis 的 AP 模型
Redis 是一个高性能的键值存储系统,通常被设计为满足 AP 模型(可用性和分区容忍性):
- 可用性(Availability):Redis 的主从复制机制和哨兵模式确保了数据的高可用性,能够在主节点出现故障时快速切换到从节点。
- 分区容忍性(Partition Tolerance):即使在网络分区的情况下,Redis 也能继续提供服务。可能会牺牲一致性,但能够保证服务的持续可用性。
Redis 的这种设计使其非常适合用于需要高可用性和快速响应的场景,例如缓存、会话存储等。
Zookeeper 的 CP 模型
Zookeeper 是一个分布式协调服务,通常被设计为满足 CP 模型(一致性和分区容忍性):
- 一致性(Consistency):Zookeeper 使用了 Zab(Zookeeper Atomic Broadcast)协议来确保数据的一致性。每次写操作都需要在多数节点上确认,确保了强一致性。
- 分区容忍性(Partition Tolerance):即使在网络分区的情况下,Zookeeper 也能保证数据的一致性,但在分区期间可能会牺牲部分可用性。
Zookeeper 的这种设计使其非常适合用于需要强一致性和协调的场景,例如分布式锁、配置管理、领导选举等。
总结
- Redis(AP):强调高可用性和分区容忍性,适合缓存、会话存储等场景。
- Zookeeper(CP):强调强一致性和分区容忍性,适合分布式锁、配置管理等需要协调和一致性的场景。