Java Synchronized(二)锁的优化

347 阅读15分钟

synchronized 用的锁是存在 Java 对象头里的,在看 synchronized 锁优化之前先来看一下 Java 对象头

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化,以 32 位的 JDK 为例(主要看最后 3 位 bit):

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

Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了 "偏向锁" 和 "轻量级锁":锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以升级但不能降级

偏向锁

HotSpot(虚拟机)的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,偏向锁正是为了在只有一个线程执行同步块时提高性能的

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)

偏向锁的获取过程

  1. 访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为 01 —— 确认为可偏向状态
  2. 如果为可偏向状态,则测试 ThreadID 是否指向当前线程,如果是,进入步骤 5,否则进入步骤 3
  3. 如果 ThreadID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将 Mark Word 中 ThreadID 设置为当前 ThreadID,然后执行 5;如果竞争失败,执行 4
  4. 如果 CAS 获取偏向锁失败,则表示有竞争(CAS 获取偏向锁失败说明至少有一个其他线程曾经获得过偏向锁,因为线程不会主动去释放偏向锁)。当到达全局安全点(safepoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为 01),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为 00),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁
  5. 执行同步代码

偏向锁的释放过程

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

关闭偏向锁

偏向锁在jdk6、jdk7、jdk8默认开启,由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数 -XX:-UseBiasedLocking 禁用偏向锁,也可以通过 -XX:+UseBiasedLocking 来开启偏向锁

轻量级锁

轻量级锁是为了在线程近乎交替执行同步块时提高性能

轻量级锁的加锁过程

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为 01 ,是否为偏向锁为 0),JVM 首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前 Mark Word 的拷贝,官方称之为 Displaced Mark Word
  2. 拷贝对象头中的 Mark Word 复制到锁记录中
  3. 拷贝成功后,JVM 将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤 4,否则执行步骤 5
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为 00,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示
  5. 如果这个更新操作失败了,JVM 首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止 CPU 空转,锁标志的状态值变为 10,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态

轻量级锁的解锁过程

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

重量级锁

如上轻量级锁的加锁过程步骤 5,轻量级锁所适应的场景是线程近乎交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。Mark Word 的锁标记位更新为 10,Mark Word 指向互斥量(重量级锁)

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

具体见 Java Synchronized(一)原理

偏向锁、轻量级锁、重量级锁之间转换

偏向所锁、轻量级锁都是乐观锁(总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,适用于读多的场景),重量级锁是悲观锁(总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁)

  • 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的 ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS 在进行操作
  • 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向
  • 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止 CPU 空转

其它锁优化

锁消除

锁消除即删除不必要的加锁操作,JVM 即时编辑器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除

根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。例如以下代码,虽然 StringBufferappend 是一个同步方法,但是这段程序中的 StringBuffer 属于一个局部变量,并且不会从该方法中逃逸出去(即 StringBuffer sb 的引用没有传递到该方法外,不可能被其他线程拿到该引用),所以其实这过程是线程安全的,可以将锁消除

public void append(String str1, String str2) {
    StringBuffer sb = new StringBuffer();
    sb.append(str1).append(str2);
}

@Override
public synchronized StringBuffer append(String str) {

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗

如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

public class StringBufferTest {
    StringBuffer stringBuffer = new StringBuffer();

    public void append(){
        stringBuffer.append("a");
        stringBuffer.append("b");
        stringBuffer.append("c");
    }
}

这里每次调用 stringBuffer.append 方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次 append 方法时进行加锁,最后一次 append 方法结束后进行解锁

补充:自旋锁与自适应自旋锁

引入自旋锁的原因: 互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的

自旋锁: 让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用 -XX:+UseSpinning 来开启;在 JDK1.6 中默认开启

自旋锁的缺点: 自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,例如让其循环 10 次,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起(进入阻塞状态)。通过参数 -XX:PreBlockSpin 可以调整自旋次数,默认的自旋次数为 10

自适应的自旋锁: JDK1.6 引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多;如果自旋失败了,则自旋的次数就会减少

自旋锁使用场景: 从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行 CAS 操作失败时,是要通过自旋来获取重量级锁的(见前面 轻量级锁

小结

对象头中的 Mark Word 根据锁标志位的不同而被复用

偏向锁: 在只有一个线程执行同步块时提高性能。Mark Word 存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单比较 ThreadID。特点:只有等到线程竞争出现才释放偏向锁,持有偏向锁的线程不会主动释放偏向锁。之后的线程竞争偏向锁,会先检查持有偏向锁的线程是否存活,如果不存活,则对象变为无锁状态,重新偏向;如果仍存活,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁

轻量级锁: 在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,尝试拷贝锁对象目前的 Mark Word 到栈帧的 Lock Record,若拷贝成功:虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向对象的 Mark Word。若拷贝失败:若当前只有一个等待线程,则可通过自旋稍微等待一下,可能持有轻量级锁的线程很快就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁

重量级锁: 指向互斥量(mutex),底层通过操作系统的 mutex lock 实现。等待锁的线程会被阻塞,由于 Linux 下 Java 线程与操作系统内核态线程一一映射,所以涉及到用户态和内核态的切换、操作系统内核态中的线程的阻塞和恢复



本文整理自: 知乎 - Java synchronized原理总结

参考: 简书 - JAVA那些事--偏向锁