Spring Boot 3.4 Virtual Threads: 面接質問とパフォーマンスベンチマーク

Spring Boot 3.4でJava 21 Virtual Threadsを使いこなしましょう。15の面接質問、パフォーマンスベンチマーク、技術面接を突破するための移行パターンを解説します。

Spring Boot 3.4 Virtual Threads: 面接質問とパフォーマンスベンチマーク

Virtual ThreadsはJava 21における最も重要な進歩のひとつであり、Spring Boot 3.4はそれをネイティブに統合しています。Project Loomのこの機能は、バックエンドアプリケーションにおける並行処理のあり方を大きく変えます。技術面接では、内部メカニズムの理解、適切なユースケース、避けるべき典型的な落とし穴が問われるようになっています。

準備のヒント

面接官はVirtual Threadsを本当に理解している候補者と、何となく使っているだけの候補者を見分けます。利点を知ることと同じくらい、いつ使うべきでないかを把握することが重要です。

Virtual Threadsの基礎

質問1: Virtual ThreadとPlatform Threadはどう違うのか

Virtual ThreadはOSではなくJVMが管理する軽量なスレッドです。Platform Threads(従来のスレッド)とは異なり、Virtual ThreadはOSスレッドへ直接マッピングされません。JVMはわずかなメモリ消費で数百万ものVirtual Threadを生成できます。

VirtualThreadDemo.javajava
// Comparing thread creation approaches
public class VirtualThreadDemo {

    public void demonstrateDifference() {
        // Platform Thread: ~1MB stack per thread
        // Practical limit: a few thousand on a standard JVM
        Thread platformThread = new Thread(() -> {
            performBlockingOperation();
        });

        // Virtual Thread: ~a few KB per thread
        // Can create millions without issues
        Thread virtualThread = Thread.ofVirtual().start(() -> {
            performBlockingOperation();
        });
    }

    // A Virtual Thread "mounts" onto a Platform Thread (carrier)
    // During I/O blocking, it releases the carrier for other Virtual Threads
    private void performBlockingOperation() {
        try {
            Thread.sleep(1000); // Virtual Thread detaches from carrier here
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

本質的な違いはブロッキング時のふるまいにあります。Platform ThreadはI/OやsleepでブロックされてもなおキャリアであるOSスレッドを保持し続けます。一方Virtual Threadはキャリアスレッドからアンマウントし、他のVirtual Threadがそのキャリアを利用できるようにします。

質問2: Spring Boot 3.4でVirtual Threadsを有効化する方法

Spring Boot 3.4ではVirtual Threadsの有効化が単一の設定プロパティに集約されています。フレームワーク全体が自動的に追従し、Tomcat、RESTコントローラー、ブロッキング呼び出しが即座にこの最適化の恩恵を受けます。

yaml
# application.yml
# Global Virtual Threads activation
spring:
  threads:
    virtual:
      enabled: true

# Optional Tomcat pool configuration
server:
  tomcat:
    threads:
      max: 200  # Less critical with Virtual Threads
      min-spare: 10
WebConfig.javajava
// Programmatic activation if needed
@Configuration
public class WebConfig {

    @Bean
    public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
        // Each HTTP request runs on a Virtual Thread
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

有効化によりTomcatの動作が変わります。固定サイズのスレッドプールではなく、リクエストごとに専用のVirtual Threadが割り当てられるためです。これにより従来のスレッドプールのボトルネックが解消されます。

質問3: 「mounting」と「unmounting」の概念を説明してください

MountingはVirtual Threadをキャリアスレッド(Platform Thread)に取り付けることを指します。UnmountingはブロッキングオペレーションでキャリアをほかのVirtual Threadに解放する動きです。この仕組みによりCPUリソースを最大限に活用できます。

MountingDemo.javajava
// Illustrating the mounting/unmounting cycle
public class MountingDemo {

    public void demonstrateMounting() {
        Thread.ofVirtual().start(() -> {
            // MOUNTED: Virtual Thread is using a carrier thread
            System.out.println("Carrier: " + getCurrentCarrier());

            // UNMOUNTING: releases carrier during blocking
            performDatabaseQuery(); // Blocking JDBC call

            // REMOUNTED: may be on a different carrier
            System.out.println("New carrier: " + getCurrentCarrier());
        });
    }

    // Blocking operations trigger unmounting automatically
    private void performDatabaseQuery() {
        // JDBC connection, file read, network call...
        // Virtual Thread detaches during I/O wait
    }

    private String getCurrentCarrier() {
        // Gets the current carrier thread name
        return Thread.currentThread().toString();
    }
}

この仕組みは開発者には透過的です。コードは従来どおりの命令型スタイルで記述しますが、JVMがキャリア利用を自動最適化します。少数のキャリアからなるプールでも数百万のVirtual Threadを処理できます。

技術メモ

キャリアスレッドの数は標準でCPUコア数と一致します。JVMはForkJoinPoolを通じてこのプールを動的に調整します。

ユースケースとアンチパターン

質問4: Virtual Threadsはどのようなときに性能向上をもたらすのか

Virtual Threadsは外部REST呼び出し、データベースクエリ、ファイル読み込みなどI/Oバウンドの処理で真価を発揮します。これらの処理は大半の時間を待機に費やし、その間Virtual Threadはキャリアを解放します。

IOBoundService.javajava
// Ideal case for Virtual Threads
@Service
public class IOBoundService {

    private final RestClient restClient;
    private final UserRepository userRepository;

    // Each call involves network and database waiting
    // Virtual Threads shine here
    public UserProfile enrichUserProfile(Long userId) {
        // DB call - VT detaches during SQL query
        User user = userRepository.findById(userId).orElseThrow();

        // External REST call - VT detaches during HTTP wait
        ExternalData externalData = restClient
            .get()
            .uri("/api/external/{id}", userId)
            .retrieve()
            .body(ExternalData.class);

        // Data aggregation
        return new UserProfile(user, externalData);
    }
}

性能向上は、より多くの同時リクエストを処理できることに由来します。Platform Thread 200本と100msのリクエストでは最大スループットが2,000 req/sですが、Virtual Threadsでは同一マシン上で50,000 req/s超に達する可能性があります。

質問5: Virtual Threadsで避けるべきアンチパターンは何か

Virtual ThreadsはCPUバウンドのワークロードや「pinning」が発生するケースには不向きです。pinningは、ブロッキング中にもVirtual Threadがキャリアに張り付いてしまい、仮想化の利点が打ち消される現象です。

AntiPatterns.javajava
// Examples of cases to avoid
@Service
public class AntiPatterns {

    // ANTI-PATTERN 1: CPU-intensive computation
    // Virtual Threads provide no benefit here
    public BigInteger computeFactorial(int n) {
        // 100% CPU, no I/O, no unmounting possible
        BigInteger result = BigInteger.ONE;
        for (int i = 2; i <= n; i++) {
            result = result.multiply(BigInteger.valueOf(i));
        }
        return result; // Carrier is monopolized throughout
    }

    // ANTI-PATTERN 2: Synchronized causes pinning
    private final Object lock = new Object();

    public void pinnedOperation() {
        synchronized (lock) { // PINNING: VT stays on carrier
            performDatabaseQuery(); // Unmounting doesn't happen!
        }
    }

    // SOLUTION: Use ReentrantLock
    private final ReentrantLock reentrantLock = new ReentrantLock();

    public void unpinnedOperation() {
        reentrantLock.lock();
        try {
            performDatabaseQuery(); // Unmounting possible
        } finally {
            reentrantLock.unlock();
        }
    }
}

リソース観点で見ると、pinningはVirtual ThreadをPlatform Threadに変えてしまいます。主な原因はsynchronizedブロックとJNIによるネイティブ呼び出しです。ReentrantLockへの移行で前者は解消できます。

質問6: アプリケーションでpinningを検出する方法

JVMはpinningのケースを特定する診断オプションを備えています。Virtual Threadsへ移行する際、この情報は欠かせません。pinningは性能を改善するどころか低下させてしまう可能性があるためです。

PinningDiagnostics.javajava
// Configuration and pinning detection
public class PinningDiagnostics {

    // JVM option to log pinning
    // -Djdk.tracePinnedThreads=full (detailed)
    // -Djdk.tracePinnedThreads=short (summary)

    // Example code causing pinning
    public void demonstratePinning() {
        Thread.ofVirtual().start(() -> {
            synchronized (this) {
                // This log will appear with tracePinnedThreads enabled
                try {
                    Thread.sleep(100); // Pinned during sleep!
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });
    }
}
bash
# Typical output with -Djdk.tracePinnedThreads=full
Thread[#23,VirtualThread[#1]/runnable@ForkJoinPool-1-worker-1,5,CarrierThreads]
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
    java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
    com.example.PinningDiagnostics.demonstratePinning(PinningDiagnostics.java:15)

pinningログを分析することで、修正すべきホットスポットが浮かび上がります。pinningが頻発するアプリケーションはVirtual Threadsの利点を活かせず、Platform Threadsより遅くなることさえあります。

Spring Bootの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

パフォーマンスベンチマーク

質問7: Virtual Threadsで期待できる性能向上はどの程度か

ベンチマークでは、典型的なI/Oバウンドアプリケーションで顕著な改善が示されます。改善幅はI/O時間とCPU時間の比率、必要となる並行度の水準に依存します。

BenchmarkController.javajava
// Endpoint for measuring performance
@RestController
@RequestMapping("/api/benchmark")
public class BenchmarkController {

    private final ExternalApiClient apiClient;

    // Simulation of a typical endpoint with external calls
    @GetMapping("/user/{id}")
    public ResponseEntity<UserData> getUser(@PathVariable Long id) {
        // Three sequential I/O calls
        UserInfo info = apiClient.fetchUserInfo(id);        // ~50ms
        List<Order> orders = apiClient.fetchOrders(id);     // ~80ms
        CreditScore score = apiClient.fetchCreditScore(id); // ~100ms

        return ResponseEntity.ok(new UserData(info, orders, score));
    }
}
text
# Benchmark results - 10,000 concurrent requests
# Configuration: 8 cores, 16GB RAM, simulated latency 230ms/request

Platform Threads (pool 200):
- Throughput: 850 req/s
- P99 latency: 1250ms
- Heap memory: 2.1GB

Virtual Threads:
- Throughput: 4200 req/s
- P99 latency: 280ms
- Heap memory: 850MB

Gain: 5x throughput, 4.5x P99 latency reduction

Virtual Threadsはメモリ消費も抑えます。スレッドあたり数KBしか確保せず、Platform Threadのおよそ1MBに比べてはるかに軽量です。

質問8: Virtual Threadsを使う場合のコネクションプールの設定方法

Virtual Threadsを利用すると、HikariCPやLettuceなどのコネクションプールが新たなボトルネックとなります。10コネクションのプールでは、たとえVirtual Threadが数百万あってもDBクエリの同時実行は10件に制限されます。

yaml
# application.yml
# HikariCP configuration optimized for Virtual Threads
spring:
  datasource:
    hikari:
      # More connections since Virtual Threads allow more concurrency
      maximum-pool-size: 50
      minimum-idle: 10
      # Shorter timeout due to more concurrent requests
      connection-timeout: 5000
      # Fast validation
      validation-timeout: 3000

  # Redis with Lettuce - already async-friendly
  data:
    redis:
      lettuce:
        pool:
          max-active: 50
          max-idle: 20
ConnectionPoolMonitor.javajava
// Pool monitoring to avoid contention
@Component
public class ConnectionPoolMonitor {

    private final HikariDataSource dataSource;

    @Scheduled(fixedRate = 10000)
    public void logPoolStats() {
        HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
        log.info("Pool stats - Active: {}, Idle: {}, Waiting: {}",
            pool.getActiveConnections(),
            pool.getIdleConnections(),
            pool.getThreadsAwaitingConnection());

        // Alert if too many threads waiting
        if (pool.getThreadsAwaitingConnection() > 100) {
            log.warn("Connection pool contention detected!");
        }
    }
}

プールサイズの目安はデータベースのキャパシティに依存し、Virtual Thread数とは関係ありません。標準的なPostgreSQLは100〜200のアクティブコネクションを扱える程度です。

質問9: MicrometerでVirtual Threadsの効果を測定するには

MicrometerとSpring Boot ActuatorはVirtual Threadsの効果を評価するための重要なメトリクスを提供します。これらのメトリクスにより、利点を裏付け、潜在的な問題を見つけられます。

VirtualThreadMetrics.javajava
// Custom metrics for Virtual Threads
@Component
public class VirtualThreadMetrics {

    private final MeterRegistry registry;
    private final AtomicLong activeVirtualThreads = new AtomicLong(0);

    @PostConstruct
    public void registerMetrics() {
        // Active Virtual Threads counter
        Gauge.builder("virtual.threads.active", activeVirtualThreads, AtomicLong::get)
            .description("Number of active virtual threads")
            .register(registry);

        // JVM metrics for carriers
        Gauge.builder("virtual.threads.carriers", this::getCarrierCount)
            .description("Number of carrier threads")
            .register(registry);
    }

    private double getCarrierCount() {
        // Gets the ForkJoinPool carrier count
        return ForkJoinPool.commonPool().getPoolSize();
    }

    // Interceptor to trace requests
    public void trackVirtualThread(Runnable task) {
        activeVirtualThreads.incrementAndGet();
        try {
            task.run();
        } finally {
            activeVirtualThreads.decrementAndGet();
        }
    }
}
yaml
# application.yml
# Metrics exposure
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
  metrics:
    tags:
      application: ${spring.application.name}

メトリクスを分析するとVirtual Threads/Carriersの比率が見え、コネクションプールでの競合期間も特定できます。

移行と互換性

質問10: Virtual Threadsと互換性のあるライブラリは何か

互換性はsynchronizedブロックの利用とネイティブ呼び出しの有無に左右されます。Springエコシステムは概ね互換性を備えていますが、一部のライブラリは特定バージョンを必要とします。

CompatibilityCheck.javajava
// Checking dependency compatibility
@Configuration
public class CompatibilityCheck {

    // Compatible libraries (recommended versions)
    // - Spring Boot 3.2+ (native support)
    // - HikariCP 5.1+ (ReentrantLock instead of synchronized)
    // - Lettuce 6.3+ (non-blocking I/O)
    // - Jackson 2.16+ (no synchronized)

    // Libraries requiring attention
    // - JDBC drivers: check version
    // - Some legacy HTTP clients

    @Bean
    public CommandLineRunner checkCompatibility() {
        return args -> {
            // Log versions for audit
            log.info("Java version: {}", System.getProperty("java.version"));
            log.info("Virtual threads available: {}",
                Thread.ofVirtual() != null);

            // Support verification
            if (Runtime.version().feature() < 21) {
                throw new IllegalStateException(
                    "Java 21+ required for Virtual Threads");
            }
        };
    }
}

大半のモダンなフレームワークはすでにReentrantLockへ移行済みです。レガシーな依存関係には-Djdk.tracePinnedThreads=shortを有効にしたロードテストでpinning問題を洗い出せます。

質問11: Virtual Threadsへ段階的に移行する方法

段階的な移行は、本番に影響を与えずに利点を確認し問題を洗い出すために役立ちます。推奨される戦略は、Virtual Threadsを使うエンドポイントを従来型のものから分離することです。

DualExecutorConfig.javajava
// Configuration for progressive migration
@Configuration
public class DualExecutorConfig {

    // Traditional executor for legacy endpoints
    @Bean("platformExecutor")
    public ExecutorService platformExecutor() {
        return Executors.newFixedThreadPool(200);
    }

    // Virtual Threads executor for new endpoints
    @Bean("virtualExecutor")
    public ExecutorService virtualExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

@RestController
@RequestMapping("/api/v2")
public class MigratedController {

    @Qualifier("virtualExecutor")
    private final ExecutorService executor;

    // Endpoint migrated to Virtual Threads
    @GetMapping("/users/{id}")
    public CompletableFuture<User> getUser(@PathVariable Long id) {
        return CompletableFuture.supplyAsync(() -> {
            // Business code unchanged
            return userService.findById(id);
        }, executor);
    }
}
yaml
# application.yml
# Feature flag for migration
features:
  virtual-threads:
    enabled: true
    endpoints:
      - /api/v2/**
      - /api/reports/**

# Disable for quick rollback
# features.virtual-threads.enabled: false
注意

依存関係の検証を済ませる前にグローバルでVirtual Threadsを有効化すべきではありません。pinningによって性能が大幅に劣化する恐れがあります。

質問12: 本番デプロイ前に必要なテストは何か

Virtual Threadsの検証にはロードテスト、pinningテスト、互換性テストが欠かせません。これらのテストは現実の本番条件を模擬する必要があります。

VirtualThreadLoadTest.javajava
// Load test with JUnit and Virtual Threads
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class VirtualThreadLoadTest {

    @LocalServerPort
    private int port;

    @Test
    void shouldHandleHighConcurrency() throws Exception {
        int concurrentRequests = 5000;
        CountDownLatch latch = new CountDownLatch(concurrentRequests);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger errorCount = new AtomicInteger(0);

        HttpClient client = HttpClient.newHttpClient();

        // Launch 5000 simultaneous requests
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < concurrentRequests; i++) {
                executor.submit(() -> {
                    try {
                        HttpRequest request = HttpRequest.newBuilder()
                            .uri(URI.create("http://localhost:" + port + "/api/test"))
                            .build();

                        HttpResponse<String> response = client.send(
                            request, BodyHandlers.ofString());

                        if (response.statusCode() == 200) {
                            successCount.incrementAndGet();
                        } else {
                            errorCount.incrementAndGet();
                        }
                    } catch (Exception e) {
                        errorCount.incrementAndGet();
                    } finally {
                        latch.countDown();
                    }
                });
            }
        }

        latch.await(60, TimeUnit.SECONDS);

        // Performance assertions
        assertThat(successCount.get()).isGreaterThan(4900); // >98% success
        assertThat(errorCount.get()).isLessThan(100);
    }
}

テストでは競合シナリオ(コネクションプール枯渇)、タイムアウト、数分間継続する負荷を網羅する必要があります。

高度な質問

質問13: Virtual ThreadsとStructured Concurrencyの関係は

Structured Concurrency(JEP 453)はVirtual Threadsを補完し、並行タスクが同じライフサイクルを共有することを保証します。このアプローチはエラー処理とキャンセル処理を簡潔にします。

StructuredConcurrencyExample.javajava
// Combining Virtual Threads + Structured Concurrency
public class StructuredConcurrencyExample {

    public UserDashboard fetchDashboard(Long userId) throws Exception {
        // StructuredTaskScope ensures all tasks complete together
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

            // Three parallel tasks on Virtual Threads
            Subtask<UserProfile> profileTask = scope.fork(() ->
                userService.getProfile(userId));

            Subtask<List<Notification>> notifTask = scope.fork(() ->
                notificationService.getRecent(userId));

            Subtask<AccountBalance> balanceTask = scope.fork(() ->
                accountService.getBalance(userId));

            // Wait for all tasks or fail on first error
            scope.join();
            scope.throwIfFailed();

            // All tasks succeeded - safe aggregation
            return new UserDashboard(
                profileTask.get(),
                notifTask.get(),
                balanceTask.get()
            );
        }
        // If one task fails, others are automatically cancelled
    }
}

Structured Concurrencyはスレッドリークを防ぎ、コールスタックがコードの論理構造を反映するためデバッグが容易になります。

質問14: Virtual Threadsとリアクティブプログラミングの違いは

どちらも同じ問題(I/O効率)に取り組みますが、プログラミングモデルが異なります。Virtual Threadsは従来の命令型コードを許容しますが、リアクティブはストリーム形式での書き直しを必要とします。

ComparisonService.javajava
// Same logic: imperative vs reactive
@Service
public class ComparisonService {

    // VIRTUAL THREADS APPROACH: classic imperative code
    // Easy to read, debug, and maintain
    public UserData getUserDataImperative(Long id) {
        User user = userRepository.findById(id).orElseThrow();
        List<Order> orders = orderRepository.findByUserId(id);
        PaymentInfo payment = paymentService.getInfo(id);

        return new UserData(user, orders, payment);
    }

    // REACTIVE APPROACH: streams and operators
    // More complex but native backpressure
    public Mono<UserData> getUserDataReactive(Long id) {
        return userRepository.findById(id)
            .zipWith(orderRepository.findByUserId(id).collectList())
            .zipWith(paymentService.getInfo(id))
            .map(tuple -> new UserData(
                tuple.getT1().getT1(),
                tuple.getT1().getT2(),
                tuple.getT2()
            ));
    }
}

| 観点 | Virtual Threads | リアクティブ | |------|-----------------|------------| | 学習コスト | 低い | 高い | | デバッグ | 古典的なスタックトレース | 複雑 | | バックプレッシャー | 手動 | ネイティブ | | エコシステム | 拡大中 | 成熟 | | レガシー移行 | 容易 | 書き直し |

新規アプリケーションや移行ではVirtual Threadsが推奨されます。リアクティブは精緻なバックプレッシャーが必要なケースで依然有用です。

質問15: Virtual ThreadsでのThreadLocalの扱いは

ThreadLocalはVirtual Threadsでも使えますが、インスタンスごとにメモリを消費します。Scoped Values(JEP 446)はコンテキスト共有のためのより効率的な代替手段です。

ThreadLocalVsScopedValue.javajava
// Comparing context approaches
public class ThreadLocalVsScopedValue {

    // CLASSIC APPROACH: ThreadLocal
    // Works but expensive with millions of VT
    private static final ThreadLocal<RequestContext> requestContext =
        new ThreadLocal<>();

    public void processWithThreadLocal(Request request) {
        requestContext.set(new RequestContext(request.getTraceId()));
        try {
            // Context accessible everywhere in the thread
            processRequest();
        } finally {
            requestContext.remove(); // Important to prevent leaks
        }
    }

    // MODERN APPROACH: ScopedValue (Java 21+)
    // More efficient, immutable, explicit scope
    private static final ScopedValue<RequestContext> CONTEXT =
        ScopedValue.newInstance();

    public void processWithScopedValue(Request request) {
        ScopedValue.where(CONTEXT, new RequestContext(request.getTraceId()))
            .run(() -> {
                // Context accessible within this scope
                processRequest();
                // No cleanup needed - automatic at scope end
            });
    }

    private void processRequest() {
        // Context access
        String traceId = CONTEXT.isBound()
            ? CONTEXT.get().getTraceId()
            : "unknown";
        log.info("Processing with trace: {}", traceId);
    }
}

ScopedValuesは新規開発で推奨されます。ThreadLocalを利用するレガシーコードは段階的な移行が望ましいです。

Spring Bootの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

まとめ

Virtual ThreadsはシンプルかつハイパフォーマンスなコードでJavaバックエンド開発を変革します。重要なポイント:

基礎:

  • spring.threads.virtual.enabled=trueで有効化
  • I/Oバウンドな処理(REST、DB、ファイル)に最適
  • CPU負荷の高い計算には不向き

性能:

  • コネクションプールを適切にサイジング(新たなボトルネック)
  • -Djdk.tracePinnedThreadsでpinningを監視
  • synchronizedReentrantLockへ移行

移行:

  • エンドポイント単位で段階的にテスト
  • 依存関係の互換性を検証
  • 並列処理にはStructured Concurrencyを活用

Virtual Threadsの理解は、現代アプリケーションの性能課題を捉えている候補者を際立たせます。これらの概念はSpring Bootの技術面接で必須の知識となっています。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#spring boot
#virtual threads
#java 21
#project loom
#performance

共有

関連記事