Java对象栈上分配

时间:2023-03-08 17:41:20

转自 https://blog.****.net/o9109003234/article/details/101365108

在学习Java的过程中,很多喜欢说new出来的对象分配一定在对上;

其实不能这么说,只能说大部分对象分配是在对上。通过对象的分配过

程分析,除了堆以外,还有两个地方可以存放对象:

栈和TLAB(Thread Local Allocation Buffer)。

Java对象分配流程图:

Java对象栈上分配

如果开启栈上分配,JVM会先进行栈上分配,如果没有开启栈上分配或则不符合条件的则会进行TLAB分配,如果TLAB分配不成功,再尝试在eden区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。

栈上分配

在JVM中,堆是线程共享的,因此堆上的对象对于各个线程都是共享和可见的,只要持有对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但对于垃圾收集器来说,无论筛选可回收对象,还是回收和整理内存都需要耗费时间。

如果确定一个对象的作用域不会逃逸出方法之外,那可以将这个对象分配在栈上,这样,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,无须通过垃圾收集器回收,可以减小垃圾收集器的负载。

JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。

栈上分配的技术基础:

一是逃逸分析:逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。关于逃逸分析的问题可以看我另一篇文章:

二是标量替换:允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。

只能在server模式下才能启用逃逸分析,参数-XX:DoEscapeAnalysis启用逃逸分析,参数-XX:+EliminateAllocations开启标量替换(默认打开)。Java SE 6u23版本之后,HotSpot中默认就开启了逃逸分析,可以通过选项-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果。

TLAB(Thread Local Allocation Buffer)

TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。

由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步(虚拟机采用CAS配上失败重试的方式保证更新操作的原子性),而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程同步,提高了对象分配的效率。

TLAB本身占用eEden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB空间。参数-XX:+UseTLAB开启TLAB,默认是开启的。TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行分配,总是会直接分配在堆上。TLAB空间由于比较小,因此很容易装满。比如,一个100K的空间,已经使用了80KB,当需要再分配一个30KB的对象时,肯定就无能为力了。这时虚拟机会有两种选择,第一,废弃当前TLAB,这样就会浪费20KB空间;第二,将这30KB的对象直接分配在堆上,保留当前的TLAB,这样可以希望将来有小于20KB的对象分配请求可以直接使用这块空间。实际上虚拟机内部会维护一个叫作refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。这个阈值可以使用TLABRefillWasteFraction来调整,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste。默认情况下,TLAB和refill_waste都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,并使用-XX:TLABSize手工指定一个TLAB的大小,-XX:+PrintTLAB可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。

所谓TLAB其实就是这样的一个东西:(简化伪代码)

struct ThreadLocalAllocBuffer {

HeapWord* _start;

HeapWord* _top;

HeapWord* _end;

};

每个线程会从Eden分配一大块空间,例如说100KB,作为自己的TLAB。这个start是TLAB的起始地址,end是TLAB的末尾,然后top是当前的分配指针。显然start <= top < end。

在Eden分配空间时,用的是bump-the-pointer方式来分配,但由于Eden是所有Java线程所共享的,在bump pointer的时候必须加锁(或者CAS)才可以保证安全;而当每个线程从Eden分配到一块空间当作TLAB来用之后,在TLAB里分配小块空间同样是bump-the-pointer(上面示意的top指针)则不需要加锁。
当一个Java线程在自己的TLAB中分配到尽头之后,再要分配就会出发一次“TLAB refill”,也就是说之前自己的TLAB就“不管了”(所有权交回给共享的Eden),然后重新从Eden里分配一块空间作为新的TLAB。所谓“不管了”并不是说就让旧TLAB里的对象直接死掉,而是把那块空间的控制权归还给普通的Eden,里面的对象该怎样还是怎样。
通常情况下,在TLAB中分配多次才会填满TLAB、触发TLAB refill,这样使用TLAB分配就比直接从共享部分的Eden分配要均摊(amortized)了同步开销,于是提高了性能。其实很多关注多线程性能的malloc库实现也会使用类似的做法,例如TCMalloc。

到触发GC的时候,无论是minor GC还是full GC,要收集Eden的时候里面的空间无论是属于某个线程的TLAB还是不属于任何TLAB都一视同仁,把Eden当作一个整体来收集里面的对象——把活的对象拷贝到survivor space(或者直接晋升到Old Gen)。在GC结束之后,每个Java线程又会重新从Eden分配自己的TLAB。周而复始。

想像这样的代码:

public class Test {
public static Test sharedStatic;
   public Test sharedInstanceField;
   public static void foo() {
Test localVar = new Test();   // 1
       if (sharedStatic == null) {
sharedStatic = localVar;    // 2
       } else {
sharedStatic.sharedInstanceField = localVar; // 3
       }
}
}

(这个例子纯粹为了示意“独占”与“共享”的概念,请不要吐槽线程安全问题 ),我们在 (1) 创建了一个新的Test实例。如题主所说,在启动UseTLAB(默认开启)的时候,这个Test实例会被分配在当前执行Test.foo()的线程的TLAB里。TLAB在执行分配动作的时候要更新top指针,而更新这个指针不需要加任何锁。

对象内存分配的两种方法

为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

指针碰撞(Serial、ParNew等带Compact过程的收集器)

假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。

空闲列表(CMS这种基于Mark-Sweep算法的收集器)

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

参考:

https://www.zhihu.com/question/56538259

https://blog.****.net/yangsnow_rain_wind/article/details/80434323

https://blog.****.net/xiaomingdetianxia/article/details/77688945

相关文章