写点什么

亦直问 JVM?凡不凡啊?记住这篇就不怕

  • 2022 年 5 月 14 日
  • 本文字数:6203 字

    阅读完需:约 20 分钟


[](()6.1 JVM 的堆、栈、方法区分别指什么?



[](()6.1.1 堆(Heap 线程共享)

被所有线程共享的一块内存区域,在虚拟机启动的时候创建,用于存放对象实例。对可以按照可扩展来实现(通过-Xmx 和-Xms 来控制)当队中没有内存可分配给实例,也无法再扩展时,则抛出 OutOfMemoryError(OOM)异常。

[](()6.1.2 方法区(线程共享)

被所有方法线程共享的一块内存区域。用于存储已经被虚拟机加载的类信息,常量,静态变量等。这个区域的内存回收目标主要针对常量池的回收和堆类型的卸载。

[](()6.1.3 虚拟机栈(VM Stack 线程私有)

线程私有的。每个方法在执行的时候也会创建一个栈帧,存储了局部变量,操作数,动态链接,方法返回地址。每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。通常所说的栈,一般是指在虚拟机栈中的局部变量部分。局部变量所需内存在编译期间完成分配,如果线程请求的栈深度大于虚拟机所允许的深度,则 StackOverflowError。如果虚拟机栈可以动态扩展,扩展到无法申请足够的内存,则 OutOfMemoryError。

[](()6.1.4 本地方法栈(线程私有)

和虚拟机栈类似,主要为虚拟机使用到的 Native 方法服务。也会抛出 StackOverflowError 和 OutOfMemoryError。

[](()6.1.5 程序计数器(线程私有)

是当前线程锁执行字节码的行号指示器,每条线程都有一个独立的程序计数器,这类内存也称为“线程私有”的内存。正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果是 Native 方法,则为空。



总结:


1. 堆是线程共享的内存区域,栈是线程独享的内存区域。


2. 堆中主要存放对象实例,栈中主要存放各种基本数据类型、对象的引用。


堆内存是线程共享的不完全正确,具体原因查看这篇文章:https://mp.weixin.qq.com/s/Wws24Fhg1nH4dHvtcFYi2g


[](()6.2 描述一下类加载过程?




系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

[](()6.2.1 加载

类加载过程的第一步,主要完成下面 3 件事情:


  1. 通过全类名获取定义此类的二进制字节流

  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构

  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口


加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。


总结:通过类名获取二进制字节流,将静态存储结构转换为方法区运行时数据结构,内存中生成 Class 对象。

[](()6.2.2 验证


总结:验证 Class 文件格式规范,描述信息符合 Java 语言规范,程序语义符合规范,确保解析动作正确运行。

[](()6.2.3 准备

准备阶段是正式为类变量分配内存并设置 《一线大厂 Java 面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》无偿开源 威信搜索公众号【编程进阶路】 类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:


这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。


这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了 public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会复制)。特殊情况:比如给 value 变量加上了 fianl 关键字 public static final int value=111 ,那么准备阶段 value 的值就被复制为 111。

[](()6.2.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。


符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方发表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。


综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

[](()6.2.5 初始化

初始化是类加载的最后一步,也是真正执行类中定义的 Java 程序代码(字节码),初始化阶段是执行类构造器 ()方法的过程。


对于() 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。


对于初始化阶段,虚拟机严格规范了有且只有 5 种情况下,必须对类进行初始化:


  1. 当遇到 new 、 getstatic、putstatic 或 invokestatic 这 4 条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。

  2. 使用 java.lang.reflect 包的方法对类进行反射调用时 ,如果类没初始化,需要触发其初始化。

  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。

  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。

  5. 当使用 JDK1.7 的动态动态语言时,如果一个 MethodHandle 实例的最后解析结构为 REF_getStatic、REF_putStatic、REF_invokeStatic、的方法句柄,并且这个句柄没有初始化,则需要先触发器初始化。


[](()6.3 什么是双亲委派原则?(常问)




即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。



双亲委派模型避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。


即父类加载,不重复加载。


[](()6.4 对象在堆上的分配原则(分代回收机制)?(问 JVM 必问)




大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。




[](()6.5 描述一下 JVM 的垃圾回收机制?(常问)



[](()6.5.1 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。


这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

[](()6.5.2 标记清除

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。适用于对象存活比较多的时候适用、老年代。这种垃圾收集算法会带来几个明显的问题:


  1. 提前 GC

  2. 碎片空间

  3. 扫描了两次:标记存活对象;清除没有标记的对象


[](()6.5.3 标记复制算法

为了解决效率问题,“标记-复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。


适合场景:


  • 存活对象少 比较高效

  • 扫描了整个空间(标记存活对象并复制异动)

  • 适合年轻代


缺点:


  • 需要空闲空间

  • 需要复制移动对象


[](()6.5.4 标记整理算法

根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。



[](()6.6 有哪些常见的垃圾回收器?简单介绍一下



[](()6.6.1 CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。


CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。


从名字中的 Mark Sweep 这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:


  1. 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快;

  2. 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。

  3. 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。

  4. 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。



从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:


  1. 对 CPU 资源敏感

  2. 无法处理浮动垃圾

  3. 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生

[](()6.6.2 G1

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。


被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:


  1. 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。

  2. 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。

  3. 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。

  4. 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。



G1 收集器的运作大致分为以下几个步骤:


  1. 初始标记:stw 从 gc root 开始直接可达的对象

  2. 并发标记:gc root 对对象进行可达性分析 找出存活对象(可达性分析算法)

  3. 最终标记

  4. 筛选回收:根据用户期待的 gc 停顿时间指定回收计划


G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。


回收模式:


1. young gc


Young GC 主要是对 Eden 区进行 GC,它在 Eden 空间耗尽时会被触发。在这种情况下,Eden 空间的数据移动到 Survivor 空间中,如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到年老代空间。Survivor 区的数据移动到新的 Survivor 区中,也有部分数据晋升到老年代空间中。最终 Eden 空间的数据为空,GC 停止工作,应用线程继续执行。


2. mixed gc


Mix GC 不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。


它的 GC 步骤分 2 步:


  1. 全局并发标记(global concurrent marking)

  2. 拷贝存活对象(evacuation)

[](()6.6.3 CMS 和 G1 的区别

  1. G1 分区域 每个区域是有老年代概念的,但是 CMS 以整个区域为单位收集

  2. G1 回收后马上合并空闲内存,CMS 在 STW 的时候做

[](()6.6.4 内存区域设置

  1. XX:G1HeapRegionSize: 设置 G1 收集器一个 Region 的大小。取值范围从 1M 到 32M,且是 2 的指数。如果不设定,那么 G1 会根据 Heap 大小自动决定。

  2. 复制成活对象到一个区域,暂停所有线程


[](()6.7 什么是三色标记算法?




提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。


  • 黑色:根对象,或者该对象与它的子对象都被扫描

  • 灰色:对象本身被扫描,但还没扫描完该对象中的子对象

  • 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象


当 GC 开始扫描对象时,按照如下图步骤进行对象的扫描:


  1. 根对象被置为黑色,子对象被置为灰色。



  1. 继续由灰色遍历,将已扫描了子对象的对象置为黑色。



  1. 遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。



写入屏障(在进行并发标记时若有对象的增删):


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


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


[](()6.8 什么时候会触发 Full GC?



[](()6.8.1 System.gc()

System.gc()方法的调用。此方法的调用是建议 JVM 进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数。建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过-XX:+ DisableExplicitGC 来禁止 RMI(Java 远程方法调用)调用 System.gc()。

[](()6.8.2 老年代写满

老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行 Full GC 后空间仍然不足,则抛出错误:java.lang.OutOfMemoryError: Java heap space 。为避免以上两种状况引起的 FullGC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

[](()6.8.3 持久代空间不足

Permanet Generation 中存放的为一些 class 的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation 可能会被占满,在未配置为采用 CMS GC 的情况下会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出错误信息:java.lang.OutOfMemoryError: PermGen space 。为避免 Perm Gen 占满造成 Full GC 现象,可采用的方法为增大 Perm Gen 空间或转为使用 CMS GC。


[](()6.9 Minor GC、Major GC 和 Full GC 之间的区别




  1. Minor GC: 从年轻代空间(包括 Eden 和 Survivor 区域) 回收内存被称为 Minor GC。

  2. Major GC: Major GC 是清理老年代

  3. Full GC: Full GC 是清理整个堆空间—包括年轻代和老年代

用户头像

还未添加个人签名 2022.04.13 加入

还未添加个人简介

评论

发布
暂无评论
亦直问JVM?凡不凡啊?记住这篇就不怕_Java_爱好编程进阶_InfoQ写作社区