synchronized关键字解析

synchronized关键字解析

synchronized关键字的作用

synchronized是Java在语言层面提供的一个用于线程并发同步的关键字。它可以应用在三个方面。

  • 修饰实例方法:作用于当前实例对象,对当前实例对象加锁,进入同步代码前需要获取当前实例的锁。
  • 修饰静态方法:作用于当前类对象(XXX.class对象),进入同步代码前需要获取当前类对象的锁。
  • 修饰代码块:作用域指定的对象,进入代码前需要获取指定对象的锁。

修饰实例方法

如下例程:

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
public class SynchronizedTest implements Runnable {

private static int i = 0;

private synchronized void addOne() {
// i++ 不具备原子性,需要锁来保护
i++;
}

public void run() {
for (int i = 0;i < 100000;i++) {
addOne();
}
}


public static void main(String[] args) {
SynchronizedTest st = new SynchronizedTest();
Thread t1 = new Thread(st);
Thread t2 = new Thread(st);

t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println(i);
}
}

类SynchronizedTest的静态变量i是共享资源。对其的操作**i++**并不具有原子性,操作分三步:1. 取i原值;2. 加一;3. 存入新值。如果两个线程同时取了i值,同时加1,然后先后存入,就会导致i实际上只加了一次,存在线程安全问题。因此用synchronized关键字对addOne方法进行了修饰。确保同一时间只能有一个线程对i进行自增操作。

加上synchronized关键字得到正确的结果

注意:synchronized关键字修饰的是实例方法,这种场景下,锁住的是当前对象,也就是this。此时,其他线程无法在访问该对象的其他synchronized方法。

因此,下面这种场景下,是无法得到正确的值的。因为,虽然都是用synchronized修饰了addOne方法,但是他们锁住的是两个不同的SynchronizedTest对象。造成i结果互相干扰,每次执行结果不定。

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
public class SynchronizedTest implements Runnable {

private static int i = 0;

private synchronized void addOne() {
i++;
}

public void run() {
for (int i = 0;i < 100000;i++) {
addOne();
}
}


public static void main(String[] args) {
// 两个不同的实例对象,锁不住
Thread t1 = new Thread(new SynchronizedTest());
Thread t2 = new Thread(new SynchronizedTest());

t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println(i);
}
}

修饰静态方法

静态方法是属于类而不是属于实例的。因此,synchronized修饰的静态方法锁定的是这个类对象。因此,上个章节的问题,就可以通过在方法addOne前面加上static修饰符解决。无论new出来多少个Synchronized对象,它的类对象始终只有一个,就不会存在线程安全的问题了。

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
public class SynchronizedTest implements Runnable {

private static int i = 0;

// 这里加上static修饰符
private static synchronized void addOne() {
i++;
}

public void run() {
for (int i = 0;i < 100000;i++) {
addOne();
}
}


public static void main(String[] args) {
Thread t1 = new Thread(new SynchronizedTest());
Thread t2 = new Thread(new SynchronizedTest());

t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println(i);
}
}

如果当前线程T正在访问同步静态方法,那么其他的线程是否可以访问,这个类的非静态同步方法呢?答案是肯定的。因为,静态同步方法锁住的是类对象(XXX.class),而非静态同步方法锁住的是当前对象,是两个不同的对象,二者互不干涉。

修饰代码块

Java允许对一小块代码使用synchronized关键字。此时可以使用任何对象充当被锁的对象。

使用当前实例对象充当锁

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
public class SynchronizedTest implements Runnable {
private static int i = 0;
private void addOne() {
synchronized(this) {
i++;
}
}

public void run() {
for (int i = 0;i < 100000;i++) {
addOne();
}
}

public static void main(String[] args) {
SynchronizedTest st = new SynchronizedTest();
Thread t1 = new Thread(st);
Thread t2 = new Thread(st);

t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println(i);
}
}

使用类对象充当锁

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
public class SynchronizedTest implements Runnable {
private static int i = 0;
private void addOne() {
synchronized(SynchronizedTest.class) {
i++;
}
}

public void run() {
for (int i = 0;i < 100000;i++) {
addOne();
}
}


public static void main(String[] args) {
Thread t1 = new Thread(new SynchronizedTest());
Thread t2 = new Thread(new SynchronizedTest());

t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println(i);
}
}

上面两段代码块的运行结果是一致的,都是20000.

synchronized关键字的原理

我们先把synchronized关键字修饰的相关代码反编译一下:

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
public class SynchronizedTest {
private void addOne() {
System.out.println("Start ");

synchronized(this) {
i++;
}
}

public static synchronized void staticMethod() throws InterruptedException {
System.out.println("静态同步方法开始");
Thread.sleep(1000);
System.out.println("静态同步方法结束");
}
public synchronized void method() throws InterruptedException {
System.out.println("实例同步方法开始");
Thread.sleep(1000);
System.out.println("实例同步方法结束");
}
public synchronized void method2() throws InterruptedException {
System.out.println("实例同步方法2开始");
Thread.sleep(3000);
System.out.println("实例同步方法2结束");
}
public static void main(String[] args) {
final SynchronizedTest synDemo = new SynchronizedTest();
Thread thread1 = new Thread(() -> {
try {
synDemo.method();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
try {
synDemo.method2();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread1.start();
thread2.start();
}
}

首先将上述源文件编译为.class文件,执行命令 javac SynchronizedTest.java即可。获取到SynchronizedTest.class字节码文件之后,再执行javap -v SynchronizedTest命令,反编译字节码文件,得到如下内容

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
void addOne();
descriptor: ()V
flags:
Code:
stack=2, locals=2, args_size=1
0: getstatic #20 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #26 // String Start
5: invokevirtual #28 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: dup
10: astore_1
11: monitorenter
12: getstatic #10 // Field i:I
15: iconst_1
16: iadd
17: putstatic #10 // Field i:I
20: aload_1
21: monitorexit
22: goto 28
25: aload_1
26: monitorexit
27: athrow
28: return

public static synchronized void staticMethod() throws java.lang.InterruptedException;
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Exceptions:
throws java.lang.InterruptedException

public synchronized void method() throws java.lang.InterruptedException;
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Exceptions:
throws java.lang.InterruptedException

从上面反编译的内容可以发现,同步方法都会有一个ACC_SYNCHRONIZED的flag.而synchronized修饰的代码块反编译出来的指令中多了两条monitorenter和monitorexit指令。它们作用是进入和退出管程,Java虚拟机就是靠管程对象,实现同步的。

使用java虚拟机规范中的一句话来解释可能更加浅显易懂

1
Synchronization in the Java Virtual Machine is implemented by monitor entry and exit, either explicitly (by use of the monitorenter and monitorexit instructions) or implicitly (by the method invocation and return instructions). For code written in the Java programming language, perhaps the most common form of synchronization is the synchronized method. A synchronized method is not normally implemented using monitorenter and monitorexit. Rather, it is simply distinguished in the run-time constant pool by the ACC_SYNCHRONIZED flag, which is checked by the method invocation instructions (§2.11.10).

大意就是

  • 在Java虚拟机中,同步的实现是通过管程的进入和退出实现的。要么显式地通过monitorenter和monitorexit指令实现,要么隐式地通过方法调用和返回指令实现。
  • 对于Java代码来说,最常用的同步实现就是同步方法。其中同步代码块是通过使用monitorenter和monitorexit实现的,而同步方法确实使用ACC_SYNCHRONIZED标记符隐式地实现,原理是通过方法调用指令检查该方法在常量池中是否包含ACC_SYNCHRONIZED标记符。

如果有设置该值,则需要先获取管程的锁,然后开始执行方法,方法执行之后在释放管程的锁,这时候如果其他线程来请求执行该方法,会因无法获得管程锁而被阻塞住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常跑到方法外面之后,管程的锁将会被自动释放

monitorenter

1
2
3
4
5
6
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.

If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.

If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

大意是:每个对象都关联了一个管程。一个管程如果已经拥有了一个所有者,那么就被视为锁住了。以下是三种线程执行指令monitorenter来获取对象的管程拥有权的场景及其结果。

  • 对象关联的管程引用计数是0,那么当前线程获取该管程的所有权,并将计数设置为1
  • 如果当前线程已经是该管程的所有者,那么重新进入管程,并将计数自增1
  • 如果其他线程已经拥有了某个对象的管程所有权(即管程引用计数不唯一,并且所有者为其他线程)。当前线程再去获取的话,就会阻塞,直到该管程的引用计数为0才可能获取到所有权。

monitorexit

1
2
3
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.

The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

大意是:只有管程的所有者才可以执行monitorexit指令。当线程减少管程的引用计数直至0时,此线程就退出了管程,也就不再是该管程的所有者了。此时,其他被阻塞的线程就可以尝试着去获取这个管程了。

monitorenter流程

Java对象头

在理解管程之前,需要了解一下Java的对象头相关知识。

Java对象保存在内存中,主要以以下三部分组成:

  • 对象头
  • 实例数据
  • 对齐填充数据

对象头

对象头由以下三个部分组成:

  • Mark Word:用于存储对象自身运行时的数据,它是实现轻量级锁和偏向锁的关键
  • 类型指针:是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
  • 数组长度(如果是数组对象的话)

Mark Word

虚拟机位数 头对象结构 说明
32/64位 Mark Word 存储对象自身的运行时数据,如hash码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间里的25位用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,表示非偏向锁。其他状态如下图所示:

Mark Word示例

依据这张图,就可以更好地说明synchronized的锁升级流程了。众所周知,Java中锁升级是从无锁->偏向锁->轻量级锁->重量级锁,并且只能升级,不能降级。

下面就来结合mark word 来梳理下这个流程。

  1. 当Mark Word 没有被当做是锁时,就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否为偏向锁那一位是0;
  2. 当对象被当做同步锁并有一个线程A抢到了锁时,锁的标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态;
  3. 当线程A再次试图来获取锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,并且记录的线程id就是自己的id,那么线程A就继续执行同步的代码;
  4. 当线程B试图获取这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中记录的线程id不是B的id,那么线程B就会先尝试着用CAS操作获取锁。这里获取锁的操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果获取锁成功,就把Mark Word中的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步代码。如果去获取锁失败,则执行步骤5;
  5. 偏向锁状态获取锁失败,代表当前锁存在一定的竞争,偏向锁将升级为轻量级锁。JVM会在线程B的线程栈中开辟一块独立的空间,里面保存指向对象所Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个都是CAS操作,如果保存成功,代表线程B抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6;
  6. 轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不停地重试,尝试抢锁。从Java 1.7开始,自旋锁默认是启用的,自选次数由JVM决定(适应性自旋锁)。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7;
  7. 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10,锁指针就指向monitor的起始地址。在这个状态下,未抢到锁的线程都会被阻塞。

实例数据

对象的实例数据就是在java代码中能看到的属性和他们的值。

对齐填充数据

因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。

管程

管程(monitor)可以被理解为是一种同步工具,或者是同步机制,它通常被描述为一个对象。操作系统的管程是概念原理,ObjectMonitor是它的原理实现。

操作系统管程

  • 管程是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
  • 这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。
  • 与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。
  • 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

monitor

ObjectMonitor

ObjectMonitor主要数据结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

ObjectMonitor主要字段释义如下:

monitor

ObjectMonitor中有两个队列,_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程。

当多个线程同时访问一段同步代码时,首先会进入_EntryList,当线程获取到对象的monitor后,进入owner区域,并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count自增1.

如果线程调用wait方法,将释放当前持有的monitor,owner变量恢复为null,计数器count自减1,同时该线程进入Waitset中等待被唤醒。

如果其他线程调用 notify() / notifyAll() ,会唤醒_WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入_Owner区域。

若当前线程也执行完毕,也会释放monitor并复位变量的值,以便其他线程进入获取monitor。如下图所示:

monitor

对象与monitor关联

monitor

参考资料

深入理解Java并发之synchronized实现原理

Synchronized解析——如果你愿意一层一层剥开我的心

0%