并发编程八:深入理解synchronized(下)

深入理解synchronized(下)

上篇回顾总结

先回顾下偏向锁、轻量级锁、重量级锁。
偏向锁:不存在竞争情况下,会偏向某个线程,该线程后续的进入synchronized中不需要加锁和解锁操作
轻量级锁:线程之间存在轻微竞争(线程交替执行),通过CAS获取锁,获取锁失败后膨胀为重量级锁
重量级锁:发生激烈竞争的情况,在膨胀期间创建一个monitor对象。

关于偏向锁轻量级锁重量级锁存在的理解误区

  1. 无锁–>偏向锁–>轻量级锁 -->重量级锁
    问题出在于无锁到偏向锁。在不考虑偏向锁延迟的情况下,对象的锁状态要么处于无锁状态,要么处于偏向锁状态。并没有从无锁到偏向锁的概念。无锁状态的改变和偏向锁一样,会往轻量级锁和重量级锁的状态改变。
  2. 轻量级锁子旋获取锁失败,会膨胀升级为重量级锁
    问题在于轻量级锁自旋。轻量级锁不存在自旋 轻量级锁CAS失败后会膨胀为重量级锁,这里面没有自旋
  3. 重量级锁不存在自旋
    当轻量级锁膨胀为重量级锁时,会通过CAS自适应自旋,只有自旋失败后才会真正的从用户态到内核态的切换,调用操作系统的挂起指令

锁状态跟踪

上篇有展示不同锁状态的改变。现在来更详细的跟踪锁状态,并总结锁状态的改变。

 public static void main(String[] args) throws InterruptedException {
 		log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
        //HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
        Thread.sleep(5000);
        Object obj = new Object();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());

        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){
                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();
}

先来看log.debug(ClassLayout.parseInstance(new Object()).toPrintable());的打印记录
在这里插入图片描述
没满足偏向锁延迟,此时是无锁状态
然后满足偏向锁延迟后 log.debug(ClassLayout.parseInstance(obj).toPrintable());
在这里插入图片描述
此时是偏向锁状态,不过此时的偏向锁状态属于可偏向状态,因为没有可偏向的线程也叫匿名偏向
执行log.debug(Thread.currentThread().getName()+"开始执行。。。\n"+ClassLayout.parseInstance(obj).toPrintable());
在这里插入图片描述
任然处于匿名偏向状态
当加上synchronized之后synchronized (obj){ log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n" +ClassLayout.parseInstance(obj).toPrintable()); }

在这里插入图片描述
此时是偏向锁状态,并且指向了对应线程
释放锁log.debug(Thread.currentThread().getName()+"释放锁。。。\n" +ClassLayout.parseInstance(obj).toPrintable());

在这里插入图片描述
偏向锁释放锁任然是偏向锁,并且任然指向之前的线程。因为偏向锁并没有真正的上锁和解锁,就是说没有上锁和解锁的开销。

结论:经过偏向锁延迟后创建的对象锁状态为偏向锁状态。此时的偏向锁为匿名偏向,没有偏向任何线程。当使用synchronized对象会通过CAS操作,指向上锁的线程,此时偏向锁才真正偏向该线程。偏向锁没有上锁和解锁的开销,所以解锁操作没有实际意义。

案例代码修改
在这里插入图片描述
增加了obj对象调用hashCode方法,其他代码没变。上篇有说过偏向锁状态没有地方保存hashcode,为了保存这个hashcode的值会改变偏向锁状态
在这里插入图片描述
上篇有说过如果偏向锁状态处于可偏向状态(匿名偏向),为了保存hashcode偏向锁状态会变成无锁状态。但是从偏向锁状态到无锁状态需要有一个偏向锁撤销的步骤。偏向锁状态要更改成其他状态,会先进行偏向锁撤销。
在这里插入图片描述
无锁状态有轻微竞争的话会升级轻量级锁,轻量级锁解锁后会变成无锁状态。
结论:当对象处于匿名偏向时,调用hashcode方法,会触发偏向锁撤销,锁状态变成无锁状态。无锁状态的对象被synchronized上锁后,如果此时竞争不激烈优先升级为轻量级锁,轻量级锁释放锁后会降级为无锁状态

上面代码修改,将obj.hashCode();放到synchronized里面执行
在这里插入图片描述

在这里插入图片描述
一开始是偏向锁处于匿名偏向,通过synchronized指向了线程,通过obj.hashCode();偏向锁会升级为重量级锁。

锁从重量级
重量级锁释放后变成无锁状态。上面代码释放锁休眠1秒后在打印,重量级锁到无锁的改变需要清除Monitor。清除Monitor是通过GC清除,所以需要一定的时间。
结论:当对象的偏向锁状态已经偏向某个线程时,调用hashcode方法,会触发偏向锁撤销,锁状态升级为重量级锁。重量级锁解锁后变成无锁状态

上面都是没有竞争的情况下,下面模拟竞争的情况

@Slf4j
public class LockEscalationDemo{

    public static void main(String[] args) throws InterruptedException {

        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
        //HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
        Thread.sleep(5000);
        Object obj = new Object();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());

        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){
                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();
        //控制线程竞争时机
        Thread.sleep(1);
        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){

                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread2").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){

                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread3").start();
        Thread.sleep(5000);
        log.debug(ClassLayout.parseInstance(obj).toPrintable());



    }
}

在竞争的情况下,每次运行情况都不一样,这里就不去分析。

synchronized锁优化

对于synchronized的优化除了引入偏向锁、轻量级锁外,还有偏向锁批量重偏向、偏向锁批量撤销、自旋优化、锁消除等。

偏向锁批量重偏向和批量撤销

从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能 开销基本可以忽略,但是当有其他线程尝试获得锁时,偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。
原理
以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同 时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。
当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后 (默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后, 对于该class的锁,直接走轻量级锁的逻辑。
目的
批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操 作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。
批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
测试批量重偏向

 public static void main(String[] args) throws  InterruptedException {
       //延时产生可偏向对象
       Thread.sleep(5000);
       // 创建一个list,来存放锁对象
       List<Object> list = new ArrayList<>();
       // 线程1
       new Thread(() -> {
           for (int i = 0; i < 50; i++) {
               // 新建锁对象
               Object lock = new Object();
               synchronized (lock) {
                   list.add(lock);
               }
           }
           try {
               //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
               Thread.sleep(100000);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
       }, "thead1").start();
       //睡眠3s钟保证线程thead1创建对象完成
       Thread.sleep(3000);
       log.debug("打印thead1,list中第20个对象的对象头:");
       log.debug((ClassLayout.parseInstance(list.get(19)).toPrintable()));
       // 线程2
       new Thread(() -> {
           for (int i = 0; i < 40; i++) {
               Object obj = list.get(i);
               synchronized (obj) {
                   if(i>=15&&i<=21||i>=38){
                       log.debug("thread2-第" + (i + 1) + "次加锁执行中\t"+
                               ClassLayout.parseInstance(obj).toPrintable());
                   }
               }
               if(i==17||i==19){
                   log.debug("thread2-第" + (i + 1) + "次释放锁\t"+
                           ClassLayout.parseInstance(obj).toPrintable());
               }
           }
           try {
               Thread.sleep(100000);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
       }, "thead2").start();
       LockSupport.park();

   }

测试结果:
thread1: 创建50个偏向线程thread1的偏向锁 1-50 偏向锁
在这里插入图片描述
thread2:1-18 偏向锁撤销,升级为轻量级锁 (thread1释放锁之后为偏向锁状态)
在这里插入图片描述
19-40 偏向锁撤销达到阈值(20),执行了批量重偏向 (测试结果在第19就开始批量重偏向了)
在这里插入图片描述
在这里插入图片描述
测试批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会认为不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。 注意:时间-XX:BiasedLockingDecayTime=25000ms范围内没有达到40次,撤销次数清为0, 重新计时

public static void main(String[] args) throws  InterruptedException {
       //延时产生可偏向对象
       Thread.sleep(5000);
       // 创建一个list,来存放锁对象
       List<Object> list = new ArrayList<>();
       // 线程1
       new Thread(() -> {
           for (int i = 0; i < 50; i++) {
               // 新建锁对象
               Object lock = new Object();
               synchronized (lock) {
                   list.add(lock);
               }
           }
           try {
               //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
               Thread.sleep(100000);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
       }, "thead1").start();
       //睡眠3s钟保证线程thead1创建对象完成
       Thread.sleep(3000);
       log.debug("打印thead1,list中第20个对象的对象头:");
       log.debug((ClassLayout.parseInstance(list.get(19)).toPrintable()));
       // 线程2
       new Thread(() -> {
           for (int i = 0; i < 40; i++) {
               Object obj = list.get(i);
               synchronized (obj) {
                   if(i>=15&&i<=21||i>=38){
                       log.debug("thread2-第" + (i + 1) + "次加锁执行中\t"+
                               ClassLayout.parseInstance(obj).toPrintable());
                   }
               }
               if(i==17||i==19){
                   log.debug("thread2-第" + (i + 1) + "次释放锁\t"+
                           ClassLayout.parseInstance(obj).toPrintable());
               }
           }
           try {
               Thread.sleep(100000);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
       }, "thead2").start();
       Thread.sleep(3000);
       new Thread(() -> {
           for (int i = 0; i < 50; i++) {
               Object lock =list.get(i);
               if(i>=17&&i<=21||i>=35&&i<=41){
                   log.debug("thread3-第" + (i + 1) + "次准备加锁\t"+
                           ClassLayout.parseInstance(lock).toPrintable());
               }
               synchronized (lock){
                   if(i>=17&&i<=21||i>=35&&i<=41){
                       log.debug("thread3-第" + (i + 1) + "次加锁执行中\t"+
                               ClassLayout.parseInstance(lock).toPrintable());
                   }
               }
           }
       },"thread3").start();
       Thread.sleep(3000);
       log.debug("查看新创建的对象");
       log.debug((ClassLayout.parseInstance(new Object()).toPrintable()));

       LockSupport.park();

   }

测试结果:
thread3:
线程2的1-18加锁后从偏向锁升级轻量级锁,轻量级锁解锁后为无锁。所以线程3中的1-18 从无锁状态升级为轻量级锁
在这里插入图片描述
线程2的19-40 偏向锁撤销阈值达到20,发生批量重偏向。所以线程2的19-40是偏向锁状态,线程2的19-40解锁后还是偏向锁
所以线程3的19-40从偏向锁升级为轻量级锁
在这里插入图片描述
线程2没有用到41-50的锁对象,因此41-50的锁状态是thread1释放锁之后为偏向锁状态。当达到偏向锁撤销的阈值40,批量撤销偏向锁,升级为轻量级锁
在这里插入图片描述
最后log.debug("查看新创建的对象"); log.debug((ClassLayout.parseInstance(new Object()).toPrintable()));
无锁状态在这里插入图片描述
在一定时间内当偏向锁撤销次数到达40次时,后续创建的对象都会认为不使用于偏向锁。
当然这些阈值是可以设置的。配置jvm参数-XX:+PrintFlagsFinal会打印启动时所有jvm参数
在这里插入图片描述
在这里插入图片描述
intx BiasedLockingBulkRebiasThreshold = 20 //默认偏向锁批量重偏向阈值
intx BiasedLockingBulkRevokeThreshold = 40 //默认偏向锁批量撤销阈值

总结

  1. 批量重偏向和批量撤销是针对类的优化和对象无关。
  2. 偏向锁重偏向一次之后不可再次重偏向。
  3. 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调 用,存在用户态和内核态切换,这才是重量级锁最大的开销)

锁粗化

假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中 的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个 操作序列的外部。

//public synchronized StringBuffer append(Object obj) 
StringBuffer buffer = new StringBuffer(); 
        /** 
         * 锁粗化 
         */ 
        public void append(){
            buffer.append("aaa").append(" bbb").append(" ccc");
        }

上述代码每次调用 buffer.append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同 一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次 append方法时进行加锁,最后一次append方法结束后进行解锁。

锁消除

锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

/**
     * 锁消除
     * -XX:+EliminateLocks 开启锁消除(jdk8默认开启)
     * -XX:-EliminateLocks 关闭锁消除
     * @param str1
     * @param str2
     */
    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

    public static void main(String[] args) throws InterruptedException {
        LockEliminationTest demo = new LockEliminationTest();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            demo.append("aaa", "bbb");
        }
        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - start) + " ms");
    }

StringBuffer的append是个同步方法,但是append方法中的 StringBuffer 属于一个局部变 量,不可能从该方法中逃逸出去,因此其实这过程是线程安全的,可以将锁消除。

synchronized锁状态变更总结

先回答上篇遗留的两个问题:

  • 轻量级锁是否可以降级为偏向锁?
    轻量级锁是不会降级为偏向锁的,轻量级锁释放锁后是无锁状态
  • 重量级锁释放之后变为无锁,此时有新的线程来调用同步块,会获取什么锁?
    无锁可能会升级为轻量级锁和重量级锁,这根据竞争的程度,但是无锁状态不会变成偏向锁状态。

锁状态变更总结
创建对象会判断是否满足偏向条件(当偏向锁撤销阈值到达40,偏向锁禁用、偏向锁延迟时间内等皆为不满足)
不满足:为无锁状态
满足:偏向锁状态(此时为匿名偏向)
满足的情况下:

  • 当synchronized标记该对象,那么该对象从匿名偏向通过CAS方式将当前线程id设置到锁对象的Mark Word中 此时该对象偏向了某个线程
  • 持有偏向锁的线程进入同步代码后不会进行加锁解锁的操作,因此偏向锁解锁任然是偏向锁
  • 当偏向锁为匿名偏向时,调用hashcode方法,通过偏向锁撤销变成无锁状态。因为偏向锁状态下对象没有地方保存hashcode,无锁状态下hashcode保存在mark work中
  • 当偏向锁已经偏向了某个线程,调用hashcode方法或者调用wait方法时,通过偏向锁撤销升级为重量级锁,hashcode会保存到monitor对象中
  • 当发生轻微竞争时,偏向锁通过偏向锁撤销升级为轻量级锁
  • 当多个线程同时竞争该对象时,偏向锁通过偏向锁撤销升级为重量级锁
  • 当偏向锁撤销阈值到达20时,触发批量重偏向,当阈值到达40时触发批量撤销,后续该类的实例对象的创建都不会是偏向锁状态。

不满足的情况下:

  • 对象为无锁状态。synchronized标记后任然是无锁状态
  • 当发生轻微竞争,锁对象修改MarkWord,并拷贝MarkWord到线程栈上的线程记录中。轻量级锁解锁后是无锁状态
  • 当发生激烈竞争时,无锁状态膨胀修改MarkWord创建monitor对象,重量级锁解锁后是无锁状态
  • 如果锁状态从轻量级锁升级到重量级锁时,轻量级锁膨胀,修改MarkWord创建monitor对象。轻量级锁在膨胀过程中只会进行一次CAS操作,如果获取锁成功就不会膨胀为重量级锁,如果获取失败就会膨胀为重量级锁,这个过程中没有自旋。

知识补充:逃逸分析(Escape Analysis)

逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流 分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而 决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。
方法逃逸(对象逃出当前方法) 当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。 线程逃逸((对象逃出当前线程) 这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量

使用逃逸分析,编译器可以对代码做如下优化:

  1. 同步省略或锁消除(Synchronization Elimination)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  2. 将堆分配转化为栈分配(Stack Allocation)。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  3. 分离对象或标量替换(Scalar Replacement)。有的对象可能不需要作为一个连续的内存结构存 在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值