Java中的锁事
Java中提供了种类丰富的锁,定义这些锁可以在适当的场景中发挥更好的作用。
乐观锁&悲观锁
乐观锁
每次去拿取数据的时候认为不会有人进行修改,所以不会去添加锁。只是会在更新数据时去判断是否有其他线程对这个数据进行了修改。通过判断版本号检测是否发生了更新,未发生变化直接写入新数据;发生了变化,就需要重复执行读版本号-比较有无发生变化-写入新数据操作。
在Java中一般通过CAS算法实现。例如Atomic*类内部都是通过CAS实现的。
乐观锁适合读操作多的场景,不加锁可以大大提升读操作的效率。
但“乐观”与“悲观”的选择并不只是简单看读写比例,还要结合冲突概率、临界区长度以及失败重试成本。如果共享状态冲突很少、操作时间短,乐观锁往往更划算;如果冲突明确、复合操作很多,悲观锁通常更容易保证正确性边界。
CAS算法
compare and swap(比较与交换),是一种无锁算法(在不使用锁的情况下实现线程间的变量同步)。
CAS算法涉及了三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 要写入的新值 B
当且仅当V的值等于A时,CAS通过原子方式更新V的值为B。否则不会执行任何操作(比较与更新为一个原子操作),一般情况下为一个自旋操作,需要不断进行重试。
CAS的价值不只是“比较并交换”这一条指令本身,还在于它通常会配合volatile或底层内存语义一起使用,从而同时具备原子更新与结果可见性。但它依然更适合处理单个共享状态的提交,不适合直接覆盖复杂的多步骤事务逻辑。
乐观锁缺点
ABA问题
CAS需要在操作值的时候检查内存值是否发生了变化,没有发生变化才会去更新值。但有一种特殊情况,内存中的值发生了A->B->A这类变化,在检查时得到的结果就是没有发生变化,这显然是不合理的。
解决该问题的方法有两种:
- 通过在变量前面添加版本号,每次变量更新时进行版本号增加操作,可以保证监听到值的变化
- 通过
AtomicStampedReference类解决,需要检查当前引用与预期引用,当前标记与预期标记是否相同,相同则更新。
循环时间长开销大
CAS操作不成功时,默认会进行自旋操作(直到成功为止),会一直占用CPU资源造成极大的消耗。
可以通过处理器的pause指令进行解决。
pause指令有两个作用:
- 延迟流水线执行
- 避免退出循环时因为内存顺序冲突引起CPU流水线被清空
只能保证一个共享变量的原子操作
CAS只对单个变量有效,无法对多个变量同时生效。
可以通过AtomicReference来保证引用对象之间的原子性,把多个变量放于同一个对象里进行CAS操作。
悲观锁
每次去拿取数据的时候都认为别人会进行修改,所以每次在拿数据的时候都会进行上锁操作,确保数据不会被其他线程修改。在其他线程想要操作该数据时,就会被阻塞直到得到锁(共享资源每次只给一个线程使用,其他线程被阻塞,等到当前线程使用完毕后,其他线程才可以获取锁)。
可以通过
Thread.holdsLock()来获取当前线程是否持有锁。
悲观锁适合写操作多的场景,可以保证进行写操作时的数据正确。
其中Java中的synchronized及Lock就是悲观锁的具体实现。
锁的状态
在JVM中锁的状态分为四种:
- 无锁:对象当前没有处于monitor竞争状态,此时可以理解为还没有进入重量级同步路径
- 偏向锁
- 轻量级锁
- 重量级锁:锁已经膨胀为依赖monitor/互斥量的同步形态
这里要注意两点:
CAS更准确地说是一种无锁并发手段,而不是“无锁状态”本身的唯一代表。synchronized也不是一上来就一定进入重量级锁,它在不同竞争强度下可能经历偏向锁、轻量级锁,竞争再加剧时才膨胀为重量级锁。
锁的进化状态为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。不可退化
对象头
锁的每个状态都是有标记的,他们都存储于对象头的Mark Word中。
对象头分为两部分:Mark Word、Klass Pointer
Mark Word:存储对象的HashCode,分代年龄、线程ID以及锁标志信息。
其中四种锁状态主要对应在锁标志位上
| 锁状态 | 锁标志 | 存储内容 |
|---|---|---|
| 无锁 | 01 | 对象的HashCode、分代年龄、是否是偏向锁(0) |
| 偏向锁 | 01 | 偏向线程ID、偏向时间戳、分代年龄,是否是偏向锁(1) |
| 轻量级锁 | 00 | 指向栈中锁记录的指针 |
| 重量级锁 | 10 | 指向互斥量(重量级锁)的指针 |
Klass Pointer:对象指向它的类元数据指针,虚拟机通过该指针确定实例。
无锁
不会对资源进行锁定,所有线程都能访问并修改同一个资源,但是只能有一个线程修改成功在同一时间内。
无锁的特点就是修改操作在循环中进行,线程会不断的去尝试修改共享资源。如果修改成功就直接退出,否则继续循环尝试。
在已存在线程修改共享资源时,其他线程会进入自旋状态直至修改成功。
自旋锁
如果持有锁的线程能在很短的时间内就释放锁,其他需要等待竞争锁的线程就不需要在内核态和用户态之间进行切换,导致进入阻塞状态,其他线程只有执行自旋,等待执行操作的线程释放锁之后就可以去直接获取锁,避免线程切换的开销。
优缺点
自旋锁尽可能的减少阻塞发生,对于锁的竞争不激烈且不会占用锁事件过长的操作性能提升明显,自旋的消耗相对线程的切换小很多。在线程阻塞和唤醒的过程中会发生两次上下文切换过程。
上下文切换:当CPU执行从一个线程切换到另一个线程时,需要先存储当前线程的本地数据、程序指针等。然后载入另一线程的本地数据、程序指针等,然后开始执行另一个线程。
自旋锁本身是有缺点的,无法代替阻塞功能。自旋虽然避免了线程切换的开销,但是需要占用CPU。如果锁被占用的时间很短,自旋锁的效果就会非常好。
但是,如果锁被占用的时间很长,其他自旋等待的线程就会一直占用CPU资源,导致极大的浪费。这时就需要去关闭自旋锁。
默认设置自旋次数超过10次-XX:PreBlockSpin进行修改就会自动关闭并挂起当前线程。
自适应自旋锁
在1.6之前,自旋次数上限是写死的,在1.6之后引入了
自适应自旋锁,意味着自旋上限不再是固定的,而是根据 上一次同一个锁上的自旋时间以及锁拥有者的状态进行决定的。
偏向锁
一段同步代码一直被同一个线程访问,那么该线程自动获取锁,降低获取锁的代价。
适用场景
始终只有一个线程在执行同步代码块,即使没有执行完,也不会有其他线程去执行同步代码块。为了在只有一个线程执行同步代码块时提高性能。
在高并发场景下会直接禁用偏向锁,通过设置-XX:-UseBiasedLocking。关闭后,会进入轻量级锁。
另外还要注意:不同JDK版本里偏向锁的默认策略和实现背景并不完全一致,阅读旧资料时要结合版本环境理解,不要把某一版本下的默认行为当成长期不变的结论。
获取过程
- 当线程访问同步块代码并获取锁时,会在
Mark Word中存入当前线程的ID并设置偏向锁标识为1 - 再次有线程访问该代码块时,先去判断
Mark Word中偏向锁标识是否为1且线程ID是否一致 - 若一致,则执行同步代码
- 不一致时,需要通过
CAS去获取锁,如果竞争成功,修改Mark Word中的线程ID为当前线程 - 若竞争失败,说明当前还有其他线程在竞争锁。那么就需要释放偏向锁。
释放过程
偏向锁只有遇到其他线程竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。
偏向锁的释放,需要等待全局安全点(safe-point)-当前时间点没有字节码在执行,会首先暂停拥有偏向锁的线程,并判断该锁对象是否处于被锁定状态。
释放偏向锁后恢复无锁状态或进化到**轻量级锁(标记00)**状态。
轻量级锁
轻量级锁是由偏向锁升级来的,当偏向锁被另一个线程访问时,偏向锁就会升级为轻量锁。
在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁并不是用来替代传统重量级锁。
加锁过程
- 代码进入同步块的时候,如果同步对象锁状态为无锁状态(标志位01,是否为偏向锁0),在当前线程的栈帧中建立一个
Lock record用于存储Mark Word,名为Displaced Mark Word。 - 拷贝当前线程对象头的
Mark Word到Lock Record中 - 拷贝成功后,虚拟机使用
CAS操作尝试将对象的Mark Word更新为指向Lock Record指针,并将Lock Record指针指向Mark Word - 更新成功,该线程就拥有了对象的锁,并且设置
Mark Word的锁标志位为00,表明当前处于轻量级锁定状态。
如果更新失败,虚拟机首先检查
Mark Word。
- 如果
Mark Word已经指向当前线程自己的Lock Record,说明当前线程发生了重入,可以继续进入同步块。 - 如果
Mark Word指向的是其他线程的锁记录,说明已经出现竞争,轻量级锁就可能继续膨胀为重量级锁。
释放锁过程
轻量级锁解锁时,会尝试使用CAS把对象头中的Mark Word替换回原先保存在Displaced Mark Word里的内容:
- 替换成功,说明整个同步过程结束,没有发生更激烈的竞争。
- 替换失败,说明在释放锁期间已经有其他线程参与竞争,这时往往意味着锁已经膨胀,需要在释放时配合唤醒后续等待线程。
重量级锁
重量级锁本质上依赖于monitor以及更底层的互斥量机制来完成同步控制。相比偏向锁和轻量级锁,它的代价更高,因为线程竞争失败后通常不再只是忙等,而是要进入阻塞与唤醒流程。
但“重量级”不代表它没有价值。恰恰相反,当竞争已经明显、锁持有时间较长时,继续让大量线程自旋反而会更浪费CPU,此时进入重量级锁路径反而是更合理的兜底方案。
阻塞
线程在竞争重量级锁失败后,通常会进入阻塞状态,等待持有锁的线程释放资源后再被唤醒。阻塞的代价主要来自:
- 线程挂起和恢复需要更多系统参与
- 用户态与内核态切换成本更高
- 被唤醒后线程还要重新参与调度与竞争
因此自旋与阻塞并不是简单的“谁更先进”,而是在不同竞争强度下的两种权衡:锁持有时间很短时,自旋可以减少切换;锁持有时间较长时,阻塞可以避免空耗CPU。
锁优化
锁消除
锁消除是指JIT编译器在运行时发现某些同步代码不可能出现共享数据竞争,于是把这部分锁直接去掉。它成立的关键前提是:通过逃逸分析证明对象不会逃逸出当前线程。
如果一个对象只在方法内部短暂存在,没有被发布给其他线程,那么即使源码里写了同步,JVM也有机会把它优化掉。
锁粗化
锁粗化和锁消除正好相反:它不是去掉锁,而是把一串过于零碎的小同步块合并成更大的同步范围。这样做的目的,是减少频繁加锁解锁带来的额外开销。
例如循环里反复对同一对象执行小块synchronized操作时,过细的同步边界反而会比一次更大的同步范围更贵。
其他锁分类
公平锁&非公平锁
公平锁强调“先来先得”,更关注等待顺序的可预期性;非公平锁允许新来的线程在某些时机先尝试抢锁,整体吞吐通常更高。
在Java里,ReentrantLock可以显式选择公平或非公平策略,而synchronized更接近非公平语义。
可重入锁&非重入锁
可重入锁表示同一个线程在已经持有锁的前提下,可以再次获取这把锁而不被自己阻塞。synchronized和ReentrantLock都属于可重入锁。
如果一把锁不可重入,那么线程在持有锁期间再次进入同一临界区时,就可能把自己阻塞住。
独占锁&共享锁
独占锁同一时刻只允许一个线程持有,例如ReentrantLock。共享锁则允许多个线程在满足条件时同时获取资源,例如读写锁中的读锁,或者AQS共享模式下的某些同步器。
读写锁
读写锁把“读”和“写”区分成两种访问语义:
- 读读之间通常可以并发
- 写与读、写与写之间仍然互斥
因此它更适合“读多写少”的场景。在Java中典型实现就是ReentrantReadWriteLock。
死锁
两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,他们会互相阻塞。
产生死锁的条件有4个:
- 互斥条件:一个资源每次只能被一个线程占用
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:线程已获得的资源,未使用完之前不得强行剥夺
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
实践中预防死锁最常见的做法有:
- 统一加锁顺序,避免循环等待
- 缩小锁粒度,减少持锁时间
- 避免在持有锁的情况下调用外部方法或执行不可控逻辑
- 在合适场景下使用
tryLock()和超时机制
排查死锁时,最常见的手段则是导出线程dump(例如jstack),再结合“线程持有什么锁、又在等待什么锁”的关系去找循环依赖链。