synchronized底层原理与锁升级


synchronized 关键字解决的是多个线程之间访问资源的同步性,即保证线程同步,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。为什么呢?

因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

使用

synchronized 实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下 3 种形式:

  1. 修饰实例方法,锁是当前实例对象
  2. 修饰静态方法,锁是当前类的Class对象
  3. 修饰代码块,锁是 synchronized 括号里配置的对象

当一个线程试图访问同步代码块时,它首先必须获得锁,退出或抛出异常时必须释放锁。


底层原理

JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者实现细节不一样。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,而方法同步是通过方法 flags 标志,方法同步同样可以使用这两个指令来实现。

修饰代码块

monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处

为什么会有两个monitorexit呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。

任何对象都内置了一个 monitor 对象,当且一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁

在执行 monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0,则表示锁可以被获取,获取后将锁计数器设为 1,也就是 +1。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

修饰方法

方法同步是通过在方法 flags 上添加 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。 ACC_SYNCHRONIZED 会去隐式调用刚才的两个指令:monitorenter 和 monitorexit 。该标记表明线程进入该方法时,需要 monitorenter,退出该方法时需要 monitorexit 。

不过两者的本质都是对对象监视器 monitor 的争夺。

Monitor 对象

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步代码块或同步方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因

Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。

Java 对象头

在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分:对象头实例数据填充对齐

synchronized 用的锁是存在 Java 对象头里的,具体来说是存在 Mark Word 中。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在32位虚拟机中,1 字宽等于 4 字节,即 32 bit。在64位虚拟机中,1 字宽等于 8 字节,即 64 bit。

内容说明长度
Mark Word存储对象的hashCode、分代年龄和锁标记位32/64bit
Class MetadataAddress存储到对象类型数据的指针32/64bit
Array length数组的长度(如果当前对象是数组)32/32bit

Hotspot 对象头主要包括两部分数据:Mark Word(标记字段)和Klass Pointer(类型指针)

  • Mark Word默认存储对象的 HashCode、分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化
  • Klass Point表示的是类型指针,对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。

无锁状态:对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01。

偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01。

轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00。

重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为10。

GC 标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。

其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。


锁升级

Java 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和 “轻量级锁“ 。锁的状态总共有 4 种,级别由低到高依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这四种状态会随着竞争情况逐渐升级,锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

无锁

无锁即没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是在循环内进行修改操作,线程会不断的尝试修改共享资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。CAS 就是无锁的实现。

偏向锁
为什么引入偏向锁?

HotSpot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。它的出现是为了解决只有在一个线程执行同步时提高性能。

实现原理

当一个线程首次访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。

如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁(轻量级锁需要) ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
撤销偏向锁

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点,即在这个时间点上没有正在执行的字节码。它会先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭,-XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁

轻量级锁是指当前锁是偏向锁的时候,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,称为Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁;如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁,若自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

自旋:不断尝试去获取锁,一般用循环来实现。

自旋锁

获取轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,使用了自旋锁进行优化。

互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。

一般线程持有锁的时间都不是太长,所以如果直接挂起线程是得不偿失的。 所以让线程不断自旋,即不停地循环判断锁是否能够被成功获取。

自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。

但是JDK采用了更聪明的方式——适应性自旋锁,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

轻量级锁的释放:

在释放轻量级锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程(其他线程重新争夺锁访问同步块)。

重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。

当锁处在重量级锁状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

锁对比
优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间,同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行时间较长

其他优化

  • 锁消除

    对于线程的私有变量,不存在并发问题,没有必要加锁,即使加锁编译后,也会去掉。

  • 锁粗化

    当一个循环中存在加锁操作时,可以将加锁操作提到循环外面执行,一次加锁代替多次加锁,提升性能。


synchronized三大特性

  • 原子性:一个或多个操作要么全部执行成功,要么全部执行失败,过程不会被任何因素打断。synchronized 关键字可以保证只有一个线程拿到锁,访问共享资源。Java内存模型提供了字节码指令monitorentermonitorexit 来支持。
  • 可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized 时,会对应执行 lock 、unlock原子操作,并且在解锁之前必须先把共享变量同步回主内存中,从而保证可见性。
  • 有序性:程序的执行顺序会按照代码的先后顺序执行。synchronized 修饰的代码,同一时间只能被同一线程访问,那么可以认为是单线程执行的,由于as-if-serial语义是指不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。所以synchronized 可以保证有序性。

synchronized 和 volatile 的区别

  • volatile关键字主要用于解决共享变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
  • volatile 关键字只能用于变量,而 synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字仅能保证数据的可见性,不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile不会造成线程的阻塞,synchronized会造成线程的阻塞。volatile 关键字是线程同步的轻量级实现,所以 volatile性能比synchronized关键字好

参考资料

《Java 并发编程的艺术》

9 synchronized与锁 · 深入浅出Java多线程 (redspider.group)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值