java面经

面试

企业是如何筛选简历

1、HR先筛选,筛硬条件

学历、院校、经验、年龄、跳槽频率

2、部门负责人筛选

当前项目技术栈

是否符合业务条件

额外加分项:

高可用高并发经验

熟悉基于公有云的开发经验

团队管理的经验

博客,github等项目地址

简历的注意事项

1、职业技能

img

写职业技能的时候,要给面试官留问问题的空间

img

把自己学过的技术点,熟练掌握怎么说的技术点,具体的技术点写上去,让他问

2、项目经历

img

应届生该如何找到合适的练手项目

找项目,学习项目

主要是在Gitee/Github上搜项目

拉取到本地先运行起来

debug跟踪代码逻辑

梳理业务逻辑

删除核心的代码,试试自己能不能独立完成

吃透项目(权限认证)

1、功能实现

业务功能

用户名密码登录、二维码登录、手机短信登录、用户、角色、权限管理和分配

技术方案

RBAC模型、Spring Security、Apache Shiro

2、常见的问题

token刷新问题、加密、解密、XSS防跨站攻击

3、权限系统设计

可扩展性、高可用性、通用性

Java程序员的面试过程

主要是技术面

使用场景

img

img

redis

缓存穿透

img

缓存穿透:查询一个不存在的数据,mysql查询不到的数据也不会直接写入缓存,导致每次请求都查数据库。

解决方案1:缓存空数据

优点:简单

缺点:消耗内存,如果该id有了数据,从缓存里取到的还是空数据,会发生数据不一致问题

解决方案2:布隆过滤器

img

布隆过滤器

img

简单说就是把数据id用hash计算3次,得到三个位数,如果有值就记为1。 那么通过布隆过滤器判断id是否有值,就是把用户传入的id用hash计算3次,看一下对应位置是否都是1,如果不是,说明数据不存在。

缺点:存在误判率

img

可以通过扩大数组来减少误判,但是会增加内存消耗

测试代码

// size 布隆过滤器存储的元素个数,0.05是误判率
boomFilter.tryInit(size,0.05);

缓存击穿

img

缓存击穿:给某个Key设置了过期时间,key过期的时候,刚好有大量的请求过来,瞬间把数据库压垮

也就是key过期,发请求发现没有,从数据库往redis存数据的这50ms把数据库击垮

解决方案1:互斥锁

img

流程:如果出现查询不到数据的情况,直接对该数据加锁,然后去请求db,把数据写入缓存,才释放锁,如果这个操作过程中其他线程想要获取该数据,会发现被上了锁,就休眠一会再试,直到缓存命中为止。

优点:可以保证数据的强一致性

缺点:性能比较差,所有线程都需要等到数据写入缓存才能返回数据

解决方案2:逻辑过期

img

所有数据加个过期字段

{"id":1,"expire":156563269}

流程:如果查询数据,发现数据已经过期,加互斥锁,开启新进程去完成从db中往缓存取数据,修改过期时间等,本进程直接返回过期数据,如果此时还有其他进程也访问该数据,但是发现加了互斥锁,直接返回过期数据。直到新线程把数据写到缓存,释放了互斥锁,线程就可以获取新数据了。

优点:高可用、性能好

缺点:返回的数据不一致

使用建议:看应用场景,如果需要高度一致,比如和钱相关的金融业务,那么必须要用互斥锁,如果为了快速响应,提高用户体验,可以用逻辑过期这种处理方式。

缓存雪崩

img

缓存雪崩:因为大量的key过期,或者redis直接宕机,导致大量的请求到达数据库,带来了巨大的压力。

解决方案

  • 如果是因为大量的key过期,那就在设置过期的时间TTL加个随机值,可以减少key同时过期的概率
  • 如果是redis宕机了,那就在部署redis的时候,使用redis集群,比如哨兵模式、三主三从的集群模式,来提高服务的可用性
  • 给缓存业务添加降级限流策略,比如nginx或spring cloud gateway去添加限流策略
  • 给业务添加多级缓存 ,比如把Guava或Caffeine作为一级缓存,nginx作为二级缓存

双写一致性

概念:修改数据库的同时要更新缓存,让数据库和缓存保持一致

为什么数据库和缓存会不一样呢?

原因

img

因为线程1把缓存删了,线程2没命中,直接把旧数据读出来写到缓存中,而此时还未把新数据写到数据库,导致缓存里是旧数据

先操作数据库,再删除缓存也会出现脏数据

img

也就是说线程1未命中查询到的数据库是旧数据,直接写入缓存了,返回数据了,而此时,线程2正把新数据写入数据库呢,线程1没读到新数据

总结一句话,就是不管先删缓存,还是先改数据库,都可能会出现把旧数据缓存到redis,出现脏数据的情况,所以我们需要在修改数据库前后删两次缓存来保证数据库和redis数据的一致性。

解决方案1:延迟双删

img

为什么第二次删除缓存的时候要延时呢?

因为主数据库要把数据写到从数据库上需要时间,但是因为有延时所以还是会有脏数据。

解决方案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异步通知

img

每次修改数据库,我们都发布消息给MQ,缓存随时监听MQ的变化,如果有新的消息,再更新缓存,在高并发下会有数据不一致的情况,但是我们可以保证最终数据的一致性。

解决方案4:使用Canal异步通知

img

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中了

img

这种方式存在脏读的问题,所以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对比

RDBAOF
持久化方式定时对整个内存做快照记录每一次写命令
数据完整性不完整,两次备份之间会有数据丢失相对完整,取决于刷盘策略
文件大小有压缩,体积小记录文件,体积大
宕机恢复速度很快慢(因为体积)
数据恢复优先级低,因为数据相对不完整
系统占用资源高,大量的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,内存满了不允许写入新数据

img

数据淘汰策略使用建议

1、如果数据有明显的冷热分区,使用allkeys-lru,把最常用的,热度最高的数据留在缓存里

2、如果数据访问没特点(访问频率差别不大,没有冷热区分),那就用随机策略,allkeys-random

3、如果有置顶需求,可以设置置顶数据不过期,淘汰其他过期时间数据,volatile-lru

4、如果业务中有短时高频访问的数据,可以使用带lfu算法的策略

分布式锁

使用场景

抢券逻辑

img

问题点

img

如果不加锁,都扣减库存,可能会使库存变成-1,出现错误

如果在整个流程外面套上下面的锁

synchronized(this){
    
}

对于单服务器的可以上锁,阻塞其他进程,但是对于多服务器部署(集群部署),处在不同服务器上的线程就没办法上锁,阻塞进程了

在这种情况下,就要用到我们的分布式锁

实现原理

主要是利用setnx命令,setnx是set if not exists(如果不存在)的简写

指令

SET lock value NX EX 10 #添加锁,NX是互斥,EX是设置超时时间
DEL key #释放锁

添加锁命令可以分两步执行吗?也就是我先添加锁再设置过期时间?

最好不要,两条命令,不能保证原子性。

原子性: 指一组操作要么全部执行成功,要么全部失败,而不会在执行过程中出现中间状态 。

分两步会出现的问题

  • 锁的有效期不确定:如果第一步成功但第二步失败(例如由于网络问题、系统崩溃等),锁将没有过期时间,这可能导致死锁。

img

  • 并发冲突:如果在两个命令之间另一个客户端尝试获取同一个锁,它可能会成功获取,因为第一个命令执行后锁已经存在,但过期时间还没有设置,这会导致资源争用和冲突。

以上的问题,说明了设置锁的有效时长是必要的。

那么怎么设置有效时长呢?

1、可以根据业务执行时间预估

这个不靠谱,因为可能会因为网络堵塞,网络抖动等原因,导致执行业务的时间变长,业务实际执行时间不好预估

2、给锁续期

简单说,就是再开个线程监控,隔一段时间就给锁续期,等业务执行完,手动释放锁

reddision分布式锁实现锁续期

img

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的时候把记录删除

keyfieldvalue
snoweelockthread11

redission分布式锁实现主从一致性

问题

会出现redis主从集群上,主节点宕机前加了锁,某个从节点变主节点,又对相同的业务加了锁,从而导致出现两个线程同时持有一把锁

img

解决方案一

RedLock(红锁):在创建锁的时候,不止在主节点创建锁,而是在多个redis实例上创建锁(n/2+1也就是超过半数),避免在一个redis实例上加锁

缺点:实现复杂、性能差、运维繁琐

建议采用zookeeper实现的分布式锁

redis实现的分布式锁是ap思想,zookeeper实现的分布式锁是cp思想

在分布式系统中,CAP 定理(也称为布鲁尔定理)描述了分布式数据存储在以下三个特性之间的权衡:

  1. 一致性(Consistency):每次读取都能返回最新的写入结果。
  2. 可用性(Availability):每次请求都会收到(成功或失败)响应——但是不保证数据是最新的。
  3. 分区容忍性(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):强调强一致性和分区容忍性,适合分布式锁、配置管理等需要协调和一致性的场景。