Javaコードを最適化するためのヒント:熊手を踏まないようにする方法

こんばんは、同僚。



本日ご紹介する記事の翻訳は、「Javaコードの最適化に関する書籍全体が必要ですか?」という質問への回答に役立つように設計されています。 この資料があなたにとって興味深く見えるだけでなく、実際に役立つことを願っています。 投票することを忘れないでください。



この記事では、Javaコードを最適化するためのいくつかのヒントを概説します。 実際のJavaプログラムでの特定の操作を具体的に検討します。 これらのヒントは、本質的に、高いパフォーマンスを必要とする特定のシナリオに適用できます。したがって、通常、速度の向上はわずかであるため、この方法ですべてのコードを記述する必要はまったくありません。 ただし、最も暑い地域では、違いが大きくなる可能性があります。



プロファイラーを使用してください!



最適化に着手する前に、開発者はパフォーマンスを正しく評価することを確認する必要があります。 スローダウンしているように見えるコードの一部は、実際にはスリップの真のソースをマスクしているだけなので、「明示的な」遅延ソースをいくら最適化しても、効果はほとんどゼロになります。 さらに、最適化が効果をもたらすかどうか、もしそうならどの効果を比較するかを比較できるコントロールポイントを選択する必要があります。



これらの両方の目標を達成するには、プロファイラーを使用するのが最も便利です。 コードのどの部分の実行が遅いか、そのコードの実行にかかる時間を判断するツールを提供します。 VisualVM (無料)とJProfiler (有料-しかし絶対にお金に見合う)の2つのプロファイラーをお勧めできます。



この情報があれば、必要なコードを正確に最適化していること、および加えた変更の効果を測定できることを保証できます。

一歩下がって、問題へのアプローチ方法を検討しましょう。



特定のコード実行パスのポイント最適化に移行する前に、コードが現在どのように実行されているかを考える必要があります。 選択したアプローチに根本的な欠陥がある場合があります。たとえば、信じられないほどの努力とすべての可能な最適化を犠牲にしてこのコードを25%スピードアップできますが、アプローチを変更する(別のアルゴリズムを選択する)と、コードの実行を1桁以上加速することができます。 多くの場合、これは、処理する必要があるデータの規模が劇的に変化したときに発生します。 この特定のケースで機能するソリューションを作成するのは簡単ですが、実際のデータを使用するのには適さない場合があります。



解決策が簡単な場合もあります-データを保存する構造を変更するだけです。 想像上の例を次に示します。プログラムが通常ランダムな順序でデータにアクセスし、それをLinkedList



に保存する場合、 ArrayList



に切り替えるだけで十分にコードが実行されます。 大規模なデータセットを操作し、生産性が重要な問題を解決する場合、データの形式と実行される操作に一致する適切なデータ構造を選択することが非常に重要です。



常に振り返って考えることをお勧めします:最適化しようとしているコードはそれ自体で効果的であるか、それが不器用に書かれているか、実行するための最良の方法ではないために遅くなります。



ストリーミングAPIと古き良きforループの比較



ストリームはJava言語の驚くべき革新であり、コードのジャンクフラグメントを簡単にやり直し、信頼性の高い実行を保証するより普遍的で再利用可能なコードブロックを優先してループを放棄します。 ただし、そのような設備には支払いが必要です。スレッドを使用すると、パフォーマンスが低下します。 幸いなことに、この価格は明らかに高すぎません。 最も一般的な操作の場合、数パーセントの加速と10〜30%の減速の両方を得ることができますが、この点に留意する必要があります。



99%の場合、スレッドを使用したときのパフォーマンスの低下は、コードがより明確になるという事実によって補われます。 ただし、スレッドがおそらく非常にアクティブなサイクルで使用される場合の1%の場合、パフォーマンスを優先して妥協点を検討する必要があります。 これは、特に高帯域幅のアプリケーションに当てはまります。ストリーミングAPIでの作業はアクティブなメモリ割り当てに関連付けられていると思わせます(StackOverflowのこのスレッドでは、新しいフィルタがそれぞれ88バイトのメモリを消費することを読んでいます)。 この場合、ガベージコレクタをより頻繁に実行する必要があり、パフォーマンスに非常に悪影響を及ぼす。



並列スレッドでは別の話です。 それらを使用するのは非常に簡単ですが、まれにしか使用せず、並列操作と順次操作のプロファイリングによって並列処理が高速であることを確認した後にのみ使用してください。 小さなデータセットで作業する場合(データセットのサイズは、作業中のストリーム操作のコストに応じて決定されます)、タスクを分散し、他のスレッド間でタスクをスケジュールし、ストリームの処理が完了した後に結果をステッチするコストは、かなり重複します計算の並列化によって達成される速度の向上。



また、コードが実行される正確な環境にも注意を払う必要があります。 高度な並列環境(たとえば、サイト)について話している場合、別のストリームを追加して作業を高速化することはできません。 実際、高負荷では、このような状況は非並列実行よりもさらに悪質になる可能性があります。 事実、ワークロードが本質的に並列である場合、プログラムは残りのプロセッサコアを可能な限り効率的に使用する可能性が高いということです。つまり、タスクの分割にリソースを浪費しているため、処理能力が増えません。



一連の制御測定を行いました。 testList



は100,000の要素の配列で、 testList



の数字で構成され、文字列に変換されてから混合されます。



 // ~1 500 / public void testStream(ArrayState state) { List<String> collect = state.testList .stream() .filter(s -> s.length() > 5) .map(s -> "Value: " + s) .sorted(String::compareTo) .collect(Collectors.toList()); } // ~1 500 / public void testFor(ArrayState state) { ArrayList<String> results = new ArrayList<>(); for (int i = 0;i < state.testList.size();i++) { String s = state.testList.get(i); if (s.length() > 5) { results.add("Value: " + s); } } results.sort(String::compareTo); } // ~8 000 / //  :     10 000            testStream public void testStreamParrallel(ArrayState state) { List<String> collect = state.testList .stream() .parallel() .filter(s -> s.length() > 5) .map(s -> "Value: " + s) .sorted(String::compareTo) .collect(Collectors.toList()); }
      
      





そのため、スレッドはコードのサポートに役立ち、可読性を向上させます。ほとんどの場合、これはパフォーマンスを無視します。 ただし、ロードされたサイクルからドロップまでのすべてのパフォーマンスを実際に絞る必要があるまれなケースでは、考えられるコストを考慮する必要があります。



日付の転送とそれによる操作



たとえば、日付文字列を日付オブジェクトに解析するとき、および日付オブジェクトを日付文字列にフォーマットするときに発生するコストを過小評価しないでください。 100万個のオブジェクトのリストがある場合を想像してください(これらは通常の行または行がサポートするデータフィールドの形式で要素を表すオブジェクトのいずれかです)-リスト全体を特定の日付に調整する必要があります。 この日付が文字列として表示される場合、まずこの文字列を解析してDateオブジェクトに変換し、Dateオブジェクトを更新してから、再度文字列としてフォーマットする必要があります。 日付がすでにUnixタイムスタンプとして(または、実際には、Unixタイムスタンプの単なるラッパーであるDate



オブジェクトとして)提示されている場合、単純な算術演算、加算または減算を行う必要があります。



私のテストでは、日付オブジェクトを操作した場合、解析して文字列に変換したり、文字列に変換したりするよりも、プログラムが最大500倍速く実行されることが示されています。 構文解析段階を単に除外しても、100倍の加速を達成できます。 この例は大げさなように思えるかもしれませんが、日付値が文字列としてデータベースに保存され、API応答で文字列として返されるケースを知っていると確信しています。



 // ~800 000 /c public void dateParsingWithFormat(DateState state) throws ParseException { Date date = state.formatter.parse("20-09-2017 00:00:00"); date = new Date(date.getTime() + 24 * state.oneHour); state.formatter.format(date); } // ~3 200 000 / public void dateLongWithFormat(DateState state) { long newTime = state.time + 24 * state.oneHour; state.formatter.format(new Date(newTime)); } // ~400 000 000 / public long dateLong(DateState state) { long newTime = state.time + 24 * state.oneHour; return newTime; }
      
      





したがって、日付オブジェクトの解析とフォーマットに関連するコストを常に考慮し、文字列として保持する必要がない場合は、日付をUnixタイムスタンプとして提示する方がはるかに合理的です。



文字列操作



文字列の操作は、おそらくどのプログラムでも最も一般的な操作の1つです。 ただし、誤って実行すると、コストが高くなる可能性があります。 このため、Java最適化に関するこの記事で文字列を扱うことに非常に注意を払っています。 以下に、最も一般的な落とし穴の1つを示します。 ただし、このような問題は、最速のコードフラグメントを実行する場合、またはかなりの数の行を処理する必要がある場合にのみ発生することをさらに強調したいと思います。 99%の場合、次のいずれも発生しません。 ただし、このような問題が発生した場合、パフォーマンスに壊滅的な影響を与える可能性があります。



単純な連結が機能する場合にString.format



を使用する



String.forma



tの最も単純な呼び出しは、値を文字列に手動で連結するよりも約100倍遅くなります。 私のマシンでは1秒あたり数百万の操作を処理しているため、これは一般に許容されます。 ただし、数百万の要素で動作するビジーサイクルの場合、パフォーマンスの低下が顕著になります。



ただし、パフォーマンス要件の高い環境であっても、連結ではなく文字列形式を_ _



がある場合が1つあります。ログのデバッグについて説明しています。 このコンテキストで発生する2つの呼び出しを検討してください。



 logger.debug("the value is: " + x); logger.debug("the value is: %d", x);
      
      





生産における2番目のケース(一見すると直感に反するように見えるかもしれません)は、たまたま高速です。 実稼働サーバーでデバッグ情報のロギングが有効になる可能性は低いため、最初の場合、プログラムは新しい行を選択しますが、使用されません(ログは表示されないため)。 2番目のケースでは、定数行をロードする必要があります。その後、フォーマット手順はスキップされます。



 // ~1 300 000 / public String stringFormat() { String foo = "foo"; String formattedString = String.format("%s = %d", foo, 2); return formattedString; } // ~115 000 000 / public String stringConcat() { String foo = "foo"; String concattedString = foo + " = " + 2; return concattedString; }
      
      





ループ内でラインビルダーを使用しない



ループ内でラインビルダーを使用しないと、コードのパフォーマンスが大幅に低下します。 単純化された実装では、 +=



演算子を使用してループ内で行を拡大し、既存の行に新しい行を追加します。 このアプローチの問題は、ループの各反復で新しい行が割り当てられ、各反復で古い行を新しい行にコピーする必要があることです。 この操作自体も、非常に多くの行を作成および破棄するときに必要な追加のガベージコレクションに関連する余分な負荷は言うまでもなく、コストがかかります。 StringBuilder



を使用して、メモリ割り当て操作の数を制限します。これにより、パフォーマンスを大幅に改善できます。 私のテストでは、この方法でプログラムを500倍以上高速化することができました。 ラインビルダーを作成するときに、結果のラインのサイズを少なくとも自信を持って想定できる場合は、事前に正しいサイズを設定することでコードをさらに10%高速化できます(この場合、内部バッファーのサイズを再計算して割り当てを削除する必要はありませんコピー)。



また、(ほとんど) StringBuilder



ではなく、常にStringBuilder



を使用していることに注意してください。 StringBuffer



マルチスレッド環境で動作するように設計されているため、内部同期が装備されています。 このような同期のコストは、シングルスレッド環境でも負担する必要があります。 多くのスレッドからのデータを使用して文字列を拡大する必要がある場合(たとえば、ロギングを使用した実装)-ここでは、 StringBuffer



ではなくStringBuilder



使用する必要がある数少ない状況の1つです。



 // ~11    public String stringAppendLoop() { String s = ""; for (int i = 0;i < 10_000;i++) { if (s.length() > 0) s += ", "; s += "bar"; } return s; } // ~7 000    public String stringAppendBuilderLoop() { StringBuilder sb = new StringBuilder(); for (int i = 0;i < 10_000;i++) { if (sb.length() > 0) sb.append(", "); sb.append("bar"); } return sb.toString(); }
      
      





ループ外でラインビルダーを使用する



ループ外でラインビルダーを使用するというインターネット上の推奨事項に出会いました。これは適切なようです。 しかし、私の実験では、実際には、 StringBuilder



がループ外にある場合でも、このコードは+=



-の場合よりも3倍遅く実行されることが示されました。 このコンテキストで+=



javac



によってStringBuilder



れるStringBuilder



呼び出しに変わりますが、 StringBuilder



直接使用するよりもコードがはるかに高速であることに驚かされました。



誰かがこれが起こる理由のバージョンを持っている場合-コメントで共有してください。



 // ~20 000 000    public String stringAppend() { String s = "foo"; s += ", bar"; s += ", baz"; s += ", qux"; s += ", bar"; s += ", bar"; s += ", bar"; s += ", bar"; s += ", bar"; s += ", bar"; s += ", baz"; s += ", qux"; s += ", baz"; s += ", qux"; s += ", baz"; s += ", qux"; s += ", baz"; s += ", qux"; s += ", baz"; s += ", qux"; s += ", baz"; s += ", qux"; return s; } // ~7 000 000    public String stringAppendBuilder() { StringBuilder sb = new StringBuilder(); sb.append("foo"); sb.append(", bar"); sb.append(", bar"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); sb.append(", baz"); sb.append(", qux"); return sb.toString(); }
      
      





そのため、文字列の作成には明らかなコストが伴うため、この方法は可能な限り避ける必要があります。 これは簡単に実現できます-ループ内でStringBuilder



を使用するだけです。



ここで概説したJavaコードを最適化するためのヒントが役立つことを願っています。 ここでも、ほとんどの場合、ここで説明する手法は役に立たないことを強調します。 文字列のフォーマットを1秒間に何回管理するかは問題ではありません。これらの操作のいくつかを実行する必要がある場合は、100万回または8000万回です。



しかし、これらの重大なケースでは、何百万ものそのような操作について本当に話すことができれば、コードを80倍高速化することで多くの時間を節約できます。



この記事を書いた後、ここで言及したすべてのデータを含むzipアーカイブを収集しました。以下では、すべてのコントロールポイントをチェックした後の出力を示します。 すべての結果は、i5-6500を搭載したPCで取得されます。 コードは、Windows 10上のJDK 1.8.0_144、VM 25.144-b01で始まりました







すべてのコードは、GitHubからダウンロードできます。



All Articles