Jak rychlá je reflexe na JVM?
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:
- vygenerovat bytekód, který adaptuje argumenty a zavolá správnou metodu.
- klasická reflexe
java.lang.reflect.Method java.lang.invoke.MethodHandlejava.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
java.beans.Expressionje o 3 řády pomalejší než běžné volání. Pohodlnost má očividně svou cenu.- Přímé volání bude prakticky ještě rychlejší než naměřené 2.5 ns, protože do času spadá i režie benchmarkování. JMH spouští testovanou metodu ve smyčce, ale postará se, aby nebyla optimalizovaná napříč iteracemi smyčky. Nemělo by tedy docházet k loop unrolling atd. V reálném kódu má JIT nejlepší příležitost právě takhle optimalizovat přímá volání, protože se nemusí nejdřív probít přes abstrakce ostatních variant.
- Reflexe, pokud opakovaně používá předem vytvořený
Methodobjekt, je překvapivě rychlá. To je proto, že JVM pod kapotou pro každý reflexivníMethodneboConstructorobjekt vygeneruje specializovanou třídu, která se postará o kontrolu parametrů a volání konkrétní metody. Je ji možné optimalizovat spolu s okolním kódem a výsledek je proto rychlejší, než když se musí volat z Javy do Céčkové implementace reflexe a zpátky. Tento proces se v JVM slangu označuje inflation. - Pokud na objektu
MethodzavolátesetAccessible(true), volání se o něco málo zrychlí, protože není třeba opakovat kontrolovat viditelnost metody. MethodHandleje na tom podobně jako ostatní rychlovky, ale musím varovat, že moc nevím oMethodHandlea tedy netuším, jestli to používám dobře nebo existují nějaké doporučené postupy. Pokud víte, napište do komentářů.
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é.