本文へ移動

CooLWJを検索

选择搜索范围,输入关键词后按回车。

言語

技術を俯瞰する-Javaマルチスレッドの進化史

はじめに

2022年09月22日、JDK19がリリースされました。このバージョン最大の注目点は仮想スレッドへの対応で、軽量スレッドの仲間に新たな有力メンバーが加わりました。仮想スレッドにより、JVMはオペレーティングシステムによるスレッドのスケジューリングという制約から解放され、JVM自身でスレッドをスケジューリングします。実は初期のsunもSolarisオペレーティングシステムの仮想マシンでJVMによるスレッドのスケジューリングを実装したことがありますが、その複雑さと保守性を考慮し、最終的にはオペレーティングシステムがスレッドをスケジューリングする方式に戻りました。

長安から帰った錦衣の客は、昨日、城南に新居を構えた。ここまでの道のりを振り返ると、マルチスレッドに関する概念は目がくらむほど多く、インターネット上の解説も枚挙にいとまがありませんが、全体を見渡す視点が足りないと常々感じていました。そこで筆者は、Java のマルチスレッドの進化の歴史を体系的に整理しました。マルチスレッドの知識を身につける助けになれば幸いです。

本稿で扱わないこと:

1 一部の技術事項の詳しい実装原理は説明せず、ソースコードの分析や図解も行いません。本稿で興味のある概念や技術が見つかったら、自分で検索してください 2 Quasar、Akka、Guavaなど、並行処理を支援するライブラリやフレームワークは扱いません

この記事で扱うこと

1 JDK のマルチスレッドの発展の歴史を説明します 2 発展の過程におけるいくつかの技術的な要点について、機能の仕組みと背景、そしてどのような問題を解決したかを説明します 3 いくつかの技術的な要点に対する筆者の見解を述べます。異なる見解をお持ちの方は、ぜひコメント欄で議論してください

マイルストーン

いつものように、まず一覧表を示します。歴代JDKのスレッドに関する中核概念を整理したものです。ここで、少々不適切かもしれませんが、自動車にたとえてみましょう。マルチスレッドの進化は、マニュアル車の時代(JDK1.4以前)、オートマチック車の時代(JDK5-JDK18)、自動運転の時代(JDK19以降)に対応させることができます。このたとえで伝えたいのは、JDK5以降はマルチスレッドをより楽に扱えるようになり、JDK19以降は単なる快適さを超えて、IO処理の多いサービスの性能に質的な飛躍をもたらしたということです。

時代バージョン公開日時主要な概念
マニュアル運転JDK1.01996-01-23Thread と Runnable
マニュアル運転JDK1.21998-12-04ThreadLocal、Collections
オートマチックJDK1.5/5.02004-09-30Java メモリモデルの明確化、並行処理パッケージの導入
オートマチックJDK1.6/6.02006-12-11synchronizedの最適化
オートマチックJDK1.7/7.02011-07-28Fork/Joinフレームワーク
オートマチックJDK1.8/8.02014-03-18CompletableFuture、Stream
オートマチックJDK1.9/9.02014-09-08ロック競合の仕組みを改善する
オートマチックJDK102018-03-21スレッドローカル管理
オートマチックJDK152020-09-15バイアスロックの無効化と非推奨化
自動運転JDK192022-09-22仮想スレッド

2f98c71c0436418d8d260d75cb921c81~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Lqs5Lic5LqR5byA5Y-R6ICF_q75.webp

マニュアル車の時代

筆者はJDK1.4以前を、マルチスレッドの「マニュアル操作」の時代、あるいはネイティブなマルチスレッドの時代と呼んでいる。スレッドの操作はまだ比較的低水準で、利用できるスレッドプールはなかった。開発者は、スレッドの頻繁な生成によるリソースの浪費を避けるためのツールを自ら書き、共有リソースを手動でロックしなければならなかった。まさにこの時代に、多くの優れたマルチスレッドフレームワークが育まれ、その中で最も有名なものがJDK5.0に採用された。

JDK 1.0 ThreadとRunnable

1996年1月のJDK1.0は、当初からJavaの最も基本的なスレッドモデルを確立していました。その後の修正や改良でも、このスレッドモデルに実質的な変更はなく、継承性のある優れた設計と言えます。プリエンプティブ方式と協調方式は、よく使われる二つのプロセス/スレッドスケジューリング方式です。OSがプロセスをスケジューリングするには、プリエンプティブ方式が非常に適しています。各プロセスにタイムスライスを割り当て、長時間応答しないプロセスからリソースを取り上げたり、強制的に停止させたりすることができます。協調方式では、プロセスが自発的かつ能動的にリソースを解放する必要があります。この方式では、実行時間の長い一つのスレッドによって、CPUを必要とするほかのすべてのスレッドが「飢餓状態」に陥る可能性があります。Java hotspot仮想マシンのスケジューリングはプリエンプティブ方式であり、そのためJava言語は当初からプリエンプティブなスレッドスケジューリングを採用しています。JDK 1.0でスレッドを作成する主な方法は、Threadクラスを継承するか、Runnableインターフェースを実装することです。オブジェクトインスタンスのstartメソッドでスレッドを起動し、並列処理が必要なコードをrunメソッドに記述します。スレッド間の協調や通信には、stop/resume/suspendといった単純で強引なメソッドを使います。

Java スレッドの状態と状態遷移の模式図

stop/resume/suspendの概念は、どう説明すればよいでしょうか? つまり、メインスレッドから子スレッドの停止、一時停止、再開のメソッドを直接呼び出せるということです。子どもの頃に携帯音楽プレーヤーを使ったことがあるなら、停止、一時停止、再開の3つのボタンがあったでしょう。3台の携帯音楽プレーヤーを同時に聴いているところを想像してください。3台のプレーヤーが3つの子スレッドで、あなたがメインスレッドです。この3台の機器の起動と停止を自由に制御できます。

この一連の仕組みには、デッドロックが起きやすいという致命的な問題があります。スレッドAがあるリソースをロックし、まだ解放していないときに、メインスレッドによって一時停止されたとします(suspendメソッドはロックを解放しません)。このとき、スレッドBがそのリソースを取得しようとしても、スレッドAが処理を再開(resume)してリソースを解放するまで待つしかありません。そうでなければ永久に取得できず、デッドロックが発生します。

JDK 1.2

このバージョンでは、強引な stop/resume/suspend の仕組みの使用が禁止され、代わりに wait/notify/sleep のように複数のスレッドが協調して動作する方式が採用されました。特筆すべきは、このバージョンですでにアトミックオブジェクト AtomicityXXX が設計されており、主に i++ がアトミックでないという問題への対処を目的としていたことです。ThreadLocal と Collections の追加によってマルチスレッドの使い方が広がったため、筆者はこの時代を Java のターボ時代と呼んでいます。

ThreadLocal

ThreadLocalは、ロックを使わずに、スレッドセーフではないオブジェクトを複数のスレッドで共有するための仕組みである。「銀行口座や在庫の加算、減算」といった問題を解決するものではなく、「ツール」としての性質を持つクラスを複製し、その「ツール」のメソッドを安全に実行することを得意とする。代表例にはSimpleDateFormatやデータベース接続などがある。その設計が非常に巧妙であることにも触れておきたい。自分で設計するとしたらどうするか、想像してみてほしい。一般的な単純な発想は、ThreadLocal内にグローバルでスレッドセーフなMapを保持し、keyをスレッド、valueを共有オブジェクトとするものだ。この設計にはメモリリークという欠点がある。スレッドが増えるにつれてMapが際限なく膨らむためだ。メモリリークを解消するには、スレッドの終了時にMapをクリーンアップしなければならず、そのためにはGCの能力も強化する必要があり、明らかに費用対効果が見合わない。そこでThreadLocalでは、MapをThreadLocalではなくThread自体が保持する設計になっている。keyはThreadLocal変数、valueは値である。各Threadは使用するThreadLoaclをすべてその中に格納する(もちろん、この設計から派生する別の問題もあるが、ここでは扱わない。興味があれば自分で検索してほしい)。

Collections

Collectionsユーティリティクラスはこのバージョンで設計され、SynchronizedListなどのスレッドセーフなコレクションをラッパーとして提供しました。スレッドセーフなコレクションがHashtable、Vector、Stackなどしかなかった当時、その登場には時代を画する意味がありました。Collectionsユーティリティの基本的な考え方は、スレッドセーフでないコレクションをスレッドセーフなものに包み込むというものです。そのため、既存コードの更新や改修に多くの時間をかける必要はなく、コレクションを作成するときに、提供されるメソッドで初期化するだけで済みます。エンジンの排気量を変えずに出力とトルクを増やす、自動車のターボ技術に似ています。Javaのターボ時代がやってきました^\_^

オートマの時代

JDK 5.0

並行処理パッケージの導入

Doug Lea、中国語名は道格·利。米国の大学教員で、達人級の人物です。J.U.C は彼の手によるものです。JDK1.5 より前は、プログラムの並行アクセスにおける同期コードの制御には synchronized しか使えませんでした。当時の synchronized はまだ性能の最適化が十分でなく、性能はよくありませんでした。スレッドの制御にも Object の wait と notify メソッドしか使えませんでした。そこで Doug Lea は JCP に JSR-166 の提案を提出しました。JSR-166 を提出する以前から、Doug Lea は J.U.C パッケージと同様の機能を持つコードをすでに3年以上使用しており、このコードが J.U.C の原型となりました。

J.U.Cは、アトミックなオブジェクト、ロックと各種ユーティリティ、スレッドプール、スレッドセーフなコンテナーなど、いくつかの主要な種類のツールを提供しています。開発者は各機能を柔軟に使い、自分たちの製品を構築できます。本格的に取り組むならReentrantLockで基盤フレームワークを構築でき、手軽に進めるなら既存のツールやコンテナーをそのまま使って業務コードを書けます。歴史的な視点から見れば、2004年のJ.U.Cは間違いなく「最先端の技術製品」と呼べるものでした。Javaの普及に多大な貢献を果たしました。Javaにオートマチックの時代が到来したのです。オートマチック車が運転のハードルを下げたように、J.U.Cはプログラマーがマルチスレッドを利用するハードルを大幅に下げました。一つの時代を切り開いた製品です。

もちろんJ.U.Cにもいくつかの欠点があります:

CPUのオーバーヘッドが大きい:スピンによるCASが長時間成功しないと、CPUに非常に大きな負荷がかかります。

解決策:JUCでは、一部でCASのスピン回数を制限しています。例えば、BlockingQueueのSynchronousQueueが該当します。

ABA問題:ある値がもともとAで、Bに変わった後、再びAに戻ると、CASによるチェックでは変更されていないと判断されますが、実際には変更されています。これがABA問題です。ほとんどの場合、ABA問題はプログラムの並行処理の正しさに影響しません。

解決策:各変数にバージョン番号を付け、変更のたびに1を加えます。つまり、A —> B —> Aが、1A —> 2B —> 3Aになります。Javaはこの問題を解決するためにAtomicStampedReferenceを提供しています。AtomicStampedReferenceは[E,Integer]のタプルをラップすることで、オブジェクトにバージョンスタンプ(stamp)を付け、ABA問題を回避します。

一つの共有変数に対する操作の原子性しか保証できません:CASの仕組みが保証するのは、一つの変数に対する操作の原子性だけであり、コードブロック全体の原子性は保証できません。

解決策:例えば、3個の変数をまとめてアトミックに更新する必要がある場合は、Synchronized を使わざるを得ません。また、AtomicReference で複数の変数をラップし、この方法で複数の共有変数を扱うことも検討できます。

Java メモリモデルの明確化

このバージョンのJDKでは、Javaメモリモデルが改めて明確に定義された。それ以前の一般的なメモリモデルには、逐次一貫性メモリモデルと先行発生モデルがあった。逐次一貫性モデルでは、プログラムの実行順序はコードに記述された順序と完全に一致する。これを、命令実行の最適化を行う現代のマルチコアCPUで保証するのは非常に難しい。また、逐次一貫性の保証は、JVMによるコードの実行時最適化を大きく制限してしまう。

しかし、このバージョンのJSR 133仕様で定められた先行発生関係(Happens-before)により、命令の実行順序に柔軟性が生まれます:

同じスレッド内では、コードの実行順序(つまりコードの意味上の順序)に従い、前の操作は後の操作に先行して発生します monitorオブジェクトのロック解除操作は、その後の同じmonitorオブジェクトへのロック操作に先行します volatileフィールドへの書き込み操作は、その後の同じフィールドの読み取り操作に先行します スレッドのstart操作(スレッドオブジェクトのstart()メソッドの呼び出し)は、そのスレッドの他のあらゆる操作に先行します あるスレッド内のすべての操作は、他のいかなるスレッドがそのスレッドに対してjoin()メソッドを呼び出すよりも先行します 操作AがBに先行し、操作BがCに先行するなら、操作AはCに先行します

また、メモリ割り当てにおいて各スレッドの作業メモリをメインメモリから独立させることで、JVMにはスレッド内の命令実行を最適化する大きな余地が生まれます。メインメモリ内の変数をスレッドの作業メモリにコピーして個別に処理し、処理終了後、ある時点で結果をメインメモリに書き戻すことができます:しかし、スレッド間のデータの整合性はどう保証するのでしょうか?JLS(Java Language Specification)が示す方法では、デフォルトでは任意の時点でのデータの整合性は保証できませんが、意味規則が強化されたキーワードであるsynchronized、volatile、finalを使用することで、データの整合性を実現できます。

JDK 6.0 synchronizedの最適化

「共和国の長男」であるsynchronizedキーワードは、5.0バージョンではReentrantLockに脚光を奪われました。このバージョンでは挽回しなければなりません。そのため、JDK 6.0では、ロックのスピン、ロック除去、ロック粗粒度化、軽量ロック、バイアスロックなど、ロックにいくつかの最適化が施されました。今回の最適化は、「きめ細かな管理」という理念を体現したものです。最適化前は、synchronizedでロックされるオブジェクトには、ロックなし、ロックあり(重量ロック)の2つの状態しかありませんでした。最適化後はロックに合計4種類の状態があり、低いレベルから順に、ロックなし、バイアスロック、軽量ロック、重量ロックとなります。これらの状態は競合状況に応じて段階的に昇格しますが、降格はできません。目的はロックの取得と解放の効率を高めることです(筆者としては、実際には複雑すぎて、JVMの開発者が尻込みしたのだと思っています)。

この最適化でsynchronizedは面目を施し、以後、ReentrantLockより性能が低いとは誰にも言わせなくなりました。でも、見どころはまだ続きます。偏向ロックはJDK 15で非推奨になったのです(─.─||)。筆者は、synchronizedの不利な点は、それが単なるキーワードであることだと考えています。内部の動作はJVMが担うため、アプリケーションがロックをかける際にどのようなやり方が適しているかは、JVMが「推測」するしかありません。ReentrantLockは違います。この判断をプログラマーに直接委ねています。公平性を望むなら公平ロックを使い、不公平でよければ非公平ロックを使えばよいのです。設計上はある種の手抜きともいえますが、同時に柔軟性でもあります。

JDK 7.0 Fork/Joinフレームワーク

Fork/Joinも、登場当時は比較的先進的な仕組みでした。その最大の強みは、タスクの再帰的な分割をサポートし、各タスクの結果を統合できることにあります。ただし、これはなじみがあるようで、なじみの薄い技術でもあります。なじみがあるというのは、これからJDK8.0で取り上げるCompletableFutureやStreamなど、さまざまな場所で使われているためです。一方、なじみが薄いというのは、業務システムの開発では、私たちが直接使う機会は少ないように思われるためです。

役に立たないとさえ感じている人もいる。筆者の見解では、業務要件に関わる開発をしているなら、これはあまり役に立たない。基本的に使う場面がなく、大量データを扱う場面にはデータウェアハウスのツール群があり、その他の場面ではスレッドプールで代用できるからだ。一方、ミドルウェアやフレームワークの開発に関わっているなら、役に立たないものではなく、使うこともあるだろう。中国語圏のインターネットではこの技術に疑問を呈する人は少ないが、海外ではすでに議論されている。興味があれば、直接リンク先を参照してほしい JavaのFork-Joinフレームワークは壊れているのか?

JDK 8.0

このバージョンのリリースはJavaにとって画期的なもので、現在世界中で稼働しているJavaプログラムの半数以上がこのバージョンを占めるほどです。ただし、マルチスレッド関連の更新はJDK5.0ほど革新的ではありません。このバージョンでは、いくつかのアトミックオブジェクトが追加されたほか、特に目を引くのが次の二つの更新です。

CompletableFuture

インターネットには CompletableFuture に関する紹介が数多くあり、その大半は仕組みや使い方を説明しています。しかし、筆者にはずっと分からないことが一つありました:これほど多くのスレッドプールのツールがある中で、なぜ CompletableFuture が登場したのか、どのような課題を解決したのか?その中核となる強みは、いったい何なのか?皆さんも考えたことがあれば、同じ疑問を抱くはずです。ご安心ください。筆者がすでに答えを見つけておきました。

結論:CompletableFuture の競争力の核心はタスクのオーケストレーション。CompletableFutureはFutureインターフェースの特性を受け継いでおり、タスクの並行実行などが可能ですが、これらの機能には代替手段があります。しかし、そのタスクを組み合わせて実行を制御する能力は代替できません。中核となるAPIに含まれるタスクチェーンの構築やタスク結果の統合などは、いずれもタスクの実行制御のために設計されています。したがって、JDKがこのバージョンでこのフレームワークを導入したのは、主に業務開発でますます切実になっていた、タスクの実行を組み立てて制御する必要に応えるためです。

最後にもう一言、CompletableFutureは内部でFork/Joinフレームワークを使って実装されています。

Stream

『Clean Architecture 達人に学ぶソフトウェアの構造と設計』では、プログラミングパラダイムには、構造化プログラミング(手続き型プログラミング)、オブジェクト指向プログラミング、関数型プログラミングの三つがあると述べられています。Streamは、Java言語における関数型プログラミングの一つの表れです。筆者は、初級プログラマーが中級へ進むうえで避けて通れないのが、Streamを習得することだと考えています。初めてStreamに触れたときは、きっとかなり戸惑うでしょう。しかし、慣れれば、プログラミングの方法について新たな発想が開けます。開発者は、関数型プログラミング、Stream、Lambda式という三つの概念を混同し、同じものを指していると思いがちです。以下は筆者の理解です:

•関数型プログラミングはプログラミングの考え方の一つであり、さまざまなプログラミング言語で実践されています

•StreamはJDK8.0の新機能であり、新しい概念を作ったものとも捉えられます。その目的は関数型プログラミングという考え方に対応することで、Streamという形式を通じてコレクションクラスで関数型プログラミングを実現できます

•Lambda 式(lambda expression)は無名関数であり、関数型プログラミングをより簡潔かつ効率的に表現できる

ここまで説明してきましたが、Streamとマルチスレッドにはどんな関係があるのでしょうか?Streamの並列処理に関するメソッドは、内部でFork/Joinフレームワークを使って実装されています。『Effective Java』には「Streamの並列処理は慎重に使う」という関連する助言があります。その理由は、すべての並列処理が一つの共通Fork/Joinプールで実行されるため、一つのパイプラインの実行に異常が起きると、無関係な他の部分の性能まで損なう可能性があるからです。

JDK 9.0

ロック競合の仕組みを改善する

ロック競合は、多くのJavaマルチスレッドアプリケーションの性能を制限しています。新しいロック競合の仕組みはJavaのオブジェクトモニターの性能を改善し、さまざまなベンチマーク(Volanoなど)で検証されています。こうしたテストでは、JVMの最大スループットを推定できます。実際、新しいロック競合の仕組みは、22種類の異なるベンチマークすべてで優れた成績を収めました。この新しい仕組みがJava 9に採用されれば、アプリケーションの性能は大幅に向上するでしょう。簡単に説明すると、複数のスレッドがロックを競合した場合、最適化前:後から来たスレッドはすべて同じ標準手順でロックを待ちます。最適化後:JVMが最適化可能な状況を認識すると、後から来たスレッドに「VIP専用通路」のような形で直接ロック獲得を競わせます。

詳しい説明はこちらを参照: 競合するロックの解説 – パフォーマンスの観点から

リアクティブストリーム

リアクティブストリーム(Reactive Streams)は、ノンブロッキングのバックプレッシャー方式で非同期データストリームを処理するための標準であり、必要な操作とエンティティを記述する最小限のインターフェース、メソッド、プロトコルを提供します。

ノンブロッキングのバックプレッシャーとは何でしょうか? バックプレッシャーはback pressureの略で、簡単に言えば、プロデューサーがコンシューマーにデータを送り、コンシューマーが処理しきれなくなるとプロデューサーに通知し、プロデューサーが生成速度を下げる仕組みです。この仕組みはブロッキング方式で実装するのが最も簡単で、送信時に負荷情報を直接返します。ノンブロッキング方式で実装すると設計は複雑になりますが、性能も向上します。 PS:「背压」という中国語訳はあまりよくない気がします。字面だけでは意味が分かりません。「反压」のほうがよいのでは^\_^

コンシューマーが膨大なリソース負荷(pressure)を受けてクラッシュするおそれがあるという問題を解決するには、高速なデータフローが受信側を圧倒しないよう、データフローの速度を制御する必要があります。これがフロー制御(flow control)です。そのため、逆方向の圧力、つまりバックプレッシャー(back pressure)が必要になり、プロデューサーとコンシューマーはバックプレッシャーの仕組みを実装することで相互に連携する必要があります。このバックプレッシャーの仕組みは非同期かつノンブロッキングである必要があります。同期的でブロッキングする方式だと、コンシューマーがデータを処理している間、プロデューサーは待たなければならず、パフォーマンス上の問題が生じます。

リアクティブストリーム(Reactive Streams)は、一連のエンティティ、インターフェース、相互運用の方法を定義することで、ノンブロッキングのバックプレッシャーを実装するための標準を示しています。サードパーティーはこの標準に従って具体的なソリューションを実装しており、代表的なものにはReactor、RxJava、Akka Streams、Ratpackなどがあります。

JDK 10 スレッドローカル制御

Safepoint とその欠点:

Safepointは、Hotspot JVMですべてのアプリケーションを停止させる仕組みです。JVMは低レベルの処理を行うために、Stop The Worldによってアプリケーションスレッドをすべて停止させる必要があります。ただし、強引に直接停止させるのではなく、アプリケーションスレッドに命令シグナルを送り、停止すべきことを伝えます。すると、アプリケーションスレッドはSafepointに到達した時点で命令に従い、応答します。これがSafepointと呼ばれる理由です。safeを付けているのは、JVMが全体に関わる処理を安全に行うことを強調するためです。

グローバルなセーフポイントを必要とする処理には、以下があります: 1、ガベージコレクションのための停止 2、コードの最適化解除(Code deoptimization)。 3、コードキャッシュのフラッシュ。 4、クラスファイルの再定義時(Class redefinition、例えばホットアップデートやinstrumentation)。 5、バイアスロックの解除(Biased lock revocation)。 6、各種デバッグ操作(例えば: デッドロック検査やスタックトレースのダンプなど)。

しかし、すべてのスレッドを最寄りのsafepointまで進めて停止させること自体に、かなりの時間がかかります。しかも、すべてのスレッドを停止させるのは、いささか乱暴で一方的ではないでしょうか。そこでJava10では、すべてのスレッドを停止させずに済む方式として、スレッド単位の制御(Thread Local Handshake)が導入されました。

例えば、次のような場面ではすべてのスレッドを停止せずに処理できる: 1、バイアスロックの解除。これは単一のスレッドを停止するだけで解除でき、すべてのスレッドを停止する必要はない。 2、各種の保守・診断用クエリが VM 全体の遅延に与える影響を減らす。例えば、大量の Java スレッドを持つ VM ですべてのスレッドのスタックトレースを取得する処理は、時間がかかることがある。 3、シグナルへの依存を減らし、より安全なスタックトレースのサンプリングを行う。 4、いわゆる非対称 Dekker 同期技法を使い、Java スレッドとのハンドシェイクによって一部のメモリバリアを取り除く。 例えば、G1 と CMS で使われる「条件付きカードマーキングコード」では、「メモリバリア」が不要になる。これにより、G1 が生成する「書き込みバリア」を最適化でき、メモリバリアを回避しようとする分岐も削除できる。

JDK 15でバイアスロックを無効化・非推奨化

なぜバイアスロックを廃止するのでしょうか?かつてバイアスロックがもたらした性能向上は、現在ではそれほど顕著ではなくなっています。バイアスロックの恩恵を受けるアプリケーションは、初期の Java コレクション API(JDK 1.1)を使用しているものが多く、これらの API(Hashtable と Vector)はアクセスのたびに同期処理を行います。JDK 1.2 ではシングルスレッド向けの同期処理を行わないコレクション(HashMap と ArrayList)が導入され、JDK 1.5 ではマルチスレッド向けに、より高性能な並行データ構造が登場しました。つまり、不要な同期処理があるためにバイアスロックの恩恵を受けているアプリケーションは、より新しいクラスを使うようにコードを更新すれば、性能が大幅に向上する可能性があります。また、スレッドプールのキューとワーカースレッドを中心に構築されたアプリケーションは、通常、バイアスロックを無効にしたほうが性能が向上します。

以下は Hashtable と Vector の API を使用した実装です: java.lang.Classloader Vectorを使用 java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL Hashtable を使用 java.net.URConnection Hashtable を使用 java.util.ZipOutputStream Vectorを使用 javax.management.timer.TimerMBean インターフェースに Vector がある

自動運転の時代

仮想スレッドによって、Javaは自動運転の時代に入りました。Go、C#、Erlang、Luaなど、多くの言語には「仮想スレッド」に似た技術があり、それらは「コルーチン」と呼ばれています。今回、javaはキーワードを一切追加せず、新しい概念すら追加していません。仮想スレッドはgoroutineやコルーチンよりもずっと理解しやすく、名前を見れば何をするものなのか、おおよそ分かります。

JDK 19 仮想スレッド

従来のJavaのスレッドモデルは、オペレーティングシステムのスレッドと 1:1 で対応しており、スレッドの作成や切り替えのコストが大きく、OSの制約によって作成できる数も限られます。同時実行数が非常に多い場合、リクエストごとにスレッドを作成することはできません。スレッドプールを使えば問題を緩和でき、スレッド作成の負担は減りますが、スレッド数を増やせるわけではありません。仮に同時実行数が2000で、スレッドプールに1000本のスレッドしかなければ、同時に処理できるのは1000件のリクエストだけです。残る1000件は処理できず、拒否するか、スレッドが空くまで待機させることになります。

仮想スレッド以前の方法は、非同期スタイルを採用することでした。すでに多くのフレームワークが非同期スタイルの並行プログラミングを実装しており(Spring5 の Reactor など)、スレッドの共有によって可用性を高めています。仕組みとしては、スレッドの共有によってスレッドの切り替えを減らして負荷を抑え、同時にブロッキングも避けます。プログラムの実行時にだけスレッドを使い、待機が必要なときにはスレッドを占有しません。非同期スタイルには確かに多くの改善点がありますが、欠点もあります。大半の非同期フレームワークはチェーン形式の記述を採用し、プログラムを多数のステップに分け、それぞれのステップが異なるスレッドで実行される可能性があります。使い慣れた ThreadLocal などの並行プログラミング関連 API は、そのままでは使えなくなり、使うとエラーが生じる可能性があります。プログラミングスタイルも大きく変わり、従来のスタイルよりはるかに複雑で、学習コストが高く、プロジェクト内の既存モジュールの多くを非同期モデルに適合させるために改修する必要もあるかもしれません。

仮想スレッドの実装原理は、一部の非同期フレームワークとよく似ており、同じくスレッドを共有するため、当然プール化も不要です。使用する際には、仮想スレッドは無限に豊富にあると考えてよく、必要なだけ作成でき、問題が起きる心配はありません。それだけでなく、仮想スレッドはデバッグに対応し、Java関連の監視ツールでもサポートされる点が重要です。仮想スレッドによってプログラムのメモリ使用量は大幅に減り、Web Serversなど、あらゆるI/O集約型アプリケーションで、同じハードウェア条件のままI/Oスループットを大幅に向上できます。従来はメモリ1Gで同時に1000件のアクセスを処理できていたところ、仮想スレッドを使うと、公式の説明では100万件の同時処理にも余裕で対応できます。具体的な業務場面で対応できるかどうかは負荷テスト次第ですが、控えめに見積もっても10万件は容易に実現できるはずで、そのために何らかの代償を払う必要もなく、コードの変更さえ不要かもしれません。仮想スレッドでは、1リクエストに1スレッドという従来のプログラミングスタイルを維持し、スレッドと同じように仮想スレッドを使用できるため、プログラムにはごくわずかな変更だけで済むからです。仮想スレッドは新しい構文も導入しておらず、学習と移行のコストは極めて低いと言えます。

特筆すべき点として、仮想スレッドの内部では依然としてFork/Joinフレームワークが使われています。


コメント

送信中

好きなテーマを選ぶ

選んだテーマはサイト全体に適用されます。