Javaのスレッド(Thread)とは?複数の処理を同時に動かす仕組み「スレッド」の扱い方
Javaのスレッド(thread)とは、プログラム上で複数の処理を同時に動かす仕組みです。スレッドをJavaで使うためのクラスjava.lang.Threadを指す言葉でもあります。
さて、多くの作業は人が手分けすれば早く終わります。違う作業であっても、それぞれの作業へ人を割り当てれば同時に行えます。当然、一人での作業より効率的です。
それと同じで、プログラムでも処理を複数同時に動かせば、短い時間で効率よく処理できます。その仕組みがスレッドです。スレッドは、今ではごく当たり前に使われているのです。
この記事では、Javaでのスレッドの考え方・使い方の基本から、スレッドを使う上で気を付けたいこと、スレッドに関する話題について、ポイントを絞って初心者向けに説明します。
※この記事はJava 13時点の言語仕様・APIに基づいています。サンプルはJava 13の環境で動作確認しています。
1.スレッドの基本的な考え方・使い方
ここでは、スレッドのことを軽く学んで、Javaのスレッドを体験してみましょう。Javaでは、スレッドを作って動かすことは、とても簡単なことなのです。
やるべきことは、スレッドで動かしたい処理を普通のメソッドと同じように作って、java.lang.Threadに「この処理を動かしてください」と頼むだけなのです。
1-1.スレッドの考え方
スレッド(thread)とは、プログラムが処理を実行する単位をプログラマの必要に応じて増やせるものです。
スレッドは、主にコンピュータの処理能力(特に、マルチコアやマルチプロセッサ)を最大限に生かすために使われます。プログラムでのスレッドの使い道は、例えば以下のものです。
- 違うことを同時に行う:あるスレッドでネットワーク通信をしつつ、別のスレッドではユーザが行った画面操作への処理ができます
- 処理にかかる時間を短くする:大きな問題を同時に処理できるより小さな問題に分割し、小さな問題をスレッドで同時に処理して、全体時間を短縮します
- 単位時間当たりの処理量を増やす:コンピュータにスレッドを同時に動かせるだけの性能があれば、単位時間当たりに処理できる量を増やせます
身の回りのソフトウェアを思い浮かべると、いくつかの処理を同時に動かしていそうなものがあるでしょう。WEBブラウザでも何でも、今ではスレッドを使って動くのが当たり前なのです。
1-1-1.スレッドをコンビニで例えると
プログラム以外でスレッドを考えてみるなら、コンビニエンスストアを思い浮かべましょう。特に、お昼時などでお客さんでごった返しているコンビニがいいですね。
コンビニのレジが1つだけなら、すぐ精算の行列ができます。でも、レジが複数あれば、精算を同時に複数できて、行列は短くなります。それに店員が複数人いれば、違う仕事が同時にできますよね(レジ打ちや品出し、清掃など)。
コンビニがプログラムだとすれば、店員がスレッドです。共通しているのは、何かの仕事や処理をする単位だということです。そして、店員はすぐには増やせませんが、プログラムではスレッドを必要に応じてすぐに作り動かせるのです。
1-2.Threadクラスを継承して使ってみる
では、Javaで実際にスレッドを使ってみましょう。スレッドを使う方法の一つは、java.lang.Threadクラスを継承したクラスを作り、インスタンス化して動かすことです。
1-2-1.スレッドを1つ作って動かしてみる
Threadを継承したクラスでは、スレッドで実行したい処理をrunメソッドの中に書きます。普通にメソッドへ書けることであれば、例えばファイル読み込み、ネットワーク通信、数値計算など、大抵のことが出来ます。
ThreadSample.java
class ThreadSample extends Thread { public void run() { System.out.println("スレッドで動いてまーす"); } }
スレッドを動かすには、このThreadを継承したクラスのインスタンスを生成し、startメソッドを呼び出します。これだけで、新しいスレッドが生成され、そのスレッドでrunメソッドへ記述した処理を動かせられます。
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { ThreadSample t = new ThreadSample(); t.start(); } }
さっそくThreadExecutorを動かしてみると、以下のような出力になるでしょう。おめでとうございます。見事、スレッドを使えました。
実行結果
スレッドで動いてまーす
ここで、プログラム上ではThreadSampleのrunを呼び出してもいないのに、文字が表示されました。つまり、スレッドがrunメソッドを自動で呼び出してくれた、ということですね。
重要なポイントは、プログラマが自分でrunを呼び出すのではないということです。スレッドがいつrunを呼び出すかは指示できず、スレッドの準備が終わったタイミングで呼び出されます。
スレッドを使う上では、このようにプログラマが制御できないことが多くあるのです。いわゆるシングルスレッドなプログラムとは違う箇所が出てきます。
1-2-2.いくつかのスレッドを同時に動かしてみる
先ほどのプログラムでは、スレッドを1つ作って動かしただけでした。でも、スレッドは複数のスレッドを同時に動かしてこそ、ですよね。
ですから、次はスレッドを3つ作り、同時に動かしてみましょう。さあ、いよいよマルチスレッドプログラミングっぽくなってきました。
ThreadSample.java
class ThreadSample extends Thread { public void run() { System.out.println("スレッド" + getName() + "で動いてまーす"); } }
出力メッセージが同じだと、3つの別々のスレッドが同時に動いたのかが分からないので、スレッドごとに割り当てられるユニークな名前も一緒に出力してみます。
runの中で呼んでいるgetNameメソッドは、スレッドの名前を取得できるThreadクラスのメソッドです。ThreadSampleはThreadのサブクラスですから、runからgetNameを呼び出せるのです。
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { ThreadSample t1 = new ThreadSample(); ThreadSample t2 = new ThreadSample(); ThreadSample t3 = new ThreadSample(); t1.start(); t2.start(); t3.start(); } }
では、またThreadExecutorを実行してみましょう。
実行結果
スレッドThread-0で動いてまーす スレッドThread-2で動いてまーす スレッドThread-1で動いてまーす
と、このように違うスレッド名が3行出力されました。プログラムは同じでも、実行結果はスレッドごとに違います。これが、それぞれの処理を動かしたスレッドが別だということです。
このプログラムが動く時のイメージは、以下のような感じです。今の処理から3つのスレッドが分岐して、それぞれが独立して動いているのです。
1-3.ThreadクラスとRunnableインターフェイスで使ってみる
スレッドを使う方法のもう一つは、スレッドで動かしたい処理をjava.lang.Runnableの実装クラスとして作り、そのRunnableをThreadクラスのインスタンスに動かしてもらうことです。実際には、こちらが使われます。
Runnableとは「実行(run)できる(able)もの」という意味のインターフェイスで、抽象メソッドは戻り値void、引数なしのrunだけです。Runnableはスレッドに限らず、Javaで何か動かしたい処理を表現・実装するのにもよく使われます。
そして、Runnableを使うと、スレッドそのものと、スレッドで動かしたい処理をプログラム上で明確に分けられます。このような役割分担は、プログラムの見通しの良さにもつながる考え方です。
1-3-1.1つのスレッドから呼び出してみる
さて、実際のプログラムを見てみましょう。RunnableSampleはRunnableを実装しています。やっていることはThreadを継承した場合と同じで、runメソッドの実装があるだけです。
RunnableSample.java
class RunnableSample implements Runnable { public void run() { String threadName = Thread.currentThread().getName(); System.out.println("スレッド" + threadName + "で動いてまーす"); } }
今回は、runメソッドの中で「Thread.currentThread().getName()」でスレッドの名前を取得しています。RunnableはThreadではないので、Thread.getName()を直接使えないからです。
RunnableSampleのインスタンスを生成し、Runnableを引数に取るThreadのコンストラクタへ渡してThreadのインスタンスを生成します。その後、Threadのstartメソッドを呼び出します。
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { RunnableSample r = new RunnableSample(); Thread t = new Thread(r); // Runnableをスレッドに渡してインスタンスを生成する t.start(); } }
結果は、例えば以下となります。
実行結果
スレッドThread-0で動いてまーす
RunnableSample.runをプログラム上からは呼び出していませんが、実際には呼び出されています。これはThreadを継承した時と同じように、スレッドからrunメソッドが呼び出されたのです。
1-3-2.いくつかのスレッドから呼び出してみる
では次に、いくつかのスレッドからRunnableを呼び出してみましょう。
RunnableSampleのインスタンスを生成し、ThreadのRunnableを引数に取るコンストラクタに渡して、Threadのインスタンスを3つ作りました。その後、スレッド3つを開始させています。
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { RunnableSample r = new RunnableSample(); Thread t1 = new Thread(r); Thread t2 = new Thread(r); Thread t3 = new Thread(r); t1.start(); t2.start(); t3.start(); } }
結果は以下です。ここで、RunnableSample.runが動いた時のスレッド名はすべて違います。でも、RunnableSampleのインスタンスは1つだけです。これは何を意味するのでしょうか。
実行結果
スレッドThread-2で動いてまーす スレッドThread-1で動いてまーす スレッドThread-0で動いてまーす
これは、3つのスレッドが動いた結果、それぞれのスレッドからRunnableSample.runが3回呼び出され、結果として3行が出力されたということです。
つまり、メソッドは、複数のスレッドから同時に呼び出されることがある、ということです。これはインスタンスメソッドに限らず、クラスメソッドでも同じことです。
さらに言えば、インスタンスやクラスのフィールドも、複数のスレッドから同時に読み書きされます。これらはプログラミング上の実に厄介な課題なのですが、詳細は後からお伝えします。
2.Threadのメソッドの使い方
Threadには便利なメソッドが数多くあります。その中でも、比較的よくプログラム上で出てくるものの使い方を、簡単にご紹介します。
2-1.スレッドの識別子・名前を取得するThread.getId/getName
スレッドへは、スレッドの識別のための数字(識別子)をJava仮想マシンが与えます。それ以外にも、Java仮想マシンが自動で名前も付けます(プログラマが名前を付けることもできます)。
そのスレッドの識別子と名前を取得するためのメソッドが、Thread.getIdとgetNameです。
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { Thread t1 = new Thread(); Thread t2 = new Thread(); // スレッドの識別子を得る long t1Id = t1.getId(); long t2Id = t2.getId(); // スレッドの名前を得る String t1Name = t1.getName(); String t2Name = t2.getName(); System.out.println("t1の識別子は" + t1Id + "、名前は" + t1Name + "です"); System.out.println("t2の識別子は" + t2Id + "、名前は" + t2Name + "です"); } }
実行結果
t1の識別子は12、名前はThread-0です t2の識別子は13、名前はThread-1です
プログラム中では、今の処理がどのスレッドで動いているのかを知りたい時があります。その時にこれらのメソッドを使えば、どのスレッドで動いているか分かります。
2-2.今のスレッドを取得するThread.currentThread
Threadを継承したクラスなら、自分自身がスレッドとして何者かは簡単に分かります。なぜなら、自分自身がスレッドなので、自分自身のgetId/getNameを直接呼び出せばいいからです。
ThreadSample.java
class ThreadSample extends Thread { public void run() { long id = getId(); String name = getName(); System.out.println("スレッドの識別子は" + id + "、名前は" + name + "です"); } }
一方、Runnableやスレッドから呼び出されたメソッドでは、今どのスレッドで動いているのかはすぐには分かりません。まず今のThreadを何らかの方法で持ってこなければ、Thread.getId/getNameを呼び出せないからです。
そんな時にThread.currentThreadを呼び出せば、今の処理を動かしているThreadのインスタンスを取得できます。そこからgetId/getNameを呼び出せば、どのスレッドかが分かります。
RunnableSample.java
class RunnableSample implements Runnable { public void run() { Thread t = Thread.currentThread(); // このメソッドを動かしているThreadを得る long id = t.getId(); String name = t.getName(); System.out.println("スレッドの識別子は" + id + "、名前は" + name + "です"); } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { RunnableSample r = new RunnableSample(); Thread t1 = new Thread(r); Thread t2 = new Thread(r); t1.start(); t2.start(); } }
実行結果
スレッドの識別子は12、名前はThread-0です スレッドの識別子は13、名前はThread-1です
RunnableSampleのインスタンスは一つですが、runメソッドの呼び出し元になっているスレッドが違うので、それぞれの出力で違う識別子・名前になっています。
2-3.今のスレッドを一時停止するThread.sleep
時には、スレッドの処理を一時停止したい場合があります。何らかの処理待ちをしたい場合などです。そういう時にはThread.sleepを呼び出します。
Thread.sleepを呼び出すと、今実行中のスレッドを引数で指定した秒数停止できます。秒数の単位はミリ秒で、オーバーライドされたメソッド(Thread.sleep(long millis, int nanos))では、ナノ秒での指定もできます。
ThreadSample.java
class ThreadSample extends Thread { public void run() { System.out.println("sleepを始めます"); try { Thread.sleep(10000L); // 10秒(10000ms)間、今のスレッドを停止させる } catch (InterruptedException e) { } System.out.println("sleepが終わりました"); } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { Thread t = new ThreadSample(); t.start(); } }
停止するのはThread.sleepを呼び出したスレッドだけです。それ以外のスレッドは実行され続けます。それに、任意のスレッドの実行を、別のスレッドから直接停止させられもしません。
これは結構分かりづらいポイントです。もしsleepの動きが意図どおりにならないなら、どのスレッドでsleepしているかを、getIdやgetNameで調べてみましょう。
なお、停止しているスレッドへ、後述する割り込みが起きた場合は、途中で停止状態が解除されることもあり得ます。その場合は、catchしている例外InterruptedExceptionが発生します。
Thread.sleepについては個別記事もありますので、参考にしていただければと思います。
関連記事2-4.スレッドの処理が終わるのを待つThread.join
特定のスレッドの処理が終わるのを待つには、そのスレッドのThread.joinを呼び出します。
Thread.sleepと同じように、自分自身のスレッドの処理は停止します。それが再び動き出すきっかけは、Thread.joinが呼び出されたスレッドの処理の終了です。
ThreadSample.java
class ThreadSample extends Thread { public void run() { System.out.println("sleepを始めます"); try { Thread.sleep(10000L); // 10秒停止する } catch (InterruptedException e) { } System.out.println("sleepが終わりました"); } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { Thread t = new ThreadSample(); t.start(); System.out.println("joinを始めます"); try { t.join(); // スレッドでの処理が終わるまで、ここでブロックされる } catch (InterruptedException e) { } System.out.println("joinが終わりました"); } }
実行結果
joinを始めます sleepを始めます sleepが終わりました joinが終わりました ← 10秒後にこれが表示される
2-5.スレッドに割り込むThread.interrupt
処理中のスレッドへ、処理の中断を伝えたい場合があります。その際は、スレッドの割り込みという機能を使うと、すぐに伝えられます。
2-5-1.Thread.interruptで割り込む
スレッドへの割り込みは、対象のスレッドのThread.interruptを呼び出すことで行えます。
スレッドがThread.sleepなどで待ちの状態にある場合などでは、割り込みされたスレッドでInterruptedExceptionが直ちに発生しますので、割り込みをされたことが分かります。
ThreadSample.java
class ThreadSample extends Thread { public void run() { System.out.println("sleepを始めます"); try { Thread.sleep(10000L); // 10秒停止する System.out.println("sleepが終わりました"); } catch (InterruptedException e) { System.out.println("別のスレッドから割り込まれました"); } } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { Thread t = new ThreadSample(); t.start(); try { Thread.sleep(1000L); // 1秒待機 } catch (InterruptedException e) { } System.out.println("スレッドに割り込みます"); t.interrupt(); System.out.println("スレッドに割り込みました"); } }
実行結果
sleepを始めます スレッドに割り込みます ←1秒後に表示される スレッドに割り込みました 別のスレッドから割り込まれました ←10秒待たずに表示される
なお、割り込みをInterruptedExceptionで検知できるケースは限定されています。具体的にはObject.wait、Thread.join、Thread.sleepでスレッドが待ちの状態にある場合です。
2-5-2.Thread.interrupted/isInterruptedで割り込みを検出する
スレッドが前述した状態(sleep等)ではない時に割り込みされたかを知るには、Thread.interruptedやThread.isInterruptedを呼び出します。
今のスレッドが割り込みされたか調べるには、Thread.interruptedを使います。特定のスレッドが割り込みされたか調べるには、そのスレッドのThread.isInterruptedを使います。
それぞれのメソッドでは、割り込みされた直後の呼び出しではtrueを戻し、以後は再び割り込みされなければfalseを戻します。ですから、割り込みがあったか分かるのは最初の一回だけということです。
InterruptedThreadSample1.java
class InterruptedThreadSample1 extends Thread { public void run() { System.out.println("ループを始めます1"); // 時間稼ぎのための空ループ for (long i = 0; i < 10000000000L; i++) { } System.out.println("ループが終わりました1"); if (isInterrupted()) { System.out.println("別のスレッドから割り込まれています1"); } } }
InterruptedThreadSample2.java
class InterruptedThreadSample2 extends Thread { public void run() { System.out.println("ループを始めます2"); // 時間稼ぎのための空ループ for (long i = 0; i < 10000000000L; i++) { } System.out.println("ループが終わりました2"); if (Thread.interrupted()) { System.out.println("別のスレッドから割り込まれています2"); } } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { Thread t1 = new InterruptedThreadSample1(); Thread t2 = new InterruptedThreadSample2(); t1.start(); t2.start(); try { Thread.sleep(1000L); } catch (InterruptedException e) { } System.out.println("スレッドに割り込みます"); t1.interrupt(); t2.interrupt(); System.out.println("スレッドに割り込みました"); } }
以下のとおり、ループをしている最中に割り込んでも処理は中断されません。ループ後にThread.isInterruptedまたはThread.interruptedを呼び出すと、割り込みがあったかが分かります。
実行結果
ループを始めます1 ループを始めます2 スレッドに割り込みます ←1秒後に表示される スレッドに割り込みました ループが終わりました2 ←各スレッドのループ終了後に以下が表示される 別のスレッドから割り込まれています2 ループが終わりました1 別のスレッドから割り込まれています1
2-6.例外を処理するThread.UncaughtExceptionHandler
Thread.UncaughtExceptionHandlerは、Thread.runやRunnable.runで発生した例外を補足するために使います。
2-6-1.スレッド内で発生した例外の扱い
メソッド宣言から明らかなように、Thread.runやRunnable.runは、チェック例外をthrowできません。ですので、処理中で発生したチェック例外は、runの中でtry-catch文ですべて処理するか、throw句への記載が不要なRuntimeExceptionとしてthrowします。
RuntimeExceptionをthrowした場合は、例外がthrowされるとそのスレッドの実行全体が止まります。その上で、何が起きたのかもスレッドの呼び出し元からは簡単には分かりません。
ThreadSample.java
class ThreadSample extends Thread { public void run() { if (true) { throw new RuntimeException("スレッド" + getName() + "からRuntimeExceptionをthrowします"); } } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { System.out.println("スレッドの実行を始めます"); try { Thread t = new ThreadSample(); t.start(); } catch (Exception e) { System.out.println("例外が発生しました"); e.printStackTrace(); } System.out.println("スレッドの実行を行いました"); } }
実行結果
スレッドの実行を始めます スレッドの実行を行いました Exception in thread "Thread-0" java.lang.RuntimeException: スレッドThread-0からRuntimeExceptionをthrowします at ThreadSample.run(ThreadSample.java:6)
このプログラムを動かした時は、runメソッドから例外がthrowされても、mainメソッドにあるcatch句には入りません。
スレッドで発生した例外はJava仮想マシンが受け取って処理しますが、プログラム上でスレッドで発生した例外を知り、何かの処理をさせるにはひと工夫必要です。
2-6-2.Thread.UncaughtExceptionHandlerで例外の発生を知る
Thread.UncaughtExceptionHandlerを使うと、スレッドで発生した例外へのフォロー処理を設定できます。
例外発生時のスレッドでの処理を再開できるわけではありませんが、必要な後処理(ログ出力など)を行う機会が得られます。
ThreadSample.java
class ThreadSample extends Thread { public void run() { if (true) { throw new RuntimeException("スレッド" + getName() + "からRuntimeExceptionをthrowします"); } } }
以下のように、Thread.UncaughtExceptionHandlerのインスタンスを生成し、Thread.setUncaughtExceptionHandlerメソッドの引数として、Threadに引き渡します。
Thread.UncaughtExceptionHandlerのuncaughtExceptionメソッドが、例外発生時に呼び出されます。uncaughtExceptionメソッドの引数へは、例外が発生したスレッドと例外が渡されます。
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { Thread.UncaughtExceptionHandler handler = new Thread.UncaughtExceptionHandler() { public void uncaughtException(Thread thread, Throwable throwable) { System.out.println("スレッド" + thread.getName() + "で例外が発生しました"); throwable.printStackTrace(); } }; Thread t = new ThreadSample(); t.setUncaughtExceptionHandler(handler); t.start(); } }
実行結果
スレッドの実行を始めます スレッドの実行を行いました スレッドThread-0で例外が発生しました java.lang.RuntimeException: スレッドThread-0からRuntimeExceptionをthrowします at jp.engineer_club.thread.ThreadSample.run(ThreadSample.java:6)
3.スレッド関連のトピック
この章では、スレッドに関連するトピックをお伝えします。
3-1.スレッドでの処理結果の受け取り
スレッドでの処理結果をスレッドの外部から受け取りたい時があります。ですが、Thread.runとRunnable.runのどちらも、メソッドの戻り値はvoidなので、結果を呼び出し元へ直接は戻せません。
スレッドでの処理結果の受け取り方には、大まかには三種類あります。以下では、それぞれのやり方をサンプルを交えて説明します。
- ・ポーリング:Thread/Runnableに処理が終わったか問い合わせ、終わったら結果を得るメソッドを呼ぶ
- コールバック:Thread/Runnableに処理が終わった後に呼び出すオブジェクトを渡し、終わったら所定のメソッドを呼んでもらう
- データ共有用オブジェクト:処理結果を受け取るオブジェクトを、呼び出し元と先とで共有する
3-1-1.ポーリングの例
ポーリング(polling)とは、プログラミングでは定期的に問い合わせをする処理方法のことです。シンプルかつ直感的な処理方式です。
ポーリングでは、スレッドでの処理が終わったかを、スレッド自身へ定期的に問い合わせます。スレッドが終わったと回答したなら、そこで結果を受け取るのです。
ThreadSample.java
class ThreadSample extends Thread { private int result; private boolean finished; public void run() { for (int i = 1; i <= 10; i++) { result += i; } finished = true; } int getResult() { return retult; } boolean finished() { return finished; } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { ThreadSample t = new TheadSample(); t.start(); // 処理が完了するまで待つ為のループ while (true) { try { Thread.sleep(1000L); } catch (InterruptedException e) { } // 処理が完了していたらループを抜ける if (t.finished()) { break; } } int result = t.getResult(); System.out.println("スレッドでの処理結果は" + result + ”です”); } }
実行結果
スレッドでの処理結果は55です
プログラムを簡単にするために、Thread.sleepしながらループしています。実際には、このsleepの箇所で別の処理をします。すると、コンピュータの処理時間を効率的に使えます。
欠点は、呼び出し元が定期的に状態を聞かなければならないことです。つまり「あとはよろしく~」と、仕事を丸投げできず、終わりまで面倒を見なければなりません。
3-1-2.コールバックの例
コールバック(callback)とは、一般的には「(電話を)かけ直す・かけ返す、呼び直す」という意味です。
プログラミングでのコールバックは、呼び出し元が処理を事前に指定して、終わった時に呼び出し先から呼び返してもらうやり方のことです。
以下は、スレッドでの処理結果をコールバックを使って受け取る例です。
ThreadSample.java
class ThreadSample extends Thread { private Callback callback; public void run() { int result = 0; for (int i = 1; i <= 10; i++) { result += i; } callback.finished(result); } void setCallback(Callback callback) { this.callback = callback; } static interface Callback { void finished(int result); } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { ThreadSample.Callback callback = new ThreadSample.Callback() { public void finished(int result) { System.out.println("スレッドでの処理結果は" + result + "です"); } }; ThreadSample t = new ThreadSample(); t.setCallback(callback); t.start(); } }
実行結果
スレッドでの処理結果は55です
Callbackインターフェイスを実装したクラス(今回は匿名クラスです)が、スレッドでの処理が終わった後に呼ばれる処理です。処理結果は、finishedメソッドの引数で渡されますので、それを使って何かをします。
スレッドを作る側のプログラムは、ポーリングの例と比べるとずいぶんすっきりしましたね。これは、ポーリングで必要だった「スレッドの処理待ちをする処理」がないからです。
要注目は、終わった後の処理がCallbackインターフェイスで抽象化されたことです。ThreadSampleは、処理の実装が何であれfinishedメソッドを呼び出すだけなので、汎用性が増しています。
3-1-3.データ共有用オブジェクトの例
呼び出し元と呼び出し先とで、何かしらのオブジェクトを共有するやり方があります。そのオブジェクトで処理結果や、処理のパラメータを受け渡しできます。
スレッドはプログラム上で動くものですが、一つのプログラムの中でなら、メモリを経由すればスレッド間でデータを共有できるのです。
プログラムを越えてデータのやりとりをしたい場合は、プログラムの外にあるファイルやデータベース、ネットワーク通信などを使わなければなりません。
以下は、データ共有オブジェクトを使ってみた例です。
SharedData.java
class SharedData { private Integer result; Integer getResult() { return result; } void setResult(int result) { this.result = result; } }
ThreadSample.java
class ThreadSample extends Thread { private SharedData sharedData; public void run() { int result = 0; for (int i = 1; i <= 10; i++) { result += i; } sharedData.setResult(result); } void setSharedData(SharedData sharedData) { this.sharedData = sharedData; } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { SharedData sharedData = new SharedData(); ThreadSample t = new ThreadSample(); t.setSharedData(sharedData); t.start(); while (true) { try { Thread.sleep(1000L); } catch (InterruptedException e) { } if (sharedData.getResult() != null) { break; } } System.out.println("スレッドでの処理結果は" + sharedData.getResult() + "です"); } }
実行結果
スレッドでの処理結果は55です
データを持つSharedDataクラスのインスタンスを、呼び出し元と先で共有していますね。プログラムの構造が、ポーリングと似ているのはたまたまです。
この例では、2つのスレッド間でデータを共有しましたが、もちろんスレッド数は2つだけに限定されず、必要なだけ広げられます。それがこのやり方のメリットです。
ただし、複数のスレッド間でのデータ共有は危険でもあります。プログラムが複雑になりますし、後述する同期化・排他ロックの問題がとても表面化しやすいからです。
なお、publicなクラス変数を共有データとして使えもしますが、そうすべきではありません。いわゆるグローバル変数と同じことですから、オブジェクト指向で対処してきた様々な問題が再発しかねません。
3-1-4.コールバックの使い過ぎには要注意
コールバックは、スレッド関連以外でも実用プログラムでは多用されます。特にJavaScriptなどでのHTMLアプリや、Android/iOSでのスマホアプリのようなGUIアプリではごく普通に使いますので、慣れておくべきです。
ただし、便利だからとコールバックを使いまくると、プログラムの構造や処理の呼び出し順序が直感的に分かりづらくなり、大きな問題となります。「コールバック地獄(callback hell)」という言葉があるくらいなのです。
このコールバック地獄の解消のために、Rx(Reactive Extensions)などのフレームワークが活用されたり、Java以外でもプログラミング言語の機能強化が続いていたりします。
また、素直にポーリングする方が簡単かつプログラムが読みやすくなる場合も多いので、適材適所でやり方を選びましょう。
3-2.【参考】mainメソッドはmainスレッドが動かす
Javaのプログラムが動く時には、必ず何かのスレッドがあり、動いています。言い方を変えれば、Javaのプログラムは必ず何かのスレッドが動かしている、ということです。
以下のプログラムを動かしてみましょう。mainメソッドを動かしているスレッドの名前を出力しています。
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { String threadName = Thread.currentThread().getName(); System.out.println("スレッド" + threadName + "で動いてまーす"); } }
動かした結果は、以下となりました。どうやらこのmainメソッドは、“main”という名前のスレッドが動かしているようですね。
実行結果
スレッドmainで動いてまーす
でも、このプログラム中ではThreadまたはThreadのサブクラスのインスタンスを作っていません。でもスレッドの名前はあるので、誰かがmainスレッドを作って動かしているのです。
このmainスレッドを作って動かしているのは、Java仮想マシンです。Javaが動く時にはJava仮想マシンがmainスレッドを自分自身で作り、そのmainスレッドでmainメソッドを動かします。
皆さんは、以下のエラーを見たことがあるかもしれません。これはmainメソッドがないクラスを実行しようとした時のものです。このメッセージの“main”こそが、mainメソッドのことです。
java.lang.NoSuchMethodError: main Exception in thread "main"
1-2と1-3では、スレッドを3つ作って動かしました。これは、より正確に言えばmainスレッドからスレッドを3つ作って動かしたのです。ですから、プログラム上では一時的にスレッドが4つあったことになります。
3-3.【発展】同期化・排他ロックの考え方
スレッドを使う上でほぼセットになるのが、同期化や排他ロックと呼ばれる考え方です。これらは、スレッドにより「処理が同時に動く」ことを防ぐ仕組みです。
同期化や排他ロックをきちんと使いこなさなければならない局面が、スレッドを使う上での腕の見せ所です。スレッドを使いこなすためにも、しっかり学んでおいてください。
3-3-1.1を10000回足しても10000にはならない(ことがある)
以下のプログラムは、10個のスレッドがそれぞれ1,000回、int型の変数を+1し続けます。10×1,000は当然10,000ですよね。
でも、これを実行した場合に、結果が常に10,000になるとは限りません。単に、int型の変数を+1し続けているだけなのに、です。
RunnableSample.java
class RunnableSample implements Runnable { private int count; public void run() { for (int i = 0; i < 1000; i++) { count++; } } int getCount() { return count; } }
ThreadExecutor.java
class ThreadExecutor { public static void main(String[] args) { for (int i = 0; i < 100; i++) { RunnableTest r = new RunnableTest(); Thread[] threads = new Thread[10]; for (int j = 0; j < threads.length; j++) { threads[j] = new Thread(r); } for (int j = 0; j < threads.length; j++) { threads[j].start(); } // 全スレッドの処理の終了を待って… for (int j = 0; j < threads.length; j++) { try { threads[j].join(); } catch (InterruptedException e) { } } // 処理結果を出力してみると… System.out.println((i + 1) + "回目:処理結果は" + r.getCount()) + "です"; } } }
実行結果
1回目:処理結果は8796です 2回目:処理結果は9559です 3回目:処理結果は8183です 4回目:処理結果は9139です 5回目:処理結果は9675です 6回目:処理結果は9767です 7回目:処理結果は9967です 8回目:処理結果は10000です 9回目:処理結果は10000です 10回目:処理結果は10000です
さて、皆さんの環境での実行結果はいかがでしたでしょうか。単純な足し算をしているだけなのになかなか不思議ですが、これがスレッドを使ったプログラムの難しさの一側面です。
3-3-2.同時に動くことはやっかいな問題を生む
こうなる理由は、変数の数値を+1するだけでも、Java内部では以下のステップを経るからです。これらのステップが複数スレッドで同時に動くと、結果が意図したとおりにならないのです。
- スレッドがメモリ上の変数countの現在値を読み、スレッド内に一時的に持つ
- スレッド内で現在値を+1する
- +1した値を、変数countがあるメモリにスレッドが書き戻す
例えば、countの現在の値が10だとします。2つのスレッドがcountを「同時に」読み出したなら、2つのスレッドが10という値を一時的に持ちます。10を+1すれば11ですから、2つのスレッドがそれぞれ書き込みをすれば、countの値は12ではなく11になります。
少し専門用語を使うなら、変数countはヒープ領域にあるので、スレッドがその値を使った計算を行うため、スレッドごとのスタック領域へ一時的に値をコピーするために起こる現象です。
変数の値を+1するだけでもこれですから、もっとやっかいな問題が起きそうです。実際スレッド関連では、いくらやっても再現できなかったり、原因がさっぱり分からず解決の糸口すらつかめない問題もよくあります。
3-3-3.同期化で同時に動くことを防ぐ
この例で、常に結果を10,000にしたければ、複数のスレッドへ変数を同時に読み書きさせてはいけません。同時の変数の読み書き、メソッド呼び出しを防ぐ機能が、同期化や排他ロックと一般に呼ばれるものです。
今回の例では、RunnableSampleのメソッドrunを、複数のスレッドから同時に動かせなくすれば良さそうです。そのために、synchronizedというキーワードをメソッドの前につけてみます。
RunnableSample.java
class RunnableSample implements Runnable { private int count; // voidの前にsynchronizedを付けて、メソッドを排他実行されるようにした public synchronized void run() { for (int i = 0; i < 1000; i++) { count++; } } int getCount() { return count; } }
すると、結果は常に10,000です。synchronizedというキーワードにより、複数のスレッドからrunという「変数を+1する処理」が同時実行されなくなるからです。
もしこの状態で同時にメソッド呼び出しが行われたら、処理を実行する権利を得たスレッド以外は、メソッドの終了待ちをします。そして、メソッドが終わったら、またどれかのスレッドが実行権を得て実行し始めます。
Javaでの同期化や排他ロックの方法は複数あります。キーワードを挙げておきますので、興味のある方は調べてみてください。それぞれ一長一短がありますので、使い分けが必要ですよ。
- synchronizedメソッドの使用
- synchronizedブロックの使用
- 変数へのvolatile修飾子の指定
- java.util.concurrent.lock.ReentrantLockの使用
- java.util.concurrent.atomic.AtomicIntegerなどの使用
3-4.【発展】Concurrent UtilitiesとExecutorフレームワーク(Java 1.5~)
Java 1.5で導入されたConcurrent Utilities、特にその中に含まれるExecutorフレームワークは、スレッドをもっと簡単に使うためのものです。
Javaも13となった今では、ここまで説明してきたThreadクラスはあまり使わず、Executorフレームワークを使うのが普通です。
もちろん、スレッドの考え方は同じですし、Threadクラスも同じように使えます。でも、Executorフレームワークではスレッドを使う上で不便だった制約や問題点が多く解消されています。
Executorフレームワークで解消された制約や問題点は、例えば、Threadそのものをプログラマが管理しないといけなかったり、スレッドでの実行結果を直接得られないなどです。
3-4-1.Executorフレームワークの例(Executors/ExecutorService)
Executorフレームワークを使うと、以下のような感じになります。Runnableでスレッドで動かす処理を作るのは同じです。でも、Threadがプログラム中にありませんし、見たことのないクラスもいくつかあります。
RunnableSample.java
class RunnableSample implements Runnable { private int result; public void run() { for (int i = 1; i <= 10; i++) { result += i; } } int getResult() { return result; } }
ExecutorServiceTest.java
import java.util.concurrent.Executors; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; class ExecutorServiceTest { public static void main(String[] args) { RunnableSample r = new RunnableSample(); ExecutorService service = Executors.newSingleThreadExecutor(); Future f = service.submit(r); try { f.get(); } catch (ExecutionException | InterruptedException e) { } service.shutdown(); System.out.println("スレッドでの処理結果は" + r.getResult() + "です"); } }
Threadの代わりに使うのが、ExecutorsやExecutorServiceです。これらのクラスに面倒なスレッドの管理をすべて任せて、使う側が「スレッドをこんな感じで使いたい」と、Executors/ExecutorServiceへ伝えます。
Executorsは、使う側の要求に応じたExecutorServiceを作ります。この例では、1つのスレッドを管理するExecutorServiceを要求しています。そして、ExecutorServiceへ動かすRunnableを渡し、実行させています。
Executors/ExecutorServiceでは、いわゆるスレッドプール(thread pool)と呼ばれる機能も簡単に使えます。このように、スレッドを活用したプログラムを実に簡単に、手軽に作れるのです。
3-4-2.Executorフレームワークの例(Callable)
Executorフレームワークでは、動かす処理を表現するインターフェイスとしてjava.util.concurrent.Callableが追加されました。Callableを使うと、スレッドでの実行結果をスレッドの外から直接得られるのです。
CallableSample.java
import java.util.concurrent.Callable; class CallableSample implements Callable<Integer> { // 戻り値を戻せるし、チェック例外もthrowできる!! public Integer call() throws Exception { int result = 0; for (int i = 1; i <= 10; i++) { result += i; } return result; } }
ExecutorServiceTest.java
import java.util.concurrent.Executors; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; class ExecutorServiceTest { public static void main(String[] args) { CallableSample c = new CallableSample(); ExecutorService service = Executors.newSingleThreadExecutor(); Future<Integer> f = service.submit(c); try { System.out.println("スレッドでの処理結果は" + f.get() + "です"); } catch (ExecutionException e) { System.out.println("スレッドで例外が発生しました"); e.getCause().printStackTrace(); } catch (InterruptedException e) { } service.shutdown(); } }
Futureを間に挟むので少々ややこしいですが、スレッドでの処理結果をFuture.getで得られるので、先述のポーリングやコールバックが不要となり、より直感的にプログラムを作れます。
また、Callableを使うとスレッドで動かす処理からチェック例外をthrowでき、スレッドを呼び出した側からもスレッドからthrowされた例外を取得できたりもするのです。
3-4-3.Concurrent Utilitiesの他のクラス
Concurrent Utilitiesでは、ExecutorServiceやCallable以外にも、以下のような便利なクラス群が追加されました。
- 値への操作が同期化されたAtomicInteger、AtomicBooleanなど
- 排他ロックを簡単に行うためのReentrantLock
- 同期化の処理パフォーマンスが改善されたConcurrentArrayListなど
- スレッド間での待ち合わせができるCyclicBarrier、Semaphore、Phaserなど
- 定期的な処理をスレッドで実行できるScheduledExecutorService
- スレッド実行後の後続処理を指定できるCompletableFuture
- スレッドを使って処理を効率化するためのForkJoinPool/ForkJoinTaskなど
これらのクラスでスレッドのユースケースは全部実現できる、と言っても過言ではありません。ぜひこれらのクラスを学び、スレッドを活用したプログラムを作れるようになってください。
4.まとめ
スレッドとは処理を実行する単位です。JavaではThreadクラスでスレッドの機能を使います。スレッドとセットなのは、Runnableという「実行する処理」を表現するインターフェイスです。
スレッドの難しさは、メソッドやフィールドが複数スレッドから同時に呼び出されたりアクセスされる部分です。それを解決するために同期化や排他制御などのいろいろな手段が用意されています。
現時点でスレッドを扱うには、Executorフレームワークを使うのが普通です。Threadをそのまま使う時の問題点が解消されます。Cocurrent Utilitiesには、さらに便利なクラスがあります。
スレッドの理解は、Java仮想マシンがどうプログラムを動かしているのかの深い理解にもつながります。ぜひこの記事をきっかけに、Javaの仕組みを理解する一歩を踏み出してみましょう。
コメント