若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。
- 参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
很多人将 java 内存结构 与 java 内存模型 傻傻分不清,
java 内存模型 是 Java Memory Model(JMM)的意思。
简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障
JMM 定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
Java 内存模型的主要目的是定义程序中各种变量的访问规则
此处的变量(Variables)与 Java 编程中所说的变量有所区别
为了获得更好的执行效能
注意:此处请读者注意区分概念
Java 内存模型规定了所有的变量都存储在 主内存(Main Memory)中
每条线程还有自己的 工作内存(Working Memory,可与前面讲的处理器高速缓存类比)
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
这两者(此处所讲的主内存和之前所讲的 Java 内存区域中的部分)基本上是没有任何关系的
如果两者一定要勉强对应起来
从更基础的层次上说
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
关于主内存与工作内存之间具体的交互协议,
即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,
Java 内存模型中定义了以下 8 种操作来完成。
Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
注意:对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read 和 load 操作;
如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作。
注意:Java 内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。
也就是说 read 与 load 之间、store 与 write 之间是可插入其他指令的,
如对主内存中的变量 a、b 进行访问时,一种可能出现的顺序是 read a、read b、load b、load a。
除此之外,Java 内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则:
这 8 种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对 volatile 的一些特殊规定,就已经能准确地描述出 Java 程序中哪些内存访问操作在并发下才是安全的。
这种定义相当严谨,但也是极为烦琐,实践起来更是无比麻烦。
可能部分读者阅读到这里已经对多线程开发产生恐惧感了。
后来 Java 设计团队大概也意识到了这个问题,将 Java 内存模型的操作简化为 read、write、lock 和 unlock 四种。
但这只是语言描述上的等价化简,Java 内存模型的基础设计并未改变。
然而即使是这四操作种,对于普通用户来说阅读使用起来仍然并不方便。
不过读者对此无须过分担忧。
除了进行虚拟机开发的团队外,大概没有其他开发人员会以这种方式来思考并发问题,我们只需要理解 Java 内存模型的定义即可。
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
Java 内存模型为 volatile 专门定义了一些特殊的访问规则。
在介绍这些比较拗口的规则定义之前,先用一些不那么正式,但通俗易懂的语言来介绍一下这个关键字的作用。
当一个变量被定义成 volatile 之 后,它将具备两项特性:
这里的 “可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。
比如,线程 A 修改一个普通变量的值,然后向主内存进行回写;
另外一条线程 B 在线程 A 回写完成了之后再对主内存进行读取操作,新变量值才会对线程 B 可见。
关于 volatile 变量的可见性,经常会被开发人员误解。
他们会误以为下面的描述是正确的:“volatile 变量对所有线程是立即可见的,对 volatile 变量所有的写操作都能立刻反映到其他线程之中。
换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是线程安全的”。
这句话的论据部分并没有错,但是由其论据并不能得出 “基于 volatile 变量的运算在并发下是线程安全的” 这样的结论。
volatile 变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中 volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是 Java 里面的运算操作符并非原子操作,这导致 volatile 变量的运算在并发下一样是不安全的。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性:
volatile 的适用于一个写线程,多个读线程的情况。
普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
因为在同一个线程的方法执行过程中无法感知到这点,这就是 Java 内存模型中描述的所谓 “线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。
现在我们来看看在众多保障并发安全的工具中选用 volatile 的意义——它能让我们的代码比使用其他的同步工具更快吗?
在某些情况下,volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁);
但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说 volatile 就会比 synchronized 快上多少。
如果让 volatile 自己与自己比较,那可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
不过即便如此,大多数场景下 volatile 的总开销仍然要比锁来得更低。
我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。
本节的最后,我们再回头来看看 Java 内存模型中对 volatile 变量定义的特殊规则的定义。
假定 T 表示一个线程,V 和 W 分别表示两个 volatile 型变量,
那么在进行 read
、load
、use
、assign
、store
和 write
操作时需要满足如下规则:
load
的时候,线程 T 才能对变量 V 执行 use
动作;use
的时候,线程 T 才能对变量 V 执行 load
动作。use
动作可以认为是和线程 T 对变量 V 的 load
、read
动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次使用 V 前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量 V 所做的修改。
assign
的时候,线程 T 才能对变量 V 执行 store
动作;store
的时候,线程 T 才能对变量 V 执行 assign
动作。assign
动作可以认为是和线程 T 对变量 V 的 store
、write
动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次修改 V 后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量 V 所做的修改。
use
或 assign
动作,load
或 store
动作,read
或 write
动作;use
或 assign
动作,load
或 store
动作,read
或 write
动作。这条规则要求 volatile 修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
原子性(Atomicity)
由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write 这六个
我们大致可以认为,基本数据类型的访问、读写都是具备原子性的
如果应用场景需要一个更大范围的原子性保证(经常会遇到)
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
public class Demo4_1 {
static int i = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i++;
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i--;
}
});
/*
* run():封装线程执行的代码,直接调用,相当于普通方法的调用
* start():启动线程。然后由 JVM 调用此线程的 run() 方法
*/
t1.start();
t2.start();
// join():等待该线程死亡
t1.join();
t2.join();
System.out.println(i);
}
}
上面的程序运行后的结果可能是正数、负数、零。
以上的结果可能是正数、负数、零。
这是因为 Java 中对静态变量的自增,自减并不是原子操作。
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令
getstatic i // 获取静态变量 i 的值
iconst_1 // 准备常量 1
iadd // 加法
putstatic i // 将修改后的值存入静态变量 i
对应的 i- - 也是如此
getstatic i // 获取静态变量 i 的值
iconst_1 // 准备常量 1
isub // 减法
putstatic i // 将修改后的值存入静态变量 i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题
// 假设 i 的初始值为 0
getstatic i // 线程1-获取静态变量 i 的值 线程内i=0
iconst_1 // 线程1-准备常量 1
iadd // 线程1-自增 线程内 i=1
putstatic i // 线程1-将修改后的值存入静态变量 i 静态变量 i=1
getstatic i // 线程1-获取静态变量 i 的值 线程内 i=1
iconst_1 // 线程1-准备常量 1
isub // 线程1-自减 线程内 i=0
putstatic i // 线程1-将修改后的值存入静态变量 i 静态变量 i=0
但多线程下这 8 行代码可能交错运行
出现负数的情况
// 假设 i 的初始值为 0
getstatic i // 线程1-获取静态变量i的值 线程内 i=0
getstatic i // 线程2-获取静态变量i的值 线程内 i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内 i=1
putstatic i // 线程1-将修改后的值存入静态变量 i 静态变量 i=1
iconst_1 // 线程2-准备常量 1
isub // 线程2-自减 线程内 i=-1
putstatic i // 线程2-将修改后的值存入静态变量 i 静态变量 i=-1
出现正数的情况
// 假设 i 的初始值为 0
getstatic i // 线程1-获取静态变量i的值 线程内 i=0
getstatic i // 线程2-获取静态变量i的值 线程内 i=0
iconst_1 // 线程1-准备常量 1
iadd // 线程1-自增 线程内 i=1
iconst_1 // 线程2-准备常量 1
isub // 线程2-自减 线程内 i=-1
putstatic i // 线程2-将修改后的值存入静态变量 i 静态变量 i=-1
putstatic i // 线程1-将修改后的值存入静态变量 i 静态变量 i=1
synchronized
(同步关键字)
语法
synchronized(对象){
要作为原子操作代码
}
用 synchronized
解决并发问题
static int i = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
如何理解呢?
你可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。
当线程 t1 执行到 synchronized(obj)
时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 count++
代码。
这时候如果 t2 也运行到了 synchronized(obj)
时,它发现门被锁住了,只能在门外等待。
当 t1 执行完 synchronized{}
块内的代码,这时候才会解开门上的锁,从 obj 房间出来。
t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count--
代码。
注意
synchronized
锁住同一个 obj 对象参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此。
普通变量与 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了 volatile 之外,Java 还有两个关键字能实现可见性,它们是 synchronized 和 final。
同步块的可见性是
而 final 关键字的可见性是指
如下方代码块所示,变量 i 与 j 都具备可见性,它们无须同步就能被其他线程正确访问。
public static final int i;
public final int j;
static {
i = 0;
// 省略后续动作
}
{
// 也可以选择在构造函数中初始化
j = 0;
// 省略后续动作
}
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程 t 不会如预想的停下来
}
volatile(易变关键字)
/* ***************************************************** */
volatile static boolean run = true;
/* ***************************************************** */
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程 t 不会如预想的停下来
}
这个例子体现的实际就是可见性
它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见的。
这个例从字节码理解是这样的:
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
但是 volatile 是不能保证原子性,它仅用在一个写线程,多个读线程的情况。
此处比较一下之前我们将线程安全时举的例子:
两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错
// 假设 i 的初始值为 0
getstatic i // 线程1-获取静态变量 i 的值 线程内 i=0
getstatic i // 线程2-获取静态变量 i 的值 线程内 i=0
iconst_1 // 线程1-准备常量 1
iadd // 线程1-自增 线程内 i=1
putstatic i // 线程1-将修改后的值存入静态变量 i 静态变量 i=1
iconst_1 // 线程2-准备常量 1
isub // 线程2-自减 线程内 i=-1
putstatic i // 线程2-将修改后的值存入静态变量 i 静态变量 i=-1
注意
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。
但缺点是 synchronized 是属于重量级操作,性能相对更低
如果在前面示例的死循环中加入 System.out.println()
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
System.out.println(1);
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程 t 不会如预想的停下来
}
运行程序后发现:即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,这是为什么?
这是底层中 synchronized 关键字起到的作用
// PrintStream 类
public void println(int x) {
synchronized (this) {
print(x);
newLine();
}
}
println 方法中添加了 synchronized 关键字的
要对当前的 PrintStream(即打印输出流),做一个同步
同步关键字(synchronized)可以防止当前线程从高速缓存中获取值,强制当前线程(t 线程)去读取主从中值,破坏了 JIT 的优化。
总结:
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
Java 内存模型的有序性在前面讲解 volatile 时也比较详细地讨论过了
Java 程序中天然的有序性可以总结为一句话:
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性
int num = 0;
boolean ready = false;
// 线程 1 执行此方法
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程 2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果。
问:可能的结果有几种?
有人这么分析
ready = false
,所以进入 else
分支结果为 1num = 2
,但没来得及执行 ready = true
,线程 1 执行,还是进入 else
分支,结果为 1ready = true
,线程 1 执行,这回进入 if
分支,结果为 4(因为 num
已经执行过了)但是还有一种情况:结果还有可能是 0
这种情况下是:线程 2 执行 ready = true
,切换到线程 1,进入 if
分支,相加为 0,再切回线程 2 执行 num = 2
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现。
这就需要借助到 java 并发压测工具 jcstress 了。
首先使用下面的命令创建一个骨架项目
mvn archetype:generate -DinteractiveMode=false -
DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DgroupId=org.sample -DartifactId=test -Dversion=1.0
之后提供如下测试类
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;
//@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
执行
mvn clean install
java -jar target/jcstress.jar
会输出我们感兴趣的结果,摘录其中一次结果
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
2 matching test results.
[OK] test.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation])
Observed state Occurrences Expectation Interpretation
0 1,729 ACCEPTABLE_INTERESTING !!!!
1 42,617,915 ACCEPTABLE ok
4 5,146,627 ACCEPTABLE ok
[OK] test.ConcurrencyTest
(JVM args: [])
Observed state Occurrences Expectation Interpretation
0 1,652 ACCEPTABLE_INTERESTING !!!!
1 46,460,657 ACCEPTABLE ok
4 4,571,072 ACCEPTABLE ok
可以看到,出现结果为 0 的情况有 638 次,虽然次数相对很少,但毕竟是出现了。
volatile 修饰的变量,可以禁用指令重排
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;
//@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
/* ************************************* */
volatile boolean ready = false;
/* ************************************* */
@Actor
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
结果
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
0 matching test results.
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。
所以,上面代码真正执行时,既可以是
i = ...; // 较为耗时的操作
j = ...;
也可以是
j = ...;
i = ...; // 较为耗时的操作
这种特性称之为 『指令重排』,多线程下 『指令重排』 会影响正确性
例如著名的 double-checked locking 模式实现单例
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized 代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
但在多线程环境下,上面的代码是有问题的, INSTANCE = new Singleton()
对应的字节码为
0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "":()V
7: putstatic #4 // Field
INSTANCE:Lcn/itcast/jvm/t4/Singleton;
其中 4 7 两步的顺序不是固定的。
也许 JVM 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法
如果之后两个线程 t1,t2 按如下时间序列执行:
时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为 Singleton 对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)
时间4 t2 线程进入 getInstance() 方法,发现 INSTANCE != null(synchronized 块外),直接返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结
抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z
这套规则里说的变量都是指 成员变量 或 静态成员变量
CAS 即 Compare and Swap ,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作
// 需要不断尝试
while(true) {
int 旧值 = 共享变量 ; // 比如拿到了当前值 0
int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if( compareAndSwap ( 旧值, 结果 )) {
// 成功,退出循环
}
}
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令
下面是直接使用 Unsafe 对象进行线程安全保护的一个例子(了解即可)
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class TestCAS {
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
int count = 5;
Thread t1 = new Thread(() -> {
for (int i = 0; i < count; i++) {
dc.increase();
}
});
t1.start();
t1.join();
System.out.println(dc.getData());
}
}
class DataContainer {
private volatile int data;
static final Unsafe unsafe;
static final long DATA_OFFSET;
static {
try {
// Unsafe 对象不能直接调用,只能通过反射获得
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
}
try {
// data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
DATA_OFFSET = unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
} catch (NoSuchFieldException e) {
throw new Error(e);
}
}
public void increase() {
int oldValue;
while(true) {
// 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
oldValue = data;
// cas 尝试修改 data 为 旧值 + 1,如果期间旧值被别的线程改了,返回 false
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue + 1)) {
return;
}
}
}
public void decrease() {
int oldValue;
while(true) {
oldValue = data;
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue - 1)) {
return;
}
}
}
public int getData() {
return data;
}
}
CAS 是基于乐观锁的思想
synchronized 是基于悲观锁的思想
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作
例如:AtomicInteger、AtomicBoolean 等,它们底层就是采用 CAS 技术 + volatile 来实现的。
可以使用 AtomicInteger 改写之前的例子:
// 创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndIncrement(); // 获取并且自增 i++
// i.incrementAndGet(); // 自增并且获取 ++i
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndDecrement(); // 获取并且自减 i--
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
输出结果是 0
Java HotSpot 虚拟机中
当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、 线程 ID 等内容
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
这就好比:
学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。
如果这期间有其它学生(线程 B)来了,会告知(线程 A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来
假设有两个方法同步块,利用同一个对象加锁
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
线程 1 | 对象 Mark Word | 线程 2 |
---|---|---|
访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 A | 00(轻量锁)线程 1 锁记录地址 | - |
访问同步块 B,把 Mark 复制到线程 1 的锁记录 | 00(轻量锁)线程 1 锁记录地址 | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 00(轻量锁)线程 1 锁记录地址 | - |
失败(发现是自己的锁) | 00(轻量锁)线程 1 锁记录地址 | - |
锁重入 | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 B | 00(轻量锁)线程 1 锁记录地址 | - |
同步块 B 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
同步块 A 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
成功(解锁) 01(无锁) | - | |
- | 01(无锁) | 访问同步块 A,把 Mark 复制到线程 2 的锁记录 |
- | 01(无锁) | CAS 修改 Mark 为线程 2 锁记录地址 |
- | 00(轻量锁) | 线程 2 锁记录地址 成功(加锁) |
- | … | … |
如果在尝试加轻量级锁的过程中,CAS 操作无法成功
这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
线程 1 | 对象 Mark | 线程 2 |
---|---|---|
访问同步块,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 访问同步块,把 Mark 复制到线程 2 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为线程 2 锁记录地址 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 失败(发现别人已经占了锁) |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为重量锁 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞中 |
执行完毕 | 10(重量锁)重量锁指针 | 阻塞中 |
失败(解锁) | 10(重量锁)重量锁指针 | 阻塞中 |
释放重量锁,唤起阻塞线程竞争 | 01(无锁) | 阻塞中 |
- | 10(重量锁) | 竞争重量锁 |
- | 10(重量锁) | 成功(加锁) |
- | … | … |
重量级锁竞争的时候,还可以使用自旋来进行优化。
如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的。
比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;
反之,就少自旋甚至不自旋,总之,比较智能。
自旋重试成功的情况
线程 1 (cpu 1 上) | 对象 Mark | 线程 2 (cpu 2 上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行完毕 10 | (重量锁)重量锁指针 | 自旋重试 |
成功(解锁) | 01(无锁) | 自旋重试 |
- 10(重量锁) | 重量锁指针 | 成功(加锁) |
- 10(重量锁) | 重量锁指针 | 执行同步块 |
- | … | … |
自旋重试失败的情况
线程 1(cpu 1 上) | 对象 Mark | 线程 2(cpu 2 上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
- | … | … |
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:
只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。
-XX:-UseBiasedLocking
禁用偏向锁具体情况可以参考这篇论文:https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf
假设有两个方法同步块,利用同一个对象加锁
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
线程 1 | 对象 Mark |
---|---|
访问同步块 A,检查 Mark 中是否有线程 ID | 101(无锁可偏向) |
尝试加偏向锁 | 101(无锁可偏向)对象 hashCode |
成功 | 101(无锁可偏向)线程 ID |
执行同步块 A | 101(无锁可偏向)线程 ID |
访问同步块 B,检查 Mark 中是否有线程 ID | 101(无锁可偏向)线程 ID |
是自己的线程 ID,锁是自己的,无需做更多操作 | 101(无锁可偏向)线程 ID |
执行同步块 B | 101(无锁可偏向)线程 ID |
执行完毕 | 101(无锁可偏向)对象 hashCode |
同步代码块中尽量短
将一个锁拆分为多个锁提高并发度
例如:
多次循环进入同步块不如同步块内多次循环
另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
new StringBuffer().append("a").append("b").append("c");
JVM 会进行代码的逃逸分析
例如某个加锁对象是方法内局部变量,不会被其它线程所访问到。这时候就会被即时编译器忽略掉所有同步操作。
CopyOnWriteArrayList、ConyOnWriteSet
参考文章链接
- https://wiki.openjdk.java.net/display/HotSpot/Synchronization
- http://luojinping.com/2015/07/09/java锁优化/
- https://www.infoq.cn/article/java-se-16-synchronized
- https://www.jianshu.com/p/9932047a89be
- https://www.cnblogs.com/sheeva/p/6366782.html
- https://stackoverflow.com/questions/46312817/does-java-ever-rebias-an-individual-lock