学习笔记(1)——并发编程BUG来源

目录

1.缓存导致的可见性问题

2.线程切换导致的原子性问题

3.编译优化带来的有序性问题

4.volatile


不管是什么语言,并发编程都是难点,知识点也比较零散。大神Doug Lea也没具体的发过什么相关论文。总之要啃这么难啃的饼,我们没有什么捷径可走,只有弄清楚原理,原因才能从根本上解决问题。

随着CPU,内存,I/O设备的不断更新换代,我们的计算机性能越来越强大。但是有个核心的矛盾依然存在:CPU,内存,I/O设备之间的速度差异。内存读写速度和CPU速度有着天壤之别,而磁盘I/O又和内存有着天壤之别。对于一个程序,它的短板就是I/O设备,所以就算你用什么神仙级别的CPU只要其他两者的性能达到瓶颈,也都是浪费!

我们为了调和三者矛盾,为了弥补短板,我们有了操作系统,计算机体系,编译程序等。

  1. CPU增加了缓存来平衡内存速度的差异
  2. 操作系统有了进程,线程可以复用CPU,进而平衡了CPU和I/O设备之间的速度差异
  3. 编译程序会对CPU指令顺序进行优化,使得缓存能更加合理的应用。

但是我们在坐享这些成果的同时,也遇到了并发带来的难题。这些问题的根源答题有三种:

1.缓存导致的可见性问题

单核情况下,CPU缓存中的变量值和内存中是一样的,并发环境下,所有的线程操作的是同一CPU的缓存,所以他们之间的操作是可见的。如下图,A线程更改了CPU缓存中的变量V,那么B线程读到的值肯定是A改过的。这就是可见性。

但是单核的时代已经过去了,现在的电脑都是什么四核八核。那就有点悲催了,简直就是灾难,是硬件工程师给我们软件工程师挖的大坑。如下图所示,A线程和B线程,分别访问不同的CPU,那么线程A对CPU1做了什么,线程B哪儿知道去?

那么,问题就来了。来一段程序:


public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行add()操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

单核环境下我们就可以预测到,最后返回的count肯定是20000。

但是多核环境下,运行一下就不知道了,多运行几次发现返回的其实是一个小于等于20000的随机数。为什么呢?

两个线程都读了内存中的值到缓存,然后都+1后写内存,我们本来预期的是2的,但是另一个线程作怪它没意识到前一个线程已经+1了,然后它还是执行给自己读到缓存中的数+1后写内存,相当于重复干了同一件事。就相当工作中两个同事不相互沟通,闭门造车,结果另个人忙活了一整天,最后提交代码的时候发现,两个人开发的是同一个功能,一个把一个的代码覆盖了,他们其中一个人等于白忙活了。面对这样的问题,解决方法我们先不做讨论。

2.线程切换导致的原子性问题

我们现在所熟知的线程切换,进程切换,概念其实很简单。但是不要小看它,这个概念的出现是具有划时代意义的。有个专业的词叫做“分时复用”,Unix就因为决绝了这一问题而著名。

什么是分时复用?OS允许某个线程或者某个进程运行一小段时间,过了这一小段时间,然后让它停止执行,再选择让其他进程运行。

一个进程在进行IO操作时,比如读取文件,它会把自己标记为休眠状态,然后会让出CPU给其他进程运行,当文件读取完成时,OS会将这个休眠的进程唤醒。这里之所以让出CPU是为了能够让CPU在这段空闲的时间里去做其他的事情。其实单核CPU中是没有真正的并发的,但是我们还是能够边敲代码边听歌,为什么?就是因为“任务切换”,是的这就是“分时复用”也可以称作“任务切换”。然后在进程进行IO操作的时候,另一个进程如果也要进行IO操作的时候,OS会让新的进程进行排队,这样当磁盘驱动完成一次IO后就立马让排队的进程进行下次IO操作,这样能提高效率。

但问题是这回带来什么问题呢?
我们平时喜欢用 count++;这个语句来在循环或者其他地方计数,当然我们现在不关心它的功能。我们说说它的原理。其实这一条高级语言语句,其实到最后会被编译或者解释成三条CPU指令来执行的,他们分别是:

  1. 将count从内存中加载到寄存器
  2. 在内存中执行++
  3. 将自增长后的值写入内存(缓存机制可能导致写入的是CPU缓存,而非内存)

我们平时写代码下意识认为count++是一个原子操作,就是完整的一步操作,但是直觉骗了我们。所有高级语言最终都会编译成CPU指令去执行。只有每一条CPU指令才是原子操作。所以既然count++这条语句是非原子性(我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性)操作,那在线程切换的时候其他线程也执行这条语句的时候,CPU指令就会存在乱序执行的风险,执行得到的结果就不是我们想要的,我们叫这个线程不安全。

3.编译优化带来的有序性问题

我们先来看一段代码:


public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

我们先预想一下,跟读下代码:

假设两个线程A and B,如果A线程抢到了锁,它会执行初始化实例的代码。然后B没抢到锁,进入就绪状态,等A释放锁,然后B去抢,当B进去的时候就没必要再创建对象,直接用A创建好的。多省事儿啊,一切都似乎很完美。这也是单例模式的设计思路。

然而世事哪有完美。事实上我们知道,一个创建对象的语句new Singleton(),编译后会被转成三条CPU指令来完成的:

  1. 堆里面开辟出一个空间
  2. 在开辟的空间里创建一个对象
  3. 将对象的门牌号(对象地址)赋值给instance这个引用变量

但,别以为这就完了,这还不是全部的真相。事实上,是的又是事实上,经过编译优化,指令的顺序并非上面说的那个顺序,而是这样:

  1. 堆里面开辟出一个空间
  2. 将对象的门牌号(对象地址)赋值给instance这个引用变量
  3. 在开辟的空间里创建一个对象

那么问题就来了,单核CPU的情况下,A抢到锁后进行初始化,但正好在顺序2的时候让出了时间片,发生了线程切换。这时候OS将时间片分给了B,B对实例进行了第一次判断,结果发现,这个引用变量并不是null(因为第2条指令给它赋值了),但是B将获取到的变量值返回后就抛出了NullpointerException。为什么呢?因为这个对象还没创建完成,指令3没有执行。

线程B:我在哪儿?我是谁?发生了什么?

多核Cpu的情况类似,当A线程执行到指令2的时候,又来了个线程B调用getInstance()方法,也进行了第一个判断,发现变量不等于null,于是它也没办法进入锁住的代码块,就直接返回了。造成了相同的后果。

我们说要避免这种情况发生,会用volatile 修饰变量Singleton instance;我们知道volatile修饰的变量对其他线程是具有可见性的。那么volatile的原理是什么呢?为什么能保证可见性呢?

4.volatile

为什么用volatile就能达到对象在线程间的可见性呢?那是因为volatile关键字可以防止指令重排序。so...我们应该先明白什么是指令重排

指令重排序:是JVM在不影响单线程执行结果的前提下,为了优化性能,对编译后的CPU指令进行重新排序。

但是指令重排是把双刃剑,性能是优化了,但引出了上面我们说的问题。JMM为了针对上述问题制定一一个原则,那就是 happens-before,看字面意思我们就知道,它是在说:

  1. A线程happens-before B线程,那么线程A对共享资源的操作应该对B是可见的,线程A的执行顺序在线程B之前。
  2. 如果AB互为happens-before关系,那么就不一定要安规则来执行,如果重排后执行的结果和按规则来执行的结果一致,就是正常的。

好像说的也不是很明白,这个问题我们先不在这儿具体的说了。这个地方我们只说volatile有禁止指令重排的作用就可以了。原理后续博客再讨论。

注:文中图片来自极客时间,王宝令老师的《Java并发编程实战》课程

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值