Skip to main content

悲观锁

David LiuAbout 12 min

悲观锁

代表:synchronized 和 Lock

  • 核心是:线程占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待
  • 线程从运行到阻塞,再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
  • 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会

synchronized

是可重入锁。

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

底层原理

对象头中包含两部分:MarkWord 和 类型指针。如果是数组对象的话,对象头还有一部分是存储数组的长度。

多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行 CAS 操作。

  • synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
  • synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法。

Java 虚拟机是通过进入和退出 Monitor 对象来实现代码块同步和方法同步的,代码块同步使用的是monitorentermonitorexit 指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitoropen in new windowopen in new window实现的。每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

从上图可以总结获取 Monitor 和释放 Monitor 的流程如下:

  1. 当多个线程同时访问同步代码块时,首先会进入到 EntryList 中,然后通过 CAS 的方式尝试将 Monitor 中的 owner 字段设置为当前线程,同时 count 加 1,若发现之前的 owner 的值就是指向当前线程的,recursions 也需要加 1。如果 CAS 尝试获取锁失败,则进入到 EntryList 中。
  2. 当获取锁的线程调用wait()方法,则会将 owner 设置为 null,同时 count 减 1,recursions 减 1,当前线程加入到 WaitSet 中,等待被唤醒。
  3. 当前线程执行完同步代码块时,则会释放锁,count 减 1,recursions 减 1。当 recursions 的值为 0 时,说明线程已经释放了锁。

锁升级

Java6 对 synchronized 进行了优化。

Java 对象内存布局如下

图片

JVM 的实现 HostSpot 规定对象的起始地址必须是 8 字节的整数倍,换句话来说,现在 64 位的 OS 往外读取数据的时候一次性读取 64bit 整数倍的数据,也就是 8 个字节,所以 HotSpot 为了高效读取对象,就做了"对齐",如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。所以对齐填充区域的大小不是固定的。

锁的级别从低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

图片

偏向锁

适合:一个线程对一个锁的多次获取的情况。

偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。

引入偏向锁的目的:减少只有一个线程执行同步代码块时的性能消耗,即在没有其他线程竞争的情况下,一个线程获得了锁。

偏向锁的获取流程:

  1. 检查对象头中 Mark Word 是否为可偏向状态,如果不是则直接升级为轻量级锁。
  2. 如果是,判断 Mark Work 中的线程 ID 是否指向当前线程,如果是,则执行同步代码块。
  3. 如果不是,则进行 CAS 操作竞争锁,如果竞争到锁,则将 Mark Work 中的线程 ID 设为当前线程 ID,执行同步代码块。
  4. 如果竞争失败,升级为轻量级锁。

轻量级锁

适合:锁执行体比较简单(即减少锁粒度或时间),自旋一会儿就可以成功获取锁的情况。

之所以是轻量级,是因为它仅仅使用 CAS 进行操作,实现获取锁。

如果线程发现对象头中 Mark Word 已经存在指向自己栈帧的指针,即线程已经获得轻量级锁,那么只需要将 0 存储在自己的栈帧中(此过程称为递归加锁);在解锁的时候,如果发现锁记录的内容为 0, 那么只需要移除栈帧中的锁记录即可,而不需要更新 Mark Word。

线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录(Lock Record)的指针, 如上图所示。如果成功,当前线程获得轻量级锁,如果失败,虚拟机先检查当前对象头的 Mark Word 是否指向当前线程的栈帧,如果指向,则说明当前线程已经拥有这个对象的锁,则可以直接进入同步块执行操作,否则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数 达到界限值(threshold),轻量级锁将会膨胀为重量级锁。

重量级锁

重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗 CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。

锁消除

锁消除是指 Java 虚拟机在即时编译时,通过对运行上下的扫描(逃逸分析),消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。

例如:Vector、Stack、StringBuffer 这样的类,它们中的很多方法都是有锁的。当我们在一些不会有线程安全的情况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提高性能。

开启锁消除是在 JVM 参数上设置的,当然需要在 server 模式下:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

并且要开启逃逸分析。 逃逸分析的作用呢,就是看看变量是否有可能逃出作用域的范围,如果不会逃逸的话,就可以消除锁。

锁粗化

一般情况下,为了提高性能,总是将同步块的作用范围限制到最小,这样可以使得需要同步的操作尽可能地少。但如果一系列连续的操作一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。

如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。如下面这个经典案例。

for (int i=0; i<n; i++) {
    synchronized (lock) {
    }
}

这段代码会导致频繁地加锁和解锁,锁粗化后

synchronized (lock) {
    for (int i=0; i<n; i++) {
    }
}

ReentrantLock

图片

可以配合 Condition 条件变量

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

public class ReentrantLock implements Lock, java.io.Serializable {}

ReentrantLock 里面有一个内部类 SyncSync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

公平锁和非公平锁有什么区别?

  • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁 :锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。(锁饥饿)

ReentrantReadWriteLock

读锁、写锁

  • 写锁:独占锁 WLock

  • 读锁:共享锁 RLock

  • 共享锁 :一把锁可以被多个线程同时获得。

  • 独占锁 :一把锁只能被一个线程获得。

  • 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。

  • 读写锁进行并发控制的规则:读读共享、读写互斥、写写互斥。

死锁

读锁死锁的场景:

![截屏2023-02-02 21.57.01](https://xingqiu-tuchuang-1256524210.cos.ap-shanghai.myqcloud.com/3978/截屏2023-02-02open in new window 21.57.01.png)

写锁死锁的场景:

![截屏2023-02-02 22.00.01](https://xingqiu-tuchuang-1256524210.cos.ap-shanghai.myqcloud.com/3978/截屏2023-02-02open in new window 22.00.01.png)

读的时候不能写,写的时候可以读

线程持有读锁还能获取写锁吗?

  • 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。

过程:获取写锁 -> 获取读锁 -> 释放写锁 -> 释放读锁

必要性:

主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 假设此刻另一个线程(记作线程 T)获取了写锁并修改了数据,那么当前线程无法感知线程 T 的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程 T 将会被阻塞,直到当前线程使用数据并释放读锁之后,线程 T 才能获取写锁进行数据更新。

可能存在一个事务线程不希望自己的操作被别的线程中断,而这个事务操作可能分成多部分操作更新不同的数据(或表)甚至非常耗时。如果长时间用写锁独占,显然对于某些高响应的应用是不允许的,所以在完成部分写操作后,退而使用读锁降级,来允许响应其他进程的读操作。只有当全部事务完成后才真正释放锁。

按你的理解如果当中写锁被其他线程占用,那么这个事务线程将不得不中断等待别的写锁释放。

意义:在一边读一边写的情况下提高性能。

读锁为什么不能升级为写锁?

写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。

另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。