Java JVM(内存结构,垃圾回收,类加载,内存模子)

打印 上一主题 下一主题

主题 902|帖子 902|积分 2706

一、JVM 主要功能

1. 什么是 jvm?

JVM(Java Virtual Machine):负责运行 Java 程序的核心组件。它将 Java 字节码(.class 文件)解释或编译为机器代码,并提供内存管理、垃圾回收和线程管理等功能。
JRE (Java Runtime Environment):Java 运行时环境,包罗运行 Java 应用程序所需的全部组件,包括 JVM 和 Java 标准库(如核心类和辅助类)。提供一个运行 Java 应用的完备环境。
JDK (Java Development Kit):Java 开发工具包,是用于开发 Java 应用程序的完备工具集。它包罗 JRE 和额外的开发工具,如编译器(javac)、调试器(jdb)、打包工具(jar)等。
  1. JDK (开发工具 + JRE)
  2. |
  3. +-- JRE (JVM + 类库)
  4.       |
  5.       +-- JVM (执行 Java 字节码的核心)
复制代码
2. 功能


  • 加载和执行字节码

    • JVM 将 Java 编译器天生的字节码加载到内存中,并逐行解释或编译执行。

  • 内存管理

    • JVM 管理应用程序的内存,包括堆(Heap)、栈(Stack)、方法区(Method Area)等,负责对象的分配与回收。

  • 垃圾回收(Garbage Collection, GC)

    • 自动回收无用对象的内存,淘汰内存泄漏的风险。

  • 提供运行时环境

    • JVM 提供线程管理、异常处理、安全性检查等功能。

  • 跨平台性

    • Java 的“一次编写,到处运行”得益于 JVM 的实现。不同平台有各自的 JVM 实现,但字节码标准统一。

下面开始讲解的是 JVM 的核心构成部门,这里我用到的java版本是17,不同的java版本 jvm的实现、需要的测试工具、虚拟机参数可能有略微不同。
二、JVM 内存结构

JVM 的内存结构是其运行 Java 程序时管理和分配内存的重要构成部门。它将内存分别为多个区域,每个区域都有特定的用途,主要用于存储类信息、对象实例、方法执行时的数据以及线程相干信息等。
按照 Java 虚拟机规范,JVM 的运行时内存结构包括以下几个部门:
内存区域线程共享 / 独立用途程序计数器线程独立用于记录当前线程执行的字节码指令地点。虚拟机栈(JVM Stack)线程独立存储方法调用的局部变量、操作数栈、动态链接和方法返回信息。本地方法栈线程独立与 JVM 栈雷同,但用于执行本地方法(如 JNI 调用)。堆(Heap)线程共享存储全部对象实例和数组,是 GC(垃圾回收)的主要管理区域。方法区(Method Area)线程共享存储类信息、常量池、静态变量、即时编译(JIT)后的代码等元数据信息(在 Java 8 之前称为“永久代”)。运行时常量池线程共享方法区的一部门(java8之前),存储类和方法的符号引用及编译期天生的常量。1. 程序计数器

Java 源码通过编译器编译成 .class 字节码文件,其中包罗 JVM 指令,操作系统无法直接运行。当运行程序时,JVM 将字节码加载到内存中,通过解释器逐条将 JVM 指令 解释为 机器码 执行,或者通过 即时编译器 将热点代码直接编译为本地机器码以提高性能。程序计数器记录当前线程正在执行的 JVM 指令 的偏移量,帮助 JVM 定位字节码指令并执行相应操作。(每个线程都有独立的程序计数器。)
特点:

  • 每个线程都有独立的程序计数器
  • 程序计数器不存在内存溢出
2. 虚拟机栈(JVM Stack)

虚拟机栈(JVM Stack) 是 Java 虚拟机为每个 线程 分配的私有内存空间,生命周期与线程相同。它主要用于管理 Java 方法 的执行。每个方法在调用时会创建一个栈帧(Stack Frame),用于存储方法的 局部变量表、操作数栈、动态链接和返回地点。
栈中变量:

  • 对于栈中基本数据类型的变量,会直接存储在栈内存中,而不是引用。
  • 对于栈中引用数据类型的变量,栈内存存储的是对象的引用地点,而对象的实际内容存储在堆内存中。
2.1 栈和栈顶

下面展示栈和栈帧的关系。
当调用方法1(栈帧1)时,栈帧1进栈,栈帧1中调用了方法2(栈帧2),栈帧2进栈,当方法2执行完后,栈帧2出战,栈帧1出栈。


  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)构成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
2.2 栈大小


  • 每个栈内存是有限的,可以通过 JVM 参数 -Xss 设置栈的大小。
  • 假如栈的内存凌驾限定,会抛出 StackOverflowError。
2.3 栈帧的生命周期


  • 当一个方法被调用时,会自动分配内存,创建一个新的栈帧并压入当前线程的虚拟机栈顶部。
  • 方法执行结束后,栈帧弹出
  • 栈帧弹出时,会自动开释对应的内存。不是由垃圾回收机制回收的
3. 本地方法栈

本地方法栈是 JVM 的运行时区域之一,主要为本地方法调用提供支持,尤其是那些由 JNI 或其他机制实现的非 Java 方法。但专注于底层与操作系统交互的功能。
它和虚拟机栈雷同,遵循 线程私有、按调用顺序管理栈帧的规则,同样存储方法的局部变量表、操作数栈、动态链接和返回地点,可调整栈内存大小。
本地方法栈与虚拟机栈的区别

特性虚拟机栈本地方法栈用途管理 Java 方法调用管理本地方法调用执行的代码类型Java 字节码本地方法(通常是 C/C++ 实现)是否依赖 JVM完全由 JVM 管理借助 JNI 与操作系统或本地库交互上面讲到的 程序计数器、虚拟栈、本地栈都是线程独立的,也就是每个线程都有本身对应的,是不共享的。下面讲到的堆、方法区、运行时常量池都是线程共享的,也就是全部线程公用的。
4. 堆(Heap)

堆(Heap)是 JVM 中最重要的内存区域之一,用于存储全部 对象实例数组。它是被线程共享的内存区域,在 JVM 启动时创建,生命周期与 JVM 同等。
堆是 JVM 中存储对象的主要区域,分为年轻代和老年代两部门,负责对象生命周期管理和垃圾回收。
4.1 堆的结构

现代 JVM 的堆通常分为以下区域,主要用于优化对象的生命周期管理和垃圾回收:

  • 年轻代(Young Generation)

    • 存放内容:新创建的对象通常会分配在年轻代。大多数对象很快变得不可达,因此年轻代的垃圾回收频率较高。
    • 进一步分别

      • Eden 区:对象最初分配的地方。(伊甸园区)
      • Survivor 区:存活的对象会被转移到 Survivor 区,两块 Survivor 区轮换使用(From 和 To)。(幸存区)

    • 垃圾回收算法:采用 复制算法,快速清理短生命周期的对象。

  • 老年代(Old Generation 或 Tenured Generation)

    • 存放内容:从年轻代提升的长期存活对象。
    • 特点:老年代通常占用堆的大部门空间,垃圾回收频率较低,但回收本钱较高。
    • 垃圾回收算法:通常采用 标记-清除标记-整理 算法。

4.2 堆的内存分配


  • 对象分配

    • 当一个对象通过 new 创建时,JVM 会首先实验在堆的 Eden 区 分配空间。
    • 假如 Eden 区空间不足,则触发 Minor GC,清理 Eden 区无用对象,并将存活对象移到 Survivor 区。

  • 对象提升

    • 假如一个对象经过多次 Minor GC 仍然存活,JVM 会将其移动到老年代。

  • 大对象

    • 大对象(如大型数组)直接分配到老年代,避免在年轻代的复制本钱过高。

4.3 堆的垃圾回收

堆是垃圾回收的主要区域,GC 会根据对象的生命周期选择不同的算法:

  • Minor GC(年轻代回收):

    • 处理年轻代的无用对象。
    • 因为年轻代对象存活率低,使用 复制算法

  • Major GC/Full GC(老年代回收):

    • 处理老年代和整个堆的垃圾。
    • 回收本钱较高,通常结合 标记-清除标记-整理 算法。

5. 方法区 和 运行时常量池

方法区 是 JVM 的逻辑部门,在 Java 8 之后由元空间(Metaspace)实现,主要用于存储类的元信息。(元空间使用直接内存)
元信息包罗的内容:

  • 类的名称、父类、实现的接口。
  • 方法、字段的信息(包括名称、修饰符、类型等)。
  • 全部方法,包括平凡方法、静态方法和构造方法的字节码
  • 静态变量的引用。
运行时常量池也 是一个存储常量的区域(堆内存),在类加载时动态地从类文件的静态常量池中加载创建。(静态常量池在把 .java 文件编译成 .class 文件时天生的)
静态常量池包罗的常量类型:

  • 字面量常量:例如,字符串常量、数值常量(如 100,3.14)等。
  • 符号引用:例如,类名、方法名、字段名等。这些符号引用会在运行时被解析为实际的内存地点或执行位置。
  • 静态常量:如 static final 字段的值,这些值会被存储在运行时常量池中。
对常量池的一些补充:运行时常量池 是为每个类单独维护的,同一个类的运行时常量池不会出现重复的常量,比如 Class A 的运行时常量池中不会存在两个数字1000。但是每个类的字符串都会存在一个字符串常量池StringTable中(也会去重)。
下面是通过 javap -c -verbose Main.class 查察到的 Main.class 的常量池,当 .class 加载到内存时 #1... 会变成实际在内存中的地点,为运行时常量池。

1. StringTable

String Table 是 运行时常量池 中专门用于存储字符串常量的一个哈希表,在 Java 8 及之后,,严酷意义上来说,StringTable 不再直接归属于运行时常量池,但逻辑上的关联依然存在。(StringTable会受到垃圾回收的管理)
运行时常量池(RCP) 和 StringTable 各自管理不同的资源:

  • 运行时常量池仍然存储类常量、字段、方法名的符号引用。
  • StringTable 只存储字符串常量。
StringTable 是一个哈希表,存储全部字符串常量,主要用于:

  • 实现字符串的字符串池(String Intern Pool)机制。
  • 淘汰重复字符串的内存开销,提高内存利用率。
  • 支持字符串的唯一性特性,使同一内容的字符串只占用一份内存。
2. StringTable 的工作机制


  • 字符串池(String Intern Pool)

    • 当创建字符串 常量(例如 String str = "hello";)时,JVM 会先检查字符串池中是否已有值为 "hello" 的字符串。

      • 假如有,则直接返回池中字符串的引用。
      • 假如没有,则将 "hello" 添加到字符串池中。

    • 字符串池确保相同内容的字符串常量在 JVM 内存中只有一份。

  • 动态插入

    • 假如在运行时调用了 String.intern() 方法,也可以将动态天生的字符串放入字符串池。例如:
      1. String str = new String("hello").intern();
      复制代码

      • 假如 "hello" 不在字符串池中,它会被加入,并返回字符串池中的引用。
      • 假如已经存在,intern() 返回池中的引用。


3. 常量字符串的拼接相干问题。
  1. public class Main {
  2.     public static void main(String[] args) {
  3.         String s1 = "a";
  4.         String s2 = "b";
  5.         String s3 = "ab";
  6.         String s4 = s1 + s2;
  7.         String s5 = "ab";
  8.         String s6 = "a" + "b";
  9.         System.out.println(s4 == s3);
  10.         System.out.println(s5 == s3);
  11.         System.out.println(s6 == s3);
  12.     }
  13. }
复制代码
下图是上面代码编译后的 .class 文件,可以通过 javap -c -verbose Main.class 查察。
其中 ldc 是 Load Constant(加载常量)的意思,只有当程序用到对应的字符串,常量池 和 StringTable才会记录并缓存字符串的引用,同时Stringtable确保记录中字符串不会重复。(比如当程序执行到 s2 = "b" 的时候,后面的 "ab" 不会被记录)

下面是常量池,其中也存储了变量对应的值

代码中的 String s4 = s1 + s2;,JVM 根据运行时上下文选择最优的拼接方式(例如使用 StringBuilder、StringConcatHelper 等,java9之前用的都是 StringBuilder。不论用的哪个,返回的都是一个崭新的对象,而不是之前 运行时常量池中 的引用。而 s5 和 s6 返回的都是 运行时常量池中 对象的引用。(不只是字符串,任何对象都是,只不过字符串多了一个StringTable)
以是上面代码最后输出的是:(s4的地点跟s3不同,s5跟s3的相同,s6跟s3的相同(s6 jvm回去常量池看是否有"ab"))
  1. false
  2. true
  3. true
复制代码
下面是两个案例,动态讲字符串插入StringTable。
  1. public static void main(String[] args) {
  2.     String s = new String("a") + new String("b");        // a, b常量自动记录在StringTable, ab 不会
  3.     String intern = s.intern();        // 手动插入 ab 到 StringTable,并返回StringTable中 ab 的引用
  4.     System.out.println(intern == "ab");
  5.     System.out.println(s == "ab");
  6. }
  7. // true
  8. // true
复制代码
  1. public static void main(String[] args) {
  2.     String x = "ab";
  3.     String s = new String("a") + new String("b");        // new 出来的两个变量在编译阶段是不确定的
  4.     String intern = s.intern();
  5.     System.out.println(intern == "ab");
  6.     System.out.println(s == "ab");
  7. }
  8. // true
  9. // false
复制代码
一开始介绍时,我们讲 StringTable 在jvm的堆内存中(java8之后),下面验证一下。
我们给虚拟机参数设置堆内存的最大值 -Xmx10m,10M 内存。

执行下面代码可以看到
  1. public class Main {
  2.     public static void main(String[] args) {
  3.         List<String> list = new ArrayList<String>();
  4.         try {
  5.             for (int i = 0; i < 300000; i++) {
  6.                 list.add(String.valueOf(i));
  7.             }
  8.         } catch (Exception e) {
  9.             e.printStackTrace();
  10.             throw new RuntimeException(e);
  11.         }
  12.         System.out.println("ok");
  13.     }
  14. }
复制代码
outOfMemoryError Heap Space
  1. Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  2.         at java.base/java.lang.Integer.toString(Integer.java:456)
  3.         at java.base/java.lang.String.valueOf(String.java:4329)
  4.         at com.example.Main.main(Main.java:12)
复制代码
4. StringTable的性能调优

-XX:+PrintStringTableStatistics : 控制台输出 StringTable 的细节内容
-XX:StringTableSize=:控制 StringTable 哈希表的桶数(默认值为 60013)。可以适当增大这个桶数的值,淘汰哈希冲突
-Xms512m 和 -Xmx2g: 控制初始堆内存大小或最大堆内存大小,减小在堆内存不足的时候,垃圾回收机制堆StringTbale的影响
5. 方法区与其他内存区域的关系


  • 堆内存:存储对象实例和数组。
  • 栈内存:存储方法调用过程中的局部变量和操作数。
  • 方法区:存储类信息、常量、静态变量、JIT 优化后的代码。
6. 直接内存

Java 中的直接内存(Direct Memory)是一种 JVM 外的内存,与堆(Heap Memory)不同。直接内存是通过操作系统分配的,用于提高 I/O 性能,特殊是在需要频繁进行大数据块的读写时,直接内存可以淘汰数据在 JVM 内存与操作系统内存之间的拷贝,从而提升性能。
用户态和内核态堆内存和直接内存
在操作系统中,运行的程序可以分为两种模式:

  • 用户态(User Mode)

    • 是应用程序运行的模式,权限较低,无法直接访问硬件资源(如磁盘、网络等)。
    • 用户态程序(如 Java 应用)需要通过 系统调用(System Call) 向操作系统哀求资源。

  • 内核态(Kernel Mode)

    • 是操作系统运行的模式,权限较高,可以直接操作硬件资源。
    • 文件系统、网络通讯等操作由内核负责处理,内核会管理内存、调度硬件装备。

1. 堆内存的文件读写

流程
使用堆内存(例如 byte[])进行文件读写时:

  • 用户态程序调用 FileInputStream.read() 或 FileOutputStream.write() 发起 I/O 哀求。
  • 进入内核态,操作系统将文件数据加载到内核缓冲区
  • 数据从内核缓冲区拷贝到用户态的堆内存(byte[] 缓冲区)。
  • 切换到用户态,在堆内存中处理数据。
特点

  • 双次拷贝

    • 读取文件时:

      • 从磁盘读取数据到内核缓冲区。
      • 从内核缓冲区拷贝到用户态堆内存。

    • 写入文件时:

      • 从堆内存拷贝到内核缓冲区。
      • 从内核缓冲区写入磁盘。


  • 性能影响

    • 数据从内核缓冲区到用户态的堆内存拷贝(用户态和内核态切换的开销)。
    • 当文件较大或 I/O 操作频繁时,频繁的数据拷贝增加了 CPU 和内存的消耗。

  1. 磁盘  <--->  内核缓冲区  <--->  用户态堆内存 (byte[])
复制代码
2. 直接内存的文件读写

流程
使用直接内存(通过 ByteBuffer.allocateDirect())进行文件读写时:

  • 用户态程序发起 I/O 哀求(如 FileChannel.read())。
  • 进入内核态,操作系统将文件数据加载到内核缓冲区
  • 数据直接从内核缓冲区拷贝到用户态的直接内存(Direct Memory)。
  • 用户态程序直接操作直接内存中的数据。
特点

  • 淘汰一次拷贝

    • 读取文件时:

      • 数据直接从内核缓冲区传输到直接内存,无需再经过 JVM 堆。

    • 写入文件时:

      • 数据直接从直接内存传输到内核缓冲区。


  • 性能提升

    • 避免了 JVM 堆内存和内核缓冲区之间的多次拷贝。
    • 淘汰了用户态与内核态之间的上下文切换次数。

  1. 磁盘  <--->  内核缓冲区  <--->  用户态直接内存 (Direct Memory)
复制代码
3. 总结

特性堆内存直接内存拷贝次数两次一次内存管理JVM 自动管理需手动管理性能较低较高适用场景平凡文件操作大文件、高性能 I/O 场景用户态切换开销高(多次用户态与内核态切换)较低(淘汰切换次数)4. 直接内存的分配与开释

直接内存的分配和开释不是由 JVM 的垃圾回收机制直接管理,而是依赖于 ByteBuffer.allocateDirect() 方法进行分配,开释由 JVM 在内存不再使用时通过 Cleaner 或 Unsafe 机制开释。
4.1 直接内存的分配过程


  • 创建一个 DirectByteBuffer 对象

    • JVM 会在堆中创建一个 Java 对象(DirectByteBuffer)来管理这块直接内存,并通过该对象记录直接内存的地点和大小。

  • 直接内存由操作系统分配

    • 调用 ByteBuffer.allocateDirect(size) 时,JVM 通过 Unsafe 的 setMemory 方法调用操作系统的内存分配接口来分配内存。



4.2 直接内存的开释过程
  1. static int _1Gb = 1024 * 1024 * 1024;
  2. public static void main(String[] args) throws IOException {
  3.     ByteBuffer buffer = ByteBuffer.allocateDirect(_1Gb);
  4.     System.out.println("分配内存完成");
  5.     System.in.read();
  6.     buffer = null;
  7.     System.out.println("开始垃圾回收");
  8.     System.gc();        // 手动垃圾回收
  9.     System.out.println("回收完毕");
  10.     System.in.read();
  11. }
复制代码

  • 直接内存的开释由 DirectByteBuffer 对象的 Cleaner 负责

    • 每个直接内存分配对应的 DirectByteBuffer 对象中有一个 Cleaner,Cleaner 是一个特殊的清理器(内部使用 sun.misc.Cleaner 或雷同机制实现),用于在对象被垃圾回收时开释对应的直接内存。

  • 开释触发条件

    • 当 DirectByteBuffer 对象不再被引用且被垃圾回收时,Cleaner 的 clean() 方法会被调用,开释对应的直接内存。

当垃圾回收时,尽管直接内存本身不受垃圾回收的直接管理,但与其绑定的 DirectByteBuffer 对象的回收会间接导致直接内存被开释。
5 禁用手动垃圾回收,手动管理直接内存

-XX:+DisableExplicitGC  参数可以使 System.gc(); 无效,System.gc(); 性能比力差一般对jvm调优时会禁用。那么我们就需要手动开释直接内存或者当内存不敷时,jvm触发垃圾回收,回收掉 DirectByteBuffer 时。
手动回收直接内存:
在运行下面代码时要加上一些虚拟机参数:
--add-exports java.base/sun.nio.ch=ALL-UNNAMED --add-exports java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/jdk.internal.ref=ALL-UNNAMED
这里我用的是java17,由于在 Java 17 中,很多内部类(例如 jdk.internal.ref.Cleaner)都被封装在 java.base 模块中,不允许默认访问。以是需要手动解除访问限定,java8 则不需要这些参数,可以直接访问。
  1. public class Main {
  2.     static int _1Gb = 1024 * 1024 * 1024;
  3.     public static void main(String[] args) throws Exception {
  4.         ByteBuffer buffer = ByteBuffer.allocateDirect(_1Gb);
  5.         System.out.println("分配内存完成");
  6.         System.in.read();
  7. //        buffer = null;
  8.         System.out.println("开始垃圾回收");
  9.         clean(buffer);
  10.         System.out.println("回收完毕");
  11.         System.in.read();
  12.     }
  13.        
  14.         // 反射获取 Clearner 中的 clean 方法
  15.     public static void clean(ByteBuffer buffer) throws Exception {
  16.         if (buffer.isDirect()) {
  17.             Method cleanerMethod = buffer.getClass().getMethod("cleaner");
  18.             cleanerMethod.setAccessible(true);
  19.             Object cleaner = cleanerMethod.invoke(buffer);
  20.             Method cleanMethod = cleaner.getClass().getMethod("clean");
  21.             cleanMethod.invoke(cleaner);
  22.         }
  23.     }
  24. }
复制代码
三、JVM 垃圾回收

JVM 的垃圾回收(GC,Garbage Collection)是自动管理 Java 堆内存的机制,其主要使命是回收不再被引用的对象,从而开释内存空间。Java 中的垃圾回收机制由 JVM 自动管理,无需手动开释内存,淘汰了内存泄漏和内存管理的困难。
1. 如何判断对象是否可回收

1. 引用计数法(Reference Counting)

引用计数法是通过维护每个对象的 引用计数来判断对象是否可回收。每当有一个引用指向该对象时,计数器就增加;每当引用失效(即引用不再指向该对象)时,计数器就淘汰。假如一个对象的 引用计数为零,阐明该对象不再被任何地方引用,可以被垃圾回收。
然而,这种方法有一个显著的缺点,就是无法处理 循环引用 的问题。比如,两个对象相互引用对方,即使它们都没有被其他对象引用,引用计数也不为零,导致无法被回收。
2. 可达性分析法(Reachability Analysis)

目前,JVM 主流的垃圾回收器(如 HotSpot)都采用了 可达性分析法 来判断对象是否可回收。这个方法比引用计数法更为机动和准确,可以或许办理循环引用的问题。
可达性分析法的工作原理:


  • 根节点(GC Roots):首先,JVM 会从 一组 特殊的根节点开始(这些根节点是一些直接可访问的对象,如:当前线程栈上的对象、类的静态变量、系统类加载器等)。这些根节点构成了所谓的 GC Roots
  • 可达性分析:通过遍历从 GC Roots 出发的引用链(称为 "引用链"),标记出全部可以到达的对象。那些可以或许通过引用链访问到的对象,阐明它们仍然在使用中,是 存活对象
  • 不可达对象:假如一个对象无法通过任何引用链(即从 GC Roots 出发)访问到,阐明该对象不再被任何活跃的线程或静态引用所引用,它就是 不可达对象,即为垃圾对象,可以被回收。
2.1 GC Roots 的聚集

JVM 中,GC Roots 是一组特殊的对象,通常包括:

  • 栈上的局部变量:方法的局部变量,它们是当前线程执行的过程中的一部门。
  • 静态字段:类的静态字段(静态变量),即使没有任何线程持有该类的实例引用,只要静态字段指向对象,这个对象仍然是可达的。
  • JNI 引用:Java 代码通过本地方法(Native Methods)进行的引用,JNI 是 Java 与本地代码交互的桥梁。
2.2 Reachability Analysis 的过程


  • 初始化阶段:首先,标记全部 GC Roots(根节点)对象,作为初始可达对象。
  • 标记阶段:遍历这些对象引用的其他对象,继续标记那些被引用的对象,直到没有新的对象被标记。
  • 清除阶段:标记完成后,遍历堆中的全部对象,找出全部没有被标记的对象,这些对象就是不可达的,可以被回收。
3. 引用的类型

在 Java 中,对象可以通过不同的引用类型来引用。根据引用的强度,JVM 会对不同类型的引用采取不同的回收计谋。常见的引用类型包括:

  • 强引用(Strong Reference):平凡的引用,通常是 Object obj = new Object();。假如一个对象具有强引用,它就不会被垃圾回收。
  • 软引用(Soft Reference):SoftReference,当 JVM 内存不足时,软引用指向的对象会被回收,但在内存充足时,软引用的对象不会被回收。
  • 弱引用(Weak Reference):WeakReference,当垃圾回收器进行 GC 时,无论内存是否充足,弱引用指向的对象都会被回收。
  • 虚引用(Phantom Reference):PhantomReference,虚引用不会影响对象的生命周期,只能用来跟踪对象被垃圾回收的过程。虚引用所指向的对象已被垃圾回收,但还未从内存中移除时,虚引用会被加入到一个 引用队列 中,开发者可以在此队列中处理对象回收后的后续工作。

    • 在 6. 直接内存的 4.2 中 就用到了虚引用, DirectByteBuffer 使用虚引用引用队列来管理堆外内存。通过虚引用,当 DirectByteBuffer 对象被垃圾回收时,JVM 会关照应用程序通过 Cleaner 来开释底层直接内存。(引用队列一般弄一个线程,当队列不为空时根据代码开释资源)

3.1 软引用示例

下面代码会出现堆内存不足的环境
  1. // 虚拟机参数:-Xmx20m
  2. // 设置虚拟机堆内存为 20 Mb
  3. public class Main {
  4.     static Integer _5Mb = 5 * 1024 * 1024;
  5.     public static void main(String[] args) {
  6.         List<byte[]> list = new ArrayList<>();
  7.         for (int i = 0; i < 5; i++) {
  8.             byte[] bytes = new byte[_5Mb];
  9.             list.add(bytes);
  10.         }
  11.         System.out.println("ok");
  12.     }
  13. }
复制代码
下面我们将这个 list 中的元素弄成软引用,
  1. public class Main {
  2.     static Integer _5Mb = 5 * 1024 * 1024;
  3.     public static void main(String[] args) {
  4.         List<SoftReference<byte[]>> list = new ArrayList<>();
  5.         for (int i = 0; i < 5; i++) {
  6.             SoftReference<byte[]> reference = new SoftReference<>(new byte[_5Mb]);
  7.             list.add(reference);
  8.         }
  9.         for (SoftReference<byte[]> reference : list) {
  10.             System.out.println(reference.get());
  11.         }
  12.     }
  13. }
  14. // 输出: null null null null [B@7ba4f24f
  15. // 可以看出 前四个byte[] 已经在内存空间不足时被回收
复制代码
从上面可以看到 SoftReference 的引用被没有被gc回收,因为他和gcroot之间是强引用。上面我们只讲到虚引用被加入引用队列,其实我们也可以让 软引用和弱引用 加入到引用队列中,然后操作引用队列
这里我们通过引用队列,删撤除 list 中已经被gc回收内存的 SoftReference 的引用
  1. public class Main {
  2.     static Integer _5Mb = 5 * 1024 * 1024;
  3.     public static void main(String[] args) {
  4.         ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
  5.         List<SoftReference<byte[]>> list = new ArrayList<>();
  6.         for (int i = 0; i < 5; i++) {
  7.             SoftReference<byte[]> reference = new SoftReference<>(new byte[_5Mb], queue);   // 指定被回收时加入到的引用队列
  8.             list.add(reference);
  9.         }
  10.         Reference<? extends byte[]> poll = queue.poll();
  11.         while (poll != null) {
  12.             System.out.println(poll);
  13.             list.remove(poll);
  14.             poll = queue.poll();
  15.         }
  16.         System.out.println("======");
  17.         for (SoftReference<byte[]> reference : list) {
  18.             System.out.println(reference.get());
  19.         }
  20.     }
  21. }
  22. //输出:
  23. // java.lang.ref.SoftReference@7ba4f24f
  24. // java.lang.ref.SoftReference@3b9a45b3
  25. // java.lang.ref.SoftReference@7699a589
  26. // java.lang.ref.SoftReference@58372a00
  27. // ======
  28. // [B@4dd8dc3
复制代码
界说子类时加载父类
界说一个类时,假如该类有父类,则会先加载父类。
  1. public class Main {
  2.     static Integer _7Mb = 7 * 1024 * 1024;
  3.     public static void main(String[] args) {
  4.         List<byte[]> list = new ArrayList<>();
  5.         list.add(new byte[_7Mb]);
  6.     }
  7. }
复制代码
程序的入口类
执行程序时指定的入口类会被加载。
  1. public class Main {
  2.     static Integer _8Mb = 8 * 1024 * 1024;
  3.     public static void main(String[] args) {
  4.         List<byte[]> list = new ArrayList<>();
  5.         list.add(new byte[_8Mb]);
  6.     }
  7. }
复制代码
</ol>以下环境虽然会涉及到类,但不会触发类的加载:

  • 引用类的静态常量
    假如静态字段是 final 修饰的常量,在编译期会被存储到调用类的常量池中,不会触发类的加载。
    1. Integer num = 10;  // 自动装箱
    2. int value = num;   // 自动拆箱
    复制代码
  • 通过数组界说类引用
    界说类的数组,不会触发类的加载。
    1. Integer num = Integer.valueOf(10);  // 自动装箱
    2. int value = num.intValue();        // 自动拆箱
    复制代码
  • 类的静态字段赋值时不涉及类本身
    通过子类访问父类的静态字段时,只会触发父类的加载,不会触发子类的加载。
    1. for (String name : names) {
    2.     System.out.println(name);
    3. }
    复制代码
下面是类的加载过程
1. 加载(Loading)

在这个阶段:
类的二进制字节码文件被加载到内存中,通常是从磁盘文件(如 .class 文件)或网络中加载。同时JVM 会为该类在堆中创建一个 Class 对象在元空间创建一个 instanceKlass 的数据结构,以表示和存储这个类的元信息。
instanceKlass 是 JVM 内部的一个数据结构,表示某个类的完备形貌。  包罗_java_mirror、fields(字段列表)、methods(方法列表)、_super(父类)、_class_loader(类加载器)、itable(接口表)等内容。
Class对象instanceKlass的关系
instanceKlass 是 JVM 层面真正的底层实现,全部元信息都存储在这里。Class对象 是 Java 层对 instanceKlass 的包装,开发者通过 Class 访问类的元信息。
  1. for (Iterator<String> iterator = names.iterator(); iterator.hasNext();) {
  2.     String name = iterator.next();
  3.     System.out.println(name);
  4. }
复制代码
2. 链接(Linking)

链接阶段将加载的类与 JVM 运行时环境连接起来,分为以下三个子阶段:
(1)验证(Verification)


  • 确保类的字节码文件符合 JVM 的规范,没有安全风险。通常检验二进制文件的魔数是否精确
(2)准备(Preparation)


  • 为类的静态变量分配内存并初始化默认值(java8之后存储在堆中),大部门类 的静态变量分配内存是在准备阶段完成,赋值是在初始化阶段完成。
(3)解析(Resolution)


  • 将类的符号引用(常量池中的字符串)替换为直接引用(具体内存地点)。
  • 例如:

    • 常量池中对 java/lang/Object 的符号引用会被解析为实际的内存地点。

留意:上面在准备阶段我们说是大部门,下面讲一下那大部门指的是哪些
类级别(static修饰)的编译期常量会被直接写入 .class 文件的常量池(Constant Pool)中,并在类加载的准备阶段完成赋值。编译期常量指的是 int, double 等基本类型修饰的常量,比如 int a = 1,还有String类型修饰的常量,比如 String s = "hello",虽然String类型是引用类型,这是因为jvm的字符串常量池StringTable对常量字符串的优化。(String s = new ("hello") 这个不是在准备阶段)
这里再举一个在初始化阶段赋值的,比如引用类型的 Integer n = 1,上面我们在讲编译器优化的时候讲到  Integer n = 1 编译器实际天生的代码是 Integer n = Integer.valueOf(1),这里调用到了类的静态方法,以是静态方法需要在类初始化后才可以访问
3. 初始化(Initialization)

这是类加载的最后阶段:用来执行静态变量的显式赋值和静态代码块(static block)

  • 初始化阶段的工作由类的  方法完成
    编译器会将静态变量的显式赋值和静态代码块整合成一个  方法。
    对于以下代码:
    1. List<String> list = new ArrayList<>();        // Object
    2. list.add("Hello");        // Object
    3. String item = list.get(0);
    复制代码
    编译后的  方法逻辑如下:
    1. List list = new ArrayList();
    2. list.add("Hello");
    3. String item = (String) list.get(0);
    复制代码
4. 使用(Usage)

一旦类完成初始化,JVM 可以开始使用它。图片中展示了两种使用环境:

  • 实例化对象:Person 类的对象(如 name="张三" 和 name="李四") 被存储在堆中。

    • 它们的 class 地点指向对应的 Person.class。

  • 访问静态成员:通过 Person.class 对象访问静态字段或方法。
5. 卸载(Unloading)

当某个类不再被使用且类加载器被回收时,该类也会被卸载(通常发生在应用程序停止时)。

  • 卸载过程会开释类的元数据(存储在 Metaspace 中)。
下面是利用类加载实现懒惰初始化单例模式
6. 懒惰初始化单例模式

懒惰初始化的单例模式(Lazy Initialization Singleton Pattern)是一种设计模式,用于在需要时才初始化单例实例,以节约资源。这种方式可以避免在类加载时就创建实例,而是比及第一次需要时才天生实例。
核心头脑


  • 实例的创建是延迟的,只有在第一次调用时才会进行初始化。
  • 目标是避免不必要的资源开销,尤其是在实例初始化过程较重时。
  • 确保只有一个实例存在(单例性子)。
静态内部类方式

这种方式充分利用了 JVM 类加载机制实现了线程安全和懒加载:
  1. String greeting = "Hello, " + "World!";
复制代码

  • 优点:JVM 包管了类加载的线程安全性。实现了懒加载,只有在第一次调用 getInstance 方法时,才会加载内部类并创建实例。性能优越,不需要显式加锁。
4. 类加载器

类加载器是 Java 虚拟机(JVM)中的一个组件,用于将  Java 类的字节码文件(.class 文件) 加载到内存中,并将这些类转化为 JVM 可以辨认的 **Class 对象。
1. 类加载器的作用


  • 加载类:从文件系统、网络等位置加载 .class 文件。
  • 隔离类:提供类的命名空间,允许不同的类加载器加载同名的类。
  • 链接类:验证类的精确性并解析它们之间的依赖。
  • 初始化类:为类的静态变量赋值并执行静态代码块。
2. 类加载器的分类

Java 的类加载器分为以下几种,构成一个具有父子关系的双亲委派模子:

  • 1. 启动类加载器(Bootstrap ClassLoader)

    • 形貌:JVM 内置的加载器,使用 C/C++ 实现。
    • 作用:负责加载核心类库(如 java.lang.*、java.util.*)。
    • 加载路径:$JAVA_HOME/lib 或由 -Xbootclasspath 指定的目录。

  • 2. 扩展类加载器(Extension ClassLoader)

    • 形貌:由 Java 实现,继承自 ClassLoader。
    • 作用:加载扩展类库。
    • 加载路径:$JAVA_HOME/lib/ext 或由系统变量 java.ext.dirs 指定。

  • 3. 应用程序类加载器(Application ClassLoader)

    • 形貌:由 JVM 默认使用,继承自 ClassLoader。
    • 作用:加载应用程序的类和第三方库。
    • 加载路径:classpath 环境变量指定的路径。

  • 4. 自界说类加载器(Custom ClassLoader)

    • 形貌:用户可以继承 ClassLoader 并覆盖 findClass 方法来实现。
    • 作用:实现自界说的类加载逻辑(如从数据库、加密文件加载类)。

3. 类加载器的双亲委派机制

双亲委派模子是类加载器的一种工作方式,加载类时遵循以下顺序:

  • 委托父类加载器:每个类加载器会先把加载哀求交给父加载器处理。
  • 父加载器未找到类:假如父加载器无法加载,则当前加载器实验加载。
优点:

  • 避免重复加载类。
  • 包管核心类的安全性,防止核心类被覆盖。
五、运行期的优化

1. 即时编译
  1. String greeting = new StringBuilder("Hello, ").append("World!").toString();
复制代码
可以观察到代码执行一段时间后执行的变快了,这是由于 JIT 编译器在运行时将 热点代码(即频繁执行的字节码)编译为 本地机器码,以提高程序的运行效率。
正常字节码是由解释器逐条解释执行字节码,每次都需要进行指令解析,性能较低。
一旦将热点代码被 JIT编译器 编译为本地机器码,后续的执行就无需再解释,可以直接运行,大幅提升性能。
2. 方法内联

方法内联是指将一个方法调用的代码直接嵌入到调用点,而不是通过通例的调用机制(如栈操作)来执行。

  • 平凡调用:方法调用通常需要压栈、参数传递、跳转到目标方法执行,然后返回。
  • 内联优化:直接用方法体的代码替代方法调用,省去了调用开销。
  1. try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
  2.     System.out.println(br.readLine());
  3. }
复制代码
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

傲渊山岳

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表