跳转到内容

快手支付一面&二面

一面考点深度解析

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 分析执行计划,关注 typekeyrowsExtra 字段
  • 索引下推(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 流程(面试重点):

  1. 计算 key 的 hash 值:(h = key.hashCode()) ^ (h >>> 16) —— 高 16 位参与异或,降低碰撞
  2. 判断 table 是否为空,空则 resize
  3. 通过 (n-1) & hash 定位桶位置
  4. 桶为空 → 直接插入
  5. 桶非空 → 判断是链表还是红黑树,遍历查找 key 是否已存在
  6. 存在则更新 value;不存在则尾插(JDK 8 尾插,JDK 7 头插)
  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 内存模型,对象创建全流程。

对象创建的完整流程

  1. 类加载检查:遇到 new 指令 → 检查类是否加载、解析、初始化
  2. 分配内存
    • 指针碰撞(Bump the Pointer):规整内存,Serial / ParNew 等带 Compact 的收集器
    • 空闲列表(Free List):不规整内存,CMS 等基于清除的收集器
    • 并发安全:CAS + 失败重试;或 TLAB(Thread Local Allocation Buffer)
  3. 初始化零值:所有字段初始化为零值(保证不赋初值也能用)
  4. 设置对象头:Mark Word(HashCode、GC 分代年龄、锁信息)+ Klass Pointer
  5. 执行 &lt;init&gt; 方法:构造器代码块、成员变量赋值

内存分配策略(分配到哪里?)

栈分配(逃逸分析) → TLAB(Eden 内线程私有) → Eden 区 → 老年代

优先顺序:

  1. 栈上分配:逃逸分析证明对象不会逃逸 → 栈上分配(无需 GC)
  2. TLAB 分配:每个线程在 Eden 区有私有缓冲区,无锁高效分配
  3. Eden 区分配:TLAB 不足时直接在 Eden 区分配
  4. 老年代分配:大对象直接进老年代

对象生命周期

创建 → 强可达(使用中) → 不可达 → 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)- 存放类元信息、常量、静态变量

关键参数:

参数

说明

-Xms / -Xmx

堆初始/最大大小

-Xmn

新生代大小

-XX:SurvivorRatio

Eden/Survivor 比例,默认 8

-XX:NewRatio

老年代/新生代比例,默认 2

-XX:MetaspaceSize / -XX:MaxMetaspaceSize

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 区中,相同年龄所有对象大小总和 &gt; Survivor 空间的一半
  • 年龄 &gt;= 该年龄的对象直接晋升老年代

4. Survivor 空间分配担保失败

  • Minor GC 时 Survivor 放不下 → 直接进老年代

多大的对象算"大对象"?

  • -XX:PretenureSizeThreshold:通常设置为 3MB~10MB
  • G1 收集器:对象大小 &gt; Region 大小的一半(即 &gt; 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 设计思路:

  1. 找到两个有交集的行(id=1 和 id=2)
  2. 事务 A 先锁 id=1 再锁 id=2
  3. 事务 B 先锁 id=2 再锁 id=1
  4. 循环等待 → 死锁

更多 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_modeheap_noindex、事务指针等
  • 每个锁对应一个 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+ 树的核心优势

  1. 更低的高度,更少的 IO
    • 非叶子节点不含 data,一个节点可以存更多 key
    • 同样的数据量,B+ 树层数更少
    • 每次磁盘 IO 读一页,层数少 = IO 次数少
  2. 稳定的查询效率
    • 所有数据都在叶子节点 → 每次查询都要走到叶节点
    • B 树可能在非叶节点就命中 → 最快一次 IO,最慢多次 IO
  3. 高效的范围查询
    • 叶子节点形成有序双向链表
    • SELECT * FROM t WHERE id BETWEEN 10 AND 100 → 找到 10 后顺序遍历即可
    • B 树需要反复回到上层遍历
  4. 适合磁盘预读
    • 操作系统页大小(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 数 &lt; 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&lt;&gt;() 不带参数,默认 Integer.MAX_VALUE):

new ThreadPoolExecutor(
    5,    // corePoolSize
    10,   // maximumPoolSize  ← 这个参数是不是没用了?
    60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>()  // 无界队列
);

答案:这种情况下 maximumPoolSize 确实"形同虚设"!

为什么?

因为线程池的执行逻辑是:

核心线程数满 → 入队列 → 队列满 → 创建非核心线程 → 达到 max → 拒绝
                     ↑
              无界队列永远不会满!
              永远不会走到"创建非核心线程"这步

所以线程池最多只有 corePoolSize 个线程在运行,多余的线程永远不会被创建。

maximumPoolSize 的意义(什么时候有效)

  1. 使用有界队列时(如 ArrayBlockingQueue(100), LinkedBlockingQueue(100)
    • 队列满了 → 创建非核心线程(最多到 max - core)→ 这才是 max 发挥作用的时候
  2. 使用 SynchronousQueue 时
    • SynchronousQueue 容量为 0,不能存储,必须立即移交
    • 每个任务都要线程来执行,max 直接决定了并发上限
  3. 实际意义
    • 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 并发

锁体系手撕

⭐⭐⭐

快手支付面试特点

  1. 重 MySQL:一面、二面都有大量 MySQL 深入问题,尤其是锁机制
  2. 重 JVM:一面重点考察堆内存、对象分配
  3. 重手撕代码:一面 LRU + HashMap,二面 B+ 树 + 锁
  4. 追问深:从一个 SQL 语句能追问 3-4 层(FOR UPDATE → 间隙锁 → 锁存储 → B+树)
  5. 项目经验:二面有屏幕共享看项目,要准备好项目中的技术亮点

推荐备考资料

  • 《高性能 MySQL》(索引、锁章节)
  • 《深入理解 Java 虚拟机》(内存与 GC 章节)
  • 《Java 并发编程的艺术》(锁和线程池章节)
  • LeetCode:LRU(146)、设计 HashMap(706)
  • MySQL 官方文档:InnoDB Locking 章节
  • 动手画 B+ 树插入/删除过程