Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

时间:2022-12-27 15:32:19
本人的“Java虚拟机学习笔记”系列,主要是参考《深入理解Java虚拟机》和《Java虚拟机规范(Java SE 8)》两本书,算是本人的学习笔记,供大家参考,如有问题,烦请指出谢谢!


一、运行时数据区

关于什么是“运行时数据区”,两本书上说得差不多,都很模糊,个人觉得大概就是Java虚拟机中用户线程能够接触的、控制的、动态更改的内存区域。根据《Java虚拟机规范(Java SE 8)》这本书,规范定义了6个内存区域,总体结构如下图所示。

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

《深入》这本书还有提到DirectMemory直接内存,按照我的理解,这也算是运行时数据区,不过《规范》这本书中并没说。个人对此理解就是,这个只是实现上的不同,虚拟机可以不采用DirectMemory来实现某些方法、功能。

1、PC寄存器

全中文名称叫“程序计数器”,这个跟CPU中的PC寄存器是一个相似东西。当前线程正在执行的是普通的Java方法的话,PC寄存器就保存正在执行的字节码的地址;当前线程正在执行一个native method时,这个值则是undefined。Java虚拟机规范没有规定此区域能出现OOM,可以近似理解为PC寄存器这块不会有错误出现。

2、Java虚拟机栈

虚拟机栈跟汇编程序中手动控制的栈差不多,用于保存局部变量、临时结果、处理方法调用/返回。

此区域经常出现的内存错误是*Error,出现这个错误一般表明方法调用太深,比如递归方法。也有可能因为申请栈空间时可用内存不足出现OOM,不过这种比较少见,我没遇见过,模拟也没真正意义上的成功过。

虚拟机启动参数中可以通过-Xss来指定最大虚拟机栈大小,一般情况是不用更改这个的。

3、本地方法栈

跟虚拟机栈类似,不过是执行本地方法时用的栈,《深入》一书指出,可以用虚拟机栈来实现本地方法栈,这样二者就是结构一样的了。此区域也会出现*和OOM错误。

4、方法区

方法区用来存储类的结构信息,保存方法的字节码内容,类的字段、运行时常量池,此部分数据由所有线程共享,此区域也是可以不实现垃圾收集的,因此也有另外一个名字——非堆,用于跟必须实现GC的Java堆区别开来。当方法区内存空间不能满足分配需求时,会抛出OOM错误。

方法区是虚拟机规范定义的,jdk8之前的Perm Gen(永久代)和jdk8开始的MetaSpace(元空间)等等,都是实现方法区的一种具体方式。

5、运行时常量池

jclasslib打开一个字节码文件,可以看到里面有一大段,叫作Constant Pool,这就是运行时常量池。常量池主要存储一个类中各种字面信息,比如类的全限定名,方法名,方法类型,参数信息,方法引用,变量的字面值等等。此区域也会发生OOM错误,常见的出现这种错误的方式是动态代理加载了大量的类而又不卸载回收空间。

6、Java堆

虚拟机内存的大头,对象分配和垃圾收集的主要区域,堆只要是逻辑上连续的即可,物理上可以不需要连续。此区域大小可以固定,也可以动态扩展,可用空间不足时会出现OOM错误。虚拟机启动参数可以通过-Xms(初始堆大小)和-Xmx(最大堆大小)来控制这个,一般生产环境的程序都需要配置此参数。

7、直接内存

直接内存不是规范的运行时数据区,但是现在nio/nio2、各种netty及以netty为基础的非阻塞/异步框架都会使用这部分区域。一般情况下是无法直接使用的,最常见的方式是通过sun,misc.Unsafe来手动地像malloc那样申请内存。直接内存是不受堆大小控制的,使用参数-XX:DirectoryMemorySize和-XX:MaxDirectoryMemorySize来控制,如果你对程序使用了nio/netty/vertx等,可以关注下次参数。


二、Java常见内存错误

下面的demo,如果没有指出java运行环境,那么就是默认环境jdk 1.8.0_72

1、*Error

在递归调用中这个很常见

package pr.jvm.oom;

// 默认配置
public class *Demo {
private int depth = 0;
public static void main(String[] args) {
*Demo demo = new *Demo();
try {
demo.recursiveCall();
} catch (Throwable e) {
System.err.println("depth: " + demo.depth);
e.printStackTrace();
}
}

public void recursiveCall() {
depth++;
this.recursiveCall();
}
}

运行时会出现下列错误,框框中这个19226并不是固定的,运行多次会得到不一样的值,至于为什么,大牛说这个要研究虚拟机源码才能了解。我本人是没看过的,这个疑问就留待后面在解决。

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误


2、Java堆OOM

OOM并不都是因为可用空间不足,还有别的情况,比如下面这种:

package pr.jvm.oom;

// -Xmx10m
public class OutOfMemoryDemo1 {
public static void main(String[] args) {
int[] ints = new int[Integer.MAX_VALUE];
}
// 数组长度超过虚拟机限制,这种情况不会分配内存,也完全没必要去分配内存
// Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
// at pr.vm.OutOfMemoryDemo.main(OutOfMemoryDemo.java:6)
}

下面的这种则是最常见的情况,它有个很明显的标志就是会告诉你是Java heap space空间不足。造成这种一般有两个原因,可能是虚拟机参数不合理,也可能是Java程序内存泄漏导致垃圾回收后可用空间越来越小。

如果是参数不合理,改正参数后一般有很大改观,如果是内存泄漏,可以设置HeapDumpOnOOM来保留崩溃时的堆栈,使用一些工具分析。虽然最常见,但是处理这种问题的方法也是最多的。

// -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
public class OutOfMemoryDemo2 {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 1_000; i++) {
list.add(new byte[1024 * 1024]);
}
}
// 创建对象时申请的内存无法被回收,造成创建对象时无法申请到可用内存,最常见的OOM
// Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
// at pr.vm.OutOfMemoryDemo2.main(OutOfMemoryDemo2.java:10)
}

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

这种可以模拟内存泄漏,如果程序中list是那种生命周期很长(比如static)的变量并且无法修改其元素,那么这就算是一种Java内存泄漏。举个具体的例子,比如一个简单的栈,不提供按下标索引的方法,先入栈很多次,然后一次性全部出栈,如果出栈方法没有将元素引用赋值为null,那么这个栈直到销毁前都是持有那些出栈元素引用的,那些出栈的、已经没有用的元素迟迟不能被垃圾回收。

这里可以用HeapAnalyzer简单分析下。关于此工具,可以自己网上搜下,有eclipse插件,可以把堆dump文件直接拖到eclipse中打开,如果之前的虚拟机堆设置的很大,也要相应设置下工具的堆大小,用起来还是比较简单的。

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误


Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

通过此工具发现最后是一个List中每个元素太大,结合程序代码知道,最后无法为其添加的第8个元素再分配空间,出现OOM错误。


3、方法区OOM

package pr.jvm.oom;

import java.io.File;
import java.lang.management.ClassLoadingMXBean;
import java.lang.management.ManagementFactory;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;

// jdk1.8: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
// jdk1.6: -XX:PermSize=10m -XX:MaxPermSize=10m
public class OutOfMemoryDemo3 {
public static void main(String[] args) {
try {
URL url = new File("f:/").toURI().toURL();
URL[] urls = { url };
// 获取有关类型加载的JMX接口
ClassLoadingMXBean loadingBean = ManagementFactory.getClassLoadingMXBean();
// 保留类加载器的引用,相当于用于在jvm运行周期内全局缓存类加载器
// 这一步以及下面的add操作是出现oom必不可少的条件,保留了类加载器的引用,避免因为它被垃圾回收导致加载的类被卸载回收,而无法出现OOM的情况
List<ClassLoader> classLoaders = new ArrayList<ClassLoader>();
while (true) {
// 加载类型并缓存类加载器实例
ClassLoader classLoader = new URLClassLoader(urls);
classLoaders.add(classLoader);
classLoader.loadClass("Test");
// 显示数量信息(共加载过的类型数目,当前还有效的类型数目,已经被卸载的类型数目)
System.out.println("total: " + loadingBean.getTotalLoadedClassCount());
System.out.println("active: " + loadingBean.getLoadedClassCount());
System.out.println("unloaded: " + loadingBean.getUnloadedClassCount());
}
} catch (Throwable e) {
e.printStackTrace();
}
}
// 如果eclipse不能出现OOM情况,请在命令行下运行
// 不要将手动加载的类的.class文件(Test.class)放在main程序同一个目录及其子目录下
// 因为java命令运行时会自动把当前目录当作classpath目录,classpath目录默认是由AppClassLoader负责加载
// AppClassLoader是jvm内置的加载器,其在加载类的优先级比程序中手动new的一个URLClassLoader要高
// 它加载了的类是不会被URLCLassLoader再加载一遍的,放在同一个目录下就会出现类加载数目长时间不变
// 无法出现OOM情况(触发了垃圾回收等jvm内置操作会突然变化,但是之后依旧会长时间保持不变)
}
在命令行中运行结果如下
Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

可以看到是Metaspace出现了OOM,Metaspace是jdk1.8中方法区的实现。更多的关于这个问题,可以网上搜jdk8 metaspace 元空间。


在jdk1.6中使用的是Perm Gen永久代来实现的方法区,运行结果如下,错误信息是永久代出现了OOM。

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误


jdk1.7运行同一个程序结果如下,是永久代OOM,在jdk1.7中永久代还没有完全被取消,但是已经在取消的路上了,下面的第4点说明了这个。

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误


此处还有一个问题,那就是JMX获取到的类加载数目不一样:jdk1.8在OOM时加载了1411个类,jdk1.7是7870个,jdk1.6是6358个。这个数目应该跟程序运行多少次没关系,我运行了好多次这几个数目都是一样的,是jdk版本不一样导致的这个数目改变了。6到7的改变我觉得应该是这个原理:从7开始字符串池的实际存储从永久代移动到了Java堆,导致在加载一个类时的实际消耗的永久代内存变小了,永久代能够存储的类信息也变多了。但是7到8这个数目变化也太大了。关于这一问题,留待以后我研究搜寻下,暂时就给自己留个问题吧。


4、字符串常量池OOM

package pr.jvm.oom;

import java.util.ArrayList;
import java.util.List;

// jdk1.8: -Xmx40m -XX:PermSize=10m -XX:MaxPermSize=10m -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
// jdk1.7: -Xmx40m -XX:PermSize=10m -XX:MaxPermSize=10m
// jdk1.6: -Xmx40m -XX:PermSize=10m -XX:MaxPermSize=10m
// jdk1.8中-XX:PermSize=10m -XX:MaxPermSize=10m是废弃参数,运行时一开始会给出警告
public class OutOfMemoryDemo4 {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
String str = "Hello World";
try {
while (true) {
str = str + str;
list.add(str.intern());
}
} catch (Throwable e) {
System.err.println("list size: " + list.size());
e.printStackTrace();
}
}
}

三个环境下的运行结果如下:

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误


Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误

对比下可以知道从jdk7开始,字符串常量池的实际存储位置就从Perm Gen永久代转到了Java堆,同时对String.intern()进行了修改。关于String.intern()这个问题,更具体的可以网上搜下,《深入》一书上也有一个实际例子,这里就不细说了。简而言之就是,jdk6中永久代负责存储字符串常量的引用和实际存储;jdk7开始永久代只存储字符串常量的引用,实际存储放在Java堆中,这一改变导致了String.intern()执行效果和jdk1.6的不一样了;jdk8又把永久代取消,字符串常量的引用放在Metaspace,实际存储还是在Java堆中。


5、直接内存溢出

package pr.jvm.oom;

import java.lang.reflect.Field;

import sun.misc.Unsafe;

// -XX:DirectoryMemorySize=10m -XX:MaxDirectoryMemorySize=10m
// eclipse报错时,在project上右键 -> Properties -> Java Compiler -> Errors/Warnings -> Deprecated and restricted API -> 手动修改编译器错误级别
public class OutOfMemoryDemo5 {
public static void main(String[] args) {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
try {
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(1024 * 1024);
}
} catch (IllegalArgumentException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}

运行结果如下

Java虚拟机学习笔记(一)——JVM运行时数据区和常见内存错误
使用nio出现这种情况,在调整参数的同时,也要注意下程序,尽量避免一次性读取大文件/网络数据到内存中再操作,尽量使用buffered IO流一样的操作或者手动分片;对于length等必须依赖全部数据的,修改双方通信协议,增加length字段,或者多次累加求length。

整体而言直接内存的OOM错误是个比较麻烦的事,因为它不是堆内存,无法用HA等工具分析,多数还是靠经验积累。