JVM 内存模型、类加载以及垃圾回收

JVM 的体系结构

答案参考自:

JVM的组成:

  • 类加载子系统 Class loader
  • 运行时数据区
  • 执行引擎


类加载器

  • 负责加载 class文件(class文件在文件开头有特定的文件标识),将 class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。
  • ClassLoader只负责加载 class文件的加载,至于它是否可以运行,则由Execution Engine决定。

在 Java 9 以前,一共有三种类加载器:

  • 启动类加载器(BootStrap ClassLoader
  • 扩展类加载器(Extension ClassLoader
  • 应用类加载器(Application ClassLoader

在 Java 9 之后,经过更改后变成了两种:

  • 启动类加载器
  • 平台类加载器

启动类加载器是所有类加载器的祖先,由 C++ 编程,没有对应的 Java 对象,因此在 Java 中用 null 来指代。

除了启动类加载器,其他的加载器都有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。


双亲委派机制及其优势

  • 全盘负责:

    当前线程的类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用CLassLoader.loadClass()指定类加载器来载入。

  • 父类委托:

    先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。所以我们在开发中尽量不要使用与JDK相同的类(例如自定义一个java.lang.System类),因为父类加载器中已经有一份java.lang.System类了,它会直接将该类给程序使用,而你自定义的类压根就不会被加载。

  • 双亲委派模型:

    如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中。

    只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

  • 双亲委派模型的工作流程:

    1. AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。

    2. ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrap ClassLoader去完成。

    3. 如果BootStrap ClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载。

    4. ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException

  • 双亲委派模型的优势:

    1. 系统类防止内存中出现多份同样的字节码
    2. 保证Java程序安全稳定运行

内存模型

线程私有内存

程序计数器(Program Counter Register)

程序计数器可以看作是当前线程所执行的字节码的行号指示器

字节码解释器工作是就是通过改变程序计数器的值来选取下一条需要执行的字节码指令(执行本地方法的时候,程序计数器的值为 null)。

每条线程都需要有一个独立的程序计数器,各条的程序计数器互不影响,独立存储。

此内存区域是唯一一个没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈(VM Stack)

Java 虚拟机栈为虚拟机执行 Java 方法服务。

Java 虚拟机栈的生命周期和线程相同。

虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会在虚拟机栈中同步创建一个 栈帧 用于存储局部变量表、操作数栈、方法出口等信息。

每一个方法被调用直至执行完毕的过程,就对应这一个栈帧在虚拟机栈中从入栈到出栈的过程。

1
2
3
4
5
6
栈帧:
1. 局部变量表
2. 操作数栈
3. 动态链接
4. 方法出口
5. ...

本地方法栈(Native Method Stack)

本地方法栈为虚拟机使用本地Native方法服务。

线程共享内存

Java 堆(Java Heap)

Java 堆是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。

Java 堆可以处于物理上不连续的内存空间中,但在逻辑上都连续存放。

从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。无论如何划分,无论是那个区域,存储的都只能是对象的实例。

Java 堆既可以实现成固定大小的,也可以是扩展的。 如果在 Java 堆中没有内存完成对象分配时,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

方法区(Method Area)

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

在 JDK 1.8 之前,HotSpot 虚拟机把它当作永久代进行垃圾回收。

在 JDK 1.8 以后,移除永久代,并将永久代拆分至堆和元空间。元空间位于本地内存中,而不是虚拟机内存中,存储类的元数据;堆中则额外存放方法区的静态变量和常量池等。

运行时常量池(Runtime Constant Pool)

运行时常量池是方法区的一部分。

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

除了 保存 Class 文件中描述的符号引用 外,还会把符号引用翻译出来的直接引用也存储再运行时常量池中。

运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。


类加载机制的几个阶段

Java 语言的类型可以分为两个大类:基本类型和引用类型。

其中的引用类型,Java 又将其细分为了四种:类、接口、数组类以及泛型参数。

其中,泛型参数会在编译过程中被擦除,数组类则是由 Java 虚拟机直接在内存中动态构造出来的。所以我们只讨论类和接口的加载过程。

类的加载过程:

JVMjavac 编译好的class字节码文件加载到内存中,并对该数据进行验证、解析和初始化、形成JVM可以直接使用的JAVA类,最终回收(卸载)的过程。

字节码(.class)文件来源:

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • zip, jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • Java源文件动态编译为.class文件

加载

加载阶段是类加载的第一个部分,在此阶段需要完成三件事情:

  1. 通过类的完全限定名找到该类对应的二进制字节流。
  2. 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。

对于非数组类的其他类而言,Java 虚拟机需要通过类加载器来完成查找字节流的过程。

验证

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

准备

准备阶段是正式为类变量(类字段)(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段。

这些变量所使用的内存都将在方法区中分配,只包括类变量。

初始值“通常情况”下是数据类型的零值。

“特殊情况”下,如果类字段的字段属性表中存在 ConstantValue 属性(即被 final 修饰的变量),那么在准备阶段变量的值就会被初始化为 ConstantValue 属性所指定的值。

解析

虚拟机将常量池内的符号引用替换为直接引用的过程。

这个过程可以在初始化阶段之后进行。

初始化

类加载过程中的最后一步。

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

<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。

<clinit>() 与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。

如果一个类没有声明任何的类变量,也没有静态代码块,那么可以没有类<clinit>方法。

JVM 必须确保一个类在初始化的过程中,如果是多线程需要同时初始化它,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程(所以可以利用静态内部类实现线程安全的单例模式)。

简单地说,初始化就是对类变量进行赋值及执行静态代码块。

使用

程序使用 JVM 加载的类。

卸载

触发卸载的五个时机:

  • 执行了 System.exit() 方法
  • JVM 垃圾回收机制触发回收
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

何时触发初始化

  1. 遇到 newgetstaticputstaticinvokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。
    生成这4条指令的最常见的Java代码场景是:

    1. 使用new关键字实例化对象的时候
    2. 读取或设置一个类的静态字段的时候
    3. 调用一个类的静态方法的时候
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候。

  3. 当初始化一个类的时候,发现其父类还没有进行过初始化,则需要先出发父类的初始化。

  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。


对象实例化时的顺序

答案参考自:

注:(A,B)表示A和B为同一阶段初始化,执行顺序取决于它们在代码中的顺序。

无继承

(静态变量,静态代码块)-> (实例变量,普通代码块) -> 构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 静态变量和静态语句块的初始化顺序取决于它们在代码中的顺序
public static String staticField = "静态变量";

static {
System.out.println("静态语句块");
}

public String field = "实例变量";

{
System.out.println("普通语句块");
}

public InitialOrderTest() {
System.out.println("构造函数");
}

有继承

  1. 父类(静态变量、静态语句块)
  2. 子类(静态变量、静态语句块)
  3. 父类(实例变量、普通语句块)
  4. 父类(构造函数)
  5. 子类(实例变量、普通语句块)
  6. 子类(构造函数)

垃圾回收机制

答案参考自:

判断对象是否存活

引用计数法

它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。

它的具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。

除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。举个例子,假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。

可达性分析

Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

GC Roots

可以暂时理解为由堆外指向堆内的引用,一般而言,GC Roots 包括(但不限于)如下几种:

  • Java 方法栈桢中的局部变量;
  • 已加载类的静态变量;
  • JNI handles;
  • 已启动且未停止的 Java 线程。

可达性分析可以解决引用计数法所不能解决的循环引用问题。

虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。

比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。

误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。

怎么解决这个问题呢?在 Java 虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)

方法区的回收

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。

主要是对常量池的回收和对类的卸载。

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。

类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:

  • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

finalize方法

类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用

引用类型

判定对象是否可被回收都与引用有关。

  1. 强引用

    被强引用关联的对象不会被回收。

    使用 new 一个新对象的方式来创建强引用。

  2. 软引用

    被软引用关联的对象只有在内存不够的情况下才会被回收。

    使用 SoftReference 类来创建软引用。

  3. 弱引用

    被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

    使用 WeakReference 类来创建弱引用。

  4. 虚引用

    又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

    为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

    使用 PhantomReference 来创建虚引用。

垃圾回收的三种方式

  1. 标记-清除

    即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

    缺点:

    • 会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
    • 分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
  2. 标记-整理(压缩)

    即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销

  3. 标记-复制

    将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

    主要不足是只使用了内存的一半。

    现在的商业虚拟机都采用这种复制收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 EdenSurvivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor

    HotSpot 虚拟机的 EdenSurvivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

  4. 分代收集

    现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

    一般将堆分为新生代和老年代。

    • 新生代使用:复制算法
    • 老年代使用:标记-清除 或者 标记-整理 算法

垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。‘

基本概念

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
  • 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge 和 Parallel New。这三个采用的都是标记 - 复制算法。其中,Serial 是一个单线程的,Parallel New 可以看成 Serial 的多线程版本。Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐率。此外,Parallel Scavenge 不能与 CMS 一起使用。

针对老年代的垃圾回收器也有三个:刚刚提到的 Serial Old 和 Parallel Old,以及 CMS。Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本。

CMS 采用的是标记 - 清除算法,并且是并发的。除了少数几个操作需要 Stop-the-world 之外,它可以在应用程序运行过程中进行垃圾回收。在并发收集失败的情况下,Java 虚拟机会使用其他两个压缩型垃圾回收器进行一次垃圾回收。由于 G1 的出现,CMS 在 Java 9 中已被废弃。

G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。

G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。 这也是 G1 名字的由来。


回收策略和内存分配策略

Minor GC & Full GC

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

内存分配策略流程

  1. 一个人(对象)出来(new 出来)后会在Eden Space(伊甸园)无忧无虑的生活,直到GC到来打破了他们平静的生活。GC会逐一问清楚每个对象的情况,有没有钱(此对象的引用)啊,因为GC想赚钱呀,有钱的才可以敲诈嘛。然后富人就会进入Survivor Space(幸存者区),穷人的就直接kill掉。
  2. 并不是进入Survivor Space(幸存者区)后就保证人身是安全的,但至少可以活段时间。GC会定期(可以自定义)会对这些人进行敲诈,亿万富翁每次都给钱,GC很满意,就让其进入了Genured Gen(养老区)。万元户经不住几次敲诈就没钱了,GC看没有啥价值啦,就直接kill掉了。
  3. 进入到养老区的人基本就可以保证人身安全啦,但是亿万富豪有的也会挥霍成穷光蛋,只要钱没了,GC还是kill掉。

内存分配策略总结

  1. 对象优先在 Eden 分配

    大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

  2. 大对象直接进入老年代

    大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组

    经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

  3. 长期存活的对象直接进入老年代

    为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

    Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15,那么该对象将被晋升(promote)至老年代。

  4. 动态对象年龄判定

    另外,如果单个 Survivor 区已经被占用了 50%,那么较高复制次数的对象也会被晋升至老年代。

  5. 空间分配担保

    在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。否则会继续判断其他条件,选择是否进行Full GC。

GC 触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。 而 Full GC 则相对复杂,有以下条件:

  1. 调用 System.gc()

    只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

  2. 老年代空间不足

    老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

  3. 空间分配担保失败

    使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。(可参考上文“内存分配策略总结”第5点。)

  4. Concurrent Mode Failure

    执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。(可参考上文“垃圾收集器”部分。)


TODO

  1. JVM中synchronized的实现

JVM 内存模型、类加载以及垃圾回收
https://luoyuy.top/posts/20ccf97165e3/
作者
LuoYu-Ying
发布于
2022年5月27日
许可协议