多线程前置知识
多线程内存可见性
CPU和JVM的重排序
CPU及JVM为了优化代码执行效率,会对代码进行重排序,其中包括:
- 编译器重排序(没有先后依赖关系的语句,编译器可以重新调整语句执行顺序)
- CPU指令重排序(让没有依赖关系的多条指令并行)
- CPU内存重排序(
CPU有自己的缓存,指令执行顺序和写入主内存顺序不一致
)
其中CPU内存重排序
是导致内存可见性
的主因。根据JMM内存模型,我们描述下过程:
如果线程需要修改共享变量,那么线程A会拷贝共享变量的副本到本地线程中并对其进行修改
,之后会将值写回共享内存中(时间不确定),但在写回之前,线程B读取共享变量到本地准备修改,而此时线程A修改共享变量的操作对线程B不可见
。
重排序规则:
as-if-serial
不管怎么重排序,单线程程序的执行结果不能被改变
。只要操作之间没有数据依赖性,那么编译器和CPU都可以任意重排序。
happen-before(JVM层面)
为了明确多线程场景下那么可以重排序,哪些不可以重排序,引入了JMM内存模型,而JMM提供了happen-before
规范,用于在开发者编写程序和系统运行之间效率找到平衡点,它描述了两个操作之间的内存可见性,若A happen before B,如果A在B之前执行,则A的执行结果必须对B可见
。
- 单线程的每个操作,happen-before 于该线程中任意后续操作。
对volatile变量的写入,happen-before 于后续对这个变量的读取
。- 对于synchronized的解锁,happen-before于后续对这个锁的加锁。
- 对final域的写(构造函数中),happen-before于对final域所在对象的读。
happen-before传递性
假设线程A先调用了set(),设置了a=5,之后线程B调用了get(),返回一定是a=5。
class Test {
private int a = 0;
private volatile int c = 0;
void set() {
a = 5;// step 1
c = 1;// step 2
}
int get() {
int d = c;// step 3
return a;// step 4
}
}
因为step1和step2在同一块内存中,所以step1 happen-before step2,同理step3 happen before step4,且因为c是volatile变量,
根据volatile变量的写 happen-before volatile变量的读,以及happen-before传递性
,step1 的结果一定对step4可见。
volatile
作用
volatile保证了内存的可见性,对于共享变量操作会直接从共享内存中读取,修改时会直接将结果刷入共享内存,其次禁止了volatile修饰的变量和非volatile变量之间的重排序
。
原理
为了禁止编译器重排序和CPU重排序,底层原理是通过内存屏障
指令来实现的。
编译器内存屏障
只是为了告诉编译器不要对指令进行重排序,但编译完成后,这种内存屏障就消失了,CPU不会感知到编译器中内存屏障的存在。
CPU内存屏障
由CPU提供的指令(不同的CPU架构,提供的指令不同),可以由开发者显示调用,volatile就是通过CPU内存屏障指令来实现的。
实现流程:
- 在volatile写操作的前面插入一个
StoreStore屏障
。保证volatile写操作不会和之前的写操作重排序。 - 在volatile写操作的后面插入一个
StoreLoad屏障
。保证volatile写操作不会和之后读操作
重排序。 - 在volatile读操作后面插入一个
LoadLoad
屏障 +LoadStore
屏障。保证volatile读操作不会和之前的读操作、写操作
重排序。
与synchronized关键字的异同
多线程会产生三大问题:原子性、有序性和可见性。
synchronized和volatile在共享变量的操作上具有相同的内存语义(从主内存读取,立即写入主内存
),保证了变量的可见性。但是synchronized相比volatile还具有原子性(阻塞和排他性,同一时刻只能有一个线程执行,而volatile是非阻塞的)
,所以volatile是弱化版的synchronized
。
class Test {
// 这里的flag就可以不用锁同步
private static volatile boolean flag = true;
// 模拟AtomicInteger
private static CasUnsafe UNSAFE = new CasUnsafe(0);
// 按照顺序打印1-100的奇偶数
public static void main(String[] args) {
THREAD_POOL.execute(() -> {
while (UNSAFE.getValue() < 100) {
if (flag) {
System.out.println(UNSAFE.incrementAndGet());
flag = false;
}
}
});
THREAD_POOL.execute(() -> {
while (UNSAFE.getValue() < 100) {
if (!flag) {
System.out.println(UNSAFE.incrementAndGet());
flag = true;
}
}
});
THREAD_POOL.shutdown();
}
}
Q:什么时候用volatile而可以不用synchronized?
A:如果
写入变量值不依赖变量当前值(count++就是依赖当前值,先去内存读取值,然后将当前值+1,将计算后的值赋给count。比如)
,那么就可以用volatile。
DCL(Double Check Lock)
双重检查加锁问题简称DCL
,用于懒汉式单例的一种写法,问题如下所示:
public class DoubleCheckSingleton {
/**
* 为什么这个地方要使用volatile修饰?
*
* 首先我们需要了解JVM是存在`编译器优化重排`功能的(编译器在不改变单线程语义情况下,重新安 * 排代码的执行顺序。但是不保证多线程情况)
* 执行如下代码
* singleton = new DoubleCheckSingleton();
* 在JVM是分成三步的:
* 1. 开辟空间分配内存
* 2. 初始化对象
* 3. 将singleton引用指向分配的内存地址
*
* 在不使用volatile时,可能被JVM优化成
* 1. 开辟空间分配内存
* 3. 将singleton引用指向分配的内存地址
* 2. 初始化对象
*
* 那么当线程A执行1&3步的时候,线程B获取了CPU执行权,去验证`null == singleton`,
* 发现不为null,直接返回一个未初始化完成的singleton,导致程序出错。
*
* volatile禁止被修饰变量的 编译器重排序 和 处理器重排序(内存屏障) (JDK1.5后)
*
*/
private static volatile DoubleCheckSingleton singleton;
private DoubleCheckSingleton() {
}
public static DoubleCheckSingleton getInstance() {
// 不是任何线程进来都尝试去获取锁,而是先判断singleton是否为null,优化性能
if (null == singleton) {
// 尝试去获取锁,保证线程安全
synchronized (DoubleCheckSingleton.class) {
// 获取锁后判断singleton是否为null
if (null == singleton) {
singleton = new DoubleCheckSingleton();
}
}
}
return singleton;
}
}
MESA
在解释MESA模型之前,我们需要了解什么是管程:又称为监视器,它是描述并实现对共享变量的管理与操作,使其在多线程下能正确执行的一个管理策略。可以理解成临界区资源的管理策略。
MESA模型是管程的一种实现策略,Java使用的就是该策略。
相关术语
- enterQueue:
管程的入口队列
,当线程在申请进入管程中发现管程已被占用,那么就会进入该队列并阻塞。 - varQueue:
条件变量等待队列
,在线程执行过程中(已进入管程),条件变量不符合要求,线程被阻塞时会进入该队列。 - condition variables:条件变量,存在于管程中,一般由程序赋予意义,程序通过判断条件变量执行阻塞或唤醒操作。
- 阻塞和唤醒:wait()和await()就是阻塞操作。notify()和notifyAll()就是唤醒操作。
模型概念图
Synchronized和Lock在MSEA监视器模型中的区别在于
前者只有一个条件变量,后者可以有多个
。
执行流程
- 多个线程进入
入口等待队列enterQueue
,JVM会保证只有一个线程能进入管程内部,Synchronized中进入管程的线程随机。 - 进入管程后通过条件变量判断当前线程是否能执行操作,如果不能跳到step3,否则跳到step4。
- 条件变量调用
阻塞
方法,将当前线程放入varQueue,等待其他线程唤醒,跳回step1。 - 执行相应操作,执行完毕后调用notify/notifyAll等唤醒操作,唤醒对应varQueue中的一个或多个等待线程。
- 被唤醒的线程会从varQueue放入enterQueue中,再次执行step1。
被唤醒的线程不会立即执行,会被放入enterQueue,等待JVM下一次选择运行,而正在运行的线程会继续执行,直到程序执行完毕。