并发基础

并发BUG的源头

并发的原因现在主流是三种,原子性、可见性、有序性。本质乃是计算机CPU、内存之间的问题。问题本身很复杂,才疏学浅只简单的讲下理解的。目的就是为了有个基本的概率,可以更好的理解并发。

可见性

一个线程对共享变量的修改,其他线程是否能够立即看到,就称为可见性。至于为什么会有这个问题,其实主要是因为缓存的问题导致的
在计算机中CPU并不是直接操作内存里面的数据,每个CPU本身也会缓存,CPU只会操作自己缓存的数据,然后CPU缓存和内存进行交互。多线程的情况就会出现缓存和主内存的数据不一致的问题!

image

CPU缓存和内存的关系图
其实在Java的内存模型中,也会出现这种情况,在下图中可以明显看到。JAVA的引用对象,每个线程在修改对象数据时,并不是直接修改内存的数据,而是每个线程都保存了一份实际内存的数据备份。这样也会可见性的问题

image

原子性

原子性的日常理解是:某个单位已经到了最小不可拆分的单位。就说明这个单位具有原子性。在编程中其实也比较好理解,比如 count += 1 就不具备原子性。因为我们都知道他是分为几步完成的

1
2
3
将count的数据从内存中取出  -->  1
count+1 --> 2
将count放回内存中 --> 3

这三部在执行过程中。都会出现中断或者多线程的原因其他线程中途也对count进行了操作。这一步操作并不是最小单位。

有序性

有序性,就是程序按代码编写的逻辑执行。但是实际情况下程序并不是按代码编写的逻辑执行的,为什么会这样?主要原因是编译优化,比如程序中: int a=1; int b = 2; 正常应该先做a,在做b。但是实际情况并不是确定,有可能b先做,但是在编译优化并不会影响程序在单线程中执行,多线程就不确定了。

在单例模式中的双重检查有一个很经典的例子。

Java内存模型

理解以上三种特性后,想要避免多线程的BUG,比如可见性是缓存导致的,我们就禁用缓存。有序性是编译优化导致的,我们就禁用编译优化。这样做确实可以减少很多并发的BUG,但是会导致性能大减。

合理方案应该是按需优化

现代计算机体系大部是采用的对称多处理器的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行。在Java中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。

Happens-Before

Happens-Before 原则主要是Java推出的一些规范。

  1. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面”是指时间上的先后顺序。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的”后面”同样是指时间上的先后顺序。
  4. 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

volatile

volatile 关键可以对象的可见性以及指令重排序,以为volatile修饰的变量在操作时会通过内存屏障来禁止指令重排序。而且其修饰的关键字在使用变量和更新变量时都会更新内存中的变量缓存和提交线程中的变量缓存。所以解决了可见性的问题,但是无法解决原子性的问题

主要使用用于标记状态、doubleCheck

final

final关键字申明的对象是无法再修改的,也就天然的保证了其可见性的问题,只有查看而没有修改。(注意final修饰引用对象时,对象内部的引用对象也是可以修改的)。

锁主要是解决其原子性的问题。后续会详细讲解