深入理解Synchronized(三)

在前面两篇关于Synchronized的文章中,已经简单介绍了Synchronized中管程模型、锁对象如何保存锁状态以及偏向锁和轻量级锁基本的锁状态转换,这篇文章更加深入介绍Synchronized中相关锁的底层原理、锁状态转换以及锁优化等内容

一、轻量级锁底层原理

1.1 加锁逻辑

轻量锁的锁对象与线程锁记录之间的关系如下图所示:

线程每次加锁,都会在线程的栈帧中分配一块锁记录的空间,锁记录里面包含了锁对象的Mark Word(displaced word)和指向锁对象的指针,而锁对象的对象头里面的ptr_to_lock_record是指向栈帧中锁记录的指针。

当线程第一次加锁时,锁记录就去记录完整的Mark Word和锁对象指针,当线程重入加锁时,同样会生成一个锁记录,但该锁记录里面的displaced word就为null,只记录了指向锁对象的指针,而锁对象里面的ptr_to_lock_record记录的是线程栈顶的锁记录

当字节码解释器在执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈上显示或隐式分配一个lock record。其主要作用就是持有displaced word和锁对象的元数据,解释器可以使用lock record来检测非法的锁状态;隐式地充当重入机制的计数器。

轻量级锁加锁逻辑如下图所示:

轻量级锁加锁的过程:

  1. 复制锁对象的Mark Word到所记录的Displaced Word中
  2. 通过CAS将锁对象的Mark Word中的信息设置为指向栈帧中锁记录的指针
  3. 修改锁状态为00
  4. 将栈帧中锁记录的Obj指向锁对象

上面图中后面的逻辑主要介绍了锁膨胀的过程,锁膨胀的结果是生成一个ObjectMonitor对象,并把锁状态更新为重量级锁,而重量级锁的加锁逻辑在后面会介绍

通过上面轻量级锁加锁和膨胀的过程,我们可以清晰地看到,从轻量级锁膨胀到重量级锁,并没有自旋,只要出现不同线程竞争锁的情况,就会膨胀成重量级锁。

注:在无锁的状态下,如果通过CAS尝试获取失败时,会直接膨胀成重量级锁

1.2 解锁逻辑

轻量级锁的解锁逻辑比较简单,如下图所示:

二、重量级锁原理

2.1 加锁逻辑

从下面的加锁逻辑中可以看出,为了避免调用系统同步,重量级锁在获取锁的过程中会多次去尝试获取锁和适应性自旋通过CAS来获取锁,只有当实在获取不到锁的时候,才会把线程封装成一个node节点,然后插入的_cxq队列(入口等待队列)的头部

把线程的node节点插入到入口等待队列的头部后,就循环来尝试获取锁,或者当线程被唤醒时,第一时间去尝试获取锁,并通过适应性自旋来尝试获取锁,如果成功,就跳出循环,将node节点从入口等待队列移除;否则就继续调用park方法进行阻塞

2.2 解锁逻辑

重量级锁的解锁逻辑也比较简单,将ObjectMonitor中的_owner属性置为null,然后调用一个写屏障让修改生效就表示释放锁了,但需要注意的是此时锁对象的状态还是重量级锁,锁状态从重量级锁变成无锁状态,需要等到ObjectMonitor对象被GC回收才行,所以即便没有线程竞争锁,锁状态也可能依然是重量级锁状态。

在解锁逻辑中,最重要的是唤醒策略,根据QMode的不同会有不同的唤醒策略:

  1. QMode=2且_cxq不为空,取_cxq队首的ObjectWaiter对象,然后调用ExitEpilog()方法,该方法会唤醒ObjectWaiter对象中的线程,然后立即返回
  2. QMode=3且_cxq不为空,把_cxq队首元素放入到_entryList队列的尾部(这两个都是MESA模型的入口等待队列),直接从_entryList中唤醒线程
  3. QMode=4且_cxq不为空,把_cxq队首元素放到_entryList队列的头部,直接从_entryList中唤醒线程
  4. QMode=1且_cxq不为空,_entryList为空,将_cxq的元素全部转移到_entryList中,并反转顺序
  5. QMode=0(默认),如果_entryList为空,则把_cxq的元素按原有顺序插入到_entryList中,并唤醒第一个线程,如果_entryList不为空,直接从_entryList中唤醒线程

三、锁对象状态变换

下图展示了无锁、偏向锁、轻量级锁、重量级锁之间的状态变换:

如果开启了偏向锁模式,创建的锁对象将处于未锁定状态的偏向锁(ThreadID为0),它是在没有线程竞争的情况下存在的,线程持有锁对象前后,锁对象都处于偏向锁状态。

偏向锁,它只是偏向某个线程,后续进入同步块的逻辑,没有加锁和解锁的开销

偏向锁的重偏向,在后面锁优化的部分会介绍为什么会出现重偏向的问题。

在轻量级锁加锁的过程中,如果锁对象处于无锁状态,就会通过CAS来获取锁,如果失败,就会直接膨胀成重量级锁,也就是上图中后边红线表示的信息,直接从无锁,膨胀成重量级锁。

四、Synchronized锁优化

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

上面已经说过,偏向锁加锁和解锁前后,它都处于偏向锁模式,只是锁对象中的ThreadID发生了变化,而当一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但当其他线程尝试获取锁时,就得等到safe point时,将偏向锁撤销为无锁或轻量级锁状态,会消耗一定的性能。所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降,所以就有了偏向锁批量重偏向和批量撤销的机制

基本原理

以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的epoch值继续增长,当期达到批量撤销的阈值(默认40)后,JVM就认为该Class的使用场景存在多线程竞争,会将该Class标记为不可偏向,之后对于该Class的实例对象的锁,直接走轻量级锁的逻辑。

设置JVM参数-XX:+PrintFlagsFinal,在项目启动时即可输出JVM的默认参数值

intx BiasedLockingBulkRebiasThreshold   = 20   //默认偏向锁批量重偏向阈值

可以通过-XX:BiasedLockingBulkRebiasThreshold 和 -XX:BiasedLockingBulkRevokeThreshold 来手动设置阈值

应用场景

批量重偏向(bulk rebias):假设有一个类LockObject,现在创建了大量的该类实例对象作为锁对象,在线程A中,用这些锁对象来做同步操作;同时,在线程B中,也用这些锁对象来做同步操作,这个时候,由于线程B会尝试去获取锁,这些锁会撤销到轻量级锁,当撤销达到重偏向的阈值后,JVM就会对这些锁对象进行重偏向,让它们偏向线程B。

可以使用下面的代码来演示批量重偏向,thread1中,对这些锁对象执行同步操作,这些锁对象都会偏向thread1,第一个打印出来的对象内存布局可以看到,此时处于偏向锁状态,而在thread2中,再次用这些对象进行同步操作,可以看到第1至第18个锁对象已经撤销到了轻量级锁状态,而后面从第19个锁对象开始,经过了重偏向之后,又回到了偏向锁状态。

//延时产生可偏向对象
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);
        }
    }
    //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
    Thread.sleep(100000);
}, "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());
        }
    }
    Thread.sleep(100000);
}, "thead2").start();

LockSupport.park();

批量撤销(bulk revoke):在明显多线竞争剧烈的情况下,使用偏向锁是不合适的。此时直接从无锁状态开始向轻量级锁膨胀性能更好,避免了偏向锁撤销到轻量锁时等待safe point的开销。

所以批量撤销之后,新创建的锁对象都处于无锁状态,直接从无锁开始膨胀。

当撤销偏向锁阈值超过 40 次后,jvm 会认为不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

注意:时间-XX:BiasedLockingDecayTime=25000ms范围内没有达到40次,撤销次数清为0,重新计时

在上面演示重偏向的代码thread2后面,加入下面的代码就可以看到批量撤销的效果

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();

log.debug((ClassLayout.parseInstance(new Object()).toPrintable()));

thread2中锁对象经过了重偏向后,第19至第38个锁对象就还是偏向锁状态,但从第39个锁对象开始,经过了批量撤销之后,变成了轻量级锁状态,而后面再创建的锁对象,就是无锁状态的了。

总结:

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

4.2 重量级锁自旋

在前面重量级锁原理部分,介绍了重量级锁加锁的流程,在重量级锁加锁的过程中,会通过自适应性自旋和多次重试获取锁,来避免线程阻塞。线程阻塞和唤醒都需要调用系统内核方法,涉及到用户态到内核态的切换,这是开销很大的操作。

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

4.3 锁粗化和锁消除

锁粗化

假设一系列的操作都会对同一个锁对象反复加锁及解锁,甚至加锁操作是在循环体内部,即使没有出现线程竞争,频繁进行同步和互斥操作也会导致不必要的性能损耗。

如果JVM检测到有一连串的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部

以我们常用的StringBuffer为例,它的append()方法是一个同步方法

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

下面的应用场景在我们日常开发中是比较常见的,如果每次调用append()方法都会进行解锁和加锁的逻辑,对性能影响很大,但JVM检测到这一连串的append操作都是对同一个锁对象进行加锁,就会将其合并为一个范围更大的加锁和解锁逻辑,即在第一次调用append()方法时进行加锁,最后一次调用append()方法结束时进行解锁。

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

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

锁消除的前提一定是不存在共享资源的竞争,即资源是线程私有的。

仍以StringBuffer为例子,在自定义的append()方法中,定义了一个StringBuffer对象,这个对象是零时变量,归方法栈所有,方法调用完就会被销毁,不可能从该方法中逃出去,因此这个过程是线程安全的,所以,可以将锁消除。

我们通过开启和关闭锁消除来比较一下这个耗时

/**
 * 锁消除
 * -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");
}

测试结果如下:

开启锁消除

执行时间:2692 ms

关闭锁消除:

执行时间:6427 ms

4.4 逃逸分析

逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。

方法逃逸

当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

线程逃逸

这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。

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

jdk6才开始引入该技术,jdk7开始默认开启逃逸分析。在Java代码运行时,可以通过JVM参数指定是否开启逃逸分析:

-XX:+DoEscapeAnalysis  //表示开启逃逸分析 (jdk1.8默认开启)
-XX:-DoEscapeAnalysis //表示关闭逃逸分析。
-XX:+EliminateAllocations   //开启标量替换(默认打开)
/**
 * @author  lizhi
 *
 * 进行两种测试
 * 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
 * VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
 *
 * 开启逃逸分析  jdk8默认开启
 * VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
 *
 * 执行main方法后
 * jps 查看进程
 * jmap -histo 进程ID
 *
 */
public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            alloc();
        }

        long end = System.currentTimeMillis();

        log.info("执行时间:" + (end - start) + " ms");
        try {
            Thread.sleep(Integer.MAX_VALUE);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    /**
     * JIT编译时会对代码进行逃逸分析
     * 并不是所有对象存放在堆区,有的一部分存在线程栈空间
     * Ponit没有逃逸
     */
    private static String alloc() {
        Point point = new Point();
        return point.toString();
    }

    /**
     *同步省略(锁消除)  JIT编译阶段优化,JIT经过逃逸分析之后发现无线程安全问题,就会做锁消除
     */
    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

    /**
     * 标量替换
     *
     */
    private static void test2() {
        Point point = new Point(1,2);
        System.out.println("point.x="+point.getX()+"; point.y="+point.getY());

//        int x=1;
//        int y=2;
//        System.out.println("point.x="+x+"; point.y="+y);
    }
}

@Data
@AllArgsConstructor
@NoArgsConstructor
class Point{
    private int x;
    private int y;
}

测试结果:开启逃逸分析,部分对象会在栈上分配

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Java多线程是Java语言中的一项非常重要的特性,它允许程序同时执行多个任务。多线程可以提高程序的并发性和性能,但是也带来了一些挑战,如线程安全、死锁、资源竞争等问题。 Java多线程的实现方式有两种:继承Thread类和实现Runnable接口。继承Thread类需要重写run()方法,该方法中包含线程需要执行的代码。实现Runnable接口需要实现run()方法,但是需要将Runnable对象传递给Thread类的构造方法中。 Java多线程的核心概念包括线程优先级、线程同步、线程通信、线程池等。线程优先级可以通过设置Thread类的setPriority()方法来进行设置,但是并不保证优先级高的线程一定会先执行。线程同步可以通过关键字synchronized来实现,它可以保证同一时刻只有一个线程可以访问共享资源。线程通信可以通过wait()、notify()、notifyAll()等方法来实现,它可以使线程之间进行协作。线程池可以通过Executor框架来实现,它可以实现线程的复用,减少线程创建和销毁的开销。 在使用Java多线程时,需要避免一些常见的问题,如死锁、资源竞争、线程安全等。死锁会导致线程之间相互等待,无法进行下去;资源竞争会导致多个线程同时访问共享资源,可能会导致数据的不一致;线程安全问题会导致多个线程同时访问共享资源,可能会导致数据的不一致或者程序崩溃等问题。 综上所述,Java多线程是一项非常重要的特性,它可以提高程序的并发性和性能,但是在使用时需要注意一些常见的问题。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值