目录
一、概述
二、垃圾回收相关算法
三、垃圾回收相关概念
四、垃圾回收器(7种)
想要学习垃圾回收机制(Garbage Collection),首先我们需要了解以下几点:
【什么是垃圾呢??】
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用进程结束,被保留的空间无法被其他对象使用,甚至可能导致内存溢出。
【我们为什么要进行垃圾回收??】
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。 随着应用程序所应对的业务越来越大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。
【早期的垃圾回收】
【Java的垃圾回收机制】
自动内存管理:无需开发人员手动参与内存的分配和回收,这样降低内存泄漏和内存溢出的风险。可以将程序员从繁重的内存管理中释放出来,使其更专心地专注于业务开发。
但自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就是弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。此时了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够遇见OutOfMemoryError时,快速的根据错误异常日志定位问题和解决问题。当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。其中,Java堆是垃圾回收器的工作重点。从次数上讲,频繁地收集Young区,较少收集Old区,进本不动Perm(或MetaSpace)区。
堆和方法区的详解可参见博文:JVM堆和方法区底层结构及原理
垃圾回收需要经过两大步骤:标记阶段(即我们需要知道哪些是垃圾)、清除阶段(即把垃圾清除掉)
标记阶段我们需要了解两个算法:
清除阶段我们需要了解三个算法:
下面我们就一一介绍这几种算法!!!!
【垃圾标记阶段】
【垃圾标记阶段:引用计数算法】
【垃圾标记阶段:可达性分析】
可达性分析算法也称跟搜索算法、追踪性垃圾收集算法,相对于引用计数算法而言,可达性算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用地问题,防止内存泄漏的发生。
如果要使用可达性分析算法来判断内存是否回收,那么分析工作必须在一个能保障一致性的快照中进行,这点不满足的话分析结果的准确性就无法保证。这点也是导致GC进行时必须要"Stop The World"(停掉用户线程)的一个重要原因。即使是号称不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
【对象的finalization机制】
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象时,即:垃圾回收此对象之前,总会先调用这个对象的finalization()方法。该方法允许在子类中被重写,用于在对象回收时进行资源释放。通常在这个进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
【垃圾清除阶段】
当成功区分出内存中存活的对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占有的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是 标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)
【垃圾清除阶段:标记-清除算法】
标记-清除算法是一种非常基础和常见的垃圾收集算法。执行过程:当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项是标记,第二项是清除:
标记清除有哪些缺点?
效率不算高;在进行GC的时候,需要停止整个应用程序,导致用户体验差;这种方式清理出来的空闲内存是不连续的,产生内存碎片(即上图的空闲空间),需要维护一个空闲列表。
注意:何为清除?
这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
【垃圾清除阶段:复制算法】
为了解决标记-清除算法在垃圾收集效率方面的缺陷,复制算法就诞生了。复制算法的核心思想就是:将活着的内存空间分为两块,每次只使用其中的一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中所有的对象,交换两个内存的额角色,最后完成垃圾回收。堆中新生代的幸存者0区和幸存者1区使用的就是复制算法。
特别地,如果系统中的存活对象很多,复制算法不会很理想,。因为复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。
【垃圾清除阶段:标记-压缩算法】
复制算法的高效性是建立在存活对象少、垃圾堆想多的前提下的。这种情况经常在新生代发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率底下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础上进行改进。标记-压缩算法由此诞生。
【小结】
从效率上来说,复制算法是当之无愧的老大,但是却浪费了太多的内存。而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽人意,它比复制算法多了一个标记的阶段,比标记-清除算法多了一个整理内存的阶段。
难道就没有一种最优的算法吗?答案是没有。没有最好的,只有最适合的。所以我们具体问题具体分析,引出分代收集算法。
【分代收集算法】
以Hotspot中CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure),将采用Serial Old执行Full GC以达到对老年代内存的整理。
【增量收集算法、分区算法】
使用这种方法,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统停顿的时间。但是,因为线程切换和上下文转换地消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
注意:这些只是基本算法思路,实际GC实现过程要复杂地多,目前还在发展中地前沿GC都是复合算法,并且并行和并发兼备。
【System.gc( )的理解】
在默认你情况下,通过System.gc( )或者 Runtime.getRuntime( ).gc( )的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。然而,System.gc( )调用附带一个免责声明,无法保证对垃圾收集器的调用。JVM实现这可以通过System.gc( )调用来决定JVM的GC行为,而一般情况下,垃圾回收应该是自动进行的,无需手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc( )。
来一道小问题:下面两个方法再分别调用了System.gc( )后,哪个方法的buffer数组会被回收掉?
public void localvarGC1() {
{
byte[] buffer = new byte[10 * 1024 * 1024];
}
System.gc();
}
public void localvarGC2() {
{
byte[] buffer = new byte[10 * 1024 * 1024];
}
int value = 10;
System.gc();
}
答案是localvarGC1中的数组不会被回收,localvarGC2中的数组会被回收掉。
原因是:在localvarGC2中出现了Slot变量槽复用的情况。在localvarGC1方法中,该方法的局部变量表中索引为0的Slot槽是this,索引为1的Slot槽是buffer,{}相当于匿名方法,其参数不显示在{}外的方法作用域中,但其所占用索引为1的Slot变量槽并未被复用,其一直引用着堆中的对象。而localvarGC2方法在出了{}后,有定义了常量value,其占用了原buffer所占用的索引为1的Slot的变量槽,使得堆中buffer的实例没有了指向其的引用,故在System.gc( )是被回收。
这块Slot变量槽不太懂的可以看一看文章:JVM运行时数据区结构及原理
中虚拟机栈章节中对局部变量表的介绍
【内存溢出】
没有内存空间内存的情况:说明Java虚拟机的堆内存不够。原因有二:(1)Java虚拟机的堆内存设置的不够(内存泄漏或-Xms、-Xmx设置不够)。(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
在抛出OOM之前,通常垃圾收集器会被触发,尽其所能去清理出空间。例如:在引用分析及之中,涉及到JVM会尝试去回收软引用指向的对象等(这个后面会讲)。
当然,也不是在任何情况下垃圾收集器都会被出发的。比如:我们去分配一个超大的对象直接超出堆的最大内存,就会直接抛出OOM。
【内存泄漏】
内存泄漏,也称“存储泄露”。严格说,只有对象不会再被程序用到了,但是GC又不能将其回收的情况,才叫做内存泄漏。但实际情况很多时候一些不太好的实践(或疏忽),会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。比如:本可以定义在局部变量表中的变量却定义在了方法的外部,甚至用static修饰,就会随着类的消亡才被回收,拉长了生命周期。
尽管内存泄漏并不会立刻引起程序的崩溃,但是一旦发生内存泄漏,程序中可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OOM,导致程序崩溃。
【Stop The World】
STW事件和采用哪款GC无关,所有的GC都有这个事件。哪怕是G1也不能完全避免STW情况的发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停的时间。STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程都停掉。开发中不要使用System.gc( ),会导致STW同时发生。
【垃圾回收的并行和并发】
在介绍垃圾回收的并行和并发前,我们先回顾一下程序的并行和并发。
并行:
并发:
只有在多个CPU或者一个CPU多核的情况中,才会发生并行,否则,看似同时发生的事情,其实都是并发执行的。
【安全点和安全区域】
安全点:
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置成为“安全点(Safe Point)”。Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。 程序中大部分指令的执行时间都非常短暂,而Safe Point的选择通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
那么,如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
安全区域:
SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的SafePoint。但是,程序“不执行”的时候呢?例如线程处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起。JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region 看作是被扩展了的Safe Point。
实际执行时:当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果在这段时间内发生了GC,JVM会忽略标识为Safe Region状态的线程。当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止。
【引用】
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
在JDK1.2后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。除了强引用,其他三个引用都继承了java.lang.ref包下的Reference。
在面试中,一个既偏门又高频的面试题:强引用、弱引用、软引用、虚引用有什么区别?具体使用场景是什么?接下来我们就及逆行一一讲解
注意:这几种引用的回收都是在引用仍然存在的情况下进行的,即该引用还指向其对象。
【强引用】
强引用的特点:
【软引用】
(不足即回收)
或者:
SoftReference userSoftRef = new SoftReference(new User(1,"wenhao"));
【弱引用】
(发现及回收)
【虚引用】
(对象回收跟踪)
最后我们再了解一下终结器引用:
【垃圾回收器的分类】
垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本,从不同角度分析垃圾收集器,可以将GC分为不同的类型。
垃圾回收器按线程数分,可以分为串行垃圾回收器和并行垃圾回收器。
按工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。
按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
按工作的内存空间分,可分为年轻代垃圾回收器和老年代垃圾回收器。
【评估GC的性能指标】
在设计(或使用)GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折中。现在标准:在最大吞吐量优先的情况下,降低停顿时间。
【7款经典的垃圾收集器】
-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID
【Serial回收器:串行回收】
这种串行的垃圾收集器大家了解一下即可,现在已经不使用串行的了。而且在限定单核cpu才可以用,现在都不是单核的了。
【ParNew回收器:并行回收】
【Parallel回收器:吞吐量优先】
在程序吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。在Java8中,默认是此垃圾收集器。
【CMS回收器:低延迟】
不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1.5 中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。在G1出现之前,CMS使用还是非常广泛的,一直到今天,仍然有很多系统使用CMS GC。
有人会觉得既然 Mark Sweep 会造成内存碎片,那么为什么不把算法换成 Mark Compact 呢?
答案其实很简单,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提是它运行的资源不受影响嘛,而Mark Compact更适合“Stop The World”这种场景下使用。
【G1回收器:区域化分代式】
对于堆种的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为一个老年代来看待。
G1回收器的缺点:相较于CMS,G1还不具备全方位、压倒性的优势。比如,在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS高。从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发。
G1回收器垃圾回收的过程:
如果需要,单线程、独占式、高强度的Full GC还是继续存在的。他针对GC的评估失败提供了一种失败保护机制,即强力回收。
G1回收过程详解:
【7种经典垃圾回收器总结】
【GC日志分析】
Minor GC: