Java synchronized

215 阅读10分钟

未完待续~~

synchronized 的三种使用方式

Synchronized 的使用方式有三种

  • 修饰普通函数,监视器锁(monitor)便是对象实例(this
  • 修饰静态静态函数,监视器锁(monitor)便是对象的 Class 实例(每个对象只有一个 Class 实例)
  • 修饰代码块,监视器锁(monitor)是指定对象实例

作用在静态方法上

public class Test {
    public static synchronized void test(){
        //业务代码
    }
}

锁的是 Class 对象,如上例则是 Test.class

作用在普通方法上

public class Test {
    public synchronized void test(){
        //业务代码
    }
}    

this 作为锁,即当前对象,以上面的代码段为例就是 syncTest 对象。

作用在代码块上

public class Test {
    public synchronized void test(){
        synchronized (object) {
           //业务代码
        }
    }
} 

前面介绍的普通函数与静态函数粒度都比较大,以整个函数为范围锁定,现在想把范围缩小、灵活配置,就需要使用代码块

synchronized 实现原理

monitor

,在反编译后的结果中,我们发现存在 monitorenter 与 monitorexit 指令(获取锁、释放锁)。

monitorenter 指令插入到同步代码块的开始位置,monitorexit 指令插入到同步代码块的结束位置,JVM 需要保证每一个 monitorenter 都有 monitorexit 与之对应。

synchronized 锁优化思路

Java 1.6 之前,synchronized 关键字只使用 monitor 来对资源进行锁定,而 monitor 是系统级,锁比较重,性能不是很好。

从 1.6 开始,Java 对 synchronized 关键字进行了优化,HotSpot 的开发人员在优化 synchronized 关键字的过程中,调查、统计中发现

  1. 在对象(锁)的生命周期中,大部分时间,锁的竞争激烈程度都是比较低甚至是没有竞争

image.png

  1. 在无竞争的时候,大多数时间,锁都是被同一个线程连续获取释放

image.png

如上,大部分时间锁都是被线程a或线程b连续获取释放,小部分时间是线程a和线程b交替获取

而锁的获取和释放都是比较消耗性能的。某个线程连续获取释放锁,其实是在浪费性能,最好是这段连续的过程只获取释放一次锁,于是就有了偏向锁

对上面两项调查结果进行分析后,就诞生了 synchronized 的优化思路

  1. 根据锁的竞争激烈程度加以区分,不同的竞争场景下使用不同的锁机制

  2. 无竞争时,使用偏向锁

没有竞争

只有一个线程在使用锁,任何锁刚开始都会处于该阶段。

在这一阶段,由于没有其他线程竞争锁,因此没必要真的给资源“加锁”,只需要使用一些技巧达到相当于加锁的效果即可。

在 JVM 中是将当前线程记录在对象头的 MarkWord 中,表示当前对象被该线程持有,当其他线程尝试获取该对象时,jvm 会检查 MarkWord 中的线程id是否能对应的上,如果对应不上则拒绝访问,这样就实现了对资源的锁定

在这一阶段,由于锁只会交给MarkWord 中记录的线程,因此叫做偏向锁

偏向锁没有使用传统意义上的锁,例如 JVM 中的 monitor ,而是使用了一种讨巧的方式达到了对资源(对象)的锁定,那该称呼它为锁吗?

我是这样理解的,锁本身就是一种保证同一时间只有一个线程访问资源的抽象,只要达到了这一效果,任何东西,不管是一种方式、方法、一个系统关键字还是一段代码,都可以称之为锁

轻度竞争

在竞争的线程不多的时候,也没有必要使用传统的锁(系统级锁),因为使用传统的锁(系统级锁)需要使用到内核api,会发生用户态到内核态的切换,比较消耗性能,另外其他没有获取到锁的线程都处于挂起状态,等待锁释放后,系统换醒,而线程状态切换也是比较消耗性能的。

既然竞争不激烈,线程完全可以不断的轮询,由于竞争的线程不多,很快就能获取到锁,这种轻度竞争的环境下,轮询获取锁反而比线程挂起、唤醒的方式的性能强多了

由于资源消耗比操作系统级锁要小,因此 JVM 称之为轻量级锁

重度竞争

当竞争的线程很多时,再使用轮询获取锁的方式就不合适了,因为大量线程长时间都处于获取不到锁的情况,大量线程轮询,严重浪费 cpu 性能,这种情况下,传统形式的锁性能反而更好,因为获取不到锁的线程会被挂起,不需要做轮询,不会浪费 cpu 资源。

传统形式的锁需要使用到操作系统的锁机制,资源消耗比较重,因此 JVM 将其称之为重量级锁

总结

上面讲了根据锁的竞争激烈程度使用不同的锁机制来对锁进行优化,这样优化思路不仅适用于 java ,也适用于任何使用到锁的场景。

而在 java 中,对 synchronized 的优化,也是遵循该思路的,具体的设计思想则体现在对象头的 MarkWord

关于 MarkWord 以及偏向锁轻量级锁重量级锁 的详细介绍,参考 MarkWord 与锁

synchronized 的性能问题

锁粗化

锁粗化就是将「多个连续的加锁、解锁操作连接在一起」,扩展成一个范围更大的锁,避免频繁的加锁解锁操作

image.png

JVM 会检测到一连串的操作都对同一个对象加锁(for 循环 10000 次执行 j++,没有锁粗化就要进行 10000 次加锁 / 解锁),此时 J V M 就会将加锁的范围粗化到这一连串操作的外部(比如 for 循环体外),使得这一连串操作只需要加一次锁即可。

锁消除

Java 虚拟机在 JIT 编译时 (可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析(对象在函数中被使用,也可能被外部函数所引用,称为函数逃逸),去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的时间消耗。

image.png

代码中使用 Object 作为锁,但是 Object 对象的生命周期只在 incrFour() 函数中,并不会被其他线程所访问到,所以在 J I T 编译阶段就会被优化掉(此处的 Object 属于没有逃逸的对象)。

锁升级

同步影响JVM的多个部分:对象头的结构在类oopDesc和markOopDesc中定义,同步影响JVM的多个部分:对象头的结构定义在类oopDesc和markOopDesc中,

细锁的代码集成在解释器和编译器中,

类ObjectMonitor表示膨胀锁。

偏置锁集中在类BiasedLocking中。它可以通过标志-XX:+UseBiasedLocking启用,通过-XX:-UseBiasedLocking禁用。

它在Java 6和Java 7中默认是启用的,但是只有在应用程序启动后几秒钟才被激活。因此,要注意短期运行的微基准测试。如果需要,使用标志-XX:BiasedLockingStartupDelay=0来关闭延迟。

JVM 升级锁的过程 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。在这个状态下,未抢到锁的线程都会被阻塞

JAVA 对象内存布局

偏向锁

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁,其目标就是在只有一个线程执行同步代码块时,降低获取锁带来的消耗,提高性能(可以通过 J V M 参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态)。

线程执行同步代码或方法前,线程只需要判断对象头的 Mark Word 中线程 ID 与当前线程 ID 是否一致

当访问对象的身份哈希码时,偏差也会被撤销,因为哈希码位与线程 ID 共享

轻量级锁

轻量级锁考虑的是竞争锁对象的线程不多,持有锁时间也不长的场景。因为阻塞线程需要 C P U 从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失,所以干脆不阻塞这个线程,让它自旋一段时间等待锁释放。

重量级锁

参考

Synchronization