Java语言经过几十年的发展,其内存分配策略与内存回收策略已经发展的相当成熟,一切看起来都进入到了“自动化”的时代了。但是在通往高级开发人员的道路上,在遇到各种内存溢出、内存泄漏的问题时,当垃圾回收成为高并发的瓶颈时,如果不了解内存分配的策略、内存回收的策略,那么肯定解决不了这些问题,注定成为不了一名合格的高级开发人员。
在理解垃圾回收前,先在上帝视角上,不妨思考以下3个问题:
1.哪些内存需要回收?
2.什么时候回收?
3.怎么回收?
在《深入理解JVM内存模型(运行时数据区域)》一文说过,堆内存是Java虚拟机管理的最大的一块内存,Java中的对象也是分配存储在这一块区域中。方法区存放着各种常量,类型信息。一般而言,内存回收也是针对这一块区域。而程序计数器、虚拟机栈、本地方法栈都是与线程同生共死,线程结束了,内存自然跟着释放了。因此我们只考虑堆内存和方法区的内存回收。
在进行内存回收之前,虚拟机首先要弄清楚的是哪些内存符合回收标准?
引用计数算法
在对象中添加一个引用计数器,每有一个地方引用,计数器+1,当引用时效时,计数器-1.任何时刻当计数器的值为0时,说明没有任何引用指向这个对象,则这个对象就是“垃圾对象”了,可以被回收了。
客观来说,引用计数法简单高效,大多数情况下,这是一个内存回收很不错的选择。实际上也有一些语言是采用引用计数法来进行内存管理的,比如微软的COM技术、Python语言、Squirrel(常用于游戏脚本领域)等。但是在Java领域里,没有任何一款Java虚拟机采用引用计数法,因为它看似简单,但是在Java虚拟机要为很多额外的场景做大量的工作,比如对象之间的循环引用。
可达性分析算法
这个算法的基本思路就是通过一系列的“GC Roots” 的根对象开始,向下遍历整个引用关系,遍历过程走过的路径称为“引用链”。如果某个对象到GC Roots之间没有任何引用链相连(从图论的角度来说,从GC Roots到这个对象不可达),则证明这个对象就是“垃圾对象”了,可以被回收了。
在Java语言里,可以作为GC Roots的对象有以下几种:
1.在虚拟机栈(栈桢中的局部变量表)中引用的对象,比如线程调用的方法的入参、局部变量等;
2.在本地方法栈中引用的对象;
3.在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用;
4.所有被同步锁(synchronized)持有的对象;
5.Java虚拟机中常驻的对象,比如数据类型所对应的Class对象,一些常驻的异常对象等;
6.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
再谈引用
不论是引用计数算法还是可达性分析算法,都离不开对象的引用。在JDK1.2版本之前,Java对引用的定义如下:如果reference类型存储的数据代表着另一块内存的存储地址,就代表着该reference数据代表着某块内存、某个对象的引用。这种定义正常情况下是正确的,但是它只能表述“被引用”和“未被引用”这两种状态,具备一定的狭隘性。针对一些“食之无味,弃之可惜”的对象,就显得有些无能为力。比如我们希望描述这样一类的对象:当内存充足时,能够保留在内存中,当内存紧张时,这些对象可以被抛弃。
实际上,这种“食之无味,弃之可惜”的对象在很多系统/框架的缓存功能都符合这种场景。
Java语言的开发团队很显然也注意到这一点,因此在JDK1.2版本对“引用”的概念进行了扩充。将引用分为以下四种:
1.强引用
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=newObject()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
2.软引用
软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前(Full GC之后仍然内存不够),会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
3.弱引用
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现弱引用。
4.虚引用
虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
关于Java中这四种引用的详细原理,以后会有文章专门介绍。
不可达的对象非死不可?
经过可达性分析算法之后,如果一个对象与GC Roots间不可达,那么它非死不可吗?非也!
真正宣告一个对象是否死亡,至少需要经过两次标记。
如果对象不可达,此时将会被第一次标记;随后会进行一次条件筛选,筛选的条件是此对象是否有必要执行finalize()方法。
判断对象没有必要执行finalize()有两种情况:1.对象没有重写finalize()方法;2.对象重写了finalize()方法,但是已经被调用过了。(一个对象的finalize()方法最多只能被调用一次!)
如果对象有必要执行finalize()方法,那么该对象会被放置在F-Queue队列中,虚拟机稍后会自动创建一个低优先级的Finalizer线程去执行这个队列中对象的finalize()方法。
finalize()方法是对象最后一次逃脱被回收的机会。只要在finalize()方法中,重新与GC Roots相连,就可以避免被回收。Finalizer线程只负责触发对象的finalize()方法的执行,但并不保证一定会等待它执行结束(如果finlize()方法执行缓慢或者死循环等极端情况,那么当前线程将无限期阻塞,这将导致整个内存回收子系统崩溃,从而导致虚拟机运行崩溃!)
虚拟机在稍后会对F-Queue队列进行第二次小规模的标记,如果对象在finalize()方法中成功地“拯救”了自己,那它就可以避免被回收。否则,这个对象将等待着垃圾收集器的回收!
方法区的回收
经典的垃圾收集器都是基于分代理论的,因此对于堆内存的回收效率很高,比如新生代通常一次可以回收70%~90%的空间。
但是对于方法区的回收,由于其回收条件非常严苛,因此回收效果也很不理想。实际上,《Java虚拟机规范》并没有要求虚拟机实现方法区的垃圾收集,并且现实情况下,没有任何一款虚拟机、任何一个垃圾收集器实现了方法区完整的垃圾收集。(在JDK11版本中的ZGC甚至就不支持类卸载)。
方法区的垃圾收集主要有两部分:1.废弃的常量;2.不在使用的类型。
回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判断一个常量是否“废弃”相对简单,但是要判断一个类型是否废弃,则条件苛刻许多,它必须同时满足以下3个条件:
1.该类的所有实例都已经被回收,也就是说在Java堆中不存在该类的任一实例及子类实例。
2.加载该类的类加载器已经被回收,这个条件十分苛刻,除非通过精心设计的可替换的类加载器场景,比如OSGi(模块化下的类加载器)、JSP等。
3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问到该类的方法。
同时满足上述3个条件的类型允许被虚拟机回收。注意,这里说的是“允许”回收,具体是否要回收,由具体参数控制(HotSpot虚拟机提供了-Xnoclassgc参数进行控制)。
虽然说类型的卸载很苛刻,但是有某些场景下又是很有必要的,在大量使用反射、动态代理、CGlib等字节码框架、动态生成JSP、OSGi等频繁自定义类加载器场景中,通常都需要具备有类型卸载的能力,以免对方法区造成太大的内存压力!
通过以上分析,我们知道了哪些对象是可以被回收的,当对象被判定为“垃圾”对象之后,等待它的就是被垃圾收集器回收!垃圾收集器的运作机制是怎样的,它遵循什么样的设计思想?请听下回解析。
感谢阅读,希望对你有所帮助。
特别说明:本文没有任何商业目的,仅供交流。