Java interviews for 3 years of experience care less about definitions and more about what you did: a bug you traced, a feature you shipped, how you test your own code, how you use the debugger and the build tools, and what code review taught you. It is written for Java developers with about two to four years of real work, the stage where you own features end to end but someone else still designs the system. Each question shows what the interviewer is checking, the shape of a good answer and a short spoken answer. Swap the stories for your own before the interview.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Read the trace: the top frame in your own code, and which reference on that line was null.
Walk it back: where that value was set, and which input or path left it empty.
Fix the source: validate at the boundary or change the contract, then add a test for that input.
“We had an NPE in our invoice export that only hit a few customers. The stack trace pointed at a line that chained three getters, and newer JDKs print which call returned null, so I could see it was the billing address. I didn't just add a null check there. I walked back to where the customer was loaded and found that customers created through an old import had no billing address at all, while our code assumed every customer had one. So the real fix was two parts: the export falls back to the main address when billing is missing, and the import now rejects records without an address. I added a unit test with a customer that has no billing address. The lesson I kept is that a null check at the crash site usually just moves the bug somewhere quieter.”
Saying you fixed it by adding a null check on the line that crashed, with no idea why the value was null.
Cause: somewhere bytes and text are converted without naming a charset, so the machine's default is used.
Where to look: getBytes and new String with no charset, readers and writers on files and streams, then the database and HTTP layers.
Fix: name UTF-8 explicitly wherever text crosses a boundary, and add a test with accented characters.
“Garbled accents almost always mean bytes were turned into text, or text into bytes, with a different encoding from the one they were written in. The usual cause is code that doesn't name a charset, like getBytes with no argument, new String on a byte array, or an old FileReader, so it uses the machine's default. Before Java 18 that default came from the operating system settings, so my laptop and a minimal server image could differ. Since Java 18 the default is UTF-8 for most APIs, but I still pass StandardCharsets.UTF_8 so the code doesn't depend on it. When I hit this, the culprit was a CSV export built with getBytes. I also check the other boundaries: the database column and connection settings, and the charset in the HTTP Content-Type header. Then I add a test with a name like José, so it can't quietly come back.”
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
String back = new String(bytes, StandardCharsets.UTF_8);
try (BufferedReader in = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
// read lines
}
Blaming the server or the font, or fixing it by stripping accents out of the data.
Cause: an int tops out a little above two billion, and going past it wraps round to a negative number with no error.
Where it hides: mapToInt(...).sum() returns an int, and so do multiplications like days times milliseconds per day.
Guard: use long, and Math.addExact or multiplyExact where an overflow must fail loudly.
“Java's int holds a bit over two billion, and when a sum goes past that it doesn't throw anything, it just wraps round to a big negative number. In my case a report added up item quantities across all orders with mapToInt and sum, and that sum is an int, so once the data grew enough the total flipped negative. The fix was mapToLong, so the sum is a long. Then I looked for the same pattern elsewhere and found a timeout worked out as days times 24 times 60 times 60 times 1000, all in int, which overflows from 25 days up. Writing 24L so the maths happens in long fixed it, though Duration.ofDays reads better. Where a wrong number would be worse than a crash, I use Math.addExact or multiplyExact, which throw an ArithmeticException on overflow.”
long totalQty = orders.stream()
.mapToLong(Order::quantity) // mapToInt(...).sum() would be an int
.sum();
long timeoutMs = Duration.ofDays(days).toMillis();
int checked = Math.addExact(a, b); // throws ArithmeticException on overflow
Expecting Java to throw an exception on overflow by itself, or fixing it by switching to double.
Why not double: binary floating point can't hold most decimal fractions exactly, so sums drift.
Creating values: from a String or valueOf, never the double constructor.
Traps: equals checks scale, divide needs a rounding mode, results need a set scale.
“Yes, on a billing module. Double can't represent values like 0.1 exactly, so adding many of them drifts by tiny amounts, and those show up as a total that's off in the last decimal place. BigDecimal fixed that, but it had its own traps. The first was creating it with new BigDecimal of a double, which carries the double's error into it. I use a String or BigDecimal.valueOf instead. The second was equals: 2.0 and 2.00 aren't equal because equals checks scale too, so I compare with compareTo. The third was divide: if the result doesn't terminate, like dividing by three, it throws an ArithmeticException unless you tell it how to round, usually with a scale and a rounding mode. We agreed on one rounding mode with finance and put it in one helper.”
Saying double is fine for money if you round at the end.
Fields and methods: each constant carries its own data, set in a constructor.
Replace branching: logic that switched on a string moves into the enum.
Care points: persist by name not ordinal; EnumMap and EnumSet for lookups.
“In a subscription feature we had plan names as strings and if-else chains in several places deciding seat limits. I turned it into an enum where each plan holds its seat limit as a field and has a method that says whether a team size is allowed. That removed the scattered checks, and adding a plan became one line. When a new plan got added, the compiler also pointed at every switch that didn't cover it, because we used switch expressions with no default branch. Two things I watch for: we store the enum's name in the database, never the ordinal, because someone reordering the constants would silently change what saved numbers mean. And for maps keyed by an enum I use EnumMap, which is compact and keeps the declaration order.”
enum Plan {
BASIC(1), TEAM(10), BUSINESS(50);
private final int seats;
Plan(int seats) { this.seats = seats; }
boolean allows(int users) { return users <= seats; }
}
Saving enums by ordinal, or never having used an enum for anything but constants.
Stream it: BufferedReader or Files.lines, one line at a time, never readAllLines.
Close it: try-with-resources, because Files.lines holds the file open.
Write in batches: group records into chunks for the database, and report bad lines instead of failing the run.
“The trap is reading the whole file into a list, which works in testing and then dies on the real file. I read it as a stream of lines, with Files.lines or a BufferedReader, so only a little of the file is in memory at a time. Files.lines keeps the file open, so it goes inside try-with-resources. I parse each line, and instead of saving one row at a time I collect a few hundred records and write them as one batch, then clear the batch. Bad lines go to an error file with the line number rather than stopping a two-hour job halfway. On a real job I'd also use a proper CSV parser, because quoted fields with commas inside break a simple split.”
try (Stream<String> lines = Files.lines(path)) {
lines.skip(1) // header
.map(parser::parse)
.forEach(batcher::add); // flushes every few hundred rows
}
batcher.flush();
Using readAllLines on a file of several gigabytes, or not closing the stream.
Chain: Comparator.comparing for the first key, thenComparing for the next.
Direction and nulls: reverseOrder for newest first, wrapped in nullsLast.
Gotchas: enums sort by declaration order, and a null status would still throw.
“I'd build it with Comparator chaining rather than a hand-written compare method. First comparing on status. Status is an enum, so it sorts in the order the constants are declared, which is worth knowing because someone reordering the enum changes the sort. Then thenComparing on the created date, with reverseOrder so the newest comes first, wrapped in nullsLast so orders with no date drop to the bottom of their status group. If status itself could be null, I'd wrap that key too, otherwise comparing throws a NullPointerException. I like this style because each line reads like the requirement, and when the product owner asks for a third sort key it's one more thenComparing instead of rewriting nested if statements.”
List<Order> sorted = orders.stream()
.sorted(Comparator.comparing(Order::status)
.thenComparing(Order::createdAt,
Comparator.nullsLast(Comparator.reverseOrder())))
.toList();
Subtracting two values inside compare, which can overflow, or not noticing that nulls will crash the sort.
Collector: toMap with a merge function, or groupingBy with a reducing downstream.
Duplicate keys: toMap without a merge function throws IllegalStateException.
Amounts: BigDecimal with add, not double, for money.
“I'd use toMap with three arguments: the customer id as the key, the amount as the value, and BigDecimal add as the merge function, so two orders for the same customer get summed. If you leave out the merge function, toMap throws an IllegalStateException the moment it meets a duplicate key. I've seen that pass in tests with one order per customer and then blow up on real data. The other trap is that toMap throws a NullPointerException if a value is null, so if an amount can be missing I filter those out first or map them to zero. groupingBy with a reducing collector gives the same result. I use BigDecimal because summing money in double drifts.”
Map<Long, BigDecimal> totals = orders.stream()
.collect(Collectors.toMap(
Order::customerId,
Order::amount,
BigDecimal::add));
Using the two-argument toMap and not knowing it fails on duplicate keys.
Cause: the variable says List, but the object behind it doesn't support add, and that only shows at runtime.
The three: Arrays.asList is fixed-size but settable and backed by the array; List.of can't be changed at all and rejects nulls; unmodifiableList is a read-only view of a list that can still change underneath.
Habit: copy into a new ArrayList before changing a list you didn't create, and make clear what your own methods return.
“All of these are typed as List, so the compiler can't warn you. The crash comes when one path hands you a list that doesn't support add. In my case a method normally returned a new ArrayList, but its empty case returned List.of(), so callers that added to the result blew up only when nothing was found. The three differ like this. Arrays.asList is a fixed-size wrapper over the array: set works and writes through to the array, but add and remove throw. List.of can't be changed at all, and it throws a NullPointerException if you pass it a null. Collections.unmodifiableList is a read-only view, so the original list can still change behind it. My rule now is that if I need to change a list I didn't create, I copy it into a new ArrayList first.”
List<String> fixed = Arrays.asList("a", "b");
fixed.set(0, "z"); // fine, writes through to the array
fixed.add("c"); // UnsupportedOperationException
List<String> none = List.of();
none.add("x"); // UnsupportedOperationException
List<String> mine = new ArrayList<>(none); // safe to change
mine.add("x");
Assuming any List can be added to, or catching the exception instead of fixing who owns the list.
Pin behaviour first: a few tests that capture what the method does today, even the odd parts.
Small steps: extract pieces with IDE refactorings, running the tests after each one.
Change and review: make the real change in the extracted piece, keep the refactor and the change easy to review.
“I had this with a pricing method of a few hundred lines that nobody wanted to touch. Before changing anything, I wrote tests that fed it real cases from production data and asserted whatever it returned today, even where the result looked odd, because other code might depend on that. Then I used the IDE's extract method refactoring to pull out the discount part, which was the bit my ticket touched, running the tests after each step. Once that piece was a small method with its own tests, the actual change was a few lines. I put the refactoring and the behaviour change in separate commits, so the reviewer could check each one. The odd behaviour I found, I raised as a question instead of quietly fixing it.”
Editing the method directly and relying on manual testing, or rewriting it from scratch inside a small ticket.
Isolate: mock the repository and client; test the class's own logic.
Arrange, act, assert: stub inputs, call the method, check the result.
Verify sparingly: only the interactions that matter, like the save.
“I use constructor injection, so the service takes the repository and the client as dependencies, which makes it easy to pass mocks. With the Mockito extension, I mark both as mocks and let InjectMocks build the service. In the test I stub the tax client to return a rate, call create, and assert on the total the service worked out. Then I verify the repository's save was called, because saving is part of the behaviour I care about. What I avoid is verifying every single call, because then the test breaks whenever someone refactors without changing behaviour. I also keep a few integration tests against a real database, because mocks can't tell you that a query is wrong.”
@ExtendWith(MockitoExtension.class)
class InvoiceServiceTest {
@Mock InvoiceRepository repo;
@Mock TaxClient taxClient;
@InjectMocks InvoiceService service;
@Test
void addsTaxAndSaves() {
when(taxClient.rateFor("NORTH")).thenReturn(new BigDecimal("0.20"));
Invoice invoice = service.create("NORTH", new BigDecimal("100"));
assertEquals(0, new BigDecimal("120").compareTo(invoice.total()));
verify(repo).save(any(Invoice.class));
}
}
Tests that mock the class under test itself, or assert nothing except that mocks were called.
Cause: the code and the test each read the real clock, and the date can change between them.
Fix: inject a java.time Clock and call now(clock).
In tests: Clock.fixed at an exact moment, including the edge cases like midnight.
“The problem is that the code reads the real clock, and so does the test, so near midnight they can land on different days. I've had exactly this with a trial-expiry check. The fix is to treat time as a dependency. The class takes a java.time Clock in its constructor, and every now call passes that clock. In production we wire in the system clock, in the test I pass Clock.fixed at a chosen instant and zone. That also lets me write the tests I actually care about, like one minute before midnight and one minute after, on the last day of the trial. It's a small change, and it removes a whole group of tests that fail now and then for no clear reason.”
class TrialService {
private final Clock clock;
TrialService(Clock clock) { this.clock = clock; }
boolean isExpired(LocalDate endDate) {
return LocalDate.now(clock).isAfter(endDate);
}
}
// in the test
Clock fixed = Clock.fixed(Instant.parse("2026-03-01T23:59:00Z"), ZoneOffset.UTC);
assertFalse(new TrialService(fixed).isExpired(LocalDate.of(2026, 3, 1)));
Fixing it by adding a sleep, retrying the test, or marking it as ignored.
What to retry: timeouts and server errors, not client errors like a bad request.
How: a small number of attempts with backoff and some random jitter.
Safety: only retry calls that are safe to repeat, or send an idempotency key.
“I added retries to a shipping-label API that failed a few times a day. First I decided what to retry: timeouts and 5xx responses, because those might work on a second try, but never a 400, which will fail the same way every time. I capped it at three attempts with a growing wait between them plus a little randomness, so all our threads don't retry at the same moment and pile onto a service that's already struggling. The part people miss is whether the call is safe to repeat. Creating a label twice would have charged us twice, so we sent an idempotency key the provider supported. I also logged each retry with the attempt number, which later showed us when the provider was having a bad day.”
Retrying every exception in a tight loop with no limit, or retrying a payment call that isn't safe to repeat.
The incident: what you needed to know and what the logs actually said.
The changes: request ids, the key business ids, full exceptions, sensible levels.
What you avoid now: logging secrets or personal data, and noisy logs nobody reads.
“We had failed payments one weekend and the log just said 'payment failed' about two hundred times, with no order id, no customer and no stack trace, because the code logged e.getMessage and dropped the exception. I couldn't tell which failures belonged to which request. Afterwards I made three changes in our service. We put a request id in the logging context at the start of each request, so every line from that request carries it. Error logs now pass the exception itself to the logger, so the full stack trace and cause are there. And each important log line has the ids someone would search for, like the order id. I also made sure we never log card numbers or tokens, and turned a few chatty info logs down to debug.”
Logging only e.getMessage, or saying you just add more println statements until you find it.
Understand first: check the history and what that code is protecting against.
Low-risk step: at least log the exception, if that doesn't change behaviour.
Make it visible: raise a ticket or mention it in review rather than a silent rewrite.
“I wouldn't just leave it, because a swallowed exception is how you get data that's quietly wrong for months. But I also wouldn't rewrite it inside an unrelated fix. First I'd look at the history and the caller to understand why someone did it. Sometimes it was hiding a known noisy failure. If adding a warning log with the exception doesn't change behaviour, I'd do that in my change and call it out in the PR description, so the reviewer sees it. If the right fix means changing behaviour, like letting the error fail the request, I'd raise a separate ticket with what I found and talk to whoever owns that area, because that decision might affect callers I don't know about.”
Ignoring it because it's not your ticket, or silently changing its behaviour in an unrelated PR.
Cause: if one run of scheduleAtFixedRate or scheduleWithFixedDelay throws, all later runs are suppressed.
Why it was silent: the exception is kept in the returned future, and nobody ever checks it.
Fix: catch and log inside the task body, and alert when the last successful refresh gets too old.
“With scheduleAtFixedRate or scheduleWithFixedDelay, if a single run throws an exception, the executor stops running that task. There are no more runs, and the exception sits inside the ScheduledFuture that nobody looks at, so nothing reaches the logs. That's exactly what we had. The cache refresh called another service that once sent back a malformed response, the parsing threw, and the cache stayed stale for days while everything looked healthy. The fix was to wrap the task body in a try-catch that logs the exception, so one bad run gets logged and the next run still happens. We also recorded the time of the last successful refresh and alerted when it got too old, because a job that stops quietly is worse than one that fails loudly.”
scheduler.scheduleWithFixedDelay(() -> {
try {
cache.refresh();
lastSuccess = Instant.now();
} catch (Exception e) {
log.error("Cache refresh failed, will try again next run", e);
}
}, 0, 5, TimeUnit.MINUTES);
Assuming the executor retries or logs failures by itself, or restarting the service without finding out why the task stopped.
Why it happens: the check and the insert are two steps, and both requests can pass the check before either one inserts.
Why synchronized isn't enough: it only covers threads inside one JVM, and the service runs on several instances.
Fix: a unique constraint in the database, with the duplicate-key error turned into a clear response.
“The check and the insert are two separate steps, so two requests can both run the check, both see no account, and both insert. Synchronized on the method would close that gap inside one JVM, but we ran three instances behind a load balancer, so requests landing on different instances never share the lock. It would also make every sign-up wait in one line. What actually fixed it was a unique constraint on the email column, with emails stored in lowercase, so the database itself refuses the second insert. In the code I kept the friendly check for the normal case, and also caught the duplicate-key exception from the insert and returned the same 'email already registered' response. Before the constraint could go on, I had to clean up the duplicates that were already there.”
Saying synchronized fixes it, without noticing the service runs on more than one instance.
Reproduce: ideally as a failing test, so the loop is fast.
Targeted breakpoints: conditional breakpoints, exception breakpoints, evaluate expression.
Beyond local: remote debugging a test environment, and logs where a debugger can't go.
“First I try to reproduce the bug as a failing unit test, because then the loop is quick. Then I put a breakpoint just before where I think things go wrong and step through. The features I use most are conditional breakpoints, so it only stops for the one order id that fails instead of on every loop pass, and exception breakpoints, which stop the moment a particular exception is thrown, even if something catches it later. While paused I use evaluate expression to try a fix or check a value without restarting. For a bug that only shows up in a test environment, I've attached the debugger remotely over the debug port, but never on production, where logs and dumps are the tools.”
Saying you only debug with print statements, or have never set a breakpoint condition.
Cause: code compiled against one version of a class, but a different version is on the runtime classpath.
Find it: the dependency tree, to see which versions were pulled in and which one won.
Fix: pin one version centrally or exclude the stray one, then test the other library that wanted it.
“NoSuchMethodError means the code was compiled against a version of a class that has the method, but the version loaded at runtime doesn't. Usually that's a dependency conflict: two libraries need different versions of the same thing, and the build picked one. In Maven, the version closest to your project in the tree wins, not the newest, so an old transitive version can quietly win. When I hit it, I ran the dependency tree, found two versions of a JSON library, and saw the older one had won through a reporting library. I pinned the newer version in dependencyManagement and ran that library's tests, since it now got a version it wasn't built for. Gradle picks the highest version by default, so the same bug looks different there.”
Deleting the local repository and rebuilding until it goes away, without finding which versions clash.
Before code: questions you asked and edge cases you found in the ticket.
Build and test: how you split the work and what you tested at each level.
Release and after: how it went out, what you watched, what you'd do differently.
“The one I'd pick is a bulk refund feature for support staff. Before coding I went back to the product owner with questions the ticket didn't answer: what happens if one refund in the batch fails, and can the same order be refunded twice. We agreed each refund stands alone, and the whole thing had to be safe to retry, so I used the order id as an idempotency key. I built it as a new endpoint plus a small job that processes the batch, with unit tests for the rules and an integration test against a test database. It shipped behind a feature flag to two support staff first. The next morning I checked the logs and found one refund stuck on a provider timeout, which led to adding a retry. Then we opened it to everyone.”
Describing only the coding part, as if the work ended when the pull request was merged.
The comment: what the reviewer said and what your code did.
Your reaction: whether you agreed straight away or asked why.
The habit: what you now do differently, with a small example.
“Early on I had a method that returned null when a customer had no orders. A senior reviewer asked me to return an empty list instead, and pointed out that three callers already had null checks and a fourth didn't, which was a crash waiting to happen. At first I thought it was a small style point, but when I looked at the callers I saw it was right. Now my habit is that methods returning collections never return null, and for a single value that may be missing I return Optional so the caller has to deal with it. Another comment from the same reviewer was to name tests after the behaviour, like refunds are rejected after thirty days, and I've kept that as well.”
Saying you've never had useful feedback, or describing review comments as nitpicking.
The task: what you estimated and how far off it was.
What you missed: the hidden work, like legacy code, tests, data or reviews.
Now: break it down, spike unknowns, and say early when it's slipping.
“I estimated two days to add a new export format to our reporting service. It took about two weeks. I'd looked only at the code I'd write, and missed that the existing export code had no tests, so I had to add some before I could safely change it. On top of that, the real data had encodings and very large files the test data didn't. What I did right was tell my lead on day three, not day ten, with what I'd found and a new estimate. What I do differently now is break the task into steps before giving a number, include testing and review time, and when there's a real unknown, ask for half a day to look first and give the estimate after.”
Blaming the product owner or the codebase entirely, or saying you have never missed an estimate.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.