Java中synchronized的实现原理及优化

2019-02-09

1 实现原理

synchronized是基于Monitor来实现同步的。

1.1 概述

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

  1. 普通同步方法,锁是当前实例对象
  2. 静态同步方法,锁是当前类的class对象
  3. 同步方法块,锁是括号里面的对象

当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁。

  • 同步代码块是使用monitorenter和monitorexit指令显式实现的;
  • 同步方法依靠的是方法修饰符上的ACC_SYNCHRONIZED标记符隐式实现

同步代码块:monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;

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

1.2 Monitor

Monitor从两个方面来支持线程之间的同步:

  • 互斥执行
  • 协作
  1. Java 使用对象锁 ( 使用 synchronized 获得对象锁 ) 保证工作在共享的数据集上的线程互斥执行。
  2. 使用 notify/notifyAll/wait 方法来协同不同线程之间的工作。
  3. Class和Object都关联了一个Monitor。

1.2.1 Monitor工作机理

  1. 线程进入同步方法中。
  2. 为了继续执行临界区代码,线程必须获取 Monitor 锁。如果获取锁成功,将成为该监视者对象的拥有者。任一时刻内,监视者对象只属于一个活动线程(The Owner)
  3. 拥有监视者对象的线程可以调用 wait() 进入等待集合(Wait Set),同时释放监视锁,进入等待状态。
  4. 其他线程调用 notify() / notifyAll() 接口唤醒等待集合中的线程,这些等待的线程需要重新获取监视锁后才能执行 wait() 之后的代码。
  5. 同步方法执行完毕了,线程退出临界区,并释放监视锁。

1.2.2 monitorenter

每一个对象都有一个monitor,一个monitor只能被一个线程拥有。当一个线程执行到monitorenter指令时会尝试获取相应对象的monitor,获取规则如下:

  • 如果monitor的进入数为0,则该线程可以进入monitor,并将monitor进入数设置为1,该线程即为monitor的拥有者。
  • 如果当前线程已经拥有该monitor,只是重新进入,则进入monitor的进入数加1,所以synchronized关键字实现的锁是可重入的锁。
  • 如果monitor已被其他线程拥有,则当前线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor。

1.2.3 monitorexit

只有拥有相应对象的monitor的线程才能执行monitorexit指令。每执行一次该指令monitor进入数减1,当进入数为0时当前线程释放monitor,此时其他阻塞的线程将可以尝试获取该monitor。

1.3 Java虚拟机hotspot的对象头

对象头:

  1. 存储对象自身运行时数据。如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,官方称为”Mark Word“。
  2. 类型指针。指向它的类元数据指针,通过这个指针可以确定该对象是那个类的实例。(并不是所有的虚拟机实现都有这个)
  3. 如果对象是一个Java数组,那么对象头中必须有一块用来记录数组长度。

Java对象头格式

轻量级锁定、重量级锁定、GC标记和可偏向的对象头状态:

32位jvm中无锁状态的对象头:

32位无锁

32位jvm中其他状态的对象头:

32位锁状态变化

64位jvm中各个状态的对象头:

64位锁状态变化

2 Java线程阻塞的代价

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间。

synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

3 synchronized锁优化

在 JDK1.6 之后,出现了各种锁优化技术,如轻量级锁、偏向锁、适应性自旋、锁粗化、锁消除等,这些技术都是为了在线程间更高效的解决竞争问题,从而提升程序的执行效率。

通过引入轻量级锁和偏向锁来减少重量级锁的使用。锁的状态总共分四种:无锁状态、偏向锁、轻量级锁和重量级锁。锁随着竞争情况可以升级,但锁升级后不能降级,意味着不能从轻量级锁状态降级为偏向锁状态,也不能从重量级锁状态降级为轻量级锁状态。

无锁状态 → 偏向锁状态 → 轻量级锁 → 重量级锁

3.1 自旋锁与自适应自旋

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(不放弃处理器的执行时间,执行忙循环,也就是“自旋”),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗

但是线程自旋是需要消耗cpu的,说白了就是让cpu在做无用功,如果一直获取不到锁,那线程也不能一直占用cpu自旋做无用功,所以需要设定一个自旋等待的最大时间(默认自旋次数为10次)。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

在JDK 1.6中引入了自适应的自旋锁,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,则虚拟机任务这次自旋很有可能成功,进而允许自旋等待相对更长的时间。
  • 如果对于某个锁,自旋很少成功获得过,则在以后要获取这个锁时将可能忽略自旋过程,以避免浪费资源。

3.1.1 自旋锁的优缺点

  • 自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗。
  • 如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁;

3.2 偏向锁

无锁竞争的情况下为了减少锁竞争的资源开销,引入偏向锁。轻量级锁是在无多线程竞争的情况下,使用 CAS 操作去消除互斥量;偏向锁是在无多线程竞争的情况下,将这个同步都消除掉。

偏向锁提升性能的经验依据是:对于绝大部分锁,在整个同步周期内不仅不存在竞争,而且总由同一线程多次获得。偏向锁会偏向第一个获得它的线程,如果接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程不需要再进行同步。这使得线程获取锁的代价更低。

JVM对那种会有多线程加锁,但不存在锁竞争的情况也做了优化,因为线程之前除了互斥之外也可能发生同步关系,被同步的两个线程(一前一后)对共享对象锁的竞争很可能是没有冲突的。对这种情况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价还是蛮大的,因此这里应当理解为一种类似时间戳的identifier) 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁

3.2.1 偏向锁的获取

  1. 线程执行同步块,锁对象第一次被获取的时候,JVM 会将锁对象的 Mark Word 中的锁状态设置为偏向锁(锁标志位为’01’,是否偏向的标志位为’1’),同时通过 CAS 操作在 Mark Word 中记录获取到这个锁的线程的 ThreadID
  2. 如果 CAS 操作成功。持有偏向锁的线程每次进入和退出同步块时,只需测试一下 Mark Word 里是否存储着当前线程的 ThreadID。如果是,则表示线程已经获得了锁,而不需要额外花费 CAS 操作加锁和解锁
  3. 如果不是,则通过CAS操作竞争锁,竞争成功,则将 Mark Word 的 ThreadID 替换为当前线程的 ThreadID
  4. 当另一个线程尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态。

偏向锁、轻量级锁的装填及对象Mark Word的关系如下图:

偏向锁轻量级锁的状态变化

3.2.2 偏向锁的释放

  1. 当一个线程已经持有偏向锁,而另外一个线程尝试竞争偏向锁时,CAS 替换 ThreadID 操作失败,则开始撤销偏向锁。偏向锁的撤销,需要等待原持有偏向锁的线程到达全局安全点(在这个时间点上没有字节码正在执行),暂停该线程,并检查其状态
  2. 如果原持有偏向锁的线程不处于活动状态或已退出同步代码块,则该线程释放锁。将对象头设置为无锁状态(锁标志位为’01’,是否偏向标志位为’0’)
  3. 如果原持有偏向锁的线程未退出同步代码块,则升级为轻量级锁(锁标志位为’00’)

3.2.3 偏向锁的适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作; 在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用;

3.3 轻量级锁

synchronized的偏向锁、轻量级锁以及重量级锁是通过Java对象头实现的。轻量级锁所适应的场景是线程交替执行同步块的情况。轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。

轻量级锁是相对基于OS的互斥量实现的重量级锁而言的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用OS的互斥量而带来的性能消耗

3.3.1 加锁

  1. 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录(Lock Record)的空间(官方称为 Displaced Mark Word),用于存储锁对象目前的Mark Word的拷贝,此时堆栈与对象头的状态如图所示:

    轻量级锁1

  2. JVM 使用 CAS 操作尝试将对象头中的 Mark Word 更新为指向 Lock Record 的指针。如果更新成功,则执行步骤3;更新失败,则执行步骤4
  3. 如果更新成功,那么这个线程就拥有了该对象的锁,对象的 Mark Word 的锁状态为轻量级锁(标志位转变为’00’)。此时线程堆栈与对象头的状态如图所示:

    轻量级锁2

  4. 如果更新失败,JVM 首先检查对象的 Mark Word 是否指向当前线程的栈帧。
    • 如果是,就说明当前线程已经拥有了该对象的锁,那就可以直接进入同步代码块继续执行
    • 如果不是,就说明这个锁对象已经被其他的线程抢占了,当前线程会尝试自旋一定次数来获取锁。如果自旋一定次数 CAS 操作仍没有成功,那么轻量级锁就要升级为重量级锁(锁的标志位转变为’10’),Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也就进入阻塞状态

3.3.2 解锁

  1. 通过 CAS 操作用线程中复制的 Displaced Mark Word 中的数据替换对象当前的 Mark Word
  2. 如果替换成功,整个同步过程就完成了
  3. 如果替换失败,说明有其他线程尝试过获取该锁,那就在释放锁的同时,唤醒被挂起的线程

4 总结

参考文献

Java并发编程:synchronized和锁优化
Java中的锁以及sychronized实现机制
Java中的锁[原理、锁优化、CAS、AQS]
【死磕Java并发】—–深入分析synchronized的实现原理