@Async とは、メソッドに付けるだけで「呼び出し元を待たせず、別スレッドで処理を走らせる」ための Spring のアノテーションです。 設定クラスに @EnableAsync を付け、Spring が管理する Bean の public メソッドに @Async を付けると、呼び出しはすぐに戻り、本体は TaskExecutor(スレッドを用意する部品)に渡されて動きます。

この記事では、そのまま貼れるコード全文、設定項目の一覧、@Async が効かないときの原因表を1ページにまとめました。スレッドプールが「いつ増えるか」は Java 21 で動かした出力を載せています。

確認環境:Spring Boot 4.1.1/Spring Framework 7.0.9 の公式リファレンスと Javadoc で確認(確認日 2026年10月7日)。Java 単体で再現できる部分は Ubuntu 24.04・OpenJDK 21.0.10 で実行。

@Async の早見表

やりたいこと 書き方 補足
非同期を有効にする 設定クラスに @EnableAsync これが無いと同期のまま動く
投げっぱなしで実行 @Async + 戻り値 void 例外は呼び出し元に戻らない
結果を受け取る 戻り値を CompletableFuture<T> join() や thenApply でつなぐ
Executor を使い分ける @Async("mailExecutor") Bean 名(または修飾子名)を指定
プールの大きさを変える spring.task.execution.pool.* 自動構成の Executor に効く
void の例外を拾う AsyncUncaughtExceptionHandler AsyncConfigurer で登録
仮想スレッドで動かす spring.threads.virtual.enabled=true Java 21 以上

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot @Async|非同期の使い方と設定一覧・効かない原因表」

最小構成のコード全文(設定・サービス・呼び出し側)

まずは Spring Boot が自動で用意する Executor をそのまま使う、いちばん短い形です。公式リファレンスには「コンテキストに Executor の Bean が無い場合、Spring Boot は AsyncTaskExecutor を自動構成する」とあります。

依存関係(Spring Boot 4.x・Maven)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

Spring Boot 4.0 の移行ガイドでは、spring-boot-starter-web は spring-boot-starter-webmvc に名前が変わったと案内されています。@Async 自体は Spring Framework の機能なので、専用のスターターは不要です。

設定クラス

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
}

サービス(void と CompletableFuture)

package com.example.demo.service;

import java.util.concurrent.CompletableFuture;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class ReportService {

    private static final Logger log = LoggerFactory.getLogger(ReportService.class);

    /** 投げっぱなし:呼び出し元は結果を待たない */
    @Async
    public void sendSummaryMail(String to) {
        log.info("メール送信を開始 to={} thread={}", to, Thread.currentThread().getName());
        // 時間のかかる送信処理
    }

    /** 結果を返す:呼び出し元は CompletableFuture で受け取る */
    @Async
    public CompletableFuture<Integer> countOrders(String month) {
        log.info("注文件数を集計 month={} thread={}", month, Thread.currentThread().getName());
        int count = 120; // 時間のかかる集計処理の結果
        return CompletableFuture.completedFuture(count);
    }

    @Async
    public CompletableFuture<Integer> countMembers(String month) {
        int count = 45;
        return CompletableFuture.completedFuture(count);
    }
}

呼び出し側(複数の結果をまとめる)

package com.example.demo.web;

import java.util.Map;
import java.util.concurrent.CompletableFuture;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.service.ReportService;

@RestController
public class ReportController {

    private final ReportService reportService;

    public ReportController(ReportService reportService) {
        this.reportService = reportService;
    }

    /** 2つの集計を同時に走らせ、両方そろってから返す */
    @GetMapping("/reports/{month}")
    public Map<String, Integer> summary(@PathVariable String month) {
        CompletableFuture<Integer> orders = reportService.countOrders(month);
        CompletableFuture<Integer> members = reportService.countMembers(month);

        CompletableFuture.allOf(orders, members).join();

        return Map.of("orders", orders.join(), "members", members.join());
    }

    /** 送信は裏で進め、画面にはすぐ応答する */
    @PostMapping("/reports/{month}/mail")
    public Map<String, String> mail(@PathVariable String month) {
        reportService.sendSummaryMail("admin@example.com");
        return Map.of("status", "accepted");
    }
}

ポイントは「@Async を付けたメソッドを、別の Bean から呼ぶ」ことです。 コントローラーに注入された reportService は Spring が作ったプロキシ(本体の手前に立つ代理オブジェクト)で、このプロキシが呼び出しを受け取って Executor に渡します。

戻り値の型ごとの例外の届き方

公式リファレンスが例に挙げている戻り値は void・Future・CompletableFuture の3つです。例外がどこに届くかは戻り値の型で決まります。

戻り値の型 結果の受け取り 例外の届き先
void 受け取らない 呼び出し元には戻らない。既定ではログに出るだけ
Future<T> get() で待つ get() を呼んだときに投げられる
CompletableFuture<T> join()・thenApply など join()/get() のとき。exceptionally で代替値も可

この違いは Spring を使わない Java だけでも再現できます。次のコードは、戻り値なしの実行と CompletableFuture での実行を比べたものです。

import java.util.concurrent.*;

public class ReturnTypeDemo {
    static final ThreadLocal<String> USER = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2, r -> {
            Thread t = new Thread(r);
            t.setName("async-worker");
            t.setUncaughtExceptionHandler((th, ex) ->
                    System.out.println("[ハンドラー] " + th.getName() + " で捕捉: " + ex.getMessage()));
            return t;
        });

        // 1) 戻り値なし:例外は呼び出し側に戻らない
        try {
            pool.execute(() -> { throw new IllegalStateException("void の中で失敗"); });
            System.out.println("1) 呼び出し側: 例外は届かずに次の行へ進む");
        } catch (Exception e) {
            System.out.println("1) 呼び出し側で捕捉: " + e);
        }
        Thread.sleep(200);

        // 2) CompletableFuture:join() / get() のときに例外が届く
        CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {
            throw new IllegalStateException("Future の中で失敗");
        }, pool);
        try {
            f.join();
        } catch (CompletionException e) {
            System.out.println("2) join() で捕捉: " + e.getClass().getSimpleName() + " / 原因=" + e.getCause().getMessage());
        }
        System.out.println("2) exceptionally で代替値: " + f.exceptionally(ex -> "代替値").join());

        // 3) ThreadLocal は別スレッドに引き継がれない
        USER.set("tanaka");
        System.out.println("3) 呼び出し側スレッド: " + USER.get());
        System.out.println("3) 別スレッド: " + CompletableFuture.supplyAsync(USER::get, pool).join());

        // 4) 包んで渡すと引き継げる(TaskDecorator と同じ考え方)
        String captured = USER.get();
        Callable<String> decorated = () -> {
            USER.set(captured);
            try { return USER.get(); } finally { USER.remove(); }
        };
        System.out.println("4) 包んで渡した別スレッド: " + pool.submit(decorated).get());

        pool.shutdown();
    }
}

実行結果(java ReturnTypeDemo.java、OpenJDK 21.0.10):

1) 呼び出し側: 例外は届かずに次の行へ進む
[ハンドラー] async-worker で捕捉: void の中で失敗
2) join() で捕捉: CompletionException / 原因=Future の中で失敗
2) exceptionally で代替値: 代替値
3) 呼び出し側スレッド: tanaka
3) 別スレッド: null
4) 包んで渡した別スレッド: tanaka

1) では try-catch で囲んでも例外は届かず、別スレッド側のハンドラーだけが受け取っています。Spring の @Async で void を返すメソッドも同じ形になり、その受け皿が次の AsyncUncaughtExceptionHandler です。3) と 4) は後半の「別スレッドに引き継がれないもの」で使います。

用途別に Executor を分け、void の例外も拾う設定の全文

メール送信と集計のように性質の違う処理は、Executor を分けておくと片方が混んでももう片方に影響が出にくくなります。あわせて void メソッドの例外を受け取るハンドラーも登録します。

設定クラス(AsyncConfigurer を実装)

package com.example.demo.config;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    /** メール送信用:件数が多く1件は短い */
    @Bean("mailExecutor")
    public ThreadPoolTaskExecutor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setKeepAliveSeconds(30);
        executor.setThreadNamePrefix("mail-");
        // 満杯のときは呼び出し元のスレッドで実行し、受付の勢いを自然に抑える
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        return executor;
    }

    /** 集計用:件数は少なく1件が長い */
    @Bean("reportExecutor")
    public ThreadPoolTaskExecutor reportExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(2);
        executor.setQueueCapacity(20);
        executor.setThreadNamePrefix("report-");
        return executor;
    }

    /** 名前を付けない @Async が使う Executor */
    @Override
    public Executor getAsyncExecutor() {
        return mailExecutor();
    }

    /** 戻り値 void の @Async メソッドで起きた例外の受け皿 */
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new LoggingAsyncExceptionHandler();
    }
}

例外ハンドラー

package com.example.demo.config;

import java.lang.reflect.Method;
import java.util.Arrays;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;

public class LoggingAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(LoggingAsyncExceptionHandler.class);

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.error("非同期処理で例外: method={} params={}",
                method.toGenericString(), Arrays.toString(params), ex);
        // 必要に応じて通知や再実行の登録を行う
    }
}

Executor を名前で指定するサービス

package com.example.demo.service;

import java.util.concurrent.CompletableFuture;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class ReportService {

    @Async("mailExecutor")
    public void sendSummaryMail(String to) {
        // 送信処理
    }

    @Async("reportExecutor")
    public CompletableFuture<Integer> countOrders(String month) {
        int count = 120;
        return CompletableFuture.completedFuture(count);
    }

    @Async("reportExecutor")
    public CompletableFuture<Integer> countMembers(String month) {
        int count = 45;
        return CompletableFuture.completedFuture(count);
    }
}

@Async("...") の値には、コンテナにある Executor の Bean 名か、Executor に付けた修飾子の名前を書けます(Spring Framework リファレンス)。

自前の Executor を登録したときの動きは、Spring Boot 4.1.1 リファレンスに次のように書かれています。

状況 動き
Executor の Bean が無い Boot が AsyncTaskExecutor を自動構成する
自前の Executor を登録 自動構成は引っ込み、@EnableAsync は自前のものを使う
AsyncConfigurer を登録 @EnableAsync は AsyncConfigurer の Executor を使う
Spring MVC の非同期処理 applicationTaskExecutor という名前の Bean が必要
spring.task.execution.mode=force 自前の Executor があっても自動構成を行う

上の全文のように自前で new した ThreadPoolTaskExecutor には spring.task.execution.* は反映されません。プロパティで調整したい場合は、最小構成の形(自動構成の Executor)を使うか、Boot が用意する ThreadPoolTaskExecutorBuilder の Bean から組み立てます。

spring.task.execution の設定項目と既定値

自動構成される ThreadPoolTaskExecutor について、公式リファレンスは「コアスレッドは8本で、負荷に応じて増減する」「キューは既定で上限なし」「待機スレッドは既定で60秒後に回収」と説明しています。

application.yml の全文

spring:
  task:
    execution:
      thread-name-prefix: "async-"
      pool:
        core-size: 8
        max-size: 16
        queue-capacity: 100
        keep-alive: 10s
        allow-core-thread-timeout: true
      shutdown:
        await-termination: true
        await-termination-period: 30s

公式の例(max-size=16・queue-capacity=100・keep-alive=10s)では、「キューが上限つきになり、100件たまったらスレッドが最大16本まで増える。空いたスレッドは60秒ではなく10秒で回収される」と説明されています。

項目の一覧

プロパティ名は spring.task.execution. に続けて書きます。

プロパティ 意味 既定
pool.core-size 常に確保するスレッド数 8
pool.max-size 増やせる上限(公式の例は16) —
pool.queue-capacity 待ち行列の長さ 上限なし
pool.keep-alive 余ったスレッドを回収するまでの時間 60秒
pool.allow-core-thread-timeout コアスレッドも回収対象にするか —
thread-name-prefix スレッド名の先頭 —
shutdown.await-termination 終了時に実行中の処理を待つか —
shutdown.await-termination-period 待つ最大時間 —
simple.concurrency-limit 同時実行数の上限(プールなしの Executor 用) —
simple.reject-tasks-when-limit-reached 上限到達時に待たせず拒否するか —
mode force で自動構成を必ず行う —

既定の欄に値があるものは Spring Boot 4.1.1 リファレンスの本文に書かれている値です。「—」の項目は、リファレンス本文または TaskExecutionProperties の Javadoc(4.1.1)に項目として載っているものです。

仮想スレッドを有効にしたとき

Java 21 以上で次の1行を加えると、自動構成される Executor が切り替わります。

spring:
  threads:
    virtual:
      enabled: true
項目 無効(既定) 有効
@Async 用の Executor ThreadPoolTaskExecutor 仮想スレッドの SimpleAsyncTaskExecutor
スレッドの再利用 プールで再利用 処理ごとに新しいスレッド
pool.* の設定 有効 プールが無いため出番がない
同時実行数の絞り方 pool.max-size とキュー simple.concurrency-limit
@Scheduled 用 ThreadPoolTaskScheduler(1本) SimpleAsyncTaskScheduler。プール関連は無視

SimpleAsyncTaskExecutor の Javadoc によると、同時実行数は既定で無制限(-1)で、上限に達したときは既定では投入側を待たせます。外部の API や DB の接続数に合わせて上限を決めたいときは simple.concurrency-limit を指定します。

spring:
  threads:
    virtual:
      enabled: true
  task:
    execution:
      simple:
        concurrency-limit: 50

TaskExecutor の実装5種

Spring Framework 7.0.9 リファレンスに載っている、あらかじめ用意された実装です。

実装 特徴 使いどころ
SyncTaskExecutor 呼び出したスレッドでそのまま実行 単純なテスト
SimpleAsyncTaskExecutor 再利用せず毎回新しいスレッド。同時実行数の上限と仮想スレッドに対応 仮想スレッドでの実行
ConcurrentTaskExecutor java.util.concurrent.Executor のアダプター 既存の Executor を包む
ThreadPoolTaskExecutor 最もよく使われる。プールの設定を Bean のプロパティで行う 通常の非同期処理
DefaultManagedTaskExecutor JNDI で取得した ManagedExecutorService を使う Jakarta EE のアプリサーバー上

スレッドプールは「キューが満杯になってから」増える

max-size を大きくしたのにスレッドが増えない、という相談はよくあります。理由はプールの増え方の順番にあります。Spring Framework リファレンスの説明は次の4段階です。

  1. 動いているスレッドがコア数より少なければ、空きスレッド(または新しいスレッド)で実行する
  2. コア数に達していたら、キューに空きがあるかぎりキューに入れる
  3. キューが満杯になって初めて、コア数を超えてスレッドを増やす
  4. 最大数にも達していたら、タスクを拒否する

ThreadPoolTaskExecutor の中身である JDK の ThreadPoolExecutor で確かめます。コア2・最大4で、8件のタスクを続けて投入します。

import java.util.concurrent.*;

public class PoolGrowthDemo {
    public static void main(String[] args) throws Exception {
        run("キュー上限3(core=2, max=4)", new LinkedBlockingQueue<>(3));
        run("キュー上限なし(core=2, max=4)", new LinkedBlockingQueue<>());
    }

    static void run(String title, BlockingQueue<Runnable> queue) throws Exception {
        System.out.println("== " + title + " ==");
        ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, queue);
        CountDownLatch release = new CountDownLatch(1);
        for (int i = 1; i <= 8; i++) {
            String result;
            try {
                pool.execute(() -> {
                    try { release.await(); } catch (InterruptedException ignored) { }
                });
                result = "受付";
            } catch (RejectedExecutionException e) {
                result = "拒否(RejectedExecutionException)";
            }
            System.out.printf("タスク%d: %s  スレッド数=%d  キュー待ち=%d%n",
                    i, result, pool.getPoolSize(), pool.getQueue().size());
        }
        release.countDown();
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
        System.out.println("最大到達スレッド数=" + pool.getLargestPoolSize());
        System.out.println();
    }
}

実行結果(java PoolGrowthDemo.java、OpenJDK 21.0.10):

== キュー上限3(core=2, max=4) ==
タスク1: 受付  スレッド数=1  キュー待ち=0
タスク2: 受付  スレッド数=2  キュー待ち=0
タスク3: 受付  スレッド数=2  キュー待ち=1
タスク4: 受付  スレッド数=2  キュー待ち=2
タスク5: 受付  スレッド数=2  キュー待ち=3
タスク6: 受付  スレッド数=3  キュー待ち=3
タスク7: 受付  スレッド数=4  キュー待ち=3
タスク8: 拒否(RejectedExecutionException)  スレッド数=4  キュー待ち=3
最大到達スレッド数=4

== キュー上限なし(core=2, max=4) ==
タスク1: 受付  スレッド数=1  キュー待ち=0
タスク2: 受付  スレッド数=2  キュー待ち=0
タスク3: 受付  スレッド数=2  キュー待ち=1
タスク4: 受付  スレッド数=2  キュー待ち=2
タスク5: 受付  スレッド数=2  キュー待ち=3
タスク6: 受付  スレッド数=2  キュー待ち=4
タスク7: 受付  スレッド数=2  キュー待ち=5
タスク8: 受付  スレッド数=2  キュー待ち=6
最大到達スレッド数=2

上限3のキューでは、3件たまった後の6件目で3本目のスレッドが作られ、8件目で拒否されました。キューに上限が無い場合は最大4を指定していても2本のままで、残りはすべてキューで待ちます。Spring Boot の既定(キュー上限なし)で max-size だけを変えても同時実行数が変わらないのはこのためです。

サイズとキューの決め方

決めるもの 考え方
core-size ふだん同時に走らせたい数。待ち時間の長い処理(外部 API・メール送信)は多め、計算中心の処理は CPU コア数前後
queue-capacity 待たせてよい件数。上限を付けると max-size が働き、たまりすぎにも気づける
max-size 一時的な山に備える上限。接続先(DB の接続プール、相手側の同時接続数)を超えない値に
拒否時の動き 既定は AbortPolicy で TaskRejectedException。CallerRunsPolicy にすると呼び出し元のスレッドで実行される
終了時の動き await-termination を有効にすると、停止時に実行中の処理を待てる

拒否時のポリシーは Spring Framework リファレンスに AbortPolicy(既定)・DiscardPolicy・DiscardOldestPolicy・CallerRunsPolicy の4つが挙げられています。CPU コア数は Runtime.getRuntime().availableProcessors() で確認できます。

@Async が効かない原因の早見表

「付けたのに同期で動く」ときは、ログに出るスレッド名を見ると早く切り分けられます。呼び出し元と同じスレッド名なら、プロキシを通っていません。

原因 なぜ効かないか 直し方
@EnableAsync を付けていない @Async を処理する仕組みが有効になっていない 設定クラスに @EnableAsync
同じクラス内から呼んでいる 既定のプロキシ方式では、プロキシ経由の呼び出しだけが横取りされる 別の Bean に移して注入する
private や final のメソッド サブクラスで上書きできず、プロキシが割り込めない public にし、final を外す
@PostConstruct から呼んでいる ライフサイクルのコールバックとは併用できない 初期化用の別 Bean から呼ぶ
new したインスタンス Spring の Bean でないとプロキシが作られない コンテナから注入して使う

各項目の根拠は、Spring Framework 7.0.9 リファレンスの Task Execution and Scheduling、Proxying Mechanisms と @EnableAsync の Javadoc です。@EnableAsync の Javadoc には「プロキシ方式ではプロキシ経由の呼び出しだけが横取りされ、同じクラス内のローカルな呼び出しでは @Async は無視される」と明記されています。

自己呼び出しを直す例

直す前(register から同じクラスの sendMail を呼んでいる):

package com.example.demo.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class MemberService {

    public void register(String email) {
        // 会員登録の処理
        sendMail(email); // this.sendMail(...) と同じ。プロキシを通らず同期で動く
    }

    @Async
    public void sendMail(String email) {
        // 送信処理
    }
}

直した後(非同期のメソッドを別の Bean に移す):

package com.example.demo.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class MemberMailSender {

    @Async
    public void sendMail(String email) {
        // 送信処理
    }
}
package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class MemberService {

    private final MemberMailSender mailSender;

    public MemberService(MemberMailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void register(String email) {
        // 会員登録の処理
        mailSender.sendMail(email); // 注入されたプロキシ経由。別スレッドで動く
    }
}

@PostConstruct から呼びたいときの例

公式リファレンスの形に沿って、初期化を担当する Bean を分けます。

package com.example.demo.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class CacheWarmer {

    @Async
    public void warmUp() {
        // 起動後に時間をかけて行う読み込み
    }
}
package com.example.demo.service;

import jakarta.annotation.PostConstruct;

import org.springframework.stereotype.Component;

@Component
public class CacheWarmerInitializer {

    private final CacheWarmer cacheWarmer;

    public CacheWarmerInitializer(CacheWarmer cacheWarmer) {
        this.cacheWarmer = cacheWarmer;
    }

    @PostConstruct
    public void initialize() {
        cacheWarmer.warmUp(); // 別 Bean の @Async メソッドを呼ぶ
    }
}

別スレッドに引き継がれないものと対処

@Async のメソッドは呼び出し元とは別のスレッドで動くため、スレッドにひも付く情報はそのままでは渡りません。

引き継がれないもの 起きること 対処
トランザクション 呼び出し元の @Transactional は別スレッドに伝わらない 非同期側のメソッドで改めてトランザクションを張る
未コミットのデータ 非同期側から見えないことがある コミット後に呼ぶ。ID だけ渡して読み直す
ThreadLocal の値 別スレッドでは null(前掲の出力 3)) TaskDecorator でコピーする
例外(void の場合) 呼び出し元の try-catch に届かない CompletableFuture を返すかハンドラーを登録

トランザクションについては @Transactional の Javadoc(7.0.9)に「現在の実行スレッドにひも付くトランザクションを扱い、メソッド内で新しく開始したスレッドには伝わらない」と書かれています。

非同期側でトランザクションを張る例

package com.example.demo.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderHistoryWriter {

    /** 別スレッドで動き、このメソッドの中だけで完結するトランザクションを使う */
    @Async
    @Transactional
    public void write(Long orderId) {
        // orderId で注文を読み直し、履歴テーブルに書き込む
    }
}

引数はエンティティそのものではなく ID にしておくと、非同期側で最新の状態を読み直せます。

ThreadLocal の値を TaskDecorator でコピーする例

TaskDecorator は、実行直前の Runnable を包むためのコールバックです。Javadoc には主な用途として「タスクの実行の前後に実行コンテキストを設定すること」が挙げられています。前掲の出力 4) と同じ考え方を Spring の部品で書くと次のようになります。

package com.example.demo.config;

public final class RequestIdHolder {

    private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();

    private RequestIdHolder() {
    }

    public static void set(String requestId) {
        HOLDER.set(requestId);
    }

    public static String get() {
        return HOLDER.get();
    }

    public static void clear() {
        HOLDER.remove();
    }
}
package com.example.demo.config;

import org.springframework.core.task.TaskDecorator;

public class RequestIdTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // ここは呼び出し元のスレッドで動く
        String requestId = RequestIdHolder.get();
        return () -> {
            // ここからは非同期側のスレッドで動く
            RequestIdHolder.set(requestId);
            try {
                runnable.run();
            } finally {
                RequestIdHolder.clear();
            }
        };
    }
}

登録は Executor の設定に1行足します。

executor.setTaskDecorator(new RequestIdTaskDecorator());

プールのスレッドは使い回されるので、finally で値を消しておくと、次の処理に前の値が残りません。

@Async に向く処理と、別の方法が合う処理

処理の性質 合う方法 理由
応答を待たせたくない通知・メール送信 @Async(void) 画面の応答と切り離せる
独立した複数の問い合わせをまとめたい @Async(CompletableFuture) 同時に走らせて合計時間を短くできる
再起動しても必ず実行したい処理 メッセージキューやジョブ管理の仕組み @Async のキューはメモリ上にある
決まった時刻・間隔で動かす処理 @Scheduled 時刻で起動する仕組みが別にある
数ミリ秒で終わる軽い処理 同期のまま スレッドの受け渡しのほうが重くなる
順番を守る必要がある処理 同期、または1本のキューで直列に 並行に走ると順番が前後する

判断に迷ったら「この処理が失敗したり消えたりしたとき、後から気づけて、やり直せるか」を基準にすると選びやすくなります。

よくある質問

Q. Spring Boot でも @EnableAsync は必要ですか? A. 必要です。Spring Boot が自動で用意するのは Executor までで、@Async を処理する仕組みは @EnableAsync で有効にします。公式リファレンスも「@EnableAsync を使った非同期タスクの実行」に自動構成の Executor が使われると説明しています。

Q. max-size を増やしたのに同時実行数が増えません。 A. キューに上限が無いと、コア数を超えるスレッドは作られません。queue-capacity に上限を付けるか、core-size を増やします。前掲の出力で、上限なしのキューでは最大4を指定しても2本のままでした。

Q. @Async と CompletableFuture.supplyAsync はどう使い分けますか? A. @Async は Spring の Bean のメソッド単位で、Executor や例外ハンドラーを設定でまとめて管理できます。メソッドの中の一部分だけを並行にしたいときは supplyAsync に Executor を渡す書き方が向いています。

Q. コントローラーで CompletableFuture を join すると、非同期の意味は残りますか? A. 複数の処理を同時に走らせる効果は残ります。2つの集計を順番に呼ぶと合計時間がかかりますが、同時に走らせれば長いほうの時間で済みます。リクエストのスレッド自体は join() の間は待っています。

まとめ

  • @EnableAsync を付け、Bean の public メソッドに @Async、呼び出しは別の Bean からが基本形
  • 例外の届き先は戻り値で決まる:void は AsyncUncaughtExceptionHandler、CompletableFuture は join()/exceptionally
  • Spring Boot 4.1.1 の既定はコア8本・キュー上限なし・回収60秒。spring.task.execution.pool.* で調整する
  • スレッドはキューが満杯になってから増える。max-size を働かせるには queue-capacity に上限を付ける
  • 仮想スレッドを有効にすると SimpleAsyncTaskExecutor に切り替わり、同時実行数は simple.concurrency-limit で絞る
  • 効かないときは5つを確認:@EnableAsync、自己呼び出し、private/final、@PostConstruct、new したインスタンス
  • トランザクションと ThreadLocal は別スレッドに渡らない。非同期側で張り直す、TaskDecorator でコピーする

参考・出典

確認日はいずれも 2026年10月7日です。

  • Spring Boot リファレンス(4.1.1)Task Execution and Scheduling:https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html
  • Spring Framework リファレンス(7.0.9)Task Execution and Scheduling:https://docs.spring.io/spring-framework/reference/integration/scheduling.html
  • Spring Framework リファレンス(7.0.9)Proxying Mechanisms:https://docs.spring.io/spring-framework/reference/core/aop/proxying.html
  • @EnableAsync Javadoc(7.0.9):https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/EnableAsync.html
  • ThreadPoolTaskExecutor Javadoc(7.0.9):https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/concurrent/ThreadPoolTaskExecutor.html
  • SimpleAsyncTaskExecutor Javadoc(7.0.9):https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/task/SimpleAsyncTaskExecutor.html
  • TaskDecorator Javadoc(7.0.9):https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/task/TaskDecorator.html
  • @Transactional Javadoc(7.0.9):https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/annotation/Transactional.html
  • TaskExecutionProperties Javadoc(4.1.1):https://docs.spring.io/spring-boot/api/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.html
  • Spring Boot 4.0 Migration Guide:https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide
  • Spring Boot プロジェクトページ(現行 4.1.1):https://spring.io/projects/spring-boot

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot @Async|非同期の使い方と設定一覧・効かない原因表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?