重学Android基础系列篇(四):Android虚拟机垃圾回收

1,351 阅读40分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

前言

本系列文章主要是汇总了一下大佬们的技术文章,属于Android基础部分,作为一名合格的安卓开发工程师,咱们肯定要熟练掌握java和android,本期就来说说这些~

[非商业用途,如有侵权,请告知我,我会删除]

DD一下: Android进阶开发各类文档,也可关注公众号<Android苦做舟>获取。

1.Android高级开发工程师必备基础技能
2.Android性能优化核心知识笔记
3.Android+音视频进阶开发面试题冲刺合集
4.Android 音视频开发入门到实战学习手册
5.Android Framework精编内核解析
6.Flutter实战进阶技术手册
7.近百个Android录播视频+音视频视频dome
.......

四、Android虚拟机垃圾回收

1.垃圾收集算法

查看默认垃圾收集算法

java -XX:+PrintCommandLineFlags -version

1.1 对象存活的判断

也就是说GC回收的时候,如果判断哪些对象要回收,那些对象不需要回收。

  • 引用计数法 引用计数算法是这样的,给对象添加一个引用计数器,每当有一个地方调用他,就给计数器加1,每当引用失效,计数器就减1;任何时刻计数器为0的对象就不可以再被使用。引用计数算法简单高效但是Java虚拟机没有采用引用计数算法来管理内存,主要是因为很难解决对象之间互相循环引用的问题但是Python语言有使用。
  • 可达性分析算法(Java采用可达性分析算法) Java和C#采用可达性分析算法,基本思路就是通过一系列称为"GC Roots"的对象作为起点,从这个点开始向下搜索,搜索所走过的路径就是引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的

在明确了对象是否存活的算法后,那么Java程序中是怎么判断这个对象是不是‘非死不可’呢。其实即使在可达性分析算法中不可达的对象,也并非是‘非死不可’的,这时候他们只是暂时处于‘缓行’阶段,而要真正宣告一个对象死亡要至少经历两次标记过程。如果对象在经历可达性分析后发现没有与GC Roots相连接的引用,那么它将被第一次标记并且进行筛选,筛选的条件是此对象是否有必要执行finalize()方法,或者finalize()已经被虚拟机调用过,虚拟机将这两种情况都看作是‘没必要执行筛选’。

如果对象被判定是有必要执行筛选的,那么这个对象将被放在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自己创建的低优先级的线程去执行它。如果一个对象想要逃离死亡的命运,那么这时候是最后的一次机会,只要对象可以和引用链上的任何一个对象建立关系,那么在第二次标记的时候它将被移出“即将回收集合”;如果对象此时还没有逃脱,那么稍后GC将对F-Queue中的对象进行第二次标记,被第二次标记上的对象基本上就被真的回收了。

image.png

一般能作为GC Root:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈 的变量等

1.2 什么时候会进行垃圾回收

GC回收一般是JVM自己控制的,我们也可以通过System.gc()来让JVM进行垃圾回收,因为GC的时候消耗的资源比较大,不建议手动进行。

  • 当Eden区或者S区不够用了
  • 老年代空间不够用了
  • 方法区空间不够用了
  • System.gc()

2.垃圾收集算法分类

  • 标记-清除算法

    • 特点:先标记处需要回收的对象,然后在清除需要被回收的对象

    • 缺点

      • 1.会产生大量内存碎片,空间不连续后面有大对象时无法使用,可能会引起一次垃圾回收
      • 2.标记和清除两个过程都比较耗时,效率不高
  • 标记-复制算法

    • 特点:将内存分为相等的两部分,每次只用一块当内存块用完的时候,将存活的复制到另一块空间,然后把当前这块清理掉,因为要预留一半空间,因此不适合老年代

    • 缺点

      • 1.解决了标记-清除算法中清除慢的问题,但是空间利用率低
      • 2.在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都有100%存活的极端情况
  • 标记-整理算法

    • 特点:标记过程和标记-清除算法一样,但是接下来不是直接回收对象,而是让所有存活的对象都向一端移动,然后清理掉边界以外的内存
  • 分代收集算法

    • 特点:

      • 1.当前商业虚拟机基本都采取分代回收算法
      • 2.Young区采用复制算法(Young区对象生命周期短,复制效率更高)
      • 3.Old区一般是标记-清除或者标记-整理

标记-清除算法

“标记-清除”(Mark-Sweep)算法,如同名字一样,算法分为两个阶段,标记和清除阶段。首先标记出所有需要回收的对象(可达性分析),在标记完成后统一回收所有被标记的对象。这是最基础的算法,但是有两个问题,效率问题和空间问题,标记和清除两个过程效率都不高;另外回收完成之后会产生大量不连续的内存空间,会导致在后面如果有大对象的时候,有可能找不到足够的连续内存从而不得不进行一次垃圾收集动作

回收前:

image.png

回收后 :

image.png

标记-复制算法

为了解决“标记-清除”算法的效率问题,出现了复制算法,它将内存分为大小相等的两块,每次只使用一块。当这一块内存使用完了,就将还存活的的对象复制到另一块上面,然后再把另一块内存完全清理掉。这样就不用考虑内存碎片的问题,但是每次只能使用一般内存,代价太高。这种算法用来回收新生代,新生代中的对象大多都是‘朝生夕死’,所以并不用按照1比1来划分,而是将内存分为一块大的Eden空间和两块较小的Survivor空间,当回回收时,将Eden和1个Survior中存活的对象一次copy到另一个Survior中,也就是说最多就10%的浪费。HotSpon默认的Eden和Survior的比例是8:1。加入回收时1个Survior中的空间不够,那么需要依赖老年代来进行分配担保。

image.png

标记-整理算法

“标记-整理”(Mark-Compact),标记过程和标记-清除一样,但是接下来不是直接对可回收对象进行清理,而是让所有存活对象都想一端移动,然后清理掉端边界以外的部分。

image.png

分代收集算法

当前商业虚拟机的的垃圾收集都采用"分代收集"(Generational Collection)算法。就是根据对象存活周期将内存分为几块,一般Java堆是分为新生代和老年代,这样各个代就可以根据自己的特点选择对应的算法。在新生代对象只有少量存活就选择复制算法;而老年代对象存活率高,而且没有而外空间来担保,就必须采用“标记-清除”或者“标记-整理”算法来进行回收

3.垃圾收集器

如果说算法是垃圾回收的方法论,那么垃圾收集器就是内存回收的具体实现了。虚拟机中并没有明确规定,因此不同的厂商,不同版本都有自己的实现,主要为一下几种:

  • Serial收集器:单线程收集过程需要暂停所有线程,采用复制算法适合用于新生代(Young)
  • Serial Old收集器:Serial的老年代版本,也是单线程的,采取标记-整理算法
  • ParNew收集器:Serial的多线程版本,采用复制算法适用于新生代,多CPU时候比Serial效率高,单CPU时候效果不如Serial
  • Parallel Scavenge收集器:新生代(Young)收集器采用复制算法,并行的多线程收集器,和ParNew相比跟关注吞吐量
  • Parallel Old收集器:Parallel Scavenge的老年代版本采用标记-整理算法
  • CMS收集器:并发收集、低停顿,采用标记-清除算法产生大量空间碎片,并发阶段会降低吞吐量
  • G1收集器:将对划分为大小相等的Region,保留了新生代和老年代的概念但是不再是物理隔离了,采用标记-整理算法不会产生空间碎片,可以让使用者指定在M毫秒的时间内,花费在垃圾收集的时间不超过N毫秒
  • ZGC:没有碎片问题,不存在新老年代的概念

Serial收集器

Serial是一个历史悠久的收集器,这个收集器是单线程的收集器。这里的单线程不是说只能有一个CPU或者1条收集线程来完成垃圾收集工作,而是说它在进行垃圾回收的时候,必须暂停其他工作线程,直到它收集结束。“Stop the world”实际用起来我们并不能接受,假设程序正在跑,但是隔一会就要程序停下来等待垃圾收集,例如等待5分钟,那么这种我们是肯定不能接受的。

image.png

Serial Old收集器

是Serial的老年代版本,也是单线程收集器,使用“标记-整理”算法。

image.png

ParNew收集器

ParNew其实就是Serial的多线程版本,其他和Serial比起来没太大差别,但是ParNew在单CPU的环境下不会比Serial效果好,在两个CPU的环境中ParNew也不能说会稳超Serial,但是多核变得很常见,ParNew在一些CPU比较多的环境下,效果还是比较好的。我们可以通过参数-XX:ParallelGCThreads来限制参与垃圾收集的线程数。默认情况下开启的线程数和CPU数量相同。很多时候我们并不期待使用全部CPU资源。

image.png

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,采用复制算法也是多线程的收集器。它和ParNew的区别是,其他收集器关注的是收集是尽可能缩短用户线程的停顿时间,而Parallel Scavenge是为了达到一个可控的吞吐量。

所谓吞吐量=运行用户代码时间/(垃圾收集时间+用户代码运行时间)

/**
*设置最大垃圾收集停顿时间,但是必须是一个大于0的毫秒数,但是并不是说这个值设置到非常小,就能使垃圾收集速度快。
*/
-XX:MaxGCPauseMillis
/**
*设置吞吐量大小是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的占比
*/
-XX:GCTimeRatio:
//手工指定新生代大小
-Xmn

//Eden和Survivor的占比
-XX:SurvivorRatio

//GC自适应调节策略,设置了该参数就不需要手动指定新生代,也不需要指定Eden和Survivor占比等参数
-XX:+UseAdaptiveSizePolicy

Parallel Old收集器

Parallel Scavenge的老年代版本,使用多线程和“标记-整理”算法。在一些注重吞吐量和CPU敏感的场合可以使用Parallel Scavenge+Parallel Old(Java8默认垃圾收集器)。

CMS收集器

CMS(Concurrent Mark Sweep)是一种以获取最短停顿时间的垃圾收集器,是“标记-清除”算法实现的。他的运行过程复杂点,有四个过程:

  • 初始标记:需要“Stop the world”,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

  • 并发标记:是进行GC Roots追踪(GC Roots Tracing)的过程.这个过程和用户线程并发执行。

  • 重新标记:需要“Stop the world”,是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分的对象的标记记录,这个停顿时间大于初始标记,但是远小于并发标记过程。

  • 并发清除:和用户程序并发执行。 CMS的优点是,并发收集,低停顿。有一个明显缺点就是因为采用了“标记-清除”算法,最后会出现大量碎片,有可能会出现在某一个时刻,当有大对象生成,不得不进行一次Full GC来解决这个问题。为了解决该问题CMS有一个参数-XX:UseCmsCompactAtFullCollection来解决因为空间不足进行Full GC。这个参数默认开启,用于在CMS收集器顶不住要进行Full GC是开启内存碎片合并整理的过程,内存整理过程是无法并发的,因此就会耗时。同时还有一个参数是-XX:CMSFullGCsBeforeCompaction,这个参数是用于执行多次不压缩的GC后,跟着来一次压缩的(默认是0,表示每次进入Full GC都进行碎片整理)。

image.png

G1收集器

G1(Garbage-First)收集器:并行和并发,分代收集,空间整合,可预测的挺顿。 G1和其他收集器很大的不同是,其他收集器收集范围都是整个新生代和老年代,而G1不再是这样。使用G1收集器的时候,Java堆分为多个大小相等的区域(Region),虽然也保留了新生代和老年代的概念,但是不再是物理隔离了,他们都是一部分Region的集合(这些Region不一定是连续的)。G1保留了Eden和Survivor的比例也是8:1:1。

工作过程

  • 初始标记(Initial Marking) 标记以下GC Roots能够关联的对象,并且修改TAMS的值,需要暂停用户线程

  • 并发标记(Concurrent Marking) 从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行

  • 最终标记(Final Marking) 修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂停用户线程

  • 筛选回收(Live Data Counting and Evacuation) 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划

image.png

HumongousObject:一个大小达到甚至超过分区Region 50%以上的对象称为巨型对象(Humongous Object),巨型对象会独占一个或多个连续分区。HumongousObject直接分配在老年代。

image.png

ZGC

JDK11引入的ZGC收集器,在物理和逻辑上已经没有新/老年代的概念了,会分为一个个page,当进行GC操作时候会对page进行压缩,因此没有碎片问题,只能在64位linux上使用。

  • 可以达到10ms以内的停顿要求
  • 支持TB级别的内存
  • 堆内存变大后停顿时间还是在10ms以内

4.垃圾收集器分类

  • 串行收集器:只能有一个垃圾回收线程执行,用户线程暂停,适用于内存小的嵌入式设备 Serial和Serial Old
  • 并行收集器吞吐量优先,多个垃圾收集线程同时工作,但是这时候用户线程处于等待状态 Parallel Scanvenge、Parallel Old
  • 并发收集器停顿时间优先,用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行,适合web场景 CMS、G1

5.附录

常用JVM调试命令

jstack:查看栈信息,可用于多线程时分析和查看线程运行状况。

jstack pid
#查看pid=443进程的栈信息
jstack 443

jmap:查看堆内存信息和生成dump文件

//查看进程的内存映像信息
1.jmap pid

//显示Java堆详细信息,打印一个堆的摘要信息,包括使用的GC算法、堆配置信息和各内存区域内存使用信息
2.jmap heap pid

//显示堆中对象的统计信息,其中包括每个Java类、对象数量、内存大小(单位:字节)、完全限定的类名。打印的虚拟机内部的类名称将会带有一个’*’前缀。如果指定了live子选项,则只计算活动的对象
3.jmap -histo:live pid

//打印类加载器信息,-clstats是-permstat的替代方案,在JDK8之前,-permstat用来打印类加载器的数据
打印Java堆内存的永久保存区域的类加载器的智能统计信息。对于每个类加载器而言,它的名称、活跃度、地址、父类加载器、它所加载的类的数量和大小都会被打印。此外,包含的字符串数量和大小也会被打印
4.jmap -clstats pid

//打印等待终结的对象信息,Number of objects pending for finalization: 0 说明当前F-QUEUE队列中并没有等待Fializer线程执行final
5.jmap -finalizerinfo pid

//生成堆转储快照dump文件,以hprof二进制格式转储Java堆到指定filename的文件中。live子选项是可选的。如果指定了live子选项,堆中只有活动的对象会被转储。想要浏览heap dump,你可以使用jhat(Java堆分析工具)读取生成的文件
6.jmap -dump:format=b,file=heapdump.phrof pid

jmap -dump:format=b,file=heapdump.phrof pid,这个命令执行,JVM会将整个heap的信息dump写入到一个文件,heap如果比较大的话,就会导致这个过程比较耗时,并且执行的过程中为了保证dump的信息是可靠的,所以会暂停应用, 线上系统慎用

jhat:可以在浏览器解析和查看dump文件

//查看dumpfile的内存详细
jhat dumpfile

q@troyMac oom % jhat java_pid3874.hprof
Reading from java_pid3874.hprof...
Dump file created Sat May 09 11:45:32 CST 2020
Snapshot read, resolving...
Resolving 814571 objects...
Chasing references, expect 162 dots..................................................................................................................................................................
Eliminating duplicate references..................................................................................................................................................................
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

6.物理内存与虚拟内存

物理内存

物理内存指的是内存条上的内存,早期一个进程的数据是全部加载在物理内存上,CPU直接通过物理内存地址来访问进程数据。这种方式会产生以下几个问题:

  • 内存不够用:启动的应用过多,全部加载会导致内存条的空间不够用。
  • 内存占用浪费:当应用越来越大的时候,用户可能只用到部分功能,此时如果全部加载到内存,会导致内存占用浪费。
  • 内存数据的安全问题:通过访问物理地址,可以直接修改物理内存上的数据。

为了解决物理内存的这几个问题,CPU访问进程数据就不能直接通过物理内存地址,而是通过虚拟内存来间接访问。

虚拟内存

虚拟内存是处于进程和物理内存之间的一个中间层,由系统生成,内部作分页管理,结构如下图所示:

image.png

一个虚拟内存对应一个进程,大小为4GB,虚拟内存里会分为很多页(page),每页的大小在iOS中为16kb,其他系统中为4kbPage里的每一格对应进程中的某一项数据,会记录该数据的虚拟内存地址和物理内存地址,因此虚拟内存本质上是一张关联进程各项数据的虚拟内存地址和物理内存地址的映射表官方文档 后续验证

采用虚拟内存后,CPU访问进程数据的情况如下:

  • 进程启动后,系统会为进程建立一个对应的虚拟内存,里面记录了进程每项数据的虚拟内存地址,此时进程还未加载到物理内存中,所以page记录的各项数据的物理内存地址为0x00000...。
  • 当进程的某部分活跃后,CPU根据这部分数据的虚拟内存地址找到其对应的物理内存地址,再通过物理地址访问到物理内存上的数据。
  • 如果在page上没有找到对应的物理地址时,说明此page上所关联的进程数据没被加载到物理内存中,此时会触发缺页异常(Page Fault),中断当前进程,先将当前页所对应的进程数据加载到物理内存中,然后page会记录每项数据的物理地址,CPU再通过物理地址来访问内存上的数据。

因此,相比直接访问物理内存,虚拟内存的优势如下:

  • 内存使用更高效:进程的数据经过分页管理后,只将活跃的page所关联的数据加载在物理内存中,当物理内存都被占用的时候,此时会覆盖掉不活跃的内存,加载当前活跃的page数据,这样就能提高对内存的使用效率。
  • 内存数据更安全:每次启动进程,系统都会重新建立对应的虚拟内存,并为虚拟内存分配一个ASLR随机值(Address Space Layout Randomization),数据的虚拟地址即为:ASLR随机值+偏移值,这样数据的虚拟地址每次都会变,并且CPU是通过虚拟内存来间接访问物理内存的,在这个过程中物理内存地址没有暴露出来,所以就能保证内存数据的安全性。

7.GC root算法

7.1 可达性分析算法

  • JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
  • 具体是这样的:扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收

7.2 哪些对象可以作为 GC Roots 呢?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
  • 所有被同步锁持有的对象

7.3 为什么采用可达性分析而不是引用记数?

  • 引用计数虽然简单,但可能发生对象间互相引用而无法被GC的情况,会造成内存泄漏

    引用记数法有一个严重的问题,即无法处理循环引用的情况。一个简单的循环引用问题的描述如下:有对象A和对象B,对象A中含有对象B的引用,对象B中含有 对象A的引用。此时,对象A和对象B的引用计数器都不为0,但是在系统中却不存在任何第3个对象引用了A或B。也就是说,A和B是应该被回收的垃圾对象,但是由于 垃圾对象之间相互引用,从而使垃圾回收器无法识别,引起内存泄漏。

image.png

将最后一个元素的next属性指向第一个元素,即引用第一个元素,从而构成循环引用。这个时候如果将列表的头head赋值为null,此时列表的各个元素的计数器都不为0,同时也失去了对列表的引用 控制,从而导致列表元素不能被回收。

引用计数器拥有一些特性,首先它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。其次,每次赋值都需要更新计数器,这增加了时间开销。再者,垃圾对象便于辨识,只要计数器为0,就可作为垃圾回收 ,接下来它能方便及时的回收垃圾,没有延迟性最后不能解决循环引用的问题,正是由于最后一条知名缺陷,导致在java的垃圾回收器中没有使用这类算法。

7.4 四种引用是什么?

  • 强引用:以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,垃圾回收器不会回收它。
  • 软引用:当触发GC时,如果内存空间足够,垃圾回收器就不会回收被软引用(例如softReference)所指的对象,只有当垃圾回收后内存空间不足了才会回收软引用所指的内存。
  • 弱引用:当触发Full GC时,无论内存空间足不足够,弱引用所指的对象都会被回收
  • 虚引用:如果一个对象仅持有虚引用,在任何时候都可能被垃圾回收。虚引用主要与ByteBuffer使用,当虚引用进入引用队列时,由一个Handler调用虚引用相关方法释放ByteBuffer的直接内存

ps:

  • 软引用和弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用弱引用所指的对象被垃圾回收,JAVA 虚拟机就会把这个软引用弱引用本身加入到与之关联的引用队列中,由一个优先级很低的Cleaner进行回收。
  • 虚引用必须配合引用队列使用

8. 内存泄露的理解与分类

8.1 何为内存泄漏(memory leak)

可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用。那么对于这种情况下,由于代码的实现不同就会出现很多内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。

主要关注两点:

  1. 是否还被使用?是
  2. 是否还被需要?否

8.2 内存泄漏(memory leak)的理解

严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。但实际情况很多时候一些不太好的实践(或疏忽)才导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。

8.3 内存泄漏与内存溢出的关系

  1. 内存泄漏(memory leak)申请了内存用完了不释放,比如一共有1024M的内存,分配了512M的内存一直不回收,那么可以用的内存只有512M了,仿佛泄漏掉了一部分;通俗一点来讲,内存泄漏就是【占着茅坑不拉屎】。
  2. 内存溢出(out of memory)申请内存时,没有足够的内存可以使用;通俗一点儿讲,一个厕所就三个坑,有两个站着茅坑不走的(内存泄漏),剩下最后一个坑,厕所表示接待压力很大,这时候一下子来了两个人,坑位(内存)就不够了,内存泄漏变成了内存溢出了。

8.4 内存泄漏与内存溢出的关系

内存泄漏的增多,最终会导致内存溢出。

8.5 泄漏的分类

  1. 经常发生:发生内存泄漏的代码会被多次执行,每次执行,泄漏一块内存;
  2. 偶然发生:在某些特定情况下才会发生;
  3. 一次性:发生内存泄漏的方法只会执行一次;
  4. 隐式泄漏:一直占着内存不释放,直到执行结束;严格的说这个不算内存泄漏,因为最终释放掉了,

图例

image.png

8.6 Java中内存泄漏的8种情况

8.6.1 静态集合类

静态集合类,如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的声明周期与JVM程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。

public class MemoryLeak {
   static List list = new ArrayList();
   
   public void oomTests() {
       Object obj = new Object();// 局部变量
       list.add(obj);
   }
8.6.2 单例模式

单例模式,和静态集合导致内存泄漏的原因类似,因为单例的静态特性,它的生命周期和JVM的生命周期一样长,所以如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏。

8.6.3 内部类持有外部类

内部类持有外部类,如果一个外部类的实例对象的方法返回了一个内部类的实例对象。这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。

8.6.4 各种连接,如数据库连接、网络连接和IO连接等

各种连接,如数据库连接、网络连接和IO连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、Statement或ResultSet不显性的关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

8.6.5 变量不合理的作用域

变量不合理的作用域。一般而言,一个变量的定义的作用范围大于其使用范围,很可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生。

8.6.6 改变哈希值

改变哈希值,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了。否则对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。这也是String为什么被设置成了不可变类型,我们可以放心的把String存如HashSet,或者把String当做HashMap的key值;

8.6.7 缓存泄漏

内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘。比如:之前项目在一次上线的时候,应用启动奇慢知道夯死,就是因为代码中加载一个表中的数据到缓存(内存)中,测试环境只有几百条数据,但是生产环境有几百万的数据。

对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值。

8.6.8 监听器和回调

内存泄漏另一个常见来源是监控器和其他回调,如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。

需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存为WeakHashMap中的键。

9.内存泄漏案例分析

案例代码

public class Stack {

    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        elements[size++] = e;
    }

    // 存在内存泄漏问题
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }

    private void ensureCapacity() {
        if (size == elements.length)
            elements = Arrays.copyOf(elements, size * 2 + 1);
    }
}

分析

上述程序并没有明显的错误,但是这段程序有一个内存泄漏,随着GC活动的增加,或者内存占用的不断增加,程序性能的降低就会表现出来,严重时可导致内存泄漏,但是这种失败情况相对较少。代码的主要问题在pop函数,下面通过这张图示展示,假设这个栈一直增长,增长后如下图所示。

image.png

当进行大量的pop操作时,由于引用未进行置空,gc是不会释放的,如下图所示

image.png

从上图中可以看出,如果栈先增长,在收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些对象,他们也不会回收,因为栈中任然保存着这些对象的引用,俗称过期引用,这个内存泄漏很隐蔽。

解决办法

    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size];
        elements[size] = null;
        return result;
    }

一旦引用过期,清空这些引用,将引用置空。

image.png

10.MAT

1. MAT 工具简介

MAT(全名:Memory Analyzer Tool),是一款快速便捷且功能强大丰富的 JVM 堆内存离线分析工具。其通过展现 JVM 异常时所记录的运行时堆转储快照(Heap dump)状态(正常运行时也可以做堆转储分析),帮助定位内存泄漏问题或优化大内存消耗逻辑。

1.1 MAT 使用场景及主要解决问题

场景一:内存溢出,JVM堆区或方法区放不下存活及待申请的对象。如:高峰期系统出现 OOM(Out of Memory)异常,需定位内存瓶颈点来指导优化。

场景二:内存泄漏,不会再使用的对象无法被垃圾回收器回收。如:系统运行一段时间后出现 Full GC,甚至周期性 OOM 后需人工重启解决。

场景三:内存占用高。如:系统频繁 GC ,需定位影响服务实时性、稳定性、吞吐能力的原因。

1.2 基础概念

1.2.1 Heap Dump

Heap Dump 是 Java 进程堆内存在一个时间点的快照,支持 HPROF 及 DTFJ 格式,前者由 Oracle 系列 JVM 生成,后者是 IBM 系列 JVM 生成。其内容主要包含以下几类:

  • 所有对象的实例信息:对象所属类名、基础类型和引用类型的属性等。
  • 所有类信息:类加载器、类名、继承关系、静态属性等。
  • GC Root:GC Root 代表通过可达性分析来判定 JVM 对象是否存活的起始集合。JVM 采用追踪式垃圾回收(Tracing GC)模式,从所有 GC Roots 出发通过引用关系可以关联的对象就是存活的(且不可回收),其余的不可达的对象(Unreachable object:如果无法从 GC Root 找到一条引用路径能到达某对象,则该对象为Unreachable object)可以回收。
  • 线程栈及局部变量:快照生成时刻的所有线程的线程栈帧,以及每个线程栈的局部变量。

1.2.2 Shallow Heap

Shallow Heap 代表一个对象结构自身所占用的内存大小,不包括其属性引用对象所占的内存。如 java.util.ArrayList 对象的 Shallow Heap 包含8字节的对象头、8字节的对象数组属性 elementData 引用 、 4字节的 size 属性、4字节的 modCount 属性(从 AbstractList 继承及对象头占用内存大小),有的对象可能需要加对齐填充但 ArrayList 自身已对齐不需补充,注意不包含 elementData 具体数据占用的内存大小。

1.2.3 Retained Set

一个对象的 Retained Set,指的是该对象被 GC 回收后,所有能被回收的对象集合(如下图所示,G的 Retain Set 只有 G 并不包含 H,原因是虽然 H 也被 G 引用,但由于 H 也被 F 引用 ,G 被垃圾回收时无法释放 H);另外,当该对象无法被 GC 回收,则其 Retained set 也必然无法被 GC 回收。

1.2.4 Retained Heap

Retained Heap 是一个对象被 GC 回收后,可释放的内存大小,等于释放对象的 Retained Heap 中所有对象的 Shallow Heap 的和(如下图所示,E 的 Retain Heap 就是 G 与 E 的 Shallow Heap 总和,同理不包含 H)。

1.2.5 Dominator tree

如果所有指向对象 Y 的路径都经过对象 X,则 X 支配(dominate) Y(如下图中,C、D 均支配 F,但 G 并不支配 H)。Dominator tree 是根据对象引用及支配关系生成的整体树状图,支配树清晰描述了对象间的依赖关系,下图左的 Dominator tree 如下图右下方支配树示意图所示。支配关系还有如下关系:

  • Dominator tree 中任一节点的子树就是被该节点支配的节点集合,也就是其 Retain Set。
  • 如果 X 直接支配 Y,则 X 的所有支配节点均支配 Y。

img

1.2.6 OQL

OQL 是类似于 SQL 的 MAT 专用统一查询语言,可以根据复杂的查询条件对 dump 文件中的类或者对象等数据进行查询筛选。

1.2.7 references

outgoing references、incoming references 可以直击对象间依赖关系,MAT 也提供了链式快速操作。

  • outgoing references:对象引用的外部对象(注意不包含对象的基本类型属性。基本属性内容可在 inspector 查看)。
  • incoming references:直接引用了当前对象的对象,每个对象的 incoming references 可能有 0 到多个。
2. MAT 功能概述及对比
2.1 MAT 功能概述

注:MAT 的产品能力非常丰富,本文简要总结产品特性帮大家了解全貌,在下一篇文章《JVM 内存分析实战进阶篇——核心功能及应用场景》中,会详细展开介绍各项核心功能的场景、案例、最佳实践等。

MAT 的工作原理是对 dump 文件建立多种索引,并基于索引来实现 [1]内存分布、[2]对象间依赖(如实体对象引用关系、线程引用关系、ClassLoader引用关系等)、[3]对象状态(内存占用量、字段属性值等)、[4]条件检索(OQL、正则匹配查询等)这四大核心功能,并通过可视化展现辅助 Developer 精细化了解 JVM 堆内存全貌。

2.1.1 内存分布

  • 全局概览信息:堆内存大小、对象个数、类的个数、类加载器的个数、GC root 个数、线程概况等全局统计信息。
  • Dominator tree:按对象的 Retain Heap 排序,也支持按多个维度聚类统计,最常用的功能之一。
  • Histogram:罗列每个类实例的内存占比,包括自身内存占用量(Shallow Heap)及支配对象的内存占用量(Retain Heap),支持按 package、class loader、super class、class 聚类统计,最常用的功能之一。
  • Leak Suspects:直击引用链条上占用内存较多的可疑对象,可解决一些基础问题,但复杂的问题往往帮助有限。
  • Top Consumers:展现哪些类、哪些 class loader、哪些 package 占用最高比例的内存。

2.1.2 对象间依赖

  • References:提供对象的外部引用关系、被引用关系。通过任一对象的直接引用及间接引用详情(主要是属性值及内存占用),进而提供完善的依赖链路详情。
  • Dominator tree:支持按对象的 Retain Heap 排序,并提供详细的支配关系,结合 references 可以实现大对象快速关联分析;
  • Thread overview:展现转储 dump 文件时线程栈帧等详细状态,也提供各线程的 Retain Heap 等关联内存信息。
  • Path To GC Roots:提供任一对象到GC Root的链路详情,帮助了解不能被 GC 回收的原因。

2.1.3 对象状态

  • 最核心的是通过 inspector 面板提供对象的属性信息、类继承关系信息等数据,协助分析内存占用高与业务逻辑的关系。
  • 集合状态的检测,如:通过 ArrayList 或数组的填充率定位空集合空数组造成的内存浪费、通过 HashMap 冲突率判定 hash 策略是否合理等。

2.1.4 按条件检索对象

  • OQL:提供一种类似于SQL的对象(类)级别统一结构化查询语言。如:查找 size=0 且未使用过的 ArrayList: select * from java.util.ArrayList where size=0 and modCount=0;查找所有的String的length属性的: select s.length from instanceof String s。
  • 内存分布及对象间依赖的众多功能,均支持按字符串检索、按正则检索等操作。
  • 按虚拟内存地址寻址,根据对象的十六进制地址查找对象。

此外,为了便于记忆与回顾,整理了如下脑图:

img

2.2 常见内存分析工具对比

下图中 Y 表示支持,N 表示不支持,时间截至发稿前。

产品功能MATJProfilerVisual VMjhatjmaphprof
对象关联分析、深浅堆、GC ROOT、内存泄漏检测、线程分析、提供自定义程序扩展扩展YNNNNN
离线全局分析YNYYNN
内存实时分配情况NYYYYY
OQLYNYNNN
内存分配堆栈、热点比例NYNNNN
堆外内存分析NNNNNN

注 1:Dump 文件包含快照被转储时刻的 Java 对象 在堆内存中的分布情况,但快照只是瞬间的记录,所以不包含对象在何时、在哪个方法中被分配这类信息。

注 2:一般堆外内存溢出排查可结合 gperftools 与 btrace 排查,此类文章较多不展开介绍。

3. Quick Start 及使用技巧
3.1 Quick Start

注:Quick Start 文章较多,本文着重介绍安装流程及使用技巧。

1、安装 MAT:戳【下载链接】;也可直接集成到 Eclipse IDE中(路径:Eclipse → Help → Eclipse Marketplace → 搜 “MAT”)。

2、调节 MAT 堆内存大小:MAT 分析时也作为 Java 进程运行,如果有足够的内存,建议至少分配 dump 文件大小*1.2 倍的内存给 MAT,这样分析速度会比较快。方式是修改MemoryAnalyer.ini文件,调整Xmx参数(Windows 可用搜索神器 everything 软件查找并修改、MAC OS 一般在 /Applications/mat.app/Contents/Eclipse/MemoryAnalyzer.ini,如找不到可用 Alfred 软件查询修改)。

3、获取堆快照 dump 文件(堆转储需要先执行 Full GC,线上服务使用时请注意影响),一般用三种方式:

  • 使用 JDK 提供的 jmap 工具,命令是 jmap -dump:format=b,file=文件名 进程号。当进程接近僵死时,可以添加 -F 参数强制转储:jmap -F -dump:format=b,file=文件名 进程号。
  • 本地运行的 Java 进程,直接在 MAT 使用 File → accquire heap dump 功能获取。
  • 启动 Java 进程时配置JVM参数:-XX:-HeapDumpOnOutOfMemoryError,当发生 OOM 时无需人工干预会自动生成 dump文件。指定目录用 -XX:HeapDumpPath=文件路径 来设置。

4、分析 dump 文件:路径是 File → Open Heap Dump ,然后 MAT 会建立索引并分析,dump 文件较大时耗时会很长。分析后 dump 文件所在目录会有后缀为 index 的索引文件,也会有包含 HTML 格式的后缀为 zip 的文件。

5、完成索引计算后,MAT 呈现概要视图(Overview),包含三个部分:

  • 全局概览信息,堆内存大小、类数量、实例数量、Class Loader数量。
  • Unreachable Object Histogram,展现转储快照时可被回收的对象信息(一般不需要关注,除非 GC 频繁影响实时性的场景分析才用到)
  • Biggest Objects by Retained Size,展现经过统计过的哪几个实例所关联的对象占内存总和较高,以及具体占用的内存大小,一般相关代码比较简单情况下,往往可以直接分析具体的引用关系异常,如内存泄漏等。此外也包含了最大对象和链接支持继续深入分析。 img

6、如果代码比较复杂,需要继续使用 MAT 各种工具并结合业务代码进一步分析内存异常的原因。最常用的几项如下(具体案例、场景、使用方式在《JVM 内存分析工具 MAT 的深度讲解与实践——进阶篇》详细介绍):

  • 查看堆整体情况的:Histogram、Dominator tree、Thread details等(各功能入口整理如下) img
  • MAT 分析过的 Top Consumers 、Leak Suspects等 img
3.2 使用技巧及注意事项

1、注意对运行进程的性能影响:Heap dump 时会先进行 Full GC,另外为保证对象数据视图一致,需要在安全点 Stop The World 暂停响应,线上服务进行务必注意性能影响。可以采取以下技巧减少影响:

  • 先禁用入口流量,再执行 dump 动作。
  • 选择影响较小时 dump 内存。
  • 使用脚本捕获指定事件时 dump 内存。

2、Dump 文件及建立的索引文件可能较大,如果开发机配置不足无法分析,可在服务器先执行分析后,基于分析后的索引文件直接查看结果,另外也需要注意磁盘占用问题:

  • 大文件分析方法:一般 dump 文件不高于分析机主存 1.2 倍可直接在开发机分析;若 dump 文件过大,可以使用 MAT 提供的脚本在配置高的高配机器先建立索引再直接展现索引分析结果(一般是 Linux 机器,可以使用 MAT 提供的脚本:./ParseHeapDump.sh $HEAPDUMP,堆信息有 unreachable 标记的垃圾对象,在 dump 时也保存了下来,默认不分析此部分数据,如需要在启动脚本 ParseHeapDump.sh 中加入:-keep_unreachable_objects)。
  • 如果不关注堆中不可达对象,使用“live”参数可以减小文件大小,命令是 jmap -dump:live,format=b,file=
  • Dump 前主动手动执行一次 FULL GC ,去除无效对象进一步减少 dump 堆转储及建立索引的时间。
  • Dump文件巨大,建立索引后发现主视图中对象占用内存均较小,这是因为绝大部分对象未被 GC Roots 引用可释放。
  • Dump 时注意指定到空间较大的磁盘位置,避免打满分区影响服务。
  • 建立 dump 索引机器的磁盘空间需要足够大,一般至少是 dump 文件的两倍,因为生成的中间索引文件也较大,如下图: img

3、其他

  • JDK 版本问题:如遇“VMVersionMismatchException”,使用启动目标进程的 JDK 版本即可。

  • 部分核心功能主界面未展现,问题足够复杂时需打开,如 MAT 默认不打开 inspector,如需根据对象数据值做业务分析,建议打开该视图。

  • 配置了 HeapDumpOnOutOfMemoryError 参数,但 OutOfMemoryError 时但没有自动生成 dump 文件,可能原因有三个:

    • 应用程序自行创建并抛出 OutOfMemoryError
    • 进程的其他资源(如线程)已用尽
    • C 代码(如 JVM 源码)中堆耗尽,这种可能由于不同的原因而出现,例如在交换空间不足的情况下,进程限制用尽或仅地址空间的限制,此时 dump 文件分析并无实质性帮助。