[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xkKr6bPF-1666975994807)(./java虚拟机运行时数据区.jpg)]
执行引擎: 即时编译器(JIT)/垃圾收集
当前线程所执行的字节码的行号指示器,唯一一个没有 oom 的区域
虚拟机栈和线程的生命周期相同,每个方法被执行的时候, Java 虚拟机都 会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,
局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用和 returnAddress 类型
如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出 StackOverflowError 异常;如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常
本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常
此内存区域的唯一目的就是存放对象实例,是垃圾收集器管理的内存区域
Java 虚拟机通过参数-Xmx 和-Xms 设定扩展。
如果在 Java 堆中没有内存完成实例分配,并且堆也无法再 扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常
它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
java8 中,取消永久代,方法存放于元空间(Metaspace),元空间仍然与堆不相连,但与堆共享物理内存,逻辑上可认为在堆中
如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。
运行时常量池(Runtime Constant Pool)是方法区的一部分,常量池表用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中,具备动态性,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern()方法
当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
直接内存的分配不会受到 Java 堆大小的限制,但是需要合理设置
当使用 Serial、ParNew 等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用 CMS 这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存
一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用 CAS 配上失败 重试的方式保证更新操作的原子性;
另外一种是把内存分配的动作按照线程划分在不同的空间之中进 行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定。虚拟机是否使用 TLAB,可以通过-XX:+/-UseTLAB 参数来 设定
虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值
执行构造函数,即 Class 文件中的
方法
对象头(Header, 实例数据(Instance Data)和对齐填充(Padding)
对象头:
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,使用着动态定义的数据结构;
另外一部分是类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针 来确定该对象是哪个类的实例
实例数据:
是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字 段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来
对齐填充:
主流的访问方式主要有使用句柄和直接指针两种
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nOwxF6Mk-1666975994809)(./句柄访问对象.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8IVSBdEv-1666975994810)(./指针访问对象.png)]
使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机 HotSpot 而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了 Shenandoah 收集器的话也会有一次额外的转发,具体可参见第 3 章),但从整个软件开发的范围来看,在各种语言、框架中 使用句柄来访问的情况也十分常见
要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链,找到泄漏对象是通过怎 样的引用路径、与哪些 GC Roots 相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息 以及它到 GC Roots 引用链的信息
检查 Java 虚拟机的堆参数(-Xmx 与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运 行期的内存消耗
无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候, HotSpot 虚拟机抛出的都是 StackOverflowError 异常
java7 之前: 常量池分配在永久代,常量池内存溢出异常抛出 permgen space
java8:
-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存 大小
-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整
-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率
-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比
如果发现内存溢出之后产生的 Dump 文件很小,而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是 NIO),那就可以考虑重点检查一下直接内存方面的原因了
中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭不需要过多考虑如何回收的问题
而堆和方法区这两个区域则有着很显著的不确定性需要考虑如何回收的问题
单纯的引用计数 就很难解决对象之间相互循环引用的问题
通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连, 或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的
固定可作为 GC Roots 的对象
在虚拟机栈(栈帧中的本地变量表)中引用的对象: 各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量
方法区中类静态属性引用的对象: 类的引用类型静态变量
方法区中常量引用的对象
本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象
所有被同步锁(synchronized 关键字)持有的对象
反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值
软引用是用来描述一些还有用,但非必须的对象
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只 能生存到下一次垃圾收集发生为止
虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系,唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行 finalize()方法。假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对 象要在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合
任何一个对象的 finalize()方法都只会被系统自动调用一次,如果对象面临 下一次回收,它的 finalize()方法不会被再次执行
尽量避免使用 finalize()方法
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
主要缺点有两个:
第一个是执行效率不稳定,如果 Java 堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点
Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾搜集时,将 Eden 和 Survivor 中仍 然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间
其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可 回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行
分为 eden 区/servivorFrom/sivivorTo
eden(8/10)
Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收
servivorFrom(1/10)
上一次 GC 的幸存者,作为这一次 GC 的被扫描者
servivorTo(1/10)
保留了一次 MinorGC 过程中的幸存者
minorGC 过程(复制算法)
Eden/servivorFrom -> servivorTo/old -> 清空 eden/servivorFrom -> servivorFrom 和 servivorTo 互换
主要存放应用程序中生命周期长的内存对象
当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC(标记清楚算法) 进行垃圾回收腾出空间
首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。
MajorGC 会产生内存碎片,为了减少内存损耗,一般需要进行合并或者标记出来方便下次直接分配。
当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常
指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常
元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中。
这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制
根节点枚举
根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统 看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化 的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS、G1、ZGC 等收集器,枚举根节点时也是必须要停顿的
安全点
只是在“特定的位置”记录了这些信息(oopmap(记录引用对象)),这些位置被称为安全点(Safepoint)
例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点
如何在垃圾收集发生时让所有线程(这里其实不包括 执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。
这里有两种方案可供选择:抢先式中断 (Preemptive Suspension)(几乎没有采用) 和主动式中断(Voluntary Suspension)
主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一 个标志位,各个线程执行过程时会不停地主动去轮询这个标志
HotSpot 使用内存保护陷阱的方式, 把轮询操作精简至只有一条汇编指令的程度
安全区域
所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,
这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。
对于这种情况,就必须引入安全区域(Safe Region)来解决
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化
记忆集与卡表
记忆集(数据结构)用以避免把整个老年代加进 GC Roots 扫描范围
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等
字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描
写屏障
在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作
除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题
为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元
素未被标记过时才将其标记为变脏
在 JDK 7 之后,HotSpot 虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。
开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡
并发的可达性分析
可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析, 这意味着必须全程冻结用户线程的运行
当且仅当以下两个条件同时满足时,会产生“对象消失”的问 题,即原本应该是黑色的对象被误标为白色:
赋值器插入了一条或多条从黑色对象到白色对象的新引用;
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用;
要解决并发扫描时的对象消失问题,产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)
增量更新: 黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象
原始快照: 无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
CMS 是基于增量更新 来做并发标记的,G1、Shenandoah 则是用原始快照来实现
Serial 收集器
是一个单线程工作的收集器,对于运行在客户端模式下的虚拟机来说是一个很好的选择
parnew 收集器
是 Serial 收集器的多线程并行版本
ParNew 可以说是 HotSpot 虚拟机中第一款退出历史舞台的垃圾收集器
g1
是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作
parallel scavenge
是基于标记-复制算法实现的收集器,也是 能够并行收集的多线程收集器
Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量,也经常被称作"吞吐量优先收集器",主要适合在后台运算而不需要太多交互的分析任务
serial old
是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法
parallel old
是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现
在注重 吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合
cms
真正意义上支持并发的垃圾收集器
是一种以获取最短回收停顿时间为目标的收集器。目前很 大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS 收集器就非常符合这类应用的需求,基于标记-清除算法实现
1)初始标记(CMS initial mark)
2)并发标记(CMS concurrent mark)
3)重新标记(CMS remark)
4)并发清除(CMS concurrent sweep)
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一 起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的
CMS 默认启动的回收线程数是(处理器核心数量 +3)/4, 当处理器核心数量不足四个时, CMS 对用户程序的影响就可能变得很大
由于 CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的 Full GC 的产生
可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction 的值 来提高 CMS 的触发百分比,降低内存回收频率,获取更好的性能
但参数-XX:CMSInitiatingOccupancyFraction 设置得太高将会很容易导致 大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置
空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找 到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况
为了解决这个问题, CMS 收集器提供了一个-XX:+UseCMS-CompactAtFullCollection 开关参数(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在 Shenandoah 和 ZGC 出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBefore- Compaction(此参数从 JDK 9 开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量 由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表 示每次进入 Full GC 时都进行碎片整理)
garbage first
器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式
到了 JDK 8 Update 40 的时候,G1 提供并发的类卸载的支持
G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以
根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果
处理思路是让 G1 收集器去跟踪各个 Region 里面的垃 圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis 指定,默 认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来
可以由用户指定期望的停顿时间是 G1 收集器很强大的一个功能,设置不同的期望停顿 时间,可使得 G1 在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡
通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的
目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其 优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间
低延时垃圾收集器(shenandoah/ZGC)
内存的扩大,对延迟反而会带来负面的效果
Shenandoah 是一款只有 OpenJDK 才会包含,而 OracleJDK 里反而不存在的收集器
Shenandoah 反而更像是 G1 的下一代继承者,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上 都高度一致,甚至还直接共享了一部分实现代码,这使得部分对 G1 的打磨改进和 Bug 修改会同时反映 在 Shenandoah 之上,而由于 Shenandoah 加入所带来的一些新特性,也有部分会出现在 G1 收集器中,譬 如在并发失败后作为“逃生门”的 Full GC,G1 就是由于合并了 Shenandoah 的代码才获得多线程 Full GC 的支持
Shenandoah 摒弃了在 G1 中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨 Region 的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降 低了伪共享问题(见 3.4.4 节)的发生概率。
初始标记
并发标记
最终标记
并发清理
并发回收
初始化引用更新
并发引用更新
最终引用更新
并发清理
管通过对象头上的转发指针(Brooks Pointer)来保证并发时原对象与复制对象的访问一致性
计划在 JDK 13 中将 Shenandoah 的内存屏障模型改 进为基于引用访问屏障(Load Reference Barrier)[10]的实现,所谓“引用访问屏障”是指内存屏障只拦 截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够 省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗
ZGC 收集器
ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器
ZGC 直接把标记信息记在引用对象的指针上,由于这些标志位进一步压缩了原本就只有 46 位的地址空间,也直接导致 ZGC 能够管理的内存不可以超过 4TB,不能支持 32 位平台,不能支持压缩指针(-XX: +UseCompressedOops)等
并发标记
并发预备重分配
并发重分配
并发重映射
如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那 ZGC 很值得尝试
如果你对还处于实验状态的收集器的稳定性有所顾虑,或者应用必须运行在 Win-dows 操作系统 下,那 ZGC 就无缘了,试试 Shenandoah 吧。
如果你接手的是遗留系统,软硬件基础设施和 JDK 版本都比较落后,那就根据内存规模衡量一 下,对于大概 4GB 到 6GB 以下的堆内存,CMS 一般能处理得比较好,而对于更大的堆内存,可重点考 察一下 G1
java -Xlog:gc* xxx
UseXxxGC
对象优先在 Eden 分配
大对象直接进入老年代
长期存活的对象将进入老年代
动态对象年龄判定
空间分配担保
在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看- XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC
jps: 虚拟机进程状况工具
可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一 ID
jstat: 虚拟机统计信息监视工具
是用于监视虚拟机各种运行状态信息的命令行工具
jinfo: java 配置信息工具
是实时查看和调整虚拟机各项参数
jmap: java 内存映像工具
生成堆转储快照的-dump 选项和用于查看每个类的实例、空间占用统计
jstack: java 堆栈跟踪工具
用于生成虚拟机当前时刻的线程快照,通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因
JHSDB(免费): 基于服务性代理的调试工具
JConsole(免费): java 监控与管理控制台
VisualVM(免费): 功能最强大的运行监视和故障处理程序之一,它可以直接应用在生产环境中生成
浏览堆转储快照
分析程序性能
BTrace 插件动态日志跟踪
JMC(付费)
启动飞行记录时,可以进行记录时间、垃圾收集器、编译器、方法采样、线程记录、异常记 录、网络和文件 I/O、事件记录等选项和频率设定
大内存硬件上的程序部署策略
单体应用在较大内存的硬件上主要部署方式有两种
通过一个单独的 java 虚拟机实力来管理大量的 java 堆内存
同时使用若干个 java 虚拟机,建立逻辑集群来利用硬件资源
集群见同步导致的内存溢出
集群的写操作会带来很大的网络同步的开销
堆外内存导致的溢出错误
这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制
直接内存
线程堆栈
socket 缓存区
JNI 代码
虚拟机和垃圾收集器
外部命令导致系统缓慢
执行这个 Shell 脚本是通过 Java 的 Runtime.getRuntime().exec()方法来调用的。 这种调用方式可以达到执行 Shell 脚本的目的,但是它在 Java 虚拟机中是非常消耗资源的操作,而且不仅是处理器消耗,内存负担也很重
服务器虚拟机进程崩溃
使用了异步的方式调用 Web 服务,但由于两边服务速度的完全不对等
不恰当数据结构导致内存占用过大
由 window 虚拟内存导致的长时间停顿
在 Java 的 GUI 程序中要避免这种现象,可以加入参数“- Dsun.awt.keepWorkingSetOnMinimize=true”来解决。这个参数在许多 AWT 的程序上都有应用
由安全点导致长时间停顿
是 HotSpot 虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用 int 类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop)
相对应地,使用 long 或者范围更大的数据类型作为索引值的循环就被称为不可数循环 (Uncounted Loop), 将会被放置安全点
升级 JDK 版本的性能变化及兼容问题
在 eclipse.ini 中明确指定- XX:MaxPermSize=256M 这个参数
编译时间和类加载时间的优化
因此通过参数-Xverify:none 禁止掉字节码验证过程也可作为一项优 化措施
调整内存设置控制垃圾收集频率
把-Xms 和-XX: PermSize 参数值设置为-Xmx 和-XX:MaxPermSize 参数值一样,这样就强制虚拟机在启动的时候就把老年代和永久代的容量固定下来,避免运行时自动扩展,参数-XX:+DisableExplicitGC 屏蔽掉 System.gc()
可以通过以下几个参数要求虚拟机生成 GC 日志:
-XX:+PrintGCTimeStamps(打印 GC 停顿时 间)
-XX:+PrintGCDetails(打印 GC 详细信息)
-verbose:gc(打印 GC 信息,输出内容已被前一 个参数包括,可以不写)
-Xloggc:gc.log
选择收集器降低延迟
在 eclipse.ini 中再加入这两个参数,-XX:+UseConc-MarkSweepGC 和-XX:+UseParNewGC(ParNew 是 使用 CMS 收集器后的默认新生代收集器,写上仅是为了配置更加清晰),要求虚拟机在新生代和老年代分别使用 ParNew 和 CMS 收集器进行垃圾回收
修改收集器配置后的 Eclipse 配置
-vm D:/_DevSpace/jdk1.6.0_21/bin/javaw.exe -startup plugins/org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar --launcher.library plugins/org.eclipse.equinox.launcher.win32.win32.x86_1.0.200.v20090519 -product org.eclipse.epp.package.jee.product -showsplash org.eclipse.platform -vmargs -Dcom.sun.management.jmxremote -Dosgi.requiredJavaVersion=1.5 -Xverify:none -Xmx512m
-Xms512m -Xmn128m -XX:PermSize=96m -XX:MaxPermSize=96m -XX:+DisableExplicitGC -Xnoclassgc -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=85
Class 类文件的结构
Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表
无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个 字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名 都习惯性地以"_info"结尾
每个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件
Class 文件的魔数取得很有“浪漫气息”, 值为 0xCAFEBABE
它是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 Class 文件空间最大的数据项目之一,还是在 Class 文件中第一个出现的表类型数据项目
Class 文件结构中只有 常量池的容量计数是从 1 开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从 0 开始
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
符号引用则属于编译原理方面的概念,主要包括下面几类常量
被模块导出或者开放的包
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
方法句柄和方法类型
动态调用点和动态常量
截至 JDK 13,常量表中分别有 17 种不同类型的常量
CONSTANT_Utf8_info UTF-8 编码的字符串
CONSTANT_Integer_info 整型字面量
CONSTANT_Float_info 浮点型字面量
CONSTANT_Long_info 长整型字面量
CONSTANT_Double_info 双精度浮点型字面量
CONSTANT_Class_info 类或接口的符号引用
CONSTANT_String_info 字符串类型字面量
CONSTANT_Fieldref_info 字段的符号引用
CONSTANT_Methodref_info 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 接口中方法的符号引用
CONSTANT_NameAndType_info 字段或方法的部分符号引用
CONSTANT_MethodHandle_info 表示方法句柄
CONSTANT_MethodType_info 表示方法类型
CONSTANT_Dynamic_info 表示一个动态计算常量
CONSTANT_InvokeDynamic_info 表示一个动态方法调用点
CONSTANT_Moudle_info 表示一个模块
CONSTANT_Package_info 表示一个模块中开放或者导出的包
由于 Class 文件中方法、字段等都需要引用 CONSTANT_Utf8_info 型常量来描述名 称,所以 CONSTANT_Utf8_info 型常量的最大长度也就是 Java 中方法、字段名的最大长度
而这里的最大长度就是 length 的最大值,既 u2 类型能表达的最大值 65535。所以 Java 程序中如果定义了超过 64KB 英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译
在 JDK 的 bin 目录中专门用于分析 Class 文件字节码的工具:javap (javap -verbose Xxx.class)
访问标志
这个标志用于识别一些类或 者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final
ACC_PUBLIC 0x0001 是否为 public 类型
ACC_FINAL 0x0010 是否被声明为 final,只有类可设置
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语义(必须是真)
ACC_INTERFACE 0x0200 标识这是一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型,对于接口或者抽象类来说,此标志为真.其他类型为假
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生的
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举
ACC_MODULE 0x8000 标识这是一个模块
类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合 (interfaces)是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定该类型的继承关系
以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0
接口索引集合就用来描述这个类实现了哪些接口,接口顺序从左到右排列在接口索引集合中
字段表(field_info)用于描述接口或者类中声明的变量。Java 语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量
字段可以包括的修饰符有字段的作用域(public、private、protected 修饰 符)、是实例变量还是类变量(static 修饰符)、可变性(final)、并发可见性(volatile 修饰符,是否强制从主内存读写)、可否被序列化(transient 修饰符)、字段数据类型(基本类型、对象、数组)、 字段名称
各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述
access_flags
接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL 标志
name_index 和 descriptor_index
它们都是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符
简单名称则就是指没有类型和参数修饰 的方法或者字段名称
描述符的作用是用来描述字段 的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值
根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示,而对象类型则用字符 L 加对象的全限定名来表示
对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型 的二维数组将被记录成“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成“[I”
用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。
如方法 void inc()的描述符为“()V”,方法 java.lang.String toString()的描述符 为“()Ljava/lang/String;”,
方法 int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target, int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”
值为 0x0002,代表 private 修饰符的 ACC_PRIVATE 标志位为真
常量表中可查得第五项常量是一个 CONSTANT_Utf8_info 类型的字符 串,其值为“m”
代表字段描述符的 descriptor_index 的值为 0x0006,指向常量池的字符串“I”
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本 Java 代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段
方法表的结构如同字段表一样,依 次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)
仅在访问标 志和属性表集合的可选项中有所区别
因为 volatile 关键字和 transient 关键字不能修饰方法,所以方法表的访问标志中没有了 ACC_VOLATILE 标志和 ACC_TRANSIENT 标志。
与之相对,synchronized、native、strictfp 和 abstract 关键字可以修饰方法,方法表的访问标志中也相应地增加了 ACC_SYNCHRONIZED、 ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 标志
ACC_PUBLIC 0x0001 是否为 public
ACC_PRIVATE 0x0002 方法是否为 private
ACC_PROTECTED 0x0004 方法是否为 protected
ACC_STATIC 0x0008 方法是否为 static
ACC_FINAL 0x0010 方法是否为 final
ACC_SYNCHRONIZED 0x0020 方法是否为 synchronized
ACC_BRIDGE 0x0040 方法是不是由编译器产生的桥接方法
ACC_VARARGS 0x0080 方法是否接受不定参数
ACC_NATIVE 0x0100 方法是否为 native
ACC_ABSTRACT 0x0400 方法是否为 abstract
ACC_STRICT 0x0800 方法是否为 strictfp
ACC_SYNTHETIC 0x1000 方法是否由编译器自动产生
方法里的 Java 代码,经过 Javac 编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面
要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求 必须拥有一个与原方法不同的特征签名
特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以 Java 语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。
但是在 Class 文件格式之中,特征签名的范围明显要更大一些, 只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个 Class 文件中的
属性表集合的限制稍微宽松一 些,不再要求各个属性表具有严格顺序
《Java 虚拟机规范》最初只预定义了 9 项所有 Java 虚拟机实现都应 当能识别的属性,而在最新的《Java 虚拟机规范》的 Java SE 12 版本中,预定义属性已经增加到 29 项
Code
Java 程序方法体里面的代码经过 Javac 编译器处理之后,最终变为字节码指令存储在 Code 属性内,但并非所有的方法表都必须存在这个属性,譬如接口或者抽 象类中的方法就不存在 Code 属性
max_stack 代表了操作数栈(Operand Stack)深度的最大值
max_locals 代表了局部变量表所需的存储空间,在这里,max_locals 的单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位
code_length 和 code 用来存储 Java 源程序编译后生成的字节码指令,《Java 虚拟机规范》中明确限制了一个方法不允许超过 65535 条字节码指令,即它实际只使用了 u2 的长度
在任何实例方法里面,都可以通过“this”关键字访问到此方法所属的对象
ConstantValue
ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值
只有被 static 关键字修饰的变量才可以使用这项属性
目前 Oracle 公司实现的 Javac 编译器的选择是,如 果同时使用 final 和 static 来修饰一个变量(按照习惯,这里称“常量”更贴切),并且这个变量的数据类 型是基本类型或者 java.lang.String 的话,就将会生成 ConstantValue 属性来进行初始化;如果这个变量没 有被 final 修饰,或者并非基本类型及字符串,则将会选择在
ConstantValue 属性是一个定长属性,它的 attribute_length 数据项值必须固定 为 2。constantvalue_index 数据项代表了常量池中一个字面量常量的引用,根据字段类型的不同,字面量可以是 CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、 CONSTANT_Integer_info 和 CONSTANT_String_info 常量中的一种
Deprecated
Deprecated 属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念
Deprecated 属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通 过代码中使用“@deprecated”注解进行设置
Exceptions
Exceptions 属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在 throws 关键字后面列举的异常
EnclosingMethod
InnerClasses
InnerClasses 属性用于记录内部类与宿主类之间的关联
数据项 number_of_classes 代表需要记录多少个内部类信息
inner_class_info_index 和 outer_class_info_index 都是指向常量池中 CONSTANT_Class_info 型常量的索引,分别代表了内部类和宿主类的符号引用
inner_name_index 是指向常量池中 CONSTANT_Utf8_info 型常量的索引,代表这个内部类的名称, 如果是匿名内部类,这项值为 0
inner_class_access_flags 是内部类的访问标志
LineNumberTable
LineNumberTable 属性用于描述 Java 源码行号与字节码行号(字节码的偏移量)之间的对应关系
LocalVariableTable
LocalVariableTable 属性用于描述栈帧中局部变量表的变量与 Java 源码中定义的变量之间的关系
start_pc 和 length 属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围覆盖 的长度,两者结合起来就是这个局部变量在字节码之中的作用域范围
LocalVariableTypeTable
使用字段的特征签名来完 成泛型的描述
StackMapTable
这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用(详见第 7 章字节码验证部分),目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器
StackMapTable 属性中包含零至多个栈映射帧(Stack Map Frame),每个栈映射帧都显式或隐式地代表了一个字节码偏移量,用于表示执行到该字节码时局部变量表和操作数栈的验证类型
如 果方法的 Code 属性中没有附带 StackMapTable 属性,那就意味着它带有一个隐式的 StackMap 属性,这个 StackMap 属性的作用等同于 number_of_entries 值为 0 的 StackMapTable 属性。一个方法的 Code 属性最 多只能有一个 StackMapTable 属性,否则将抛出 ClassFormatError 异常
Signature
任何类、接口、初 始化方法或成员的泛型签名如果包含了类型变量(Type Variable)或参数化类型(Parameterized Type),则 Signature 属性会为它记录泛型签名信息
其中 signature_index 项的值必须是一个对常量池的有效索引
如果当前的 Signature 属性是类文件的属性,则这个结构表示类签名,如果当前的 Signature 属性是方法表的属性,则这个结构表示方法类型签名,如果当前 Signature 属性是字段表的属性,则这个结构表示字段类型签名
SourceFile
SourceFile 属性用于记录生成这个 Class 文件的源码文件名称
SourceDebugExtension
SourceDebugExtension 属性用于存储额外的代码调试信息
Synthetic
Synthetic 属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念
Synthetic 属性代表此字段或者方法并不是由 Java 源码直接产生的,而是由编译器自行添加的
所有由不属于用户代码产生的类、方法及字段都应当至少设置 Synthetic 属性或者 ACC_SYNTHETIC 标志位中的一项,唯一的例外是实例构造器
LocalVaribleTypeTable
运行时注解相关属性
RuntimeVisibleAnnotations
RuntimeInvisibleAnnotations
RuntimeVisibleParameterAnnotations
RuntimeInvisibleParameterAnnotations
AnnotationDefalut
BootstrapMethods
这个属性用于保存 invokedynamic 指令引用的引导方法限定符
num_bootstrap_methods 项的值给出了 bootstrap_methods[]数组中的引导 方法限定符的数量。而 bootstrap_methods[]数组的每个成员包含了一个指向常量池 CONSTANT_MethodHandle 结构的索引值,它代表了一个引导方法
bootstrap_method_ref:bootstrap_method_ref 项的值必须是一个对常量池的有效索引。常量池在该索引处的值必须是一个 CONSTANT_MethodHandle_info 结构。
num_bootstrap_arguments:num_bootstrap_arguments 项的值给出了 bootstrap_argu-ments[]数组成员的数量。
bootstrap_arguments[]:bootstrap_arguments[]数组的每个成员必须是一个对常量池的有效索引
RuntimeVisibleTypeAnnotations
RuntimeInvisibleTypeAnnotations
MethodParameters
MethodParameters 的作用是记录方法的各个形参名称和信息
access_flags 是参数的状态指示器,它可以包含以下三种状态中的一种或多种
0x0010(ACC_FINAL):表示该参数被 final 修饰。
0x1000(ACC_SYNTHETIC):表示该参数并未出现在源文件中,是编译器自动生成的。
0x8000(ACC_MANDATED):表示该参数是在源文件中隐式定义的。Java 语言中的典型场景是 this 关键字
模块化相关属性
Class 文件格式也扩展了 Module、ModulePackages 和 ModuleMainClass 三个属性用于支持 Java 模块化相关功能
Modules
除了表示该模块的名称、版本、标志信息以外,还存储了这个模块 requires、exports、opens、uses 和 provides 定义的全部内容
0x0020(ACC_OPEN):表示该模块是开放的。
0x1000(ACC_SYNTHETIC):表示该模块并未出现在源文件中,是编译器自动生成的。
0x8000(ACC_MANDATED):表示该模块是在源文件中隐式定义的。
module_version_index 是一个指向常量池 CONSTANT_Utf8_info 常量的索引值,代表了该模块的版本号
exports 属性的每一元素都代表一个被模块所导出的包
exports_flags 是该导出包的状态指 示器,它可以包含以下两种状态中的一种或多种
0x1000(ACC_SYNTHETIC):表示该导出包并未出现在源文件中,是编译器自动生成的。
0x8000(ACC_MANDATED):表示该导出包是在源文件中隐式定义的
ModuleMainClass
用于确定该模块的主类
ModulePackages
ModulePackages 是另一个用于支持 Java 模块化的变长属性,它用于描述该模块中所有的包,不论是不是被 export 或者 open 的
package_count 是 package_index 数组的计数器,package_index 中每个元素都是指向常量池 CONSTANT_Package_info 常量的索引值,代表了当前模块中的一个包
NestHost
NestMembers
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode) 以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成
由于限制了 Java 虚拟机操作码的长度为一个字节(即 0 ~ 255),这意味着指令集的操作码总数不能够超过 256 条
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成
iload 指 令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。这两 条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class 文件中它们必须拥有各自独立的操作码
大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i 代表对 int 类型的数据操作,l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference
Java 虚拟机的指令集对于特定的操作 只提供了有限的类型相关指令去支持它
大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的对 int 类型作为运算类型(Computational Type)来进行的
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输
将一个局部变量加载到操作栈:iload、iload_
将一个数值从操作数栈存储到局部变量表:istore、istore_
将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、 iconst_、lconst_
扩充局部变量表的访问索引的指令:wide
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶
无论是哪种算术指令,均是使用 Java 虚拟机的算术类型来进行计算的,换句话说是不存在直接支持 byte、short、char 和 boolean 类型的算术指令,对于上述几种数据的运算,应使用操作 int 类型的指令代替
加法指令:iadd、ladd、fadd、dadd
减法指令:isub、lsub、fsub、dsub
乘法指令:imul、lmul、fmul、dmul
除法指令:idiv、ldiv、fdiv、ddiv
求余指令:irem、lrem、frem、drem
取反指令:ineg、lneg、fneg、dneg
位移指令:ishl、ishr、iushr、lshl、lshr、lushr
按位或指令:ior、lor
按位与指令:iand、land
按位异或指令:ixor、lxor
局部变量自增指令:iinc
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
《Java 虚拟机规范》中并没有明确定义过整型数据溢出具体会得到什么计算结果,仅规定了在处理整型数据时,只有除法指令(idiv 和 ldiv)以及求余指令(irem 和 lrem)中当出现除数为零时会导致虚拟机抛出 ArithmeticException 异常,
其余任何整型数运算场景都不应该抛出运行时异常
Java 虚拟机在进行浮点数运算时,所有的运算结果都必须舍入到适当的精度,非精确的结果必须舍入为可被表示的最接近的精确值;如果有两种可表示的形式与该值一样接近,那将优先选择最低有效位为零的。这种舍入模式也是 IEEE 754 规范中的默认舍入模式,称为向最接近数舍入模式,而在把浮点数转换为整数时,Java 虚拟机使用 IEEE 754 标准中的向零舍入模式,这种模式的舍入结果会导致数字被截断,所有小数部分的有 效字节都会被丢弃掉。向零舍入模式将在目标数值类型中选择一个最接近,但是不大于原值的数字来作为最精确的舍入结果
当一个操作产生溢出时,将会使用有符号的无穷大来表示;如果某个操作结果没有明确的数学定义的话,将会使用 NaN(Not a Number)值来表示。所有使用 NaN 值作为操作数的算术操作,结果都会返回 NaN
Java 虚拟机直接支持以下数值类型的宽化类型转换
int 类型到 long、float 或者 double 类型
long 类型到 float、double 类型
float 类型到 double 类型
处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指令来完成,这些转换指令包括 i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。
窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失
Java 虚拟机将一个浮点值窄化转换为整数类型 T(T 限于 int 或 long 类型之一)的时候,必须遵循以下转换规则
如果浮点值是 NaN,那转换结果就是 int 或 long 类型的 0。
如果浮点值不是无穷大的话,浮点值使用 IEEE 754 的向零舍入模式取整,获得整数值 v。如果 v 在目标类型 T(int 或 long)的表示范围之类,那转换结果就是 v;否则,将根据 v 的符号,转换为 T 所能表示的最大或者最小正数
从 double 类型到 float 类型做窄化转换的过程与 IEEE 754 中定义的一致,通过 IEEE 754 向最接近数舍入模式舍入得到一个可以使用 float 类型表示的数字。
如果转换结果的绝对值太小、无法使用 float 来表 示的话,将返回 float 类型的正负零;
如果转换结果的绝对值太大、无法使用 float 来表示的话,将返回 float 类型的正负无穷大。
对于 double 类型的 NaN 值将按规定转换为 float 类型的 NaN 值
对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素
创建类实例的指令:new
创建数组的指令:newarray、anewarray、multianewarray
访问类字段(static 字段,或者称为类变量)和实例字段(非 static 字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、 daload、aaload
将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、 dastore、aastore
取数组长度的指令:arraylength
检查类实例类型的指令:instanceof、checkcast
将操作数栈的栈顶一个或两个元素出栈:pop、pop2
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、 dup2_x1、dup_x2、dup2_x2
将栈最顶端的两个数值互换:swap
条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、 if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne
复合条件分支:tableswitch、lookupswitch
无条件分支:goto、goto_w、jsr、jsr_w、ret
对于 boolean 类型、byte 类型、char 类型和 short 类型的条件分支比较操作,都使用 int 类型的比较指令来完成,
对于 long 类型、float 类型和 double 类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp,见 6.4.3 节),运算指令会返回一个整型值到操作数栈中,随后再执行 int 类型的条件分支比较操作来完成整个分支跳转
invokevirtual 指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
invokeinterface 指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial 指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic 指令:用于调用类静态方法(static 方法)
invokedynamic 指令:用于在运行时动态解析出调用点限定符所引用的方法,invokedynamic 指令的分派逻辑 是由用户所设定的引导方法决定的
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,
另外还有一条 return 指令供声明为 void 的方法、实例初始化方法、类和接口的类初始化方法使用
Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现
Java 虚拟机中,处理异常(catch 语句)不是由字节码指令来实现的(很久之前曾经使用 jsr 和 ret 指令来实现,现在已经不用了),而是采用异常表来完成
Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管 程(Monitor,更常见的是直接将它称为“锁”)来实现的
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中
代码清单 6-6 代码同步演示
void onlyMe(Foo f) { synchronized(f) { doSomething(); } } 编译后,这段代码生成的字节码序列如下:
Method void onlyMe(Foo)
0 aload_1 // 将对象 f 入栈
1 dup // 复制栈顶元素(即 f 的引用)
2 astore_2 // 将栈顶元素存储到局部变量表变量槽 2 中
3 monitorenter // 以栈定元素(即 f)作为锁,开始同步
4 aload_0 // 将局部变量槽 0(即 this 指针)的元素入栈
5 invokevirtual #5 // 调用 doSomething()方法
8 aload_2 // 将局部变量 Slow 2 的元素(即 f)入栈
9 monitorexit // 退出同步
10 goto 18 // 方法正常结束,跳转到 18 返回
13 astore_3 // 从这步开始是异常路径,见下面异常表的 Taget 13
14 aload_2 // 将局部变量 Slow 2 的元素(即 f)入栈
15 monitorexit // 退出同步
16 aload_3 // 将局部变量 Slow 3 的元素(即异常对象)入栈
17 athrow // 把异常对象重新抛出给 onlyMe()方法的调用者
18 return // 方法正常返回
Exception table:
FromTo Target Type
4 10 13 any
13 16 13 any
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须有其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束
为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有 的异常,它的目的就是用来执行 monitorexit 指令
选择哪种特性取决于 Java 虚拟机实现的目标和关注点是什么,虚拟机实现的方式主要有以下两种
将输入的 Java 虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集
将输入的 Java 虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译器代码生成技术)
Class 文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是 Java 技术体系实现平台无关、语言无关两项特性的重要支柱
Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制
类型的加载、连接和初始化过程都是在程序运行期间完成的
Java 天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、
准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,
其中验证、准备、解析三个部分统称 为连接(Linking)
《Java 虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之 前开始)
1)遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有:
使用 new 关键字实例化对象的时候。读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
调用一个类型的静态方法的时候。
2)使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解 析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
除此之外,所有引用类型的方式都不会触发初始化,称为被动引用
通过子类引用父类的静态字段,不会导致子类初始化
通过数组定义来引用类,不会触发此类的初始化
常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
NotInitialization 的 Class 文件之中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 文件后就已不存在任何联系了
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化
加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
加载阶段既可以使用 Java 虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的
如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类 型区分开来)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上(这点很重要,在 7.4 节会介绍,一个类型必须与类加 载器一起确定唯一性)。
如果数组的组件类型不是引用类型(例如 int[]数组的组件类型为 int),Java 虚拟机将会把数组 C 标记为与引导类加载器关联。
数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到
类型数据妥善安置在方法区之后,会在 Java 堆内存中实例化一个 java.lang.Class 类的对象, 这个对象将作为程序访问方法区中的类型数据的外部接口
验证
这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
验证字节码是 Java 虚拟 机保护自身的一项必要措施
验证阶段大致上会完成下面四个阶段的检验动作:
文件格式验证
是否以魔数 0xCAFEBABE 开头。
主、次版本号是否在当前 Java 虚拟机接受范围之内。
常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)。
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据。
Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的
元数据验证
是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求
这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方 法重载,例如方法参数都一致,但返回值类型却不同等)
字节码验证
主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的
这阶段就要 对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作 栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中”这样的情况。
保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全 的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个 数据类型,则是危险和不合法的
在 JDK 6 之后的 Javac 编译器和 Java 虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到 Javac 编译器里进行
具体做法是给方法体 Code 属性的属性表中新增加了一项名 为“StackMapTable”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java 虚拟机就不需要根据程序推导这些状态的合法性,只需要检查 StackMapTable 属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间
符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生
符号引用验证可以看作是对类自身以外(常量池中的各种符号 引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源
符号引用中通过字符串描述的全限定名是否能找到对应的类。
在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的可访问性(private、protected、public、
符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如: java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等
准备
准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段
首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中
public static int value = 123; 那变量 value 在准备阶段过后的初始值为 0 而不是 123
public static final int value = 123; 编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123
解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程
符号引用(Symbolic References):
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java 虚拟机规 范》的 Class 文件格式中。
直接引用(Direct References):
直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在
《Java 虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行
ane-warray、 checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、 invokestatic、invokevirtual、
ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析
对方法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、 private、
Java 虚拟机都需要保证的是在同一个实体中,除 invokedynamic 指令以外,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪 怕这个请求的符号在后来已成功加载进 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 种常量类型
类或接口的解析
1)如果 C 不是一个数组类型,那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
2)如果 C 是一个数组类型,并且数组的元素类型为对象,也就是 N 的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。如果 N 的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
3)如果上面两步没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或接口了, 但在解析完成前还要进行符号引用验证,确认 D 是否具备对 C 的访问权限。如果发现不具备访问权限, 将抛出 java.lang.IllegalAccessError 异常
在 JDK 9 引入了模块化以后,一个 public 类型也不再意味着程序任 何位置都有它的访问权限,我们还必须检查模块间的访问权限
被访问类 C 是 public 的,并且与访问类 D 处于同一个模块。
被访问类 C 是 public 的,不与访问类 D 处于同一个模块,但是被访问类 C 的模块允许被访问类 D 的模块进行访问。
被访问类 C 不是 public 的,但是它与访问类 D 处于同一个包中
要解析一个未被解析过的字段符号引用,首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用
如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那把这个字段所属的类或接口用 C 表示,《Java 虚拟机规范》要求按照如下步骤对 C 进行后续字段的搜索
1)如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2)否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3)否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4)否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。 如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出 java.lang.IllegalAccessError 异常
方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的 class_index 项中索引的方 法所属的类或接口的符号引用,如果解析成功,那么我们依然用 C 表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索
1)由于 Class 文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现 class_index 中索引的 C 是个接口的话,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
2)如果通过了第一步,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则 返回这个方法的直接引用,查找结束。
3)否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标 相匹配的方法,如果存在匹配的方法,说明类 C 是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError 异常。
5)否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此 方法的访问权限,将抛出 java.lang.IllegalAccessError 异常
除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序
而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源
public class Test { static { i = 0; // 给变量复制可以正常编译通过 System.out.print(i); // 这句编译器会提示“非法向前引用” }static int i = 1; }
Java 虚拟机会保证在子类的
由于父类的
如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成
但接口与类不同的是,执行接口的
如果多个线程同 时去初始化一个类,那么只会有其中一个线程去执行这个类的
同一个类加载器下,一个类型只会被初始化一次
通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义
站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:
一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++语言实现,是虚拟机自身的一部分;
另外一种就是其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader
自 JDK 1.2 以来,Java 一直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在 Java 模块化系统出现后有了一些调整变动,但依然未改变其主体结构
启动类加载器(Bootstrap Class Loader):
这个类加载器负责加载存放在
扩展类加载器(Extension Class Loader):
这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载
在 JDK 9 之后,这种扩展机制被模块化带来的天然的扩展能力所取代,开发者可以直接在程序中使用扩展类加载器来加载 Class 文件
应用程序类加载器(Application Class Loader):
它负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载
在 JDK 1.2 之后的 java.lang.ClassLoader 中添加一个新的 protected 方法 findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码
线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContext-ClassLoader()方 法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器
在 JDK 6 时,JDK 提供了 java.util.ServiceLoader 类,以 META-INF/services 中的配置信息,辅以责任链模式,这才算是给 SPI 的加 载提供了一种相对合理的解决方案
在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索
1)将以 java.*开头的类,委派给父类加载器加载。
2)否则,将委派列表名单内的类,委派给父类加载器加载。
3)否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。
4)否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。
5)否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载。
6)否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。
7)否则,类查找失败
在 JDK 9 中引入的 Java 模块化系统(Java Platform Module System,JPMS)是对 Java 技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,
Java 虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作
Java 的模块定义还包含以下内容
依赖其他模块的列表。
导出的包列表,即其他模块可以使用的列表。
开放的包列表,即其他模块可反射访问模块的列表。
使用的服务列表。
提供服务的实现列表
在 JDK 9 以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样 Java 虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,
如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常
JDK 9 中 的 public 类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些 public 的类型可以被其他哪一些模块访问,这种访问控制也主要是在类加载过程中完成的
JDK 9 提出了与“类路径”(ClassPath)相对应的“模块路径”(ModulePath)的概念,就是某个类库到底是模块还是传统的 JAR 包,只取决于它存放在哪种路径上
JAR 文件在类路径的访问规则:所有类路径下的 JAR 文件及其他资源文件,都被视为自动打包在一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK 系统模块中所有的导出包,以及模块路径上所有模块中导出的包。
模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统 JAR 包的内容。
JAR 文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的 JAR 文件放置到模块路径中,它就会变成一个自动模块(Automatic Module)。尽管不包含 module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包
扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代
平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader
现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader
BootClassLoader 启动类加载器现在是在 Java 虚拟机内部和 Java 类库共同协作实现的类加载器,尽管有了 BootClassLoader 这样的 Java 类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如 Object.class.getClassLoader())中仍然会返回 null 来代替,而不会得到 BootClassLoader 的实例
启动类加载器负责加载的模块:
java.base java.security.sasl java.datatransfer java.xml java.desktop jdk.httpserver java.instrument jdk.internal.vm.ci java.logging jdk.management java.management jdk.management.agent java.management.rmi jdk.naming.rmi java.naming jdk.net java.prefs jdk.sctp java.rmi jdk.unsupported
平台类加载器负责加载的模块: java.activation* jdk.accessibility java.compiler* jdk.charsets java.corba* jdk.crypto.cryptoki java.scripting jdk.crypto.ec java.se jdk.dynalink java.se.ee jdk.incubator.httpclient java.security.jgss jdk.internal.vm.compiler* java.smartcardio jdk.jsobject java.sql jdk.localedata java.sql.rowset jdk.naming.dns java.transaction* jdk.scripting.nashorn java.xml.bind* jdk.security.auth java.xml.crypto jdk.security.jgss java.xml.ws* jdk.xml.dom java.xml.ws.annotation* jdk.zipfs
应用程序类加载器负责加载的模块: jdk.aot jdk.jdeps jdk.attach jdk.jdi jdk.compiler jdk.jdwp.agent jdk.editpad jdk.jlink jdk.hotspot.agent jdk.jshell jdk.internal.ed jdk.jstatd jdk.internal.jvmstat jdk.pack jdk.internal.le jdk.policytool jdk.internal.opt jdk.rmic jdk.jartool jdk.scripting.nashorn.shell jdk.javadoc jdk.xml.bind* jdk.jcmd jdk.xml.ws* jdk.jconsole
所有的 Java 虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息
在编译 Java 程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的 Code 属性之中
对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)
局部变量表
是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量
局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽都应该能存放一个 boolean、 byte、char、short、int、float、reference 或 returnAddress 类型的数据,这 8 种数据类型,都可以使用 32 位或更小的物理内存来存储
一个变量槽可以存放一个 32 位以内的数据类型,Java 中占用不超过 32 位存储空间的数据类型有 boolean、byte、char、short、int、 float、reference 和 returnAddress 这 8 种类型
对于 64 位的数据类型,Java 虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间
Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的变量槽数量
当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递
如果执行的是实例方法(没有被 static 修饰的方法),那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽
如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为
如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的
同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到 Code 属性的 max_stacks 数据项之中
Javac 编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数 栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)
道 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接
当一个方法开始执行后,只有两种方式退出这个方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为"异常调用完成",一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态
一般来说,方法正常退出时,主调方法的 PC 计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等
在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程
一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)
这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用
所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,这类方法的调用被称为解析(Resolution)
“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通 过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析
调用不同类型的方法,字节码指令集里设计了不同的指令
invokestatic。用于调用静态方法。
invokespecial。用于调用实例构造器
invokevirtual。用于调用所有的虚方法。
invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象
invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面 4 条调用指令,分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法 4 种,再加上被 final 修饰的方法(尽管它使用 invokevirtual 指令调用),这 5 种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)
解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成
分派(Dispatch)调用则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。
这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况
静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么
使用哪个重载版本,就完全取决于传入参数的数量和数据类型
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载
Javac 编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯 一”的,往往只能确定一个“相对更合适的”版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是个比较稀罕的事件,产生这种模糊结论的主要原因是字面量天生的模糊性,它不需要定义,所以字面量 就没有显式的静态类型,它的静态类型只能通过语言、语法的规则去理解和推断
char 可以转型成 int, 但是 Character 是绝对不会转型为 Integer 的,它只能安全地转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable
如果同时出现两个参数分别为 Serializable 和 Comparable
不应该在实际应用中写这种晦涩的重载代码
动态分派与 Java 语言多态性的另外一个重要体现——重写(Override)
invokevirtual 指令的运行时解析过程大致分为以下几步
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C。
2)如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回 java.lang.IllegalAccessError 异常。
3)否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。
正是因为 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是 Java 语言中方法重写的本质
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派
既然这种多态性的根源在于虚方法调用指令 invokevirtual 的执行逻辑,那自然我们得出的结论就只会对方法有效
字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
这是因为 Son 类在创建的时候,首先隐式调用了 Father 的构造函数,而 Father 构造函数中对 showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是 Son::showMeTheMoney()方法,所以输出的是“I am Son”,这点经过前面的分析相信读者是没有疑问的了。而这时候虽然父类的 money 字段已经被初始化成 2 了,但 Son::showMeTheMoney()方法中访问的却 是子类的 money 字段,这时候结果自然还是 0,因为它要到子类的构造函数执行时才会被初始化。 main()的最后一句通过静态类型访问到了父类中的 money,输出了 2
虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java 虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表——Interface Method Table,简称 itable)
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址
为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕
由于 Java 对象里面的方法默认(即不 使用 final 修饰)就是虚方法,虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间
动态类型语言支持
二十余年间只新增过一条指令,它就是随着 JDK 7 的发布的字节码首位新成员——invokedynamic 指令。这条新增加的指令是 JDK 7 的项目目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一, 也是为 JDK 8 里可以顺利实现 Lambda 表达式而做的技术储备
动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的
静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。
而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升
java 与动态类型
JDK 7 以前的字节码指令集中,4 条方法调用指令(invokevirtual、invokespecial、invokestatic、 invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info 或者 CONSTANT_InterfaceMethodref_info 常量)
方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者,动态类型方法调用时,由于无法确定调用对象的静态类型,而导致的方法内联无法有效进行
在 Java 虚拟机层面上提供动态类型的直接支持就成为 Java 平台发展必须解决的问题,这便是 JDK 7 时 JSR-292 提案中 invokedynamic 指令以及 java.lang.invoke 包出现的技术背景
java.lang.invoke 包
这个包的主要目的是在之前 单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
/*** JSR 292 MethodHandle基础用法演示 * @author zzm */
public class MethodHandleTest {
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
// 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。
getPrintlnMH(obj).invokeExact("icyfenix");
}
private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
// MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和 具体参数(methodType()第二个及以后的参数)。
MethodType mt = MethodType.methodType(void.class, String.class); // lookup()方法来自于MethodHandles
// .lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
// 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo() 方法来完成这件事情。
return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}
}
Reflection 和 MethodHandle 机制本质上都是在模拟方法调用,但是 Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用
Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象所包含的信息来得多
MethodHandle 仅包含执行该方法的相关信息
MethodHandle 则设计为可服务于所有 Java 虚拟机之上的语言
invokedynamic 指令
某种意义上可以说 invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有 4 条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。
每一处含有 invokedynamic 指令的位置都被称作“动态调用点(Dynamically-Computed Call Site)”
这条指令的第一个参数不再是代表方法符号引用的 CONSTANT_Methodref_info 常量,而是变为 JDK 7 时新加入的 CONSTANT_InvokeDynamic_info 常量,从这个新常量中可以得到 3 项信息:引导方法 (Bootstrap Method,该方法存放在新增的 BootstrapMethods 属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值规定是 java.lang.invoke.CallSite 对象,这个对象代表了真正要执行的目标方法调用
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class InvokeDynamicTest {
public static void main(String[] args) throws Throwable {
INDY_BootstrapMethod().invokeExact("icyfenix");
}
public static void testMethod(String s) {
System.out.println("hello String:" + s);
}
public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
}
private static MethodType MT_BootstrapMethod() {
return MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String; " +
"Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", null);
}
private static MethodHandle MH_BootstrapMethod() throws Throwable {
return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
}
private static MethodHandle INDY_BootstrapMethod() throws Throwable {
CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(), "testMethod", MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
return cs.dynamicInvoker();
}
}
invokedynamic 指令与此前 4 条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
class Test {
class GrandFather {
void thinking() {
System.out.println("i am grandfather");
}
}
class Father extends GrandFather {
void thinking() {
System.out.println("i am father");
}
}
class Son extends Father {
void thinking() {
// try {
// MethodType mt = MethodType.methodType(void.class);
// MethodHandle mh = lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
// mh.invoke(this);
// } catch (Throwable e) {
// }
try {
MethodType mt = MethodType.methodType(void.class);
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
mh.invoke(this);
} catch (Throwable e) {
}
}
}
public static void main(String[] args) {
(new Test().new Son()).thinking();
}
}
只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较合理确切
Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法 树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现
基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束
栈架构的指令集还有一些其他的优点, 如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等
栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是 寄存器架构也从侧面印证了这点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了
的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际的运作过程并不会完全符合概念模型的描述。
更确切地说,实际情况会和上面描述的概念 模型差距非常大,差距产生的根本原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,即使解释器中也不是按照字节码指令去逐条执行的。例如在 HotSpot 虚拟机中,就有很多 以“fast_”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,即时编译器的优化手段则更是花样繁多
在 Class 文件格式与执行引擎这部分里,用户的程序能直接参与的内容并不太多,Class 文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能
一个功能健全的 Web 服务器,都要解决如下的这些问题
部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求每个类库在一个服务器中只能有一份,服务器应当能够保证两个独立应用程序的类库可以互相独立使用。
部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以互相共享。这个需求与前面一点正好相反,但是也很常见,例如用户可能有 10 个使用 Spring 组织的应用程序部署在同一台服务器上,如果把 10 份 Spring 分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
服务器需要尽可能地保证自身的安全不受部署的 Web 应用程序影响。目前,有许多主流的 Java Web 服务器自身也是使用 Java 语言来实现的。因此服务器本身也有类库依赖的问题,一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
支持 JSP 应用的 Web 服务器,十有八九都需要支持 HotSwap 功能。我们知道 JSP 文件最终要被编译 成 Java 的 Class 文件才能被虚拟机执行,但 JSP 文件由于其纯文本存储的特性,被运行时修改的概率远大 于第三方类库或程序自己的 Class 文件。而且 ASP、PHP 和 JSP 这些网页应用也把修改后无须重启作为一个很大的“优势”来看待,因此“主流”的 Web 服务器都会支持 JSP 生成类的热替换,当然也有“非主 流”的,如运行在生产模式(Production Mode)下的 WebLogic 服务器默认就不会处理 JSP 文件的变化
Tomcat 具体是如何规划用户类库结构和类加载器的
放置在/common 目录中。类库可被 Tomcat 和所有的 Web 应用程序共同使用。
放置在/server 目录中。类库可被 Tomcat 使用,对所有的 Web 应用程序都不可见。
放置在/shared 目录中。类库可被所有的 Web 应用程序共同使用,但对 Tomcat 自己不可见。
放置在/WebApp/WEB-INF 目录中。类库仅仅可以被该 Web 应用程序使用,对 Tomcat 和其他 Web 应用程序都不可见。
而 Common 类加载器、Catalina 类加载器(也称为 Server 类 加载器)、Shared 类加载器和 Webapp 类加载器则是 Tomcat 自己定义的类加载器,它们分别加载/common/、/server/、/shared/*和/WebApp/WEB-INF/*中的 Java 类库。其中 WebApp 类加载器和 JSP 类加载器通常还会存在多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 JasperLoader 类加载器
Common 类加载器能加载的类都可以被 Catalina 类加载器和 Shared 类加载器使用,而 Catalina 类加载器和 Shared 类加载器自己能加载的类则与对方相互隔离。WebApp 类加载器可以使用 Shared 类加载器加载到的类,但各个 WebApp 类加载器实例之间相互隔离。而 JasperLoader 的加载范围仅仅是这个 JSP 文件所编译出来的那一个 Class 文件,它存在的目的就是为了被丢弃:当服务器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过再建立一个新的 JSP 类加载器来实现 JSP 文件的 HotSwap 功能
在 Tomcat 6 及之后的版本简化了默 认的目录结构,只有指定了 tomcat/conf/catalina.properties 配置文件的 server.loader 和 share.loader 项后才会真正建立 Catalina 类加载器和 Shared 类加载器的实例,否则会用到这两个类加载器的地方都会用 Common 类加载器的实例代替,而默认的配置文件中并没有设置这两个 loader 项,所以 Tomcat 6 之后也顺理成章地把/common、/server 和/shared 这 3 个目录默认合并到一起变成 1 个/lib 目录,这个目录里的类库相当于以前/common 目录中类库的作用
如果默认设置不能满足需要,用户可以通过修改配置文件指定 server.loader 和 share.loader 的方式重新启用原来完整的加载器架构
通常引入 OSGi 的主要理由 是基于 OSGi 架构的程序很可能(只是很可能,并不是一定会,需要考虑热插拔后的内存管理、上下文 状态维护问题等复杂因素)会实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停用、重新安装然后启用程序的其中一部分
OSGi 的 Bundle 类加载器之间只有规则,没有固定的委派关系
不涉及某个具体的 Package 时,各个 Bundle 加载器都是平级的关系,只有具体使用到某个 Package 和 Class 的时候,才会根据 Package 导入导出定义来构造 Bundle 间的委派和依赖
一个 Bundle 类加载器为其他 Bundle 提供服务时,会根据 Export-Package 列表严格控制访问范围。如果一个类存在于 Bundle 的类库中但是没有被 Export,那么这个 Bundle 的类加载器能找到这个类, 但不会提供给其他 Bundle 使用,而且 OSGi 框架也不会把其他 Bundle 的类加载请求分配给这个 Bundle 来处理
类加载时可能进行的查找规则如下
以 java.*开头的类,委派给父类加载器加载。
否则,委派列表名单内的类,委派给父类加载器加载。
否则,Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。
否则,查找当前 Bundle 的 Classpath,使用自己的类加载器加载。
否则,查找是否在自己的 Fragment Bundle 中,如果是则委派给 Fragment Bundle 的类加载器加载。
否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。
否则,类查找失败
在 JDK 7 时才终于出现了 JDK 层面的解决方案,类加载器架构进行了一次专门的升级,在 ClassLoader 中增加了 registerAsParallelCapable 方法对可并行的类加载进行注册声明,把锁的级别从 ClassLoader 对象本身,降低为要加载的类名这个级别
Web 服务器中的 JSP 编译器,编译时织入的 AOP 框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度
动态代理的简单示例
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("hello world");
}
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),
originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}
跟踪这个方法的 源码,可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤 并不是我们关注的重点,这里只分析它最后调用 sun.misc.ProxyGenerator::generateProxyClass()方法来完 成生成字节码的动作,这个方法会在运行时产生一个描述代理类的字节码 byte[]数组。如果想看一看这 个在运行时产生的代理类中写了些什么,可以在 main()方法中加入下面这句
System.getProperties().put(“sun.misc.ProxyGenerator.saveGeneratedFiles”, “true”);
反编译的动态代理类的代码
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
public final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello {
private static Method m3;
private static Method m1;
private static Method m0;
private static Method m2;
public $Proxy0(InvocationHandler paramInvocationHandler) throws {
super(paramInvocationHandler);
}
public final void sayHello() throws {
try {
this.h.invoke(this, m3, null);
return;
} catch (RuntimeException localRuntimeException) {
throw localRuntimeException;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
// 此处由于版面原因,省略equals()、hashCode()、toString()3个方法的代码 // 这3个方法的内容与sayHello()非常相似。
static {
try {
m3 = Class
.forName("org.fenixsoft.bytecode.DynamicProxyTest$IHello").getMethod("sayHello", new Class[0]);
m1 = Class.forName
("java.lang.Object").getMethod("equals", new Class[]{Class.forName("java.lang.Object")});
m0 = Class.forName
("java.lang.Object").getMethod("hashCode", new Class[0]);
m2 = Class.forName("java.lang.Object").getMethod
("toString", new Class[0]);
return;
} catch (NoSuchMethodException localNoSuchMethodException) {
throw new
NoSuchMethodError(localNoSuchMethodException.getMessage());
} catch (ClassNotFoundException localClassNotFoundException) {
throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
}
}
}
产生代理类“$Proxy0.class”的字节码的,大致的生成过程其实就是根据 Class 文件的格式规范去拼装字节码
“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 中使用的形式,同时也对接口默认方法提供了有限度的支持
JDK 的每次升级新增的功能大致可以分为以下五类
1)对 Java 类库 API 的代码增强。譬如 JDK 1.2 时代引入的 java.util.Collections 等一系列集合类,在 JDK 5 时代引入的 java.util.concurrent 并发包、在 JDK 7 时引入的 java.lang.invoke 包,等等。
2)在前端编译器层面做的改进。这种改进被称作语法糖,如自动装箱拆箱,实际上就是 Javac 编 译器在程序中使用到包装对象的地方自动插入了很多 Integer.valueOf()、Float.valueOf()之类的代码;变长参数在编译之后就被自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经被擦 除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。
3)需要在字节码中进行支持的改动。如 JDK 7 里面新加入的语法特性——动态语言支持,就需要在虚拟机中新增一条 invokedynamic 字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对稳定的状态,这种要在字节码层面直接进行的改动是比较少见的。
4)需要在 JDK 整体结构层面进行支持的改进,典型的如 JDK 9 时引入的 Java 模块化系统,它就涉及了 JDK 结构、Java 语法、类加载和连接过程、Java 虚拟机等多个层面。
5)集中在虚拟机内部的改进。如 JDK5 中实现的 JSR-133 规范重新定义的 Java 内存模型(Java Memory Model,JMM),以及在 JDK 7、JDK 11、JDK 12 中新增的 G1、ZGC 和 Shenandoah 收集器之类的改动,这种改动对于程序员编写代码基本是透明的,只会在程序运行时产生影响
逆向移植工具能比较完美地模拟了前两类,从第 3 类开始就逐步深入地涉及了直接在虚拟机内部实现的改进了,这些功能一般要么是逆向移植工具完全无能为力,要么是不能完整地或者在比较良好的运行效率上完成全部模拟
第一类模拟以独立类库的方式便可实现
第二类 JDK 在编译阶段进行处理的那些改进,Retrotranslator 则是使用 ASM 框架直接对字节码进行处理
用 Retrolambda 模拟 JDK 8 的 Lambda 表达式属于涉及字节码改动的第三类情况,Retrolambda 的 Backport 过程实质上就是生成一组匿名内部类来代替 Lambda,里面会做一些优化措施,譬如采用单例来保证无状态的 Lambda 表达式不会重复创建匿名类的对象
不依赖某个 JDK 版本才加入的特性(包括 JVMTI),能在目前还被普遍使用的 JDK 中部署,只要是使用 JDK 1.4 以上的 JDK 都可以运行。
不改变原有服务端程序的部署,不依赖任何第三方类库。
不侵入原有程序,即无须改动原程序的任何代码。也不会对原有程序的运行带来任何影响。
考虑到 BeanShell Script 或 JavaScript 等脚本与 Java 对象交互起来不太方便,“临时代码”应该直接支持 Java 语言。
“临时代码”应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里写的是“不需要”而不是“不可以”,当“临时代码”需要引用其他类库时也没有限制,只要服务端程序能使用的类型和接口,临时代码都应当能直接引用。
“临时代码”的执行结果能返回到客户端,执行结果可以包括程序中输出的信息及抛出的异常等
HotSwapClassLoader
* @author zzm */
public class HotSwapClassLoader extends ClassLoader {
public HotSwapClassLoader() {
super(HotSwapClassLoader.class.getClassLoader());
}
public Class loadByte(byte[] classByte) {
return defineClass(null, classByte, 0, classByte.length);
}
}
ClassModifier
* 修改Class文件,暂时只提供修改常量池常量的功能 * @author zzm
*/
public class ClassModifier {
/*** Class文件中常量池的起始偏移 */
private static final int CONSTANT_POOL_COUNT_INDEX = 8;
/*** CONSTANT_Utf8_info常量的tag标志 */
private static final int CONSTANT_Utf8_info = 1;
/*** 常量池中11种常量所占的长度,CONSTANT_Utf8_info型常量除外,因为它不是定长的 */
private static final int[] CONSTANT_ITEM_LENGTH = {-1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5};
private static final int u1 = 1;
private static final int u2 = 2;
private byte[] classByte;
public ClassModifier(byte[] classByte) {
this.classByte = classByte;
}
/*** 修改常量池中CONSTANT_Utf8_info常量的内容 * @param oldStr 修改前的字符串 * @param newStr 修改后的字符串 * @return 修改结果 */
public byte[] modifyUTF8Constant(String oldStr, String newStr) {
int cpc = getConstantPoolCount();
int offset = CONSTANT_POOL_COUNT_INDEX + u2;
for (int i = 0; i < cpc; i++) {
int tag = ByteUtils.bytes2Int(classByte, offset, u1);
if (tag == CONSTANT_Utf8_info) {
int len = ByteUtils.bytes2Int(classByte, offset + u1, u2);
offset += (u1 + u2);
String str = ByteUtils.bytes2String(classByte, offset, len);
if (str.equalsIgnoreCase(oldStr)) {
byte[] strBytes = ByteUtils.string2Bytes(newStr);
byte[] strLen = ByteUtils.int2Bytes(newStr.length(), u2);
classByte = ByteUtils.bytesReplace(classByte, offset - u2, u2, strLen);
classByte = ByteUtils.bytesReplace(classByte, offset, len, strBytes);
return classByte;
} else {
offset += len;
}
} else {
offset += CONSTANT_ITEM_LENGTH[tag];
}
}
return classByte;
}
/*** 获取常量池中常量的数量 * @return 常量池数量 */
public int getConstantPoolCount() {
return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2);
}
}
ByteUtils
* Bytes数组处理工具 * @author
*/
public class ByteUtils {
public static int bytes2Int(byte[] b, int start, int len) {
int sum = 0;
int end = start + len;
for (int i = start; i < end; i++) {
int n = ((int) b[i]) & 0xff;
n <<= (--len) * 8;
sum = n + sum;
}
return sum;
}
public static byte[] int2Bytes(int value, int len) {
byte[] b = new byte[len];
for (int i = 0; i < len; i++) {
b[len - i - 1] = (byte) ((value >> 8 * i) & 0xff);
}
return b;
}
public static String bytes2String(byte[] b, int start, int len) {
return new String(b, start, len);
}
public static byte[] string2Bytes(String str) {
return str.getBytes();
}
public static byte[] bytesReplace(byte[] originalBytes, int offset, int len, byte[] replaceBytes) {
byte[] newBytes = new byte[originalBytes.length + (replaceBytes.length - len)];
System.arraycopy(originalBytes, 0, newBytes, 0, offset);
System.arraycopy(replaceBytes, 0, newBytes, offset, replaceBytes.length);
System.arraycopy(originalBytes, offset + len, newBytes, offset + replaceBytes.length, originalBytes.length - offset - len);
return newBytes;
}
}
HackSystem
import java.io.InputStream;
import java.io.PrintStream;
/*** 为Javaclass劫持java.lang.System提供支持 * 除了out和err外,其余的都直接转发给System处理 ** @author zzm */
public class HackSystem {
public final static InputStream in = System.in;
private static ByteArrayOutputStream buffer = new ByteArrayOutputStream();
public final static PrintStream out = new PrintStream(buffer);
public final static PrintStream err = out;
public static String getBufferString() {
return buffer.toString();
}
public static void clearBuffer() {
buffer.reset();
}
public static void setSecurityManager(final SecurityManager s) {
System.setSecurityManager(s);
}
public static SecurityManager getSecurityManager() {
return System.getSecurityManager();
}
public static long currentTimeMillis() {
return System.currentTimeMillis();
}
public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length) {
System.arraycopy(src, srcPos, dest, destPos, length);
}
public static int identityHashCode(Object x) {
return System.identityHashCode(x);
}// 下面所有的方法都与java.lang.System的名称一样 // 实现都是字节转调System的对应方法 // 因版面原因,省略了其他方法 }
JavaclassExecuter
/*** Javaclass执行工具 ** @author zzm */
public class JavaclassExecuter {
/*** 执行外部传过来的代表一个Java类的Byte数组
* 将输入类的byte数组中代表java.lang.System的CONSTANT_Utf8_info常量修改为劫持后的HackSystem类 *
* 执行方法为该类的static main(String[] args)方法,输出结果为该类向System.out/err输出的信息 * @param classByte 代表一个Java类的Byte数组 * @return
* 执行结果 */
public static String execute(byte[] classByte) {
HackSystem.clearBuffer();
ClassModifier cm = new ClassModifier(classByte);
byte[] modiBytes = cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem");
HotSwapClassLoader loader = new HotSwapClassLoader();
Class clazz = loader.loadByte(modiBytes);
try {
Method method = clazz.getMethod("main", new Class[]{String[].class});
method.invoke(null, new String[]{null});
} catch (Throwable e) {
e.printStackTrace(HackSystem.out);
}
return HackSystem.getBufferString();
}
}
前端编译器(叫“编译器的前端”更准确一些)把*.java 文件转变成*.class 文件的过程
即时编译器(常称 JIT 编译器,Just In Time Compiler)运行期把字节码转变成本地机器 码的过程
提前编译器(常称 AOT 编译器,Ahead Of Time Compiler)直接把程 序编译成与目标机器指令集相关的二进制代码的过程
前端编译器:JDK 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)
即时编译器:HotSpot 虚拟机的 C1、C2 编译器,Graal 编译器。
提前编译器:JDK 的 Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET
Java 虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是由 Javac 产生的 Class 文件(如 JRuby、Groovy 等语言的 Class 文件)也同样能享受到编译器优化措施所带来的性能红利
Javac 确实是做了许多针对 Java 语言编码过程的优化措施来降低程序员的编码复杂度、提高编码效率
javac 的源码与调试
从 Javac 代码的总体结构来看,编译过程大致可以分为 1 个准备过程和 3 个处理过程
1)准备过程:初始化插入式注解处理器。
2)解析与填充符号表过程,包括:词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。填充符号表。产生符号地址和符号信息。
3)插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一 个插入式注解处理器来影响 Javac 的编译行为。
4)分析与字节码生成过程,包括:
标注检查,对语法的静态信息进行检查。
数据流及控制流分析,对程序动态运行过程进行检查。
解语法糖,将简化代码编写的语法糖还原为原有的形式。
字节码生成,将前面各个步骤所生成的信息转化成字节码
词法,语法分析
词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素,在 Javac 的源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner 类来实现
语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构(SyntaxConstruct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构
填充符号表
符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,可以是有序符号表、树状符号表、栈结构符号表等各种形式
符号表中所登记的信息在编译的不同阶段都要被用到。
譬如在语义分析的过程中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据
注解处理器
可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理
插入式注解处理器的初始化过程是在 initPorcessAnnotations()方法中完成的,而它的执行过程则是在 processAnnotations()方法中完成
语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查
我们编码时经常能在 IDE 中看到由红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果
Javac 在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤,分别由 attribute()和 flow()方法完成
标注检查
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配,在标注检查中,还会顺便进行一个称为常量折叠(Constant Folding)的代码优化
标注检查步骤在 Javac 源码中的实现类是 com.sun.tools.javac.comp.Attr 类和 com.sun.tools.javac.comp.Check 类
数据及控制流分析
数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量 在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题
编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可以看作是一致的,但校验范围会有所区别,有一些校验项只有在编译期或运行期才能进行
在 Javac 的源码中,数据及控制流分析的入口是图 10-5 中的 flow()方法,具体操作由 com.sun.tools.javac.comp.Flow 类来完成
解语法糖
解语法糖的过程由 desugar()方法触发,在 com.sun.tools.javac.comp.TransTypes 类 和 com.sun.tools.javac.comp.Lower 类中完成
字节码生成
字节码生成是 Javac 编译过程的最后一个阶段,在 Javac 源码里面由 com.sun.tools.javac.jvm.Gen 类来完成。
字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作
如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性(public、protected、private 或
Java 虚拟机会自动保证父类构造器的正确执行,但在
除了生成构造器以外,还有其他的一些代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为 StringBuffer 或 StringBuilder(取决于目标代码的版本是否大于或等于 JDK 5)的 append()操作
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交到 com.sun.tools.javac.jvm.ClassWriter 类手上,由这个类的 writeClass()方法输出字节码,生成最终的 Class 文件,
到此,整个编译过程宣告结束
泛型
泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法
Java 语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type,稍后我们会讲解裸类型具体是什么)了,并且在相应的地方插入了强制转型代码
泛型的历史背景
要完全向后兼容无泛型 Java,即保证“二进制向后兼容性”。二进制向后兼容性是明确写入《Java 语言 规范》中的对 Java 使用者的严肃承诺,譬如一个在 JDK 1.2 中编译出来的 Class 文件,必须保证能够在 JDK 12 乃至以后的版本中也能够正常运行
类型擦除
裸类型应被视为所有该类型泛型化实例的共同父类型
说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个 Class 文件中的
引入了诸如 Signature、LocalVariableTypeTable 等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息
擦除法所谓的擦除,仅仅是对方法的 Code 属 性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段 取得参数化类型的根本依据
值类型与未来的泛型
Valhalla 的语言改进项目,希望改进 Java 语言留下的各种缺陷(解决泛型的缺陷就是项目主要目标其中之一)
值类型可以与引用类型一样,具有构造函数、方法或是属性字段,等等,而它与引用类型的区别在于它在赋值的时候通常是整体复制,而不是像引用类型那样传递引用的
更为关键的是,值类型的实例很容易实现分配在方法的调用栈上的,这意味着值类型会随着当前方法的退出而自动释放,不会给垃圾收集子系统带来任何压力
自动装箱,拆箱与遍历循环
建议在实际编码中尽量避免这样使用自动装箱与拆箱
条件编译
为 Java 语言天然的编译方式(编译器并非一个个地编译 Java 文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用到预处理器
Java 语言中条件编译的实现,也是 Java 语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower 类中)完成
插入式注解处理器
我们实现注解处理器的代码需要继承抽象类 javax.annotation.processing.AbstractProcessor,这个抽象类中只有一个子类必须实现的抽象方法:“process()”,它是 Javac 编译器在执行注解处理器代码时要调用的过程
还有一个很重要的实例变量“processingEnv”,它是 AbstractProcessor 中的一个 protected 变量,在注解处理器初始化的时候(init()方法执行的时候)创建,继承了 AbstractProcessor 的注解处理器代码可以直接访问它
@SupportedAnnotationTypes 和@SupportedSourceVersion,前者代表了这个注解处理器对哪些注解感兴趣,可以使用星号“*”作为通配符代表对所有的注解都感兴趣,后者指出这个注解处理器可以处理哪些版本的 Java 代码
当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器
整个 Java 虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作
解释器 -> 即时编译 -> 编译器
编译器 <- 逆优化 <- 编译器
HotSpot 虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为 C1 编译器和 C2 编译器(部分资料和 JDK 源码中 C2 也叫 Opto 编译器),第三个是在 JDK 10 时才出现的、长期目标是代替 C2 的 Graal 编译器
HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式
java -version mixed mode
分层编译在 JDK 7 的服务端模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括
第 0 层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
第 1 层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
第 2 层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
第 3 层。仍然使用客户端编译器执行,开启全部性能监控,除了第 2 层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
第 4 层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化
解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间
热点代码主要有两类
被多次调用的方法
被多次执行的循环体
主流的热点探测判定方式有两种
基于采样的热点探测(Sample Based Hot Spot Code Detection)。
采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。
基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨
J9 用过第一种采样热点探测,而在 HotSpot 虚拟机中使用的是第二种基于计数器的热点探测方法,
为了实现热点计数,HotSpot 为每个方法准备了两类计数器: 方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思 就是指在循环边界往回跳转)
方法调用计数器
这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次,这个阈值可以通过虚拟机参数-XX:CompileThreshold 来人为设定
如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成
的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:- UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。
另外还可以使用-XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒
回边计数器
作用是统计一个方法中回边的次数
可以设置另外一个参数-XX:OnStackReplacePercentage 来间接调整回边计数器的阈值
虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX: CompileThreshold)乘以 OSR 比率(-XX:OnStackReplacePercentage)除以 100。其中-XX: OnStackReplacePercentage 默认值为 933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为 13995。
虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX: CompileThreshold)乘以(OSR 比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX: InterpreterProfilePercentage)的差值)除以 100。其中-XX:OnStack ReplacePercentage 默认值为 140,- XX:InterpreterProfilePercentage 默认值为 33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为 10700
编译过程
无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。
用户可以通过参数-XX:-BackgroundCompilation 来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码
对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段
第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR 使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在 HIR 的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成 HIR 之前完成。
第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等,以便让 HIR 达到更高效的代码表示形式。
最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器代码
服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到 GNU C++编译器使用-O2 参数时的优化强度。它会执行大部分经典的优化动作,如:
无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、
消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,
还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除
机器码至少要反汇编成基本的汇编语言才可能被人类阅读。虚拟机提供了一组通用的反汇编接口,可以接入各种平台下的反汇编适配器,如使用 32 位 x86 平台应选用 hsdis-i386 适配器,64 位则需要选用 hsdis-amd64,其余平台的 适配器还有如 hsdis-sparc、hsdis-sparcv9 和 hsdis-aarch64 等,可以下载或自己编译出与自己机器相符合的反汇编适配器,之后将其放置在 JAVA_HOME/lib/amd64/server 下,只要与 jvm.dll 或 libjvm.so 的路径相同即可被虚拟机调用。为虚拟机安装了反汇编适配器之后,我们就可以使用-XX: +PrintAssembly 参数要求虚拟机打印编译方法的汇编代码了
如果没有 HSDIS 插件支持,也可以使用-XX:+PrintOptoAssembly(用于服务端模式的虚拟机) 或-XX:+PrintLIR(用于客户端模式的虚拟机)来输出比较接近最终结果的中间代码表示
-XX:+PrintAssembly 参数输出反汇编信息需要 FastDebug 或 SlowDebug 优化级别的 HotSpot 虚拟机才能直接支持,如果使用 Product 版的虚拟机,则需要加入参数-XX +UnlockDiagnosticVMOptions 打开虚拟机诊断模式
想再进一步跟踪本地代码生成的具体过程,那可以使用参数- XX:+PrintCFGToFile(用于客户端编译器)或-XX:PrintIdealGraphFile(用于服务端编译器)要求 Java 虚拟机将编译过程中各个阶段的数据(譬如对客户端编译器来说包括字节码、HIR 生成、LIR 生 成、寄存器分配过程、本地代码生成等数据)输出到文件中。然后使用 Java HotSpot Client Compiler Visualizer(用于分析客户端编译器)或 Ideal Graph Visualizer(用于分析服务端编译器)打开这些数据文件进行分析
在运行 Java 程序的 FastDebug 或 SlowDebug 优化级别的虚拟机上的参数中加 入“-XX:PrintIdealGraphLevel=2-XX:PrintIdeal-GraphFile=ideal.xml”,即时编译后将会产生一个名为 ideal.xml 的文件,它包含了服务端编译器编译代码的全过程信息,可以使用 Ideal Graph Visualizer 对这些信息进行分析
每一个方块代表了一个程序的基 本块(Basic Block)。基本块是指程序按照控制流分割出来的最小代码块,它的特点是只有唯一的一个入口和唯一的一个出口,只要基本块中第一条指令被执行了,那么基本块内所有指令都会按照顺序全部执行一次
现在提前编译产品和对其的研究有着两条明显的分支,一条分支是做与传统 C、C++编译器类似 的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在 运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器 其他 Java 进程使用)时直接把它加载进来使用
第一条路径,这是传统的提前编译应用形式,它在 Java 中存在的价值直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源
第二条路径,本质是给即时编译器做缓存加速,去改善 Java 程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dynamic AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)。在目前的 Java 技术体系里,这条路径的提前编译已经完全被主流的商用 JDK 支持
三种即时编译器相对于提前编译器的天然优势
性能分析制导优化
在动态运行时却能看出它们具有非常明显的偏好性。如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它
激进预测性优化
虚拟机会通过类继承关系分析等 一系列激进的猜测去做去虚拟化(Devitalization),以保证绝大部分有内联价值的虚方法都可以顺利内联
链接时优化
Java 语言天生就是动态链接的,一个个 Class 文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码
jaotc 的提前编译
jaotc --output libHelloWorld.so HelloWorld.class
通过以上命令,就生成了一个名为 libHelloWorld.so 的库,我们可以使用 Linux 的 ldd 命令来确认这是 否是一个静态链接库,使用 mn 命令来确认其中是否包含了 HelloWorld 的构造函数和 main()方法的入口信息
ldd libHelloWorld.so
nm libHelloWorld.so
使用这个静态链接库而不是 Class 文件来输出 HelloWorld
java -XX:AOTLibrary=./libHelloWorld.so HelloWorld
目前 Jaotc 只支 持 G1 和 Parallel(PS+PS Old)两种垃圾收集器
jaotc -J-XX:+UseCompressedOops -J-XX:+UseG1GC -J-Xmx4g --compile-for-tiered --info --compile-commands java.base-list.txt --output libjava.base-coop.so --module java.base
还可以使用-XX:+PrintAOT 参数来确认哪些方法使用了提前编译的版本
java -XX:AOTLibrary=java_base/libjava.base-coop.so,./libHelloWorld.so HelloWorld
目前状态的 Jaotc 还有许多需要完善的地方,仍难以直接编译 SpringBoot、MyBatis 这些常见的第三 方工具库,甚至在众多 Java 标准模块中,能比较顺利编译的也只有 java.base 模块而已
HotSpot 虚拟机的即时编译器在生成代码时采用的代码优化技术
编译器策略
延迟编译
分层编译
栈上替换
延迟优化
程序依赖图表示
静态单赋值表示
基于性能监控的优化技术
乐观空值断言
乐观类型断言
乐观类型增强
乐观数组长度增强
剪裁未被选择的分支
乐观的多态内联
分支频率预测
调用频率预测
基于证据的优化技术
精确类型推断
内存值推断
内存值跟踪
常量折叠
重组
操作符退化
空值检查消除
类型检测退化
类型检测消除
代数化简
公共子表达式消除
数据流敏感重写
条件常量传播
基于流承载的类型缩减转换
无用代码消除
语言相关的优化技术
类型继承关系分析
去虚拟机化
符号常量传播
自动装箱消除
逃逸分析
锁消除
语言相关的优化技术
锁膨胀
消除反射
内存及代码位置变换
表达式提升
表达式下沉
冗余存储消除
相邻存储合并
交汇点分离
循环变换
循环展开
循环剥离
安全点消除
迭代范围分离
范围检查消除
循环向量化
全局代码调整
内联
全局代码外提
基于热度的代码布局
switch 调整
控制流图变换
本地代码编排
本地代码封包
延迟槽填充
着色图寄存器分配
线性扫描寄存器分配
复写聚合
常量分裂
复写移除
地址模式匹配
指令窥孔优化
基于确定有限状态机的代码生成
即时编译器对这些代码优化变换是建立在代码的中间表示或者是机器码之上的
方法内联
去除方法调用的成本
行冗余访问消除
复写传播
无用代码消除
最重要的优化技术之一:方法内联
只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法和使用 invokestatic 指令调用的静态方法才会 在编译期进行解析
Java 语言中默认的实例方法是虚方法,对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本
为了解决虚方法的内联问题,Java 虚拟机首先引入了一种名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息
如果遇到虚方法,则会向 CHA 查询此方法在当 前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。不过由于 Java 程序是动态连接的,说不准什么时候就会加载到新的类型从而改变 CHA 结论,因此这种内联属于激进预测性优化,必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。假如在程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译
在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存(Monomorphic Inline Cache)。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行方法分派
最前沿的优化技术之一:逃逸分析
为其他优化措施提供依据的分析技术
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访 问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸
栈上分配
如果确定一个对 象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁
栈上分配可以支持方法逃逸,但不能支持线程逃逸
标量替换
若一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型及 reference 类型等)都不能再进一步分解了,那么这些数据就可以被称为标量
相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java 中的对象就是典型的聚合量
如果把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量 恢复为原始类型来访问,这个过程就称为标量替换
假如逃逸分析能够证明一个对象不会被方法外部 访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替
同步消除
线程同步本身是一个相对耗时的过程,如果逃逸分析 能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉
以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析
一直到 JDK 7 时这逃逸分析才成为服务端编译器默认开启的选项
也可以使用参数-XX:+DoEscapeAnalysis 来手动开启逃逸分析, 开启之后可以通过参数-XX:+PrintEscapeAnalysis 来查看分析结果。有了逃逸分析支持之后,用户可以使用参数-XX:+EliminateAllocations 来开启标量替换,使用+XX:+EliminateLocks 来开启同步消除,使用参数-XX:+PrintEliminateAllocations 查看标量的替换情况
语言无关的经典优化技术之一:公共子表达式消除
如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就称为公共子表达式
可能还会进行代数化简
语言相关的经典优化技术之一:数组边界检查消除
隐式异常处理,Java 中空指针检查和算术运算中除数为零的检查都采用了这种方案
HotSpot 虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案
与语言相关的其他消除操作还有不少,如自动装箱消除(Autobox Elimination)、安全点消除 (Safepoint Elimination)、消除反射(Dereflection)等
从 JDK 10 起,HotSpot 就同时拥有三款不同的即时编译器(经典的客户端编译器/服务端编译器/Graal 编译器)
从 JDK 10 起,Graal 编译器可以替换服务端编译器,成为 HotSpot 分层编译中最顶层的即时编译器
Java 虚拟机编译器接口(Java-Level JVM Compiler Interface,JVMCI)使得 Graal 可以从 HotSpot 的代码中分离出来。主要提供如下三种功能
响应 HotSpot 的编译请求,并将该请求分发给 Java 实现的即时编译器。
允许编译器访问 HotSpot 中与即时编译相关的数据结构,包括类、字段、方法及其性能监控数据等,并提供了一组这些数据结构在 Java 语言层面的抽象表示。
提供 HotSpot 代码缓存(Code Cache)的 Java 端抽象表示,允许编译器部署编译完成的二进制机器码
构建编译调试环境
由于 Graal 编译器要同时支持 Graal VM 下的各种子项目,如 Truffle、Substrate VM、Sulong 等,还要支持作为 HotSpot 和 Maxine 虚拟机的即时编译器,所以只用 Maven 或 Gradle 的话,配置管理过程会相当复杂。为了降低代码管理、依赖项管理、编译和测试等环节的复杂度,Graal 团队专门用 Python 2 写了一个名为 mx 的小工具来自动化做好这些事情。我们要构建 Graal 的调试环境,第一步要先把构建工具 mx 安装好
git clone https://github.com/graalvm/mx.git
export PATH=`pwd`/mx:$PATH
可以选择这个版本的 JDK 8 来进行编译。只关注 Graal 编译器在 HotSpot 上的应用而不想涉及 Graal VM 其他方面时,可直接采用 JDK 9 及之后的标准
export JAVA_HOME=/usr/lib/jvm/oraclejdk1.8.0_212-jvmci-20-b01
git clone https://github.com/graalvm/graal.git
进入 compiler 子目录,使用 mx 构建 Graal 编译器
cd graal/compiler
mx build
需要一个 IDE 来 支持本次实战的。mx 工具能够支持 Eclipse、Intellij IDEA 和 NetBeans 三种主流的 Java IDE 项目的创建(需要把 IDE 配置中使用的 Java 堆修改到 2GB 或以上)
cd graal/compiler
mx eclipseinit // 修改成 idea 的
idea 导入整个 Graal VM (org.graalvm.complier.hotspot)
> HotSpotGraalComplier
javac Demo.java
java -XX:+PrintCompilation -XX:CompileOnly=Demo::workload Demo
-XX:-TieredCompilation 关闭分层编译,让虚拟机只采用有一个 JVMCI 编 译器而不是由客户端编译器和 JVMCI 混合分层。然后使用参数-XX:+EnableJVMCI、-XX: +UseJVMCICompiler 来启用 JVMCI 接口和 JVMCI 编译器。由于这些目前尚属实验阶段的功能,需要再 使用-XX:+UnlockExperimentalVMOptions 参数进行解锁
java8 运行配置
-Djvmci.class.path.append=/graal/compiler/mxbuild/dists/jdk1.8/graal.jar:/graal/sdk/mxbuild/dists/jdk1.8/graal-sdk.jar
-XX:+UnlockExperimentalVMOptions
-XX:+EnableJVMCI
-XX:+UseJVMCICompiler
-XX:-TieredCompilation
-XX:+PrintCompilation
-XX:CompileOnly=Demo::workload
java9 或以上版本的运行配置
–module-path=~/graal/sdk/mxbuild/dists/jdk11/graal.jar
–upgrade-module-path=~graal/compiler/mxbuild/dists/jdk11/jdk.internal.vm.compiler.jar
-XX:+UnlockExperimentalVMOptions
-XX:+EnableJVMCI -XX:+UseJVMCICompiler
-XX:-TieredCompilation
-XX:+PrintCompilation
-XX:CompileOnly=Demo::workload
代码中间表示
Graal 是基于理想图去优化代码的
理想图本质上就是这种将数据流图和控制流图以某种方式合并到一起,用一种边来表示数据流向,另一种边来表示控制流向的图形表示
上再增加一个参数-Dgraal.Dump,要求 Graal 编译器把构造的理想图输出出来
可以使用 mx igv 命令来获得能够支持 Graal 编译器生成的理想图格式的新版本的 Ideal Graph Visualizer 工具
代码优化与生成
每一个理想图的节点都有两个共同的主要操作,一个是规范化(Canonicalisation),另一个是生成机器码(Generation),即代码优化/代码翻译
AddNode 节点的规范化是实现在 canonical()方法中的,机器码生成则是实现在 generate()方法中的, 从 AddNode 的创建方法上可以看到,在节点创建时会调用 canonical()方法尝试进行规范化缩减图的规模
从 AddNode 的 canonical()方法中我们可以看到为了缩减理想图的规模而做的相当多的努力,即使只是两个整数相加那么简单的操作,也尝试过了常量折叠(如果两个操作数都为常量,则直接返回一个 常量节点)、算术聚合(聚合树的常量子节点,譬如将(a+1)+2 聚合为 a+3)、符号合并(聚合树的相 反符号子节点,譬如将(a-b)+b 或者 b+(a-b)直接合并为 a)等多种优化
至于代码生成,Graal 并不是直接由理想图转换到机器码,而是和其他编译器一样,会先生成低级 中间表示(LIR,与具体机器指令集相关的中间表示),然后再由 HotSpot 统一后端来产生机器码
目前它只提供了三种目标平台的 指令集(SPARC、x86-AMD64、ARMv8-AArch64)的低级中间表示,所以现在 Graal 编译器也就只能支持这几种目标平台
由于计算机 的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多 层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲
缓存一致性(Cache Coherence)。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System)
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作
与处理器的乱序执行优化类似,Java 虚拟机的即时编译器中也有指令重排序 (Instruction Reorder)优化
《Java 虚拟机规范》中曾试图定义一种“Java 内存模型”(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果
主内存与工作内存
Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据
如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享,但是 reference 本身在 Java 栈的局部变量表中是线程私有的
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存
内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java 内存模型中定义了以下 8 种操作来完成。
Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作
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 操作)
Java 内存模型的操作简化为 read、write、lock 和 unlock 四种,但这只是语言描述上的等价化简,Java 内存模型的基础设计并未改变
等效判断原则——先行发生原则,用来确定一个操作在并发环境下是否安全的
对于 volatile 型变量的特殊规则
关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,当一个变量被定义成 volatile 之后,它将具备两项特性
第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的
在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized、java.util.concurrent 中的锁或原子类)来保证原子性
运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
变量不需要与其他的状态变量共同参与不变约束
使用 volatile 变量的第二个语义是禁止指令重排序优化
为 IA32 手册规定 lock 前缀不允许配合 nop 指令使用
lock 前缀,它的作用是将本处理器的缓存写入了内存,该写入动作也会引起 别的处理器或者别的内核无效化(Invalidate)其缓存
volatile 屏蔽指令重排序的语义在 JDK 5 中才被完全修复,此前的 JDK 中即使将变量声明为 volatile 也 仍然不能完全避免重排序所导致的问题(主要是 volatile 变量前后的代码仍然存在重排序问题),这一点也是在 JDK 5 之前的 Java 中无法安全地使用 DCL(双锁检测)来实现单例模式的原因
针对 long 和 double 型变量的特殊规则
Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这八种操作都具有原子性,但是对于 64 位的数据类型(long 和 double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现自行选择是否要保证 64 位数据类型的 load、store、read 和 write 这四个操作的原子性
如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值
对于 32 位的 Java 虚拟机,譬如比较常用的 32 位 x86 平台下的 HotSpot 虚拟机,对 long 类型的数据确实存在非原子性访问的风险
从 JDK 9 起,HotSpot 增加了一个实验性的参数-XX:+AlwaysAtomicAccesses(这是 JEP 188 对 Java 内存模型更新的一部分内容来约束虚拟机对所有数据类型进行原子性的访问
实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的 long 和 double 变量专门声明为 volatile
原子性,可见性,有序性
原子性
由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write 这六个,可以认为基本数据类型的访问读写都是具备原子性的
可见性
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改
Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的
volatile 的特殊规则保证了新值 能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性
Java 还有两个关键字能实现可见性,它们是 synchronized 和 final
有序性
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的(指“指令重排序”现象和“工作内存与主内存同步延迟”现象)
先行发生原则
先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,比如说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到
下面这些先行发生关系无须任何同步器协助就已 经存在,可以在编码中直接使用
程序次序规则(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 的结论
时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准
线程的实现
内核线程实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上
每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——线程,因此只有先支持内核线程,才能有轻量级进程
由于 是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调 用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈 空间),因此一个系统支持轻量级进程的数量是有限的
用户线程实现
用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理
混合实现
将内核线程与用户线程一起使用的实现方式
既存在用户线程,也存在轻量级进程。 用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁, 这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险
java 线程的实现
但从 JDK 1.3 起,“主流”平台上的“主流”商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型
HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以 HotSpot 自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提
供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的
Solaris 版的 HotSpot 也对应提供了两个平台专有的虚拟机参数,即-XX: +UseLWPSynchronization(默认值)和-XX:+UseBoundThreads 来明确指定虚拟机使用哪种线程模型
《Java 虚拟机规范》中才不去限定 Java 线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对 Java 程序的编码和运行过程来说,这些差异都是完全透明的
java 线程调度
线程调度是指系统为线程分配处理器使用权的过程
调度主要方式有两种,分别是协同式 (Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定
Java 使用的线程调度方式就是抢占式调度
状态转换
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):已终止线程的线程状态,线程已经结束执行
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Bn8IjSQG-1666975994811)(./线程状态.png)]
内核线程的局限
1:1 的内核线程模型是如今 Java 虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限
线程的复苏
内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本
最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”
由于这时候的协程会 完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”
无栈协程的典型应用是各种语言中的 await、async、yield 这类关键字。无栈协程本质上 是一种有限状态机,状态保存在闭包里,自然比有栈协程恢复调用栈要轻量得多,但功能也相对更有限
协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多
协程当然也有它的局限,需要在应用层面实现的内容(调用栈、调度器这些)特别多
纤程
典型的有栈协程,方便应用做现场保存、恢复和纤程调度
在新并发模型下,一段使用纤程并发的代码会被分为两部分——执行过程(Continuation)和调度器(Scheduler)。执行过程主要用于维护执行现场,保护、恢复上下文状态,而调度器则负责编排所有要执行的代码的顺序
Loom 中默认的调度器就是原来已存在 的用于任务分解的 Fork/Join 池
定义:
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
java 的线程安全
可以将 Java 语言中各种操作共享的数 据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
不可变
指 JDK 5 以后不可变的对象一定是线程安全的
String 类的对象实例,它是一个典型的不可变对象,用户调用它的 substring()、replace()和 concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象
Integer 将内部状态变量 value 定义为 final 来保障状态不变
绝对线程安全
绝对的线程安全能够完全满足给出的线程安全的定义
在 Java API 中标注自己是线程安全的类,大多数都不是绝对的线程安全
相对线程安全
通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
集合类 ArrayList 和 HashMap
线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于 Java 语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免
一个线程对立的例子是 Thread 类的 suspend()和 resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险
常见的线程对立 的操作还有 System.setIn()、Sytem.setOut()和 System.runFinalizersOnExit()等
互斥同步
最基本的互斥同步手段就是 synchronized 关键字
synchronized 关键字经过 Javac 编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象
根据《Java 虚拟机规范》的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止
两个关于 synchronized 的直接推论
被 synchronized 修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块 也不会出现自己把自己锁死的情况
被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出
可重入性是指一条线程能够反复进入被它自己持有锁的同步块的特性,即锁关联的计数器,如果持有锁的线程再次获得它,则将计数器的值加一,每次释放锁时计数器的值减一,当计数器的值为零时,才能真正释放锁
synchronized 是 Java 语言中一个重量级的操作,有经验的程序员都只会在确实必要的情况下才使用这种操作
自 JDK 5 起,Lock 接口便成了 Java 的另一种全新的互斥同步手段
ReentrantLock 与 synchronized 相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件
等待可中断
是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改 为处理其他事情
公平锁
是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;
而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁
synchronized 中的锁是非公平的,ReentrantLock 在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
不过一旦使用了公平锁,将会导致 ReentrantLock 的性能急剧下降,会明显影响吞吐量
锁绑定多个条件
是指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象
synchronized 对性能的影响,尤其在 JDK 5 之前是很显著的
JDK 6 中加入了大量针对 synchronized 锁的优化措施之后,相同的测试中就发现 synchronized 与 ReentrantLock 的性能基本上能够持平
推荐在 synchronized 与 ReentrantLock 都可满足需要时优先使用 synchronized
需要基础的同步功能时,更推荐 synchronized
为 Java 虚拟机可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 Lock 的话,Java 虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的
非阻塞同步
随着硬件指令集的发展,基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了
如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止
CAS 指令需要有三个操作数,分别是内存位置(在 Java 中可以简单地理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和准备设置的新值(用 B 表示)。CAS 指令执行时,当且仅当 V 符合 A 时,处理器才会用 B 更新 V 的值,否则它就不执行更新。但是,不管是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断
JDK 9 之前只有 Java 类库可以使用 CAS,用户程序有使用 CAS 操作的需求,要么就采用反射手段突破 Unsafe 的访问限制,要么就只能通过 Java 类库 API 来间接使用它
JDK 9 之后,Java 类库才在 VarHandle 类里开放了面向用户程序使用的 CAS 操作
CAS 存在 ABA 问题: 如果在这段期间它的值曾经被改成 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过
为了解决这个问题,提供了一个带有标记的原子引用类 AtomicStampedReference,它可以通过控制变量值的版本来保证 CAS 的正确性
大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要 解决 ABA 问题,改用传统的互斥同步可能会比原子类更为高效
无同步方案
如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性
可重入代码
这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响
所有可重入的代码都是线程安全的
不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等
线程本地存储
在同一个线程中,无须同步也能保证线程之间不出现数据争用的问题
ThreadLocal 类来实现线程本地存储的功能
适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)
自旋锁与自适应自旋
忙循环(自旋)
如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费
自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin 来自行更改
自适应意味着自旋的时间不再是固定的了,而是由 前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续 100 次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除
锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行
有许多同步措施并不是程序员自己加入的
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
轻量级锁
HotSpot 虚拟机对象的内存布局,对象头分为两部分
第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄(Generational GC Age)等。这部分数据的长度在 32 位和 64 位的 Java 虚拟机中分别会占用 32 个或 64 个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。Mark Word 被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息
另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度
HotSpot 虚拟机对象头 Mark Word:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DnT59MJ2-1666975994812)(./对象头.png)]
在代码即将进入 同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方为 这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word)
然后虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。
如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位(Mark Word 的 最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态,
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。
如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态
解锁过程也同样是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。
假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢
偏向锁
这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步
假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,这是自 JDK 6 起 HotSpot 虚拟机的默认值),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式
同时使用 CAS 操作把获取到这个锁的线程 的 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束
根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位 为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行
在 Java 语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载 hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的 API 都可能存在出错风险。而作为绝大多数对象哈希码来源的 Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变
因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁
在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态(标志位为“01”)下的 Mark Word,其中自然可以存储原来的哈希码
如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:- UseBiasedLocking 来禁止偏向锁优化反而可以提升性能
模块化
混合语言
多核并行
进一步丰富的语法
字节码 助记符 指令含义
0x00 nop None
0x01 aconst_null 将 null 推送至栈顶
0x02 iconst_m1 将 int 型-1 推送至栈顶
0x03 iconst_0 将 int 型 0 推送至栈顶
0x04 iconst_1 将 int 型 1 推送至栈顶
0x05 iconst_2 将 int 型 2 推送至栈顶
0x06 iconst_3 将 int 型 3 推送至栈顶
0x07 iconst_4 将 int 型 4 推送至栈顶
0x08 iconst_5 将 int 型 5 推送至栈顶
0x09 lconst_0 将 long 型 0 推送至栈顶
0x0a lconst_1 将 long 型 1 推送至栈顶
0x0b fconst_0 将 float 型 0 推送至栈顶
0x0c fconst_1 将 float 型 1 推送至栈顶
0x0d fconst_2 将 float 型 2 推送至栈顶
0x0e dconst_0 将 double 型 0 推送至栈顶
0x0f dconst_1 将 double 型 1 推送至栈顶
0x10 bipush 将单字节的常量值(-128~127)推送至栈顶
0x11 sipush 将一个短整型常量(-32768~32767)推送至栈顶
0x12 ldc 将 int,float 或 String 型常量值从常量池中推送至栈顶
0x13 ldc_w 将 int,float 或 String 型常量值从常量池中推送至栈顶(宽索引)
0x14 ldc2_w 将 long 或 double 型常量值从常量池中推送至栈顶(宽索引)
0x15 iload 将指定的 int 型本地变量推送至栈顶
0x16 lload 将指定的 long 型本地变量推送至栈顶
0x17 fload 将指定的 float 型本地变量推送至栈顶
0x18 dload 将指定的 double 型本地变量推送至栈顶
0x19 aload 将指定的引用类型本地变量推送至栈顶
0x1a iload_0 将第一个 int 型本地变量推送至栈顶
0x1b iload_1 将第二个 int 型本地变量推送至栈顶
0x1c iload_2 将第三个 int 型本地变量推送至栈顶
0x1d iload_3 将第四个 int 型本地变量推送至栈顶
0x1e lload_0 将第一个 long 型本地变量推送至栈顶
0x1f lload_1 将第二个 long 型本地变量推送至栈顶
0x20 lload_2 将第三个 long 型本地变量推送至栈顶
0x21 lload_3 将第四个 long 型本地变量推送至栈顶
0x22 fload_0 将第一个 float 型本地变量推送至栈顶
0x23 fload_1 将第二个 float 型本地变量推送至栈顶
0x24 fload_2 将第三个 float 型本地变量推送至栈顶
0x25 fload_3 将第四个 float 型本地变量推送至栈顶
0x26 dload_0 将第一个 double 型本地变量推送至栈顶
0x27 dload_1 将第二个 double 型本地变量推送至栈顶
0x28 dload_2 将第三个 double 型本地变量推送至栈顶
0x29 dload_3 将第四个 double 型本地变量推送至栈顶
0x2a aload_0 将第一个引用类型本地变量推送至栈顶
0x2b aload_1 将第二个引用类型本地变量推送至栈顶
0x2c aload_2 将第三个引用类型本地变量推送至栈顶
0x2d aload_3 将第四个引用类型本地变量推送至栈顶
0x2e iaload 将 int 型数组指定索引的值推送至栈顶
0x2f laload 将 long 型数组指定索引的值推送至栈顶
0x30 faload 将 float 型数组指定索引的值推送至栈顶
0x31 daload 将 double 型数组指定索引的值推送至栈顶
0x32 aaload 将引用类型数组指定索引的值推送至栈顶
0x33 baload 将 boolean 或 byte 型数组指定索引的值推送至栈顶
0x34 caload 将 char 型数组指定索引的值推送至栈顶
0x35 saload 将 short 型数组指定索引的值推送至栈顶
0x36 istore 将栈顶 int 型数值存入指定本地变量
0x37 lstore 将栈顶 long 型数值存入指定本地变量
0x38 fstore 将栈顶 float 型数值存入指定本地变量
0x39 dstore 将栈顶 double 型数值存入指定本地变量
0x3a astore 将栈顶引用类型数值存入指定本地变量
0x3b istore_0 将栈顶 int 型数值存入第一个本地变量
0x3c istore_1 将栈顶 int 型数值存入第二个本地变量
0x3d istore_2 将栈顶 int 型数值存入第三个本地变量
0x3e istore_3 将栈顶 int 型数值存入第四个本地变量
0x3f lstore_0 将栈顶 long 型数值存入第一个本地变量
0x40 lstore_1 将栈顶 long 型数值存入第二个本地变量
0x41 lstore_2 将栈顶 long 型数值存入第三个本地变量
0x42 lstore_3 将栈顶 long 型数值存入第四个本地变量
0x43 fstore_0 将栈顶 float 型数值存入第一个本地变量
0x44 fstore_1 将栈顶 float 型数值存入第二个本地变量
0x45 fstore_2 将栈顶 float 型数值存入第三个本地变量
0x46 fstore_3 将栈顶 float 型数值存入第四个本地变量
0x47 dstore_0 将栈顶 double 型数值存入第一个本地变量
0x48 dstore_1 将栈顶 double 型数值存入第二个本地变量
0x49 dstore_2 将栈顶 double 型数值存入第三个本地变量
0x4a dstore_3 将栈顶 double 型数值存入第四个本地变量
0x4b astore_0 将栈顶引用型数值存入第一个本地变量
0x4c astore_1 将栈顶引用型数值存入第二个本地变量
0x4d astore_2 将栈顶引用型数值存入第三个本地变量
0x4e astore_3 将栈顶引用型数值存入第四个本地变量
0x4f iastore 将栈顶 int 型数值存入指定数组的指定索引位置
0x50 lastore 将栈顶 long 型数值存入指定数组的指定索引位置
0x51 fastore 将栈顶 float 型数值存入指定数组的指定索引位置
0x52 dastore 将栈顶 double 型数值存入指定数组的指定索引位置
0x53 aastore 将栈顶引用型数值存入指定数组的指定索引位置
0x54 bastore 将栈顶 boolean 或 byte 型数值存入指定数组的指定索引位置
0x55 castore 将栈顶 char 型数值存入指定数组的指定索引位置
0x56 sastore 将栈顶 short 型数值存入指定数组的指定索引位置
0x57 pop 将栈顶数值弹出(数值不能是 long 或 double 类型的)
0x58 pop2 将栈顶的一个(对于非 long 或 double 类型)或两个数值(对于非 long 或 double 的其他类型)弹出
0x59 dup 复制栈顶数值并将复制值压入栈顶
0x5a dup_x1 复制栈顶数值并将两个复制值压入栈顶
0x5b dup_x2 复制栈顶数值并将三个(或两个)复制值压入栈顶
0x5c dup2 复制栈顶一个(对于 long 或 double 类型)或两个(对于非 long 或 double 的其他类型)数值并将复制值压入栈顶
0x5d dup2_x1 dup_x1 指令的双倍版本
0x5e dup2_x2 dup_x2 指令的双倍版本
0x5f swap 将栈顶最顶端的两个数值互换(数值不能是 long 或 double 类型)
0x60 iadd 将栈顶两 int 型数值相加并将结果压入栈顶
0x61 ladd 将栈顶两 long 型数值相加并将结果压入栈顶
0x62 fadd 将栈顶两 float 型数值相加并将结果压入栈顶
0x63 dadd 将栈顶两 double 型数值相加并将结果压入栈顶
0x64 isub 将栈顶两 int 型数值相减并将结果压入栈顶
0x65 lsub 将栈顶两 long 型数值相减并将结果压入栈顶
0x66 fsub 将栈顶两 float 型数值相减并将结果压入栈顶
0x67 dsub 将栈顶两 double 型数值相减并将结果压入栈顶
0x68 imul 将栈顶两 int 型数值相乘并将结果压入栈顶
0x69 lmul 将栈顶两 long 型数值相乘并将结果压入栈顶
0x6a fmul 将栈顶两 float 型数值相乘并将结果压入栈顶
0x6b dmul 将栈顶两 double 型数值相乘并将结果压入栈顶
0x6c idiv 将栈顶两 int 型数值相除并将结果压入栈顶
0x6d ldiv 将栈顶两 long 型数值相除并将结果压入栈顶
0x6e fdiv 将栈顶两 float 型数值相除并将结果压入栈顶
0x6f ddiv 将栈顶两 double 型数值相除并将结果压入栈顶
0x70 irem 将栈顶两 int 型数值作取模运算并将结果压入栈顶
0x71 lrem 将栈顶两 long 型数值作取模运算并将结果压入栈顶
0x72 frem 将栈顶两 float 型数值作取模运算并将结果压入栈顶
0x73 drem 将栈顶两 double 型数值作取模运算并将结果压入栈顶
0x74 ineg 将栈顶 int 型数值取负并将结果压入栈顶
0x75 lneg 将栈顶 long 型数值取负并将结果压入栈顶
0x76 fneg 将栈顶 float 型数值取负并将结果压入栈顶
0x77 dneg 将栈顶 double 型数值取负并将结果压入栈顶
0x78 ishl 将 int 型数值左移指定位数并将结果压入栈顶
0x79 lshl 将 long 型数值左移指定位数并将结果压入栈顶
0x7a ishr 将 int 型数值右(带符号)移指定位数并将结果压入栈顶
0x7b lshr 将 long 型数值右(带符号)移指定位数并将结果压入栈顶
0x7c iushr 将 int 型数值右(无符号)移指定位数并将结果压入栈顶
0x7d lushr 将 long 型数值右(无符号)移指定位数并将结果压入栈顶
0x7e iand 将栈顶两 int 型数值"按位与"并将结果压入栈顶
0x7f land 将栈顶两 long 型数值"按位与"并将结果压入栈顶
0x80 ior 将栈顶两 int 型数值"按位或"并将结果压入栈顶
0x81 lor 将栈顶两 long 型数值"按位或"并将结果压入栈顶
0x82 ixor 将栈顶两 int 型数值"按位异或"并将结果压入栈顶
0x83 lxor 将栈顶两 long 型数值"按位异或"并将结果压入栈顶
0x84 iinc 将指定 int 型变量增加指定值(如 i++, i–, i+=2 等)
0x85 i2l 将栈顶 int 型数值强制转换为 long 型数值并将结果压入栈顶
0x86 i2f 将栈顶 int 型数值强制转换为 float 型数值并将结果压入栈顶
0x87 i2d 将栈顶 int 型数值强制转换为 double 型数值并将结果压入栈顶
0x88 l2i 将栈顶 long 型数值强制转换为 int 型数值并将结果压入栈顶
0x89 l2f 将栈顶 long 型数值强制转换为 float 型数值并将结果压入栈顶
0x8a l2d 将栈顶 long 型数值强制转换为 double 型数值并将结果压入栈顶
0x8b f2i 将栈顶 float 型数值强制转换为 int 型数值并将结果压入栈顶
0x8c f2l 将栈顶 float 型数值强制转换为 long 型数值并将结果压入栈顶
0x8d f2d 将栈顶 float 型数值强制转换为 double 型数值并将结果压入栈顶
0x8e d2i 将栈顶 double 型数值强制转换为 int 型数值并将结果压入栈顶
0x8f d2l 将栈顶 double 型数值强制转换为 long 型数值并将结果压入栈顶
0x90 d2f 将栈顶 double 型数值强制转换为 float 型数值并将结果压入栈顶
0x91 i2b 将栈顶 int 型数值强制转换为 byte 型数值并将结果压入栈顶
0x92 i2c 将栈顶 int 型数值强制转换为 char 型数值并将结果压入栈顶
0x93 i2s 将栈顶 int 型数值强制转换为 short 型数值并将结果压入栈顶
0x94 lcmp 比较栈顶两 long 型数值大小, 并将结果(1, 0 或-1)压入栈顶
0x95 fcmpl 比较栈顶两 float 型数值大小, 并将结果(1, 0 或-1)压入栈顶; 当其中一个数值为 NaN 时, 将-1 压入栈顶
0x96 fcmpg 比较栈顶两 float 型数值大小, 并将结果(1, 0 或-1)压入栈顶; 当其中一个数值为 NaN 时, 将 1 压入栈顶
0x97 dcmpl 比较栈顶两 double 型数值大小, 并将结果(1, 0 或-1)压入栈顶; 当其中一个数值为 NaN 时, 将-1 压入栈顶
0x98 dcmpg 比较栈顶两 double 型数值大小, 并将结果(1, 0 或-1)压入栈顶; 当其中一个数值为 NaN 时, 将 1 压入栈顶
0x99 ifeq 当栈顶 int 型数值等于 0 时跳转
0x9a ifne 当栈顶 int 型数值不等于 0 时跳转
0x9b iflt 当栈顶 int 型数值小于 0 时跳转
0x9c ifge 当栈顶 int 型数值大于等于 0 时跳转
0x9d ifgt 当栈顶 int 型数值大于 0 时跳转
0x9e ifle 当栈顶 int 型数值小于等于 0 时跳转
0x9f if_icmpeq 比较栈顶两 int 型数值大小, 当结果等于 0 时跳转
0xa0 if_icmpne 比较栈顶两 int 型数值大小, 当结果不等于 0 时跳转
0xa1 if_icmplt 比较栈顶两 int 型数值大小, 当结果小于 0 时跳转
0xa2 if_icmpge 比较栈顶两 int 型数值大小, 当结果大于等于 0 时跳转
0xa3 if_icmpgt 比较栈顶两 int 型数值大小, 当结果大于 0 时跳转
0xa4 if_icmple 比较栈顶两 int 型数值大小, 当结果小于等于 0 时跳转
0xa5 if_acmpeq 比较栈顶两引用型数值, 当结果相等时跳转
0xa6 if_acmpne 比较栈顶两引用型数值, 当结果不相等时跳转
0xa7 goto 无条件跳转
0xa8 jsr 跳转至指定的 16 位 offset 位置, 并将 jsr 的下一条指令地址压入栈顶
0xa9 ret 返回至本地变量指定的 index 的指令位置(一般与 jsr 或 jsr_w 联合使用)
0xaa tableswitch 用于 switch 条件跳转, case 值连续(可变长度指令)
0xab lookupswitch 用于 switch 条件跳转, case 值不连续(可变长度指令)
0xac ireturn 从当前方法返回 int
0xad lreturn 从当前方法返回 long
0xae freturn 从当前方法返回 float
0xaf dreturn 从当前方法返回 double
0xb0 areturn 从当前方法返回对象引用
0xb1 return 从当前方法返回 void
0xb2 getstatic 获取指定类的静态域, 并将其压入栈顶
0xb3 putstatic 为指定类的静态域赋值
0xb4 getfield 获取指定类的实例域, 并将其压入栈顶
0xb5 putfield 为指定类的实例域赋值
0xb6 invokevirtual 调用实例方法
0xb7 invokespecial 调用超类构建方法, 实例初始化方法, 私有方法
0xb8 invokestatic 调用静态方法
0xb9 invokeinterface 调用接口方法
0xba invokedynamic 调用动态方法
0xbb new 创建一个对象, 并将其引用引用值压入栈顶
0xbc newarray 创建一个指定的原始类型(如 int, float, char 等)的数组, 并将其引用值压入栈顶
0xbd anewarray 创建一个引用型(如类, 接口, 数组)的数组, 并将其引用值压入栈顶
0xbe arraylength 获取数组的长度值并压入栈顶
0xbf athrow 将栈顶的异常抛出
0xc0 checkcast 检验类型转换, 检验未通过将抛出 ClassCastException
0xc1 instanceof 检验对象是否是指定类的实际, 如果是将 1 压入栈顶, 否则将 0 压入栈顶
0xc2 monitorenter 获得对象的锁, 用于同步方法或同步块
0xc3 monitorexit 释放对象的锁, 用于同步方法或同步块
0xc4 wide 扩展本地变量的宽度
0xc5 multianewarray 创建指定类型和指定维度的多维数组(执行该指令时, 操作栈中必须包含各维度的长度值), 并将其引用压入栈顶
0xc6 ifnull 为 null 时跳转
0xc7 ifnonnull 不为 null 时跳转
0xc8 goto_w 无条件跳转(宽索引)
0xc9 jsr_w 跳转至指定的 32 位 offset 位置, 并将 jsr_w 的下一条指令地址压入栈顶