面试面经-1

他人面经

1.spring的底层实现&三级缓存原理

我们先来说三级缓存的实现吧

首先三级缓存是那三个缓存呢?是在DefaultSingletonBeanRegistry类里面定义的三个Map。然后第一第二层都是key是bean的名字,value是bean的实例。第三次key是bean的名字,value是objectfactory.

第一层缓存是用来存储我们已经完全实例化好的bean,在这里可以直接使用的

第二层缓存时用来存储我们早期的bean,创建好,但是并没有进行依赖注入的

第三次缓存是用来存储我们的objectfactory的,用来创建代理对象的

然后我们缓存的核心方法是我们的getSingleton方法,他定义了我们如何去获取缓存的顺序

我们首先先去看第一层缓存,如果第一次没有且bean正在创建中的话。我们再去找第二层缓存,第二层也没有的话,允许早期引用。然后从三级缓存中获取objectfactory

然后使用objectfactory来创建对象,这里可能是代理对象。因为比如AOP,或者使用了其他的代理模式

然后将其升级到二级缓存,将三级缓存里面的删除。(暂不使用,然后再可以进行依赖注入。然后如果我们的作用域是单例的话,就直接放入一级缓存,不是的话,就根据场景来)

然后在我们进行依赖注入的时候可能会出现循环依赖的问题,就是A依赖于B,B也依赖于A的问题

这个时候就需要解决依赖的问题,我们的三层缓存使用的是提前暴露的方法来解决循环依赖的问题

在AbstractAutowireCapableBeanFactory.doCreateBean的方法里面定义了解决的实现

首先我们先实例化bean,只是采用构造器,创建,并没有进行属性的注入

然后我们去判断需不要早期暴露,是不是单例的,因为单例的才会允许循环依赖注入。然后允不允许我们循环依赖。这个是在SpringApplication中设置的,然后这个bean是不是正在被创建中。,

满足了这个条件,我们才会将objectfactory放入三级缓存的时候,保证二级缓存没有,然后这个会获取一个早期引用,如果我们需要AOP 的话,会获取他的代理对象

然后对我们需要的依赖进行属性的填充,进行依赖注入

注入完进行初始化bean

然后为什么是三级缓存呢?因为AOP代理对象的延迟创建问题

在AOP的后置处理器中,获取早期的引用对象的时候,会返回的是我们的代理对象。

如果我们使用二级缓存的,不知道什么时候创建代理对象,可能会创建多个代理对象,AOP的时机控制会失效。

so:

三级缓存通过ObjectFactory实现了:

  • 按需创建:只有真正发生循环依赖时才创建代理对象
  • 唯一性保证:确保一个Bean只有一个早期引用实例
  • 时机控制:代理对象的创建时机由Spring容器精确控制

2.hashmap为什么扩容要是2的倍数

HashMap扩容为2的倍数的根本原因是为了实现高效的哈希计算均匀的元素分布

我们hashmap的tableSizeFor进行容量初始化的时候,通过位运算确保了任何输入都会背转换成大于等于该数的最小2的幂次方。确保了容量为2的次方幂。

然后在put一个元素的时候,我们是通过hash来确定这个值得索引的,使用的是(n-1) & hash 的按位与运算来确定位置的。然后hash函数也进行了优化,高16位与低16位异或,增加散列的随机性

然后我们的扩容方法里面是这样规定的,是单个元素的话,我们使用位运算重新规划位置,如果是红黑树的话,我们对他的头节点进行位运算,

如果是链表的话,我们要对链表进行拆分,通过一个位来判断元素的去向,如果与老容量的位运算是0的话,就留在原位置,是1的话就转移到原位置+oldcap的位置,这个算法是非常巧妙的,比如假设hash为21,oldcap=16-1,那么原位置就是5,然后现在我们再进行运算,21&16=0,那么他就是留在原位置。

然后讲我们拆分的链表加入到我们新的数组之中。这个时候要注意链表的长度,如果超过了8的话,且数组位数大于64,需要转为红黑树。

然后为什么是2的倍数呢?当我们的数字长度为2的次方幂的时候,我们使用位运算比我们的取模运算高效的多,然后我们不需要重新计算hash值,只需要检查一个位就可以确定新的位置,然后如果是2的幂次方保证了hash值的每一位都能参与到索引计算中,而且对cpu的缓存更加友好

3.sychronized的底层原理

sychronized的底层原理,如何实现一个线程在另外一个线程之前执行,两个线程没有进入锁没有先后但要求执行按照指定的前后执行

sychronized的字节码层面的实现是基于监视器实现的,进入监视器,然后执行sychronized修饰的代码块,然后退出监视器

然后在Hotspot JVM中,synchronized基于Monitor对象实现,ObjectMonitor规定了持有锁的线程,重入的次数,等待获取锁的线程的队列,竞争队列,wait方法等待的线程

然后如果我们成功获得锁,直接返回。如果是线程的id等于锁的id的话,重入锁的计数器+1,然后如果不相等的话,就产生了锁的竞争,将其放入慢路径。然后慢路径里面的线程自旋,继续取尝试获取活,如果超过了重试次数的话,将其加入等待队列。

然后出现的三个线程的关系是,竞争队列->唤醒队列->获取锁

然后如何实现一个线程在另外一个线程之前执行,两个线程没有进入锁没有先后但要求执行按照指定的前后执行

我们可以使用countDownLatch

分为A,B两个线程,分别重写他们的run方法,然后A线程执行完之后计数器完成,就通知B线程可以进行。

然后B线程,重修run方法,调用wait等待,计数器为0的时候开始线程的执行

然后countdownlatch的底层实现是,state等于0的时候允许通过,然后释放锁的通过cas来实现的

或者是使用Semaphore,将其初始化为0,然后分为线程A和线程B,线程A执行完之后释放许可,然后B线程执行的时候,尝试获取许可执行

或者是直接使用LockSupport,在A线程完之后,直接唤醒线程B,然后重写B线程的run方法,先是park的等待A线程执行完之后的唤醒

然后我们最常用的实现方案是synchronized + wait/notify

我们先初始化lock然后用sychronized去获取lock,和一个静态的标识位设定为A线程是不是完成。

然后重写A线程run方法,讲标志位设定为true,然后唤醒等待的线程。线程Bwait等待去执行

4.tcp状态机,java底层怎么实现tcp的

那我先说TCP状态机的流程吧,分为客户端状态机和服务端状态机

客户端:

CLOSED -> SYN_SENT -> ESTABLISHED -> FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT -> CLOSED

服务端:

CLOSED -> LISTEN -> SYN_RCVD -> ESTABLISHED -> CLOSE_WAIT -> LAST_ACK -> CLOSED

1
2
3
4
5
6
7
8
9
10
11
CLOSED(0),      // 初始状态,无连接
LISTEN(1), // 服务端监听状态
SYN_SENT(2), // 客户端发送SYN后的状态
SYN_RCVD(3), // 服务端收到SYN后的状态
ESTABLISHED(4), // 连接建立状态
FIN_WAIT_1(5), // 主动关闭方发送FIN后的状态
FIN_WAIT_2(6), // 主动关闭方收到ACK后的状态
CLOSE_WAIT(7), // 被动关闭方收到FIN后的状态
CLOSING(8), // 双方同时关闭的中间状态
LAST_ACK(9), // 被动关闭方发送FIN后的状态
IME_WAIT(10); // 主动关闭方的最终等待状态

那我先说一下TCP三次握手的状态机变化:

客户端主动连接服务端,首先客户端的状态机位close,然后发送完SYN包之后变为SYN_SENT

然后收到服务端发来的SYN+ACK包,发送ACK包,然后状态转为 ESTABLISHED

服务端是监听状态的情况下,接收到客户端的SYN包之后,发送SYN-ACK包,状态变为SYN_RCVD

然后收到客户端的ACK包,状态转变为ESTABLISHED

TCP四次挥手状态机变化

先是主动关闭方,一般是客户端,发送FIN包,然后变为FIN_WAIT_1 状态

然后收到服务端发来的ACK,状态转变为FIN_WAIT_2

然后再收到发来的FIN包,向服务端发送ACK,转为 TIME_WAIT 等待2MSL后关闭,变为Close状态

服务端,收到FIN请求后,发送ACK,请求,变为CLOSE_WAIT,然后应用程序调用close方法,发送FIN包,状态变为LAST_ACK

然后收到ACK请求后,状态变为CLose,彻底关闭


后面这个有些难度了,哭

5.协程和线程的区别是什么?

线程是由操作系统内核进行抢占式调度,任何时候都可能被强制切换,二协程是由用户态程序进行协作式调度,只在主动让出时才会切换

然后线程:

  • 每个线程需要独立的栈空间(通常1MB)
  • 创建和切换需要用户态和内核态转换,开销较大
  • 1000个线程大约占用1GB内存
  • 共享内存模型,需要使用锁、synchronized等机制保证线程安全

适合:

  • CPU密集型任务
  • 需要真正并行执行的场景
  • 传统的多线程编程

协程:

  • 栈空间很小(通常几KB)
  • 在用户态完成创建和切换,开销很小
  • 10万个协程可能只占用几百MB内存
  • 通常采用消息传递模型(如Channel),天然避免数据竞争

适合:

  • IO密集型任务
  • 高并发场景(如服务器处理大量连接)
  • 异步编程,避免回调地狱

协程是轻量级的用户态线程,更适合高并发的IO密集型场景,而线程更适合CPU密集型的并行计算场景。协程的核心优势是用更少的资源实现更高的并发度。

6.介绍一下ArrayList扩容机制

首先先介绍一下ArrayList他的底层是动态的数组,默认的容量为10。但是有一个最大数组的容量,这个主要是考虑JVM的,因为ArrayList给他在JVM里面分配的是一个连续的内存。最大是Integer.MAX_VALUE - 8,为什么呢?JVM在数组对象头中需要存储一些元数据,预留防止出现OOM

然后ArrayList的扩容是add元素之后,剩余的内存容量还足够,通过数组复制的方法,为新元素腾出空间

我们首先会进行容量的检查,这里检查的是Size+1,先去查查内部的容量,然后去检查我们所需要的容量,如果是空数组的话,那么至少需要10的容量

然后去显示的检查容量,计数器+1,如果最小需求容量>当前数组的长度的话,就进行扩容

然后触发核心的扩容方法,新的容量是新容量 = 旧容量 + 旧容量的一半,是根据oldCapacity + (oldCapacity >> 1)计算来的。这样计算更快

然后处理边界的情况,先是最小边界,1.5倍如果还不够的话,就用最小需求量,然后是超大的边界处理,将容量设为容量的最大值

然后检查完之后,进行数组的复制,将元素复制到更大的数组。这里使用Arrays.copyOf然后最终调用System.arraycopy的native方法

优化的策略:

我们可以先进行预估一下,然后再确定一开始初始化需要的初始容量,避免频繁扩容。最好的办法是,开了负载因子,然后容量比我们预计的多一点

7.口述实现非公平与公平的redis分布式锁

非公平锁:

获取锁

  1. 使用SET key value NX EX timeout命令尝试获取锁
  2. 如果设置成功,说明获取锁成功,返回true
  3. 如果设置失败,说明锁被占用,可以选择重试或返回失败
  4. value通常设置为当前线程的唯一标识(如UUID+线程ID)

释放锁

  1. 使用Lua脚本确保原子性操作
  2. 先检查锁的value是否是当前线程设置的
  3. 如果是,则执行DEL命令删除锁
  4. 如果不是,说明锁已超时被其他线程获取,不能删除

重试机制

  • 获取失败后,线程sleep一小段时间再重试
  • 重试间隔可以是固定时间或采用指数退避策略
  • 设置最大重试次数或超时时间避免无限等待

公平锁:

获取锁

  1. 检查当前锁状态
    • 如果锁未被占用,直接尝试获取
    • 如果被占用,进入排队流程
  2. 加入等待队列
    • 将当前线程标识和时间戳加入queue:resource_name有序列表
    • 使用ZADD命令,以时间戳为分数确保顺序
  3. 检查队列位置
    • 获取队列中的第一个元素(最早等待的线程)
    • 如果是当前线程,说明轮到自己,尝试获取锁
    • 如果不是,继续等待
  4. 等待通知机制
    • 使用Redis的BLPOP或发布订阅机制等待通知
    • 或者采用轮询方式,定期检查是否轮到自己
  5. 获取锁的尝试
    • 当轮到自己时,使用SET NX命令尝试获取锁
    • 获取成功后,从等待队列中移除自己
    • 获取失败说明锁被其他线程抢占,重新进入等待

释放锁

  1. 释放锁资源
    • 使用Lua脚本原子性地检查并删除锁
  2. 通知下一个等待者
    • 从等待队列中获取下一个等待的线程
    • 向该线程发送通知信号
    • 可以使用Redis的发布订阅或设置特定key来通知
  3. 清理过期等待者
    • 定期清理队列中的过期等待线程
    • 避免队列无限增长

9.OS的虚拟内存和页面置换算法

虚拟内存是操作系统的核心抽象,通过MMU和页表机制将进程的虚拟地址空间映射到有限的物理内存,实现内存保护、共享和扩展。

当物理内存不足时,页面置换算法决定哪些页面被换出到磁盘。FIFO算法实现简单但可能出现Belady异常;LRU算法基于局部性原理效果最好但实现成本高;Clock算法是LRU的高效近似实现,使用访问位模拟时钟指针给页面第二次机会;Enhanced Clock算法进一步考虑修改位,优先置换干净页面减少磁盘I/O。

现代操作系统如Linux采用多层LRU设计,将页面分为active和inactive链表动态管理热度,并结合预取、内存压缩等技术优化性能。关键是要平衡算法复杂度与性能提升,充分利用程序的时间空间局部性,同时考虑NUMA、SSD等现代硬件特性。在生产环境中需要根据应用特征选择合适策略,避免内存抖动,并通过监控缺页率等指标持续优化。虚拟内存的设计体现了操作系统在资源管理上的核心思想:通过抽象和调度算法,在有限资源上为应用提供无限且高效的服务体验。

10.AQS的设计思想,如果让你去利用AQS设计一个同步工具,怎么写

AQS是一个设计思想,本质上就是一个同步器框架。主要控制状态管理和线程排队,状态管理交给子类,然后线程排队AQS统一处理

核心组件就是state变量和CLH队列变种

state:

  • 使用volatile int保证可见性
  • 通过CAS操作保证原子性修改
  • 语义由子类定义:可以表示锁的持有状态、信号量的许可数量、CountDownLatch的计数等

CLH队列:

  • 双向链表:支持超时和中断处理
  • 虚拟头节点:简化边界条件处理
  • 节点状态:SIGNAL、CANCELLED、CONDITION等,精确控制唤醒时机

流程:

他是先尝试快速获取锁,避免排队。失败后加入队列,但是不会立刻阻塞。在队列中尝试去获得锁,获取前驱释放的锁。实在无法获取的适合才堵塞。

然后获取锁的时候state+1,线程Id=锁的Id,然后解锁的时候-1。知道为0的时候释放锁。

然后在CLH中设计了懒初始化:只有竞争时才创建队列,减少内存开销。设计了唤醒机制,释放资源的线程负责唤醒后记的节点,被唤醒的线程获取资源后,唤醒下一个。避免了雷鸣群羊”问题,精确控制唤醒数量

被唤醒的线程,实现CAS自旋尝试去获得锁,失败后会被park,直到再次unpark。

同步工具,我写了一个基于AQS和令牌桶实现的限流操作

先是引入桶容量,每秒补充的速率。然后对他们进行初始化,初始化令牌桶,将时间戳和令牌数压缩到一个long变量中。

然后重要的是实现tryAcquire 和tryRelease,但是令牌桶不需要主动的释放资源

先去解析我们当前的状态,获取时间和需要的令牌数量,然后计算我们需要补充的令牌的数量,然后判断我们是否可以获取。然后使用CAS更新状态,返回剩余令牌数。CAS失败就自旋重试,避免了线程堵塞。

11.介绍一下动态代理

动态代理主要有JDK动态代理CGLIB字节码增强两种实现方式。JDK动态代理基于接口,在运行时通过Proxy.newProxyInstance()创建代理对象,底层使用反射调用InvocationHandler的invoke方法来拦截目标方法;CGLIB字节码增强无需接口,通过ASM字节码操作库在运行时生成目标类的子类,重写目标方法并插入拦截逻辑,在方法中通过super调用原始方法或MethodInterceptor进行增强。核心区别:JDK代理生成的是接口实现类,性能更好但必须有接口;CGLIB生成的是继承子类,更灵活但不能代理final方法。Spring AOP默认策略是有接口用JDK代理,无接口用CGLIB,都是在运行时动态生成字节码实现方法拦截和功能增强,广泛应用于AOP、事务管理、权限控制等场景。

12.tomcat底层(TODO)

13.布隆过滤器的底层实现

布隆过滤器本质上就是一个大的Bitset+多个独立的hash函数,根据期望元素数和误判率计算最优参数,假设有m个位,k个哈希函数,插入n个元素,最优数组位大小是 -n ln(p) / (ln(2))²,最优hash函数个数是k = (m/n) ln(2)

然后我们添加元素的时候,对元素进行hash,然后放在对应的hash%bitsetsize的位置上,然后设置为1

查询元素的时候,遍历hash,获取其index,然后必须所以的hash位都要是1才可能存在

我们单个使用hash太慢了,我们需要高效生成k个独立的哈希值,就可以使用双hash方法,使用两个不同的哈希函数,比如一个直接调用,一个取16位,让组合生成一个新的hash比如hash1+i+hash2,常用MurmurHash + FNV Hash两个hash组合

然后其布隆过滤器不是完全准确的,可能布隆过滤器中没有,但是数据库中有的,我们就需要计算准确率,较少的计算出某个位为0的概率,然后计算出k个位都为1但是元素不存在的概率

然后1-概率1 pow上概率2

当这个布隆过滤器很大的时候,一般我们都用16位就可以了,k=16时已经能提供足够好的误判率。理论值误判率:(1 - e^(-kn/m))^k

单个优化策略:

  1. 动态扩容创建新的过滤器,容量翻倍,误判率减半,然后只要有一个过滤器返回true就可以
  2. 增加位数组大小,相同的元素数量下,位数组越多,误判率越小。
  3. Counting Bloom Filter,使用计数器代替位数组,成功添加之后,计数器+1,避免单一位冲突,可以支持删除操作
  4. 分层布隆过滤器,元素依次通过多层过滤器,在第一个返回的层添加其元素。然后只有所有的层都返回true才表明存在

关键特性:

如何判断没有一定没有?

  • 如果元素真的被添加过,它的所有哈希位置都必须为1,发现任何一个位置为0,说明元素绝对没有被添加过,所有位置都为1时,可能是其他元素设置的(假阳性)

然后实际的参数的设计,100万商品的话,误判率设为0.01

然后位数组大小 ≈ 9,585,058*,然后单击场景我们就放在JVM内存中,分布式的话就存在redis里面

14.ThreadLocal的底层实现

Threalocal涉及两个组件,一个是他对象本身负责set和get

然后一个ThreadLocalMap负责数据的存储,每一个线程都持有一个ThreadLocalMap他就是副本。是线程隔离的,不会有并发的线程不安全问题

ThreadLocalMap是Thread下面的一个内部类,使用了自定义的散列表来存储键值对,其键是 ThreadLocal 实例对象(弱引用),值是线程本地变量的值(强引用)。

然后ThreadLocalMapEntry 是用 WeakReference 来存储 ThreadLocal 对象实例,这样可以避免 ThreadLocal 对象不会因为强引用而无法被垃圾回收。但需要注意的是,value 是一个强引用,如果 ThreadLocal 没有正确清理,就可能导致内存泄漏的问题。然后我们如果想回收内存的话,需要显示的调用remove方法移除。或者是查找当前线程关联的map,将其键值对分别设为当前线程和null

Threadlocal可以用于线程不安全类的线程安全封装,典型场景SimpleDateFormatSimpleDateFormat 是一个线程不安全的类,可以为每个线程提供一个独立的实例,避免竞争。

在数据库连接的时候应用,通常为每个线程(每个请求)独立创建一个数据库连接,使用 ThreadLocal 来管理这些连接。

管理用户的上下文信息,这个最常用

在分布式事务或者嵌套事务中,通过 ThreadLocal 存储事务信息,使得同一线程的不同方法调用间能共享事务上下文。

InheritableThreadLocal 用于子线程继承父线程变量;

15.详细解析下CAS和原子类

CAS的英文是compare and swap ,就是比较然后交换的意思,他是一个原子操作,用于在多线程环境下实现同步

包括三个操作数

  • V:要更新的变量(内存地址)。
  • E:期望值。
  • N:新值。

CAS 指令会先比较 V 的当前值是否等于 E,如果相等,则将 V 的值原子性地更新为 N;如果不相等,则什么也不做。 整个比较和替换操作是一个原子操作。通常会返回一个布尔值,表示是不是操作成功

CAS 的实现依赖于 CPU 提供的原子指令。不同的 CPU 架构实现方式有所不同,但基本原理类似

在java中是使用的是JUC包下的原子类来实现CAS操作的。这些类通过 Unsafe 类来调用底层的 cmpxchg 指令。该指令的操作数与 CAS 的 V、E、N 对应。该指令会 lock 总线,从而实现原子性。

Unsafe 类是 Java 提供的一个后门,可以直接访问底层系统资源,包括内存操作。AtomicInteger 等原子类使用 Unsafe 类的 compareAndSwapInt 等方法来实现 CAS。

在unsafe类里面静态方法尝试去先获取表示 value 字段在 AtomicInteger 对象中的内存偏移地址。

然后调用compareAndSwapInt方法去执行调用底层的 cmpxchg 指令

但是CAS有一个典型的ABA问题

如果一个变量 V 的值被修改为 A,然后又被修改回 A,CAS 操作会认为 V 的值没有发生变化,从而成功更新。但实际上,V 的值可能已经经过了其他的改变,只是最终又变回了 A。

我们可以加入版本号或者是时间戳的一个遍历来实现,CAS的时候不仅是要去比较变量的值,还要去比较版本号或者时间戳

或者是使用AtomicStampedReferenceAtomicStampedReference 类维护了一个变量值和一个 Stamp(类似于版本号)。CAS 操作需要同时比较变量值和 Stamp,确保变量没有被修改过。

CAS应用在ConcurrenthashMap这种类里面,JUC包下的原子类里面,AQS的实现,还有自旋。不适合高冲突的场景

16.详细说明一下concurrenthashmap的变化

介绍一下,然后其他的线程安全的实现。

数据结构:由分段锁变为了数组链表+红黑树然后使用node数组来存储数据

线程安全的实现:

初始化的时候使用volatile+CAS来确保node数组的初始化

然后如果桶为空使用CAS,不为空使用synchronized锁住链表/红黑树的头节点。扩容的时候使用ForwardingNode标记正在迁移的桶

读操作的时候大部分情况无锁,使用volatile来保证可见性,node节点的next和val都是volatile来修饰的

效果:

锁粒度更细:只锁住具体的桶,而不是整个segment

读操作基本无锁

使用CAS减少锁竞争

然后扩容和红黑树变换

结合实际

17.熟悉RocketMQ的架构设计,深入了解消息幂等性、延迟消息、死信队列等特性

RocketMQ如何保证消息的幂等性?在您的项目中是如何实现的?

延迟消息的实现原理是什么?它有什么限制?

如果消费者处理消息失败,RocketMQ是如何处理的?死信队列的作用是什么?

1.消息幂等:因为RocketMQ只保证了消息至少发送一次,所以我们要在业务逻辑上实现

通过业务唯一ID进行检查,然后尽量使用msg的key的操作来保持幂等,在数据库方面,使用唯一索引和去重表

2.延迟消息:延迟队列定时执行任务,只支持固定的18个延迟级别,可以在broker.conf文件中修改

3.异常处理:消息是先放入redis设计一个短的过期时间,然后执行业务逻辑,执行成功延长过期时间,失败就过期。然后ACK消息

然后设置消息为RECONSUME_LATER,执行重试逻辑。到达重试次数,记录日志,发送到死心队列进行人工处理,死信消息默认保留3天

18.详细解释ZSet在Redis中的底层实现,以及跳表的使用场景

ZSet是Redis中一种常用的数据结构,用于存储有序的元素集合,它可以根据元素的分数(score)进行排序,通常用于排行榜等场景。

他的底层实现是两种数据结构

  1. 压缩列表(ziplist): 在元素数量较少且元素成员(member)较短时使用。 压缩列表的特点是内存占用小,但插入、删除操作的效率较低,因为它需要进行连续的内存移动。

    • 结构: 压缩列表是一种特殊的”连续内存块”构成的数据结构,类似于数组,但可以存储不同长度的数据。 它由多个entry组成,每个entry保存ZSet中的一个元素(member-score)。
    • 缺点:当压缩列表中某个entry的长度发生变化时,可能会导致后续entry的offset也需要更新,如果更新的entry数量较多,就会导致连锁更新,影响性能。
  2. 跳表(skiplist) + 字典(dict): 在元素数量较多或元素成员较长时使用。 跳表可以提供较高的插入、删除、查找效率,但会占用更多的内存空间。 字典则用于存储member到score的映射,使得可以通过member快速查找score。

    • 跳表的定义: 跳跃表(skiplist)是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。 跳表是一种概率型数据结构,它通过随机算法来决定每个节点拥有多少个指针。 跳表是一种可以实现平均O(log N) 查找,插入,删除的有序数据结构。
    • 跳表的结构: 跳表由多层链表组成,每一层链表都包含所有的元素,但元素之间的连接方式不同。 最底层是包含所有元素的有序链表,而上层链表则以一定的概率包含下层链表的元素,从而构成一种类似于索引的结构。 节点会包含一个后退指针(backward pointer),指向位于前一个节点。 在跳表中,节点按照它们的分值大小进行排序。
    • 字典的定义: 字典,也称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。

满足这两个条件就会使用跳表

  1. 元素数量超过zset_max_ziplist_entries配置的值(默认128): 这个配置限制了使用压缩列表存储的元素数量。
  2. 集合中任一member的长度大于zset_max_ziplist_value配置的值(默认64): 这个配置限制了使用压缩列表存储的元素的长度。

19.ziplist的底层以及改进

1
<zlbytes><zltail><zllen><entry1><entry2>...<entryN><zlend>

zlbytes:整个压缩列表占用字节数

zltail:表尾节点距离起始地址的偏移量

zllen:节点数量

entry:各个节点

zlend:标记压缩列表末端

每个entry

1
<prevlen><encoding><entry-data>

当前一个节点长度从小于254字节变为大于等于254字节时,prevlen字段会从1字节扩展到5字节,可能引发后续节点的连锁扩展

然后就属于节点数量比较少的时候使用

在list中会被listpack代替,zset中被skiplist代替在listpack中,取消了prevlen字段,增加了len字段记录当前节点长度,可以从后往前遍历,避免连锁更新

20.索引下推

SELECT * FROM users WHERE age > 20 AND name LIKE ‘张%’ AND city = ‘北京’;

存在复合索引:(age, name, city)

没有索引下推的话,使用索引定位范围测试之后,每一个查询都需要进行回表。然后在MySQL Server层过滤name和city条件

有索引下推的话,使用索引定位完之后,在索引层直接判断,只有同时满足三个条件的记录才回表。大幅度减少回表查询的次数

因为他范围查询,会破坏后续字段的有序性。正常无法走索引,但是索引下推可以。

使用 EXPLAIN 命令查看执行计划,确认是否使用了索引下推优化。ICP,然后进行调试让其走索引下推

21.JVM垃圾回收器

分类:

Serial / Parallel(吞吐优先)

CMS(低停顿)

G1(低停顿 + 区域化收集)

ZGC / Shenandoah(可预测、低延迟、支持大内存)搜集场景题

收集器 特点 回收策略 适用场景
CMS 并发回收,停顿低,已淘汰 标记-清除/存在内存碎片问题 老项目,注重响应延迟(已不推荐)
G1 分区回收,预测停顿 Region + 标记-整理 推荐,适合大内存、对吞吐和延迟平衡
ZGC 并发、低延迟、支持 TB 级堆 可并发标记/重定位 极低停顿、现代系统(JDK ≥ 17)

Redis&数据库分布式锁

我们一般就是使用的redisson,Redisson 基于 RedLock;提到了锁的可重入机制;与 AQS 有类比意识;简略提到数据库锁的粒度更大,Redis 粒度更细并发性更好;

Redisson 提供的是一种 基于 Redis 的可重入分布式锁实现,其原理包括:

  • 使用 Redis 的 SET key value NX PX timeout 命令原子性加锁;
  • value 为唯一线程标识(如 UUID + ThreadId);
  • 可重入:同一个线程再次加锁会对 value 维护一个计数器;
  • 释放锁时会先比对线程标识,确保只有锁的拥有者可以释放;
  • 使用 Lua 脚本释放锁,保证原子性;
  • 提供“看门狗机制(watchdog)”,定时自动续期避免超时释放锁;

数据库:

常见方式有两种:

  • 利用数据库的行级锁(如基于 select ... for update);
  • 使用数据库表记录作为“锁资源”,进行 insert/update 加锁;

特点:

  • 实现简单,易于理解;
  • 可靠性高(依赖事务和 ACID);
  • 性能差,阻塞严重,不适合高并发;
  • 不具备重入机制,不支持超时释放;
  • 会增加数据库压力,影响主业务。

场景题目

1.MySQL自增ID用完了怎么办?

那么我们先来分析一下,我们的自增ID最多可以多少呢?

如果是INT类型类型的话,普头的int,差不读能容纳约21的数据,UNSIGNED INT的话,能容纳43亿的数据

如果我们使用的BIGINT的话,-2^63^ ——2^63的数据,UNSIGNED BIGINT更多了,大约1844万亿的数据

一般的话,我们有一下几个方案:

1.INT升级到BIGINT,可以使用pt-online-schema-change安全升级,或者直接使用Mysql的ALTER TABLE your_table MODIFY COLUMN id BIGINT AUTO_INCREMENT;

这样的话,我们的数据量变大了,但是升级过程中会锁表,建议在维护窗口进行,而且需要同步修改相关的外键应用,而且java里面我们得用long字段而不是int字段了

2.分库分表,一般的我们可以根据ID的范围来分表,多少到多少为一个。这样分表比较简单。

3.使用UUID,是由32个十六进制数字组成的,因此一个UUID总共由128(32*4)个bit组成,也是说理论上有2的128次方个值可以使用。作为我们的自增主键,但是普通的UUID性能较差,可以使用OrderedUUID,这个有序的UUID,而且还可以将时间戳嵌入UUID的前缀,确保了有序性

4.使用雪花算法,利用机器ID和时间戳来生成64位长整型ID。最终生成的ID是会按时间递增的,但是也要考虑时钟回拨的问题,建议使用。

5.使用ID回收策略,回收被我们删除的ID,然后优先使用回收的ID,没有回收的话,才使用新的ID。一般用于实现假删除的表,但是我们需要去查,哪一些才是假删除的。

2.如何把一个文件快速下发到100w个服务器

对于100万台服务器的文件快速下发问题,我感觉可以使用P2P网络来解决

我们传统的方案是中心化下发,一个传输端发送给很多的接收端,很显然,在这个场景下不合适

我们可以设计一下

构建多个独立的生成树同时传输,将100万节点构建成3-5个不相交的生成树,每个节点在不同树中担任不同角色(有时是父节点,有时是子节点),文件分片后通过不同树路径并行传输

分片策略,将大文件切分为N个数据块,不同的数据块交给不同的生成树,每个节点收集所有分片之后重组文件

我们构建三层架构,种子层:5-10台高带宽服务器作为初始种子,中继层:按地理位置/网络拓扑划分的1000-5000台中继节点,叶子层:普通服务器节点

减少网络的跳数,降低延迟。然后理由网络拓扑特性,避免跨地域传输。

然后建立容错机制,每个节点连接2-3个父节点并行下载,任一节点故障时子节点自动重新寻找父节点,通过健康检查和动态调整保证传输连续性。优化策略包括最稀缺分片优先传输、自适应带宽分配、就近父节点选择等。

这样的话,我们理论上O(log N)轮传输完成,实际10-15轮即可完成。

实际上是树状结构升级为容错的多树并行网络,既保持P2P高效性又解决了单点故障和路径瓶颈问题。

3.服务器上如果有很多time wait如何解决,以及出现这个问题的场景有哪些

首先time_wait是tcp连接中,主动关闭一方的状态,等待2MSL后关闭。然后服务器出现说明是服务器主动断开的连接

比如:

  1. 并发Web服务:比如电商大促期间,Nginx反向代理服务器向后端应用服务器发起大量短连接请求,每次请求完成后主动关闭连接,导致Nginx服务器积累大量TIME_WAIT状态。
  2. 微服务架构:服务A调用服务B的REST API,使用HTTP短连接方式,每次调用后都关闭连接。在高QPS场景下,比如订单服务调用库存服务,会产生大量TIME_WAIT。
  3. 数据库连接池配置不当:应用服务器连接MySQL时,如果没有使用连接池或连接池配置过小,频繁创建和关闭数据库连接,会在应用服务器上产生大量TIME_WAIT。
  4. 监控和健康检查:负载均衡器(如F5、ELB)对后端服务器进行健康检查,每次检查都是短连接,高频率的健康检查会导致后端服务器TIME_WAIT堆积。

timewait过多会导致:

端口耗尽:Linux默认可用端口范围是32768-65535,大约3万个端口。当TIME_WAIT状态的连接达到这个数量时,无法创建新的出站连接,出现”Cannot assign requested address”错误。

内存占用:每个TIME_WAIT连接都会占用一定的内核内存,大量堆积会消耗系统资源。

我们可以使用分层解决的办法

应用层:因为可能是http短连接导致的,我们启用启用HTTP Keep-Alive,在nginx里面配置keeplive

然后可能是数据库的频繁连接导致的,我们使用连接池,HikariCP、Druid。http的连接池,HttpClient的PoolingHttpClientConnectionManager,redis的连接池,Jedis Pool

系统层:

  1. 开启TIME_WAIT重用,使用echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse 参数设置为1开启
  2. TIME_WAIT超时时间设计减少,尽快让其超时
  3. 扩大端口的可用范围,ip_local_port_range增大
  4. 增加socket buffer,/etc/sysctl.conf里面设置’net.core.rmem_max = 16777216’

4.服务器cpu占用过高如何去定位,如何发现和判断死锁发生的位置

我们一般都是部署在linux服务器上,如果安装了监控软件的话,直接从监控软件上看

没有的话,我们使用top -c找出CPU占用最多的进程,然后top -H -PID 找出占用最多的线程

然后将线程ID转为16进制之后,jstack PID|grep -A 20 ID 查看其栈堆,对比找出我们具体是什么问题?

典型场景:

  1. 死循环和无限递归
  2. 频繁的GC,我们可以jstat -gc PID 1000查看GC统计
  3. 正则表达式回溯,复杂的正则表达式出现了回溯
  4. 死锁,我们可以jstack PID|grep -A 5 -B “deadlock”直接显示死锁,或者是用jconsole等图形化工具来检测

死锁的场景,数据库事务死锁,查询Mysql日志,DEADLOCK信息。然后在应用层的表现是,大量的线程阻塞在JDBC操作上

分布式死锁,主要是redis分布式锁,查找redis-cli keys “lock“,应用层表现是线程一直在等待锁

5.有一张200W数据量的会员表,每人会员会有长短不的到期时间,现在想在快到期之前发送邮件通知提醒续费该如何实现

我们可以不主动查询,等用户登录到时候,执行会员过期时间的检测,给用户发送邮件提醒。但是这样用户不登录,我们没有办法提醒了就。而且我们要进行全表扫描,效率较低,数据库压力大。但我们可以将他作为补充的手段。

我们可以使用ES等搜索引擎,把会员表里的会员id和会员到期时间存储到搜索引擎里面。然后根据范围查询,查询到即将过期的用户,对他们发送消息。但是这样系统复杂度高了,而且我们还是需要进行定时轮询

可以使用redis,key为用户的id,value为该id的过期时间。然后使用redis的过期提醒功能,监视key的过期事件,检查成功发送邮件提醒。但是这样只能精确到秒,不能提前提醒,而且内存压力较大。

使用MQ延迟队列,计算该用户的过期事件,然后存储到延迟消息队列里面,轮询执行,一道过期事件发送消息。这是我们最佳的方案。

流程:

→ 计算提醒时间点(到期前7天、3天、1天)

→ 投递延迟消息到RocketMQ

→ 消息到期自动消费

→ 验证用户状态后发送邮件

然后加上补偿机制,定时执行任务,只扫描近期(如15天内)即将到期用户,然后查看是否发送,补偿未发送的。重新发送

然后这个过程中需要保持消息幂等,不应该多次发送,查询用户当前会员状态,然后检查到期时间,检查是否发送过,发送之后记录日志。消费失败后自动重试三次,发送失败进入死信队列处理。然后多个途径进行提醒。

RocketMQ作为消息队列,mysql作为主库,redis缓存用户的状态。

6.假设你们系统有一个下单接口,突然并发量暴增,比如从 1k QPS 涨到 10w QPS,你会怎么优化?重点考虑系统的稳定性和响应时间。

第一,限流。我们在网关层加了 Sentinel 做接口限流和熔断,防止系统被打垮。或者是我们使用AOP+bucket令牌桶进行限流

第二,缓存优化。我们使用了本地 + Redis 两级缓存,热点商品信息预加载,避免高并发直接打到数据库。然后防止缓存击穿,使用互斥锁

第三,异步削峰。下单写操作进入 MQ,由后台线程异步落库,极大缓解数据库压力。

第四,数据库层我们做了读写分离,写入走主库,查询走从库,提升吞吐。
最后就只能服务熔断了不行的话。

7.你们系统中 Redis 如果宕机了,会有哪些影响?你们是怎么保证高可用和数据一致性的?

失效的影响:

请求直打数据库:热点接口并发高时,数据库容易被压垮。

响应时间变长:Redis 是内存级别,数据库响应慢,用户体验下降。

缓存穿透:用户请求不存在的数据,Redis 拿不到,数据库压力大。

缓存不一致:应用读了 Redis 旧值,可能会造成业务逻辑问题。

应对策略:

1)缓存高可用部署

  • Redis 使用主从 + Sentinel 实现自动故障转移;
  • 或使用 Redis Cluster,提供分布式高可用能力。

2)加缓冲 + 限流

  • 引入线程池 / 信号量等方式限流;
  • 使用本地缓存(如 Caffeine)做短时间的过渡保护。

3)缓存预热 / 冷数据淘汰

  • 关键数据在启动时预热到 Redis;
  • 利用定时任务刷热点数据;
  • 对非热点数据设置较短 TTL。

4)避免缓存穿透 / 击穿 / 雪崩

  • 穿透:对 null 值做缓存 / 用布隆过滤器拦截;
  • 击穿:加互斥锁 / 单飞请求重建缓存;
  • 雪崩:设置随机 TTL + 限流 + 降级处理。

5)降级与熔断

  • 如果 Redis 和数据库都失败,降级为静态页 / 弹窗提示;
  • 监控超时率,自动熔断部分接口,防止服务雪崩。

热点数据丢失后?AOP的日志自己来恢复

用户体验如何保证?

redis的高可用,主从,哨兵,集群

缓存一致性保证,延迟双删策略

8.假设现在有一个高并发场景,需要统计用户访问次数,您会选择什么数据结构?为什么?

技术选型:

  1. concurrenthashmap+AtomicLong 精确统计,但是占内存较大,不适合大量用户
  2. Redis HyperLogLog,内存占用小,适合大基数去重,有0.81%的误差,无法获取具体的元素

9.您在”生活商店优选”项目中提到了”多级缓存(Caffeine-Redis-MySQL)”和”基于BCP中间件监听binlog实现DB与Redis的对账”。请详细说明:

多级缓存的数据一致性如何保证?

延迟双删+异步补偿

BCP中间件:

CDC方案,使用Canal订阅binglog然后通过MQ发送,缓存更新,允许短暂不一致,保证最终一致

无侵入性:不需要修改业务代码

准确性:只有事务提交后才会写入binlog

完整性:捕获所有数据变更

实时性:近实时同步

10.mysql的分页查询优化

SELECT * FROM products ORDER BY id LIMIT 1000000, 20;

意识到了大偏移量会导致性能问题,

需要——

扫描并排序前1000020条记录

丢弃前1000000条记录

返回后20条记录,然后即使走了索引,也要”数”1000000条记录才能定位到起始位置

我们可以使用子查询来优化,

1
2
3
4
SELECT * FROM products p1 
INNER JOIN (
SELECT id FROM products ORDER BY id LIMIT 1000000, 20
) p2 ON p1.id = p2.id;

或者是使用游标,假设上页最后一个id是1000

1
2
3
SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20;


11.分库分表的查询如何进行

如何定位需要查询的分表?

如何聚合多个分表的结果?

如何处理分页查询?

按用户ID分表(user_id % 16)

可以使用异构索引表来查询,

1
2
3
4
5
6
7
CREATE TABLE product_order_index (
product_id BIGINT,
user_id BIGINT,
order_id BIGINT,
table_suffix INT, -- 记录在哪个分表
INDEX(product_id)
);

或者是引入ES搜索

然后我们如何聚合查询呢?使用归并排序

从每个分表获取足够的数据,每个分表需要取 page * size + size 条数据,然后返回冰柜排序