文章目录
深入JVM:详解JVM内存模型及其演变过程
一、序言
对于Java工程师而言,深入理解JVM(Java虚拟机)不仅是掌握Java程序运行机制的基础,也是提升系统性能、优化应用和解决复杂问题能力的重要一步,更是Java进阶之路的重中之重。
JVM内存模型定义了Java程序在运行过程中的内存分配、使用和回收策略,其作为Java虚拟机的核心知识,相信大家面试的时候,或多或少经历过JVM相关的连环拷打。同时随着Java版本的迭代,不同版本的Java虚拟机内存模型也会存在差异,这就致使当我们在阅读技术博客时,如果作者没有明确指出所讨论的Java版本,很容易混淆这些概念,一会称方法区为永久代、一会为元空间、一会字符串常量池存放在堆内存,一会又在元空间,很容易被搞晕。
本文小豪将深入解析JVM内存模型的组成,探讨不同Java版本中内存模型的变化和演进,帮助大家全面理解和掌握JVM内存模型。
二、内存模型组成
在之前【深入JVM:从源码剖析双亲委派机制】一文中,我们了解到类加载器在程序运行时将类的字节码文件加载到JVM内存中。
JVM内存也就是Java虚拟机在运行Java程序过程时管理的内存区域(即我们常说的运行时数据区),主要有Java虚拟机栈、本地方法栈、程序计数器、元空间和堆,我们通常将它们分成两大类:
本文介绍的内存模型基于目前大部分企业使用的Java 8版本的虚拟机
-
线程私有:Java虚拟机栈、本地方法栈、程序计数器
-
线程共享:元空间(方法区)、堆
1、线程私有
(1)Java虚拟机栈
Java虚拟机栈是Java程序中每个线程运行时所需要的内存,采用栈(先进后出)的数据结构来管理方法调用中的数据,每个线程都有自己的虚拟机栈,栈的生命周期和线程相同。
每个栈又由多个栈帧组成,在线程上每个正在执行的方法都对应一个栈帧,每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
举个例子:
public class Test {
public static void main(String[] args) {
animal();
}
public static void animal() {
System.out.println("I'm a Cat");
eat();
}
public static void eat() {
System.out.println("I like to eat fish");
}
}
Java虚拟机栈内存如下:
1.1 栈帧组成
每个栈帧里面又由三部分组成:
- 局部变量表:存储方法中的局部变量,包括基本数据类型的变量以及对象的引用变量。
- 操作数栈:在方法执行时用于存放中间计算结果和操作数的临时内存区。
- 帧数据:与栈帧控制和动态链接相关的信息,包括动态链接、方法出口和异常表。
- 动态链接:当前类的字节码指令引用了其它类的属性或方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址(直接引用)。动态链接就保存了编号到运行时常量池的内存地址的映射关系。
- 方法出口:当方法执行完毕后,需要返回到调用该方法的地方继续执行,方法出口就记录了返回地址,使程序计数器能够定位到调用方法后的下一条指令。
- 异常表:记录方法内部的异常处理器信息,包括异常类型、异常处理器的起始位置和结束位置,以及异常处理器的代码处理程序。
小豪在这里提个问题,我们知道Java虚拟机栈是线程私有的,那么方法内的局部变量是否是线程安全的呢?
首先,如果局部变量只在方法内部使用,它就是线程安全的,因为每个虚拟机栈是线程私有的。
但是要是通过形参将线程不安全的变量传入方法中,或者将变量
return
出去,那都是线程不安全的。
1.2 栈内存溢出
Java虚拟机栈内存溢出时会出现StackOverflowError
的错误,包括两种情况:
-
栈帧过多导致栈内存溢出,常见问题就是递归调用;
-
栈帧过大导致栈内存溢出,常见的就是定义了太多的局部变量。
(2)本地方法栈
本地方法栈存储的是Native
本地方法的栈帧,本地方法栈就是运行Native
本地方法时分配的一块空间。
另外,在Hotspot虚拟机中,Java虚拟机栈和本地方法栈共享同一个内存空间,即本地方法也在Java虚拟机栈上分配栈帧。这样做可以在出现异常时统一处理栈信息,同时也便于临时保存本地方法的参数。
(3)程序计数器
程序计数器(即PC寄存器)是用于记录当前线程将要执行的字节码指令的地址,总是指向下一条将会被的执行字节码指令的地址。
主要包括两个作用:
- 控制字节码指令的执行流程和实现分支、跳转等功能
- 在多线程环境中,记录每个线程的执行位置,使得在CPU切换线程后能够从返回到正确的指令继续执行
由于每个线程的程序计数器只存储一个固定长度的内存地址,所以程序计数器也是JVM内存模型中唯一不会发生内存溢出的区域
2、线程共享
(1)元空间(方法区)
元空间(方法区)主要存放的是类的基础信息,在Java 8中元空间已经不在Java虚拟机中,而是存储在本地内存,其主要包括以下两部分内容:
-
类的元信息:通过类加载器被加载的所有类的基本信息,包括类描述信息、静态常量池、字段、方法、虚方法表
-
运行时常量池:运行时常量池就是当类被加载到内存中之后,它的静态常量池信息就会放入到一块内存,这块内存就叫运行时常量池,而且会把里面的符号引用变为直接引用,也就是对应的真实地址。
静态常量池存储的是一些字面量(方法描述、类描述)和符号引用,静态常量池可以看作是一张表,字节码文件通过编号查表的方式找到常量
(2)堆
堆内存主要用于存储创建出来的对象实例,以及所有线程共享的数据,包括以下内容:
- 对象实例:存放所有创建出来的对象实例
- 数组:包括对象数组及基本数据类型数组
- 字符串常量池:存储在代码中定义的常量字符串内容
- 静态变量:
static
关键字修饰的静态变量
其中字符串常量池、静态变量在Java 7之前存放于方法区,在Java 7版本时移动至堆内存
1.1 字符串编译优化
小豪在这里额外讲一下字符串的编译优化,下面有个小例子,大家可以思考一下运行结果:
public static void main(String[] args) {
String con = "Hello World";
String m1 = "Hello ";
String m2 = "World";
// 变量相加
String can1 = m1 + m2;
// 字符串相加
String can2 = "Hello " + "World";
System.out.println(con == can1);
System.out.println(con == can2);
}
控制台输出:
false
true
结果为何会是这样呢?
-
第一个返回值为
false
,首先我们知道String
被final
修饰,它的不可变的,m1
和m2
变量相加,底层实际上使用的是StringBuilder
对象进行拼接的,生成的对象在堆内存中,而con
字符串变量直接是在字符串常量池中,自然它们的地址是不相等的。 -
第二个返回值为
true
,这是由于两个写死的字符串拼接,在编译期间JVM就会自动进行优化,直接获取的是字符串常量池中对应的值。
底层字节码指令如下:
1.2 堆内存溢出
堆内存溢出时会出现OutOfMemoryError
异常(OOM),具体异常的产生原因和排查定位方法我们会在后续单独开一篇详解,本文不做过多补充
三、内存模型演变路线
JVM内存模型在不同版本的JDK中存在差异,同时,部分区域存放的内容也发生了变化。
不少小伙伴可能在学习的时候,经常会被方法区、永久代和元空间这几个概念搞混,甚至搞不清楚静态变量、字符串常量池到底放在哪个区域。
本节小豪将带大家彻底搞清楚这些问题。
- 首先方法区它是一片区域的抽象概念,是在《Java虚拟机规范》中定义的运行时数据区域之一,它与堆一样在线程之间共享。在Java 7及其之前,它的具体实现叫永久代(Hotspot虚拟机),而在Java 7中,它的具体实现叫元空间
1、Java 6版本
Java 6及以前在HotSpot
虚拟机中,方法区被定义为永久代,甚至在物理区域上属于堆内存中的一块,使用虚拟机内存,也就是内存大小由虚拟机参数控制。
永久代的垃圾回收是和堆内存中的老年代是捆绑在一起的,只要任意一个的空间满了,都会触发垃圾收集。
只有
HotSpot
虚拟机有永久代,其它类型的虚拟机并没有
此时字符串常量池和静态变量都在永久代。
Java 6虚拟机内存结构如下:
2、Java 7版本
Java 7永久代依然在,但是其中的字符串常量池、静态变量都已经转移到了堆内存中。
Java 7虚拟机内存结构如下:
那为何要将字符串常量池和静态变量转移到堆内存呢?
无非就是为了优化垃圾回收:因为永久代很少触发垃圾回收的(只有Full GC
时),而堆的垃圾回收较为频繁,字符串常量池的回收逻辑和类对象的回收逻辑类似,不被使用就可以被回收,移动到堆之后,就可以利用对象的垃圾回收器,及时释放内存。
3、Java 8版本
Java 8永久代被移除,方法区被定义元空间。此时元空间已经不在虚拟机中,而是脱离虚拟机内存,直接使用本地内存。元空间的内存上限被大大提高,只受限于操作系统的内存大小。
Java 8虚拟机内存结构如下:
那为何要使用元空间替换永久代呢?
两方面原因:
- 优化垃圾回收:永久代在物理区域上属于堆内存,堆内存中主要存放对象,而对象的垃圾回收本质上还是与类的回收有区别,在【深入JVM:从类加载机制解读类的生命周期】一文中,我们了解到类的卸载条件相对苛刻,程序运行期间基本不太可能发生。而永久代使用堆中老年代的垃圾回收策略,显然不够灵活。
- 调整内存上限:元空间直接使用本地内存,默认并没有设置上限,可以动态的分配内存,降低OOM风险。
四、后记
本文从JVM内存模型的各个组成部分入手,详细介绍Java虚拟机栈、程序计数器、堆等各部分的存储内容和作用,同时通过组成图对比的形式,清晰地梳理各Java版本的内存模型变化,相信经过本文,大家已经对JVM内存模型的整体结构有了更加清晰的认知。
其中堆内存作为Java虚拟机管理内存中最大的一块区域,不仅是存储实例对象的关键区域,也是我们进行的垃圾回收和性能调优的关键角色,后续也会继续深入这部分内容,解决实际生产问题并提高故障排查效率。
未来一段时间,小豪将会持续更新JVM相关知识体系,如果大家觉得内容还不错,可以先点点关注,共同进步~