深入jvm:Java内存区域与内存溢出异常


简介

Java虚拟机官方文档:https://docs.oracle.com/javase/specs/index.html

内存区域划分

根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。

程序计数器

也叫pc寄存器或程序的钩子

  • 是当前线程所执行的字节码的行号指示器
  • 储了下一条需要执行的字节码指令的地址
  • 控制程序流转依赖与此计数器,如分支,循环,跳转,异常处理,线程恢复都需要依赖此计数器
  • 在多线程中,一个处理器都只会执行一条线程中的指令。为了线程能够恢复到正确的位置,每条线程都有一个独立的程序计数器,各个线程之间计数器独立不影响。
  • 这是一块线程独有的内存区域,这也是一块在虚拟机规范中唯一一个没有规定内存溢出的区域

指令执行流程

Java虚拟机栈

  • 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同
  • 栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态连接方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 这个区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展[2],当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
  • 如何设置栈的大小:参数 -Xss255k 设置当前线程栈的大小

栈帧结构

栈帧:每个线程启动时都会创建一个虚拟机栈,栈中存的就是栈帧,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程,是一块内存区域存着执行方法的各个数据,如局部变量表操作数栈动态连接方法出口附加信息从Class文件格式的方法表中找到以上大多数概念的静态对照物。

局部变量表,操作数栈的大小在编译期间就被确定(写入到class文件方法表的Code属性之中),也就是说栈帧的大小是提前确定好的。
只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作

局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

  • 用槽(slot)存放数据类型
  • 容量大小在编译时就已经确定了。
  • 除了 long 和 double 类型需要用到 2个slot(连续的),其他的数据类型(boolean,byte,char,shot,int,flot,reference,returnAddress)只需要一个 slot
  • 槽(slot)可以复用(会影响到系统的垃圾收集)
  • 通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量
  • 当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽

存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个(每个slot都会分配一个索引,jvm通过索引即可访问变量表的值。方法中的参数和变量按照声明顺序放入变量表)。 局部变量表所需的内存空间在编译期间完成分配。slot是可以重复利用的,当一个槽位的局部变量过了作用域,那么之后的变量可能会利用该槽位,节省资源。局部变量表中的变量也是重要的垃圾回收的根节点,只要被局部变量表所引用的对象都不会被回收。

安装jclasslib即可查看局部变量表

或者使用javap -v 文件名.calss查看局部变量表

变量槽的复用会直接影响到系统的垃圾收集行为演示:

/**
 *VM options : -verbose:gc
 * 在内存种创建64m空间,查看垃圾收集时是否回收
 */
public class GCPrint {
    @Test
    public void test0(){
        byte[] bytes = new byte[1024 * 1024 * 64];
        System.gc();
    }

    @Test
    public void test1(){
        {
            byte[] bytes = new byte[1024 * 1024 * 64];
        }
        System.gc();
    }
    @Test
    public void test2(){

        {
            byte[] bytes = new byte[1024 * 1024 * 64];
        }
        int a= 0;
        System.gc();
    }
}

运行结果test0:没有被回收(槽没有新变量来替换)
[GC (System.gc())  74195K->67384K(125952K), 0.0020189 secs]
[Full GC (System.gc())  67384K->67051K(125952K), 0.0101357 secs]

运行结果test1:没有(用了新的变量槽)
[GC (System.gc())  74204K->67296K(125952K), 0.0019641 secs]
[Full GC (System.gc())  67296K->67051K(125952K), 0.0113122 secs]

运行结果test2:回收了(复用了之前的变量槽)
[GC (System.gc())  74201K->67328K(125952K), 0.0021333 secs]
[Full GC (System.gc())  67328K->1514K(125952K), 0.0098357 secs]

bytes能否被回收的根本原因就是:局部变量表中的变量槽是否还存有关于bytes数组对象的引用。第一次修改中,代码虽然已经离开了bytes的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,bytes原本所占用的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。

通过以上代码想到Java语言的一本非常著名的书籍《Practical Java》中将把“不使用的对象应手动赋值为null”作为一条推荐的编码规则,虽然初衷相同,但是给不用对象赋值null,在编译优化后几乎是一定会被当作无效操作消除掉的,这时候将变量设置为null就是毫无意义的行为。

操作数栈(表达式栈)

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。

  • 在方法执行过程当中,根据字节码指令写入或提取数据(入栈或出栈)。比如求和,复制,运算指令。
  • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
  • 基于数组实现的栈结构。并不通过索引访问,而是进栈出栈。
  • 其最大深度在编译期就确定好了,最大长度保存在方法的code属性max_stack中。栈帧入栈时操作数栈刚开始为空,通过执行字节码指令数值不断进栈出栈。
  • 保存任意Java数据类型,32位以内占用一个栈单位,64位占用两个栈单位。
  • 如果被调用的方法有返回值,其返回值会被压入到当前栈帧的操作数栈当中,并更新pc寄存器执行下一条指令
  • jvm解释引擎是基于栈的执行引擎,此栈就是操作数栈

执行流程

栈顶缓存技术:由于操作数栈是在内存当中,频繁的读写内存必然会影响性能,hotspot设计者提出将栈顶的数据全部存储在cpu寄存器当中,以降低对内存的读写。

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈

动态链接

指向运行时常量池的引用。每个栈帧内部都记录了一个指向运行时常量池该栈帧方法调用的引用地址,记录这个地址的目的为了该方法的代码能够实现动态连接。Java文件转换为字节码文件时,所有的常量和方法都作为符合引用存放在常量池,动态连接就是将这些引用转换为调用方法时的直接引用

此图上的就是动态链接

这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

也就是说动态链接可以确定运行期间的被调用的方法引用

那么如确定调用的方法?

Class文件的编译过程中,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

方法的调用:将calss文件中的符号引用转化为调用方法的直接引用与方法的绑定机制有关(Java的多态由此实现)

  • 方法的链接方式

    • 静态链接:被目标调用者的方法在编译期就确定,这种将符号引用转化为方法的直接引用称为静态链接
    • 动态链接:被调用的方法在编译期无法确定,只能在程序运行时将符号引用转为直接引用称为动态链接。
  • 方法的绑定方式

    • 早期绑定:被调用的目标方法在编译期就可知,且在运行期不变,被调用的方法就可在编译期就与调用目标的所属类型绑定。这样一来就确定了被调用的目标方法是哪一个,因此可以使用静态链接将符号引用转为直接引用。
    • 晚期绑定:被调用的方法无法在编译期确定,只能在运行期绑定所属类型,这种只能采用动态链接将符号引用转为直接引用
  • 方法的分类

    • 非虚方法:静态方法,私有方法,final方法,构造方法,父类方法。
      • 这类方法可以被invokestatic和invokespecial指令调用(final方法被invokevirtual指令调用),并且都可以在解析阶段中确定唯一的调用方法是哪个
    • 虚方法:其他都是虚方法。
  • 方法调用指令

    • invokedynamic:Java7之后引入指令,为了实现动态类型语言的支持(类型检查是在编译期间或运行期间)。
    • invokestatic :静态方法调用。
    • invokespecial : 构造方法,父类方法,私有方法。
    • invokevirtual : 调用所有的虚方法,和final修饰的方法。
    • invokeinterface :调用接口方法
  • 方法的重写

  • 虚方法表 :因为方法重写的原因,需要不断向上查找,为了减少此开销建立虚方法表

静态方法解析演示:通过Javap查看calss反编译

那么在Java的多态中,方法是如何分派的?

静态方法多态分派演示:

/**
 * 方法静态分派演示
 */
public class StaticDispatch {
    static abstract class Human {
    }
    static class Man extends Human {
    }
    static class Woman extends Human {
    }
    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }
    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}

运行结果:
hello,guy!
hello,guy!

待解析…..

方法返回地址

又称方法出口。当一个方法开始执行后,只有两种方式退出这个方法:

  • 正常退出,执行引擎遇到任意一个方法返回的字节码指令。方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定
  • 异常退出:在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

不同情况下执行的指令:

  • 正常结束:执行引擎遇到字节码的返回指令(void方法默认存在return指令),将方法的返回值传递给上层调用者。指令包含——ireturn(byte,boolean,short,char,int),lreturn,fretrun,dreturn,areturn(引用类型),
  • 异常结束:在方法执行过程中遇到异常,在方法的异常表中没有找到处理异常的处理器就会导致方法异常退出(没有进行异常处理)——-异常处理表:字节码方法中expection table

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等

一些附加信息

与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现

本地方法栈

  • 本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务
  • 这块区域在请求栈深度过长或者扩展失败时分别抛出StackOverflowError和OutOfMenoryError。
  • 线程私有
  • 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一

    作用:与Java环境外的交互。与操作系统交互。与sun‘s Java解释器交互。

Java堆

  • 虚拟机管理的最大的一块内存空间,被Java所以线程所共享的一块内存,虚拟机启动时创建。这块区域的目的就是为了存放Java对象的实例。虚拟机规范规定“所有的对象实例以及数组都应当在堆上分配”,随着发展已不是那么绝对。如果没有发生逃逸则可能在栈上分配
  • Java堆时垃圾收集器管理的内存区域,也被称GC堆,垃圾收集器都是基于经典分代来设计(分代设计—-年轻代,老年代。各个年代又基于不同算法实现)。
  • 所有线程共享的Java堆可以划分出多个线程私有的分配缓冲区(TLAB),以提升对象分配的效率
  • 无论堆怎么分配,堆中存储的都只能是对象的实例。将堆细分的目的就是为更好的垃圾回收。如果对象实例超出了堆的空间那么将抛出OOM。
  • 堆的内存结构:不同虚拟机实现方式不同。作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代”来设计
  • 参数设置::
-Xms600m 堆初始大小
-Xmx600m 堆最大值
-XX:NewRaito=1:2 新生代:老年代比例 (默认1:2)
新生代内存划分:
 -XX: +useAdaptiveSizePolicy  用+-是否关闭内存自适应分配。
 -XX:SurvivorRatio=8  设置Eden和survior比例
 -XX:MaxTenuringThreshold =15  (调整进入老年代的年龄)
 是否开启TLAB:-XX:+UseTLAB  
 设置TLAB在Eden区占比:-XX:TLABWasteTargetPercent (默认占比1%)
 查看所有jvm参数的默认值:-XX:+PrintFlagsInitial
 查看jvm参数最终值:-XX:+PrintFlagsFinal
 -XX:+PrintHeapAtGC 每次gc之后印堆信息。

逃逸分析:方法内部创建的对象是否在外部被调用,或者外部线程调用。-XX:DoEscapeAnalysis 开启逃逸分析(jdk7以后默认开启) -XX:PrintEscapeAnalysis 逃逸分析结果。

经典分代:指新生代(其中又包含一个Eden和两个Survivor,有时也叫from区,to区)、老年代这种划分。
Java7:新生代+老年代+永久区(方法区的实现)
Java8:新生代+老年代+元空间(方法区的实现)

Jvisualvm 链接虚拟机 可查看堆的内存结构

方法区

  • 各个线程共享的区域,
  • 如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常(方法区的大小决定了系统可以保存多少个类)。
  • 这块区域主要用于存放加载的类型信息常量静态变量即时编译的代码缓存等数据。
  • 规范中将方法区描述成堆的一个逻辑部分,为了区分别名为“非堆”。目的是与“堆”区分开来
  • 《Java虚拟机规范》对方法区的约束是非常宽松的,可以选择不实现垃圾收集,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
  • 运行时常量池也是方法区的一部分。

类型信息

  1. class信息:
    • 这个类型的全限定类名(包名)
    • 这个类型的直接父类(interface,object都没有父类)
    • 这个类型的修饰符(public,abstract,final)
    • 这个类型直接接口的有序列表
  2. field信息:
    • 字段名,字段类型,字段修饰符
  3. method信息:
    • 方法名称,返回类型,参数,修饰符,字节码、操作数栈、局部变量表、异常表
  4. 类的加载器

运行时数据区结构图

堆,栈,方法区交互关系

在JDK 8以前,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替

参数设置:

jdk7:-XX:MaxPermSize 
jdk8: MetaspaceSize=10m -XX:MaxMetaspaceSize=100m(废弃永久代,使用元空间 直接使用本地内存,默认无大小)
本地内存即与操作系统有关:
windows 默认大小是21m , Linux为-1(机器所有可用内存大小)

运行时常量池区

  • 常量池分为:class文件常量池和运行时常量池。通过jvm加载后的class文件常量池就是运行时常量池,即常量可以在程序运行期间创建。
  • Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

运行时数据区交互图

方法区jdk6,jdk7,jdk8演进细节

字符串常量池:StringTable
为什么字符串常量池要调整?
因为方法区垃圾回收效率低。只有full GC时才会回收方法区,full GC的触发条件是老年代空间不足时才会触发。而实际开发过程中会频繁的创建字符串,回收效率低,导致方法区空间不足,移至堆中,方便回收。

如何查看静态变量存放位置?

直接内存

  • 并不是虚拟机运行时数据部分(本地内存)。主要时在nio设计中被大量使用,目的时减少Java堆和内存数据复制,提升性能。
  • 可以通过参数设置 MaxDirectMemorySize
  • 会发生OutOfMemoryError异常

    在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

Java对象在内存中的结构

对象的创建

创建对象的4种方式

  • new关键字
  • 反射
  • 反序列化
  • 克隆

通过new创建对象:(其他方式不做讨论)

  1. 当jvm遇到new指令时会去方法区中的常量池能否定位到这个类的符号引用,并且检查这个类是否被加载,解析和初始化过,如果没有那么必须执行类加载过程。
  2. 类加载通过后,开始为新生对象分配内存。一般分配内存有两种方式,“指针碰撞”和“空闲列表”。这两种分配方式又取决于Java堆的空闲内存和使用过的内存是否连续规整的。而堆的是否规整又是由垃圾收集器是否拥有空间压缩整理的能力决定的。(对象在分配中也是存在线程安全的,hotspot采用了cas和TLAB解决此安全问题)
  3. 内存分配完毕之后jvm将分配到的内存空间(不包括对象头)都初始化为零,如果是TLANB内存分配的话这项工作提前到TLAB分配时进行。这部操作保证了程序可以访问对象实例字段的零值
  4. 接下来jvm才会对对象头进行设置。这些设置包括对象属于哪个类的实例,元数据信息,对象哈希码(实际上调用hashcode方法时设置),GC分代年龄,锁信息。
  5. 在jvm层面已经完成了对象创建,但是从Java程序而言对象创建才刚刚开始,此时会执行构造函数指令,按照程序代码对对象进行初始化,此时一个对象才被真正创建!

指针碰撞:用于内存规整的内存区域。指针不断移动,直至找到空闲的区域
空闲列表:用于不规则的内存区域。用列表记录了空闲的内存碎片。

对象的内存布局

对象在内存中的布局分为三部分(在hotspot虚拟机里):对象头实例数据对齐填充

对象头

对象头包含两部分信息:Mark Word类型指针

Mark Word:主要用于存储对象自身运行时数据,如哈希码,GC分代年龄,锁状态,线程持有的锁,偏向线程id,偏向时间戳等。(32位系统中其中25位表示哈希码,4个比特表示分代年龄(所有对象从新生代到老年代需要GC15次),2个比特表示锁标志,1个比特固定为0)

类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。

如果对象是数组,则对象头还需要记录用一块空间记录内存长度。

实例数据

我们在程序代码里面所定义的各种类型的字段内容,包括从父类继承下来的。

分配规则:

  • 默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)(相同宽度字段分配到一起)。
  • 父类变量会在子类前面
  • 如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间

对齐填充

主要起到占位的作用。因为hotspot分配对象内存必须要求是8字节的整数倍,如果不满足则补充到8字节的整数倍,满足就无需此区域。

运行时数据区协作图

对象的访问定位

那么如果访问对象?Java栈上本地变量表记录引用指针,jvm规范没有规定访问堆的对象访问方式,主流有两种方式:句柄访问直接访问

句柄访问:Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息

直接访问:Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,

两种访问方式的优缺点:

  • 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
  • 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本

如何排查内存溢出(OutOfMemoryError)

目的:

  • 通过代码验证《Java虚拟机规范》中描述的各个运行时区域储存的内容;
  • 在工作中遇到实际的内存溢出异常时,能根据异常的提示信息迅速得知是哪个区域的内存溢出,知道怎样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。

Java堆溢出

参数设置:-Xmx和-Xms设置堆大小。 -XX:+HeapDumpOnOutOfMemoryError开启内存溢出快照打印
出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。

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

运行结果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3404.hprof ...
Heap dump file created [22045981 bytes in 0.663 secs]
  1. 设置jvm启动参数,-XX:+HeapDumpOnOutOfMemoryError 让虚拟机内存溢出时Dump出内存快照方便事后分析。
  2. 利用内存分析工具,jvisualvm或者Eclipse Memory Analyzer 对Dump文件进行分析。先分析是内存溢出还是内存泄漏
  3. 如果是内存泄漏,那么通过工具查看对象的GC Roots的引用链,找到代码位置,如果内存溢出那么就要考虑是否要调整堆大小还是修改代码

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

此区域会出现两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

异常演示:

StackOverflowError异常:会直接提示哪个方法溢出,直接定位代码

  1. 超出虚拟机栈的深度:递归调用产生:StackOverflowError。
/**
 * VM Args:-Xss128k
 */
public class JavaVMStackSOF_2 {

    private int stackLength = 1;

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

    public static void main(String[] args) {
        JavaVMStackSOF_2 oom = new JavaVMStackSOF_2();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

运行结果:
stack length:2402
Exception in thread "main" java.lang.StackOverflowError
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:20)
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21)
at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21)
  1. 栈帧太大:
/**
 * VM: JDK 1.0.2, Sun Classic VM
 */
public class JavaVMStackSOF_3 {
    private static int stackLength = 0;

    public static void test() {
        long unused1, unused2, unused3, unused4, unused5,
                unused6, unused7, unused8, unused9, unused10,
                unused11, unused12, unused13, unused14, unused15,
                unused16, unused17, unused18, unused19, unused20,
                unused21, unused22, unused23, unused24, unused25,
                unused26, unused27, unused28, unused29, unused30,
                unused31, unused32, unused33, unused34, unused35,
                unused36, unused37, unused38, unused39, unused40,
                unused41, unused42, unused43, unused44, unused45,
                unused46, unused47, unused48, unused49, unused50,
                unused51, unused52, unused53, unused54, unused55,
                unused56, unused57, unused58, unused59, unused60,
                unused61, unused62, unused63, unused64, unused65,
                unused66, unused67, unused68, unused69, unused70,
                unused71, unused72, unused73, unused74, unused75,
                unused76, unused77, unused78, unused79, unused80,
                unused81, unused82, unused83, unused84, unused85,
                unused86, unused87, unused88, unused89, unused90,
                unused91, unused92, unused93, unused94, unused95,
                unused96, unused97, unused98, unused99, unused100;

        stackLength ++;
        test();

        unused1 = unused2 = unused3 = unused4 = unused5 =
                unused6 = unused7 = unused8 = unused9 = unused10 =
                        unused11 = unused12 = unused13 = unused14 = unused15 =
                                unused16 = unused17 = unused18 = unused19 = unused20 =
                                        unused21 = unused22 = unused23 = unused24 = unused25 =
                                                unused26 = unused27 = unused28 = unused29 = unused30 =
                                                        unused31 = unused32 = unused33 = unused34 = unused35 =
                                                                unused36 = unused37 = unused38 = unused39 = unused40 =
                                                                        unused41 = unused42 = unused43 = unused44 = unused45 =
                                                                                unused46 = unused47 = unused48 = unused49 = unused50 =
                                                                                        unused51 = unused52 = unused53 = unused54 = unused55 =
                                                                                                unused56 = unused57 = unused58 = unused59 = unused60 =
                                                                                                        unused61 = unused62 = unused63 = unused64 = unused65 =
                                                                                                                unused66 = unused67 = unused68 = unused69 = unused70 =
                                                                                                                        unused71 = unused72 = unused73 = unused74 = unused75 =
                                                                                                                                unused76 = unused77 = unused78 = unused79 = unused80 =
                                                                                                                                        unused81 = unused82 = unused83 = unused84 = unused85 =
                                                                                                                                                unused86 = unused87 = unused88 = unused89 = unused90 =
                                                                                                                                                        unused91 = unused92 = unused93 = unused94 = unused95 =
                                                                                                                                                                unused96 = unused97 = unused98 = unused99 = unused100 = 0;
    }

    public static void main(String[] args) {
        try {
            test();
        }catch (Error e){
            System.out.println("stack length:" + stackLength);
            throw e;
        }
    }
}

OutOfMemoryError异常:线程过多(无法在横向扩展)内存溢出,也有直接提示

/**
 * VM Args:-Xss2M (这时候不妨设大些,请在32位系统下运行)
 */
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) throws Throwable {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread

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

由于方法区和运行时常量池在不同jdk中不断的变化。所以设置的参数也不相同。

  • jdk6,永久代即方法区 -xx:permsize 和-xx:maxPermSize设置
  • jdk7用-XX:MaxPermSize参数
  • JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数
/**
 * VM Args:jdk6: -XX:PermSize=10M -XX:MaxPermSize=10M
 *          jdk7:-XX:MaxPermSize
 *          jdk8:-XX:MaxMeta-spaceSize
 * 
 */
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() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {
    }
}
运行结果:
jdk6 :
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)

jdk7:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.lang.Integer.toString(Integer.java:440)
at java.base/java.lang.String.valueOf(String.java:3058)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)

jdk8:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.HashMap.resize(HashMap.java:699)
at java.base/java.util.HashMap.putVal(HashMap.java:658)
at java.base/java.util.HashMap.put(HashMap.java:607)
at java.base/java.util.HashSet.add(HashSet.java:220)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFile-Object:14)

在JDK 8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
  • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值
  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

本机直接内存溢出

  • 可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致
/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 */
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);
        }
    }
}
运行结果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)

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


文章作者: Needle
转载声明:本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Needle !
  目录
  评论