HotSpot 17.0-b12的逃逸分析/标量替换的一个演示

之前做了一次 关于Java程序的执行的分享,里面提到了“逃逸分析”,以及它可以引出的一些优化,包括“标量替换”“栈上分配”“锁削除”之类。这里用一个实际例子来演示Sun的JDK 6中实现的标量替换优化。

简单说明两个概念:

逃逸分析,escape analysis。分析程序中指针的动态作用域,看某个指针是否指向某个固定的对象并且没有“逃逸”出某个函数/方法或者线程的范围。如果没有逃逸则可知该指针只在某个局部范围内可见,外部(别的函数/方法或线程)无法看到它。于是可以为别的一些优化提供保证。

标量替换,scalar replacement。Java中的原始类型无法再分解,可以看作标量(scalar);指向对象的引用也是标量;而对象本身则是聚合量(aggregate),可以包含任意个数的标量。如果把一个Java对象拆散,将其成员变量恢复为分散的变量,这就叫做标量替换。拆散后的变量便可以被单独分析与优化,可以各自分别在活动记录(栈帧或寄存器)上分配空间;原本的对象就无需整体分配空间了。

虽然概念上的JVM总是在Java堆上为对象分配空间,但并不是说完全依照概念的描述去实现;只要最后实现处理的“可见效果”与概念中描述的一直就没问题了。所以说,“you can cheat as long as you don't get caught”。Java对象在实际的JVM实现中可能在GC堆上分配空间,也可能在栈上分配空间,也可能完全就消失了。这种行为从Java源码中看不出来,也无法显式指定,只是聪明的JVM自动做的优化而已。

引用一段注释:
escape.cpp
// in order for an object to be scalar-replaceable, it must be:
//   - a direct allocation (not a call returning an object)
//   - non-escaping
//   - eligible to be a unique type
//   - not determined to be ineligible by escape analysis


普通Java对象的大小貌似不影响标量替换的判定,不过数组的大小则会影响。
escape.cpp, ConnectionGraph::process_call_result()
case Op_Allocate:
{
  Node *k = call->in(AllocateNode::KlassNode);
  const TypeKlassPtr *kt = k->bottom_type()->isa_klassptr();
  assert(kt != NULL, "TypeKlassPtr  required.");
  ciKlass* cik = kt->klass();

  PointsToNode::EscapeState es;
  uint edge_to;
  if (cik->is_subclass_of(_compile->env()->Thread_klass()) ||
     !cik->is_instance_klass() || // StressReflectiveCode
      cik->as_instance_klass()->has_finalizer()) {
    es = PointsToNode::GlobalEscape;
    edge_to = _phantom_object; // Could not be worse
  } else {
    es = PointsToNode::NoEscape;
    edge_to = call_idx;
  }
  set_escape_state(call_idx, es);
  add_pointsto_edge(resproj_idx, edge_to);
  _processed.set(resproj_idx);
  break;
}

case Op_AllocateArray:
{

  Node *k = call->in(AllocateNode::KlassNode);
  const TypeKlassPtr *kt = k->bottom_type()->isa_klassptr();
  assert(kt != NULL, "TypeKlassPtr  required.");
  ciKlass* cik = kt->klass();

  PointsToNode::EscapeState es;
  uint edge_to;
  if (!cik->is_array_klass()) { // StressReflectiveCode
    es = PointsToNode::GlobalEscape;
    edge_to = _phantom_object;
  } else {
    es = PointsToNode::NoEscape;
    edge_to = call_idx;
    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.
      ptnode_adr(call_idx)->_scalar_replaceable = false;
    }
  }
  set_escape_state(call_idx, es);
  add_pointsto_edge(resproj_idx, edge_to);
  _processed.set(resproj_idx);
  break;
}

这里可以看到数组的大小(元素个数)与EliminateAllocationArraySizeLimit的关系。EliminateAllocationArraySizeLimit的默认值是64:
c2_globals.hpp
product(intx, EliminateAllocationArraySizeLimit, 64, "Array size (number of elements) limit for scalar replacement")


=================================================================

接下来开始实验。

从官网下载 JDK 6 update 21 build 03的fastdebug版。运行java -server -version应可以看到:
command prompt 写道
java version "1.6.0_20-ea-fastdebug"
Java(TM) SE Runtime Environment (build 1.6.0_20-ea-fastdebug-b02)
Java HotSpot(TM) Server VM (build 17.0-b12-fastdebug, mixed mode)

本帖的例子都是使用上述JDK在32位Windows XP SP3上运行的。

该版本HotSpot的server模式在32位x86上的一些默认参数值如下:
CompileThreshold               = 10000
OnStackReplacePercentage       = 140
InterpreterProfilePercentage   = 33
InterpreterBackwardBranchLimit =
    (CompileThreshold * (OnStackReplacePercentage - InterpreterProfilePercentage)) / 100
                               = 10700

注意到CompileThreshold与InterpreterBackwardBranchLimit这两个值:
CompileThreshold:当某个方法被调用+循环次数累计超过该值时,触发标准的JIT编译;
InterpreterBackwardBranchLimit:当某个方法调用+循环次数累计超过该值时,触发OSR形式的JIT编译。

下面就来造一个能演示逃逸分析/标量替换优化的例子。
我们希望能写一个test()方法,让它被调用足够次数以触发标准JIT编译;由于HotSpot默认采用后台编译,还得让程序运行足够时间让编译完成;最好能避免其它方法的编译,减少输出结果的干扰。
代码如下:
public class TestEAScalarReplacement {
    private static int fooValue;
    
    public static void test(int x) {
        int xx = x + 2;
        Point p = new Point(xx, 42);
        fooValue = p.getX();         // "publish" result
    }
    
    private static void driver0(int x) {
        for (int i = 0; i < 5; i++) {
            test(i + x);
        }
    }
    
    private static void driver1(int x) {
        for (int i = 0; i < 5; i++) {
            test(i + x);
        }
    }
    
    private static void driver2(int x) {
        for (int i = 0; i < 5; i++) {
            test(i + x);
        }
    }
    
    private static void driver3(int x) {
        for (int i = 0; i < 5; i++) {
            test(i + x);
        }
    }
    
    public static void driver(int x) {
        for (int i = 0; i < 40; i++) {
            driver0(i + x);
            driver1(i + x);
            driver2(i + x);
            driver3(i + x);
        }
    }
    
    public static void main(String[] args) {
        for (int i = 0; i < 40; i++) {
            driver(i);
        }
    }
}

class Point {
    private int x;
    private int y;
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    public int getX() {
        return x;
    }
    
    public int getY() {
        return y;
    }
}

这里,Point是用于演示被替换为标量的类型,TestEAScalarReplacement.test()是目标方法,其它一些方法都是用于预热用的。

这种预热方式可以保证test()被调用足够次数,在收集足够多的profile信息的前提下触发标准JIT编译;而驱动预热的方法各自被调用的次数与循环的次数都不足以触发其OSR形式的JIT编译。

test()方法中,最后一句将一个p.x赋值给一个镜头变量。这是为了保证局部变量xx对应的计算能存活而写的。
由于成员变量与静态变量都“外部可见”,例如说从别的线程可以观察到这些值的变化,所以JVM不能够对成员变量与静态变量的赋值做太多优化。这里的赋值保证可以生效,于是p.x就被“使用”过,间接也就“使用”过局部变量xx的值。可以确定没有被使用过的值/计算则可以被优化掉,在最终生成的代码里就看不到了。
这种向成员变量或静态变量赋值的操作也叫做“发布”(publish)一个值。

OK,那么用下述参数跑一下该实验:
java -server -XX:+DoEscapeAnalysis -XX:+EliminateAllocations TestEAScalarReplacement


什么也没看到?呵呵,那就让HotSpot输出点日志来看看。下面是几个输出日志的参数与对应的日志:

-XX:+PrintCompilation
  3       TestEAScalarReplacement::test (23 bytes)

这里说明TestEAScalarReplacement.test()方法确实被编译了,而且是标准JIT编译。

-XX:+PrintInlining
      @ 11   Point::<init>  inline (hot)
        @ 1   java.lang.Object::<init>  inline (hot)
      @ 16   Point::getX  inline (hot)

这里说明test()方法编译时将Point与Object的构造器,以及Point.getX()方法内联了进去。也就是说,test()方法在内联后等价变为:
public static void test(int x) {
    int xx = x + 2;
    Point p = new_Point(); // 创建一个Point对象但不调用其构造器
    p.x = xx;              // Point构造器内容被内联到这里
    p.y = 42;
    fooValue = p.x;        // getX()方法也被内联了
}

留意到现在变量p指向的Point对象还是一个整体。

-XX:+PrintEscapeAnalysis
======== Connection graph for  TestEAScalarReplacement::test
    27 JavaObject NoEscape  [[ 94F 88F]]   27	Allocate	===  5  6  7  8  1 ( 25  23  24  1  1  22  1 ) [[ 28  29  30  37  38  39 ]]  rawptr:NotNull ( int:>=0, java/lang/Object:NotNull *, bool, int ) TestEAScalarReplacement::test @ bci:4 !jvms: TestEAScalarReplacement::test @ bci:4
LocalVar [[ 27P]]   39	Proj	===  27  [[ 40  94  88 ]] #5 !jvms: TestEAScalarReplacement::test @ bci:4

这里说明通过逃逸分析,发现变量p没有逃逸出test()方法。

-XX:+PrintEliminateAllocations
Scalar  27	Allocate	===  5  6  7  8  1 ( 25  23  24  1  1  22  1 ) [[ 28  29  30  37  38  39 ]]  rawptr:NotNull ( int:>=0, java/lang/Object:NotNull *, bool, int ) TestEAScalarReplacement::test @ bci:4 !jvms: TestEAScalarReplacement::test @ bci:4
++++ Eliminated: 27 Allocate

这里说明变量p指向的Point对象被标量替换了。被标量替换后的test()方法等价于:
public static void test(int x) {
    int xx = x + 2;
    int px = xx;    // Point消失了,留下其成员为分散的局部变量
    int py = 42;
    fooValue = px;
}


-XX:+PrintAssembly
Decoding compiled method 0x00be5248:
Code:
[Disassembling for mach='i386']
[Entry Point]
[Verified Entry Point]
  # {method} 'test' '(I)V' in 'TestEAScalarReplacement'
  # parm0:    ecx       = int
  #           [sp+0x10]  (sp of caller)
  0x00be5320: push   %ebp
  0x00be5321: sub    $0x8,%esp          ;*synchronization entry
                                        ; - TestEAScalarReplacement::test@-1 (line 5)
  0x00be5327: add    $0x2,%ecx
  0x00be532a: mov    $0x150,%ebp
  0x00be532f: mov    %ecx,0x1024b4e0(%ebp)  ;*putstatic fooValue
                                        ; - TestEAScalarReplacement::test@19 (line 7)
                                        ;   {oop('TestEAScalarReplacement')}
  0x00be5335: add    $0x8,%esp
  0x00be5338: pop    %ebp
  0x00be5339: test   %eax,0x9c0000      ;   {poll_return}
[Deopt MH Handler Code]
  0x00be533f: ret    
[Exception Handler]
[Stub Code]
  0x00be5340: jmp    0x00be4d40         ;   {no_reloc}
[Deopt Handler Code]
  0x00be5345: push   $0xbe5345          ;   {section_word}
  0x00be534a: jmp    0x00bcbb40         ;   {runtime_call}
[Constants]
  0x00be534f: int3

这里显示了test()方法最终被JIT编译器变成了怎样的x86指令(AT&T记法)。无法使用该参数的同学可以用-XX:+PrintOptoAssembly来代替,看到的是C2在最终生成代码之前的中间代码,已经非常接近最后结果了。

刨去一些结构信息,test()方法对应的编译结果是:
; 方法入口处理
0x00be5320: push   %ebp
0x00be5321: sub    $0x8,%esp

;; xx = x + 2
; (参数x从ECX寄存器传入,然后局部变量xx被叠加分配在ECX上)
0x00be5327: add    $0x2,%ecx

;; TestEAScalarReplacement.fooValue = xx
0x00be532a: mov    $0x150,%ebp
0x00be532f: mov    %ecx,0x1024b4e0(%ebp)

;; 方法出口处理
0x00be5335: add    $0x8,%esp
0x00be5338: pop    %ebp
; 下面这句是检查是否要进入safepoint
0x00be5339: test   %eax,0x9c0000      ;   {poll_return}
; 最后返回
0x00be533f: ret


也就是说,最后编译出来的test()方法等价于:
public static void test(int x) {
    fooValue = x + 2;
}


=================================================================

上面的实验演示了Sun的JDK 6中实现的逃逸分析与标量替换。不过话说回来,这个实现确实不够理想,要达到上面实验中这么明显的效果比较困难,特别是分析的代码规模一大就不太能行了……也就是说很RP。所以逃逸分析及相关优化即便在JDK 6的server模式默认也是不开的。
之前在JDK6u18的时候这些优化还被暂时禁用了一段时间。所以用6u18来测逃逸分析的同学们请换新版本来测吧,那个版本上看不到效果的……

你可能感兴趣的:(jvm,jdk,编程,XP,oop)