Java内存模型JMM之六深入理解synchronized(1)
一、互斥同步
在前面我们了解了什么是线程安全与synchronized的基本应用,那么如何才能实现线程安全?互斥同步是最常见的一种并发线程安全保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程使用,互斥是实现同步的一种手段,临界区、互斥量和信号量都是互斥典型的实现方式。故:互斥是方法,同步才是目的。在Java里面,最基本的互斥同步手段就是使用synchronized。
二、synchronized字节码层面的实现方式
Java虚拟机基于进入和退出Monitor对象来实现synchronized代码块同步和方法同步,但二者在字节码层面的表现略有差别。
1. synchronized同步块:通过javap命令反汇编查看经过javac编译过的synchronized同步块代码,可以看到在同步块的开始和结束(包括异常退出)处分别插入了monitorenter和monitorexit字节码指令。这两个指令都需要一个引用类型的参数来指明用于锁定和解锁的对象。这也就说明了synchronized锁住的是对象而不是代码片段。在Java中,任何一个对象都有一个Monitor(锁)与之关联,在执行monitorenter指令时,首先要去尝试获取对象的锁(Monitor),如果这个锁对象没被锁定或者已被当前线程拥有,那么锁的计数器加1,相应地,在执行monitorexit时,会将锁计数器减1.当计数器为0,锁就被释放。如果获取对象锁失败,当前线程就要阻塞等待,直到其被所拥有的线程释放为止。
2. synchronized方法:在字节码层面可能会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,而是通过设置Class文件的方法表中将该方法的access_flags字段中的ACC_SYNCHRONIZED标志位来表示该方法是同步方法,并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。其具体的实现细节同样可以使用monitorenter和monitorexit字节码指令来实现。
三、synchronized底层实现原理与锁优化
由于Java的线程是映射到操作系统的原生线程上的,而synchronized的实现又会阻塞/唤醒其他尝试获取对象锁失败的线程,阻塞和唤醒线程操作都需要操作系统来完成,并且需要从用户态转换到核心态,该转换过程需要耗费很多的处理器时间,对于代码简单的同步代码,状态转换消耗的时间可能比用户代码执行的时间还要长,所以synchronized是Java中重量级锁。
jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,按照升级次序依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
3.1 Object Header对象头
因为synchronized相关的锁信息作为运行时数据存放在对象头中,所以在研究 synchronized之前,必须先了解清楚对象头。在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
HotSpot虚拟机的对象头分为两部分:Mark Word(标记字段)、Klass Pointer(类型指针)。但如果对象是数组类型,还会有一个额外的部分用于存储数组长度。对象头每一部分一般都占一个机器码字宽(32位虚拟机中一个字宽位4字节,32bit,64位虚拟机则为8字节,64bit)。 对象头结构:
长度 | 内容 | 说明 |
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Klass Pointer | 对象指向它的类元数据的指针,JVM通过这个指针确定该对象是哪个类的实例 |
32/64bit | Array length | 数组的长度(如果当前对象是数组才有这部分) |
3.1.1 Mark Word
从对象头的结构可以看出, Mark Word才是我们研究锁的关键部分,对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
从对象头的Mark Word的运行时数据结构可以看出,在不同的锁状态下,其存放的相关信息各不相同。下面我们一一进行分析。
3.2 无锁态
在32位的HotSpot的虚拟机中, Mark Word的32个bit用于存储对象的哈希码(hashCode),4个bit存储对象的分代年龄,1个bit固定为0,标识为非偏向锁,2个bit用于存储无锁标志位。
3.3 轻量级锁
了解轻量级锁之前,从Mark Word可以看出,在轻量级锁状态下,Mark Word中保存的将是指向线程栈中锁记录的指针,那么什么是锁记录呢?
锁记录(以下称Lock Record)是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个Lock Record关联(通过对象头中的Mark Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下图所示为Lock Record的内部结构:
Owner:初始时为NULL表示当前没有任何线程拥有该Lock Record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住Lock Record失败的线程。
RcThis:表示blocked或waiting在该Lock Record上的所有线程的个数。
Nest:用来实现重入锁的计数。
HashCode:保存从对象头Mark Word拷贝过来的HashCode值(可能还包含GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。
轻量级锁具体实现:
轻量级锁是JDK1.6中才加入的锁机制,它并不是用来替代重量级锁的,而是为了在没有多线程竞争(多线程交替执行)的条件下,减少相较于传统的重量级锁使用操作系统互斥量实现产生的大量性能消耗。这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程交替执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。在JDK1.6中默认是开启轻量级锁机制的。
当一个线程欲进入同步代码块的时候,轻量级锁的加锁(monitorenter)过程为(源码在synchronizer.cpp文件的ObjectSynchronizer::slow_enter):
(1)判断锁对象的对象头的Mark Word是否是无锁态。
a. 如果是无锁态:线程首先从自己的可用Lock Record列表中取得一个空闲的Lock Record,初始Nest和Owner值分别被预先设置为1和该线程自己的标识。一旦Lock Record准备好,复制对象头中的Mark Word到该Lock Record中。然后通过CAS原子指令尝试将对象的Mark Word更新为指向该Lock Record的指针。如果该CAS操作成功,表示竞争到轻量级锁,则将锁标志位设为00(表示此对象处于轻量级锁状态)。如果CAS操作失败,则表示存在其他线程竞争锁的情况,那么重新执行加锁过程,即从(1)重新开始开始。
(2) 如果锁对象的对象头的Mark Word处于轻量级锁态: 但是Owner中保存的线程标识为获取锁的当前线程自己,这就是重入锁的情况,只需要简单的将Nest加1即可。不需要任何原子操作,效率非常高。
(3) 如果锁对象的对象头的Mark Word处于轻量级锁态: 但是Owner的值为Null.当一把锁上存在被阻塞或等待的线程,并且锁的前一个拥有者刚刚释放锁时就会出现这种状态。此时多个线程通过CAS原子指令在多线程竞争状态下都试图将Owner设置为自己的标识来获得锁,竞争失败的线程则会进入到(4)的执行路径。
(4)如果锁对象的对象头的Mark Word处于轻量级锁态,并且Owner的值不为Null,也不是当前想获取锁的线程自己:在调用操作系统的重量级的互斥锁(即膨胀为重量级锁)之前先自旋一定的次数,当达到一定的次数时如果仍然没有成功获得锁,则开始准备进入阻塞状态,首先将rcThis的值原子性的加1,由于在加1的过程中可能会被其他线程破坏Mark Word和monitor record之间的关联关系,所以在原子性加1后需要再一次比较以确保Mark Word的值没有被改变,当发现Mark Word被改变后则重新执行加锁过程,即从(1)重新开始开始。这次就可能会执行到(3) ,即Owner为NULL,如果在(3)中锁再次竞争失败则进入到阻塞状态而不是又进入(4)形成死循环。
轻量级锁释放(monitorexit)过程如下(源码在synchronizer.cpp文件的ObjectSynchronizer::fast_exit完成):
(1)首先检查该对象是否处于轻量级锁状态并且该线程是这个锁的拥有者,如果发现不对则抛出异常;
(2)检查Nest字段是否大于1,如果大于1则简单的将Nest减1并继续拥有锁,如果等于1,则进入到第(3)步;
(3)检查rcThis是否大于0,如果是则设置Owner为NULL然后唤醒一个正在阻塞或等待的线程再一次试图获取锁,如果等于0则进入到第(4)步
(4)通过将对象的Mark Word置换回原来的HashCode值,解除和Lock Record之间的关联来释放锁,同时将Lock Record放回到线程的可用Lock Record列表中。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内线程都是交替执行,不存在竞争的”,这是一个经验数据,如果不存在竞争,轻量级锁使用CAS操作从而避免了使用操作系统互斥量的重量级锁开销,但是如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作的消耗,因此在有竞争的情况下,轻量级锁会比重量级锁更慢开销更大。所以应该视具体情况分析是否适用轻量级锁。
3.4 偏向锁
偏向锁也是JDK1.6中引入的一项锁优化, 它的目的是消除无竞争情况下锁的性能问题,因为研究发现,在大多数情况下,锁不但不存在多线程竞争,而且总是由同一个线程多次获得。因此为了减少同一线程多次获取/释放轻量级锁时的多次CAS操作的代价而引入偏向锁。因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟,后续有CAS详解。偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活。
偏向锁的核心思想是:如果一个线程获得了锁,那么该锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
关于源码,在HotSpot中,偏向锁的入口位于synchronizer.cpp文件的ObjectSynchronizer::fast_enter函数。
偏向锁的加锁过程如下:
(1)检测Mark Word是否为可偏向状态,即是否为偏向锁为1,锁标识位为01
(2)若为可偏向状态,则测试线程ID是否指向当前线程,如果是,则直接执行同步代码块,否则进入步骤(3)。
(3)如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
偏向锁的释放采用了一种只有出现竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争(例如上面加锁的第四步)。 偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
(1)暂停/挂起拥有偏向锁的线程(不会释放锁),判断锁对象是否还处于被锁定状态;
(2)如果锁对象没有处于被锁定状态(表示之前拥有偏向锁的线程已经执行完毕),那么撤销偏向锁后恢复到无锁态(标志位为“01”)
(3)如果锁对象还是处于被锁定状态(表示之前拥有偏向锁的线程仍然还在运行),那么撤销偏向锁后恢复到轻量级锁状态。思考:就算持有偏向锁的线程依然活着,但是已经离开了synchronized同步块,是否也可以恢复到无锁态?
(4)唤醒被暂停/挂起的线程。
偏向锁可以提高带有同步但无多线程竞争的程序性能,但是如果程序中大多数的锁都总是被多个不同的线程访问或者锁竞争比较激烈,那么偏向模式就是多余的。 所以是否适用偏向锁,需具体问题具体分析。
3.5 重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,故切换成本非常高。
当锁处于重量级锁态时,Mark Word存储的就是指向monitor对象的指针。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
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 ;//处于等待锁被挂起的线程列表,JDK8默认策略下,是一个后进先出(LIFO)的队列,每次放入和取出都操作队头 FreeNext = NULL ; _EntryList = NULL ; //处于等待锁挂起状态的线程,有资格成为候选的线程会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }由以上ObjectMonitor的构造函数可以看出,ObjectMonitor中有三个重要队列,_cxq,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表。ObjectWaiter 对象里存放的就是thread(线程对象), 每一个等待锁的线程都被封装成一个ObjectWaiter对象,ObjectWaiter是一个双向链表结构的对象。_owner指向持有ObjectMonitor对象的线程,也就是当前拥有锁的线程。下图展示了JDK8默认设置下(Policy为2,QMode为0)竞争重量级锁的线程的流转过程。
根据上图竞争线程的流转图可以得出:
- Policy == 0:放入_EntryList队列的排头位置;
- Policy == 1:放入_EntryList队列的末尾位置;
- Policy == 2:_EntryList队列为空就放入_EntryList,否则放入_cxq队列的排头位置;
- Policy == 3:放入_cxq队列中末尾位置;
- QMode = 2,并且_cxq非空:取_cxq队列排头位置的ObjectWaiter对象并立即唤醒其中的线程并返回。
- QMode = 3,并且_cxq非空:把_cxq队列首元素放入_EntryList的尾部;
- QMode = 4,并且_cxq非空:把_cxq队列首元素放入_EntryList的头部;
- QMode = 0,不做什么,继续往下执行。
- 根据QMode不同策略操作完成(除开QMode==2立即返回)之后的后续操作为:取_EntryList的首元素,如果_EntryList为空就将cxq的首元素取出来放入_EntryList,然后再从_EntryList中取出来。
(1)检查是否是无锁状态,如果是,则走轻量级锁或偏向锁加锁过程,如果不是,就检查是否就是当前线程持有锁,如果是则执行重入锁逻辑,如果不是则调用inflate方法开始锁膨胀(源码在synchronized.cpp).
(2)膨胀:检查是否已经是重量级锁状态,如果是则返回相应的ObjectMonitor对象执行步骤(5)。如果不是则执行步骤(3);
(3)检查是否正处于膨胀中状态(即其他线程正在膨胀中),如果是则当前线程进行自旋等待膨胀完成,完成后获取到相应的ObjectMonitor对象执行步骤(5)。
(4)如果既不是重量级锁状态也不是正处于膨胀中,那么目前就是轻量级锁状态了,开始膨胀至重量级锁:通过CAS指令将OjectMonitor的状态设置为INFLATING,标识当前锁正在膨胀中,
如果CAS失败,说明同一时刻其他线程已经在膨胀了,则当前线程进行自旋等待膨胀完成,完成后获取到相应的ObjectMonitor对象执行步骤(5)。
如果CAS成功则表示设置了对象头指向的ObjectMonitor对象。返回相应的ObjectMonitor对象执行步骤(5)
(5)线程获取到相应的ObjectMonitor对象之后,真正的锁竞争才开始(源码在ObjectMonitor.cpp的enter函数),即通过CAS指令尝试将monitor的_owner字段设置为当前线程,
如果CAS成功则表是竞争到锁或者是重入。如果CAS失败表示竞争锁失败指向步骤(6)。
(6)将当前竞争失败的线程封装成ObjectWaiter对象的节点,通过CAS将其添加到cxq列表的头部,放入失败之后立即尝试竞争锁TryLock,竞争失败继续尝试放入cxq列表。
如此交替,如果没有竞争到锁导致被加入到cxq列表之后,最后执行一次TryLock,如果依然竞争失败,则执行park将线程挂起(这里有自旋逻辑,如果满足自旋条件则挂起操作将有超时时间,到达超时时间后自动唤醒)等待被唤醒,唤醒之后继续尝试, 如果失败继续挂起,如此交替。
3.6 自旋锁与自适应自旋
在上面的轻量级锁与重量级锁的加锁过程中,当出现锁竞争的时候,都有提到自旋操作,自旋的含义和作用也在重量级锁的流程图解释中给予了说明,即就是为了避免不必要的挂起和恢复线程时状态转换造成的性能消耗。其实现方式大概就是执行多次无意义的空循环,以达到不放弃处理器的执行时间。自旋锁在JDK1.4.2中就被引入,但默认是关闭的。在JDK1.6中就已经默认开启了,自旋时间周期的选择将决定自旋是否真正在起到提高性能的作用,因为长时间的自旋将导致白白的消耗处理器资源,这对提高性能来说反而起到了反作用。自旋次数的默认值是10次,一旦达到这个限定的阈值,线程将退出自旋进入阻塞状态。值得庆幸的是,在JDK1.6中还引入了自适应的自旋锁,其意味着自旋时间不再试固定的次数,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来动态决定。比如:在同一个锁对象上,上一次的自旋等待成功获得了锁,并且持有锁的线程处于运行中,那么虚拟机将认为这一次自旋也很可能会再次成功,进而可能将允许自旋的次数更多一些,比如100次循环。但是如果对于莫一个锁,自旋很少成功过(比如一些花费时间很长的同步块),那么在以后要获取这个锁时将可能直接省略掉自旋的过程,避免浪费处理器资源。
篇幅太长。。。。下篇继续。。。。。
关于以上synchronized的源码研究,可以参考如下文章:
https://www.jianshu.com/p/c5058b6fe8e5
http://blog.csdn.net/boling_cavalry/article/details/77793224
http://www.woowen.com/java/2017/01/01/JAVA%20Synchronized/
上一篇: java多线程学习之从正常到自残
下一篇: Synchronized