切换语言为:繁体

使用Synchronized时,锁资源对象升级过程是否一定会经过偏向锁阶段?

  • 爱糖宝
  • 2024-08-20
  • 2061
  • 0
  • 0

在java语言中,有两个锁一个是jdk层面的ReentrantLock,一个是jvm层面的synchronized,这两个锁是我们掌握并发编程中不可或缺的两个类。本文主要从使用、原理两个方面来介绍synchronized

使用

synchronized用法

public class SynchronizedTest {

    Object o1 = new Object();

    static Object o2 = new Object();

    @SneakyThrows
    public synchronized void test1() {
        Thread.sleep(1000);
    }

    @SneakyThrows
    public static synchronized void test2() {
        Thread.sleep(1000);
    }

    @SneakyThrows
    public void test3() {
        synchronized (o1) {
            Thread.sleep(1000);
        }
    }

    @SneakyThrows
    public void test4() {
        synchronized (o2) {
            Thread.sleep(1000);
        }
    }

    @SneakyThrows
    public void test5() {
        synchronized (this) {
            Thread.sleep(1000);
        }
    }

    @SneakyThrows
    public void test6() {
        synchronized (SynchronizedTest.class) {
            Thread.sleep(1000);
        }
    }
}

这是synchronized的几种常见用法,但你了解其中锁的粒度么?哪些是锁对象、哪些是锁整个类的呢?下面写了一个测试的执行代码,通过代码的执行结果最有说服力。
代码说明:

  1. 获取开始时间;

  2. 分别创建两个线程,并且new两个SynchronizedTest,使用对象分别调用上述方法;

  3. 开启两个线程;

  4. 等待两个线程执行完成;

  5. 打印执行的总时长;

    • 如果时长为1s,则说明锁的粒度是当前new出来的对象;

    • 如果时长为2s,则说明锁的力度是当前类。

@SneakyThrows
public static void main(String[] args) {
    long startTime = System.currentTimeMillis();
    Thread t1 = new Thread(() -> new SynchronizedTest().test1());
    Thread t2 = new Thread(() -> new SynchronizedTest().test1());
    t1.start();
    t2.start();

    t1.join();
    t2.join();
    System.out.println("执行时间: " + (System.currentTimeMillis() - startTime) / 1000);
}

锁粒度

大家可以根据上述代码自己执行一遍,这样印象会更深;下面我直接贴出采用各使用方法时锁的粒度。

test1 : 锁的是当前实体的该方法,执行结果为1s.
test2 : 锁的是当前,执行结果为2s.
test3 : 锁的是当前实体的o1属性,执行结果为1s.
test4 : 锁的是当前类的o2属性,执行结果为2s.
test5 : 锁的是当前对象,执行结果为1s.
test6 : 锁的是当前,执行结果为1s.

原理

在原理方面,会先看一下加了synchronized后字节码会有什么变化,再介绍一下在jdk1.6做了哪些优化,然后着重介绍一下锁的各个阶段以及重量级锁的实现方法。

字节码

首先我们来分析一下,使用synchronized时字节码层面会如何变化,这里我写了一个类再通过javac、javap来查看一下字节码有什么变化。java代码如下:

public class Test {
    Object o = new Object();
    public void test1(){
    }

    public void test2(){
        synchronized (o){
        }
    }
}

在该java类型目录下,执行javac Test.java,再执行 javap -v Test.class,执行完后会出现稍微能看明白一点的汇编代码,其中这两个方法汇编代码如下:

  public void test1();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 11: 0

  public void test2();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1 
      /** * 0: 将对象引用压入操作数栈 */ 
      0: aload_0 
      /** * 1: 获取指定字段的值并压入操作数栈 */ 
      1: getfield #7 // Field o:Ljava/lang/Object; 
      /** * 4: 复制操作数栈顶元素 */ 
      4: dup 
      /** * 5: 将操作数栈顶元素存储到局部变量 1 中 */ 
      5: astore_1 
      /** * 6: 进入同步块,获取对象的监视器锁 */ 
      6: monitorenter 
      /** * 7: 将局部变量 1 所引用的对象压入操作数栈 */ 
      7: aload_1 
      /** * 8: 退出同步块,释放对象的监视器锁 */ 
      8: monitorexit 
      /** * 9: 跳转到偏移量为 17 的指令 */ 
      9: goto 17 
      /** * 12: 将异常对象引用存储到局部变量 2 中 */ 
      12: astore_2 
      /** * 13: 将局部变量 1 所引用的对象压入操作数栈 */ 
      13: aload_1 
      /** * 14: 退出同步块,释放对象的监视器锁 */ 
      14: monitorexit 
      /** * 15: 将局部变量 2 所引用的异常对象压入操作数栈 */ 
      15: aload_2 
      /** * 16: 抛出操作数栈顶的异常 */ 
      16: athrow 
      /** * 17: 方法返回 */ 
      17: return

这里我给test2方法增加了注释,可以观察到到加了synchronized后会多出来monitorenter,monitorexit两个指令,后面我们分析源码时可以着重看一下monitor是如何实现的锁。

锁优化

在JDK1.5的时候,Doug Lee推出了JUC包,在这里实现了ReentrantLock,并且它的性能远高于synchronized,所以JDK团队就在JDK1.6中,对synchronized做了大量的优化。例如:

锁消除:在synchronized修饰的代码中,如果不存在操作共享变量的情况,会触发锁消除,即便写了synchronized,也不会触发。如:

public int test(){
    synchronized (o){
        int i = 0;
        return ++i;
    }
}

锁膨胀:如果在一个方法中,频繁的获取和释放锁资源,就会优化为将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗。如:

public void test1() {
    for (int i = 0; i < 100000; i++) {
        synchronized (o) {
        }
    }
}

锁升级:在synchronized在1.6版本之前,获取不到锁时,会立即挂起当前线程,后续释放锁时再唤醒竞争线程进行锁的竞争。而到了1.6版本时,对synchronized做了锁升级的优化;

无锁、匿名偏向:当前对象没有作为锁存在。
偏向锁:如果当前锁资源,只有一个线程在使用,那么这个线程过来,只需要判断,该对象的threadId指向的是否为当前线程。

  • 如果是,直接获取锁;

  • 如果不是,基于CAS的方式,尝试通过cas将threadId指向当前线程。如果获取不到,触发锁升级,升级为轻量级锁。(偏向锁状态出现了锁竞争的情况)

轻量级锁:会采用自旋锁的方式去频繁的以CAS的形式获取锁资源(采用的是自适应自旋锁

  • 如果cas成功则获取锁;

  • 如果自旋了一定次数,没获取到锁,锁升级。

重量级锁:就是最传统的synchronized方式,获取不到锁资源,就挂起当前线程,等待其他线程释放锁并唤起该线程进行锁资源竞争。

观察锁升级

为了更直观的观察到锁的升级过程,首先我在下面我贴了一张锁资源对象头中markdown在各个状态下各个字节含义的示意图,大家对比着看一下。

使用Synchronized时,锁资源对象升级过程是否一定会经过偏向锁阶段? 其次在代码中引用了jol-core,它可以在代码层面打印出对象头信息,接下来我会举一些例子让大家能更直观的看到锁的升级。

implementation 'org.openjdk.jol:jol-core:0.9'

无锁

public static void main(String[] args) {
    Object o = new Object();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

上面代码就是打印对象o的实例信息,打印结果如下:

使用Synchronized时,锁资源对象升级过程是否一定会经过偏向锁阶段?

可以看到我用红色框框圈出来的就是锁的标识位001为无锁状态

偏向锁

public static void main(String[] args) {
    Object o = new Object();
    new Thread(() -> {
        synchronized (o) {
            //t1  - 偏向锁
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }).start();
}

上面代码就是用一个线程获取锁资源,此时该锁资源就会偏向到该线程,再看一下打印结果:

使用Synchronized时,锁资源对象升级过程是否一定会经过偏向锁阶段? 使用Synchronized时,锁资源对象升级过程是否一定会经过偏向锁阶段? 这两张图的结果都是正确的,不同的jdk版本会出现不同的结果,如果你本地执行发现是000轻量级锁的标识不用惊讶,这是因为你使用的jdk版本默认开启了锁的偏向延迟。我本地测试使用open jdk8是第一种结果,在代码执行前加上Thread.sleep(5000L);会变成第二种结果,使用zulu jdk11、jdk17、jdk21都是都是000,他们默认都不开启偏向锁。

偏向锁在升级为轻量级锁时,会涉及到偏向锁撤销,需要等到一个安全点(STW),才会让偏向锁撤销,在知道有并发时,就可以选择不开启偏向锁,或者是设置偏向锁延迟开启。

因为JVM在启动时,需要加载大量的.class文件到内存中,这个操作会涉及到synchronized的使用,为了避免出现偏向锁撤销操作,JVM启动初期,有一个延迟4s开启偏向锁的操作

命令参数如下:

-XX:+/-UseBiasedLocking 启动/禁用偏向锁,默认虚拟机启动4秒后启动偏向锁 -XX:BiasedLockingStartupDelay 虚拟机启动后,立刻启动偏向锁

轻量级锁

这里不就举例了,上个例子中不开启偏向锁会直接变更为轻量级锁的。

重量级锁

大家还记得上面在看字节码时有一个Monitor对象,我们就来看一下这个对象中有什么内容。

ObjectMonitor() { 
  // 头节点,存储着 MarkWord 
  _header = NULL; 
  // 竞争锁的线程个数 
  _count = 0; 
  // 等待(wait)的线程个数 
  _waiters = 0; 
  // 标识当前 synchronized 锁重入的次数 
  _recursions = 0; 
  // 关联的对象 
  _object = NULL; 
  // 持有锁的线程 
  _owner = NULL; 
  // 保存等待(wait)线程的信息,双向链表 
  _WaitSet = NULL; 
  // WaitSet 的锁 
  _WaitSetLock = 0 ; 
  // 负责的线程(可能用于特定的职责标识) 
  _Responsible = NULL ; 
  // 后继节点(可能用于链表结构) 
  _succ = NULL ; 
  // 获取锁资源失败后,线程要放到当前的单向链表中 
  _cxq = NULL ; 
  // 下一个空闲节点(可能用于链表管理) 
  FreeNext = NULL ; 
  // 等待获取锁的线程队列(_cxq 以及被唤醒的 WaitSet 中的线程,在一定机制下,会放到这里) 
  _EntryList = NULL ; 
  // 自旋频率 
  _SpinFreq = 0 ; 
  // 自旋时钟 
  _SpinClock = 0 ; 
  // 是否为线程所有者的标识 
  OwnerIsThread = 0 ; 
  // 前一个所有者线程的线程 ID 
  _previous_owner_tid = 0; 
}

上述标识我自己翻译了一遍,这个类还有很多实现方法,但由于都是c语言了,就不再详细解释了,可以观察到这里面有几个属性和ReentrantLock很像,大概也借鉴了ReentrantLock的实现。如重入次数链表等待线程持有锁的线程等,有一个区别是这里采用了单向链表,而ReentrantLock采用了双向链表因为ReentrantLock有找前驱节点的需求所以采用了双向链表,例如在释放锁资源唤醒后置节点时,此时后置已经被取消,那么就需要从tail节点往前查询,又或者加入到链表中,从后插入只需要O(1)的时间复杂度。而ObjectMonitor采用单向链表,由于我没详细看过代码,没法给大家具体的观点,如果有会c语言的同学可以帮忙解答一下。

总结

‌在jdk1.6以后在低并发时使用synchronized的性能是高于lock的,因为它是在JVM层面实现的,而且不需要显式地获取和释放锁。但是在高并发下synchronized是不如lock,例如lock支持读写锁分离、线程挂起时插入到链表中时间复杂度为O(1)等。
除此之外lock还支持更多使用场景,例如:公平锁和非公平锁、尝试获取锁等。具体选择哪个还是要根据具体的应用场景和需求来选择,大家平时都使用哪个锁呢?

0条评论

您的电子邮件等信息不会被公开,以下所有项均必填

OK! You can skip this field.