一. 什么是垃圾收集
不同于C、C++等主动申请与释放内存的语言,Java对内存的使用是动态的,回收是自动的。回收内存的动作就叫垃圾收集(Garbage Collection, GC),垃圾收集可采用不同的回收算法,从而可以延伸出不同的垃圾收集器。不管哪种垃圾收集器,都需要完成三件事情:
- 哪些内存需要回收?
依据引用计数或可达性分析来确定 - 什么时候回收?
在安全点或安全区域进行回收 - 如何回收?
利用不同的垃圾回收算法和垃圾收集器
下面的内容主要就是围绕这三个问题进行阐述。
二. 为何要了解GC和内存分配
既然Java的内存是动态分配的,回收又是自动完成的,我们为何还要去了解GC和内存分配的机制呢?有如下三个主要原因:
- 当发生内存溢出、内存泄露时,需要排查与修改;
- 当垃圾收集成为系统达到更高并发量的瓶颈时,需要进行必要的调节。
- 只有了解内存分配和垃圾回收的机制,才能做出更加优秀的设计。
三. 如何确定哪些对象需要回收
垃圾收集需要完成的第一件事情就是要知道哪些内存需要回收。
确定哪些对象需要回收的算法有两种:
- 引用计数算法
- 可达性分析算法
1. 引用计数算法
算法原理: 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;当计数器值为0时,对象就是不可能再被使用的,可以被回收。
优点: 实现简单,判定效率很高。
缺点: 难以解决对象之间循环引用的问题。
2. 可达性分析算法
算法原理: 以“GC Roots”对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连接时,就说明此对象是不可用的,可被回收。
在Java语言中,可作为GC Roots的对象有下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中JNI(即Native方法)引用的对象
- 方法区中常量引用的对象
- 方法区中类静态属性引用的对象
Java引用可分为4种引用类型:
- 强引用(Strong Reference):在程序代码中普遍存在的一种引用,类似
Object obj = new Object()
,只要存在强引用,垃圾收集器就永远不会回收掉被引用的对象。- 软引用(Soft Reference):它用来描述一些还有用但并非必需的对象。软引用关联着的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围之中,进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用(Weak Reference):它也用来描述非必需的对象,它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用(Phantom Reference):它是最弱的一种引用关系,它不会对对象的生命周期构成任何影响,也不能通过虚引用来取得一个对象实例,为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
方法区的垃圾回收
Java虚拟机规范中没有要求一定要对方法区进行垃圾收集,但实现方法区的垃圾收集也是必要的,尽管对它的收集一般“性价比”比较低。在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,方法区的收集效率却远低于此。
方法区的垃圾收集主要回收两部分内容:废弃常量和无用的类。
判定一个类是无用的类需要同时满足下面3个条件:
- 该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
四. 垃圾收集算法
垃圾收集算法有很多,下面介绍4种常用的算法:
1. 标记-清除算法
算法原理:分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:主要有两个,一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2. 复制算法
算法原理:在标记-清除算法的基础上,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
由于新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被浪费。
98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(指老年代)进行分配担保。
3. 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会降低,另外,还需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接使用复制算法。
算法原理:标记过程同“标记-清除”算法一样,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
4. 分代收集算法
算法原理:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都会有大批对象死去,只有少量存活,就选用复制算法,这样就只需要复制少量存活对象就可以完成收集;而老年代中,对象存活率高,没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
五. 垃圾收集器
在介绍垃圾收集器之前,先了解一下什么是新生代GC(Minor GC)和老年代GC(Major GC/Full GC)。
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对)。Major GC的速度一般会比Minor GC慢10倍以上。
1. Serial收集器
Serial收集器是最基本、发展历史最悠久的收集器,它是一个单线程的收集器,它只会使用一个CPU或一条收集线程去完成垃圾收集工作,在垃圾收集时,必须暂停其他所有工作线程(Stop The World)。
Serial简单高效,没有线程交互的开销,只专心做垃圾收集工作,并且在桌面应用场景中,分配给虚拟机管理的内存一般也不大,因此,Serial收集器是运行在Client模式下的虚拟机优选的垃圾收集器。
2. ParNew收集器
ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其他行为、策略与Serial收集器完全一样。
ParNew收集器是许多运行在Server模式下的虚拟机首选的新生代收集器,一个很重要的原因就是目前只有它能与CMS收集器配合工作。
3. Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,是使用复制算法的并行多线程收集器,和ParNew相似,但它的目标是达到一个可控制的吞吐量。吞吐量越大,停顿时间就越短,就越适合需要与用户交互的程序,因为响应速度能提升用户体验。
Parallel Scavenge收集器也常被称为吞吐量优先收集器。
吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值。即吞吐量=运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)。
4. Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,它也是一个单线程的收集器,使用“标记-整理”算法。它主要用于Client模式下的虚拟机。
5. Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
6. CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验,CMS收集器就符合这类应用的需求。
CMS收集器采用“标记-清除”算法实现,整个收集过程分为4个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中,初始标记、重新标记这两个步骤仍需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是进行GC Roots Tracing的过程;重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程可以与用户线程同时工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。它的优势就是并发收集、低停顿。
7. G1收集器
G1(Garbage-First)收集器是当今收集器技术最前沿的成果之一,是一款面向服务端应用的垃圾收集器。
它的特点是:并行与并发、分代收集、空间整合、可预测的停顿。
G1收集器的工作过程大致分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
六. 内存分配与回收策略
Java体系中提倡的自动内存管理可以归结为自动化地解决了给对象分配内存和回收分配给对象的内存。前面我们已经了解了垃圾收集器体系及其运作原理,后面我们了解一下给对象分配内存的相关问题。
对象的内存分配,就是指在堆上给对象分配内存使用。对象主要分配在新生代的Eden区上,如果启用了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中,分配的规则并不是完全固定不变的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机与内存相关的参数的设置。
关于内存分配,最普遍的几条内存分配规则如下:
1. 对象优先在Eden区分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
虚拟机提供了-XX:PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
2. 大对象直接进入老年代
所谓的大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来分配给它们。
虚拟机提供了一个-XX:PretenureSizeThreshhold参数,大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区以及两个Survivor区之间发生大量的内存复制。
3. 长期存活的对象将进入老年代
虚拟机采用了分代收集的思想来管理内存,内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。因此,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden区出生并经过一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认是15岁),就会晋升到老年代中。
虚拟机提供了-XX:MaxTenuringThreshold参数来设置晋升老年代的年龄阈值。
4. 动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
5. 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,虚拟机就会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,就会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,就改为进行一次Full GC。
内存回收与垃圾收集器在很多时候都是影响系统性能以及并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及大量的调节参数,是因为只有根据实际应用需求与实现方式才能选择最优的收集方式,获取最高的性能。