快手支付一面&二面
一面考点深度解析
1. 建立索引的原则有哪些
考点定位:MySQL 索引设计与优化,考察候选人对数据库索引的理解深度和实践经验。
核心原则
哪些列适合建索引:
- WHERE 子句中的列:频繁作为查询条件的字段优先建索引
- JOIN 关联列:外键、关联字段必须建索引,否则 JOIN 会全表扫描
- ORDER BY / GROUP BY 列:避免 filesort 和临时表
- 高选择性(高区分度)列:如用户 ID、订单号等区分度高的字段
- 覆盖索引:如果查询列都在索引中(覆盖索引),无需回表,性能最高
哪些情况不适合建索引:
- 表数据量很小(< 几千行)—— 全表扫描可能更快
- 频繁更新的列 —— 索引维护成本高
- 区分度极低的列(如性别只有男/女)—— 索引效果差
- WHERE 条件中使用了函数或运算 —— 索引失效
WHERE col LIKE '%xxx'前导模糊 —— 索引失效
联合索引(复合索引)原则:
- 最左前缀原则:
(a, b, c)相当于建了(a)、(a,b)、(a,b,c)三个索引 - 区分度高的列放最左边
- 把查询频率高的列尽量靠左
加分回答:
- 使用
EXPLAIN分析执行计划,关注type、key、rows、Extra字段 - 索引下推(ICP,MySQL 5.6+):将 WHERE 条件下推到存储引擎层过滤
- MRR(Multi-Range Read):减少随机 IO
- 了解索引的代价:占用磁盘空间、降低写性能
2. 介绍一个最熟悉的集合
考点定位:Java 集合框架,通常选 HashMap 来展示深度。
HashMap 详解(JDK 8)
底层数据结构:数组 + 链表 + 红黑树
核心参数:
- 默认初始容量:16(必须是 2 的幂)
- 默认负载因子:0.75
- 链表转红黑树阈值:8(链表长度 >= 8 且数组长度 >= 64)
- 红黑树退化为链表阈值:6
put 流程(面试重点):
- 计算 key 的 hash 值:
(h = key.hashCode()) ^ (h >>> 16)—— 高 16 位参与异或,降低碰撞 - 判断 table 是否为空,空则 resize
- 通过
(n-1) & hash定位桶位置 - 桶为空 → 直接插入
- 桶非空 → 判断是链表还是红黑树,遍历查找 key 是否已存在
- 存在则更新 value;不存在则尾插(JDK 8 尾插,JDK 7 头插)
- 插入后判断 size > threshold,是则 resize 扩容
为什么容量必须是 2 的幂?
(n-1) & hash等价于hash % n,位运算更快- 扩容时只需判断 hash 的新增 bit 是 0 还是 1,均匀分布
JDK 7 vs JDK 8 关键区别:
- JDK 7:数组 + 链表,头插法,扩容时可能产生死循环(并发场景)
- JDK 8:数组 + 链表 + 红黑树,尾插法,解决死循环问题
线程安全问题:
- HashMap 非线程安全,并发环境使用 ConcurrentHashMap
- ConcurrentHashMap:JDK 7 分段锁(Segment),JDK 8 CAS + synchronized
为什么负载因子是 0.75?
- 时间和空间的折中
- 太高 → 碰撞概率大,查询慢
- 太低 → 频繁扩容,浪费空间
- 0.75 是泊松分布计算出的最优值
3. 缓存淘汰策略
考点定位:考察对缓存系统的理解,LRU 是最高频考点。
常见淘汰策略
|
策略 |
原理 |
优缺点 |
|---|---|---|
|
LRU |
最近最少使用 |
最常用,双链表+HashMap O(1) |
|
LFU |
最不经常使用 |
适合热点数据,但实现复杂 |
|
FIFO |
先进先出 |
简单但可能淘汰热点 |
|
TTL |
过期时间 |
适合有时效性的数据 |
|
Two-Queue |
两次访问才进缓存 |
解决偶发访问污染 |
LRU 的实现(高频手撕)
使用 HashMap + 双向链表:
- HashMap:存储 key → Node 的映射,O(1) 查找
- 双向链表:维护访问顺序,头部是最近使用的,尾部是最久未使用
- get(key):找到节点,移到链表头部
- put(key, val):如果存在则更新并移到头部;不存在则插入头部;容量满则删除尾部
class LRUCache {
class Node { int key, val; Node prev, next; }
Map<Integer, Node> map;
Node head, tail;
int capacity;
// get: map.get → moveToHead
// put: 更新 or 新增 + evict if full
// 关键操作:addToHead, removeNode, removeTail
}
进阶考点:
- Redis 的近似 LRU(随机采样 + 淘汰最久未访问的)
- Redis 的 LFU(访问计数 + 时间衰减)
- MySQL Buffer Pool 的改进 LRU(年轻代 + 老年代,防止全表扫描污染)
4. JVM 创建一个对象的内存分配流程 & 生命周期
考点定位:JVM 内存模型,对象创建全流程。
对象创建的完整流程
- 类加载检查:遇到 new 指令 → 检查类是否加载、解析、初始化
- 分配内存:
- 指针碰撞(Bump the Pointer):规整内存,Serial / ParNew 等带 Compact 的收集器
- 空闲列表(Free List):不规整内存,CMS 等基于清除的收集器
- 并发安全:CAS + 失败重试;或 TLAB(Thread Local Allocation Buffer)
- 初始化零值:所有字段初始化为零值(保证不赋初值也能用)
- 设置对象头:Mark Word(HashCode、GC 分代年龄、锁信息)+ Klass Pointer
- 执行
<init>方法:构造器代码块、成员变量赋值
内存分配策略(分配到哪里?)
栈分配(逃逸分析) → TLAB(Eden 内线程私有) → Eden 区 → 老年代
优先顺序:
- 栈上分配:逃逸分析证明对象不会逃逸 → 栈上分配(无需 GC)
- TLAB 分配:每个线程在 Eden 区有私有缓冲区,无锁高效分配
- Eden 区分配:TLAB 不足时直接在 Eden 区分配
- 老年代分配:大对象直接进老年代
对象生命周期
创建 → 强可达(使用中) → 不可达 → GC 标记 → 回收
↓
软引用:内存不足时回收
弱引用:下次 GC 回收
虚引用:回收时收到通知
面试技巧:画图辅助说明 —— 栈 → 堆 → 方法区的关系,展示对象引用的流向。
5. 堆的区域划分
考点定位:JVM 内存结构基础,必考。
堆的内存结构(JDK 8)
Java Heap
├── 新生代(Young Generation)- 占堆 1/3(默认)
│ ├── Eden 区(80%)- 新对象分配区
│ ├── Survivor 0 / From 区(10%)
│ └── Survivor 1 / To 区(10%)
└── 老年代(Old Generation)- 占堆 2/3(默认)
方法区(非堆,本地内存)
└── MetaSpace(JDK 8,替代 PermGen)- 存放类元信息、常量、静态变量
关键参数:
|
参数 |
说明 |
|---|---|
|
|
堆初始/最大大小 |
|
|
新生代大小 |
|
|
Eden/Survivor 比例,默认 8 |
|
|
老年代/新生代比例,默认 2 |
|
|
MetaSpace 初始/最大 |
为什么需要 Survivor 区?
如果只有 Eden + 老年代:
- Eden 满了 → Minor GC → 存活对象直接进老年代
- 导致老年代快速填满 → Full GC 频繁
有了 Survivor:
- 对象经历多次 Minor GC 后才晋升老年代(默认 15 次)
- 过滤掉"朝生夕死"的对象,保证老年代存的是长期存活对象
6 & 7. 什么对象 / 多大的对象直接进老年代
考点定位:JVM 调优经验,大对象分配策略。
什么对象直接进老年代?
1. 大对象(超过阈值)
- 通过
-XX:PretenureSizeThreshold设置(默认 0,即不启用) - 注意:仅对 Serial 和 ParNew 收集器有效,G1 不支持
2. 长期存活的对象
- 经过
-XX:MaxTenuringThreshold次 Minor GC(默认 15,CMS 默认 6)
3. 动态年龄判断
- Survivor 区中,相同年龄所有对象大小总和 > Survivor 空间的一半
- 年龄 >= 该年龄的对象直接晋升老年代
4. Survivor 空间分配担保失败
- Minor GC 时 Survivor 放不下 → 直接进老年代
多大的对象算"大对象"?
-XX:PretenureSizeThreshold:通常设置为 3MB~10MB- G1 收集器:对象大小 > Region 大小的一半(即 > 50% Region) 视为大对象(Humongous Object)
- G1 中 Humongous Object 直接分配到 Humongous Region(一组连续 Region)
- 大对象 = 长生命周期数组、大字符串 Buffer、大文件内容
面试加分回答:
- 大对象对 GC 的影响:增加拷贝成本、容易引发 Full GC
- 编程中应避免大对象的频繁创建
- G1 中 Humongous Object 可能引发并发周期提前触发
8 & 9. 死锁的四个条件 + MySQL 死锁
考点定位:操作系统经典问题 + 数据库实战场景。
死锁的四个必要条件(缺一不可)
|
条件 |
说明 |
破坏方法 |
|---|---|---|
|
互斥 |
资源一次只能被一个线程占用 |
无法破坏(锁的本质) |
|
占有并等待 |
持有资源同时等待其他资源 |
一次性申请所有资源 |
|
不可剥夺 |
持有的资源不能被强制释放 |
允许超时释放 |
|
循环等待 |
存在循环等待链 |
按统一顺序加锁 |
MySQL 产生死锁的例子
经典场景:两个事务以不同顺序更新相同行
-- 场景:表 t(id, name),id 为主键,当前数据 id=1, id=2
-- 事务A -- 事务B
BEGIN; BEGIN;
UPDATE t SET name='a'
WHERE id = 1; UPDATE t SET name='b'
WHERE id = 2;
-- 持有 id=1 的 X 锁 -- 持有 id=2 的 X 锁
UPDATE t SET name='a2'
WHERE id = 2; UPDATE t SET name='b2'
-- 等待 id=2 的锁 WHERE id = 1;
-- 等待 id=1 的锁
-- 💀 死锁!
死锁 SQL 设计思路:
- 找到两个有交集的行(id=1 和 id=2)
- 事务 A 先锁 id=1 再锁 id=2
- 事务 B 先锁 id=2 再锁 id=1
- 循环等待 → 死锁
更多 MySQL 死锁场景:
- INSERT ... ON DUPLICATE KEY UPDATE 与间隙锁
- 共享锁升级为排他锁
- 辅助索引 + 聚簇索引的双重锁定顺序
MySQL 如何检测和解决死锁:
- InnoDB 自动检测死锁(等待图检测)
- 选择回滚代价最小的事务
SHOW ENGINE INNODB STATUS查看最近死锁信息- 设置
innodb_lock_wait_timeout超时时间
避免死锁的方法:
- 所有事务以相同顺序访问表和行
- 尽量缩短事务,减少锁持有时间
- 合理使用索引(避免不必要的锁范围扩大)
- 使用乐观锁代替悲观锁
- 降低隔离级别(RR → RC)
10. 手撕:LRU 缓存 + HashMap
考点定位:数据结构设计 + 编码基本功。
LRU 缓存核心思路
class LRUCache {
// 双向链表节点
class DLinkedNode {
int key, value;
DLinkedNode prev, next;
}
private Map<Integer, DLinkedNode> cache;
private int capacity;
private DLinkedNode head, tail; // 哨兵节点
public LRUCache(int capacity) {
this.capacity = capacity;
cache = new HashMap<>();
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) return -1;
moveToHead(node); // 移到链表头部(最近使用)
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node == null) {
DLinkedNode newNode = new DLinkedNode();
newNode.key = key; newNode.value = value;
cache.put(key, newNode);
addToHead(newNode);
if (cache.size() > capacity) {
DLinkedNode tail = removeTail();
cache.remove(tail.key);
}
} else {
node.value = value;
moveToHead(node);
}
}
// 封装四个基本操作:addToHead, removeNode, moveToHead, removeTail
// 必须画图理解指针的操作顺序!
}
HashMap 手撕要点
// 核心方法:put, get, resize
// JDK 8 风格实现要点:
// 1. Node<K,V>[] table 存储
// 2. hash = (h = key.hashCode()) ^ (h >>> 16)
// 3. (n-1) & hash 定位桶
// 4. 链表 + 红黑树(可简化为仅链表)
// 5. resize:容量翻倍,旧链表拆分为高低两条
面试注意点:
- 先说明数据结构选型,再说复杂度(O(1))
- 双向链表操作画图说明
- HashMap 的 hash 函数和扩容机制是加分点
二面考点深度解析
1 & 2 & 3. SELECT ... FOR UPDATE 深度分析
考点定位:MySQL InnoDB 行锁、间隙锁、临键锁,高级面试必考。
看到这条 SQL 能想到什么?
SELECT * FROM t WHERE id = 1 FOR UPDATE;
逐层分析:
第一层:SQL 语义
FOR UPDATE= 悲观锁,对查询到的行加排他锁(X 锁)- 在
READ COMMITTED(RC)和REPEATABLE READ(RR)下行为不同
第二层:锁什么?(取决于索引)
- id 是唯一索引/主键,且记录存在 → 只锁 id=1 这一行(Record Lock)
- id 是普通索引,且记录存在 → 锁 id=1 的索引记录 + 对应主键记录 + 前后间隙
- id 是普通索引,记录不存在 → 间隙锁(Gap Lock)
- id 没有索引 → 全表扫描,所有行加锁(实际上加 Next-Key Lock 覆盖所有间隙)
第三层:事务隔离级别的影响
- RR(默认):Next-Key Lock = Record Lock + Gap Lock,避免幻读
- RC:只加 Record Lock,不加 Gap Lock(存在幻读风险)
第四层:性能考量
- 事务中尽早加锁,缩小锁范围
- 避免长事务,减少锁等待
- 查看锁等待:
SELECT * FROM performance_schema.data_locks
如果没有 id=1 这条记录,会锁什么?
核心考点:间隙锁(Gap Lock)
假设表中现有记录 id = 0, 5, 10(主键索引)
-- 事务 A
BEGIN;
SELECT * FROM t WHERE id = 1 FOR UPDATE;
-- id=1 不存在 → 加间隙锁,锁住 (0, 5) 这个区间
间隙锁的行为:
|
操作 |
结果 |
原因 |
|---|---|---|
|
插入 id=1 |
阻塞 |
间隙锁禁止在 (0, 5) 区间插入 |
|
插入 id=2 |
阻塞 |
同上,任何在 (0, 5) 区间的插入都被阻止 |
|
插入 id=6 |
成功 |
id=6 在 (5, 10) 区间,不在锁范围内 |
|
插入 id=-1 |
成功 |
在 (-∞, 0) 区间,不在锁范围内 |
|
更新 id=0 |
成功 |
间隙锁不阻止对已有记录的更新 |
插入 id=1 和 id=2 都失败的原因是:InnoDB 在 RR 隔离级别下使用 Next-Key Lock,对不存在的记录会加上 Gap Lock,锁定该记录应该在的那个区间。
如果隔离级别是 RC(Read Committed):
- 不存在记录 → 不加锁
- id=1 和 id=2 都可以插入成功
- 但会出现幻读
锁怎么记录的?存在哪里?
实现层面:
- InnoDB 的锁信息存在内存(Buffer Pool)中
- 锁数据结构:
lock_t结构体,包含type_mode、heap_no、index、事务指针等 - 每个锁对应一个
lock_t对象,通过位图(bitmap)记录锁定了哪些行
查看锁信息:
- MySQL 8.0:
performance_schema.data_locks(替代 INFORMATION_SCHEMA.INNODB_LOCKS) SHOW ENGINE INNODB STATUS查看事务与锁等待performance_schema.data_lock_waits查看锁等待关系
锁的存储层级:
表空间 → 页(Page)→ 行(Row)
↑
lock_t 结构体挂在 buf_page_t 上
通过 bitmap 标记该页中哪些行被锁定
4. B+ 树的优点
考点定位:数据库索引底层数据结构,必须掌握。
B+ 树 vs B 树
|
特性 |
B 树 |
B+ 树 |
|---|---|---|
|
数据存储 |
所有节点都存数据 |
只有叶子节点存数据 |
|
非叶子节点 |
存 key + data |
只存 key(更"瘦") |
|
叶子节点 |
各自独立 |
双向链表连接 |
|
范围查询 |
需要中序遍历 |
叶子遍历即可,O(范围大小) |
|
树高度 |
相对较高 |
更矮(非叶节点能存更多 key) |
B+ 树的核心优势
- 更低的高度,更少的 IO
- 非叶子节点不含 data,一个节点可以存更多 key
- 同样的数据量,B+ 树层数更少
- 每次磁盘 IO 读一页,层数少 = IO 次数少
- 稳定的查询效率
- 所有数据都在叶子节点 → 每次查询都要走到叶节点
- B 树可能在非叶节点就命中 → 最快一次 IO,最慢多次 IO
- 高效的范围查询
- 叶子节点形成有序双向链表
SELECT * FROM t WHERE id BETWEEN 10 AND 100→ 找到 10 后顺序遍历即可- B 树需要反复回到上层遍历
- 适合磁盘预读
- 操作系统页大小(4K/8K/16K)与 B+ 树节点大小匹配
- 一次 IO 读入整个节点,利用局部性原理
InnoDB B+ 树的实践
- 主键索引(聚簇索引):叶子节点存完整行数据
- 二级索引:叶子节点存主键值 → 回表
- 页大小:默认 16KB
- 一棵 3 层 B+ 树大约能存 2000 万行数据
5. 手撕 B+ 树节点结构
考点定位:数据结构理解深度,能否写出关键结构。
非叶子节点(Internal Node)
class InternalNode:
def __init__(self, order): # order = 阶数,最大孩子数
self.keys = [] # 键值数组 (n 个)
self.children = [] # 子节点指针数组 (n+1 个)
# m 阶 B+ 树:
# - ceil(m/2) <= 孩子数 <= m(根节点除外)
# - 孩子数 = keys数 + 1
叶子节点(Leaf Node)
class LeafNode:
def __init__(self, order):
self.keys = [] # 键值数组
self.values = [] # 数据指针(聚簇索引存完整行,二级索引存主键)
self.next = None # 指向下一个叶子节点(双向链表)
self.prev = None # 指向前一个叶子节点
B+ 树的关键操作
查找:
- 从根节点开始,二分查找 keys 确定去哪个孩子
- 一直到叶子节点,二分查找目标 key
插入(分裂):
- 找到对应叶子节点插入
- 如果叶子节点满了 → 分裂成两个节点,中间 key 提升到父节点
- 父节点可能继续分裂 → 递归到根
删除(合并):
- 找到并删除
- 如果节点 key 数 < ceil(m/2) → 从兄弟借,或合并
- 可能递归调整
6. 线程池有几种状态
考点定位:Java 并发编程,ThreadPoolExecutor 源码。
五种状态(用 int 高 3 位表示)
|
状态 |
值 |
说明 |
|---|---|---|
|
RUNNING |
111 |
接受新任务,处理队列中任务 |
|
SHUTDOWN |
000 |
不接受新任务,处理队列中剩余任务 |
|
STOP |
001 |
不接受新任务,不处理队列中任务,中断正在执行的任务 |
|
TIDYING |
010 |
所有任务已终止,workerCount=0,即将执行 terminated() |
|
TERMINATED |
011 |
terminated() 执行完毕 |
状态转换
RUNNING → SHUTDOWN:调用 shutdown()
RUNNING → STOP: 调用 shutdownNow()
SHUTDOWN → TIDYING:队列和线程都为空
STOP → TIDYING: 线程池为空
TIDYING → TERMINATED:terminated() 执行完毕
线程池核心参数
ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 空闲线程存活时间
TimeUnit unit,
BlockingQueue<Runnable> workQueue, // 阻塞队列
ThreadFactory threadFactory,
RejectedExecutionHandler handler // 拒绝策略
)
任务执行流程
提交任务 → 核心线程有空闲?
↓ 是 → 核心线程执行
↓ 否 → 队列已满?
↓ 否 → 入队等待
↓ 是 → 线程数 < max?
↓ 是 → 创建新线程执行
↓ 否 → 执行拒绝策略
四种拒绝策略:
- AbortPolicy(默认):抛出异常
- CallerRunsPolicy:由提交任务的线程执行
- DiscardPolicy:直接丢弃
- DiscardOldestPolicy:丢弃队头任务
7. 最大线程参数的意义(阻塞队列无限长时)
这是一道经典的"追问"题,考察对线程池机制的深层理解。
问题分析
如果使用 无界队列(如 new LinkedBlockingQueue<>() 不带参数,默认 Integer.MAX_VALUE):
new ThreadPoolExecutor(
5, // corePoolSize
10, // maximumPoolSize ← 这个参数是不是没用了?
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>() // 无界队列
);
答案:这种情况下 maximumPoolSize 确实"形同虚设"!
为什么?
因为线程池的执行逻辑是:
核心线程数满 → 入队列 → 队列满 → 创建非核心线程 → 达到 max → 拒绝
↑
无界队列永远不会满!
永远不会走到"创建非核心线程"这步
所以线程池最多只有 corePoolSize 个线程在运行,多余的线程永远不会被创建。
maximumPoolSize 的意义(什么时候有效)
- 使用有界队列时(如
ArrayBlockingQueue(100),LinkedBlockingQueue(100))- 队列满了 → 创建非核心线程(最多到 max - core)→ 这才是 max 发挥作用的时候
- 使用 SynchronousQueue 时
- SynchronousQueue 容量为 0,不能存储,必须立即移交
- 每个任务都要线程来执行,max 直接决定了并发上限
- 实际意义:
- maxPoolSize 配合有界队列使用是对系统资源的兜底保护
- 相当于:先尽量排队,排队排不下了才加线程应急
- 核心线程 = 常态化处理能力,max = 峰值处理能力
最佳实践
// 推荐:使用有界队列
new ThreadPoolExecutor(
coreSize, maxSize,
keepAlive, unit,
new ArrayBlockingQueue<>(capacity), // 有界队列
new ThreadPoolExecutor.CallerRunsPolicy() // 让提交者线程帮忙执行
);
|
队列类型 |
corePoolSize 满后 |
max 有作用? |
|---|---|---|
|
无界队列 |
直接入队 |
否 |
|
有界队列 |
入队到满 → 创建线程 |
是 |
|
SynchronousQueue |
直接创建线程 |
是(决定并发上限) |
8. 手撕 Java 常见锁
考点定位:Java 并发编程全貌,锁体系。
锁的分类体系
Java 锁
├── synchronized(隐式锁 / 内置锁)
│ ├── 对象锁(实例方法 / synchronized(this))
│ ├── 类锁(静态方法 / synchronized(Xxx.class))
│ └── 锁升级:偏向锁 → 轻量级锁 → 重量级锁
│
├── Lock 接口(显式锁,java.util.concurrent.locks)
│ ├── ReentrantLock(可重入互斥锁)
│ │ ├── 公平锁 / 非公平锁
│ │ ├── 可中断获取锁 lockInterruptibly()
│ │ └── 支持 Condition 多条件等待
│ ├── ReentrantReadWriteLock(读写锁)
│ │ ├── 读锁(共享锁):多线程可同时读
│ │ └── 写锁(排他锁):写时互斥
│ └── StampedLock(JDK 8,乐观读)
│
├── 工具类
│ ├── CountDownLatch(倒计数门闩)
│ ├── CyclicBarrier(可循环屏障)
│ ├── Semaphore(信号量,限流)
│ └── Phaser(JDK 7,多阶段栅栏)
│
└── 无锁 / CAS
└── AtomicInteger 等(基于 Unsafe.compareAndSwap)
synchronized vs ReentrantLock
|
特性 |
synchronized |
ReentrantLock |
|---|---|---|
|
实现 |
JVM 内置 |
JDK 实现(AQS) |
|
释放 |
自动释放 |
必须 finally unlock |
|
中断 |
不支持 |
lockInterruptibly() |
|
公平锁 |
非公平 |
可选公平/非公平 |
|
条件 |
单一 wait/notify |
多个 Condition |
|
尝试获取 |
不支持 |
tryLock(超时) |
|
性能 |
JDK 6 后大幅优化,接近 |
略高但差异已很小 |
synchronized 锁升级过程
无锁 → 偏向锁(一个线程多次进入)→ 出现竞争
↓
轻量级锁(CAS 自旋)→ 自旋失败或竞争激烈
↓
重量级锁(mutex,OS 内核态)
面试技巧:
- 先画出锁分类的思维导图
- 重点对比 synchronized 和 ReentrantLock
- 提到 AQS(AbstractQueuedSynchronizer)表示深入理解
- 手写可重入锁的实现可以展示对 AQS 的理解
手撕示例:简单可重入锁
class MyReentrantLock {
private Thread owner;
private int count;
public synchronized void lock() {
Thread current = Thread.currentThread();
if (owner == current) {
count++; // 重入
return;
}
while (owner != null) {
wait();
}
owner = current;
count = 1;
}
public synchronized void unlock() {
if (owner != Thread.currentThread()) return;
if (--count == 0) {
owner = null;
notify();
}
}
}
面经总结与备考建议
一面考点分布
|
领域 |
考点 |
难度 |
|---|---|---|
|
MySQL |
索引原则 |
⭐⭐ |
|
Java 基础 |
集合框架(HashMap) |
⭐⭐ |
|
系统设计 |
缓存淘汰(LRU) |
⭐⭐⭐ |
|
JVM |
对象分配、堆结构、老年代 |
⭐⭐⭐ |
|
操作系统 |
死锁条件 |
⭐⭐ |
|
MySQL |
死锁实战 |
⭐⭐⭐ |
|
编码 |
LRU + HashMap 手撕 |
⭐⭐⭐ |
二面考点分布
|
领域 |
考点 |
难度 |
|---|---|---|
|
MySQL |
FOR UPDATE 深度分析 |
⭐⭐⭐⭐ |
|
MySQL |
间隙锁、锁存储机制 |
⭐⭐⭐⭐ |
|
数据结构 |
B+ 树原理与手撕 |
⭐⭐⭐⭐ |
|
Java 并发 |
线程池状态与参数 |
⭐⭐⭐ |
|
Java 并发 |
锁体系手撕 |
⭐⭐⭐ |
快手支付面试特点
- 重 MySQL:一面、二面都有大量 MySQL 深入问题,尤其是锁机制
- 重 JVM:一面重点考察堆内存、对象分配
- 重手撕代码:一面 LRU + HashMap,二面 B+ 树 + 锁
- 追问深:从一个 SQL 语句能追问 3-4 层(FOR UPDATE → 间隙锁 → 锁存储 → B+树)
- 项目经验:二面有屏幕共享看项目,要准备好项目中的技术亮点
推荐备考资料
- 《高性能 MySQL》(索引、锁章节)
- 《深入理解 Java 虚拟机》(内存与 GC 章节)
- 《Java 并发编程的艺术》(锁和线程池章节)
- LeetCode:LRU(146)、设计 HashMap(706)
- MySQL 官方文档:InnoDB Locking 章节
- 动手画 B+ 树插入/删除过程