Java 的内存管理是一个老生常谈的问题, 虽然 Java 号称可以自动管理自己的内存, 使程序员从内存管理的围墙解放出来, 但是一连串的内存泄漏和溢出方面的问题, 使得我们不得不去深入了解 Java 的内存管理机制. 本篇文章将从 Java 的内存区域开始剖析 Jvm 的内存机制, 阐述内存溢出异常产生的原因.
运行时数据区域
众说周知, Java 程序是运行在 Java 虚拟机中的, 虚拟机顾名思义, 就是一个虚拟的计算机. 所以 Java 虚拟机也拥有一些与真实计算机相近的概念, 比如栈, 堆, 程序计数器等, 通常我们在这些概念面前加上虚拟机, 以表明特指 Java 虚拟机的栈.
Java 程序运行时, Java 虚拟机会对内存进行管理, 划分为若干个不同的数据区域, 每个数据区域都有其不同的功能. 根据《Java 虚拟机规范(Java SE 7 版)》的规定, Java 虚拟机所管理的区域会包括以下几个运行时区域, 如下图所示.
下面我们一一介绍每个区域的不同功能.
程序计数器
程序计数器, 即 PC. 学过计算机组成原理的同学一定对这个概念不陌生, 在计算机组成原理中 PC 指的是 PC 寄存器, 用来存放计算机执行的指令的所在内存区域的地址. 而在 Java 虚拟机中, PC 也有类似的作用, 它的作用是存储当前线程所执行的字节码的行号指示器, 通过改变这个计数器的值来选取下一条需要执行的字节码指令. 与计算机 PC 不同的是, 在 Java 虚拟机中, PC 只是一块较小的内存空间, 而不是寄存器.
由于 Java 虚拟机是多线程的, 为了在线程之间进行隔离, 每一个线程都会拥有一个独立的程序计数器. 因此, 在进行线程调度的时候, 每个线程的执行互不影响. 我们称这类内存区域为 "线程私有" 的内存.
程序计数器记录的只是正在执行的虚拟机字节码指令的地址, 如果执行的是 Native 方法, 那么计数器的值则为空. 该内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域.
Java 虚拟机栈
Java 虚拟机栈与系统栈也有些类似, 都用来存储程序运行过程中创建的栈帧, 不过 Java 虚拟机栈存储的是方法的栈帧而已, 它与程序计数器一样, 都是线程私有的.
在 Java 方法执行时创建的栈帧是用来存储局部变量表, 操作数栈, 动态链接, 方法出口等信息, 我们平常所说的方法的入栈和出栈就是一个方法从执行到结束的过程. 虚拟机栈的特性与一般的栈一样, 同样是后进先出, 递归调用的原理就是基于此.
一般来说, 对于 Java 虚拟机栈, 我们主要关心的部分是它的局部变量表的存储. 在我们定义一个变量的时候, 变量到底被存放在哪里是我们经常遇到的问题. 对于基本数据类型, 如 boolean,byte 等以及对象的引用 (reference 类型, 一个指向对象的指针或者是一个句柄, 不是对象本身) 和 returnAddress 类型(指向了一条字节码指令的地址).
局部变量表的大小是在编译期就已经完全确定下来的, 在方法运行期间不会改变局部变量表的大小. 同时, 对于 64 位长度的 long 和 double 类型的数据会占用两个局部变量空间, 其余的只占用一个.
在 Java 虚拟机规范中, 对这个区域规定了两种异常情况: StackOverflowError 异常和 OutOfMemoryError 异常.
本地方法栈
本地方法栈与虚拟机栈类似, 唯一的区别是本地方法栈是用来执行 Native 方法的.
Java 堆
Java 堆是我们在编写 Java 程序中所能使用的最大的一块的内存区域了, 也是我们经常需要调整的区域. 这个区域的唯一目的就是存放对象实例, 几乎所有的对象实例都在这里分配内存, 包括数组(因为数组也是引用数据类型).Java 堆与虚拟机栈不同, 它是被所有线程共享的区域, 在虚拟机启动的时候创建.
Java 堆还可以进一步分为: 新生代和老年代; 再细致一点的有 Eden 空间, From Survivor 空间, To Survivor 空间等, 这些更细致的分区是在 Java 堆垃圾收集器进行垃圾管理的时候需要考虑的.
Java 堆的大小可以是固定的, 也可以是不固定的, 可以通过 - Xmx 和 - Xms 控制, 前者是最大值, 后者是最小值, 在两者相同时, 堆的大小就是固定的. 在内存中如果没有足够的空间来分配, 将会抛出 OutOfMemoryError 异常.
方法区
方法区和 Java 堆一样, 是各个线程共享的内存区域, 用来存储已经被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码等数据.
这个区域也是属于需要进行垃圾回收的区域, 主要是回收常量池和对类型的卸载, 一般来说, 回收的效果不会太理想, 但是却是必须的.
根据 Java 虚拟机的规范规定, 当方法区无法满足内存分配需求时, 将抛出 OutOfMemoryError 异常.
运行时常量池
该区域是方法区的一部分, 用于存放编译期生成的各种字面量和符号引用, 有时候直接引用也会放入, 这部分内容将在类加载后进入方法区的运行时常量池中存放.
运行时常量池有一定的动态性, 对 String 类有所了解的同学应该明白, 在运行期间通过 String 类的 intern()方法可以动态往常量池里动态添加常量.
直接内存
直接内存不属于 Java 虚拟机运行时数据区的一部分, 而是属于操作系统管理的区域. 这部分的使用很频繁, 利用的好, 可以大大提升程序的运行效率, 比较优秀的使用例如基于 NIO 的 Netty 框架等.
为什么使用直接内存可以提升性能呢, 因为可以避免在 Java 堆和 Native 堆中来回复制数据的开销.
这部分的内存使用不会收到 Java 堆大小的限制, 但会收到本机的内存大小限制. 因此, 在操作这部分内存时需要谨慎, 一旦出问题, 可能会影响到本机的其它服务. 当各个内存区域总和大于物理内存限制, 抛出 OutOfMemoryError 异常.