我将独自升级!-- 锁升级

2024-03-01 09:14:14 浏览数 (1)

我将独自升级!-- 锁升级

大家好,我是小高先生。在经过对锁的基础知识和对象头概念的学习之后,相信各位已经对锁机制有了初步的了解。在之前的文章中,我有提到过关于锁升级的概念。今天,我想和大家一起深入探讨一下什么是锁升级。借助于我们之前内容的积累,理解这一部分内容将会是轻而易举的。

  • 锁优化背景
  • 锁升级标志位变化
  • 轻量级锁
  • 重量级锁
  • 消失的hashCode
  • 总结

锁优化背景

先一起再看一下阿里规范中有关锁的内容。

高并发时,同步调用应该考量锁的性能消耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不用类锁。

在多线程编程中,我们经常使用的synchronized关键字提供的是一种重量级锁,它为我们的并发控制提供了最高级别的安全性,但同样因为线程在获取锁失败时会被阻塞,这也可能导致性能的下降。这确实给人一种“全有或全无”的印象,仿佛我们在安全性和性能之间必须做出一个绝对的选择。

然而,现实总是存在灰色地带,不必总是极端。正如在生活中,我的单身好兄弟小董面对小病小痛,不一定非得直奔大医院,家附近的小诊所往往就能解决问题。这样的权衡和折中,也是锁机制设计的智慧所在。因此,为了找到一个平衡点,Java中的锁机制引入了一个过程,即“锁升级”。这个过程允许我们在加锁时根据需要逐步增加锁的“重量”,而不是一开始就直接使用最重的锁。

回想一下对象头中的Mark Word,它存储了对象的各种状态信息,包括锁状态的标志位。正是这些标志位,实现了锁状态的变迁与升级,从而让我们在多线程编程的路上,能够更灵活地舞蹈在安全与效率之间。

在Java 5之前,Java语言中的线程同步主要依赖于synchronized关键字,它对应的锁机制是重量级的。Synchronized实现的互斥是由Java对象头中的monitor来完成的,而monitor又是基于操作系统提供的Mutex Lock(互斥锁)构建的。当一个线程进入synchronized代码块时,它需要获取对象的锁;如果该锁已被其他线程持有,则当前线程将进入阻塞状态,并且从用户态切换到内核态,等待锁的释放。同样地,当锁被释放,等待的线程被唤醒时,也会发生从内核态到用户态的切换。这种在用户态和内核态之间的切换需要消耗系统资源,并影响程序的性能。

这里再插入一些关于monitor的内容,之前《并发编程防御装-锁(基础版)》也涉及到过。monitor是一种同步工具,也可以理解为一种同步机制。在HotSpot虚拟机中,monitor是通过ObjectMonitor实现的,每一个Java对象都可以作为一个锁。monitor本质是依赖于底层操作系统Mutex Lock(互斥锁)实现,操作系统实现线程之间的切换需要从用户态转成内核态,成本高,所以synchronized是一个重量级操作。

确实,如果线程的同步代码块执行得非常快,那么线程的阻塞、唤醒以及上下文切换的时间可能会超过实际执行代码的时间,这在高并发的场景下会导致效率低下。

为了解决这个问题,从Java 6开始,Java引入了偏向锁(Biased Locking)和轻量级锁(Lightweight Lock)。这些优化策略旨在减少线程阻塞和唤醒时的开销,提高同步的效率:

  • 偏向锁:它假设锁总是由同一个线程多次请求,因此避免了与其他线程的竞争。当一个线程第一次获得锁时,虚拟机将在对象头中记录下线程ID,之后该线程进入同步块时,只需要检查这个ID即可,无需再次进行CAS操作或其他同步操作。只有当其他线程尝试获取这个锁时,偏向模式才会撤销,恢复到标准的轻量级锁逻辑。
  • 轻量级锁:它尝试在没有竞争的情况下使用CAS操作(Compare-And-Swap)来获取锁,避免线程阻塞和操作系统级别的状态切换。仅当CAS操作失败时(即发生锁竞争),线程才会退化为传统的重量级锁。

这些优化显著改善了Java线程在面临竞争激烈的同步场景下的性能表现。

锁升级标志位变化

多线程抢锁有四种情况:

  1. 无锁
  2. 只有一个线程来访问
  3. 有两个线程A、B交替访问
  4. 多个线程访问,竞争激烈

Mark Word锁指向也分三种,需要知道:

  1. 偏向锁:Mark Word中前54 bit存的是线程ID
  2. 轻量锁:Mark Word中前62 bit存的是指向线程栈中Lock Record的指针
  3. 重量锁:Mark Word中前62 bit存的是指向堆中monitor对象的指针

《承前启后,Java对象内存布局和对象头》这篇文章讲过如何察看对象头结构。接下来我们看看这四种抢锁场景下,Mark Word标志位是怎么变化的。

无锁

一个对象被创建之后,没被任何线程竞争,就是无锁状态。

代码语言:java复制
public class SyncUpDemo {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

这里看无锁状态下Mark Word值为1。因为new出来的对象并没有调用hashCode(),所以Mark Word中并不会记录对象的哈希值。调用之后hashCode()的结果就会发现Mark Word记录了哈希值。

偏向锁

想象一下,小董每天早晨都会光顾楼下的便利店买早餐。经过一段时间,店员已经熟悉了他的习惯:总是买四个鸡蛋。因此,当小董走进店时,店员不再需要询问,就已经准备好了他的常规订单。

偏向锁的机制与这个便利的故事类似。当一个线程反复访问某段同步代码时,就像小董每天的早餐习惯一样,该线程会获得一种特殊的“认可”,在之后的访问中就不再需要进行复杂的加锁过程这种优化省去了线程在获取和释放锁时的额外开销,也避免了CPU在用户态与内核态之间切换的性能成本,从而提高了程序的运行效率

我们可以通过多线程买票案例体会偏向锁。创建三个线程去买票,当线程A连续多次获取锁,锁就会记住这个线程,并开始对线程A有所”偏爱“。这种”偏爱“体现在,当线程A继续访问同步代码块时,锁会认为它是”常客“,无需再经历复杂的抢锁过程,直接访问问同步代码块。这就跟服务员遇到常客的特殊待遇一样,可以省去很多时间。

代码语言:java复制
class Ticket{
    private int num = 50;

    Object lockObject = new Object();
    public void sale(){
        synchronized (lockObject){
            if(num > 0){
                System.out.println(Thread.currentThread().getName()   "卖出第:t"   (num--)   "t 还剩下:"   num);
            }
        }
    }
}
public class SaleTicketDemo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(() -> {
            for (int i = 0;i < 55;i  ){
                ticket.sale();
            }
        },"a").start();
        new Thread(() -> {
            for (int i = 0;i < 55;i  ){
                ticket.sale();
            }
        },"b").start();
        new Thread(() -> {
            for (int i = 0;i < 55;i  ){
                ticket.sale();
            }
        },"c").start();

    }
}

当一个线程首次获得同步代码块的锁时,该线程的ID会被记录到锁对象的Mark Word中。这一过程不仅标记了线程的”身份“(Mark Word前54 bit记录当前线程ID),而且还启动了偏向锁机制(偏向锁标志位设置为1)。此后,每当这个线程重复访问这个同步代码块,它会执行一次身份核对,将自己的线程ID和Mark Word中记录的线程ID进行比较,然后进入同步代码块,而不需要再次获取锁。如同一位常客进入咖啡馆,店长记住了他的喜好,不用问就知道做什么咖啡。

如果ID匹配,则说明没有其他线程争夺锁,当前线程可轻而易举的进入同步代码块而不需要繁琐的CAS算法更新锁对象的对象头信息(Mark Word的线程ID),也不会出现用户态到内核态的切换。这种机制大大提升了低竞争环境下程序运行的效率。如果ID不匹配,说明有其他线程访问同步代码块,这是就会产生竞争。新线程就会用CAS算法尝试更新Mark Word中的线程ID为自己的ID,争夺主权。如果争夺成功,Mark Word会记录新的线程ID,但偏向锁不会升级。如果争夺失败,那竞争会依旧存在,此时偏向锁升级为轻量级锁,以便更公平的处理多线程之间的竞争关系

这里着重强调一下,持有偏向锁的线程通常不会主动释放锁。只有当其他线程竞争成功获取锁时,原来的偏向锁才会转移。下面例子可以看见,退出同步代码块后偏向锁仍然记录线程ID。

代码语言:java复制
public class SyncUpDemo {
    public static void main(String[] args) {
        Object o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
        System.out.println("-------------------------------------");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

偏向锁在JDK 1.6以上是默认开启的,但是它在应用程序启动4秒后才激活(比如在Java 8中),如果有必要就可以使用JVM参数关闭延迟:-XX:BiasedLockingStartupDelay=0如果你能确定锁通常处于竞争状态,就可以通过参数-XX:-UseBiasedLocking=false关闭偏向锁,程序默认进入轻量级锁。

下面代码中,只有main线程会占据锁,所以是偏向锁,但是Mark Word记录值最后三位是000而不是101,000是轻量级锁,知道为什么吗?就是因为偏向锁启动会有4秒的延迟,所以只是开启了偏向锁,而不是使用了偏向锁,程序使用的是轻量级锁,可以设置参数取消延时。

代码语言:java复制
public class SaleTicketDemo {
    public static void main(String[] args) {
       Object o = new Object();
       synchronized (o){
           System.out.println(ClassLayout.parseInstance(o).toPrintable());
       }
    }
}

这里有个事儿要说一下,大家也要注意。Java 15以后就没有默认开启偏向锁了,逐步废除派你想所,你要想用偏向锁就得先开启偏向锁,我的版本就是Java 17,偏向锁的内容大家就按照Java 8来学习,所以我这里出现轻量级锁不是因为偏向锁延时,而是版本默认就是轻量级锁,如果大家的版本是15以上,就要用参数**-XX: UseBiasedLocking**先开启偏向锁,而且启动的时候没有延时。如果用的是Java 8,那出现000的原因就是因为偏向锁启动有延时,还没启动呢锁对象就已经被占据,锁从无锁升级为轻量级锁。

下面就是开启偏向锁后的运行结果。

换一种方式可以不修改延时参数,也能启动偏向锁,就在程序中手动延时超过偏向锁默认延时,超过4 s偏向锁就会启动。这时候你会发现,偏向锁打开了,但是Mark Word中没有记录线程ID,是因为对象并没有成为锁对象。

代码语言:java复制
public class SyncUpDemo {
    public static void main(String[] args) {
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

    }
}

当有第二个线程尝试获取同一个对象的锁时,偏向模式就不再适用。在这种情况下,JVM会撤销偏向锁,并将锁的状态改为轻量级锁状态。当JVM决定撤销偏向锁时,它会等待全局安全点,在全局安全点上,JVM会检查持有偏向锁的线程是否活跃,如果该线程不活动了(线程完成或者中断),则JVM会将对象头设置为无锁状态,这样其他线程就可以获取锁,重新偏向如果持有偏向锁的线程仍然活着,该偏向锁会撤销并出现锁升级,轻量级锁由原线程持有,继续执行同步代码,而正在竞争的线程会进入自旋等待获取该轻量级锁

全局安全点:在Java虚拟机(JVM)中,为了管理内存和执行垃圾回收等操作,JVM需要在某些时刻暂停所有的用户线程,这个时刻被称为全局安全点。在这个时间点上,没有字节码指令正在执行,JVM可以安全地进行必要的操作而不用担心线程状态的改变会影响操作的正确性。

轻量级锁

轻量级锁主要适用于多线程竞争不激烈的场景。当一个线程执行同步代码块时,有其他线程试图获取同一把锁,JVM会使用CAS操作获取锁,而不是直接进入阻塞状态,竞争线程不断尝试获取锁,直到获取成功。在这种情况下,任意时刻最多只有一个线程来竞争锁。意味着虽然有竞争,但绝不激烈,获取锁的冲突时间极短。

锁升级为轻量级锁的过程在偏向锁中已经说过,不再重复。JVM会在每个线程的栈帧中创建了用于存储锁记录的空间,称为Displaced Mark Word。若一个线程获取锁的时候发现是轻量级锁,会把锁对象的Mark Word复制到线程的Displaced Mark Word里面。然后线程尝试用CAS将锁的Mark Word替换为指向线程栈中Lock Record的指针。当一个线程尝试获取锁的时候,就会在栈中创建一个Lock Record。这个Lock Record包含了Displaced Mark Word,以及一个指向该对象的指针。如果成功,则线程获得锁;如果失败,当前线程通过自旋来获取锁。

自旋是轻量级锁的核心,使线程不断尝试获取锁,不会使线程阻塞。

自旋一定次数后线程还没获取到锁,轻量级锁就会升级为重量级锁,,因为自旋会消耗CPU。在Java 6之前,默认情况下自旋次数超过10次升级,或者自选线程数超过CPU核数一般,了解即可。在Java 6之后,使用自适应自旋锁。自适应自旋锁的特点是根据历史信息来调整自旋次数。如果一个线程在过去自旋成功获取了锁,那JVM会认为这次自旋获取锁的概率很高,从而增加自旋次数。相反,如果自旋很少成功,JVM会减少自选次数,甚至必要时候放弃自旋,直接进入阻塞。举个例子,假如自旋次数设定为10,超过10次自旋还没获得锁,线程就会阻塞。现在,如果线程总是自旋成功,JVM就会认为获取锁的概率很高,就会增加自旋次数,比如增加到20,自旋20次后没获取锁线程才进入阻塞状态。如果线程自旋多次,很少能获取锁,JVM就会较少自旋次数,比如减少到5次。

这里总结一下偏向锁和轻量级锁的区别

  • 偏向锁没有竞争,而争夺轻量级锁失败后,通过自旋再次尝试获取锁
  • 偏向锁退出同步代码块不释放锁,发生竞争才释放偏向锁,而轻量级锁退出同步代码块要释放锁。下面例子说明,线程退出同步代码块后轻量级锁变成无锁状态
代码语言:java复制
public class SyncUpDemo {
    public static void main(String[] args) {
        Object o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
        System.out.println("-------------------------------------");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

重量级锁

当一个线程尝试获取锁时,发现锁是重量级锁,那么线程进入阻塞状态,直到锁被释放。这种阻塞涉及到用户态与内核态之间的切换,带来了额外的开销

具体来说,当一个线程在用户态下运行并尝试获取一个已经被占据的重量级锁,它就会被阻塞并进入内核态等待队列。这个由用户态切换到内核态的过程是由系统调用完成的。当锁被释放后,内核会从等待队列中选出一个线程解除阻塞,回到用户态,使其继续执行,这个过程又涉及了内核态到用户态的切换。

重量级锁的原理就是synchronzied的原理,在《并发编程中的金光咒-锁(基础版)》这篇文章中已经详细讲述了。

消失的hashCode

大家会不会发现,在无锁状态下,Mark Word还记录了对象的哈希值,但是锁升级之后,Mark Word中就没有位置存储哈希值了,那消失的哈希值去哪里了呢?

在无锁状态下,Mark Word中可以存储对象的哈希值。当对象的hashCode()方法第一次被调用,JVM会生成哈希值存储到Mark Word中。

对于偏向锁,在线程获取偏向锁时,会将Thread ID覆盖在哈希值上。如果一个对象已经调用过hashCode(),那这个对象不能再偏向线程,会直接变成轻量级锁。因为如果可以是偏向锁的话,Mark Word中哈希值已经被Thread ID覆盖,那在调用hashCode()产生结果就跟前面不一致了。如果对象处于偏向状态,此时又调用了hashCode(),偏向锁会被撤销,升级为重量级锁。

代码语言:java复制
//在偏向锁没偏向线程的时候对象调用hashCode()
public class SyncUpDemo {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println("本应该是偏向锁");
        //偏向锁开启,但还未偏向
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

        o.hashCode();
        synchronized (o){
            System.out.println("已经升级为轻量级锁");
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}
代码语言:java复制
//已经偏向后,对象调用hashCode()
public class SyncUpDemo {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println("本应该是偏向锁");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){
            System.out.println("偏向锁已经偏向");
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
            o.hashCode();
            System.out.println("升级为重量级锁");
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

升级为轻量级锁后,JVM会在线程栈帧中创建一个Lock Record,里面包含了Mark Word的拷贝,这个拷贝可以包含对象的哈希值,所以轻量级锁可以和哈希值共存,释放锁之后这些信息写回对象头。

升级为重量级锁后,Mark Word保存锁对象指针,代表锁的ObjectMonitor中有字段记录非加锁状态下的Mark Word,里面记录了哈希值,锁释放后将信息写回对象头。

总结

在Java的并发世界中,锁升级机制扮演着关键的角色,旨在提升多线程程序的性能。在JDK 1.6之前,synchronized关键字默认使用的是重量级锁,这种锁在多线程竞争激烈的情境下会导致显著的性能下降。为了缓解这一问题,JDK 1.6引入了一系列锁优化措施,包括无锁、偏向锁、轻量级锁和重量级锁这四种状态的过渡。

无锁状态提供了最佳的程序性能,因为它完全避免了锁的使用,但这仅适用于不存在线程安全风险的场景。当一个线程首次获得锁时,对象进入偏向锁状态。在这种状态下,如果同一个线程再次请求锁,它可以快速地通过检查Mark Word中的线程ID来确认自己是否持有锁,从而无需进行昂贵的加锁操作。

然而,当另一个线程尝试获取已被偏向的锁并失败时,表明存在竞争,此时偏向锁会升级为轻量级锁。在轻量级锁状态下,抢锁失败的线程将采用自旋的方式不断尝试获取锁,而不是立即进入阻塞状态。自旋是一种忙等策略,它允许线程在不占用额外资源的情况下重复检查锁是否可用。

如果自旋尝试达到一定的阈值后仍未能获得锁,轻量级锁最终会转变为重量级锁。在这个转变过程中,未能获取锁的线程将进入阻塞状态,等待1操作系统内核的信号量。这种状态切换涉及到用户态与内核态之间的转换,会带来额外的开销,影响程序的运行效率。

总结来说,Java中的锁升级机制是一个精心设计的平衡艺术,它在保证线程安全的同时,努力减少同步操作对程序性能的影响。通过逐步升级锁状态,Java虚拟机能够根据不同场景选择最适合的同步策略,从而实现高效的并发控制。

0 人点赞