Java虚拟机—堆内存分代和GC垃圾收集算法

Posted SunFlow007

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java虚拟机—堆内存分代和GC垃圾收集算法相关的知识,希望对你有一定的参考价值。

前言:

上一篇文章我们说到了「对象」这个在java中无比重要的概念,也讨论了对象在堆内存里的创建、布局和访问定位,本篇文章我们就要讨论下「对象」的「死亡」和垃圾收集。

不同的JVM实现采用了不同的垃圾收集器,不同的垃圾收集器的工作原理也是不同的,本文主要以介绍HotSpot虚拟机的垃圾收集器及其实现。由于现在主流的垃圾收集器都采用分代式垃圾回收算法,所以我们会重点介绍相关算法以及对于的Java堆中分代的概念。

所以本文的主要内容有:

  • 1.对象存活判断

  • 2.Java堆分代的概念

  • 3.GC垃圾收集算法

  • 4.常用垃圾收集器介绍(G1、CMS)

  • 5.内存分配和回收策略


0.概述

垃圾收集(Garbage Collection),也叫GC,学Java的童鞋大部分人都听过这个概念,而且知道这是为了回收无用的Java堆内存上的「对象」而产生的。其实GC的历史比Java久远,1960年诞生的Lisp是第一门使用内存动态分配和垃圾收集的编程语言。

正如上一篇文章中提到的:

Java堆中内存的排列是否规整取决于堆中垃圾收集器,如果JVM中的垃圾收集器带有空间压缩整理功能,则内存规整;否则内存不规整。

在Java堆内存上,垃圾收集器往往和内存分配紧密相关,因为不同的垃圾收集器可能采用不同的内存分配方式、不同的收集算法和不同的内存压缩整理方式。

下面请思考一个小问题——GC垃圾收集为什么只回收Java堆内存和方法区?(HotSpot VM中)那Java虚拟机栈、本地方法栈、程序计数器这些呢?

答案:Java虚拟机栈、本地方法栈、程序计数器这三者是线程私有的,随线程而生随线程而灭。栈中的栈帧随着方法的进入和退出有条不紊的出栈入栈,每个栈帧需要分配多少内存,在类结构确定下来时就是已知的(尽管运行期间会有JIT编译器进行一些优化,但在基于概念模型的讨论中,大体可以认为是编译器可知的)。因此上述这些区域的内存分配和回收都具备确定性,故不需要过多考虑垃圾回收的问题。而Java堆和方法区则不一样:一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支所需的内存也不一样,我们只有在程序运行期间才知道会创建哪些对象,所以这部分内存的分配和回收都是动态的,所以垃圾回收器所关注的重点是位于Java堆和方法区上的内存。

1.对象存活判断

在Java堆中存放着Java世界中几乎所有的对象实例,在垃圾回收器回收内存前,首先要做的事情就是判断这些对象哪些还「活着」哪些已经「死去」。何为活着?活着就表示该对象可以被发现和使用,死去则表示该对象已经无法被任何途径所使用,将要等待着被当作垃圾收集。

对象存活性的判断,有两类比较常用的算法:

  • 引用计数算法

  • 可达性算法

1.1引用计数算法

引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当此对象被某个地方引用时,计数值+1,引用失效时-1,所以当计数值为0时表示对象已经不能被使用。引用计数算法大多数情况下是个比较不错的算法,简单直接,也有一些著名的应用案例但是对于Java虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相互循环引用的问题。

譬如有A和B两个对象,他们都互相引用,除此之外都没有任何对外的引用,那么理论上A和B都可以被作为垃圾回收掉,但实际如果采用引用计数算法,则A、B的引用计数都是1,并不满足被回收的条件,如果A和B之间的引用一直存在,那么就永远无法被回收了

1.2可达性算法

在主流的商用程序语言如Java、C#等的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的。此算法的基本思路就是通过一系列的“GC Roots”的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为引用链(Reference Chain),当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 栈帧中的局部变量表中的reference引用所引用的对象

  • 方法区中类static静态引用的对象

  • 方法区中final常量引用的对象

  • 本地方法栈中JNI(Native方法)引用的对象

1.3对象的引用

无论是引用计数算法或者是可达性分析算法,判断对象是否“存活”都和「引用」相关。在JDK1.2以后,「引用」分为4种类型:

  • 强引用-Strong Reference

  • 软引用-Soft Reference

  • 弱引用-Weak Reference:

  • 虚引用-Phantom Reference

强引用就是在代码中普遍存在的,类似Object obj = new Object()这类的引用,只要强引用存在,则垃圾回收器永远不会回收掉它。

软引用用来描述一些还有用,但是非必须的对象。这些对象通常不会被回收。在虚拟机内存即将溢出之前,垃圾回收器会回收这部分软引用的内存,如果还是内存不够,则抛出内存溢出异常。存在软引用的对象只在内存即将溢出时被回收。

弱引用也是用来描述非必须的对象,且它的强度比弱引用更浅。它的生命只能存活到下一次垃圾收集之前。当下一次垃圾收集发生时,无论内存是否足够,都会回收弱引用的内存。

虚引用是最弱的一类引用,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过一个弱引用来获取一个对象实例(即:无论是否存在此引用,不会影响一个对象的回收)。例如,为一个对象设置若引用,则该对象被垃圾收集器回收后能收到一个系统通知。

1.4对象生死大逃亡

对象经过可达性算法分析后,判断为不可达,那么对象就「必死无疑」了么?不一定,对象在面临垃圾回收器的处理时,还有最后一次求生的机会。

要kill掉一个对象,至少要经过垃圾回收器的2次标记过程,不可达的对象被第一次标记后会进行一次筛选,筛选的条件是「此对象是否有必要执行finalize()方法」,当对象没有覆盖finalize方法或者已经执行过finalize方法时,会被判断为:没必要执行。如果被判断为有必要执行,则该对象会被放置在一个F-Queue队列,并在稍后虚拟机建立的Finalizer线程中执行finalize()来kill掉对象。在回收前垃圾回收器会对F-Queue队列中的对象进行第二次标记,如果在标记前,对象成功与引用链上的任意对象建立了关联,则会在第二次标记时被移出F-Queue,从而实现「自救」

1.5方法区的垃圾回收

首先,要明确一个概念——方法区,是一个「概念」,是Java虚拟机规范中定义的概念,一个「非堆」的运行时数据区域,用于存放被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,运行时常量池也是存放于方法区中。逻辑上的「非堆」表示和Java堆独立,那物理上呢?

Java虚拟机规范中定义了方法区这个概念,但是并没有规定此区域的是否需要垃圾收集。

在Java7以前,HotSpot虚拟机中,方法区也被称为“永久代”,因为在物理上,方法区使用的是由JVM开辟的堆内存,由于和Java堆共享内存且内存空间由垃圾收集器统一分配和管理,自然的垃圾收集也拓展到了方法区上。此时,Java堆中分区为青年代Young Generation和老年代Old Generation,而方法区自然地被称为永久代Permanent Generation 。

(JVM虚拟机有不同的实现,比较主流的是sun公司的HotSpot虚拟机,其他虚拟机不存在“永久代”这个概念)

在Java8中,HotSpot虚拟机改变了原有方法区的物理实现,将原本由JVM管理内存的方法区的内存移到了虚拟机以外的计算机本地内存,并将其称为元空间(Metaspace)。这样一来,现在的方法区实际存储在于元空间,再也不用和Java堆共享内存了,“永久代”也就永久地被撤销了。由于元空间Metaspace用的是计算机本地内存,所以理论上来说只要计算机内存足够大,元空间就能有多大。

尽管永久代撤销了,方法区这个逻辑上的空间一直是存在的,实际上Metaspace的大小是可以通过参数设定的,如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。常用的G1和CMS垃圾收集器都能很好地回收Metaspace区。所以在java8以后,方法区的垃圾回收在物理上就是对元空间的垃圾回收。


2.Java堆分代的概念

Java堆是垃圾收集器管理的主要内存,由于主流的虚拟机实现中,垃圾收集器大多采用分代式垃圾回收算法(Generational Garbage Collection),所以会将垃圾收集器所管理的堆内存划分为不同的代。

Java7以前Hotspot虚拟机中将Java堆内存分为3个部分:

  • 青年代Young Generation

  • 老年代Old Generation

  • 永久代Permanent Generation 

Java虚拟机—堆内存分代和GC垃圾收集算法

在Java8以后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了,在几天前(2018年9约25日)Java11正式发布以后,我从官网上找到了关于Java11中垃圾收集器的官方文档,文档中没有提到“永久代”,而只有青年代和老年代。以官网给出的垃圾收集器分代图片为例,堆分代情况如下:

Java虚拟机—堆内存分代和GC垃圾收集算法
Serial垃圾收集器中分代的默认排列
Java虚拟机—堆内存分代和GC垃圾收集算法
Parallel垃圾收集器中分代的默认排列

可见,无论是串行垃圾收集器(Serial Collector)还是并行垃圾收集器(Parallel Collector),目前的分代情况只有两种:

  • 青年代Young Generation

  • 老年代Old Generation

而在G1垃圾收集器(G1 garbage collector)上,对Java堆内存的划分和上述两种垃圾收集器有较大区别,但是还是保留了青年代和老年代的概念。其堆内存布局如下:

Java虚拟机—堆内存分代和GC垃圾收集算法
https://docs.oracle.com/en/java/javase/11/gctuning/garbage-first-garbage-collector.html#GUID-15921907-B297-43A4-8C48-DC88035BC7CF

我们将在下面的小节中介绍这个G1收集器。

3.GC垃圾收集算法

3.1标记-清除算法(Mark-Sweep)

正如其名,此算法主要分为“标记”和“清除”两个阶段,首先标记出需要收集/回收的对象,在标记完成后将标记过的对象统一回收。标记清除算法是最基础的收集算法。

Java虚拟机—堆内存分代和GC垃圾收集算法

标记清除算法有两处劣势:

  • 效率较低,因为标记和清除这两个过程效率都比较低

  • 空间问题,标记清除后会产生大量不联系的内存空间(碎片),导致如果有大内存的对象,那么就无法找到足够大的连续内存空间以供分配。

3.2复制算法

复制算法,将完整内存区域分为大小相等的2块,每次只使用其中的一块,当这块内存满了(用完),则将此块内存上的对象都「复制」到另一块空内存上去,然后将用完的那块内存进行垃圾回收。这样的好处是将对象复制到空内存空间时由于是按顺序分配,只需要移动堆顶指针,实现起来简单高效,无内存碎片。劣势:空间消耗比较大,一半的内存空间得不到利用。

Java虚拟机—堆内存分代和GC垃圾收集算法

IBM公司的研究表明,98%的对象是“朝生夕死”的,即存活时间很短,所以在“复制算法”中没必要按1:1来划分内存空间,而是将整个内存划分为一块Eden区域和两块Survivor区域,每次使用Eden和其中的一块Survivor。HotSpot虚拟机默认Edan和Survivor的比值为8:10:10。这样分配内存空间时,只浪费了1个Survivor的空间也就是10%。

商用虚拟机的垃圾收集器实现多采用复制算法来完成青年代Young Generation的垃圾回收。

3.3标记-压缩算法(Mark-Compact)

在青年代采用复制算法是非常合适的,因为青年代的特点是对象数量多,生存时间短,所以空间利用率比较重要,而复制算法对于老年代Old Generation则不太适合,因为老年代的对象数量虽少,但比较稳定存活率高这样会有较多的复制开销,针对这种情况,出现了标记-压缩算法。

Java虚拟机—堆内存分代和GC垃圾收集算法

标记-压缩算法和标记-清除算法类似,先通过标记找出等待回收的对象,然后在清除之前将存活的对象都整理整齐放到一边,然后再清除掉边界以外的内存。

3.4分代收集算法(Generational Collectjion)
目前的商用虚拟机的垃圾收集器实现大多采用分代收集算法。分代收集算法是前几种算法的集合体。Java堆分为年轻代和老年代,分代收集算法是指对不同的代采取不同的算法实现,在年轻代中选择复制算法,而老年代中由于对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清除或标记-压缩算法。

此算法的具体实现稍后在第5小节——内存分配和回收策略中具体介绍。


4.常用垃圾收集器(Garbage Collector)介绍

  • Serial收集器

  • Parnew收集器

  • Parellel Scavenge收集器

  • Serial Old收集器

  • Parellel Old收集器

  • CMS收集器

  • G1收集器

1.Serial收集器

Serial收集器是最基本、发展历史最悠久的垃圾收集器在JDK1.3以前是青年代垃圾收集的唯一选择。Serial翻译为串行,看名字就知道这个收集器采用单线程“串行”工作,它在进行垃圾回收的工作时,必须暂停JVM中的其他工作线程,直到垃圾收集介绍,所以这段时间就称为——“Stop The World”,虽然名字很酷,但是由于它会暂停用户的所有线程,造成停滞,所以带来了很不好的用户体验。尤其是Stop The World时间过长时,会让人怀疑人生。虽然很古老,但是Serial经过不断地优化还是一个简单而高效的单线程下的垃圾收集器。

2.Parnew收集器

Parnew收集器其实就是Serial收集器的多线程版本。由于是多线程版本,所以在单CPU环境下的效率并不如传统的单线程Serial收集器。可以和CMS收集器配合一起工作,因此是虚拟机中青年代常用的垃圾收集器。

3.Parellel Scavenge收集器

Parellel Scavenge收集器也是一个青年代,采用复制算法的收集器。和CMS收集器尽可能缩短垃圾收集时Stop The World停顿时间不同,Parellel Scavenge收集器的主要关注点在于达到一个可控制的吞吐量(Throughtput)。

吞吐量就是CPU用于运行用户代码的时间/CPU总消耗时间,即吞吐量 = 运行用户代码的时间/(运行用户代码+垃圾收集时间)

低停顿时间的关注点在于以良好的响应速度和低延迟来提升用户体验,适合需要和用户有较多交互的场景;而高吞吐量的关注点在于可以高效率地利用CPU时间以尽快完成运算任务,此场景主要适合较少用户交互多后台计算任务的场景。

4.Serial Old收集器

Serial Old收集器是Serial收集器在老年代上的版本,同样是采用复制算法的单线程收集器。

5.Parellel Old收集器

是Parellel Scavenge的老年代版本,采用多线程和标记-压缩算法。

6.CMS收集器

CMS收集器全名(Concurent Mark Sweep),从名字可以看出这款收集器是一款比较优秀的基于标记-清除算法的并发收集器。之前也提到过,此收集器的目标在于尽量小的Stop The World间隔时间,用于用户交互比较多的场景。它的收集过程分为4步:

  • 初始标记

  • 并发标记

  • 重新标记

  • 并发清除

其中初始标记和重新标记两个步骤仍需要Stop The World间隔。初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快。并发标记阶段就是进行GC Roots追踪的过程,而重新标记则是为了修正并发标记期间由于用户程序继续执行可能产生变动的那部分对象的标记记录,此阶段会比初始标记长一些,但远小于并发标记的时间。

整个阶段并发标记和并发清除是耗时最长的两个阶段。但是由于CMS收集器是并发执行的,故可以和用户线程一起工作,所以从整体上CMS收集器的工作过程是和用户线程并发执行的。

优点:

  • GC收集间隔时间短,多线程并发。

缺点:

  • 1.并发时对CPU资源占用多,不适合CPU核心数较少的情况。

  • 2.且由于采用标记清除算法,所以会产生内存碎片。

  • 3.无法处理浮动垃圾。

浮动垃圾:CMS并发清除阶段由于用户线程还可以继续执行,所以可能会产生新的垃圾)


以上垃圾收集器的内容摘自《深入理解Java虚拟机-周志明》-第二版,由于Java更新速度很快,最新的Java11已经出来了

以上是关于Java虚拟机—堆内存分代和GC垃圾收集算法的主要内容,如果未能解决你的问题,请参考以下文章

JVM系列:堆(Heap)

gc算法与内存分析(jvm)

《深入理解java虚拟机》笔记JVM调优(分代垃圾收集器)

深入理解java虚拟机GC垃圾回收-垃圾收集算法

深入理解java虚拟机GC垃圾回收-垃圾收集算法

JVM堆内存控制/分代垃圾回收