深入理解JVM内存模型与垃圾回收机制

2024-08-04 12:47:22 浏览数 (1)

前言

Java虚拟机(JVM)是Java程序运行的基础,其内存模型和垃圾回收(GC)机制对于程序性能有着重要影响。本文将详细介绍JVM的内存结构、类加载机制、字节码执行引擎以及GC算法和垃圾回收器。

JVM内存模型

JVM内存模型主要由类加载器、JVM内存、字节码执行引擎和GC组成。其中,JVM内存包括方法区、堆、栈、程序计数器和本地方法栈。

类加载器

类加载器是Java虚拟机的重要组成部分,主要分为三种:启动类加载器、扩展类加载器和应用程序类加载器。

启动类加载器负责加载Java核心类,例如位于 rt.jar 包中的类。它是Java虚拟机的一部分,负责最基础和最核心的类加载任务。

扩展类加载器用于加载Java扩展目录(通常是 jre/lib/ext 目录)中的类。它扩展了Java的核心功能,允许开发人员和第三方供应商提供额外的类库。

应用程序类加载器加载我们自己编写的Java类。它是最常见的类加载器,负责加载应用程序的类路径上的类。

Java的类加载机制有两种主要的委派机制:

  1. 双亲委派机制:当一个类加载器收到加载类的请求时,它会先委托给父加载器去加载。这种机制保证了类加载的顺序和一致性,避免重复加载。例如,应用程序类加载器加载一个类时,会先委托给扩展类加载器,然后扩展类加载器再委托给启动类加载器,直到核心类库,如果找不到则返回到应用程序类加载器。
  2. 全盘负责委托机制:在这种机制下,当一个类加载器负责加载某个类时,它会负责加载该类的所有依赖。如果没有显式指定使用另一个加载器,当前加载器会尝试加载所有相关的类。这种方式相对简单直接,但可能导致类冲突和资源浪费。

这些机制共同确保了Java程序的稳定性和安全性,使得类加载过程能够高效地管理和组织类及其依赖关系。

如果想要打破双亲委派机制,虽然相对简单,但需要深入理解底层的源码实现。

代码语言:java复制
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

我们可以观察到在查找 parent.loadClass 的代码段,因此只需重写 loadClassfindClass 方法即可实现打破双亲委派机制。

JVM内存操作

我们可以深入理解操作数栈和局部变量表的交互机制,这里可以通过一个简单的示例来说明。

代码语言:java复制
package com.xiaoyu;

public class Main {
    public Main() {
    }

    public int add() {
        int a = 1;
        int b = 2;
        int c = (a   b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Main main = new Main();
        main.add();
        System.out.println("aaa");
    }
}

我们可以进入 main.class 并执行 javap -c 进行反编译,从而得到如下所示的一些代码片段:

代码语言:java复制
  Last modified 2019-9-8; size 714 bytes
  MD5 checksum 316510b260c590e9dd45038da671e84e
  Compiled from "Main.java"
public class com.xiaoyu.Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #8.#28         // java/lang/Object."<init>":()V
   #2 = Class              #29            // com/xiaoyu/Main
   #3 = Methodref          #2.#28         // com/xiaoyu/Main."<init>":()V
   #4 = Methodref          #2.#30         // com/xiaoyu/Main.add:()I
   #5 = Fieldref           #31.#32        // java/lang/System.out:Ljava/io/PrintStream;
   #6 = String             #33            // aaa
   #7 = Methodref          #34.#35        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #8 = Class              #36            // java/lang/Object
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               Lcom/xiaoyu/Main;
  #16 = Utf8               add
  #17 = Utf8               ()I
  #18 = Utf8               a
  #19 = Utf8               I
  #20 = Utf8               b
  #21 = Utf8               c
  #22 = Utf8               main
  #23 = Utf8               ([Ljava/lang/String;)V
  #24 = Utf8               args
  #25 = Utf8               [Ljava/lang/String;
  #26 = Utf8               SourceFile
  #27 = Utf8               Main.java
  #28 = NameAndType        #9:#10         // "<init>":()V
  #29 = Utf8               com/xiaoyu/Main
  #30 = NameAndType        #16:#17        // add:()I
  #31 = Class              #37            // java/lang/System
  #32 = NameAndType        #38:#39        // out:Ljava/io/PrintStream;
  #33 = Utf8               aaa
  #34 = Class              #40            // java/io/PrintStream
  #35 = NameAndType        #41:#42        // println:(Ljava/lang/String;)V
  #36 = Utf8               java/lang/Object
  #37 = Utf8               java/lang/System
  #38 = Utf8               out
  #39 = Utf8               Ljava/io/PrintStream;
  #40 = Utf8               java/io/PrintStream
  #41 = Utf8               println
  #42 = Utf8               (Ljava/lang/String;)V
{
  public com.xiaoyu.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 10: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/xiaoyu/Main;

  public int add();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: bipush        10
         9: imul
        10: istore_3
        11: iload_3
        12: ireturn
      LineNumberTable:
        line 13: 0
        line 14: 2
        line 15: 4
        line 16: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  this   Lcom/xiaoyu/Main;
            2      11     1     a   I
            4       9     2     b   I
           11       2     3     c   I

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class com/xiaoyu/Main
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method add:()I
        12: pop
        13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #6                  // String aaa
        18: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: return
      LineNumberTable:
        line 20: 0
        line 21: 8
        line 22: 13
        line 23: 21
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      22     0  args   [Ljava/lang/String;
            8      14     1  main   Lcom/tuling/Main;
}
SourceFile: "Main.java"

我们将专注于 add 方法的代码分析,需要使用 JVM 指令集。具体的指令可以在网上查找,如:

  • iconst_1:将整数值1推送到操作数栈顶。
  • istore_1:将操作数栈顶的整数值存储到第一个本地变量。

在这些指令中,操作数栈(Stack)起着重要作用,它是 JVM 中用来执行操作的主要工作区域之一。

GC算法

GC是JVM自动内存管理的重要组成部分。主要算法包括标记-清除、标记-整理、复制算法和分代收集算法。

  1. 标记-清除算法:这种算法分为两个阶段,首先标记所有需要回收的对象,然后统一回收所有被标记的对象。它是最基础的收集算法。
  2. 标记-整理算法:与标记-清除算法类似,首先进行标记阶段,但在清理阶段不是简单地回收对象,而是让所有存活的对象向一端移动,然后直接清理掉边界以外的内存。这样可以减少内存碎片化。
  3. 复制算法:这种算法将内存分为两块大小相等的区域,每次只使用其中的一块。当一块内存区域的对象存活时间结束时,将存活的对象复制到另一块区域中,然后清理掉该块区域。这样做的好处是每次回收时只需对其中一半的内存空间进行操作,减少了碎片化问题。

为什么要GC

堆内存在Java等编程语言中是一种重要的内存区域,用于存储对象实例和数组。堆内存的结构通常包括以下几个关键部分:

  1. 新生代(Young Generation):新创建的对象首先会被分配到新生代中。新生代通常被进一步分为 Eden 空间和两个 Survivor 空间(通常称为 From 和 To 区或者 S0 和 S1 区)。大多数对象在新生代中很快变得不可达并被垃圾回收器收集。
  2. 老年代(Old Generation):新生代中经历多次垃圾回收仍然存活的对象会被移动到老年代。老年代用于存储生命周期较长的对象,通常会被更慎重地回收。
  3. 永久代(或元空间,Permanent Generation / Metaspace):在较早版本的Java中存在永久代,用于存储类的元数据、常量池等。在现代的Java版本中,永久代被元空间(Metaspace)取代,元空间使用本地内存来存储这些数据,因此不再有固定大小的限制,而且垃圾收集的方式也不同于堆内存。

如果你想查看正在运行的Java程序的堆信息,可以使用一系列命令和工具。例如,通过使用 jps 命令可以列出当前正在运行的Java进程及其进程ID。接着,使用 jmap -heap <pid> 命令,将进程ID替换为你想要查看的Java进程的实际ID,就可以获取关于该进程当前堆的详细信息。这些信息包括堆的总体使用情况、新生代与老年代的大小及使用情况、永久代(如果有)的使用情况等。这对于诊断内存问题和优化Java应用程序的性能非常有帮助。

代码语言:java复制
Attaching to process ID 7964, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.73-b02

using thread-local object allocation.
Parallel GC with 4 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 2128609280 (2030.0MB)
   NewSize                  = 44564480 (42.5MB)
   MaxNewSize               = 709361664 (676.5MB)
   OldSize                  = 89653248 (85.5MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 34078720 (32.5MB)
   used     = 4771760 (4.5507049560546875MB)
   free     = 29306960 (27.949295043945312MB)
   14.002169095552885% used
From Space:
   capacity = 5242880 (5.0MB)
   used     = 0 (0.0MB)
   free     = 5242880 (5.0MB)
   0.0% used
To Space:
   capacity = 5242880 (5.0MB)
   used     = 0 (0.0MB)
   free     = 5242880 (5.0MB)
   0.0% used
PS Old Generation
   capacity = 89653248 (85.5MB)
   used     = 0 (0.0MB)
   free     = 89653248 (85.5MB)
   0.0% used

当Old区未使用完时,不会触发Full GC;而当年轻代填满时,将会触发Young GC,但这不会引起全局停顿(Stop-The-World)。现在让我们来讨论一下各种垃圾回收器的特点。

垃圾回收器

JVM提供了多种垃圾回收器,包括Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS和G1等。每种收集器适用于不同的应用场景,开发者可以根据应用特点选择合适的垃圾回收器。

Serial收集器

Serial收集器通过复制算法来处理新生代,而老年代则使用标记-整理算法。该收集器利用单线程进行垃圾回收,因此在回收过程中会直接中断所有程序线程,实施全局停顿(Stop-The-World)。

ParNew收集器

ParNew收集器主要用于新生代,采用复制算法进行垃圾回收,而老年代则使用标记-整理算法。它利用多线程来处理垃圾回收,在进行回收时,应用程序仍会经历中断。

Parallel Scavenge收集器

Parallel Scavenge收集器专为吞吐量优先的应用程序设计,其新生代采用复制算法进行垃圾回收,而老年代则采用标记-整理算法。与ParNew收集器相似,它也利用多线程来处理垃圾回收工作。

Serial Old收集器和Parallel Old收集器分别是Serial收集器和Parallel Scavenge收集器的老年代版本。它们可以看作是将老年代的垃圾回收算法单独提取出来,以便与其他收集器的新生代部分组合使用。

CMS收集器

CMS收集器(Concurrent Mark-Sweep收集器)在启动垃圾回收时,首先快速获取与根节点直接相连的对象,因此停顿时间较短。随后,它与应用程序竞争CPU资源,进行并发标记阶段,识别仍然可达的对象。随着并发标记的进行,新生成的对象会在后续的短暂停顿期间被标记。最终,在清理阶段,CMS收集器会清理那些没有被标记的空间内存。

G1收集器

G1收集器将Java堆划分为多个大小相等的独立区域(Region)。虽然保留了新生代和老年代的概念,但它们不再是物理上的隔离,而是由许多(可以是不连续的)Region组成的集合。G1收集器允许大对象直接分配到Humongous区域,这些区域专门用于存放短期的巨型对象,避免了因为无法找到连续空间而提前触发下一次GC,从而减少了Full GC所带来的大量开销。

在G1收集器中,除了将Java堆划分为多个大小相等的独立区域(Region)外,还实现了筛选回收的过程。用户可以指定回收时间,因此JVM会评估回收成本并制定回收计划,以优先回收对系统性能影响较大的对象。

结语

通过本文的介绍,我们深入探讨了Java虚拟机(JVM)的内存模型、类加载机制、字节码执行引擎以及垃圾回收(GC)算法和垃圾回收器的工作原理。JVM作为Java程序的运行环境,其性能和稳定性对于应用程序至关重要。

首先,我们了解了JVM的内存结构,包括方法区、堆、栈、程序计数器和本地方法栈,这些组成部分共同协作,为Java程序的运行提供支持。

其次,我们深入研究了类加载器的角色和机制,探讨了双亲委派机制和全盘负责委派机制的工作原理及其在Java程序中的应用。

接着,我们详细分析了JVM中的字节码执行引擎,讨论了操作数栈和局部变量表的交互机制,并通过实际代码示例展示了字节码指令的执行过程。

最后,我们介绍了JVM的垃圾回收算法,包括标记-清除、标记-整理、复制算法和分代收集算法,以及它们在管理堆内存中对象生命周期和内存空间利用方面的不同应用。

通过本文的学习,我们不仅对Java应用程序的底层运行机制有了更深入的理解,还能够更好地优化程序性能、排查内存泄漏问题,提升应用的稳定性和可靠性。JVM作为Java技术体系的核心之一,其深奥的内部原理和精妙的设计理念,值得我们进一步探索和学习。


我是努力的小雨,一名 Java 服务端码农,潜心研究着 AI 技术的奥秘。我热爱技术交流与分享,对开源社区充满热情。身兼掘金优秀作者、腾讯云内容共创官、阿里云专家博主、华为云云享专家等多重身份。

0 人点赞