1 硬件的效率与一致性
为了解决CPU与内存的速度差异,引入了高速缓存,如果有多个CPU而且他们又共享同一主存,所以引入了一个新的问题:缓存一致性。
为了解决这个问题,需要CPU在访问缓存时都要遵循一定的协议,比如MESI协议(文末有粗略的解释MESI协议,并未深入研究)。
2 Java内存模型
每条线程有自己的工作内存(类似于cache),工作内存中包括主存中数据的副本,但是不包括线程私有的局部变量和方法参数。如果局部变量是一个reference,他引用的对象在Java堆中被各个线程共享,但是reference本身在Java栈的局部变量表中是线程私有的。
类变量的线程不安全问题
即使一个变量是类变量(static修饰的),内存模型中的read是把这个位于”方法区“的类变量的值加载到工作内存,然后load操作把它赋值给工作内存的变量副本,然后做完操作再通过store和write写回主存。
3 volatile
关键字
特性
-
此变量对所有线程的可见,是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
- 新值立即同步到主存,而且每次使用之前都要去主存取值(
MESI
协议)
- 新值立即同步到主存,而且每次使用之前都要去主存取值(
-
是禁止指令重排序优化
第一个特性 可见性 还有两个关键字
synchronized同步块的可见性是由对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。
final
volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。一般情况下volatile的开销仍然要比锁低。
普通变量被volatile修饰同样存在线程不安全问题
/*
* 深入理解JVM虚拟机第三版作者 周志明
*/
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++; // 非原子性导致线程不安全
System.out.println(race);
}
private static final int threads = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[threads];
for (int i = 0; i < threads; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}}
});
threads[i].start();
}
// 等待所有累加线程都结束
while (Thread.activeCount() > 1)
Thread.yield();
System.out.println(race);
}
}
虽然变量race由volatile关键字修饰,但是由于
race++
的非原子性导致线程不安全,但是如下一段代码使用原子类就可以解决线程安全问题了。
/*
* 根据 深入理解JVM虚拟机第三版作者 周志明的代码修改版
*/
public class Main {
//int类型 换成 AtomicInteger类型
public static volatile AtomicInteger race = new AtomicInteger(0);
private static final int threads = 20;
public static void increase() {
race.getAndIncrement();
System.out.println(race);
}
public static void main(String[] args) {
Thread[] t = new Thread[threads];
for (int i = 0; i < threads; i++) {
t[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}}
});
t[i].start();
}
// 等待所有累加线程都结束
while (Thread.activeCount() > 1)
Thread.yield();
System.out.println(race);
}
}
以下场景仍需加锁或使用原子类:
volatile只保证变量的可见性,所以我们在类似场景下仍然需要使用锁或使用原子类。
禁止指令重排
创建对象的三个步骤
以 instance = new SingletonLazyInit();
为例,创建一个对象需要三个步骤:
-
给对象申请内存空间,申请好内存之后,对象中所有属性值都是默认值(int 类型为 0, boolean 为 false 等等)
-
给对象属性赋值
-
把对象的引用交给接收值,也就是引用交给 instance
指令重排序的影响下会可能导致 步骤2与步骤3 颠倒,导致instance中的属性值都是默认值,从而导致很罕见的程序错误
步骤二步骤三颠倒的话,恰巧有一个线程来执行if (instance == null){
即双空判断的第一个判断,现在虽然instance里边的变量没有赋初始值(还是默认值的状态),但是instance已经不指向null了,所以他跳过了双空判断直接返回一个内部都为空的实例,会发生很罕见的程序错误,不过这种概率仍然相当相当低!
/*
* @Author 郭学胤
* @University 深圳大学
* @Description
* 单例模式的双锁(DCL)
* DCL 需不需要 volatile
* 答案是需要加 volatile
*
*
要的。因为有可能会因为在巨量高并发的情况下因为指令重排序导致非常罕见的错误。
* @Date 2021/2/7 15:37
*/
public class L16_Volatile_Instruction_Reorder {}
/*
* 双空判断的饿汉式加载
* */
class SingletonLazyInit{
private volatile SingletonLazyInit instance;
Object lock = new Object();
private SingletonLazyInit(){}
//获取单例实例
public SingletonLazyInit getInstanceLazy(){
if (instance == null){
synchronized (lock){
if (instance == null){
instance = new SingletonLazyInit();
}
}
}
return instance;
}
}
被翻译成CPU指令之后如下所示:(盗用了周志明老师的翻译结果)
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000 # 给 instance 赋值的指令
0x01a3de1a: shr $0x9,%esi ;...c1ee09
0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00 # 这条指令保证了所有这条指令下边的命令都不能在指令重排序的时候
# 放到这条指令上边执行
;*putstatic instance
; - Singleton::getInstance@24
我的理解
这是一个简单到不能再简单的代码
public class NewObject {
public static void main(String[] args) {
Object o = new Object();
}
}
这是Java的字节码指令
0 new #2 <java/lang/Object> // 新开辟一块堆内存,这堆内存中的所有属性值都是默认值
3 dup
4 invokespecial #1 <java/lang/Object.<init>> // 调用 Object 的构造方法,给赋值初始值
7 astore_1 // 让 instance 指向这块内存
8 return
如果发生了重排序
0 new #2 <java/lang/Object> // 新开辟一块堆内存,这堆内存中的所有属性值都是默认值
3 dup
7 astore_1 // 让 instance 指向这块内存(指向了默认值)
// 正好有另一线程t2来到了双空判断,发现 instance 虽然指向了默认值的内存
// 但是不指向 null 了,创建对象的这个线程正好又被调度算法调离CPU,
// t2得到了默认值对象,程序有可能报错
4 invokespecial #1 <java/lang/Object.<init>> // 调用 Object 的构造方法,给赋值初始值
8 return
加了volatile
之后的伪字节码
0 new #2 <java/lang/Object> // 新开辟一块堆内存,这堆内存中的所有属性值都是默认值
3 dup
4 invokespecial #1 <java/lang/Object.<init>> // 调用 Object 的构造方法,给赋值初始值
//------------------------------------内存屏障分割线---------------------------------------------
7 astore_1 // 让 instance 指向这块内存
8 return
实验代码
实验进行30多个小时无果。
不知道各位有什么新思路,欢迎邮箱联系本人
guoxueyin@163.com
/*
* @Author 郭学胤
* @University 深圳大学
* @Description
* @Date 2021/2/20 12:51
*/
public class Reorder_test {
static Test test = new Test();
public static void main(String[] args) {
/*
* 一个线程死循环,不断更改test的引用
* */
new Thread(()->{
for (;;){
test = new Test();
}
}).start();
/*
* 万一发生指令重排序,Test 的 num 属性肯定是 0
* */
for (int j = 0; j < 48; j++) {
new Thread(()->{
for(;;){
if (test.num == 0)
System.out.println("Oops!");
}
}).start();
}
}
}
class Test{
int num = 10;
}
MESI协议
- Modified(M):
当一个CPU的缓存行的状态是M时,说明当前CPU最近修改了这个cache,那么其他CPU的cache不能再修改当前缓存行对应的主存,除非该cache将这个修改同步到了主存。这个CPU对这一块主存可以理解为Owned。 - Exclusive(E):
E这个状态与M很像,区别在于当前CPU并没有修改当前的缓存行,这意味着当前缓存行存储的主存location的值是最新的。当前CPU可以对该缓存行进行modify且不需要与其他CPU的cache同步。这个CPU对这块主存可以理解为Owned。 - Share(S):
S表示当前缓存行在其他CPU的cache也存在,当前CPU如果需要修改该缓存行则需要与其他CPU的cache进行提前沟通。 - Invalid(I):
I表示当前缓存行是空的。
缓存行的状态变化需要在各个CPU之间同步