1.1 概述:Java代码的执行之旅
一段简单的Java代码System.out.println("Hello JVM");,从源代码到屏幕输出,需要经历编译和运行两个阶段。
- 编译期:JDK中的javac编译器将.java源文件编译成JVM能够理解的.class字节码文件。字节码是一种平台无关的中间代码,它是JVM的“机器语言”。
- 运行期:JVM负责加载.class文件,通过类加载器(Class Loader) 将其载入内存,然后由执行引擎(Execution Engine) 解释或编译(JIT编译器)成特定操作系统的本地机器码并执行。
而这个加载和执行过程所发生的地方,就是运行时数据区(Runtime Data Areas),它是JVM内存模型的核心,是Java世界的物理基石。理解它,是理解一切JVM原理和调优的基础。
下图清晰地展示了JVM运行时数据区的核心组成部分及其关系:
1.2 线程私有区域:程序的执行现场
线程私有区域的生命周期与线程相同,随线程的启动而创建,随线程的结束而销毁。
程序计数器(Program Counter Register)
- 定义与作用:它可以看作是当前线程所执行的字节码的行号指示器。执行引擎通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 特性:
- 线程私有:每个线程都有自己独立的程序计数器,互不干扰。这是JVM内存区域中唯一一个没有规定任何OutOfMemoryError情况的区域。
- 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
- 如果正在执行的是Native方法(本地方法,如C代码),这个计数器值则为空(Undefined)。
Java虚拟机栈(Java Virtual Machine Stacks)
- 定义与作用:描述的是Java方法执行的内存模型。每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 特性:线程私有。
- 可能产生的错误:
- StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的深度(例如,一个方法无限递归调用自己),将抛出此错误。
- OutOfMemoryError:如果虚拟机栈可以动态扩展(当前大部分Java虚拟机都可动态扩展,但也允许固定长度的虚拟机栈),而在扩展时无法申请到足够的内存,就会抛出此异常。
栈帧(Stack Frame)结构详解
每个栈帧包含以下核心部分:
- 局部变量表(Local Variables):
- 一个数字数组,主要用于存储方法参数和方法内部定义的局部变量。
- 以变量槽(Slot) 为最小单位。对于64位的long和double类型变量,会占用两个连续的Slot。
- 在方法执行时,虚拟机使用局部变量表来完成参数值到参数变量列表的传递过程。
- 操作数栈(Operand Stack):
- 也常称为操作栈,它是一个后入先出(LIFO)栈。
- 方法的执行过程,就是执行引擎不断地将常量或变量从局部变量表压入操作数栈,再进行各种计算,最后将结果出栈并存储的过程。可以理解为方法计算的工作区。
- 动态链接(Dynamic Linking):
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接。
- 在Class文件的常量池中存有大量的符号引用(如方法名、描述符)。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。动态链接的作用就是将这些符号引用转换为调用方法的直接引用。
- 方法返回地址(Return Address):
- 存放调用该方法的程序计数器的值。
- 方法退出后,需要返回到方法被调用的位置,程序才能继续执行。无论是正常返回(return)还是异常退出(抛出异常),都必须返回到这个位置。
本地方法栈(Native Method Stack)
- 定义与作用:与虚拟机栈所发挥的作用非常相似。其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
- 特性与错误:与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
- 在HotSpot虚拟机中,本地方法栈和Java虚拟机栈是同一个,并不做区分。
1.3 线程共享区域:数据的宇宙中心
Java堆(Java Heap)
- 定义与作用:这是JVM管理的最大的一块内存,此内存区域的唯一目的就是存放对象实例。几乎所有的对象实例以及数组都在这里分配内存。“几乎”是因为现代JVM的优化技术(如逃逸分析)可能导致对象在栈上分配。
- 特性:线程共享,是垃圾收集器管理的主要区域,因此也被称作“GC堆”。
- 可能产生的错误:OutOfMemoryError: Java heap space。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出此异常。
堆的内存划分
从垃圾回收(GC) 的角度来看,现代收集器基本都采用分代收集算法,所以Java堆可以细分为:
- 新生代(Young Generation):新创建的对象首先在这里分配。
- Eden区:对象诞生的地方。
- Survivor区(S0和S1,也称为From和To):用于存放Minor GC后存活的对象。两个Survivor区大小永远一致,互为备份。
- 老年代(Old/Tenured Generation):在新生代中经历了多次GC后仍然存活的对象(默认15次),会被晋升到老年代。一些大对象(如长数组)也可能直接在老年代分配。
这种划分是为了更好地管理对象的生命周期,从而采用更高效(Stop-The-World时间更短)的垃圾收集算法。
方法区(Method Area)与运行时常量池
- 定义与作用:用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 特性:线程共享。
- 可能产生的错误:OutOfMemoryError: Metaspace(JDK 8+)。
演进:从永久代(PermGen)到元空间(Metaspace)
这是一个非常重要的演变,也是面试常考点。
- JDK 7及之前:方法区在HotSpot虚拟机中被称为永久代(Permanent Generation)。它使用JVM的堆内存来实现方法区。
- JDK 8及之后:永久代被完全移除,取而代之的是元空间(Metaspace)。元空间不在虚拟机内存中,而是使用本地内存(Native Memory)。
演变的主要原因:
- 解决内存溢出问题:永久代有固定的上限(-XX:MaxPermSize),很容易在动态生成大量类(如CGLib、JSP)时出现OutOfMemoryError: PermGen space。而元空间使用本地内存,默认只受本地内存大小的限制,可通过-XX:MaxMetaspaceSize设定上限。
- 促进融合:将HotSpot与JRockit等JVM融合,JRockit本就没有永久代。
- 提高性能:永久代的垃圾回收效率低,且与老年代共用收集器,调优困难。元空间由元空间虚拟机自行管理,效率更高。
- 运行时常量池(Runtime Constant Pool):是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。String.intern()方法的行为与运行时常量池密切相关。
1.4 直接内存(Direct Memory)
- 定义与作用:并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但它被频繁使用(如NIO的DirectByteBuffer),可能导致内存溢出。
- 原理:通过在Java堆中分配一个对象,这个对象作为这块内存的引用进行操作,避免了在Java堆和Native堆中来回复制数据,能显著提高性能。
- 特性:受本机总内存大小限制。
- 可能产生的错误:虽然不受JVM内存参数限制,但若各个内存区域总和大于物理内存限制,动态扩展时会导致OutOfMemoryError。可通过-XX:MaxDirectMemorySize参数指定大小。
1.5 内存区域核心特性对比表
内存区域 |
是否线程私有 |
是否GC主要区域 |
可能产生的错误 |
作用 |
程序计数器 |
是 |
否 |
无 |
当前线程执行的字节码行号指示器 |
Java虚拟机栈 |
是 |
否 |
StackOverflowError |
存储Java方法执行的栈帧 |
本地方法栈 |
是 |
否 |
StackOverflowError |
为Native方法服务 |
Java堆 |
否 |
是 |
OutOfMemoryError: Java heap space |
存放几乎所有对象实例和数组 |
方法区/元空间 |
否 |
是(卸载类) |
OutOfMemoryError: Metaspace |
存储类信息、常量、静态变量等 |
直接内存 |
- |
否 |
OutOfMemoryError |
NIO使用的堆外内存,提高IO性能 |
1.6 本章小结与面试精要
核心知识点:
- JVM内存分为线程私有(PC、JVM栈、本地方法栈)和线程共享(堆、方法区)两大部分。
- 堆是GC的主战场,分为新生代和老年代。
- 方法区在JDK 8+称为元空间,使用本地内存,其大小可动态调整,解决了永久代的OOM问题。
- 直接内存不属于JVM运行时数据区,但频繁使用且可能导致OOM。
常见面试问题:
- 说一下JVM的内存区域划分?
- 堆和栈的区别是什么?
- 永久代为什么被元空间取代?
- String.intern()方法有什么作用?它与运行时常量池有什么关系?
- 堆外内存(直接内存)如何分配和回收?
- 如何理解“栈帧”?
在下一部分,我们将深入垃圾收集机制,探讨JVM如何自动清理这些内存区域中的“垃圾”,让Java世界保持整洁。