JVM堆内存及垃圾回收简介

前言

我们知道在JVM内存模型中,堆是十分重要的一块,堆是内存占用最大,管理最复杂的一个区域,其用途就是存放生成的对象实例,所有的对象都会在堆上进行分配使用。

JDK1.8后,字符串常量池从永久代剥离了出来,也存放在了堆上。

正文

堆内存结构

我们来看一下堆内存结构,JDK1.8后JVM堆内存结构如下图:

upload successful

可以看到堆内存分为年轻代(Young Generation)、年老代(Old Generation)及元空间(MetaData Space)。

PS:JDK8 完全移除永久代(Permanent Generation), 取而代之的是元空间MetaData Space(JVM使用本地内存,存放类的元数据)。

年轻代(Young Generation)又分为 Eden Space 和 Survivor Space,其中Survivor区有两部分构成 Survivor 1 和 Survivor 2 。

JVM虚拟机默认Eden区和两块Survivor区的内存比例为8:1:1。

GC流程

年轻代内存的大致使用过程为:

年轻代将内存分为Eden和2块Survivor区(分别叫from和to)。

一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。

对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。

紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。

经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。

经过Minor GC之后,如果Survivor存放不下存活的对象,对象就会通过分配担保机制进入老年代,而如果老年代空间还不够,就会进行Full GC。

Minor GC会一直重复这样的过程,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

因此对象进入年老代有以下4种情况:

  • 经过Minor GC后,Survivor区存放不下存活的对象进入年老代。
  • 对象长期存活,当年龄达到一定阈值后进入年老代,默认15。年龄阈值,可以通过-XX:MaxTenuringThreshold来设置。
  • 大对象直接进入年老代,通过 -XX:PretenureSizeThreshold 参数可以进行设置多大的对象直接在年老代进行分配,从而避免大对象在年轻代(Eden和Survivor区)发生大量内存赋值操作。
  • 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。(动态对象年龄绑定)

GC的大致回收流程如下图:

upload successful

GC的回收分为垃圾的收集和回收两部分,收集和回收都涉及到一些算法逻辑,我们来整理下。

垃圾收集算法

JVM中常用的垃圾收集算法大致有两种,引用计数法和根搜索法。

  1. 引用计数法

    引用计数法本质是给对象添加引用计数器,当引用对象时计数器+1,引用失效时,计数器-1,当计数器等于0时,对象失效,内存可以被回收。

    但会有一个问题,如果A对象引用B对象,同时B对象又引用A对象,但它们都不会再被系统使用,则它们可认为为垃圾,但是它们的引用计数是永不为0的,因此该方法永远也不会将其标位垃圾。

    优点:实现简单高效。

    缺点:对象之间的互相循环引用问题不好解决。

  2. 根搜索法

    通过GC roots可达的对象路径称为引用链(reference chain),当一个对象没有引用链时(即从GC roots不可达)则视为不可用对象,内存可以被回收。

    JVM主要使用根搜索法进行垃圾收集。

    那在JVM中,哪些对象可以视为GC roots呢?

    • 虚拟机栈中(即栈帧中的本地变量)的引用对象;

    • 本地方法栈中的引用对象;

    • 方法区中的静态变量引用的对象和常量池中引用的对象。

垃圾回收算法

  1. 标记-清除算法

    分两步进行,第一步标记出可以回收的对象,第二步统一清理可以回收的对象内存。

    缺点:如果在被标记后直接对对象进行清除,会带来另一个新的问题——内存碎片化。如果下次有比较大的对象实例需要在堆上分配较大的内存空间时,可能会出现无法找到足够的连续内存而不得不再次触发垃圾回收。

  2. 复制算法

    此GC算法实际上解决了标记-清除算法带来的“内存碎片化”问题。首先还是先标记处待回收内存和不用回收的内存,下一步将不用回收的内存复制到新的内存区域,这样旧的内存区域就可以全部回收,而新的内存区域则是连续的。

    缺点:就是会损失掉部分系统内存,因为你总要腾出一部分内存用于复制。

  3. 标记-整理算法

    标记-压缩算法首先还是“标记”,标记过后,将不用回收的内存对象压缩到内存一端,此时即可直接清除边界处的内存,这样就能避免复制算法带来的效率问题,同时也能避免内存碎片化的问题。

  4. 分代收集算法

    对于JVM堆内存的垃圾回收,可以认为是分代收集算法。

    对于年轻代,大部分对象都不会存活,所以在新生代中使用复制算法较为高效。

    而对于年老代来讲,大部分对象可能会继续存活下去,如果此时还是利用复制算法,效率则会降低,此时使用标记-整理算法,不仅提高效率,更节约内存。

当然,具体使用哪种垃圾回收算法,也和垃圾收集器的实现有具体关系。

垃圾收集器

再来看一下JVM的几种垃圾收集器。

目前JVM有7种作用于不同分代的垃圾收集器。如下图:

upload successful

上图两个垃圾收集器之间的连线表示它们可以搭配使用。

  1. Serial收集器

    Serial收集器是最基本、发展历史最悠久的收集器。是单线程的收集器。它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集完成。

    Serial收集器依然是虚拟机运行在Client模式下默认新生代(年轻代)收集器,对于运行在Client模式下的虚拟机来说是一个很好的选择。

  2. ParNew收集器

    ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial 收集器完全一样。

    ParNew收集器是许多运行在Server模式下的虚拟机中首选新生代收集器,其中有一个与性能无关但很重要的原因是,除Serial收集器之外,目前只有ParNew它能与CMS收集器配合工作。

  3. Parallel Scavenge(并行回收)收集器

    Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

    该收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。

    停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可用高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

    Parallel Scavenge收集器提供两个参数用于精确控制吞吐量,分别是控制最大垃圾收起停顿时间的 -XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数

    Parallel Scavenge收集器还有一个参数:-XX:+UseAdaptiveSizePolicy。这是一个开关参数,当这个参数打开后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGVPauseMillis参数或GCTimeRation参数给虚拟机设立一个优化目标。

    自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

  4. Serial Old 收集器

    Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。

    如果在Server模式下,主要两大用途:

    (1)在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用。

    (2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

  5. Parallel Old 收集器

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

  6. CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

    CMS收集器是基于“标记-清除”算法实现的。它的运作过程相对前面几种收集器来说更复杂一些,整个过程分为4个步骤:

    (1)初始标记

    (2)并发标记

    (3)重新标记

    (4)并发清除

    其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”.

    CMS收集器主要优点:并发收集,低停顿。

    CMS三个明显的缺点:

    (1)CMS收集器对CPU资源非常敏感。CPU个数少于4个时,CMS对于用户程序的影响就可能变得很大,为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器变种。所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想。

    (2)CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在JDK1.6中,CMS收集器的启动阀值已经提升至92%。

    (3)CMS是基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多,可能会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发FullGC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间变长了。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的Full GC(默认值为0,表示每次进入Full GC时都进行碎片整理)。

  7. G1收集器

    G1收集器的优势:

    (1)并行与并发

    (2)分代收集

    (3)空间整理 (标记——整理算法,复制算法)

    (4)可预测的停顿(G1除处理追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经实现Java(RTSJ)的垃圾收集器的特征)

    备注:

    The Real-time Specification for Java (RTSJ) is an open specification that augments the Java language to open the door more widely to using the language to build real-time systems (see Related topics). Implementing the RTSJ requires support in the operating system, the JRE, and the Java Class Library (JCL).

    详见:RTSJ 中的Garbage collection的规范。

    使用G1收集器时,Java堆的内存布局是整个规划为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。

    G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获取的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的又来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽量可能高的收集效率。

    G1 内存“化整为零”的思路:

    在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会遗漏。

    如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为一下步骤:

    (1)初始标记

    (2)并发标记

    (3)最终标记

    (4)筛选回收

参考:https://www.cnblogs.com/chengxuyuanzhilu/p/7088316.html

JVM的一些参数

我们再来看下JVM的一些常用参数设置。

JVM的基础参数

  • -Xmx2048m:设置JVM最大堆内存为2048M。
  • -Xms2048m:设置JVM初始堆内存为2048M。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
  • -Xss128k:设置每个线程的栈大小。JDK5.0以后每个线程栈大小为1M,之前每个线程栈大小为256K。应当根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。需要注意的是:当这个值被设置的较大(例如>2MB)时将会在很大程度上降低系统的性能。
  • -Xmn1g:设置年轻代大小为1G。在整个堆内存大小确定的情况下,增大年轻代将会减小年老代,反之亦然。此值关系到JVM垃圾回收,对系统性能影响较大,官方推荐配置为整个堆大小的3/8。
  • -XX:NewSize=1024m:设置年轻代初始值为1024M。
  • -XX:MaxNewSize=1024m:设置年轻代最大值为1024M。
  • -XX:PermSize=256m:设置持久代初始值为256M。(1.7以下JDK版本有效)
  • -XX:MaxPermSize=256m:设置持久代最大值为256M。(1.7以下JDK版本有效)
  • -XX:MetaspaceSize=8m:初始元数据空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。(1.7以上JDK版本有效)
  • -XX:MaxMetaspaceSize=50m:元数据最大空间大小,默认是没有限制的。(1.7以上JDK版本有效)
  • -XX:NewRatio=4:设置年轻代(包括1个Eden和2个Survivor区)与年老代的比值。表示年轻代比年老代为1:4。
  • -XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的比值。表示2个Survivor区与1个Eden区的比值为2:4,即1个Survivor区占整个年轻代大小的1/6。
  • -XX:MaxTenuringThreshold=7:表示一个对象如果在Survivor区(救助空间)移动了7次还没有被垃圾回收就进入年老代。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代,对于需要大量常驻内存的应用,这样做可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代存活时间,增加对象在年轻代被垃圾回收的概率,减少Full GC的频率,这样做可以在某种程度上提高服务稳定性。
  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。(1.7以上JDK版本有效)
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。(1.7以上JDK版本有效)

PS:可以看到-Xmn,-XX:NewSize/-XX:MaxNewSize,-XX:NewRatio 3组参数都可以影响年轻代的大小,它们混合使用生效的优先级为:

高优先级:-XX:NewSize/-XX:MaxNewSize 
中优先级:-Xmn(默认等效  -Xmn=-XX:NewSize=-XX:MaxNewSize=?) 
低优先级:-XX:NewRatio 

推荐使用-Xmn参数。

JVM垃圾回收参数

  • -XX:+UseSerialGC:设置串行收集器。
  • -XX:+UseParallelGC:设置为并行收集器。此配置仅对年轻代有效。即年轻代使用并行收集,而年老代仍使用串行收集。
  • -XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时有多少个线程一起进行垃圾回收。此值建议配置与CPU数目相等。
  • -XX:+UseParallelOldGC:配置年老代垃圾收集方式为并行收集。JDK6.0开始支持对年老代并行收集。
  • -XX:MaxGCPauseMillis=100:设置每次年轻代垃圾回收的最长时间(单位毫秒)。如果无法满足此时间,JVM会自动调整年轻代大小,以满足此时间。
  • -XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动调整年轻代Eden区大小和Survivor区大小的比例,以达成目标系统规定的最低响应时间或者收集频率等指标。此参数建议在使用并行收集器时,一直打开。
  • -XX:+UseConcMarkSweepGC:即CMS收集,设置年老代为并发收集。CMS收集是JDK1.4后期版本开始引入的新GC算法。它的主要适合场景是对响应时间的重要性需求大于对吞吐量的需求,能够承受垃圾回收线程和应用线程共享CPU资源,并且应用中存在比较多的长生命周期对象。CMS收集的目标是尽量减少应用的暂停时间,减少Full GC发生的几率,利用和应用程序线程并发的垃圾回收线程来标记清除年老代内存。
  • -XX:+UseParNewGC:设置年轻代为并发收集。可与CMS收集同时使用。JDK5.0以上,JVM会根据系统配置自行设置,所以无需再设置此参数。
  • -XX:CMSFullGCsBeforeCompaction=0:由于并发收集器不对内存空间进行压缩和整理,所以运行一段时间并行收集以后会产生内存碎片,内存使用效率降低。此参数设置运行0次Full GC后对内存空间进行压缩和整理,即每次Full GC后立刻开始压缩和整理内存。
  • -XX:+UseCMSCompactAtFullCollection:打开内存空间的压缩和整理,在Full GC后执行。可能会影响性能,但可以消除内存碎片。
  • -XX:+CMSIncrementalMode:设置为增量收集模式。一般适用于单CPU情况。
  • -XX:CMSInitiatingOccupancyFraction=70:表示年老代内存空间使用到70%时就开始执行CMS收集,以确保年老代有足够的空间接纳来自年轻代的对象,避免Full GC的发生。
  • -XX:+ScavengeBeforeFullGC:年轻代GC优于Full GC执行。
  • -XX:+DisableExplicitGC:不响应 System.gc() 代码。
  • -XX:+UseThreadPriorities:启用本地线程优先级API。即使 java.lang.Thread.setPriority() 生效,不启用则无效。
  • -XX:SoftRefLRUPolicyMSPerMB=0:软引用对象在最后一次被访问后能存活0毫秒(JVM默认为1000毫秒)。
  • -XX:TargetSurvivorRatio=90:允许90%的Survivor区被占用(JVM默认为50%)。提高对于Survivor区的使用率。
  • -XX:+UseG1GC: 设置使用G1垃圾回收器(1.7以上JDK版本有效)
  • -XX:G1HeapRegionSize=n:设置g1 region大小,不设置的话自己会根据堆大小算,目标是根据最小堆内存划分2048个区域(1.7以上JDK版本有效)

JVM其它参数

  • -XX:+CITime:打印消耗在JIT编译的时间。
  • -XX:ErrorFile=./hs_err_pid.log:保存错误日志或数据到指定文件中。
  • -XX:HeapDumpPath=./java_pid.hprof:指定Dump堆内存时的路径。
  • -XX:+HeapDumpOnOutOfMemoryError:当首次遭遇内存溢出时Dump出此时的堆内存。
  • -XX:OnError=”;”:出现致命ERROR后运行自定义命令。
  • -XX:OnOutOfMemoryError=”;”:当首次遭遇内存溢出时执行自定义命令。
  • -XX:+PrintClassHistogram:按下 Ctrl+Break 后打印堆内存中类实例的柱状信息,同JDK的 jmap -histo 命令。
  • -XX:+PrintConcurrentLocks:按下 Ctrl+Break 后打印线程栈中并发锁的相关信息,同JDK的 jstack -l 命令。
  • -XX:+PrintCompilation:当一个方法被编译时打印相关信息。
  • -XX:+PrintGC:每次GC时打印相关信息。
  • -XX:+PrintGCDetails:每次GC时打印详细信息。
  • -XX:+PrintGCTimeStamps:打印每次GC的时间戳。
  • -XX:+TraceClassLoading:跟踪类的加载信息。
  • -XX:+TraceClassLoadingPreorder:跟踪被引用到的所有类的加载信息。
  • -XX:+TraceClassResolution:跟踪常量池。
  • -XX:+TraceClassUnloading:跟踪类的卸载信息。
  • -client:设置JVM使用Client模式,特点是启动速度比较快,但运行时性能和内存管理效率不高,通常用于客户端应用程序或开发调试;在32位环境下直接运行Java程序默认启用该模式。
  • -server:设置JVM使Server模式,特点是启动速度比较慢,但运行时性能和内存管理效率很高,适用于生产环境。在具有64位能力的JDK环境下默认启用该模式。

PS:关于参数名称定义如下。

标准参数(-),所有JVM都必须支持这些参数的功能,而且向后兼容;
非标准参数(-X),默认JVM实现这些参数的功能,但是并不保证所有JVM实现都满足,且不保证向后兼容;
非稳定参数(-XX),此类参数各个JVM实现会有所不同,将来可能会不被支持,需要慎重使用;

参考:https://blog.csdn.net/kthq/article/details/8618052

各个区域的OOM

我们来看下JVM各个区域的OOM。

堆的OOM

我们创建如下类,进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class JvmTest {
public void oomTest(){
List<JvmTest> list = new ArrayList<>();
try{
while (true){
list.add(new JvmTest());
}
}catch(Exception e){
e.printStackTrace();
}
}
public static void main(String[] args) {
new JvmTest().oomTest();
}
}

运行后可以看到抛出如下异常:

upload successful

这也是非常常见的一种OOM异常。出现的原因可能是创建了大量大对象、一些流未及时关闭等,导致堆内存溢出。

出现这种情况,必须考虑程序的优化解决方法。而不是单纯的通过-Xmn参数增大内存来解决。

栈的OOM

当栈深度超过虚拟机分配给线程的栈大小时,就会出现栈的溢出异常。

我们创建测试类,来看一下。

1
2
3
4
5
6
7
8
9
10
11
12
public class JvmTest {
public int stackOverTest(int n){
if(n==1){
return 8;
}else{
return stackOverTest(n-1)+2;
}
}
public static void main(String[] args) {
new JvmTest().stackOverTest(200000);
}
}

运行后可以看到如下异常:

upload successful

这种异常一般是调用递归或者死循环等产生的,导致栈深度超过虚拟机分配给线程的栈大小。

当然可以通过-Xss参数控制每个线程的栈大小来解决,但通常情况下,应检查程序,减少递归的使用。

关于Metaspace与PermGen(永久代)

JDK1.8移除了PermGen(永久代),取而代之的是Metaspace(元空间),元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

我们在JDK1.8环境下,设置Metaspace的大小,进行测试。(-XX:MetaspaceSize=5M -XX:MaxMetaspaceSize=5M)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class JvmTest {
public void MetaSpaceOOMTest() {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(JvmTest.class);
enhancer.setUseCache(false);
enhancer.setCallback(
new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o,objects);
}
}
);
enhancer.create();
}
}
public static void main(String[] args) {
new JvmTest().MetaSpaceOOMTest();
}
}

运行后可以看到如下异常:

upload successful

这种问题出现较少,如果出现一般为动态代理生成大量class类引起的问题。

我们在JDK1.6环境下,设置PermGen(永久代)大小,进行测试。(-XX:PermSize=10m -XX:MaxPermSize=10m)

测试方法同上。

可以看到日志输出如下:

upload successful

由于项目大多数JDK版本都在8及以上,故这种OOM异常已经很少在见到了。

JVM 日志

我们再来看下JVM的垃圾回收日志,并简单解读下。我们这儿主要来看新的JVM(1.8及其后)的GC日志。

我们在运行时添加如下参数: -XX:-PrintGCDetails

我们用上面的 JvmTest类里的oomTest方法来进行测试。

可以看到如下一些GC运行日志和OOM的dump日志。

我们先来看下GC的运行日志部分:

1
2
3
4
5
6
7
8
[GC (Allocation Failure) [PSYoungGen: 207268K->200192K(339968K)] 879015K->879170K(1489408K), 0.8137540 secs] [Times: user=2.95 sys=0.06, real=0.81 secs] 
[GC (Allocation Failure) [PSYoungGen: 339968K->243200K(339456K)] 1292739K->1292955K(1488896K), 0.8972164 secs] [Times: user=3.18 sys=0.20, real=0.90 secs]
[Full GC (Ergonomics) [PSYoungGen: 243200K->0K(339456K)] [ParOldGen: 1049755K->1110054K(1722880K)] 1292955K->1110054K(2062336K), [Metaspace: 3502K->3502K(1056768K)], 7.8097561 secs] [Times: user=16.27 sys=0.19, real=7.81 secs]
[GC (Allocation Failure) [PSYoungGen: 96256K->96384K(424448K)] 1206310K->1206438K(2147328K), 0.3838048 secs] [Times: user=1.39 sys=0.05, real=0.38 secs]
[GC (Allocation Failure) [PSYoungGen: 192640K->192672K(425472K)] 1302694K->1302726K(2148352K), 0.6567791 secs] [Times: user=2.53 sys=0.00, real=0.66 secs]
[GC (Allocation Failure) [PSYoungGen: 303776K->303840K(430080K)] 1824520K->1824584K(2152960K), 1.1635894 secs] [Times: user=4.29 sys=0.00, real=1.16 secs]
[GC (Allocation Failure) [PSYoungGen: 414944K->347136K(496640K)] 1935688K->1935808K(2219520K), 1.6472200 secs] [Times: user=5.76 sys=0.19, real=1.65 secs]
[Full GC (Ergonomics) [PSYoungGen: 347136K->0K(496640K)] [ParOldGen: 1588672K->1661667K(2083840K)] 1935808K->1661667K(2580480K), [Metaspace: 3502K->3502K(1056768K)], 10.5294207 secs] [Times: user=23.63 sys=0.09, real=10.53 secs]
  • (Allocation Failure):Allocation Failure表示向young generation(eden)给新对象申请空间,但是young generation(eden)剩余的合适空间不够所需的大小导致的GC。
  • [PSYoungGen: 207268K->200192K(339968K)] 879015K->879170K(1489408K), 0.8137540 secs] 这段分别表示 [年轻代: GC前内存容量 -> GC后内存容量 (年轻代总容量)] GC前堆内存大小 -> GC后堆内存大小(堆内存总大小),该内存区域GC耗时(与Times的real相等),单位是秒。
  • [Times: user=2.95 sys=0.06, real=0.81 secs] 这段分别表示用户态耗时,内核态耗时和总耗时。
  • Full GC (Ergonomics) 表明该次发生了Full GC,Ergonomics就是Full GC的原因,可以认为如果晋升到老生代的平均大小大于老生代的剩余大小,则认为需要一次full gc。某些垃圾回收器会负责自动的调解gc暂停时间和吞吐量之间的平衡,然后JVM虚拟机性能更好,因而会出现这种Full GC原因。
  • ParOldGen部分表示年老代的相关GC信息。
  • Metaspace部分表示元空间的相关GC信息。

我们在GC相关源码(openjdk源码中gcCause.cpp文件)中还可以看到多种GC原因,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#include "precompiled.hpp"
#include "gc/shared/gcCause.hpp"

const char* GCCause::to_string(GCCause::Cause cause) {
switch (cause) {
case _java_lang_system_gc:
return "System.gc()";

case _full_gc_alot:
return "FullGCAlot";

case _scavenge_alot:
return "ScavengeAlot";

case _allocation_profiler:
return "Allocation Profiler";

case _jvmti_force_gc:
return "JvmtiEnv ForceGarbageCollection";

case _gc_locker:
return "GCLocker Initiated GC";

case _heap_inspection:
return "Heap Inspection Initiated GC";

case _heap_dump:
return "Heap Dump Initiated GC";

case _wb_young_gc:
return "WhiteBox Initiated Young GC";

case _wb_conc_mark:
return "WhiteBox Initiated Concurrent Mark";

case _wb_full_gc:
return "WhiteBox Initiated Full GC";

case _update_allocation_context_stats_inc:
case _update_allocation_context_stats_full:
return "Update Allocation Context Stats";

case _no_gc:
return "No GC";

case _allocation_failure:
return "Allocation Failure";

case _tenured_generation_full:
return "Tenured Generation Full";

case _metadata_GC_threshold:
return "Metadata GC Threshold";

case _metadata_GC_clear_soft_refs:
return "Metadata GC Clear Soft References";

case _cms_generation_full:
return "CMS Generation Full";

case _cms_initial_mark:
return "CMS Initial Mark";

case _cms_final_remark:
return "CMS Final Remark";

case _cms_concurrent_mark:
return "CMS Concurrent Mark";

case _old_generation_expanded_on_last_scavenge:
return "Old Generation Expanded On Last Scavenge";

case _old_generation_too_full_to_scavenge:
return "Old Generation Too Full To Scavenge";

case _adaptive_size_policy:
return "Ergonomics";

case _g1_inc_collection_pause:
return "G1 Evacuation Pause";

case _g1_humongous_allocation:
return "G1 Humongous Allocation";

case _dcmd_gc_run:
return "Diagnostic Command";

case _last_gc_cause:
return "ILLEGAL VALUE - last gc cause - ILLEGAL VALUE";

default:
return "unknown GCCause";
}
ShouldNotReachHere();
}

这儿就不在对上面的所有GC情况做详细介绍了,有兴趣的同学可以查阅相关资料了解。

gcCause相关资料:

我们再来看下出现OOM后GC的dump日志部分。

1
2
3
4
5
6
7
8
9
Heap
PSYoungGen total 584192K, used 10301K [0x0000000780700000, 0x00000007ba880000, 0x00000007c0000000)
eden space 257536K, 3% used [0x0000000780700000,0x000000078110f510,0x0000000790280000)
from space 326656K, 0% used [0x00000007a6980000,0x00000007a6980000,0x00000007ba880000)
to space 347136K, 0% used [0x0000000790280000,0x0000000790280000,0x00000007a5580000)
ParOldGen total 2083840K, used 2054113K [0x0000000701400000, 0x0000000780700000, 0x0000000780700000)
object space 2083840K, 98% used [0x0000000701400000,0x000000077e9f8790,0x0000000780700000)
Metaspace used 3535K, capacity 4506K, committed 4864K, reserved 1056768K
class space used 392K, capacity 394K, committed 512K, reserved 1048576K

它们打印的JVM终止时Heap(堆内存)的信息,从该日志中我们能分析出JVM终止的一些原因。

可以看到PSYoungGen(年轻代) eden区使用了3%,(两个Survivor)from和to区使用了0%,ParOldGen(年老代) object space(对象区)使用了98%,Metaspace(元空间) class space(类加载区)的使用情况。

因此明显由于创建了大量对象,一直存在,无法被垃圾回收,导致内存空间用尽,出现OOM异常。

JVM监控Demo

现在有许多JVM监控工具,如JConsole、Java VisualVM等,我们这里不过多介绍。

我们自写一个监控Demo来看下JVM在内存使用过程中的一些变化特点。

来看下java.lang.management包下的一些类。

upload successful

  • MemoryMXBean : 它里面有两个方法 getHeapMemoryUsage (获取堆内存使用情况)和getNonHeapMemoryUsage(获取非堆内存使用情况),返回MemoryUsage对象。
  • MemoryUsage:包含init(初始化了多少内存)、used(使用了多少内存)、committed(申请了多少内存)、max(最大内存)信息。
  • MemoryPoolMXBean:这里包含young(eden和survivor)、old等内存区的使用情况,我们可以通过 ManagementFactory.getMemoryPoolMXBeans() 获取到一个 MemoryPoolMXBean 列表,MemoryPoolMXBean里还有一个getName方法可以获得当前区域的名称。
  • GarbageCollectorMXBean:这个是垃圾收集相关的Bean,可以通过ManagementFactory.getGarbageCollectorMXBeans()获取其列表。其getName方法可以获得垃圾收集器的名称,getCollectionCount可以获得当前已经进行了多少次垃圾收集,getCollectionTime返回垃圾收集时间。

我们写一个测试类来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public class JVMMonitorMemoryTest {
//定时任务线程
private static final ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1);
private ScheduledFuture future = null;
private JvmTest jvmTest = new JvmTest();
public void doMonitor(){
future = executorService.scheduleAtFixedRate(()->{
JSONObject jsonObject = new JSONObject();
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
jsonObject.put("totalMaxMemery", memoryMXBean.getHeapMemoryUsage().getMax()>>10>>10);
jsonObject.put("totalUsedMemery", memoryMXBean.getHeapMemoryUsage().getUsed()>>10>>10);
jsonObject.put("totalInitMemery", memoryMXBean.getHeapMemoryUsage().getInit()>>10>>10);
//这里会返回老年代,新生代等内存区的使用情况
List<MemoryPoolMXBean> memoryPoolMXBeans = ManagementFactory.getMemoryPoolMXBeans();
memoryPoolMXBeans.forEach((pool) -> {
String poolName = pool.getName().trim();
long max = pool.getUsage().getMax()>>10>>10;
long used = pool.getUsage().getUsed()>>10>>10;
long init = pool.getUsage().getInit()>>10>>10;
long maxPeak = pool.getPeakUsage().getMax()>>10>>10;
long usedPeak = pool.getPeakUsage().getUsed()>>10>>10;
long initPeak = pool.getPeakUsage().getInit()>>10>>10;

JSONObject poolJSON = new JSONObject();
poolJSON.put("max", max);
poolJSON.put("used", used);
poolJSON.put("init", init);
poolJSON.put("maxPeak", maxPeak);
poolJSON.put("usedPeak", usedPeak);
poolJSON.put("initPeak", initPeak);

if("PS Eden Space".equalsIgnoreCase(poolName)){
jsonObject.put("eden", poolJSON);
}else if("PS Survivor Space".equalsIgnoreCase(poolName)){
jsonObject.put("survivor", poolJSON);
}else if("PS Old Gen".equalsIgnoreCase(poolName)){
jsonObject.put("old", poolJSON);
}else if("Metaspace".equalsIgnoreCase(poolName)){
jsonObject.put("metaspace",poolJSON);
}
});
//垃圾收集相关
List<GarbageCollectorMXBean> garbageCollectorMXBeans = ManagementFactory.getGarbageCollectorMXBeans();
garbageCollectorMXBeans.forEach(collector -> {
String gcName = collector.getName();
long gcCount = collector.getCollectionCount();
long gcTime = collector.getCollectionTime();
JSONObject gcJSON = new JSONObject();
gcJSON.put("gcCount", gcCount);
gcJSON.put("gcTime", gcTime);
if(gcName.toLowerCase().contains("scavenge")){
jsonObject.put("edenGc", gcJSON);
}else if(gcName.toLowerCase().contains("marksweep")){
jsonObject.put("oldGc", gcJSON);
}
});
System.out.println(JSON.toJSONString(jsonObject));
}, 1, 1, TimeUnit.SECONDS);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//调用生成对象的方法
jvmTest.test();
}
public static void main(String[] args) {
JVMMonitorMemoryTest jvmMonitorMemoryTest = new JVMMonitorMemoryTest();
jvmMonitorMemoryTest.doMonitor();
}
}
//创建一个方法不停生产对象
public class JvmTest {
public void test() {
List<JvmTest> list = new ArrayList<>();
try{
while (true){
list.add(new JvmTest());
if(list.size()>10000){
list = new ArrayList<>();
TimeUnit.MILLISECONDS.sleep(100);
}
}
}catch(InterruptedException e){
e.printStackTrace();
}
}
}

我们运行后可以看到相关输出信息,即JVM堆内存变化情况及垃圾收集情况。

数字数据不是很直观,我们结合Echart图表,动态展示JVM相关信息,因此我们把项目改造下,结合WebSocket来实现。

项目大致结构如下:

upload successful

说一下里面的关键部分,MonitorJVMMemory.java(监控JVM内存变化类)和jvm-echart.js(Echart前端动态展示)。

MonitorJVMMemory相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
@ServerEndpoint("/websocket/jvm/monitor")
public class MonitorJVMMemory implements ServletContextListener {

/**
* 日志
*/
private static final Logger logger = LoggerFactory.getLogger(MonitorJVMMemory.class);
//定时任务线程
private static final ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1);
private ScheduledFuture future = null;
private JvmTest jvmTest = new JvmTest();

/**
* websocket会话
*/
private Session session;

@OnOpen
public void init(Session session) {
this.session = session;
future = executorService.scheduleAtFixedRate(() -> {
JSONObject jsonObject = new JSONObject();
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
jsonObject.put("totalMaxMemery", memoryMXBean.getHeapMemoryUsage().getMax() >> 10 >> 10);
jsonObject.put("totalUsedMemery", memoryMXBean.getHeapMemoryUsage().getUsed() >> 10 >> 10);
jsonObject.put("totalInitMemery", memoryMXBean.getHeapMemoryUsage().getInit() >> 10 >> 10);
//这里会返回老年代,新生代等内存区的使用情况,按需自取就好
List<MemoryPoolMXBean> memoryPoolMXBeans = ManagementFactory.getMemoryPoolMXBeans();
memoryPoolMXBeans.forEach((pool) -> {
String poolName = pool.getName().trim();
long max = pool.getUsage().getMax() >> 10 >> 10;
long used = pool.getUsage().getUsed() >> 10 >> 10;
long init = pool.getUsage().getInit() >> 10 >> 10;
long maxPeak = pool.getPeakUsage().getMax() >> 10 >> 10;
long usedPeak = pool.getPeakUsage().getUsed() >> 10 >> 10;
long initPeak = pool.getPeakUsage().getInit() >> 10 >> 10;

JSONObject poolJSON = new JSONObject();
poolJSON.put("max", max);
poolJSON.put("used", used);
poolJSON.put("init", init);
poolJSON.put("maxPeak", maxPeak);
poolJSON.put("usedPeak", usedPeak);
poolJSON.put("initPeak", initPeak);

if ("PS Eden Space".equalsIgnoreCase(poolName)) {
jsonObject.put("eden", poolJSON);
} else if ("PS Survivor Space".equalsIgnoreCase(poolName)) {
jsonObject.put("survivor", poolJSON);
} else if ("PS Old Gen".equalsIgnoreCase(poolName)) {
jsonObject.put("old", poolJSON);
}
});
//垃圾收集
List<GarbageCollectorMXBean> garbageCollectorMXBeans = ManagementFactory.getGarbageCollectorMXBeans();
garbageCollectorMXBeans.forEach(collector -> {
String gcName = collector.getName();
long gcCount = collector.getCollectionCount();
long gcTime = collector.getCollectionTime();
JSONObject gcJSON = new JSONObject();
gcJSON.put("gcCount", gcCount);
gcJSON.put("gcTime", gcTime);
if (gcName.toLowerCase().contains("scavenge")) {
jsonObject.put("edenGc", gcJSON);
} else if (gcName.toLowerCase().contains("marksweep")) {
jsonObject.put("oldGc", gcJSON);
}
});
try {
session.getBasicRemote().sendText(jsonObject.toJSONString());
} catch (IOException e) {
e.printStackTrace();
}
}, 1, 1, TimeUnit.SECONDS);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
jvmTest.test();
}

/**
* 接收信息
*/
@OnMessage
public void acceptMessage(String message) {
logger.info("Accept>>>" + message);
}

/**
* 关闭会话
*/
@OnClose
public void closeSession(CloseReason closeReason) {
this.destory();
logger.info(closeReason.getReasonPhrase());
}

/**
* 异常处理
*/
@OnError
public void errorHandler(Throwable e) {
this.destory();
logger.info("MonitorJVMMemory websocket error :" + e.getMessage());
}

/**
* 关闭资源
*/
private void destory() {
try {
if (future != null && !future.isCancelled()) {
future.cancel(true);
}
if (session != null) {
session.close();
}
} catch (Exception e) {
logger.error("destory", e);
}
}

@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {

}

@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
jvmTest.stop();
executorService.shutdownNow();
}
}

可以看到我们使用了Websocket,当连接Open后,使用定长线程池,里面维护一个每隔1s调用一次的方法,来查看当前内存情况,并使用jvmTest.test()来生成测试对象。

线程池里运行的线程执行的就是我们上面JVMMonitorMemoryTest类的doMonitor方法。

再看下jvm-echart.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
//echart
var memoryEchart = echarts.init(document.getElementById('memory_main'));
var memoryData = new Array(3);
//定义图表样式
var memoryOption = {
tooltip : {
trigger: 'axis',
axisPointer : { // 坐标轴指示器,坐标轴触发有效
type : 'shadow' // 默认为直线,可选为:'line' | 'shadow'
},
formatter: function (params) {
var dataIndex = params[0].dataIndex;
var res = params[0].axisValue;
if(dataIndex==0 || dataIndex==1){
res += '<br/>累计回收次数:' + params[0].data;
res += '<br/>累计回收时间:' + params[1].data + "ms";
res += '<br/>平均回收时间:' + parseInt(params[1].data/params[0].data) + "ms";
}else{
res += '<br/>已用内存量:' + params[0].data + "MB";
if(params[0].axisValue!='峰值内存消耗'){
var maxData = memoryData[2];
res += '<br/>可用内存量:' + params[1].data + "MB";
res += '<br/>最大内存量:' + maxData[dataIndex] + "MB";
}
}
return res;
}
},
color: ['#ff0000','#91C7AE'],
legend: {
data: ['已用内存(MB)', '可用内存(MB)']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value'
},
yAxis: {
type: 'category',
data: ['OldGenGC','EdenGC','Old Gen','Survivor Space','Eden Space','峰值内存消耗','JVM总内存']
},
series: [
{
name: '已用内存(MB)',
type: 'bar',
stack: '总量',
barWidth: 60,
label: {
normal: {
show: true,
position: 'insideRight'
}
},
data: memoryData[0]
},
{
name: '可用内存(MB)',
type: 'bar',
stack: '总量',
barWidth: 60,
label: {
normal: {
show: true,
position: 'insideRight'
}
},
data: memoryData[1]
}
]
};

memoryEchart.setOption(memoryOption);

//刷新图表数据
function refreshMemoryData() {
memoryEchart.setOption({
series: [{
data: memoryData[0]
},{
data: memoryData[1]
}]
});
}

//与websocket建立连接
var memorySocket;
function initMemorySocket() {
if(memorySocket!=undefined || memorySocket!=null){
memorySocket.close("3000", "断开连接");
}
var wsUrl = 'ws://'+$('#hid_host').val()+'/websocket/jvm/monitor';
console.log(wsUrl);
memorySocket = new WebSocket(wsUrl);
memorySocket.onopen = function (evt) {
console.log("Connection the jvm monitor server success!!!");
};
memorySocket.onmessage = function (evt) {
var memory = $.parseJSON(evt.data);
var peakUsed = memory.old.usedPeak + memory.eden.usedPeak;
var usedData = [memory.oldGc.gcCount, memory.edenGc.gcCount, memory.old.used, memory.survivor.used, memory.eden.used, peakUsed, memory.totalUsedMemery];
var peakMax = parseInt((memory.old.maxPeak + memory.eden.maxPeak)*0.8);
var usable = [memory.oldGc.gcTime, memory.edenGc.gcTime, memory.old.max-memory.old.used,
memory.survivor.max-memory.survivor.used, memory.eden.max-memory.eden.used, 0, memory.totalMaxMemery-memory.totalUsedMemery];
var maxData = [memory.oldGc.gcTime, memory.edenGc.gcTime, memory.old.max, memory.survivor.max, memory.eden.max, 0, memory.totalMaxMemery];
memoryData[0] = usedData;
memoryData[1] = usable;
memoryData[2] = maxData;
refreshMemoryData();
};
memorySocket.onerror = function (evt) {
memorySocket.close();
};
}

//断开监控连接
function closeMemoryMonitor() {
if(memorySocket!=undefined || memorySocket!=null){
memorySocket.close("3000", "断开连接");
}
memoryData[0] = [];
memoryData[1] = [];
memoryData[2] = [];
refreshMemoryData();
$.messager.show({ title: '系统提示', msg: '已断开监控连接!'});
console.log("Disconnect the jvm monitor server success!!!");
}

$(function () {
initMemorySocket();
});

这个就是在解析后台数据构造Echart图表,这儿就不详细介绍了。

详细源码可以在 jvm-monitor-memory 看到。

我们可以简单看下运行效果图,可以看到JVM进行垃圾回收后内存的变化情况。

upload successful

总结

通过这篇文章,我们了解了JVM垃圾回收的一些运行原理,对JVM堆内存有了更深入的认识。了解虚拟机内存及垃圾回收的一些特性,有助于我们在工作过程中排查定位问题。




-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

您的支持就是我创作的动力!

欢迎关注我的其它发布渠道