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

概述

​ 对于java与C&C++开发的一大区别就在内存管理方面。Java是通过虚拟机管理内存,但如果不熟悉虚拟机怎么使用管理内存,出现内存泄露和内存溢出问题,修正就会很艰难。

运行时数据区域

​ Java虚拟机在执行Java程序的过程中会把所管理的内存划分为一个个小部分,有的部分随着进程的启动而一直存在,有的部分随着用户线程的启动和结束而创建和销毁。其管理的内存分为以下几个运行时数据区域。

请添加图片描述

程序计数器

​ 是一块儿较小的内存空间,他可以看做是当前线程所执行的字节码的行号指示器,通俗的来讲就是通过改变计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都是依赖计数器完成的。

​ 在java多线程中,是利用线程轮流切换,分配处理器执行时间的方式来实现的,也就是说我执行了一会儿A线程,时间到了,我要去执行B线程了,那B线程执行时间到,我要再切换会A线程,那我怎么知道刚才任务完成到哪了?所以每个线程都私有一个程序计数器来保证线程切换后能回到正确的位置。

​ 如果执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址,如果执行的是一个本地方法(native),这个计数器值则应为空(Undefined)。这里的内存区域是唯一在《Java虚拟机规范》中没有任何OutOfMemoryError情况的区域。

Java虚拟机栈

请添加图片描述

​ Java虚拟机栈也是线程私有的,他的生命周期与线程相同,每个方法被调用的时候,虚拟机栈就会同步创建一个栈帧来存储局部变量表,操作数栈,动态链接,方法出口等信息,每个方法的调用与执行结束都对应这栈帧的入栈与出栈。

​ 其中局部变量表存放编译器可知的各种虚拟机基本数据类型(int,char),对应引用(Object),returnAddress(指向一条字节码指令地址)。局部变量表需要多大的空间在编译期间就可以确定的。也就是变量槽数量是不变了的。

​ 这里存在两种异常情况:

  1. 栈溢出(StackOverflowError),一般我们在递归算法中如果无限调用方法就会爆出该错误
  2. 内存溢出(OutOfMemoryError),如果设置栈可以动态扩展空间,当栈无法申请到更多的空间就会爆出OOM内存溢出。

本地方法栈

​ 与虚拟机栈类似,只不过虚拟站是执行Java方法,虚拟机栈是执行Native本地方法。也会有上述两种异常情况。

​ 堆是虚拟机管理内存中最大的一部分,是所有线程所共享的 ,该区域的唯一目的就是存放对象实例。本里应该是所有对象都存放在这里的,不过随着Java语言的不断发展,也不是很绝对。

​ 由于堆空间是垃圾回收器管理的内存区域,也叫做“GC堆”,从回收内存的角度来看,由于现代垃圾回收器大部分都是基于分代收集理论设计的,所以我们常说:新生代,老年代,永久代,Eden空间,From Survivor空间,To Survivor空间,但这个只是一部分垃圾回收器的共同特性和设计风格,并不是标准就是这么规定的,还有一些其他的垃圾回收策略。

​ 从分配内存的角度来看。Java堆空间可以划分出多个线程私有的分配缓冲区,以提高对象分配时的效率。将堆空间进行细分,只是为了更好地内存回收和内存分配。

​ 根据《Java虚拟机规范》,Java堆可以处于物理上不连续的内存空间,但是在逻辑上它应该被视为连续的。

​ Java堆可以被实现成固定大小的,可以设置为可扩展的。(通过参数-Xmx和-Xms设定),如果没有可分配的内存了,也会爆出OOM异常。

方法区

​ 方法区也是各个线程共享的区域,主要存放虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码缓存。

​ 这里必须提到“永久代”这个概念,在JDK8以前很多人吧方法区叫做永久代,这是因为在之前HotSpot团队将收集器的分代思想扩展到方法区,用永久代来实现方法区,这样垃圾回收器就可以像管理堆一样的管理栈,但其他的虚拟机实现中并没有永久代,例如JRockit,IBM j9,这个并不被规范设定,而属于各自的实现。现在来看用永久代来实现方法区,更容易导致内存溢出问题。永久代有一个默认上限值。所以到了JDK6时就已经有使用本地内存来实现方法区的计划,到了JDK7时,就将字符串常量池,静态变量等移植到堆中,到了JDK8就彻底放弃了永久代,使用本地内存来实现**元空间(Meta-space)**把JDK7中剩下的内容放入(主要是类型信息)。

​ 方法区这部分管理是比较宽松的,不需要连续的内存和内存大小的动态性。甚至可以不实现垃圾回收,但是并不是进去了,就永远保留,主要的内存回收针对的是常量池的回收和对类型的卸载。

​ 如果方法区无法分配到更多的内存空间,就会爆出OOM异常。

运行时常量池

​ 属于方法区的一部分,Class文件中除了类的模板,字段,方法,接口等描述信息,还有一个就是常量池表,用于存放编译后的各种字面量和符号引用。这部分内容在类加载后放在方法区的运行时常量池。

​ 运行时常量池的一个特点就是动态性,并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量放入。用得较多的就是String::intern()方法。

​ 这部分在方法区内所以也有OOM的风险。

直接内存

​ 这部分并不属于运行时数据区域,但是又频繁使用,而且会导致OOM的风险。

​ 在JDK1.4中新加入了NIO类,引入了基于管道和缓冲区的I/O方式,它可以还是用Native函数库直接分配堆外内存,然后通过一个存储在堆里的DirectByteBuffer对象作为这片内存的引用操作,这样能提高性能,不用在java堆和Native堆上来回复制数据。

​ 显然直接内存不会受到堆内存大小的限制,但是肯定是受到本机总内存的限制,一般服务器管理员如果在配置虚拟机内存管理大小时忽略掉这部分大小,可能就导致总使用内存大于实际内存大小。从而导致动态扩展时导致OOM异常。

HotSpot虚拟机对象探秘

这一章节我们讨论HotSpot虚拟机上Java堆中对象的分配,布局和访问的全过程

对象的创建

​ 当虚拟机遇到一个字节码new指令时,会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个类有没有加载,解析,初始化。如果没有则必须先加载类。

​ 在一切准备工作完成后,虚拟机需要给新生对象分配内存空间,而该对象需要多大的空间在类的加载后就可以确定了,而为对象分配内存空间也就是把一块儿确认大小的内存块儿从堆空间中划分出来。这里有两种分配方式:

  1. 假如堆中内存是绝对规整的,所有被使用的内存块儿放在一边,另一边是空闲的内存,中间放一个指针作为分界点。这样当需要新的空间时,只需要让指针平移与对象大小相等的距离,这种分配方式叫做指针碰撞。

请添加图片描述

  1. 如果堆中空间并不是规整的,被使用的区域和空闲区域交叉在一起,则不能只用指针碰撞,而只能维护一个列表,记录哪些内存块儿可用。在分配时在列表中找一个足够大的区域进行分配,并更新列表。

请添加图片描述

​ 使用哪种分配方式是由堆是否规整决定的,而堆是否规整是由采用的垃圾回收器是否带有空加压缩整理的能力决定的。

除了如何划分空间以外,还有一个问题,对象创建在虚拟机中是非常频繁的操作,在多线程下,仅修改指针指向位置有这线程安全问题,比方说正在给对象A分配空间,指针还没有移动,对象B又使用原来的指针修改了。

​ 这里有两种解决方案:

  1. 对分配动作进行同步处理,而实际中的虚拟机采用CAS来保证原子性和安全性。
  2. 把内存分配动作按照线程划分在不同的地方,即每个线程提前在堆中间分配一块儿空间,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,需要分配新的空间时再进行同步操作。是否使用TLAB,可以通过-XX:+/-UseTLAB来设定。

​ 在内存分配完成后,虚拟机必须将分配到的内存空间都初始化为0值,保证了对象的实例字段在java代码中可以不赋初始值就能直接使用。

​ 接下来,虚拟机还需要对对象进行必要的设置,例如:这个对象是哪个类的实例(你是谁家的?),如何找到类的元数据信息(你家在哪?),对象的Hash值是多少(你身份证号多少?),对象的GC分代年龄(你多大?成年没?)

​ 上面工作做完后,从虚拟机的角度看,一个新的对象已经创建好了,但从Java程序来看,对象的创建才刚刚开始——构造函数。也就是Class文件的()方法没有执行,所有的字段为默认的零值,一般来说new指令之后会接着执行()方法,按照程序设定来完成。这样一个可用对象就完成了。

对象的内存布局

​ 在HotSpot虚拟机中,对象在堆的存储布局分为:对象头,实例数据,对齐填充

  1. 对象头有两类信息
    1. 存储对象自身的运行时数据,例如:哈希值,GC年龄,锁状态等数据。在32和64位虚拟机中分别为32位个bit和64个bit,官方称为 Mark Word ,但对象运行时数据很多,超过了32,64位Bitmap所能记录的最大限度,但对象头内的数据是对象自身定义数据的额外成本,所以被设计成一种动态的数据结构。
    2. 另一部分为类型指针,也就是虚拟机根据该指针来确定该对象是哪个类的实例,此外如果是数组,还需要记录数组的长度。
  2. 实例数据,这里就存放着对象自身定义和从父类继承下来的各种类型的字段。HotSpot虚拟机默认分配顺序为longs、doubles、ints、shorts、chars、bytes、booleans,可以发现相同宽度的字段放在一起,且父类定义的会放在子类之前。如果参数设置:+XX:CompactFields参数为true(默认为true),子类中比较小的变量也允许插入到父类变量的空隙中,节省空间。
  3. 对齐填充,仅仅起到占位的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说任何对象的大小都必须是8字节的整数倍。对象头部分已经被设计成了8字节的倍数。

对象的访问

​ Java程序会通过栈上的reference数据来操作堆上的具体对象,它只是一个指向对象的引用。所以对象的访问方式也是又具体的虚拟机实现的,主流的访问方式是使用句柄和直接指针两种。

  1. 通过句柄
    1. 好处:reference中存放的稳定句柄地址,对象被移动(垃圾回收)时只改变句柄中的实例数据指针。reference本身不变。

请添加图片描述

  1. 通过直接指针(默认)
    1. 优点:一次访问,速度块

请添加图片描述

实战:OutOfMemoryError异常

​ 在Java虚拟机规范中,除了程序计数器以外,其他区域都有发生内存溢出的可能,这一节通过代码验证各个运行时区域存储的内容,并且将初步介绍若干最基本的HotSpot虚拟机参数。

​ 另外希望读者在实际工作中遇到内存异常时,能根据提示信息知道是哪个区域发生了异常,并且知道什么样的代码可能导致溢出。提示如果使用的控制台控制程序,直接在java后写参数即可,我这里使用的idea。

请添加图片描述

Java堆异常

​ 堆是存放对象实例的,所以当创建过多的对象并没有及时的GC掉就会挤满堆空间。

 static class HeapOOM{}
    /**
     * vm args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
     * 限制java堆的大小为20MB,不可扩展
     * -XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机出现内存溢出异常时
     * Dump出当时的内存堆转储快照以便进行事后分析
     * 
     * java.lang.OutOfMemoryError: Java heap space
     */
    @Test
    public void heap() {
        ArrayList<HeapOOM> list = new ArrayList();
        while (true) {
            list.add(new HeapOOM());
        }
    }

​ 当出现java.lang.OutOfMemoryError: Java heap space 表示堆空间发生问题,要解决这个问题,我们需要分析映射出的Dumping heap to java_pid753.hprof ...文件。通过内存映像分析工具对Dump出来的堆转储快照进行分析。这里我们使用JProfiler来控制

  1. 第一步确认到底是内存溢出还是内存泄露

    1. 内存溢出是指我无法申请到更多的空间
    2. 内存泄露是指我申请了空间,却无法释放已经申请的空间。

    内存泄露 会导致 内存溢出

  2. 如果是内存泄露,可以根据工具查看泄漏对象到GC Roots的引用链,什么原因导致无法被垃圾回收掉。

  3. 如果不是内存泄露,说明内存中的对象确实需要一存活,则应该检查-Xms -Xmx设置大小,看是否还可以多设置一些内存,再从代码上看,是否某些对象生命周期过长,存储结构不合理等。

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

​ 在虚拟机中并不区分虚拟机栈和本地方法栈,只能通过-Xss参数化来设定,其中只有两种异常:

  1. 栈深度大于虚拟机允许的最大深度:爆出StackOverflowError异常。
  2. 如果栈内存允许动态扩充,当无法再申请时爆出OOM。
/**
     * 虚拟机栈中出现的异常分为,超出栈限制的最大深度,抛出StackOverFlowError
     * 如果允许栈内存动态拓展,则会在无法申请到更多内存时,抛出OOM
     * HotSpot虚拟机选择的是不支持扩展,所以多数是抛出StackOverFlowError
     * 在单线程下,可能会因为递归不断地插入新的栈帧而将栈挤爆
     * 在多线程下,也可能单个线程的栈还没有溢出,申请线程太多也会造成内存溢出OOM
     * vm args:-Xss限制栈内容量
     */
    @Test
    public void stack() throws Throwable {
        try {
            stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length : "+stackLength);
            throw e;
        }
    }

​ 出现栈溢出还是比较好确定位置的。栈溢出的话我们可以采取扩容和尾递归优化等方法记性扩容,如果是多线程导致的内存溢出,且不能减少内存数量的情况,只能通过减少最大堆和减少栈容量来换取更多的线程。

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

	这里使用String.intern()方法来测试,该方法会先判断字符串是否在字符串常量池内,如果已经存在,则直接返回地址,如果没有则创建一对象,并返回地址。而在JDK6以前,字符串常量池是在永久代的,所以可以通过`-XX:PermSize -XX:MaxPermSize`来限制永久代的大小,即可间接限制字符串常量池的大小。运行结果为:OOME:PermGen space

​ 但在JDK7,8以后再使用该参数就没有用了,因为字符串常量池一定转移到堆中了,使用-Xms来控制堆的大小。

/**
     * 在方法区和运行时常量池溢出
     * 通过String.inter()方法,该方法会先从字符串常量区寻找该对象,如果有直接返回,如果没有则创建并返回
     * JDK6以前 设置vm args:-XX:PermSize=6m -XX:MaxPermSize=6m 控制方法区大小 会爆出:OOM:PermGen space,
     * 说明这个时候的字符串常量池是在方法区中
     * JDK7以后 设置上述参数已经没有用了,字符串常量池被放在堆空间中,只能通过设置-Xmx参数限制堆大小可反应出来
     * vm args: -Xmx6m  爆出错误:OutOfMemoryError:GC overhead limit exceeded,这个代表当前已经没有可用内存了,经过多次GC之后仍然没有有效释放内存
     * -Xmx6m -XX:-UseGCOverheadLimit   爆出错误:OutOfMemoryError: Java heap space  说明字符串常量池已经转移到了堆空间中
     */
    @Test
    public void permGenSpace() {
        HashSet<String> set = new HashSet();
        int i = 1;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }

这里还有一个面试题

/**
     * 这个是String.intern()经典的问题
     * 当JDK6中得到的结果是两个false
     *      因为JDK6中 intern()方法将首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是字符串常量池的引用
     *      而StringBuilder创建的对象是在堆上面的,所有两者的地址当然不同
     * 而在JDK7中
     *      inter()方法就不需要再拷贝到字符串实例到常量池中了,字符串常量池已经在堆中了,只需要在字符串常量中记录一下实例引用就可以了
     *      而关于java的intern()爆出false,是因为在类加载的时候就有一个静态变量加入到字符串常量池了,所以后面再创建的就 不是同一个对象了
     *      System  --->  initializeSystemClass()  --->   sun.misc.Version.init();  其中Version类在加载的时候创建了一个静态变量
     *      private static final String launcher_name = "java";
     */
    @Test
    public void stringInternTest(){
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1 == str1.intern());
        //这里是直接在堆中创建了一个StringBuilder对象
        StringBuilder builder = new StringBuilder("ja").append("va");
        //去字符串常量池找是否有java,有则返回地址,没有则创建后放回
        String intern = "java".intern();
        //这里是根据StringBuilder的值先去看字符串常量池是否有,有则返回,没有则创建
        String str2 = (String) builder.toString();
        System.out.println("builder:"+builder.hashCode());
        System.out.println("str:"+str2.hashCode());
        System.out.println("or_str:"+intern.hashCode());
    }

方法区也是放置类型信息的地方,如果动态生成大量的类,也会导致方法区溢出

/**
     * 关于方法去的内存溢出,注意:JDK6及其以前,方法区也被承认永久代,因为hotspot团队将堆空间的分代思想来构造方法区
     * 这样,就不用在针对方法区构建代码了,但是后来从JDk7开始将一些内容从方法区中抽离,而JDK完全使用元空间来代替永久代
     * 而一般类加载信息就会放到这部分中,我们可以通过不断地加载新的类,造成OOM
     * 这一块儿我们使用动态代理不断地创建新的类,需要注意的是这里并非纯粹的实验,许多主流的框架例如Spring都会使用到动态代理
     * 来创建新的代理类,而增强类越多,方法区就要越大,这样需要注意可能导致方法区的OOM
     * JDK7使用  JDK8中该指令已经remove了
     * vm args: -XX:PermSize=10M -XX:MaxPermSize=10M
     * JKD8使用
     * -XX:MaxMetaspaceSize:设置元空间的最大值,默认是-1,即不限制
     * -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,当触及该值时,进行垃圾回收
     *      ,同时收集器对该值进行调整,如果释放大量空间则将该值降低,如果释放很少的话,则会在最大值下相应提升
     * -XX:MinMetaspaceFreeRatio:作用是在垃圾回收之后控制最小的元空间剩余容量的百分比,可减少因为元空间的不足导致的
     *      垃圾回收的频率
     * -XX:MaxMetaspaceFreeRatio:控制对最大的元空间剩余容量的百分比
     *
     * Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
     */
    @Test
    public void methodArea() {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(objects,objects);
                }
            });
            enhancer.create();
        }
    }
static class OOMObject{}

直接内存溢出

/**
     * 直接内存,不属于运行时数据区,但这部分内存被频繁使用,而在JKD 1.4后加入了NIO
     * 它可以使用Native函数库直接分配堆外内存然后通过一个缓存在堆中的DirectByteBuffer对象
     * 作为这块儿内存的引用来操作,能显著的提高性能,因为避免了在Java堆和Native堆中来回的复制数据
     *
     * 容量可通过:-XX:MaxDirectMemorySize参数来设定大小,如果不设定默认使用java堆的最大值(-Xmx指定)
     *
     * java.lang.OutOfMemoryError
     * 	at sun.misc.Unsafe.allocateMemory(Native Method)
     * 	at OOM_EXP.text.DirectMemory(text.java:148)
     *
     * 由直接内存导致的内存溢出,明显特征是在Heap Dump文件中不会看见什么明显的异常情况
     * 如果发现内存溢出后的Dump文件很小,而程序中又间接的使用了DirectMemory(典型的间接使用就是Nio)
     * 那可以着重的检查直接内存方面的原因了
     */
    @Test
    public void DirectMemory() throws Exception{
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
    private static final int _1MB = 1024*1024;

​ 由直接内存导致的内存溢出的一个明显的特征就是dump文件不会有明显异常情况,dump文件很小,如果程序中又使用了NIO,则需要重点关注。

总结

​ 现在我们已经知道虚拟机大概的划分,和各个区域存储什么信息,什么样的代码操作会导致,虽然Java中有垃圾回收,但如果不注意就会导致问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值