JVM :Java 内存区域划分

380 阅读11分钟

Java 与 C++ 之间有一堵内存动态分配和 GC 围成的高墙,里面的人想出去,外面的人却想出来。

​ —— 《深入理解 Java 虚拟机》

对于 Java 程序员,由于我们将内存分配的职责托管给了虚拟机,因此我们不需要像 C++ 程序员一样随时要使用 free 手动释放内存。可正因如此,当发生内存泄露的问题时,我们又很难去排查出哪里发生了问题。在了解虚拟机内存管理的方式之前,我们首先需要大概知道 Java 对内存区域的划分都有哪些。

Java 的内存管理绕不开两个数据结构,即栈与堆。从软件设计的角度来说,栈代表了运算逻辑,而堆代表了数据存储。OOP 思想是堆栈分而治之的有机结合——将方法交给栈,将数据交给堆。

Java 运行时数据区

简单来说,运行时区域可以分为两种:一种是随着虚拟机启动工作时就一直存在,一种是依赖用户线程启动与结束建立且销毁。根据 《 Java 虚拟机规范》 的规定,虚拟机管理的内存包括以下几个运行时的数据区域:

注:直接内存不属于虚拟机运行时直接管辖的范围内。

程序计数器

它是一块较小的内存空间,所谓代码执行,就是字节码解释器工作时依赖修改程序计数器的值来找到下一条要执行的字节码指令。

每个线程在同一时刻值只会执行一条指令,各个线程之间的程序计数器互相独立,以便于在处理器不断切换线程时都能继续执行每个线程独立的任务。

如果线程正在执行 Java 方法,则该计数器记录的是虚拟机字节码指令的地址,如果线程正在执行本地方法,则该计数器会指向空( Undefined )。

Java 虚拟机栈

虚拟机会为每个线程分配一个独立的栈空间,这个栈空间的生命周期和线程相同。也可以这么说,虚拟机栈用来描述线程级别的内存模型。

当线程在执行某个方法时,虚拟机都会同步地创建一个*栈帧(Stack Frame)*存储该方法对应的局部变量表,操作数栈,动态连接,方法出口等信息。

一个方法从被调用到执行完毕的过程,就是其对应的栈帧入栈,出栈的过程。但换个角度去想,这也限制了栈内数据的生存周期。

大部分人都会简单地像 C/C++ 一样将 Java 的内存管理也分为栈内存(Stack)堆内存(Heap),比如说**”Java 的基本类型数据会存储到栈内存“**中。说得更严谨一点,这个数据是存储到了栈帧内的局部变量表当中。

局部变量表存放着编译期可知的基本数据类型值和对象引用(reference 类型,它不存储对象本身,而是只保存对象的地址。对象本身在绝大部分情况下保存在 Java 堆当中)。这些数据的存储单位是局部变量槽(Slot)。除了 64 位的 doublelong 类型需要 2 个变量槽之外,其它数据类型均使用 1 个变量槽。而一个变量槽是 32 位还是 64 位,则由不同的虚拟机自行来决定。

就 x86 或者 arm 指令集而言,数据的操作是基于寄存器的。而在虚拟机中,数据的计算操作则是在操作数栈进行,因此它屏蔽了硬件上的差异(以牺牲速度为代价)。简单地说,操作数栈可以理解为用于计算的临时数据存储区域。随着方法的执行,局部变量表内的值运送到操作数栈进行计算,并将计算结果弹出到局部变量表当中,或者是返回给方法的调用者。

有关于栈的两个异常

《 Java 虚拟机规范》 对内存区域规定了两类异常:

  1. StackOverFlowError:线程请求的栈深度超过了虚拟机允许的深度。(通常是出错的递归函数容易遇到)
  2. OutOfMemoryError:栈拓展时没有申请到足够的内存而引发的异常。

本地方法栈

何为本地方法?本地方法指代一个 Java 程序调用非 Java 代码的接口(比如说调用 C/C++ 代码实现的一些功能,或者说进行了系统调用,需要使线程进行内核态/用户态转换的函数),这些接口所期望实现的功能是 JVM 职责之外的,需要由本地系统库提供支持的,因而又称之本地方法。

它的好处是让 Java 程序员忽略系统调用的一些细节(比如我们使用 Socket 时可以忽略网络协议的诸多细节),而在上层构建自己的应用程序。涉及到本地方法的 Java 方法会使用 native 关键字修饰,这个关键字在各类处于”上游“的框架源代码中很少见到。

本地方法栈的功能和虚拟机栈职责相同,只不过它是专门为本地方法服务的。《 Java 虚拟机规范》并没有规定本地方法栈使用的语言,使用方式,数据结构等内容,每个虚拟机可以有自己的实现。

我们常用的 HotSpot 虚拟机会将 Java 栈和本地方法栈合并管理

Java 堆

我们通常说,Java 的对象通常存放于 Java 堆当中,原因就是 "堆结构的内存更适合管理大空间的数据"。那么,这句话有什么依据呢?

堆内存,按照堆结构记录的空闲内存。堆是按照某种结构排列(比如大根堆,小根堆)的完全二叉树。堆中的每个节点记录着零散的空闲内存地址和大小等信息。每当程序申请一个大小为 x 的内存空间时,系统就会顺着堆节点去寻找第一个空闲内存 >= x 的堆节点,然后将它对应的空间分配给程序。

因此,内存空间中零散的部分可以按照堆结构组织起来,并且根据申请的大小灵活地分配合适的内存空间,或者称动态内存分配。比如这样去做:

#include <malloc.h>
int main(){
	int n = 10;
	int* ints = (int*)malloc(n * sizeof(int))
}

这个 ints 指针实际会分配到多大的内存,取决于变量 n 的值。在复杂的业务当中,我们会根据自己的需要调整 n 值,以此来改变分配到的空间大小。

不同于栈的 ”调用则入,完毕则出“ ,尤其对于 C/C++ 程序员,存储在堆内存的数据生命周期,需要自己精心管理,如果操作不当就会引起内存泄露1的问题。不过对于 Java 程序员来说,堆空间的管理托管给了虚拟机,内部实现方式正是我们津津乐道的种种 GC 技术。因此,有人会直接将 Java 堆再划分出 ”新生代“,”老年代“,“永久代” 等名词,而它们并不是 《 Java 虚拟机规范》 的范畴,而是一些垃圾回收器的特性或者是做法。

Java 堆内存是 ”全局“ 的,那么就要提防在并发情况下多线程申请到同一个内存区域的情况。虚拟机会以牺牲少许效率为代价,使用同步方式来避免这个问题,比如使用 CAS。另外,'"全局"的概念也不是绝对的,我们仍然可以提前为每个线程分配一些缓冲区,即 TLAB 技术(Thread Local Allocation Buffer)

随着即时编译技术的进步,尤其是逃逸分析技术的强大,栈上分配,标量替换等手段的兴起,当年《 Java 虚拟机规范》中提到的“所有对象实例以及数组都应当在堆上分配”的规定已经在悄然间发生变化了。

另外,Java 堆可以设置为固定的,也可以设置为可自由拓展的(通过设置 -Xmx-Xms 参数设定)。如果 Java 堆内部没有空间分配,且也无法拓展时,则会抛出 OutOfMemoryError 异常。

方法区

方法区同 Java 堆一样,可以被各个线程共享。它保存了已经被虚拟机加载的类型信息,常量,静态变量,以及即时编译器编译后的代码缓存部分(比如 spring 使用 IOC 或者 AOP 创建 bean 时,或者使用 cglib ,反射的形式动态生成 class 信息等)。

《 Java 虚拟机规范》 将方法区描述为了堆的一部分,但是对于一些追求简单的虚拟机实现来说可以不在方法区内进行垃圾回收和压缩等操作。为了以示区别,方法区又称之为非堆( Non-Heap)

如何实现方法区并没有受《 Java 虚拟机规范》 的约束,HotSpot 虚拟机使用*永久代( PermGen space)*的概念实现了它(永久代和垃圾回收机制有关系),其设计目的是为了将方法区纳入 HotSpot 垃圾回收器的管理范畴内同时又省去专门为方法区编写的内存管理的代码工作。而对于 JRockit,IBM J9 等其它虚拟机而言,则没有永久代的这个概念。

用永久代实现方法区并不是一个好的想法,该设计让 Java 应用更容易遇到内存溢出等问题。因此在 JDK 1.7 ~ 1.8 版本这段期间, HotSpot 逐步放弃了永久代的概念,转而向 JRockit 和 IBM J9 等虚拟机看齐,基于*本地内存( Native Memory )元空间( Meta-Space )*实现了方法区。

如果方法区(包括运行时常量池)无法满足内存分配需求,则会抛出 OutOfMemoryError 异常。

运行时常量池

运行时常量池是方法区的一部分。

.class 文件规定了一个类的版本,字段,方法,接口等描述信息之外,还有一个常量池表( Constant Pool Table)。它记录着编译期间生成的各种字面量和符号引用,当虚拟机将这个 .class 文件加载到内存时,这部分内容会加载进运行时常量池当中。另外,一般虚拟机也会将符号引用翻译出来的直接引用也放到这里。

Java 不要求常量只会在编译期产生,或者说并非一定要提前写入到常量池表的内容才能进入到运行时常量池当中,运行期间亦可以将新的常量放到运行时常量池当中。

上面这段介绍涉及了一些概念,笔者以折叠的方式给出简单定义。在了解类加载之前,先暂时不用深究其意义。

字面量 ( Literal )
表示源代码中对固定值的表示法(notation).比如这一条赋值语句:表示源代码中对固定值的表示法(notation).比如这一条赋值语句:int a = 10,这个 10 就是一个字面量。字符串也属于字面量,指的是双引号括住的字符部分。
    ,这个 10 就是一个字面量。字符串也属于字面量,指的是双引号括住的字符部分。
    
符号引用 ( Symbolic References )
比如代码中的这一行:比如代码中的这一行: System.out.println("hi"),在编译时,编译器会使用一个临时字面量java/io/PrintStream.println:(Ljava/lang/String;)V标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载println方法,并将它转换成一个直接引用。,在编译时,编译器会使用一个临时字面量比如代码中的这一行:比如代码中的这一行: System.out.println("hi"),在编译时,编译器会使用一个临时字面量java/io/PrintStream.println:(Ljava/lang/String;)V标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载println方法,并将它转换成一个直接引用。,在编译时,编译器会使用一个临时字面量java/io/PrintStream.println:(Ljava/lang/String;)V标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载println方法,并将它转换成一个直接引用。标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载比如代码中的这一行:比如代码中的这一行: System.out.println("hi"),在编译时,编译器会使用一个临时字面量java/io/PrintStream.println:(Ljava/lang/String;)V标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载println方法,并将它转换成一个直接引用。,在编译时,编译器会使用一个临时字面量比如代码中的这一行:比如代码中的这一行: System.out.println("hi"),在编译时,编译器会使用一个临时字面量java/io/PrintStream.println:(Ljava/lang/String;)V标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载println方法,并将它转换成一个直接引用。,在编译时,编译器会使用一个临时字面量java/io/PrintStream.println:(Ljava/lang/String;)V标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载println方法,并将它转换成一个直接引用。标记它。当这行代码所在的类被加载时,虚拟机才会根据这个字符串提供的线索加载println方法,并将它转换成一个直接引用。方法,并将它转换成一个直接引用。
直接引用 ( Direct References )
直接引用是可以直接指向目标的指针,相对偏移量,或者是可以间接定位到目标的句柄。
    

直接内存

直接内存不属于 Java 虚拟机运行时数据区的范畴。它的分配不受 Java 堆大小的限制,而是受本机总内存以及处理器空间的限制。

在 JDK 1.4 版本中,引入了 NIO (New Input/Output) 类,它基于通道( Channel )缓冲区( Buffer) 的 I/O 方式,通过 Native 函数库分配堆外内存,并用一个 Java 堆内部的 DirectByteBuffer 对象媒介进行管理。显然,这种方式避免了 I/O 数据在 Java 堆和 Native 堆当中来回复制的繁琐过程。

参考资料

  1. 《深入理解 Java 虚拟机》 周志明

  2. Java 堆内存是线程共享的!面试官:你确定吗?

  3. 本地方法栈和本地方法接口

  4. Java虚拟机—栈帧、操作数栈和局部变量表

Footnotes

  1. 内存泄露,指一个对象被分配了内存空间,却不可达。C/C++ 没有 GC 机制,因此这块内存将永远无法回收。这种说法不意味着有 GC 机制的 Java 就不会发生此问题。详情可以参见:Java 的内存泄漏