0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解】初学者がJavaのスレッドを学習したので解説してみる

Posted at

はじめに

 苦戦していたJavaのスレッドを理解することが出来たので、記事にしてみました。勘違いしていたことや、イメージしづらかったことをなるべく図解したので、同じくスレッドに苦しめられている方のお役に立てれば嬉しいです。

私も初学者なので、誤りがある可能性もございます。あらかじめご了承ください。

 今回スレッドを学習している際に私が感じた以下の疑問点を中心に解説していきます。

私が感じた5つの疑問点

  • そもそもスレッドって何?
  • sleep()を使うと順番がばらばらになるのはなぜ?
  • スレッドを使ってリストを操作したらリスト全体の要素が減るのはなぜ?
  • synchronizedListにするだけでリスト全体の要素数が減らなくなるのはなぜ?
  • join()って終了じゃないの?

そもそもスレッドって何?

 スレッドというのはプログラムの中で独立して実行される最小単位の処理です。難しそうな定義は置いといて、簡単に言うと関数だと思ってくれればイメージしやすいと思います。普段私たちが使っているmain関数も一つのスレッドになります。mainだけ実行している場合、スレッドが一つなのでシングルスレッドといい、処理を並列で行っていない状態になります。今回の記事で実装するようなスレッドが二つ以上であるものをマルチスレッドといいます。

image.png

 今回用いるプログラムのシングルスレッドでの実装は以下の通りになります。果物の文字列をリストに格納して表示するプログラムです。リンゴ、ナシ、オレンジを2回ずつ格納して表示したいものとします。

import java.util.ArrayList;
import java.util.List;

/**
 * シングルスレッドなプログラム
 */
public class Main {
    public static void main(String[] args) {
        List<String> fruitList = new ArrayList<>();
        String apple = "リンゴ";
        String pear = "ナシ";
        String orange = "オレンジ";

        //リンゴを2回追加
        for(int i = 0; i < 2; i++) {
            addFruit(fruitList, apple);
        }

        //ナシを2回追加
        for(int i = 0; i < 2; i++) {
            addFruit(fruitList, pear);
        }

        //オレンジを2回追加
        for(int i = 0; i < 2; i++) {
            addFruit(fruitList, orange);
        }

        //リストの表示
        printFruitList(fruitList);
    }

    //フルーツを追加するメソッド
    private static void addFruit(List<String> fruitList, String fruitName) {
        fruitList.add(fruitName);
    }

    //リストの中身を表示するメソッド
    private static void printFruitList(List<String> fruitList) {
        for(String fruit : fruitList) {
            System.out.println(fruit);
        }
    }
}

出力

 結果は予想通り、リンゴが2回改行されながら表示されて、ナシ、オレンジが続いて2回改行されながら表示されます。今回の記事ではこの処理をスレッドを用いて並列処理していきます。

リンゴ
リンゴ
ナシ
ナシ
オレンジ
オレンジ
並行処理と並列処理の違いって何?

少しややこしいことを書くので混乱したくないという方は読み飛ばしてください

 実は並行処理と並列処理は違います。並行処理は、複数のスレッドを切り替えて行うことで、並列処理は複数のスレッドを同時に行うことです。図解すると以下のようになります。

image.png

 Javaのプログラムを動かすソフトウェアであるJVMでは1つの処理を実行していくものです。要するにスレッドを1つずつしか実行できないので並列処理の実現不可能ではないかと思いました。調べたところ、厳密にいうと並列処理ではないのですが、高速にスレッドを切り替えることで、スレッドを並列処理しているように見せているそうです。つまり本当は並行処理なんだけど、切り替える時間が早すぎるから並列処理に見えているということです。複数のスレッドの処理の仕方は厳密には高速で切り替えている平行処理なのですが、今回の記事では並列処理として記述しています。

今回用いるソースコードの説明

 残りの4つの疑問点を解説するにはソースコードの説明が不可欠なので、先にソースコードの説明をします。シングルスレッドの例は前項で説明しましたが、今回はそれをスレッドを用いて並列処理するバージョンに変更します。そのため、クラスをMainとMyThreadの二つ用意します。先にMyThreadから説明します。スレッドを実行するにはThreadクラスを作らなければいけません。Threadクラスの作り方は以下の二つがあります。

① Threadクラスを継承して作る
② インタフェースのRunnableを継承して、Threadクラスのコンストラクタに渡して作る

 コンストラクタとか書きましたが、コードで言うと以下のようになるってことです。

//Threadクラスを継承して作る
public class MyThread extends Thread

MyThread appleThread = new MyThread("リンゴ", 30)
//インタフェースのRunnableを継承して
public class MyThread implements Runnable

//Threadクラスのコンストラクタに渡して作る
Thread appleThread = new Thread(new MyThread("リンゴ", 30));

 今回は①の方を用います。この2つの作り方の違いも細かくあるそうなのですが、今回私はざっくりとしか理解してないのであまり詳しいことはわかりません。1番大きい違いは①ではクラスの継承は1つなので 別のクラスを継承できません。②はインタフェースを継承してるので、別のクラスも継承可能ということになります。
 簡単なスレッドクラスは①、多機能で複雑なスレッドクラスを作りたければ、②の方法を用いると良いという認識です。よって今回は①の方法を用います。

MyThreadクラスのソースコード

 以下がソースコードです。MyThreadクラスはクラスで共通した果物リストと、インスタンスごとに異なる果物の名前を持ちます。並列処理したい処理をrun()に書き込みます。今回では果物の名前を2回リストに登録します。
 継承しているThreadクラスに抽象メソッドとしてrun()が定義されているため、 必ずオーバーライドしてください。オーバーライドしないと、コンパイルエラーになります。

import java.util.ArrayList;
import java.util.List;

/**
 * スレッドを実行するためのクラス
 */
public class MyThread extends Thread {
    /**果物を格納するリスト、クラスで共通*/
    private static List<String> fruitList = new ArrayList<>();
    /**果物の名前 インスタンスごとに異なる*/
    private String fruitName;

    /**
     * コンストラクタ インスタンスごとに果物の名前を設定
     * @param fruitName 果物の名前
     */
    MyThread(String fruitName) {
        this.fruitName = fruitName;
    }

    /**
     * 並列処理したい処理をrunに書く。
     * 親クラスであるThreadクラスにrun()があるのでオーバーライドする必要がある
     */
    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
            fruitList.add(fruitName);
        }
    }

    /**
     * 結果であるフルーツリストを返す
     * @return 果物を格納したリスト
     */
    public List<String> getfruitList() {
        return fruitList;
    }
}

Mainクラスのソースコード

 続いてMainクラスのソースコードです。Mainクラスではまず、先ほどのMyThreadクラスを3つ作ります。今回格納する果物の名前はリンゴ、ナシ、オレンジとします。その後Threadクラス.start()でスレッドを開始しrun()の中に書いた処理が実行されます。Threadクラス.join()では、処理が終了するまで待ちます。 join()ではInterruptedExceptionが発生する可能性があるのでtry文を書く必要があります。InterruptedExceptionとはスレッドが中断されたときに起こるエラーだそうです。最後にリストの中身を表示するという流れになっています。

import java.util.List;

/**
 * マルチスレッドなプログラム
 */
public class Main {
    public static void main(String[] args) {
        MyThread appleThread = new MyThread("リンゴ");
        MyThread pearThread = new MyThread("ナシ");
        MyThread orangeThread = new MyThread("オレンジ");

        //スレッドの開始
        appleThread.start();
        pearThread.start();
        orangeThread.start();

        //joinで処理の終了待ち
        try {
            appleThread.join();
            pearThread.join();
            orangeThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //リストの中身表示
        printFruitList(appleThread.getfruitList());
    }

    /**
     * リストの中身を表示するメソッド
     * @param fruitList 果物のリスト 
     */
    private static void printFruitList(List<String> fruitList) {
        for(String fruit : fruitList) {
            System.out.println(fruit);
        }
    }
}

 こちらのコードを実行すると以下のような出力結果になります。ここまではなんの問題もなく処理ができていますね。

出力

リンゴ
リンゴ
ナシ
ナシ
オレンジ
オレンジ

ソースコードに変更を加えてみる

 ここでMyThreadクラスのソースコードを以下のように変更します。sleep()はスレッドを一時停止することができる関数です。これにより、登録する果物を乱数ms待ってからリストに格納するという処理になります。出力結果を予想してみてください。

import java.util.ArrayList;
import java.util.List;

/**
 * スレッドを実行するためのクラス
 */
public class MyThread extends Thread {
    /** 果物を格納するリスト、クラスで共通 */
    private static List<String> fruitList = new ArrayList<>();
    /** 果物の名前 インスタンスごとに異なる */
    private String fruitName;
+   /** 途中で処理を一時停止する時間(ms) */
+   private int sleepTime;

    /**
     * コンストラクタ インスタンスごとに果物の名前を設定
     * 
     * @param fruitName 果物の名前
     * @param sleepTime 途中で処理を一時停止する時間
     */
+   MyThread(String fruitName, int sleepTime) {
        this.fruitName = fruitName;
+       this.sleepTime = sleepTime;
    }

    /**
     * 並列処理したい処理をrunに書く。
     * 親クラスであるThreadクラスにrun()があるのでオーバーライドする必要がある
     */
    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
+           try {
+               // sleepTimeミリ秒だけ一時停止
+               Thread.sleep(sleepTime);
+           } catch (InterruptedException e) {
+               e.printStackTrace();
+           }

            //格納予定の果物をリストへ登録
            fruitList.add(fruitName);
        }
    }

    /**
     * 結果であるフルーツリストを返す
     * 
     * @return 果物を格納したリスト
     */
    public List<String> getfruitList() {
        return fruitList;
    }
}

 コンストラクタの引数を変えたのでMainの方も少し変えます。

public class Main {
    public static void main(String[] args) {
+       MyThread appleThread = new MyThread("リンゴ", 1);
+       MyThread pearThread = new MyThread("ナシ", 2);
+       MyThread orangeThread = new MyThread("オレンジ", 3);

出力 ※実行するたび、変化します。

ナシ
オレンジ
リンゴ
オレンジ
ナシ

 私は出力が変わらないと思っていました。そしたらこのようなわけが分からない出力になり、戸惑いました。最初のソースコードから変わった点は二つです。

① 順番が変わっている
② リストの要素が減っている

 前置きが長くなりましたが、ここから私の疑問点解決コーナーになります。

sleep()を使うと順番がばらばらになるのはなぜ?

 まず順番が変わっている問題についてです。sleep()を加えたことによって起こっているのでそれに関する原因があるはずです。しかし考えてもわからなかったので、調べてみたところ、スレッドは実行可能状態になったものからOSからCPUへの指示命令が出て、実行されるそうです。そしてこの時、 どのスレッドから実行するのかはOSのタスクスケジューリングに依存するため、どの果物から出力されるかはわからないということになります。タスクスケジューリングというのは基本情報技術者試験とか受けている方なら聞いたことがあるかもしれませんが、OSがスレッドを処理するCPUをどこに割り当てようかなーと選んでいくやつです。
 今回は待機時間を1ms、2ms、3msにしたのでほぼ同時に実行可能状態になってあとはOSの気分次第の順番になったと考えられます。ちなみに10ms、20ms、30msにしても順番がバラバラになるので、マルチスレッドの際は処理の実行はかなりOSのタスクスケジューリングによります。図解すると以下の感じですね。

image.png

 この対策としては、後々解説するjoin()を使うことで、並行処理に変更することで、順番を保つことができます。また、sleep()を入れないことも対策として挙げられますが、CPUにかかる負荷を減らすためにsleep()を使いたい時があるそうなので、どちらかというと前者の対策をした方がよさそうです。並列処理で順番を変えないという手法は今回思いつきませんでした。

スレッドを使ってリストを操作したらリスト全体の要素が減るのはなぜ?

 これが私の中で最大の謎でした。3個のfor文で2回ずつやっているので3×2で6にならないとおかしいじゃないかと思いました。強制終了とかも疑いましたが、どうやら共有しているものに原因があるようです。次に注目してほしいのは果物を格納するリストであるfruitListです。こちらにフルーツを格納する際に、二つのスレッドから同時にアクセスされると、片方の処理の結果がなくなってしまうそうです。このように 同じ資源に複数の主体が同時にアクセスすること競合といいます。図解すると以下のような感じです。

image.png

 この競合ですが、私が試した結果、解決方法は以下の3つあります。

  • 共有する変数を持たない
  • sleep()を入れない
  • SynchronizedListを用いる

 1つ目は、共有する変数を持たないことです。今回ではリストを共有する変数ではなく各スレッドクラスに持たせて、あとからリストを結合する方法です。しかし、これではスレッドの数が大きな数になった時、その分のリストを用意しなければならないので、無駄にメモリを消費すると考えられます。
 2つ目はsleepを入れないことです。実行停止をしない事で他のスレッドの割り込みを許さず、正しい結果が出力されます。しかし、sleep()を使うことにも理由があります。sleep()を適切に使うことでCPUの負荷を減らすことが出来ます。大規模なマルチスレッドではsleep()を使わなければいけないケースもあるそうなので、その時にはこの対策法は用いることができません。
 3つ目は、ソースコードを少し変えるだけで解決します。リストをSynchronizedListというものに変えました。正直最初はこれにすると治るよーってインターネットに書いてあったので書いてみたら治って驚きました。※これで治るのは競合のみで、出力がしっかり6個出てきますが、 順番の問題は解決しません

import java.util.ArrayList;
+ import java.util.Collections;
import java.util.List;

/**
 * スレッドを実行するためのクラス
 */
public class MyThread extends Thread {
    /** 果物を格納するリスト、クラスで共通 */
+    private static List<String> fruitList = Collections.synchronizedList(new ArrayList<>());

synchronizedListにするだけでリスト全体の要素数の問題が解決するのはなぜ?

 先ほどこれにするだけで治るというsynchronizedListの謎に迫ります。というわけで公式ドキュメントから引用してきました。

指定されたリストに連動する同期(スレッドセーフな)リストを返します。確実に直列アクセスを実現するには、基になるリストへのアクセスはすべて、返されたリストを介して行う必要があります。
返されたリストの反復処理を行う場合、ユーザーは、次に示すように手動で同期をとる必要があります。

これだけ見てもわからん! というわけでもう少し調べてみたところやっと理解できたので解説します。ちなみにスレッドセーフという用語が出てきたので説明すると、複数のスレッドから実行しても結果が変わらないものになります。現在のプログラムはスレッドセーフではない例になります。ちなみに反対の言葉はスレッドアンセーフだそうです。スレッドアウトかと思ってました。
 話を戻すと、直列アクセスや返されたリストを介して行う必要があるとのことなので、 処理が終わるまで他のスレッドからのアクセスを受け付けないというのがsynchronizedの特徴だと考えられます。図解すると以下のようになります。

image.png

 synchronizedなものはリストに限らず、それぞれの型やメソッドにも付けられるものがあります。今回ではこの手法を解決策として用いましたが、処理が終わるまで待つので、実行時間はやや遅くなります。

join()って終了じゃないの?

 最後にこれは私が勘違いしていたことですが勝手にjoin()のことをスレッドの終了だと思っていました。そんな私が、join()書かなかったらどうなるんだろうとjoin()をコメントアウトして実行してみました。

出力

 書き忘れとかではないです。実行しても何も出ませんでした。そこでやっとjoin()がスレッドの終了ではないことに気づきました。何も出ない理由としてはmainもスレッドであり、実行可能状態になったスレッドより先にmainスレッドが終わってしまうことが考えられます。図解すると以下のようになります。

image.png

 ちなみに、いろいろ試してみて、以下のようにソースコードを変更すると、リンゴとナシとオレンジが二行ずつきれいに出るようになりました。

    try {
        appleThread.start();
        appleThread.join();
        pearThread.start();
        pearThread.join();
        orangeThread.start();
        orangeThread.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

出力

リンゴ
リンゴ
ナシ
ナシ
オレンジ
オレンジ

 これでjoin()が明らかに終了であることが分かったと思います。ちなみにソースコードを変更した後のスレッド処理を図解すると以下のようになります。mainとどれかのスレッドが動いているので並列処理ですが、main以外のスレッドだけで見ると並行処理になっています。

image.png

まとめ

 今回はスレッドに関して解説してみました。おさらいするとスレッドとは、 プログラムの中で独立して実行される最小単位の処理です。また、マルチスレッドなプログラムを書く際には 共有する変数を持たないようにすること、もしくは処理が完遂するまで他のスレッドから変更を加えられないようにすることが大切です。最後に私の感じた5つの疑問点とその答えを列挙して終わりにしたいと思います。

私が感じた5つの疑問点とその答え

  • そもそもスレッドって何?→プログラムの中で独立して実行される最小単位の処理
  • sleep()を使うと順番がばらばらになるのはなぜ?→どのスレッドから実行するのかはOSのタスクスケジューリングに依存するため
  • スレッドを使ってリストを操作したらリスト全体の要素が減るのはなぜ?→リストに格納する処理が同時に起こった場合、競合が発生して片方の処理が反映されなくなってしまうから
  • synchronizedListにするだけでリスト全体の要素数の問題が解決するのはなぜ?→synchronizedにより、そのリストにアクセスしているスレッドがそのリストにロックをかける。そのため、他のスレッドからアクセスできなくなり、競合が発生しないから
  • join()って終了じゃないの?→joinは終了ではなく終了待ち

ここまで読んでくださりありがとうございました。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?