写点什么

面试 JVM 一问三不知?看这篇就够

作者:java易二三
  • 2023-08-03
    湖南
  • 本文字数:6288 字

    阅读完需:约 21 分钟

小伙伴儿们,大家好!今天来学习 Java 虚拟机相关内容,作为面试必问的知识点,来深入了解一波!

思维导图:


1,JVM 是什么?

1.1,概述

JVM 是 Java Virtual Machine (Java 虚拟机)的缩写,JVM 是一种用于 **计算设备的规范 **。引入 Java 虚拟机后,Java 语言在不同平台上运行时 **不需要重新编译 **。Java 语言编译程序只需生成在 Java 虚拟机上运行的 **目标代码(字节码) **,就可以在多种平台上不加修改地运行。任何平台只要装有针对于该平台的 Java 虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。

所谓 java 能实现跨平台,是由在不同平台上运行不同的虚拟机决定的,因此 java 文件的执行不直接在操作系统上执行,而是通过 jvm 虚拟机执行,我们可以从这张图看到,JVM 并没有直接与硬件打交道,而是与操作系统交互用以执行 java 程序。

1.2,JVM 运行流程


这个是 JVM 的组成图,由四个部分组成:

  • 类加载器

  • 执行引擎

  • 本地接口

  • 运行时数据区

2,JVM 的内存区域

​ 内存区域也就是上面的运行时数据区。对于从事 C 或者 C++的程序员来说,必须对每个对象的整个生命周期负责。但是对 java 程序员来说,在 jvm 的自动内存管理机制下,不需要为每一个对象去写 delete 或者 free 代码,不容易出现内存泄漏或内存溢出的问题。但正因为 java 程序员将内存管理权力交给了内存管理机制,所以一旦出现内存泄漏或者内存溢出的问题,在对 jvm 内存结构不清楚的情况下,排查错误将会成为一项非常复杂且困难的工作。

运行时数据区


2.1,程序计算器

程序计数器是一小块的内存区域,可以看做当前线程执行字节码的行号指示器,在虚拟机的概念模型里, 字节码解释工作就是通过改变这个程序计数器的值来选取下一个要执行的字节码指令。比如分支控制,循环控制,跳转,异常等操作,线程恢复等功能都是通过这个计数器来完成。

由于 jvm 的多线程是通过 **线程的轮流切换并分配处理器执行时间 **来实现的。因此,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。 因此,为了线程切换后能回到正确的执行位置,每条线程都需要自己独有的程序计数器,多条线程计数器之间互不影响,独立存储。我们称这类内存区域为线程私有的内存区域。

如果线程执行的是 Java 方法时,程序计数器记录的是 Java 虚拟机 **正在执行的字节码指令的地址 **,而在线程执行 Native 方法时,程序计数器为空,因为此时 Java 虚拟机调用是和操作系统相关的接口,接口的实现不是 Java 语言,而是 C 语言和 C++。

程序计数器是唯一一个在 Java 虚拟机中不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而结束。

2.2,Java 虚拟机栈

**与程序计数器一致,Java 虚拟机栈也是线程私有的,生命周期与线程相同。虚拟机栈描述的是 Java 方法的执行内存模型 **,每个方法在执行的时候都会创建一个 **栈帧 **(用于存储局部变量表、操作数栈、动态链栈、方法出口等信息)。每一个方法从执行到结束的过程, 就对应一个栈帧从入栈到出栈的过程。

Java 内存可以粗糙地分为 **堆内存(Heap)和 **栈内存(Stack) ,当然 Java 内存区域的划分实际上远比这复杂,我们现在所说的 **Java 虚拟机栈就是这里的栈内存 **,或者说是 **虚拟机栈中局部变量表部分 **。

**局部变量表存放了编译器可知的四类八种基本数据类型 **(boolean、byte、char、short、int、float、long、double), **对象引用 **(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

Java 虚拟机会出现两种异常状况:

如果线程在栈中 **申请的深度 **大于 **虚拟机所允许的深度 **,将出现 StackOverFlowError 异常;如果虚拟机栈可以 **动态扩展,且扩展无法申请到足够的内存 **,就会抛出 OutOfMemoryError 异常。

2.3,本地方法栈

本地方法栈与虚拟机栈的作用非常类似,区别是: **虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务 **,在 HotSpot 虚拟机中直接将虚拟机栈和本地方法栈合二为一。

与 Java 虚拟机栈一样,本地方法栈在执行的时候也会创建一个 **栈帧 **(用于存储局部变量表、操作数栈、动态链栈、方法出口等信息)。也会抛出 StackOverFlowError 异常和 OutOfMemoryError 异常。

2.4,Java 堆

Java 堆是 JVM 所管理的内存中最大的一块区域,Java 堆是被所有线程所共享的一片区域,在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap) 。从内存回收的角度看,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代。进一步划分的目的是更好地回收内存,或者更快地分配内存。根据 JVM 的规范规定, **Java 堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可 **。如果在堆中没有完成内存分配,且堆也没有可扩展的内存空间,则会抛出 OutOfMemoryError 异常。

2.5,方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于 **存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 **。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 **Non-Heap(非堆) **,目的应该是与 Java 堆区分开来。

Java 虚拟机相对而言对方法区的限制非常宽松,除了和堆一样 **不需要连续的空间 **和 **可以选择固定大小 **或者 **可扩展之外 **,还可以 **选择不实现垃圾回收 **。相对而言,垃圾回收在这个区域算比较少见了,但并非数据进入方法区以后就可以实现永久存活了,这个区域的回收目标 **主要是常量池的回收和对类型的卸载 **,一般来说,这个区域的回收成绩是比较难以让人满意的。尤其是类型的卸载,条件相当苛刻。根据 Java 虚拟机规范规定,当方法区无法满足内存分配时,将抛出 OutOfMemoryError 异常。

我们在这里举一个简单例子来看看,看看上述的哪些信息会存放上方法区中;

静态变量和常量,在编译期间就放在方法区中;


//静态变量,在编译期间存放在方法区private static int num=10;//常量,在编译期间存放在方法区private final String name="boy";
复制代码

我们先来看看 new String 时堆中的变化;


String s1="hello";String s2=new String("hello");String s3=new String("hello");System.out.println(s1==s3); // falseSystem.out.println(s2==s3); // false
复制代码

这个输出的结果肯定是 false,采用 new 的时候会在堆内存开辟一块空间存放 hello 对象,虽然 s2 和 s3 指向的内容相同,但是栈种存放的地址不同,所以是不相等的。


对于引用类型来说,"=="指的是地址值的比较。

双引号直接写的字符串是在常量池之中,而 new 的对象则不在池之中。

再来看看运行期间添加进常量池的;


String s2=new String("hello");String s3=new String("hello");//在运行过程中添加进常量池中System.out.println(s2.intern()==s3.intern());
复制代码


**如果常量池中存在当前字符串,那么直接返回常量池中该对象的引用 **。

**如果常量池中没有此字符串, 会将此字符串引用保存到常量池中后, 再直接返回该字符串的引用 **!

2.6,运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

2.7,直接内存

**直接内存并不属于 Jvm 运行时数据区的一部分,但是这部分内存区域被频繁的调用,也可能发生 OutOfMemoryError 异常 **。显然本机的直接内存不会受到 Java 堆分配内存的影响,但是 **既然是内存,肯定要受到本机总内存大小以及处理器寻址空间的限制 **。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx 等参数信息,但经常忽略直接内存。使得各个区域的内存总和大于物理内存限制,从而导致动态扩展时出现 OutOfMemoryError 异常。

3,Java 对象的创建过程

下面这张图就是 Java 对象创建的过程,总共来说分为五部分;


3.1,类加载过程

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

3.2,分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。 **分配方式 **有 **“指针碰撞” **和 **“空闲列表” **两种, **选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定 **。

指针碰撞:

  • 场景:Java 堆中内存是绝对规整的;

  • 原理:所有用过的内存都放在一边,空闲的内存放在另外一边,中间放一个指针作为分界点的指示器,分配内存时只需要把那个指针向空闲空间那边挪动一段与对象大小相等的距离就可以了;

  • GC 收集器:Serial、ParNew 等带 Compact 过程的收集器。

空闲列表:

  • 场景:Java 堆中内存不是规整的;

  • 原理:虚拟机会维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录;

  • GC 收集器:CMS 基于 Mark-Sweep 算法的收集器。

内存分配并发的问题

在创建对象的时候还需要考虑的一个问题就是在并发情况下,线程是否安全的问题。因为创建对象在虚拟机中是非常频繁的行为,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。因此必须要保证线程安全,解决这个问题有两种方案:

  • CAS 以及失败重试(比较和交换机制):对分配内存空间的操作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。CAS 操作需要输入两个数值,一个旧值(操作前期望的值)和一个新值,在操作期间先比较旧值有没有发送变化,如果没有变化,才交换成新值,否则不进行交换。

  • TLAB(分配缓冲):把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存,也就是本地线程分配缓冲。TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。

3.3,初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都 **初始化为零值 **(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

3.4,设置对象头

初始化零值完成之后, **虚拟机要对对象进行必要的设置 **,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 **这些信息存放在对象头中。 **另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

3.5,执行 Init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, <init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

4,对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有 **使用句柄 **和 **直接指针 **两种。

4.1,使用句柄

如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池, reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。如图所示:


通过句柄访问对象

4.2,直接指针

如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。如图所示:


通过直接指针访问对象

这两种对象访问方式各有优势, **使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址, **在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。 **使用直接指针访问方式最大的好处就是速度更快 **,它节省了一次指针定位的时间开销。由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常乐观的执行成本。

5,OutOfMemoryError(内存溢出)异常

在 Java 虚拟机规范的描述中,除了程序计算器之外, **虚拟机内存的其他几个运行时区域都有发生 ****OutOfMemoryError** **异常的可能。 **现在我们通过两个实例来验证异常发生的场景,也会初步介绍几个与内存相关的最基本的虚拟机参数。

5.1,堆内存异常

我们来演示一下堆内存的异常:


/** * @author 公众号:Java斗帝* @create 2020-11-23 08:54* @description */public class HeapOOM { public static void main(String[] args) { //测试堆内存异常 List<HeapOOM> heapOOMList=new ArrayList<>(); //这里只添加一个对象,不会发生异常 heapOOMList.add(new HeapOOM()); //添加进死循环,不断地new对象,堆内存已经耗尽while (true) { heapOOMList.add(new HeapOOM()); } }}
复制代码

在运行这个程序之前,我们先要设置 Java 虚拟机的参数。由于 IDEA 默认设置的堆内存很大,所以我们需要单个配置; 点击Run >> Edit Configurations ,然后就开始配置,如下,初始化堆内存和最大堆内存都设置为 10m,看看上面的死循环能否在 10m 内存中完成;


我们来看运行结果:


可以看到堆内存发生异常,上面的 **死循环中我们不断地 new 对象,导致堆内存已经耗尽,无法为新生的对象分配内存,从而发生异常 **。

5.2,栈内存异常

再来看看栈内存异常:


/*** @author 公众号:Java斗帝* @create 2020-11-23 09:14 * @description */public class StackOOM { public static void main(String[] args) { test(); } //我们设置一个简单的递归方法,没有跳出递归条件的话,就会发生栈内存异常 public static void test(){ test(); }}
复制代码

我们设置一个简单的递归方法,但是不给出跳出递归条件,这样的话就会异。

运行结果如下:


这种是 **线程请求的栈深度超过虚拟机所允许的最大深度,抛出 **StackOverflowError **异常 **,原因就是使用不合理的递归造成的。

我们再来看看第二种异常情况:


packageRegion;
importjavafx.stage.Window;
/***@author公众号:程序员的时光*@create2020-11-23 10:05*@description*/publicclassStackOOM1{
//线程任务,每个线程任务一直在执行privatevoidWinStop(){while(true){System.out.println(System.currentTimeMillis());}}
//不断创建线程publicvoidStackByThread(){while(true){Thread thread=newThread(newRunnable() {@Overridepublicvoidrun(){WinStop();}});}}
publicstaticvoidmain(String[] args){StackOOM1 stackOOM1=newStackOOM1();stackOOM1.StackByThread();}}
复制代码

上述代码的理论上运行结果是: Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread ,但是运行这段代码可能会导致操作系统卡顿,运行须谨慎。

这种是 **虚拟机在扩展栈时无法申请到足够的内存空间,抛出 **OutOfMemoryError **异常 **,原因是不断创建活跃的线程造成的。


用户头像

java易二三

关注

还未添加个人签名 2021-11-23 加入

还未添加个人简介

评论 (1 条评论)

发布
用户头像
加个原创?
21 小时前 · 广东
回复
没有更多了
面试 JVM 一问三不知?看这篇就够_Java_java易二三_InfoQ写作社区