(二)深入理解Java并发编程之Synchronized关键字实
引言
Synchronized关键字(互斥锁)原理,一线大厂不变的面试题,同时也是理解Java并发编程必不可少的一环!其中覆盖的知识面很多,需要理解的点也很多,本文是以相关书籍和结合自己的个人理解从基础的应用范围到底层深入剖析的方式进行阐述,如果错误或疑问欢迎各位看官评论区留言纠正,谢谢!
一、Synchronized应用方式及锁类型
众所周知,在项目开发过程中使用多线程的效果就是一个字:快!多线程编程能够去给我们的程序带来很大的性能收益,同时也能够去把机器的性能发挥到极致。而当现在的时代进步发展,机器的硬件早就摆脱了单核的限制,所以我们往往开发过程中只是编写单线程的程序在很多时候是在浪费机器的计算能力,所以多线程编程在我们现在的开发过程中显的越来越重要,同时也成了一线大厂面试必问的一个门槛。而当我们在研究Java并发编程的时候线程安全问题是我们的重要关注点,而构成这个问题的根本原因无非就三个要素:
多线程
、共享资源(临界资源)
、非原子性操作
,一句话概叙线程安全问题产生的根本原因:多条线程同时对一个共享资源进行非原子性操作时会诱发线程安全问题。(如果对于这三个概念存在疑问,请仔细阅读我的上篇文章理解:JMM与Volatile)。那么既然我们出现了这个问题又该怎么去解决呢?无他,破坏掉构成这个问题的三要素中的任何一个就可以啦!因此为了解决这个问题,我们可能在出现上述问题时需要去把多线程的并行执行变为单线程串行执行,其他线程需要等到这个线程执行完成之后才能执行,这种方案有一个高大尚而响亮的名字互斥锁/排他锁
,也就是当多条线程同时执行一段被互斥锁保护的代码(临界资源)时需要获取锁,但只会有一个线程获取到锁资源成功执行,其他线程将陷入等待的状态,直到当前线程执行完毕释放锁资源之后其他线程才能执行。在Java并发编程中提供了一种机制Synchronized
关键字来实现互斥锁的功能做到如上描述。当然我们也需要注意的一个内容是Synchronized
的另一个作用:Synchronized
可以保证一个线程对临界资源(共享资源)发生了改变之后能够对其他所有线程可见,完全可以代替我们上章节所说的Volatile所保障的可见性功能。(但是Synchronized
无法完全取代Volatile
,因为Synchronized
可以保证可见性、原子性、“有序性”,但是无法禁止指令重排序,这点我们会在后面分析)。
1.1、Synchronized关键字三种锁类型(本质上都是依赖对象来锁)
- this锁:当前实例锁
- class锁:类对象锁
- Object锁:对象实例锁
1.2、Synchronized关键字三种应用方式
- 修饰实例成员方法:使用this锁,线程想要执行被Synchronized关键字修饰的成员实例方法必须先获取当前实例对象的锁资源;
- 修饰静态成员方法:使用class锁,线程想要执行被Synchronized关键字修饰的静态方法必须先获取当前类对象的锁资源;
- 修饰代码块:使用Object锁,使用给定的对象实现锁功能,线程想要执行被Synchronized关键字修饰的代码块必须先获取当前给定对象的锁资源;
1.2.1、synchronized修饰实例成员方法
public class SyncIncrDemo implements Runnable{
//共享资源(临界资源)
static int i = 0;
//synchronized关键字修饰实例成员方法
public synchronized void incr(){
i++;
}
@Override
public void run() {
for(int j=0;j<1000;j++){
incr();
}
}
public static void main(String[] args) throws InterruptedException {
SyncIncrDemo syncIncrDemo = new SyncIncrDemo();
Thread t1=new Thread(syncIncrDemo);
Thread t2=new Thread(syncIncrDemo);
t1.start();
t2.start();
/**
*join:使得放弃当前线程的执行,并返回对应的线程,例如下面代码的意思就是:
程序在main线程中调用t1,t2线程的join方法,则main线程放弃cpu控制权,并返回
t1,t2线程继续执行直到线程t1,t2执行完毕;
所以结果是t1,t2线程执行完后,才到主线程执行,相当于在main线程中同步t1,t2
线程,t1,t2执行完了,main线程才有执行的机会
*/
t1.join();
t2.join();
System.out.println(i);
}
/**
* 输出结果:
* 2000
*/
}
上述代码中,我们开启t1,t2
两个线程操作同一个共享资源即int变量i
,由于自增操作i++;
在我们上章节分析到该操作并不具备原子性,具体是分为三步来执行:1)先从主存中读取值;2)在自己工作内存进行+1操作;3)将结果刷新回主存。如果t2
线程在t1
线程读取旧值和写回新值期间(也就是t2在t1在自己工作内存中做+1计算时)读取全局资源i
的值,那么t2
线程就会与t1
线程一起看到同一个值,并执行相同值的+1操作,这也就造成了线程安全失败,因此对于incr方法必须使用synchronized
修饰,做到线程的互斥解决线程安全问题。此时我们应该注意到synchronized
修饰的是对象实例方法incr()
,在这样的情况下,当前线程的锁便是当前实例this锁,也就是当前实例对象syncIncrDemo
,注意Java中的线程同步锁可以是任意对象(依赖于对象头中的Monitor,稍后会分析)。从代码执行结果来看确实是正确的,倘若我们没有使用synchronized
关键字修饰incr()
方法,其最终输出结果就有可能小于2000,这便是synchronized
关键字的作用,内存示意图如下:
这里我们还需要意识到,当一个线程正在访问一个对象的
synchronized
实例方法,那么其他线程不能访问该对象的其他被synchronized
修饰的对象实例方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他被synchronized
修饰的对象实例方法,但是其他线程还是可以访问该实例对象的其他没有被synchronized
修饰的成员方法或者被synchronized
修饰的静态成员方法,当然如果是一个线程 A
需要访问实例对象 obj1
中被 synchronized
修饰的对象实例方法 f1
(当前对象锁是obj1
),另一个线程B
需要访问实例对象象 obj2
中被 synchronized
修饰的对象实例方法f2
(当前对象锁是obj2
),这样是允许同时访问的,因为两个实例对象锁并不同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下代码将演示出该情况:
public class SyncIncrDemo implements Runnable{
//共享资源(临界资源)
static int i = 0;
//synchronized关键字修饰实例成员方法
public synchronized void incr(){
i++;
}
@Override
public void run() {
for(int j=0;j<1000;j++){
incr();
}
}
public static void main(String[] args) throws InterruptedException {
SyncIncrDemo syncIncrDemo1 = new SyncIncrDemo();
SyncIncrDemo syncIncrDemo2 = new SyncIncrDemo();
Thread t1=new Thread(syncIncrDemo1);
Thread t2=new Thread(syncIncrDemo2);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
/**
* 输出结果:
* 1991
*/
}
上述代码与前面不同的是我们同时创建了两个新实例syncIncrDemo1,syncIncrDemo2
,然后启动两个不同的线程对共享变量i
进行操作,但很遗憾操作结果是1991
而不是期望结果2000
,因为上述代码犯了严重的错误,虽然我们使用synchronized
修饰了incr
方法,但却new了两个不同的实例对象,这也就意味着存在着两把不同的实例对象锁,因此t1
和t2
都会进入各自的对象锁,也就是说t1
和t2
线程使用的不是同一把锁,因此线程安全是无法保证的。内存示意图如下:
解决这种困境的的方式是将
incr
方法使用static
来修饰,这样的话,对象锁就当前类对象,所以我们无论创建多少个实例对象,但对于的类对象(class对象)来说,虚拟机只会加载字节码后生成一个,所以在这样的情况下对象锁就是唯一的。下面我们看看如何使用将synchronized
作用于静态的incr
方法。
1.2.2、synchronized修饰静态成员方法
当synchronized
用于修饰静态方法时,其锁就是当前类的class
对象锁(当使用class锁时那么当前Java程序中使用该类对象锁的时候只会有一把,不会因为new出多个实例造成多把锁,线程分别获取不同锁资源的情况发生)。由于静态成员不专属于任何一个实例对象,是类成员,因此可以通过class
对象锁控制静态 成员的并发操作。需要注意的是如果一个线程A调用一个实例对象的非static
并且被 synchronized
修饰方法,而线程B需要调用这个实例对象所属类的也被synchronized
修饰的static
方法,是允许同时执行的,并不会发生互斥现象,因为访问静态 synchronized
方法的线程获取的是当前类的class
对象的锁资源,而访问非静态 synchronized
方法线程获取的是当前实例对象锁资源,看如下代码:
public class SyncIncrDemo implements Runnable{
//共享资源(临界资源)
static int i = 0;
//synchronized关键字修饰实例成员方法 锁对象:this 当前 new 的实例对象
public synchronized void reduce(){
i--;
}
//synchronized关键字修饰静态成员方法 锁对象:class SyncIncrDemo.class
public static synchronized void incr(){
i++;
}
@Override
public void run() {
for(int j=0;j<1000;j++){
incr();
}
}
public static void main(String[] args) throws InterruptedException {
SyncIncrDemo syncIncrDemo1 = new SyncIncrDemo();
SyncIncrDemo syncIncrDemo2 = new SyncIncrDemo();
Thread t1=new Thread(syncIncrDemo1);
Thread t2=new Thread(syncIncrDemo2);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
/**
* 输出结果:
* 2000
*/
}
由于synchronized
关键字修饰的是静态incr
方法,与修饰实例方法不同的是,实例方法其锁对象是当前实例对象(this对象),而静态方法的锁对象是当前类的class对象,这样就能做到就算new出多个实例也不会在多线程同时执行incr
方法出现线程安全问题。内存示意图如下:
注意代码中的
reduce
方法是实例方法,其对象锁是当前实例对象,如果别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不同,但我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量 i )。
PS:无论synchronized
是修饰对象实例方法还是修饰静态成员方法,使用的锁都是this锁类型的,只不过在修饰对象实例方法时这个this指的是当前new出来的对象,因为对象实例方法是属于当前对象的。而当synchronized
是修饰静态成员方法时这个this指的是class对象,因为静态成员不属于任何一个实例对象,是类成员(这里可能有点抽象难以理解,但是只要记住,synchronized
用的就是this对象作为锁)。
1.2.3、synchronized修饰代码块
除了使用synchronized
关键字修饰实例方法和静态方法外,还可以使用它同步代码块,因为在某些情况下,我们编写的方法体可能比较大(比如2000行的方法),如果直接使用用synchronized
关键字修饰这个方法,那么它执行的过程是比较耗时的,而且这2000行代码中也并不是所有的代码都存在发生线程安全问题的可能,假设2000行代码中还存在一些比较耗时的操作(IO操作),如果这种情况直接对整个方法进行同步操作,那么会在程序中导致大量的线程阻塞,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行synchronized
关键字修饰了,代码示例如下:
public class SyncIncrDemo implements Runnable{
//共享资源(临界资源)
static int i = 0;
//synchronized关键字修饰代码块
public void methodA(){
//省略一千行代码....
/**
* 假设我们此时只有这里存在对共享资源操作,我们如果对整个方法进行同步
* 那么是不应该的,而我们可以使用同步这段代码的形式使用`synchronized`
* 关键字对它进行同步修饰
*/
synchronized(SyncIncrDemo.class){
i++;
}
// 省略八百行代码....
}
@Override
public void run() {
methodA();
}
public static void main(String[] args) throws InterruptedException {
SyncIncrDemo syncIncrDemo = new SyncIncrDemo();
for(int j=0;j<1000;j++){
new Thread(syncIncrDemo).start();
}
Thread.sleep(10000);
System.out.println(i);
}
/**
* 输出结果:
* 1000
*/
}
从上述代码可以看出,我们将当前使用synchronized
修饰代码块时给予了类对象(class)做为锁资源(即锁对象),每次当线程进入synchronized
包裹的代码块时就会要求当前线程持有SyncIncrDemo.class
类对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了类对象作为锁资源外,我们还可以使用this对象(代表当前实例)或者给予一个对象作为锁对象,如下代码:
//当前实例
synchronized(this){
i++;
}
//给予对象
Object obj = new Object();
synchronized(obj){
i++;
}
如上就是我们关于synchronized的基本描述与使用,那么接下来我们需要研究的是关于我们synchronized关键字底层的实现原理,从而进一步加深对于synchronized的理解。
二、Synchronized底层原理剖析
前面我们提到synchronized是依赖于对象的对象头中的Monitor来实现的锁功能,而从官方的虚拟机规范文档上可以看到Java中的synchronized同步的确是基于Monitor(管程)对象来实现的,获取锁:进入管程对象(显式型:monitorenter指令),释放锁:退出管程对象(显式型:monitorexit指令),但是需要清楚,我们使用synchronized修饰方法成员的时候是无法通过javap看到进入/退出管程对象的指令的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来实现的,synchronized修饰方法时使用的隐式同步,但是无论我们是显式同步还是隐式同步都是通过进入/退出管程对象来实现的同步(关于显式和隐式我们稍后会分析),不过值得注意的是关于同步在Java中并不仅仅只是在synchronized中应用,只不过关于Java语言中同步的出现场景大部分都是在synchronized修饰的代码或者方法,synchronized只是同步的一种实现,值得注意的是synchronized并不能完全代表Java的同步机制,在虚拟机规范中对于同步的描述是这样的:Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。
2.1、理解Java对象内存布局:对象头、MarkWord及Monitor对象
在JVM中一个Java对象在内存中的布局主要分为三个区域:对象头、实例数据以及对齐填充,如下:
- 对象头: 存储MarkWord和类型指针(ClassMetadataAddress/KlassWord),如果是数组对象,还会存在数组长度。
- 实例数据: 存放当前对象属性成员信息以及父类属性成员信息,比如:存在两个int和一个long类型的属性,那么就是 4 + 4 + 8 = 16byte(字节)。
- 对齐填充: 由于虚拟机要求对象起始地址必须是8byte的整数倍,所以虚拟机会对于每个对象做8的倍数填充,如果这个对象的大小(对象头+实例数据大小)已经是8的整数倍了,那么并不会存在对齐填充,所以值得注意的是对齐填充并不是每个对象都存在的,仅仅是为了字节对齐,避免减少堆内存的碎片空间和方便OS读取。
关于Java对象头实则是synchronized底层实现的关键要素,下面我们重点分析对象头的构成, JVM采取2个字宽(Word/Class指针大小)存储对象头(如果对象是数组,额外需要存储数组长度,所以32位虚拟机采取3个字宽存储对象头,而64位虚拟机采取两个半字宽+半字宽对齐数据存储对象头),而在32位虚拟机中一个字宽的大小为4byte,64位虚拟机下一个字宽大小为8byte,64位开启指针压缩的情况下 -XX:+UseCompressedOops,MarkWord为8byte,KlassWord为4byte,而关于这块的内容很多资料都含糊不清,那么我在这里例出如下信息(如有任何疑问欢迎留言),所以最终对象头结构信息说明如下:
虚拟机位数 | 对象头结构信息 | 说明 | 大小 |
---|---|---|---|
32位 | MarkWord | HashCode、分代年龄、是否偏向锁和锁标记位 | 4byte/32bit |
32位 | ClassMetadataAddress/KlassWord | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例 | 4byte/32bit |
32位 | ArrayLenght | 如果是数组对象存储数组长度,非数组对象不存在 | 4byte/32bit |
虚拟机位数 | 对象头结构信息 | 说明 | 大小 | |
---|---|---|---|---|
64位 | MarkWord | unused、HashCode、分代年龄、是否偏向锁和锁标记位 | 8byte/64bit | |
64位 | ClassMetadataAddress/KlassWord | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例 | 8byte/64bit | 开启指针压缩的情况下为4byte/32bit |
64位 | ArrayLenght | 如果是数组对象存储数组长度,非数组对象不存在 | 4byte/32bit |
其中32位的JVM中对象头内MarkWord在默认情况下存储着对象的HashCode、分代年龄、是否偏向锁、锁标记位等信息,而64位JVM中对象头内MarkWord的默认信息存储着HashCode、分代年龄、是否偏向锁、锁标记位、unused,如下:
虚拟机位数 | 锁状态 | HashCode | 分代年龄 | 是否偏向锁 | 锁标志信息 |
---|---|---|---|---|---|
32位 | 无锁态(默认) | 25bit | 4bit | 1bit | 2bit |
虚拟机位数 | 锁状态 | HashCode | 分代年龄 | 是否偏向锁 | 锁标志信息 | unused |
---|---|---|---|---|---|---|
64位 | 无锁态(默认) | 31bit | 4bit | 1bit | 2bit | 26bit |
由于对象头的信息是与对象自身定义的成员属性数据没有关系的额外存储成本,因此考虑到JVM的空间效率,MarkWord被设计成为一个非固定的数据结构,以便可以复用方便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,除了上述列出的MarkWord默认存储结构外,还有如下可能变化的结构(前面32位,后面64位):
- markwork:参考上述内容。
- LockRecord:在我们前面提到,当对象状态为偏向锁时,markword存储的是偏向的线程ID;当状态为轻量级锁时,markword存储的是指向线程栈中LockRecord的指针;当状态为重量级锁时,为指向堆中的monitor对象的指针,而LockRecord存在于线程栈中,翻译过来就是锁记录,它会拷贝一份对象头中的markword信息到自己的线程栈中去,这个拷贝的markword 称为Displaced Mark Word ,另外还有一个指针指向对象。
markword信息:
- unused:未使用的。
- identity_hashcode:对象最原始的hashcode,就算重写hashcode()也不会改变。
- age:对象年龄。
- biased_lock:是否偏向锁。
- lock:锁标记位。
- ThreadID:持有锁资源的线程ID。
- epoch:偏向锁时间戳。
- ptr_to_lock_record:指向线程本地栈中lock_record的指针。
- ptr_to_heavyweight_monitor:指向堆中monitor的指针。
其中轻量级锁和偏向锁是JavaSE1.6 对synchronized
锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized
的对象锁,锁标识位为10,其中指针指向的是monitor
对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor
与之关联,对象与其 monitor
之间的关系有存在多种实现方式,如monitor
可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor
被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor
是由ObjectMonitor
实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp
文件):
位置:openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp
实现:C/C++
代码:
ObjectMonitor() {
_header = NULL; //markOop对象头
_count = 0; //记录个数
_waiters = 0, //等待线程数
_recursions = 0; //重入次数
_object = NULL; //监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
_owner = NULL; //指向获得ObjectMonitor对象的线程或基础锁
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL;
FreeNext = NULL;
_EntryList = NULL; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ; // _owner is (Thread *) vs SP/BasicLock
_previous_owner_tid = 0; // 监视器前一个拥有者线程的ID
}
- monitor:monitor存在于堆中,什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用,Monitor对象结构如下(可忽略,个人记笔记用的):
Monitor对象结构- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中。
- Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中。
- Wait Set:哪些调用wait方法被阻塞的线程被放置在这里。
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck。
- Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL,当前已经获取到所资源的线程被称为Owner。
- !Owner:当前释放锁的线程。
- RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
- Nest:用来实现重入锁的计数。
- Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。
- HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
- EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:
image.png
由此看来,monitor对象存在于每个Java对象的对象头中markword内(存储的指针的指向),synchronized关键字便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析),有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。
2.2、从反编译字节码理解synchronized修饰代码块底层实现原理
编译前Java源文件:
public class SyncDemo{
int i;
public void incr(){
synchronized(this){
i++;
}
}
}
使用javac编译如上代码并使用Javap -p -v -c得到反汇编后得到如下字节码:
Classfile /C:/Users/XYSM/Desktop/com/SyncDemo.class
Last modified 2020-6-17; size 454 bytes
MD5 checksum 457e08e7b9caa345db5c5cca53d8d612
Compiled from "SyncDemo.java"
public class com.SyncDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
...... //省略常量池信息
{
int i;
descriptor: I
flags:
// 构造函数
public com.SyncDemo();
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 3: 0
/*-------synchronized修饰incr()方法中代码块反汇编之后得到的字节码文件-------- */
public void incr();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // monitorenter进入同步
4: aload_0
5: dup
6: getfield #2 // Field i:I
9: iconst_1
10: iadd
11: putfield #2 // Field i:I
14: aload_1
15: monitorexit // monitorexit退出同步
16: goto 24
19: astore_2
20: aload_1
21: monitorexit // 第二次出现monitorexit退出同步
22: aload_2
23: athrow
24: return
Exception table:
// 省略其他字节码信息........
}
SourceFile: "SyncDemo.java"
跟synchronized有关系的我们只需要关注如下字节码:
3: monitorenter // monitorenter进入同步
15: monitorexit // monitorexit退出同步
21: monitorexit // 第二次出现monitorexit退出同步
从字节码中可知同步语句块的实现是基于进入管程 monitorenter 和 退出管程 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。伪代码如下:
monitorenter指令伪代码:
if(count == 0){
获取锁成功!
count = count + 1;
} else{
当前锁资源已被其他线程持有!
}
monitorexit指令伪代码:
count = count - 1;
但是值得注意的是,如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式结束,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束,这也是为什么在上述字节码文件中会出现两个 monitorexit 指令的原因。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令,确保在方法执行过程中是由于异常导致的方法意外结束时不出现死锁现象。
2.3、从反编译字节码理解synchronized修饰方法底层实现原理
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令时将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现,编译前Java源文件:
public class SyncDemo{
int i;
public synchronized void reduce(){
i++;
}
}
使用javac编译如上代码并使用Javap -p -v -c得到反汇编后得到如下字节码:
Classfile /C:/Users/XYSM/Desktop/com/SyncDemo.class
Last modified 2020-6-17; size 454 bytes
MD5 checksum 457e08e7b9caa345db5c5cca53d8d612
Compiled from "SyncDemo.java"
public class com.SyncDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
...... //省略常量池信息
{
int i;
descriptor: I
flags:
// 构造函数
public com.SyncDemo();
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 3: 0
// synchronized修饰方法
public synchronized void reduce();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 11: 0
line 12: 10
// 省略其他字节码信息........
}
SourceFile: "SyncDemo.java"
从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的是在flags: ACC_PUBLIC 之后增加了一个 ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized性能效率低的原因。不过值得庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。
三、Java6之后对于synchronized的优化 锁膨胀(锁升级)
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着线程的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级一般是单向的,也就是说只能从低到高升级,不会出现锁的降级,但是有个细节值得的注意,这个不会出现锁降级只是对于用户线程而言的,但是对于重量级锁的会出现锁降级的情况,降级发生于STW阶段,降级对象就是那些仅仅能被VMThread访问而没有其他JavaThread访问的Monitor对象(具体参考:重量级锁降级 )。关于重量级锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,不过毕竟涉及到具体过程比较繁琐,如需了解详细过程可以查阅《深入理解Java虚拟机原理》,因此在此并不会对锁升级进行细节性的分析而是阶段性的总结。
3.1、无锁态
当我们在Java程序中 new 一个对象时,会默认启动匿名偏向锁,但是值得注意的是有个小细节,偏向锁的启动有个延时,默认是4秒(JVM启动四秒之后再开启匿名偏向锁,在JVM启动时前四秒内new的对象不会启动匿名偏向锁)why?因为JVM虚拟机自己有一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。但是还有一个值得注意的是对于一个对象就算启动了匿名偏向锁,这个对象头中也没有任何的线程ID,因为是新创建的对象,所以对于一个新new对象而言,不管有没有启动匿名偏向锁都被称为概念上的无锁态对象,因为就算启动了匿名偏向锁,但是在没有成为真正的偏向锁之前,markword信息中的threadID是空的,因为此时没有线程获取该锁(但是当对象成为匿名偏向锁时mrakword中的锁标志位仍然会改为101:偏向锁的标志)。
3.2、偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。换句通俗易懂的话说:偏向锁其中的“偏”是偏心的偏。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不再需要去进行加锁或者解锁操作,而是会做以下的步骤:
- Load-and-test,也就是简单判断一下当前线程id是否与Markword当中的线程id是否一致
- 如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码
- 如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁”标志位的值
- 如果还未偏向,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作
但是当第二个线程来尝试获取锁时,如果此对象已经偏向了,并且不是偏向自己,则说明存在了竞争。此时会根据该锁的线程竞争情况,可能会产生偏向撤销,重新偏向,但大部分情况下就是膨胀成轻量级锁了。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失。
偏向锁撤销过程
- 在一个安全点停止拥有锁的线程。
- 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Markword,使其变成无锁状态。
- 唤醒当前线程,将当前锁升级成轻量级锁。
所以,如果程序中大量同步代码块大多数情况下都是有两个及以上的线程竞争的话,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就通过XX:-UseBiasedLocking
把偏向锁这个默认功能给关闭,从而做到性能上的优化。
偏向锁膨胀过程
当第一个线程进入的时候发现是匿名偏向状态,则会用cas指令把mark word中的threadid替换为当前线程的id如果替换成功,则证明成功拿到锁,失败则锁膨胀;
当线程第二次进入同步块时,如果发现线程id和对象头中的偏向线程id一致,则经过一些比较之后,在当前线程栈的lock record中添加一个空的Displaced Mark Word,由于操作的是私有线程栈,所以不需要cas操作,synchronized带来的开销基本可以忽略;当其他线程进入同步块中时,发现偏向线程不是当前线程,则进入到撤销偏向锁的逻辑,当达到全局安全点时,锁开始膨胀为轻量级锁,原来的线程仍然持有锁,如果发现偏向线程挂了,那么就把偏向锁撤销,并将对象头内的MarkWord改为无锁状态,锁膨胀,但是需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。
3.3、轻量级锁
倘若偏向锁失败,Synchronized并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时MarkWord的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。
轻量级锁膨胀过程
当锁膨胀为轻量级锁时,首先判断是否有线程持有锁(判断markwork),如果是,则在当前线程栈中创建一个lock record 复制mark word 并且cas的把当前线程栈的lock record 的地址放到对象头中(细节:之前持有偏向锁的线程会优先进行cas并将锁信息指针更改到对象头内mrakword中),如果成功,则说明获取到轻量级锁,如果失败,则说明锁已经被占用了,此时记录线程的重入次数(把lock record 的mark word 设置为null),锁会自旋(自适应自旋),确保在竞争不激烈的情况下,仍然可以不膨胀为真正意义上的“内核态重量级锁”从而减少消耗。如果cas失败,则说明线程出现竞争,需要膨胀为重量级的锁,代码如下:
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
markOop mark = obj->mark();
assert(!mark->has_bias_pattern(), "should not see bias pattern here");
// 如果是无锁状态
if (mark->is_neutral()) {
//设置Displaced Mark Word并替换对象头的mark word
lock->set_displaced_header(mark);
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
TEVENT (slow_enter: release stacklock) ;
return ;
}
} else
if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
assert(lock != mark->locker(), "must not re-lock the same lock");
assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock");
// 如果是重入,则设置Displaced Mark Word为null
lock->set_displaced_header(NULL);
return;
}
...
// 走到这一步说明已经是存在多个线程竞争锁了 需要膨胀为重量级锁
lock->set_displaced_header(markOopDesc::unused_mark());
ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);
}
不过需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁,但在JDK1.4之后,膨胀到重量级锁阶段后,最开始的重量级锁不会直接进入内核态级别的重量锁,而是会进入一个“自旋锁”阶段,后续被优化成了自适应自旋。
轻量级锁小细节
轻量级锁主要有两种类型:
- 自旋锁:所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。
注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。
所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。
经验表明,大部分同步代码块执行的时间都是很短很短的,也正是基于这个原因,才有了轻量级锁这么个东西。- 自旋锁的一些问题:
- 如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在原地等待空消耗cpu,这会让人很难受。
- 本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁。
- 基于这些问题,我们必须通过
-XX:PreBlockSpin
给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。默认情况下,自旋的次数为10次或者自旋线程超过CPU一半会发生锁膨胀(自旋锁是在JDK1.4.2的时候引入的)。
- 自旋锁的一些问题:
- 自适应自旋锁:所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。
其大概原理是这样的(在重量级锁阶段自旋):
假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。
另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。
同时,当锁资源的竞争已经非常激烈后,自适应自旋存在的意义已经没有必要了,因为存在大量线程竞争同一把锁,就算自旋一段时间之后,其他线程还是需要继续自旋等待,此时自旋带来的开销已经大于:在内核态挂起线程的开销了,所以在竞争很激烈的情况下,自适应自旋阶段的自旋次数可能会为0,也就是不再尝试自旋,而是直接膨胀为真正意义上的“内核态重量级锁”。
3.4、重量级锁
关于重量级锁我们在前面已经详细分析过了,重量级锁就是我们传统意义上的锁了,当线程发生竞争,锁膨胀为重量级锁,对象的mark word 指向堆中的 monitor,此时会将线程封装为一个objectwaiter对象插入到monitor中的contextList中去,然后暂停当前线程,当持有锁的线程释放线程之前,会把contextList里面的所有线程对象插入到EntryList中去,会从EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人(应该是这样翻译),就是图中的Ready Thread,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁(这也是它叫"假定"继承人的原因)。
Monitor对象结构
如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,当被Object#notify唤醒后,会将线程从WaitSet移动到cxq或EntryList中去。需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。
3.5、锁状态总结
- JVM启动4S后创建对象默认匿名偏向锁和4S前普通对象 -------无锁态
- 只有一个线程进入临界区 -------偏向锁
- 多个线程交替进入临界区--------轻量级锁
- 多个线程同时进入临界区-------重量级锁
3.6、Object对象四种锁状态分析
public class ObjectHead {
public static void main(String[] args) throws InterruptedException {
/**
无锁态:虚拟机刚启动时 new 出来的对象处于无锁状态
**/
Object obj = new Object();
// 查看对象内部信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
/**
匿名偏向锁:休眠4S后再创建出来的对象处于匿名偏向锁状态
PS:当一个线程在执行被synchronized关键字修饰的代码或方法时,如果看到该锁
对象是处于匿名偏向锁状态的(标志位为偏向锁但是对象头中MrakWord内threadID
为空),那么这个线程将会利用cas机制把自己的线程ID设置到mrakword中,此后
如果没有其他线程来竞争该锁,那么这个线程再执行被需要获取该锁的代码将不需
要经过任何获取锁和释放锁的过程。
**/
Thread.sleep(4000);
Object obj1 = new Object();
System.out.println(ClassLayout.parseInstance(obj1).toPrintable());
/**
轻量级锁:对于真正的无锁态对象obj加锁之后的对象处于轻量级锁状态
**/
synchronized (obj) {
// 查看对象内部信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
/**
重量级锁:调用wait方法之后锁对象直接膨胀为重量级锁状态
**/
new Thread(()->{
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Thread.sleep(1);
synchronized (obj) {
// 查看对象内部信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
}
输出结果:
java.lang.Object object internals: 锁标志位状态:001:真正意义上无锁状态
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)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: 锁标志位状态:101:匿名偏向锁状态
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)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: 锁标志位状态:000:轻量级锁状态
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 18 f5 41 01 (00011000 11110101 01000001 00000001) (21099800)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: 锁标志位状态:010:重量级锁状态
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 5a de db 17 (01011010 11011110 11011011 00010111) (400285274)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
/**抛出异常原因:违法的监控状态异常。当某个线程试图等待一个自己并不拥有的对象(Obj)的监控器或者通知其他线程等待该对象(Obj)的监控器时,抛出该异常。**/
Exception in thread "Thread-0": java.lang.IllegalMonitorStateException
at java.lang.Object.wait(Native Method)
at java.lang.Object.wait(Object.java:502)
at com.sixstar.springbootvolatilesynchronized.Synchronized.ObjectHead.lambda$main$0(ObjectHead.java:27)
at java.lang.Thread.run(Thread.java:748)
四、Synchronized关键字细节点及其他特性分析
4.1、同步消除
同步消除是虚拟机另外一种对于锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在appendString方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
情况一:
public void appendString(String s1, String s2) {
/*
StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
因此sb属于不可能共享的资源,JVM会自动消除内部的锁
*/
StringBuffer sb = new StringBuffer();
sb.append(s1).append(s2);
}
情况二:
StringBuffer sb = new StringBuffer();
public synchronized void appendString(String s1, String s2) {
/*
StringBuffer是线程安全,由于sb是在appendString方法中使用,而appendString
是被synchronized修饰的,是线程安全的,那么没有必要再这里获取两把锁
因此JVM会自动消除内部的锁,有些小伙伴看到这里会疑惑,这不是锁重入吗?
其实并不是,锁重入指的是同一个锁资源被线程多次获取时直接跳过获取锁逻辑,稍后会分析
*/
sb.append(s1).append(s2);
}
4.2、Synchronized重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:
public class SyncIncrDemo implements Runnable{
//共享资源(临界资源)
static int i = 0;
//synchronized关键字修饰实例成员方法
public synchronized void incr(){
i++;
}
@Override
public void run() {
synchronized(this){
for(int j=0;j<1000;j++){
incr();
}
}
}
public static void main(String[] args) throws InterruptedException {
SyncIncrDemo syncIncrDemo = new SyncIncrDemo();
Thread t1=new Thread(syncIncrDemo);
Thread t2=new Thread(syncIncrDemo);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
上述代码中,当我创建一个SyncIncrDemo实例以及启动两个线程,线程启动后会去执行run方法,而在run方法内部使用了synchronized修饰代码块并将this对象作为锁资源,那么线程必须先获取当前实例syncIncrDemo的锁资源才能执行for循环代码,而当一个线程成功获取到锁时会发现for循环内部调用的是该类另外一个被synchronized修饰的成员实例方法incr(),那么难道当前线程再去获取一次当前实例锁资源?我们在前面分析到,成员实例方法最终的锁对象还是当前this实例对象,那么当前线程需要获取当前实例两次锁资源?并不需要,因为此类情况就是重入锁最直接的体现,不过值得注意的是synchronized是基于Monitor实现的,每次重入时monitor中的计数器仍然会+1,还有一个细节需要稍微留意,就是当当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。
4.3、Thread的等待唤醒机制与synchronized关键字
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。
Object obj = new Object();
synchronized (obj) {
obj.wait();
obj.notify();
obj.notifyAll();
}
需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后才能继续执行,而sleep方法只让线程休眠并不释放锁(for(;;){})。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。
4.4、Thread的中断机制与synchronized关键字
4.4.1、线程中断
关于Java中线程对象调用start()方法之后如果想中止该线程可以调用Thread类中的stop()方法强制让该线程停止,但是非常遗憾的是stop()方法的使用是强制式停止的,因此会造成很严重的问题,在JDK版本1.2之后被遗弃,所以在目前的Java版本中并没有提供给开发者能够强制性停止正在执行(跑run方法)的线程,取而代之的是协调式的方式,在目前的Java版本中提供了如下三个有关线程中断的API:
//中断线程(实例方法)
public void Thread.interrupt();
//判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();
//判断是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();
当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用Thread.interrupt()方式中断该线程,注意此时将会抛出一个InterruptedException的异常,同时中断状态将会被复位(由中断状态改为非中断状态),如下代码将演示该过程:
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread() {
@Override
public void run() {
//while在try中,通过异常中断就可以退出run循环
try {
while (true) {
//当前线程处于阻塞状态,异常必须捕捉处理,无法往外抛出
TimeUnit.SECONDS.sleep(2);
}
} catch (InterruptedException e) {
System.out.println("Interruted When Sleep");
boolean interrupt = this.isInterrupted();
//中断状态被复位
System.out.println("interrupt:"+interrupt);
}
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
//中断处于阻塞状态的线程
t1.interrupt();
/**
* 输出结果:
Interruted When Sleep
interrupt:false
*/
}
如上述代码所示,我们创建一个线程,并在线程中调用了sleep方法从而使用线程进入阻塞状态,启动线程后,调用线程实例对象的interrupt方法中断阻塞异常,并抛出InterruptedException异常,此时中断状态也将被复位。这里有些人可能会诧异,为什么不用Thread.sleep(2000);而是用TimeUnit.SECONDS.sleep(2);其实原因很简单,前者使用时并没有明确的单位说明,而后者非常明确表达秒的单位,事实上后者的内部实现最终还是调用了Thread.sleep(2000);,但为了编写的代码语义更清晰,建议使用TimeUnit.SECONDS.sleep(2);的方式,注意TimeUnit是个枚举类型。除了阻塞中断的情景,我们还可能会遇到处于运行期且非阻塞的状态的线程,这种情况下,直接调用Thread.interrupt()中断线程是不会得到任响应的,如下代码,将无法中断非阻塞状态下的线程:
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(){
@Override
public void run(){
while(true){
System.out.println("未被中断");
}
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
t1.interrupt();
/**
* 输出结果(无限执行):
未被中断
未被中断
未被中断
......
*/
}
虽然我们调用了interrupt方法,但线程t1并未被中断,因为目前Java中的线程中断都是协调式的,在这里只是由mian线程向t1线程发送一个中断信号,但是t1线程还在执行,那么它并不会停止,所以对于处于非阻塞状态的线程需要我们手动进行中断检测并结束程序,改进后代码如下:
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(){
@Override
public void run(){
while(true){
//判断当前线程是否被中断
if (this.isInterrupted()){
System.out.println("线程中断");
break;
}
}
System.out.println("已跳出循环,线程中断!");
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
t1.interrupt();
/**
* 输出结果:
线程中断
已跳出循环,线程中断!
*/
}
是的,我们在代码中使用了实例方法isInterrupted判断线程是否已被中断,如果被中断将跳出循环以此结束线程,注意非阻塞状态调用interrupt()并不会导致中断状态重置。综合所述,可以简单总结一下中断两种情况,一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位,另外一种是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)。有时我们在编码时可能需要兼顾以上两种情况,那么就可以如下编写:
public void run(){
try {
//判断当前线程是否已中断,注意interrupted方法是静态的,执行后会对中断状态进行复位
while (!Thread.interrupted()) {
TimeUnit.SECONDS.sleep(2);
}
} catch (InterruptedException e) {
}
}
4.4.2、线程中断与synchronized
事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。演示代码如下:
public class SyncBlock implements Runnable{
public synchronized void occupyLock() {
System.out.println("Trying to call occupyLock()");
while(true) // 从不释放锁
Thread.yield();
}
/**
* 在构造器中创建新线程并启动获取对象锁
*/
public SyncBlock() {
//该线程已持有当前实例锁
new Thread() {
public void run() {
occupyLock(); // 当前线程获取锁
}
}.start();
}
public void run() {
//中断判断
while (true) {
if (Thread.interrupted()) {
System.out.println("中断线程!!");
break;
} else {
occupyLock();
}
}
}
public static void main(String[] args) throws InterruptedException {
SyncBlock sync = new SyncBlock();
Thread t = new Thread(sync);
//启动后调用occupyLock()方法,无法获取当前实例锁处于等待状态
t.start();
TimeUnit.SECONDS.sleep(1);
//中断线程,无法生效
t.interrupt();
}
}
我们在SyncBlock构造函数中创建一个新线程并启动获取调用启动后调用occupyLock()获取到当前实例锁,由于SyncBlock自身也是线程,启动后在其run方法中也调用了启动后调用occupyLock(),但由于对象锁被其他线程占用,导致t线程只能等到锁,此时我们调用了t.interrupt();但并不能中断线程。
4.5、为什么说synchronized能够保证有序性却不能禁止指令重排序?
在阐述这个问题答案之前如果有小伙伴对于指令重排序、有序性这些概念还不太清楚的那么请先移步我的另外一篇文章:玩命死磕Java内存模型(JMM)与Volatile关键字底层原理。
实际上synchronized关键字所保证的原子性、可见性、有序性实际上都是基于一个思路:将之前的多线程并行执行变为了单线程的串行执行。在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。那么实际对于单线程而言,所有操作都是有序的,因此synchronized将之前的多线程并行执行变为了单线程的串行执行之后必然可以保证“有序性”,而对于单线程而言,指令重排是对单线程的执行有利的,那么没有必要去禁止指令重排序,禁止了反而影响单线程的性能。所以对于这个问题,为什么说synchronized能够保证有序性却不能禁止指令重排序?那是因为synchronized没有必要禁止指令重排序。
4.6、synchronized与ReentrantLock相比性能不好的原因
synchronized是基于进入和退出管程Monitor实现的,而Monitor底层是依赖于底层OS的Mutex Lock,获取锁和释放锁都需要经过系统调用,而系统调用涉及到用户态和内核态的切换,会经过0x80中断,经过内核调用后再返回用户态,因此而效率低下。而ReentrantLock底层实现依赖于特殊的CPU指令,比如发送lock指令和unlock指令,不需要用户态和内核态的切换,所以效率高(这里和volatile底层原理类似)。
五、Hotspot源码深度解读Synchronized关键字原理
关于此内容由于本文章篇幅已经过长了,关于Hotspot层面的源码解读打算另开一章来阐述,如果对于源码层面的实现有兴趣的小伙伴移步:
死磕并发之深入Hotspot源码剖析Synchronized关键字实现。
六、参考资料
- 《深入理解JVM虚拟机》
- 《Java并发编程之美》
- 《Java高并发程序设计》
- 《亿级流量网站架构核心技术》
- 《Java并发编程实战》