深入理解Java中synchronized关键字的实现原理

一、Java对象的组成

1.Java对象的组成

在JVM中,对象在内存中的布局分为三块区域:对象头、实例变量和填充数据。如下:
深入理解Java中synchronized关键字的实现原理_第1张图片
(1) 实例数据:存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐。
(2) 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
(3) 对象头:它是实现synchronized的锁对象的基础,这点我们重点讨论它。

2.Java对象头

  一般而言,synchronized使用的锁对象是存储在Java对象头里的,JVM中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度)。以Hotspot虚拟机为例。对象头主要包括三部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)和数组长度(只有数组对象有)。其中Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。Klass Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。数组长度是数组对象特有的部分,该数据在32位和64位JVM中长度都是32bit。在32位系统下,存放Klass Point的空间大小是4字节,MarkWord是4字节,对象头为8字节;在64位系统下,存放Klass Point的空间大小是8字节,MarkWord是8字节,对象头为16字节。

  这里重点介绍Mark Word,它主要用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Mark Word同时记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。Mark Word在不同的锁状态下存储的内容不同,在32位JVM中的存储内容如下图,其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
深入理解Java中synchronized关键字的实现原理_第2张图片
JVM使用锁和Mark Word的基本思想如下:
(1)当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
(2)当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
(3)当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步中的代码。
(4)当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步代码。如果抢锁失败,则继续执行步骤5。
(5)偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
(6)轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步代码,如果失败则继续执行步骤7。
(7)自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

二、monitor对象

  由上述内容可知,重量级锁synchronized的标识位是10,其中指针指的是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 ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于获取锁失败的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

  ObjectMonitor中有四个重要的部分,即_owner,_WaitSet,_EntryList和count。_owner用来指向持有monitor的线程,初始时为NULL表示当前没有任何线程拥有该monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL。ObjectMonitor中有两个队列,_EntryList和_WaitSet,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象)。其中_EntryList存放所有试图获取monitor而阻塞的线程,_WaitSet存放所有处于wait状态的线程。当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor后会把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1。若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

三、synchronized的底层实现原理

1.同步代码块的底层实现原理

为了查看synchronized底层原理,定义一个同步代码块如下:

private int i = 0;
public void fun(){
    synchronized(this){
        i++;
    }
}

编译上述代码并使用javap反编译后得到字节码如下(这里我们省略一部分没有必要的信息)
深入理解Java中synchronized关键字的实现原理_第3张图片
  从字节码中可知同步语句块的实现使用的是monitorentermonitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。当代码执行到monitorenter 指令时,将会尝试获取该对象对应的Monitor的所有权,即尝试获得该对象的锁。当该对象的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有该对象monitor的持有权,那它可以重入这个 monitor ,计数器的值也会加 1。与之对应的执行monitorexit指令时,锁的计数器会减1。倘若其他线程已经拥有monitor 的所有权,那么当前线程获取锁失败将被阻塞并进入到_WaitSet 中,直到等待的锁被释放为止。也就是说,当所有相应的monitorexit指令都被执行,计数器的值减为0,执行线程将释放 monitor(锁),其他线程才有机会持有 monitor 。
  需要注意的是,字节码中有两个monitorexit指令,因为编译器需要确保方法中调用过的每条monitorenter指令都有执行对应的monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常时,monitorenter和monitorexit指令也能正常配对执行,编译器会自动产生一个可以处理所有异常的异常处理器,它的目的就是用来执行异常的monitorexit指令。而字节码中多出的monitorexit指令,就是异常结束时用来释放monitor的指令。
上述过程可以总结如下:
(1) 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
(2) 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
(3) 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

2.同步方法的底层实现原理

  方法级的同步是隐式,即无需通过字节码指令来控制。JVM可以通过方法常量池中的方法表结构(method_info Structure) 中的访问标志ACC_SYNCHRONIZED ,区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。示例同步方法如下:

public class SyncMethod {
   public int i;
   public synchronized void syncTask(){
           i++;
   }
}

使用javap反编译后的字节码如下:
深入理解Java中synchronized关键字的实现原理_第4张图片
  从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
同步不仅仅保证互斥访问,同步还保证当前线程在同步块前和同步块中,对内存的写操作对于同一个monitor上同步的其他线程是可见的。当我们退出了同步块,会释放monitor,并且将缓存数据刷新到内存,这样当前线程的写操作对于其他线程是可见的,当我们进入同步块之前,会获取monitor,并且使得当前处理器的缓存失效,从而读取数据必须从内存中重新加载,这样我们就可以看到其他线程在同步块中写操作。

3.等待唤醒机制与synchronized

  等待唤醒机制主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象。在前面的分析中,我们知道monitor存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。
  Obj.wait()与Obj.notify()必须要和synchronized(Obj)一起使用,也就是wait与notify是针对已经获取了Obj锁进行操作,从语法角度来说就是Obj.wait(),Obj.notify()必须在synchronized(Obj){…}语句块内。从功能上来说,wait()使得线程在获取对象锁后,主动释放对象锁,同时本线程休眠。直到有其它线程调用对象的notify()唤醒该线程,才能继续获取对象锁,并继续执行。但需要注意的是notify()调用后,并不是马上就释放对象锁的,而是在相应的synchronized(){}语句块执行结束,自动释放锁后,JVM会在对象锁的wait等待队列中随机选取一线程,赋予其对象锁,唤醒线程,继续执行。Thread.sleep()与Object.wait()二者都可以暂停当前线程,释放CPU控制权,主要的区别在于Object.wait()在释放CPU同时,释放了对象锁的控制。

4.synchronized的可重入性

  从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中,synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

四、Java虚拟机对synchronized的优化

  以上便是synchronized锁在同步代码块和同步方法上实现的基本原理。需要注意的是,在Java早期版本中,synchronized属于重量级锁,效率低下。这是因为在实现上,JVM会阻塞未获取到锁的线程,直到锁被释放的时候才唤醒这些线程。阻塞和唤醒操作是依赖操作系统来完成的,所以需要从用户态切换到内核态,开销很大。并且monitor调用的是操作系统底层的互斥量(mutex),本身也有用户态和内核态的切换。Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单介绍一下Java官方在JVM层面对synchronized锁的优化。
  锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。前面已经详细分析过重量级锁,下面将介绍偏向锁和轻量级锁以及JVM的其他优化手段。

1.偏向锁

  偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段.经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是被同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即可获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

2.轻量级锁

  如果偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(Java1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。轻量级锁在实际没有锁竞争的情况下,将申请互斥量这步也省掉。

3.自旋锁

  轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
  自旋会跑一些无用的CPU指令,所以会浪费处理器时间,如果锁被其他线程占用的时间短的话确实是合适的。但是如果长的话就不如直接使用阻塞。那么JVM怎么知道锁被占用的时间到底是长还是短呢?因为JVM不知道锁被占用的时间长短,所以使用的是自适应自旋。就是线程空循环的次数时会动态调整的。可以看出,自旋会导致不公平锁,不一定等待时间最长的线程会最先获取锁。

4.锁消除

  消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。例如,StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

你可能感兴趣的:(Java修行之路)