【Java】JVM - 线程内存模型与并发

433 阅读23分钟

Java 线程

并发不一定依赖于多线程,像 PHP 多进程也是并发。但是多线程一定是为了并发,甚至是并行操作。目前线程是 Java 处理器资源调度的最小单位,后续可能会引入纤程(Fiber)。

Java 线程的实现

现在基本上所有操作系统内核都支持多线程,由操作系统内核提供支持的线程称为内核级线程(Kernel-level Thread)。高级语言线程的实现主要有三种方式:

  • 使用内核级线程,程序定义的内存实体直接使用内核线程,程序线程与内核线程是 1 : 1 的关系。
  • 使用用户级线程,程序自己实现一套多线程调度机制,程序线程与内核级线程是 N : 1 的关系。
  • 混合实现多路复用:程序会使用多个内核线程,同时程序自己定义了一套线程调度机制,程序线程与内核线程是 N : M 的关系。

HotSpot 多线程的实现使用的是直接使用内核线程的方式,每一个 Java 线程都会直接映射到内核的原生线程上,虚拟机不会去干涉内核的线程调度,最多只会给内核提供调度建议,至于线程什么时候调度阻塞、唤醒、分配运行时间和处理器调度都由内核决定。

Java 线程状态

Java 定义了六种线程状态,一个线程只能处于一种状态:

  • 新建:线程创建后的状态。
  • 运行:包括操作系统线程状态的运行与就绪,处于此状态的线程有可能正在执行,也有可能在等待运行。
  • 无限期等待:处于这种状态的线程不会被处理器唤醒,只能等待其他线程显式唤醒。
  • 限期等待:处于这种状态的线程在等待一定时间后可以接受操作系统的唤醒。
  • 阻塞:线程被阻塞了,阻塞状态的线程在等待获取一个排他锁,只有没有其他线程占有这个锁,线程才能获得排他锁。
  • 结束:线程终止执行。

image.png

Java 内存模型

Java 并不允许多个线程直接操作主内存,而是定义了一套在多线程环境下,程序中各种共享变量的读写规则,这套规则就是 Java 内存模型。这里的变量不包括本地变量表中的变量,因为本地变量表存储在栈帧中,是线程级别的。

Java 内存模型规定:所有的变量都存储在 主内存(Main Memory) 中。每条线程拥有自己的 工作内存(Working Memory)。线程在工作内存中保存了主内存变量的副本,线程对变量的所有操作只能在工作内存完成,不能直接读写主内存中的数据。线程无法访问其他线程工作内存的变量,线程间变量的传递需要通过主内存来完成。

Java 定义的工作内存值得并不是真正的内存区域,它是一个抽象概念,它涵盖了缓存、缓冲区、寄存器以及其他的硬件和编译器优化。简而言之它就是 CPU 寄存器和高速缓存的抽象。
对于对象而言,变量的“副本”是指变量的引用、数据域会被单独复制出来,而不是指整个对象。

内存操作指令与操作规则

Java 内存模型为线程的变量使用定义了 8 种原子的数据操作:

  • lock :独占主内存中的变量,即上锁
  • unlock:释放主内存中的变量,即解锁
  • read :将主内存中的变量读取到工作内存。
  • load :将读取的变量存储到工作内存中,即建立副本
  • use  :使用变量,将变量值从工作内存传递给执行引擎。
  • assign:变量赋值,将变量值从执行引擎写入到工作内存。
  • store :将工作内存中的变量发送到主内存。
  • write :将发送的变量写入到主内存中。

其中 read 必须与 load 配合,store 必须与 write 配合,但它们之间是可以插入其他指令的,比如 read a, read b, load b, load a

image.png

同时线程必须遵守以下规则:

  1. 不允许单独执行 readloadstorewrite 中的其中一个。
  2. 变量发生更改必须写回主内存,即 assign 后必须要执行 store & write
  3. 变量没有发生更改不得写回主内存,即没有 assign 就不能执行 store & write 操作。
  4. 变量只能在主内存诞生,即。
  5. 变量一次只能被一个线程 lock ,可以 lock 多次,但 lock 次数要和 unlock 相同。
  6. 变量被 lock 时会清除所有工作内存中的值,线程需要使用 loadassign 进行初始化。
  7. 线程必须先 lockunlock,且只能 unlock 自己加锁的变量。
  8. 执行 unlock 之前,必须先将变量写回主内存。

线程的内存操作

由于操作规则的存在,线程对内存的操作可以简化为四种,分别是 lockunlockreadwrite

操作作用
lock负责对主内存中的变量进行加锁
unlock负责对主内存中的变量进行解锁
read负责从主内存中读取变量到工作内存,是 readload 的组合
write负责将变量从工作内存写入到主内存,是 storewrite 的组合

由于规则的存在,线程对主内存的四种操作可以解释为原子操作。但规则虽然要求变量发生更改必须执行 readwrite 操作,但没有对 readwrite 的时间做出要求,因此,线程无法保证工作内存中的变量一直和主内存相同,那么就可能存在着种种问题

  • A 线程更新变量但没有同步到主内存,其他线程读取到脏数据。
  • A 线程变量更新且同步到主内存,其他线程仍使用旧的本地内存副本。
  • A 线程变量更新且同步到主内存,其他线程无视 A 的更新对主内存进行覆盖。
  • ...

并发

线程安全

线程安全的对象是我们的程序代码,线程安全意味着调用者无须关心代码在多线程下的调用问题,不需要使用任何措施来保证它在多线程环境下的正确调用。从线程安全的角度出发,Java 中的类可以按照线程安全程度分为五类:

  1. 不可变:不可变对象一定是线程安全的,由于无法更改对象的状态,对象不存在着线程间状态不一致的问题,因此不需要进行任何安全保障措施,如 String 类就是不可变的。
  2. 绝对线程安全:对象是可变的,但是对象经过一系列内部维护措施,使得对象不管在任何运行环境,都不需要执行任何额外的同步措施,而能够保证正确运行。绝对线程安全要实现的代价非常大,通常需要舍弃大量的内存和添加大量的额外操作进行保证,这样代码的效率会受到较大的影响,因此 Java 中绝大部分线程安全的类都不是绝对线程安全的。
  3. 相对线程安全:我们通常意义上的线程安全就是相对线程安全,它只保证了对象的单次操作是线程安全的,例如 Vector::size 是安全的,Vector::add 是安全的,但二者连续调用就不能保证线程安全。
  4. 线程兼容:线程兼容的对象指能够在多线程环境下使用,但是无法提供任何线程安全措施的对象,如 ArrayList。但如果在调用段采取同步措施,就可以保证线程安全。
  5. 线程对立:线程对立的对象,无论在调用段采用何种手段,都无法保证线程安全,如 Thread::suspendThread::resume,无论调用是否进行同步,都存在发生死锁的风险。

    TODO: 探究为什么一定会有死锁的风险

原子性、可见性和有序性

如果要保证线程间操作的正确性,需要满足三个特性:线程操作的原子性和可见性,线程间操作与线程内操作的有序性

原子性

原子性(Atomicity)指线程安全需要保证线程的单个或者连续的操作是一次性执行完的。如果操作无法原子执行,那么操作的正确性就没有任何意义了。JVM 保证线程八种内存操作是原子性的,对于更大范围操作的原子性保证,则交给 lockunlock 指令配合使用。尽管 JVM 没有直接开放这两条指令给用户使用,但也提供了更高层次的 moniorentermoniorexit 指令给用户隐式操作,这两个指令在 Java 中通过 synchronized 关键字或者锁对象调用。所以,Java 默认并不会保证连续操作的原子性,需要我们显式加锁解决。

可见性

可见性(Visibility)指线程安全需要保证当一个线程修改了变量的值时,其他线程可以立刻得知这个修改。从上文得知,JVM 的内存操作规则并没有规定 readwrite 操作必须马上执行,因此默认情况下,Java 变量无法保证可见性。要使操作保证可见性,有三种办法:

  1. 进行 lockunlock 操作。在 Java 中可以通过 synchronized 关键字或者主动加锁实现。
  2. 使用 volatile 关键字,volatile 修饰的变量可以保证所有对变量的操作都是可见的。
  3. 使用 final 关键字,原理是“不可变”,在不可变变量初始化之后,它的值对所有线程都是可见的、不变的。

有序性

有序性(Ordering)指线程安全需要保证线程间和线程内操作的有序性。默认情况下 JVM 不会保证线程间的每一个操作是顺序执行的,同时由于“指令重排序”优化的存在,JVM 也无法保证线程内的操作是顺序执行的。要协调线程间的顺序,可以通过 lockunlock 指令处理。要禁止指令重排序操作,可以使用 volatile 关键字处理。

线程安全的实现方法

在 Java 中,对于一切线程安全问题,解决的方式就是两个字“同步”。同步首先保证了线程间执行的有序性,由于 lock 的规则要求,同步保证了线程操作的可见性,而原子性就由 lockunlock 搭配实现。

同步有两种方式,一种是互斥同步,由于两个线程是互斥的,同一时间仅能运行一个,因此就是阻塞同步,另一种自然就是非阻塞同步(Non-Blocking Synchronization)。阻塞同步与非阻塞同步就是悲观锁与乐观锁的根本区别。

悲观锁

在并发环境下,悲观锁认为,如果线程获取锁失败,那么短时间内大概率无法成功获取锁,因此线程进入阻塞。线程进入阻塞态需要内核操作,重新唤醒也需要内核操作,内核操作和线程切换是比较重量级的操作,因此悲观锁的使用成本比较高。

乐观锁

在并发环境下,乐观锁认为,如果线程获取锁失败,那么短时间内大概率可以获取到锁,因此线程选择不阻塞,进入自旋等待其他线程释放锁。此时线程在进行空转,所在 CPU 会被占满,虽然线程可以以极快的速度重新获取锁,但是自旋期间其他线程无法执行,会浪费 CPU 资源。伪代码实现为:

while(获取锁失败) {}

乐观锁不需要借助 lockunlock 关键字,因此没有陷入内核的担忧,但是它需要保证获取锁的操作是原子的,获取锁的步骤分为两步:

  1. 判断锁是否空闲。
  2. 将锁标识为已使用。

我们不可能使用 lockunlock 来保证这两步操作的原子性,但是又必须保证这两步的原子性才能使用乐观锁,因此乐观锁的使用需要硬件的保障。如今硬件已支持通过一条指令完成多步操作,如:

  • 测试并设置(Test-and-Set);
  • 获取并增加(Fetch-and-Incerement);
  • 交换(Swap);
  • 比较并交换(Compare-and-Swap,CAS);
  • 加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)。

Java 就是通过 CAS 实现乐观锁的。

CAS

CAS 是比较并交换,因此需要三个操作数,分别是变量地址,旧值和新值,伪代码如下:

CAS(addr, old_value, new_value) {
    value = *addr;
    if (value == old_value) {
        *addr = new_value;
    }
    return value;
}

如果地址上原来的值等于旧值,那么就更新。无论更新与否都返回地址上原来的值。通过比较旧值是否等于返回值就可以判断修改是否成功。

因此基于 CAS 的乐观锁实现为

while (0 != CAS(lock, 0, 1)) {} // 进入锁
// Do sth...
lock = 0;                       // 退出锁

Java 在 JDK5 之后开始使用 CAS 操作,由 Unsafe::compareAndSwapIntUnsafe::compareAndSwapLong 等几个方法包装提供,JVM 对这些方法进行了特殊处理,即时编译时会编译出平台相关的 CAS 指令。

ABA 问题

CAS 虽然保证了“测试 + 更新”的原子性,但它无法保证测试成功就一定意味着该变量没有变过,这是它的逻辑漏洞。例如:

Thread-0: old_value = *addr;       

// 线程切换

Thread-1: A = *addr;
Thread-1: CAS(addr, A, B)
// Thread-1 do sth...
Thread-1: CAS(addr, B, A)

// 线程切换

Thread-0: CAS(addr, old_value, new_value) // 成功,无法发觉 Thread-1 的存在

由于 ABA 问题对大部分 CAS 的应用场景都没有,所以多数场景并没有处理这个问题。不过 JUC 依旧提供了带有标记的原子引用类 AtomicStampedReference,通过控制变量版本的方式保证 CAS 的正确性。

Volatile

保证可见性与禁止指令重排序

volatile 的意思是“易变的”,表示被它标识的变量是不稳定的。它有两个作用:保证修改操作的可见性和禁止指令重排序,它相当于保证了线程操作的可见性和线程内的有序性,由于它无法保证操作的原子性和线程间操作的有序性,因此单靠 volatile 无法保证变量的线程安全。

  1. 保证修改可见性

    volatile 保证变量在被修改之后,会马上将变量值从工作内存同步到主内存中,并马上清空其他线程工作内存该变量的副本。所以其他线程在使用变量之前必须重新在主内存中读取变量,由此保证了线程操作的可见性。

    有些书或者文章在讲线程模型时会一直提及工作内存,但是一讲到 volatile 时就改口一直说 CPU 缓存。实际上,上文也提及了,工作内存就是 CPU 高速缓存和寄存器的抽象,工作内存就是高速缓存和寄存器。

  2. 禁止指令重排序

    JVM 为了更好的性能会对一些字节码指令进行重排序执行,这在单线程中是没有问题的,但多线程中就会存在隐患。volatile 关键字可以显式地告知 JVM 不对变量的相关代码进行重排序。常见的代码重排序例子就是单例模式的“双重检查加锁模式”:

    private static volatile Singleton singleton;
    
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null)
                    singleton = new Singleton();
            }
        }
        return singleton;
    }
    

    singleton = new Singleton() 这一语句会分为三步执行:分配内存、执行构造方法和变量赋值。由于分配内存之后就可以直接给变量赋值,构造方法可以在赋值后执行,因此这三步可能被优化为 132 来执行。多线程条件下,如果执行了 13 就发生了线程切换,由于其他线程判断 singlton != null,直接使用对象,此时就会发生错误。

实现细节

对于 volatile 修饰的变量,赋值后会多执行一个 lock addl $0x0,(%esp) 操作,这个操作相当于一个内存屏障。JVM 指令重排序时不能把后面的指令重排序到内存屏障之前的位置,所以像上面的双重检查加锁模式,第三步就不能比第二步先执行。

这一语句可以看出是进行了一步对 esp 寄存器加 0 的空操作,之所以选择加 0 实现空操作是因为 lock 指令不能接 nop 指令。lock 指令的作用是将当前处理器的缓存写入内存,同时引起别的处理器的缓存无效化(Invalidate),也就是清空其他线程工作内存中的值,这样其他线程在使用到该变量时必须去主内存重新获取。

无效化其他处理器的缓存就是对 CPU 缓存进行一致性处理,实现缓存一致性是通过硬件实现的,表现到汇编层面就是 lock 指令。可以通过对总线加锁或者缓存一致性协议(MESI)解决。

MESI 协议的细节是:如果 CPU 操作的变量是共享变量,那么它在修改变量时会发出信号通知其他 CPU 将该变量的缓存行设置为无效状态。当其他 CPU 使用这个变量时,首先会去嗅探该变量缓存行是否有效。如果无效就会去内存读取这个变量。

伪共享问题

由于 lock 会使其他线程整个缓存行都无效化,那么就会带来一个伪共享的问题:

线程本地缓存通常以缓存行为单位,通常是 64B,当并发修改使得 volatile 变量在线程本地缓存失效时,是整个缓存行失效的。除了 volatile 变量之外的其他变量也会失效,这带来的性能问题我们称为伪共享。

传统的解决方式是在定义该变量后,使用多个无意义的变量堆缓存行进行填充,这样就不会影响有效变量的使用。但这种方式有时候会被 JVM 优化掉而失效。在 JDK1.8 之后官方提供 @Contended 注解,专门用于告诉 JVM 自动填充缓存行,例如 Thread 类:

@sun.misc.Contended("tlr")
long threadLocalRandomSeed;

synchronized

synchronized 是“同步”的意思。它的作用是为当前执行线程获取一把锁,用于在多线程条件下使线程针对同一个对象的操作串行化,相当于给目标对象加锁。

  1. 加锁目标
    对于静态方法和静态代码块,锁住的是整个类;对于实例方法和普通代码块,锁住的是实例对象。

  2. 字节码实现
    synchronized 是依靠 monitorentermonitorexit 指令实现的,用于代表获取锁和释放锁。当用来修饰方法时,会在字节码中将 ACC_SYNCHRONIZED 标志位设置为 1 。线程执行引擎在执行方法前会先去获取对象的锁。

  3. 加锁操作
    在 JDK1.6 以后 synchronized 进行了重大优化,将锁分为无锁偏向锁轻量级锁重量级锁四种状态,它的状态随着锁竞争的情况逐步升级,这个过程称为锁膨胀锁的膨胀是不可逆的。在 JVM 中通过对象的对象头来记录锁的状态。

  4. 锁实体
    对象的锁的实体是 monitor,是 ObjectMonitor 类型,在 JDK1.6 之后只有当锁膨胀为重量级锁之后才会出现。

对象头

对象的内存布局

对象在 Java 堆中可以拆解为三个部分:对象头、实例数据和填充数据

  • 对象头: 用于保存关于对象重要的基本信息。
  • 实例数据:对象的内存实体。
  • 填充数据:用于填充对齐,因为 JVM 规定所有对象占用内存的大小必须是 8bit 的倍数。

对象头(Object Header)的组成分为三个部分:

  1. MarkWord,用于保存对象信息,如哈希码、GC 分代年龄和锁状态等,长度与操作系统字长相同。
  2. 类型指针,指向方法区中对象类型数据的指针。
  3. 数组长度,如果对象是数组类型,那么会多出这 32 位存储数组的长度。因此 JVM 中数组的最大长度就是 2 的 32 次方。

Mark Word

MarkWord 是一个非固定的动态数据结构,可以在极小的空间占用中表示更多的信息。根据对象的状态不同,Mark Word 的表示也不同,对象的状态包括:正常状态(无锁)、可偏向、轻量级锁定状态、重量级锁定状态和 GC 标记状态。

Snipaste_2021-08-08_21-35-05.png

Mark Word 最后两位固定为标志位,前 30 位根据对象的状态有不同的表示,各个状态的 Mark Word 表示为:

状态表示
无锁哈希码(25)+ 分代年龄(4)+ 偏向位 0(1)+ 标志位 01(2)
可偏向偏向线程 ID(23)+ 偏向时间戳 Epoch(2)+ 分代年龄(4) + 偏向位 1(1) + 标志位 01(2)
轻量级锁定线程栈中锁记录指针(30)+ 标志位 00(2)
重量级锁定重量级锁对象的指针(30)+ 标志位 10(2)
GC标志空(30)+ 标志位 11(2)

当标志位为 11 时,表示对象在垃圾收集中被标记为待回收对象,此时不需要记录任何对象信息了,因此为空。

锁膨胀

对象头标识了对象的三种锁状态:可偏向、轻量级锁定和重量级锁定。使用了 synchronized 锁定的对象,会逐步从可偏向状态升级到重量级锁定状态,这个过程称为锁膨胀。锁膨胀是不可逆的。锁膨胀的意义在于,在锁竞争的最初,此时存在的竞争可能非常少甚至没有竞争,此时通过偏向锁和轻量级锁的方式,可以尽可能地减少线程获取锁时的消耗。只有在竞争非常激烈时才升级为消耗较大的重量级锁。

锁膨胀的过程是:偏向锁到轻量级锁再到重量级锁。但理解这几种锁,从重量级锁到轻量级锁,再到偏向锁会更清晰。

重量级锁

在 JDK1.6 之前,synchronized 关键字直接使用重量级锁实现,依赖于操作系统底层的 mutex 指令实现,消耗比较大。当对象处于重量级锁状态的时候,标志位时 10,前 30 位变成指向 Monitor 的指针

Monitor

Monitor 是 JVM 中对象的同步工具,是 JVM 内置的 ObjectMonitor 类型,其定义为:objectMonitor.hpp,其主要结构为:

class ObjectMonitor {
  volatile markOop _header;           // 对象的 MarkWord
  void * volatile _owner;             // 当前线程
  volatile jlong _previous_owner_tid; // 上一个线程的 ID
  volatile intptr_t _recursions;      // 重入次数,第一次获取线程成功为 0
  Thread * volatile _succ;            // 下一个被唤醒的线程
  ObjectWaiter * volatile _EntryList; // 阻塞线程队列,线程在进入或者重新进入时被阻塞
  ObjectWaiter * volatile _cxq;       // 阻塞线程队列,线程第一次进入时发现已经发生阻塞
  ObjectWaiter * volatile _WaitSet;   // 等待线程队列
}
  • _header 字段存储了对象无锁状态时 MarkWord,因此对象处于重量级锁状态时,获取哈希值和分代年龄可以通过 monitor 获取。

  • _recursions 字段记录了线程的重入次数,线程第一次获取锁时,值为 0。

申请锁

线程通过调用 ObjectMonitor::enterI 申请获得对象的 monitor,主要流程为:

  1. 调用 ObjectMonitor::tryLock 尝试通过 CAS 操作将 monitor 的 owner 指向自己,如果操作成功则成果获取锁。
  2. 如果操作失败,则进行 ObjectMonitor::trySpin 尝试通过自旋获取锁,向操作系统发送急需资源的信号。
  3. 如果依旧失败,将当前线程包装为 ObjectWaiter 对象,并加入到 cxq 队列头部。
  4. 调用 pthread_cond_wait 阻塞当前线程。
  5. 当线程被唤醒并且获得锁时,调用 UnlinkAfterAcquire 将线程从 cxqEntryList 移出。
graph LR
    enterI(["start enterI"])
    id1{"tryLoc"}
    id2{"trySpin"}
    id3["ObjectWaiter::new"]
    id4[("cxq")]
    id5[["pthread_cond_wait"]]
    id6[["UnlinkAfterAcquire"]]
    id7[("cxq")]
    id8["移除"]
    exit([end])
    awake([唤醒])
    enterI --> id1
    id1 --> |"成功"| exit
    id1 --> |"失败"| id2
    id2 --> |"成功"| exit
    id2 --> |"失败"| id3
    id3 --> id4
    id4 --> id5
    awake --> id6 --> id8
    id7 --> id8
    id8 -->exit

退出锁

线程通过调用 ObjectMonitor::exit 退出对 monitor 的占用,主要流程为:

  1. 查看重入次数是否是 0,如果不是 recursions 减一并直接返回,线程继续占有锁执行。
  2. 如果 EntryList 不为空,将 EntryList 头结点唤醒。
  3. 通过循环将 cxq 变成双向链表并赋予 EntryList,再调用 ExitEpilogcxq 头结点唤醒。
graph LR
enter([exit 执行])
exit([退出])
if_recursions{"recursions > 0"}
if_entrylist{"EntryList == null"}
entrylist_yes["唤醒头节点"]
change_cxq["cxq 转为 EntryList"]
exit_epilog[[ExitEpilog]]
enter --> if_recursions
if_recursions --> |Yes| exit
if_recursions --> |No| if_entrylist
if_entrylist --> |Yes| entrylist_yes
if_entrylist --> |No| change_cxq --> exit_epilog --> entrylist_yes -->exit

等待与就绪

_cxq_WaitSet_EntryList 都用于保存 ObjectWaiter 对象列表,ObjectWaiter 是封装了的线程对象。由上文可得知,线程如果直接获取锁失败,会进入 cxq 队列。cxq 队列会在占有锁线程释放锁时转化为 EntryList,锁空闲时程序会唤醒 EntryListcxq 队列头部线程,这表示 EntryListcxq 都是就绪线程队列。WaitSet 用于存储限期等待或者无限期等待的线程。当线程等待结束,或者被调用 Object::notify 唤醒之后,就会插入到 EntryList 结尾。

graph LR
begin([monitorenter])
exit([end])
i1{请求 Monitor}
i2["加入 CXQ"]
i3[Running]
i6[释放锁并加入 WaitList]
i7{EntryList == null}
i8[WaitSet 头元素出队<br/>放入 CXQ 队尾]
i9[WaitSet 头元素出队<br/>放入 EntryList 队尾]
i5([monitorexit])
i10([monitorexit])
i11{EntryList == null}
i12[唤醒 CXQ 线程]
i13[唤醒 EntryList 线程]
begin --> i1
i1 --> |成功| i3
i1 --> |失败| i2
i3 -- "调用 await" --> i6
i3 -- "调用 notify" --> i7
i7 --> |为空| i9
i7 --> |不为空| i8
i8 & i9 --> i5
i10 --> i11 --> |为空|i12
i11 --> |不为空|i13
i12 & i13 --> exit

轻量级锁

重量级锁的 EntrySet 设计可以有效地处理多条线程对锁的争用,但是对于线程竞争非常少的情况,这样重量级的设计就像杀鸡还要用宰牛刀。因此 Java 引入了基于乐观锁原理的轻量级锁。轻量级锁认为线程之间的竞争很轻,一般两个线程对于锁的操作都会错开,或者只需要稍稍等待一下另一个线程就会释放锁。所以,一个字描述轻量级锁就是“等”,等的实现方式就是自旋

在对象即将进入轻量级锁状态时,线程会在栈帧中建立一个名为“锁记录(Lock Record)”的空间,用于存储对象 MarkWord 的拷贝(_displaced_header)和锁对象的指针,为了保存对象的状态和方便寻找对象。

image.png

然后虚拟机会使用 CAS 操作尝试将对象的 MarkWord 更新为指向 MarkWord 拷贝的指针。如果操作成功,对象标志位就会更新为 00,标志对象处于轻量级锁状态。如果操作失败,线程会检查对象的 MarkWord 是否指向当前线程的栈帧,如果是说明线程已经获取过锁了,直接进入同步代码块。如果不是,线程就进入自旋。自旋的次数默认是 10 次,可以通过 -XX:PreBlockSpin 设置。

以下两种情况,线程会发现锁竞争比较激烈,就会膨胀为重量级锁:

  1. 自旋失败。即自旋结束之后,发现锁还被占用着。
  2. 线程数大于二。即自旋过程中,又有一个线程要获取锁。

锁记录

根据轻量级锁膨胀的原因,我们可以解释锁记录存在的意义。