面试-JVM

1. JVM是什么

  • Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)
  • 优点:
    • 一次编写,到处运行
    • 自动内存管理,垃圾回收机制(对比C)

2. JVM组成

image-20240504164337076

  • 在运行数据区中,分为:
    • 元空间
    • 程序计数器
    • 虚拟机栈
    • 本地方法栈
  • 在运行数据区中,浅粉色是线程私有的,其他是线程共享

2.1 方法区/元空间

  • 主要存储类的信息、运行时常量池
  • 虚拟机启动的时候创建,关闭虚拟机时释放
  • 如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace

image-20240504165801894

  • 从JDK8之后,将方法区就从堆移至本地内存。
  • 默认情况,元空间大小只会受到本地内存限制。

2.1.1 常量池

  • 可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 可以使用命令javap -v xxx.class查看字节码结构(类的基本信息、常量池、方法定义)
1
2
3
4
5
6
7
8
public class HxdsMisApiApplication {

public static void main(String[] args) {
System.out.println("hello world");
SpringApplication.run(HxdsMisApiApplication.class, args);
}

}

经过javap之后,代码会被解释为机器指令

image-20240504170307447

  • 左边:字节码行号,当处于cpu切片的时候,程序计数器会记录当前运行的位置。

  • 右边:符号引用,指向常量池中的内容。

    image-20240504170643663

2.1.2 运行常量池

  • 当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址(内存地址)。

2.2 堆

  • 主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

image-20240504171001857

  • 年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到老年代区间。
  • 老年代主要保存生命周期长的对象,一般是一些老的对象

2.3 程序计数器

  • 线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。
  • 同样通过命令javap -v xxx.class打印堆栈大小,局部变量的数量和方法的参数。

2.4 虚拟机栈

  • 每个线程运行时所需要的内存,称为虚拟机栈,先进后出

  • 每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存

  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

  • 当栈帧弹出之后,栈内存会释放;如果有持有的引用,对应的堆内存会被回收

  • 栈内存并非越大越好,栈帧过大会导致线程数变少;默认为1024K

  • 如何判断方法内的局部变量是否线程安全?

    • 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的

      image-20240504171823999

      虽然方法内部创建一个对象,但是每个线程进入这个方法都会创建对应的栈帧,每个栈帧中有自己的对象。

    • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

      image-20240504171953061

      • m2方法有接收参数,如果在外部使用不同对象操作这个对象,会引起线程不安全

      image-20240504172058019

      • m3方法最后变量进行了返回,逃离了作用范围,也会引起线程安全问题。
    • 当进行递归调用,没有设置对应的break时,会造成栈溢出。如果栈帧过大,也会引起栈溢出(概率很低)

2.4.1 堆栈区别

  • 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。

  • 堆会GC垃圾回收,而栈不会。

  • 栈内存是线程私有的,而堆内存是线程共有的。

  • 两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。

    栈空间不足:java.lang.StackOverFlowError。

    堆空间不足:java.lang.OutOfMemoryError。

2.5 本地方法栈

  • 保存的是native方法的信息,当一个jvm创建的线程调用native方法后,jvm不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法;

3. 类加载器

  • JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
  • 从上到下:
    • 启动类加载器
    • 扩展类加载器
    • 应用类加载器
    • 自定义类加载器
  • image-20240504173349026

3.1 双亲委派

  • 加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类。
  • 即:在加载一个类的时候,首先会一层层向上请求加载,当上层无法加载的时候,才会让下层进行加载。
  • 使用双亲委派的优点:
    • 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
    • 为了安全,保证类库API不会被修改

3.2 类加载流程

  • 类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)

    image-20240504173826153

3.2.1 加载

  • 通过全类名,获取类的二进制数据流。

  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)

  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

  • image-20240504174116013

    • 现在有一个Person类,被类加载器加载之后进入运行时数据区。

    • 在方法区/元空间存储这个类的信息 (构造函数、方法、字段… )

    • 在堆中开辟一块空间存储Person.class的Class对象,同时会作为这个类的各种数据的访问入口。

    • 以后创建Person类的时候就会基于这个Class对象进行创建,创建出来的每个Person的对象头都会指向这个Class对象。但是Class对象中的信息存储在元空间,

3.2.2 验证

  • 验证类是否符合 JVM****规范,安全性检查
  • 验证的内容:
    • 文件格式是否错误、语法是否错误、字节码是否合规
    • 符号引用验证,即:Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法,检查它们是否存在;也就是验证常量池中的符号引用的内容是否存在

3.2.3 准备

  • 为类变量分配内存并设置类变量初始值
    • static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
    • static变量是final的基本类型,以及字符串常量,值已确定,赋值在准备阶段完成
    • static变量是final的引用类型,那么赋值也会在初始化阶段完成

3.2.4 解析

  • 把类中的符号引用转换为直接引用
  • 例如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。

3.2.5 初始化

  • 对类的静态变量,静态代码块执行初始化操作
  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

3.2.6 使用

  • JVM 开始从入口方法开始执行用户的程序代码
  • 调用静态类成员信息(比如:静态字段、静态方法)
  • 使用new关键字为其创建对象实例

3.2.7 卸载

  • 当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象。

4. 垃圾回收算法

4.1 堆中对象如何判定可以被回收

  • 如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。

4.1.1 引用计数法

  • 一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收

  • 但是这样存在一个问题:如果对象直接循环引用的话,对象的引用计数永远不会归零

    image-20240504180640921

    最后哪怕设置为了null,但是在堆中,两个对象互相引用。

4.1.2 可达性分析法

  • 现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾
  • Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看是否能够沿着 GC Root 对象 为起点的引用链找到该对象,找不到,表示可以回收

4.1.2.1 什么对象可以做为GC Root

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

4.2 标记清除法

  • 标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
    • 根据可达性分析算法得出的垃圾进行标记
    • 对这些标记为可回收的内容进行垃圾回收

image-20240504181232584

  • 优点:标记和清除速度较快
  • 缺点:碎片化较为严重,内存不连贯的

4.3 复制法

  • 在清除的时候,会申请另外同样大小的内存空间,把存活的对象进行整理并移动至新申请的内存空间中。

image-20240504181703730

  • 优点:
    • 在垃圾对象多的情况下,效率较高
    • 清理后,内存无碎片
  • 缺点:
    • 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低

4.4 标记整理法

  • 同标记清除法,只是最后会将所有的存活对象移动,使内存中尽可能多的连续空间

image-20240504181539469

4.4 分代收集法

  • 在java8时,堆被分为了两份:新生代和老年代【1:2】
  • 新生代划分为三个区域
    • 伊甸园区Eden,新生的对象都分配到这里
    • 幸存者区survivor(分成from和to)
    • Eden区,from区,to区【8:1:1】

image-20240504182148869

4.4.1 流程

  • 第一步:
  • 新创建的对象,都会先分配到eden区
  • 当伊甸园内存不足,标记伊甸园与 from的存活对象
  • 将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸园和 from 内存都得到释放
  • 因为使用了复制算法,所以清除之后form和to区交换,原来的from就是下一步的to,原来的to就是下一步的from

image-20240504182512875

  • 第二步:

    • 经过一段时间后伊甸园的内存又出现不足,标记eden区域以及from区存活的对象,将存活的对象复制到to区
    • 再次交换from和to区
  • 第三步:

    • 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会导致提前晋升)
  • MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)

  • Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有

  • FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免

5. 垃圾收集器

5.1 串行垃圾收集器

  • Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑

  • Serial 作用于新生代,采用复制算法

  • Serial Old 作用于老年代,采用标记-整理算法

  • 垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

5.2 并行垃圾收集器

  • Parallel New和Parallel Old是一个并行垃圾回收器,JDK8****默认使用此垃圾回收器
  • Parallel New作用于新生代,采用复制算法
  • Parallel Old作用于老年代,采用标记-整理算法
  • 垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

5.3 G1垃圾收集器

  • 应用于新生代和老年代,在JDK9之后默认使用G1
  • 划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段:新生代回收、并发标记、混合收集
  • 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC

总结:

初始标记(Initial Marking)

  • 标记从GC Roots直接可达的对象。
  • 这个阶段是短暂的,并且与年轻代的垃圾回收(Young GC)一起执行。

并发标记(Concurrent Marking)

  • 从GC Roots开始,递归地标记所有可达的对象。
  • 这个阶段是并发进行的,不会停止应用程序的执行。

最终标记(Final Marking)

  • 完成标记阶段,处理在并发标记阶段发生的引用变化。
  • 这个阶段可能会暂停应用程序的执行。

筛选回收(Live Data Counting and Evacuation)

  • 根据之前的标记结果,计算每个区域的存活对象数量。
  • 选择最需要回收的区域进行回收,并将存活的对象移动到新的区域(压缩内存,减少碎片)。
  • 这个阶段可以分为并行和串行两部分。

5.3.1 新生代回收

  • 初始时,所有区域都处于空闲状态

  • 创建了一些对象,随机挑出一些空闲区域作为伊甸园区存储这些对象

    image-20240504184611981

  • 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程

    image-20240504184559841

    image-20240504184628944

  • 随着时间流逝,伊甸园的内存又有不足

  • 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代

    image-20240504184723552

    image-20240504184735175

5.3.2 并发标记

  • 当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程(去老年代中找到存活对象进行标记 )

    image-20240504185119426

  • 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。

5.3.3 混合收集

  • 此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少就意味着能释放更多的内存)的区域(这也是 Gabage First 名称的由来)。

    image-20240504185851744

  • 混合收集阶段中,参与复制的有 eden、survivor、old

  • image-20240504185838224

  • 复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集

    image-20240504185905970

6. 四种引用

6.1 强引用

  • 通过GC ROOT通过可达性分析之后,在这个链上的所有对象都不会被回收
  • 最简单的就是手动new的对象

6.2 弱引用

  • 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收

    1
    2
    User user = new User();
    SoftReference softReference = new SoftReference(user);

6.3 弱引用

  • 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象

    1
    2
    User user = new User();
    WeakReference weakReference = new WeakReference(user);
  • ThreadLocal的entry的key是弱引用,但是其value是强引用,因此会造成内存泄漏

6.4 虚引用

  • 必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存

  • 当发生了垃圾回收的时候,会将虚引用对象加入到引用 队列中,Reference Handler 线程释放虚引用对象关联的外部资源(直接内存)

    1
    2
    3
    User user = new User();
    ReferenceQueue referenceQueue = new ReferenceQueue();
    PhantomReference phantomReference = new PhantomReference(user,queue);
  • 用的太少,了解即可


面试-JVM
https://baijianglai.cn/面试-JVM/594ac1719190/
作者
Lai Baijiang
发布于
2024年5月4日
许可协议