亲宝软件园·资讯

展开

JAVA JVM面试题

程序员cxuan 人气:0
想了解JAVA JVM面试题总结的相关内容吗,程序员cxuan在本文为您仔细讲解JAVA JVM面试题的相关知识和一些Code实例,欢迎阅读和指正,我们先划重点:JAVA,JVM面试题,JAVA,JVM,下面大家一起来学习吧。

JVM 的主要作用是什么?

JVM 就是 Java Virtual Machine(Java虚拟机)的缩写,JVM 屏蔽了与具体操作系统平台相关的信息,使 Java 程序只需生成在 Java 虚拟机上运行的目标代码 (字节码),就可以在不同的平台上运行。

请你描述一下 Java 的内存区域?

JVM 在执行 Java 程序的过程中会把它管理的内存分为若干个不同的区域,这些组成部分有些是线程私有的,有些则是线程共享的,Java 内存区域也叫做运行时数据区,它的具体划分如下:

堆空间的内存分配(默认情况下):

老年代 : 三分之二的堆空间

年轻代 : 三分之一的堆空间

eden 区: 8/10 的年轻代空间

survivor 0 : 1/10 的年轻代空间

survivor 1 : 1/10 的年轻代空间

命令行上执行如下命令,会查看默认的 JVM 参数。

java -XX:+PrintFlagsFinal -version

输出的内容非常多,但是只有两行能够反映出上面的内存分配结果

请你描述一下 Java 中的类加载机制?

Java 虚拟机负责把描述类的数据从 Class 文件加载到系统内存中,并对类的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称之为 Java 的类加载机制。

一个类从被加载到虚拟机内存开始,到卸载出内存为止,一共会经历下面这些过程。

类加载机制一共有五个步骤,分别是加载、链接、初始化、使用和卸载阶段,这五个阶段的顺序是确定的。

其中链接阶段会细分成三个阶段,分别是验证、准备、解析阶段,这三个阶段的顺序是不确定的,这三个阶段通常交互进行。解析阶段通常会在初始化之后再开始,这是为了支持 Java 语言的运行时绑定特性(也被称为动态绑定)。

下面我们就来聊一下这几个过程。

加载

关于什么时候开始加载这个过程,《Java 虚拟机规范》并没有强制约束,所以这一点我们可以自由实现。加载是整个类加载过程的第一个阶段,在这个阶段,Java 虚拟机需要完成三件事情:

《Java 虚拟机规范》并未规定全限定名是如何获取的,所以现在业界有很多获取全限定名的方式:

加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以使用用户自定义的类加载器来完成。程序员可以通过自己定义类加载器来控制字节流的访问方式。

数组的加载不需要通过类加载器来创建,它是直接在内存中分配,但是数组的元素类型(数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。

验证

加载过后的下一个阶段就是验证,因为我们上一步讲到在内存中生成了一个 Class 对象,这个对象是访问其代表数据结构的入口,所以这一步验证的工作就是确保 Class 文件的字节流中的内容符合《Java 虚拟机规范》中的要求,保证这些信息被当作代码运行后,它不会威胁到虚拟机的安全。

验证阶段主要分为四个阶段的检验:

文件格式验证。

元数据验证。

字节码验证。

符号引用验证。

文件格式验证

这一阶段可能会包含下面这些验证点:

实际上验证点远远不止有这些,上面这些只是从 HotSpot 源码中摘抄的一小段内容。

元数据验证

这一阶段主要是对字节码描述的信息进行语义分析,以确保描述的信息符合《Java 语言规范》,验证点包括

需要记住这一阶段只是对《Java 语言规范》的验证。

字节码验证

字节码验证阶段是最复杂的一个阶段,这个阶段主要是确定程序语意是否合法、是否是符合逻辑的。这个阶段主要是对类的方法体(Class 文件中的 Code 属性)进行校验分析。这部分验证包括

如果没有通过字节码验证,就说明验证出问题。但是不一定通过了字节码验证,就能保证程序是安全的。

符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化将在连接的第三个阶段,即解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,这个验证主要包括

这一阶段主要是确保解析行为能否正常执行,如果无法通过符号引用验证,就会出现类似 IllegalAccessErrorNoSuchFieldErrorNoSuchMethodError 等错误。

验证阶段对于虚拟机来说非常重要,如果能通过验证,就说明你的程序在运行时不会产生任何影响。

准备

准备阶段是为类中的变量分配内存并设置其初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配,在 JDK 7 之前,HotSpot 使用永久代来实现方法区,是符合这种逻辑概念的。而在 JDK 8 之后,变量则会随着 Class 对象一起存放在 Java 堆中。

下面通常情况下的基本类型和引用类型的初始值

除了"通常情况"下,还有一些"例外情况",如果类字段属性中存在 ConstantValue 属性,那就这个变量值在初始阶段就会初始化为 ConstantValue 属性所指定的初始值,比如

public static final int value = "666";

编译时就会把 value 的值设置为 666。

解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。

这样说你可能还有点不明白,我再换一种说法:

在编译的时候一个每个 Java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。

《Java 虚拟机规范》并未规定解析阶段发生的时间,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对所使用的符号引用进行解析。

解析也分为四个步骤

类或接口的解析

字段解析

方法解析

接口方法解析

初始化

初始化是类加载过程的最后一个步骤,在之前的阶段中,都是由 Java 虚拟机占主导作用,但是到了这一步,却把主动权移交给应用程序。

对于初始化阶段,《Java 虚拟机规范》严格规定了只有下面这六种情况下才会触发类的初始化。

其实上面只有前四个大家需要知道就好了,后面两个比较冷门。

如果说要答类加载的话,其实聊到这里已经可以了,但是为了完整性,我们索性把后面两个过程也来聊一聊。

使用

这个阶段没什么可说的,就是初始化之后的代码由 JVM 来动态调用执行。

卸载

当代表一个类的 Class 对象不再被引用,那么 Class 对象的生命周期就结束了,对应的在方法区中的数据也会被卸载。

⚠️但是需要注意一点:JVM 自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的。

在 JVM 中,对象是如何创建的?

如果要回答对象是怎么创建的,我们一般想到的回答是直接 new 出来就行了,这个回答不仅局限于编程中,也融入在我们生活中的方方面面。

但是遇到面试的时候你只回答一个"new 出来就行了"显然是不行的,因为面试更趋向于让你解释当程序执行到 new 这条指令时,它的背后发生了什么。

所以你需要从 JVM 的角度来解释这件事情。

当虚拟机遇到一个 new 指令时(其实就是字节码),首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析和初始化。

因为此时很可能不知道具体的类是什么,所以这里使用的是符号引用。

如果发现这个类没有经过上面类加载的过程,那么就执行相应的类加载过程。

类检查完成后,接下来虚拟机将会为新生对象分配内存,对象所需的大小在类加载完成后便可确定(我会在下面的面试题中介绍)。

分配内存相当于是把一块固定的内存块从堆中划分出来。划分出来之后,虚拟机会将分配到的内存空间都初始化为零值,如果使用了 TLAB(本地线程分配缓冲),这一项初始化工作可以提前在 TLAB 分配时进行。这一步操作保证了对象实例字段在 Java 代码中可以不赋值就能直接使用。

接下来,Java 虚拟机还会对对象进行必要的设置,比如确定对象是哪个类的实例、对象的 hashcode、对象的 gc 分代年龄信息。这些信息存放在对象的对象头(Object Header)中。

如果上面的工作都做完后,从虚拟机的角度来说,一个新的对象就创建完毕了;但是对于程序员来说,对象创建才刚刚开始,因为构造函数,即 Class 文件中的 <init>() 方法还没有执行,所有字段都为默认的零值。new 指令之后才会执行 <init>() 方法,然后按照程序员的意愿对对象进行初始化,这样一个对象才可能被完整的构造出来。

内存分配方式有哪些呢?

在类加载完成后,虚拟机需要为新生对象分配内存,为对象分配内存相当于是把一块确定的区域从堆中划分出来,这就涉及到一个问题,要划分的堆区是否规整

假设 Java 堆中内存是规整的,所有使用过的内存放在一边,未使用的内存放在一边,中间放着一个指针,这个指针为分界指示器。那么为新对象分配内存空间就相当于是把指针向空闲的空间挪动对象大小相等的距离,这种内存分配方式叫做指针碰撞(Bump The Pointer)

如果 Java 堆中的内存并不是规整的,已经被使用的内存和未被使用的内存相互交错在一起,这种情况下就没有办法使用指针碰撞,这里就要使用另外一种记录内存使用的方式:空闲列表(Free List),空闲列表维护了一个列表,这个列表记录了哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

所以,上述两种分配方式选择哪个,取决于 Java 堆是否规整来决定。在一些垃圾收集器的实现中,Serial、ParNew 等带压缩整理过程的收集器,使用的是指针碰撞;而使用 CMS 这种基于清除算法的收集器时,使用的是空闲列表,具体的垃圾收集器我们后面会聊到。

请你说一下对象的内存布局?

hotspot 虚拟机中,对象在内存中的布局分为三块区域:

对象头(Header)

实例数据(Instance Data)

对齐填充(Padding)

这三块区域的内存分布如下图所示

我们来详细介绍一下上面对象中的内容。

对象头 Header

对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。

在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。

如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。

在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。

用中文翻译过来就是

其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。

关于为什么这么分配的内存,我们可以从 OpenJDK 中的markOop.hpp类中的枚举窥出端倪

来解释一下

在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态),偏向锁,轻量级锁,重量级锁,其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。

所以我们的重点放在对 synchronized 重量级锁的研究上,当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)

这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter 对象。

_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。

那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。

锁的两个列表

当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count - 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。

Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

你可能不是很理解指针是个什么概念,你可以简单理解为指针就是指向某个数据的地址。

实例数据 Instance Data

实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。

对齐 Padding

对齐不是必须存在的,它只起到了占位符(%d, %c 等)的作用。这就是 JVM 的要求了,因为 HotSpot JVM 要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的字节大小是 8 的整数倍,不够的需要使用 Padding 补全。

对象访问定位的方式有哪些?

我们创建一个对象的目的当然就是为了使用它,但是,一个对象被创建出来之后,在 JVM 中是如何访问这个对象的呢?一般有两种方式:通过句柄访问和 通过直接指针访问。

这两种对象访问方式各有各的优势,使用句柄最大的好处就是引用中存储的是句柄地址,对象移动时只需改变句柄的地址就可以,而无需改变对象本身。

使用直接指针来访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因为这类的开销也是值得优化的地方。

上面聊到了对象的两种数据,一种是对象的实例数据,这没什么好说的,就是对象实例字段的数据,一种是对象的类型数据,这个数据说的是对象的类型、父类、实现的接口和方法等。

如何判断对象已经死亡?

我们大家知道,基本上所有的对象都在堆中分布,当我们不再使用对象的时候,垃圾收集器会对无用对象进行回收♻️,那么 JVM 是如何判断哪些对象已经是"无用对象"的呢?

这里有两种判断方式,首先我们先来说第一种:引用计数法

引用计数法的判断标准是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就会加一;当引用失效时,计数器的值就会减一;只要任何时刻计数器为零的对象就是不会再被使用的对象。虽然这种判断方式非常简单粗暴,但是往往很有用,不过,在 Java 领域,主流的 Hotspot 虚拟机实现并没有采用这种方式,因为引用计数法不能解决对象之间的循环引用问题。

循环引用问题简单来讲就是两个对象之间互相依赖着对方,除此之外,再无其他引用,这样虚拟机无法判断引用是否为零从而进行垃圾回收操作。

还有一种判断对象无用的方法就是可达性分析算法

当前主流的 JVM 都采用了可达性分析算法来进行判断,这个算法的基本思路就是通过一系列被称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,则证明此这个对象是无用对象,需要被垃圾回收。

这种引用方式如下

如上图所示,从枚举根节点 GC Roots 开始进行遍历,object 1 、2、3、4 是存在引用关系的对象,而 object 5、6、7 之间虽然有关联,但是它们到 GC Roots 之间是不可大的,所以被认为是可以回收的对象。

虽然我们上面提到了两种判断对象回收的方法,但无论是引用计数法还是判断 GC Roots 都离不开引用这一层关系。

这里涉及到到强引用、软引用、弱引用、虚引用的引用关系,你可以阅读作者的这一篇文章

小心点,别被当成垃圾回收了。

如何判断一个不再使用的类?

判断一个类型属于"不再使用的类"需要满足下面这三个条件

虚拟机允许对满足上面这三个条件的无用类进行回收操作。

加载全部内容

相关教程
猜你喜欢
用户评论