高手的存在,就是让服务10亿人的时候,你感觉只是为你一个人服务......

浅析JVM内存区域

目录
  1. 1. 程序计数器(Program Counter Register)
  2. 2. java虚拟机栈(VM Stack)
    1. 2.1. java虚拟机栈内存溢出
  3. 3. 本地方法栈(Native Method Stack)
  4. 4. java堆(heap区)
    1. 4.1. 一个对象的一生
    2. 4.2. java堆内存溢出
  5. 5. 方法区(Method Area)
    1. 5.1. 运行时常量池(Runtime Constant Pool)
    2. 5.2. 方法区内存溢出
  6. 6. 直接内存(Direct Memory)
  7. 7. java虚拟机各内存区域图汇总

Java虚拟机在执行java程序的时候,会把它所管理的内存划分为若干不同的数据区。
这些区域有的随着java进程的启动而生成,有的随着线程的启动和结束而创建和销毁。

大多数 JVM 将内存区域划分为 Method Area(Non-Heap)(方法区),Heap(堆),Program Counter Register(程序计数器), VM Stack(虚拟机栈,也有翻译成JAVA 方法栈),Native Method Stack (本地方法栈)
其中Method Area和Heap是线程共享的,VMStack,Native Method Stack 和Program Counter Register是线程私有的。
Alt text

概括地说来,JVM初始运行的时候都会分配好Method Area(方法区)和Heap(堆),
而JVM 每遇到一个线程,就为其分配一个Program Counter Register(程序计数器), VM Stack(虚拟机栈)和Native Method Stack (本地方法栈),
当线程终止时,三者(虚拟机栈,本地方法栈和程序计数器)所占用的内存空间也会被释放掉。

方法区与堆这两块区域的内存清理通过垃圾收集器来回收。


程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,可以看做是当前线程执行的字节码的位置指示器
分支、循环、跳转、异常处理和线程恢复等基础功能都需要依赖这个计算器来完成。

为什么程序计数器是线程私有的呢? 由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。
在任何一个时刻,一个处理器都只会处理一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器来保证各条线程之间的程序计数器互不影响,独立存储。

如果线程正在执行的是一个java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址。
如果正在执行的是Native方法,则计数器为空(Undefined)。

该内存区域是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域。


java虚拟机栈(VM Stack)

java虚拟机栈也是线程私有的,它的生命周期与线程相同,线程结束后栈内存也就释放了,所以对于java虚拟机栈来说不存在垃圾收集的问题

有些资料把该区域翻译成java方法栈,大概是因为它所描述的是java方法执行的内存模型。
每个方法的执行,同时都会在虚拟机栈上创建一个栈帧(Stack Frame),用于存储局部变量表,操作栈(Operand Stack,记录出栈、入栈的操作),方法出口,动态链接等。
每个方法被调用直到执行完毕的过程,对应这帧栈在虚拟机栈的入栈和出栈的过程。有时候方法的递归,会造成大量的栈帧,达到一定的深度,会报StackOverflowError异常。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double),对象的引用以及returnAddress类型(指向了一条字节码指令的地址)。
有一点需要说明:在编译器编译Java代码时,就已经在字节码中为每个方法都设置好了局部变量区和操作数栈的数据和大小。并在JVM首次加载方法所属的Class文件时, 就将这些数据放进了Method Area(方法区),该局部变量表所需要的内存空间是固定的,运行期间也不会改变。
因此在线程调用方法时,只需要根据方法区中的局部变量区和操作数栈的大小来分配一个新的栈帧的内存大小,并堆入Java栈就可以了。
Alt text

栈帧是一个内存区块,是一个数据集(类似数据结构中的栈),是一个有关方法(Method)和运行期数据的数据集。
当一个方法 A 被调用时就产生了一个栈帧 F1,并被压入到栈中,A 方法又调用了 B 方法,于是产生栈帧 F2 也被压入栈,执行完毕后,先弹出 F2栈帧,再弹出 F1 栈帧,遵循“先进后出”原则
(对于“先进后出”,这里差个话题,为什么函数调用要用栈实现?,大R的回答)。
Alt text

java虚拟机栈可以动态的扩展,如果扩展时无法申请到足够的内存,则报OOM的错误。

java虚拟机栈内存溢出

如果线程请求的栈深度大于虚拟机所允许的最大深度,则抛出StackOverFlowError异常。

如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

java虚拟机栈-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
/**
* -Xss128k
*
* @author lit
*/

public class JavaStackOOMTest {

private int stackLength = 1;

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

public static void main(String[] args) throws Throwable {

JavaStackOOMTest oom = new JavaStackOOMTest();
try {
oom.stackLeak();
}
catch (Throwable err) {
System.out.println("Stack length:" + oom.stackLength);
throw err;
}

}
}

运行结果:
Alt text

可以看到,我机器上128k的栈容量能承载深度为2401的方法调用。

java虚拟机栈-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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* -Xss1M
*
* @author lit
*/

public class StackOOM {

public static AtomicInteger counter_integer = new AtomicInteger(0);

public void dnotStop() {
while (true) {

}
}

public void countThread() {
counter_integer.getAndIncrement();
}

/**
* 一直创建线程
*/

public void stackLeakByThread() {

while (true) {
new Thread() {

@Override
public void run() {


countThread();

dnotStop();
}
}.start();
}

}

public static void main(String[] args) throws Throwable {

StackOOM oom = new StackOOM();
try {
oom.stackLeakByThread();
}
catch (Throwable err) {
System.out.println("====>" + counter_integer.get());
throw err;
}
}
}

Xss可以设置大一些,很快就会报错

运行结果:
Alt text


本地方法栈(Native Method Stack)

本地方法栈是线程私有的。

与VM Strack相似,VM Strack为JVM提供执行JAVA方法的服务,Native Method Stack则为JVM提供使用native 方法的服务。它的实现比较自由,比如Hotspot把本地方法栈和java虚拟机栈合二为一了。

本地方法栈与java虚拟机栈一样,会报StackOverflowError与OOM异常。


java堆(heap区)

大多情况下,堆是jvm中最大的一块内存区域。

java堆被所有线程共享,在虚拟机启动的时候创建,几乎所有的对象实例都在这里分配内存(TLAB与逃逸分析,在栈上分配内存)。

java堆是垃圾收集的主要区域,整个java堆可以细分为:新生代(eden区、Survivor区(s1、s0))和年老代
现在的垃圾收集器基本都采用分代收集算法,根据不同代中对象的生命周期长短,采用不同的垃圾收集算法,可以扬长避短。
Alt text

Eden Space:大多情况下,对象在新生代eden区中分配内存(jvm提供了-XX:PretenureSizeThreshold参数,让大于这个值的对象直接进入年老代分配内存,避免eden和survivor区之间发生大量的内存复制)。

Survivor Space:用于保存在eden区中经过垃圾回收后没有被回收的对象,也就是“幸存还活着”的对象。

Survivor区分为两个相同大小的区域(s0,s1),
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。
紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。
年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。
经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。
不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
Alt text

Old Space:有些资料上也叫作Tenured区。对象经过survivor区,每经历过一次垃圾回收,年龄就增加1,超过设定阀值后,被移入年老代,当然也包括由于担保机制移入的对象。

年老代中对象的生命周期相对长一些,当年老代的内存使用到一定阀值的时候(或者晋升失败等其他触发major gc的情况),会触发年老代的垃圾回收,回收不用的对象。

java堆可以处于物理上不连续的内存空间中,只要逻辑上面是连续的即可。
堆的大小可以通过jvm参数扩展(-Xmx与-xms,一般情况设置为相同的值,避免堆自动扩展),如果对象的实例进入java堆时,没有足够的内存空间分配,同时堆也无法进行扩展,会报OOM异常。

一个对象的一生

我是一个普通的java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。
有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。
直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了,于是我就去了年老代那边。年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了很多年,然后被回收。

java堆内存溢出

java堆用于存储对象的实例,只要不断的去创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。

java堆OutOfMemoryError测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* VM Args:-Xmx10M -Xms10M -XX:+HeapDumpOnOutOfMemoryError
*
* @author lit
*/

public class HeapOOMTest {

private static final int _1MB = 1024 * 1024;

public static void main(String[] args) {

byte[] allocation = new byte[_1MB];

List<byte[]> list = new ArrayList<byte[]>();

while (true) {
list.add(allocation);
}

}
}

XX:+HeapDumpOnOutOfMemoryError 参数可以在jvm出现内存溢出时dump出当前的内存堆快照。

运行结果:
Alt text

OOM在实际的应用中非常常见,当出现java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示:“java heap space”表明是java堆的溢出。


方法区(Method Area)

方法区和java堆一样,是线程共享的内存区域。 它用于存储已经被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。
为了跟java堆区分开来,它有一个别名叫做Non-Heap。对于HotSpot虚拟机来说,jdk1.8以前该区域经常也会被叫做永久代,1.8以后永久代被取消

永久代可以使用-XX:MaxPermSize来扩展大小,垃圾回收在这个区域会比较少出现,这个区域内存回收的目的主要针对常量池的回收和类的卸载。
当方法区无法满足内存分配需求的时候,会报OOM异常。

运行时常量池(Runtime Constant Pool)

运行时常量池是方法区中非常重要的一部分。在字节码文件(Class文件)中,除了有类的版本、字段、方法、接口等先关信息描述外,还有常量池(Constant Pool Table)信息,用于存储编译器产生的字面量和符号引用。
这部分内容在类被加载后,都会存储到方法区中的运行时常量池中。值得注意的是,运行时产生的新常量也可以被放入常量池中,比如 String 类中的 intern() 方法产生的常量(大量的使用会造成方法区OOM)。

方法区内存溢出

这里使用String.intern(),它的作用是:如果字符串已经在方法区的常量池中,则返回代表池中这个字符串的String对象,否则会把字符串添加到常量池中。

方法区OutOfMemoryError测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* -XX:PermSize=10M -XX:MaxPermSize=10M
*
* @author lit
*/

public class ConstantOOM {

public static void main(String[] args) {

List<String> list = new ArrayList<String>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}

}

-XX:PermSize和-XX:MaxPermSize来限制方法区的大小

运行结果:
Alt text

方法区存放Class的相关信息,如类名、常量池、字段描述、方法描述等,如果不断的去产生大量的类,方法区也会溢出。
尤其是现在越来越多的框架,如Spring AOP、Hibernate等,在对类进行增强时,会使用CGLib这类字节码技术,来转换字节码并生成新的类,
所以在我们实际的工作中经常会碰到由此产生的“OutOfMemoryError: PermGen space”异常。

(CGLib)方法区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
37
38
import java.lang.reflect.Method;

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

/**
* -XX:PermSize=10M -XX:MaxPermSize=10M
*
* @author lit
*/

public class MethodAreaOOM {

static class OOMOjbect {
}

/**
* @param args
*/

public static void main(String[] args) {
// TODO Auto-generated method stub
while (true) {
Enhancer eh = new Enhancer();
eh.setSuperclass(OOMOjbect.class);
eh.setUseCache(false);
eh.setCallback(new MethodInterceptor() {

@Override
public Object intercept(Object arg0, Method arg1, Object[] arg2, MethodProxy arg3) throws Throwable {
// TODO Auto-generated method stub
return arg3.invokeSuper(arg0, arg2);
}

});
eh.create();
}
}
}

运行结果:
Alt text


直接内存(Direct Memory)

直接内存不是java虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。
该区域也会在 Java 开发中使用到,并且存在导致内存溢出的隐患。

如果你对 NIO 有所了解,可能会知道 NIO 是可以使用 Native Methods 来使用直接内存区的。
NIO是在JDK1.4以后新加入的类,基于通道(Channel)与缓冲区(Buffer)的I/0方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用来进行操作。
这样可以显著提高性能,避免java堆与native堆中来回复制数据。

直接内存不会受到java堆大小的限制,但是会受到本机总内存的大小和处理器寻址空间的限制。所以也可能出现oom的异常。


java虚拟机各内存区域图汇总

通过上面的介绍,我们对JVM的内存区域有了一定的理解。JVM内存区域可以分为线程共享和线程私有两部分,线程共享的有堆和方法区,线程私有的有虚拟机栈,本地方法栈和程序计数器;另外,直接内存也是线程共享的。

除了程序计数器,其他区域都有出现OOM的情况。

Alt text