多线程-锁升级测试+ 源码

多线程加锁是为了当多个线程同时调用相同代码块时,将多线程在该代码块上的并行变成串行,一个一个执行,这样就能保证每个线程能拿到“最新的”变量,从而避免各线程计算出错。

sychronized 是常用的加锁手段, 在JDK 1.6 之前,synchronized(obj) 会直接在操作系统中为 obj 专门创建一个 monitor 对象。A 线程执行 synchronized(obj){...} 时,会将 A 的线程 ID 写到 monitor 的 owner 属性中。此时 如果B 线程也要执行该代码段,会先去检查 monitor.woner,发现里面是 A,那么B 线程就会等待(陷入阻塞),B线程将它的线程 ID 写到 monitor 的等待队列(EntryList) 中“沉睡”, 待 A 线程执行完后,操作系统“唤醒”B线程,B 将自己的线程ID 写到 monitor.woner 中,并开始执行该代码块。

重量级锁由来

线程 A、B 运行在 JVM 之上,JVM 运行在操作系统之上。 monitor 是在操作系统内部创建的对象, 它本质上依赖于操作系统的 互斥锁(Mutex Lock) 实现的。操作系统在执行线程切换时,需要在用户空间和内核空间执行切换,中间会涉及到大量的系统变量保存与读取,这是个很 “重” 的操作,所以就有了【重量级锁】这个称号。

操作系统执行线程切换会耗费较大的时间,并发量大时,重量级锁的执行效率很低,而当只有单线程时,就为 obj 创建 monitor 对象又显得很不划算。因为单线程不涉及并发,直接执行就好了。

重量级锁优化

所以,1.6 以后就对重量级锁进行了优化。优化的思路是:依旧采用 synchronized(obj){...} 的代码形式执行加锁,但 JVM 在执行时,别一上来就加重量级锁,关联到操作系统底层。而是在 JVM 层面先解决下,等到场景实在不行了,再创建重量级锁。

基于这个思路,在具体的实现上就出现了【偏向锁】和【轻量级锁】。从而根据不同的线程场景,出现了 偏向锁 --> 轻量级锁 --> 重量级锁 这样的一个锁升级过程。

对象的锁状态

了解各种锁状态之前,先看得看下java对象的组成,因为对象锁的状态肯定写在该对象的某个地方。一个 java 对象由三部分构成:对象头,实例数据,对齐填充。平时代码中写的成员变量就属于实例数据部分,对象的锁状态信息存储在对象头里面。

这里以 32 虚拟机与 64 为虚拟机对象头的内容是一样的,区别只是存放内容的位数不一样。32平米的房子里能住一个人,那这个人住在64平米的房子里也是没问题的。这里以 32 位虚拟机为例。

对象头由两部分构成:

  • Mark Word 存放跟锁相关的信息。
  • Klass Word 存放着该对象属于哪个类。
|-----------------------------------------------------------|
|                    Object Header (64 bits)                |
|---------------------------------|-------------------------|
|             Mark Word (32 bits) | Klass Word (32 bits)    |
|---------------------------------|-------------------------|

详细看下 Mark Word 的状态:

|-------------------------------------------------------------------|
|                      Mark Word(32bits)               |   State    | 
|-------------------------------------------------------------------|    
|     hashcode:25         | age:4 | biased_lock:0 | 01 |   无状态    |   
|-------------------------------------------------------------------|    
|     thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 |   偏向锁    |    
|-------------------------------------------------------------------|    
|     ptr_to_lock_record:30                       | 00 |   轻量级    |     
|-------------------------------------------------------------------|    
|    ptr_to_heavyweight_monitor:30                | 10 |   重量级    | 
|-------------------------------------------------------------------|    
|                                                 | 11 |   垃圾回收  | 
|-----------------------------------------------------------------

无状态和偏向锁:Mark Word 的倒数第三位 biased_lock 表示该对象是否开启偏向锁,只有开启对象锁的对象才能线程加上对象锁。JVM 默认延迟 4s 左右开启对象锁,就是说 程序刚运行时,前 4s 内创建的对象是无状态的(001),4s 以后创建的对象都是开启偏向锁的,这些对象 Mark Word 后三位都是 101。

轻量级锁:Mark Word 的最后两位是 00,前面的空间用来存放锁记录的地址。JVM会给每个线程分配一定的栈空间,线程执行方法时,会在栈空间中创建一个栈帧,锁记录是栈帧里面的一个对象。

重量级锁:Mark Word 的最后两位是 10,前面的空间用来存放 monitor 的地址(指针)。 前面提到过,monitor 是操作系统内部的对一个对象。

从这里可以看出,偏向锁和轻量级锁都是在 JVM 层面加的锁,说白了都是 java对象交换了地址,这些都能在 JVM 层面处理掉。只有重量级锁涉及要与操作系统内部的对象执行地址交换。

锁升级过程

1.6 以后,开发者依旧只需要使用 synchronized 对代码块执行加锁,至于该加哪种锁,由 JVM 自己根据线程调用情况进行判断,并适当执行锁升级过程。从而实现对 synchronized 的优化。

无状态和开启偏向

JVM 默认延时开启偏向,时间大概 4s 左右。但可以通过参数设定,关闭默认延迟开启偏向锁。

public static void test0() throws InterruptedException {
    User user1 = new User();
    System.out.println("无状态(001):" + ClassLayout.parseInstance(user1).toPrintable());

    // 默认 4s 以后开启偏向锁
    Thread.sleep(5000);
    User user2 = new User();
    System.out.println("开启偏向(101):" + ClassLayout.parseInstance(user2).toPrintable());
}

user1:
image-20210110141316536
User2:

偏向锁

当对象开启偏向锁后, 如果只是单线程调用(很常见的场景),或者 多线程调用但是没有竞争,synchronized(obj) 会给 obj 加偏向锁。

public static void test1() throws InterruptedException {
    Thread.sleep(5000);
    User user2 = new User();
	
    // for 循环模拟机同一线程重复加锁。
    for (int i = 0; i < 2; i++) {
      synchronized (user2) {
        System.out.println("加偏向锁(101),并带着线程id:" + ClassLayout.parseInstance(user2).toPrintable());
      }
      System.out.println("释放偏向锁(101),并带着线程id:" + ClassLayout.parseInstance(user2).toPrintable());
    }
  }

加偏向锁:

释放偏向锁:

可以观察到,当 synchronized(user){} 代码块执行完后,user2 的 Mark Word 字段没有变化。偏向锁不会主动释放,目的是当同一线程再次对该代码段加锁时,user2 的 Mark Word 存放着自己的线程 ID,那就直接执行代码块,不用再执行加锁过程(省去了交换线程 ID 的步骤)。

偏向锁的目的:单线程执行同步资源(synchronized代码块)时,可以直接获取资源,不需要重复执行加锁过程。(注意一点,此时没有线程竞争)

这里可能有迷惑,单线程没有并发不需要加锁呀,搞这个干嘛。问题是开发者写的时候觉得这块大概率会出现并发,所以用了 synchronized,但他预估不到此时是单线程执行呀。等到JVM 执行的时候,发现当前是单线程执行,JVM 就寻思着“单线程执行,给你创建个偏向锁就可以了,没必要搞 monitor 。多线程出现竞争了,我再升级下锁就可以了”。这样就 避免了一上来就创建重量级锁。

轻量级锁

即便是代码块执行结束,偏向锁加上后也不会被释放,当另一个线程执行synchronized (obj) 时,会直接将 obj 从偏向锁升级到轻量级锁。

public static void test2() throws InterruptedException {
	Thread.sleep(5000);
    User user2 = new User();
    // 主线程给 user2 加偏向锁
    synchronized (user2) {
      System.out.println("加偏向锁(101),并带着线程id:" + ClassLayout.parseInstance(user2).toPrintable());
    }
    Thread.sleep(100);
    // 新线程给 user2 加轻量级锁
    new Thread(() -> {
      synchronized (user2) {
        System.out.println("轻量级锁(00),带着线程id:" + ClassLayout.parseInstance(user2).toPrintable());
      }
      System.out.println("轻量级锁释放,变成无状态(001)" + ClassLayout.parseInstance(user2).toPrintable());
    }).start();
	// 第三次加锁
    Thread.sleep(500);
    synchronized (user2) {
      System.out.println("再次加锁" + ClassLayout.parseInstance(user2).toPrintable());
    }
    System.out.println("再次释放锁" + ClassLayout.parseInstance(user2).toPrintable());
}

偏向锁:

轻量级锁:

轻量级锁释放:

再次加锁:

再次释放锁:

从测试结果可以看出:

  1. 其他线程给偏向锁对象再加锁时,JVM 会将偏向锁升级成轻量级锁,当线程执行完代码块后,对象的轻量级锁会被释放,变成无锁状态。

  2. 一个对象只能被加一次偏向锁,如果锁被升级后,以后每次加锁至少是轻量级的。(其实就是对象锁只能升级,不能降级)。

  3. 注意一下,这里出现了多线程,但没有并发。

那轻量级锁的目的是什么呢?

这里得先简单说下 synchronized(obj) 执行轻量级加锁时干了什么事情。栈帧里有个所记录对象:lockRecord。A 线程给 obj 加锁时,先将 lockRecord 的地址 + 00 写到 obj 的Mark word 里面, 再将 obj 的地址写到 lockRecord 中保存,做了一次交换,等释放轻量锁时,再将 obj 恢复成无状态的。

轻量锁的目的:当多个线程竞争同步资源时,没有获取到资源的线程会自旋等待锁释放。比如:A 线程正在执行 synchronized(obj) 还没结束,B 线程也要执行该代码块,B 发现 obj 不是无状态的,B就开始等着,隔一会就检查下 obj 没有被释放,没释放就继续等,再隔一会,再检查。这个“等待–> 检查 --> 等待–>检查”的过程就叫自旋。如果 B 自旋了很少几次,A就释放锁了,那B开始给 obj 加轻量级锁,并开始执行代码块。

对线程加锁时,JVM 会自动加轻量级锁,当出现竞争时,没获得锁的线程会先采用自旋的方式“盯着”锁被释放。线程自旋占用了 CPU 的计算,而且是空转,当自旋次数较小时,采用 cpu 的计算时间换操作系统切换线程的开销,还是很划算的。

重量级锁

自旋是有次数限制, 默认允许 10 次,但可以通过虚拟机参数更改。在 java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

但如果长时间自旋依旧依旧等不到锁释放时,此时,JVM 就会将轻量级锁升级到重量级锁。

  private static void test3() throws InterruptedException {
    Thread.sleep(5000);
    User user2 = new User();
    System.out.println("开启偏向(101):" + ClassLayout.parseInstance(user2).toPrintable());

    synchronized (user2) {
      System.out.println("加偏向锁(101),并带着线程id:" + ClassLayout.parseInstance(user2).toPrintable());
    }

    new Thread(() -> {
      synchronized (user2) {
        System.out.println("轻量级锁(00),带着线程id:" + ClassLayout.parseInstance(user2).toPrintable());
        try {
            // 占着锁不释放
          Thread.sleep(3000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }).start();

    Thread.sleep(1000);
    new Thread(() -> {
      synchronized (user2) {
        System.out.println(" 重量级锁(10),带着线程id:" + ClassLayout.parseInstance(user2).toPrintable());
      }
    }).start();
    
    Thread.sleep(3000);
    System.out.println(" 重量级锁释放:" + ClassLayout.parseInstance(user2).toPrintable());
  }

加重量级锁:

重量级锁释放:

从测试结果可以看到,重量级锁释放后,对象也会被恢复成无状态的。

之前提到过,重量级锁会关联到操作系统中的 monitor 对象。

重量级锁的目的是:多个线程竞争同步资源时,没有获取到资源的线程会陷入阻塞,陷入“沉睡”,被动等待被唤醒。“盯着”了好久都没拿到锁,而且还很费 CPU 开销,那不如放弃 CPU 空转,自己进入等待队列,让别人(操作系统)来唤醒自己。

小结一下:

  1. 开发者依旧使用 synchronized(obj) {} 形式加锁,至于加哪种锁,JVM 会根据当前线程自己确定。
  2. 只有单线程加锁时,JVM 会加偏向锁,这样当单线程重复加锁时,可以直接执行代码块。
  3. 一个对象只能被加一次偏向锁,因为单线程不会重复加锁,多线程会加轻量级锁,锁只能升级不能降级。
  4. 多线程加锁时,JVM 会加轻量级锁,当出现多线程竞争同步资源时,未获得资源的线程会先自旋,等待轻量级锁释放。(“盯着”)。
  5. 当自旋超过限制时,JVM 会将轻量级锁升级到重量级锁,操作系统给 obj 创建 monitor 对象,没有拿到锁的线程进入 monitor 的等待队列中,等待被唤醒。(“沉睡”)
  6. 轻量级锁和重量级锁释锁后,obj 会变成无状态。
  7. 偏向锁本身就是串行,轻量级锁在JVM 层面将多线程并行变成串行,重量级锁将多线程并行变成串行要借助操作系统创建对象,并执行线程调度。

两个小问题:

  1. 给未开启偏向的对象加锁,会加成什么锁?

    会直接加成轻量级锁。

  2. 线程加了偏向锁,且还没有执行完毕,此时出现多线程竞争,锁会怎么变?

    我测试的结果是:没获得锁的线程线程接升级到重量级锁,并陷入阻塞,等待锁释放。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值