一、JVM 概述
JVM(Java Virtual Machine)即 Java 虚拟机,它是 Java 编程语言的核心组件之一,负责执行 Java 程序。JVM 使得 Java 程序可以实现“一次编写,到处运行”的特性,因为它提供了一个抽象的运行环境,将 Java 字节码与具体的硬件和操作系统隔离开来。Java 编译器(如 javac)将 Java 源代码编译成字节码,这些字节码被存储在 .class 文件中,JVM 加载这些字节码文件,并使用字节码执行引擎来解释和执行其中的指令。
二、JVM 运行时数据区域
JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的区域,这些区域各自有各自的用途以及特性。运行时数据区域主要分为线程共享区域和线程私有区域。
2.1 线程共享区域
2.1.1 堆(Heap)
堆是 JVM 中最大的一块内存区域,用于存储对象实例和数组,是垃圾收集器管理的主要区域。当堆中没有足够的内存来完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError
异常。
为了更好地管理堆内存中的对象,包括内存的分配和回收,堆被划分为新生代和老年代,默认比例为 1:2(可以通过 -XX:NewRatio
调整)。新生代又可以被划分为三个区域:Eden 区、From Survivor 区和 To Survivor 区,默认的比例为 8:1:1(可以通过 -XX:SurvivorRatio
来设定)。
2.1.2 方法区(Method Area)
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在 JDK 8 及之前的版本中,方法区被实现为永久代(Permanent Generation),而在 JDK 8 之后的版本中,方法区被替换为元空间(Metaspace),使用本地内存实现。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError
异常。
2.2 线程私有区域
2.2.1 程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError
情况的区域。
2.2.2 虚拟机栈(JVM Stack)
虚拟机栈描述的是 Java 方法执行的内存模型,每个方法被执行的时候都会同时创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError
异常;如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError
异常。
2.2.3 本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。同样,本地方法栈也可能抛出 StackOverflowError
和 OutOfMemoryError
异常。
三、对象的创建与访问定位
3.1 对象的创建
当 Java 虚拟机在遇到一条字节码 new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程。类加载检查通过后,接下来虚拟机将为新生对象分配内存。
3.2 对象的内存分配
从 Java 堆的内存是否规整的角度,对象的内存分配方式分为指针碰撞和空闲列表两种。如果 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那么所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”;如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
3.3 对象的访问定位
Java 程序通过栈上的引用变量来访问堆中的对象实例,对象的访问方式主要有使用句柄和直接指针两种。
- 使用句柄:Java 堆中会划分出一块内存来作为句柄池,引用变量中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
- 直接指针:引用变量中存储的直接就是对象的地址。
使用句柄的好处是引用变量中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用变量本身不需要修改;直接指针的好处是访问速度快,节省了一次指针定位的时间开销。
四、垃圾收集器和内存分配策略
4.1 判断对象的生死
4.1.1 引用计数法
引用计数法是垃圾收集器中的早期策略,它为堆中每个对象实例都分配一个引用计数器。当对象被创建时,计数器被设置为 1;当有一个变量被设置为这个对象的引用时,计数器加 1;当这个对象的某个引用被销毁或指向新的值时,计数器减 1;当计数器为 0 时,这个对象实例就被认为是垃圾,可以被回收。然而,引用计数法存在很大的缺点,当两个对象循环引用时,由于互相存在引用,所以引用计数器一直为 1,但是没有任何其他变量指向它们,即使它们已经没有任何用处,仍然不能当作垃圾进行回收,长时间下来会浪费大量的内存。
4.1.2 可达性分析算法
为了解决引用计数算法无法解决循环引用的问题,推出了可达性分析算法。该算法基于离散数学的有向图,通过一个 GC Roots 节点开始,寻找它的引用节点,找到后再寻找新节点的引用节点,直到找完所有的节点,那么剩下的节点就会被认为是没有引用的节点,便会判断为可以回收的对象。能成为 GC Roots 的对象主要包括:虚拟机栈中的引用对象、方法区中类的静态属性和常量引用对象、本地方法栈中的 JNI 引用对象。
4.2 引用类型
在 Java 语言中,引用分为强引用、软引用、弱引用、虚引用 4 种,这四种引用强度依次逐渐减弱。
- 强引用:在程序代码中普遍存在的,类似
Object obj = new Object()
这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。 - 软引用:用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
- 弱引用:也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:也叫幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知,通常用于指向一些对象被回收时的清理操作。
4.3 垃圾收集算法
4.3.1 标记 - 清除算法
标记 - 清除算法分为标记和清除两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。该算法的缺点是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;另外,还会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
4.3.2 标记 - 复制算法
标记 - 复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。该算法的优点是对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可,实现简单,运行高效。缺点是如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,而且可用内存缩小为了原来的一半,空间浪费未免太多。
4.3.3 标记 - 整理算法
标记 - 整理算法的标记过程仍然与“标记 - 清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。该算法避免了标记 - 清除算法的内存碎片问题,但整理阶段开销较大。
4.3.4 分代收集算法
分代收集算法是目前大部分 JVM 采用的垃圾收集算法,它将堆内存划分为不同的代,不同代使用不同的垃圾回收算法。新生代对象生命周期短,使用复制算法;老年代生命周期较长的对象,使用标记 - 清除或标记 - 整理算法。
4.4 经典的垃圾收集器
4.4.1 Serial 收集器
Serial 收集器是单线程的收集器,适用于单核或小内存环境。它在进行垃圾回收时,必须暂停所有应用线程(STW,Stop - The World),影响响应时间,但实现简单,开销低。
4.4.2 ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,同样需要暂停所有应用线程。它在多核处理器环境下可以提高垃圾回收的吞吐量。
4.4.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器是新生代收集器,采用复制算法,是多线程并发的垃圾收集器。它的目标是达到一个可控的吞吐量,适合对吞吐量要求较高的应用场景。该收集器提供了垃圾收集的自适应的调节策略,这是它区别于 ParNew 收集器的一个重要特性。
4.4.4 Serial Old 收集器
Serial Old 收集器是 Serial 收集器的老年代版本,同样是单线程收集器,使用标记 - 整理算法。
4.4.5 Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,采用多线程并行的方式进行垃圾回收,使用标记 - 整理算法,适合对吞吐量要求较高的应用场景。
4.4.6 CMS 收集器(重点)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,适用于对响应时间要求高的应用。它采用并发标记和清除的方式,减少了垃圾回收的停顿时间。但该收集器需要更多的 CPU 资源,可能出现“浮动垃圾”问题,并且会产生内存碎片。
4.4.7 G1 收集器(重点)
G1(Garbage - First)收集器是近年来广泛使用的垃圾回收器,特别适合大内存、低延迟场景。它将堆内存划分为多个区域(Region),并采用并发回收的方式,优先回收垃圾最多的区域,平衡了吞吐量和停顿时间,但实现复杂,调优难度较大。
4.5 内存分配策略
- 对象优先在 Eden 分配:大多数情况下,新创建的对象会优先在新生代的 Eden 区分配内存。
- 大对象直接进入老年代:大对象是指需要大量连续内存空间的对象,如大数组等。为了避免在新生代频繁进行垃圾回收,大对象会直接进入老年代。
- 长期存活的对象将进入老年代:对象在 Survivor 区每存活一次,年龄就会加 1,当达到一定的年龄(默认是 15,可以通过
-XX:MaxTenuringThreshold
设置)时,就会晋升到老年代。 - 动态对象年龄判定:如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到
MaxTenuringThreshold
中要求的年龄。 - 空间分配担保:在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的;如果不成立,则虚拟机会查看
-XX:HandlePromotionFailure
设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX:HandlePromotionFailure
设置不允许冒险,那这时就要进行一次 Full GC。
五、JVM 类加载机制
5.1 类加载的时机
Java 虚拟机规范并没有强制约束类加载过程的第一阶段(即加载)什么时候开始,但是对于类的初始化阶段,虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到
new
、getstatic
、putstatic
或invokestatic
这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用new
关键字实例化对象的时候、读取或设置一个类的静态字段(被final
修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。 - 使用
java.lang.reflect
包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。 - 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含
main()
方法的那个类),虚拟机会先初始化这个主类。 - 当使用 JDK 1.7 的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invodeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
5.2 类加载的过程
类加载过程包括加载、验证、准备、解析和初始化五个阶段。
5.2.1 加载
加载阶段是类加载过程的第一个阶段,在这个阶段,虚拟机需要完成以下三件事情:
- 通过类的全限定名来获取定义该类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时存储结构。
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
5.2.2 验证
验证是连接阶段的第一步,这一阶段的目的是确保 .class
字节码文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成下面 4 个阶段的检验动作:
- 文件格式验证:验证字节流是否符合
.class
字节码文件格式的规范,并且能被当前版本的虚拟机处理。 - 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段中发生。符号引用验证的目的是确保解析动作能正常执行。
5.2.3 准备
准备阶段是正式为类变量(被 static
修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里所说的初始值通常是数据类型的零值,例如,int
类型的初始值是 0,boolean
类型的初始值是 false
等。
5.2.4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可;直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
5.2.5 初始化
初始化阶段是类加载过程的最后一步,在这个阶段,才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。初始化阶段是执行类构造器 <clinit>()
方法的过程。<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static
块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。
5.3 类加载器
Java 中的类加载器主要分为以下几种:
5.3.1 启动类加载器(Bootstrap ClassLoader)
启动类加载器负责加载支撑 JVM 运行的位于 JRE 的 lib
目录下的核心类库,比如 rt.jar
、charsets.jar
等。它是用 C++ 实现的,是虚拟机自身的一部分,无法被 Java 程序直接引用。
5.3.2 扩展类加载器(Extension ClassLoader)
扩展类加载器负责加载支撑 JVM 运行的位于 JRE 的 lib
目录下的 ext
扩展目录中的 JAR 类包。它由 sun.misc.Launcher$ExtClassLoader
实现。
5.3.3 应用程序类加载器(Application ClassLoader)
应用程序类加载器负责加载 ClassPath 路径下的类包,主要就是加载用户自己编写的那些类。它由 sun.misc.Launcher$AppClassLoader
实现,一般情况下,Java 应用的类都是由它来完成加载的,可以通过 ClassLoader.getSystemClassLoader()
来获取它。
5.3.4 自定义类加载器
用户可以通过继承 java.lang.ClassLoader
类的方式实现自定义类加载器,以满足一些特殊的需求,比如隔离加载类、修改类加载方式、扩展加载源等。
5.4 双亲委派模型
双亲委派模型是 JVM 类加载器的一种工作模式。当一个类加载器收到了类加载的请求时,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载器无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。
双亲委派模型的优点是可以保证 Java 程序的安全性和稳定性,避免类的重复加载,同时也可以防止核心 API 库被随意篡改。例如,我们自己编写的 java.lang.String
类不会被加载,因为启动类加载器会优先加载 JDK 中的 String
类。
六、字节码执行引擎
6.1 字节码执行引擎的概念
字节码执行引擎是 JVM 内用于解释和执行 Java 字节码的模块。Java 编译器将 Java 源代码编译成字节码,这些字节码被存储在 .class
文件中,JVM 加载这些字节码文件,并使用字节码执行引擎来解释和执行其中的指令。字节码是一种中间表示形式,独立于具体的硬件和操作系统,这使得 Java 应用程序可以跨平台运行。
6.2 字节码执行引擎的工作原理
6.2.1 解释执行
解释执行是字节码执行引擎的基本功能,JVM 内置的解释器会逐条读取和解释字节码指令,并执行对应的操作。这种执行方式简单但效率较低。
6.2.2 即时编译(JIT)
为了提高性能,现代 JVM 实现了即时编译(Just - In - Time Compilation,JIT)技术。JIT 编译器在运行时将频繁执行的字节码编译为本地机器码,以提高执行速度。JIT 编译后的本地机器码比解释执行快得多,因为它可以利用硬件和操作系统的特性进行优化。
6.2.3 栈帧管理
字节码执行引擎管理每个方法调用的栈帧,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它存储了方法的局部变量、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
6.2.4 异常处理
字节码执行引擎负责处理 Java 程序中的异常。当方法中发生异常时,JVM 会查找该方法的异常处理表,以确定是否有对应的异常处理器。如果找到了匹配的异常处理器,JVM 将控制转移到异常处理器执行;如果当前方法中没有匹配的异常处理器,JVM 会将异常抛给调用该方法的方法,一直沿着调用栈向上查找,直到找到合适的异常处理器或栈帧用尽。
6.3 字节码执行引擎的主要组件
6.3.1 解释器
解释器是字节码执行引擎的基本组件,它逐条解释和执行字节码指令。解释器的优点是实现简单、启动快,因此适合应用程序的启动阶段。然而,解释器的执行速度较慢,因为它需要逐条解析和执行字节码指令。
6.3.2 即时编译器(JIT Compiler)
JIT 编译器通过将热点字节码编译为本地机器码,显著提高程序的执行速度。JIT 编译器在运行时对代码进行动态优化,这些优化可以基于实际的执行情况,如内联方法调用、消除不必要的代码和循环优化等。JIT 编译器有多个级别的优化,可以根据代码的执行频率逐步提高优化程度。
6.3.3 垃圾收集器(Garbage Collector,GC)
垃圾收集器不是字节码执行引擎的一部分,但它在字节码执行过程中起着重要作用。垃圾收集器负责自动回收不再使用的对象,释放堆内存,以确保系统不会耗尽内存。字节码执行引擎和垃圾收集器协同工作,以管理 Java 程序的内存使用。
6.4 字节码执行引擎的优化技术
6.4.1 即时编译(JIT)优化
JIT 编译器使用多种优化技术来提高程序的执行速度,如方法内联、逃逸分析、循环优化、动态编译等。方法内联是将被频繁调用的方法的代码直接插入到调用者中,减少方法调用的开销;逃逸分析是分析对象的作用域,如果对象不会逃逸出方法范围,可以在栈上分配内存而不是在堆上,减少垃圾收集的压力。
6.4.2 解释执行与编译执行的混合模式
现代 JVM 通常使用解释执行与即时编译的混合模式。程序启动时,JVM 使用解释器执行字节码,这样可以快速启动程序。当 JVM 检测到热点代码时,触发 JIT 编译器对这些代码进行优化编译。这种混合模式结合了解释执行的快速启动优势和即时编译的高效执行优势。
6.4.3 分层编译
分层编译(Tiered Compilation)是 JVM 的一种优化策略,结合了不同级别的编译和优化。它包括解释模式、C1 编译器和 C2 编译器。解释模式用于快速启动和执行程序;C1 编译器进行基础的即时编译,适用于中等热点代码,提供较快的编译速度和适度的优化;C2 编译器进行高级优化,适用于非常热的代码,提供高优化水平但编译开销较大。通过分层编译,JVM 可以在程序生命周期的不同阶段应用不同的优化策略,平衡启动时间和执行效率。
七、JVM 常见问题与调优
7.1 常见问题
7.1.1 内存问题
- 内存泄漏:对象无法被垃圾回收,导致内存占用持续增长。常见的原因包括静态集合持有对象、未关闭资源(如数据库连接)、监听器未注销等。
- 内存溢出:堆内存或方法区内存不足,无法分配新对象。堆内存溢出可能是由于内存泄漏、数据规模过大、JVM 堆设计不合理、高并发场景等原因导致的;方法区溢出可能是由于动态生成大量类等原因引起的。
7.1.2 性能问题
- CPU 使用率过高:某些线程占用大量 CPU 资源,可能是由于线程竞争(如频繁锁竞争)或大量 GC 引起的。
- GC 频繁或耗时过长:垃圾回收导致应用暂停时间过长,影响应用的性能和响应时间。
7.1.3 线程问题
- 死锁:多个线程相互等待,导致程序无法继续执行。
- 线程阻塞:线程因等待资源而被阻塞,可能会影响应用的并发性能。
7.1.4 类加载问题
- 类加载失败:类加载器无法找到指定的类,可能是由于类路径配置错误、类文件损坏等原因导致的。
- 类冲突:多个类加载器加载了相同类名的不同版本,可能会导致程序出现异常。
7.2 调优步骤
7.2.1 分析需求和瓶颈
通过监控工具(如 JVisualVM、JConsole、Prometheus + Grafana)查看应用的运行情况,找出是内存不足、GC 频繁还是 CPU 过高等问题。
7.2.2 选择合适的 GC 算法
根据应用场景选择适合的垃圾收集器:
- 低延迟:适合实时性要求高的应用,推荐 G1 或 ZGC。
- 高吞吐:适合批处理或后台任务,推荐 Parallel GC。
- 小内存:推荐 Serial GC。
7.2.3 调整堆内存设置
设置初始堆大小(-Xms
)和最大堆大小(-Xmx
),建议两者设置为相同值,以避免堆的动态扩展带来的性能开销。同时,设置新生代和老年代的比例(-XX:NewRatio
)。
7.2.4 监控与调试
通过日志分析 GC 情况(-XX:+PrintGCDetails
,-Xlog:gc
),调整参数并观察改动效果。
7.2.5 线上验证
将调优后的参数部署到测试环境或线上小流量环境,逐步验证。
7.3 具体调优案例
7.3.1 GC 频繁导致性能下降
- 现象:应用响应变慢,查看 GC 日志发现每秒触发多次 Minor GC。
- 解决方案:检查堆大小配置,增加堆内存大小;调整新生代大小,增加新生代空间比例;替换 GC 算法,如从 Parallel GC 切换到 G1 GC。
7.3.2 内存泄漏导致 OOM
- 现象:应用运行一段时间后崩溃,报
OutOfMemoryError
。 - 解决方案:使用 VisualVM 或 MAT 分析堆转储文件,定位泄漏的根本原因,修复代码问题,如未关闭的资源或缓存问题;配置 OOM 日志输出。
7.3.3 延迟过高,Full GC 频繁
- 现象:响应延迟明显增大,GC 日志显示 Full GC 频繁,每次暂停时间较长。
- 解决方案:减少老年代内存占用,增大新生代空间,减少对象进入老年代;启用 CMS 或 G1 GC;调整 G1 的暂停时间目标;降低 GC 触发频率,调整老年代的使用阈值。
7.3.4 CPU 占用过高
- 现象:应用 CPU 使用率长期居高不下,GC 日志显示 GC 时间占比过高。
- 解决方案:检查 GC 算法,切换到低 CPU 占用的 GC(如 G1 或 ZGC);优化代码逻辑,检查热点方法,优化高频调用代码或使用缓存;限制 GC 线程数。
八、JVM 监控工具
8.1 内置工具
8.1.1 jps(Java Virtual Machine Process Status Tool)
用于列出当前用户的所有 Java 进程及其 PID。常用命令如 jps -l
显示主类全名,jps -v
显示 JVM 启动参数。
8.1.2 jstat(JVM Statistics Monitoring Tool)
实时监控 JVM 内存、GC、类加载等状态。例如,jstat -gcutil <pid> 1000
可以每秒输出堆各区域的使用率。
8.1.3 jmap(Memory Map Tool)
生成堆转储文件(Heap Dump)或查看堆内存对象分布。常用命令如 jmap -heap <pid>
查看堆内存使用详情,jmap -histo:live <pid>
进行直方图统计对象数量。
8.1.4 jstack(Stack Trace Tool)
生成线程快照,用于分析线程死锁或高 CPU 问题。例如,jstack <pid> > thread_dump.txt
可以生成线程快照并保存到文件中。
8.1.5 jcmd(JVM Command Tool)
多功能工具,支持 GC、类加载、线程等诊断。例如,jcmd <pid> VM.flags
可以查看 JVM 参数,jcmd <pid> GC.heap_info
可以查看堆内存信息。
8.2 图形化工具
8.2.1 JConsole
JDK 自带的图形化监控工具,支持内存、线程、类加载、MBean 等监控。使用时,在命令行中输入 jconsole
,然后选择要监控的 JVM 进程即可。
8.2.2 VisualVM
功能强大的图形化工具,支持堆转储分析、线程快照、CPU/内存采样、插件扩展等。可以实时监控堆、线程、CPU 等信息,支持插件(如 BTrace)。
8.2.3 Java Mission Control(JMC)
Oracle 提供的商业级监控工具(JDK7u40 + 自带),支持低开销的性能分析和事件记录。核心特性包括 Flight Recorder 记录 JVM 运行事件,以及提供详细的内存、线程、GC 分析可视化报告,适用于生产环境性能诊断。
8.3 第三方工具
8.3.1 Arthas(阿里开源)
在线诊断工具,支持动态监控、热更新、方法调用追踪等。例如,dashboard
可以实时监控面板,thread -n 3
可以查看最忙的 3 个线程。
8.3.2 JProfiler
商业级性能分析工具,支持内存泄漏检测、CPU 热点分析、线程监控等。可视化能力强,可用于内存泄漏、线程争用分析。
8.3.3 IntelliJ IDEA 内置 Profiler
提供 CPU 和内存分析,如热点图、调用树、方法列表、时间轴等多种视图,帮助开发者快速定位性能瓶颈。无需安装额外插件,直接在 IDE 中启动分析,适合轻量级的性能分析需求,尤其是在开发过程中快速定位问题。
8.3.4 MAT(Eclipse Memory Analyzer)
堆转储文件分析工具,用于定位内存泄漏和大对象。可以通过 Dominator Tree 显示占用内存最多的对象,以及自动分析内存泄漏嫌疑点。
8.4 日志分析工具
8.4.1 GCViewer
分析 GC 日志的可视化工具,可用于优化 GC 性能。
8.4.2 ELK Stack(Elasticsearch + Logstash + Kibana)
集中化日志管理与分析,可用于生产环境日志监控。
综上所述,JVM 是 Java 程序运行的核心,深入理解 JVM 的各个知识点,包括运行时数据区域、垃圾收集器、类加载机制、字节码执行引擎等,对于 Java 开发者来说至关重要。同时,掌握 JVM 常见问题的排查和调优方法,以及合理使用监控工具,能够帮助我们提高 Java 应用的性能和稳定性。"