98.Java内存溢出

151 阅读8分钟

一、Java对象内存结构

对象在内存中存储的结构由三部分组成:对象头、实例数据、对其填充 image.png

1.1 对象头

image.png

  • 对象头(MarkWord):包括两部分信息,一部分记录哈希码、分代年龄、锁标志位、偏向线程ID、偏向时间等信息。MarkWord被设计成一个非固定的数据结构以便在技校的空间内存储尽量多的信息,它会根据对象的状态服用自己的存储空间。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过该指针确定这个对象属于哪个实例。

MarkWord是根据对象状态区分不同状态位,从而区分不同的存储结构。

image.png

1.2 实例数据

实例数据部分记录对象真正存储的有效信息,就是程序代码中所定义的各种类型的字段内容。

1.3 对齐填充

该部分并不是必然存在的,也没有特俗含义仅起着占位符的作用。因为HotSpotVM自动内存管理系统要对对象起始地址必须是8字节的整数倍,换句话说就是独享大小必须是8字节的整数倍。对象头正好是8字节的倍数,因此当对象实例部分没有对齐的话,就需要通过对齐填充来补全。

二、对象的创建过程

说到对象的创建,我们第一时间会想到new关键字。但是对象的创建还有克隆、反序列化等方式。我们就new方式创建对象简单介绍。

对象存储共涉及到运行时数据区的三个部分:方法栈(存储对象实例引用)、方法区(存储类信息、常量、静态变量)、堆(存储对象的示例数据)。

2.1 对象创建过程

  • new 类名
  • JVM根据new的类型在常量池中定位一个类的符号引用
  • 如果没有找到这个符号引用,说明类还没有被加载,则先进行类的加载、解析和初始化
  • JVM为对象在堆中分配内存(指针碰撞、空间列表两种分配方式)
  • 内存分配完成后进行初始化操作,将分配的内存空间都初始化为零值(不包括对象头)。
  • JVM对对象设置,例如该对象是哪个实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息
  • 调用对象init方法

image.png

2.2 指针碰撞

假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲空间侧挪动一段与对象大小相等的距离,这种分配方式就是指针碰撞。

2.3 空闲列表

如果Java堆中内存不是规整的,已使用的内存和空闲内存相互交错,那就没有办法进行简单的指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存快可用,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。这种分配方式就是空闲列表。

选择哪种分配方式是由Java堆是否规整决定的,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定的。

三、对象的访问定位

建立对象是为了适用对象。Java中需要通过栈上的reference数据操作堆上的具体对象。由于reference只规定了一个指向对象的引用,并没有定义通过何种方式去定位。所以对象访问方式取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种方式:

3.1 使用句柄访问

使用句柄访问的话,那么Java堆中将会划分一块内存作为句柄池,reference中存储的对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。 image.png

使用句柄访问的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变。

3.2 使用直接指针访问

使用直接指针访问,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息。此时reference中存储的就是对象地址。

image.png

使用直接指针访问最大好处就是速度更快。因为它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销在积少成多后也是一项非常可观的执行成本。

四、内存异常

在工作中我们也会经常遇到内存溢出的问题,如果我们一遇到这类问题就修改JVM堆内存大小这是不够的。我们可以通过一定手段定位问题,找到问题后才能采取合适的解决方案,而不是一遇到内存溢出就修改参数。

4.1 Java堆内存溢出

Java堆内存用于存储对象实例,只要不断创建对象并保证GC Roots到对象间有可达路径避免垃圾回收机制清除这些对选哪个,那么对象数量达到最大堆容量限制后就会产生内存异常异常。

public class JavaHeapOOM {

    /**
     * VM Args
     * -Xms20m (-Xms和-Xmx设置成一样可避免堆自动扩展)
     * -Xmx20m
     * -XX:+HeapDumpOnOutOfMemoryError(改参数可控制虚拟机发生OOM时Dump当前的内存堆转储快照)
     * @param args
     */
    public static void main(String[] args) {
        List<User> list = new ArrayList<>();
        while (true) {
            User user = new User();
            list.add(user);
        }
    }

    public static class User {
        String userName;
        String phone;
        String address;
    }
}

代码运行后抛出异常如下: image.png

使用VisualVM导入生成的heapDemp文件: image.png

image.png

分析dump文件可以看到该类创建了647593个实例导致内存溢出

4.2 Java栈内存溢出

Java虚拟机规范中描述了两种异常:

  • 如果线程请求栈深度(虚拟机栈中未出栈的方法帧)大于虚拟机所允许最大深度,将抛出StackOverflowError异常
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出OutOfMemoryError异常

tips:虚拟机栈容量可以通过-Xss设置

/**
 * VM Args: -Xss160k
 * 线程请求栈深度大于虚拟机所允许的最大深度时将抛出StackOverflowError异常
 */
public void stackLeak() {
    stackLength++;
    stackLeak();
}

/**
 * 创建线程导致内存溢出
 */
public void stackLeakByThread() {
    while (true) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                doStop();
            }
        });
        thread.start();
    }
}

虚拟机默认参数,栈深度在大多数情况下达到1000-2000完全没有问题,对于正常的方法调用包括递归该深度完全够用。

当创建大量线程时容易导致内存溢出,我们可以通过减少最大堆容量和减少栈容量换取更多的线程。 线程数*(最大栈容量)+最大堆值+其它内存(忽略不计或一般不改动)=机器最大内存

4.3 方法区和运行时常量池溢出

4.3.1 运行时常量池溢出

String.intern()是一个Native方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串则返回代表池中这个字符串的String对象

我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小从而间接限制常量池容量

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 * 运行时常量池导致的内存溢出异常
 */
public void RuntimeConstantPoolOOM() {
    //使用List保持常量池引用,避免Full GC回收常量池行为
    ArrayList<String> list = new ArrayList<>();
    //10MB的PerSize在Integer范围内足够产生OOM了
    int i = 0;
    while(true) {
        list.add(String.valueOf(i++).intern());
    }
}

运行该段代码产生OOM,在OutOfMemoryError后跟随提示信息PermGen space说明运行时常量池属于方法区

4.3.2 方法区内存溢出

方法区用于存放Class相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。我们可以使用CGLIB操作字节码生成大量动态类测试。

  • JDK7之前可以通过-XX:PermSize -XX:MaxPermSize控制永久代的大小
  • JDK8正式去除“永久代”,换成Metaspace(源空间)作为JVM虚拟机规范中方法区的实现。源空间和永久代间最大的区别在于:源空间并不在虚拟机中,而是使用本机内存。因此,默认情况下,元空间的大小仅受本地内存限制,但仍可以通过参数-XX:MetaspaceSize和-XX:MaxMetaspace来控制大小

代码模拟方法区内存溢出

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 * 借助CGLib试方法区出现内存溢出异常
 */
public void JavaMethodAreaOOM() {
    while (true) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(OOMObject.class);
        enhancer.setUseCache(false);
        enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(o, objects));
        enhancer.create();
    }
}

在经常动态生成大量Class的应用中,需要特别注意类的回收状况。这类场景除了上例使用的CGLib字节码增强和动态语言外还有大量JSP或动态产生JSP文件的应用等。