栈和堆的访问速度以及对象创建
引子: 这个主题的出现是由于在学习编译后期优化(包括 JIT),也就是在看深入理解 Java 虚拟机这本书(第十一章中的”逃逸分析“小节)的过程中了解到被称为标量替换(Scalar Replacement)中所提到的:如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。
但是,实际上 HotPot 虚拟机目前的实现方式导致栈上分配实现起来比较复杂,因此在HotSpot中暂时还没有做这项优化。但是的确是可以优化的,是一个有意义的研究方向。
所以这里来我们进行一定程度的学习。
以下问题以 Java 语言为例。
首先回答是不是的问题:对象并非只能存在于内存中的堆,其可以存于栈上。这是因为栈和堆在内存角度上看,没有任何区别。
1. 访问栈比访问堆速度快吗?
首选看看 R 大是怎么说这个问题的?
原题引用的:
看到很多书上写栈的运行速度快,处于堆和寄存器之间,所以用来运行程序;堆得速度慢,所以用来存放对象。
必须是雾很大啊。通常的环境下,内存管理意义的堆(heap)和栈(stack)的访问速度一样。都是普通内存。
得定义“运行”是指什么方面的动作,是分配?释放?还是访问?
也就是说,实际上,堆和栈既然都是一样的普通内存空间,那么就不会有访问速度上的区别,除非涉及优化。堆和栈的逻辑上区分用于逻辑上划分出两块不同内存空间来存储不同类型的数据,因为对于不同了类型的数据我们将采用不同方式的操作。重点:存储介质上一般没区别,所以访问速度都是相同的,区别在于操作方式不同会有不同的额外开销。
按照这个逻辑,我们重点放在堆和栈上使用访问方式区别所带来的运行效率问题。
首先看看 Java 中堆和栈分别存储什么数据:
简单来说,
- 堆(heap):JVM用来存储对象实例以及数组值的区域,可以认为 Java 中所有通过 new 创建的对象的内存都在此分配;且堆是一个线程共享的区域。
- 栈(stack):栈是线程私有的,每个线程创建的同时都会创建 JVM 栈,JVM 栈中存放的为当前线程中局部基本类型的变量、部分的返回结果以及Stack Frame,非基本类型的对象在 JVM 栈上仅存放一个指向堆上的地址;
所以可以这么认为:栈负责任务的执行、规划,而堆负责对象的创建、回收,而后者的创建工作被前者发起。可以这么认为栈是堆的上层结构,堆对于栈是可见的,反之则不然(栈是直接和 CPU 打交道的内存,堆不和 CPU 打交道,堆和栈打交道)。下面从线程安全以及操作方法角度上进行比较:
- 线程安全角度:为了确保在堆上创建对象对所有线程均可见、同步,所以需要额外的线程安全保障。而栈由于线程私有,局部变量、方法出口的分配都不需要消耗这一份额外的开销;所以,看似来似乎做同样的一件事情,堆需要更多的 overhead,但是如果你说访问速度上有什么区别,实际上还是没区别;
- 操作方法上的角度:堆上还需要额外进行 GC(垃圾回收)的工作。垃圾回收机制是比较复杂的,其复杂性带来很多执行上的开销,而栈则无需承担这个工作,释放数据只需要通过移动栈顶指针,就可以随着栈帧出栈而自动销毁(实际上没有进行数据的释放工作)。但是,这也不会导致访问堆比访问栈慢,因为做多少事和做事的效率对于机器而言是没有关系的(机器不需要休息);
从表象上栈比堆在进行对象的拆分优化后上会快一点。
我们知道多个线程间处于竞争关系,一个单核 CPU 在某个时刻只能执行一个线程的任务,所以一段时间内如果 CPU 在执行一个线程,那么 CPU 在这个时间段和该线程划上等号。该线程的栈内存可以转移到 CPU 的高速寄存器中存储,这样一来“读取栈上”的数据的表象上看的确比读取堆上的数据快。但是需要指出的是,原本 CPU 访问栈内存的操作变成了 CPU 访问自身寄存器的操作,并没有访问原来栈上数据。所以这样说来,严格意义上,访问栈和访问堆的速度永远都是一样的。
2. 目标是 JIT 优化实现栈上分配对象
在 Java 中被修饰为 private 的对外不可见,如果在线程中创建一个 private 修饰的对象,那么理论上我们无需确保线程安全性,这样一来,在堆上创建至少可以减少创建对象时的锁上消耗。但是,这不并够,因为我们还可以进一步优化。对象本质上由域以及方法组成,对象在内存中的表现形式既是域和方法。单单考虑私有对象的域,如果对象拆分,程序执行的时候不创建这个对象,仅仅创建当前线程会用到该对象的若干个域,并且在栈而不是堆上创建。这样一来,因为线程中的域(栈上存储的域)有很大概率被虚拟机分配至物理机器的高速寄存器中,那么对线程中创建的私有对象访问速度将得到很大的提高。
3. 栈的存储
栈用于存储与方法调用直接相关的数据,栈用于实现方法之间的顺序执行以及相互调用关系。
- 相互调用关系:栈是一种 LIFO 后入先出的结构,其完美符合“方法的调用有完美的嵌套关系——调用者的生命期总是长于被调用者的生命期,并且后者在前者的之内”。
- 顺序执行关系:顺序执行关系是不需要栈这种结构,普通的内存即可。一个方法调用完了,其返回值存储于局部变量中。之后关于方法调用所占用的内存从头开始被重写就好可,是不需要后入先出的逻辑关系,但是后入先出是可以被用于顺序调用的。
4. TLAB
TLAB 全称ThreadLocalAllocBuffer
,是线程的一块“私有”内存,如果设置了虚拟机参数 -XX:UseTLAB
,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个 Buffer,如果需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率,当 Buffer 容量不够的时候,再重新从 Eden 区域申请一块继续使用,这个申请动作还是需要原子操作的。
TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,均摊对 GC 堆(eden区)里共享的分配指针做更新而带来的同步开销。
TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的(确保了堆内存空间对于),只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB,而在老 TLAB 里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从 TLAB 分配出来的,而只关心自己是在 eden 里分配的。
相关引用:https://www.jianshu.com/p/cd85098cca39