八股-锁

Synchronized

背景知识

  • 每个Java对象头部都有一个MarkWord,存储着对象的运行时数据,如哈希码、锁状态信息、GC分代年龄等,锁状态信息标志了Synchronized的状态

原理

Java的同步机制是分层抽象的。

  1. 在语言层面,synchronized 是一个关键字
  2. 在JVM字节码层面,synchronized 对应的是 monitorenter 和 monitorexit 两条字节码指令,表示进入临界区,告诉JVM在此处需要加锁和解锁。
  3. 在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() 唤醒 // 这行可以不说
  1. 在操作系统层面需要从用户态切换到内核态,向操作系统底层申请互斥量mutex,性能开销很大,因此称其为重量级锁

锁升级过程

synchronized 由于涉及到操作系统层面申请互斥量mutex,还涉及到线程的阻塞和唤醒

都需要从用户态切换到内核态,性能开销很大,因此称其为重量级锁

在JDK1.6 考虑不同竞争强度的场景 对synchronized进行了优化

无锁->偏向锁->轻量级锁(自旋锁)->重量级锁


无锁->偏向锁

最初是无锁的状态,当一个synchronized代码块被线程首次进入时,JVM会将锁标记为偏向锁,且会在锁对象头中记录下该线程的ID。如果该线程再次请求同一把锁(即重入锁),通过比较线程ID它会直接获得锁,是为了没有锁竞争且需要重入的场景而引入的。

偏向锁->轻量级锁

如果有其他线程来请求获取锁,此时偏向锁就会被撤销,两个线程会通过CAS的方式自旋尝试获取锁,获取锁后JVM会将锁标记为轻量级锁。获取锁的流程:

  1. 将锁对象头中的Mark Word复制到线程栈中的锁记录结构中。
  2. 尝试通过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
2
3
4
5
6
- 在执行更新之前,先查询目标记录的版本号
SELECT value, version FROM table WHERE id = 1;

- 执行更新时,检查目前的版本号和之前查到的版本号是否一致
UPDATE table SET value = new_value, version = version + 1
WHERE id = 1 AND version = current_version;