GC英文全称为Garbage Collection,即垃圾回收
Java与C++开发者之间有一堵深深的鸿沟
Java:内存申请和释放,这么重要的事情一定不能交给程序员自己去做
C++:内存申请和释放,这么重要的事情一定不能交给机器自己去做
有不少人把GC这项技术当作Java语言的伴生产物。事实上,垃圾收集的历史远远比Java久远,不过因为Java的出现,人们普遍开始认识到GC的可贵,自此多数脚本语言都具备了GC,实际上比将GC成为垃圾回收而言,称为“虚拟内存”更加适合,因为它的实质就是在较小的物理内存基础上,通过及时清理让程序得以申请更多的内存。
当内存管理自动化的现在,我们学习GC算法为的是当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就可以对这些“自动化”的技术实施必要的监控和调节。
如何判断哪些对象可以被回收,对象应该怎么被回收,对象所占内存在什么时候被回收是GC设计者所必须思考的问题。
承接上上篇,将JVM内存架构的一节,我们可以笼统的把JVM内存运行区分为堆和栈两个部分,GC发生的位置显然不会是随着线程出生入死的栈内存,因为栈内存随着线程结束就被回收,共享内存堆中才会滞留需要被清理的垃圾。
现在开始正式思考,GC设计的三个问题
很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。很多应届生和一些有多年工作经验的开发人员,他们对于这个问题给予的都是这个答案。
客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。比如Python就是引用计数算法进行内存管理的,但是主流的Java虚拟机里面都没有选用引用计数算法来管理内存,它存在的最大问题是无法解决循环引用的问题。
设置idea参数打印GC过程
加入参数-XX:+PrintGCDetails
循环引用代码示例
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
打印结果节选
[0.208s][info ][gc ] GC(0) Pause Full (System.gc()) 14M->3M(20M) 8.606ms
[0.208s][info ][gc,cpu ] GC(0) User=0.00s Sys=0.02s Real=0.01s
[0.208s][info ][gc,heap,exit ] Heap
[0.208s][info ][gc,heap,exit ] garbage-first heap total 20480K, used 3160K [0x0000000600c00000, 0x0000000800000000)
[0.208s][info ][gc,heap,exit ] region size 2048K, 1 young (2048K), 0 survivors (0K)
[0.208s][info ][gc,heap,exit ] Metaspace used 6308K, capacity 6379K, committed 6528K, reserved 1056768K
[0.208s][info ][gc,heap,exit ] class space used 549K, capacity 570K, committed 640K, reserved 1048576K
从运行结果中可以清楚看到内存回收日志中包含“14M->3M(20M)”,意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的。
当前主流的商用程序语言(Java、C#)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
如图3-1所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
1.位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(其实就是我们方法中的局部变量同样也包括本地方法栈中JNI引用的对象。
2.类的静态成员变量引用的对象(方法区) 常量池中引用的对象,譬如String里的引用(方法区)。
3.加有锁(synchronized关键字)的对象。4.Java虚拟机内部的引用(如基本数据类型对应的Class对象,一些常驻的异常对象(比如
NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
5.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等) 除了这些固定的GC
Roots集合以外,不同垃圾收集器不同回收的内存区域,还可以“临时”加入,共同构成完整GC Roots集合。
譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
目前最新的几款垃圾收集器[1]无一例外都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Objectobj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
public class Main {
public static void main(String[] args) {
//强引用写法:Object obj = new Object();
//软引用写法:
SoftReference<Object> reference = new SoftReference<>(new Object());
//使用get方法就可以获取到软引用所指向的对象了
System.out.println(reference.get());
}
}
这里我们来进行一个测试,首先我们需要设定一下参数,来限制最大堆内存为10M,并且打印GC日志:
-XX:+PrintGCDetails -Xms10M -Xmx10M
接着运行以下代码:
public class Main {
public static void main(String[] args) {
ReferenceQueue<Object> queue = new ReferenceQueue<>();
SoftReference<Object> reference = new SoftReference<>(new Object(), queue);
System.out.println(reference);
try{
List<String> list = new ArrayList<>();
while (true) list.add(new String("lbwnb"));
}catch (Throwable t){
System.out.println("发生了内存溢出!"+t.getMessage());
System.out.println("软引用对象:"+reference.get());
System.out.println(queue.poll());
}
}
}
运行结果如下:
java.lang.ref.SoftReference@232204a1
[GC (Allocation Failure) [PSYoungGen: 3943K->501K(4608K)] 3943K->2362K(15872K), 0.0050615 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 3714K->496K(4608K)] 5574K->4829K(15872K), 0.0049642 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 3318K->512K(4608K)] 7652K->7711K(15872K), 0.0059440 secs] [Times: user=0.03 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) --[PSYoungGen: 4608K->4608K(4608K)] 11807K->15870K(15872K), 0.0078912 secs] [Times: user=0.05 sys=0.00, real=0.01 secs]
[Full GC (Ergonomics) [PSYoungGen: 4608K->0K(4608K)] [ParOldGen: 11262K->10104K(11264K)] 15870K->10104K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0587856 secs] [Times: user=0.24 sys=0.00, real=0.06 secs]
[Full GC (Ergonomics) [PSYoungGen: 4096K->1535K(4608K)] [ParOldGen: 10104K->11242K(11264K)] 14200K->12777K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0608198 secs] [Times: user=0.25 sys=0.01, real=0.06 secs]
[Full GC (Ergonomics) [PSYoungGen: 3965K->3896K(4608K)] [ParOldGen: 11242K->11242K(11264K)] 15207K->15138K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.0972088 secs] [Times: user=0.58 sys=0.00, real=0.10 secs]
[Full GC (Allocation Failure) [PSYoungGen: 3896K->3896K(4608K)] [ParOldGen: 11242K->11225K(11264K)] 15138K->15121K(15872K), [Metaspace: 3207K->3207K(1056768K)], 0.1028222 secs] [Times: user=0.63 sys=0.01, real=0.10 secs]
发生了内存溢出!Java heap space
软引用对象:null
java.lang.ref.SoftReference@232204a1
Heap
PSYoungGen total 4608K, used 4048K [0x00000007bfb00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 4096K, 98% used [0x00000007bfb00000,0x00000007bfef40a8,0x00000007bff00000)
from space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
to space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
ParOldGen total 11264K, used 11225K [0x00000007bf000000, 0x00000007bfb00000, 0x00000007bfb00000)
object space 11264K, 99% used [0x00000007bf000000,0x00000007bfaf64a8,0x00000007bfb00000)
Metaspace used 3216K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 352K, capacity 388K, committed 512K, reserved 1048576K
可以看到,当内存不足时,软引用所指向的对象被回收了,所以get()方法得到的结果为null,并且软引用对象本身被丢进了队列中。
3. 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
弱引用比软引用的生命周期还要短,在进行垃圾回收时,不管当前内存空间是否充足,都会回收它的内存。
我们可以像这样创建一个弱引用:
public class Main {
public static void main(String[] args) {
WeakReference<Object> reference = new WeakReference<>(new Object());
System.out.println(reference.get());
}
}
// 使用方法和软引用是差不多的,但是如果我们在这之前手动进行一次GC:
public class Main {
public static void main(String[] args) {
SoftReference<Object> softReference = new SoftReference<>(new Object());
WeakReference<Object> weakReference = new WeakReference<>(new Object());
//手动GC
System.gc();
System.out.println("软引用对象:"+softReference.get());
System.out.println("弱引用对象:"+weakReference.get());
}
}
可以看到,弱引用对象直接就被回收了,而软引用对象没有被回收。同样的,它也支持ReferenceQueue,和软引用用法一致,这里就不多做介绍了。
WeakHashMap正是一种类似于弱引用的HashMap类,如果Map中的Key没有其他引用那么此Map会自动丢弃此键值对。
public class Main {
public static void main(String[] args) {
Integer a = new Integer(1);
WeakHashMap<Integer, String> weakHashMap = new WeakHashMap<>();
weakHashMap.put(a, "yyds");
System.out.println(weakHashMap);
a = null;
System.gc();
System.out.println(weakHashMap);
}
}
可以看到,当变量a的引用断开后,这时只有WeakHashMap本身对此对象存在引用,所以在GC之后,这个键值对就自动被舍弃了。所以说这玩意,就挺适合拿去做缓存的。
4. 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
虚引用相当于没有引用,随时都有可能会被回收。
看看它的源码,非常简单:
public class PhantomReference<T> extends Reference<T> {
/**
* Returns this reference object's referent. Because the referent of a
* phantom reference is always inaccessible, this method always returns
* null
.
*
* @return null
*/
public T get() {
return null;
}
/**
* Creates a new phantom reference that refers to the given object and
* is registered with the given queue.
*
* It is possible to create a phantom reference with a null
* queue, but such a reference is completely useless: Its get
* method will always return null and, since it does not have a queue, it
* will never be enqueued.
*
* @param referent the object the new phantom reference will refer to
* @param q the queue with which the reference is to be registered,
* or null if registration is not required
*/
public PhantomReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}
也就是说我们无论调用多少次get()方法得到的永远都是null,因为虚引用本身就不算是个引用,相当于这个对象不存在任何引用,并且只能使用带队列的构造方法,以便对象被回收时接到通知。
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
还记得一开始学Object时候Object
类的finalize()
方法吗?
此方法正是最终判定方法,如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行finalize()
方法,而在此方法中,当前对象是完全有可能重新建立GC Roots的!所以,如果在二次确认后对象不满足可回收的条件,那么此对象不会被回收,巧妙地逃过了垃圾回收的命运。
笔者建议大家尽量避免使用它,因为这是Java刚诞生时为了使传统C、C++程序员更容易接受Java所做出的一项妥协。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,建议大家完全可以忘掉Java语言里面的这个方法。
前面我们介绍了对象存活判定算法,现在我们已经可以准确地知道堆中的哪些对象可以被回收了,那么,接下来就该考虑如何对对象进行回收了,垃圾收集器会不定期地检查堆中的对象,查看它们是否满足被回收的条件。
垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法都有差异,在本节中我们暂不过多讨论算法实现,只重点介绍分代收集理论和几种算法思想及其发展过程。
这也是八股文背的最严重的的一块,什么新生代老年代面试的时候背来背去却从来没有真正理解,首先确定一个概念,分代收集理论是建立在以下两个假说基础上的。
1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。这里笔者提前提及了一些新的名词,它们都是本章的重要角色,稍后都会逐一登场,现在读者只需要知道,这一切的出现都始于分代收集理论。
把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域[2]。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在前一节讲述垃圾对象标记判定算法时其实已经介绍过了。之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如图3-2所示。
标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。(实现的时候我们可以根据具体内存情况分配以达到节省内存的目的)
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,标记-真理算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法的示意图如图3-4所示。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
我们可以将标记清除算法和标记整理算法混合使用,在内存空间还不是很凌乱的时候,采用标记清除算法其实是没有多大问题的,当内存空间凌乱到一定程度后,我们可以进行一次标记整理算法。
a.当年轻代空间不足时,就会触发Minor GC,这里的年轻代指的是Eden区满的时候,Survivor满了不会引发GC(每次 Minor GC 会清理年轻代的内存
b.因为Java对象大多都具备朝生夕灭的特性,所以Minor GC 非常频繁,一般回收速度也比较块。
c.Minor GC 会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才会恢复运行。
a.指发生在老年的GC,对象从老年代消失时,我们说“Major GC”或“Full GC” 发生了。
b.出现Major GC,经常会伴随着至少一次的Minor GC(但非绝对的,在Parallel Scavenge 收集器的收集策略里就有直接进行Major GC的策略选择。)
就是在老年代空间不足时,会尝试触发Minor GC。如果之后空间还不足,则触发Major GC
c.Major GC的速度一般会比Minor GC 慢10倍 以上,STW的时间更长。
d.如果Major GC 后,内存还不够,就报OOM 了
a.调用System.gc()时,系统建议执行Full GC,但是不必然执行。
b.老年代空间不足。
c.方法区空间不足。
d.通过Minor GC后进入老年代的平均大小大于老年代可用内存。
e.由Eden区,survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大于To Space可用内存,则把该对象转存倒老年代,且老年代的可用内存小于该对象大小。
说明:Full GC 是开发或调优中尽量要避免的,这样暂停时间会短一些
聊完了对象存活判定和垃圾回收算法,接着我们就要看看具体有哪些垃圾回收器的实现了。我们可以自由地为新生代和老年代选择更适合它们的收集器。
具体流程就暂不介绍了,列名字让大家有个印象,用的的时候再去细致了解
这个准则只能参考,因为性能取决于堆的大小,应用程序维护的实时数据量以及可用处理器的数量和速度
如果应用程序的内存在100M左右,使用串行收集器 -XX:+UseSerialGC。
如果是单核心,并且没有停顿要求,默认收集器,或者选择带有选项的-XX:+UseSerialGC
如果允许停顿时间超过1秒或者更长时间,默认收集器,或者选择并行-XX:+UseParallelGC
如果响应时间最重要,并且不能超过1秒,使用并发收集器 -XX:+UseConcMarkSweepGC or -XX:+UseG1GC
1.8默认的垃圾回收:PS + ParallelOld