0xDEADBEEF

[RSS]
««« »»»

Jak rychlá je reflexe na JVM?

25. 8. 2019

Když píšete interpretr běžící na JVM, je velice užitečné, aby uměl používat již existující knihovny napsané v Javě. K tomu ale musí umět volat do Javy, což v případě, že váš jazyk je dynamicky typovaný (třeba nějaká varianta Lispu), nemusí být triviální a/nebo rychlé. Volání může proběhnout několika způsoby:

  1. vygenerovat bytekód, který adaptuje argumenty a zavolá správnou metodu.
  2. klasická reflexe java.lang.reflect.Method
  3. java.lang.invoke.MethodHandle
  4. java.beans.Expression

Poslední jmenovaná je rozhodně nejpohodlnější varianta. Ale jak rychlá je v porovnání s ostatními? Napsal jsem proto tento maličký JMH benchmark testující jednotlivé metody volání.

class X {
  def method() = 1
}

class MethodCalls {
  val obj = new X
  val method = obj.getClass.getDeclaredMethod("method")
  val method2 = {
    val m = obj.getClass.getDeclaredMethod("method")
    m.setAccessible(true)
    m
  }

  val mh: MethodHandle = MethodHandles.lookup.unreflect(method)

  @Benchmark def directCall: Int =
    obj.method

  @Benchmark def reflection: Int =
    obj.getClass.getDeclaredMethod("method").invoke(obj).asInstanceOf[Int]

  @Benchmark def reflectionReused: Int =
    method.invoke(obj).asInstanceOf[Int]

  @Benchmark def reflectionAccessible: Int =
    method2.invoke(obj).asInstanceOf[Int]

  @Benchmark def beans: Int =
    new java.beans.Expression(obj, "method", Array()).getValue.asInstanceOf[Int]

  @Benchmark def methodHandle: Int =
    (mh.invokeExact(obj): Int)
}

Výsledky na OpenJDK 11.0.4 a věrném procesoru i5-4570 jsou následující:

Benchmark                              Score   Error  Units
MethodCalls.directCall                 2,559 ± 0,004  ns/op
MethodCalls.reflection                41,441 ± 0,063  ns/op
MethodCalls.reflectionReused           6,259 ± 0,046  ns/op
MethodCalls.reflectionAccessible       5,192 ± 0,011  ns/op
MethodCalls.beans                   1259,326 ± 0,998  ns/op
MethodCalls.methodHandle               5,692 ± 0,067  ns/op

Dostatečně inteligentní a agresivní JIT by teoreticky mohl zoptimalizovat všechny verze na úroveň lepší reflexe, kdyby inlinoval kompletní graf volání a nasekal tam fůru inline chache. V tomto kontextu by mohlo být zajímavé zjistit, jaké časy vyprodukuje GraalVM.


MethodHandle podává lepší výsledky, pokud volám metodu s primitivními parametry.

Stejný benchmark, změnilo se jen tohle:

class X {
  private[this] val str = "EVENDEEPER"
  def method(pos: Int): Int = str.charAt(pos)
}

@Benchmark def directCall: Int =
  obj.method(4)

@Benchmark def reflectionReused: Int =
  method.invoke(obj, new Integer(4)).asInstanceOf[Int]

@Benchmark def methodHandle: Int =
  (mh.invokeExact(obj, 4): Int)

Výsledky jsou následující:

Benchmark                              Score   Error  Units
MethodCalls.directCall                 5,701 ± 0,116  ns/op
MethodCalls.methodHandle              10,925 ± 0,003  ns/op
MethodCalls.reflectionReused          19,670 ± 0,349  ns/op

MethodHandle je (IMHO) rychlejší, protože nemusí boxovat a unboxovat primitivní typy.


Ale jako vždy nezapomeňte, jde jen o čísla, která nohou mít s realitou běžných programů společného o dost méně, než by se nám líbilo. Benchmarkování dynamicky kompilujících virtuálních strojů je komplikované.

píše k47 (@kaja47, k47)