• 欢迎访问 winrains 的个人网站!
  • 本网站主要从互联网整理和收集了与Java、网络安全、Linux等技术相关的文章,供学习和研究使用。如有侵权,请留言告知,谢谢!

深入理解synchronized底层实现原理

Java技术 winrains 来源:科技伍小黑 1年前 (2019-08-31) 38次浏览

引言

我们都知道数据的同步需要加锁,在JAVA领域,最常用的是使用synchronized关键字,那么synchronized关键字在底层是如何实现同步的呢?

synchronized的使用

synchronized的使用方式有如下几种:

  1. synchronized 加在代码块中
public class SynchronizedDemo {
    public void add(Object obj) {
        synchronized (obj) {
            // do something
        }
    }
}

synchronized 如果锁住的是某个obj实例,其实锁的作用是在代码块中。

  1. synchronized 加在方法中
public synchronized void del() {
    //do something
}

synchronized加在方法中,锁住的是当前对象实例

  1. synchronized 加在静态方法中
public static synchronized void update(){
    //do something
}

synchronized 加在静态代码块中,锁住的便是当前class 的实例,因为 Class数据存在于永久带(jdk1.7 是存储在元空间),因此静态方法锁相当于该类的一个全局锁.

线程状态及状态转换

当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:

  1. Contention List:所有请求锁的线程将被首先放置到该竞争队列
  2. Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
  3. Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
  4. OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
  5. Owner:获得锁的线程称为Owner
  6. !Owner:释放锁的线程

他们转换关系如下图所示:

深入理解synchronized底层实现原理

Contention List 虚拟队列

ContentionList 并不是一个真正的队列,而只是一个虚拟队列,原因在于ContentionList是由Node及其next指 针逻辑构成,并不存在一个Queue的数据结构。ContentionList是一个后进先出(LIFO)的队列,每次新加入Node时都会在队头进行, 通过CAS改变第一个节点的的指针为新增节点,同时设置新增节点的next指向后续节点,而取得操作则发生在队尾。显然,该结构其实是个Lock- Free的队列。
因为只有Owner线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了CAS的ABA问题。

EntryList

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对 ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到 EntryList,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给 OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot中把OnDeck的选择行为称之为“竞争切换”。
OnDeck线程获得锁后即变为owner线程,无法获得锁则会依然留在EntryList中,考虑到公平性,在EntryList中的位置不 发生变化(依然在队头)。如果Owner线程被wait方法阻塞,则转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒, 则再次转移到EntryList。

自旋锁

那些处于ContetionList、EntryList、WaitSet中的线程均处于阻塞状态,阻塞操作由操作系统完成(在Linxu下通 过pthread_mutex_lock函数)。线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响 锁的性能
缓解上述问题的办法便是自旋,其原理是:当发生争用时,若Owner线程能在很短的时间内释放锁,则那些正在争用线程可以稍微等一等(自旋), 在Owner线程释放锁后,争用线程可能会立即得到锁,从而避免了系统阻塞。但Owner运行的时间可能会超出了临界值,争用线程自旋一段时间后还是无法 获得锁,这时争用线程则会停止自旋进入阻塞状态(后退)。基本思路就是自旋,不成功再阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有非 常重要的性能提高。自旋锁有个更贴切的名字:自旋-指数后退锁,也即复合锁。很显然,自旋在多处理器上才有意义。
还有个问题是,线程自旋时做些啥?其实啥都不做,可以执行几次for循环,可以执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机 会。所以说,自旋是把双刃剑,如果旋的时间过长会影响整体性能,时间过短又达不到延迟阻塞的目的。显然,自旋的周期选择显得非常重要,但这与操作系统、硬 件体系、系统的负载等诸多场景相关,很难选择,如果选择不当,不但性能得不到提高,可能还会下降,因此大家普遍认为自旋锁不具有扩展性。

自旋优化策略

对自旋锁周期的选择上,HotSpot认为最佳时间应是一个线程上下文切换的时间,但目前并没有做到。经过调查,目前只是通过汇编暂停了几个CPU周期,除了自旋周期选择,HotSpot还进行许多其他的自旋优化策略,具体如下:
如果平均负载小于CPUs则一直自旋
如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
如果CPU处于节电模式则停止自旋
自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
自旋时会适当放弃线程优先级之间的差异
那synchronized实现何时使用了自旋锁?答案是在线程进入ContentionList时,也即第一步操作前。线程在进入等待队列时 首先进行自旋尝试获得锁,如果不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平。还有一个不公平的地方是自旋线程可能会抢占了 Ready线程的锁。自旋锁由每个监视对象维护,每个监视对象一个。

synchronized原理

Java 虚拟机中的同步(Synchronization)是基于进入和退出Monitor对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法并不是由monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。
我们对如下同步代码块进行javap反编译:

public void add(Object obj) {
    synchronized (obj) {
     //do something
    }
}

反编译后的代码如下:

public class com.wuzy.thread.SynchronizedDemo {
    public com.wuzy.thread.SynchronizedDemo();
    Code:
    0: aload_0
    1: invokespecial #1 // Method java/lang/Object."<init>":()V
    4: return
    public void add(java.lang.Object);
    Code:
    0: aload_1
    1: dup
    2: astore_2
    3: monitorenter //注意此处,进入同步方法
    4: aload_2
    5: monitorexit //注意此处,退出同步方法
    6: goto 14
    9: astore_3
    10: aload_2
    11: monitorexit //注意此处,退出同步方法
    12: aload_3
    13: athrow
    14: return
    Exception table:
    from to target type
    4 6 9 any
    9 12 9 any
}

我们看下第13行~15行代码,发现同步代码块是使用monitorenter和monitorexit指令来进行代码同步的,注意看第19行代码,为什么会多出一个monitorexit指令,主要是JVM为了防止代码出现异常,也能正确退出同步方法。
接下来我们将同步整个方法进行反编译一下:

public synchronized void update() {
}

反编译后的代码如下:

public class com.wuzy.thread.SynchronizedDemo {
    public com.wuzy.thread.SynchronizedDemo();
    Code:
    0: aload_0
    1: invokespecial #1 // Method java/lang/Object."<init>":()V
    4: return
    public synchronized void update();
    Code:
    0: return
}

从反编译的代码看,同步方法并不是用monitorenter和monitorexit指令来进行同步的,实际上同步方法会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置设为1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示做为锁对象。

1、Java对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

深入理解synchronized底层实现原理

这里对这几个概念做简要描述:

  1. 实例变量:
  2. 存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  3. 填充数据:
  4. 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
  5. 对象头:
  6. 对象头实现synchronized锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:
深入理解synchronized底层实现原理

其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等,以下是32位JVM的Mark Word默认存储结构:

深入理解synchronized底层实现原理

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

深入理解synchronized底层实现原理

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

深入理解synchronized底层实现原理

2. Monitor Record

Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下所示为Monitor Record的内部结构:

深入理解synchronized底层实现原理

下面对这几个概念做下解释:

  1. Owner:
  2. 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL
  3. EntryQ:
  4. 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
  5. RcThis:
  6. 表示blocked或waiting在该monitor record上的所有线程的个数。
  7. Nest:
  8. 用来实现重入锁的计数
  9. HashCode:
  10. 保存从对象头拷贝过来的HashCode值(可能还包含GC age)
  11. Candidate:
  12. 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

JDK 对锁进行了哪些优化?

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

  1. 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
  2. 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
  3. 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
  4. 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  5. 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

锁的升级

JDK1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在JDK1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率.

深入理解synchronized底层实现原理

1、偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

深入理解synchronized底层实现原理

关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。
膨胀过程:当前线程执行CAS获取偏向锁失败(这一步是偏向锁的关键),表示在该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁所有权。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,并从偏向锁所有者的私有Monitor Record列表中获取一个空闲的记录,并将Object设置LightWeight Lock状态并且Mark Word中的LockRecord指向刚才持有偏向锁线程的Monitor record,最后被阻塞在安全点的线程被释放,进入到轻量级锁的执行路径中,同时被撤销偏向锁的线程继续往下执行同步代码。

2. 轻量级锁

轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。

深入理解synchronized底层实现原理

不同锁之间的比较

1.偏向锁:
优点:加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。
缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗。
适用场景:适用于只有一个线程访问同步块场景。
2. 轻量级锁:
优点:竞争的线程不会阻塞,提高了程序的响应速度。
缺点:如果始终得不到锁竞争的线程使用自旋会消耗CPU。
适用场景:追求响应时间。同步块执行速度非常快。
3. 重量级锁:
优点:线程竞争不使用自旋,不会消耗CPU。
缺点:线程阻塞,响应时间缓慢。
适用场景:追求吞吐量。同步块执行速度较长。

参考文档

  • JVM规范(Java SE 7)
  • Java语言规范(JAVA SE7)
  • 周志明的《深入理解Java虚拟机》
  • Java偏向锁实现原理

作者:科技伍小黑

来源:https://www.toutiao.com/a6583171434007233028/


版权声明:文末如注明作者和来源,则表示本文系转载,版权为原作者所有 | 本文如有侵权,请及时联系,承诺在收到消息后第一时间删除 | 如转载本文,请注明原文链接。
喜欢 (1)