JVM 够聪明吗?

Is JVM smart enough?

我知道随着时间的推移,JVM 会变得更加智能(优化代码等...)。
但它能更聪明吗?

让我们考虑一些我经常看到的真实场景。

public static String toJson(final Object object) throws JsonProcessingException {
   final ObjectMapper mapper = new ObjectMapper();
   return mapper.writeValueAsString(object); 
}

分析这个,我们看到每次调用这个方法都会在堆上创建一个ObjectMapper(我认为很重)的实例。
这对垃圾收集器来说是非常昂贵的。

所以我的问题是,JVM 能否足够智能并只创建一个实例(类似于静态实例)?

没有。你需要告诉它这些事情。阅读 Java 语言规范以准确了解 JVM 的行为方式。

https://docs.oracle.com/javase/specs/

JVM 不会根据静态对象隐式共享此对象,这将违反 Java 语言规范。即使对象本身没有字段,如果它依赖于其他对象及其状态,行为也可能会改变,这可能不是线程安全的。即使对象是线程安全的或代码是单线程的,隐式重用也可能会破坏原始代码的假设,因为实现并不总是从初始状态开始。

至于消除对象分配本身,它是可以做到的,而且是在特定情况下做到的。通过逃逸分析,JIT 可以确定对新创建对象的引用在理论上是否可以根据当前局部变量集离开或“逃逸”当前帧,在这种情况下,引用可能存储在其他堆对象的字段中或静态字段。如果分析发现给定对象没有,堆分配可以替换为堆栈分配,因为对象的生命周期仅限于当前帧。

标量替换

OpenJDK 执行 escape analysis。编译器将结果表示如下:

  typedef enum {
    UnknownEscape = 0,
    NoEscape      = 1, // An object does not escape method or thread and it is
                       // not passed to call. It could be replaced with scalar.
    ArgEscape     = 2, // An object does not escape method or thread but it is
                       // passed as argument to call or referenced by argument
                       // and it does not escape during call.
    GlobalEscape  = 3  // An object escapes the method or thread.
  } EscapeState;

有了这些信息,就可以优化分配。但是,JVM 不会用堆栈分配代替堆分配。相反,它执行称为“标量替换”的优化,这意味着如果对象引用未转义当前帧并且所有调用的对象方法都可以内联,则对象字段访问将替换为相应的局部变量。 JVM 从而完全消除了实际的对象实例。根据 ObjectMapper 的实施,此优化可能适用。

为了说明标量替换的效果,请考虑以下示例:

public class Test {
  public static void main(String[] args) {
    int count = 0;
    for (int i = 0; i < 1000*1000*100; i++) {
      TestProcessor proc = new TestProcessor("context");
      String output = proc.process("input");
      if (output.length() > 0) {
        count++;
      }
    }
    System.out.println(count);
  }
}

class TestProcessor {
  String m_context;

  TestProcessor(String context) {
    m_context = context;
  }

  String process(String input) {
    return m_context != null && System.currentTimeMillis() > 0?m_context:input;
  }
}  

临时 TestProcessor 实例永远不会离开当前帧,它不会“逃脱”它。 运行 没有标量替换的示例 (-XX:-EliminateAllocations) 触发垃圾收集器:

[0.021s][info][gc] Using G1
[0.317s][info][gc] Periodic GC disabled
[0.100s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 13M->1M(66M) 1.455ms
[...]
[5.709s][info][gc] GC(58) Pause Young (Normal) (G1 Evacuation Pause) 58M->1M(96M) 0.458ms
100000000

使用默认选项,根本不会触发 GC,总 运行时间明显不那么明显:

[0.011s][info][gc] Using G1
[0.035s][info][gc] Periodic GC disabled
100000000

标量替换和字段

将实例引用存储在对象字段、数组元素或静态字段中会禁用标量替换,即使对象字段属于未转义框架的对象也是如此。以下示例中的所有四种情况:

public class Test {
  static Object s_obj;
  static Object[] s_objs = new Object[1];

  public static void main(String[] args) {
    Holder holder = new Holder();
    int count = 0;
    for (int i = 0; i < 1000*1000*100; i++) {
      TestProcessor proc = new TestProcessor("context");
      // Store the reference in a static field
      s_obj = proc;
      // Store the reference in an array element
      s_objs[0] = proc;
      // Store the reference in an object field (non-escaping object #1)
      proc.m_obj = proc;
      // Store the reference in an object field (non-escaping object #2)
      holder.m_obj = proc;
      String output = proc.process("input");
      if (output.length() > 0) {
        count++;
      }
    }
    System.out.println(count);
  }
}

class Holder {
  Object m_obj;
}

class TestProcessor {
  String m_context;
  Object m_obj;

  TestProcessor(String context) {
    m_context = context;
  }

  String process(String input) {
    return m_context != null && System.currentTimeMillis() > 0?m_context:input;
  }
}

标量替换和内联

内联是标量替换的要求。如果无法内联对象的方法或访问要进行标量替换的对象的代码,则该对象不适合进行标量替换。原因很简单:代码必须访问对象字段,而这些字段由当前帧中的局部变量表示,因此代码必须在当前帧中 运行 才能获得局部变量访问权限。

添加可以内联的对象访问(Object.equals())不会阻止标量替换:

      if (output.length() > 0 && args.equals(proc)) {
        count++;
      }

添加无法内联的对象访问(Object.hashCode() 有本机实现)禁用标量替换:

      if (output.length() > 0 && proc.hashCode() > 0) {
        count++;
      }

在那种情况下,覆盖 hashCode 再次启用标量替换,因为可以内联新方法:

  public int hashCode() {
    return 42;
  }

内联决策也受方法大小的影响。在原始示例中,方法 TestProcessor.process() 足够小。该示例多次调用该方法,因此相关限制由 -XX:FreqInlineSize 控制。我基于 Java 14 的测试中的默认值是 325:

$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version 2>&1 | grep FreqInlineSize
     intx FreqInlineSize                           = 325 {pd product} {default}

通过 switch 块增加方法大小允许我们超过限制:

  String process(String input) {
    switch (input.hashCode()) {
      case 0: System.currentTimeMillis();
      // ...
      case 35: System.currentTimeMillis();
    }
    return m_context != null && System.currentTimeMillis() > 0?m_context:input;
  }

之前(javap 输出):

        16: getfield      #7                  // Field m_context:Ljava/lang/String;
        19: goto          23
        22: aload_1
        23: areturn

之后(javap 输出):

       324: getfield      #7                  // Field m_context:Ljava/lang/String;
       327: goto          331
       330: aload_1
       331: areturn

超过限制,标量替换再次被禁用。

标量替换和数组

在Java中,数组是对象。因此,数组可以进行标量替换。但是,限制很重要:

    if (call->is_AllocateArray()) { 
      if (!cik->is_array_klass()) { // StressReflectiveCode
        es = PointsToNode::GlobalEscape;
      } else {
        int length = call->in(AllocateNode::ALength)->find_int_con(-1);
        if (length < 0 || length > EliminateAllocationArraySizeLimit) {
          // Not scalar replaceable if the length is not constant or too big.
          scalar_replaceable = false;
        }
      }
    }

数组大小不得超过-XX:EliminateAllocationArraySizeLimit=<value>。默认值为 64。此外,大小必须是恒定的。堆栈帧大小通常是常数,局部变量的概念也是如此。这个限制是有道理的。

同样,用于元素访问的索引必须是常量。用循环迭代数组与标量替换不兼容。

标量替换和其他约束

来源揭示了更多影响对象定义本身的约束:

    } else {  // Allocate instance
      if (cik->is_subclass_of(_compile->env()->Thread_klass()) || 
          cik->is_subclass_of(_compile->env()->Reference_klass()) ||
         !cik->is_instance_klass() || // StressReflectiveCode
         !cik->as_instance_klass()->can_be_instantiated() ||
          cik->as_instance_klass()->has_finalizer()) {
        es = PointsToNode::GlobalEscape;
      } else {
        int nfields = cik->as_instance_klass()->nof_nonstatic_fields();
        if (nfields > EliminateAllocationFieldsLimit) {
          // Not scalar replaceable if there are too many fields.
          scalar_replaceable = false;
        }
      }
    }

就现实世界的相关性而言,最重要的方面是覆盖 Object.finalize 会禁用标量替换。完成要求在调用挂钩时可以访问对象 - 这意味着 GlobalEscape.

不太相关的是可通过 -XX:EliminateAllocationFieldsLimit=<value> 配置的字段限制。默认值为 512,因此不太可能导致问题。

标量替换与堆栈分配

标量替换是一项有价值的优化。它当然不是消除临时对象分配开销的包罗万象的解决方案,而且它从来没有打算成为。与其他优化一样,需要满足一些约束条件。

堆栈分配有可能消除内联依赖,因为对象引用可以直接传递,但有其自身的一组含义,而且效率可能会降低。堆栈分配需要在内存中表示对象,另一方面,标量替换可能表示寄存器中的对象字段。

底线

如果您知道可以根据对象的文档安全地共享和重用该对象,那么最好的方法是显式重用该实例,而不是依赖可能因 JVM 实现而异的潜在优化,更重要的是,可能会因 caller/callee 将来的更改而被禁用。