JVM内存区域与内存溢出异常

对于Java程序员来说,Java虚拟机是再熟悉不过了,尤其是对Hotspot VM最熟悉了,因为它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广泛的Java虚拟机。相比C、C++开发人员来说,有了JVM,Java程序员不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出的问题。但也正因为内存控制完全交由JVM,一旦出现内存泄漏和溢出的问题,如果没有深入了解过JVM的工作机制,那么排查问题将会很困难,所以我准备对JVM相关的东西做一下研究整理。

1. 首先了解一下Java内存区域

JVM运行时数据区
如上图(图片摘自 深入理解Java虚拟机),堆内存和方法区的内容是所有线程共享的,虚拟机栈、本地方法栈和程序计数器是每个线程独有的。其中方法区就是jdk中永久代(jdk1.7之后叫metaspace),本地方法栈一般是指java native方法。

  • 对程序计数器做下介绍
    当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变计时器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。每个线程都会有自己独立的计数器,因为多线程之间切换后能恢复到正确的执行位置,就是靠各自的计数器记录的指令地址(如果正在执行的是Native方法,则计数器值为空Undefined)做到的
  • Java虚拟机栈(Java栈)
    它的声明周期和线程相同,每个方法在执行的同时,都会创建一个栈帧(方法运行时的基础数据结构)用于存储局部变量表(存放了编译器可知的各种基本类型、对象引用或句柄,其中只有long、double占用2个局部变量空间Slot,其他数据类型只占1个)、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,对应着一个栈帧在虚拟机栈(Java栈)中入栈道出栈的过程。
    说明:Java栈有两种异常类型
    StackOverflowError异常:如果线程请求的栈深度大于虚拟机锁允许的深度,就会抛出这个异常;
    OutOfMemoryError异常:如果虚拟机栈动态扩展时,无法申请到足够的内存,就会抛出这个异常;
    稍后会有报错的案例分析
  • 本地方法栈
    它和虚拟机栈的作用基本一样,唯一的区别是它是为虚拟机使用到的Native方法服务(一般Java中执行C语言的方法,使用的就是本地方法栈),而虚拟机栈是为栈执行Java方法服务。
  • Java堆
    堆内存大家都很熟悉了,它的唯一目的就是存放对象实例,几乎所有的对象实例(随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换技术使对象不一定在堆上分配)都在这里分内存。
  • 方法区
    它主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在Hotspot虚拟机中,该区域叫“永久代”(jdk1.7之后叫metaspace)。但方法区并不等价于永久代,这个是java虚拟机规范中的概念,仅仅是因为Hotspot设计团队选择把GC分代收集扩展至方法区(或者说使用永久代来实现方法区而已),这样垃圾收集器可以像管理Java堆一样来管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。
    说明:方法区可能抛出的异常
    当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError,如果是jdk1.6及以前的版本,后边会跟随着提示信息是“PermGen space”。
  • 运行时常量池
    它是方法区的一部分(Class文件中除了有类的版本号、字段、方法、接口等描述信息外,还有就是常量池),用于存放编译期生成的各种字面量(例如:常量)和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
    说明:运行期间使用String.intern()方法可以将新的常量放入池中
  • 符号引用和直接引用区别
    符号引用:使以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标就好。例如:Class文件中它以Constant_Class_info等类型的常量出现;
    直接引用:可以是直接指向目标的指针,例如:Class对象、类变量的直接引用里可能是指向方法区的指针,相对偏移量、一个能间接定位到目标的句柄。

2. Hotspot虚拟机内存分析

接下来,就对最为流程的JVM进行详细的研究学习

  • 对象创建的内存分配方式
    一般有两种分配方式:一种是指针碰撞,但前提是堆内存规整的情况下;
    另一种是空闲列表(可以在堆内存不规整的情况下)。
    Serial、ParNew(带压缩整理) GC算法,通常采用指针碰撞;CMS(标记清除)算法,通常采用空闲列表。

  • 堆内存分配并发处理
    一种是对内存空间的动作进行同步处理,实际上JVM采用CAS+失败重试的方式;
    另一种是把内存分配的动作按照线程分在不同空间之中进行,也就是说每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)。虚拟机是否使用TLAB,可通过-XX: +/-UseTLAB来设定,默认是开启的。

  • 对象的内存布局
    可分为3块区域:对象头、实例数据、对齐填充;
    **对象头:**包含两部分信息,一是用于存储对象自身的运行时数据(如:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳),这些又称为“Mark Word”;另一部分是类型指针,即对象指向它的类元数据的指针,JVM通过这个指针来确定这个对象是哪个类的实例。注意:如果是java数组的话,对象头中还必须有一块用于记录数组长度的数据,因为JVM可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中却无法确定数组的大小。
    **实例数据:**是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
    对齐填充: 因为HotspotVM要求对象起始地址必须是8字节的整数倍,也就是说对象大小必须是8字节的整数倍,所以对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

  • 对象的访问定位方式
    有两种方式:一种是使用句柄访问,在java堆中将会分出一块内存来作为句柄池,栈中reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息,如下图(图片来自 深入理解Java虚拟机):
    句柄访问

另一种方式是使用直接指针访问,reference中存储的直接就是对象地址
指针访问
说明:两者优缺点
这两种方式各有优势,使用句柄来访问,最大的好处就是reference中存储的是稳定的句柄地址,不管对象怎么被移动(垃圾收集时移动对象是非常普遍的行为),只需要改变句柄中的实例数据指针,而reference本身不需要修改;
使用指针访问,最大的好处就是速度更快,它节省了一次指针定位的时间开销,但是对象变化的频繁,会带来栈指针变化的成本。

3. 内存溢出分析

  • Java堆溢出 OOM案例,以下为案例代码:
/**
 * @className: JavaHeapOOM
 * @summary: Java堆内存溢出案例
 * @Description: OOM
 *  VM 参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 *  说明:以上参数限制java堆的大小为20MB,不可扩展(堆最大值和最小值设置为一样的)
 *  -XX:+HeapDumpOnOutOfMemoryError参数的作用是让JVM出现内存溢出异常时,
 *  Dump出当前的内存堆转储快照以便事后进行分析。
 * @author: helon
 * date: 2018/10/28 1:13 PM
 * version: v1.0
 */
public class JavaHeapOOM {

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

    }
}

在启动运行之前,我们要设置以下idea的VM参数,如下截图:
VM参数配置
配置完成后开始启动main方法,发现报出了OOM,如下信息:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid2342.hprof ...
Heap dump file created [27819705 bytes in 0.095 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:265)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
	at java.util.ArrayList.add(ArrayList.java:462)
	at com.helon.JavaHeapOOM.main(JavaHeapOOM.java:27)

Dumping heap to java_pid2342.hprof … 报错之后dump了一份hprof堆转储快照文件,可以使用Eclipse的MAT(Memory Analyzer Tool)工具进行分析(idea貌似没有类似的工具,找了好久没有找到…),确认对象是否是必要的,也就是说先分析清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。如果是内存泄漏了,那么可以通过工具查看泄漏对象到GC ROOTS(可达性分析对象到GC ROOTS的引用链关系,包括虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象)的引用链,看是什么原因导致对象没有被回收。如果不是内存泄漏,那么就要分析一下,堆内存参数配置对比物理内存是否可以适当调大,或者尝试减少程序运行期的内存消耗。

  • 虚拟机栈和本地方法栈溢出案例
/**
 * @className: JavaVMStackSOF
 * @summary: 虚拟机栈和本地方法栈OOM测试
 * @Description: 设置虚拟机栈容量 -Xss, -Xoss为本地方法栈大小,但设置不小
 * 设置 -Xss大小为160k
 * 单线程运行
 * @author: helon
 * date: 2018/10/28 4:09 PM
 * version: v1.0
 */
public 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("====栈深度:" + oom.stackLength);
            throw e;
        }
    }
}

在代码运行前,首先配置一下VM参数,将虚拟机栈大小设置为160k,如下截图:
SOF案例测试
运行main方法,每次报错都是StackOverflowError,如果定义大量的本地变量,结果也是抛出StackOverflowError:

====栈深度:772
Exception in thread "main" java.lang.StackOverflowError
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:18)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	...

测试结果表明:在单线程情况下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,JVM都是抛出StackOverflowError异常。
那么我们在多线程情况下做个测试,如下代码:

/**
 * @className: JavaVMStackOOM
 * @summary: 多线程场景下导致虚拟机栈OOM
 * @Description: 设置-Xss2m
 * @author: helon
 * date: 2018/10/28 4:44 PM
 * version: v1.0
 */
public class JavaVMStackOOM {

    private void dontStop() {
        while (true) {

        }
    }
    public void stackLeakByThread() {
        while (true) {
            new Thread(() -> {
                dontStop();
            }).start();
        }
    }
    public static void main(String[] args) throws Throwable {

        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

该代码执行过程中,可能会造成操作系统卡死,最终运行结果:

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

测试表明,在多线程情况下可以产生栈内存溢出异常,但是这样产生的内存溢出异常与栈空间是否足够大,并不存在任何联系,或者说为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。因为堆内存总大小减去Xmx最大堆容量,再减去MaxPermSize最大方法区容量,程序计数器可以忽略不计,那么剩下的内存就是虚拟机栈和本地方法栈的内存大小了。如果每个线程分配到的内存容量越大,那么可以建立的线程数量就越少。

  • 方法区和运行时常量池溢出案例
    首先看以下代码:
/**
 * @className: RuntimeConstantPoolOOM
 * @summary: JDK1.6及以前版本 使用以下案例可以测出OOM:PermGen space
 * @Description: 设置 -XX:PermSize=10M -XX:MaxPermSize=10M
 * @author: helon
 * date: 2018/10/28 5:30 PM
 * version: v1.0
 */
public class RuntimeConstantPoolOOM {

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

首先解释下String的intern()方法,它是一个Native方法,作用是:如果字符串常量池中已经包含了一个等于此String对象的字符串,那么就返回常量池中这个字符串的String对象,如果不包含就将此String对象包含的字符串添加到常量池中,并返回引用。
以上代码需要在JDK1.6及之前版本能够测出OOM异常,我本机为JDK1.8环境,测试不出异常,因为在JDK1.7及以上版本已经去“永久代”,设置PermSize参数是无效的。并且,JDK1.7之后,intern方法不会再复制对象实例,只是在常量池中记录首次出现的引用
除了以上测试方式,还可以借助CGLib直接操作字节码运行时生成大量的动态类,让其填满方法区,直到溢出,参照以下代码:

/**
 * @className: JavaMethodAreaOOM
 * @summary: 借助CGLib使方法区出现内存溢出异常
 * @Description: JDK1.6及之前版本,设置-XX:PermSize=10m -XX:MaxPermSize=10m
 * JDK1.7及之后版本,设置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
 * @author: helon
 * date: 2018/10/28 6:01 PM
 * version: v1.0
 */
public class JavaMethodAreaOOM {
    static class OOMObject {
    }
    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 o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }
}

启动main方法前,先设置一下VM参数:JDK1.6及之前版本,设置-XX:PermSize=10m -XX:MaxPermSize=10m;JDK1.7及之后版本,设置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m。根据本地环境来设置不同的参数,我使用的是JDK1.8环境,设置的是metaspace的大小,设置完毕运行main方法,运行一小会时间结果抛出了OOM,如下错误信息:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

其实,在当前的很多主流框架里,如:Spring、Hibernate在对类进行增强的时候,都会使用到CGLib这类字节码技术,增强类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。如果滥用类似字节码增强技术或动态语言(如groovy等),并未做好控制的话,很可能会导致以上异常。

  • 本机直接内存溢出分析
    如果以上查找OOM问题的时候,以上所有情况都没有定位原因,并且Heap Dump文件中没有看到明显的异常,而程序又直接或者间接使用到了NIO,那就有可能是这方面的原因。

4. 总结

好了,以上就是JVM常见的几种OOM,并且也总结了虚拟机中的内存划分,后续我还会经常分享一些JVM的其他知识,如果有疑义的地方,欢迎指正批评。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值