JAVA对象分配的过程

    技术2025-03-18  25

    一、前言

        学习JAVA的时候,一般会认为new 出来的对象都是分配在堆上的,但通过对JAVA对象分配的过程进行分析,有两种情况会导致JAVA中new出来的对象并不一定分配在堆上,这两种情况分别是 JAVA中的逃逸分析和TLAB(Thread Local Allocation Buffer)

    二、逃逸分析

        在《深入理解JAVA虚拟机》中关于JAVA堆内存有这样一段话:

    但是,随着JIT编译器的发展与逃逸分析计算逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

        在编译期间,JIT会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配的压力   ,其中一项重要的技术叫做逃逸分析。

        逃逸分析是一种可以有效减少JAVA程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

        通过逃逸分析,JAVA Hotspot编译器能够分析出一个新的对象引用的使用范围,从而决定是否要将这个对象分配到堆上。

        逃逸分析的基本行为就是分析对象的动态作用域: 当一个对象在方法中被定义后,它可能被外部方法所引用,比如 作为调用参数传递到其他地方去,称为方法逃逸

    public static StringBuffer createStringBuffer(String s1, String s2) { // sb是方法内部变量 StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb; }

        在上面的代码中是直接将sb进行返回的,这个sb很有可能被其他方法所改变,所以它的作用域就不止在方法内部。这样的情况称其逃逸到了方法外部,甚至还有可能被外部线程所访问,比如 赋值给类变量就可以在其他线程访问这个实例变量,称其为线程逃逸。

        如果想要sb不逃出方法

    public static StringBuffer createStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString(); }

        不直接返回StringBuffer,那么StringBuffer就不会逃逸出方法。

        使用逃逸分析,编译器可以对代码做如下优化:

        1. 同步省略 : 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作,可以不考虑同步。

            锁优化中的锁消除技术,依赖的也是逃逸分析技术

        2. 将堆分配转化为栈分配 : 如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配

        3. 分离对象或标量替换 : 有的对象可能不需要作为一个连续的内存结构存在,也可以被访问到,那么对象的部分(或全部)可以存储在内存,而是存储在CPU寄存器中。

        在HotSpot中,栈上内存分配并没有真正的进行实现,而是通过标量替换来进行实现的。

    什么是标量替换,如何通过标量替换实现栈上分配?

        JIT经过逃逸分析之后,发现某个对象并没有逃逸到方法体之外的话,就可能对其进行优化,而这一优化最大的结果就是可能改变JAVA对象都是堆上分配内存的这一原则。

        标量(Scaler)指无法再分解成更小数据的数据

        JAVA中的原始数据类型就是标量。相对的,那些还可以进行分解的数据叫做聚合量(Aggregate),JAVA中的对象就是聚合量,因为它可以再分解成其他聚合量和标量。

        在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替,这就是标量替换。

    public static void main(String[] args) { alloc() } private static void alloc() { Point point = new Point(1,2); System.out.println("point.x="+point.x + "; point.y=" + point.y); } class Point { private int x; private int y; }

        以上代码中,point对象并没有逃逸出 alloc() 方法,并且Point对象是可以被拆解成标量的。那么,JIT就不会直接创建Point对象,而是直接使用两个标量int x, int y来替代Point对象。

    private static void alloc() { int x = 1; int y = 2; System.out.println("point.x="+point.x + "; point.y=" + point.y); }

     

        可以看到,Point这个聚合量在经过逃逸分析后,并没有逃逸,就被替换成两个聚合量了。

        通过标量替换,原本的一个对象,被替换成了多个成员变量。而原本需要在堆上分配的内存,也就不再需要了,完全可以在本地方法栈中完成对成员变量的内存分配。

    实验证明:

    public class TaoYiFenXi { public static void main(String[] args) { long a1 = System.currentTimeMillis(); for (int i = 0; i < 1000000; i++) { alloc(); } // 查看执行时间 long a2 = System.currentTimeMillis(); System.out.println("cost " + (a2 - a1) + " ms"); // 为了方便查看堆内存中对象的个数,线程sleep try { Thread.sleep(100000); } catch (InterruptedException e) { e.printStackTrace(); } } private static void alloc() { User user = new User(); } static class User { } }

     

     

     

     

        代码中在 alloc() 方法中定义了User对象,但是并没有在方法外部引用,这个对象就不会逃逸到方法外部去。经过JIT的逃逸分析后,就可以对其内存分配进行优化。

    1. 关闭逃逸分析

    -Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

        第三个参数就代表关闭逃逸分析

    使用jmap命令,查看当前堆内存中有多少个User对象:

     

     

     

        共创建了100万个 User 实例

    2. 开启逃逸分析

    -Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

    使用jmap命令,查看当前堆内存中有多少个User对象:

        开启逃逸分析后,堆内存中只有10万多个 User 对象,这就是JIT优化的结果。

     

        除了通过jmap来验证对象个数的方法以外,还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析 : 

        开启逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少。

     

        在两次的运行结果中可以看到,开启逃逸分析之后,对象数目并没有变为0,说明JIT优化并不会对所有情况都进行优化。

     

        逃逸分析在JDK1.6才有实现,而且这项技术到现在也并不是十分成熟。

     

        根本原因: 无法保证逃逸分析的性能消耗一定高于内存的消耗。虽然经过逃逸分析可以做标量替换、栈上分配和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,是一个相对耗时的过程。

        虽然这项技术并不成熟,但是它也是即时编译器优化技术中一个十分重要的手段。

     

    三、TLAB

        JVM在Eden Space中开辟了一小块线程私有的区域,称为TLAB(Thread-local-allocation buffer),默认设定为占用Eden Space的1%

        在JAVA程序中很多对象都是小对象且生命周期短,所以他们不存在线程共享也适合被快速GC,所以对于这种小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有的,所以没有锁开销。

        也就是说,JAVA中每一个线程都会有自己的缓冲区称为TLAB(Thread-local allocation buffer),每一个TLAB都只有一个线程可以操作,TLAB结合bump-the-pointer技术可以实现快速的对象分配,而不需要任何的锁进行同步。

    什么是bump-the-pointer?

        HotSpot虚拟机使用了两种技术来加快内存分配,分别是 "bump-the-pointer"和TLABs。

        由于Eden Space是连续的,因此bump-the-pointer技术主要是跟踪Eden Space创建的最后一个对象。在对象创建的时候,只需要检查最后一个对象后面是否有足够的剩余空间。如果有,就会被创建在Eden Space,并且放置在顶部。这样的技术将极大的加快内存分配速度。

        但是,如果是在多线程的情况,事情就会截然不同。如果想要以线程安全的方式以多线程在Eden Space存储对象,不可避免的需要加锁,而这将极大的影响性能。

        TLABs 是 HotSpot虚拟机针对这一问题(多线程)的解决方案。该方案将Eden区分成若干段,每个线程使用独立的一段,避免互相影响。TLAB结合bump-the-pointer技术,将保证每个线程都是用Eden区的一段,并快速的为对象分配内存,不需要任何的锁进行同步,也就是说,在对象分配的时候不用锁住整个堆,只需要在自己的缓冲区分配即可。

     

    JAVA对象分配的过程

    1. JIT编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2

    2. 如果tlab_top + size <= tlab_end 则在TLAB上直接分配对象并增加tlab_top的值,如果现有的TLAB不足与存放当前对象则进入3

    3. 重新申请一个TLAB,并在此尝试存放当前对象。如果放不下,则4

    4. 在Eden区加锁(这个区市多线程共享的),如果eden_top + size <= eden_end则将对象放在Eden区,增加eden_top的值,如果Eden区不足以存放,则5

    5. 执行一次minor GC

    6. 经过minor GC后,如果Eden区仍然不足以存放当前对象,则直接分配到老年代

        对象不在堆上分配的主要原因是因为堆是共享的,在堆上分配有锁的开销。无论是TLAB还是栈都是线程私有的,私有即避免了竞争(但是也可能会产生可见性问题等额外的问题),这是典型的用空间换效率的做法。

     

     

    参考链接:

    https://blog.csdn.net/yangzl2008/article/details/43202969

    https://www.cnblogs.com/hollischuang/p/12501950.html

    https://blog.csdn.net/zhou2s_101216/article/details/79221310

    https://blog.csdn.net/w372426096/article/details/80333657

     

     

     

     

     

     

     

     

     

     

     

    Processed: 0.010, SQL: 12