第二章 并发编程的三大特性
# 一、原子性
# 1.1 什么是并发编程的原子性
JMM(Java Memory Model)。不同的硬件和不同的操作系统在内存上的操作有一定差异的。Java为了解决相同代码在不同操作系统上出现的各种问题,用JMM屏蔽掉各种硬件和操作系统带来的差异。
让Java的并发编程可以做到跨平台。
JMM规定所有变量都会存储在主内存中,在操作的时候,需要从主内存中复制一份到线程内存(CPU内存),在线程内部做计算。然后再写回主内存中(不一定!)。
原子性的定义:原子性指一个操作是不可分割的,不可中断的,一个线程在执行时,另一个线程不会影响到他。
并发编程的原子性用代码阐述:
private static int count;
public static void increment(){
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
当前程序:多线程操作共享数据时,预期的结果,与最终的结果不符。
原子性:多线程操作临界资源,预期的结果与最终结果一致。
在多线程环境下,操作一个共享变量的过程需要保证原子性,以确保预期的结果与最终结果一致。对于++
操作(如count++
),尽管在代码中看起来是一个单一操作,实际上它在底层由三个步骤组成,这在多线程并发执行时可能会引起问题,因为这三个步骤不是原子性的:
- 读取:线程从主内存中读取变量
count
的当前值到CPU的寄存器中。 - 修改:在CPU的寄存器中将
count
的值加1。 - 写回:将修改后的值写回到主内存中。
由于这三个步骤在执行过程中可以被其他线程中断,如果多个线程同时执行这个++
操作,它们可能读取相同的初始值,各自增加1,然后写回相同的结果值,导致count
增加的次数少于预期。
# 1.2 保证并发编程的原子性
# 1.2.1 synchronized
因为++操作可以从指令中查看到
可以在方法上追加synchronized关键字或者采用同步代码块的形式来保证原子性
synchronized可以让避免多线程同时操作临街资源,同一时间点,只会有一个线程正在操作临界资源
# 1.2.2 CAS
到底什么是CAS?
compare and swap也就是比较和交换,他是一条CPU的并发原语。
它在替换内存的某个位置的值时,首先查看内存中的值与预期值是否一致,如果一致,执行替换操作。这个操作是一个原子性操作。
CAS 执行的具体流程如下:
- 将需要修改的值从主内存中读入本地线程缓存(工作内存);
- 执行 CAS 操作,将本地线程缓存中的值与主内存中的值进行比较;
- 如果本地线程缓存中的值与主内存中的值相等,则将需要修改的值在本地线程缓存中修改;
- 如果修改成功,将修改后的值写入主内存,并返回修改结果;如果失败,则返回当前主内存中的值;
- 在多线程并发执行的情况下,如果多个线程同时执行 CAS 操作,只有一个线程的 CAS 操作会成功,其他线程的 CAS 操作都会失败,这也是 CAS 的原子性保证。
Java中基于Unsafe
的类提供了对CAS的操作的方法,JVM会帮助我们将方法实现CAS汇编指令。
但是要清楚CAS保证比较和交换的过程是原子性的,CAS操作之前,你需要从主内存中去读取需要修改的值,这个读取过程是需要你自己实现的,而且这个过程本身并不是原子性的一部分。
// 使用AtomicInteger作为计数器,保证了增加操作的原子性
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// 创建第一个线程,负责对count进行100次增加
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
count.incrementAndGet(); // 原子地将当前值加1,然后返回更新后的值
}
});
// 创建第二个线程,负责对count进行100次增加
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
count.incrementAndGet(); // 原子地将当前值加1,然后返回更新后的值
}
});
// 启动两个线程
t1.start();
t2.start();
// 等待t1和t2线程执行完毕
t1.join();
t2.join();
// 打印最终的count值,预期为200
System.out.println(count);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
在Java中,java.util.concurrent.atomic
包中的原子类就是通过CAS来实现的。这些类使用了一种有效的方式来减少锁的使用,提高了并发性能。例如,AtomicInteger
类提供的compareAndSet
方法就是基于CAS操作实现的。
下面这段代码是Unsafe
类中getAndAddInt
方法的实现,整个过程如下:
- 读取当前值:
var5 = this.getIntVolatile(var1, var2);
从var1
对象的var2
偏移量处安全地(保证内存可见性)读取当前的int
值,存储在局部变量var5
中。 - 计算新值:将读取到的当前值
var5
与增量var4
相加,得到新的值var5 + var4
。 - 尝试原子更新:通过
compareAndSwapInt
原子操作尝试更新字段的值。它会检查当前值是否仍等于var5
(即没有其他线程在这个过程中修改了这个值),如果是,就将字段的值更新为新计算的值(var5 + var4
)。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 获取对象var1在内存中偏移量为var2的int字段当前的值 (保证读取到的是最新值)
var5 = this.getIntVolatile(var1, var2);
// 循环尝试,使用CAS操作更新该字段的值
// CAS操作:比较对象var1在内存中偏移量为var2的字段当前值是否仍为var5,
// 如果是,则更新为"var5 + var4"(即原值加上增量)
// compareAndSwapInt会返回一个布尔值,表示是否成功更新
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
// 循环直到CAS操作成功为止,然后返回操作前的值var5
return var5;
}
2
3
4
5
6
7
8
9
10
11
12
13
虽然getAndAddInt
方法本身由多个步骤组成(读取值、计算新值、CAS比较并交换),这些步骤看起来不是单个原子操作,但是整个方法通过CAS循环(自旋锁)确保了其操作的最终一致性和原子性。
CAS的缺点:CAS只能保证对一个变量的操作是原子性的,无法实现对多行代码实现原子性
。
CAS的问题:
ABA问题:ABA问题发生在一个线程通过CAS检查一个变量时发现它没有改变(A到A),然后继续执行操作。但实际上,在这两次检查之间,变量可能已经从A变成了B,然后又变回了A。这种情况下,第一个线程可能会错误地认为变量没有被其他线程修改过。
为了解决ABA问题,可以引入"版本号"来跟踪每次变量更新时的状态变化。每次变量更新时,除了改变变量的值外,还会增加版本号。这样,即使一个变量的值被改回了原始值,版本号的变化也会反映出这个变量实际上是被修改过的。
Java提供了
AtomicStampedReference
类来解决ABA问题。AtomicStampedReference
维护了对象引用以及这个引用的整数"时间戳"(可以认为是版本号)。当使用AtomicStampedReference
进行CAS操作时,不仅比较引用是否相等,还比较时间戳是否相等。这确保了即使在引用未发生变化的情况下,也能检测到在两次操作之间是否有其他修改发生。为了演示
AtomicStampedReference
解决ABA问题,我们可以设计一个简单的场景:一个变量开始时为"A",然后被线程1改为"B",再改回"A",同时线程2尝试将这个变量从"A"改为"C",使用版本号来防止ABA问题。public static void main(String[] args) throws InterruptedException { // 初始化AtomicStampedReference,引用初始值为"A",初始版本号为1 AtomicStampedReference<String> reference = new AtomicStampedReference<>("A", 1); int initialStamp = reference.getStamp(); // 获取初始版本号 // 线程1:将"A"更改为"B",然后再更改回"A" Thread t1 = new Thread(() -> { reference.compareAndSet(reference.getReference(), "B", initialStamp, initialStamp + 1); // 将"A"改为"B" int newStamp = reference.getStamp(); // 获取"B"后的新版本号 reference.compareAndSet(reference.getReference(), "A", newStamp, newStamp + 1); // 将"B"改回"A" }); t1.start(); t1.join(); // 确保t1操作完成 // 线程2尝试使用“初始的版本号”(即1)将"A"更新为"C" Thread t2 = new Thread(() -> { // 使用初始的版本号1尝试更新,预期由于版本号不匹配而失败 boolean isSuccess = reference.compareAndSet("A", "C", initialStamp, initialStamp + 1); System.out.println("是否修改成功: " + isSuccess + ",当前版本号:" + reference.getStamp() + ",当前值:" + reference.getReference()); }); t2.start(); t2.join(); // 确保t2操作完成 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
AtomicStampedReference.compareAndSet()方法源码解析
public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair<V> current = pair; // 当前持有的引用和版本号的对(Pair对象) return expectedReference == current.reference && // 检查当前引用值是否等于预期引用值 expectedStamp == current.stamp && // 检查当前版本号是否等于预期版本号 ((newReference == current.reference && newStamp == current.stamp) || // 如果新引用值和新版本号与当前值相同(这种情况下没必要更新) casPair(current, Pair.of(newReference, newStamp))); // 符合更新条件,使用CAS操作尝试更新引用和版本号 }
1
2
3
4
5
6
7
8
9
10
11
12
- 可以看到AtomicStampedReference在CAS时操作时,不但会判断原值,还会比较版本信息。
- 自旋时间过长问题:
- 可以指定CAS一共循环多少次,如果超过这个次数,直接失败/或者挂起线程。(自旋锁、自适应自旋锁)
- 可以在CAS一次失败后,将这个操作暂存起来,后面需要获取结果时,将暂存的操作全部执行,再返回最后的结果。
# 1.2.3 Lock锁
Lock锁是在JDK1.5由Doug Lea研发的,他的性能相比synchronized在JDK1.5的时期,性能好了很多多,但是在JDK1.6对synchronized优化之后,性能相差不大,但是如果涉及并发比较多时,推荐ReentrantLock锁,性能会更好。
Lock锁是一个接口,其所有的实现类为:
ReentrantLock(可重入锁)
ReentrantReadWriteLock.ReadLock(可重入读写锁中的读锁)
ReentrantReadWriteLock.WriteLock(可重入读写锁中的写锁)
2
3
实现方式:
private static int count; // 共享资源
private static ReentrantLock lock = new ReentrantLock(); // 创建一个可重入锁
public static void increment() {
lock.lock(); // 获取锁
try {
count++; // 安全地增加共享资源的值
try {
Thread.sleep(10); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace(); // 处理中断异常
}
} finally {
lock.unlock(); // 无论如何,最终都释放锁
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment(); // 在线程中多次调用增加操作
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment(); // 在另一个线程中也多次调用增加操作
}
});
t1.start(); // 启动第一个线程
t2.start(); // 启动第二个线程
t1.join(); // 等待第一个线程结束
t2.join(); // 等待第二个线程结束
System.out.println(count); // 打印最终的共享资源值
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
ReentrantLock可以直接对比synchronized,在功能上来说,都是锁。
但是ReentrantLock的功能性相比synchronized更丰富。
ReentrantLock底层是基于AQS实现的,有一个基于CAS维护的state变量来实现锁的操作。
# 1.2.4 ThreadLocal
Java中的四种引用类型
Java中的使用引用类型分别是强,软,弱,虚。
User user = new User();
在 Java 中最常见的就是强引用
,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它始终处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。
SoftReference
其次是软引用
,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中,作为缓存使用。
然后是弱引用
,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。可以解决内存泄漏问题,ThreadLocal就是基于弱引用解决内存泄漏的问题。
最后是虚引用
,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。不过在开发中,我们用的更多的还是强引用。
ThreadLocal保证原子性的方式,是不让多线程去操作临界资源,让每个线程去操作属于自己的数据。
- 当你在某个线程中使用
ThreadLocal
的set
方法设置一个值时,这个值被存储在当前线程的ThreadLocalMap
中,ThreadLocal
实例本身作为键。 - 当你尝试通过
get
方法获取ThreadLocal
的值时,实际上是从当前线程的ThreadLocalMap
中根据ThreadLocal
实例本身作为键来查找对应的值。 - 由于每个线程都有自己的
ThreadLocalMap
,所以即使多个线程中使用了相同的ThreadLocal
实例,它们也互不干扰,每个线程只能访问和修改自己ThreadLocalMap
中的值。
// 创建两个ThreadLocal实例
static ThreadLocal tl1 = new ThreadLocal();
static ThreadLocal tl2 = new ThreadLocal();
public static void main(String[] args) {
// 在主线程中为两个ThreadLocal实例设置值
tl1.set("123"); // 为tl1设置字符串"123"
tl2.set("456"); // 为tl2设置字符串"456"
// 创建并启动一个新线程t1
Thread t1 = new Thread(() -> {
// 尝试获取在主线程中设置的tl1和tl2的值
// 由于ThreadLocal为每个线程提供独立的副本,t1线程此时获取的值将为null,因为它没有设置过这些值
System.out.println("t1:" + tl1.get()); // null
System.out.println("t1:" + tl2.get()); // null
});
t1.start(); // 启动线程
// 在主线程中获取并打印tl1和tl2的值
// 这里将打印出主线程设置的值,因为这些值是在主线程中设置的
System.out.println("main:" + tl1.get()); // "123"
System.out.println("main:" + tl2.get()); // "456"
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ThreadLocal实现原理:
ThreadLocal的实现原理是Java并发编程中的一个重要概念,它允许我们为每个线程提供一个独立的变量副本,避免了变量在多线程环境下的共享所导致的线程安全问题。下面是ThreadLocal实现原理的关键点,以及对应的Java源码中的关键部分:
- 每个Thread中都存储着一个成员变量,ThreadLocalMap。
/* Thread类中的成员变量 */
ThreadLocal.ThreadLocalMap threadLocals = null;
2
- Java的
Thread
类包含一个ThreadLocalMap
的实例变量,它用来存储当前线程的本地变量。每个线程都拥有自己的ThreadLocalMap
实例。
- ThreadLocal本身不存储数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap。
/* ThreadLocal类的set方法 */
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
2
3
4
5
6
7
8
9
ThreadLocal
类的set
方法通过获取当前线程的ThreadLocalMap
,并使用ThreadLocal
实例本身作为key来存储数据。如果当前线程还没有ThreadLocalMap
,则会创建一个。
- ThreadLocalMap本身就是基于Entry[]实现的,因为一个线程可以绑定多个ThreadLocal,这样一来,可能需要存储多个数据,所以采用Entry[]的形式实现。
/* ThreadLocalMap内部结构 */
static class ThreadLocalMap {
// ThreadLocalMap有一个Entry内部类,用于存放多个ThreadLocal的实例
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // value 就是ThreadLocal对象set进去的值
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
// 省略其他实现细节
}
2
3
4
5
6
7
8
9
10
11
12
13
14
ThreadLocalMap
使用Entry[] table
数组来存放键值对。每个Entry
包含一个对ThreadLocal
对象的弱引用和与之对应的值。这意味着一个线程可以拥有多个ThreadLocal
变量,每个变量及其值都存储在ThreadLocalMap
的Entry
中。
每个线程都自己独立的ThreadLocalMap,再基于ThreadLocal对象本身作为key,对value进行存取。
ThreadLocalMap中Entry的key是一个弱引用:因为
ThreadLocalMap.Entry
类继承自WeakReference<ThreadLocal<?>>
,它将ThreadLocal
对象作为参数传递给WeakReference
的构造函数。这样,ThreadLocal
对象就被Entry
以弱引用的形式持有了弱引用的特点是,即便有弱引用,在GC时,也必须被回收。这里是为了在ThreadLocal对象失去引用后,如果key的引用是强引用,会导致ThreadLocal对象无法被回收。
总结:
ThreadLocal为每个线程提供了一个独立的变量副本空间,通过ThreadLocalMap
和弱引用键实现了线程本地存储的隔离与安全,同时采用弱引用避免了潜在的内存泄露问题。这些机制共同保证了在并发环境中变量的线程安全性和数据的隔离性。
ThreadLocal内存泄漏问题:
- 如果ThreadLocal引用丢失,key因为弱引用会被GC回收掉,如果此时线程仍然在运行,并且没有显式地调用
ThreadLocal.remove()
来移除这个Entry
,则该Entry
中的值(value
字段)会占用内存,导致内存泄漏,内存中的value无法被回收,同时也无法被获取到。
内存泄漏(Memory Leak)
指的是已分配的内存资源未能成功释放并且回收,导致该部分内存无法再被程序利用,进而可能引起程序运行时可用内存逐渐减少,严重时甚至导致程序或系统崩溃。
我们只需要在使用完毕ThreadLocal对象之后,及时的调用remove方法,移除Entry即可
public void remove() { // 获取当前线程的ThreadLocalMap对象 ThreadLocalMap map = getMap(Thread.currentThread()); if (map != null) { map.remove(this); // 该方法会移除当前ThreadLocal在ThreadLocalMap中的Entry } }
1
2
3
4
5
6
7
# 二、可见性
# 2.1 什么是可见性
可见性问题是基于CPU位置出现的,CPU处理速度非常快,相对CPU来说,去主内存获取数据这个事情太慢了,CPU就提供了L1,L2,L3的三级缓存,每次去主内存拿完数据后,就会存储到CPU的三级缓存,每次去三级缓存拿数据,效率肯定会提升。
这就带来了问题,现在CPU都是多核,每个线程的工作内存(CPU三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致数据不一致问题。
可见性问题的代码逻辑
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.2 解决可见性的方式
# 2.2.1 volatile
volatile是一个关键字,用来修饰成员变量。
如果属性被volatile修饰,相当于会告诉CPU,对当前属性的操作,不允许使用CPU的缓存,必须去和主内存操作。
volatile的内存语义:
- volatile属性被写:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中
- volatile属性被读:当读一个volatile变量,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量
注意volatile 无法保证原子性
- 尽管
volatile
能够保证变量修改的可见性,它却不能保证复合操作的原子性。 - 对于涉及多个步骤的复合操作(如自增操作
i++
),volatile
不能保证整个操作序列的原子性。这是因为复合操作需要多个步骤完成(读取-修改-写入),在这些步骤执行的过程中,其他线程可能会介入并修改变量,导致最终结果出现错误或不一致。
其实加了volatile就是告知CPU,对当前属性的读写操作,不允许使用CPU缓存,加了volatile修饰的属性,会在转为汇编之后后,追加一个lock的前缀,CPU执行这个指令时,如果带有lock前缀会做两个事情:
- 将当前处理器缓存行的数据写回到主内存
- 这个写回的数据,在其他的CPU内核的缓存中,直接无效。
总结:volatile就是让CPU每次操作这个数据时,必须立即同步到主内存,以及从主内存读取数据。
private volatile static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.2.2 synchronized
synchronized也是可以解决可见性问题的,synchronized的内存语义。
如果涉及到了synchronized的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从CPU缓存中移除
,必须去主内存中重新拿数据,而且在释放锁之后,会立即将CPU缓存中的数据同步到主内存。
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
synchronized (MiTest.class){
//...
}
// 如果注释上面加锁的代码块,只输出一条打印的语句也能解决可见性问题
// 因为println方法执行打印的时候也被加锁了,那么获取到锁以后,就需要重新去主内存中更新变量值
System.out.println(111);
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 2.2.3 Lock
Lock锁保证可见性的方式和synchronized完全不同,synchronized基于他的内存语义,在获取锁和释放锁时,对CPU缓存做一个同步到主内存的操作。
Lock锁是基于volatile实现的。Lock锁内部再进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作。
如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取。
private static boolean flag = true;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
lock.lock();
try{
//...
}finally {
lock.unlock();
}
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 2.2.4 final
final修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取final属性,值肯定是一样。
final并不是说每次取数据从主内存读取,他没有这个必要,而且final和volatile是不允许同时修饰一个属性的
final修饰的内容已经不允许再次被写了,而volatile是保证每次读写数据去主内存读取,并且volatile会影响一定的性能,就不需要同时修饰。
# 三、有序性
# 3.1 什么是有序性
有序性:对于一个线程的执行代码而言,我们总是习惯性地认为代码的执行总是从上到下,有序执行。
- 指令重排序:为了提高执行效率,编译器和CPU可能会对指令序列进行重新排序。这种重排序遵循的原则是,不改变单线程内的程序执行结果。
- 多线程环境:在多线程环境下,指令重排序可能导致其他线程观察到不一致的状态,从而引发错误。
在Java中,.java文件中的内容会被编译,在执行前需要再次转为CPU可以识别的指令,CPU在执行这些指令时,为了提升执行效率,在不影响最终结果的前提下(满足一些要求),会对指令进行重排。
指令乱序执行的原因,是为了尽可能的发挥CPU的性能。
Java中的程序是乱序执行的。
Java程序验证乱序执行效果:
static int a, b, x, y;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
// 重置变量
a = 0;
b = 0;
x = 0;
y = 0;
// 线程1执行
Thread t1 = new Thread(() -> {
a = 1; // T1.1 可能被重排序后的执行顺序变化
x = b; // T1.2 可能在a的赋值之前执行
});
// 线程2执行
Thread t2 = new Thread(() -> {
b = 1; // T2.1 可能被重排序后的执行顺序变化
y = a; // T2.2 可能在b的赋值之前执行
});
t1.start();
t2.start();
t1.join();
t2.join();
// 检查x和y是否同时为0,这种情况理论上在没有重排序的情况下不应该发生
if (x == 0 && y == 0) {
System.out.println("第" + i + "次,x = " + x + ", y = " + y);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- 如果没有指令重排序,我们直觉上认为x和y不可能同时为0。因为:
- 对于线程1来说,设置了
a = 1
后,线程2读取a
的值赋给y
时,理应看到a
的值为1(除非线程2的修改先于线程1发生,那么y
可能为0,但x
不应该为0)。 - 同理,线程2设置了
b = 1
后,线程1读取b
的值赋给x
时,也应该看到b
的值为1。
- 对于线程1来说,设置了
- 然而,由于指令重排序,线程1和线程2中的指令(如
a = 1; x = b;
和b = 1; y = a;
)的执行顺序可能会被改变。在某些情况下,这可能导致即使两个线程都执行了修改操作,它们读取对方变量的操作仍然看到了修改前的值(即0),从而出现了x == 0 && y == 0
的情况。
在单例模式中,特别是懒汉式实现(即实例在需要时才被创建)时,指令重排序可能导致严重的问题。这种问题主要出现在创建单例实例的过程中。
具体来说,创建Singleton
实例的过程可以分解为以下三个步骤:
- 分配内存空间给这个新的
Singleton
实例。 - 调用
Singleton
构造函数,初始化成员变量。 - 将
instance
引用指向分配的内存空间(此时instance
不再是null
)。
由于Java内存模型允许指令重排序,步骤3可能在步骤2之前执行。如果另一个线程在步骤3完成后、步骤2完成前尝试访问instance
,它看到的将是一个尚未完全初始化的实例。
解决方法
使用volatile
关键字修饰instance
变量。这不仅可以防止instance
变量在多个线程中的可见性问题,还能阻止指令重排序,确保在instance
指向分配的内存空间之前,对象已经被完全初始化了。
# 3.2 as-if-serial
as-if-serial语义:Java的as-if-serial语义确保了在单个线程中,不管怎样重排序(即编译器和处理器为了提高执行效率而做的优化),程序的执行结果不会改变。这意味着,单线程程序的行为就好像所有的操作都是按照代码中的顺序一个接一个地执行的。
- 单线程保证:在单线程环境下,即便发生了指令重排序,程序的行为和没有重排序时应该是一样的,保证了程序的执行结果不变。
- 依赖关系限制:如果代码之间存在逻辑上的数据依赖性,编译器和处理器在重排序时会保持这种依赖,确保正确的执行顺序。因此,不能随意重排序那些逻辑上相互依赖的操作。
// 这种情况肯定不能做指令重排序
int i = 0;
i++; // i递增,依赖于上一步的值
// 这种情况肯定不能做指令重排序
int j = 200;
j * 100;
j + 100;
// 这里如果出现了指令重排,可能会影响最终的结果 20100
2
3
4
5
6
7
8
9
# 3.3 happens-before原则
这个原则非常重要:它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的底层编译原理之中。
次序规则:
- 一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作;
- 前一个操作的结果可以被后续的操作获取。讲白点就是前面一个操作把变量X赋值为1,那后面一个操作肯定能知道X已经变成了1。
锁定规则:一个unLock操作先行发生于后面((这里的“后面”是指时间上的先后))对同一个锁的lock操作;
volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的“后面”同样是指时间上的先后。
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作
线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;可以通过Thread.interrupted()检测到是否发生中断。
线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。对象没有完成初始化之前,是不能调用finalized()方法的
JMM只有在不出现上述8中情况时,才不会触发指令重排效果。
不需要过分的关注happens-before原则,只需要可以写出线程安全的代码就可以了。
# 3.4 volatile
如果需要让程序对某一个属性的操作不出现指令重排,除了满足happens-before原则之外,还可以基于volatile修饰属性,从而对这个属性的操作,就不会出现指令重排的问题了。
volatile如何实现的禁止指令重排?
内存屏障概念。将内存屏障看成一条指令。
会在两个操作之间,添加上一道指令,这个指令就可以避免上下执行的其他指令进行重排序。
内存屏障(Memory Barrier)
- 内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
- 写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在缓存(store buffers)中的数据同步到主内存,也就是说当看到Store屏障指令,就必须把该指令之前的所有写入指令执行完毕才能继续往下执行。
- 读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在Load屏障指令之后就能够保证后面的读取数据指令一定能够读取到最新的数据。(实现了可见性)
为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型,happens-before 之 volatile 变量规则
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
普通变量
的读写操作没有强制要求及时与主内存同步,这可能会导致一个线程对变量的修改对其他线程不可见。
volatile
变量通过确保所有写操作都立即同步到主内存,以及所有读操作都直接从主内存获取最新值。
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile
写操作的前面
插入一个StoreStore屏障。 - 在每个volatile
写操作的后面
插入一个StoreLoad屏障。 - 在每个volatile
读操作的后面
插入一个LoadLoad屏障。 - 在每个volatile
读操作的后面
插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图,如图所示。
- 在
volatile
写之前插入的是StoreStore屏障,其主要目的是防止volatile
写之前的普通写(对普通变量的写操作)与volatile
写重排序。它确保了在volatile
写操作发生之前,所有之前的普通写操作都已经同步到主内存中。 - 在
volatile
写之后插入的是StoreLoad屏障,它的作用是防止volatile
写与之后可能发生的任何volatile
读/写操作重排序。它确保了volatile
写操作的结果对随后发生的volatile
读操作立即可见。
下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
- LoadLoad屏障:确保对
volatile
变量的读操作完成后,任何后续的普通读操作都能看到这次volatile
读操作前所有volatile
写入的最新结果。这防止了volatile
读之后的普通读操作与volatile
读重排序。 - LoadStore屏障:确保对
volatile
变量的读操作完成后,之后的写操作(普通写)必须在读取到volatile
变量的最新值之后执行。这防止了volatile
读之后的普通写操作与volatile
读重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
# 四、单例模式
单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象。
单例模式有两种类型:
- 饿汉式:在类加载时已经创建好该单例对象,等待被程序使用。
- 懒汉式:只有在真正需要使用对象的时候才去创建该单例类对象。
优点: 在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例(比如管理学院首页页面缓存)。
缺点: 没有接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。
# 4.1 饿汉式实现单例模式
public class Singleton {
// 提供一个对象的引用,并在类加载时就创建这个单例对象。这保证了线程安全性。
private static Singleton instance = new Singleton();
// 私有构造方法,防止外部通过new操作符创建对象实例。
private Singleton(){}
// 提供一个公共的静态方法,使得外部能够访问到创建的单例对象。
// 由于单例对象在类加载时已经创建并初始化好了,所以此方法直接返回那个创建好的单例对象。
public static Singleton getInstance(){
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4.2 懒汉式实现单例模式
采用了双重检查锁定(Double-Checked Locking) 机制以及volatile
关键字,来确保线程安全
- 第一次检查:检查
instance
是否为null
之前不需要加锁,提高了方法的性能。 - 加锁:只有
instance
为null
时,才进入同步代码块,进一步确保了线程安全。 - 第二次检查:在同步代码块内部再次检查
instance
是否为null
,防止多个线程同时通过外层的null
检查,进入同步代码块内部时重复创建实例。 - 同时使用
volatile
关键字避免了由于指令重排可能导致的安全问题。
public class Singleton {
// 提供一个对象的引用,并使用volatile关键字修饰以防止指令重排
private static volatile Singleton instance = null;
// 私有构造函数防止外部通过new创建实例
private Singleton(){}
// 提供一个静态的公共方法返回实例
public static Singleton getInstance(){
// 第一次检查,避免每次调用都需要进入同步代码块
if(instance == null) {
synchronized (Singleton.class) { // 同步块,确保线程安全
// 第二次检查,防止多个线程同时通过外层判断后创建多个实例
if (instance == null){
instance = new Singleton(); // instance变量使用volatile,确保写操作不会与之前的读操作重排
}
}
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 4.2.1 如果没有第二次检查会出现什么问题?
假设有两个线程A和B,它们同时调用getInstance()
方法尝试获取Singleton的实例。
- 线程A和线程B同时到达第一次检查,此时
instance
为null
,因此都判断条件为真,但是在进入同步代码块之前,它们需要等待获取到Singleton.class
的锁。 - 线程A比线程B稍微快一些,它首先获取到锁,进入同步代码块。在同步代码块内,它再次检查
instance
是否为null
(这是第二次检查),发现确实是null
,于是它创建一个新的Singleton实例,并将引用赋值给instance
。 - 线程A创建完实例后,它离开同步代码块,并释放了
Singleton.class
的锁。 - 现在,线程B获取到锁,进入同步代码块。如果没有第二次检查(即这里的
if (instance == null)
),线程B也会创建一个新的Singleton实例。这样,就会有两个Singleton实例被创建,违反了单例模式的原则。 - 但是,因为有第二次检查,线程B在同步代码块内再次检查到
instance
已经不是null
了(因为线程A已经创建了实例),所以线程B不会创建实例,而是直接返回第一次创建的那个实例。
总结
通过这个过程,我们可以看到第二次检查确保了即使多个线程同时通过了第一次的null
检查并进入了同步代码块,也只会有一个线程创建实例,其他线程在同步代码块内的第二次检查后不会创建实例,从而保证了单例。
# 4.2.2 如果没有加volatile会出现什么问题?
创建Singleton
实例的过程,本质上可以分解为以下三个步骤:
- 分配内存空间给Singleton实例。
- 初始化Singleton实例。
- 将
instance
引用指向分配的内存空间(此时instance
不再是null
)。
在没有volatile
修饰符的情况下,Java虚拟机的即时编译器在运行时可能会对这些操作进行重排序。例如,编译器可能会将步骤3和步骤2的顺序调换,这样重排序后的执行顺序可能变成1-3-2。
现在,假设有两个线程A和B,来看看在指令重排序的情况下会发生什么:
- 线程A开始执行
getInstance()
方法,到达同步代码块,并执行到重排序后的步骤1和步骤3:首先为Singleton实例分配内存空间,然后立即将instance
引用指向这块内存空间。此时,由于步骤3被提前执行了,instance
已经不是null
了,但实际上Singleton实例的构造函数还没有被调用,对象还没有被完全初始化。 - 在线程A还没有执行步骤2(即初始化Singleton实例)之前,线程B调用
getInstance()
方法。线程B在第一次检查时看到instance
已经不是null
(因为线程A在步骤3时已经将instance
指向了某块内存空间),因此线程B认为Singleton实例已经被创建,并直接返回instance
。 - 线程B接着使用这个实例,但这个实例实际上还没有被初始化(因为线程A还没执行步骤2),这可能导致程序崩溃或者错误的行为。
笔记
使用volatile
关键字修饰instance
变量可以防止这种指令重排序。当变量被volatile
修饰时,写操作会在读操作之前完成,确保在写instance
之前,对象的构造函数已经完全执行完毕,避免了未初始化实例的发布
# 4.3 参考文献
【多线程基础】单例模式:https://blog.csdn.net/m0_53882348/article/details/129691700 (opens new window)
以上便是本文的全部内容,本人才疏学浅,文章有什么错误的地方,欢迎大佬们批评指正!我是scholar,一个在互联网行业的小白,立志成为更好的自己。
如果你想了解更多关于scholar (opens new window) (opens new window),可以关注公众号-书生带你学编程,后面文章会首先同步至公众号。