【浅度渣文】JVM——简述垃圾回收

753 阅读12分钟

原文链接:http://www.dubby.cn/detail.html?id=9062

垃圾回收的简单描述

什么是自动垃圾收集?

自动垃圾收集是查看堆内存的过程,可以识别哪些对象正在使用,哪些不是,以及删除未使用的对象。一个正在使用的对象或一个被引用的对象,意味着你的程序的某个部分仍然保持着一个指向这个对象的指针。未使用的对象或未引用的对象不再被程序的任何部分引用。所以未被引用的对象所使用的内存可以被回收。

在像C这样的编程语言中,分配和释放内存是一个手动过程。在Java中,释放内存的过程由垃圾收集器自动处理。基本过程可以描述如下。

第1步:标记

这个过程的第一步就是标记。这是垃圾收集器标记内存中哪些对象正在被使用,哪些对象已经没有被使用。

image

有用的对象显示为蓝色,没有用的对象显示为黄色。在标记阶段扫描所有对象,然后做出这个决定。如果必须扫描系统中的所有对象,这可能是非常耗时的过程。

第2步:普通删除

内存维护着一个空闲内存列表,每次分配空间时,会来这个列表上找到合适的空间分配。正常删除时,会把没有用到的对象的内存空间还给空闲列表。

image

另一种第2步:删除并压缩

为了进一步提高性能,除了删除未引用的对象之外,还可以压缩剩余的引用对象。 通过移动被引用的对象,这使得新的内存分配变得更容易和更快。

image

为什么使用分代垃圾收集?

如前所述,标记和压缩JVM中的所有对象效率不高。 随着越来越多的对象被分配,对象列表的增长和增长导致更长和更长的垃圾收集时间。 然而,应用程序的实证分析表明,大多数对象是短暂的。

这里给个数据的例子。

image

正如你所看到的,随着时间的推移对象保持存活的越来越少。 实际上,大多数对象的寿命都很短,如图左侧较高的值所示。

JVM 的分代

根据上面的对象的行为特性,我们可以总结出一个更好的方式来提高JVM垃圾回收的效率。所以,就把堆内存分成几种代,新生代老年代永久代(Java8之后就没有永久代了,取而代之的是元数据Metaspace)。

image

一个新的对象会被分配在新生代上,并且新的对象会在新生代里慢慢变老。当新生代的空间被占满后,就会触发一次minor gc。假设新生代里的对象死亡率很高的话,那么新生代的垃圾回收就是很优的。一个充满死亡对象的新生代收集起来其实很快。幸存下来的对象会慢慢变老,直到可以移入老年代。

Stop the World Event——所有的新生代手机都是停止世界的事件。Stop the World Event的意思是,所有的应用程序的线程都会被暂停,直到垃圾回收完成。新生代GC总是Stop the World。

老年代是存放那些经历了多次minor gc,年纪达到一个阈值之后的存活的对象。一般来说,会给对象设置一个年龄阈值,达到阈值之后,就会移入老年代。最后,老年代需要进行垃圾回收,就会触发一次major gc。

Major gc也是导致Stop the World。在大部分情况下,major gc是会比minor gc慢很多。所以,对于一个关注响应时间的应用来说,应该尽可能的降低major gc的次数。这里也要注意到,major gc的停顿时间(Stop the World的时间)是和你选取的垃圾收集器有关的。

永久代包含了JVM所需要的class和method的定义等元数据。永久代会随着JVM运行时加载的class而填充新的元数据。除此之外,Java SE的类库也会被存储在这里。

如果JVM检测到这部分class不会被使用了,而且需要更多的内存空间来加载其他的class,那么class也会被回收(unloaded/卸载)。这个收集包含在一次full gc中。(即便是在Java8之后,没有了所谓了永久代,取而代之的是元数据,但是,也会存在类型卸载的回收)

分代垃圾回收

现在你已经明白了为什么需要把堆细分成不同的几个代,现在是时候仔细的看看这种空间是如何工作的了。下面的图演示了在JVM中,对象的分配和变老的过程。

1.首先,任何对象都会被分配在eden区。两个suvivor区一开始都是空的。

这里是为了给读者介绍垃圾回收器的设计过程,和一步步的思考过程,在之后还是会有很多优化,可能会和一开始的设计意图相违背,请见谅。比如,有的对象甚至不分配到堆里(逃逸分析),有的大对象甚至会直接分配到old区(大对象分配),有的对象甚至会分配到堆外内存(nio等),等等各种特殊情况。

image

2.当eden满了之后,就会触发一次minor gc。

image

3.活着的对象会被移到第一个suvovor区(第一个第二个都是相对的)。没有被是用的对象就直接被清除了。

image

4.下一次minor gc发生时,同样的操作。没有被使用的对象被清除,活着的对象和被移到另一个suvivor区。而且,这些对象年龄会+1,然后被移入第二个suvivor。所有的活着的对象都被移入这个新的suvivor1,那么eden和suvivor0又都空了。但是,现在在suvivor1中,对象的年龄是不一样的。

image

5.下一次minor gc,又会重复上面的步骤。不过对象是从eden和suvicor1移入到suvivor0中了。

image

6.终于,随便不断的minor gc,对象的年龄越来越大,达到了阈值(这里是8)时,他们会晋升带老年代。

image

7.随着更多的minor gc,也有更多的对象晋升到老年代。

image

8.上面已经涵盖了新生代的整个过程。最后,老年代需要进行一次major gc来清除,压缩老年代的空间。

image

执行并观察

上面说了那么多,相信机智的读者已经大致了解垃圾回收的过程了。现在让我们亲眼看一看这个执行过程。这一部分,我们会运行一个Java应用程序,然后使用Visual VM分析回收的过程。Visual VM是JDK提供给我们的一个工具,开发者可以使用这个工具对JVM进行各个方面的监视。

1. 你需要先去Oracle官网下载JavaDemo

2.启动示例代码

确保你的电脑已经安装了JDK,并下载了上一步说的demo。然后解压到本地一个目录下。我的目录是/Users/teeyoung/Desktop/code4me/javademos8

然后执行Java2demo.jar,java -Xmx120m -Xms30m -Xmn10m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar Java2Demo.jar

注意:1.这些命令稍后会解释;2.-XX:PermSize=20m -XX:MaxPermSize=20m如果是在Java 8之后是提示无效,以为已经被移除了。

程序运行起来是这个样子:

image

你可以看到很多tab,那些演示了Java的绘图功能(看到这个程序,让我想到了买家秀和卖家秀,别人写的代码和我写的代码)。

随意点击各个tab,大致是这样的:

image

这个界面可以看到垃圾回收行为的结果,看右下角的内存监视。我们先让他运行着,我们稍后会用到它的。

3.启动VisualVM

如果你的jdk/bin已经在你的path下了,那么直接执行jvisualvm,否则需要输入完整的路径,如:/usr/bin/jvisualvm

image

4.安装Visual GC插件

我们需要安装Visual GC这个插件,但是java.net这个站点都关了,无法联网安装,所以我写了另一篇文章演示如何安装插件。请移步jvisualvm插件安装的正确姿势(解决网络问题):http://www.dubby.cn/detail.html?id=9061

安装之后就是这个样子:

image

5.分析Java2Demo

首先双击Java2Demo这个本地进程,或者右击->Open:

image

然后点击Visual GC这个tab:

image

然后就自由尝试各个tab页,看看每个信息代表JVM的什么指标。还有,你可以尝试着改变Java2Demo上的string和image的数量,看看对垃圾回收有什么影响。

Java垃圾收集器

现在你知道了垃圾回收的基本概念,还有如何去监视JVM的垃圾回收。现在我们来了解Java给我们提供的不同的垃圾回收器,还有我们需要掌握如何使用这些垃圾回收器的命令行。

通用的命令行

这里给出一些通用的命令行,不管你是什么收集器,都会用到的。

选项 描述
-Xms 设置JVM启动时,堆的初始大小
-Xmx 设置堆的最大的容量
-Xmn 设置新生代的容量
-XX:PermSize 设置永久代的初始大小(Java 8以废弃
-XX:MaxPermSize 设置永久代的最大容量(Java 8以废弃
-XX:MinHeapFreeRatio 设置堆最小空闲容量,低于这个阈值就扩容,但是堆总量还是要在Xmx和Xms之间
-XX:MaxHeapFreeRatio 设置堆最大空闲容量,高于这个阈值就收缩,但是堆总量还是要在Xmx和Xms之间

Serial收集器

Serial收集器是客户端默认的收集器。使用Serial收集器,minor gc和major gc都是单线程处理。而且,老年代使用并发-压缩算法。把老年代的活着的对象移到老年代的前面,后面空出空闲区域,以供后续分配,可以避免空间碎片。

使用场景

Serial收集器是一些客户端(PC,不是服务器)应用使用,而且对于低延时要求不高的。他的优势是单线程处理。直到今天,对于一些不是很重要,堆内存只有几百MB的应用来说,Serial GC依然是个很有效的垃圾收集器。

还有一个广泛使用Serial收集器的场景是,一个机器上运行着很多JVM(在某些场景下,JVM的数量比处理器的核数还要多)。在这种情况,使用Serial收集器可以减少JVM之间冲突,即便GC的时间变长了。

最后,随着嵌入式设备的普及,内存少,核数少,Serial收集器可能会重新绽放光彩。

命令行选项

开始Serial收集器:

-XX:+UseSerialGC

给个完整的例子:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar Java2demo.jar

Parallel收集器

Parallel收集器在回收新生代时,使用多线程进行收集。默认的回收线程数等于机器的核数。可以使用-XX:ParallelGCThreads=<desired number>来设置希望的线程数。

在只有一个CPU的机器上,即便你已经开启了Parallel收集器,JVM还是会使用默认的收集器来工作。

使用场景

Parallel收集器也被称为吞吐收集器。因为他可以利用多线程来加快应用的吞吐。这个收集器一般被用作有很多工作需要做,而且对低延要求时不那么高的应用。例如,批处理(报表,账单,或者是很大的数据库查询等)。

-XX:+UseParallelGC

这个命令行选项是开启新生代的多线程收集,老年代的多线程收集。老年代也是整理方式。

给个完整的例子:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelGC -jar Java2demo.jar

-XX:+UseParallelOldGC

这个选项是开启新生代的多线程收集,老年代的多线程收集。老年代也是整理方式。

整理:就是会把活着的对象移到内存的前面,这样就对象和对象之间的小的空闲的空间(内存碎片)。内存碎片可能导致,空闲空间足够,但是大对象无法分配的情况。

完整的例子:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelOldGC -jar Java2demo.jar

CMS收集器

并发(Concurrent)标记(Mark)清除(Sweep)收集器(CMS)(也被叫做:并发低延时收集器)是一个手机老年代的垃圾收集器。他试图把大部分垃圾收集工作和应用程序的线程并发执行,以降低所造成的停顿(Stop the World)时间。通常情况下,CMS不会压缩整理活着对象。所以,会存在内存碎片的问题。如果内存碎片成为你的问题,那么可以考虑换用更大的堆(哈哈,也可以考虑换收集器,但是,换更大的堆是直接并且简单的方法)。

注意:CMS收集器在新生代的收集方式和Parallel在新生代的收集方式一样(单线程,复制)。

使用场景

CMS适用对低延时有高要求的应用。比如,响应事件的桌面应用,响应请求的Web服务器,或者响应查询的数据库。

命令行选项

开启命令:

-XX:+UseConcMarkSweepGC

设置线程数:

-XX:ParallelCMSThreads=<n>

这里给个完整的例子:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseConcMarkSweepGC -XX:ParallelCMSThreads=2 -jar Java2demo.jar

G1收集器

具体的可以查看【浅度渣文】JVM——G1收集器:http://www.dubby.cn/detail.html?id=9059

这里简单描述一下吧,G1在Java 7才出现的,是一个并发的,低延时的,整理收集器。对堆内存管理和之前的收集器都不一样。

命令行选项

开启命令:

-XX:+UseG1GC

完整的例子:

java -Xmx12m -Xms3m -XX:+UseG1GC -jar Java2demo.jar