Java应用的诊断与监控

本篇博客主要针对Java虚拟机的内存,分别从JMX的使用、捕获Java堆的dump以及使用JOL库来查看对象的内存布局。

JMX的基本介绍

JMX(Java Management Extensions)框架可以用来管理本地或远程的Java程序,该框架引入MBean的概念,用于实时地管理应用。本部分介绍如何创建基本的MBean,并通过JConsole来对其管理。

JMX的架构

JMX遵循三层架构:

  • 诊断层(Instrumentation Layer):在该层上,将MBean注册到JMX代理上,通过MBean来管理资源;
  • JMX代理层(JMX agent Layer):该层提供核心组件(MbeanServer)用于维护所管理的MBean的注册中心;
  • 远程管理层(Remote management Layer):使用客户端工具,例如JConsole

创建MBean类

创建MBean类的时候需要遵循一种特定的模式:MBean类必须要实现一个接口,该接口的名字规则是”类名+MBean”。

首先定义一个MBean接口:

1
2
3
4
5
public interface GameMBean {
public void playFootball(String clubName);
public String getPlayName();
public void setPlayerName(String playerName);
}

定义一个MBean类实现以上的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Game implements GameMBean {

private String playerName;

public void playFootball(String clubName) {
System.out.println(this.playerName + " playing football for " + clubName);
}

public String getPlayName() {
System.out.println("Return playName " + this.playerName);
return playerName;
}

public void setPlayerName(String playerName) {
System.out.println("Set playerName to value " + playerName);
this.playerName = playerName;
}
}

使用JMX代理进行诊断

JMX代理可以运行在本地或运程,负责管理注册到它上面的MBean。我们会使用PlatformMbeanServer,并将Game这个MBean注册到上面。

1
2
3
4
5
6
7
8
9
10
11
12
Game game = new Game();
try {
ObjectName objectName = new ObjectName("com.vidots.stock:type=basic,name=game");
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(game, objectName);
} catch (MalformedObjectNameException | InstanceAlreadyExistsException | MBeanRegistrationException | NotCompliantMBeanException e) {
e.printStackTrace();
}
// 这里使用死循环是为了测试
while (true) {

}

获取MBean

在控制台输入jconsole来启动JConsole图形化界面,并在上面进行对MBean的控制:

  • MBean的属性可以读取和写入;
  • MBean的方法可以调用,并提供参数;

捕获Java堆的Dump

Java堆的dump是指某个时刻JVM内存中所有对象的快照,可以使用其来诊断内存泄漏问题、优化内存使用。Java堆的dump通常存储在二进制hprof文件中,我们可以使用jhatJVisualVMMAT等工具来分析该文件。

自动捕获堆的dump(推荐)

Java提供了HeapDumpOnOutOfMemoryError命令行选项,当抛出java.lang.OutOfMemoryError时候就会产生堆的dump;默认产生的java_pid.hprof文件存放在运行应用的目录。如果想指定文件或目录,可以使用HeapDumpPath选项。

1
java -XX:+HeapDumpOnOutOfMemoryError --XX:HeapDumpPath=/tmp/dump.hprof

jmap

格式:

1
jmap -dump:[live],format=b,file=<file-path> <pid>
  • live:如果设置的话,只会打印出存活的对象,而忽略那些会被垃圾回收的对象;
  • file-path:指定产生的dump的路径;
  • pid:Java进程id;

使用示例:

1
jmap -dump:live,format=b,file=/tmp/dump.hprof  12587

jcmd

jcmd的工作原理是它向JVM发送命令请求,必须在Java进程运行的机器上使用它。格式:

1
jcmd <pid> GC.heap_dump <file-path>
  • file-path:指定产生的dump的路径;
  • pid:Java进程id;

使用示例:

1
jcmd 12587 GC.heap_dump /tmp/dump.hprof

JVisualVM

JVisualVM是一个图形化用户界面,帮助监控、诊断Java应用,它有个选项,可以用来捕获堆的dump。操作方式:右击一个Java进程,选择Heap Dump选项。

JMX

使用HotSpotDiagnostic,这个MBean提供了dumpHeap方法,接收两个参数:

  • outputFile:dump存放的文件路径;
  • live:值是true或false,是否只会存活的对象进行dump;

打开JConsole,连接到指定的Java进程,选择com.sun.management - HotSpotDiagnostic - 操作 - dumpHeap

编程方式

在Java代码中调用HotSpotDiagnostic:需要获取MBeanServer实例来获取MBean,即获取HotSpotDiagnosticMXBean,然后调用它的dumpHeap方法。

1
2
3
4
5
MBeanServer server = ManagementFactory.getPlatformMBeanServer();

HotSpotDiagnosticMXBean mxBean = ManagementFactory.newPlatformMXBeanProxy(server, "com.sun.management:type=HotSpotDiagnostic", HotSpotDiagnosticMXBean.class);

maxBean.dumpHeap(filePath, live);

注意:hprof文件是不能被重写。

对象的内存布局

在这部分,我们将会查看JVM在堆内存中是如何布局对象和数据的;通常,运行时数据区的内存布局并不是JVM声明的一部分,而是交给具体的虚拟器实现者处理。对于对象和数组在内存中的布局,每个虚拟机实现者拥有不同的策略,本部分只针对HotSpot虚拟机。

OOP(Ordinary Object Pointer)

HotSpot虚拟机使用OOP这种数据结构来表示指向对象的指针;JVM中所有的指针都是基于一种特殊的数据结构,即oopDesc。每个oopDesc使用下面的信息来描述指针:

  • markWord(c++编写的类);
  • Klass(c++编写的类):可能被压缩;

markWord描述对象的头部(header),HotSpot虚拟机使用这个markWord来存储identity hash codebiased locking patternlocking informationGC metadata等。markWord的状态只包含uintptr_t,因此,它的大小在32位机器上是4个字节,而在64位机器上是8个字节。而且,偏向对象(biased objects)和普通对象(normal objects)的markWord是不同的。但是,我们只需考虑普通对象,因为Java15打算舍弃偏向锁。

Klass封装了语言级别的类信息,如类名,类修饰符,父类信息等。

Java中的普通对象,使用instanceOop来表示,它的对象头部包含markWordKlass,外加可能的对齐padding。头部之后,可能有零个或多个实例变量的引用。所以,在64位机器上至少占有16个字节,因为markWord四个字节,Klass四个字节,另四个字节用于padding

数组,使用arrayOop表示,它的对象头部除了markWordKlasspadding外,还有四个字节用于表示数据长度。

检查内存布局

使用jol-core依赖项来检测JVM中对象的布局:

1
2
3
4
5
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.14</version>
</dependency>

首先运行下面的代码来查看虚拟机的基本信息:

1
2
3
4
5
6
7
8
9
10
System.out.println(VM.current().details());

控制台打印出以下的内容:

# Running 64-bit HotSpot VM.
# Using compressed oop with 0-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

以上表明,引用类型占据4字节,boolean和byte占据1字节,short和char占据2字节,int和float占据4字节,long和double占据8字节;如果这些类型用作数据元素的时候,它们占据相同的内存空间。

如果通过-XX:-UseCompressedOops来禁止压缩的引用,只有引用类型的大小改为8字节:

1
2
# Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

下面看一个简单的类:

1
2
3
public class SimpleInt {
private int state;
}

打印该类的内存布局:

1
System.out.println(ClassLayout.parseClass(SimpleInt.class).toPrintable());

控制台显示:

1
2
3
4
5
OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
0 12 (object header) N/A
12 4 int SimpleInt.state N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

从上面可以看出,对象头部占有12字节,包括8字节mark和4字节klass,后面还有4字节用于int类型的字段state。这个类的任何对象将会消耗16字节的空间。

Identity Hash Code

所有的Java对象都有hashCode()方法,当我们没有为一个类声明hashCode()方法时,Java会使用identity hash code作为该方法的返回值。对象的identity hash code在生命周期中不会发生改变,HotSpot虚拟机会在markWord中存储它的值。

运行下面的代码来获取一个对象实例的内存布局:

1
2
SimpleInt simpleInt = new SimpleInt();
System.out.println(ClassLayout.parseInstance(simpleInt).toPrintable());

控制台的输出:

1
2
3
4
5
6
7
OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
12 4 int SimpleInt.state 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

当前,markWord似乎没有存储任何重要的东西,但是,如果调用System.identityHashCode()Object.hashCode(),那么情况就会发生改变:

1
2
System.out.println("identity hash code : " + System.identityHashCode(simpleInt));
System.out.println(ClassLayout.parseInstance(simpleInt).toPrintable());

控制台输出:

1
2
3
4
5
6
7
8
9
identity hash code : 1956725890
com.vidots.stock.jml.SimpleInt object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 82 44 a1 (00000001 10000010 01000100 10100001) (-1589345791)
4 4 (object header) 74 00 00 00 (01110100 00000000 00000000 00000000) (116)
8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
12 4 int SimpleInt.state 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

HotSpot虚拟机存储identity hashcode作为82 44 a1 74,记住,虚拟机存储这个值是以little-endian的格式,因此十进制1956725890恢复的话,是74 a1 44 82.

对齐Alignment

默认虚拟机会添加足够的padding到对象上,以使对象的大小是8的倍数,例如:

1
2
3
public class SimpleLong {
private long state;
}

解析一下内存布局:

1
System.out.println(ClassLayout.parseClass(SimpleLong.class).toPrintable());

控制台的打印为:

1
2
3
4
5
6
7
com.vidots.stock.jml.SimpleLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 8 long SimpleLong.state N/A
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

从上面看出,对象的头部和long类型的字段state一共消耗20字节,为了使得这个大小是8字节的倍数,虚拟机添加了4字节的padding。我们可以通过-XX:ObjectAlignmentInBytes来改变默认的对齐大小。例如,对于上面的SimpleLong类,设置-XX:ObjectAlignmentInBytes=16选项的话,内存布局为:

1
2
3
4
5
6
7
8
com.vidots.stock.jml.SimpleLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 8 long SimpleLong.state N/A
24 8 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 4 bytes internal + 8 bytes external = 12 bytes total

对象的头部和long类型变量仍然消耗20字节,所以添加12个字节来让它是16的倍数;虚拟机添加4个内部的padding字节来,接着才是long类型变量,然后在long类型变量后面添加8个外部的字节。

字段调序

当一个类有多个字段时,虚拟机可能会对这些字段进行调整,例如:

1
2
3
4
5
6
7
public class MultiFields {
private boolean first;
private char second;
private double third;
private int fourth;
private boolean fifth;
}

字段声明顺序和内存布局中的顺序是不同的:

1
2
3
4
5
6
7
8
9
10
11
com.vidots.stock.jml.MultiFields object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int MultiFields.fourth N/A
16 8 double MultiFields.third N/A
24 2 char MultiFields.second N/A
26 1 boolean MultiFields.first N/A
27 1 boolean MultiFields.fifth N/A
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

这样的目的是为了减少padding的浪费。

虚拟机在markWord中也存储了锁相关信息,对于下面的类:

1
public class Lock() {}

创建一个实例,那么该实例的内存布局如下:

1
2
3
4
5
6
7
8
com.vidots.stock.jml.Lock object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

如果对该实例进行加锁,内存布局为:

1
2
3
4
5
6
7
8
com.vidots.stock.jml.Lock object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 18 f1 e8 02 (00011000 11110001 11101000 00000010) (48820504)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以看出,当握住monitor锁时,markWordbit-pattern就会发生变化。

对象存活时间

为了促使一个对象进入年老代,虚拟机需要跟踪每个对象的存活次数,虚拟机也是在markWord中保存这个信息的。

为了模拟minor GC,我们会创建许多垃圾;并且将一个对象分配给volatile变量,目的是防止JIT编译器可能执行的死码消除。

注:死码消除(Dead code elimination)是一种编译器原理中编译最优化技术,它的用途是移除对程序运行结果没有任何影响的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MinorGC {

volatile Object consumer;

public void testLock() {
Object instance = new Object();
long lastAddr = VM.current().addressOf(instance);
ClassLayout layout = ClassLayout.parseInstance(instance);

for (int i = 0; i < 10_000; i++) {
long currentAddr = VM.current().addressOf(instance);
if (currentAddr != lastAddr) {
System.out.println(layout.toPrintable());
}

for (int j = 0; j < 10_000; j++) {
consumer = new Object();
}

lastAddr = currentAddr;
}
}
}

每次一个存活对象的地址发生变化的时候,可能是因为minor GC和survivor space之间的移动。从控制台的打印来看,对象的地址发生了变化。

数组

数组长度也是数组oop的一部分,例如下面的代码:

1
2
boolean[] booleans = new boolean[3];
System.out.println(ClassLayout.parseInstance(booleans).toPrintable());

控制台的打印:

1
2
3
4
5
6
7
8
9
 OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 00 00 20 (00000101 00000000 00000000 00100000) (536870917)
12 4 (object header) 03 00 00 00 (00000011 00000000 00000000 00000000) (3) # 数组长度
16 3 boolean [Z.<elements> N/A
19 5 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 5 bytes external = 5 bytes total

对象的头部占有16字节,包含8字节的markWord,4字节的Klass,4字节的长度,后面就是3字节的含有3个元素的数组。