synchronized详解
liduoan.efls Engineer

同步器

多线程编程中,可能出现多个线程同时访问同一个共享,可变资源的情况,这个资源我们称之为临界资源。

共享:资源可以由多个线程同时访问

可变:资源可以在其生命周期内被修改

由此,我们可以引出一个问题:

由于线程执行的过程是不可控的,所以需要采用同步机制来协同对象可变状态的访问。

常见的解决线程并发安全问题的方式是序列化访问临界资源

即在同一时刻只允许一个线程访问临界资源,也称作为同步互斥访问。

Java中提供了两种方式来实现同步互斥访问:synchronized Lock

同步器的本质就是加锁,为了序列化访问临界资源

synchronized原理详解

synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。

加锁的方式:

1、同步实例方法,锁是当前实例对象

2、同步类方法,锁是当前类对象

3、同步代码块,锁是括号里面的对象

Monitor监视器锁

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。

Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

synchronized关键词的JVM指令

使用synchronized修饰代码块时,字节码后会被翻译成monitorentermonitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置:

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
// class version 51.0 (51)
// access flags 0x21
public class Basic/Thread1 {
.........
// access flags 0x9
public static main([Ljava/lang/String;)V
...........
//!!!!!!!!!!!!!!
MONITORENTER
L0
LINENUMBER 38 L0
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
LDC "\u9501\u8fdb\u5165wait\u7b49\u5f85\u4e4b\u524d\u7684\u4ee3\u7801"
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L7
LINENUMBER 39 L7
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
LDC "\u9501\u8fdb\u5165wait\u7b49\u5f85\u4e4b\u540e\u7684\u4ee3\u7801"
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L8
LINENUMBER 40 L8
ALOAD 2
//!!!!!!!!!!!!!!!!!!!!!!!!
MONITOREXIT
L1
GOTO L9
L2
FRAME FULL [[Ljava/lang/String; java/lang/String java/lang/Object] [java/lang/Throwable]
ASTORE 3
ALOAD 2
//!!!!!!!!!!!!!!!!!!!!!!!!
MONITOREXIT
L3
..........................
}

这两条指令对于不同的锁会有不同的操作。

image

monitorenter

每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;

3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

monitorexit

执行monitorexit的线程必须是object所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异常退出释放锁

通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理

Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因

而使用synchronized修饰方法时,JVM指令如下:

image

对象上的锁

Synchronized一般有三种用法:

  1. 锁类中的普通方法,锁的是当前实例对象(this),多个实例对象对应多把锁
  2. 锁类中的静态方法,锁的是当前类的Class对象,只有唯一一把锁
  3. 锁类中普通方法的代码块,锁是synchronized()括号里面的对象,如果是静态对象只有唯一一把锁

无论是哪种用法,锁的都是对象,那么到底怎么给一个Java对象加锁呢?

首先我们都知道synchronized是依靠Monitor进行加解锁的,而Monitor对象是存在于每个Java对象的对象头Mark Word中(存储的指针的指向)。

Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。

监视器Monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。

那么有个问题来了,我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?

答案是锁状态是被记录在每个对象的对象头(Mark Word)中,下面我们一起认识一下对象的内存布局

对象头结构

之前我们也介绍过,HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域,对象头(Header)、实例数据(Instance Data)和对齐填充(Padding):image

HotSpot虚拟机的对象头包括两部分信息

其中第一部分是Mark Word,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键

这部分数据的长度在32位和64位的虚拟机中分别为32个和64个Bits

我们单以32位举例:

32位虚拟机的Mark Word

image

Mark word确定了该对象的状态,我们可以通过上图看到,锁标志位为重量级锁的时候,才指向Monitor指针。

而在无锁 偏向锁 轻量级锁时,无锁状态时分代年龄等都记录在Markword

偏向锁的时候,就是线程ID和分代年龄等等。

那么锁升级的时候,那些信息大概是保存在其他地方了。

锁升级

在Mark Word字段表中,分别有无锁,偏向锁,轻量级锁和重量级锁,下面我们分别介绍。

无锁

无锁状态对应Mark Word表中:

image

无锁非常简单,一个对象在刚刚创建的时候它就是无锁状态:

1
2
3
4
5
6
7
8
public class Juc_PrintMarkWord {

public static void main(String[] args) throws InterruptedException {
Object o = new Object();
//未出现任何获取锁的时候
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

对象头中的Mark Word内容如下:

1
2
3
4
// 无锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

锁标志为对应的是001,表示没有锁。【小端模式

偏向锁(单线程)

偏向锁,顾名思义就是偏向第一个获取到锁对象的线程,并且在运行过程中,只有一个线程会访问同步代码块,不存在多线程的场景,这种情况下加的就是偏向锁。偏向锁状态对应Mark Word表中:

image

可以看到当对象持有偏向锁时,对象头中记录了持有该偏向锁的线程ID。我们来演示一下:

1
2
3
4
5
6
7
8
9
10
11
12
public class Juc_PrintMarkWord {

public static void main(String[] args) throws InterruptedException {
Object o = new Object();
//未出现任何获取锁的时候
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
// 获取一次锁之后
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}

我们预期的结果应该是第一次打印001,第二次打印101,可是结果却如下:

1
2
3
4
5
6
7
8
9
// 第一次打印:无锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

// 第二次打印:轻量级锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 18 f8 e5 02 (00011000 11111000 11100101 00000010) (48625688)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

不对啊,为什么直接变成轻量级锁了?难道不应该是偏向锁吗?

这是由于java对于偏向锁的启动是在启动几秒之后才激活导致的,因为JVM启动的过程中会有大量的同步块,且这些同步块都有竞争,如果一启动就启动偏向锁,会出现很多没有必要的锁升级和撤销,因此JVM选择直接将其设置为轻量级锁。

我们可以通过参数-XX:BiasedLockingStartupDelay=0参数,将偏向锁启动延迟设置为0,我们再启动一次看看:

1
2
3
4
5
6
7
8
9
// 第一次打印:匿名偏向锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

// 第二次打印:偏向锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 28 05 03 (00000101 00101000 00000101 00000011) (50669573)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

可以看到,JVM一开始就将对象状态设置为偏向锁,这是因为它检测到了我们后面可能会以该对象为锁产生线程竞争。不过我们可以发现一开始,其对象头中并没有保存线程ID,因为还没有线程获取锁,我们将其称为匿名偏向锁

之后又一个线程对获取了该锁,于是该对象头中就会记录该线程的ID。另外,通过参数-XX:-UseBiasedLocking可以关闭偏向锁,默认开启。

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(一些CAS操作)的代价而引入偏向锁。

偏向锁的核心思想是:

如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时, 无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。

但是对于不止一个线程访问锁的场合(无论有没有竞争),偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,而是升级为轻量级锁,如下图所示:

image

当然,撤销偏向锁也是一个过程:需要在安全点暂停当前持有该偏向锁的线程,如果这时该线程还没有执行完同步代码块,则将锁升级为轻量级锁。

轻量级锁(多个线程交替执行)

倘若出现了多个线程,虚拟机并不会立即将锁升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时 Mark Word 的结构也变为轻量级锁的结构:

image

我们来演示一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Juc_PrintMarkWord {
public static void main(String[] args) {
Object o = new Object();
// 未出现任何获取锁的时候
System.out.println(ClassLayout.parseInstance(o).toPrintable());
// 线程1获取锁
new Thread(()->{
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();

System.out.println(ClassLayout.parseInstance(o).toPrintable());

// 线程2获取锁
new Thread(()->{
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
}
}

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 第一次输出:匿名偏向锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

// 第二次输出:偏向锁(线程1持有)
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 d8 b2 1f (00000101 11011000 10110010 00011111) (531814405)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)


// 第三次输出:偏向锁(线程1持有)
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 d8 b2 1f (00000101 11011000 10110010 00011111) (531814405)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

// 第四次输出:轻量级锁(线程2持有)
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 90 f0 94 1f (10010000 11110000 10010100 00011111) (529854608)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

可以看到,当线程2申请锁后,由于当前偏向锁中的线程ID不是自己的ID,因此升级为了轻量级锁。轻量级锁对应的Mark Word中除了锁标志位,还有一个**指向线程栈中锁记录的指针(pointer to Lock Record)**,那么这个锁记录(Lock Record)是什么?

当升级为轻量级锁后,Mark Word的结构会发生改变,原来Mark Word的内容会被拷贝至当前持有该锁的线程栈帧中的**锁记录(Lock Record)**区域,而Mark Word会变为指向这块区域的一个指针。如图所示:

image

Lock Record是线程帧栈中的一个内存区域,其中有两个重要的字段:

  • _displaced_header:存放原来锁对象Mark Word的拷贝,用于CAS操作
  • owner:指向锁对象,便于找到哪个对象被锁住了

简单来说,Mark Word中的指针用于找到哪个线程正持有该锁。如果有另一个线程要获取该轻量级锁,流程如下:

image

可以看到,轻量级锁竞争的过程,其实主要就是锁对象Mark Word中锁记录指针修改的过程,如果修改失败,那么说明该锁对象正在被其他线程持有。自旋一会,依旧不能获得锁就会升级为重量级锁。这样看,轻量级锁的情况下一旦有竞争,就会升级为重量级锁,那轻量级锁的存在有什么意义呢?

轻量级锁能够提升程序性能的依据是对绝大部分的锁,在整个同步周期内都不存在竞争,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,即产生了锁的竞争,就会导致轻量级锁膨胀为重量级锁。 膨胀过程大致如下:

image

  1. 线程2申请轻量级锁,发现锁记录指针指向线程1的栈帧,锁被线程1持有,发生了锁竞争,锁进行膨胀
  2. 线程2初始化Monitor对象,将锁记录指针修改为指向Monitor对象的指针
  3. 此时,线程1还在执行,因此Monitor的拥有者为线程1,而线程2进入等待队列
  4. 线程1执行完毕后,发现锁以及升级为重量级锁了,通过锁记录中的owner找到Monitor对象,进行重量级锁的释放流程

重量级锁(多线程存在竞争)

在轻量级锁的情况下,如果存在了多线程竞争锁,那锁就会升级为重量级锁,对象的Mark Word也会发生变化:

image

我们来演示一下重量级锁:

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
public class Juc_PrintMarkWord {
public static void main(String[] args) {
Object o = new Object();
// 未出现任何获取锁的时候
System.out.println(ClassLayout.parseInstance(o).toPrintable());
// 线程1获取锁
Thread thread1 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
//让线程晚点死亡,造成锁的竞争
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
// 线程2获取锁
Thread thread2 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
// 线程1、2同时启动,产生锁竞争
thread1.start();
thread2.start();
}
}

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 第一次输出:匿名偏向锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

// 第二次输出:偏向锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 98 e7 1f (00000101 10011000 11100111 00011111) (535271429)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

// 第三次输出:重量级锁
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) a f5 e3 1c (01011010 11110101 11100011 00011100) (484701530)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

可以看到,线程1、2同时启动后,会产生锁竞争,于是我们看到出现了重量级锁。之前我们能也介绍了,Mark Word中的指向锁记录的指针,会变成指向Monitor对象的指针。那这个Monitor对象是什么?它有什么用?

我们先来看看C++源码中对Monitor的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Monitor的结构体
ObjectMonitor::ObjectMonitor() {
_header = NULL; // 对象头
_count = 0;
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL;
_owner = NULL; // 标识拥有该monitor的线程,即持有锁的线程
_WaitSet = NULL; // 等待线程的集合,调用object.wait()方法处于wait状态的线程处于其中
// 调用object.notify()方法后会进入_cxq或_EntryList中
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 多线程竞争锁时,竞争线程首先进入的单向链表
FreeNext = NULL ;
_EntryList = NULL ; // 所有在等待获取锁的线程的集合
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

可以把Monitor理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。

与一切皆对象一样,在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,它就是实现重量级锁的关键。

所有的状态为重量级锁的对象,其对象头中的指针都指向一个Monitor,那么所有线程针对这把锁的竞争、释放都是基于这个Monitor实现的。

我们来看看在重量级锁的级别下线程对锁的竞争:

image

我们看到了一个叫做自旋的操作,什么是自旋?

锁申请失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟在我们之前介绍过的内核线程模型下,操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程循环重新申请锁。

在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起。我们可以通过下面的伪代码理解:

1
2
3
4
5
6
7
8
9
10
11
12
// 设定自旋次数为10
count = 10;
// 自旋
while(count > 0){
// 申请成功
if(加锁成功) break;
// 申请失败
count--;
// 自旋10次后仍然申请失败
if(count == 0) 阻塞线程;
}
// 执行同步代码块

当然,虽然通过自旋的方式可以在一定程度上减少用户态和内核态的切换,减少对操作系统中与线程相关的库函数调用,但是自旋的过程需要一直占用CPU,因此过度的自旋可能适得其反。

最后注意,在Java代码中,如果线程获得锁后调用object.wait()方法,则会将线程加入到Monitor对象的WaitSet中,当被object.notify()唤醒后,会将线程从WaitSet移动到 _cxq 或 _EntryList中去。

需要注意的是,当调用waitnotify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。也就是说,wait和notify等方法依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用这些方法,否则会抛出java.lang.IllegalMonitorStateException的异常。