1

本文就JVM运行时内存区域和Java内存模型进行一些简单的梳理。

一、JVM运行时内存区域

Java虚拟机在执行Java程序时,会将分配给JVM的内存划分为几个不同的区域。有些区域在JVM启动之后就存在,直到关闭JVM进程;有些区域则依赖于用户线程,随着用户线程的生命周期一同创建和销毁。

按照《Java虚拟机规范》的对应对JVM运行时内存区域的划分,以及Java8前后HotSpot虚拟机对该规范的具体实现,可以参考下图:

image.png

接下来我们对每个区域略作介绍。

1.1 程序计数器

程序计数器(Program Counter Register)是对当前线程执行的字节码的行号指示器。程序计数器占用的内存空间很小,它是线程私有的。当字节码(class)被执行时,线程通过自己的程序计数器来选取下一条字节码指令。程序控制流(分支,循环,异常处理等等)和线程切换时的线程上下文恢复都需要依赖这个计数器。

1.2 虚拟机栈

虚拟机栈就是我们平常讲JVM内存堆栈中的栈,它也是线程私有的,每个虚拟机栈的生命周期都与一个线程相同。虚拟机栈是线程用来执行方法的内存区域,后入先出(Last In First Out,LIFO)。如下图所示:

image.png

一个线程在执行方法时,每调用一个方法,就是将该方法作为栈帧压入自己的虚拟机栈;方法里调用另一个方法,就是将另一个方法的栈帧再压入虚拟机栈;线程当前执行的方法就是栈顶帧。

每个栈帧对应一个方法,其内部包括以下内容:

  1. 局部变量表,对应方法参数与局部变量,其类型是Java的8种基本数据类型加上对象引用。注意是对象的引用,不是对象本身。
  2. 操作栈,线程执行方法内部字节码操作指令时使用的后入先出栈,各种指令会往操作栈中写入和提取信息。Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作栈。
  3. 动态连接,每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,栈帧持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking),即,调用一个方法是通过该引用找到运行时常量池中的方法信息的。
  4. 方法返回地址,方法执行结束,不管是正常退出还是异常退出,都需要返回到该方法被调用的位置。

1.3 本地方法栈

本地方法栈与虚拟机栈类似,不同的是,虚拟机栈是用来执行java方法(class字节码)的,而本地方法栈是用来执行本地方法(native)的。所谓本地方法即JVM进程所在机器的OS的本地函数库,例如linux的.so或windows的.dll这些可执行类库中的方法。Java语法上,使用过JNI调用这些native接口的。

1.4 堆区

Java堆是JVM内存中最大的一块区域,JVM几乎所有的对象实例都在堆里分配内存并创建。堆内部区域的划分取决于JVM的垃圾回收策略,即GC策略。目前主流的GC策略大部分是基于分代收集算法的,如 parNew+CMS,或者G1等等。因此我们可以将Java堆再划分为新生代老年代。如下图所示:

image.png

分代收集算法大致过程:

  1. JVM新创建的对象会放在eden区域。
  2. eden区域快满时,触发Minor GC新生代GC,通过可达性分析将失去引用的对象销毁,剩下的对象移动到幸存者区S1,并清空eden区域,此时S2是空的。
  3. eden区域又快满时,再次触发Minor GC,对edenS1的对象进行可达性分析,销毁失去引用的对象,同时将剩下的对象全部移动到另一个幸存者区S2,并清空edenS1
  4. 每次eden快满时,重复上述第3步,触发Minor GC,将幸存者在S1S2之间来回倒腾。
  5. 在历次Minor GC中一直存活下来的幸存者,或者太大了会导致新生代频繁Minor GC的对象,或者Minor GC时幸存者对象太多导致S1S2放不下了,那么这些对象就会被放到老年代。
  6. 老年代的对象越来越多,最终会触发Major GCFull GC,对老年代甚至整堆的对象进行清理。通常Major GCFull GC会导致较长时间的STW,暂停GC以外的所有线程,因此频繁的Major GCFull GC会严重影响JVM性能。
Java8之前有一个永久代,也会被分代收集算法的GC管理。但永久代严格来说是不属于Java堆区域的,它实际上是对方法区的一种实现。下面的章节会对该概念进行说明。

1.5 方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。要注意的是,《Java虚拟机规范》中的方法区是一个逻辑上的区域,不同的JVM对它都有不同的实现。另外,它有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

在Java8之前,HotSpot虚拟机将方法区实现为永久代,能够通过分代收集的GC来管理其内存区域。但这种设计导致Java应用经常遇到内存溢出问题,很多JVM都需要在启动时添加参数-XX:MaxPermSize来调整永久代的大小。因此在Java7的时候,就先将方法区中的字符串常量池,静态变量等转移到了Java堆中;而到了Java8,就直接移除了永久代,将其中剩下的内容如类的元信息,方法元信息,class常量池,运行时常量池等移动到了一个新的区域Metaspace元数据区,将JIT即时编译的代码缓存放到了CodeCache区域。

不管是Java8之前的永久代,还是Java8以后的元数据区CodeCache,还是Java7以后堆中的字符串常量池,它们在逻辑上都属于方法区。只是不同JVM在不同版本中的具体实现不一样罢了。

这里提到的各种常量池,如字符串常量池class常量池运行时常量池将在以后的文章中进一步梳理。

1.6 内存区域异常类型

JVM内存的异常有两种,分别是内存溢出和栈溢出。

  • 内存溢出是OutOfMemoryError,一般对应线程共享区域如堆和元数据区。当内存不足以分配对象空间,而堆或方法区又无法扩展时,就会抛出该异常。比如对应堆区的OutOfMemoryError: Java heap space,对应元数据区的OutOfMemoryError: Metaspace。如果Java虚拟机栈容量可以动态扩展 ,当栈扩展时无法申请到足够的内存也会抛出OutOfMemoryError
  • 栈溢出是StackOverflowError,对应虚拟机栈和本地方法栈,当线程请求的栈深度大于虚拟机所允许的深度时就会抛出该异常。

二、Java内存模型

第一章讲的是JVM运行时的内存区域划分。而JVM还有一个重要的内存模型的概念,名字有点像,但其实讲的是并发环境下,JVM内存中的共享变量的访问规范。它叫JMM,Java内存模型。

该模型仅针对并发环境下,多个线程之间共享变量的场景。例如class的成员变量,在多线程环境下,不同的线程改变该成员变量的值时,如何在线程之间控制和通信。JMM本质上是一个缓存一致性协议。它的目的,是为了在多CPU核环境下,在尽量利用硬件提高计算性能的同时,保证缓存一致性。

2.1 硬件的效率与一致性

现代计算机执行计算任务时总是尽量让多个cpu尽量并行计算,但计算任务并非只有cpu就行,它总是需要读写内存数据;但内存IO的速度和CPU计算的速度之间有几个数量级的差距,因此现代CPU设计了多层高速缓存,让数据尽量离CPU更近一点。但高速缓存引入了一个新的问题,那就是缓存一致性。多个CPU分别使用自己的高速缓存进行读写后,需要将数据写回内存,如果各自不一致怎么办?以谁为准?为了解决这个问题,就需要在高速缓存和内存之间使用统一的规则来进行数据同步,这就是缓存一致性协议。

image.png

2.2 Java内存模型

JMM本身的设计如下:

image.png

JMM规范将共享变量所在内存划分为主内存工作内存。主内存为各线程共享,工作内存为各线程私有。当线程操作共享变量时,它需要将共享变量从主内存复制一份到工作内存中,在工作内存中修改之后再写回主内存。线程只能直接操作工作内存中的变量副本,变量副本与主存之间的读取和写入都是由实现了JMM规范的某种机制实现。

共享变量包括成员字段、静态字段以及数组中的元素。如果共享变量是一个基本数据类型,工作内存中的副本也是基本数据类型;如果共享变量是一个对象,工作内存中的副本就是该对象的引用。

JMM提供了4种操作来完成主存和工作内存之间的变量同步机制,分别是readwritelockunlock。这里不作细述。

一开始是8种,后来出于降低理解难度和严谨性的考虑,降为4种。利用这四种操作,JMM能够实现多个线程间对共享变量操作的原子性,可见性和有序性。

2.3 JMM与JVM运行时内存区域

JMM和JVM运行时内存区域其实没啥关系,但主存和工作内存的划分,容易和JVM运行时的各种内存区域产生联想,导致概念上的混淆。

周志明先生的《深入理解Java虚拟机》一书中有下面的叙述:

如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

另外可以参考下面这篇文章来理解二者之间对应关系:

Java Memory Model

大致意思就是主存主要对应堆,而工作内存属于虚拟机栈的一部分。但二者并非完全对应。JVM运行时内存区域并不是只对应硬件上的RAM内存,它的堆和栈都有可能部分分配在RAM上,部分分配在CPU寄存器与CPU高速缓存上。只是一般而言,堆区大部分位于RAM上,而虚拟机栈则有部分会分配在高速缓存上。至于工作内存,则应该随着虚拟机栈一起优先分配于寄存器和高速缓存中。


下塘烧饼
97 声望17 粉丝

个人文章迁移到知乎了:[链接]