八股-锁
锁
Synchronized
背景知识
- 每个Java对象头部都有一个MarkWord,存储着对象的运行时数据,如哈希码、锁状态信息、GC分代年龄等,锁状态信息标志了Synchronized的状态
原理
Java的同步机制是分层抽象的。
- 在语言层面,synchronized 是一个关键字
- 在JVM字节码层面,synchronized 对应的是 monitorenter 和 monitorexit 两条字节码指令,表示进入临界区,告诉JVM在此处需要加锁和解锁。
- 在JVM逻辑层面,JVM内部通过 Monitor(监视器)来管理线程的竞争和等待
- 每个锁对象都关联一个Monitor,内部通过三个逻辑区域管理线程竞争
- Owner:当前持有锁的线程
- EntryList:等待锁的阻塞线程队列
- WaitSet:调用
<font style="color:rgb(64, 64, 64);">obj.wait()</font>后进入等待状态的线程队列 // 这行可以不说
- 具体流程:
- 当线程执行 monitorenter 时,JVM会检查 锁对象 的Monitor
- 若 Owner 为空,线程成为 Owner
- 若 Owner 被占用,线程进入 EntryList 阻塞
- 当线程执行 monitorexit 时,JVM释放锁,并唤醒 EntryList 中的线程,让它们去竞争锁
- 调用 obj.wait() 的线程会释放锁并进入 WaitSet,等待 obj.notify() 唤醒 // 这行可以不说
- 当线程执行 monitorenter 时,JVM会检查 锁对象 的Monitor
- 在操作系统层面,需要从用户态切换到内核态,向操作系统底层申请互斥量mutex,性能开销很大,因此称其为重量级锁。
锁升级过程
synchronized 由于涉及到操作系统层面申请互斥量mutex,还涉及到线程的阻塞和唤醒
都需要从用户态切换到内核态,性能开销很大,因此称其为重量级锁
在JDK1.6 考虑不同竞争强度的场景 对synchronized进行了优化
无锁->偏向锁->轻量级锁(自旋锁)->重量级锁
无锁->偏向锁
最初是无锁的状态,当一个synchronized代码块被线程首次进入时,JVM会将锁标记为偏向锁,且会在锁对象头中记录下该线程的ID。如果该线程再次请求同一把锁(即重入锁),通过比较线程ID它会直接获得锁,是为了没有锁竞争且需要重入的场景而引入的。
偏向锁->轻量级锁
如果有其他线程来请求获取锁,此时偏向锁就会被撤销,两个线程会通过CAS的方式自旋尝试获取锁,获取锁后JVM会将锁标记为轻量级锁。获取锁的流程:
- 将锁对象头中的Mark Word复制到线程栈中的锁记录结构中。
- 尝试通过CAS操作将锁对象头的Mark Word的内容更新为指向锁记录的指针。如果这个更新操作成功,那么这个线程就成功获取了这个对象的轻量级锁。
轻量级锁->重量级锁
当有较多线程短时间内请求获取锁,意味着当前竞争比较激烈,而轻量级锁使用的CAS的方式会占用CPU,因此此时会发生锁膨胀,JVM将锁标记为重量级锁,重量级锁中竞争失败的锁会陷入阻塞,不会占用CPU,而重量级锁的原理:(请看上方Synchronized的原理)
总结:锁升级的过程/设计思路 其实是为了适配不同竞争强度下的场景
Lock 是接口,ReentrantLock 是其实现类,底层实现是AQS。
AQS
AQS是阻塞式锁和相关的同步器工具的框架。AQS主要由两部分组成,分别是 state属性 和 FIFO的等待队列(双向链表)。
- 用 state 属性来表示共享资源的状态(分为独占模式和共享模式),不同的子类实现中对于state的值有不同的涵义
- 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程同时访问资源。
- 使用CAS机制更新state属性 : 保证对于state值的修改的线程安全
- FIFO的等待队列(双向链表):用于存储等待获取锁的线程,因为有些线程可以抢占到锁,而有些线程无法抢占到锁,此时,没有竞争到锁的线程就会被打包为一个Node节点,按照顺序组成一个双向链表。当释放锁时,会唤醒双向链表中的第一个Node节点。
独占模式:只有一个线程能够访问资源。例<font style="color:rgb(64, 64, 64);">ReentrantLock</font> 就是独占模式的实现
共享模式:允许多个线程同时访问资源。例<font style="color:rgb(64, 64, 64);">CountDownLatch</font> 就是共享模式的实现
在不同的子类实现中,**<font style="color:rgb(64, 64, 64);">state</font>** 的含义可能不同:
- 在
<font style="color:rgb(64, 64, 64);">ReentrantLock</font>中:<font style="color:rgb(64, 64, 64);">state = 0</font>:表示当前资源没有被加锁。<font style="color:rgb(64, 64, 64);">state = 1</font>:表示当前资源已经被加锁。<font style="color:rgb(64, 64, 64);">state > 1</font>:表示当前资源已经被加了多把锁(可重入锁)。
- 在
<font style="color:rgb(64, 64, 64);">CountDownLatch</font>中:<font style="color:rgb(64, 64, 64);">state</font>表示剩余的计数。
ReentrantLock
ReentrantLock 是 Java 并发包(<font style="color:rgb(64, 64, 64);">java.util.concurrent.locks</font>)中的一个类。ReentrantLock 提供了比 synchronized 关键字更灵活的锁机制,支持 公平锁 和 非公平锁,并且可以 中断、超时 等高级特性。
ReentrantLock 和 Synchronized 的比较
| ReentrantLock | Synchronized | |
|---|---|---|
| 特性 | JDK提供的一个类 | Java内置关键字 |
| 实现层面 | 在JDK代码层面实现 | 在JVM层面(操作系统)层面实现 |
| 用法区别 | lock()和unlock()加锁解锁,非常灵活 | 一般修饰在方法/代码块上 |
| 公平锁 | 提供了公平锁和非公平锁 | 是非公平锁,不支持公平锁 |
| 可重入性 | 可重入 | 可重入 |
| 使用场景 | 适合复杂场景 | 适合简单场景 |
ReentrantLock 如何实现公平锁和非公平锁
首先解释公平和非公平的概念:
- 公平:竞争锁资源的线程 严格 按照请求的顺序来分配锁
- 非公平:竞争锁资源的线程 允许 插队来尝试抢占锁资源
ReentrantLock 默认是非公平锁
底层AQS的实现 当线程没有抢占到锁时,会将其打包为Node节点加入到 FIFO 的双向链表
在上述背景的情况下:
- 公平锁的实现:当线程在竞争锁资源时,会判断双向链表中是否有线程在等待,如果有就加入到链表的尾部进行等待。
- 非公平的实现:当线程在竞争锁资源时,不管双向链表中是否有线程在等待,都会去尝试抢占锁资源,如果抢占不到,再加入双向链表。
公平和非公平其实是相对于在双向链表中等待的线程而言的。
CAS
Compare And Swap(比较再交换):在更新前检查数据是否被修改,若未被修改则更新,否则自旋重试
具体解释:
主存中有一个共享变量,当线程想要去修改这个变量的值的时候,需要先把这个变量的值拷贝到线程的工作内存中,然后进行计算操作,得到一个新的值。然后线程带着旧值和新值再次找到主存中的共享变量。
先比较旧值和现在主存中的共享变量是否相同。
- 若相同,说明在这期间没有其他线程修改过主存中的共享变量,是线程安全的。因此把新值写入主存的共享变量中。
- 若不同,说明在这期间有其他线程修改过主存中的共享变量,不是线程安全的。因此此时需要自旋重试
- 自旋:再次取得主存中共享变量的值。回到线程的工作内存进行计算操作得到新值。再次比较旧值和主存中的共享变量的值是否相同。又回到刚刚的逻辑。直至自旋成功。
CAS 的应用场景
- AQS中大量使用到CAS来实现基本操作。
- JUC包下的原子类中(例如AtomicInteger) 大量使用到CAS + volatile来保证线程安全。
CAS 的优劣点
优点:不加锁,无锁并发保存线程安全。
缺点:自旋占用CPU。
乐观锁与悲观锁
定义和区别
乐观锁:持乐观态度,认为并发冲突的概率较低,在提交时检查数据是否被修改,若未被修改则提交,否则重试或抛出异常。
- 例如 版本号法、CAS(compare and swap),即乐观锁实现
- 应用场景:
- 适合读多写少(并发冲突的概率低),可以降低锁定带来的性能开销
- 系统能够容忍或者处理 失败的情况,因为乐观锁可能会因为并发冲突导致执行失败。
- 并发性能高
悲观锁:持悲观态度,认为并发冲突的概率高,每次操作数据时都加锁,确保线程安全。
- 例如 synchronized,即悲观锁实现
- 应用场景:
- 适合写多读少(并发冲突的概率高),避免频繁冲突导致的多次重试
- 数据一致性要求极高,如金融系统
- 并发性能低(串行化操作)
如何实现乐观锁
版本号机制:
- 为数据添加版本号字段(如 version)。
- 更新时检查版本号是否匹配,匹配则更新并递增版本号。
1 | - 在执行更新之前,先查询目标记录的版本号 |