深入理解 Java 虚拟机(JVM)从入门到精通

2023-12-13 23:14:42

本文将按照以下思维导图的结构,深入讲解Java虚拟机(JVM)的核心概念
在这里插入图片描述

一、JVM内存结构

在网上借鉴几张图片,可以很形象看出jvm的内存结构
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1、堆(Heap)

堆是JVM内存中最大的一块,用来存储对象和数组,它被所有线程共享

(1)特点

  • 通过 new 关键字,创建的对象都会使用堆内存,数组和字符串常量池(StringTable)也存储在堆中
  • 它是线程共享的
  • 堆中对象都需要考虑线程安全的问题,有垃圾回收机制

(2)堆内存分配

在 Java 的堆内存中,可以分配为新生代老年代的主要依据是对象的生命周期。这个分配是为了更好地进行垃圾回收和提高内存利用率。默认分配比例如下:
在这里插入图片描述

  • 新生代(Young Generation)

新生代由伊甸园(Eden Space)两个幸存者区(Survivor Space)组成。

伊甸园(Eden Space):伊甸园是新生代中的一部分,用于存放新创建的对象。大部分对象在伊甸园中被创建。当内存需要分配给新对象时,大部分对象都会首先被放入伊甸园中。

幸存者区(Survivor Space):幸存者区包括两个区域,分别为From区To区。幸存者区的数据是在 From 区和 To 区之间进行交换的。
例如:当from区和to区都是null的时候,第一次从新生代eden进行垃圾回收,会把存活下来的对象放入from区,下次垃圾回收会把存活下来的数据放入to区,然后from区清空。再下次垃圾回收会把存活下来的数据放入from区,然后to区清空。直到达到一定的年龄后,这些对象会被晋升到老年代。

老年代(Old Generation):用于存放新生代中经过多次gc依然存活的对象,或者新生代中放不下的大对象。

(3)晋升到老年代的方式

  • 年龄阈值:当对象在 survivor 区存活了 15 次(默认)之后,会被移到老年代区。可以通过JVM参数
    -XX:MaxTenuringThreshold 修改。
  • 大对象:当对象大于survivor区空间一半(默认)的时候,会被移到老年代区。可以通过JVM参数
    -XX:PretenureSizeThreshold 参数设置
  • survivor空间不足:当存活下来的对象大于survivor区容量的时候,会被移到老年代区。

假设新生代由100MB的Eden空间和两个50MB的Survivor空间组成,老年代有500MB的空间。
初始情况下,所有新创建的对象都分配在Eden空间。
进行第一次GC,此时Eden空间有80MB的对象,被GC后只有30MB的对象存活。这些存活的对象被移动到Survivor1,Eden被清空。
再次分配对象,Eden空间再次填满到80MB,此时Survivor1中还有30MB的存活对象。
进行第二次GC,Eden区的80MB对象中,60MB存活,加上Survivor1中的30MB存活对象,一共有90MB需要被移动到Survivor2,但Survivor2只有50MB的容量。
此时,JVM会检查Survivor1中对象的年龄,并将年龄大的对象提前晋升到老年代,假设10MB的对象被晋升,这样剩下20MB的对象与Eden区的60MB存活对象能够被移动到Survivor2。
如果Survivor空间依旧不足以处理这60MB的对象,那么无论年龄如何,都会将多出来的部分提前晋升到老年代
GC的这些细节实际上取决于使用的垃圾收集器以及JVM的配置参数,不同的垃圾收集器(如Serial, Parallel, CMS, G1,
ZGC等)会以不同的方式管理这些区域。

(4)堆内存检验方式

1、jmap

  • 首先使用jps查看有哪些进程
  • 然后根据 jmap -heap [进程ID] 查看进程的堆内存

实例:
new一个10M的字节对象,来占用堆内存,在输出分别在输出 1 2 3后打出 jmap命令对比堆内存变化

public class Test {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("1111111111111111111111111");
        Thread.sleep(20000);

        byte[] array = new byte[1024 * 1024 * 10]; // 10M内存
        System.out.println("2222222222222222222222222");
        Thread.sleep(20000);

        System.gc(); // 垃圾回收
        System.out.println("3333333333333333333333333");
        Thread.sleep(10000);
    }
}

打出 111111 后先根据 jps命令查看到进程id

23968 Test
3312
24196 Jps
22764 Launcher
4828 RemoteMavenServer36

可以看出启动类Test进程ID是23968,然后输入命令:jmap -heap 23968

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 66584576 (63.5MB)
   used     = 8754440 (8.348884582519531MB) // 这里只展示部分打印信息,可以看见这里最初占用了8M
   13.14784973625123% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used

然后控制台打印22222222后,继续输入命令:jmap -heap 23968

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 66584576 (63.5MB)
   used     = 19240216 (18.348899841308594MB) // 可以看见这里占用内存变成了18M
   28.895905261903298% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used

然后控制台打印333333333后,继续输入命令:jmap -heap 23968

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 66584576 (63.5MB)
   used     = 1331712 (1.27001953125MB) // 可以看见这里占用内存变成了1M
   free     = 65252864 (62.22998046875MB)
   2.0000307578740157% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used

2、jconsole
还是运行刚才代码,然后执行jconsole命令,选择’本地连接’->'对应进程’用图形查看该进程的堆内存变化
在这里插入图片描述
3、jvisualvm
如下代码可以使堆内存在1万秒内增加200M内存占用空间。以便模拟我们排查问题

public class Test {
    public static void main(String[] args) throws InterruptedException {
        List<Student> students = new ArrayList<>();
        for (int i =0; i < 200 ;i++){
            students.add(new Student());
        }
        Thread.sleep(10000000); // 10000秒
    }
}
class Student {
    private byte[] big = new byte[1024*1024];
}

首先我们还是jconsole 查看,并点击了垃圾回收,但是毫无作用,说明这个类一直被占用。
在这里插入图片描述
然后我们输入 jvisualvm ,根据图片进行操作
在这里插入图片描述
在这里插入图片描述
到了这里,我们可以很清楚看见,是Test这个类下面,一个Student的数组引起的,即可找到代码解决问题
在这里插入图片描述

2、虚拟机栈(VM Stack)

每个线程都有自己的虚拟机栈,这个栈用于存储栈帧。每当一个线程调用一个方法时,JVM就会为这个方法创建一个栈帧,并且将它压入虚拟机栈中。栈帧是用来存储局部变量、执行运算过程中的操作栈、动态链接信息以及方法返回地址等数据。

在这里插入图片描述

(1)特点

线程私有,每个线程运行时所需要的内存,称为虚拟机栈

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

(2)局部变量表

用来存储方法的参数和方法内部定义的局部变量。这些数据包括各种基本数据类型(int、float、long、double等)、对象引用和returnAddress类型(指向了一条字节码指令的地址)。

举例:

int sum(int a, int b) {
    int result = a + b;
    return result;
}

在调用sum方法时,它的局部变量表将会包含以下内容:

a 的值
b 的值
result 变量

(3)操作栈

每个栈帧内部含有一个操作数栈,通常也叫做操作栈。这是一个后进先出(LIFO)的栈,用于执行方法中的字节码指令。操作数栈的主要作用是作为计算过程中的临时存储空间,用于存储操作指令的输入和输出参数

举例:

public int addNumbers(int a, int b) {
    int result = a + b;
    return result;
}

当这个方法被调用时,JVM会使用操作栈来执行计算过程。以下是一个简化的操作栈示例:

操作   |   操作数栈
---------------------
// 初始状态: 操作数栈为空
iload_1  // 将第一个参数a压入操作数栈
iload_2  // 将第二个参数b压入操作数栈
iadd     // 将栈顶两个元素相加
istore_3 // 将结果存储到局部变量表的索引3(即result)

在这个例子中,iload_1 和 iload_2 将参数 a 和 b 压入操作数栈,iadd 从栈中弹出这两个参数相加,然后 istore_3 将结果存储到局部变量表中的 result 变量中。

(4)动态连接

每个栈帧内部含有一个指向运行时常量池中该栈帧所属方法的引用,这使得当前方法能够动态链接到其它方法和变量。简而言之,动态连接是指方法在运行时实际引用的地址可以被替换成其他的方法或变量地址,这为Java的多态和方法重载提供了基础。

举例:

class A {
    void foo() {
        System.out.println("A's foo()");
    }
}

class B extends A {
    void foo() {
        System.out.println("B's foo()");
    }
}

public class Test {
    public static void main(String[] args) {
        A obj = new B();
        obj.foo();  // 动态链接到B类的foo()方法
    }
}

在上述代码中,虽然变量 obj 的类型是 A,但在运行时,obj.foo() 动态链接到了 B 类的 foo 方法。

(5)方法返回地址

当一个方法开始执行后,它需要知道在完成执行后返回到哪里。方法返回地址就是保存这个信息的地方,它指向调用该方法的位置的下一条指令地址。

举例:

void caller() {
    callee();
    int a = 10; // 当callee方法完成之后,返回到这里继续执行
}

void callee() {
    // do something
    return; // 在这里,方法返回地址指向caller方法中callee调用之后的指令
}

在 caller 方法中调用 callee 方法后,JVM 会在 callee 方法的栈帧中存储返回地址,当 callee 方法执行完毕后,控制权将会返回到 caller 方法中 callee 调用后的位置。

(6)栈内存溢出

  • 栈帧过多导致栈内存溢出(如方法递归调用没有设置下线)
  • 栈帧过大导致栈内存溢出

用debug方式演示:
每个线程会创建一个虚拟机栈,每个方法会创建一个栈帧,放入虚拟机栈。当走到方法b时就会创建三个栈帧(main,a,b),每个方法里面的参数(如变量x e)会被放入到这个栈帧里面。当调用方法b完成回到方法a时,就会释放方法b栈帧
(注:栈内存会自己释放,因此不需要垃圾回收)
在这里插入图片描述

栈是不是越大越好?
不是,如内存为500M,每个栈为1M,那么最多可以有500个线程并发。所以栈越大,线程越少。

3、程序计数器

记住下一条jvm指令的执行地址

(1)特点

  • 是线程私有的(每个线程都有自己的程序计数器,因为每个线程执行地址不一样)
  • 不会存在内存溢出(由jvm规定的)

(2)举例

public class Example {
    public static void main(String[] args) { // 1
        int a = 5; // 2
        int b = 10; // 3
        int c = addNumbers(a, b); // 4 
        System.out.println(c); // 7
    }

    public static int addNumbers(int a, int b) {
        int result = a + b; // 5
        return result; // 6
    }
}

在上面的方法中,程序计算器指向的地址分别是1到7,代码执行的每一步操作都会被记录

4、本地方法栈

本地方法栈的结构与虚拟机栈类似,也是由栈帧(Stack Frame)组成的,栈帧中保存了Native方法的局部变量、操作数栈、方法出口等信息。与虚拟机栈不同的是,本地方法栈中的方法不是用Java语言编写的,而是用其它语言编写的,比如C、C++等。因此,本地方法栈的结构与虚拟机栈类似,但是用于调用本地方法。

这里有一个简单的示例,演示了一个Java程序如何调用一个使用C语言编写的Native方法:

public class NativeExample {
    static {
        System.loadLibrary("NativeLibrary");
    }

    public native void nativeMethod();

    public static void main(String[] args) {
        NativeExample example = new NativeExample();
        example.nativeMethod();
    }
}

在这个示例中,NativeExample类中的nativeMethod方法是一个本地方法,它用native关键字修饰,表示这个方法是用其它语言实现的。在main方法中,通过example.nativeMethod()调用了这个本地方法。在执行时,虚拟机会使用本地方法栈来执行native Method方法的相关操作。

5、方法区

方法区实现方式:永久代、元空间

在早期的 Java 版本中,方法区与永久代有着密切的关系。方法区是一块用于存储类的相关信息、常量、静态变量、即时编译器优化后的代码等数据的内存区域。而永久代是 HotSpot 虚拟机中的概念,它实际上就是方法区的一种实现

在 Java 7 及之前的版本中,永久代用于存储类和方法相关的信息,包括类的字节码、运行时常量池、字段、方法、构造函数等。由于永久代的大小在JVM启动时固定,并且随着应用的运行可能会出现永久代内存溢出的错误(OutOfMemoryError),在Java 8中被元空间所替代。

因此,从 Java 8 开始,永久代逐渐被元空间(Metaspace)所取代。它使用本地内存(即非JVM堆内存)来存储类元数据。这样的设计减少了内存溢出的可能性,因为元空间的大小仅受到系统可用内存的限制。当然,元空间中还是有一个初始大小,并且可以设置上限,一旦超过这个上限,仍然会抛出OutOfMemoryError异常。因此,方法区与永久代之间的关系在 Java 8 及以后的版本中已经不再存在。
在这里插入图片描述
元空间主要包括以下内容:

  • 类的元数据信息:包括类的名称、方法名、访问修饰符、字段描述符等。
  • 静态变量:类的静态变量存放在元空间中。
  • 常量池:其中存放着字符串常量、字面量和符号引用。

方法区的对象不会被Java堆中的垃圾回收器以相同的方式回收,它有自己的内存管理系统(在使用元空间的情况下,内存可以从操作系统直接获取)。

让我们通过一段简单的Java代码,说明方法区中某些部分是如何被使用的:

public class ExampleClass {
    // 常量池中的内容
    private final static String CONSTANT_STRING = "Hello, World!";
    
    // 方法区中静态变量
    private static int counter = 0;

    // 类型信息和方法代码
    public static void increment() {
        counter++;
    }

    public static void main(String[] args) {
        ExampleClass.increment();
        System.out.println(CONSTANT_STRING);
    }
}

在上述代码中:

字符串"Hello, World!"会被存储在常量池中。
静态变量counter会被存储在方法区。
类ExampleClass的类型信息(比如它的方法和字段)也会存储在方法区。
increment方法和main方法的代码,在被即时编译器编译之后,编译后的机器码也会存储在方法区。

当Java程序运行时,JVM会加载ExampleClass,这个过程中会将ExampleClass的类型信息、常量池中的常量、increment和main方法的字节码等数据存储在方法区,静态变量counter同样存储在方法区内,但具体是在永久代还是元空间则取决于JVM的版本及配置。在Java 8及之后版本,这部分数据会存储在操作系统的本地内存中,称作元空间。

6、方法栈和本地方法栈的区别

方法栈(Method stack):

  • 方法栈存储的是 Java 方法的调用信息。每当一个方法被调用时,JVM都会在方法栈中分配一个栈帧(Stack Frame),用于存储该方法的调用信息。
  • 方法栈中的栈帧会随着方法的调用和返回而动态地被创建和销毁,方法栈的栈帧也包括了方法的参数、局部变量以及用于返回的指令地址等信息。

本地方法栈(Native method stack):

  • 本地方法栈则是用于执行本地(Native)方法的栈,即使用本地语言(如 C 或 C++)编写的方法。它与方法栈类似,但是用于执行本地方法。
  • 本地方法栈也会为每个本地方法分配一个栈帧,用于存储本地方法的调用信息。

7、运行时常量池和字符串常量池的区别

  • 运行时常量池:存在于元空间中,用于存储字符串常量、字面量和符号引用。
  • 字符串常量池(String Table): 存在于堆中(jdk8), 存储的是程序中创建的字符串对象,包括字符串常量和通过 intern 操作成为字符串常量的字符串对象。

8、总结

  • 程序计数器:存储jvm指令的执行地址,不会内存溢出
  • 虚拟机栈:每个线程运行时所需要的内存,每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存, 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
  • 本地方法栈:存储非java代码编写的本地方法
  • :通过 new 关键字,创建对象都会使用堆内存。同时包含字符串常量池和数组。
  • 方法区:它存储每个类的结构,如运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括在类和实例初始化以及接口初始化中使用的特殊方法

共同特点:

  • 程序计数器、栈是线程私有;方法区、堆是线程共享。
  • 程序计数器不会内存溢出,其他都会。

二、垃圾回收(Garbage Collection)

1、垃圾判定

垃圾判定是指在编程中确定哪些内存中的对象是“垃圾”,即不再被应用程序使用的对象,因此可以被垃圾回收器回收的过程。

在Java中,垃圾回收(Garbage Collection, GC)主要采用两种基本方法:引用计数法和可达性分析。下面分别对这两种方法进行说明:

(1)引用计数法(Reference Counting)

引用计数算法是一种最直观的垃圾收集技术。其基本思想是给每个对象分配一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1。任何时刻计数器为0的对象就是不可能再被使用的,因此可以回收其占用的内存。

不过,Java并不采用引用计数法来进行垃圾回收,因为它存在循环引用的问题。在循环引用中,两个或多个对象相互引用,但它们可能都已经不再被其他活动部分的应用程序所引用。由于它们相云引用,因此它们的引用计数永远不会达到0,导致内存泄漏。

public class ReferenceCounting {
    Object instance = null;

    public static void main(String[] args) {
        ReferenceCounting objA = new ReferenceCounting();
        ReferenceCounting objB = new ReferenceCounting();

        // 创建循环引用
        objA.instance = objB;
        objB.instance = objA;

        // 尝试手动置空以断开引用
        objA = null;
        objB = null;

        // 希望GC能回收objA和objB,但如果是采用引用计数法,则无法回收
        System.gc();
    }
}

(2)可达性分析(Reachability Analysis)

Java采用的是可达性分析算法来进行垃圾回收。在这种方法中,通过一系列的称为“GC Roots”的对象作为起点,然后向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java中,可作为GC Roots的常见对象包括:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象:例如,正在执行的方法中的局部变量或参数。
  • 方法区中类静态属性引用的对象:这些静态变量所引用的对象也被称为 GC Roots。
  • 方法区中常量引用的对象:例如,字符串常量池(String Table)的引用
  • 本地方法栈中JNI(Java Native Interface)引用的对象:在使用 JNI 调用本地方法的过程中,会涉及到本地方法栈,其中引用的对象也是 GC Roots。

举一个简单的例子来描述可达性分析:

public class ReachabilityAnalysis {
    public static void main(String[] args) {
        ReachabilityAnalysis obj = new ReachabilityAnalysis(); // 对象obj是可达的,因为它被栈上的引用变量所引用

        // 现在让我们断开这个引用
        obj = null; // 此时对象不再可达

        // 垃圾回收可以执行了,它将使用可达性分析来确定obj的内存是否可以被释放
        System.gc();
    }
}

在JVM模型中,垃圾回收主要发生在堆内存(Heap)中,因为这里是存放对象实例的地方。当前主流的JVM使用分代垃圾收集算法,将堆内存分为年轻代(Young Generation),老年代(Old Generation),以及永久代(Permanent Generation,但在Java 8及之后被MetaSpace所替代)。不同代的对象会根据其生命周期的不同被相应的垃圾回收器回收,以提高回收效率。

垃圾回收算法、垃圾回收器的选择以及垃圾回收的时机,通常是由JVM自动管理的,但是开发者可以通过JVM参数来对其进行调优。

2、垃圾回收算法

(1)标记-清除

标记-清除算法分为两个阶段:标记阶段和清除阶段。

  • 标记阶段:从根对象(如活动线程的堆栈指针、静态对象等)开始,递归遍历所有可达的对象,并将它们标记为活动的。
  • 清除阶段:遍历堆内存中所有对象,对于没有被标记为活动的对象,释放其占用的内存空间。

缺点:

  • 整个过程中需要停止应用程序,导致停顿时间(STW,Stop-The-World)。
  • 会产生内存碎片。

(2)标记-整理

标记-整理算法是标记-清除的改进版。在标记活动对象之后,它会将所有存活的对象移到内存的一端,然后清理掉端边界外的内存空间。

优点:

  • 解决了内存碎片问题,不需要复制活动对象。

缺点:

  • 需要移动存活对象,可能会造成较大的内存迁移开销。
  • 需要较多的停顿时间,不适合对响应时间要求较高的应用。

(3)复制

复制算法将堆内存分为两半:一半用于分配内存,另一半处于空闲状态。在垃圾收集期间,它将所有活动对象从当前的内存区域复制到另一半,接着清除原有的内存区域中的所有对象。

优点:

  • 解决了内存碎片问题,适合存活对象较少场景。

缺点:

  • 不适用于处理存活较多对象的场景
  • 会占用双倍内存空间

3、Minor GC 和 Full GC 的区别

Minor GC:对新生代的垃圾回收
Full GC :对堆(新生代、老年代)和方法区(永久代/元空间)的垃圾回收

推荐参考:JVM中 Minor GC 和 Full GC 的区别

4、空间担保策略

空间担保策略是指当触发 minor gc 时,会判断老年代剩余最大连续空间大于历次Minor GC晋升的平均大小 或者 大于新生代所有对象的大小总和 , 大于任意一个,就允许触发MinorGC,反之触发 Full GC

推荐参考:深入理解JVM内存空间的担保策略

5、垃圾回收器

JDK 8 中默认的垃圾回收器组合为Parallel Scavenge(用于Young Generation)加上Parallel Old(用于Old Generation)。

推荐参考:Java中常用的垃圾回收器

三、类的加载过程

Java类加载主要分为三个阶段:加载、链接、初始化

推荐参考:深入理解Java类加载过程

四、双亲委派机制

双亲委派是 Java 类加载器的一种机制。当一个类加载器收到加载类的请求时,它会首先将这个请求委派给父类加载器去完成。只有当父类加载器无法完成这个加载请求时,子类加载器才会尝试加载。

1、双亲委派机制特点

  • 避免重复加载:由于双亲委派机制,如果一个类已经被某个类加载器加载过了,那么其他的类加载器就没有必要再加载一次,可以直接复用已经加载的类。这样可以避免类的重复加载,节省内存。
  • 安全性:通过双亲委派机制,核心类库会被由启动类加载器加载,因此可以防止核心类库被恶意篡改。另外,由于类加载器可以通过双亲委派机制追溯到启动类加载器,所以可以确保核心类库不会被自定义的类所替代,从而保证了系统安全性。

2、如何打破双亲委派机制

要打破双亲委派机制,可以自定义类加载器,并重写 ClassLoader 类中的 loadClass(String name, boolean resolve) 方法(或者是 findClass(String name) 方法,根据具体需求)。自定义的类加载器可以先尝试加载类,而不是直接委派给父加载器。

下面是一个简化的示例,说明如何自定义类加载器以打破双亲委派模型:

public class CustomClassLoader extends ClassLoader {

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 首先, 检查请求的类是否已经被加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 尝试自己加载类,而不是委派给父类加载器
                c = findClass(name);
            } catch (ClassNotFoundException e) {
                // 如果自己无法加载类,那么调用父类加载器尝试加载
                c = super.loadClass(name);
            }
        }
        return c;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 在这里加入具体的类加载逻辑,比如从文件系统中读取.class文件的字节流
        // byte[] classBytes = ...;
        // return defineClass(name, classBytes, 0, classBytes.length);
        
        // 示例中没有具体实现,因为它通常需要读取文件或其他数据源中的类数据
        throw new ClassNotFoundException();
    }
}

在这个例子中,findClass(String name) 方法被重写用于尝试加载类。如果在 findClass 中没有找到类,则会抛出 ClassNotFoundException 异常,然后调用父类加载器尝试加载。

注意,直接破坏双亲委派机制可能会导致各种问题,如类冲突、安全问题等。因此,在实际开发中,只有在真正需要时才应该打破双亲委派模型,并且必须非常小心地实现。

自定义类加载器可以用在很多场景中,例如热部署(hot deploy)一个正在运行的应用程序,这通常需要动态地加载和卸载类。在框架开发中,比如OSGI、JSP的servlet容器等,这样的需求也是很常见的。

五、直接内存

直接内存是操作系统中分配的一块内存,不受JVM管理,Java代码可以直接获取直接内存中的数据。

推荐参考:直接内存(Direct Memory)

六、JAVA中的四种引用

Java 提供了四种不同的引用类型:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

推荐参考:Java 中的四种引用类型和它们的使用场景

七、JVM常用调优参数

JVM提供了一些常用调优参数:-Xms、-Xmx、-Xmn等

推荐参考:JVM常用调优参数

八、JVM调优

正常情况下,JVM 是不需要额外调优的。默认的配置通常适用于许多应用程序,因为 JVM 实现考虑了大量的使用情况,并经过了在不同场景下的测试和优化。
除非是系统有特殊的性能需求或者存在特定的瓶颈,一般来说,在生产环境中使用默认参数是合适的。然而,在一些特殊场景下,可以对 JVM 进行一些微调,以获取更好的性能或者更好的资源利用率。这通常需要仔细评估和测试,以确保调整后的参数能够有效地改善系统的性能。

1、JVM调优方法

JVM调优通常涉及到调整内存设置、选择合适的垃圾回收器以及优化JVM参数等方面。

  • 堆内存设置:通过调整堆(heap)大小,你可以控制Java应用可用的内存数量。堆内存过小可能导致频繁的垃圾回收,降低应用性能;过大则可能导致垃圾回收停顿时间过长。比如:设置-Xms和-Xmx来定义堆的初始大小和最大大小。
  • 选择垃圾回收器:根据应用的需求选择合适的垃圾回收器(GC)。不同的垃圾回收器,比如Parallel GC、CMS、G1 GC,有着不同的特点和适用场景。

2、OOM发生区域

JVM中出现OOM的区域通常有:

  • 堆内存(Heap Memory):如果堆内存太小,或者应用程序中有内存泄漏,都可能导致堆内存OOM。
  • 永久代/元空间(PermGen/Metaspace):存储Java类元数据的地方。如果加载了大量的类或者大量的动态生成类的情形,可能导致这部分内存溢出。
  • 方法栈:比如方法递归调用,可能会导致这个区域内存溢出。

3、常见分析工具

  • JConsole:Java监控和管理控制台,是Java Development Kit (JDK)的一部分,可以用来监控JAVA应用运行时的资源消耗。
  • JVisualVM:集成了多个JDK命令行工具的可视化工具,提供了内存和CPU分析功能。
  • Memory Analyzer Tool (MAT):用于分析堆转储,可以帮助你找出内存泄漏和查看内存消耗的对象。
  • jmap:命令行工具,可以用来生成堆转储文件,分析内存使用情况。

4、模拟OOM和分析示例

下面是一个简单的Java代码片段,用于模拟堆内存溢出。

import java.util.ArrayList;
import java.util.List;

public class GenerateOOM {
    static final int SIZE = 2 * 1024 * 1024;

    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        while (true) {
            list.add(new Object[SIZE]);
        }
    }
}

运行这个程序,很快就会因为堆内存溢出而出现 OutOfMemoryError

首先使用 jps 查看进程ID,然后使用jmap来生成堆转储文件:

jmap -dump:live,format=b,file=heapdump.hprof <PID>

使用MAT打开堆转储文件(heapdump.hprof),MAT将会对文件进行分析,并提供内存使用的概览。

文章来源:https://blog.csdn.net/weixin_44183847/article/details/128409810
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。