略....
略....
程序计数器(Program Counter Register)
它可以看作当前线程所执行的字节码的行号指示器。
为了线程切换后能恢复到正确的执行位置,每个线程都需要一个独立的计数器。线程私有
它是唯一一个没有OutOfMemoryError情况的区域
Java虚拟机栈(Java Virtual Machine Stack)
也是线程私有的
它的生命周期与线程相同
每个方法被执行都会同步创建一个栈帧。即栈中存储的就是栈帧
每个栈帧中有独立的 局部变量表 操作数栈 动态链接 方法出口等信息。
局部变量表存放了编译期可知的各种 Java基本数据类型 对象引用(reference类型,可以是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与对象相关的位置) returnAddress返回地址(指向了一条字节码指令的地址)
局部变量表的基本单位 变量槽slot 64位的long double占用两个变量槽 其余的只占用一个。局部变量表所需的内存空间在编译期已确定。
如果线程请求栈深度大于虚拟机栈所允许的最大深度,将抛出StackOverFlowError
如果Java虚拟机栈允许扩容但无法申请到足够内存时,将抛出OutOfMemoryError
注意:!HotSpot虚拟机栈容量是不可以动态扩展的,所以HotSpot虚拟机不会由与虚拟机无发扩展而导致OutOfMemoryError 只要线程申请栈空间成功就不会有OOM,但是如果申请失败就仍然会出现OOM。
理解:hotspot不会因为扩容问题出现OOM,但是线程申请栈空间失败会出现OOM,这个失败的原因肯定不是超过了最大深度,因为超过最大深度抛出的是StackOverFlow异常。
本地方法栈(Native Method Stacks)
与虚拟机栈类似,只是它执行的是本地方法而非Java方法。
HotSpot已将虚拟机栈与本地方法栈结合
Java堆(Java Heap)
Java堆在虚拟机启动时创建,被所有线程共享的最大区域,存放对象实例。
共享的Java堆中可以划分线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)
-Xmx 最大堆内存 -Xms 最小堆内存(启动时的堆大小)
堆没有内存完成实例分配,并且无法扩展抛出OutOfMemoryError
分代收集 Eden
To Survivor
From Survivor
方法区(Method Area)
与堆同样是线程共享区域
储存 类型信息 常量 静态变量 即使编译器编译后的代码缓存。
Jdk8以前用"永久带"实现方法区,之后用本地物理内存"元空间"实现
如果方法区无法满足新的内存分配需求,抛出OutOfMemoryError
运行时常量池(Runtime Constant Pool)
它是方法区的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
与Class文件常量池不同的是具备动态性,可以将新常量放入常量池而并非一定要预置于Class文件中,比如String类的intern()方法。
异常同方法区,因为本就是方法区的一部分。
即本地物理内存,也会导致OutOfMemoryError
JDk1.4中加入的NIO 可以使用ByteBuffer.allocateDirect()返回的DirectByteBuffer对象操作直接内存
当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析、初始化。如果没有,先加载对应的类。加载检查通过后,将为新对象分配内存。
如果堆中是规整的,分已使用和未使用两个区,一个指针指向临界点,分配内存仅仅是移动指针,这种方式称"指针碰撞"
如果不规整,虚拟机必须维护一个表来记录哪些可用内存,这种分配方式是"空闲列表"
在并发情况下并分配内存也不是线程安全的,有两种解决方案:
- 分配内存时采用CAS配上失败重试进行同步处理
- 使用本地线程缓冲(TLAB),分配在缓冲区。通过
XX: +/-UseTLAB
参数赖设定
分配完内存后,虚拟机必须将分配到的内存空间初始化零值(不包括对象头,如果使用了TLAB会在TLAB分配时顺便进行)
接着设置对象头,再执行()方法(构造)
cmpxchg是x86中的CAS指令 这是一个C++方法
分为三个部分:对象头(Header) 实例数据(Instance Data) 对齐填充(Padding)
对象头:分两类信息
- 第一类信息用与存储对象自身的运行时数据,如 哈希码 GC分带年龄 锁状态标志 线程持有的锁 偏向线程ID 偏向时间戳。官方称为"Mark Word"。32位虚拟机中mark word占32位,64位占64位
- 第二类信息是类型指针。此外如果是数组对象必须有一块用于记录数组长度。
实例数据:
实例数据中包含自己与父类的实例数据,分配的顺序受 -XX:FieldsAllocationStyle
参数和定义的顺序影响。Hotspot默认顺序:longs/doubles ints shorts/chars bytes/booleans oops,宽度相同的总是被分配到一起。在这个条件下,父类的参数总是在子类之前,但参数 +XX:CompactFields
为true时(默认为ture)子类中较窄的变量也运行插入父类变量的空隙中。
对齐填充:
HostSpot虚拟机要求对象起始地址必须是8字节的整数倍。换句话说就是对象大小必须是8字节的整数被,不足则会填充。
由栈上的reference类型访问对象。主流的访问方式主要有 “句柄” 和 “直接指针” 两种。
优缺点:句柄访问在内存频繁移动时,不需要大量修改reference地址,只要修改句柄上的对象地址,但是访问速度慢。而指针访问速度快,但对象移动时,所有指向这个对象的reference地址都需要改动。Hotspot使用的是直接指针(如果使用的Shenandoah收集器的话也会有一次额外的转发)。
详见代码readbook.c241
VM Args: -Xms20m(最小堆内存) -Xmx20m(最大堆内存)
+HeapDumpOnOutOfMemoryError(Dump内存堆快照)
-Xoss 设置本地方栈的大小 对Hotspot没有意义
-Xss 设置栈容量 Hotspot栈不可扩容不能设置最大值
-XX:MaxMetaspaceSize 元空间最大容量 默认-1 即不限制 或只受限于本地内存
-XX:MetaspaceSize 元空间初始容量 当达到该值时就会GC 并根据需要修改容量 但不超过设置的最大值
-XX:MaxMetaspaceFreeRatio 调整GC后最小元空间剩余容量的百分比
-XX:MinMetaspaceFreeRatio 最大元空间剩余容量的百分比
在1.7之后 String常量池放在堆内存中
/**
* 在1.6前会打印两个false
* 因为 StringBuild创建的String对象在堆中 str1.intern() 对象放在了方法区中
* 而在1.7之后会打印一个true 一个false
* 打印true是因为 intern() 放入String常量池中 而String常量池已经移到了堆上
* 而打印false是因为sun.misc.Version类加载时就将java放入了String常量池(堆)
* StringBuild创建的就是另一个对上的对象了
*/
public class RuntimeConstantPool {
public static void main(String args[]) {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1); // 1.6 false 1.7 true
String str1 = new StringBuilder("ja").append("va").toString();
System.out.println(str1.intern() == str1);// 1.6 false 1.7 false
}
}
-XX:MaxDirectMemorySize 最大直接内存 默认值与java堆一致(-Xmx的值)
如果使用了DirectMemory的程序需要注意(比如NIO DirectByteBuffer)
略....(请看上面的笔记)
略....
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
优缺点:实现简单,相互引用就会产生内存泄露
通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GCRoots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
GCRoots对象:
- 在虚拟机栈(本地变量表)中引用的对象
- 在方法区中类静态属性引用的对象
- 在方法区中常量引用的对象
- 在本地方法栈中JNI(Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象 一些常驻的异常对象(NullPoint,OOM),还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
引用分为:
- 强引用(Strongly Reference):
指在程序代码之中普遍存在的引用赋值,类似Object obj = new Object()
只要强引用关系还存在,垃圾收集器就永远不会回收。 - 软引用(Soft Reference):
描述非必须的对象。在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。JDK 1.2版之后提供了SoftReference类来实现软引用。 - 弱引用(Weak Reference):
被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。 - 虚引用(PhantomReference)
虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。 这4种引用强度依次逐渐减弱。
经过可达性分析后被标记的对象并不会马上死亡,GC系统会筛选出此对象是否必要执行finalize()方法。如果对象没有重写,或已经被调用过一次(每个对象虚拟机只会调用一次finalize()方法),虚拟机就判断不再需要执行,进入"即将回收"的集合。如果重写了没有执行过,则会进入F-Queue队列让一个低调用线程去执行(但并不保证可以直接结束)。如果在执行方法时,this对象被重新引用,对象就不会被销毁,反之则进入"即将回收"的集合。
废弃的常量:
没有任何引用,垃圾收集器判断有必要的话会被回收。
不再使用的类型: 判定是否废弃的类型需要满足3个条件:
- 该类所有实例(包括派生类)被回收
- 该类的类加载器被回收
- 该类的Class对象没有任何引用
满足以上3个条件也仅仅是"允许被回收"
-Xnoclasscg 参数控制是否有类回收
有大量的反射,动态代理,CGLib等字节码框架,动态生成Jsp以及OGSi这类频繁自定义类加载器的场景中需要开启。
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
标记需要回收的对象/不需要回收的对象,清楚被标记/未被标记的对象。
缺点:效率不稳定,空间碎片化
"半区复制" 即 To Survivor
From Survivor
优缺点:空间连续,浪费了一定空间,只适合存活对象不多的区域。所以用于新生代回收算法。
Eden与Survivor 比例 8:1:1
即在标记清除上多做一次整理,让内存空间不在有碎片。
HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中
所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。
HotSpot没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。
安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:
- 抢先式中断(Preemptive Suspension):系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
- 主动式中断(Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
所有涉及部分区域收集行为的垃圾收集器都会面临对象跨代引用所带来的问题。解决方案就是依靠记忆集。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
记忆集的的记录精度:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节。 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作。
伪共享:现代**处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
HotSpot虚拟机增加了一个新的参数 -XX:+UseCondCardMark ,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
三色标记:
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
如果用户线程与收集器是并发工作,收集器在对象图上标记颜色,同时用户线程在修改引用关系,可能出现两种后果:
- 把原本消亡的对象错误标记为存活。可以容忍。
- 原本存活的对象错误标记为已消亡。非常致命。
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题(2问题),即原本应该是黑色的对象被误标为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用。
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:
-
增量更新(Incremental Update):
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
。 -
原始快照(Snapshot At TheBeginning,SATB): 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,
无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索
。
这里讨论的是在JDK 7Update 4之后(在这个版本中正式提供了商用的G1收集器,此前G1仍处于实验状态)、JDK11正式发布之前,OracleJDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器。
上图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择,这个收集器是一个单线程工作的收集器。强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
它是所有收集器里额外内存消耗(Memory Footprint)最小的。
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio(设置eden占n/10 默认8)、-XX:PretenureSizeThreshold(设置超过n大小直接在old区分配 默认0 优先eden分配)、-XX:HandlePromotionFailure
等)、收集算法、Stop TheWorld、对象分配规则、回收策略等都与Serial收集器完全一致。
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器……Parallel Scavenge的诸多特性从表面上看和ParNew非常相似。
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。吞吐量 = 运行时代码时间/运行时代码时间 + 运行垃圾收集时间
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:
- 控制最大垃圾收集停顿时间的
-XX:MaxGCPauseMillis
:
允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的。 - 设置吞吐量大小的参数
-XX:GCTimeRatio
:
参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。(N:1
N 即设置的值 如19 吞吐量 = 1/(19+1) 如99 吞吐量 = 1/(99+1) 默认值为99)
Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注:-XX:+UseAdaptiveSizePolicy
:
这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。
只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
Parallel/Parallel Old 收集器运行示意图: 注*这是jdk1.8默认收集器组合!
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。是基于标记-清除算法实现的。
它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见3.4.6节中关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
Concurrent Mark Sweep 收集器运行示意图:
Garbage First(简称G1)收集器是面向局部收集的设计思路和基于Region的内存布局形式。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。可以通过参数-XX:G1HeapRegionSize
设定。G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis
指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。
解决跨Region引用:每个Region都维护有自己的记忆集,G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。通过原始快照(SATB)算法来实现收集线程与用户线程互不干扰。
的运作过程大致可划分为以下四个步骤:
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象。这个阶段需要停顿线程。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation):根据用户所期望的停顿时间来制定回收计划。必须暂停用户线程,由多条收集器线程并行完成的。
-XX:MaxGCPauseMillis
:允许的收集停顿时间,默认值是200毫秒。
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency)
下图,中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。
可见,在CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop TheWorld”式的停顿;CMS和G1分别使用增量更新和原始快照(见3.4.6节)技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。
最后的两款收集器,Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time GarbageCollector)。
Shenandoah是由RedHat公司独立发展的新型收集器项目。Shenandoah像是G1的下一代继承者。它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码。
Shenandoah相较G1的改进,虽然Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的Region……但在管理堆内存方面,它与G1至少有三个明显的不同之处:
- 最重要的当然是支持并发的整理算法。
- Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在。
- Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系。降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题。
Shenandoah收集器的工作过程大致可以划分为以下九个阶段:
- 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
- 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
- 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
- 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
- 并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。并发回收阶段运行的时间长短取决于回收集的大小。
- 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务。
- 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。
- 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GCRoots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
- 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。
Shenandoah用以支持并行整理的核心概念——Brooks Pointer:转发指针
在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。与句柄定位有一些相似之处,柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。
转发指针与并发写入:(并发读取无论是新对象还是就对象都是一致的) 通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性的。
ZGC内存布局:
与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region。ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Region可以具有大、中、小三类容量:
- 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
- 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
- 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。
并发整理算法的实现:TODO
一般来说,收集器的选择就从以上这几点出发来考虑:
- 应用程序的主要关注点是什么?如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。
- 运行应用的基础设施如何?譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是ARM/Aarch64;处理器的数量多少,分配内存的大小;选择的操作系统是Linux、Solaris还是Windows等。
- 使用JDK的发行商是什么?版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑或是其他公司的发行版?该JDK对应了《Java虚拟机规范》的哪个版本?
- 查看GC基本信息,在JDK 9之前使用-XX:+PrintGC,JDK 9后使用-Xlog:gc:
- 查看GC详细信息,在JDK 9之前使用-XX:+PrintGCDetails,在JDK 9之后使用-X-log:gc*,用通配符*将GC标签下所有细分过程都打印出来,如果把日志级别调整到Debug或者Trace(基于版面篇幅考虑,例子中并没有),还将获得更多细节信息
- 查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC,JDK 9之后使用-Xlog:gc+heap=debug:
- 查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用-XX:+Print-GCApplicationConcurrentTime以及-XX:+PrintGCApplicationStoppedTime,JDK 9之后使用-Xlog:safepoint:
- 查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息。在JDK 9之前使用-XX:+PrintAdaptive-SizePolicy,JDK 9之后使用-Xlog:gc+ergo*=trace:
- 查看熬过收集后剩余对象的年龄分布信息,在JDK 9前使用-XX:+PrintTenuring-Distribution,JDK 9之后使用-Xlog:gc+age=trace:
下图给出了全部在JDK 9中被废弃的日志相关参数及它们在JDK9后使用-Xlog的代替配置形式。
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。
在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。
HotSpot虚拟机提供了-XX:PretenureSizeThreshold
参数,指定大于该设置值的对象直接在老年代分配。(只对Serial和ParNew两款新生代收集器有效)
虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15,最大也为15,因为分代年龄只占1byte 4个bit位,最大值就是15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
设置。
HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败;如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次FullGC。
实际虚拟机中已经不会再使用它。JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。
略....
//TODO 本章节笔记请再次阅读时补上 第五章笔记也如此
Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:
- 无符号数:
无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。 - 表:
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由表6-1所示的数据项按严格顺序排列构成。
每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(MinorVersion),第7和第8个字节是主版本号(Major Version)。
生命周期:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段
六种情况必须立即对类进行“初始化”:
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时
- 使用java.lang.reflect包的方法对类型进行反射调用的时候
- 需要先触发其父类的初始化
- 一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
对于这六种会触发类型进称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。被动引用的案例c72包下
接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段
如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8种常量类型。
初始化阶段就是执行类构造器<clinit()>方法的过程。
<clinit()>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
由于父类的<clinit()>方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。
只存在两种不同的类加载器:一种是启动类加载器(BootstrapClassLoader),这个类加载器使用C++语言实现[插图],是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
针对JDK 8及之前版本的Java来介绍什么是三层类加载器,以及什么是双亲委派模型:
- 启动类加载器(Bootstrap Class Loader):前面已经介绍过,这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。
- 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。
- 应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。它负责加载用户类路径(ClassPath)上所有的类库。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
JNDI服务使用线程上下文类加载器(Thread Context ClassLoader)去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBI等。为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。
在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:
- 将以java.*开头的类,委派给父类加载器加载。
- 否则,将委派列表名单内的类,委派给父类加载器加载。
- 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
- 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
- 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
- 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
- 否则,类查找失败。
tomcat类加载机制:
- 先在本地缓存中查找是否已经加载过该类(对于一些已经加载了的类,会被缓存在resourceEntries这个数据结构中),如果已经加载即返回,否则 继续下一步。
- 让系统类加载器(AppClassLoader)尝试加载该类,主要是为了防止一些基础类会被web中的类覆盖,如果加载到即返回,返回继续。
- 前两步均没加载到目标类,那么web应用的类加载器将自行加载,如果加载到则返回,否则继续下一步。
- 最后还是加载不到的话,则委托父类加载器(Common ClassLoader)去加载。
第3第4两个步骤的顺序已经违反了双亲委托机制,除了tomcat之外,JDBC,JNDI,Thread.currentThread().setContextClassLoader();等很多地方都一样是违反了双亲委托。
在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。
JDK 9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:
- 依赖其他模块的列表。
- 导出的包列表,即其他模块可以使用的列表。
- 开放的包列表,即其他模块可反射访问模块的列表。
- 使用的服务列表。
- 提供服务的实现列表。
JDK 9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问
JDK 9提出了与“类路径”(ClassPath)相对应的“模块路径”(ModulePath)的概念。简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。只要是放在类路径上的JAR文件,无论其中是否包含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文件,它也仍然会被当作一个模块来对待。
- JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。
- 模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。
- JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块(Automatic Module)。尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。
以上3条规则保证了即使Java应用依然使用传统的类路径,升级到JDK 9对应用来说几乎(类加载器上的变动还是可能会导致少许可见的影响,将在下节介绍)不会有任何感觉,项目也不需要专门为了升级JDK版本而去把传统JAR包升级成模块。
扩展类加载器(Extension Class Loader)被平台类加载器(Platform ClassLoader)取代。
- 取消了<JAVA_HOME>\jre目录<JAVA_HOME>\lib\ext目录
- 平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader。启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader
- 启动类加载器现在是在Java虚拟机内部和Java类库共同协作实现的类加载器,尽管有了BootClassLoader这样的Java类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景中仍然会返回null来代替
- 平台及应用程序类加载器收到类加载请求,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。双亲委派的第四次破坏。
启动类加载器负责加载的模块:(图)
平台类加载器负责加载的模块:(图)
应用程序类加载器负责加载的模块:(图)
本章介绍了类加载过程的“加载”“验证”“准备”“解析”和“初始化”这5个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的。
而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(VirtualMachine Stack)的栈元素。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。
只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(CurrentStack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。
局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽都应该能存放一个32位以内的数据类型boolean、byte、char、short、int、float、reference或returnAddress类型的数据。
第7种reference类型表示对一个对象实例的引用,虚拟机实现至少都应当能通过这个引用做到两件事情,一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
第8种returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。
由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的。
操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。
操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
当一个方法开始执行后,只有两种方式退出这个方法:
- 正常调用完成(Normal Method InvocationCompletion):执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定。
- 异常调用完成(Abrupt Method Invocation Completion):方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的。
退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用阶段唯一的任务就是确定被调用方法的版本。
在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。
在类加载的解析阶段,会将其中的一部分“编译期可知,运行期不可变”的方法的符号引用转化为直接引用(private static 构造/父类方法 final)。这类方法的调用被称为解析(Resolution)。
在Java虚拟机支持以下5条方法调用字节码指令,分别是:
- invokestatic。用于调用静态方法。
- invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。
- invokevirtual。用于调用所有的虚方法。
- invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-VirtualMethod),与之相反,其他方法就被称为“虚方法”(Virtual Method)。
解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。
另一种主要的方法调用形式分派(Dispatch)调用则要复杂许多。它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。
-
静态分派:“重载”
“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type)。
静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定。 所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。
很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更合适的”版本。
例子c832.OverLoad结论:具体静态类型 > 自动转型(char>int>long>float>double) > 自动装拆箱 > 具体类型的实现接口类型 > Object > 可变长参数
注意*! 其实重载在JVM虚拟机里没有意义。重载的方法其实对JVM来说就是一个类的不同方法,JVM定位一个方法是根据方法的签名,而方法签名包括方法的全名和方法的参数列表(与返回值无关) -
动态分派:“重写”
运行期根据实际类型确定方法执行版本的分派过程称为动态分派。 invokevirtual指令的运行时解析过程:1)找到栈顶元素所指向的对象实际类型。2)如果此类型中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限效验,如果通过则返回这个方法的直接引用,查找过程结束。如果不通过则返回java.lang.IllegalAccessError异常。3)否则,按照继承关系从下往上找进行第二步。4)始终没找到抛出java.lang.AbstractMethodError异常。
字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。 -
单分派与多分派:
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
Java语言是一门静态多分派、动态单分派的语言。 -
虚拟机的动态分派的实现:
种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
invokedynamic
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的。
在Java虚拟机层面上提供动态类型的直接支持就成为Java平台发展必须解决的问题,这便是JDK 7时JSR-292提案中invokedynamic指令以及java.lang.invoke包出现的技术背景。
Java语言也可以拥有类似于函数指针或者委托的方法别名这样的工具,代码清单c843演示了方法句柄的基本用法。
MethodHandle在使用方法和效果上与Reflection(反射)的区别:
- Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为
- java.lang.reflect.Method对象远比java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅包含执行该方法的相关信息。
- 虚拟机在这方面做的各种优化在MethodHandle上也应当可以采用类似思路去支持,而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施。
- Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已,而且Java在这里并不是主角。
每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed CallSite)”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7时新加入的CONSTANT_InvokeDynamic_info常量。
从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。
引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。
invokedynamic指令与此前4条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。
通过一个简单例子,帮助读者理解程序员可以掌控方法分派规则之后,我们能做什么以前无法做到的事情。(代码清单c845)
Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择在。本节中,我们将会分析在概念模型下的Java虚拟机解释执行字节码时,其执行引擎是如何工作的。
在Tomcat目录结构中,可以设置3组目录(/common/*、/server/和/shared/,但默认不一定是开放的,可能只有/lib/目录存在)用于存放Java类库,另外还应该加上Web应用程序自身的“/WEB-INF/”目录,一共4组。把Java类库放置在这4组目录中,每一组都有独立的含义,分别是:
- 放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。
- 放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。
- 放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
- 放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见。
Tomcat自定义了多个类加载器,对目录里面的类库进行加载和隔离。如图
那么被Common类加载器或Shared类加载器加载的Spring如何访问并不在其加载范围内的用户程序呢?
答:Spring是被线程上下文加载器加载的,而线程上下文加载器默认为WebAppClassLoader加载器。即每个WebApp都会加载自己用到的Spring相关的类。
OSGi(Open Service Gateway Initiative)是OSGi联盟(OSGi Alliance)制订的一个基于Java语言的动态模块化规范。
在OSGi里,类加载时可能进行的查找规则如下:
- 以java.*开头的类,委派给父类加载器加载。
- 否则,委派列表名单内的类,委派给父类加载器加载。
- 否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
- 否则,查找当前Bundle的Classpath,使用自己的类加载器加载。
- 否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。
- 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
- 否则,类查找失败。
代码清单c923演示了一个最简单的动态代理的用法,原始的代码逻辑是打印一句“helloworld”,代理类的逻辑是在原始类方法执行前打印一句“welcome”。我们先看一下代码,然后再分析JDK是如何做到的。
在上述代码里,唯一的“黑匣子”就是Proxy::newProxyInstance()方法,这个方法返回一个实现了IHello的接口,并且代理了new Hello()或new Fuck()实例行为的对象。跟踪这个方法的源码,它最后调用sun.misc.ProxyGenerator::generateProxyClass()方法来完成生成字节码的动作。
把高版本JDK中编写的代码放到低版本JDK环境中去部署使用,了解决这个问题,一种名为“Java逆向移植”的工具(Java Backporting Tools)应运而生,Retrotranslator[插图]和Retrolambda是这类工具中的杰出代表。
Retrotranslator的作用是将JDK 5编译出来的Class文件转变为可以在JDK 1.4或1.3上部署的版本,它能很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持JDK 5中新增的集合改进、并发包及对泛型、注解等的反射操作。
Retrolambda的作用与Retrotranslator是类似的,目标是将JDK 8的Lambda表达式和try-resources语法转变为可以在JDK 5、JDK 6、JDK 7中使用的形式,同时也对接口默认方法提供了有限度的支持。
排查问题的过程中,想查看内存中的一些参数值,却苦于没有方法把这些值输出到界面或日志中。又或者定位到某个缓存数据有问题,由于缺少缓存的统一管理界面,不得不重启服务才能清理掉这个缓存。
类似的需求有一个共同的特点,那就是只要在服务中执行一小段程序代码,但就是偏偏找不到可以让服务器执行临时代码的途径,通常解决类问题有以下几种途径:
- 可以使用BTrace这类JVMTI工具去动态修改程序中某一部分的运行代码,类似的JVMTI工具还有阿里巴巴的Arthas等。
- 使用JDK 6之后提供了Compiler API,可以动态地编译Java程序,这样虽然达不到动态语言的灵活度,但让服务器执行临时代码的需求是可以得到解决的。
- 也可以通过“曲线救国”的方式来做到,譬如写一个JSP文件上传到服务器,然后在浏览器中运行它,或者在服务端程序中加入一个BeanShell Script、JavaScript等的执行引擎(如Mozilla Rhino)去执行动态脚本。
- 在应用程序中内置动态执行的功能。
TODO
3类编译过程里一些比较有代表性的编译器产品:
- 前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)。
- 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
- 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。
相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者Java虚拟机的底层改进来支持。我们可以这样认为,Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高。
它本身就是一个由Java语言编写的程序,这为纯Java的程序员了解它的编译过程带来了很大的便利。
Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList与ArrayList其实是同一个类型。
总结:(代码清单c1032)
遇到运算符就会自动拆箱,结果为运算中类型最大的普通类型。
“==” 遇到两边出现普通类型就会自动拆箱,并且会强转为类型较大的进行比较。
equals()方法会根据普通类型自动装箱,但是不会强转类型。
TODO
Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
此外,我们还将解决以下几个问题:
- 为何HotSpot虚拟机要使用解释器与即时编译器并存的架构?
- 为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?
- 程序何时使用解释器执行?何时使用编译器执行?
- 哪些程序代码会被编译为本地代码?如何编译本地代码?
- 如何从外部观察到即时编译器的编译过程和编译结果?
“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器。第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。
“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(CompiledMode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。可以通过虚拟机的“-version”命令的输出结果显示出这三种模式。
分层编译(jdk1.7开始 默认策略)根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
- 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
- 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
- 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
编译器编译(编译的目标对象都是整个方法体)的目标是“热点代码”,这里所指的热点代码主要有两类,包括:
- 被多次调用的方法:由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象。
- 被多次执行的循环体:执行入口会稍有不同,编译时会传入执行入口点字节码序号。(栈上替换:即方法的栈帧还在栈上,方法就被替换了。)
热点探测判定方式有两种:
- 基于采样的热点探测:周期性地检查各个线程的调用栈顶。(J9)
- 基于计数器的热点探测:虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数。(Hotspot)
HotSpot为每个方法准备了两类计数器:
- 方法调用计数器(Invocation Counter):默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。
- 回边计数器(BackEdge Counter,“回边”的意思就是指在循环边界往回跳转:
原始代码开始
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ... do stuff...
z = b.get();
sum = y + z;
}
首先,第一个要进行的优化是方法内联,它的主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等);二是为其他优化建立良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段,可以获取更好的优化效果。
public void foo() {
y = b.value;
// ... do stuff...
z = b.value;
sum = y + z;
}
第二步进行冗余访问消除,假设代码中间注释掉的“…dostuff…”所代表的操作不会改变b.value的值,那么就可以把“z=b.value”替换为“z=y”,因为上一句“y=b.value”已经保证了变量y与b.value是一致的,这样就可以不再去访问对象b的局部变量了。(如果把b.value看作一个表达式,那么也可以把这项优化看作一种公共子表达式消除)
public void foo() {
y = b.value;
//... do stuff...
z = y;
sum = z + y;
}
第三步进行复写传播,为这段程序的逻辑之中没有必要使用一个额外的变量z,它与变量y是完全相等的,因此我们可以使用y来代替z。
public void foo() {
y = b.value;
//... do stuff...
y = y;
sum = y + y;
}
第四步进行无用代码消除(Dead Code Elimination),无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码。
public void foo() {
y = b.value;
//... do stuff...
sum = y + y;
}
四项有代表性的优化技术:方法内联、逃逸分析、公共子表达式消除和数组边界检查消除。
Java虚拟机并不好进行方法内联:因为大量的代码是虚方法,invokevirtual是使用虚方法表(接口方法表)根据实际类型动态分派的。
为了解决虚方法的内联问题,类型继承关系分析(ClassHierarchy Analysis,CHA):确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。
编译器在进行内联时不同情况采取不同的处理:如果是非虚方法,那么直接进行。如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,进行守护内联(Guarded Inlining)。假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存(Inline Cache)的方式来缩减方法调用的开销。
守护内联:虽然当前状态下某个虚方法的实现只有一个,但是说不准什么时候就会加载到新的类型从而改变CHA结论,因此这种内联属于激进预测性优化,必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
内联缓存:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存(Monomorphic Inline Cache)。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行方法分派。
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
根据逃逸程度不同采取不同的优化:
- 栈上分配:适合从不逃逸,方法逃逸。对象由栈帧的出栈而同时销毁,减小了GC系统的工作压力。
- 标量替换:
什么是标量?虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。
如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。 - 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。
如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common SubexpressionElimination)。
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
Java内存模型中定义了以下8种操作来完成一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节:
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
当一个变量被定义成volatile之后,它将具备两项特性:
- 第一项是保证此变量对所有线程的可见性,但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。
- 第二个语义是禁止指令重排序优化。
“long和double的非原子性协定”(Non-Atomic Treatment of doubleand long Variables)。
除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。
- 原子性:Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个。基本数据类型的访问、读写都是具备原子性的(除long和double的非原子性协定)。Java内存模型还提供了lock和unlock操作来满足更大范围的原子性保证,虚拟机未把lock和unlock操作直接开放给用户使用,字节码指令monitorenter和monitorexit来隐式地使用这两个操作。反映到Java代码中就是同步块——synchronized关键字。
- 可见性:可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。synchronized是因为unlock操作之前必须先执行store、write操作。
- 有序性:Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的。
“先行发生”(Happens-Before)原则:先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到(“影响”包括修改了内存**享变量的值、发送了消息、调用了方法等)。
下面是Java内存模型下一些“天然的”先行发生关系,如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
- volatile变量规则Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
结论:时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。
实现线程主要有三种方式:
- 内核线程实现:使用内核线程实现的方式也被称为1:1实现。
- 用户线程实现:使用用户线程实现的方式被称为1:N实现。
- 混合实现:有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。
- Java线程的实现:以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。
线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种:
- 协同式(Cooperative Threads-Scheduling)线程调度:
- 抢占式(Preemptive Threads-Scheduling)线程调度:Java实现的类型。有Thread::yield()方法可以主动让出执行时间,但无法控制获取。
Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
Java语言定义了6种线程状态:
- 新建(New):创建后尚未启动的线程处于这种状态。
- 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
- 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
- 没有设置Timeout参数的Object::wait()方法;
- 没有设置Timeout参数的Thread::join()方法;
- LockSupport::park()方法。
- 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
- Thread::sleep()方法;
- 设置了Timeout参数的Object::wait()方法;
- 设置了Timeout参数的Thread::join()方法;
- LockSupport::parkNanos()方法;
- LockSupport::parkUntil()方法。
- 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
上述6种状态在遇到特定事件发生的时候将会互相转换,它们的转换关系如图。
为什么内核线程调度切换起来成本就要更高?
答:内核线程的调度成本主要来自于用户态与核心态之间的状态转换(int 0x80),而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。
如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改为采用用户线程,这部分开销就能够省略掉吗?
答:不能。但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,那我们就可以打开脑洞,通过玩出很多新的花样来缩减这些开销。
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine)。又由于这时候的协程会完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine),起这样的名字是为了便于跟后来的“无栈协程”(StacklessCoroutine)区分开。
OpenJDK在2018年创建了Loom项目,这是Java用来应对本节开篇所列场景的官方解决方案,根据目前公开的信息,如无意外,日后该项目为Java语言引入的、与现在线程模型平行的新并发编程机制中应该也会采用“纤程”这个名字。从Oracle官方对“什么是纤程”的解释里可以看出,它就是一种典型的有栈协程。
定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”
我们可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
- 不可变:不可变(Immutable)的对象一定是线程安全的。
- 绝对线程安全:这个定义其实是很严格的
- 相对线程安全:相对线程安全就是我们通常意义上所讲的线程安全
- 线程兼容:程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
- 线程对立:线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。
-
互斥同步:
互斥是因,同步是果;互斥是方法,同步是目的。也叫阻塞同步(Blocking Synchronization)或互斥锁。synchronized关键字 -> monitorenter和monitorexit这两个字节码。 synchronized使用注意:
- 被synchronized修饰的同步块对同一条线程来说是可重入的。
- 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。
JUC包ReentrantLock重入锁相比synchronized的高级特性:
- 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。(公平锁实现机制是AQS)
- 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。
-
非阻塞同步:
基于冲突检测的乐观并发策略(乐观锁),这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程。因为我们必须要求操作和冲突检测这两个步骤具备原子性,我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test-and-Set);
- 获取并增加(Fetch-and-Increment);
- 交换(Swap);
- !*比较并交换(Compare-and-Swap,下文称CAS);在IA64、x86指令集中有用cmpxchg指令完成的CAS功能
- 加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。
在JDK 5之后,Java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。
CAS指令需要有三个操作数:内存位置、旧的预期值、准备设置的新值。CAS操作的“ABA问题”,J.U.C包提供了一个带有标记的原子引用类AtomicStampedReference(用传统的互斥同步可能会比原子类更为高效),它可以通过控制变量值的版本来保证CAS的正确性。
-
无同步方案:
- 可重入代码(Reentrant Code):不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。
- 线程本地存储(Thread Local Storage):可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。
适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等
挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。
自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。
案例:
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
编译后:
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
StringBuffer是线程安全的,但经过逃逸分析后会发现它的动态作用域被限制在concatString()方法内部。也就是sb的所有引用都永远不会逃逸到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
案例:接上案例,锁会粗化到第一个append()操作之前直至最后一个append()之后。
轻量级锁的工作过程:代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的MarkWord的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图
然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。此线程会自旋一段时间等待锁施放,但如果出现两条以上的线程争用同一个锁的情况或者线程自旋超过一定的阈值(自适应自旋,也可以设置自旋次数),那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
启用参数-XX:+UseBiased Locking(默认启用) 延迟时间 -XX:BiasedLockingStartupDelay=4(默认)
在程序启动后4秒,新建的对象都是可偏向的(偏向锁位1),显示或隐式调用了hashcode()方法后会变成不可偏向(偏向锁位0)
操作过程:判断是否可偏向,如果可以CAS操作修改对象markword为偏向线程ID