1 内存
1.1 内存一致性
由于计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory),如图所示:
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly 及 DragonProtocol,等等。
1.2 主内存与工作内存
Java 内存模型的主要目标是定义程序各个变量的访问规则,即在虚拟机机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量(Variable)与 Java 编程中所说的变量略有区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的[3],不会被共享,自然就不存在竞争问题。为了获得较好的执行效能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码执行顺序这类权利,线程对变量的操作都必须在工作内存中进行,而不能直接读写主内存,线程间无法直接访问对方工作内存的中变量,线程间变量值得传递均需要通过主内存来完成。如图所示:
1.3 内存间的交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下八种操作来完成:
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。
write(写入):作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中。
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。Java 内存 模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间, store 和 write 之间是可以插入其他指令的,如对主内存中的变量 a、b 进行访问时,可能的顺 序是 read a,read b,load b, load a。
Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
不允许 read 和 load、store 和 write 操作之一单独出现
不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现
如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
1.4 重排序
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:
1.编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
2.指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3.内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 Java 源代码到最终实际执行的指令序列,会经过下面三种重排序:
为了保证内存的可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java 内存模型把内存屏障分为 LoadLoad、LoadStore、StoreLoad 和 StoreStore 四种:
1.5 volatile 型变量
当一个变量定义为 volatile 之后,它将具备两种特性:
第一:保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量的值在线程间传递需要通过主内存来完成
由于 valatile 只能保证可见性,在不符合一下两条规则的运算场景中,我们仍要通过加锁来保证原子性
1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2.变量不需要与其他的状态变量共同参与不变约束
第二:禁止指令重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致,这个就是所谓的线程内表现为串行的语义
Java 内存模型中对 volatile 变量定义的特殊规则。假定 T 表示一个线程,V 和 W 分别表示两个 volatile 变量,那么在进行 read、load、use、assign、store、write 操作时需要满足如下的规则:
1.只有当线程 T 对变量 V 执行的前一个动作是 load 的时候,线程 T 才能对变量 V 执行 use 动作;并且,只有当线程 T 对变量 V 执行的后一个动作是 use 的时候,线程 T 才能对变量 V 执行 load 操作。线程 T 对变量 V 的 use 操作可以认为是与线程 T 对变量 V 的 load 和 read 操作相关联的,必须一起连续出现。这条规则要求在工作内存中,每次使用变量 V 之前都必须先从主内存刷新最新值,用于保证能看到其它线程对变量 V 所作的修改后的值。
2.只有当线程 T 对变量 V 执行的前一个动是 assign 的时候,线程 T 才能对变量 V 执行 store 操作;并且,只有当线程 T 对变量 V 执行的后一个动作是 store 操作的时候,线程 T 才能对变量 V 执行 assign 操作。线程 T 对变量 V 的 assign 操作可以认为是与线程 T 对变量 V 的 store 和 write 操作相关联的,必须一起连续出现。这一条规则要求在工作内存中,每次修改 V 后都必须立即同步回主内存中,用于保证其它线程可以看到自己对变量 V 的修改。
3.假定操作 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 相应的对变量 V 的 read 或 write 操作。如果 A 先于 B,那么 P 先于 Q。这条规则要求 valitile 修改的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。
1.6 对于 long 和 double 型变量的特殊规则
Java 模型要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都具有原子性,但是对于 64 为的数据类型(long 和 double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作分为两次 32 为的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性
1.7 原子性、可见性和有序性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。Java 内存模型是通过在变量修改后将新值同步会主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性,valatile 特殊规则保障新值可以立即同步到祝内存中。Synchronized 是在对一个变量执行 unlock 之前,必须把变量同步回主内存中(执行 store、write 操作)。被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有吧 this 的引用传递出去,那在其他线程中就能看见 final 字段的值
可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性:即程序执行的顺序按照代码的先后顺序执行。
1.8 先行发生原则
这些先行发生关系无须任何同步就已经存在,如果不再此列就不能保障顺序性,虚拟机就可以对它们任意地进行重排序
1.程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说,应该是控制顺序而不是程序代码顺序,因为要考虑分支。循环等结构
2.管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而后面的是指时间上的先后顺序
3.Volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的后面同样是指时间上的先后顺序
4.线程启动规则:Thread 对象的 start()方法先行发生于此线程的每一个动作
5.线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.joke()方法结束、ThradisAlive()的返回值等手段检测到线程已经终止执行
6.线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生
7.对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始
8.传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论
2 Java 线程
协同式调度:线程的执行时间由线程本身控制
抢占式调度:线程的执行时间由系统来分配
2.1 状态转换
1.新建
2.运行:可能正在执行。可能正在等待 CPU 为它分配执行时间
3.无限期等待:不会被分配 CUP 执行时间,它们要等待被其他线程显式唤醒
4.限期等待:不会被分配 CUP 执行时间,它们无须等待被其他线程显式唤醒,一定时间会由系统自动唤醒
5.阻塞:阻塞状态在等待这获取到一个排他锁,这个时间将在另一个线程放弃这个锁的时候发生;等待状态就是在等待一段时间,或者唤醒动作的发生
6.结束:已终止线程的线程状态,线程已经结束执行
2.2 线程安全
1、不可变:不可变的对象一定是线程安全的、无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障。例如:把对象中带有状态的变量都声明为 final,这样在构造函数结束之后,它就是不可变的。
2、绝对线程安全
3、相对线程安全:相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
4、线程兼容:对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全使用
5、线程对立:是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码
2.3 线程安全的实现方法
1.互斥同步:
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果:互斥是方法,同步是目的
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,它经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个字节码都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象参数,那就是这个对象的 reference;如果没有指明,那就根据 synchronized 修饰的是实例方法还是类方法,去取对应的对象实例或 Class 对象来作为锁对象。在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1,对应的在执行 monitorexit 指令时会将锁计数器减 1,当计数器为 0 时,锁就被释放。如果获取对象锁失败,哪当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止
Synchronized,ReentrantLock 增加了一些高级功能
1.等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助
2.公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;非公平锁则不能保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。Synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
3.锁绑定多个条件是指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象,而在 synchronized 中,锁对象的 wait()和 notify()或 notifyAll()方法可以实现一个隐含的条件,如果要和多余一个的条件关联的时候,就不得不额外地添加一个锁,而 ReentrantLock 则无须这样做,只需要多次调用 newCondition 方法即可
2.非阻塞同步
3.无同步方案
可重入代码:也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身)而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
判断一个代码是否具备可重入性:如果一个方法,它的返回结果是可预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的
线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保障,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题
2.4 锁优化
适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁
2.4.1 自旋锁与自适应自旋
自旋锁:如果物理机器上有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一下,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁
自适应自旋转:是由前一次在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自过程,以避免浪费处理器资源。
2.4.2 锁消除
锁消除是指虚拟机即时编辑器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。如果在一段代码中。推上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行
2.4.3 锁粗化
如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
2.4.4 轻量级锁
2.4.5 偏向锁
它的目的是消除无竞争情况下的同步原语,进一步提高程序的运行性能。如果轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把这个同步都消除掉,CAS 操作都不做了
如果在接下俩的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要在进行同步
3 逃逸分析
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,成为方法逃逸。甚至还可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸
如果一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化
栈上分配:如果确定一个对象不会逃逸出方法外,那让这个对象在栈上分配内存将会是一个不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。如果能使用栈上分配,那大量的对象就随着方法的结束而销毁了,垃圾收集系统的压力将会小很多
同步消除:如果确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉
标量替换:标量就是指一个数据无法在分解成更小的数据表示了,int、long 等及 refrence 类型等都不能在进一步分解,它们称为标量。
如果一个数据可以继续分解,就称为聚合量,Java 中的对象就是最典型的聚合量
如果一个对象不会被外部访问,并且这个对象可以被拆散的化,那程序正整执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替