一、互斥同步
在前面我们了解了什么是线程安全与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)竞争重量级锁的线程的流转过程。