亲宝软件园·资讯

展开

java  OutOfMemoryError异常

怪咖软妹@ 人气:0

在Java虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError (下文称OOM)异常的可能。本篇主要结合着【深入理解Java虚拟机】一书当中整理了本篇博客,感兴趣的跟着小编一块来学习呀!

本篇文章和上一篇写到的 Java内存区域划分 息息相关,如果您对Java内存区域划分不是很了解,建议了解一下,不然这篇文章读起来会很痛苦。。。

一、简言

本节内容的目的有两个:

下面代码的开头都注释了执行时所需要设置的虚拟机启动参数(注释中“VM Args”后面跟着的参数),这些参数对实验的结果有直接影响,请读者调试代码的时候不要忽略掉。(本篇文章所有案例都采用了JDK1.8版本进行测试)

如果读者使用控制台命令来执行程序,那直接跟在Java命令之后书写就可以。如果读者使用Eclipse IDE,可以在Debug/Run页签中的设置。

二、代码实战

1、Java堆溢出

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,就会在对象数量到达最大堆的容量限制后产生内存溢出异常。

将Java堆设置大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析(内存堆转储快照 指的是溢出后,内存当中的对象占用情况)。

我用的是ider:

设置启动参数:
Xms:最小堆内存 Xmx:最大可扩展内存
XX:+HeapDumpOnOutOfMemoryError:可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析

-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

import java.util.ArrayList;
import java.util.List;
public class HeapOOM {
    static class OOMObject{
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true){
            list.add(new OOMObject());
        }
}

运行结果:

因为设置了-XX:+HeapDumpOnOutOfMemoryError参数,所以生成了 这个报告。可以查看对象占用内存。

Java堆内存的OOM异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息"java.lang.OutOfMemoryError”会跟着进一步提示“Java heapspace"。

要解决这个区域的异常,一般的手段是首先通过内存映像分析工具(如EclipseMemory Analyzer、Dier的jprofiler)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。

如果不存在泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

后面我会专门写一篇关于内存分析工具的博客,XX:+HeapDumpOnOutOfMemoryError这个只是有内存占用情况,工具可以帮我们看到对象的引用链情况。

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

由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常。
注意:HotSpot虚拟机的栈容量是不可以动态扩展的。

public class JavaVMStackSOF {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}
  1. 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在JAVA虚拟机栈中
  2. 当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在JAVA虚拟机的栈中,该变量所指向的对象是放在堆类存中的。

实验结果表明:在单个线程下,无论是由于栈帧太大,还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。换成远古时代的Classic虚拟机,这款虚拟机可以支持动态扩展 栈内存的容量,这时候就会报StackOverflowError异常了。

也就是当我设置-Xss128k和不设置都是报同样的错误,并没有出现内存溢出异常,原因就是 HotSpot虚拟机的栈容量是不可以动态扩展的,但是值得注意的是我的电脑是16G运行内存的,当我设置-Xss128k的时候输出的长度是将近1000,当我不限制-Xss128k大小的时候输出的长度是20000左右,也就意味着每个线程的栈帧大小默认最大是2MB

如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

原因其实不难理解,操作系统分配给每个进程的内存是有限制的,譬如32位Windows的单个进程 最大内存限制为2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值。那么虚拟机栈和本地方法栈内存如下:

虚拟机栈和本地方法栈内存=2GB-最大堆容量-最大方法区容量-程序计数器容量

因此为每个线程分配到的栈内存越大,可以建立的线程数量自 然就越少,建立线程时就越容易把剩下的内存耗尽。

通过上面了解到,出现StackOverflowError异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在(一般出现死循环可能会导致)。

如果是建立过多线程导致的内存溢出,而不是栈溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。

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) {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

注意 重点提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作,由于在 Windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上,无限制地创建线程会对操 作系统带来很大压力,上述代码执行时有很高的风险,可能会由于创建线程数量过多而导致操作系统 假死(电脑可能直接死机)。

在32位操作系统下的运行结果:
原因:32位有进程大小内存限制。

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

注意:如果要测试上面内存溢出代码,记住先保存当前的工作,避免电脑卡死带来的麻烦。

3、运行时常量池溢出

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经 提到HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么 实际的影响。

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串,则返回代表池中这个字符串的String对象的引用否则,会将此String对象包含的字符串添加 到常量池中,并且返回此String对象的引用

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

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        // 使用List保持着常量池引用,避免Full GC回收常量池行为
        List<String> list = new ArrayList<>();
        // 10MB的PerSize在integer范围内足够产生00M
        int i = 0;
        while (true){
            list.add(String.valueOf(i++).intern());
        }
    }
}

使用JDK 7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK 7中继续使 用-XX:MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同 样限制在6MB,都不会出现溢出异常,循环将一直进行下去,永不停歇。出现这种变 化,是因为自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版 本,限制方法区的容量对该测试用例来说是毫无意义的

在JDK1.7中(包括1.7以上)常量池存储的不再是对象,而是对象引用,真正的对象是存储在堆中的。把RuntimeConstantPoolOOM.java运行时的VM参数改为如下(设置堆大小)所示:

-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

运行结果:

查看生成的堆内存快照:

4、方法区溢出

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这个区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor 和动态代理等),但在本次实验中操作起来比较麻烦。借助CGLib直接操作字节码运行时,生成了大量的动态类。

值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如Spring和Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载人内存

测试示例:

import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

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 {
    }
}

设置元空间最大空间,和初始化空间参数:
类信息是都存在方法区的,方法区在jdk1.8将永久区改为了元空间。自此以后,常量池在元空间都是存储的引用。实际对象是在堆中。

-XX:MaxMetaspaceSize=10m -XX:MetaspaceSize=10m

运行结果:

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比 较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场 景除了之前提到的程序使用了CGLib字节码增强和动态语言外,常见的还有:大量JSP或动态产生JSP 文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同 的加载器加载也会视为不同的类)等。

5、本机直接内存溢出

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中 定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

直接内存:可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用`进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据(但是有一点注意,虽然不占用堆内存,但是他占用了服务器内存)。

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不 去指定,则默认与Java堆最大值(由-Xmx指定)一致。

代码示例:

越过了DirectByteBuffer类直接通 过反射获取Unsafe实例进行内存分配Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实 例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢 出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会 在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()

import sun.misc.Unsafe;

import java.lang.reflect.Field;

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);
        }
    }
}

运行参数:

-Xmx20M -XX:MaxDirectMemorySize=10M -XX:+HeapDumpOnOutOfMemoryError

运行结果:

我设置了-XX:+HeapDumpOnOutOfMemoryError发现运行完成之后并没有发现有内存快照。

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

三、JVM常用的启动参数

堆:

栈:

方法区:

内存:

GC:

四、面试题

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。在jdk1.8运行也是,true、false。

产 生差异的原因是,在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池 中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在 Java堆上,所以必然不可能是同一个引用,结果将返回false。

而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例 到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引 用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。
而对str2比较返 回false,这是因为“java”这个字符串在执行String-Builder.toString()之前就已经出现过了,字符串常量 池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次 出现的,因此结果返回true。(这块说实话不好理解,说白了就是java是个特殊的字符串,他在常量池里面就一直存在)

总结:在1.8之后通过intern()添加到常量池,只有字符串在常量池不存在的时候才会返回字符串的引用。

五、总结

到此为止,我们明白了虚拟机里面的内存是如何划分的,哪部分区域、什么样的代码和操作可能 导致内存溢出异常。虽然Java有垃圾收集机制,但内存溢出异常离我们并不遥远,本章只是讲解了各 个区域出现内存溢出异常的原因,下一章将详细讲解Java垃圾收集机制为了避免出现内存溢出异常都 做了哪些努力。

加载全部内容

相关教程
猜你喜欢
用户评论