ReentrantLock 与 Synchronized 原理分析

ReentrantLock 与 Synchronized 原理分析

ReentrantLock,可重入锁,是一种递归无阻塞的同步机制。它可以等同于 synchronized 的使用,但是 ReentrantLock 提供了比 synchronized 更强大、灵活的锁机制,可以减少死锁发生的概率。

synchronized 是 Java 中的关键字,是利用锁的机制来实现同步的。


一、synchronized

synchronized 是 Java 中的关键字,是利用锁的机制来实现同步的。
锁机制有如下两种特性:

互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。

可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。

1. 实现原理

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。

Java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础:

  1. 普通同步方法,锁是当前实例对象
  2. 静态同步方法,锁是当前类的 class 对象
  3. 同步方法块,锁是括号里面的对象

通过javap工具生成对class文件信息来分析synchronized

1
2
3
monitorenter 
aload_i
monitorexit
  1. 同步代码块是使用 monitorenter 和 monitorexit 指令实现的;
  2. 同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED 实现。

同步代码块:monitorenter 指令插入到同步代码块的开始位置,monitorexit 指令插入到同步代码块的结束位置,JVM 需要保证每一个 monitorenter 都有一个 monitorexit 与之相对应。任何对象都有一个 Monitor 与之相关联,当且一个 Monitor 被持有之后,他将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁。

同步方法:synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn 指令,在 VM 字节码层面并没有任何特别的指令来实现被synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置设置为 1,表示该方法是同步方法,并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 作为锁对象

ReentrantLock的底层是借助AbstractQueuedSynchronizer实现,所以其数据结构依附于AbstractQueuedSynchronizer的数据结构

2.Java 对象头、Monitor

Java 对象头和 Monitor 是实现 synchronized 的基础!下面就这两个概念来做详细介绍。

2.1 Java对象头

synchronized用的锁是存在Java对象头里的。那么什么是 Java 对象头呢?Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中:

  • Klass Point 是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键

Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bits)。但是如果对象是数组类型,则需要三个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

2.2 Monitor

我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。每一个 Java 对象本身就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁。

  • 互斥: 一个 Monitor 锁在同一时刻只能被一个线程占用,其他线程无法占用。
  • 信号机制( signal ): 占用 Monitor 锁失败的线程会暂时放弃竞争并等待某个谓词成真(条件变量),但该条件成立后,当前线程会通过释放锁通知正在等待这个条件变量的其他线程,让其可以重新竞争锁。

Monitor Record 是线程私有的数据结构,每一个线程都有一个可用 Monitor Record 列表,同时还有一个全局的可用列表。
每一个被锁住的对象都会和一个 Monitor Record 关联(对象头的 MarkWord 中的 LockWord 指向 Monitor 的起始地址),Monitor Record 中有一个 Owner 字段,存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:

1
2
3
4
5
6
Owner
EntryQ
RcThis
Nest
HashCode
Candidate
  1. Owner:

    1. 初始时为 NULL 表示当前没有任何线程拥有该 Monitor Record。
    2. 当线程成功拥有该锁后保存线程唯一标识。
    3. 当锁被释放时又设置为 NULL 。
  2. EntryQ:关联一个系统互斥锁( semaphore ),阻塞所有试图锁住 Monitor Record失败的线程 。

  3. RcThis:表示 blocked 或 waiting 在该 Monitor Record 上的所有线程的个数。
  4. Nest:用来实现重入锁的计数。
  5. HashCode:保存从对象头拷贝过来的 HashCode 值(可能还包含 GC age )。
  6. Candidate:用来避免不必要的阻塞或等待线程唤醒。因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate 只有两种可能的值 :1)0 表示没有需要唤醒的线程;2)1 表示要唤醒一个继任线程来竞争锁。

3. 锁优化

3.1 自旋锁

由来

线程的阻塞和唤醒,需要 CPU 从用户态转为核心态。频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时,我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

定义

所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。

怎么等待呢?执行一段无意义的循环即可(自旋)。

3.1.1 适应自旋锁

JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

3.2 锁消除

由来

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制。但是,在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。

定义

锁消除的依据是逃逸分析的数据支持。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐性的加锁操作。比如 StringBuffer 的append(..)方法,Vector 的add(...)

3.3 锁粗化

由来

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小:仅在共享数据的实际作用域中才进行同步。这样做的目的,是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数的情况下,上述观点是正确的,但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。

定义

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

如下面实例:vector 每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。

1
2
3
4
5
6
7
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for (int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}

3.4 锁的升级

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。它们会随着竞争的激烈而逐渐升级。注意,锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

3.4.1 重量级锁

重量级锁通过对象内部的监视器(Monitor)实现。

其中,Monitor 的本质是,依赖于底层操作系统的 Mutex Lock 实现。操作系统实现线程之间的切换,需要从用户态到内核态的切换,切换成本非常高。

3.4.2 轻量级锁

引入轻量级锁的主要目的,是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

当关闭偏向锁功能或者多个线程竞争偏向锁,导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

获取锁

  1. 判断当前对象是否处于无锁状态?
    • 若是,则 JVM 首先将在当前线程的栈帧中,建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word的 拷贝(官方把这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word)
    • 否则,执行步骤(3);
  2. JVM 利用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指正
    • 如果成功,表示竞争到锁,则将锁标志位变成 00(表示此对象处于轻量级锁状态),执行同步操作
    • 如果失败,则执行步骤(3);
  3. 判断当前对象的 Mark Word 是否指向当前线程的栈帧?
    • 如果是,则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;
    • 否则,只能说明该锁对象已经被其他线程抢占了,当前线程便尝试使用自旋来获取锁。若自旋后没有获得锁,此时轻量级锁会升级为重量级锁,锁标志位变成 10,当前线程会被阻塞。

释放锁

轻量级锁的释放也是通过 CAS 操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在 Displaced Mark Word 中 数据。
  2. 使用 CAS 操作将取出的数据替换当前对象的 Mark Word 中。如果成功,则说明释放锁成功;否则,执行(3)。
  3. 如果 CAS 操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是:“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”。如果打破这个依据则除了互斥的开销外,还有额外的 CAS 操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

3.4.3 偏向锁

引入偏向锁主要目的是:为了在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。

上面提到了轻量级锁的加锁解锁操作,是需要依赖多次 CAS 原子指令的。那么偏向锁是如何来减少不必要的 CAS 操作呢?我们可以查看 Mark Word 的数据结构就明白了。

偏向锁时 Mark Word 的数据结构为:线程 ID、Epoch( 偏向锁的时间戳 )、对象分带年龄、是否是偏向锁( 1 )、锁标识位( 01 )

只需要检查是否为偏向锁、锁标识为以及 ThreadID 即可,处理流程如下:

获取偏向锁

  1. 检测 Mark Word是 否为可偏向状态,即是否为偏向锁的标识位为 1 ,锁标识位为 01 。
  2. 若为可偏向状态,则测试线程 ID 是否为当前线程 ID ?
    • 如果是,则执行步骤(5)
    • 否则,执行步骤(3)。
  3. 如果线程 ID 不为当前线程 ID ,则通过 CAS 操作竞争锁。
    • 竞争成功,则将 Mark Word 的线程 ID 替换为当前线程 ID ,则执行步骤(5)
    • 否则,执行线程(4)。
  4. 通过 CAS 竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。
  5. 执行同步代码块

撤销偏向锁

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。

偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态。
  2. 撤销偏向锁,恢复到无锁状态( 01 )或者轻量级锁的状态。
  3. 最后唤醒暂停的线程。

关闭偏向锁

偏向锁在 JDK 1.6 以上,默认开启。开启后程序启动几秒后才会被激活,可使用 JVM 参数-XX:BiasedLockingStartupDelay = 0来关闭延迟。

如果确定锁通常处于竞争状态,则可通过JVM参数-XX:-UseBiasedLocking=false关闭偏向锁,那么默认会进入轻量级锁。


二、ReentrantLock

1. 简介

一个可重入的互斥锁定 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁定相同的一些基本行为和语义,但功能更强大。ReentrantLock 将由最近成功获得锁定,并且还没有释放该锁定的线程所拥有。当锁定没有被另一个线程所拥有时,调用 lock 的线程将成功获取该锁定并返回。如果当前线程已经拥有该锁定,此方法将立即返回。可以使用isHeldByCurrentThread()getHoldCount()方法来检查此情况是否发生。

ReentrantLock 还提供了公平锁和非公平锁的选择,通过构造方法接受一个可选的 fair 参数(默认非公平锁):当设置为 true 时,表示公平锁;否则为非公平锁。

公平锁与非公平锁的区别在于,公平锁的锁获取是有顺序的。但是公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

ReentrantLock 整体结构:

  • ReentrantLock 实现 Lock 接口,基于内部的 Sync 实现。
  • Sync 实现 AQS ,提供了 FairSync 和 NonFairSync 两种实现。

2. Sync 抽象类

Sync 是 ReentrantLock 的内部静态类,实现 AbstractQueuedSynchronizer 抽象类,同步器抽象类。它使用 AQS 的 state 字段,来表示当前锁的持有数量,从而实现可重入的特性。

2.1 lock

1
2
3
4
5
/**
* Performs {@link Lock#lock}. The main reason for subclassing
* is to allow fast path for nonfair version.
*/
abstract void lock();

执行锁。抽象了该方法的原因是,允许子类实现快速获得非公平锁的逻辑。

2.2 nonfairTryAcquire

nonfairTryAcquire(int acquires)方法,非公平锁的方式获得锁。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final boolean nonfairTryAcquire(int acquires) {
//当前线程
final Thread current = Thread.currentThread();
//获取同步状态
int c = getState();
//state == 0,表示没有该锁处于空闲状态
if (c == 0) {
//获取锁成功,设置为当前线程所有
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//线程重入
//判断锁持有的线程是否为当前线程
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}

该方法主要逻辑:首先判断同步状态 state == 0 ?

  • 如果是,表示该锁还没有被线程持有,直接通过CAS获取同步状态。
    • 如果成功,返回 true 。
    • 否则,返回 false 。
  • 如果不是,则判断当前线程是否为获取锁的线程?
    • 如果是,则获取锁,成功返回 true 。成功获取锁的线程,再次获取锁,这是增加了同步状态 state 。通过这里的实现,我们可以看到上面提到的 “它使用 AQS 的 state 字段,来表示当前锁的持有数量,从而实现可重入的特性”。
    • 否则,返回 false 。

理论来说,这个方法应该在子类 FairSync 中实现,但是为什么会在这里呢?在下文的 ReentrantLock.tryLock() 中,详细解析。

2.3 tryRelease

tryRelease(int releases)实现方法,释放锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected final boolean tryRelease(int releases) {
// 减掉releases
int c = getState() - releases;
// 如果释放的不是持有锁的线程,抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// state == 0 表示已经释放完全了,其他线程可以获取同步状态了
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}

  • 通过判断判断是否为获得到锁的线程,保证该方法线程安全。
  • 只有当同步状态彻底释放后,该方法才会返回 true 。当state == 0时,则将锁持有线程设置为 null ,free = true,表示释放成功。

从这些方法中,我们可以看到,ReentrantLock 是独占获取同步状态的模式。

3. Sync 实现类

3.1 NonfairSync

NonfairSync 是 ReentrantLock 的内部静态类,实现 Sync 抽象类,非公平锁实现类。

3.1.1 lock

lock()实现方法,首先基于 AQS state 进行 CAS 操作,将 0 => 1 。

  • 若成功,则获取锁成功。
  • 若失败,执行 AQS 的正常的同步状态获取逻辑。

代码如下:

1
2
3
4
5
6
7
@Override
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

3.1.2 tryAcquire

tryAcquire(int acquires)实现方法,非公平的方式,获得同步状态。代码如下:

protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}

直接调用nonfairTryAcquire(int acquires)方法,非公平锁的方式获得锁。

3.2 FairSync

FairSync 是 ReentrantLock 的内部静态类,实现 Sync 抽象类,公平锁实现类。

3.2.1 lock

lock()实现方法,代码如下:

1
2
3
final void lock() {
acquire(1);
}

直接执行 AQS 的正常的同步状态获取逻辑。

3.2.2 tryAcquire

tryAcquire(int acquires)实现方法,公平的方式,获得同步状态。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && // <1>
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}

比较非公平锁和公平锁获取同步状态的过程,会发现两者唯一的区别就在于:公平锁在获取同步状态时多了一个限制条件 <1> 处的hasQueuedPredecessors()方法,是否有前序节点,即自己不是首个等待获取同步状态的节点

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// AbstractQueuedSynchronizer.java
public final boolean hasQueuedPredecessors() {
Node t = tail; //尾节点
Node h = head; //头节点
Node s;

//头节点 != 尾节点
//同步队列第一个节点不为null
//当前线程是同步队列第一个节点
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}

该方法主要做一件事情:主要是判断当前线程是否位于 CLH 同步队列中的第一个。如果是则返回 true ,否则返回 false 。

4. Lock 接口

java.util.concurrent.locks.Lock接口,定义方法如下:

1
2
3
4
5
6
void lock();  //获取当前锁后返回
void lockInterruptibly() throws InterruptedException; //获取锁对过程中可以中断
boolean tryLock(); //非阻塞对获取锁,如果能够获取就返回true,如果不能返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //超时的获取锁 1.在时间内成功获取锁2.超时返回3.被中断
void unlock(); //释放锁
Condition newCondition(); //获取等待组件,该组件和当前的锁绑定,只有当前线程获取了锁,才能调用wait()方法,调用后当前线程的锁被释放

5. ReentrantLock

java.util.concurrent.locks.ReentrantLock,实现 Lock 接口,重入锁。

ReentrantLock 的实现方法,基本是对 Sync 的调用(通过委托的方式)。

5.1 构造方法

1
2
3
4
5
6
7
public ReentrantLock() {
sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

基于fair参数,创建 FairSync 还是 NonfairSync 对象。

5.2 lock

1
2
3
4
@Override
public void lock() {
sync.lock();
}

5.3 lockInterruptibly

1
2
3
4
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}

5.4 tryLock

  • tryLock()实现方法,在实现时,希望能快速的获得是否能够获得到锁,因此即使在设置为 fair = true ( 使用公平锁 ),依然调用 Sync#nonfairTryAcquire(int acquires) 方法。
  • 如果真的希望tryLock()还是按照是否公平锁的方式来,可以调用tryLock(0, TimeUnit)方法来实现。

5.5 tryLock(0, TimeUnit)

1
2
3
4
5
@Override
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}

5.6 unlock

1
2
3
4
5

@Override
public void unlock() {
sync.release(1);
}

5.7 newCondition

1
2
3
4
@Override
public Condition newCondition() {
return sync.newCondition();
}

三、总结

  1. 与 synchronized 相比,ReentrantLock提供了更多,更加全面的功能,具备更强的扩展性。例如:时间锁等候,可中断锁等候,锁投票。
  2. ReentrantLock 还提供了条件 Condition ,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,ReentrantLock 更加适合(以后会阐述Condition)。
  3. ReentrantLock 提供了可轮询的锁请求。它会尝试着去获取锁,如果成功则继续,否则可以等到下次运行时处理,而 synchronized 则一旦进入锁请求要么成功要么阻塞,所以相比 synchronized 而言,ReentrantLock会不容易产生死锁些。
  4. ReentrantLock 支持更加灵活的同步代码块,但是使用 synchronized 时,只能在同一个 synchronized 块结构中获取和释放。注意,ReentrantLock 的锁释放一定要在 finally 中处理,否则可能会产生严重的后果。
  5. ReentrantLock 支持中断处理,且性能较 synchronized 会好些。
#

评论

`
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×