Java 可重入锁内存可见性分析

简介: 之前在做 ReentrantLock 相关的试验,试验本身很简单,和本文相关的简化版如下:**(提示:以下代码均可左右滑动)就是通过可重入锁的保护并行对共享变量进行自增。突然想到一个问题:共享变量 count 没有加 volatile 修饰,那么在并发自增的过程当中是如何保持内存立即可见的呢?上面的代码做自增肯定是没问题的,可见 LOCK 不仅仅保证了独占性,必定还有一种机制保证了内存可见性。

之前在做 ReentrantLock 相关的试验,试验本身很简单,和本文相关的简化版如下:**(提示:以下代码均可左右滑动)


image.png


就是通过可重入锁的保护并行对共享变量进行自增。


突然想到一个问题:共享变量 count 没有加 volatile 修饰,那么在并发自增的过程当中是如何保持内存立即可见的呢?上面的代码做自增肯定是没问题的,可见 LOCK 不仅仅保证了独占性,必定还有一种机制保证了内存可见性。


可能很多人和我一样,对 LOCK 的认知是如此 “理所应当”,以至于从没有去思考为什么。就好像每天太阳都会从东方升起而不觉得这有什么好质疑的。现在既然问到这儿了,那就准备一探究竟。


几个概念

Java Memory Model (JMM)

即 Java 内存模型,直接引用 wiki 定义:


"The Java memory model describes how threads in the Java programming language interact through memory. Together with the description of single-threaded execution of code, the memory model provides the semantics of the Java programming language."


JMM 定义了线程和内存之间底层交互的语义和规范,比如多线程对共享变量的写 / 读操作是如何互相影响。


Memory ordering

Memory ordering 跟处理器通过总线向内存发起读 (load)写 (store)的操作顺序有关。对于早期的 Intel386 处理器,保证了内存底层读写顺序和代码保持一致,我们称之为 program ordering,即代码中的内存读写顺序就限定了处理器和内存交互的顺序,所见即所得。而现代的处理器为了提升指令执行速度,在保证程序语义正确的前提下,允许编译器对指令进行重排序。也就是说这种指令重排序对于上层代码是感知不到的,我们称之为 processor ordering.


JMM 允许编译器在指令重排上自由发挥,除非程序员通过 synchronized/volatile/CAS 显式干预这种重排机制,建立起同步机制,保证多线程代码正确运行。


Happens-before

对于 volatile 关键字大家都比较熟悉,该关键字确保了被修饰变量的内存可见性。也就是线程 A 修改了 volatile 变量,那么线程 B 随后的读取一定是最新的值。然而对于如下代码有个很容易被忽视的点:


image.png


当线程 A 执行完 line 2 之后,变量 a 的更新值也一同被更新到内存当中,JMM 能保证随后线程 B 读取到 b 后,一定能够看到 a = 1。之所以有这种机制是因为 JMM 定义了 happens-before 原则,直接贴资料:


Each action in a thread happens-before every action in that thread that comes later in the program order


An unlock on a monitor happens-before every subsequent lock on that same monitor


A write to a volatile field happens-before every subsequent read of that same volatile


A call to Thread.start() on a thread happens-before any actions in the started thread


All actions in a thread happen-before any other thread successfully returns from a Thread.join()on that thread


其中第 3 条就定义了 volatile 相关的 happens-before 原则,类比下面的同步机制,一图胜千言:


image.png


也就是说 volatile 写操作会把之前的共享变量更新一并发布出去,而不只是 volatile 变量本身。Happens-before 原则会保证 volatile 写之后,针对同一个 volatile 变量读,后面的所有共享变量都是可见的。


初步释疑

Happens-before 正是解释文章开头问题的关键,以公平锁为例,我们看看 ReentrantLock 获取锁 & 释放锁的关键代码:

private volatile int state; // 关键 volatile 变量protected final int getState() {    return state;}// 获取锁protected final boolean tryAcquire(int acquires) {    final Thread current = Thread.currentThread();    int c = getState(); // 重要!!!读 volatile 变量    ... // 竞争获取锁逻辑,省略   }// 释放锁protected final boolean tryRelease(int releases) {    boolean free = false;    ... // 根据状态判断是否成功释放,省略    setState(c); // 重要!!!写 volatile 变量    return free;}

简单来说就是对于每一个进入到锁的临界区域的线程,都会做三件事情:


获取锁,读取 volatile 变量;


执行临界区代码,针对本文是对 count 做自增;


写 volatile 变量 (即发布所有写操作),释放锁。


结合上面 happens-before 概念,那么 count 变量自然就对其它线程做到可见了。


事情还没有结束

我们只是利用 volatile 的 happens-before 原则对问题进行了初步的解释,happens-before 本身只是一个 JMM 的约束,然而在底层又是怎么实现的呢?这里又有一个重要的概念:内存屏障(Memory barriers)。


我们可以把内存屏障理解为 JMM 赖以建立的底层机制,wiki 定义:


"A memory barrier, also known as a membar, memory fence or fence instruction, is a type of barrier instruction that causes a central processing unit (CPU) or compiler to enforce an ordering constraint on memoryoperations issued before and after the barrier instruction. This typically means that operations issued prior to the barrier are guaranteed to be performed before operations issued after the barrier."


简而言之就是内存屏障限制死了屏障前后的内存操作顺序,抽象出来有四种内存屏障(因为内存 load/store 最多也就四种组合嘛),具体实现视不同的处理器而不同,我们这里看最常见的 x86 架构的处理器:image.png

volatile 的 happens-before 原则其实就是依赖的 StoreLoad 内存屏障,重点关注 LOCK 指令实现,这和 volatile 的底层实现息息相关,查看下面代码片段对应的汇编代码:


image.png


利用 hsdis 查看对应汇编片段(只包含关键部分):image.png

可以看到在 set() 方法对 a,b,c 赋值后,多出了一行 "lock addl $0x0,(%rsp)",这行代码只是对 stack pointer 加 0,无含义。但通过上文我们得知,x86 架构处理器的 LOCK prefix 指令相当于 StoreLoad 内存屏障。LOCK prefix 的指令会触发处理器做特殊的操作,查看 Intel 64 and IA-32 开发手册的相关资料:


"Synchronization mechanisms in multiple-processor systems may depend upon a strong memory-ordering model. Here, a program can use a locking instruction such as the XCHG instruction or the LOCK prefix to ensure that a read-modify-write operation on memory is carried out atomically. Locking operations typically operate like I/O operations in that they wait for all previous instructions to complete and for all buffered writes to drain to memory."


LOCK prefix 会触发 CPU 缓存回写到内存,而后通过 CPU 缓存一致性机制(这又是个很大的话题),使得其它处理器核心能够看到最新的共享变量,实现了共享变量对于所有 CPU 的可见性。


总结

针对本文开头提出的内存可见性问题,有着一系列的技术依赖关系才得以实现:count++ 可见性 → volatile 的 happens-before 原则 → volatile 底层 LOCK prefix 实现 → CPU 缓存一致性机制。


补充一下,针对 ReentrantLock 非公平锁的实现,相比公平锁只是在争夺锁的开始多了一步 CAS 操作,而 CAS 在 x86 多处理器架构中同样对应着 LOCK prefix 指令,因此在内存屏障上有着和 volatile 一样的效果。


相关文章
|
7月前
|
安全 Java 应用服务中间件
Spring Boot + Java 21:内存减少 60%,启动速度提高 30% — 零代码
通过调整三个JVM和Spring Boot配置开关,无需重写代码即可显著优化Java应用性能:内存减少60%,启动速度提升30%。适用于所有在JVM上运行API的生产团队,低成本实现高效能。
828 3
|
8月前
|
安全 Java 编译器
new出来的对象,不一定在堆上?聊聊Java虚拟机的优化技术:逃逸分析
逃逸分析是一种静态程序分析技术,用于判断对象的可见性与生命周期。它帮助即时编译器优化内存使用、降低同步开销。根据对象是否逃逸出方法或线程,分析结果分为未逃逸、方法逃逸和线程逃逸三种。基于分析结果,编译器可进行同步锁消除、标量替换和栈上分配等优化,从而提升程序性能。尽管逃逸分析计算复杂度较高,但其在热点代码中的应用为Java虚拟机带来了显著的优化效果。
248 4
|
6月前
|
Java 大数据 Go
从混沌到秩序:Java共享内存模型如何通过显式约束驯服并发?
并发编程旨在混乱中建立秩序。本文对比Java共享内存模型与Golang消息传递模型,剖析显式同步与隐式因果的哲学差异,揭示happens-before等机制如何保障内存可见性与数据一致性,展现两大范式的深层分野。(238字)
180 4
|
6月前
|
安全 Java 数据库连接
一把锁的两种承诺:synchronized如何同时保证互斥与内存可见性?
临界区指多线程中访问共享资源的代码段,需通过互斥机制防止数据不一致与竞态条件。Java用`synchronized`实现同步,保证同一时刻仅一个线程执行临界区代码,并借助happens-before规则确保内存可见性与操作顺序,从而保障线程安全。
253 11
|
6月前
|
存储 缓存 Java
【深入浅出】揭秘Java内存模型(JMM):并发编程的基石
本文深入解析Java内存模型(JMM),揭示synchronized与volatile的底层原理,剖析主内存与工作内存、可见性、有序性等核心概念,助你理解并发编程三大难题及Happens-Before、内存屏障等解决方案,掌握多线程编程基石。
|
6月前
|
存储 Java Go
【Java】(3)8种基本数据类型的分析、数据类型转换规则、转义字符的列举
牢记类型转换规则在脑海中将编译和运行两个阶段分开,这是两个不同的阶段,不要弄混!
313 2
|
6月前
|
Java Go 开发工具
【Java】(9)抽象类、接口、内部的运用与作用分析,枚举类型的使用
抽象类必须使用abstract修饰符来修饰,抽象方法也必须使用abstract修饰符来修饰,抽象方法不能有方法体。抽象类不能被实例化,无法使用new关键字来调用抽象类的构造器创建抽象类的实例。抽象类可以包含成员变量、方法(普通方法和抽象方法都可以)、构造器、初始化块、内部类(接 口、枚举)5种成分。抽象类的构造器不能用于创建实例,主要是用于被其子类调用。抽象类中不一定包含抽象方法,但是有抽象方法的类必定是抽象类abstract static不能同时修饰一个方法。
288 0
|
7月前
|
缓存 监控 Kubernetes
Java虚拟机内存溢出(Java Heap Space)问题处理方案
综上所述, 解决Java Heap Space溢出需从多角度综合施策; 包括但不限于配置调整、代码审查与优化以及系统设计层面改进; 同样也不能忽视运行期监控与预警设置之重要性; 及早发现潜在风险点并采取相应补救手段至关重要.
909 17
|
7月前
|
数据采集 存储 弹性计算
高并发Java爬虫的瓶颈分析与动态线程优化方案
高并发Java爬虫的瓶颈分析与动态线程优化方案
|
8月前
|
存储 监控 算法
Java垃圾回收机制(GC)与内存模型
本文主要讲述JVM的内存模型和基本调优机制。