运行时数据区域整体概览
下图为 JDK1.6 版本的 Java 虚拟机运行时数据区域。
关于新版本的改动:
- JDK1.7 将字符串常量池从运行时常量池挪到了堆中
- JDK1.8 对比 JDK 1.7 最大的改动就是使用元空间替代了永久代(即方法区)的实现,并且元空间被挪到了本地内存当中。原来方法区中存放的已被加载的类信息置于元空间中,而原来方法区中存放的常量池被放入堆中。如下图所示
本文主要基于 JDK1.6 版本的运行时数据区域进行梳理,因为所要描述的东西基本大同小异。
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。运行时数据区域主要有 程序计数器(Program Counter Register)、Java 虚拟机栈(JVM Stack)、本地方法栈(Native Method Stack)、堆(Heap)、方法区(Method Area) 这五个部分,其中前三个是线程私有的,后两个是线程共享的。方法区也被称之为永久代,在 JDK 1.8 之后被挪到了本地内存当中。
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程执行的字节码的行号指示器,记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为 undefined)。
因为每个线程都要执行自己的任务,所以每个线程都要有一个独立的程序计数器。所以说程序计数器是线程私有的。
另外,此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。换句话说,此区域不会发生内存泄露。
Java 虚拟机栈
与程序计数器一样,Java 虚拟机栈也是线程私有的。
每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、方法出口等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
局部变量表存放了:
- 编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、double、long)
- 对象引用
- returnAddress 类型(指明了一条字节码指令的地址)
在 Java 虚拟机规范中,对这个区域规定了两种异常情况:
1. StackOverFlowError 异常:
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverFlowError 异常。(所以,到这里相信你应该明白了编写错误的或不良好的递归为什么会引发 StackOverFlowError 异常,不停的压栈,栈不爆掉才怪)
2. OutOfMemoryError 异常:
栈进行动态扩展时如果无法申请到足够内存,将抛出 OutOfMemoryError 异常。换句话说,就是虚拟机栈过多的情况,将会导致 OutOfMemoryError 异常。
这段代码就是一个非常典型的例子(不要尝试去跑。。。容易让你的电脑崩溃,尤其是你的操作系统是 Windows 的话):
public void stackLeakByThread() {
while (true) {
new Thread() {
public void run() {
while (true) {
}
}
}.start();
}
}
异常报告:
Exception in thread "main" java.lang.OutOfMemoryError:
unable to create new native thread
因为 Windows 版本的虚拟机实现,Java 线程会映射到内核线程上,所以执行上述代码很有可能导致你的系统 GG,如果你真的头铁,请保存好笔记之类的重要信息,再去执行。
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小(这会间接的影响并发线程数的大小),在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M:
java -Xss2M HelloWorld
本地方法栈
本地方法栈与 Java 虚拟机栈所发挥的作用是非常相似的,它们之间的区别只不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈为本地方法(Native 方法)服务。
本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序。
与虚拟机栈一样,本地方法栈区域也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。
至此,线程私有的这三块内存已经梳理完毕,接下来我们来看看线程共享的部分。
Java 堆
对于大多数应用来说,Java 堆(Heap)是 Java 虚拟机所管理的内存中最大的一块,堆是被所有线程共享的一块内存区域。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java 堆是垃圾收集的主要区域(“GC 堆”,Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆还可以细分为:
- 新生代(Young Generation)
- 老年代(Old Generation)
进一步的还可以将新生代划分为:
- Eden 空间
- From Survivor 空间
- To Survivor 空间
但是请牢记一点,无论如何划分堆,不管哪个区域,存储的都是对象实例,进一步划分的目的在于更好地回收内存,或是更好地分配内存
堆不需要连续内存(指的是物理上不连续,但是逻辑上需要是连续的),并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。
可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
java -Xms2M -Xmx4M HelloWorld
方法区
方法区(Method Area)与 Java 堆一样,是各个线程所共享的内存区域。
它用于存储:
- 已被虚拟机加载的类信息
- 常量
- 静态变量
- 即时编译器编译后的代码等数据
和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。
使用元空间(Meta Space)相较于永久代(PermGen)的优势:
- 字符串常量池存放在永久代中(注意这里字符串常量池并不是放在了元空间中,而是转移到了堆中),容易出现性能问题和内存溢出。
- 类和方法的信息大小难以确定,给永久代的大小指定带来了困难。
- 永久代会为 GC(垃圾回收)带来不必要的复杂性。
运行时常量池
Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。
除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁使用,而且也可能导致 OutOfMemoryError 异常出现。
在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。