java累加是原子性的么_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);

}

}

我们看下效果:线程数执行次数预期结果实际结果1010001000010000

5010005000046132

5010000500000420000

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

1.3 volatile不保证原子性分析

c91f57dae16f4955af847d9fe9c4f928.png

我们分析如下:

线程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原则;

54d3f35448b4b49b1d8cd6238c1984d3.png

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的执行特性,最大限度发挥机器性能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值