面试面经-1

面试面经-1
mengnankkzhou他人面经
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 | CLOSED(0), // 初始状态,无连接 |
那我先说一下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分布式锁
非公平锁:
获取锁
- 使用
SET key value NX EX timeout
命令尝试获取锁 - 如果设置成功,说明获取锁成功,返回true
- 如果设置失败,说明锁被占用,可以选择重试或返回失败
- value通常设置为当前线程的唯一标识(如UUID+线程ID)
释放锁
- 使用Lua脚本确保原子性操作
- 先检查锁的value是否是当前线程设置的
- 如果是,则执行DEL命令删除锁
- 如果不是,说明锁已超时被其他线程获取,不能删除
重试机制
- 获取失败后,线程sleep一小段时间再重试
- 重试间隔可以是固定时间或采用指数退避策略
- 设置最大重试次数或超时时间避免无限等待
公平锁:
获取锁
- 检查当前锁状态
- 如果锁未被占用,直接尝试获取
- 如果被占用,进入排队流程
- 加入等待队列
- 将当前线程标识和时间戳加入
queue:resource_name
有序列表 - 使用ZADD命令,以时间戳为分数确保顺序
- 将当前线程标识和时间戳加入
- 检查队列位置
- 获取队列中的第一个元素(最早等待的线程)
- 如果是当前线程,说明轮到自己,尝试获取锁
- 如果不是,继续等待
- 等待通知机制
- 使用Redis的BLPOP或发布订阅机制等待通知
- 或者采用轮询方式,定期检查是否轮到自己
- 获取锁的尝试
- 当轮到自己时,使用SET NX命令尝试获取锁
- 获取成功后,从等待队列中移除自己
- 获取失败说明锁被其他线程抢占,重新进入等待
释放锁
- 释放锁资源
- 使用Lua脚本原子性地检查并删除锁
- 通知下一个等待者
- 从等待队列中获取下一个等待的线程
- 向该线程发送通知信号
- 可以使用Redis的发布订阅或设置特定key来通知
- 清理过期等待者
- 定期清理队列中的过期等待线程
- 避免队列无限增长
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
单个优化策略:
- 动态扩容,创建新的过滤器,容量翻倍,误判率减半,然后只要有一个过滤器返回true就可以
- 增加位数组大小,相同的元素数量下,位数组越多,误判率越小。
- Counting Bloom Filter,使用计数器代替位数组,成功添加之后,计数器+1,避免单一位冲突,可以支持删除操作
- 分层布隆过滤器,元素依次通过多层过滤器,在第一个返回的层添加其元素。然后只有所有的层都返回true才表明存在
关键特性:
如何判断没有一定没有?
- 如果元素真的被添加过,它的所有哈希位置都必须为1,发现任何一个位置为0,说明元素绝对没有被添加过,所有位置都为1时,可能是其他元素设置的(假阳性)
然后实际的参数的设计,100万商品的话,误判率设为0.01
然后位数组大小 ≈ 9,585,058*,然后单击场景我们就放在JVM内存中,分布式的话就存在redis里面
14.ThreadLocal的底层实现
Threalocal涉及两个组件,一个是他对象本身负责set和get
然后一个ThreadLocalMap负责数据的存储,每一个线程都持有一个ThreadLocalMap他就是副本。是线程隔离的,不会有并发的线程不安全问题
ThreadLocalMap是Thread下面的一个内部类,使用了自定义的散列表来存储键值对,其键是 ThreadLocal
实例对象(弱引用),值是线程本地变量的值(强引用)。
然后ThreadLocalMap
的 Entry
是用 WeakReference
来存储 ThreadLocal
对象实例,这样可以避免 ThreadLocal
对象不会因为强引用而无法被垃圾回收。但需要注意的是,value
是一个强引用,如果 ThreadLocal
没有正确清理,就可能导致内存泄漏的问题。然后我们如果想回收内存的话,需要显示的调用remove方法移除。或者是查找当前线程关联的map,将其键值对分别设为当前线程和null
Threadlocal可以用于线程不安全类的线程安全封装,典型场景:SimpleDateFormat
。SimpleDateFormat
是一个线程不安全的类,可以为每个线程提供一个独立的实例,避免竞争。
在数据库连接的时候应用,通常为每个线程(每个请求)独立创建一个数据库连接,使用 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的时候不仅是要去比较变量的值,还要去比较版本号或者时间戳
或者是使用AtomicStampedReference
,AtomicStampedReference
类维护了一个变量值和一个 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)进行排序,通常用于排行榜等场景。
他的底层实现是两种数据结构
压缩列表(ziplist): 在元素数量较少且元素成员(member)较短时使用。 压缩列表的特点是内存占用小,但插入、删除操作的效率较低,因为它需要进行连续的内存移动。
- 结构: 压缩列表是一种特殊的”连续内存块”构成的数据结构,类似于数组,但可以存储不同长度的数据。 它由多个entry组成,每个entry保存ZSet中的一个元素(member-score)。
- 缺点:当压缩列表中某个entry的长度发生变化时,可能会导致后续entry的offset也需要更新,如果更新的entry数量较多,就会导致连锁更新,影响性能。
跳表(skiplist) + 字典(dict): 在元素数量较多或元素成员较长时使用。 跳表可以提供较高的插入、删除、查找效率,但会占用更多的内存空间。 字典则用于存储member到score的映射,使得可以通过member快速查找score。
- 跳表的定义: 跳跃表(skiplist)是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。 跳表是一种概率型数据结构,它通过随机算法来决定每个节点拥有多少个指针。 跳表是一种可以实现平均O(log N) 查找,插入,删除的有序数据结构。
- 跳表的结构: 跳表由多层链表组成,每一层链表都包含所有的元素,但元素之间的连接方式不同。 最底层是包含所有元素的有序链表,而上层链表则以一定的概率包含下层链表的元素,从而构成一种类似于索引的结构。 节点会包含一个后退指针(backward pointer),指向位于前一个节点。 在跳表中,节点按照它们的分值大小进行排序。
- 字典的定义: 字典,也称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。
满足这两个条件就会使用跳表
- 元素数量超过
zset_max_ziplist_entries
配置的值(默认128): 这个配置限制了使用压缩列表存储的元素数量。 - 集合中任一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后关闭。然后服务器出现说明是服务器主动断开的连接
比如:
- 并发Web服务:比如电商大促期间,Nginx反向代理服务器向后端应用服务器发起大量短连接请求,每次请求完成后主动关闭连接,导致Nginx服务器积累大量TIME_WAIT状态。
- 微服务架构:服务A调用服务B的REST API,使用HTTP短连接方式,每次调用后都关闭连接。在高QPS场景下,比如订单服务调用库存服务,会产生大量TIME_WAIT。
- 数据库连接池配置不当:应用服务器连接MySQL时,如果没有使用连接池或连接池配置过小,频繁创建和关闭数据库连接,会在应用服务器上产生大量TIME_WAIT。
- 监控和健康检查:负载均衡器(如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
系统层:
- 开启TIME_WAIT重用,使用echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse 参数设置为1开启
- TIME_WAIT超时时间设计减少,尽快让其超时
- 扩大端口的可用范围,ip_local_port_range增大
- 增加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 查看其栈堆,对比找出我们具体是什么问题?
典型场景:
- 死循环和无限递归
- 频繁的GC,我们可以jstat -gc PID 1000查看GC统计
- 正则表达式回溯,复杂的正则表达式出现了回溯
- 死锁,我们可以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.假设现在有一个高并发场景,需要统计用户访问次数,您会选择什么数据结构?为什么?
技术选型:
- concurrenthashmap+AtomicLong 精确统计,但是占内存较大,不适合大量用户
- 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 | SELECT * FROM products p1 |
或者是使用游标,假设上页最后一个id是1000
1 | SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20; |
11.分库分表的查询如何进行
如何定位需要查询的分表?
如何聚合多个分表的结果?
如何处理分页查询?
按用户ID分表(user_id % 16)
可以使用异构索引表来查询,
1 | CREATE TABLE product_order_index ( |
或者是引入ES搜索
然后我们如何聚合查询呢?使用归并排序
从每个分表获取足够的数据,每个分表需要取 page * size + size 条数据,然后返回冰柜排序