概述
深入理解Java虚拟机:JVM高级特性与最佳实践(第三版)周志明 读书笔记选取了书中部分内容,Java虚拟机更多的是一种规范,具体的Java虚拟机实现是有很多的。作者提到本文多数是以Hotspot虚拟机作为讲解。
第2章 Java内存区域与内存溢出异常
2.1 概述
对于C/C++程序员来说,担负每一个对象生命从开始到终结的维护责任。而对于Java程序员来说,Java帮助程序员自动管理内存,不需要写显式的代码去释放内存。但虚拟机不是万能的,一旦出现内存泄漏和溢出问题,如果不了解虚拟机怎样使用内存,将难以排查错误和修正问题。 本章从概念上介绍Java虚拟机内存的各个区域,及其可能产生的问题。
2.2 运行时数据区域
Java虚拟机在执行Java程序时,会将它管理的内存分成功能不同的运行时数据区域。这些区域有着不同的用户,不同的创建和销毁时间。 有的区域随着虚拟机进程生命周期,有的区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》规定,Java虚拟机管理的内存包括以下运行时数据区。
Java虚拟机基于栈的方式去执行程序。每一个线程都会有相应的虚拟机栈,而虚拟机栈的栈帧对应于Java方法。
2.2.1 程序计数器
程序计数器(Progrom Counter Register),记录当前线程执行的字节码行号指示器。通常来说,字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等都依赖计数器完成。每个线程都有独立的计数器,互不影响。计数器分配在线程私有的内存空间中。此区域不会出现OOM的情况。
2.2.2 Java虚拟机栈
Java虚拟机栈也是线程私有的,它的生命周期与线程相同,描述的是Java方法执行的线程内存模型,即每个方法被执行时,都会同步创建一个栈帧(Stack Frame) 存储局部变量表,操作数栈,动态连接等。方法的调用与执行完毕,对应一个栈帧的入栈和出栈。
在Java源码编译成字节码时,一个栈帧需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,即编译过后就已经能够需要多大内存,内存取决于源码和具体的虚拟机栈内存布局形式。
在《Java虚拟机规范》中提到:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果虚拟机栈容量可以动态扩展,无限扩展,内存不足会抛出OutOfMemoryError异常。 Hotspot虚拟机栈容量不可动态扩展,但如果线程申请栈空间失败,仍然会OOM。
2.2.3 本地方法栈
相较于Java虚拟机栈执行Java方法,本地方法栈执行Native方法。作用是相似的,也会有同样的异常问题。在HotSpot虚拟机中,本地方法栈与Java虚拟机栈合二为一。
2.2.4 Java堆
Java堆是所有线程共享的内存区域,在虚拟机启动时创建。用来存放对象实例。数组也是一种对象实例。
Java堆是垃圾收集器管理的内存区域。基于分代收集理论设计,多数虚拟机的堆内存可以分为新生代、老年代、永久代,Eden,Survivor等。随之垃圾收集器技术的发展,也出现了不采用分代设计的新垃圾收集器,那就不存在上述所谓的代划分。
Java堆中,可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer ,TLAB)。我们说TLAB是线程独享的,但只是分配是独享的,读操作和垃圾回收等动作上是线程共享的。TLAB通常是在Eden区分配,因为Eden区本身不大,TLAB实际内存也非常小,默认占Eden空间的1%,所以必然存在一些大对象无法在TLAB直接分配。
无论怎么划分,都不会改变堆存放对象实例的作用。各种划分是为了更好的分配和回收内存。
《Java虚拟机规范》规定,逻辑上连续的内存空间,在物理上可以不连续。但多数虚拟机实现出于实现简单和存储高效,也会要求连续的物理内存空间。
主流Java虚拟机的堆内存空间都是可扩展的,但有上限值。当对象实例无法被分配内存,且堆达到上限。Java虚拟机便会抛出OutOfMemoryError异常。
2.2.5/2.2.6 方法区(包含运行时常量池)
方法区(Method Area),运行时常量池(Runtime Constant Pool)
方法区,线程共享,存储已被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等数据。
JDK8,不再使用永久代(Permanent Generation Space)实现方法区,而是在本地内存中实现的元空间(Metaspace)来代替。而字符串常量移到Java堆。这部分的内存回收目标主要针对常量池的回收和对类型的卸载。
运行时常量池,存放常量池表(Constant Pool Table),即Class文件编译期生成的各种字面量与符号引用。
根据《Java虚拟机规范》规定,如果方法区无法满足新的内存分配,将会抛出OutOfMemoryError异常。
2.2.7 直接内存
直接内存(Direct Memory). NIO(New input/output)是 JDK1.4新加入的类,引入了一种基于通道(channel)和缓冲区(buffer)的I/O方式,它可以使用Native函数直接分配堆外内存,然后通过堆上DirectByteBuffer对象对这块内存进行引用和操作。直接内存的大小不受JVM的限制,但同样可能会OutOfMemoryError异常。
2.3 HotSpot虚拟机对象探秘
以HotSpot为例,讲述在Java堆中对象的创建、结构和访问。
2.3.1 对象创建
分配堆内存 根据内存是否规整,分为两种:指针碰撞(Bump The Pointer)和空闲列表(Free List).前者内存规整。
解决并发情况下的线程安全问题的两种方式
- 对分配内存的动作进行同步处理,实际采用CAS(CompareAndSwap)配上失败重试保证操作的原子性
- 本地线程缓冲区(Thread Local Allocation Buffer,TLAB,线程预分配私有写内存区域。
2.3.2对象结构
- 对象头(Header)
- 对象自身运行时数据:哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID,偏向时间戳等。这部分的数据在32位虚拟机和64位虚拟机上的大小分别也是32bit和64bit,称之为Mark Word。
- 类型指针,指向其类型元数据。
- 实例数据(Instance Data)
- 对象真正存储的有效信息
- 对齐填充(Padding)
- 保证对象内存大小为8字节的整数倍,对齐填充补全。
2.3.3对象的访问定位
在《Java虚拟机规范》中规定,栈上的reference类型数据只是一个指向对象的引用。实际通过引用访问对象有两种方式:
- 句柄访问,reference存储的是句柄地址,对象被移动,改变句柄中的指针就好,reference本身不会被修改。
- 直接指针访问,少一次指针定位的开销,对象访问在Java中非常频繁。HotSpot采用直接指针访问。
2.4 实战:OutOfMemoryError异常
模拟Java堆、虚拟机栈、本地方法栈、方法区、运行时常量池,本地直接内存的溢出。
2.4.1 Java堆溢出
/***Intellij IDEA 配置 Run Configgurations
* VM options:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* 限制Java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样)
*/
public class HeapOOM {
static class OOMObject{}
public static void main(String[] args) {
List<OOMObject> list=new ArrayList<OOMObject>();
while (true){
list.add(new OOMObject());
}
}
}
运行结果
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid58651.hprof ...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
Heap dump file created [27770272 bytes in 0.159 secs]
2.4.2 虚拟机栈和本地方法栈溢出
HotSpot不区分虚拟机栈和本地方法栈,需要设置 -Xss。 -Xoss(设置本地方法栈)没有效果。
两种异常:
- 栈内存不可动态扩展,请求栈深度大于允许最大深度,则 StackOverflowError。
- 栈内存可动态扩展,当内存不足,无法申请,则 OutOfMemoryError。
HotSpot栈内存不允许动态扩展,我们使用-Xss参数减少栈内存容量。
// VM Args:-Xss160k
public class JavaVMStackOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
System.out.println("stack length:" + stackLength);
stackLeak();
}
public static void main(String[] args) throws Exception {
JavaVMStackOF oom = new JavaVMStackOF();
try {
oom.stackLeak();
} catch (Exception e) {
e.printStackTrace();
throw e;
}
}
}
运行结果
...
stack length:754
at oom.JavaVMStackOF.stackLeak(JavaVMStackOF.java:9)
at oom.JavaVMStackOF.stackLeak(JavaVMStackOF.java:10)
at oom.JavaVMStackOF.stackLeak(JavaVMStackOF.java:10)
...
2.4.3 方法区和运行时常量池溢出
在JDK6及之前版本中运行String::intern()
在JDK6及以前的HotSpot中,常量池分配在永久代中,通过 -XX:PermSize 和 -XX:MaxPermSize限制永久代的大小。String::intern() 可以将一个字符串对象添加到常量池,如果常量池中不包此字符串对象。 在JDK6的HotSpot中
/*** VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<String>();
// 在short范围内足以让6MB的PermSize产生OOM了
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
运行结果,也验证了字符串常量在永久代中
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18
在JDK7及以后版本中运行String::intern()
由于字符串常量转移到了Java堆中, 所以在JDK7中设置 -XX:MaxPermSize, 或者在JDK8中设置 --XX:MaxMeta-spaceSize都不会出现JDK6中的溢出问题。但我们可以限制最大堆内存空间-Xmx6m,从而产生OOM。
/*** VM Args:-Xmx6m
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
Set<String> set = new HashSet<String>();
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
运行结果,也验证了字符串常量在堆中
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.put(HashMap.java:611)
at java.util.HashSet.add(HashSet.java:219)
at oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:27)
2.4.4 本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过 -XX:MaxDirectMemorySize指定。
/*** VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemoryOOM {
public static void main(String[] args) throws Exception {
int count=1;
Field unsafeFiled=Unsafe.class.getDeclaredFields()[0];
unsafeFiled.setAccessible(true);
Unsafe unsafe= (Unsafe) unsafeFiled.get(null);
while (true){
unsafe.allocateMemory(1024*1024*1024);
System.out.println(count++);
}
}
}
运行结果
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at oom.DirectMemoryOOM.main(DirectMemoryOOM.java:17)
2.5 小结
到目前为止,明白了虚拟机中内存的划分,以及出现内存溢出的场景。下一章将详细讲解Java垃圾收集机制如何避免内存溢出。
第3章 垃圾收集与内存分配策略
3.1概述
垃圾收集(Garbage Collection,GC),1960年麻省理工Lisp语言,使用动态内存分配和垃圾收集技术。 当Lisp胚胎时期,其作者John McCarthy就思考过GC需要完成的三件事情:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
Java虚拟机内存运行时区间中,程序计数器、虚拟机栈、本地方法栈随线程而生,随线程而灭。在编译器其大小基本确定。而GC关注的是线程共享的区域 Java堆和方法区。
3.2对象已死?
GC回收堆之前,如何判定哪些对象需要被回收,或者说这些对象已死?
3.2.1 引用计数法(Reference Counting)
3.2.2可达性分析算法(Reachability Analysis)
可固定作为GC Roots的对象包括:
- 虚拟机栈帧中的本地变量表引用的对象
- 方法区中类静态属性引用对象
- 方法区中常量引用对象
- 本地方法栈中JNI引用对象
- 虚拟机内部的引用,如Class对象,常驻异常对象(NullPointException),类加载器等等
- 被同步锁持有的对象
- 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等。
除固定外,还有临时性的其他对象等。
3.2.3再谈引用
Java将引用分为四种:
- 强引用(Strongly Reference),引用赋值(即Object obj=new Object),永不回收
- 软引用(Soft reference),存活到即将发生内存溢出异常前的二次回收
- 弱引用(Weak Reference),存活到下一次垃圾收集
- 虚引用(Phantom Reference),无法通过虚引用获取对象实例,只是感知对象被回收
3.2.4 生存还是死亡
要宣告一个对象死亡,至少要经历两次不可达标记过程。重载对象的finalize()方法,可以再第一次标记后执行,重新挂上引用链避免被回收,但finalize()只会被执行一次。
3.2.5回收方法区
3.3垃圾收集算法
- 引用计数式垃圾收集(Reference Counting GC)
- 追踪式垃圾收集(Tracing GC)
这里我们讨论的是追踪式垃圾收集。
3.3.1 分代收集理论
分代收集(Generational Collection)的理论假说基础:
- 弱分代假说(Weak Generational Hypothesis)
绝大多数对象都是朝生夕灭的
- 强分代假说(Strong Generational Hypothesis):
熬过越多次垃圾收集过程的对象越难以消亡
- 跨代引用假说(Intergenerational Reference Hypothesis):
跨代引用相对同代引用来说,仅占极少数
基于弱分代/强分代假说,一般Java虚拟机至少会把Java堆划分为
- 新生代(Young Generation)
- 老年代(Old Generation)
每次垃圾收集,新生代大量死去的对象会被回收,存活的对象将会逐步晋升到老年代中。大对象直接进入老年代。在新生代中建立一个全局的数据结构(记忆集,Remebered Set),把老年代分成多个小块,标识出某些块存在跨代引用。 针对不同级别的分代的收集,我们定义一下名词:
- 部分收集(Partial GC)
- 新生代收集(Minor GC/Young GC)
- 老年代收集(Major GC/Old GC)
- 混合收集(Mixed GC),收集新生代和部分老年代
- 整堆收集(Full GC)
3.3.2 标记-清除算法
标记-清除(Mark-Swap) 1960 Lisp John McCarthy,基础性算法。 缺点:
- 执行效率不稳定,随对象数量增长而降低
- 内存碎片化问题
3.3.3 标记-复制算法
1969 Fenichel "半区复制" SemisSpace Copying 解决内存碎片化和执行效率问题,缺点明显:浪费一半内存。
IBM研究发现98%的新生代对象熬不过第一轮收集,因此不用1:1分配内存
1989年 Andrew Appel ,提出更优化的半区复制分代策略。
可以看到始终有一个 Survivor(10%新生代内存)作为保留,用来存放回收后存活对象,若果Survivor空间不够,则需要老年代进行分配担保(Handle Promotion)
标记-复制算法适用于新生代,即大量对象会被回收,需要复制的对象很少。老年代对象存活率高,就不适用了。
3.3.4 标记-整理算法
标记-整理(Mark-Compact) 1974年 Edward Lueders。
移动式回收算法,标记-清除是非移动式的。 移动对象则回收时复杂,不移动对象则分配内存时复杂。
3.4 HotSpot垃圾收集算法细节
3.4.1 根节点枚举
所有垃圾回收算法在根节点枚举时,都需要暂停用户线程,即 Stop The World! HotSpot采用准确式(Exact)垃圾回收,使用称为OopMap的数据结构,记录栈上本地变量到堆上对象的引用关系。 从而减少根节点枚举耗费的大量时间。 找出栈上的指针/引用 介绍了保守式,半自动式,准确式垃圾回收,同时也引出了OopMap。
3.4.2 安全点
根节点枚举需要暂停线程,总不能在每条指令后都去中断线程,所以有些固定的指令位置,作为中断的点,称之为 safe point。采用主动式中断,即达到安全点,检查是否要执行中断线程。 安全点的位置一般为:
- 循环的末尾
- 方法返回前/调用方法的call指令后
- 可能抛出异常的位置
3.4.3 安全区域
safe region。安全区域指在某个代码片段中,引用关系不会发生变化,在这个区域内可以安全的开始垃圾收集。
3.4.4 记忆集与卡表
RememberSet,记录非收集区到收集区的引用,避免把整个非收集区加入到GC Root扫描。比如说收集新生代对象时,避免整个老年代加入GCRoot扫描。
从精度上来看,记忆集可以分为
- 字长精度
- 对象精度
- 卡精度,每个记录精确到一块内存区域,记录该区域内是否含有跨代指针。
卡表,即为常见的卡精度的记忆集。 卡表简单来说,可以只是一个字节数组(Card Table)。每个数组元素都对应一个卡页(Card Page),卡页是某块特定大小的内存块,一般来说大小为2的N次幂字节数,HotSpot中为512字节。只要卡页中有对象存在跨代引用,则对应卡表元素标记为1,即元素变脏(Dirty)
3.4.5 写屏障与AOP
何时去记录RememberSet? 写屏障(Write Barrier),虚拟机层面对"引用类型字段赋值"动作的AOP切面,虚拟机为赋值操作生成相应指令。 环形(Around)通知,提供写前屏障(Pre-Write Barrier)和写后屏障(Post-Write Barrier)。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓 存行。卡表在高并发下的伪共享(False Sharing)问题, 写脏前,先判断是否已脏。 在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断
3.4.6 并发的可达性分析与三色标记
前面我们通过OopMap、安全区域、RememberSet等手段,提升了根节点枚举的速度。根节点枚举带来的停顿已经相当短暂和固定了,而从GC Roots继续往下遍历对象的停顿时间与堆容量成正比。 可达性分析(标记)算法要求全过程在一个一致性的快照中分析,势必要冻结全部用户线程,且冻结的时间完全不可控。在堆容量过大情况下,冻结时间是无法接受的。因此,可达性分析过程,如果能与用户线程并发执行,是最好不过了。
我们先来看并发可达性分析过程 即三色标记
并发可达性分析又会引起两类问题:
- 1.该回收的没有被标记(浮动垃圾,Floating Garbage),这个可接受,大不了下次回收时,再回收。
- 2.不该回收的被标记(对象消失),这个不可接受,因为用户线程需要的对象没了。
当且仅当同时满足下面两个条件,会产生对象消失问题,即原本应当为黑色的对象被误标为白色(Wilson,1994年证明):
- 赋值器插入了一条黑色到白色的引用。
- 同时赋值器删除了全部灰色到该白色的直接或间接引用。
- 增量更新,记录新增的引用,并发扫描结束后,重新以黑色为根扫描,即黑色变为灰色
- 原始快照,记录删除的引用,并发扫描结束后,重新以灰色为根扫描。
3.5 经典垃圾收集器
3.5.0 概述
在介绍之前我们先明确几个概念:
- 并行,可以有多个垃圾收集线程同时运行。 串行则同时只能有一个垃圾收集线程运行。
- 并发,垃圾收集线程可以与用户线程同时运行。
- 高吞吐量,垃圾收集时间/(用户线程运行时间+垃圾收集时间)
- 低延迟,快速响应。可以容忍总的收集时间增加,降低平均每次收集时间。
3.5.1 Serial收集器
新生代,无并行,无并发,标记复制,简单高效,额外内存消耗(Memory Footprint)最小,适用于单核/少核,JDK1.3.1之前。
3.5.2 ParNew收集器
新生代,并行,Serial的多线程版本,标记复制, [JDK1.3 - JDK8)
3.5.3 Parallel Scavenge收集器
新生代,并行 ,无并发,JDK1.4,标记复制,注重吞吐量
3.5.4 Serial Old 收集器
老年代,无并行,无并发,Serial老年代版本,标记整理
3.5.5 Parallel Old 收集器
老年代,并行,无并发,Parallel Scavenge 老年代版本,标记整理,注重吞吐量
3.5.6 CMS收集器
Concurent Mark Sweep, 老年代,并行,并发, [JDK5 - JDK8],标记清除,注重低延迟, 并发标记使用增量更新。四个步骤:
-
- 初始标记(CMS initial mark),仅标记GC Roots直接关联对象,短STW
- 2)并发标记(CMS concurrent mark),遍历整个对象图,耗时长,可以与用户线程并发
- 3)重新标记(CMS remark),增量更新,避免对象消失问题,短STW
- 4)并发清除(CMS concurrent sweep),不需要移动对象,可以与用户线程并发
CMS的三个明显缺点:
- 回收线程占用处理器资源,CMS默认启动的回收线程数 (处理器核心数量+3)/4. 并发阶段,应用程序会变慢,吞吐量降低
- 浮动垃圾 Floating Garbage, 并发过程失败,需要启用Serial Old,做一次老年代收集。
- 内存碎片,标记清除算法带来的问题,进行若干次标记清除后,会执行一次碎片整理。因为整理需要移动对象,无法并发。
3.5.7 G1收集器
Garbage First,JDK7完善, JDK9开始成为默认垃圾收集器。 新生代,老年代。Region分区,局部标记-复制,整体标记-整理,注重低延迟,并发标记使用原始快照。 大对象(内存超过Region内存的一半)直接进入 Humongous Region区域。Region是回收的最小单元。每一个Region都可以根据需要扮演Eden空间,Survivor空间或者老年代空间。可预测时间停顿模型。 四个步骤:
-
- 初始标记( Initial Marking),仅标记GC Roots直接关联对象,短STW
- 2)并发标记(Concurrent Marking),遍历整个对象图,耗时长,可以与用户线程并发
- 3)最终标记(Final Marking),并发标记使用原始快照,避免对象消失问题,短STW
- 4)筛选回收(Live Data Counting and Evacuation),对各个Region回收的价值和成本排序,根据期望停顿时间,组合任意多个Region回收。待回收的Region存活对象复制到空Region中,回收旧Region,涉及对象移动,需要STW
G1是垃圾收集器技术发展历史上的里程碑式的结果,开创了面向局部手机的设计思路和基于Region的内存布局形式。从G1开始,垃圾收集器的设计导向变为追求应付内存分配速率(Allocation Tate),而不追求一次把整个Java堆清理干净。G1的更多介绍
3.6 低延迟垃圾收集器
3.6.1 Shenandoah收集器
略,实在不会
3.6.2 ZGC收集器
略,实在不会
第6章 类文件结构
代码编译的结果从本地机器码变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
6.1 概述
程序语言 --> 字节码 --> 二进制本地机器码
6.2 无关性的基石
无关性的基石 -- 字节码(Byte Code)
平台无关性,语言无关性
6.3 Class类文件的结构
Class文件以8个字节为单位的二进制流,各数据项严格按照顺序紧凑排列在文件中,中间没有任何分隔符。 Class文件结构中只有两种数据类型:
- 无符号数,基本数据类型,u1、u2、u4、u8分别代表1个字节 、2个字节、4个字节 、8个字节的无符号数,用来描述数字,引用,数量值或者UTF-8编码字符串。
- 表,多个无符号数组成的符合数据类型,命名一般以“_info”结尾。
| 类型 | 名称 | 数量 | 解释 |
|---|---|---|---|
| u4 | magic | 1 | 4字节魔数 0xCAFEBABE |
| u2 | minor_version | 1 | 次要版本号 |
| u2 | major_version | 1 | 主要版本号 |
| u2 | constant_pool_count | 1 | 常量池计数值 |
| cp_info | constant_pool | constant_pool_count-1 | 常量池 |
| u2 | access_flags | 1 | 访问标志 |
| u2 | this_class | 1 | 类索引 |
| u2 | super_class | 1 | 父类索引 |
| u2 | interfaces_count | 1 | |
| u2 | interfaces | interfaces_count | 接口索引集合 |
| u2 | fields_count | 1 | |
| field_info | fields | fields_count | 字段表集合,类变量 |
| u2 | methods_count | 1 | |
| method_info | methods | methods_count | 方法表集合 |
| u2 | attributes_count | 1 | |
| attribute_info | attributes | attributes_count | 属性表集合 |
先来一段代码 TestClass.Java
package clazz;
public class TestClass {
public static void main(String[] args) { }
private int m;
public int inc(){return m+1;}
}
通过编译得到二进制字节码文件 TestClass.class
javap -v TestClass.class得到字节码中包含的类信息。我们现在要做的就是模拟javap这个解析的过程。
Last modified 2020-7-29; size 483 bytes
MD5 checksum ad62060802ee27c385e20042d24e8b38
Compiled from "TestClass.java"
public class clazz.TestClass
minor version: 0
major version: 51
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#22 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#23 // clazz/TestClass.m:I
#3 = Class #24 // clazz/TestClass
#4 = Class #25 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lclazz/TestClass;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 inc
#19 = Utf8 ()I
#20 = Utf8 SourceFile
#21 = Utf8 TestClass.java
#22 = NameAndType #7:#8 // "<init>":()V
#23 = NameAndType #5:#6 // m:I
#24 = Utf8 clazz/TestClass
#25 = Utf8 java/lang/Object
{
public clazz.TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lclazz/TestClass;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 args [Ljava/lang/String;
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 13: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lclazz/TestClass;
}
SourceFile: "TestClass.java"
6.3.1 魔数与Class文件版本
魔数(Magic Number) CAFEBABE ,表示这是一个Class类型文件,4字节。
minor version: 0(0x0000),2字节。
major version: 51 (0x0033),2字节。
6.3.2 常量池
接在魔数和版本后面的是常量池。 常量池中存放两大类型常量:
- 字面量(Literal):文本字符串,被申明为final的常量等
- 符号引用(Symbolic References)
- 包
- 类和接口的全限定名,
- #13,Lclazz/TestClass
- 字段的名称和描述符 ,
- #5 = Utf8 m
- #6 = Utf8 I
- 方法的名称和描述符,
- #14 = Utf8 main,
- #15 = Utf8 ([Ljava/lang/String;)V
- 方法句柄和方法类型,
- #23 = NameAndType #5:#6 // m:I
- 动态调用点和动态常量
常量池中的常量有17种类型,比如说 CONSTANT_Methodref_info,CONSTANT_Classref_info,CONSTANT_Utf8_info等等。 每种类型常量的结构也不近相同。共同点是,都已u1的tag开头,表示类型。《深入理解Java虚拟机》中,列出了完整的定义,下面简单举例。
CONSTANT_Methodref_info:
- tag,u1,值为10
- index,u2指向声明方法的类描述符CONSTANT_Classref_info的索引
- index,u2指向名称及类型描述符CONSTANT_NameAndType_info的索引
CONSTANT_Classref_info:
- tag,u1,值为7
- index,u2指向全限定名常量项的索引
常量池项目数量:25 (0x001A是26,常量池索引值从1开始,0保留,所以实际只用25个常量,0可以理解成不引用常量池中的项目)。
第一项(0A 00 04 00 16), #1 = Methodref #4.#22:
- 0A,tag,u1 为10表示是CONSTANT_Methodref_info
- 00 04,index,u2,所在类描述符索引,即 #4 = Class #25 // java/lang/Object
- 00 16,index,u2,名称及类型描述符索引,即#22 = NameAndType #7:#8 // "":()V
值得注意的是 父类方法类型是Methodref,类方法inc类型是utf8。
第二项(09 00 03 00 17) , #2 = Fieldref #3.#23 // clazz/TestClass.m:I
- 09,tag,u1 为9表示是CONSTANT_Fieldref_info
- 00 03,index,u2为 3所在类描述符索引, #3 = Class #24 // clazz/TestClass
- 00 17,index,u2为23名称及类型描述符索引,即 #23 = NameAndType #5:#6 // m:I
剩余的23个项目,太多了,懂意思就好了。
6.3.3 访问标志
结束了常量池25项解析,接着是Class访问标志(access_flags)
00 21 表示 0x0020 & 0x0001
- 0x0001 ACC_PUBLIC 是否为public类型
- 0x0020 ACC_SUPER 是否允许使用invokespecial字节码指令的新语义,invokespecial语义在JDK1.0.2发生改变,1.0.2后编译的类,这个标志必须为真。
6.3.4 类索引、父类索引与接口索引集合
- 类索引,u2,0x0003,#3 = Class #24 // clazz/TestClass
- 父类索引,u2,0x0004,#4 = Class #25 // java/lang/Object
- 接口索引数量,u2,0x0000,没有接口
6.3.5 字段表集合
- fields_count,u2,0x0001,表示有一个字段
- access_flags,u2,0x0002,ACC_PRIVATE
- name_index,u2,0x0005, #5 = Utf8 m
- descriptor_index,u2,0x0006, #6 = Utf8 I
- attributes_count,u2,0x0000, 无
- attributes_info,无
6.3.6方法表集合
方法数量,u2,0x0003,有3个方法
方法表结构
- access_flags,u2,0x0001
- name_index,u2,0x0007
- descriptor_index,u2,0x0008
- attributes_count,u2,0x0001
- attribute_info
- attribute_name_index,u2,0x0009,对应是Code属性,接下来按照Code来解析
- attribute_length,u4,0x0000002f
- max_stack,u2,0x0001,操作数栈的最大深度
- max_locals,u2,0x0001,局部变量表所需要的空间
- code_length,u4,0x00000005,code长度为5个u1
- code,u1, 2A,B7,00,01,B1
- 2A,对应指令aload_0,将第0个变量槽中reference类型的本地变量推送到操作数栈
- B7, 对应指令invokespecial,
- 00, 对应指令nop,什么也不做
- 01,对应指令aconst_null,将null推到栈顶
- B1,对应指令 return
- exception_table_length,u2,0x0000
- exception_table,
- attributes_count,u2,0x0002
- attributes。。。后面就不解析了,意会就好。
6.3.7属性表集合
在方法表中,我们遇到了一个属性"Code",还有其他属性。
| 属性名称 | 使用位置 | 含义 |
|---|---|---|
| Code | 方法表 | Java代码编译成的字节码指令 |
| ConstantValue | 字段表 | 由final关键字定义的常量值 |
| LocalVariableTable | Code属性 | 方法的局部变量描述 |
| SourceFile | 类文件 | 记录类文件名称 |
| ... | ... | ... |
解析工作就不做了。
6.4 字节码指令简介
Java虚拟机指令,单字节操作码(OpCode)+操作数(Operand,零或多个)。面向操作数栈,而非寄存器。多数指令不包含操作数,指令参数放在操作数栈。 指令执行过程简易伪代码
do{
自动计算PC寄存器值加1;
根据PC寄存器指示位置,从字节码流中取出操作码;
if(字节码存在操作数) 从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码流长度>0);
6.4.1 字节码与数据类型
大多数指令都包含其操作要求的数据类型,例如iload,从局部变量表中加载int型数据到操作数栈中。 i代表int,l代表long,f代表float,a代表reference。 也有些指令跟数据类型无关,比如 goto。
6.4.2指令分类
- 加载和存储指令, iload,iload_0,iload_1,fload,istore,bipush,sipush,wide...
- 运算指令,iadd,isub,idiv,ishl,ior,ixor,iinc,dcmpg
- 类型转换指令,窄化显式类型转换 i2b,i2c,d2f。
- 对象创建与访问指令,new,newarray,anewarray,getfield,baload,iaload
- 操作数栈管理指令,pop,pop2,dup2_x1,swap
- 控制转移指令,ifeq,tableswitch,goto,goto_w
- 方法调用和返回指令, invokevirtual,invokeinterface,invokespecial,invokestatic,invokedynamic
- 异常处理指令,athrow
- 同步指令。
其他等等。。这章就了解下字节码构成和指令。
第7章 虚拟机类加载机制
7.1 概述
类加载机制:从Class文件到内存中Java类型的过程。各个阶段时间段上可以有重叠。 类加载是在运行期间执行的,也描述为动态加载和动态连接。
7.2 类加载时机
对于什么时候开始加载, 《Java虚拟机规范》没有强制约束。但是严格规定了有且只有六种情况,如果类没有初始化,必须立即对类进行"初始化" (加载、连接必然会先执行),称之为主动引用:
- 遇到new、getstatic、putstatic、invokestatic字节码指令时
- 指令new实例化对象。
- 指令getstatic/putstatic 访问其静态对象(被final修饰,编译期已放入常量池的除外)。
- 指令invokestatic,调用其静态方法。
- 反射调用
- 子类初始化时,先触发父类的初始化
- 虚拟机启动时,初始化用户指定的要执行的主类
- MethodHandle解析结果为REF_getStatic,REF_setStatic,REF_invokeStaitc,Ref_newInvokeSpecial
- 当一个接口定义了JDK 8新加入的默认方法(default修饰)
不会触发类初始化的几个场景举例:
- 通过子类引用父类的静态字段,不会导致子类初始化
- 通过数组定义引用类,不会触发此类的初始化
- 引用在编译期已被放入常量池的常量。
//场景一 通过子类引用父类的静态字段,不会导致子类初始化
public class SuperClass {
static { System.out.println("SuperClass init!");}
public static int value=123;
}
public class SubClass extends SuperClass{
static { System.out.println("SubClass init!");}
}
public class NoInitialization1 {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
//场景二 通过数组定义引用类,不会触发此类的初始化
public class NoInitialization2 {
public static void main(String[] args) {
SuperClass[] scarray=new SuperClass[10];
}
}
//场景三 引用在编译期已被放入常量池的常量。
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWRLD="hello world";
}
public class NoInitialization3 {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWRLD);
}
}
对于场景三我们查看NoInitialization3的字节码发现,“hello world”已经在其常量池中,使用 ldc指令将常量压入栈中。而System.out则是使用getstatic指令。这个地方不涉及到ConstClass的初始化
Constant pool:
#4 = String #25 // hello world
#25 = Utf8 hello world
{
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String hello world
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
7.3类加载过程
7.3.1 加载
加载(loading):从静态文件到运行时方法区。完成三件事情:
- 通过一个类的全限定名获取定义此类的二进制字节流。
- 可以从ZIP包中读取(JAR,WAR等等)
- 从网络中获取,比如Web Applet
- 运行时计算生成,比如动态代理技术, “*$Proxy”代理类
- 数据库中读取
- 加密文件中读取
- ......
- 将该字节流的类静态存储结构转化成方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。
使用Java虚拟机内置的引导类加载器,或者用户自定义的类加载器。
7.3.2 验证
确保字节流符合《Java虚拟机规范》的约束,代码安全性问题验证。验证是重要的,但不是必须的。 四个阶段:
- 文件格式验证,此阶段通过后,会存储到方法区。后面阶段基于方法区数据进行验证,不再读取字节流。
- 元数据验证,类元数据信息语义校验
- 字节码验证,最复杂,对类的Code部分进行检验分析。程序语义合法性,安全性等等
- 符号引用验证,在解析过程中发生,如果无法通过符号引用验证,Java虚拟机会抛出java.lang.IncompatibleClassChangeError的子类异常,如 NoSuchFieldError,NoSuchMethodError等等。
7.3.3 准备
通常情况下,为类变量(静态变量),分配内存并设置初始值(零值)。初值并不是代码中赋的值123。123要等到初始化阶段。
public static int value = 123;
编译成class文件
public static int value;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
...
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
0: bipush 123
2: putstatic #2 // Field value:I
5: return
...
某些情况下,设置初始值为456。比如final修饰的变量。因为变量值456,会提前加入到常量池。
public static final int value2 = 456;
public static final int value2;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 456
7.3.4 解析
将常量池内的符号引用替换为直接引用的过程。 比如说这种,我们要把 #2替换成实际的类引用,如果是未加载过的类引用,又会涉及到这个类加载过程。
getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
- 类或接口解析
- 字段解析
- 方法解析
- 接口方法解析
7.3.5 初始化
执行类构造器clinit()方法,不是实例构造器init()方法 。
clinit()方法:执行类变量赋值语句和静态语句块(static{})。顺序为其在源文件中顺序决定。
举例1:非法向前引用变量。 value的定义在 static{} 之后,只能赋值,不能读取值。
public class PrepareClass {
static {
value=3;
System.out.println(value);// value: illegal forword reference
}
public static int value=123;
}
但是下面就可以
public class PrepareClass {
public static int value=123;
static {
value=3;
System.out.println(value);
}
}
class文件参考
0: bipush 123
2: putstatic #2 // Field value:I
5: iconst_3
6: putstatic #2 // Field value:I
9: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
12: getstatic #2 // Field value:I
15: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
18: return
举例2: clinit()执行顺序。子类初始化时,要先初始化父类
public class TestCInitClass2 {
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
输出:
2
Java虚拟机必须保证clinit()方法在多线程环境下的同步问题。
7.4 类加载器
实现“通过一个类的全限定名来获取其二进制字节流”的代码,称之为“类加载器”(Class Loader)。
7.4.1 类与类加载器
类与其加载器确定了这个类在Java虚拟机中的唯一性。
三层类加载器,绝大多数Java程序会用到以下三个系统提供的类加载器进行加载:
- 启动类加载器(BootStrap Class Loader)
- 扩展类加载器(Extension Class Loader)
- 应用程序类加载器(Application Class Loader)
除了以上三个还有用户自定义的加载器,通过集成java.lang.ClassLoader类来实现。
启动类加载器
加载Java的核心库,native代码实现,不继承java.lang.ClassLoader
URL[] urls= sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
System.out.println(url);
}
结果输出:
file:../jdk1.8.0_73.jdk/Contents/Home/jre/lib/resources.jar
file:../jdk1.8.0_73.jdk/Contents/Home/jre/lib/rt.jar
file:../jdk1.8.0_73.jdk/Contents/Home/jre/lib/sunrsasign.jar
file:../jdk1.8.0_73.jdk/Contents/Home/jre/lib/jsse.jar
file:../jdk1.8.0_73.jdk/Contents/Home/jre/lib/jce.jar
file:../jdk1.8.0_73.jdk/Contents/Home/jre/lib/charsets.jar
file:../jdk1.8.0_73.jdk/Contents/Home/jre/lib/jfr.jar
file:../jdk1.8.0_73.jdk/Contents/Home/jre/classes
扩展类加载器
加载Java的扩展库,加载ext目录下的Java类
URL[] urls= ((URLClassLoader) ClassLoader.getSystemClassLoader().getParent()).getURLs();
for (URL url : urls) {
System.out.println(url);
}
结果输出:
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/sunec.jar
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/nashorn.jar
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/cldrdata.jar
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/jfxrt.jar
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/dnsns.jar
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/localedata.jar
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/sunjce_provider.jar
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/sunpkcs11.jar
应用程序类加载器
加载Java应用的类。通过ClassLoader.getSystemClassLoader()来获取。
URL[] urls= ((URLClassLoader) ClassLoader.getSystemClassLoader().getParent()).getURLs();
for (URL url : urls) {
System.out.println(url);
}
结果输出:
file:/.../jdk1.8.0_73.jdk/Contents/Home/jre/lib/ext/sunec.jar
...
file:/.../jdk1.8.0_73.jdk/Contents/Home/lib/tools.jar
file:/.../java_sample/out/production/java_sample/ //这是我们的应用程序
file:/Applications/IntelliJ%20IDEA.app/Contents/lib/idea_rt.jar
自定义类加载器
7.4.2 双亲委派模型
ClassLoader.loadClass
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
AppClassLoader,ExtClassLoader都继承URLClassLoader。 URLClassLoader.findClass(name)
protected Class<?> findClass(final String name)throws ClassNotFoundException {
// 1、安全检查
// 2、根据绝对路径把硬盘上class文件读入内存
byte[] raw = getBytes(name);
// 3、将二进制数据转换成class对象
return defineClass(raw);
}
如果我们自己去实现一个类加载器,基本上就是继承ClassLoader之后重写findClass方法,且在此方法的最后调包defineClass。 ** 双亲委派确保类的全局唯一性。 例如无论哪个类加载器需要加载java.lang.Object,都会委托给最顶端的启动类加载器加载。
为什么要双亲委派?
确保类的全局唯一性。
如果你自己写的一个类与核心类库中的类重名,会发现这个类可以被正常编译,但永远无法被加载运行。因为你写的这个类不会被应用类加载器加载,而是被委托到顶层,被启动类加载器在核心类库中找到了。如果没有双亲委托机制来确保类的全局唯一性,谁都可以编写一个java.lang.Object类放在classpath下,那应用程序就乱套了。
从安全的角度讲,通过双亲委托机制,Java虚拟机总是先从最可信的Java核心API查找类型,可以防止不可信的类假扮被信任的类对系统造成危害。
7.4.3 线程上下文类加载器
线程上下问类加载器出现的原因:
Q: 越基础的类由越上层的加载器进行加载,如果基础类又要调用回用户的代码,那该怎么办?
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文类加载器,也就是父类加载器请求子类加载器去完成类加载的动作(即,父类加载器加载的类,使用线程上下文加载器去加载其无法加载的类),这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则。
Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类
加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行
为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性
原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、
JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供
者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
第8章 虚拟机字节码执行引擎
8.1 概述
虚拟机是相对于物理机的概念。 物理机的执行引擎是直接建立在处理器,缓存,指令集合操作系统底层上。 虚拟机的执行引擎是建立在软件之上,不受物理条件限制,定制指令集与执行引擎。 虚拟机实现中,执行过程可以是解释执行和编译执行,可以单独选择,或者混合使用。 但所有虚拟机引擎从统一外观(Facade)来说,都是输入字节码二进制流,字节码解析执行,输出执行结果。
本章从概念角度讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元。每个方法在执行时,都会有一个对应的栈帧(Stack Frame) .栈帧同时也是虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
一个栈帧需要多大的局部变量表,需要多深的操作数栈,早在编译成字节码时就写到了方法表的Code属性中。
Code: stack=2, locals=1, args_size=1
因此一个栈帧需要分配多少内存,在运行前就已确定,取决于源码和虚拟机自身实现。
8.2.1 局部变量表
局部变量表容量最小单位为变量槽(Variable Slot), 《Java虚拟机规范》规定一个变量槽可以存放一个boolean,byte,char,init,float,reference或returnAddress类型的数据。32位系统可以是32位,64位系统可以是64位去实现一个变量槽。对于64位的数据类型(long和double),以高位对齐的方式分配两个连续的变量槽。 由于是线程私有,无论两个连续变量槽的读写是否为原子操作,都不会有线程安全问题。
从参数到参数列表
当一个方法被调用时,会把参数值放到局部变量表中。类方法参数Slot从0开始。实例方法参数Slot从1开始,Slot0给了this,指向实例。 我们比较类方法和实例方法的字节码。
public static int add(int a, int b) {return a + b;}
public int remove(int a, int b) {return a - b;}
public static int add(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 a I
0 4 1 b I
public int remove(int, int);
flags: ACC_PUBLIC
Code:
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Lexecute/Reerer;
0 4 1 a I
0 4 2 b I
变量槽复用
当变量的作用域小于整个方法体时,变量槽可以复用,为了节约栈内存空间。比如 {},if(){}等代码块内。变量槽复用会存在“轻微副作用”,内存回收问题。
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
//执行结果
[Full GC (System.gc()) 66040K->65934K, 0.0040938 secs]
//解释: 虽然placeholder的作用域被限制,但gc时,局部变量表仍然引用placeholder,无法被回收。
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int i=0;
System.gc();
}
//执行结果
[Full GC (System.gc()) 66040K->398K, 0.0044978 secs]
//解释: 虽然placeholder的作用域被限制,int i=0复用了slot0,切断了局部变量表的引用placeholder。
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
placeholder=null;
}
System.gc();
}
//执行结果
[Full GC (System.gc()) 66088K->398K, 0.0050265 secs]
//解释 主动释放placeholder
局部变量赋值
类变量在准备阶段,会被赋默认零值。而局部变量没有准备阶段。所以下面代码是编译不通过的,即便编译通过,在检验阶段,也会被发现,导致类加载失败。
public static void fun4(){
int a;
//编译失败,Variable ‘a’ might not have been initialized
System.out.println(a);
}
8.2.2操作数栈
操作数栈(Operand Stack) 字节码指令读取和写入操作数栈。操作数栈中元素的数据类型必须与指令序列严格匹配。编译阶段和类检验阶段都会去保证这个。 在大多数虚拟机实现中,上面栈帧的操作数栈与下面栈帧的局部变量会有一部分重叠,这样不仅节约了空间,重要的是在方法调用时直接公用数据,无须而外的参数复制。
8.2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方 法调用过程中的动态连接(Dynamic Linking)。 在类加载过程中,会把符号引用解析为直接引用。方法调用指令以常量池中的符号引用为参数。 根据方法符号引用转化为直接引用的时机,可以分为:
- 静态解析:在类加载或第一次使用时转化。
- 动态连接:在每次运行期间转化。 关于这两个转化过程的具体过程,将在8.3节中再详细讲解。
8.2.4 方法返回地址
正常调用完成和异常调用完成。 恢复主调方法的执行状态。
8.3 方法调用
Java虚拟机中的5条方法调用指令:
- invokestatic,调用静态方法。
- invokespecial,调用实例构造器init方法,私有方法和父类中的方法。
- invokevirtual,调用虚方法?
- invokeinterface,调用接口方法,会在运行期,确定一个该接口的实现对象。
- invokedynamic,运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
前4个指令逻辑固化在虚拟机内部,而invokedynamic指令的分派逻辑由用户设定的引导方法决定。 方法按照类加载阶段是否能转化成直接引用分类,可以分为:
- 非虚方法(Non-Virtual Method), 类加载阶段,直接把符号引用解析为该方法的直接引用。
- 包括可以被invokestatic调用的静态方法,
- 包括可以被invokespecial调用的实例构造器,私有方法,父类方法
- final修饰的方法(尽管它使用invokevirtual调用),此类型方法无法被覆盖,不存在多太选择,是唯一的。
- 虚方法 (Virtual Method) ,其他方法。
8.3.1 解析
非虚方法的调用称之为解析(Resolution),"编译器可知,运行期不可变",即类加载阶段把符号引用转化为直接引用。 而另外一个方法调用的方式称之为分派(Dispatch)。
8.3.2 分派
分派(Dispatch)是静态或者动态的,又或者是单分派或者多分派。重载或者重写会出现同名方法。同名方法的选择,我可以称之为分派
1. 静态分派
Method Overload Resolution, 这部分内容实际上叫做方法重载解析。静态分派发生在编译阶段。 先来看一段代码,sayHello方法重载。
//方法静态分派
public class StaticDispatch {
static abstract class Human{}
static class Man extends Human{}
static class Woman extends Human{}
public static void sayHello(Man man){System.out.println("hello,gentleman!"); }
public static void sayHello(Human guy){ System.out.println("hello,guy!");}
public static void sayHello(Woman women){System.out.println("hello,lady!");}
public static void main(String[] args) {
Human man=new Man();
Human woman=new Woman();
StaticDispatch dispatch=new StaticDispatch();
dispatch.sayHello(man);
dispatch.sayHello(woman);
}
}
//执行结果:
hello,guy!
hello,guy!
对应Class字节码
public static void main(java.lang.String[]);
Code:
stack=2, locals=3, args_size=1
0: new #7 // class execute/StaticDispatch$Man
3: dup
4: invokespecial #8 // Method execute/StaticDispatch$Man."<init>":()V
7: astore_1
8: new #9 // class execute/StaticDispatch$Woman
11: dup
12: invokespecial #10 // Method execute/StaticDispatch$Woman."<init>":()V
15: astore_2
16: new #11 // class execute/StaticDispatch
19: dup
20: invokespecial #12 // Method "<init>":()V
23: astore_3
24: aload_3
25: aload_1
26: invokevirtual #13 // Method sayHello:(Lexecute/StaticDispatch$Human;)V
29: aload_3
30: aload_2
31: invokevirtual #13 // Method sayHello:(Lexecute/StaticDispatch$Human;)V
34: return
- 第0~15行,我们构建了Man对象和Woman对象,并放入了局部变量表中。
- 第26行,执行方法Method sayHello:(Human;)V
- 第31行,执行方法Method sayHello:(Human;)V, 实际执行的都是sayHello(Human)。而不是sayHello(Man)或者sayHello(Woman)。 这里涉及到两个类型:
- 静态类型(Static Type),或者叫“外观类型(Apparent Type)”,即Human
- 实际类型(Actual Type),或者叫“运行时类型(Runtime Type)”,即Man,Woman
编译期并不知道对象的实际类型,所以按照对象的静态类型去分派方法。
2. 动态分派
与重写(Override)密切关联。动态分派发生在运行期间。在运行时,确定方法的接收者(方法所属对象)
//方法动态分派
public class DynamicDispatch {
static abstract class Human {
public void sayHello() {System.out.println("hello,guy!");}
}
static class Man extends Human {
public void sayHello() {System.out.println("hello,gentleman!"); }
}
static class Woman extends Human {
public void sayHello() {System.out.println("hello,lady!");}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();//hello,gentleman!
woman.sayHello();//hello,lady!
man = new Woman();
man.sayHello();//hello,lady!
}
}
//执行结果:
hello,gentleman!
hello,lady!
hello,lady!
对应字节码
0: new #2 // class execute/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method execute/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class execute/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method execute/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method execute/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method execute/DynamicDispatch$Human.sayHello:()V
24: new #4 // class execute/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method execute/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method execute/DynamicDispatch$Human.sayHello:()V
36: return
第7行,astore_1存储了Man对象
第15行,astore_2存储了Woman对象
第16,17行,aload_1,invokevirtual.实际调用的是Man.sayHello()方法
第20,21行,aload_2,invokevirtual.实际调用的是Woman.sayHello()方法
第31行,astore_1存储了Woman对象
第32,33行,aload_1,invokevirtual.实际调用的是Woman.sayHello()方法
运行期间,选择是根据man和woman对象的实际类型分派方法。
小知识:字段永远不参与多态,方法中访问的属性名始终是当前类的属性。子类会遮蔽父类的同名字段
3.单分派与多分派
方法的宗量:方法的接收者与方法的参数 单分派:基于一种宗量分派 多分派:基于多种宗量分派。 当前Java语言是一门静态多分派,动态单分派的语言。编译期根据方法接收者和参数确定方法的符号引用。运行期根据方法的接收者,解析和执行符号引用。
考虑下面一段代码
public class Dispatch {
static class Father{
public void f() {System.out.println("father f void");}
public void f(int value) {System.out.println("father f int");}
}
static class Son extends Father{
public void f(int value) {System.out.println("Son f int"); }
public void f(char value) { System.out.println("Son f char");}
}
public static void main(String[] args) {
Father son=new Son();
son.f('a');
}
}
//执行结果: Son f int
字节码
0: new #2 // class execute/Dispatch$Son
3: dup
4: invokespecial #3 // Method execute/Dispatch$Son."<init>":()V
7: astore_1
8: aload_1
9: bipush 97
11: invokevirtual #4 // Method execute/Dispatch$Father.f:(I)V
首先是编译期的静态分派,先选择静态类型Father,由于Father中没有f(char),则会选择最合适的f(int),确定方法为Father.f:(I)V。其次是运行期,接收者为Son,Son中有重写的f:(I)V。所以最终执行的是Son.f:(I)V
4. 虚拟机动态分派的实现
虚方法表,接口方法表,类型继承分析,守护内联,内联缓存
8.5 基于栈的字节码解释执行引擎
8.5.1 解释执行
Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK 1.0时代,这种定义还算是比较 准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译 执行,就成了只有虚拟机自己才能准确判断的事。再后来,Java也发展出可以直接生成本地代码的编 译器(如Jaotc、GCJ[1],Excelsior JET),而C/C++语言也出现了通过解释器执行的版本(如 CINT[2]),这时候再笼统地说“解释执行”,对于整个Java语言来说就成了几乎是没有意义的概念,只 有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会 比较合理确切。
无论是解释还是编译,也无论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后获得执行能力。大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图8-4中的各个步骤。如果读者对大学编译原理的相关课程还有印象的话,很容易就会发现图8-4中下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;而中间的 那条分支,自然就是解释执行的过程。
如今,基于物理机、Java虚拟机,或者是非Java的其他高级语言虚拟机(HLLVM)的代码执行过程,大体上都会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说, 词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义 的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法 树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟 机的内部,所以Java程序的编译就是半独立的实现。
8.5.2 基于栈的指令集与基于寄存器的指令集
Javac编译器输出的字节码指令流,基本上[1]是一种基于栈的指令集架构,字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集。
举个最简单的例子,分别使用这两种指令集去计算“1+1”的结果,基于栈的指令集会是这样子的:
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。而如果用基于寄存器的指令集,那程序可能会是这个样子:
mov eax, 1
add eax, 1
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。
基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接用到这些寄存器,那就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更简单一些。 栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构也从侧面印证了这点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。
在解释执行时,栈架构指令集的代码虽然紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈操作本身就产生了相当大量的指令。更重要的是栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。因此由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度会相对慢上一点。
8.5.3 基于栈的解释器执行过程
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
stack=2, locals=4, args_size=1
0: bipush 100 //常量100压入操作数栈顶
2: istore_1 //栈顶元素(100)存入变量槽1,同时消费掉栈顶元素
3: sipush 200 //常量200压入操作数栈顶
6: istore_2 //栈顶元素(200)存入变量槽2,同时消费掉栈顶元素
7: sipush 300 //常量300压入操作数栈顶
10: istore_3 //栈顶元素(300)存入变量槽3,同时消费掉栈顶元素
11: iload_1 //将局部变量slot1值100压入操作数栈顶
12: iload_2 //将局部变量slot2值200压入操作数栈顶
13: iadd //消费栈顶100和200,得到300,并压入栈顶
14: iload_3 //将局部变量slot3值300压入操作数栈顶
15: imul //消费栈顶300和300,得到90000,并压入栈顶
16: ireturn //消费栈顶90000,整型结果返回给方法调用者
第10章 前端编译与优化
10.1 概述
先明确几个概念 即时编译器(JIT编译器,Just In Time Compiler),运行期把字节码变成本地代码的过程。 提前编译器(AOT编译器,Ahead Of Time Compiler),直接把程序编译成与目标及其指令集相关的二进制代码的过程。
这里讨论的“前端编译器”,是指把*.java文件转换成*.class文件的过程,主要指的是javac编译器。
10.2 Javac编译器
10.2.1 介绍
Javac编译器是由Java语言编写。分析Javac代码的总体结构来看,编译过程大致分为1个准备过程和3个处理过程。如下:
- 1)准备过程:初始化插入式注解处理器
- 2)解析与填充符号表
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。
- 3)插入式注解处理器的注解处理
- 4)分析与字节码生成
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查
- 解语法糖。将语法糖代码还原为原有形式
- 字节码生成。将前面各个步骤生成的信息转化为字节码。
如果注解处理产生新的符号,又会再次进行解析填充过程。
Javac编译动作的入口com.sun.tools.javac.main.JavaCompiler类。代码逻辑主要所在方法compile(),compile2()
10.2.2 解析和填充符号表
1. 词法、语法分析
对应parserFiles()方法 词法分析:源码字符流转变为标记(Token)集合的过程。标记是编译时的最小元素。关键字、变量名、字面量、运算符都是可以作为标记。 如“int a = b + 2”, 包含了6个标记,int,a , =, b, +, 2 。词法分析由com.sun.tools.javac.parser.Scanner实现。 语法分析:根据标记序列构造抽象语法树的过程。抽象语法树(Abstract Syntax Tree,AST),描述代码语法结构的树形表示形式,树的每个节点都代表一个语法结构,例如包,类型,运算符,接口,返回值等等。com.sun.tools.javac.parser.Parser实现。抽象语法树是以com.sun.tools.javac.tree.JCTree类表示。 后续的操作建立在抽象语法树之上。
2.填充符号表
对应enterTree()方法。
10.2.3 注解处理器
JDK6,JSR-269提案,“插入式注解处理器”API。提前至编译期对特定注解进行处理,可以理解成编译器插件,允许读取、修改、添加抽象语法树中的任意元素。如果产生改动,编译器将回到解析及填充符号表过程重新处理,直到不产生改动。每一次循环过程称为一个轮次(Round). 使用注解处理器可以做很多事情,譬如Lombok,可以通过注解自动生成getter/setter方法、空检查、产生equals()和hashCode()方法。
10.2.4 语义分析与字节码生成
抽象语法树能够表示一个正确的源程序,但无法保证语义符合逻辑。语义分析的主要任务是进行类型检查、控制流检查、数据流检查等等。 例如
int a = 1;
boolean b = false;
char c = 2;
//后续可能出现的运算,都是能生成抽象语法树的,但只有第一条,能通过语义分析
int d= a + c;
int d= b + c;
char d= a + c;
在IDE中看到的红线标注的错误提示,绝大部分来源于语义分析阶段的结果。
1. 标注检查
attribute()方法,检查变量使用前是否已被声明,变量与赋值的数据类型是否匹配等等。 3个变量的定义属于标注检查。标注检查顺便会进行极少量的一些优化,比如常量折叠(Constant Folding).
int a = 1 + 2; 实际会被折叠成字面量“3”
2. 数据及控制流分析
flow()方法,上下文逻辑进一步验证,比如方法每条路径是否有返回值,数值操作类型是否合理等等。
3. 解语法糖
语法糖(Syntactic Sugar),编程术语 Peter J.Landin。减少代码量,增加程序可读性。比如Java语言中的泛型(其他语言的泛型不一定是语法糖实现,比如C#泛型直接有CLR支持),变长数组,自动装箱拆箱等等。 解语法糖,编译期将糖语法转换成原始的基础语法。
4. 字节码生成
- 将前面生成的信息(语法树,符号表)转化为字节码,
- 少量代码添加,(),()等等
- 少量代码优化转换,字符串拼接操作替换为StringBuffer或StringBuilder等等。
10.3 Java语法糖
10.3.1 泛型
1.Java泛型
JDK5,Java的泛型实现称为“类型擦除式泛型”(Type Erasure Generic),相对的C#选择的是“具现化泛型”(Reified Generics),C#泛型无论在源码中,还是编译后的中间语言表示(此时泛型都是一个占位符),List 与List是两个不同的类型。而Java泛型,只是在源码中存在,编译后都变成了统一的类型,称之为类型擦除,在使用处会增加一个强制类型转换的指令。
Map<String, String> stringMap = new HashMap<String, String>();
stringMap.put("hello", "你好");
System.out.println(stringMap.get("hello"));
Map objeMap = new HashMap();
objeMap.put("hello2", "你好2");
System.out.println((String)objeMap.get("hello2"));
截取部分字节码
0: new #2 // class java/util/HashMap
4: invokespecial #3 // Method java/util/HashMap."<init>":()V
13: invokeinterface #6, 3 // InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
25: invokeinterface #8, 2 // InterfaceMethod java/util/Map.get:(Ljava/lang/Object;)Ljava/lang/Object;
30: checkcast #9 // class java/lang/String
33: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
36: new #2 // class java/util/HashMap
40: invokespecial #3 // Method java/util/HashMap."<init>":()V
49: invokeinterface #6, 3 // InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
61: invokeinterface #8, 2 // InterfaceMethod java/util/Map.get:(Ljava/lang/Object;)Ljava/lang/Object;
66: checkcast #9 // class java/lang/String
69: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
72: return
可以看到两部分代码在编译后是一样的。 第0行,new HashMap<String, String>() 实际构造的是java/util/HashMap。 第30行,stringMap.get("hello") ,checkcast指令,做了一个类型转换
2. 历史背景
2004年,Java5.0。为了保证代码的“二进制向后兼容”,引入泛型后,原先的代码必须能够编译和运行。例如Java数组支持协变,集合类也可以存入不同类型元素。 代码如下
Object[] array = new String[10];
array[0] = 10; // 编译期不会有问题,运行时会报错
ArrayList things = new ArrayList();
things.add(Integer.valueOf(10)); //编译、运行时都不会报错
things.add("hello world");
如果要保证Java5.0引入泛型后,上述代码依然可以运行,有两个选择:
- 原先需要泛型化的类型保持不变,再新增一套泛型化的类型版本。泛型具现化,比如C#新增了一组System.Collections.Generic的新容器,原先的System.Collections保持不变。
- 把需要泛型化的类型原地泛型化,Java5.0采用的原地泛型化方式为类型擦除。
为何C#与Java的选择不同,主要是C#当时才2年遗留老代码少,Java快10年了老代码多。类型擦除是偷懒留下的技术债。
3.类型擦除
类型擦除除了前面提到的编译后都变成了统一的裸类型以及使用时的类型检查和转换之外还有其他缺陷。 1)不支持原始类型(Primitive Type)数据泛型,ArrayList需要使用其对应引用类型ArrayList,导致了读写的装箱拆箱。 2)运行期无法获取泛型类型信息,例如
public <E> void doSomething(E item){
E[] array=new E[10]; //不合法,无法使用泛型创建数组
if(item instanceof E){}//不合法,无法对泛型进行实例判断
}
当我们去写一个List到数组的转换方法时,需要额外传递一个数组的组件类型
public static <T> T[] convert(List<T> list,Class<T> componentType){
T[] array= (T[]) Array.newInstance(componentType,list.size());
for (int i = 0; i < list.size(); i++) {
array[i]=list.get(i);
}
return array;
}
3)类型转换问题。
//无法编译通过
//虽然String是Object的子类,但ArrayList<String>并不是ArrayList<Object>的子类。
ArrayList<Object> list=new ArrayList<String>();
为了支持协变和逆变,泛型引入了 extends ,super
//协变
ArrayList<? extends Object> list = new ArrayList<String>();
//逆变
ArrayList<? super String> list2 = new ArrayList<Object>();
4 值类型与未来泛型
2014年,Oracle,Valhalla语言改进项目内容之一,新泛型实现方案
10.3.2 其他
自动装箱,自动拆箱,遍历循环,变长参数,条件编译,内部类,枚举类,数值字面量,switch,try等等。
10.3.3 *扩展阅读
10.4 实战 Lombok注解处理器
第11章 后端编译与优化
11.1概述
前面一章讲的是从*.java到*.class的过程,即源码到字节码的过程。 这一章讲的是从二进制字节码到目标机器码的过程,分为两种即时编译器和提前编译器。
11.2 即时编译器
目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器 (Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认 定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代 码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称 为即时编译器。
11.3 提前编译器
- 即时编译消耗的时间都是原本可用于程序运行的时间,消耗的运算资源都是原本可用
于程序运行的资源,
- 给即时编译器做缓存加速,去改善Java程序的启动时间,以
及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dynamic AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)
Android虚拟机历程: Android4.4之前 Dalvik虚拟机 即使编译器 Android4.4开始 Art虚拟机 提前编译器,导致安装时需要编译App,非常耗时,但运行性能得到提升 Android7.0开始 重新启用解释执行和即时编译,系统空闲时间时自动进行提前编译。
11.3 编译器优化技术
略
第12章 Java内存模型与线程
12.1 概述
介绍虚拟机如何实现多线程,多线程之间由于共享数据而导致的一系列问题及解决方案
12.2 硬件效率与一致性
介绍Java虚拟机内存模型前,先了解下物理机的并发问题。
- 硬件效率问题。计算机处理任务除了处理器计算外,还有内存交互,即读写数据。而存储设备与处理机运行速度相差几个数量级,为此引入了读写速度尽可能接近处理器的高速缓存Cache。处理器读写缓存数据,缓存将数据同步到内存。
- 缓存一致性问题。在共享内存多核系统中,每个处理器都有自己的高速缓存,又共享同一主内存。为了解决一致性问题,处理器访问高速缓存时,需要遵循一些协议,比如MSI,MESI,MISI,Synapse,Dragon Protocol等。
- 代码乱序执行优化问题。处理器为了提高运算效率,会出现不按顺序执行的情况,但单线程下,处理器会保证执行结果与顺序执行结果一致。而多线程的情况下,无法保证多个任务都按照顺序执行。
Java虚拟机有自己的内存模型,也会有与物理机类型的问题。
12.3 Java内存模型
12.3.1 概述
Java内存模型规定:所以变量都存储在主内存(Main Memory)中,线程有自己的工作内存,工作内存保存变量在主内存副本。线程对变量的读写只能再工作内存(Working Memory)中,线程间共享变量需要通过主内存完成。 JVM内存模型的执行处理将围绕解决两个问题展开:
- 工作内存数据一致性
- 指令重排序优化,编译期重排序和运行期重排序。
12.3.2 内存交互操作
主内存与工作内存的交互协议定义如下操作,Java虚拟机必须保证这些操作是原子性的。
- lock,作用于主内存变量,把变量标识为线程独占状态,使其他线程无法lock
- unlock,作用于主内存变量,解除线程独占状态
- read,作用于主内存变量,把变量值传输到工作内存中,一边随后的load使用
- load,作用于工作内存变量,把read的变量值放入工作内存的变量副本中。
- use,作用于工作内存变量,变量值传递给执行引擎
- assign,作用于工作内存变量,执行引擎赋值给工作内存中的变量
- store,作用于工作内存变量,变量值传输到主内存,以便后续write使用
- write,作用于主内存变量,把store的变量值放入主内存变量中。
如果要把变量从主内存拷贝到工作内存,必须顺序执行 read和load,但不要求一定连续。
如果要把变量从工作内存同步到主内存,必须顺序执行 store和write,但不要求一定连续。
12.3.3 内存模型运行规则
1.内存交互基本操作的3个特性
Java内存模型是围绕着在并发过程中如何处理这3个特性来建立的,归根结底是为了实现共享变量在多个工作内存中的一致性,以及并发时,程序能如期运行。
- 原子性(Atomicity),即一个操作或者多个操作,要么不执行,要么全部执行且执行过程不会被打断
- 可见性(Visibility),当多个线程访问同一个变量时,一个线程改变了变量值,其他线程要能立即看到修改过的值。线程通过共享主内存实现可见性。
- 有序性(Ordering),线程内指令串行(as-if-serial),线程间,对于同步(synchrinized)代码以及volatile字段的操作需要维持相对有序
2.先行发生原则
happens-before
- 程序次序规则
- 管程锁定规则
- volatile变量规则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性
3.内存屏障
内存屏障是被插入到两个CPU指令之间的一种指令,用来禁止处理器指令发生指令重排序。
12.3.4 volatile型变量
volatile主要有下面两种语义
语义1 保证可见性
保证了不同线程对该volatile型变量操作的内存可见性,但不等同于并发操作的安全性
- 线程写volatile变量的过程assign-store-write必须连续出现:
- 改变工作内存中volatile变量副本的值
- 将改变的副本值刷新到主内存中
- 线程读volatile变量的过程read-load-use必须连续出现:
- 从主内存读取volatile变量值并存入工作线程副本
- 从工作内存读取变量副本
语义2 禁止指令重排序
volatile型变量使用场景总结起来就是"一次写入,到处读取",某个线程负责更新变量,其他线程只读取变量,并根据变量新值执行相应逻辑,例如状态标志位更新,观察者模型变量值发布