深入理解JVM:Java内存区域与内存溢出异常

来自Wikioe
跳到导航 跳到搜索


概述

Java与C++之间有一堵由内存动态分配垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

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

Java虚拟机运行时数据区.jpg

程序计数器(Program Counter Register)


程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。


  • 线程私有:每条线程都有一个独立的程序计数器,条线程间计数器互不影响;
  • 此内存区域是“唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域”。
  • 执行本地方法时,程序计数器为空:
    如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
    如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)


Java虚拟机栈(Java Virtual Machine Stack)


用于描述Java方法执行的线程内存模型

  1. 方法的执行,对应于栈中栈帧(Stack Frame)的创建;
  2. 方法的调用直至执行完毕的过程,对应栈帧在虚拟机栈中从入栈到出栈的过程。


  • 栈帧,用于存储“局部变量表”、“操作数栈”、“动态连接”、“方法出口”等信息:【方法相关信息】
    1. “局部变量表”:包括方法参数、方法内变量
    2. “操作数栈”:(先入后出)元素时任意Java类型,随着方法的执行而进行入栈出栈操作
    3. “动态连接”:class文件中,方法调用其他方法时,将其他方法的符号引用(存在于运行时常量池对应方法信息中)转化为其内存的直接引用,即动态链接。
      【每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用】
    4. “方法出口”:包括正常出口、异常出口


  • 线程私有:生命周期与线程相同
  • 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:
    如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;(请求大于栈深)
    如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。(扩展超出内存)

局部变量表

局部变量表存放了编译期可知的各种Java虚拟机:

  1. 基本数据类型(boolean、byte、char、short、int、float、long、double)、
  2. 对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)
  3. returnAddress类型(指向了一条字节码指令的地址)。


  • 局部变量表所需的内存空间在编译期间完成分配:
   这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
   (请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。)

本地方法栈(Native Method Stacks)


与“虚拟机栈”作用相似的,区别虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务,即“Native方法执行的线程内存模型”。


  • 线程私有;
  • 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出“StackOverflowError”和“OutOfMemoryError”异常。
  • HotSpot 的栈包括了虚拟机栈和本地方法栈
    《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

Java堆(Java Heap)


内存区域的唯一目的就是存放对象实例以及数组,Java世界里“几乎”所有的对象实例都在这里分配内存。


  • 所有线程共享,在虚拟机启动时创建;
  • 如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出“OutOfMemoryError”异常。
  • “GC堆”:Java堆是垃圾收集器管理的内存区域;
  • Java堆是“物理上不连续,逻辑上连续”的内存空间;
  • 通过“-Xmx”和“-Xms”设定堆大小:
    Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现。

方法区(Method Area)


用于存储已被虚拟机加载的“类型信息”、“常量”、“静态变量”、“即时编译器编译后的代码”缓存等数据。【类的相关信息(对比“虚拟机栈”的“栈帧”:存放方法的相关信息)】

虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。


  • 线程共享;
  • 如果方法区无法满足新的内存分配需求时,将抛出“OutOfMemoryError”异常。
  • JDK 8 之前,HotSpot用永久代来实现方法区;JDK 8 之后,废弃了永久代,改用“元空间”(Metaspace)来代替。

运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)用于保存静态常量引用和符号引用:

  • 【class文件中的“常量池表”,就加载到“运行时常量池”中】
  • Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。


  • 当常量池无法再申请到内存时会抛出“OutOfMemoryError”异常。
  • 运行时常量长的动态性:
    运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的“intern()”方法。
    【intern:若常量池中不存在等值的字符串,才创建新的字符串对象保存在常量池,并返回其引用】
  • JDK 7的HotSpot,字符串常量池、静态变量不再是永久代(方法区)的一部分;

直接内存


直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。


  • 不受Java堆大小的限制;
    只受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制;
  • 动态扩展时可能出现“OutOfMemoryError”异常。


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

HotSpot虚拟机对象探秘

对象的创建

对象的创建分为以下几个步骤:(仅讨论使用“new”创建普通对象;数组、Class对象等不在讨论中)

  1. 类的检查和加载:
    检查指令的参数是否能在常量池中定位到一个类的符号引用;
    检查这个符号引用代表的类是否已被加载、解析和初始化过(如果没有,则执行相应的类加载过程);
  2. 内存分配:
    (对象所需内存的大小在类加载完成后便可完全确定)
    1. 分配方式:(分配方式的选择,与JVM使用的GC方式相关)
      1. “指针碰撞”:(内存规整时)移动空闲空间的指针即可;
      2. “空闲列表”:(内存不规整)在JVM维护的空闲空间列表中,查找可用的、足够大的空间进行分配;
    2. 分配安全:
      1. “CAS”:(“乐观锁”)虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
      2. “TLAB”:(“本地线程分配缓冲”)把内存分配的动作按照线程划分在不同的空间之中进行;
  3. (内存空间初始化)
    即,将内存空间都初始化为零值,保证对象的域不赋初始值就直接使用。(如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行)
  4. 对象设置:
    在对象的对象头(Object Header)中设置,诸如“这个对象是哪个类的实例”、“如何才能找到类的元数据信息”、对象的哈希码、对象的GC分代年龄等信息。
  5. 对象初始化:
    即:执行“init”方法,按照程序员的意愿对对象进行初始化。

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  1. 对象头(Header):分为两个部分
    1. “Mark Word”:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;(在32位和64位JVM中分别为32和64bit)
    2. “类型指针”:即对象指向它的类型元数据的指针(Java虚拟机通过这个指针来确定该对象是哪个类的实例);
  2. 实例数据(Instance Data):对象真正存储的有效信息,即代码所定义的各种类型的字段内容(包括从父类中定义的)。
  3. 对齐填充(Padding):不是必然存在的,仅起着占位作用。


HotSpot虚拟机对象头Mark Word:

HotSpot虚拟机对象头Mark Word.jpg

对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的具体对象。而对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有两种:

  1. 使用句柄”:(Java堆中划分内存作为句柄池)reference中存储对象的句柄地址,而句柄中包含了对象“实例数据”与“类型数据”各自具体的地址信息;
  2. 直接指针”:reference中存储的直接就是对象地址(对象内部指针指向其类型数据);
  • HotSpot 使用的是“直接指针”方式;


句柄:

通过句柄访问对象.jpg

指针:

通过直接指针访问对象.jpg


比较:

  • 句柄:在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
  • 指针:节了一次指针定位的时间开销,访问速度更快,适用于对象访问在Java中非常频繁;

实战:OutOfMemeoryError异常

Java堆溢出

Java堆内存的“OutOfMemoryError”异常,运行结果如:

java.lang.OutOfMemoryError: Java heap space
   Dumping heap to java_pid3404.hprof ...
   Heap dump file created [22045981 bytes in 0.663 secs]


关于“内存泄漏”与“内存溢出”:

  1. “Memory Leak”:有内存无法掌控,如申请后无法释放的内存;
  2. “Memory Overflow”:内存不够,不能满足申请使用;


Note:

  • -Xms”:堆的最小值;
  • -Xmx”:堆的最大值;
    将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展;
  • “-XX:+HeapDumpOnOutOf-MemoryError”:让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照;
  • 内存映像分析工具(如Eclipse MemoryAnalyzer)可对Dump出来的堆转储快照进行分析;

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

虚拟机栈和本地方法栈中的两种异常:

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


  • 异常运行结果如:
    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)
       ……后续异常堆栈信息省略
    
    stack length:3716
    java.lang.OutOfMemoryError
       at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:27)
       at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28)
       at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28)
       ……后续异常堆栈信息省略
    


关于解决栈的内存溢出:

  • 如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)到达1000~2000是完全没有问题的。
  1. 一般情况下(栈帧太大或虚拟机栈容量太小),通过增大栈容量,可以解决“StackOverflowError”;
  2. 但是,如果是建立过多线程导致的内存溢出,则考虑通过“减少最大堆”和“减少栈容量”来换取更多的线程。
   操作系统分配给每个进程的内存是有限制的,譬如32位Windows的单个进程最大内存限制为2GB。
   
   HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量;由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。
   
   因此为每个线程分配到的栈内存(-Xss值)越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。


Note:

  • “-Xoss”:设置本地方法栈大小;
    HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,该设置没有任何效果;
  • -Xss”:(每个栈的)栈内存容量;
    当栈容量小于最小值限制时:(不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制)
    The Java thread stack size specified is too small. Specify at least 228k
    
  • HotSpot虚拟机不支持栈的动态扩展;
    所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,运行过程中只会出现“StackOverflowError”;

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

  • (HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代;)
    在JDK 6(或更早之前的HotSpot虚拟机中):常量池都是分配在永久代中,我们可以通过“-XX:PermSize”和“-XX:MaxPermSize”限制永久代的大小,即可间接限制其中常量池的容量;
    JDK 7:继续使用“-XX:MaxPermSize”参数用于限制永久代最大值;
    JDK 8(及以上版本):使用“-XX:MaxMeta-spaceSize”参数用于限制元空间大小;

关于String.intern()

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。


String.intern()返回引用的测试:

public class RuntimeConstantPoolOOM {
   public static void main(String[] args) {
   String str1 = new StringBuilder("计算机").append("软件").toString();
   System.out.println(str1.intern() == str1);
   
   String str2 = new StringBuilder("ja").append("va").toString();
   System.out.println(str2.intern() == str2);
   }
}

这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false:

  • (由StringBuilder创建的字符串对象实例在Java堆)
  1. 在JDK 6中:intern()方法实现会“复制字符串实例到字符串常量池中”,并返回方法区的引用;
    str1.intern()、str2.intern() 均是常量池的引用(方法区),str1、str2 均是Java堆的引用,所以必然不可能相等;
  2. 而JDK 7(以及部分其他虚拟机,例如JRockit)中:intern()方法实现只需要“在常量池里记录首次出现的字符串实例引用”即可;【字符串常量池已经移到Java堆
    str1.intern()、str1 均指向堆中的字符串实例,所以为 true;
    str2 是Java堆中“StringBuilder.toString()”创建的、新的String对象的引用,str2.intern() 是Java堆中常量池的引用(“java”在加载 sun.misc.Version 这个类的时候进入常量池的),所以为 false;

本机直接内存溢出

  • 异常运行结果如:
    Exception in thread "main" java.lang.OutOfMemoryError
       at sun.misc.Unsafe.allocateMemory(Native Method)
       at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)
    


  • -XX:MaxDirectMemorySize”:指定直接内存(Direct Memory)的容量大小;【默认与Java堆最大值(由-Xmx指定)一致】