Java并发的基本概念

硬件层次

由于CPU执行指令的速度是很快的,但是内存访问的速度就慢了很多,相差的不是一个数量级,于是为了不让内存成为计算机程序处理的瓶颈,通过在CPU和内存之间增加高速缓存的方式来解决

缓存一致性

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致

解决缓存一致性的两种方案

  • 通过在总线加LOCK#锁的方式(现代计算机都是多核CPU,总线加锁会导致其他CPU也无法访问内存,效率低下)
  • 通过缓存一致性协议(Cache Coherence Protocol)

MESI缓存一致性协议

缓存一致性协议(Cache Coherence Protocol),最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的

MESI的核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取

在MESI协议中,每个缓存可能有有4个状态,它们分别是:

  • M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
  • E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
  • S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
  • I(Invalid):这行数据无效

MESI协议,可以保证缓存的一致性,但是无法保证实时性

处理器优化和指令重排

  • 处理器优化: 为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理
  • 指令重排: 除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排
  • 联想记忆到:
    1. Spark中不存在依赖关系的task并发执行优化计算
    2. 不存在资源竞争的程序并发执行,比如某个程序抢占IO资源,那么可以先去执行其他不抢占IO资源的程序,省却等待时间

并发编程中的三个概念

原子性

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行(联想记忆到数据库事务处理的原子性)

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

有序性

有序性即程序执行的顺序按照代码的先后顺序执行

其实,原子性问题,可见性问题和有序性问题。是人们抽象定义出来的。而这个抽象的底层问题就是前面提到的缓存一致性问题、处理器优化问题和指令重排问题等。
缓存一致性问题其实就是可见性问题,而处理器优化可能会造成导致原子性问题的,指令重排即会导致有序性问题

Java的内存模型

基本概念

  • Java的并发采用“共享内存”模型,线程之间通过读写内存的公共状态进行通讯。多个线程之间是不能通过直接传递数据交互的,它们之间交互只能通过共享变量实现。

  • JMM本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,规定所有变量都是存在主存中的,类似于普通内存,每个线程又包含自己的工作内存,类比高速缓存。所以线程的操作都是以工作内存为主,它们只能访问自己的工作内存,且工作前后都要把值在同步回主内存

ps: 联想到有点类似于缓存+DB的系统架构,所有变量都是存在主存中的,类似于DB, 每个线程又包含自己的工作内存,类比高速缓存

Java内存模型的实现

image.png

  • Java内存模型(JMM)规定所有变量都存储在主内存中,每个线程还有自己的工作内存:

    1. 线程的工作内存中保存了被该线程使用到的变量的拷贝(从主内存中拷贝过来),线程对变量的所有操作都必须在工作内存中执行,而不能直接访问主内存中的变量。
    2. 不同线程之间无法直接访问对方工作内存的变量,线程间变量值的传递都要通过主内存来完成。
  • Java线程之间的通信由内存模型JMM(Java Memory Model)控制:

    1. JMM决定一个线程对变量的写入何时对另一个线程可见。
    2. 线程之间共享变量存储在主内存中
    3. 每个线程有一个私有的本地内存,里面存储了读/写共享变量的副本。
    4. JMM通过控制每个线程的本地内存之间的交互,来为程序员提供内存可见性保证。
  • 内存间交互操作:

    1. lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
    2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    3. read(读取):作用于主内存变量,把主内存的一个变量读取到工作内存中。
    4. load(载入):作用于工作内存,把read操作读取到工作内存的变量载入到工作内存的变量副本中
    5. use(使用):作用于工作内存的变量,把工作内存中的变量值传递给一个执行引擎。
    6. assign(赋值):作用于工作内存的变量。把执行引擎接收到的值赋值给工作内存的变量。
    7. store(存储):把工作内存的变量的值传递给主内存
    8. write(写入):把store操作的值入到主内存的变量中

注意:主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分

Java中并发的实现

原子性的实现

可见性的实现

参考: 并发三特性-可见性定义、可见性问题与可见性保证技术

  • 通过volatile关键字标记内存屏障保证可见性。

  • 通过synchronized关键字定义同步代码块或者同步方法保障可见性。

  • 通过Lock接口保障可见性。

  • 通过Atomic类型保障可见性

  • 通过final关键字实现

    被final修饰的字段一旦初始化完成(静态变量或者在构造函数中初始化),并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其它线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值

有序性的实现

在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性

  • volatile关键字会禁止指令重排
  • synchronized关键字保证同一时刻只允许一条线程操作

happens-before原则

JMM具备一些先天的有序性,即不需要通过任何手段就可以保证的有序性,通常称为happens-before原则. 《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则:

  • 程序顺序规则: 即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行
  • 监视器锁规则:对一个线程的解锁,happens-before于随后对这个线程的加锁
  • volatile变量规则: 对一个volatile域的写,happens-before于后续对这个volatile域的读
  • 传递性:如果A happens-before B ,且 B happens-before C, 那么 A happens-before C
  • start()规则: 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
  • join()线程终止原则: 线程的所有操作先于线程的终结,如果A执行ThreadB.join()并且成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • interrupt()线程中断原则: 对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生
  • finalize()对象终结原则:一个对象的初始化完成先行发生于它的finalize()方法的开始

参考链接