LHJ's Blog

Java 内存回收与垃圾收集器

Java语言因为有一套内存回收机制,因此不像C、C++语言那样需要开发人员全程参与到内存的分配与回收中,减轻了开发人员的负担。尽管内存回收机制已经很智能了,但是在一些特定的场景中,如果内存回收成为了性能的瓶颈,就需要对这些智能的操作进行必要的监控和调节。

🚚 内存回收

💭 内存回收主要关注三个问题:

​ ⭐ 哪些内存需要回收

​ ⭐ 什么时候回收

​ ⭐ 怎么回收

🍬 判断对象”已死”

对于第一个问题哪些内存需要回收,自然是哪些无用的内存,即”已死”的内存需要回收。那么又怎么去判断内存或者占据这个内存的对象是否已死呢?

🍡 引用计数法

所谓引用计数法就是,每个对象维护一个计数器,如果这个对象被其它对象引用了,那么引用计数器的数值+1,反之如果不再引用了-1,如果一个对象对应的引用数值为0,说明这个对象没有被其他对象引用,那么这个对象就是”已死”的,可以被回收。引用计数法的原理和实现都很简单,但是Java并不采用这种方式判断对象是否”已死”,因为引用计数法有一个很严重的缺陷:无法解决循环引用的问题。当出现循环引用的时候,即使对象已经没用了,但是由于引用计数器的数值不为0,就会认为这个对象还有用,不会回收他们。很容易导致内存溢出的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ReferenceCounting {

private ReferenceCounting = null;

public static void main(Stirng[] args) {
ReferenceCounting a = new ReferenceCounting();
ReferenceCounting b = new ReferenceCounting();

a.instance = b;
b.instance = a;

a = null;
b = null;

System.gc();
}

}

通过在IDEA添加代码运行参数-XX:PrintGCDetails可以打印GC日志,在个人电脑上打印的日志如下:

1
2
3
4
5
6
7
8
9
10
11
[GC (System.gc()) [PSYoungGen: 5255K->776K(76288K)] 5255K->784K(251392K), 0.0026403 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 776K->0K(76288K)] [ParOldGen: 8K->605K(175104K)] 784K->605K(251392K), [Metaspace: 3207K->3207K(1056768K)], 0.0044350 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 76288K, used 1966K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
eden space 65536K, 3% used [0x000000076b400000,0x000000076b5eb9e0,0x000000076f400000)
from space 10752K, 0% used [0x000000076f400000,0x000000076f400000,0x000000076fe80000)
to space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
ParOldGen total 175104K, used 605K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c974e8,0x00000006cc700000)
Metaspace used 3216K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 349K, capacity 388K, committed 512K, reserved 1048576K

可以看到第一行打印的信息表示年轻代进行了一次GC,说明了Java并不是采用引用计数法来判断对象是否”已死”,如果是,应该不会进行GC才对。

🍭 可达性分析法

可达性分析法是通过一系列叫做”GC Roots”的节点作为起始点向下搜索,搜索过程中所经过的路径成为引用链,当一个对象不在任何一条引用链上的时候,就认为这个对象是不可达的,也就是”已死”对象,可以进行回收。

如下图,虽然obj9、obj10的引用计数器的值都不为0,但是由于没有引用链关联,因此是可回收的对象。

在Java中,可以作为GC Roots节点的对象有四种:

🍥 虚拟机栈中引用的对象

🍥 本地方法栈中JNI引用的对象

🍥 方法区中常量引用的对象

🍥 方法区中类静态属性引用的对象

🍨 引用分类

无论是引用计数法还是可达性分析法,都要涉及到引用。在Java中,引用分为4类,分别是:强引用、软引用、弱引用和虚引用。

⛅ 强引用就是类似Object obj = new Object()这样的引用,用于描述系统运行过程中不可缺失的部分,强引用是不会被GC回收的。

⛅ 软引用指的是那种”食之无味,弃之可惜”的引用对象,当内存还足够的时候不会回收软引用的对象,但是如果内存不足了就会回收。

⛅ 弱引用比软引用的等级还要低一些,如果在GC的时候发现了弱引用,那么就一定会回收它,无论内存是否充足,因此弱引用只能活到下一次GC之前。

⛅ 虚引用是最弱的一种引用,虚引用的存在并不会影响到GC的过程,只是对象在被回收的时候会受到一个系统通知。

⛔ 不可达就一定得”死”吗?

前面已经了解了Java是通过可达性分析算法来确定一个对象是不是”已死”的,但是对象不可达就一定”已死”吗?

事实上Java进行内存回收的时候会有两次标记,一次是通过可达性分析算法进行一次标记,第二次标记是对第一次标记过的对象再进行一次标记,也就是说第一次标记的对象处于一种”半死不死”的状态。

第二次标记的依据是:这个对象是否有必要指向finalize()方法。虚拟机判断对象是否有必要执行这个方法的依据是:对象有没有覆盖finalize()方法或finalize()方法有没有被虚拟机调用过。如果finalize()方法已经被覆盖或被执行,那么虚拟机认为这种情况是没有必要执行的,即不会进行二次标记。如果需要进行二次标记,那么就会把这些对象放置到一个F-Queue的队列中,由虚拟机创建一个低优先级的线程对这些对象进行回收,这些对象就真的”已死”了。

🥚 方法区的内存回收

虽然内存回收的主要区域是堆内存,在方法去也会有一部分的内存回收。

方法区的内存回收的主要内容是:废弃的常量和无用的类。

判断一个常量是否是废弃的常量很简单,比如说一个字符串”Hello World”,如果系统中没有任何一个String对象引用这个常量或者没有任何一个地方引用了它,那么这个常量就可以视为废弃常量了。

判断一个常量是否是废弃的常量很简单,但是判断一个类是否为无用的类就比较苛刻,需要同时满足以下三个条件:

1️⃣ 该类的所有实例都已经被回收。即堆中不存在任何该类的实例。

2️⃣ 加载该类的类加载器已经被回收。

3️⃣ 该类的java.lang.Class对象没有在任何地方被引用,没有任何地方可以通过反射的方式访问这个类的方法。

🛒 分代回收机制

Java是通过将堆内存分为年轻代和老年代来进行内存回收的。年轻代的垃圾回收成为Minor GC,老年代的垃圾回收称为Full GC

🛫 分代回收的好处

之所以要分代进行回收,其目的是为了加快回收的速度和方便分配内存。比如说堆内存的大小为100M,如果不进行分代,那么每一次垃圾回收都要扫描这100M大小的堆。事实上,堆中的某些对象存活的时间很短,可能”朝生夕灭”,有的对象存活的时间比较长,可能一直驻留在虚拟机中。因此可以依照这个特性,将堆内存分为年轻代和老年代,假设年轻代20M,老年代80M。由于年轻代的对象一般活不长,因此这一区域的回收频率会快一些,而老年代比较稳定,回收频率会低一些,这样大部分时间都是在回收这20M的内存,自然会比每次回收100M的内存快得多。

🛬 年轻代进一步细分

年轻代再进一步细分后可以划分为三个区域:eden、from survivor、to survivor。对象主要是在eden区创建,当eden区没有足够的空间的时候,虚拟机会发起一次Minor GC,在进行垃圾回收的时候会将存活的对象放置到from区,然后将from和to区交换,始终保持from区是空闲的。

默认的,eden、from、to三个区域的比值是:8 : 1 : 1

🚁 年轻代对象什么时候进入老年代

既然采用了分代回收的机制,那么就必须有一种分配对象到年轻代和老年代的方式。每一个对象都有一个年龄,如果一个对象在eden区出生,经过一次Minor GC后仍然存活且survivor有足够的空间容纳,那么就置对象的年龄为1,此后这个对象每熬过一次Minor GC,年龄就会+1,当到达一定的数值(默认是15)的时候,就会进入老年代。这个数值可以通过添加-XX:MaxTenuringThreshold进行设置。

🚀 分配担保

分配担保主要是为了解决这么一种情况:年轻代进行回收后,如果存活的对象无法放置到survivor区怎么办呢?此时就会触发分配担保机制,无法放置就会直接进入老年代。

⛵ 垃圾收集算法

🔥 标记-清除算法

标记-清除算法的原理很简单,就是对需要回收的内存进行标记,然后再统一的进行回收。标记-清除算法有两个很明显的缺点:一个是很容易产生内存碎片,使得空间利用率降低,会导致很快就没有足够的空间从而引发GC;另一个是回收的效率不高。

🔥 复制算法

复制算法的原理是:将内存空间分为大小相等的两部分,每次只使用其中的一部分,当内存需要回收的时候,首先将仍然存活的对象复制到另一部分去,然后对这一部分的所有对象进行回收。复制算法的优点是不会产生空间碎片,缺点也很明显:由于一次只会使用空间的1/2,即把内存空间缩减为原来的一半,代价比较高。

复制算法适合对象满足”朝生夕死”的特征,如果对象存活的时间很长,即一次GC后,存活对象占据大多数的情况,那么就会导致需要复制很多的对象到另外一半的内存中,效率并不高。因此在分代回收中,复制算法一般应用于年轻代的回收(比如from区和to区)

🔥 标记-整理算法

标记-整理算法和标记清除算法的过程差不多,首先也是需要对需要回收的内存进行标记,和标记-清除算法不同的地方在于,标记-整理算法是将存活对象向一端移动,然后清理掉端边界以外的内存。

🚌 垃圾收集器

垃圾收集算法是对内存收集的理论指导,而垃圾收集器就是这些理论的具体实现了。目前Java中主要有7种垃圾收集器,如图所示(有线相连的表示可以配合使用):

⚽ Serial收集器

Serial收集器是最早的一款收集器,它是一款单线程的收集器,单线程不仅仅意味着使用一个CPU、一个线程,在进行垃圾收集的时候还会停止所有的用户线程,直到收集结束,俗称”Stop The World”。停止所有用户线程是很难忍受的,假设你的应用运行1小时,暂停5分钟…

虽然说Serial收集器的缺点很明显,但是不意味着这个收集器就没用了,后面的收集器都是依据Serial收集器改进而来的,而且Serial收集器还有一个特点:简单高效。在单个CPU环境下,由于Serial收集器只是用一个CPU,没有线程的切换开销,专心做垃圾收集,因此效率比较高,而且在分配的内存不是很多的情况下,比如一两百兆的时候,回收一次也就几十毫秒,只要不是很频繁,问题就不大。

⚾ ParNew收集器

ParNew收集器是Serial收集器的多线程版本,其它的比如垃圾回收算法、Stop The World、内存分配规则等和Serial完全一样。

ParNew收集器虽然适用了多线程,但是在单个CPU的环境下并不能保证就比Serial收集器有更好的效率,因为ParNew还有线程上下文的开销。

🏀 Parallel Scavenge收集器

Parallel Scavenge收集器也是一个多线程并行的收集器,但是和Serial、ParNew收集器不同的是,前者关注于尽可能减少Stop The World的时间,提升垃圾回收的速度;而后者关注的是吞吐量,即用户线程工作的时间和总时间的比值,假设用户线程工作的时间是99,垃圾回收的时间是1,那么吞吐量就是99%。

停顿时间越短越适合于用户交互要求比较高的应用,而吞吐量越高就越适合对于计算要求比较高的应用。

Parallel Scavenge收集器可以通过设置-XX:MaxGCPasuseMillis参数调整垃圾回收的最大时间或设置-XX:GCTimeRadio参数调整吞吐量的大小来控制吞吐量。另外还可以通过设置-XX:UseAdaptiveSizePolicy为true让收集器自动的去调整堆内存种各个区域的大小。

🏐 Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,它也是一个单线程收集器,使用标记-整理算法应用于老年代的垃圾收集。Serial Old收集器的主要用处是和Serial、Parallel Scavenge收集器配合已使用以及作为CMS收集器的一个备选方案。

🏈 Parallel Old收集器

Parallel Old收集器是Parallel Scavenge的老年代版本,采用多线程和标记-整理算法进行老年代的垃圾收集。在JDK1.6之前,年轻代适用了Parallel Scavenge收集器,老年代就只能选择Serial Old收集器,处于半控制吞吐量的状态。在Parallel Old收集器出现后,Parallel Scavenge就可以和Parallel Old收集器配合使用,完全控制吞吐量。

🏉 CMS收集器

CMS收集器全称 Concurrent Mark Sweep收集器,从名称上可以看出,CMS收集器采用的是标记-清除算法。

CMS收集器主要分为四个步骤:

1️⃣ 初始标记

2️⃣ 并发标记

3️⃣ 重新标记

4️⃣ 并发清除

其中,初始标记和重新标记需要Stop The World。初始标记是查找GC Roots可以关联到的对象,速度很快。

并发标记就是一次GC Roots Tracing的过程

重新标记的主要目的是由于并发标记的时候用户线程和垃圾收集线程同时工作,可能导致引用发生变换,因此需要暂停用户线程重新进行标记。这一步的时间会比初始标记的时间长,但是远比并发标记的时间短。

CMS收集器有三个明显的缺点:

1️⃣ 对CPU资源敏感。虽然CMS收集器并发阶段不会暂停用户线程,但是由于是并发的,势必会和用户线程争抢CPU资源,默认的,CMS收集器的回收线程数是(CPU数 + 3)/ 4,也就是当CPU在4个以上时,CMS要占据不低于25%的CPU资源,如果CPU数少于4个时,就要分出一半的CPU资源用来进行垃圾回收,导致应用在某段时间内速度降低50%。

2️⃣ 无法处理浮动的垃圾。在并发清理阶段,回收线程和用户线程并发执行,这样就会产生新的垃圾,由于新出现的垃圾在标记过程之后,这一部分垃圾CMS收集器无法处理,称为浮动垃圾。这样CMS收集器就不可以等到内存空间用完了才进行回收,必须先预留一部分内存用来存放新产生的垃圾。JDK1.6以后,CMS默认的当老年代使用了92%以后就要进行垃圾回收。但是如果这8%的空间仍然不够存放新的垃圾呢?那么就会出现一次Concurrent Mode Failure异常,这样就会启动预备方案:临时切换到Serial Old收集器进行老年代的垃圾回收,这样垃圾回收䣌停顿时间就比较长。

3️⃣ 最后一个问题就是,由于CMS收集器采用标记-清除算法,导致产生了大量的空间碎片,内存利用率不高。

🎳 G1收集器

G1收集器是最前沿的研究成果,从整体看,G1收集器是基于标记-整理算法,从局部看是基于复制算法。和其它垃圾收集器相比,G1收集器具有以下特点:并行与并发、分代收集、空间整合、可预测的停顿。

G1收集器将整个堆内存分割为一个个大小相等的独立的Region,在垃圾收集的时候不会像之前的垃圾收集器一样扫描整个堆内存,而是在后台维护一个优先级列表,根据设定的垃圾回收时间来决定具体回收哪些内存,以达到最大的价值。

G1收集器分为以下几个步骤:

1️⃣ 初始标记

2️⃣ 并发标记

3️⃣ 最终标记

4️⃣ 筛选回收

初始标记需要暂停用户线程,但是耗时很短,仅仅是找出GC Roots能够关联的对象,并且修改TAMS(Next Top at Mark Start)的值,以便下一阶段用户线程并发执行时,可以正确使用Region中的对象。

并发标记主要是进行可达性分析,找出需要回收的对象。这一阶段时间较长,但是可以和用户线程并发执行。

最终标记是修正在并发标记中用户线程并发运行导致的对象引用关系的变化,G1收集器会把这一部分变化记录到Remembered Set Logs中,最后把Remembered Set Logs合并到Remembered Set。

筛选回收阶段则是根据规定的垃圾回收时间,按照算法对Region的回收价值进行排序,选择需要回收的内存。



 评论