最全的 JVM 面试知识点(一):运行时数据区

96
aoho
2019.06.15 15:23 字数 6292

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

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

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

图片来自 https://www.cnblogs.com/dolphin0520/p/3613043.html

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

本文的主要内容:

  • JVM 内存划分
    • 方法区
    • 运行时常量池
    • Java 虚拟机栈
    • 本地方法栈
    • 程序计数器
    • 栈与堆
  • 直接内存
    • 堆外内存垃圾回收机制
  • JVM 类加载
    • 类的加载过程
    • JVM 预定义的类加载器
    • 双亲委派模式
      • 双亲委派机制
      • 双亲委派作用
    • 对象的创建
      • 对象的内存布局
    • 对象的访问定位

JVM 内存划分

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

image

下面我们依次介绍这些数据区。

堆用于存放对象实例,所有的对象和数组都要在堆上分配。是 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)。


image
  1. 加载
    查找并加载类的二进制数据。
    加载是类加载过程的第一个阶段,虚拟机在这一阶段需要完成以下三件事情:

    • 通过类的全限定名来获取其定义的二进制字节流;
    • 将字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    • 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
  2. 验证
    确保被加载的类的正确性。
    这一阶段是确保 Class 文件的字节流中包含的信息符合当前虚拟机的规范,并且不会损害虚拟机自身的安全。包含了四个验证动作:文件格式验证,元数据验证,字节码验证,符号引用验证。

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

  4. 解析
    把类中的符号引用转换为直接引用。
    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

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

JVM 预定义的类加载器

image
  • 启动(Bootstrap)类加载器
    引导类装入器是用本地代码实现的类装入器,它负责将 < JavaRuntimeHome >/lib 下面的类库加载到内存中。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用。
  • 标准扩展(Extension)类加载器
    扩展类加载器,负责将 < Java_Runtime_Home >/lib/ext 或者由系统变量 java.ext.dir 指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
  • 应用程序类加载器(Application)
    应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)上的类库。

除此之外,还有用户自定义类加载器,是 java.lang.ClassLoader 的子类。在程序运行期间,通过java.lang.ClassLoader 的子类动态加载 class 文件,体现 Java 动态实时类装入特性.

双亲委派模式

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

双亲委派机制
  1. 当 AppClassLoader 加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器 ExtClassLoader 去完成。
  2. 当 ExtClassLoader 加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给 BootStrapClassLoader 去完成。
  3. 如果 BootStrapClassLoader 加载失败,会使用 ExtClassLoader 来尝试加载;
  4. 若 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 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

小结

本文主要讲了 JVM 中运行时数据区的划分以及类加载机制。JVM 中的对象创建之后,如何回收无用的对象呢?JVM 的垃圾回收算法和多种垃圾收集器是怎么样的呢?下篇文章将会具体讲解。

订阅最新文章,欢迎关注我的公众号

微信公众号
面试基础