锁的四种状态及升级过程(JVM对synchronized的优化)

本文详细介绍了Java1.6中synchronized的优化,包括偏向锁、轻量级锁和重量级锁的状态及升级过程,以及锁在对象头中的存储结构。同步方法和代码块的实现分别基于monitorenter和monitorexit指令以及ACC_SYNCHRONIZED修饰。锁的状态从无锁到重量级锁逐级升级,不可逆。偏向锁适用于单线程频繁访问,轻量级锁通过CAS操作避免阻塞,而重量级锁则依赖于操作系统互斥量。
摘要由CSDN通过智能技术生成

一、概述

在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重了。

本文详细介绍 Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁轻量级锁,以及锁的存储结构和升级过程。

二、实现同步的基础

Java 中的每个对象都可以作为锁,具体变现为以下3中形式:

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的 Class 对象
  3. 对于同步方法块,锁是 synchronized 括号里配置的对象

一个线程试图访问同步代码块时,必须获取锁,在退出或者抛出异常时,必须释放锁。

三、实现方式

JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但是两者的实现细节不一样。

  1. 代码块同步:通过使用 monitorenter 和 monitorexit 指令实现的
  2. 同步方法:ACC_SYNCHRONIZED 修饰

monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 指令是在编译后插入到同步代码块的结束处或异常处,对于同步方法,个人觉得也是类似的原理,进入方法前添加一个 monitorenter 指令,退出方法后条件一个 monitorexit 指令。

为了证明 JVM 的实现方式,下面通过反编译代码来证明:

public class Demo {

    public void f1() {
        synchronized (Demo.class) {
            System.out.println("Hello World.");
        }
    }

    public synchronized void f2() {
        System.out.println("Hello World.");
    }

}

编译之后的字节码如下(只摘取了方法的字节码):

public void f1();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=2, locals=3, args_size=1
       0: ldc           #2                  // class me/snail/base/Demo
       2: dup
       3: astore_1
       4: monitorenter
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String Hello World.
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      14: monitorexit
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
    LineNumberTable:
      line 6: 0
      line 7: 5
      line 8: 13
      line 9: 23
    StackMapTable: number_of_entries = 2
      frame_type = 255 /* full_frame */
        offset_delta = 18
        locals = [ class me/snail/base/Demo, class java/lang/Object ]
        stack = [ class java/lang/Throwable ]
      frame_type = 250 /* chop */
        offset_delta = 4

public synchronized void f2();
  descriptor: ()V
  flags: ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
    stack=2, locals=1, args_size=1
       0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #4                  // String Hello World.
       5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
    LineNumberTable:
      line 12: 0
      line 13: 8

先说 f1() 方法,发现其中一个 monitorenter 对应了两个 monitorexit,这是不对的。但是仔细看 #15: goto 语句,直接跳转到了 #23: return 处,再看 #22: athrow 语句发现,原来第二个 monitorexit 是保证同步代码块抛出异常时锁能得到正确的释放而存在的,这就理解了。

综上:发现同步代码块是通过 monitorenter 和 monitorexit 来实现的,同步方法是加了一个 ACC_SYNCHRONIZED 修饰来实现的。

四、Java对象头(存储锁类型)

在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充。

对象头中包含两部分:MarkWord 和 类型指针。如果是数组对象的话,对象头还有一部分是存储数组的长度。

多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作。

1、MarkWord

Mark Word 用于存储对象自身的运行时数据,如 HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等。
占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位,64位JVM -> MarkWord是64位)。

2、类型指针

虚拟机通过这个指针确定该对象是哪个类的实例。

3、对象头的长度
长度内容说明
32/64bitMarkWord存储对象的hashCode或锁信息等
32/64bitClass Metadada Address存储对象类型数据的指针
32/64bitArray Length数组的长度(如果当前对象是数组)

如果是数组对象的话,虚拟机用3个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头,如果是普通对象的话,虚拟机用2字宽存储对象头(32/64bit + 32/64bit)。

五、优化后synchronized锁的分类(锁的状态)

级别从低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

锁可以升级,但不能降级。即:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的。

下面看一下每个锁状态时,对象头中的 MarkWord 这一个字节中的内容是什么。

以32位系统为例:

1、无锁状态
25bit4bit1bit(是否是偏向锁)2bit(锁标志位)
对象的hashCode对象分代年龄001

这里的 hashCode 是 Object#hashCode 或者 System#identityHashCode 计算出来的值,不是用户覆盖产生的 hashCode。

2、偏向锁状态
23bit2bit4bit1bit2bit
线程IDepoch对象分代年龄101

这里 线程ID 和 epoch 占用了 hashCode 的位置,所以,如果对象如果计算过 identityHashCode 后,便无法进入偏向锁状态,反过来,如果对象处于偏向锁状态,并且需要计算其 identityHashCode 的话,则偏向锁会被撤销,升级为重量级锁。

epoch:

对于偏向锁,如果 线程ID = 0 表示未加锁。

什么时候会计算 HashCode 呢?比如:将对象作为 Map 的 Key 时会自动触发计算,List 就不会计算,日常创建一个对象,持久化到库里,进行 json 序列化,或者作为临时对象等,这些情况下,并不会触发计算 hashCode,所以大部分情况不会触发计算 hashCode。

Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。

3、轻量级锁状态
30bit2bit
指向线程栈锁记录的指针00

这里指向栈帧中的 Lock Record 记录,里面当然可以记录对象的 identityHashCode。

4、重量级锁状态
30bit2bit
指向锁监视器的指针10

这里指向了内存中对象的 ObjectMonitor 对象,而 ObectMontitor 对象可以存储对象的 identityHashCode 的值。

六、锁的升级

1、偏向锁

偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。

为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。

如果支持偏向锁(没有计算 hashCode),那么在分配对象时,分配一个可偏向而未偏向的对象(MarkWord的最后 3 位为 101,并且 Thread Id 字段的值为 0)。

a、偏向锁的加锁
  1. 偏向锁标志是未偏向状态,使用 CAS 将 MarkWord 中的线程ID设置为自己的线程ID,
    1. 如果成功,则获取偏向锁成功。
    2. 如果失败,则进行锁升级。
  2. 偏向锁标志是已偏向状态
    1. MarkWord 中的线程 ID 是自己的线程 ID,成功获取锁
    2. MarkWord 中的线程 ID 不是自己的线程 ID,需要进行锁升级

偏向锁的锁升级需要进行偏向锁的撤销。

b、偏向锁的撤销
  1. 对象是不可偏向状态
    1. 不需要撤销
  2. 对象是可偏向状态
    1. MarkWord 中指向的线程不存活
      1. 允许重偏向:退回到可偏向但未偏向的状态
      2. 不允许重偏向:变为无锁状态
    2. MarkWord 中的线程存活
      1. 线程ID指向的线程仍然拥有锁
        1. 升级为轻量级锁,将 mark word 复制到线程栈中
      2. 不再拥有锁
        1. 允许重偏向:退回到可偏向但未偏向的状态
        2. 不允许重偏向:变为无锁状态

小结: 撤销偏向的操作需要在全局检查点执行。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不在拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否 允许重偏向(rebiasing),获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁 升级为轻量级锁,线程B自旋请求获得锁。

偏向锁的撤销流程

img

2、轻量级锁

之所以是轻量级,是因为它仅仅使用 CAS 进行操作,实现获取锁。

a、加锁流程

如果线程发现对象头中Mark Word已经存在指向自己栈帧的指针,即线程已经获得轻量级锁,那么只需要将0存储在自己的栈帧中(此过程称为递归加锁);在解锁的时候,如果发现锁记录的内容为0, 那么只需要移除栈帧中的锁记录即可,而不需要更新Mark Word。

加锁前:

img

加锁后:

img

线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录(Lock Record)的指针, 如上图所示。如果成功,当前线程获得轻量级锁,如果失败,虚拟机先检查当前对象头的 Mark Word 是否指向当前线程的栈帧,如果指向,则说明当前线程已经拥有这个对象的锁,则可以直接进入同步块 执行操作,否则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数 达到界限值(threshold),轻量级锁将会膨胀为重量级锁。

b、撤销流程

轻量级锁解锁时,如果对象的Mark Word仍然指向着线程的锁记录,会使用CAS操作, 将Dispalced Mark Word替换到对象头,如果成功,则表示没有竞争发生。如果失败, 表示当前锁存在锁竞争,锁就会膨胀为重量级锁。

3、重量级锁

重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。

七、总结

首先要明确一点是引入这些锁是为了提高获取锁的效率, 要明白每种锁的使用场景,

  • 比如偏向锁适合一个线程对一个锁的多次获取的情况;
  • 轻量级锁适合锁执行体比较简单(即减少锁粒度或时间), 自旋一会儿就可以成功获取锁的情况.

要明白MarkWord中的内容表示的含义.

八、简单版总结

以下是32位的对象头描述

锁状态25 bit4bit1bit2bit
23bit2bit是否偏向锁锁标志位
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC标记11
偏向锁线程IDEpoch对象分代年龄1

synchronized锁的膨胀过程:

当线程访问同步代码块。首先查看当前锁状态是否是偏向锁(可偏向状态)

  • 1、如果是偏向锁:
    • 检查当前mark word中记录是否是当前线程id,如果是当前线程id,则获得偏向锁执行同步代码块。
    • 如果不是当前线程id,cas操作替换线程id,替换成功获得偏向锁(线程复用),替换失败,锁撤销,升级轻量锁(同一类对象多次撤销升级达到阈值20,则批量重偏向,这个点可以稍微提一下,详见下面的注意)
  • 2、升级轻量锁:
    • 升级轻量锁对于当前线程,分配栈帧锁记录lock_record(包含mark word和object-指向锁记录首地址),对象头mark word复制到线程栈帧的锁记录,mark word存储的是无锁的hashcode(里面有重入次数问题)
  • 3、重量级锁(纯理论可结合源码)
    • CAS自旋达到一定次数升级为重量级锁(多个线程同时竞争锁时)
    • 存储在ObjectMonitor对象,里面有很多属性ContentionList、EntryList 、WaitSet、 owner。当一个线程尝试获取锁时,如果该锁已经被占用,则该线程封装成ObjectWaiter对象插到 ContentionList队列的对首,然后调用park挂起。该线程锁时方式会从ContentionList或EntryList挑一个唤醒。线程获得锁后调用Object的wait方法,则会加入到WaitSet集合中(当前锁或膨胀为重量级锁)

注意:

  • 1.偏向锁在JDK1.6以上默认开启,开启后程序启动几秒后才会被激活
  • 2.偏向锁撤销是需要在safe_point,也就是安全点的时候进行,这个时候是stop the word的,所以说偏向锁的撤销是开销很大的,如果明确了项目里的竞争情况比较多,那么关闭偏向锁可以减少一些偏向锁撤销的开销
  • 3.以class为单位,为每个class维护一个偏向锁撤销计数器。每一次该class的对象发生偏向撤销操作时(这个时候进入轻量级锁),该计数器+1,当这个值达到重偏向阈值(默认20,也就是说前19次进行加锁的时候,都是假的轻量级锁,当第20次加锁的时候,就会走批量冲偏向的逻辑)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象也会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时,class中的epoch值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的站,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获取锁时,发现当前对象的epoch值和class不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id改为当前线程ID
### 回答1: 非常感谢您的问题。synchronized升级过程是Java中优化技术之一,它可以减少的竞争,提高程序的执行效率。在Java中,每个对象都有一个(也称为内置或监视器),线程在执行同步代码块时会获取该对象的,并在代码块执行完成后释放该。当多个线程同时访问同一个对象的同步代码块时,就会出现竞争的情况,影响程序的执行效率。 在Java6之前,synchronized的实现存在较大的性能问题,当多个线程竞争同一个时,会导致线程频繁地进入和退出阻塞状态,增加了线程切换的开销,降低了程序的执行效率。为了解决这个问题,Java6引入了偏向、轻量级和重量级优化技术,并采用升级的方式在不同之间进行转换,从而提高程序的执行效率。 具体来说,当一个线程第一次进入同步代码块时,会先尝试获取对象的偏向,如果对象没有被其他线程访问过,那么当前线程会获取到该对象的偏向,并将当前线程ID记录在对象头中,以后该线程每次进入同步代码块时都无需重新获取,直接使用偏向即可。如果有其他线程访问了该对象,那么当前线程会尝试将偏向升级为轻量级。轻量级是一种自旋,它采用CAS(Compare And Swap)指令来保证线程的原子性,避免了线程阻塞和唤醒的开销。如果当前线程获取轻量级成功,那么就可以在自旋过程中执行同步代码,避免了线程阻塞和唤醒的开销。如果轻量级获取失败,那么当前线程就会尝试将轻量级升级为重量级,此时当前线程会阻塞等待其他线程释放该对象的,直到获取到重量级才能执行同步代码。 总之,synchronized升级过程是Java中优化技术之一,它可以提高程序的执行效率,减少的竞争。 ### 回答2: synchronized是Java中用于保证多个线程访问同一个共享资源时的线程安全的关键字。当一个线程想要执行synchronized修饰的同步代码块时,需要先获取对象的,如果被其他线程占用,则当前线程会被阻塞,直到获取到为止。 synchronized升级过程分为偏向、轻量级和重量级三个阶段。 首先,在没有竞争的情况下,将被优化为偏向。偏向的作用是,当一个线程获取到时,会在对象的头部标记记录该线程的ID。在下次该线程再次申请时,无需再次进行同步操作,直接获取即可。 然后,如果有多个线程竞争同一个,偏向将转为轻量级。这时每个线程都会在自己的线程栈帧中保存的副本。线程会通过CAS(比较并交换)来进行的获取和释放,而不再阻塞线程。 最后,如果多个线程仍然竞争同一个,轻量级升级为重量级。重量级的实现是利用操作系统提供的互斥量机制,当一个线程获取后,其他线程将被阻塞,直到持有的线程释放升级过程在多线程环境下进行,根据状态切换来提高并发效率。通过合理地选择的类型以及的级别,可以更好地平衡性能与安全性之间的关系。 ### 回答3: synchronized升级过程是指在Java中保证多线程访问同步代码时的一种优化机制。其主要目的是提高多线程并发访问共享资源时的性能和效率。 当一个线程尝试进入同步代码块时,会先尝试获取对象的无状态。如果成功获取无状态,则可以直接执行同步代码,并将对象标记为偏向。这是的第一级别,也是最轻量级的。如果在此时另一个线程也想要进入同步代码,就会造成竞争。 如果存在竞争,偏向就会升级为轻量级。轻量级是通过在对象头中的标识字段中记录指向线程栈中记录的指针来实现的。如果线程竞争太激烈,轻量级就会升级为重量级。 重量级是指同步代码块被多个线程访问时,会将线程阻塞并等待释放。重量级采用操作系统的互斥量实现,所以比较耗时和耗资源。 在升级过程中,状态会从无状态到偏向,再到轻量级,最后到重量级。在逐步升级过程中,的开销也会逐渐增加。 需要注意的是,在JDK 6之后,引入了消除和膨胀机制。消除指的是JVM在编译器优化时发现某些代码分支中不存在线程竞争时,会去除相应的操作;膨胀指的是JVM会根据竞争情况,将轻量级升级为重量级。 综上所述,synchronized升级过程是为了提高多线程并发访问同步代码时的性能和效率。通过从无状态到偏向,再到轻量级,最后到重量级升级过程JVM可以根据竞争情况选择最适合的状态,以实现最佳的性能和资源利用。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

悬浮海

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值