《深入理解Java虚拟机》阅读笔记-第五部分 高效并发
前言:了解虚拟机Java内存模型的结构及操作,讲解原子性、可见性、有序性在Java内存模型中的体现。线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,并且介绍了虚拟机实现高效并发所做的一系列锁优化措施。
一、Java内存模型与线程
衡量一个服务性能好坏,每秒事务处理数(Transactions Per Second,TPS)是最重要的指标,代表着一秒内服务端平均能响应的请求数,与程序的并发能力有这非常密切的关系。
1. 硬件的效率与一致性
为了存储设备的读写速度能和处理器的运算速度匹配,在内存和处理器之间加入了一层缓存来进行缓冲,很好的解决了处理器与内存的速度矛盾,但也带了缓存一致性的问题。每个处理器都有自己的高速缓存,当多个处理器的运算任务涉及同一块内存区域是,将可能导致各自缓存数据不一致的情况。为了保证一致性,缓存的读写需要根据协议来进行操作,或者对指令进行乱序执行优化。
2. Java内存模型
Java虚拟机规范试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各种平台下都能达到一致的并发效果。而类似C/C++直接使用物理内存,会在不同的平台的内存模型上产生不一样的运行结果。
主内存和工作内存
Java内存模型所有变量都存储在主内存,每条线程还有自己的工作内存,工作内存保存该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都是在工作内存中完成,不能直接读写主内存,线程各自的工作内存独立,线程之间的值传递均需要通过主内存来完成。三者关系如下图:
这里的主、工作内存和Java内存区域中的Java堆、栈、方法区并不是同一层次的内存划分。从低层次的理解,主内存就是硬件的内存,而工作内存会优先存储于寄存器和高速缓存中。
内存间的交互操作
即主内存和工作内存之间的交互协议,实现细节有八大操作来完成:Lock、Unlock、Read、Load、Use、Assign、Store、Write,变量从主内存复制到工作内存为顺序执行read和load,变量同步回主内存为store和write,顺序执行并且不能单一出现。
volatile型变量的特殊规则
一个变量被定义为volatile后具备两种特性,一是可见性,指一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的,只保证可见性不保证原子性,因此也适合作为并发的控制,volatile字段改变时所有线程都可以感知到该变量发生了改变;第二个是禁止指令排序优化,前后代码的执行顺序不会被打乱。
long和double型变量的特殊规则
long和double有非原子性协定,即允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位操作来进行。如果多个进程共享一个未声明为volatile的long或double变量,那么有些线程可能会独到一个既不是原值,也不是其他线程修改值的“半个变量”,但在实际中各大平台下的虚拟机厂商都会把64位数据的读写操作作为原子操作来对待。
原子性、可见性与有序性
原子性:Java内存模型直接保证原子性的操作包括read、load、assign、use、store和write操作,大体上也可以认为基本数据类型的访问读写是具备原子性的。
可见性:即一个线程修改了共享变量的值,其他线程能够立即得知这个修改,volatile依靠修改后立即同步到主内存,使用主内存作为传递媒介方式来实现可见性。
有序性:线程内表现为串行的语义,但是线程之间来看,指令会存在重排序,也可说是工作内存和主内存同步延迟现象。
先行发生原则
“先行发生”也就是说内存模型中定义的两项操作之间的偏序关系,在先的操作的影响能够被在后的操作观察到,包括共享变量的值、发送了消息、调用了方法等。
衡量并发安全时都以先行发生原则为准,先行发生原则简单来说有:程序控制流顺序、管程锁定(lock/unlock)、volatile变量、线程启动(start方法比线程内所有动作先行)、线程终止(线程内所有操作都先于线程的终止检测)、线程中断(中断先行发生于中断线程代码检测到的中断事件)、对象终结(初始化先于finalize)、传递性(A先于B + B先于C -> A先于C),时间上的先后顺序和先行原则之间基本没有太大关系。
3. Java与线程
线程的实现
使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。
- 内核线程实现
是直接由操作系统内核支持的线程,由内核来完成线程切换、通过操纵调度器对线程进行调度。程序一般不会直接使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程,每个轻量级进程都有一个内核线程支持。 - 用户线程实现
广义上讲一个线程不是内核线程就可以认为是用户线程,狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现。线程的操作完全在用户态完成,因此快速且低消耗,支持更大规模的线程数量。但缺点在于使用用户线程实现的程序一般都比较复杂,实际上主流语言都不会采用。 - 用户线程加轻量级进程混合实现
混合实现下,既存在用户线程也存在轻量级进程,线程的操作快速、低消耗、支持大规模并发,同时又可以使用内核提供的线程调度功能及处理器映射。
对Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型,一条Java线程映射到一条轻量级进程之中。
Java线程调度
调度过程即为线程分配处理器使用权的过程,主要有两种,协同式线程调度和抢占式线程调度。
协同式调度的线程执行时间由线程本身来控制,自己的工作完成后主动通知系统切换到另一个线程上去,实现较为简单,好处是切换操作是可知的,坏处是执行时间不可控。
抢占式调度的执行时间由系统分配,线程切换不由线程本身决定,线程本身可以让出,不能获取执行时间,这样不至于导致系统崩溃。
状态转换
Java语言定义了5种进程状态,任意一个时间点,一个进程有且只有其中一种状态:
- 新建(New)
尚未启动 - 运行(Runable)
包括了操作系统中的Running和Ready,可能正在执行或者等待CPU为它分配执行时间。 - 无限期等待(Waiting)
这种状态的进程不会被分配CPU执行时间,要等待被其他线程显式的唤醒。 - 限期等待(Timed Waiting)
同上不会被分配CPU时间,但会在一定时间后由系统自动唤醒。 - 阻塞(Blocked)
阻塞是等待获取一个排它锁,在另一个线程放弃这个锁的时候发生。对比等待,等待是等一段时间或者唤醒动作。 - 结束(Terminated)
已终止,结束执行。
二、线程安全与锁优化
这部分主要描述的是HotSpot虚拟机内的即时编译器(Just In Time Compiler)的行为,与多数主流的虚拟机中的即时编译器的行为有很多相似相通之处。
1. 线程安全
线程安全的代码都具备一个特征:本身封装了所有必要的正确性保障手段,例如互斥同步等,令调用者无须关心多线程的问题,更无须自己实现任何措施来保证多线程的正确调用,
Java语言中的线程安全
可以将Java语言中各种操作共享数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
- 不可变
在Java语言中不可变的对象一定是线程安全的。 - 绝对线程安全
一个类达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”,这通常需要付出很大,甚至是不切实际的代价。大多数Java API中标注线程安全的类都不是绝对的线程安全。 - 相对线程安全
单独的操作是线程安全的,不需要额外的保障措施,但是一些特定顺序的连续调用,就可能在调用端使用额外的同步手段来保证调用的正确性。例如Vector、HashTable等。 - 线程兼容
指对象本身不是线程安全的,但是可以通过调用端正确的使用同步手段来保证对象在并发环境中安全的使用。 - 线程对立
不管是否采取了同步措施,都无法在多线程环境中并发使用的代码。
线程安全的实现方法
虚拟机提供的同步和锁机制对实现线程安全提供了便利。
- 互斥同步
互斥是方法,同步是目的,互斥是实现同步的一种手段。最基本的互斥同步手段就是synchronized关键字。除了synchronized外,还可以使用可重入锁ReentrantLock来实现同步,ReentrantLock与synchronized很相似,都具备一样的线程重入特性,可重入锁相对来说多了一些高级功能,有以下三项:
等待可中断:持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
可实现公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized是非公平的,ReentrantLock可以通过带布尔值的构造函数要求使用公平锁。
锁绑定多个条件:可重入锁可以绑定多个Condition对象,而synchronized可以通过wait/notify/ntifyAll实现一个隐含条件。 - 非阻塞同步
互斥同步也被称为阻塞同步,是一种悲观的并发策略,而非阻塞同步是一种乐观的并发策略,先进行操作,没有其他线程争用共享数据就操作成功,产生了冲突就改为重试。 - 无方案同步
可重入代码:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用不可重入的方法。一个方法只要输入了相同的数据,就能返回相同的结果,那么就满足可重入要求,是线程安全的。
线程本地存储:一段代码所必需的的数据必须与其他代码共享,但也可以保证这些共享数据在同一个线程中执行,这样无须同步也能保证线程之间不出现数据争用的问题。
2. 锁优化
自旋锁与自适应自旋
互斥同步对性能造成最大的影响时阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,给系统的并发性能带来很大的压力。
但实际上共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,如果物理机有一个以上的处理器,能够让两个或以上的线程同时并行执行,那么就可以让后面请求锁的那个线程“稍等一会”,不放弃处理器的执行时间,看看持有锁的线程是否很快会释放锁,这样一个让线程等待,即让线程执行一个忙循环的技术,就是自旋锁。
自适应自旋锁意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。选择是否自旋是一个不断适应的过程,自旋后成功获得锁的与否都会对后来的自旋时间做出帮助,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越准确。
锁消除
指的是虚拟机即时编译器在运行时对一些代码上要求同步,但是被检测不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析数据支持,也就是一段代码在堆上所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为是线程私有的,即同步加锁也无须进行。
锁粗化
一般原则下,我们总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样也是为了让锁相关操作性能更高。
但是有时候也会出现一系列连续操作都对同一个对象反复加锁和解锁,例如加锁操作出现在循环体里,即使没有竞争,频繁的互斥同步操作也会导致不必要的性能损耗,锁粗化技术就是会把加锁同步的范围扩展到整个操作序列的外部,这样只需要加锁一次就可以了。
轻量级锁
传统的锁机制被称为重量级锁,轻量级锁的目的不是代替重量级锁的,本意是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。
解释轻量级锁需要先了解虚拟机对象的内存布局,对象头的第一部分用于存储对象自身的运行时数据,也被称为“Mark Word”,使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的锁记录空间(Lock Record),如果更新成功,则轻量级锁获取成功,否则,说明已有线程获得了轻量级锁,即当前情况下已经发生了锁竞争,不适宜继续使用轻量级锁,接下来膨胀为重量级锁。
缺点是对于锁竞争激烈的场景,维持轻量级锁的过程就造成了浪费。
偏向锁
偏向锁的目的是消除无竞争情况下的同步原语,进一步提高程序的运行性能。轻量级锁时在无竞争的情况下使用CAS操作去消除同步使用的互斥量,偏向锁就是在无竞争状态下把整个同步都消除掉,连CAS操作都不做了。
偏向锁的“偏”的意思是指会偏向于第一个获得它的线程,只有第一个申请锁的线程会使用锁,在Mark Word中CAS记录所有者,如果接下来的执行过程中该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步,偏向锁获取成功。否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁无法使用自旋锁优化,一旦有其他线程申请锁,就破坏了偏向锁的假定。
Ref
周志明. 深入理解Java虚拟机[M]. 机械工业出版社, 2013.