九神带你入门JVM(上)

概述

本篇较长,九神带你从0入门JVM,全文包括包括JVM的分类、JVM垃圾回收综述、JVM的内存模型(Java 8)、对象存活判断、GC算法、常见的GC回收器、GC日志一共七个部分。

下面是一张基于Java 8的JVM思维导图,如果需要,请关注公号:“九神说编程”,回复“JVM”获取。

img

JVM的分类

对于新手,最大的认识偏差就是觉得只有一种JVM,这个认知是不对的。SUN公司有对JVM的规范,只要符合JVM规范大家都可以在此基础上开发出自己的虚拟机。事实上,我们现在最常用的HotSpot VM就不是SUN公司开发的。HotSpot VM是对JVM相关JSR(Java Specification Requests,Java规范)的一个RI(Reference Implementation,参考实现)。

关于相关的JSR可以在JCP(Java Community Process,Java社区组织)的官网上寻找。

如果你想知道你使用的Java虚拟机的种类,可以在java -version中查询:

img

1、HotSpot VM

HotSpot VM是绝对的主流。大家用它的时候很可能就没想过还有别的选择,或者是为了迁就依赖了Oracle/Sun JDK某些具体实现的烂代码而选择用HotSpot VM省点心。

Oracle / Sun JDK、OpenJDK的各种变种(例如IcedTea、Zulu),用的都是相同核心的HotSpot VM。 从Java SE 7开始,HotSpot VM就是Java规范的“参考实现”(RI,Reference Implementation)。把它叫做“标准JVM”完全不为过。

当在面试中问道“Java性能如何如何”、“Java有多少种GC”、“JVM如何调优”等等,默认问的就是特指HotSpot VM,可见其“主流性”。(其实这不是件好事,可能有些面试官自己都不知道VM需要分类,当我们讨论与JVM相关的问题是还是要具体到哪个VM才正确严禁)

JDK8的HotSpot VM已经是以前的HotSpot VM与JRockit VM的合并版,也就是传说中的“HotRockit”,只是产品里名字还是叫HotSpot VM。这个合并并不是要把JRockit的部分代码插进HotSpot里,而是把前者一些有价值的功能在后者里重新实现一遍。移除PermGen、Java Flight Recorder、jcmd等都属于合并项目的一部分。

不过要留意的是,这里我说的HotSpot VM特指“正常配置”版,而不包括“Zero / Shark”版。Wikipedia那个页面上把后者称为“Zero Port”。用这个版本的人应该相当少,很多时候它的release版在其指定OS上都build不成功!

下面是抄来的一段与之相关的历史:

提起HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。 但不一定所有人都知道的是,这个目前看起来“血统纯正”的虚拟机在最初并非由Sun公司开发,而是由一家名为“Longview Technologies”的小公司设计的; 甚至这个虚拟机最初并非是为Java语言而开发的,它来源于Strongtalk VM, 而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机, Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果,在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。

HotSpot VM既继承了Sun之前两款商用虚拟机的优点(如前面提到的准确式内存管理),也有许多自己新的技术优势, 如它名称中的HotSpot指的就是它的热点代码探测技术(其实两个VM基本上是同时期的独立产品,HotSpot还稍早一些,HotSpot一开始就是准确式GC, 而Exact VM之中也有与HotSpot几乎一样的热点探测。 为了Exact VM和HotSpot VM哪个成为Sun主要支持的VM产品,在Sun公司内部还有过争论,HotSpot打败Exact并不能算技术上的胜利), HotSpot VM的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。 如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。 通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序, 即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。

在2006年的JavaOne大会上,Sun公司宣布最终会把Java开源,并在随后的一年,陆续将JDK的各个部分(其中当然也包括了HotSpot VM)在GPL协议下公开了源码, 并在此基础上建立了OpenJDK。这样,HotSpot VM便成为了Sun JDK和OpenJDK两个实现极度接近的JDK项目的共同虚拟机。

在2008年和2009年,Oracle公司分别收购了BEA公司和Sun公司,这样Oracle就同时拥有了两款优秀的Java虚拟机:JRockit VM和HotSpot VM。 Oracle公司宣布在不久的将来(大约应在发布JDK 8的时候)会完成这两款虚拟机的整合工作,使之优势互补。 整合的方式大致上是在HotSpot的基础上,移植JRockit的优秀特性,譬如使用JRockit的垃圾回收器与MissionControl服务, 使用HotSpot的JIT编译器与混合的运行时系统。

2、J9 VM

J9是IBM开发的一个高度模块化的JVM,这也是我们在工作中可能会用到的另外一个VM(除了这两个,在中国的大厂里不大可能接触到其他的了)。

在许多平台上,IBM J9 VM都只能跟IBM产品一起使用。这不是技术限制,而是许可证限制。例如说在Windows上IBM JDK不是免费公开的,而是要跟IBM其它产品一起捆绑发布的;使用IBM Rational、IBM WebSphere的话都有机会用到J9 VM(也可以自己选择配置使用别的Java SE JVM)。

根据许可证,这种捆绑在产品里的J9 VM不应该用于运行别的Java程序,但是大家自己“偷偷的”拿来跑别的程序,IBM也没力气管。而在一些IBM的硬件平台上,很少客户是只买硬件不买配套软件的,IBM给一整套解决方案,里面可能就包括了IBM JDK。这样自然而然就用上了J9 VM。所以J9 VM得算在主流里,虽然很少是大家主动选择的首选。

J9 VM的性能水平大致跟HotSpot VM是一个档次的。有时HotSpot快些,有时J9快些。不过J9 VM有一些HotSpot VM在JDK8还不支持的功能,最显著的一个就是J9支持AOT编译和更强大的class data sharing。

3、Sun Classic VM

从名字就可以看出来这是SUN公司开发的第一款商用的虚拟机,现在此款虚拟机已经淘汰了。 这个虚拟机只能使用纯解释器的方式来执行Java代码。

4、Exact VM

这是一款被HotSpot VM给PK掉的VM,上面讲历史的时候讲过。它只存在了很短暂的时间,而且只在Solaris平台发布过。

5、JRockit VM

JRockit VM曾经号称“世界上速度最快的Java虚拟机” 。由于专注于服务器端应用,它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时 编译器编译后执行。

除此之外,JRockit的垃圾收集器和MissionControl服务套件等部分的实现,在众多Java虚拟机中也一直处于领先水平。这一套思想已经在Java 8中被HotSpot VM给用上了。

6、其他

其他的VM还有很多很多,比如Dalvik VM、Microsoft JVM、Azul VM、Liquid VM、Zing VM等等。但是这些VM和我们实际中不大会有交集,也不是历史,更不是HotSpot VM曾经的竞品,所以这里就不探讨了。

JVM垃圾回收综述

为了更快搞明白JVM里面的相关概念,先综述JVM的垃圾回收(基于Java 8)。

Hotspot VM的垃圾回收采用“分代回收”的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。

Hotspot VM将堆划分为不同的物理区,就是“分代”思想的体现。如图所示,JVM堆主要由新生代、老年代、元空间构成。

img

1、新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。

新生代内又分三个区:一个Eden区,一般而言有两个Survivor区,大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到第一个Survivor区。当第一个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到第一个Survivor区。

对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。

2、老年代(Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。

3、元空间(Metaspace):主要存放元数据,例如类元信息、字段、静态属性、方法、常量,还有运行时常量池等(不含字符串常量),与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响很小。

JVM的内存模型(Java 8)

JVM内存模型分为堆(heap)、元空间、栈、本地方法栈、程序计数器。

JDK8的内存模型如下图:

img

其中,堆和元空间是线程共享的,在Java虚拟机中只有一个堆、一个元空间,并在JVM启动的时候就创建,JVM停止才销毁。栈、本地方法栈、程序计数器是每个线程私有的,随着线程的创建而创建,随着线程的结束而死亡。

img

1. 本地方法栈

提供虚拟机使用到的本地Native方法服务。

2. 程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间。寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?

每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常。

特点:

  • 一块较小的内存空间
  • 线程私有
  • 是唯一一个不会出现OOM的内存区域

3. 栈(Stack)

JVM中的虚拟机栈是描述Java方法执行的内存区域,它是线程私有的。每个方法在执行的同时都会创建一个栈帧用于存储局部变量、操作数栈、动态链接、方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出stackoverflowError通常出现在递归方法中;如果虚拟机可以动态扩展,但是无法申请到足够的内存时,就会抛出outOfMemoryError异常。

img

4、堆

Heap存储着几乎所有的对象及数组,JVM8中把静态变量(字符串常量池)也移到堆区进行存储。

堆是OOM故障最主要的发源地,也是是垃圾回收的主要区域,所以也被称为GC堆。通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间。堆的内存空间既可以固定大小,也可运行时动态地调整,通过如下参数设定初始值和最大值,比如-Xms256M. -Xmx1024M。其中-X表示它是JVM运行参数,ms是memorystart初始堆容量的简称 ,mx是memory max最大堆容量的简称。但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力,所以在线上生产环境中,JVM的Xms和Xmx设置成一样大小,避免在GC后调整堆大小时带来的额外压力。

堆分成两大块:新生代和老年代,对象产生之初在新生代,步入暮年时进入老年代。新生代又分为1个Eden区+ 2个Survivor区,8:1:1的比例。绝大部分对象在Eden(意为伊甸园)区生成,当Eden区装填满的时候,会触发Young GC。垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收。依然存活的对象会被移送到Survivor(幸存者)区,这个区真是名副其实的存在。Survivor 区分为S0和S1两块内存空间,送到哪块空间呢?每次Young GC的时候,将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。

如果Young GC要移送的对象大于Survivor区容量上限,或者超大对象的阈值超过eden分配担保设置值的上限,则直接移交给老年代.如果老年代也无法放下,则会触发Full Garbage Collection(Full GC),如果依然无法放下,则抛OOM.。

假如一些没有进取心的对象以为可以一直在新生代的Survivor区交换来交换去,那就错了。每个对象都有一个计数器,每次Young GC都会加1。-XX:MaxTenuringThreshold参数能配置计数器的值到达某个阈值的时候,对象从新生代晋升至老年代。默认值是15,可以在Survivor 区交换14次之后,晋升至老年代。

堆出现OOM的概率是所有内存耗尽异常中最高的。出错时的堆内信息对解决问题非常有帮助,所以给JVM设置运行参数-XX:+HeapDumpOnOutOfMemoryError,让JVM遇到OOM异常时能输出堆内信息,使用-XX:HeapDumpPath参数指定dump路径。利用JVM参数-XX:OnOutOfMemoryError可以在发生OOM异常时,运行一个本机的脚本或指令。

img

5、方法区和持久代

注意,这部分并不存在与Java8,属于Java 8之前的东西。虽然Java 8移除了这部分,但是这里稍微回顾一下,可供大家比较不同,也防止万一面试官同学根本不懂,还在问方法区和持久代。

方法区中存放已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。这部分内容中的字符串变量在Java8倍丢到了堆里,其他的部分全部丢入元空间。

方法区与堆(Java Heap)一样,是各个线程共享的内存区域。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做非堆(Non-Heap)。他们的区别是堆存储对象数据,方法区存储静态信息。

img

持久代,即PermGen space的全称是Permanent Generation space,是指内存的永久保存区域,有人称之为永久代。需要知道的是,上述的方法区是JVM的规范,持久代是HotSpot VM对这一规范的实现。不同的Java虚拟机之间可能会进行类共享,因此持久代又分为只读区和读写区。

JVM用于描述应用程序中用到的类和方法的元数据也存储在持久代中。JVM运行时会用到多少持久代的空间取决于应用程序用到了多少类。除此之外,Java SE库中的类和方法也都存储在这里。我们把所有存储的内容总结如下:

  • JVM中类的元数据在Java堆中的存储区域。
  • Java类对应的HotSpot虚拟机中的内部表示也存储在这里。
  • 类的层级信息,字段,名字。
  • 方法的编译信息及字节码。
  • 变量
  • 常量池和符号解析

JVM 种类有很多,需要注意的是,PermGen space是Hotspot才有,JRockit以及J9是没有这个区域。

6、元空间

随着JDK8的到来,JVM不再有PermGen。但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory)中。

img

元空间由Klass Metaspace和NoKlass Mestaspace组成,其中:

  • Klass Metaspace:Klass Metaspace就是用来存klass的,klass是我们熟知的class文件在jvm里的运行时数据结构,不过有点要提的是我们看到的类似A.class其实是存在heap里的,是java.lang.Class的一个对象实例。这块内存是紧接着Heap的,和我们之前的perm一样,这块内存大小可通过-XX:CompressedClassSpaceSize参数来控制,这个参数默认是1G,但是这块内存也可以没有,假如没有开启压缩指针就不会有这块内存,这种情况下klass都会存在NoKlass Metaspace里,另外如果我们把-Xmx设置大于32G的话,其实也是没有这块内存的,因为会这么大内存会关闭压缩指针开关。还有就是这块内存最多只会存在一块。
  • NoKlass Metaspace:NoKlass Metaspace专门来存klass相关的其他的内容,比如method,constantPool等,这块内存是由多块内存组合起来的,所以可以认为是不连续的内存块组成的。这块内存是必须的,虽然叫做NoKlass Metaspace,但是也其实可以存klass的内容。

Klass Metaspace和NoKlass Mestaspace都是所有classloader共享的,所以类加载器们要分配内存,但是每个类加载器都有一个SpaceManager,来管理属于这个类加载的内存小块。如果Klass Metaspace用完了,那就会OOM了,不过一般情况下不会,NoKlass Mestaspace是由一块块内存慢慢组合起来的,在没有达到限制条件的情况下,会不断加长这条链,让它可以持续工作。

元空间的本质是对JVM规范中方法区的实现。不过元空间与持久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:

  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

下面讲一下元空间的特点:

  • 充分利用了Java语言规范中的好处:类及相关的元数据的生命周期与类加载器的一致。
  • 每个加载器有专门的存储空间
  • 只进行线性分配
  • 不会单独回收某个类
  • 省掉了GC扫描及压缩的时间
  • 元空间里的对象的位置是固定的
  • 如果GC发现某个类加载器不再存活了,会把相关的空间整个回收掉

最后讲一下元空间的内存分配模型:

  • 绝大多数的类元数据的空间都从本地内存中分配
  • 用来描述类元数据的类(klasses)也被删除了
  • 分元数据分配了多个虚拟内存空间
  • 给每个类加载器分配一个内存块的列表。块的大小取决于类加载器的类型; sun/反射/代理对应的类加载器的块会小一些
  • 归还内存块,释放内存块列表
  • 一旦元空间的数据被清空了,虚拟内存的空间会被回收掉
  • 减少碎片的策略

7、为什么移除持久代

对于老的Java程序员,我们都会遇到一个异常:java.lang.OutOfMemoryError: PermGen space 。说白了就是持久代内存溢出!

为什么会内存溢出呢?因为持久代用于存放Class和Meta的信息,Class在被 Load的时候被放入PermGen space区域,它和和存放Instance的Heap区域不同,所以如果你的APP会LOAD很多CLASS的话,就很可能出现PermGen space错误。这种错误常见在web服务器对JSP进行pre compile的时候。

而这个异常实质是暴露了这一设计根源的一个问题:

持久代大小受到-XX:PermSize和-XX:MaxPermSize两个参数的限制,而这两个参数又受到JVM设定的内存大小限制,这就导致在使用中可能会出现持久代内存溢出的问题

另外一方面,为了和JRockit进行融合而做的努力,JRockit用户并不需要配置持久代,所以HotSpot也移除了持久代。

根据上面的内外两方面原因,持久代最终被移除,方法区移至Metaspace,字符串常量移至Java Heap

你是否感觉技术要学的内容太多?永无止境?
你是否感觉已经学会很多,但是面试就挂?
非酱油已经启动互联网“零”计划
提供1对1辅导,带你从“零”到入职
中途不经过任何弯路,最短距离拿到高薪offer
到非酱油藏经阁修炼,工资最少涨2000!