JVM | 从类加载到JVM内存结构

news2024/11/24 1:14:51

引言

我在上篇文章:JVM | 基于类加载的一次完全实践 中为你讲解如何请“建筑工人”来做一些定制化的工作。但是,大型的Java应用程序时,材料(类)何止数万,我们直接堆放在工地上(JVM)上吗?相反,JVM有着一套精密的管理机制,来确保类的加载、验证、解析和初始化等任务能够有序且高效地完成。
在Java的世界中,虚拟机(JVM)是我们每一个程序的运行环境,而它的内存结构更是决定我们程序运行性能的关键因素。理解JVM的内存结构,不仅可以帮助我们编写出更高效的代码,而且可以在程序出现问题时,更快地定位并解决问题。然而,JVM内存结构的复杂性,很多人仍然存在许多误解和疑惑。
在本篇文章中,我们将详细地探讨这些“建筑工人”是如何处理“建筑材料”的,从而帮助你更深入地理解JVM类加载和初始化的内部工作机制。希望通过这篇文章,可以带你更深入地理解Java程序的运行机制。让我们开始吧!


类的加载

我在之前为你讲解了类的生命周期,你还记得吗?我们来回顾下:加载、验证、准备、解析、初始化、使用和卸载。
接下来,我们再深入分析完整的过程。

加载类进JVM内存

还是以Building为例。假设你在编译器中编写了Building类,并生成了相应的字节码文件Building.class。当你启动你的Java程序时,首先JVM启动并初始化。在这个过程中,JVM的类装载子系统起着关键的作用。类装载子系统的主要职责就是加载类到JVM中。当类被加载时,Java虚拟机首先将类的元信息放入运行时数据区的元空间中,然后在堆中生成java.lang.Class类的实例。这个Class对象会包含指向元空间中类元信息的引用。文字还是过于抽象,我画了一张图,你看:
在这里插入图片描述

这里有几个让人混淆的地方,我来为你解释一下:

两个Class

图中有两处Building.class。但是,此Class非彼Class。第一步的Class代表着Building的字节码文件。而第二步的Class则为指向Building类元信息的Class对象。

两处元空间

这里我从不同的JDK内存结构讲起,你可以比较这两者差异:
在JDK7里,类元数据信息被存储在堆的一部分,叫做方法区,它需要参与垃圾回收,但时常被GC忽略。所以方法区的存在让内存管理成本变高,而且在空间分配不当的情况下,容易出现内存溢出的情况。
所以在JDK8时,将方法区改为元空间,并把其移到本地内存中,这样可以更好地管理内存,避免出现内存溢出的情况。

JVM内存和直接内存

在图中你可以看到,JVM内存本地内存都属于(物理)内存的一部分,为什么要把它们分开讨论呢?因为目标不同,JVM是由JVM进程管理的一块内存空间,它可以对其中的内存进行自动垃圾收集。而本地内存是不受JVM管理,而且不受JVM内存设置的限制。

直接内存和(操作系统)内存

虽然直接内存不受垃圾回收管理。但是它依然是Java虚拟机从操作系统申请的。它可以用于高效的I/O操作,如果你想使用直接内存空间可以使用这个方法:ByteBuffer.allocateDirect()


类的链接过程

接下来我们看下链接的过程,链接分为三步:验证阶段,准备阶段,解析阶段。这个过程由类加载子系统来完成,我们来看下:

验证阶段

JVM 读取类文件后,需要对其进行验证,确保这个类文件满足 JVM规范要求,不会有安全问题。

准备阶段

JVM 为类的静态变量分配内存,并且为它们设置默认值。在我们的 Building 类中,constructionYear 就是一个静态变量,所以它会在这个阶段被初始化为 0(对于 int 类型,初始化默认值为 0)。静态变量是属于类的,我们会把它放在元空间中,你看:
在这里插入图片描述

解析阶段

JVM 将类的二进制数据中的符号引用替换为直接引用。这个过程是在元空间完成的。符号引用就是一组符号来描述所引用的目标,直接引用就是直接指向目标的指针、相对偏移量或者是一个能直接定位到目标的句柄。
直接引用好理解,符号应用是啥?以Building为例,符号引用就是:org.kfaino.jvm.Building.construct:()Lorg/kfaino/jvm/Building; 这两个东西都在元空间的运行时常量池中,你看:
在这里插入图片描述


类的初始化阶段

在讲类初始化之前,我们应该要知道类什么时候开始初始化,什么时候又不初始化?这里也是面试的常考题,我们来重点分析下。


类什么时候不初始化?

我直接以代码举例,你可以看下:

static String CONSTANT = "我是静态常量,我要被放到堆的常量池里面了";
static int i = 128;

这里展示了两种情况,引用类型的String会被放到堆的字符串常量池中,而int类型则会被放在上面的元空间的静态变量中,你可以结合上面的图理解。接下来,我们看下初始化的情况。


类什么时候开始初始化?

还是以代码举例,你可以看下:

Building building = new Building();
Building.静态方法();
// 如果initializeBoolean为false也不会初始化
Class<?> clazz = Class.forName("org.kfaino.jvm.Building");

// 作为父类的情况
class SubBuilding extends Building {}

看完这些初始化的情况之后,我们来看下具体是怎么初始化的。


类的初始化

初始化阶段首先会为对象分配内存,内存分配完成后,需要将分配给对象的内存空间都初始化为零值(分配零值)。然后设置对象头。分配内存好理解,因为当Class被加载进元空间中就已经可以算出每个类型的内存大小了。至于对象头,我打算在垃圾回收时为你讲解,限于篇幅,这里按下不表。
这里的分配零值也有可考的内容,你看:

public class ZeroTest {
    int i;  
    public void testMethod() {
        int j;  
        System.out.println(i);  
        // Variable 'j' might not have been initialized
        System.out.println(j);  
    }
}

因为i在初始化时有分配0,所有可以正常输出。但是j是局部变量,没有初始化就会报错。

做完这三件事之后,JVM 会执行类的初始化代码。对于 Building 类来说,constructionYear 在这个阶段会被初始化为 2023,这个值是在类的静态初始化器(<clinit>)中设置的。
我在上篇文章中说到:如果我们在多线程中使用类加载器,可能会导致类被重复加载多次。除了会浪费资源外,还会导致我们一些静态初始化代码被执行多次。 指的就是<clinit>
。有关也有一个常见的面试题,我为你展示代码,你暂停思考下,结果如何:

public class Building {
    static int constructionYear = 2023;

    static {
        constructionYear = 2024;
    }

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

想好了吗?最终答案是2024。因为静态变量和静态代码块会放在静态初始化器中按顺序执行的。
至此,类的完整初始化流程已经为你梳理完毕,顺便我画了一张图,你可以看一下:
在这里插入图片描述


使用

在完成初始化后,类就可以被应用程序正常使用了。当你调用一个方法时,JVM会为这个方法创建一个新的栈帧,并压入到当前线程的Java栈中。Java栈是线程私有的内存区域,用于存储每个方法调用的状态,包括局部变量、操作数栈、动态链接等信息。

方法调用

方法调用具体过程是什么样的呢? 依然以 Building 为例, 我i先改造下它,加上一个计算建筑年龄的方法,你看:

public class Building {
    private static final int CONSTRUCTION_YEAR = 1998;

    public int calculateAge(int currentYear) {
        return currentYear - CONSTRUCTION_YEAR;
    }
}

接下来,假设有一段代码调用了 calculateAge 方法:

public static void main(String[] args) {
    Building building = new Building();
    int age = building.calculateAge(2023);
}

calculateAge 方法被调用时,我们来看下在JVM虚拟机内存发生了什么?为了方便你理解, 我事先画了一张图,你看:
在这里插入图片描述
我在图中完整标注出执行顺序,你可以暂停看下。接下来我详细的为你解释:

  1. 方法调用:当Java代码执行到building.calculateAge(2023)时,首先JVM会通过对象引用(即building)查找到类Building,然后在类中查找calculateAge方法的符号引用。
  2. 动态链接:JVM会根据Building类中的符号引用找到calculateAge方法在运行时常量池中的直接引用,获取改方法的内存地址。
  3. 创建新的栈帧:JVM为调用的方法创建一个新的栈帧,并推入当前线程的Java栈顶。这个栈帧包含局部变量表、操作数栈、动态链接和方法出口
  4. 初始化局部变量表:JVM将方法调用的参数(即currentYearthis)存储到新栈帧的局部变量表中。
  5. 更新程序计数器:JVM的程序计数器更新为calculateAge方法的第一条字节码指令。
  6. 执行方法体: JVM开始执行calculateAge方法的字节码。当执行到currentYear - CONSTRUCTION_YEAR时,它会将currentYearCONSTRUCTION_YEAR推入操作数栈,然后执行减法操作,并将结果推入操作数栈顶。
  7. 方法返回:执行完calculateAge方法后,JVM将操作数栈顶的结果(即年龄)作为方法返回值,并将calculateAge方法的栈帧从Java栈中弹出。
  8. 接收返回值:calculateAge方法的返回值被推入调用者(即main方法)的操作数栈中,并赋值给局部变量age
  9. 更新程序计数器:JVM的程序计数器更新为main方法的下一条指令。

至此,我们就完成了从类的加载,到类的实例化,再到类的使用和最后的垃圾回收的整个过程。在这个过程中,你可以看到JVM运行时数据区的各个部分是如何协同工作的。细心体会之后,你会发现类的加载和初始化阶段主要与元空间有关,而类的实例化阶段主要与堆内存有关。接下来我们来看下类不用之后如何被卸载。


卸载

垃圾回收

Building对象不再被任何引用变量引用时(对象不可达),它就成为了垃圾。在某个时间点,垃圾收集器会回收这个对象占用的堆内存,这块我将在后续的垃圾回收为你详细讲解。

类的完全卸载

如果Building类的ClassLoader实例被回收,且没有任何线程在Building类的方法内执行,且没有任何Java栈帧持有Building类的方法的引用,那么JVM会判断Building类可以被卸载,并可能在未来的某个时间点,由垃圾收集器回收其在元空间内占用的内存。对,你没听错。方法区也可以进行垃圾回收。但是,类的完全卸载是一件苛刻的事情,你还记得我在第一篇文章中说的AppClassLoader吗?它是由BootstrapClassLoader创建,它的生命周期与JVM一样长,不会被垃圾回收。所以由AppClassLoader创建的类不会被卸载。当然,如果你想要卸载类,可以用第二篇文章中的自定义类加载器。


文中重要部分解析

初始化和未初始化

我在前面强调:什么时候会进行类的初始化阶段,什么会只进行加载和链接。知道这两个差异有什么用呢?我们在编写代码的时候可以减少内存开销,我们现在知道类的初始化阶段需要分配内存,如果我们写一个懒加载,在使用时才初始化,那么我们的内存就会减少很多。相信你已经明白它的价值了。当然,空有概念没有代码可不行,我为你举一个例子,你可以看下:

public class ConfigManager {
    private Map<String, Supplier<Config>> allConfigs = new HashMap<>();

    public ConfigManager() {
        // 在初始化阶段,只是将配置类的构造函数注册到map中
        allConfigs.put("config1", Config1::new);
        allConfigs.put("config2", Config2::new);
        // ...
        allConfigs.put("configN", ConfigN::new);
    }

    public Config getConfig(String name) {
        return allConfigs.get(name).get();
    }
}

相比原来new的操作,我使用了Config1::new。它不会在一开始就被初始化,而是在我们getConfig()的时候,才进行初始化。这就是专家级和普通级别程序员的差距。

直接内存VSJVM内存

我在之前为你提到:ByteBuffer.allocateDirect() 方法,它可以使用直接内存。用直接内存有什么好处?答案是可以减少内存复制的开销,直接缓冲区可以直接在内存中进行数据操作,无需将数据复制到Java堆内存中。还是老规矩,我用代码为你演示一个读取文件IO的场景,你看:

	// 一个5G的视频
    private static final String FILE_PATH = "C:\\Users\\xxx\\Desktop\\1.mp4";
    // 1MB
    private static final int BUFFER_SIZE = 1024 * 1024;

    public static void main(String[] args) throws Exception {
    	// 我用了懒加载
        testBufferAllocator(ByteBuffer::allocate, "Heap Buffer");
        testBufferAllocator(ByteBuffer::allocateDirect, "Direct Buffer");
    }

    private static void testBufferAllocator(BufferAllocator allocator, String testName) throws Exception {
        try (FileChannel channel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ)) {
            ByteBuffer buffer = allocator.allocate(BUFFER_SIZE);

            Instant start = Instant.now();
            while (channel.read(buffer) > 0) {
                buffer.clear();
            }
            Instant end = Instant.now();

            System.out.printf("%s: %s ms%n", testName, Duration.between(start, end).getNano() / 1000000);
        }
    }

    private interface BufferAllocator {
        ByteBuffer allocate(int capacity);
    }

我分别用堆缓存直接缓存来测试它们两个的吞吐量。我们来看下结果:

Connected to the target VM, address: '127.0.0.1:5061', transport: 'socket'
Heap Buffer: 934 ms
Direct Buffer: 765 ms
Disconnected from the target VM, address: '127.0.0.1:5061', transport: 'socket'

Process finished with exit code 0

直接内存比堆内存快了将近200ms。这两种内存的差距就在于堆内存多出了数据从内核缓冲区复制到Java堆内存中的缓冲区步骤。


关于intern()方法

我在上面说到,String类型的静态变量会被放到堆的字符串常量池中。它的目的就是为了减少相同字符串初始化带来的开销。当然,这样的设计就会带来一个问题。你来看下这段代码:

String s1 = "Building";
String s2 = new String("Building");
System.out.println(s1 == s2);
System.out.println(s1 == s2.intern()); 

输出结果是多少呢?暂停思考下,有答案了你再接着往下看

我来公布答案:第一个为false ,因为 s2 是一个新的字符串实例:第二个为true,因为 s2.intern() 返回的是字符串常量池中的 “Hello”;

如果你感兴趣还可以阅读官方文档,我对相关部分进行了截图,你可以看下,链接已放在参考文献中,如果你感兴趣,也可以阅读。
在这里插入图片描述

总结

至此,本篇完结。我们来回顾一下:本篇文章是类加载过渡到JVM内存结构的衔接文章。为了让你把之前的知识串起来,我结合了内存结构重新为你讲解类的生命周期。希望看完这篇文章,你会有不一样的收获。

参考文献

  1. Java虚拟机规范(Java SE 8版)
  2. JVMInternals
  3. JavaGuide Java内存区域详解

后续

本篇文章从类的完整生命周期的角度为你深入解析了JVM内存结构,但仍有一些细节未涉及,例如:本地方法栈的具体工作方式,以及本地方法是C++代码,它是如何运作的?在接下来的文章中,我将进一步展开,为你勾勒出JVM内存结构的全貌,让你对其有更深入、全面的理解。敬请继续关注!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/825871.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

企业如何有效保护文件传输的安全性

文件传输是现代商业世界中每个企业日常操作的必需品。但是&#xff0c;传统的文件传输方式&#xff0c;如电子邮件和网络共享&#xff0c;并不总是安全可靠。黑客攻击、网络钓鱼和数据泄露等风险时刻存在。因此&#xff0c;企业需要采取措施保障文件传输的安全性。本文将介绍如…

Shell脚本学习-case条件语句

case条件语句相当于多分支的if/elif/else条件语句&#xff0c;但是它更规范工整。常被应用于实现系统服务启动脚本等企业应用场景中。 语法结构&#xff1a; case "变量" in值1)指令1...;;值2)指令2...;;*)指令3... esac 说明&#xff1a; 1&#xff09;case语句…

从 GPU 到 ChatGPT,一文带你理清GPU/CPU/AI/NLP/GPT之间的千丝万缕【建议收藏】

目录 硬件 GPU 什么是 GPU&#xff1f; GPU 是如何工作的&#xff1f; GPU 和 CPU 的区别 GPU 厂商 海外头部 GPU 厂商&#xff1a; 国内 GPU 厂商&#xff1a; nvidia 的产品矩阵 AI 什么是人工智能 (Artificial Intelligence-AI)&#xff1f; 人工智能细分领域 …

手把手教你写代码——基于控制台的通讯录管理系统(多人)(代码详细注释)

写在前面 本文章适合刚开始学习java的同学&#xff0c;不适合已参与java开发的人群&#xff01;本项目源代码已绑定资源中可免费获取&#xff01;如果对你有帮助请 栏目介绍 本栏目专为入门java学习者设计的一些简单的入门项目 功能介绍 本项目为简单的基于控制台的通讯录管理系…

音乐节《迷笛音乐节》游玩感

上周&#xff0c;去了烟台&#xff0c;参加音乐节&#xff0c;以前从未参加过&#xff0c;所以趁着本周六周日双休的时候&#xff0c;去游玩了一次。&#xff08;1&#xff09;一种新奇体验 对于自己来说&#xff0c;参加音乐节还是一种新奇的体验的&#xff0c;也是疫情放开了…

【MyBatis】初学MyBatis

目录 MyBatis 是什么&#xff1f;MyBatis框架搭建1.添加MyBatis框架2.设置MyBatis配置数据库的相关链接信息xml 保存路径和命名格式 根据MyBatis写法完成数据库的操作MyBatis插件MyBatis传递参数查询${} 和 #{} 有什么区别&#xff1f;SQL注入问题 MyBatis like查询MyBatis多表…

Lombok,一个神奇的存在

1、概述 Lombok主要用于在编译POJO类源文件时通过注解的方式自动为该类生成构造方法、getter/setter、equals、hashcode、toString等方法&#xff0c;有效地简化了POJO类代码&#xff0c;提高了软件的开发速度。 2、安装 a、启动IntelliJ IDEA—>点击CtrlAltS快捷键&…

【LeetCode】链表反转

题目 题目&#xff1a;给定单链表头节点&#xff0c;将单链表的链接顺序反转过来 例&#xff1a; 输入&#xff1a;1->2->3->4->5 输出&#xff1a;5->4->3->2->1 要求&#xff1a;按照两种方式实现 解决办法 方式一&#xff1a;&#xff08;直接迭…

从0开始自学网络安全(黑客)

前言 黑客技能是一项非常复杂和专业的技能&#xff0c;需要广泛的计算机知识和网络安全知识。你可以参考下面一些学习步骤&#xff0c;系统自学网络安全。 在学习之前&#xff0c;要给自己定一个目标或者思考一下要达到一个什么样的水平&#xff0c;是学完找工作&#xff08;…

这所211考数一英二,学硕降分33分,十分罕见!

一、学校及专业介绍 合肥工业大学&#xff08;Hefei University of Technology&#xff09;&#xff0c;简称“合工大”&#xff0c;校本部位于安徽省合肥市&#xff0c;是中华人民共和国教育部直属的全国重点大学&#xff0c;是国家“双一流”建设高校&#xff0c; 国家“211工…

PHP代码审计——实操!

ctfshow PHP特性 web93 八进制与小数点 <?php include("flag.php"); highlight_file(__FILE__); if(isset($_GET[num])){$num $_GET[num];if($num4476){die("no no no!");}if(preg_match("/[a-z]/i", $num)){die("no no no!")…

git 忽略掉不需要的文件

第一步&#xff1a;创建.gitignore文件 touch .gitignore 第二步&#xff1a;使用vi编辑器 输入不需要的文件&#xff0c;或用通配符*来忽视一系列文件 效果&#xff1a;

【Java可执行命令】(十二)依赖分析工具jdeps:通过静态分析字节码并提取相关信息来实现依赖分析 ~

Java可执行命令之jdeps 1️⃣ 概念2️⃣ 优势和缺点3️⃣ 使用3.1 语法格式3.2 jdeps -dotoutput < dir>3.3 jdeps -s3.4 jdeps -v3.5 jdeps -cp < path>3.6 注意事项&#xff1a; 4️⃣ 应用场景&#x1f33e; 总结 1️⃣ 概念 Java中的jdeps命令是一个用于分析类…

使用脱机 MFA确保远程员工的安全

远程工作支持的优势 未更改的企业访问&#xff1a;远程工作支持开辟了访问企业网络和资源以及其中保存的数据的替代方法。应采取必要措施&#xff0c;确保它们保持完整&#xff0c;不受远程破坏企图的影响。提高工作效率&#xff1a;理想情况下&#xff0c;远程工作支持可提高…

程序框架-事件中心模块-观察者模式

1.Monster //触发事件 EventCenter.GetInstance().EventTrigger("MonsterDead",this);2.Player void Start() { EventCenter.GetInstance().AddEventListener("MonsterDead", MonsterDeadDo); }public void MonsterDeadDo(object info) {Debug.Log(&q…

【测试开发】Mq消息重复如何测试?

本篇文章主要讲述重复消费的原因&#xff0c;以及如何去测试这个场景&#xff0c;最后也会告诉大家&#xff0c;目前互联网项目关于如何避免重复消费的解决方案。 Mq为什么会有重复消费的问题? Mq 常见的缺点之一就是消息重复消费问题&#xff0c;产生这种问题的原因是什么呢…

从封面开始,打造一个引人注目的视频作品

在如今的互联网时代&#xff0c;短视频已经成为了人们生活中不可或缺的一部分。而一个吸引人的视频封面可以让你的作品更具吸引力&#xff0c;吸引更多观众的点击。那么&#xff0c;如何制作一个令人印象深刻的视频封面呢&#xff1f;下面就让我们揭秘一些实用技巧吧&#xff0…

Chrome 75不支持保存成mhtml的解决方法

在Chrome 75之前&#xff0c;可以设置chrome://flags -> save as mhtml来保存网页为mhtml。 升级新版&#xff0c;发现无法另存为/保存网页为MHTML了。 在网上搜索无果后&#xff0c;只得从chromium项目的commits中查找&#xff0c;原来chrome搞了个"Chrome Flag Owner…

新闻稿发布中,首发来源和转载是什么意思?

一秒推小编告诉您&#xff0c;在新闻稿发布中&#xff0c;首发来源和转载是两个常用的词语&#xff0c;它们有着不同的含义和使用场合。#新闻稿发布# 首发来源指的是原创的、第一次发布该条新闻的媒体或媒体机构。比如&#xff0c;如果一家新闻机构发布了一则新闻稿&#xff0c…

圆圈中最后剩下的数字(约瑟夫环)——剑指 Offer 62

文章目录 题目描述法一 数学递归 题目描述 法一 数学递归 int lastRemaining(int n, int m){return f(n, m);}int f(int n, int m){if(n1){return 0;}int x f(n-1, m);return (mx)%n;}