Java并发基础之内存模型
并发三问题
- 重排序
- 内存可见性
- 原子性
public class Test {private static int x = 0, y = 0;
private static int a = 0, b =0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for(;
;
) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(() -> {
try {
latch.await();
} catch (InterruptedException e) {
}
a = 1;
x = b;
});
Thread other = new Thread(() -> {
try {
latch.await();
} catch (InterruptedException e) {
}
b = 1;
y = a;
});
one.start();
other.start();
latch.countDown();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
观察代码可以发现,如果没有意外情况发生的话,在上下两个线程中,出现的结果应该下面三种情况
x= 0 ,y = 1;
x= 1 ,y = 1;
x= 1 ,y = 0;
但是在实际运行过程中,却最终有概率出现 x=0,y=0的情况。这种情况发生的原因就是出现了重排序。
重排序由一下几种机制引起:
- 编译器优化:对于没有数据依赖关系的操作,编译器在编译的过程中会进行一定程度的重排。
可以看到线程1中的代码,编译器是可以将a=1和x=b换一下顺序的,因为它们之间没有数据依赖关系,同理,线程2也一样,那就不难得到x==y==0的结果了。
- 指令重排序:CPU优化行为,也是会对不存在数据依赖关系的指令进行一定程度的重排
这个和编译器优化差不多,就算编译器不发生重排,CPU也可以对指令进行重排。
- 内存系统重排序:内存系统没有重排序,但是由于缓存的存在,使得程序整体上会表现出乱序的行为。
假设不发生编译器重排和指令重排,线程1修改了a的值,但是修改以后,a的值可能还没写回到主内存中,那么线程2得到a==0就是很自然的事了。同理,线程2对于b的赋值操作也可能没有及时刷新到主存中。
【Java并发基础之内存模型】线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。如果每个核心共享同一个缓存,那么也就不存在内存可见性问题了。
现代多核CPU中每个核心拥有自己的一级缓存或一级缓存加上二级缓存等,问题就发生在每个核心的独占缓存上。每个核心都将会自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些核心读取的值是一个过期的值。
在JMM中,抽象了主内存和本地内存的概念。
所有的共享变量存在于主内存中,每个线程有自己的本地内存,线程读写共享数据也是通过本地内存交换,所以可见性问题依然存在。这里说的本地缓存并不是真的是一块给每个线程分配的内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。
3.原子性
对于long和double,它们的值都需要占用64位的内存空间,Java编程语言规范中提到,对于64位的值的写入,可以分为两个32位的操作进行写入。本来一个整体的赋值操作,将被拆分为低32位赋值和高32位赋值两个操作,中间如果发生了其他线程对于这个值的读操作,必然会读到一个奇怪的值。
这个时候我们需要使用volatile关键字进行控制了,JMM规定了对于volatile long 和volatile double,JVM需要保证写入操作的原子性。
另外,对于引用的读写操作始终是原子的,不管是32位的机器还是64位的机器。
Java编程规范同样提到,鼓励JVM的开发者能保证64位值操作的原子性,也鼓励使用尽量使用volatile或使用正确的同步方式。关键词是“鼓励”。
在64位JVM中,不加volatile也是可以的,同样能保证对于long和double写操作的原子性。
Java对于并发的规范约束 Synchronization Order
- 对于监视器m的解锁与所有后续操作对于m的加锁同步
- 对于volatile变量v的写入,与所有其他线程后续对v的读同步
- 启动线程的操作与线程职工的第一个操作同步
- 对于每个属性写入默认值(0,false,null)与每个线程对其进行的操作同步。
两个操作可以用Happens-before来确定它们的执行顺序,如果一个操作happens-before于另一个操作,那么我们说第一个操作对于第二个操作是可见的。
如果我们分别有操作X和操作Y,我们写成hb(x,y),来表示 x happens-before y 。
- 如果操作x和操作y是同一个线程的两个操作,并且在代码上操作x先于操作y出现,那么有hb(x,y)。
- 对象构造方法的最后一行指令happens-before于finalize()方法的第一行指令。
- 对于操作x与随后的操作y构成同步,那么hb(x,y)
- hb(x,y)和hb(y,z),那么可以推断出hb(x,z)
synchronized关键字
一个线程获取到锁以后才能进入synchronized控制的代码块,一旦进入代码块,首先,该线程对于
共享变量的缓存就会失效
,因此synchornized代码块中对于共享变量的读取需要从主内存中重新获取,也就能获取到最新的值。退出代码块的时候,会将该线程写缓冲区的数据刷到主内存中。
推荐阅读
- JAVA(抽象类与接口的区别&重载与重写&内存泄漏)
- 事件代理
- Java|Java OpenCV图像处理之SIFT角点检测详解
- java中如何实现重建二叉树
- 数组常用方法一
- Python基础|Python基础 - 练习1
- 【Hadoop踩雷】Mac下安装Hadoop3以及Java版本问题
- Java|Java基础——数组
- RxJava|RxJava 在Android项目中的使用(一)
- java之static、static|java之static、static final、final的区别与应用