Fork me on GitHub

JVM 锁膨胀机制深度剖析:自动化路径与开发者影响力

本篇文档旨在回答一个核心问题:synchronized 的世界里,究竟是 JVM 说了算,还是程序员的写法能改变命运?


一、 锁升级的自动化流水线:JVM 的“自作主张”

锁的膨胀过程(无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁)确实是由 JVM 自动管理的。程序员无法通过代码直接下令“请使用轻量级锁”,但 JVM 会根据底层硬件状态和实时竞争情况进行“自适应”决策。

1. 膨胀的决策链路

  1. 偏向锁阶段:当一个线程进入 synchronized 时,JVM 检查对象头的 Mark Word。如果是空的,直接记录当前线程 ID。这是 JVM 对单线程场景的极致偏爱。
  2. 轻量级锁阶段:当第二个线程试图获取锁, JVM 发现 Mark Word 里不是它,于是通过 CAS(Compare And Swap) 尝试抢夺。如果抢到了,说明竞争不激烈,维持在轻量级锁。
  3. 自旋与适应性自旋:抢不到锁的线程不会立刻放弃,而是在门口“打转”(自旋)。JVM 会根据历史数据判断:如果这个锁以前很容易抢到,它就多自旋一会儿;反之则少自旋。
  4. 重量级锁阶段:如果自旋多次依然失败,或者竞争线程过多,JVM 认为“场面失控”,会调用操作系统的 mutex 指令,进入重量级锁,将线程挂起。

二、 程序员的写法:如何“间接”控制锁的命运?

虽然你不能直接指挥 JVM,但你的写法逻辑会直接决定 JVM 走哪条优化路径。

1. 锁的粒度(Granularity)

  • 做法synchronized(this) 锁定整个对象 vs synchronized(lockObj) 锁定特定变量。
  • 影响:如果你锁定整个大对象,多个无关业务的线程会产生“虚假竞争”,强行让 JVM 将原本可以维持在偏向锁/轻量级锁的状态升级为重量级锁。
  • 建议尽量减小同步块的范围。

2. 持锁时长(Hold Time)

  • 做法:在同步块里做 IO 操作或复杂计算。
  • 影响:子线程自旋是有时间限制的。如果你持锁时间太长,正在自旋的线程等不及了,就会触发 JVM 的“锁膨胀”机制,强行升级为重量级锁。
  • 建议同步块内只做最核心的、快速的状态变更。

3. 对象重用与逃逸分析

  • 做法:局部变量加锁 vs 成员变量加锁。
  • 影响:如果你对一个永远不会逃逸出当前方法的对象加锁(例如方法内的局部变量),JVM 的 JIT 编译器会通过逃逸分析触发 “锁消除” —— 即使你写了 synchronized,运行的时候根本没锁。
  • 建议利用局部变量的封闭性减少全局竞争。

三、 JVM 的“脾气”:你必须知道的潜规则

1. 偏向锁的延迟开启

JVM 启动时,偏向锁通常会有几秒钟的延迟(默认 4s)。

  • 为什么? 因为 JVM 启动初期会有大量线程竞争(如加载类),此时偏向锁反而会降低性能(撤销开销大)。
  • 写法影响:如果你在 App 刚启动时就做大量同步,它们可能直接从无锁跳到轻量级锁。

2. 锁降级(罕见现象)

虽然通常说锁“只升不降”,但在某些特定的 JVM 实现(如 HotSpot)中,在 STW(Stop The World) 进行全局垃圾回收时,可能会尝试将重量级锁降级。但这对程序员来说是不可控且不可感知的。


🎙 面试官进阶 Q&A

Q:程序员可以手动干预锁升级吗?

  • :不能直接干预。但可以通过参数调节(如 -XX:BiasedLockingStartupDelay=0)或者通过业务逻辑优化。例如,如果已知某个锁一定会有高并发竞争,我们可以提前让它“热身”,或者干脆改用 ReentrantLock 来获得更确定的控制力。

Q:为什么 synchronized 块比 synchronized 方法更好?

  • :核心在于减小临界区。锁块可以只包裹那几行真正需要同步的代码,从而缩短持锁时间,给 JVM 留出更多保持在“轻量级锁”状态的空间,避免膨胀到性能低下的重量级锁。

📌 总结

锁膨胀是 JVM 的“自动挡”,而程序员的逻辑是“油门和刹车”。 你写出的竞争频率、持锁时长、逃逸程度,就是 JVM 决定是否切换挡位的唯一依据。

Android 面试题库:深度解析并发锁机制与应用实践

在 Android 高级开发面试中,并发锁是考察候选人底层功底、内存模型理解以及系统架构能力的核心考点。本文针对 Android 实际开发场景,系统性梳理了各种锁的原理与选型。


一、 Java 基础锁原语

1. synchronized (内置锁/监视器锁)

  • 特性:自动获取/释放、可重入、非公平。在现代 ART 虚拟机中经过了偏向锁、轻量级锁、重量级锁的膨胀优化。
  • 面试考点
    • 对象锁 vs 类锁:同步代码块与静态同步方法的区别。
    • 原理:每个对象关联的 monitor 对象(EntryList, WaitSet)。
  • 应用场景:本项目的 GLTextureView 中用于状态机的全局调度。

2. ReentrantLock (显示锁)

  • 特性:基于 AQS(AbstractQueuedSynchronizer)实现。支持公平/非公平选型、可中断、支持超时获取、多条件变量(Condition)。
  • 面试考点
    • synchronized 区别:灵活性(tryLock)、性能(高竞争下更稳定)、可扩展性。
  • 应用场景:复杂的数据结构操作,或需要响应中断的长时间等待场景。

二、 读写与性能优化锁

3. ReentrantReadWriteLock (读写锁)

  • 核心逻辑读读共享、读写互斥、写写互斥
  • 面试考点
    • 锁降级:写锁可以降级为读锁,但读锁不能升级为写锁。
    • 饥饿问题:大量读操作可能导致写操作长时间无法获取锁。
  • 应用场景:App 的内存缓存(LruCache)查询频率远高于更新频率的场景。

4. StampedLock (乐观读锁)

  • 特性:Java 8 引入。提供了一种乐观读模式,读操作不会阻塞写操作。
  • 面试考点
    • 对比 ReadWriteLock:通过版本戳(Stamp)验证数据有效性,在读多写极少的极端场景下性能近乎无锁。
  • 应用场景:坐标点数据计算、频繁读取的配置信息。

三、 原子操作与轻量级同步

5. CAS (Compare And Swap)

  • 原理:利用 CPU 的 cmpxchg 指令实现无锁原子更新。
  • 面试考点
    • ABA 问题:如何通过 AtomicStampedReference 解决。
    • 自旋开销:高竞争下 CPU 占用率过高。
  • 应用场景:计数器(AtomicInteger)、状态标记位。

6. volatile 关键字

  • 作用可见性、有序性(禁止指令重排)。不保证原子性。
  • 面试考点
    • DCL(双重检查锁定)单例模式:为什么必须加 volatile?(防止对象初始化未完成就被引用)。
  • 应用场景:本项目的 mHasContentToDraw 标志位同步。

四、 Android 场景下的特有应用

7. 渲染链路同步 (本项目核心)

  • 痛点:GL 线程与 UI 线程的生命周期不同步。
  • 方案:使用 synchronized 配合 wait/notifyAll 构建保护性暂停(Guarded Suspension)模型。
  • 面试回答:参考 GLTEXTUREVIEW_LOCKING.md 中的握手协议描述。

8. 锁的选型指南 (横向对比)

维度 synchronized ReentrantLock Atomic/CAS
灵活性 低(自动) 高(手动控制) 极高(无锁)
性能 中(低中竞争优) 高(高竞争稳定) 极高(极短操作)
功能 基础 丰富(Condition/公平性) 仅限原子变量
风险 高(易忘释放) 容易出现 ABA 问题

五、 资深面试官进阶追问

Q1:如何定位 App 线上发生的死锁?

  • :1. 使用 Thread.getAllStackTraces() 获取堆栈。2. 寻找 BLOCKED 状态的线程。3. 分析锁的循环等待链(A 等 B,B 等 A)。

Q2:为什么 ConcurrentLinkedQueue 不需要加锁?

  • :它采用了 Michael-Scott 非阻塞队列算法,底层完全基于 CAS 操作头尾指针,实现了极高的并发吞吐量。本项目在 HybridNormalRenderer 中使用它来处理弹幕队列,避免了每一帧渲染都要持锁的开销。

Q3:什么是偏向锁和锁消除?

  • :锁消除是 JIT 编译器的优化,若发现对象只会在单线程访问,则直接去掉 synchronized。偏向锁是假设锁一直由同一线程持有,减少 CAS 操作开销。

📌 总结

锁不是越多越好,而是越轻量越好。在 Android 开发中,首选无锁结构(Concurrent 容器),次选轻量级标记(volatile/Atomic),最后才考虑重权重的 Monitor 或显示锁。

深度解析:Java 锁的膨胀机制(偏向锁、轻量级锁、重量级锁)

在 Android 的 ART(Android Runtime)和 HotSpot JVM 中,synchronized 关键字的性能优化主要依赖于锁的膨胀(Inflation)机制。系统会根据竞争情况,自动在四种状态间转换:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁


一、 锁状态的核心存储:Mark Word

锁的信息存储在 Java 对象的对象头(Object Header)中的 Mark Word 字段里。

  • Mark Word 会根据锁状态的变化,复用自己的空间来存储:偏向线程 ID、指向栈中锁记录的指针、或指向互斥量(Monitor)的指针。

二、 四种锁状态详解

1. 偏向锁 (Biased Locking)

  • 核心思想:假设锁总是由同一个线程多次获得,那么就没有必要进行同步操作(如 CAS)。
  • 实现:在 Mark Word 中记录当前线程的 ID。下次该线程进入时,只需检查 ID 是否一致,完全不需要 CPU 原子指令。
  • 优缺点
    • 优点:单线程执行临界区代码时,性能接近无锁。
    • 缺点:一旦出现另一个线程竞争,撤销偏向锁会产生较大的性能开销(需要等待全局安全点 Safe Point)。
  • Android 现状:在 Android ART 中,偏向锁的实现逻辑与 HotSpot 略有不同,但在高版本中出于简化系统和减少 Safe Point 停顿的考虑,偏向锁的使用逐渐被弱化甚至在某些场景下默认关闭。

2. 轻量级锁 (Lightweight Locking)

  • 触发时机:当偏向锁被撤销,或者有第二个线程尝试获取锁(但竞争不激烈)时。
  • 实现:线程在自己的虚拟机栈中创建一个名为“锁记录(Lock Record)”的空间,尝试用 CAS 操作将对象的 Mark Word 指向这个记录。
  • 自旋优化:如果 CAS 失败,线程不会立即阻塞,而是执行一段自旋(Spinning)循环,期望持锁线程能迅速释放。
  • 优点:避免了操作系统内核态与用户态切换的昂贵开销。

3. 重量级锁 (Heavyweight Locking)

  • 触发时机:竞争激烈。如果自旋超过一定次数,或者在轻量级锁状态下又有第三个线程来抢锁。
  • 实现:Mark Word 指向堆中的 Monitor(监视器) 对象。Monitor 依赖于操作系统的 mutex 指令。
  • 行为:未能抢到锁的线程会被挂起(Park),进入阻塞状态,等待操作系统唤醒。
  • 代价:涉及内核态切换,上下文切换开销巨大。

三、 锁的升级(膨胀)流程图

  1. 初始状态:对象处于“无锁”或“可偏向”状态。
  2. 偏向开启:线程 A 访问,Mark Word 记录 A 的 ID。
  3. 轻度竞争:线程 B 访问,发现 ID 不匹配。撤销偏向锁,升级为轻量级锁。
  4. CAS 竞争:线程 A 和 B 通过 CAS 争夺锁记录指针。
  5. 激烈竞争:CAS 失败次数过多,轻量级锁膨胀为重量级锁,线程 B 进入阻塞队列。

注意:锁的升级通常是不可逆的(但在某些 JVM 实现中,在全局垃圾回收 GC 时可能会发生锁降级)。


四、 补充优化:锁消除与锁粗化

除了状态膨胀,编译器(JIT)还做了以下两项神级优化:

1. 锁消除 (Lock Elimination)

  • 原理:通过逃逸分析,如果发现一个对象只会在当前线程内部使用(不会被其他线程看到),JIT 编译器会直接把 synchronized 去掉。
  • 例子:在方法内部定义的 StringBuffer.append()

2. 锁粗化 (Lock Coarsening)

  • 原理:如果一系列连续的操作都对同一个对象加锁(如循环内加锁),编译器会把加锁的范围扩大到整个操作序列外部,避免频繁开关锁。

🎙 面试考察点:资深级别回答

Q:既然重量级锁慢,为什么不一直用轻量级锁自旋?

  • :自旋是需要消耗 CPU 算力的(虽然没挂起,但 CPU 在跑空转指令)。如果持锁线程执行时间很长,让其他线程一直自旋会白白浪费大量 CPU 资源。因此,当自旋达到一定阈值(适应性自旋),必须膨胀为重量级锁,让竞争线程去“睡觉”(挂起),把 CPU 让给真正干活的线程。

Q:在 Android 开发中,这套机制给我们的启示是什么?

  • :1. 尽量减少锁的竞争范围(减小临界区)。2. 对于高频调用的代码,优先使用无锁结构(CAS/Atomic),因为一旦膨胀到重量级锁,在移动端有限的算力下,性能损耗非常直观。

01. 双指针-只有一个输入, 从两端开始遍历

正文

双指针(Two Pointers)是一种常用于数组或字符串问题的技巧。当我们需要在有序序列中查找符合特定条件的元素对,或者判断序列是否具有某种属性(如回文)时,从两端向中间遍历是非常高效的。

算法模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun doublePointer(arr: IntArray): Int {
var left = 0
var right = arr.size - 1

while (left < right) {
// 根据 left 和 right 相关的条件进行操作
// 例如:if (arr[left] + arr[right] == target) return ...

if (CONDITION) {
left++
} else {
right--
}
}

return 0 // 返回需要的结果
}

使用说明

  1. 适用场景

    • 有序数组:如两数之和(有序版)、三数之和。
    • 字符串处理:如判断回文字符串、反转数组/字符串。
    • 容器盛水问题:如“盛最多水的容器”。
  2. 核心优势

    • 能够将 $O(N^2)$ 的暴力枚举优化到 $O(N)$。
    • 利用了序列的有序性或特定单调性。
  3. 注意点

    • 循环条件通常是 left < right(找对子)或 left <= right(处理中间元素)。
    • 更新指针时要确保逻辑能够覆盖所有情况,避免死循环。

02. 双指针-有两个输入, 两个都需要遍历完

正文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fun twoPointers(nums1: IntArray, nums2: IntArray) {
var pointer1 = 0
var pointer2 = 0

while (pointer1 < nums1.size && pointer2 < nums2.size) {
// 处理指针1和指针2对应位置的元素
// ...

// 移动指针
pointer1++
pointer2++
}

// 处理剩余未遍历完的元素
while (pointer1 < nums1.size) {
// 处理指针1对应位置的元素
// ...
pointer1++
}

while (pointer2 < nums2.size) {
// 处理指针2对应位置的元素
// ...
pointer2++
}
}

03. 滑动窗口

正文

滑动窗口(Sliding Window)是一种在数组或字符串上执行操作的优化技术。它将嵌套循环的问题转化为单个循环,从而显著降低时间复杂度。

算法模板(固定窗口大小)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fun slidingWindow(nums: IntArray, k: Int): Int {
val n = nums.size
if (n < k) return 0

var sum = 0
var maxSum = 0

// 1. 初始化:计算第一个窗口的和
for (i in 0 until k) {
sum += nums[i]
}

maxSum = sum

// 2. 滑动:从第 k 个元素开始遍历
for (i in k until n) {
// 新窗口的和 = 前一个窗口的和 + 新进入的元素 - 离开窗口的元素
sum += nums[i] - nums[i - k]
// 更新结果
maxSum = maxOf(maxSum, sum)
}

return maxSum
}

使用说明

  1. 适用场景

    • 固定窗口:给定窗口大小 $k$,求最大/最小和、平均值等。
    • 可变窗口:寻找满足特定条件的最长/最短子数组(通常配合双指针 left, right 使用)。
  2. 核心优势

    • 避免了重复计算窗口重叠部分的值。
    • 时间复杂度通常为 $O(N)$。
  3. 注意点

    • 注意边界条件,如数组长度小于 $k$ 的情况。
    • 如果是求平均值,注意浮点数转换。
    • 对于可变窗口,通常逻辑是:右边界右移增加元素 -> 窗口不满足条件时左边界右移移除元素。

04. 构建前缀和

正文

前缀和(Prefix Sum)是一种非常重要的预处理技巧,主要用于高效计算数组中任意区间的元素之和。

算法模板

1
2
3
4
5
6
7
8
9
10
11
fun prefixSum(nums: IntArray): IntArray {
// 前缀和数组长度为 n + 1,prefixSum[i] 表示 nums 中前 i 个元素的和
val prefixSum = IntArray(nums.size + 1)

// 计算前缀和数组
for (i in 1..nums.size) {
prefixSum[i] = prefixSum[i - 1] + nums[i - 1]
}

return prefixSum
}

使用说明

  1. 区间求和
    使用前缀和数组 prefixSum,我们可以以 $O(1)$ 的时间复杂度回答多个区间和查询。计算区间 [left, right](包含两端)的元素之和:

    1
    val sum = prefixSum[right + 1] - prefixSum[left]
  2. 核心优势
    将多次区间查询的时间复杂度从 $O(N)$ 降低到 $O(1)$。

  3. 注意点

    • 前缀和数组长度通常设为 n + 1,其中 prefixSum[0] = 0。这样可以统一处理 left = 0 的边界情况,无需额外的 if 判断。
    • 如果数组元素较大,注意前缀和数组可能发生整型溢出,必要时使用 LongArray

05. 高效的字符串构建

正文

以下是一个高效的字符串构建算法模板:

1
2
3
4
5
6
7
8
9
fun buildString(chars: List<Char>): String {
val sb = StringBuilder()

for (ch in chars) {
sb.append(ch)
}

return sb.toString()
}

在这个模板中,我们使用 StringBuilder 来构建字符串。StringBuilder 是可变的字符串类,可以高效地进行字符串的拼接操作。

我们通过遍历字符列表 chars,逐个将字符添加到 StringBuilder 中。最后,通过调用 toString() 方法,将 StringBuilder 转换为最终的字符串结果并返回。

使用 StringBuilder 的好处是它避免了在每次拼接字符串时创建新的字符串对象,从而提高了性能和效率。特别是在需要频繁拼接大量字符串的情况下,使用 StringBuilder 可以避免不必要的性能损耗。

使用这个模板,你可以根据具体需求构建字符串。可以根据问题的要求在遍历过程中进行一些字符处理、条件判断等操作。

06. 链表-快慢指针

正文

在模板中,我们使用两个指针,一个指针每次向后移动一个节点,另一个指针每次向后移动两个节点。如果链表中存在循环,快指针最终会追上慢指针,这样我们就可以判断出链表有循环。如果链表没有循环,快指针会先到达链表的末尾,此时我们就可以判断链表没有循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun hasCycle(head: ListNode?): Boolean {
var slow = head
var fast = head

while (fast?.next != null && fast.next?.next != null) {
slow = slow?.next
fast = fast.next?.next

if (slow == fast) {
return true
}
}

return false
}

07. 反转链表

正文

反转链表(Reverse Linked List)是链表操作中最基础且最高频的考点。它通过改变节点间的指针指向,将原本 A -> B -> C 的结构变为 C -> B -> A

算法模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ListNode(var value: Int) {
var next: ListNode? = null
}

fun reverseList(head: ListNode?): ListNode? {
var prev: ListNode? = null
var current = head

while (current != null) {
val nextNode = current.next // 1. 暂存下一个节点
current.next = prev // 2. 反转当前节点的指向
prev = current // 3. prev 向前移动
current = nextNode // 4. current 向前移动
}

return prev // 新的头节点
}

使用说明

  1. 核心逻辑
    在遍历过程中,我们需要维持三个变量:prev(已反转部分的头)、current(当前处理的节点)和 nextNode(待处理部分的头)。

  2. 关键步骤
    反转指针前,**必须先暂存 current.next**,否则一旦修改了 current.next,就无法找到链表的后续部分。

  3. 时间与空间复杂度

    • 时间复杂度:$O(N)$,只需遍历一次链表。
    • 空间复杂度:$O(1)$,仅需常数级别的额外空间。
  4. 注意点

    • 空链表或只有一个节点的链表应能正常工作。
    • 在 Kotlin 中,注意处理可空性(ListNode?)。