初识Java虚拟机 - JVM

Posted 汇通达 技术研发中心

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了初识Java虚拟机 - JVM相关的知识,希望对你有一定的参考价值。

JVM是什么?


输入

    JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM有自己完善的硬件架构,如处理器、堆栈等,还具有相应的指令系统。其本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。

(本文末有优化实战案例)

JVM运行时数据区域

    Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁时间。


1.       程序计数器

    程序计数器时一块较小的内存,它可以看做是当前线程所执行的字节码的行号指示器。每条线程都需要一个独立的程序计数器,各条线程之间的程序计数器互不影响,是线程私有的内存。


2.       Java虚拟机栈

    Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。


3.       本地方法栈

    本地方法栈与虚拟机栈的作用非常相似,它们之间的区别在于虚拟机栈为虚拟机执行Java方法服务,而本地方法栈是为虚拟机使用到的Native方法服务。有的虚拟机直接把本地方法栈和虚拟机栈合二为一。


4.       Java堆

    Java堆(Java Heap)时Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

    Java堆去垃圾收集器管理的主要区域,因此很多时候也称为GC堆,从内存回收的角度来看,由于现在收集器采用的是分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点有Eden空间、From Survivor空间、To Survivor空间。


5.       方法区

    方法区是各个线程共享的区域,它用于存储已被虚拟机加载的类信息、常亮、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开。


6.       运行时常量池

    运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。


图:Java虚拟机运行时数据区

GC算法和垃圾收集器

    垃圾收集器(Garbage Collection, GC),这项技术在1960诞生于MIT的Lisp语言,经过大半个世纪的发展,目前内存的动态分配与内存回收技术已经相当成熟。在JVM中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不絮地执行着出栈和入栈操作。因此这几个区域的内存分配和回收都具备确定性,无需过多的考虑回收的问题。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存不一样,一个方法中的多个分支需要的内存也可能不一样,只有在运行期间才能确定会创建哪些对象,这部分的内存的分配和回收都是动态的,垃圾收集器所关注的也是这部分内存。

对象存活判断


    在垃圾收集器在对堆进行回收之前,需要判断哪些对象还“存活”,哪些已经“死去”!通常有两种算法来判断对象的是否可回收。


引用计数器法:

    给对象中添加一个引用计数器,每当一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时候计数器为0的对象就是不可能再被使用的。


可达性分析算法:

    算法的基本思路是通过一系列的称为“GC ROOT”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC ROOT没有任何引用链相连时,则证明此对象是不可用的。

    在Java语言中,可作为GC ROOT根的对象包括下面几种:

*   虚拟机栈(虚拟机栈中的本地变量表)中引用的对象

*   方法区中类静态属性引用的对象。

*   方法区中常量引用的对象。

*   本地方法栈中JNI(即一般说的Native方法)引用的对象。

垃圾收集算法

标记 - 清除算法

    最基础的收集算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。

    有两个不足点:一个是效率问题,标记和清除的效率都不高;另一个是空间的问题,标记清除之后会产生大量不连续的内存碎片。

初识Java虚拟机 - JVM

标记 - 清除算法示例图


复制算法

    将内存划分为两块相同大小的区域,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块上,然后把已使用的内存空间一次性全清除掉。

    商用虚拟机都采用这种算法来回收新生代,但并不是按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden区和两块Survivor区,默认比例8:1。当有回收时,将Eden区和Survivor中还存会的对象一次性复制到另一块Survivor空间上,最后清理掉Eden区和刚才用过的Survivor区空间。

初识Java虚拟机 - JVM

复制算法示例图


标记 - 整理算法

    算法的标记过程任然与“标记 - 清除”算法一样,但后续的步骤不是直接对可回收对象进行清理,而是让所有存回的对象都像一端移动,然后清理掉端边界以外的内存。

初识Java虚拟机 - JVM

标记 - 整理算法示例图


分代收集算法

    根据对象的存活周期的不同将内存划分为几块,一般把Java堆分为新生代和老年代,根据各个年代的特点采用合适的收集算法。在新生代中,每次垃圾收集都会发现大批对象死去,只有少量存活,那就选用复制算法,只需要少量的Survivor区空间即可。而老年代因为对象存活比率高,没有额外的担保空间,所以必须使用“标记 - 清理”或者“标记 - 整理”算法来进行回收。

垃圾收集器

    如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。由于不同的版本,厂商的虚拟机所提供的的垃圾收集器可能会有较大差异,这里讨论的收集器基于JDK1.8的HotSpot虚拟机,这个虚拟机所含的所有收集器如图所示。

初识Java虚拟机 - JVM

Serial收集器

    Serial收集器时最基本和时间最悠久的收集器,在JDK1.3.1之前是新生代的唯一选择,这个收集器是一个单线程收集器,但它的单线程不仅仅指只使用一个CPU或者一条收集线程,而且在它进行垃圾回收时,必须暂停其他所有工作线程,直到它收集结束。


ParNew收集器

    ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器一致。

ParNew收集器是使用-XX:UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。

ParNew默认开启的收集线程数量与CPU的数量相同,在CPU非常多的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集线程数。


Parallel Scavenge收集器

    Parallel Scavenge收集器是一个新生代收集器,采用复制算法,并且是多线程收集器。它的关注点和其他收集器不一样,它的目标是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU的总消耗时间的比值,即吞吐量= 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。

    高吞吐量可以更高效率的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。


Serial Old收集器

    Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。它主要有两大用途,第一是在JDK1.5以及之前与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备预案。


Parallel Old收集器

     Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法,这个收集器在JDK1.6中才开始提供。


CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器,基于“标记 - 清除”算法实现的,它的运作过程分为4个步骤,包括:

*  初始标记(CMS initial mark)

*  并发标记(CMS concurrent mark)

*  重新标记(CMS remark)

*  并发清除(CMS concurrent sweep)

    其中,初始标记和重新标记任然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程;而重新标记阶段则是为了修正并发标记期间,因为用户线程继续运作而导致标记产生变动的那一部分对象重新标记,这个阶段的停顿时间通常会比初始标记阶段稍长;最后是并发清除阶段,清理删除掉标记阶段判断的已死亡的对象,这个阶段是和用户线程并发执行的。

    优点:并发收集,低停顿时间。正如官方文档里称之为“并发低停顿收集器”(Concurrent Mark Sweep)。

    缺点:

    1. CMS收集器对CPU资源敏感,它虽然不会导致用户线程停顿,但是因为会占用一部分线程(或者称为CPU资源)而导致应用程序变慢,总吞吐量会降低。

    2. 无法处理浮动垃圾,由于CMS在并发清理阶段用户线程还在运行着,在程序运行期间自然会产生新的垃圾,这一部分垃圾产生于标记过程之后,CMS无法在当次收集中处理它们,只好留到下一次GC再清理掉。这一部分垃圾即被称为浮动垃圾。

    3. 基于“标记 - 清除”算法实现的收集器,收集结束会产生大量的空间碎片。


Garbage First(G1)收集器

    G1是目前垃圾收集器技术发展的最前沿成果之一,它开创了收集器面向局部收集和设计的思路和基与Region的内存布局形式。

初识Java虚拟机 - JVM

G1收集器堆内存划分示例图



与其他GC收集器相比G1收集器有以下特点:


支持停顿时间模型

    停顿时间模型的意思是能够支持指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎是实时Java(RTSJ)的中软实时垃圾手机器特征了。


Region内存布局

    G1开创了基于Region的堆内存布局形式,虽然G1也任然遵守分代收集理论设计,但其堆内存的布局与其他收集器差异明显。G1不再坚持固定大小以及固定数量的分代区域划分,而是把Java堆划分为多个大小相等的独立区域(Region),每一个Region根据需要,扮演新生代Eden空间、Survivor空间,或者老年代空间。Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过一个Region的容量一半的对象即可判定为大对象。


动态的回收收益

    G1收集器可以跟踪每个Region里面的牢记堆积的“价值”大小,价值即回收所获得的的空间大小以及回收所需要时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间。优先处理回收价值收益最大的那些Region,这也是“Garbage First”名字的由来。这种方式保证了G1收集器在有限的时间内获取尽可能高的收集效率。


算法优势

    与CMS的“标记 - 清除”算法不同,G1从整体来看是基于“标记 - 整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记 - 复制”算法实现,无论如何,这两种算法都意味着G1在运行期间不会产生内存空间碎片,垃圾手机完成之后能够提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续的内存空间而提前触发下一次收集。


实战:IDEA运行速度调优

    可能大家觉得系统调优一般都是针对服务端应用而言的,普通Java开发人员很少有机会实践。今天就通用一个Java开发人员日常工作中经常使用的开发工具开做一次调优实战。


    我在日常工作中的主要IDE工具是IntelliJ IDEA,由于安装的插件较多,项目代码也比很多,所以运行速度不是特别令人满意,所以决定对其进行调优。

    IDEA的运行平台是64位Windows10系统,虚拟机为HotSpot 1.8 b64。硬件为Intel i7-10510U,8GB物理内存。


初始JVM参数配置如下
-Xmx512m

-XX:ReservedCodeCacheSize=240m

-XX:SoftRefLRUPolicyMSPerMB=80

-ea

-Dsun.io.useCanonCaches=false

-Djava.net.preferIPv4Stack=true

-XX:+HeapDumpOnOutOfMemoryError

-XX:-OmitStackTraceInFastThrow


    为了方便与调优后的结果做对比,在开始前先做一组初始数据测试。

    由于无法得知IDEA启动的准确耗时,我们通过VisualGC收集到的信息,总结初始配置下的测试结果:


    最后一次启动的数据样本中,垃圾收集总耗时3.102秒,其中:

*  FULL GC被触发了6次,共耗时1.109秒。

*  Minor GC被触发了89次,共耗时1.933秒。

*  加载类41148个,耗时28.908秒。

*  虚拟机的512MB堆内存被分配为50MB的新生代(40MB的Eden区和两个5MB的Survivor区)和462MB的老年代。

初识Java虚拟机 - JVM

图:VisualVM监控



1、编译时间和类加载优化

    通过测试数据可以看到,加载类和编译的时间非常耗时,而在其中字节码校验耗时占较大的比例。IDEA作为一款广为使用的成熟产品,它的编译代码我们可以认为是安全可靠的,不需要在加载过程中再进行字节码验证,因此可以通过-Xverify:none参数禁止掉字节码校验过程。

初识Java虚拟机 - JVM

图:类的生命周期


    在加入这个参数后,类的加载速度得到了一定的提升,加载时间缩减到19秒左右。


初识Java虚拟机 - JVM


2、调整内存设置控制垃圾收集频率

    下面我们要对“GC时间”进行调整优化,“GC时间”是最为重要的一块,这不单单是因为它消耗的时间较长,而是因为垃圾回收是一个稳定而持续的过程。在当前的测试用例中,加载类和即使编译的时间所占比例看上去比较高,但是在绝大多数的应用中,不可能出现持续的类加载和卸载过程。程序在运行了一段时间后,随着热点方法被不断的编译,新的热点方法数量也会下降,这会让类加载和即时编译所占的时间比例随着运行时间的增加而逐渐下降。但是垃圾回收却是随着程序运行而持续运作的,所以它才是对性能影响最重要的部分。

    从测试的样本中来看,在IDEA启动过程中,共发生了6次FULL GC和89次Minor GC,一共95次GC造成了约3秒的停顿。

    首先来分析新生代的Minor GC,尽管垃圾收集时间只有不到2秒,但是发生了89次之多。由于每次垃圾回收都需要用户线程跑到最近的安全点然后挂起来等待垃圾回收,频繁的垃圾收集必然会导致很多没有必要的线程挂起和恢复动作。

    新生代垃圾收集频繁很明显是由于虚拟机分配的新生代空间太小导致的,Eden区加上一个Survivor区才45MB。所以我们通过-Xmn参数指定新生代大小为128MB。

        再来看那6次FULL GC,虽然触发次数较少,但是平均每次的耗时要比较于Minor GC要高的多,所以降低垃圾收集的停顿时间的主要目标就是要降低FULL GC时间。我们从GC日志中分析得到FULL GC的原因,从GC日志中截取FULL GC部分日志。


[Full GC (Metadata GC Threshold) 2020-05-23T14:55:15.403+0800: 4.363: [Tenured: 56560K->54011K(77824K), 0.0691307 secs] 71164K->54011K(123904K), [Metaspace: 34482K->34482K(1081344K)], 0.0692415 secs] [Times: user=0.06 sys=0.00, real=0.07 secs]


[Full GC (Metadata GC Threshold) 2020-05-23T14:55:15.403+0800: 4.363: [Tenured: 56560K->54011K(77824K), 0.0691307 secs] 71164K->54011K(123904K), [Metaspace: 34482K->34482K(1081344K)], 0.0692415 secs] [Times: user=0.06 sys=0.00, real=0.07 secs]


[Full GC (Metadata GC Threshold) 2020-05-23T14:55:17.319+0800: 6.279: [Tenured: 67919K->65602K(90020K), 0.1222525 secs] 78376K->65602K(136100K), [Metaspace: 55927K->55927K(1099776K)], 0.1224057 secs] [Times: user=0.13 sys=0.00, real=0.12 secs]


[Full GC (Metadata GC Threshold) 2020-05-23T14:55:19.520+0800: 8.480: [Tenured: 81857K->76282K(109340K), 0.1617177 secs] 105366K->76282K(155420K), [Metaspace: 91958K->91958K(1132544K)], 0.1618629 secs] [Times: user=0.19 sys=0.00, real=0.16 secs]


[GC (Allocation Failure) 2020-05-23T14:55:29.399+0800: 18.360: [DefNew: 46080K->3039K(46080K), 0.0338515 secs]2020-05-23T14:55:29.433+0800: 18.394: [Tenured: 129641K->112262K(129828K), 0.3193513 secs] 170759K->112262K(175908K), [Metaspace: 147845K->147845K(1183744K)], 0.3535963 secs] [Times: user=0.36 sys=0.00, real=0.35 secs]


[GC (Allocation Failure) 2020-05-23T14:55:40.032+0800: 28.992: [DefNew: 46080K->5120K(46080K), 0.0528825 secs]2020-05-23T14:55:40.085+0800: 29.044: [Tenured: 198033K->176461K(198052K), 0.4157306 secs] 231627K->176461K(244132K), [Metaspace: 194691K->194691K(1224704K)], 0.4690424 secs] [Times: user=0.48 sys=0.00, real=0.47 secs]


    日志中加粗的部分代表着老年代的容量,几乎每一次FULL GC的原因都是老年代空间耗尽,每一次FULL GC都伴随着老年代空间的扩容:77824K → 90020K → 109340K → 129828K → 198052K。

日志中还显示有些时候内存回收效果不理想,空间扩容成了获取可用内存的最主要手段,比如这一句:[Tenured: 81857K->76282K(109340K), 0.1617177 secs]

    代表在老年代当前容量为109340KB,内存使用到81857K时发生了FULL GC,花费了0.3193513秒时间把内存使用降低到76282K,回收了5575KB的内存空间。但是这次垃圾回收未达到效果,触发了空间扩容。扩容相比起回收过程可以看做基本不需要花费时间,所以这0.3193513秒时间几乎是浪费了。

    由上述分析可以得到一个结论,FULL GC大多数是由于老年代容量扩展而导致的。那怎样避免扩容时的性能浪费呢,可以把-Xms参数设置为-Xmx参数值一样。将堆栈的空间固定下来,避免了运行时的自动扩展。

    由于元空间(Metaspace)的扩展也占据了一部分的垃圾收集时间,我们可以通过设置一个元空间初始值来避免掉一部分扩展。(由于元空间的默认最大值是不受限制的,即只受限于本地内存大小,故只调整初始空间大小)

注:截取的GC日志,显示元空间的扩展也消耗了部分GC时间。

[Metaspace: 55927K->55927K(1099776K)], 0.1224057 secs]
[Metaspace: 91958K->91958K(1132544K)], 0.1618629 secs]


根据以上的分析,优化方案如下:

*     新生代空间提升到128MB(-Xmn128m)

*   Java堆容量固定为512MB(-Xms512m、-Xmx512m)

*  元空间容量初始值设置为(-XX:MetaspaceSize=250m)


调整后的JVM配置
-Xms512m
-Xmx512m
-Xmn128m
-XX:MetaspaceSize=250m
-Xverify:none


    现在这个配置下,垃圾收集的次数已经大幅降低,只发生了34次Minor GC和1次FULL GC,总耗时1.823秒。


初识Java虚拟机 - JVM

GC调整后的运行数据


    从结果看,优化效果很明显,但是有一点疑问,从Old Gen的曲线上来看,老年代的空间直接固定在384MB,而内存使用量还不足以触发FULL GC。那一次FULL GC是怎么来的呢?查看GC日志来查明原因。

2020-05-23T16:02:56.546+0800: 21.430: [Full GC (System.gc()) 2020-05-23T16:02:56.546+0800: 21.430: [Tenured: 160328K->152291K(393216K), 0.4226741 secs] 221862K->152291K(511232K), [Metaspace: 143107K->143107K(1181696K)], 0.4228607 secs] [Times: user=0.42 sys=0.00, real=0.42 secs]

    原来是代码中显示的调用了System.gc()触发了垃圾收集,在内存设置调整后,这种显式的垃圾收集不符合我们的期望。在JVM参数中加入-XX:DisableExplicitGC屏蔽掉System.gc()。


3、选择收集器降低延迟

    通过查看启动期间的CPU使用情况,我们可以看到CPU的平均使用率并不高,垃圾收集的处理器使用率就更低了,几乎和横坐标紧贴在一起。这说明处理器资源还很富足。

初识Java虚拟机 - JVM

    Java虚拟机提供了多种垃圾收集器的组合,回顾之前介绍的几种垃圾收集器,很容易想到CMS是最符合当前场景的选择。在JVM配置中加入这两个参数:

-XX:+UseParNewGC、-XX:+UseConcMarkSweepGC;

要求虚拟机在新生代和老年代分别使用ParNew和CMS收集器来进行垃圾回收。

指定ParNew和CMS收集器后的GC数据


    再次测试后,新生代停顿553毫秒,老年代停顿96毫秒,总耗时降低为649毫秒。相比较于调整垃圾收集器前快了将近三倍之多。

    当然,由于CMS的停顿时间只是整个收集过程中的一小部分,大部分收集行为都是和用户线程并发执行的。所以这里并不是真的将收集时间降低到649毫秒了。

    到此为止,对于IDEA的JVM调优就结束了,我们终于可以愉快的使用IDEA进行工作了!最终的配置清单如下所示。

# custom IntelliJ IDEA VM options
-Xms512m
-Xmx512m
-Xmn128m
-XX:MetaspaceSize=250m
-Xverify:none
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=85
-XX:+DisableExplicitGC-XX:ReservedCodeCacheSize=240m
-XX:SoftRefLRUPolicyMSPerMB=80
-ea
-Dsun.io.useCanonCaches=false
-Djava.net.preferIPv4Stack=true
-XX:+HeapDumpOnOutOfMemoryError
-XX:-OmitStackTraceInFastThrow



技术研发中心

 汇通达 技术研发中心


以上是关于初识Java虚拟机 - JVM的主要内容,如果未能解决你的问题,请参考以下文章

初识JAVA

jvm系列-01初识虚拟机与java虚拟机

初识Java虚拟机 - JVM

Java虚拟机详解01----初识JVM

JVMJava虚拟机组成详解

深入理解jvm虚拟机一