JVM面试题

124 阅读21分钟

JVM

整体结构

说一下 JVM由那些部分组成,运行流程是什么?

JVM包含两个子系统和两个组件: 两个子系统为Class loader(类装载)、Execution engine(执行引擎); 两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
  • Execution engine(执行引擎):执行classes中的指令。
  • Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

流程 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

在这里插入图片描述

内存区域

五大内存区域?

先说每个线程独占的:

  1. 程序计数器:记录当前线程执行到的位置,方便线程上下文切换后续的执行
  2. 虚拟机栈:方法压栈时会产生一个栈帧,用于记录方法的局部变量表(基本数据类型、方法域中定义的变量及引用等)、操作数栈、动态链接、方法出口(出栈时需要回到调用该方法的位置)等
  3. 本地方法栈:和虚拟机栈作用相似,不过它是为虚拟机使用到的Native方法服务

线程共享的:

  1. 堆:几乎所有对象实例分配内存的地方、垃圾回收的主要战场
  2. 方法区:存储类的元信息、常量、静态变量、即时编译器编译后的代码等数据

详细的介绍下程序计数器?(重点理解)

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。称之为“线程私有”的内存。程序计数器内存区域是虚拟机中唯一没有规定OutOfMemoryError情况的区域。

详细介绍下Java虚拟机栈?(重点理解)

虚拟机栈中是有单位的,单位就是栈帧,一个方法一个栈帧。一个栈帧中他又要存储,局部变量,操作数栈,动态链接,出口等。它的生命周期和线程相同。

局部变量表:是用来存储我们临时8个基本数据类型、对象引用地址、returnAddress类型。(returnAddress中保存的是return后要执行的字节码的指令地址。)

操作数栈:操作数栈就是用来操作的,例如代码中有个 i = 6*6,他在一开始的时候就会进行操作,读取我们的代码,进行计算后再放入局部变量表中去

动态链接:假如我方法中,有个service.add()方法,要链接到别的方法中去,这就是动态链接,存储链接的地方。

出口:出口是什么,出口正常的话就是return,不正常的话就是抛出异常

方法区、永久代、元空间的区别?

方法区是一种JVM规范,永久代和元空间都是方法区的一种实现方式,jdk1.7以前采用永久代概念,jdk1.8及以后采用元空间。

元空间与永久代的区别:

  1. 元空间不存在于虚拟机中,而是使用本地内存,解决了永久代大小不好确定的问题(太小容易导致永久代溢出,太大容易导致老年代溢出)
  2. 这两者的存储内容没怎么变化,在内存限制、垃圾回收等机制上改变较大,元空间解决了类信息过多导致OOM的问题

元空间保存在本地内存中的好处是什么呢?

  1. 因为直接内存,JVM将会在IO操作上具有更高的性能,因为它直接作用于本地系统的IO操作。而非直接内存,也就是堆内存中的数据,如果要作IO操作,会先复制到直接内存,再利用本地IO处理。 从数据流的角度,非直接内存是下面这样的作用链:本地IO --> 直接内存 --> 非直接内存 --> 直接内存 --> 本地IO,而直接内存是:本地IO --> 直接内存 --> 本地IO
  2. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到java.lang.OutOfMemoryError。

你听过直接内存吗?

直接内存是基于物理内存和Java虚拟机内存的中间内存,但是这部分内存也被频繁地使用,而且也可能导致Out Of Memory Error 异常出现。在Java堆中,可以用directByteBuffer对象直接引用并操作

  • 静态变量放在方法区
  • 静态的对象还是放在堆。

Java对象的内存分配过程如何保证线程安全?

堆是线程间共享的,为了解决并发问题必须做到同步控制,HotSpot虚拟机使用的方法是TLAB(Thread Local Allocation Buffer)分配技术。线程会单独拥有堆中一小块内存的分配权,如果需要内存分配就在自己的空间上分配,不会存在竞争。

TLAB太小会导致分配对象空间大于TLAB时面临一个抉择:直接在堆内存中分配该对象、废弃当前TLAB重新申请TLAB空间进行内存分配。虚拟机定义了一个最大浪费空间的参数,用于指定什么情况下采用哪种策略。

JVM和JMM的关系?

JMM即Java内存模型,划分了主内存和工作内存两种,与JVM的内存划分是在不同层次下进行的,两者之间关系不大,但也是可以类比一下的。

JMM定义了JVM在计算机内存(RAM)中的工作方式。

类比一下JMM -- JVM -- 操作系统的话,就是下面这样的:

主内存 -- Java堆 -- 操作系统的物理内存

工作内存 -- 栈中的部分区域 -- 寄存器和高速缓存

Java线程有各自的工作内存,不同线程的工作内存中保存了该线程使用到的变量,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。线程间通信需要借助主内存来完成。

img

GC

Java会存在内存泄漏吗?请说明为什么?

  • 内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。
  • 但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。

怎么判断对象是否可以被回收?

引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;(这个已经淘汰了)

可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。(市场上用的非常非常广泛)

GC Roots是什么,有哪些对象可以作为GC Roots

HotSpot虚拟机使用可达性分析算法确定对象是否可以被GC

GC Roots包括以下对象:

  1. 虚拟机栈上的本地变量表引用的对象
  2. 方法区中类的静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI引用的对象

JVM 垃圾回收算法有哪些?

  • 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
  • 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
  • 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
  • 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

OopMap是什么?

由于所有的垃圾回收器都要求根节点枚举时暂停用户线程,所以停顿的时间要越少越好。OopMap(普通对象指针表)就是通过空间换时间解决这一问题。

OopMap是一个专门的映射表,记录哪些位置存放着对象引用,在根节点枚举阶段直接读取这些指针,避免全栈扫描。

Safe Point和Safe Region是什么?

Safe Point

通过OopMap可以快速进行GCROOT枚举,但是随着而来的又有一个问题,就是在方法执行的过程中, 可能会导致引用关系发生变化,那么保存的OopMap就要随着变化。如果每次引用关系发生了变化都要去修改OopMap的话,这又是一件成本很高的事情。所以这里就引入了安全点的概念。

主要思路是:并不需要一发生改变就去更新这个映射表。只要这个更新在GC发生之前就可以了。所以OopMap只需要在预先选定的一些位置上记录变化的OopMap就行了。这些特定的点就是SafePoint(安全点)。由此也可以知道,程序并不是在所有的位置上都可以进行GC的,只有在达到这样的安全点才能暂停下来进行GC。

Safe Region

对于处在Sleep或Blocked状态的线程,他们不会在很短时间内跑到安全点去,所以引入了安全区域的概念。

安全区域就是在程序的一段代码片段中不会导致引用关系发生变化,就不用去更新OopMap了,在这段代码区域内任何地方进行GC都没有问题。

stw是什么,什么情况会发生?

STW是“Stop the world”的简称。即整个Java虚拟机应用线程暂停工作。Minor GC和Full GC的时候,需要去标记或者计算对象是否还在被引用,如果在标记或者计算的过程中如果还有新对象产生,那么标记就不完整了,所以这个时候就需要STW了。

CMS垃圾回收器中根节点枚举重新标记的过程中会发生stw(stop the world)以保证对象引用状态不会发生变化。

写屏障你了解吗?

写入屏障是用于解决并发清除过程中出现的对象消失问题。(并发清除过程中还有浮动垃圾的问题,不过这是小问题。)

在《深入理解Java虚拟机》书中用的是三色标记法来解释这个问题。黑色、白色、灰色分别表示引用的节点全被扫描、未被扫描、引用的节点至少还有一个未扫描。经过分析发现,当以下两个条件同时满足时,会产生对象消失问题:

  1. 在并发标记过程中,一个黑色节点新增了一个或多个指向白色节点的引用
  2. 所有指向一个白色节点的灰色节点都删除了对它的引用

所以解决问题的思路就是破坏其中一个条件,主要有两种方式:

增量更新(Incremental Update) :当黑色对象直接引用了一个白色对象后,我们就将这个黑色对象记录下来,在扫描完成后,重新对这个黑色对象扫描

原始快照(Snapshot At TheBeginning,SATB) :当删除了灰色对象到白色对象的直接或间接引用后,就将这个灰色对象记录下来,再以此灰色对象为根,重新扫描一次。

分代理论

基于两个假说:

  1. 绝大部分对象的生命周期很短
  2. 经历了越多次垃圾回收仍存活的对象,越难被清理

针对不同特性的对象,分为新生代和老年代分开收集,便于采用不同的算法提高效率。

空间分配担保是什么?

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

在jdk6以后的规则是:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC。

jdk6以前的规则还需要加一个是否允许担保失败的参数,允许担保且老年代的连续空间大于新生代历次晋升的平均大小时,会尝试进行一次垃圾回收。

Java的引用类型

强引用:大部分引用实际都是强引用,被强引用的对象不会被回收。

软引用:如果内存空间足够垃圾回收器就不会回收他,如果不足了就会回收。可以用来实现内存敏感的高速缓存,使用频率第二高,因为软引用可以加速JVM对垃圾内存的回收速度,防止OOM等问题的产生。

弱引用:只能生存到下一次垃圾回收时。弱引用可以和一个引用队列联合使用,当弱引用所引用的对象被垃圾回收,就会把这个弱引用加入到与之关联的引用队列中。

虚引用:和没有引用一样,任何时候都可能被垃圾回收。主要用于跟踪对象被垃圾会受到活动。

方法区的垃圾回收行为

方法区的回收效果比较难以令人满意,但也是要回收的

问题可以分为两小部分:

  • 如何收集常量池中的废弃常量?

    主要是字面量和符号常量,回收时和堆中策略一样,常量池中的对象如果没有被引用的话就可以被收集

  • 如何收集不再使用的类型(类的卸载)?

    条件比较苛刻,需要同时满足以下三个条件:

    1. 该类所有实例已被回收
    2. 类加载器已被回收,需要在设计时指定可替换类加载器的场景
    3. Class对象没有在任何地方被引用,即不能通过反射访问该类的方法

Minor GC和Full GC的触发时机?

Minor GC:

Eden区满时会触发MinorGC,使Eden区和Survivor From区中存活的对象复制到Survivor To区中,然后存活对象的年龄加一。

Full GC:

  1. 老年代空间不足
  2. 方法区空间不足
  3. MinorGC后进入老年代的平均大小大于老年代的可用内存
  4. Minor GC时Survivor To区内存不足且老年代连续可用内存不足
  5. 手动调用System.gc()

Minor GC如何提高性能?

由于Minor GC是发生在新生代的垃圾回收,比较频繁,因此不能每次都进行全堆扫描(有老年代对象持有新生代对象的情况),可以采用卡表的方式,用一个卡表来记录老年代中对象是否引用了新生代对象,并置标记位,脏卡则能作为扫描的起点。

对象进入老年代的条件?

  1. MinorGC时出现Survivor To存不下对象
  2. 大对象直接进入
  3. 长期存活的对象
  4. 动态对象年龄判定,当From空间中相同年龄所有对象的大小综合大于Survivor区空间的一半,则将晋升年龄调整为该年龄,而不用等到15岁(默认)

常用垃圾回收器有哪些?重点讲讲CMS和G1?

可以分为单线程、多线程、高吞吐量、停顿时间优先、可控制停顿时间等类型的垃圾回收器,视具体场景应用不同的回收器。

CMS:

获取最短回收停顿时间为目标,真正意义上的并发收集器,第一次实现了让垃圾收集线程和用户线程同时工作。采用标记-清除算法,收集结束时会有大量的空间碎片产生。

分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。具体来说,就是用三色标记法,通过增量更新的方式记录下新增引用的黑色节点;
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短;
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

G1:

jdk1.7以后的新款收集器,可以满足大部分情况下的GC停顿时间,也保证了高吞吐量,对服务器来说十分友好。

  • 利用CPU多核环境缩短stw停顿时间
  • 采用分区收集,但也保留了分代收集的概念,可以独自管理整个GC堆,不需要与其他垃圾回收器配合
  • 总整体上看,通过标记-整理算法收集;局部上看,使用复制算法实现收集
  • 相比CMS来说,G1除了追求低停顿外,还建立了可预测的事件模型

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)

缺点:用户程序运行过程中,垃圾收集产生的内存占用大、运行时的额外负载较高,在小内存应用表现不如CMS(平衡点在6-8G之间)

具体过程十分复杂,有空会再好好研究。

类加载相关

简述一下Java对象创建的过程

  1. 判断类是否被加载

    先去常量池中查找这个类的符号引用,如果能找到,说明该类已经被加载到方法区,否则需要使用类加载器执行类的加载过程,再进行后续操作。

  2. 在堆上为对象分配内存

    具体的分配方式有两种,一种是空间规整情况下的指针碰撞;一种是内存空间不连续的空闲列表。

    同时也要保证分配内存的线程安全问题:

    • CAS加失败重试机制
    • 本地线程分配缓冲(TLAB),每一个线程在堆中预先分配一小块内存,这样当分配内存时,线程之间就不会发生冲突
  3. 内存空间上的对象初始化零值

  4. 对对象进行其他设置,如对象所属的类、类的元信息、GC分代年龄等信息

  5. 执行init方法,赋真正的值

简述类的加载过程

  1. 加载: 根据类的全类名找到对应的字节码文件并读入内存中(字节流代表的静态存储结构转换为方法区的运行时数据结构)、(在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口)

  2. 连接:

    • 验证: 文件格式校验、字节码校验、元数据校验、符号校验等,以保证生成的类符合jvm规范。
    • 准备: 为静态变量、final变量分配内存、赋初始值
    • 解析: 将符号引用解析成直接引用,(符号引用是编译原理方面的概念,包括了类和接口的全限定名、字段和方法的名称和描述符)
  3. 初始化: 执行初始化方法 () ,真正执行类中定义的Java程序代码。比如给类的成员变量赋值等操作就是在这一部分完成的。

    初始化阶段有5种情况下,必须对类进行初始化:

    • 当遇到new、getstatic、putstatic、invokestatic关键字时
    • 使用反射调用,如果类没初始化,则需要触发其初始化
    • 初始化一个类如果父类还未被初始化,则先触发该父类的初始化
    • 程序启动时执行主类(包含Main方法的那个类)会先被初始化
    • MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类
    • jdk1.8以后,在接口中声明为default的方法,如果有这个接口的实现类发生了初始化,则该接口要在其之前被初始化。

讲一下双亲委派模型?

类加载过程中,系统首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的loadClass()处理,当父类加载器无法处理时,才由自己处理。因此整个过程就是:

image.png

双亲委派模型的好处

避免类的重复加载,也保证了Java核心API不被篡改

对象的访问定位的方式

句柄:Java堆中划分出一块内存用来作为句柄池,reference中存储的就是对象的句柄地址,聚丙种包含了实例数据和类型数据各自的具体地址。

直接指针:Java堆对象的布局中考虑如何放置访问类型数据的相关信息,reference中存储的就是对象的地址。

使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。