Spring Boot 3.4 Virtual Threads: Perguntas de Entrevista e Benchmarks de Performance

Domine as Virtual Threads do Java 21 com Spring Boot 3.4: 15 perguntas de entrevista, benchmarks de performance e padrões de migração para vencer entrevistas técnicas.

Spring Boot 3.4 Virtual Threads: perguntas de entrevista e benchmarks de performance

As Virtual Threads representam um dos avanços mais importantes do Java 21, e o Spring Boot 3.4 as integra de forma nativa. Esse recurso do Project Loom transforma o gerenciamento da concorrência em aplicações backend. As entrevistas técnicas passaram a avaliar a compreensão dos mecanismos internos, os casos de uso adequados e as armadilhas comuns que devem ser evitadas.

Dica de Preparação

Os entrevistadores diferenciam os candidatos que entendem as Virtual Threads daqueles que as utilizam às cegas. Saber quando NÃO usá-las é tão importante quanto conhecer seus benefícios.

Fundamentos das Virtual Threads

Pergunta 1: O que é uma Virtual Thread e como ela difere de uma Platform Thread?

Uma Virtual Thread é uma thread leve gerenciada pela JVM, e não pelo sistema operacional. Ao contrário das Platform Threads (threads tradicionais), as Virtual Threads não são mapeadas diretamente para threads do SO. A JVM consegue criar milhões delas com consumo mínimo de memória.

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

A diferença fundamental está no comportamento durante o bloqueio. Quando uma Platform Thread bloqueia (E/S, sleep), ela permanece presa à thread do SO. Uma Virtual Thread se "desmonta" da carrier thread, liberando-a para outras Virtual Threads.

Pergunta 2: Como ativar as Virtual Threads no Spring Boot 3.4?

O Spring Boot 3.4 reduz a ativação das Virtual Threads a uma única propriedade de configuração. Todo o framework se adapta automaticamente: Tomcat, controllers REST e chamadas bloqueantes passam a se beneficiar imediatamente dessa otimização.

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

A ativação muda o comportamento do Tomcat: em vez de um pool fixo de threads, cada requisição recebe sua própria Virtual Thread. Essa abordagem elimina o gargalo tradicional do pool de threads.

Pergunta 3: Explique o conceito de "mounting" e "unmounting"

O mounting designa o vínculo de uma Virtual Thread a uma carrier thread (Platform Thread). O unmounting ocorre durante operações bloqueantes e libera a carrier para outras Virtual Threads. Esse mecanismo permite o uso ótimo dos recursos de 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();
    }
}

Esse mecanismo é transparente para o desenvolvedor. O código segue o estilo imperativo clássico, mas a JVM otimiza automaticamente o uso das carriers. Um pool com poucas carriers consegue atender milhões de Virtual Threads.

Nota Técnica

O número de carrier threads geralmente corresponde à quantidade de núcleos da CPU. A JVM ajusta esse pool dinamicamente por meio do ForkJoinPool.

Casos de Uso e Antipadrões

Pergunta 4: Quando as Virtual Threads geram ganhos de performance?

As Virtual Threads brilham em cargas dominadas por E/S: chamadas REST externas, consultas a banco, leituras de arquivos. Essas operações passam a maior parte do tempo aguardando, intervalo no qual a Virtual Thread libera sua 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);
    }
}

O ganho vem do tratamento de muito mais requisições concorrentes. Com 200 Platform Threads e requisições de 100ms, o throughput máximo é de 2.000 req/s. Com Virtual Threads esse número pode ultrapassar 50.000 req/s na mesma máquina.

Pergunta 5: Quais antipadrões evitar com Virtual Threads?

As Virtual Threads não são apropriadas para cargas CPU-bound nem para situações que provoquem "pinning". O pinning ocorre quando uma Virtual Thread permanece presa à sua carrier apesar do bloqueio, anulando os benefícios da virtualização.

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

O pinning transforma uma Virtual Thread em uma Platform Thread do ponto de vista de recursos. As principais causas são blocos synchronized e chamadas nativas via JNI. A migração para ReentrantLock resolve o primeiro caso.

Pergunta 6: Como detectar pinning em uma aplicação?

A JVM oferece opções de diagnóstico para identificar casos de pinning. Essa informação é essencial durante a migração para Virtual Threads, já que o pinning pode degradar o desempenho em vez de melhorá-lo.

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)

A análise dos logs de pinning revela os pontos quentes que precisam ser corrigidos. Uma aplicação com pinning frequente não aproveita totalmente as Virtual Threads e pode ficar até mais lenta do que com Platform Threads.

Pronto para mandar bem nas entrevistas de Spring Boot?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Benchmarks de Performance

Pergunta 7: Que ganhos de performance esperar com Virtual Threads?

Os benchmarks mostram melhorias significativas em aplicações I/O-bound típicas. Os ganhos dependem da relação entre tempo de E/S e tempo de CPU, além do nível de concorrência exigido.

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

As Virtual Threads também reduzem o consumo de memória, já que cada thread aloca apenas alguns KB em vez dos cerca de 1MB de uma Platform Thread.

Pergunta 8: Como configurar pools de conexão com Virtual Threads?

Os pools de conexão (HikariCP, Lettuce) tornam-se o novo gargalo com as Virtual Threads. Um pool de 10 conexões limita a 10 as consultas simultâneas ao banco, mesmo com milhões de 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!");
        }
    }
}

O dimensionamento do pool depende da capacidade do banco, e não do número de Virtual Threads. Um PostgreSQL padrão suporta cerca de 100-200 conexões ativas.

Pergunta 9: Como medir o impacto das Virtual Threads com Micrometer?

O Micrometer e o Spring Boot Actuator fornecem métricas essenciais para avaliar a eficácia das Virtual Threads. Essas métricas validam os ganhos e identificam problemas potenciais.

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}

A análise das métricas revela a relação entre Virtual Threads e Carriers, além de identificar períodos de contenção sobre o pool de conexões.

Migração e Compatibilidade

Pergunta 10: Quais bibliotecas são compatíveis com Virtual Threads?

A compatibilidade depende do uso de blocos synchronized e de chamadas nativas. O ecossistema Spring é amplamente compatível, mas algumas bibliotecas exigem versões específicas.

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

A maior parte dos frameworks modernos já migrou para ReentrantLock. Para dependências legadas, um teste de carga com -Djdk.tracePinnedThreads=short revela os problemas de pinning.

Pergunta 11: Como migrar progressivamente para Virtual Threads?

Uma migração progressiva valida os ganhos e identifica problemas sem arriscar a produção. A estratégia recomendada isola os endpoints com Virtual Threads dos tradicionais.

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
Atenção

Não se deve ativar as Virtual Threads de forma global antes de testar todas as dependências. O pinning pode degradar o desempenho de maneira significativa.

Pergunta 12: Quais testes realizar antes de implantar em produção?

A validação das Virtual Threads exige testes de carga, testes de pinning e testes de compatibilidade. Esses testes precisam simular condições realistas de produção.

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

Os testes precisam cobrir cenários de contenção (pool de conexões saturado), timeouts e carga sustentada por vários minutos.

Perguntas Avançadas

Pergunta 13: Como as Virtual Threads interagem com a Structured Concurrency?

A Structured Concurrency (JEP 453) complementa as Virtual Threads garantindo que tarefas concorrentes compartilhem o mesmo ciclo de vida. Essa abordagem simplifica o tratamento de erros e cancelamentos.

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

A Structured Concurrency previne vazamentos de threads e simplifica a depuração, pois a call stack reflete a estrutura lógica do código.

Pergunta 14: Qual é a diferença entre Virtual Threads e programação reativa?

As duas abordagens resolvem o mesmo problema (eficiência de E/S), mas com modelos de programação distintos. As Virtual Threads permitem código imperativo clássico, enquanto a programação reativa exige reescrever tudo em forma de streams.

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

| Critério | Virtual Threads | Reativo | |----------|-----------------|---------| | Curva de aprendizado | Baixa | Alta | | Depuração | Stack traces clássicas | Complexa | | Backpressure | Manual | Nativo | | Ecossistema | Em crescimento | Maduro | | Migração de legado | Simples | Reescrita |

As Virtual Threads são recomendadas para novas aplicações e migrações. A programação reativa segue relevante em casos que exigem backpressure sofisticado.

Pergunta 15: Como lidar com ThreadLocal usando Virtual Threads?

ThreadLocal funciona com Virtual Threads, mas consome memória para cada instância. Os Scoped Values (JEP 446) oferecem alternativa mais eficiente para compartilhar contexto.

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

Os ScopedValues são recomendados para novos desenvolvimentos. Para código legado que utiliza ThreadLocal, sugere-se uma migração progressiva.

Pronto para mandar bem nas entrevistas de Spring Boot?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Conclusão

As Virtual Threads transformam o desenvolvimento backend Java ao permitir código simples e de alta performance. Pontos-chave:

Fundamentos:

  • Ativação com spring.threads.virtual.enabled=true
  • Ideais para cargas I/O-bound (REST, BD, arquivos)
  • Evitar para cálculos intensivos em CPU

Performance:

  • Dimensionar corretamente os pools de conexão (novo gargalo)
  • Monitorar pinning com -Djdk.tracePinnedThreads
  • Migrar synchronized para ReentrantLock

Migração:

  • Testar progressivamente por grupos de endpoints
  • Validar a compatibilidade das dependências
  • Aproveitar a Structured Concurrency para operações paralelas

Dominar as Virtual Threads diferencia os candidatos que entendem os desafios de performance das aplicações modernas. Esses conceitos hoje são indispensáveis nas entrevistas técnicas de Spring Boot.

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Tags

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

Compartilhar

Artigos relacionados