JAVA对象分配的过程

一、前言

    学习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

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值