Java虚拟机

虚拟机历史

1996年1月23日,Sun公司发布JDK 1.0 这个JDK中所带的虚拟机就是Sun Classic VM。这款虚拟机只能使用纯解释器的方法来执行代码,并且解释器和编译器不能配合工作,必须每一行代码事无巨细全部编译,基于程序响应的压力,编译器也不敢应用编译耗时的优化技术。这个阶段虚拟机即使使用了JIT编译器,执行效率也比C/C++慢很多。

在JDK 1.2时,Sun公司改进了Classic VM,发布了Exact VM,这是现代高性能虚拟机的雏形。主要特点是拥有了准确式内存管理,虚拟机可以直到内存中的数据具体是什么类型。降低了一次定位对象的简介查找开销,提升了执行性能。Exact VM只存在了很多的时间便被HotSpot VM取代,比Classic VM存在的时间还短。Classic VM直到JDK 1.4才退出。

HotSpot VM 是Sun JDK 和OpenJDK中所带的虚拟机,也是目前使用最广泛的Java虚拟机。由Longview Technologies 公司设计。HotSpot VM的热点代码探测指的是通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。通过解释器和编译器的协同工作,可以在最优的程序响应时间与最佳的性能中取得平衡。

除了商用虚拟机之外,Sun 公司面对移动和嵌入式市场也开发过虚拟机产品,还有一些为了研究,实验性质的虚拟机。包括

  1. KVM Android,IOS出现收集平台上的虚拟机
  2. CDC/CLDC HotSpot Implementation,希望在手机,电子书,PDA建立统一的Java编程接口。
  3. Squawk VM 主要由Java语言实现的运行于Java Card和Sun SPOT。
  4. JavaInJava,实验性质。
  5. MaxineVM,前者升级版,几乎全部由Java代码实现的元循环虚拟机。

除了Sun公司开发的虚拟机之外还有BEA公司的JRockit VM,其主要运行在服务器上并不在乎程序启动速度,内部不包含解析器,全部代码靠即使编译器编译后执行,垃圾收集器和MissionControl在所有Java虚拟机中领先。

IBM公司开发的J9 VM,市场定位和HotSpot相同,主要应用在IBM的产品中例如WebSphere。

性能最高的虚拟机实际上是运行于特定硬件平台的专有虚拟机。A租赁System 开发的Azul VM是在HotSpot基础上针对专有硬件Vega进行了优化。2010年发布了 Zing JVM。

Liquid VM 由BEA公司开发,其本身实现了一个专有的操作系统,直接控制硬件,提升了Java程序的执行性能。

微软的Java虚拟机,曾经是windows下性能最好的虚拟机,后来因为Sun公司起诉微软侵权,不正当竞争导致Java虚拟机相关功能被移除。

自动内存管理

Java虚拟机会将管理的内存划分为不同的数据区。由所有线程共享的区域包括方法区和堆,由每个线程独占的区域包括虚拟机栈,本地方法栈,程序计数器。

JVM内存分布

字节码解释器工作时通过程序计数器保存下一条需要执行的字节码指令,流程控制,异常处理,异常恢复等基础功能都需要依赖这个计数器来完成。如果线程执行一个Java方法,程序计数器中会记录字节码指定的内存地址,如果正在执行的Native方法,计数器的值为undfined,这块内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError的区域。

虚拟机栈描述了Java方法执行的内存模型。每个方法在执行的同时会创建一个栈帧,用于存储局部变量,操作数栈,动态链接,方法出口等信息,每一个方法从调用到完成的过程,对应了栈帧入栈到出栈的过程。局部变量表存放了编译器可知的各种基本数据类型,对象引用,和returnAddress类型。除了long和double占用两个局部变量槽,其余所有数据类型占用1个。局部变量表所需要的内存空间在编译器就完成分配,方法运行期间局部变量表不会改变。虚拟机规范中规定了两种意外情况,当线程请求的栈深度大于虚拟机允许的深度将抛出StackOverflowError,如果虚拟机栈允许动态扩展,当扩展时候无法申请到足够内存抛出OutOfMemoryError

本地方法栈主要用于记录本地方法的调用。虚拟机规范对本地方法栈中的语言,数据结构并没有强制规定。有些虚拟机实现直接把本地方法栈和虚拟机栈合二为一。

Java堆 是虚拟机内存管理中最大的一块。Java虚拟机规范中规定所有对象和数组都必须保存在堆中,但是随着JIT编译器和逃逸分析技术的发展,可能会出现由对象在栈中分配。堆是垃圾回收的主要区域,现在的垃圾回收多采用分代收集算法,从内存回收的角度,堆中又可以细分为新生代老年代,更细致的划分为Eden,From Survivor,To Survivor空间。从内存分配的角度来看堆种又可以划分出多个线程私有的分配缓冲区。堆内存可以是不连续的,只需要保证逻辑上连续。目前主流虚拟机都是按照可拓展的方式来实现堆,通过-Xms,-Xmx来控制堆的大小。如果堆中没有足够空间分配,并且也无法拓展,就会抛出OutOfMemoryError

方法区也是线程共享的区域,用于存储已经被加载的类信息,常量,静态变量,JIT编译后的代码。规范中规定方法区是堆的一个逻辑部分,但是与常规堆还是又很大区别。HotSpot把GC分代收集拓展至方法区,使用永久代(Permanent Generation)的概念来实现方法区。规范对于方法区如何实现没有明确的规定。所以对于JRockit、J9并不存在永久代。但是永久代并不是最好的实现,可能在没达到内存上限时因为遇到了-XX:MaxPermSize而导致内存溢出,并且String.intern()方法会因此受到影响,在不同的虚拟机下有不同的表现。在JDK 1.7中HotSpot已经把字符串常量池由方法区移到堆中。1.8中已经正式移除了方法区,改用元空间代为实现,元空间使用本地内存(Native Memory)实现,不在虚拟机中。规范对于此块区域没有明确要求实现GC,这个区域的垃圾回收也比较少发生。当方法区不能满足内存分配要求时,将抛出OutOfMemoryError

运行时常量池是方法区的一部分,Class文件中除了有类的版本字段方法等描述信息外,还有一项常量池用于存储编译器生成的各种字面量和符号引用,以及翻译出来的直接引用。常量池在类加载后保存在方法区的运行时常量池中。常量池可以动态修改,在程序运行期间向常量池中增加内容。

直接内存并不是虚拟机的一部分,但是这里也会被频繁使用,并可能导致OutOfMemoryError。在NIO中可以使用Native函数库直接在分配对外内存,然后通过Java对象作为这块内存地址的引用避免在Java堆中和Native堆中的数据复制。直接堆内存并不会受到Java堆大小的限制,但是仍然有物理内存的限制。

对象的创建

虚拟机遇到new指令时,会区常量池中检查对应的符号引用,以及对应的类是否已经被加载。加载后虚拟机将为新对象分配内存,本质就是在内存中划分一块确定大小的空间。划分的方式取决于垃圾收集器,Serial,ParNew等带有压缩功能的收集器会在回收内存后将所有存活对象复制到内训的一端,另一端则是空闲部分。此时只需要将作为分界点的指示器挪动对象大小的距离。CMS这种基于Mark-Sweep算法的收集器则需要使用一个空闲列表维护可用空间,在空闲列表上寻找一块足够大小的空间,然后更新列表。

即使是挪动指针来创建对象,在并发情况下也不是安全的。虚拟机采用CAS加上失败重试的方式保证更新的原子性,或者采用TLAB(Thread Local Allocation Buffer),为每个线程分配一定的内存空间,线程需要创建对象时就在自己的TLAB中创建,只有TLAB用完,需要申请新的TLAB时候才需要同步锁定。可以通过虚拟机参数 -XX:+/-UseTLAB来启用TLAB。

对象分配完成之后除了对象头,整个对象的内存空间都会被初始化为零,保证对象的实例属性不需要赋值就能使用属性对应的初始值。虚拟机的创建对象行为最后一步是设置对象头,对象头包括对象的class信息,类的元数据引用,对象的hash码,GC分代年龄等。从虚拟机的角度来看此时已经创建好了一个新对象,但是从Java程序来看,还需要执行\<init>方法来完成代码中的初始化行为。

在HotSpot中,对象在内存中存储的布局可以分为3块,对象头Header,实例数据Instance Data和对其填充Padding。对象头包含了对象自身的运行时数据(HashCode,GC年龄,锁信息)称之为Mark Word,在32位,64位(不启用压缩指针)虚拟机中分别为32bit和64bit。对象头存储的信息很多,为了空间效率,Mark Word的数据结构并不固定。
| 存储内容 | 标志位 | 状态 |
| —————————– | —— | ———- |
| 对象的hashcode,GC age | 01 | 未锁定 |
| 锁记录指针 | 00 | 轻量级锁定 |
| 重量级锁指针 | 10 | 重量级锁定 |
| 空 | 11 | GC标记 |
| 偏向线程ID,偏向时间戳,GC age | 01 | 可偏向 |

对象头的另外一部分是类型指针,是对象指向它的类元数据的指针,虚拟机可以通过这个指针来访问到对象的class信息。目前的主流实现方式有两种,一种是通过在堆上开辟句柄池,reference指向句柄池中句柄,句柄又包含到类型数据和实例数据的两个指针。这种情况下垃圾回收移动了对象的时候reference中存储的地址不需要变,只需要改变句柄中示例数据的指针。
句柄引用
另一种方式是reference直接指向实例数据,实例数据中保存指向类型数据的指针。这种方式节约了一次指针定位的开销,也是HotSpot的实现方式。
指针引用

对象头之后是对象的实例数据,这里存储了应用程序关心的数据。这部分的存储顺序受到虚拟机分配策略的影响,HotSpot主要特征是相同宽度的的字段会被分配在一起,父类变量总会出现在子类之前,如果启用了CompactFields,子类较窄的变量亦可能会插入父类变量的空隙之中。

对齐填充(padding)并不是一定存在,HotSpot要求对象的长度必须是8字节的整数倍,对象头一定是8字节或者16字节,为了保证对象示例数据的长度,可能需要填充补全。

垃圾收集

在微软的COM技术,Python等使用引用计数法来判断一个对象是否还存活。这种算法没法解决循环引用的问题,因此在主流的JVM中采用可达性分析来判断对象是否存活。可达性分析指的是从GC ROOT开始,向下搜索,所有可达的对象是存活的,否则应该被回收掉。可以作为GC ROOT的对象主要有:

  1. 虚拟机栈帧中本地变量表的引用对象
  2. 方法区中静态属性引用对象
  3. 方法区中常量引用对象
  4. Native方法引用对象

JDK 1.2 之后Java对引用的概念进行了扩充,按照引用强度分为强软弱虚引用。

  • 强引用就是一般的引用,只要强引用存在,强引用对象就不会被回收。
  • 软引用使用SoftReference实现,在内存溢出发生之前,这类引用才会被列入回收范围进行二次回收。
  • 弱引用使用WeakReference实现,对对象的生命周期没有影响。
  • 虚引用使用PhantomReference实现,无法通过虚引用来获得对象。虚引用对象只能在被回收时获取系统通知。

在可达性分析中被标记为不可达的对象如果实现了finalize方法,并且没有被执行过,则会被放在一个队列中由一个低优先级线程执行。虚拟机并不保证一定等待finalize方法执行完毕,如果finalize方法中使得当前对象重新变的与GC ROOT可达,GC会将当前对象移除回收集合,否则这个对象就会被回收。

虚拟机规范不要求方法区实现垃圾回收,实际上方法区也可以实现垃圾回收。主要包括常量池的清理,类的卸载。

垃圾回收算法有4种:

  1. 标记清除(Mark-Sweep)算法。首先标记待回收对象,标记完成后回收所有被标记对象。这是所有后续算法的基础。没有压缩整理过程可能会导致大量内存空间碎片,并且标记清除的过程效率都不高。
  2. 复制算法。将内存划分为两块,每次分配对象只使用其中一块,内存不足时,将存活对象复制到两一块空闲内存中,清理所有已使用的空间。这种算法简单高效,空间利用率太低。目前的商用虚拟机都是采用这种方法回收新生代,但是是将新生代分为1块Eden,2块Survivor,其空间大小比例为8:1。每次使用Eden和1块Survivor,空间利用率为90%。
  3. 标记整理(Mark-Compact)算法。首先标记待回收对象,然后将存活对象移动到内存区域的一端。保证内存空间的连续。
  4. 分代收集(Generational Collection)算法。当前商用虚拟机都是采用这种算法,将堆分为新生代老年代,新生代采用复制算法,老年采用标记整理或者标记清理算法。

在HotSpot中,使用一组OopMap的数据结构保存了所有的对象引用,避免了对方法区和栈帧的遍历。OopMap处于一直变化的过程中,HotSpot只在特定位置(称为安全点,safe point)才会记录对象信息,GC也并不是在任何时候都能发生,只能发生在安全点。在发生GC时,所有线程都必须在最近的安全点上停顿。抢先式中断不需要线程代码的配合,虚拟机直接终端所有线程,如果线程不在安全点则恢复线程直到安全点。主流虚拟就都是采用主动式中断。主动式中断仅仅设置一个简单的标志,线程执行时主动轮询这个标志,发现标志为真时候主动挂起。轮询标志的地方和安全点是重合的。安全点可以被拓展为安全区域(safe region),在这个区域中引用关系不会发生变化,所有线程阻塞之前标记自己进入了安全区域,GC时就不需要管这些线程,线程唤醒后会判断GC是否完成,GC完成后线程才能离开安全区域。

HotSpot种提供了7种不同的垃圾收集器

七种垃圾收集器

  1. Serial是最基本的收集器,其在垃圾收集时,必须暂停其他所有工作线程,直到收集结束。并且只能使用一个CPU或者一个单线程。是虚拟机运行在client模式下默认的新生代收集器。对于限定单个CPU的环境来说,Serial收集器没有线程切换的开销。
  2. ParNew是Serial的多线程版本。是运行在Server模式下虚拟机种首选的新生代收集器。只有Serial和Parnew能够和老年代收集器CMS配合。可以通过虚拟机参数-XX:+UseConcMarkSweepGC或者-XX:+UseParNewGC指定。
  3. Parallel的关注可控制的吞吐量,即CPU用于运行用户代码的时间占CPU总消耗时间的比值。-XX:MaxGCPauseMillis用于控制GC时间。-XX:GCTimeRatio表示运行时间和垃圾收集时间的比值,默认99。并可以指定-XX:UseAdapiveSizePolicy来动态控制Eden和Survivor的比例等等参数。
  4. Serial Old是Serial的老年代版本,主要运行在Client模式虚拟机上。在Server模式作为CMS的后备预案,在并发失败时使用。
  5. Parallel Old是Parallel Scavenge的老年代版本。
  6. CMS(Concurrent Mark Sweep)以最短回收停顿时间为目标。主要分为四个阶段,第一阶段只标记与GC ROOT 直接关联的对象,第二阶段与用户线程并发标记,第三阶段修复用户线程带来的对象引用变化。第四阶段和用户线程并发清除回收对象。CMS对CPU资源敏感,无法处理并发清除阶段用户线程产生的浮动垃圾,并且没有压缩整理可能会产生内存碎片。
  7. G1收集器是JDK 1.7新增加的面向服务器收集器。能充分利用多CPU核心的优势,降低STW的时间。不需要其他收集器配合就能完成分代收集。带有空间压缩整理功能。可预测的低停顿。使用G1收集器时,Java堆的内存布局会有一些变化,被划分为多个大小相等的region,新生代和老年代不必须是物理隔离的连续区域,改为由region组合而成。G1收集器采用化整为零的思想,会维护一个优先列表,表中记录了每隔region回收空间的价值以及回收所需要的时间的经验值。从而根据用户预期的停顿时间选择合适的region进行回收。G1的整个回收过程也是分为4个阶段和CMS相似。为了避免引用关系跨region导致的跨region扫描,G1为每个region建立Remembered set记录了被跨region引用的对象,并在在reference发生改变时更新。这也是其他收集器解决引用跨代的方案。

sun实验室G1对比CMS

内存分配策略

内存的分配行为并不是一成不变,而是取决于虚拟机内存分配参数和所使用的垃圾收集器。主要的原则是大部分在堆上分配,主要分配在新生代的Eden区域,如果启动了TLAB,则优先在TLAB上分配,偶尔直接分配在老年代。

当Eden区域空间不足时,虚拟机会发起一次Minor GC。可以通过-XX:+PrintGCDetails打印内存回收日志。-XX:PretenureSizeThreshold参数控制了直接进入老年代的对象大小,避免大对象在新生代的复制。Eden区的对象每经历过一个Minor GC 年龄就会增加1,-XX:MaxTenuringThreshold=15表示默认情况下,对象的年龄超过15就会直接进入老年代。如果Survivor空间中相同年龄的对象占用了一半以上的空间,年龄大于等于这个年龄的所有对象也会直接进入老年代。Survivor空间默认情况下只占用新生代的10%,极端情况下Minor GC 之后所有多向都会存活,为了安置这些对象需要用老年代的空间进行担保,-XX:HandlePromotionFailure参数允许担保失败,这种情况下即使老年代的空闲空间没有新生代所有对象大也会进行Minor GC,GC失败则会触发Full GC,这是最坏的情况。这个参数后来已经被废弃,JDK实现改为只要老年代空闲连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC。

虚拟机执行子系统

字节码文件结构

类型 名称 数量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interface_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

Class文件是一组以8位字节为基础单位的二进制流,中间没有任何分隔符,超过8为的数据项按照大端序的方式排列。Class文件采用类似C语言结构体的伪结构来存储数据,伪结构中只有无符号数和表两种数据类型。u1,u2,u3,u4,u8分表表示1,2,4,8个字节的无符号数。”_info”结尾的表示表,表由无符号数或者表组成。

Class文件的前4字节是”CAFEBABE”表示文件是能被虚拟机接受的文件。随后的4个字节表示版本号,前两个字节是次版本号,后两个字节是主版本号。随后2字节表示常量池容量,为了表示“不引用任何常量池项目”,常量池计数从1开始。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic Reference)。整个常量池实际上就是各种常量索引的拼接以尽量达到复用的目的。

整个Class文件信息包括:

  1. 4字节魔术“CAFEBABE”
  2. 4字节版本号,前2字节次版本号,后2字节主版本号。主版本号从45开始,表示JDK 1.0。
  3. 常量池表。有常量项数量,常量项组成。所有的具体常量项都是以一位的标志位开始,表明当前常量属于哪一类,每一种常量项的结构又各不相同。JDK 1.7时已经有14种常量类型。
  4. 类的限制信息,位与合并表示。
  5. 类信息,父类信息,接口信息的索引。
  6. 字段信息。包括修饰符,字段的描述信息,额外属性信息。
  7. 方法信息。几乎和字段信息一致。多了用于表示方法代码的属性表Code。Code中包含属性表长度,最大栈深度,局部变量表所需存储空间,字节码指令长度,字节码指令采用1字节表示。指令之后是异常表。
  8. 异常属性,记录可能抛出的受检异常。
  9. 行号,记录字节码和源码行号对应关系,用于打印异常信息的行号。
  10. 本地变量表,记录栈帧变量和源码变量的对应关系,用于方法被引用时提供有意义的变量名。
  11. 源文件名称。
  12. 常量值。Sun Javac编译器里,如果属性被static和final修饰,并且是基本数据类型或者String,则使用常量值来为属性赋值。实际上虚拟机规范中并没有要求final关键词。
  13. 内部类。记录内外部类的限定名,以及内部类的访问标志。
  14. 过时,合成标志。分别用来表示类是否有@Deprecated注解和是否由编译器自动生成。除了实例构造器\<init>和类构造器\<clinit>,其他由编译器生成的代码应该设置Synthetic标志或者ACC_SYNTHETIC标志。
  15. 栈映射表。通过记录一系列验证类型来跳过之前运行期通过数据流分析验证字节码的步骤,提升字节码验证的性能。
  16. 签名。记录了泛型信息。Java的泛型是一个伪泛型,在编译成字节码之后泛型信息都会被清除,这样实现起来最简单,也能节约一些类型所占用的内存空间,但是运行期反射无法获得泛型信息。签名信息就是为了弥补这个缺陷。
  17. BootstrapMethods,记录invokedynamic指令的引导方法限定符。

字节码文件一直处于稳定的状态,相对于语言,API等没有发生大的变化,目前发生的变化都是集中在本来就设计为可动态拓展的数据结构中。字节码文件平台无关性是JVM想要实现平台,语言无关的基础。

字节码指令

JVM采用了面向操作数栈而不是寄存器的架构,虚拟机指令由一个字节长的操作码和参数操作数组成,大部分操作码没有参数。Class文件放弃了编译后代码的操作数长度对齐。这两个特点体现了Java面向网络,智能家电的设计初衷。

限于一个字节的长度,JVM并没有为每一种数据类型设置相关的指令。大部分指令都不支持byte,cahr,short,boolean。编译器在编译期或者运行期将byte,short带符号拓展称为int,将boolean,char零位拓展为int。

字节码指令主要分为:

  • 加载和存储指令,用于将数据在栈帧的局部变量表和操作数栈之间来回传输。
  • 运算指令,使用虚拟机的数据类型,会将byte,short,char,boolean转换为int。遵循IEEE 754规范。
  • 类型转换指令,虚拟机支持自动的宽化类型转换,窄化类型转换需要强制使用指令,可能会导致符号,量级的变化。
  • 对象创建和访问指令,创建对象和数组,对象和数组的创建访问方式不同。
  • 操作数栈管理指令
  • 控制转移指令,修改PC寄存器的值,控制执行的语句顺序。各种类型比较操作最终都会转化为堆int类型的比较操作。
  • 方法调用和返回指令
  • 异常处理指令
  • 同步指令,JVM的同步是使用Monitor实现的。方法表中ACC_SYNCHORIZED标志指示了方法是否为同步方法,如果是同步方法,线程会要求获取到Monitor,在正常返回或者抛出异常时释放。在处理synchorized方法时,编译器会自动产生一个异常处理语句,并在捕捉异常后释放Monitor。

类加载

类从被加载到虚拟机内存到卸载出内存会经历加载,验证,准备,解析,初始化,使用,卸载7个阶段。

类加载过程

虚拟机规范规定了5种必须立刻初始化类的时机:

  1. 遇到new,getstatic,putstaic,invokestatic这4条字节码指令时。对应的Java代码场景new实例化对象,调用或者设置非final的静态字段,调用静态方法。
  2. 对类进行反射调用的时候,如果类没有被初始化需要触发初始化。
  3. 初始化类时,如果父类没有被初始化,则需要先触发父类的初始化。
  4. 虚拟机启动时,初始化main方法入口所在的主类。
  5. MethodHandle最后的解析句柄对应的类没有被初始化则先初始化对应类。

这5种对类引用的方式被称为主动引用,除此之外引用类的方式都是被动引用。接口的初始化过程几乎和类一样,但是并不要求接口的父类全部完成初始化,只需要在用到父类接口时才会触发初始化。

类加载

虚拟机规范规定了类加载阶段要完成的3件事:

  1. 通过全限定名来获取一个类文件的二进制字节流
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个Class对象

加载完成之后字节码文件所含有的信息就会按照虚拟机自定义的格式储存在方法区,然后在内存中实例化一个Class对象,虚拟机规范没有规定这个对象必须在堆中,对于HotSpot虚拟机,这个对象存放在方法区。加载阶段未完成的时候连接阶段可能已经开始了。

验证

验证是连接的第一步,这一阶段保证了字节码文件中包含的信息符合虚拟机规范,不会有安全性隐患。验证阶段大致会完成4个动作:

  1. 文件格式验证,主要保证输入的字节流可以被正确的解析。
  2. 元数据验证,验证字节码信息的语义,保证其描述信息符合Java语言规范。
  3. 字节码验证,主要验证方法的逻辑,保证方法是合法自洽的。1.7之后增加了StackMapTable用于改进检查,并替代了类型推导的验证方式。
  4. 符号引用验证,对常量池中各种符号引用的信息进行匹配性校验。验证失败可能会抛出IncompatibleClassChangeError异常以及其子类。

验证阶段并不是必要的,如果所有代码都已经经过验证,可以通过-Xverify:none来关闭大部分类验证措施,缩短加载时间。

准备

准备阶段开始为静态变量分配内存并设置初始值,这些变量将会被分配在方法区中。实例变量则会分配在堆中。默认情况下静态变量会被初始化为零值,并在随后的类初始方法\<clinit>中修改为代码中的初始值。如果字段属性表中存在ConstantVlue,这一阶段会直接复制为ConstantValue指定的值。

解析

解析阶段是虚拟机将常量池符号引用替换为直接引用的过程。符号引用指的是一种以字面描述的方式表达所引用的目标,和虚拟机内存布局无关。直接引用是直接指向目标的指针或者句柄,和内存布局相关。虚拟机并未规定解析的时间,只是要求在执行操作符号引用的字节码指定之前先对符号引用进行解析,因此虚拟机可以在类加载时解析,也可以在使用时解析。

解析类或者接口时候,虚拟机会将符号引用传递给当前类的加载器去加载目标类,加载时触发验证过程可能会导致额外的加载情况。一旦加载失败解析过程就会失败。如果符号引用对应数组对象,先加载数组的元素类型,然后由虚拟机生成一个数组对象。加载完成之后,进行符号引用验证以保证当前类对引用类有足够的访问权限,否则抛出IllegalAccessError

对字段的解析首先会触发对字段所在类的解析。类解析完成之后按照类本身,父类接口,父类对象的顺序查找符合简单名称,字段描述符的字段。查找失败则返回NoSuchFieldError。实际的虚拟机实现中,如果父类和接口中有同名属性,可能会无法通过编译。对类方法的解析和对字段的解析大致相同,但是类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现索引的方法所在类是一个接口,则直接抛出异常。找到对应方法后验证权限。对接口方法的解析如果发现接口方法表中引用的类是一个类而不是接口,则抛出异常。

初始化

初始化是类加载的最后一步,Java代码在这一阶段才真正开始发挥作用。类构造器\<clinit>方法是由编译器自动收集类中静态变量复制语句和静态语句块合并产生的,因此也可能不存在。静态块可以为定义在其之前的静态变量赋值,但是不能访问。类构造器与实例构造器\<init>不同,不必显式得调用父类的构造器,虚拟机会保证父类的类构造器先于子类调用。所以java.lang.Object的类构造器一定是第一个运行的。接口中也可能会存在类构造器,但是接口的类构造器不要求父类接口的类构造器先执行。父类的静态语句块会先于子类的变量赋值操作。虚拟机会保证类构造器的线程安全,多个线程同时执行类构造器时,可能会造成多个进行的阻塞。阻塞线程被唤醒后并不会再次执行\

类加载器

JVM并没有规定如何获取一个字节码文件的二进制流,这个获取动作放在JVM外部实现,实现这个动作的模块称为类加载器。任何一个类的的唯一性不仅仅由类本身确定,还要加上加载器。

从JVM的角度来看只有两种类加载器,一种使用C++实现的Bootstrap ClassLoader,另一类由Java实现的其他类加载器。从开发者角度类加载器分为

  1. 启动类加载器Bootstrap ClassLoader,用于加载lib目录下且由虚拟机按照名字识别的类库。在Java代码中无法被直接引用。
  2. 拓展类加载器Extension ClassLoader,负责加载lib/ext目录中的所有类库
  3. 应用程序加载器Application Classloader,程序默认的加载器。加载用户类路径上所指定的类库。

如果一个类收到了类加载请求,它会首先委派父类加载器去完成。只有父类无法完成加载请求时,自加载器才会尝试自己去加载。称之为双亲委派模型。双亲委派模型是Java类型体系健壮性的一个重要保证。

双亲委派模型是一个推荐做法,但是并不是强制要求的,主要有3类情况会破坏这个情况:

  1. 双亲委派模型在JDK 1.2才引入,在此之前用户有很多自定义的类加载器。
  2. 当底层代码需要调用用户层代码时,底层类加载器无法加载用户层代码。为了解决这个问题,Java设计团队设计了线程上下文类加载器(Thread Context ClassLoader),如果创建线程时没有设置,线程会从父线程继承一个类加载器,如果全局都没有设置,这个类加载器就会是默认的应用程序类加载器。Java中所有涉及到SPI的加载动作都是采用这种方式。
  3. 程序动态性的需求,代码热替换等功能。

字节码执行引擎

栈帧

虚拟机栈桢中保存了方法调用所需要的全部信息,栈帧的大小在编译之后就已经确定,不会受到运行时变量的影响。栈帧中保存了局部变量表,操作数栈,动态链接,方法返回地址和一些额外的附加信息。

局部变量表 用于存放方法参数和方法内部定义的局部变量,以变量槽(Variable Slot)为基本单位,所有长度不超过32位的数据类型都使用一个槽保存,包括boolean,byte,char,short,int,float,reference,returnAddress。对于64位的数据类型则需要两个槽,只有long,double。对于64位变量使用时必须同时指定两个槽,任何单独访问其中一个槽的字节码都会在校验时抛出异常。

参数的传递通过局部变量表来实现,如果执行的是对象方法,局部变量表第0位默认存储当前对象的引用。槽是可以重用的,当PC寄存器的值超过某个变量的作用范围,这个变量所使用的槽就可以被重用。但是局部变量表并不一定会在变量超出作用域就会立刻复用变量槽。不同于实例对象,局部变量表并不会有准备阶段,如果没有初始化,即使直接生成字节码也会不能通过虚拟机校验。

操作数栈的最大深度在编译之后已经确定,同样32位及以下的数据类型栈1个栈容量,64位占2个栈容量。操作数栈中的元素类型必须和字节码指令完全匹配,否则无法通过校验。实际虚拟机的实现中会将操作数栈和局部变量有一部分共享重合区域。节省参数复制传递的过程。

动态链接常量池中的符号引用会有一部分在运行时才会转化位直接引用,称为动态链接。

方法返回地址方法开始执行后,有两种方式可以退出:遇到返回指令,不同的返回指令可能会有返回值给上层调用者也可能没有。另一种是异常返回,只要异常表中没有匹配的异常处理器就会异常退出。正常退出时返回地址可以是PC计数器的值,异常退出则要取决于异常表。方法退出实际上是把当前栈帧出栈,恢复上层方法栈帧,把返回值压入调用者栈帧,调整PC计数器。

附加信息虚拟机规范允许虚拟机实现增加一些规范之外的描述信息,这部分因人而异。

方法调用

方法调用阶段主要确定调用具体的哪一个方法,而不涉及方法内容。虚拟机提供了5中字节码指令用来调用方法:

  1. invokestatic,调用静态方法
  2. invokespecial,调用实例构造器,私有方法和父类方法
  3. invokevirtual,调用所有的虚方法,以及final方法
  4. invokeinterface,调用接口方法,在运行时确定具体实现者
  5. invokedynamic,运行时及析出限定符引用的方法,由引导方法决定

在解析阶段就可以唯一确定的方法称之为非虚方法,包括静态方法,私有方法,类实例构造器,父类方法。其余的方法除了final都称之为虚方法。对于非虚方法的调用在编译阶段就可以直接把符号引用转换为直接引用,这种调用方式称为解析调用。对于虚方法则是分派调用,根据方法宗量的不同又可以分为单分派和多分派。

在方法重载中,静态分配指的是依赖静态类型来选择合适的实现方法。静态类型指的就是对象的类类型。在有些情况下,编译器并不能确定唯一的方法,只能选择一个最合适的。重载方法的优先级:

  1. 形参实参静态类型相同的方法
  2. 没有形参符合实参的,基本数据类型自动向上类型转换
  3. 向上类型转换仍没有合适方法的,自动装箱
  4. 自动装箱类仍没有合适方法的,将实参转换为其父类接口或者父类
  5. 如果父类接口或者父类有两个以上相同优先级的,编译器报错。否则一直向上找,直到Object。
  6. 寻找可变参数的方法

invokevirtual指令具有多态查找的特性,这条指令首先会查找操作数栈的第一个元素所指向的实际类型C,然后按照描述符和方法名称查找C的实例方法和权限校验,没找到合适方法则继续按照继承关系向上查找。查找参数的实际类型就是Java中重写的本质,这种在运行期根据接收者实际类型确定方法的分派过程称之为动态分配。

Java是一门静态多分派,动态单分派的语言。静态多分配指的是在编译期间,编译器根据两个宗量,方法的接收者静态类型和方法参数的静态类型选择具体的方法符号引用。动态单分派指的是在运行阶段,虚拟机不会再受参数的影响,此时只会根据方法接收者的实际类型一个宗量来选择具体方法。

虚拟机的实际实现中,为了节约频繁搜索元数据的时间,常用的手段是在类的方法区中建立虚方法表和接口方法表,虚方法表中存放着各个方法的实际入口地址,如果重写了父类方法则指向子类实现地址,否则和父类指向同一个地址。具有相同签名的方法在虚方法表中具有一样的索引号。方法表在类加载的时候就会初始化完成。

代码编译和优化

Java语言的编译过程实际上由3类不同的编译器组成:

  1. 前端编译器,把java文件变成class文件。Sun的Javac。
  2. JIT编译器,把字节码转换为机器码。HotSpot VM的C1,C2编译器
  3. AOT编译器,直接把java文件转换为本地机器代码。GCJ

其中javac主要针对Java语言优化,部分Java语法糖是通过javac来实现的,但是为了使其他语言也可以享受到编译优化,虚拟机团队把针对性能的优化主要放在了JIT编译器中。

javac编译过程

在javac中,通过com.sun.tools.javac.parser.Scanner来实现词法分析,通过com.sun.tools.javac.parser.Parser进行语法分析,生成抽象语法树由com.sun.tools.javac.tree.JCTree表示,随后编译器就不会再操作源码文件,后续操作都是建立在抽象语法树上。填充符号表的过程由com.sun.tools.javac.comp.Enter完成,符号表储存了符号地址和符号信息。在语义分析中用于语义检查和产生中间代码,在目标代码生成阶段进行地址分配。

JDK的注解可能会修改抽象语法树中的任意元素,一旦元素被修改,编译器就会回到解析以及符号填充阶段,直到没有注解修改语法树。语法树是结构正确的源程序的抽象表示,还需要语义分析来判断逻辑正确性。

语义分析分为标注检查和数据控制流两部分。标注检查检查变量是否已被声明,数据类型是否对应,以及进行一些常量折叠,直接计算出简单表达式的结果。数据控制流主要检查变量被复制,受检异常以及一些编译器的专门动作。比如final关键字,final关键字信息再编译完成之后就会失去,和不带final修饰的变量在字节码上表现完全一致。

在解除语法糖之后会生成字节码,由com.sun.tools.javac.jvm.Gen类来完成。如果类没有构造方法,这个阶段会添加一个默认构造方法,并且会生成默认的类构造器\<clinit>和实例构造器\<init>方法。并且会替换一部分代码以优化程序。

Java中最常见的语法糖就是泛型,Java文件被编译成字节码文件时,泛型信息会被擦除,并会在相应地方插入强制类型转换代码。这种实现称之为伪泛型。伪泛型的存在并不是为了提升性能,而是为了提升语义的准确性,避免程序员开发过程中可能出现的错误。伪泛型并不能称为方法重载的判断依据,甚至会导致不同编译器不同的行为,在Sun JDK 1.6的javac编译器中只要方法返回值不同,仍然可以通过编译。但返回值并不参与重载时方法的选择过程,只要方法描述符不同,这样的方法也可以合法存在于class文件中。实际上只是class字节码中Code表中擦除了参数类型,为了通过反射获取参数化类型,JCP组织对虚拟机规范做了相应的修改,指定了Signature等参数用于保存字节码层面的方法签名,其中保存了参数化类型。

运行期优化

在部分商用虚拟机中,当虚拟机发现某个方法或者代码块运行特别频繁时,会将这部分代码认定为“热点代码(Hot Spot Code)”,并将其编译为与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器就是即时编译器(Just In Time Compiler)。

HotSpot VM中采用解释器与编译器并行的架构,当程序启动时解释器首先发挥作用,节省编译时间,程序运行后,编译器开始将热点代码编译为本地机器码。编译器会根据概率选择一些大多数情况都能提升性能的优化手段,当优化手段遇到了罕见情况可以通过逆优化退回到解释状态执行。HotSpot中JIT编译器有C1编译器(Client Compiler)和C2编译器(Server Compiler)两个。可以通过-client-server指定具体的JIT编译器。

由于JIT需要解释器提供性能参数,这对解释执行的速度会造成影响。HotSpot会采用分层编译的策略:

  • 第0层,程序解释执行,解释器不开启性能监控,可触发第1层编译
  • 第1层,也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如果有必要就加入性能监控
  • 2层以及以上,C2编译,启用一些编译耗时长的优化,或者根据监控信息进行激进优化。

热点代码有两类,被多次调用的方法和多次执行的循环体。不论哪种都会编译整个方法。其中后者的编译过程发生在方法执行的过程中,因此称为栈上替换(OSR)。对于热点的判断主要有两类:

  1. 基于采样的热点探测,虚拟机会周期性的检查线程的栈顶,如果有某个方法经常出现在栈顶就被认定为热点方法。这种实现方式简单,并且容易获取方法调用关系,但是容易受到线程阻塞的影响。
  2. 基于计数器的热点探测,为每个方法或代码块建立计数器,统计方法的执行次数,如果超过阈值就认为是热点方法。统计结果更准确。

在HotSpot中采用的第二种方法。并为每隔方法准备了两类计数器,方法调用计数器和回边计数器。第一类计数器可以通过-XX:CompileThreshold指定阈值,默认在Client模式下是1500,在Server模式下是10000次。当方法被调用时,虚拟机先检查是否存在JIT编译版本,如果存在则使用编译后的本地代码运行,如果不存在,则将计数器加1,然后判断两个计数器和是否超过方法调用计数器的阈值,如果超过则提交一个JIT编译请求。默认情况下,解释器并不会阻塞等待JIT编译,而是继续以解释器的模式运行。当编译工作完成后,方法调用的入口地址就会被改写成新的。

默认情况下,方法计数器统计的是半衰周期内方法的执行频率。如果超过一定时间限度,方法调用次数仍然不足以触发JIT编译,计数器阈值就会降低一半。这个过程在垃圾收集时触发,可以使用-XX:-UseCounterDecay关闭。关闭热度衰减后,方法计数器统计绝对次数,只要程序运行时间足够长,所有代码都会被JIT编译。

回边计数器统计了控制流向后转的次数,这是为了触发栈上替换。虚拟机提供了-XX:OnStackReplacePercentage来间接调整回边计数器阈值。其计算公式为:

  • Client模式,$方法调用计数器阈值 \times OSR比率 /100$默认为13995
  • Server模式,$方法调用计数器阈值 \times (OSR比率-解释器监控比率) /100$默认为10700

与方法计数器不同的是,回边计数器没有热度衰减,并且在提交JIT编译请求时回将计数器值调低,以便在解释器中执行循环。

在C1编译器中主要关注局部性能的优化,放弃了许多耗时较长的全局优化手段,JIT编译分为3部分:

  1. 前端编译器将字节码转为高级中间代码表示
  2. 后端从高级中间代码中产生低级中间代码表示
  3. 后端在低级中间代码表示中分配寄存器,并做优化,产生机器码

C2编译器会使用所有的经典优化动作,这会增加编译时间,但是输出代码质量提高会抵消这部分消耗。

  • 公共子表达式消除。已经计算过的表达式,在变量不会发生变化时可以直接用之前计算结果替代表达式。
  • 数组边界检查消除。自动对数组的上下界进行检查,为了避免每次运行期间检查可以在编译期间检查或者转换为隐式异常处理。
  • 方法内联。方法内联实际上将被调用的方法复制到调用者方法内部,避免发生方法调用。也是后续其他优化的基础。虚拟机引入了类型继承关系分析(CHA),在编译器尝试内联时如果是非虚方法,不涉及到解析分派可以直接内联。否则查询CHA,如果查询结果只有一个版本也可以内联,如果后续载入了新的实现类,查询结果增加了新的版本就需要回退到解释执行或者重新编译。如果查询到多个版本则尝试使用内联缓存进行内联。
  • 逃逸分析。逃逸指的是一个方法的局部对象可能被其他线程访问到或者被其他方法访问到。如果对象不会逃逸,就可以对它进行高效的优化。包括栈上分配,同步消除,标量替换。但是目前逃逸分析计数并不成熟,可能导致分析时间超过性能优化节约的时间。

高效并发

Java内存模型

虚拟机规定了8中对于内存间交互的命令:

  1. lock,锁定主内存变量,使变量变为线程独占。
  2. unlock,解除锁定。
  3. read,从主内存中读取一个变量
  4. load,将主内存中读取的变量副本存到工作内存
  5. use,把工作内存中的变量传递给执行引擎
  6. assign,把执行引擎中的值赋给工作内存中变量
  7. store,把工作内存中的变量传递到主内存
  8. write,把传递到主内存的变量写入主内存

基于以上8中命令,还有一些规则:

  • read/load,store/write必须成对出现
  • 线程的assign不允许丢弃,变量被改变后必须写回到主内存
  • 线程不允许无原因的将工作内存变量同步到主内存
  • use,store之前必须经过assign和load
  • 主内存变量只能被一个线程锁定,可以多次锁定
  • lock会清除工作内存中对应变量,必须重新load,assign
  • 只能unlock被本线程lock的变量
  • unlock之前,必须先store,write

volatile关键字保证了变量对所有线程的可见性,实际上是通过Java内存模型中对volatile定义特殊规则实现的:

  1. 线程对volatile变量的load,use必须连续出现。保证线程能看见其他线程的修改。
  2. 线程的assign和store必须连续出现。保证线程的修改立刻写入主内存。
  3. 如果a线程先use,assign变量,则a线程一定先read,write。保证volatile不会被指令重排。

虚拟机规范要求前8中命令都具有原子性,但是对于64位数据类型如果没有被volatile修饰,对其读写操作可以划分为两次32位操作。但是实际JVM实现中仍然将所有操作是为原子命令。

先行发生原则(happens-before)是Java内存模型中定义的两项操作之间的顺序关系,A先行发生于B,则A所产生的影响能被B观测到。Java语言中存在几种先行发生关系,除此之外的情况,指令可能被虚拟机重排:

  1. 程序次序规则,按照控制流顺序,写在前面的先于后面。
  2. 管程锁定规则,同一个锁的unlock先于lock
  3. volatile规则,对于volatile的写先于对其的读
  4. 线程启动规则,start方法先于线程的其他方法
  5. 线程结束规则,线程的所有方法都先于对此线程的终止探测
  6. 线程中断规则,interrupt方法先于interrupted方法
  7. 对象终结规则,构造函数先于finalize方法
  8. 传递性,以上规则具有传递性

Java线程在 JDK 1.2之后线程模型改为基于操作系统原生线程模型来实现,因此目前虚拟机的线程映射取决于平台。在Windows和Linux上都是一对一的线程模型,在Solaris可以通过参数指定。其线程调度模型采用了抢占式线程调度,和协同是线程调度相比,抢占式的线程执行时间式操作系统可控的,不会出现一个线程的阻塞导致整个系统的崩溃。Java线程中可以通过优先级类增加线程运行的机会,但是线程优先级可能无法和操作系统原生优先级对应,并且可能会被操作系统修改。

Java中最基本的互斥同步手段synchorized经过编译后会产生两个字节码指令monitorenter,monitorexit。这两条指令都需要一个引用类型变量来指定锁定解锁的对象。如果synchorized没有指定参数,则实例方法锁定当前对象,静态方法锁定当前class对象。虚拟机规范中synchorized是可重入的,但是会阻塞其他线程的执行。Java线程是映射到操作系统原生线程中的,如果要唤醒被阻塞的线程,需要切换到核心态,这种切换需要消耗时间,因此synchorized是重量级操作。

java.util.consurrent.ReentrantLock也可以实现同步并主要又以下3个高级特性:

  1. 等待可中断。如果长期获取不到锁,等待线程可以转为处理其他事情。
  2. 公平锁。等待线程按照等待时间依次获取锁。
  3. 锁绑定多个条件。

在JDK 1.5 以前synchorized的性能要远比Lock差,但是后续版本的JDK不对针对synchorzied进行优化,现在已经和Lock相差无几。

随着处理器的发展,乐观锁计数逐渐出现,并且乐观锁可以避免线程的阻塞带来更高的性能。JDK 1.5后Java中支持了CAS操作。这些方法经过编译后会直接产生一条平台相关的CAS指令。Java的CAS操作由Unsafe类提供,但是类中限制了只有Bootstrap ClassLoader加载的类才能直接调用,也就是说这个类只能通过反射来实现客户端的直接使用,或者通过Java的API来实现间接的调用。但是CAS模型有一个漏洞,不能解决ABA问题,即经过两次修改变量值又变回原样。

JDK 1.6后对锁进行了一系列的优化,包括:

  1. 适应性自旋锁,如果一个锁对象上如果前一次自旋成功获取到锁,这一次就乎允许多自旋一段时间,否则就可能会省略自旋。
  2. 锁消除,如果通过逃逸分析,发现某些变量只能在本线程内访问,则可能会消除锁。
  3. 锁粗化,如果在循环中反复加锁解锁,则可能把加锁范围扩大
  4. 轻量级锁,尝试将对象头信息拷贝到锁空间,并通过CAS修改对象头为当前栈帧中锁空间。如果失败则轻量级锁不再有效要膨胀为重量级锁。
  5. 偏向锁,在对象头中记录之前获取锁的线程id,如果下一个需要获取锁的仍是原线程,则不需要同步直接获取锁。直到有新的线程竞争。