前言
在周志明老师的《深入理解Java虚拟机:JVM高级特性和最佳实践》中有下面一段话:
Java与C++之间有一堵有内存动态分配和垃圾收集技术所围成的“高墙”,墙外的人想进去,墙里面的人却想出来。
Java语言最大的特性之一就是安全,因为内存的控制权属于Java虚拟机(Java Virtual Machine,简称JVM),所以不容易出现内存泄漏和溢出问题。
而这也恰恰导致了一旦出现内存泄漏或内存溢出的问题,如果不了解JVM如何使用内存,那么修复问题将会变得异常艰难。
本文笔者将和大家一起学习JVM内存的各个区域,以便在未来的面试和工作中遇到相关的问题能够得心应手的解决它们。
运行时数据区域
JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建销毁的时间,有的区域随着虚拟机的进程的启动而存在(共享区域),有些区域则依赖用户线程的启动和结束而建立和销毁(线程私有区域)。
Java虚拟机管理的内存包括以下几个部分:
程序计数器(Program Counter Register)
虚拟机栈(VM Stack)
本地方法栈(Native Method Stack)
堆区(Heap)
方法区(Method Area)
其中堆区和方法区是线程共享的,所有线程共享这些区域的数据。而其他三个区域是线程私有的,每个线程都有自己的程序计数器、虚拟机栈、本地方法栈。
1. 程序计数器
程序计数器是JVM内存中相对较小的一块内存空间,可以将它看成是字节码文件的行号指示器。
字节码解释器工作时就是通过改变这个计数器的值来决定下一条需要执行的字节码指令的,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖它来完成。
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来完成的,在任意一个时间点,一个处理器(对于多核处理器来说是一个内核)都会执行一条线程中的指令。
所以线程切换之后都需要恢复到正确的执行位置,那么就要求每个线程都拥有自己的程序计数器,也就是说这块内存是“线程私有”的。
特殊的,JVM执行的方法分为Java方法和本地方法(由Native修饰的方法,主要由C++语言编写,通常简称为JNI,Java Native Interface),当执行的方法是Java方法时,计数器记录的是正在执行的字节码指令的地址;
当执行的是本地方法的时候,计数器的值为空(Undefined)。此内存区域是唯一哥哥在Java虚拟机规范中没有任何规定OutOfMemoryError情况的区域。
2. Java虚拟机栈
虚拟机栈描述的是Java方法执行的内存模型:每一个方法在执行是都会创建一个栈帧(Stack Frame,栈帧是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用到执行完成都对应一个栈帧入栈和出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型和引用类型(reference类型)的变量。
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,并且不会改变。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果现场请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常,例如无限递归的情况,每一次递归调用都对应一个栈帧入栈,栈满了之后就会出现这种情况。
对于可动态扩展的虚拟机栈在动态扩展时无法申请到足够的内存时,就会抛出OutOfMemoryError。
3. 本地方法栈
这个栈和上面的虚拟机栈本质上没有什么区别,他们唯一区别就是本地方法栈为本地方法提供服务,虚拟机栈为普通的Java方法提供服务。
4. Java堆
通常来讲,Java堆是JVM所管理的内存区域中最大的一块内存区域,并且它是所有线程共享的区域,随着虚拟机的启动而创建。
这块区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存(这里先不说例外的情况)。
Java堆是垃圾收集器管理的主要区域(还有一部分区域是方法区),所以很多时候被称为“GC堆”(Garbage Collected Heap)。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。
在实现时,可以实现成固定大小,也可以实现成可扩展的。
这里指的是,规范是官方定的,而具体不同的虚拟机生产厂商可能实现不同的虚拟机。
某些厂商实现的虚拟机的堆内存大小在虚拟机启动的时候就确定了,无法再“扩容”。
而当前市场主流的虚拟机的堆内存都是可扩展的,就是说当发现堆内存大小不足时,会动态扩展,直到堆内存太大而无法扩展。
如果在创建一个实例的时候堆内存不足,且无法再扩展时,会抛出OutOfMemoryError异常。
5. 方法区
方法区和Java堆一样,是各个线程共享的内存区域,所有有些文章中也会称之为共享区。
它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译期编译后的代码等数据。
对于习惯了在HotSpot虚拟机(最为常见的一种虚拟机)上开发、部署程序的开发者来说,很多人都更愿意把方法区称为“永久带”(Permanent Generation)(关于分代的知识将会在后面垃圾回收相关的博文中讲述)。
本质上两者并不等价,仅仅是因为HotSpot虚拟机设计团队为了省去专门为方法区编写内存管理的代码而选择把GC分代扩展至方法区而已。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
相对而言,垃圾收集的行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”的存在了。
这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
一般来说这个区域的回收效率较低,尤其是类型的卸载,条件非常的苛刻,但是这部分区域的回收确实是必要的。
根据Java虚拟机规范的规定,当方法区无法满足内存分配的需求时,会抛出OutOfMemoryError异常。
5.1 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table)(请区分运行时常量池和Class文件常量池),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中的常量池的内容才能进入方法区的运行时常量池,运行期间也可能将新的常量放入运行时常量池中。
扩展:直接内存
直接内存(Direct Memory)并不属于虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。
但是这部分内存也会被频繁的使用,并且可能导致OutOfMemoryError。这部分区域也通常被称为“堆外区域”。
堆外内存的典型应用
在JDK 1.4中新加入了NIO(New IO,或称为Non-Blocking I/O,非阻塞I/O),引入了一种新的基于通道(Channel)和缓冲区(Buffer)的I/O方式。
NIO 弥补了原来的 I/O 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。通过定义包含数据的类,以及通过以块的形式处理这些数据,NIO 不用使用本机代码就可以利用低级优化,这是原来的 I/O 包所无法做到的。
其中java.nio.DirectByteBuffer是Java中用于实现堆外内存的一个重要类,通常用于在通信中做缓冲池,如在Netty、MINA等NIO框架中广泛应用。
它可以使用sun.misc.Unsafe类调用Native函数库来直接分配堆外内存,然后通过这个对象操作这块内存。
可能引发的问题
直接内存的分配不受Java堆大小的限制,但是会受到本机总内存的限制。服务器管理人员在配置虚拟机参数是,会根据实际内存设置-Xmx等参数信息设置堆区最大值。
但是由于忽略直接内存,使得各个区域内存大小总和大于本机总内存,从而导致动态扩展(上面提到很多区域都支持动态扩展)时出现OutOfMemoryError异常。
总结
JVM运行时数据区域分为五个部分,分别为程序计数器、虚拟机栈、本地方法栈、堆、方法区。其中程序计数器、虚拟机栈、本地方法栈线程私有,而堆和方法区是线程共享的。
区域
说明
程序计数器
可以简单理解为记录字节码运行位置的计数器;
占用的空间很小;
唯一一个不会出现OutOfMemoryError异常的区域。
Java虚拟机栈
描述了Java方法执行的内存模型,用于存储局部变量表、操作数栈、动态链接、方法出口等信息;
可能出现StackOverFlowError和OutOfMemoryError异常。
本地方法栈
类似Java虚拟机栈,不同的是为本地方法服务。
堆
最大内存区域;
存放对象实例;
垃圾回收的主要区域;
可能出现OutOfMemoryError异常。
方法区
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译期编译后的代码等数据;
垃圾回收区域,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
可能出现OutOfMemoryError异常。
参考
周志明《深入理解Java虚拟机:JVM高级特性和最佳实践》第2版