java的垃圾回收机制

java的垃圾回收机制 (Garbage Collection)


1.垃圾回收机制的意义

我们知道C++中对象是在堆栈上分配内存的,如果程序员忘了delete/free该对象的内存空间的话,就会造成内存泄漏(memory leak),如果一个对象删除两次的话就会导致程序崩溃。因此有经验的C++程序员都会在删除之后将指针重置为NULL,然后在删除之前先判断指针是否为NULL。
而java采用垃圾回收机制,使程序员不需要关心内存的管理。由于有个垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”。垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存。然而另一方面,也正是因为Java程序员把内存控制的权利交给Java虚拟机,一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误将会是一项异常艰难的工作。

2.垃圾判定的算法

既然如此,java怎么判断哪些东西是垃圾呢

  • 引用计数法
    一个对象创建时,其实例引用计数器为1,以后每有一个引用指向该实例,引用计数器就加1;当指向该实例的引用脱离作用于或者设置为null时,引用计数器减1。当引用计数器为0 时表示该实例是垃圾,占用的内存空间需要释放。
    这种方法不能解决循环引用带来的问题,举个例子。
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;
    }
}

object1和object2指向的对象需要被回收,但是引用计数器不为0。所以java并不采用这种回收技术

  • 根搜索算法(GC Root Tracing)
    通过一系列的名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,即从GC Roots到这个对象不可达,则证明此对象是不可用的。

    比如上图,左边的对象都是存活的,右边的都是可以回收的。
    可作为GC ROOT的对象有:
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中 JNI(Native 方法)的引用对象。

实际上,在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法,或 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。如果该对象被判定为有必要执行 finalize()方法,那么这个对象将会被放置在一个名为 F-Queue 队列中,并在稍后由一条由虚拟机自动建立的、低优先级的 Finalizer 线程去执行 finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的 finalize()方法最多只会被系统自动调用一次),稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果要在 finalize()方法中成功拯救自己,只要在 finalize()方法中让该对象重引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

3.垃圾收集算法

判段哪些是垃圾后就可以进行收集了
* Mark-Sweep(标记-清除)算法
标记-清除算法采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如上图所示。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
* Mark-Compact (标记-整理) 算法
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题
* 复制算法
为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。

很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。
* Generational Collection(分代收集)算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。
而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。
注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。

4.GC(垃圾收集器)

新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge

老年代收集器使用的收集器:Serial Old、Parallel Old、CMS

5.垃圾回收分析

在用代码分析之前,我们对内存的分配策略明确以下三点:

  • 对象优先在 Eden 分配。
  • 大对象直接进入老年代。
  • 长期存活的对象将进入老年代。

对垃圾回收策略说明以下两点:

  • 新生代 GC(Minor GC):发生在新生代的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,因此Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC/Full GC):发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次 Minor GC。由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行 Full GC,而且其速度一般会比 Minor GC 慢 10 倍以上。另外,如果分配了 Direct Memory,在老年代中进行 Full GC时,会顺便清理掉 Direct Memory 中的废弃对象。

下面我们来看代码

public class SlotGc{  
    public static void main(String[] args){  
        byte[] holder = new byte[32*1024*1024];  
        System.gc();  
    }  
}  

代码很简单,就是向内存中填充了 32MB 的数据,然后通过虚拟机进行垃圾收集。在 javac 编译后,我们执行如下指令:java -verbose:gc SlotGc 来查看垃圾收集的结果,得到如下输出信息:

[GC 208K->134K(5056K), 0.0017306 secs]

[Full GC 134K->134K(5056K), 0.0121194 secs]

[Full GC 32902K->32902K(37828K), 0.0094149 sec

注意第三行,“->”之前的数据表示垃圾回收前堆中存活对象所占用的内存大小,“->”之后的数据表示垃圾回收堆中存活对象所占用的内存大小,括号中的数据表示堆内存的总容量,0.0094149 sec 表示垃圾回收所用的时间。

从结果中可以看出,System.gc()运行后并没有回收掉这 32MB 的内存,这应该是意料之中的结果,因为变量holder 还处在作用域内,虚拟机自然不会回收掉 holder 引用的对象所占用的内存。

我们把代码修改如下:

public class SlotGc{  
    public static void main(String[] args){  
        {  
        byte[] holder = new byte[32*1024*1024];  
        }  
        System.gc();  
    }  
}  

加入花括号后,holder 的作用域被限制在了花括号之内,因此,在执行System.gc()时,holder 引用已经不能再被访问,逻辑上来讲,这次应该会回收掉 holder 引用的对象所占的内存。但查看垃圾回收情况时,输出信息如下:

[GC 208K->134K(5056K), 0.0017100 secs]

[Full GC 134K->134K(5056K), 0.0125887 secs]

[Full GC 32902K->32902K(37828K), 0.0089226 secs]

很明显,这 32MB 的数据并没有被回收。下面我们再做如下修改:

public class SlotGc{  
    public static void main(String[] args){  
        {  
        byte[] holder = new byte[32*1024*1024];  
        holder = null;  
        }  
        System.gc();  
    }  
}  

这次得到的垃圾回收信息如下:

[GC 208K->134K(5056K), 0.0017194 secs]

[Full GC 134K->134K(5056K), 0.0124656 secs]

[Full GC 32902K->134K(37828K), 0.0091637 secs]

说明这次 holder 引用的对象所占的内存被回收了。我们慢慢来分析。

首先明确一点:holder 能否被回收的根本原因是局部变量表中的 Slot 是否还存有关于 holder 数组对象的引用。
在第一次修改中,虽然在 holder 作用域之外进行回收,但是在此之后,没有对局部变量表的读写操作,holder 所占用的 Slot 还没有被其他变量所复用(回忆 Java 内存区域与内存溢出一文中关于 Slot 的讲解),所以作为 GC Roots 一部分的局部变量表仍保持者对它的关联。这种关联没有被及时打断,因此 GC 收集器不会将 holder 引用的对象内存回收掉。 在第二次修改中,在 GC 收集器工作前,手动将 holder 设置为 null 值,就把 holder 所占用的局部变量表中的 Slot 清空了,因此,这次 GC 收集器工作时将 holder 之前引用的对象内存回收掉了。
当然,我们也可以用其他方法来将 holder 引用的对象内存回收掉,只要复用 holder 所占用的 slot 即可,比如在 holder 作用域之外执行一次读写操作。

为对象赋 null 值并不是控制变量回收的最好方法,以恰当的变量作用域来控制变量回收时间才是最优雅的解决办法。另外,赋 null 值的操作在经过虚拟机 JIT 编译器优化后会被消除掉,经过 JIT 编译后,System.gc()执行时就可以正确地回收掉内存,而无需赋 null 值。


reference:一个面试官对面试问题的分析

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值