バギーJavaコード:Java開発者が犯す最も一般的な間違いトップ10

公開: 2022-03-11

Javaは、当初はインタラクティブテレビ用に開発されたプログラミング言語ですが、時間の経過とともに、ソフトウェアを使用できるあらゆる場所に普及するようになりました。 オブジェクト指向プログラミングの概念を使用して設計され、CまたはC ++、ガベージコレクション、アーキテクチャに依存しない仮想マシンなどの他の言語の複雑さを排除して、Javaは新しいプログラミング方法を作成しました。 さらに、それは穏やかな学習曲線を持っており、それ自体のモットーにうまく準拠しているように見えます-「一度書いて、どこでも実行する」、これはほとんど常に真実です。 しかし、Javaの問題はまだ存在しています。 私は、最も一般的な間違いであると思う10のJavaの問題に対処します。

よくある間違い#1:既存のライブラリを無視する

Javaで書かれた無数のライブラリを無視することは、Java開発者にとって間違いなく間違いです。 車輪の再発明を行う前に、利用可能なライブラリを検索してみてください。それらの多くは、長年にわたって洗練されており、自由に使用できます。 これらは、logbackやLog4jなどのロギングライブラリ、またはNettyやAkkaなどのネットワーク関連ライブラリである可能性があります。 Joda-Timeなどの一部のライブラリは、デファクトスタンダードになっています。

以下は、私の以前のプロジェクトの1つからの個人的な経験です。 HTMLエスケープを担当するコードの部分は、最初から作成されました。 それは何年もの間うまく機能していましたが、最終的にはユーザー入力に遭遇し、無限ループに陥りました。 ユーザーは、サービスが応答しないことを検出し、同じ入力で再試行しようとしました。 最終的に、このアプリケーションに割り当てられたサーバー上のすべてのCPUは、この無限ループによって占有されていました。 この素朴なHTMLエスケープツールの作成者が、Google GuavaのHtmlEscapersなど、HTMLエスケープに使用できるよく知られたライブラリの1つを使用することを決定した場合、これはおそらく発生しなかったでしょう。 少なくとも、背後にコミュニティがある最も人気のあるライブラリに当てはまりますが、このライブラリのコミュニティによってエラーが以前に発見され、修正されていたはずです。

よくある間違い#2:Switch-Caseブロックに「break」キーワードがない

これらのJavaの問題は非常に恥ずかしいものであり、本番環境で実行されるまで発見されない場合があります。 多くの場合、switchステートメントのフォールスルー動作が役立ちます。 ただし、そのような動作が望ましくないときに「break」キーワードを見逃すと、悲惨な結果につながる可能性があります。 以下のコード例で「case0」に「break」を入れるのを忘れた場合、プログラムは「Zero」の後に「One」を書き込みます。これは、ここでの制御フローが「switch」ステートメント全体を通過するためです。それは「休憩」に達します。 例えば:

 public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }

ほとんどの場合、よりクリーンな解決策は、ポリモーフィズムを使用し、特定の動作を持つコードを別々のクラスに移動することです。 このようなJavaの間違いは、FindBugsやPMDなどの静的コードアナライザーを使用して検出できます。

よくある間違い#3:リソースを解放するのを忘れる

プログラムがファイルまたはネットワーク接続を開くたびに、Javaの初心者は、使用が終わったらリソースを解放することが重要です。 そのようなリソースの操作中に例外がスローされた場合も、同様の注意が必要です。 FileInputStreamには、ガベージコレクションイベントでclose()メソッドを呼び出すファイナライザーがあると主張する人もいるかもしれません。 ただし、ガベージコレクションサイクルがいつ開始されるかわからないため、入力ストリームはコンピュータリソースを無期限に消費する可能性があります。 実際、Java 7では、特にこの場合に、try-with-resourcesと呼ばれる非常に便利できちんとしたステートメントが導入されています。

 private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }

このステートメントは、AutoClosableインターフェースを実装する任意のオブジェクトで使用できます。 これにより、ステートメントの終わりまでに各リソースが確実に閉じられます。

関連: 8つの重要なJavaインタビューの質問

よくある間違い#4:メモリリーク

Javaは自動メモリ管理を使用します。手動でメモリを割り当てたり解放したりすることを忘れても安心ですが、初心者のJava開発者がアプリケーションでのメモリの使用方法を認識してはならないという意味ではありません。 メモリ割り当ての問題は依然として発生する可能性があります。 プログラムが不要になったオブジェクトへの参照を作成する限り、それは解放されません。 ある意味では、このメモリリークと呼ぶことができます。 Javaでのメモリリークはさまざまな方法で発生する可能性がありますが、最も一般的な理由は、オブジェクトへの参照が残っている間はガベージコレクタがヒープからオブジェクトを削除できないため、オブジェクト参照が永続することです。 オブジェクトのコレクションを含む静的フィールドでクラスを定義し、コレクションが不要になった後でその静的フィールドをnullに設定するのを忘れることで、このような参照を作成できます。 静的フィールドはGCルートと見なされ、収集されることはありません。

このようなメモリリークの背後にあるもう1つの潜在的な理由は、相互に参照するオブジェクトのグループであり、循環依存を引き起こし、ガベージコレクターが相互依存参照を持つこれらのオブジェクトが必要かどうかを判断できないようにします。 もう1つの問題は、JNIを使​​用した場合の非ヒープメモリのリークです。

プリミティブリークの例は、次のようになります。

 final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }

この例では、2つのスケジュールされたタスクを作成します。 最初のタスクは、「numbers」と呼ばれる両端キューから最後の数値を取得し、数値が51で割り切れる場合に備えて、数値と両端キューのサイズを出力します。2番目のタスクは、数値を両端キューに入れます。 両方のタスクは固定レートでスケジュールされ、10ミリ秒ごとに実行されます。 コードを実行すると、両端キューのサイズが永続的に増加していることがわかります。 これにより、最終的に、使用可能なすべてのヒープメモリを消費するオブジェクトで両端キューがいっぱいになります。 このプログラムのセマンティクスを維持しながらこれを防ぐために、両端キューから数値を取得するための別の方法「pollLast」を使用できます。 メソッド「peekLast」とは異なり、「pollLast」は要素を返し、両端キューから削除しますが、「peekLast」は最後の要素のみを返します。

Javaでのメモリリークの詳細については、この問題をわかりやすく説明した記事を参照してください。

よくある間違い#5:過剰なゴミの割り当て

プログラムが短期間のオブジェクトを多数作成すると、過剰なガベージ割り当てが発生する可能性があります。 ガベージコレクタは継続的に機能し、不要なオブジェクトをメモリから削除します。これは、アプリケーションのパフォーマンスに悪影響を及ぼします。 1つの簡単な例:

 String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));

Java開発では、文字列は不変です。 したがって、反復ごとに新しい文字列が作成されます。 これに対処するには、可変のStringBuilderを使用する必要があります。

 StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));

最初のバージョンの実行にはかなりの時間がかかりますが、StringBuilderを使用するバージョンでは、大幅に短い時間で結果が生成されます。

よくある間違い#6:必要のないヌル参照の使用

nullの過度の使用を避けることは良い習慣です。 たとえば、NullPointerExceptionを防ぐのに役立つため、nullではなくメソッドから空の配列またはコレクションを返すことをお勧めします。

以下に示すように、別のメソッドから取得したコレクションをトラバースする次のメソッドについて考えてみます。

 List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }

ユーザーがアカウントを持っていないときにgetAccountIds()がnullを返すと、NullPointerExceptionが発生します。 これを修正するには、ヌルチェックが必要になります。 ただし、nullの代わりに空のリストが返される場合は、NullPointerExceptionは問題ではなくなります。 さらに、変数accountIdsをnullチェックする必要がないため、コードはよりクリーンになります。

nullを回避したい他のケースに対処するために、さまざまな戦略を使用できます。 これらの戦略の1つは、空のオブジェクトまたは何らかの値のラップのいずれかであるオプション型を使用することです。

 Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }

実際、Java8はより簡潔なソリューションを提供します。

 Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);

オプション型はバージョン8からJavaの一部ですが、関数型プログラミングの世界では長い間よく知られています。 これ以前は、以前のバージョンのJava用にGoogleGuavaで利用可能でした。

よくある間違い#7:例外を無視する

多くの場合、例外を未処理のままにしておくことをお勧めします。 ただし、初心者と経験豊富なJava開発者の両方にとってのベストプラクティスは、それらを処理することです。 例外は意図的にスローされるため、ほとんどの場合、これらの例外の原因となる問題に対処する必要があります。 これらのイベントを見落とさないでください。 必要に応じて、それを再スローするか、ユーザーにエラーダイアログを表示するか、ログにメッセージを追加することができます。 少なくとも、他の開発者に理由を知らせるために、例外が未処理のままになっている理由を説明する必要があります。

 selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }

例外の重要性を強調するより明確な方法は、次のように、このメッセージを例外の変数名にエンコードすることです。

 try { selfie.delete(); } catch (NullPointerException unimportant) { }

よくある間違い#8:同時変更の例外

この例外は、イテレータオブジェクトによって提供されるメソッド以外のメソッドを使用してコレクションを反復処理しているときにコレクションが変更された場合に発生します。 たとえば、帽子のリストがあり、イヤーフラップのある帽子をすべて削除します。

 List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }

このコードを実行すると、コードがコレクションを反復処理しながら変更するため、「ConcurrentModificationException」が発生します。 同じリストで動作している複数のスレッドの1つがコレクションを変更しようとしているときに、他のスレッドがコレクションを反復処理している場合、同じ例外が発生する可能性があります。 複数のスレッドでのコレクションの同時変更は当然のことですが、同期ロック、同時変更に採用された特殊コレクションなど、同時プログラミングツールボックスの通常のツールで処理する必要があります。このJavaの問題の解決方法には微妙な違いがあります。シングルスレッドの場合とマルチスレッドの場合。 以下は、シングルスレッドシナリオでこれを処理できるいくつかの方法の簡単な説明です。

オブジェクトを収集し、別のループで削除します

後で別のループ内から削除するためにリストにイヤーフラップ付きの帽子を収集することは明らかな解決策ですが、削除する帽子を保存するための追加の収集が必要です。

 List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }

Iterator.removeメソッドを使用します

このアプローチはより簡潔であり、追加のコレクションを作成する必要はありません。

 Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }

ListIteratorのメソッドを使用する

変更されたコレクションがリストインターフェイスを実装する場合は、リストイテレータを使用するのが適切です。 ListIteratorインターフェースを実装するイテレーターは、削除操作だけでなく、追加および設定操作もサポートします。 ListIteratorはIteratorインターフェースを実装しているため、例はIteratorremoveメソッドとほぼ同じように見えます。 唯一の違いは、ハットイテレータのタイプと、「listIterator()」メソッドを使用してそのイテレータを取得する方法です。 以下のスニペットは、「ListIterator.remove」メソッドと「ListIterator.add」メソッドを使用して、各帽子をソンブレロ付きのイヤーフラップに置き換える方法を示しています。

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }

ListIteratorを使用すると、removeおよびaddメソッド呼び出しを単一のset呼び出しに置き換えることができます。

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }

Java8で導入されたストリームメソッドを使用するJava8では、プログラマーはコレクションをストリームに変換し、いくつかの基準に従ってそのストリームをフィルタリングすることができます。 これは、ストリームAPIが帽子をフィルタリングして「ConcurrentModificationException」を回避するのにどのように役立つかを示す例です。

 hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));

「Collectors.toCollection」メソッドは、フィルターされたハットを使用して新しいArrayListを作成します。 これは、フィルタリング条件が多数のアイテムによって満たされ、結果としてArrayListが大きくなる場合に問題になる可能性があります。 したがって、注意して使用する必要があります。 Java8で提示されたList.removeIfメソッドを使用するJava8で利用可能な別のソリューションであり、明らかに最も簡潔なのは、「removeIf」メソッドの使用です。

 hats.removeIf(IHat::hasEarFlaps);

それでおしまい。 内部では、「Iterator.remove」を使用して動作を実行します。

専門のコレクションを使用する

最初に「ArrayList」の代わりに「CopyOnWriteArrayList」を使用することにした場合、「CopyOnWriteArrayList」は変更されない変更メソッド(set、add、removeなど)を提供するため、まったく問題はありませんでした。コレクションのバッキング配列ではなく、コレクションの新しい変更バージョンを作成します。 これにより、「ConcurrentModificationException」のリスクなしに、コレクションの元のバージョンとその変更を同時に繰り返すことができます。 そのコレクションの欠点は明らかです。変更するたびに新しいコレクションが生成されます。

「CopyOnWriteSet」や「ConcurrentHashMap」など、さまざまなケースに合わせて調整された他のコレクションがあります。

コレクションの同時変更で発生する可能性のあるもう1つの間違いは、コレクションからストリームを作成し、ストリームの反復中にバッキングコレクションを変更することです。 ストリームの一般的なルールは、ストリームのクエリ中に基になるコレクションが変更されないようにすることです。 次の例は、ストリームを処理する誤った方法を示しています。

 List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));

メソッドpeekはすべての要素を収集し、それぞれに対して提供されたアクションを実行します。 ここで、アクションは基になるリストから要素を削除しようとしていますが、これは誤りです。 これを回避するには、上記の方法のいくつかを試してください。

よくある間違い#9:契約を破る

標準ライブラリまたはサードパーティベンダーによって提供されるコードは、動作させるために従う必要のあるルールに依存している場合があります。 たとえば、hashCodeとequalsコントラクトを使用すると、Javaコレクションフレームワークからのコレクションのセット、およびhashCodeとequalsメソッドを使用する他のクラスの動作が保証されます。 コントラクトに従わないことは、常に例外を引き起こしたり、コードのコンパイルを中断したりするようなエラーではありません。 危険の兆候なしにアプリケーションの動作を変更することがあるため、より注意が必要です。 誤ったコードは製品リリースに組み込まれ、望ましくない影響を大量に引き起こす可能性があります。 これには、UIの動作不良、データレポートの誤り、アプリケーションパフォーマンスの低下、データ損失などが含まれる可能性があります。 幸いなことに、これらの悲惨なバグはあまり発生しません。 hashCodeとequalsコントラクトについてはすでに説明しました。 これは、HashMapやHashSetなどのオブジェクトのハッシュと比較に依存するコレクションで使用されます。 簡単に言えば、契約には2つのルールが含まれています。

  • 2つのオブジェクトが等しい場合、それらのハッシュコードは等しくなければなりません。
  • 2つのオブジェクトが同じハッシュコードを持っている場合、それらは等しい場合と等しくない場合があります。

コントラクトの最初のルールに違反すると、ハッシュマップからオブジェクトを取得しようとするときに問題が発生します。 2番目のルールは、同じハッシュコードを持つオブジェクトが必ずしも等しいとは限らないことを意味します。 最初のルールを破った場合の影響を調べてみましょう。

 public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }

ご覧のとおり、クラスBoatはequalsメソッドとhashCodeメソッドをオーバーライドしています。 ただし、hashCodeは、呼び出されるたびに同じオブジェクトのランダムな値を返すため、コントラクトが破られています。 次のコードは、以前にその種のボートを追加したにもかかわらず、ハッシュセットに「Enterprise」という名前のボートが見つからない可能性があります。

 public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }

契約の別の例には、finalizeメソッドが含まれます。 これは、その機能を説明する公式のJavaドキュメントからの引用です。

ファイナライズの一般的な契約は、JavaTM仮想マシンが、(まだ停止していない)スレッドがこのオブジェクトにアクセスできる手段がなくなったと判断した場合に呼び出されることです。ファイナライズの準備ができている他のオブジェクトまたはクラスのファイナライズによって実行されるアクション。 finalizeメソッドは、このオブジェクトを他のスレッドで再び使用できるようにするなど、任意のアクションを実行できます。 ただし、ファイナライズの通常の目的は、オブジェクトが取り返しのつかないほど破棄される前にクリーンアップアクションを実行することです。 たとえば、入出力接続を表すオブジェクトのfinalizeメソッドは、オブジェクトが完全に破棄される前に、明示的なI/Oトランザクションを実行して接続を切断する場合があります。

ファイルハンドラーなどのリソースを解放するためにfinalizeメソッドを使用することを決定することもできますが、それは悪い考えです。 これは、ガベージコレクション中に呼び出されるため、finalizeがいつ呼び出されるかについての時間保証がなく、GCの時間が決定できないためです。

よくある間違い#10:パラメーター化されたタイプの代わりにRawタイプを使用する

Java仕様によると、raw型は、パラメーター化されていない型、またはRのスーパークラスまたはスーパーインターフェイスから継承されていないクラスRの非静的メンバーです。ジェネリック型がJavaに導入されるまで、raw型に代わるものはありませんでした。 。 バージョン1.5以降、ジェネリックプログラミングをサポートしており、ジェネリックは間違いなく大幅に改善されています。 ただし、下位互換性の理由により、型システムを壊す可能性のある落とし穴が残されています。 次の例を見てみましょう。

 List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

ここに、生のArrayListとして定義された数値のリストがあります。 その型はtypeパラメータで指定されていないため、任意のオブジェクトを追加できます。 しかし、最後の行では、要素をintにキャストし、それを2倍にして、2倍にした数値を標準出力に出力します。 このコードはエラーなしでコンパイルされますが、実行すると、文字列を整数にキャストしようとしたため、実行時例外が発生します。 明らかに、型システムは、必要な情報を隠してしまうと、安全なコードを書くのに役立ちません。 この問題を修正するには、コレクションに保存するオブジェクトのタイプを指定する必要があります。

 List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

オリジナルとの唯一の違いは、コレクションを定義する行です。

 List<Integer> listOfNumbers = new ArrayList<>();

整数のみを格納することが期待されるコレクションに文字列を追加しようとしているため、修正されたコードはコンパイルされませんでした。 コンパイラはエラーを表示し、文字列「Twenty」をリストに追加しようとしている行を指し示します。 ジェネリック型をパラメーター化することは常に良い考えです。 このようにして、コンパイラーは可能なすべての型チェックを行うことができ、型システムの不整合によって引き起こされる実行時例外の可能性が最小限に抑えられます。

結論

プラットフォームとしてのJavaは、洗練されたJVMと言語自体の両方に依存して、ソフトウェア開発の多くのことを簡素化します。 ただし、手動のメモリ管理や適切なOOPツールの削除などの機能は、すべての問題を排除するわけではなく、通常のJava開発者が直面する問題です。 いつものように、このような知識、実践、およびJavaチュートリアルは、アプリケーションエラーを回避して対処するための最良の手段です。したがって、ライブラリを理解し、Javaを読み、JVMドキュメントを読み、プログラムを作成します。 静的コードアナライザーも、実際のバグを指摘し、潜在的なバグを強調する可能性があるため、忘れないでください。

関連:高度なJavaクラスチュートリアル:クラスのリロードのガイド