深入理解 Java JVM,包括垃圾收集器原理、垃圾回收算法原理、类加载机制等

JVM

基本八股

Java 中的垃圾回收算法你了解多少呢?(把图也大概描述一下)

我大概了解三种:分别是标记-清除算法,标记-整理算法,标记-复制

首先是标记-清除算法:它从根节点(栈、寄存器、全局变量等)开始找,找出不可回收的对象,然后将可回收的对象清除、会进行两遍扫描,效率偏低:第一遍扫描找到有用的对象,第二遍扫描清除无用的对象,因为它并没有整理内存,所以缺点就是会产生内存碎片,优点就是:实现简单,而且能够处理堆中的所有对象。

标记-清除算法中的内存分配

然后是标记-整理算法:它第一遍扫描,会从根节点开始找,找到有用的对象,然后第二遍扫描,将所有有用对象整理到内存的一端,最后清除无用对象。优点就是:不会产生碎片,方便内存分配,并且产生内存减半。缺点就是:会扫描两次,需要移动对象,效率偏低。

标记-整理算法图

最后是标记-复制算法(Copying):将内存一分为二,将有用的对象拷贝到未被使用的内存块中,然后清除正在使用的内存块中剩下的垃圾对象。优点就是:只扫描一次,适用于存活对象较少的情况,没有碎片。缺点就是:空间浪费,移动复制对象,需要调整对象引用,效率比较低。

标记-复制算法

Java 的类加载过程是怎么样的?(典中典)

首先将二进制流读入内存,生成一个 Class 对象,然后验证加载进来的二进制流是否符合一定格式,是否规范,是否符合当前 JVM 版本等等之类的验证再然后为静态变量赋初始值,然后将常量池的符号引用转化为直接引用,在内存中可以通过这个引用查找到目标,最后执行一些静态代码块,为静态变量赋值。

关于 Java 中的垃圾收集器你了解多少呢?

Java 收集器

新生代收集器

Serial 收集器:单线程收集器,适合小型应用和单处理器环境。主要通过触发 Stop-The-WorLd(STW) 操作,所有应用线程在 GC 时暂停,仍然是 HotSpot 虚拟机在客户端模式下默认的新生代收集器。

Snipaste_2024-05-02_21-04-13.jpg

ParNew 收集器:是 Serial 收集器的多线程版本,能够并行进行垃圾收集。主要与 CMS 收集器配合使用,通常会选择 ParNew 收集器作为新生代收集器。

Snipaste_2024-05-02_21-05-06.jpg

Parallel Scavenge 收集器(吞吐量优先): 适用于大规模运算密集型后台任务,适合对吞吐量要求较高的场景。并行处理新生代垃圾回收,适合大规模后台任务处理,注重吞吐量而非延迟。

吞吐量 = 运行用户代码时间 \ (运行用户代码时间 + 运行垃圾收集时间)
  • -XX:MaxGCPauseeMilis: 控制最大垃圾收集时间,假设需要回收的垃圾重量不变,那么降低垃圾收集的时间就会导致收集频率变高。
  • -XX:MaxGCTimeRatio:直接用于设置吞吐量大小,它是一个大于 0 小于 100 的整数。假设把它设置为 19,表示此时允许的最大垃圾收集时间占总时间的 %5(即 1+(1+19));默认值为 99,即允许最大 1% (1 / (1+99)) 的垃圾收集时间。
老年垃圾收集器
Serial Old 收集器

适合单线程环境和低内存使用场景,通常配合 Serial 收集器一起使用。是 Serial 收集器的老年代版本,使用标记-整理(Mark-Compact) 算法进行垃圾回收。

Snipaste_2024-05-02_21-06-38.jpg

Parallel Old 收集

适合大规模并行计算的场景,适用于高吞吐量要求的任务。Parallel Scavenge 收集器的老年代版本,使用多线程并行标记-整理算法。

Snipaste_2024-05-02_21-08-38.jpg

CMS (Concurrent Mark-Sweep) 收集器

适合对于响应时间有较高要求的应用。并发标记-清除收集器,追求低延迟,减少 GC 停顿时间,缺点是会产生内存碎片,并且在并发阶段可能会发生 Concurrent Mode Failure,导致 Full GC。流程如下:以下是收集流程:

  1. 初始标记(initial mark):在这个阶段,CMS 会进行一个快速的初始标记,标记所有根对象(如栈中的引用)直接可达的对象。此过程是 STW 的,但时间较短。
  2. 并发标记:初始标记后,CMS 进入并发标记阶段。在此阶段,垃圾收集器与应用线程并发运行,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。
  3. 并发预清理(Concurrent precleaning): 这个阶段也是和应用线程开发,就是想帮重新标记阶段先做点工作,扫描一下卡表脏的区域和新晋升到老年代的对象等,因为重新标记是 STW 的,所以先分担一点。
  4. 可中断的预清理阶段(AbortablePreclean): 这个和上一个阶段基本上一致,就是为了分担重新标记标记的工作,但是它可以被中断。
  5. 重新标记(remark):这个阶段是 STW 的,因为并发阶段引用关系会发生变化,所以要重新遍历一遍新生代对象、Gc Roots、卡表等,来修正标记。
  6. 并发清理:CMS 进行并发清除阶段,标记为不可达的对象会被清除。此过程与应用线程并发运行,旨在减少停顿时间。
  7. 并发重置(Concurrent reset): 这个阶段和应用线程并发,重置 cms 内部状态。

其优点在于耗时长的 并发标记 和 并发清除 阶段都不需要暂停用户线程,因此其停顿时间较短。缺点如下:

  • 涉及并发操作,因此对处理器资源比较敏感
  • 由于是基于 标记-清除 算法实现的,因此会产生大量空间碎片
  • 无法处理浮动垃圾(Floating Garbage): 由于并发清除时用户线程还是在继续,所以此时仍然会产生垃圾,这些垃圾就被称为浮动垃圾,只能等到下一次垃圾收集时再进行清理。

Snipaste_2024-05-02_21-10-28.jpg

G1 (Garbage First) 收集器

是一种面向服务器的垃圾收集器,适合大内存、多 CPU 服务器应用,尤其在延迟和响应时间敏感的场景中表现出色。主要用于取代 CMS 的低延迟垃圾收集器,能够提供可预测的停顿时间,通过分区来管理内存,并在垃圾收集时优先处理最有价值的区域,避免了 CMS 的内存碎片问题。

G1 虽然也遵循分代收集理论,但不再以固定大小和固定数量来划分分代区域,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据不同的需求来扮演新生代的 Eden 空间、Survivor 空间或者老年代空间,收集器会根据其扮演角色的不同而来用不同的收集策略。

zongjie-e0f5da26-6e46-4f9d-bfcc-0842cc7079e7.png

G1 收集器的运行大致可以分为以下四个步骤:

1。初始标记(Inital Marking): 标记 GC Roots 能直接关联到的对象,并且修改 TAMS(Top at Mark Start) 指针的值,让下一阶段用户线程并发运行时,能够正常的在 Reigin 中分配新对象。 G1 为每一个 Reigin 都设计了两个名为 TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围。

2。并发标记(Concurrent Marking): 从 GC Roots 能直接关联到的对象开始遍历整个对象图,遍历完成后,还需要处理 SATB 记录中变动的对象。SATB(snapshot-at-beginning,开始阶段快照) 能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比 CMS 重新标记阶段所使用的增量更新算法效率更高。

3)最终标记(Final Marking):对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量的 STAB 记录。虽然并发标记阶段会处理SATB 记录,但由于处理时用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来处理;

4)筛选回收 (Live Data Counting and Evacuation) :负责更新 Region 统计数据,按照各个Region 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个 Region 构成回收集。
然后将回收集中 Region 的存活对象复制到空的 Region 中,再清理掉整个旧的 Region。 此时因为涉及到存活对象的移动,所以需要暂停用户线程,并由多个收集线程并行执行。

Snipaste_2024-05-02_21-14-27.jpg

G1 分为 young GC 和 mixed gc,young gc 会选取所有年轻代的 region 进行收集,midex gc 会选取所有年轻代的 region 和一些收集收益高的老年代 region 进行收集。

所以年轻代的 region 都在收集范围内,因此不需要额外记录年轻代到老年代的跨代引用

ZGC (Z Garbage Collector) 收集器

适用于需要管理大堆内存且对低延迟要求极高的应用。优点就是:低停顿、高吞吐量的垃圾收集器,停顿时间一般不会超过 10 ms。

它们之间的关系如下(连线代表可是搭配使用)

image.png

JVM 垃圾回收调优思路

吞吐量调优:主要关注降低垃圾回收器回收的总时间,通过 Parallel Scavenge 和 Parallel Old 提高 CPU 使用效率。

延迟调优:关注最大停顿时间,通过 CMS、G1、ZGG 等收集器降低 STW 停顿时间。

堆大小调优:通过合理的堆内存分配和分代比例调优,避免频繁的 Minor GC 和 Full GC。

垃圾回收调优的主要目标是什么

第一个是:减少应用程序的停顿时间,确保在垃圾回收过程中尽量保持应用的响应能力。第二个是:提高应用的吞吐量,即在单位时间内完成更多的业务处理,通过合理的 GC 策略和配置,减少 GC 的频率和时间。

方案一:每次 GC 停顿 100 ms,每秒停顿 5 次。

方案二:每次 GC 停顿 200ms,每秒停顿 2 次。

Java G1 相对于 CMS 有哪些进步的地方

内存管理机制

G1 将整个堆划分为多个大小相等的 Region(默认最多 2048个),物理上不再严格区分新生代和老年代,而是根据回收价值动态调整各区域的回收,避免了 CMS 因固定分代导致的内存碎片问题,提升了内存利用率。

从回收算法来看
  • CMS 采用标记-清除算法,容易产生内存碎片,可能导致 Full GC 提前触发。
  • G1 基于标记-整理算法,回收时通过 Region 间的对象复制实现空间整合,几乎不产生碎片。
停顿时间控制来看
  • G1 允许用户通过参数 (如 -XX:MaxGCPauseMillis) 设定预期的最大停顿时间,并动态选择回收价值高的 Region 优先处理,从而避免 CMS 在极端场景下的不可控停顿(如 内存碎片引发的长时间 Full GC)。
从大对象优化来看

CMS 中超过 Survivor 区大小的对象会直接进入老年代,加剧碎片问题;而 G1 通过跨 Region 存储(对象操过单个 Region 50% 时分散存放),避免大对象对分代模型的破坏。

除此之外 CMS 仅负责老年代,需配合 ParNew 等新生代收集器,而 G1可独立管理整个堆,减少协调开销,更适合大内存和多核环境。

Java 的 CMS 垃圾回收器和 G1 垃圾回收器器在记忆集的维护上有什么不同

G1 垃圾回收器

G1 的记忆集(Remembered Set),其粒度可以细化到堆的各个区域,记忆集用于跟踪一个 Region 中的对象引用了其他 Region 的对象。G1 采用多层次的记忆集维护机制,将老年代对新生代的引用、其他 Region 之间的引用关系都记录在记忆集中,每个 Region 都有自己的记忆集,维护成本相对较高,但是有助于 G1 进行精准的增量式回收。G1 的记忆集在某些情况下会比 CMS 的卡表更加精细和准确,可以根据需要选择扫描的具体区域,而 CMS 的卡表往往只能标记大范围的区域。

points-out 和 points-into

cms 的记忆集的实现是卡表 即 card table。

通常实现的记忆集是 points-out 的,我们知道记忆集是用来记录非收集区域指向收集区域的跨代引用,在 cms 中只有老年代指向年轻代的卡表,用于年轻代 GC。

G1 是基于 region 的,所以在 points-out 的卡表上还加了个 points-into 的结构,因为一个 region 需要知道有哪些别的 region 有指向自己的指针,然后还需要知道这些指针在哪些 card 中

其实 G1 的记忆集就是这个 hash table,key 就是别的 region 的起始地址,然后 value 是一个集合,里面存储这 card table 的 index。

G1 回收集

像每次引用字段的赋值都需要维护记忆集开销很大,所以 G1 的实现利用了 logging write barrier。

了解过 Java 的 ZGC(Z Garbage Collector) 吗?

ZGC(Z Garbage Collector) 是 Java11 引入的低延迟垃圾回收器,旨在支持大内存(可以高达数 TB) 的应用程序,并保持短的 GC 停顿时间。

它的主要特点是:ZGC 的收集过程大部分是在应用线程运行的同时进行,使得 GC 的停顿时间通常在毫秒级,并且使用指针压缩技术,减少内存占用并且提高性能,而且不会产生内存碎片。ZGC 的不分代其实是它的缺点:因为分代比较难实现,不过以后应该会加上吧(JDK 21 实现分代了)。

它的工作机制是:ZGC 采用分代(包括年轻代和老年代)的方式管理内存,通过记忆集跟踪对象的引用关系,以便在 GC过程中快速识别活动对象。

ZGC 的回收流程解析

ZGC 的回收流程解析

ZGC 的步骤大致可以分为三大阶段分别是标记、转移、重定位。它从根开始标记所有存活对象,选择部分活跃对象转移到新的内存空间上,因为对象地址变了,所以之前指向老对象的指针都要换到新对象的地址上,这三个阶段都是并发的。

标记流程

简单的说就是从第一个 GC 开始经历了标记,然后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里去了。在第二个 GC 开始标记的时候发现这个对象是被转移了,然后发现引用还是老的,则进行重定位,即修改成新的引用,所以说重定向是糅合在下一步的标记阶段中。

初始标记

这个阶段其实大家应该很熟悉,CMS、G1 都有这个阶段,这个阶段是 STW 的,仅标记根直接可达的对象,压到标记栈中,当然还会进行 重置 TLAB、判断是否要清除软引用等待,这里不做具体的分析。

并发标记

根据初始标记的对象开始并发遍历对象图,统计每个 region 的存活对象数量。其中标记栈只有一个,但是并发标记的线程有多个,然后为了减少之间的竞争每个线程其实会分到不同的标记带来执行,你就理解为标记栈被分割为好几块,每个线程负责其中的一块进行遍历标记对象,就和 1.7 Hashmap 的 segment 一样。有的线程标记的快,有的线程标记的慢,先空闲下来的线程会去窃取别人的任务来执行,从而实现负载均衡,这也就是著名的 ForkJoinPool 的工作窃取机制。

再标记阶段

这一阶段是 STW 的,因为并发阶段应用线程还是在运行的,所以会修改对象的引用导致漏标的情况,因此需要个再标记阶段来标记漏标的那些对象。

如果这个阶段执行的时间过长,就会再次进入并发标记阶段,这个阶段还会做非强根并行标记,非强根指的是:系统字典、JVMTI、JFR、字符串表。有些非强根可以并发,有些不行,具体呢这里不做分析。

非强引用并发标记和引用并发处理

这个阶段是并发的,接着上一步非强根的遍历,然后引用就软引用、弱引用、虚引用的一些处理。

重置转移集

在读写屏障时提到的 forwarding Table 就是这个映射集,不过这个映射集在标记阶段已经用了,但新一轮的垃圾回收需要还是要用到这个映射集,因此在这个阶段对那些转移分区的地址映射集做一个复位的操作。

你可以理解为 key 就是对象转移前的地址, value 就是对象转移后的地址。

回收无效分区

在内存紧张的时候会释放物理内存,如果同时释放虚拟空间的话也不能释放分区,因为分区需要在新一轮标记完成之后才能释放,就会有无效的虚拟内存页面存在,在这个阶段进行回收。

选择待回收的分区

这个和 G1 一样,因为会有很多可以回收的分区,所以会筛选垃圾较多的分区,来作为这次回收的分区集合。

初始化代转移集合的转移表

这一步就是初始化待回收的分区的 forwardingTable

初始转移

这个阶段其实就是从根集合出发,如果对象在转移的分区集合中,则在新的分区分配对象空间,如果不在转移分区集合中,则将对象标记为 Remapped。

这个阶段是 STW,只转移根直接可达的对象。

并发转移

从上一步转移的对象开始遍历,G1 的转移对象整体都需要 STW,而 ZGC 做到了并发转移,所以延迟会低很多。

NUMA-aware

image.png

这个核心越加越多,渐渐的总线和北桥就成为瓶颈,于是就想了一个办法,把 CPU 和 内存集成一个单元上,这个就是非一致性内存访问(Non-Uniform Memory Access, NUMA)。

image.png

简单的说就是把内存分一分,每个 CPU 访问自己的本地的内存比较快,访问别人的远程内存就比较慢。也可以多个 CPU 享受一块内存或者多块。

image.png

但是因为内存被切分为本地内存和远程内存,当某个模块比较“热”的时候,就可能产生本地内存爆满,而远程内存都很空闲的情况。如果有些策略规定不能访问远程内存的时候,就会出现明明还有很多内存却产生 SWAP(将部分内存置换到硬盘中)的情况。即使允许访问远程内存那也比本地内存访问速率相差较大,这是使用 NUMA 需要考虑的问题。

ZGC 对 NUMA 的支持是小分区分配时会优先从本地内存分配,如果本地内存不足则从远程内存分配。对于中、大分区的话就交由操作系统决定。理由就是:绝大部分都是小分区对象,因此优先本地分配数据较快,而且也不易造成内存不平衡的情况,而中、大分区对象较大,如果都从本地分配可能会导致内存不平衡的情况。

Using colored pointers

染色指针就是从 64 位的指针中,拿几位来标识对象此时的情况,分别表示 Marked0、Marked1、Remapped、Finalizable。

染色指针

源码

1 KB = 2^10 bytes = 1024 bytes
1 MB = 2^20 bytes
1 GB = 2^30 bytes
1 TB = 2^40 bytes

0-41 这 42 位就是正常的地址,所以说 ZGC 最大支持 4TB(理论上可以 16TB) 的内存,因为就 42 位用来表示地址,也因此 ZGC 不支持 32 位指针,也不支持指针压缩。然后用 42-45 位来作为标志位,这是通过多重映射来做的,很简单就是多个虚拟机指向同一个物理地址,不过对象地址是 0001、0010、0100 对应的都是同一个物理地址即可。

为什么支持 4 TB,不是还有很多位没有用吗?

首先 X86_64 的地址总线只有 48 条,所以最多其实只能用 48 位,指令集是 64 位,但是硬件层面就支持 48 位,那现在对象地址就用了 42 位,染色指针用了 4 位,不是还有 2 位可以用吗?是的,理论上可以支持 16 TB,不过暂时认为 4 TB 就够了,所以暂做保留,仅此而已没啥特别的含义。

染色指针的标记位

染色指针的标记位

地址视图:指的就是此时地址指针的标记位,在垃圾回收开始前视图是 Remapped。

进入标记标记时,标记线程访问发行对象地址试图是 Remapped 这时候将指针标记为 M0,即将地址视图置为 M0,表示活跃对象,如果扫描到对象地址视图是 M0 则说明这个对象是标记开始之后新分配的或者已经标记过的对象,所以无需处理。

应用线程:如果创建新对象,则将其地址视图置为 M0,如果访问的对象地址视图是 Remapped 则将其置为 M0,并且递归标记其引用的对象,如果访问到的是 M0,则无需进行操作。

标记阶段结束后:ZGC 会使用一个对象活跃表来存储这些对象地址,此时地址活跃的对象地址视图是 M0。

并发转移阶段:地址视图被置为 Remapped,也就是说 GC 线程如果访问到对象,此时对象地址视图是 M0,并且存在或活跃表中,则将其转移,并将地址视图置为 Remapped,如果在活跃表中,但是地址视图已经是 Remapped 说明已经被转移了,不做处理。

应用线程:此时创建新对象,地址视图会被设为 Remapped,如果访问到的对象不在活跃表中,则不做处理。如果地址视图为 M0,则说明还未转移,则需要转移,并将其地址视图置为 Remapped。

简单的说就是 M1 标识本次垃圾回收中活跃的对象,而 M0 是上一次回收被标记的对象,但是没有被转移,在本次回收中也没有被标记活跃的对象,那么就会停留在 M0 这个地址视图。而下一次 GC 如果还是用 M0 来标识那混淆了这两种对象,所以搞了个 M1。

Using load barriers

在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障。写屏障是在对象引用赋值时候的 AOP,而读屏障是在读取引用时的 AOP。比如说 object a = 0bj.foo,这个过程就会触发读屏障,也正是用了读屏障,ZGC 可以并发转移对象,而 G1 用的是写屏障,所以转移对象时候只能 STW。

简单来说就是 GC 线程转移对象之后,应用线程读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移,如果是的话修正对象的引用,按照上面的例子,不仅 a 能得到最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了。其实就是转移的时候找地方记一下即 forwarding Table,然后读的时候触发引用的修正。

读屏障

这种也被称为“自愈”,不仅赋值的引用时最新的,自身引用也修正了。染色指针和读屏障是 ZGC 能实现并发转移的关键所在

为什么 Java 的垃圾收集器将堆分为老年代和新生代

主要是为了提高垃圾回收效率,依据对象的生命周期特点来进行优化。

  • 大多数对象存活时间短:大部分对象很快变成垃圾,不再被使用,这些短生命周期的对象会被分配在新生代。
  • 少部分对象存活时间长: 一些长期存活对象不会很快被回收,分配在新生代的对象经过多次垃圾回收仍存活的,将晋升到老年代。
不同的回收算法
  • 新生代的回收:新生代通常采用 复制算法,因为新生代中大部分对象生命周期短,大部分会在一次 GC 中被回收,复制算法只需要在内存中保留少量存活对象,并将它们复制到 Survivor 空间,回收剩余区域。这种算法效率很高,适合新生代对象频繁创建和回收的特点。
  • 老年代的回收:老年代对象存活时间长,回收频率低,使用标记-整理算法标记-清除算法,更加适合老年代对象的特性。
堆的分代机制

Java 堆内存根据对象生命周期被划分为三部分

  • 新生代(Young Generation):存放新创建的对象。
  • 老年代(Old Generation):存放存活时间较长的对象,通常是从新生代晋升过来的对象。
  • 永久代(Metaspace):(JDK 8以前为永久代,JDK 8以后为元空间):存放类的元数据类型,包括类的静态变量,方法等。
新生代结构
  • Eden 区:所有新创建的对象首先分配到 Eden 区。
  • Survivor 区:Eden 区中存活对象会被复制到 Survivor区(一般分为 S0 和 S1),经过多次 GC 存活的对象会逐渐晋升到老年代。

新生代中采用复制算法,每次垃圾回收时,将 Eden 和 Survivor 中的存活对象复制到另一个 Survivor 空间,效率高且避免内存碎片。

老年代的作用

老年代用于存放生命周期较长的对象,通常是从新生代晋升过来的。老年代使用的回收算法不同于新生代,常用标记-清除算法标记-整理算法,适合回收长生命周期的对象。

  • 标记-清除算法:遍历对象图,标记存活的对象,然后清除未标记的对象,但容易产生内存碎片。
  • 标记-整理算法:标记存活对象后,将存活对象整理到堆的一端,清理掉无效对象,避免了内存碎片问题。

JVM 新生代垃圾回收如何避免全堆扫描

卡表(Card Table) 机制

JVM 将老年代划分为小块区域(通常是 512 字节左右),称为 “卡”,每个卡对应一个字节,这些字节组成了所谓的卡表,当老年代对象持有对新生代对象的引用时,该引用对应的卡字节会被标记为 “脏卡”(Dirty Card),在进行新生代垃圾回收时,GC 不会扫描整个老年代,而是只会扫描卡表中被标记为脏卡的区域,这样可以有效避免全堆扫描,提升垃圾回收效率。

写屏障(Write Barrier)

写屏障是一个用于拦截对象写入引用的机制,在老年代对象引用新生代对象时,写屏障会立即将相应的卡表区域标记为脏卡。通过写屏障的监控,JVM 能够在垃圾回收过程中准确地定位哪些老年代区域包含对新生代对象的引用,避免不必要的扫描。

卡表的进一步理解

根据对象存活的特性进行了分代,提高了垃圾收集的效率,但是像在回收新生代的时候,有可能有老年代的对象引用了新生代对象,所以老年代也需要作为根,但是如果扫描整个老年代的话效率就又降低了。所以搞了个叫记忆集(Remembered Set) 的东西,来记录跨代之间的引用而避免扫描整体非收集区域

记忆集就是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,根据记录的精度分为:字长精度,每条记录精确到机器字长。对象精度,每条记录精度到对象。卡精度,每条记录精确到一块内存区域。

最常见的是用卡精度来实现记忆集,称之为卡表。 卡的意思就是将内存空间分成很多卡片,假设新生代对象 A 被老年代 D 引用了,那么就需要记录老年代 D 所在的那一块内存片有引用的新生代对象。

卡表

也就是说堆被卡切割了,假设卡的大小是 2,堆是 20,那么堆一共可以划分成 10 个卡。因为卡的范围大,如果此时 D 旁边在同一个卡内的对象也有引用新生代对象的话,那么就只需要一条记录。一般会用字节数组来实现卡表,卡的范围也是设为 2 的 N 此幂大小。

image.png

到时候回收新生代的时候,只需要扫描卡表,把标识为 1 的脏表所在内存块加入到 GC Roots 中扫描,这样就不需要扫描整个老年代了。

用了卡表的话占用内存比较少,但是相对字长、对象来说精度不准,需要扫描一片。

多卡表

这种多卡表表示的地址范围更大,这样可以先扫描范围大的表,发现中间一块脏了,然后再通过下标计算直接得到更具体的地址范围,在堆内存中比较大,且跨代引用较少,扫描效率更高

卡表一般是通过写屏障来维护的,写屏障其实就相当于一个 AOP,在对象引用字段赋值的时候加入更新卡表的代码。

引用字段赋值的时候判断下当前对象是老年代对象,所引用对象是新生代对象,于是就在老年代对象所对应的卡表位置置为1,表示脏,待会需要加入根扫描。

不过这种将老年代作为根来扫描会有浮动垃圾的情况,因为老年代的对象可能已经成为垃圾,所以拿垃圾来作为根扫描出来的新生代对象也很有可能是垃圾。

为什么 Java 新生代被划分为 S0、S1 和 Eden 区

当前用 Eden + s0 两块区,gc 的时候将存活的对象拷贝至 s1, 然后清理 Eden 和 s0,接着使用 Eden + s1 作为新的对象分配区域。后面 gc 后,把存活的对象拷贝至 s0,就这样往复使用两个 Survivor 区即可,这种划分手段就提升了内存的利用率,并且程序可以根据自身的特性调整 Eden 区和 Survivor 区的比例,默认 8:1:1。

image.png

如果单个 Survivor 放不下 GC 存活的对象怎么办

如果 Survivor 放不下存活的对象,那么超出的对象直接晋升到老年代。如果老年代剩余的空间也放不下这些存活对象怎么办呢?如果是 CMS 垃圾回收器,则会触发 CMS 回收,如果 CMS 回收不足以回收足够的空间,会触发 Full GC(Serial Old回收器)。如果是 G1 垃圾回收器则会触发 Mixed GC。

Java 中的 young GC、old GC、full GC 和 mixed GC 的区别是什么

young GC(Minor GC 或 YGC),即年轻代垃圾回收:

它仅针对新生代(Eden 和 s0/s1),当新生代内存(尤其是 Eden 区)被填满时触发,只回收新生代中的对象,老年代不受影响。特点是:回收频率较高,回收时间较短,因为新生代中的对象大多数是短命对象,容易被回收。

Old GC(Major GC 或 OGC), 老年代垃圾回收

它只针对老年代,当老年代空间不足时触发,通常是当新生代晋升到老年代的对象过多,或者老年代的存活对象数量达到一定阈值时。它只回收老年代的对象,新生代不受到影响。特定是执行时间比 Young GC 长,因为老年代中的对象存活时间更长,且数量较多。

Mixed GC(仅适用于 G1 GC 的混合垃圾回收)

它同时回收新生代和部分老年代区域,当 G1 垃圾回收器发现老年代区域的垃圾过多时触发,混合回收新生代和部分老年代区域,主要目的减少老年代中的垃圾积压。它结合了 YGC 的快速回收和 OGC 的深度回收,尽量减少停顿时间,适用于大内存应用。

Full GC,全堆垃圾回收

它对整个堆内存(包括新生代和老年代) 进行回收,当老年代空间不足且无法通过老年代垃圾回收释放足够空间,或其他情况导致系统内存压力较大时触发(如 System.gc() 调用),它回收所有代(新生代、老年代) 中的垃圾,并且可能会伴随着元空间的回收。特点是,回收时间最长,会触发整个 JVM 的停顿(Stop-The-World),对性能有较大的影响,通常不希望频繁发生。

如何对 Java 的垃圾回收器进行调优

它的核心思路就是尽可能地使对象在年轻代被回收,减少对象进入老年代。

具体调优还是得看场景根据 GC 日志具体分析,常见得需要关注得指标是 Young GC 和 Full GC 触发频率、原因、晋升的速率、老年代内存占用量等等。比如发现频率会产生 Full GC,分析日志之后发现没有内存泄漏,只是 Young GC 之后会有大量的对象进入老年代,然后最终触发 Full GC,所以就能得知是 Survivor 空间设置太小,导致对象过早进入老年代,因此调大 Survivor。或者是晋升年龄设置的太小,也有可能分析日志之后发现是内存泄漏,或者有第三方类库调用了 System.gc 等等。反之具体场景具体分析,核心思想就是尽量在新生代把对象给回收了

GC 调优这种问题肯定是具体场景具体分析,但是在面试中就不要讲太细,大方向说清楚就行,不需要涉及具体的垃圾收集器比如 CMS 调什么参数,G1 调什么参数之类的

什么条件会触发 Java 的 yong GC

在 Java 中,Young GC(Minor GC) 是针对新生代(Young Generation) 对象的垃圾回收。

Eden 区空间不足:新生代被划分为三个区域:Eden 区、S0(Survivor 0) 区和 S1(Survivor 1) 区,大部分新创建的对象会先分配到 Eden 区。当 Eden 区的对象被填满时,无法再为新的对象分配空间时,Young GC 会被触发,回收新生代中不再适用的对象。

Eden 区 +Survivor 区都装满:如果 Eden 区和 Survivor 区的空间都不足存放新分配的对象时,Young GC 也会被触发,清理空间并将幸存的对象转移到 Survivor 区或老年代。

部分垃圾回收器在 full gc 之前:有一些收集器的回收实现时在 full gc 前会让先执行以下 young gc,比如 Parallel Scavenge,不过有些参数可以调整让其不进行 young gc。

什么情况会触发 Java 的 Full GC

老年代空间不足

当老年代空间不足时(且无法通过老年代垃圾回收释放足够空间),会触发 Full GC 来回收老年代中的对象。

永久代或元空间空间不足

在 Java8 之前,如果永久代(PermGen) 空间不足,会触发 Full GC。Java 8 之后,永久代被移除,元空间(Metaspace) 取代了永久代。如果元空间(设置了阈值)内存不足,也可能触发 Full GC。

调用 System.gc() 、jamp -dump 等命令:

显式调用 System.gc() 方法,建议 JVM 执行一次 Full GC, JVM 并不保证立即执行,但是可能触发。

空间分配担保(Promotion Failed):

当新生代的 to 区放不下从 eden 和 form 拷贝过来对象或新生代的对象晋升到老年代时,如果老年代没有足够的空间来容纳这些对象,会发生 Promotion Failed,从而触发 Full GC。

新生代到老年代的晋升失败:

年轻代中的大对象或长期存活对象被晋升到老年代,如果实时老年代空间不足,也会引发 Full GC。

年轻代平均晋升大小计算:
  • 在要进行 yong gc 的时候,根据之前统计数据发现年轻代平均晋升大小比现在老年代剩余空间要大,那就会触发 full gc。
如何减少 Full GC 的触发
  • 调整内存大小:通过调整堆内存大小(-Xms-Xmx 参数) 来减少老年代空间不足的情况。
  • 增加新生代:增加新生代的大小,减少对对象晋升到老年代的频率。
  • 优化对象分配和生命周期:通过分析对象的生命周期,减少长时间存在的大对象,优化应用的内存适用模式。
  • 合理设置元空间大小:避免元空间过小导致频繁的 Full GC,可以使用 -XX:MetaspaceSize-XX:MaxMetaspacesSize 参数进行配置。

为什么 Java8 移除了永久代(PermGen) 并引入元空间(Metaspace)

主要是为了解决 PermGen 固定大小,容易导致内存溢出、GC 效率低的问题。元空间使用本地内存,具备更灵活的内存分配能力,提升了垃圾收集和内存管理的效率。

PermGen 的局限性
  • 固定大小

  • 类和方法的存储限制:永久代用于存放类的元数据(类信息、方法等),其容量受限,导致某些应用特别是在大量动态生成类或使用大量第三方库时,容易出现内存管理问题。

  • GC效率低:永久代内大部分存放的类的元数据都是被使用的,不是垃圾对象,因此无法被回收,回收效率低。

Metaspace 的改进
  • 性能提升: 元空间(在堆外) 减少了 GC 对类元数据的影响,避免了频繁回收 PermGen 时的停顿,改善了 JVM 的整体性能。
  • 自动调整大小:元空间可以根据应用的需要自动扩展大小,从而降低了出现 OutOfMemoryError 的风险,提升了内存使用的灵活性和效率。
  • 使用本地内存:元空间使用的是 本地内存(Native Memory),而不是 JVM 的堆内存,这样使得内存的分配更加灵活,避免了 PerGen 固定大小带来的局限性。
如何监控和调整元空间的大小

JVM 提供了 -XX:MetaspaceSize-XX:MaxMetaspaceSize 的参数来控制元空间的初始和最大大小。如果不设置,元空间会根据需要动态扩展,通常情况下不需要手动调整,但对于特定的大型应用,建议进行调优以避免内存问题。

官方对移除的动机解释

官方写了:因为 JRockit 没有永久代,而 JRockit 要和 Hotspot 融合,所以把 Hotspot 永久代给过去了。

永久代的话,永久代满了也会触发 full gc,触发了回收但是回收率又很低,所以很不划算。因此官方借着和 JRockit 合并就把永久代也干掉,用元空间代替,元空间放在堆外,至少没堆内存的限制了。

Java 的双亲委派机制了解过吗?(典中典)

它是 Java 类加载器中的工作机制之一,它的工作流程是自底向上委派,自顶向下加载,即一个类加载器收到类加载请求时,它首先不会自己加载,而是将请求委派给父类加载器,父类加载器再委派给它的父类加载器,直至顶层的启动类加载器。如果父类加载器无法加载,那么子类加载器才会尝试加载。通过双亲委派模型加载类可以避免类的重复加载,父类加载器若加载,子类加载器就不会加载并且可以保证核心类库的安全,防止用户自定义的类覆盖 JDK 核心类,这实现了类的隔离,不同类加载器加载的类相互隔离,打破双亲委派机制的场景有:SPI 机制、Tomcat 热部署。

选择自顶向下加载而不是自底向上是因为类加载器是组合关系,子类加载器记录了父类加载器,而父类加载器没有记录子类加载器。假如父类加载器无法加载,尝试交由子类加载器加载,若它有很多个子类加载器,那么父类加载器就不知道交由哪个子类加载器。

双亲委派机制-图片

自定义类加载器的简单示例

通过简单的重写 findClass 即可实现:

public class MyClassLoader extends ClassLoader {
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 自定义加载类的逻辑
        byte[] classData = loadClassData(name);
        
        if (classData == nulll) {
            throw new ClassNotFoundException();
        }
        
        return defineClass(name, classData, 0, classData.length);
    }
    
    
    private byte[] loadClassData(String name) {
        // 读取 class 文件的字节码
        
        return null; // 简单示例,此处省略实际实现
    }
}
那你认为双亲委派机制是否可以被打破呢?能举几个例子吗

首先想到的是,Java EE 容器中,每个 Web 应用都有自己的类加载器,应用类加载器优先加载应用的类库,而不是父类加载器。然后是 Java 的 SPI 机制,因为 SPI 允许开发者在类路径中自定义服务实现,通常通过线程上下文类加载器来加载 SPI 实现类,绕过了父类加载器。最后是 JDBC 了,JDBC 通过 SPI 机制和线程上下文类加载器,有意识地打破了严格的双亲委派模型,解决了父加载器需要访问子加载器资源的现实需求。"

什么是 Java 中的 logging write barrier

Java 中的 logging write barrier(日志写屏障) 是一种与垃圾回收(GC) 相关的机制,在应用程序运行期间,通过 write barrier 可以检测对象的引用关系何时发生变化,从而维护记忆集或卡表等数据解构。

当对象的引用关系发生变化时,write barrier 会维护卡表或记忆集(如 G1 中的 Remembered Set),以便垃圾收集器在增量回收时能够正确地处理对象之间地引用。写屏障会消耗应用程序的性能,因为写屏障是在引用赋值的时候执行的逻辑,而 logging 可以把写屏障要执行的一些逻辑搬运到后台线程执行,来减轻对应用程序的影响,然后提升性能。

write Barrier 的类型
  • Pre-Write-Barrier:在对象引用改变前后触发,用于记录引用变更前的状态。
  • Post-Write-Barrier: 在对象引用改变后触发,用于记录引用变更后的状态。
  • Logging Write Barrier: 属于 post-write-barrier 的一种变体,用于记录和追踪引用变更的日志。该日志可以在 GC 阶段异步处理这些引用变更。
原理

image-20251117125735117

Logging Write Barrier 在不同 GC 中的应用
  • G1 垃圾收集器: G1 使用 logging write barrier 来更新 Remembered Set,它会记录每次对象引用的变更,从而帮助垃圾收集器在 Mixed GC 阶段快速定位跨代引用。G1 的记忆集机制依赖 write barrier 来确保跨 Region 的引用能够被正确追踪,避免全堆扫描。
  • Shenandoah 和 ZGC: 这两种垃圾收集器也是基于并发的,write barrier 用于保持对象引用的正确性,确保垃圾收集器和应用线程能够同时运行。在这些垃圾收集器中,write barrier 是并发回收阶段不可或缺的机制。
写屏障与读屏障的区别

Write Barrier: 用于记录和追踪对象引用的写操作,主要用于处理对象之间的引用变更。

Read Barrier: 用于在读取对象引用时,确保对象引用的正确性,常见于并发垃圾回收器,用于处理对象的并发标记或压缩。

Java 中的 CMS 和 G1 垃圾收集器如何维护并发的正确性

漏标的两个充分必要条件是:将新对象插入已扫描完毕的对象中,即插入黑色对象到白色对象的引用。删除了灰色对象到白色对象的引用。

CMS 用了增量更新,打破了第一个条件,通过写屏障将插入的白色对象标记成灰色,加入到标记栈中,在 remark 阶段再扫描,防止漏标情况。

G1 用了 SATB,打破了第二个条件,会通过写屏障把旧的引用关系记下来,之后再把旧引用关系扫描一遍。

SATB 进一步理解

Snapshot at the beginning 是一种垃圾回收的技术,主要目的是在并发垃圾回收过程确保对象的引用关系是准确的,SATB 机制在开始时候会记录一个快照,快照的时候对象是存活的,后续就一直认为它是存活的(当然 gc 过程中新分配的对象也都认为是活的),之后这些对象的任何引用变化都会被跟踪,这样在标记阶段不会遗漏任何对象。

G1 中每个 region 会维持 TAMS(top at mark start) 指针,分别是 prevTAMS 和 nextTAMS 分别标记两次并发标记开始时候 Top 指针的位置。

Top 指针就是 region 中最新分配对象的位置,所以 nextTAMS 和 Top 之间区域的对象都是新分配的对象,都认为其实存活的即可。

SATB

增量更新进一步理解

它是指在并发处理过程中,垃圾收集器可以分阶段、逐步更新对象的状态或引用信息,而不是在某个时刻一次性处理所有对象。

cms 会在 remark 阶段需要重新扫描所有线程栈和整个年轻代,重新扫描就不会出现漏标,但如果年轻代的对象很多的话会比较耗时,但是这个阶段是 STW 的,所以 CMS 也提供了一个 CMSScavengeBeforeRemark 参数,来强制 remark 阶段之前来一次,减少堆中的对象数量,减低扫描的耗时。

扫盲八股

关于 Java 中的类加载器你了解多少呢?

类加载器(ClassLoader) 是 JVM 中用于动态加载类文件的组件。它将 .class 文件中的字节码加载到内存中,并将其转换为 Class 对象,以供 JVM 执行。它的作用是在运行时根据需要加载类,而不是在编译时加载所有类,并且可以通过不同的类加载器,可以隔离同名类,使得它们不会相互冲突。(动态加载类,隔离不同的类命名空间)

在 JDK8 的时候一共有四种类加载器:分别是启动类加载器、扩展类加载器、应用程序类加载器、和自定义类加载器。

首先是启动类加载器:它加载 Java 核心类库,无法被 Java 程序直接引用,在 JVM 启动时创建,负责加载最核心的类(例如 Object、System),其通常由操作系统实现,并不存在于 JVM 体系,负责加载的目录为 <JAVA_HOME>\lib;

然后是扩展类加载器(Extensions ClassLocader):负责加载一下扩展的系统类(如 XML、加密、压缩相关的功能类),从 JDK9 开始扩展类加载器更换为平台类加载器,负责加载的目录为 <JAVA_HOME>\lib\ext目录或被 java.ext.dirs 系统变量锁指定的路径的类库。

接着是应用类加载器(Application ClassLoader):负责加载用户类路径上的类库,可以在代码中直接使用,它的加载目录是:用户类路径(ClassPath);

最后是自定义类加载器(custom ClassLoader),它通过继承 ClassLoader 并重写 findClass 方法实现,一般如果没有自定义类加载器的情况下,应用类加载器就是默认的类加载器。

在 JDK9 之后,类加载器进行了一些修改,主要是因为 JDK9 引入了模块化,即 Jigsaw,原来的 rt.jar、tool.jar 等都被拆成了数十个 jmod 文件,已满足可扩展需求,无需保留 <JAVA_HOMT>\lib\ext,所以扩展类加载器也被重命名为平台类加载器(PlatformClassLoader),主要加载被 module-info.java中定义的类,且双亲委派的路径也做了一定的变化。

JDK9 以后双亲委派的路径

常见的类加载器应用场景:首先是 Web 容器,如 tomcat,每个 Web 应用都有自己的类加载器,确保各应用相互隔离,并且防止类冲突。然后是开发环境中使用的热部署技术(如 JRebel) 通过自定义类加载器支持类的动态重载。

什么是 三色标记算法呢

就是现代垃圾回收器中常用的一种 增量标记算法,它的标记过程是:一开始所有对象都是白色,然后从根对象(GC Roots) 开始,把根对象变为 灰色,然后递归扫描所有灰色对象,将其引用的对象变为灰色,标记为已访问,当灰色对象的所有引用都处理完毕时,灰色对象会变成黑色,最后呢经过扫描,所有存活的对象最总都会变成黑色,未被访问的白色对象即为垃圾,会被清除。它的优点就是分阶段处理不同对象,避免了一次性全量检查的开销,垃圾回收器可以和程序同时工作,而不需要完全停止程序运行,可并发执行和避免误回收。缺点就是会出现漏标和多标的情况,相对应的解决办法就是:利用写屏障在黑色引用白色对象时候,将白色对象置为灰色或者利用写屏障在灰色对象删除白色对象的引用时,将白色对象置为灰。它的应用场景主要是在 CMS 和 G! GC 都使用了三色标记算法,因为它能够在 并发环境 下执行垃圾回收器,不影响应用程序的正常运行。

image-20251031181752180

白色对象表示还没有被垃圾回收器访问到的对象,这些对象有可能是垃圾。黑色对象表示已经被访问到且其引用的所有对象也都已经标记完毕,这些对象不会被回收。灰色对象表示已经被访问到,但其引用的其他对象还没有被处理完。

过程图片

什么是 Java 中的直接内存(堆外内存)

它是通过 java.nio 包中的 ByteBuffer.allocateDirect() 方法分配的,它是由操作系统分配的内存区域,可以绕过 JVM 垃圾回收机制,直接与本地系统内存交互。

性能优化策略

第一呢:可以缓存 ByteBuffer.allocateDirect() 分配的缓存区,减少频繁的直接内存分配。第二个是:堆外内存不归 JVM 设置的堆大小限,但是可以通过设置 -XX:MaxDirectMemorySize 来设置直接内存的最大使用量,避免不必要的内存消耗。

直接内存使用示例

在 Java 中可以利用 Unsafe 类和 NIO 类库使用直接内存

public class DirectMemoryExample {
    
    public static void main(String[] args) {
        // 分配直接内存
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
        
        // 写入数组
        directBuffer.put("Hello, 面试呀 Direct Memory!".getBytes());
        
        // 切换为读模式
        directBuffer.flip();
        
        // 读取数据
        byte[] bytes = new byte[directBuffer.remainning()];
        directBuffer.get(bytes);
        
        // 打印结果
        String retrievedData = new String(bytes, StandardCharsets.UTF_8);
        System.out.println(retrievedData);
        
        // 手动释放直接内存
        ((sun.nio.ch.DirectBuffer)directBuffer).cleaner().clean();
    }
}

因为垃圾回收器无法直接管理堆外内存,所以 JVM 在创建 ByteBuffer 的时候,在堆内存储了这个对象的指针,然后注册了一个关联的 cleaner(清理器)。如果 JVM 检测到没有对象关联 ByteBuffer,说明这个堆外内存已经成为了垃圾,此时 ByteBuffer 会被回收,然后 cleaner 会被加入到引用队列中,之后会就会触发其 clean 接口,然后清理堆外内存。

Java 中如何判断对象是否是垃圾

首先是引用计数法(Reference Counting)

引用计数法

工作流程如下:每个对象维护一个引用计数器,引用计数器增加时,计数器加 1,减少时,计数器减 1。当引用计数器为 0 时,说明该对象不再被引用,可以被回收。优点是:实现简单,实用性好,它的缺点是:无法处理循环引用的问题,两个对象相互引用时,引用计数器永远不会为 0.

由此可以知晓引用计数需要占据额外的存储空间,如果本身的内存单元较小则计数器占用的空间就会变得明显。其次引用计数的内存释放等于把这个开销平摊到应用的日常运行中,因为在计数为 0 的那一刻,就是释放的内存的时刻,这对内存敏感的场景很适用。

可达性分析算法(Reachability Analysis)

image.png

它是 Java 中垃圾回收主要采用的算法。它通过一组称为 GC Roots 的对象触发,遍历所有可达的对象,凡是无法通过 GC Roots 到达的对象,均被视为垃圾。它能够较好的解决循环引用的问题,并且需要消耗一定的资源进行标记。

我们会在内存不足的时候进行 GC,而内存不足时也是对象最多的时候,所以需要扫描标记的时间也长。所以标记-清除等于把垃圾积累起来,然后再一次性清除,这样就会在垃圾回收时消耗大量资源,影响应用的正常运行。所以才会有分代式垃圾回收和仅先标记节点直达的对象再并发 tracing 的手段。

CPython 使用引用计数、它是如何解决循环引用的问题呢

像 List、dictionaries、instances 这类容器对象就有可能产生循环依赖的问题,因此 Python 在引用计数的基础之上又引入了标记-清除来做备份处理,但是它采取的是找不可达的对象,而不是可达的对象。它最大的开销是每个容器对象需要额外字段,并且它没有解决引用计数的循环引用问题,只是结合了非传统的标记-清除方案来兜底,算是曲线救国。

GC Roots 的来源
  • 线程栈中的引用:每个线程栈中的局部变量、参数等。
  • 类的静态变量:被类加载器加载后的类会存储在方法区,类的静态变量可以作为 GC Roots。
  • JNI 全局引用:通过 JNI 创建的全局引用可以作为 GC Roots。

JVM 是由哪些部分组成的

主要组成部分包括类加载系统(ClassLoader)、运行时数据区(Runtime Data Area)、执行引擎(Execution Engine) 以及 本地方法接口(Native Interface, JNI)

image.png

首先需要准备编译好的 Java 字节码文件,然后需要先通过一定方式(类加载器) 将 class 文件加载到内存中(运行时数据区),又因为字节码文件时 JVM 定义的一套指令集规范,底层操作系统无法直接执行,因此需要特定的命令解释器(执行引擎) 将字节码翻译成特定的操作系统指令集交给 CPU 去执行。这个过程中会需要调用到一些不同语言为 Java 提供的接口(例如驱动、地图制作等),这就用到了本地方法接口(Native Interface)

类加载器子系统

它负责将 Java 类从文件系统或网络中加载,并将它们转化为 JVM 能理解的数据结构。主要过程包括:找到并加载类文件到 JVM,将类文件的数据合并到 JVM 中,分为验证(Verification)、准备(Preparation) 和解析(Resolution) 三个阶段,最后执行类的静态初始化块和静态变量赋值。

执行引擎(Execution Engine)

负责将字节码转换为机器指令并执行,它逐行解释字节码并执行,适用于程序首次运行时,然后是及时编译器(JIT Compiler):将热点代码(频繁执行的代码)编译为机器码,提升执行效率。

本地方法接口(JNI)

本地方法接口允许 Java 程序调用非 Java 代码(如 C/C++),便于与操作系统或其他本地库交互。JNI 提供了跨语言调用能力,使 Java 程序可以访问操作系统级别的功能或高性能库。

编译执行与解释执行的区别是什么,JVM 使用哪种方式

解释执行是指将程序代码一次性编译成机器码并立即执行的过程,在 Java 中,这个过程主要由 Java 虚拟机(JVM) 中的解释器完成。

编译执行:是指将程序代码中一次性编译成机器码,并存储起来,然后直接执行这些机器码的过程,在 Java 中,这个过程通常由 JVM 中的即使编译器(JIT 编译器) 完成。JIT 编译器会监控程序的运行,当发现某些代码(如热点代码)被频繁执行时,会将这些代码编译成机器码,用来提高执行效率

在 Java 中,解释执行和编译执行是共同存在的。JVM 通常会在程序启动时采用解释执行的方式快速启动程序(提高应用程序的响应时间),并在程序运行时逐步将热点代码编译成机器码以提高执行效率。这种混合执行模式既保证了程序的快速启动,又能在程序运行过程中逐步提高执行效率。

JVM 的内存区域是如何划分的?

虚拟机运行时数据区分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。

内存区域

方法区(Method Area)

它存储类信息、常量、静态变量,并且属于线程共享区域,所有线程共享方法区内存。在 JDK8 之前,HotSpot 使用永久代(PermGen) 来实现方法区,JDK 8之后被元空间(Metaspace) 取代,元空间使用的是本地内存(Native Memory)。

堆(Heap)

用于存放所有线程共享的对象和数组,是垃圾回收的主要区域。

堆与栈的主要区别是什么

栈:它主要用于存储局部变量和方法的调用信息(如返回地址、参数等)。在方法执行期间,局部变量(包括引用变量,但不包括它们引用的对象) 被创建在栈上,并在方法结束时被销毁。它的生命周期非常短,比如一次方法的调用,在调用的时候就存入,执行完成就被弹出释放。栈的空间大小都是固定的,根据操作系统决定,如果是 64 位那么大小为 8 个字节。

堆(Heap):用于存储对象实例和数组。每当使用 new 关键字创建对象时,JVM 都会在堆上为该对象分配内存空间。它是需要通过 GC 进行回收的,所以堆空间的数据生命走起相对较长。它的空间大小并不确定,根据对象的大小进行一个划分。

示例

int a = 10,这个时候并不会分配堆内存,10 会直接存在栈空间。A a = new A(),这种 a 分配到栈空间是一个地址,指向堆中的实例化的 A. 如果 A 中定义了一个属性 B b = new B();这个 b 并不会存在栈空间,而是直接放在堆空间,存储的是实例化 B 的地址。

虚拟机栈(JVM Stack)

它每个线程创建一个栈,用来保存局部变量、操作数栈、动态链接、方法出口信息等。局部变量表中存储的是基本数据类型(如 int、float) 以及对象引用,它是线程私有的,生命周期与线程相同。

本地方法栈(Native Method Stack)

它为本地方法服务,使用 JNI(Java Native Interface) 调用的本地代码在次区域分配内存,它和虚拟机栈类似,也是线程私有的。

虚拟机栈和栈帧

每当一个方法被调用时,虚拟机会在栈中创建一个新的栈帧(Stack Fram),该栈帧用于存储方法的局部变量表、操作数栈、常量池引用等。方法执行完毕后,栈帧会被弹出,释放内存。

程序计数器的作用

它是唯一不会出现 OOM 错误的内存区域,因为它只需要记录当前执行的字节码行号,非常小。在多线程环境下,每个线程都有自己的程序计数器,以实现线程切换时的准确恢复。

直接内存(Direct Memory)

它由 NIO 库通过 ByteBuffer 直接分配的内存,不受堆内存限制,但是会受到本机内存的限制。

什么是 Java 中的 JIT(Just-In-Time)

它是一种在程序运行时将字节码转换为机器码的技术,也被称为即时编译。它在 Java 程序运行时,发现热点代码(频繁执行的代码段)时,就将这段代码编译成机器码,减少解释执行的开销,使得 Java 代码接近本地代码的性能。

热点代码(Hotspot Code)

JIT 编译器重点优化“热点代码”,即被多次调用或循环执行的代码,通过分析代码执行频率,JIT 能识别这些热点并进行优化编译。这里的优化编译采用了多种技术:如方法内联(Inlining)、逃逸分析(Escape Analysis)、循环展开(Loop Unrolling)等,使得编译后的机器码更加高效。

JIT 编译的类型

C1(Client Compiler): 用于快速启动的轻量级优化,它适用于客户端应用程序。

C2(Server Compiler): 用于长时间运行的重度优化,适用于服务器端应用程序。

JIT 的调优

JVM 提供了多种参数用于调优 JIT 编译器,如 -XX:+PrintCompilation 用于输出编译信息,-XX:TieredStopAtLevel 控制 JIT 编译级别等。

JIT 编译后的代码存在哪

JIT 编译后的机器码通常存放在 Code Cache(代码缓冲区,不在堆内) 中。JVM 提供参数用于调整 Code Cache 的大小和行为。

  • -XX:InitialCodeCacheSize: 初始大小。
  • -XX: ReservedCodeCacheSize: 最大大小。
  • -XX:+PrintCodeCache: 打印 Code Cache 信息。
Code Cache 的特点
固定大小

它的大小由 JVM 参数配置,通常有一个最大值,它的默认大小依赖于 JVM 版本和运行环境(例如,HotSpot JVM 在 Java8 中默认大小约为 48MB)。

分层解构

Java 8中引入了分层编译(Tiered Compilation),Code Cache 可能分为多个区域,分别存储不同级别的编译代码。

  • 非方法代码(Non-method Code): 存储运行时的 JVM 调优代码或模板代码。
  • 方法代码(Method Code):存储普通 JIT 编译的代码。
  • 轮廓代码(Profiled Code): 存储优化级别更高的代码。
// 当看到下面的日志时候,说明 Code Cache 大小不足了,此时 JIT 编译已经被禁用,应用性能会下降(尚未被编译的代码只能以解释方式执行),这时候需要手动调整 Code Cache 的大小。
Server VM warning: CodeCache is full. Compiler has been disabled.

什么是 Java 的 AOT(Ahead-Of-Time)

Java 的 AOT(Ahead-Of-Time,预编译) 是一种在程序运行之前,将 Java 字节码直接编译为本地机器码的技术,是能减少运行时编译的开销,且减少程序启动所需的编译时间,提高启动速度。

AOT 的工作原理

它是在构建阶段对 Java 字节码进行静态分析,并将其编译为目标平台的机器码。编译后的代码可以直接运行在目标硬件上,无需在运行时通过 JVM 进行解释或即时编译。

它的优点是:快速启动,更小的内存占用。缺点是:无法利用运行时的动态信息进行深度优化,可能在长时间运行的应用程序中性能低于 JIT 并且 AOT 编译出的机器码是针对特定平台的,缺乏平台的灵活性。

Java 的 AOT 工具
  • GraalVM: GraalVM 是一个多语言虚拟机,支持 Java 的 AOT 编译。它可以将 Java 应用程序编译成独立的本地可执行文件,这些文件不依赖于 JVM,即可直接在目标操作系统上运行。
  • jatoc: 在 Java9 中引入的 jaotc 工具将 Java 字节码编译为 AOT 的本地代码,不过,jaotc 的使用在生成环境中并不广泛。

Java 里的对象在虚拟机里面是怎么存储的

image.png

对齐填充(Padding):为了满足内存对齐要求(一般是 8 字节对齐),JVM 可能会在对象末尾添加填充字节。例如,一个对象大小为 12 字节,JVM 会增加 4 字节填充,使其达到 16 字节对齐。

实例数据:存储对象的实际数据,即类的字段(包括从父类继承的字段)

对象头(Header):包含对象的元信息和运行时数据。首先是:Mark Word: 用于存储运行时数据,例如对象的哈希码(HashCode)、GC 标记信息、锁状态标志等,它是一个多功能字段,会根据对象的状态动态变化。

类型指针(Class Pointer):指向对象对应的类的元数据,用于确定该对象的类型。

数组长度(只有数组才有)

64 位 MarkWord 在不同状态下的内存布局

它之所以设计的很复杂,主要是为了节省内存,让同一个内存区域在不同阶段有不同的用处。

MarkWord 设计

JVM 方法区是否会出现内存溢出

Java 7及之前 的方法区被实现为永久代(PermGen) 中,它是固定的内存区域,不能动态扩展,如果加载的类过多或常量池的数据过多,超出了永久代的限制,就会出现 OutOfMemoryError: PermGen space 错误。

Java 8及之后 方法区改用元空间(Metaspace) 来代替永久代。元空间不再使用堆内存,而是使用本地内存(Native Memory) 元空间大小默认没有限制(仅受物理内存限制),但可以通过参数设置最大大小。如果加载的类数量过多,或者大量动态生成类可能仍会导致元空间内存溢出,报错信息为 OutOfMemoryError: Metasapce

常用的 JVM 配置参数有了解过吗?

  • -Xmx: 最大堆内存大小
  • -Xms:初始化堆内存大小
  • -Xss: 设置每个线程的栈大小
  • -XX:MetaspaceSize:初始化元空间大小
  • -XX:MaxMetaspaceSize: 最大元空间大小
  • -XX:+HeapDumpOnOutOfMemoryError: 当发生 OutOfMemoryError 时,生成堆转储(heap dump)
  • -XX:+PrintGCDetails:打印详细的垃圾回收日志
  • -XX:+UseG1GC: 启动 G1 垃圾收集器
  • -XX:+UseConcMarkSweepGC: 启动 CMS 垃圾收集器
  • -XX:+UseZGC: 启动 ZGC(低延迟垃圾收集器)

什么是 Java 中的常量池

它是一块存储用于运行时的常量或符号的区域,它主要存在于两种地方。

  • 运行时常量池:在每个类或接口的 Class 文件中存储编译时生成的常量信息,并在类加载时进入 JVM 方法区(Java 8 之后是 metaspace)
  • 字符串常量池:用于存储字符串字面量,位于堆内存中的一块特殊区域。通过 String 类中的 intern() 方法可以将字符串加入到字符串常量池。

它的作用是用于减少重复对象的创建,节省内存并提高效率。在 Java 编译过程中,一些常用的常量值如字符串、基本类型等会存储在常量池中,避免重复创建相同的常量。

字符串常量池与堆内存
  • 直接使用字面量String s = "Hello"; 会将 Hello 存储在常量池中,如果常量池中已存在 “Hello”,则不会重复创建。
  • 使用 new 关键字String s = new String("Hello"); 不论常量池中是否已经存在 “Hello”,都会在堆中创建一个新的 String 对象。

Jdk1.6 与 jdk 1.7的区别

按照 Java 虚拟机定义而言,字符串常量池还是属于运行时常量池,只不过 HotSpot 的实现将其放在里堆中而已,逻辑上它还是属于运行时常量池。

什么是 Java 的 PLAB

它是 Java 垃圾回收器中的一种优化机制,主要用于 G1 垃圾收集器,目的是提高对象晋升(Promotion)到老年代时的效率。

在垃圾回收过程中,新生代中的某些对象由于生命周期较长,会被晋升到老年代,**为了减少线程竞争和提升晋升效率,G1 为每个 GC 线程分配一个局部缓冲区,称为 PLAB,**每个线程可以在其本地 PLA 中直接进行对象晋升操作,而不需要竞争全局老年代的内存空间,减少了锁的竞争,提高了多线程垃圾回收的效率。

进一步理解

什么是 PLAB呢

每个线程先从老年代 freelist(空闲内存链表) 申请一块空间(PLAB),然后单个线程在这一块空间中就可以通过指针加法(bump the pointer) 来分配内存,这样对 freelist 竞争也少了,分配空间也快了

PLAB 参数调优

G1PLABWastePercent: 这个参数定义了 PLAB 空间中可容忍的浪费百分比,默认值为 10%,如果浪费的空间超过该值,PLAB 的大小会调整。

PLABSize: PLAB 的大小在每次垃圾回收周期中是动态调整的,依赖于对象晋升的情况和空间使用率,如果发现晋升对象超出 PLAB 容量,JVM 会自动扩大 PLAB 缓冲区。

JVM 中的 TLAB(Thread-Local Allocation Buffer) 是什么

TLAB(Thread-Local Allocation Buffer) 是 JVM 中为每个线程分配的一小块堆内存,用于加数对象的分配内存操作,每个线程都有自己的 TLAB,大大加速了内存分配的同时避免了多线程竞争共享堆内存时的同步开销。

每个线程在执行过程中优先从自己的 TLAB 中分配内存,当 TLAB 中的内存耗尽时,线程会重新向 Eden 区申请一个新的 TLAB,或者直接从 Eden 区分配内存,当对象操过一定大小时(大对象),不会在 TLAB 中分配,而是直接在 Eden 区进行分配。

深入理解

生成对象需要向堆中的新生代申请内存空间,但是堆又是全局共享的,像新生代内存又是规整的,是通过一个指针来划分的。

新生代内存

内存是紧凑的,新对象创建指针就右移对象大小 size 即可,这叫指针加法(bump [up] the pointer)。如果多个线程都在分配对象,那么这个指针就会成为热点资源,需要互斥那分配的效率就低了,于是搞了一个 TLAB(Thread Local Allocation Buffer),为一个线程分配的内存申请区域,这个区域只允许这一个线程申请分配对象,允许所有线程访问这块内存区域

TLAB 的思想其实很简单,就是划一块区域给一个线程,这样每个线程只需要在自己的那亩地申请对象内存,不需要争抢热点指针,当这块内存用完了之后再去申请即可,分布式发号器也是运用了这种思想,每次不会一个一个号的取,而是用完之后再去申请一批。

TLAB

可以看到每个线程有自己的一块内存分配区域,短一点的箭头代表 TLAB 内部的分配指针,如果这块区域用完了再去申请即可,不过每次申请的大小不固定,会根据该线程启动请求分配的次数来进行调整。

TLAB 内存申请

在 HotSpot 中会生成一个填充对象来填满这一块,因为堆需要线性遍历,遍历的流程是通过对象头得知对象的大小,然后跳过这个大小就能找到下一个对象,当然也可以通过空闲链表等外部记录方式来实现遍历,还有 TLAB 只能分配小对象,大的对象还是需要在共享的 eden 区分配,总的来说 TLAB 是为了避免对象分配时的竞争而设计的。

配置与大内存分配
-XX:+UseTLAB: 启动 TLAB
-XX:-UseTLAB: 禁用 TLAB
-XX:TLABSize: 设置每个 TLAB 的初始大小

TLAB 并不是适用于所有对象的分配,通常只有小对象会分配到 TLAB 中,如果对象超过一定大小(通常为几 KB),则直接在 Eden 区中分配,这是因为大对象分配在小块 TLAB 中效率较低,直接在 Eden 区分配会更合适。

JVM 有那几种情况会产生 OOM(内存溢出)

堆内存溢出(Java Heap Space)

Java 堆用于存放对象实例,如果创建了过多对象或有内存泄漏导致对象无法被垃圾回收,堆内存就会被耗尽。如果有大量创建对象或集合类的场景,持续增加数据但未释放就会产生堆内存溢出。

java.lang.OutOfMemoryError: Java heap space,检查对象创建逻辑,确保及时释放无用对象,增加堆内存大小(-Xmx 参数)。

直接内存溢出(Direct Buffer Memory)

Java NIO 使用直接内存(Direct Memory)来加快 I/O 操作,虽然该内存不受 JVM 堆内存的限制,如果分配过多的直接内存,超过了设置的最大值,也会导致内存溢出。常见于:使用 NIO 操作 ByteBuffer 分配大量内存,或者 Netty 等框架中频繁使用内存场景。

java.lang.OutOfMemoryError: Direct buffer memory ,检查直接内存的分配和释放问题,增加直接内存大小限制(-XX:MaxDirectMemorySize),避免过多使用直接内存。

GC 执行耗时过长导致的 OOM(GC Overhead Limit Exceeded)

当 JVM 在垃圾回收上花费的时间过多且回收的内存不足以满足需要,JVM 会抛出 GC Overhead Limit Exceeded 错误,以避免长时间的垃圾回收循环,通常发生在堆内存接近耗尽但又无法完全释放的情况下。常见于对象频繁创建和销毁导致 GC 频繁触发,内存不足导致 GC 效率低下场景。

java.lang.OutOfMemoryError: GC overhead limit exceeded

增大堆内存,优化代码以减少短生命周期对象的创建,或调整垃圾回收策略。

线程数过多导致的内存溢出(Unable to create new native thread)

每个线程都需要栈空间和一定的操作系统资源,如果创建过多线程而超出系统的资源限制,可能无法再创建新的线程,导致 OOM。常见于创建大量线程或线程池大小过大。

java.lang.OutOfMemoryError: Unable to create new native thread,解决方法:减少线程数,合理设置线程池的大小,避免无限制地创建新线程。

栈内存溢出(StackOverflowError)

每个线程都有独立的栈空间,栈用于存储方法调用的信息(局部变量、方法参数、返回地址等)。如果方法调用层次过深或存在无限递归,栈空间耗尽就会导致栈溢出。常见于递归方法没有正确的退出条件、深层嵌套的方法调用场景。

java.lang.StackOverflowError,检查递归条件、优化递归算法或增加栈空间(-Xss 参数)

方法区或元空间溢出(Metaspace / PermGen space)

它常见于使用动态代理频繁生成类、大量反射调用或频繁热部署场景。

Java 7 及之前 java.lang.OutOfMemoryError: PermGen span Java8 及以后: java.lang.OutOfMemoryError: Metaspace。增加元空间大小(-XX:MaxMetaspaceSize),优化代码以减少类加载和反射的频率。

逃逸分析了解过吗?

它是 Java 编译器的优化技术,用于确定一个对象的作用范围,即分析对象是否会逃逸出当前方法或线程的作用范围,如果不会逃逸,JVM 可以实现栈上分配、同步消除、标量替换等优化,减少内存分配开销和同步开销。

两种类型 + 三种优化
  • 方法逃逸:一个对象在方法内部创建,并作为返回值或者通过参数传递给其他方法,则该对象会逃逸到方法之外。
  • 线程逃逸:如果对象被另一个线程访问,或者被保存为静态变量或共享变量,该对象会逃逸到该线程或者全局作用域。
  • 同步消除:如果对象只在线程内部使用且不会逃逸,JVM 会移除不必要的同步锁,提升性能。
  • 标量替换:如果对象没有逃逸且拆解,JVM 可能将该对象的字段替换为标量,避免内存分配。
  • 栈上分配:如果对象没有逃逸出当前方法,JVM 可以将该对象分配到栈上,而不是堆中。
逃逸分析配置

默认情况下,JVM 已经支持逃逸分析,但你可以通过一下 JVM 参数启动或禁用逃逸分析相关优化。

-XX:+DoEscapeAnalysis: 默认开启逃逸分析(默认开启)

-XX:EliminateLocks: 启动同步消除(基于逃逸分析)

-XX:EliminateAllocations: 启用标量替换(基于逃逸分析)

标量替换示例

对象的字段可以分解成多个局部变量,JVM 会进行标量替换,避免完整对象的创建和分配。

public class Point {
    int x;
    int y;
}

public void movePoint() {
	Point p = new Point();
    p.x = 10;
    p.y = 20;
}

JVM 垃圾回收时产生的 concurrent mode failure 的原因是什么

concurrent mode failure 是在 Java 虚拟机使用 CMS 垃圾收集器的一种失败现象。CMS 收集器是并发执行的,意味着它会与应用线程同时运行。然而,如果在 CMS 的并发回收阶段,还没有及时清理出足够的空间来满足新对象分配,就会出现 concurrent mode failure

处理措施

增加老年代内存:通过增加老年代的堆大小(通过 JVM 参数 -Xmx-XX:CMSInitiatingOccupancyFraction) 来减少 CMS 的触发频率,避免老年代内存不足。

碎片整理:CMS 不会自动整理碎片,但可以通过配置 -XX:+UseCMSCompactAtFullCollection 来 Full GC 后进行碎片整理,避免碎片化导致的内存不足问题。

增加年轻代大小:通过增加年轻代(Young Generation) 的内存大小,减少频繁晋升到老年代,进而减少老年代的内存压力。

**调优 CMS 的触发阈值:**可以调整 CMS 的回收触发点,参数 -XX:CMSInitiatingOccupancyFraction=<N> 控制了 CMS 在老年代占用达到 N% 时触发垃圾回收,适当提前触发垃圾回收可以减少发生 concurrent mode failure 的概率。

# 启用 CMS 垃圾收集器,并调整其启动参数
java -XX:+UserConcMarkSweepGC \
	 -XX:CMSInitiatingOccupancyFraction = 70 \
	 -XX:+UseCMSCompactAtFullCollection \
	 -Xmx4g -Xms4g YourApplication
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值