JVM--08--堆2---TLAB逃逸分析

Posted 高高for 循环

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM--08--堆2---TLAB逃逸分析相关的知识,希望对你有一定的参考价值。

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


堆空间分代思想

问题1:为什么要把 Java 堆分代?不分代就不能正常工作了吗?

  • 经研究,不同对象的生命周期不同。70%-99%的对象是临时对象
  • 其实不分代完全可以,分代的唯一理由就是优化 GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。 GC 的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。


  • 新生代:有 Eden 、两块大小相同的 Survivor(又称为 From/To,S0/S1)构成,To 总为空。
  • 老年代:存放新生代中经历多次 GC 仍然存活的对象。

内存分配策略

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 区中每熬过一次 Minor GC ,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个 JVM 、每个 GC 都有所不同)时,就会被晋升到老年代

对象晋升老年代的年龄阀值,可以通过选项 -XX:MaxTenuringThreshold 来设置

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到 Eden
  • 开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发
    Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢
  • 大对象直接分配到老年代尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
  • 空间分配担保: -XX:HandlePromotionFailure
    也就是经过 Minor GC 后,所有的对象都存活,因为 Survivor 比较小,所以就需要将 Survivor 无法容纳的对象,存放到老年代中。

为对象分配内存:TLAB

问题:堆空间都是共享的么?

  • 不一定,因为还有 TLAB 这个概念,在堆中划分出一块区域,为每个线程所独占

为什么有 TLAB?

TLAB:Thread Local Allocation Buffer,也就是为每个线程单独分配了一个缓冲区

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

什么是 TLAB

  • 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。

说明:

  • 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选
  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否=启 TLAB 空间。
  • 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置 TLAB 空间所占用 Eden空间的百分比大小。
  • 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

TLAB分配过程

对象首先是通过 TLAB 开辟空间,如果不能放入,那么需要通过 Eden 来进行分配

堆空间的参数设置

  • -Xms:初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的GC处理日志
  • 打印gc简要信息:①-Xx:+PrintGC ② - verbose:gc
  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
  • -XX:HandlePromotionFalilure:是否设置空间分配担保

空间分配担保

在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次 Minor GC 是安全的

  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允担保失败。

  • 如果 HandlePromotionFailure=true ,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。

    • 如果大于,则尝试进行一次 Minor GC ,但这次 Minor GC 依然是有风险的;
    • 如果小于,则改为进行一次 Full GC 。
  • 如果 HandlePromotionFailure=false,则改为进行一次 Full GC 。

在 JDK 6 Update24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。 JDK6 Update24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC ,否则将进行 Full GC 。

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC ,否则将进行 Full GC 。

逃逸分析

堆是分配对象的唯一选择么?

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

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

1. 逃逸分析

  • 在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

2. 定制的 TaoBao VM

  • 此外,前面提到的基于 OpenJDK 深度定制的 TaoBao VM ,其中创新的 GCIH(GC Invisible Heap)技术实现Off-Heap将生命周期较长的 Java 对象从 Heap 中移至 Heap 外,并且 GC 不能管理 GCIH内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的。

GCIH

将生命周期较长的 Java 对象,放到本地内存当中,gc将不会考虑这块内存区域,已此提高性能

逃逸分析原理:

如何将堆上的对象分配到栈,需要使用逃逸分析手段。

  • 这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
  • 通过逃逸分析, Java HotSpot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
  • 逃逸分析的基本行为就是分析对象动态作用域:
  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

如何快速的判断是否发生了逃逸分析,就看 new 的对象是否在方法外被调用。

逃逸分析举例

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除,每个栈里面包含了很多栈帧,也就是发生逃逸分析

案例1 :

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

如果想要 StringBuffer sb 不发生逃逸,可以这样写

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

案例2 :

public class EscapeAnalysis {

    public EscapeAnalysis obj;

    /**
     * 方法返回EscapeAnalysis对象,发生逃逸
     * @return
     */
    public EscapeAnalysis getInstance() {
        return obj == null ? new EscapeAnalysis():obj;
    }

    /**
     * 为成员属性赋值,发生逃逸
     */
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }

    /**
     * 对象的作用于仅在当前方法中有效,没有发生逃逸
     */
    public void useEscapeAnalysis() {
        EscapeAnalysis e = new EscapeAnalysis();
    }

    /**
     * 引用成员变量的值,发生逃逸
     */
    public void useEscapeAnalysis2() {
        EscapeAnalysis e = getInstance();
        // getInstance().XXX  发生逃逸
    }
}

参数设置

在 JDK 6u23 版本之后, HotSpot 中默认就已经开启了逃逸分析

如果使用的是较早的版本,开发人员则可以通过:

  • 选项 “-XX:+DoEscapeAnalysis” 显式开启逃逸分析
  • 通过选项 “-XX:+PrintEscapeAnalysis” 查看逃逸分析的筛选结果

结论:

开发中能使用局部变量的,就不要使用在方法外定义。

  • 局部变量存储在栈帧的局部变量表中,出栈就释放内存了.
  • 成员变量存储在堆中,gc才会释放内存,而gc会STW.暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行.影响程序运行效率.

逃逸分析–代码优化

1.栈上分配

  • JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

举例分析:

public class StackAllocation {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为:" + (end - start) + " ms");

        // 为了方便查看堆内存中对象个数,线程sleep
        Thread.sleep(10000000);
    }

    private static void alloc() {
        User user = new User();
    }
}

class User {
    private String name;
    private String age;
    private String gender;
    private String phone;
}

1.1 设置 JVM 参数,表示未开启逃逸分析

-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails


  • 运行结果,同时还触发了 GC 操作
  • 花费的时间为:456 ms

1.2 设置 JVM 参数,开启逃逸分析

-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

  • 不会发生 GC 操作
  • 花费的时间为:4 ms

然后再看内存情况,我们发现只有很少的 User 对象,说明 User 发生了逃逸,因为他们存储在栈中,随着栈的销毁而消失

2.同步省略


这个取消同步的过程就叫同步省略,也叫锁消除。

我们将其转换成字节码

3.分离对象和标量替换

标量(Scalar):

  • 是指一个无法再分解成更小的数据的数据。 Java 中的原始数据类型就是标

聚合量(Aggregate)

  • 相对的,那些还可以分解的数据叫做聚合量(Aggregate), Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

标量替换定义:

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


以上代码,经过标量替换后,就会变成

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

标量替换—好处

可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。

逃逸分析的不足

逃逸分析并不成熟

  • 目前很多书籍还是基于 JDK 7 以前的版本, JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上

目前Hotspot----JDK1.8,

1. 暂时只开启了,标量替换和同步省略

2. 并没有施行栈上分配,所以对象实例都是分配在堆上

堆 小结:

  • 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  • 老年代放置长生命周期的对象,通常都是从 Survivor 区域筛选拷贝过来的 Java 对象。当然,也有特殊情况,我们知道普通的对象会被分配在 TLAB 上;如果对象较大,JVM 会试图直接分配在 Eden 其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。
  • 当 GC 只发生在年轻代中,回收年轻代对象的行为被称为 Minor GC 。当 GC 发生在老年代时则被称为 Major GC 或者Full GC 。一般的,Minor GC 的发生频率要比 Major GC 高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。

以上是关于JVM--08--堆2---TLAB逃逸分析的主要内容,如果未能解决你的问题,请参考以下文章

什么是逃逸分析?

深入理解java虚拟机(十四)JVM逃逸分析

深入理解java虚拟机(十四)JVM逃逸分析

深入理解java虚拟机(十四)JVM逃逸分析

为何要做逃逸分析

go语言学习笔记 — 基础 — 基本语法 — 常量与变量 — 变量的生命周期:变量逃逸分析 —— go编译器自动决定变量的内存分配方式(堆还是栈),提高程序运行效率