Java虚拟机内存管理(四)—垃圾回收

Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 “高墙”,墙外面的人想进去,墙里面的人却想出来。——《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》周志明

Java 虚拟机作为运行 Java 程序抽象出来的计算机,具有内存管理的能力,像内存分配、垃圾回收等这些相关的内存管理问题,Java 虚拟机都会帮我们解决,所以作为一个 Java 程序员要比 C++ 程序员幸福,但是内存方面一旦出现问题,如果对虚拟机怎样使用内存不了解,就很难排查错误。

这段时间看周志明先生的《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》,下面就对 Java 虚拟机对内存的管理做一个系统的整理,本篇文章是该专题的第四篇。

4、垃圾回收

在前面我们模拟了内存异常,其实 Java 虚拟机的垃圾回收机制为避免内存异常已经做出了最大努力,但还是无法避免上面情况的发生。垃圾收集简称为 “GC”,垃圾回收主要解决下面三个问题:

  • 哪些内存区域需要回收?
  • 什么时候回收?
  • 如何回收?

在前面对内存的划分中,程序计数器、虚拟机栈和本地方法栈都是随线程生和死的,栈中的栈帧随着方法的调用有序的进栈和出栈,每个栈帧上分配的内存在类结构确定时就已知了,所以这些区域内存的分配和回收都是具有确定性的,很容易回收内存。当方法调用结束或者该线程结束,占用的内存就要回收。而 Java堆和方法区中,每个类需要的内存都可能不一样,一个方法中多个分支需要的内存也可能不一样,只有在运行期才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾回收也主要是对这部分的内存进行回收。

理论上来说,如果一个对象已死,那么此时它占用的内存就应该被回收。但是怎么判断一个对象的生死,并不是那么容易的。我们也常说一些人虽然还活着但就像死了一样,而一些人虽然死了但仍然活在我们心中,在程序中生死可不能这么模棱两可,所以必须要有判断对象生死的方法。常见判断算法有下面两种:

1、引用计数算法

给每个对象添加一个引用计数器,当有一个地方引用该对象时,就将引用计数器的值加1,当引用失效时,就将引用计数器的值减1,当计数器的值为0时,就说明不存在对该对象的引用了,这个对象就没什么存活的意义了,也即是可以说这个对象是死的,可被回收。

缺点:这种判断方法虽然看起来简单高效,但是不能解决对象之间相互循环引用的问题。例如 A 对象和 B对象都是同一个类的对象实例,A 中字段 instance 引用对象 B,B 中字段 instance 也引用对象 A。如果垃圾收集器想要回收 A 对象,那么 A 的引用计时器值要为 0,也就是要清除 B 中字段 instance 对 A 的引用,也就是要清除 B 对象,而 B 对象又被 A 中的字段 instance 引用着,也就是要清除 A 对象,想要回收 A 对象,A 的 引用计数器值要为 0 ......这样就形成了循环,A 和 B 都不能被回收。不知道你有没有懵逼,反正垃圾收集器已经懵逼了。

2、可达性分析算法

从一个根节点开始向下搜索对象,搜索所走的路径称为是引用链,当一个对象从根节点开始找不到一条引用链时,就说明这个对象无法使用,或者说是对象已死可被回收。这个根节点叫做 GC Roots,是一个特殊的对象,且绝对不能被其他对象引用,不然也会像上面引用计数算法那样有循环引用的问题,GC Roots 对象包括虚拟机栈(栈帧本地变量表)中引用的对象、方法区中静态属性引用的对象、方法区常量引用的对象、本地方法栈中(Native 方法)引用的对象。

可达性分析算法判定对象是否回收.jpg

上面这两种判断方法都是“引用”有关的,引用计数算法需要计算对象的引用数量,可达性分析算法需要判断对象是否有可达的引用链。引用也是一个很模糊的概念,为了更加清晰的描述 Java 中的对象引用,在 JDK1.2 后,Java 将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference) 4 种,并且除了强引用外都有与之对应的 Java 类,分别是基类 Reference、软引用类 SoftReference、弱引用类 WeakReference、虚引用类 PhantomReference。

强引用很常见,类似 Object obj = new Object() 这种引用就是强引用。

软引用是用来描述一些有用但非必须的对象引用,当内存紧张的时候,会把这些对象列为回收目标,进行二次回收,如果回收之后还是没有足够的内存,那么就会出现内存异常。

弱引用也是用来描述一些非必须的对象引用,但是引用的强度要比软引用弱,被弱引用关联的对象将会在下一次垃圾收集时回收,而不管内存是否充足。

虚引用又称为是幽灵引用或是幻影引用,是最弱的引用关系。一个对象是否有虚引用,完全不会对该对象的生存造成影响,也无法用虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是,在这个对象被垃圾收集器回收时收到一个系统的死亡通知,通俗的说也就是死的明明白白吧。

生存还是死亡?这是个问题。

在可达性分析算法中,即使是不可达的对象,也并非是要立即执行 “死刑”,它们暂时处于 “死缓”。就像 C++ 中,对象死亡要调用析构函数一样,Java 中对象在死亡时也有一个类似的 finalize() 方法,不可达的对象会被第一次标记并进行一次筛选,筛选的条件就是这个 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法被虚拟机调用了而这个对象还没有会回收,finalize() 方法都不会被执行。如果判定需要执行 finaliz() 方法,这个对象就会被放到一个队列中,由低优先级的单独线程(刽子手)执行对象中的 finalize() 方法。如果在 finalize() 方法中,该对象被引用链上的其他对象关联了,那么这个对象就可以被移出这个 “即将回收” 队列,从而死里逃生。这个 finalize() 方法可以说是对象逃脱死亡命运的最后一次机会,如果没有逃脱,这个对象就真正要被垃圾回收器执行死刑了。但是这个机会每个对象只有一次,第一次是可以逃脱的,第二次再次进入这个队列无论如何也逃脱不了被回收的命运。

虽然 Java 虚拟机规范中没有要求对方法区进行垃圾回收,但是一些虚拟机(如 HotSpot 虚拟机)仍然实现了方法区的垃圾回收,在 HotSpot 虚拟机中称方法区为 “永久代”,其实都是一个意思,方法区的垃圾主要是废弃的常量和无用的类。我们知道方法区中有一些常量池,如字符串常量池,如果系统中不存在引用常量池中常量的引用,那么在内存紧张的时候,这些常量就应该被废弃回收,常量池中的其他类(接口)、方法、字段、符号引用也是如此。

判断常量是否应该被废弃的方法比较简单,而判断一个类是无用的类,则需要满足下面三个条件:

  • 该类的所有实例都已经被回收了,也即 Java堆内存中没有该类的对象实例。
  • 加载该类的 ClassLoader 已经被回收了。
  • 该类对应的 java.lang.Class 对象在任何地方都没有被引用,也即无法通过反射访问该类。

但满足了上述这些条件,也不是说这个类就要被非回收不可,我们可以通过设置虚拟机参数进行控制。

至此,哪些内存区域需要回收和什么时候回收就说完了,下面就是如何去回收了。垃圾回收是一个具体的过程,里面涉及到一些收集算法。几种常见的垃圾回收算法思想如下:

1、标记-清除算法

如同它的名字,算法分为 “标记” 和 “清除” 两个过程,首先标记出需要回收的对象,如下图中的灰色区域,然后再将标记出的区域内容清除。标记过程肯定需要遍历,这里面也涉及到广度优先搜索和深度优先搜索,这里就不多说了。

不足之处:一个是效率问题,搜索的效率;另外一个是空间问题,标记清除后会产生内存碎片,不利于给大对象分配内存空间。

标记-清除算法是最基础的收集算法,后续的收集算法都是对它的改进。

标记-清除算法.jpg

2、复制算法

为了解决标记-清除算法的效率问题,复制算法将内存容量划分为两个等量的部分,每次只使用一块,当一块使用完后,就将还存活的对象复制到另一块内存区域,并把刚才使用的那块内存区域一次性清除,这样每次都只需要对一半内存区域进行垃圾回收。

不足之处:这种做法看似简单除暴,但实现简单,运行高效,确实可以解决产生内存碎片的问题,而牺牲了一半可以使用的内存空间的代价未免太大。另外在对象存活率较高的时候,就需要进行大量的复制操作,效率将会变低,所以对于存活时间长的对象一般不使用这种收集算法。

复制算法.jpg

3、标记-整理算法

标记-整理算法和标记-清除算法,标记过程相同,但不同的是,标记-整理算法是将存活对象向同一端移动,然后再清除这之外的内存区域,而不是对可回收的内存直接清除。

标记-整理算法.jpg

4、分代收集算法

分代收集算法中没有新的思想,只是根据对象存活的周期长短又对 Java堆内存进行了划分,一般是把 Java堆分为新生代(Young)和老年代(Old)。新生代和老年代的默认内存大小比例是 1 : 2,也即是新生代占据 1/3 的堆内存空间,老生代占据 2/3 的堆内存空间,这个比例值是可以通过 -XX:NewRatio 参数来动态设置的。而新生代又被划分为 Eden、From Survivor、To Survivor 三个区域,Eden 占据 8/10 的新生代内存空间,并且Java 虚拟机每次只会使用 Eden 区和一个 Survivor(From Survivor 和 To Survivor中的一个) 区,总有一个 Survivor 区是空闲着的。新生代区域用来存放那些朝生夕死的 Java 对象,这些对象存活时间很短,很容易就会被垃圾收集器回收,所以新生代使用复制算法会比较好,而老生代区域用来存放大对象(如对象数组),这些对象不是很容易被回收,存活时间比较长,使用标记-清除算法和标记-清理算法是比较好的。针对不同区域内对象存活时间的长短,使用合适的收集算法,可以最大的发挥出算法优势。

分代收集算法.jpg

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏北京马哥教育

让你的Python代码更加pythonic

何为pythonic? pythonic如果翻译成中文的话就是很python。很+名词结构的用法在中国不少,比如:很娘,很国足,很CCTV等等。 我的理解为...

2084
来自专栏何俊林

Android性能提升之强引用、软引用、弱引用、虚引用使用

背景:收到公众投稿,《从面试题中看Java的Reference(引用)》,总感觉少了实际的例子和应用场景。于是结合自己工作中场景,小小补充下。小总结一下。看下A...

29710
来自专栏小樱的经验随笔

【python进阶】Garbage collection垃圾回收1

前言 GC垃圾回收在python中是很重要的一部分,同样我将分两次去讲解Garbage collection垃圾回收,此篇为Garbage collection...

3177
来自专栏JMCui

终结 finalize()和垃圾回收(garbage collection)

1、为什么要有finalize()方法?     假定你的对象(并非使用new)获得了一块“特殊”的内存区域,由于垃圾回收器只知道释放那些经由new分配的内存,...

3097
来自专栏软件开发 -- 分享 互助 成长

C++中关于指针初始化和使用NULL的理解

1、严禁使用未被初始化的指针:C++创建指针的时候,只分配存储地址的内存,并不会分配存储数据的内存,所以指针可能指向任何位置。    (1)使用解除运算符(*)...

27510
来自专栏Jaycekon

深入Java虚拟机--判断对象存活状态

程序计数器,虚拟机栈和本地方法栈 首先我们先来看下垃圾回收中不会管理到的内存区域,在Java虚拟机的运行时数据区我们可以看到,程序计数器,虚拟机栈,本地方法栈...

3116
来自专栏developerHaoz 的安卓之旅

你真的懂 Java 的内存管理和引用类型吗?

对于 Java 程序员来说,在 Java 虚拟机自动内存管理机制的帮助下,不再需要为每一个 new 操作去写对应的 delete/free 代码,不容易出现内存...

962
来自专栏lonelydawn的前端猿区

lambda+reduce的一句艰深代码

一句话一脸懵逼 某天晚上看到一句lambda+reduce 组合的代码,看的头都炸了,愣是没看懂,不过也可能因为稀疏的脑神经经过一天的摧残已经运转不动了,这两天...

1998
来自专栏云霄雨霁

Java虚拟机--对象回收

2135
来自专栏趣谈编程

可以管理时间的二叉堆

面试官:写一个堆排吧 我心想:堆排是什么鬼 理解堆排,首先要理解二叉堆。理解了二叉堆的“下沉”操作,基本上就可以理解堆排了。今天我们来看一看什么是堆,以及堆...

2106

扫码关注云+社区

领取腾讯云代金券