synchronized 底层原理
概括来说,synchronized
之所以能通过持有对象的锁实现同步是通过 Monitor
(管程)实现的。(Monitor 是操作系统级的对象)
我们看一下普通对象的对象头储存了那些内容
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
64位系统 Mark Word存储的内容如下,这与锁升级的过程息息相关(最后两位状态码表示是否加锁)
|--------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|--------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal |
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased |
|--------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | 00 | Lightweight Locked |
|--------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked |
|--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|
加锁的工作原理如下
- 刚开始
Monitor
中Owner
(所有者)为 null(Monitor 中只能有一个 Owner )- 当使用
synchronized
给对象上锁(重量级锁)之后,该对象的对象头里的 MarkWord 中被设置为指向Monitor
对象的指针(状态为Heavyweight
)- 当 Thread-2 执行
synchronized(obj)
时,会将 Monitor 的所有者 Owner 设置为 Thread-2- Thread-2 上锁的过程中,若其他线程也来执行 synchronized(obj),就会进入阻塞队列 EntryList (其他线程执行到 synchronized部分时会首先判断 obj 是否关联上 Monitor 锁对象,然后判断 Monitor 锁是否有所有者 Owner)
- Thread-2 执行完同步代码块中的内容后会唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的(不是先进先出)
WaitSet
中是之前获得过锁,但条件不满足进入 Waiting状态的线程(也就是执行了 wait 方法的线程)
synchronized 优化
上文提到的使用 Monitor
进行加锁,属于重量级锁,在 JDK 1.6 以前只有重量级锁,效率较低。在JDK 1.6之后,为了减少获得锁和释放锁所带来的性能消耗,从 JVM 层面对 synchronized
进行了优化,引入了偏向锁和轻量级锁。
锁的升级过程:偏向锁 -> 轻量级锁 -> 重量级锁
(整个过程由 JVM 控制)
轻量级锁
使用场景:
如果一个对象虽然有多个线程访问,但是多线程访问时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是
synchronized
。系统会优先使用轻量级锁方式加锁,轻量级锁加锁失败后,(进入锁膨胀流程)才会自动转为重量级锁。
流程:
- 创建锁记录(
Lock Record
)对象。(每个线程的栈帧中都包含一个记录锁结构的结构,锁记录对象比 Monitor 更加简单、轻量化,内部可以存储锁定对象的 Mark Word) - 让锁记录中的
Object reference
(对象指针)指向锁对象,并尝试用 CAS 替换(交换) Object 的 Mark Word,将 Mark Word 的值存入锁记录 - 如果 CAS 替换成功,对象头中存储了锁记录地址和状态,表示由该线程给对象加锁(CAS 操作是原子性的,不会被打断。最后两位状态码表示加锁与否和加锁状态,01表示未加锁,00 表示轻量级锁,10表示重量级锁)
- 线程存在竞争CAS 操作失败除了线程存在竞争(其他线程已经持有了该 Object 的轻量级锁)之外还有可能是由锁重入导致的(锁重入不会转为重量级锁)
- 如果线程在已经执行上一次 CAS 成功之后,再次执行
synchronized
锁重入,那么会在添加一条Lock Record
作为重入的计数 - 如果是由线程存在竞争导致的 CAS 操作失败,会进入锁膨胀流程,由轻量级锁转为重量级锁
- 解锁
- 如果有取值为 null 的锁记录,表示重入,此时重置锁记录,表示重入计数减一
- 当锁记录的值不为 null,此时使用 CAS 将 Mark Word 的值恢复给对象 Object
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁的解锁流程
- 按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,并唤醒 EntryList 中 BLOCKED 线程
锁膨胀
出现的原因:
在线程尝试加轻量级锁时,进行 CAS 操作失败(说明此时存在其他线程为此对象加上了轻量级锁,即存在竞争),进入锁膨胀流程将轻量级锁变为重量级锁
流程:为 Object 对象申请
Monitor
锁,让 Object 指向重量级锁的地址,然后自己进入 Monitor 的EntryList
变为BLOCKED
状态
自旋优化
重量级锁竞争的时候,可以使用自旋来进行优化,如果当前线程自旋成功(即持锁线程退出了同步块,释放了锁),当前线程就可以避免阻塞。
- 避免阻塞意味着避免了上下文切换,可以极大的节约系统资源
- 自旋简单来说就是让线程不断执行循环(无意义的循环),循环结束后查看锁是否释放
- 在自旋重试了一定次数之后,当前线程还不能获取对象锁,当前线程仍然会进入阻塞状态
- 自旋次数是由 JVM 进行控制的,一般根据之前的自旋情况进行处理
- 自旋也会占用 CPU 时间,对于单核 CPU 而言自旋就是浪费,多核 CPU 自旋才可以发挥优势
- Java 7 之后无法控制是否开启自旋
偏向锁
为什么需要偏向锁?
这是因为轻量级锁在没有竞争时,每次重入仍然需要执行 CAS 操作,这无疑是没有必要的。
偏向锁优化流程:
只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后检查这个线程 ID 是否属于自己,是的话,表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个锁对象就归该线程所有。
偏向状态
在对象头的 Mark Word 值的倒数第三位 biased_lock 表示是否开启了偏向锁(1 表示开启,0 表示未开启),开启之后,MarkWord 的最后三位为 101
偏向锁的开启是延迟的,不会在程序启动之后立即生效。
(如果想避免延迟,可以加 VM 参数
- XX:BiasedLockingStartupDelay=0
来禁用延迟)注意:这里的开启偏向锁指的是可以使用偏向锁,而非已经通过 synchronized 添加锁对象。
禁用偏向锁:添加 VM 参数
-XX:-UseBiasedLocking
撤销偏向锁
调用对象的 hashcode方法
调用 hashcode
方法之后会撤销偏向锁,原因是调用 hashcode
方法后,需要在对象头中存储 hashcode
,而此时偏向锁的 mark word 已经存储了 54 位的线程ID,存不下31位的hashcode
了。故而,只能选择撤销偏向锁。
- 此时轻量级锁会在锁记录中记录 hashcode
- 此时重量级锁会在 Monitor 中记录 hashcode
其他线程使用对象
当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁(升级轻量级锁之后,变为不可偏向)
调用 wait/notify
由于 wait/notify
都是重量级锁才有的,所以调用这两个方法会将偏向锁转换为重量级锁
批量重偏向
如果对象被多个线程访问,但是没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象 的 Thread ID(而不是直接撤销偏向锁,升级为轻量级锁)
当撤销偏向锁的阈值超过 20 次后(第20次开始批量重偏向),jvm 才会在给剩下的这些对象加锁时重新偏向至当前加锁线程
批量撤销
当撤销偏向锁的阈值超过 40 次后,JVM 会觉得根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
执行顺序:先对锁对象执行撤销操作,达到批量重定向的阈值之后,将剩下的对象锁批量重定向。然后,继续执行如果达到批量撤销的阈值在对剩下的锁对象执行批量撤销
注意:撤销次数只记录对偏向锁的撤销次数(也就是说一旦由偏向锁变为轻量级锁,后续不论发生什么变化,都不在对其计数)