JVM 常见问题

lllllan ... 2022-3-16 About 13 min

# 永久代和元空间

面试官 | JVM 为什么使用元空间替换了永久代? - 知乎 (zhihu.com) (opens new window)

JVM年轻代,老年代,永久代详解 - 经典鸡翅 - 博客园 (cnblogs.com) (opens new window)

方法区是一种规范,永久代和元空间都是对方法区的一种实现。

JDK1.8 以前,采用的是永久代。当时的堆和方法区在逻辑上是两个概念,但是物理地址是连续的。

JDK1.8 之后,取消了永久代,改用元空间。元空间不再和堆连续,而是 存在于本地内存 。这么做的好处是,只要本地内存足够,他就不会像永久代一样出现OOM:java.lang.OutOfMemoryError: PermGen space

# 什么时候触发 Full GC

  1. 老年代空间不足。老年代只有在新生代对象转入及创建大对象、大数组时才会出现不足的现象;当执行 Full GC 之后空间仍然得不到满足,则抛出 java.lang.OutOfMemoryError: Java heap space
  2. 方法区空间不足。当系统中要加载、反射的类和调用的方法较多时,方法区可能会被占满;当执行 Full GC 之后空间仍然得不到满足,则抛出 java.lang.OutOfMemoryError: PermGen space
  3. 对于采用 CMS 进行老年代 GC 的程序而言,尤其要注意 GC 日志中是否有 promotion failed 和 concurrent mode failure 两种状况,当这两种状况出现时可能会触发 Full GC。
  4. 统计得到的 Minor GC 晋升到旧生代的平均大小大于旧生代的剩余空间。空间担保机制

# JVM 加载 class 文件的原理机制

JVM 中类的装在是由类加载器和它的子类来实现的,Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。

由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。

当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备、解析)和初始化。

  1. 类的加载是把类的 class 文件中的数据读入到内存中,通常是创建一个字节数组读入 class 文件,然后产生于所加载类对应的 Class 对象。加载完成之后,Class 对象还不完整,所以此时的类还不可用。

  2. 当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)。

  3. 最后 JVM 对类进行初始化,包括:

    1. 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类

    2. 如果类中存在初始化语句,就依次执行这些初始化语句

# 类加载器

类的加载是由加载器完成的,类加载器包括

  • 根加载器,BootStap Class Loader
  • 扩展加载器,Extension Class Loader
  • 应用程序类加载器,Application Class Loader

# 类装载方式

  1. 隐式装载:程序在运行过程中当碰到通过 new 等方式生成类或者子类对象、使用类或者子类的静态域时,隐式调用类加载器加载对应的的类到 JVM 中。
  2. 显式装载:通过调用 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 的缺点

  1. 会有部分对象本来应该已经死了,但有疑似指针指向它们,使它们逃过 GC 的收集。这对程序语义来说是安全的,因为所有应该活着的对象都会是活的;但对内存占用量来说就不是件好事,总会有一些已经不需要的数据还占用着 GC 堆空间。
  2. 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。

# 半保守式 GC

JVM 可以选择在栈上不记录类型信息,而在对象上记录类型信息。这样的话,扫描栈的时候仍然会跟上面说的过程一样,但扫描到 GC 堆内的对象时因为对象带有足够类型信息了,JVM 就能够判断出在该对象内什么位置的数据是引用类型了。这种是“半保守式GC”,也称为“根上保守(conservative with respect to the roots)”。

半保守式 GC 对 JNI 方法调用的支持会比较容易:管它是不是 JNI 方法调用,是栈都扫过去…完事了。不需要对引用做任何额外的处理。当然代价跟完全保守式一样,会有“疑似指针”的问题。

# 准确式 GC

就是说给定某个位置上的某块数据,要能知道它的准确类型是什么,这样才可以合理地解读数据的含义;GC 所关心的含义就是“这块数据是不是指针”。 要实现这样的 GC,JVM 就要能够判断出所有位置上的数据是不是指向 GC 堆里的引用,包括活动记录(栈 + 寄存器)里的数据。

# OopMap和安全点 ▲

oop和安全点

# OopMap

在 HotSpot 中,对象的类型信息里有记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。

可以把 oopMap 简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap 就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在 JIT 编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个 oopMap,就是根据 safepoint 把一个方法的代码分成几段,每一段代码一个 oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的 oopMap 就会包含多条记录。

每个被JIT编译过后的方法也会在一些特定的位置记录下 OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样 GC 在扫描栈的时候就会查询这些 OopMap 就知道哪里是引用了。

特定的位置主要在:

  1. 循环的末尾
  2. 方法临返回前 / 调用方法的 call 指令后
  3. 可能抛异常的位置

# 安全点

这种位置被称为“安全点”(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 本地线程分配缓存

分配内存 | TLAB

内存分配在并发情况下也并 不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:

  1. 对分配内存空间的动作进行同步处理——实际上虚拟机是采用 CAS配上失败重试 的方式保证更新操作的原子性
  2. 另外一种是把内存分配的动作按照线程划分在不同的空间之中进 行,即每个线程在 Java 堆中预先分配一小块内存,称为 本地线程分配缓冲 (Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定

# 垃圾回收器 ▲

垃圾回收器

  • 如果你的堆大小不是很大,选择串行收集器一般是效率最高的。SerialGC
  • 如果你的应用运行在单核的机器上,或者你的虚拟机核数只有单核,选怎并行收集器没有任何收益。SerialGC
  • 如果你的应用是【吞吐量】优先的,并且堆较长时间的停顿没有什么特别的要求,选择并行收集器更好。ParallelGC
  • 如果你的应用对响应时间要求较高,想要较少的停顿。选择 G1、ZGC、CMS,但是吞吐量会低一些。

# 双亲委派机制

JVM 并不是在启动时就把所有的 .class 文件都加载一遍,而是程序在运行过程中用到了这个类才去加载。

首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载

双亲委派模型

为什么要有双亲委派机制

  1. 避免一个类的重复加载
  2. 保护框架需要加载的类不被应用程序覆盖
Last update: 2022年4月27日 15:55
Contributors: lllllan