并发编程锁之synchronized总结

synchronized

并发编程中数据同步需要依赖锁进行控制,上篇博文通过ReentrantLock源码分析也对Lock实现锁机制的大致原理有了一个了解,Lock主要是通过编码的方式实现锁,其核心就是:CAS+循环,CAS原子操作需要依赖底层硬件层特殊的CPU指令。这节我们来看下Java中另一种非常常见的实现同步的方式:synchronized。synchronized主要通过底层JVM进行实现,而且JVM为了优化,产生偏向锁、轻量级锁、重量级锁,由于其处于JVM底层实现中,对很多并发编程人员来说能清晰理解它们间的区别还是件困难的事。通过本篇博文,构建出对Java中锁得体系结构,让你对其有个更系统全面的认知。

synchronized实现同步主要分为两种情况:
​ 1、同步方法:synchronized方法则会被翻译成普通的方法调用,在JVM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位设置成1,表示该方法是同步方法,当某个线程要访问某个方法的时候,使用调用该方法的对象(普通方法同步)或该方法所属的Class在JVM的内部对象表示Klass做为监视器锁(静态方法同步,全局锁),这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放
​ 2、同步代码块:对于同步代码块,JVM采用monitorenter、monitorexit两个指令来实现同步。monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应,这样就保证了执行monitorexit指令的线程是monitor监视器的所有者。根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取栈顶对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应地,在执行monitorexit指令时会将栈顶对象锁计数器减1,当计数器为0时,锁就会被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

对于同步方法或同步块,通过Class文件中的access_flags或monitorenter、monitorexit指令来标记执行这些代码时需要进行同步,但是具体如何进行同步呢?这就是接下来要分析的主要内容。再讲解同步之前,我们先来看下对象头,因为JVM中synchronized的实现关键就涉及到对象头的操作。

对象头

Java中对象的内存布局主要分为三个区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。synchronized的实现方式依赖于对象头,所以,这里我们先来简单介绍下Java中对象头。

1517815118540

实例数据区主要是实例属性数据存储区域,对齐填充在HotSpot中主要采用8字节对齐方式,对象头和实例数据区字节数不是8的倍数,采用对齐填充方式让其等于8的倍数。这里来看下对象头,如果是数组类型,其由MarkWord、length(数组长度)和Pointer,Pointer是指向该对象的元数据信息,即该对象的Class实例,对象的方法定义都是在Class实例中;如果是非数组类型,对象头只包含:MarkWord和Pointer两部分。

对象头具体描述如下:
1、如果是数组类型,则使用3个字宽存储对象头,如果是对象非数组类型,则使用2个字宽,在32位JVM中,一个字宽等于4字节,而64位JVM中,一个字宽等于8字节,即在32-bit JVM上对象头占用8bytes,在64-bit JVM上对象头占用16bytes(开启指针压缩后占用4+8=12bytes)
2、64位机器上,数组对象的对象头占用24 bytes,启用压缩之后占用16 bytes。之所以比普通对象占用内存多是因为需要额外的空间存储数组的长度,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
3、HotSpot虚拟机的对象头包括两部分信息:Mark Word(标记字段)和Klass Pointer(类型指针)
4、Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等
5、对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot 虚拟机中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示:

1517451612357

上图展示的是32位机器下对象头的情况,64位情况下原理大致一样而且更简单,这里就不再介绍了。 MarkWord里默认数据是存储对象的hashcode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式,当对象成为锁(被锁住)后,对象头里的MarkWord字段就会存储Monitor信息,Monitor信息可以理解为锁信息。

锁的状态可分为四种:无锁状态、偏向锁、轻量级锁和重量级锁,其实现原理要依赖对象头进行控制。再了解对象头的基础上,下面我们就可以分析每种锁的实现原理。

锁分类

早期,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统互斥Mutex Lock来实现的,而操作系统实现线程之间的阻塞、调度、唤醒等操作时需要从用户态切换到内核态,最后再由内核态切换到用户态,将CPU的控制权交由用户进程,用户态与内核态之间频繁的切换,严重影响锁的性能,这也是为什么早期的synchronized效率低的原因。

在Java 6之后Java官方对从JVM层面对synchronized进行较大优化,所以现在的synchronized锁效率也优化得很不错了,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁、轻量级锁和自旋锁等概念,下面就来分析下它们的原理。

轻量级锁

轻量级锁实现的背后基于这样一种场景假设:在真实生产环境下,我们程序中的大部分同步代码一般都处于无锁竞争状态,轻量级锁主要解决如下场景:线程A和线程B都要访问对象o的同步方法,但是它们之间不会同时访问,线程A访问完成后线程B再去访问,它们之间访问类似于交替访问,因此,这种情况下并不会产生锁竞争问题。在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,只需要依靠CAS原子指令就可以完成锁的获取及释放,但是当检测到存在锁竞争的情况下,轻量级锁就会膨胀为重量级锁。

下面通过如下同步代码块分析下轻量级锁实现的大致流程:

1
2
3
4
public class Obj {
public synchronized void fun1(){
}
}

​ 1、当代码进入同步块时,即调用Obj.fun1()方法,当Obj实例为无锁状态,即对象头的锁标志位为01,当前线程会在栈帧中创建一个锁记录(Lock Record),同时将锁对象Obj的对象头中MarkWord拷贝到锁记录中,因为栈是线程私有的,Java方法的调用就是通过栈帧得到入栈和出栈实现的,所以将锁记录保存到栈帧中,这一步主要完成MarkWord拷贝过程;

1517817478686

​ 2、将MarkWord拷贝到Lock Record中完成后,尝试使用CAS将MarkWord更新为指向锁记录的指针,如果更新成功,当前线程就获得了锁,同时更新锁标志位为00,表示当前对象处于轻量级锁状态

1517818199892

​ 3、更新失败情况主要如下:比如有两个线程A和线程B同时竞争锁,执行步骤1时由于当前对象处于无锁状态,所以这两个线程都会在它们的栈帧中创建Lock Record,然后将对象头中的MarkWord拷贝进去,然后它们都同时进入步骤2执行CAS原子操作将对象头中的锁指针指向自己栈帧中的Lock Record,所以,肯定有一个成功,另一个就会失败,成功的就是获取到偏向锁的线程,失败的就是没有获取偏向锁的线程。如果更新失败,JVM会先检查锁对象的MarkWord是否指向当前线程的锁记录,如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块,这是重入锁特性,不是则说明其有其它线程抢占了锁

​ 4、其它线程抢占了锁,说明存在锁竞争情况,这时轻量级锁并不为立即膨胀为重量级锁,而是进入自旋模式,自旋模式期间还是无法获取锁,就会膨胀为重量级锁,大致思路:尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有非常重要的性能提高。

为什么要进入自旋模式原因?

膨胀为重量级锁会涉及到有用户态切换到内核态进行线程的休眠和唤醒操作,然后再切换到用户态,这些操作给系统的并发性能带来了很大的压力,共享数据的锁定状态可能只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,可以让后面请求锁的那个线程”稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,只需要让线程执行一个忙循环(自旋),所以自旋会对CPU造成资源浪费,特别是长时间无法获取锁的情况下,所以自旋次数一定要设置成一个合理的值,而不能无限自旋下去。JDK1.6默认是开启了自旋锁功能,而且对自旋次数也不在是固定值,而是通过一套优化机制进行自适应,简化了对自旋锁的使用。

注意:自旋在多处理器上才有意义,这理解也很简单:自旋是不会释放CPU资源的,在单处理器上如果某个线程处于自旋状态,也就意味着没有其它线程处于同时处于运行状态,也就在自旋期间不可能存在线程释放锁资源。所以,单处理上自旋是没有意义的,不过现在服务器一般不可能运行在单处理器上。

​ 5、如何膨胀为重量级锁呢?
​ a.步骤4中在自旋模式下依然无法获取锁,即会膨胀为重量锁
​ b.首先当前线程会修改Obj对象头中锁标志位,由代表轻量级锁的00修改成代表重量级锁的10,然后当前线程进入休眠模式,当然了再进入休眠模式之前还会进行一些操作,这里先这么理解,后面分析重量锁时具体流程再分析说明
​ c.当持有Obj对象偏向锁的线程执行完同步方法后,会通过一次CAS原子操作将对象头中的MarkWord由当前栈帧中的Lock Record进行重置回之前内容,如果重置成功,则释放锁完成;但是,根据上步骤我们知道,由于当前已膨胀为重量级锁,导致Obj对象的MarkWord中的锁标志位已被修改,CAS重置对象头操作会失败,这时就会感知到:在偏向锁运行期间,存在了其它线程竞争锁资源情况,当前锁已被膨胀为重量级锁,所以,在释放锁得到同时,会唤醒应等待该锁导致休眠的线程

轻量级锁是不支持”并发”,遇到”并发”就要膨胀为重量级锁。可能你会疑问:锁就是用来解决并发下资源同步问题,轻量级锁对“并发”都不支持要它能干什么呢?注意:此并发并非彼并发,这里的并发是带有引号的,即不存在锁竞争的并发。

轻量级锁在申请锁资源时通过一个CAS操作即可获取,释放锁资源时也是通过一个CAS操作即可完成,CAS是一种乐观锁的实现机制,其开销显然要比互斥开销小很多,这就是轻量级锁提升性能的核心所在。但是,轻量级锁只是对无锁竞争并发场景下的一个优化,如果锁竞争激烈,轻量级锁不但有互斥开销,还要多一次CAS开销,这是轻量级锁比重量级锁性能更差。所以,JVM检测到锁竞争时自动膨胀为重量级锁原因就在于此。

偏向锁

轻量级锁优化了并发情况下串行化访问的场景,即下面示意图中的场景一,现在有个更极端情况:假如一段时间间隔内同步方法只会被同一个线程多次访问,即下面示意图中的场景二,从总体看同步方法是在单线程环境中运行。如果使用轻量级锁,每次调用同步方法要通过一次CAS操作申请锁,执行完后同样通过一次CAS操作释放锁,如下面场景二产生了7次调用共要执行14次CAS操作,还不包括其它开销。JVM工程师们对场景二进一步进行优化:只会在线程第一次调用同步方法时获取锁,执行完成后不去释放,后面该线程再次进入时不需要再次获取锁,直接进入,只有当其它线程申请锁时才会释放,因此,同样的场景二,偏向锁只会产生2次CAS操作。

1517823994203

偏向锁的引入,主要是JVM工程师们经过研究发现:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。偏向锁是对轻量级锁的进一步优化,轻量级锁优化了并发时串行化执行的场景,而偏向锁是对并发时”单线程”场景的优化。

默认JVM是开启偏向锁特性,但是默认JVM启动后的的头4秒钟这个feature是被禁止的,这也意味着在此期间,prototype MarkWord会将它们的bias位设置为0,以禁止实例化的对象被偏向。4秒钟之后,所有的prototype MarkWord的bias位会被重设为1,如此新的对象就可以被偏向锁定了,当然也可以通过如下方式缩短这个延迟:

1
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

偏向锁的MarkWord信息如下:

1517840236294

批量重偏向&批量撤销

存在如下两种情况:
​ 1、对于存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列,生产者线程获得了偏向锁,消费者线程再去获得锁的时候,就涉及到这个偏向锁的撤销(revoke)操作,而这个撤销是比较昂贵的,而且在多生产者、多消费者情况下,这种状况更加糟糕,而且可能程序中使用了大量的这种队列,解决方案就是:识别出这些对象并禁止它们使用偏向锁特性;
​ 2、还存在这样对象集,它们偏向的线程并不合适,但是重新偏向另外线程确实合适的,例如线程t1初始化了大量对象obj,然后对每个对象执行了用于初始化的同步方法,这样导致这组对象集偏向锁中的threadID都指向了t1,但是如果另外一个线程开始真正指向obj对象集上的同步方法,这就导致了大量偏向锁的revoke操作

怎么判断对象是否适合偏向锁呢?解决方案是:jvm采用以class类为单位的做法,其内部为每个类维护一个偏向锁计数器,对其对象进行偏向锁的撤销操作进行计数。当这个值达到指定阈值的时候,jvm就认为这个类的偏向锁有问题,需要进行重偏向(rebias),对所有属于这个类的对象进行重偏向的操作叫批量重偏向(bulk rebias)。

之前的做法是对heap进行遍历,但是这种实现方式如果堆增加到很大时是会存在性能问题的,后来便引入epoch。Class实例中包含了MarkWord原型–mark_prototype属性,该属性中的bias决定了该类型的对象是否允许被偏向锁定,与此同时,当前的epoch位也被保留在mark_prototype中。当需要bulk rebias时,对这个类的epcho值加1,以后分配这个类的对象的时候mark字段里就是这个epoch值了,同时还要对当前已经获得偏向锁的对象的epoch值加1,当然是在线程处于安全点时停止线程执行更新。对于那些正在运行且持有偏向锁的线程,由于没法更新导致对象头中的epoch和mark_prototype的epoch值不匹配,即偏向锁状态失效,下一个试图获取锁对象的线程使用原子CAS指令可将该锁对象绑定于当前线程。

偏向撤销(revoke):如果一个新线程申请偏向锁,发现该对象已经处于偏向锁状态,就会去判断epoch是否有效且线程ID是否指向自己,如果无效或线程ID并没有指向自己,需要让偏向锁撤销并重新偏向自己。在重新偏向自己之前,还回去判断之前线程是否还在运行,如果还在运行是否还在继续使用锁,如果还在继续使用锁则产生锁竞争,偏向锁会被膨胀为轻量级锁,否则,新线程通过CAS原子操作将对象头中的线程ID重新偏向新线程。

批量重偏向导致对象头中的线程ID指向被重置为null,即线程重新通过CAS操作获取偏向锁。简单理解:批量重偏向是对当前类型下的对象偏向锁的一次校正,因为当前该类型的偏向锁存在大量的revoke被JVM判定是存在问题的偏向锁,批量重偏向后这个类的revoke计数器会被重置,如果这个类的revoke计数器继续增加到一个阈值,可能会继续进行一次批量重偏向,也可能不再继续批量重偏向,就这样继续1到多次批量重偏向后,jvm就认为这个类不适合偏向锁了,就要进行批量撤销(bulk revoke),将该类的Class的mark_prototype中的bias属性设置成0,表示该类型下所有对象不允许被偏向锁定,同时将已存在的偏向锁膨胀为轻量级锁。

在批量重偏向(bulk rebias)的操作中,prototype的epoch位将会被更新;在批量吊销(bulk revoke)的操作中,prototype将会被置成不可偏向的状态——bias位被置0。

下面通过如下同步代码块分析下偏向锁实现的大致流程:

1
2
3
4
public class Obj {
public synchronized void fun1(){
}
}

​ 1、检测对象类型class中的bias设置是否允许偏向锁特性,只有开启此特性才能使用偏向锁
​ 2、检测Obj对象头中MarkWord锁标识位等于01,代表无锁状态或已处于偏向锁状态,否则不能进行偏向锁设置;
​ 3、检测Obj对象头中MarkWord偏向标识位,如果等于0,表示当前对象处于无锁状态,通过一次CAS原子操作将对象头线程ID设置成当前线程ID,设置成功则获取偏向锁成功
​ 4、检测对象头中MarkWord偏向标识位,如果等于1表示当前已处于偏向锁状态,然后检测MarkWord中的线程ID是否等于当前线程ID,不等于则进入步骤(5),等于会再进行判断epoch是否等于Obj类型的Class实例中的epoch,不等于说明该偏向锁失效,进入步骤(5),等于则表明获取偏向锁成功,进入同步方法
​ 5、监测偏向锁指向的线程是否还在运行,没有运行则执行步骤(6),否则继续判断该线程是否还在持有锁,如果没有持有则执行步骤(6),如果线程还在持有锁,则说明产生了锁竞争,会在持有偏向锁线程运行到全局安全点(这个时间点上没有正在执行的代码)时挂起运行线程,并将偏向锁膨胀为轻量级锁
​ 6、使用CAS原子操作将对象头MarkWord中的线程ID设置成当前线程ID

偏向锁的核心思想是,锁不存在多线程竞争,且一个线程获取锁后接下来继续获取该锁的概率更大,可见偏向锁模式下线程是不会主动去释放偏向锁,只有其它线程来竞争该偏向锁时才会考虑撤销或膨胀。偏向锁解决了一次CAS操作可以实现任意多次调用,节省了每次调用申请锁、释放锁性能消耗,避免了轻量级锁产生大量的CAS操作导致的性能消耗,从而提升锁性能。和轻量级锁一样,偏向锁并不能解决锁竞争问题,一旦遇到锁竞争偏向锁就会膨胀为轻量级锁,轻量级锁也不能解决锁竞争问题,为什么不直接膨胀为重量级锁呢?如果锁竞争不是很激烈或者竞争时间非常短暂,前面介绍过轻量级锁有个自旋模式,可以通过自旋模式补救避免因偶然的误差导致直接膨胀为重量级锁。如果自旋模式也无法解决,说明说竞争可能确实激烈,轻量级锁也无能为力了,只能膨胀为重量级锁。

另外,偏向锁也不适合像生产者/消费者这种线程交替获取锁模式,这样可能会导致产生大量的偏向锁撤销和重偏向操作,得不偿失。

重量级锁

通过前面分析发现,无论是轻量锁还是偏向锁,都不能代替重量锁,都只是在无锁竞争或者竞争不是很激烈的情况下进行的一些性能优化,减少重量锁产生的性能消耗,并不能真正解决锁竞争问题。轻量锁和偏向锁都是重量锁的乐观并发优化,因为它们都是通过CAS原子操作尝试性获取锁,在锁竞争不是很激烈情况下,尝试性获取锁的概率当然就会很大,避免了由用户态切换到内核态,借助系统的Mutex Lock互斥锁实现线程协调的过程,但是一旦锁竞争激烈,还是需要借助于重量级锁特性才能解决。

synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么synchronized效率低的原因。

当锁被膨胀为重量级锁后,锁标识位会被设置成10,同时对象头会指向一个monitor对象,它会管理协调这些竞争锁资源的线程们。大致示意图如下:

1517900250327

流程如下:

​ 1、如果线程A执行Obj对象的同步方法,通过对象头查找到Monitor的位置,然后线程A会进入WaitQueue区域,该区域主要是用于存储所有竞争锁资源的线程,多个线程同时竞争锁资源,只会有一个线程竞争成功,其它线程就会存储到该区域中,该区域主要维护两个队列:
​ a.Contention List:所有请求锁的线程将被首先放置到该竞争队列中
​ b.Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List,这个设计一方面也是从性能方面考虑:Contention List在高并发场景下不断的有新线程加入该队列,并且存在多个线程同时操作Content List,所以要进行同步控制,如果锁释放时直接从Contention List获取线程显然存在并发访问问题。所以,Owner线程首先会从Contention List迁移出一批线程到Entry List中,锁资源释放时从Entry List中获取线程,一般都是将Entry List的head赋值给OnCheck,Entry List不会存在并发访问问题,因为只有Owner线程才会从Entry List中提取数据,且也只有Owner才能从Contention List迁移线程到Entry List中,所以性能更好,只有等Entry List使用完为空时,Owner线程会再次从Contention List迁移一批线程放入到Entry List中

​ 2、Ready Thread区域主要是存储下一个可以参与竞争锁资源的线程,等锁资源释放时让OnCheck指向的线程参与锁竞争,OnCheck一般指向的是Entry List的head位置。注意:等待队列中只会有一个线程参与竞争,一般是FIFO方式参与竞争,避免所有等待线程一起竞争锁资源造成性能问题。

OnCheck要竞争锁资源,而不是将Owner的锁资源直接传递给OnCheck线程,OnCheck只代表有资格竞争锁资源的线程,竞争锁资源就意味着可能会失败,失败就意味着这是一种非公平锁的实现机制。到底哪些线程会和OnCheck线程竞争锁资源呢?就是当前新加入申请锁资源的线程们,因为我们知道,只有申请锁资源失败的线程才会放入到Contention List,现在假如新加入的线程还在刚申请,走了狗屎运这时刚好Owner线程释放了锁资源,这就导致了这些新加入线程会和OnCheck一起竞争锁资源,这些新加入的线程可能优先竞争到锁资源,这就是非公平性的体现。这么做主要是从性能方面考虑,毕竟新线程如果竞争失败要做一大堆初始化工作然后放入到等待队列Contention List中,而OnCheck线程竞争失败只需要重新阻塞即可,显然工作量要小很多。但是,进入等待队列中的线程基本上是按照先进先出FIFO策略获取到锁资源的,因此,这种机制只会牺牲一定的公平性。另外,至少OnCheck线程还可以参与竞争,而不是从性能考虑直接让新线程获取到锁,避免等待队列中线程饿死现象。这里的实现和之前分析的ReentrantLock的思想基本一致,可以参考之前ReentrantLock实现机制加深对这块的理解。

​ 3、Running Thread区域主要是存储当前获取到锁后正在运行的线程,使用Owner指向当前运行线程

​ 4、Blocking Queue区域主要是存储那些获取到锁资源但是调用wait等方法被阻塞的线程,由于wait操作会释放当前锁,即Owner会被重置为null,当前线程进入WaitSet中,同时OnCheck线程参与锁竞争获取锁资源,等被阻塞的线程被唤醒后会被移入Entry List重新等待获取锁资源

只有获取到某个对象的锁时才能调用该对象的wait()让当前线程挂起,也就是如下代码:

1
2
3
4
5
6
7
> Object obj = new Object();
> public void fun2() throws InterruptedException {
> synchronized (obj){
> obj.wait();
> }
> }
>

>

也就是只有获取obj对象的锁才能调用obj.wait()让当前线程挂起到obj对象上,同样唤醒该对象时也只有先获取obj锁时才能调用obj.notify()或obj.notifyAll()唤醒obj对象上阻塞的线程。这种设计是如何实现的呢?通过这里对Monitor结构的分析,你可能很容易就想到:
​ 1、Monitor是线程私有的,也就是只会被当前锁资源持有线程就是Monitor对象的拥有者,即Owner指向的线程
​ 2、只有Monitor的拥有者才能调用wait()方法释放监视锁,该线程进入阻塞队列,其它竞争锁资源新线程重新拥有Monitor线程
​ 3、同理,只有Monitor拥有者才能调用notify()/notifyAll(),这时会从Blocking Queue队列中将阻塞线程移入到Entry List,等待重新获取锁
​ 4、wait()、notify()和notifyAll()都是只有Monitor的拥有者线程才能调用,而Monitor的拥有者线程就是当前持有obj对象锁的线程

多个线程竞争锁资源借助底层系统的Mutex Lock互斥锁实现,需要由用户态切换到内核态,由内核协调哪个线程获取到锁,哪些线程无法获取到锁,获取锁失败的线程会被内核进行阻塞,线程阻塞才能释放CPU资源。系统执行完后,会由内核态重新切换到用户态,将CPU的控制权交给获取锁的线程进行执行。

内核切换属于操作系统范畴,想了解的可以自行搜索资料学习。这里大致简单描述下:
​ 1、程序经过编译最终会被翻译成机器指令进行执行
​ 2、如果程序执行的是“1+1”这种简单指令,CPU获取到这个指令后直接执行加操作即可,这时CPU处于用户态下,相当于用户进程调用CPU执行指令
​ 3、但是如果程序执行的是读取外围设备IO、线程休眠、线程唤醒等操作,这种操作涉及到用户无法访问内存某些区域,出于安全考虑,用户进程需要将CPU的控制权交由内核,由内核代替用户进行执行这些操作,这就是用户态向内核态切换,内核代替用户执行完这些敏感指令后,然后再将CPU控制权重新交给用户进程,用户进程获得CPU控制权后继续执行后续指令,你可以简单认为:处于内核态时,CPU可以执行更多操作指令
​ 4、你会发现,涉及到内核切换一般至少要切换两次,即:由用户态切换到内核态,将CPU控制权交给内核,内核执行完后,再由内核态切换到用户态,用户进程重新获取CPU控制权继续向下执行,内核切换还是比较耗费性能的,所以,synchronized底层优化才会出现偏向锁、轻量级锁等

JVM中通过对象监视器Monitor实现重量级锁,也大致了解了Monitor结构,对Monitor进一步抽象可以总结为:其核心就是两个队列,竞争锁队列和信号阻塞队列,前者用于线程互斥,后者用于线程协调。

1517920072719

上图非常形象生动的描述了Monitor本质,图中圆圈代表线程, 左边区域是竞争锁的线程排队区域,简称等待区,右边是曾经获取过锁由于wait()等操作导致线程挂起锁被剥夺排队区域,简称阻塞区,它们中的线程都拼命的争夺进入中心舞台的入场券,而且这张入场券只有一张,这就导致中心舞台只能同时容纳一个线程,当中心区域的线程任务执行完成后,退出时会把它持有的入场券交出来,此时,等待区和阻塞区中的线程又开始竞争,如此往复。

锁总结

JVM底层实现synchronized同步时依赖的偏向锁、轻量级锁和重量级锁的大致原理也分析完成了,还记得轻量级锁中对象头指向Lock Record和重量级锁中对象头指向Monitor,可能你会比较好奇它们之间有什么区别吗?这里我试着总结下,主要区别如下:
​ 1、Lock Record存储在线程栈的栈帧中,如果你了解栈帧应该知道,栈帧代表的是一个方法调用,当方法调用完成,该栈帧也会从栈中出栈,因此,如果线程执行完同步方法后释放锁时Lock Record也就不复存在了,这时的对象头会被恢复至之前的MarkWord内容,可以说Lock Record是线程独有的;
​ 2、Monitor是线程私有,Monitor中Owner指向的线程就是Monitor的拥有者,注意这里的线程私有和上面Lock Record线程独有是有区别的:Lock Record随线程同步方法执行完成会被销毁,新线程获得锁后继续在自己的线程栈的栈中重新创建一个Lock Record,并让对象头指向自己即获得锁;而Monitor拥有者在进行锁释放时,是不会销毁Monitor对象的,而只会把Monitor中的Owner重置为null,表示当前没有线程持有锁,然后其它线程竞争锁资源,竞争成功的线程会被设置到Owner上,Monitor不会随着线程执行完同步方法而被销毁,这就表明Monitor不可能存储在线程栈中,而是存储在堆上;
​ 3、Lock Record和Monitor在释放锁时的行为也存在很大差别:Lock Record释放锁时会被销毁,对象头会被重置为之前的MarkWord内容,然后有新线程申请锁时会重新创建Lock Record让对象头指向,而Monitor释放锁时,只会把Monitor中的Owner重置为null,也就是说Monitor释放锁时对象头是不会变化的
​ 4、Monitor结构明显比Lock Record复杂,Lock Record主要保存对象头的MarkWord信息,由于结构太过简单导致Lock Record没法维护由于锁竞争导致的等待线程,最多也就是让它们自旋几下,并没有存储它们的区域,这就是轻量级锁无法解决锁竞争问题的本质。Monitor不但要保存对象头的MarkWord信息,还要使用队列维护等待线程和阻塞线程,因此,产生锁竞争时只能用重量级锁处理。另外,Lock Record结构简单才可以每次释放锁时销毁,申请锁时重新创建,而Monitor创建代价大的多,所以,一旦对象膨胀为重量级锁,初始化完Monitor后会被对象头一直指向该Monitor
​ 5、由于重量级锁维护着复杂的Monitor结构,同时还要使用底层系统的Mutex Lock导致用户态/内核态之间的多次切换对性能的损耗,所以才出现偏向锁,轻量级锁优化在锁竞争不激烈时的性能,情不得已时才会启用重量级锁

锁是并发编程中非常重要的一个内容,解决了高并发场景下非原子操作导致的状态不一致问题,通过上篇博文 《并发编程锁之ReentrantLock总结》及这篇博文,已经对Java中锁的两种主要实现机制进行大致的分析,再去理解偏向锁、轻量级锁、重量级锁、自旋锁、重入锁、悲观锁、乐观锁等一堆曾经困扰我很久的锁概念时,可以非常清晰的、简明扼要的表达出它们之间的本质区别。

偏向锁、轻量级锁、重量级锁都是JVM底层实现synchronized同步时引入的概念,最开始synchronized采用的是重量级锁机制实现,采用复杂的Monitor锁+底层系统Mutex Lock,由于太过复杂的Monitor结构和频繁的用户态/内核态间的切换导致性能不足,JVM工程师们在JDK1.6版本中引入了偏向锁、轻量级锁对重量级锁进行优化。

偏向锁和轻量级锁都是解决无锁竞争场景下锁的性能问题,因为它们都无法维护由于锁竞争导致的线程等待问题,所以遇到锁竞争就懵逼了,还是只能用重量级锁来处理。首先来看下轻量级锁,主要是解决线程间交替访问同步块问题,由于是线程交替访问而不是同时访问,所以并不会产生锁竞争,就没有必要使用笨重的重量级锁;再来看下偏向锁,偏向锁就更极端了,偏向锁认为不但没有锁竞争,而且在一段时间t1内都是线程A访问同步块,另一段时间t2内都是另一个线程B访问同步块,这样t1时间段内线程A通过一次CAS获取锁后,即使访问完同步块也不用去释放锁,不管线程A调用同步块多少次,都只需要第一次调用时申请锁,后面通过简单的判断直接进入,用完后即可离开,不需考虑锁申请和释放的问题,直到时间t2线程B过来访问,这时会把锁重偏向到线程B即可。

偏向锁锁解决的是一个周期内“单线程”访问共享资源问题,连CAS操作都是能节省就尽量节省,轻量级锁解决的是一个周期内多线程交替访问共享资源问题,使用CAS操作消除底层系统的互斥,而重量级锁解决的是一个周期内同时访问共享资源问题,需要管理等待线程以及依赖于底层系统互斥指令。

再来说说自旋锁,自旋锁不是锁的种类,而是锁的一项特性,如同重入锁一样,自旋的目的无非是优化性能,比如轻量级锁膨胀为重量级锁及ReentrantLock在真正进入休眠之前都会进行自旋,因为一旦轻量级锁膨胀为重量级锁或ReentrantLock中的线程进入休眠状态,对锁的性能都会造成很大的影响,自旋是为了极力挽救避免锁进入更糟糕的情况。但是自旋也会带来一个问题,自旋状态下会一直占用CPU资源,如果长时间无法获取锁而一直自旋下去,对系统资源造成很大的浪费,但是到底自旋多久比较合适呢,这还真是一个比较难拿捏的问题,好在JDK已经引入了自适应自旋,JVM会根据它的监控统计信息进行优化,自动动态的计算出自旋的周期,而不再简单的一个固定值。另外,自旋锁在单核系统下是没有意义的,因为自旋线程占用了CPU希望其它线程尽快释放锁才好结束自旋,其它持有锁的线程由于无法获取到CPU资源所以在自旋期间不可能获取到锁,但是现在一般不可能是单核系统,所以,JDK已经默认开启了自旋特性。

重入锁也是锁的一项特性,而非种类,其实Java中的锁基本都是重入锁,不可重入性锁会导致自己锁死自己的问题,而且出现一旦锁死再也无法解锁的严重问题,重入锁就是线程获取锁期间可以继续获取该锁,主要是通过在锁中设置一个计数器count,用于统计重入次数,同理在释放锁时,只有释放同样次数情况下才可能完全释放锁。重入锁的代码大致如下:

1
2
3
4
5
public synchronized void fun1(){//synchronized方法已保证进入方法中线程已经获取到当前对象的锁
synchronized (this){//这里再次获取当前对象锁,而且会成功,这就是重入锁特性
System.out.println("test");
}
}

最后,再来看下乐观锁和悲观锁,这是从另一个维度对锁进行的分类,乐观锁、悲观锁与具体编程语言无关,基本所有的编程语言以及涉及到并发编程的系统中都会存在悲观锁和乐观锁,比如redis、oracle、elasticsearch等中都存在悲观锁和乐观锁的身影。乐观锁借助系统的原子性指令,对共享资源进行操作,即其在操作前并不会加锁控制同步块,而是乐观认为不会存在锁竞争所以没必要加锁,但是一旦操作失败就表示出现了锁竞争,乐观锁一般通过多次自旋方式进行多次尝试,直到操作成功,具体可以参看ReentrantLock源码中CAS+无限循环方式,这就是典型的乐观锁在Java中的实现。而悲观锁有如其名,悲观的认为操作一定会出现多线程竞争导致的同步问题,所以在对同步块操作之前,先锁起来,只有自己能操作共享资源,其它线程此时是无法访问共享资源的,这种控制多线程串行化访问共享资源方式虽然解决了线程安全问题,但是效率肯定是不高的。乐观锁在竞争不是太激烈的情况下,性能一般是高于悲观锁的,但是一旦在高并发下多线程竞争激烈,由于乐观锁失败的概率增加从而乐观锁不断尝试获取锁导致效率降低,性能反而可能会低于悲观锁,但在一般的生产中,大多数线程都是竞争不太激烈的情况,所以乐观锁的使用概率还是非常大的

偏向锁和轻量级锁都是借助于CAS操作完成,可以理解为是乐观锁的一种实现,而重量级锁借助于底层系统互斥,可以看成是悲观锁的实现。

回过头来,对比synchronized和ReentrantLock实现机制,会发现它们在很多实现思想上如出一辙,虽然它们实现方式不一样,只有提炼出它们的设计思想才能掌握它们的核心本质,同时提升对并发编程的驾驭能力。

坚持原创技术分享,您的支持将鼓励我继续创作!