本篇博客主要针对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 | public interface GameMBean { |
定义一个MBean类实现以上的接口:
1 | public class Game implements GameMBean { |
使用JMX代理进行诊断
JMX代理可以运行在本地或运程,负责管理注册到它上面的MBean。我们会使用PlatformMbeanServer,并将Game这个MBean注册到上面。
1 | Game game = new Game(); |
获取MBean
在控制台输入jconsole来启动JConsole图形化界面,并在上面进行对MBean的控制:
- MBean的属性可以读取和写入;
- MBean的方法可以调用,并提供参数;
捕获Java堆的Dump
Java堆的dump是指某个时刻JVM内存中所有对象的快照,可以使用其来诊断内存泄漏问题、优化内存使用。Java堆的dump通常存储在二进制hprof文件中,我们可以使用jhat、JVisualVM、MAT等工具来分析该文件。
自动捕获堆的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 | MBeanServer server = ManagementFactory.getPlatformMBeanServer(); |
注意:hprof文件是不能被重写。
对象的内存布局
在这部分,我们将会查看JVM在堆内存中是如何布局对象和数据的;通常,运行时数据区的内存布局并不是JVM声明的一部分,而是交给具体的虚拟器实现者处理。对于对象和数组在内存中的布局,每个虚拟机实现者拥有不同的策略,本部分只针对HotSpot虚拟机。
OOP(Ordinary Object Pointer)
HotSpot虚拟机使用OOP这种数据结构来表示指向对象的指针;JVM中所有的指针都是基于一种特殊的数据结构,即oopDesc。每个oopDesc使用下面的信息来描述指针:
markWord(c++编写的类);Klass(c++编写的类):可能被压缩;
markWord描述对象的头部(header),HotSpot虚拟机使用这个markWord来存储identity hash code,biased locking pattern,locking information和GC metadata等。markWord的状态只包含uintptr_t,因此,它的大小在32位机器上是4个字节,而在64位机器上是8个字节。而且,偏向对象(biased objects)和普通对象(normal objects)的markWord是不同的。但是,我们只需考虑普通对象,因为Java15打算舍弃偏向锁。
Klass封装了语言级别的类信息,如类名,类修饰符,父类信息等。
Java中的普通对象,使用instanceOop来表示,它的对象头部包含markWord和Klass,外加可能的对齐padding。头部之后,可能有零个或多个实例变量的引用。所以,在64位机器上至少占有16个字节,因为markWord四个字节,Klass四个字节,另四个字节用于padding。
数组,使用arrayOop表示,它的对象头部除了markWord、Klass和padding外,还有四个字节用于表示数据长度。
检查内存布局
使用jol-core依赖项来检测JVM中对象的布局:
1 | <dependency> |
首先运行下面的代码来查看虚拟机的基本信息:
1 | System.out.println(VM.current().details()); |
以上表明,引用类型占据4字节,boolean和byte占据1字节,short和char占据2字节,int和float占据4字节,long和double占据8字节;如果这些类型用作数据元素的时候,它们占据相同的内存空间。
如果通过-XX:-UseCompressedOops来禁止压缩的引用,只有引用类型的大小改为8字节:
1 | # Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] |
下面看一个简单的类:
1 | public class SimpleInt { |
打印该类的内存布局:
1 | System.out.println(ClassLayout.parseClass(SimpleInt.class).toPrintable()); |
控制台显示:
1 | OFFSET SIZE TYPE DESCRIPTION VALUE |
从上面可以看出,对象头部占有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 | SimpleInt simpleInt = new SimpleInt(); |
控制台的输出:
1 | OFFSET SIZE TYPE DESCRIPTION VALUE |
当前,markWord似乎没有存储任何重要的东西,但是,如果调用System.identityHashCode()或Object.hashCode(),那么情况就会发生改变:
1 | System.out.println("identity hash code : " + System.identityHashCode(simpleInt)); |
控制台输出:
1 | identity hash code : 1956725890 |
HotSpot虚拟机存储identity hashcode作为82 44 a1 74,记住,虚拟机存储这个值是以little-endian的格式,因此十进制1956725890恢复的话,是74 a1 44 82.
对齐Alignment
默认虚拟机会添加足够的padding到对象上,以使对象的大小是8的倍数,例如:
1 | public class SimpleLong { |
解析一下内存布局:
1 | System.out.println(ClassLayout.parseClass(SimpleLong.class).toPrintable()); |
控制台的打印为:
1 | com.vidots.stock.jml.SimpleLong object internals: |
从上面看出,对象的头部和long类型的字段state一共消耗20字节,为了使得这个大小是8字节的倍数,虚拟机添加了4字节的padding。我们可以通过-XX:ObjectAlignmentInBytes来改变默认的对齐大小。例如,对于上面的SimpleLong类,设置-XX:ObjectAlignmentInBytes=16选项的话,内存布局为:
1 | com.vidots.stock.jml.SimpleLong object internals: |
对象的头部和long类型变量仍然消耗20字节,所以添加12个字节来让它是16的倍数;虚拟机添加4个内部的padding字节来,接着才是long类型变量,然后在long类型变量后面添加8个外部的字节。
字段调序
当一个类有多个字段时,虚拟机可能会对这些字段进行调整,例如:
1 | public class MultiFields { |
字段声明顺序和内存布局中的顺序是不同的:
1 | com.vidots.stock.jml.MultiFields object internals: |
这样的目的是为了减少padding的浪费。
锁
虚拟机在markWord中也存储了锁相关信息,对于下面的类:
1 | public class Lock() {} |
创建一个实例,那么该实例的内存布局如下:
1 | com.vidots.stock.jml.Lock object internals: |
如果对该实例进行加锁,内存布局为:
1 | com.vidots.stock.jml.Lock object internals: |
可以看出,当握住monitor锁时,markWord的bit-pattern就会发生变化。
对象存活时间
为了促使一个对象进入年老代,虚拟机需要跟踪每个对象的存活次数,虚拟机也是在markWord中保存这个信息的。
为了模拟minor GC,我们会创建许多垃圾;并且将一个对象分配给volatile变量,目的是防止JIT编译器可能执行的死码消除。
注:死码消除(Dead code elimination)是一种编译器原理中编译最优化技术,它的用途是移除对程序运行结果没有任何影响的代码。
1 | public class MinorGC { |
每次一个存活对象的地址发生变化的时候,可能是因为minor GC和survivor space之间的移动。从控制台的打印来看,对象的地址发生了变化。
数组
数组长度也是数组oop的一部分,例如下面的代码:
1 | boolean[] booleans = new boolean[3]; |
控制台的打印:
1 | OFFSET SIZE TYPE DESCRIPTION VALUE |
对象的头部占有16字节,包含8字节的markWord,4字节的Klass,4字节的长度,后面就是3字节的含有3个元素的数组。