synchronized底层是怎么实现的?
本文为转载内容,来自作者:纪莫
synchronized 使用场景
我们在使用 synchronized 的时候都知道它是可以使用在方法上的也可以使用在代码块上的,那么使用在这两个地方有什么区别呢?
synchronized 用在方法上
使用在静态方法上,synchronized
锁住的是类对象。
1 | public class SynchronizedTest { |
使用在实例方法上,synchronized
锁住的是实例对象。
1 | public class SynchronizedTest { |
synchronized 用在代码块上
synchronized
的同步代码块用在类实例的对象上,锁住的是当前的类的实例。
即执行 buildName 的时候,整个对象都会被锁住,直到执行完成 buildName 后释放锁。
1 | public class SynchronizedTest { |
synchronized
的同步代码块用在类对象上,锁住的是该类的类对象。
1 | public class SynchronizedTest { |
synchronized
的同步代码块用在任意实例对象上,锁住的就是配置的实例对象。
1 | public class SynchronizedTest { |
synchronized 的使用就介绍到这里,正常情况下会用了就可以了,能在实际场景中使用的时候知道锁住的范围就可以了。
synchronized 的原理
我们来看一下 synchronized 底层是怎么实现的吧。
例如:
下面一段代码,包含一个 synchronized 代码块和一个 synchronized 的同步方法。
1 | public class SynchronizedTest { |
在编译完成后生成了 class 文件,我将 class 文件反编译出来,看看生成的 class 文件的内容。(也可以直接使用 IDEA 打开)
1 | javap -p -v -c SynchronizedTest.class |
反编译出来的字节码文件内容有点多,我只截取了关键部分来分析。
根据《Java 虚拟机规范》的要求
- 在执行
monitorenter
指令的时候,首先要去尝试获取对象的锁(获取对象锁的过程,其实是获取 monitor 对象的所有权的过程)。 - 如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一。
- 而在执行
monitorexit
指令时会将锁计数器减一。一旦计数器的值为零,锁随即就被释放了。 - 如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
同步方法
同步方法 test1 的反编译后的字节码文件部分如下
monitor 对象
我在上面说了,获取对象锁的过程,其实是获取 monitor 对象的所有权的过程。
哪个线程持有了 monitor 对象,那么哪个线程就获得了锁,获得了锁的对象可以重复的来获取 monitor 对象,但是同一个线程每获取一次 monitor 对象所有权,锁计数就加一,在解锁的时候也是需要将锁计数减成 0 才算真的释放了锁。
注意:monitor 对象,我们其实在 Java 的反编译文件中并没有看到。这个对象是存放在对象头中的。
对象头
这里要介绍一下对象头,首先要说一下对象的内存布局,在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和 对齐填充(Padding)。
- 实例数据里面存储的是对象的真正有效数据,里面包含各种类型的字段内容,无论是自身的还是从父类继承来的。
- 对齐填充这部分并不是必然存在的,只是为了占位。虚拟机自动管理内存系统要求对象的大小必须是 8 字节的整数倍,当整个对象的大小不是 8 字节的整数倍时,用来对齐填充补全。
- 对象头部分包含两类信息。
1、第一类是自身运行时数据,如何哈希码(hashcode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID等,这部分数据官方称它为“Mark Word”。
2、第二类是类型指针,即对象指向它的类型元数据的指针,虚拟机通过它来确定对象是哪个类型的实例。
接着回到我们的 monitor 对象,monitor 对象的源码是 C++写的,在虚拟机的 ObjectMonitor.hpp
文件中。
数据结构长这个样子。
1 | ObjectMonitor() { |
重量级锁
在主流的 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,这种状态的转换要耗费很多的处理时间。
所以在 ObjectMonitor 文件中的调用过程和复杂的操作系统运行机制导致线程的阻塞或唤醒时是很耗费资源的。
这样在 JDK1.6 之前都称 synchronized 为重量级锁。
重量级锁的减重
高效并发是从 JDK5 升级到 JDK6 的一项重要的改进项,在 JDK6 版本上虚拟机开发团队花费了大量的资源去实现各种锁优化技术,来为重量级锁减重。
synchronized 在升级后的整个加锁过程,大致如下图。
这里要说明一下,锁升级的过程是不可逆的。
偏向锁
上面在介绍对象头的时候,说到了对象头中包含的内容了,其中有一个就是偏向锁的线程 ID,它代表的意思就是说,如果当一个线程获取到了锁之后,锁的标志计数器就会+1,并且把这个线程的 id 存储在锁住的这个对象的对象头上面。
这个过程是通过 CAS 来实现的,每次线程进入都是无锁的,当执行 CAS 成功后,直接将锁的标志计数+1(持有偏向锁的线程以后每次进入锁时不做任何操作,标志计数直接+1),这个时候其他线程再进来时,执行 CAS 就会失败,也就是获取锁失败。
偏向锁在 JDK1.6 是默认开启的,通过参数进行关闭xx:-UseBiasedLocking=false
。
偏向锁可以提高带有同步但无竞争的程序性能,但如果大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。
轻量级锁
轻量级锁还是和对象头的第一部分(Mark Word)相关。
- 在代码即将进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用户存储锁对象目前的 Mark Word 的拷贝。
- 然后 JVM 将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,说明线程获取锁成功,并执行后面的同步操作。
- 如果这个更新动作失败了,说明锁对象已经被其他线程抢占了,那轻量级锁不在有效,必须膨胀为重量级锁。此时被锁住的对象的标志变为重量级锁的标志
自旋锁
当轻量级锁获取失败后,就会升级为重量级锁,但是重量级锁之前也介绍了是很耗资源的,JVM 开发团队注意到许多程序上,共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。
所以想到了一个策略,那就是当线程请求一个已经被锁住的对象时,可以让未获取锁的线程“稍等一会”,但不放弃处理器执行时间,只需要让线程执行一个忙循环(自旋),这就是所谓的自旋锁。
自旋锁在 JDK1.4.2 中引入,默认关闭,可以通过-XX:UserSpinning 参数来开启,默认自旋次数是 10 次,用户可以自定义次数,配置参数是-XX:PreBockSpin
无论是用户指定还是默认值的自旋次数,对 JVM 重所有的锁来说都是相同的。在 JDK6 中引入了自适应自旋,根据前一次在同一锁上的自旋时间及拥有者的状态来决定。如果上一次同一个对象自旋锁获得成功了,那么再次进行自旋时就会认为成功几率很大,那么自旋次数就会自动增加。反之如果自旋很少成功获得锁,那么以后这个自旋过程都有可能被省略掉。
这样在轻量级失败后,就会升级为自旋锁,如果自旋锁也失败了,那就只能是升级到重量级锁了。
锁升级过程总结
Synchronized 减重的过程,通常被称为锁膨胀或是锁升级的过程。
主要步骤是:
- 先是通过偏向锁来获取锁,解决了虽然有同步但无竞争的场景下锁的消耗。
- 再是通过对象头的 Mark Word 来实现的轻量级锁,通过轻量级锁如果还有竞争,那么继续升级。
- 升级为自旋锁,如果达到最大自旋次数了,那么就直接升级为重量级锁,所有未获取锁的线程都阻塞等待。
总结
Synchronized 原理:
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED
访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先持有 monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放 monitor。
代码块的同步是利用 monitorenter
和 monitorexit
这两个字节码指令。它们分别位于同步代码块的开始和结束位置。
当 jvm 执行到 monitorenter 指令时,当前线程试图获取 monitor 对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;
当执行 monitorexit 指令时,锁计数器-1;当锁计数器为 0 时,该锁就被释放了。
如果获取 monitor 对象失败,该线 程则会进入阻塞状态,直到其他线程释放锁
拓展
Lock 原理
Lock 的存储结构:一个 int 类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)
Lock 获取锁的过程:本质上是通过 CAS 来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。
Lock 释放锁的过程:修改状态值,调整等待链表。
Lock 大量使用 CAS + 自旋。因此根据 CAS 特性,lock 建议使用在低锁冲突的情况下。
Lock 与 synchronized 的区别:
- Lock 的加锁和解锁都是由 java 代码配合 native 方法(调用操作系统的相关方法)实现的,而 synchronize 的加锁和解锁的过程是由 JVM 管理的
- 当一个线程使用 synchronize 获取锁时,若锁被其他线程占用着,那么当前只能被阻塞,直到成功获取锁。而 Lock 则提供超时锁和可中断等更加灵活的方式,在未能获取锁的条件下提供一种退出的机制。
- 一个锁内部可以有多个 Condition 实例,即有多路条件队列,而 synchronize 只有一路条件队列;同样 Condition 也提供灵活的阻塞方式,在未获得通知之前可以通过中断线程以及设置等待时限等方式退出条件队列。
- synchronize 对线程的同步仅提供独占模式;而 Lock 即可以提供独占模式,也可以提供共享模式