Java内存区域&内存模型

Java作为一种面向对象、跨平台的语言,其对象、内存等一直是比较难懂的点。在谈及JVM时,Java内存区域(内存结构)和 Java内存模型(JMM)都是必不可少的,但是很多人把这俩概念都搞混了:Java内存区域和Java内存模型是两个概念

内存区域:指JVM运行时将数据分区域存储,强调对内存空间的划分。

内存模型:定义了线程和主内存之间的抽象关系,即JMM定义了JVM在计算机内存(RAM)中的工作方式。

Java内存区域

JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。可参考Oracle官方:

The Structure of the Java Virtual Machine

JVM运行时内存区域结构如下:

n0d4uF.png

图片摘自周志明《深入理解Java虚拟机——JVM高级特性与最佳实践》

其中:程序计数器、Java虚拟机栈、本地方法栈为线程私有;Java堆、方法区、执行引擎、本地库接口为线程共享的内存区域。

附上一张JDK8 之后的 JVM 内存布局,摘自阿里巴巴《码出高效》:

n0rmQ0.png

程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型中,字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

程序计数器是一块“线程私有”的内存,如上文的图所示,每条线程都有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。这样设计使得在多线程环境下,线程切换后能恢复到正确的执行位置。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;若执行的是Native方法,则计数器为空(Undefined)(因为对于Native方法而言,它的方法体并不是由Java字节码构成的,自然无法应用上述的“字节码指令的地址”的概念)。

程序计数器也是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的内存区域。

Java 虚拟机栈(Java Virtual Machine Stacks)

Java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame),栈帧中存储着局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,会对应一个栈帧在虚拟机栈中入栈到出栈的过程。与程序计数器一样,Java虚拟机栈也是线程私有的。

在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。

虚拟机栈规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(表示请求的栈溢出,导致内存耗尽,通常出现在递归方法);如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

操作栈的压栈与出栈

虚拟机栈通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上。在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。

下面分别说说栈帧中的局部变量表、操作栈、动态连接、方法返回地址。

局部变量表

局部变量表是存放方法参数和局部变量的区域。 局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内。

操作栈

操作栈是个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。

下面用一段简单的代码说明操作栈与局部变量表的交互:

1
2
3
4
5
6
public int simpleMethod() {
int x = 13;
int y = 14;
int z = x + y;
return z;
}

详细的字节码操作顺序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public simpleMethod() ;
descriptor : () I
flags : ACC_PUBLIC
Code :
stack=2, locals=4 , args_Size=1 // 最大深度为2,局部变量个数为4
BIPUSH 13 // 常量13压入操作栈
ISTORE_1 // 并保存到局部变量表slot_1

BIPUSH l4 // 常量14压入操作栈,注意是BIPUSH
ISTORE_2 // 并保存到局部变量表slot_2中

ILOAD_1 // 把局部变量表的slot_1元素(int x)压入操作栈
ILOAD_2 // 把局部变量表的slot_2元素(int y)压入操作栈
IADD // 把上方的两个数都取出来,在CPU里加一下,并压回操作栈的栈顶
ISTORE_3 // 把栈顶的结果存储到局部变量表的slot_3中

ILOAD_3
IRETURN // 返回栈顶元素值

局部变量表就像一个中药柜,里面有很多抽屉,依次编号为 0, 1, 2, 3, …, n ,字节码指令 ISTORE_l 就是打开1号抽屉,把栈顶中的数 13 存进去。栈是一个很深的竖桶, 任何时候只能对桶口元素进行操作 ,所以数据只能在栈顶进行存取。某些指令可以直接在抽屉里进行,比如iinc指令,直接对抽屉里的数值进行+l操作。

常见的i++++i的区别,可以从字节码上对比出来:

a = i++a = ++i
0:iload_10:iinc 1,1
1:iinc 1,13:iload_1
4:istore_24:istore_2

在表左列中, iload_l 从局部变量表的第 l 号抽屉里取出一个数,压入栈顶, 下一步直接在抽屉里实现 + l 的操作 , 而这个操作对栈顶元素的值没有影响,所以 istore_2 只是把栈顶元素赋值给 a ;表格右列 ,先在第 l 号抽屉里执行 + l 操作 , 然 后通过 iload_l 把第 1 号抽屉里的数压入栈顶, 所以 istore_2存人的是 + 1 之后的值。

注意: i++ 并非原子操作,即使通过 volatile 关键字进行修饰 ,多个线程同时写的话,也会产生数据互相覆盖的问题。

动态连接

每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。

方法返回地址

方法执行时有两种退出情况:

  1. 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等。
  2. 异常退出。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

  1. 返回值压入上层调用栈帧。
  2. 异常信息抛给能够处理的栈帧。
  3. PC计数器指向方法调用后的下一条指令。

本地方法栈(Native Method Stack)

地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowErrorOutOfMemoryError 异常。

线程开始调用本地方法时,会进入 个不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory

JNI 类本地方法最著名的应该是 System.currentTimeMillis() ,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中, 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性,威胁到程序运行的稳定性。假如需要与本地代码交互,就可以用中间标准框架进行解耦,这样即使本地方法崩溃也不至于影响到JVM的稳定。当然 ,如果要求极高的执行效率、偏底层的跨进程操作等,可以考虑设计为 JNI 调用方式。

Java 堆(Heap)

Heap 是 OOM 故障最主要的发源地 , 它存储着几乎所有的实例对象 , 堆由垃圾收集器自动回收 , 堆区由各子线程共享使用。通常情况下 ,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间。堆的内存空间既可以固定大小 ,也可以在运行时动态地调整,通过如下参数设定初始值和最大值 ,比如 -Xms256M -Xmxl024M ,其中 -X 表示它是 JVM 运行参数, msmemory start的简 称, mxmemory max的简称,分别代表最小堆容量最大堆容量

在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力,所以在线上生产环境中,JVM的XmsXmx设置成一样大小,避免在 GC 后调整堆大小时带来的额外压力 。

Java堆是垃圾收集器管理的主要区域,因此也被称为“GC堆”,从内存回收的角度看内存空间可划分如下:

n0xBMn.png

  1. 新生代(Young): 新生成的对象优先存放于此,新生代又可细分为Eden、From Survivor、To Survivor(也叫 S0 和 S1 区),默认比例为8:1:1。绝大部分对象在 Eden 区生成 ,当 Eden 区装填满的时候 , 会触发 YGC 。没有被引用的对象则直接回收。依然存活的对象会被移送到 Survivor 区。在这里,每次YGC的时候,它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。如果 YGC 要移送的对象大于 Survivor 区容量上限 ,则直接移交给老年代。新生代对象朝生夕死,存活率很低。在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。

  2. 老年代(Tenured/Old):每个对象都有一个计数器,每次YGC都会加l。-XX:MaxTenuringThreshold参数能配置计数器的值到达某个阈值的时候,对象从新生代晋升至老年代。如果该参数配置为1,那么从新生代的Eden区直接移至老年代。默认值是15,可以在Survivor区交换14次之后,晋升至老年代。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。

  3. 永久代(Perm):永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收

其中新生代和老年代组成了Java堆的全部内存区域,而永久代不属于堆空间,它在JDK 1.8以前被Sun HotSpot虚拟机用作方法区的实现。

注意:在不同的 JVM 实现及不同的回收机制中 , 堆内存的划分方式是不一样的

附上对象分配与简要的GC流程图:

nBm4Qx.png

方法区(Method Area)

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

JDK 1.8以前的永久代(PermGen)

Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集,也就是说,Java虚拟机规范只是规定了方法区的概念和它的作用,并没有规定如何去实现它。

对于JDK 1.8之前的版本,HotSpot虚拟机设计团队选择把GC分代收集扩展至方法区,即用永久代来实现方法区,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他的虚拟机(如Oracle JRockit、IBM J9等)来说是不存在永久代的概念的。

如果运行时有大量的类产生,可能会导致方法区被填满,直至溢出。常见的应用场景如:

  1. Spring和ORM框架使用CGLib操纵字节码对类进行增强,增强的类越多,就需要越大的方法区来保证动态生成的Class可以载入内存
  2. 大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)
  3. 基于OSGi的应用(即使是同一个类文件,被不同的类加载器加载也会视为不同的类)
    ……

这都会导致方法区溢出:java.lang.OutOfMemoryError: PermGen space,需设定运行参数:-XX:MaxPermSize=1280m

JDK 1.8的元空间(Metaspace)

在JDK 1.8中,HotSpot虚拟机设计团队为了促进 HotSpot 与 JRockit 的融合,修改了方法区的实现,移除了永久代,选择使用本地化的内存空间(而不是 JVM 的内存空间)存放类的元数据,这个空间叫做元空间(Metaspace)

做了这个改动后 java.lang.OutOfMemoryError: PermGen 的问题将不复存在,并且不再需要调整和监控这个内存空间。在 JDK8 及以上版本中,设定 MaxPermSize 参数, JVM 在启动时并不会报错,但是会提示: Java HotSpot 64Bit Server VM warning: ignoring option MaxPem1Size=2560m; support was removed in 8.0

如果类元数据的空间占用达到参数 MaxMetaspaceSize 设置的值,将会触发对死亡对象和类加载器的垃圾回收,元空间过多的垃圾收集可能表示类、类加载器内存泄漏或对你的应用程序来说空间太小了。为了限制垃圾回收的频率和延迟,适当的监控和调优元空间是非常有必要的。

元空间的内存管理由元空间虚拟机来完成。先前,对于类的元数据我们需要不同的垃圾回收器进行处理,现在只需要执行元空间虚拟机的C++代码即可完成。在元空间中,类和其元数据的生命周期和其对应的类加载器是相同的。话句话说,只要类加载器存活,其加载的类的元数据也是存活的,因而不会被回收掉。

在 JDK8 里, Perm 区中的字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内。

nBDWhd.png

比如图中的 Object 类元信息、静态属性 System.out、整型常量 10000000 等。显示在常量池中的 String ,其实际对象是被保存在堆内存中的。

运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池存放。

Java虚拟机对Class文件每一部分(自然包括常量池)的格式有严格规定,每一个字节用于存储那种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行。但对于运行时常量池,Java虚拟机规范没有做任何有关细节的要求,不同的提供商实现的虚拟机可以按照自己的需求来实现此内存区域。不过一般而言,除了保存Class文件中的描述符号引用外,还会把翻译出的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译器才能产生,也就是并非置入Class文件中的常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,此特性被开发人员利用得比较多的便是String类的intern()方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当无法再申请到内存时会抛出 OutOfMemoryError 异常。

直接内存(Direct Memory)

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但这部分内存也被频繁运用,而却可能导致OutOfMemoryError异常出现,所以这里放到一起讲解。

在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

nBg8XR.png

内存模型(Java Memory Model)

计算机高速缓存和缓存一致性

计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。

在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。

nBXapd.png

JVM主内存与工作内存

Java内存模型(JMM),其实JMM并不像JVM内存结构这样真实存在,它只是一个抽象概念:

JSR-133: Java Memory Model and Thread Specification

JMM是和多线程相关的,它描述了一组符合内存模型规范、屏蔽了各种硬件和操作系统的访问差异,定义了一个线程对共享变量的写入时对另一个线程是可见的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的一种机制及规范。

Java内存模型规定了所有的变量都存储在主内存中,每个线程有自己的工作内存,这个内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步。

nBbaqO.png

简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是 volatilesynchronized 等关键字。

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

有关重排序、内存屏障、happens-before等将会在下一篇文章中进行单独介绍。

参考文章

深入理解Java虚拟机(第2版)
码出高效:Java开发手册
《深入理解java虚拟机》
深入理解 Java 内存模型
Java内存模型是什么
Java内存模型和Java内存区域
Java内存模型原理,你真的理解吗?

附上《深入理解Java虚拟机(第二版)》,下载于网络,侵删!

点击查看

本文标题:Java内存区域&内存模型

文章作者:北宸

发布时间:2019年08月09日 - 10:10:33

最后更新:2023年08月19日 - 13:26:00

原始链接:https://www.liaofuzhan.com/posts/3268644462.html

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

-------------------本文结束 感谢您的阅读-------------------
🌞