3. スレッドの排他制御

複数のスレッドが同じオブジェクトを同時に操作すると、プログラムが予想外の動作をすることがあります。そこでこの章では、複数のスレッドの動作を制御し、同じオブジェクトが同時に操作されないようにする方法を説明します。

3.1. synchronizedブロック

2つのスレッドは、同じオブジェクトを並行して同時に扱うことができます。2つのスレッドがあるオブジェクトのフィールド変数を同時に書き込んだりすると、プログラムが時として意図しない動作をすることがあります。

例として、預金口座を表すクラスを考えてみましょう。このクラスには、お金を振り込むためのdepositメソッドが定義されています。

1
2
3
4
5
6
7
    class Account {
        private int balance = 0;        // 預金残高
        public void deposit(int money){
            int total = balance + money;
            balance = total;
        }
    }

このプログラムにはなにも問題がないように見えます。たしかに、同じインスタンスが1つのスレッドからしか利用されないのであれば問題はありません。しかし、同じインスタンスが複数のスレッドから同時にアクセスされたとき、問題が生じるおそれがあります。

balanceが0である状態のときに2つのスレッドから同じインスタンスのdeposit(1000)を呼んだとしましょう。2つのスレッドが次のようなタイミングで並列動作したらどうなるでしょうか。

スレッド1 スレッド2
 int total = balance + money;  
 int total = balance + money;
 balance = total;
   balance = total;

totalはメソッドで定義されたローカル変数ですので、メソッドの呼び出しごとに個別に領域確保されます。つまり、それぞれのスレッドで別々の領域を使用してします。両方のスレッドでtotalへの代入が終わったとき、どちらのスレッドのtotalの値も1000になっています。balanceはインスタンス変数ですので、スレッド1とスレッド2で領域を共有しています。スレッド1で1000(スレッド1のtotalの値)が代入され、スレッド2でも再度1000(スレッド2のtotalの値)が代入されます。つまり、最終的にbalanceの値は1000となります。

結果を見てみると、deposit(1000)を2回実行したにもかかわらずbalanceが1000しか増えていません。銀行で、2箇所から1000円振り込んでもらったのに、1000円しか預金が増えてなかっら困りますね!

それでは次のように1文で処理を書いてしまえばよいのでしょうか。

1
2
3
    public void diposit(int money){
        balance += money;
    }

これなら絶対にさっきのような間違いは発生しないように思えます。しかし、これでも誤動作の可能性があります。それぞれのスレッドは内部的に固有の作業コピー領域を持っており、より高速にプログラムを実行するために、変数の値を一時的にスレッド固有のメモリにコピーして作業することが許されています。この例では、balanceの値を共有メモリからスレッド固有メモリにコピーし、そこで加算処理を実施してから共有メモリに書き戻す、という処理を行います。つまり、このように1行でプログラムを書いたとしても、先ほどのプログラムと同じようなことが内部的に実行されてしまうのです。

この問題を解決するにはsynchronizedブロック(またはsynchronizedメソッド)を利用します。

1
2
3
4
5
6
    public void deposit(int money){
        synchronized(this){
            int total = balance + money;
            balance = total;
        }
    }

synchronizedブロックがどのような意味を持つのかは、次の節で説明します。

3.2. synchronizedブロックの仕組み

クラスやインスタンスはそれぞれ「ロック」というものを持っています。ロックとは「鍵」のことです。スレッドは、synchronizedブロックの開始時にsynchronized文で指定したオブジェクトのロックを取得し、終了時にそのロックを開放します。あるスレッドがロックを取得している状態では、他のスレッドは同じロックを取得することができません。ロックが返却されるまで待たされます。

つまり、同じオブジェクトのロックを獲得しようとするsynchronizedブロック同士は、原則として同時には並行して実行されないことになります。オブジェクトに鍵をかけて、他のスレッドに邪魔されないようにして作業をしていると考えるとよいでしょう。

ただし、オブジェクトに対して鍵をかけると考えると、synchronizedブロックの実行は絶対に割り込まれないと思うかもしれませんが、それは誤りです。ロックが使用中であるかどうかを気にするのは、あくまでも、スレッドがsynchronizedブロックを実行しようとしているときだけです。あるスレッドがsynchronizedブロックでオブジェクトを独占しているつもりでも、synchronizedを利用していない箇所からのアクセスを防ぐことはできません。

Accountクラスに、5%の利息を加算するaddInterestメソッドを追加するとしましょう。

1
2
3
    public void addInterest(){
        balance = (int)(balance * 1.05);
    }

depositメソッドでの処理をsynchronizedブロックで実行していたとしていても、dipositメソッド実行中にaddInterestメソッドが同時に実行される可能性は残されています。addInterestメソッドを実行するスレッドは、実行時にロックが使用中であることを確認しないからです。意図した通りの動作をさせたい場合、このaddInterestメソッドでもsynchronizedブロックを使用しなければいけません。

3.3. synchronized メソッド

メソッド全体をオブジェクトthisに対するsynchronizedブロックとしたい場合、synchronizedメソッドを利用することができます。synchronizedメソッドは、メソッドの処理全体を、thisに対するsynchronizedブロックで囲んだのと同じ意味を持ちます。

したがって、depositメソッドは次のように書き換えることができます。

1
2
3
4
    synchronized public void deposit(int money){
        int total = balance + money;
        balance = total;
    }

この例のような単純な処理では、synchronizedメソッドを使用することは問題ありません。しかし、より長いメソッドや処理に時間のかかるメソッドはむやみにsynchronizedメソッドにすべきではありません。他のスレッドがロック解放待ちのために長時間待たされるおそれがあります。排他制御は、誤動作が起きない必要最低限の狭い範囲に限定して使用するべきです。

3.4. static synchronized メソッド

クラス(static)メソッドもsynchronizedメソッドにすることができます。クラスメソッドはそのクラスのインスタンスが存在していなくても呼び出すことができますが、スレッドはどのオブジェクトのロックを取得するのでしょうか。

あるクラスがプログラムにおいて利用可能であるとき、必ずそのクラスに対応するjava.lang.Classクラスのオブジェクトが存在しています。スレッドは、static synchronized メソッドを実行するとき、そのクラスに対応するClassオブジェクトのロックを取得します。

なお、あるクラスに対応するClassオブジェクトは、ClassクラスのクラスメソッドであるforNameメソッドを用いて取得することができます。

1
2
3
4
5
6
    class SomeClass {
        .............
        synchronized static public void someMethod() {
            .............
        }
    }

このプログラムは、次のプログラムと同じ意味になります。

1
2
3
4
5
6
7
8
    class SomeClass {
        .............
        static public void someMethod() {
            synchronized(Class.forName("SomeClass")) {
            .............
            }
        }
    }

3.5. volatile 変数

synchronized の説明のところにも書いたように、各スレッドは、共有する変数の内容をスレッド固有の作業領域にコピーして作業を行います。したがって、共有する変数を使用するためには、作業コピーへの読み込みや、共有メモリへの書き込みを行う必要があります。この読み書きのタイミングにはある程度の自由が許されており、連続して同じ変数にアクセスするときには途中で書き戻さなかったり、プログラムで記述した順序とは違う順序で書き戻したりすることが許されています。

次のプログラムのincrementメソッドでは、まずaを加算してからbを加算するように記述しています。しかしあるスレッドでincrementメソッド実行中に別スレッドからprintメソッドを実行した場合、(現実にそのようになることはほとんどありませんが)「a=0,b=1」と表示される可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    class someClass {
        private int a = 0, b = 0;
        public void increment() {
            a++;
            b++;
        }
        public void print(){
            System.out.println("a=" + a + ",b=" + b);  
        }
    }

そのような意図しない動作を防ぐためには、共有される変数をvolatileとして宣言します。volatile変数は、スレッドからアクセスがあるたびに、必ず共有メモリ上の変数の値とスレッドの作業コピー上の値とを一致させます。したがって必ず要求した順序で変数の値が書き換えられます。

1
volatile private int a = 0, b = 0;

無用のトラブルを防ぐには、複数のスレッドからアクセスされる可能性のあるメンバ変数はvolatileとして宣言しておくのがよいでしょう。ただし、synchronizedブロックでのロックの開放時には、必ず作業コピーの内容は共有メモリに書き戻されますので、synchronizedブロック内からしかアクセスされない変数はvolatile変数にする必要はありません。


(実習課題1)


次の条件を満たすプログラムを作成しなさい。

Runnableインタフェースを実装させる。 mainメソッドでは自分自身のクラスを元にスレッドを新しく2つ起動させる。 2つのスレッドでは共通のAccountインスタンスを扱う。 各スレッドのrunメソッドでは、「1秒以下のランダムな時間待機した後、Accountインスタンスのdeposit(1000)とshowBalance()を呼び出す処理」を10回繰り返す。 銀行口座を表すAccountクラスは、次のものを利用すること。プログラムを動作させ、最終的な残高が20000円にならない場合があることを確認すること。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    public class Account {
        private int balance = 0;
        public void deposit (int money) {
            int total = balance + money;
            try {
            Thread.sleep((long)(Math.random()*1000));
            } catch (InterruptedException e) {}
            balance = total;
        }
        public void showBalance() {
            System.out.println("現在の残高は " + balance + " 円です。");
        }
    }