在JDK 1.5之前,要确保某个共享变量的更新在多线程环境下是线程安全的,通常使用 synchronized
关键字。为了提升性能和减少锁带来的开销,JDK 1.5引入了原子类型(如 AtomicInteger
、AtomicBoolean
等),这些原子类利用了CAS(Compare-And-Swap) 机制,使得在并发情况下无需加锁即可保证线程安全。
前文 《同步锁 synchronized》 有讲到关键字 synchronized
是通过 Mark Word 和 ObjectMonitor 来保证对共享变量修改的排他性的,通过 Java 内存模型保证修改后的可见性。
那原子类又是怎么保证排他性和可见性呢?Java 内存模型凭什么可以保证可见性呢?
CAS 指令
CAS(Compare-And-Swap,比较并交换)是 CPU 的一个指令,既然是 CPU 指令那它肯定就是原子操作,就意味着对共享变量的读写操作不会被线程切换中断,没有了线程切换也就保证了对共享变量修改的排他性。
原子性 强调的是操作本身的不可分割性。排他性强调的是同一时间只有一个线程能够访问某个资源或执行某段代码。
比如在 x86 架构中,CMPXCHG 是一个硬件级的 CAS 操作指令。
CMPXCHG destination, source
这条指令会:
- 比较累加器寄存器(通常是 EAX)的值与内存中 destination 的值。
- 如果相等,则将另一个寄存器中 source 的值复制到 destination。
- 如果不相等,则将 destination 的值复制到 EAX,不更新内存,并设置标志寄存器以表示比较失败。
所以CAS 指令涉及三个操作数:
⨳ 内存位置 V:表示要操作的变量或内存地址,即 destination。
⨳ 期望值 E:操作线程认为当前变量应该具备的值,即累加器寄存器。
⨳ 新值 N:如果变量的当前值与期望值一致,则用新值 N 更新它,即 source 。
所以一个完整的 CAS 操作涉及三个阶段:
-
CAS前:处理器将 期望值 E 和 新值 N 从内存加载到寄存器中,以便进行后续的比较和写入操作。
-
执行 CAS 指令:处理器使用 CAS 指令将 寄存器中的期望值 E 与 内存地址 V 中的当前值进行比较。如果相等,表示内存地址 V 的值自期望值 E 以来没有变化,那么处理器会将 新值 N 写入 内存地址 V,并且 CAS 操作成功。如果不相等,表示有其他线程修改过该变量,操作失败,处理器不会更新内存中的值。
-
CAS后:在 CAS 操作成功的情况下,处理器会将 新值 N 写入内存。但由于 CPU 缓存 的存在,这个写操作并不一定会立即刷新到主内存中。
CAS 原子性只是CAS 指令的原子性,并不是CAS 操作的原子性,这一点要注意!而且因为CPU 缓存 的存在,也不能保证修改后的可见性。
其实对比关键字 synchronized
的加锁过程,不也是需要通过 CAS 操作,尝试将对象头中的 Mark Word 更新为指向线程栈中锁记录的指针嘛。
原子类
Java 中的原子类型主要包含在 java.util.concurrent.atomic
包中,常见的原子类型有以下几类:
⨳ 基本数据类型的原子类:对 Java 的基本数据类型(int、long、boolean 等)的原子封装,能够提供线程安全的自增、自减、更新等操作。如 AtomicInteger、AtomicLong、AtomicBoolean ...
⨳ 引用类型的原子类:对引用类型对象的原子操作。如 AtomicReference、AtomicStampedReference 和 AtomicMarkableReference...
⨳ 数组类型的原子类:对数组中元素的原子性操作,允许在多线程环境下安全地操作数组中的单个元素,如 AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray...
⨳ 对象属性的原子类:对对象中的某些属性(字段)进行原子操作的工具,如 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater...
⨳ 累加器和加法器:Java 8 引入了新的原子类,用于高效地进行并发的计数和累加操作,相比原子化的基本数据类型,速度更快。如 DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder...
基本数据类型的原子类
AtomicInteger
下面就以 原子整型 AtomicInteger
为例,看看原子类是怎么使用 CAS 指令的。
原子整型核心属性如下:
⨳ private static final Unsafe U = Unsafe.getUnsafe();
Unsafe 是 Java 中的一个非常底层的类,提供了对内存操作的直接访问。
⨳ private static final long VALUE= U.objectFieldOffset(AtomicInteger.class, "value");
使用 Unsafe 提供的 内存偏移 操作,返回 AtomicInteger
对象中 value 字段的内存偏移量。
⨳ private volatile int value;
是 AtomicInteger
中实际存储整数的字段。
三个属性,三个知识点。
- Unsafe 是 Java 中的一个非常底层的类,位于
sun.misc.Unsafe
包中。
它允许开发者执行一些常规 Java API 无法完成的操作,比如直接操作内存、线程调度、CAS 操作等。由于其功能过于强大,而且绕过了 Java 的安全性和内存管理机制,所以 Unsafe 被标记为不安全的,普通的开发者无法通过常规手段直接使用它。
但 CAS 操作必须要直接读取和写入内存地址,所以对于原子类来说是必须的,第二个属性 VALUE
不就是使用 Unsafe 提供的方法获取value 字段的内存偏移量嘛。
- 用于表示
value
内存地址的VALUE
是个静态常量,是因为每个原子对象的value
地址都一样吗?
并不是,VALUE 存储的其实是 AtomicInteger 类中 value 字段相对于对象内存地址的偏移量。这个偏移量对于所有 AtomicInteger 对象来说是固定的,因为它是相对于类定义的,并且不随实例的变化而变化。因此,偏移量的计算只需要做一次,结果对于所有 AtomicInteger 对象都一样。
- 存储整数的
value
使用 关键字volatile
修饰。
关于这个关键字,下面会详细讨论,这里只需要知道 volatile
修饰的 value 字段在CAS之后能可以立即被别的线程看到,保证了修改后的可见性。
常用方法如下:
⨳ boolean compareAndSet(int expect, int update)
如果当前值等于 expect,则将其更新为 update。
⨳ int getAndSet(int newValue)
先返回当前值,然后将其设置为 newValue。
⨳ int getAndIncrement()
先返回当前值,然后将其增加 1。
⨳ int incrementAndGet()
先将当前值增加 1,然后返回增加后的值。
⨳ int getAndDecrement()
先返回当前值,然后将其减 1。
⨳ int decrementAndGet()
先将当前值减 1,然后返回减少后的值。
⨳ int getAndAdd(int delta)
先返回当前值,然后增加指定的 delta 值。
⨳ int addAndGet(int delta)
先增加指定的 delta 值,然后返回增加后的值。
⨳ ...
其实这个方法底层都是调用 Unsafe类的 CAS 相关方法进行值更新的:
@IntrinsicCandidate public final native boolean compareAndSetInt(Object o, long offset, int expected, int x);
Unsafe 类在 JVM 中中对应 unsafe.cpp
,而 unsafe.cpp
关于 CAS 相关方法是底层就是 CAS 指令,具体就不细看了。
这里需要注意由于 compareAndSwapInt
方法并没内有加锁,所以不具备排他性,所以会存在对 value 修改失败的情况。
-
线程 A 读取当前值 V,它的期望值也是 V,准备将其更新为 V1;
-
线程 B 读取当前值 V,它的期望值也是 V,准备将其更新为 V2;
-
线程 B 在 A 执行 CAS指令操作之前,已经成功将 V 更新为了 V2,因为value 使用 关键字volatile修饰,修改结果可以被 线程 A 看到;
-
线程 A 进行 CAS指令操作 时,比较失败,因为它的期望值 V 已经不再是当前值(此时是 V2),所以 A 的更新操作将失败,
compareAndSet
方法返回 false。
那 compareAndSet
方法在 CAS 操作失败后会返回 false
,那上述列举的其他方法好像没有失败的情况呢。
这时因为其他方法进行 CAS 操作的期望值都是AtomicInteger 当前的 value值,而且都有一个自旋处理,以 getAndIncrement
方法为例:
// AtomicInteger
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// Unsafe
@IntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do { // 根据内存偏移量读取对象的当前值。使用 `volatile` 保证了内存的可见性。
v = getIntVolatile(o, offset); // 如果 CAS 失败,表示有其他线程修改了该值,方法会继续重试
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
这时就是所谓的乐观锁,而所谓自旋,其实就是循环尝试。
乐观锁的核心思想是乐观地认为大多数情况下,数据不会发生冲突。因此,它不会主动加锁,而是在数据被修改之前,进行一次验证。如果在这个过程中数据没有被其他线程修改,操作就可以顺利进行;如果数据被修改,则认为发生了冲突,操作失败,需要重新尝试或采取其他措施,这里采取的其他措施就是再次读取
value
值,循环尝试。
AtomicBoolean
AtomicBoolean
提供了对 boolean
类型值的原子更新操作。它和 AtomicInteger
差不多,但还是有点区别:
public class AtomicBoolean implements java.io.Serializable {
private static final long serialVersionUID = 4654671469794556979L;
private static final VarHandle VALUE;
static {
try {
MethodHandles.Lookup l = MethodHandles.lookup();
VALUE = l.findVarHandle(AtomicBoolean.class, "value", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
private volatile int value;
...
private volatile int value;
这是AtomicBoolean
使用 int 来表示 boolean(0 代表false
,1 代表true
)。
这是因为 CAS 原子指令,通常只能在固定宽度的内存块上工作,比如 32 位(4 字节)或 64 位(8 字节)。在大多数硬件平台上,CAS 操作最小支持的单位是 32 位整数,而非单个字节或位。这意味着在底层实现 boolean
的原子性操作时,int
是更合适的选择。
- 使用
VarHandle
而不是Unsafe
进行 CAS 操作
在 Java 9 之前,开发者通常会使用 Unsafe
类来进行高效的内存操作,但 Unsafe
类的问题在于它的访问权限非常广泛,容易导致安全问题。同时,Unsafe
操作也往往是平台相关的,使用起来容易出错。
VarHandle
是 Java 9 中引入的,用来提供一种比传统反射和 Unsafe
类更灵活和安全的方式来操控字段、数组元素、数组内对象等的引用和更新操作。它类似于指向某个变量的句柄,允许我们对该变量执行原子操作、内存可见性控制和线程安全的更新等功能。
MethodHandles.Lookup l = MethodHandles.lookup();
VarHandle VALUE = l.findVarHandle(AtomicBoolean.class, "value", int.class);
VALUE.compareAndSet(this, (expectedValue ? 1 : 0), (newValue ? 1 : 0));
AtomicLong
AtomicLong
也和 AtomicInteger
差不多,只是 AtomicLong
的 value
是 long
类型:
private volatile long value;
其实还有一点需要注意,在 64 位的 CPU 上,通常可以直接通过 CMPXCHG
指令处理 64 位的长整型(long
),但在 32 位的 CPU 上,CMPXCHG
指令通常只能处理 32 位的数据。对于 64 位的 long
型数据,硬件无法直接支持原子操作,因此 JVM 需要通过锁来模拟原子性。
引用类型的原子类
AtomicReference
AtomicReference
也和 AtomicInteger
差不多,只是 AtomicReference
的 value
是泛型:
private volatile V value;
稍微了解Java语言的都知道,引用类型本质上是一个指针,它指向堆内存中的对象。因此,V value
变量本身存储的只是对象的引用(指针),而不是对象本身。
这个引用指针占用几个字节也是和 CPU 有关的,在 32 位系统上,指针的长度是 32 位(4 字节),在 64 位系统上,指针的长度是 64 位(8 字节)。
需要注意的是,64 位 JVM 中,如果启用了 指针压缩(Compressed Oops,默认在内存小于 32 GB 时开启),对象引用仍然可以是 4 字节。指针压缩通过缩小对象引用的大小来减少内存占用。
基于 volatile 关键字 + CAS指令无锁的操作方式来确保共享数据在多线程操作下的线程安全性。
AtomicStampedReference
AtomicStampedReference
是 AtomicReference
的升级版本,,它不仅对 引用对象 进行原子性操作,还维护了一个 整数标志(stamp) ,用来解决多线程并发中的 ABA 问题。
ABA 问题是指在没有额外控制时,一个线程将对象从状态 A 改为 B,然后又改回 A。另一个线程可能感知不到中间状态的变化,只看到对象当前是 A,从而误认为对象没有发生过改变,导致潜在的数据一致性问题。
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
可以看到,AtomicStampedReference
的 value 是包含对象引用(reference)和整数标志(stamp)的Pair 封装,相应的对 reference
进行 CAS 操作前,要先对比一下这个整数标志,如果不匹配就不进行 CAS 操作了。
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
还有一个与 AtomicStampedReference
类似,使用一个布尔值 mark 来解决并发环境中的 ABA 问题的 AtomicMarkableReference
,这里就不赘述了。
数组类型的原子类
数组类型的原子类有 AtomicIntegerArray
、AtomicLongArray
和 AtomicReferenceArray
,这三者的实现方式都差不多,都是保证对数组元素操作的原子性,以 AtomicIntegerArray
为例:
public class AtomicIntegerArray implements java.io.Serializable {
private static final long serialVersionUID = 2862133569453604235L;
private static final VarHandle AA
= MethodHandles.arrayElementVarHandle(int[].class);
private final int[] array;
...
public final boolean compareAndSet(int i, int expectedValue, int newValue) {
return AA.compareAndSet(array, i, expectedValue, newValue);
}
...
可以看到,AtomicIntegerArray
也是使用 VarHandle
进行 CAS 操作的,但请注意 array
是个常量,没有使用关键字 volatile
修饰,常量很理解,毕竟 array
只是个引用指针,那数组中元素没有使用volatile
修饰能确保可见性吗?
这就要讲到 Unsafe
和 VarHandle
进行 CAS 时的不同了,VarHandle
的 compareAndSet
方法具有特定的内存语义,VarHandle
会使用 setVolatile
和 getVolatile
的内存语义,这保证了内存操作的可见性和顺序性。VarHandle
的 compareAndSet 方法注释如下:
Atomically sets the value of a variable to the newValue with the memory semantics of setVolatile if the variable's current value, referred to as the witness value, == the expectedValue, as accessed with the memory semantics of getVolatile. The method signature is of the form (CT1 ct1, ..., CTn ctn, T expectedValue, T newValue) boolean.
对象属性的原子类
对象属性的原子类用于对对象的某些字段进行原子操作。Java 提供了以下几种对象属性的原子类:
⨳ AtomicIntegerFieldUpdater<T>
:用于对 int
类型的字段进行原子更新。
⨳ AtomicLongFieldUpdater<T>
:用于对 long
类型的字段进行原子更新。
⨳ AtomicReferenceFieldUpdater<T, V>
:用于对引用类型的字段进行原子更新。
对象属性的原子类使用实例如下:
public class Demo {
// 必须是 volatile,不能是 final
private volatile int count = 0;
// 获取 AtomicIntegerFieldUpdater
private static final AtomicIntegerFieldUpdater<Demo> updater = AtomicIntegerFieldUpdater.newUpdater(Demo.class, "count");
public void increment() {
// 原子性递增操作
updater.incrementAndGet(this);
}
public int getCount() {
return count;
}
}
AtomicFieldUpdater
使用起来比直接的原子类(如 AtomicInteger
)要复杂,因为它是基于反射来获取字段的:
// AtomicIntegerFieldUpdater#newUpdater 方法节选
tclass.getDeclaredField(fieldName);
getDeclaredField
方法在《反射》篇有讲过,可以获取类声明的所有字段,包括私有字段,所以AtomicFieldUpdater
不能操作父类的成员属性,而且因为反射操作需要足够的权限,所以还需要确保类的字段访问控制没有被安全管理器限制,比如在其他类中反射私有的属性。
AtomicFieldUpdater
底层依旧是 Unsafe
的 CAS 操作,所以被原子操作的属性也要被关键字 volatile
关键字修饰,才能保证 CAS 操作后的原子性。
加法器和累加器
加法器和累加器和(Adder
和 Accumulator
)是用于高效处理并发下数值累加的类:
⨳ LongAdder
:用于在多线程环境下执行高性能的整数累加操作。
⨳ DoubleAdder
:与 LongAdder
类似,专门用于高效地处理并发场景下的双精度浮点数的累加操作。
⨳ LongAccumulator
:与 LongAdder
不同,LongAccumulator
可以使用自定义的函数来控制累加行为,而不仅仅局限于加法操作。
⨳ DoubleAccumulator
:与 LongAccumulator
类似,处理双精度浮点数 (double
) 的原子累加操作。
无论是累加器还是加法器,其实都采用了分段锁(Striped64)的技术,以 LongAdder
为例,LongAdder
将一个 long
类型的值拆分成多个单元(cells
),每个单元可以独立地进行更新操作。这样,当多个线程同时更新累加器时,它们可以分别更新不同的变量,从而减少了竞争,提高了并发性能。
// Striped64 节选
abstract class Striped64 extends Number {
transient volatile Cell[] cells;
transient volatile long base;
@jdk.internal.vm.annotation.Contended static final class Cell {
volatile long value;
static {
try {
MethodHandles.Lookup l = MethodHandles.lookup();
VALUE = l.findVarHandle(Cell.class, "value", long.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
// ...
}
-
LongAdder
有一个基础的base
值,表示当前累加的总和。在开始时,所有线程都直接对base
进行累加操作,这与AtomicLong
类似。 -
当多个线程同时试图对
base
进行更新时,如果检测到竞争(CAS 失败),LongAdder
会创建多个cell
,每个cell
是一个独立的计数单元,多个线程可以分别对不同的cell
进行更新。
// LongAdder 节选
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null || !casBase(b = base, b + x)) { // CAS 失败,对 `cell` 进行累加
int index = getProbe();
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[index & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
longAccumulate(x, null, uncontended, index);
}
}
- 每个线程会根据其身份(如线程局部随机数)选择一个
cell
来更新,从而避免了对base
的集中竞争。
// Thread 类节选
/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
int threadLocalRandomProbe;
- 当需要获取累加器的当前值时,
LongAdder
会首先获取base
的值,然后遍历所有的cell
,将它们的值加总,得到最终的累加结果。
// LongAdder 节选
public long sum() {
Cell[] cs = cells;
long sum = base;
if (cs != null) {
for (Cell c : cs)
if (c != null)
sum += c.value;
}
return sum;
}
到此为止,整个 java.util.concurrent.atomic
包中类都介绍完了,那回过头看看几乎所有原子操作的共享属性都被关键字 volatile
修饰,这也是 CAS 操作后保证可见性的关键,那下面就看看这个关键字是怎么保证可见性的。
可见性的本质
《并发概述》 提到了现代CPU都会用三级缓存来提高内存访问速度,这也是导致对共享变量修改后其他线程不可见问题的根源。下面就详细谈谈这个问题。
三级缓存
三级缓存是现代计算机处理器中常见的缓存架构设计,用于提高CPU对数据的访问速度。它通过在处理器和主内存之间引入多个缓存级别,减少了CPU访问内存时的延迟。各级缓存的特性、容量和访问速度各不相同,构成了一种分层的缓存体系结构。
⨳ L1缓存:离CPU最近的缓存,速度最快,但容量最小。通常每个CPU核心都有自己独立的L1缓存,分为指令缓存(L1i)和数据缓存(L1d) ,大小通常为32KB-128KB。
⨳ 2缓存:L2缓存的容量比L1大一些,通常在每个CPU核心拥有独立的L2缓存,大小通常为256KB-512KB,速度稍慢于L1。
⨳ L3缓存:L3缓存容量更大,但速度相较L1、L2更慢。L3缓存通常是多个CPU核心共享的,大小可能达到几MB到几十MB。
当CPU需要读取某个数据时,首先会在L1缓存中查找。如果数据在L1缓存中存在(命中),那么CPU可以直接使用缓存中的数据,访问速度极快(几纳秒)。L1缓存未命中时,CPU会从L2缓存中继续查找。如果L2缓存中命中目标数据,数据会直接返回给CPU,且可能将数据写入L1缓存,以加快未来访问速度。L2缓存未命中时,CPU会查询L3缓存。如果命中,数据同样会被送往L1或L2缓存以便后续使用。
当CPU访问一个内存中的数据时,数据会从内存流入L3缓存,然后逐步传递到L2和L1缓存。一旦数据进入L1缓存,后续对该数据的访问可以避免再次访问内存,大大减少延迟。
缓存虽好,但也导致可见性问题,如果两个线程同时加载同一块数据并保存到缓存中,再分别进行修改,那么如何保证缓存的一致性呢?
CPU中的L1和L2缓存是CPU私有的,当CPU需要修改某个变量时,如果该数据存在于L1缓存(命中),CPU会直接修改L1缓存中的数据,而不会立即将修改后的数据写回主内存,那运行在其他CPU上的线程又怎么能即使发现这个变量被修改了呢。
那么怎么解决缓存一致性问题呢?CPU层面提供了两种解决方法:总线锁和缓存锁
⨳ 总线锁(Bus Lock):它通过锁定系统的总线,确保同一时间只有一个处理器可以访问内存或进行内存修改,从而避免多个处理器同时修改相同的数据,导致数据不一致的问题。
⨳ 缓存锁(Cache Lock): 缓存锁依赖于现代 CPU 使用的缓存一致性协议(如 MESI、MOESI 协议),通过在缓存中对特定的缓存行进行锁定,而不需要锁定整个系统总线。
早期的多处理器系统中一般使用总线锁(Bus Lock)解决缓存一致性问题的一种方法,但总线锁会阻塞其他处理器的内存访问请求,因此会显著降低系统的并行性。随着多核处理器的发展,缓存锁(Cache Locking)逐渐替代了总线锁,缓存锁仅锁定相关的缓存行,而不是整个系统的总线,这样其他处理器仍然可以继续访问非冲突的内存地址,大幅度提升了并行执行的能力。
缓存锁
MESI 是一种典型的缓存一致性协议,用于确保多个处理器的缓存中数据保持一致。它将缓存行标记为以下几种状态:
⨳ Modified(修改) :- 缓存中的数据已经被当前处理器修改,与主存中的数据不同。此时,只有该处理器持有该缓存行的数据,其他处理器缓存中没有该数据。这时,数据是“脏”的,需要在写回主存时进行同步。
⨳ Exclusive(独占) :缓存中的数据与主存中的数据一致,但该数据只存在于当前处理器的缓存中,其他处理器的缓存中没有副本。如果需要修改该数据,可以直接在缓存中进行修改,不需要通知其他处理器。
⨳ Shared(共享) :缓存中的数据与主存中的数据一致,且该数据可能存在于多个处理器的缓存中。此时,所有处理器都可以读取该数据,但如果有处理器要修改该数据,必须通知其他处理器使它们的缓存行失效,从而确保只有一个处理器可以修改该数据。
⨳ Invalid(无效) :缓存中的数据无效,不可用。如果处理器要访问这部分数据,它必须从主存或其他处理器的缓存中重新读取。
当一个处理器要访问某个内存地址时,MESI 协议会根据缓存行的当前状态进行相应的操作。比如:
⨳ 读操作:如果缓存行的状态是 M
、E
或 S
,处理器可以直接读取缓存中的数据。如果状态是 I
,则必须从主存或其他处理器的缓存中读取该数据,并更新自己的缓存。
⨳ 写操作:如果缓存行的状态是 M
,处理器可以直接修改缓存中的数据。如果状态是 E
,可以修改数据并将状态变为 M
。如果状态是 S
或 I
,则需要通知其他处理器使它们的缓存行失效,之后将数据的状态设为 M
并进行修改。
在 x86 架构中,CPU 提供了
lock
指令前缀,用于保证对共享数据的原子操作。lock
指令会在执行过程中锁定该操作所涉及的缓存行,确保其他处理器在执行操作期间无法访问或修改该缓存行。
MESI协议虽然可以实现缓存的一致性,但是也会存在一些问题:当多个CPU缓存同一个变量时,如果其中一个CPU(如CPU0)想要对该变量进行写入,它需要通过发送失效消息给其他CPU来更新它们的缓存状态。这个过程中会产生延迟,CPU0需要等待确认回执,导致在这段时间内它可能会处于阻塞状态。这种同步机制虽然能确保缓存一致性,但可能降低并行执行的效率。
为了缓解这种因为等待确认导致的阻塞问题,现代CPU架构中引入了Store Buffers(存储缓冲区) 。
⨳ 暂存写操作:当CPU执行写入操作时,写入的数据首先被存储在存储缓冲区中,而不必立即将数据写入缓存或主存。并同时发出缓存一致性协议需要的失效消息等操作。由于写操作已经被存储在缓冲区中,CPU不需要等待所有缓存一致性确认回执就能继续执行其他指令。
⨳ 写回缓存:当收到其他所有 CPU 发送了失效确认消息时,再将缓冲区中的数据数刷新到缓存行中,最后再从缓存行同步到主内存。
由于写操作被暂存在存储缓冲区中,其他处理器或线程可能无法立即看到这些更改。这在多线程环境中,可能会导致数据可见性问题。
为了解决这个问题,现代处理器和编程语言引入了内存屏障(Memory Barriers) 或者 内存栅栏,它们强制处理器在某些关键点上刷新存储缓冲区,并确保数据的可见性。
内存屏障
内存屏障通常分为以下几种类型,它们分别用于控制不同方向的内存操作顺序:
⨳ Store Memory Barrier(写内存屏障):写屏障会阻止写入操作重排到屏障之后,因此它可以确保前面的写操作已经被刷新到缓存或主存后,后续的写操作才可以执行。
⨳ Load Memory Barrier(读内存屏障):读屏障会阻止读操作被重排到屏障之前,保证后续的读操作看到的值是最新的。
⨳ Full Memory Barrier(全内存屏障):全内存屏障结合了读和写屏障的效果,既阻止写操作被重排到屏障之后,也阻止读操作被重排到屏障之前。
假设两个处理器核心 CPU0
和 CPU1
,它们共享一个变量 x
。CPU0
对 x
进行了写操作,但它的写入值暂时保存在自己的 Store Buffer 中,并没有立刻刷新到主存。此时,如果 CPU1
读取 x
,它将从主存中读取到旧的值,因为 CPU0
的写入尚未对其他处理器可见。
写屏障的作用是强制将 Store Buffer 中的数据刷入主存,确保之前所有的写操作都被提交,使其对其他线程可见。写屏障主要解决了 写延迟导致的可见性问题。
-
CPU0
对变量x
进行写操作。 -
该写操作可能暂时保存在
CPU0
的 Store Buffer 中,并没有立即写入主存。 -
如果插入了写内存屏障(如
StoreMemoryBarrier
),系统会强制将CPU0
的 Store Buffer 刷新到主存,使得其他 CPU 能够看到x
的最新值。
读屏障的作用是强制当前核心从主存中重新读取最新数据,而不是使用缓存中的数据(包括 Store Buffer 中的值)。读屏障确保了读操作看到的是最新的写入结果,避免读取到过时的值。
-
CPU1
尝试读取变量x
。 -
如果
x
的最新值还没有从其他核心的 Store Buffer 刷新到主存,CPU1
可能会读取到旧的值。 -
插入读内存屏障后,
CPU1
会从主存中读取x
的最新值,而不是从缓存中读取旧值。
许多锁和同步原语都依赖于内存屏障,以确保在获取锁之前的所有操作已经完成,以及释放锁后所有操作是可见的。例如,synchronized
块、volatile
关键字在Java中隐式地使用了内存屏障。
关键字 volatile
volatile
关键字可以自动插入适当的内存屏障,来解决可见性问题:
⨳ 写入volatile
变量时,Java会插入一个写屏障,强制将之前的写操作(包括写入的非 volatile
变量)刷入主存。
⨳ 读取volatile
变量时,Java会插入一个读屏障,确保之后的读操作能从主存读取到最新的值。
volatile
关键字使用内存屏障解决可见性的另一个结果就是可以禁止指令重排:
⨳ 通过插入写屏障,确保 volatile
变量的写操作与之前的写操作不会发生重排序,也就是说,volatile
变量的写操作一定会发生在之前所有的写操作之后。
⨳ 通过插入读屏障,确保 volatile
变量的读操作与之后的读操作不会发生重排序,也就是说,volatile
变量的读操作一定会发生在之后所有的读操作之前。
在Java Memory Model (JMM) 中,除了通过 volatile
、synchronized
等关键字主动确保可见性之外,Java还引入了一个非常重要的概念,称为 happens-before 关系,用来定义线程之间操作的执行顺序以及内存可见性规则。
happens-before 规则
Java 内存模型规定了一些默认的 happens-before
规则,确保在特定场景下操作的顺序性和可见性。如果一个操作 A
"happens-before" 操作 B
,那么:
⨳ 顺序性:操作 A
必须在操作 B
之前发生。
⨳ 可见性:A
对共享变量的修改对 B
是可见的,操作 B
一定能看到操作 A
的结果。
以下是几种常见的 happens-before
规则:
⨳ 程序次序规则:在单个线程中,按照程序的顺序,前面的操作happens-before后面的操作。这条规则保证了线程内部的操作是有序的。
int a = 1; // 写入a
int b = 2; // 写入b
// `a = 1` happens-before `b = 2`,因为它们在同一线程中按顺序执行
⨳ 监视器锁规则:一个 unlock
操作 happens-before 之后对同一个锁的 lock
操作。这保证了对共享数据的加锁解锁操作是线程安全的。
synchronized (lock) {
// 对共享变量的修改
} // 释放锁
// 另一个线程获得相同的锁
synchronized (lock) {
// 此时,另一个线程可以看到第一个线程的共享变量修改
}
⨳ volatile 变量规则:对 volatile
变量的写操作 happens-before 之后对同一个 volatile
变量的读操作。通过 volatile
,可以确保变量的修改对其他线程可见。
volatile boolean flag = false;
void thread1() {
flag = true; // 对 volatile 变量的写操作 happens-before 后续的读取
}
void thread2() {
if (flag) {
// thread2 一定能够看到 thread1 修改后的 flag 值为 true
}
}
⨳ 线程启动规则:在线程 Thread.start()
之前的所有操作 happens-before 该线程开始执行的任何操作。这确保了启动新线程之前的操作对该线程是可见的。
Thread t = new Thread(() -> {
// 新线程的操作
});
t.start(); // t.start() happens-before 新线程执行
⨳ 线程终止规则:一个线程的所有操作 happens-before 其他线程检测到该线程已终止。通过 Thread.join()
可以确保一个线程完成所有操作后,其他线程能够读取到线程执行结果。
Thread t = new Thread(() -> {
// 线程执行的操作
});
t.start();
t.join(); // t 线程的所有操作 happens-before join 之后的操作
⨳ 传递性:如果操作 A
happens-before 操作 B
,且操作 B
happens-before 操作 C
,那么可以推断 A
happens-before 操作 C
。这叫做传递性。
happens-before
规则定义了线程之间的可见性和有序性,在实际的程序中,即使没有使用 volatile
或 synchronized
,只要线程的操作满足 happens-before
关系,内存模型就能保证操作的可见性,避免数据竞争。
总结
解决并发问题主要就是保证对共享变量修改的排他性,和修改后的可见性。
上面讨论的一大坨,都是介绍原子类是怎么使用 CAS
操作保证排他性,怎么使用关键字 volatile
解决可见性的。
总而言之,CAS 是一种乐观锁机制,在执行操作时假设没有其他线程在竞争,不立即加锁,而是通过检查机制来判断是否需要重新执行或进行其他处理。而 synchronized
是一种悲观锁机制,它假设竞争经常会发生,因此在访问共享资源时采取主动加锁,阻止其他线程的访问。
但关键字 synchronized
的轻量级锁何尝不是一种乐观锁呢,简单来说,重量级锁也不过是比原子类增加了入口等待队列和条件等待队列,添加了复杂点的等待和调度机制罢了。