JVM极简教程

基础概念

1.1. Java 虚拟机

是运行 Java字节码的虚拟机

1.2. JVM跨平台原理

JVM在不同的系统(Linux、Windows、MacOS)上有不同的实现,目的是在使用相同的字节码,它们都会给出相同的结果

JVM跨平台本质:不同操作系统 上运行的JVM是不同

1.3. Java字节码

在Java中,JVM可理解的代码就叫作字节码,即扩展名为.class的文件。

Java文件首先被javac编译为.class字节码,JVM将.class字节码解释成机器指令

JVM整体架构

2.1. 架构概述

  1. Hello.java: Java源代码文件,包含Java代码。

  2. Hello.class: 通过Java编译器将Hello.java文件编译成字节码文件Hello.class。

  3. 类加载子系统: 将Hello.class文件加载到JVM内存中,准备执行。

  4. 运行时数据区(Runtime Data Area): JVM运行时使用的内存区域,包括多个部分:

    • 本地方法栈: 用于本地方法(使用JNI调用的非Java方法)执行的栈。

    • Java方法栈: 每个Java方法调用时都会创建一个栈帧,存储局部变量和部分结果。

    • 程序计数器: 用于存储当前线程所执行的字节码指令的地址,以方便线程继续执行接下来的指令。

    • 方法区: 存储类结构信息、常量池、方法数据和方法代码等。

    • : 用于存储所有Java对象的区域。

  1. 执行引擎: 负责执行字节码,包括以下组件:
    • 解释器: 将字节码逐行解释执行。

    • JIT编译器: 将热点字节码编译成本地机器代码,提高执行效率。

    • 垃圾回收器: 管理和回收不再使用的对象内存。

整体流程:

  1. Java源代码文件(Hello.java)被编译成字节码文件(Hello.class)。

  2. 类加载子系统将字节码文件加载到方法区。

  3. 执行引擎负责解释和编译字节码,最终在运行时数据区执行。

2.2. 类加载子系统

2.2.1. 加载

加载阶段是将类的字节码文件(如 Hello.class)从磁盘或其他存储设备加载到 JVM 的内存中。加载过程包括以下几个步骤:

  • 通过类的全限定名获取类的二进制字节流 :根据类的全限定名(比如 com.example.Hello),找到对应的 .class 文件,并读取其二进制数据。

  • 创建一个类的 Class****对象 :将类的二进制数据转换成 JVM 中的 Class 对象,该对象包含了类的结构信息(类的字段、方法、父类、接口等)。

  • 存储在方法区(或元空间):将加载的类信息存储在 JVM 的方法区(或元空间)中,包括类的结构信息、静态变量等。方法区是 JVM 的一部分,用于存储类的结构信息和静态变量。

2.2.2. 链接

链接阶段主要负责将 Java 类的二进制代码合并到运行环境中,包括以下三个步骤:

1. 验证(Verification)

验证阶段主要确保加载的类符合 JVM 规范,不会破坏 JVM 的内部结构和安全:

  • 文件格式验证:验证类文件的字节码格式是否符合 JVM 规范,防止恶意代码或错误的类文件导致 JVM 崩溃。

  • 语义验证:对类文件进行语义分析,确保类的字节码执行时不会违反 Java 语言规范,如类型转换的正确性、访问权限的合法性等。

  • 字节码验证:确保类的字节码操作的正确性和安全性,防止恶意代码通过字节码操纵 JVM。

2. 准备(Preparation)

准备阶段为类的静态变量分配内存空间,并设置默认初始值(零值):

  • 为静态变量分配内存空间:在方法区中为类的静态变量分配内存空间,不包括在代码中赋值的操作。

  • 设置默认初始值:静态变量会被默认初始化为零值(数值类型为 0,引用类型为 null)。

3. 解析(Resolution)

解析阶段将类、接口、字段和方法的符号引用解析为直接引用,找到引用类在方法区具体的地址:

  • 将符号引用解析为直接引用:将类或接口名转换为对应的直接引用,如方法、字段等的直接引用地址,以便后续执行时能够直接定位到具体的内存地址。

2.2.3. 初始化

初始化阶段是类加载过程的最后一步,主要执行类构造器 <clinit>() 方法,初始化类的静态变量和静态代码块:

  • 执行 **<clinit>()**方法<clinit>() 是类构造器方法,由编译器自动收集类中的所有静态变量的赋值动作和静态代码块(静态初始化块)的合并产生的。在执行过程中,虚拟机会保证 <clinit>() 方法的线程安全性,确保它仅被执行一次,即使多个线程同时去初始化一个类。

  • 静态变量和静态代码块的初始化:执行静态变量的显式赋值和静态代码块中的逻辑,完成对静态成员变量的初始化。

2.3. 类加载器的分类

  1. 启动类加载器(Bootstrap Class Loader)
  • 是 Java 虚拟机的一部分,负责加载 Java 的核心类库,如 java.lang 包中的类。它是由本地代码实现的,通常不是由 Java 实现的类。
  • 启动类加载器是所有类加载器的根,它不是 ClassLoader 的子类,它在虚拟机启动时首先被初始化。
  1. 扩展类加载器(Extension Class Loader)
  • sun.misc.Launcher$ExtClassLoader 类的实例,负责加载 Java 的扩展库,如 java.ext.dirs 系统属性指定的目录中的类。
  • 它是由 Java 实现的类,是 Launcher 类的内部静态类。
  • 扩展类加载器的父加载器是启动类加载器。
  1. 应用程序类加载器(Application Class Loader)
  • sun.misc.Launcher$AppClassLoader 类的实例,负责加载应用程序类路径(classpath)上的类,即我们自己编写的类和第三方类库。
  • 它也是由 Java 实现的类,是 Launcher 类的内部静态类。
  • 应用程序类加载器的父加载器是扩展类加载器。

这三种类加载器之间形成了一种层次关系,如下所示:

  • 应用程序类加载器(Application Class Loader)

    • 父加载器:扩展类加载器(Extension Class Loader)

      • 父加载器:启动类加载器(Bootstrap Class Loader)

这种层次结构使得类加载器可以按照一定的规则委托父加载器来加载类,从而实现类加载的双亲委派模型(Delegation Model)。

当类加载器收到加载类的请求时:

  1. 检查是否已经加载过:首先,类加载器会检查该类是否已经被自己加载过了。如果已经加载过,则直接返回已加载的类,避免重复加载。

  2. 委托给父类加载器:如果该类没有被当前类加载器加载过,它会将加载请求委托给父类加载器。父类加载器会按照同样的流程检查是否已经加载过该类,如果没有则继续向上委托,直到达到启动类加载器(Bootstrap Class Loader)为止。

  3. 尝试自己加载:如果所有父类加载器都无法加载该类(即在其委托链上都找不到),那么当前类加载器会尝试自己加载该类。这时,它会根据自己的加载规则(通常是从指定的类路径或者其他路径加载)去加载该类。

这种加载模型被称为双亲委派模型(Delegation Model),它的主要优势在于确保类加载的顺序和一致性,避免了同名类的冲突和安全性问题。通过这种方式,即使在不同的类加载器实例中,同一个类也只会被加载和定义一次,保证了类的唯一性和一致性。

2.4. 双亲委派

点击如下代码的loadClass

java 复制代码
import lombok.SneakyThrows;

/**
 * @author guowei23
 * @Description
 * @create 2024-07-2024/7/18 00:47
 */

public class Test {
    @SneakyThrows
    public static void main(String[] args) {
        String str = "123";
        Class<?> tObj = str.getClass().getClassLoader().loadClass("java.lang.String");
    }
}

进入到

java 复制代码
public abstract class ClassLoader {
    ...
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
    ...
}

再次点击loadClass

java 复制代码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 首先检查这个类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            // c为null证明类没有被加载过
            long t0 = System.nanoTime();
            try {
                // 找到该classLoader的父类
                if (parent != null) {
                    // 尝试通过父类加载器加载类
                    c = parent.loadClass(name, false);
                } else {
                    // 调用 findBootstrapClassOrNull(name) 来加载核心类库
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父类加载器抛出ClassNotFoundException,则继续尝试加载
            }

            if (c == null) {
                // 如果仍然未找到,则调用findClass来加载类
                long t1 = System.nanoTime();
                c = findClass(name);

                // 记录性能统计数据
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            // 调用 resolveClass(c) 方法来解析该类。解析过程包括初始化类的静态变量和执行静态代码块。
            resolveClass(c);
        }
        return c;
    }
}

2.5. 运行时数据区

  1. 方法区:存储类信息、常量、静态变量和即时编译器编译后的代码等数据。方法区是被所有线程共享的内存区域。

  2. :Java堆是JVM所管理的内存中最大的一块,用于存储所有的对象实例和数组。堆也是垃圾收集器管理的主要区域,通常被称为"垃圾收集堆"。

  3. Java方法栈:每个线程运行时都会创建一个栈,用于存放局部变量、操作数栈、动态链接和方法返回地址等信息。

  4. 程序计数器:每个线程都有一个程序计数器,用于记录当前线程执行的字节码指令地址。

  5. 本地方法栈:专门用于支持本地方法的执行。

线程共享 线程私有

2.6. 程序计数器

PC Register ,程序计数寄存器简称为程序计数器:

  1. 是物理寄存器的抽象实现:在Java虚拟机中,程序计数器是物理寄存器的一个抽象表示,用于独立于物理硬件的虚拟环境中。

  2. 用来记录待执行的下一条指令的地址:程序计数器保存着当前线程正在执行的字节码指令的地址,如果执行的是本地方法,则此计数器值为undefined。

  3. 它是程序控制流的指示器,循环、if lese、异常处理、线程恢复等都依赖它来完成:程序计数器对于控制流的管理至关重要,确保在多线程环境中,每个线程在执行控制结构(如循环、分支等)和异常处理时能准确恢复执行状态。

  4. **解释器工作时就是通过它来获取下一条需要执行的字节码指令:**在解释器模式下,JVM通过程序计数器来读取接下来要执行的指令。

  5. **它是唯一一个在JM规范中没有规定任何OutOfMemoryError情况的区域:**程序计数器是唯一一个JVM规范中没有规定可能抛出OutOfMemoryError的区域。这是因为其生命周期和线程一样,通常不需要动态扩展,也不会因为存储不足而导致错误。

2.7. 虚拟机栈(Java栈、Java方法栈)

每个线程在创建时都会创建一个虚拟机栈,栈内会保存一个个栈帧,每个栈帧对应一个方法。

  1. 虚拟机栈是线程私有的:每个线程创建时都会拥有自己的虚拟机栈,用于存储该线程执行的方法信息。

  2. 一个方法开始执行栈帧入栈方法执行完对应的栈帧就出栈所以虚拟机栈不需要进行垃圾回收

  3. 虚拟机栈存在OutOfMemoryError、以及StackOverflowError

  4. 线程太多,就可能会出现OutOfMemoryError,线程创建时没有足够的内存区创建虚拟机栈了

  5. 方法调用层次太多,就可能会出现StackOverflowError

  6. 可以通过-Xss来设置虚拟机栈的大小

2.8. 局部变量表和操作数栈

栈帧

测试代码

java 复制代码
public class Test {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;
        int c = a + b;
    }
}
复制代码
编译代码
bash 复制代码
javac -g -d ./ Test.java

查看字节码

bash 复制代码
javap -v Test
复制代码
查看字节码结果
bash 复制代码
Classfile /Users/guowei23/IdeaProjects/github/com/molefuckgo/SpringBootDemo/src/main/java/com/example/springbootdemo/com/example/springbootdemo/Test.class
  Last modified 2024年7月21日; size 477 bytes
  SHA-256 checksum 21b9823acb804801b50c2ed62fa3ed6b2ef2ffd3b024fd230d5f61eee2aa7f1b
  Compiled from "Test.java"
public class com.example.springbootdemo.Test
  minor version: 0
  major version: 59
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #7                          // com/example/springbootdemo/Test
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Class              #8             // com/example/springbootdemo/Test
   #8 = Utf8               com/example/springbootdemo/Test
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/example/springbootdemo/Test;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               a
  #19 = Utf8               I
  #20 = Utf8               b
  #21 = Utf8               c
  #22 = Utf8               SourceFile
  #23 = Utf8               Test.java
{
  public com.example.springbootdemo.Test();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
        // stack=1:最大堆栈深度为1。
        // locals=1:局部变量表中有1个槽位。
        // args_size=1:方法参数个数为1(this)。
        // aload_0:将this引用加载到操作数栈中。
        // invokespecial #1:调用父类Object的构造函数。
        // return:返回到调用者。
      LineNumberTable: // 显示源代码行号与字节码偏移量的映射
        line 9: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/example/springbootdemo/Test;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
      // stack=2:最大堆栈深度为2,表示此方法在执行过程中可能需要的最大栈大小为2。
      // locals=4:局部变量表中有4个槽位,包括方法参数和局部变量。
      // args_size=1:方法参数个数为1,即String[] args。
         0: bipush        10     // 将常量10推送到操作数栈。 根据LineNumberTable:对应Test.java第11行:int a = 10;
         2: istore_1             // 将操作数栈顶的int值存储到局部变量表索引1的位置。
         3: bipush        20     // 将常量20推送到操作数栈。根据LineNumberTable:对应Test.java第12行:int b = 20;
         5: istore_2             // 将操作数栈顶的int值存储到局部变量表索引2的位置。
         6: iload_1              // 将局部变量表索引1处的int值加载到操作数栈。根据LineNumberTable:对应Test.java第13行:int c = a + b;
         7: iload_2              // 将局部变量表索引2处的int值加载到操作数栈。
         8: iadd                 // 将操作数栈顶的两个int值相加,并将结果压入操作数栈。
         9: istore_3             // 将操作数栈顶的int值存储到局部变量表索引3的位置。
        10: return               // 返回到调用者。
      LineNumberTable:
        line 11: 0    // 源代码行号:指的是在源代码文件(例如Test.java)中的行号。
                      // 字节码偏移量:指的是在编译后生成的字节码文件(例如Test.class)中的具体位置偏移量。
                      // 源代码第11行对应于字节码的偏移量0处。这意味着当程序执行到字节码偏移量0处时,它正在执行源代码文件中的第11行代码
        line 12: 3
        line 13: 6
        line 14: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  args   [Ljava/lang/String;
            3       8     1     a   I
            6       5     2     b   I
           10       1     3     c   I
        // Start:变量的起始字节码偏移量。
        // Length:变量在字节码中的作用范围。
        // Slot:局部变量表的索引(槽位)。
        // Name:变量名。
        // Signature:变量类型签名。
}
SourceFile: "Test.java"

2.9. 本地方法栈

本地方法:native method,在Java中定义的方法,但由其他语言实现。

虚拟机栈存的是Java方法调用过程的栈帧,本地方法栈存的是本地方法调用过程中的栈帧。

也是线程私有的,也可能会出现OOM和SOF

2.10. 堆

堆是JVM中最重要的一块区域,JVM规范中规定所有的对象和数组都应该存放在堆中,在执行字节码指令时会把创建的对象存入堆中,对象对应的引用地址存入虚拟机栈中的栈帧中,不过当方法执行完之后,刚刚所创建的对象并不会立马被回收,而是要等JVM后台执行GC后,对象才会被回收。

-Xms: ms(memory start),指定堆的初始化内存大小,等价--XX:InitialHeapSize

-Xmx:mx(memory max),指定堆的最大内存大小,等价于-XX:MaxHeapSize样,一般会把-Xms和-Xmx设置为一样,这样JVM就不需要在GC后去修改堆的内存大小了,提高了效率

默认情况下,初始化内存大小=无力内存大小/64,最大内存大小=物理内存大小/4

-X:NewRatio 是一个 Java 虚拟机 (JVM) 参数,用于设置 Java 垃圾收集器中年轻代和老年代的比率。

bash 复制代码
-XX:NewRatio=<ratio>

这里的 <ratio> 是老年代与年轻代的比率。例如,-XX:NewRatio=3 表示老年代的大小是年轻代的三倍。这个参数可以帮助调整垃圾收集器的性能,根据应用程序的特点选择合适的比率,以优化内存使用和垃圾收集频率

Java 堆内存主要分为两大部分:新生代(Young Generation)和老年代(Old Generation)。它们在垃圾收集行为和目的方面有所不同:

新生代 (Young Generation)

  • 目的:大多数 Java 对象初始创建时都会被分配到新生代。由于许多对象生命周期较短,新生代经常进行垃圾回收,这种回收被称为 Minor GC。

  • 结构:新生代通常进一步分为三个部分:一个较大的Eden区和两个较小的Survivor区(通常标记为 Survivor1 和 Survivor2 或 From 和 To)。对象首先在 Eden 区域分配。Minor GC 时,存活的对象会从 Eden 区移动到一个 Survivor 区,之后从一个 Survivor 区移动到另一个。

  • 回收频率:相对较高,但每次回收处理的数据量较小,速度快。

老年代 (Old Generation)

  • 目的:在多次 Minor GC 之后依然存活的对象会被晋升到老年代。老年代用来存放应用中生命周期长的数据。

  • 回收频率:较新生代低很多,但每次回收的开销较大,因为涉及到的数据量通常较大。

  • 垃圾回收:当老年代满时进行的垃圾回收称为 Major GC 或 Full GC,这种回收会涉及整个堆,包括新生代和老年代,因此会造成较长的停顿时间。

垃圾回收算法

不同的垃圾收集器可能会使用不同的算法来管理这两个区域。常见的垃圾收集器包括:

  • Serial GC:单线程收集器,适用于小型数据。

  • Parallel GC:多线程收集器,适用于需要高吞吐量的应用。

  • Concurrent Mark Sweep (CMS) GC:以获取最小停顿时间为目标,适用于需要低延迟的应用。

  • G1 GC :将堆划分为多个区域并并行处理,目标是在提供高吞吐量的同时控制停顿。

Young GC/Minor Gc:负声对新生代进行垃圾回收

Old GC/Major Gc: 负责对CMS垃圾收集器会单独对老年老年代进行垃圾回收,目前只有CMS垃圾收集器会单独对老年代进行垃圾收集,其他垃圾收集器基本都是整堆回收的时候对老年代进行垃圾收集

Full GC:整堆回收,也会堆方法区进行垃圾收集

在Java的垃圾回收机制中,对象主要在堆内存中进行分配和移动。堆内存被分为几个区域,其中包括年轻代(Young Generation)和老年代(Old Generation)。年轻代进一步分为Eden区、Survivor0(S0)区和Survivor1(S1)区。对象的移动过程如下:

  1. 对象首次分配:当新对象被创建时,它们通常首先被分配在Eden区。Eden区是年轻代中最大的部分,大多数新生成的对象都会被分配到这里。

  2. 第一次垃圾回收:当Eden区满时,将触发一次Minor GC(也称为Young GC),负责清理年轻代的垃圾回收。GC过程中,存活的对象会从Eden区移动到一个Survivor区(例如S0)。在这个过程中,所有从Eden区和另一个Survivor区(此时通常为空,例如S1)中存活下来的对象都会被复制到S0区。

  3. 对象在Survivor区间移动:在随后的几次Minor GC中,如果对象在S0区存活下来,它们会被移动到S1区,然后在下一次GC时可能又会移回S0区。这种在两个Survivor区间的来回移动主要是为了过滤掉只临时存活的对象,确保只有较长时间存活的对象最终被移动到老年代。

  4. 晋升到老年代 :一个对象如果在Survivor区中存活了足够多的垃圾回收周期(这个阈值可以通过JVM参数进行设置,如-XX:MaxTenuringThreshold),或者Survivor区不够大以至于无法容纳这些对象,它们就会被移动到老年代。老年代有更大的空间,用于存放长时间存活的对象。

  5. 在老年代的回收:老年代的回收频率较低,且通常采用不同的回收算法(如标记-清除或标记-整理)。老年代满了时会触发Major GC(也称为Full GC),这个过程通常比Minor GC要慢得多。

2.11. 垃圾回收

2.11.1. 垃圾标记阶段

2.11.1.1. 引用计数法

每个对象都保存一个引用计数器属性,用户记录对象被引用的次数

引用计数法优缺点:

|--------------------|--------------------------------------------------------------------------------------|
| 优点 | 缺点 |
| 实现简单,计数器为0则表示是垃圾对象 | 需要额外的空间来存储引用计数 |
|  | 需要额外的时间维护引用计数 |
|  | 无法处理引用循环问题 |

2.11.1.2. 可达性分析法

用于确定哪些对象是"活的"。可达性分析法以GC Roots作为起始点,然后一层一层找到所引用的对象,被找到的对象就是存活的对象,其他不可达对象就是垃圾对象

GC Roots包括哪些

|----------------------|----------------------------------------------------------------------------------------------------------------------------|
| 方法区静态属性引用的对象 | 全局对象的一种,Class对象本身很难被回收,回收的条件非常苛刻,只要Class对象不被回收,静态成员就不能被回收。 |
| 方法区常量池引用的对象 | 属于全局对象,例如字符串常量池,常量本身初始化后不会再改变,因此作为GC Roots也是合理的 |
| 方法栈中栈帧本地变量表引用的对象 | 属于执行上下文中的对象,线程在执行方法时,会将方法打包成一个栈帧入栈执行,方法里用到的局部变量会存放到栈帧的本地变量表中。只要方法还在运行,还没出栈,就意味这本地变量表的对象还会被访问,GC就不应该回收,所以这一类对象也可作为GC Roots。 |
| JNI本地方法栈中引用的对象 | 和上一条本质相同,无非是一个是Java方法栈中的变量引用,一个是native方法(C、C++)方法栈中的变量引用。 |
| 被同步锁持有的对象 | 被synchronized锁住的对象也是绝对不能回收的,当前有线程持有对象锁呢,GC如果回收了对象,锁不就失效了嘛。 |

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

一种非常基础和常用的垃圾回收算法,针对某块内存空间,比如新生代、老年代,如果可用内存不足后,就会STW,暂定用户线程的执行,然后执行算法进行垃圾回收:

  1. 标记阶段:从GC Rootsi开始遍历,找到可达对象,并在对象头中进行记录

  2. 清除阶段:堆内存空间进行线性遍历,如果发现对象头中没有记录是可达对象,则回收它

|-------------------------------------------------------|-------------------------------------------------------------------------------------------|
| 优点 | 缺点 |
| 简单和直观:标记清除算法的逻辑相对简单,易于实现。它直接标记所有可达对象,然后清除未被标记的对象。 | 效率问题:每次垃圾回收都需要遍历所有的GC Roots以及从这些Roots可达的所有对象,这在对象数量庞大时会消耗较多的时间。 |
| 无需移动对象:该算法在清除垃圾时不需要移动存活的对象,这避免了对象地址更新相关的复杂性和开销。 | 内存碎片:虽然不移动对象减少了复杂性,但这也导致了内存碎片的问题。内存碎片可能使得大对象难以找到足够的连续空间,从而影响性能。 |
|  | 停顿时间:标记和清除过程中需要暂停应用程序的运行(Stop-the-World),在用户体验和实时系统中可能是不可接受的,特别是在标记和清除阶段需要的时间较长时更为明显。 |

2.11.3.复制(Copying)算法

复制算法通常用于年轻代,年轻代内存被分为两个半区:一为活动区,一为空闲区。当进行垃圾回收时,算法遵循以下步骤:

  1. 标记阶段:从GC Roots开始,标记所有可达的对象。

  2. 复制阶段:将所有标记的存活对象复制到空闲的内存半区。

  3. 清除阶段:清空原有的活动内存区,然后交换活动区和空闲区的角色。

|-------------------------------------------------------------|-----------------------------------------------------------------------|
| 优点 | 缺点 |
| 减少碎片:由于存活的对象被连续复制到另一块干净的内存区域,内存碎片得到显著减少,这有助于提高内存的分配速度。 | 内存利用率:复制算法需要额外的内存空间来存放复制的对象,实际上只有一半的内存区域是可用的,这减少了内存的有效利用率。 |
| 停顿时间短:相比标记-清除算法,复制算法只处理存活的对象,而且通常只在年轻代执行,因此可以实现更短的停顿时间。 | 成本问题:在对象存活率较高的场景下,复制的成本会相对较高,因为需要复制大量对象到新空间。 |
| 简化分配:在清除原内存区后,新对象可以快速地在一块连续的内存区域中分配,通常只需要简单地移动堆的指针。 | 适用性限制:复制算法更适用于对象存活率低的场合(如Java中的年轻代),在老年代或存活对象较多的情况下,复制算法的效率会受到影响。 |

2.11.3.标记-整理(Makr-Compact)算法

标记-整理算法的工作过程可以分为几个步骤:

  1. 标记阶段:从GC Roots开始,标记所有从根节点可达的对象。

  2. 整理阶段:所有存活的对象被整理并向内存区的一端移动。这一步骤压缩了对象,使得它们在内存中连续存放,从而消除了碎片。

  3. 清理阶段:完成对象整理后,未被移动的内存区域被视为垃圾并清理,从而为新的对象分配提供连续的空间。

|-------------------------------------------------------------------------|----------------------------------------------------------------------|
| 优点 | 缺点 |
| 消除内存碎片:通过移动存活对象并压缩它们,标记-整理算法有效地消除了内存碎片,保证了更大块的连续内存可用,这对于大对象的分配非常有利。 | 效率问题:整理内存需要移动对象,并更新引用,这个过程比单纯的标记或复制更耗时。 |
| 不改变对象位置:与复制算法不同,在整理阶段之前,对象的位置保持不变,这简化了对象引用的更新。 | 停顿时间:整个标记和整理过程需要在垃圾回收期间暂停应用程序(Stop-the-World),这可能导致比其他算法更长的停顿时间。 |
| 更好的内存利用率:与复制算法相比,标记-整理算法不需要额外的空间作为复制目标,从而提高了内存利用率。 |  |

2.11.4.对比总结

|------|--------|--------|----|
|  | 标记-清除 | 标记-整理 | 复制 |
| 速度 | 中等 | 最慢 | 最快 |
| 空间开销 | 少(有碎片) | 少(无碎片) | 最多 |
| 移动对象 | 否 | 是 | 是 |

2.11.5.分代收集算法

**分代收集(Generational Collection)**算法是一种广泛应用于现代垃圾回收技术中的方法,它基于这样一个观察:对象的生命周期不同,有些对象很快就不再使用(短命),而有些则可能持续存在很长时间(长寿)。为了更有效地管理内存,分代收集算法将对象按照生命周期分成几代,通常包括年轻代(Young Generation)、老年代(Old Generation)和(在某些垃圾收集器中)永久代(Permanent Generation,但在Java 8及以上版本已被元空间(Metaspace)所替代)。

分代的基本结构和特点:

  1. 年轻代
    • 包括一个Eden区和两个Survivor区(通常标为S0和S1)。

    • 新创建的对象首先在Eden区分配。

    • 经过一次Minor GC后,存活的对象会从Eden区移动到一个Survivor区(S0或S1),并在Survivor区间移动,直到年龄达到一定值后晋升到老年代。

    • 使用复制算法,因为大多数新生对象很快变得不可达。

  1. 老年代
    • 存放生命周期较长的对象。

    • 当对象在年轻代中存活足够长的时间后,会被晋升到老年代。

    • 通常使用标记-清除或标记-整理算法,以减少内存碎片并有效地利用空间。

  1. 元空间(Java中特有):
    • 存放类的元数据。

    • 与堆内存分开管理,在Java 8中替代了永久代,不再由JVM直接管理内存的分配,而是依赖于本地内存。

相关推荐
装不满的克莱因瓶26 分钟前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb
n北斗33 分钟前
常用类晨考day15
java
骇客野人37 分钟前
【JAVA】JAVA接口公共返回体ResponseData封装
java·开发语言
yuanbenshidiaos2 小时前
c++---------数据类型
java·jvm·c++
向宇it2 小时前
【从零开始入门unity游戏开发之——C#篇25】C#面向对象动态多态——virtual、override 和 base 关键字、抽象类和抽象方法
java·开发语言·unity·c#·游戏引擎
Lojarro2 小时前
【Spring】Spring框架之-AOP
java·mysql·spring
莫名其妙小饼干2 小时前
网上球鞋竞拍系统|Java|SSM|VUE| 前后端分离
java·开发语言·maven·mssql
isolusion2 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp3 小时前
Spring-AOP
java·后端·spring·spring-aop
Oneforlove_twoforjob3 小时前
【Java基础面试题033】Java泛型的作用是什么?
java·开发语言