一、锁的起源:为什么需要锁?
1.1 问题场景
int count = 0; // 线程a 和 线程b 同时执行 count++;
count++在cpu层面是三步操作:
1. 读取 count 的值到寄存器
2. 寄存器的值 +1
3. 把结果写回内存
1.2 并发问题
线程a: 读取count=0
线程b: 读取count=0
线程a: 计算0+1=1
线程b: 计算0+1=1
线程a: 写入count=1
线程b: 写入count=1结果:count=1,而不是期望的2
核心问题:如何让一组操作不可分割地执行?
二、volatile 能解决吗?
2.1 答案:不能
volatile int count = 0; // 依然有问题 count++;
2.2 volatile 解决的是
| 特性 | 说明 |
|---|---|
| 可见性 | 一个线程修改后,其他线程立即看到 |
| 有序性 | 禁止指令重排序 |
不解决原子性 —— "读-改-写"之间,别的线程还是可以插进来。
三、cas:硬件级别的原子操作
3.1 cpu 提供的能力
cpu 提供特殊指令(x86 的 cmpxchg):
比较内存值是否等于预期值
如果相等,就把新值写入
整个过程不可被打断
这就是 compare-and-swap。
3.2 技术栈关系
cpu 提供:cas指令(硬件能力,一直都有)
↓
jvm 实现:unsafe类(java访问底层能力的桥梁)
↓
jdk 封装:locksupport、atomic类等
四、java 锁的演进历程
4.1 演进时间线
jdk 1.0:synchronized(重量级锁)
jdk 1.5:reentrantlock、cas、aqs
jdk 1.6:锁升级(偏向锁、轻量级锁)
jdk 1.8:stampedlock(乐观读)
jdk 15: 废弃偏向锁
jdk 21: 虚拟线程,synchronized 有 pinning 问题
jdk 24+:修复 synchronized 支持虚拟线程
五、jdk 1.0:synchronized 重量级锁
5.1 实现方式
synchronized (obj) {
count++;
}
早期实现:
- 直接调用操作系统的互斥量(mutex)
- 需要从用户态切换到内核态
- 让抢不到锁的线程进入阻塞状态
5.2 内核态切换的开销
为什么需要切换?
用户态(user mode):java程序运行的地方,权限受限
内核态(kernel mode):操作系统运行的地方,权限最高
阻塞线程、唤醒线程等操作需要操作系统介入,必须切换。
切换时发生了什么?
1. 保存现场
- 当前所有cpu寄存器的值
- 程序计数器(执行到哪了)
- 栈指针
→ 全部存到内存2. 切换栈
- 从用户栈切到内核栈3. 执行内核代码
- 内核的代码和数据被加载到cpu缓存
- 程序的代码和数据可能被挤出缓存4. 返回用户态
- 恢复之前保存的所有寄存器
- 切回用户栈
- 重新加载程序的代码和数据到缓存
开销量化
cpu缓存访问:约 1-10 纳秒
内存访问:约 100 纳秒一次 count++: 约 几个 cpu周期
一次内核态切换: 约 几千~几万个 cpu周期
问题:锁本身的开销 >> 临界区代码的开销
六、jdk 1.5:reentrantlock 与 aqs
6.1 改进思路
先用 cas 自旋几次(用户态,不切换)
↓
还拿不到?再调用 park 阻塞(才进内核态)
能不阻塞就不阻塞。
6.2 对比
早期 synchronized:
拿不到 → 立即阻塞 → 进内核态reentrantlock:
拿不到 → 先自旋 → 还不行再阻塞
| 特性 | thread.sleep() | object.wait() | locksupport.park() |
|---|---|---|---|
| 锁释放 | 不释放任何锁 | 释放 synchronized 监视器锁 | 不直接操作锁 |
| 唤醒方式 | 超时自动唤醒 | notify()/notifyall() | unpark(thread) |
| 唤醒目标 | 只能唤醒自己 | 随机或全部 | 精确唤醒指定线程 |
| 使用位置 | 任何地方 | 必须在 synchronized 块内 | 任何地方 |
6.4 aqs 核心结构
aqs = state 状态变量 + clh 双向队列
clh:craig, landin, and hagersten queue,三个发明者的名字。
七、jdk 1.6:synchronized 锁升级
7.1 核心思想
根据竞争激烈程度,逐步升级:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
7.2 对象头 mark word
每个 java 对象在内存中都有对象头,其中 mark word(64位)存储锁信息:
锁标志位(最后2位):
01 → 无锁 或 偏向锁(看倒数第3位区分)
00 → 轻量级锁
10 → 重量级锁详细结构:
┌─────────────────────────────────────────────────┐
│ 无锁: │ hashcode │ 分代年龄 │ 0 │ 01 │ │
│ 偏向锁: │ 线程id │ epoch │ 分代年龄 │ 1 │ 01 │ │
│ 轻量级: │ 指向栈中锁记录的指针 │ 00 │ │
│ 重量级: │ 指向monitor对象的指针 │ 10 │ │
└─────────────────────────────────────────────────┘
7.3 偏向锁
场景:只有一个线程反复访问
// 线程a 第一次进入
synchronized (lock) {
count++;
}
// 对象头写入线程a的id
// 线程a 再次进入
synchronized (lock) {
count++;
}
// 检查线程id == a?直接进入,零开销
特点:
- 加锁:比较线程id,匹配就进入
- 解锁:什么都不做,对象头保持不变
7.4 轻量级锁
场景:多个线程交替访问,无激烈竞争
触发条件: 第二个线程来访问时
// 线程a 进入
synchronized (lock) {
count++;
}
// cas 修改对象头,指向a的锁记录
// 线程a 离开
// cas 恢复对象头,还原原来的mark word
特点:
- 每次都要执行 cas 加锁和解锁
- 开销比偏向锁大,但仍在用户态
7.5 重量级锁
场景:cas 自旋多次仍失败,竞争激烈
monitor 结构:
┌─────────────────────────┐ │ owner: 当前持有锁的线程 │ │ entrylist: 阻塞等待的线程 │ ← 抢锁失败的线程排队 │ waitset: 调用wait()的线程 │ └─────────────────────────┘
7.6 锁升级流程图
lock 对象刚创建
│
▼
无锁(0 01)
│
│ 线程a第一次访问
▼
偏向锁(1 01)── 对象头记录线程a的id
│
│ 线程b来访问
▼
轻量级锁(00)── cas自旋竞争
│
│ 自旋失败,竞争激烈
▼
重量级锁(10)── 阻塞等待,进内核态
7.7 三种锁的开销对比
偏向锁: 比较id → 进入 (一次比较)
轻量级锁:cas修改指针 → 进入 (一次原子操作)
重量级锁:系统调用 → 可能阻塞 (内核态切换)
7.8 锁升级的特点
1. 单向升级,不会降级
2. 一旦出现过竞争,jvm认为后续还可能有竞争
八、jdk 15:废弃偏向锁
8.1 原因
现代应用并发程度高,单线程访问场景少
偏向锁撤销的开销 > 它带来的收益
8.2 变化
锁升级变成:
无锁 → 轻量级锁 → 重量级锁
跳过了偏向锁
九、读写锁
9.1 java 读写锁(reentrantreadwritelock)
存储位置:aqs 的 state 变量(32位 int)
┌─────────────────┬─────────────────┐
│ 高16位:读锁数量 │ 低16位:写锁数量 │
└─────────────────┴─────────────────┘
9.2 java 与数据库锁对应关系
| java | 数据库 |
|---|---|
| 读锁(read lock) | 共享锁(s锁) |
| 写锁(write lock) | 排他锁(x锁) |
9.3 兼容矩阵
读锁 写锁
读锁 兼容 ✓ 冲突 ✗
写锁 冲突 ✗ 冲突 ✗
核心:读读不冲突,其他都冲突
十、数据库锁
10.1 意向锁(intention lock)
解决的问题:
事务a:锁住某一行(行级x锁)
事务b:想锁整张表(表级x锁)
↓
事务b 需要确保表里没有任何行被锁住
↓
没有意向锁:遍历100万行检查
有意向锁:检查表级标记,直接判断
工作流程:
事务a:
1. 先给表加 ix锁(意向排他锁)
2. 再给行加 x锁事务b 想加表锁:
1. 检查表级别有没有意向锁
2. 发现有 ix锁 → 直接等待
意向锁兼容矩阵:
is ix s x is ✓ ✓ ✓ ✗ ix ✓ ✓ ✗ ✗ s ✓ ✗ ✓ ✗ x ✗ ✗ ✗ ✗
10.2 粒度锁
解决的问题:幻读
-- 表里有 id = 1, 5, 10 三行 -- 事务a select * from users where id > 3 and id < 8; -- 结果:id = 5 -- 事务b 插入新行 insert into users (id) values (6); commit; -- 事务a 再查一次 select * from users where id > 3 and id < 8; -- 结果:id = 5, 6 ← 幻读
三种锁:
| 锁类型 | 说明 | 作用 |
|---|---|---|
| 记录锁(record lock) | 锁具体的行 | 防止修改/删除 |
| 间隙锁(gap lock) | 锁行之间的空隙 | 防止插入 |
| 临键锁(next-key lock) | 记录锁 + 间隙锁 | 防止幻读 |
间隙示例:
表里有 id = 1, 5, 10
间隙:(-∞, 1) (1, 5) (5, 10) (10, +∞)
查询 id > 3 and id < 8 时:
锁住间隙 (1, 5) 和 (5, 10)
↓
插入 id = 6 被阻塞
十一、stampedlock(jdk 1.8)
11.1 解决的问题
reentrantreadwritelock 问题:
1. 读锁和写锁互斥,大量读时写线程饥饿
2. 即使只是读,也要加锁
11.2 乐观读
stampedlock lock = new stampedlock();
long stamp = lock.tryoptimisticread(); // 获取版本号,不加锁
int x = this.x; // 读数据
int y = this.y;
if (!lock.validate(stamp)) { // 验证版本号
// 版本变了,升级为悲观读锁
stamp = lock.readlock();
try {
x = this.x;
y = this.y;
} finally {
lock.unlockread(stamp);
}
}
11.3 三种模式
| 模式 | 方法 | 说明 |
|---|---|---|
| 乐观读 | tryoptimisticread() | 不加锁,性能最好 |
| 悲观读 | readlock() | 和读写锁一样 |
| 写锁 | writelock() | 排他 |
11.4 对比
| reentrantreadwritelock | stampedlock | |
|---|---|---|
| 乐观读 | ✗ | ✓ |
| 可重入 | ✓ | ✗ |
| 支持condition | ✓ | ✗ |
十二、公平锁 vs 非公平锁
| 类型 | 说明 | 性能 |
|---|---|---|
| 公平锁 | 严格按队列顺序,不允许插队 | 较低 |
| 非公平锁 | 允许插队,谁抢到谁用 | 较高 |
reentrantlock 默认是非公平的。
十三、可重入锁
概念: 同一线程可以多次获取同一把锁
实现原理: 判断锁的持有者是否是当前线程
synchronized (lock) { // 第一次获取
synchronized (lock) { // 同一线程再次获取,允许
// ...
}
}
作用: 避免同一线程自己把自己锁死
十四、jdk 21+:虚拟线程与 pinning 问题
14.1 虚拟线程原理
平台线程(os线程):重量级,数量有限
虚拟线程:轻量级,可以创建百万个┌─────────────────────────────────────┐
│ 平台线程(载体线程) │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │虚拟1│ │虚拟2│ │虚拟3│ 轮流执行 │
│ └─────┘ └─────┘ └─────┘ │
└─────────────────────────────────────┘
正常情况:
虚拟线程1 执行中,遇到阻塞
↓
jvm 把虚拟线程1 从平台线程上"卸载"
↓
平台线程去执行虚拟线程2
↓
虚拟线程1 的阻塞完成后,再"挂载"回来
14.2 pinning 问题
synchronized (lock) {
httpclient.send(request); // 网络io阻塞
}
问题流程:
虚拟线程进入 synchronized 块
↓
monitor(监视器)记录在平台线程的栈帧上
↓
遇到 io 阻塞,想卸载虚拟线程
↓
但 monitor 信息和平台线程绑定,无法卸载
↓
虚拟线程"钉住"了平台线程
↓
平台线程只能傻等
14.3 为什么 reentrantlock 没这个问题?
reentrantlock 用 java 代码实现(aqs)
↓
锁状态存在堆内存的 state 变量中
↓
不依赖平台线程的栈
↓
虚拟线程可以正常卸载
14.4 jdk 24 修复(jep 491)
重新实现 synchronized 的底层机制
↓
monitor 信息从栈上移到堆上
↓
不再和平台线程绑定
↓
虚拟线程可以正常卸载
对,你说得很准确。我更新一下总结:
十五、总结:锁的本质
两种实现路径
| 锁类型 | 争抢目标 | 存储位置 |
|---|---|---|
| synchronized | 对象头 mark word | 对象内存布局 |
| reentrantlock / stampedlock | state 状态变量 | aqs 堆内存 |
核心机制
synchronized:
cas 修改对象头 → 成功则拿到锁juc 锁:
cas 修改 state 变量 → 成功则拿到锁
演进思路
能不阻塞就不阻塞
能在用户态解决就不进内核态
根据竞争程度选择合适的锁
总结
到此这篇关于java锁机制的文章就介绍到这了,更多相关java锁机制内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论