深度解析 Android 并发锁机制与应用实践

Android 开发中的并发编程:从关键字到锁机制

在 Kotlin/Android 开发中,处理多线程并发主要有两个核心目标:可见性(Visibility)原子性(Atomicity)。为了达成这些目标,Java 语言及其并发包(JUC)提供了从简单到复杂的多种工具。


1. 基础关键字:原子性与可见性的保障

volatile

volatile 是最轻量级的同步机制。

  • 作用: 保证变量的可见性禁止指令重排序
  • 原理: 当一个线程修改了 volatile 变量,新值会立即同步到主内存;其他线程读取时,会强制从主内存读取。
  • 局限: 不保证原子性。例如 i++ 操作在多线程下依然是不安全的。

典型案例:为什么 DCL 单例必须加 Volatile?
以下是你代码中的问题分析及修正方案:

❌ 错误代码示例

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
public class Singleton {
// 1. volatile 必须加:保证可见性,禁止指令重排序
private static volatile Singleton instance = null;

// 2. 锁对象必须是静态的:确保全局唯一,拦截所有线程的访问
private static final Object lock = new Object();

// 私有构造函数:防止外部 new
private Singleton() {}

public static Singleton getInstance() {
// 第一次检查:为了效率。如果 instance 已经创建,直接返回,避免进入锁竞争
if (instance == null) {
// 这里使用静态锁对象。如果 lock 不是 static 的,每个 Singleton 实例都有自己的锁,
// 而 getInstance 是静态方法,无法保证多个线程竞争的是同一把锁。
synchronized (lock) {
// 第二次检查:为了安全性。确保在获取锁的瞬间,没有其他线程抢先创建了实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

在 Java 5 之后,volatile 语义得到了增强。它不仅保证了线程 A 对 instance 的修改对线程 B 可见,更重要的是它在 new Singleton() 时建立了一个内存屏障。
如果没有 volatile,instance = new Singleton(); 可能会被分解为:

  • allocate (分配内存)
  • instance = memory (设置引用,此时对象还没初始化)
  • ctorSingleton (执行构造函数)

如果 2 和 3 发生重排序,其他线程就会拿到一个“半成品”对象。

在单例模式的 DCL (Double Check Locking) 中,volatile 的核心作用其实是禁止指令重排序。
如果没有 volatile,对象可能在构造函数还没执行完时,就把内存地址赋给了变量。另一个线程拿到的就是一个“半成品”对象,直接崩溃。

用途: 确保“在变量赋值之前,之前所有的初始化工作已经全部完成”。
场景: 刚才提到的单例模式赋值,或者作为“完成信号”标记。

再深入理解一下
作为“触发器 / 内存栅栏” (Memory Barrier)
当线程 A 写入一个 volatile 变量时,JMM (Java 内存模型) 会把该线程之前所有的普通变量修改也一起刷新到主内存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
  var data = 0
@Volatile var ready = false

// 线程 A
data = 42
ready = true // 写入 volatile 变量,这会像“存盘”一样把 data 也刷过去

------------------------- 这里好似有一层屏障------------------------------

// 线程 B
if (ready) {
println(data) // 此时 data 保证一定是 42
}

所以 volatile 仅用于变量,且有能力影响其他的变量

2. 现代并发原语:CAS (Compare And Swap)

为什么不叫“修改”而叫“交换”?
这源于底层 CPU 指令(如 x86 的 LOCK CMPXCHG)。在硬件层面,这个操作通常是原子的,它会将寄存器里的新值与内存位置的值进行交换(或者说覆盖),以此确保在多核环境下,这一系列动作不会被其他处理器中途干扰。

CAS 是实现无锁编程(Lock-Free)的核心算法。

  • 原理: 它包含三个操作数:内存地址 V、旧的预期值 A、即将更新的新值 B。只有当内存值 V 等于 A 时,才会将 V 修改为 B。
  • 优点: 避免了线程切换的开销,效率极高。
  • 缺点: 1. ABA 问题(可用版本号解决)。
    1. 自旋时间长会消耗 CPU。
  • 应用: Kotlin 中的 AtomicInteger, AtomicBoolean, AtomicReference 等都是基于 CAS 实现的。
特性 AtomicInteger AtomicReference
操作目标 具体的 Int 数值 对象的引用地址
原子范围 数值的加减、替换 整个对象的切换/替换
性能 极高(直接操作底层数字) 高(操作指针),但对象内部属性不保证原子性
Kotlin 示例 AtomicInteger(0) AtomicReference(User("Gemini"))

CAS 完整日志流 (含冲突重试)

第一轮:遭遇冲突

  • [读取] 内存值 V = 10。
  • [准备] 预期值 A = 10,拟更新值 B = 11。
  • [验证] 比较 V 是否为 A?
  • [冲突] 硬件返回 No(此时变量已被其他线程改成了 12)。
  • [动作] 写入失败,触发**自旋 (Retry)**。

第二轮:重试命中

  • [读取] 重新获取内存值 V = 12。
  • [准备] 预期值 A = 12,拟更新值 B = 13。
  • [验证] 比较 V 是否为 A?
  • [命中] 硬件返回 Yes(值未被改动)。
  • [结束] 成功写入 13,更新完成。

3. 基础锁机制:Synchronized

synchronized 是 Java 中最常用的同步机制,它是一种隐式锁(由 JVM 自动管理获取和释放),也是一种互斥锁

核心用法

  • 修饰实例方法:锁住当前实例对象 (this)。
  • 修饰静态方法:锁住当前类的 Class 对象。
  • 修饰代码块:锁住指定的对象实例(最灵活,建议缩小锁的范围)。

底层原理:Monitor(管程)

每个 Java 对象天生就带了一把“锁”,在 JVM 中通过 Monitor 机制实现。

  • **Enter (入锁)**:执行 monitorenter 指令,线程尝试获取对象的 Monitor。
  • **Exit (释放)**:执行 monitorexit 指令,线程释放 Monitor(即使发生异常,JVM 也会保证释放)。

JVM 对它的“黑科技”优化

在早期 Java 版本中,synchronized 是笨重的重量级锁。但从 Java 6 开始,引入了锁升级路径以提升性能:

  1. **偏向锁 (Biased Locking)**:锁会偏向于第一个访问它的线程,无竞争时几乎无开销。
  2. **轻量级锁 (Lightweight Locking)**:当出现少量竞争时,通过 CAS(自旋)来获取锁,避免线程挂起。
  3. **重量级锁 (Heavyweight Locking)**:当竞争激烈时,升级为重量级锁,线程进入阻塞状态,交给操作系统调度。

4. 高级锁机制:ReentrantLock 家族

synchronized 的灵活性不足时,我们会使用 java.util.concurrent.locks 中的工具。

ReentrantLock (可重入锁)

  • 对比 synchronized: * 手动控制: 必须手动调用 lock()unlock()(建议配合 try-finally)。
    • 公平性: 支持公平锁(按等待顺序获取)。
    • 可中断: 支持 lockInterruptibly()
    • 超时: 支持 tryLock(timeout),避免死锁。

ReentrantReadWriteLock (读写锁)

  • 核心逻辑: 读读共享,读写互斥,写写互斥
  • 场景: 适用于“读多写少”的情况。
  • 特点: 允许多个线程同时读取资源,但只有一个线程能进行写入。如果已有线程在读,写线程必须等待。

synchronized 和 ReentrantLock 的区别

编写一个程序,开启三个线程(分别为 Thread A, Thread B, Thread C):

  1. Thread A 负责打印字母 A
  2. Thread B 负责打印字母 B
  3. Thread C 负责打印字母 C

要求:

  • 这三个线程必须顺序执行,即控制台输出的结果必须是 ABCABCABC... 的形式。
  • 每个线程各自循环打印 $n$ 次(例如你代码中的 loops = 5)。

1. 使用 synchronized

这是最基础的实现方式,依靠 wait() 让出锁,并依靠 notifyAll() 唤醒其他正在等待该锁的线程。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import kotlin.concurrent.thread

fun synchronizedExample() {
val lock = Object()
var state = 0 // 0:A, 1:B, 2:C
val loops = 5

val t1 = thread {
repeat(loops) {
synchronized(lock) {
while (state != 0) lock.wait()
print("A")
state = 1
lock.notifyAll()
}
}
}

val t2 = thread {
repeat(loops) {
synchronized(lock) {
while (state != 1) lock.wait()
print("B")
state = 2
lock.notifyAll()
}
}
}

val t3 = thread {
repeat(loops) {
synchronized(lock) {
while (state != 2) lock.wait()
print("C ")
state = 0
lock.notifyAll()
}
}
}
}

2. 使用 ReentrantLock

ReentrantLock 配合 Condition 的好处是它可以精准唤醒。在上面的 synchronized 例子中,notifyAll() 会唤醒所有线程,但其实只有下一个顺序的线程才是真正需要的。使用 Condition 可以直接定向唤醒。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.thread

fun reentrantLockExample() {
val lock = ReentrantLock()
// 为每个线程创建特定的“等待间”
val conditionA = lock.newCondition()
val conditionB = lock.newCondition()
val conditionC = lock.newCondition()

var state = 0
val loops = 5

thread(name = "A") {
repeat(loops) {
lock.lock()
try {
if (state != 0) conditionA.await()
print("A")
state = 1
conditionB.signal() // 精准唤醒 B
} finally {
lock.unlock()
}
}
}

thread(name = "B") {
repeat(loops) {
lock.lock()
try {
if (state != 1) conditionB.await()
print("B")
state = 2
conditionC.signal() // 精准唤醒 C
} finally {
lock.unlock()
}
}
}

thread(name = "C") {
repeat(loops) {
lock.lock()
try {
if (state != 2) conditionC.await()
print("C ")
state = 0
conditionA.signal() // 精准唤醒 A
} finally {
lock.unlock()
}
}
}
}

3. 关键区别点

特性 synchronized ReentrantLock
灵活性 自动加锁/释放,代码简洁 需手动 lock()unlock(),更安全需配合 finally
唤醒机制 notifyAll() 唤醒所有,由线程竞争 Condition 可以实现精准“点名”唤醒,性能更优
等待响应 不可中断 支持 lockInterruptibly(),可中断等待

4. 终极性能优化:StampedLock

StampedLock 是 Java 8 引入的,是对读写锁的改进。

  • 核心改进: 引入了乐观读(Optimistic Reading)
  • 原理: 读操作时不会阻塞写操作。读完后检查一个“戳(Stamp)”,如果期间没有写操作发生,则读取成功;如果有写操作,再退化为悲观读锁。
  • 注意: 它不可重入,且不支持 Condition

5. Kotlin 线程安全的数据结构

深入分类解读

数据结构 底层原理 特点 最佳场景
ConcurrentHashMap CAS + synchronized (分段锁定) 高并发读写,允许完全并发的读 缓存、全局状态管理
CopyOnWriteArrayList 写时复制副本 (Copy-On-Write) 读操作无锁,写操作极慢且耗内存 监听器列表、配置读取 (读多写极少)
CopyOnWriteArraySet 基于 CopyOnWriteArrayList 去重,且具备写时复制特性 白名单、去重的回调通知
ConcurrentLinkedQueue CAS (无锁算法) 非阻塞、高性能、无界 高频率、高并发的任务调度
LinkedBlockingQueue 独占锁 (ReentrantLock) 阻塞式,可选有界/无界 线程池任务队列 (ExecutorService)
ArrayBlockingQueue 独占锁 + 数组 阻塞式,必须指定容量 生产者-消费者(防止内存溢出)

1. “快读” 阵营:写时复制 (Copy-On-Write)

  • 成员CopyOnWriteArrayList, CopyOnWriteArraySet
  • 原理:当你修改集合时,它不直接在原数组上改,而是复制一个新数组,改完后再把引用指向新的。
  • 优点:遍历(Iterator)时不需要加锁,绝对不会抛出 ConcurrentModificationException
  • 缺点:写操作代价极高(内存占用翻倍)。

2. “高频” 阵营:无锁/细粒度锁 (CAS/Segment)

  • 成员ConcurrentHashMap, ConcurrentLinkedQueue
  • 原理:使用硬件层面的 CAS (Compare And Swap) 原子操作。ConcurrentHashMap 在 Java 8+ 之后对桶位(Bucket)加锁,而不是整个 Map。
  • 优点:吞吐量极大,多个线程可以同时往里塞数据而不需要互相排队。

3. “同步” 阵营:阻塞队列 (Blocking)

  • 成员LinkedBlockingQueue, ArrayBlockingQueue
  • 原理:利用 Condition 实现“等待-唤醒”机制。如果队列满了,写线程会停下等;如果队列空了,读线程会停下等。
  • 区别
    • Array 是预先分配好内存的,适合对内存控制严格的场景。
    • Linked 是动态分配的,吞吐量通常略高于 Array,但要注意如果不设置容量限制,可能会导致 OOM。

1. ConcurrentHashMap 原理 (Java 8+)

ConcurrentHashMap 的核心目标是在保证线程安全的同时,提供接近 HashMap 的高性能。

核心架构:由“分段锁”进化为“桶位锁”

在 Java 8 之后,它摒弃了 Java 7 的 Segment 方案,改用 CAS + synchronized + 红黑树 的混合结构。

A. 关键组件
  • Node 数组:存储数据的核心数组。
  • **CAS (Compare And Swap)**:用于无锁地插入新节点或更新状态。
  • synchronized:仅锁定当前正在操作的 桶(Bucket)的头节点
  • 红黑树:当链表长度超过 8 时,转化为红黑树以保证查询效率为 O(\log n)。

我们可以从以下几个维度来深度理解这个设计:

1. 为什么要锁“头节点”?

在散列表中,数组的每一个下标(Bucket)都是一个入口。如果你要往某个链表里增加节点,或者修改某个节点,你必须保证在这个过程中没有其他人也在动这根链表。

  • 头节点就是“守门员”:头节点是访问整个桶位的唯一入口。通过 synchronized(f)(这里的 f 就是头节点对象),Java 实际上是把这个桶位变成了一个同步块
  • 最小化粒度:只要两个线程访问的是不同的数组下标(比如线程 A 在下标 1,线程 B 在下标 5),它们之间完全没有锁竞争。这也就是为什么它的并发性能极高。

2. 为什么不用 CAS 到底,非要用 synchronized

这是一个很棒的工程权衡。你会发现:

  • 当桶位为空时:使用 CAS。因为这时候只需要把新节点挂上去,不涉及复杂逻辑,CAS 失败了重试即可。
  • 当桶位有数据时:情况变复杂了。你可能需要遍历链表看 Key 是否重复,或者需要旋转红黑树。
    • 如果用 CAS 实现复杂的链表插入,逻辑会变得异常复杂(需要处理各种中间态)。
    • synchronized 的进化:在 Java 8 中,synchronized 已经过优化(偏向锁、轻量级锁、锁消除等),在只有少量线程竞争时,性能非常出色。
    • 代码可读性与稳定性:使用 synchronized 锁定头节点,可以让后续的链表/红黑树操作变成“单线程化”,确保了逻辑的简单和安全。
B. 读写流程
  1. 写操作(put)
    • 计算 Hash 值,定位到数组下标。
    • 如果目标位置为空,利用 CAS 尝试插入。
    • 如果目标位置已有数据且正在扩容,则协助扩容。
    • 如果目标位置有数据且未扩容,使用 synchronized 锁定该位置的 头节点,然后进行链表或红黑树的插入。
  2. 读操作(get)
    • 完全无锁。Node 节点的 valuenext 指针使用了 volatile 关键字,确保了多线程间的可见性。
C. 扩容机制 (Multiphase Transfer)

它是并发扩容的。当一个线程发现需要扩容时,它会参与进来协助数据的迁移,每个线程负责处理一小部分桶位,通过 ForwardingNode 标记已处理过的区域。

ConcurrentLinkedQueue 原理

ConcurrentLinkedQueue 是 Java 并发包(java.util.concurrent)中性能最高的队列之一。它是一个基于链接节点的无界线程安全队列,采用了 Michael & Scott 非阻塞队列算法

其核心思想是:不使用锁(synchronized 或 ReentrantLock),而是完全通过 CAS(Compare-And-Swap)原子操作来实现。


1. 核心架构与设计

ConcurrentLinkedQueue 内部维护了两个关键指针:head(头节点)和 tail(尾节点)。每个节点(Node)包含数据 item 和指向下一个节点的 next 指针。

  • 原子性保障itemnext 都被声明为 volatile,确保了多线程间的内存可见性。
  • 哨兵节点:初始化时,队列会创建一个空的“伪节点”,headtail 最初都指向它。这简化了边界条件判断。

2. 关键算法:延迟更新(HOPS 策略)

这是 ConcurrentLinkedQueue 最精妙的地方。在传统的无锁算法中,每次插入都要更新一次 tail,但 CAS 是昂贵的(涉及缓存一致性流量)。为了提高吞吐量,该队列并不保证 tail 永远指向最后一个节点。

A. 入队 (offer) 的逻辑

  1. 寻找真正的尾部:由于 tail 可能是“旧的”,程序会从 tail 开始往后遍历 next,直到找到真正的最后一个节点(即 next == null 的节点)。
  2. CAS 插入:使用 CAS 将真正的尾节点的 next 指向新节点。
  3. 延迟更新 tail:只有当 tail 偏离真正末尾的距离超过一个阈值(通常是 1 个节点长度)时,才会通过 CAS 将 tail 指向新节点。

直观理解:就像接力赛,并不是每一棒都要把旗子插在终点,而是跑几棒后再统一更新终点标志的位置。

B. 出队 (poll) 的逻辑

  1. 寻找真正的头部head 也不一定指向第一个有效元素。它可能指向一个已经被弹出的节点。
  2. CAS 提取:找到第一个 item 不为 null 的节点,尝试用 CAS 将其 item 置为 null
  3. 延迟更新 head:同理,只有当 head 偏离位置较远时,才会更新 head 指针。

3. CAS 如何解决竞争?

假设两个线程同时尝试入队:

  1. 线程 A 执行 CAS 成功,将新节点挂载到了队列末尾。
  2. 线程 B 的 CAS 会失败(因为预期的 null 变成了线程 A 插入的新节点)。
  3. 自旋重试:线程 B 发现失败后,不会挂起,而是进入下一轮循环,重新定位新的尾节点并再次尝试 CAS。
优点
  • 无锁化:避免了线程切换和阻塞的开销。
  • 高性能:在高并发场景(尤其是生产者和消费者都很频繁时)下,吞吐量远超 LinkedBlockingQueue
  • 永不失效的迭代器:它的迭代器是弱一致性的,不会抛出 ConcurrentModificationException
缺点/局限
  • size() 开销大:由于是链表结构且没有全局计数器(维护计数器本身需要同步),计算 size 必须遍历全表,复杂度为 $O(n)$。
  • 内存压力:无界队列如果不加控制,在生产者远快于消费者时可能会导致 OOM。

ConcurrentLinkedQueue 是通过 CAS 原子指令 + 延迟指针更新 实现的。它通过牺牲一定的实时准确性(如 tail 指针的滞后)换取了极高的并发处理能力,是实现高性能异步处理任务的理想选择。

以下是触发 tail 偏离并重新校准的具体场景分析:

1. 典型的“跳跃更新”场景(Hops = 2)

为了平衡 CAS 的性能开销,Doug Lea(JUC 作者)设计了让 tail 滞后。

  • 初始状态headtail 都指向同一个哨兵节点(Node 0)。
  • 第一次插入(Thread A)
    1. 发现 tail.next == null
    2. CAS 将 tail.next 指向新节点 Node 1。
    3. 不更新 tail。此时 tail 依然指向 Node 0,但 Node 0 指向 Node 1。这就产生了偏离(距离为 1)。
  • 第二次插入(Thread B 或 A)
    1. 检查 tail.next,发现不为 null(指向了 Node 1)。
    2. 顺着指针找到真正的末尾 Node 1。
    3. CAS 将 Node 1 的 next 指向 Node 2。
    4. 触发阈值:因为此时 tail 距离真正的末尾已经跳了 2 步,代码会执行 compareAndSetTail,将 tail 直接指向 Node 2。
2. 多线程竞争导致的“大幅偏离”

在极端高并发下,tail 偏离的距离可能远超 2 个节点:

  • 竞速状态:当线程 A 正在寻找末尾准备插入时,线程 B、C、D 已经快速完成了三次插入,但它们还没来得及更新 tail 指针。
  • 被迫寻根:此时线程 A 发现 tail.next 后面跟着一串节点(Node 1 -> Node 2 -> Node 3)。线程 A 必须沿着 next 链条一直往后走,直到找到真正的末尾。
  • 校准:一旦线程 A 完成了它的插入,它会尝试一次性将 tail 移动到它刚插入的那个最新节点上,瞬间完成“大幅度校准”。
3. 节点被删除导致的特殊偏离(Dangling Tail)

有一种特殊情况:tail 指向的节点已经被出队(poll)并从链表中逻辑删除了。

  • 原理:当一个节点出队后,为了帮助 GC,它的 next 可能会指向它自己(p.next = p)。
  • 处理:当 offer 操作发现 tail.next == tail 时,说明 tail 已经由于落后太多,掉进了“时空裂缝”(指向了一个已失效的节点)。此时,线程会直接跳到 head 指针处,从头开始重新寻找末尾。

CopyOnWriteArrayList

CopyOnWriteArrayList 是 Java 并发包中设计最独特的集合之一。它的核心思想正如其名:写时复制(Copy-on-Write, COW)

其设计初衷是解决在多线程环境下,对 ArrayList 进行遍历时如果发生修改会抛出 ConcurrentModificationException 的问题,同时提供比完全加锁(Vector)更高的读取性能。

核心设计原理

CopyOnWriteArrayList 的核心逻辑可以概括为:读写分离,空间换时间

A. 内部结构

它内部维护了一个 volatile 修饰的数组 arrayvolatile 确保了当数组引用指向一个新的数组时,所有线程都能立即看到。

B. 写操作(修改、添加、删除)

当你执行 add()remove() 时,它不会直接修改当前数组,而是执行以下步骤:

  1. 加锁:获取一把独占锁(ReentrantLock),确保同一时刻只有一个线程在修改。
  2. 复制:创建一个与当前数组内容完全相同的新数组。
  3. 修改:在新数组上执行添加或删除操作。
  4. 覆盖:将内部的 array 引用指向这个新数组。
  5. 释放锁:操作完成。
C. 读操作(get、遍历)

读操作是完全无锁的。线程直接读取当前的 array

  • 由于写操作是在副本上进行的,读线程即使在写操作过程中进行遍历,读取的也是旧数组的快照(Snapshot),因此不会产生冲突,也不会抛出异常。
优点
  1. 线程安全且无锁读:读操作性能极高,适合高频读取。
  2. 一致性快照:遍历时不需要加锁,且不会受到修改操作的影响。
缺点
  1. 内存开销:每次写操作都要复制整个数组。如果数组很大,会频繁触发 GC,甚至导致 OOM。
  2. 数据延迟(最终一致性):读线程可能会在短时间内读取到旧数据,因为写操作还没把引用指向新数组。
  3. 写操作慢:因为涉及数组拷贝,写性能远低于 ArrayList
最佳实践场景:
  • 读多写极少:例如白名单、黑名单配置、系统属性配置。
  • 监听器列表:在 Android 中存储 ListenerObserver 集合。通常这些监听器在初始化时注册,运行期间频繁触发读取,极少修改。
Collections.synchronizedList 的区别
特性 CopyOnWriteArrayList SynchronizedList
读性能 极高(无锁) 一般(有锁)
写性能 低(复制数组) 一般(锁竞争)
迭代器 弱一致性(Snapshot),不会报错 强一致性,修改会抛 Exception
内存占用 较高(写时翻倍) 低(原地修改)

附录

Java 7 ConcurrentHashMap Segment 分段锁

在 Java 7 的 ConcurrentHashMap 中,Segment 分段锁的实现本质上是“化整为零”的锁管理策略。它通过将一个巨大的哈希表拆分为多个独立维护的小型哈希表,从而实现真正的并发写入。

以下是其核心设计与实现逻辑:

1. 核心数据结构:分层架构

Segment 的实现依赖于一个三层嵌套结构:

  • 外层 ConcurrentHashMap:持有一个 Segment 数组。
  • 中层 Segment:每个 Segment 内部包含一个小型 HashEntry 数组。最关键的是,**Segment 类继承自 ReentrantLock**,这使得每个分段本身就是一把锁。
  • 内层 HashEntry:具体的键值对节点。

2. 写入流程(put 操作):双重定位

当你向 Map 中插入数据时,会经历两次定位过程:

  1. 定位 Segment:通过对 Key 的 hashCode 进行再哈希,计算出该数据属于哪一个 Segment。
  2. 加锁操作:调用该 Segment 的 lock() 方法。此时,只有操作同一个 Segment 的线程才会被阻塞。操作不同 Segment 的线程可以并行执行。
  3. 定位 HashEntry:在 Segment 内部的数组中找到具体的桶位置,执行插入逻辑。
  4. 释放锁:操作完成后通过 unlock() 释放。

3. 读取流程(get 操作):无锁化设计

Segment 设计的一个精妙之处在于 get 操作通常是不需要加锁的。

  • 实现原理HashEntryvalue 字段和 next 指针都使用了 volatile 关键字
  • 效果volatile 保证了内存可见性,即一个线程修改了数据,另一个线程能立刻看到最新值。这使得读取操作可以在不获取锁的情况下安全进行,极大提升了吞吐量。

4. 关键技术点:为什么是 2 的幂次方?

正如之前提到的,Segment 数组的大小(并发级别)会被调整为 2 的幂次方。

  • 掩码运算:假设 Segment 数量为 16(即 $2^4$),寻址时只需执行 (hash >>> segmentShift) & segmentMask
  • 效率:这种位运算比传统的取模运算(%)要快得多,确保了在高并发下定位 Segment 的性能损耗降到最低。

5. 总结:Segment 的本质

Segment 实际上是对锁的物理隔离

  • 它解决了 Hashtable 中“一人持锁,全家等待”的尴尬局面。
  • 限制:一旦初始化完成,Segment 的数量就固定了,这意味着它的最大并发度在整个生命周期内无法动态扩展。这也是为什么 Java 8 最终转向了基于 CAS 的桶级(Bucket-level)锁,实现了更细粒度的控制。

CAS原子操作原语

无锁软件通常依赖硬件提供的原子读取-修改-写入(Read-Modify-Write)原语。

在较老一代的 ARM64 CPU 上,原子操作使用 LL/SC 循环(Load-Link/Store-Conditional)。CPU 加载一个值并标记该地址。如果另一个线程写入了该地址,存储操作会失败,循环会重试。因为线程可以不断尝试并在不等待其他线程的情况下成功,所以这一操作是无锁的。

1
2
3
4
5
6
asmretry:
ldxr x0, [x1] // 从地址 x1 独占加载到 x0
add x0, x0, #1 // 值加 1
stxr w2, x0, [x1] // 独占存储
// w2 为 0 表示成功,1 表示失败
cbnz w2, retry // 如果 w2 非零(失败),跳转到 retry

(在 Compiler Explorer 中查看)

更新的 ARM 架构(ARMv8.1)支持大型系统扩展(Large System Extensions, LSE),包括 CAS(Compare-And-Swap)和 Load-And-Add 等指令。在 Android 17 中,我们为 ART 编译器添加了 LSE 检测支持,能够在支持 LSE 的设备上生成优化指令:

1
2
3
asm// ARMv8.1 LSE 原子操作示例
ldadd x0, x1, [x2] // 原子 load-add
// 更快,无需循环

在谷歌的基准测试中,使用 CAS 的高竞争代码比 LL/SC 变体实现了约 3 倍加速

Java 语言通过 java.util.concurrent.atomic 提供原子操作原语,底层依赖这些特殊的 CPU 指令。

,