阅读 545

一夜搞懂 | Java 内存模型与线程

前言

本文已经收录到我的 Github 个人博客,欢迎大佬们光临寒舍:

我的 GIthub 博客

学习导图

学习导图
学习导图

一.为什么要学习内存模型与线程?

并发处理的广泛应用是 Amdah1 定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压制计算机运算能力的最有力武器

线程通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递

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

Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果你想设计表现良好的并发程序,理解 Java 内存模型是非常重要的Java 内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

二.核心知识点归纳

2.1 概述

Q1:多任务处理的必要性

  • 充分利用计算机处理器的能力,避免处理器在磁盘 I/O、网络通信或数据库访问时总是处于等待其他资源的状态
  • 便于一个服务端同时对多个客户端提供服务

通过指标 TPSTransactions Per Second)可衡量一个服务性能的高低好坏,它表示每秒服务端平均能响应的请求总数,进而体现出程序的并发能力

Q2:硬件的效率与一致性

为了更好的理解 Java 内存模型,先理解物理计算机中的并发问题,两者有很高的可比性

为了平衡内存交互速度与处理器的运算速度之间几个数量级的差距,引入一层高速缓存Cache)来作为内存与处理器之间的缓冲:

  • 将运算需要使用到的数据复制到缓存中,让运算能快速进行
  • 当运算结束后再从缓存同步回内存之中,而无须让处理器等待缓慢的内存读写
  • 出现问题:引入高速缓存虽解决了处理器与内存速度之间的矛盾,但是其引入了一个新的问题——缓存一致性
  • 解决办法:需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作
缓存与协议的结构图
缓存与协议的结构图

内存模型可以理解为:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象

2.2 Java 内存模型

之前笔者在 进阶之路 | 奇妙的Thread之旅中简要介绍过 Java 内存模型,相信看过的读者都有一些印象

2.2.1 设计目的

屏蔽掉各种硬件和操作系统的内存访问差异,实现 Java 程序在各种平台下都能达到一致的内存访问效果

2.2.2 设计方法

通过定义程序中各个变量访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节

注意:这里的变量与 Java 中说的变量不同,而指的是实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数(其存放于局部变量表中,而局部变量表在 JVM 栈中),因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

2.2.3 模型结构

内存模型
内存模型
  • 主内存:所有变量的存储位置。直接对应于物理硬件的内存

注意:这里的主内存、工作内存与 一文洞悉JVM内存管理机制 说的 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分

  • 工作内存:每条线程还有自己的工作内存,用于保存被该线程使用到的变量的主内存副本拷贝。为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中

注意:

  • 线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量
  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存来完成
  • 交互协议:用于规定一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。

    共有 8 种操作:

交互协议
交互协议
  1. 作用于主内存变量:
  • 锁定 lock :把变量标识为一条线程独占的状态
  • 解锁 unlock :把处于锁定状态的变量释放出来
  • 写入 write :把 store 操作从工作内存中得到的变量的值放入主内存的变量中
  • 读取 read :把变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用

​ 2.用于工作内存变量:

  • 赋值 assign :把从执行引擎接收到的值赋给工作内存的变量
  • 使用 use :把工作内存中一个变量的值传递给执行引擎
  • 存储 store :把工作内存中变量的值传送到主内存中,以便随后的 write 操作使用
  • 写入 write :把 store 操作从工作内存中得到的变量的值放入主内存的变量中

结论:注意是顺序非连续

  • 如果要把变量从主内存复制到工作内存,那就要顺序地执行 readload
  • 如果要把变量从工作内存同步回主内存,就要顺序地执行 storewrite

2.2.4 确保并发操作安全的原则

A1:执行八种基本操作的时候,必须满足如下规则:

  • 不允许 readloadstorewrite 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现

可以简单理解为不能拒绝别人给的东西

  • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因地,即没有发生过任何 assign 操作,就把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中『诞生』 ,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量,即对一个变量实施 usestore 操作之前必须先执行过了 assignload 操作
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 loadassign 操作初始化变量的值

下文的 volatile底层就是用到了 lock 来实现可见性

  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中

可见这么多规则非常繁琐,实践也麻烦,下面再介绍一个等效判断原则 -- 『先行发生原则』

A2:先行发生原则:

Java 内存模型中定义的两项操作之间的偏序关系。

下面例举一些 『天然的』先行发生关系,无须任何同步器协助就已经存在,可以在编码中直接使用

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
  • volatile 变量规则:对一个 volatile 变量的操作先行发生于后面对这个变量的操作
  • 线程启动规则Threadstart() 先行发生于此线程的每一个动作
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。可通过 Thread.join() 结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行
  • 线程中断规则:对线程 interrupt() 的调用先行发生于被中断线程的代码检测到中断事件的发生。可通过 Thread.isInterrupted() 检测到是否有中断发生
  • 对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 的开始
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 一定先行发生于操作 C

2.2.5 保证原子性、可见性和有序性的措施

  • 原子性:一个操作要么都执行要么都不执行
  • 可直接保证的原子性变量操作有:readloadassignusestorewrite,因此可认为基本数据类型的访问读写具备原子性的特征

  • 若需要保证更大范围的原子性,可通过更高层次的字节码指令 monitorentermonitorexit 来隐式地使用 lockunlock 这两个操作,反映到 Java 代码中就是同步代码块 synchronized 关键字

  • 可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改
  • 通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现
  • 提供三个关键字保证可见性:
    1. volatile 能保证新值能立即同步到主内存,且每次使用前立即从主内存刷新
    2. synchronized 对一个变量执行 unlock 操作之前可以先把此变量同步回主内存中
    3. final 修饰的字段在构造器中一旦初始化完成且构造器没有把 this 的引用传递出去,就可以在其他线程中就能看见 final 字段的值
  • 有序性:程序代码按照指令顺序执行
  • 如果在本线程内观察,所有的操作都是有序的,指 “线程内表现为串行的语义”;
  • 如果在一个线程中观察另一个线程,所有的操作都是无序的,指 “指令重排序” 现象和 “工作内存与主内存同步延迟” 现象
  • 提供两个关键字保证有序性:
    1. volatile 本身就包含了禁止指令重排序的语义
    2. synchronized 保证一个变量在同一个时刻只允许一条线程对其进行 lock 操作,使得持有同一个锁的两个同步块只能串行地进入

想详细了解 volatile 的读者,可以看下笔者之前写的文章:进阶之路 | 奇妙的 Thread 之旅

2.3 Java 与线程

2.3.1 线程实现的三种方式

1.使用内核线程

英文: Kernel-Level Thread ,简称:KLT

  • 定义:由操作系统内核支持的线程
  • 原理:由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身, 这样操作系统就有能力同时处理多件事情
  • 多线程内核:支持多线程的内核
  • 轻量级进程Light Weight Process,简称:LWP):内核线程的一种高级接口
  • 优点:每个轻量级进程都由一个内核线程支持,因此每个都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞,也不会影响整个进程继续工作
  • 缺点:
    1. 由于基于内核线程实现,所以各种线程操作(创建、析构及同步)都需要进行系统调用,代价相对较高,需要在用户态内核态中来回切换
    2. 一个系统支持轻量级进程的数量是有限的
  • 一对一线程模型:轻量级进程与内核线程之间 1:1 的关系,如图所示
内核线程模型
内核线程模型
2.使用用户线程

英文: User Thread ,简称:UT

  • 定义:
  • 广义上认为一个线程不是内核线程就是用户线程
  • 狭义上认为用户线程指的是完全建立在用户空间的线程库上,而系统内核不能感知线程存在的实现
  • 优点:由于用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助,甚至可以不需要切换到内核态,所以操作非常快速且低消耗的,且可以支持规模更大的线程数量
  • 缺点:由于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,线程的创建、切换和调度都是需要考虑的问题,实现较复杂
  • 一对多的线程模型进程:进程与用户线程之间 1:N 的关系,如图所示
用户线程模型
用户线程模型
3.混合
  • 定义:既存在用户线程,也存在轻量级进程

  • 优点:

  • 用户线程完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发
  • 操作系统提供支持的轻量级进程作为用户线程和内核线程之间的桥梁,可以使用内核提供的线程调度功能及处理器映射,且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险
  • 多对多的线程模型:用户线程与轻量级进程的数量比不定,即用户线程与轻量级进程之间 N:M 的关系,如图所示
混合模型
混合模型

Q: Java 线程的实现是选择哪一种呢?

A:答案是不确定的。操作系统支持怎样的线程模型,在很大程度上决定了 JVM 的线程是怎样映射的。线程模型只对线程的并发规模和操作成本产生影响,而对 Java 程序的编码和运行过程来说,这些差异都是透明的。

2.3.2 线程调度的两种方式

线程调度:指系统为线程分配处理器使用权的过程

1.协同式线程调度
  • 线程本身来控制线程的执行时间。线程把自己的工作执行完后,要主动通知系统切换到另外一个线程上
  • 好处:
  • 实现简单
  • 切换操作自己可知,不存在线程同步的问题
  • 坏处:线程执行时间不可控,假如一个线程编写有问题一直不告知系统进行线程切换,那么程序就会一直被阻塞
2.抢占式线程调度
  • 系统来分配每个线程的执行时间
  • 好处:线程执行时间是系统可控的,不存在一个线程导致整个进程阻塞的问题
  • 可以通过设置线程优先级,优先级越高的线程越容易被系统选择执行

但是线程优先级并不是太靠谱,一方面因为 Java 的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,在一些平台上不同的优先级实际会变得相同;另一方面优先级可能会被系统自行改变。

2.3.3 线程的六种状态

线程状态
线程状态

在任意一个时间点,一个线程只能有且只有其中的一种状态:

  • 新建 New :线程创建后尚未启动

  • 运行 Runable:包括正在执行(Running)和等待着 CPU 为它分配执行时间(Ready)两种

  • 无限期等待 Waiting:该线程不会被分配 CPU 执行时间,要等待被其他线程显式地唤醒。

    以下方法会让线程陷入无限期等待状态:

  • 限期等待 Timed Waiting:该线程不会被分配 CPU 执行时间,但在一定时间后会被系统自动唤醒。以下方法会让线程进入限期等待状态:
  • Thread.sleep()
  • 设置了 Timeout 参数的 Object.wait()
  • 设置了 Timeout 参数的 Thread.join()
  • LockSupport.parkNanos()
  • LockSupport.parkUntil()
  • 阻塞 Blocked:线程被阻塞

注意区别阻塞和等待:

  • 阻塞状态:在等待获取到一个排他锁,在另外一个线程放弃这个锁的时候发生;
  • 等待状态:在等待一段时间或者唤醒动作的发生,在程序等待进入同步区域的时候发生。
  • 结束 Terminated:线程已经结束执行

三.碎碎念

恭喜你!已经看完了前面的文章,相信你对 Java 内存模型与线程已经有一定深度的了解!你可以稍微放松奖励自己一下,可以睡一个美美的觉,明天起来继续冲冲冲!!!

PS:原本《深入理解Java虚拟机》第3版中还提及了协程,但是我还没学过协程的基本用法,这时候给大家讲解感觉有点打肿脸充胖子的感觉 hhh,明天《第一行代码-第三版》也要到了,待我看完《第一行代码》再补充协程的内容吧 hhhh


如果文章对您有一点帮助的话,希望您能点一下赞,您的点赞,是我前进的动力

本文参考链接:

本文使用 mdnice 排版