vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【Java】 volatie に触れてみたい 1

はじめに

前回ちょこっと出てきた volatile 。

CompletableFuture.java

~省略~
volatile Object result; 
~省略~

名前すら触れませんでしたが Twitter で流れてきたブログを見て気になっていたこともあり、調べてみることにしました。

volatile とは

とりあえずググってみます。

まとめると下記のような特徴があるらしい、と。

  • 変数に付加する(例: 「volatile int count = 0;」)
  • マルチスレッド処理の文脈で使用される
  • synchronized より軽量に扱うことができる
  • synchronized と違ってアトミック性を持たない
  • 可視性を持っており、複数のスレッドから同じ変数を変更した場合も、
    値を取得するときに(スレッドごとに持っている古い値ではなく)新しい値を取得できる
  • 取り扱いが難しく、正しく使用できる状況は限られている

とりあえずアトミック性、可視性について見てみます。

アトミック性について

先に挙げた通り、 volatile を変数に付けたとしても、アトミック性は保証されません。

ということは、複数のスレッドから同時に値を変更した場合に、正しい結果にならない場合がある、ということです。

これを 1.通常、2. volatile を付けた場合、3. synchronized を付けた場合、4.変数を AtomicInteger にした場合で試してみます。

まず変数の保持、変更を行うためのクラスです。

3.以外はこのクラスを複数のスレッドから呼び出して最後の値を比較します。

SampleBehaviour.java

package SearchContainedFiles;

public interface SampleBehaviour {
    void add();
    void subtract();
    int getCount();
}

MultithreadSample.java

package SearchContainedFiles;

class MultithreadSample implements SampleBehaviour{

    private int count;

    public void add(){
        count++;
    }
    public void subtract(){
        count--;
    }
    public int getCount(){
        return count;
    }
}

int の値を +1 / -1 するだけです。

1.通常

App.java

package SearchContainedFiles;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class App {

    public static void main(String[] args) {

        MultithreadSample s = new MultithreadSample();

        CompletableFuture callSample1 = CompletableFuture.supplyAsync(() -> add(s));
        CompletableFuture callSample2 = CompletableFuture.supplyAsync(() -> subtract(s));
        var all = CompletableFuture.allOf(callSample2, callSample1);

        try {
            all.join();
            all.get();

            System.out.println("call: " + s.getCount());
            
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
    private static int add(SampleBehaviour s){
        System.out.println("call1 " + Thread.currentThread().getId());
        for(int i = 0; i < 10000; i++){
            try{
                s.add();
            }
            catch(IllegalArgumentException e){
                System.out.println(e.getLocalizedMessage());
            }
        }

        return 0;
    }
    private static int subtract(SampleBehaviour s){
        System.out.println("call2 " + Thread.currentThread().getId());
        for(int i = 0; i < 10000; i++){
            try{
                s.subtract();
            }
            catch(IllegalArgumentException e){
                System.out.println(e.getLocalizedMessage());
            }
        }
        return 0;
    }
}

+1 / -1 するメソッドを 10000 回ずつ呼び、最後に値を受け取ります。

期待値は 0 であり、「call: 0」になるはずです。

結果

出力結果は下記の通りです。

call1 13
call2 14
call: -35

Oh......

ややこしいのは、実行されるタイミングによっては正しい結果が出てくることです。

その場合、 s.add() や s.subtract() の前後で Thread.sleep してやると再現しやすい気がします。

アトミック

+1 / -1 を同じ回数実行しても 0 に戻らないのは、 int がアトミックではないためです。

例えば add() を実行するとき、実際には下記の処理が行われることになります。

  1. count の値を取得する
  2. count に 1 を追加する
  3. count の値を上書きする

1.が実行され、 3.が実行されるより前に subtract() が実行されてしまうと、 3.の時点での count の値が 1.と異なってしまうため、ズレが生じてしまう、ということのようです。

これを防ぐ方法は?というのが、後述する AtomicInteger や synchronized です。

2. volatile を付けた場合

とその前に、今回の主役である volatile を試してみます。

MultithreadSample.java

package SearchContainedFiles;

class MultithreadSample implements SampleBehaviour{
    private volatile int count;

    public void add(){
        count++;
    }
    public void subtract(){
        count--;
    }
    public int getCount(){
        return count;
    }
}

変数に volatile をつけただけですが、果たしてどうなるか......

call1 13
call2 14
call: -363

デスヨネー。

volatile はアトミック性は保証しないため、今回の実験結果としては特に変化がありません。

3. synchronized を付けた場合

~省略~
class MultithreadSample implements SampleBehaviour{
    private int count;

    public synchronized void add(){
        count++;
    }
    public synchronized void subtract(){
       count--;
    }
~省略~

結果は

call1 13
call2 14
call: 0

正しく 0 になりました。

興味深いのは、下記のようにコンソールに出力されるようにしてみると......

~省略~
class MultithreadSample implements SampleBehaviour{
    private int count;

    public synchronized void add(){
        System.out.println("add " + count);
        count++;
    }
    public synchronized void subtract(){

        System.out.println("subtract " + count);
        count--;
    }
~省略~

このような出力結果になります。

subtract 0
subtract -1
subtract -2
subtract -3
subtract -4
~省略~
subtract -84
subtract -85
subtract -86
subtract -87
subtract -88
add -89
add -88
add -87
add -86
add -85
~省略~
add 2
add 3
add 4
add 5
add 6
subtract 7
subtract 6
subtract 5
subtract 4
subtract 3
~省略~

add() 、 subtract() の内一方の処理が実行されるときにロックがかかるため、同時に実行しているつもりでも add() -> subtract() -> add() のような順番では実行されません。

なお、synchronized を呼び出し元である App.java の add() 、 subtract() につけた場合、 add() の処理が全部終了してから subtract() が実行されることになります。

App.java

~省略~
    private static synchronized int add(SampleBehaviour s){
        System.out.println("call1 " + Thread.currentThread().getId());
        for(int i = 0; i < 10000; i++){
            try{
                s.add();
            }
            catch(IllegalArgumentException e){
                System.out.println(e.getLocalizedMessage());
            }
        }
        return 0;
    }
    private static synchronized int subtract(SampleBehaviour s){
        System.out.println("call2 " + Thread.currentThread().getId());
        for(int i = 0; i < 10000; i++){
            try{
                s.subtract();
            }
            catch(IllegalArgumentException e){
                System.out.println(e.getLocalizedMessage());
            }
        }
        return 0;
    }
}

このロックの機構、および処理が同時に実行されない辺りが synchronized が重い、と言われる所以でしょうか。

4.変数を AtomicInteger にした場合

実のところ、現状で今回試しているような複数のスレッドから値の変更をしたい場合、 AtomicInteger を使うのが良さそうです。

理由としてはアトミック性を持ちつつも、 synchronized より速いことが挙げられています。

MultithreadSample.java

package SearchContainedFiles;

import java.util.concurrent.atomic.AtomicInteger;

class MultithreadSample implements SampleBehaviour{
    private AtomicInteger count;

    public MultithreadSample(){
        // ぬるぽ注意.
        count = new AtomicInteger(0);
    }
    public void add(){
        System.out.println("add " + count);
        count.incrementAndGet();
    }
    public void subtract(){

        System.out.println("subtract " + count);
        count.decrementAndGet();
    }
    public int getCount(){
        return count.intValue();
    }
}

実行の結果としては正しく 0 になります。

call1 13
call2 14
call: 0

また、先ほどの synchronized と同じように、 add() 、 subtract() がどのような順番で呼ばれているかを見てみると......

add 0
subtract 0
add 1
subtract 0
add 1
subtract 0
add 1
add 1
add 2
add 3
add 4
add 5
add 6
add 7
add 8
subtract 0
add 9
subtract 8
~省略~

固まっているところもありますが、 synchronized と比較するとバラけているように見えます。

この AtomicInteger の中身を見てみたいところですが、長くなってきたので一旦切ります。