This page collects 26 Java interview questions we use to tell a senior Java engineer from someone who has only written CRUD endpoints. They cover Java 21 and 25 LTS features, Spring and JPA, the JVM, virtual threads and senior design calls. At Ryz, Java candidates are sourced by recruiters who check what they actually shipped, then complete structured NTRVSTA AI interviews built around questions like these. Recruiters review every candidate before and after that interview. AI scores are advisory, and people make the decisions.
Pick six to eight questions per interview and weight them by level. For a mid-level role, two fundamentals and a few intermediate questions are enough; for a senior role, spend most of the time on concurrency, production debugging and design.
Ask follow-ups. A candidate who explains what happens to a JDBC pool when you start 50,000 virtual threads has run them in production. Pair the conversation with the practical exercise at the end.
The usual cause is a key whose hashCode changes after insertion, because a field used in equals and hashCode was mutated. The entry still sits in the bucket for the old hash, so lookups go to the wrong bucket. Overriding equals without hashCode causes the same symptom. The fix is immutable keys, ideally records.
What a strong answer shows: They know the equals/hashCode contract as a production bug, not a textbook rule, and reach for immutable keys.
Integer a = 127, b = 127; a == b return true, but the same check with 128 returns false?Autoboxing goes through Integer.valueOf, which caches values from -128 to 127 by default. Inside that range both variables point to the same cached object, so reference comparison happens to work. Outside it you get two distinct objects. == on wrapper types compares references, so the right comparison is equals or unboxing to int.
What a strong answer shows: They understand boxing, reference versus value equality, and why the bug only shows up with real data volumes.
Records are transparent, shallowly immutable data carriers: the compiler generates the constructor, accessors, equals, hashCode and toString. They are a good fit for DTOs, value objects, map keys and the results of pattern matching.
public record Money(BigDecimal amount, String currency) {
public Money {
Objects.requireNonNull(amount);
if (currency.length() != 3) throw new IllegalArgumentException("ISO code");
}
}
They are a poor fit for JPA entities, which need a no-arg constructor, mutable state and proxies. Records are also only shallowly immutable.
What a strong answer shows: They know the limits, especially shallow immutability and why Hibernate entities stay classes.
? extends T and ? super T mean, and when do you use each?Producer extends, consumer super. A parameter you only read from, like a source list, takes List<? extends Number> so callers can pass a list of Integer. A parameter you only write into takes List<? super Integer>. Because of type erasure these checks exist only at compile time, which is why you cannot write new T[].
What a strong answer shows: Comfort with wildcards in API design and a clear model of what erasure prevents.
Checked exceptions force callers to handle a condition, which helps when the caller can realistically recover, such as a missing optional file. Most service code favors unchecked exceptions because checked ones do not compose with lambdas and leak details through layers. A sound approach is domain runtime exceptions mapped to HTTP responses in one place, with the cause preserved.
What a strong answer shows: A consistent policy, exception chaining, and no swallowed exceptions or catch (Exception e) {}.
List.of, Collections.unmodifiableList and List.copyOf?List.of and List.copyOf return truly unmodifiable lists that reject nulls. Collections.unmodifiableList is a read-only view: if someone still holds the original list and changes it, the view changes too. For defensive copies in constructors, List.copyOf is the right tool.
What a strong answer shows: They know the difference between a view and a copy, which is where a lot of "immutable" classes quietly break.
The enhanced for loop uses an iterator, and the iterator detects that the list was structurally modified outside of it. It is a fail-fast check, not a threading problem. Use Iterator.remove, or better, list.removeIf(predicate).
What a strong answer shows: They separate fail-fast iteration from thread safety and know the idiomatic fix.
Since Java 21 you can declare a closed hierarchy and switch over it exhaustively. The compiler fails the build when a new subtype is added and a switch does not handle it.
sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String authCode) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(Duration retryAfter) implements PaymentResult {}
String describe(PaymentResult r) {
return switch (r) {
case Approved a -> "ok " + a.authCode();
case Declined(String reason) -> "declined: " + reason;
case Pending p when p.retryAfter().toSeconds() > 60 -> "retry later";
case Pending p -> "retry soon";
};
}
What a strong answer shows: They use exhaustiveness as a safety net, know record patterns and guards, and avoid a default branch that would hide new cases.
@Transactional is called and no transaction starts. What happened?Spring applies transactions through a proxy. If the method is called from another method in the same class, the call bypasses the proxy, so the annotation has no effect. Related traps: private methods, and checked exceptions, which do not trigger rollback by default. Move the method to another bean or use TransactionTemplate.
What a strong answer shows: They understand proxy-based AOP rather than treating annotations as magic.
That is the N+1 problem: each order lazily loads its customer or items. Confirm it with SQL logging or Hibernate statistics. Fixes include a fetch join, an @EntityGraph on the repository method, batch fetching with @BatchSize or hibernate.default_batch_fetch_size, or a DTO projection when the endpoint does not need entities. Be careful with fetch joins on collections plus pagination, which Hibernate may apply in memory.
What a strong answer shows: They measure first, know several fixes and know the pagination trap.
Optional is meant for return types that might have no value, so callers cannot forget the empty case. It is misused as a field type, a method parameter or a collection element, and when people call get() without checking. Note that orElse always evaluates its argument; orElseGet does not.
What a strong answer shows: Restraint, and awareness of the eager evaluation in orElse.
Parallel streams run on the shared common ForkJoinPool. They hurt when the work is small, when the source splits poorly (a LinkedList or an iterator), when operations are ordered or stateful, and when tasks block on I/O, which starves every other user of that pool.
What a strong answer shows: They default to sequential streams and benchmark with JMH before parallelizing.
Capture a heap dump on OOM with -XX:+HeapDumpOnOutOfMemoryError, or take one with jcmd <pid> GC.heap_dump. Open it in Eclipse MAT and look at the dominator tree and retained sizes. Common culprits: unbounded caches, static maps keyed by request data and ThreadLocals in pooled threads.
What a strong answer shows: A concrete tool chain and a list of suspects drawn from experience.
Virtual threads, final in Java 21, are cheap threads scheduled by the JVM onto a small pool of carrier threads. Blocking I/O unmounts the virtual thread, so you can write simple thread-per-request code and handle very high concurrency without reactive frameworks.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Quote>> futures = providers.stream()
.map(p -> executor.submit(() -> p.fetchQuote(request)))
.toList();
// collect results...
}
They do not speed up CPU-bound work, and they do not create database capacity. With 10,000 virtual threads and a 20-connection pool, you have 9,980 threads waiting on the pool. You still need limits, such as a semaphore around the scarce resource.
What a strong answer shows: They separate concurrency from throughput and protect downstream resources.
A virtual thread is pinned when it cannot unmount from its carrier during a blocking call. In Java 21, blocking inside a synchronized block or a native frame pinned the carrier, which could exhaust carriers under load; the advice was to switch to ReentrantLock. JDK 24 (JEP 491) removed pinning for synchronized, so on Java 25 LTS it is far less of an issue, though native calls still pin. jdk.VirtualThreadPinned events in JFR show where it happens.
What a strong answer shows: Version-aware knowledge and a way to detect the problem instead of guessing.
No pooling: virtual threads are cheap to create, and pooling defeats the model. Use a semaphore if you need to cap concurrency. ThreadLocals work, but with millions of short-lived threads expensive per-thread caches become wasteful. Scoped values, final in Java 25, give an immutable, bounded way to pass context such as a request ID down a call tree.
What a strong answer shows: They update old habits from platform-thread days and know the newer API.
volatile.The Java Memory Model only guarantees that one thread sees another's writes when there is a happens-before edge: a lock release and later acquire, a volatile write and later read, thread start and join. Without volatile, the reference to a lazily created singleton can be published before its constructor's writes are visible, so another thread sees a partially built object.
What a strong answer shows: They reason about visibility and reordering, not just mutual exclusion.
Map<String, Integer> counts = new ConcurrentHashMap<>();
void hit(String key) {
Integer c = counts.get(key);
counts.put(key, c == null ? 1 : c + 1);
}
Each call is thread-safe on its own, but the get-then-put sequence is not atomic, so concurrent hits lose updates. Use counts.merge(key, 1, Integer::sum), or a ConcurrentHashMap<String, LongAdder> with computeIfAbsent(key, k -> new LongAdder()).increment() for hot keys.
What a strong answer shows: They see compound-action races in code that uses "thread-safe" classes.
Confirm with GC logs (-Xlog:gc*) and JFR that pauses line up with the spikes. Look for humongous allocations, full GCs and allocation rate spikes. G1 is a solid default; tune pause goals and region size before switching. If pauses must stay in the low milliseconds regardless of heap size, generational ZGC (the only ZGC mode since JDK 24) is the usual choice, at the cost of some throughput and memory overhead.
What a strong answer shows: Evidence before tuning, and a clear latency versus throughput trade-off.
The kernel killed the container because total process memory exceeded the limit, not because the heap filled. Heap is only part of the footprint: metaspace, thread stacks, code cache, direct buffers and native allocations add to it. Size the heap with -XX:MaxRAMPercentage (often 50 to 75 percent), cap direct memory, and use Native Memory Tracking (-XX:NativeMemoryTracking=summary) to see where the rest goes.
What a strong answer shows: They know the JVM in containers, not just the heap.
Upgrade in steps. Move to the latest Spring Boot 2.7 first and clear deprecations, then to Java 17 or 21, then Boot 3, which brings the javax to jakarta namespace change and Hibernate 6 query changes. OpenRewrite recipes handle most mechanical edits. Run tests and a canary at each step and compare GC and memory metrics.
What a strong answer shows: Incremental risk control, tool use, and knowledge of the namespace change that blocks most migrations.
Consumers see at-least-once delivery, so the charge must be idempotent. Use an idempotency key from the message, store processed keys in the same database transaction as the business change, and commit offsets only after that transaction succeeds. Pass the payment provider's idempotency key too. Publish follow-up events through a transactional outbox rather than writing to the database and Kafka separately.
What a strong answer shows: They do not claim exactly-once for free and design for redelivery.
Not as a first move. Find the real pain first: build times, deploy coupling or a shared database. Often the better step is enforcing module boundaries inside the monolith with ArchUnit or Spring Modulith, plus separate schemas per module. Extract a service only when a module needs independent scaling, release cadence or ownership, and the boundary has proven stable.
What a strong answer shows: Diagnosis before architecture, and an understanding of the operational cost of distribution.
Profile startup first: classpath scanning, migrations at boot and remote calls in initializers are common. Then consider class data sharing (AppCDS), the AOT cache work from Project Leyden that has been landing since JDK 24, Spring's AOT processing, lazy initialization for rarely used beans, or a GraalVM native image when its reflection constraints and build time are acceptable.
What a strong answer shows: Measurement first and an honest view of what each option costs.
Take thread dumps with jcmd <pid> Thread.print a few seconds apart and map hot native thread IDs from top -H. Start a JFR recording with jcmd <pid> JFR.start duration=60s, or attach async-profiler for a flame graph.
What a strong answer shows: Low-overhead tools that are safe in production and a calm, ordered approach.
Run mvn dependency:tree or gradle dependencies to see what wins. Align versions with a BOM, such as the Jackson or Spring Boot BOM, and enforce convergence with the Maven Enforcer plugin or Gradle constraints. Shading is a last resort.
What a strong answer shows: Build tool fluency and a preference for alignment over clever classloader tricks.
@Transactional, @Async or @Cacheable as working everywhere, with no idea they rely on proxies.synchronized on everything, or uses a ConcurrentHashMap and assumes compound actions are safe.Give candidates a small Spring Boot 3 service on Java 21 or 25 with Postgres in Docker Compose. It exposes an endpoint that aggregates prices from three slow mock HTTP providers and saves an audit row per request. Seed it with problems: an N+1 query, a get-then-put race in a cache, a @Transactional self-call and sequential provider calls. Ask them, in a 90-minute pairing session or a take-home of about three hours, to make the endpoint fast and correct under 200 concurrent requests and to explain each change.
CompletableFuture and add timeouts?If you would rather not run this loop yourself, Ryz can introduce senior Java developers who have already been through it. They are the top 1% of candidates we interview, they work on your team and repos, and they keep hours within ±1h of US time zones. Read how our vetting process works, or start from our Java developer job description to define the role.
Six to eight in a 60-minute interview, leaving time for follow-ups. One question taken three levels deep tells you more than ten quick answers.
Only as much as your stack does. Spring knowledge matters for most backend roles, but a strong engineer who knows the JVM and concurrency picks up Spring conventions quickly.
Mid-level developers write correct features. Senior developers know what the JVM, database and framework do underneath, debug production issues with evidence and plan upgrades without outages.
Questions we didn't answer? Email info@ryzlabs.com.