Spring Boot 3.4 Virtual Threads: Питання Співбесід і Бенчмарки Продуктивності

Опануйте Virtual Threads з Java 21 та Spring Boot 3.4: 15 питань для співбесід, бенчмарки продуктивності й шаблони міграції для успішних технічних інтерв'ю.

Spring Boot 3.4 Virtual Threads: питання співбесід і бенчмарки продуктивності

Virtual Threads — одна з найважливіших новацій Java 21, а Spring Boot 3.4 інтегрує їх нативно. Ця можливість Project Loom змінює спосіб роботи з конкурентністю в backend-додатках. Технічні співбесіди тепер перевіряють розуміння внутрішніх механізмів, доречних сценаріїв застосування та типових пасток.

Порада щодо Підготовки

Інтерв'юери розрізняють кандидатів, які розуміють Virtual Threads, від тих, хто застосовує їх наосліп. Знати, коли НЕ варто їх використовувати, не менш важливо, ніж знати їхні переваги.

Основи Virtual Threads

Питання 1: Що таке Virtual Thread і чим він відрізняється від Platform Thread?

Virtual Thread — це легкий потік, яким керує JVM, а не операційна система. На відміну від Platform Threads (класичних потоків), Virtual Threads не відображаються напряму на потоки ОС. JVM здатна створити їх мільйони з мінімальним споживанням пам'яті.

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), він залишається прикріпленим до потоку ОС. Virtual Thread натомість "відмонтовується" від потоку-носія і дає змогу іншим Virtual Threads використати цей носій.

Питання 2: Як увімкнути Virtual Threads у Spring Boot 3.4?

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 Threads. Цей механізм забезпечує оптимальне використання ресурсів 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 Threads.

Технічна Примітка

Кількість потоків-носіїв за замовчуванням відповідає кількості ядер CPU. JVM динамічно регулює цей пул через ForkJoinPool.

Сценарії та Антипатерни

Питання 4: Коли Virtual Threads дають приріст продуктивності?

Virtual Threads сяють у I/O-навантаженнях: зовнішні REST-виклики, запити до БД, читання файлів. Ці операції більшу частину часу очікують, і саме під час очікування 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);
    }
}

Виграш походить від обробки значно більшої кількості одночасних запитів. З 200 Platform Threads і запитами по 100 мс максимальна пропускна здатність — 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 також знижують споживання пам'яті, оскільки кожен потік виділяє лише кілька КБ замість приблизно 1 МБ у Platform Thread.

Питання 8: Як налаштовувати пули з'єднань із Virtual Threads?

Пули з'єднань (HikariCP, Lettuce) стають новим вузьким місцем за наявності Virtual Threads. Пул на 10 з'єднань обмежує одночасні запити до БД до десяти, навіть за мільйонів Virtual Threads.

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 Threads. Стандартний PostgreSQL витримує близько 100-200 активних з'єднань.

Питання 9: Як виміряти вплив Virtual Threads за допомогою Micrometer?

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 | Реактивний | |----------|-----------------|------------| | Крива навчання | Низька | Висока | | Дебаг | Класичний стек викликів | Складний | | Backpressure | Ручне | Нативне | | Екосистема | Зростаюча | Зріла | | Міграція legacy | Проста | Перепис |

Для нових застосунків і міграцій рекомендовано Virtual Threads. Реактивне програмування залишається доречним там, де потрібен складний backpressure.

Питання 15: Як працювати з ThreadLocal у Virtual Threads?

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 трансформують backend-розробку на Java, дозволяючи писати простий і високопродуктивний код. Ключові тези:

Основи:

  • Активація через spring.threads.virtual.enabled=true
  • Ідеальні для I/O-навантажень (REST, БД, файли)
  • Уникати для CPU-інтенсивних обчислень

Продуктивність:

  • Правильно підбирати розмір пулів з'єднань (нове вузьке місце)
  • Моніторити pinning через -Djdk.tracePinnedThreads
  • Мігрувати synchronized на ReentrantLock

Міграція:

  • Тестувати поступово, групами ендпоінтів
  • Перевіряти сумісність залежностей
  • Використовувати Structured Concurrency для паралельних операцій

Володіння Virtual Threads вирізняє кандидатів, які розуміють виклики продуктивності сучасних застосунків. Ці концепції стали обов'язковими на технічних співбесідах за Spring Boot.

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Теги

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

Поділитися

Пов'язані статті