Java内存区域与内存溢出异常
Java与C++之间有一睹由内存动态分配和垃圾收集技术所围成的”高墙”
概述
C、C++既拥有每一个对象的”所有权”,又担负着每一个对象开始到终结的维护;而Java在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,正因为Java把内存控制的权力交给了Java虚拟机,一旦出现内存泄漏和溢出问题,如果不了解虚拟机是怎样使用内存的,排查错误将会很困难.
运行时数据区域
Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
- 线程共享区:
- 方法区:存储运行时常量池,已被虚拟机加载的类信息、常量、静态变量,即时编译器编译后的代码等数据
- java堆:存储对象实例
- 线程独占区:
- 虚拟机栈:存放方法运行时所需的数据,成为栈帧
- 本地方法栈:为JVM所调用到的native方法,即本地方法服务
- 程序计数器:记录当前线程所执行到的字节码的行号(标识)
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器.字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成.
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令.因此为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称为”线程私有”的内存
如果线程执行的是Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址.如果正在执行的是native方法,这个计数器的值是undefined.此区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
Java虚拟机栈
栈帧
Java虚拟机栈也是线程私有的,生命周期与线程相同.虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息.每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程
当执行一个方法1时,就会创建一个栈帧,然后让栈帧进行入栈;然后方法1开始执行,如果在执行的过程中调用了另外的方法2,这方法2也要创建栈帧,然后此方法2的栈帧进行入栈,当方法2执行完毕后,方法2的栈帧出栈,出栈后栈帧销毁;然后方法1继续执行,这就是方法的栈帧执行过程,也即debug的栈示意图
局部变量表
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、引用类型(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
局部变量表所需的内存空间在编译期就完成了分配,当进入到一个方法时,这个方法需要在栈中分配多少内存其实是固定的,在方法运行期间是不会改变局部变量表的大小(局部变量表存储是变量的引用,实际的变量存储在堆中)
栈大小
- 当虚拟机栈存储了方法的栈帧(栈帧里面存在固定的局部变量表)后,已经满(超过虚拟机栈指定大小)了,但是还需要继续存储方法的栈帧,此时就相当于遇到了熟悉的StackOverFlowError栈内存溢出错误,在我们写递归调用的时候很常见.
- 如果栈的大小(深度)不限定大小,那么这时候栈中一直被申请,导致栈的内存区域已经大于java虚拟机的内存;就会导致OutOfMemory错误
本地方法栈
本地方法栈与虚拟机栈发挥作用是非常相似的,它们之间的区别不过是:虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机内部的Native方法服务;本地方法栈也会抛出StackOverFlowError和OutOfMemory异常
Java堆
Java堆(Java Heap)是Java虚拟机所管理的内存中最大一块.Java堆是被所有线程共享的,在虚拟机启动时创建.此内存区域唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存.但JIT编译器发展和逃逸分析技术,栈上分配、标量替换优化将会导致变化
Java堆是垃圾收集器管理的主要区域,也被称为”GC堆”.由于现在收集器基本都采用分代收集算法,所以Java堆细分为:新生代和老年代;Eden空间、From Survivor空间、To Survivor空间等.
Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可.堆是可扩展的,如果需要修改堆的大小: -Xmx -Xms;如果在创建对象的过程中堆内存申请不下来,就会抛出OutOfMemory异常
方法区
方法区也是各个线程共享的内存区域,用于存储运行时常量池、虚拟机加载的类信息(类的版本、字段、方法、接口)、常量、静态变量以及即时编译器编译后的代码等数据
对于HotSpot虚拟机来说,方法区也称为永久代,本质上两者并不等价,主要是HotSpot虚拟机的设计团队把GC分代收集扩展到了方法区中,这样HotSpot垃圾收集器可以像管理Java堆一样管理这部分内存,省去了专门为方法区编写内存管理代码的工作.对于其他虚拟机不存在永久代的概念.
使用永久代来实现方法区,有以下缺点:
- 更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限)
- 有极少数方法(例如String.intern())会因为这个原因导致不同虚拟机有不同表现
垃圾回收的主要区域在Java堆中,垃圾回收在方法区中是出现比较少的,但并非数据进入方法区就”永久”存在了.这区域的内存回收目标主要是针对常量池的回收、对象类型的卸载
当我们在内存申请失败的时候,会抛出OutOfMemory异常
运行时常量池
运行时常量池是属于方法区的一块.Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息,用来存放编译期生成的各种字面量以及符号引用;这部分内容在类加载时进入方法区的常量池中进行存放
运行时常量池相对于Class文件常量池的一个重要特征是具备动态性,Java语言可以在预置入Class文件中常量池内容才能进入方法区运行时常量池,运行期间也可新常量放入池中,比如String.intern()
1 | public static void main(String[] args) { |
我们的任何一个字符串的创建都会扔到常量池中,我们可以想象常量池中维护着一个StringTable;数据类型可以想象成HashSet;来一个字符串常量就往HashSet中仍一个(HashSet是无序不可重复的);
如果是以new的方式创建一个字符串变量就会在堆中开辟一块空间来存放这个字符串变量
上述代码整体内存结构如下:
intern()方法会将在堆中创建的字符串搬运到常量池中;如上述代码的s1、s2属于已经的常量即字节码常量,而intern()和字符串的拼接等则属于运行时产生的常量
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范的内存区域.但是确实是存在的一块内存区域,也是被我们频繁使用的一块区域,也可能导致OutOfMemoryError异常出现
在JDK1.4的时候新增了一个NIO,可以直接使用native函数(DirectByteBuffer的allocateDirect方法)直接分配堆外内存,从而不受java虚拟机内存的制约,但是会受到操作系统的物理内存的制约
HotSpot虚拟机对象
对象的创建
虚拟机遇到一条new指令时执行过程:
new参数在常量池定位类的符号引用
首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过.如果没有,那必须先执行相应的类加载过程
虚拟机为对象分配内存
虚拟机将为新生对象分配内存.对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把确定大小的内存从Java堆中划分出来.
- 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针指向空闲那边挪动一段与对象大小相等的距离,这种分配方式称为”指针碰撞”.
- 如果Java堆中内存不是规整的,已使用的内存和空闲内存相互交错,就没办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为”空闲列表”.
选择哪种方式取决于Java堆是否规整,而Java堆是否规则又由所采用的垃圾收集器是否带有压缩整理功能决定.因此在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时采用空闲列表
对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,解决这个问题有两种方案:
- 对分配内存空间的动作进行同步处理–实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
- 把内存分配的动作按照线程划分在不同空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB).哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定.虚拟机是否使用TLAB,可用通过-XX:+/-UseTLAB参数设定
分配的内存初始化为零值
内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB这一工作过程也可也提前到TLAB分配时进行.这一步操作保证了对象的实例字段在Java代码中可用不赋初始值就可用直接使用
对象设置
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息
调用对象的init方法
此时init方法还没执行,所有字段都还为零.执行init方法把对象按照程序员的意愿进行初始化,这样可用的对象才算完整产生出来
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可用分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding).
对象头
HotSpot虚拟机的对象头包括两部分信息:
存储对象自身的运行时数据
第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分信息官方称为”Mark Word”,Mark Word被设计成一个非固定的数据结构以便在极小的空间存储尽量多的信息,它会根据对象的状态复用自己的存储空间.
HotSpot虚拟机对象头Mark Word示意图:
将32位的内存区域进行了复用,大大提高了内存的使用效率
类型指针
对象指向它类型元数据的指针,虚拟机通过指针确定它是哪个类的实例,当然并不是所有的虚拟机在对象实现上保留类型指针;
如果对象是一个Java数组,那么对象头中还有块记录数组长度的数据,因为虚拟机可以通过普通Java对象信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小
InstanceData(实例数据)
对象真正存储的信息,也是程序代码所定义的各种类型的字段内容.无论是从父类继承的还剩子类中定义的,都需要记录起来.
这部分的存储顺序会受到虚拟机分配策略参数和字段在java源码中定义的顺序的影响,
HotSpot虚拟机的分配策略是
- 相同宽度的字段被分配到一起,如long/ double、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
- 在上述前提条件下,在父类中定义的字段会出现在子类字段之前
Padding(填充)
这部分数据并不是必然存在的,也并没有特别含义,仅仅相当于占位符,主要是HotSpot虚拟机的自动内存管理要求对象起始地址必须是8个字节的整数倍即对象的大小必须是8个字节的整数倍.而头像头必须是8的整数倍,如果不满足则必须使用padding部分去填充
对象的访问定位
建立对象就是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象.由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中对象的具体位置,所以对象访问方式也是取决于虚拟机实现,目前主流的访问方式有使用句柄和直接指针两种
使用句柄
使用句柄访问的话,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,图示如下:
直接指针
直接指针访问,那么Java堆对象的布局中就必须考虑如何访问类型数据的相关信息,而reference中存储的直接就是对象的地址,图示如下:
两种方式对比
使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍)时只会改变句柄中的实例数据指针,栈中引用指针不变
使用直接指针的方式的最大好处就是速度更快,:减少了一次寻址的过程时间开销,性能高;因为对对象的访问频率高,对性能开销还是有很大的节约,HotSpot就是采用此种方式
OutOfMemoryError异常
除了程序计数器外,虚拟机内存的其他几个运行时区域都可能发生OutOfMemoryError异常,通过实例方式验证此种异常,主要目的如下:
- 通过代码验证Java虚拟机规范中描述的各个运行时区域存储的内容
- 实际项目中遇到内存溢出异常时,能根据异常信息快速判断是哪个区域的内存溢出,知道什么代码可能会导致区域内存溢出以及出现这些异常后该如何处理
在VM arguments中设置:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
Java堆溢出
Java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常
通过参数-XX:+HeapDumpOnOutOfMenoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析
1 | /** |
运行结果:
1 | java.lang.OutOfMemoryError: Java heap space |
要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如Eclipse Memory Analyzer或JProfile)对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,分清楚是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链.可以查出泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们.掌握泄漏对象的类型信息及GC Roots引用链的信息,就可以定位出泄漏代码的位置
如果不存在内存泄漏,就是内存中对象确实必须存活,那就应该检查虚拟机的堆参数(-Xmx与-Xms)与机器物理内存对比看是否可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗
虚拟机栈和本地方法栈溢出
由于在HotSpot虚拟机并不区分虚拟机栈和本地方法栈,因此对于HotSpot虚拟机虽然-Xoss(设置本地方法栈大小)参数存在,但实际上无效的,栈容量只由-Xss参数设定.关于虚拟机栈和本地方法栈,Java虚拟机描述了两种溢出:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,抛出StackOverflowError异常
- 如果虚拟机在扩展栈时无法申请到足够的内存,抛出OutOfMemoryError异常
使用以下两种方式去栈抛出异常:
- 使用-Xss参数减少栈内存容量.结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小运行结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/**
* -Xss128k
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength ++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF stackSOF = new JavaVMStackSOF();
try {
stackSOF.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + stackSOF.stackLength);
throw e;
}
}
}上述结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,虚拟机抛出的都是StackOverflowError异常1
2
3
4
5stack length:997
Exception in thread "main" java.lang.StackOverflowError
at com.eric.multithreading.jvm.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:10)
at com.eric.multithreading.jvm.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:11)
at com.eric.multithreading.jvm.outofmemory.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:11)
如果测试时不限于单线程,通过不断的建立线程的方式是可以产生内存溢出异常的,为每个线程的栈分配的内存越大,越容易产生内存溢出溢出
操作系统分配给每个进程的内存是有限制的,如windows限制为2GB.虚拟机提供了参数控制Java堆和方法区两部分内存的最大值.剩余的内存为2GB减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小可以忽略.如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈”瓜分”了.每个线程分配到栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容量把剩下的内存耗尽.
出现StackOverflowError异常时有错误堆栈信息比较容易找到问题所在,如果使用虚拟机默认参数,栈深度在大多数情况下达到1000~2000完全没有问题,对于正常的方法调用(包括递归)这个深度已经够用了.如果是过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程.
- 定义了大量的本地变量,增大此方法桢中的本地变量表的长度,结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小
运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个放到一起.在JDK6及之前的版本中,由于常量池分配在永久代内,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,从而间接限制其中常量池的容量
1 | /** |
运行结果:
1 | Exception in thread "main" java.lang.OutOfMemoryError: PermGen space |
运行结果看出此处运行时常量池溢出,在OutOfMemoryError后面的提示信息是:PermGen space,说明运行时常量池属于方法区
JDK7运行上述程序则会while循环一直进行下去.
下面看看String.intern()返回引用的测试:
1 | public class RuntimeContantPoolOOM { |
在JDK6中运行会得到两个false,在JDK7中运行会得到一个true和一个false.产生差异的原因是:
- 在JDK6中intern方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串在Java堆上,所以不是同一个返回false.
- JDK7中的intern实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个.对str2比较返回false是因为”java”这个字符串在指向StringBuilder.toString之前已经出现过,字符串常量池已经有它的引用,不符合”首次出现”的原则,而”计算机软件”这个字符是首次出现则true
方法区溢出
方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等,对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区直至溢出,下例借助CGLib操作字节码运行时产生了大量动态类:
1 | /** |
运行结果:
1 | Caused by: java.lang.OutOfMemoryError: Metaspace |
方法区溢出也是常见的内存溢出异常,一个类被垃圾收集器回收掉,判定条件是比较苛刻的.在经常动态生成大量Class的应用中,需要主要类的回收
本机直接内存溢出
DirectMenory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx)一样,下例直接通过发射获取Unsafe实例进行内存分配.虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()
1 | /** |
运行结果:
1 | Exception in thread "main" java.lang.OutOfMemoryError |
由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果发现OOM后Dump文件很小,而程序中直接或间接使用了NIO,可以考虑是不是这方面原因