Java的内存区域与溢出

以下内容主要源自《深入理解java虚拟机》一书的第二章

虚拟机的内存区域

一共有五个区域,其中两个是被所有的线程所共享的(因为JVM本身就是一个进程,所以运行在它上面的所有的java程序自然都是线程了)

1574476009874

稍微说明以下这个图。

第一行,说明了这是一个java的内存模型。

第二行,java内存主要由三部分组成,堆、方法区(也被称为永久代)以及每个线程的独有空间。

第三行,它只是把堆分成了两个,分别是新生代和老年代。

第四行,最后新生代又分成三个,Eden 空间、From Survivor 空间(s0)、To Survivor 空间(s1)。


第二张图是《深入理解java虚拟机》的配图

1574476244638

  • 方法区(共享)
  • 堆(共享)
  • 虚拟机栈(独享)
  • 本地方法栈(独享)
  • 程序计数器(独享)

程序计数器

这只是很小的一块内存空间,用它里面的值来指示接下来要执行的字节码的位置。因为有线程切换,所以每次切换的时候,这个程序计数器就会被别的线程的程序计数器所替换。显然程序计数器之间的数据绝对是不能共享的。

如果执行的是java方法,那么这里指示的是字节码指令的位置;如果是Native方法,这里则为空(Undefined),而且它理论上不存在溢出的可能性,所以没有规定任何的OutOfMemoryError。

虚拟机栈

这里记录的是线程的所有的方法的信息,每个方法都会有一个栈帧(Stack Frame),记录了方法的局部变量、方法出口等信息。而一个又一个的栈帧就形成了虚拟机栈,注意是每个线程都会有一个栈,然后栈又是由一个又一个栈帧形成的。每一个方法从开始调用到完成,对应的就是栈帧进入虚拟机栈到离开虚拟机栈的这么一个过程。

局部变量表里面存放的是各种数据类型、对象的引用和returnAddress类型。64位的long和double会占用两个局部变量空间,且局部变量空间所需要的内存在编译期就完成。

当线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError,而目前大部分虚拟机栈都是可以动态扩展的,当动态扩展没办法申请到内存的时候,就会抛出OutOfMemoryError异常

本地方法栈

它和虚拟机栈的区别只是,它为本地方法服务(用native关键字),而虚拟机栈则是被用来给java方法使用,所以你可以理解为它和虚拟机栈完完全全是一个东西。

注意!堆是共享的,是各个线程之间都可以使用的一块区域,这块区域会随着虚拟机的启动而创建,而且基本上它也是占用内存最大的一块。它存在的意义也很简单——存放对象(包括数组),由于对象存在生命周期,所以这里也是垃圾回收机制重点处理的区域。堆由于是共享的,而且存放了所有的对象,所以需要的内存空间比较大,但是只需要在逻辑上是连续的即可。现在的堆,既可以是固定大小的,也可以是可扩展的,如果遇到堆内存不够了,就会抛出OutOfMemoryError错误

方法区

如果比较不好理解,你可以理解为它就是堆的一部分,存的是一些已经被虚拟机加载的类信息、常量、静态变量等。垃圾回收理论上来说是会回收存放在这里的内容的,但是一般很少回收常量,所以这里一般很少有垃圾处理。有人管这里叫永久代,因为这里极少发生垃圾回收,但是目前这个说法已经不正确且不再流行了,忘了永久代这个说法吧。如果遇到方法区内存不够了,就会抛出OutOfMemoryError错误。

运行时常量池

在方法区里还有一片特殊的区域,叫运行时常量池,主要用来存放编译器生成的各种字面量和符号引用

字面量就是实际的值,比如int i = 0中的数字0和String s = "a"中的字符串"a".

符号引用就是上面的is,因为所有的符号引用本身也是一个字符串,所以会放到运行时常量中。

因为运行时常量本就属于方法区,自然也会因为空间不足而发生OutOfMemoryError异常。

直接内存

在JDK1.4中引入了NIO,可以使用native函数库直接分配堆外内存,并且能通过对象来操作这块堆外内存,能够在某些场景中显著提高效率。既然是堆外内存,那就不会受到java堆的影响,但是毕竟还是内存,当你把其它内存都分完了,总内存都不够用的时候,这部分还是会因为动态扩展不足而发生OutOfMemoryError异常。

对象是如何创建的

下面的例子以HotSpot虚拟机

java中创建一个对象是非常简单的,直接用关键字new一个出来就行(这里暂时先把数组排除在外),当然还可以反序列化等,这里先按下

1
Dog dog = new Dog();

当虚拟机遇到一个new的时候,它首先先要去常量池里面看看有没有一个dog的符号引用,然后确认一下这个类(Dog)有没有被加载,如果没有,那就需要先加载进来(这里先省略加载这部分);如果有,那就可以直接用了,反正能够确保这个类一定已经有了。既然有了类,那就可以为这个类的对象分配内存了,这个类对象需要多大的内存是可以确定的(具体方法略)。分配内存也很简单,这里简单从两种情况考虑:

  1. 内存是连续的,已经分配的内存和没有分配的内存之间用一个指针来进行记录,那虚拟机要做的仅仅是把这个指针移动一下位置,然后就可以把需要的内存分配给这个dog对象了。
  2. 内存并不连续。在这种情况下虚拟机会维护一个空闲内存的表格,他会查找这个表格,然后根据其中空闲的内存信息分配给dog空间。

具体内存连不连续主要取决于垃圾回收算法。

以上的两种方法听上去都很简单很美妙,但是没有考虑到多线程所带来的问题。很简单一种情况,正在给dog1分配内存,我还没修改指针呢,有一个dog2需要分配内存而移动了指针,这就很头大了。解决这个的办法也有两个:

  1. 用一种方法保证操作的原子性——CAS(Compare And Swap乐观锁)+失败重试
  2. 针对每个线程,我都为那个线程单独分配一块小的内存区域,这样线程之间就不会有矛盾了。为每个线程单独分配的空间有一个名字叫做TLAB(Thread Local Allocation Buffer),这样只要这块小的内存没用完,线程之间永远不会因为分配内存空间而发生矛盾,当然如果这个小空间用完了,那就使用方法1呀。如果需要启用这个功能,可以通过-XX:+UserTLAB参数

当内存分配完成之后,虚拟机就会把这些内存初始化成零,这也就是为什么对象的实例会有初始值,就算你没设置。

然后虚拟机会对对象头进行设置,这里先省略,接下来

OK此时对于虚拟机来说,“对象”已经生成好了。但是对于程序员来说才正要开始初始化对象,执行构造函数。

对象在内存中的存放

那么对象在内存中是怎么存放的呢?有三个部分,分别是对象头、实例数据和它的对齐填充。

对象头有两部分:

  • 第一部分存的是对象自己在运行时需要用到的数据,比如HashCode、GC分代年龄、锁状态标志等,非常
  • 另外一部分是类型的指针,通过这个指针能够确定这个对象是哪个类的实例。比较特殊的是数组额外还有一个记录长度的部分。

实例部分就是对象的数据了,比如你定义了int之类的。这里注意默认情况下具有相同宽度的字段会被分配在一起,并且在满足这个条件下,父类中定义的变量会在子类之前。不过似乎这里的顺序也

第三部分更好理解了,就是内存要求对象的大小必须是8字节的整数倍,所以需要填补字节来补全。

之前说过对象是在堆上面创建的,那么每个线程怎么找到自己的对象呢?答案是它把对象的地址记录在了自己的栈中。所以通过栈能够找到堆中的地址,再到堆中的指定位置就能够找到对象了。

具体的实现对于不同的虚拟机来说也是有点不同的,有句柄和直接指针两种类型,句柄(句柄放在句柄池里,句柄池在堆中)可以理解为一个中转指针,通过它再找到真正的数据,直接指针顾名思义就是直接指过去了。显然直接指针效率更高,但是如果用到中转指针,那对于垃圾处理来说更友好,因为只需要修改中转指针到真正数据的那部分,不需要修改前面的那部分。

HotSpot使用的是直接指针,因为快。

堆溢出

堆想要溢出还是非常容易的,只需要不断创建新的对象就行了嘛(当然也要避免对象被回收,我们的方法是在list中放入这个对象)。

1
2
3
4
5
6
7
8
9
10
11
12
public class HeapOverFlow {
static class OOMObject {
// nothing to do
}

public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}

然后需要配置一下虚拟机选项:
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
其中的Xmx指定JVM最大的内存分配池,而Xms就是初始化的内存分配池。所以一般来讲Xmx肯定要比Xms大得多的,这里我们设置成一样的,就可以让堆不进行自动扩展了。-XX:+HeapDumpOnOutOfMemoryError则是为了让虚拟机在内存出现溢出错误的时候存储一下快照

当然你也可以在命令行先编译然后在执行的时候加入这个选项。

1
2
javac com\test\HeapOverFlow.java
java -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError com.test.HeapOverFlow

具体的报错见下:

1
2
3
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid47672.hprof ...
Heap dump file created [28306502 bytes in 0.062 secs]

所以当实际中出现了这种问题,应该怎么办呢?首先肯定是看看这个叫java_pid47672.hprof的文件,这个需要特殊的工具以后再说,通过这个文件你可以查看是什么对象把你的内存占满了,如果这些对象不是你需要的,那就找到对应的代码去修改;如果这些对象你确实需要,你可以考虑修改一下它们的生命周期啦之类的,或者最简单粗暴的:给JVM更大的内存来使用(实在不行你可以再买几根内存条)。

栈溢出

同样也有两个参数来分别设置本地方法栈(-Xoss)和虚拟机栈(-Xss),但是之前也讲过了这俩其实可以理解为是一样的,所以在HotSpot中并不对它们进行区分,只是用Xss来限定即可。

下面的代码是单线程,但是疯狂扩展栈深度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class StackOverFlow {
private int number = 1;

public void addNumber() {
number++;
addNumber();
}

public static void main(String[] args) throws Throwable {
StackOverFlow stackOverFlow = new StackOverFlow();
try {
stackOverFlow.addNumber();
} catch (Throwable e) {
System.out.println("At last the number is " + stackOverFlow.number);
throw e;
}
}
}

很简单的,就是一个不停调用同一个函数的过程就行了。

下面的这段代码是疯狂创建线程来实现溢出操作的,但是在我的win10计算机上面一运行就炸,慎重使用(后来找出来的原因是因为java线程被映射到了操作系统内核的线程,怪不得…建议只在linux下执行然后linux也死机了…):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class StackOverFlow {
private void runForever() {
while (true) {
// do nothing
}
}

public void createThreads() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
runForever();
}
});
thread.start();
}
}

public static void main(String[] args) {
StackOverFlow stackOverFlow = new StackOverFlow();
stackOverFlow.createThreads();
}
}

编译和执行:

1
2
javac com\test\StackOverFlow.java
java -Xss128k com.test.StackOverFlow

最后第一个扩展栈深度的程序,实测在我的机器上跑到981就结束了,报的错误是一个知名网站:StackOverFlow。从理论上来讲,如果我请求的栈的深度超过了虚拟机允许的深度,那就是StackOverFlowError,但是如果是虚拟机想要扩展栈的时候被操作系统拒绝了,那就是OutOfMemoryError,在上面的例子中因为是同一个线程疯狂申请空间,所以导致的是StackOverFlowError。

导致栈出现问题有两个,一个是栈深度太大了而导致的溢出,还有一个是栈太多了导致的问题,但是栈的深度来说一般是不可能超的,就算递归也不会超(除非出现死循环这种的),而如果是因为栈太多了(每个线程一个栈,说明你线程太多了),那你只能优化下代码、加大内存或者减少堆的分配(因为系统给虚拟机的总内存固定的,当堆的内存少下去了,栈能用的内存就多起来了)。

方法区和常量池溢出

这里需要用到String.intern()方法,这个方法首先是一个Native方法,似乎是C++实现的,不多说了,美团技术团队写了一篇非常不错的文章,可以好好了解一哈。简单来说,你在java中用双引号引起来的,叫做String对象,是会存放在常量池中的,而这个方法的作用就是去常量池里面查询一下有没有你要找的字符串,有的话就返回,不存在就替你放到常量池里面。

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.ArrayList;
import java.util.List;

public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}

代码也非常简单。

再加上虚拟机参数-XX:PermSize=10M -XX:MaxPermSize=10M就可以了。

但是这个实验似乎已经无法复现了,因为我用的JDK8已经把这两个虚拟机选项移除了….所以会永远循环下去。

常量池本身已经在JDK7中被移动到了堆中,所以上面的代码并不能导致方法区

本机内存溢出

本机内存溢出就更简单了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class DirectMemoryOOM {
private static final int MB = 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(MB);
}
}
}

不停申请内存就行了,肯定会溢出的。但是还是建议在虚拟机选项中加入-Xmx20M -XX:MaxDirectMemorySize=10M来限制会更好。