Java内存回收机制
一、前言
1.JVM的内存结构
jvm2.pngJVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。
GC( Garbage Collection)垃圾收集器在对堆区和方法区进行回收前,首先需要考虑的就是如何去判断哪些垃圾(内存)需要回收,这就要用到判断对象是否存活的算法!
2.内存分配与回收策略
下图所示是堆中内存分配示意图,创建一个对象,首先会在eden区域分配区域,如果内存不够,就会将年龄大的转移到Survivor区,当survivor区域存储不下,则会转移年老代的。对于一些静态变量不需要使用对象,直接调用的,则会被放入永生代(在Java8中去掉了永久代,以元数据空间代替)。一般来说长期存活的对象最终会被存放到年老代,还有一种特殊情况也会被存放到年老代,就是创建大对象时,比如数据这种需要申请连续空间的,如果空间比较大的,则会直接进入年老代。
image.png
在回收过程中,有一个参数比较重要,就是对象的年龄,如果在一次垃圾回收过程中有使用该对象的,则将对象年龄加1,否则减1,当计数为0,则进行回收,如果年龄达到一定数字则进入老生代。总的来说内存分配机制主要体现在对象创建之后是否仍在使用,已经不使用的则回收,继续使用的则对其年龄进行更新,达到一定程度,转移到年老代。
二、对象存活判定算法
1. 引用计数算法
- 算法思想:给对象中添加一个引用计数器,每当有一个地方引用它时,计数值加1,当引用失效时,计数器值减1,当引用数为0的时候也就说明这个对象不在被使用就可以被回收。
- 优点:实现简单,判断效率也很高。
- 缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。那么GC也就无法回收他们。
public class ReferenceCountingGC {
public Object instance = null;
public static void main(String []args){
// 第一部分
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
// 第二部分
objA.instance = objB;
objB.instance = objA;
// 第三部分
objA = null;
objB = null;
}
}
第一部分代码,开辟两块内存,对应的实例我们暂且命名为A和B,分别被objA和objB引用,因此A、B两个对象的引用计数器分别为1;
第二部分代码,由于又有各自的instance指向内存A和B,因此A、B两个对象的引用计数器分别为2;
第三部分代码,由于给objA和objB制空,因此A、B两个对象的引用计数器分别减1,最后A、B两个对象的引用计数器还是为1,不为0,因此不能回收。
2. 可达性分析算法
- 算法思想:通过一系列的“GC Roots”对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,可以进行内存回收。如下图,Object5、6、7是可以被回收的。
image.png
拓展:
2.1 大多数垃圾回收算法使用了根集(root set)这个概念;所谓根集就是正在执行的Java程序可以访问的引用变量的集合(包括局部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。
2.2 在Java语言中,可作为GC Roots的对象包括下面几种:- 虚拟机栈中引用的对象(栈帧中的本地变量表);
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(Native方法)引用的对象。
其实对于可达性分析算法,并不是发生一次GC就会对不在GC Roots相连接的引用链的对象进行回收,有些对象可能处于“死缓”状态,对于这些对象至少要经历两次标记过程。
第一次标记:如果对象在进行可达性分析后发现没有在引用链上,它将会被第一次标记
第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。筛选的条件是此对象是否有必要执行finalize方法,有必要执行finalize方法的对象就属于“死缓”状态的对象。
那么如何判断是否有必要执行finalize方法呢?
当对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过一次,这两种情况只要满足其一,都表示没必要执行finalize()方法。
对于被判定有必要执行finalize()方法的对象,GC会将该对象放到一个叫F-Queue的队列之中,并由虚拟机自动创建的一个Finalizer线程去执行各个对象的finalize()方法,在该过程即会进行第二次标记过程,如果某些对象存在“自我救赎”现象,则会将这些对象移出“即将回收”的集合,那对于没有移出的对象,基本上就真正回收了。
什么样的情况属于对象的“自我救赎”?
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("yes, i am still alive ...");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize()....");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String []args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null; // 第一次制空
System.gc();
Thread.sleep(1000);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no, i am dead ...");
}
SAVE_HOOK = null; // 第二次制空
System.gc();
Thread.sleep(1000);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no, i am dead ...");
}
}
}
// 输出如下:
finalize()....
yes, i am still alive ...
no, i am dead ...
通过上述代码,可以看到,第一次制空时,执行了finalize方法,由于发生了对象的引用,造成该对象不能进行回收,这样就发生了对象的自我救赎,SAVE_HOOK并不为null,当第二次制空过后,由于不会再执行finalize方法,因此该对象接下来将会被回收。
三、如何回收?常用的垃圾回收算法
1. 标记-清除算法
标记-清除(Mark-Sweep)算法可以算是最基础的垃圾收集算法,该算法主要分为“标记”和“清除”两个阶段。先标记可以被清除的对象,然后统一回收被标记要清除的对象,这个标记过程采用的就是可达性分析算法。
主要不足有两个:
- 一个是效率问题,标记和清除两个过程的效率都不高;
- 一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2. 复制算法
为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。复制算法的执行过程如下图所示:
image.png
优点:
- 如果系统中的垃圾对象很多,复制算法需要复制的存活对象就会相对较少。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。
- 回收的内存空间没有碎片。
- 适用于新生代。
缺点
- 将系统内存空间折半,只使用一半空间。
- 如果内存空间中垃圾对象少的话,复制对象也是很耗时的,因此,单纯的复制算法也是不可取的。
- 不适合老年代。
3. 标记-整理算法
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
复制算法的高校建立在存活对象少,垃圾对象多的前提下。这种情况在年轻代比较容易发生,在老年代更常见的情况是大部分都是存活对象。
标记整理算法,是一种老年代的回收算法,从根节点对所有的对象做一次标记,然后将所有存活的对象移动到内存的另外一端,在清除界边以外所有的空间。这种方法不产生碎片,又不需要2块相同的内存空间。
4. 分代收集算法
当前商业虚拟机的垃圾回收都是采用“分代收集”(Generational Collection)算法,这种算法其实并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般把Java堆分为新生代和老年代,然后根据不同年代的特点采用适当的收集算法。例如在新生代,一般情况下每次垃圾收集都会有大量的对象死去,只有少量的存活,这时候一般使用复制算法,而对于年老代,由于对象存活率高,没有额外空间对它跟配担保,因此一般采用“标记-清理”或“标记-整理”算法来实现。
现在大部分虚拟机基本都是采用分代收集算法来实现垃圾回收,而分代收集其实又是采用其他三种实现方式结合而成。
四、 GC收集器
垃圾回收器就是对垃圾收集算法的具体实现。
下图展示了JDK1.7Update 14之后的HotSpot虚拟机所包含的收集器。如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
jvm4.png
4.1 Serial收集器
Serial收集器是一款年轻代的垃圾收集器,使用标记-复制垃圾收集算法。它是一款发展历史最悠久的垃圾收集器。Serial收集器只能使用一条线程进行垃圾收集工作,并且在进行垃圾收集的时候,所有的工作线程都需要停止工作,等待垃圾收集线程完成以后,其他线程才可以继续工作。
Client模式下的默认收集器,因为没有线程交互的开销,更能专心的做垃圾收集工作,比其他收集器的单线程收集器效果高效。
4.2 ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The Worl、对象分配规则、回收策略等都与Serial 收集器完全一样.
jvm6.png
ParNew收集器是许多运行在Server模式下的虚拟机中首选新生代收集器,其中有一个与性能无关但很重要的原因是,除Serial收集器之外,目前只有ParNew它能与CMS收集器配合工作。
4.3 Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。
Parallel Scavenge收集器关注的是如何控制系统运行的吞吐量(Throughput)。这里说的吞吐量,指的是CPU用于运行应用程序的时间和CPU总时间的占比,吞吐量 = 代码运行时间 / (代码运行时间 + 垃圾收集时间)。如果虚拟机运行的总的CPU时间是100分钟,而用于执行垃圾收集的时间为1分钟,那么吞吐量就是99%
。
在用户界面程序中,使用低延迟的垃圾收集器会有很好的效果,而对于后台计算任务的系统,高吞吐量的收集器才是首选。
Parallel Scavenge收集器提供了两个参数用于控制吞吐量。-XX:MaxGCPauseMillis用于控制最大垃圾收集停顿时间,-XX:GCTimeRatio用于直接控制吞吐量的大小。MaxGCPauseMillis参数的值允许是一个大于0的整数,表示毫秒数,收集器会尽可能的保证每次垃圾收集耗费的时间不超过这个设定值。但是如果这个这个值设定的过小,那么Parallel Scavenge收集器为了保证每次垃圾收集的时间不超过这个限定值,会导致垃圾收集的次数增加和增加年轻代的空间大小,垃圾收集的吞吐量也会随之下降。GCTimeRatio这个参数的值应该是一个0-100之间的整数,表示应用程序运行时间和垃圾收集时间的比值。如果把值设置为19,即系统运行时间 : GC收集时间 = 19 : 1,那么GC收集时间就占用了总时间的5%(1 / (19 + 1) = 5%),该参数的默认值为99,即最大允许1%(1 / (1 + 99) = 1%)的垃圾收集时间。
Parallel Scavenge收集器还有一个参数:-XX:UseAdaptiveSizePolicy。这是一个开关参数,当开启这个参数以后,就不需要手动指定新生代的内存大小(-Xmn)、Eden区和Survivor区的比值(-XX:SurvivorRatio)以及晋升到老年代的对象的大小(-XX:PretenureSizeThreshold)等参数了,虚拟机会根据当前系统的运行情况动态调整合适的设置值来达到合适的停顿时间和合适的吞吐量,这种方式称为GC自适应调节策略。
Parallel Scavenge收集器也是一款多线程收集器,但是由于目的是为了控制系统的吞吐量,所以这款收集器也被称为吞吐量优先收集器。
4.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
如果在Server模式下,主要两大用途:
(1)在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用
(2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
jvm7.png
4.5 Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用"标记-整理"算法。这个收集器是在JDK1.6版本中出现的。
在JDK1.6之前,新生代的Parallel Scavenge只能和Serial Old这款单线程的老年代收集器配合使用。
Parallel Old垃圾收集器和Parallel Scavenge收集器一样,也是一款关注吞吐量的垃圾收集器,和Parallel Scavenge收集器一起配合,可以实现对Java堆内存的吞吐量优先的垃圾收集策略。
Parallel Old垃圾收集器的工作原理和Parallel Scavenge收集器类似。
jvm8.png
4.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
从图中可以看出,CMS收集器的工作过程可以分为4个阶段:
- 初始标记(CMS initial mark)阶段
- 并发标记(CMS concurrent mark)阶段
- 重新标记(CMS remark)阶段
- 并发清除(CMS concurrent sweep)阶段
从图中可以看出,在这4个阶段中,
- 初始标记和重新标记这两个阶段都是只有GC线程在运行,用户线程会被停止,所以这两个阶段会发送STW(Stop The World)。
- 初始标记阶段的工作是标记GC Roots可以直接关联到的对象,速度很快。
- 并发标记阶段,会从GC Roots 出发,标记处所有可达的对象,这个过程可能会花费相对比较长的时间,但是由于在这个阶段,GC线程和用户线程是可以一起运行的,所以即使标记过程比较耗时,也不会影响到系统的运行。
- 重新标记阶段,是对并发标记期间因用户程序运行而导致标记变动的那部分记录进行修正,重新标记阶段耗时一般比初始标记稍长,但是远小于并发标记阶段。
- 最终,会进行并发清理阶段,和并发标记阶段类似,并发清理阶段不会停止系统的运行,所以即使相对耗时,也不会对系统运行产生大的影响。
由于并发标记和并发清理阶段是和应用系统一起执行的,而初始标记和重新标记相对来说耗时很短,所以可以认为CMS收集器在运行过程中,是和应用程序是并发执行的。由于CMS收集器是一款并发收集和低停顿的垃圾收集器,所以CMS收集器也被称为并发低停顿收集器。
虽然CMS收集器可以是实现低延迟并发收集,但是也存在一些不足。
- 首先,CMS收集器对CPU资源非常敏感。对于并发实现的收集器而言,虽然可以利用多核优势提高垃圾收集的效率,但是由于收集器在运行过程中会占用一部分的线程,这些线程会占用CPU资源,所以会影响到应用系统的运行,会导致系统总的吞吐量降低。CMS默认开始的回收线程数是(Ncpu + 3) / 4,其中Ncpu是机器的CPU数。所以,当机器的CPU数量为4个以上的时候,垃圾回收线程将占用不少于%25的CPU资源,并且随着CPU数量的增加,垃圾回收线程占用的CPU资源会减少。但是,当CPU资源少于4个的时候,垃圾回收线程占用的CPU资源的比例会增大,会影响到系统的运行,假设有2个CPU的情况下,垃圾回收线程将会占据超过50%的CPU资源。所以,在选用CMS收集器的时候,需要考虑,当前的应用系统,是否对CPU资源敏感。
- 其次,CMS收集器在处理垃圾收集的过程中,可能会产生浮动垃圾,由于它无法处理浮动垃圾,所以可能会出现Concurrent Mode Failure问题而导致触发一次Full GC。所谓的浮动垃圾,是由于CMS收集器的并发清理阶段,清理线程是和用户线程一起运行,如果在清理过程中,用户线程产生了垃圾对象,由于过了标记阶段,所以这些垃圾对象就成为了浮动垃圾,CMS无法在当前垃圾收集过程中集中处理这些垃圾对象。由于这个原因,CMS收集器不能像其他收集器那样等到完全填满了老年代以后才进行垃圾收集,需要预留一部分空间来保证当出现浮动垃圾的时候可以有空间存放这些垃圾对象。在JDK 1.5中,默认当老年代使用了68%的时候会激活垃圾收集,这是一个保守的设置,如果在应用中老年代增长不是很快,可以通过参数"-XX:CMSInitiatingOccupancyFraction"控制触发的百分比,以便降低内存回收次数来提供性能。在JDK 1.6中,CMS收集器的激活阀值变成了92%。如果在CMS运行期间没有足够的内存来存放浮动垃圾,那么就会导致"Concurrent Mode Failure"失败,这个时候,虚拟机将启动后备预案,临时启动Serial Old收集器来对老年代重新进行垃圾收集,这样会导致垃圾收集的时间边长,特别是当老年代内存很大的时候。所以对参数"-XX:CMSInitiatingOccupancyFraction"的设置,过高,会导致发生Concurrent Mode Failure,过低,则浪费内存空间。
- CMS的最后一个问题,就是它在进行垃圾收集时使用的"标记-清除"算法,在进行垃圾清理以后,会出现很多内存碎片。过多的内存碎片会影响大对象的分配,会导致即使老年代内存还有很多空闲,但是由于过多的内存碎片,不得不提前触发垃圾回收。为了解决这个问题,CMS收集器提供了一个"-XX:+UseCMSCompactAtFullCollection"参数,用于CMS收集器在必要的时候对内存碎片进行压缩整理。由于内存碎片整理过程不是并发的,所以会导致停顿时间变长。"-XX:+UseCMSCompactAtFullCollection"参数默认是开启的。虚拟机还提供了一个"-XX:CMSFullGCsBeforeCompaction"参数,来控制进行过多少次不压缩的Full GC以后,进行一次带压缩的Full GC,默认值是0,表示每次在进行Full GC前都进行碎片整理。
虽然CMS收集器存在上面提到的这些问题,但是毫无疑问,CMS当前仍然是非常优秀的垃圾收集器。
4.7 G1收集器
G1收集器是当今收集器技术最前沿的成功之一与其他。与其他收集器相比主要特点如下:
1、并行与并发:G1能充分的利用多CPU、多核的环境使用多个CPU来缩短停顿的时间,也就是说同样拥有和用户线程同时执行的功能。
2、分代收集:虽然G1可以不需要其他收集器的配合就能独立管理整个Java堆,但是还是采用了不同的方式去处理新建对象和存活了一短时间的对象,这样效果更佳
3、空间整理:与CMS的标记清理算法不同,G1从整体来看是基于标记整理算法实现的,从局部两个Region上来看是基于复制算法,但是不管哪种算法都不会产生内存碎片的问题。
4、可预测的停顿时间:这是G1比CMS的另一优势,降低停顿时间是CMS和G1的共同关注点,但是G1出了追求低停顿外,还可以预测停顿的时间,让使用者明确指定一个长度为毫秒的时间,消耗在垃圾收集的时间不超过这个时间。
G1收集器不再是完全的将堆划分新生代和老年代,取而代之的是将堆划分为多个大小的相等的独立区域(Region),虽然还保留了新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
G1可以预测停顿时间是因为它可以有计划的避免对整个Java堆进行全区域的垃圾收集,G1跟踪各个Region里面的垃圾价值情况,也就是回收后会获得的空间大小和回收所需要多少时间的经验,在后台维护一个优先列表,每次根据允许的时间去判断回收哪个区域后获得的价值更大,这样使用Region和优先级的方式回收,可以保证G1在有限的时间内获得最高的收集价值。
因为一个对象被分配到一个Region中,但是并非只能本Region中的其他对象才能引用,而是可以被整个Java堆中的任意对象所产生引用关系,那么为了避免进行全局的扫描,G1收集器在每个Region中都维护了一个Remembered Set(用来记录跨Region引用的数据结构,在分代中就是记录夸新生代和老年代)。如果虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,然后检查Reference引用的对象是否处于不同的Region之中(在分代中就是检查老年代和新生代的夸代引用),如果是就会通过CardTable(可以理解为是Remembered Set的一种实现)把相关引用的信息记录到被引用对象所属的Region的Rememered Set之中。当进行内存回收时,在GC跟节点的范围加入对Remembered Set中的对象分析,这样就不用为了查找引用而进行全堆的搜索了
使用G1收集器时,Java堆的内存布局是整个规划为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。
G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在真个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获取的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的又来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽量可能高的灰机效率,G1 内存“化整为零”的思路在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会遗漏。
如果不计算Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking) :初始标记阶段只是为了标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top Mark Start)的值,让下一个阶段用户程序并发运行时,能在正确的Region中创建对象。这个阶段需要停顿线程,但是耗时很短
- 并发标记(Concurrent Marking): 并发标记阶段是从GC Roots开始对堆中的对象进行可达性分析,找出存活的对象,这个阶段耗时较长,但是可以和用户线程并发执行。
-
最终标记(Final Marking): 最终标记阶段是为了修正并发标记期间程序继续运行而导致标记产生变化的一部分对象的记录,虚拟机将这段时间对对象的变化记录在线程REmembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这个阶段需要停顿线程,可是可以并行执行。
筛选回收(Live Data Counting and Evacuation) : 筛选回收阶段首先要对各个Region的回收价值和成本进行排序,然后根据用户所期望的GC停顿时间来制定回收计划,从Sun透露的消息,这个阶段可以做到和用户线程并发执行,但是因为只是回收一部分Region,时间是用户可控的,而且停顿用户线程将大幅度提高手机的效率。
jvm11.png
4.8 GC收集器总结
- 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
-
停顿时间短则响应速度好提升用户体验;高吞吐量则CPU利用率高,适合后台运算
image.png
五、参考
1.https://blog.csdn.net/u010349644/article/details/82191822
2.https://blog.csdn.net/yqlakers/article/details/70138786
3.https://blog.csdn.net/u012998254/article/details/81428621
4.https://blog.csdn.net/wen7280/article/details/54428387
5.https://www.cnblogs.com/chengxuyuanzhilu/p/7088316.html
6.https://blog.csdn.net/yulong0809/article/details/77421615