Java虚拟机(三)虚拟机中的内存溢出

OutOfMemoryError异常

OutOfMemoryError异常(简称OOM)是Java虚拟机中一个比较常见的异常,它的情况有很多种,我们就以HotSpot的虚拟机为例,讲解一下常见的异常。

Java堆溢出

Java堆是在虚拟机中,所有线程共享的一个堆栈,我们如果在程序中不断地添加一个对象而不去销毁,只会让虚拟机的堆内存填满直到溢出,这是一种常见的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 默认的jvm设置

-Xms128m
-Xmx750m
-XX:ReservedCodeCacheSize=240m
-XX:+UseConcMarkSweepGC
-XX:SoftRefLRUPolicyMSPerMB=50
-ea
-Dsun.io.useCanonCaches=false
-Djava.net.preferIPv4Stack=true
-Djdk.http.auth.tunneling.disabledSchemes=""
-XX:+HeapDumpOnOutOfMemoryError
-XX:-OmitStackTraceInFastThrow

这是IDEA中的默认的虚拟机配置,可以自行改动。

java堆前面说过,是所有线程共享的,java程序代码在某些时候出现的错误操作,会导致内存溢出,这个和C++类似,不过Java的虚拟机不仅仅支持了代码的跨平台性,还拥有着自动的内存回收功能,下面来看看Java虚拟机在操作不当时候会出现的错误吧。

注1:在idea使用虚拟机设置时,点击上方的run-edit configuration-vm option

注2:以下实验默认基于JDK8,否则会特别声明版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

// VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
import java.util.ArrayList;
import java.util.List;

public class test {
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_pid15176.hprof ...
Heap dump file created [28147742 bytes in 0.098 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:265)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
at java.util.ArrayList.add(ArrayList.java:462)
at test.main(test.java:14)

像这样不断增加对象的方式,会使得Java堆溢出。在idea中,可以直接点击test.java.14直接找到是哪里出现了错误。

虚拟机栈和本地方法溢出

过多的循环

如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常,虽然在Java虚拟机规范当中,是允许Java虚拟机选择是否支持栈动态扩展的,但是在HotSpot中没有这个选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//使用-Xms128k
//减小栈的深度
public class test {
private int stackLength = 1;

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

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

Exception in thread "main" java.lang.StackOverflowError
stack length:997
at test.stackLeak(test.java:8)
at test.stackLeak(test.java:9)
at test.stackLeak(test.java:9)
at test.stackLeak(test.java:9)
at test.stackLeak(test.java:9)
。。。。。。。。

这个表示着不断地加入循环,在不断地压栈过程中极大的增加了栈的深度,导致了内存溢出。对于不同的操作系统,栈容量最小值可能有所限制,比如Windows最小不能低于180k,而Linux最小不能低于228k。当然,也不是仅仅使用循环过深才导致的,也会有创建线程过多,导致内存溢出。

过多的线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//不在虚拟机上,千万不要尝试,有死机风险
//实验机器为32位系统,可以使用VMbox去尝试
//VM Args:-Xss2M
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();
}
}

在高并发环境下,产生过多线程在服务器中是比较正常的事情了。但像这种产生过多的线程,又不能减少线程数的情况下,去减少最大堆和减少栈容量是比较好的选择。

栈容量无法申请足够的内存

第一个实验都是基于虚拟机栈不能允许动态扩展的前提下,但如果允许动态扩展,却无法申请到足够内存时,也会抛出StackOverflowError异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

public class test {

private static int stackLength = 0;

public static void test2() {
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 ++;
test2();

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 {
test2();
}catch (Error e){
System.out.println("stack length:" + stackLength);
throw e;
}
}
}


stack length:6004
Exception in thread "main" java.lang.StackOverflowError

实验表明,无论是栈帧太大,还是虚拟机栈容量大小,当新的栈帧内存无法分配时,都会抛出StackOverflowError异常。

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

常量池溢出

方法区曾被称之为永久代,但在jdk7以及之后开始逐步去永久代了,并且在JDK8当中,使用了元空间去代替。方法区存放的是常量池在不断存放过多的常量后也会溢出。以下实验先使用JDK6进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//JDK6

import java.util.ArrayList;
import java.util.List;

/**
* VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class test {

public static void main(String[] args) {
// 使用List保持着常量池引用,避免Full GC回收常量池行为
List<String> list = new ArrayList<String>();
// 10MB的PermSize在integer范围内足够产生OOM了
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at test.main(test.java from InputFileObject:15)

这里的intern()是Native方法,作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象,否则将此String对象包含的字符串添加到常量池中,并返回此string对象的引用。如此一来,添加过多的常量,却不进行GC则导致的常量池溢出异常。

那么接下来是JDK7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//JDK7
//-Xmx6M

import java.util.HashSet;
import java.util.Set;

public class test {
public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<String>();
// 在short范围内足以让6MB的PermSize产生OOM了
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}


Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:704)
at java.util.HashMap.putVal(HashMap.java:663)
at java.util.HashMap.put(HashMap.java:612)
at java.util.HashSet.add(HashSet.java:220)
at test.main(test.java:13)

如果在JDK7中,仍然使用 VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M去进行的话,将会永久的运行下去,因为在JDK7中,已经把字符串常量池移动到了Java堆中。所以可以使用-Xmx6M去限制最大的堆,就可以看到不同的结果,结果表明是Java堆溢出。

JDK6和JDK7

还有一个比较重要的案例是:

1
2
3
4
5
6
7
8
9
10
11
//JDK7
public class test2 {
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);
} }
}

这个案例在jdk6中会出现两个false,但是在jdk7中会出现一个true和一个false,这是因为在jdk6中,intern()会返回首次遇到的字符串复制在永久代中,而StringBuilder则是在堆中,所以两次的引用都不一样。

而在jdk7中,intern()不会再去复制,而只是在常量池中记录首次出现的引用,因此str1返回的字符串,都是在堆上的,而str2的字符串,在常量池中已经有它的引用了,所以str2的intern()还是返回的常量池的引用。

方法区溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

//VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M

public class test {

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 {

}
}

java.lang.OutOfMemoryError: PermGen space

方法区内存溢出属于比较常见的溢出异常。一个类要被垃圾收集器回收,判定条件是比较苛刻的。像这样的情况,产生大量的class,就难以被回收,如此类似的还有很多JSP文件。

在JDK8以后,永久代便安全退出了历史舞台,元空间作为代替者登场。元空间是一个很大的改变,比如像前面的这一些测试,已经很难再使虚拟机产生方法区溢出异常了。我们看看元空间的一些防御措施:

—XX:MaxMetaspaceSize:设置元空间的最大值,默认为-1,即不受限制,或者说只受限于本地内存大小。

—XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:MaxMetaspaceFreeRatio。

本机内存直接溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

//VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M

import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class test {


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

在这一段代码中,通过了反射去获取Unsafe的实例,而unsafe.allocateMemory(_1MB);在不断向系统申请内存分配,却又不进行销毁,导致的本地内存溢出异常。