GC垃圾回收机制

3,375 阅读14分钟

前言

对于java开发者而言如果想要java应用能在性能方面有所提升,那么就必须要对jvm进行调优

jvm调优是一个没有明确定义正确方式的知识体系,因为每个程序的侧重点不同,只有不断地根据自身程序运行情况去尝试调优才能正确找到最合适当前程序的调优方式

哈哈!读者可能感觉在扯犊子,没有明确定义那得怎么调呢?难道自己乱调吗?

当然不是一无所知的去调优,前提是开发者需要了解类加载机制GC回垃圾收机制JVM的配置参数

只有了解java程序运行底层逻辑,才能更准确地判断出当前程序需要配置什么样的参数配置后会达到什么效果.这也是为什么很多java程序员在面试过程中经常会被问到有没有了解过jvm的原因,不是在造火箭而是基本功

垃圾回收机制所有的信息和资料都来自于oracle对外开放的一些文档,这也是为什么大家不管在哪里看到垃圾回收机制基本上都差不多的原因

本文将详细阐述垃圾回收机制中

  • 怎么去标记垃圾?
  • 怎么去进行的垃圾收集?
  • 进行垃圾收集的落地实现有哪些垃圾收集器?
  • 这些收集器的区别又是什么?

自动垃圾收集

自动垃圾收集是查看堆内存,识别正在使用哪些对象哪些对象未被删除删除未使用对象的过程,使用中的对象或引用的对象意味着程序的某些部分仍然维护指向该对象的指针,程序的任何部分都不再引用未使用的对象未引用的对象,因此可以回收未引用对象占用的内存

像c这样的编程语言分配和释放内存是一个手动过程可是在java中解除分配内存的过程由垃圾收集器自动处理

如何确定内存需要被回收

该过程为标记.这是垃圾收集器识别哪些内存正在使用而哪些不在使用的地方

如何标记

对象回收

引用计数

引用计数的基本实现思路是,一个对象加个计数器当用时+1用完-1,最终回收计数为0的对象.但是这种实现方式有个问题,叫循环引用.假设A对象引用了B对象,B对象也引用了A对象。GC在做回收的时候就会觉得两个都不能回收,因为都两个对象都存在引用.

回收A时候发现B在引用A所以不能回收.回收B时发现A在引用B也不能回收,所以产生了循环引用的概念

可达性分析

简单来说,将对象及其引用看作一个关系图.选定活动对象为GC Roots; 然后跟踪引用链条,如果一个对象和GC Roots之间不可达,也就是不存在引用,那么既可认为是可回收对象.

jvm在做垃圾回收的时候首先是要标记,找出来所有可以作为GC Root的对象.全部找完之后,沿着每个GC Root一个个顺藤摸瓜地往下找,会把所有正在使用的全部标记下来.标记完成之后对于那些没有标记为正在使用的就会被回收掉了

所谓的root就是一个根,如果根都要被回收了,那么和这个根相关联的一些对象都会被回收.也就是说一个对象在任意地方都找不到它,那么可以认为他可以被回收了

可以作为GC Root的对象

  • 虚拟机栈中正在引用的对象
  • 本地方法栈用正在引用的对象
  • 静态属性引用的对象
  • 方法区常量引用的对象

引用类型和可达性级别

关于这个可达性算法中还提到一个概念叫引用,在java中这个引用还有蛮多种的

对于可达性分析算法除了要理解GC Root的概念之外还要理解引用类型和可达性级别

引用类型

引用类型 描述
强引用 StrongReference 最常见的普通对象引用,只要还有强引用指向一个对象,就不会回收
软引用 SoftReference JVM认为内存不足时,才会去试图回收软引用指向的对象(缓存场景)
弱引用 WeakReference 虽然是引用,但随时可能被回收掉
虚引用 PhantomReference 不能通过它访问对象.提供了对象被finallize以后,执行指定逻辑的机制(Cleaner)

可达性级别

可达性级别 描述
强可达 Strongly Reachable 一个对象可以有一个或多个线程可以不通过各种引用访问到的情况
软可达 Softly Reachable 就是当我们只能通过软引用才能访问到对象的状态
弱可达 Weakly Reachable 只能通过弱引用访问时的状态.当弱引用被清除的时候,就符合销毁条件
幻象可达 Phantom Reachable 不存在其他引用,并且finallize过了,只有幻象引用指向这个对象
不可达 unreachable 意味着对象可以被清除了

小结

可达性算法就是找到一个能够用来作为入口的对象(这种对象一般存在常量和栈里面),然后通过这个对象顺藤摸瓜的找到所有正在被使用的对象并且标记出来.标记完成以后就确定了哪些对象不可达既哪些对象可回收

方法区回收

在类加载器被回收时就会卸载存放在方法区里面的类和类元素信息.

垃圾收集算法

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

首先识别出所有要回收的对象,然后进行清除

标记、清除过程效率有限,有内存碎片化问题,不合适特别大的堆

其他收集算法基本基于标记-清除的思路进行改进

内存碎片指的是内存占用不连续例如图上这块内存区域.如果按照每个绿色小内存块为8个字节,那么整个内存区域总共32个字节.回收完2和4小内存块以后,1和3内存块不连续,这时候来了一个16字节的对象.因为2和4的位置都是8个字节所以不管怎么放都放不下.可是如果将1和3的位置连续起来这个内存区域就还能放下16字节的对象

复制(Copying)算法

划分两块同等大小的区域,收集时将活着的对象复制到另一块区域.

拷贝过程中将对象顺序存放,就可以避免内存碎片化.弊端是复制+预留内存会造成一定的内存浪费

复制到新的内存区域过程中是按顺序放入,所以复制算法解决了内存碎片的问题

弊端也很明显,要预留一块内存作为存活对象的存放区域.如果说程序运行需要8G的内存,为了避免内存碎片的问题又得加多8G内存用来作复制算法,那就太浪费了

标记-整理(Mark-Compact)算法

类似于标记-清除,但为了避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间

本质上来说和标记-清除算法是差不多的,但是它多了一个整理的步骤,主要目的也是为了避免内存碎片的问题了

分代收集

每一种算法它的优缺点都是不同的,那么我们在做垃圾回收的时候到底用的是什么算法呢?其实JVM早就帮我们想到这个问题了,既然说垃圾收集算法有好有坏.那么就根据对象的存活周期,将划分为几个区域,不同区域采用合适的垃圾收集算法,这就是分代收集.

在HotStop中主要分成了两个区域新生代和老年代

新生代又划分为三个区域

  • Eden区 (用于存放新对象)
  • Survivor0区(用于保存在Eden区中经过垃圾回收后没有被回收的对象)
  • Survivor1区(用于保存在Survivor0区中经过垃圾回收后没有被回收的对象)

分代区域默认内存占比

  • 新生代 S0:S1:Eden -> 1:1:8
  • 新生代:老年代 -> 2:8

新对象会分配到Eden区,如果超过-XX:PretenureSizeThreshold:设置大对象直接进入老年代的阈值

如下图

分代回收很重要的点就是要能够理解到每一个分代的原因,分代以后GC是怎么对各个代进行回收的

通过上面这幅图可以理解所谓的分代收集以及不同算法的使用,针对于不同的区域采取不同的算法,从而达到最高的执行效率.新生代使用的是复制算法而老年代使用的是标记整理算法

因为在新生代中一个对象的存活时间不会太长基本上是一个线程开始到结束的时间,所以采用复制算法

而老年代的对象存活的时间比较长甚至可能比较大,为了避免内存消耗,所以使用标记整理算法

垃圾收集器

对于上面讲述的是关于垃圾回收的标记垃圾收集的算法,那么接下来就讲一下垃圾收集具体的执行者垃圾回收器

java中有很多的垃圾回收器,每种回收器的实现都不一样,主要体现在垃圾收集算法、并行串行、GC停顿时间

串行收集器

单个线程来执行所有垃圾收集工作,比较适合单处理机器,Client模式下JVM默认的收集器

Serial GC

参数设置为 : -XX:+UseSerialGC

新生代的串行收集器,它采用了复制算法

Serial old

参数设置为 : -XX:+SerialOldGC

老年代的串行收集器,它采用了标记-整理算法,区别于新生代的复制算法

概述

绿色代表的是用户执行的线程,红色代表的是GC收集器的线程.它是启动一个线程去做一些标记、整理、清除的事情.当GC在工作的时候有一个很重要的点是stop-the-world(停止所有的事情),将用户的线程停掉,等GC线程做完了垃圾回收相关工作之后再跑用户的代码,不管是新生代和老年代都会存在这个问题.

串行收集器无法做到一边清除垃圾一边生产垃圾,这个概念其实也很好理解,用户线程执行时不停地产生对象而执行完又释放很多对象.由于串行收集器的GC线程只有一个线程所以只能先停掉用户的线程,等它完成了垃圾收集的工作后再执行用户的线程.就好比如我一边在打扫垃圾有人在另一边不停地扔垃圾,这个工作永远都无法做完一样.不过这种收集器使用的比较少,一般只有客户端才会去使用,服务端在多核CPU的情况下是不会采用这种收集器的

关于GC优化一个很大的优化点就是如何减少GC工作的时间,让程序更快一点.因为在GC回收的时候我们的程序是会有延时的,如果说延时很长整个程序的响应速度就会很慢,用户体验就会很差

并行收集器

Server模式JVM的默认GC选择,整体算法和Serial比较相似,区别是新生代和老年代GC都是多线程并行进行

Parallel GC

参数设置为 : -XX:+UseParallelGC

新生代的并行收集器,它采用了复制算法

ParNew GC

参数设置为 : -XX:+UseParNewGC

新生代的并行收集器,它采用了复制算法,实际上是Serial GC的多线程版本

最常见的应用场景是配合老年代的CMS工作

Parallel Old GC

参数设置为 : -XX:+UseParallelOldGC

老年代的并行收集器,它采用了标记-整理算法

概述

并行收集器也称为吞吐量优先的GC: 吞吐量 = 代码运行时间 / (代码运行时间+GC时间)

可以设置GC时间或吞吐量等值,可以自动进行适应性调整Eden,Survivor大小、MaxTenuringThreshold的值

该收集器常用参数设置

-XX:ParallelGCThreads:设置用于垃圾回收的线程数.通常情况下可以和CPU数量相等;
-XX:MaxGCPauseMills:设置最大垃圾收集停顿时间.它的值是一个大于0的整数;
-XX:GCTimeRatio:设置吞吐量大小,它的值是一个0100之间的整数;
-XX:+UseAdaptiveSizePolicy:打开自适应GC策略.以达到在堆大小、吞吐量和停顿时间直接的平衡点;

并行收集器的特点就是将串行收集器单线程处理变成了多线程处理

但是尽管在并行收集的情况下相应的一些停止时间stop-the-world还是存在,也就是说当GC在工作的时候我们的用户线程仍然还是无法访问.当然一般来说这个时间是很短的,用户几乎感觉不到

并发收集器

前面提到的关于串行并行收集器都会存在一个stop-the-world(停止执行)的问题.如果GC停顿的时间很长对于整个应用来说都是不友好的.JVM团队也是不断去寻找和不断的在优化,来采取各种各样的实现方式

CMS (Concurrent Mark Sweep)

参数设置为 : -XX:+UseConcMarkSweepGC

专用于老年代,基于标记-清除算法,设计目的是尽量减少停顿时间

因为它尝试着去和用户线程一起执行,减少了停顿时间,这一点对于互联网等对时间敏感的系统非常重要,一直到今天,仍然有很多系统使用CMS

CMS工作的时候并不是每一个步骤都会和用户线程并发执行,它也会有stop-the-world的情况出现,主要在初始化标记和重新标记的时候会有GC停顿.但是在并发标记和并发清除的时候是和用户线程并发执行的

因为采用的是标记-清除算法,存在着内存碎片化问题,长时间运行的情况下发生FullGC,导致恶劣的停顿

CMS会占用更多的CPU资源去和用户线程争抢

CMS由于在整个GC工作效果以及算法已经没有太大的可调整空间,所以oracle团队的在jdk8中官宣不再维护

G1

G1收集器在java7中开始投入使用,它是一个针对大堆内存设计的收集器,兼顾吞吐量和停顿时间,JDK9后为默认选型目的是替代CMS

G1将堆分成固定大小的区域(Region)每个区域可以是老年代或新生代

Region之间是复制算法,但实际整体上可看作是标记-整理算法,可以有效地避免内存碎片

左图

内存整体布局

  • 红色新生代(Eden和Surivor)
  • 蓝色老年代
  • 找不到大内存时执行FullGC

右图

GC执行阶段.每一个圆圈代表一次暂停stop-the-world

  • 黄色是标记
  • 蓝色是新生代收集
  • 红色是混合收集 (除了垃圾回收之外它还对这个空间进行整理)

当java的堆特别大的时候G1的优势就更加明显了,因为它将堆拆分成了很多个小区域对每一个区域单独去做管理

所以对于系统会有更好的提升.整体来说它的吞吐量的停顿时间表现都不错

垃圾回收器组合

一共介绍了有七种垃圾回收器,有作用于新生代或老年代,也有G1这种两种都可以兼容的

JDK默认组合是并行收集器(ParallelScavenge+Parallel Old)

关于垃圾收集器更多的是选择默认,如果有更改的必要才会去选择其他的收集器以及调整收集器里面的参数