死磕并发之可见性、有序性、原子性
原子性
原子性(Atomicity):一个操作是不可中断的,要么全部执行成功要么全部执行失败。
在典型的转账场景中,入账和出账操作要么同时成功,要么同时失败。其操作过程是一个整体不可分割,这种操作就是原子性的操作。
int a = 1;//1
a++;//2
int b = a;//3
通过上面的伪代码来解释下什么是原子性操作。首先java内存模型中定义了8中操作都是原子的,不可再分的(java内存模型-内存间的八种同步操作)。而八种操作执行期间可能会被插入其他操作。所以八种操作的组合并不是原子性的。
第一行,是将1赋值给线程工作内存的变量a。执行了assign操作,该操作是原子性的不可在分的。
第二行,实际执行的是 读取变量a,执行了use。cpu执行加1操作,将cpu运算完成的值写回变量a,执行了assign操作。时间上是三个操作,由于执行了3条指令所以并不是原子性的。
第三行,实际执行的是 读取变量a,赋值给变量b。由于执行了2条指令所以该操作也不是原子性的。
注:可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。在无其他线程干扰的情况下单线程的原子性JMM是可以保证的。
可见性
可见性(Visibility):所有线程都能看到共享内存的最新状态。
上一章我们讲到java内存模型的概念,线程之间的通信是通过主内存来交互的,而每个线程都拥有自己的工作内存,工作内存中保存的是主内存变量的副本。而对变量的操作通常发生在工作内存中。而当某个线程的工作内存对这个变量进行修改时并没有及时同步到主内存中,这种情况下该变量对于其他线程来说是不可见的。
@Slf4j
public class Visibility {
public static boolean a = true;
public static void main(String[] args) throws Exception {
log.info("我开始了");
ExecutorService executorService = Executors.newCachedThreadPool();
//线程开始
executorService.execute(() -> {
while(a){
}
log.info("我退出了");
});
Thread.sleep(100);
a = false;
}
}
在当前状态下执行该代码产生的结果如下
18:23:34.968 [main] INFO visibility.VisibilityExample - 我开始了
我们可以看到程序并没有停止执行。 我们明明将flag设置为true了 为什么没有被看见呢。这说明这个flag只是在工作内存中改变了并没有同步到主内存中。
如果我们将flag添加一个关键字
private static volatile boolean a = false;
那么当该值修改时会强制执行写入主内存的操作。那么程序就是可以结束的
18:27:50.032 [main] INFO visibility.VisibilityExample - 我开始了
Disconnected from the target VM, address: \'127.0.0.1:60922\', transport: \'socket\'
18:27:50.143 [Thread-0] INFO visibility.VisibilityExample - 我退出了
有序性
有序性(Ordering):数据不相关的变量在并发的情况下,实际执行的结果和单线程的执行结果是一样的,不会因为重排序的问题导致结果不可预知。
在《CPU缓存一致性协议MESI》中我们讲到缓存在执行一致性时通知其他缓存是需要时间的,而这个等待时间远比一个指令的执行时间要长。cpu会将这个指令的执行放入缓冲区。然后自己执行下一条指令,这是内存系统的重排序,比如缓存和读写缓冲区导致的重排序。还有编译级别的重排序,比如编译器的优化以及指令级重排序,比如CPU指令执行的重排序。依据jmm规范java是天然有序的,当然这种有序只能是在本线程内部观察、超出了本线程的范围去观察另外一个线程,那么所有操作都是无序的。
在写有序性的demo之前先了解下happen-before规则
happen-before定义
-
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。
-
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
happen-before规则
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
并非严格按照该规则执行,否则就不存在乱序的问题了,只是在单线程状态下乱序之后的结果和乱序之前的并无差别
锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作.
同一个锁想要锁定必须先释放之后或者处于释放状态下才能再次锁定。
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
后面会专门写一章关于volatile的来解释这个规则。
volatile
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
执行了start方法后才可以继续执行线程中的代码。
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
只有执行了interrupt()方法之后,才能被检测到该线程被中断。
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
线程终止的时候线程内部的功能执行完毕。
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
finalize() 调用在对象销毁之前,如果对象没有初始化那么久无法调用对象的销毁
finalize
@Slf4j
public class SimpleHappenBefore {
/**
* 这是一个验证结果的变量
*/
private static int a = 0;
/**
* 这是一个标志位
*/
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
//由于多线程情况下未必会试出重排序的结论,所以多试一些次
for (int i = 0; i < 1000; i++) {
ThreadA threadA = new ThreadA();
ThreadB threadB = new ThreadB();
threadA.start();
threadB.start();
//这里等待线程结束后,重置共享变量,以使验证结果的工作变得简单些.
threadA.join();
threadB.join();
a = 0;
flag = false;
}
}
static class ThreadA extends Thread {
@Override
public void run() {
a = 1;
flag = true;
}
}
static class ThreadB extends Thread {
@Override
public void run() {
if (flag) {
a = a * 1;
}
if (a == 0) {//如果顺序执行此处永远不可能为0
//触发到这里说明ThreadA中的2条赋值语句并不是按照顺序执行的。
System.out.println("ha,a==0");
}
}
}
}
这里有两个共享变量:a和flag,初始值分别为0和false.在ThreadA中先给a=1,然后flag=true.
如果按照有序的话,那么在ThreadB中如果if(flag)成功的话,则应该a=1,而a=a*1之后a仍然为1,下方的if(a==0)应该永远不会为真,永远不会打印.
但实际情况是:在试验100次的情况下会出现0次或几次的打印结果,而试验1000次结果更明显,有十几次打印.
指令执行的可能情况
以上5种方式都不能进入if(a0)方法
那么只有线程a中的2个赋值语句重排序才有可能出现a0的情况