一、概述
-
并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因
- Amdahl定律:通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算加速能力
- 摩尔定律:用于描述处理器晶体管数量与运行效率之间的发展关系
- 这两个定律的更替代表了近年来硬件发展从追求处理器频率到追求多核心并行处理的发展过程
-
衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second,TPS)是最重要的指标之一
- 它代表着一秒内服务端平均能响应的请求总数
二、硬件的效率与一致性
-
问题引出:
-
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但同时也为计算机系统带来了更高的复杂度。
-
处理器、高速缓存、主内存间的交互关系
-
同时引入了一个新的问题:缓存一致性(Cache Coherence)
-
-
解决:各个处理器访问缓存时都遵循一些协议
-
内存模型:可以理解为特定的操作协议下,对特定的内存或高速缓存进行读写访问过程的抽象
-
乱序执行优化:
-
目的:为了使得处理器内部的运算单元能尽量被充分利用
-
过程:处理器可能会对输入代码进行乱序执行(Out-Of—Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码一致
- 因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证
-
引出:与之类似的是Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)
-
三、Java内存模型
- 目的:
- 屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
1、主内存与工作内存
-
Java内存模型的主要目标:定义程序中各个变量的访问规则
-
即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节
-
此处的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数这种线程私有的变量
-
-
操作过程
-
Java 内存模型规定了所有的变量都存储在 主内存(Main Memory)
- 类比计算机中的物理硬件内存
-
每条线程还有自己的 工作内存(Working Memory)
- 类比计算机中的寄存器和高速缓存
-
线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量
-
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
-
线程、主内存、工作内存三者的交互关系:
-
2、内存间交互操作(主内存与工作内存之间具体的交互协议)
-
目的:
- 确定了哪些Java程序中内存访问操作在并发下是安全的
-
内容概述:
- 一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节
-
具体操作(8种操作):
序号 名称 操作区域 作用 1 lock(锁定) 作用于主内存的变量 把一个变量标识为一条线程独占的状态 2 unlock(解锁) 作用于主内存的变量 把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 3 read(读取) 作用于主内存的变量 把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用 4 load(载入) 作用于工作内存的变量 把read操作从主内存中得到的变量值放入工作内存的变量副本中 5 use(使用) 作用于工作内存的变量 把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作 6 assign(赋值) 作用于工作内存的变量 把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作 7 store(存储) 作用于工作内存的变量 把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用 8 write(写入) 作用于主内存的变量 把 store 操作从工作内存中得到的变量的值放入主内存的变量中 -
要求:
-
(1)每一种操作都是原子的、不可再分的
- 对于double、long变量,load、store、read和write操作在某些平台上允许有例外
-
(2)如果要把一个变量从主内存复制到工作内存,那就要顺序的执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序的执行 store 和 write 操作
- 两种操作必须“顺序”,但不要求“连续”,中间可以插入其他指令
-
(3)不允许 read 和 load、store 和 write 操作之一单独出现
- 即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现
-
(4)不允许一个线程丢弃它的最近的 assign 操作
- 即变量在工作内存中改变了之后必须把该变化同步回主内存
-
(5)不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中
-
(6)一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量
- 对一个变量实施 use、store 操作之前,必须先执行过了 load 和 assign 操作
-
(7)一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁
-
(8)如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值
-
(9)如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量
-
(10)对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)
-
3、对于 volatile 型变量的特殊规则
-
概述:
volatile
关键字是Java 虚拟机提供的最轻量级的同步机制
-
volatile
变量的两种特性(两种语义)-
(1)保证此变量对所有线程的可见性
-
“可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的
-
普通变量的值在线程间传递则需要通过主内存来完成,做不到可见性。eg:线程A修改一个普通变量的值,然后向主内存回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见
-
-
(2)禁止指令重排序优化(JDK1.5才被完全修复实现此功能)
-
指令重排序定义:CPU采用了允许将多条指令不按照程序规定的顺序分开发送给各相应的电路单元处理。
-
普通变量仅仅会保证在该方法执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致
-
因为在一个线程的方法执行过程中无法感知到指令重排序,这也就是Java内存模型中描述的所谓的 “线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)
-
-
-
volate
变量的并发安全性语句 判断 volatile
变量在各个线程中是一致的正确 基于 volatile
变量的运算在并发下是安全的错误 -
volatile
变量在各个线程的工作内存中不存在一致性问题- 在各个线程的工作内存中,
volatile
变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题
- 在各个线程的工作内存中,
-
Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的
-
-
仍需加锁的情况
-
由于
volatile
变量只能保证可见性,在不符合以下两条规则的运算场景中,仍然要通过加锁(synchronized
或java.util.concurrent
中的原子类)来保证原子性(原因:一条字节码指令可能被转化成若干条本地机器码指令)- (1)运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- (2)变量不需要与其他的状态变量共同参与不变约束
-
-
原理(以DCL单例为例):
-
有
volatile
关键字修饰的变量,赋值后多执行了一个“lock addl $0x0, (%esp)
”操作,这个操作相当于是一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置)-
指令重排序过程要求:CPU需要能正确处理指令依赖情况(eg:运算法则的先后顺序)以确保程序能得出正确的执行结果。所以在本内CPU内,重排序看起来依然是有序的。
-
因此通过“
lock addl $0x0, (%esp)
”指令实现上述要求,将修改同步到内存时,说明其之前的操作(即被依赖的操作)都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果 -
只有一个CPU访问内存时不需要内存屏障。如果有两个或更多CPU访问同一块内存,并且其中又一个在观测另一个,就需要内存屏障来保证一致性
-
-
lock
前缀作用:使得本CPU的Cache写入了内存,该写入动作也会引起别的CPU或者别的内核无效化(Invalidate)其Cache(其他CPU的数据失效)- 这种操作相当于对Cache中的变量做了一次Java内存模式中的 store 和 write 操作
- 其它CPU再次使用时需重新获取,即刷新数据值
- 实现了
volatile
变量的修改对其它CPU立即可见
-
-
选择
volatile
还是锁实现同步- 唯一依据:
volatile
是否能满足使用场景的要求 - 原因:
volatile
的同步机制性能确实优于锁,volatile
变量的读操作的性能消耗与普通变量也几乎没有什么区别。但写操作可能会慢一些,因为需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行
- 唯一依据:
-
Java内存模型对
volatile
变量定义的特殊规则-
前提:假定T表示一个线程,V和W分别表示两个
volatile
型变量,那么在进行 read、load、use、assign、store和write操作时需要满足如下规则 -
(1)
read-load-use
必须连续配对出现-
只有当线程T对变量V执行的前一个动作是 load 的时候,线程T才能对变量V执行 use 动作;并且,只有当线程T对变量V执行的后一个动作是 use 的时候,线程T才能对变量V执行 load 动作
-
这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于确保能看见其他线程对变量V所做的修改后的值
-
-
(2)
assign-store-write
必须连续配对出现-
只有当线程T对变量V执行的前一个动作是 assign 的时候,线程T才能对变量V执行 store 动作;并且,只有当线程T对变量V执行的后一个动作是 store 的时候,线程T才能对变量V执行 assign 动作
-
这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于确保其他线程可以看到自己对变量V的修改
-
-
(3)如果对V的 use 或 assign 操作先于对W的 use 或 assign 操作,则V的操作的连续配对操作(read、use或store、write)也先于W操作的连续配对操作
- 这条规则要求
volatile
修饰的变量不会被指令重排序优化,确保代码的执行顺序与程序的顺序相同
- 这条规则要求
-
4、对于long和double型变量的特殊规则
-
long和double的非原子性协定(Nonatomic Treatment of double and long Variables)
-
Java内存模型中的八种操作具有原子性,但对于64位数据类型(long、double)相对宽松:允许虚拟机将没有被
volatile
修饰的64位数据的读写操作划分为两次32位的操作来进行- 即允许虚拟机实现选择可以不保证64位数据类型的 load、store、read和write 这四个操作的原子性
-
-
实际开发中,目前各个平台的商用虚拟机一般都把64位数据的读写操作作为原子操作来对待
- 所以编写代码一般不需要把long和double专门声明为
volatile
- 所以编写代码一般不需要把long和double专门声明为
5、原子性、可见性与有序性(并发中如何处理这三个特性)
-
(1)原子性(Atomicity)
-
基本数据类型的访问读写是具备原子性的(long、double除外)
- Java内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write
-
在
synchronized
块之间的操作实现更大范围的原子性- Java代码中的
synchronized
-> 字节码指令中的monitorenter
和monitorexit
-> Java内存模型中的lock
和unlock
- Java代码中的
-
-
(2)可见性(Visibility)
-
定义:可见性是指当一个线程修改了共享变量的值,其他线程能立即得知这个修改
-
实现原理:Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的
-
具体Java实现:
-
1⃣
volatile
变量:volatile
的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新(volatile
变量保证了多线程操作时变量的可见性) -
2⃣
synchronized
同步块:同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)”这条规则获得的 -
3⃣
final
关键字:被final
修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸使其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final
字段的值
-
-
-
(3)有序性(Ordering)
-
定义:
-
如果在本线程中观察,所有的操作都是有序的:线程内表现为串行的语义
-
如果在一个线程中观察另一个线程,所有的操作都是无序的:“指令重排序”现象和“工作内存与主内存同步延迟”现象
-
-
实现:
- 1⃣
volatile
关键字:有禁止指令重排序的语义 - 2⃣
synchronized
同步块:“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”,因此持有同一个锁的两个同步块只能串行的进入
- 1⃣
-
6、先行发生原则
-
作用:是判断数据是否存在竞争、线程是否安全的主要依据
- 避免Java内存模型中所有的有序性都靠
volatile
和synchronized
完成
- 避免Java内存模型中所有的有序性都靠
-
定义:
-
先行发生是Java内存模型汇总定义的两项操作之间的偏序关系,即“操作A先行发生于操作B”意味着发生操作B之前,操作A产生的“影响”能被操作B观察到
- “影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等
-
-
Java内存模型下的一些“天然的”先行发生关系:
-
概述:这些线程先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意的重排序。
-
(1)程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 准确的说是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构
-
(2)管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于(时间顺序)后面对同一个锁的 lock 操作
-
(3)volatile变量规则(Volatile Variable Rule):对一个
volatile
变量的写操作先行发生于(时间顺序)后面对这个变量的读操作 -
(4)线程启动规则(Thread Start Rule):Thread对象的
start()
方法先行发生于此线程的每一个动作 -
(5)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测
- 可以通过
Thread.join()
方法结束、Thread.isAlive()
的返回值等手段检测到线程已经终止执行
- 可以通过
-
(6)线程中断规则(Thread Interruption Rule):对线程
interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生- 可以通过
Thread.interrupted()
方法检测到是否有中断发生
- 可以通过
-
(7)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的
finalize()
方法的开始 -
(8)传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论
-
-
衡量并发安全问题,时间顺序不具有参考性(指令重排序等问题),一切必须以先行发生原则为准
四、Java与线程
- 为什么要引入线程?
- 线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)
1、线程的实现(3种实现方法)
-
(1)使用内核线程实现(一对一的线程模型)
-
定义:内核线程(Kernel-Level Thread, KLT)就是直接由操作系统内核(Kernel)支持的线程
-
特点:这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上
-
多线程内核:每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)
-
一对一的线程模型
-
引出:程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP)(轻量级进程即通常意义上的线程)
-
定义:由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间的 1 :1 的关系称为一对一的线程模型
-
-
轻量级进程优点:
- 由于内核线程的支持,每个轻量级进程都称为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作
-
轻量级进程缺点:
-
1⃣ 由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换
-
2⃣ 每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的
-
-
-
(2)使用用户线程实现(一对多的线程模型)
-
定义(狭义):完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现
-
优点:
-
用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助
-
如果程序实现得当,这种线程不许要切换到内核态,因此操作可以是非常快速且低消耗的
-
可以支持规模更大的线程数量(eg:部分高性能数据库中的多线程实现)
-
-
缺点:
-
没有系统内核的支援,所有的线程操作都需要用户程序自己处理(eg:阻塞如何处理、多处理器系统中如何将线程映射到其他处理器上)
-
因此基本不直接使用
-
-
一对多的线程模型
-
定义:进程与用户线程之间 1:N 的关系
-
-
-
(3)使用用户线程加轻量级进程混合实现(多对多的线程模型)
-
定义:在混合实现下,既存在用户线程,也存在轻量级进程
-
优点:
-
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发
-
操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险
-
-
多对多的线程模型
-
定义:用户线程与轻量级进程的数量比是不定的,即为N:M的关系
-
-
-
Java线程的实现
-
在JDK1.2前:基于称为“绿色线程”(Green Threads)的用户线程实现
-
在JDK1.2后:基于操作系统原生线程模型实现
-
因此在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的
-
对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的
-
-
2、Java线程调度(2种调度方式)
-
定义:线程调度是指系统为线程分配处理器使用权的过程
-
(1)协同式线程调度(Cooperative Threads-Scheduling)
-
过程:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上
-
优点:
- 实现简单
- 切换操作对线程自己是可知的(线程要把自己的事情干完后才会进行线程切换),不存在线程同步问题
-
缺点:
- 线程执行时间不可控制
- 如果某个线程编写有问题,可能导致线程阻塞
-
-
(2)抢占式线程调度(Preemptive Threads-Scheduling)
-
过程:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定
- 在Java中,
Thread.yield()
可以让出执行时间 - 对于获取执行时间,线程本身是没什么办法的
- 在Java中,
-
优点(Java使用此种调度方式):
- 线程的执行时间是系统可控的
- 不会有一个线程导致整个进程阻塞的问题
-
Java实现中的优先级:
-
Java线程调度是系统自动完成的,但可通过语言中预设的10个线程优先级来增大线程被系统选择执行的概率
-
线程优先级不完全准确:Java的线程是通过映射到系统的原生线程上来实现的,所以线程的调度最终还是取决于操作系统。不同操作系统中的线程优先级不一定能与Java线程优先级一一对应,并且还存在一些特殊情况(eg:Windows系统中的优先级推进器可能会越过线程优先级给活跃线程分配执行时间)
-
-
3、状态转换(5种线程状态)
-
概述:Java语言定义了5种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态
-
(1)新建(New):创建后尚未启动的线程处于这种状态
-
(2)运行(Runnable):Runnable 包括了操作系统线程状态中的 Running 和 Ready ,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间
-
(3)等待状态
-
1⃣ 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,他们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期等待状态:
-
没有设置Timeout参数的
Object.wait()
方法 -
没有设置Timeout参数的
Thread.join()
方法 -
LockSupport.park()
方法
-
-
2⃣ 限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无需等待被其他线程显式的唤醒,在一定时间之后他们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
-
Thread.sleep()
方法 -
设置了Timeout参数的
Object.wait()
方法 -
设置了Timeout参数的
Thread.join()
方法 -
LockSupport.parkNanos()
方法 -
LockSupport.parkUntil()
方法
-
-
-
(4)阻塞(Blocked):线程被阻塞。在程序等待进入同步区域的时候,线程将进入这种状态
- “阻塞”与“等待”的区别:
-
“阻塞状态”在等待着获取一个排他锁,这个时间将在另外一个线程放弃这个锁的时候发生
-
“等待状态”在等待一段时间或是唤醒动作的发生
-
- “阻塞”与“等待”的区别:
-
(5)结束(Terminated):已终止线程的线程状态,线程已结束执行