未完待续~~
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 关键字的过程中,调查、统计中发现
- 在对象(锁)的生命周期中,大部分时间,锁的竞争激烈程度都是比较低甚至是没有竞争
- 在无竞争的时候,大多数时间,锁都是被同一个线程连续获取释放
如上,大部分时间锁都是被线程a或线程b连续获取释放,小部分时间是线程a和线程b交替获取
而锁的获取和释放都是比较消耗性能的。某个线程连续获取释放锁,其实是在浪费性能,最好是这段连续的过程只获取释放一次锁,于是就有了偏向锁
对上面两项调查结果进行分析后,就诞生了 synchronized 的优化思路
-
根据锁的竞争激烈程度加以区分,不同的竞争场景下使用不同的锁机制
-
无竞争时,使用偏向锁
没有竞争
只有一个线程在使用锁,任何锁刚开始都会处于该阶段。
在这一阶段,由于没有其他线程竞争锁,因此没必要真的给资源“加锁”,只需要使用一些技巧达到相当于加锁的效果即可。
在 JVM 中是将当前线程记录在对象头的 MarkWord 中,表示当前对象被该线程持有,当其他线程尝试获取该对象时,jvm 会检查 MarkWord 中的线程id是否能对应的上,如果对应不上则拒绝访问,这样就实现了对资源的锁定
在这一阶段,由于锁只会交给MarkWord 中记录的线程,因此叫做偏向锁
偏向锁没有使用传统意义上的锁,例如 JVM 中的 monitor ,而是使用了一种讨巧的方式达到了对资源(对象)的锁定,那该称呼它为锁吗?
我是这样理解的,锁本身就是一种保证同一时间只有一个线程访问资源的抽象,只要达到了这一效果,任何东西,不管是一种方式、方法、一个系统关键字还是一段代码,都可以称之为锁
轻度竞争
在竞争的线程不多的时候,也没有必要使用传统的锁(系统级锁),因为使用传统的锁(系统级锁)需要使用到内核api,会发生用户态到内核态的切换,比较消耗性能,另外其他没有获取到锁的线程都处于挂起状态,等待锁释放后,系统换醒,而线程状态切换也是比较消耗性能的。
既然竞争不激烈,线程完全可以不断的轮询,由于竞争的线程不多,很快就能获取到锁,这种轻度竞争的环境下,轮询获取锁反而比线程挂起、唤醒的方式的性能强多了
由于资源消耗比操作系统级锁要小,因此 JVM 称之为轻量级锁
重度竞争
当竞争的线程很多时,再使用轮询获取锁的方式就不合适了,因为大量线程长时间都处于获取不到锁的情况,大量线程轮询,严重浪费 cpu 性能,这种情况下,传统形式的锁性能反而更好,因为获取不到锁的线程会被挂起,不需要做轮询,不会浪费 cpu 资源。
传统形式的锁需要使用到操作系统的锁机制,资源消耗比较重,因此 JVM 将其称之为重量级锁
总结
上面讲了根据锁的竞争激烈程度使用不同的锁机制来对锁进行优化,这样优化思路不仅适用于 java ,也适用于任何使用到锁的场景。
而在 java 中,对 synchronized 的优化,也是遵循该思路的,具体的设计思想则体现在对象头的 MarkWord 中
关于 MarkWord 以及偏向锁、轻量级锁、重量级锁 的详细介绍,参考 MarkWord 与锁
synchronized 的性能问题
锁粗化
锁粗化就是将「多个连续的加锁、解锁操作连接在一起」,扩展成一个范围更大的锁,避免频繁的加锁解锁操作
JVM 会检测到一连串的操作都对同一个对象加锁(for 循环 10000 次执行 j++,没有锁粗化就要进行 10000 次加锁 / 解锁),此时 J V M 就会将加锁的范围粗化到这一连串操作的外部(比如 for 循环体外),使得这一连串操作只需要加一次锁即可。
锁消除
Java 虚拟机在 JIT 编译时 (可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析(对象在函数中被使用,也可能被外部函数所引用,称为函数逃逸),去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的时间消耗。
代码中使用 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 从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失,所以干脆不阻塞这个线程,让它自旋一段时间等待锁释放。