第12章 Java内存模型与线程
并发处理的广泛应用使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器
12.1 概述
- 多任务处理在现代计算机操作系统中是一项必备的功能
- 衡量一个服务性能的高低好坏,每秒事务处理数(Transaction Per Second,TPS)是最重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力有密切的关系
- 多线程、多线程之间由于共享和竞争数据而导致的一系列问题及解决方案
12.2 硬件的效率与一致性
- 现代计算机系统加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲
- 基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Cohesion)
- 为了解决一致性问题,需要各个处理器访问缓存时都遵循时都遵循一些协议,在读写时要根据协议来进行操作,这类协议MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等
- 内存模型:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象
- 为了使处理器内部的运算单元能尽量被充分利用,处理器的乱序执行优化(Out-Of-Order Execution)类似,Java虚拟机的即时编译器中也有类似的指令重拍序(Instruction Reorder)优化
12.3 Java内存模型
- Java Memory Model,JMM:Java内存模型用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
12.3.1主内存与工作内存
- Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节
- 变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的
- Java内存模型规定所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)
12.3.2 内存间交互操作
-
主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存定义了8种操作来完成:
操作 作用域 作用 lock,锁定 主内存的变量 把一个变量标识为一条线程独占的状态 unlock,解锁 主内存的变量 把一个出于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 read,读取 主内存的变量 把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用 load,载入 工作内存的变量 把read操作从主内存中得到的变量值放入工作内存的变量副本中 use,使用 工作内存的变量 把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作 assign,赋值 工作内存的变量 把一个从执行引擎接收到的值付给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作 store,存储 工作内存的变量 把工作内存中一个变量的值传递到主内存中,以便随后的write操作使用 write,写入 主内存的变量 把store操作从工作内存中得到的变量的值放入主内存的变量中 -
规则
-
规则规则 不允许read和load、store和write操作之一单独出现 不允许一个线程丢弃它的最近的assign操作 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assigned)的变量 一个变量在同一个时刻值允许一条线程对其进行lock操作,但lock操作可被同一条线程重复执行多次 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中
12.3.3 对于volatile型变量的特殊规则
-
关键字volatile是Java虚拟机提供的最轻量级的同步机制
-
当一个变量定义为volatile之后,他将具备两种特性
- 第一是保证此变量对所有线程的可见性
- ”基于volatile变量的运算在并发下是安全的“是不正确的
- 通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性的运算场景:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
- 第二是禁止指令重排序优化
- Java内存模型中“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)
- Memory Barrier,Memory Fence,内存屏障,指的是重排序时不能把后面的指令重排序到内存屏障之前的位置
- 从硬件架构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理
- 第一是保证此变量对所有线程的可见性
-
在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁)
- 原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障来保证处理器不发生乱序执行
-
Java内存模型中对volatile变量定义的特殊规则,假设T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:
规则 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A想关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;类似的,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相4应的对变量W的read或write动作。如果A先与B,那么P先于Q
12.3.4 对于long和double型变量的特殊规则
- Java模型要求lock、unlock、read、load、assign、use、store、write这8个操作具有原子性,但对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:
- long和double的非原子性协定,Nonatomic Treatment ofdouble and long Variables:允许虚拟机将没有被volitile修饰的64位数据得读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性
- 如果多个线程共享一个并未声明为volitile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值
- 在实际开发中,目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的long和double变量专门声明为volatile
12.3.5 原子性、可见性与有序性
- 原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write
- 我们大致可以认为基本数据类型的访问读写是具备原子性的
- synchronized块之间的操作也具备原子性
- 可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
- Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的
- volatile保证了多线程操作时变量的可见性,而普通变量则不知能保证这一点
- Java还有两个关键字能实现可见性,即synchronized和final
- synchronized:同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)这条规则获得的
- final:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this"的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过该这个引用访问到”初始化一半“的对象),那在其他线程中就能看见final字段的值
- 有序性(Ordering):
- Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的
- 前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)
- 后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象
- Java语言提供了volitile和synchronized两个关键字来保证线程之间的操作的有序性
- volatile关键字本身就包含了禁止指令重排序的语义
- synchronized则是由“一个变量在同一时刻值允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入
- 大部分的并发控制操作都能使用synchronized来完成
12.3.6 先行发生原则
-
“现行并发”(happens-before)原则是判断数据是是否存在竞争、线程是否安全的主要依据
-
现行发生是Java内存模型中定义的两项操作之间的偏序关系,Java内存模型下一些“天然的”先行发生关系
规则 详细 程序次序规则 Programe Order Rule 在一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作 管程锁定规则 Monitor Lock Rule 一个unlock操作先行发生于后面对一个锁的lock操作 Volitile Variable Rule 对一个volitile变量的写操作先行于后面对这个变量的读操作 Thread Start Rule Thread对象的start()方法先行发生于此线程的每一个动作 Thread Termination Rule 线程中的所有操作都先行发生于对此线程的终止监测 线程中断规则 Thread Interruption Rule 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生 Finalizer Rule 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始 Transitivity 如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论 -
时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准
12.4 Java与线程
并发不一定要依赖多线程(PHP中很常见的多进程并发),但是在Java里面谈论并发,大多数都与线程脱不开关系
12.4.1 线程的实现
- 线程是比进程更轻量级的调度执行单位
- 各个线程可以共享进程资源(内存地址、文件I/O)
- 线程是CPU调度的基本单位
- Java语言中每个已经执行start()且还未结束的java.lang.Thread类的实例就代表了一个线程
- Thread类所有的关键方法都是声明为Native的
- 在Java API中,一个Native方法往往意味着这个方法没有使用或无法使用平台无关的手段来实现
- 实现线程主要有3种方式:
- 使用内核线程实现
- Kernel-Level Thread,内核线程就是直接有操作系统内核(Kernel)支持的线程
- Multi-Threads Kernel:多线程内核是支持多线程的内核,每个内核线程可以视为内核的一个分身
- Light Weight Process,LWP,轻量级进程就是我们通常意义上所讲的线程,每个轻量级进程都由一个内核线程支持,称为一对一的线程模型
- 轻量级进程的局限性:
- 各种线程操作(创建、析构及同步)都需要进行系统调度,代价相对较高
- 每个轻量级进程需要一个内核线程的支持,消耗一定的内核资源(如内核线程的栈空间),所以数量是有限的
- 使用用户线程实现
- 广义上,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT)
- 狭义上,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现
- 进程与用户线程之间1:N的关系称为一对多的线程模型
- 优势在于不需要系统内核支援
- 劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,因而使用用户线程实现的程序都比较复杂
- 使用用户线程加轻量级进程混合实现
- 用户线程与轻量级进程的数量比是不定的,即为N:M的关系,就是多对多的线程模型
-
- 使用内核线程实现
- Java的线程实现
- 在目前的JDK版本中,操作系统支持怎么的线程模型,很大程度上决定了Java虚拟机的线程怎样映射的
- Windows版与Linux版都是使用一对一的线程模型实现的
- Solaris平台可以同时支持一对一(通过Bound Threads或Alternate Libthread实现)及多对多(通过LWP/Thread Based Synchronized实现)的线程模型,通过虚拟机参数:-XX:+UseLWPSynchronization(默认值)和-XX:+UseBoundThreads来明确指定虚拟机使用哪种线程模型
12.4.2 Java线程调度
- 线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种
- Cooperative Threads-Scheduling,协同式线程调度
- 线程的执行由线程本身来控制
- 最大好处是实现简单
- 坏处:线程执行时间不可控制
- Preemptive Threads-Scheduling,抢占式调度
- 每个线程将有系统来分配执行时间,线程的切换不由线程本身来决定
- Java使用的调度方式就是抢占式调度
- Cooperative Threads-Scheduling,协同式线程调度
- Java语言设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)
12.4.3 状态转换
- Java语言定义了5中线程状态,在任意一个时间点,一个线程只能有且只有其中的一个状态:
- New,新建:创建后尚未启动的线程处于这种状态
- Runable,运行:Runable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程可能正在执行,也有可能正在等待着CPU为它分配执行时间
- Waiting,无限等待期:处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒
- 没有设置TImeout参数的Object.wait()方法
- 没有设置timeout参数的Thread.join()方法
- LockSupport.park()方法
- Timed Waiting,限期等待:处于这种状态的线程也不会被分配CPU执行时间,不够无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒
- Thread.sleep()方法
- 设置了Timeout参数的Object.wait()方法
- 设置了Timeout参数的Thread.join()方法
- LockSupport.parkNanos()方法
- LockSupport.parkUntil()方法
- Blocked,阻塞:线程被阻塞了
- “阻塞状态”与"等待状态"的区别
- “阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生
- “等待状态”则是在等待一段时间,或者唤醒动作的发生。
- 在程序等待进入同步区域的时候,线程将进入这种状态
- “阻塞状态”与"等待状态"的区别
- Terminated,结束:已终止线程的线程状态,线程已经结束执行
-
第13章 线程安全与锁优化
并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类”压榨“计算机运算能力的最有力武器
13.1 概述
- 面向过程的编程思想:把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式直接站在计算机的角度去抽象和解决问题
- 面向对象的编程思想是站在现实世界的角度去抽象和解决问题,把数据和行为都看做是对象的一部分
13.2 线程安全
- 线程安全有一个比较恰当的定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的
- 线程安全的代码都必须具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用
13.2.1 Java语言中的线程安全
- Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
- 不可变
- 不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何线程安全保障措施
- 绝对线程安全
- 绝对的线程安全完全满足Brian Goetz给出的线程安全的定义,即“不管运行时环境如何,调用者都不需要任何额外的同步措施
- 相对线程安全
- 相对的线程安全就是我们通常意义上所讲的线程安全,我们在调用的时候不需要做额外的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
- Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等
- 线程兼容
- 线程兼容是指对象本身并不是线程安全的,但是可以通过调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
- Vector和HashTable相对应的集合类ArrayList和HashMap等
- 线程对立
- 线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码
- Tread类的suspend()和resume()方法,已经被JDK声明废弃(@Deprecated)了。常见的线程对立的操作还有System.setIn()、System.setOut()和System.runFinalizersOnExit()等
- 不可变
12.2.3 线程安全的实现方法
- 代码编写如何实现线程安全和虚拟机如何实现同步和锁
- 互斥同步
- Mutual Exclusion & Synchronization,互斥同步是常见的一种并发正确性保障手段
- 同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或者是一些,使用信号量的时候)线程使用
- 互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式
- 在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字进过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象
- synchronized是Java语言中一个重量级(Heavyweight)的操作
- 还可以使用java.util.concurrent(下文称J.U.C)包中的重入锁(ReentrantLock)来实现同步,有一些高级功能
- 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
- 可实现公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都会有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
- 锁绑定多个条件:指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件
- 虚拟机在未来的性能改进中肯定也会更加偏向于原生的synchronized
- 非阻塞同步
- 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。
- Non-Blocking Synchronization,非阻塞同步:基于冲突监测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就采取其他的补偿措施
- 硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,下文称CAS)
- 加载链接/条件存储(Load-Linked/Store-Conditional,下文称LL/SC)
- 在JDK1.5之后,Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。
- J.U.C包里面的整数原子类,其中compareAndSet()和getAndIncrement()等方法都使用了UNsafe类的CAS操作
- CAS操作的“ABA”问题
- 无同步方案
- 天生就是线程安全的代码:
- 可重入代码(Reentrant Code):
- 这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调它本身),而在控制权返回后,原来的程序不会出现任何错误。
- 如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,那就能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的
- 线程本地存储(Thread Local Storage):
- 如果一段代码中所需要的数据必须与其他代码共享,那就看看这些数据的代码是否能保证在同一个线程中执行
- 大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会讲产品的消费过程尽量在一个线程中消费完
- Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为“易变的”;
- 如果一个变量要被某个线程独享,Java中没有类似C++中_declspec(Thread)这样的关键字,可以通过java.lang.ThreadLocal类来实现线程本地存储的功能
- 可重入代码(Reentrant Code):
- 天生就是线程安全的代码:
13.3 锁优化
- 锁优化技术
锁优化技术 | |
---|---|
适应性自旋 | Adaptive Spinning |
锁消除 | Lock Elimination |
锁粗化 | Lock Coarsening |
轻量级锁 | Lightweight Locking |
偏向锁 | Biased Locking |
13.3.1 自旋锁与自适应自旋
- 自旋锁:为了让线程等待,我们让线程执行一个忙循环(自旋)
- JDK1.4.2中,-XX:UseSpinning参数开启自旋锁,JDK1.6默认开启
- 自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改
- JDK1.6引入了自适应锁
13.3.2 锁消除
- 锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被监测到不可能存在共享数据竞争的锁进行消除
13.3.3 锁粗化
- 如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会吧加锁同步的范围扩展(粗化)到整个操作序列的外部
13.3.4 轻量级锁
- 轻量级锁是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为”重量级“锁。
- 轻量级锁是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗
- HotSpot对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,这部分数据得长度在32位和64位虚拟机中分别为32bit和64bit,官方称它为“Mark Word",它是实现轻量级锁和偏向锁的关键,另外一部分用于存储指向方法区对象类型数据的指针
- 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”
13.3.5 偏向锁
- 偏向锁的目的是消除数据在无竞争情况下的同位原语,进一步提高程序的运行性能
- 偏向锁会偏向于第一个获得它的线程
- -XX:UseBiasedLocking启用参数
- 偏向锁可以提高带有同步但无竞争的程序性能