paint-brush
Javaスレッドプリミティブの再学習ガイド@shai.almog
761 測定値
761 測定値

Javaスレッドプリミティブの再学習ガイド

Shai Almog8m2023/04/11
Read on Terminal Reader

長すぎる; 読むには

Synchronized は革命的であり、今でも素晴らしい用途があります。しかし、より新しいスレッド プリミティブに移行し、場合によってはコア ロジックを再考する時が来ました。
featured image - Javaスレッドプリミティブの再学習ガイド
Shai Almog HackerNoon profile picture

私は最初のベータ版から Java でコーディングしてきました。当時でさえ、スレッドはお気に入りの機能のリストの一番上にありました。 Java は、言語自体にスレッド サポートを導入した最初の言語です。当時は物議を醸した決定でした。


過去 10 年間、すべての言語は async/await を含めようと競い合い、 Java でさえサードパーティがそれをサポートしていました… しかし、Java はザグする代わりにジグザグに進み、はるかに優れた仮想スレッド (プロジェクト Loom)を導入しました。この投稿はそれについてではありません。


それは素晴らしく、Java の核となる力を証明していると思います。言語としてだけでなく、文化として。流行に飛びつくのではなく、じっくり考えて変化していく文化。


この投稿では、Java でスレッド化を行う古い方法を再検討したいと思います。私はsynchronizedwaitnotifyなどに慣れていますが、それらが Java でのスレッド化の優れたアプローチであったのはかなり昔のことです。


私は問題の一部です。私はまだこれらのアプローチに慣れていて、Java 5 以降に存在するいくつかの API に慣れるのが難しいと感じています。これは習慣の力です。 T


ここのビデオで説明するスレッドを操作するための優れた API はたくさんありますが、基本的でありながら重要なロックについてお話したいと思います。

Synchronized vs. ReentrantLock

シンクロナイズドを残すことに私が抵抗したのは、代替手段があまり優れていないということです。今日それをやめた主な動機は、現時点では、同期が Loom でスレッドの固定を引き起こす可能性があることです。これは理想的ではありません。


JDK 21 ではこれが修正される可能性がありますが (Loom が GA になった場合)、そのままにしておくことには意味があります。


synchronized の直接の代替は ReentrantLock です。残念ながら、ReentrantLock には同期に勝る利点がほとんどないため、移行の利点はせいぜい疑わしいものです。


実際、これには大きな欠点が 1 つあります。それを理解するために、例を見てみましょう。これは、同期を使用する方法です。

 synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }


ReentrantLockの最初の欠点は冗長性です。ブロック内で例外が発生した場合、ロックが維持されるため、try ブロックが必要です。私たちにとってシームレスに同期されたハンドル。


ロックをAutoClosableでラップするというトリックがあります。これはおおよそ次のようになります。

 public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }


理想的な Lock インターフェイスを実装していないことに注意してください。これは、lock メソッドがvoidではなく自動クローズ可能な実装を返すためです。


それができたら、次のようなより簡潔なコードを書くことができます。

 try(LOCK.lock()) { // safe code }


簡潔な冗長性は気に入っていますが、try-with-resource はクリーンアップを目的として設計されており、ロックを再利用しているため、これは問題のある概念です。これは close を呼び出していますが、同じオブジェクトでそのメソッドを再度呼び出します。


ロック インターフェースをサポートするために try with resource 構文を拡張するとよいと思います。しかし、それが起こるまで、これは価値のあるトリックではないかもしれません.

ReentrantLock の利点

ReentrantLock使用する最大の理由は、Loom のサポートです。他の利点は優れていますが、どれも「キラー機能」ではありません.


連続ブロックではなく、メソッド間で使用できます。ロック領域を最小限に抑えたいので、これはおそらく悪い考えであり、失敗が問題になる可能性があります。私はその機能を利点とは考えていません。


公平性のオプションがあります。これは、最初にロックで停止した最初のスレッドを処理することを意味します。私は、これが問題になる現実的な非複雑な使用例を考えようとしましたが、空白を描いています。


多くのスレッドが常にリソースのキューに入れられている複雑なスケジューラを作成している場合、他のスレッドが入ってくるため、スレッドが「枯渇」する状況が発生する可能性があります。 .


多分私はここで何かを見逃しています...


lockInterruptibly()使用すると、ロックを待機しているスレッドを中断できます。これは興味深い機能ですが、現実的に違いを生む状況を見つけるのは困難です。


割り込みに対して非常に応答性が高くなければならないコードを作成する場合は、その機能を得るためにlockInterruptibly() API を使用する必要があります。しかし、平均してlock()メソッド内でどれくらいの時間を費やしていますか?


高度なマルチスレッド コードを実行している場合でも、これがおそらく重要であるが、ほとんどの人が遭遇するようなものではない、エッジ ケースがあります。

ReadWriteReentrantLock

もっと良いアプローチはReadWriteReentrantLockです。ほとんどのリソースは、頻繁な読み取り操作と少数の書き込み操作の原則に従います。変数の読み取りはスレッドセーフであるため、変数への書き込み処理中でない限り、ロックは必要ありません。


これは、書き込み操作をわずかに遅くしながら、読み取りを極端に最適化できることを意味します。


これがユースケースであると仮定すると、はるかに高速なコードを作成できます。読み取り/書き込みロックを使用する場合、2 つのロックがあります。次の図に示すように、読み取りロック。複数のスレッドを通過させ、事実上「すべての人に無料」です。


変数に書き込む必要がある場合は、次の図に示すように、書き込みロックを取得する必要があります。書き込みロックを要求しようとしましたが、まだ変数から読み取っているスレッドがあるため、待機する必要があります。


スレッドが読み取りを完了すると、すべての読み取りがブロックされ、次の図に示すように、単一のスレッドからのみ書き込み操作を行うことができます。書き込みロックを解放すると、最初のイメージの「すべての人に無料」の状況に戻ります。


これは、コレクションを大幅に高速化するために活用できる強力なパターンです。典型的な同期リストは非常に遅いです。読み取りまたは書き込みのすべての操作で同期します。読み取りは高速ですが、書き込みは非常に遅い CopyOnWriteArrayList があります。


メソッドから反復子を返さないようにできると仮定すると、リスト操作をカプセル化してこの API を使用できます。


たとえば、次のコードでは、名前のリストを読み取り専用として公開していますが、名前を追加する必要がある場合は、書き込みロックを使用します。これは、 synchronizedリストよりも簡単に優れています。

 private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }

刻印ロック

StampedLockについて最初に理解する必要があるのは、再入可能ではないということです。次のブロックがあるとします。

 synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }

これは機能します。同期されているため、再入可能です。すでにロックを保持しているため、 methodB()からmethodA() ) に入ってもブロックされません。これは、同じロックまたは同じ同期オブジェクトを使用すると仮定して、ReentrantLock でも機能します。


StampedLockロックを解除するために使用するスタンプを返します。そのため、いくつかの制限があります。しかし、それでも非常に高速で強力です。これには、共有リソースを保護するために使用できる読み取りと書き込みのスタンプも含まれています。


ただしReadWriteReentrantLock,ロックをアップグレードできます。なぜそれをする必要があるのでしょうか?

先ほどのaddName()メソッドを見てください…「Shai」で2回呼び出したらどうなるでしょうか?


はい、Set を使用できます…しかし、この演習の要点として、リストが必要だとしましょう… ReadWriteReentrantLockを使用してそのロジックを記述できます。

 public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }


これはひどい。場合によっては(多くの重複があると仮定して) contains()をチェックするためだけに書き込みロックを「支払い」ました。書き込みロックを取得する前にisInList(name)を呼び出すことができます。次に、次のことを行います。


  • 読み取りロックを取得する


  • 読み取りロックを解除する


  • 書き込みロックを取得する


  • 書き込みロックを解除する


どちらのグラブの場合でも、キューに入れられる可能性があり、余分な手間をかける価値がない場合があります。


StampedLockを使用すると、読み取りロックを書き込みロックに更新し、必要に応じてその場で変更を行うことができます。

 public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }

これは、これらのケースに対する強力な最適化です。

ついに

上記のビデオ シリーズでは、多くの同様のテーマについて取り上げています。それをチェックして、あなたの考えを教えてください。


私はしばしば、考え直さずに同期コレクションに手を伸ばします。それが合理的な場合もありますが、ほとんどの場合、最適ではない可能性があります。スレッド関連のプリミティブに少し時間を費やすことで、パフォーマンスを大幅に向上させることができます。


これは、根本的な競合がはるかにデリケートな Loom を扱う場合に特に当てはまります。 1M の同時スレッドで読み取り操作をスケーリングすることを想像してみてください。そのような場合、ロックの競合を減らすことの重要性ははるかに大きくなります。


synchronizedコレクションがReadWriteReentrantLockStampedLockを使用できないのはなぜだと思うかもしれません。


これは、API の表面積が非常に大きく、一般的なユース ケースに合わせて最適化するのが難しいため、問題があります。ここで、低レベルのプリミティブを制御することが、高スループットとブロッキング コードの違いを生む可能性があります。