深入理解 JVM(1)——Java 内存区域与 Java 对象
in Java 进击 with 0 comment

深入理解 JVM(1)——Java 内存区域与 Java 对象

in Java 进击 with 0 comment

深入理解 JVM(1)——Java 内存区域与 Java 对象

JVM 载执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。具体如下图所示:

Demo 图:
JVM

程序计数器(Program Counter Register)

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

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

如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;若执行的是Native 方法,则计数器为空(Undefined)(因为对于 Native 方法而言,它的方法体并不是由 Java 字节码构成的,自然无法应用上述的 “字节码指令的地址” 的概念)。程序计数器也是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的内存区域。

Java 虚拟机栈(Java Virtual Machine Stacks)

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

函数的调用有完美的嵌套关系——调用者的生命期总是长于被调用者的生命期,并且后者在前者的之内。这样,被调用者的局部信息所占空间的分配总是后于调用者的(后入),而其释放则总是先于调用者的(先出),所以正好可以满足栈的 LIFO 顺序,选用栈这种数据结构来实现调用栈是一种很自然的选择。

局部变量表中存放了编译期可知的各种:

基本数据类型(boolen、byte、char、short、int、 float、 long、double)
对象引用(reference 类型,它不等于对象本身,可能是一个指向对象起始地址的指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)
returnAddress 类型(指向了一条字节码指令的地址)

其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变量空间(Slot),其余数据类型只占用 1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

Java 虚拟机规范中对这个区域规定了两种异常状况:

StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度,将会抛出此异常。
OutOfMemoryError:当可动态扩展的虚拟机栈在扩展时无法申请到足够的内存,就会抛出该异常。

本地方法栈(Native Method Stack)

本地方法栈(Native Method Stack) 与 Java 虚拟机栈作用很相似,它们的区别在于虚拟机栈为虚拟机执行 Java 方法(即字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

在虚拟机规范中对本地方法栈中使用的语言、方式和数据结构并无强制规定,因此具体的虚拟机可实现它。甚至有的虚拟机(Sun HotSpot 虚拟机)直接把本地方法栈和虚拟机栈合二为一。与虚拟机一样,本地方法栈会抛出 StackOverflowErrorOutOfMemoryError 异常。

Java 堆(Heap)

对于大多数应用而言,Java 堆(Heap)是 Java 虚拟机所管理的内存中最大的一块,它被所有线程共享的,在虚拟机启动时创建。此内存区域唯一的目的存放对象实例,几乎所有的对象实例都在这里分配内存,且每次分配的空间是不定长的。在 Heap 中分配一定的内存来保存对象实例,实际上只是保存对象实例的属性值属性的类型对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在 Stack 中), 在 Heap 中分配一定的内存保存对象实例和对象的序列化比较类似。对象实例在 Heap 中分配好以后,需要在 Stack 中保存一个 4 字节的 Heap 内存地址,用来定位该对象实例在 Heap 中的位置,便于找到该对象实例。

Java 虚拟机规范中描述道:所有的对象实例以及数组都要在堆上分配,但是随着 JIT 编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都在堆上分配的定论也并不 “绝对” 了。

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

新生代(Young): 新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低。在新生代中,常规应用进行一次垃圾收集一般可以回收 70% ~ 95% 的空间,回收效率很高。新生代又可细分为 Eden 空间From Survivor 空间To Survivor 空间,默认比例为 8:1:1。它们的具体作用将在下一篇文章讲解 GC 时介绍。
老年代(Tenured/Old):在新生代中经历了多次(具体看虚拟机配置的阀值)GC 后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行 GC 的频率相对而言较低,而且回收的速度也比较慢。
永久代(Perm):永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java 虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

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

方法区(Method Area)

方法区(Method Area) 与 Java 堆一样,是各个线程共享的内存区域。 Object Class Data(类定义数据) 是存储在方法区的,此外,常量静态变量JIT 编译后的代码也存储在方法区。正因为方法区所存储的数据与堆有一种类比关系,所以它还被称为 Non-Heap

JDK 1.8 以前的永久代(PermGen)

Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集,也就是说,Java 虚拟机规范只是规定了方法区的概念和它的作用,并没有规定如何去实现它。对于 JDK 1.8 之前的版本,HotSpot 虚拟机设计团队选择把 GC 分代收集扩展至方法区,即用永久代来实现方法区,这样 HotSpot 的垃圾收集器可以像管理 Java 堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他的虚拟机(如Oracle JRockitIBM J9等)来说是不存在永久代的概念的。

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

这些都会导致方法区溢出,报出 java.lang.OutOfMemoryError: PermGen space

JDK 1.8 的元空间(Metaspace)

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

做了这个改动以后,java.lang.OutOfMemoryError: PermGen 的空间问题将不复存在,并且不再需要调整和监控这个内存空间。且虚拟机需要为方法区设计额外的 GC 策略:如果类元数据的空间占用达到参数 “MaxMetaspaceSize”设置的值,将会触发对死亡对象和类加载器的垃圾回收。 为了限制垃圾回收的频率和延迟,适当的监控和调优元空间是非常有必要的。元空间过多的垃圾收集可能表示类、类加载器内存泄漏或对你的应用程序来说空间太小了。

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

我们从行文到现在提到的元空间稍微有点不严谨。准确的来说,每一个 类加载器的存储区域 都称作一个元空间,所有的元空间合在一起就是我们一直说的元空间。 当一个类加载器被垃圾回收器标记为不再存活,其对应的元空间会被回收。在元空间的回收过程中没有重定位和压缩等操作。但是元空间内的元数据会进行扫描来确定 Java 引用。

元空间虚拟机负责元空间的分配,其采用的形式为组块分配。组块的大小因类加载器的类型而异。在元空间虚拟机中存在一个全局的空闲组块列表。当一个类加载器需要组块时,它就会从这个全局的组块列表中获取并维持一个自己的组块列表。当一个类加载器不再存活,那么其持有的组块将会被释放,并返回给全局组块列表。类加载器持有的组块又会被分成多个块,每一个块存储一个单元的元信息。组块中的块是线性分配(指针碰撞分配形式)。组块分配自内存映射区域。这些全局的虚拟内存映射区域以链表形式连接,一旦某个虚拟内存映射区域清空,这部分内存就会返回给操作系统。

上图展示的是虚拟内存映射区域如何进行元组块的分配。类加载器 1 和 3 表明使用了反射或者为匿名类加载器,他们使用了特定大小组块。 而类加载器 2 和 4 根据其内部条目的数量使用小型或者中型的组块。

运行时常量池(Runtime Constant Pool)

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

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

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

直接内存

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

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

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

对象的创建

Java 的对象创建大致有如下四种方式:

  1. new 关键字这应该是我们最常见和最常用最简单的创建对象的方式。
  2. 使用 newInstance() 方法这里包括 Class 类的 newInstance() 方法和Constructor类的 newInstance() 方法(前者其实也是调用的后者)。
  3. 使用 clone() 方法要使用 clone() 方法我们必须实现实现 Cloneable 接口,用 clone() 方法创建对象并不会调用任何构造函数。即我们所说的 浅拷贝
  4. 反序列化要实现反序列化我们需要让我们的类实现 Serializable 接口。当我们序列化和反序列化一个对象,JVM 会给我们创建一个单独的对象,在反序列化时,JVM 创建对象并不会调用任何构造函数。即我们所说的深拷贝

上面的四种创建对象的方法除了第一种使用 new 指令之外,其他三种都是使用 invokespecial(构造函数的直接调用)。这里我们只说 new 创建对象的方式,关于 invokespecial 的内容将在后续文章中介绍。下面我们来看看当虚拟机遇到 new 指令的时候对象是如何创建的。

1. 类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过的,如果没有,则必须先执行相应的类加载过程,关于类加载机制和类加载器的详细内容将在后续文章中介绍。

2. 分配内存

在类加载检查通过后,虚拟机就将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定(如何确定在下一节对象内存布局时再详细讲解),为对象分配空间的任务具体便等同于从 Java 堆中划出一块大小确定的内存空间,可以分如下两种情况讨论:

Java 堆中内存绝对规整所有用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为 “指针碰撞”(Bump The Pointer)
Java 堆中的内存不规整已被使用的内存和空闲的内存相互交错,那就没有办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为 “空闲列表”(Free List)

选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此在使用 Serial、ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时(说明一下,CMS 收集器可以通过 UseCMSCompactAtFullCollection 或 CMSFullGCsBeforeCompaction 来整理内存),就通常采用空闲列表。关于垃圾收集器的具体内容将在下一篇文章中介绍。

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

对分配内存空间的动作进行同步实际上虚拟机是采用 CAS 配上失败重试的方式保证更新操作的原子性。
把内存分配的动作按照线程划分在不同的空间之中进行即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB ,Thread Local Allocation Buffer),哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完,分配新的 TLAB 时才需要同步锁定。虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB 参数来设定。

3. 初始化

内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用 TLAB 的话,这一个工作也可以提前至 TLAB 分配时进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用。

4. 设置对象头

接下来,虚拟机要设置对象的信息(如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息)并存放在对象的对象头(Object Header) 中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。关于对象头的具体内容,在下一节再详细介绍。

5. 执行 <init> 方法

在上面工作都完成之后,在虚拟机的视角来看,一个新的对象已经产生了。但是在 Java 程序的视角看来,对象创建才刚刚开始——<init> 方法还没有执行,所有的字段都还为零值。所以一般来说(由字节码中是否跟随有 invokespecial 指令所决定),new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

1. 对象头

HotSpot 虚拟机的对象头包括两部分信息:

对象自身的运行时数据 “Mark Word” 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,这部分数据的长度在 32 位和 64 位的虚拟机(暂不考虑开启压缩指针的场景)中分别为 32 个和 64 个 Bits,官方称它为 “Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机中对象未被锁定的状态下,Mark Word 的 32 个 Bits 空间中的 25Bits 用于存储对象哈希码(HashCode),4Bits 用于存储对象分代年龄,2Bits 用于存储锁标志位,1Bit 固定为 0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下图所示:

类型指针类型指针即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身,这点我们在下一节讨论。另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小。

2. 实例数据

实例数据是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响。HotSpot 虚拟机默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields 参数值为 true(默认为 true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。

3. 对齐填充

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。对象头部分正好似 8 字节的倍数(1 倍或者 2 倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问定位

我们的 Java 程序需要通过栈上的对象引用(reference)数据(存储在栈上的局部变量表中)来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范里面也只规定了是一个指向对象的引用,并没有定义这个引用的具体实现,对象访问方式也是取决于虚拟机实现而定的。主流的访问方式有使用句柄直接指针两种。

1. 使用句柄访问

如果使用句柄访问的话,Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据类型数据的各自的具体地址信息。如下图所示:

2. 使用直接指针访问

如果使用直接指针访问的话,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如下图所示:


这两种对象访问方式各有优势,下面分别来谈一谈:

句柄使用句柄访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改
直接指针使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问的在 Java 中非常频繁,因此这类开销积小成多也是一项 非常可观的执行成本。从上一部分讲解的对象内存布局可以看出,HotSpot 是使用直接指针进行对象访问的,不过在整个软件开发的范围来 看,各种语言、框架中使用句柄来访问的情况也十分常见。

参考:

From: https://crowhawk.github.io/2017/08/09/jvm_1/