Java interview questions with model answers

109 questions written alongside the free Java course, each with the answer an interviewer is listening for. 30 are below, spread from first principles to advanced; the rest are in the course.

What Java interviews test

Java interviews cover the language and the JVM together: classes, interfaces and inheritance, the collections framework and equals/hashCode, exceptions, generics, concurrency with threads and executors, and memory and garbage collection.

  1. 1. What is the difference between the JDK, the JRE and the JVM?

    The JVM executes bytecode. The JRE is the JVM plus the core libraries — enough to run an application. The JDK is the JRE plus development tools, chiefly the javac compiler. You install the JDK to build, and historically shipped the JRE to run, though modern deployments usually bundle a trimmed runtime instead.

    From the lesson What Java Is and Why It Endures

  2. 2. When would you use printf rather than println?

    When the output needs controlled formatting — fixed decimal places, column alignment, grouped thousands, or padding. println just calls toString, so a double prints its full representation rather than the two decimal places a person expects.

    From the lesson Printing Output: println, print and printf

  3. 3. What is the difference between a switch statement and a switch expression?

    A statement performs actions and, in the colon form, falls through unless you break. An expression produces a value that can be assigned, uses arrow labels with no fall-through, and must be exhaustive — over an enum that means covering every constant or supplying a default, which the compiler checks.

    From the lesson Conditionals and the Modern switch

  4. 4. What happens when you print an array with println?

    You get the type descriptor and identity hash, such as [I@1b6d3586, because arrays inherit Object.toString and do not override it. Arrays.toString prints the contents, and Arrays.deepToString handles nested arrays.

    From the lesson Arrays, Bounds and the Arrays Utility

  5. 5. What is the difference between overriding and overloading?

    Overriding replaces an inherited method with the same signature and is resolved at runtime from the actual object, which is what makes polymorphism work. Overloading is several methods sharing a name with different parameter lists, resolved at compile time from the declared types of the arguments. Runtime versus compile time is the essential distinction.

    From the lesson Dynamic Dispatch, Casting and instanceof

  6. 6. What does declaring a record generate, and when would you not use one?

    A canonical constructor, an accessor per component, value-based equals and hashCode, and a toString. I would not use one when the type has real behaviour or mutable state, or needs to extend a class — records are implicitly final and are meant to say "this is data".

    From the lesson Records and Compact Constructors

  7. 7. How do you make a class immutable, and where does it usually go wrong?

    Make the class final, all fields private final, provide no mutators, copy mutable arguments in the constructor, and copy or wrap mutable fields in getters. It usually goes wrong at the copying steps — a final field holding a List fixes the reference but not the contents, so storing or returning the caller’s collection leaves the object mutable through the back door.

    From the lesson Immutable Objects and Defensive Copying

  8. 8. What happens when a method throws an exception it does not catch?

    The method exits immediately and the JVM looks for a handler in the caller, then its caller, up the stack. Nothing after the failing line runs, and each frame's finally blocks execute as the stack unwinds. If nothing handles it the thread terminates and the trace is printed. That is usually what you want, because the method that fails is rarely the one that knows what to do about it.

    From the lesson throw, throws and Propagation

  9. 9. What is the difference between HashMap, LinkedHashMap and TreeMap?

    All three implement Map. HashMap gives O(1) average lookup and promises nothing about iteration order. LinkedHashMap adds a linked list through the entries so iteration follows insertion order, for a small memory cost. TreeMap keeps keys sorted using their natural order or a comparator, which makes operations O(log n) and means it cannot accept a null key, because null cannot be compared.

    From the lesson Sets and Maps: Hash, Linked and Tree

  10. 10. What does PECS mean and when would you use each half?

    Producer Extends, Consumer Super. If a parameter produces values the method reads, declare it List<? extends T> — you can read them all as T but cannot add, because the actual list might be of some narrower subtype. If it consumes values the method writes, declare it List<? super T> — you can add a T but can only read back Object. A parameter that does both takes a plain type parameter and no wildcard.

    From the lesson Wildcards, ? extends, ? super and PECS

  11. 11. What problem does Optional solve, and where should you not use it?

    It puts "this may be absent" in the method signature, so a caller cannot silently ignore the case the way they can with a returned null. Use it as a return type. Not as a field or a parameter — it adds a wrapper without adding information and is not serialisable — and never return a null Optional. Also prefer map, filter and orElse to isPresent plus get, since that pair is the null check it was meant to replace.

    From the lesson Optional: Absence in the Type System

  12. 12. Why is a sliding window O(n) when it contains a nested while loop?

    Because the inner loop is bounded across the whole run rather than per iteration. The left edge only ever moves forward, so every element is removed from the window at most once — the total inner work is at most n over the entire pass. Counting the inner while as a nested loop and calling it quadratic is the usual analysis mistake.

    From the lesson Two Pointers, Sliding Window and Prefix Sums

  13. 13. A service reads uploaded CSVs and after about a week of uptime starts failing every request with 'too many open files'. Walk me through the diagnosis.

    The symptom names the cause: file descriptors are being opened and not closed, and the service reaches its ulimit. I would confirm it first rather than guess — lsof -p on the process, or /proc/<pid>/fd, and look at what the leaked descriptors point at. If they all name uploaded CSVs, the leak is in the read path. The usual culprit in Java is a Stream returned by Files.lines, Files.walk or Files.list used outside a try-with-resources: those three hold a handle, unlike every other Stream, and nothing about the calling code looks wrong. The second candidate is an InputStream opened in a method that returns early on a validation failure without a finally. Both are found by grepping for those calls and checking each is in a resource block. Longer term I would make the leak impossible to reintroduce rather than fix one instance: a static analysis rule for unclosed AutoCloseables, and a load test that runs long enough to exhaust the limit, because a leak that takes a week to show up will never be caught by a unit test. As an immediate mitigation, raising the ulimit buys time and nothing else — the leak is still there and the service will still fall over, just later.

    From the lesson Path, Files and the Three Ways to Read a File

  14. 14. Your application fails on a JDK upgrade with InaccessibleObjectException from a library. What are your options, in what order?

    The exception says a library used deep reflection into a package the module system does not open, which strong encapsulation started refusing by default in Java 16. First I want the exact package and module from the message, because the right fix depends on whether it is one of ours or one of the platform's. If the library is reflecting into a package of OUR code, the fix is ours and it is correct: add opens for that package, scoped to the library's module rather than to everything. If it is reflecting into a platform package — java.lang, java.util — then the library is reaching somewhere it was never meant to, and the real fix is upgrading the library, because almost every widely used one was fixed years ago. Checking the version is the first thing I would do, since it often costs nothing. If an upgrade is not available yet, --add-opens in the launch script is the honest stopgap: scoped as narrowly as possible, written in the build file rather than someone's shell history, with a comment naming the library, the version that fixes it, and who owns removing it. What I would not do is add ALL-UNNAMED opens broadly to make the message go away, because that re-opens the platform for every dependency including the ones we have not audited, and nobody will ever take it out. If the library is unmaintained and reaching into JDK internals, that is worth surfacing as a risk in its own right rather than a launch flag.

    From the lesson Modules, Strong Encapsulation and the Classpath You Still Have

  15. 15. A team migrates a request handler to virtual threads and throughput barely improves. Where do you look?

    Virtual threads only help if the threads were the constraint, so the first question is what the bottleneck actually was, measured rather than assumed. The three answers I would expect. One: the work is CPU-bound, not I/O-bound. Virtual threads do nothing for that — the carrier pool is sized to the cores and the cores were already busy, so the ceiling has not moved. Two: something is pinning. On JDK 21 a virtual thread cannot unmount inside a synchronized block or a native call, so a hot path that blocks inside synchronized holds its carrier and, with the carrier pool sized to the core count, a handful of them stall the whole server. -Djdk.tracePinnedThreads=full names the stack, and the fix is a ReentrantLock. Three: the real limit is downstream. If the connection pool has twenty connections, a million virtual threads all queue for those twenty, and now they queue in your heap instead of in the OS scheduler — the same throughput with worse memory behaviour, and with the added risk that removing the implicit thread-count limit turned a graceful queue into an unbounded one. I would also check that they are not pooling virtual threads, which is the most common migration mistake and reinstates the old limit exactly. The general shape of my answer: measure where the time goes before and after, because 'we adopted the new thing and nothing improved' usually means the new thing addressed a constraint that was not binding.

    From the lesson Virtual Threads, Structured Concurrency and Composing Futures

  16. 16. A service restarts every few days with OutOfMemoryError: Java heap space. Walk me through finding it.

    A slow climb to OOM over days is a leak, and a leak in Java is always something holding a reference, so the goal is to find the reference rather than to guess at a cause. First I want evidence rather than theories: -XX:+HeapDumpOnOutOfMemoryError so the next occurrence leaves a dump, and GC logging so I can confirm the shape — the signature of a leak is that old-generation occupancy after each full collection trends upward rather than returning to a baseline. If it is flat and the service simply needs more heap, that is a capacity problem, not a leak, and the fix is different. With a dump I would open it in Eclipse MAT and run the leak suspects report, which looks for a small number of objects retaining a large share of the heap, and then follow the dominator tree to the shortest path to a GC root — that path names the field that is holding everything. In my experience it is one of three things: a map or list used as a cache with no eviction, a listener registry nobody unregisters from, or a ThreadLocal on a pooled thread. Each has a different fix — a bounded cache, a matching unsubscribe or weak references, and a clear in a finally — and I would want the fix to be structural, so a bounded type rather than a rule that someone must remember. For prevention I would add a heap-usage-after-GC metric and alert on the trend, because that catches the next leak in hours instead of days. I would not start by increasing -Xmx: it does not fix a leak, it just moves the restart further out and makes the eventual dump bigger.

    From the lesson Generations, Collectors, Reachability and Leaks

  17. 17. An intermittent NullPointerException appears in production, maybe once every few thousand requests. The stack trace points at a line that looks fine. How do you track it down?

    The stack trace points where the null was dereferenced, not where it was created, so the useful question is where the null came from — and on JDK 14 and later the first thing I would check is whether helpful NullPointerException messages are on, because they name the exact expression that was null, which often ends the investigation immediately. If that is not enough, I want more context at the failure rather than more guessing. That means adding fields to the log line at the catch site — the request id, the identifiers of whatever was being processed — and making sure a correlation id is threaded through, so I can pull every line for one failing request rather than reasoning across interleaved output. Once I can see a handful of failures side by side, the question becomes what those requests have in common: a particular endpoint, a particular field absent from the payload, a code path only taken on retry. Intermittent almost always means conditional on data or on timing, and the correlation is usually visible once you can group by request. If it is timing rather than data, that changes the approach: a field being null intermittently in a multi-threaded context suggests unsafe publication or a shared object being mutated, and then a field watchpoint in a reproduction, or making the field final and seeing what fails to compile, tells you more than any amount of logging. For production specifically, JFR running continuously is the tool I would want in place beforehand — a custom event around the suspect operation, dumped after an occurrence, gives the state at the moment without a debugger and without waiting for it to happen again while I watch.

    From the lesson Structured Logging, Debugging Technique and Automated Checks

  18. 18. A team wants to adopt hexagonal architecture on an existing Spring service where entities are JPA-annotated and services return ResponseEntity. Where would you start?

    I would not start with a folder restructure, because that moves code without changing any dependency and burns the team's appetite for the whole idea. The first question is what problem we are solving: if the pain is that nothing can be tested without a database and a Spring context, then that is the thing to fix, and the fix is one seam at a time. I would pick a single use case that people change often, and do three things to it. Extract the rules into a plain class that takes its collaborators as constructor parameters and returns a domain outcome rather than a ResponseEntity — that alone makes it unit-testable in milliseconds. Define the interface it needs for persistence in the domain package, and have the existing repository implement it, so the dependency inverts without rewriting the data access. And introduce a real domain type for whatever the JPA entity was pretending to be, with a mapper. That last step is the one people resist because it looks like duplication, and it is the one that pays: the JPA entity's shape is dictated by the schema and by the framework's need for a no-arg constructor and setters, which is exactly why it cannot enforce an invariant. Once one slice looks like that, the pattern is concrete and the team can judge whether it is worth repeating rather than arguing about it in the abstract. I would also add an ArchUnit test as soon as the first domain package exists, because the rule that domain must not import jakarta.persistence is worth nothing if it lives only in a wiki page, and the first violation will arrive in a hurried PR. What I would not do is insist on separate Maven modules, a mapper for every layer, or a big-bang migration — the cost of those is real and the benefit depends on a size this service may not have.

    From the lesson Ports, Adapters, Value Objects and the Dependency Rule

  19. 19. A client reports that some of their orders are being created twice. Your logs show two identical POSTs a few seconds apart. How do you fix it?

    Two identical POSTs seconds apart is almost always a retry after a lost or slow response, so the client is behaving reasonably and our API is what makes it unsafe. POST is not idempotent by definition, and a client that does not receive a response genuinely cannot tell whether the order was created — its only options are to retry and risk a duplicate, or not retry and risk losing the order. So the fix is to give it a third option. The standard mechanism is an idempotency key: the client generates a unique key per intent, sends it as a header, and we store the key with the result of the first request. A repeat with the same key returns the original response without doing the work again. Three details matter in the implementation. The key must be stored with the RESULT, not just recorded as seen, so the retry gets the same body and status rather than a 409. It needs a fingerprint of the request so that a key reused with different content is rejected as a client bug rather than silently returning the wrong result. And the store needs a TTL, because keys accumulate forever otherwise — twenty-four hours is a common choice, and it must be durable, since an in-memory map does not survive the deploy that happens between the request and the retry. If the duplicates predate any retry — the client is genuinely sending two intents — then the honest answer is different: a uniqueness constraint on a natural business key, so the second insert conflicts and we return 409. I would check which of the two it is before building anything, because the log timestamps and whether the first response was ever sent will tell us. In the meantime I would also look at why the response was slow enough to trigger a retry, because that is a real problem regardless of how we handle the duplicate.

    From the lesson Resources, Status Codes, Errors, Versioning and Idempotency

  20. 20. You are asked to make an endpoint faster. It currently takes 400ms at p99. Where do you start?

    I would start by refusing to guess, because the single most common way to waste time here is optimising something that turns out to be five percent of the latency. So the first step is a breakdown: a profile of where those 400ms actually go, ideally from production rather than from a local run, because the shape is usually different — production has real data volumes, a real network, and a connection pool under contention. JFR running continuously, or async-profiler for a sample, gives that without much overhead. The second thing I would establish before touching anything is what the target is and why. 'Faster' is not a requirement; 'p99 under 200ms because the page makes three of these calls and the budget is 600ms' is. That also tells me whether the problem is even in this service — if 300 of the 400ms is a downstream call, the work is somewhere else. Once I have the breakdown I would run the Amdahl arithmetic on each candidate: what fraction of the total is this, what speed-up is plausible, and what does that buy overall. In my experience the answer is usually unglamorous — a missing index, an N+1 query, a serialisation step doing more work than it needs — rather than the algorithm somebody wants to rewrite. I would also look at the distribution rather than just the p99 number: if p50 is 30ms and p99 is 400ms, that is a tail problem, and tails usually come from queueing, garbage collection pauses, or a cache miss path, which are different investigations from 'the code is slow'. Finally, whatever I change, I would want a before-and-after measurement of the same thing, from the same place, because an optimisation nobody measured is a change nobody can defend — and about a third of them turn out not to help.

    From the lesson Benchmarks That Are Not Lies, Latency Percentiles and Amdahl's Law

  21. 21. Users report that a desktop application 'freezes for a few seconds' when they click Refresh. What is happening and how do you fix it?

    A freeze on a click is the event dispatch thread being occupied inside the click handler. The UI thread runs one task at a time — dispatch the event, repaint, take the next event — so anything slow inside the handler means nothing repaints and no input is processed for exactly as long as it takes. Refresh almost certainly does a network call or a query inline, and a few seconds is consistent with that. Confirming it is quick: a thread dump taken during the freeze will show the AWT-EventQueue thread in a socket read or a JDBC call, which is the whole diagnosis. The fix is not to make the call faster, it is to get it off that thread: a SwingWorker whose doInBackground does the call and whose done() applies the result, or a JavaFX Task with its value handler. Two things usually need adding at the same time. A loading state, so the user sees a spinner and a disabled button rather than a window that looks broken — without that the fix changes the freeze into a period where nothing appears to happen, which users report as the same bug. And a failure state, because a background call that throws must not leave the spinner running forever; done() has to handle the exception from get(). I would also look for the same pattern elsewhere, since a codebase with one inline call in a handler almost always has several, and the cheapest long-term fix is a convention plus a check — some teams install a repaint manager or an EDT-violation detector in debug builds, which catches both this and the mirror-image bug of touching components from a background thread.

    From the lesson The Event Dispatch Thread, Models, and Why an App Freezes

  22. 22. A colleague has written a lock-free queue by hand and it passes all their tests. What do you say in review?

    My first question is what problem the lock-free version solves, because the answer determines whether the review is about the code or about the decision. Lock-free buys exactly one property: no thread can block another by being descheduled while holding something. That is decisive in a real-time system where a pause is a correctness failure, and it can matter on a path measured to be lock-bound under heavy contention. If neither applies, then we are taking on a large correctness risk for no measured benefit, and the honest recommendation is ConcurrentLinkedQueue — which is the same algorithm, already written, and verified far more thoroughly than anything we will produce. On the code itself, the tests passing tells us very little and I would say that gently but clearly. Concurrency bugs are interleaving-dependent, so a race that occurs one time in ten million passes every suite and then fails in production on different hardware. What I would look for specifically: every read-then-write pair, because each one needs a CAS that fails if anything changed in between; whether nodes are ever recycled, because that is where ABA becomes a real bug rather than a textbook one; and whether any function inside a retry loop has a side effect, since it runs once per attempt rather than once per success. I would also ask what the memory ordering argument is — which accesses need release/acquire and why — because if there is no argument there is no guarantee, only current behaviour. If we do keep it, I would want jcstress in the build. It enumerates the outcomes the memory model permits rather than sampling the ones this machine produces, which is the only kind of test that means anything for code like this. And I would want a comment at the top naming the algorithm and the paper it comes from, so the next person to change it knows what invariant they are about to break.

    From the lesson CAS, Retry Loops, ABA and the Limits of Lock-Free

  23. 23. A team proposes rewriting a Spring MVC service in WebFlux to handle more load. What do you ask?

    My first question is what the measured constraint is, because reactive only helps with one of them. If the service is running out of threads — if the pool is saturated with requests blocked on I/O and adding threads costs too much memory — then the diagnosis is right and there are now two treatments rather than one. If it is CPU-bound, or if the real ceiling is a downstream connection pool, then neither reactive nor anything else about the thread model raises it, and the rewrite buys nothing. I would want the numbers: concurrent requests, time blocked versus time computing, and what the pool and downstream limits actually are. If threads genuinely are the constraint, then on JDK 21 the comparison is no longer reactive against blocking — it is reactive against virtual threads. Virtual threads give the same removal of the thread-count limit while keeping the stack trace, the debugger, the profiler and code that reads top to bottom, and the migration is a configuration change rather than a rewrite. That is a very different cost-benefit conversation, and it is the one I would want the team to have explicitly. The costs of WebFlux are real and land on everyone: a failure produces a stack trace naming operators instead of the line that threw, stepping through code lands in a scheduler, one blocking call accidentally left in a chain stalls an event-loop thread and degrades unrelated requests, and every new engineer pays a learning cost with a new class of bug attached. Where I would support it without argument is a genuine streaming requirement — server-sent events to slow clients, a data pipeline where the consumer must set the pace — because back-pressure is a protocol and no thread model provides it. And whichever way it goes, I would push for the decision to be written down with the measurement that justified it, so that in two years somebody can tell whether the reason still holds.

    From the lesson The Flow Protocol, Back-Pressure and Operator Semantics

  24. 24. A service's p99 latency degrades slowly over the first ten minutes after deploy, then stabilises worse than the previous release. How would you investigate?

    Two things are happening and I would separate them, because the first is normal and the second is not. Degrading over the first few minutes and then stabilising is the JIT settling — or, more precisely, it is deoptimisation and recompilation as the profile fills in. Early on a call site has seen one type and gets inlined; as real traffic arrives it sees several, the speculation is invalidated, the method is made not entrant and recompiled with a virtual call, and the steady state is slower than the optimistic first minutes. That part is expected. What needs explaining is why the new steady state is worse than the previous release's. The evidence I would gather first is -XX:+PrintCompilation on one instance, filtered for 'made not entrant'. A burst during warm-up is normal; a steady trickle afterwards means something keeps breaking a speculation, and the log names the method. Alongside that, a JFR recording gives the compilation events and the time spent in the compiler, so I can tell whether we are actually recompiling continuously rather than inferring it. Then the question is what changed between releases. The usual causes are not obvious from a diff. A new implementation of an interface — a new strategy, a feature-flag variant, a decorator — turns a monomorphic call site megamorphic and removes inlining from a hot path that nobody edited. A method that grew past the inlining size limit stops being inlined and takes everything that depended on that with it. A branch that used to be never-taken starts being taken by real data. Any of those produces exactly this shape: same code, worse steady state. Measurement discipline matters here too — I would want the comparison to be like for like, after both versions have fully warmed, because comparing a warm old release against a still-warming new one produces a confident wrong conclusion. And if the cause turns out to be a legitimately megamorphic call site on the hot path, the fix is usually to separate that path rather than to try to make the shared one faster.

    From the lesson Tiers, Inlining, Profile Pollution and Deoptimisation

  25. 25. A team wants to let users define calculation rules that the service evaluates. They propose compiling the rules to Java for speed. What do you say?

    My first question is who the users are, because that decides everything else. If 'users' means our own engineers editing a file in the repository, then compiling the rules is a legitimate and rather good design: the rules are code, they are reviewed like code, a syntax error is a build failure with a line number, and the evaluation is ordinary bytecode the JIT will optimise. If 'users' means customers typing into a web form, then the proposal is remote code execution and I would say so plainly — the generated class runs with the application's full privileges, it can read the file system and open sockets, and there is no sandbox to fall back on now that the SecurityManager has been removed. No amount of input filtering makes that safe, because the thing being filtered is a Turing-complete language. For the untrusted case the answer is a small expression language with a fixed grammar: an interpreter over a parsed tree where the only operations that exist are the ones you defined, so the worst an input can do is fail to parse. That is usually a few hundred lines, and if performance genuinely matters it can be compiled to a tree of closures or to a bytecode interpreter, neither of which reaches Java. I would also want to know whether speed is a real requirement or an assumption — interpreting a small expression tree is typically fast enough for anything short of a hot inner loop, and the measurement should come before the design. There are secondary costs worth naming for the trusted case too: each compiled rule set is loaded by its own class loader, and if rules are recompiled on change then every stale loader that something still references stays in metaspace, which is the classic redeployment leak. So even where compiling is right, the lifecycle needs designing rather than assuming.

    From the lesson JavaCompiler, Diagnostics, In-Memory Compilation and Processors

  26. 26. You want per-request timing in production. Logs, metrics, tracing or JFR?

    They answer different questions, and the honest answer is usually more than one — but I would start by asking what decision the data is meant to support. Metrics answer 'is it slow right now', they are aggregates, they are cheap and they are what an alert should fire on; they cannot tell you why. Tracing answers 'where did this particular request spend its time', across services, and it is the right tool when the latency is distributed — its cost is sampling decisions and propagation discipline, and it is usually the most expensive of the four per request. Logs answer 'what happened to this one request' in whatever detail you wrote down, and they are the most expensive per unit of information: a formatted line, an I/O path, and a storage bill. JFR sits somewhere people do not expect: it is the cheapest way to record structured per-event data inside one JVM, because an event is a struct written to a thread-local buffer rather than a string written to a file, and it is the only one of the four that also gives you the JVM's own view — safepoints, allocation, lock contention, virtual thread pinning — correlated on the same timeline as your events. For per-request timing specifically I would use metrics for the alert, a custom JFR event with a threshold for the detail, and tracing only if the request crosses services. The JFR event with a threshold is the piece people miss: the fast path costs a begin, an end and a boolean, so it is affordable to leave on for every request, and the slow ones are recorded with their stack. I would also be clear about what JFR is not: buffers are bounded and events are dropped silently under pressure, so it is a diagnostic tool and never an audit trail. And I would be careful about what goes in the fields — a query event carrying bound parameters puts personal data into a file people email around.

    From the lesson Custom Events, RecordingStream, Settings and Overhead

  27. 27. A consumer group's lag is growing. Walk me through the diagnosis.

    The first thing I would do is stop looking at total lag and look at it per partition, because those are two completely different problems. If every partition is lagging roughly equally, consumption is genuinely slower than production and it is a capacity question. If one partition is lagging and the others are fine, something is specific to that partition — and that is the more common case. For the uneven case there are three usual causes. A poison message the consumer keeps retrying, which stops that partition dead while everything behind it waits; I would look for a repeating exception on a fixed offset. A hot key, where one tenant or one entity is most of the traffic, which shows as a partition that is busy rather than stuck. Or a single unhealthy consumer instance — descheduled, throttled, on a bad node — which shows as one partition lagging while its owner is alive but slow. For the even case I would check whether the group is rebalancing, because a rebalance loop looks exactly like slow consumption from the outside. The mechanism there is that processing exceeds max.poll.interval.ms, the broker evicts the consumer, its commit is rejected, the new owner reprocesses from the last successful commit, is equally slow, and the cycle repeats — lag climbs and nothing is ever committed. The rebalance rate and the consumer logs settle that in a minute. If it is genuinely capacity, the fixes are ordered by cost. Make processing faster, which usually means finding the synchronous call inside the loop — a database round trip per record, an HTTP call without connection reuse. Then add consumers, but only up to the partition count, because beyond that they sit idle. Then add partitions, which is the expensive option: it changes which partition a key maps to, so ordering guarantees break across the change and existing keys stay where they were. I would also check whether the lag is real or an artefact — a consumer that has stopped committing but is still processing shows lag that does not exist, and a compacted topic makes offset arithmetic misleading. And I would want to know what the lag means to the business, because the right response to 'reports are twenty minutes stale' is very different from the right response to 'payments are twenty minutes stale'.

    From the lesson Partitions, Groups, Offsets, Rebalancing and Compaction

  28. 28. Traces from your service stop at the message broker. The consumer's work appears as separate, unparented traces. What is happening?

    Context is not being propagated across the broker, and that is expected rather than a bug — trace context lives in a thread-local and travels on instrumented HTTP and gRPC calls because those have a header slot the instrumentation knows to fill. A message broker has headers too, but somebody has to inject the traceparent into them on the producing side and extract it on the consuming side, and if the client is not auto-instrumented or the message is constructed by hand, nothing does. So the producer's span ends when it publishes, and the consumer starts a fresh root span with no idea where the message came from. The fix has two halves. On the producer, inject the current context into the message headers using the propagator — one call, and the headers are just bytes the broker carries. On the consumer, extract it and use it when starting the span. The important subtlety is what relationship to use: the consumer should generally use a LINK rather than a parent. Two reasons. First, a consumer often processes a batch containing messages from many different traces, and a span can have only one parent but any number of links. Second, a parent-child relationship implies the child happened within the parent's lifetime, which is false when a message sits in a queue for ten minutes — modelling it as a parent produces a trace whose root span appears to last ten minutes, which is misleading in every latency view. A link says 'this work was caused by that trace' without claiming containment. I would also check the same four boundaries elsewhere while I am there, because they usually all have the same gap: thread pool submissions, which need the runnable wrapped; scheduled jobs, which need a deliberate new root span rather than inheriting whatever was current on that thread; and any hand-rolled HTTP client. The scheduled-job one is worth fixing even if nobody has complained, because the symptom is subtle — a nightly batch silently attaching itself to some user's request trace. Lastly I would want to know whether the sampling decision is being propagated too. If the producer's trace was not sampled and the consumer decides independently, you get consumer spans with no producer context to join them to, which looks exactly like this problem and has a different cause.

    From the lesson Traces, Context Propagation, Metrics, Sampling and Cardinality

  29. 29. You are asked to add 'AI search' over the company's internal documents. What are the first questions you ask?

    My first question is not about models at all, it is about permissions: who is allowed to see which documents, and does the existing system enforce that? A vector index built over everything is an authorisation bypass with a similarity score — the retrieval step will happily pull an HR document into the context of an engineer's question, and the model will summarise it helpfully. So the index has to carry the access metadata and the retrieval has to filter by the asking user's permissions before ranking, not after. That is the design constraint that shapes everything else, and it is the one most often discovered late. Second, what does 'search' mean here — is the deliverable an answer or a list of documents? If people mostly need to find the right document, a good keyword search with a reranker may serve better, be cheaper, and have no hallucination risk at all. I would want to know what is wrong with the current search before assuming the answer is a model. Third, how will we know if it is any good? I would want twenty or thirty real questions with the passage that answers each, before writing anything, because that gives us retrieval recall as a metric that needs no model and tells us whether the hard part is even solvable with this corpus. Quality is dominated by retrieval, and retrieval is dominated by chunking, which is an ordinary text-processing problem — so most of the engineering effort will go somewhere people do not expect. Fourth, what happens when it is wrong? An internal tool that occasionally gives a confident wrong answer about the expenses policy is a support problem; the same tool answering questions about safety procedures is something else. That decides how prominent the citations need to be and whether we surface the source passage rather than only the summary. Then the ordinary engineering questions: what is the budget and who enforces it, what is the latency target and does that mean streaming, what gets logged and does that put document content into our telemetry platform, and does any of this data leave our infrastructure in a way that our data-handling commitments forbid. The AI part is one HTTP call. Everything that makes it succeed or fail is permissions, chunking, evaluation and cost control.

    From the lesson Model Calls, Token Budgets, Retrieval, Structured Output and Evaluation

  30. 30. You maintain a widely-used internal library. A team asks for a method signature change that would make the API cleaner. How do you handle it?

    My first question is what kind of change it is, because the answer is mostly mechanical once I know. If it is purely additive — a new method, a new builder setting, a default method on an interface — it is a minor release and there is nothing to discuss. If it changes an existing signature, then even a change that looks like an improvement, like widening a parameter from ArrayList to List, is binary-incompatible: the JVM links by exact method descriptor, so every caller compiled against the old jar fails with NoSuchMethodError the first time that line runs. The people who feel that are not the team asking — they recompile — it is their transitive dependencies, which nobody is going to rebuild. So my answer is almost always: yes, and additively. Add the new signature, have the old one delegate to it, deprecate the old one with the replacement named and a note on how the semantics differ, and remove it at the next major version. That gives the cleaner API immediately and costs the users nothing. I would also check one thing that surprises people: while both overloads exist, a call like process(null) becomes ambiguous and stops compiling, so an additive binary-safe change can still be a source break. Worth mentioning in the release notes. Then I would ask the question behind the question — why does the signature bother them? Sometimes the real problem is that the method does too much, or that the parameter list is long enough to transpose, and the right answer is a builder or a smaller interface rather than a different signature. A cleaner API is not always a differently-shaped version of the same API. I would also want to know how widely the method is used, because that changes the timeline rather than the approach; for something used in two places I would still do it additively, just with a shorter overlap. And whatever we decide, I would make sure japicmp or revapi is running against the last released jar in CI, failing the build on an incompatible change unless the version says major — so this conversation happens automatically next time rather than depending on whether whoever reviews the PR happens to know the binary compatibility rules.

    From the lesson API Surface, Compatibility, Evolution and Extension Points

Learn it, then drill it

The Java course teaches every one of these in context, with runnable examples. For drilling, the practice bank has 2,610 multiple-choice Java questions with explanations.

Java topic pages

Other subjects