一、垃圾回收机制
1、为什么需要垃圾回收
Java 程序在虚拟机中运行,是会占用内存资源的,比如创建的对象、加载的类型数据等,而且内存资源都是有限的。当创建的对象不再被引用时,就需要被回收掉,释放内存资源,这个时候就会用到JVM的垃圾回收机制。
JVM 启动时就提供了一个垃圾回收线程来跟踪每一块分配出去的内存空间,并定期清理需要被回收的对象。Java 程序无法强制执行垃圾回收,我们可以通过调用 System.gc 方法来"建议"执行垃圾回收,但是否可执行,什么时候执行,是不可预期的。
2、垃圾回收发生在哪里
JVM内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,随着线程的创建而创建,销毁而销毁。栈中的栈帧随着方法的调用而入栈,随着方法的退出而出栈,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此这三个区域的内存分配和回收都具有确定性。
而堆和方法区这两个区域则有着显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾回收的重点就是关注堆和方法区中的内存,堆中的回收主要是垃圾对象的回收,方法区的回收主要是废弃常量和无用的类的回收。
3、对象在什么时候可以被回收
一般一个对象不再被引用,就代表该对象可以被回收。主流的虚拟机一般都是使用 可达性分析算法 来判断该对象是否可以被回收,有些内存管理系统也是用 引用计数法 来判断。
1)引用计数算法:
这种算法是通过在对象中添加一个引用计数器来判断该对象是否被引用了。每当对象被引用,计数器就加 1;每当引用失效,计数器就减 1。当对象的引用计数器的值为 0 时,就说明该对象不再被引用,可以被回收了。
引用计数算法实现简单,判断效率也很高,但它无法解决对象之间相互循环引用的问题。两个对象若互相引用,但没有任何其它对象引用他们,而它们的引用计数器都不为零,就无法被回收。
2)可达性分析算法:
GC Roots 是该算法的基础,GC Roots 是所有对象的根对象。在垃圾回收时,会从这些 GC Roots 根对象开始向下搜索,在搜索的这个引用链上的对象,就是可达的对象;而一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可达的,可以被回收。
在Java中,可作为 GC Roots 对象的一般包括如下几种:
Java虚拟机栈中的引用的对象,如方法参数、局部变量、临时变量等
方法区中的类静态属性引用的对象
方法区中的常量引用的对象,如字符串常量池的引用
本地方法栈中JNI的引用的对象
Java虚拟机内部的引用,如基本数据类型的 Class 对象,系统类加载器等
比如下面的代码:
其中,类静态变量 MAPPER,loadAccount 方法的局部变量 account1、account2、accountList 都可以作为 GC Roots(ArrayList 内部是用 Object[] elementData 数组来存放元素的)。
在调用 loadAccount 方法时,堆中的对象都是可达的,因为有 GC Roots 直接或间接引用到这些对象,此时若发生垃圾回收,这些对象是不可被回收的。loadAccount 执行完后,弹出栈帧,方法内的局部变量都被回收了,虽然堆中 ArrayList 对象还指向 elementData 数组,而 elementData 指向 Account 对象,但没有任何 GC Roots 的引用链能达到这些对象,因此这些对象将变为垃圾对象,被垃圾回收器回收掉。
4、回收方法区
方法区垃圾回收的“性价比”通常是比较低的,方法区的垃圾回收主要回收两部分内容:废弃的常量和不再使用的类型。
1)废弃的常量:
如常量池中废弃的字面量,字段、方法的符号引用等
2)不再使用的类型:
判定一个类型是否属于“不再被使用的类”需要同时满足三个条件:
该类所有的实例都已经被回收,Java堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
5、Java中的引用类型
Java 中有四种不同的引用类型:强引用、软引用、弱引用、虚引用,这4种引用强度依次逐渐减弱。
1)强引用:
强引用是最普遍的引用方式,如在方法中定义:Object obj = new Object()。只要引用还在,垃圾回收器就不会回收被引用的对象。
2)软引用:
软引用是用来描述一些有用但非必须的对象,可以使用 SoftReference 类来实现软引用。对于软引用关联着的对象,在系统将要发生内存溢出异常之前(一般发生老年代GC时),会把这些对象列进回收范围之中。如果回收之后内存还是不足,才会报内存溢出的异常。
这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现内存缓存,当内存快满时,就回收掉这些软引用的对象,然后需要的时候再重新查询。比如下面的代码:
3)弱引用:
弱引用是用来描述非必须的对象,可以使用 WeakReference 类来实现弱引用。它只能生存到下一次垃圾回收发生之前(一般发生年轻代GC时),当垃圾回收机制开始时,无论是否会内存溢出,都将回收掉被弱引用关联的对象。
需注意的是,我们使用 SoftReference 来创建软引用对象,使用 WeakReference 来创建弱引用对象,垃圾回收时,是回收它们关联的对象,而不是 Reference 本身。同时,如果 Reference 关联的对象被其它 GC Roots 引用着,也是不能被回收的。如下面的代码,在垃圾回收时,只有 T002 这个 Account 对象能被回收,回收后 reference2.get() 返回值为 null,account、reference1、reference2 所指向的对象都不能被回收。
4)虚引用:
最没有存在感的一种引用关系,可以使用 PhantomReference 类来实现虚引用。存在不存在几乎没影响,也不能通过虚引用来获取一个对象实例,存在的唯一目的是被垃圾回收器回收后可以收到一条系统通知。
二、垃圾回收算法
1、分代收集理论
大部分虚拟机的垃圾回收器都是遵循“分代收集”的理论进行设计的,它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般至少将堆划分为新生代和老年代两个区域,然后可以根据不同代的特点采取最适合的回收算法。在新生代中,每次垃圾回收时都有大量对象死去,因为程序创建的绝大部分对象的生命周期都很短,朝生夕灭。而新生代每次回收后存活的少量对象,将会逐步晋升到老年代中存放。老年代每次垃圾收集时只有少量对象需要被回收,因为老年代的大部分对象一般都是全局变量引用的,生命周期一般都比较长。
在Java堆划分出不同的区域之后,垃圾回收器就可以每次只回收其中某一个或者某些部分的区域,因而也有了“Young GC”、“Old GC”、“Full GC”这样的回收类型的划分。也能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾回收算法,因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾回收算法。
GC类型:
新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集,包括新生代、老年代、方法区的回收,一般 Full GC 等价于 Old GC。
经典分代模型:
2、标记-清除算法(Mark-Sweep)
标记-清除算法 分为“标记”和“清除”两个阶段,首先从 GC Roots 进行扫描,对存活的对象进行标记,标记完后,再统一回收所有未被标记的对象。
优点:
标记-清除算法不需要进行对象的移动,只需回收未标记的垃圾对象,在存活对象比较多的情况下极为高效。
缺点:
标记-清除算法执行效率不稳定,如果堆中对象很多,而且大部分都是要回收的对象,就必须要进行大量的标记和清除动作,导致标记、清除两个过程的效率随着对象数量增长而降低。
标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3、标记-复制算法(Copying)
标记-复制算法简称为复制算法,复制算法主要是为了解决标记-清除算法在存在大量可回收对象时执行效率低下和内存碎片的问题。
1)半区复制算法
它将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存满了,就从 GC Roots 开始扫描,将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
优点:
每次都是针对整个半区进行内存回收,清理速度快,没有内存碎片产生
每次回收后,对象有序排列到另一个空闲区域,分配内存时也就不用考虑有空间碎片的复杂情况
缺点:
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销
复制回收算法将可用内存缩小为了原来的一半,内存使用率低
2)复制算法的优化
大多数对象都是朝生夕灭,新生代中98%的对象几乎都熬不过第一轮回收,因此并不需要按照 1∶1 的比例来划分新生代的内存空间。
因此新生代复制算法一般是把新生代分为一块较大的 Eden 区和两块较小的 Survivor(from survivor、to survivor) 区,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾回收时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间,如此往复。当对象经过垃圾回收的次数超过一定阀值还未被回收掉时,就会进入老年代,有些大对象也可以直接进入老年代。
相比半区复制算法:
优点:HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 : 1 : 1,新生代与老年代的比例大概是 1 : 2。内存空间利用率高,只会有 10% 的空闲空间。
缺点:有可能一次 Young GC 后存活的对象超过一个 survivor 区的大小,这时候会依赖其它内存区域进行分配担保,让这部分存活下来的对象直接进入另一个区域,一般就是老年代。
4、标记-整理算法(Mark-Compact)
复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,它不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
优点:没有内存碎片产生,适合老年代垃圾回收
缺点:会有对象的移动,老年代存活对象多,移动对象还需要更新指针,因此成本会更高
5、总结对比