并发编程关键问题

JDK天生就是多线程的,多线程大大提速了程序运行的速度,但是凡事有利就有弊,并发编程时经常会涉及到线程之间的通信同步问题,一般也说是可见性、原子性、有序性。

线程通信

线程的通信是指线程之间通过什么机制来交换信息,在编程中常用的通信机制有两个,共享内存消息传递

  1. 共享内存。

在共享内存的并发模型中线程之间共享程序的公共数据状态,线程之前通过读写内存中的公共内存区域来进行信息的传递,典型的共享内存通信方式就是通过共享对象来进行通信。

  1. 消息传递,比如在Linux系统中同步机制有管道、信号、消息队列、信号量、套接字这几种方式。

在消息传递的并发模型中,线程之间是没有共享状态的,线程之间必须通过明确的发送消息来显式的进行通信,在Java中的典型通信方式就是wait()notify()

在C/C++中可以同时支持共享内存跟消息传递机制,Java中采用的是共享内存模型

线程同步

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。

  1. 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
  2. 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
JMM

现代计算机物理上的内存模型

缓存

了解JMM前我们先了解下现代计算机物理上的数据存储模型。侃侃JMM 助你面试锦上添花_经验分享随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,我执行一个任务一共耗时10秒,结果CPU获取数据耗时8秒,CPU计算耗时2秒,大部分时间都用来获取数据上了。

怎么解决这个问题呢?就是在CPU和内存之间增加高速缓存。缓存的概念就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。

以后程序运行获取数据就是如下的步骤了。侃侃JMM 助你面试锦上添花_经验分享_02并且随着CPU计算能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L2),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的,性能对比如下:侃侃JMM 助你面试锦上添花_经验分享_03

单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。侃侃JMM 助你面试锦上添花_经验分享_04

缓存一致性

随着计算机能力不断提升,开始支持多线程。那么问题就来了。我们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。

  1. 单线程。cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
  2. 单核CPU多线程。进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
  3. 多核CPU,多线程。每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致侃侃JMM 助你面试锦上添花_经验分享_05缓存一致性(Cache Coherence):在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,比如共享内存的一个变量在多个CPU之间的共享。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

不一致demo如下:

//线程A 执行如下
a = 1 // A1
x = b // A2
-----
// 线程B 执行如下
b = 2 // B1
y = a // B2

侃侃JMM 助你面试锦上添花_经验分享_06处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。

  1. 处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),a=1,b=2。
  2. 写操作a = 1,b = 2要经过A3跟B3 刷新到共享缓存才算完毕。
  3. 如果这一步在第二步执行执行了(A2,B2),x=b,y=a。程序就可以得到x=y=0的结果。
处理器优化和指令重排

上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。

除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java的JIT[1]

可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。硬件级别跟编译器级别都会对这些问题进行解决。

并发编程问题

前面说的都是跟硬件相关的问题,我们需要知道软件的基层是硬件,软件在这样的层面上运行就会出现原子性可见性有序性问题。其实,原子性问题,可见性问题和有序性问题。是人们抽象定义出来的。而这个抽象的底层问题就是前面提到的缓存一致性、处理器优化、指令重排问题。

一般而言并发编程,为了保证数据的安全,需要满足以下三个特性:

  1. 原子性:指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
  2. 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  3. 有序性:程序执行的顺序按照代码的先后顺序执行。

你可以发现缓存一致性问题其实就是可见性问题。而处理器优化是可以导致原子性问题的。指令重排即会导致有序性问题。

内存模型

前面提到的,缓存一致性、处理器优化、指令重排问题是硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢 为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是内存模型

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障

JMM

前面说到计算机内存模型是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。

我们知道,Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型,主要由JSR-133[2]: JavaTM Memory Model and Thread Specification 描述。简单形象图如下:侃侃JMM 助你面试锦上添花_经验分享_07JMM功能:

这是一种虚拟的规范,作用于工作内存主存之间数据同步过程。目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

PS:

这里面提到的主内存和工作内存(高速缓存,寄存器),可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。

任意的线程之间通信方式简单如下:侃侃JMM 助你面试锦上添花_经验分享_08在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,关于JVM具体的讲解参考以前博文[3],这里只给出大致架构图,细节部分都写过了。侃侃JMM 助你面试锦上添花_经验分享_09

JMM带来的问题
  1. 共享对象对各个线程的可见性

A 线程读取主内存数据修改后还没来得及将修改数据同步到主内存,主内存数据就又被B线程读取了。

  1. 共享对象的竞争现象

AB两个线程同时读取主内存数据,然后同时加1,再返回。

侃侃JMM 助你面试锦上添花_经验分享_10对于上面的问题无非就是变量用volatile,加锁,CAS等这样的操作来解决。

指令重排

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。比如:

code1 // 耗时10秒
code2 // 耗时2秒
----
如果code1跟code2符合指令重拍的要求,code2不会一直等到code1执行完毕再执行。

编译的源代码可能经过如下重排加速才是最终CPU执行的指令。侃侃JMM 助你面试锦上添花_经验分享_11

  1. 编译器优化的重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  1. 指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  1. 内存系统的重排序

处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行(处理器重排)

数据依赖跟控制依赖

重排序对于数据依赖性跟控制依赖性的代码不会重拍。

  1. 数据依赖 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性,这样的代码都不允许重排(重排后结果就不一样了)。数据依赖分下列三种类型:侃侃JMM 助你面试锦上添花_经验分享_12
  2. 控制依赖 flag变量是个标记,用来标识变量a是否已被写入,在use方法中比变量i依赖if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了。
public void use(){
   if(flag){ //A
      int i = a*a;// B 
      ....
   }
}
as-if-serial

不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念, as-if-serial语义的意思是:

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。

int a = 1//1
int b = 2;//2
int c = a + b ;// 3

1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。因此在最终执行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。asif-serial语义使单线程下无需担心重排序的干扰,也无需担心内存可见性问题

多线程下重排问题

比如下面的类中两个经典函数,如果AB线程分别同时执行不同的函数,

  1. 线程A对12指令重排,AB线程执行顺序为 2-3-4-1。
  2. 线程B对34进行了指令重排,先读取a值为0,然后计算出a*a= 0,临时存储下来,然后如果线程A执行完毕后导致use函数里的i最终是0。侃侃JMM 助你面试锦上添花_经验分享_13

解决在并发下的问题

内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。

  1. 保证特定操作的执行顺序。
  2. 影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:

  1. 不管什么指令都不能和这条Memory Barrier指令重排序。
  2. Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

目前有4种屏障.。

  1. LoadLoad 屏障

序列:Load1,Loadload,Load2  读 读 大白话就是Load1一定要在Load2前执行,及时Load1执行慢Load2也要等Load1执行完。通常能执行预加载指令/支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

  1. StoreStore 屏障  写 写

序列:Store1,StoreStore,Store2 大白话就是Store1的指令任何操作都可以及时的从高速缓存区写入到共享区,确保其他线程可以读到最新数据,可以理解为确保可见性。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。

  1. LoadStore 屏障  读 写

序列:Load1; LoadStore; Store2 大致作用跟第一个类似,确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

  1. StoreLoad 屏障  写 读

序列: Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。StoreLoad Barriers是一个全能型 的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

临界区

也就是加锁,运行两个函数的时候都加上相同的锁,这样就保证了两个线程执行两个函数的有序性,在同步方法里只要负责as-if-serial即可。侃侃JMM 助你面试锦上添花_经验分享_14

Happens-Before

因为有指令重排的存在会导致难以理解CPU内部运行规则,JDK用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。其中CPU的happens-before无需任何同步手段就可以保证的。

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(对程序员来说)
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的(对编译器和处理器 来说)

侃侃JMM 助你面试锦上添花_经验分享_15happens-before具体规则Mark下,以备不时之需。

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。7.线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。
volatile语义

volatile保证变量的可见性,同时还具有弱原子性。关于 volatile 以前博文写过细节不再重复,指令重排的时候对volatile规则如下:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。
  2. 在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障
锁内存语义

有点类似于重型版本的volatile,功能如下:

  1. 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。。
  2. 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
final内存语义

编译器和处理器要遵守两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。看代码备注1
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。看代码备注2
class SoWhat{
    final int b;
    SoWhat(){
        b = 1412;
    }
    public static void main(String[] args) {
        SoWhat soWhat = new SoWhat();
        // 备注1:禁止在 b = 1 这个语句执行完之前,系统将新new出来的对象地址赋值给了sowhat。
        System.out.println(soWhat); //A
        System.out.println(soWhat.b); //B
        // 备注2:A B 两个指令不能重排序。
    }
}

final为引用类型时,增加了如下规则:

在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

class SoWhat{
    final Object b;
    SoWhat(){
        this.b = new Object(); //  A
    }
    public static void main(String[] args) {
        SoWhat soWhat = new SoWhat(); //B
        // 含义是 必须A执行完毕了 才可以执行B
    }
}

final语义在处理器中的实现

  1. 会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore屏障。
  2. 读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。