第二篇介绍了 Java
内存运行时区域,其中 程序计数器 、 虚拟机栈 、 本地方法栈 三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的 内存分配和回收 都具备 确定性 。在这几个区域内不需要过多考虑回收的问题, 因为方法结束或线程结束时,内存自然就跟随着回收了 。
而 Java
堆 和 方法区 则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样。我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分 内存的分配和回收 都是 动态的 , 垃圾收集器 所关注的是这部分内存。
如何判断 Java
中一个对象应该 “存活” 还是 “死去” ,这是 垃圾回收器要做的第一件事。
Java
堆 中每个具体对象( 不是引用 )都有一个 引用计数器 。当一个对象被创建并初始化赋值后,该变量计数设置为 1
。每当有一个地方引用它时,计数器值就 加1 。当引用 失效 时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就 减1 。任何引用计数为 0
的对象可以被当作 垃圾收集 。当一个对象被垃圾收集时,它引用的任何对象计数减1。
引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利。
难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。
可达性分析算法也叫 根搜索算法 ,通过一系列的称为 GC Roots
的对象作为起点,然后向下搜索。搜索所走过的 路径 称为引用链 ( Reference Chain
), 当一个 对象 到 GC Roots
没有任何 引用链 相连时, 即该对象 不可达 ,也就说明此对象是 不可用的 。
如下图所示: Object5
、 Object6
、 Object7
虽然互有关联, 但它们到 GC Roots
是不可达的, 因此也会被判定为可回收的对象。
在 Java
中, 可作为 GC Roots
的对象包括以下四种:
虚拟机栈(栈帧中的 本地变量表 )中引用的对象
本地方法栈中 JNI
( Native
方法)引用的变量
方法区中 类静态属性 引用的变量
方法区中 常量 引用的变量
JVM中用到的所有现代GC算法在回收前都会先找出所有仍存活的对象。可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图。下图展示的JVM中的内存布局可以用来很好地阐释这一概念:
## (二). 对象引用分类 #### **1. 强引用(Strong Reference)** 在代码中普遍存在的,类似`Object obj = new Object()`这类引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。
有用但并非必需的对象,可用 SoftReference
类来实现软引用。在系统将要发生 内存溢出异常 之前,将会把这些对象列进回收范围之中进行 二次回收 。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
非必需的对象,但它的 强度 比软引用更弱,被弱引用关联的对象 只能 生存到下一次垃圾收集发生之前, JDK
提供了 WeakReference
类来实现弱引用。无论当前内存是否足够,用软引用相关联的对象都会被回收掉。
虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系, JDK
提供了 PhantomReference
类来实现虚引用。为一个对象设置虚引用的唯一目的是:能在这个对象在 垃圾回收器 回收时收到一个 系统通知 。
## (三). finalize()二次标记 一个对象是否应该在垃圾回收器在`GC`时回收,至少要经历**两次标记过程**。
第一次标记过程,通过 可达性分析算法 分析对象是否与 GC Roots
可达。经过第一次标记,并且被筛选为 不可达 的对象会进行第二次标记。
第二次标记过程,判断不可达对象是否有必要执行 finalize
方法。执行条件是当前对象的 finalize
方法被重写,并且还未被系统调用过。如果允许执行那么这个对象将会被放到一个叫 F-Query
的队列中,等待被执行。
注意:由于 finalize
由一个优先级比较低的 Finalizer
线程运行,所以该对象的的 finalize
方法不一定被执行,即使被执行了,也不保证 finalize
方法一定会执行完。如果对象第二次小规模标记,即 finalize
方法中拯救自己,只需要重新和引用链上的任一对象建立关联即可。
## (四). 垃圾回收算法
本节具体介绍一下各种垃圾回收算法的思想:
标记-清除算法对 根集合 进行扫描,对 存活 的对象进行标记。标记完成后,再对整个空间内 未被标记 的对象扫描,进行回收。
实现简单,不需要进行对象进行移动。
标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。
这种收集算法解决了标记清除算法存在的效率问题。它将内存区域划分成相同的两个 内存块 。每次仅使用一半的空间, JVM
生成的新对象放在一半空间中。当一半空间用完时进行 GC
,把可到达对象复制到另一半空间,然后把使用过的内存空间一次清理掉。
按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
标记-整理算法采用和 标记-清除算法 一样的方式进行对象的标记,但后续不直接对可回收对象进行清理,而是将所有的 存活对象 往一端 空闲空间 移动,然后清理掉端边界以外的内存空间。
解决了标记-清理算法存在的内存碎片问题。
仍需要进行局部对象移动,一定程度上降低了效率。
当前商业虚拟机都采用 分代收集 的垃圾收集算法。分代收集算法,顾名思义是根据对象的 存活周期 将内存划分为几块。一般包括 年轻代 、 老年代 和 永久代 ,如图所示:
绝大多数最新被创建的对象会被分配到这里,由于 大部分对象 在创建后会很快变得 不可达 ,所以很多对象被创建在 新生代 ,然后 消失 。对象从这个区域消失的过程我们称之为 minor GC
。
新生代中存在一个 Eden
区和两个 Survivor
区。新对象会首先分配在 Eden
中(如果新对象过大,会直接分配在老年代中)。在 GC
中, Eden
中的对象会被移动到 Survivor
中,直至对象满足一定的年纪(定义为熬过 GC
的次数),会被移动到 老年代 。
可以设置 新生代 和 老年代 的相对大小。这种方式的优点是新生代大小会随着整个 堆 大小 动态扩展 。参数 -XX:NewRatio
设置 老年代 与 新生代 的比例。例如 -XX:NewRatio=8
指定 老年代/新生代 为 8/1
. 老年代 占堆大小的 7/8
, 新生代 占堆大小的 1/8
(默认即是 1/8
)。
例如:
-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8 复制代码
对象没有变得不可达,并且从新生代中 存活 下来,会被 拷贝 到这里。其所占用的空间要比新生代多。也正由于其相对 较大的空间 ,发生在 老年代 上的 GC
要比 新生代 要 少得多 。对象从 老年代 中消失的过程,可以称之为 major GC
(或者 full GC
)。
像一些 类的层级信息 , 方法数据 和 方法信息 (如 字节码 , 栈 和 变量大小 ), 运行时常量池 ( JDK7
之后移出 永久代 ),已确定的 符号引用 和 虚方法表 等等。它们几乎都是 静态的 并且 很少 被 卸载和回收 ,在 JDK8
之前的 HotSpot
虚拟机中,类的这些**“永久的”** 数据存放在一个叫做 永久代 的区域。
永久代一段 连续的内存空间 ,我们在 JVM
启动之前可以通过设置 -XX:MaxPermSize
的值来控制永久代的大小。但是 JDK8
之后取消了永久代,这些 元数据 被移到了一个与堆 不相连 的称为 元空间 ( Metaspace
) 的 本地内存区域 。
JDK8
堆内存一般是划分为 年轻代 和 老年代 , 不同年代 根据自身特性采用 不同的垃圾收集算法 。
对于 新生代 ,每次 GC
时都有 大量 的对象死亡,只有 少量 对象存活。考虑到复制成本低,适合采用 复制算法 。因此有了 From Survivor
和 To Survivor
区域。
对于 老年代 ,因为对象 存活率高 ,没有额外的内存空间对它进行担保。因而适合采用 标记-清理算法 和 标记-整理算法 进行回收。
周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社
欢迎关注技术公众号:零壹技术栈
本帐号将持续分享后端技术干货,包括虚拟机基础,多线程编程,高性能框架,异步、缓存和消息中间件,分布式和微服务,架构学习和进阶等学习资料和文章。