JMM(三):浅谈synchronized关键字

多线程并发的核心问题在于如何控制同步,幸运的是Java语言原生地提供了一个同步手段——synchronized关键字。本文这里就来简单谈一谈synchronized关键字

abstract.png

基本使用

Java中synchronized关键字是通过使用任意一个不为null的对象作为锁来实现同步控制的。具体的,根据锁对象类型的不同可分为对象锁、类锁。前者是将类的实例对象作为锁,而后者则是将该类的Class实例作为锁。可以看到所谓类锁,本质上依然是使用对象而不是类作为锁,只不过该对象比较特殊罢了,是类的Class对象

对象锁

所谓对象锁,就是将该类的实例对象作为锁来使用。具体地,可通过以下三种姿势来使用对象锁

1. 显式地指定一个对象实例作为锁

这里我们在同步代码块中显式地指定一个实例对象lock作为锁

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
62
63
64
65
66
67
68
/**
* synchronized 对象锁: 使用同步代码代码块, 自行构造一个对象作为锁
*/
public class ObjectLockDemo1 implements Runnable {

public static ObjectLockDemo1 o1 = new ObjectLockDemo1();
public static ObjectLockDemo1 o2 = new ObjectLockDemo1();

/**
* 构造一个对象作为锁使用
*/
public List lock = new LinkedList();

public void setLock (List lock) {
this.lock = lock;
}

/**
* 使用lock对象作为锁
*/
@Override
public void run() {
synchronized (lock) {
System.out.println( Thread.currentThread().getName() + "获取到锁");
try{
Thread.sleep(500);
}catch (InterruptedException e) {
System.out.println("Error ...");
}
System.out.println( Thread.currentThread().getName() + "准备释放锁" );
}

}

public static void main(String[] args) throws InterruptedException {
System.out.println("------------- TEST 1 -------------");
// 多线程之间使用同一个锁对象
Thread threadA = new Thread(o1,"线程A");
Thread threadB = new Thread(o1,"线程B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();

// 多线程之间使用不同的锁对象
System.out.println("------------- TEST 2 -------------");
Thread threadC = new Thread(o1,"线程C");
Thread threadD = new Thread(o2,"线程D");
threadC.start();
threadD.start();
threadC.join();
threadD.join();

// 多线程之间使用同一个锁对象
System.out.println("------------- TEST 3 -------------");
List newLock = new LinkedList();
o1.setLock(newLock);
o2.setLock(newLock);

Thread threadM = new Thread(o1,"线程M");
Thread threadN = new Thread(o2,"线程N");
threadM.start();
threadN.start();
threadM.join();
threadN.join();

}
}

可以看到在Test 1中,由于线程A、B使用的是同一个Runnable实例,即两个线程的lock对象是同一个,故可以看到线程A、B之间是同步的;同理,在Test 2下,由于线程C、D使用的是两个不同的Runnable实例,自然各自的成员变量lock不是同一个,所以线程C、D是非同步的;如果我们既期望使用不同的Runnable实例,又期望两个Runnable实例之间能够同步该怎么办呢?其实也很简单,只需让两个Runnable实例使用同一个lock实例作为锁即可,如Test 3所示

figure 1.png

2. 显式地指定this作为锁

对于一个非静态方法而言,其必然是通过一个该类的实例对象进行调用。那么自然我们也可以将该方法的调用者(即当前对象实例)作为锁来使用,我们知道在非静态方法中this可用来指代当前对象实例,故这里我们在同步代码块中显式地指定this作为锁

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
/**
* synchronized 对象锁: 使用同步代码代码块,将方法的调用者作为锁
*/
public class ObjectLockDemo2 implements Runnable {


public static ObjectLockDemo2 o1 = new ObjectLockDemo2();
public static ObjectLockDemo2 o2 = new ObjectLockDemo2();

/**
* 使用调用该方法的实例对象作为锁
*/
@Override
public void run() {
synchronized (this) {
System.out.println( Thread.currentThread().getName() + "获取到锁");
try{
Thread.sleep(500);
}catch (InterruptedException e) {
System.out.println("Error ...");
}
System.out.println( Thread.currentThread().getName() + "准备释放锁" );
}

}

public static void main(String[] args) throws InterruptedException {

System.out.println("------------- TEST 1 -------------");
// 多线程之间使用同一个锁对象: o1
Thread threadA = new Thread(o1,"线程A");
Thread threadB = new Thread(o1,"线程B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();

System.out.println("------------- TEST 2 -------------");
// 多线程之间使用不同的锁对象: o1、o2
Thread threadC = new Thread(o1,"线程C");
Thread threadD = new Thread(o2,"线程D");
threadC.start();
threadD.start();
threadC.join();
threadD.join();
}
}

可以看到在Test 1中,由于线程A、B使用的是同一个Runnable实例,即两个线程使用同一个锁,故可以看到线程A、B之间是同步的;而在Test 2下,由于线程C、D使用的是两个不同的Runnable实例,即使用各自的Runnable实例作为锁,故自然线程C、D之间不是同步的

figure 2.png

3. 修饰非静态方法

上面我们介绍了如何在同步代码块中,通过显式地指定this将当前非静态方法锁设置为当前对象实例。其实还有一种更简洁的姿势,即直接使用synchronized关键字来修饰非静态方法,则其默认使用当前对象实例作为锁(即this)

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
/**
* synchronized 对象锁: 修饰非静态方法
*/
public class ObjectLockDemo3 implements Runnable {


public static ObjectLockDemo3 o1 = new ObjectLockDemo3();
public static ObjectLockDemo3 o2 = new ObjectLockDemo3();

/**
* 使用调用该方法的实例对象作为锁,即使用this作为锁
*/
@Override
public synchronized void run() {
System.out.println( Thread.currentThread().getName() + "获取到锁");
try{
Thread.sleep(500);
}catch (InterruptedException e) {
System.out.println("Error ...");
}
System.out.println( Thread.currentThread().getName() + "准备释放锁" );
}

public static void main(String[] args) throws InterruptedException {

System.out.println("------------- TEST 1 -------------");
// 多线程之间使用同一个锁对象: o1
Thread threadA = new Thread(o1,"线程A");
Thread threadB = new Thread(o1,"线程B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();

System.out.println("------------- TEST 2 -------------");
// 多线程之间使用不同的锁对象: o1、o2
Thread threadC = new Thread(o1,"线程C");
Thread threadD = new Thread(o2,"线程D");
threadC.start();
threadD.start();
threadC.join();
threadD.join();
}
}

可以看到,其和显式指定this作为锁,效果是一样的

figure 3.png

类锁

我们知道,虽然在JVM中一个类的实例对象可能不止一个,但是JVM中,该类的Class对象正常情况下是唯一的。故我们可将其作为锁来使用,即所谓的类锁。具体地,可通过以下两种姿势来使用类锁

1. 显式地指定一个类的Class实例作为锁

这里我们在同步代码块中显式地指定一个类的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
35
36
37
38
39
40
41
42
43
44
45
46
/**
* synchronized 类锁: 使用同步代码块
*/
public class ClassLockDemo1 implements Runnable {


public static ClassLockDemo1 o1 = new ClassLockDemo1();
public static ClassLockDemo1 o2 = new ClassLockDemo1();

/**
* 使用ClassLockDemo1类的Class对象作为锁
*/
@Override
public void run() {
synchronized (ClassLockDemo1.class) {
System.out.println( Thread.currentThread().getName() + "获取到锁");
try{
Thread.sleep(500);
}catch (InterruptedException e) {
System.out.println("Error ...");
}
System.out.println( Thread.currentThread().getName() + "准备释放锁" );
}
}

public static void main(String[] args) throws InterruptedException {

System.out.println("------------- TEST 1 -------------");
// 多线程之间使用同一个锁对象: ClassLockDemo1的Class对象
Thread threadA = new Thread(o1,"线程A");
Thread threadB = new Thread(o1,"线程B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();

System.out.println("------------- TEST 2 -------------");
// 多线程之间使用同一个锁对象: ClassLockDemo1的Class对象
Thread threadC = new Thread(o1,"线程C");
Thread threadD = new Thread(o2,"线程D");
threadC.start();
threadD.start();
threadC.join();
threadD.join();
}
}

Test 1Test 2中,我们可以看出,不论多个线程是否使用同一个Runnable实例,由于其Class实例是唯一的,所以线程之间均会被同步。换句话说,只要多个线程之间的使用锁是同一把锁,即会被正确地同步

figure 4.png

2. 修饰静态方法

当然,我们也可以直接使用synchronized关键字来修饰静态方法,其同样是使用该类的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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* synchronized 类锁: 修饰静态方法
*/
public class ClassLockDemo2 implements Runnable {


public static ClassLockDemo2 o1 = new ClassLockDemo2();
public static ClassLockDemo2 o2 = new ClassLockDemo2();

@Override
public void run() {
method1();
}

/**
* 使用ClassLockDemo2类的Class对象作为锁
*/
private static synchronized void method1() {
System.out.println( Thread.currentThread().getName() + "获取到锁");
try{
Thread.sleep(500);
}catch (InterruptedException e) {
System.out.println("Error ...");
}
System.out.println( Thread.currentThread().getName() + "准备释放锁" );
}

public static void main(String[] args) throws InterruptedException {

System.out.println("------------- TEST 1 -------------");
// 多线程之间使用同一个锁对象: ClassLockDemo2的Class对象
Thread threadA = new Thread(o1,"线程A");
Thread threadB = new Thread(o1,"线程B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();

System.out.println("------------- TEST 2 -------------");
// 多线程之间使用同一个锁对象: ClassLockDemo2的Class对象
Thread threadC = new Thread(o1,"线程C");
Thread threadD = new Thread(o2,"线程D");
threadC.start();
threadD.start();
threadC.join();
threadD.join();
}
}

毫无疑问,其与 显式地指定一个类的Class实例作为锁 的方式,效果是一样的

figure 5.png

异常情况下锁的释放

我们知道正常情况下线程进入synchronized的临界区时获取锁,而在线程执行完毕离开synchronized的临界区时释放锁。那如果线程在synchronized的临界区中因抛出了异常而退出,会不会释放锁呢?答案是

下面我们来通过一个实例来验证这个结果,可以看到如果A线程先拿到锁,则会进入同步临界区并抛出一个异常;而如果此时线程B可以拿到锁进入同步方法,即验证上面提出的答案

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

public static ThrowExceptionDemo o1 = new ThrowExceptionDemo();

@Override
public void run() {
method1();
}

/**
* 使用ClassLockDemo2类的Class对象作为锁
*/
private static synchronized void method1() {
System.out.println( Thread.currentThread().getName() + "获取到锁");
try{
Thread.sleep(500);
}catch (InterruptedException e) {
System.out.println("Error ...");
}
// 如果是线程A,则抛出一个异常
if( Thread.currentThread().getName().equals("线程A") ) {
// 抛出异常时,会释放锁
throw new RuntimeException();
}
System.out.println( Thread.currentThread().getName() + "准备释放锁" );
}

public static void main(String[] args) throws InterruptedException {

// 多线程之间使用同一个锁对象: ClassLockDemo2的Class对象
Thread threadA = new Thread(o1,"线程A");
Thread threadB = new Thread(o1,"线程B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}

可以看到,在线程A因抛出异常而退出同步方法后锁被正确地释放了,随后线程B获取到了该锁

figure 6.jpeg

并发三要素

原子性

之前我们在Java内存模型中介绍过,在JMM中提供了lock、unlock操作,尽管虚拟机未将这两个操作开放提供给用户使用。但是却提供了更高层次的字节码指令monitorenter、monitorexit来隐式地使用了这两个操作,而这两个字节码指令反映到Java代码层面就是同步块——synchronized关键字。所以synchronized块之间的一系列操作同样具备原子性

下面我们对ByteCodeDemo类的test1方法反编译,可以看到分别使用monitorenter、monitorexit字节码指令完成加锁锁、释放锁。细心的朋友可能发现,这里释放锁的monitorexit指令却出现了两次。其实很简单,一个是用于同步代码块正常执行完毕来释放锁,另外一个则是当同步代码块中抛出异常后来释放锁的。这也就解释我们上面所说的当抛出异常时,synchronized锁将会被释放

figure 7.jpeg

Note

对于用synchronized修饰的方法,其同步不是通过monitorentry、monitorexit指令实现,而是通过方法调用指令来检查运行时常量池中的ACC_SYNCHRONIZED标志来隐式实现的。如果有,则需要先获取锁

可见性

synchronized关键字的内存语义保证了其具备可见性。当进入synchronized块时,其会将synchronized块内使用到的共享变量从线程的工作内存中清除,保证了synchronized块中的共享变量是直接从主内存中获取的,而不是从线程的工作内存中获取;当退出synchronized块时,会将synchronized块内的对共享变量的修改同步回主内存中。上面我们所说的synchronized语义其实也是加锁、释放锁的语义

有序性

之前我们在Java内存模型中提到,如果在一个线程观察另一个线程,可以看到其操作都是无序的。原因就在于 指令重排序 、 工作内存与主内存同步延迟。而线程间的synchronized块执行肯定是有序的,结合前面所说synchronized的可见性,可知其不存在由于工作内存与主内存刷新同步延迟而导致的无序现象。但是对于synchronized块中的代码,其并不能保证不会发生指令重排现象。所以synchronized的有序性实际上是指线程间操作synchronized块的有序

这也是为什么在DCL单例中必须要使用volatile关键字来修饰实例变量的原因,我们知道创建实例主要包括以下三个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将内存空间的地址赋值给对应的引用

而在DCL单例的synchronized块中,创建实例的过程可能会由于指令重排而出现 实例变量已经指向刚刚分配好的内存空间、但实际上对象初始化还没完成 的现象。即创建实例过程步骤顺序原本是 1 -> 2 -> 3,由于指令重排而出现 1 -> 3 -> 2 的执行顺序。假设线程A按此顺序(1 -> 3 -> 2)执行到步骤3时,此时线程B就有可能会拿到一个非null的实例变量。相信很多人会对此有一个疑问,因为根据synchronized的内存语义,线程A不应该在继续执行完步骤2退出synchronized块后,其工作内存的实例变量才会被刷新同步到主内存中么?为什么在线程A还没退出synchronized块,工作内存的实例变量就被刷新同步到主内存呢?实际上这是因为在Java中, 如果共享变量的引用已经和构造器的调用内联了, 即使构造器未完成初始化该共享变量也可能会立即被同步到主内存中。所以说,对于一个线程安全的DCL单例而言,使用volatile修饰实例变量是必要的,其目的在于禁止指令重排序

可重入锁

可重入锁,又称作递归锁。其是指当一个线程在已经持有该锁的情况下,依然可以在该线程中继续对其请求加锁并成功,则该称锁即为可重入锁;否则即为不可重入锁。可重入锁的一大好处就是可以避免死锁。对于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
public class ReentryDemo {

public static void main(String[] args) {
Cat cat = new Cat("cat");
cat.setName("Tony");
cat.setAge(12);

cat.getInfo();
}

}

@ToString(callSuper = true)
@AllArgsConstructor
class Cat extends Animal{
private String type;

// 调用该方法时,主线程对cat进行加锁并持有
public synchronized void getInfo() {
System.out.println("[" + Thread.currentThread().getName() +"]: Cat Method : " + this.toString() );
super.getInfo();
}
}

@Data
class Animal {
private String name;
private Integer age;

// 调用该方法时,主线程再次对cat加锁
public synchronized void getInfo() {
System.out.println("[" + Thread.currentThread().getName() +"]: Animal Method : " + this.toString() );
}
}

当我们调用cat实例的getInfo方法时,当前线程即对cat实例进行加锁并成功持有;在Cat类的getInfo方法中继续调用了父类Animal的getInfo方法,由于此时线程已经持有了锁——cat实例。所以当前线程在调用父类的getInfo方法过程中,由于可重入锁的缘故,可以继续对其加锁,从而可以进入父类的getInfo方法体并执行。程序的测试结果如下,可以看到子类、父类方法均被正确执行,没有发生死锁

figure 8.jpeg

即然我们知道了synchronized锁是一个可重入锁,那它原理、机制是怎么样的呢?其实也很简单,当执行monitorenter指令时,首先会尝试获取对象的ObjectMonitor锁。如果这个对象没有被锁定或者发现当前线程已经持有了该对象的锁,则直接把ObjectMonitor锁的计数器加1;同理,在执行monitorexit指令时,直接将ObjectMonitor锁的计数器减1;显然 加1的次数 必须和 减1的次数相匹配,即monitorenter、monitorexit指令次数相匹配。当计数器为0时,该锁即被释放

参考文献

  1. Java核心技术·卷I 凯.S.霍斯特曼著
  2. Java并发编程之美 翟陆续、薛宾田著
  3. 深入理解Java虚拟机·第2版 周志明著
  4. JSR-133: Java Memory Model and Thread Specification
0%