五、编译优化技术
5.1 逃逸分析
逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术
逃逸分析的基本原理是: 分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用
- 例如作为调用参数传递到其他方法中,这种称为方法逃逸
- 甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸
- 从
不逃逸 、方法逃逸 到线程逃逸 ,称为对象由低到高的不同逃逸程度。
关于逃逸的优化:
- 栈上分配: 如果
确定一个对象不会逃逸出线程之外 ,那让这个对象在栈上分配内存将会是一个很不错的主意。即可以让这个对象随栈的弹出而释放,栈上分配可以减轻Java堆垃圾回收的压力 - 标量替换:
- 若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据 就可以被称为标量
- 如果可再分就被称为聚合量
- 把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型 来访问,这个过程就称为标量替换
- 同步消除: 线程同步本身是一个相对耗时的过程,如果逃逸分析
能够确定一个变量不会逃逸出线程 ,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉 。
六、高效并发
6.1 Java内存模型与线程模型
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节
JMM规定:
- 所有变量都存储在主内存中(Main Memory)中。主内存主要对应于Java堆中对象实例数据部分
- 每条线程都有自己的工作内存(Working Memory),工作内存中保存该线程使用的变量关于主内存的副本拷贝 。工作内存对应于虚拟机栈中的部分区域
- 线程对变量所有操作都必须在工作内存中进行,不能直接读写主内存的变量
内存直接的交互:八大操作
- lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
- unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量 才可以被其他线程锁定
- read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便 随后的load动作使用
- load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
- use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机 遇到一个需要使用到变量的值,就会使用到这个指令
- assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变 量副本中
- store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中, 以便后续的write使用
- write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内 存的变量中
并且八大操作必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须 write
- 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有assign的数据从工作内存同步回主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量 实施use、store操作之前,必须经过assign和load操作
- 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解 锁 如果对
- 一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前, 必须重新load或assign操作初始化变量的值
- 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
- 对一个变量进行unlock操作之前,必须把此变量同步回主内存
6.2 volatile型变量的特殊规则
Volatile是Java虚拟机提供的轻量级同步机制,具有以下特点
- 1、保证可见性
- 2、禁止指令重排
- 3、注意:不保证原子性
“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
禁止指令重排:
x = 1;
y = x;
x = 2;
如上,如果允许指令重排,x=2可能被换到x=1的位置,这样最终y的值就不对了
禁止指令重排,可以在加了volatile关键字的变量上加上内存屏障,不让指令重排越过这一层屏障
6.3 synchronized和锁优化
6.3.1 从JVM角度解读synchronized
Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成。这就不可避免**用户态到内核态的切换 ** ,这种状态切换需要耗费很多处理器时间
阻塞同步:
最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。
synchronized关键字经过Javac编译 之后,会在同步块的前后分别形成 monitorenter 和monitorexit 这两个字节码指令。
一个对象有一个锁的计数器,当一个线程执行到monitorenter 时会看看这个计数器是否为0,为0就拿到锁,并让这个计数器的值加一。当一个线程执行到monitorexit 时会让整个计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
synchronized锁 在JDK6之前使用的是操作系统内部的互斥量来实现的,持有锁是一个重量级(Heavy-Weight) 的操作,对于一些简单的代码,可能状态切换消耗的时间会比代码本身执行的时间还要长
非阻塞同步:
由于synchronized太笨重了,之后出现了基于冲突检测的乐观并发策略:当发生竞争的时候可以使用硬件指令集不断重试(如CAS),直到资源没有竞争为止,这种方式也常被称为无锁编程
从JDK5到JDK6后,Hotspot耗费了大量资源实现各种锁优化技术
如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等
6.3.2 自旋锁与自适应自旋
前面说到了,synchronized重量级锁挂起线程和恢复线程需要到内核态去进行,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得
所以当两个或以上线程竞争一个资源的时候,可以让后面请求锁的线程稍微等一下,只需要让线程执行一个忙循环(自旋) ,这就是自旋锁
JDK6之前自旋锁默认关闭,可以使用-XX:+UseSpinning 参数开启。如果超过自旋时间太长也会白白浪费资源,所以长时间自旋应该让这个线程挂起,自旋次数默认为10次,可以使用-XX:PreBlockSpin设置自旋次数
JDK6对自旋锁进行了优化,引入了自适应的自旋,可以让虚拟机自行判断自旋时间,并且越预测越精准
6.3.3 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
这时候就需要用到逃逸分析 提供支持,例如一段代码中,在堆上的所有数据都不会逃逸到其他线程,那就可以把它们当作栈上数据对待,栈上数据是线程私有的,同步加锁自然无须进行。
6.3.4 锁粗化
如果一系列连续的操作都对同一个对象反复加锁解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
6.3.5 轻量级锁
轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁
轻量级锁的工作过程:
- 在代码即将进入同步块时,如果此同步对象被锁定(对象头锁标志位为"01")
- 虚拟机会首先在当前线程的栈帧中建立一个名为**
锁记录Lock Record ** 的空间 - 然后将**
锁对象的Mark Word拷贝 ** 到这个空间中,这份拷贝加了一个Displaced前缀; - 接着虚拟机尝试用CAS 把锁对象头Mark Word**
改变为一个指向Lock Record的指针 **
- 如果成功了就代表线程拥有了锁,并把锁对象Mark Work的锁标志位改为"00"
- 如果失败了就代表至少有一条线程与当前线程竞争该锁,
虚拟机会检测Mark Work是否指向当前线程的栈帧
- 如果是,就代表当前线程拥有锁,直接进入同步块继续执行就可以了
- 如果不是,就代表锁对象被其他线程抢占了。
- 如果出现两条以上线程竞争同一个锁,就需要膨胀为重量级锁 ,锁标志变为"10" ,Mark Word存储的就是重量级锁(互斥量)的指针
6.3.6 偏向锁
偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是**消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。 **
如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
偏向锁原理:
假设虚拟机开启了偏向锁-XX:+UseBiased Locking ,JDK6默认开启
- 锁对象第一次被线程获取的时候,虚拟机会把对象头中标志位设为"01" ,把偏向模式设为"1"
- 同时使用CAS操作把获取到这个锁的线程ID 记录到Mark Word上
- 如果CAS成功,持有偏向锁的线程(Mark Word记录的线程)每次进入这个锁相关的同步块时都不用进行任何同步操作
- 一旦出现另一个线程尝试获取这个锁,偏向模式马上宣告结束。撤销后把标志位恢复到**
未锁定"01" 或轻量级锁定状态"00" **,后续同步操作就按轻量级锁那样执行
我们知道Mark Word会存放哈希码,而且这个hashcode值一般是需要强制保持不变的,而偏向锁需要使用Mark Word来存放线程ID,这怎么办呢?
事实上,当一个对象计算过哈希值之后,它就无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求[时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码
偏向锁可以提高带有同步但无竞争的程序性能 ,但它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。 在具体问题具体分析的前提下,有时候使用参数-XX:- UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。
6.3.7 synchronized锁升级
无锁->偏向锁->轻量级锁->重量级锁
锁只能升级不能降级!!!
|