本篇文章是对 Java 内存区域的概述与总结,大部分参考自《深入理解 Java 虚拟机》1,以及自己对这部分的理解。记性不好就先把它记下来,以便日后复习使用。如果能帮助到你的话最好了。

为什么需要掌握 Java 内存区域?

一句话:为了在程序出错时及时排错,根据 Java 内存区域的相关原理找到错误发生的位置以及明确是如何发生的。

一般情况下,我们不需要像 C 或 C++ 一样去手动管理内存区域,反而是将该任务交给 JVM 去管理,让它自己去实现数据在内存中的分配以及对垃圾的回收。

但对于 Java 程序员来说,理解 Java 内存区域对于能够帮助我们更好地掌握代码中数据的存放位置以及使用方式,对于以后框架的学习或者与 Java 有关的知识能够起到雪中送炭的作用,同时这部分内容也是面试必考的问题,不管是对校招的学生来说,还是对社招的跳槽人士来说,都显得十分重要。

Java 内存区域的组成

Java 虚拟机在执行程序的时候会把它所管理的内存划分为不同的区域,有的区域随着虚拟机进程的启动而存在,有的区域随着用户线程的启动和结束而相应的创建和销毁。Java 内存 运行时数据区域 的划分如下图2所示:

image.png

其中,由所有线程共享的数据区有:

  • 方法区

线程私有或线程隔离的数据区有:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

程序计数器

程序计数器(Program Counter Register)是当前线程所执行的字节码的行号指示器,它占用一块较小的内存空间。字节码解释器通过改变该计数器的值来选取下一条需要执行的字节码指令,其中包括分支、循环、跳转、异常处理、线程恢复等基础功能。

如果线程正在执行的是一个 Java 方法,则该计数器记录的是正在执行的 虚拟机字节码指令的地址;如果正在执行的是一个 Native 方法,则该计数器为空(Undefinaed)。

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

关于 Native 方法?

即被 native 关键字修饰的方法,这是一个非访问修饰符,用于访问以非 Java 语言(如 C/C++)实现的方法,该方法没有方法体并且以分号结束。它有如下的几个作用3

  • 通过使用其它编程语言编写的系统调用或库,来实现接口的功能;
  • 能够访问只能从其它语言访问的系统或硬件资源;
  • 能够将已经存在的用 C/C++ 编写的遗留代码集成到 Java 应用程序中;
  • 使用 Java 中的任意代码调用编译后的动态加载库

为什么程序计数器是线程私有的?

因为 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都会只执行一条线程中的指令。

因此,为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储。

虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks)也就是我们平时所说的 栈内存 ,或者指的就是虚拟机栈中的 局部变量表 部分。它描述的是 Java 方法执行的内存模型:即每个方法在执行的同时都会创建一个 栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完毕的过程,就对应着一个栈帧在虚拟机栈中的入栈和出栈的过程。

局部变量表存放了编译期的各种基本数据类型(byte、short、int、long、float、double、boolean、char)、对象引用类型和 returnAdderss 类型。局部变量表所需的内存空间是在编译期间完成分配的,当进入一个方法的时候,该方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

对象引用类型,即 reference 类型。它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是一个代表对象的句柄或其它与此对象相关的位置。

returnAdderss 类型,即指向了一条字节码指令的地址。该类型会被 JVM 的 jsr、ret 和 jsr_w 指令所使用,该类型的值指向一条虚拟机指令的操作码,且该类型不存在 Java 语言中相应的类型,也无法在程序运行期间更改4

对于虚拟机栈,在 JVM 中存在两种异常:

  • StackOverflowError:若线程请求的栈深度大于虚拟机所允许的深度,将抛出该异常。
  • OutOfMemoryError:对于可以动态扩展的虚拟机栈,如果扩展时无法申请到足够的内存,将抛出该异常。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈的作用是相似的。但本地方法栈为虚拟机使用到的 Native 方法提供服务。

此外,对于此区域出现的异常,和虚拟机栈是相同的。

堆(Heap)区域对于 JVM 来说是非常重要的,它是 JVM 所管理的内存中最大的一块,虚拟机启动的时候就创建了堆。

该内存区域的作用是为 对象实例以及数组分配内存。同时 Java 堆也是垃圾回收器管理的主要区域,具体内存的分配方式如下图所示:

image.png

主要分为三部分:

  • 新生代(也叫年轻代),包括 Eden Space、Survivor 0 Space、Survivor 0 Space。对于后两者也有不同的叫法,即 From Survivor、To Survivor;
  • 老年代(Tenure Generation Space 或者 Old Space);
  • JDK 1.8 之前(不包括 1.8)使用的持久代(Permanent Space);
  • JDK 1.8 之后(包括 1.8)将持久代换成了元空间(Metaspace)。

具体细节不展开,这里可以再写一篇文章。

对于堆来说,它可以位于物理上不连续而逻辑上连续的内存空间中。在实际情况下也可以通过调整虚拟机参数的方式对堆进行扩展,即通过 -Xmx-Xms 控制。

如果在堆中没有内存完成实例分配,并且堆也没有办法再扩展的时候,会出现 OutOfMemoryError 异常。

方法区

方法区(Method Area)用于存储已被虚拟机加载的 类信息常量静态变量即时编译器编译后的代码 等数据。

在 HotSpot 虚拟机中,可以将方法区称为永久代(Permanent Generation)。因为当初在设计的时候,对于 GC 分代收集扩展到方法区了,即使用永久代来实现方法区,这样对于垃圾回收机制来说,垃圾收集器就可以像管理 Java 堆一样来管理这部分内存。但是也容易出现内存溢出的问题,所以在 JDK 1.7 中,已经把原本放在永久代的字符串常量池给移出了。

此外,当方法区无法满足内存分配需求时,会出现 OutOfMemoryError 异常。

运行时常量池

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

这里有两个概念:运行时常量池Class 文件常量池。运行时常量池具有动态性,即对于常量来说,并不一定只有在编译期才能产生常量,也就是说并非事先加载到 Class 文件中常量池的内容才能进入方法区的运行时常量池,而在运行期间也可能将新的常量放到池中(使用 String 类的 intern() 方法)。

此外,当运行时常量池受到方法区内存大小限制的时候,即常量池无法再申请到内存的时候,会出现 OutOfMemoryError 异常。

直接内存

你可以看到在 Java 内存运行时数据区域 划分图中没有 直接内存 这一部分,因为直接内存并不属于 Java 内存区域中的一员,但该部分也会出现 OutOfMemoryError 异常。

由于本机直接内存的分配不会受到 Java 堆大小的限制,但还是会受到本机总内存(包括 RAM 及 SWAP 区或者分页文件)的大小及处理器寻址空间的限制。在进行虚拟机参数配置的时候,如果 Java 内存区域中的各部分内存的总和大于物理内存而忽略了直接内存的话,就会导致动态扩展时出现 OutOfMemoryError 异常。

JVM 内存中的细节

下面主要讨论在 HotSpot 虚拟机下的 Java 堆中,对象的分配、布局和访问的全过程。

以下仅限普通 Java 对象,不包括数组和 Class 对象等。

对象的创建

我们一般使用 new 关键字去创建对象,当虚拟机遇到一条 new 指令的时候,首先去检查该指令是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,则必须先执行响应的类加载过程,即 加载 -> 验证 -> 准备 -> 解析 -> 初始化

当类加载完成后,虚拟机将为该对象分配内存。对象所占内存空间的大小在类加载完成后便可以完全确定,为对象分配空间的任务等于把一块确定大小的内存从 Java 堆中 划分 出来。

划分的方式分为两种,即 碰撞指针(Bump the Pointer)空闲列表(Free List)

碰撞指针(Bump the Pointer)

  • 假设初始的 Java 堆是相对规整的,也就是所有用过的内存放在一边,没有用过(空闲)的内存放在另一边,中间放着一个指针作为分界点的指示器。这么一来,所分配的内存就是把该指针向空闲空间那边挪动一段与对象大小相等的距离。

空闲列表(Free List)

  • 假设初始的 Java 堆不是相对规整的,已使用的内存和空闲内存交错在一起,这时虚拟机就维护了一个列表,用来记录哪些内存是可用的,在分配内存的时候从该列表中找到一块足够大的空间划分给该对象实例,并更新列表上的记录。

选择哪种分配空间的方式是由 Java 堆是否规整决定的,而 Java 堆是否规整又是由所使用的垃圾回收器是否带有压缩整理功能决定的。

因此,在使用 Serial、ParNew 等带有 Compact 过程的收集器时,系统采用的分配算法是 指针碰撞;而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用 空闲列表

关于垃圾收集机制也需要写一篇文章。

除了如何划分可用空间之外,还需要考虑的问题是:在虚拟机中,对象的创建是一种非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的。因为可能会出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。

有两种方法解决上述问题:

  • 比较并交换(Compare And Swap,CAS)
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

第一种方式通过对分配内存空间的动作进行 同步 处理,虚拟机采用 CAS(Compare And Swap,比较并交换) 与失败重试的方式保证更新操作的原子性;另一种方式是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称为 本地线程分配缓冲,哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。

内存分配完以后,虚拟机需要将分配到的内存空间都初始化为 零值(不包括对象头)。根据不同的数据类型,不同的初始化如下表所示:

数据类型 零值
byte (byte) 0
short (short) 0
int 0
long 0 L
float 0.0 f
double 0.0 d
char ‘\u0000’
boolean false
reference null

接下来,虚拟机需要设置对象,即该对象是哪个类的实例、如何才能找到类的元数据类型、对象的哈希码、对象的 GC 分代年龄等信息。

完成上面的工作后,从虚拟机角度看,一个新的对象就已经产生了。但从 Java 程序角度看,<init> 方法还没有执行,所以执行 new 指令后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个对象才算完全产生出来。

对象的内存布局

对象在内存中的布局分为 3 个区域:对象头(Header)实例数据(Instance Data) 以及 对齐填充(Padding)

对象头

对象头(Header) 所包含的信息是与对象自身定义的数据无关的额外存储成本,其包括两部分信息:一部分是用于存储对象自身的 运行时数据,另一部分是 类型指针

运行时数据包括哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据长度在 32 位和 64 位的虚拟机中分别表示为 32 bit 和 64 bit,被称为 “Mark Word”Mark Word 被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

类型指针 即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

此外,如果对象是一个数组,则在对象头中还必须要有一块用于记录数组长度的数据,因为虚拟机可以通过普通的 Java 对象的元数据信息确定 Java 对象的大小,而不能通过数组的元数据来确定数组的大小。

实例数据

实例数据 部分是对象真正存储的有效信息,无论是从父类继承下来的,还是子类中自己定义的,都需要记录起来。该部分的存储顺序收到虚拟机分配策略参数和字段在 Java 源码中定义顺序的影响。

默认的分配策略5如下:

1
long/double -> int/float -> short/char -> byte/boolean -> reference

如果设置了 -XX:FieldsAllocationStyle=0(默认是1),那么引用就会放在最前面:

1
reference -> long/double -> int/float -> short/char -> byte/boolean

即:分配策略总是按照宽度由大到小的顺序排列,相同宽度的放在一起。

对齐填充

对齐填充 可有可无,只是起到占位符的作用。对于 HotSpot 来说,其要求对象起始地址必须是 8 的整数倍,即对象的大小是 8 的整数倍。因此,如果对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

对于对象的访问,主要有两种方式:句柄直接指针

句柄

该方式会在 Java 堆中划分出一块内存来作为 句柄池reference 中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息,Java 程序通过访问栈中本地变量表的 reference 数据从而获得对象的句柄地址,然后根据句柄地址去定位或访问对象。如下图所示6

该方式的优点是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时会移动对象)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改,适用于频繁移动对象地址的场景。

直接指针

该方式会将对象类型数据的指针直接存放在 Java 堆对象中,无需存放在句柄池中。栈中的 reference 存储的是对象的地址, Java 程序通过访问栈中的 reference 数据从而获得对象的地址,直接访问到对象或其实例数据,然后再根据对象类型数据的指针访问对象类型数据。如下图所示6

该方式的优点是速度快,因为节省了一次指针定位的时间开销,适用于频繁访问对象的场景。