阅读本文至少要知道 synchronized 用来是干什么的... 需要的前置知识还有 Java 对象头和 Java 字节码的部分知识。
synchronized 的使用
synchronized 有三种使用方式,三种方式锁住的对象是不相同的。
锁分为实例对象锁和 class 对象锁 和 类对象锁,注意这三种锁是不一样的。
- 修饰实例方法,此时锁住的是对象,锁分为实例对象锁
- 修饰静态方法,此时锁住的是类对象锁
- 修饰代码段,此时锁住的是括号中的对象(
synchronized(this)
),可以是实例对象锁或者 class 对象锁(synchronized(Object.class)
)
此时出现了锁住类和锁住对象,要注意这两个锁是不同的,在一个线程拿到类的锁时,另外一个线程是可以拿到对象的锁的。
synchronized 底层语义实现
每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
当多个线程同时请求某个对象监视器时,新请求锁的线程将首先被加入到 ConetentionList 中。对象监视器会设置几种状态用来区分请求的线程:
Contention List:所有请求锁的线程将被首先放置到该竞争队列
Entry List:Contention List 中那些有资格成为候选人的线程被移到Entry List
Wait Set:那些调用 wait 方法被阻塞的线程被放置到 Wait Set
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck
Owner:获得锁的线程称为 Owner
!Owner:释放锁的线程
代码同步块和方法级别的 synchronized 使用在JVM 层实现是不一样的。
synchrionized 在代码同步块的入口插入 monitorenter
,在同步块出口插入monitorexit
来实现互斥,可以通过反编译看到。
方法级别的同步是隐式的,在字节码层面上没有显示出来。JVM 可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 字段访问标志区分一个方法是否为同步方法。如果是同步方法,则去获取 monitor,然后执行方法。
具体可以查看《深入理解 JVM 虚拟机》 中字节码一章。
对 synchronized 的优化
JDK 1.6 实现了对锁的大量优化。可以分为两种,一种是减少对 synchronized 的使用,一种是在特殊条件下使用更轻量级的锁来代替 synchronized。
减少对锁的使用
锁消除
当编译器检测到一些被加上 synchronized 的代码不存在竞争的时候(通过逃逸分析,感兴趣可以去看一下《深入理解 Java 虚拟机》),就会被视为线程私有的,锁会被安全的消除掉。
锁粗化
当编译器发现 synchronized 被加入在循环当中,不断的加锁解锁会有极大的效率问题。不要认为你不会写出这么傻的代码,JDK 中有许多方法是同步的,比如 HashTable 中的一些方法。
for (int i = 0; i < 100; i++) {
synchronized (this) {
//do something
}
}
编译器会自动把它优化成
synchronized (this) {
for (int i = 0; i < 100; i++) {
//do something
}
}
来减少锁的获取和释放。
自旋锁与自适应锁
有相当多一段代码在代码同步块中只运行一小会儿,如果为了等待这一会儿去挂起和恢复线程,切换线程带来的开销不是很值得,在引入了自旋锁后,当遇到锁被别的线程占用的时候,这个线程就进入一段忙循环,这就是自旋。
但是如果多次忙循环后仍然获取不到锁,那么只能挂起线程将锁升级为重量级锁了。
自适应锁会记录之前在代码同步快的运行时间来决定是否要执行自旋以及自旋的时间,如果之前自旋成功过,那么这次也很有可能会自旋成功。如果之前自旋失败,那么就省略掉自旋过程直接挂起线程避免浪费 CPU 资源。
通过轻量级锁来代替 synchronized
轻量级锁设计出来是想要在竞争较少的情况下减少 synchronized 的性能消耗,而不是用来代替 synchronized 的。想要看懂轻量级锁的使用需要对 Java 对象头有一定的了解。关于 Java 对象头可以参考。好,接下来我就默认认为你懂 Mark Word 是什么了。
锁的膨胀过程是 偏向锁→轻量级锁→重量级锁,膨胀过程的单方向的。不能缩小回来。
下面是 Mark Word 的内容和锁的关系。
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码,对象分代年龄 | 01 | 未锁定 |
指向记录锁指针 | 00 | 轻量级锁定 |
指向重量级锁指针 | 10 | 膨胀(重量级锁定) |
空 | 11 | GC 标记 |
偏向线程 id,时间戳,分代年龄 | 01 | 可偏向 |
偏向锁
偏向锁的思想就是:锁经常被同一个线程重复获取,那么可以通过设置偏向锁来避免使用重量级锁。因为如果这段时间只有这一个线程在重复获取这个对象的锁,那么对这部分代码的同步就是无意义的。
当线程获取锁的时候发现 Mark Word 是未锁定的状态,那么就采用 CAS 把这个 Mark Word 设置成偏向状态,把这个线程的 id 设置进去,然后如果这个线程再次获取这个锁的时候发现这个偏向锁的 id 和当前线程的 id 一样则不需要同步直接运行。
当有另外一个线程尝试获取这个偏向锁的时候,锁会恢复到未锁定或者轻量级锁的状态。
- 如果对象未被锁定,则会变成未锁定的,不可偏向的对象
- 如果对象被锁定了,则会变成轻量级锁状态
如果大多数锁总是被多个不同的线程访问,那么偏向模式就是多余的,可以 采用 --XX:UseBiaseLocking 来禁止偏向锁来提高性能。
轻量级锁
当线程进入一个代码同步块的时候,虚拟机将使用 CAS 将 Mark Word 更新为指向 Lock Record 的指针。如果成功则线程拥有这个对象锁,mark word 将被设为 00。
如果更新失败,则检查该线程是否持有这个对象锁,如果已经持有则直接向下执行
如果没有持有这个对象锁则轻量级锁膨胀为重量级锁,锁标志状态变为 10。
参考文献
- 周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社, 2011.
- 方腾飞.Java 并发编程的艺术 [M]. 机械工业出版社, 2015.
- 深入理解Java并发之synchronized实现原理
- [JVM底层又是如何实现synchronized的