Java并发-并发三大特性之原子性跟可见性

摘要

我们之前讲解了JMM的8大原子操作(lock、unlock、read、load、use、assign、store、write) 以及基于这些操作的并发3大特性:可见性、原子性、有序性中的可见性原理中volatile可见性保障原则。本节再主要讲解下原子性跟有序性。

思维导图

内容

1.原子性

1.1 原子性概念

引入: 中午去食堂打饭,假设你非常非常的饥饿,需要一荤两素再加一份米饭。如果食堂打饭的阿姨再给你打一个菜的时候,被其他人打断了,给其他人打饭,然后再回过头给你打饭。你选一荤两素再加一份米饭打完的过程被打断了5次耗时20分钟。你想想你自己的感受。是不是要疯了,要暴走了!其实,如果把从你点菜到阿姨给你打完饭这个过程,看着计算机的一个线程执行过程的话那么在你点菜到你拿到饭菜这个过程是一个完整的,不能被打断的,这就是所谓的原子性。如果被多次打断的话想想你的心理,就知道程序如果在执行过程被打断后的结果了。

原子性操作定义: (操作从头到尾不可分割)
所谓的原子性操作就是线程对变量的操作一旦开始,就会一直运行直到结束。中间不会因为其他原因而切换到另一个线程。操作是不可分割的,在执行完毕之前是不会被其他任务或是事件中断的。一个操作或者是多个操作要么执行都成功要么执行都失败(可以结合数据库的原子性理解)。

1.2 volatile原子性问题

我们使用volatile修饰一个主存中共享变量:counter;在多线程下去修改这个数据。(10个线程去操作数据,每个线程累加10000此,按照原子性定义的话,每一个线程操作应该是从read到store整个过程数据操作是不可打断,不可分割的话,结果就会为:100000);代码如下:

public class VolatileAtomicTest {
    private volatile static int counter=0;
 public static void main(String[] args) {
        /**
 * 原子性场景引入 */ for(int i = 0;i<10;i++){
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter++;//不是一个原子操作;内存counter++可能会被同时多个线程去++,而不是每一个线程原子操作完之后,其他线程再去原子操作。
 //其他代码段 }
                System.out.println("线程:"+Thread.currentThread().getName()+" 修改后的值counter: "+counter);
 },"线程"+i);
 thread.start();
 }
        try {
            Thread.sleep(5000);
 }catch (Exception e){
            e.printStackTrace();
 }
        System.out.println("counter:"+counter);
 }
}

我们看下效果:

线程数 执行次数 预期结果 实际结果
10 1000 10000 10000
50 1000 50000 46132
50 10000 500000 420000

从上面表格中我们可以看到,即时共享变量用volatile修饰了。但是随着线程数量或者执行次数的增加,实际运行结果与预期结果相差越来越大。如果预期结果和运行结果一致则说明保证了原子性,但是从结果来看不是这样的。从而证明了volatile的第二个特性:不能保证原子性。

1.3 volatile不保证原子性分析

我们分析如下:
线程1跟线程2都是通过主内存将数据加载到各自工作内存;加载之后线程数据之间不共享,线程通信只能通过主内存通信,所以线程1跟线程2都可能改成为1;线程1跟线程2先后将counter=1同步到主内存时候可能将之前的值覆盖掉了。volatile是根据缓存一致性协议实现的;数据有MESI四种状态:M(修改)、E(独占)、S(共享)、I(无效)。

分析如下:
刚开始线程1从主内存加载数据:counter=0到线程1的工作内存,此时counter在本地缓存中有效,状态是读占E、本地工作内存跟主存数据一致;然后在总线上嗅探这个缓存行是否被其他线程/cpu修改;然后线程2加载counter=0加载到本地工作内存,并嗅探总线;此时线程1嗅探到其他线程加载了这个数据,然后将其状态修改为S,同理线程2也是S状态,本地工作内存数据有效,并且跟主内存数据不一致。然后线程1修改值变成M状态;通知其他总线修改了这个值,其他线程嗅探到修改了这个值之后,本地缓存变成I(无效)状态。但是此时操作已经执行了(程序计数器已经将线程2对应的指令执行完毕了),所以加1没有加上。循环次数减了。

所以结果可能不等于100000原因。

引出的坑: 假如我们线程2已经counter失效了,此时需要从主内存读取数据的时候,不能保证此时线程1数据(M状态)修改后的数据就立马写会到了主内存。此时线程2如果需要加载数据的时候,会延迟等待线程1将修改后的新值刷新到我们主内存中之后读取。但是此时cpu是不会傻傻等着线程1去刷新数据后执行运算。假如我们上面counter++后面还有别的其他代码段。再不影响counter值的指令他会继续执行;这个时候就会涉及到指令重排。有序性问题。

2.有序性

2.1 有序性问题引入

上一节目我们说过,线程2不会等待线程1把修改后的结果刷新到主存之后才去执行counter++ 后面的代码, 所有为了优化性能,cpu会进行指令重排;只要不影响counter相关值依赖的运算结果下,其他的代码段就会移动到我们的counter++前面去执行,就进行这么一个指令重排。原则上这些指令是顺序地加载到我们工作内存里面去的。也就是说指令会加载到我们的缓存区里面区,cpu发现我们的counter++操作指令操作需要等待一段时间片区读取的话会严重影响我们性能,所以我们cpu不会等待执行完counter++之后再去执行其他操作,而是会将其他操作在counter++前面区执行,也就是指令重排;当然指令重排需要遵循一些规则:as-if -serial原则;

2.2 生活例子

指令重排序的生活例子

去餐厅吃饭预定位置的的时候。假设要去A餐厅吃饭,A餐厅有前台B、服务员C以及老板D。如果就只有你一个人去吃饭的时候,你给前台或者给服务器或者给老板说一声把2号桌预定了,半小时后过来。餐厅在为了2小时内就你一个人去吃饭。那么OK,没问题,别说等半个小时,就是等一个小时,2号桌还是你的。

但是,如果现在是吃饭高峰期,很多人来吃饭,你给前台说了,前台忙着没有及时给服务员或者没有给老板说,这个时候有个路人甲来吃饭,刚好看到2号桌没人,老板或者服务员就让他就坐2号桌吃饭了。那么,等你过来的时候,2号桌已经有人了。这个时候对于你来说,这个结果就不是你想要的了。

上面案例,如果从计算机执行指令角度来分析的话,你要到2号桌吃饭,这是预期结果。餐厅A就相当于是处理器,前台B就相当于是编译器,服务员C和老板D就是指令和内存系统。如果你预定的时间点不是吃饭高峰期或者没有人去餐厅A吃饭。那么你就相当于是一个线程。就是单线程的。老板、前台、服务员怎么安排都可以。因为只有你一个2号桌肯定是你的。这是单线程情况下。预期结果与实际结果就是一致的。

如果你预定的时间点是吃饭高峰期,很多人来吃饭(很多线程),这个时候为了餐厅效益,无论是前台还是服务员或者是老板都会对你的位置进行重排序。在你没有来的时候,会安排其他人到你预定的位置吃饭。如果其他人在你的位置吃饭,这个时候你再来吃饭,那么实际结果和预期结果就不一样了。这个时候餐厅应该做出相应的赔偿。为了解决这种赔偿问题,老板就想到了一个方案。做个牌子放在客人预定的桌子上。

当前台或者是服务员或者是老板看到餐桌上放的这个牌子,就知道这个位置不能再调动了。其中这个放在餐桌上的牌子就是特殊类型的内存屏障了。

2.3 指令重排

as-if-serial语义: 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守:as-if-serial语义。

指令重排序: java语言规范规定了JVM线程内部维持顺序化语义。即只要程序的最终结果与他顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,次过程叫做指令重排序。

指令重排序的意义是什么?

JVM能根据处理器特性(CPU多级缓存系统,多核处理器等)适当的对机器指令重排序,使机器指令能更符合cpu的执行特性,最大限度发挥机器性能。

你可能感兴趣的:(java并发)