JVM | 从类加载到JVM内存结构
引言
我在上篇文章: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 initializedSystem.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虚拟机内存发生了什么?为了方便你理解, 我事先画了一张图,你看:
我在图中完整标注出执行顺序,你可以暂停看下。接下来我详细的为你解释:
- 方法调用:当Java代码执行到
building.calculateAge(2023)
时,首先JVM会通过对象引用(即building
)查找到类Building
,然后在类中查找calculateAge
方法的符号引用。 - 动态链接:JVM会根据
Building
类中的符号引用找到calculateAge
方法在运行时常量池中的直接引用,获取改方法的内存地址。 - 创建新的栈帧:JVM为调用的方法创建一个新的栈帧,并推入当前线程的Java栈顶。这个栈帧包含局部变量表、操作数栈、动态链接和方法出口。
- 初始化局部变量表:JVM将方法调用的参数(即
currentYear
和this
)存储到新栈帧的局部变量表中。 - 更新程序计数器:JVM的程序计数器更新为
calculateAge
方法的第一条字节码指令。 - 执行方法体: JVM开始执行
calculateAge
方法的字节码。当执行到currentYear - CONSTRUCTION_YEAR
时,它会将currentYear
和CONSTRUCTION_YEAR
推入操作数栈,然后执行减法操作,并将结果推入操作数栈顶。 - 方法返回:执行完
calculateAge
方法后,JVM将操作数栈顶的结果(即年龄)作为方法返回值,并将calculateAge
方法的栈帧从Java栈中弹出。 - 接收返回值:
calculateAge
方法的返回值被推入调用者(即main
方法)的操作数栈中,并赋值给局部变量age
。 - 更新程序计数器: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";// 1MBprivate 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内存结构的衔接文章。为了让你把之前的知识串起来,我结合了内存结构重新为你讲解类的生命周期。希望看完这篇文章,你会有不一样的收获。
参考文献
- Java虚拟机规范(Java SE 8版)
- JVMInternals
- JavaGuide Java内存区域详解
后续
本篇文章从类的完整生命周期的角度为你深入解析了JVM内存结构,但仍有一些细节未涉及,例如:本地方法栈的具体工作方式,以及本地方法是C++代码,它是如何运作的?在接下来的文章中,我将进一步展开,为你勾勒出JVM内存结构的全貌,让你对其有更深入、全面的理解。敬请继续关注!
相关文章:

JVM | 从类加载到JVM内存结构
引言 我在上篇文章:JVM | 基于类加载的一次完全实践 中为你讲解如何请“建筑工人”来做一些定制化的工作。但是,大型的Java应用程序时,材料(类)何止数万,我们直接堆放在工地上(JVM)…...

Golang之路---04 并发编程——WaitGroup
WaitGroup 为了保证 main goroutine 在所有的 goroutine 都执行完毕后再退出,前面使用了 time.Sleep 这种简单的方式。 由于写的 demo 都是比较简单的, sleep 个 1 秒,我们主观上认为是够用的。 但在实际开发中,开发人员是无法…...

React(4)
1.属性(props)初始 状态state都是组件内部写的,也就是A组件内的state就只能A组件里面用,其他组件复用不了。因此属性props就可以。 比如一个导航栏,首页有,购物车有,我的有,他们三个…...

STM32 CubeMX USB_(HID 鼠标和键盘)
STM32 CubeMX STM32 CubeMX USB_HID(HID 鼠标和键盘) STM32 CubeMX前言 《鼠标小节》一、STM32 CubeMX 设置USB时钟设置USB使能UBS功能选择 二、代码部分添加代码鼠标发送给PC的数据解析实验效果 《键盘小节》STM32 CubeMX 设置(同上…...

[PM]敏捷开发之Scrum总结
在项目管理中,不少企业和项目团队也发现传统的项目管理模式已不能很好地适应今天的项目环境的要求。因此,敏捷项目管理应运而生,本文将为大家介绍Scrum敏捷项目管理以及应用方法。 什么是Scrum敏捷项目管理 敏捷项目管理作为新兴的项目管理模…...

大数据Flink(五十七):Yarn集群环境(生产推荐)
文章目录 Yarn集群环境(生产推荐) 一、准备工作...

web集群学习:源码安装nginx配置启动服务脚本
1、源码安装nginx,并提供服务脚本。 1、源码安装会有一些软件依赖 (1)检查并安装 Nginx 基础依赖包 pcre-devel 、openssl-devel # rpm -qa | egrep pcre-devel | openssl-devel(2)安装 Nginx 所需的 pcre 库 正则支…...
LNMP
lNmp安装: 一、LNMP LNMP架构是目前成熟的企业网站应用模式之一,指的是协同工作的一整套系统和相关软件, 能够提供动态Web站点服务及其应用开发环境。LNMP是一个缩写词,具体包括Linux操作系统、nginx网站服务器、MySQL数据库服务…...
Python网络爬虫在信息采集中的应用及教程
Python网络爬虫在信息采集中的应用与法律警告 摘要 随着互联网的发展,我们每天都面临着海量的信息。这些信息蕴含着无尽的价值,而要从中获取有用的数据,网络爬虫就成了我们的得力助手。Python作为一门简单而又强大的编程语言,被…...
云主机测试Flink磁盘满问题解决
问题描述: 使用云主机测试Flink时,根目录满了。 经排查发现运行Flink任务后根目录空间一直在减少,最后定位持续增加的目录是/tmp目录 解决方法: 修改Flink配置使用一个相对较大的磁盘目录做为Flink运行时目录 # Override the…...
iOS开发-NSOperationQueue实现上传图片队列
iOS开发-NSOperationQueue实现上传图片队列 在开发中,遇到发帖需要上传图片,需要上传队列,这时候用到了NSOperationQueue 一、NSOperation与NSOperationQueue 什么NSOperation NSOperation为控制任务状态、优先级、依赖关系以及任务管理提…...

通过 CCIP 构建跨链应用(5 个案例)
Chainlink 的跨链互操作性协议(CCIP)是一种新的通用跨链通信协议,为智能合约开发人员提供了以最小化信任的方式在区块链网络之间传输数据和通证的能力。 目前,部署在多个区块链上的应用程序面临着资产、流动性和用户的碎片化问题…...

基于 yolov8 的人体姿态评估
写在前面 工作中遇到,简单整理博文内容为使用预训练模型的一个预测 Demo测试图片来源与网络,如有侵权请告知理解不足小伙伴帮忙指正 对每个人而言,真正的职责只有一个:找到自我。然后在心中坚守其一生,全心全意,永不停…...

计算机视觉(六)图像分类
文章目录 常见的CNNAlexnet1乘1的卷积 VGG网络Googlenet(Inception V1、V2、V3)全局平均池化总结 Resnet、ResnextResNet残差网络ResNeXt网络 应用案例VGGResnet 常见的CNN Alexnet DNN深度学习革命的开始 沿着窗口进行归一化。 1乘1的卷积 VGG网络…...

解决:vue通过params传参刷新页面参数丢失问题以及实现vue路由可选参数的解决办法
目录 🙋♂️ 实现params传参,刷新页面不丢参 🙋♂️ 实现vue配置可选路由参数 🙋♂️ 参考资料 解决vue 通过 name 和 params 进行页面传参时,刷新页面参数丢失问题以及vue路由实现可选参数 🙋♂…...
将postman接口导出的json转换为markdown
您可以使用 Postman 官方提供的工具或第三方工具将 Collection 文件转换为 Markdown 文件。 方式一 Postman 官方提供的工具是 Newman,它是一个命令行工具,可以帮助您运行和测试 Postman Collection,还可以将 Collection 转换为多种格式&am…...

教您一招解决找素材困难好的方法
创作视频内容时,找到合适的素材是至关重要的。然而,有时候寻找视频素材可能会变得困难。本文将分享一些实用的方法,帮助您轻松解决找视频素材困难的问题。 素材库和在线平台是寻找视频素材的首选方法。 利用专业的视频剪辑工具 在电脑上安…...

python_PyQt5开发验证K线视觉想法工具V1.2_批量验证
目录 运行情况: 编辑 结果json文件格式: 代码: 承接 【python_PyQt5开发验证K线视觉想法工具V1.1 _增加标记类型_线段】 博文 地址:python_PyQt5开发验证K线视觉想法工具V1.1 _增加标记类型_线段_程序猿与金融与科技的博客-…...
应急响应-web后门(中间件)的排查思路
0x01 获取当前网络架构 语言,数据库,中间件,系统环境等 0x02 分析思路 1.利用时间节点筛选日志行为 2.利用已知的漏洞在日志进行特征搜索,快速定位到目标ip等信息 3.后门查杀,获取后门信息,进一步定位目…...

XML 学习笔记 7:XSD
本文章内容参考自: W3school XSD 教程 Extensible Markup Language (XML) 1.0 (Second Edition) XML Schema 2001 XML Schema Part 2: Datatypes Second Edition 文章目录 1、XSD 是什么2、XSD 内置数据类型 - built-in datatypes2.1、基本数据类型 19 种2.1.1、基本…...
KubeSphere 容器平台高可用:环境搭建与可视化操作指南
Linux_k8s篇 欢迎来到Linux的世界,看笔记好好学多敲多打,每个人都是大神! 题目:KubeSphere 容器平台高可用:环境搭建与可视化操作指南 版本号: 1.0,0 作者: 老王要学习 日期: 2025.06.05 适用环境: Ubuntu22 文档说…...

docker详细操作--未完待续
docker介绍 docker官网: Docker:加速容器应用程序开发 harbor官网:Harbor - Harbor 中文 使用docker加速器: Docker镜像极速下载服务 - 毫秒镜像 是什么 Docker 是一种开源的容器化平台,用于将应用程序及其依赖项(如库、运行时环…...
日语学习-日语知识点小记-构建基础-JLPT-N4阶段(33):にする
日语学习-日语知识点小记-构建基础-JLPT-N4阶段(33):にする 1、前言(1)情况说明(2)工程师的信仰2、知识点(1) にする1,接续:名词+にする2,接续:疑问词+にする3,(A)は(B)にする。(2)復習:(1)复习句子(2)ために & ように(3)そう(4)にする3、…...

循环冗余码校验CRC码 算法步骤+详细实例计算
通信过程:(白话解释) 我们将原始待发送的消息称为 M M M,依据发送接收消息双方约定的生成多项式 G ( x ) G(x) G(x)(意思就是 G ( x ) G(x) G(x) 是已知的)࿰…...
使用van-uploader 的UI组件,结合vue2如何实现图片上传组件的封装
以下是基于 vant-ui(适配 Vue2 版本 )实现截图中照片上传预览、删除功能,并封装成可复用组件的完整代码,包含样式和逻辑实现,可直接在 Vue2 项目中使用: 1. 封装的图片上传组件 ImageUploader.vue <te…...
Spring Boot+Neo4j知识图谱实战:3步搭建智能关系网络!
一、引言 在数据驱动的背景下,知识图谱凭借其高效的信息组织能力,正逐步成为各行业应用的关键技术。本文聚焦 Spring Boot与Neo4j图数据库的技术结合,探讨知识图谱开发的实现细节,帮助读者掌握该技术栈在实际项目中的落地方法。 …...
Rust 异步编程
Rust 异步编程 引言 Rust 是一种系统编程语言,以其高性能、安全性以及零成本抽象而著称。在多核处理器成为主流的今天,异步编程成为了一种提高应用性能、优化资源利用的有效手段。本文将深入探讨 Rust 异步编程的核心概念、常用库以及最佳实践。 异步编程基础 什么是异步…...
Java线上CPU飙高问题排查全指南
一、引言 在Java应用的线上运行环境中,CPU飙高是一个常见且棘手的性能问题。当系统出现CPU飙高时,通常会导致应用响应缓慢,甚至服务不可用,严重影响用户体验和业务运行。因此,掌握一套科学有效的CPU飙高问题排查方法&…...
文件上传漏洞防御全攻略
要全面防范文件上传漏洞,需构建多层防御体系,结合技术验证、存储隔离与权限控制: 🔒 一、基础防护层 前端校验(仅辅助) 通过JavaScript限制文件后缀名(白名单)和大小,提…...
用 Rust 重写 Linux 内核模块实战:迈向安全内核的新篇章
用 Rust 重写 Linux 内核模块实战:迈向安全内核的新篇章 摘要: 操作系统内核的安全性、稳定性至关重要。传统 Linux 内核模块开发长期依赖于 C 语言,受限于 C 语言本身的内存安全和并发安全问题,开发复杂模块极易引入难以…...