JVM总结之垃圾回收

垃圾回收

一、java堆内存的细分

1、分代收集算法

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为 新生代(Eden 区、Survivor From 区和 Survivor To 区)和老年代 。。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
在这里插入图片描述

2、新生代

新生代是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。

2.1、Eden 区

Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。

2.2、Survivor From 区

上一次 GC 的幸存者,作为这一次 GC 的被扫描者。

2.2、Survivor To 区

保留了一次 MinorGC 过程中的幸存者。

3、老年代

老年代主要存放应用程序中生命周期长的内存对象。

老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。

4、元数据

在 Java8 中, 永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代 。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于: 元空间并不在虚拟机中,而是使用本地内存 。因此,默认情况下,元空间的大小仅受本地内存限制。 类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中 ,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。

5、对象分配

  • 优先在Eden区分配。当Eden区没有足够空间分配时, VM发起一次Minor GC, 将Eden区和其中一块Survivor区内尚存活的对象放入另一块Survivor区域。如Minor GC时survivor空间不够,对象提前进入老年代,老年代空间不够时进行Full GC;
  • 大对象直接进入老年代,避免在Eden区和Survivor区之间产生大量的内存复制, 此外大对象容易导致还有不少空闲内存就提前触发GC以获取足够的连续空间.

6、对象晋级

  • 年龄阈值: VM为每个对象定义了一个对象年龄(Age)计数器, 经第一次Minor GC后仍然存活, 被移动到Survivor空间中, 并将年龄设为1. 以后对象在Survivor区中每熬过一次Minor GC年龄就+1. 当增加到一定程度(-XX:MaxTenuringThreshold, 默认15), 将会晋升到老年代.
  • 提前晋升: 动态年龄判定;如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代, 而无须等到晋升年龄.

二、垃圾回收

1、怎么定位垃圾

在垃圾回收之前,肯定得先确认哪些是垃圾,这里有两种方法: 引用计数法可达性分析 ;其中java虚拟机用的是可达性分析。

1.1、引用计数法

在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。

1.2、可达性分析

为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。
在这里插入图片描述
在Java, 可作为GC Roots的对象包括:

  1. 方法区: 类静态属性引用的对象;
  2. 方法区: 常量引用的对象;
  3. 虚拟机栈(本地变量表)中引用的对象.
  4. 本地方法栈JNI(Native方法)中引用的对象。

要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。

2、怎么回收垃圾

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

最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图:
在这里插入图片描述
缺点:
效率问题: 标记和清除过程的效率都不高。
空间问题: 标记清除后会产生大量不连续的内存碎片, 空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集。

2.2、复制算法(copying)

为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:
在这里插入图片描述
优点:

  • 由于是每次都对整个半区进行内存回收,内存分配时不必考虑内存碎片问题。
  • 垃圾回收后空间连续,只要移动堆顶指针,按顺序分配内存即可,实现简单,内存效率高;
  • 特别适合java朝生夕死的对象特点;

缺点:

  • 内存减少为原来的一半,太浪费了;
  • 对象存活率较高的时候就要执行较多的复制操作,效率变低;
  • 如果不使用50%的对分策略,老年代需要考虑的空间担保策略

目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

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

结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:
在这里插入图片描述
优点:

  • 不会损失50%的空间;
  • 垃圾回收后空间连续,只要移动堆顶指针,按顺序分配内存即可;
  • 比较适合有大量存活对象的垃圾回收;

缺点:

  • 标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。

老年代因为每次只回收少量对象,因而采用 标记整理算法(Mark-Compact)。

  1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。
  2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况(对象较大)会直接分配到老生代。
  3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
  4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
  5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
  6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中。

3、三色标记算法

  • 白色:未被标记的对象。
  • 灰色:自身被标记,成员变量未被标记。
  • 黑色:自身和成员变量均已标记完成
3.1、漏标

漏标是指,本来是live object,但是由于没有遍历到,被当成garbage回收掉了。

产生漏标:

  1. 标记进行时增加了一个黑到白的引用,如果不重新对黑色进行处理,则会漏标。
  2. 标记进行时删除了灰对象到白对象的引用,那么这个白对象有可能被漏标。

解决漏标的方法:

  1. incremental update – 增量更新,关注引用的增加,把黑色重新标记为灰色,下次重新扫描属性,该方案呗CMS使用
  2. SATB snapshot at the beginning – 关注引用的删除,当灰色 → 白色消失时,要把这个引用推到GC的堆栈,保证该白色对象还能被GC扫描到,该方案呗G1使用

三、垃圾回收器

由于新生代和老年代的特点不同,所以会用不同的垃圾回收器去回收,而不同的垃圾回收器用的算法不尽相同,且一般会用一个新生代垃圾回收器配合一个老年代垃圾回收器进行垃圾回收。但是G1回收器比较牛逼,他可以直接回收新生代和老年代,算法也和其他回收器不太一样,会是以后主流的垃圾回收器。不同垃圾回收器可以搭配使用的关系如下:
在这里插入图片描述
新生代垃圾回收器:

收集器收集对象和算法收集器类型说明使用场景
Serial新生代,复制算法单线程进行垃圾收集时,必须暂停所有工作线程,直到完成;(stop the world)简单高效;适合内存不大的情况;
ParNew新生代,复制算法并行的多线程收集器ParNew垃圾收集器是Serial收集器的多线程版本搭配CMS垃圾回收器的首选
Parallel Scavenge吞吐量优先收集器新生代,复制算法并行的多线程收集器类似ParNew,更加关注吞吐量,达到一个可控制的吞吐量;本身是Server级别多CPU机器上的默认GC方式,主要适合后台运算不需要太多交互的任务;

注:
吞吐量=运行用户代码时间/(运行用户代码时间+ 垃圾收集时间)
垃圾收集时间= 垃圾回收频率 * 单次垃圾回收时间

老年代垃圾回收器:

收集器收集对象和算法收集器类型说明使用场景
Serial Old老年代,标记整理算法单线程jdk7/8默认的老生代垃圾回收器Client模式下虚拟机使用
Parallel Old老年代,标记整理算法并行的多线程收集器Parallel Scavenge收集器的老年代版本,为了配合Parallel Scavenge的面向吞吐量的特性而开发的对应组合;在注重吞吐量以及CPU资源敏感的场合采用
CMS老年代,标记清除算法并行的多线程收集器尽可能的缩短垃圾收集时用户线程停止时间;缺点在于:1.内存碎片 2.需要更多cpu资源 3.浮动垃圾问题,需要更大的堆空间重视服务的响应速度、系统停顿时间和用户体验的互联网网站或者B/S系统。互联网后端目前cms是主流的垃圾回收器;
G1跨新生代和老年代;标记整理 + 化整为零并行的多线程收集器JDK1.7才正式引入,采用分区回收的思维,基本不牺牲吞吐量的前提下完成低停顿的内存回收;可预测的停顿是其最大的优势;面向服务端应用的垃圾回收器,目标为取代CMS

1、CMS

在这里插入图片描述
cms用的是标记清除算法(Mark-Sweep),cms的垃圾回收过程如下:

  1. 初试标记:标记一下GC Roots 能直接关联的对象,这个过程会stop the world,但是耗时不会太多。
  2. 并发标记:用多线程标记剩下的对象是否存活,这个过程耗时相对较长,但是业务线程可以同时运行,所以影响不大。
  3. 重新标记:重新修改出在并发标记阶段产生的垃圾,这个过程也会stop the world,由于数据较少,所以耗时也较少。
  4. 并发清理:清理标记出的垃圾,业务线程可同时运行,这一阶段产生的垃圾会到下一次垃圾回收再处理。

由于cms采用的是标记清除算法(Mark-Sweep),会产生内存碎片,碎片到达一定程度,CMS的老年代分配对象分配不下的时候,使用SerialOld 进行老年代回收。

由于cms问题较多,任何一个版本的jvm都没有把cms设置为默认的垃圾回收器,并且在新的版本中已经把cms舍弃掉了,但是cms为G1的推出有重要作用,了解CMS可以帮助我们更好的理解G1。

2、G1(Garbage First)

G1是一种服务端应用使用的垃圾收集器,目标是用在多核、大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间,同
时还能保持较高的吞吐量。

Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收集器两个最突出的改进是:

  1. 基于标记-整理算法并发收集,不产生内存碎片。
  2. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
2.1、G1垃圾回收算法与过程

G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。
在这里插入图片描述
每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。

年轻代、幸存区、老年代这些概念还存在,成为逻辑上的概念,这样方便复用之前分代框架的逻辑。在物理上不需要连续,则带来了额外的好处——有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾,这也就是G1名字的由来,即首先收集垃圾最多的分区。

新生代其实并不是适用于这种算法的,依然是在新生代满了的时候,对整个新生代进行回收——整个新生代中的对象,要么被回收、要么晋升,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。

G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。每个分区的大小从1M到32M不等,但是都是2的冥次方。

2.2、CSet

CSet = Collection Set
一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。

2.3、RSet

RSet = RememberedSet
记录了其他Region中的对象到本Region的引用,RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。

由于RSet 的存在,那么每次给对象赋引用的时候,就得做一些额外的操作,这个额外的操作指的是在RSet中做一些额外的记录,这在GC中被称为写屏障,注意这个写屏障 不等于 内存屏障。

2.4、YGC和MixedGC

YGC:当老年代小于InitiatingHeapOccupacyPercent(默认值45%)设置的比例时,采用的是YGC模式,配合CSet用复制算法、会stop the world。

MixedGC:当老年代超过InitiatingHeapOccupacyPercent(默认值45%)设置的比例时,启动MixedGC(混合回收),步骤如下:

  1. 初始标记 STW
  2. 并发标记
  3. 最终标记 STW (重新标记)
  4. 筛选回收 STW (并行)
2.5、G1的Full GC

G1是有可能出现Full GC的,当old对象空间不足时就会出现。但是正常情况不能让G1出现Full GC,如果出现我们就要做优化了,可以从以下方面入手:

1. 扩内存
2. 提高CPU性能(回收的快,业务逻辑产生对象的速度固定,垃圾回收越快,内存空间越大)
3. 降低MixedGC触发的阈值,让MixedGC提早发生(默认是45%)

java 10以前是串行FullGC,之后是并行FullGC。

2.6、什么G1用SATB

灰色 → 白色 引用消失时,如果没有黑色指向白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要
扫描整个堆去查找指向白色的引用,效率比较高,SATB 配合 RSet ,浑然天成。

3、ZGC

ZGC是jdk11开始引入的,使用参数:-XX:+UseZGC ,通过尽量并行和内存屏障来减少stop the world,去掉了G1占内存的RememeredSet。

设计目标

  1. 支持TB级别(4T,据说已经扩展到16T)
  2. 最大GC停顿10ms
  3. 内存增大,停顿时间不长
  4. throughput不超过15%的影响,– SPECjbb 2015基准测试,128G堆内存,单次GC停顿最大1.68ms, 平均1.09ms

垃圾回收默认配置及互联网后台推荐配置

  • 在JVM的客户端模式(Client)下,JVM默认垃圾收集器是串行垃圾收集器(Serial GC + Serial Old,-XX:+USeSerialGC);
  • 在JVM服务器模式(Server)下默认垃圾收集器是并行垃圾收集器(Parallel Scavaenge +Serial Old,-XX:+UseParallelGC)
  • 而适用于Server模式下
    1. ParNew + CMS + SerialOld(失败担保),-XX:UseConcMarkSweepGC;
    2. Parallel scavenge + Parallel,-XX:UseParallelOldGC

1.4版本后期引入CMS,1.5、1.6开始流行,1.7引入G1,1.8如果内存比较大推荐G1。

四、java四种引用类型

1、强引用

在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。

2、软引用

软引用是用来描述一些还有用但并非必须的对象,软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收,如果回收了软引用内存还不够使用,就会抛出内存溢出异常。软引用通常用在对内存敏感的程序中,例如可以用于缓存数据,但实际用得较少,因为缓存一般都用内存数据库。
eg:

public class T02_SoftReference {
    public static void main(String[] args) {
        SoftReference<byte[]> m = new SoftReference<>(new byte[1024*1024*10]);
        //m = null;
        System.out.println(m.get());
        System.gc();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(m.get());

        //再分配一个数组,heap将装不下,这时候系统会垃圾回收,先回收一次,如果不够,会把软引用干掉
        byte[] b = new byte[1024*1024*15];
        System.out.println(m.get());
    }
}

3、弱引用

弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存,一般用于容器中,容器中的对象如果没有在其他地否引用,对于某些容器来说,这些对象就永远不会被用到,例如ThreadLocal,这种类型的容器里面的对象,一般是用弱引用指向对象的内存地址,并且该对象在容器外的其他地方还有用,当容器外的其他地方都没有指向该对象的内存地址时,gc回收时,该对象就会被回收,但只要容器外还有一个对象指向该地址,就不会被回收。

4、虚引用

虚引用需要 PhantomReference 类来实现,虚引用储存在堆外内存中,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。

当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。


import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.util.LinkedList;
import java.util.List;

public class T04_PhantomReference {
    private static final List<Object> LIST = new LinkedList<>();
    private static final ReferenceQueue<M> QUEUE = new ReferenceQueue<>();



    public static void main(String[] args) {


        PhantomReference<M> phantomReference = new PhantomReference<>(new M(), QUEUE);


        new Thread(() -> {
            while (true) {
                LIST.add(new byte[1024 * 1024]);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    Thread.currentThread().interrupt();
                }
                System.out.println(phantomReference.get());
            }
        }).start();

        new Thread(() -> {
            while (true) {
                Reference<? extends M> poll = QUEUE.poll();
                if (poll != null) {
                    System.out.println("--- 虚引用对象被jvm回收了 ---- " + poll);
                }
            }
        }).start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}

常见内存溢出溢出问题

java内存溢出异常主要有两个:

  1. OutOfMemeoryError:当堆、栈(多线程情况)、方法区、元数据区、直接内存中数据达到最大容量时产生;
  2. StackOverFlowError:如果线程请求的栈深度大于虚拟机锁允许的最大深度,将抛出StackOverFlowError,其本质还是数据达到最大容量;

一、堆溢出

1、产生原因

堆用于存储实例对象,只要不断创建对象,并且保证GC Roots到对象之间有引用的可达,避免垃圾收集器回收实例对象,就会在对象数量达到堆最大容量时产生OutOfMemoryError异常。 java.lang.OutOfMemoryError: Java heap space

2、解决方法

使用-XX:+HeapDumpOnOutOfMemoryError可以让java虚拟机在出现内存溢出时产生当前堆内存快照以便进行异常分析,主要分析那些对象占用了内存;也可使用jmap将内存快照导出;一般检查哪些对象占用空间比较大,由此判断代码问题,没有问题的考虑调整堆参数;

二、栈溢出

1、产生原因

  1. 如果线程请求的栈深度大于虚拟机锁允许的最大深度,将抛出StackOverFlowError;
  2. 如果虚拟机在扩展栈时无法申请到足够的内存空间,抛出OutOfMemeoryError;

2、解决办法

  • StackOverFlowError 一般是函数调用层级过多导致,比如死递归、死循环,避免这种情况的发生;
  • OutOfMemeoryError一般是在多线程环境才会产生,一般用“减少内存的方法”,既减少最大堆和减少栈容量来换取更多的线程支持;

三、方法区或元数据区溢出

1、产生原因

  1. jdk 1.6以前,运行时常量池还是方法区一部分,当常量池满了以后(主要是字符串变量),会抛出OOM异常;
  2. 方法区和元数据区还会用于存放class的相关信息,如:类名、访问修饰符、常量池、方法、静态变量等;当工程中类比较多,而方法区或者元数据区太小,在启动的时候,也容易抛出OOM异常;

2、解决办法

  • jdk 1.7之前,通过-XX:PermSize,-XX:MaxPerSize,调整方法区的大小;
  • jdk 1.8以后,通过-XX:MetaspaceSize ,-XX:MaxMetaspaceSize,调整元数据区的大小;

四、本机直接内存溢出

1、产生原因

jdk本身很少操作直接内存,而直接内存(DirectMemory)导致溢出最大的特征是,Heap Dump文件不会看到明显异常,而程序中直接或者间接的用到了NIO;

2、解决办法

直接内存不受java堆大小限制,但受本机总内存的限制,可以通过MaxDirectMemorySize来设置(默认与堆内存最大值一样)

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值