深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制

简介: 本文介绍了 JVM 的内存区域划分、类加载过程及垃圾回收机制。内存区域包括程序计数器、堆、栈和元数据区,每个区域存储不同类型的数据。类加载过程涉及加载、验证、准备、解析和初始化五个步骤。垃圾回收机制主要在堆内存进行,通过可达性分析识别垃圾对象,并采用标记-清除、复制和标记-整理等算法进行回收。此外,还介绍了 CMS 和 G1 等垃圾回收器的特点。

1. JVM 内存区域划分

  1. 程序计数器(空间比较小)。保存了下一条要执行的指令的地址(指向元数据区指令的地址)
  2. 堆。JVM 最大的空间,new 出来的对象都在堆上
  3. 栈。函数中的局部变量,函数的形参,函数之间的调用关系都存放在栈上。栈也可以分为 Java 虚拟机栈和本地方法栈,Java 虚拟机栈主要服务于 Java 方法的执行,而本地方法栈主要服务于本地方法(C/C++实现的方法)
  4. 元数据区(方法区)。存放 Java 程序中的指令(指令都是包含在类的方法中),也就是保存了代码中涉及到类的相关信息,同时也保存了类的 static 属性

在一个 Java 进程中,元数据区和堆是只有一份的,程序计数器和栈可能有多份(当一个 Java 进程中有多个线程的时候,每个线程都有自己的程序计数器和栈,因为每个需要记录自己接下来需要执行什么指令还有其中的函数调用关系),由于堆和元数据区是共用一份的,所以如果是在堆上 new 了一个对象,或是元数据区的一个 static 属性,每一个线程都可以访问。

来看下面创建的变量都处在哪个内存区域:

一个变量处在哪个内存区域和变量是不是“内置类型”无关,而是和变量的形态有关

局部变量:栈           成员变量:堆          静态成员变量:元数据区(方法区)

所以说 a,b在堆区 c,d 在方法区,e,f 在元数据区(方法区)

2. JVM 类加载的过程

我们平时写的 Java 代码是一个 .java 文件,通过 javac 编译成 .class 文件,既然是文件,那么都是存储在硬盘上的,当运行 Java 进程的时候,JVM 就需要读取 .class 中的内容,并且执行里面的指令,这就是类加载的过程,也就是把类涉及到的字节码从硬盘上读取到内存中(元数据区), .class 中的指令转变为类对象,类对象中就包含了 .class 文件的各种信息,比如:类的名字,类中定义的属性,方法,实现的接口和继承关系等。

下面介绍一下类加载的具体过程:

2.1. 加载

  1. 加载。找到对应的 .class 文件,通过类的全限定名打开并读取文件内容,获取定义此类的二进制字节流。

2.2. 验证

  1. 验证。验证是确保 Class 文件的字节流中包含的信息符合当前 JVM 的要求,在 Java 标准文档中,也明确定义了 Class 文件的格式

2.3. 准备

  1. 准备。准备阶段是正式为类中被 static 修饰的变量(类变量)在方法区中分配内存空间的,这些类变量在整个类的生命周期中都是有效的,并且被所有的实例对象所共享,根据上面读取到的内容来确定出需要的内存空间,然后申请内存空间,并把空间内所有的内容都初始化为 0

例如,对于 public static int value = 10;,在准备阶段,value 会被初始化为 0,而不是   10。真正的赋值 10 是在初始化阶段进行的。

2.4. 解析

  1. 解析。主要是针对类中的字符串常量进行处理,JVM 将常量池中的符号引用替换为直接引用的过程,也就是初始化常量的过程。

符号引用和内存引用:字符串常量已经在 Class 文件中了,这时是没有“地址”这样的概念的,地址是内存的地址,还没有加载到内存中,这时是用“文件偏移量”来表示的,当加载到内存空间之后就会使用地址来标识,也就是直接引用

2.5. 初始化

  1. 初始化:Java 虚拟机真正开始执⾏类中编写的 Java 程序代码,将主导权移交给应⽤程序。初始
    化阶段就是执⾏类构造器⽅法的过程 。例如创建类的实例,调用类的静态方法,访问类或接口的静态变量(不包括 final 修饰的静态变量,在编译阶段就确定了值),初始化子类时,父类会先被初始化,执行静态代码块。

普通的成员变量(非静态变量)不会在 JVM 类加载过程中的初始化阶段被初始化。类加载过程中的初始化阶段主要是针对类变量(被 static 修饰的变量)以及执行静态代码块。普通成员变量是在对象创建时,即通过构造函数或其他实例初始化方法进行初始化的。当使用 new 关键字创建对象时,会为对象在堆内存中分配空间,并对成员变量进行初始化等操作。

2.6. 双亲委派模型

双亲委派模型是 Java 类加载机制中的一种重要设计模式,也就是上述类加载中的第一步

JVM 默认是有三个类加载器的:

这里的父子关系并不是 Java 中的继承关系,而是类似与指向的一个 parent 引用

双亲委派模型工作原理:

当一个应用程序需要加载一个类时,应用程序类加载器首先会将加载请求委派给它的父类加载器扩展类加载器,扩展类加载器再委派给启动类加载器。如果启动类加载器无法加载该类,扩展类加载器会尝试加载。如果扩展类加载器也无法加载,最后应用程序类加载器才会尝试自己去加载。

那为什么不一开始就给启动类加载器来执行而是又绕了一圈?

  1. 避免类的重复加载:由于父类加载器优先加载,相同的类只会被加载一次,避免了类的重复加载和内存浪费。
  2. 保证核心类库的安全性:防止核心类库被恶意篡改(程序员自己写的类和标准库中的类名字一样,直接从启动类加载器加载会覆盖掉标准库的类),确保了 Java 程序的稳定性和安全性。

就类似与公司中的员工,领导和老板,当员工发现问题是需要上报给领导,领导再上报给老板,如果说这个问题比较重要,老板就亲自解决,如果不是那么重要,但也有一定分量,就交给领导解决,如果只是一些小事,就让员工自己解决

其实类加载器可以程序员自定义,自定义的类加载器可以遵守双亲委派模型也可以不遵守。

3. JVM 的垃圾回收机制(GC)

JVM 的垃圾回收主要是在堆内存进行的,这里存储的都是 new 出来的对象,当这些对象没有引用指向的时候,就成为了垃圾,如果放任不管,就会一直占用着内存,最终造成内存泄露

进行垃圾回收可能会触发 “Stop The World”(STW)问题:在执行垃圾回收的某个阶段,为了保证垃圾回收的准确性和一致性,需要暂停所有应用程序线程,使得整个应用程序出现短暂的停顿。

所以说 C++ 这种追求高性能的语言并没有使用垃圾回收机制

接下来看 JVM 中是如何进行垃圾回收的

首先,进行垃圾回收肯定需要先标记出哪些对象是垃圾

那么怎么去找哪个对象是垃圾呢?

3.1. 识别垃圾

方案一:引用计数。

给每一个对象分配一个计数器,来统计有多少引用指向,每增加一个引用,计数器 + 1,每减少一个引用,计数器 - 1,当计数器减为 0,就变成垃圾了

这个方法看似可行,python 等一些语言采用的就是这个,但是还存在一个问题:

无法解决循环引用的问题。例如,对象 A 和对象 B 相互引用,除此之外没有其他地方引用它们,此时它们的引用计数器都不为 0,但实际上它们已经不再被程序使用,应该被回收。

同时,引入计数器,也会消耗掉额外的空间

方案二:可达性分析

以一系列称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,是垃圾对象,也就是在类似图的结构中从多个根节点遍历一遍,如果发现有的对象没有任何路径可以到达,也就是没有引用指向了,那么就是垃圾

可达性分析有效的解决了循环引用的问题,可以作为根节点的可以是方法区中类静态属性引用的对象(static 修饰的变量引用的对象),常量池中引用的对象,方法中的局部变量引用的对象,本地方法栈引用的对象。

可达性分析由于需要每次都遍历一遍对象,所以是比较消耗时间的

3.2. 垃圾回收的方法

  1. 标记 - 清除法。

在上面遍历的时候标记出哪些是垃圾,然后回收所有被标记的对象所占用的空间,这种方法有很大的缺点:由于每次申请的内存都是一块连续的区域,所以会产生内存碎片,回收后的内存不完整,接下来再申请内存的话可能会申请失败

  1. 复制算法。

将堆空间分为两个大小相等的区域,一般称为 From 区和 To 区。当进行垃圾回收时,将 From 区中仍然被引用的对象(也就是需要继续使用的对象)复制到 To 区,然后清空 From 区。下次垃圾回收时,交换 From 区和 To 区的角色。

这种算法缺点也很明显,就是空间利用率低,一大块区域只有一半能用,同时如果存活下来的对象有很多,复制的成本也比较大

  1. 标记 - 整理算法。

标记阶段就是把哪些对象是垃圾标记出来,接下来整理就是把存活的对象移动到另一端,然后回收边界以外的空间

这种方法的缺点就是移动对象的时候比较消耗时间,特别是对象很多的情况下

3.3. 分代收集算法

根据对象的生命周期将堆空间分为新生代和老年代,也就是在可达性分析的过程中,每经过一轮扫描,对象仍然存活(不是垃圾),年龄就 + 1,新生代中的对象生命周期较短,很多对象短时间内就变成垃圾了,老年代中的对象生命周期较长。

新创建的对象首先在伊甸区分配内存,当伊甸区满时,触发一次新生代垃圾回收,将伊甸区和幸存区 1中仍然存活的对象复制到幸存区 2,然后清空伊甸区和幸存区 1。下次垃圾回收时,交换幸存区 1 和幸存区 2 的角色,继续重复上述操作,幸存区 1 到 幸存区 2 二者的转化会反复进行多次

由于新生代对象大多存活时间少,要复制的也比较少,效率并不会有多大影响,同时空间也没有太大浪费,两个幸存区的空间比较小,也不会有太大空间的浪费。

当一个对象在幸存区反复拷打,年龄不断增加,就进入了老年代,老年代的对象生命周期长,如果还采用复制算法的话,要复制的对象就很多,影响效率,所以就可以采用标记 - 整理算法,需要整理的次数也不多。

上述过程就很好的利用了各个算法的优势来结合,通过分代收集算法,提高了垃圾回收的效率

3.4. 垃圾回收器

JVM 中还提供了多种“垃圾回收器”,对上面介绍的分代回收算法进行了进一步的扩充和具体实现

CMS 垃圾回收器

先大概的进行一个初始标记,然后再通过多线程的方式并发的进行重新标记,标记之后再进行并发的清理,通过这样的方法,能和业务线程并发执行,使 STW 的时间尽可能的缩短

G1 垃圾回收器

G1 是把整个堆内存分层很多小块,进行垃圾回收时,不要求一个周期就把所有的垃圾全都回收,而是一轮周期回收一部分,通过限制每一轮 GC 花的时间在可控范围之内,同时划分为多个区域也是为了更好的使用多线程

相关文章
|
Java 开发者
重学Java基础篇—Java类加载顺序深度解析
本文全面解析Java类的生命周期与加载顺序,涵盖从加载到卸载的七个阶段,并深入探讨初始化阶段的执行规则。通过单类、继承体系的实例分析,明确静态与实例初始化的顺序。同时,列举六种触发初始化的场景及特殊场景处理(如接口初始化)。提供类加载完整流程图与记忆口诀,助于理解复杂初始化逻辑。此外,针对空指针异常等问题提出排查方案,并给出最佳实践建议,帮助开发者优化程序设计、定位BUG及理解框架机制。最后扩展讲解类加载器层次与双亲委派机制,为深入研究奠定基础。
624 0
|
人工智能 自然语言处理 Java
FastExcel:开源的 JAVA 解析 Excel 工具,集成 AI 通过自然语言处理 Excel 文件,完全兼容 EasyExcel
FastExcel 是一款基于 Java 的高性能 Excel 处理工具,专注于优化大规模数据处理,提供简洁易用的 API 和流式操作能力,支持从 EasyExcel 无缝迁移。
4076 65
FastExcel:开源的 JAVA 解析 Excel 工具,集成 AI 通过自然语言处理 Excel 文件,完全兼容 EasyExcel
|
存储 设计模式 Java
重学Java基础篇—ThreadLocal深度解析与最佳实践
ThreadLocal 是一种实现线程隔离的机制,为每个线程创建独立变量副本,适用于数据库连接管理、用户会话信息存储等场景。
576 5
|
存储 监控 安全
重学Java基础篇—类的生命周期深度解析
本文全面解析了Java类的生命周期,涵盖加载、验证、准备、解析、初始化、使用及卸载七个关键阶段。通过分阶段执行机制详解(如加载阶段的触发条件与技术实现),结合方法调用机制、内存回收保护等使用阶段特性,以及卸载条件和特殊场景处理,帮助开发者深入理解JVM运作原理。同时,文章探讨了性能优化建议、典型异常处理及新一代JVM特性(如元空间与模块化系统)。总结中强调安全优先、延迟加载与动态扩展的设计思想,并提供开发建议与进阶方向,助力解决性能调优、内存泄漏排查及框架设计等问题。
692 5
|
机器学习/深度学习 人工智能 Java
Java机器学习实战:基于DJL框架的手写数字识别全解析
在人工智能蓬勃发展的今天,Python凭借丰富的生态库(如TensorFlow、PyTorch)成为AI开发的首选语言。但Java作为企业级应用的基石,其在生产环境部署、性能优化和工程化方面的优势不容忽视。DJL(Deep Java Library)的出现完美填补了Java在深度学习领域的空白,它提供了一套统一的API,允许开发者无缝对接主流深度学习框架,将AI模型高效部署到Java生态中。本文将通过手写数字识别的完整流程,深入解析DJL框架的核心机制与应用实践。
1025 3
|
安全 IDE Java
重学Java基础篇—Java Object类常用方法深度解析
Java中,Object类作为所有类的超类,提供了多个核心方法以支持对象的基本行为。其中,`toString()`用于对象的字符串表示,重写时应包含关键信息;`equals()`与`hashCode()`需成对重写,确保对象等价判断的一致性;`getClass()`用于运行时类型识别;`clone()`实现对象复制,需区分浅拷贝与深拷贝;`wait()/notify()`支持线程协作。此外,`finalize()`已过时,建议使用更安全的资源管理方式。合理运用这些方法,并遵循最佳实践,可提升代码质量与健壮性。
574 1
|
传感器 监控 Java
Java代码结构解析:类、方法、主函数(1分钟解剖室)
### Java代码结构简介 掌握Java代码结构如同拥有程序世界的建筑蓝图,类、方法和主函数构成“黄金三角”。类是独立的容器,承载成员变量和方法;方法实现特定功能,参数控制输入环境;主函数是程序入口。常见错误包括类名与文件名不匹配、忘记static修饰符和花括号未闭合。通过实战案例学习电商系统、游戏角色控制和物联网设备监控,理解类的作用、方法类型和主函数任务,避免典型错误,逐步提升编程能力。 **脑图速记法**:类如太空站,方法即舱段;main是发射台,static不能换;文件名对仗,括号要成双;参数是坐标,void不返航。
667 5
|
存储 缓存 Java
Java 并发编程——volatile 关键字解析
本文介绍了Java线程中的`volatile`关键字及其与`synchronized`锁的区别。`volatile`保证了变量的可见性和一定的有序性,但不能保证原子性。它通过内存屏障实现,避免指令重排序,确保线程间数据一致。相比`synchronized`,`volatile`性能更优,适用于简单状态标记和某些特定场景,如单例模式中的双重检查锁定。文中还解释了Java内存模型的基本概念,包括主内存、工作内存及并发编程中的原子性、可见性和有序性。
563 5
Java 并发编程——volatile 关键字解析
|
Java API 数据处理
深潜数据海洋:Java文件读写全面解析与实战指南
通过本文的详细解析与实战示例,您可以系统地掌握Java中各种文件读写操作,从基本的读写到高效的NIO操作,再到文件复制、移动和删除。希望这些内容能够帮助您在实际项目中处理文件数据,提高开发效率和代码质量。
1026 4
|
存储 Java 计算机视觉
Java二维数组的使用技巧与实例解析
本文详细介绍了Java中二维数组的使用方法
864 15

推荐镜像

更多
  • DNS