APP下载

最全的 JVM 面试知识点——执行时资料区

消息来源:baojiabao.com 作者: 发布时间:2026-05-23

报价宝综合消息最全的 JVM 面试知识点——执行时资料区

本系列文章讲解 面试中常见的 JVM 问题。这些问题之所以常见,是因为很基础,对于一个有点逼格的程式猿来说, JVM 的相关特性和原理在工作也需要熟知。笔者也在面试的过程中屡屡受挫,屡败屡战,总结一些常见知识点,这些知识点既可以应付面试,也可以帮助读者深入了解 JVM 提供大纲。

在用 C 之类的程式语言时,程序员需要自己手动分配和释放内存。而 Java 不一样,它有垃圾回收器,释放内存由回收器负责。

Java 虚拟机器在执行 Java 程式的过程中会把它管理的内存划分成若干个不同的资料区域。那我们来简单看一下 Java 程式具体执行的过程:

首先 Java 源代码档案(.java 字尾)会被 Java 编译器编译为字节码档案(.class 字尾),然后由 JVM 中的类载入器载入各个类的字节码档案,载入完毕之后,交由 JVM 执行引擎执行。在整个程式执行过程中,JVM 会用一段空间来储存程式执行期间需要用到的资料和相关资讯,这段空间一般被称作为 Runtime Data Area(执行时资料区),也就是我们常说的 JVM 内存。因此,在 Java 中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间)。

本文的主要内容:

JVM 内存划分

堆方法区执行时常量池Java 虚拟机器栈本地方法栈程式计数器栈与堆直接内存

堆外内存垃圾回收机制JVM 类载入

类的载入过程JVM 预定义的类载入器双亲委派模式双亲委派机制双亲委派作用物件的建立物件的内存布局物件的访问定位

JVM 内存划分

执行时资料区分为执行绪私有和共享资料区两大类。其中执行绪私有的资料区包含程式计数器、虚拟机器栈、本地方法区,所有执行绪共享的资料区包含 Java 堆、方法区,在方法区内有一个常量池。

下面我们依次介绍这些资料区。

堆用于存放物件例项,所有的物件和阵列都要在堆上分配。是 JVM 所管理的内存中最大的一块区域。Java 堆是所有执行绪共享的一块内存区域,在虚拟机器启动时建立。此内存区域的唯一目的就是存放物件例项,几乎所有的物件例项以及阵列都在这里分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代。新生代具体划分有:Eden 空间、From Survivor、To Survivor 空间等,进一步划分的目的是更好地回收内存,或者更快地分配内存。

方法区

方法区与 Java 堆一样,是各个执行绪共享的内存区域,它用于储存已被虚拟机器载入的类资讯、常量、静态变数、即编译器编译后的程式码等资料。

HotSpot 虚拟机器中方法区也常被称为 永久代 ,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机器设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机器的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢位问题。相对而言,垃圾收集行为在这个区域是较少出现的,但并非资料进入方法区后就永久存在了。

执行时常量池

执行时常量池是方法区的一部分。Class档案中除了有类的版本、字段、方法、界面等描述资讯外,还有常量池资讯(用于存放编译期生成的各种字面量和符号引用)

Java虚拟机器栈

Java 虚拟机器栈是执行绪私有的,它的生命周期和执行绪相同,描述的是 Java 方法执行的内存模型。 Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机器栈,或者说是虚拟机器栈中区域性变量表部分。储存区域性变量表、算子栈、动态连结和方法出口等资讯。 区域性变量表主要存放了编译器可知的各种资料型别、物件引用。

本地方法栈

和虚拟机器栈所发挥的作用非常相似,区别是: 虚拟机器栈为虚拟机器执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机器使用到的 Native 方法服务。 一个 Native Method 就是一个 Java 程式呼叫非 Java 程式码的界面。在定义一个 Native method 时,并不提供实现体(有些像定义一个java interface),因为其实现体是由非 Java 语言在外面实现的。识别符号native可以与所有其它的 Java 识别符号连用,但是 abstract 除外。

我们知道,当一个类第一次被使用到时,这个类的字节码会被载入到内存,并且只会回载一次。在这个被载入的字节码的入口维持着一个该类所有方法描述符的 list,这些方法描述符包含这样一些资讯:方法程式码存于何处,它有哪些引数,方法的描述符(public 等)等等。

如果一个方法描述符内有 native,这个描述符块将有一个指向该方法的实现的指标。这些实现在一些 DLL 档案内,但是它们会被操作系统载入到 Java 程式的地址空间。当一个带有本地方法的类被载入时,其相关的 DLL 并未被载入,因此指向方法实现的指标并不会被设定。当本地方法被呼叫之前,这些 DLL 才会被载入,这是通过呼叫 java.system.loadLibrary() 实现的。

需要提示的是,使用本地方法是有开销的,它丧失了 Java 的很多好处。如果别无选择,我们可以选择使用本地方法。

程式计数器

程式计数器是一块较小的内存空间,可以看作是当前执行绪所执行的字节码的行号指示器。字节码直译器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、执行绪恢复等功能都需要依赖这个计数器来完。 另外,为了执行绪切换后能恢复到正确的执行位置,每条执行绪都需要有一个独立的程式计数器,各执行绪之间计数器互不影响,独立储存,我们称这类内存区域为“执行绪私有”的内存。

栈与堆

栈解决程式的执行问题,即程式如何执行,或者说如何处理资料;堆解决的是资料储存的问题,即资料怎么放、放在哪儿。

在 Java 中一个执行绪就会相应有一个执行绪栈与之对应,这点很容易理解,因为不同的执行绪执行逻辑有所不同,因此需要一个独立的执行绪栈。而堆则是所有执行绪共享的。栈因为是执行单位,因此里面储存的资讯都是跟当前执行绪(或程式)相关资讯的。包括区域性变数、程式执行状态、方法返回值等等;而堆只负责储存物件资讯。

Java 的堆是一个执行时资料区,类的(物件从中分配空间。这些物件通过 new、newarray、anewarray 和 multianewarray 等指令建立,它们不需要程式程式码来显式的释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在执行时 动态分配内存的,Java 的垃圾收集器会自动收走这些不再使用的资料。但缺点是,由于要在执行时动态分配内存,存取速度较慢。栈的优势是,存取速度比堆要快,仅次于暂存器,栈资料可以共享。但缺点是,存在栈中的资料大小与生存期必须是确定的,缺乏灵活性。栈中主要存放一些基本类 型的变数(int, short, long, byte, float, double, boolean, char)和物件控制代码。

直接内存

在 Java 中当我们要对资料进行更底层的操作时,一般是操作资料的字节(byte)形式,这时经常会用到 ByteBuffer 这样一个类。ByteBuffer 提供了两种静态例项方式:

public static ByteBuffer allocate(int capacity)

public static ByteBuffer allocateDirect(int capacity)

为什么要提供两种方式呢?这与 Java 的内存使用机制有关。ByteBuffer 有两种,一种是 heap ByteBuffer,该类物件分配在 JVM 的堆内存里面,直接由 Java 虚拟机器负责垃圾回收;一种是 direct ByteBuffer 是通过 JNI 在虚拟机器外内存中分配的。JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与快取区(Buffer) 的 I/O 方式,它可以直接使用 Native 函式库直接分配堆外内存,然后通过一个储存在 Java 堆中的 DirectByteBuffer 物件作为这块内存的引用进行操作。这样就能在一些场景中显著提高效能,因为避免了在 Java 堆和 Native 堆之间来回复制资料。本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器定址空间的限制。通过 Jmap 无法检视该快内存的使用情况。只能通过 top 来看它的内存使用情况。

直接内存并不是虚拟机器执行时资料区的一部分,也不是虚拟机器规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 异常出现。 DirectMemory 容量可以通过 -XX:MaxDirectMemorySize 指定,如果不指定,则预设为与 Java 堆的最大值。

堆外内存垃圾回收机制

direct ByteBuffer 通过 full gc 来回收内存,direct ByteBuffer 会自己检测情况而呼叫 system.gc() ,但是如果引数中使用了 -DisableExplicitGC 那么就无法回收该快内存了, -XX:+DisableExplicitGC 标志自动将 System.gc() 呼叫转换成一个空操作,就是应用中呼叫 System.gc() 会变成一个空操作,因此需要我们手动来回收内存了。

@Test

public void testGcDirectBuffer() throws NoSuchFieldException, IllegalAccessException {

ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

Field cleanerField = buffer.getClass().getDeclaredField("cleaner");

cleanerField.setAccessible(true);

Cleaner cleaner = (Cleaner) cleanerField.get(buffer);

cleaner.clean();

}

除此之外,CMS GC 也会回收 Direct ByteBuffer 的内存,CMS 主要是针对老年代空间的垃圾回收。

JVM 类载入

在 Java 中,型别的载入、连线和初始化过程都在程式执行期间完成的,这种策略虽然会使类载入时增加一些效能开销,但是提供了高度的灵活性,Java 天生可以动态扩充套件的语言就是依赖于执行期动态载入和动态连线的特点实现的。

虚拟机器把描述类的资料从 Class 档案载入到内存,并对资料进行校验、转换解析和初始化,最终形成可以被虚拟机器直接使用的 Java 型别,这就是 Java 虚拟机器的类载入机制。Class 档案是一串二进位制的字节流。实际上,每个 Class 档案都有可能代表着 Java 语言中的一个类或者界面。

类的载入过程

类从被载入到虚拟机器内存中开始,到卸载出内存为止,它的整个生命周期包括:载入(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安装(Unloading)7个阶段。其中准备、验证、解析3个部分统称为连线(Linking)。

1. 载入

查询并载入类的二进位制资料。 载入是类载入过程的第一个阶段,虚拟机器在这一阶段需要完成以下三件事情:

通过类的全限定名来获取其定义的二进位制字节流;将字节流所代表的静态储存结构转化为方法区的执行时资料结构;在 Java 堆中生成一个代表这个类的 java.lang.Class 物件,作为对方法区中这些资料的访问入口。2. 验证

确保被载入的类的正确性。 这一阶段是确保 Class 档案的字节流中包含的资讯符合当前虚拟机器的规范,并且不会损害虚拟机器自身的安全。包含了四个验证动作:档案格式验证,元资料验证,字节码验证,符号引用验证。

档案格式检验:检验字节流是否符合Class档案格式的规范,并且能被当前版本的虚拟机器处理。检验可能包含下列几种:是否以魔数开头、主次版本号是否在虚拟机器的处理范围之内,常量池中的常量是否不被支援、档案是否被删除或附加什么资讯等等。 只有通过档案格式检验的二进位制字节流才能进入内存的方法区进行储存,所以后面的3个检验阶段都是基于方法区的储存结构进行的,不会在操作字节流。元资料检验:对字节码描述的资讯进行语义分析,以保证其描述的内容符合Java语言规范的要求。 验证点包括:是否有父类(除了object)、父类是否继承了不可被继承的类(被final修饰的类)、如果这个类不是抽象类,是否实现了其父类或界面之中要求实现的所有方法、类中的方法和字段是否与父类产生矛盾(覆盖了父类的final字段、出现不合规矩的方法过载等)。 元资料检验主要是对类的元资料资讯进行语义校验,保证不符合Java语言规范的元资料资讯不存在。字节码检验:通过资料流和控制流分析,确定程式语义是合法、符合逻辑的。第二阶段是对元资料资讯中的资料型别做了检验,这一阶段是对类的方法体进行校验分析,保证被校验类的方法在执行时不会做出危害虚拟机器安全的事情。 检验点包括:保证任意时刻算子栈的资料型别与指令程式码序列都能配合工作、保证指令跳转不会跳转到方法体之外的地方、保证方法体内的型别转换都是有效的。 事实上,即便是经过字节码检验后的方法体也不一定是安全的。符号引用检验:最后一个检验发生在虚拟机器将符号引用转化为直接引用时,这个转化动作将在连线的第三阶段–解析阶段中发生的。符号引用检验可以看作是对类自身以外(常量池中的各种符号引用)的资讯进行匹配性校验。 校验点:符号引用中通过字串描述的全限定名是否能找到对应的类、在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段、符号引用中的类、字段、方法的访问许可权是否能让当前类访问到等。 符号引用检验的目的是确保解析动作的正常执行,如果无法通过符号引用检验,将会丢掷 java.lang.IncompatibleClassChangeError 异常的子类,如 IllegalAccessError 、 NoSuchfiledError 、 NoSuchMethodError 等。3. 准备

为类的静态变数分配内存,并将其初始化为预设值。 准备阶段是正式为类变数分配内存并设定类变数初始值的阶段,这些内存都将在方法区中分配。

4. 解析

把类中的符号引用转换为直接引用。 解析阶段是虚拟机器将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或界面、字段、类方法、界面方法、方法型别、方法控制代码和呼叫点限定符 7 类符号引用进行。

5. 初始化

类变数进行初始化 为类的静态变数赋予正确的初始值,JVM 负责对类进行初始化,主要对类变数进行初始化。

JVM 预定义的类载入器

启动(Bootstrap)类载入器:引导类装入器是用原生代码实现的类装入器,它负责将 /lib 下面的类库载入到内存中。由于引导类载入器涉及到虚拟机器本地实现细节,开发者无法直接获取到启动类载入器的引用。标准扩充套件(Extension)类载入器:扩充套件类载入器,负责将 /lib/ext 或者由系统变数 java.ext.dir 指定位置中的类库载入到内存中。开发者可以直接使用标准扩充套件类载入器。应用程序类载入器(Application ClassLoader):负责载入使用者路径(classpath)上的类库。除此之外,还有使用者自定义类载入器,是 java.lang.ClassLoader 的子类。在程式执行期间,通过java.lang.ClassLoader 的子类动态载入 class 档案,体现 Java 动态实时类装入特性.

双亲委派模式

双亲委派模型的工作流程是:如果一个类载入器收到了类载入的请求,它首先不会自己去载入这个类,而是把请求委托给父载入器去完成,依次向上。因此,所有的类载入请求最终都应该被传递到顶层的启动类载入器中,只有当父载入器没有找到所需的类时,子载入器才会尝试去载入该类。

双亲委派机制

当 AppClassLoader 载入一个 class 时,它首先不会自己去尝试载入这个类,而是把类载入请求委派给父类载入器 ExtClassLoader 去完成。当 ExtClassLoader 载入一个 class 时,它首先也不会自己去尝试载入这个类,而是把类载入请求委派给 BootStrapClassLoader 去完成。如果 BootStrapClassLoader 载入失败,会使用 ExtClassLoader 来尝试载入;若 ExtClassLoader 也载入失败,则会使用 AppClassLoader 来载入,如果 AppClassLoader 也载入失败,则会报出异常 ClassNotFoundException。

双亲委派作用

通过带有优先级的层级关系可以避免类的重复载入; 保证 Java 程式安全稳定执行,Java 核心 API 定义型别不会被随意替换。

物件的建立

虚拟机器遇到一条 new 指令时,首先将去检查这个指令的引数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被载入、解析和初始化过。如果没有,那必须先执行相应的类载入过程。

在类载入检查通过后,接下来虚拟机器将为新生物件分配内存。物件所需的内存大小在类载入完成后便可确定,为物件分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有 “指标碰撞” 和 “空闲列表” 两种:

指标碰撞 把指标向空闲物件移动与物件占用内存大小相等的距离。空闲列表 虚拟机器维护一个列表,记录可用的内存块,分配给物件列表中一块足够大的内存空间。选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。虚拟机器采用CAS配上失败重试的方式保证更新操作的原子性。

物件的内存布局

在 Hotspot 虚拟机器中,物件在内存中的布局可以分为3块区域:物件头、例项资料和对齐填充。

物件头,Hotspot 虚拟机器中的物件头包括两部分资讯,第一部分用于储存物件自身的自身执行时资料(杂凑吗、GC 分代年龄、锁状态标志等等);另一部分是型别指标,即物件指向它的类元资料的指标,虚拟机器通过这个指标来确定这个物件是那个类的例项。例项资料,是物件真正储存的有效资讯,也是在程式中所定义的各种型别的字段内容。对齐填充部分,不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机器的自动内存管理系统要求物件起始地址必须是8字节的整数倍,换句话说就是物件的大小必须是8字节的整数倍。而物件头部分正好是8字节的倍数(1倍或2倍),因此,当物件例项资料部分没有对齐时,就需要通过对齐填充来补全。

物件的访问定位

建立物件就是为了使用物件,我们的Java程式通过栈上的reference资料来操作堆上的具体物件。物件的访问方式由虚拟机器实现而定,目前主流的访问方式有控制代码和直接指标两种:

使用控制代码,那么 Java 堆中将会划分出一块内存来作为控制代码池,reference 中储存的就是物件的控制代码地址,而控制代码中包含了物件例项资料与型别资料各自的具体地址资讯;直接指标访问,那么 Java 堆物件的布局中就必须考虑如何防止访问型别资料的相关资讯,reference 中储存的直接就是物件的地址。这两种物件访问方式各有优势。使用控制代码来访问的最大好处是 reference 中储存的是稳定的控制代码地址,在物件被移动时只会改变控制代码中的例项资料指标。而 reference 本身不需要修改。使用直接指标访问方式最大的好处就是速度快,它节省了一次指标定位的时间开销。

2019-12-15 02:51:00

相关文章