1、概述
1)、java虚拟机的垃圾回收(Garbage Collection,GC)主要解决三个问题:
- 有哪些内存是需要回收?
- 什么时候进行回收?
- 如何进行回收?
2)、然后是垃圾回收器的工作区域:一般情况程序计数器、虚拟机栈和本地方法栈三个区域随线程生灭,这几个区域的内存分配回收都是确定的。所以垃圾回收器主要工作区域在堆和方法区
以下内容先从原理上来说明如何解决这三个问题,最后介绍实际中的垃圾回收器
2、垃圾回收原理
1)哪些内存需要回收
堆内存的回收 ( 如何判断一个对象需要回收 )
a、引用计数法
给对象添加一个引用计数器,每当一个地方引用他时,就进行+1;当引用失效时计数器进行-1;当计数器的值为0时,这时候认为没有引用,当前对象已死,可以进行清除。
但是主流的java虚拟机中没有使用该方法,最主要原因是:当有两个或多个对象之间相互引用,但并没有外部引用其中任何一个对象,按理说这一堆对象都应该是死去的,可是因为他们之间相互引用,对象的引用计数器不为0,所以无法识别这种情况。
b、可达性分析算法
这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜素,搜索过的路径称为引用链,当一个对象到“GC Roots”没有任何引用链时,则证明此对象是不可用的。
java中“GC Roots”包含以下几个
- 虚拟机栈(栈中的本地变量表)中的引用对象
- 方法区的静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用对象
不可达对象的处理
当一个对象被标记为不可达对象时,即将要被清理掉,但要真正被清理掉至少要经理两次标记过程:
- 第一次标记:对象被检测出没有和GC Roots相连接的引用链;
- 判断对象是否有必要执行finalize()方法;如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机执行过了,这两种情况判断为没有必要执行。
- 如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被放在F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalize线程去执行他。这里的执行只是触发这个方法,并不会等待finalize方法执行完。
- 如果在这个方法中,该对象重新获得了引用链,那么在第二次标记时将被移出即将回收集合。(这里需要注意的是:从finalize方法中只能逃出一次,第二次无论有无引用都必须被回收)。
- 否则第二次标记完,就会真正的回收这部分内存。
注意:这个finalize方法只需要了解含义,真正没有实用价值。
引用
这里再解释一下引用,常规理解的引用就是:如果reference中存储的值代表的是一块内存的起始地址,就称这个reference代表着一个引用。
这样的定义很纯粹,但有些狭隘,一个对象在这种定义下只有被引用或者没有引用两种状态,无法描述一些“食之无味,弃之可惜”的对象。
我们希望增加描述一些这样的对象:当内存空间足够时,这些对象可以保存在内存中,当内存看空间不足时,这些对象可以删除。很多系统的缓存功能都符合这样的引用场景。
java之后的版本对于引用进行了相应的扩充,将引用分为4种强度:
- 强引用;就是直接
Object obj = new Object()
这类引用,只要引用还在,垃圾回收器永远不会回收这些。 - 软引用;描述一些还有用,但是非必须的对象。在系统将要发生内存溢出时会将这部分内存进行回收。如果回收完还没有足够空间,这时才会抛出异常。SoftReference类来实现。
- 弱引用;描述非必须对象。强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾回收器工作之前。垃圾回收器开始工作时,无论是否内存充足都会回收。WeakReference类来实现。
- 虚引用;最弱的引用关系。这个引用存在不存在不影响其对象的生存,唯一的目的就是能在这个对象被垃圾回收器回收时收到一个系统通知。PhantomReference类来实现。
方法区的回收
方法区的垃圾回收归入HotSpot的永久代(老年代),回收效率比较低,这部分主要回收两部分内容:废弃常量和无用类
判定一个类是无用类:
- 这个类的所有实例不存在,即堆中没有这个类的实例。
- 该类的类加载器已经被回收。
- 该类对应的java.long.Class对象没有在任何地方被引用,即无法通过反射方法访问该类。
2)如何进行回收内存
内存回收策略
a、标记-清除算法
首先对需要回收的内存进行标记,然后再进行回收。
缺点:
- 效率太低;无论是标记还是回收执行效率都不高
- 空间问题;标记清除后会产生大量不连续的空间,可能会导致较大对象没有连续的大内存分配。
b、复制算法
因为新生代中的对象大多都是“朝生夕死”,所以将内存空间分为一块较大的Eden空间和两块较小的Survivor空间。
每次使用一个Survivor和Eden空间存放对象,当进行垃圾回收时,将这两块内存中活着的对象复制到另一块Survivor中,然后清理Edge和Servivor空间。一般情况Eden:Servivor=8:1,所以就会有风险,Eden和Servivor中存活的对象大于Servivor空间时,这就需要依赖其他内存。
这里主要依赖的是老年代的内存作为担保,当出现Servivor中存放不下时,会把一些对象直接存放进老年代。这个称为担保策略
c、标记-整理算法
一般都是针对老年代,老年代内存中对象的特点存活率比较高,所以使用复制算法会降低效率,所以选用标记整理。
将所有活着的对象都向一个方向移动,使内存连在一起,然后清除掉边界外的内存。
分代回收
根据对对象的特点将内存划分为两个部分:年轻代、老年代
年轻代:其中的对象“朝生夕死”,所以其中存活的对象不多,可以采用复制算法,复制少量存活对象就可以实现高效的内存回收清理。
老年代:其中的对象存活时间较长,因此不能使用复制算法,所以使用标记整理(或者标记清除)来进行内存回收。
3)什么时候进行内存回收
上述已经介绍了如何判定哪些内存需要回收和具体的回收算法,而什么时候进行内存回收,这个问题要考虑到虚拟机的执行效率。
主要依靠:OopMap,在安全点、安全区域来进行内存回收,保证执行效率。
问题:遍历效率极低
从可达性算法分析,从GC Roots节点开始(也就是静态引用和栈内存中引用),逐个遍历检查引用十分耗时,这样遍历效率极低。
解决办法:OopMap
HotSpot虚拟机为解决这个问题设置了一个OopMap数据结构,在类加载完后,HotSpot就把对象什么偏移量上是什么类型计算出来,在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用,然后把引用存储在OopMap中。这样GC在扫描时就可以直接得到这些信息。借助OopMap即可以快速实现GC Roots枚举遍历。
问题:OopMap过多会占用大量空间
在程序运行过程中会有很多程序导致引用发生变化,如果在每个发生变化的位置都记录一个OopMap的话,会造成OopMap过多占用空间。
解决办法:设置安全点
设置一些安全点,只在这些位置记录下OopMap,程序也只能在这些位置暂停进行GC,这些位置称为安全点SafePoint,所以安全点不能选取太多也不能能选取太少。
问题:GC时保证多个线程都能停止在安全点
解决办法:两种策略抢先式中断、主动式中断
抢先式中断:不需要线程执行代码的配合,在GC的时候中断所有线程,检查是否有不在安全点上的线程,有的话就恢复该线程跑到安全点停止。现在几乎没有虚拟机采用这种策略。
主动式中断:当需要GC中断时,不直接对线程进行中断,只是设置中断标志位,让线程轮询这个标志位,当发现中断标志位为真时,挂起线程。注意轮询点和安全点是重合的。HotSpot生成轮询指令,当需要中断时,将该安全点位置的内存页面设置为不可读,线程到这里就会产生自陷异常信号。
问题:线程处于sleep或者blocked中无法响应GC中断请求
解决办法:安全区域
安全区域指一段代码中不会发生引用改变,在这个区域任何地方进行GC都是安全的。
2、内存分配与回收策略
大多数情况大对象优先分配在新生代Eden中,当Eden中空间不足时,虚拟机将发生一次Minor GC(Minor GC:是在新生代进行一次内存回收使用复制算法)。然后在进行存储大对象,要是内存还不足时,就可以通过担保机制直接存储到老年代中。
虚拟机还提供了一个参数,可以设置当大于一个设定大小的对象时,这个对象就可以直接存在老年区
长期存活的对象将进入老年代,每经历一次Minor GC后仍存活的对象年龄+1,当年龄累计到一定程度(默认15),然后就被复制进老年代
注意:
Minor GC:指发生在年轻代的GC,比较频繁,但速度块
Major GC/Full GC:发生在老年代的GC,一般比较慢,而且发生时一般伴随着Minor GC。
3、实际实现的垃圾收集器
实际实现的垃圾收集器一共有7种:
Serial:年轻代的垃圾收集器,单线程,在进行垃圾收集时需要stop the world
ParNew:年轻代收集器,其实是Serial的多线程版本,可以和老年代的CMS收集器配合工作
Parallel Scavenge:年轻代收集器,使用复制算法,注重吞吐量,只能配合Parallel Old老年代收集器使用
CMS:老年代,是以获取最短停顿时间作为目标的收集器
Serial Old(MSC):老年代收集器,使用标记整理算法,单线程
Parallel Old:老年代收集器,使用多线程和标记整理算法
G1:老年代和年轻代
这里着重介绍一下CMS和G1
CMS收集器
主要工作在老年代,注重停顿时间,基于标记清除算法实现,主要分为四步
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记时仍需要stop the world ,但初始标记只是标记一下GC Roots能直接关联到的对象,速度很快;
并发标记阶段就是进行剩余引用可达性分析阶段
重新标记阶段则是为了修正并发标记期间因为程序运行而导致引用变化的那一部分对象,这个阶段也需要stop the world;
整个阶段并发标记,并发清除都可以和用户线程一起进行。
由于CMS采用标记清除算法,所以会产生大量内存碎片,当出现大量内存碎片无法找到满足对象大小的连续空间,则会开启内存碎片整理过程,整理过程无法并发,停顿时间变长。
G1收集器
- 整体是基于标记-整理算法,局部是复制算法
- 采用分代收集
- 并行与并发
- 预测停顿时间
G1收集器将java整个堆划分为独立区域(Region),虽然还有新生代和老年代,但这两个不是物理隔离的,他们都是一部分Region的集合。
G1之所以能预测停顿时间,是因为他可以有计划的避免在整个java堆上进行垃圾回收。G1 可以根据每个Region中垃圾堆积价值,在后台维护一个优先列表,每次根据收集时间,优先回收价值最大的Region。
Region不可能是孤立的,一个Region中的对象可能和多个Region有引用,这样在判断对象是否存活时就比较麻烦。为了避免在全堆进行遍历使用Remembered Set。
G1中每个Region都有一个Remembered Set,虚拟机发现程序在对reference进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用对象是否处于不同的Region中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当进行垃圾回收操作时,在GC根节点的枚举范围中加入Remembered Set即可以保证不对全堆进行扫描也不会有遗漏。
其余过程与CMS类似
- 初始标记
- 并发标记
- 最终标记
- 筛选回收