Java进阶 JVM从详解到实战与调优

回收方式对比

手动回收

忘记回收->内存泄漏

并发多次回收->异常错误

自动回收

简单、安全

JVM内存结构

区域/版本

<JDK8

>=JDK8

程序计数器

记录线程执行字节码的行数

记录线程执行字节码的行数

方法栈

(虚拟机栈)

基本数据、对象引用、returnAddress

基本数据、对象引用、returnAddress

Native方法栈

基本同虚拟机栈

基本同虚拟机栈

G1前是分代模型,分为Eden、Survivor、老年代。

G1 ZGC等是分区模型。

常量池在堆中。

元空间

方法区永久代PermSize,已加载的类型信息、即时编译器编译后的代码缓存等数据。

常量池在方法区。

元空间Metaspace,已加载的类型信息、即时编译器编译后的代码缓存等数据。

直接内存

大小默认等于Xmx

大小默认等于Xmx

可回收对象算法

引用计数法

引用计数法(Reference Counting)是一种垃圾收集算法的实现方式,用于自动管理内存中的对象。在引用计数法中,每个对象都有一个引用计数器,用于记录当前有多少引用指向该对象。当创建一个对象时,其引用计数器被初始化为1(假设对象在创建时就被引用)。每当有一个新的引用指向该对象时,引用计数器就增加1;每当一个引用被释放或指向其他对象时,引用计数器就减少1。

引用计数法的基本思想是:当一个对象的引用计数器为0时,表示没有任何引用指向该对象,因此该对象可以被安全地回收。这种算法的优点是实现简单,垃圾收集的开销相对较小。然而,它也存在一些缺点和限制:

  1. 循环引用问题:引用计数法无法解决循环引用的问题。如果两个或多个对象相互引用,即使它们在其他地方不再被使用,它们的引用计数器也不会为0,因此它们不会被回收,导致内存泄漏。
  2. 效率问题:每次对对象的引用进行增加或减少操作时,都需要更新引用计数器。这可能会引入一定的性能开销,尤其是在高并发环境下。
  3. 需要额外的内存空间:每个对象都需要一个额外的引用计数器来记录其引用数量,这会增加一定的内存开销。

根可达算法

根可达算法(Root Reachability Algorithm)是Java垃圾收集器用来确定哪些对象仍然存活,从而避免被错误回收的重要机制。这个算法是垃圾收集过程中标记阶段(Mark Phase)的核心部分。

在根可达算法中,垃圾收集器从一组被称为“GC Roots”的对象开始,这些对象被认为是活跃的,即它们是不会被垃圾收集器回收的。

这个算法是Java虚拟机中垃圾收集的重要组成部分,它确保了Java应用程序的稳定性和性能。

垃圾回收算法

标记-清除(Mark-Sweep)

这是最基本的垃圾回收算法。它分为两个阶段:标记阶段和清除阶段。在标记阶段,从根对象开始递归地访问所有可达对象,并将它们标记为存活。在清除阶段,遍历整个堆,回收未被标记的对象所占用的空间。

缺点:有内存碎片问题,清除后会产生大量不连续的内存,给大对象分配内存时无法找到合适内存

复制(Copying)

这种算法将可用内存划分为两个等大小的区域,每次只使用其中一个区域。当这个区域用完时,垃圾回收器会将存活的对象复制到另一个区域,然后清空当前区域的所有对象。这种方式简单高效,但代价是牺牲了一半的内存空间。

优点:内存连续,且清除效率高。

缺点:牺牲内存空间。

标记-整理(Mark-Compact)

标记阶段与标记-清除算法相同,但在清除阶段,它会将所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。这样可以避免内存碎片问题。

优点:内存连续,内存利用率高。

缺点:移动大量存活对象并更新所有引用这些对象的地方的效率低,Stop The World时间偏长。

垃圾回收器

3种垃圾回收算法,结合分代/分区算法,形成不同的垃圾回收器

左边6个是分代模型,新生代/老年代;右边的是分区模型。

分代模型

新生代一般使用 复制算法,老年代一般使用 标记-整理算法。

分代模型一般适用于小于4G的内存,分区模型用于大于4G的内存。

分区模型

G1GC的分区

Region是G1堆内存划分的小块独立区域,每个Region的大小是固定的,并且可以通过参数-XX:G1HeapRegionSize进行设置,范围通常在1MB到32MB之间,并且必须是2的幂。

这些Region在物理上并不要求是连续的,但它们是G1进行垃圾收集的最小单位。Region根据其角色可以分为几种类型:

  1. Eden Region:新生代的Eden空间,用于存放新创建的对象。

  2. Survivor Region:新生代的Survivor空间,用于存放经历了一次或多次垃圾收集后仍然存活的对象。

  3. Old Region:老年代空间,用于存放长时间存活的对象。

  4. Humongous Region:用于存放巨型对象(Humongous Object),即大小超过一个Region容量的对象。如果一个对象的大小超过了一个阈值(通常是Region大小的50%),它就会被认为是一个巨型对象。一个巨型对象可能会占用一个或多个连续的Region。

G1垃圾收集器使用这些Region来进行并行的垃圾收集,它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾以维护其回收价值的大小,并在后台维护一个优先列表。每次根据允许的收集时间,G1会优先回收价值最大的Region,这也是它名字中“Garbage-First”的由来。这种方式可以确保回收尽可能多的垃圾,同时减少收集过程中的停顿时间。

ZGC的分区

ZGC有类似于G1的Region概念。但是,ZGC的Region与G1有一些不同之处。

ZGC的Region不像G1那样是固定大小的,而是动态地决定Region的大小。ZGC将堆内存划分为三种类型的Region,分别是小型Region(Small Region)、中型Region(Medium Region)和大型Region(Large Region)。

  • 小型Region的容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region的容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型Region的容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,因此虽然名为“大型Region”,但其实际容量完全有可能小于中型Region,最小容量可低至4MB。值得注意的是,大型Region在ZGC的实现中是不会被重分配的,因为复制大对象的代价非常高。

与G1一样,ZGC也将Region作为清理、移动以及并行GC线程工作分配的单位。然而,与G1不同的是,ZGC可以动态地创建和销毁Region,以及动态地决定Region的大小。这种灵活性使得ZGC能够更好地适应应用程序的内存使用模式,并提供更高效的垃圾收集性能。

Serial和Serial Old

Serial垃圾回收器是JVM中最简单、最基本的垃圾回收器之一。它属于单线程垃圾回收器,使用复制算法

Serial垃圾回收器适用于单线程环境,因为它可以避免多线程之间的同步和协调开销。然而,由于它只能使用单个线程进行垃圾回收,因此在多核处理器上的性能可能不如并行垃圾回收器。此外,由于Stop-The-World的存在,应用程序在垃圾回收期间可能会出现明显的停顿。

Serial垃圾回收器通常用于客户端应用程序和简单的服务器端应用程序,特别是在内存和CPU资源有限的环境中。它也可以作为其他更高级垃圾回收器的备选方案,例如当更复杂的垃圾回收器无法满足应用程序的性能要求时。

Serial Old垃圾回收器是JVM中与Serial垃圾回收器相对应的老年代垃圾回收器。Serial Old是Serial垃圾回收器的扩展,用于处理老年代中的垃圾回收。

与Serial垃圾回收器一样,Serial Old也是单线程的,它使用单个线程来执行老年代的垃圾回收。在Serial Old中,垃圾回收的过程也是分为标记和整理两个阶段,并且会暂停所有的应用线程(Stop-The-World)。

Serial Old的主要应用场景是客户端应用程序和简单的服务器端应用程序,特别是当老年代内存使用较小,且不需要高并发性能时。它通常与Serial垃圾回收器一起使用,组成Serial/Serial Old垃圾回收组合,用于整个Java堆的内存管理。

需要注意的是,随着Java技术的不断发展,Serial Old垃圾回收器已经逐渐被更先进、更高效的垃圾回收器所替代。在现代JVM中,Serial Old通常不是默认的垃圾回收器选择,而是作为备选方案存在。

Paraller Scavenge和Paraller Old

Parallel Scavenge(也称为Parallel Collector)是JVM中提供的一个并行垃圾收集器。它属于新生代(Young Generation)垃圾收集器,专注于提升吞吐量(Throughput),即应用程序运行期间花费的总时间与用于垃圾收集的时间之比。

Parallel Scavenge垃圾收集器使用多线程并行地执行垃圾收集,以充分利用多核处理器的优势。在新生代中,它使用标记-清除算法来管理内存。

Parallel Scavenge的特点包括:

  1. 并行性:Parallel Scavenge使用多个线程来执行垃圾收集任务,以提高性能。这意味着在标记和清除阶段,多个线程会同时工作,减少了垃圾收集的总时间。

  2. 吞吐量优化:Parallel Scavenge的设计目标是最大化吞吐量。它通过在后台并行地执行垃圾收集,以减少应用程序的停顿时间。这使得Parallel Scavenge适合于对吞吐量有较高要求的应用程序。

  3. 适应性大小调整:Parallel Scavenge具有自适应调整的能力,可以根据应用程序的运行情况动态地调整垃圾收集的参数,如线程数、Eden区与Survivor区的大小等。这有助于优化垃圾收集的性能。

  4. 停顿时间控制:虽然Parallel Scavenge主要关注吞吐量,但它也提供了一些参数来控制最大停顿时间。这意味着可以在一定程度上限制垃圾收集过程中的暂停时间。

Parallel Old是JVM中的老年代(Old Generation)收集器,使用多线程和标记-整理算法。Parallel Old收集器是在JDK 1.6中开始提供的,用于替代老年代的Serial Old收集器。

Parallel Old收集器的设计目标是提高吞吐量,即在应用程序运行过程中,减少因垃圾收集而导致的停顿时间。它通过在后台并行地执行垃圾收集任务,充分利用多核处理器的优势,以提高垃圾收集的效率。

与Serial Old收集器相比,Parallel Old使用多线程进行垃圾收集,从而减少了单个线程在处理大量垃圾时的性能瓶颈。此外,Parallel Old还采用了“标记-整理”算法,该算法在标记阶段将可达对象标记为存活,然后在整理阶段将存活对象移动到连续的内存区域,以便更高效地利用内存空间。

当使用Parallel Old收集器时,JVM会自动选择Parallel Scavenge作为新生代的垃圾收集器,从而形成一个完整的垃圾收集组合。

需要注意的是,Parallel Old收集器适用于注重吞吐量且对CPU资源敏感的场合。在程序吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合可以提供较好的内存回收性能。然而,对于延迟敏感的应用程序,可能需要考虑其他更适合的垃圾收集器,如CMS(Concurrent Mark Sweep)或G1收集器。

ParNew和CMS

ParNew垃圾回收器是Serial收集器的多线程版本,是许多JVM运行在Server模式下的新生代默认垃圾收集器。ParNew中的"Par"是Parallel的缩写,表示并行,而"New"则表示它只能处理新生代。

ParNew收集器在新生代中仍然采用复制算法和STW(Stop-The-World)机制,但与Serial收集器的主要区别在于它采用并行垃圾收集方式。这意味着在多CPU场景下,ParNew可以充分利用多CPU、多核心的物理硬件优势,更快地完成垃圾回收,提升程序吞吐量。然而,在单CPU场景下,ParNew的多个线程会相互竞争资源,反而可能导致效率不如Serial收集器。

ParNew收集器与Serial收集器非常相似,除了采用并行回收方式外,两者之间的行为、控制参数、手机算法、Stop The World、对象分配规则、回收策略等都完全相同。因此,这两种收集器共用了相当多的代码。

在程序中,开发人员可以通过-XX:+UseParNewGC参数手动指定使用ParNew收集器执行内存回收任务。此外,-XX:ParallelGCThreads参数可以限制GC线程数量,JVM默认开启的线程数与CPU数量相同。

总的来说,ParNew收集器适用于多CPU、对吞吐量有较高要求且新生代垃圾回收频繁的场景。然而,在单CPU或延迟敏感的应用中,ParNew可能不是最佳选择。

CMS(Concurrent Mark Sweep)垃圾收集器是一种以获取最短回收停顿时间为目标的收集器,非常适合于响应时间优先的应用场景。CMS收集器是一种并发的、使用标记-清除算法的垃圾收集器,它主要用于老年代的垃圾收集。

CMS收集器的运作过程大致分为以下四个步骤:

  1. 初始标记(Initial Mark):此阶段标记GC Roots能直接关联到的对象,速度较快但需要暂停所有的应用线程(Stop-The-World),这个阶段是多线程的。

  2. 并发标记(Concurrent Mark):这里并发指的是与应用程序并发。此阶段在应用程序正常运行的同时,标记从GC Roots开始直接或者间接关联的所有对象,这个过程耗时较长但不需要暂停应用

  3. 重新标记(Remark):为了修正(三色标记漏标)并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段也需要暂停应用线程,但时间通常会比初始标记阶段更短。

  4. 并发清除(Concurrent Sweep):这里并发指的是与应用程序并发。此阶段清理掉标记阶段认为是垃圾的对象,这个阶段也可以与用户线程并发执行

标记直接关联的对象速度较快

CMS收集器的优点在于它的低停顿时间,非常适合对响应时间要求较高的应用。然而,它也有一些缺点:

  • 无法处理浮动垃圾:在并发标记和并发清除阶段,由于用户线程还在运行,可能会产生新的垃圾对象,这部分垃圾被称为浮动垃圾。CMS收集器会在下一次老年代GC时清理这部分垃圾。

  • 碎片问题:CMS使用“标记-清除”算法,会导致内存碎片化。

 三色标记

  1. 白色:表示对象尚未被垃圾收集器访问过。在可达性分析刚刚开始的阶段,所有的对象都是白色的。如果分析结束后对象仍然是白色,即代表不可达,这些对象会被当成垃圾对象。
  2. 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,是安全存活的。如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  3. 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过,可以理解为正在搜索的对象。

以上并发标记阶段与应用程序线程并发,误把不是垃圾的误标成垃圾。

为了修正误标进入重新标记阶段,使用Incremental Update(增量更新),由于使用多线程并发标记,A->C和A->B的检查会同时进行,会把A应该标为是灰色的覆盖为了黑色,导致A不会再去扫描,C还是误标为垃圾。

在使用CMS收集器时,可以通过-XX:+UseConcMarkSweepGC参数启用它。同时,可以通过-XX:CMSInitiatingOccupancyFraction参数设置老年代使用率达到多少时触发CMS GC,默认值为68%,也可以通过-XX:+UseCMSInitiatingOccupancyOnly参数来确保CMS GC只在老年代空间占用率达到设定值时才被触发。

由于CMS收集器的缺点和复杂性,它并不总是最佳选择。在现代的JVM版本中,G1垃圾收集器通常是一个更好的选择,因为它旨在提供可预测的停顿时间和更高的吞吐量。

G1(Garbage-First)

G1垃圾回收器的一个显著特点是它采用了优先级的垃圾回收策略。在每次GC循环中,G1会选择优先级最高的Region进行回收,优先回收垃圾对象较多的Region,从而实现垃圾回收的高效性和可控性。此外,G1还采用了并发和并行的垃圾回收方式,充分利用多核处理器的优势,提高垃圾回收的并行度,减少停顿时间。

G1的新生代使用复制算法,老年代使用标记-整理算法,回收阶段是筛选回收,优先回收最有价值的分区。

 G1的标记阶段算法与CMS算法很相似,但它使用SATB解决了CMS三色标记漏标问题。

SATB全称是snapshot-at-the-beginning,在并发标记阶段有删除对象引用时,生成GC线程堆栈快照,然后在最终标记阶段检查是否还有引用到该对象来确定是否存活。

在G1垃圾回收器中,写屏障的具体应用是在并发标记阶段。在这个阶段,应用线程可能会修改引用关系,导致一些存活对象未被标记。为了解决这个问题,G1引入了写屏障机制。每当存在引用更新的情况,G1会通过写屏障将修改之前的值写入一个log buffer。在最终标记阶段,G1会根据这个log buffer中的信息来修正SATB(Snapshot-At-The-Beginning)记录的误差,从而更准确地标记存活对象。

总结:因为对对象的引用删除做了快照记录,所以在最终标记阶段会拿这些记录去检查对应的对象是否还有其他引用,如果没有了就是垃圾

动态内存

不设置类似-Xmn等用于固定大小的参数时,G1会根据实际运行情况合理的动态调整年轻代、老年代的大小,以及年轻代中E和S的大小比例,总体目的是为了减少GC停顿时间和避免FullGC。

可以通过jhsdb jmap --heap --pid [pid] 来进行实时观察,前一秒后一秒都可能大小比例不同。

Mixed GC

在G1垃圾回收器中,老年代的回收是与年轻代一起进行的,即混合回收(Mixed GC),但不是FGC。混合回收过程中,G1会同时回收年轻代和老年代的一部分Region。与年轻代回收不同,老年代的回收不需要整个老年代被回收,而是每次只扫描/回收一小部分老年代的Region。这些被回收的Region可能与年轻代一起被回收,也可能单独回收。

G1回收器在进行混合回收时,会根据堆内存的使用情况、GC停顿时间目标等因素来动态调整需要回收的Region数量。这样可以更好地平衡GC的性能和停顿时间。

Full GC

G1回收器的Full GC和Mixed GC之间存在显著的区别。

Full GC是清理整个堆空间,包括年轻代(Young Generation)、老年代(Old Generation)以及元空间。当老年代空间满了或者达到其阈值时,就会触发Full GC。Full GC的过程和Minor GC过程类似,但是回收的是整个堆和方法区。

相比之下,Mixed GC是G1收集器独有的,它用于收集整个年轻代(Young Generation)以及部分老年代(Old Generation)的垃圾回收。Mixed GC是针对G1垃圾收集器的一种优化,目标是减少Full GC的发生频率。

Full GC时机

  1. 并发模式失败(Concurrent Mode Failure):当G1回收器尝试在并发标记阶段完成清理工作时,如果老年代的空间不足以容纳并发标记阶段识别出来的存活对象,就会触发Full GC。这通常发生在老年代内存紧张而并发标记还未完成时。

  2. 担保失败(Evacuation Failure):在G1回收器的混合收集(Mixed GC)过程中,如果年轻代或老年代中没有足够的空闲空间来容纳晋升的对象(即从年轻代晋升到老年代的对象),G1会触发Full GC。这可能是因为内存碎片过多,无法找到连续的空间来分配大对象。

  3. 巨型对象分配失败(Humongous Object Allocation Failure):如果G1回收器在尝试分配一个大于一个Region大小的巨型对象时,发现老年代中没有足够的连续空间来容纳这个对象,也会触发Full GC。

  4. 老年代空间不足:如果老年代空间不足以容纳更多的对象,并且没有足够的空间进行垃圾回收,G1回收器可能会触发Full GC。

  5. 手动触发:调用System.gc()来请求执行Full GC。

ZGC

ZGC(Z Garbage Collector)属于低延迟垃圾回收器的一种。ZGC的设计目标是在满足亚毫秒级(<10ms)最大暂停时间的同时支持超大内存。

在JDK 11中,ZGC支持的最大内存为4TB。在JDK 13及更高版本中,ZGC支持的最大内存已经增加到了16TB

为了实现超低停顿,ZGC的标记与传统回收器的有区别

传统垃圾回收器的垃圾标记是在对象的对象头上进行,相当于修改对象的字段属性,在大内存上对象数量超多时标记耗时会显著提升。

ZGC为了实现超大内存的支持并且STW时间还能控制在<10ms,使用指针着色(对象地址引用)来实现更快的标记。简单来说就是对64位地址中的高位进行标记,因为16T内存还用不到64位的高位。

初始标记:标记与GC Roots直接关联的对象,这一步跟CMS/G1一样,需要STW,时间控制在<1ms,跟堆大小几乎没关系。

并发标记标记从GC Roots开始直接或者间接关联的所有对象,这个过程耗时较长但不需要暂停应用。这一步也跟CMS/G1一样。

再标记:这一步跟G1的最终标记相似,目的也是为了解决三色标记漏标问题,跟G1的写屏障不同的是使用读屏障,发现读引用是已标记为垃圾的对象时将其修正非垃圾。需要STW,时间控制在<1ms,因为漏标情况很少。

并发转移准备:识别哪些区块应该回收,没有垃圾或垃圾很少的就不需要回收。

初始转移:把直接关联的存活对象复制到其他区块,例如小区块A的复制到小区块B。需要STW,时间控制在<1ms,因为数量不多。

并发转移:把间接关联的存活对象并发的复制到其他区块,由于对象的内存地址会改变且要求不能STW,因此会在原区块维护一个像Map结构似的转发表。这里也使用读屏障,当应用代码使用到对象的原引用时读屏障会使它引到新的内存地址,屏障相当于AOP。

以下是ZGC的GC日志 

可以看到ZGC的STW时间非常短。

Shenandoah

Shenandoah是一个在Java 9中引入的低延迟垃圾回收器,它属于ZGC家族的一部分。Shenandoah垃圾回收器旨在提供可预测的停顿时间和高吞吐量,同时保持较低的内存占用。

Shenandoah垃圾回收器的特点包括:

  1. 并发性:Shenandoah是一个并发的垃圾回收器,它可以在应用程序运行时同时进行垃圾回收工作,从而减少了垃圾回收对应用程序性能的影响。

  2. 分区:Shenandoah使用分区(region)的概念来管理堆内存。它将堆内存划分为多个独立的区域,每个区域都可以独立地进行垃圾回收。这种分区的设计使得Shenandoah能够更灵活地管理内存,并提供更好的可预测性。

  3. 停顿时间控制:Shenandoah垃圾回收器通过调整并发阶段的工作量和停顿时间,可以在满足延迟要求的同时实现高吞吐量。它提供了一组配置参数,允许开发人员根据具体的应用场景调整垃圾回收器的行为。

  4. 适应性:Shenandoah垃圾回收器具有自适应性,能够根据应用程序的运行时特征动态调整其回收策略。例如,它可以根据对象的分配速率和存活率来调整区域的划分和垃圾回收的频率。

  5. 与ZGC的关系:Shenandoah是ZGC家族的一部分,它们共享一些核心的设计原则和技术。然而,Shenandoah与ZGC在目标和工作方式上有所不同。ZGC是一个面向大内存和低延迟的垃圾回收器,而Shenandoah则更注重于提供可预测的停顿时间和高吞吐量。

Epsilon

Epsilon垃圾回收器是Java 9中引入的一个特殊垃圾回收器,它基本上不执行任何实际的垃圾回收工作。Epsilon垃圾回收器的目标是提供一个完全被动的垃圾回收实现,主要用于那些不需要垃圾回收或者想要完全控制垃圾回收的应用场景。

Epsilon垃圾回收器的特点是:

  1. 无操作:Epsilon垃圾回收器不会执行任何堆内存的清理或压缩操作。当堆内存耗尽时,它会直接退出应用程序,而不会尝试回收内存。

  2. 低开销:由于Epsilon垃圾回收器不执行任何实际的垃圾回收工作,因此它的运行时开销非常小。这使得Epsilon成为那些不需要垃圾回收的应用程序的理想选择。

  3. 实验性质:Epsilon垃圾回收器被视为一个实验性的特性,在Java 9及以后的版本中提供。它的设计主要是为了探索和研究目的,而不是作为一个生产就绪的垃圾回收器。

  4. 用途限制:由于Epsilon垃圾回收器不执行任何垃圾回收,因此它只适用于那些能够自己管理内存或者通过其他方式避免内存泄漏的应用程序。对于大多数常规应用程序来说,使用Epsilon垃圾回收器会导致应用程序在堆内存耗尽时立即崩溃。

  5. 配置和启用:可以通过设置JVM参数-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC来启用Epsilon垃圾回收器。但是,由于它的实验性质和限制性用途,建议仅在特定场景下使用。

类加载器ClassLoader

类加载过程

JVM的类加载过程主要包括三个步骤:加载(Loading)、链接(Linking)和初始化(Initialization)。

        加载(Loading)

加载是类加载过程的第一个阶段,这个阶段的主要任务是将类的二进制数据读入到内存中,并为之创建一个对应的java.lang.Class对象,作为元空间这个类的各种数据的访问入口。

加载阶段完成后,在Java堆中也创建一个表示这个类的java.lang.Class类型的对象,这样便可以通过该对象访问元空间中的这些数据。

需要注意,加载阶段实现了类的二进制数据到内存中的转换,但加载阶段并不会执行任何代码。

这个阶段我们可以通过自定义类加载器实现类加载。

        链接(Linking)

链接是类加载过程的第二个阶段,它包含三个子阶段:验证(Verification)、准备(Preparation)和解析(Resolution)。

  • 验证(Verification):验证阶段是为了确保被加载的类文件是符合JVM规范的,没有安全方面的问题。验证的内容包括文件格式验证、元数据验证、字节码验证和符号引用验证。
  • 准备(Preparation):准备阶段是正式为类的静态变量分配内存,并将其初始化为默认值。这里所说的静态变量,即被static修饰的变量。
  • 解析(Resolution):解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用就是一组符号来描述所引用的目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

        初始化(Initialization)

初始化阶段是类加载过程的最后一个阶段,主要执行类构造器方法的方法体。这个方法是由编译器自动收集类中的所有类变量的赋值动作(非final静态字段赋真实值)和静态代码块集合而来的。当我们new一个对象、访问静态属性、访问静态方法、反射调用类、子类初始化都会触发类的初始化。

继承时父子类的初始化顺序按如下顺序进行

类构造器方法是类加载阶段被调用的,按字面意思理解,类构造器方法就是用于初始化类的。此方法不同于类的实例构造器(即在类实例化时调用的构造函数)和实例方法。

注意:类加载过程中的加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。

        使用

正常一个类的过程。

        卸载

类卸载的条件主要有三个:

  1. 该类所有的实例都已经被回收。
  2. 加载该类的类加载器已经被回收。请注意,对于JVM自带的根类加载器(如启动类加载器、扩展类加载器和系统类加载器),JVM本身会始终引用这些类加载器,因此这个条件通常不会满足。然而,对于自定义的类加载器加载的类,这个条件是有可能满足的。
  3. 该类对应的java.lang.Class对象没有被引用。这意味着无法在任何地方通过反射访问该类的方法。

当以上三个条件全部满足时,JVM会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,这样Java类的整个生命周期就结束了。

请注意,类卸载在实际应用中并不常见,因为许多类(特别是系统类和库类)在程序运行期间始终被引用,因此它们无法被卸载。只有那些由自定义类加载器加载的类在满足上述条件时才有可能被卸载。

JVM类加载器

在JDK8-21中,自带的类加载器是相同的,因为Java的类加载器架构在多个版本中保持了稳定性。

  1. Bootstrap ClassLoader(启动类加载器):这是最顶层的类加载器,由JVM自身实现(C++实现),负责加载%JAVA_HOME%\lib目录下核心类库,如java.langjava.util等包中的类。由于它是JVM的一部分,因此无法被Java代码直接引用。

  2. Extension ClassLoader(扩展类加载器):这是Bootstrap ClassLoader的子类加载器,负责加载%JAVA_HOME%\lib\ext目录下的JAR包和class文件。如果这些文件在缓存中已经存在,则不会重新加载。

  3. AppClassLoader(应用程序类加载器或系统类加载器):这是Extension ClassLoader的子类加载器,也是Java应用程序默认的类加载器。它负责加载用户类路径(通过-classpath-Djava.class.path参数指定)下的类库。AppClassLoader是自定义类加载器的默认父类加载器

双亲委派

所谓双亲委派是在没有破坏ClassLoader.loadClass()的前提下,向上委托去加载。

即 自定义ClassLoader->AppClassLoader->ExtClassLoader顺序。

只要双亲有一个能加载,则一个类在JVM中只会被加载一次。

如果自定义了一个ClassLoader去加载classpath下的类,那么这个类因为能被AppClassLoader加载,因此实际会被AppClassLoader加载,只会存在一个Class对象。

如果覆盖破坏了ClassLoader.loadClass()去加载classpath中的类,就能加载成一个全新的Class对象。

自定义类加载器

自定义类加载器有3种方式。

1是覆盖findClass(String name)方法,这种方式不会破坏双亲委派。

2是覆盖loadClass(String name, boolean resolve)方法,这种方式会打破双亲委派。

3是在构造参数中调用super设置parent为null,覆盖findClass(String name)方法,这样就没有双亲也会打破双亲委派。

热部署演示

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;

/**
 * 
 * @author Fangfang.Xu
 *
 */
public final class InputStreamClassLoader extends ClassLoader {

	private final String className;
	private InputStream is;
	private ClassLoader appClassLoader;

	/**
	 * 
	 * @param className 完整类名,例如io.github.icodegarden.nutrient.lang.classloader.Tester
	 * @param is
	 * @throws IOException
	 */
	public InputStreamClassLoader(String className, InputStream is) throws IOException {
		super(null);// 脱离双亲,否则无法重复加载classpath下的class
		if (is.available() == 0) {
			throw new IOException("InputStream's available must > 0");
		}
		this.className = className;
		appClassLoader = getSystemClassLoader();
		this.is = is;
	}

	/**
	 * 类加载的入口,给客户端一个友好的接口,不用指定name
	 * 
	 * @return 加载完成的类
	 */
	public Class<?> loadClass() throws ClassNotFoundException {
		return loadClass(className);
	}

	/**
	 * 客户端可以直接使用该接口创建实例,而不需要先使用 {@link #loadClass()}
	 * 
	 * @param interfaceClass 必须指定接口
	 * @param initargs       构造参数
	 * @return 新的实例
	 */
	public <T> T newInstance(Class<T> interfaceClass, Object... initargs) throws Exception {
		if (!interfaceClass.isInterface()) {
			throw new IllegalArgumentException("class " + interfaceClass.getName() + " is not a interface");
		}
		Class<?> loaded = loadClass();

		Class<?>[] parameterTypes = null;
		if (initargs != null && initargs.length > 0) {
			parameterTypes = new Class<?>[initargs.length];
			for (int i = 0; i < initargs.length; i++) {
				parameterTypes[i] = initargs[i].getClass();
			}
		}
		Constructor<?> declaredConstructor = loaded.getDeclaredConstructor(parameterTypes);
		return (T) declaredConstructor.newInstance(initargs);
	}

	/**
	 * 时序:
	 * 第1次进来:加载name指定的类,如io.github.icodegarden.nutrient.lang.classloader.Tester,此时从stream中读取bytes并触发defineClass(...)<br>
	 * 第2次进来:加载依赖的接口,例如io.github.icodegarden.nutrient.lang.classloader.ITester,而接口应使用已加载的class而不需要重新加载,所以进到if分支内<br>
	 */
	@Override
	protected Class<?> findClass(String name) throws ClassNotFoundException {
		try {
			if (is == null) {
				return Class.forName(name);
			}
			byte[] b = new byte[is.available()];
			is.read(b);
			is = null;
			return defineClass(name, b, 0, b.length);
		} catch (IOException e) {
			throw new ClassNotFoundException(name, e);
		}
	}
}


public interface ITester {

	public void m();

}

public class Tester implements ITester {

	Integer num;

	public Tester() {
		num = -1;
	}

	public Tester(Integer i) {
		num = i;
	}

	public void m() {
		System.out.println("num=" + num);
	}

}

	@BeforeEach
	public void before() throws IOException {
		FileInputStream fileInputStream = new FileInputStream(
				"target/classes/io/github/icodegarden/nutrient/lang/classloader/Tester.class");
		byte[] b = new byte[fileInputStream.available()];
		fileInputStream.read(b);
		fileInputStream.close();

		ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(b);
		classLoader = new InputStreamClassLoader(Tester.class.getName(),
				byteArrayInputStream);
	}

	@Test
	void hotDeployment() throws Exception {
		ITester tester = new Tester();
		tester.m();

		for (int i = 0; i < 20; i++) {
			InputStreamClassLoader classLoader = new InputStreamClassLoader(
					Tester.class.getName(),
					new FileInputStream("target/classes/io/github/icodegarden/nutrient/lang/classloader/Tester.class"));
			
			tester = classLoader.newInstance(ITester.class, i * 10);
			
			tester.m();

			Thread.sleep(100);
		}
	}

JVM知识补充

Java进程峰值内存计算

最大堆内存(Xmx)+

最大Metaspace内存(<JDK8 方法区MaxPermSize)+

最大线程数*栈内存(Xss默认1M)+

最大直接内存(-XX:MaxDirectMemorySize 默认=Xmx,如果代码不会使用到则不占用)+

Socket缓存区(每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,例如最大200个连接=200*(37+25)/1024=12M )+

JVM进程本身所需内存(一般30-50M)

以普通的Springboot服务为例,Xmx2048M+200个tomcat线程*256K+MaxMetaspaceSize256M+0直接内存+(200个tomcat连接+10个mysql连接)*62K+进程50M=2416M,以容器部署时,分配给容器的内存至少需要接近3G(容器操作系统也需要内存)才能保障满负载时内存足够。

Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,可能会抛出IOException:Too many open files异常。

StackOverflowError原因

1、栈深度超过时。

2、栈内存不够时(基本类型、对象引用、returnAddress)。

OutOfMemoryError原因


1、创建对象时堆内存不足(Xmx)并且已达到Xmx时 OutOfMemoryError: Java heap space。

2、Metaspace超过时OutOfMemoryError: metaspace(JDK8及之后,默认MaxMetaspaceSize:-1 不限制),OutOfMemoryError: PermGen space(JDK7及以前)。

3、创建线程时给每个线程分配栈内存时超过操作系统的最大内存时OutOfMemoryError: unable to create native thread(给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常)。

4、运行时常量池超过时OutOfMemoryError: Java heap space(JDK7之后运行时常量池在堆中), OutOfMemoryError: PermGen space(JDK6运行时常量池在方法区,即永久代)。

5、直接内存超过时OutOfMemoryError。

线程状态

jstack命令可能显示的线程状态及其描述:

  1. NEW:线程已经创建,但尚未启动。

  2. RUNNABLE:线程正在Java虚拟机中执行。这意味着线程正在运行或准备运行。它可能处于操作系统的可运行状态,或者正在等待CPU时间。

  3. BLOCKED:线程被阻塞,等待监视器锁(即等待获取对象的同步锁)。这通常发生在线程尝试进入一个同步块或方法,但所需的锁被其他线程持有。

  4. WAITING:线程无限期地等待另一个线程执行特定的(唤醒)动作。这通常发生在调用Object.wait()Thread.join()LockSupport.park()等方法时。

  5. TIMED_WAITING:线程在等待另一个线程执行动作,但有一个指定的等待时间。这通常发生在调用Thread.sleep(long)Object.wait(long)Thread.join(long)LockSupport.parkNanos()LockSupport.parkUntil()等方法时。

  6. TERMINATED:线程已经退出,即线程的run方法已经执行完毕。

Java大对象

大对象需要在内存中分配连续的内存空间,一般是很长的字符串、数组;不需要连续内存的不是大对象,有很多属性也不一定是大对象。

由于需要连续内存,大对象在分配内存时可能找不到足够内存,触发GC(更坏的是该大对象可能是朝生夕灭的,GC的意义更小);而大对象的复制开销较大,设置-XX:PretenureSizeThreshold=3145728(不能直接写3M,只对Serial和ParNew收集器有用)可以使超过大小的大对象分配时直接进入老年代

JVM运行模式

JVM的运行模式有client、server、mix。

Client模式注重启动速度,编译速度快,对GUI有优化,适合用于客户端环境下。

Server模式注重运行速度,编译速度慢,但启动后运行性能高,常用方法会进一步编译为硬件可直接识别的机器码。

Mix模式当字节码被多次调用时,JVM会将其编译成本地代码以提高执行效率。而对于被调用次数较少(甚至只有一次)的方法,则会在解释模式下执行,这样可以减少编译和优化的成本。

模式可通过启动参数或修改jre配置修改。

JVM问题排查实战

线上堆内存OOM排查

排查思路:使用jmap -histo命令看是否能直接定位到原因,如果不行再导出dump文件进行分析,定位到哪些对象超多超大无法回收导致的OOM。

java -Xms32m -Xmx32m -XX:+UseG1GC -jar jvm-opt.jar --jvm.opt.oom=true

运行进程,产生OOM。

使用 jmap -histo [pid] 查看堆内存直方图,反应简洁的内存对象情况。

以上反映我们的User对象数量非常多,且占用很多内存bytes,如果程序员很清楚这个对象在哪些代码中有使用,就可以直接断定应该去看一看哪些代码可能有问题。当然我们还可以进一步排查。

使用 jhsdb jmap --heap --pid [pid] 

整个堆已经使用了97%+,无法回收因此OOM。

再进一步分析堆dump文件,使用 jmap -dump:format=b,file=heap.hprof [pid] 产生dump文件,然后用MAT工具打开。

以上是分析概览,在这里说有一个LinkedList占了70%+的堆内存。

这里我们找到我们写的类名称。

再通过树形结构可以清楚的知道引用关系,发现是OomService内部有大量的Users无法回收。

OOM后进程的运行情况

结论1 OOM后进程不会退出。

那么是否还能正常服务呢,有2种情况。

情况1 引起OOM的对象能被回收,例如大量的局部变量,这些对象将在OOM后回收,进程依然能正常提供服务。但在OOM的那段时间如果是高并发,将出现大量的不正常服务。

情况2 引起OOM的对象不能被回收,例如spring bean中添加大量的成员变量,这样其他的代码触发时由于需要创建对象但已无足够的内存可用,不能正常服务。

线上CPU飙升排查

排查思路:

使用top命令定位java进程号。

使用top -H -p [pid]定位java进程中的CPU最高的几个线程。

使用printf '%x' [tid] 转换线程号。

使用jstack [pid] 结合 转换线程号 定位线程对应的代码。

java -Xms32m -Xmx32m -XX:+UseG1GC -jar jvm-opt.jar --jvm.opt.cpu=true

运行进程,使CPU飙升。

执行top

执行top -H -p [pid]

执行printf '%x' [tid]

执行jstack [pid] 并找到对应的线程

定位到cpu高的原因是CpuHighService的29行代码引起的。

线上死锁排查

死锁时线程的状态大多是BLOCKED。

以下是JVM死锁的一些表现:

  1. 响应时间延长:由于线程被阻塞,系统的响应时间会显著延长。用户可能会遇到界面无响应、请求超时等问题。

  2. 任务没有按预期执行结束:死锁时自动任务、后台任务没有按预期执行完成。

  3. CPU和内存占用:虽然死锁线程处于等待状态,但它们仍然会占用一定的CPU和内存资源。这可能导致系统性能下降,甚至影响其他正常运行的线程。

  4. 性能下降:由于死锁导致线程无法执行,系统的整体性能会受到影响。这可能导致吞吐量下降、延迟增加等问题。

jstack [pid]

线上频繁FullGC排查

排查FullGC原因,首先需要明白FGC是因为有大量对象不能被及时回收,导致新对象所需内存不足引起的,因此找到哪些对象堆积的最多是定位原因的重要线索,要得到这样的信息只有gclog是不够的,log只包含简单的信息,最有用的信息还是得拿到dump文件。

我们可以配置FullGC前置拦截(-XX:+HeapDumpBeforeFullGC)和后置拦截(-XX:+HeapDumpAfterFullGC),特别是HeapDumpBeforeFullGC,这样发生FGC时就会像OOM一样产生dump,然后我们像分析OOM一样使用MAT工具分析对象,最终定位到对应的代码原因。

需要注意的是如果FGC很频繁,配置了HeapDumpBeforeFullGC则会产生很多的dump文件,这个文件往往是有好几个G的,容易导致磁盘爆满,需要有自动清理程序搭配。

JVM调优实战

亿级流量系统JVM调优

亿级流量即日访问量超过1亿次,假设下单率达到20%,按照2/8原则高峰期流量占80%,高峰期时间按每天4小时计算,每秒1E*20%*80%/4/3600 > 1000单/s。

订单服务部署4个实例,这样每个实例每秒需要处理 >250 个订单。

经过大致计算创建订单对象需要1KB内存,这样每秒需要250KB内存,再加上库存、优惠、积分以及其他查询、框架开销等方面的各种各样处理,按扩大200倍计算,每秒需要50MB内存。

分配给我们的每个机器/容器内存是3G,因此我们使用以下关键参数进行部署。

-Xms1500M -Xmx1500M -Xss256K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseConcMarkSweepGC

照这样进行,大约每3分钟就会发生一次Full GC。

调优目标:在不加大硬件配置的前提下,尽可能不发生Full GC。

我们只需调整年轻代和老年代的大小比例,默认年轻代占1/3,老年代占2/3。

-Xms1500M -Xmx1500M -Xmn1000M -Xss256K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseConcMarkSweepGC

这样发生Full GC的可能性就很低了,一般始终不会发生。

G1 FullGC调优

依然使用订单系统为例,增加运行2个定时任务,每个任务需要占用512M内存。为了加快演示每5秒执行一次任务。

-Xms1500M -Xmx1500M -Xmn500M  -Xss256K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseG1GC

在这种情况下大约每过10几秒就会触发一次FGC。

调整参数G1不推荐固定年轻代和老年代的大小,使用它动态的调整。

-Xms1500M -Xmx1500M -Xss256K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseG1GC

在这种场景下G1自适应的把老年代调大来避免FGC。

  • 24
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值