在Java内存模型中,有三大性质:原子性、有序性和可见性。
原子性
熟悉数据库特性都知道数据库sql执行中也有原子性,数据库中的原子性是这样定义的在一个事务中要么所有的sql都执行,要么都不执行。
java内存模型中的原子性也是类似,要么所有的指令都执行,要么都不执行。这样才能保证并发操作的安全性和一致性。但是并发在带来方便的同时,却不能很好的解决原子性的问题。
操作系统为了提高并行处理问题的能力,会将时间分成一个个小的分片,例如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(称为“任务切换”)
针对同一个cpu,线程A获取到cpu的使用权后开始执行自己的任务,但是当时间超过一个时间分片之后需要将cpu的使用权让出来,此时线程B会占有cpu的使用权。但是如果此时线程A的任务没有执行完成,又进行了线程切换,此时线程A的操作就无法保证原子性了。下面举一个例子 针对代码 count+=1;至少需要三条 CPU 指令.
(1) 将count的值从内存中读取到寄存器中。
(2) 在寄存器中进行+1操作。
(3) 将结果写入内存中。
但是当上述三个过程中线程A 刚执行完步骤(1)后,进行了线程切换,线程B重新执行上述操作,最后内存中存储的数据是1而不是2。具体流程如图2所示。
以上便是线程切换带来的Java并发原子性问题。
可见性
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。 举例:
public class Test {
private int count = 0;
private void add() {
int idx = 0;
while(idx++ < 1000) {
count += 1;
}
}
public static long calc() {
final Test test = new Test();
Thread th1 = new Thread(()->{
test.add();
});
Thread th2 = new Thread(()->{
test.add();
});
th1.start();
th2.start();
th1.join();
th2.join();
return count;
}
}
启动了2个线程分别对一个从0开始的数字分别加1000次,最后返回的结果一定是介于1000–2000中间的数字。出现这个问题的原因是多线程操作时一个线程修改了内存中的数值时没有来得及通知其他线程所在cpu的缓存中。导致另外一个线程从缓存中读到的数据还是原来的数据,所以最后的结果达不到2000。这也就是并发问题中的可见性问题。
有序性
有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,但是不会影响最终的执行结果。有序性比较经典的例子就是利用双重检查创建单例对象。
在Java创建对象的时候主要进行以下几个步骤:
(1)分配一块内存区域W;
(2)在内存W上初始化 Singleton 对象;
(3) 然后 W 的地址赋值给 instance 变量。
但是Java在编译的时候会进行编译优化,把方法的执行顺序修改成
(1)分配一块内存区域W;
(2) 然后 W 的地址赋值给 instance 变量。
(3)在内存W上初始化 Singleton 对象;
如果线程A按照优化好的逻辑执行到第(2)步骤给开辟的内存地址分配给instance对象后,线程B的请求也打过来,此时getInstance()方法时 instance 是不等于null的,线程B会认为已经创建好单例对象,直接返回,后面的业务代码在进行对象操作的时候会出现空指针的问题。具体流程如下图所示。
|