第 12 章:Java 内存模型与线程

本文最后更新于:1 年前

多任务处理产生的原因:

  • 充分利用计算机处理器的运算能力计算机的运行速度与它的存储和通信子系统的速度差距太大,大量的时间都花费在硬盘 I/O、网络通信或者数据库访问上
  • 一个服务端要同时对多个客户端提供服务。

硬盘 I/O 优化:

  • 高速缓存:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。
    【引入问题】缓存一致性:在共享内存多核系统在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存中,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
    【解决问题】需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。
    内存模型:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
  • 处理器对输入代码进行乱序执行优化,并将执行后的结果重组,保证该结果与顺序执行的结果是一致的。

Java 内存模型:主要目的是定义程序中各种变量的访问规则。

  • 原子性:操作是不可再分的。
    • 规定了所有的变量都存储在主内存中。
    • 每条线程有自己的工作内存
      • 工作内存保存了被该线程使用的变量的主内存副本
      • 线程对变量的所有操作都必须在工作内存中进行。
      • 不同线程之间无法访问对方工作内存中的变量。
    • 线程间变量值的传递均需要通过主内存来完成。
    • 主内存工作内存之间的操作Java 虚拟机实现时必须保证每一种操作都是原子的
      • lock:作用于主内存的变量。把一个变量标识为一条线程独占的状态。
      • unlock:作用于主内存的变量。释放一个变量。
      • read:作用于主内存的变量。把一个变量的值从主内存传输到线程的工作内存中。
      • load:作用于工作内存的变量。把上一个变量的值放入工作内存的变量副本中。
      • use:作用于工作内存的变量。把一个变量的值传递给执行引擎。
      • assign:作用于工作内存的变量。把一个从执行引擎接收的值赋给工作内存的变量。
      • store:作用于工作内存的变量。把一个变量的值从线程的工作内存传输到主内存中。
      • write:作用于主内存的变量。把上一个变量的值放入到主内存的变量中。
    • long 和 double 的非原子性协定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现自行选择是否要保证 64 位数据类型的 load、store、read、write 操作的原子性。
    • 更大范围的原子性保证:字节码指令 monitorenter 和 monitorexit,对应的就是 Java 代码中的同步块 synchronized 关键字。
  • 可见性:当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
    • volatile:Java 虚拟机提供的最轻量级的同步机制。
      volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行
      • 在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值。
        • 【存在的问题】volatile 变量在各个线程的工作内存中是不存在一致性问题的,但是 Java 里面的运算操作符并非原子操作,这导致 volatile 变量的运算在并发下一样是不安全的。
          【解决问题】在不符合以下规则的运算场景中,使用加锁synchronized、java.util.concurrent 中的锁或原子类来保证原子性。
          • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
          • 变量不需要与其他的状态变量共同参与不变约束。
    • synchronized:对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中。
    • final:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看见 final 字段的值。
  • 有序性:
    • 线程内天然的无须任何同步手段保障就能成立的先行发生原则衡量并发安全问题时以此为准,不以操作时间的先后顺序为准:Java 内存模型中定义的两项操作之间的偏序关系。
      • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
      • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
      • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
      • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。
      • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。
      • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
      • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
      • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
    • 线程之间操作的有序性:
      • volatile:禁止指令重排序处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理
      • synchronized:一个变量在同一时刻只允许一条线程对其进行 lock 操作。

线程:比进程更轻量级的调度执行单位,目前是 Java 里面进行处理器资源调度的最基本单位Loom 项目有纤程 Fiber

  • 实现线程的 3 种方式:
    • 【HotSpot】使用内核线程实现(1:1 实现):
      • 直接由操作系统内核支持的线程,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
      • 程序一般不会直接使用内核线程,而是使用内核线程的高级接口——轻量级进程也就是线程
      • 劣:
        • 切换、调度代价高:用户态与核心态之间的状态转换转换开销主要来自于响应中断、保护和恢复执行现场的成本
        • 需要占用内核资源。
    • 使用用户线程实现(1:N 实现):
      • 完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现。
      • 优:不需要系统内核支援;操作快且消耗低;支持更大规模的线程数量。
      • 劣:实现复杂。
    • 使用用户线程 + 轻量级进程混合实现(N:M 实现)
  • Java 线程调度:系统为线程分配处理器使用权的过程。
    • 协同式线程调度:线程的执行时间由线程本身来控制。
      • 优:实现简单;一般没有线程同步问题。
      • 劣:线程的执行时间不可控;如果有 bug,会导致程序一直阻塞。
    • 抢占式线程调度:由系统分配执行时间。
      • 优、劣和协同式线程调度相反。
  • 线程的 6 种状态及转换:
    • 新建。
    • 运行:正在运行或等待系统分配执行时间。
    • 无限期等待:等待被其他线程显式唤醒。
    • 限期等待:在一定时间后由系统唤醒。
    • 阻塞:等待着获取一个排它锁。
    • 结束。

操作系统中线程的演化:

  • 操作系统不支持多线程:
    • 单人单工工作模式。
    • 栈纠缠工作模式:用户自己模拟多线程、自己保护恢复现场。
      • 原理:在内存里划出一片额外空间来模拟调用栈,其他“线程”中的方法压栈、退栈时遵守规则,不破坏这片空间即可。
  • 操作系统支持多线程:
    • 栈纠缠演化为用户线程最初多数的用户线程被设计成协同式调度,所以也叫协程
      • 优:轻量。
      • 劣:在应用层实现的内容调用栈、调度器特别多。

协程:

  • 分类:
    • 有栈协程:完整地做调用栈地保护、恢复工作。
    • 无栈协程:本质是一种有限状态机,状态保存在闭包里,所以比有栈协程轻量,但功能也相对更有限。
  • Kotlin 的协程:
    • 是一种不依赖虚拟机很影响性能、对即时编译器的干扰也非常大、必须用户手动标注每一个函数是否会在协程上下文被调用的实现。
    • 一旦遇到 synchronized 关键字,挂起来的仍然是整个线程。

纤程:Loom 项目,意图是重新提供对用户线程的支持。

使用纤程并发的代码会被分为两部分用户可以选择自行控制其中的一个或两个

  • 执行过程:主要用于维护执行现场,保护、恢复上下文状态。
  • 调度器:负责编排所有要执行的代码的顺序。

第 12 章:Java 内存模型与线程
https://weichao.io/d30ab4864c66/
作者
魏超
发布于
2023年1月28日
更新于
2023年2月12日
许可协议