我是靠谱客的博主 诚心向日葵,最近开发中收集的这篇文章主要介绍《深入理解Java虚拟机》第三版 第二章(3),觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

之前总结JVM内存区域时提及相关区域及发生在其上的OutOfMemoryError异常,下面将依照除程序计数器(这个区域并没有规定OOM异常)外发生OOM的相关原因、示例代码和解决方法,为以后遇到相关问题时解决提供简单的预习。

实战:OOM异常

在《Java虚拟机规范》中描述了两种异常:
1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出SOF异常。
2)如果虚拟机的占内存允许动态扩展,当可以栈占内存无法申请到足够的内存时,将抛出OOM异常。
But,《规范》中明确允许Java虚拟机实现自行选择是否支持站的动态扩展,而Oracle目前使用的HotSpot的选择是不可拓展,所以除非在创建线程申请内存是就因无法获得足够的内存而出现OOM异常,否则在线程运行时是不会因为扩展而导致内存溢出,只会因为栈容量无法容纳新的栈帧而导致SOF异常
实例代码中注释了执行时需要设置的虚拟机参数(注释中“VM Args”后面跟着的参数),这些参数对实验的结果有直接影响。

1、Java堆溢出

Java堆中存储对象实例,当GC Roots到对象的可用路径避免垃圾回收机制清除对象时,只要不断创建对象,随着对象的增加,总容量触及最大堆内存容量限制后将产生内存溢出异常(OOM Error)。
以下代码规定了一个不可拓展的大小为20MB的Java堆(调整堆的最小值-Xms参数与最大值-Xmx参数甚至为一样即可避免对自动拓展),通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。
Java堆内存溢出异常异常测试:

/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* @author zzm
*/
public class Demo001 {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse MemoryAnalyzer)对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(MemoryOverflow)。
如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
以上是处理Java堆内存问题的简略思路,处理这些问题所需要的知识、工具与经验是后面的主题,后续将针对具体的虚拟机实现、具体的垃圾收集器和具体的案例来进行分析,这里就先暂不展开。

2、虚拟机栈和本地方法栈溢出

由于HotSpot并不区分虚拟机栈和本地方法栈,所以栈容量只能通过-Xss参数设置(设置本地方法栈的-Xoss参数并不起作用),结合《规范》中的两种异常定义,进行以下两个实验:

  • 使用-Xss参数减少栈内存容量
  • 结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
    代码如下:
/**
* VM Args:-Xss256k
* @author zzm
*/
class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}

结果如下:

Exception in thread "main" java.lang.StackOverflowError

不同版本JVM和操作系统不同,栈容量最小值会有限制,在64位Windows下的JDK 11(我本人使用的),-Xss不能低于180k,小于此限制将会给出提示。

  • 定义大量本地变量,增加此方法帧中本地变量表的长度
  • 结果:抛出SOF异常,异常出现时输出的堆栈深度相应缩小。

第二种方法的代码繁杂,主要是用户自行定义的本地变量太多,导致SOF,代码不再展示,但两种方法的结果相同。
实验证明,栈帧太大或者栈容量太小,导致新的栈帧无法分配时,在HotSpot中将抛出SOF异常,但如果在允许动态拓展的虚拟机上,相同的代码可能报错不同。
值得一提的是,如果测试方法不限于单个线程,而是通过建立多个线程的方式进行,虚拟机同样会抛出SOF异常,而且给每个线程分配的内存越大,越容易发生SOF异常,但原因与栈空间是否充足没有直接关系,而是操作系统本身的内存使用有关。
可以这样理解,假设单个线程最大内存限制为2GB,HotSpot提供参数以控制Java堆和方法区的内存最大值,则剩余的内存为2GB(操作系统限制)减去最大堆和方法区的容量,忽略消耗较小的程序计数器,如果把直接内存和虚拟机进程本身消耗的内存也去掉,剩下的内存才是虚拟机栈和本地方法栈来分配的量,所以,给单个线程分配的栈内存越大,可建立的内存数量就越小,就更容易产生SOF异常,报错如下:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread

综上,当产生SOF异常时,一般会有明确错误栈帧可供分析,进而确定问题所在,在HotSpot默认参数下,栈深度一般够用。但是如果是因为建立过多线程导致的溢出,但无法减少线程数或更换虚拟机,就需要减小最大堆和减小栈容量来换取更多线程,没有相关经验不容易想到这种解决方法,在开发32位系统的多线程应用时需要注意(部分虚拟机也可能会人性化的在以上提示信息中“unable to create native thread”后面特别注明原因可能是“possibly out of memory or process/resource limits reached”)。

3、方法区和进行时常量池溢出

因为进行时常量池是方法区的一部分,所以将两者放在一起讨论。
首先说说进行时常量池,因为经过JDK 6、7、8三代的调整,进行时常量池所属的永久代概念被抛弃,采用元空间来描述,原本存放在永久代中的字符串常量池被移至Java堆中,调整后的JDK 8发生常量池溢出的可能性大大降低,再加上进来市场上使用的JDK大多是从8开始,有关JDK 6、7的部分内容不再总结,如果遇到有关JDK 6、7的内容,自行翻阅原书。
关于方法区的其他部分,方法区的职责是存储类型的相关信息,如类名、访问修饰符、常量池等。关于这部分的检测,基本思路是运行时产生大量的类去填满方法区,直到溢出为止。
这里的方法是使用CGLib直接操作字节码运行时生成类(虽然可以使用Java SE API动态生成,但比较麻烦),值得注意的是,CGLib这类字节码技术,在当前许多主流架构,如Spring、Hibernate对类增强时也会用的到,所以以后也会遇到类似溢出场景:

/**
* VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
* @author zzm
*/
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() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
}
static class OOMObject {
}
}

结果如下:

java.lang.OutOfMemoryError: PermGen space

虽然JDK 8之后,默认设置下动态创建类的测试方法很难令虚拟机产生方法区溢出,但是为了让使用者预防实际应用中的类似上面代码出现的破坏性异常,HotSpot提供了一些参数作为元空间的防御措施:

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
  • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

4、本机直接内存溢出

直接内存(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 zzm
*/
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

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

小结

产生各个内存区域产生异常的原因:

  • Java堆:实例对象数量过多,触及最大堆容量产生OOM异常。
  • 虚拟机栈和本地方法栈:1、栈容量过小或栈帧太大,导致SOF异常。2、多线程创建对象时,单个对象内存设置过大,产生OOM异常。
  • 方法区和运行时常量池:创建类型数量过多,方法区被填满,导致溢出,产生OOM异常。
  • 直接内存:调用错误的方法(直接调用unsafe方法,实际应该是Unsafe::allocateMemory())导致OOM异常。

最后

以上就是诚心向日葵为你收集整理的《深入理解Java虚拟机》第三版 第二章(3)的全部内容,希望文章能够帮你解决《深入理解Java虚拟机》第三版 第二章(3)所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(65)

评论列表共有 0 条评论

立即
投稿
返回
顶部