JVM(五)-垃圾收集器入门
概述:
大家都知道java相较于c、c++而言最大的优点就是JVM会帮助程序员去回收垃圾,实现对内存的自动化管理。那为什么程序员还需要去了解垃圾回收和内存分配?答案很简单,当需要排查各种内存溢内存泄漏等问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。前面介绍了java内存运行时区域,其中的线程私有区,包括程序计数器、虚拟机栈、本地方法栈,它们都是随着线程而销毁,所以这几个区域就不需要过多考虑如何回收的问题。而java堆和方法区这两个区域则有着很显著的不确定性,特别是java堆,因为java程序在运行过程中,会无时无刻产生对象,如果不进行垃圾回收,内存很快就会白占满,导致程序崩溃。所以垃圾收集器主要关注的就是java栈和方法区这俩个个区域的内存该如何管理。
如何判断对象是否死亡?
引用计数算法:
引用计数算法是这样的,在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值加一,当引用失效时,计数器值减一。当计数器为零时,则代表该对象没有任何引用,即达到被回收的条件。客观的说,引用计数算法原理简单、判定效率也很高,在大多数情况下都是一个不错的算法。但是把它作为java判断对象的算法,还是不太合适,其中最大的阻碍就在于,如果存在对象的循环引用,用引用计数算法就无法进行回收,因为互相引用的对象,它们的计数永远都是1,无法到达被回收的条件。
可达性分析算法:
既然引用计数算法无法全面的判断java对象是否存活,那java虚拟机是通过什么算法来实现的呢。其实当前主流的程序语言java、c#等都是通过可达性分析算法来判定对象是否存活的,也被称为根可达算法。它的基本思路就是通过一系列称为“GC Roots”的根对象作为其实节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到达“GC Roots”间没有任何的引用链,则证明此对象是不可能再被引用的。如下图所示,对象Object5、Object6、Object7虽然互相关联、但是它们到“GC Roots”是不可达的,因此它们将被判定为可回收的对象。
在java体系里面,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池里的引用。
- 在本地方法栈中JNI(即通常说的Native方法)引用的对象。
- java虚拟机内部的引用,如基本数据类型对于的class对象,一些常驻的异常对象等,还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
再谈引用:
无论是使用引用计数器还是通过可达性分析算法判断对象是否可被回收,判定对象是否存活都和引用离不开关系。在JDK1.2之前,java的引用是很传统的定义:如果reference类型的数据中存储的值代表另外一块内存的起始地址,就称该reference数据代表某个对象的引用。这种定义看起来是合理的,但是现在看来过于狭隘了,一个对象只有被引用或者未被引用两种状态,对于那种“食之无味,弃之可惜”的对象就显得无能为力。在JDK1.2之后,java对引用进行了扩充,将引用分为强引用(strongly reference)、软引用(soft reference)、弱引用(weak reference)、虚引用(phantom reference)4种,这4种引用强度依次逐渐减弱,以此来帮助虚拟机更清晰的辨别哪些对象该回收,哪些对象该延迟回收。
- 强引用是最传统的“引用”的定义,是指在代码中通过new关键字创建的引用。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收被引用的对象。
- 软引用是用来描述,那些还有用,但是非必须的对象。只被软引用关联的对象,在系统要发生内存溢出前,会把这些对象列进回收范围之中进行回收。JDK1.2之后提供了SoftReference类来实现软引用。
- 弱引用也是用来描述那些被必须的对象,但是它的强度比软引用还要弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在JDK1.2之后提供WeakReference类来实现弱引用。
- 虚引用也被称为“幽灵引用”,它是最弱的一种引用关系。一个对象是否有虚拟引用的存在,完全不会对其生存时间构成影响,也无法通过虚拟引用来取得一个对象实例。它唯一得目的就是为了能在这个对象被垃圾回收时能够收到系统得通知。在JDK1.2之后提供了PhantomReference类来实现虚引用。
方法区的垃圾收集:
方法区主要存放的是Class类型对象以及常量池中的常量对象等,这些对象都有一个共同的特点,就是会被经常使用且不容易改变。所以有些人直接认为方法区是没有垃圾回收行为的。跟堆相比,方法区的垃圾收集实在是性价比极低,本身方法区所占用的内存就远低于堆,比例也远远低于堆。所以在JDK11的ZGC干脆取消了Class类型的卸载。但是当下主流还是java8,如果在程序中大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGI这类频繁自定义加载器的场景中,通常需要java虚拟机具备类型卸载的能力,以保证方法区造成过大的内存压力。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。判断一个常量是否废弃比较简单,比如一个字符串“java”在常量池中,当前系统没有任何一个字符串对象的值是“java”,就说明该对象达到了被回收的条件。但是判断一个类型是否属于“不再被使用的类”的条件就比较苛刻了,需要同事满足以下三个条件:
- 该类的所有实例都已被回收。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则很难达成。
- 该类的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法思想:
java虚拟机的垃圾算法实现有很多种,在这里我们暂时不讨论算法的具体实现,只介绍分代收集理论和几种算法思想,为后续学习具体的GC算法打下基础。
分代收集理论:
分代收集名为理论,实质是一套大多数程序运行时机情况的经验发则,它建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说;熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说奠定了多款常用的垃圾收集器的一致设计原则:收集器将java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的内存区域之中,然后根据不同的区域采取不同的收集方式。例如在HotSpot中,设计者把java堆划分为新生代和老年代两个区域,新生代区域的回收频率远高于老年代区域的回收频率。
标记-清除算法:
如它的名字一样,算法分为标记和清楚两个阶段,首先标记需要回收的对象,然后再清除被标记的对象,也可以反过来,标记存活的对象,然后回收未被标记的对象。它是垃圾收集算法中最基础的算法,后续的收集算法都是以其为基础,对其缺点进行改进而得到的。它的主要缺点有两个:
- 第一个是执行效率不稳定,如果java对象中包含大量的对象需要回收,这时需要大量的标记和清除动作,数量越大,效率就越低。
- 第二个是内存空间的碎片化问题,标记清除后会产生大量的不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程中需要分配较大对象时无法找到足够的连续的内存空间,从而提前触发另外以此垃圾收集动作。
标记-复制算法:
标记-复制算法被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,复制算法的设计者将内存按容量划分为大小相等的两块区域,每次只使用其中一块区域。当使用的一块的内存用完的时候,就将存活的对象复制到另外一块区域,将这个区域的内间一次性清除。如果存活的对象较少的情况下,该算法效率很高,因为仅需要复制少量的对象即可,但是存在大量的内存存活,就会产生大量内存之间的复制开销,并且这种复制算法的代价也很明显,我们真正使用的内存空间只有一半,造成了比较大的内存空间浪费。但是这种算法用来回收新生代却十分的合适,因为新生代区域的对象,恰恰符合对象数量众多,且需要回收的对象的占的比例也及其的高,只需要复制少部分不需要回收的对象即可。
标记-整理算法:
上面我们提到了,标记-复制算法十分契合新生代区域的回收,很明显,标记-复制算法的不适合老年代,那老年代区域,我们采用什么样的算法呢。首先我们想到的是标志-清除算法,但是内存碎片化问题,无法解决。所以就诞生了标记-整理算法,它和标记-清除算法十分相似,唯一的区别是在清除的时候,标记清除算法需要移动,而标记整理算法需要通过移动将存活对象的内存和未使用的内存分开,且让他们都连续。但是这样必然会导致效率上的问题,还有一个致命的问题是,在移动过程中,必须要保障应用程序处于暂停状态。所以垃圾算法的具体实现,通常将标记-清除算法和标记-整理算法结合在一起使用,大部分情况下都通过标记-清除算法去回收,当内存碎片达到一定程度,比如影响到对象的内存分配时,再采用标记-整理算法回收一次,以获得规整的内存空间。