Yige

Yige

Build

Java并发的基本概念

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 () 方法的开始

参考链接#

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。