写在前面:在浏览了大量了面试经历之后发现,其实在 JVM 这个模块考察的最多的就是垃圾回收机制。所以写此文来学习一下有关的内容。
一、哪些内存需要回收
堆、方法区。
一个接口的多个实现类需要的内存可能不一样,一个方法执行的不同分支条件需要的内存也不一样。只有运行期间,才能知道程序究竟创建了哪些对象,这部分的内存的分配和回收是动态的,垃圾收集器所关注的也是这一部分内存,我们所指的内存分配与回收也是指这一部分内存。
二、怎么定义垃圾
1. 引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。这就是引用计数算法,听上去感觉很有道理,但是主流的 Java 虚拟机都没有采用这个算法,因为它存在一个弊端:很难解决对象之间相互循环引用的问题。
举个例子:假设对象objA和objB都有字段instance。然后执行下面的代码:
objA.instance=objB;
objB.instance=objA
除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
2. 可达性分析算法
可达性分析算法的思路是:
通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点根据引用关系向下搜索,搜索过程走过的路径叫做引用链,如果某个对象到 GC Roots 之间没有任何引用链相连,或者说从 GC Roots 到目标对象不可达时,则该对象是不可能在被使用的。
如图所示,尽管 object5、object6、object7 互相关联,但是对于 GC Roots 来说他们都是不可达的,那么它们就会被判断为可回收的对象。
可以做 GC Roots 的对象包括
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
3. 回收方法区
方法区的垃圾回收主要回收两个部分——废弃的常量和不再使用的类型。
假如说在字符串常量池里面存在一个字符串 “a” ,但是目前没有任何一个字符串对象的值是 “a”,同时也在任何地方没有引用这个 “a”,那么在发生内存回收的时候,这个 “a” 就会被清理掉。
什么是“不再被使用”呢?所谓的“不再被使用”需要满足 3 个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
三、怎么回收垃圾
有 4 种回收垃圾的理论:分代收集理论、标记-清除算法、标记-复制算法-标记-整理算法,本文主要解读分代收集理论 和 标记-复制算法。
分代收集理论本质上是一套符合大多数程序实际运行情况的经验法则,建立在两个假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾回收的对象越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
标记-复制算法的运行方式是:
把可用的内存分为大小相同的两块区域,每次只使用其中的一块。当这一块内存用完了,就会去扫描这块内存,把所有存活的对象复制到另一块没有使用的内存上,然后把原来的那块内存一次清理掉。这样实现非常简单高效,但是缺点就是可用内存变成了原来的一半。
后来根据对象具备朝生夕死的特点,提出了 Appel式回收 。把新生代划分为一块较大的 Eden 空间和 两块较小的 Survivor 空间。每次分配内存只使用 Eden 和其中的一块 Survivor 。发生垃圾回收时,将这两个空间里仍然存活的对象一次性复制到另一块 Survivor 空间上,然后把原来的两个空间直接清理掉。HotSpot 虚拟机默认 Eden 和 Survivor 的比例是 8:1 。也就是说每次新生代的可用空间是整个新生代容量的90%。他们测试出了在“普通场景”下,98%的对象是可以被回收的,但是没有人能保证每次都是只有不超过10%的对象存活,所以 Appel式回收 也留了后手 —— 如果 Survivor 空间不能够容纳一次 Minor GC 后存活的对象,就要依赖其他的内存区域(如老年代)进行分配担保
四、分代回收机制
分代收集理论在上面已经提及了,这里不再赘述。
在实际的 Java 虚拟机当中,设计者会把 Java 堆至少分成两个部分:新生代 和 老年代 。
在 新生代 中,每次垃圾回收都会有大量的对象被清理,每次回收之后剩下的少量对象则会逐步晋升到 老年代 中存放。
从字面上看上去,分代回收机制的基本理论非常好,高频的回收新生代的内容,低频率回收老年代的内容。但是实际上这里面存在一种情况:对象并不是孤立的,老年代中的对象可能会引用新生代里的对象,那么不得不在固定的 GC Roots 之外,再去遍历老年代里面所有的对象来确保可达性分析的正确性,反之也是如此。这样一来,无异于增大了很多性能上的负担。所以分代收集理论便拥有了第三条经验法则
- 跨代引用假说:跨代引用相对于同代引用仅占极少数。
根据这个假说我们直接就理解了:跨代引用的情况非常少见,尽管我们需要处理,但是实质上它并不会频繁发生。所以我们就不必要为了少量的跨代引用就遍历全部的老年代,也没必要去开辟一块空间去记录每个对象是否存在跨代引用。只要在新生代上建立一个全局的数据结构,称之为记忆集。这个结构把老年代划分为小块并标识出哪一块内存存在跨代引用。以后再发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。
五、对象如何晋升到老年代
虚拟机给每个对象定义了一个年龄计数器。对象通常在 Eden 区产生,如果经过第一次 Minor GC 后仍然存活并且被 Survivor 区容纳,那么就会把对象移动到 Survivor 区中,并将年龄设为 1岁 。
对象每在 Survivor 区中熬过一次 Minor GC ,年龄就会 + 1 。当年龄达到一定程度(默认15岁),就会被晋升到老年代。
年龄阈值可以通过设置 MaxTenuringThreshold 属性来调整。
六、新生代为什么要分为Eden和Survivor
参考第三章 —— 怎么回收垃圾 里面的 标记-复制算法
标记-复制算法更加高效,但是用 1:1 的比例划分内存十分不划算(因为“普通环境”下有98%的对象熬不过第一轮收集)。所以提出了 Appel式回收 ,在HotSpot虚拟机中,是把内存按照 8:1:1 划分出1个 Eden 和2个Survivor。每次分配内存只使用 Eden 和其中的一块 Survivor 。发生垃圾回收时,将这两个空间里仍然存活的对象一次性复制到另一块 Survivor 空间上,然后把原来的两个空间直接清理掉。
七、为什么设置2个Survivor
最大的好处是解决内存的碎片化。我们不妨假设只存在1个Survivor会出现什么情况:
Minor GC 执行后,Eden 区的对象被清空了,存活的对象被复制到 Survivor 中,但是这个 Survivor 中也可能存在需要被清除的对象。那么这种情况下只能对 Survivor 区进行 标记-清除算法 ,这个算法最大的问题就会产生大量内存碎片。而在新生代这种经常会有对象消亡的区域,内存碎片的产生就会更加严重。而设置2个 Survivor ,每次都是把存活的对象复制到其中的一个 Survivor 里面,就能保证总有1个 Survivor 是空的(被清理掉了),另一个 Survivor 是没有碎片的(被复制过去的)。
为什么不设置更多的 Survivor
内存的总大小是固定的,Survivor 越多,每个区域的容量就会越小,就会容易区满,两块 Survivor 是经过权衡之后的最佳方案。
八、为什么新生代和老年代要采用不同的算法
参考第三章 —— 怎么回收垃圾 里面的 分代收集理论
如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
九、为什么老年代不能使用标记-复制算法
因为 标记-复制算法 是把存活的对象复制到某个内存块中,而老年代存放的都是存活率较高的对象。这样一来如果使用标记-复制算法,就有大量的老年代对象需要复制,效率就会降低。所以一般来说老年代不能使用标记-复制算法。
十、GC相关
1. JVM 中,一次完整的GC流程是什么样子
首先我们要回到8:1:1的新生代划分来开始。从前文我们可以知道,新生代会被以 8:1:1 的形式划分出 1个 Eden 和2个 Survivor。创建对象的时候只会使用到 Eden 和其中的1个 Survivor ,在垃圾搜索的时候才会用到另一块 Survivor。明白了这一点,就可以来看 完整的GC流程 是怎样的了。
在正式的 Minor GC 之前,JVM 会先判断新生代中的对象是比老年代剩余空间更大还是小。目的是如果 Minor GC 后,Survivor 存不下存活的对象,那就得把他们转移到老年代中(标记-复制算法有提及这件事情)。所以要提前判断老年代是否够用。这时候存在2中情况。
i. 老年代剩余空间充足 -> 直接 Minor GC,哪怕 Survivor 不够放,也可以放到老年代里面
ii. 老年代剩余空间不足。这时候要看是否启用了老年代空间分配担保规则。
如果老年代剩余空间的大小大于过去的 Minor GC 后剩余的对象的大小,那么就执行 Minor GC。因为从概率上说,之前能放下的现在也能放下,
如果小于的话,就要进行 Full GC ,把老年代空出来再检查。
即便是启用了老年代空间分配担保规则,也不能保证万无一失,所以 Minor GC 后有下面几种情况会发生
首先是成功GC的3种情况:
- Minor GC 后剩余的对象能放在 Survivor 种,GC 结束。
- 不能在 Survivor 中完全放下,但是放完 Survivor 之后剩下的对象可以在老年代放下,GC 结束。
- 不能在 Survivor 中完全放下,老年代也放不下,进行 Full GC。
然后是失败GC的3种情况,GC失败会报OOM:
- 在上面第三步执行 Full GC 后,老年代还是放不下剩余对象,OOM。
- 没有开老年代空间分配担保规则,且一次 Full GC 后,老年代放不下剩余对象,OOM。
- 开了担保规则,但是担保不通过,一次 Full GC 后,老年代放不下剩余对象,OOM。
2. JVM 什么时候触发 GC ,如何减少 Full GC
当 Eden 区空间耗尽的时候就会触发 Minor GC。
serial GC 中,老年代内存剩余已经小于之前年轻代晋升老年代的平均大小,则进行 Full GC。而在 CMS等并发收集器中则是每隔一段时间检查一下老年代内存的使用量,超过一定比例时进行 Full GC 回收。
可以采用以下措施来减少Full GC的次数:
- 增加方法区的空间;
- 增加老年代的空间;
- 减少新生代的空间;
- 禁止使用System.gc()方法;
- 使用标记-整理算法,尽量保持较大的连续内存空间;
- 排查代码中无用的大对象。
3. Full GC会导致什么
会 Stop The World,GC期间全程暂停用户的应用程序
4. 对GC算法的了解
4.1 标记-清除算法
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点:
- 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
4.2 标记-复制算法
前文以多次讲解该算法,不再阐述,直接访问 [三、怎么回收垃圾] 即可
4.3 标记-整理算法
与标记-清除算法类似,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
十一、垃圾收集器
1. G1垃圾收集器
G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数 G1HeapRegionSize 设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的 Humongous Region 之中,G1的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的价值大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
2. CMS垃圾收集器
2.1 CMS的概念
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤,包括:
- 初始标记(CMS initial mark);
- 并发标记(CMS concurrent mark);
- 重新标记(CMS remark);
- 并发清除(CMS concurrent sweep)。
初始标记是标记一下GC Roots能直接关联到的对象,速度很快
并发标记是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
并发清除,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
2.2 CMS的缺点
- 对处理器资源非常敏感。并发阶段虽然不会导致用户线程停顿,但是因为占用了一部分线程,会导致应用程序变慢。
- CMS无法处理浮动垃圾,可能会导致“Con-current Mode Failure”失败进而导致另一次完全“Stop TheWorld”的Full GC的产生。
- CMS是基于标记-清除算法实现的,而这个算法本身就会导致大量内存碎片产生。在后续大对象分配时,老年代往往还有大量剩余空间,但是找不到连续的内存空间进行分配,所以不得不再触发一次 Full GC 。
2.3 浮动垃圾
在并发标记阶段本来可达的对象,由于用户线程的作用变得不可达了,即产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终导致这些新产生的垃圾对象没有被及时回收。这些垃圾就是浮动垃圾。
问:既然浮动垃圾是再并发标记阶段产生的,那再进行重新标记不就解决了吗?
答案是否定的。
标记阶段是从 GC Roots 开始标记可达对象,那么在并发阶段会有两种情况发生:
- 本来可达的对象变得不可达了
- 本来不可达的对象变得可达了
对于第二种情况,在并发标记的阶段中,用户 new 了一个对象,但是因为初始标记和并发标记都无法从 GC Roots 可达,如果在重新标记阶段还没有把这个对象标记为可达,导致这个对象被清理,这是非常严重的错误。所以这种情况被CMS妥善处理了。
而对于第一种情况相对来看就允许发生了,毕竟这个对象曾经是可达的。如果强行要把这个对象也清理,那么就需要重新从 GC Roots 开始遍历,相当于重新回到了第一步初始标记,这样会增加重新标记的开销,带来更大的时间消耗,而CMS的理念本就是获取最短回收停顿时间,显然这是不能容忍的。
十二、内存的泄露与溢出
1. 内存泄漏
内存泄漏的根本原因是长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象已经不再需要,但由于长生命周期对象持有它的引用而导致不能被回收。以发生的方式来分类,内存泄漏可以分为4类:
- 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
- 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
- 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。
- 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。
避免内存泄漏的几点建议:
-
尽早释放无用对象的引用。
-
避免在循环中创建对象。
-
使用字符串处理时避免使用String,应使用StringBuffer。
-
尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收。
2. 内存溢出
内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。引起内存溢出的原因有很多种,常见的有以下几种:
- 内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
- 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
- 代码中存在死循环或循环产生过多重复的对象实体;
- 使用的第三方软件中的BUG;
- 启动参数内存值设定的过小。
内存溢出的解决方案:
- 修改JVM启动参数,直接增加内存。
- 查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
- 对代码进行走查和分析,找出可能发生内存溢出的位置。
- 使用内存查看工具动态查看内存使用情况。
3. 区别
内存泄漏:指程序运行过程中分配内存给临时变量,用完之后却没有被GC回收,始终占用着内存,既不能被使用也不能分配给其他程序,于是就发生了内存泄漏。
内存溢出:指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
十三、哪些区域会OOM,怎么触发OOM
除了程序计数器之外,都有发生OOM(Out Of Memory)的可能。
-
Java 堆溢出
Java 堆用来存放对象的实例,我们会不断的创建对象,并保证 GC Roots 到对象之间总是可达的,这样就不会让垃圾回收机制回收我们的对象。在这个情况下我们不断创建对象,随者对象数量的增加,总容量超过最大堆的容量时就会产生OOM异常。
-
虚拟机栈和本地方法栈溢出
HotSpot 虚拟机并没有区分虚拟机栈和本地方法栈,如果虚拟机的栈内存允许动态扩展,则在扩展栈容量的时候如果没有申请到足够的内存,就会产生OOM异常。
-
方法区和运行常量池溢出
方法区溢出也是一种常见的内存溢出异常,在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景常见的包括:程序使用了CGLib字节码增强和动态语言、大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使同一个类文件,被不同的加载器加载也会视为不同的类)等。
在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,即常量池是方法区的一部分,所以上述问题在常量池中也同样会出现。而HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代,所以上述问题在JDK 8中会得到避免。 -
本地直接内存溢出
是一种常见的内存溢出异常,在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景常见的包括:程序使用了CGLib字节码增强和动态语言、大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使同一个类文件,被不同的加载器加载也会视为不同的类)等。
在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,即常量池是方法区的一部分,所以上述问题在常量池中也同样会出现。而HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代,所以上述问题在JDK 8中会得到避免。
-
本地直接内存溢出
直接内存的容量大小可通过 MaxDirectMemorySize 参数来指定,如果不指定,则默认与Java堆最大值一致。如果直接通过反射获取 Unsafe 实例进行内存分配,并超出了上述的限制时,将会引发OOM异常。