java锁机制(CAS和synchronize)的实现原理和使用方法
一、 CAS(Compare And Swap)实现原理和用法
CAS基本概念:
CAS 是一种无锁并发技术,用于在多线程环境下实现原子操作。核心思想是:先比较内存中的值是否与预期相同,若相同则更新为新值,否则不操作。
CAS实现机制:
-
操作步骤:
• 预期值(Expected):读取当前内存值作为预期。
• 新值(New):准备写入的新值。
• 比较与交换:原子性地检查内存值是否等于预期值。若相等,则更新为新值;否则放弃操作。 -
硬件支持:
• 依赖 CPU 指令(如 x86 的CMPXCHG
指令)实现原子性操作,确保比较和交换过程不可中断。 -
代码示例(伪代码):
public boolean cas(int expected, int newValue) {if (currentValue == expected) { // 比较当前值是否为预期值currentValue = newValue; // 如果是,更新为新值return true;}return false; }
-
优缺点:
• 优点:无锁,避免线程阻塞,适合低竞争场景。
• 缺点:
◦ ABA 问题:值从 A → B → A,CAS 无法感知中间变化。解决方法:使用版本号(如AtomicStampedReference
)。
◦ 高竞争下自旋开销大:线程可能长时间循环尝试 CAS,浪费 CPU。
好的!我用一个实际的转账例子来解释这三个值,保证你彻底明白。
CAS场景模拟:银行账户转账
假设你有一个银行账户,余额是 100元。现在有两个操作同时发生:
- 你想转账 50元(余额变为
100 - 50 = 50元
)。 - 同时,银行系统自动给你账户 充值30元(余额变为
100 + 30 = 130元
)。
我们用 CAS 来保证转账和充值的原子性(避免数据错乱)。
第一步:你发起转账(操作1)
-
读取当前余额(预期值 Expected):
Expected = 100元
(你转账前看到的余额是100元) -
计算新值(New):
New = Expected - 50元 = 50元
(转账后你预期余额变成50元) -
执行 CAS 操作:
• 检查当前内存中的余额是否还是100元
(和预期值一致)。
• 如果一致:说明这段时间没有人修改余额,可以安全更新为50元
。
• 如果不一致:说明有其他操作(比如充值)修改了余额,转账失败,需要重试。
第二步:银行系统同时充值(操作2)
-
读取当前余额(预期值 Expected):
Expected = 100元
(充值前看到的余额也是100元) -
计算新值(New):
New = Expected + 30元 = 130元
(充值后余额变成130元) -
执行 CAS 操作:
• 检查当前内存中的余额是否还是100元
。
• 如果一致:更新为130元
。
• 如果不一致:充值失败,需要重试。
关键点:CAS 的原子性
两个操作同时发生,但 CAS 保证只有一个能成功,具体流程如下:
-
假设转账操作(操作1)先执行:
• 检查余额是100元
(和预期一致)→ 更新为50元
。
• 此时充值操作(操作2)的 CAS 会失败,因为当前余额已经是50元
(不再是它预期的100元
)。
• 充值操作需要重新读取当前余额(50元
),重新计算新值(50 + 30 = 80元
),再次尝试 CAS。 -
假设充值操作(操作2)先执行:
• 检查余额是100元
→ 更新为130元
。
• 转账操作(操作1)的 CAS 会失败,因为当前余额是130元
(不再是预期的100元
)。
• 转账操作需要重新读取余额(130元
),重新计算新值(130 - 50 = 80元
),再次尝试 CAS。
实际代码演示(Java)
用 AtomicInteger
模拟账户余额:
AtomicInteger balance = new AtomicInteger(100); // 初始余额100元// 操作1:转账50元(线程1)
boolean transferSuccess = balance.compareAndSet(100, // 预期值 Expected = 10050 // 新值 New = 50
);// 操作2:充值30元(线程2)
boolean rechargeSuccess = balance.compareAndSet(100, // 预期值 Expected = 100130 // 新值 New = 130
);// 最终只有一个操作成功!
System.out.println("转账是否成功:" + transferSuccess); // 可能输出 true 或 false
System.out.println("充值是否成功:" + rechargeSuccess); // 另一个输出 false
System.out.println("最终余额:" + balance.get()); // 最终余额可能是50或130,之后需要重试失败的操作
总结三个值的作用
-
预期值(Expected):你操作前看到的原始值,用来判断是否有人中途修改过数据。
• 比如转账前看到的100元
。 -
新值(New):你希望更新后的值。
• 比如转账后的50元
或充值后的130元
。 -
比较与交换:原子性地检查数据是否未被改动,只有未被改动才更新。
• 如果数据被改动,说明有其他操作介入,你的操作需要 重试或放弃。
想象你和朋友同时修改一份在线文档:
• 预期值:你打开文档时看到的内容。
• 新值:你修改后的内容。
• CAS:提交修改时,系统会检查文档是否还是你打开时的版本。如果是,更新成功;如果已经被别人修改过,系统会提示你“版本冲突,请重新编辑”。
二、 synchronized 实现原理和用法
synchronized基本概念
假设有一个公共厕所(共享资源),多个用户(线程)要使用它。为了安全,厕所门上有锁(synchronized)。
-
无锁状态:
• 厕所门开着,没人使用(对象未被任何线程锁定)。 -
偏向锁:
• 老王第一个进去,发现厕所没人,直接在门上贴了自己的名字(线程ID)。
• 以后老王再来时,直接推门进去(无需额外操作)。 -
轻量级锁:
• 老王在里面时,小李来敲门(竞争发生)。
• 老王说:“稍等,我马上出来!”(自旋等待)。
• 老王出来后,小李进去(通过 CAS 更新门锁信息)。 -
重量级锁:
• 如果厕所门口排了很多人(高竞争),大家只能排队等待。
• 管理员(操作系统)给每个人发号牌,叫号进入(线程阻塞并唤醒)。
• 锁对象:每个 Java 对象都有一个“内置锁”(类比厕所门锁)。
• Monitor 机制:JVM 通过 ObjectMonitor
(C++实现)管理锁的获取和释放。
• 锁升级:根据竞争强度,从无锁 → 偏向锁 → 轻量级锁 → 重量级锁逐步升级。
synchronized 场景示例:多线程售票系统
• 假设一个电影院有 10 张票
,由 3 个售票窗口(线程)
同时售卖。
• 必须保证每张票只能被卖出一次(线程安全)。
错误写法(未加锁):
public class TicketSeller {private int tickets = 10;public void sell() {if (tickets > 0) {System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");tickets--;}}public static void main(String[] args) {TicketSeller seller = new TicketSeller();// 启动 3 个线程卖票new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();}
}
问题:
多个线程可能同时判断 tickets > 0
,导致卖出同一张票,甚至出现负数!
正确写法(使用 synchronized
)
public class TicketSeller {private int tickets = 10;private final Object lock = new Object(); // 锁对象public void sell() {synchronized (lock) { // 对 lock 对象加锁if (tickets > 0) {System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");tickets--;}}}public static void main(String[] args) {TicketSeller seller = new TicketSeller();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();new Thread(() -> { while (seller.tickets > 0) seller.sell(); }).start();}
}
关键点:
• synchronized (lock)
表示线程必须获取 lock
对象的锁,才能执行代码块。
• 同一时间只有一个线程能持有锁,其他线程在锁外等待。
synchronized
的三种用法
1. 同步代码块(手动指定锁对象)
synchronized (任何对象) { // 需要同步的代码
}
2. 同步实例方法(锁对象是当前实例 this
)
public synchronized void method() {// 代码
}
3. 同步静态方法(锁对象是类的 Class 对象)
public static synchronized void method() {// 代码(锁的是 TicketSeller.class)
}
synchronized底层实现原理(结合售票系统)
1. 锁对象的内存布局
• 每个对象头中的 Mark Word 存储锁状态(如偏向锁、轻量级锁、重量级锁)。
• 当线程进入 synchronized
代码块时:
• 如果无竞争,JVM 使用偏向锁(记录线程ID)。
• 若有其他线程竞争,升级为轻量级锁(CAS自旋)。
• 自旋失败后,升级为重量级锁(线程阻塞,由操作系统调度)。
2. 售票系统的锁流程
- 线程A 进入
sell()
方法,检查lock
对象的锁状态。 - 如果锁空闲,线程A获取锁,修改 Mark Word 为偏向锁(记录线程A的ID)。
- 线程B 尝试进入:
• 发现锁被线程A持有,升级为轻量级锁(自旋等待)。
• 若自旋超过阈值(如 10 次),升级为重量级锁,线程B进入阻塞队列。 - 线程A释放锁后,唤醒线程B继续执行。
常见错误和注意事项
错误1:锁对象不一致
// 错误!每个线程用不同的锁对象,无法同步
public void sell() {synchronized (new Object()) { // 代码}
}
错误2:锁范围过大或过小
// 错误!锁范围太小,可能重复卖票
public void sell() {if (tickets > 0) {synchronized (lock) {tickets--;}}
}
• 核心思想:通过对象内置锁(Monitor)保证同一时间只有一个线程访问共享资源。
• 锁升级:根据竞争动态调整锁策略,平衡性能和安全性。
• 最佳实践:
• 锁对象尽量用 private final
对象(如 private final Object lock = new Object()
)。
• 同步代码块的范围要精确覆盖共享资源的读写操作。
特性 | CAS | synchronized |
---|---|---|
实现方式 | 硬件指令(如 CMPXCHG ) | 对象头、Monitor、锁升级机制 |
锁类型 | 无锁 | 偏向锁 → 轻量级锁 → 重量级锁 |
适用场景 | 低竞争,简单原子操作 | 高竞争,复杂同步代码块 |
性能开销 | 自旋消耗 CPU(高竞争下差) | 重量级锁涉及内核切换(高竞争下较好) |
ABA 问题 | 需要额外处理(如版本号) | 无 |
编程复杂度 | 需手动处理失败重试 | 自动加锁/释放,简单易用 |
三、自旋锁的概念
1. 什么是自旋?
自旋(Spinning)是一种线程等待锁的策略,当线程尝试获取锁失败时,不会立即放弃 CPU(不进入阻塞状态),而是进入一个循环,不断检查锁是否被释放,直到成功获取锁或超过一定时间。
类比:
想象你在超市的储物柜前存包:
• 自旋等待:你站在柜子前,反复尝试开锁按钮(不断检查),直到柜门打开。
• 阻塞等待:你发现柜子被占用后,先去逛超市,每隔几分钟回来检查一次(线程挂起,让出 CPU)。
2. 自旋的优缺点
• 优点:
在低竞争场景(锁很快被释放),自旋避免线程挂起和唤醒的开销(上下文切换),性能更高。
• 缺点:
在高竞争场景(锁长时间被占用),自旋会空耗 CPU 资源(循环什么也不做)。
3. 自旋的实际例子
假设有一个共享计数器,两个线程同时尝试通过自旋更新它:
public class SpinExample {private volatile int count = 0; // 使用 volatile 保证可见性public void increment() {// 自旋:循环尝试直到成功while (true) {int current = count;int newValue = current + 1;if (compareAndSet(current, newValue)) { // 伪代码:CAS操作break; // 成功则退出循环}// 失败则继续循环(自旋)}}private boolean compareAndSet(int expected, int newValue) {// 原子性操作:如果当前值等于 expected,则更新为 newValue// (此处简化逻辑,实际使用 AtomicInteger 的 CAS)if (count == expected) {count = newValue;return true;}return false;}
}
流程解析:
- 线程A 读取
count = 0
,尝试通过 CAS 将其更新为1
,成功并退出。 - 线程B 同时读取
count = 0
,也尝试 CAS 更新为1
,但发现当前值已被线程A改为1
,CAS 失败。 - 线程B 自旋:重新读取
count = 1
,计算新值2
,再次尝试 CAS,直到成功。
4. 自旋在 Java 中的应用
场景1:轻量级锁(synchronized 的锁升级)
• 自旋阶段:当线程竞争锁时,JVM 会让线程自旋几次(比如 10 次),尝试获取锁。
• 成功:获取锁,继续执行。
• 失败:锁升级为重量级锁,线程进入阻塞状态。
场景2:CAS 操作(如 AtomicInteger)
• 自旋重试:CAS 失败时,通常会配合自旋重试(比如 AtomicInteger
的 incrementAndGet()
)。
5. 自旋与阻塞的对比
行为 | 自旋(Spinning) | 阻塞(Blocking) |
---|---|---|
线程状态 | 保持运行状态(Running) | 挂起(Waiting) |
CPU 占用 | 占用 CPU(空循环) | 不占用 CPU(让给其他线程) |
适用场景 | 锁很快被释放(低竞争) | 锁长时间被占用(高竞争) |
开销 | 无上下文切换开销 | 有上下文切换开销 |
6. 自旋的优化:适应性自旋
现代 JVM 会根据历史自旋成功率动态调整自旋次数:
• 成功率高 → 增加自旋次数。
• 成功率低 → 减少自旋次数或直接阻塞。
7. 自旋锁总结
• 自旋的本质:通过循环不断尝试获取锁,避免线程挂起。
• 适用场景:适合锁竞争不激烈、锁持有时间短的场景(如简单的计数器更新)。
• 注意事项:高竞争场景需谨慎使用,避免浪费 CPU。
四、 ConcurrentHashMap 的实现原理(JDK 1.8)
1. 核心思想:用最小的锁实现线程安全
想象一个储物柜大厅,多个用户(线程)要存取物品。ConcurrentHashMap 的设计就像:
• 每个储物柜(桶)独立上锁:你操作 1 号柜子时,不影响别人操作 2 号柜子。
• 无锁化尝试:如果柜子没人用,直接用指纹(CAS)开锁;如果冲突了,才用钥匙(synchronized)。
2. 底层结构:数组 + 链表/红黑树
// 类似这样的结构(简化版)
Node[] table = new Node[16]; // 初始桶数组
• 每个桶(table[i]
)可能存三种数据:
• 空:直接通过 CAS 插入新节点。
• 链表:节点数量 < 8,用链表存储。
• 红黑树:节点数量 ≥ 8,转成树(防止链表过长,查询变慢)。
3. PUT 操作流程(重点!)
以两个线程同时存数据为例:
场景:线程 A 存 Key="apple"
, 线程 B 存 Key="banana"
,两个 Key 哈希到同一个桶。
步骤 1:计算哈希,定位桶
• 两个线程都计算哈希值,发现要操作的是桶 table[3]
。
步骤 2:无锁尝试(CAS)
• 线程 A 发现桶 table[3]
为空,尝试用 CAS 写入新节点(Node("apple")
)。
if (CAS(table[3], null, new Node("apple"))) {// 写入成功!
}
• 如果成功:直接返回,无需加锁。
• 如果失败:说明其他线程已修改该桶(比如线程 B 抢先写入),进入步骤 3。
步骤 3:加锁操作(synchronized)
• 线程 A 对桶 table[3]
的头节点加锁(synchronized (头节点)
)。
synchronized (headNode) {// 遍历链表或树,插入或更新节点
}
• 此时线程 B 如果也想操作该桶,必须等待线程 A 释放锁。
步骤 4:处理链表/树
• 插入新节点或更新值。
• 如果链表长度超过 8,转换为红黑树。
4. 线程安全的 Size 计算
直接全局加锁统计大小会非常慢,ConcurrentHashMap 用了分段计数:
• baseCount:基础计数器,通过 CAS 更新。
• CounterCell[]:当多个线程同时修改时,每个线程更新自己的 CounterCell,减少竞争。
// 最终 size = baseCount + CounterCell[0] + CounterCell[1] + ...
类比:
• 超市收银台统计营业额:
• baseCount
:主收银台的总金额。
• CounterCell[]
:高峰时段开放多个临时收银台,各自统计后再汇总。
5. 为什么用 synchronized 而不是 ReentrantLock?
• 性能优化:JDK1.8 的 synchronized 经过锁升级优化(偏向锁 → 轻量级锁 → 重量级锁),性能接近 ReentrantLock。
• 代码简洁:synchronized 由 JVM 直接支持,无需手动释放锁。