3.JVM

发布于:2024-10-09 ⋅ 阅读:(27) ⋅ 点赞:(0)

了解 JVM,包括内存布局、类加载机制、垃圾回收算法、垃圾回收器的工作原理。
在这里插入图片描述

1.1 JVM内存模型

JVM组成

**JVM由三部分组成:**类加载子系统、运行时数据区、执行引擎。

它们的运行流程是:

第一,类加载器(ClassLoader)把Java代码转换为字节码。

第二,运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行。

第三,执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native MethodLibrary)来实现整个程序的功能。

JVM 运行时数据区

在这里插入图片描述

运行时数据区包含了堆区方法区栈区本地方法栈程序计数器这几部分,每个功能作用不一样。

  • 堆区解决的是对象实例存储的问题,垃圾回收器管理的主要区域。

  • 方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。

  • 栈区解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息。

  • 本地方法栈与栈功能相同,本地方法栈执行的是本地方法(Native)。

  • 程序计数器(PC寄存器)中存放下一个要执行的指令的地址,每个线程对应有一个程序计数器。

其中方法区和堆区是多线程共享的,可能存在线程安全问题。栈区和程序计数器是每一个线程私有的。(重要!!!)

为了避免方法区出现OOM,所以在java8中将堆上的方法区【永久代】移动到了本地内存上,重新开辟了一块空间,叫做元空间

程序计数器

java虚拟机对于多线程是通过线程轮流切换,并且分配线程执行时间。在任何的一个时间点上,一个处理器只会处理执行一个线程,如果当前被执行的这个线程它所分配的执行时间用完了【挂起】。处理器会切换到另外的一个线程上来进行执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。这时候程序计数器就起到了关键作用,程序计数器在来回切换的线程中记录他上一次执行的行号,然后接着继续向下执行

虚拟机栈和本地方法栈

虚拟机栈是描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢。保存执行方法时的局部变量、动态连接信息、方法返回地址信息等等。方法开始执行的时候会进栈,方法执行完会出栈【相当于清空了数据】,所以这块区域不需要进行GC

虚拟机栈和本地方法栈都是为了支持方法的调用和执行而设计的,但虚拟机栈用于存放 Java 方法的栈帧,而本地方法栈用于存放 Native 方法的栈帧。

堆区(重点)

Java中的堆属于线程共享的区域。主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

在 Java8 中堆内会存在年轻代老年代

  • Young区用来存放新生的对象。年轻代被划分为三部分,Eden区两个大小严格相同的Survivor区,其中,Survivor区间中,某一时刻只有其中一个是被使用的另外一个留做垃圾收集时复制对象用。在Eden区变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到Tenured区
  • Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区

方法区 和 原空间(MetaSpace)

方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。

为了避免方法区出现OOM,所以在java8中将堆上的方法区【永久代】移动到了本地内存上,重新开辟了一块空间,叫做元空间

直接内存

它又叫做堆外内存,线程共享的区域,在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果大量动态生成类(将类信息放入永久代),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。

所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能

堆和栈的区别是什么?

  • 第一,栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组。堆会GC垃圾回收,而栈不会。
  • 第二、栈内存是线程私有的,而堆内存是线程共有的
  • 第三、两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。栈空间不足:java.lang.StackOverFlowError。堆空间不足:java.lang.OutOfMemoryError。

1.2 类加载机制

https://blog.csdn.net/m0_67683346/article/details/128163144

类加载:.java编译为.class后,类加载器会将.class文件加载到jvm中进行解析,然后在内存中构造一个类Class对象并初始化(反射)。

类加载过程:类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:

1.加载 :类加载器将.class字节码文件加载到JVM内部,并在方法区创建各个类的Class实例。

2.连接

  • 验证:验证类是否符合JVM规范,安全性检查,如Class文件格式验证,元数据验证,字节码验证,符号引用验证。

  • 准备:为类变量(static/static final)分配内存并设置类变量默认初始值

  • 解析:把类中的符号引用转换为直接引用

3.初始化:对类的静态变量,静态代码块执行初始化操作

4.使用

5.卸载。

1.3 类加载器分类和双亲委派机制

分类(4种):启动 扩展 应用 自定义类加载器。

类加载器的体系并不是“继承”体系,而是委派体系,类加载器首先会到自己的parent中查找类或者资源,如果找不到才会到自己本地查找。类加载器的委托行为动机是为了避免相同的类被加载多次

双亲委派模型?

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就返回成功;只有父类加载器无法完成此加载任务时,才由下一级去加载。

好处:1.避免相同的类被加载多次。2.为了安全,保证类库API不会被修改。

如何打破双亲委派?

两个典型的方法:

  • 自定义类加载器(继承ClassLoader 类),重写了 loadClass 方法,并在其中判断是否加载指定的类。如果加载的类是指定的类,就调用 findClass 方法加载(自定义委派机制);否则,调用父类的 loadClass 方法。
  • 使用线程上下文类加载器。

1.4 对象实例化过程

类加载、分配内存(内存规整和不规整)、处理并发安全问题、设置对象头、成员变量赋初值、执行构造方法

1.判断对应类是否加载过:首先JVM检查在方法区Metaspace(元空间)的常量池里能否定位到该类的符号引用,能的话通过符号引用检查该类是否加载链接初始化过;若没有则在双亲委派机制下,当前类加载器调用findClass()方法查找类的.class字节码文件,然后调用loadClass(“类全限定名”)方法遵循双亲委派机制加载链接初始化类到内存中,并生成类的class对象,作为方法区这个类各种数据的访问入口。
2.创建对象

  1. 分配堆内存空间:如果内存规整:(例如标记整理算法),采用指针碰撞法为新对象分配内存。如果内存不规整:(有内存碎片,例如标记清除算法),在空闲列表里找到合适大小的空闲内存分配给新对象。现在主流虚拟机新生代都是使用标记复制算法,内存都是规整的。
  2. 处理并发安全问题:CAS失败重试,区域加锁,每个线程分配一块TLAB内存缓冲区。
  3. 设置对象头:将哈希码、GC分代年龄、锁信息、GC标记等存在对象头的Mark Word(对象标记)中;
  4. 成员变量赋初值:若指定了初值则赋指定的值。若未指定初值,则基本类型赋0或false、引用类型赋null。

3.执行构造方法:有父类的话,子类构造方法第一行会隐式或手动显式地加super()。

1.5 垃圾回收机制

垃圾回收机制

GC:程序员只申请内存资源后,不需要手动释放资源,释放内存的工作JVM的GC完成。

GC主要针对堆区来进行内存回收(比如没有引用指向该对象的情况,如何判断是否是垃圾:1.引用计数器 2.可达性分析)。

依据分代假说理论,垃圾回收可以分为: 新生代收集、老年代收集、混合收集、整堆收集

引用计数器:可能产生循环引用。

可达性分析算法

Java使用的是可达性分析算法来判断对象是否可以被回收,。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)普通对象,对象与对象之间存在引用关系。可达性分析算法指的是如果从某个到GC Root对象是可达的,对象就不可被回收

哪些对象被称之为GC Root对象呢?

虚拟机中的本地变量引用、方法区中的类静态属性引用方法区中的常量引用本地方法栈中 JNI(Native 方法) 引用的对象。

​ 栈区的变量,释放时机确定(出了作用域生命周期就结束),不必回收;程序计数器是固定内存空间,不必回收;方法区中的静态属性,在类加载之后一般是不会卸载的,所以也不需要进行处理。

强引用、软引用、弱引用、虚引用的区别?

  • 强引用最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
  • 软引用表示一个对象处于有用且非必须状态,如果一个对象处于软引用,在内存空间足够的情况下,GC机制并不会回收它,而在内存空间不足时,则会在OOM异常出现之间对其进行回收。但值得注意的是,因为GC线程优先级较低,软引用并不会立即被回收。(通过SoftReference 对象包装为软引用)
  • 弱引用表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。同样的,因为GC线程优先级较低,所以弱引用也并不是会被立刻回收。
  • 虚引用表示一个对象处于无用的状态**。在任何时候都有可能被垃圾回收**。虚引用的使用必须和引用队列Reference Queue联合使用

延伸话题:ThreadLocal内存泄漏问题 ThreadLocalMap<key,value> key使用弱引用减小了内存泄漏的概率,对于value强引用及时调用 remove() 方法清除即可,从而防止value强引用导致的内存泄漏。

1.6 垃圾回收算法

1.标记清除 2.标记复制 3.标记整理 4.分代回收 比较优缺点(效率、空间浪费、调整引用、stw)、使用场景

1.标记清除算法(Mark-Sweep):

  • 标记、清除:当堆中有效内存空间被耗尽时,会STW(stop the world,暂停其他所有工作线程),然后先标记,再清除。

  • **标记:**可达性分析法,从GC Roots开始遍历,找到可达对象,并在对象头中进行标记。

  • 清除:堆内存内从头到尾进行线性遍历,“清除”非可达对象。注意清除并不是真的置空,垃圾还在原来的位置。实际是把垃圾对象的地址维护在空闲列表,对象实例化的申请内存阶段会通过空闲列表找到合适大小的空闲内存分配给新对象。

  • **优点:**简单

  • 缺点

    • **效率不高:**需要可达性遍历和线性遍历,效率差。
    • **STW导致用户体验差:**GC时需要暂停其他所有工作线程,用户体验差。
    • **有内存碎片,要维护空闲列表:**回收垃圾对象后没有整理,导致堆中出现一块块不连续的内存碎片。
  • **适用场景:**适合小型应用程序,内存空间不大的情况。应用程序越大越不适用这种回收算法。

2.标记复制算法(Copying) :

  • 标记、复制、清除:将内存空间分为两块,每次只使用一块。在进行垃圾回收时,先可达性分析法标记可达对象,然后将可达对象复制到没有被使用的那个内存块中,最后再清除当前内存块中的所有对象。后续再按同样的流程来回复制和清除。
  • 优点
    • **垃圾多时效率高:**只需可达性遍历,效率很高。
    • **无内存碎片:**因为有移动操作,所以内存规整。
  • 缺点:
    • **内存利用率低,浪费内存:**始终有一半以上的空闲内存。
    • **需要调整引用地址:**可达对象移动后,内存地址发生了变化,需要调整所有引用,指向移动后的地址。
    • **垃圾少时效率相对差,但还是比其他算法强:**如果可达对象比较多,垃圾对象比较少,那么复制算法的效率就会比较低。只为了一点垃圾而移动所有对象未免有些小题大做。所以垃圾对象多的情况下,复制算法比较适合。
  • **适用场景:**适合垃圾对象多,可达对象少的情况,这样复制耗时短。非常适合新生代的垃圾回收,因为新生代要频繁地把可达对象从伊甸园区移动到幸存区,而且是新生代满了适合再Minor GC,垃圾对象占比高,所以回收性价比非常高,一次通常可以回收70-90%的内存空间,现在的商业虚拟机都是用这种GC算法回收新生代。

3.标记整理算法(Mark-Compact) :

  • **标记、整理、清除:**首先可达性分析法标记可达对象,然后将可达对象按顺序整理到内存的一端,最后清理边界外的垃圾对象。相当于内存碎片优化版的标记清楚算法,不用维护空闲列表。

  • 优点:

    • 无内存碎片:内存规整。
    • 内存利用率最高:内存既规整又不用浪费一般空间。
  • 缺点:

    • **效率最低:**效率比其他两种算法都低
    • **需要调整引用地址:**可达对象移动后,内存地址发生了变化,需要调整所有引用,指向移动后的地址。
    • **STW导致用户体验差:**移动时需要暂停其他所有工作线程,用户体验差。

4.分代收集算法:

1.堆的区域划分
  • 在java8时,堆被分为了两份:新生代和老年代,它们默认空间占用比例是1:2

  • 对于新生代,内部又被分为了三个区域。Eden区,S0 from区,S1 to区默认空间占用比例是8:1:1

2.分代回收算法的工作机制
  • 1.新创建的对象,都会先分配到eden区
  • 2.当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
  • 3.将存活对象采用复制算法复制到to中,复制完毕后,伊甸园和 from 内存都得到释放
  • 4.经过一段时间后伊甸园的内存又出现不足,标记eden区域to区存活的对象,将其复制到from区
  • 5.当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会提前晋升)
MinorGC、Mixed GC、FullGC的区别是什么
  • MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)
  • Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
  • FullGC:新生代 +老年代完整区域垃圾回收,暂停时间长(STW),应尽力避免.(用户体验差!!)

1.7 垃圾回收器(7种 重点)

Serial、Serial Old、PawNew、Parallel Scavenge、Parallel Old、CMS、G1

**各版本默认回收器:**JDK8默认回收器是Parallel Scavenge + Parallel Old。

各区域对应算法:

  • **新生代回收算法:**标记复制算法;
  • **老年代回收算法:**标记清除/整理算法
  • **整堆回收算法:**分区算法。

1.Serial(串行收集器):

  • 介绍:单线程、单处理器回收新生代,回收时会STW。
  • **STW:**Stop The World,暂停其他所有工作线程直到收集结束。
  • 算法:标记复制算法
  • 回收区域:新生代
  • **优点:**简单、比其他单线程收集器效率高:单线程,不用线程切换,可以专心进行垃圾回收。
  • **应用场景:**适用于内存小的桌面应用,可以在较短时间完成收集。Serial GC是最基础、历史最悠久的收集器,曾是HotSpot虚拟机新生代收集器的唯一选择。
  • **命令:**指定新生代用Serial GC,老年代用Serial Old GC:-XX:+UseSerialGC

2.Serial Old(老年代串行收集器):

  • 介绍:Serial收集器的老年代版本。单线程、单处理器回收老年代,回收时会STW。
  • 算法: 标记-整理算法

3.ParNew(并行新生代收集器):

Par是Parallel(并行,平行)的缩写,New:只能处理的是新生代

  • 介绍:Serial收集器的多线程并行版本。多线程并行回收新生代,回收时会STW。
  • 算法: 标记复制算法
  • 回收区域: 新生代
  • **优点:**多CPU场景下性能高,吞吐量大
  • **缺点:**单CPU场景下性能差,不如串行收集器
  • **应用场景:**多CPU场景下。

4.Parallel Scavenge(并行收集器):

  • 介绍:可控制高吞吐量,多线程并行回收新生代,回收时会STW。比ParNew优秀,可以动态调整内存分配情况。
  • 算法: 标记复制算法
  • 回收区域: 新生代
  • **应用场景:**后台运算量大而不需要太多交互的任务。JDK8默认回收器是Parallel+Parallel Old

5.Parallel Old(老年代并行收集器):

  • 介绍:Parallel Scavenge收集器的老年代版本。可控制高吞吐量,多线程并行回收老生代,回收时会STW。
  • 算法: 标记整理算法
  • 回收区域: 老年代

6.CMS(并发标记清除收集器):

  • 介绍:以最短停顿时间目标,JDK1.5推出,第一次实现了垃圾收集线程和用户线程同时工作。多线程并行回收老年代,低stw。初始标记和重新标记需要stw,但耗时很短。

  • 算法: 标记清除算法。不使用标记整理算法是为了保证清除时不影响用户线程中的工作线程,如果使用标记整理算法的话工作线程引用指向的对象地址就都变了。

  • 回收区域: 老年代

  • 步骤:

    • 初始标记:标记GC Roots直接关联的对象。单线程且停顿用户线程,速度很快。

    • 并发标记:从直接关联对象并发遍历整个图,标记可达对象。并发不停顿。

    • 重新标记:修正上一步用户线程变动的标记。并发停顿。速度远比并发标记阶段快。注意只能修正原有

      象不能修正新增对象,即只能修正原有对象非可达变可达、可达变非可达。

    • 并发清除:并发线性遍历并清理未被标记的对象。并发不停顿。

  • 优点:

    • 并发速度快;
    • **低停顿:**用户线程和垃圾回收器同时执行,仅初始标记和重新标记阶段需要停顿,这两个阶段运行速度很快。
  • 缺点:

    • 并发占线程
    • **有内存碎片:**内存不规整,需要维护空闲列表。
    • **无法处理浮动垃圾:**并发标记阶段会产生新对象,重新标记阶段又只能修正不能新增,所以会出现浮动垃圾。
    • **回收时要确保用户线程有足够内存:**不能等老年代满了再回收,而是内存到达某个阈值后回收,防止用户线程在并发执行过程中新创建对象导致内存不够,导致虚拟机补偿使用Serial Old收集器进行回收并处理内存碎片,从而浪费更多时间。CMS默认是老年代68%时触发回收机制。-XX:CMSInitiatingOccupancyFraction
  • **应用场景:**因为底层是标记清除算法,所以有内存碎片,适合小应用。

7.G1(Garbage-First,垃圾优先收集器):

JDK9后默认使用G1垃圾回收器

  • 介绍:以延迟可控并保证高吞吐量为目标,为了适应内存大小和处理器数量不断扩大而在JDK7推出的垃圾回收器。开创了收集器面向局部收集的设计思路和基于Region(区域)的内存布局形式。JDK8支持并发类卸载后被Oracle官方称为“全功能的垃圾收集器”。并行低停顿,除了并发标记外需要stw,但耗时很短(初始标记和最终标记是真短,筛选回收是有指定STW)。

  • 实现机制:不再把堆划分为连续的分代,而是将堆内存分割成2048个大小相等的Region,各Region根据需要扮演伊甸园区、幸存区、老年代区、巨大区。垃圾优先收集器跟踪各Region里垃圾的回收价值(回收空间大小和预计回收时长),在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,回收优先级最高的那些Region,以达到垃圾优先的效果。

  • 设置最大停顿时间:-XX:MaxGCPauseMillis=默认0.2s

  • Humongous Region(巨大区):存储大小超过Region一半空间的大对象,如果大对象的内存大小超过了Region大小,将会被存在几个连续的巨大区里。G1的大多数行为把巨大区看作老年代的一部分。

  • 算法分区收集算法(整体是标记整理算法、Region之间标记复制算法)

  • 回收区域:整堆(新生代 + 老年代)。整堆里哪个Region垃圾最多,回收收益最大。

  • 步骤:

    • 初始标记:标记GC Roots直接关联的可达对象,Region之间标记复制算法对年轻代的垃圾进行回收。单线程且停顿用户线程,速度很快。

    • 并发标记:从直接关联对象并发遍历整个图,标记可达对象。并发不停顿。

    • 最终标记:重新标记所有存活的对象和上个阶段用户线程新产生的可达对象。并发停顿。采用SATB算法,效率比CMS重新标记高。并发停顿。

    • 筛选回收根据优先级列表,回收价值高的一些Region,将存活对象通过标记复制算法复制到同类型的空闲Region。根据指定的最大停顿时间回收,因此可能来不及回收所有垃圾对象,但能保证回收到最高回收价值的垃圾。并发停顿。

      筛选回收阶段如果并发失败(回收速度赶不上创建对象的速度)会触发Full GC (Mixed GC -> Full GC)

  • 记忆集:是一个抽象概念。每个Region都维护一个记忆集Rset,用来记录其他Region对象对本Region对象的引用。本Region在回收后对象地址会改变,用记忆集就能直接知道直接找到对应引用修改指向的地址,从而不用全局扫描。

  • 卡表(CardTable):是记忆集的一种实现方式。卡表是一个字节数组,每个元素对应一个内存块,每个内存块大小都是2n字节(Hotspot是29=512字节)。

  • 写屏障:当前对象被其他Region对象通过引用关系赋值时,赋值前后会插入写前屏障和写后屏障中断当前Region垃圾回收。

  • CMS的记忆集和写屏障:其他回收器也用到了记忆集和写后屏障,用来防止回收导致位置改变时,不用为了更正引用地址而扫描整个堆。例如CMS记忆集记录老年代指向年轻代的引用。但只有G1用到了写前屏障。

  • 优点:

    • **无内存碎片:**因为整体和局部是整理和复制,都不会产生内存碎片。
    • **无浮动垃圾:**最终标记阶段不但会修正,也会标记新增对象。
  • 缺点:

    • 比CMS更耗费内存和负载
    • **可能来不及回收所有垃圾:**根据指定的STW时间(默认0.2s)回收,因此可能来不及回收所有垃圾对象,但能保证回收到最高回收价值的垃圾。
    • **比CMS更耗费内存和负载:**因为使用写前屏障和写后屏障维护记忆集,而cms只用写后屏障。
  • **应用场景:**适合多核CPU且内存大的大应用,小应用不及其他回收器,但未来会越来越适合。

2)CMS收集器和G1收集器的区别:

CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用;
G1收集器收集范围是老年代和新生代,不需要结合其他收集器使用;
CMS收集器以最小的停顿时间为目标的收集器;
G1收集器可预测垃圾回收的停顿时间
CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片
G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。

1.8 JVM调优思路

JVM调优三步骤:

  1. 监控发现问题
  2. 工具分析问题
  3. 性能调优

1.监控发现问题:

看服务器有没有以下情况,有的话需要调优:

比如:GC频繁 CPU负载过高 OOM 内存泄露 死锁 程序响应时间较长

2.工具分析问题:

使用分析工具定位oom、内存泄漏等问题

  • 调优依据:吞吐量提高的代价是停顿时间拉长。如果应用程序跟用户基本不交互,就优先提升吞吐量。如果应用程序和用户频繁交互,就优先缩短停顿时间。
  • GC日志:使用GCViewer、VisualVM、GCeasy等日志分析工具打印GC日志;
  • JDK自带的命令行调优工具
    • jps:查看正在运行的 Java 进程。jps -v查看进程启动时的JVM参数;
    • jstat:查看指定进程的 JVM 统计信息。jstat -gc查看堆各分区大小、YGC,FGC次数和时长。如果服务器没有 GUI 图形界面,只提供了纯文本控制台环境,它是运行期定位虚拟机性能问题的首选工具。
    • jinfo:实时查看和修改指定进程的 JVM 配置参数。jinfo -flag查看和修改具体参数。
    • jstack:打印指定进程此刻的线程快照。定位线程长时间停顿的原因,例如死锁、等待资源、阻塞。如果有死锁会打印线程的互相占用资源情况。
      线程快照:该进程内每条线程正在执行的方法堆栈的集合。
  • JDK自带的可视化监控工具
    • jconsole:用于对jvm的内存,线程 和 类 的监控。
    • Visual VM: 能够监控线程,内存的情况。
  • MAT:解析Heap Dump(堆转储)文件dump.hprof,查看GC Roots、引用链、对象信息、类信息、线程信息。可以快速生成内存泄漏报表。
    • 生成dump文件方式

      • jmap
      • JVM参数:OOM后生成、FGC前生成
      • Visual VM
      • MAT直接从Java进程导出dump文件

3.性能调优

  • 排查大对象和内存泄漏:使用MAT分析堆转储日志中的大对象,看是否合理。大对象会直接进入老年代,导致Full GC频繁。具体排查步骤看下面OOM。

  • 调整JVM参数:主要关注停顿时间和吞吐量,两者不可兼得,提高吞吐量会拉长停顿时间。

    • 减少停顿时间:垃圾收集器做垃圾回收中断应用执行的时间。 可以通过-XX:MaxGCPauseMillis参数进行设置,以毫秒为单位,至少大于1
    • 提高吞吐量:吞吐量=运行时长/(运行时长+GC时长)。通过-XX:GCTimeRatio=n参数进行设置,99的话代表吞吐量为99%, 一般吞吐量不能低于95%。吞吐量太高会拉长停顿时间,造成用户体验下降。
    • 调整堆内存大小:根据程序运行时老年代存活对象大小(记为x)进行调整,整个堆内存大小设置为X的3~4倍。年轻代占堆内存的3/8。
      • -Xms:初始堆内存大小。默认:物理内存小于192MB时,默认为物理内存的1/2;物理内存大192MB且小于128GB时,默认为物理内存的1/4;物理内存大于等于128GB时,都为32GB。
      • -Xmx:最大堆内存大小,建议保持和初始堆内存大小一样。因为从初始堆到最大堆的过程会有一定的性能开销,而且现在内存不是稀缺资源。
      • -Xmn:年轻代大小。JDK官方建议年轻代占整个堆大小空间的3/8左右。
    • 调整堆内存比例:调整伊甸园区和幸存区比例、新生代和老年代比例。Young GC频繁时,我们提高新生代比例和伊甸园区比例。默认情况,伊甸园区:S0:S1=8:1:1,新生代:老年代=1:2。
    • 年轻代晋升老年代阈值:JDK8时Young GC默认把15岁的对象移动到老年代。JDK9默认值改为7。当Full GC频繁时,我们提高升老年龄,让年轻代的对象多在年轻代待一会,从而降低Full GC频率。JDK8默认Young GC时将15岁的对象移动到老年代。
    • 调整大对象阈值:Young GC时大对象会不顾年龄直接移动到老年代。当Full GC频繁时,我们关闭或提高大对象阈值,让老年代更迟填满。默认是0,即大对象不会直接在YGC时移到老年代。
    • 调整GC的触发条件
      • CMS调整老年代触发回收比例:CMS的并发标记和并发清除阶段是用户线程和回收线程并发执行,如果老年代满了再回收会导致用户线程被强制暂停。所以我们修改回收条件为老年代的60%,保证回收时预留足够空间放新对象。CMS默认是老年代68%时触发回收机制。
      • G1调整存活阈值:超过存活阈值的Region,其内对象会被混合回收到老年代。G1回收时也要预留空间给新对象。存活阈值默认85%,即当一个内存区块中存活对象所占比例超过 85% 时,这些对象就会通过 Mixed GC 内存整理并晋升至老年代内存区域。
  • 选择合适的垃圾回收器:最有效的方式是升级,根据CPU核数,升级当前版本支持的最新回收器。

    • CPU单核,那么毫无疑问Serial 垃圾收集器是你唯一的选择。
    • CPU多核,关注吞吐量 ,那么选择Parallel Scavenge+Parallel Old组合(JDK8默认)。
    • CPU多核,关注用户停顿时间,JDK版本1.6或者1.7,那么选择ParNew+CMS,吞吐量降低但是低停顿。
    • CPU多核,关注用户停顿时间,JDK1.8及以上,JVM可用内存6G以上,那么选择G1。
  • 优化业务代码:绝大部分问题都出自代码。要尽量减少非必要对象的创建,防止死循环创建对象,防止内存泄漏,有些情景下需要以时间换空间,控制内存使用

    • 增加机器:增加机器,分散节点压力
    • 调整线程池参数:合理设置线程池线程数量
    • 缓存、MQ等中间件优化:使用中间件提高程序效率,比如缓存、消息队列等

1.9 项目中有没有实际的JVM调优经验?

1.CPU飙升

原因:CPU利用率过高,大量线程并发执行任务导致CPU飙升。例如锁等待(例如CAS不断自旋)、多线程都陷入死循环、Redis被攻击、网站被攻击、文件IO、网络IO。

定位步骤

  1. 定位进程ID:通过top命令查看当前服务CPU使用最高的进程,获取到对应的pid(进程ID)
  2. 定位线程ID:使用top -Hp pid,显示指定进程下面的线程信息,找到消耗CPU最高的线程id
  3. 线程ID转十六进制:转十六进制是因为下一步jstack打印的线程快照(线程正在执行方法的堆栈集合)里线程id是十六进制。
  4. 定位代码:使用jstack pid | grep tid(十六进制),打印线程快照,找到线程执行的代码。一般如果有死锁的话就会显示线程互相占用情况。
  5. 解决问题:优化代码、增加系统资源(增多服务器、增大内存)。

2.GC调优

**最差情况下能接受的GC频率:**Young GC频率10s一次,每次500ms以内。Full GC频率10min一次,每次1s以内。

其实一小时一次Full GC已经算频繁了,一个不错的应用起码得控制一天一次Full GC。

**监控发现问题:**上午8点是我们的业务高峰,一到高峰的时候,用户感觉到明显卡顿,监控工具(例如Prometheus和Grafana)发现TP99(99%请求在多少ms内完成)时长明显变高,有明显的的毛刺;内存使用率也不稳定,会周期性增大再降低,于是怀疑是GC导致。

**命令行分析问题:**通过jstat -gc观察服务器的GC情况,发现Young GC频率提高成原来的10倍,Full GC频率提高成原来的四倍。正常YGC 10min一次,FGC 10h一次。异常YGC 1min一次,FGC 3h一次;

所以主要问题是Young GC频繁,进而导致Full GC频繁。Full GC频繁会触发STW,导致TP99耗时上升。

解决方案:

  • 排查内存泄漏、大对象、BUG;
  • 增大堆内存:服务器加8G内存条,同时提高初始堆内存、最大堆内存。-Xms、-Xmx。
  • 提高新生代比例:新生代和老年代默认比例是1:2。-XX:NewRatio=由4改为默认的2
  • 降低升老年龄:让存活对象更快进入老年代。-XX:InitialTenuringThreshold=15(JDK8默认)改成7(JDK9默认)
  • 设置大对象阈值:让大于1M的大对象直接进入老年代。-XX:PretenureSizeThreshold=0(默认)改为1000000(单位是字节)
  • 垃圾回收器升级为G1:因为是JDK8,所以直接由默认的Parallel Scavenge+Parallel Old组合,升级为低延时的G1回收器。如果是JDK7版本,不支持G1,可以修改成ParNew+CMS或Parallel Scavenge+CMS,以降低吞吐量为代价降低停顿时间。-XX:CMSInitiatingOccupancyFraction
  • 降低G1的存活阈值:超过存活阈值的Region,其内对象会被混合回收到老年代。降低存活阈值,更早进入老年代。-XX:G1MixedGCLiveThresholdPercent=90设为默认的85

调优效果:调优后我们重新进行了一次压测,发现TP99耗时较之前降低60%。FullGC耗时降低80%,YoungGC次数减少30%。TP99耗时基本持平,完全符台预期。

1.10 内存溢出

内存溢出、溢出原因、解决方案、mat定位

内存溢出: 申请的内存大于系统能提供的内存。

1.溢出原因:

  • **本地直接内存溢出:**本地直接内存设的太小导致溢出。设置直接内存最大值-XX:MaxDirectMemorySize,若不指定则默认与Java堆最大值一致。

  • 虚拟机栈和本地方法栈溢出:如果虚拟机的栈内存允许动态扩展,并且方法递归层数太深时,导致扩展栈容量时无法申请到足够内存。例如递归调用深度过大导致StackOverFlowError。

  • **方法区溢出:**运行时生成大量动态类时会内存溢出。

    • CGlib动态代理:CGlib动态代理产生大量类填满了整个方法区(方法区存常量池、类信息、方法信息),直到溢出。CGlib动态代理是在内存中构建子类对象实现对目标对象功能扩展,如果enhancer.setUseCache(false);,即关闭用户缓存,那么每次创建代理对象都是一个新的实例,创建过多就会导致方法区溢出。注意JDK动态代理不会导致方法区溢出。
    • JSP:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)。
  • 堆溢出:

    • 死循环创建过多对象;
    • 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
    • 内存中加载的数据量过于庞大,如一次从数据库取出的数据集太大、第三方接口接口传输的大对象、接收的MQ消息太大;
    • Tomcat参数设置不当导致OOM:Tomcat会给每个线程创建两个默认4M大小的缓冲区,高并发情况下会导致缓冲区创建过多,导致OOM。
  • 程序计数器不会内存溢出。

2.使用JDK自带的命令行调优工具 ,判断是否有OOM

  1. 使用jsp命令查看当前Java进程;
  2. 使用jstat命令多次统计GC,比较GC时长占运行时长的比例;
  3. 如果比例超过20%,就代表堆压力已经很大了;
  4. 如果比例超过98%,说明这段时期内几乎一直在GC,堆里几乎没有可用空间,随时都可能抛出 OOM 异常。

3.MAT定位导致OOM

写死循环创建对象,不断添加到list里,导致堆内存溢出;

1.导出dump文件

2.MAT解析dump文件

3.定位大对象

4.**这个对象被谁引用:**点击支配树(dominator tree),看大对象被哪个线程调用。

5.定位具体代码出现问题的行号

解决方案:

  1. 通过jinfo命令查看并修改JVM参数,直接增加内存。如-Xmx256m
  2. 检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误
  3. 对代码进行走查和分析,找出可能发生内存溢出的位置。
  4. 使用内存查看工具动态查看内存使用情况。

1.11 内存泄露

内存泄漏、内存泄露的9种情况、性能分析工具判断是否有内存泄漏、解决办法

内存泄漏: 不再使用的对象仍然被引用,导致GC无法回收;

1.内存泄露的9种情况:

  • 1.静态容器里的对象:静态集合类的生命周期与 JVM 程序一致,容器里的对象引用也将一直被引用得不到GC;Java里不准静态方法用非静态方法也是防止内存泄漏。
  • 2.单例对象引用的外部对象:单例模式里,如果单例对象如果持有外部对象的引用,因为单例对象不会被回收,那么这个外部对象不会被回收
  • 3.外部类跟随内部类被引用:内部类持有外部类,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。
  • 4.数据库、网络、IO等连接忘记关闭:在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close 方法来释放与数据库的连接。如果对 Connection、Statement 或 ResultSet 不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。
  • 5.变量作用域不合理:例如一个变量只会在某个方法中使用,却声明为成员变量,并且被使用后没有被赋值为null,将会导致这个变量明明已经没用了,生命周期却还跟对象一致。
  • 6.HashSet中对象改变哈希值:当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了。否则对象哈希值改变,找不到对应的value。
  • 7.缓存引用忘删除:一旦你把对象引用放入到缓存中,他就很容易遗忘,缓存忘了删除,将导致引用一直存在。
  • 8.逻辑删除而不是真实删除:监听器和其他回调:如果客户端在你实现的 API 中注册回调,却没有显示的取消,那么就会积聚。需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为 软WeakHashMap 中的键。例如出栈只是移动了指针,而没有将出栈的位置赋值null,导致已出栈的位置还存在引用。
  • 9.线程池时,ThreadLocal忘记remove():使用线程池的时候,ThreadLocal 需要在使用完线程中的线程变量手动 remove(),否则会内存泄漏。因为线程执行完后没有销毁而是被线程池回收,导致ThreadLocal中的对象不能被自动垃圾回收。

2.性能分析工具判断是否有内存泄漏:

  • JDK自带的命令行调优工具:
    • 每隔一段较长的时间通过jstat命令采样多组 OU(老年代内存量) 的最小值;
    • 如果这些最小值在上涨,说明无法回收对象在不断增加,可能是内存泄漏导致的。
  • MAT监视诊断内存泄漏:
    • **生成堆转储文件:**MAT直接从Java进程导出dump文件
    • **可疑点:**查看泄漏怀疑(Leak Suspects),找到内存泄漏可疑点
    • **可疑线程:**可疑点查看详情(Details),找到可疑线程
    • **定位代码:**查看线程调用栈(See stacktrace),找到问题代码的具体位置。
  • GC详细日志:启动参数开启GC详细日志,设置日志地址;-XX:+PrintGCDetails;
  • 编译器警告:查看Eclipse等编译器的内存泄漏警告;
  • Java基准测试工具:分析代码性能;

3.解决办法:

  1. 牢记内存泄漏的场景,当一个对象不会被使用时,给它的所有引用赋值null,堤防静态容器,记得关闭连接、别用逻辑删除,只要用到了引用,变量的作用域要合理。
  2. 使用java.lang.ref包的弱引用WeakReference,下次垃圾收集器工作时被回收。
  3. 检查代码;

1.12 一次完整的GC流程是怎样的?

堆分为哪几个区、GC流程、注意大对象和年龄15(JDK8)

  1. 首先,任何新对象都分配到 eden 空间。两个幸存者空间开始时都是空的。
  2. 当 eden 空间填满时,将触发一个Minor GC(年轻代的垃圾回收,也称为Young GC),删除所有未引用的对象,大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年代。
  3. 所有被引用的对象作为存活对象,将移动到第一个幸存者空间S0,并标记年龄为1,即经历过一次Minor GC。之后每经过一次Minor GC,年龄+1。GC分代年龄存储在对象头的Mark Word里。
  4. 当 eden 空间再次被填满时,会执行第二次Minor GC,将Eden和S0区中所有垃圾对象清除,并将存活对象复制到S1并年龄加1,此时S0变为空。
  5. 如此反复在S0和S1之间切换几次之后,还存活的年龄等于15的对象(JDK8默认15,JDK9默认7,-XX:InitialTenuringThreshold=7)在下一次Minor GC时将放到老年代中。
  6. 当老年代满了时会触发Major GC(也称为Full GC),Major GC 清理整个堆 – 包括年轻代和老年代。