JVM内存模型是Java虚拟机在运行Java程序时管理内存的一套规范,它定义了内存的划分方式、各区域的作用以及内存的分配和回收规则,是Java程序跨平台运行的重要基础。不同的内存区域承担着不同的功能,对应着不同的生命周期和使用规则。

JVM内存区域划分
根据Java虚拟机规范,JVM内存主要划分为以下几个核心区域:
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。每个线程都有独立的程序计数器,线程之间互不影响,属于线程私有的内存区域。如果线程正在执行Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是本地方法,计数器的值为空。该区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈
Java虚拟机栈也是线程私有的,它的生命周期与线程相同。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈与虚拟机栈的作用非常相似,区别是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
Java堆
Java堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都应当在堆上分配。Java堆是垃圾收集器管理的内存区域,因此很多时候也被称作GC堆。如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。Java堆可以处于物理上不连续的内存空间中,但在逻辑上应当被视为连续的。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区
方法区也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在JDK8之前,方法区的实现是永久代,JDK8及之后改为元空间,元空间直接使用本地内存,不再受JVM堆内存大小的限制。如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池
运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池具备动态性,Java语言并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern方法。
GC回收机制详解
GC即垃圾回收,是JVM自动管理内存的核心机制,主要负责回收不再被使用的对象所占用的内存空间,避免内存泄漏和内存溢出问题。
判断对象是否可回收的算法
在回收对象之前,首先需要判断哪些对象已经不再被使用,常见的判断算法有两种:
- 引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。但是该算法很难解决对象之间相互循环引用的问题,因此主流的Java虚拟机都没有选用这种算法。
- 可达性分析算法:通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。可以作为GC Roots的对象包括:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象等。
常见垃圾回收算法
确定了可回收对象之后,就需要执行具体的回收操作,常见的回收算法有以下几种:
- 标记-清除算法:分为标记和清除两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法的缺点是标记和清除两个过程的效率都不高,并且会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
- 标记-复制算法:将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这种算法实现简单,运行高效,解决了内存碎片问题,但是内存利用率只有一半,代价太高。
- 标记-整理算法:标记过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。该算法解决了内存碎片问题,但是移动存活对象的过程需要更新所有引用这些对象的地方,开销较大。
- 分代收集算法:当前商业虚拟机的垃圾收集都采用分代收集算法,这种算法根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,然后根据各个年代的特点采用适当的收集算法。新生代中对象大部分朝生夕死,所以选用标记-复制算法,只需要付出少量存活对象的复制成本就可以完成收集;老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用标记-清除或者标记-整理算法来进行回收。
常见垃圾回收器
不同的垃圾回收算法有不同的实现,常见的垃圾回收器包括:
| 回收器名称 | 作用区域 | 特点 |
|---|---|---|
| Serial收集器 | 新生代 | 单线程收集器,简单高效,适合客户端场景 |
| Parallel Scavenge收集器 | 新生代 | 并行收集器,目标是达到可控制的吞吐量 |
| CMS收集器 | 老年代 | 并发收集器,以获取最短回收停顿时间为目标 |
| G1收集器 | 整堆 | 面向服务端应用,支持可预测的停顿时间模型 |
GC触发条件
不同的内存区域触发GC的条件不同:
- 新生代GC(Minor GC):当新生代的Eden区空间不足时触发,回收新生代的垃圾对象。
- 老年代GC(Major GC/Full GC):当老年代空间不足、方法区空间不足、或者调用System.gc()方法时可能触发,Full GC会回收新生代、老年代和方法区的垃圾,停顿时间通常比Minor GC长很多。
代码示例:观察GC行为
我们可以通过以下代码简单观察GC的回收行为:
public class GcDemo {
// 创建一个1MB大小的数组
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
// 分配2MB内存,此时对象在新生代Eden区
byte[] allocation1 = new byte[2 * _1MB];
// 再分配2MB内存,如果Eden区不足会触发Minor GC
byte[] allocation2 = new byte[2 * _1MB];
// 断开allocation1的引用,使对象变为可回收状态
allocation1 = null;
// 再次分配2MB内存,可能触发GC回收allocation1占用的空间
byte[] allocation3 = new byte[2 * _1MB];
}
}
运行该程序时,可以通过添加JVM参数-XX:+PrintGCDetails来打印GC的详细日志,观察内存分配和回收的过程。
需要注意的是,手动调用System.gc()方法只是建议虚拟机执行Full GC,但是虚拟机不一定会立刻执行,因此不建议在生产环境中使用该方法触发GC。
理解JVM内存模型和GC回收机制,能够帮助开发者在编写代码时更合理地使用内存,同时在遇到内存相关问题时,可以快速定位问题根源,通过调整JVM参数优化程序的运行性能。