通过new我们能在堆区里给指定对象分配内存空间,但Java里没有像C++那样提供类似于delete或free的释放内存空间的方法,虚拟机会在特定的时间点启动垃圾回收机制(GC机制)来回收已经不被使用的堆内存。
这就会给一些程序员(尤其是年限在三年以内的初级程序员)造成一种误解,他们往往会认为,既然虚拟机的垃圾回收机制能自动回收,那么对此程序员不需要(也没必要)做什么事。
恰恰相反,我们得深入了解堆的结构和垃圾回收流程,并在此基础上提升我们代码的内存使用效率,否则的话,代码运行速度慢还是小事,因内存用尽而导致程序崩溃也不是没有可能。
分代管理与垃圾回收流程
这里我们可以通过深入了解下虚拟机里堆内存的结构,从而能更清晰地了解垃圾回收的流程。虚拟机的堆内存其实可以再划分为“年轻代”、“年老代”和“持久代“三个区域,如下图所示。
其中,持久代(也叫持久区)中主要存放的是Java类信息或在代码里通过import引入的类信息,这块空间里的内存对象在代码运行时一般会持久地存在(也是被称为持久代的原因),所以我们平时讨论的垃圾回收流程一般不会涉及到这块空间。
在年轻区里,一般会划分为伊甸区和两个Survivor区(本书翻译成缓冲区,或许在其它资料上有其它的译法),而这里我们把Tenured翻译成年老代。
接下来我们来看下垃圾回收的一般流程。
1 我们new出来的对象一般是先到伊甸区(Eden)里申请空间,如果伊甸区满了(当前从伊甸区里无法申请到空间),那么会把伊甸区里还存活的对象复制到其中的一个Survivor区里。这里其实已经有个隐含的回收流程,当我们把伊甸区存活的对象复制到Survivor区时,已经把其中无用的对象回收了。
2 接下来当伊甸区和其中的一个Survivor区都满了时,那么会把伊甸区和其中一个Survivor里的存活对象再复制到另外个Survivor区里,这里同样隐含着一次回收流程。
3 如果年轻代的空间都满了(即无法从伊甸区和两个Survivor区里申请到对象),那么虚拟机会把年轻代里还存活的对象复制到年老代里。
4 当年老代再满时(不会再复制到持久代了),会启动Full GC,对年轻代,年老代和持久代进行全面地回收了,这个就需要耗费较长的时间了。
在上述的回收流程中,其实包含着两类回收机制。
第一类是轻量级回收(Minor GC)。在年轻代里的回收流程都是属于这种,比如我们new出来的一个对象在Eden区申请空间失败,就会触发这类GC。
在这类回收流程里,一般会用到一种效率相对较高的标记复制算法(Mark Copy),这种算法不涉及对无用对象的删除,只是把标记存活的对象从一个内存区拷贝到另一个内存区。
第二类是刚才已经提到的重量级的Full GC流程,如下的四种情况会触发这种GC。
1 年老代(Tenured)被写满。
2 持久代被写满。
3 程序员显式地调用了System.gc()方法。
4我们可以通过java命令分配堆空间的运行策略,比如可以设置年轻代和年老代的比例,如果虚拟机监控到上次GC后,这种运行策略发生变化,也会触发Full GC。
这里讲一个可能会导致误解的知识点,程序员还是可以通过System.gc()来建议虚拟机启动垃圾回收,但调用这个方法后,虚拟机一般并不会直接启动,而是会过会找个合适的时间点。这和我们之间给出的“程序员无法通过代码回收内存”说辞并不矛盾。
不重视内存性能可能会导致的后果
一般来讲,轻量级回收的代价大家可以忽略不计,但大家一定得重视Full GC,这里来举个例子来说明这类GC对系统的影响,从中大家可以看下不重视内存性能可能会导致的后果。
在某项目里有个批处理程序,每天下午2点跑,要做的业务是,从每天都会更新的xml文件里读取数据,并把它们插入到数据库里。常规情况下是2点半结束,但在某天,它在下午5点时还在跑,从日志上看,卡住了,不继续运行,而且也没报异常(这个是最令人担忧的,因为我们无法获知异常的原因)。
结果从内存监控上一看,这个程序申请了1G内存,但由于代码没写好,那天正好引发一段平时走不到的流程,从而导致内存使用量持续上升,最后停留在1G的水平。
这时由于年轻代和年老代都满了,就触发了Full GC,在执行Full GC时,会导致”Stop the World”情况发生,也就是说虚拟机终止了所有Java程序,专门做Full GC,这就是卡住的原因。
这个例子倒不是让大家尽量避免Full GC,因为如果代码没写好或者内存分配策略不对,Full GC导致的Stop The World现象迟早会发生。这个例子的作用是让大家一定要重视后继我们讲述的内存性能优化内容,否则有很大概率会发生类似的“卡住”的问题,或者即使不“卡“,也会报出OOM内存溢出异常,这也会造成“程序运行终止“这样的严重问题。
判断对象可回收的依据
不论是轻量级回收还是Full GC,我们都无法回避这样一个问题:Java虚拟机如何判断一个对象可以被回收?
标准非常简单,当某个对象上没有强引用时,该对象就可以被回收。不过,在绝大多数的场景里,当某对象上的最后一个强引用被撤去后,该对象不会被立即回收,而是会在下次启动垃圾回收机制时被回收。
在JDK的早期版本里,是用“引用计数法”来判断对象上是否有强引用,具体来讲,当一个对象上有一个强引用时,把该对象的引用计数值加1,反之则减1。
- String a = new String(“123”); //包含123内容的对象上的引用数加1
- a = null; //引用数减1
这里请大家区分“引用”和“值”的差别。比如通过上述代码的第1行,我们会在堆空间里分配一块空间,假设内存首地址是1000,在其中存放了123这个内容,而且通过一个引用a指向这块空间,这时,1000号内存的引用数是1。
在第2行里,我们并不是把1000号内存里的值设置成null(初学者往往会有这样错误的理解),而是把a这个引用指向null。这时,虽然1000号内存的值没变,但没有引用指向它了,它的引用计数值就变0了。 在这种情况下,下次垃圾回收机制启动时,1000号内存就会被回收。
引用计数法的好处是简单,但缺点是无法回收循环引用的对象,比如a引用指向b,b指向c,c再指向a,在这种情况下,哪怕它们游离于主程序之外了(程序不再用到它们了),abc三个引用的计数值都是1,这样它们就始终无法被回收。
正是因为有这样的原因,在后继的JDK版本里,引入了“根搜索算法”(Tracing Collector)。这个算法的效果如下图所示。
在这个算法里,将从一个根节点(GC ROOT)开始,寻找它所对应的引用节点,找到这个节点后,继续寻找该节点的引用节点,以此类推。这样当所有的引用节点都搜索完毕后,剩下的就是没有被引用的节点,也就是可以回收的节点。比如在上图9.4里,从根节点里能找到a,b,c和d这四个节点,而u1和u2这两个节点属于不可达,也就是可以被回收。
具体来讲,可作为GC Root的对象有如下四个。第一,虚拟机栈中引用的对象。第二,方法区中静态属性引用的对象。第三, 方法区中常量引用的对象。第四,本地方法栈中引用的对象。
深入了解finalize方法
finalize()是Object类里的protected类型的方法,子类(所有类都是Object的子类)可以通过覆盖这个方法来实现回收前的资源清理工作。和这个方法相关的流程如下所述。
1 Java虚拟机一旦通过刚才提到的“根搜索算法”判断出某对象处于可回收状态时,会判断该对象是否重写了Object类的finalize方法,如果没,则直接回收。
2如重写过finalize方法,而且未执行过该方法,则把该对象其放入F-Queue队列,另个线程会定时遍历F-Queue队列,并执行该队列中各对象的finalize方法。
3 finalize方法执行完毕后,GC会再次判断该对象是否可被回收,如果可以,则进行回收,如果此时该对象上有强引用,则该对象“复活”,即处于“不可回收状态”。
通过下面的FinalizeDemo.java,我们来演示下通过finalize方法复活对象的做法。
1 public class FinalizeDemo {
2 static FinalizeDemo obj = null;
3 //重写Object里的finalize方法
4 protected void finalize() throws Throwable {
5 System.out.println("In finalize()");
6 obj = this; //给obj加个强引用
7 }
8 public static void main(String[] args) throws InterruptedException {
9 obj = new FinalizeDemo();
10 obj = null; //去掉强引用
11 System.gc(); //垃圾回收
12 //sleep 1秒,以便垃圾回收线程清理obj对象
13 Thread.sleep(1000);
14 if (null != obj) { //在finalize方法复活
15 System.out.println("Still alive.");
16 } else {
17 System.out.println("Not alive.");
18 }
19 }
20 }
在main函数里的第9行里,我们给第2行定义的obj对象分配了一块内存空间,并在第10行去掉obj所指空间的强引用,在第11行,通过System.gc方法启动了垃圾回收机制。
这时,由于obj所指向的对象上没有强引用,所以这块对象可以被回收,在回收前,是会执行其中的finalize方法。
在第4行重写的finalize方法里,我们给obj对象加了一个强引用,这样的话,在finalize方法被执行后,obj对象就不符合被回收的条件了,所以在第14行的if…else判断里,走第15行的流程,输出“still alive.”这句话。
不过,由于垃圾回收和遍历F-Queue队列不是同一个线程,所以一旦重写了这个方法,就有可能导致对象被延迟回收,如果这个方法再被放入错误的代码,就极有可能导致该对象无法回收。
所以在实际的项目里,一般不会重写finalize方法。其实我们已经在第二章讲过这个结论,在这里,通过了解F-Queue队列以及垃圾回收的具体流程,大家可以更清晰地理解这个结论。
更多Java虚拟机文章:
CSDNhttps://mp.csdn.net/mp_blog/creation/editor/121846013
这是我的公众号,其中包含了大量面试文章,同时我自己出了多本Python和Java方面的书籍,会定期在公众号里发书的电子版。请大家关注下我的公众号,谢谢了。