回答
JVM 的运行时内存区域是 Java 虚拟机在运行 Java 程序时用来存储各种数据的地方。它主要分为如下几个部分:
- 程序计数器(Program Counter Register):程序计数器是一块很小的内存区域,它可以看作是当前线程所执行的字节码的行号指示器。
- Java 虚拟机栈:每个线程都有一个独立的 Java 虚拟机栈,用于存储局部变量、操作数栈、动态链接、方法出口等信息。每当一个方法被调用时,都会创建一个新的栈帧(Stack Frame),用于存储方法的局部变量和操作数栈等信息。当方法调用结束时,该栈帧就会销毁。
- 本地方法栈:本地方法栈与 Java 虚拟机栈类似,只不过本地方法栈是为 JVM 使用的 Native 方法服务的。
- 堆:堆是 JVM 管理的最大一块内存区域,用于存放对象实例和数组。
- 方法区:方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区在很多 JVM 实现中也被称为“永久代”(Permanent Generation),但在 JDK 8 之后,永久代被移除,取而代之的是“元空间”(Metaspace)。
扩展
程序计数器
程序计数器是一块很小的内存区域,它的主要作用是记录当前线程所执行的字节码指令地址。通过程序计数器,JVM能够知道下一条要执行的指令是什么,从而保证程序的正常执行流程。
每个线程都有一个独立的程序计数器,这是因为在多线程的环境下,JVM 在任意一个时刻只会执行一个线程的指令。程序计数器为每个线程维护了独立的执行路径,线程之间不会互相干扰。同时,程序计数器的生命周期与线程的生命周期相同,它随着线程的创建而创建,随着线程的结束而销毁。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域。
Java 虚拟机栈
与程序计数器一样,Java 虚拟机栈也是线程私有,其生命周期也和线程的生命周期一样。
Java 虚拟机栈由栈帧(Stack Frame)组成,每次方法的调用,JVM 会向 Java 虚拟机栈中压入一个新建的栈帧,方法结束时,栈帧会被弹出。
每个栈帧包含如下几个部分:
局部变量表
局部变量表用于存放方法参数和局部变量。局部变量表中的变量以索引的方式访问,索引值从0开始,其类型可以是各种基本数据类型(boolean
、byte
、char
、short
、int
、float
、long
、double
)、对象引用(reference)。
操作数栈
一个用于操作数据的先入后出的栈,我们可以把它认为是方法调用的中转站,用于存放方法执行过程中产生的中间计算结果。大多数字节码指令都要从操作数栈中弹出数据,进行计算,再将结果压回操作数栈。
动态链接
在 Java 中,方法的调用包括静态解析和动态链接。其中,静态解析是在编译时完成的,主要用于静态方法和私有方法调用。而动态链接是在运行时完成的,主要用于实例方法的调用。
每个栈帧都包含一个指向运行时常量池中该方法所属类的引用,而动态链接负责将常量池中的符号引用(一种指向类、方法或字段的逻辑地址)转换为直接引用(实际的内存地址)。具体指令:
invokevirtual
:用于调用对象的实例方法,根据对象的实际类型进行动态方法分派。invokespecial
:用于调用实例的私有方法、构造方法(<init>
)以及通过super
调用父类的方法。invokeinterface
:用于调用接口方法,会在运行时搜索实现了这个接口的对象的实际方法。invokestatic
:用于调用类的静态方法。
动态链接是 Java 实现多态性的基础。在 Java 中,它允许一个父类引用指向不同的子类对象,通过动态链接,可以在运行时决定调用哪个具体的子类方法。
方法返回地址
当方法执行结束后,需要返回到调用它的方法。方法返回地址用于存放调用该方法的字节码指令的地址。
在程序运行过程中,Java 虚拟机栈可能会抛出两种错误:
StackOverflowError
:如果线程请求的栈深度大于虚拟机所允许的深度,会抛出StackOverflowError
。常见于递归调用过深导致的栈空间不足。OutOfMemoryError
:如果虚拟机栈可以动态扩展,但内存不足以支持栈的扩展时,会抛出OutOfMemoryError
。
本地方法栈
本地方法栈和 Java 虚拟机栈非常相似,他们的区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。它的主要作用是:
- 支持本地方法的调用和执行:当 Java 程序调用本地方法时,本地方法栈记录方法调用的状态和执行过程中所需的数据。
- 与操作系统交互:本地方法通常用于与底层操作系统进行交互,执行一些 Java 语言无法直接完成的任务。
方法区
https://juejin.cn/post/6898119654233866248
方法区是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
但是**《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现,那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的**。例如我们常说的永久代,它其实是 HotSpot 对方法区的一种具体实现方式。也就是说,永久代是 HotSpot 的一个实现,而方法区则是 Java 虚拟机规范中的一个定义,一种规范,他们之间的关系类似于实现类和接口。
《深入理解Java虚拟机》中对方法区存储内容描述如下:它用于存储已被虚拟机加载的类信息、方法信息、域信息、常量、静态变量、即时编译器编译后的代码缓存等等。
方法区存储内容
类信息
每当一个类被加载到 jvm 中时,相关的类元数据就会存储在方法区,这些元数据包含了 JVM 执行类所需的所有重要信息。包括:
- 类的完全限定名:包括包名和类名。例如,
com.skjava.MyClass
是一个类的完全限定名。 - 类的修饰符:如
public
,private
,abstract
,final
等,定义了类的可访问性和特性。 - 父类信息:存储该类的父类的引用。所有 Java 类最终都继承自
java.lang.Object
。 - 接口信息:该类实现的所有接口的列表,JVM 使用这些信息来确保类在运行时的行为与其声明一致。
- 类加载器引用:每个类在 JVM 中都有一个关联的类加载器引用,用于管理该类的加载过程。
方法信息
方法区还需要存储每个类中所有方法的详细信息,这些信息包括:
- 方法签名:包括方法名、返回类型和参数类型。例如,
public int add(int a, int b)
的签名为add(int, int): int
。 - 方法修饰符:如
public
,private
,static
,final
等,定义了方法的可访问性和特性。 - 方法字节码:方法的具体实现以字节码的形式存储在方法区中。JVM 通过解释这些字节码来执行方法。
- 方法的异常表:存储了与方法相关的异常处理信息,用于异常抛出和捕获。包括每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
域信息
域信息描述了类中定义的所有字段。每个字段都有其名称、类型、修饰符等信息,JVM 需要这些信息来管理类的属性和对象的状态。具体内容包括:
- 字段名称:如
private int age;
中的age
。 - 字段类型:如
int
,String
,double
等,定义字段的数据类型。 - 字段修饰符:如
private
,public
,static
,final
等,决定了字段的可访问性和特性。
常量
常量在类加载时解析并存储在运行时常量池中,具体内容包括:
- 字面量:编译时确定的常量,如字符串、整数、浮点数等。例如,
"Hello, World!"
和123
。 - 符号引用:对类、方法、字段的符号化表示,需要在运行时解析成具体的内存地址。比如方法调用中的符号引用。
静态变量
我们知道静态变量是与类关联的变量,而不是与类的实例关联。所以,所有静态变量都存储在方法区中,这保证了它们在 JVM 中只有一份副本,且在所有实例间共享。
- 静态字段名称:如
private static int counter;
中的counter
。 - 静态字段类型:如
int
,String
,定义了静态变量的数据类型。 - 静态字段的初始值:在类加载时,静态变量通常会被初始化,初始值也存储在方法区中
方法区的变更历史
在 Java 中,方法区经历了多次变更
JDK版本 | 方法区实现 | 变化 |
---|---|---|
JDK 1.6 | 永久代 | 字符串常量池、运行时常量池、静态变量都是在永久代中 |
JDK 1.7 | 永久代 | 字符串常量池和静态变量被移动到了堆当中,运行时常量池还是在永久代中 |
JDK 1.8 | 元空间 | 字符串常量池和静态变量仍然在堆当中;运行时常量池、类型信息、常量、字段、方法被移动都了元空间中 |
至于要换的原因,详情:JVM为什么使用元空间替换了永久代
堆
在 Java 中,“几乎”所有的 Java 对象都是在堆中分配的,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,所有 Java 对象都在堆上分配就不是这么绝对了,详情大明哥后面详细介绍。
Java 堆主要用于存放程序运行时创建的对象和数据,是 JVM 内存中最大的一块,通常会被划分为几个区域。
新生代
主要用于存放新建的对象,它通常较小,经过多次 GC 操作后,如果对象仍然存活,他们将会被移动到老年代。新生代被划分为三个:
- Eden区
新建的对象首先被分配到 Eden区,当Eden区的内存被填满时,JVM会触发一次年轻代的垃圾回收。
- Survivor区
新生代有两个 Survivor区,S0 和 S1,也叫From Survivor(From幸存区)和To Survivor(To幸存区)。S0 和 S1 是交替使用的,即 Eden 区的垃圾回收过程中,存活的对象会被移动到其中一个Survivor区,另一个Survivor区则会在下一次垃圾回收时使用。
老年代
老年代用于存放那些在新生代经过多次垃圾回收后仍然存活的对象,它主要用于存放生命周期较长的对象,且内存大小比新生代的内存大。
在大部分情况下,对象首先是在新生代的 Eden 区分配,在经历一次新生代垃圾回收后,如果对象存活,则会进入 S0 或者 S1 中,且对象的年龄会增加 1,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。为什么是 15 可以阅读这篇文章:GC年龄为什么默认为15,可以超过15吗?
在默认情况下,新生代与老年代占比为 1:2,Eden 区与 survivor 区的占比为 8:1:1。
关于堆,大明哥在后面文章做更加详细的介绍!