01-运行时数据区分类
JVM 中定义了多个运行时数据区,根据它们创建、销毁的时机,可分为两类:
- per-VM,JVM启动时创建,退出时销毁。通常由 JVM 中所有的线程共享。
- 堆
- 方法区
- 运行时常量池
- 堆外内存(Java 7之前的永久代,或Java 8之后的元空间、代码缓存)
- per-thread,线程创建时创建,退出时销毁。通常由线程独享。
- pc 寄存器
- 栈 / 本地方法栈
今天我们主要学习下运行时数据区中的堆空间。
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 有什么缺点或者说问题呢?
- 空间存在一定的浪费。预先分配给每个线程,虽然线程暂时用不了那么多,也预先分配给他。
- 假设每个 TLAB 空间有 100KB,现在已占用 80KB,当需要分配一个 30KB 大小的内存时,无法直接在 TLAB 中分配,此时有两种处理方案:
- 直接在内存中分配;
- 废弃当前 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();
创建对象的过程涉及到了堆空间、栈空间、方法区,接下来我们一步步分析。
new Object(),在堆上(具体一点就是 Eden)区分配一块空间存储 Object 对象。这块空间的大小根据方法区中 Object 对应的类定义确定。Object obj,在虚拟机栈的当前栈帧中创建一个局部变量 obj,它的类型是 reference。- 赋值语句,将局部变量与堆中 Object 对象关联起来。至于通过 obj 访问到堆中 Object 对象则有两种方式(称为对象的访问定位):
- 通过句柄的方式,reference 类型的变量指向对象句柄,句柄包含两部分内容,方法区中的类元数据和堆中对象分配的空间。
- 直接指针访问,reference 类型的变量直接指向对象所在的堆空间。
- 以上两种方式各有优劣,前者在堆中对象发生变化时,无序修改 reference;而后者则在访问速度上更胜一筹,毕竟无需两次索引。HotSpot JVM 中就是通过直接指针访问的方式定位对象。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。
历史文章推荐