介绍
JVM大家都听说过,或者也深入学习过,从我们刚接触Java这一门语言的时候,我相信你肯定知道一句名言:“一次编译,到处运行”。这句话的依赖,便是JVM。我个人的理解,在计算机的世界中,所有的问题都可以通过添加一层来解决。
而JVM便是用于解决c/c++语言跨平台性不好的问题锁添加的一层。JVM屏蔽了底层操作系统的调用细节,JVM只需要得到编译后的.class字节码文件,然后将.class 字节码文件转换成机器语言,最后运行。而今天我想分享的并不是JVM中编译、转换等细节,而是另一大块,JVM中的内存管理。
Java语言的一大特点:自动的内存管理,程序员无需手动管理内存。因为内存的申请与创建稍微一不注意就可能造成空间无法释放从而导致内存溢出。
这是本文的大纲,我想你看完之后,对GC并不会停留在零散的知识点上,而是有一条记忆的逻辑线,帮助你更好的理解记忆Java的GC。
JVM中的分区
Java的GC是发生在堆区的(希望不要误解,它不是垃圾堆),既然说到了堆区,我们看看JVM的内存分区是怎么样的。
我们关注的点需要放到堆区上。
我们平时利用 new 关键字得到的对象,就是放在堆区的。而直接 采用 = 号连接的对象,我们称为强引用。
关于Java中的引用大致存在四种,我放在后面提,因为我觉得它其实并不算主线知识点。
判断一个对象是不是垃圾
思考一个问题:怎么判断一个对象是不是垃圾?
当然是没人在用他啦,就是我们用完之后就丢掉的那些对象。关于这个问题,我们需要知道下面两种分析算法。
引用计数
这种方式就是添加多一个字段,作为count来判断是否有对象引用它。不过,这种方式存在一个很明显的问题,"引用循环"问题。就是下面这样。
可达性分析
那Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。方式是、扫描堆中的对象,看是否能够沿着 GC Root对象 为起点的引用链找到该对象,找不到,表示可以回收。
什么对象可以作为Root对象
这里我理解为那些很难被GC的对象,支撑Java程序运行必需的类对象。
例如:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
这些对象都是长久存在内存当中,因此可以作为GC Root节点。
垃圾回收算法
知道了在堆中进行GC,知道了堆中那些GC Roots 不可达对象是需要被GC的。接下来就是如何回收这些对象了。
下面是几种回收算法,朴实无华。
标记-清除算法
标记无用对象,然后进行清除回收。 标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:
- 标记阶段:标记出可以回收的对象。
- 清除阶段:回收被标记的对象所占用的空间。
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。 - 优点:实现简单,不需要对象进行移动。
- 缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。
标记-清除算法的执行的过程如下图所示
复制算法
为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。
- 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
- 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
复制算法的执行过程如下图所示
标记-整理算法
在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。
- 优点:解决了标记-清理算法存在的内存碎片问题。
- 缺点:仍需要进行局部对象移动,一定程度上降低了效率。
标记-整理算法的执行过程如下图所示
分代收集算法
对于上述的一些回收算法,我们可以看到各有优劣。有适合对象生命短的算法,有适合对象生命长的算法,因此JVM对堆区做了进一步的划分。
当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代、老年代 和 永久代,如图所示:
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。 新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
- 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
- 清空 Eden 和 From Survivor 分区;
- From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。 老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。
好了,我们现在知道了GC的大部分理论知识了,接下来就看具体实现。
垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了 7 种作用于不同分代的收集器,其中用于回收新生代的收集器包括 Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括 Serial Old、Parallel Old、CMS,还有用于回收整个 Java 堆的 G1 收集器。不同收集器之间的连线表示它们可以搭配使用。
接下来大致的介绍各个垃圾收集器。垃圾收集器的几个指标
- 吞吐量
- 响应速度
在垃圾回收的过程中,会存在STW(Stop The World),意思是只能等待GC结束才能继续运行服务。所以垃圾收集器在什么时候会发送STW也是我们需要关注的点。
串行 GC(Serial GC)
串行 GC 对年轻代使用 mark-copy(标记—复制) 算法,对老年代使用 mark-sweep-compact(标记—清除—整理)算法。
两者都是单线程的垃圾收集器,不能进行并行处理,所以都会触发全线暂停(STW),停止所有的应用线程。
因此这种 GC 算法不能充分利用多核 CPU。不管有多少 CPU 内核,JVM 在垃圾收集时都只能使用单个核心。
要启用此款收集器,只需要指定一个 JVM 启动参数即可,同时对年轻代和老年代生效:
-XX:+UseSerialGC
对于服务器端来说,因为一般是多个 CPU 内核,并不推荐使用,除非确实需要限制 JVM 所使用的资源。大多数服务器端应用部署在多核平台上,选择 串行 GC 就意味着人为地限制了系统资源的使用,会导致资源闲置,多余的 CPU 资源也不能用增加业务处理的吞吐量。
并行 GC(Parallel GC)
并行垃圾收集器这一类组合,在年轻代使用“标记—复制(mark-copy)算法”,在老年代使用“标记—清除—整理(mark-sweep-compact)算法”。年轻代和老年代的垃圾回收都会触发 STW 事件,暂停所有的应用线程来执行垃圾收集。两者在执行“标记和复制/整理”阶段时都使用多个线程,因此得名“Parallel”。通过并行执行,使得 GC 时间大幅减少。
通过命令行参数 -XX:ParallelGCThreads=NNN 来指定 GC 线程数,其默认值为 CPU 核心数。可以通过下面的任意一组命令行参数来指定并行 GC:
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:+UseParallelGC -XX:+UseParallelOldGC
并行垃圾收集器适用于多核服务器,主要目标是增加吞吐量。因为对系统资源的有效使用,能达到更高的吞吐量:
在 GC 期间,所有 CPU 内核都在并行清理垃圾,所以总暂停时间更短;
在两次 GC 周期的间隔期,没有 GC 线程在运行,不会消耗任何系统资源。
另一方面,因为此 GC 的所有阶段都不能中断,所以并行 GC 很容易出现长时间的卡顿(注:这里说的长时间也很短,一般来说例如 minor GC 是毫秒级别,full GC 是几十几百毫秒级别)。如果系统的主要目标是最低的停顿时间/延迟,而不是整体的吞吐量最大,那么就应该选择其他垃圾收集器组合。
注:长时间卡顿的意思是,此 GC 启动之后,属于一次性完成所有操作,于是单次 暂停 的时间会较长。
Parallel Old 收集器 (标记-整理算法)
老年代并行收集器,吞吐量优先,Parallel Scavenge 收集器的老年代版本。
ParNew 收集器 (复制算法)
新生代收并行集器,实际上是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现。
CMS(Concurrent Mark Sweep)收集器(标记-清除算法)
CMS是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。
在启动 JVM 的参数加上-XX:+UseConcMarkSweepGC
来指定使用 CMS 垃圾回收器。
整个过程分为四个步骤:
1、初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快(STW) ;
2、并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
3、重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短(STW)
4、并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。
G1(Garbage First)收集器 (标记-整理算法)
G1 GC 最主要的设计目标是:将 STW 停顿的时间和分布,变成可预期且可配置的。
事实上,G1 GC 是一款软实时垃圾收集器,可以为其设置某项特定的性能指标。例如可以指定:在任意 xx 毫秒时间范围内,STW 停顿不得超过 yy 毫秒。举例说明:任意 1 秒内暂停时间不超过 5 毫秒。G1 GC 会尽力达成这个目标(有很大概率会满足,但并不完全确定)。
G1 GC 的特点
为了达成可预期停顿时间的指标,G1 GC 有一些独特的实现。
首先,堆不再分成年轻代和老年代,而是划分为多个(通常是 2048 个)可以存放对象的 小块堆区域(Smaller Heap Regions)。每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor 区或者 Old 区。在逻辑上,所有的 Eden 区和 Survivor 区合起来就是年轻代,所有的 Old 区拼在一起那就是老年代,如下图所示:
这样划分之后,使得 G1 不必每次都去收集整个堆空间,而是以增量的方式来进行处理:每次只处理一部分内存块,称为此次 GC 的回收集(collection set)。每次 GC 暂停都会收集所有年轻代的内存块,但一般只包含部分老年代的内存块,见下图带对号的部分:
G1 的另一项创新是,在并发阶段估算每个小堆块存活对象的总数。构建回收集的原则是:垃圾最多的小块会被优先收集。这也是 G1 名称的由来。
G1收集的大致过程:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
一些额外的点
Java的四种引用类型
只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用
对象
可以配合引用队列来释放软引用自身
仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
可以配合引用队列来释放弱引用自身
必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,
由 Reference Handler 线程调用虚引用相关方法释放直接内存
Java版本升级发现有趣的事情
Java每次版本升级,都会废弃之前承诺废弃的类
Java每次版本升级,都想不断升级垃圾收集器,说明垃圾收集器是非常重要的东西。
在后续的版本,采用了全新的垃圾收集器ZGC,废弃掉了CMS垃圾收集器。
参考资料:
《深入理解 Java 虚拟机》
《极客时间 深入JVM 核心技术 32 讲》