总结
无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候, HotSpot虚拟机抛出的都是StackOverflowError异常。可是如果在允许动态扩展栈容量大小的虚拟机上,相同代码则会导致不一样的情况。
创建线程导致内存溢出
注意:下面的这个实验可能导致操作系统卡死,建议大家在虚拟机中执行
/** * VM Args:-Xss512k * * @author xx * @date 2021-08-13 */ public class JavaVMStackOOM { private void dontStop() { while (true) { } } public void stackLeakByThread() { while (true) { Thread thread = new Thread(new Runnable() { @Override public void run() { dontStop(); } }); thread.start(); } } public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } }
方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。HotSpot从JDK 7 开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代。
方法区内存溢出
方法区的主要职责是用于存放类型的相关信息,如类 名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产 生大量的类去填满方法区,直到溢出为止。虽然直接使用Java SE API也可以动态产生类(如反射时的 GeneratedConstructorAccessor和动态代理等),但在本次实验中借助了CGLib直接操作字节码运行时生成了大量的动态类。
/** * VM Args:-XX:MetaspaceSize=21m -XX:MaxMetaspaceSize=21m * * @author xx * @date 2021-08-13 */ public class JavaMethodAreaOOM { public static void main(String[] args) { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOMObject.class); enhancer.setUseCache(false); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { return proxy.invokeSuper(obj, args); } }); enhancer.create(); } } static class OOMObject { } }
输出代码
Caused by: java.lang.OutOfMemoryError: Metaspace Caused by: java.lang.OutOfMemoryError: Metaspace
常量池案例
String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加 到常量池中,并且返回此String对象的引用。在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量。
/** * @author xx * @date 2021-08-13 */ public class RuntimeConstantPoolOOM2 { public static void main(String[] args) { String str1 = new StringBuilder("计算机").append("软件").toString(); System.out.println(str1.intern() == str1); String str2 = new StringBuilder("ja").append("va").toString(); System.out.println(str2.intern() == str2); } }
这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false。产生差异的原因是,在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在 Java堆上,所以必然不可能是同一个引用,结果将返回 false。
而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返 回false,这是因为“java”这个字符串在执行String-Builder.toString()之前就已经出现过了,字符串常量 池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。
本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过 -XX:MaxDirectMemorySize
参数来指定,默认与Java堆最大值(由-Xmx指定)一致,代码越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用),因为虽然 DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是 Unsafe::allocateMemory()。
/** * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M * * @author xx * @date 2021-08-13 */ public class DirectMemoryOOM { private static final int _1MB = 1024 * 1024; public static void main(String[] args) throws Exception { Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe)unsafeField.get(null); while (true) { unsafe.allocateMemory(_1MB); } } }
输出内容:
Exception in thread "main" java.lang.OutOfMemoryError Exception in thread "main" java.lang.OutOfMemoryError at java.base/jdk.internal.misc.Unsafe.allocateMemory(Unsafe.java:616) at jdk.unsupported/sun.misc.Unsafe.allocateMemory(Unsafe.java:462) at cn.xx.jvm.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:21)
参考资料
- 《深入理解 JVM 虚拟机-第三版》 周志明