ハードコアJava / JVMタスク

Konturからパフォーマンスタスクが既にありました 、次はJBreak 2018 Javaカンファレンス(別名「Excel from siell」)のハードコアタスクを紹介しました。







タスクは元の定式化で与えられ、各タスクにはいくつかの正解があります。各タスクに対して、ネタバレの下で解決策が与えられます。







タスク1



同僚はJava言語仕様を読み、次のことを書きました。







void playWithRef() { Object obj = new Object(); WeakReference<Object> ref = new WeakReference<>(obj); System.out.println(ref.get() != null); System.gc(); System.out.println(ref.get() != null); }
      
      





そして、あなたにすくい上げるために:実行のどんな結果が可能ですか?









回答と解決策

正解は、 ACDです。







解決策

変数obj



のスコープはメソッド全体であり、寿命のスコープはWeakReference



コンストラクターを終了した後(実際には、コンストラクターの内部で-少し前でも)終了します。 そして、 GCがこのオブジェクト破壊できるかどうかに影響を与えるのは人生の領域です。







ただし、便利な場合、 VMは変数の寿命を延ばすことができます。 たとえば、HotSpotインタープリターは、変数が表示されている限り生存していることをGCに伝えます(これは、デバッガーで確認できることです)。 つまり、HotSpot VMで追加オプションなしで(または明示的な-Xint



)サンプルを実行すると、オプションDが簡単に実現されます。







Cの結果は、多くのコンパイラ(たとえば、HotSpot C1 / C2、Excelsior JET JIT&AOTなど)で実現されます。 コンパイラーは、 obj



変数が使用されておらず、最初のget()



呼び出しによってGCがオブジェクトを破棄することを何も妨げないことを計算できるほどスマートです。 ただし、ほとんどの場合、GCにはSystem.gc()



明示的な呼び出しのみが付属します。この動作は、 -Xcomp



するHotSpot VMまたは任意のモードのExcelsior JETで現れます。







たとえば、 WeakReference



コンストラクターの実行の最後にGCが到着した場合、オプションAは理論的に達成可能です。







タスクはJDK 8コードのバグに基づいており、メソッドの引数は不正確にWeakReference



保存され、メソッドの実行中に死にました。 これについては、テクニカルブログに個別の詳細な投稿があります。







タスク2



邪悪なハッカーが元のJavaファイルを削除し、クラスファイルの断片をシャッフルしました。







 A: 0700 0401 0001 4300 2000 0300 0100 0000 B: 0000 0000 00 C: 6a61 7661 2f6c 616e 672f 4f62 6a65 6374 D: cafe babe 0000 0031 0005 0700 0201 0010
      
      





検証可能なクラスファイルを取得できるように、それらを再配置します。







回答と解決策

正解は、 DCABです。







解決策

このタスクはより独創的ですが、それでも新しいことを教えます。







クラスファイルが4バイトのヘッダー0xCAFEBABE



で始まることは広く知られています。つまり、 Dが確実に最初になります。 常識は、短いピースBが最後に来ることを教えてくれます-それは尾です。







次に、クラスファイルにはConstantPoolがあり、2バイトの長さとUTF-8でエンコードされた実際の文字列で構成される文字列定数があります。 UTF-8のように見える唯一のピースはCピースです-これはjava/lang/Object



文字列(クラスのスーパークラスへの参照)を表すUTF-8です。 これは、バイト0x0010



(文字列の長さは16)の前にある必要があり、唯一の適切なオプションはDである 、つまりCが2番目であることを意味します。







あるいは、 Bの最後の行全体がゼロで構成されていることに気付くことができます。つまり、最後から2番目の行はゼロで終わるはずです。つまり、 Aです!







Javap出力
 class C minor version: 0 major version: 49 flags: ACC_SUPER Constant pool: #1 = Class #2 // java/lang/Object #2 = Utf8 java/lang/Object #3 = Class #4 // C #4 = Utf8 C { }
      
      





タスク3



Graalについての別の話を聞いて、インスピレーションでJVMコンパイラインターフェイスを見て、独自のJavaコンパイラを書くことにしました! そして、メソッドのx86_64コードを生成することから始めることにしました。







 static boolean invert(boolean x) { return !x; }
      
      





そのようなメソッドに対してどのような生成コードが正しいでしょうか?







凡例:Intel構文が使用され、呼び出し規約は、引数がrcx



にあり、 rax



rax



です。







 A: test ecx, ecx jnz True mov eax, 1 ret True: mov eax, 0 ret B: xor eax, eax test ecx, ecx jnz End add eax, 1 End: ret C: mov eax, 1 sub eax, ecx ret D: mov eax, ecx xor eax, 1 ret
      
      





回答と解決策

正解はABです。







解決策

アセンブラーのリストは、Java会議で頻繁に見ることができますが、Intel x86コマンドシステムに精通しいない場合、同等のCコードを以下に示します。







 A: res = (arg == 0) ? 1 : 0; B: res = 0; if (arg == 0) res += 1; C: res = 1; res -= arg; D: res = arg; res ^= 1;
      
      





実際、これらのすべての反転アルゴリズムは正しく機能しますが、入力引数は通常の論理値0



および1



取ります。







その後、楽しみが始まります。 検証者の観点から見るとすべての短整数型( boolean



byte



char



short



)はint



と同等です。 さらにboolean



固有のバイトコード命令はまったく存在しません。 たとえば、調査中のメソッドのバイトコード命令は次のとおりです。







 public static boolean invert(boolean); 0: iload_0 1: ifne 8 4: iconst_1 5: goto 9 8: iconst_0 9: ireturn
      
      





したがって、 boolean



を受け入れるメソッドはint



で動作する準備ができている必要があり、ゼロ以外の値はすべてtrue



として扱われtrue



。 この場合、「最適化された」バリアントCおよびDは、誤ってC(2) = -1



およびD(2) = 3



として動作し始め、より単純なAおよびBは引き続き動作しますA(2) = B(2) = 0









これらの微妙さを説明するには、バイトコードを操作する必要があります。 GitHubの例で 、0、1、2、3、-1の数値がinvert



メソッドに渡されて結果が出力され、その後にprintln(boolean)



およびprintln(int)



呼び出されます。







奇妙な事実:JDK 8ではHotSpot C2コンパイラーがバージョンDを生成し、JDK 9では生成テンプレートがより正しいものに変更されました。







Intel x86_64でのHotSpot C2生成コード

JDK 8では、テンプレートDが明確に表示され、誤った結果が表示されます。







 $ jdk8/bin/java -Xcomp -Xbatch -XX:-TieredCompilation -XX:CompileCommand=print,Inverter.invert -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=intel BooleanHell ... Compiled method (c2) 1216 533 Inverter::invert (10 bytes) ... # {method} {0x0000000012600d08} 'invert' '(Z)Z' in 'Inverter' # parm0: rdx = boolean # [sp+0x20] (sp of caller) 0x00000000057d7ac0: sub rsp,0x18 0x00000000057d7ac7: mov QWORD PTR [rsp+0x10],rbp ;*synchronization entry ; - Inverter::invert@-1 (line 3) 0x00000000057d7acc: mov eax,edx 0x00000000057d7ace: xor eax,0x1 ;*ireturn ; - Inverter::invert@9 (line 3) 0x00000000057d7ad1: add rsp,0x10 0x00000000057d7ad5: pop rbp 0x00000000057d7ad6: test DWORD PTR [rip+0xfffffffffdf58524],eax # 0x0000000003730000 ; {poll_return} 0x00000000057d7adc: ret ... false (0) -> true (1) true (1) -> false (0) true (2) -> true (3) true (3) -> true (2) true (-1) -> true (-2)
      
      





JDK 9はboolean



正規化を改善しました。入力引数が範囲{0、1}( test



およびsetne



)に削減され、結果が正しくなりました。







 $ jdk9/bin/java -Xcomp -Xbatch -XX:-TieredCompilation -XX:CompileCommand=print,Inverter.invert -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=intel BooleanHell ... Compiled method (c2) 4702 1496 Inverter::invert (10 bytes) ... # {method} {0x000001fa974d2dc0} 'invert' '(Z)Z' in 'Inverter' # {method} {0x000001fa974d2dc0} 'invert' '(Z)Z' in 'Inverter' # parm0: rdx = boolean # [sp+0x20] (sp of caller) 0x000001fafcb57720: sub rsp,0x18 0x000001fafcb57727: mov QWORD PTR [rsp+0x10],rbp ;*synchronization entry ; - Inverter::invert@-1 (line 3) 0x000001fafcb5772c: test edx,edx 0x000001fafcb5772e: setne al 0x000001fafcb57731: movzx eax,al 0x000001fafcb57734: xor eax,0x1 ;*ireturn {reexecute=0 rethrow=0 return_oop=0} ; - Inverter::invert@9 (line 3) 0x000001fafcb57737: add rsp,0x10 0x000001fafcb5773b: pop rbp 0x000001fafcb5773c: test DWORD PTR [rip+0xfffffffffdf688be],eax # 0x000001fafaac0000 ; {poll_return} 0x000001fafcb57742: ret ... false (0) -> true (1) true (1) -> false (0) true (2) -> false (0) true (3) -> false (0) true (-1) -> false (0)
      
      





タスク4



突然、このメソッドの呼び出しが次のことにつながることに非常に興味があることに気付きました。







 void guessWhat(Iterable<?> x) { System.out.println(x.getClass()); }
      
      







回答と解決策

正解はADです。







解決策

Object.getClass()



常にゼロ以外のクラスを返し、インターフェース型のインスタンスはないため、バリアントBCは不可能です。 オプションAは簡単に実装できます: guessWhat(new ArrayList<Object>())









ただし、オプションDは実現可能ですIterable



Iterable



インターフェイスを実装しませんが、それでも、そのメソッドのインスタンスをこのメソッドにIterable



ことができます。 答えは、典型的なJava言語システムの厳密さが再びJVM検証型システムの弱点に陥ったということです。すべての参照型 、すべてのインターフェースと割り当て互換です。 つまり、インターフェイスタイプが期待されるほとんどすべての場所(パラメーター、戻り値、フィールドを含む)、任意の参照値(つまり、任意のクラスと配列)を渡すことができます。







この効果 、バイトコードを操作するか、クラスファイルを部分的に再コンパイルすることで実証できます。







タスク5



もう一度javacの不可in性を信じて、あなたは実験することにしました:







 class C { private boolean getBoolean() { return false; } } interface I { default boolean getBoolean() { return true; } } class D extends C implements I {} public class Test { public static void main(String[] a) { foo(new D()); } public static void foo(I i) { System.out.println(i.getBoolean()); } }
      
      





Test



クラスをコンパイルして実行しようとするとどうなりますか?









回答と解決策

正解はBです







解決策

多くの人は、 IllegalAccessError



が部分的な再コンパイルまたは難読化でやり過ぎている人の運命だと信じています。 そのため、 ProGuard難読化中に 2つの異なるメソッド(1つはプライベート、もう1つはデフォルト)に同じ名前を付け、結果のアプリケーションはIllegalAccessError



をスローし始めました。







ただし、2つのそのようなメソッドがソースコードですぐに同じ名前を持つ場合、 javac



警告なしでそれらをコンパイルし、 IllegalAccessError



も実行時にスローされることが判明しました。







このJVMの動作は、 invokeinterface



ステートメントのターゲットメソッドがどのようにinvokeinterface



されるかによって説明されます。 仕様によれば、クラスとすべてのスーパークラスのインスタンスメソッドが最初に調べられ、次に適切なデフォルトメソッドがスーパーインターフェース間で検索されますが、見つかったメソッドのプライバシーはプロセス全体の完了後にのみチェックされます。







したがって、検索は、スーパークラスC



のプライベートgetBoolean



メソッドで終了しますI



は、スーパーインターフェースI



からデフォルトのgetBoolean



メソッドを見つける方法をgetBoolean



ますI



その後、 IllegalAccessError



すでに論理的にスローされます。







興味深いことに、これ Java 11で変更され、検索プロセスでプライベートメソッドをスキップする予定です。







タスク6



突然、コンパイルされたJavaアプリケーションのネイティブコードをデバッグすることになります。 あなたはソースを持っていませんが、問題のあるメソッドをすでに見つけています、ここにあります:







 1: lea rax, [rel _Test_foo] 2: push rax 3: mov eax, dword [rcx+0FH] 4: idiv dword [rdx+0FH] 5: mov rbx, qword [rel _Test_array] 6: mov ebx, dword [rbx+3BH] 7: add eax, ebx 8: ret 8
      
      





このメソッドを実行すると、さまざまなタイプのJava例外がスローされる可能性があると思われました。 どの指示が非難される可能性があるのか​​を理解する(番号を示す)のはまだですか?









回答と解決策

正解は次のとおりです。







  • StackOverflowError



    :2
  • NullPointerException



    NullPointerException



  • ArithmeticException



    :4
  • IndexOutOfBoundsException



    :なし


解決策

コンパイラは多くの方法で例外チェックを生成できます。 たとえば、オブジェクトのフィールドにアクセスする前に、 null



に対するオブジェクトの明示的なチェックを生成できます。失敗した場合は例外がスローされます。 ただし、このような明示的なチェックは、パフォーマンスとコードサイズに悪影響を及ぼします。 したがって、コンパイラーは暗黙的なチェックを実行しようとします:ポインター逆参照コードのみが生成されます。これは、nullポインターの場合、JVMが対応するJava例外としてキャッチ、認識、再スローするハードウェア例外を引き起こします。







この問題では、このような暗黙の例外を引き起こす可能性のある命令を見つけることが必要でした。







StackOverflowError



は、許可された範囲外の次のスタックスロットを読み書きしようとすると発生します。 これは、 push rax



命令で発生する可能性があります。







暗黙のArithmeticException



idiv dword [rdx+0FH]



候補は1つだけです。整数除算命令idiv dword [rdx+0FH]



です。 参照解除された値がゼロの場合、ゼロによるハードウェア除算が行われ、その後にArithmeticException



続きます。







NullPointerException



をスローできる暗黙のチェックは、Javaコードで非常に一般的です。 それらを見つけるには、何かが間接参照されているすべての場所を考慮してください。 命令mov rbx, qword [rel _Test_array]



は相対アドレスの静的データを逆参照するため、エラーにつながることはありません。 しかし、命令mov eax, dword [rcx+0FH]



idiv dword [rdx+0FH]



mov ebx, dword [rbx+3BH]



は、メソッドパラメーターを逆参照し、静的データを読み取ります。つまり、 NullPointerException



をスローできます。







興味深いことに、 idiv dword [rdx+0FH]



は、一度に2つの暗黙的なチェックが含まれています。これにより、多くのJVM問題が発生することがあります







IndexOutOfBoundsException



暗黙的なチェックは、配列の要素をアドレス指定する命令で行う必要があります。 ヒントは、特定の_Test_array



をレジスターに読み取り、命令5



および6



それを逆参照することです。 ただし、配列要素へのアクセスを生成するこのパターンでは、範囲外のインデックスは単純に配列に隣接するヒープ内のメモリにアクセスするため、ハードウェア例外は発生しません。 したがって、ほとんどのプロセッサアーキテクチャでは、 IndexOutOfBoundsException



チェックIndexOutOfBoundsException



明示的に生成されます。 ただし、まれに、コンパイラはこのようなチェックがまったく必要ないことを証明できます。これがこの問題で発生します。 つまり、 IndexOutOfBoundsException



はここではまったくIndexOutOfBoundsException



できません。







タスク7



邪悪なハッカーがコンピューターを再びハッキングし、 Helper.class



hexエディターで編集して、 sayC



メソッドの終わりが検証不能になるようにしました。







 public class Main { public static void main(String[] args) { System.out.print("A"); Helper.sayB(); Helper.sayC(); } } public class Helper { public static void sayB() { System.out.print("B"); } public static void sayC() { System.out.print("C"); // bad bytecode goes here } }
      
      





Main



クラスを開始するとどうなりますか?









回答と解決策

正解はBです







解決策

特定のクラスのバイトコードの検証は、このクラスのメソッドが実行される前に機能します。 Helper



クラスには、検証不可能なsayC



メソッドがあります。これは、クラス全体が完全に検証不可能であることを意味します。 したがって、オプションCDは間違いです。実行はsayB



メソッドに到達しません。







次に、 VerifyError



スローされるポイントを理解する必要があります。 仕様により 、実行時にリンクが必要な場合、JVMに強力なリンク解決がある場合でも(クラスがロードされるとすべてのリンクがすぐに解決されます)、リンク解決エラーがスローされます。 このタスクでは、 Helper



への参照は「 A



」の出力後にのみ必要なので、正解はBです







良い例は、説明されている動作を示しています。 手動操作によって取得された検証不可能なバイトコード。







Nikita Lipsky別名pjBoomsJBreak 2018 (これまでのスライドのみ)およびJPoint 2017ビデオがあります )でJavaバイトコード検証について詳しく説明しました。







おわりに



会議ではアセンブラーのタイプに怖がっていた人もいましたが、JVMの複雑さに没頭することを決めた人がかなりいました:私たちのスタンドにタスクを渡すすべての人に、バイトコードの複雑さ、検証者、暗黙の例外についてのエクスプレスコースを実施しました。 解決策を読んで、 新しいことを学んでほしい もしそうなら、私たちの目標は達成されました!








All Articles