ToB企服应用市场:ToB评测及商务社交产业平台

标题: Java JVM(内存结构,垃圾回收,类加载,内存模子) [打印本页]

作者: 傲渊山岳    时间: 2024-12-3 02:33
标题: Java JVM(内存结构,垃圾回收,类加载,内存模子)
一、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版本是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出栈。

2.2 栈大小

2.3 栈帧的生命周期

3. 本地方法栈

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

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

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

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

4.3 堆的垃圾回收

堆是垃圾回收的主要区域,GC 会根据对象的生命周期选择不同的算法:
5. 方法区 和 运行时常量池

方法区 是 JVM 的逻辑部门,在 Java 8 之后由元空间(Metaspace)实现,主要用于存储类的元信息。(元空间使用直接内存)
元信息包罗的内容:
运行时常量池也 是一个存储常量的区域(堆内存),在类加载时动态地从类文件的静态常量池中加载创建。(静态常量池在把 .java 文件编译成 .class 文件时天生的)
静态常量池包罗的常量类型:
对常量池的一些补充:运行时常量池 是为每个类单独维护的,同一个类的运行时常量池不会出现重复的常量,比如 Class A 的运行时常量池中不会存在两个数字1000。但是每个类的字符串都会存在一个字符串常量池StringTable中(也会去重)。
下面是通过 javap -c -verbose Main.class 查察到的 Main.class 的常量池,当 .class 加载到内存时 #1... 会变成实际在内存中的地点,为运行时常量池。

1. StringTable

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

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. 方法区与其他内存区域的关系

6. 直接内存

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

流程
使用堆内存(例如 byte[])进行文件读写时:
特点
  1. 磁盘  <--->  内核缓冲区  <--->  用户态堆内存 (byte[])
复制代码
2. 直接内存的文件读写

流程
使用直接内存(通过 ByteBuffer.allocateDirect())进行文件读写时:
特点
  1. 磁盘  <--->  内核缓冲区  <--->  用户态直接内存 (Direct Memory)
复制代码
3. 总结

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

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



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 对象的回收会间接导致直接内存被开释。
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)都采用了 可达性分析法 来判断对象是否可回收。这个方法比引用计数法更为机动和准确,可以或许办理循环引用的问题。
可达性分析法的工作原理:

2.1 GC Roots 的聚集

JVM 中,GC Roots 是一组特殊的对象,通常包括:
2.2 Reachability Analysis 的过程

3. 引用的类型

在 Java 中,对象可以通过不同的引用类型来引用。根据引用的强度,JVM 会对不同类型的引用采取不同的回收计谋。常见的引用类型包括:
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>以下环境虽然会涉及到类,但不会触发类的加载:
下面是类的加载过程
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)

(2)准备(Preparation)

(3)解析(Resolution)

留意:上面在准备阶段我们说是大部门,下面讲一下那大部门指的是哪些
类级别(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)
4. 使用(Usage)

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

当某个类不再被使用且类加载器被回收时,该类也会被卸载(通常发生在应用程序停止时)。
下面是利用类加载实现懒惰初始化单例模式
6. 懒惰初始化单例模式

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

静态内部类方式

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

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

2. 类加载器的分类

Java 的类加载器分为以下几种,构成一个具有父子关系的双亲委派模子:
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企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4