一、Java内存区域与内存溢出异常

运行时数据区域

概述

Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域。这些区域有各自用途,以及创建和销毁的时间,有的区域 随着虚拟机进程的启动而存在 ,有些区域则 依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE 7版)的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。

分类

  1. 程序计数器 (Program Counter Register)

    • 可以看作是当前线程所执行的字节码的行号指示器。

    • 由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间来实现的。因此,为了线程切换后能回到正确位置,每条线程都需要一个单独的程序计数器,称这类内存区域为“线程私有的内存”。

    • 如果线程正在执行Java方法,则计数器记录字节码地址;如果执行Native方法,则计数器记录为空。所以是唯一一个不存在OutOfMemoryError情况的内存区域。
  2. Java 虚拟机栈(Java Virtual Machine Stacks)

    • 线程私有的,它的生命周期与线程相同。描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口灯信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
    • 局部变量表 存放了编译器可知的各种 基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
    • 两种异常情况,如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
  3. 本地方法栈(Native Method Stack)

    • 功能与虚拟机栈相似,不同是虚拟机栈为Java方法服务,而本地方法栈则为Native方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError 和 OutOfMemoryError异常。
  4. Java 堆 (Java Heap)

    • Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。在内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
    • Java堆可细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
  5. 方法区(Method Area)

    • 各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后 的代码等数据。有一个别名叫做Non-Heap (非堆),为了与堆区分开。
    • 这区域的内存回收目标主要针对常量池的回收和对类型的卸载。
    • 对于习惯在HotSpot虚拟机上开发、部署的开发者来说,很多人把这部分称为“永久代”(Permanent Generation)。
    1. 运行时常量池 (Runtime Constant Pool)
      • 运行时常量池是方法区的一部分。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译器才能产生,运行期间也可能将新的变量放入池中,例如:String 类的intern() 方法。
  6. 直接内存(Direct Memory)

    • 直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。
    • 在JDK 1.4 中新加入了NIO (New Input/Output) 类,引入了一种基于通道(Channel)与缓冲器(Buffer)的I/O 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在 Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native 堆中来回复制数据。
    • 本机直接内存的分配不会受到Java 堆大小的限制,但是会受到本机总内存的限制。

HotSpot虚拟机对象探秘

对象的创建

  1. 虚拟机遇到一条new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java 堆中划分出来。
  2. 对象创建在虚拟机中是非常频繁的行为,在并发的情况下也并不是线程安全的。例如:可能出现正在给对象A分配内存,指针还没来得及修改,对象B 又同时使用了原来的指针来分配内存的情况。有如下两种解决方案:
    1. 对分配内存空间的动作进行同步处理—— 实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
    2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。虚拟机是否使用TLAB,可以通过 -XX:+/-UseTLAB 参数来设定。
  3. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。
  4. 对象头(Object Header)的设置:虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头之中。
  5. 1 ~ 4 步过后,从虚拟机视角来看,一个新的对象已经产生,但从Java程序的视角来看,对象创建才刚开始,这时候执行 方法, 把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

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

  • 对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
  • 实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
  • 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。

###对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种:

  • 句柄访问:Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

    使用句柄池来访问对象

  • 直接指针访问:Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

  • 两种访问方式的比较:使用句柄来访问的最大好处就是 reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference本身不需要修改。使用直接指针访问方式的最大好处就是速度快,它节省了一次指针定位的时间开销。Sun HotSpot,它是使用第二种方式进行对象访问的。

实战:OutOfMemoryError异常

Java堆溢出

实例

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
package com.jvm.cjm;

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

/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*
* @Author: Jie Ming Chen
* @Date: 2018/10/6
* @Version 1.0
*/
public class HeapOOM {

static class OOMObject {

}

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

while (true) {
list.add(new OOMObject());
}
}
}

运行结果

1
2
3
4
5
6
7
8
9
10
11
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /Users/jieming/Documents/workspaces/deap-temp/java_pid5372.hprof ...
Heap dump file created [27971407 bytes in 0.209 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 com.jvm.cjm.HeapOOM.main(HeapOOM.java:23)

解决这个区域的异常,一般的手段是对Dump出来的堆转储快照进行分析。分析清楚是出行了内存泄露(Memory Leak)还是内存溢出(Memory Overflow)。如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链。

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

由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,虽然-Xoss参数(设置本地方法栈)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,Java虚拟机规范描述了两种异常

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

单线程操作下

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
package com.jvm.cjm;

/**
* VM Args: -Xss180k
*
* @Author: Jie Ming Chen
* @Date: 2018/10/6
* @Version 1.0
*/
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.println("stack length:" + oom.stackLength);
throw e;
}
}

}

// 结果
Exception in thread "main" stack length:1004
java.lang.StackOverflowError
at com.jvm.cjm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)

实验结果表明:在单线程情况下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。

多线程操作下

通过不断地建立线程的方式可以产生内存溢出异常,这种情况下栈空间分配的内存越大。反而越容易产生内存溢出异常。原因是操作系统分配给每个进程的内存是有限制的,虚拟机提供了参数控制 Java堆和方法区的这两部分内存的最大值。操作系统限制 减去 Xms 再减去MaxPermSize,程序计数器消耗内存很小,可以忽略掉。如果虚拟机本身耗费的内存不就计算在内,剩下的内存就由虚拟机栈和本地方法栈瓜分了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越小,建立线程时就越容易把剩下的内存耗尽。

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
package com.jvm.cjm;

/**
* VM Args: -Xss2M
*
* @Author: Jie Ming Chen
* @Date: 2018/10/6
* @Version 1.0
*/
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) {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}

}
// 运行结果
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:717)
at com.jvm.cjm.JavaVMStackOOM.stackLeakByThread(JavaVMStackOOM.java:28)
at com.jvm.cjm.JavaVMStackOOM.main(JavaVMStackOOM.java:34)
Java HotSpot(TM) 64-Bit Server VM warning: Exception java.lang.OutOfMemoryError occurred dispatching signal SIGINT to handler- the VM may need to be forcibly terminated

如果是多线程导致的溢出,在不能减少线程数,就只能减少最大堆和减少栈容量来换取更多的线程。

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

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
package com.jvm.cjm;

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

/**
* 借助CGLib使方法区出现内存溢出的异常
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*
* jdk1.8之后彻底去掉永久代(方法区),上述配置会被忽略
*
* @Author: Jie Ming Chen
* @Date: 2018/10/6
* @Version 1.0
*/
public class JavaMethodAreaOOM {

static class OOMObject {

}

public static void main(String[] args) {

while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, args);
}
});

enhancer.create();
}
}
}

jdk1.7中在永久代中去掉了常量池,jdk1.8中去掉了方法区。

本机直接内存溢出

DirectMemory容量可通过-XX: MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。

二、垃圾收集器与内存分配策略

对象已死吗

垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中那些还“存活”着,那些已经“死去”(即不可能再被任何途径使用的对象)

引用计数算法

给对象中添加一个引用计数器,每当有一个地方应用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。确定是难解决对象之间互相循环引用的问题

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
package com.jvm.cjm.ch3;

/**
* 引用计数算法的缺陷
*
* @Author: Jie Ming Chen
* @Date: 2018/10/7
* @Version 1.0
*/
public class ReferenceCountingGC {

public Object instance = null;

private static final int _1MB = 1024 * 1024;

/**
* 这个成员属性的意义就是占点内存,以便能在GC日志中看清楚是否被回收
*/
private byte[] bigSize = new byte[2 * _1MB];

public static void testGC() {

ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

}

public static void main(String[] args) {
testGC();
}

}

可达性分析算法

主流的商用程序语言实现都称通过 可达性分析(Reachability Analysis)来判定对象是否存活,这个算法的基本思路就是通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(Reference Chain) ,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

  • 在Java 语言中,可作为GC Roots的对象包括下面几种
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区中类静态属性引用的对象。
    • 方法区中常量引用的对象。
    • 本地方法栈中JNI(即一般说的Native 方法)引用的对象。

再谈引用

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,这四种引用强度依次减弱。

  • 强引用:类似 “Object obj = new Object()”,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用:用来描述一些还有用但并非必须的对象。对于这些对象,在系统发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。SoftReference 类来实现软引用。
  • 弱引用:用来描述非必须对象,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉值只被弱引用关联的对象。WeakReference类来实现弱引用。
  • 虚引用:幽灵引用或者幻影引用,最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。PhantomReference 类来实现虚引用。

生存还是死亡