JVM 常见问题
转载声明
- 面试官 | JVM 为什么使用元空间替换了永久代? - 知乎 (zhihu.com) (opens new window)
- 【JVM】JVM加载class文件的原理机制_FAIRY哦的博客-CSDN博客_jvm加载类文件原理机制 (opens new window)
- java对象的创建过程_你别过来的博客-CSDN博客_对象的创建过程 (opens new window)
- 对象和数组并不是都在堆上分配内存的。-HollisChuang's Blog (opens new window)
- Java系列:JVM中的OopMap(zz) - 拿走不谢 - 博客园 (cnblogs.com) (opens new window)
# 永久代和元空间
面试官 | JVM 为什么使用元空间替换了永久代? - 知乎 (zhihu.com) (opens new window)
JVM年轻代,老年代,永久代详解 - 经典鸡翅 - 博客园 (cnblogs.com) (opens new window)
方法区是一种规范,永久代和元空间都是对方法区的一种实现。
JDK1.8 以前,采用的是永久代。当时的堆和方法区在逻辑上是两个概念,但是物理地址是连续的。
JDK1.8 之后,取消了永久代,改用元空间。元空间不再和堆连续,而是 存在于本地内存 。这么做的好处是,只要本地内存足够,他就不会像永久代一样出现OOM:java.lang.OutOfMemoryError: PermGen space
# 什么时候触发 Full GC
- 老年代空间不足。老年代只有在新生代对象转入及创建大对象、大数组时才会出现不足的现象;当执行 Full GC 之后空间仍然得不到满足,则抛出
java.lang.OutOfMemoryError: Java heap space - 方法区空间不足。当系统中要加载、反射的类和调用的方法较多时,方法区可能会被占满;当执行 Full GC 之后空间仍然得不到满足,则抛出
java.lang.OutOfMemoryError: PermGen space - 对于采用 CMS 进行老年代 GC 的程序而言,尤其要注意 GC 日志中是否有 promotion failed 和 concurrent mode failure 两种状况,当这两种状况出现时可能会触发 Full GC。
- 统计得到的 Minor GC 晋升到旧生代的平均大小大于旧生代的剩余空间。空间担保机制
# JVM 加载 class 文件的原理机制
JVM 中类的装在是由类加载器和它的子类来实现的,Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。
由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。
当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备、解析)和初始化。
类的加载是把类的 class 文件中的数据读入到内存中,通常是创建一个字节数组读入 class 文件,然后产生于所加载类对应的 Class 对象。加载完成之后,Class 对象还不完整,所以此时的类还不可用。
当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)。
最后 JVM 对类进行初始化,包括:
如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类
如果类中存在初始化语句,就依次执行这些初始化语句
# 类加载器
类的加载是由加载器完成的,类加载器包括
- 根加载器,BootStap Class Loader
- 扩展加载器,Extension Class Loader
- 应用程序类加载器,Application Class Loader
# 类装载方式
- 隐式装载:程序在运行过程中当碰到通过 new 等方式生成类或者子类对象、使用类或者子类的静态域时,隐式调用类加载器加载对应的的类到 JVM 中。
- 显式装载:通过调用
Class.forName()或者ClassLoader.loadClass(className)等方法,显式加载需要的类。
# 类加载的动态性体现
一个应用程序总是由 n 多个类组成,Java 程序启动时,并不是一次把所有的类全部加载再运行,他总是把保证程序运行的基础类一次性加载到 JVM 中,其他类等到 JVM 用到的时候再加载,这样是为了节省内存的开销,因为 Java 最早就是为嵌入式系统而设计的,内存宝贵,而用到时再加载这也是 Java 动态性的一种体现。
# 对象创建的过程 ★
HotSpot 虚拟机中的对象 | lllllan (opens new window)
# 逃逸分析
通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。逃逸分析(Escape Analysis),是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
对象和数组并不是都在堆上分配内存的。-HollisChuang's Blog (opens new window)
什么是逃逸:
当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。
- 当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸
- 甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸
逃逸分析的好处
- 同步消除。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 栈上分配。可以降低垃圾收集器运行的频率
- 标量替换。把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有,一、减少内存使用,因为不用生成对象头。二、程序内存回收效率高,并且GC频率也会减少
# 保守式/准确式GC ▲
Java系列:JVM中的OopMap(zz) - 拿走不谢 - 博客园 (cnblogs.com) (opens new window)
调用栈里的引用类型数据是 GC 的根集合(root set)的重要组成部分;找出栈上的引用是 GC 的根枚举(root enumeration)中不可或缺的一环。
# 保守式 GC
因为栈中没有对某块数据记录类型,虚拟机甚至无法区分指针和非指针,
保守式 GC,会把所有看上去像是引用的的数据,都看作是引用
保守式 GC 的好处是相对来说实现简单些,而且可以方便的用在对 GC 没有特别支持的编程语言里提供自动内存管理功能。
保守式 GC 的缺点
- 会有部分对象本来应该已经死了,但有疑似指针指向它们,使它们逃过 GC 的收集。这对程序语义来说是安全的,因为所有应该活着的对象都会是活的;但对内存占用量来说就不是件好事,总会有一些已经不需要的数据还占用着 GC 堆空间。
- 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。
# 半保守式 GC
JVM 可以选择在栈上不记录类型信息,而在对象上记录类型信息。这样的话,扫描栈的时候仍然会跟上面说的过程一样,但扫描到 GC 堆内的对象时因为对象带有足够类型信息了,JVM 就能够判断出在该对象内什么位置的数据是引用类型了。这种是“半保守式GC”,也称为“根上保守(conservative with respect to the roots)”。
半保守式 GC 对 JNI 方法调用的支持会比较容易:管它是不是 JNI 方法调用,是栈都扫过去…完事了。不需要对引用做任何额外的处理。当然代价跟完全保守式一样,会有“疑似指针”的问题。
# 准确式 GC
就是说给定某个位置上的某块数据,要能知道它的准确类型是什么,这样才可以合理地解读数据的含义;GC 所关心的含义就是“这块数据是不是指针”。 要实现这样的 GC,JVM 就要能够判断出所有位置上的数据是不是指向 GC 堆里的引用,包括活动记录(栈 + 寄存器)里的数据。
# OopMap和安全点 ▲
# OopMap
在 HotSpot 中,对象的类型信息里有记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。
可以把 oopMap 简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap 就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在 JIT 编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个 oopMap,就是根据 safepoint 把一个方法的代码分成几段,每一段代码一个 oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的 oopMap 就会包含多条记录。
每个被JIT编译过后的方法也会在一些特定的位置记录下 OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样 GC 在扫描栈的时候就会查询这些 OopMap 就知道哪里是引用了。
特定的位置主要在:
- 循环的末尾
- 方法临返回前 / 调用方法的 call 指令后
- 可能抛异常的位置
# 安全点
这种位置被称为“安全点”(safepoint)。之所以要选择一些特定的位置来记录 OopMap,是因为如果对每条指令(的位置)都记录 OopMap 的话,这些记录就会比较大,那么空间开销会显得不值得。选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的。 因为这样,HotSpot 中 GC 不是在任意位置都可以进入,而只能在 safepoint 处进入。
# JVM组成部分
- Class Loader,类装载器。根据给定的全限定名来装在 class 文件到运行时数据区的方法区中。
- Execution engine,执行引擎。执行 class 的指令。
- Native Interface,本地接口。于 native lib 交互,式其他编程语言交互的接口。
- Runtime data area,运行时数据区域。即 JVM 内存。
首先通过编译器把 Java 源代码转换成字节码,Class loader(类装载)再把字节码加载到内存中,将其放在运行时数据区的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能
# 碰撞指针和空闲列表 ★
# TLAB 本地线程分配缓存
内存分配在并发情况下也并 不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:
- 对分配内存空间的动作进行同步处理——实际上虚拟机是采用 CAS配上失败重试 的方式保证更新操作的原子性
- 另外一种是把内存分配的动作按照线程划分在不同的空间之中进 行,即每个线程在 Java 堆中预先分配一小块内存,称为 本地线程分配缓冲 (Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定
# 垃圾回收器 ▲
- 如果你的堆大小不是很大,选择串行收集器一般是效率最高的。SerialGC
- 如果你的应用运行在单核的机器上,或者你的虚拟机核数只有单核,选怎并行收集器没有任何收益。SerialGC
- 如果你的应用是【吞吐量】优先的,并且堆较长时间的停顿没有什么特别的要求,选择并行收集器更好。ParallelGC
- 如果你的应用对响应时间要求较高,想要较少的停顿。选择 G1、ZGC、CMS,但是吞吐量会低一些。
# 双亲委派机制
JVM 并不是在启动时就把所有的 .class 文件都加载一遍,而是程序在运行过程中用到了这个类才去加载。
首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载。

为什么要有双亲委派机制
- 避免一个类的重复加载
- 保护框架需要加载的类不被应用程序覆盖