JUC(9):synchronized 锁升级

一、背景引入

我们使用锁的目的是为了保证程序在多线程下竞争产生的问题,虽然加锁能够保证我们的安全性,但是随之而来的性能会下降,无锁则不安全,那么,是否有折中的方法来达到一个平衡呢?

因此,synchronized 在java6 开始对此做了个改进,锁的升级过程变为如下:

  • 无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁

通常,synchronized 被视为重量级锁,但它并不是一步到位的,就像我们平常感冒。我们可能先去卫生所开几副药就可以,但要是感冒一直不好,我们可能会去县里的人民医院,最后到三甲医院的这一过程。(杀鸡焉用牛刀)

区别就是,锁只会升级不能降级。

1.1 Java5之前

Java 的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态和内核态之间做切换,这种切换会消耗大量的系统资源。

  • 因为用户态和内核态都有各自专用的内存空间,专用的寄存器等。切换回传递很多变量

在Java 早期版本,synchronized 属于重量级锁,效率低下,因为监视器锁是依赖于底层的操作系统来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个 Java线程需要操作系统切换 CPU 状态来完成,这种状态切换需要耗费处理器时间 ,因此在java6 之后,引入了偏向锁和轻量级锁。

1.2 Java 对象结构

Java 对象分对象头、对象体、对齐字节组成。

  • 对象头分三个字段:

    • 第一个字段 Mark Word,用于存储 GC 标志位,哈希码,锁状态等信息;
    • 第二个字段叫做类型指针,存储此对象的类地址
    • 第三个字段叫做 Array Length(数组长度): 如果对象是一个 Java 数组,那么此字段必须有,用于记录数组长度的数据;如果对象不是一个 Java 数组,那么此字段不存在,所以这是一个可选字段。
  • 对象体:包含了对象的实例变量,用于成员属性值,包括父类的成员属性值,这部分内存按4字节对齐
  • 对齐字节:填充对齐,其作用是用来保证Java对象在所占内存字节数为8的倍数(8N bytes)。 HotSpot VM的内存管理要求对象起始地址必须是8字节的整数倍

1.3 Monitor 与 Java 对象以及线程是如何关联的

JVM 中每个对象都有一个监视器,监视器和对象一起创建、销毁。

监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。

本质上,监视器是一种同步工具,也可以说是一种同步机制。

ObjectMonitor 的几个属性比较关键,monitor 的 Owner 字段会存放拥有相关联对象锁的线程id 。

1、Cxq:竞争队列。Cxq 是一个虚拟队列,在线程进入 Cxq前,例如A线程持有锁没释放,BC抢锁线程会先尝试通过 CAS 自旋获取锁,如果获取不到,就进入 Cxq 队列。每次新加入的 Node 会在 Cxq 的队头进行,Cxq 取元素时,会从队尾获取,因此 Cxq 队列是不公平的。

2、EntryList:同步队列。A 线程释放锁,B 和 C 线程中会选定一个继承者(可以取争抢锁的这个线程),另外一个线程会被放入我们的 EntryList 队列里面。

3、OnDeck Thread 与 Owner Thread:JVM不直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在JVM中,也把这种选择行为称之为"竞争切换"。 OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁的OnDeck Thread则会依然留 在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化(依然在队头)。 在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过CAS自旋成为Owner而抢到锁。

4、WaitSet:等待线程。如果 Owner 线程被 Object.wait() 方法阻塞,就转移到 WaitSet 队列中,直到某个时刻通过 Object.notify() 或者Object.notifyAll()唤醒,直接竞争锁,如果竞争失败就进入 cxq。

5、header:重量级锁保存 mark word 的地方。

6、own:指向我们持有锁的线程,对象的 markdword 里面也保存了指向 monitor 的指针。

总结

  • 通俗点说,就是加锁之后,我们在对象头的 markword 标志位中指向了一个对象监视器的地址。
  • 向这个监视器里头登记一些信息。然后操作系统和底层就明白了哪个线程持有了锁

二、锁升级流程

打印对象头信息,需要引入依赖 JOL 工具,可以查看对象的内部结构。

xml 复制代码
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

2.1 锁升级流程

synchronized 用的锁是存在 Java 对象头里的 Mark Word 中。锁升级功能主要依赖 MarkWord 中锁标志位和释放偏向锁标志位。

锁指向:

  • 偏向锁:Markword 存储的是偏向的线程ID;
  • 轻量级锁:MarkWord 存储的是指向线程栈中 Lock Record 的指针。
  • 重量锁:MarkWord 存储的是指向堆中的 monitor 对象的指针。

2.2 无锁

一个对象被实例化之后,如果还没有任何线程竞争锁,那么它就是无锁状态(0 01)

csharp 复制代码
public class LiteLock {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("========== 无锁 ==============");
        Object o = new Object();
        System.out.println("10进制:"+o.hashCode());

        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

输出结果:

vbnet 复制代码
10进制:1956725890
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 82 44 a1 (00000001 10000010 01000100 10100001) (-1589345791)
      4     4        (object header)                           74 00 00 00 (01110100 00000000 00000000 00000000) (116)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)

可以看到10进制的 hashcode 刚好与对象的头中 Mark Word 包含的哈希码一致。

而去掉 System.out.println("10进制:"+o.hashCode()); 这条语句,打印的结果是如下,就没指向任何地方了。

python 复制代码
========== 无锁 ==============
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

2.3 偏向锁

主要作用,当一段同步代码一直被同一个线程多次访问,由于只有一个线程,那么该线程在后续访问时便会自动获得锁。

同一个老顾客来访,直接老规矩行方便。

2.3.1 偏向锁理论

单线程竞争

当线程 A 第一次竞争到锁时,通过操作修改Mark Word 中的偏向线程 ID、偏向模式。

如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要进行同步。

Hotspot 的作者经过研究发现,大多数情况下:

  • 多线程的情况下,锁不仅不存在线程竞争,还存在锁由一个线程多次获得的情况;

  • 偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。

  • 备注:偏向锁会偏向于第一个访问锁的线程。

原理

线程竞争成功后,加上锁,在对象头里记录下偏向线程ID,后续这个线程在进入和退出这个锁时,不需要再次加锁和释放锁。而是直接去检查锁的 Markword 里面是不是放的自己的线程ID

  • 如果相等:不用尝试获得锁,直接进入同步;
  • 如果不等:表示发生了竞争,这时候会尝试使用 CAS 来替换 MarkWord 里面的线程ID 为新线程 ID;
  • 竞争成功:表示之前的线程不存在,MarkWord 里面的线程ID 为新线程的ID,锁不会升级;
  • 竞争失败:这时候可能要升级轻量级锁,才能保证线程间公平竞争锁。

注意:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

技术实现

一个 synchronized 方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的 Mark Wrod 中将偏向锁修改状态位,同时还会占用前 54 位来存储线程指针作为标识。若该线程再次访问同一个 synchronized 方法时,该线程只需去对象头的 Mark Word 中去判断以下是否有偏向锁指向本身的 ID,无需再进入 Monitor 去竞争对象了。

2.3.2 参数设置

偏向锁使用具备前提条件。

Java6 java7开始默认是开启的,但是启动时间有延迟,在程序启动几秒之后才激活。

开启偏向锁:

  • -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

关闭偏向锁:

  • -XX: -UseBiasedLocking=false

所以,演示过程中,需要将这个参数设置为 0 才可以看到打印处理的101效果

此外,让程序停止5秒后,也可以查看偏向锁的启动时间。

偏向锁的延迟时间为4秒。

java 复制代码
public class LiteLock {
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

2.3.3 偏向锁的撤销

假如存在多线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁的id不是自己,就产生竞争,就可能膨胀到轻量级锁,而原来持有锁的线程就会尝试撤销偏向锁。

偏向锁的撤销条件:

  • 1)多个线程竞争偏向锁;
  • 2)调用偏向锁对象 obj 的 hashCode() 方法或者 System.identityHashCode() 方法计算对象的哈希码之后,偏向锁将被撤销。

案例:

csharp 复制代码
public static void main(String[] args) throws InterruptedException {
    Object o = new Object();
    System.out.println("============ 未偏向线程的偏向锁 ==========");
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
    o.hashCode();
    synchronized (o){
        System.out.println("============ 偏向锁 ==========");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

调用对象的 hashcode 方法,会生成一次哈希码保存到 Mark Word 中,因为偏向锁的 mark word 已经保存了线程ID,没有地方再保存哈希码时,所以只能撤销偏向锁,将 mark word 用于存放对象的哈希码。

为了让线程获得锁的代价更低而引入了偏向锁,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需要简单的测试下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。

轻量级锁会在帧栈的Lock Record(锁记录)中记录哈希码,重量级锁会在监视器中记录哈希码,起到了对哈希码备份的作用。而偏向锁没有地方备份哈希码,所以只能撤销偏向锁。调用哈希码计算将会使对象再也无法偏向,因为在Mark Word中已经放置了哈希码,偏向锁没有办法放置Thread ID了。调用哈希码计算后,当锁对象可偏向时,Mark Word 将变成未锁定状态,并只能升级成轻量级锁;当对象正处于偏向锁时,调用哈希码将使偏向锁撤销后强制升级成重量锁

偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。

撤销需要等待全局安全点(该时间点上没有字节码在执行),同时检查持有偏向锁的线程是否还在执行。

  • 1、第一个线程正在执行 synchronized 方法(处于同步块),它还没有执行完,其他线程来抢夺,该偏向锁会被取消并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,而正在竞争的线程会进入自旋等待获得该轻量级锁。
  • 2、第一个线程执行完成 synchronized 方法(退出同步块),则将对象头设置为无锁状态并撤销偏向锁,重新偏向。

偏向锁在java15后默认不开启,废弃了。

2.4 轻量级锁

引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗。重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗。

主要作用:

  • 本质就是自旋锁 CAS,获取锁的时间极短

2.4.1 加锁与释放锁

加锁:

JVM 会为每个线程在当前线程的 栈帧中创建用于存储锁记录的空间(Lock Record 记录)。称为 Displaced Mark Word。

若一个线程获得锁时发现是轻量级锁,会把锁的 MarkWord(前30位(25位的hashcode、4位的分代年龄、1位是否为偏向锁)) 复制到自己的 Displaced Mark Word 里面。

然后线程尝试用 CAS 将锁的 MarkWord 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其他线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋 CAS :不断尝试去获取锁。尽量不阻塞

释放:

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

2.4.2 轻量级锁证明

如果关闭偏向锁,就直接进入轻量级锁。

自旋达到一定次数后,可以升级为重量级锁。

java6 之前是10次。

java6 之后,自适应自旋锁。

自适应意味着自旋的次数不是固定不变的。

大致原理:

线程如果自旋成功了,那下次自旋的最大次数会增加,因为 JVM 认为既然上次成功了,那么这次也很大概率会成功。

反之,如果很少自旋成功,那么下次会减少自旋的次数甚至不自旋,避免CPU空转。

面试:偏向锁和轻量锁区别?

1、争夺轻量级锁失败时,自旋尝试抢占锁;

2、轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁。

2.5 重量级锁

2.6 锁升级后 hashcode 去哪了

锁升级为轻量锁和重量锁后,Mark Word 中保存的分别是 线程栈里的锁记录指针和 重量级锁指针。已经没有位置再保存哈希码、GC 年龄了,那么这些信息被移动到那里去了呢?

  • 在无锁状态下,Mark Word 中可以存储对象的 identity hashcode值,当 对象的 hashcode 方法第一次被调用时,JVM 会生成对应的 hashcode 值并存入 mark word 中。
  • 在偏向锁:线程获取偏向锁时,会用 线程id 和 epoch 值覆盖原来的 hash code 的位置。如果一个对象的hashcode 方法已经被调用一次之后,这个对象不能被设置偏向锁。因为如果可以的话,那 mark word 中 的hash code 比如会被偏向线程 id 覆盖,造成两次调用 hash code 方法得到结果不一致。(升级到重量锁中有)
  • 升级为轻量级锁时:JVM 会在当前线程的栈帧中创建一个锁记录空间(Lock Record)空间,用于存储锁对象的 Mark Word 拷贝,该拷贝中可以包含 hash code,所以轻量级锁可以和 hash code 共存,哈希码和GC年龄也在这。释放锁后会把这些信息写回到对象头。
  • 升级到重量级锁:Mark word 保存的是重量级别锁指针,代表重量级锁的 ObjectMonitor 类里面有字段记录非加锁状态下的mark word,锁释放后这些信息也回到对象头

2.7 锁升级总结

Synchronized 在1.6 版本之前性能较差,在并发不严重的时候,因为 Synchronized 依然给对象上锁,每个对象需要维护一个管程对象,管程对象需要维护一个 Mutex 互斥量对象。

Mutex 是由操作系统内部的 phread 线程库维护的。上锁需要通过 JVM 从用户态切换到内核态来调用底层操作系统的指令,这样操作的性能较差。

AQS 框架中的 ReetrantLock 锁通过 Java 语言编写,实现了可重入锁和公平锁,且性能比 Synchronized 要好很多。

JDK6 为了弥补 Synchronized 的性能缺陷,设计了锁碰撞升级,也就是根据当前线程的激烈程度,设计了不同效果的锁。

分别是:

无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁

  • 偏向锁:适用于单线程的情况,在不存在竞争的时候进入同步方法、代码块则使用偏向锁。

  • 轻量锁:适用于竞争不激烈的情况,存在锁竞争升级为重量锁,轻量锁采用的是自旋锁,如果同步方法\代码块执行时间很短的话采用轻量锁虽然会占用CPU资源,但是相对比使用重量锁还是更高效

  • 重量锁:适用于竞争激烈的情况,如果同步方法、代码块执行时间很长,那么使用轻量锁自旋带来的性能消耗就比使用重量锁更严重,这时候就需要升级为重量锁。

总结一下synchronized的执行过程,大致如下:

  • 无锁状态:当对象锁被创建出来时,在线程获得该对象锁之前,对象处于无锁状态
  • 偏向锁 :在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。偏向锁的核心思想是:一旦有线程持有这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的 ID 记录在对象的 Mark Word 中,当该线程再次请求锁时,无需做任何同步操作,省去了大量有关锁申请的操作。当锁竞争比较激烈时,偏向锁就失效,因为这种场合很可能每次申请锁的线程都是不同的,因此这种场合不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即碰撞为重量级锁,而是先升级为轻量级锁。
  • 轻量级锁 :如果对象是无锁的,JVM 会在当前线程的栈帧中建立一个 Lock Record(锁记录)的空间,用来存放对象的 Mark Word 拷贝,然后把 Lock Record 中的 owner 属性指向当前对象。

    • 接下来 JVM 会利用 CAS 尝试把对象原本的 Mark Word 更新回 Lock Record 的指针,成功就说明加锁成功,于是改变锁标志位,指向相同的同步操作
    • 如果失败了,判断当前对象的 Mark Word 是否指向当前线程的栈帧,如果是就表示当前线程已经持有对象锁,如果不是,说明当前对象锁被其他线程持有,于是进行自旋。
    • 自旋锁:线程通过不断的自旋尝试上锁,为什么要自旋?因为如果线程被频繁挂起,也就意味着用户态和内核态之间切换频繁,通过自旋,让线程在等待时不会被挂起,自旋次数默认是10次,如果达到阈值升级为重量级锁。
  • 重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。

三、拓展|编译器堆锁的优化

JIT:Just in time Compiler:即时编译器

3.1 锁消除

不是公共锁,没有任何意义。

typescript 复制代码
/**
锁消除
从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,
极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
*/
public class LockClearUPDemo
{
 static Object objectLock = new Object();//正常的
    public void m1()
    {
        //锁消除,JIT会无视它,synchronized(对象锁)不存在了。不正常的
        Object o = new Object();
        synchronized (o)
        {
            System.out.println("-----hello LockClearUPDemo"+"\t"+o.hashCode()+"\t"+objectLock.hashCode());
        }
    }
    public static void main(String[] args)
    {
        LockClearUPDemo demo = new LockClearUPDemo();
        for (int i = 1; i <=10; i++) {
            new Thread(() -> {
                demo.m1();
            },String.valueOf(i)).start();
        }
    }
}

3.2 锁粗化

csharp 复制代码
/**
锁粗化
假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,
加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能
*/
public class LockBigDemo
{
 static Object objectLock = new Object();
    public static void main(String[] args)
    {
        new Thread(() -> {
            synchronized (objectLock) {
                System.out.println("11111");
            }
            synchronized (objectLock) {
                System.out.println("22222");
            }
            synchronized (objectLock) {
                System.out.println("33333");
            }
        },"a").start();
        new Thread(() -> {
            synchronized (objectLock) {
                System.out.println("44444");
            }
            synchronized (objectLock) {
                System.out.println("55555");
            }
            synchronized (objectLock) {
                System.out.println("66666");
            }
        },"b").start();
    }
}
相关推荐
九圣残炎6 分钟前
【从零开始的LeetCode-算法】1456. 定长子串中元音的最大数目
java·算法·leetcode
wclass-zhengge8 分钟前
Netty篇(入门编程)
java·linux·服务器
LunarCod14 分钟前
WorkFlow源码剖析——Communicator之TCPServer(中)
后端·workflow·c/c++·网络框架·源码剖析·高性能高并发
Re.不晚35 分钟前
Java入门15——抽象类
java·开发语言·学习·算法·intellij-idea
雷神乐乐41 分钟前
Maven学习——创建Maven的Java和Web工程,并运行在Tomcat上
java·maven
sszmvb123442 分钟前
测试开发 | 电商业务性能测试: Jmeter 参数化功能实现注册登录的数据驱动
jmeter·面试·职场和发展
码农派大星。1 小时前
Spring Boot 配置文件
java·spring boot·后端
测试杂货铺1 小时前
外包干了2年,快要废了。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
王佑辉1 小时前
【redis】redis缓存和数据库保证一致性的方案
redis·面试
顾北川_野1 小时前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java