avatar

目录
与synchronized相关的 Java “锁” 事

当多个线程并发访问一个共享资源时,JDK提供了synchronized关键字用以保证线程之间的同步和对资源的原子性操作,synchronized是JVM实现的一种内置锁.

1. 并发与同步

首先要厘清两个概念,即何为并发, 又何为同步?

  • 并发

并发(concurrency),按照字面意思,可以理解为并行发生。在计算机学科领域,指的是:在操作系统中,同时有多个任务达到可运行状态,经由处理机调度之后,在一段时间内所有任务可以不按顺序或者按照一定的顺序独立执行,而最终的执行结果不受干扰。任务之间的同时发生强调的是一段时间内。在维基百科上,对concurrency有这样一句定义:

IIn computer science, concurrency is the ability of different parts or units of a program, algorithm, or problem to be executed out-of-order or in partial order, without affecting the final outcome.

而在Java中,并发地执行多任务意味着在多线程环境中执行作业,而且往往涉及到对共享资源的操作。

  • 同步

与并发强调一段时间内不同的是,同步更多的强调的是某一时刻。同步指定是系统处于某一具体时刻时,当且仅有一个线程能够对共享资源进行操作而其他线程阻塞等待的情况。

2. synchronized 关键字

那么,为了实现多线程之间对共享资源的同步访问,JDK原生提供了关键字synchronized, 在JDK1.5之后还有了Lock相关的API,但是这里我们先只讨论synchronized.

synchronized可以用来修饰代码块和方法以实现同步加锁。 看下面这段代码示例:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SynchronizedDemo {

// 关键字在方法上,锁为该类的对象实例
public synchronized void method1() {
// write other code here
}

// 关键字在代码块上,锁为括号里面的对象
public void method2() {
Object o = new Object();
synchronized (o) {
// write other code here
}
}
}

编译一下上面这段代码在javap -v SynchronziedDemo 反编译一下,就可以看到这段代码将要执行的字节码指令了,如下:

sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
{
public SynchronizedDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public synchronized void method1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 6: 0

public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter
12: aload_2
13: monitorexit
14: goto 22
17: astore_3
18: aload_2
19: monitorexit
20: aload_3
21: athrow
22: return
Exception table:
from to target type
12 14 17 any
17 20 17 any
LineNumberTable:
line 10: 0
line 11: 8
line 13: 12
line 14: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 17
locals = [ class SynchronizedDemo, class java/lang/Object, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "SynchronizedDemo.java"

可以看到synchronized在修饰方法和代码块时的不同之处,在method1() 中,主要是存在ACC_SYNCHRONIZED这样一个flag, 而在 method2() 中,则存在一对指令分别是 monitorentermonitorexit .

这里又不得不说到Monitor这个监视器对象了。JVM会为每一个对象都关联一个Monitor,JVM实现同步的手段是通过进入和退出管程对象(Monitor)来实现的,其底层则是通过操作系统的Mutex Lock来实现。Monitor 这个对象是由ObjectMonitor来实现,在JVM的源码中可以通过查看ObjectMonitor.hpp 文件来了解详情。

其中的部分内容如下:

c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}

当多个线程同时访问一段同步代码时,多个线程会先被存放在 ContentionList 和 _EntryList 集合中,处于阻塞状态的线程,都会被加入到该列表。接下来当线程获取到对象的 Monitor 时,即线程成功持有底层的Mutex Lock,则该线程进行临界区中执行代码,其他线程由于无法获取到Mutex Lock,将被挂起再次进入到ContentionList.

当获得Mutex Lock的线程顺利执行完同步操作后,退出临界区将释放Mutex Lock.

如果线程调用 wait() 方法,也会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合中,等待下一次被唤醒。

总的来说,synchronized 关键字加锁是通过进入和退出锁对象的Monitor来实现同步,其底层是对操作系统提供的Mutex Lock的竞争获取和释放。

3. 锁升级

由于synchronized加锁其实是基于操作系统的Mutex Lock,而对Mutex Lock的获取与释放又必须是在内核态中进行,因此可以看出一次上锁动作涉及到了由用户态到内核态的转换,开销代价还是不小的。

使用synchronized在性能上的开销还是不小的。为了提升synchronized的性能,JDK在1.6之后引入了偏向锁,轻量级锁和重量级锁的概念,对synchronized进行了一次升级优化。

一个Java对象被synchronized修饰而成为同步锁之后,这把锁的升级与转换就均匀对象的对象头相关了。

3.1 对象头

在 JDK1.6 JVM 中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中 Java 对象头由 Mark Word、指向类的指针以及数组长度三部分组成。Mark Word 记录了对象和锁有关的信息。Mark Word 在 64 位 JVM 中的长度是 64bit,如下图所示:

对象头

锁升级主要依赖于对象头中末2bit的锁标志位和偏向锁相关的标志位。锁从偏向锁开始,伴随着对锁竞争激烈程度的上升,慢慢的一步步完成 偏向锁—> 轻量级锁 —> 重量级锁 的过程。

3.2 偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID,无需再进入 Monitor 去竞争对象了。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

3.3 轻量级锁

当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。

3.4 自旋锁

在多个线程竞争轻量级锁的过程中,只有一个线程能最终获取到锁。另外的线程则要被重新挂起处于阻塞状态。如果此时持有锁的线程很快就完成操作释放了锁,则那些被挂起的线程很快又能参与到锁的竞争中了。从这一点来看,短时间内的挂起阻塞带来的性能开销(Java线程与操作系统的线程是1:1映射关系,涉及到用户态到内核态的切换)有明显优化的空间。JVM提供了自旋锁来优化这个问题。自旋锁的使用前提是,大多数时候持有锁的线程不会长时间的持有,从而那些CAS竞争锁失败的线程可以通过不断自旋等待来避免自己被重新挂起。

3.5 重量级锁

线程的自旋等待是要消耗CPU的运算时间的,因此不可能一直自旋下去,通常会有一个自旋次数。在JDK1.7之后这个次数由JVM自己控制,在自旋超过一定次数后,如果线程争夺锁资源依然失败,那么此时锁会升级为重量级锁。

锁标志位会被改为 “10”。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中,即未抢到锁的线程会通通阻塞。

综上偏向锁 通过对比Mark Word解决加锁问题,避免执行CAS操作。而 轻量级锁 是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。 重量级锁 是将除了拥有锁的线程以外的线程都阻塞。


本文主要参考来源:

  1. 不可不说的Java “锁” 事

  2. 《Java性能调优实战第12篇——极客时间》

文章作者: JanGin
文章链接: http://jangin.github.io/2021/04/11/keyword-synchronized-in-java/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 JanGin's BLOG

评论