Java-JVM探析

一、概述

JVM 是 Java 虚拟机(Java Virtual Machine)的缩写,它是 Java 程序运行的执行引擎。它屏蔽了底层操作系统的差异,让 Java 字节码可以在不同平台上运行。更重要的是在虚拟机自动内存管理机制的帮助下,不再需要手动释放内存,不容易出现内存泄露和内存溢出问题。

Java 编译器(javac)把 .java 文件编译成 .class 字节码,而 JVM 负责执行这些字节码。

了解虚拟机的运行机制有助于在我们开发中,更容易发现问题,找到内存泄漏原因...

须知:

  • JVM 是规范(接口标准)
  • HotSpot / ART/Dalvik 是实现(具体虚拟机)
  • PC 端的虚拟机是HotSpot,安卓端则使用的是ART/Dalvik

二、 JVM 核心组件(构成体系)

JVM 主要由以下部分组成:

类加载器系统(Class Loader Subsystem)

  • 加载 .class 文件(可来自文件系统、网络等)
  • 双亲委派模型:防止类的重复加载

执行引擎(Execution Engine)

负责将字节码转为机器码并执行。

  • 解释器(Interpreter):逐行解释执行
  • JIT 编译器(Just-In-Time):热点代码编译成本地机器码,提高效率

本地接口(Native Interface)

通过 JNI(Java Native Interface)调用 C/C++ 等底层库。

运行时数据区(Runtime Data Area)

这是 JVM 运行时用于存储各种数据的内存区域:

区域 说明
方法区(Method Area) 存储类元数据、静态变量、常量池(JDK 8后并入元空间)
堆(Heap) 存储对象实例,GC 主要作用区域
虚拟机栈(VM Stack) 每个线程一个,存储栈帧、局部变量
本地方法栈 用于执行 native 方法
程序计数器(PC) 当前线程执行的字节码行号

前面三个只简单了解下即可,因为在开发中 运行时数据区 才是我们需要重点关照的。

三、 运行时数据区

运行时数据区(Runtime Data Area)是 JVM 在执行 Java 程序时用于内存分配的关键部分。掌握这部分知识对解决内存泄漏、优化性能和理解 Java 内部机制至关重要。 当然也是面试的重灾区!

程序计数器 (Program Counter Register)

每个线程都有其独立的程序计数器,这是一个非常小的内存空间,用于保存当前线程正在执行的字节码指令的地址。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,则计数器的值为undefined。

在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

此内存区域是唯一 一个在Java虚拟机规范中没有规定OutOfMemoryError情况的区域。

特点:

  • 线程私有
  • 唯一一个不会出现 OutOfMemoryError 的区域

**用途:**支持线程切换(线程上下文切换时,保存和恢复执行位置)

Java 虚拟机栈(VM Stack / Java Stack)

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。

栈中的每个元素称为栈帧(Frame),栈帧中包括:局部变量表操作数栈动态链接方法返回地址等。

经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),其中所指的"堆"就是Java堆,而所指的"栈"就是现在所讲的虚拟机栈,或者说是虚拟机栈中局部变量表部分。

局部变量表

存储方法参数和方法内部定义的局部变量、存放基本数据类型、对象引用(reference)和returnAddress类型

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关位置)和returnAddress类型(指向了一条字节码指令的地址)。

以变量槽(Slot)为最小单位,32位类型占1个Slot,64位类型占2个Slot

其中64为长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

操作数栈

操作数栈是栈帧内部的一个后进先出(LIFO)的工作区 ,用于存储计算过程中的临时数据。JVM 执行算术运算、方法调用传参、存储中间结果等操作时,都会依赖操作数栈。例如,执行 **iadd**(整数加法)指令时,JVM 会从操作数栈弹出两个整数相加,再将结果压回栈中。

动态链接

动态链接存储了指向运行时常量池中当前方法引用 的信息,用于支持方法调用时的动态绑定(如多态)。在类加载阶段,符号引用(如方法名)会被解析为直接引用(实际内存地址),动态链接确保方法调用能正确找到目标方法,包括虚方法(如接口或重写方法)的动态分派。

方法返回地址

方法返回地址记录了方法执行完毕后应该回到的指令位置 。如果方法正常结束(**return**),JVM 会使用该地址恢复调用者的执行;如果方法异常退出(未捕获的异常),JVM 会查找异常表处理,并跳转到对应的异常处理代码,而非原返回地址。


如果栈空间不足:StackOverflowError、 如果无法分配内存:OutOfMemoryError

在开发中常见的就是栈溢出,如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,比如一个方法自己调用自己,就会报这个错误。如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

特点:

  • 线程私有**,每个线程一个独立的虚拟机栈**
  • 生命周期和线程相同
  • 编译期间确定大小,运行期不会改变

本地方法栈(Native Method Stack)

native 方法(JNI调用C/C++) 服务,类似虚拟机栈,但用于 native 方法执行时的栈帧保存。

在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定。HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

其中"HotSpot" 指的是虚拟机能通过运行时分析,自动找出运行频率高的代码(热点代码),并用 JIT 编译器 将其编译为机器码,从而提高性能。 它是最主流的 JVM 实现,支持高性能执行、JIT 编译、多种 GC 策略,是 Java 应用程序运行的核心引擎。

特点:

  • 线程私有。
  • 不是所有 JVM 都区分虚拟机栈与本地方法栈(HotSpot JVM 就合并处理了)。

堆(Heap)

Java 堆是 JVM 中最大的一块内存区域 ,也是所有线程共享 的。它用于存放对象实例和数组。几乎所有的对象实例和数组都在这里分配内存,是 Java 内存管理(GC)工作的重点区域。

Tips:但随着JIT编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上就不那么绝对了。

JDK 7 及以前,方法区被称为永久代 (Permanent Generation),它位于堆的内部!

虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

现代垃圾回收器通常采用分代收集 (Generational Collection) 的思想,将堆划分为新生代 (Young Generation) 和老年代 (Old Generation)。

java 复制代码
堆
├── 新生代(Young)
│   ├── Eden 区
│   └── Survivor 区 (S0 / S1)
└── 老年代(Old)
  • 新生代 :用于存放新创建的对象。又细分为 Eden 空间、From Survivor 空间和 To Survivor 空间。大多数对象在 Eden 区创建,经过 Minor GC 后存活的对象会进入 Survivor 区,多次 Minor GC 仍然存活的对象会进入老年代。
  • 老年代:存放生命周期较长的对象,或者在新生代中多次 GC 后仍然存活的对象。

堆是可伸缩的,启动时可以指定其大小,也可以在运行时动态扩展或收缩。

java 复制代码
-Xms256m -Xmx1024m  # 设置堆初始和最大大小

当堆中没有足够的内存完成实例分配,并且垃圾收集器也无法再提供更多内存时,就会抛出 OutOfMemoryError

特点:

  • 线程共享
  • 是 GC 管理的主要区域。
  • 大部分对象在 Eden 区创建(新生代)。

方法区(Method Area)(JDK 8 前)

方法区是所有线程共享 的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。JVM 加载类时将类的信息放入方法区,也被称为"永久代"(PermGen Space)。

尽管在JDK 7及之前的方法区(永久代)从物理角度看是堆的一部分,但由于其独特的作用和管理方式,在解释Java内存模型时,常常将其与堆的其他部分区分开来描述。

虽然方法区是 GC 的一部分,但其回收条件相对苛刻,主要是针对废弃常量和无用的类进行回收。

JDK 8 开始,永久代被彻底移除,方法区由元空间 (Metaspace) 实现,元空间使用的是本地内存 (Native Memory)

异常情况:

  • java.lang.OutOfMemoryError: PermGen space(JDK 8 前)
  • java.lang.OutOfMemoryError: Metaspace(JDK 8 后)

当方法区无法满足内存分配需求时,会抛出 OutOfMemoryError。在 JDK 7 以前的永久代中,更容易出现 OutOfMemoryError;在 JDK 8 后的元空间中,由于使用本地内存,相对来说不容易出现此问题,但也并非不可能。

特点:

  • GC 不频繁,但会发生
  • 线程共享
  • 被称为"永久代"(PermGen Space)------直到 JDK8。

运行时常量池

运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量 (Literal) 和符号引用。字节码中的常量(字面值、符号引用)在类加载后放入运行时常量池。

  1. 字面量: 字面量分为文本字符串(如: "abc",1等)和用final修饰的成员变量(实例变量和静态变量)
  2. 符号引用: 符号引用包括三种:类的全限定名,方法名和描述符,字段名和描述符。

除了编译期已确定的常量,还可以在运行时动态地将一些数据放入常量池,例如 String 类的 intern() 方法。

intern() 的主要作用是确保返回的字符串常量池中具有相同值的一个引用。具体来说,当调用 intern() 方法时,如果字符串常量池中已经包含了一个等于此 String 对象的字符串(使用 equals(Object) 方法进行比较),则返回常量池中的这个字符串的引用。否则,将此 String 对象添加到字符串常量池中,并返回对该对象的引用。

与方法区一样,当常量池无法再申请到内存时,会抛出 OutOfMemoryErrorString.intern() 时如果池满会导致内存溢出(尤其是 JDK 7 前后行为不同)。

直接内存(堆外内存)

除了 JVM 规范中定义的 运行时数据区(堆、方法区、栈等) ,在实际的 HotSpot 虚拟机中,还有一个重要概念叫做 直接内存(Direct Memory) ,也称为 堆外内存(Off-Heap Memory)

  • 它是在 Java 堆外操作系统本地内存 申请的内存空间,不受 JVM 堆大小(-Xmx)限制。直接受操作系统管理。
  • 通过 Unsafe 类 或 NIO 包中的 ByteBuffer.allocateDirect() 申请。

作用:

减少堆内存复制

  • 在进行 I/O 操作(比如网络传输、文件读写)时,如果使用普通堆内存,数据需要在堆 → 内核缓冲区之间复制。
  • 使用直接内存可以直接操作操作系统的内存缓冲区,减少一次复制,提高性能。

大数据缓存

  • 用于缓存海量数据,而不影响 JVM 堆 GC。

当直接内存达到最大限制时就会触发GC,如果回收失败则会引起OutOfMemoryError

四、JMM(Java Memory Model)

Java 内存模型 (JMM) 是 Java 语言规范的一部分 ,它定义了在多线程环境下,线程如何通过内存进行交互。简单来说,JMM 规范了当一个线程修改了共享变量时,另一个线程何时以及如何能够"看到"这个修改。

JMM 的核心概念:主内存(Main Memory)与工作内存(Working Memory)

在多核处理器架构下,每个处理器都有自己的高速缓存 (Cache),并且数据最终会存储在主内存 (Main Memory) 中。JVM 运行在这样的硬件之上,为了优化性能,编译器和处理器都可能对指令进行重排序。如果没有一个明确的内存模型来规范这些行为,多线程程序的行为将会变得不可预测,容易出现各种并发问题,如可见性问题、原子性问题和有序性问题。


JMM 主要解决了以下三个并发编程中的核心问题:

1. 可见性 (Visibility)

  • 可见性是指当一个线程修改了共享变量的值,其他线程能够立即(或者在某个确定的时间点)看到这个修改。
  • 在多核系统中,每个线程可能拥有自己独立的 CPU 缓存。当一个线程修改了变量,这个修改可能只反映在其私有缓存中,而没有立即写入主内存。其他线程从自己的缓存中读取到的可能是旧的值,这就导致了可见性问题。
  • JMM 通过特定的同步机制来保证可见性,例如 volatile 关键字、synchronized 关键字、final 字段等。
    • volatile:保证变量对所有线程的可见性,禁止指令重排序。
    • synchronized:通过加锁,保证可见性和原子性。
    • final:在构造函数完成之前,禁止对该变量的重排序。

2. 原子性 (Atomicity)

  • 原子性是指一个操作是不可中断的,要么全部执行成功,要么全部不执行。
  • 例如,i++ 看起来是一个简单的操作,但它实际上包含了三个独立的步骤:读取 i 的值、对 i 进行加 1、将新的值写回 i。在多线程环境下,这三个步骤可能被其他线程打断,导致非原子性问题。
  • JMM 通过 synchronized 关键字、java.util.concurrent.atomic 包下的原子类等机制来保证原子性。

3. 有序性 (Ordering)

  • 有序性是指程序中指令的执行顺序。
  • 为了提高性能,编译器和处理器可能会对指令进行重排序。重排序分为两种:
    • 编译器重排序 (Compiler Reordering):编译器在不改变单线程程序语义的前提下,可能会对指令进行重排序。
    • 处理器重排序 (Processor Reordering):处理器在执行指令时,也可能会对其进行重排序。
  • 在单线程环境下,这些重排序是透明的,不会影响程序结果(称为 "as-if-serial" 语义)。但在多线程环境下,重排序可能会导致意想不到的结果。
  • JMM 通过 "happens-before" 原则 来保证有序性,并使用内存屏障 (Memory Barriers/Fences) 来防止特定类型的重排序。

JMM 的核心目标就是:在保证程序正确性的前提下,允许编译器和处理器进行必要的优化。

这里只需要了解每个线程有自己独立的工作内存,JMM 就是这些众多的线程中的交通警察,为了维护和平与正义...重要是程序正常运行而存在的。

五、JVM 与 JMM 之间的关系

JVM 和 JMM 是紧密关联的,它们的关系可以理解为:

  • JMM 是一个规范/抽象模型:JMM 定义了一套规则,它是一个理论模型,描述了多线程程序在内存中如何交互,以及这些交互应该满足哪些一致性保证。它不直接是 JVM 的某个组件,而是对 JVM 实现的要求。
  • JVM 是 JMM 的实现者 :JVM(特别是其执行引擎和内存管理部分)是 JMM 规范的具体实现者 。JVM 必须遵循 JMM 定义的规则,以确保 Java 程序在各种硬件和操作系统上都能表现出一致的并发行为

这里与上面说过的 JVM 是一种规范并无矛盾,JMM 可以看作顶级规范,凌驾与 JVM 之上,程序运行的基本规则!

打个比方:

  • JMM 就像是交通法规。它规定了车辆(线程)在道路(内存)上行驶时必须遵守哪些规则(可见性、原子性、有序性),以确保交通安全(并发程序的正确性)。
  • JVM 就像是汽车制造商和道路管理者。汽车制造商(JVM 的 JIT 编译器、垃圾回收器等)要确保他们生产的汽车(线程)能够遵守交通法规;道路管理者(JVM 的内存管理机制)要确保道路(主内存和缓存)的基础设施能够支持交通法规的实施(通过内存屏障等手段)。

总结来说:

JMM 是一个抽象的规范,规定了多线程环境下内存访问的行为,而 JVM 则是这个规范的具体实现者,它通过内部机制(如内存屏障、JIT 优化)来遵循 JMM 的规则,从而为 Java 开发者提供了可靠的并发编程环境。

六、最后

本来还想写写 GC 的发展史及不同版本的差异呢,发现啰啰嗦嗦写了一大堆,而且有些内容还只是浅尝辄止,毕竟 JVM 的知识涉及太多了,远不止几千字就能够说明白的,本篇文章仅当对 JVM 的了解了,如果有描述错误的地方还请批评指正。后面再单独写一篇关于垃圾回收的文章。

相关推荐
衍生星球7 分钟前
JSP 程序设计之 JSP 基础知识
java·web·jsp
m0_749317527 分钟前
力扣-字母异位词
java·算法·leetcode·职场和发展
黑暗也有阳光19 分钟前
java中为什么hashmap的大小必须是2倍数
java·后端
fatsheep洋22 分钟前
XSS-DOM-1
java·前端·xss
七七软件开发1 小时前
一对一交友小程序 / APP 系统架构分析
java·python·小程序·系统架构·php
TDengine (老段)1 小时前
TDengine 中 TDgpt 异常检测的数据密度算法
java·大数据·算法·时序数据库·iot·tdengine·涛思数据
YuTaoShao1 小时前
【LeetCode 热题 100】155. 最小栈
java·算法·leetcode
程序视点1 小时前
Java语言核心特性全解析:从面向对象到跨平台原理
java·后端·java ee
Warren981 小时前
MySQL查询语句详解
java·开发语言·数据库·mysql·算法·蓝桥杯·maven
丶小鱼丶2 小时前
Spring之【循环引用】
java·spring