Java内存区域

JVM是一个完整的计算机模型,所以自然需要有对应的内存模型,这个模型被称为Java内存模型,简称JMM。

JMM规定了JVM应该如何使用计算机内存(RAM)。广义来讲,Java内存模型分为两个部分:

  • JVM内存结构
  • JMM与线程规范

其中,JVM 内存结构是底层实现,也是我们理解和认识 JMM 的基础。 大家熟知的堆内存、栈内存等运行时数据区的划分就可以归为 JVM 内存结构

一.JVM内存结构

我们先来看看 JVM 整体的内存概念图:

JVM 内部使用的 JMM, 在逻辑上将内存划分为**线程栈(thread stacks)** 和**堆内存 (heap)**两个部分。 如下图所示:

  • JVM中,每个正在运行的线程,都有自己的线程栈(又叫"方法栈'或者"调用栈")
  • 线程栈包含了当前正在执行的方法链/调用链上的所有方法的状态信息
  • 线程栈里面保存了调用链上正在执行的所有方法中的局部变量。(局部变量是在方法内部声明的变量(包括方法的参数))
  • 堆内存(又叫"共享堆")中包含了 Java 代码中创建的所有对象,不管是哪个线程创建的。

下图演示了线程栈上的调用栈和局部变量,以及存储在堆内存中的对象:

  • 如果是原生数据类型的局部变量,那么它的内容就全部保留在线程栈上。
  • 如果是对象引用,则栈中的局部变量槽位中保存着对象的引用地址,而实际的对象内容保存在堆中。
  • 对象的成员变量与对象本身一起存储在堆上, 不管成员变量的类型是原生数值,还是对象引用。
  • 类的静态变量则和类定义一样都保存在堆中。

总结一下:原始数据类型和对象引用地址在栈上;对象、对象成员与类定义、静态变量在堆上

如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量,但每个线程的局部变量副本是独立的。

二.运行时数据区

运行时数据区等于JVM管理的内存,也就是说java程序内存等于运行时数据区+本地内存

Java虚拟机所管理的内存包括以下运行时数据区

2.1程序计数器PC

程序计数器 (PC,Program Counter Register)。在 JVM 规范中,每个线程都有它自己的程序计数器,并且任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行本地方法,则是未指定值(undefined)。

关于程序计数器的几点总结:

  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
  • 在 JVM 规范中,每个线程都有它自己的程序计数器是线程私有的,生命周期与线程的生命周期一致
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)
  • 它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError情况的区域

2.2 栈内存结构(虚拟机栈+本地方法栈)

  • 每启动一个线程,JVM就会在栈空间栈分配对应的线程栈
  • 如果使用了JNI方法,会分配一个单独的本地方法栈(Native Stack)
  • 线程执行过程中,一般会有多个方法组成调用栈, 比如 A 调用 B,B 调用 C......每执行到一个方法,就会创建对应的栈帧(Frame)。

2.2.1 虚拟机栈

  • Java虚拟机栈早期也叫Java栈;每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的叫做栈帧,对应着一次次的Java方法调用,是线程私有的,生命周期和线程一致。
  • 虚拟机栈的操作只有两个(入栈和出栈),当调用一个新的方法时,就创建一个栈帧压入到栈中,而一个方法执行结束就会有一个栈帧出栈,遵循"先进后出"的原则。
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧是有效的,这个栈帧被称为当前栈帧
  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。
  • 如果当前方法调用了其他方法,方法返回之际,当前栈会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  • Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出。

在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出**StackOverflowError(栈帧过多)异常;如果]ava虚拟机栈容量可以动态扩展扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError(栈帧过大)**异常。

2.2.2 本地方法栈

  • 一个native method就是一个Java调用非Java代码的接口。
  • 我们知道的Unsafe类就有很多本地方法。本地方法栈与虚拟机栈所发挥的作用是非常相似的,区别在于虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务

2.3.堆内存结构

堆内存是所有线程共用的内存空间,理论上大家都可以访问里面的内容。但 JVM 的具体实现一般会有各种优化。

Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过-Xmx和-Xms控制),如果堆中没有内存完成实例分配,并且堆无法再扩展时,就会抛出0ut0fMemoryError 异常

  • 将逻辑上的Java堆,划分为堆和非堆
  • 这么划分是因为编写的 Java 代码,基本上只能使用 Heap 这部分空间,发生内存分配和回收的主要区域也在这部分,所以有一种说法,这里的 Heap 也叫 GC 管理的堆(GC Heap)。
  • GC 理论中有一个重要的思想,叫做分代。程序中分配的对象,要么用过就扔,要么就能存活很久很久。
  • 因此,JVM 将 Heap 内存分为年轻代和老年代两部分。
  • 年轻代还划分为 3 个内存池,新生代(Eden space)和存活区(Survivor space), 在大部分 GC 算法中有 2 个存活区(S0, S1),在我们可以观察到的任何时刻,S0 和 S1 总有一个是空的, 但一般较小,也不浪费多少空间。
  • 对新生代还有优化,那就是 TLAB, 给每个线程先划定一小片空间,你创建的对象先在这里分配,满了再换。
  • Non-Heap 本质上还是 Heap,只是一般不归 GC 管理,里面划分为 3 个内存池。
  • Metaspace, 以前叫持久代(永久代, Permanent generation), Java8 换了个名字叫 Metaspace. Java8 将方法区移动到了 Meta 区里面,而方法又是class的一部分和 CCS 交叉了
  • CCS, 存放 class 信息的,和 Metaspace 有交叉。
  • Code Cache, 存放 JIT 编译器编译后的本地机器代码。

注意: 由于早期的 Hotspot JVM 实现,很多人习惯于将方法区称为永久代(Permanent Generation)。Oracle JDK 8 中将永久代移除,同时增加了元数据区(Metaspace)

2.4 方法区

  • 方法区(Method Area)与]ava堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出OutOfMemoryError错误。JVM 关闭后方法区即被释放。
  • 《]ava虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选 择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。
  • 方法区(method area)只是 JVM 规范中定义的一个概念,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
  • 两种实现存储内容不同,元空间存储类的元信息,而静态变量和字符串常量池等并入堆空间中,相当于永久代的数据被分到了堆空间和元空间中
  • Java7 中我们通过-XX:PermSize 和 -xx:MaxPermsize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过XX:Metaspacesize和-XX:MaxMetaspacesize 用来设置元空间参数。

所以总结方法区,Java8 之后的变化:

  • 移除了永久代(PermGen),替换为元空间(Metaspace)
  • 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
  • 永久代中的 interned Strings 和 class static variables 转移到了]ava堆
  • 永久代参数(PermSize MaxPermSize)->元空间参数(MetaspaceSizeMaxMetaspaceSize)

2.5 运行时常量池

  • 运行时常量池是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放
  • JVM 为每个已加载的类型(类或接口)都维护一个运行时常量池,在加载类和接口到虚拟机后创建,所以运行时常量池相对于Class文件常量池的另一重要特性:具备动态性
  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出0utOfMemoryError异常。

注意:

1.每个 class 都有一个运行时常量池,不是共享的,但数据共享。

2.字符串常量池是全局唯一的。被所有类共享。

常量池是在字节码文件中,而运行时常量池在元空间当中(1DK8 及以后),讲的是一个东西,但形态不一样,就好像一个是固态,一个是液态;

2.6本地内存和直接内存

本地内存(Native Memory): JVM 所管理的运行时数据区范围之外的内存。并不是虚拟机运行时数据区的一部分,它也不是]ava虚拟机规范定义的内存区域。我们可以看到在 HotSpot 中,JDK1.8就将方法区移除了,用元数据区来代替,并且将元数据区从虚拟机运行时数据区移除了,转到了本地内存中,也就是说这块区域是受本机物理内存的限制,当申请的内存超过了本机物理内存,才会抛出 0utOfMemoryError 异常。
直接内存(Direct Memory):

直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java堆内存,从而提高了效率

Java 程序内存 = JVM 内存 +本地内存
本地内存 = 元空间 + 直接内存

四.CPU指令

CPU 的实现都是采用流水线的方式。如果 CPU 一条指令一条指令地执行,那么很多流水线实际上是闲置的。简单理解,可以类比一个 KFC 的取餐窗口就是一条流水线。于是硬件设计人员就想出了一个好办法:"指令乱序"。 CPU 完全可以根据需要,通过内部调度把这些指令打乱了执行,充分利用流水线资源,只要最终结果是等价的,那么程序的正确性就没有问题。但这在如今多 CPU 内核的时代,随着复杂度的提升,并发执行的程序面临了很多问题。

五.内存屏障

前面提到了CPU会在合适的时机,按需要对将要进行的操作重新排序,但是有时候这个重排机会导致我们的代码跟预期不一致。

怎么办呢?JMM 引入了内存屏障机制

内存屏障可分为**读屏障写屏障**,用于控制可见性。 常见的 内存屏障 包括:

#LoadLoad

#StoreStore

#LoadStore

#StoreLoad

这些屏障的主要目的,是用来短暂屏蔽 CPU 的指令重排序功能。 和 CPU 约定好,看见这些指令时,就要保证这个指令前后的相应操作不会被打乱。

  • 比如看见 #LoadLoad, 那么屏障前面的 Load 指令就一定要先执行完,才能执行屏障后面的 Load 指令。
  • 比如我要先把 a 值写到 A 字段中,然后再将 b 值写到 B 字段对应的内存地址。如果要严格保障这个顺序,那么就可以在这两个 Store 指令之间加入一个 #StoreStore 屏障。

六.OOM

  • OOM(Out Of Memory)通俗点讲,就是JVM内存不够了;javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾回收机制没法提供更多内存
  • 这里隐含一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间,当然,也不是在任何情况下垃圾收集器都会被触发的,比如,我们去分配一个超大对象,类似-个超大数组超过堆的最大值,JM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError.

那么提出疑问,谈谈 JVM 内存区域的划分,哪些区域可能发生 OutOfMemoryError?

从上面分析的角度,除了程序计数器,其他区域都有可能会因为可能的空间不足发生 OutOfMemoryError,简单总结如下:

  • **堆内存不足是最常见的 OOM 原因之一,**抛出的错误信息是"java.lang.OutOfMemoryError:Java heap space",原因可能千奇百怪,例如,可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小;或者出现 JVM 处理引用不及时,导致堆积起来,内存无法释放等。
  • 而对于 Java 虚拟机栈和本地方法栈,这里要稍微复杂一点。如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError;当然,如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError。
  • 对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 Intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:"java.lang.OutOfMemoryError: PermGen space"。
  • 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:"java.lang.OutOfMemoryError: Metaspace"。
  • 直接内存不足,也会导致 OOM
相关推荐
栗子飞啊飞4 小时前
如何实现大模型 “边生成边显示“
java·deepseek
一介书生-0074 小时前
2025-10-27 Java AI学习路线
java·人工智能·学习
py有趣4 小时前
LeetCode算法学习之移除元素
java·数据结构·算法
旺仔小拳头..6 小时前
Maven相关
java·maven
要一起看日出6 小时前
数据结构---------红黑树
java·数据结构·红黑树
程序定小飞6 小时前
基于springboot的民宿在线预定平台开发与设计
java·开发语言·spring boot·后端·spring
FREE技术6 小时前
山区农产品售卖系统
java·spring boot
星光一影7 小时前
Java医院管理系统HIS源码带小程序和安装教程
java·开发语言·小程序