第12章 Java内存模型与线程
12.1 概述
- 略
12.2 硬件的效率与一致性
- 计算机的存储设备与处理器的运算速度有着几个数量级的差距
- 现代计算机不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之前的缓冲
- 将运算需要使用的数据复制到缓存中,让运算能够快速运行,当运算结束后再从缓存同步回内存中
- 除了增加高速缓存以外,为了使处理器内部的运算单元能尽量被充分运用,处理器可能会对输入代码进行乱序执行优化,即指令重排序优化
- 在多核处理器系统中,每个处理器都拥有自己的高读缓存,而它们又共享同一主内存,这种系统称为“共享内存多核系统”
12.3 Java内存模型
- 内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象
- 定义Java内存模型
- 必须定义的足够严谨,才能让Java的并发内存访问操作不会产生歧义
- 也必须定义的足够宽松,使得虚拟机的实现能又足够的自由空间去利用硬件的各种特性来获取更好的执行速度
12.3.1 主内存与工作内存
- Java内存模型的主要目的使定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值的底层细节
- 变量包括实例字段、静态字段以及构成数组对象的元素
- 不包括局部变量和方法参数,这些为线程私有,不会被共享,不会存在竞争问题
- Java内存模型规定了所有的变量都存储在主内存,每条线程有自己的工作内存
- 线程的工作线程中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的数据
- 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
12.3.2 内存间交互操作
- 主内存和工作内存之间具体的交互协议,即变量从主内存拷贝到工作内存、从工作内存同步回主内存这一类的实现细节
- Java内存模型定义了8种原子性的、不可再分的操作
- lock(锁定)
- 作用于主内存的变量,把一个变量标识为一条线程独占的状态
- unlock(解锁)
- 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取)
- 作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作
- load(载入)
- 作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用)
- 作用于工作内存的变量,把工作内存中的一个变量的值传递给执行引擎
- 每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign(赋值)
- 作用于工作内存的变量,把一个从执行引擎接收的值赋值给工作内存的变量
- 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- store(存储)
- 作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
- write(写入)
- 作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中
- lock(锁定)
- 操作必须按照顺序执行,但不要求是连续执行
- Java内存模型规定了在执行上述8种操作时必须遵守的规则
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存种改变了之后必须把该变化同步回主内存
- 不允许一个线程无原因的把数据从线程的工作内存同步回主内存中
- 一个新的变量只能在主内存中创建,不允许在工作内存中直接使用一个未被初始化的变量,即对一个变量实施use、store操作之前,必须先执行assign和load操作
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中
12.3.3 对于volatile型变量的特殊规则
- 变量被定义成volatile后,具备两个特性
- 保证此变量对所有线程的可见性
- “可见性”指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知
- 这并不能得出“基于volatile变量的运算在并发下是线程安全的”,Java的运算操作符并非原子操作,这导致volatile变量的运算在并发下是不安全的
- 如,自增运算中,自增的一行代码在Class文件中是由4条字节码指令构成
- volatile只能在两种运算场景中保证原子性
- 运算结构不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
- 禁止指令重排序优化
- 保证此变量对所有线程的可见性
- volatile与锁的选择的唯一判断依据是volatile的语义能否满足使用场景的需求
- Java内存模型种对volatile变量定义的特殊规则的定义
- 规则要求在工作内存中,每次使用变量前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量所作的修改
- 规则要求在工作内存中,每次修改变量后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量所作的修改
- 规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同
12.3.4 针对long与double型变量的特殊规则
- Java内存模型要求8种操作都具有原子性,但对于64位的数据类型(long和double)规定
- 允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行
- 即允许虚拟机实现自行选择是否要保证64位数据类型的操作原子性
- 这就是long和double的“非原子性协定”
- 如果多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的“半个变量”的数值
- 经过实际测试,不管在64位还是32位虚拟机种,通常不会出现非原子性访问的问题
- 除非该数据有明确可知的形成竞争,否则在编写代码时不需要刻意的将long和double专门声明为volatile
12.3.5 原子性、可见性与有序性
- 原子性
- 基本数据类型的访问、读写都具备原子性(long和double的“非原子性协定”例外)
- 通过8种操作和缓存一致性保证
- 可见性
- 普通变量和volatile变量都是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介变量的方式来实现
- volatile变量的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新
- 有序性
- Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性
- volatile关键字作用是禁止指令重排序
- synchronized关键字规定了持有同一个锁的两个同步块只能串行的进行
12.3.6 先行发生原则(Happens-Before)
- 先行发生原则规定
- 程序次序规则
- 在一个线程内,按照控制流顺序,前面的操作先行发生于后面的操作
- 管程锁定规则
- 一个unlock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则
- 对同一个volatile变量的写操作先行发生于后面对这个变量的读操作
- 线程启动规则
- Thread对象的start()方法先行发生于此线程的每一个动作
- 线程终止规则
- 线程中的所有操作都先行发生于对此线程的终止检测
- 线程中断规则
- 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件发生
- 对象终结规则
- 一个对象的初始化完成先行发生于它的finalize()方法的开始
- 传递规则
- 操作A先行发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C
- 程序次序规则
- 先行发生原则与时间先后顺序基本没有关系
12.4 Java与线程
12.4.1 线程的实现
- 线程是比进程更轻量级的调度执行单位,线程的引入可以把进程的资源分配和执行调度分开,各个线程既可以共享进程资源又可以独立调度
- 每个已经调用过start()方法且还未结束的java.lang.Thread类的实例代表应该线程
- 实现线程有三种方式
- 内核线程实现(1:1实现)
- 用户线程实现(1:N实现)
- 用户线程加轻量级进程混合实现(N:M实现)
1.内核线程实现
- 内核线程实现的方式也称为1:1实现,内核线程(Kernal-Level Thread,KLT)就是直接由操作系统内核支持的线程
- 这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上,支持多线程的内核称为多线程内核
- 程序一般不直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),即线程
- 每个轻量级进程都由一个内核线程支持,只有先支持内核线程,才能有轻量级进程
- 这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型
2.用户线程实现
- 用户线程实现的方式被称为1:N实现
- 从广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程
- 从这个定义来看轻量级进程也属于用户线程,但轻量级进程的实现始终是建立再内核之上的,并不具备意义上的用户线程的优点
- 从狭义上来讲,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现
- 用户线程的建立、同步、销毁和调度完全再用户态中完成,不需要内核的帮助
- 如果程序实现得当,这种线程不需要切换到内核态,操作快且低消耗,能够支持规模大的线程数
- 从广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程
- 用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核支援
- 线程的操作需要由用户线程自己处理,线程的创建、销毁、切换和调度都由用户实现,而实现的程序通常都比较复杂
- 一般应用程序都不倾向使用用户线程
- 这种进程与用户线程之间1:N的关系称为一对一的线程模型
3.混合实现
- 混合实现的方式被称为N:M实现
- 既存在用户线程,也存在轻量级进程
- 用户线程完全建立再用户空间中,支持大规模的用户线程并发
- 操作系统支持的轻量级进程作为用户线程和内核线程之间的桥梁,使用内核线程的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险
4.Java线程的实现
- HotSpot虚拟机的每一个Java线程都是直接映射到一个操作系统原生线程来实现
12.4.2 Java线程调度
- 线程调度是指系统为线程分配处理器使用权的的过程
- 协同式线程调度
- 线程的执行时间由线程本身来控制,线程把自己的工作执行完成后,主动通知系统切换到另外一个线程上
- 实现简单,一般不存在线程同步问题
- 线程执行时间不可控制,线程可能会一直阻塞
- 抢占式线程调度
- 每个线程将由系统来分配执行时间,线程的切换不由线程本身决定
- 线程的执行时间是系统可控的,不会有一个线程导致整个进程甚至整个系统阻塞的问题
- 协同式线程调度
12.4.3 状态转换
- Java语言定义了6中线程状态
- 新建(New)
- 创建后尚未启动的线程
- 运行(Runable)
- 分为Running和Ready,即线程有可能正在执行,也有可能正在等待操作系统为它分配执行时间
- 无限期等待(Waiting)
- 线程不会被分配处理器执行时间,需要等待被其他线程显式唤醒
- 没有设置Timed参数的Object::wait()方法
- 没有设置Timed参数的Thread::join()方法
- LockSupport::park()方法
- 限期等待(Timed Waiting)
- 线程不会被分配处理器执行时间,在一定时间后会由系统自动唤醒
- 设置了Timed参数的Object::wait()方法
- 设置了Timed参数的Thread::join()方法
- LockSupport::parkNanos()方法
- LockSupport::parkUntil()方法
- 阻塞(Blocked)
- 线程被阻塞,等待获取到排它锁
- 在线程进入同步区域的时候会进入这种状态
- 结束(Terminated)
- 已终止线程的线程状态,线程已经执行结束
- 新建(New)
12.5 Java与协程
- Java语言抽象出来隐藏了各种操作系统线程差异性的统一线程接口
12.5.1 内核线程的局限
- 请求的响应往往需要分布在不同机器上的大量服务共同协作来实现,这种服务细分的架构在减少单个服务复杂度、增加复用性的同时,不可避免的增加了服务的数量,缩短了每个服务的响应时间
- 这要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待
- Java虚拟机的内核线程模型是缺陷是切换、调度成本高昂,系统能容纳的线程数量也有限
- 在以前处理一个请求可以允许花费很长时间在单位应用中,具有这种线程切换的成本也也无伤大雅
- 在现在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近于计算本身的开销
12.5.2 协程的复苏
- 内核线程的调度成本主要来自于用户态与核心态之间的状态切换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本
- 线程切换:线程A——>系统中断——>线程B
- 代码执行时需要有上下文数据的支撑
- 以程序员的角度看,是方法调用过程中的各种局部的变量与资源
- 以线程的角度来看,是方法的调用栈中存储的各类信息
- 以操作系统和硬件的角度来看,是存储在内存、缓存和寄存器中的一个个具体数值
- 物理硬件的各种存储设备和寄存器是被操作系统内所有线程共享的资源
- 当中断发生,从线程A切换到线程B去执行之前,操作系统首先要把线程A的上下文数据妥善保管好
- 然后把寄存器、内存分页等恢复到线程B挂起时候的状态
- 用户线程不能够省略掉切换状态的开销,但是把保护、恢复现场及调度的工作从操作系统移交给程序员,则可以进行一些操作来缩减开销