浅谈Java内存溢出

浅谈Java内存溢出

博主最近通过《深入理解Java虚拟机》在恶补关于JVM底层实现和内存模型,也将陆续通过博客记录学习心得。本篇主要根据书中提到的几种内存溢出的场景进行总结。


1. 内存模型

借用网上的图说明JVM的内存模型[3]:
JVM内存模型

1.1 程序计数器(PC)

类比CPU的程序计数器,用于对字节码的读取进行计数,字节码解释器通过该计数器确定需要执行那条字节码,控制程序的分支、循环、跳转、异常处理等。由于JVM支持多线程,JVM通过切换线程获取CPU的执行时间片段实现多线程,对于某个时刻,每个线程需要知道本线程应该执行字节码中的位置,因此PC是线程私有的,即每一个线程都对应自己的PC。如果程序执行的Java方法,PC记录的是字节码的地址;如果是本地Native方法,PC为空。程序计数器是唯一一个在JVM规范中没有规定OutOfMemoryError的区域。

1.2 Java虚拟机栈(Stack)

虚拟机栈就是通常我们所说的栈内存(相对于堆内存),虽然这样的分类有点粗暴,但也反映了该内存的重要性。虚拟机栈也是线程私有的,与线程同生命周期。每一个栈中存放的元素为栈帧,一个栈帧即存放一个方法的相关信息,包括局部变量表、操作数栈、动态链接、方法出口等。
StackFrame

局部变量表存放编译器可知的原始数据类型(boolean, byte, char, short, int, long, float, double)、对象的引用(堆上对象的指针)以及returnAddress(指向一条字节码的地址)。变量表的基本单位为slot,可以存放32字节以下的数据,因此一个slot可以存放除了64byte以外的所有类型,double和long需要2个slot才可以存放。当进入一个方法的时候,这个方法需要在栈帧中分配多大的局部空间是确定的,在运行时不会改变局部变量表的大小,因为所有的本地变量的数量和引用的数量都是完全确定的。另外值得注意的是,JVM不会为本地变量赋初始值,这也就是为什么我们在方法内部用到某个本地变量时,如果没有在变量申明时给出初始值,编译无法通过,而如果这个变量是类变量或者在静态方法中就不会报错。

1.3 本地方法栈 (Native Method Stack)

与虚拟机栈类似,但是本地方法栈只服务于Native方法。具体实现上,有些虚拟机(HotSpot)直接将本地方法栈和虚拟机栈合二为一。因此该区域理论上也会产生内存溢出。

1.4 Java堆 (Java Heap)

Java堆是垃圾回收的主要区域,所有的对象实例以及数组都要在堆上分配,并且堆是线程共享的。关于堆上GC的内容,扩展开来将超出篇幅,因此将另择机会总结。堆的划分有很多种,并且还可能分配线程私有的缓冲区(Thread Local Allocation Buffer),目的都是为了更有效地进行GC。JVM规范规定堆的逻辑连续,允许物理不连续。

1.5 方法区 (Method Area)

方法区是JVM规范规定的线程共享区域,用于存储类加载信息、常量、静态变量等。通常我们讲永久代(Permanent Generation)等同于方法区,其实是概念上的混淆。方法区是规范规定的逻辑区,而永久代是虚拟机的具体实现。而从Java1.7开始,永久代已经逐渐移除,在Java1.8中已经彻底移除了永久代,取而代之的是Metaspace,它也是对方法区的实现。主要区别是,Metaspace分配的空间不在虚拟机内部而是在本地内存中。这些改变也可以看出,方法区只是一个规范概念,逻辑上是一个整体,而具体实现上可以将不同的数据分散到不同的内存区域。例如:常量池现在不再实现在永久代上,而是移到了堆内存上;而由于永久代的彻底摒弃,方法区中的类加载溢出也出现在了Metaspace上。

1.6 运行时常量池 (Runtime Constant Pool)

运行时常量池是方法区的一部分,2.5中已经提到,1.7以后的常量池不再放在永久代上,而是移到了Java堆上。编译期会产生各种字面量和符号引用,这些常量在类被JVM加载后会进入运行时常量池,而Java并不要求所有的常量都在编译器产生,最典型的应用就是String.intern()方法。后文也将针对这种运行时产生常量的方法进行内存溢出的分析。

1.7 直接内存 (Direct Memory)

直接内存没有出现在Java内存模型图中,主要原因是它并不是虚拟机运行时数据的一部分,它不属于Java虚拟机内存,它利用Native函数直接分配在堆外,然后通过一个存储在Java堆上的DirectByteBuffer对象作为堆外内存的引用进行操作。这种操作主要用于1.4后引入的NIO,可以有效地减少堆内存和本地内存之间的拷贝,提高网络和文件IO效率。既然直接内存不属于Java堆内存,自然不收虚拟机参数-Xmx-Xms的约束,但是它毕竟是内存还是受到物理内存(虚拟内存)的约束。

2. 内存溢出场景分析

本节将针对1中提到的内存模型中的各个部分作内存溢出分析。

2.1 Java堆溢出

堆内存溢出最常见的场景就是GC无法回收堆上的对象,这些对象必然是GC ROOT可以到达的路径上,当这些对象的占用超过堆内存的设定大小就会发生无法再为新的对象分配内存,产生OutOfMemoryError。通过-Xms,-Xmx控制堆内存的最大最小值,-XX:+HeapDumpOnOutOfMemoryError可以dump出内存溢出时的快照。

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

输出如下:
这里写图片描述

我们可以看到当最大堆内存设为20M时,很快我们就产生了OutOfMemoryError,并且产生在Java heap space上。

2.2 虚拟机栈和本地方法栈溢出

在Hotspot虚拟机中将虚拟机栈和本地方法合并处理,虽然有-Xoss参数设置本地方法栈大小,但实际是无效的。-Xss可以设置虚拟机栈的大小。虚拟机栈内存溢出可以从两个维度解释:

栈的大小超过了虚拟机所允许的最大深度,将抛出StackOverflowError异常。

虚拟机在扩展栈时无法申请到足够的内存,将抛出OutOfMemoryError异常。

两个维度是等价的,都取决于栈的大小,如果栈太小,那么很小深度的调用就会因太多的栈帧占满栈而产生溢出;另外如果每个栈很大,由于线程私有属性,当总内存一定的时候,多线程将会产生多个大内存栈从而当新线程无法申请到内存用于存放内存栈的时候,也会产生内存溢出。

/**
 * VM args: -Xss128k
 * @author Ken
 */
public class StackOverFlow {
    private static long stackSize = 1;
    public static void main(String[] args) throws Throwable {
        try {
            new StackOverFlow().stackOverflow();
        } catch (Throwable e) {
            System.out.println("stack depth is: " + stackSize);
            e.printStackTrace();
        }
    }
    public void stackOverflow() {
        stackSize++;
        stackOverflow();
    }
}

输出:
Stackoverflow caused by stack depth

设置一个很小栈内存,并递归调用,就会产生StackOverflowError

/**
 * VM args: -Xss4g -Xms1g -Xmx1g
 * @author Ken
 */
public class OOMCausedByThread {
    public static void main(String[] args) {
        OOMCausedByThread oom = new OOMCausedByThread();
        oom.stackLeakByThread();
    }
    public void stackLeakByThread() {
        while (true) {
            new Thread(() -> {
                try {
                    Thread.sleep(Long.MAX_VALUE);
                } catch (InterruptedException e) {
                }
            }).start();
        }
    }
}

输出:

StackOverflow caused by multi-thread
可以看到,我们使每个线程创建的私有内存栈有很大的空间,当无线循环创建线程时,最终会因为无法申请到更多内存来创建新的内存栈而抛出了OutOfMemoryError; 要解决这种问题,令人想不到的竟然是减小内存

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

之前已经提到了方法区自从1.7以后逐渐淘汰的问题,这节主要验证下方法区和常量池的内存溢出情况。先看下运行时常量池溢出的代码,通过String.intern()方法将运行时产生的字符串加入常量池,从而使得常量池溢出:

/**
 * Vm args: -XX:PermSize=10M -XX:MaxPermSize=10M -Xms1m -Xmx1m
 * @author Ken
 */
public class PermOOM {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        int i = 0;
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

在1.7之前的JDK版本中,可以通过-XX:PermSize-XX:MaxPermSize设置永久代的大小,输出:
PermGen overflow

可以看到,输出显示内存溢出发生在PerGen space证明了,运行时常量池在Java1.7之前实现在永久代,再看看在Java1.8下的输出:
Constant pool heap overflow

很明显,此时溢出发生在了Java heap,可以证明常量池被移到了堆内存,并且Java8已经不支持-XX:PermSize-XX:MaxPermSize

方法区的溢出比较难构建,由于方法区主要存放类加载信息,因此可以想到的是不断动态加载很多类,使得类信息过多产生方法区溢出,这里要借助CGLib动态代理,在运行时通过操作字节码产生大量动态类使方法区溢出。博主层试着利用InvocationHandler动态代理的方式产生动态类,但并没有产生溢出,也许是因为Java动态代理依赖实现同一接口的缘故,读者也可以尝试类似方法,如果也可行可以和博主交流。
首先准备一个动态代理类,通过CGLib的MethodInterceptor实现

import java.lang.reflect.Method;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

public class ProcessorProxy implements MethodInterceptor {

    public static Processor getInstance() {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(Processor.class);
        enhancer.setUseCache(false);
        enhancer.setCallback(new ProcessorProxy());
        return (Processor) enhancer.create();
    }

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        if ("process".equals(method.getName())) {
            return "Default implementation has been bypassed.";
        } else {
            return method.invoke(proxy, args);
        }
    }
}

接口Processor为一个处理器,简单的字符串实现StringProcessor将拼接字符串,这两段代码仅供读者理解动态代理,与内存溢出实验无关:

public interface Processor {
    public String process(String ori, String... elments);
}
public class StringProcessor implements Processor {
    @Override
    public String process(String ori, String... elements) {
        if (ori != null && elements != null) {
            StringBuilder sb = new StringBuilder(ori);
            for (String element : elements) {
                sb.append(element);
            }
            return sb.toString();
        }
        return ori;
    }
}

测试代码中,调用动态代理类获取代理对象,并执行代理方法,-XX:MaxMetaspaceSize用来设置Metaspace的大小。

/**
 * VM args: -XX:MaxMetaspaceSize=10m -XX:+HeapDumpOnOutOfMemoryError
 * @author Ken
 */
public class MetaspaceOOM {
    public static void main(String[] args) {
        while (true) {
            String process = ProcessorProxy.getInstance().process("originals", new String[] { ": ", "xyz" });
            System.out.println(process);
        }
    }
}

省略Java6的输出,与之前的现象相同都是永久代溢出,Java8的输出如下:
Metaspace overflow

可以看到,在内存溢出前,输出的是通过动态代理方法生成的字符串。最后发生内存溢出的区域是Metaspace,证明了Java8已经将方法区重新放置到了Metaspace, 并且-XX:+HeapDumpOnOutOfMemoryError并没有打印OOM信息,说明Metaspace在堆外的本地内存,并不在Java堆上。
综上,可以看出方法区和运行时常量池从原先的PerGen分别移到了不同的内存区域,但他们逻辑上还是符合JVM规范,只是实现上出于不同的考虑放在了不同的地方。

2.4 直接内存溢出

DirectMemory是堆外内存,由NIO引入,通过-XX:MaxDirectMemorySize控制大小,默认与Java最大堆内存(-Xmx)相同。直接内存无法直接分配,必须要通过Unsafe实例分配,而Unsafe实例也是无法在用户程序中实例化的,因为Java不希望用户获取rt.jar(JRE核心库)的权限产生不安全的操作。用户程序的类加载器与rt.jar的类加载器不是同一个,这部分可以参考博主关于类加载的文章《JavaEE笔记(一)类加载器(ClassLoader)》。但是我们可以通过反射获取Unsafe实例并通过实例分配直接内存,代码如下:

/**
 * VM args: -Xmx10m -XX:MaxDirectMemorySize=20m
 * @author Ken
 */
public class OffHeapOOM {
    public static void main(String[] args) throws IllegalArgumentException, IllegalAccessException {

        Field theUnsafe = Unsafe.class.getDeclaredFields()[0]; // private static final Unsafe theUnsafe;
        theUnsafe.setAccessible(true);
        Unsafe unsafe = (Unsafe) theUnsafe.get(null);
        while (true) {
            unsafe.allocateMemory(1024 * 1024); // allocate 1MB for offheap memory
        }
    }
}

输出:
Direct Memory overflow

在排错定位时,如果发现OOM的dump没有明显异常,但是整个JVM应用占用异常就需要检查DirectMemory是否存在内存溢出。因此通过-XX:MaxDirectMemorySize来显式地控制直接内存是一种很好的实践。

3. 总结

本文首先简单介绍了JVM的内存模型,进而针对每一个单独的内存模块进行了粗略的内存溢出分析,当然内存溢出还有很多实用的分析工具文中并没有涉及,博主本身也在不断地学习和精进,希望之后有机会针对工具进行系统优化方面的总结。另外,文中也提到了部分内存在各个版本的JDK中实现上的不同,限于篇幅没有展开,有兴趣的同学也可以自行研究并和博主交流,一个简单的例子,String.intern()方法在1.7和之前的版本中作了较大的改动,会产生完全不同的效果。希望本文能帮到和博主一样初入JVM原理学习的同学。


以上

© 著作权归作者所有

引用

[1]《深入理解Java虚拟机 - JVM高级特性与最佳实践》 第2版, 周志明,第二章
[2] JVM的DirectMemory设置 https://blog.csdn.net/zshake/article/details/46785469
[3] Java8内存模型 https://blog.csdn.net/universe_ant/article/details/58585854
[4] DirectBuffer及内存泄漏 https://blog.csdn.net/zhouhl_cn/article/details/6573213

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
智慧校园整体解决方案是响应国家教育信息化政策,结合教育改革和技术创新的产物。该方案以物联网、大数据、人工智能和移动互联技术为基础,旨在打造一个安全、高效、互动且环保的教育环境。方案强调从数字化校园向智慧校园的转变,通过自动数据采集、智能分析和按需服务,实现校园业务的智能化管理。 方案的总体设计原则包括应用至上、分层设计和互联互通,确保系统能够满足不同用户角色的需求,并实现数据和资源的整合与共享。框架设计涵盖了校园安全、管理、教学、环境等多个方面,构建了一个全面的校园应用生态系统。这包括智慧安全系统、校园身份识别、智能排课及选课系统、智慧学习系统、精品录播教室方案等,以支持个性化学习和教学评估。 建设内容突出了智慧安全和智慧管理的重要性。智慧安全管理通过分布式录播系统和紧急预案一键启动功能,增强校园安全预警和事件响应能力。智慧管理系统则利用物联网技术,实现人员和设备的智能管理,提高校园运营效率。 智慧教学部分,方案提供了智慧学习系统和精品录播教室方案,支持专业级学习硬件和智能化网络管理,促进个性化学习和教学资源的高效利用。同时,教学质量评估中心和资源应用平台的建设,旨在提升教学评估的科学性和教育资源的共享性。 智慧环境建设则侧重于基于物联网的设备管理,通过智慧教室管理系统实现教室环境的智能控制和能效管理,打造绿色、节能的校园环境。电子班牌和校园信息发布系统的建设,将作为智慧校园的核心和入口,提供教务、一卡通、图书馆等系统的集成信息。 总体而言,智慧校园整体解决方案通过集成先进技术,不仅提升了校园的信息化水平,而且优化了教学和管理流程,为学生、教师和家长提供了更加便捷、个性化的教育体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值