堆空间-逃逸分析

逃逸分析

:::warning

前提:逃逸分析目前是 Java 虚拟机中比较前沿的技术,并且还很不成熟,需要时间优化

参考博客:JVM对象逃逸

:::

什么是逃逸分析?

  • 定义:编译器根据字节码信息分析对象是否发生逃逸的 [分析技术]{.red}

    :::info

    ① 逃逸分析只是 [代码分析技术]{.red} 而不是 [代码优化技术]{.red}

    ② 逃逸分析是后续代码优化技术的前提,栈上分配、标量替换、同步消除都依赖于逃逸分析

    :::

为什么要使用逃逸分析?

  • 核心:[有效减少堆空间的内存分配压力,避免频繁的垃圾回收对用户线程造成的影响]{.red}

  • 解释:

    • 定义中提到逃逸分析只是分析技术,所以严谨来说逃逸分析是没有什么好处的,有好处的只是优化技术
    • 但是因为逃逸分析是优化技术的前提,所以这里用逃逸分析指代优化技术

如何判断对象是否发生逃逸?

  • 定义:

    • [本方法创建的对象在外部被使用 或者 本方法使用外部方法传递的对象 都被视为对象发生逃逸]{.red}
      • 本方法创建的对象在外部被使用:[内部对象逃逸出本方法]{.blue}
      • 本方法使用外部方法传递的对象:[外部对象逃逸进本方法]{.blue}
    • 只有本方法创建的对象不会被外部使用才认为没有发生逃逸
    // 逃逸分析举例
    public StringBuilder returnStringBuilder(){
    // 其他方法可以引用本方法创建的对象了
    return new StringBuilder("对象逃逸了!");
    }

    // 防止对象逃逸
    public String returnStringBuilder()
    {
    // 思考:为什么这个就不算做逃逸了呢?明明返回了对象
    // 回答1:创建的 StringBuilder 对象无法被其他方法引用了,所以这个对象是没有逃逸的
    // 回答2:但是 toString 方法又会创建 String 对象,String 对象是可以被其他方法引用的,所以还是逃逸了
    // 回答3:真正防止逃逸的方法只有不提供外界可引用的对象
    return new StringBuilder("对象逃逸了!").toString();
    }
  • 三种逃逸状态

    • 线程级逃逸(全局逃逸):类的 [成员变量]{.red} 引用方法的返回的对象

      • [成员变量意味着无论变量是静态的还是非静态的,都认为是逃逸对象]{.blue}
      • [成员变量可以被多个线程访问,所以称为线程级逃逸]{.blue}
      public class EscapeAnalysis{
      // 实例变量
      private StringBuilder sb;
      // 线程逃逸
      public void threadEscape(){
      // 多个线程都可以在这个方法中引用到这个对象::对象逃逸出线程
      // 问题:如果给对象加锁,那么还算做逃逸吗?
      // 个人观点:应该还是算的, 即使上锁也依然可以访问
      this.sb = new StringBuilder("对象逃出线程了!");
      }
      }
    • 方法级逃逸(参数逃逸):

      • [本方法将对象作为返回值返回,其余方法可以直接调用]{.blue}
      • [本方法将对象作为参数传递到其他方法中,供其他方法使用]{.blue}
      • 细节:每个线程引用的对象都是不可能一样的,因为每次都是新建对象,所以对于线程是不可见的,没有逃出线程
      // 方法逃逸
      // 情况1
      public StringBuilder createStringBuilder()
      {
      return new StringBuilder("对象逃出方法了!")
      }
      // 情况2
      // 这个对象显然并不属于该方法,而是属于其他方法的
      // 对象逃逸进这个方法了
      public void useStringBuilder(StringBuilder sb)
      {
      System.out.println(sb.toString());
      }
      // 对象逃逸出本方法
      public void createStringBuilder()
      {
      // 传递参数
      useStringBuilder(new StringBuilder("对象逃逸出方法了!"));
      }
    • 不逃逸:[只要确保对象不会被其他方法或者线程引用,那么就是没有逃逸的]{.red}

      public void noEscapeAnalysis()
      {
      StringBuilder sb = new StringBuilder();
      sb.append("我是对象!");
      sb.append("我没有逃逸!");
      System.out.println(sb.toString());
      }
  • 结论:[尽可能多使用局部变量而不是成员变量,减少堆分配的压力]{.green}

没有逃逸的对象应该怎么处理?

  • 核心:

    • [没有发生逃逸 的对象就 可能 会被分配在栈空间上]{.red}
    • [存放在栈帧中的对象随着栈帧的结束而被销毁]{.red}
  • 方式:

    • 栈上分配(Stack Allocation)

      • 定义:[方法逃逸和没有逃逸的对象]{.red} 将会被分配在栈上而不是堆中
      • 优点:[避免堆空间垃圾回收造成的性能消耗(诸如筛选回收对象、整理内存等),对象会随着栈帧的出栈而被销毁]{.red}
      • 细节:[栈上分配并没有明确指明对象在栈中如何存储]{.blue}
    • 标量替换(Scalar Replacement)

      • 概念:

        • 标量:无法进一步分解的数据类型都称为标量

          int、double、float、引用类型等等都是无法继续分解的标量

        • 聚合量:可以继续分解的数据类型称为聚合量

          对象就是非常典型的聚合量

      • 定义:[没有逃逸的对象 会被直接拆解为若干的成员变量存储在栈帧的局部变量表中]{.red}

        • [采用标量替换后就不会创建对象了,而是改为创建各个成员变量]{.blue}
        • 如果对象的拥有聚合量的成员变量,那么继续拆解就行
      • 细节:[标量替换可以看做是栈上分配的一种具体实现]{.red}

      • 实例:

        public static class User
        {
        private int id;
        private String name;
        }

        public static void main(String[] args)
        {
        // 创建对象
        User user = new User();
        // 标量替换:不会直接创建对象了,而是创建局部变量
        user.id;
        user.name;
        }
      • 测试代码

        // 优化方案: 标量替换
        public static class User
        {
        private int id;
        private String name;
        }

        // 由于对象没有逃逸,所以会采用标量替换的方式创建对象,所以创建消耗的时间也会较少
        public static void createUser()
        {
        User user = new User();
        user.id = 1;
        user.name = "Fuyusakaiori";
        }

        // 虚拟机参数设置
        // 第一次测试:关闭标量替换
        // 第一次参数设置:-Xms100m -Xmx100m -XX:+DoEscapeAnalysis -XX:-EliminateAllocations -XX:+PrintGC
        // 第二次测试:开启标量替换
        // 第二次参数设置:-Xms100m -Xmx100m -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+PrintGC
        public static void main(String[] args)
        {
        // 开始创建对象的起始时间
        long start = System.currentTimeMillis();
        for (int i = 0; i < 1000000000; i++)
        {
        createUser();
        }
        long end = System.currentTimeMillis();
        System.out.println("耗费时间: " + (end - start) + "ms");
        }
      • 测试结果

        • 第一次测试:[没有开启标量替换的情况下创建对象消耗的时间 4228ms,并且会不断进行垃圾回收]{.green}
        • 第二次测试:[开启标量替换的情况下创建对象仅消耗 8ms,并且不会进行垃圾回收]{.red}
        • 测试结论:标量替换没有真正创建对象,因为根本就没有打印垃圾回收的信息
    • 同步消除(Synchronization Elimination)

      • 定义:[方法逃逸和没有逃逸的对象 采用的同步措施会被 即时编译器 直接消除掉]{.red}

      • 实例:

        // 优化方案: 同步消除
        // 原始代码
        public void SynchronizationElimination()
        {
        // 每个线程进入该方法都会创建一个新的对象
        // 所有线程根本不会访问到同一个对象,那本身就没有上锁的意义啊
        User user = new User();
        synchronized (user)
        {
        user.id = 1;
        user.name = "Fuyusakaiori";
        }
        }
        // 优化方案:
        public void SynchronizationElimination()
        {
        // 消除同步机制
        User user = new User();
        user.id = 1;
        user.name = "Fuyusakaiori";
        }

      :::warning

      ① 同步消除策略非常不好测试,所以不在此进行测试

      ② 而且感觉同步消除存在一定的问题,既然其余线程都无法访问这个对象,那为什么要上锁呢?根本就不会这么写,那优化什么

      :::

逃逸分析是由谁来完成的呢?

  • 宽泛地讲逃逸分析显然是由 [执行引擎]{.red} 完成的
  • 具体地来讲执行引擎中细分为解释器和即时编译器,逃逸分析实际是由 [C2 即时编译器(服务器端)]{.red} 执行的

不成熟的逃逸分析带来的缺陷

  • 缺陷

    • [逃逸分析的成本非常高]{.red}

    • [无法保证经过逃逸分析后的优化效果能够高于逃逸分析的成本]{.red}

      极端情况:经过逃逸分析之后发现几乎所有对象都发生了逃逸,那么就浪费了这次分析

  • 细节:[逃逸分析在 JDK 6 以前都是默认不开启的,直到 JDK 7 之后才默认开启]{.blue}

逃逸分析相关参数

  • [-XX:+DoEscapeAnalysis:开启逃逸分析 / -XX:-DoEscapeAnalysis:关闭逃逸分析]{.blue}
  • [-XX:+EliminateAllocations:开启标量替换 / -XX:-EliminateAllocations:关闭标量替换]{.blue}
  • [-XX:+EliminateLocks:开启同步消除 / -XX:-EliminateLocks:关闭同步消除]{.blue}

:::warning

① 两条无法使用的命令:-XX:+PrintEliminateAllocations(打印标量替换结果),-XX:+PrintEscapeAnalysis(查看逃逸分析结果)

② 这两条命令在《深入理解虚拟机》中提到了,但是实际没有办法使用,只可以在调试版本的虚拟机中可以使用

// 虚拟机创建错误
VM option 'PrintEliminateAllocations' is notproduct and is available only in debug version of VM.

:::

回到之前那个问题:对象只能够在堆中分配吗?

  • 先回答这个答案吧,[Java 对象目前只能够在堆中分配]{.red}
  • 你可能想说,哎,不是有栈上分配技术和标量替换技术吗?为什么还是只能够在堆中分配呢?
  • [原因①:Java 虚拟机目前并没有实现栈上分配的技术,而是采用了诸如标量替换这种替代方案]{.green}
  • [原因②:标量替换并没有去创建对象,而是创建了对象包含的成员变量,这些成员变量又都是标量,所有压根就没有创建对象]{.green}
  • 结论:所以最终依然认为 Java 对象目前仅能够在堆中分配,但是随着 Valhalla 项目的进行,说不定以后就可以真的在栈上分配了

尾声(摘自《深入理解虚拟机》)

  • Java 对于栈上分配的技术有着明显的弱势

  • [但是 C/C++ 天生就支持栈上分配,只要不使用 new 关键字创建对象就好了]{.green}

  • [对于 Java 的竞争者 C# 来说也是支持栈上分配的]{.green}

Author: Fuyusakaiori
Link: http://example.com/2021/09/23/jvm/runtime/heapspace/逃逸分析/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.