Java垃圾收集

众说周知,Java与C++同为支持面向对象的语言,但他们对内存的管理方式却有很大的不同。C++开发者往往需要手动调用内存分配函数对内存进行分配,并在使用完这块内存之后,手动进行释放。而Java开发者一般来说不需要关心内存是如何进行分配的,只需要将精力放在业务开发上。究其原因,是因为Java拥有垃圾回收机制,旨在自动化的对内存进行管理。笔者在这里并不想就这两门语言的内存管理机制进行比较,两者的内存管理方式各有优劣。这里,笔者想介绍一下Java的内存回收机制,以及Java的垃圾收集器。

在详细介绍Java的垃圾回收机制之前,首先我们需要明确3三件事情,即:

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

哪些内存需要回收?

在前面我们介绍过了Java内存区域,在里面有简单的涉及到一点儿内存回收的知识,强调了对方法区和Java堆的内存回收的必要。那么其他的内存需要进行内存回收吗?准确来说,对所有Java内存区域都需要进行内存回收。只不过,程序计数器、虚拟机栈、本地方法栈这3个区域随线程而生,随线程而灭,线程销毁的时候,与之对应的这三个区域的内存就会被回收,整个过程都是确定的行为,其所需的内存基本上在类结构确定下来时就已知了。而方法区和Java堆不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存可能不一样,这部分内存的大小只能在运行时才能知道,对这部分内存的分配与回收是动态的过程,是不确定的。因此,垃圾回收器主要是关注这部分的内存。

什么时候回收?

在上一个问题中,我们明白了Java的垃圾回收主要是针对Java堆和方法区进行回收,因此,针对这两个区域,来讨论什么时候进行内存回收。

Java堆

判断对象是否需要回收的方式一般有两种:引用计数算法和可达性分析算法。

引用计数算法,故名思义,它主要根据对象被引用的情况来判断对象是否需要回收。如果一个对象没有被任何一个对象引用的话,则判定这个对象需要被回收。具体来说,就是在一个对象实例被创建的时候,对这个对象实例被引用的情况进行计数,每次对象被引用,就将引用计数器加1,当引用结束的时候,将引用计数器减1,直到引用计数器归零,就进行回收。

引用计数法是一个很简单的算法,易于实现,效率也高。但是却有一个弊端,当出现两个对象互相引用的时候,引用计数器永远无法归零,那么就永远无法被回收,所以主流的Java虚拟机都没有选择这个算法。目前来说,主流的实现方式是可达性分析算法。

可达性分析算法是通过可达性分析来判定对象是否需要被回收的。这个算法的基本思想路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象

无论是引用计数算法还是可达性分析算法,其算法都与“引用”有关。在JDK1.2之后,Java将引用分为了强引用、软引用、弱引用和虚引用4种,这4种引用强度依次减弱:

  • 强引用简单来说就是“Object obj=new Object()”这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必需的对象。对于这样的对象,在系统将要发生内存溢出之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。
方法区

方法区也被称为永久代,很多人认为永久代是不需要被回收的,Java虚拟机规范也没有明确要求永久代需要被回收,但就实际情况而言,对永久代的回收是一个必须的过程,尽管永久代的回收效率相对来说很低。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

回收废弃常量的过程和回收Java堆中的对象非常类似。当常量池中的常量没有被引用的时候,该常量就被判定为废弃常量,可以进行回收。

判定一个常量是否是“废弃常量”比较简单,判定一个类是否是“无用的类”就比较困难了。类需要同时满足下面3个条件才能算是“无用的类”:

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

虚拟机可以对满足上述3个条件的无用类进行回收,但是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

如何回收?

垃圾收集算法,主要有以下几种:标记-清除算法、复制算法、标记-整理算法。

标记-清除算法

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

复制算法

复制算法是用来解决标记-清除算法的效率问题,它将可用内存按容量划分为大小相等的两块,每次只能使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的空间一次清理到。这样使得每次都是对这个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。只是这种算法的代价是将内存缩小为原来的一半,未免有点高。

现在的商业虚拟机都采用这种收集算法来回收新生代,因为实际上98%的对象都是“朝生夕死”,所以根本不需要按照1:1的比例来划分内存空间。因此,在内存划分的时候,将内存划分一块Eden区域和两块Survivor区域,Eden区域和Survivor区域的比例默认是8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被浪费,这是可以接受的。

标记-整理算法

标记-整理算法是为了解决复制算法在对象存活率较高时就要进行较多的复制操作,效率变低以及大量空间浪费,所以在老年代一般不能直接选用这种算法。标记-整理算法的标记过程与标记-清除算法一样,但并不马上对内存进行回收,而是先让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,也就是把存活的对象进行了整理,减少了内存碎片。

分代收集算法

分代收集算法说白了就是根据对象存活周期的不同将内存划分为几块,比如说老年代和新生代,然后根据不同区域的特点采用不同的收集算法。一般对新生代采用复制算法,对老年代采用标记-清理算法或者标记-整理算法。

坚持原创技术分享,您的支持将鼓励我继续创作!