JVM之一:GC垃圾回收原理及算法分析

导读

本人java小白一枚,写博客用意一是做一个学习总结,二是作一个分享。所写内容难免会有错误或者理解不到位的情况,恳请各位大佬不断对我提出批评,用技术吊打我,鞭笞我。拜谢~~

一、java中的垃圾

1、什么是垃圾

简单来说,就是java内存中没有用了的对象,或者说是已经被嫌弃,死亡的对象。

2、如何去判断对象是属于垃圾对象呢?

最开始,有一种算法叫做引用计数法。顾名思义就是当对象被引用时,通过对对象的引用情况进行登记,如果存在引用的话,则进行加1,否则减1。当该应用计数为0时,则进行回收处理。
示例:
创建一个对象

String name = new String("csdn");

在这里插入图片描述
此时,name对象指向"csdn",所以计数器RC为1.

name = null;

后面我们将name设置为null,即将name指向为空,此时“csdn”区域将会被回收。
在这里插入图片描述
一切看起来都是那么简单而美好。然后,还是存在一些缺点的。
问题1:
引用计数法并不遵守“STOP THE WORLD”原则,即在回收垃圾时,会将所有java应用程序挂起,而引用计数法是将垃圾回收进行分摊,分到整个的应用程序当中。
问题2:
引用计数算法无法解决循环依赖的问题


public class Main {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
 
        object1.object = object2;
        object2.object = object1;
 
        object1 = null;
        object2 = null;
    }
}
 
class MyObject{
	private String name;
    public Object object = null;
    public MyObject(String name){
		this.name = name;
	}
}

代码流程示意图如下:
在这里插入图片描述
对象A和B已经不可能访问了,但是在两者还是相互被引用,导致它们的引用数永远都不可能为0.因此有内存溢出的风险。


那么,针对上述两个问题,我们该如何进行改进呢?
在这里插入图片描述
实际上,在java中垃圾标记采取的是可达性分析法。可达性分析法的基本思路是通过GC-ROOTS对象作为起点,通过该节点向下进行搜索,搜索所走过的路径称为引用链,如果一个对象跟GC-ROOTS不存在引用链的话,即该对象无法到达GC-ROOTS,就判定该对象为可回收对象。

GC-ROOTS对象主要有四种:
①虚拟机栈中引用的对象
②方法区中静态属性引用的对象
③方法区中常量引用的对象
④本地方法栈(native方法)中引用的对象

那么,被判定为可收回对象就一定会被回收吗?
答案是否定的。
可达性分析法判定为不可达的对象其实相当于一个被判死缓的人,此时的对象处于缓刑期间,在此期间,Object存在finalize方法,让不可达对象做最后的垂死挣扎,如果不可达对象重写了finalize方法,且尚未执行finalize方法,则该对象会进去队列中等待,如果在该方法中重新变为可达,则不会被回收。在队列中的不可达对象如果通过低优先级线程调用执行finalize方法后,仍旧不可达则会被 第二次标记,进行垃圾回收。
在这里插入图片描述
事实上,finalize方法的实际用处并不是很大,该方法具有很大的不确定性,无法确定该方法是否执行完,运行的代价也比较高,十分鸡肋。据不可靠消息,java之所以要设计该方法,主要是为了向C语言程序员更容易接受,而做的一个妥协让步。


二、垃圾回收机制

1、标记-清除算法

在这里插入图片描述
同过对对象进行扫描标记,然后将标记为死亡对象或者说不可达对象进行清理。这种方法是最简单粗暴和直接,哪里有垃圾就扫哪里。但是该方法同样存在缺点。从图中可以看出,清除后,内存空间存在很多的小碎片,这会影响后续的使用。例如为大对象分配空间时,由于空间碎片化,大空间不足,将会提前触发垃圾回收机制。

2、标记复制方法

在这里插入图片描述
为了解决标记-清除所带来的碎片过多的缺点,标记复制算法的提出就是在其基础上所做的改进。该方法是先将内存空间划分为两个部分,一个部分为创建对象区域,一个部分为空闲区域。首先是在创建对象区域对对象进行标记,标记完毕后,将存活对象挪至空闲区域,依次存放,挪动完毕后,一次性将原区域中的死亡对象清除,标记-复制算法解决了标记-清除算法造成碎片过多的弊端,但是同时,我们可以看到,标记-复制算法所能真正使用的空间只有原来的一半,这大大降低内存空间的使用效率。

3、标记-整理算法

在这里插入图片描述
为了改进标记-复制算法所带来的空间利用率不高的问题,人们又提出了标记-整理算法,该算法的主要思路是先将对象进行标记,标记完毕之后,将所有存活对象进行移动一端,移动完毕之后,将端边界以外的不可达对象即死亡对象进行一次清除,释放掉内存。该方法即解决了碎片了问题,也解决了空间利用率低的问题。但是,该方法仍旧不是垃圾回收机制的最优解,由于涉及到对象的移动,毫无疑问,程序需要额外增加工作,这就降低了程序回收垃圾的工作效率。就好比你在收拾东西一样,不仅要把垃圾扔掉,还需要把留下的东西分门别类整理好,肯定会占用你的时间。

4、分代回收

有个成语叫做扬长避短,即发挥东西的长处,规避掉它的短处。上述三种算法很难一下子说谁绝对是好,谁绝对的差。而且我们都知道,其实每个对象的生命周期都不尽相同,那么针对不同生命周期的对象我们其实可以采取不同的垃圾回收算法。
那么对象的生命周期我们可以先简单分类一下:
1、临时对象: 朝生夕死的对象,就是命不久矣的对象。可以看做是容易夭折的baby。例如方法内的局域变量,循环体内的临时变量等
2、持久对象: 一般是指能够存活较久的对象。可以看做是正常寿命的人。到达一定岁数还是会挂掉的。例如缓存对象,数据库连接对象等等
3、永久对象: 此类对象可以看做是出生后几乎就不死的对象,可以简单理解为千年老王八。也只是几乎不会挂。例如String池中的对象(享元模式),加载过的类信息等等。
那么,我接下来讲解分代回收的算法实现:
首先,我们先了解一下分代算法的内存模型:
在这里插入图片描述
解析:
堆内存中分为新生代和老年代,新生代占1/3,老年代占2/3。其中新生代又分为三个区域,Eden:From survivor:To survivor = 8:1:1。其中有一个关键点需要注意,From区和To区并不是跟图中所示一样,是固定的,二是两者的身份会出现互相变换,这个后面具体算法实现的时候会提到。我们在此先约定,黄色代表From区,绿色代表To区
第一次GC
首先,有对象进来,此时所有堆中都是空的,假设对象0是大对象,我们存放到old区,对象1、2、3、4对象都是小对象,此时他们的age都为0,即代表尚未经历GC回收。此时我们触发一次新生代的minor GC,假设对象1、2为不可达对象,此时我们会清空Eden区和From区(此时From区暂无对象)的不可达对象,并将存活对象挪至To区,并将其age加1.挪完毕后,此时的To区实际上已经变成了From区。思考一下为什么呢?
在这里插入图片描述
第二次GC
假设第一次GC完毕之后,又有对象5、6、7进入Eden区,此时又触发了一次minor GC,假设对象6以及From区中的对象3都是是存活对象,那么根据规则(minor GC回收后,Eden区和From区中的存活对象都会挪动到To区,这个就是为什么From和To区是动态交替变化的原因),对象3和对象6统一挪到当前的To区,同时age加1。清除其余的不可达对象。
在这里插入图片描述
第N次GC
那么,当GC次数很多时,假设我们的对象3非常之坚挺,撑过了15次minor GC,那么我们就会判定该对象长大了,将其挪至老年代。判定的age其实是可以设置的,默认为15。
在这里插入图片描述
那么,老年代在什么时候会触发GC回收呢
一般是在老年代快满了或者System.gc()时,会触发Full GC。对全堆进行回收,同时包括几次minor GC,因此运行时间会比较长。

分代算法总结:
1、新生代采用的算法是标记复制算法,其实是对原有的复制算法的优化,默认将空闲区域划分为10%,即牺牲了10%的空间来换内存的整齐度和GC效率。但是存在一个问题,就是新生代用于保存可达对象可能会存在内存不足的情况。
2、老年代采用的算法是标记-清除和标记-整理算法,具体使用哪种其实和垃圾回收器有关。这里就不一一展开讲了。感兴趣的小伙伴可以自行学习。


大家好,我是一名正在找工作的java程序员,如果您这边有合适的岗位请与我联系(头像即微信)。
原创不易,如果对您有帮助,欢迎三连。如果文中有任何错误,请留言指出,感谢您!

猜你喜欢

转载自blog.csdn.net/weixin_39085109/article/details/106771174
今日推荐