Java垃圾收集算法

可回收对象的判定

什么样的对象是垃圾(无用对象),需要被回收?现在主要有两种算法用来判定一个对象是否为垃圾。

1. 引用计数算法

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


优点是简单,高效,现在的objective-c用的就是这种算法。缺点是很难处理循环引用,比如图中相互引用的两个对象则无法释放。

2. 可达性分析算法(根搜索算法)

为了解决上面的循环引用问题,Java采用了一种新的算法:可达性分析算法。从GC Roots(每种具体实现对GC Roots有不同的定义)作为起点,向下搜索它们引用的对象,可以生成一棵引用树,树的节点视为可达对象,反之视为不可达。


Java语言定义了如下GC Roots对象:

虚拟机栈(帧栈中的本地变量表)中引用的对象。 
方法区中静态属性引用的对象。 
方法区中常量引用的对象。 
本地方法栈中JNI引用的对象。

Java垃圾收集算法

1.标记-清除算法

​ 标记-清除(Mark-Sweep)算法是最基础的收集算法,算法分为“标记”和“清除”两个阶段:首先标记出所需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的其他收集算法都是基于这种思路并对其不足进行改进而得到的,所以标记-清除算法被称为最基础的收集算法。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配交大对象是,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行如图所示:

2.复制算法

​ 复制(Copying)算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过得内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也将不用考虑内存碎片等复杂情况,只要移动堆顶项指针,按顺序分配内存即可,实现简单,运行高效。该算法的缺点就是将内存缩小为了原来的一半,浪费内存空间,如果对象存活率较高时要执行较多的复制操作,效率降低。

​ 复制算法的执行过程如图所示:


3.标记-整理算法

​ 复制收集算法在对象存活效率较高时要执行较多的复制操作,效率降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接使用这种算法。

​ 根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如图所示。


4.分代收集算法

​ GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。

​ “分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

理解Java垃圾回收机制

Jvm(Java虚拟机)内存模型

Jvm(Java虚拟机)主要管理两种类型内存:堆和非堆。 
堆是运行时数据区域,所有类实例和数组的内存均从此处分配。 
非堆是JVM留给自己用的,包含方法区、JVM内部处理或优化所需的内存(如 JIT Compiler,Just-in-time Compiler,即时编译后的代码缓存)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法的代码。

简言之,Java程序内存主要(这里强调主要二字)分两部分,堆和非堆。一般new的对象和数组都是在堆中的,而GC主要回收的内存也是这块堆内存。

堆内存(Heap Memory): 存放Java对象 
非堆内存(Non-Heap Memory): 存放类加载信息和其它meta-data 
其它(Other): 存放JVM 自身代码等。


堆内存模型

堆内存由垃圾回收器的自动内存管理系统回收。 
堆内存分为两大部分:新生代和老年代。比例为1:2。 
老年代主要存放应用程序中生命周期长的存活对象。 
新生代又分为三个部分:一个Eden区和两个Survivor区,比例为8:1:1。 
Eden区存放新生的对象。 
Survivor存放每次垃圾回收后存活的对象。


主要问题:

  1. 为什么要分新生代和老年代?
  2. 新生代为什么分一个Eden区和两个Survivor区?
  3. 一个Eden区和两个Survivor区的比例为什么是8:1:1

Stop The World

有了上面的垃圾对象的判定,我们还要考虑一个问题,那就是Stop The World。因为垃圾回收的时候,需要整个的引用状态保持不变,否则判定是判定垃圾,等我稍后回收的时候它又被引用了,这就全乱套了。所以,GC的时候,其他所有的程序执行处于暂停状态,卡住了。幸运的是,这个卡顿是非常短(尤其是新生代),对程序的影响微乎其微 (关于其他GC比如并发GC之类的,在此不讨论)。所以GC的卡顿问题由此而来,也是情有可原,暂时无可避免。

为什么不是一块Survivor空间而是两块?

这里涉及到一个新生代和老年代的存活周期的问题,比如一个对象在新生代经历15次(仅供参考)GC,就可以移到老年代了。问题来了,当我们第一次GC的时候,我们可以把Eden区的存活对象放到Survivor A空间,但是第二次GC的时候,Survivor A空间的存活对象也需要再次用Copying算法,放到Survivor B空间上,而把刚刚的Survivor A空间和Eden空间清除。第三次GC时,又把Survivor B空间的存活对象复制到Survivor A空间,如此反复。所以,这里就需要两块Survivor空间来回倒腾。

为什么Eden空间这么大而Survivor空间要分的少一点?

新创建的对象都是放在Eden空间,这是很频繁的,尤其是大量的局部变量产生的临时对象,这些对象绝大部分都应该马上被回收,能存活下来被转移到survivor空间的往往不多。所以,设置较大的Eden空间和较小的Survivor空间是合理的,大大提高了内存的使用率,缓解了Copying算法的缺点。从Eden空间往Survivor空间转移的时候Survivor空间不够了怎么办?直接放到老年代去。

Eden空间和两块Survivor空间的工作流程

现在假定有新生代Eden,Survivor A, Survivor B三块空间和老生代Old一块空间。

// 分配了一个又一个对象
放到Eden区
// 不好,Eden区满了,只能GC(新生代GC:Minor GC)了
把Eden区的存活对象copy到Survivor A区,然后清空Eden区(本来Survivor B区也需要清空的,不过本来就是空的)
// 又分配了一个又一个对象
放到Eden区
// 不好,Eden区又满了,只能GC(新生代GC:Minor GC)了
把Eden区和Survivor A区的存活对象copy到Survivor B区,然后清空Eden区和Survivor A区
// 又分配了一个又一个对象
放到Eden区
// 不好,Eden区又满了,只能GC(新生代GC:Minor GC)了
把Eden区和Survivor B区的存活对象copy到Survivor A区,然后清空Eden区和Survivor B区
// ...
// 有的对象来回在Survivor A区或者B区呆了比如15次,就被分配到老年代Old区
// 有的对象太大,超过了Eden区,直接被分配在Old区
// 有的存活对象,放不下Survivor区,也被分配到Old区
// ...
// 在某次Minor GC的过程中突然发现:
// 不好,老年代Old区也满了,这是一次大GC(老年代GC:Major GC)
Old区慢慢的整理一番,空间又够了
// 继续Minor GC
// ...
// ...

触发GC的类型

GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC。 
GC_CONCURRENT: 当我们应用程序的堆内存达到一定量,或者可以理解为快要满的时候,系统会自动触发GC操作来释放内存。 
GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。 
GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC。





猜你喜欢

转载自blog.csdn.net/hsj1213522415/article/details/78254337
今日推荐