深度剖析 Synchronized 锁升级过程

一、简介

1.1 前言

jdk1.6之前,synchronized还是一个重量级锁。

jdk1.6加上了偏向锁和轻量级锁。之后锁就有了4种状态:【无锁】【偏向锁】【轻量级锁】【重量级锁】

1.2 synchronized 内核态切换

在JVM中synchronized重量级锁的底层原理是monitorenter和moniterexit字节码依赖底层操作系统的Mutex Lock来实现的,由于使用Mutex Lock需要将当前线程挂起,并从用户带切换到内核态来执行,这种切换代价是很昂贵的。

1.3 为什么优化synchronized

研究表明,线程持有锁的时间是比较短暂的,也就是说,当前线程即使现在获取锁失败,但也可能很快就能获取到锁,这种情况将线程挂起很不划算,使用偏向锁或轻量级锁能减低用户态和内核态之间的切换,提高获得锁和释放锁的效率。

二、synchronized的升级原理

2.1 Java对象在内存中的布局

在Java虚拟机中,普通对象在内存中分为三块区域:【对象头】、【实例数据】、【对齐填充】,对象头包括markword(8字节)和类型指针(开启压缩指针4字节,不开启8字节,如果是32g以上内存,都是8字节),实例数据就是对象的成员变量,padding就是为了保证对象的大小为8字节的倍数,将对象所占字节数补到能被8整除。数组对象比普通对象在对象头位置多一个数组长度。

深度剖析 Synchronized 锁升级过程_第1张图片

2.2 Mark Word

存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等

2.3 升级阶段

2.3.1 无锁状态

偏向锁位、锁标志位的值为:0、 01,此时对象是没有做任何同步限制

2.3.2 偏向锁

偏向锁位、锁标志位的值为:1 01

大部分场景都不会发生锁资源竞争,并且锁资源往往都是由一个线程获得的。如果这种情况下,同一个线程获取这个锁都需要进行一系列操作,比如说CAS自旋,那这个操作很明显是多余的。

核心思想就是:一个线程获取到了锁,那么锁就会进入偏向模式,当同一个线程再次请求该锁的时候,无需做任何同步,直接进行同步区域执行。这样就省去了大量有关锁申请的操作。

偏向锁加锁过程:

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则判断线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

偏向锁撤销的过程:

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁的适用场景

始终只有一个线程在执行同步块,一旦有了竞争就升级为轻量级锁,升级过程会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。

所以一般JVM并不是一开始就开启偏向锁的,而是有一定的延迟,这也就是为什么会有无锁态的原因。可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁。

2.3.3 轻量级锁(自旋锁)

锁标识位(00),CAS自旋把锁对象Mark Word中的线程ID设置为自己。

自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,叫做自适应自旋锁。他的自旋次数是会变的,线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

轻量级锁的加锁过程:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
  2. 拷贝对象头中的Mark Word复制到锁记录中;
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word中的62位更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。此时为了提高获取锁的效率,线程会不断地循环去获取锁, 这个循环是有次数限制的, 如果在循环结束之前CAS操作成功, 那么线程就获取到锁, 如果循环结束依然获取不到锁, 则获取锁失败, 对象的MarkWord中的记录会被修改为指向互斥量(重量级锁)的指针,锁标志的状态值变为10,线程被挂起,后面来的线程也会直接被挂起。

轻量级锁的释放

在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,自旋一定次数后还获取不到锁,则升级为重量级锁,则切换到重量锁。

2.3.4 重量级锁

锁标志位(10),重量锁,对象头中还会存在一个监视器对象,也就是Monitor对象。这个Monitor对象就是实现synchronized的一个关键。

Monitor对象4个重要的属性

  • _count:计数器。用来记录获取锁的次数。该属性主要用来实现重入锁机制。
  • _owner:记录着当前锁对象的持有者线程。
  • _WaitSet:队列。当一个线程调用了wait方法后,它会释放锁资源,进入WaitSet队列等待被唤醒。
  • _EntryList:队列。里面存放着所有申请该锁对象的线程。

锁的获取过程

  1. 判断锁对象的锁标志位是重量级锁,于是想要获取Monitor对象锁。
  2. 如果Monitor中的_count属性是0,说明当前锁可用,于是把 _owner 属性设置为本线程,然后把 _count 属性+1。这就成功地完成了锁的获取。
  3. 如果Monitor中的_count属性不为0,再检查 _owner 属性,如果该属性指向了本线程,说明可以重入锁,于是把 _count 属性再加上1,实现锁的冲入。
  4. 如果 _owner 属性指向了其他线程,那么该线程进入 _EntryList 队列中等待锁资源的释放。
  5. 如果线程在持有锁的过程中调用了wait()方法,那么线程释放锁对象,然后进入 _WaitSet 队列中等待被唤醒。

三、synchronized可重入

synchronized是可重入锁,那么它是如何实现可重入的呢?

偏向锁:检查markWord中的线程ID是否是当前线程,如果是的话就获取锁,继续执行代码;
轻量级锁:检查markWord中指向lockRecord的指针是否是指向当前线程的lockRecord,是的话继续执行代码;
重量级锁:检查_owner属性,如果该属性指向了本线程,_count属性+1,并继续执行代码。

四、锁升级总结

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

五、锁优化

5.1 锁消除

就是在一段程序里你用了锁,但是jvm检测到这段程序里不存在共享数据竞争问题,比如使用了线程安全的api,如StringBuffer、Vector、HashTable等,这个时候会隐形的加锁,jvm就会把这个锁消除掉。

比如下面的代码,没有修改共享变量,jvm就会把这个锁消除掉。

public StringBuffer getSb(){
  StringBuffer sb= new StringBuffer();
  for(int i = 0 ; i < 10 ; i++){
    sb.append(i);
  }
  return sb;
}

5.2 减少锁的时间

不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放。

5.3 减小锁的粒度

物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间(如ConcurrentHashMap、LinkedBlockingQueue、LongAdder)。

5.4 锁粗化

假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都加锁解锁,效率是非常差的。

5.5 使用读写锁

ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。

你可能感兴趣的:(集合与并发,Java,java,开发语言)