多线程 | synchronized的优化


在了解了 synchronized 重量级锁效率特别低之后,jdk 自然做了一些优化,出现了偏向锁,轻量级锁,重量级锁,锁膨胀、锁消除、锁粗化、自适应自旋锁等优化

1. 锁消除

锁消除是指在编译阶段或运行时,Java 虚拟机(JVM)通过对代码的分析,发现某些同步代码块实际上并不存在数据竞争,从而消除这些同步代码块上的锁。例如,一个方法内部的局部变量,只在该方法内部使用,没有被多个线程共享的可能性,那么对这个局部变量的操作所涉及的同步代码块就可以被安全地消除锁。

为什么要有锁消除

  1. 提高性能:在没有数据竞争的情况下使用锁会带来额外的开销,包括锁的获取和释放操作,这些操作会消耗一定的时间和资源。通过锁消除,可以避免这些不必要的开销,提高程序的执行效率。
  2. 优化代码:使代码更加简洁高效,减少了不必要的同步代码,让程序员无需过度关注一些实际上不存在竞争的情况,专注于业务逻辑的实现。
import java.util.ArrayList;
import java.util.List;

public class LockEliminationExample {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        // 以下代码在局部方法中创建了一个局部对象,理论上不存在线程安全问题
        Object localObject = new Object();
        for (int i = 0; i < 1000; i++) {
            synchronized (localObject) {
                list.add("item" + i);
            }
        }
        System.out.println("List size: " + list.size());
    }
}

在这个例子中,局部变量localObject只在当前方法中使用,并且不会被其他线程访问到。理论上,这里的synchronized块是不必要的,现代的 Java 虚拟机(JVM)可能会进行锁消除优化,即识别出这种不可能存在线程安全问题的锁,并将其消除。

2. 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁禁止,那等待的线程也能尽快拿到锁。大部分情况下,这些都是正确的。但是,如果一些列的联系操作都是同一个对象反复加上和解锁,甚至加锁操作是出现在循环体中的,那么即使没有线程竞争,频繁地进行互斥同步操作也导致不必要的性能损耗

public class LockCoarseningExample {
    public static void main(String[] args) {
        int sum = 0;
        Object lock = new Object();
        for (int i = 0; i < 1000; i++) {
            // 这里如果没有锁粗化,会进行多次加锁和解锁操作
            synchronized (lock) {
                sum++;
            }
        }
        System.out.println("Sum: " + sum);
    }
}

在这个例子中,如果没有锁粗化优化,for循环中的每次迭代都会对lock对象进行加锁和解锁操作,这会带来较大的性能开销。
而现代的 Java 虚拟机通常会进行锁粗化优化,即会检测到连续的对同一对象的加锁和解锁操作,并且如果这些操作在一个很短的时间内发生,那么 JVM 可能会将这些操作合并成一个更大范围的锁,减少加锁和解锁的次数。

要看出是否进行了锁粗化,可以通过查看字节码或者使用一些性能分析工具来观察程序的运行时行为。例如,可以使用 Java 的性能分析工具如JProfiler或YourKit,这些工具可以显示方法的调用次数、锁的获取和释放次数等信息。如果在分析结果中发现锁的获取和释放次数明显少于代码中显式的加锁次数,那么就可能是发生了锁粗化。
另外,查看生成的字节码也可以提供一些线索。可以使用javap -c命令来反编译字节码,观察是否有连续的加锁和解锁指令被优化成了更少的指令序列。但这需要对 Java 字节码有一定的了解才能准确判断。

如果存在锁粗化,可能会看到更少的monitorenter(对应 Java 中的synchronized开始)和monitorexit(对应synchronized结束)指令,因为锁粗化会将多个连续的锁操作合并为一个较大范围的锁。
需要注意的是,判断锁粗化仅仅通过查看字节码是比较复杂的,因为 JVM 的优化可能在不同的情况下有所不同,而且字节码的解读需要一定的专业知识。同时,现代 JVM 的优化通常是动态的,不一定能通过静态分析完全确定是否发生了锁粗化。

Compiled from "LockCoarseningExample.java"
public class com.tech.mingjing.techjavaforge.syn.LockCoarseningExample {
  public com.tech.mingjing.techjavaforge.syn.LockCoarseningExample();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_0
       1: istore_1
       2: new           #2                  // class java/lang/Object
       5: dup
       6: invokespecial #1                  // Method java/lang/Object."<init>":()V
       9: astore_2
      10: iconst_0
      11: istore_3
      12: iload_3
      13: sipush        1000
      16: if_icmpge     47
      19: aload_2
      20: dup
      21: astore        4
      23: monitorenter
      24: iinc          1, 1
      27: aload         4
      29: monitorexit
      30: goto          41
      33: astore        5
      35: aload         4
      37: monitorexit
      38: aload         5
      40: athrow
      41: iinc          3, 1
      44: goto          12
      47: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      50: new           #4                  // class java/lang/StringBuilder
      53: dup
      54: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
      57: ldc           #6                  // String Sum:
      59: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      62: iload_1
      63: invokevirtual #8                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
      66: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      69: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      72: return
    Exception table:
       from    to  target type
          24    30    33   any
          33    38    33   any
}

3. 偏向锁、轻量级锁、重量级锁

synchronized到底锁的是什么呢?首先需要明确的一点是:Java 多线程的锁都是基于对象的,Java 中的每一个对象都可以作为一个锁。
这里再多说几句吧。Class 对象是一种特殊的 Java 对象,代表了程序中的类和接口。Java 中的每个类型(包括类、接口、数组以及基础类型)在 JVM 中都有一个唯一的 Class 对象与之对应。这个 Class 对象被创建的时机是在 JVM 加载类时,由 JVM 自动完成。
Class 对象中包含了与类相关的很多信息,如类的名称、类的父类、类实现的接口、类的构造方法、类的方法、类的字段等等。这些信息通常被称为元数据(metadata)。
可以通过 Class 对象来获取类的元数据,甚至动态地创建类的实例、调用类的方法、访问类的字段等。这就是Java 的反射(Reflection)机制。
所以我们常说的类锁,其实就是 Class 对象的锁。

3.1 锁的四种状态及锁降级

在 JDK 1.6 以前,所有的锁都是”重量级“锁,因为使用的是操作系统的互斥锁,当一个线程持有锁时,其他试图进入synchronized块的线程将被阻塞,直到锁被释放。涉及到了线程上下文切换和用户态与内核态的切换,因此效率较低。
这也是为什么很多开发者会认为 synchronized 性能很差的原因。

那为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁” 的概念,对 synchronized 做了一次重大的升级,升级后的 synchronized 性能可以说上了一个新台阶。
在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,很好理解。
几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World(Java 垃圾回收中的一个重要概念,JVM 篇会细讲)期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。
在这里插入图片描述

3.2 对象的锁放在什么地方

前面我们提到,Java 的锁都是基于对象的。首先我们来看看一个对象的“锁”是存放在什么地方的。
每个 Java 对象都有一个对象头。如果是非数组类型,则用 2 个字宽来存储对象头,如果是数组,则会用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位虚拟机中,一个字宽是 64 位。对象头的内容如下表所示:
在这里插入图片描述
我们主要来看看 Mark Word 的格式:
在这里插入图片描述
可以看到,当对象状态为偏向锁时,Mark Word存储的是偏向的线程 ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁时,Mark Word为指向堆中的 monitor(监视器)对象的指针。
在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器。
监视器包括两个重要部分,一个是锁,一个是等待/通知机制,后者是通过 Object 类中的wait(), notify(), notifyAll()等方法实现的
下面分别介绍这几种锁以及它们之间是如何升级的。

3.3 偏向锁

Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS(后面会细讲,戳链接直达) 操作都不做了,极大地提高了程序的运行性能。
大白话就是对锁设置个变量,如果发现为 true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为 false,代表存在其他线程竞争资源,那么就会走后面的流程。

过程

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。当下次该线程进入这个同步块时,会去检查锁的 Mark Word 里面是不是放的自己的线程 ID。
如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁;
如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID,这个时候要分两种情况:
● 替换成功,表示之前的线程不存在了, Mark Word 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁;
● 替换失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为 0,并设置锁标志位为 00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

3.4 轻量级锁

==多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。 ==
JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。
然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
自旋:不断尝试去获取锁,一般用循环来实现。
自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。
但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和 JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。

3.5 重量级锁

重量级锁依赖于操作系统的互斥锁(mutex,用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段) 实现,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。
前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:

  • Contention List:所有请求锁的线程将被首先放置到该竞争队列
  • Entry List:Contention List中那些有资格成为候选人的线程被移到 Entry List
  • Wait Set:那些调用 wait 方法被阻塞的线程被放置到 Wait Set
  • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck
  • Owner:获得锁的线程称为 Owner
  • !Owner:释放锁的线程

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到 Contention List 队列的队首,然后调用park 方法挂起当前线程。
当线程释放锁时,会从 Contention List 或 EntryList 中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁。
这是因为对于重量级锁,如果线程尝试获取锁失败,它会直接进入阻塞状态,等待操作系统的调度。
如果线程获得锁后调用Object.wait方法,则会将线程加入到 WaitSet 中,当被Object.notify唤醒后,会将线程从 WaitSet 移动到 Contention List 或 EntryList 中去。需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

3.6 锁的膨胀过程

每一个线程在准备获取共享资源时:
第一步,检查 MarkWord 里面是不是放的自己的 ThreadId,如果是表示当前线程是处于 “偏向锁” 。
第二步,如果 MarkWord 不是自己的 ThreadId,锁升级,这时候用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空。
第三步,两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作, 把锁对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。
第四步,第三步中成功执行 CAS 的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

4. 小结

引入偏向锁的目的:在只有单线程执行情况下,尽量减少不必要的轻量级锁执行路径,轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只依赖一次 CAS 原子指令置换 ThreadID,之后只要判断线程 ID 为当前线程即可,偏向锁使用了一种等到竞争出现才释放锁的机制,消除偏向锁的开销还是蛮大的。如果同步资源或代码一直都是多线程访问的,那么消除偏向锁这一步骤对你来说就是多余的,可以通过 - XX:-UseBiasedLocking=false 来关闭

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗 (用户态和核心态转换),但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁

重入: 对于不同级别的锁都有重入策略,偏向锁: 单线程独占,重入只用检查 threadId 等于该线程;轻量级锁:重入将栈帧中 lock record 的 header 设置为 null,重入退出,只用弹出栈帧,直到最后一个重入退出 CAS 写回数据释放锁;重量级锁:重入recursions++,重入退出recursions–,_recursions=0 时释放锁

5. 锁的整理帮助记忆

  1. 从功能层面来说,只有两类锁:共享锁(读锁)和排他锁(写锁)
  2. 加锁会出现阻塞性能问题,为了保证性能和数据安全性,所以需要优化锁。
  3. 锁优化:1 可以从锁粒度优化。2 无锁化编程(乐观锁)
  4. 锁原理:使用内核指令调用 mutex 机制进行锁竞争,即内核态和用户态的切换,占用资源,消耗性能,多线程阻塞等待。
  5. 优化方法:自旋锁在轻量级锁等级下进行的。加了锁的代码,有可能不存在竞争,偏向锁,当线程 1 进入锁的时候,如果当前不存在竞争,那么就把这个锁偏向于线程 1。线程 1 下次再进入时,就不再需要竞争锁,从而减少了竞争。
  6. 轻量级锁:执行 CAS 的获得资源,获取失败,线程会进入自旋,拿到锁则保持轻量级锁,拿不到则升级为重量级锁,阻塞中,等待上个线程完成唤醒自己。
  7. 重量级锁依赖于操作系统的互斥锁
  8. 编译器级别的优化:锁消除(去掉无用的锁)、锁膨胀(锁粒度)
  9. 锁的特性:重入锁(防止死锁设计),分布式锁(分布式架构下的锁粒度问题)

最后放一张摘自网上的一张大图 (保存本地, 方便食用):
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值