少年击剑更吹箫,剑气箫心一例消。这篇文章主要讲述jvm专题 - 1/3GC基础相关的知识,希望能为你提供帮助。
此章笔者会多写点,分三个子专题来讲述:理论基础、实操、工具使用。目的是争取使读者一次性弄懂。不需要再反反复复的查各种资料,笔者也会把之前踩过的坑也详细描述下,防止读者再走笔者的弯路。一、GC基础笔者看过很多文章,在面试时也问过很多候选人,发现很多人对GC的知识点并没有很清晰的总结和归类,比如会把垃圾回收算法和垃圾器类型搞混、不了解引用类型等相关知识(虽然很少用到),所以本节还是先总结一下本章的全貌,读者从逻辑上理清GC的相关知识:
牢记:可被GC管理的内存区只有堆和方法区,其它3个私有区都会随着线程终止而释放。
GC又可以称为堆收集器,它作用在堆上。除了对运行期间产生新对象经过判断引用后释放所占的内存空间,还要处理堆碎块。如果不处理即使堆有足够的空间也可能因为没有连续的空间放下新对象而溢出。GC一般为分为两步来操作:1、标记;2、释放。
GC是通过对象的finalize()终结方法来释放内存的,扩展此方法可以做一些清理工作。JVM的内存是分区的,GC可以按区来回收,否则一次GC可能会造成比较长时间的程序暂停时间(称为STW stop-the-world)。
标记整理有时也称为标记压缩,本章节会使用标记整理这个术语。二、引用对象引用对象用于封装指向其它对象的连接,其实就是把局部/类变量包装一下,以便后绪的清理工作。被指向的目标称为引用目标。其中弱引用的三种类型都是java.lang.ref.Reference的子类。
要创建一个弱类型的引用,可以把强引用传递到对应的引用对象的构造方法中去。比如要创建对某个A对象的软引用,就把此对象传递到java.lang.ref.Reference的构造方法中。用java.lang.ref.Reference做桥接就可以维护java.lang.ref.Reference的强引用,也维护了某个A对象的软引用。如果想切断这个软引用,可以调用java.lang.ref.Reference的clear()方法。
- 强引用:在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之 一。
- 软引用:软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
- 弱引用:弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
- 虚引用:又称影子引用,虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。?
jvm会维护一个java.lang.ref.ReferenceQueue的引用队列,当垃圾回收器改变了对象的触及状态时,可监听此队列进行一些异步操作,这个监听器方法是由java.lang.ref.Reference定义的poll、remove等。通过把引用对象作为构造方法的参数传递到引用队列,实现队列和引用对象的关联。弱引用还需要注意gc在对象状态改变时加入队列前的清理策略。下图是引用类型的一个状态改变过程:
- 强可触及:局部变量,即对象只可以从根节点而不能通过其它任何引用对象搜索到。只要根节点存在一个外部引用,那么这个局部变量类型的强可触及对象就不会被回收掉;
- 软可触及:对象可以从除了根节点以外的途径(未被清除掉)被搜索到,有可能被GC掉,如果GC了则会清除所有关联引用;清除掉的同时把该软引用对象放入队列java.lang.ref.ReferenceQueue的实例中。简单来说当有足够内存时就不管了,当没有足够内存时就回收了,对内存占用比较敏感
- 弱可触及:一定会被GC掉,如果GC了则会清除所有关联引用;清除掉的同时把该弱引用对象放入队列java.lang.ref.ReferenceQueue的实例中;
- 可复活的:没有任何外部引用,但还没有被GC,一般可通过finalize方法再次复活,这也是gc要进行两次扫描的原因;
- 虚可触及:已下确定此对象不会被任何对象引用(这有区别于不可触及),即将被回收。但是如果被复活。此类对象不会被GC掉,需要程序显示的释放,同时把该影子引用对象放入队列java.lang.ref.ReferenceQueue的实例中;因为需要手动清除;
- 不可触及:必被回收,并且一旦对象达到这个状态后其状态不可更改,只能被GC;
- 软引用:适合创建内存中的缓存的场景,它与程序整体内存情况有关;比方说从外部读取的文件内容,只要内存足够多,jvm都可以选择在堆中缓存而不清除,所以对于这类应用应该把堆设置的大一点,判断后再回收
- 弱引用:适合创建映射,比如hash映射,当程序中不再引用相关映射时就可以释放了,发现即回收;
- 虚引用:适合实现在终结方法finalize()之后的比较复杂的临终清理和动作,因其不可被GC,所以需要显示的调用clear()方法清除引用对象,把影子可触及状态转变为不可触及状态,跟踪后再回收
3.1、引用计数收集算法
在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关 联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。
原理就是:为堆中的每一个对象创建一个计数器,解析到引用则计数+1,当一个对象的生存期或被设置为一个新值时,计数-1。当计数为0时就可以标记为垃圾,当它被回收时,它引用的任何对象的计数都-1。这样就有可能形成连续回收的机制。
- 优点:执行快;
- 缺点:无法处理循环依赖引用,所以在jvm中没有采用这种算法
又称标记并清除算法,从对象的根节点,创建一张对象引用图,然后追踪打标,当追踪结束时,未被打标的可认为可被回收。不可达对象变为可回收对象至少要经过两次标记过程。为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。
要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。
3.3、*分区收集算法
这不是一个具体的算法,可以理解为上述算法的优化,叫分区收集,使用多种算法同时并存,根据配置等自由切换。把堆按对象存活周期分为多个子堆。优先频繁回收young,因这个子堆可多数为临时变量。在一起收集后,对于依然存活的对象需要重新分配子堆。
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间, 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次 GC 所产生的停顿。在CMS和G1垃圾回收器中有有具体的体现。
四、垃圾回收算法4.1、标记清除
最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图:
从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可 利用空间的问题。
4.2、复制
为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:把堆设置为双倍大小,一半空闲,一半使用。当使用块被占满时,中止程序运行,把块内容移动到空闲块的连续区域,同时在使用区保留转向指标;然后再使用空闲区直到填满。再移动。这种算法应用在了新手代eden, from, to的内存上,其中from和to区大小相同,功能可以互换,这两个区又 称为幸存区。
这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
4.3、标记整理
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:
这种算法多了一个移动的过程,其效率肯定不如上述两种算法快,同样也并不是说一概可以采用此种算法的垃圾回收器,还是需要综合服务器和应用情况择择选择。
4.4、*分代
这个分代和上面提到的分区是一个概念,它不是一个具体的算法,可以理解为上述算法的优化版本,分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存 划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃 圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。如下所示:
4.4.1、新生代与复制算法目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要 回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代 划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另 一块 Survivor 空间中。
4.4.2、老年代与标记复制算法而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。
- JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。
- 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。
- 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
- 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
- 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
- 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被 移到老生代中。
5.1、新生代
5.1.1、Serial 垃圾收集器(单线程、复制算法)Serial(英文连续)是最基本垃圾收集器,使用复制算法,曾经是 JDK1.3.1 之前新生代唯一的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个CPU或单一线程去完成垃圾收集工 作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。
Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
5.1.2、ParNew垃圾收集器(Serial+多线程)ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃 圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也 要暂停所有其他的工作线程。ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。ParNew 虽然是除了多线程外和 Serial 收集器几乎完全一样,它也是 ParNew 垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。
5.1.3、Parallel Scavenge 收集器(多线程复制算法、高效)Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码 的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)), 高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个 重要区别。
5.2、老年代
先了解一个术语:
- Stop The World:GC 过程中分析对象引用关系,为了保证分析结果的准确性,需要通过停顿所有 Java 执行线程,保证引用关系不再动态变化,该停顿事件称为 Stop The World(STW)。
- 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用;
- 作为年老代中使用 CMS 收集器的后备垃圾收集方案。
新生代 Parallel Scavenge 收集器与 ParNew 收集器工作原理类似,都是多线程的收集器,都使 用的是复制算法,在垃圾收集过程中都需要暂停所有的工作线程。新生代 Parallel Scavenge/ParNew 与年老代 Serial Old 搭配垃圾收集过程图:
5.2.2、ParallelOld收集器(多线程标记整理算法)Parallel Old 收集器是 Parallel Scavenge 的老年代版本,使用多线程的标记-整理算法,在 JDK1.6 才开始提供。在 JDK1.6 之前,新生代使用 ParallelScavenge 收集器只能搭配年老代的 Serial Old 收集器,只 能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge 和年老代 Parallel Old 收集器的搭配策略。新生代 Parallel Scavenge 和年老代 Parallel Old 收集器搭配运行过程图:
5.2.3、CMS收集器(多线程标记清除算法)Concurrent mark sweep(CMS)收集器是一种适用于老年代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程并发标记清除算法,它主要关注系统停顿时间,可以不阻塞应用程序运行的同时回收垃圾。它会在内存达到某个特定的使用阀值时才触发,默认为68%。使用CMSInitiatingOccupancyFraction来指定。但当程序内存增长过快时,CMS有可能失败,这时JVM会自动启动老年代串行回收器直至GC完成,代价是有可能程序中断的时间比较长。所以当内存增长过快时此值在设置的小一些防止回收发生在老年代,它在必要时才会进行一次碎片整理。
UseConcMarkSweepGC:默认线程数为(ParallelGCThreads+3)/ 4;
整个过程如下图所示,分为7个阶段:
- 初始标记(Initial Mark):此阶段的目标是标记老年代中所有存活的对象, 包括 GC Root 的直接引用, 以及由新生代中存活对象所引用的对象,触发第一次 STW 事件。这个过程是支持多线程的(JDK7 之前单线程,JDK8 之后并行,可通过参数 CMSParallelInitialMarkEnabled 调整);
- 并发标记(Concurrent Mark):此阶段 GC 线程和应用线程并发执行,遍历阶段 1 初始标记出来的存活对象,然后继续递归标记这些对象可达的对象;
- 并发预清理(Concurrent Preclean):此阶段 GC 线程和应用线程也是并发执行,因为阶段 2 是与应用线程并发执行,可能有些引用关系已经发生改变。 通过卡片标记(Card Marking),提前把老年代空间逻辑划分为相等大小的区域(Card)。如果引用关系发生改变,JVM 会将发生改变的区域标记为“脏区”(Dirty Card),然后在本阶段,这些脏区会被找出来,刷新引用关系,清除“脏区”标记;?
- 并发可取消的预清理(Concurrent Abortable Preclean):本阶段尝试在 STW 的最终标记阶段(Final Remark)之前尽可能地多做一些工作,以减少应用暂停时间。在该阶段不断循环处理:标记老年代的可达对象、扫描处理 Dirty Card 区域中的对象,循环的终止条件有:1、达到循环次数;2、达到循环执行时间阈值;3、新生代内存使用率达到阈值;?
- 最终标记(Final Remark):这是 GC 事件中第二次(也是最后一次)STW 阶段,目标是完成老年代中所有存活对象的标记。在此阶段执行:1、遍历新生代对象,重新标记;2、根据 GC Roots,重新标记;3、遍历老年代的 Dirty Card,重新标记;?
- 并发清除(Concurrent Sweep):此阶段与应用程序并发执行,不需要 STW 停顿,根据标记结果清除垃圾对象。清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并 发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行;
- 并发重置(Concurrent Reset):此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据, 为下一次 GC 循环做准备;
- 最终标记阶段停顿时间过长问题:CMS 的 GC 停顿时间约 80% 都在最终标记阶段(Final Remark),若该阶段停顿时间过长,常见原因是新生代对老年代的无效引用,在上一阶段的并发可取消预清理阶段中,执行阈值时间内未完成循环,来不及触发 Young GC,清理这些无效引用。通过添加参数:-XX:+CMSScavengeBeforeRemark。但如果在上个阶段(并发可取消的预清理)已触发 Young GC,也会重复触发 Young GC。
- 并发模式失败(concurrent mode failure):当 CMS 在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成单线程的 Full GC。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收。
- 晋升失败:当新生代发生垃圾回收,老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败,此时会触发单线程且带压缩动作的 Full GC。
并发模式失败和晋升失败都会导致长时间的停顿,常见解决思路如下:
降低触发 CMS GC 的阈值。
即参数 -XX:CMSInitiatingOccupancyFraction 的值,让 CMS GC 尽早执行,以保证有足够的空间。
增加 CMS 线程数,即参数 -XX:ConcGCThreads。
增大老年代空间。
让对象尽量在新生代回收,避免进入老年代。
- 内存碎片问题:通常 CMS 的 GC 过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩。可能原因是:1、新生代 Young GC 出现新生代晋升担保失败(promotion failed));2、程序主动执行System.gc()。可通过参数 CMSFullGCsBeforeCompaction 的值,设置多少次 Full GC 触发一次压缩。默认值为 0,代表每次进入 Full GC 都会触发压缩,带压缩动作的算法为上面提到的单线程 Serial Old 算法,暂停时间(STW)时间非常长,需要尽可能减少压缩时间
5.3.1、G1收集器Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收 集器两个最突出的改进是:
- 基于标记-整理算法,不产生内存碎片;
- 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
- 初始标记(Initial Mark):暂停所有应用线程(STW),并发地进行标记从 GC Root 开始直接可达的对象(原生栈对象、全局对象、JNI 对象)。当达到触发条件时,G1 并不会立即发起并发标记周期,而是等待下一次新生代收集,利用新生代收集的 STW 时间段,完成初始标记,这种方式称为借道(Piggybacking);
- 根区域扫描(Root Region Scan):在初始标记暂停结束后,新生代收集也完成的对象复制到 Survivor 的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到 Survivor 分区的对象,需要找出哪些对象存在对老年代对象的引用,把这些对象标记成根(Root)。这个过程称为根分区扫描(Root Region Scanning),同时扫描的 Suvivor 分区也被称为根分区(Root Region)。根分区扫描必须在下一次新生代垃圾收集启动前完成(接下来并发标记的过程中,可能会被若干次新生代垃圾收集打断),因为每次 GC 会产生新的存活对象集合;
- 并发标记(Concurrent Marking):标记线程与应用程序线程并行执行,标记各个堆中 Region 的存活对象信息,这个步骤可能被新的 Young GC 打断。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次新生代收集;
- 再次标记(Remark):和 CMS 类似暂停所有应用线程(STW),以完成标记过程短暂地停止应用线程, 标记在并发标记阶段发生变化的对象,和所有未被标记的存活对象,同时完成存活数据计算;
- 清理(Cleanup):为即将到来的转移阶段做准备, 此阶段也为下一次标记执行所有必需的整理计算工作:
- Full GC 问题:G1 的正常处理流程中没有 Full GC,只有在垃圾回收处理不过来(或者主动触发)时才会出现,G1 的 Full GC 就是单线程执行的 Serial old gc,会导致非常长的 STW,是调优的重点,需要尽量避免 Full GC。
程序主动执行 System.gc()
全局并发标记期间老年代空间被填满(并发模式失败)
Mixed GC 期间老年代空间被填满(晋升失败)
Young GC 时 Survivor 空间和老年代没有足够空间容纳存活对象
类似 CMS,常见的解决是:
增大 -XX:ConcGCThreads=n 选项增加并发标记线程的数量,或者 STW 期间并行线程的数量:-XX:ParallelGCThreads=n。
减小 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
【jvm专题 - 1/3GC基础】增大预留内存 -XX:G1ReservePercent=n,默认值是 10,代表使用 10% 的堆内存为预留内存,当 Survivor 区域没有足够空间容纳新晋升对象时会尝试使用预留内存。
- 巨型对象分配:巨型对象区中的每个 Region 中包含一个巨型对象,剩余空间不再利用,导致空间碎片化,当 G1 没有合适空间分配巨型对象时,G1 会启动串行 Full GC 来释放空间。可以通过增加 -XX:G1HeapRegionSize 来增大 Region 大小,这样一来,相当一部分的巨型对象就不再是巨型对象了,而是采用普通的分配方式。
- 平均响应时间设置:使用应用的平均响应时间作为参考来设置 MaxGCPauseMillis,JVM 会尽量去满足该条件,可能是 90% 的请求或者更多的响应时间在这之内, 但是并不代表是所有的请求都能满足,平均响应时间设置过小会导致频繁 GC。?
推荐阅读
- C++的内存分配问题
- OpenStack Train(业务组件cinder装安装)
- WTL atlApp.h
- Cilium Vxlan 跨节点通信过程
- (程序员面试题精选(02))-设计包含min函数的栈
- # yyds干货盘点 # 厉害了,Python也能使用动态链接库
- SVNX使用教程
- Python 函数进阶-递归函数
- 云原生Docker入门 -- 阿里云服务器Linux环境下安装Docker