深入JVM:探索Java虚拟机

博客思维导图

在这里插入图片描述

1. JVM简介

Java虚拟机(JVM)是Java技术的核心组件,它为Java的跨平台特性提供了基础。JVM不仅仅是一个虚拟机,它是一个完整的运行环境,负责加载、验证、编译和执行Java字节码。在本节中,我们将深入探讨JVM的定义、核心作用以及其跨平台的特性。

1.1 定义与核心作用

Java虚拟机是一个虚拟的计算机实例,它使得Java应用程序可以在任何设备或操作系统上运行,只要该设备或操作系统有一个JVM实现。JVM的主要任务是加载.class文件(Java字节码)并执行它们。这些字节码文件是由Java编译器从.java源文件编译而来的。

JVM的核心作用可以归纳为以下几点:

  • 加载字节码:JVM负责从文件系统或网络资源加载.class文件。

  • 字节码验证:确保加载的字节码是有效的、安全的,并且不会破坏JVM的内部数据结构。

  • 即时编译:JVM可以使用即时编译器(JIT)将字节码转换为本地机器代码,以提高执行速度。

  • 执行程序:JVM创建并管理所有的程序资源,如线程、内存空间和I/O操作,并执行字节码。

  • 提供内置库:JVM提供了Java API,这是一组预编译的类库,可以为Java应用程序提供核心功能。

1.2 JVM的跨平台特性

Java的口号是“一次编写,到处运行”。这得益于Java的跨平台特性,而这一特性的实现则依赖于JVM。

当Java程序被编译时,它被转换为与平台无关的字节码,而不是特定于某个操作系统的机器代码。这意味着,只要一个设备安装了JVM,它就可以运行任何Java应用程序,无论这个程序最初是在哪个平台上编写的。

这种方式的好处是显而易见的:

  • 可移植性:Java应用程序可以在任何安装了JVM的设备上运行,无需进行任何修改。

  • 安全性:由于Java应用程序在虚拟机上运行,它们与底层操作系统隔离,这为应用程序提供了一个安全的执行环境。

  • 性能:尽管Java应用程序在虚拟机上运行,但通过即时编译技术,它们的执行速度可以与本地应用程序相媲美。

  • 集成性:Java应用程序可以与其他语言编写的本地应用程序进行交互,这为复杂的应用程序集成提供了便利。

总之,JVM为Java技术提供了坚实的基础,使其成为当今最受欢迎的编程语言之一。通过深入了解JVM,我们可以更好地理解Java的工作原理,从而更有效地编写和优化Java应用程序。

2. JVM内部结构深度探索

Java虚拟机(JVM)是一个复杂的系统,负责执行Java字节码并提供Java应用程序的运行环境。为了更好地理解Java程序的运行机制,我们需要深入探讨JVM的内部结构和其工作原理。

image-20230915211004027


2.1 类加载机制

在Java中,类的加载、链接和初始化是JVM执行Java程序的基础。这些过程确保Java类正确、安全地加载到JVM中。

2.1.1 双亲委派模型

双亲委派模型是Java类加载的核心机制。它确保Java核心库的安全性,防止恶意代码篡改核心类库。

工作原理:
当一个类加载器尝试加载一个类时,它首先会请求其父类加载器来完成这个任务。这个过程会一直递归到启动类加载器;只有当父类加载器不能完成这个任务时,子类加载器才会尝试自己加载这个类。

// 示例代码:自定义类加载器
public class CustomClassLoader extends ClassLoader {
    
    
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
    
    
        // 委派给父类加载器
        return super.loadClass(name);
    }
}

代码解释: 上述代码中,我们创建了一个自定义的类加载器。当调用loadClass方法时,它会首先委派给其父类加载器。

面试题(阿里云)

什么是双亲委派机制?介绍一些运作过程,双亲委派模型的好处;

如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都不愿意干活,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己想办法去完成,这不就是传说中的双亲委派模式。

动作过程image-20230915210629324

好处

沙箱安全机制:自己写的String.class类不会被加载,这样便可以防止核心API库被随意篡改避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次

2.1.2 OSGI框架

OSGI(Open Service Gateway Initiative)是一个Java模块化框架,它允许应用程序动态地安装、启动、停止和卸载模块。

工作原理:
OSGI框架使用自己的类加载器来加载模块。这允许模块之间有自己的类版本,避免了类版本冲突的问题。

// 示例代码:OSGI BundleActivator
public class MyActivator implements BundleActivator {
    
    
    public void start(BundleContext context) {
    
    
        System.out.println("Module started");
    }

    public void stop(BundleContext context) {
    
    
        System.out.println("Module stopped");
    }
}

代码解释: 上述代码是一个简单的OSGI激活器,它在模块启动和停止时打印消息。

2.1.3 类加载器分类

在JVM中,类加载器被分为三种:

  • 启动类加载器(Bootstrap ClassLoader): 负责加载JVM核心类库,如java.lang.*

  • 扩展类加载器(Extension ClassLoader): 负责加载Java的扩展库,如javax.*

  • 应用类加载器(Application ClassLoader): 负责加载应用程序的类路径、模块路径等。

// 示例代码:获取类加载器
ClassLoader loader = MyClass.class.getClassLoader();
System.out.println(loader);

代码解释: 上述代码获取MyClass的类加载器并打印它。


2.2 JVM运行时数据区

JVM在执行Java程序时,会使用多个内存区域来存储数据。了解这些区域及其用途对于优化性能和诊断问题至关重要。

2.2.1 程序计数器

程序计数器是一个小的内存区域,它存储了当前线程正在执行的字节码的地址。

工作原理:
当JVM执行一个方法时,程序计数器会指向这个方法的第一条字节码指令。随着字节码指令的执行,程序计数器的值会递增。

// 示例代码:模拟程序计数器
public class ProgramCounterSimulation {
    
    
    public static void main(String[] args) {
    
    
        int counter = 0; // 模拟程序计数器
        method1();
        counter += 3; // 假设method1有3条字节码指令
        method2();
        counter += 2; // 假设method2有2条字节码指令
    }

    public static void method1() {
    
    
        // ...
    }

    public static void method2() {
    
    
        // ...
    }
}

代码解释: 上述代码模拟了程序计数器的工作原理。当调用一个方法时,程序计数器的值会递增,反映了字节码指令的执行。

2.2.2 本地方法栈

本地方法栈是一个内存区域,它存储了Java方法的本地变量、返回地址和其他数据。

工作原理:
当JVM调用一个方法时,它会为这个方法创建一个栈帧并压入本地方法栈。当这个方法返回时,它的栈帧会被弹出。

// 示例代码:模拟本地方法栈
public class LocalMethodStackSimulation {
    
    
    public static void main(String[] args) {
    
    
        method1();
        method2();
    }

    public static void method1() {
    
    
        int localVariable1 = 10; // 存储在本地方法栈中
        // ...
    }

    public static void method2() {
    
    
        String localVariable2 = "Hello"; // 存储在本地方法栈中
        // ...
    }
}

代码解释: 上述代码模拟了本地方法栈的工作原理。每个方法的本地变量都存储在本地方法栈中。

2.2.3 Java虚拟机栈

Java虚拟机栈是一个内存区域,它存储了Java方法的操作数栈、局部变量表和其他数据。

工作原理:
与本地方法栈类似,当JVM调用一个方法时,它会为这个方法创建一个栈帧并压

入Java虚拟机栈。但与本地方法栈不同的是,Java虚拟机栈还存储了操作数栈,这是一个用于存储计算过程中的中间结果的栈。

// 示例代码:模拟Java虚拟机栈
public class JVMStackSimulation {
    
    
    public static void main(String[] args) {
    
    
        int result = add(10, 20); // 操作数栈存储10和20,然后存储30(结果)
        System.out.println(result);
    }

    public static int add(int a, int b) {
    
    
        int sum = a + b; // 操作数栈存储a和b的值,然后存储它们的和
        return sum;
    }
}

代码解释: 上述代码模拟了Java虚拟机栈的工作原理。add方法的操作数栈首先存储ab的值,然后存储它们的和。

2.2.4 堆

堆是JVM中的一个重要的内存区域,用于存储对象实例。它被划分为年轻代和老年代,以优化垃圾回收性能。

工作原理:
新创建的对象首先被分配在年轻代。随着时间的推移,存活的对象会从年轻代移动到老年代。垃圾回收主要发生在年轻代,因为大多数对象很快就会变得不可达。

// 示例代码:创建对象
public class HeapSimulation {
    
    
    public static void main(String[] args) {
    
    
        Person person = new Person("Alice", 25); // 对象被分配在堆上
    }
}

class Person {
    
    
    String name;
    int age;

    Person(String name, int age) {
    
    
        this.name = name;
        this.age = age;
    }
}

代码解释: 上述代码创建了一个Person对象,这个对象被分配在堆上。

2.2.5 元数据区

元数据区用于存储JVM加载的类的元数据,如类的名称、字段和方法。这个区域不是堆的一部分,它有自己的垃圾回收策略。

工作原理:
当JVM加载一个类时,它会将这个类的元数据存储在元数据区。这个区域是固定大小的,如果它被填满,JVM会触发垃圾回收来回收不再使用的类的元数据。

// 示例代码:加载类
public class MetadataAreaSimulation {
    
    
    public static void main(String[] args) throws ClassNotFoundException {
    
    
        Class<?> clazz = Class.forName("com.example.MyClass"); // 类的元数据被存储在元数据区
    }
}

代码解释: 上述代码加载了一个类,并将其元数据信息存储在元数据区。

2.3 JVM内存区域的性能调优实践

理解JVM的内存区域对于Java应用程序的性能调优至关重要。通过调整这些区域的大小和参数,我们可以优化应用程序的性能,减少垃圾回收的暂停时间,并提高系统的吞吐量。

2.3.1 调优堆内存

实践案例:
假设一个Web应用程序在高并发情况下经常出现OutOfMemoryError。通过分析,我们发现这是因为堆内存设置得太小。

解决方案:
增加堆的最大大小。例如,将最大堆大小设置为2GB:

java -Xmx2g -jar my-web-app.jar

建议:

  • 使用监控工具(如JVisualVM或JMC)定期检查堆的使用情况。
  • 如果应用程序有大量的短暂对象,考虑增加年轻代的大小。
  • 如果应用程序有大量的长时间存活的对象,考虑增加老年代的大小。
2.3.2 调优元数据区

实践案例:
一个应用程序在运行时动态生成并加载了大量的类。随着时间的推移,应用程序抛出了OutOfMemoryError: Metaspace错误。

解决方案:
增加元数据区的大小。例如,将元数据区的最大大小设置为256MB:

java -XX:MaxMetaspaceSize=256m -jar my-dynamic-app.jar

建议:

  • 如果应用程序使用了大量的动态代理或CGLIB,考虑增加元数据区的大小。
  • 使用-XX:MetaspaceSize参数设置元数据区的初始大小,以避免频繁的扩展。
2.3.3 调优垃圾回收策略

实践案例:
一个在线交易应用程序在高并发情况下经常出现长时间的垃圾回收暂停,导致用户体验下降。

解决方案:
切换到低延迟的垃圾回收器,如G1或ZGC:

java -XX:+UseG1GC -jar my-trading-app.jar

建议:

  • 根据应用程序的需求选择合适的垃圾回收器。例如,对于低延迟应用程序,G1或ZGC可能是一个好选择;对于高吞吐量应用程序,Parallel GC可能更合适。
  • 使用-XX:GCTimeRatio-XX:MaxGCPauseMillis参数来调整垃圾回收的行为。

3. JVM垃圾回收机制

Java虚拟机(JVM)的垃圾回收(GC)机制是Java内存管理的核心组成部分。它自动回收不再使用的对象,从而释放内存。在本节中,我们将深入探讨JVM的垃圾回收机制,包括常见的GC收集器、垃圾回收算法以及JVM的内存分区。

3.1 常见GC收集器

Java提供了多种GC收集器,每种收集器都有其特定的应用场景和优势。选择合适的收集器可以显著提高应用程序的性能。

3.1.1 串行收集器

串行收集器是最简单的GC收集器,它在单线程环境中工作,并在进行垃圾回收时暂停所有的应用线程。

优势:

  • 适用于单线程应用程序。
  • 由于没有线程切换的开销,它在单线程环境中通常比其他收集器更快。

缺点:

  • 不适用于多线程应用程序,因为它会导致长时间的暂停。
// 启用串行收集器
// JVM参数: -XX:+UseSerialGC
3.1.2 CMS(并发标记清除)收集器

CMS收集器是一种并发收集器,它在标记和清除阶段与应用线程并发执行,从而减少暂停时间。

优势:

  • 适用于响应时间要求严格的应用程序。
  • 并发执行,减少暂停时间。

缺点:

  • 可能导致较高的CPU使用率。
  • 由于它不进行压缩,可能导致内存碎片。
// 启用CMS收集器
// JVM参数: -XX:+UseConcMarkSweepGC
3.1.3 并行收集器

并行收集器在多线程环境中工作,它在垃圾回收时使用多个线程。

优势:

  • 适用于多线程应用程序。
  • 可以充分利用多核CPU。

缺点:

  • 在垃圾回收时会暂停所有的应用线程。
// 启用并行收集器
// JVM参数: -XX:+UseParallelGC
3.1.4 G1收集器

G1收集器是一种面向区域的收集器,它将堆分为多个区域,并优先回收垃圾最多的区域。

优势:

  • 可以预测暂停时间,从而满足响应时间的要求。
  • 高效地利用多核CPU和大量内存。

缺点:

  • 可能需要更多的CPU资源。
// 启用G1收集器
// JVM参数: -XX:+UseG1GC

面试题:
GC分哪两种,Minor GC 和Full GC有什么区别?什么时候会触发Full GC?分别采用什么算法?

image-20230915210931693

对象从新生代区域消失的过程,我们称之为 “minor GC

对象从老年代区域消失的过程,我们称之为 “major GC

Minor GC

清理整个YouGen的过程,eden的清理,S0\S1的清理都会由于MinorGC Allocation

Failure(YoungGen区内存不足),而触发minorGC

Major GC

OldGen区内存不足,触发Major GC

Full GC

Full GC 是清理整个堆空间—包括年轻代和永久代

Full GC 触发的场景

1)System.gc

2)promotion failed (年代晋升失败,比如eden区的存活对象晋升到S区放不下,又尝试直接晋升到Old区又放不下,那么Promotion Failed,会触发FullGC)

3)CMS的Concurrent-Mode-Failure

由于CMS回收过程中主要分为四步: 1.CMS initial mark 2.CMS Concurrent mark 3.CMS remark 4.CMS Concurrent sweep。在2中gc线程与用户线程同时执行,那么用户线程依旧可能同时产生垃圾, 如果这个垃圾较多无法放入预留的空间就会产生CMS-Mode-Failure, 切换为SerialOld单线程做mark-sweep-compact。

4)新生代晋升的平均大小大于老年代的剩余空间 (为了避免新生代晋升到老年代失败)当使用G1,CMS 时,FullGC发生的时候是Serial+SerialOld。当使用ParalOld时,FullGC发生的时候是 ParallNew +ParallOld.

3.2 垃圾回收算法

垃圾回收算法决定了如何识别和回收不再使用的对象。选择合适的算法可以提高垃圾回收的效率。

3.2.1 复制算法

复制算法将堆分为两个相等的区域,每次只使用其中一个区域。当这个区域被填满时,它会将仍然存活的对象复制到另一个区域,并清空当前区域。

优势:

  • 没有内存碎片。
  • 只需要处理存活的对象。

缺点:

  • 堆的有效容量减半。
// 示例代码:复制算法的简化表示
public void copy() {
    
    
    for (Object obj : fromSpace) {
    
    
        if (isAlive(obj)) {
    
    
            toSpace.add(obj);
        }
    }
    fromSpace.clear();
    swap(fromSpace, toSpace);
}

代码解释: 上述代码模拟了复制算法的基本工作原理。它首先遍历fromSpace,将存活的对象复制到toSpace,然后清空fromSpace并交换两个空间。

3.2.2 标记-清除算法

标记-清除算法分为两个阶段:标记阶段和清除阶段。在标记阶段,它会标记所有存活的对象;在清除阶段,它会清除所有未被标记的对象。

img

优势:

  • 不需要移动对象。
  • 可以回收任何不再使用的对象。

缺点:

  • 可能导致内存碎片。
  • 清除阶段可能导致较长的暂停时间。
// 示例代码:标记-清除算法的简化表示
public void markAndSweep() {
    
    
    markAllAliveObjects();
    sweepUnmarkedObjects();
}

代码解释: 上述代码模拟了标记-清除算法的基本工作原理。它首先标记所有存活的对象,然后清除所有未被标记的对象。

3.2.3 标记-整理算法

标记-整理算法是标记-清除算法的一个变种,它在标记和清除阶段之间添加了一个整理阶段。在整理阶段,它会移动所有存活的对象,从而消除内存碎片。

img

优势:

  • 没有内存碎片。
  • 可以回收任何不再使用的对象。

缺点:

  • 需要移动对象,可能导致较长

的暂停时间。

// 示例代码:标记-整理算法的简化表示
public void markCompact() {
    
    
    markAllAliveObjects();
    compactAliveObjects();
    sweepUnmarkedObjects();
}

代码解释: 上述代码模拟了标记-整理算法的基本工作原理。它首先标记所有存活的对象,然后整理存活的对象,最后清除所有未被标记的对象。

3.3 JVM内存分区

JVM将内存分为几个区域,每个区域都有其特定的用途和垃圾回收策略。

3.3.1 年轻代

年轻代是堆的一部分,它包括Eden区和两个Survivor区。大多数新创建的对象首先被分配到Eden区。当Eden区被填满时,存活的对象会被移动到一个Survivor区,而非存活的对象会被回收。

// 示例代码:创建一个新对象
Object obj = new Object();

代码解释: 上述代码创建了一个新对象,这个对象首先被分配到Eden区。

3.3.2 老年代

老年代是堆的另一部分,它用于存储长时间存活的对象。当一个对象在Survivor区存活了足够长的时间,它会被移动到老年代。

// 示例代码:模拟对象的长时间存活
for (int i = 0; i < 10000; i++) {
    
    
    Object obj = new Object();
    // 使用obj...
}

代码解释: 上述代码创建了大量的对象,并使用它们。这些对象可能会被移动到老年代,因为它们存活了足够长的时间。

3.3.3 元数据区

元数据区用于存储JVM加载的类的元数据信息,如类的名称、字段和方法。这个区域不是堆的一部分,它有自己的垃圾回收策略。

// 示例代码:加载一个类
Class<?> clazz = Class.forName("com.example.MyClass");

代码解释: 上述代码加载了一个类,并将其元数据信息存储在元数据区。

4. JVM工具与性能调优

Java虚拟机(JVM)提供了一系列的工具,帮助开发者监控、诊断和优化应用程序的性能。这些工具为我们提供了深入的洞察,使我们能够更好地理解应用程序在运行时的行为。在本节中,我们将详细探讨这些工具的使用方法和它们在性能调优中的应用。


4.1 jmap:Java内存映射工具

jmap是一个用于生成堆转储和内存映射的工具。它可以帮助我们诊断内存泄漏和其他内存相关的问题。

4.1.1 生成堆转储

堆转储是JVM内存的快照,它包含了所有的对象及其引用。通过分析堆转储,我们可以识别内存泄漏和优化内存使用。

# 生成堆转储
jmap -dump:format=b,file=heapdump.hprof <pid>

代码解释: 上述命令会为指定的进程ID生成一个名为heapdump.hprof的堆转储文件。

4.1.2 查看堆配置信息

jmap还可以显示JVM的堆配置信息,这对于调优堆大小和其他相关参数非常有用。

# 查看堆配置信息
jmap -heap <pid>

代码解释: 上述命令会显示指定进程的堆配置信息,包括堆的大小、使用情况和垃圾回收策略。

4.2 jhat:Java堆分析工具

jhat是一个用于分析堆转储的工具。它可以解析hprof文件,并提供一个Web界面来查询数据。

4.2.1 启动jhat
# 使用jhat分析堆转储
jhat heapdump.hprof

代码解释: 上述命令会启动一个Web服务器,默认端口为7000,您可以在浏览器中访问http://localhost:7000来查看分析结果。

4.2.2 查询对象

jhat提供了一个简单的OOQL(Object Oriented Query Language)来查询对象。例如,您可以查询所有的String对象,或者查找特定的对象引用。

4.3 jstack:Java线程堆栈跟踪工具

jstack是一个用于生成线程堆栈跟踪的工具。它可以帮助我们诊断线程死锁、线程饥饿和其他并发问题。

4.3.1 生成线程堆栈跟踪
# 生成线程堆栈跟踪
jstack <pid>

代码解释: 上述命令会为指定的进程ID生成线程堆栈跟踪。这些跟踪信息可以帮助我们识别线程的状态和它们正在执行的任务。

4.4 jinfo:Java配置信息工具

jinfo可以显示和调整运行时的JVM配置。这对于调优JVM参数非常有用。

4.4.1 查看JVM标志
# 查看JVM标志
jinfo -flags <pid>

代码解释: 上述命令会显示指定进程的JVM标志,包括堆大小、垃圾回收策略等。

4.4.2 修改JVM标志
# 修改JVM标志
jinfo -flag +PrintGCDetails <pid>

代码解释: 上述命令会为指定进程启用PrintGCDetails标志,这会导致JVM打印详细的垃圾回收日志。

4.5 jps:Java进程状态工具

jps是一个显示Java进程信息的工具。它可以列出本地机器上运行的所有Java进程。

# 列出所有Java进程
jps -l

代码解释: 上述命令会列出本地机器上运行的所有Java进程及其主类名。

猜你喜欢

转载自blog.csdn.net/weixin_46703995/article/details/132911840
今日推荐