HotSpot JVM 「04」Runtime Data Areas - Heap

272 阅读7分钟

01-运行时数据区分类

JVM 中定义了多个运行时数据区,根据它们创建、销毁的时机,可分为两类:

  1. per-VM,JVM启动时创建,退出时销毁。通常由 JVM 中所有的线程共享。
    1. 方法区
    2. 运行时常量池
    3. 堆外内存(Java 7之前的永久代,或Java 8之后的元空间、代码缓存)
  2. per-thread,线程创建时创建,退出时销毁。通常由线程独享。
    1. pc 寄存器
    2. 栈 / 本地方法栈

今天我们主要学习下运行时数据区中的堆空间。

02-堆

JVMS 中是这样定义堆空间的:

The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.

简单来说,堆是 JVM 中线程间共享的一块运行时内存空间,用来存储所有的类对象和数组。堆空间的初始大小和上限可通过 JVM 参数-Xms -Xmx调整。堆空间的分配通过程序中的 new 关键字进行;而堆空间的回收则通过 GC(垃圾回收器)进行。

根据分代假说,堆空间被划分为两部分:新生代(young)和老年代(old)。新生代与老年代的比例可以通过 JVM 参数-XX:NewRatio设置。JVM 根据每个区域对象的特点,在不同区域应用不同的 GC 算法,以提高内存回收效率并尽可能地减少对进程的影响(Stop the world)。发生在新生代的 GC 被称为 minor GC,发生在老年代的 GC 被称为 major GC。

弱分代假说,绝大多数对象都是朝生夕灭的,生命很短。 强分代假说,活得越久也就是熬过越多次垃圾收集过程的对象就越难以消亡。

新生代又进一步地被划分为:Eden 区和 Survivor 0/1区,且它们默认的比例为8:1:1。

02.1-对象分配

JVMS 对堆空间的定义就是存放类对象和数组的内存空间。堆上内存分配一般遵循以下原则:

  • 对象空间优先从 Eden 区分配,Eden 区放不下时会出发 minor GC。
  • 如果对象所需内存太大,超过了虚拟机参数-XX:PretenureSizeThreshold设置的大小时,将直接从老年代分配空间。

虽然 JVMS 中是这样要求的,但是虚拟机厂商在实现时会加入一系列的优化措施,以提高内存分配效率、程序执行效率等。例如 HotSpot 虚拟机中的 TLAB(Thread Local Allocate Buffer)是一种提高堆内存分配效率的优化措施。它的基本思想是在 Eden 区为每个线程预留一个分配缓冲区,对于分配操作,TLAB 是由线程独享的,所以多线程间无序同步措施来避免线程安全问题。

如果没 TLAB 会有什么问题?

根据 JVMS,堆空间是所有线程之间共享的,那么当多线程同时从堆空间中分配内存时可能会出现线程安全问题。举例说明,两个线程共同申请到一块地址。为保证对内存分配安全,需要对线程采取同步,可以通过加锁或 CAS。但不论是哪种方式,无疑会降低分配内存分配效率,而为对象分配内存在 Java 程序中又是一个非常普遍地操作。所以,Hotspot 在实现时,为了提高堆内存分配效率加入了 TLAB 优化。Hotspot 在 Eden 区中为每个线程分配一块独立的空间,用作其创建对象时快速分配空间,其大小通过虚拟机参数-XX:TLABSize指定。由于 TLAB 这块空间由某个线程独享(注意:仅在分配内存时独享,引用对象或 GC 时仍在线程间共享),所以分配空间不需要加锁或其他同步措施。这种情况下,JVM 中分配对象内存与C中一样高效。但如果对象过大,超过 TLAB 大小,分配仍然直接在堆上进行。所以,Java 中创建多个小对象比创建一个大对象在内存分配上,效率更高。

TLAB 主要是为了减少线程间同步消耗,提高内存分配效率。假设线程拥有的 TLAB 可以存储 100 个对象,只有在为第 101 个对象分配空间时需要加锁向 eden 区申请更多的 TLAB。如果没有 TLAB,则每个对象分配空间都需要加锁,避免堆空间线程安全问题。

TLAB 有什么缺点或者说问题呢?

  1. 空间存在一定的浪费。预先分配给每个线程,虽然线程暂时用不了那么多,也预先分配给他。
  2. 假设每个 TLAB 空间有 100KB,现在已占用 80KB,当需要分配一个 30KB 大小的内存时,无法直接在 TLAB 中分配,此时有两种处理方案:
    1. 直接在内存中分配;
    2. 废弃当前 TLAB,重新申请 TALB 后再进行内存分配。

采用方案a,则极端情况下,假如 TLAB 仅剩 1KB,那后续分配几乎都要在堆内存中直接分配。

采用方案 b,极端情况下会频繁地申请 TLAB 空间,虽然 TLAB 空间中分配对象无需加锁,但申请 TLAB 空间需要同步控制,频繁申请 TLAB 空间就使得 TLAB 失去了本来的意义。

HotSpot 中引入了一个 refill_waste 值,简单理解为“最大浪费空间“。当请求分配的空间大于 refill_waste 时,在堆内存中分配;若小于refill_waste值,则废弃当前 TLAB,重新创建TLAB进行对象内存分配。

除了 TLAB 技术,虚拟机厂商为了提高程序在虚拟机上的执行效率,往往会对程序做一些优化,这些优化可以发生在编译器,也可以发生在运行期(JIT 编译)。逃逸分析就是一个常用的编译器优化技术。

什么是逃逸分析呢?

逃逸分析主要分析的是对象的动态作用域,当一个对象在某个作用域(例如方法)中被定义后,它可能会被作用域外的变量引用(例如作为返回值被其他变量引用),称为变量逃逸。思考如下代码:

public Object newInstance() {
    Objcet obj = new Object(); // 此处在堆中创建了对象
    return obj;
}
public void outter(){
    Object outter = newInstance(); // 对象逃逸到了其他方法中
}

逃逸分析并不是直接的优化措施,而是为后续的优化(例如栈上分配、标量替换等)提供信息。当编译器经过逃逸分析发现方法中的某个对象没有发生逃逸,即它仅在当前作用域中有效,那么编译器就可以将其内容分配在栈上,使得对象创建更加高效。这样以来,就改变了对象分配在堆上这一原则。这里就隐含着一个常见的面试问题:Java 中所有对象都分配在堆上吗?相信看到这里的读者,对这个问题已经有了答案。

02.2-对象访问

我们考虑下面创建对象的具体过程是什么?

Object obj = new Object();

创建对象的过程涉及到了堆空间、栈空间、方法区,接下来我们一步步分析。

  1. new Object(),在堆上(具体一点就是 Eden)区分配一块空间存储 Object 对象。这块空间的大小根据方法区中 Object 对应的类定义确定。
  2. Object obj,在虚拟机栈的当前栈帧中创建一个局部变量 obj,它的类型是 reference。
  3. 赋值语句,将局部变量与堆中 Object 对象关联起来。至于通过 obj 访问到堆中 Object 对象则有两种方式(称为对象的访问定位):
    1. 通过句柄的方式,reference 类型的变量指向对象句柄,句柄包含两部分内容,方法区中的类元数据和堆中对象分配的空间。
    2. 直接指针访问,reference 类型的变量直接指向对象所在的堆空间。
    3. 以上两种方式各有优劣,前者在堆中对象发生变化时,无序修改 reference;而后者则在访问速度上更胜一筹,毕竟无需两次索引。HotSpot JVM 中就是通过直接指针访问的方式定位对象。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿


历史文章推荐