内存溢出就是内存不够用,当JVM因为没有足够的内存来为对象分配空间并且垃圾回收器也已经没有空间可回收时,就会抛出 java.lang.OutOfMemoryError (注:非exception,因为这个问题已经严重到不足以被应用处理)
注意,要和内存泄漏区分开,内存泄露:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了,因为申请者不用了,而又不能被虚拟机分配给别人用。
除了 程序计数器外 ,Java虚拟机的其他运行时区域都有可能发生 OutOfMemoryError 的异常,下面分别给出验证:
一、Java堆溢出
Java堆用户存储对象实例,只要不断地创建对象,并且保证GC Roots到对象之间有可打路径来避免垃圾回收机制清除这些对象,那么对象数量达到最大堆的容量限制后就会产生内存溢出溢出。
堆内存溢出代码示例
/**
* VM Args: -Xms:20m -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堆常用参数
- -Xms 设置堆的最小值
- -Xmx 设置堆的最大值
- -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现内存溢出异常时Dump出当前的内存转储快照以便事后进行分析
二、虚拟机栈和本地方法栈溢出
由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,虽然-Xoss参数(设置本地方法栈)存在,但实际上是无效的,栈容量只由-Xss参数(指定单个线程栈容量大小)设定。
关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
单线程StackOverflowError异常示例
/**
* VM Args: -Xss128k
* 对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制,
* 这主要取决于操作系统内存分页大小。
* 譬如上述方法中的参数-Xss128k可以正常用于32位windows系统下的JDK6,但是
* 如果用于64为windows系统下的JDK11,则会提示栈容量最小不能低于180k,而在
* Linux下这个值则可能是228k,如果低于这个最小限制,HotSpot虚拟器启动时会
* 给出如下提示:
* "The Java thread stack size specified is too small. Specify at least 228k"
*/
public class JavaVMstackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMstackSOF oom = new JavaVMstackSOF();
try {
oom.stackLeak();
} catch(Throwable e) {
System.out.print("stack length: " + oom.stackLength);
throw e;
}
}
}
实验结果表明:在单线程情况下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,虚拟机将抛出的都是StackOverflowError异常。
多线程OutOfMemoryError异常示例
如果测试时不限制单线程,通过不断建立线程的方式倒是可以产生内存溢出异常,但是这样产生的内存溢出异常与栈空间是否足够大不存在任何联系,其实在这种情况下,
为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常
。
因为操作系统分配给每个进程的内存是有限制的,比如32位Windows系统限制为2GB。虚拟机提供了参数来限制Java堆和方法区这两部分的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机本身消耗的内存不计算在内,剩余的内存就是由虚拟机栈和本地方法栈瓜分掉了
,每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把内存耗尽。
所以,如果是建立过多线程导致的内存溢出,在不能减少线程数量或更换64位操作系统的情况下,可以通过减小最大堆和减小栈容量来换取更多的线程
。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会很难想到。
/**
* VM Args:-Xss2M (这时可以设置多一点)
* @author Administrator
*/
public class JavaThreadOOM {
public 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{
JavaThreadOOM oom=new JavaThreadOOM();
oom.stackLeakByThread();
}
}
三、方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个部分的测试可以放在一起进行。
String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String的对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并返回代表此String对象的引用。在JDK1.6及以前的版本中,由于常量池分配在永久代中,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,从而间接限制其中常量池的容量。
import java.util.ArrayList;
import java.util.List;
// VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
public class RunTimeConstantPool {
public static void main(String [] args){
// 使用Lst保持着常量池的引用,避免Full GC回收常量池的行为
List<String> list=new ArrayList<String>();
// 10MB的PrmeSize在integer范围内足够产生OOM了
int i=0;
while(true){
list.add(String.valueOf(i++).intern());
}
}
}
这段代码在JDK1.6及以前的版本会出现OutOfMemoryError,后面跟着的提示信息是“PermGen space”,说明运行时常量池属于方法区(HotSpot虚拟机中的永久代)的一部分。
而使用JDK1.7及以后的版本会一直运行下去,直到计算机卡死,关于这个字符串常量池的实现还引申出另一个问题。
public class RunTimeConstantPool {
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);
}
}
这段代码在JDK1.6中运行,会得到两个false,而在JDK1.7及以后的版本运行,会得到一个true和一个false。 产生差异的原因是:在JDK1.6中intern()会把首次出现的字符串复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在堆中,所以必然不是同一个引用,将返回false。而在JDK1.7中的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和StringBuilder创建的那个字符串是同一个。对str2比较返回false是因为”java”这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中指向的是第一次出现的引用,所以和str2不相同。
方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这个区域的测试,基本思路是运行时产生大量的类来填满方法区,直到溢出。虽然直接使用Java SE API也可以动态产生类,但操作起来比较麻烦。
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾回收器回收的条件是非常苛刻的。在经常生成大量Class的应用中,需要特别主要类的回收情况。比如:程序中使用了CGLib字节码增强和动态语言(Spring、Hibernate等主流框架)、大量JSP或动态生成JSP文件的应用(JSP第一次运行时需要编译为Java类),基于OSGi的应用(即使是同一个类文件被不同的类加载器加载也会被视为不同的类)等。
四、本机直接内存溢出
DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样,可以直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsa()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。不使用DirectByteBuffer类的原因是虽然这个类分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe,allocateMemory()。
import java.lang.reflect.Field;
import sun.misc.Unsafe;
// VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
public class DirectMemoryOOM {
private static final int _1MB=102481024;
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);
}
}
}
由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果发现OOM之后Dump文件很小,而程序中又直接或间接的使用了NIO,那就可以考虑检查一下是不是这方面的原因。