万字长文总结!吐血推荐的JVM面试题干货

1.什么是JVM

说一说什么是JVM

JVM,即 Java Virtual Machine,Java 虚拟机。它通过模拟一个计算机来达到一个计算机所具有的的计算功能。JVM 能够跨计算机体系结构来执行 Java 字节码,主要是由于 JVM 屏蔽了与各个计算机平台相关的软件或者硬件之间的差异,使得与平台相关的耦合统一由 JVM 提供者来实现。

2.JVM基本结构

说说JVM的基本结构是什么样子

jvm的基本结构主要分为3类:

  1. 类加载子系统

    JVM 启动时或者类运行时将需要的 class 加载到 JVM 中

  2. 运行时数据区

    将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者 PC 指针的记录器等

  3. 执行引擎

    执行引擎的任务是负责执行 class 文件中包含的字节码指令,相当于实际机器上的 CPU

3.运行时数据区

运行时数据区都由什么组成,具体到每个区存放什么

运行时数据区分为线程私有共享数据区两大类

线程私有:程序计数器、虚拟机栈和本地方法栈

共享数据区:Java堆,方法区(java8 元空间)

  • 程序计数器:记录当前线程指定指令的位置

  • 虚拟机栈:栈帧构成,每调用一个方法就压入一个栈帧,栈帧中包含操作数栈,局部变量表,动态链接和方法出口,其中局部变量表存放的类型是8种基本类型和一个引用类型

  • 本地方法栈:具有和虚拟机栈类似的特点和功能,它服务的对象是Native方法

  • 堆:存放所有的对象实例和数组

  • 方法区:虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

4 hotspot方法区的实现

hotspot 虚拟机的方法区存放了什么,1.7之前和1.8之后有什么区别

对于常用的hotspot虚拟机,方法区分为1.7和1.8版本:1.7及之前,方法区也称为永久代。存放类信息、常量、静态变量、即时编译器编译后的代码,1.8之后,使用元空间实现方法区,永久代被废弃,元空间存放在本地内存中。类信息存元空间中,常量池静态变量放到了Java

元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

为什么jdk1.8要把方法区从JVM里(永久代)移到直接内存(元空间)

原因一:
从数据流的角度,非直接内存:本地IO --> 直接内存 --> 非直接内存 --> 直接内存 --> 本地IO
而直接内存是:本地IO --> 直接内存 --> 本地IO
原因二:

​ 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到java.lang.OutOfMemoryError。

5 堆的结构

堆的分区是什么样子的,各自有什么特点

JVM线程共享区域可分为3个区域:新生代,老年代,和永久代。其中JVM堆分为新生代和老年代

新生代:

Eden空间、From Survivor空间、To Survivor空间

新对象分配内存的地方,发生minor gc会清除eden区和survival区的,把存活的对象移到另一个Survival区

(理解记忆 Eden:伊甸园 Survivor:存活)

老年代:

​ 对象创建在新生代,经过很多次回收还依然存活的,会进入老年代。

6 为何新生代要设置两个survivor区

为何新生代要在eden区外设置两个survivor区

survivor区域是为了方便实现复制算法:将原有的内存空间划分成两块,每次只使用其中一块,在垃圾回收的时候,将正在使用的内存中的存活对象复制到另一块内存区域中,然后清除正使用过的内存区域,交换两个区域的角色,完成垃圾回收。

复制算法,为什么要在新生代中使用复制算法:
因为新生代gc比较频繁、对象存活率低,用复制算法在回收时的效率会更高,也不会产生内存碎片。但复制算法的代价就是要将内存折半,为了不浪费过多的内存,就划分了两块相同大小的内存区域survivor from和survivor to。在每次gc后就会把存活对象给复制到另一个survivor上,然后清空Eden和刚使用过的survivor。

7 对象访问定位

对象访问定位有哪些方法

  • 句柄方式访问

    移动对象方便,GC时快

  • 直接指针访问(Hotspot所选)

    访问对象更快,省一次寻址时间

8 判断对象存活方式

如何判定对象是否存活

  • 引用计数法

    实例对象中存在计数器,如果某个地方引用了这个对象就+1,如果失效了就-1,当为 0 就会被回收。JVM没有使用它的原因是无法解决循环引用问题

  • 可达性分析(JVM所选)

    从GC Roots起始向下搜索,不可达对象被回收

    GC Roots对象:

    • 虚拟机栈中引用对象
    • 方法区中类静态属性引用对象
    • 方法区中常量引用对象
    • 本地方法栈中JNI(Native方法)引用对象

9 GC安全点

safepoint 是什么,如何选定安全点

HotSpot 通过GC Roots枚举判定待回收的对象。
找到对象哪些是GC Roots。有两种方法:
一种是遍历方法区和栈区查找(保守式 GC)。
一种是通过 OopMap 数据结构来记录 GC Roots 的位置(准确式 GC)。
保守式GC 的成本太高。因此在HotSpot中,使用OopMap的结构来标记对象引用的位置。OopMap 记录了栈中变量到堆上对象的引用关系,通过OopMap,HotSpot可以快速准确地定位到GC Roots,进行GC。

在执行 GC 操作时需要STW(stop the world,所有的工作线程必须停顿)

安全点意味着在这个点时,所有工作线程的状态是确定的,JVM 就可以安全地执行 GC 。

安全点太多,GC 过于频繁,增大运行时负荷;安全点太少,GC 等待时间太长。

一般会在如下几个位置选择安全点:

1、循环的末尾

2、方法临返回前

3、调用方法之后

4、抛异常的位置

为什么选定这些位置作为安全点:

避免程序长时间无法进入 Safe Point。比如 JVM 在做 GC 之前要等所有的应用线程进入安全点,如果有一个线程一直没有进入安全点,就会导致 GC 时 JVM 停顿时间延长

如何在 GC 发生时,所有线程都跑到最近的 Safe Point 上再停下来?

主要有两种方式:

抢断式中断:在 GC 发生时,首先中断所有线程,如果发现线程未执行到 Safe Point,就恢复线程让其运行到 Safe Point 上。

主动式中断:在 GC 发生时,不直接操作线程中断,而是简单地设置一个标志,让各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。

JVM 采取的就是主动式中断。轮询标志的地方和安全点是重合的。

10 GC

聊一聊GC机制

主要是三个问题:

  1. 什么是垃圾
  2. 在哪里回收垃圾
  3. 怎么回收垃圾

1.在内存上存在着无数对象,之前需要准确将这些对象标记出来,分为存活对象与垃圾对象。标记方法是可达性分析,前面已经说过。

2.发生在运行时数据区中。其中,随线程消亡,线程独享内存(栈,程序计数器和本地方栈)被回收。

3.目前主流 GC 算法主要分为三种:

  • 标记-清除算法

    先通过 GC Roots 标记出可达对象,再清理未标记对象

    缺点:内存碎片,效率不高

  • 复制算法

    用完一块内存,将对象复制到另外一块上

    缺点:空间换时间,牺牲一部分内存

  • 标记-整理算法

    通过 GC Roots 标记存活对象

    将存活对象往一端移动,按照内存地址一次排序,然后将末端边界之外内存直接清理。

    效率低,甚至不如标记清除

    图例:

    标记-清除

    复制算法:

​ 标记-整理

JVM中怎么使用的这些算法

从上面三种 GC 算法可以看到,并没有一种空间与时间效率都是比较完美的算法,所以采用分代方式使用这些算法

JVM根据对象存活周期划分新生代,老年代。新对象一般情况都会优先分配在新生代,新生代对象若存活时间大于一定阈值之后,将会移到至老年代。

新生代每次 GC 之后都可以回收大批量对象,所以比较适合复制算法。这里内存划分并没有按照 1:1 划分,默认将会按照 8:1:1 划分成 Eden 与两块 Survivor空间。每次将 Eden 与一块Survivor共同存储对象,GC时存活对象都复制到另一块空闲的Survivor区,然后这两块Survivor功能互换,以此类推。当Survivor空间并不能保存剩余存活对象,就将这些对象通过分配担保进制移动至老年代。

老年代中对象存活率将会特别高,且没有额外空间进行分配担保,所以需要使用标记-清除或标记-整理算法。

11 内存回收和分配策略

什么时候对象会进入老年代

  1. 大对象直接进入老年代

    • 哪些属于大对象呢?

    一般来说大对象指的是很长的字符串及数组,或者静态对象

    • 那么需要满足多大才是大对象呢?

    这个虚拟机提供了一个参数-XX:PretenureSizeThreshold=n,只需要大于这个参数所设置的值,就可以直接进入到老年代。

  2. 长期存活的对象将进去老年代

    对象熬过以此Minor GC就增长一岁,默认阈值15岁进入老年代

  3. 动态年龄判断

    Survivor空间相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代

什么是空间分配担保策略

Minor GC之前,先检查老年代最大可用连续空间是否大于新生代所有对象总空间,再检查空间是否大于历次晋升到老年代对象的平均大小,大于则Minor GC,大于则Full GC

12 GC收集器

介绍下JVM的垃圾收集器

上面的表示是年轻代的垃圾回收器:Serial、ParNew、Parallel Scavenge,下面表示是老年代的垃圾回收器:CMS、Parallel Old、Serial Old,以及不分老年代和年轻代的G1。连线表示可以相互配合使用。

停顿时间:GC中断执行的时间 吞吐量:执行时间(排除GC时间)占总时间的占比 1- 1/(1+n)

CMS和G1是重点,单独分析

收集器 串行、并行or并发 新生代/老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务

说一下 CMS 垃圾回收器

CMS(Concurrent Mark Sweep)收集器 目标:最短回收停顿时间,“标记-清除”实现,应用场景广泛,比较主流

  • 工作流程

    1. 初始标记:仅标记一下GC Roots能直接关联到的对象,速度很快,“Stop The World”

    2. 并发标记:从第一步标记的对象出发,并发标记所有可达对象。

    3. 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。“Stop The World”。

    4. 并发清除。

  • 优缺点:

    优:并发收集,停顿时间短

    缺:

    1. 标记清除算法的碎片问题

    2. concurrent mode failureCMS GC和业务线程都在执行,两个情况导致:(1)Minor GC完毕后需要将部分存活对象放入老年代,老年代还未来得及清理,空间不足;(2)做Minor GC的时候,新生代放不下,老年代也放不下

    3. promotion failed

      Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成。多数是由于老年代有足够的空闲空间,但是碎片较多,找不到一段连续区域存放这个对象导致的。

  • CMS 缺点解决办法

    1. 垃圾碎片的问题

      设置参数:-XX:CMSFullGCsBeforeCompaction=n 上一次CMS并发GC执行过后,还要再执行多少次full GC才会做压缩。默认0,即每次CMS GC顶不住了而要转入full GC的时候都会做压缩。

    2. concurrent mode failure问题

      设置参数-XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=60:是指设定CMS在对内存占用率达到60%的时候开始GC

      由于CMS GC过程中需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集。

    3. promotion failed问题

      让CMS在进行一定次数的Full GC时候进行一次标记整理算法,CMS提供了以下参数来控制:

      -XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5
      

      即CMS在进行5次Full GC之后进行一次标记整理算法,从而可以控制老年带的碎片在一定的数量以内

    总结一句话:使用标记整理清除碎片和提早进行CMS操作。

介绍一下G1 收集器

传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(JDK 8 元空间Metaspace),这种特点是各代的逻辑存储地址是连续的。而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。

有一些Region标明了H,代表Humongous,表示这些Region存储的是巨大对象(humongous object,H-obj),即>=region一半存储的对象。

H-obj特征:

  • H-obj直接分配到了老年代,防止了反复拷贝移动
  • H-obj在global concurrent marking阶段的cleanup 和 full GC阶段回收
  • 分配H-obj之前先检查是否超过Java堆占用率阈值, 如果超过的话就启动并发标记,为的是提早回收从而防止 Evacuation Failures 和 Full GC

为了减少连续H-objs分配对GC的影响,需要把大对象变为普通的对象,建议增大Region size。

  • GC过程

    G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。

    • Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
    • Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

    详细过程参考

G1比CMS好在哪儿

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
  • G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。
  • G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的

13 类加载过程

描述一下 JVM 加载 Class 文件的原理机制

Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,功能是把class文件从硬盘读取到内存中。除了反射等显式加载类以外,几乎不需要关心类的加载,这些都是隐式装载的

Java类的加载是动态的,保证程序运行的基础类(像是基类)完全加载到jvm中,其他类则在需要的时候才加载。节省内存开销。

说一下JVM类加载的过程

类加载过程:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

整个过程:通过全限定名来加载生成class对象到内存中,然后进行验证这个class文件,包括文件格式校验、元数据验证,字节码校验等。准备是对这个对象分配内存。解析是将符号引用转化为直接引用(指针引用),初始化就是开始执行构造器的代码

JVM中有哪些类加载器

JVM 预定义的类加载器

JVM 中内置了三个重要的 ClassLoader,除BootstrapClassLoader 外,其它均由 Java 实现且全部继承自java.lang.ClassLoader

  • 启动类加载器 BootstrapClassLoader
    负责加载系统类,加载 %JAVA_HOME%/lib目录下的jar包和类
  • 扩展类加载器 ExtensionClassLoader
    主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
  • 应用程序类加载器 AppClassLoader
    面向用户的加载器,负责加载当前应用classpath下的所有jar包和类

除此之外,还可以用户自定义类加载器

说一说双亲委派模型

在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。首先它会把这个类请求委派给父类加载器去完成,一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。(这里的双亲其实就指的是父类,没有mother。父类也不是指继承关系,只是调用逻辑是这样)当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

双亲委派模型不是一种强制性约束,是一种JAVA设计者推荐使用类加载器的方式。

双亲委派模型有什么好处

(1)安全性,避免用户自己编写的类动态替换 Java的一些核心类,比如 String。

(2)避免了类的重复加载,因为 JVM中区分不同类,不仅仅是根据类名,相同的 class文件被不同的 ClassLoader加载就是不同的两个类。

有没有破坏双亲委派模型的方式

某些情况下,需要由子类加载器去加载class文件,这时就需要破坏双亲委派模型。避免双亲委托机制,可以自定义一个类加载器,然后重写 loadClass() 即可。

经典如Tomcat,t造了一堆自己的classloader,每个Tomcat的webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。目的:

  1. 各个webapp中的class和lib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况;而对于许多应用,需要有共享的lib以便不浪费资源(举个例子,如果webapp1和webapp2都用到了log4j,可以将log4j提到tomcat/lib中,表示所有应用共享此类库,试想如果log4j很大,并且20个应用都分别加载,那实在是没有必要的。)
  2. 使用单独的classloader去装载tomcat自身的类库,以免其他恶意或无意的破坏
  3. 热部署,定期检查是否需要热部署,如果需要,则将类装载器也重新装载,并且去重新装载其他相关类
posted @ 2020-06-09 23:57  南山饱虎  阅读(337)  评论(0编辑  收藏  举报