跳至主要內容

JVM(十)垃圾回收的基础知识

安图新大约 12 分钟

JVM(十)垃圾回收的基础知识

对象是否存活判断算法:

即判断 JVM 中的所有对象,哪些对象是存活的,哪些对象可回收的算法。

1:引用计数。

最简单的垃圾判断算法。

在对象中添加一个属性用于标记对象被引用的次数,每多一个其他对象引用,计数+1,当引用失效时,计数-1,如果计数=0,表示没有其他对象引用,就可以被回收。

但是这个算法无法解决循环依赖的问题。

 
 

2:可达性分析算法

通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系链向下搜索,如果某个对象无法被搜索到,则说明该对象无引用执行,可回收。相反,则对象处于存活状态,不可回收。

在 Java 中可以作为 GC Roots 的对象包含下面几种:

1:虚拟机栈(栈帧中的本地变量表)中引用的对象。

2:方法区中类静态属性引用的对象。

3:方法区中常量引用的对象。

4:本地方法中 JNI(即一般说的 Native 方法)引用的对象。

可达性分析主要是通过引用来判断的,但是在 java 中引用又分为强引用,软引用,弱引用,虚引用。关于他们的解释,看一看下<深入理解 java 虚拟机>中的解释:

 
 

指针碰撞和空闲列表

指针碰撞:

假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

但是分配内存是并发进行的,多个线程肯定会遇到同时来申请内存的时候,这个时候都想得到这个指针来为自己分配内存,这种情况就是指针碰撞。openJDK 中的做法是采用(cas)自旋锁的方式来获取这个指针。

 
 

空闲列表:

上面说的指针碰撞是针对堆是规整的,但是堆并不是一直是规整的,对于不是规整的堆,采用的是空闲列表的方式分配内存。

虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”

注意!!!!

选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用 Serial、ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。

内存池相关的概念

我们在 Java 中创建对象的时候会占用堆内存,JVM 会给我们一块内存存放这个对象,但是如果每次需要内存的时候用 C++的 malloc,calloc 的会很耗费性能,就想我们的线程频繁创建销毁一样,所以内存池的出现也是和线程池的出现一样,为了更好的管理内存。

1:Memory Pool

管理内存块,不直接持有内存

它里面有一个 List<MemoryChunk *> m_chunks; //是一个列表存放所有向 OS 申请的内存块(Memory Chunk)。

2:Memory Chunk

申请到的内存块还会分成 Memory Cell,这样在给对象分配内存的时候就以 Cell 的倍数分配,JVM 中这个 cell 是 8B,Java 中的对象以 8 字节对齐也是有道理的。

内存池实现的算法逻辑和采用的垃圾回收算法是有关系的。

比如:标记-清除,标记-整理这两种垃圾回收算法回收的是整个 trunk。

这两种算法只需要在内存池中存在两个 List 就可以完成。 list<MemoryCell *> m_available_table; // 所有可使用的内存 。 list<MemoryCell *> m_used_table; //所有被使用的内存

分代+复制算法:假设内存五五分,一半在用,一半空闲。则需要四个 List 了。

list<MemoryCell \*> m_available_table; //所有可使用的内存
list<MemoryCell *> m_used_table; // 所有被使用的内存
list<MemoryCell *> m_idle_table; //空闲内存
list<MemoryCell *> m_transer_table; // 待交换内存

分代-复制算法中的空间交换数据移动之后还是可以使用的

指针在 JVM 中是动态计算出来,切换 From,To 区的时候数据发生移动之后这个数据对应的指针动态计算出来。

垃圾收集器

串行:用户线程 STW,一个 GC 线程运行。

Serial 收集器

相关参数:-XX:+UseSerialGC

Serial Old 收集器

Serial 收集器的老年代版本。基于标记-整理算法实现。它主要给 Client 模式下的虚拟机使用,如果在 Server 模式下,它有两大用途,1)在 JDK1.5 以及之前的版本中与 Prallel 收集器搭配。2)作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 使用。

并行:用户线程 STW, 多个 GC 线程运行

ParNew 收集器是 Serial 多线程的版本。唯一能与 CMS 收集器搭配使用的新生代收集器。

-XX:+UseConcMarkSweepGC:指定使用 CMS 后,会默认使用 ParNew 作为新生代收集器

-XX:+UseParNewGC:强制指定使用 ParNew

-XX:ParallelGCThreads:指定垃圾收集的线程数量,ParNew 默认开启的收集线程与 CPU 的数量相同。

Parallel 收集器,是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,但是它和 ParNew 收集器只是关注的点不同。

它是关注吞吐量的收集器,吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)

相关参数:

  • -XX:MaxGCPauseMillis:是一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集 300MB 新生代肯定比收集 500MB 快吧,这也直接导致垃圾收集发生得更频繁一些,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • -XX:GCTimeRatio:一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率。如果把此参数设置为 19,那允许的最大 GC 时间就占总时间的 5%(即 1 /(1+19)),默认值为 99,就是允许最大 1%(即 1 /(1+99))的垃圾收集时间。
  • -XX:+UseAdaptiveSizePolicy:一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)
Parallel Old 收集器

Parallel 收集器的老年代版本。基于标记-整理算法实现。它是在 JDK1.6 中提供的,在此之前新生代的 Parallel Scavenge 收集器只能搭配 Serial Old 收集器工作,单线程的老年代无法充分利用 CPU 资源。

并发:不需要 STW,用户线程,GC 线程并发运行。

CMS 收集器

聚焦低延迟。基于标记-清除算法实现.

由于 CMS 收集器是并发收集器,即在运行阶段用户线程依然在运行,会产生对象,所以 CMS 收集器不能等到老年代满了才触发,而是要提前触发,这个阈值是 92%。这个阈值可以通过参数-XX:CMSInitiatingOccupancyFraction 设置

使用它,**当内存满了进行垃圾回收,是回收不了的,因为没有地方存储了,而且因为 CMS 是并发的,并发更占用 cpu,所以并发很多的时候可能会出现 100%.。

因为并发处理,所以吞吐量比较高。

相关参数:

-XX:+UseConcMarkSweepGC:手动开启 CMS 收集器

-XX:+CMSIncrementalMode:设置为增量模式

-XX:CMSFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩

-XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收

-XX:UseCMSInitiatingOccupancyOnly:表示只在到达阀值的时候,才进行 CMS 回收

-XX:CMSInitiatingOccupancyFraction:设置 CMS 收集器在老年代空间被使用多少后触发

-XX:+UseCMSCompactAtFullCollection:设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片的整理

CMS 收集器工作分四个步骤:

1、 初始标记;

会 STW。只标记 GC Roots 直接关联的对象。时间很短

2、 并发标记;

不会 STW。GC 线程与用户线程并发运行。

会沿着 GC Roots 直接关联的对象链遍历整个对象图。可想而知需要的时间较长,但因为是与用户线程并发运行的,除了能感知到 CPU 飙升,不会出现卡顿现象。

3、 重新标记;

会 STW。

CMS 垃圾收集器通过写屏障+增量更新记录了并发标记阶段新建立的引用关系,重新标记就是去遍历这个记录。

4、 并发清除;

GC 线程与用户线程并发运行,清理未被标记到的对象

默认启动的回收线程数 = (处理器核心数 + 3) / 4


显然 CMS 收集器依然不是完美的,不然后面就不会出现 G1、ZGC 等。那有哪些缺点呢?

1、 运行期间会与用户线程抢夺 CPU 资源当然,这是所有并发收集器的缺点;

2、 无法处理浮动垃圾(标记结束后创建的对象);

3、 内存碎片;

具体的介绍可以参见<深入理解 Java 虚拟机这本书>

G1 收集器

G1 收集器与之前的所有收集器都不一样,它将堆分成了一个一个 Region,这些 Region 用的时候才被赋予角色:Eden、from、to、humongous。一个 region 只能是一个角色,不存在一个 region 既是 Eden 又是 from。

每个 region 的大小可通过参数-XX:G1HeapRegionSize 设置,取值范围是 2-32M。

一个对象的大小超过 region 的一半则被认定为大对象,会用 N 个连续的 region 来存储。

吞吐量没有 CMS 高

G1 名字的由来:

回收某个 region 的价值大小 = 回收获得的空间大小 + 回收所需时间

G1 收集器会维护一个优先级列表,每个 region 按价值大小排序存放在这个优先级列表中。收集时优先收集价值更大的 region,这就是 G1 名字的由来

 

四个步骤:

1、 初始标记;

会 STW。

做了两件事:

1、 修改 TAMS 的值,TAMS 以上的值为新创建的对象,默认标记为存活对象,即多标;

2、 标记 GCRoots 能直接关联到的对象;

2、 并发标记;

耗时较长。GC 线程与用户线程并发运行。

从 GCroots 能直接关联到的对象开始遍历整个对象图

3、 最终标记;

遍历写屏障+SATB 记录下的旧的引用对象图

4、 筛选回收;

更新 region 的统计数据,对各个 region 的回收价值进行计算并排序,然后根据用户设置的期望暂停时间的期望值生成回收集。

然后开始执行清除操作。将旧的 region 中的存活对象移动到新的 Region 中,清理这个旧的 region。这个阶段需要 STW。

相关参数:

-XX:G1HeapRegionSize:设置 region 的大小

-XX:MaxGCPauseMillis:设置 GC 回收时允许的最大停顿时间(默认 200ms)

-XX:+UseG1GC:开启 g1

-XX:ConcGCThreads:设置并发标记、并发整理的 gc 线程数

-XX:ParallelGCThreads:STW 期间并行执行的 gc 线程数

缺点:

1、 需要 10%-20%的内存来存储 G1 收集器运行需要的数据,如不 cset、rset、卡表等;

2、 运行期间会与用户线程抢夺 CPU 资源当然,这是所有并发收集器的缺点;

查看默认收集器

java -XX:+PrintFlagsFinal -version | grep GC

GC 日志

相关参数:

-XX:+PrintGC 输出 GC 日志

-XX:+PrintGCDetails 输出 GC 的详细日志

-XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)

-XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)

-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息

-Xloggc:../logs/gc.log 日志文件的输出路径

日志内容:

1、 gc 类型:GC、FullGC;

2、 gc 原因:MetadataGCThreshold、Lastditchcollection……;

3、 gc 前内存数据;

4、 gc 后内存数据;

5、 花费的时间:用户态、内核态、实际用时;

比如堆区 gc 日志:

 

 
 

上次编辑于:
贡献者: Andy