🙆

Springを理解するためのスレッド&スレッドローカル入門

に公開

この記事では、Javaのスレッドについて解説します。

普段Webアプリを開発する際にはあまり意識しませんが、Tomcatなどのアプリケーションサーバーが複数のリクエストを同時に受け付ける際は、1つのリクエストに対して1つのスレッドを割り当てて処理を実行しています。

スレッドローカルは、Spring SecurityやSpringトランザクションが内部的に使っている、スレッドごとの値の保存場所です。

自分でスレッドを使ったプログラムを書く機会は少ないと思いますが、アプリケーションサーバーやSpringの内部の理解するには欠かせない知識です。

環境

  • JDK 21
  • logback-classic 1.5

この記事の内容は、多少JDKのバージョンが違っていても同じだと思います。たぶん。

スレッドとは

javaコマンドでJavaプログラムを実行すると、OS内部で新規のプロセスが起動します。そしてJavaプロセスの内部には1つ以上のスレッドが存在し、これが実際に処理を実行します。

最初の例として、次のプログラムを実行してみます。

SingleThreadMain.java
package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SingleThreadMain {
    private static final Logger logger = LoggerFactory.getLogger(SingleThreadMain.class);

    public static void main(String[] args) {
        logger.info("Hello!");
        logger.info("Bye.");
    }
}
実行結果
10:19:30.885 [main] INFO com.example.SingleThreadMain -- Hello!
10:19:30.886 [main] INFO com.example.SingleThreadMain -- Bye.

main()メソッドに書かれた処理が上から順に実行されます。注目してほしいのは、ログに書かれている[main]の部分です。これはスレッドの名前です。Javaプログラムを起動すると、mainというスレッドが1つだけつくられ、それが処理を順番に実行します。

スレッドの作成+複数スレッドの同時実行

スレッドは自分で作ることができます。

まずは、Runnableインタフェースを実装して、run()メソッド内にスレッドで実行したい処理を記述します。

CounterRunnable.java
package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class CounterRunnable implements Runnable {
    private static final Logger logger = LoggerFactory.getLogger(CounterRunnable.class);

    private final String name;

    CounterRunnable(String name) {
        this.name = name;
    }

    /**
     * この処理がスレッドで実行される
     */
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            logger.info("{}: i = {}", name, i);
        }
    }
}

スレッドを生成するには Threadクラス のインスタンスを生成して、コンストラクタの引数に先程のRunnable実装クラスと任意のスレッド名を指定します。

CounterMain.java
package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class CounterMain {

    private static final Logger logger = LoggerFactory.getLogger(CounterMain.class);

    public static void main(String[] args) {
        // スレッドAを生成
        Thread threadA = new Thread(new CounterRunnable("Runnable-A"), "Thread-A");
        // スレッドAを起動
        threadA.start();
        // スレッドBを生成
        Thread threadB = new Thread(new CounterRunnable("Runnable-B"), "Thread-B");
        // スレッドBを起動
        threadB.start();

        // 以下の処理はmainスレッドで実行される
        for (int i = 0; i < 5; i++) {
            logger.info("i = {}", i);
        }
    }
}
実行結果(1回目)
10:27:54.790 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 0
10:27:54.790 [main] INFO com.example.CounterMain -- i = 0
10:27:54.790 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 0
10:27:54.792 [main] INFO com.example.CounterMain -- i = 1
10:27:54.792 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 1
10:27:54.792 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 1
10:27:54.792 [main] INFO com.example.CounterMain -- i = 2
10:27:54.792 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 2
10:27:54.792 [main] INFO com.example.CounterMain -- i = 3
10:27:54.792 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 3
10:27:54.792 [main] INFO com.example.CounterMain -- i = 4
10:27:54.792 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 4
10:27:54.792 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 2
10:27:54.792 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 3
10:27:54.792 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 4

Thread-A・Thread-B・mainの3つのスレッドの処理が、ごちゃ混ぜに実行されていることが分かります(各スレッド内での処理はちゃんと上から順に実行されています)。

3つのスレッドの処理がどのような順番で実行されるかは、OSがその時の状況(他のプロセスなど)によって決めるため、実行結果は毎回異なります。

実行結果(2回目)
10:33:05.614 [main] INFO com.example.CounterMain -- i = 0
10:33:05.614 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 0
10:33:05.614 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 0
10:33:05.615 [main] INFO com.example.CounterMain -- i = 1
10:33:05.616 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 1
10:33:05.616 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 1
10:33:05.616 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 2
10:33:05.616 [main] INFO com.example.CounterMain -- i = 2
10:33:05.616 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 3
10:33:05.616 [Thread-B] INFO com.example.CounterRunnable -- Runnable-B: i = 4
10:33:05.616 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 2
10:33:05.616 [main] INFO com.example.CounterMain -- i = 3
10:33:05.616 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 3
10:33:05.616 [main] INFO com.example.CounterMain -- i = 4
10:33:05.616 [Thread-A] INFO com.example.CounterRunnable -- Runnable-A: i = 4

通常は後述するスレッドプールを使うため、直接Threadクラスを使うことはほとんどありません。

スレッドプールの利用

スレッドの生成はやや時間のかかる処理です。なのでTomcatなどのアプリケーションサーバーでは、起動時に数百個のスレッドをあらかじめ生成しておいてスレッドプールという場所に保管しておき、リクエストを受け付けたらそこからスレッドを1つ取り出して処理を実行します。

起動時に全てのスレッドを起動するスレッドプールのほか、必要になった際に初めてスレッドを生成して処理終了後にそのスレッドはキャッシュしておくスレッドプールなど、様々な種類のスレッドプールがあります。

スレッドプールを表すのが ExecutorServiceインタフェース です。そして Executors.newFixedThreadPool()メソッド でスレッドプールを生成します。

スレッドプールからスレッドを1つ取り出して処理を実行するには、ExecutorServiceexecute()メソッドにRunnable実装を渡します。

ThreadPoolMain.java
package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolMain {

    private static final Logger logger = LoggerFactory.getLogger(ThreadPoolMain.class);

    public static void main(String[] args) {
        // 3つのスレッドを持つスレッドプールを作成
        // 処理終了時にスレッドプールを削除するように、try-with-resourcesを利用する
        try (ExecutorService executorService = Executors.newFixedThreadPool(3)) {
            // スレッドプール内のスレッドで処理を実行
            executorService.execute(new CounterRunnable("Runnable-A"));
            executorService.execute(new CounterRunnable("Runnable-B"));
            executorService.execute(new CounterRunnable("Runnable-C"));
            executorService.execute(new CounterRunnable("Runnable-D"));  // 1〜3番目のいずれかの処理が終わるまで待たされる
        }
    }
}
実行結果
10:51:06.570 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-C: i = 0
10:51:06.571 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-C: i = 1
10:51:06.570 [pool-1-thread-2] INFO com.example.CounterRunnable -- Runnable-B: i = 0
10:51:06.570 [pool-1-thread-1] INFO com.example.CounterRunnable -- Runnable-A: i = 0
10:51:06.571 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-C: i = 2
10:51:06.571 [pool-1-thread-1] INFO com.example.CounterRunnable -- Runnable-A: i = 1
10:51:06.571 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-C: i = 3
10:51:06.571 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-C: i = 4
10:51:06.571 [pool-1-thread-2] INFO com.example.CounterRunnable -- Runnable-B: i = 1
10:51:06.571 [pool-1-thread-1] INFO com.example.CounterRunnable -- Runnable-A: i = 2
10:51:06.571 [pool-1-thread-1] INFO com.example.CounterRunnable -- Runnable-A: i = 3
10:51:06.571 [pool-1-thread-1] INFO com.example.CounterRunnable -- Runnable-A: i = 4
10:51:06.571 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-D: i = 0
10:51:06.571 [pool-1-thread-2] INFO com.example.CounterRunnable -- Runnable-B: i = 2
10:51:06.572 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-D: i = 1
10:51:06.572 [pool-1-thread-2] INFO com.example.CounterRunnable -- Runnable-B: i = 3
10:51:06.572 [pool-1-thread-2] INFO com.example.CounterRunnable -- Runnable-B: i = 4
10:51:06.572 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-D: i = 2
10:51:06.572 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-D: i = 3
10:51:06.572 [pool-1-thread-3] INFO com.example.CounterRunnable -- Runnable-D: i = 4

各ログの[]で囲まれた部分がスレッド名です(自動的に名付けられます)。スレッド名を見ると、3つのスレッドで処理を実行していることが分かります。

更によく見てみると、4つ目のRunnable-Dの処理は、Runnable-Cの処理が終わったあとに同じスレッドで実行されていることが分かります。今回はスレッドプール内のスレッド数を3に指定したため、同時に実行できる処理は3つまでです。それ以上の処理については、いずれかのスレッドの処理が終わるまで待ち行列で待たされることになるのです。

スレッドローカル

スレッドローカルは、各スレッドごとに持つ値の保存場所です。スレッドローカルを表すのが ThreadLocalクラス です。通常はこのThreadLocalをクラスのstatic finalなフィールドとして作成します。

JDK 25でScoped Valueという機能が導入されますので、基本的にはそちらを使うべきです。しかし、Spring 6はJDK 17で実装されているため、変わらず旧来のThreadLocalを使っています。

まずはThreadLocalを保持するクラスを作成し、値をセット・取得・削除するメソッドを作成します。

NameHolder.java
package com.example;

public class NameHolder {
    private NameHolder() {}

    // static finalなフィールドとしてThreadLocalを作成
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void setName(String name) {
        // スレッドローカルに名前をセットする
        threadLocal.set(name);
    }

    public static String getName() {
        // スレッドローカルから名前を取得する
        return threadLocal.get();
    }

    public static void remove() {
        // スレッドローカルから名前を削除する
        threadLocal.remove();
    }
}

次にRunnable実装クラスを作成します。この中で、スレッドローカルに値をセットします。

HelloRunnable.java
package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HelloRunnable implements Runnable {

    private static final Logger logger = LoggerFactory.getLogger(HelloRunnable.class);

    private final String name;

    private final HelloService helloService;

    public HelloRunnable(String name, HelloService helloService) {
        this.name = name;
        this.helloService = helloService;
    }

    @Override
    public void run() {
        try {
            // スレッドローカルに値をセット
            NameHolder.setName(name);
            logger.info("Call service");
            // 他のクラスを呼び出す
            helloService.greet();
            logger.info("Finish service");
        } finally {
            // 処理が終わったら、必ずスレッドローカルから値を削除する
            logger.info("Remove the name from ThreadLocal");
            NameHolder.remove();
        }
    }
}

ポイントは、finally節の中でスレッドローカルから値を削除していることです。ずっとスレッドローカルに値が残っていると、

  • 同じスレッドで別のユーザーが処理を行ったときに、前のユーザーの情報が見えてしまう
  • 値がずっと残り続けると、メモリを圧迫して最悪の場合OutOfMemoryErrorでアプリケーションが停止してしまう

などの問題が起こります。値が必要なくなったら、必ずスレッドローカルから値を削除しましょう。

そして、Runnable実装クラスから呼ばれるクラスを作成します。このクラスでは、スレッドローカルにセットされた値を取得してログ出力します。

HelloService.java
package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HelloService {
    private static final Logger logger = LoggerFactory.getLogger(HelloService.class);

    public void greet() {
        // HelloRunnableがスレッドローカルにセットした名前を取得
        String name = NameHolder.getName();
        logger.info("Hello, {}!", name);
    }
}

最後にmain()メソッドを作成して実行します。

ThreadLocalMain.java
package com.example;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalMain {
    public static void main(String[] args) {
        try (ExecutorService executorService = Executors.newFixedThreadPool(2)) {
            HelloService helloService = new HelloService();
            executorService.submit(new HelloRunnable("Runnable-A", helloService));
            executorService.submit(new HelloRunnable("Runnable-B", helloService));
        }
    }
}
実行結果
11:04:49.773 [pool-1-thread-1] INFO com.example.HelloRunnable -- Call service
11:04:49.773 [pool-1-thread-2] INFO com.example.HelloRunnable -- Call service
11:04:49.779 [pool-1-thread-1] INFO com.example.HelloService -- Hello, Runnable-A!
11:04:49.779 [pool-1-thread-2] INFO com.example.HelloService -- Hello, Runnable-B!
11:04:49.780 [pool-1-thread-1] INFO com.example.HelloRunnable -- Finish service
11:04:49.780 [pool-1-thread-1] INFO com.example.HelloRunnable -- Remove the name from threadLocal
11:04:49.780 [pool-1-thread-2] INFO com.example.HelloRunnable -- Finish service
11:04:49.780 [pool-1-thread-2] INFO com.example.HelloRunnable -- Remove the name from threadLocal

pool-1-thread-1スレッドのスレッドローカルには"Runnable-A"、pool-1-thread-2スレッドのスレッドローカルには"Runnable-B"という値が保存されていることが分かります。

このように、スレッドローカルにはスレッドごとに異なる値を保存できます。

参考書籍

Discussion