JVM
虚拟机内存模型
程序寄存器
pc register (program counter): 一个包含当前时刻指令的地址的寄存器,程序寄存器区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError
情况的区域
虚拟机栈
栈会抛出两种异常:StackOverflowError
和 OutOfMemoryError
,在 HotSpot 虚拟机栈中,可以使用参数 -Xss1M
来设置栈的大小为 1MB。随着调用函数参数的增加和局部变量的增加,单次函数调用对栈空间的需求也会增加,因此栈的最大递归次数不是一成不变的。函数嵌套调用的次数由栈的大小决定:栈越大,函数嵌套调用次数越多;对一个函数而言,它的参数越多,内部局部变量越多,它的栈帧就越大,其嵌套调用次数就会越少。
- 局部变量表是存放方法参数和局部变量的区域。相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则在 index [O] 位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的
STORE
指令就是将操作栈申计算完成的局部变量写回局部变量表的存储空间内。 - 操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写人和提取信息。 JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。
- 每个栈中包含一个在常量池中对当前方法的引用 , 目的是支持方法调用过程的动态连接。
- 方法执行时有两种退出情况。第一, 正常退出,即正常执行到任何方法的返回字节码指令 , 如 RETURN 、 IRETURN 、 ARETURN 等,第二 , 异常退出。无论何种退出情况,都将返回至方法当前被调用的位置。
本地方法栈
与 stack 一样,同样抛出两种异常:StackOverflowError
和 OutOfMemoryError
。在 sun 的 HOT SPOT 虚拟机中,不区分本地方法栈和虚拟机栈。
元数据区
在 JDK7 及之前的版本中,只有 Hotspot 才有 Perm 区,译为永久代 , 它在启动时固定大小,很难进行调优,并且 FGC 时会移动类元信息。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM 。为了解决该问题 , 需要设定运行参数 -XX: MaxPermSize= 1280m
,如果部署到新机器上,往往会因为 NM 参数没有修改导致故障再现。
区别于永久代 , 元空间在本地内存中分配。在 JDK8 里, Perm 区 中的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内。
在 Java 8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制。
如果不指定元数据区大小的话,默认情况下,虚拟机会耗尽所有的可用系统内存。-XX:MaxMetaspaceSize
可以指定元数据区大小。
String 字符串哪个区
Metaspace 元数据区中的常量池里面。
Perm 为啥被淘汰
- 移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。
- 永久代内存经常不够用或发生内存溢出,爆出异常
java.lang.OutOfMemoryError: PermGen
。这是因为在 JDK1.7 版本中,指定的PermGen
区大小为 8M,由于PermGen
中类的元数据信息在每次FullGC
的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有,为PermGen
分配多大的空间很难确定,它的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。
堆
堆分成两大块 新生代和老年代。对象产生之初在新生代 , 步入暮年时进入老年代 , 但是老年代也接纳在新生代无法容纳的超大对象。新生代 = 1 个 Eden 区 + 2 个Survivor 区。绝大部分对象在 Eden 区生成 , 当 Eden 区装填满的时候 , 会触发 Young Garbage Collection , 即 YGC 。垃圾回收的时候 , 在 Eden 区实现清除策略 , 没有被引用的对象则直接回收。依然存活的对象会被移送到 Survivor 区 , 这个区真是名副其实的存在。 Survivor 区分为 S0 和 S1 两块内存空间 , 送到哪块空间呢?每次 YGC 的时候, 它 们 将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除 , 交换两块空间的使用状态。如果 YGC 要移送的对象大于 Survivor 区容量的上限 ,则直接移交给老年代。假如一些没有进取心的对象以为可以一直在新生代的 Survivor 区交换来交换去,那就错了。每个对象都有一个计数器,每次 YGC 都会加 1 。-XX:MaxTenuringThreshold
参数能配置计数器的值到达某个阐值的时候 , 对象从新生代晋升至老年代。如果该参数配置为 I ,那么从新生代的 Eden 区直接移至老年代。默认值是 15
, 可以在 Survivor 区交换 14 次之后 , 晋升至老年代。
对象与简要分配流程图:
所有对象都是分配在堆上吗?
Java 7 开始支持对象的栈分配和逃逸分析机制,这样的机制能够将堆分配对象变成栈分配对象:
void myMethod() {
V v = new V();
// use v
v = null;
}
-server
:server
模式下,才可以启用逃逸分析-XX:DoEscapeAnalysis
: 启用逃逸分析
直接内存
使用 NIO 之后,直接内存的使用变得非常普遍,直接内存跳过了 Java 堆,可以直接访问原生堆空间。直接内存适合申请次数少、访问较为频繁的场合。如果需要频繁申请,则并不适合使用直接内存。
-XX:MaxDirectMemorySize
: 最大可用直接内存,默认为-Xmx
对象的内存布局
每个 Java 对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类。
在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节。以 Integer 类为例,它仅有一个 int 类型的私有字段,占 4 个字节。因此,每一个 Integer 对象的额外内存开销至少是 400%。这也是为什么 Java 要引入基本类型的原因之一。
// Maven 库 com.carrotsearch:java-sizeof:0.0.5
// 16 Bytes = 8 Bytes (标记字段) + 4 Bytes (压缩指针) = 12 Bytes 对齐到 8 的倍数 = 16 Bytes
RamUsageEstimator.sizeOf(new Object());
为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针 的概念(对应虚拟机选项 -XX:+UseCompressedOops
,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32 位的。
默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 (-XX:ObjectAlignmentInBytes
) 的倍数 (如果一个对象实际占用 17 直接,那么最终也会分配 24 字节)。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)。
class Point {
int x; // 4
int y; // 4
char z; // 2
}
// 24 Bytes = (8 Bytes 标记字段 + 4 Bytes 引用指针) +
// 4 Bytes + 4 Bytes + 2 Bytes = 22 Bytes 对齐到 8 的倍数 = 24 Bytes
RamUsageEstimator.sizeOf(new Point());
内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。
对于对象数组的内存大小的计算方式如下:
Object[] objArray = new Object[5];
for (int i = 0; i < objArray.length; i++) {
objArray[i] = new Object();
}
// (NUM_BYTES_ARRAY_HEADER (16 Bytes) + NUM_BYTES_OBJECT_REF (4 Bytes) * 数组长度 5) = 36 Bytes
// 36 Bytes 需要对齐到 40 Bytes
//
// 内部 5 个 Object,每个 Object 是 16 Bytes,5 个 Bytes 总共 80 Bytes
//
// 总大小 = 40 Bytes + 80 Bytes = 120 Bytes
RamUsageEstimator.sizeOf(objArray);
对于基础类型的数组的内存大小计算方式如下:
char[] charArray = new char[5];
for (int i = 0; i < charArray.length; i++) {
charArray[i] = 'c';
}
// NUM_BYTES_ARRAY_HEADER (16 Bytes) +
// NUM_BYTES_CHAR (2 Bytes) * arr.length (5)
// = 26 Bytes --> 对齐到 32 Bytes
RamUsageEstimator.sizeOf(charArray);
字段重排列,Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 8 还引入了一个新的注释 @Contended,用来解决对象字段之间的虚共享(false sharing)问题。这个注释也会影响到字段的排列。虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。
静态变量
在 Java 8 之前,静态变量存储在永久区(方法区),永久区用来存储:
- Class 级别的数据:元信息
- Interned 字符串
- 静态变量
Java 8 之后,静态变量存储在堆中。
反射
在 Web 开发中,我们经常能够接触到各种可配置的通用框架。为了保证框架的可扩展性,它们往往借助 Java 的反射机制,根据配置文件来加载不同的类。举例来说,Spring 框架的依赖反转(IoC),便是依赖于反射机制。
Method.invoke()
有三种实现方式:
- ➡ 委派实现 ➡ 本地方法
- ➡ 本地方法实现
- ➡ 字节码直接使用
invoke
指令来调用目标方法。动态实现和本地实现相比,其运行效率要快上 20 倍,这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但生成字节码十分耗时。
考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold=
来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation。
反射调用的 Inflation 机制是可以通过参数(-Dsun.reflect.noInflation=true
)来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。
方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object 数组_循环外新建,基本类型的自动装箱、拆箱 (扩大缓存范围 -Djava.lang.Integer.IntegerCache.high=128
_或者循环外缓存 Integer
对象),还有最重要的方法内联。
在 VM options 加入参数: -XX:AutoBoxCacheMax=7777
,可以设置最大缓存值 7777。
五种指令
Java 字节码中与调用相关的指令共有五种:
invokestatic
:用于调用静态方法。Java 虚拟机明确要求方法调用需要提供目标方法的类名。invokespecial
:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。Java 虚拟机明确要求方法调用需要提供目标方法的类名。invokevirtual
:用于调用非私有实例方法。Java 虚拟机明确要求方法调用需要提供目标方法的类名。invokeinterface
:用于调用接口方法。Java 虚拟机明确要求方法调用需要提供目标方法的类名。invokedynamic
:用于调用动态方法。Java 7。该指令的调用机制抽象出调用点这一个概念,并允许应用程序将调用点链接至任意符合条件的方法上。Java 7 引入了更加底层、更加灵活的方法抽象 :方法句柄(MethodHandle)。方法句柄是一个强类型的、能够被直接执行的引用。它仅关心所指向方法的参数类型以及返回类型,而不关心方法所在的类以及方法名。方法句柄的权限检查发生在创建过程中,相较于反射调用节省了调用时反复权限检查的开销。
JVM 内存
JVM 堆外内存泄露
文章来自美团技术团队的《Spring Boot 引起的堆外内存泄露排查及经验总结》
使用 Java 层面的工具定位内存区域(堆内内存、Code 区域或者使用 unsafe.allocateMemory
和 DirectByteBuffer
申请的堆外内存),笔者在项目中添加 -XX:NativeMemoryTracking=detailJVM
参数重启项目,使用命令 jcmd pid VM.native_memory detail
查看到的内存分布。
发现命令显示的 committed
的内存小于物理内存,因为 jcmd
命令显示的内存包含堆内内存、Code 区域、通过 unsafe.allocateMemory
和 DirectByteBuffer
申请的内存,但是不包含其他 Native Code(C 代码)申请的堆外内存。所以猜测是使用 Native Code 申请内存所导致的问题。
为了防止误判,笔者使用了 pmap
查看内存分布,发现大量的 64M 的地址;而这些地址空间不在 jcmd
命令所给出的地址空间里面,基本上就断定就是这些 64M 的内存所导致。
因为笔者已经基本上确定是 Native Code 所引起,而 Java 层面的工具不便于排查此类问题,只能使用系统层面的工具去定位问题。gperftools
的监控如下:
从上图可以看出:使用 malloc
申请的的内存最高到 3G 之后就释放了,之后始终维持在 700M-800M。笔者第一反应是:难道 Native Code 中没有使用 malloc
申请,直接使用 mmap/brk
申请的?(gperftools
原理就使用动态链接的方式替换了操作系统默认的内存分配器(glibc
)。)
因为使用 gperftools
没有追踪到这些内存,于是直接使用命令 “strace -f -e ”brk,mmap,munmap” -p pid
” 追踪向 OS 申请内存请求,但是并没有发现有可疑内存申请。
因为使用 strace
没有追踪到可疑内存申请;于是想着看看内存中的情况。就是直接使用命令 gdp -pid pid
进入 GDB 之后,然后使用命令 dump memory mem.binstartAddress endAddressdump
内存,其中 startAddress
和 endAddress
可以从 /proc/pid/smaps
中查找。然后使用 strings mem.bin
查看 dump
的内容。
从内容上来看,像是解压后的 JAR 包信息。读取 JAR 包信息应该是在项目启动的时候,那么在项目启动之后使用 strace
作用就不是很大了。所以应该在项目启动的时候使用 strace
,而不是启动完成之后。
项目启动使用 strace
追踪系统调用,发现确实申请了很多 64M 的内存空间,截图如下:
使用该 mmap
申请的地址空间在 pmap
对应如下:
strace
命令已经显示申请内存的线程的 ID,直接使用 jstack pid
去查看线程栈,找到对应的线程(注意 10 进制和 16 进制的转换),最终找到问题:MCC (美团统一配置中心)使用了 Reflections 进行扫包,底层使用了 Spring Boot 去加载 JAR,解压 JAR 需要使用 Inflater 类,需要用到堆外内存。使用 BTrace
追踪这个类,发现没有配置包路径,默认扫描所有包,于是修改代码指定包路径上线问题解决。
MCC 扫包的默认配置是扫描所有的 JAR 包。在扫描包的时候,Spring Boot 不会主动去释放堆外内存,导致在扫描阶段,堆外内存占用量一直持续飙升。当发生 GC 的时候,Spring Boot 依赖于 finalize
机制去释放了堆外内存;但是 glibc 为了性能考虑,并没有真正把内存归返到操作系统,而是留下来放入内存池了,导致应用层以为发生了“内存泄漏”。所以修改 MCC 的配置路径为特定的 JAR 包,问题解决。笔者在发表这篇文章时,发现 Spring Boot 的最新版本(2.0.5.RELEASE)已经做了修改,在 ZipInflaterInputStream
主动释放了堆外内存不再依赖 GC;所以 Spring Boot 升级到最新版本,这个问题也可以得到解决。