虽然内存的分配和回收技术已相当成熟,但如果需要排查内存溢出、内存泄露问题,或者要求高并发、高性能时,就需要对垃圾的回收进行监控和调节,以更好优化系统提高性能。
Java 内存结构中,程序计数器、虚拟机栈、本地方法栈等随着线程而生,随线程而灭,不需要考虑内存回收问题。而 Java 堆和方法区则不同,它们的内存分配是动态的,只有在运行期间才能知道会创建哪些对象,垃圾回收关注的就是这两部分。
垃圾回收首先需要判断哪些对象还存活着,主要有引用计数和可达性分析两种算法。
它的原理如下:给对象添加一个引用计数器,每当有一个地方引用它时,计时器值就加 1 ;当引用失效时,计数器值就减 1 ;如果计数器为 0,对象就不可能再被使用。
引用计数算法虽然实现简单、判定效率较高。但它很难解决对象之间循环引用的问题。
例如两个对象相互引用,实际上两个对象都不会再访问,但因为相互引用着对方,导致它们的计数器值都不为 0 ,于是引用技术算法无法通过 GC 收集器回收它们。
它的原理如下:通过一系列称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明对象是不可用的。
Java 中,可作为 GC Roots 的对象包括如下几种:
JNI ( Native 方法) 引用的对象。 可以看到,对象回收判定算法判断对象是否存活都与引用有关。从 JDK1.2 开始,引用分为四种类型,用来实现不同的功能,它们的引用强度也依次递减。
平时使用的引用就是强引用。只要强引用还存在,该对象永远不会被回收。
可以通过将对象设置为 null ,使其被回收。
用于描述一些还有用但并非必需的对象。当系统内存空间不足时,会回收这些软引用指向的对象。它通过 SoftReference 类来实现软引用。
可以用来实现高速缓存。
用来描绘非必需对象。被弱引用指向的对象只能生存到下一次垃圾回收之前。只要垃圾收集器运行,弱引用指向的对象就会被回收。它通过 WeakReference 类来实现弱引用。
虚引用和没有引用没有任何区别。一个对象是否有虚引用,不会影响其生存时间,也无法通过虚引用获取对象实例。它通过 PhantomReference 来实现虚引用。必须和引用队列 ReferenceQueue 联合使用。
为一个对象设置虚引用的唯一目的是该对象被垃圾收集器回收前会收到一条系统通知。
方法区,或者说 HotSpot 虚拟机中的永久代,进行垃圾回收的效率一般比较低。回收主要包括两部分内容:废弃常量和无用的类。
判断一个常量是否是废弃常量比较简单,与回收 Java 堆中的对象类似。而判定一个类是否是无用的类需要满足三个条件:
ClassLoader java.lang.Class
标记-清除算法分为两个标记和清除阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。标记过程也就是对象存活判定算法。
它是最基础的收集算法,主要有两个缺点:
复制算法将可用内存分为大小相等的两块,每次只使用其中的一块。在一块内存用完后,将仍存活的对象赋值到另一块上面,再把已使用过的内存一次清理掉。
复制算法的优缺点如下:
标记-整理算法分为标记和整理两个阶段,标记阶段和“标记-清除算法”一样,但在整理阶段,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记-整理算法的优缺点如下:
分代算法根据对象存活周期将内存划分为几块。一般是将 Java 对分为新生代和老年代,根据各个年代的特点采用适当的收集算法。
新生代中,每次垃圾收集时只有少量对象存活,选择复制算法;老年代中,对象存活率较高、没有额外空间进行分配,使用“标记-清理”或“标记-整理”算法。
为了对不同生命周期的对象采用不同的回收算法,所以垃圾收集器都采用分代收集算法,将堆分为新生代和老年代。
新生代主要用来存放新创建的对象,一般占堆 1/3 的空间。由于很多对象生命周期很短,每次 Minor GC 后只有少量对象存活,所以选用复制算法。
新生代又被分为一块较大的 Eden 区和两块较小的大小相等的 Survivor 区,使用 from 和 to 来分别指代两个 Survivor 区。 HotSpot 虚拟机默认 Eden 和两块 Survivor 的大小比例为 8:1:1 。每次只会使用 Eden 和其中一块 Survivor 区为对象服务,所以总是有一块 Survivor 区是空闲的,新生代实际可用的内存空间也就为 90% 。
通常,对象会分配在 Eden 区中,当 Eden 区无法在分配对象时, JVM 便会触发一次 Minor GC ,将存活下来的对象复制到 from 指向的 Survivor 区中。
当 from 指向的 Survivor 区也无法分配时,对 Eden 和 from 指向的 Survivor 区执行 Minor GC ,将存活下来的对象复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,使 to 指向的 Survivor 区为空,以保证下次 Minor GC 有复制的空闲空间。
老年代用于存放大对象,或年龄超过一定程度的对象。一般占据堆 2/3 的空间。
如果对象需要大量连续的内存空间,例如很长的字符串及数组,这些对象会直接分配在老年代,以避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。
虚拟机为每个对象定义了一个对象年龄计数器,如果对象分配在 Eden 区,在经过一次 Minor GC 后仍然存活,之后移动到 Survivor 空间中,将其年龄设置为 1 。对象在 Survivor 区中每经过一次 Minor GC ,年龄就增加一次,当它的年龄增加到一定程度(默认为 15 )时,也会被晋升到老年代中。
如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
老年代的对象一般都比较稳定, Major GC 不会频繁执行。 Major GC 采用标记—清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。 MajorGC 的耗时较长,而且会产生内存碎片。
指发生在新生代的垃圾收集动作。当 Eden 区没有足够的空间分配时,就会触发一次 Minor GC 。由于 Java 对象大多生命周期较短,所以 Minor GC 非常频繁,一般回收速度也比较快。
指发生在老年代的垃圾收集动作,在进行 Major GC 前,一般都会进行至少一次 Minor GC 。 Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
指回收整个新生代和老年代的垃圾收集动作。成本较高,对系统性能产生影响。 FULL GC 的时候会 STOP THE WORD 。
它的触发条件主要有:
Minor GC 之前,如果老年代最大可用的连续空间小于历次晋升到老生代对象的平均大小,则触发一次 Full GC 。 System.gc() 方法时。