理解JVM运行时数据区(七)垃圾回收

前言

在前面的文章里,我们对JVM运行时数据区做了比较深入的了解。那么接下来,就来说说垃圾回收,这个不管是在Java面试中还是在Android面试中,都会被经常问到。

什么是垃圾回收

垃圾回收诞生于1960年的Lisp语言,垃圾回收在很多现代语言中都有。

我们知道java是自动内存管理的,开发人员不需要手动参与内存的分配与回收,那么当对象使用完成后,就需要虚拟机来释放掉这些无用对象占着的内存,而这个释放内存就是垃圾回收。而要了解垃圾回收,我们就要从下面三个问题出发:

  • 什么是垃圾?(哪些内存需要回收?)
  • 什么时候回收?
  • 如何回收垃圾?

什么是垃圾

垃圾简单来说就是运行时没有被引用的对象,或者说没有被指针指向的对象

Object obj = new Object();
obj = null; //断开引用
复制代码

上面的代码当我们new一个对象的时候,这个对象会被放在java堆里面,而如果我们执行obj = null;后,只是把引用断开,而创建的对象仍在堆区,不会被移除。这时候这个对象就可以称为垃圾

当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为垃圾对象。要判断对象是否是垃圾的话,有以下两种方式:

  • 可达性分析算法
  • 引用计数算法(java里面不使用这种方式,可以了解下)

什么时候回收

在java中,垃圾回收的是不确定的,由JVM决定。一般情况下是当要创建新对象时,而堆区里面没有足够的连续空间来存放这个对象,那么就会触发垃圾回收,来回收掉那些不再使用的对象。

如何回收垃圾

当知道了哪些对象是垃圾后,JVM的垃圾收集器就可以根据一些算法来清理这些垃圾,回收内存空间。目前常用的垃圾回收算法有三种:

  • 标记 - 清除算法
  • 标记 - 复制算法或称为复制算法
  • 标记 - 整理算法标记 - 压缩算法

为什么需要垃圾回收

  • 对于高级语言来说,一个基本的认知就是如果不进行垃圾回收,内存迟早会被消耗完。
  • 如果不及时对内存中的垃圾进行清理,这些垃圾对象所占用的内存空间会一直保留到应用程序结束,被保留的空间无法被利用起来。最后甚至可能会导致内存溢出。
  • 除了释放没用的的对象,垃圾回收也可以清除内存中的内存碎片,碎片整理后,才能有足够大的连续空间来存储一些大的对象。

标记阶段

上面我们说到了垃圾回收的要先知道哪些对象是垃圾对象。在这个阶段,目前有两种方式,也就是上面提到的引用计数算法可达性分析算法。其中在java中,没有使用引用计数算法,大家可以简单了解就行。

引用计数算法

引用计数算法(Reference Counting):对每个对象保存一个整形的引用计数器,用于记录对象被引用的次数。每当有一个地方引用它时,计数器值就加一。当引用失效时,计数器值就减一。当该对象的计数器值为0时,就表示该对象不再被使用,可进行回收。

引用计数算法原理简单,判定效率也很高,但是在Java里面没有选用这种算法,主要因为看似简单的算法有很多例外情况需要考虑,必须配合大量额外的处理才能保证正确的工作。比如两个无用对象的循环引用

  • 优点:
    • 原理简单、实现也简单
    • 判定效率高,回收没有延迟性。
  • 缺点:
    • 每个对象都要有一个单独存储计数器,增大内存空间开销
    • 每次赋值需要加减操作,增大了时间开销
    • 无法处理循环引用 (最致命的问题,导致Java的垃圾回收器中没有使用这类算法)

可达性分析算法

可达性分析算法也可称为根搜索算法,这个算法的基本思路就是通过一系列称为 "GC Roots" 的跟对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,所搜过程走过的路径称为 引用链 ,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达是,则证明此对象是不可能在被是使用的。

相对于引用计数算法,可达性分析算法不仅同样具备实现简单和执行效率高等特点,更重要的是该算法可以有效的解决在引用计数算法中循环引用的问题,防止内存泄漏。

GC Roots

在java体系里面,固定可作为GC Roots的包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法栈中使用到的参数、局部变量、临时变量的。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象。
  • 在本地方法栈中的JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了上面列举的这些固定的GC Roots之外,还有些是会根据jvm使用的垃圾收集器以及当前回收的内存区域不同而“临时性”加入。比如,如果只针对新生代进行垃圾回收时,那么此时这边有的对象是会被老年代里的对象关联,则此时就需要将这些关联区域的对象也一并加入到GC Roots里面去,以此来保证可达性分析的正确性。

清除阶段

当成功的筛选出内存中存活的对象和死亡的对象后,垃圾收集器接下来就需要进行垃圾回收了,以此来释放掉死亡对象所占用的内存空间。

目前在java里面比较常见的垃圾收集算法有三种,分别是标记 - 清除算法标记 - 复制算法标记 - 整理算法。可以看到这边都有带标记二字,这个标记起始就是上面说到的的标记阶段做的事。接下来我们分别来说说这三种收集算法里面的清除阶段里用的算法。

标记-清除

标记 - 清除算法是最早出现的,也是最基础的垃圾收集算法。正如它的名字一样,算法分为两个阶段:标记清除。它可以有两种实现:

  • 1、标记出所有需要回收的对象,然后统一回收掉所有被标记的对象。
  • 2、标记存活对象,统一回收所有没有被标记的的对象。

前面我们已经说了标记,现在我们就来说说它的清除阶段。当标记完成后,垃圾收集器需要从头到尾对堆内存进行遍历,如果发现某个对象是标记为垃圾(这边以上面第一条情况来描述)。则将其清除掉,回收内存。

我们可以来通过下面的图片来了解下标记 - 清除算法的执行过程:

image.png

通过标记知道了哪些对象是垃圾对象后,就使用清除算法清除掉那些灰色区域里面的垃圾对象,留下非垃圾对象以及空闲区域。

image.png

但是,通过上面的图片我们可以看到,清除完成之后,堆内存的碎片很多。明明堆内存有很多空闲区域,但是此时如果要创建一个稍微大点的数组,就放不下了。因为没有足够的连续内存来存放该数组对象了。并且,如果堆内存中有大量需要被回收的对象的话,那么他就需要大量执行清除的动作,因此如果可回收的对象越多,清除算法的效率就越低。

缺点:

  • 效率不稳定,垃圾对象越多,效率就越低
  • 内存空间碎片化

之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以它为基础,对其缺点进行改进而得到。

标记-复制

标记 - 复制算法通常也被简称为复制算法,是为了解决标记 - 清除算法效率不稳定的缺点而提出来的。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,或者放不下一个连续内存的数组时,就将还存活者的对象复制到另外一块上,然后再把已经使用过的内存空间一次清理掉。

image.png

image.png

如果内存中多数对象都是存活的,复制算法在复制时,就效率很低。但是如果对于多数对象都是可回收的情况,算法需要复制的就只有少数存活对象。但是由于它只有使用一半的内存空间,因此在效率上比标记 - 清除算法要好一点。而且每次会将存活对象复制到另一半区域,并且按顺序分配,因此不用担心内存碎片化的问题。

不过这个复制算法的缺点也很明显,就是将可用的内存缩小为了原来的一半,浪费的空间有点多。

我们在前面的文章里面有提到,新生代里面的对象基本上都是生命期很短的对象,一般情况下的回收能回收70% ~ 90%的内存空间,因此在新生代使用了改进版的复制算法,也就是将新生代划分为两块区域,两块区域的比例为8:2。也就是eden区域和survivor区域。具体的可翻看前面的文章理解JVM运行时数据区(六)堆

标记-整理

标记 - 整理算法也是对标记 - 清除算法的缺点进行优化。也可以说结合了标记 - 清除复制算法两个算法的优点,避免了标记 - 清除算法的碎片问题,同时也避免了复制算法的的空间问题。

在标记过程与标记 - 清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一段移动,然后清除掉边界意外的内存。最后的效果就像在标记 - 清除算法结束后,对剩余存活对象进行整理,使其按顺序排列。

image.png

image.png

它是以标记 - 清除算法为基础,进行了对象的移动,因此成本更高,但是解决了内存碎片的问题。

由于老年代区域较大,存活对象较多、GC不频繁。不适合复制算法,且由于标记 - 清除算法会产生内存碎片。因此虽然标记 - 整理算法效率不高,但是他确适合于老年代的垃圾收集。

但是由于移动对象会导致STW的时间边长,因此来有些垃圾收集器上针对老年代会使用标记 - 清除标记 - 整理。也就是平时多数时间使用标记 - 清除算法,暂时容忍内存碎片的存在,直到内存碎片化已经影响到对象的分配时,再采用标记 - 整理算法收集一次。

三种算法比较

指标 标记-清除(Mark-Sweep) 复制算法(Copying) 标记-整理(Mark-Compact)
速度 中等 最快 最慢
空间开销 少(但会堆积碎片) 通常需要活对象的2倍大小(不堆积碎片) 少(不堆积碎片)
移动对象

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。(空间换效率)

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些, 但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。

注意这边的移动对象,移动对象的话都是需要暂停用户线程,也就是STW。也因此不移动对象的话,暂停时间会很比较短,甚至可以不需要暂停。

安全点与安全区域

安全点

程序执行是并非在任何地方都能停顿下来开始GC,只有在特定的位置才能停顿(也就是中断用户线程,把当前用户线程挂起)下来开始GC这些位置称为安全点(Safe Ponit)安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷,降低GC的性能。

安全点位置的选取基本上是以:是否具有让程序长时间执行的特征。

因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

安全区域

使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却不一定。安全点机制保证了程序执行时在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”呢?比如说用户线程处于SleepBlocked状态。这时候线程无法响应虚拟机的中断请求,不能在走到安全的地方去中断挂起自己。对于这种情况,于是引入了 安全区域(Safe Region) 来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

  • 当线程运行到安全区域的代码时,首先标识已经进入了安全区域,如果这段时间内发生GC,JVM会忽略标识为安全区域状态的线程。
  • 当线程即将离开安全区域时,会检查JVM是否已经完成那些需要暂停用户线程的阶段,如果完成了,则继续运行,否则线程必须等待直到收到可以离开安全区域的信号为止。

四种引用

无论是通过引用计数算法判断对象的引用数量,还是用过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和 "引用"离不开关系。在jdk1.2之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称reference数据是代表某块内存、某个对象的引用。

在JDK1.2之后,Java对引用的的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4中引用强度依次减弱。

强引用

强引用是最传统的“引用”定义,是指在程序代码中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  • 当使用new操作符创建一个新对象,并将其复制给一个变量的时候,这个变量就成为指向该对象的一个强引用。
  • 强引用可以直接访问目标对象
  • 强引用所指向的对象在任何时候都不会被垃圾收集器回收,虚拟机宁愿抛出OOM,也不会回收强引用所指向的对象
  • 强引用是造成Java内存泄漏的主要原因
Object obj = new Object(); // 强引用 
obj = null ; // 此时‘obj’引用被设为null了,前面创建的'Object'对象就可以被回收了
复制代码

软引用

软引用是用来描述一些还有用,但非必须的对象。只有被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后提供了SoftReference类来实现软引用。

//方式一
Object obj = new Object();
SoftReference<Object> softReference = new SoftReference<>(obj);
obj = null; //取消强引用

//方式二
SoftReference<Test> softReference1 = new SoftReference<>(new Test());
复制代码
  • 软引用通常用来实现内存敏感的缓存。比如:高速缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
  • 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。通过这个队列来跟踪软引用对象的回收情况。
//方式一
Object obj = new Object();
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
SoftReference<Object> softReference = new SoftReference<>(obj,referenceQueue);
obj = null; //取消强引用

//方式二
SoftReference<Test> softReference1 = new SoftReference<>(new Test(),referenceQueue);
复制代码

弱引用

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生未知。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。

  • 由于垃圾回收器的线程通常优先级很低,因此,并此不一定很快地发现持有引用弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间
  • 弱引用和软引用一样,在构造弱引用时,可以指定一个引用队列,当弱引用对象被回收时,就会加入到指定的引用队列里面,通过这个引用队列可以跟踪对象的回收情况。
  • 软引用和弱引用都非常合适来保存那些可有可无的缓存。
  • 相比于软引用,在GC回收时,弱引用会更容易、更快被GC回收。
Object o1 = new Object();
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
WeakReference<Object> weakReference = new WeakReference<>(o1, referenceQueue);
System.out.println(o1);
o1 = null; //取消强引用
System.out.println(weakReference.get());
System.out.println(referenceQueue.poll());

System.out.println("===============================");

System.gc();
Thread.sleep(500);  // 保证一定被GC
System.out.println(o1);
System.out.println(weakReference.get());
System.out.println(referenceQueue.poll());   // GC前会将引用放入队列
复制代码

虚引用

虚引用也称为“幽灵引用”或“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收的时候收到一个通知。在JKD1.2之后提供了PhantomReference类来实现虚引用。

  • 虚引用主要用来跟踪对象被垃圾回收的活动
  • 它不能单独使用,也无法通过虚引用来获取被引用的对象。因此调用get()方法时,取得的对象总是null
  • 虚引用必须和引用队列一起使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象之前将这个虚引用加入到引用队列,以通知应用程序对象的回收情况。
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
PhantomReference<MyClass> phantomReference = new PhantomReference<>(new Object(),referenceQueue);
复制代码

方法区的垃圾回收

虽然《Java虚拟机规范》里面没有规定方法区要进行垃圾回收,不过由于方法区存在一些不再使用的垃圾对象,如果过多的话也会造成OOM,因此方法区也会进行垃圾回收。 方法区的垃圾收集主要回收两部分内容:

  • 废弃的常量
  • 不再使用的类型

要判断一个常量是否会被回收比较简单,也就是不再使用了。而判定一个类型是否不在使用,那么判断条件就比较苛刻了。需要同时满足下面三个条件:(这边引用《深入理解Java虚拟机》里面的描述)

  • 该类的所有实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是 和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了- Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX: +TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在 Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版 [1] 的虚拟机支持。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载 器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压 力。

结束语

垃圾回收就说到这边,如果有地方描述的有问题,欢迎在评论区留言,大家一起探讨。感谢!!

系列文章

猜你喜欢

转载自juejin.im/post/7088702743057023013