深入理解 JVM 之 垃圾回收机制

1,105 阅读9分钟

虽然内存的分配和回收技术已相当成熟,但如果需要排查内存溢出、内存泄露问题,或者要求高并发、高性能时,就需要对垃圾的回收进行监控和调节,以更好优化系统提高性能。

对象存活判定

Java 内存结构中,程序计数器、虚拟机栈、本地方法栈等随着线程而生,随线程而灭,不需要考虑内存回收问题。而 Java 堆和方法区则不同,它们的内存分配是动态的,只有在运行期间才能知道会创建哪些对象,垃圾回收关注的就是这两部分。

垃圾回收首先需要判断哪些对象还存活着,主要有引用计数和可达性分析两种算法。

引用计数算法

它的原理如下:给对象添加一个引用计数器,每当有一个地方引用它时,计时器值就加 1;当引用失效时,计数器值就减 1;如果计数器为 0,对象就不可能再被使用。

引用计数算法虽然实现简单、判定效率较高。但它很难解决对象之间循环引用的问题。

例如两个对象相互引用,实际上两个对象都不会再访问,但因为相互引用着对方,导致它们的计数器值都不为 0,于是引用技术算法无法通过 GC 收集器回收它们。

可达性分析算法

它的原理如下:通过一系列称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明对象是不可用的。

Java 中,可作为 GC Roots 的对象包括如下几种:

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

引用类型

可以看到,对象回收判定算法判断对象是否存活都与引用有关。从 JDK1.2 开始,引用分为四种类型,用来实现不同的功能,它们的引用强度也依次递减。

强引用(Strong Reference)

平时使用的引用就是强引用。只要强引用还存在,该对象永远不会被回收。

可以通过将对象设置为 null,使其被回收。

软引用(Soft Reference)

用于描述一些还有用但并非必需的对象。当系统内存空间不足时,会回收这些软引用指向的对象。它通过 SoftReference 类来实现软引用。

可以用来实现高速缓存。

弱引用(Weak Reference)

用来描绘非必需对象。被弱引用指向的对象只能生存到下一次垃圾回收之前。只要垃圾收集器运行,弱引用指向的对象就会被回收。它通过 WeakReference 类来实现弱引用。

虚引用(Phantom Reference)

虚引用和没有引用没有任何区别。一个对象是否有虚引用,不会影响其生存时间,也无法通过虚引用获取对象实例。它通过 PhantomReference 来实现虚引用。必须和引用队列 ReferenceQueue 联合使用。

为一个对象设置虚引用的唯一目的是该对象被垃圾收集器回收前会收到一条系统通知。

回收方法区

方法区,或者说 HotSpot 虚拟机中的永久代,进行垃圾回收的效率一般比较低。回收主要包括两部分内容:废弃常量和无用的类。

判断一个常量是否是废弃常量比较简单,与回收 Java 堆中的对象类似。而判定一个类是否是无用的类需要满足三个条件:

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

垃圾收集算法

标记-清除算法(Mark-Sweep)

标记-清除算法分为两个标记和清除阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。标记过程也就是对象存活判定算法。

它是最基础的收集算法,主要有两个缺点:

  • 效率问题:标记和清除两个过程的效率都不高。
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法(Copying)

复制算法将可用内存分为大小相等的两块,每次只使用其中的一块。在一块内存用完后,将仍存活的对象赋值到另一块上面,再把已使用过的内存一次清理掉。

复制算法的优缺点如下:

  • 优点:每次对半个分区进行内存回收,内存分配时也不用考虑内存碎片等情况,实现简单,运行高效。
  • 缺点:可使用的内存缩小为一半,代价较大。

标记-整理算法(Mark-compact)

标记-整理算法分为标记和整理两个阶段,标记阶段和“标记-清除算法”一样,但在整理阶段,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

标记-整理算法的优缺点如下:

  • 避免了空间碎片,空间利用率较高。
  • 效率不高,标记和清除过程的效率较低。

分代算法(Generational Collection)

分代算法根据对象存活周期将内存划分为几块。一般是将 Java 对分为新生代和老年代,根据各个年代的特点采用适当的收集算法。

新生代中,每次垃圾收集时只有少量对象存活,选择复制算法;老年代中,对象存活率较高、没有额外空间进行分配,使用“标记-清理”或“标记-整理”算法。

为了对不同生命周期的对象采用不同的回收算法,所以垃圾收集器都采用分代收集算法,将堆分为新生代和老年代。

  

内存分配和回收策略

新生代

新生代主要用来存放新创建的对象,一般占堆 1/3 的空间。由于很多对象生命周期很短,每次 Minor GC 后只有少量对象存活,所以选用复制算法。

新生代又被分为一块较大的 Eden 区和两块较小的大小相等的 Survivor 区,使用 fromto 来分别指代两个 Survivor 区。HotSpot 虚拟机默认 Eden 和两块 Survivor 的大小比例为 8:1:1。每次只会使用 Eden 和其中一块 Survivor 区为对象服务,所以总是有一块 Survivor 区是空闲的,新生代实际可用的内存空间也就为 90%

通常,对象会分配在 Eden 区中,当 Eden 区无法在分配对象时,JVM 便会触发一次 Minor GC,将存活下来的对象复制到 from 指向的 Survivor 区中。

from 指向的 Survivor 区也无法分配时,对 Edenfrom 指向的 Survivor 区执行 Minor GC,将存活下来的对象复制到 to 指向的 Survivor 区中,然后交换 fromto 指针,使 to 指向的 Survivor 区为空,以保证下次 Minor GC 有复制的空闲空间。

老年代

老年代用于存放大对象,或年龄超过一定程度的对象。一般占据堆 2/3 的空间。

如果对象需要大量连续的内存空间,例如很长的字符串及数组,这些对象会直接分配在老年代,以避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。

虚拟机为每个对象定义了一个对象年龄计数器,如果对象分配在 Eden 区,在经过一次 Minor GC 后仍然存活,之后移动到 Survivor 空间中,将其年龄设置为 1。对象在 Survivor 区中每经过一次 Minor GC,年龄就增加一次,当它的年龄增加到一定程度(默认为 15)时,也会被晋升到老年代中。

如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

老年代的对象一般都比较稳定,Major GC 不会频繁执行。Major GC 采用标记—清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时较长,而且会产生内存碎片。

三种清理方式

Minor GC(Young GC)

指发生在新生代的垃圾收集动作。当 Eden 区没有足够的空间分配时,就会触发一次 Minor GC。由于 Java 对象大多生命周期较短,所以 Minor GC 非常频繁,一般回收速度也比较快。

Major GC

指发生在老年代的垃圾收集动作,在进行 Major GC 前,一般都会进行至少一次 Minor GCMajor GC 的速度一般会比 Minor GC10 倍以上。

Full GC

指回收整个新生代和老年代的垃圾收集动作。成本较高,对系统性能产生影响。FULL GC 的时候会 STOP THE WORD

它的触发条件主要有:

  • 在执行 Minor GC 之前,如果老年代最大可用的连续空间小于历次晋升到老生代对象的平均大小,则触发一次 Full GC
  • 大对象直接进入老年代,或从年轻代晋升上来的老对象,在老年代尝试分配内存,但老年代内存空间不够时。
  • 显式调用 System.gc() 方法时。

参考资料