心安

细说Java关键字synchronized(三) -- 深入理解synchronized关键字

字数统计: 1.8k阅读时长: 6 min
2019/02/27 Share

前言

前面两篇文章细说Java关键字synchronized(一) – 入门案例细说Java关键字synchronized(二) – 详解synchronized的用法主要是对synchronized关键字的一些介绍及其用法进行了讲解。这篇文章我们将会深入到虚拟机底层来看一下synchronized关键字的实现原理以及它的一些性质,只有了解了这些,我们才能知道它的优劣,从而让我们使用起来更加的得心应手。

synchronized关键字的性质

1. 可重入性

我们先看一下什么是可重入,以下是摘自维基百科的解释:

若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。
即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

通俗来讲就是,同一线程的外层函数获取到到锁之后,内层函数可以直接获取该锁。
下面一段代码演示可重入性:

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

private synchronized void fun1() {
System.out.println("fun1 run...");
fun2();
}

private synchronized void fun2() {
System.out.println("fun2 run...");
}

public static void main(String[] args) {
new SynchronizedReentrantDemo().fun1();
}
}

上面的代码运行结果如下:

1
2
fun1 run...
fun2 run...

可以看到,fun1fun2都是同步函数,如果说synchronized关键字不具有可重入性,会发生什么呢?
根据上一篇文章的学习我们知道,两个方法用到的锁都是同一把锁,也就是this
那么假设不具有可重入性,当主线程进入到fun1的时候获取到了this锁,然后调用了fun2
由于不可重入,所以主线程又需要再次获取this锁,但是自己还持有着this锁没有释放,所以主线程获取不到锁,就会造成卡死的情况,也就是死锁。

所以可重入性所带来的最大好处之一就是避免了“死锁”。

2. 不可中断性

不可中断性就是当一个线程在尝试获取一把锁的时候,发现这把锁已经被其他线程占用了,那么当前线程只能选择等待或者阻塞,直到其他线程释放这个锁。
如果其他线程始终不释放这个锁,那么当前线程只能无限期等待下去。

与synchronized不可中断性相对的有一个java.util.concurrent.locks.Lock锁,它拥有中断的能力。
他可以中断已获取到锁的线程的执行,或者觉得等待的时间太长选择退出不再获取锁。它相比synchronized更加灵活。

深入可重入和可见性原理

接下来我们就深入jvm底层来看一下synchronized锁实现的原理。
前面我们说锁分为对象锁和类锁,但是其实类锁还是一个对象锁,只不过它比较特殊,它是java.lang.Class的一个对象。
所以说synchronized锁都是一个通过一个对象来实现的。那么对象是如何实现锁的呢?
以下关于对象的内存布局的内容是摘自周志明老师的《深入理解Java虚拟机:JVM高级特性和最佳实践》。

在HotSpot虚拟机中,对象在内存中的存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)、和对其填充(Padding)。

HotSpot虚拟机的对象头包括两部分信息。第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等

从上面的内容中我们可以知道,每一个对象都会有一个对象头,这个对象头里面会存储关于锁的数据。
对象头中其中有一部分存储的就是指向了monitor对象的指针(当对象状态为重量级锁的时候才会monitor,这里为了易于理解,暂且认为加了synchronized关键字就有了monitor对象),这个对象主要有两个指令,一个叫做monitorenter,一个叫做monitorexit
分别对应获取锁和释放锁。其中monitorenter会被插入到同步代码块开始的位置,而monitorexit会被插入到同步代码块结束的地方以及finally代码块中(synchronized代码块会被隐式的加上try-finally代码块)。
这样保证在方法结束的时候或者抛异常的时候能够释放锁,这也是为什么我们在之前文章中讲到,方法抛异常之后会自动释放锁。
下面我们反编译来看一下这些指令。

反编译看字节码文件

首先我们写一个同步代码块,并且使用javac命令来编译它,这样我们会得到一个.class文件。

1
2
3
4
5
6
7
public class DecompilationDemo {
public void fun() {
synchronized (this) {

}
}
}

然后问使用javap命令来反编译字节码文件。

1
javap -verbose DecompilationDemo.class

得到下面的内容(由于篇幅有限,这里只展示fun()相关的内容):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void fun();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return

可以看到反编译后的结果就有一个monitorenter和两个monitorexit,这就验证了上面的说法。

monitor对象拥有一个计数器,初始状态为0,当一个线程获取到了锁,monitor对象的计数器就加一,当一个线程释放了锁,这个计数器就减一。
这也是为什么synchronized锁具有可重入性。

补充:有些同学可能会问,上面反编译的是同步代码块,如果是同步方法呢?
javac对同步方法的处理其实是为它加上了一个flag,叫做ACC_SYNCHRONIZED,那么jvm在调用这个方法的时候,发现了这个关键字,就会先去尝试获取锁。
那么这两种方式实现的同步在底层都是大致相同的,感兴趣的同学也可以自己尝试去反编译。

结语

本系列博文到此就算是告一段落了,然而Java并发编程博大精深,笔者却又才疏学浅,所以远不是笔者几篇博文就能表述清楚的,笔者只能尽自己所能的分享自己的理解。
希望各位同学能够在并发编程的路上越走越远,学习到并发编程的精髓,早日成长为技术大牛。最后,感谢你的阅读,如有错误和疑问,敬请指教和沟通!

原文作者:XinAnzzZ

原文链接:https://www.yuhangma.com/2019/concurrent/2019-02-27-concurrent-synchronized-03/

发表日期:February 27th 2019, 12:00:00 am

更新日期:September 26th 2019, 10:46:42 am

版权声明:(转载本站文章请注明作者和出处 心 安 – XinAnzzZ ,请勿用于任何商业用途)

CATALOG
  1. 1. 前言
  2. 2. synchronized关键字的性质
    1. 2.1. 1. 可重入性
    2. 2.2. 2. 不可中断性
  3. 3. 深入可重入和可见性原理
    1. 3.1. 反编译看字节码文件
  4. 4. 结语