垃圾回收机制与内存分配策略

时间:2022-06-08 02:23:20

我们经常说的垃圾收集(Grabage Collection,GC)需要确定以下三个事情:

哪些内存需要回收?
什么时候回收?
如何回收?

在这三件事情之前,我们先看看为什么要进行垃圾收集?
因为程序中存在的实例对象,变量等信息如果不进行垃圾回收的话,随着程序的运行,必然会带来程序性能的下降,造成卡、慢甚至系统异常。

  • 哪些内存需要回收?

前面我们说到了程序计数器、虚拟机栈、本地方法栈三个区域是线程隔离的数据区,也就是说,这三个区域随线程而生,随线程而灭。由于这几个内存区域的内存分配和回收具有确定性,因此在这几个区域就不需要过多的考虑回收的问题。Java中的堆和方法区便不同,这部分的内存分配与回收都是动态的。我们说的垃圾收集就是对这部分的内存进行回收。

  • 什么时候回收?

回收之前,我们必须要知道那部分对象是存活的,哪部分对象已经死去。
所谓存活和死去,简单的来讲就是一个对象不存在任何引用,那么它就可以被回收了。

如何判断对象存活和死去?
引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器的值就加1,
当引用失效时,计数器的值就减1,当计时器的值为0时,该对象就是不可能
再被使用的。

引用算法实现简单,判断效率也高,像微软公司的COM、Python语言和游戏脚本领域的Squirrel都使用引用计时算法进行内存管理。
但是JAVA没有使用引用计数算法来管理内存,最主要的原因是很难解决相互循环引用的问题。当两个对象都没有用的时候,应该对其回收,但是因为互相引用着对方,导致引用计时器不为0,所以就无法回收掉这块内存。

Java采用了另外一种算法,来判断对象是否存活—–可达性分析。
这种算法是将一些称为的GC ROOTs的对象作为起点,从这些结点往下搜索,当GC无论经过多少路径都无法到达某一个对象时,就说明这个对象是不可用的,就会被判定为可回收的对象。(这个对象也不是非回收不可的,后面我们会看到对象拥有一次自救机会)

垃圾回收机制与内存分配策略

如图所示:虽然object5、object6、object7之间存在有引用,但是他们到GC是不可达的,所以会被判定为可回收的对象。

那么这个GC Roots到底是什么?可以包含下面几种
1、虚拟机栈(栈帧中的本地变量表)中引用的对象
2、方法区中类静态属性引用的成员
3、方法中常量引用的对象
4、本地方法栈中(Native方法)引用的对象

上面大部分都是引用,引用就是一块内存中数据中存储的类型是另外一块内存的起始地址。
但是这样定义太过于狭隘。JDK1.2后,Java对引用的概念进行了扩充,将引用分为分为强引用、软引用、弱引用和虚引用。
强引用:通过new出来的引用。若此引用存在,被引用的对象就永远不可能被回收。
软引用:描述一些有用但非必须的对象。像Java缓存中的对象,这对象是可有可无的,如果内存不足,则可以回收,反之留在内存中可提高程序的性能。
弱引用:也是描述非必须的对象,但是比软引用更弱一些。也就是无论当前内存是否足够,都会回收掉只被弱引用引用的对象。
虚引用:最弱的一种引用关系,一个对象的生存时间跟虚引用没有关系,也无法通过虚引用来取得一个对象的实例。此引用的目的是在这个对象被垃圾收集器回收时收到一个系统通知。

对象自救?
我们刚才说到,当判断完对象可回收时,并不会直接回收此对象。
如果此对象没有覆盖finalize()方法,或者此方法已经被虚拟机调用过,那直接就被回收了。
如果此对象的finalize()方法没有被虚拟机调用过,那么finalize()方法就是对象生存的最后一次机会,此时只要将对象与引用链(GC Roots可以到达的路径)上的任何一个对象建立关联即可,这样就可以在第二次回收的时候(和GC Roots不可达是第一次)将自己移除即将被回收的集合。

如下代码:


/**
*
* 一次对象自我拯救
* 如果对象在可行性分析后,发现没有GC root相连接的引用链,它将会被第一
* 次标记并且进入筛选
* 筛选条件是此对象是否有必要执行finalize()方法
* finalize的方法只能被执行一次
*
* */

public class FinalizeEscapeGC {

public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive()
{
System.out.println("i am alive");
}
@Override
protected void finalize() throws Throwable {

super.finalize();
System.out.println("finalize method executed");
//自救ing
FinalizeEscapeGC.SAVE_HOOK = this;
}

public static void main(String[] args) throws InterruptedException {

SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;


System.gc();
Thread.sleep(500);//0.5秒
if(SAVE_HOOK !=null)
{
SAVE_HOOK.isAlive();
}else
{
System.out.println("i am dead");
}

SAVE_HOOK = null;
System.gc();
Thread.sleep(500);//0.5秒
if(SAVE_HOOK !=null)
{
SAVE_HOOK.isAlive();
}else
{
System.out.println("i am dead");
}

}
}

回收方法区了,也就是HotSpot中的永久代。
永久代的垃圾收集主要收集两部分内容:废弃常量和无用的类
回收常量和回收Java堆中的对象类似。
类需要满足以下三个条件才能算是无用的类

1、该类的所有实例都被回收,java堆中不存在任何该类实例的对象
2、加载该类的ClassLoader已经被回收
3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任
何地方通过反射访问该类的方法。
  • 如何回收?

肯定是通过垃圾收集器回收了,垃圾收集器内有许多算法,下面来看一下GC算法。

1、标记-清除算法(Mark-Sweep):
  这是最基础的收集。分为标记和清除两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象,标记过程就是我们说的可达性分析。
  这种算法有两种不足:标记和清除的过程效率都不高;在清除完成后,会产生大量的不连续的内存碎片,这样以后需要分配大对象的话,无法找到足够的连续内存而提前触发一次垃圾收集。

2、复制算法(copying)
  将内存划分为大小相等的两块,每次只使用其中一块。当这块中的空间用完了,就将其中生存的对象复制到另外一块内存。然后再将已使用的内存清理掉。但是这种算法的代价是将内存缩小为一半。因为Java中新生代的对象大部分都是朝生夕死的,所以此算法经常用来回收新生代。
  回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor,最后清理Eden和刚用过的Survivor,当Survivor内存不够用时,老年代进行分配担保。
  
3、标记-整理算法(Mark-Compact):
  因为老年代的对象是经过多次回收依然存在的对象,如果对老年代采用复制算法,那么可能会发生所有对象都存活的情况,此时的复制效率就会非常低下。
  针对老年代的特点,有一种标记-整理算法,将可回收的对象先标记,然后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
  

  • 垃圾收集器:

      
    垃圾回收算法只是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
    因为Java虚拟机规范对垃圾收集器如何实现并没有具体规定,所以不同厂商,不同版本的虚拟机的所提供的收集器会有很大区别。
    下图是HotSpot虚拟机的垃圾收集器(两个收集器之间的连线表明二者可以搭配使用):

垃圾回收机制与内存分配策略

Serial收集器(串行)

Serial收集器是最基本,发展历史最悠久的收集器,但是这个收集器是一个单线程。
所谓的单线程除了只会启动一条线程去收集垃圾,更重要的是它收集垃圾的时候,所有线程必须暂停工作,直到它收集结束。即Stop The World。
但是Serial收集器是运行在Client模式下的默认新生代收集器。在Client模式下只要停顿不是很频繁,停顿几十毫秒是可以接受的。

ParNew收集器(并行)
ParNew收集器可以理解为Serial收集器的多线程版本,经常是Server模式下的虚拟机中的首选的新生代收集器,因为它可以与CMS配合使用。

Parallel Scavenge收集器(并行回收)
Parallel Scavenge收集器同样也是多线程的新生代收集器,类似于ParNew。
但是Parallel Scavenge收集器的特点就是它的关注点与其他收集器不同:CMS等收集器的目的是减少垃圾收集时用户进程的等待时间;而Parallel Scavenge收集器的目的是达到一个可控制的吞吐量(运行用户代码时间/(运行用户代码时间+垃圾回收时间))。所以也称为吞吐量优先收集器。
除了这点,Parallel Scavenge还具有自适应调节策略,将-XX:+UserAdaptiveSizePolicy打开后,虚拟机会根据当前系统的运行情况收集性能监控信息。

Serial Old收集器
Serial Old是Serial收集器的老年代版本,是一个单线程收集器,使用标记-整理算法,前三种都是使用停止复制算法。

Parallel Old收集器
Parallel Old是 Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法。在注重吞吐量以及CPU资源敏感的场合,可以使用Parallel Scavenge+Parallel Old

CMS收集器(并发)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
CMS是基于标记-清除算法实现的,但其过程分为四个阶段:

初始标记:标记GC Roots能直接关联到的对象
并发标记:对GC Roots进行可达性分析,判断对象是否存活
重新标记:修正在并发标记阶段因为用户操作而使标记改变的对象,停顿时间比初始标记的停顿时间长
并发清除:对其进行清除回收

整个过程中最耗时的是并发标记和并发清除,但是这两个都可以和用户线程一起工作

CMS曾经几乎是划时代垃圾收集器,但是它也有三个缺点:

1、对CPU资源非常敏感,因为是在并发阶段,不会导致用户进程停顿,但是因为占用了一部分线程,所以会导致应用程序变慢,总吞吐量降低。CMS默认启动的线程数是(CPU+3)/ 4,可以看出来,在CPU数量增多的情况下,占用的CPU会越多。
2、无法处理浮动垃圾,由于CMS在并发清理阶段,用户进程还在运行,所以还会产生垃圾,但是CMS收集器无法再当次收集过程过处理掉它们,所以留到下一次GC时在清理。这些垃圾就称为浮动垃圾。因此CMS要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器一样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发清理的程序使用。
3、由于CMS是基于标记-清除算法实现收集器,就意味着有大量空间碎片产生,当无法找到足够大的连续空间时,会提前触发一次Full GC,CMS可以开启-XX:UserCMSCompactAtFullCollection,用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程。内存整理过程是无法并发的,所以停顿时间不得不变长。

G1收集器
G1收集器可以利用多个CPU来缩短Stop The World的停顿时间,与CMS不同,G1从整体上看是基于标记-整理算法实现的收集器,但是从局部来看是基于复制算法实现的,这意味着不会产生产生内存空间碎片,也就不会提前触发GC。除了降低停顿时间外,还可以建立可预测的停顿时间模型,能让使用者明确在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不超过N毫秒。
G1的堆内存布局也产生了很大变化,将Java堆划分为多个大小相等的独立区域(Region),新生代和老生代不再是物理隔离的,是一部分Region(不需要连续)的集合。
GC1收集器的过程分为四个阶段

初始标记、并发标记、最终标记、筛选回收

垃圾收集器中的并发与并行:

并行(Parallel):多条垃圾收集线程并行工作,此时用户线程处于等待停止状态。
并发(Concurrent):用户线程与垃圾收集线程同时执行,即用户程序继续运行,而垃圾收集程序运行在另一个CPU中。

  • 内存分配与回收策略:

对象的内存分配,大的方向就是堆上分配,对象主要分配在新生代的Eden区域,如果启动了本地线程分配缓冲(TLAB),将按线程优先在TLAB上分配。

1、对象优先在Eden区域分配
2、大对象直接进入老年代
加参数-XX:PretenureSizeThreShold参数,这样做目的避免在eden和两个survivor区之间发生大量的内存复制。
3、长期存活的对象进入老年代:虚拟机给每个对象定义一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor接纳,将被移动到此区,年龄设置为1。对象在Survivor没熬过一次Minor GC年龄就加1,知道增加到一定年龄,就会晋升到老年代,通过-XX:MaxTenuringThreshold参数来设置最大年龄。
4、动态年龄判读
  如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代。

5、空间分配担保
在Minor GC之前,会首先检查老年代最大可用的连续空间是否大于新生代对象的总空间,如果这个条件成立,那么Minor GC就是安全的。如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么会继续检查老年代连续空间大于新生代对象的总大小或者历次晋升的平均大小,大于就进行一次Minor GC,如果小于或者HandlePromotionFailure设置为不允许冒险,则改为进行一次Full GC

JDK6 Update 24之后变为:

 如果老年代连续空间大于新生代对象的总大小或者历次晋升的平均大小就会
进行Minor GC,否则会进行Full GC。

为毛需要两个Survivor
网上搜到的答案:
在原始的copying收集算法里,空间被分为两半,叫做semispace。空间分配和回收的过程就是把其中一半用作from来分配空间,当from快满或满足别的一些条件时将可到达的对象复制到to,并将from与to逻辑交换的过程。单纯的copying收集不能很好的应对长时间存活的对象,因为那样的对象每次经历收集的时候都还活着,带来拷贝的开销。出于权衡,HotSpot里目前除G1外都采用相似的方式实现分代式GC,并且在young gen都使用copying收集算法。不过它使用的copying算法是原始算法的变种,留一块较大的区域作为eden,在eden与old gen之间设置semispace来作为缓冲,让“中等寿命”的对象尽量在进入old gen之前被收集掉。这就是HotSpot的survivor spaces了。嗯虽说是相似的方式,不过HotSpot里Parallel Scavenge与另外几种GC在实现上有点不同。其它的几种是共用了一套从JDK 1.4开始用的分代式GC框架,PS则是自己实现了类似的功能而没用那套框架。

其实设计成From 和 To 两个平行的区,是为了筛选真正符合old区的要求的对象(即需要长时间持有的引用的对象),然后再将他们放入old区。