终于把JVM垃圾回收的来龙去脉搞清楚了
1 什么是垃圾回收
垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
2 哪些空间的垃圾需要回收
程序员们都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法区、堆区、方法区。
其中程序计数器、虚拟机栈、本地方法区3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆区和方法区则不一样!这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。
垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法!
3 如何定义垃圾
3.1 引用计数算法
引用计数算法(Reachability Counting)是通过在堆中的每个对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数减1。
优点:引用计数算法可以很快的执行,因为它是将垃圾回收分摊到整个应用程序的运行当中了,而不是在进行垃圾收集时,要挂起整个应用的运行,直到对堆中所有对象的处理都结束。因此,采用引用计数的垃圾收集不属于严格意义上的"Stop-The-World"的垃圾收集机制。它对程序需要不被长时间打断的实时环境比较有利。
缺点:无法检测出循环引用。这样就导致一些循环引用的引用计数不可能为0,导致永远无法会回收。
什么原因导致我们最终放弃了引用计数算法呢?
如下面的代码,定义2个对象,相互引用,然后置空各自的声明引用,最后这2个对象已经不可能再被访问了, 但由于他们相互引用着对方,导致它们的引用计数永远都不会为0,通过引用计数算法,也就永远无法通知GC收集器回收它们。
3.2 可达性分析算法
可达性分析算法(Reachability Analysis)的基本思路是,通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下寻找对应的引用节点(Reference Chain),找到这个节点后以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用的节点,即无用的节点,无用的节点将被会判定为是可回收的对象。
通过可达性算法,成功解决了引用计数所无法解决的问题-“循环依赖”,只要你无法与 GC Root 建立直接或间接的连接,系统就会判定你为可回收对象。那这样就引申出了另一个问题,哪些属于 GC Root。
3.3 Java内存区域
在 Java 语言中,可作为 GC Root 的对象包括以下4种:
-
虚拟机栈中引用的对象(栈帧中的本地变量表)
-
方法区中类静态属性引用的对象
-
方法区中常量引用的对象
-
本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
(1) 虚拟机栈中引用的对象(栈帧中的本地变量表)
此时的 s,即为 GC Root,当s置空时,localParameter 对象也断掉了与 GC Root 的引用链,将被回收。
(2)方法区中类静态属性引用的对象
s 为 GC Root,s 置为 null,经过 GC 后,s 所指向的 properties 对象由于无法与 GC Root 建立关系被回收。
而 m 作为类的静态属性,也属于 GC Root,parameter 对象依然与 GC root 建立着连接,所以此时 parameter 对象并不会被回收。
(3) 方法区中常量引用的对象
m 即为方法区中的常量引用,也为 GC Root,s 置为 null 后,final 对象也不会因没有与 GC Root 建立联系而被回收。
(4)本地方法栈中引用的对象
任何 native 接口都会使用某种本地方法栈,实现的本地方法接口是使用 C 连接模型的话,那么它的本地方法栈就是 C 栈。当线程调用 Java 方法时,虚拟机会创建一个新的栈帧并压入 Java 栈。然而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不再在线程的 Java 栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。
3.4 Java中的引用你了解多少
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用4种,这四种引用强度依次逐渐减弱。
- 强引用
在程序代码中我们使用最普遍的就是强引用,类似 Object obj = new Object() 这类引用。如果一个对象具有强引用,只要强引用还存在,那就类似于必不可少的生活用品,垃圾收集器绝不会回收它。
当内存不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
在ArrayList类中定义了一个私有的变量elementData数组,在调用方法清空数组时可以看到为每个数组内容赋值为null。不同于elementData=null,强引用仍然存在,避免在后续调用 add()等方法添加元素时进行重新的内存分配。使用如clear()方法中释放内存的方法对数组中存放的引用类型特别适用,这样就可以及时释放内存。
- 软引用(SoftReference)
用来描述一些还有用但并非必须的对象,就类似于可有可无的生活用品。对于软引用关联着的对象,在系统将要发生内存溢出异常之前(内存空间不足时),将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
软引用可用来实现内存敏感的高速缓存。在实际中有重要的应用,例如浏览器的后退按钮。
按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。
(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
这时候就可以使用软引用。
- 弱引用(WeakReference)
弱引用是用来描述非必需对象的,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:具有弱引用的对象拥有更短暂的生命周期。它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
如果一个对象只是偶尔使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么就可以用弱引用来记住此对象。
- 虚引用(PhantomReference)
虚引用就是形同虚设,与其他几种引用都不同。如果一个对象仅持有虚引用,那么它和没有任何引用一样,在任何时候都可能被垃圾回收。它的作用是能在这个对象被收集器回收时收到一个系统通知。
特别注意,在实际程序设计中一般很少使用弱引用和虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出等问题的产生。
特别说明:无论引用计数算法还是可达性分析算法都是基于强引用而言的。
3.5 对象死亡(被回收)前的最后一次挣扎
即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
-
第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
-
第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记。
第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。
3.6 方法区如何判断是否需要回收
方法区存储内容是否需要回收的判断和堆中是不一样的。方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:
-
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
-
加载该类的ClassLoader已经被回收;
-
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
关于类加载的原理,也是阿里面试的主角,面试官也问过比如:能否自己定义String,答案是不行,因为jvm在加载类的时候会执行双亲委派。
4 怎么回收垃圾
下面讨论几种常见的垃圾收集算法的核心思想。
4.1 标记-清除算法(Mark-Sweep)
标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为2部分,从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,如上图所示,标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
我们知道开辟内存空间时,需要的是连续的内存区域,如果内存碎片都是1M大小的话,这时候我们若需要一个 2M的内存区域,其中有2个 1M 是没法用的。这样就导致,其实我们本身还有这么多的内存的,但却用不了。
4.2 复制算法(Copying)
复制算法(Copying)是在标记清除算法上演化而来,解决标记清除算法的内存碎片问题。它开始时把堆分成一个对象面和多个空闲面, 程序从对象面为对象分配空间,当对象面满了,基于copying算法的垃圾收集就从根集合(GC Roots)中扫描活动对象,并将每个活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
这样就保证了内存空间的连续可用,内存分配时也就不用考虑内存碎片等复杂情况,逻辑清晰,运行高效。然而很明显暴露了另一个问题,空间浪费,代价实在太高。
4.3 标记-整理算法(Mark-Compact)
标记整理算法(Mark-Compact)标记过程仍然与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,并更新对应的空闲指针,然后再清理掉端指针边界以外的内存区域。
标记整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但从上图可以看到,它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。
4.4 分代收集算法
分代收集算法(Generational Collection)严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。
对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理或者标记 - 整理算法来进行回收。
so,另一个问题来了,那内存区域到底被分为哪几块,每一块又有什么特别适合什么算法呢?
4.5 内存模型与回收策略
Java 堆(Java Heap)是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域,这里我们主要分析一下 Java 堆的结构。
Java堆主要分为2个区域:新生代与老年代,其中新生代内存按照8:1:1的比例又分为 Eden 区和 两个Survivor区( From 和 To 2个区)。
可能这时候大家会有疑问,为什么要分为新生代与老年代呢?而新生代为什么又需要Survivor 区,为什么Survivor区还要再分2个区呢。别急,下面咱就絮叨絮叨。
4.5.1 新生代的回收算法(回收以Copying复制算法为主)
所有新生成的对象首先都是放在新生代的,新生代的目标就是尽可能的快速收集那些生命周期短的对象。
大多数情况下,对象会在新生代 Eden 区中进行分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。
进行Minor GC时,会将Eden区无需回收的对象复制到Survivor的From区(若From区不够,则直接进入Old区),然后清空Eden区。当From区也存放满了时,会将Eden区和From存活的对象放到Survivor的To区,然后清空Eden区和Survivor的From区。此时Survivor的From区是空的,然后将Survivor的From区和To区交换,即保持Survivor的To区为空,如此往复。
当Survivor的To区空间不够,不足以存放Eden 区和 From 存活的对象事,就会将存活对象直接存放到 老年代(Old 区)。
新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
(1)为啥需要Survivor区?
不就是新生代到老年代么,直接 Eden 到 Old 不好了吗,为啥要这么复杂。想想如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。
所以,Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。它存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历16次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。
(2)为啥Survivor需要两个区?
设置两个Survivor区最大的好处就是解决内存碎片化。
我们先假设一下,Survivor如果只有一个区域会怎样。Minor GC执行后,Eden区被清空了,存活的对象放到了Survivor区,而之前Survivor区中的对象,可能也有一些是需要被清除的。
问题来了,这时候我们怎么清除它们?在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。
因为Survivor有2个区域,所以每次Minor GC,会将之前Eden区和From区中的存活对象复制到To区域。第二次Minor GC时,From与To职责兑换,这时候会将 Eden区和To区中的存活对象再复制到From区域,以此反复。(职责会互换)
这种机制最大的好处就是,整个过程中,永远有一个Survivor space是空的,另一个非空的Survivor space是无碎片的。那么,Survivor为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor区再细分下去,每一块的空间就会比较小,容易导致Survivor区满,两块Survivor区可能是经过权衡之后的最佳方案。
4.5.2 老年代的回收算法(回收以标记-整理算法为主)
老年代占据着2/3的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记-整理算法。
除了上述所说,在内存担保机制下,无法安置的对象会直接进到老年代,以下几种情况也会进入老年代。
-
(1)大对象:大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及2个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,得注意了。
-
(2)长期存活对象: 虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中没经历一次 Minor GC,年龄就增加1岁。当年龄增加到15岁时,这时候就会被转移到老年代。当然,这里的15,JVM 也支持进行特殊设置。
-
(3)动态对象年龄: 虚拟机并不重视要求对象年龄必须到15岁,才会放入老年区,如果 Survivor 空间中相同年龄所有对象大小的总合大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区,无需等你“成年”。这其实有点类似于负载均衡,轮询是负载均衡的一种,保证每台机器都分得同样的请求。看似很均衡,但每台机的硬件不通,健康状况不同,我们还可以基于每台机接受的请求数,或每台机的响应时间等,来调整我们的负载均衡算法。
5 GC是什么时候触发的
GC分为两种:Major GC(或称为Full GC)和minor GC,老年代采用标记-整理算法的Major GC,新生代采用复制算法的minor GC。新生代是GC收集垃圾的频繁区域。
在最近几个版本的JDK里默认包括了对永生带即方法区的回收(JDK8中无永生带了),出现Full GC的时候经常伴随至少一次的Minor GC,但非绝对的。Major GC的速度一般会比Minor GC慢10倍以上。下边看看有那种情况触发JVM进行Full GC及应对策略。
Minor GC触发条件:
一般情况下,当新对象生成,并且在Eden区申请空间失败时,就会触发触发Minor GC。
Full GC触发条件:
(1)System.gc()方法的调用
此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。强烈影响系建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过-XX:+ DisableExplicitGC来禁止RMI(Java远程方法调用)调用System.gc。
(2)老年代空间不足
老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误: java.lang.OutOfMemoryError: Java heap space为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
(3)方法区空间不足
JVM规范中运行时数据区域中的方法区,在HotSpot虚拟机中又被习惯称为永生代或者永生区,Permanet Generation中存放的为一些class的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息:java.lang.OutOfMemoryError: PermGen space
。
为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
如果发现统计数据说之前Minor GC的平均晋升大小比目前old gen剩余的空间大,则不会触发Minor GC而是转为触发full GC
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
1.Serial(串行),它是单线程的,执行时必须暂停其他所有工作线程(STW),其中新生代采用复制算法,老年代采用标记-整理算法。它的优点是简单而搞笑,缺点就是暂停导致的用户体验不太好。能与CMS收集器配合工作。
2.ParNew,它相当于Serial的多线程版本,其中新生代采用复制算法,老年代采用标记-整理算法。它是许多运行在Server模式下的虚拟机的第一选择。能与CMS收集器配合工作。
3.Parallel Scavenge,类似于ParNew,是Server模式下的默认收集器。它提供了很多参数,比如最适合的停顿时间,最大吞吐量,可以让用户来设置,实现高效率的利用CPU。其中新生代采用复制算法,老年代采用标记-整理算法。
4.Serial Old,是单线程收集器,是Serial的老年代版本。
5.Parallel Old,是Parallel Scavenge的老年代版本。
6.CMS,是一种以最短暂停时间为目标的收集器,是HotSpot虚拟机第一款真正意义上的并发收集器,实现了垃圾回收线程和用户线程同时工作。优点是:并发收集,低停顿。缺点是:对CPU资源敏感,无法处理浮动垃圾。标记-清除算法会产生大量空间碎片。
它的工作流程分为4步:
初始编辑:暂停所有线程(STW),并记录下直接与root相连的对象,速度快。
并发标记:开启GC和用户线程,用一个闭包结构去记录可达对象。
重新标记:修正并发标记期间因为用户程序在运行而导致标记发送变动的那一部分对象的标记记录,这个阶段时间比初始阶段标记稍长,但是比并发标记短得多。
并发清除:开启用户线程,GC线程开始对未标记的区域清扫。
7.G1,是一款针对服务器的垃圾收集器,也就是配备多个处理器一级大容量内存的机器。它将队划分为多个大小相等的独立区域(Regin)。分配对象不会提前出发下一次GC。特点是:
并行于并发:使用多个CPU来缩短(STW)时间。
分代收集:
空间整合:整体来看是基于“标记-整理”算法实现收集,局部来看是基于“复制”算法实现收集。
可预测的停顿:可以让使用者指定停顿时间。
收集器运作步骤如下:
初始标记:对root根进行标记。
并发标记:在整个堆中查找可访问的对象。
最终标记:触发SWT,完成标记周期。
筛选回收:对各个Regin的回收价值和成本进行排序,根据用户设置的GC停顿时间来制定回收计划,只回收一部分Regin,时间的是用户可控制的。