Spring Boot 3.4 Virtual Threads: Domande di Colloquio e Benchmark di Performance

Padroneggia i Virtual Threads di Java 21 con Spring Boot 3.4: 15 domande da colloquio, benchmark di performance e schemi di migrazione per superare i colloqui tecnici.

Spring Boot 3.4 Virtual Threads: domande di colloquio e benchmark di performance

I Virtual Threads rappresentano una delle innovazioni più rilevanti di Java 21 e Spring Boot 3.4 li integra nativamente. Questa caratteristica del Project Loom trasforma la gestione della concorrenza nelle applicazioni backend. I colloqui tecnici valutano oggi la comprensione dei meccanismi interni, dei casi d'uso adatti e degli errori comuni da evitare.

Consiglio per la Preparazione

Gli intervistatori distinguono i candidati che capiscono i Virtual Threads da quelli che li usano alla cieca. Sapere quando NON impiegarli è importante quanto conoscerne i benefici.

Fondamenti dei Virtual Threads

Domanda 1: Cos'è un Virtual Thread e in cosa differisce da un Platform Thread?

Un Virtual Thread è un thread leggero gestito dalla JVM e non dal sistema operativo. A differenza dei Platform Threads (i thread tradizionali), i Virtual Threads non vengono mappati direttamente sui thread del SO. La JVM può crearne milioni con un consumo di memoria minimo.

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();
        }
    }
}

La differenza fondamentale risiede nel comportamento durante il blocco. Quando un Platform Thread si blocca (I/O, sleep), resta legato al thread del SO. Un Virtual Thread invece si "smonta" dal thread carrier, lasciandolo libero per altri Virtual Threads.

Domanda 2: Come si attivano i Virtual Threads in Spring Boot 3.4?

Spring Boot 3.4 riduce l'attivazione dei Virtual Threads a una singola proprietà di configurazione. L'intero framework si adatta automaticamente: Tomcat, controller REST e chiamate bloccanti beneficiano subito di questa ottimizzazione.

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());
        };
    }
}

L'attivazione modifica il comportamento di Tomcat: invece di un pool di thread fisso, ogni richiesta riceve il proprio Virtual Thread. Questo approccio elimina il classico collo di bottiglia del pool di thread.

Domanda 3: Spiega i concetti di "mounting" e "unmounting"

Il mounting indica l'aggancio di un Virtual Thread a un thread carrier (Platform Thread). L'unmounting avviene durante le operazioni bloccanti e libera il carrier per altri Virtual Threads. Questo meccanismo consente un utilizzo ottimale delle risorse di 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();
    }
}

Questo meccanismo è trasparente per lo sviluppatore. Il codice viene scritto in stile imperativo classico, ma la JVM ottimizza automaticamente l'utilizzo dei carrier. Un pool composto da pochi carrier può servire milioni di Virtual Threads.

Nota Tecnica

Il numero di thread carrier corrisponde di default al numero di core della CPU. La JVM regola questo pool dinamicamente tramite il ForkJoinPool.

Casi d'Uso e Anti-pattern

Domanda 4: Quando i Virtual Threads producono guadagni di performance?

I Virtual Threads brillano sui carichi I/O-bound: chiamate REST esterne, query a database, lettura di file. Queste operazioni passano la maggior parte del tempo in attesa, intervallo durante il quale il Virtual Thread libera il proprio carrier.

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);
    }
}

Il guadagno deriva dalla gestione di un numero molto maggiore di richieste concorrenti. Con 200 Platform Threads e richieste da 100ms, il throughput massimo è di 2.000 req/s. Con i Virtual Threads si possono raggiungere oltre 50.000 req/s sulla stessa macchina.

Domanda 5: Quali anti-pattern evitare con i Virtual Threads?

I Virtual Threads non sono adatti ai carichi CPU-bound né ai casi che generano "pinning". Il pinning si verifica quando un Virtual Thread resta legato al proprio carrier nonostante il blocco, vanificando i benefici della virtualizzazione.

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();
        }
    }
}

Il pinning trasforma un Virtual Thread in un Platform Thread dal punto di vista delle risorse. Le cause principali sono i blocchi synchronized e le chiamate native JNI. La migrazione a ReentrantLock risolve il primo caso.

Domanda 6: Come rilevare il pinning in un'applicazione?

La JVM offre opzioni di diagnostica per identificare i casi di pinning. Queste informazioni sono essenziali durante la migrazione ai Virtual Threads, perché il pinning può degradare le performance invece di migliorarle.

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)

L'analisi dei log di pinning rivela gli hotspot da correggere. Un'applicazione con pinning frequente non sfrutta appieno i Virtual Threads e può risultare addirittura più lenta rispetto ai Platform Threads.

Pronto a superare i tuoi colloqui su Spring Boot?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Benchmark di Performance

Domanda 7: Quali guadagni di performance ci si può aspettare con i Virtual Threads?

I benchmark mostrano miglioramenti notevoli sulle applicazioni I/O-bound tipiche. I guadagni dipendono dal rapporto tra tempo di I/O e tempo di CPU e dal livello di concorrenza richiesto.

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

I Virtual Threads riducono anche il consumo di memoria, perché ogni thread occupa solo pochi KB invece dei circa 1MB di un Platform Thread.

Domanda 8: Come configurare i pool di connessione con i Virtual Threads?

I pool di connessione (HikariCP, Lettuce) diventano il nuovo collo di bottiglia con i Virtual Threads. Un pool da 10 connessioni limita a 10 le query simultanee al database, anche con milioni di 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!");
        }
    }
}

Il dimensionamento del pool dipende dalla capacità del database, non dal numero di Virtual Threads. Un PostgreSQL standard regge circa 100-200 connessioni attive.

Domanda 9: Come misurare l'impatto dei Virtual Threads con Micrometer?

Micrometer e Spring Boot Actuator forniscono metriche essenziali per valutare l'efficacia dei Virtual Threads. Queste metriche convalidano i guadagni e identificano problemi potenziali.

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}

L'analisi delle metriche rivela il rapporto Virtual Threads / Carriers e identifica i periodi di contesa sul pool di connessioni.

Migrazione e Compatibilità

Domanda 10: Quali librerie sono compatibili con i Virtual Threads?

La compatibilità dipende dall'uso di blocchi synchronized e dalle chiamate native. L'ecosistema Spring è ampiamente compatibile, ma alcune librerie richiedono versioni specifiche.

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");
            }
        };
    }
}

La maggior parte dei framework moderni è già migrata a ReentrantLock. Per le dipendenze legacy un test di carico con -Djdk.tracePinnedThreads=short rivela i problemi di pinning.

Domanda 11: Come migrare progressivamente ai Virtual Threads?

Una migrazione progressiva permette di convalidare i guadagni e identificare problemi senza mettere a rischio la produzione. La strategia consigliata isola gli endpoint con Virtual Threads da quelli tradizionali.

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
Attenzione

Non attivare i Virtual Threads in modo globale prima di aver testato tutte le dipendenze. Il pinning può degradare le performance in modo significativo.

Domanda 12: Quali test eseguire prima del rilascio in produzione?

La validazione dei Virtual Threads richiede test di carico, test di pinning e test di compatibilità. Questi test devono simulare condizioni realistiche di produzione.

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);
    }
}

I test devono coprire scenari di contesa (pool di connessioni saturo), timeout e carico sostenuto per diversi minuti.

Domande Avanzate

Domanda 13: Come interagiscono i Virtual Threads con la Structured Concurrency?

La Structured Concurrency (JEP 453) completa i Virtual Threads garantendo che i task concorrenti condividano lo stesso ciclo di vita. Questo approccio semplifica la gestione degli errori e delle cancellazioni.

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
    }
}

La Structured Concurrency previene i thread leak e semplifica il debug, perché lo stack di chiamata riflette la struttura logica del codice.

Domanda 14: Qual è la differenza tra Virtual Threads e programmazione reattiva?

I due approcci risolvono lo stesso problema (efficienza I/O) ma con modelli di programmazione diversi. I Virtual Threads consentono codice imperativo classico, mentre la programmazione reattiva richiede una riscrittura sotto forma di stream.

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()
            ));
    }
}

| Criterio | Virtual Threads | Reattivo | |----------|-----------------|----------| | Curva di apprendimento | Bassa | Alta | | Debugging | Stack trace classici | Complesso | | Backpressure | Manuale | Nativo | | Ecosistema | In crescita | Maturo | | Migrazione legacy | Semplice | Riscrittura |

I Virtual Threads sono consigliati per nuove applicazioni e migrazioni. Il modello reattivo resta rilevante per i casi che richiedono backpressure sofisticato.

Domanda 15: Come gestire ThreadLocal con i Virtual Threads?

ThreadLocal funziona con i Virtual Threads ma consuma memoria per ogni istanza. Gli Scoped Values (JEP 446) offrono un'alternativa più efficiente per condividere il contesto.

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);
    }
}

Gli ScopedValues sono consigliati per i nuovi sviluppi. Per il codice legacy che utilizza ThreadLocal è preferibile una migrazione progressiva.

Pronto a superare i tuoi colloqui su Spring Boot?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Conclusione

I Virtual Threads trasformano lo sviluppo backend Java permettendo codice semplice e ad alte prestazioni. Punti chiave:

Fondamenti:

  • Attivazione con spring.threads.virtual.enabled=true
  • Ideali per carichi I/O-bound (REST, DB, file)
  • Da evitare per calcoli intensivi sulla CPU

Performance:

  • Dimensionare correttamente i pool di connessione (nuovo collo di bottiglia)
  • Monitorare il pinning con -Djdk.tracePinnedThreads
  • Migrare synchronized verso ReentrantLock

Migrazione:

  • Testare progressivamente per gruppi di endpoint
  • Validare la compatibilità delle dipendenze
  • Sfruttare la Structured Concurrency per le operazioni parallele

Padroneggiare i Virtual Threads distingue i candidati che comprendono le sfide di performance delle applicazioni moderne. Questi concetti sono ormai imprescindibili nei colloqui tecnici su Spring Boot.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

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

Condividi

Articoli correlati