Java 线程
并发不一定依赖于多线程,像 PHP 多进程也是并发。但是多线程一定是为了并发,甚至是并行操作。目前线程是 Java 处理器资源调度的最小单位,后续可能会引入纤程(Fiber)。
Java 线程的实现
现在基本上所有操作系统内核都支持多线程,由操作系统内核提供支持的线程称为内核级线程(Kernel-level Thread)。高级语言线程的实现主要有三种方式:
- 使用内核级线程,程序定义的内存实体直接使用内核线程,程序线程与内核线程是 1 : 1 的关系。
- 使用用户级线程,程序自己实现一套多线程调度机制,程序线程与内核级线程是 N : 1 的关系。
- 混合实现多路复用:程序会使用多个内核线程,同时程序自己定义了一套线程调度机制,程序线程与内核线程是 N : M 的关系。
HotSpot 多线程的实现使用的是直接使用内核线程的方式,每一个 Java 线程都会直接映射到内核的原生线程上,虚拟机不会去干涉内核的线程调度,最多只会给内核提供调度建议,至于线程什么时候调度阻塞、唤醒、分配运行时间和处理器调度都由内核决定。
Java 线程状态
Java 定义了六种线程状态,一个线程只能处于一种状态:
- 新建:线程创建后的状态。
- 运行:包括操作系统线程状态的运行与就绪,处于此状态的线程有可能正在执行,也有可能在等待运行。
- 无限期等待:处于这种状态的线程不会被处理器唤醒,只能等待其他线程显式唤醒。
- 限期等待:处于这种状态的线程在等待一定时间后可以接受操作系统的唤醒。
- 阻塞:线程被阻塞了,阻塞状态的线程在等待获取一个排他锁,只有没有其他线程占有这个锁,线程才能获得排他锁。
- 结束:线程终止执行。
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
。
同时线程必须遵守以下规则:
- 不允许单独执行
read
和load
、store
和write
中的其中一个。 - 变量发生更改必须写回主内存,即
assign
后必须要执行store & write
。 - 变量没有发生更改不得写回主内存,即没有
assign
就不能执行store & write
操作。 - 变量只能在主内存诞生,即。
- 变量一次只能被一个线程
lock
,可以lock
多次,但lock
次数要和unlock
相同。 - 变量被
lock
时会清除所有工作内存中的值,线程需要使用load
或assign
进行初始化。 - 线程必须先
lock
再unlock
,且只能unlock
自己加锁的变量。 - 执行
unlock
之前,必须先将变量写回主内存。
线程的内存操作
由于操作规则的存在,线程对内存的操作可以简化为四种,分别是 lock
、unlock
、read
和 write
。
操作 | 作用 |
---|---|
lock | 负责对主内存中的变量进行加锁 |
unlock | 负责对主内存中的变量进行解锁 |
read | 负责从主内存中读取变量到工作内存,是 read 和 load 的组合 |
write | 负责将变量从工作内存写入到主内存,是 store 和 write 的组合 |
由于规则的存在,线程对主内存的四种操作可以解释为原子操作。但规则虽然要求变量发生更改必须执行 read
和 write
操作,但没有对 read
和 write
的时间做出要求,因此,线程无法保证工作内存中的变量一直和主内存相同,那么就可能存在着种种问题:
- A 线程更新变量但没有同步到主内存,其他线程读取到脏数据。
- A 线程变量更新且同步到主内存,其他线程仍使用旧的本地内存副本。
- A 线程变量更新且同步到主内存,其他线程无视 A 的更新对主内存进行覆盖。
- ...
并发
线程安全
线程安全的对象是我们的程序代码,线程安全意味着调用者无须关心代码在多线程下的调用问题,不需要使用任何措施来保证它在多线程环境下的正确调用。从线程安全的角度出发,Java 中的类可以按照线程安全程度分为五类:
- 不可变:不可变对象一定是线程安全的,由于无法更改对象的状态,对象不存在着线程间状态不一致的问题,因此不需要进行任何安全保障措施,如
String
类就是不可变的。 - 绝对线程安全:对象是可变的,但是对象经过一系列内部维护措施,使得对象不管在任何运行环境,都不需要执行任何额外的同步措施,而能够保证正确运行。绝对线程安全要实现的代价非常大,通常需要舍弃大量的内存和添加大量的额外操作进行保证,这样代码的效率会受到较大的影响,因此 Java 中绝大部分线程安全的类都不是绝对线程安全的。
- 相对线程安全:我们通常意义上的线程安全就是相对线程安全,它只保证了对象的单次操作是线程安全的,例如
Vector::size
是安全的,Vector::add
是安全的,但二者连续调用就不能保证线程安全。 - 线程兼容:线程兼容的对象指能够在多线程环境下使用,但是无法提供任何线程安全措施的对象,如
ArrayList
。但如果在调用段采取同步措施,就可以保证线程安全。 - 线程对立:线程对立的对象,无论在调用段采用何种手段,都无法保证线程安全,如
Thread::suspend
和Thread::resume
,无论调用是否进行同步,都存在发生死锁的风险。TODO: 探究为什么一定会有死锁的风险
原子性、可见性和有序性
如果要保证线程间操作的正确性,需要满足三个特性:线程操作的原子性和可见性,线程间操作与线程内操作的有序性。
原子性
原子性(Atomicity)指线程安全需要保证线程的单个或者连续的操作是一次性执行完的。如果操作无法原子执行,那么操作的正确性就没有任何意义了。JVM 保证线程八种内存操作是原子性的,对于更大范围操作的原子性保证,则交给 lock
和 unlock
指令配合使用。尽管 JVM 没有直接开放这两条指令给用户使用,但也提供了更高层次的 moniorenter
和 moniorexit
指令给用户隐式操作,这两个指令在 Java 中通过 synchronized
关键字或者锁对象调用。所以,Java 默认并不会保证连续操作的原子性,需要我们显式加锁解决。
可见性
可见性(Visibility)指线程安全需要保证当一个线程修改了变量的值时,其他线程可以立刻得知这个修改。从上文得知,JVM 的内存操作规则并没有规定 read
和 write
操作必须马上执行,因此默认情况下,Java 变量无法保证可见性。要使操作保证可见性,有三种办法:
- 进行
lock
与unlock
操作。在 Java 中可以通过synchronized
关键字或者主动加锁实现。 - 使用
volatile
关键字,volatile
修饰的变量可以保证所有对变量的操作都是可见的。 - 使用
final
关键字,原理是“不可变”,在不可变变量初始化之后,它的值对所有线程都是可见的、不变的。
有序性
有序性(Ordering)指线程安全需要保证线程间和线程内操作的有序性。默认情况下 JVM 不会保证线程间的每一个操作是顺序执行的,同时由于“指令重排序”优化的存在,JVM 也无法保证线程内的操作是顺序执行的。要协调线程间的顺序,可以通过 lock
和 unlock
指令处理。要禁止指令重排序操作,可以使用 volatile
关键字处理。
线程安全的实现方法
在 Java 中,对于一切线程安全问题,解决的方式就是两个字“同步”。同步首先保证了线程间执行的有序性,由于 lock
的规则要求,同步保证了线程操作的可见性,而原子性就由 lock
与 unlock
搭配实现。
同步有两种方式,一种是互斥同步,由于两个线程是互斥的,同一时间仅能运行一个,因此就是阻塞同步,另一种自然就是非阻塞同步(Non-Blocking Synchronization)。阻塞同步与非阻塞同步就是悲观锁与乐观锁的根本区别。
悲观锁
在并发环境下,悲观锁认为,如果线程获取锁失败,那么短时间内大概率无法成功获取锁,因此线程进入阻塞。线程进入阻塞态需要内核操作,重新唤醒也需要内核操作,内核操作和线程切换是比较重量级的操作,因此悲观锁的使用成本比较高。
乐观锁
在并发环境下,乐观锁认为,如果线程获取锁失败,那么短时间内大概率可以获取到锁,因此线程选择不阻塞,进入自旋等待其他线程释放锁。此时线程在进行空转,所在 CPU 会被占满,虽然线程可以以极快的速度重新获取锁,但是自旋期间其他线程无法执行,会浪费 CPU 资源。伪代码实现为:
while(获取锁失败) {}
乐观锁不需要借助 lock
和 unlock
关键字,因此没有陷入内核的担忧,但是它需要保证获取锁的操作是原子的,获取锁的步骤分为两步:
- 判断锁是否空闲。
- 将锁标识为已使用。
我们不可能使用 lock
和 unlock
来保证这两步操作的原子性,但是又必须保证这两步的原子性才能使用乐观锁,因此乐观锁的使用需要硬件的保障。如今硬件已支持通过一条指令完成多步操作,如:
- 测试并设置(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::compareAndSwapInt
和 Unsafe::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
无法保证变量的线程安全。
-
保证修改可见性
volatile
保证变量在被修改之后,会马上将变量值从工作内存同步到主内存中,并马上清空其他线程工作内存该变量的副本。所以其他线程在使用变量之前必须重新在主内存中读取变量,由此保证了线程操作的可见性。有些书或者文章在讲线程模型时会一直提及工作内存,但是一讲到
volatile
时就改口一直说 CPU 缓存。实际上,上文也提及了,工作内存就是 CPU 高速缓存和寄存器的抽象,工作内存就是高速缓存和寄存器。 -
禁止指令重排序
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
是“同步”的意思。它的作用是为当前执行线程获取一把锁,用于在多线程条件下使线程针对同一个对象的操作串行化,相当于给目标对象加锁。
-
加锁目标:
对于静态方法和静态代码块,锁住的是整个类;对于实例方法和普通代码块,锁住的是实例对象。 -
字节码实现:
synchronized
是依靠monitorenter
和monitorexit
指令实现的,用于代表获取锁和释放锁。当用来修饰方法时,会在字节码中将ACC_SYNCHRONIZED
标志位设置为 1 。线程执行引擎在执行方法前会先去获取对象的锁。 -
加锁操作:
在 JDK1.6 以后synchronized
进行了重大优化,将锁分为无锁、偏向锁、轻量级锁和重量级锁四种状态,它的状态随着锁竞争的情况逐步升级,这个过程称为锁膨胀。锁的膨胀是不可逆的。在 JVM 中通过对象的对象头来记录锁的状态。 -
锁实体:
对象的锁的实体是 monitor,是ObjectMonitor
类型,在 JDK1.6 之后只有当锁膨胀为重量级锁之后才会出现。
对象头
对象的内存布局
对象在 Java 堆中可以拆解为三个部分:对象头、实例数据和填充数据。
- 对象头: 用于保存关于对象重要的基本信息。
- 实例数据:对象的内存实体。
- 填充数据:用于填充对齐,因为 JVM 规定所有对象占用内存的大小必须是 8bit 的倍数。
对象头(Object Header)的组成分为三个部分:
- MarkWord,用于保存对象信息,如哈希码、GC 分代年龄和锁状态等,长度与操作系统字长相同。
- 类型指针,指向方法区中对象类型数据的指针。
- 数组长度,如果对象是数组类型,那么会多出这 32 位存储数组的长度。因此 JVM 中数组的最大长度就是 2 的 32 次方。
Mark Word
MarkWord 是一个非固定的动态数据结构,可以在极小的空间占用中表示更多的信息。根据对象的状态不同,Mark Word 的表示也不同,对象的状态包括:正常状态(无锁)、可偏向、轻量级锁定状态、重量级锁定状态和 GC 标记状态。
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,主要流程为:
- 调用
ObjectMonitor::tryLock
尝试通过 CAS 操作将 monitor 的owner
指向自己,如果操作成功则成果获取锁。 - 如果操作失败,则进行
ObjectMonitor::trySpin
尝试通过自旋获取锁,向操作系统发送急需资源的信号。 - 如果依旧失败,将当前线程包装为
ObjectWaiter
对象,并加入到cxq
队列头部。 - 调用
pthread_cond_wait
阻塞当前线程。 - 当线程被唤醒并且获得锁时,调用
UnlinkAfterAcquire
将线程从cxq
或EntryList
移出。
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 的占用,主要流程为:
- 查看重入次数是否是 0,如果不是
recursions
减一并直接返回,线程继续占有锁执行。 - 如果
EntryList
不为空,将EntryList
头结点唤醒。 - 通过循环将
cxq
变成双向链表并赋予EntryList
,再调用ExitEpilog
将cxq
头结点唤醒。
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
,锁空闲时程序会唤醒 EntryList
或 cxq
队列头部线程,这表示 EntryList
和 cxq
都是就绪线程队列。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
)和锁对象的指针,为了保存对象的状态和方便寻找对象。
然后虚拟机会使用 CAS 操作尝试将对象的 MarkWord 更新为指向 MarkWord 拷贝的指针。如果操作成功,对象标志位就会更新为 00
,标志对象处于轻量级锁状态。如果操作失败,线程会检查对象的 MarkWord 是否指向当前线程的栈帧,如果是说明线程已经获取过锁了,直接进入同步代码块。如果不是,线程就进入自旋。自旋的次数默认是 10 次,可以通过 -XX:PreBlockSpin
设置。
以下两种情况,线程会发现锁竞争比较激烈,就会膨胀为重量级锁:
- 自旋失败。即自旋结束之后,发现锁还被占用着。
- 线程数大于二。即自旋过程中,又有一个线程要获取锁。
锁记录
根据轻量级锁膨胀的原因,我们可以解释锁记录存在的意义。