参考文档:
JVM内存管理、JVM垃圾回收机制、新生代、老年代以及永久代
【转】Java中的新生代、老年代、永久代和各种GC

0. JVM 知识图谱

image-20220318161542534

1. 线程

这里所说的线程指程序执行过程中的一个线程实体。JVM 允许一个应用并发执行多个线程。

Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。

Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。

Hotspot JVM 后台运行的系统线程主要有下面几个

  1. 虚拟机线程(VM thread)
    这个线程等待 JVM 到达安全点操作出现。以下操作必须要在独立的线程里执行,并且线程都需要 JVM 位于安全点。

    • stop-the world 垃圾回收
    • 线程栈 dump
    • 线程暂停
    • 线程偏向锁(biased locking)解除。
  2. 周期性任务线程
    这线程负责定时器事件(也就是中断),用来调度周期性操作的执行。

  3. GC 线程
    这些线程支持 JVM 中不同的垃圾回收活动。

  4. 编译器线程
    这些线程在运行时将字节码动态编译成本地平台相关的机器码。

  5. 信号分发线程
    这个线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理。

2. 内存模型

image-20220318163620444

jdk8 之前

img

jdk8 之后

HotSpot模型-运行时数据区

2.1 堆

凡是 new 出来的东西,都在堆当中,其大小可以通过 -Xmx-Xms 来控制。

是垃圾收集器进行垃圾收集的最重要的内存区域。

堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 FromSpace 和 ToSpace 组成,结构图如下所示:

img

2.1.1 新生代

新建的对象都是用新生代分配内存,Eden空间不足的时候,会把存活的对象转移到Survivor中。

一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。

新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。

  • Eden 区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收
  • ServivorTo:保留了一次 MinorGC 过程中的幸存者。
  • ServivorFrom:上一次 GC 的幸存者,作为这一次 GC 的被扫描者。

当 JVM 无法为新建对象分配内存空间的时候(Eden 满了),Minor GC被触发。因此新生代空间占用率越高,Minor GC越频繁。

新生代大小可以由 -Xmn 来控制,也可以用 -XX:SurvivorRatio 来控制 Eden 和 Survivor 的比例。

2.1.2 老年代

用于存放新生代中经过多次垃圾回收仍然存活的对象。

老年代的对象比较稳定,所以 MajorGC 不会频繁执行。

在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。

当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 Full GC 进行垃圾回收腾出空间

2.1.3 永生代

指内存的永久保存区域(方法区),主要存放 Class 和 Meta(元数据)的信息。

Class 在被加载的时候被放入永久区域。它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。

在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。

2.1.4 空间分配

ava 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。

在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。

新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。

这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

堆的内存模型大致为:

img

从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。

默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),

即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小

其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分

默认的,Edem : from : to = 8 :1 : 1 ( 可以通过参数**–XX:SurvivorRatio** 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小

JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块Survivor区域是空闲着的

因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。

2.2 栈

方法的运行一定要在栈当中运行。每个线程执行每个方法的时候都会在栈中申请一个栈帧,每个栈帧包括局部变量区操作数栈用于存放此次方法调用过程中的临时变量、参数和中间结果

局部变量:方法的参数,或者是方法{}内部的变量。
作用域:一旦超出作用域,立刻从栈内存当中消失。

栈为线程私有,生命周期和线程相同。

栈帧的结构

  • 本地变量表 (Local Variable)
  • 操作数栈 (Operand Stack)
  • 动态链接 (Dynamic Linking)
  • 返回地址 (return address)

详解:运行时栈帧结构

2.3 方法区

存放了要加载的类信息、final类型的常量、属性和方法信息。

JVM 用永久代(PermanetGeneration)来存放方法区,(在JDK的HotSpot虚拟机中,可以认为方法区就是永久代)可通过 -XX:PermSize-XX:MaxPermSize 来指定最小值和最大值。

方法区的发展历程:

  • JDK6 及以前,常量池在方法区,这时的方法区也叫做永久代;
  • JDK7 的时候,开始去永久化,方法区合并到堆内存中,运行时常量池还在永久代,但字符串常量池和静态常量移至堆中,因为永久代的回收效率较低,但一般程序中创建的字符较多,所以移至堆中能过提升回收效率;
  • JDK8 及以后,方法区又从堆内存中剥离出来了,但实现方式与之前的永久代不同,这时的方法区被叫做元空间,常量池就存储在元空间,直接使用机器物理内存,因为原来的永久代内存大小不易评估,同时调优效率较低。

2.4 本地方法栈

和栈类似,只不过是用于支持 native 方法的执行,存储了每个 native 方法调用的状态。

HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。

2.5 程序计数器

和 cpu 相关,几乎不占有内存。用于取下一条执行的指令,指向虚拟机字节码指令的位置,唯一一个无 OOM 的区域。

2.6 元空间

元空间是 jdk8 提出了的,与以前的方法区类似,用的不在是 jvm 内存,而是 pc 本地内存。

jdk8 后 类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍留在堆空间。

jdk7 及以前(永久代)
-XX:PermSize 来设置永久代初始分配空间。默认值是20.75M
-XX:MaxPermSize 来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
当 JVM 加载的类信息容量超过了这个值,会报异常 OutOfMemoryError: PermGen space

jdk8 及以后(元空间)
元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定,替代上述原有的两个参数。
默认值依赖于平台。windows 下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspaceSize 的值是 -1, 即没有限制。
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。
如果元数据区发生溢出,虚拟机一样会拋出异常 OutOfMemoryError: Metaspace。

-XX:MetaspaceSize: 设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说, 其默认的 -XX:MetaspaceSize 值为 21MB .这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize 时,适当提高该值。如果释放空间过多,则适当降低该值。

如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到 Full GC 多次调用。为了避免频繁地 GC,建议将 -XX :MetaspaceSize设置为一个相对较高的值。

JVM运行时数据区--方法区_方法区_13

2.7 堆/栈/方法区的交互

JVM运行时数据区--方法区_常量池_02

3. JVM 垃圾回收机制

image-20220318170629645

3.1 如何确定垃圾

3.1.1 引用计数法

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

循环引用问题

当对象 A 和对象 B,相互引用了对方作为自己的成员变量,只有自己销毁的时,才能将成员变量的引用计数减 1,因为对象 A 的销毁依赖于对象 B 的销毁, 对象 B 的销毁依赖于对象 A 的销毁,这样子就造成了循环引用,即使外部没有指针能够访问他们,但是他们依然不能被释放。

所以目前主流 JVM 都没使用引用计数法。

3.1.2 可达性分析

为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的 GC roots对象作为起点搜索。如果在 GC roots 和一个对象之间没有可达路径,则称该对象是不可达的。

GC roots 包括:

  • 两个栈: Java 栈和 Native 栈中所有引用的对象;
  • 方法区中的常量和静态变量
  • 方法区中常量引用的对象
  • 所有线程对象

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

第一次标记

在可达性分析后发现到 GC Roots 没有任何引用链相连时,被第一次标记;并且进行一次筛选:此对象是否必要执行 finalize() 方法;

  • 没有必要执行(可以回收)

    • 对象没有覆盖 finalize() 方法
    • finalize() 方法已经被 JVM 调用过
  • 有必要执行

    对有必要执行 finalize() 方法的对象,被放入 F-Queue 队列中;稍后在 JVM 自动建立、低优先级的 Finalizer 线程(可能多个线程)中触发这个方法;

第二次标记

GC 将对 F-Queue 队列中的对象进行第二次小规模标记;

finalize() 方法是对象逃脱死亡的最后一次机会:

  • 如果对象在其 finalize() 方法中重新与引用链上任何一个对象建立关联,第二次标记时会将其移出"即将回收"的集合;
  • 如果对象没有,也可以认为对象已死,可以回收了;
  • 一个对象的 finalize() 方法只会被系统自动调用一次,经过 finalize() 方法逃脱死亡的对象,第二次不会再调用;

3.2 垃圾回收算法

3.2.1 复制算法

Copying 算法。

按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:

image-20220318174231012

这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。

3.2.2 标记清除算法

Mark-Sweep 算法

最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图:

image-20220318174406957

从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

3.2.3 标记整理算法

Mark-Compact 算法。

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

image-20220318174909458

3.2.4 分代收集算法

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老年代和新生代。

老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

目前大部分 JVM 的 GC 对于新生代都采取复制算法,老年代采用 Mark-Compact 算法。

3.3 Minor GC

Minor GC发生在新生代中的垃圾收集动作, 由于新生代通常存活时间较短,因此基于复制算法来进行回收,所谓复制算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中.

  1. 首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果有对象的年龄以及达到了老年的标准,一般是15,则赋值到老年代区)
  2. 同时把这些对象的年龄 +1(如果 ServicorTo 不够位置了就放到老年区)
  3. 然后,清空 Eden 和 ServicorFrom 中的对象;最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom 区。

Minor GC 触发机制

当年轻代满时就会触发 Minor GC,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC。通过复制算法 ,回收垃圾。复制算法不会产生内存碎片。

3.4 Full GC

旧生代与新生代不同,对象存活的时间比较长,比较稳定,所以 Full GC 是发生在老年代的垃圾收集动作,所采用的是标记-整理算法

  1. 首先扫描一次所有老年代,标记出存活的对象
  2. 然后回收没有标记的对象。
  3. 回收后对空出的空间要么进行合并,要么标记出来便于下次进行分配,总之就是要减少内存碎片带来的效率损耗。

Full GC 发生的次数不会有 Minor GC 那么频繁,并且做一次 Full GC 要比进行一次 Minor GC 的时间更长**。**

另外,标记-清除算法收集垃圾的时候会产生许多的内存碎片 ( 即不连续的内存空间 )。

此后需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次 GC 的收集动作。

Full GC 触发机制

  1. 调用 System.gc 时,系统建议执行 Full GC,但是不必然执行
  2. 老年代空间不足
  3. 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
  4. 由 Eden 区、FromSurvivor区向 ToSurvivor 区复制时,对象大小大于 ToSurvivor 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大
  5. 当永久代满时也会引发 Full GC,会导致 Class、Method 元信息的卸载。

3.5 GC 日志

public static void main(String[] args) {
    Object obj = new Object();
    System.gc();
    System.out.println();
    obj = new Object();
    obj = new Object();
    System.gc();
    System.out.println();
}

设置 JVM 参数为 -XX:+PrintGCDetails,使得控制台能够显示 GC 相关的日志信息,执行上面代码,下面是其中一次执行的结果。

img

4. 垃圾收集器

垃圾收集器(gc)

5. JVM 类加载机制

JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这五个过程。

image-20220318190743220

5.1加载流程

5.1.1 加载

加载是类加载过程中的一个阶段,这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口

注意这里不一定非得要从一个 Class 文件获取,这里既可以从 ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类)。

5.1.2 验证

这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

5.1.3 准备

准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间

注意这里所说的初始值概念,比如一个类变量定义为:

public static int v = 8080;

实际上变量 v 在准备阶段过后的初始值为 0 而不是 8080

将 v 赋值为 8080 的 put static 指令是程序被编译后,存放于类构造器 <client> 方法之中。

但是注意如果声明为:

public static final int v = 8080;

在编译阶段会为 v 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 v 赋值为 8080。

5.1.4 解析

解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。

符号引用

符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class 文件中它以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量出现。

符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。

直接引用

直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

5.1.5 初始化

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。

到了初始阶段,才开始真正执行类中定义的 Java 程序代码。

5.1.6 类构造器

初始化阶段是执行类构造器 <client> 方法的过程。

<client> 方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。

虚拟机会保证子 <client> 方法执行之前,父类的 <client> 方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成 <client>() 方法。

注意以下几种情况不会执行类初始化:

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  2. 定义对象数组,不会触发该类的初始化。
  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  4. 通过类名获取 Class 对象,不会触发类的初始化。
  5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

5.2 类加载器

虚拟机设计团队把加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类,JVM 提供了 3 种类加载器:

5.2.1 启动类加载器

负责加载 JAVA_HOME\lib 目录中的,或通过 -Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类。

5.2.2 扩展类加载器

负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库。

5.3.3 应用程序类加载器

负责加载用户路径(classpath)上的类库。

JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader 实现自定义的类加载器。

image-20220318193209357

5.3 双亲委派

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的 Class),子类加载器才会尝试自己去加载。

采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。

image-20220318193403945

6. JVM 参数

jvm 参数