Java内存模型与线程
概述
为了解决内存与cpu之间的速度矛盾,在两者之前引入了写速度尽可能接近cpu运算速度的高速缓存:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理就无须等待缓慢的内存读写了。
但是这也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性。在多处理系统中,每个处理都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
内存模型:可以理解为在特定的操作协议下,对特定的内存和高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型。
Java内存模型(Java Memory Model,JMM)从jdk1.2之后建立起来并在jdk1.5中完备过的内存模型。Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台都能达到一致的内存访问效果。C、C++等直接使用武力硬件和操作系统的内存模型,会由于不同平台内存模型的差异,导致程序无法完美的移植。
主内存和工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。 这里的比那里与Java编程中所说的变量有所区别,它包括了实例字段,静态字段和构成数组对象的元素,但不包括局部变量和方法参数(线程私有的,不会被共享,不存在竞争)
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),工作内存中保存了被该线程使用的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程见变量值的传递均需要通过主内存来完成。
这里的主内存和工作内存与Java内存区域中的Java堆,栈,方法区等不适同一个层次的内存划分。
主内存和工作内存之间具体的交互协议:一个变量如何从主内存拷贝到工作内存,如果从工作内存同步回主内存等的实现细节。
Java内存模型定了8中操作来完成,虚拟机必须保证每一种操作都是原子的,不可再分的:
(1)lock:锁定,作用于主内存的变量,它把一个变量标识为一条线程独占的状态;
(2)unlock:解锁,作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
(3)read:读取,作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
(4)load:载入,作用于工作内存的变量,它把read操作从内存中得到的变量值放入工作内存的变量副本中;
(5)use:使用,作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时将会执行这个操作;
(6)assign:赋值,作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
(7)store:存储,作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用;
(8)write:写入,作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序执行read和load操作,如果把变量从工作内存同步回主内存,就要顺序地执行store和write操作。
Java内存模型还固定了在执行8种基本操作时必须满足的规则:
(1)不允许read和load,store和write操作之一单独出现;
(2)不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变之后必须把该变化同步回主内存;
(3)不允许一个线程无原因的(没有发生过任何assign操作)把数据从工作内存同步回主内存中;
(4)一个新变量只能在主内存中“诞生”;
(5)一个变量在同一个时刻只允许一条线程对其进行lock操作,并且可以被同一条线程重复执行多次;
(6)多一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值;
(7)如果一个变量事先没有被lock锁定,就不允许对他执行unlock,也不允许去unlock一个被其他线程锁定的变量;
(8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中
volatile
volatile具备两种特性:(1)保证此变量对所有线程可见性(2)禁止指令重排序优化。
volatile变量在规格线程中的工作内存中不存在一致性的问题:在规格线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在不一致性问题。但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。
原子性,可见性与有序性
Java内存模型是围绕着在并发过程中如何处理原子性,可见性和有序性这3个特征来建立的
(1)原子性:Java内存模型来直接保证的原子性变量操作包括read,load,assign,use,store,write;
lock和unlock操作对应的字节码指令是monitorenter和monitorexit,反应到Java代码中就是同步块--synchronized关键字,因此在synchronized块之间的操作也具备原子性。
(2)可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
(3)有序性:Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。
先行发生原则
(1)程序次序规则
(2)管程锁定规则
(3)volatile变量规则
(4)线程启动规则
(5)线程终止规则
(6)线程中断规则
(7)对象终结规则
(8)传递性
一个操作“时间上的先发生”不代表这个操作会是“先行发生”,一个操作是“先行发生”也未必是“时间上的先发生”。
衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
线程的实现
主要有3种方式:(1)使用内核线程实现(2)使用用户线程实现(3)使用用户线程加轻量级进程混合实现
(1)内核线程:就是直接由操作系统(kernel,内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到规格处理器上。一般不会直接去使用内核线程,而是使用内核线程的一种高级接口--轻量级进程。
(2)用户线程:广义上,一个线程只要不是内核线程,就可以认为是用户线程,从这个定义上来说,轻量级进程也数据用户线程。狭义上,用户线程是指完全建立在用户控件的线程库上,系统内核不能感知线程存在的实现。
(3)用户线程加轻量级进程混合:即前两种的混合。在这种混合实现下,即存在用户线程,也存在轻量级进程,用户线程还是完全建立在用户空间中,因此用户线程的创建,切换,析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。
Java线程实现
(1)实现:从jdk1.2之后,线程模型替换为基于操作系统原生线程模型来实现的。操作系统支持怎么样的线程模型,在很大程度上决定了Java虚拟机线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中并未限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是透明的。
Sun JDK它的Windows和Linux版本都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中。
(2)指系统为线程分配处理器使用权的过程,主要调度方式有两种:(A)协同式线程调度(B)抢占式线程调度;Java使用的调度方式就是抢占式调度。
(3)装填转换
- 新建:创建后尚未启动的线程
- 运行:处于此状态的线程有可能正在执行,也有可能正在等待着cpu为它分配执行时间
- 无限等待:不会被分配cpu执行时间,要等待被其他线程显式地唤醒,如Object.wait(),Thread.join(),LockSupport.park()
- 有限等待:超时可由系统自动唤醒。如Thread.sleep(),Object.wait(long),Thread.join(long),LockSupport.parkNanos(),LockSupport.parkUntil()
- 阻塞:阻塞状态是指在等待着获取到一个排他锁;等待状态是指在等待一段时间或唤醒动作的发生。
- 结束:已终止。
线程安全
定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用整个对象的行为都可以获得正确的结果,那整个对象是线程安全的。
线程安全的实现方法
(1)互斥同步
在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
(2)非阻塞同步(CAS)
互斥同步最重要的问题就是进行现场阻塞和唤醒锁带来的性能问题,因此这种同步也称为阻塞同步(悲观锁),而CAS操作时一种非阻塞同步(乐观锁)详情请参考《多线程-CAS原理》。
(3)无同步方案
如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。线程本地存储。
锁优化
(1)自旋锁和自适应自旋
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成(Java线程是基于内核线程实现),这些操作给系统的并发性能带来了很大的压力。同时,在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并发执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
jdk1.6中引入了自适应的自旋锁,自适应意味着自旋的时间不再固定了,而是由前一次在同一锁上的自旋时间及锁的拥有者的状态来决定。
(2)锁消除
虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。
(3)锁粗化
原则上,推荐奖同步块的作用范围限制得尽量小--只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能小。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个序列的外部。
(4)轻量级锁
jdk1.6加入的新型锁机制。相对于使用操作系统互斥量来实现的传统锁而言,因此传统的锁机制称为“重量级”锁。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效。
轻量级锁能够提升程序性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的。
如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销。
它的本意是在没有多线竞争的前提下,减少传统的重量级锁使用操作系统互斥量的性能消耗。
(5)偏向锁
jdk1.6引入的一项锁优化。目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
偏向锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步了。
当有另外一个线程去尝试获取这个锁时,偏向锁就宣告结束。
偏向锁可以提高带有同步但无竞争的程序性能,它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向锁模式就是多余的。
参考资料
深入理解Java虚拟机:JVM高级特性与最佳实践(第二版)