Java 虚拟机在执行程序的过程中,会把它管理的内存划分为若干个数据区域,这些区域的用途不一,有着各自的创建和销毁时间。有些区域与虚拟机进程共存,有些区域则依赖用户线程。
概览
线程私有区域
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所指向的字节码的行号指示器。
由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行某个线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们称这类内存区域为线程私有的区域。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的时候本地(Native)方法,这个计数器值为空(Undefined)。
Java 虚拟机栈
与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。
Java 虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
在 《Java 虚拟机规范》中,对这个内存区域规定了两类异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。
- 如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存时,将抛出 OutOfMemoryError 异常。
HotSpot VM 没有实现动态扩展,但可以用下面的参数来指定线程栈大小:
1 | -XX:ThreadStackSize=512 //虚拟机栈大小。以 KB 为单位,0 表示使用默认的堆栈大小。 |
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈是非常相似的,区别是虚拟机栈 为虚拟机执行 Java 方法服务,而本地方法栈则是 为虚拟机使用到的本地(Native)方法服务。
《Java 虚拟机规范》对本地方法栈中使用的语言、使用方式与数据结构并没有任何强制规定。因此具体的虚拟机可以根据需要自由实现它,HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常。
线程共享区域
堆
对 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存的唯一目的就是存放对象实例,Java 世界里几乎所有的对象实例都在这里分配内存。
Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作 GC 堆(Garbage Collected Heap)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分带收集理论设计的,所以 Java 堆中经常会出现新生代、老年代、永久代、Eden 空间、From Survivor 空间、To Survivor 空间等名称。从分配内存的角度看,所有线程共享的 Java 堆可以划分出多个线程私有的分配缓存区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
Java 堆既可以被实现成固定大小的,也可以是可扩展的。不过当前主流的 Java 虚拟机都是按照可扩展来实现的。如果 Java 堆中没有足够的内存完成实例分配,并且也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。
1 | -Xms20M //Java 堆初始为 20MB。 |
方法区
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码等数据。 虽然《Java 虚拟机规范》中把方法区描述为堆的一个部分,但是它却有一个别名叫做非堆(Non-Heap),目的是与 Java 堆区分开来。
说到方法区,不得不提一下『永久代』这个概念,尤其是在 JDK 8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人更愿意把方法区称呼为『永久代』(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,仅仅是因为当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去了专门为方法区编写内存管理代码的工作。但是对于其它虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。
原则上如何实现方法区属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一。但现在回过头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应用更容易遇到内存溢出的问题(永久代有 -XX:MaxPermSize 的上限,即使不设置也有默认大小。而 JRockit 和 J9 只要没有触碰到进程可用内存的上限,例如 32 位系统中的 4GB 限制,就不会出问题),而且有极少数方法(例如 String::intern())会因为永久代的原因而导致不同虚拟机下有不同的表现。
考虑到 HotSpot 未来的发展,在 JDK 6 的时候 HotSpot 开发团队就有放弃永久代,逐步把原本放在永久代的字符串常量池、静态变量等移出。而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Meta-space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。
JDK 7 指定方法区大小。
1 | -XX:PermSize=20M //方法区初始为 20MB。 |
JDK 8 指定元空间大小。
1 | -XX:MetaspaceSize=20M //元空间初始为 20MB。 |
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constants Pool Table),用于存放编译器生成的各种字面量与符号引用,这部分内存将在类加载后存放到方法区的运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译器才能产生。也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用比较多的便是 String 类的 intern() 方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受 Java 堆大小的限制。但是,既然是内存,肯定还是会受到本机总内存(包括物理内存、SWAP 分区)大小以及处理器寻址空间的限制。 一般服务器管理员配置虚拟机参数时,会根据实际内存去设置 -Xmx 等参数信息,但经常会忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。