Java虚拟机(JVM)的内存结构是其执行字节码的核心基础,在《Java虚拟机规范》中被划分为线程私有区域和线程共享区域两大块,不同区域承担着不同的内存管理职责,且各自有明确的生命周期和作用范围。
一、 线程私有区域
线程私有区域的生命周期与所属线程完全一致,随线程创建而分配,随线程终止而释放,主要包括程序计数器、虚拟机栈、本地方法栈三个部分。
- 程序计数器
这是一块占用内存极小的区域,可看作是当前线程所执行字节码的行号指示器。JVM的字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖它来完成。
需要注意的是,如果线程正在执行的是一个Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(native)方法,计数器的值则为空(undefined)。另外,程序计数器是JVM内存结构中唯一没有规定 OutOfMemoryError 异常的区域。
- 虚拟机栈
虚拟机栈用于描述Java方法执行的内存模型,每个方法被执行时,JVM都会同步创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息,方法从调用到执行完毕的过程,对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
局部变量表:存放编译期可知的各种基本数据类型( boolean 、 byte 、 char 、 short 、 int 、 float 、 long 、 double )、对象引用(reference类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与对象相关的位置)和 returnAddress 类型(指向一条字节码指令的地址)。局部变量表的容量以变量槽(Slot) 为最小单位, long 和 double 类型的数据会占用2个变量槽,其他类型只占用1个,局部变量表所需的内存空间在编译期间就已确定,方法运行期间不会改变其大小。
-
操作数栈:是一个后入先出(LIFO)的栈结构,方法执行过程中,会根据字节码指令完成各种入栈、出栈、复制、交换、产生消费的操作,比如执行 iadd 指令时,会从操作数栈中弹出两个 int 类型的值相加,再将结果压入栈中。
-
动态链接:每个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,这个引用的作用是支持方法调用过程中的动态链接。在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池中,动态链接的作用就是将这些符号引用在运行期间转化为直接引用。
-
方法出口:记录方法正常退出或异常退出时的返回地址,保证方法执行完毕后,程序能回到之前调用它的位置继续执行后续指令。
虚拟机栈会抛出两种异常:如果线程请求的栈深度大于JVM所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(大部分JVM都支持),当扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
- 本地方法栈
本地方法栈与虚拟机栈的作用类似,区别在于虚拟机栈为执行Java方法服务,而本地方法栈为执行本地(native)方法服务。本地方法栈中也会创建栈帧存储相关信息,同样会抛出 StackOverflowError 和 OutOfMemoryError 异常。不同的JVM对本地方法栈的实现方式不同,比如Sun HotSpot虚拟机就直接将本地方法栈和虚拟机栈合二为一。
二、 线程共享区域
线程共享区域被所有线程共同使用,随JVM启动而分配,随JVM关闭而释放,主要包括Java堆、方法区,以及一个与方法区关联紧密的运行时常量池。
- Java堆
Java堆是JVM所管理的内存中最大的一块区域,也是垃圾收集器(GC)的主要工作区域,因此常被称为"GC堆"。此区域的唯一目的就是存放对象实例,几乎所有的对象实例和数组都在堆上分配内存(随着JIT编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换等优化手段会让部分对象实例直接在栈上分配,从而避免在堆上分配和垃圾回收的开销)。
Java堆在JVM启动时就被创建,其内存大小可以固定,也可以通过 -Xms (堆的初始容量)和 -Xmx (堆的最大容量)参数动态调整。如果堆中没有内存完成实例分配,并且堆也无法再扩展时,JVM会抛出 OutOfMemoryError 异常。
从内存回收的角度看,由于现代垃圾收集器基本都采用分代收集算法,Java堆可以细分为新生代和老年代;新生代又可以进一步划分为Eden区、From Survivor区和To Survivor区。这样划分的目的是为了更好地回收内存,或更快地分配内存。
从内存分配的角度看,Java堆中可能划分出多个线程私有的本地线程分配缓冲(TLAB) ,这是为了提升对象分配时的效率,避免多个线程同时在堆上分配对象时产生线程安全问题。
- 方法区
方法区用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的产物等数据,在JVM规范中,方法区被描述为堆的一个逻辑部分,但它却有一个别名叫做"非堆"(Non-Heap),目的是与Java堆区分开。
方法区的内存大小可以固定,也可以动态扩展,当方法区无法满足内存分配需求时,会抛出 OutOfMemoryError 异常。
在JDK 7及以前,方法区的实现被称为永久代(Permanent Generation) ,通过 -XX:PermSize 和 -XX:MaxPermSize 参数控制其大小;而在JDK 8及以后,永久代被彻底移除,取而代之的是元空间(Metaspace) ,元空间使用的是本地内存,默认情况下只受本地内存大小限制,也可以通过 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 参数限制其大小。
- 运行时常量池
运行时常量池是方法区的一部分,class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项重要内容就是常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后会被存放到方法区的运行时常量池中。
运行时常量池与class文件常量池的一个重要区别是具备动态性,Java语言并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量放入池中,比如 String 类的 intern() 方法,就可以将字符串实例的引用添加到运行时常量池中。当运行时常量池无法再申请到内存时,会抛出 OutOfMemoryError 异常。