深入探索 JVM:原理、机制与实战
一、JVM 概述
JVM(Java Virtual Machine)是 Java 程序运行的核心组件,它提供了一个独立于硬件和操作系统的执行环境,使得 Java 程序能够在不同平台上具有跨平台的特性。
JVM 主要由以下几部分组成:
- 类装载器(Class Loader):负责从文件系统或者网络加载 Java 类,转换成 Java 字节码,然后加载到运行时数据区。
- 运行时数据区:这是 Java 虚拟机执行 Java 程序时使用的主要内存空间,主要包括以下几部分:
- 方法区(Method Area):存储已被加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。
- 堆(Heap):这是 JVM 所管理的最大的一块内存空间,几乎所有的对象实例都在这里分配内存。
- Java 栈(Java Stacks):每个线程都有一个私有的 Java 栈,用于存储局部变量,操作数栈,动态链接和方法出口等信息。
- 本地方法栈(Native Method Stacks):对于执行 Native 方法服务的栈,每个线程都会有一个对应的本地方法栈。
- 程序计数器(Program Counter Register):它是当前线程所执行的字节码的行号指示器。
- 执行引擎(Execution Engine):负责执行字节码,主要包括解释器、即时编译器(JIT)以及垃圾回收器。
- 本地接口库(Native Interface):Java Native Interface,可以支持 Java 调用其他语言的程序。
- 本地方法库(Native Method Library):存储了所有的本地方法,由 JVM 调用。
Java 虚拟机屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java 虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。
Java 语言的跨平台特性是由 JVM 实现的,我们编写的程序运行在 JVM 上,JVM 运行在操作系统上。Java 编译将.java 文件转化成.class 文件,.class 文件中并不是机器码,而是字节码。这种字节码是不依赖于计算机硬件架构而存在的,不管是什么操作系统,只要有对应的 JVM,字节码就可以在任何平台运行,真正实现了一次编译,到处运行。
JVM 的整体结构架构模型有基于栈式和基于寄存器两种。基于栈式的指令集架构设计和实现更简单,适用于资源受限的系统,避开了寄存器的分配难题,不需要硬件支持,可移植性更好,更好实现跨平台;基于寄存器的指令集架构则完全依赖硬件,可移植性差,但性能优秀和执行更高效,花费更少的指令去完成一项操作。由于 Java 要支持跨平台,所以 Java 的指令都是针对栈来设计的。不同平台 CPU 架构不同所以不能设计成基于寄存器的。优点是跨平台指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
二、JVM 的组成部分
1. Class Loader(类加载器)
类加载器是 Java 运行时环境(JRE)的一部分,负责在运行时动态地加载 Java 类到 Java 虚拟机(JVM)中。其主要作用包括加载类、链接类以及初始化类。具体来说,它会根据类的全名找到对应的.class文件,并将其加载到 JVM 中,然后进行链接操作,包括验证、准备和解析,最后为类的静态变量赋予正确的初始值完成初始化。
Java 中有三种主要的类加载器:启动类加载器负责加载 Java 的核心类库;扩展类加载器负责加载 Java 的扩展类库;系统类加载器负责加载应用程序的类路径下的所有类。此外,开发者还可以自定义类加载器,以满足特殊需求,如热部署、代码加密等。
Java 的类加载器采用双亲委派模型,当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。这种模型保证了 Java 核心类库的类型安全,避免了类的重复加载,并且使得 Java 应用更加稳定。
类加载器的隔离性确保了不同应用程序或库之间的类不会相互干扰,从而避免了潜在的类冲突和不安全行为。例如,两个不同的应用程序可能都使用了一个名为com.example.Utils的类,但这两个类实际上可能是完全不同的。通过为每个应用程序使用不同的类加载器,可以确保每个应用程序加载和使用它自己的com.example.Utils类版本,而不会与其他应用程序的类发生冲突。
自定义类加载器允许开发者扩展 Java 的类加载机制,以满足特定的需求。通过继承ClassLoader类并重写其中的方法,开发者可以控制类的加载过程,实现如加密类的加载、从特定位置(如数据库或网络)加载类等高级功能。
2. Execution Engine(执行引擎)
执行引擎是 Java 虚拟机核心的组成部分之一,它的任务是将字节码指令解释或编译为对应平台上的本地机器指令才可以执行。可以把执行引擎看作是将高级语言翻译为机器语言的译者。
执行引擎在执行的过程中,其行为有解释执行和编译执行两种。解释执行是当 Java 虚拟机启动时,根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容 “翻译” 为对应平台的本地机器指令执行。而编译执行主要是通过 JIT(Just In Time Compiler)编译器实现,虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行。
Java 属于半编译半解释型语言,JDK1.0 时代,Java 语言定位为 “解释执行”,后来发展出可以直接生成本地代码的编译器。现在 JVM 在执行 Java 代码的时候,通常都会将解释执行与编译执行二者结合起来进行。JIT 编译器将字节码翻译成本地代码后,可以做一个缓存操作,存储在方法区的 JIT 代码缓存中,并且在翻译成本地代码的过程中可以做优化。
3. Native Interface(本地接口)
本地接口是 Java 虚拟机(JVM)提供给开发者的一种机制,用于在 Java 程序中调用本地方法。本地接口允许 Java 代码与底层的本地代码进行交互,使得 Java 程序可以调用 C、C++ 等本地语言编写的函数库或操作系统提供的功能。
本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。本地方法栈中登记 native 方法,在执行引擎执行时加载本地方法库。有声明,无实现。
本地接口允许 Java 程序调用本地方法,从而实现与底层本地代码的交互。Java 程序可以通过本地接口调用本地方法,进而调用本地函数库提供的功能,如操作系统的系统调用、硬件设备的访问等。本地接口为 Java 程序提供了与底层系统的无缝连接,极大地扩展了 Java 的应用范围。
4. Runtime data area(运行数据区)
运行时数据区是整个 JVM 的重点,所有写的程序都被加载到这里,之后才开始运行。它是 JVM 在执行 Java 程序时,用于存储和管理各种数据的内存区域,包括方法区、堆、虚拟机栈、本地方法栈和程序计数器等五大区域。
方法区存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,在 JDK 1.8 及以后版本中称为元空间。堆是所有线程共享的一块内存区域,主要用于分配和回收 Java 对象实例,几乎所有的对象都在堆上分配内存,并且是垃圾回收的主要区域。虚拟机栈是每个线程私有的,用于存储方法调用和局部变量,描述的是 Java 方法执行的内存模型。本地方法栈与虚拟机栈的作用类似,是为虚拟机调用 Native 方法服务的。程序计数器是当前线程所执行的字节码的行号指示器,是线程私有的,用于记录当前线程执行的字节码指令地址,是唯一一个不会出现内存溢出错误的区域。
三、JVM 的内存管理
JVM 的内存管理是其核心功能之一,它直接影响着 Java 程序的性能和稳定性。以下将详细介绍 JVM 的内存管理机制。
1. 内存区域划分
内存区域 | 内存区域描述 | 是否线程私有 | 主要存储内容 |
---|---|---|---|
程序计数器 | 当前线程所执行的字节码的行号指示器,JVM 中唯一不会出现内存溢出错误的区域 | 是 | 作为行号指示器独立存储各线程执行位置,互不影响 |
虚拟机栈 | 每个方法被执行时 Java 虚拟机同步创建栈帧,包含局部变量表、操作数栈等信息,可能抛出 StackOverflowError 和 OutOfMemoryError | 是 | 局部变量表存放方法中的局部变量, 操作数栈用于字节码执行时的运算, 动态链接用于在运行时找到被调用方法的实际地址, 方法出口表示方法该如何结束 |
本地方法栈 | 作用与虚拟机栈类似,为 Native 方法服务,可能抛出 StackOverflowError 和 OutOfMemoryError | 是 | |
堆 | 整个 Java 应用程序共享的区域,用于存放和管理对象和数组,是垃圾回收的主要区域,可动态扩展,扩展失败抛出 OutOfMemoryError 异常,采用分代收集算法分为新生代和老年代等区域 | 否 | 对象、数组 |
方法区(JDK 1.8 后为元空间) | 存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,垃圾回收主要目标是对常量池回收和类卸载,较难实现,动态扩展失败抛出 OutOfMemoryError 异常,运行时常量池是其一部分用于存放编译器生成的各种字面量和符号引用 | 否 | 类信息、 常量、 静态变量、 即时编译器编译后的代码 |
2. 内存分配
- 对象在堆中的分配:当一个对象被创建时,它首先进入新生代的 Eden 区。如果 Eden 区满了,就会触发 Minor GC,将 Eden 区中仍然存活的对象复制到 Survivor 区中。如果 Survivor 区也满了,或者对象在经过多次 Minor GC 后仍然存活,就会被转移到老年代中。
- 栈内存分配:Java 栈的分配是和线程绑定在一起的,当创建一个线程时,JVM 就会为这个线程创建一个新的 Java 栈。一个线程的方法的调用和返回对应着这个 Java 栈的压栈和出栈。栈中主要存放一些基本的数据类型和对象句柄(引用)。局部变量表中存储着方法相关的局部变量,其内存空间可以在编译期间就确定,运行时不再改变。
- 直接内存分配:NIO 使用 java.nio.ByteBuffer.allocateDirect () 方法分配内存,是本机内存而不是 Java 堆上的内存,增加了一次系统调用。直接 ByteBuffer 产生的数据和网络或者磁盘交互都在操作系统的内核空间中发生,不需要将数据复制到 Java 内存中,加快数据处理速度。
3. 内存分配与回收策略
- 分代收集器对象优先在 Eden 分配:新创建的对象首先在新生代的 Eden 区分配内存。
- 大对象直接进入老年代:可以通过参数调配使得大对象直接进入老年代,避免在新生代频繁进行垃圾回收。
- 长期存活的对象将进入老年代:对象在新生代经过多次垃圾回收后仍然存活,会进入老年代。
- Minor GC 与对象年龄:每次对新生代的 GC(Minor GC),存活的对象通过标记 - 复制算法复制到 Survivor 空间,并且年龄 +1,如果年龄达到老年代要求(动态年龄算法),则进入老年代。如果 Survivor 空间无法存放存活的对象,则根据分配担保机制进入其他内存空间(实际上大多是实现都是进入老年代)。
- 空间分配担保:在 Minor GC 之前,检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果大于,就能保证这次 Minor GC 是安全的;如果小于,则查看 -XX:HandlePromotionFailure 参数是否允许担保失败。如果允许,继续检查老年代最大可用空间是否大于历次晋升到老年代对象的平均大小,大于则尝试进行一次 Minor GC,分配失败再进行一次 Full GC;小于则进行一次 Full GC。如果不允许担保失败,直接进行一次 Full GC。如果分配失败,再进行一次 Full GC。
四、垃圾收集器
1. 对象失效判定算法
对象失效判定是垃圾回收的关键步骤之一,主要包括引用计数算法和可达性分析算法,以及不同引用类型对象的回收方式。
引用计数算法:每有一处引用,引用计数器加 1,每有一处引用失效,引用计数器减 1,引用计数器归 0,则该对象可以被回收。这种算法简单高效,但面对复杂场景程序需要额外的工作以保证算法有效,例如解决循环引用的问题。如以下代码所示:
public class ObjA {
private Object instance;
public void setInstance(Object instance) {
this.instance = instance;
}
}
public class ObjB {
private Object instance;
public void setInstance(Object instance) {
this.instance = instance;
}
}
public class Test {
public static void main(String[] args) {
ObjA obja = new ObjA();
ObjB objb = new ObjB();
obja.setInstance(objb);
objb.setInstance(obja);
obja = null;
objb = null;
//循环引用,如果使用引用计算算法,则两个对象的引用计算器都为1,都将无法被gc回收。
//而实际上两个对象已经没有其他引用了,永远也不会被程序调用。
}
}
可达性分析算法:通过 GC_Roots 对象向下搜索引用形成引用链,如果对象没有与任何引用链相连,该对象可被回收。可以作为 GC_Roots 的对象类型有静态属性、字符串常量、被 Native 方法引用的对象、Java 虚拟机内部的引用、同步锁持有的对象、反映 Java 虚拟机内部情况的 JMXBean 等。
不同引用类型对象的回收:
- Strongly Reference(强引用):所有基本对象默认为强引用,强引用只要 GCRoots 可达即不能被回收。
- Soft Reference(软引用):被 java.lang.ref.SoftRefenrence 对象包装的对象即为软引用对象,当因为 JVM 堆内存空间不足时触发的 gc 会将软引用包装的对象回收。
- Weak Reference(弱引用):被 java.lang.ref.WeakRefenrence 对象包装的对象即为弱引用对象,在每一次 gc 行为发生时都将被回收。
- Phantom Reference(虚引用):被 java.lang.ref.PhantomReference 包装的对象即为虚引用对象,在构造时需要传入一个 ReferenceQueue 队列对象,可以通过该对象在对象被回收时通知系统。虚引用的 get 方法返回 null,不能通过虚引用获取其包装的对象,虚引用唯一的作用是用于对象回收的系统通知。虚引用在每一次 gc 行为发生时都将被回收。
如果在构造 SoftRefenrence/WeakRefenrence/PhantomReference 时传入一个引用队列参数,对象将被回收时都将存入该队列中。可以通过如下编码进行通知:
ReferenceQueue<RefObject> queue = new ReferenceQueue<>();
new Thread(() -> {
while (true) {
Reference<? extends RefObject> reference;
if ((reference = queue.poll())!= null) {
//需要注意,此时通过reference.get()返回的都将为null
//也就是说此时对象已经被回收了
System.out.println("对象是否被回收? " + reference.get() == null);
System.out.printf("引用: %s 将被回收\n", reference);
//DOING SOMETHING
}
}
}, "monitorGcThread").start();
2. 垃圾收集算法
垃圾收集算法主要有标记 - 清除算法、标记 - 复制算法、标记 - 整理算法等,以及分代收集理论。
分代收集理论基础假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
基于这些假说,垃圾收集器设计原则是将 Java 堆分出不同的区域,然后将回收对象依据其经历垃圾收集过程的次数高低分配到不同的区域之中存储。
标记 - 清除算法:标记清除算法是大部分垃圾回收算法的基础。首先标记出所有需要回收的对象,然后回收所有被标记的对象。主要缺点是执行效率不稳定,Java 堆中有太多对象时,执行效率越差;还会产生大量不连续的内存碎片。
标记 - 复制算法:适用于对象存活率较低的垃圾回收,如新生代。将内存分为两半,每次只使用其中一半。当使用中的一半内存耗尽,进行空间清理,将存活的对象复制到另一半,清除使用中的一半内存,然后启用另一半内存。但该算法内存利用率仅为 50%,如果内存中的多数对象都是存活的,需要耗费大量的内存间复制的开销。优化后的算法将内存空间分为一个 Eden 空间和两个 Survivor 空间,提高了内存利用率。
标记 - 整理算法:适用于对象存活率较高时的垃圾回收,如老年代。标记要清理或不要清理的对象,将所有存活的对象都向内存空间一端移动,然后清理掉边界以外的内存。该算法的问题是移动存活对象并更新引用内存地址带来大量消耗,且程序需要等待这些工作完成才能正常执行;不移动对象的话会导致大量内存碎片,使得内存分配非常复杂。
3. 经典垃圾收集器
经典垃圾收集器包括 Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、Garbage First 等收集器,它们各有特点和工作流程。
Seria 垃圾收集器:最基础、历史最悠久的新生代垃圾收集器,单线程工作,简单高效但存在工作线程停顿问题,是 HotSpot 虚拟机客户端模式下的默认新生代收集器。
ParNew 垃圾收集器:Serial 垃圾收集器的多线程并行版本,激活 CMS 后默认的新生代收集器,在多逻辑核心环境下相对 Serial 稍微高效。
Parallel Scavenge 垃圾收集器:基于标记 - 复制算法实现的多线程并行收集的新生代垃圾收集器,高吞吐量,适合后台运算而不需要太多交互的分析任务。
Serial Old 垃圾收集器:Serial 垃圾收集器的老年代版本,单线程收集器,基于标记 - 整理算法。
Parallel Old 垃圾收集器:Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记 - 整理算法。
CMS (Concurrent Mark Sweep) 收集器:以获取最短回收停顿时间为目标的收集器,基于标记 - 清除算法实现,增量更新解决并发收集问题。但存在对处理器资源敏感、产生浮动垃圾、导致内存碎片等问题。GC 过程包括初始标记(stop the world)、并发标记、重新标记(stop the world)、并发清除。
Garbage First (G1) 垃圾收集器:主要面向服务端应用,可控的停顿时间,整理上是基于标记 - 整理算法,局部是基于标记 - 复制算法,是当前服务端模式下的默认垃圾收集器。原始快照解决并发收集问题。G1 收集器将连续的 Java 堆分为多个大小相等的独立区域,各个区域可以独立扮演 Eden,Survivor 空间,还有特殊的 Humongous 区域专门存储大对象。GC 过程包括初始标记(stop the world)、并发标记、重新标记(stop the world)、并发清除。
4. 低延迟垃圾收集器
Shenandoah 和 ZGC 收集器是低延迟垃圾收集器,特点和工作流程如下:
Shenandoah 垃圾收集器:非 Oracle,Oracle JDK12 + 不支持。并发垃圾标记 + 并发对象清理后的整理 = 基本全程并发 = GC <10mills,是 G1 的继承者。改进了记忆集,采用链接矩阵代替,非分代收集。GC 过程包括初始标记(短暂停顿)、并发标记、最终标记(短暂停顿)、并发清理、并发回收、初始引用更新(短暂停顿)、并发引用更新、最终引用更新(短暂停顿)、并发清理。通过读屏障和 “Brooks Pointers” 解决与用户线程的并发问题。对象头部存储一个内存地址指针,正常时指向自己,当对象移动时,旧对象的地址指向新的对象内存空间,实现间接访问对象。旧对象需要在完成引用更新后再进行删除,访问内存地址指针必须是同步操作以防止多线程内存不一致问题,Shenandoah 收集器通过 CAS 来保证并发访问。
ZGC 收集器:since JDK11 Oracle,基于 Region 内存布局,不设分代,使用读屏障、染色指针和内存多重映射等技术实现可并发的标记 - 整理算法,以低延迟为首要目标。动态创建和销毁的动态容量大小的 Region,包括小型 Region(容量固定为 2MB,存放小于 256KB 的小对象)、中型 Region(容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象)、大型 Region(容量不固定,可以动态变化,用于放置大于 4MB 的大对象,每个大型 Region 只会存放一个大对象)。GC 过程包括初始标记(短暂停顿)、并发标记、最终标记(短暂停顿)、并发预备重分配、并发重分配、并发重映射。染色指针在对象的内存地址上占用 4 个字节来判断对象是否被移动过,这也导致了 ZGC 能够管理的内存不能超过 4TB,不能支持 32 位平台,不能支持压缩指针。ZGC 在传输信息时会产生大量的 NAKACK 对象,并且有重发机制,在确定消息送达之前,数据会滞留在内存中。由于共享数据需要与多个节点通信,网络资源紧张导致大量的消息数据滞留在内存中。很快产生了内存溢出。解决方案是替换全局缓存,避免频繁的写操作,也避免了较多的缓存同步通信。ZGC 的劣势是没有分代收集导致在 GC 过程中产生大量新对象的场景将导致大量浮动垃圾,清理速度慢于对象产生速度的话将可能导致这种情况恶化。
五、虚拟机性能监控、故障处理工具
1. 基础故障处理工具
包括 jps、jstat、jinfo、jmap、jhat、jstack 等工具的功能和使用方法。
jps:虚拟机进程状况工具
- 功能:
- 列出正在运行的虚拟机进程。
- 显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID。
- 使用频率最高的 JDK 命令行工具。
- 命令格式:jps [options] [hostid]。
- options:
- -q:只输出 LVMID(虚拟机本地 ID),省略主类的名称。
- -m:输出虚拟机进程启动时传递给主类 main 函数的参数。
- -l:输出主类的全名,如果进程执行的是 JAR 包,则输出 JAR 路径。
- -v:输出虚拟机进程启动时的 JVM 参数。
- hostid:通过 RMI 协议查看在 RMI 注册表中的远程主机的进程信息等。
- options:
jstat:虚拟机统计信息监视工具
- 功能:
- 显示本地或与远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。
- 纯文本控制台环境的 JVM 定位虚拟机性能问题的常用工具。
- 命令格式:jstat [option vmid [interval [s|ms] [count]] ]。
- options:不同的选项可以展示不同的运行时数据。
- vmid:虚拟机 id,本地进程与 lvmid 一致,远程格式如下:[protocol:][//] vmid [@hostname [:port]/servername]。
- interval:查询间隔,单位毫秒。
- count:查询次数。
jinfo:Java 配置信息工具
- 功能:
- 查看 Java 虚拟机运行时的参数配置。
- 运行时修改一部分支持修改的虚拟机参数值。
- 命令格式:jinfo [option] pid。
- option:
- -flag:查询参数。
- -sysprops:查看 System.getProperties ()。
- option:
jmap:Java 内存映像工具
- 功能:
- 生成堆转储快照(一般称为 heapdump 或者 dump 文件)。
- 查询 finalize 执行队列、Java 堆和方法区的详细信息(空间使用率,当前用的哪种收集器等)。
- 堆快照:堆中的对象信息,实例数量,堆中的内存占用信息等信息。
- 命令格式:jmap [option] vmid。
- option:不同的选项可以实现不同的功能。
jhat:虚拟机堆转储快照分析工具
- 与 jmap 搭配使用。
- 功能:分析 jmap 生成的堆转储快照。
- 不推荐使用,可以用其他方式分析堆转储快照:
- 在服务器上使用 jhat 会比较占用服务器资源。
- jhat 的分析功能比较简陋。
- 推荐使用:
- VisualVM。
- Eclipse Memory Analyzer。
- IBM HeapAnalyzer。
jstack:Java 堆栈跟踪工具
- 功能:
- 用于生成虚拟机当前时刻的线程快照(threaddump/javacore 文件)。
- 通常用于定位线程出现长时间停顿的原因(如线程死锁、死循环、请求外部资源长时间挂起等)。
- 线程快照:当前虚拟机每一条线程正在执行的方法堆栈的集合。
- 命令格式:jstack [option] vmid。
- option:不同的选项可以实现不同的功能。
- 替代方案:
- 自 JDK5 起,Thread 类提供了 getAllStackTraces () 方法用于获取虚拟机所有线程的 StackTraceElement 对象,用这个方法可以完成 jstack 的大部分功能。
- 用 Thread.getAllStackTraces () 方法做一个 web 页面用于开发时查看线程堆栈信息。
2. 可视化故障处理工具
如 JHSDB、JConsole、VisualVM、JFR、JMS 等工具的功能和特点。
JHSDB:基于服务性处理的调试工具
- 集成式的可视化多功能工具箱,集成了多个 jdk 命令行工具,且功能更强大。
- 命令行模式下的使用:可以通过特定的命令行参数进行操作。
- 基于服务性代理 (Seriviceability Agent, SA) 实现的进程外调试工具。
- 对压缩指针的支持存在缺陷,64 位系统测试时可以禁用压缩指针:-XX:-UseCompressedOops。
JConsole:Java 监视与管理控制台
- Java Monitoring and Management Console。
- 基于 JMX (Java Management Extensssions) 的可视化监视、管理工具。
- 功能:
- 收集系统运行信息:
- 内存。
- 线程:线程运行情况、死锁。
- 类创建统计。
- CPU。
- 动态调整参数。
- 收集系统运行信息:
VisualVM:多合一故障处理工具
- All-in-One Java Troubleshooting Tool。
- Java 功能最强大的运行监视和故障处理程序之一。
- 基于 NetBeans 平台开发工具,可安装插件。
- 功能:
- 显示虚拟机进程以及进程的配置、环境信息:类似 jps、jinfo。
- 监视应用程序的处理器、垃圾收集、堆、方法区以及线程的信息:类似 jstat、jstack。
- dump 以及分析堆转储快照:类似 jmap、jhat。
- 方法级的程序运行性能分析:找出被调用最多、运行时间最长的方法。
- 离线程序快照:运行程序的运行时配置、线程 dump、内存 dump 等信息建立一个快照,可以将快照发送开发者进行 bug 反馈。
- 其他插件功能。
- JDK6 Update7 发布,向下兼容至 JDK1.4.2 版本。
- **JDK14 起 JDK 不再提供 visualvm,需手动下载安装 **https://visualvm.github.io/。
- 修改 visualvm.conf 设置 jdk 路径。
- 打开 visualvm.exe。
- 插件安装:
- JDK_HOME/bin 目录下找到 visualvm。
- 手动安装:
- 插件网站:https://visualvm.github.io/pluginscenters.html。
- 下载 nbm 插件包。
- 点击工具 > 插件 > 已下载菜单,指定 nbm 包安装。
- 插件安装目录:jdk9 jkd_HOME/lib/visualvm 目录下。
- 自动安装:
- 点击工具 > 插件菜单。
- 选择可用插件及已安装,勾选对应插件安装或激活即可。
- Mac 下使用 visualvm:
- https://visualvm.github.io/下载 visualvm,安装后即可使用。
- 集成 idea:
- 插件中心下载 visualvm launcher。
- 插件推荐列表:
- Btrace:动态加入原本不存在的调试代码 https://github.com/braceio/btrace。
- 打印调用堆栈、参数、返回值。
- 进行性能监视、定位链接泄漏、内存泄漏。
- 解决多线程竞争问题等。
- Btrace:动态加入原本不存在的调试代码 https://github.com/braceio/btrace。
JFR:可持续收集数据的飞行记录仪
- Java Flight Recorder。
- 内建在 HotSpot 虚拟机内的信息收集框架。
- 企业版收费,个人版免费。
- 对生产环境的网站吞吐量影响小于 1% – Zero Performance Overhead。
- 动态开启,停止。
- 飞行记录指程序运行一段时间内的信息记录:
- 一般信息:关于虚拟机、操作系统和记录的一般信息。
- 内存:关于内存管理和垃圾收集的信息。
- 代码:关于方法、异常错误、编译和类加载的信息。
- 线程:关于应用程序中线程和锁的信息。
- I/O:关于文件和套接字输入、输出的信息。
- 系统:关于正在运行 Java 虚拟机的系统、进程和环境变量的信息。
- 事件:关于记录中的事件类型的信息,可以根据线程或堆栈跟踪,按照日志或图形的格式查看。
- JFR 获取的数据粒度较细,也较为可靠。可以使用 JMS 进行统计分析。
JMS:可持续在线的 JVM 监控工具
- Java Mission Control。
- 监控 Java 虚拟机。
- 企业版收费,个人版免费。
- 基于 Eclipse RCP 框架。
- 采取 JMX 协议通信。
- 主要功能:
- 显示来自虚拟机 MBean 提供的数据。
- 展示 JFR 的数据并分析。
六、调优案例分析与实战
1. 大内存硬件上的程序部署策略
在大内存硬件环境下,程序部署可能会出现一些问题。以一个 15 万 PV / 日左右的在线文档类型网站为例,该网站服务器硬件为四路志强处理器、16GB 物理内存,操作系统为 64 位 CentOS5.4,Resin 作为 Web 服务器,软件版本选用 64 位的 JDK5,管理员启用一个虚拟机实例,将 Java 堆大小固定在 12GB。然而,运行效果并不理想,网站经常不定期出现长时间失去响应。
问题剖析:
这种情况是由垃圾收集器停顿所导致的。默认使用的是吞吐量优先收集器,回收 12GB 的 Java 堆,一次 Full GC 的停顿时间高达 14 秒。此外,由于程序设计原因,访问文档时会把文档从磁盘提取到内存中,导致内存中出现很多由文档序列化产生的大对象,这些大对象大多在分配时就直接进入了老年代,没有在 Minor GC 中被清理掉,使得内存很快被消耗殆尽。
解决方案:
目前在大内存硬件上主要有两种程序部署方式。
- 方式一:通过一个单独的 Java 虚拟机实例来管理大量的 Java 堆内存。这种方式需要考虑一些问题,如回收大块堆内存而导致的长时间停顿,虽然 G1 收集器出现后有了增量回收,但要到 ZGC 和 Shenandoah 收集器成熟后才相对彻底解决;大内存必须有 64 位虚拟机支持,但由于压缩指针、处理器缓存行容量等因素,64 位性能普遍低于 32 位虚拟机;必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生堆转储快照,就算成功了也难以分析;相同的程序在 64 位消耗的内存一般比 32 位虚拟机要大,可以开启(默认开启)压缩指针来缓解。
- 方式二:同时使用若干个虚拟机,建立逻辑集群来利用硬件资源。具体做法是在一台机器上启动多个应用服务器进程,为每个服务器进程分配不同端口,然后在前端建立一个负载均衡器,以反向代理的方式来分配访问请求。这种方式可能会遇到一些问题,如节点竞争全局资源,很难最高效率地利用某些连接池,32 位虚拟机作为集群节点会受到内存限制,大量使用本地缓存的应用在集群中会造成较大内存浪费等。
对于该案例,最终方案是调整为建立 5 个 32 位 JDK 的逻辑集群,每个进程按 2GB 计算,占用 10GB。另外建立一个 Apache 服务作为前端均衡代理,考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,处理器资源敏感度低,因此改为 CMS 收集器进行垃圾回收。
2. 集群间同步导致的内存溢出
以一个基于 B/S 的 MIS 系统为例,该系统由两台双路处理器、8GB 内存的惠普小型机组成,使用 WebLogic 部署,每台机器有 3 个 WebLogic 实例构成一个 6 节点亲和式集群。节点之间没有共享 Session,但有一些需求要实现部分数据在各个节点间共享,一开始放在数据库,后因读写频繁,影响性能。之后构建了一个全局 JBossCache 缓存,启用后不定期出现多次内存溢出问题。
问题剖析:
由于 JBossCache 在传输信息时会产生大量的 NAKACK 对象,并且有重发机制,在确定消息送达之前,数据会滞留在内存中。由于共享数据需要与多个节点通信,网络资源紧张导致大量的消息数据滞留在内存中,很快产生了内存溢出。
解决方案:
替换全局缓存,避免频繁的写操作,也避免了较多的缓存同步通信。
3. 堆外内存导致的溢出错误
以一个基于 B/S 的电子考试系统为例,测试期间发现服务端不定时抛出内存溢出异常。尝试把堆内存调到最大,基本没效果,加入 -XX:+HeapDumpOnOutOfMemoryError 参数,也没有任何反应。只好挂着 jstat 观察,发现垃圾并不频繁,内存很稳定,但就是照样不停抛出 OOM。
问题剖析:
Direct Memory 耗用的内存不算入