Post

The Complete Java Interview Preparation Guide (2026 Edition)

What actually gets asked at FAANG and top-tier companies for senior Java roles — from Java 21 features to system design rounds, with sample questions and ideal answers.

The Complete Java Interview Preparation Guide (2026 Edition)

Why Another Interview Guide?

I’ve been on both sides of the interview table for 17 years. I’ve hired senior engineers at companies across Singapore and conducted over 200 technical interviews. What I can tell you is this: the interview process for senior Java roles in 2025-2026 has shifted dramatically.

Companies no longer ask you to reverse a linked list and call it a day. For senior roles (Staff, Principal, Technical Architect), the bar has moved toward:

  • Deep Java knowledge — not syntax, but JVM internals and language design decisions
  • Spring Boot internals — not how to use it, but how it works
  • Concurrency — always the separator between mid and senior
  • System design — distributed systems thinking at scale
  • Live coding — clean, production-quality code under pressure

This guide covers exactly what I’ve seen asked — and what I ask — at companies like Google, Grab, Shopee, DBS, and ByteDance in the APAC region.


Section 1: Java 21+ Features They Expect You to Know

If you walk into an interview in 2026 still talking about Java 8 streams and Optional, you’re already behind. Here’s what interviewers expect senior candidates to understand:

Virtual Threads (Project Loom)

This is the most asked topic in 2025-2026 interviews. You need to understand why they exist, how they differ from platform threads, and when to use them.

Sample Question: “Explain virtual threads. When would you NOT use them?”

Ideal Answer:

1
2
3
4
5
6
7
8
9
// Creating virtual threads - the simple way
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

Virtual threads are lightweight threads managed by the JVM, not the OS. They’re cheap to create (a few hundred bytes vs. ~1MB for platform threads) and are ideal for I/O-bound workloads. A single JVM can run millions of virtual threads.

When NOT to use them:

  • CPU-bound tasks — Virtual threads don’t give you more CPU cores. Use platform thread pools sized to your core count.
  • When holding synchronized locks — Virtual threads get pinned to their carrier thread inside synchronized blocks. Use ReentrantLock instead.
  • ThreadLocal-heavy code — Each virtual thread has its own ThreadLocal, which defeats memory efficiency at scale.

Pattern Matching and Sealed Types

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Sealed interface with pattern matching - common interview pattern
sealed interface PaymentResult permits Success, Failure, Pending {}
record Success(String transactionId, BigDecimal amount) implements PaymentResult {}
record Failure(String errorCode, String message) implements PaymentResult {}
record Pending(String referenceId, Instant estimatedCompletion) implements PaymentResult {}

// Exhaustive pattern matching with switch
String describeResult(PaymentResult result) {
    return switch (result) {
        case Success s -> "Paid %s: %s".formatted(s.amount(), s.transactionId());
        case Failure f -> "Failed [%s]: %s".formatted(f.errorCode(), f.message());
        case Pending p -> "Pending until %s".formatted(p.estimatedCompletion());
    };
}

Why interviewers love this: It demonstrates understanding of type-safe domain modeling, algebraic data types, and how Java is evolving beyond traditional OOP.

Records and Compact Constructors

Sample Question: “When would you use a record vs. a class? What are the limitations?”

Records are transparent carriers for immutable data. Use them when:

  • The class is purely data (DTOs, value objects, events)
  • You want automatic equals(), hashCode(), toString()
  • Immutability is desired

Limitations: no inheritance (other than implementing interfaces), all fields are final, no additional instance fields.

1
2
3
4
5
6
7
8
9
10
// Compact constructor for validation
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        Objects.requireNonNull(currency, "Currency is required");
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}

Structured Concurrency (Preview in Java 21+)

1
2
3
4
5
6
7
8
9
10
11
12
// Structured concurrency - fetch user and orders in parallel
ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<User> userTask = scope.fork(() -> fetchUser(userId));
    Supplier<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
    
    scope.join();
    scope.throwIfFailed();
    
    return new UserDashboard(userTask.get(), ordersTask.get());
}

Key point for interviews: Structured concurrency ensures that child tasks don’t outlive the parent scope. If one fails, the other is cancelled. This prevents thread leaks and makes error handling predictable.


Section 2: Spring Boot Internals

At senior levels, interviewers don’t want to know that you can use @Autowired. They want to know HOW Spring Boot works.

Auto-Configuration Deep Dive

Sample Question: “Walk me through what happens when a Spring Boot application starts.”

Ideal Answer Structure:

  1. SpringApplication.run() is called
  2. Creates ApplicationContext (usually AnnotationConfigServletWebServerApplicationContext)
  3. Loads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports files
  4. Each auto-configuration class has @Conditional annotations
  5. Conditions are evaluated — @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty
  6. Beans are registered only when conditions pass
  7. User-defined beans always take precedence (@ConditionalOnMissingBean)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// How auto-configuration actually works - simplified example
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
@ConditionalOnProperty(prefix = "spring.datasource", name = "url")
public class DataSourceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public DataSource dataSource(DataSourceProperties properties) {
        return DataSourceBuilder.create()
            .url(properties.getUrl())
            .username(properties.getUsername())
            .password(properties.getPassword())
            .build();
    }
}

Bean Lifecycle Questions

Sample Question: “What’s the difference between @PostConstruct, InitializingBean, and @Bean(initMethod)? In what order do they execute?”

Answer: The order is:

  1. Constructor injection
  2. @PostConstruct
  3. InitializingBean.afterPropertiesSet()
  4. Custom initMethod

For destruction: @PreDestroy → DisposableBean.destroy() → custom destroyMethod

Spring Transaction Internals

Sample Question: “Why doesn’t @Transactional work when you call a method from within the same class?”

This is a classic senior question. The answer reveals understanding of AOP proxies:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class OrderService {
    
    public void processOrder(Order order) {
        // This WILL NOT be transactional!
        this.saveOrder(order); // Direct call bypasses the proxy
    }
    
    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

Spring creates a proxy around your bean. External calls go through the proxy (which adds transaction behavior). Internal calls (this.method()) bypass the proxy entirely. Solutions: inject the bean into itself, use AopContext.currentProxy(), or restructure into separate services.


Section 3: Concurrency Questions

Concurrency is where senior candidates fail most often. Here’s what you need to know cold:

Thread Safety Patterns

Sample Question: “Make this class thread-safe without using synchronized.”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Problem: non-thread-safe counter
public class RequestCounter {
    private long count = 0;
    
    public void increment() { count++; }
    public long getCount() { return count; }
}

// Solution: using AtomicLong
public class RequestCounter {
    private final AtomicLong count = new AtomicLong(0);
    
    public void increment() { count.incrementAndGet(); }
    public long getCount() { return count.get(); }
}

// Solution for high-contention: LongAdder
public class RequestCounter {
    private final LongAdder count = new LongAdder();
    
    public void increment() { count.increment(); }
    public long getCount() { return count.sum(); }
}

Follow-up: “When would you use LongAdder over AtomicLong?”

LongAdder distributes updates across cells to reduce contention. Better throughput under high concurrency but sum() is not atomic — it gives an eventual snapshot. Use when you need frequent writes but can tolerate approximate reads (metrics, counters).

CompletableFuture Composition

Sample Question: “Fetch data from three services in parallel, combine results, with a 5-second timeout.”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public CompletableFuture<DashboardData> fetchDashboard(String userId) {
    var userFuture = CompletableFuture.supplyAsync(
        () -> userService.getUser(userId), executor);
    var ordersFuture = CompletableFuture.supplyAsync(
        () -> orderService.getRecentOrders(userId), executor);
    var recommendationsFuture = CompletableFuture.supplyAsync(
        () -> recommendationService.getRecommendations(userId), executor);
    
    return CompletableFuture.allOf(userFuture, ordersFuture, recommendationsFuture)
        .thenApply(ignored -> new DashboardData(
            userFuture.join(),
            ordersFuture.join(),
            recommendationsFuture.join()
        ))
        .orTimeout(5, TimeUnit.SECONDS)
        .exceptionally(ex -> DashboardData.fallback(userId));
}

The Happens-Before Relationship

Sample Question: “Explain happens-before. Give three examples.”

Happens-before guarantees that memory writes by one thread are visible to another. Key relationships:

  • Monitor lock — Unlock on monitor happens-before subsequent lock on same monitor
  • Volatile write — Write to volatile field happens-before subsequent read of same field
  • Thread start — thread.start() happens-before any action in the started thread
  • Thread join — All actions in a thread happen-before successful join() returns

Section 4: System Design Rounds

For senior roles, system design is typically 45-60 minutes. Here’s the framework I use and recommend:

The 4-Step Framework

Step 1: Clarify Requirements (5 minutes)

  • Functional requirements (what does it DO?)
  • Non-functional requirements (scale, latency, availability)
  • Constraints (budget, team size, timeline)

Step 2: High-Level Design (15 minutes)

  • Draw the major components
  • Identify data flow
  • Choose storage (SQL vs. NoSQL vs. both)

Step 3: Deep Dive (20 minutes)

  • Pick the most complex component
  • Discuss data model, API design, algorithms
  • Address failure modes

Step 4: Scale and Tradeoffs (10 minutes)

  • Bottleneck analysis
  • Scaling strategies
  • What you’d do differently with more time

Common System Design Questions for Senior Java Roles

  • Design a payment processing system (idempotency, exactly-once delivery)
  • Design a rate limiter (token bucket, sliding window, distributed)
  • Design a notification system (priority queues, fan-out, delivery guarantees)
  • Design an event-driven order management system (SAGA pattern, event sourcing)

Sample: Rate Limiter Design

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Sliding window rate limiter using Redis
@Component
public class SlidingWindowRateLimiter {
    
    private final StringRedisTemplate redis;
    private final int maxRequests;
    private final Duration window;
    
    public boolean isAllowed(String clientId) {
        String key = "rate:" + clientId;
        long now = Instant.now().toEpochMilli();
        long windowStart = now - window.toMillis();
        
        // Atomic Redis operation
        return redis.execute(new SessionCallback<Boolean>() {
            @Override
            public Boolean execute(RedisOperations operations) {
                operations.multi();
                operations.opsForZSet().removeRangeByScore(key, 0, windowStart);
                operations.opsForZSet().add(key, String.valueOf(now), now);
                operations.opsForZSet().zCard(key);
                operations.expire(key, window.plusSeconds(1));
                List<Object> results = operations.exec();
                Long currentCount = (Long) results.get(2);
                return currentCount <= maxRequests;
            }
        });
    }
}

Section 5: Live Coding Patterns

The live coding round for senior roles focuses on clean, production-quality code — not algorithmic tricks.

What They Evaluate

  • Code organization — Can you structure code logically without being told?
  • Error handling — Do you handle edge cases without prompting?
  • Naming — Are your variables and methods self-documenting?
  • Testing mindset — Do you mention what you’d test?

Common Live Coding Problems

Problem 1: Implement a simple in-memory cache with TTL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class TTLCache<K, V> {
    private final ConcurrentHashMap<K, CacheEntry<V>> store = new ConcurrentHashMap<>();
    private final Duration defaultTtl;
    
    public TTLCache(Duration defaultTtl) {
        this.defaultTtl = defaultTtl;
    }
    
    public void put(K key, V value) {
        put(key, value, defaultTtl);
    }
    
    public void put(K key, V value, Duration ttl) {
        Instant expiresAt = Instant.now().plus(ttl);
        store.put(key, new CacheEntry<>(value, expiresAt));
    }
    
    public Optional<V> get(K key) {
        CacheEntry<V> entry = store.get(key);
        if (entry == null) return Optional.empty();
        if (Instant.now().isAfter(entry.expiresAt())) {
            store.remove(key);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }
    
    public void evictExpired() {
        Instant now = Instant.now();
        store.entrySet().removeIf(e -> now.isAfter(e.getValue().expiresAt()));
    }
    
    private record CacheEntry<V>(V value, Instant expiresAt) {}
}

Problem 2: Implement a retry mechanism with exponential backoff

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class RetryExecutor {
    
    private final int maxAttempts;
    private final Duration initialDelay;
    private final double multiplier;
    
    public RetryExecutor(int maxAttempts, Duration initialDelay, double multiplier) {
        this.maxAttempts = maxAttempts;
        this.initialDelay = initialDelay;
        this.multiplier = multiplier;
    }
    
    public <T> T execute(Supplier<T> operation, Predicate<Exception> retryable) {
        Exception lastException = null;
        Duration delay = initialDelay;
        
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return operation.get();
            } catch (Exception e) {
                lastException = e;
                if (attempt == maxAttempts || !retryable.test(e)) {
                    break;
                }
                sleep(delay);
                delay = Duration.ofMillis((long) (delay.toMillis() * multiplier));
            }
        }
        throw new RetryExhaustedException(
            "Failed after %d attempts".formatted(maxAttempts), lastException);
    }
    
    private void sleep(Duration duration) {
        try {
            Thread.sleep(duration);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Retry interrupted", e);
        }
    }
}

Section 6: Behavioral and Architecture Questions

At senior levels, 30-40% of the evaluation is non-coding. Here are questions I’ve seen repeatedly:

“Tell me about a technical decision you made that you later regretted.”

Show self-awareness. Pick a real decision (choosing a database, introducing a framework, over-engineering). Explain what you learned and how you’d decide differently now.

“How do you handle disagreements with your team about technical direction?”

They want to hear: data over opinions, reversibility as a factor, disagree-and-commit culture, knowing when to escalate vs. resolve locally.

“How do you decide between building vs. buying?”

Framework: Is it your core competency? What’s the maintenance cost over 3 years? What’s the switching cost? How fast do you need it?


Final Preparation Tips

  1. Practice explaining, not just solving — Senior interviews value communication as much as the solution
  2. Know your resume deeply — Every project listed is fair game for 15 minutes of grilling
  3. Prepare 3-4 system design stories — Real systems you’ve built, including what went wrong
  4. Stay current — Read the Java release notes for 17, 21, and the latest LTS
  5. Mock interview with peers — The difference between knowing an answer and articulating it clearly is massive

The best senior candidates I’ve interviewed share one trait: they can explain complex systems simply. They don’t hide behind jargon. They draw clean diagrams. They acknowledge tradeoffs instead of pretending their solution is perfect.

That’s what separates a “good engineer” from a “senior engineer I want to hire.”

This post is licensed under CC BY 4.0 by the author.