19. Capstone and Interview Preparation
This chapter turns eighteen chapters of individual techniques into the two things that get judged: a complete application you designed on purpose, and the ability to explain any part of it under questioning. The first subchapter is about building; the rest are the questions interviewers actually ask, organised by the chapter that answers them, followed by the practical skills of performing well in the room.
<a id="191-capstone-application-design"></a>
19.1 Capstone Application Design
This subchapter covers taking a project from a vague description to a structured, reviewable codebase: extracting a model from requirements, choosing a layering, applying patterns where they earn their place, improving code that already exists, and working in a team.
Requirements Analysis and Domain Modeling
Most projects fail at the beginning, not at the end. Code that solves the wrong problem is worse than no code, because it takes longer to remove than to write. The first skill is therefore turning an ambiguous description into a model precise enough to build from.
Start by extracting the vocabulary. A useful technique is to take the requirements as prose and mark the nouns and verbs.
"A customer places an order containing one or more products. Each order has
a shipping address. Orders can be cancelled while they are still pending.
When an order ships, the customer is notified."
nouns: customer, order, product, shipping address
verbs: places, contains, cancel, ship, notify
The nouns are candidate types; the verbs are candidate behaviours. This is a starting point, not an algorithm: some nouns are attributes rather than types, and some verbs belong to a service rather than to an object.
Distinguish the three kinds of type, because getting this wrong produces either anaemic data holders or objects that cannot be compared.
| Kind | Identity | Mutability | Example |
|---|---|---|---|
| Entity | Has an identifier; two instances with the same id are the same thing | Usually mutable | Order, Customer |
| Value object | Defined entirely by its values | Immutable | Money, Address |
| Service | No identity or state | Stateless | PricingService |
// A VALUE OBJECT: no id, immutable, equal when the values are equal.
public record Money(BigDecimal amount, Currency currency) {
public Money { // compact constructor
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("too many decimal places");
}
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) { // an invariant the type enforces
throw new IllegalArgumentException("currency mismatch");
}
return new Money(amount.add(other.amount), currency);
}
}
// An ENTITY: has identity, and its state changes over its lifetime.
public class Order {
private final OrderId id; // identity, never changes
private OrderStatus status;
private final List<OrderLine> lines = new ArrayList<>();
@Override
public boolean equals(Object o) {
// Two Orders are the same order when the ids match, regardless of state.
return o instanceof Order other && id.equals(other.id);
}
}
Using BigDecimal rather than double here is 16.1's rule about money, and
wrapping it in a Money type is what makes "currency mismatch" impossible to
forget.
Put behaviour on the objects that own the data. The alternative, classes with only getters and setters plus a service that manipulates them, is called an anaemic domain model, and it scatters rules that belong together.
// ANAEMIC: the rule lives outside the object it constrains, so every caller
// must remember to check it.
if (order.getStatus() == OrderStatus.PENDING) {
order.setStatus(OrderStatus.CANCELLED);
}
// RICH: the object enforces its own rule, and no caller can skip it.
public void cancel(String reason) {
if (status != OrderStatus.PENDING) {
throw new OrderNotCancellableException(id, status);
}
this.status = OrderStatus.CANCELLED;
this.cancellationReason = reason;
}
Model the invariants explicitly. An invariant is something that must always be true, and the best place for it is a constructor or a factory method, so an invalid object cannot exist at all.
Invariants for Order
--------------------
an order always has at least one line
the total equals the sum of the lines
only a PENDING order can be cancelled
a shipped order cannot be modified
public static Order place(Customer customer, List<OrderLine> lines) {
if (lines.isEmpty()) {
throw new IllegalArgumentException("an order needs at least one line");
}
return new Order(OrderId.generate(), customer, lines); // valid from birth
}
Identify the aggregates, meaning the clusters that change together and are
loaded and saved as a unit. Order and its lines are one aggregate: a line has
no meaning without its order. Customer is a separate aggregate, referenced by
identifier rather than embedded.
Order aggregate Customer aggregate
--------------- ------------------
Order (the root) Customer (the root)
+-- OrderLine +-- ContactDetails
+-- ShippingAddress
Order holds a CustomerId, not a Customer object.
Changes inside an aggregate are one transaction (16.3).
This boundary is what determines your transaction boundaries from 16.3 and your fetch strategies from 16.4, which is why it is worth deciding deliberately.
Use a glossary and stick to it. If the business says "order" and the code says "purchase", every conversation needs translating. Name types after the words the people who asked for the system use.
Key Takeaways
- Extract candidate types from the nouns and behaviours from the verbs, then refine.
- Entities have identity and change; value objects are immutable and compared by value.
- Value objects are the natural home for invariants such as currency matching.
- Put rules on the object that owns the data, rather than in a service that manipulates it.
- Enforce invariants in constructors and factories so invalid objects cannot exist.
- Aggregates cluster objects that change together and define transaction boundaries.
- Reference other aggregates by identifier, not by object.
- Use the business's own vocabulary as type names.
🧪 Practice
- Take a one-paragraph description of a library lending system and list the candidate entities, value objects, and services.
- Write a
Moneyvalue object that refuses to add different currencies and test both cases.- Convert an anaemic class with five setters into one that enforces its own rules, and list the rules you moved.
- Draw the aggregate boundaries for an order, its lines, a customer, and a product, and say which are loaded together.
- Interview: What distinguishes an entity from a value object, and why does it matter for
equals? (Hint: consider two objects holding the same data but representing different things.)
Layered Architecture in Java Applications
A codebase with no structure becomes one where any class may call any other, which means any change may affect anything. Layering is the cheapest structure that prevents this: it defines a small number of levels and one rule about which direction calls may go.
The rule is that dependencies point inward or downward, never back.
+----------------------------------------------------+
| Presentation controllers, request and response | 17.4, 18.3
| types, HTTP concerns only |
+----------------------------------------------------+
| calls
v
+----------------------------------------------------+
| Application services, use cases, transaction | 16.3, 17.2
| boundaries, orchestration |
+----------------------------------------------------+
| calls
v
+----------------------------------------------------+
| Domain entities, value objects, rules | 19.1
| no framework, no database |
+----------------------------------------------------+
^ implements interfaces from
|
+----------------------------------------------------+
| Infrastructure repositories, clients, messaging | 16.4, 17.3
+----------------------------------------------------+
The arrow from infrastructure pointing up is the important one. Rather than the domain depending on the database, the domain declares an interface and the infrastructure implements it. This is the dependency inversion principle from chapter 9, and it is what keeps the domain testable without any infrastructure at all.
// In the DOMAIN package: an interface expressed in domain terms.
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
// In the INFRASTRUCTURE package: the implementation depends on the domain.
@Repository
public class JpaOrderRepository implements OrderRepository {
// Imports jakarta.persistence. The domain package does not.
}
What each layer must not do is more useful than what it may do.
| Layer | Must not |
|---|---|
| Presentation | Contain business rules, open transactions, touch repositories |
| Application | Know about HTTP, return DTOs, contain domain rules |
| Domain | Import any framework, know about persistence or HTTP |
| Infrastructure | Contain business rules |
// A correctly thin controller: bind, delegate, map. 17.4
@PostMapping("/{id}/cancel")
public OrderResponse cancel(@PathVariable long id,
@RequestBody @Valid CancelRequest request) {
return OrderResponse.from(orderService.cancel(new OrderId(id), request.reason()));
}
// A correctly focused service: transaction boundary and orchestration. 16.3
@Transactional
public Order cancel(OrderId id, String reason) {
Order order = orders.findById(id).orElseThrow(() -> new OrderNotFound(id));
order.cancel(reason); // the RULE lives in the domain object
orders.save(order);
events.publish(new OrderCancelled(id));
return order;
}
Package by feature, not by layer. The instinctive arrangement groups every controller together, which means one feature is spread across four packages and nothing can be made package-private.
By layer (avoid) By feature (prefer)
---------------- -------------------
com.shop.controller com.shop.order
OrderController OrderController
ProductController OrderService
com.shop.service Order
OrderService OrderRepository
ProductService com.shop.product
com.shop.repository ProductController
OrderRepository ProductService
ProductRepository Product
a change touches 4 packages a change touches 1 package
everything must be public internals can be package-private
Feature packaging makes the module boundaries visible and lets you see when one feature reaches into another's internals.
Enforce the rules automatically, because conventions that are not checked decay.
@AnalyzeClasses(packages = "com.shop")
class ArchitectureTest {
@ArchTest
static final ArchRule domainIsFrameworkFree =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
@ArchTest
static final ArchRule layersAreRespected = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Web").definedBy("..web..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service");
}
Do not over-layer a small project. Three layers with a rich domain is the right default. Adding separate mapper layers, command buses, and hexagonal ports to a service with four endpoints costs more than it returns. Structure should grow with the problem.
Key Takeaways
- Layering restricts which parts may call which, so changes stay local.
- Dependencies point one way; infrastructure implements interfaces the domain declares.
- The domain must import no framework, which is what makes it testable without one.
- Controllers bind and delegate; services own transactions; domain objects own rules.
- Package by feature so a change touches one package and internals can stay hidden.
- Enforce the rules with architecture tests, or they erode.
- Match the amount of structure to the size of the problem.
🧪 Practice
- Take an existing project and draw its actual dependency arrows, then mark the ones going the wrong way.
- Move a repository interface into the domain package and its implementation into infrastructure.
- Restructure one feature from package-by-layer to package-by-feature and make two classes package-private.
- Write an architecture test asserting the domain package imports no Spring classes.
- Interview: Why should a repository interface live in the domain package rather than beside its implementation? (Hint: which package should be able to compile without the other?)
Applying Patterns to a Full Project
Chapter 9 covered design patterns individually. Applying them to a real project raises a different question, which is not "which pattern is this" but "does this problem justify a pattern at all". Patterns are solutions to recurring problems, and using one where the problem is absent adds indirection with no benefit.
The test is whether the pattern removes a real, present difficulty.
// UNNECESSARY: a factory for a class with one implementation and no choice.
public class OrderFactory {
public Order create(Customer c, List<OrderLine> l) { return new Order(c, l); }
}
// JUSTIFIED: the choice is genuine and the rule is worth naming.
public class PricingStrategyFactory {
public PricingStrategy forCustomer(Customer customer) {
return switch (customer.tier()) {
case PREMIUM -> new PremiumPricing();
case PARTNER -> new PartnerPricing();
default -> new StandardPricing();
};
}
}
Patterns that earn their place in most projects:
| Pattern | The problem it solves | Where it appears |
|---|---|---|
| Repository | Hiding persistence behind a domain interface | 16.4, every project |
| Strategy | A rule with several interchangeable variants | Pricing, shipping, ranking |
| Factory | Non-trivial construction or a runtime choice | Creating from a discriminator |
| Builder | Objects with many optional fields | Test data, request objects |
| Adapter | Making a third-party API fit your interface | Payment gateways, clients |
| Observer or events | Decoupling a trigger from its consequences | 17.1's application events |
| Decorator | Adding behaviour without editing the class | Caching, retry, metrics |
A worked example: three patterns in one flow, each solving a distinct problem.
// STRATEGY: the shipping cost rule varies by method, and new ones get added.
public interface ShippingCalculator {
Money costFor(Order order);
}
@Component
public class ExpressShipping implements ShippingCalculator { ... }
@Component
public class StandardShipping implements ShippingCalculator { ... }
// ADAPTER: the payment provider's API is not ours, and we may change provider.
public interface PaymentGateway { // our interface
PaymentResult charge(PaymentRequest request);
}
@Component
public class StripeGatewayAdapter implements PaymentGateway {
private final StripeClient stripe; // their API
@Override
public PaymentResult charge(PaymentRequest request) {
// Translate our types into theirs, and their errors into ours.
var response = stripe.charges().create(toStripeParams(request));
return toPaymentResult(response);
}
}
// EVENTS: placing an order should not know who cares that it happened.
@Transactional
public Order place(PlaceOrderCommand command) {
Order order = Order.place(command.customer(), command.lines());
orders.save(order);
events.publish(new OrderPlaced(order.id())); // 17.1
return order;
}
@Component
public class InventoryListener {
@EventListener
public void on(OrderPlaced event) { ... } // added without editing above
}
Recognise the anti-patterns as clearly as the patterns.
| Anti-pattern | Symptom |
|---|---|
| God class | One class of 2,000 lines that everything depends on |
| Anaemic domain model | Entities with only getters and setters; rules in services |
| Service locator | Classes fetching dependencies from a registry (17.1) |
| Singleton as global state | Static mutable state that makes tests order-dependent |
| Premature abstraction | An interface with exactly one implementation, forever |
| Pattern fever | A factory producing a builder producing a strategy, for one case |
The single-implementation interface deserves a fair hearing, because it is often criticised unfairly. An interface with one implementation is justified when it marks a boundary you intend to cross, such as a repository the domain declares, or when it exists so tests can substitute a fake. It is unjustified when it exists only because someone said to program to interfaces.
A practical sequence for a capstone: write the simplest thing that works, then introduce a pattern at the moment the second variant appears. The second pricing rule justifies a strategy; the first does not.
Key Takeaways
- A pattern is justified when it removes a difficulty that exists now.
- Repository, strategy, adapter, builder, and events cover most real needs.
- Adapters keep a third-party API from spreading through the codebase.
- Events decouple an action from an open-ended set of consequences.
- Anaemic models, god classes, and service locators are the common anti-patterns.
- A single-implementation interface is fine at a boundary or for testing, not as a reflex.
- Introduce a pattern when the second variant appears, not in anticipation.
🧪 Practice
- Find a
switchon a type or status in your code and decide whether strategy would improve it.- Wrap a third-party client in an adapter and make the rest of the code depend only on your interface.
- Replace a direct call to three consequences with one published event and three listeners.
- Identify a class in any codebase with more than fifteen methods and propose a split.
- Interview: When is an interface with exactly one implementation justified? (Hint: name two situations where the second implementation is a test or a future boundary.)
Refactoring an Existing Codebase
Most professional work is changing code that already exists, usually code nobody currently understands. Refactoring is the discipline of improving its structure without changing its behaviour, and the phrase "without changing its behaviour" is the whole difficulty.
The prerequisite is a safety net. Refactoring without tests is editing and hoping. If the code has no tests, the first step is characterisation tests: tests that record what the code does now, correct or not.
// A characterisation test does not assert what SHOULD happen. It pins down
// what DOES happen, so a refactor that changes it fails loudly.
@Test
void currentBehaviourForExpiredCoupon() {
var result = legacyPricer.price(cart, expiredCoupon());
// Discovered by running it, not by reading the requirements.
assertEquals(new BigDecimal("42.50"), result);
}
If a characterisation test reveals a bug, record it as a bug and keep the test asserting the current behaviour until the fix is a deliberate, separate change.
Work in small, reversible steps. The rhythm is: make one change, run the tests, commit. A refactor that takes three days before it compiles is a rewrite wearing a disguise.
Safe rhythm Unsafe
----------- ------
extract method -> test -> commit restructure everything -> hope
rename -> test -> commit one commit, 4,000 lines changed
move class -> test -> commit cannot bisect, cannot review
The refactorings that do most of the work are few, and modern IDEs perform them mechanically, which is safer than editing by hand.
| Refactoring | Applies when |
|---|---|
| Extract method | A block needs a comment to explain it |
| Rename | A name misleads or abbreviates |
| Introduce parameter object | Three or more parameters always travel together |
| Replace conditional with polymorphism | A switch on type repeats in several places |
| Extract class | A class has two groups of fields used separately |
| Replace magic value with constant | A literal appears more than once |
| Inline | An abstraction adds no clarity |
// BEFORE: one method doing four things, with a comment per section.
public Invoice generate(Order order) {
// calculate subtotal
BigDecimal subtotal = BigDecimal.ZERO;
for (OrderLine line : order.lines()) {
subtotal = subtotal.add(line.price().multiply(BigDecimal.valueOf(line.qty())));
}
// apply discount
if (order.customer().tier() == Tier.PREMIUM) { subtotal = subtotal.multiply(...); }
// add tax
BigDecimal tax = subtotal.multiply(new BigDecimal("0.21"));
...
}
// AFTER: each comment became a method name. Nothing else changed.
public Invoice generate(Order order) {
Money subtotal = subtotalOf(order);
Money discounted = applyDiscount(subtotal, order.customer());
Money tax = taxOn(discounted);
return new Invoice(discounted.plus(tax), tax);
}
Refactor toward a goal, not in general. "Tidy this up" produces endless churn. "Make it possible to add a second payment provider" produces an adapter and then stops.
The strangler pattern handles the case where a component is too large to change safely: rather than rewriting it, route new behaviour to a new implementation and shrink the old one over time.
Step 1 new code calls an interface; the old implementation sits behind it
Step 2 a second implementation handles one case; route that case to it
Step 3 move cases across one at a time, each with tests
Step 4 the old implementation handles nothing; delete it
Know when not to refactor. Code that works, is rarely touched, and nobody needs to understand is not costing anything. The justification for changing it is a change you need to make, and the boy scout rule, leaving code slightly better than you found it, applies to the file you were already editing.
Measure whether it helped. Cyclomatic complexity, class length, and coupling are crude but directional. The better signal is how long the next change takes.
Key Takeaways
- Refactoring improves structure while behaviour stays identical.
- Without tests it is not refactoring; write characterisation tests first.
- A characterisation test records current behaviour, including bugs.
- Work in small steps with tests and a commit after each one.
- Extract method, rename, and introduce parameter object do most of the work.
- Use the IDE's mechanical refactorings rather than editing by hand.
- Refactor toward a specific goal so the work has an end.
- Strangle large components incrementally rather than rewriting them.
- Leave untouched, working, rarely read code alone.
🧪 Practice
- Write characterisation tests for a method you do not fully understand, discovering the expected values by running it.
- Take a forty-line method and extract one method per comment, running tests after each extraction.
- Find three parameters that always appear together and introduce a parameter object.
- Replace a repeated
switchon an enum with polymorphism and compare the diff sizes.- Interview: You are asked to add a feature to a 900-line untested class. Describe your first two steps. (Hint: what must exist before any structural change is safe?)
Code Review Practices
A code review is the last point at which a defect is cheap to fix, and chapter 15's table on defect cost makes that concrete. It is also the main way knowledge spreads through a team, and the main way standards are either upheld or quietly abandoned.
What review is for, in priority order:
1. Correctness does it do what it claims, including edge cases?
2. Security can it be abused? (18.4)
3. Design fit does it belong here, at this layer? (19.1)
4. Maintainability will the next person understand it?
5. Tests do they test behaviour, and would they catch a
regression? (15.1)
6. Style should be automated, not discussed
The last line matters: arguing about formatting in a review is a process failure. A formatter and a linter in the build settle it, and the review covers what tools cannot.
What a reviewer should actually look for, beyond reading the diff:
| Question | Chapter |
|---|---|
| Are transaction boundaries at the business operation? | 16.3 |
| Could this query be N+1? | 16.4 |
Is the @Transactional method self-invoked? | 17.2 |
| Are entities leaking into the API? | 18.3 |
| Is input from outside parameterised? | 16.2 |
| Are errors leaking internal detail? | 18.3 |
| Is a singleton holding mutable state? | 17.1 |
| Does the test assert behaviour or implementation? | 15.1 |
Write comments that are specific, kind, and actionable. The difference is not politeness for its own sake; vague or hostile comments produce defensive authors and worse outcomes.
Unhelpful Helpful
--------- -------
"This is wrong." "If `lines` is empty, `get(0)` throws.
Should an empty order be rejected in
the factory instead?"
"Why did you do it this way?" "I'd expect the transaction to start in
the service rather than here, so the
two saves are atomic. Is there a reason
it's in the controller?"
"Use a stream." "Optional: a stream would read more
directly here. Happy either way."
Label the severity, so the author knows what blocks the merge.
blocking: must change before merge a bug, a security issue
suggestion: worth doing, author decides a clearer name, a simpler loop
nit: trivial, never blocking a typo in a comment
question: I do not understand yet genuinely asking
praise: this is good worth saying, and rarely said
For authors, the reviewable pull request is a skill of its own.
- Keep it small. Review quality collapses above roughly 400 lines. A 2,000 line pull request receives "looks good to me" because thorough review is impossible.
- One concern per change. A refactor mixed with a feature hides the feature.
- Write the description the reviewer needs: what changed, why, how it was tested, and anything you are unsure about.
- Respond to every comment, even if only to say you disagree and why.
- Separate mechanical changes. A rename touching 60 files should be its own commit so the real change is visible.
Pull request description that works
-----------------------------------
What: adds cancellation to the order API.
Why: support asked for it; see TICKET-482.
How: Order.cancel enforces the PENDING rule; the controller maps the
exception to 409 via the existing advice.
Tests: unit tests for the rule, a slice test for the 409 mapping.
Unsure about: whether cancellation should publish an event. Opinions welcome.
Review your own diff first. Reading the change as a stranger catches a surprising share of the comments a reviewer would otherwise write.
Key Takeaways
- Review catches defects while they are still cheap and spreads knowledge.
- Prioritise correctness and security over style, and automate style entirely.
- Look for the specific hazards earlier chapters named, such as N+1 and self-invocation.
- Make comments specific and actionable rather than evaluative.
- Label severity so the author knows what blocks a merge.
- Keep pull requests small and limited to one concern.
- Write a description covering what, why, how it was tested, and open questions.
- Separate mechanical changes from meaningful ones.
- Read your own diff before requesting review.
🧪 Practice
- Review a pull request and classify each of your comments as blocking, suggestion, nit, or question.
- Rewrite three harsh or vague review comments to be specific and actionable.
- Split a large change you have made into three smaller, independently reviewable ones.
- Build a review checklist for your project from the hazards table above.
- Interview: Why does review quality drop sharply with change size? (Hint: what does a reviewer do when a change is too large to hold in their head?)
Version Control Workflow for Java Projects
Version control is where a team's changes meet. The workflow decides how often they meet, and therefore how painful the meeting is. The single most important variable is how long a branch lives before it merges.
Long-lived branches Short-lived branches
------------------- --------------------
two weeks of divergence hours to two days
large, conflicted merges small, usually clean merges
integration risk discovered late discovered continuously
reviews are too big to be useful reviews are small and thorough
The two workflows worth knowing:
| Workflow | Shape | Suits |
|---|---|---|
| Trunk-based | Short branches merged to main daily; releases from main | Continuous delivery, most teams |
| Git flow | Long-lived develop, release, and hotfix branches | Versioned products with supported releases |
Git flow is widely cited and usually heavier than a web service needs. Trunk-based development with feature flags is the default for anything deployed continuously.
Trunk-based
-----------
main ---o------o------o------o------o---> always releasable
\ / \ / \ /
o--o o-o o-o branches live hours, not weeks
Feature flags decouple merging from releasing, which is what makes short branches possible for large features. 17.5's conditional properties are exactly this mechanism.
@Service
public class PricingService {
private final boolean newEngineEnabled; // from configuration, 18.2
public Money price(Order order) {
// Incomplete work can be merged safely because it is not reachable.
return newEngineEnabled ? newEngine.price(order) : legacyEngine.price(order);
}
}
Commit messages are documentation that survives. The conventional format separates a summary line from the reasoning.
Add optimistic locking to order updates
Two users editing the same order could silently overwrite each other's
changes because the update did not check the version column.
Adds a version field to Order and guards the update with it, returning 409
when the update affects zero rows.
Refs: TICKET-482
The summary line should say what the change does in the imperative, under about fifty characters. The body explains why, which is the part that cannot be recovered from the diff.
Rebase versus merge is a question with a practical answer rather than a correct one.
| Operation | Effect | Use for |
|---|---|---|
git merge | Preserves history, adds a merge commit | Merging a branch into main |
git rebase | Replays commits, producing a linear history | Updating your own unmerged branch |
| Squash merge | Collapses a branch into one commit | Keeping main readable |
The one rule that matters: never rebase a branch someone else has based work on, because it rewrites commits they already have.
Java-specific practicalities:
.gitignore
----------
target/ Maven output
build/ Gradle output
*.class
.idea/ *.iml IntelliJ
.settings/ .classpath Eclipse
*.log
application-local.yml local overrides with real credentials
Never commit build output, and never commit a local configuration file holding credentials, as 18.2 established.
Commit the wrapper, mvnw or gradlew, so every developer and the CI
server use the same build tool version. This is the same reproducibility
argument as pinning dependency versions in 18.1.
Protect main. Require a passing build and at least one approval, and
disallow force pushes. A branch that anyone can overwrite is not a source of
truth.
Tag releases so a deployed version maps to an exact commit, which is the first thing you need during an incident and connects directly to 18.5's build info endpoint.
git tag -a v1.4.2 -m "Release 1.4.2"
git push origin v1.4.2
Key Takeaways
- Branch lifetime is the variable that determines merge pain.
- Trunk-based development with short branches suits continuously deployed services.
- Feature flags let incomplete work merge safely without being released.
- A commit message's body should explain why, since the diff already shows what.
- Rebase your own unmerged branch; never rebase one others have built on.
- Ignore build output, IDE files, and local configuration holding credentials.
- Commit the build wrapper so everyone builds with the same tool version.
- Protect
mainwith required checks and reviews, and tag every release.
🧪 Practice
- Take a feature you would branch for a week and describe how to deliver it in three merges behind a flag.
- Rewrite three commit messages from a project to explain why rather than what.
- Create a conflict deliberately, resolve it with merge, then repeat with rebase and compare the histories.
- Audit a repository's
.gitignorefor anything build-generated or secret that is being tracked.- Interview: How do feature flags allow a large feature to be merged daily? (Hint: what is the difference between code being present and code being reachable?)
<a id="192-core-language-and-oop-questions"></a>
19.2 Core Language and OOP Questions
This subchapter revisits the material of chapters 2 to 5 as interviewers pose it: what the question is really testing, what a weak answer sounds like, and the detail that distinguishes someone who has used the feature from someone who has read about it.
Encapsulation, Inheritance, Polymorphism, and Abstraction
This is the most common opening question in a Java interview, and it is a trap disguised as a gift. Almost every candidate can recite four definitions, so reciting them distinguishes nobody. What the interviewer is listening for is whether you can say what each one buys you and give an example from your own code.
Weak answer Strong answer
----------- -------------
"Encapsulation is hiding data "Encapsulation means the object owns its
using private fields and invariants. Our Order can only be
getters and setters." cancelled while PENDING, and because the
status field is private, no caller can
skip that check."
Encapsulation is about controlling change, not about hiding data. Getters and setters for every field is not encapsulation; it is a public field with extra typing.
// NOT encapsulated: the invariant is unprotected despite the private field.
public class Order {
private OrderStatus status;
public void setStatus(OrderStatus s) { this.status = s; } // any value, any time
}
// Encapsulated: the class controls its own transitions.
public class Order {
private OrderStatus status;
public void cancel(String reason) {
if (status != OrderStatus.PENDING) {
throw new OrderNotCancellableException(id, status);
}
this.status = OrderStatus.CANCELLED;
}
}
Inheritance expresses that one type is a specialisation of another and can be used wherever the parent is expected. The strong answer mentions that it is the most overused mechanism in object-oriented programming, and that composition is usually better.
// WRONG: Stack is not a kind of ArrayList. Inheriting exposes add(index, e),
// remove(index), and set(index, e), all of which break stack semantics.
public class Stack<E> extends ArrayList<E> { }
// RIGHT: composition exposes exactly the stack operations.
public class Stack<E> {
private final List<E> items = new ArrayList<>(); // HAS a list
public void push(E item) { items.add(item); }
public E pop() { ... }
}
The test is the Liskov substitution principle: if code written against the parent breaks when given the child, the inheritance is wrong.
Polymorphism means one call site invoking different implementations depending on the runtime type. The detail that separates a strong answer is knowing there are two kinds, and that Java resolves them at different times.
| Kind | Also called | Resolved at |
|---|---|---|
| Overriding | Runtime polymorphism | Runtime, by the object's actual type |
| Overloading | Compile-time polymorphism | Compile time, by the declared types |
List<Shape> shapes = List.of(new Circle(2), new Square(3));
for (Shape shape : shapes) {
// The compiler knows only Shape. The JVM dispatches on the real class.
System.out.println(shape.area());
}
The practical value is the open-closed principle: adding a Triangle requires
no change to the loop.
Abstraction means exposing what something does while hiding how. The strong answer distinguishes it from encapsulation, because the two are routinely conflated.
Abstraction hides COMPLEXITY "what can I do with this?"
an interface, an abstract class, a well-named method
Encapsulation hides STATE "who is allowed to change this?"
private fields, controlled mutation
// Abstraction: callers know the capability, not the mechanism.
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}
// Whether it calls Stripe over HTTPS or writes to a file is not their concern.
Prepare one example from your own work for each, because "give me an example from a project you worked on" is the standard follow-up and generic examples land poorly.
Key Takeaways
- Reciting definitions distinguishes nobody; explain what each principle buys.
- Encapsulation protects invariants, and a setter for every field defeats it.
- Inheritance means "is a" and must satisfy Liskov substitution.
- Composition is usually the better default; be ready to say why.
- Polymorphism lets one call site serve many types, which is what makes code open to extension.
- Overriding resolves at runtime; overloading resolves at compile time.
- Abstraction hides complexity, encapsulation hides state; keep them distinct.
- Have a concrete example from your own code ready for each.
🧪 Practice
- Write a class with getters and setters for every field, then rewrite it so it enforces one invariant, and list what the caller can no longer do.
- Find an inheritance relationship in any codebase that violates Liskov substitution and convert it to composition.
- Explain each of the four principles in two sentences using one example from your own project.
- Interview: Is encapsulation the same as making fields private? (Hint: consider a private field with a public setter that accepts any value.)
Abstract Classes versus Interfaces
This question tests whether you understand that since Java 8 the two have converged considerably, and that the remaining differences are about state and constructors, not about method bodies.
The answer that dates a candidate is "interfaces cannot have implementations", which has been false since Java 8.
| Feature | Abstract class | Interface |
|---|---|---|
| Instance fields | Yes | No, only public static final |
| Constructors | Yes | No |
| Method implementations | Yes | Yes, default and static |
private methods | Yes | Yes, since Java 9 |
| Access modifiers on methods | Any | public unless private |
| How many can a class have | One | Many |
| Purpose | Shared implementation and state | A capability contract |
The decisive differences are the first two rows and the second to last. An abstract class can hold state and run constructor logic; an interface cannot. A class can implement many interfaces but extend one class.
// ABSTRACT CLASS: shared state and construction, plus a template method.
public abstract class HttpClient {
private final Duration timeout; // state: interfaces cannot do this
private int requestCount;
protected HttpClient(Duration timeout) { // a constructor: nor this
this.timeout = timeout;
}
// Template method: the algorithm is fixed, one step varies.
public final Response send(Request request) {
requestCount++;
log(request);
Response response = doSend(request, timeout); // subclass supplies this
log(response);
return response;
}
protected abstract Response doSend(Request request, Duration timeout);
}
// INTERFACE: a capability, implementable alongside others.
public interface Retryable {
int maxAttempts();
default boolean shouldRetry(Exception e) { // a shared default, no state
return e instanceof TransientException;
}
}
public interface Cacheable {
Duration cacheDuration();
}
// A class can declare several capabilities; it could extend only one class.
public class ProductClient extends HttpClient implements Retryable, Cacheable { }
The rule of thumb: use an interface to describe what something can do, and an abstract class to share how a family of things does it. When in doubt, start with an interface, because it leaves the single inheritance slot free.
default methods exist for a specific reason, and knowing it is a good
signal. They were added in Java 8 so that Collection could gain stream() and
forEach() without breaking every existing implementation in the world.
// Adding this to an interface does not break existing implementors.
public interface Collection<E> {
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
}
They are a mechanism for interface evolution, not a way to build a base class in disguise.
The diamond problem is a likely follow-up. Java permits multiple interface inheritance, so two interfaces can supply the same default method, and the compiler forces you to resolve it.
interface A { default String greet() { return "A"; } }
interface B { default String greet() { return "B"; } }
class C implements A, B {
@Override
public String greet() {
return A.super.greet(); // the explicit syntax for choosing one
} // without this, the class does not compile
}
A class's implementation always wins over an interface default, and a more specific interface wins over a less specific one.
Key Takeaways
- Since Java 8 interfaces can have implementations, so that is not the difference.
- Abstract classes hold instance state and have constructors; interfaces do not.
- A class extends one class but implements many interfaces.
- Use interfaces for capability and abstract classes for shared implementation.
- Prefer an interface when unsure, to leave the inheritance slot available.
defaultmethods exist to evolve interfaces without breaking implementors.- Conflicting defaults must be resolved explicitly with
A.super.method().- A class implementation beats an interface default.
🧪 Practice
- Write an abstract class with a template method and two subclasses that vary one step.
- Define two interfaces with the same default method, implement both, and resolve the conflict.
- Take an abstract class with no state and convert it to an interface, noting what changes.
- Interview: Why were default methods added to Java 8, given that abstract classes already existed? (Hint: what would adding
stream()toCollectionhave done to every existing implementation?)
Overloading versus Overriding
These two words sound similar and describe unrelated mechanisms. The question tests precision, and the best way to demonstrate it is to name when each is resolved, because that single fact explains every behaviour that follows.
| Aspect | Overloading | Overriding |
|---|---|---|
| Where | Same class or a subclass | Subclass, for an inherited method |
| Signature | Must differ in parameters | Must be identical |
| Return type | May differ | Same, or a covariant subtype |
| Access | May differ | Cannot be more restrictive |
| Exceptions | May differ freely | No new or broader checked exceptions |
| Resolved | Compile time, by declared types | Runtime, by the actual object |
| Also called | Static or compile-time binding | Dynamic or runtime binding |
The classic demonstration shows both in one snippet.
class Parent {
void greet() { System.out.println("Parent"); }
}
class Child extends Parent {
@Override
void greet() { System.out.println("Child"); } // OVERRIDING
void greet(String name) { System.out.println("Hi " + name); } // OVERLOADING
}
Parent p = new Child();
p.greet(); // "Child": dispatched on the RUNTIME type
// p.greet("Ada"); // does not compile: Parent has no such method
The declared type decides which methods exist; the runtime type decides which implementation runs.
The trap question almost always involves overload resolution with null or
with an inheritance hierarchy.
class Printer {
void print(Object o) { System.out.println("Object"); }
void print(String s) { System.out.println("String"); }
void print(Integer i) { System.out.println("Integer"); }
}
new Printer().print(null); // prints "String"
The compiler picks the most specific applicable overload. String and
Integer are both more specific than Object, and since neither is a subtype
of the other the call would be ambiguous, except that here it compiles only
because... in fact with both String and Integer present this is a compile
error, and with only Object and String it selects String. The lesson to
state in an interview is that overloading with null arguments is ambiguous by
design and should be avoided, with an explicit cast when it cannot be.
printer.print((Object) null); // unambiguous, and honest about the intent
Overriding has four rules, and @Override enforces all of them.
class Parent {
protected Number compute() throws IOException { return 1; }
}
class Child extends Parent {
@Override
public Integer compute() { // wider access: allowed
return 42; // covariant return: allowed
} // fewer exceptions: allowed
}
class Broken extends Parent {
@Override
private Number compute() { ... } // ERROR: narrower access
@Override
Number compute() throws Exception { } // ERROR: broader checked exception
}
Always write @Override. It is not decoration: it turns a silent failure
into a compile error.
class Order {
// Intended to override Object.equals, but the parameter type is wrong,
// so this OVERLOADS instead. Collections will never call it.
public boolean equals(Order other) { ... } // silent bug
@Override
public boolean equals(Order other) { ... } // now a compile error
}
Static methods are hidden, not overridden, which is a favourite follow-up.
class Parent { static String name() { return "Parent"; } }
class Child extends Parent { static String name() { return "Child"; } }
Parent p = new Child();
System.out.println(p.name()); // "Parent": resolved by the DECLARED type
Static methods belong to the class, so there is no dynamic dispatch. Private and final methods likewise cannot be overridden, which is exactly the mechanism behind 17.2's proxy limitations.
Key Takeaways
- Overloading differs in parameters and resolves at compile time.
- Overriding replaces an inherited implementation and resolves at runtime.
- The declared type determines which methods exist; the runtime type determines which runs.
- An override may widen access, return a covariant type, and throw fewer checked exceptions.
- Always write
@Override, which turns a mistyped signature into a compile error.- Overload resolution with
nullis ambiguous; cast explicitly or avoid it.- Static methods are hidden by the declared type, not overridden.
- Private and final methods cannot be overridden, which is why proxies cannot advise them.
🧪 Practice
- Write a class with three overloads and predict which one a
nullargument selects, then check.- Create an
equalsmethod taking the concrete type and show that aHashSetignores it.- Demonstrate that a static method is resolved by the declared type, not the object.
- Attempt an override that narrows access and read the compiler error.
- Interview: Why does
@Overridematter if the code compiles without it? (Hint: what happens when you misspell the method name or get a parameter type wrong?)
The equals and hashCode Contract
This question is asked constantly because it has a right answer, it is easy to get wrong, and getting it wrong produces bugs that look like the collections library is broken.
The contract is three rules for equals and one linking it to hashCode.
equals must be:
reflexive x.equals(x) is true
symmetric x.equals(y) == y.equals(x)
transitive x.equals(y) and y.equals(z) implies x.equals(z)
consistent repeated calls return the same result if nothing changed
null-safe x.equals(null) is false
hashCode must be:
consistent with equals equal objects MUST have equal hash codes
unequal objects MAY share a hash code
Why the link matters is the part worth explaining rather than reciting.
Hash-based collections find a bucket from the hash code and only then compare
with equals. If two equal objects hash differently, they land in different
buckets and the comparison never happens.
Overriding equals but not hashCode
----------------------------------
a = new Point(1,2) hashCode 12345 -> bucket 5
b = new Point(1,2) hashCode 98765 -> bucket 9 a.equals(b) is true
set.add(a); set.contains(b) -> looks in bucket 9, finds nothing -> FALSE
// The broken version: equal objects, but a HashSet holds both.
Set<Point> points = new HashSet<>();
points.add(new Point(1, 2));
points.add(new Point(1, 2));
System.out.println(points.size()); // 2, with equals overridden but not hashCode
The correct implementation is mechanical.
public final class Point {
private final int x;
private final int y;
@Override
public boolean equals(Object o) {
if (this == o) return true; // cheap identity shortcut
if (!(o instanceof Point other)) return false; // also handles null
return x == other.x && y == other.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y); // must use the SAME fields as equals
}
}
Records do this for you, which is the modern answer and worth giving.
// equals, hashCode, and toString are generated from the components.
public record Point(int x, int y) { }
The mutability trap is the strongest follow-up to prepare for. Mutating a
field used in hashCode after inserting into a hash collection makes the object
unfindable, including by itself.
Set<MutablePoint> set = new HashSet<>();
MutablePoint p = new MutablePoint(1, 2);
set.add(p); // stored in the bucket for hash(1,2)
p.setX(99); // the hash changes; the bucket does not
set.contains(p); // false: looks in the bucket for hash(99,2)
set.remove(p); // fails; the element is now unreachable
The rule that follows: hash-based keys should be immutable, or at least the
fields used in hashCode should be.
instanceof versus getClass() is the subtle design question.
| Approach | Behaviour |
|---|---|
getClass() | A subclass is never equal to its parent; symmetry is guaranteed |
instanceof | A subclass may equal its parent, which can break symmetry |
// The symmetry break with instanceof and an extended subclass:
Point p = new Point(1, 2);
ColourPoint c = new ColourPoint(1, 2, RED);
p.equals(c); // true: Point's equals only compares x and y
c.equals(p); // false: ColourPoint's equals also compares colour
There is no clean fix, which is why the practical advice is to make value classes final, or use composition rather than extending them.
A note on hashCode quality. Returning a constant is technically legal and
correct, and turns every hash map into a linked list, degrading lookups from
constant to linear time.
Key Takeaways
equalsmust be reflexive, symmetric, transitive, consistent, and null-safe.- Equal objects must have equal hash codes; unequal ones may collide.
- Hash collections locate a bucket by hash first, so a wrong hash makes equal objects unfindable.
- Override both or neither, using exactly the same fields in each.
- Records generate correct implementations from their components.
- Mutating a field used in
hashCodeafter insertion makes the element unreachable.- Keys in hash-based collections should be immutable.
instanceofrisks breaking symmetry with subclasses; making value classes final avoids it.- A constant
hashCodeis correct and degrades lookups to linear time.
🧪 Practice
- Write a class overriding only
equals, add two equal instances to aHashSet, and explain the size.- Add a correct
hashCodeand confirm the behaviour changes.- Insert a mutable object into a
HashSet, change a hashed field, and try to remove it.- Implement
equalswithinstanceofin a parent and a subclass, and demonstrate the symmetry violation.- Replace a hand-written value class with a record and compare the generated behaviour.
- Interview: What happens if
hashCodealways returns 1? (Hint: it is correct; what does it do to the performance of a map with a million entries?)
String Immutability and the String Pool
Strings are the most-used type in Java, and the questions about them test
whether you understand object identity, memory, and why == fails in a way
that surprises beginners.
Strings are immutable, meaning every apparent modification creates a new object.
String s = "hello";
s.toUpperCase(); // creates "HELLO" and DISCARDS it
System.out.println(s); // "hello": s never changed
s = s.toUpperCase(); // reassigning the reference is the only way
Why immutability was chosen is the interesting part, and there are four reasons worth naming.
| Reason | Explanation |
|---|---|
| Caching the hash | hashCode is computed once and stored, making strings fast map keys |
| Thread safety | No synchronisation needed; an immutable object cannot race |
| Security | A file path or a connection string cannot be altered after a check |
| Pooling | Identical literals can safely share one object |
The security point is concrete: if strings were mutable, code could pass a validated path to a method and another thread could change it between the check and the use.
The string pool is a region where the JVM keeps one copy of each distinct literal.
String a = "hello"; ---+
String b = "hello"; ---+--> the SAME object in the pool
String c = new String("hello"); --> a NEW object on the heap
pool heap
+---------+ +---------+
| "hello" | <--a,b | "hello" | <--c
+---------+ +---------+
a == b true (same pooled object)
a == c false (different objects)
a.equals(c) true (same contents)
String a = "hello";
String b = "hello";
String c = new String("hello");
String d = c.intern(); // returns the pooled instance
System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(a == d); // true
System.out.println(a.equals(c)); // true
This is why == must never be used to compare strings, and it is the
practical point the question is really about. It sometimes works, for pooled
literals, which makes the bug intermittent and therefore worse.
Compile-time constants are folded, which produces another surprising comparison.
String a = "hello";
String b = "hel" + "lo"; // folded by the compiler into "hello"
System.out.println(a == b); // true
String part = "hel";
String c = part + "lo"; // computed at runtime: a new object
System.out.println(a == c); // false
final String constPart = "hel"; // a compile-time constant
String d = constPart + "lo"; // folded again
System.out.println(a == d); // true
Concatenation in a loop is the performance question that follows.
// BAD: each += creates a new String and copies everything. O(n^2).
String result = "";
for (String word : words) {
result += word; // 10,000 words: ~50,000,000 character copies
}
// GOOD: one resizable buffer, appended in place. O(n).
StringBuilder builder = new StringBuilder();
for (String word : words) {
builder.append(word);
}
String result = builder.toString();
The compiler optimises a single concatenation expression into a StringBuilder,
but it cannot do so across loop iterations, which is why the loop is the case
that matters.
| Class | Mutable | Thread-safe | Use when |
|---|---|---|---|
String | No | Yes | Values, keys, anything shared |
StringBuilder | Yes | No | Building a string in one thread |
StringBuffer | Yes | Yes | Legacy; almost never needed |
A security note worth mentioning if passwords come up: because strings are
immutable and pooled, a password in a String stays in memory until garbage
collection and can appear in a heap dump. char[] can be overwritten
explicitly, which is why Console.readPassword returns one.
Key Takeaways
- Strings are immutable; every apparent change creates a new object.
- Immutability enables hash caching, thread safety, security, and pooling.
- The pool holds one instance per distinct literal, so literals share identity.
new String(...)always creates a distinct object;intern()returns the pooled one.- Never compare strings with
==, because it works often enough to hide the bug.- The compiler folds constant concatenations, which affects identity comparisons.
- Concatenating in a loop is quadratic; use
StringBuilder.- Passwords belong in
char[], which can be cleared, not inString.
🧪 Practice
- Predict the result of five
==comparisons among literals,new String, andintern, then verify each.- Time string concatenation against
StringBuilderfor 50,000 appends.- Show that a compile-time constant concatenation is pooled and a runtime one is not.
- Explain why a
Stringused as aHashMapkey is efficient, referring to hash caching.- Interview: Why does comparing strings with
==sometimes work? (Hint: what does the compiler do with two identical literals in the same class?)
Checked versus Unchecked Exception Scenarios
Java is nearly alone in having checked exceptions, and interviewers use the topic to see whether you have an opinion informed by experience rather than a memorised rule.
Throwable
|
+-- Error unchecked JVM-level, do not catch
| OutOfMemoryError, StackOverflowError
|
+-- Exception CHECKED must be declared or handled
| IOException, SQLException
|
+-- RuntimeException unchecked need not be declared
NullPointerException, IllegalArgumentException,
IllegalStateException
The intended distinction: checked exceptions represent conditions a reasonable caller might recover from, and unchecked ones represent programming errors.
| Situation | Kind | Why |
|---|---|---|
| A file does not exist | Checked | The caller can prompt or retry |
| A network connection drops | Checked | The caller can retry |
| A method argument is null | Unchecked | A bug in the calling code |
| An index is out of bounds | Unchecked | A bug |
| An object is in the wrong state | Unchecked | A bug |
| The heap is exhausted | Error | Nothing useful can be done |
The criticism is real and you should be able to articulate it. Checked exceptions produce two common failures.
// 1. The empty catch: the exception is silenced and the program continues
// in an unknown state. This is the worst thing in the language.
try {
process(file);
} catch (IOException e) {
// ignore
}
// 2. Signature pollution: the exception propagates up through five layers,
// each of which must declare it, coupling them all to an implementation
// detail of the bottom one.
public void handleRequest() throws IOException, SQLException, ParseException { }
This is why Spring wraps SQLException into unchecked DataAccessException, as
17.3 described, and why most modern Java frameworks and libraries prefer
unchecked exceptions.
The defence is also real: a checked exception is a documented part of the contract that the compiler will not let you ignore silently. The balanced answer is that checked exceptions are appropriate at the boundary of a library where recovery is genuinely expected, and a burden everywhere else.
Wrapping, done correctly, preserves the cause.
// GOOD: translate to a domain exception, keeping the original for diagnosis.
public Config load(Path path) {
try {
return parse(Files.readString(path));
} catch (IOException e) {
throw new ConfigLoadException("cannot read " + path, e); // cause preserved
}
}
// BAD: the stack trace of the real failure is lost forever.
} catch (IOException e) {
throw new ConfigLoadException("cannot read config"); // no cause
}
Three rules that come up as follow-ups:
finally runs even when the try returns, and a return inside finally
discards the exception entirely.
try {
return compute(); // the value is evaluated, then finally runs
} finally {
cleanup(); // runs before the method actually returns
}
// NEVER do this: it swallows any exception from the try block.
try {
throw new IllegalStateException();
} finally {
return 42; // the exception vanishes silently
}
try-with-resources replaces finally for anything closeable, and handles
suppressed exceptions correctly, as 16.1 covered in detail.
Catch the specific type, and order from specific to general.
try {
...
} catch (FileNotFoundException e) { // most specific first
...
} catch (IOException e) { // broader
...
}
// Reversing these does not compile: the second would be unreachable.
Never catch Throwable or Error. Catching OutOfMemoryError and
continuing leaves a JVM that cannot be trusted; 18.5's ExitOnOutOfMemoryError
is the correct response.
Key Takeaways
- Checked exceptions are declared and enforced; runtime exceptions and errors are not.
- The intent is recoverable conditions versus programming bugs.
- Checked exceptions cause empty catch blocks and signature pollution in practice.
- Modern frameworks translate them to unchecked exceptions at a boundary.
- Always preserve the cause when wrapping, or the diagnosis is lost.
finallyruns even on return, and returning from it discards exceptions.- Use
try-with-resources for anything closeable.- Catch specific types first; never catch
ThrowableorError.
🧪 Practice
- Write a method that wraps an
IOExceptionin a domain exception with and without the cause, and compare the stack traces.- Demonstrate that a
returninsidefinallyswallows an exception.- Take a method declaring three checked exceptions and redesign its signature.
- List five exceptions from the standard library and classify each as checked or unchecked, explaining why.
- Interview: Argue both for and against checked exceptions in thirty seconds each. (Hint: one side is about the compiler enforcing a contract, the other about what developers actually write in the catch block.)
Static, final, and Initialization Order
These keywords appear in almost every class, and the questions about them are usually puzzles: given this code, what prints, and in what order? Answering requires a model of when the JVM does what.
static means the member belongs to the class, not to an instance. There is
one copy, shared by every instance and accessible without one.
public class Counter {
private static int totalCreated; // one, shared
private final int id; // one per instance
public Counter() { this.id = ++totalCreated; }
}
final means the binding cannot be reassigned, which is different from the
object being immutable. This distinction is the most common misunderstanding.
final List<String> names = new ArrayList<>();
names.add("Ada"); // fine: the LIST is mutable
names = new ArrayList<>(); // compile error: the REFERENCE is final
| Applied to | Meaning |
|---|---|
| Variable | Can be assigned once |
| Field | Must be assigned in the declaration or constructor |
| Method | Cannot be overridden (and so cannot be proxied, 17.2) |
| Class | Cannot be extended |
| Parameter | Cannot be reassigned inside the method |
Initialization order is the mechanism the puzzle questions test. It runs in two phases, and the static phase happens once, when the class is first used.
CLASS loading (once, on first use)
1. static fields set to defaults (0, false, null)
2. static field initialisers and static blocks, IN SOURCE ORDER
INSTANCE creation (every new)
3. instance fields set to defaults
4. the constructor implicitly calls super(...)
-> the PARENT's steps 3 to 6 complete first
5. instance field initialisers and instance blocks, IN SOURCE ORDER
6. the constructor body
public class Parent {
static { System.out.println("1 parent static"); }
{ System.out.println("3 parent instance"); }
Parent() { System.out.println("4 parent constructor"); }
}
public class Child extends Parent {
static { System.out.println("2 child static"); }
{ System.out.println("5 child instance"); }
Child() {
// super() is implicit here and runs FIRST
System.out.println("6 child constructor");
}
public static void main(String[] args) {
new Child();
}
}
// 1 parent static
// 2 child static
// 3 parent instance
// 4 parent constructor
// 5 child instance
// 6 child constructor
The dangerous consequence is that a parent constructor runs before the child's fields are initialised, so calling an overridable method from a constructor sees uninitialised state.
class Parent {
Parent() { init(); } // calls the OVERRIDDEN method
void init() { }
}
class Child extends Parent {
private final List<String> items = new ArrayList<>();
@Override
void init() {
items.add("first"); // NullPointerException:
} // items is still null here
}
The rule: never call an overridable method from a constructor.
static final constants are special. A static final primitive or String
initialised with a compile-time constant is inlined at the call site, which
means a dependent class must be recompiled when it changes.
public class Config {
public static final int VERSION = 1; // inlined into callers
public static final int TIMEOUT = readTimeout(); // NOT inlined: runtime value
}
Static initialisers can fail, producing an error that confuses people the first time they meet it.
static {
RESOURCE = loadOrThrow(); // if this throws, the class fails to initialise
}
// The first access throws ExceptionInInitializerError; every LATER access
// throws NoClassDefFoundError, which hides the original cause.
Static mutable state is a design problem, worth saying explicitly since it connects to 17.1's scope discussion: it is shared across threads without synchronisation, it makes tests order-dependent, and it cannot be substituted.
Key Takeaways
staticmembers belong to the class, with one copy shared by all instances.finalprevents reassignment of the reference, not mutation of the object.- Static initialisation runs once, on first use, in source order.
- Instance initialisation runs the parent's chain fully before the child's fields.
- Never call an overridable method from a constructor; subclass fields are not yet set.
static finalcompile-time constants are inlined into callers.- A failed static initialiser throws
ExceptionInInitializerErroronce, thenNoClassDefFoundError.- Static mutable state breaks thread safety, testability, and substitution.
🧪 Practice
- Write the parent and child example above and predict the output before running it.
- Show that a
finalcollection field can still be modified.- Reproduce the constructor calling an overridden method and explain the null.
- Make a static initialiser throw and observe both error types across two accesses.
- Interview: Why is calling an overridable method from a constructor dangerous? (Hint: at that moment, have the subclass's field initialisers run?)
<a id="193-collections-generics-and-streams-questions"></a>
19.3 Collections, Generics, and Streams Questions
This subchapter covers the questions drawn from chapters 6 to 8, where interviewers probe whether you can reason about performance characteristics and type safety rather than just call the methods.
Choosing the Right Collection
"Which collection would you use here?" is a design question wearing a trivia
costume. The interviewer wants to hear you reason from the access pattern to
the data structure, not recite that ArrayList is backed by an array.
Start from the questions the data must answer.
Do I need key-value lookup? -> a Map
Do duplicates matter? -> Set removes them, List keeps them
Does order matter, and which order? -> insertion, sorted, or none
What operation dominates? -> random access, insertion, search
Is it shared between threads? -> a concurrent implementation
| Need | Choice | Why |
|---|---|---|
| Indexed access, mostly reads | ArrayList | Contiguous array, O(1) get |
| Frequent insertion or removal at the ends | ArrayDeque | O(1) at both ends; beats LinkedList |
| Unique elements, no order | HashSet | O(1) contains |
| Unique elements, insertion order | LinkedHashSet | O(1) plus a linked list |
| Unique elements, sorted | TreeSet | O(log n), navigable |
| Key-value, no order | HashMap | O(1) average |
| Key-value, insertion or access order | LinkedHashMap | Also the basis of an LRU cache |
| Key-value, sorted by key | TreeMap | Range queries, floorKey |
| Enum keys | EnumMap | Array-backed, very fast |
| Shared across threads | ConcurrentHashMap | Lock-striped, no global lock |
| Priority ordering | PriorityQueue | O(log n) insert and poll |
The complexity table is worth knowing cold, because it is asked directly.
| Operation | ArrayList | LinkedList | HashMap | TreeMap |
|---|---|---|---|---|
| Get by index | O(1) | O(n) | n/a | n/a |
| Get by key | n/a | n/a | O(1) avg | O(log n) |
| Add at end | O(1) amortised | O(1) | O(1) avg | O(log n) |
| Add at front | O(n) | O(1) | n/a | n/a |
| Remove by value | O(n) | O(n) | O(1) avg | O(log n) |
| Contains | O(n) | O(n) | O(1) avg | O(log n) |
LinkedList is almost always the wrong answer, and saying so shows
experience. Its O(1) insertion requires already holding the node, and reaching
that node is O(n). Every element is a separate object with two pointers, so it
has poor cache locality and much higher memory overhead. ArrayDeque beats it
for queue and stack use; ArrayList beats it for nearly everything else.
Sizing matters more than people expect.
// Each growth copies the entire backing array. Presizing avoids all of it.
List<Order> orders = new ArrayList<>(expectedCount);
// A HashMap resizes when it passes 75% load. Presize to avoid rehashing.
Map<String, Order> index = new HashMap<>(expectedCount * 4 / 3 + 1);
A worked reasoning example, which is the shape of answer to give:
"I need to keep the 100 most recent events per user and look one up by id."
-> lookup by id a Map
-> bounded, evict oldest access-ordered LinkedHashMap with removeEldestEntry
-> shared across threads? if yes, wrap or use a purpose-built cache
// An LRU cache in six lines, which is a common follow-up exercise.
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LruCache(int capacity) {
super(16, 0.75f, true); // true = ACCESS order, not insertion
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // evict the least recently accessed
}
}
Immutable collections should be the default for anything you return.
List<String> fixed = List.of("a", "b"); // immutable, rejects nulls
List<String> copy = List.copyOf(mutableList); // a snapshot, not a view
// Collections.unmodifiableList returns a VIEW: the backing list can still change.
List<String> view = Collections.unmodifiableList(mutableList);
Returning an internal mutable collection lets callers modify your object's state, which defeats the encapsulation of 19.2.
Key Takeaways
- Reason from the access pattern to the structure, not from memorised descriptions.
ArrayListfor indexed access,ArrayDequefor ends,HashMapfor lookup.LinkedListis rarely correct: reaching a node is O(n) and locality is poor.LinkedHashMappreserves order and, in access order, implements an LRU cache.TreeMapandTreeSetadd ordering and range queries at O(log n).- Presize collections when the count is known to avoid repeated copying.
- Return immutable collections;
List.copyOfsnapshots whileunmodifiableListis a view.
🧪 Practice
- Choose a collection for five described scenarios and justify each in one sentence.
- Benchmark
ArrayListagainstLinkedListfor 100,000 random-index reads.- Implement an LRU cache with
LinkedHashMapand test the eviction order.- Show that
Collections.unmodifiableListis a view by mutating the backing list.- Interview: When would you genuinely choose
LinkedList? (Hint: think about whether you already hold the node, and whetherArrayDequecovers the case anyway.)
HashMap Internals and Collision Handling
This is the deepest question routinely asked about collections, and it is
popular because it connects hashCode, data structures, and performance in one
answer.
The structure is an array of buckets. A key's hash selects a bucket; the bucket holds the entries whose keys landed there.
map.put("Ada", 1)
|
+-- h = key.hashCode()
+-- h = h ^ (h >>> 16) spread the high bits into the low ones
+-- index = h & (capacity - 1) cheap modulo, since capacity is a power of 2
|
v
table
[0] -> null
[1] -> ("Ada",1)
[2] -> null
[3] -> ("Bob",2) -> ("Eve",5) a collision: two keys, one bucket
...
The hash spreading step matters. Because the index uses only the low bits, two hash codes differing only in their high bits would collide constantly. Exclusive-oring the high sixteen bits down mixes them in, cheaply.
Collision handling has two modes, and knowing the threshold is the detail interviewers look for.
| Entries in one bucket | Structure | Lookup within the bucket |
|---|---|---|
| Up to 8 | Linked list | O(n) |
| 9 or more, table >= 64 | Red-black tree | O(log n) |
| Back down to 6 | Reverts to a list | O(n) |
Before treeification After treeification
-------------------- -------------------
[3] -> A -> B -> C -> ... [3] -> D
(9 entries, O(n)) / \
B F
/ \ / \
A C E G O(log n)
Treeification was added in Java 8 specifically to bound the damage from hash collision denial-of-service attacks, where an attacker sends keys chosen to collide.
Resizing happens when the size exceeds capacity times the load factor, default 0.75.
capacity 16, load factor 0.75 -> resize at 12 entries
new capacity 32, every entry rehashed into the larger table
Cost: O(n) at that moment. Presizing avoids repeated resizes.
The 0.75 default is a deliberate balance: lower wastes memory, higher increases collisions.
Why keys must be immutable connects straight back to 19.2's equals contract.
Map<Point, String> map = new HashMap<>();
Point key = new Point(1, 2);
map.put(key, "value"); // stored in bucket for hash(1,2)
key.setX(99); // the hash changes; the entry does not move
map.get(key); // null: searches the bucket for hash(99,2)
HashMap is not thread-safe, and the failure mode is worth naming: before
Java 8 concurrent resizing could create a cycle in a bucket's linked list,
causing an infinite loop at 100% CPU. Java 8 changed the resize to preserve
order and removed that specific bug, but concurrent use still loses updates and
corrupts state.
| Implementation | Thread safety | Cost |
|---|---|---|
HashMap | None | Fastest single-threaded |
Hashtable | Every method synchronised | Legacy; one global lock |
Collections.synchronizedMap | Wrapper with one lock | Serialises all access |
ConcurrentHashMap | Per-bin locking, lock-free reads | The correct choice |
ConcurrentHashMap differences worth mentioning: it rejects null keys and
values, because a null return would be ambiguous between "absent" and "mapped to
null" in a concurrent setting, and its size() is an estimate rather than an
exact snapshot.
HashMap allows one null key, stored in bucket 0 since null has no
hashCode. TreeMap rejects null keys because it must compare them.
Key Takeaways
- A
HashMapis an array of buckets indexed by a spread hash code.- The spreading step mixes high bits down, since only low bits index the table.
- Collisions form a linked list, converting to a red-black tree at eight entries.
- Treeification bounds the damage from deliberately colliding keys.
- Resizing at 75% load rehashes every entry, so presize when the count is known.
- Mutating a key after insertion makes the entry unreachable.
HashMapis unsafe under concurrency;ConcurrentHashMapis the answer.ConcurrentHashMapforbids nulls and reports an approximate size.
🧪 Practice
- Write a class whose
hashCodealways returns 1, insert 10,000 entries, and time lookups against a normal implementation.- Presize a map for a million entries and compare insertion time with the default.
- Insert a mutable key, modify it, and demonstrate the entry cannot be retrieved.
- Run concurrent puts against a
HashMapfrom ten threads and observe lost updates.- Interview: Why does
ConcurrentHashMapforbid null values whenHashMapallows them? (Hint: what would a null fromgetmean when another thread may have just removed the key?)
Fail-Fast versus Fail-Safe Iterators
This question tests whether you understand that
ConcurrentModificationException is a diagnostic aid rather than a
guarantee, and that "fail-safe" is a misleading name.
Fail-fast iterators detect structural modification and throw. Most
java.util collections behave this way.
List<String> names = new ArrayList<>(List.of("Ada", "Bob", "Eve"));
for (String name : names) {
if (name.equals("Bob")) {
names.remove(name); // ConcurrentModificationException
}
}
The mechanism is a modification counter compared on each step.
ArrayList holds modCount, incremented on every structural change.
The iterator records expectedModCount when created.
each next() and remove():
if (modCount != expectedModCount) throw new ConcurrentModificationException();
"Structural" means changing the size. Replacing an element with set does not
trigger it.
The name is a warning, not a description. The exception can be thrown by a single thread, as above, and it is explicitly best effort: it may not fire when it should. Never write logic that depends on catching it.
// The classic false negative: removing the second-to-last element.
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
if (s.equals("b")) list.remove(s); // NO exception: the loop just ends early
}
System.out.println(list); // [a, c] -- "c" was never visited
The hasNext() check is cursor != size, and after the removal both equal 2,
so the loop terminates before the check that would have thrown.
The correct ways to remove while iterating:
// 1. The iterator's own remove, which updates expectedModCount.
Iterator<String> it = names.iterator();
while (it.hasNext()) {
if (it.next().startsWith("B")) {
it.remove(); // safe
}
}
// 2. removeIf, which is clearer and usually faster.
names.removeIf(name -> name.startsWith("B"));
// 3. Collect into a new collection, leaving the original untouched.
List<String> kept = names.stream()
.filter(name -> !name.startsWith("B"))
.toList();
Fail-safe iterators work on a snapshot or tolerate concurrent change, so they never throw. The trade-off is that they may not reflect recent modifications.
| Collection | Iterator behaviour |
|---|---|
ArrayList, HashMap | Fail-fast; throws on structural modification |
CopyOnWriteArrayList | Iterates an immutable snapshot taken at creation |
ConcurrentHashMap | Weakly consistent; reflects some changes, never throws |
ConcurrentLinkedQueue | Weakly consistent |
// CopyOnWriteArrayList: the iterator sees the list as it was.
var list = new CopyOnWriteArrayList<>(List.of("a", "b"));
for (String s : list) {
list.add("c"); // no exception, and "c" is never iterated
} // each add copies the whole array: writes are O(n)
CopyOnWriteArrayList suits collections that are read constantly and written
rarely, such as a listener registry. It is a poor choice for anything
write-heavy.
"Weakly consistent" is the precise term for ConcurrentHashMap: the
iterator will not throw, will traverse elements present when it started, and may
or may not reflect changes made after.
Key Takeaways
- Fail-fast iterators throw
ConcurrentModificationExceptionon structural change.- The mechanism is a modification counter checked on each step.
- It is best effort and can miss violations, so never rely on catching it.
- Removing the second-to-last element skips the last one silently.
- Remove safely with
Iterator.remove,removeIf, or by collecting a new list.- Fail-safe iterators use a snapshot or weak consistency and never throw.
CopyOnWriteArrayListcopies on every write; use it for read-heavy collections.ConcurrentHashMapiterators are weakly consistent, not snapshots.
🧪 Practice
- Reproduce
ConcurrentModificationExceptionwith a for-each loop and fix it three ways.- Demonstrate the silent false negative by removing the second-to-last element.
- Iterate a
CopyOnWriteArrayListwhile adding to it and explain what the iterator sees.- Compare write throughput of
CopyOnWriteArrayListagainst a synchronised list.- Interview: Is
ConcurrentModificationExceptiona concurrency error? (Hint: how many threads were involved in the example above?)
Type Erasure and Wildcard Questions
Generics are compile-time only. The compiler checks types and then erases them, replacing type parameters with their bounds. Every generics question follows from this one fact.
// What you write
public class Box<T> { private T value; }
List<String> names = new ArrayList<>();
// What the bytecode contains
public class Box { private Object value; }
List names = new ArrayList();
// with casts inserted by the compiler at every read
The consequences are the material for the questions.
// 1. Two generic types are the same class at runtime.
new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass(); // true
// 2. instanceof with a type argument does not compile.
if (list instanceof List<String>) { } // error: illegal generic type
// 3. You cannot create an array of a generic type.
T[] array = new T[10]; // error
// 4. Overloads differing only by type argument clash.
void process(List<String> s) { }
void process(List<Integer> i) { } // error: same erasure
// 5. Static members cannot use the class's type parameter.
class Box<T> { static T instance; } // error
Why erasure was chosen is a good thing to be able to say: backward compatibility. Generics arrived in Java 5, and erasure allowed generic and pre-generic code to interoperate on the same collections.
Wildcards exist because generics are invariant: List<String> is not a
List<Object>, even though String is an Object.
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // error, and for good reason:
// objects.add(42); // would put an Integer into a List<String>
PECS is the rule to quote: Producer Extends, Consumer Super.
? extends T a PRODUCER: you READ T out of it, cannot add
? super T a CONSUMER: you WRITE T into it, reads come back as Object
// The signature from Collections, which uses both in one method.
public static <T> void copy(List<? super T> dest, // consumer: written to
List<? extends T> src) { // producer: read from
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}
// extends: safe to read as Number, unsafe to add anything.
List<? extends Number> producer = List.of(1, 2, 3);
Number n = producer.get(0); // fine
// producer.add(4); // error: the actual list might be List<Long>
// super: safe to add Integer, reads come back as Object.
List<? super Integer> consumer = new ArrayList<Number>();
consumer.add(42); // fine
Object o = consumer.get(0); // only Object is guaranteed
The reasoning to give aloud: with ? extends Number, the list could be
List<Long>, so adding an Integer would corrupt it. With ? super Integer,
the list holds at least Integer, so adding one is safe, but the element type
could be Object, so that is all a read can promise.
Unbounded wildcards versus raw types is a frequent follow-up.
List<?> unknown = strings; // type-safe: cannot add anything but null
List raw = strings; // unsafe: unchecked warnings, no checking at all
Raw types exist only for compatibility with pre-Java 5 code and should never appear in new code.
Bridge methods explain a subtle behaviour if it comes up.
class StringBox implements Comparable<String> {
public int compareTo(String other) { return 0; }
}
// Erasure makes the interface method compareTo(Object). The compiler generates
// a synthetic bridge method compareTo(Object) that casts and delegates, so
// polymorphism still works.
Heap pollution and varargs is the last common question.
@SafeVarargs // asserts the method does not store anything unsafe in the array
public static <T> List<T> listOf(T... items) {
return Arrays.asList(items);
}
The warning exists because a generic varargs parameter creates an array of a non-reifiable type, which erasure cannot fully check.
Key Takeaways
- Generics are checked at compile time and erased, leaving
Objectand casts.- Erasure was chosen for backward compatibility with pre-generic code.
- You cannot use
instanceofwith a type argument, create generic arrays, or overload on erasure.- Generics are invariant:
List<String>is not aList<Object>.- PECS: use
? extends Tto read from a producer,? super Tto write to a consumer.? extendsforbids adding;? superreturns onlyObjecton reads.List<?>is type-safe; a rawListdisables checking entirely.@SafeVarargsasserts a generic varargs method does not pollute the heap.
🧪 Practice
- Show that two differently parameterised lists have the same runtime class.
- Write a method summing a
List<? extends Number>and explain why you cannot add to it.- Implement
copy(dest, src)with correct PECS bounds and test it with several type combinations.- Attempt each of the five erasure consequences above and record the exact compiler errors.
- Interview: Why can you not write
new T[10]inside a generic class? (Hint: what does the JVM need to know at runtime to create an array, and what has erasure removed?)
Stream, Lambda, and Optional Scenarios
Streams questions test whether you understand laziness, statelessness, and the distinction between a pipeline and a loop, rather than whether you can chain methods.
A stream is not a collection. It holds no data, it can be consumed once, and nothing happens until a terminal operation runs.
Stream<String> stream = names.stream().filter(n -> n.startsWith("A"));
// Nothing has executed yet: filter is LAZY.
List<String> result = stream.toList(); // the terminal operation runs it
stream.count(); // IllegalStateException: already consumed
Laziness enables short-circuiting, which is the practical payoff.
// Only the elements needed are processed, not the whole list.
Optional<Order> first = orders.stream()
.filter(o -> o.total().compareTo(THRESHOLD) > 0) // evaluated lazily
.findFirst(); // stops at the first match
Processing order for [1, 2, 3, 4] with filter then map then findFirst
-------------------------------------------------------------------
element 1: filter -> false
element 2: filter -> true, map -> result, findFirst -> DONE
elements 3 and 4 are never touched
Intermediate versus terminal is the categorisation to know.
| Intermediate (lazy, returns a stream) | Terminal (eager, consumes the stream) |
|---|---|
filter, map, flatMap, peek | collect, toList, forEach |
distinct, sorted, limit, skip | reduce, count, min, max |
mapToInt, boxed | anyMatch, allMatch, findFirst |
Lambdas must not mutate external state, and this is the rule most often broken.
// WRONG: a side effect, and broken outright if the stream is parallel.
List<String> results = new ArrayList<>();
names.stream().filter(...).forEach(results::add);
// RIGHT: let the pipeline produce the result.
List<String> results = names.stream().filter(...).toList();
Effectively final is the compile-time rule: a lambda may capture a local variable only if it is never reassigned.
int count = 0;
names.forEach(n -> count++); // error: count is not effectively final
Collectors questions usually centre on grouping.
// Group orders by status.
Map<OrderStatus, List<Order>> byStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status));
// Group and count in one pass: a downstream collector.
Map<OrderStatus, Long> counts = orders.stream()
.collect(Collectors.groupingBy(Order::status, Collectors.counting()));
// Sum a field per group.
Map<Long, BigDecimal> totalPerCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customerId,
Collectors.reducing(BigDecimal.ZERO, Order::total, BigDecimal::add)));
// Partition: grouping into exactly two buckets, keyed by boolean.
Map<Boolean, List<Order>> split = orders.stream()
.collect(Collectors.partitioningBy(o -> o.total().compareTo(BIG) > 0));
Optional is for return values, and misusing it is a common review comment.
| Use | Appropriate |
|---|---|
| A method return that may be empty | Yes |
| A field | No: not serialisable, adds a wrapper |
| A method parameter | No: use an overload instead |
| A collection element | No: use an empty collection |
// WRONG: this is a longer way to write get() with a null check.
if (optional.isPresent()) {
return optional.get();
} else {
return fallback;
}
// RIGHT: the API is designed to avoid that shape.
return optional.orElse(fallback);
return optional.orElseGet(this::expensiveFallback); // lazy: only if empty
return optional.orElseThrow(() -> new NotFoundException(id));
return optional.map(Order::total).filter(t -> t.signum() > 0);
orElse evaluates its argument always; orElseGet only when empty. Using
orElse(expensiveCall()) is a real and common performance bug.
Parallel streams are the trap question. parallel() is easy to add and
usually unhelpful.
// Justified: a large dataset, CPU-bound work, no shared state, a splittable source.
long count = hugeArrayList.parallelStream().filter(this::expensiveTest).count();
// Unjustified and often slower: small data, or IO-bound work, or an ordered
// operation that forces merging.
list.parallelStream().forEach(this::writeToDatabase); // also exhausts the pool
The reasons to state: the common ForkJoinPool is shared by the whole JVM, so blocking IO inside a parallel stream starves everything else; splitting and merging has a fixed cost that small datasets never recover; and any shared mutable state becomes a race.
Key Takeaways
- A stream holds no data, is consumed once, and does nothing until a terminal operation.
- Laziness allows short-circuiting, so
findFirststops early.- Intermediate operations return streams; terminal operations produce a result.
- Lambdas must not mutate external state, and captured locals must be effectively final.
- Use collectors to produce results rather than
forEachwith a side effect.groupingBywith a downstream collector handles counting, summing, and mapping.Optionalbelongs in return types, not in fields, parameters, or collections.orElsealways evaluates its argument;orElseGetis lazy.- Parallel streams help only for large, CPU-bound, stateless work on a splittable source.
🧪 Practice
- Add a
peekthat prints to a filtered, mapped pipeline withfindFirstand observe how few elements are processed.- Group a list of orders by status and count each group in one pass.
- Replace an
isPresent/getblock with the appropriateOptionalmethod.- Demonstrate that
orElseevaluates its argument even when theOptionalis present.- Benchmark a parallel stream against a sequential one for 100 elements and for 10 million.
- Interview: Why is performing blocking IO inside a parallel stream dangerous? (Hint: which thread pool does it use, and who else is using it?)
<a id="194-memory-concurrency-and-jvm-questions"></a>
19.4 Memory, Concurrency, and JVM Questions
This subchapter covers chapters 11 and 12 as interview material: how memory is organised and reclaimed, how to diagnose a leak, and the concurrency questions that separate people who have debugged a race from people who have read about one.
Stack, Heap, and Garbage Collection
The first question is usually the simplest: what is the difference between the stack and the heap? The answer is about lifetime and ownership, not about speed.
Thread 1 stack Thread 2 stack Heap (shared)
-------------- -------------- -------------
+------------+ +------------+ +-------------------+
| frame: run | | frame: main| | Order object |
| order ----+--------+------------+----------> | lines ----------+ |
| i = 5 | | o --------+----------> | | |
+------------+ +------------+ | ArrayList <-----+ |
| frame: calc| +------------+ +-------------------+
| total |
+------------+ one stack PER THREAD one heap, ALL threads
| Aspect | Stack | Heap |
|---|---|---|
| Holds | Frames: locals, parameters, references | Objects and their fields |
| Scope | One per thread, private | Shared by all threads |
| Lifetime | Popped when the method returns | Until unreachable, then collected |
| Management | Automatic, by frame | Garbage collector |
| Size | Small, -Xss, typically 512KB to 1MB | Large, -Xmx |
| Exhausted | StackOverflowError | OutOfMemoryError |
| Thread safe | Inherently | Requires synchronisation |
The key insight: a local variable holding an object lives on the stack, but the object it references lives on the heap. Only the reference is popped when the method returns.
Garbage collection reclaims objects that are no longer reachable from a GC root, which is a precise definition worth giving.
GC roots: active thread stacks, static fields, JNI references
|
v
[root] --> A --> B --> C reachable: kept
|
[root] --> D reachable: kept
E <--> F a CYCLE, but unreachable from any root
-> COLLECTED
Reachability rather than reference counting is why Java has no problem with reference cycles, which is a good contrast to draw if the interviewer mentions other languages.
The generational hypothesis underpins the whole design: most objects die young.
Young generation Old generation
+-----------------------------------+ +-------------------+
| Eden | S0 | S1 | | Tenured |
+-----------------------------------+ +-------------------+
allocation survivor spaces promoted after
happens here copy between them surviving N collections
Minor GC frequent, fast, only the young generation
Major GC infrequent, slower, includes the old generation
Because most objects die in Eden, a minor collection only copies the few survivors, which is why allocation in Java is cheap.
Collectors differ in what they optimise for.
| Collector | Optimises for | Typical use |
|---|---|---|
| Serial | Small footprint | Tiny heaps, single core |
| Parallel | Throughput | Batch jobs where pauses are fine |
| G1 | Balanced, bounded pauses | The default since Java 9 |
| ZGC | Very low pause times | Large heaps, latency-sensitive |
| Shenandoah | Very low pause times | Similar goals to ZGC |
finalize() is dead and should be named as such. It was deprecated because
it is not guaranteed to run, delays collection, and can resurrect objects. The
replacements are try-with-resources for deterministic cleanup, as 16.1
covered, and Cleaner when a safety net is genuinely needed.
System.gc() is a suggestion, not a command, and calling it in application
code is almost always wrong: it can force a full collection and a long pause.
The three reference types occasionally come up.
| Type | Collected when | Used for |
|---|---|---|
| Strong | Never while reachable | Ordinary references |
| Soft | Before OutOfMemoryError | Memory-sensitive caches |
| Weak | At the next collection | WeakHashMap, listener registries |
| Phantom | After finalisation, for cleanup notification | Resource cleanup |
Key Takeaways
- The stack holds frames per thread; the heap holds objects shared by all threads.
- A local variable is on the stack; the object it points to is on the heap.
- Collection is based on reachability from GC roots, so cycles are collected.
- The generational hypothesis, that most objects die young, shapes the whole design.
- Minor collections handle the young generation and are fast; major ones are not.
- G1 is the default, balancing throughput against bounded pause times.
finalizeis deprecated; usetry-with-resources orCleaner.System.gc()is only a hint and usually harmful to call.
🧪 Practice
- Write a recursive method with no base case and identify which error it produces and why.
- Allocate objects in a loop and observe minor collections with GC logging enabled.
- Create an unreachable reference cycle and show that it is still collected.
- Run the same program under two collectors and compare pause distributions.
- Interview: Why does Java not leak memory on reference cycles when reference counting would? (Hint: what question does the collector actually ask about each object?)
Diagnosing Memory Leaks
Java has garbage collection, so "Java cannot leak memory" is a claim candidates sometimes make and it is wrong. A leak in Java is an object that is still reachable but will never be used again, which the collector cannot detect because reachability is all it knows.
The classic causes are worth memorising, because interviewers ask for examples.
// 1. A static collection that only ever grows.
public class Cache {
private static final Map<String, Object> CACHE = new HashMap<>();
public static void put(String k, Object v) { CACHE.put(k, v); }
// Nothing removes. The static field is a GC root, so everything is kept.
}
// 2. An unremoved listener: the registry holds the object forever.
button.addListener(this); // without a matching removeListener, 'this' lives on
// 3. ThreadLocal in a pooled thread: the thread never dies, so the value stays.
private static final ThreadLocal<Context> CONTEXT = new ThreadLocal<>();
public void handle(Request r) {
CONTEXT.set(new Context(r));
try {
process();
} finally {
CONTEXT.remove(); // MANDATORY with a thread pool, as 17.4 noted
}
}
// 4. An inner class holding an implicit reference to its enclosing instance.
public class Screen {
private final byte[] bigBuffer = new byte[10_000_000];
// A non-static inner class keeps Screen alive for the task's lifetime.
class Task implements Runnable {
public void run() { ... }
}
// Making Task static, or using a lambda that captures nothing, fixes it.
}
// 5. An unbounded key in a map whose keys are never removed, especially
// when the key's hashCode changed after insertion (19.2).
The diagnostic sequence is what a good answer walks through in order.
1. Confirm it IS a leak.
Watch the heap AFTER a full GC over hours. A flat post-GC floor is fine;
a steadily climbing floor is a leak.
2. Enable GC logging.
-Xlog:gc*:file=gc.log
Look for collections becoming more frequent and reclaiming less.
3. Take two heap dumps, minutes apart, under load.
jcmd <pid> GC.heap_dump /tmp/heap1.hprof
4. Compare them.
Which class grew? Eclipse MAT's dominator tree answers this directly.
5. Find the retention path.
"Path to GC Roots" shows WHAT is keeping the objects alive. That path
names the bug.
# Quick triage without a dump: what is on the heap right now, by class.
jcmd <pid> GC.class_histogram | head -20
# Capture a dump automatically when the heap is exhausted.
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps -jar app.jar
The dominator tree is the concept to name. It shows, for each object, how much memory would be freed if that object were released, which points at the one container holding everything rather than at the million small objects inside it.
Histogram says: Dominator tree says:
------------------- --------------------
byte[] 400 MB OrderCache retains 380 MB <-- the actual bug
String 120 MB +-- HashMap
HashMap$Node 80 MB +-- 200,000 CachedOrder
Not every OutOfMemoryError is a leak. Distinguishing the causes is a
strong answer.
| Message | Usual cause |
|---|---|
Java heap space | A real leak, or a heap too small for the load |
GC overhead limit exceeded | Collecting constantly, reclaiming almost nothing |
Metaspace | Class loader leak, common with repeated redeploys |
Unable to create native thread | Thread leak; an unbounded executor |
Direct buffer memory | Unreleased ByteBuffer.allocateDirect |
Prevention is mostly discipline: bound every cache, remove every listener
and ThreadLocal, close every resource with try-with-resources, and prefer a
purpose-built cache with eviction over a HashMap that grows.
Key Takeaways
- A Java leak is an object still reachable but never used again.
- Static collections, unremoved listeners,
ThreadLocalin pooled threads, and inner classes are the classic causes.- Always call
ThreadLocal.removein afinallywhen threads are pooled.- Confirm a leak by watching the post-collection floor, not the peak.
- Compare two heap dumps taken under load minutes apart.
- The dominator tree names the container responsible, which the histogram does not.
- Enable heap dump on out-of-memory in production.
- Metaspace, thread, and direct buffer exhaustion are distinct failures with distinct causes.
🧪 Practice
- Write a program that leaks through a static map, watch the post-GC floor climb, and capture a dump.
- Analyse that dump and find the retention path back to the static field.
- Reproduce a
ThreadLocalleak using a fixed thread pool, then fix it.- Trigger
Metaspaceexhaustion by generating classes in a loop.- Interview: If Java has garbage collection, how can it leak? (Hint: what exactly does the collector consider garbage, and is "unused" the same thing?)
Thread Safety and Synchronization
"Is this class thread-safe?" is the question, and a strong answer starts by defining the term: a class is thread-safe when it behaves correctly when used from multiple threads with no external synchronisation.
The problem is that operations that look atomic are not.
public class Counter {
private int count;
public void increment() { count++; } // THREE operations, not one
}
count++ compiles to:
read count thread A reads 5 thread B reads 5
add 1 A computes 6 B computes 6
write count A writes 6 B writes 6
Two increments, one result. This is a LOST UPDATE.
synchronized provides two guarantees, and naming both is what
distinguishes a good answer. Most candidates name only the first.
1. MUTUAL EXCLUSION only one thread holds the monitor at a time
2. VISIBILITY changes made before releasing the lock are visible
to the next thread that acquires it
Without the second guarantee, a thread could hold the lock correctly and still read a stale value from its CPU cache.
public class Counter {
private int count;
// Locks on 'this'. Equivalent to synchronized(this) { ... }
public synchronized void increment() { count++; }
public synchronized int get() { return count; } // the GETTER needs it too
}
The getter needing synchronisation surprises people: without it there is no happens-before relationship, so the reader may see a stale value.
Lock granularity matters. Synchronising a whole method serialises everything in it.
// A private lock object: better than locking on 'this', because external code
// cannot acquire your lock and deadlock you.
private final Object lock = new Object();
public void update(Order order) {
Order prepared = expensiveComputation(order); // OUTSIDE the lock
synchronized (lock) {
cache.put(order.id(), prepared); // only the shared state
}
}
synchronized versus ReentrantLock:
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Release | Automatic, on exit | Manual, must be in finally |
| Timed acquisition | No | tryLock(timeout) |
| Interruptible | No | lockInterruptibly() |
| Fairness option | No | Yes |
| Multiple conditions | One wait set | Several Condition objects |
| Simplicity | Higher | Lower |
private final ReentrantLock lock = new ReentrantLock();
public boolean tryUpdate(Order order) {
if (!lock.tryLock(2, TimeUnit.SECONDS)) { // give up rather than block forever
return false;
}
try {
cache.put(order.id(), order);
} finally {
lock.unlock(); // MUST be in finally
}
return true;
}
Use synchronized by default and ReentrantLock when you need timeouts,
interruptibility, or multiple conditions.
The best strategy is to avoid shared mutable state entirely.
| Strategy | How |
|---|---|
| Immutability | Final fields, no setters; inherently safe |
| Confinement | Keep state in a local or a ThreadLocal |
| Concurrent collections | ConcurrentHashMap instead of a synchronised one |
| Atomics | AtomicInteger for counters, lock-free |
| Higher-level | ExecutorService, CompletableFuture, queues |
This is why 17.1 insisted that singleton beans be stateless: no shared mutable state means no synchronisation question.
The compound action trap is a favourite follow-up: individually safe operations do not compose into a safe sequence.
// Each call is thread-safe. The SEQUENCE is not.
if (!map.containsKey(key)) { // thread A checks: absent
map.put(key, compute(key)); // thread B has put in between; A overwrites
}
// Correct: one atomic operation.
map.computeIfAbsent(key, this::compute);
Double-checked locking is the classic puzzle, and the answer hinges on
volatile, covered next.
private volatile Singleton instance; // volatile is REQUIRED here
public Singleton getInstance() {
if (instance == null) { // fast path, no lock
synchronized (this) {
if (instance == null) { // re-check under the lock
instance = new Singleton();
}
}
}
return instance;
}
Without volatile, another thread can observe a non-null reference to a
partially constructed object, because the constructor's writes may be reordered
with the reference assignment.
Key Takeaways
- Thread-safe means correct under concurrent use with no external synchronisation.
count++is three operations and loses updates without synchronisation.synchronizedprovides both mutual exclusion and visibility.- Getters need synchronisation too, or readers may see stale values.
- Lock a private object rather than
this, and hold the lock as briefly as possible.ReentrantLockadds timeouts, interruptibility, fairness, and multiple conditions.- Prefer immutability, confinement, and concurrent collections over locking.
- Individually atomic operations do not compose; use
computeIfAbsentand friends.- Double-checked locking requires
volatileon the field.
🧪 Practice
- Run an unsynchronised counter from ten threads a million times and record the result.
- Fix it three ways:
synchronized,AtomicInteger, and aLongAdder, then compare throughput.- Demonstrate the check-then-act race with a map and fix it with
computeIfAbsent.- Rewrite a
synchronizedmethod to lock a private object and hold it for fewer lines.- Interview: Why does a getter need synchronisation if it only reads? (Hint: name the second guarantee
synchronizedprovides besides mutual exclusion.)
volatile, Atomics, and the Java Memory Model
These questions go one level below locking, into what the JVM is permitted to do with your code. The Java Memory Model (JMM) is the specification that answers "when is a write by one thread guaranteed to be visible to another".
The problem is that three parties reorder your code.
You write the compiler may reorder
the JVM may reorder
the CPU may reorder, and caches may delay writes
All are legal as long as a SINGLE-THREADED program cannot tell.
A multi-threaded program absolutely can.
// The visibility bug that surprises everyone.
private boolean running = true; // not volatile
public void run() {
while (running) { doWork(); } // may loop FOREVER
}
public void stop() { running = false; } // written by another thread
The reader thread may cache running in a register, and the JIT compiler is
entitled to hoist the read out of the loop entirely, because nothing in that
thread modifies it.
volatile fixes visibility and ordering, and does not provide atomicity.
private volatile boolean running = true; // every read sees the latest write
volatile guarantees
-------------------
visibility a write is immediately visible to subsequent reads
ordering reads and writes are not reordered across the volatile access
(everything before the write is visible after the read)
volatile does NOT guarantee
---------------------------
atomicity count++ on a volatile field still loses updates
private volatile int count;
public void increment() { count++; } // STILL BROKEN: read-modify-write
When volatile is sufficient: a flag written by one thread and read by
others, or a reference published once, as in double-checked locking.
Atomic classes provide lock-free atomicity using a compare-and-swap instruction.
private final AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet(); // atomic: one CPU instruction, no lock
}
// The general form: retry until the swap succeeds.
public void updateMax(int candidate) {
count.accumulateAndGet(candidate, Math::max);
}
Compare-and-swap
----------------
read current value v = 5
compute new value n = 6
atomically: if the value is still 5, set it to 6, else retry
No lock, no blocking. Under high contention the retries can waste CPU,
which is why LongAdder exists for hot counters.
| Tool | Atomicity | Visibility | Blocking | Use for |
|---|---|---|---|---|
volatile | No | Yes | No | Flags, single publication |
AtomicInteger | Yes | Yes | No | Counters, compare-and-swap |
LongAdder | Yes | Yes | No | Very hot counters; faster under contention |
synchronized | Yes | Yes | Yes | Compound actions |
ReentrantLock | Yes | Yes | Yes | Compound actions with timeouts |
The happens-before relationship is the formal vocabulary, and using it correctly signals depth.
Guaranteed happens-before edges
-------------------------------
program order within a single thread
unlocking a monitor -> any later locking of the same monitor
writing a volatile -> any later read of that volatile
Thread.start() -> everything in the started thread
everything in a thread -> another thread's successful join()
a final field's initialisation -> reading it after construction completes
Final fields have a special guarantee: if an object's final fields are set in the constructor and the object does not escape during construction, other threads are guaranteed to see them fully initialised. This is why immutable objects are safe to share without synchronisation, and it is a good answer to "why is immutability thread-safe".
// Safe to publish to any thread with no synchronisation.
public final class Money {
private final BigDecimal amount; // final: guaranteed visible
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
} // 'this' never escapes the constructor
}
Key Takeaways
- The compiler, JVM, and CPU may reorder operations that a single thread cannot detect.
- A non-volatile flag can be cached or hoisted, producing an infinite loop.
volatileguarantees visibility and ordering, never atomicity.volatilesuits flags and single publication, not read-modify-write.- Atomic classes give lock-free atomicity through compare-and-swap.
LongAdderoutperformsAtomicLongfor heavily contended counters.- Happens-before edges come from locks, volatiles, thread start and join, and final fields.
- Final fields set in a constructor are safely visible, which is why immutable objects need no synchronisation.
🧪 Practice
- Write a loop reading a non-volatile flag stopped by another thread and see whether it terminates.
- Show that
volatile int count; count++still loses updates under contention.- Replace it with
AtomicIntegerand confirm the count is exact.- Benchmark
AtomicLongagainstLongAdderwith sixteen threads incrementing.- Interview: You have a boolean flag written by one thread and read by another. Is
volatileenough? (Hint: is setting a boolean a read-modify-write operation?)
Deadlock, Livelock, and Race Conditions
These three failures are grouped because interviewers ask you to distinguish them, and because the distinctions are genuinely useful when diagnosing a production hang.
| Failure | Symptom | Threads are |
|---|---|---|
| Deadlock | Permanent hang, zero CPU | Blocked forever |
| Livelock | Permanent hang, high CPU | Busy, making no progress |
| Starvation | One thread never progresses | Others monopolise |
| Race condition | Wrong or intermittent results | Running normally |
A deadlock needs four conditions simultaneously, and naming them is the textbook answer.
1. mutual exclusion a resource is held exclusively
2. hold and wait a thread holds one while waiting for another
3. no preemption a resource cannot be forcibly taken back
4. circular wait a cycle exists in the wait-for graph
Break ANY ONE and deadlock is impossible.
// The classic: two locks acquired in opposite orders.
public void transfer(Account from, Account to, BigDecimal amount) {
synchronized (from) { // thread A locks account 1
synchronized (to) { // thread B has locked account 2 and
from.debit(amount); // is waiting for account 1
to.credit(amount);
}
}
}
// transfer(a1, a2) in thread A and transfer(a2, a1) in thread B: deadlock.
Thread A: holds a1 -----> wants a2
^
|
Thread B: holds a2 -----> wants a1 a cycle
The standard fix is a consistent lock ordering, which breaks the circular wait condition. This is 16.3's advice about database deadlocks applied to in-memory locks.
public void transfer(Account from, Account to, BigDecimal amount) {
// Order by a stable, unique property so every thread locks in the same order.
Account first = from.id() < to.id() ? from : to;
Account second = from.id() < to.id() ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
The alternative is tryLock with a timeout and a retry, which breaks the hold
and wait condition instead.
Detecting a deadlock in production is a practical question.
# Dump all thread stacks. The JVM detects and prints Java-level deadlocks.
jstack <pid>
jcmd <pid> Thread.print
Found one Java-level deadlock:
=============================
"Thread-1": waiting to lock monitor 0x... which is held by "Thread-0"
"Thread-0": waiting to lock monitor 0x... which is held by "Thread-1"
Zero CPU plus a stalled application is the signature. High CPU plus a stall points at livelock or a garbage collection death spiral instead.
Livelock is deadlock's noisy cousin: threads respond to each other and keep changing state without progressing.
// Both threads politely release and retry, repeatedly colliding.
while (!acquired) {
if (lock.tryLock()) { ... }
else { releaseMyLock(); Thread.sleep(100); retry(); } // in lockstep forever
}
The fix is randomised backoff, so the two do not stay synchronised. This is the same idea as network collision backoff.
Race conditions come in two named shapes, and using the names is worthwhile.
// CHECK-THEN-ACT
if (!file.exists()) { // another thread creates it here
file.createNewFile(); // fails, or overwrites
}
// READ-MODIFY-WRITE
int current = counter.get();
counter.set(current + 1); // another thread incremented in between
Both are fixed by making the compound operation atomic, with a lock, an atomic
class, or a purpose-built method such as computeIfAbsent or
createNewFile's own atomic guarantee.
Prevention, in order of preference:
1. no shared mutable state immutability, confinement
2. higher-level abstractions concurrent collections, executors
3. atomic operations compare-and-swap
4. a single lock simple and easy to reason about
5. multiple locks in a fixed order the last resort
Testing for these bugs is genuinely hard, and saying so honestly is better than claiming otherwise. Stress tests with many threads, tools such as jcstress, and thread dumps under load are the practical approaches; a passing test proves very little.
Key Takeaways
- Deadlock is a permanent block at zero CPU; livelock is a permanent stall at high CPU.
- Deadlock needs mutual exclusion, hold and wait, no preemption, and circular wait.
- Breaking any one condition prevents it; consistent lock ordering is the usual choice.
tryLockwith a timeout breaks hold and wait instead.jstackdetects and reports Java-level deadlocks directly.- Livelock is fixed with randomised backoff.
- Check-then-act and read-modify-write are the two race shapes.
- Prefer no shared state, then high-level abstractions, then atomics, then locks.
- Concurrency bugs are hard to test; a passing test proves little.
🧪 Practice
- Write the two-account transfer deadlock, reproduce it, and capture a thread dump showing it.
- Fix it by ordering locks on the account identifier and verify under load.
- Implement the same fix with
tryLockand a timeout, and compare the failure modes.- Reproduce a check-then-act race on a map and fix it atomically.
- Interview: An application hangs with zero CPU usage. What do you suspect and what is your first command? (Hint: what distinguishes this from a high-CPU stall?)
Executors and Concurrent Collections
Creating threads by hand is the beginner approach, and interviewers ask about executors to check that you know why it does not scale.
The problems with new Thread:
// One thread per task: unbounded, expensive, and unmanageable.
for (Request request : requests) {
new Thread(() -> handle(request)).start(); // 10,000 requests = 10,000 threads
}
Each platform thread costs roughly a megabyte of stack and an operating system thread. Ten thousand exhausts memory, and the context switching alone destroys throughput. There is also no way to wait for completion, get a result, handle an exception, or limit concurrency.
An ExecutorService separates task submission from execution policy.
// A bounded pool with a bounded queue and an explicit rejection policy.
ExecutorService executor = new ThreadPoolExecutor(
4, 8, // core and maximum pool size
60L, TimeUnit.SECONDS, // idle timeout for extra threads
new ArrayBlockingQueue<>(100), // BOUNDED queue: back-pressure
new ThreadPoolExecutor.CallerRunsPolicy()); // slow the producer down
| Factory method | Pool | Caution |
|---|---|---|
newFixedThreadPool(n) | n threads, unbounded queue | The queue can exhaust memory |
newCachedThreadPool() | Unbounded threads | Can create thousands |
newSingleThreadExecutor() | One thread, unbounded queue | Serialises everything |
newWorkStealingPool() | ForkJoinPool | For CPU-bound work |
newVirtualThreadPerTaskExecutor() | A virtual thread per task | Java 21+; for blocking IO |
The unbounded queue is the hidden hazard in newFixedThreadPool: tasks
accumulate without limit until the heap is gone. A bounded queue plus a
rejection policy gives back-pressure instead.
submit versus execute is a common question with a sharp answer.
executor.execute(() -> risky()); // Runnable; an exception reaches the
// thread's uncaught handler and is logged
Future<?> future = executor.submit(() -> risky()); // the exception is CAPTURED
// If nobody calls future.get(), the exception is SILENTLY SWALLOWED.
future.get(); // throws ExecutionException, wrapping it
Silently swallowed exceptions from unexamined futures are a real production problem worth mentioning.
Shutdown must be explicit, or the JVM does not exit.
executor.shutdown(); // stop accepting new tasks
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow(); // interrupt running tasks
}
CompletableFuture composes asynchronous work without blocking.
CompletableFuture<Order> order = CompletableFuture
.supplyAsync(() -> loadOrder(id), executor) // always pass an executor
.thenApply(this::enrich) // transform
.thenCompose(o -> lookupCustomer(o.customerId())) // chain another future
.exceptionally(e -> { // handle failures
log.error("failed", e);
return Order.empty();
});
// Combine independent calls that can run in parallel.
CompletableFuture.allOf(orderFuture, inventoryFuture).join();
Always supply an executor: the no-argument overloads use the common ForkJoinPool, which is shared by the whole JVM and must not be blocked, exactly as 19.3 warned about parallel streams.
Virtual threads are the modern answer if the interview covers recent Java.
// Java 21+: each task gets a virtual thread. Blocking is cheap because the
// carrier thread is released while the virtual thread waits.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request r : requests) {
executor.submit(() -> handleBlockingIo(r)); // millions are feasible
}
}
The key point to make: virtual threads make blocking IO cheap. They do not make CPU-bound work faster, since the number of cores is unchanged.
Concurrent collections replace synchronised wrappers.
| Collection | Replaces | Characteristics |
|---|---|---|
ConcurrentHashMap | synchronizedMap | Lock-free reads, striped writes |
CopyOnWriteArrayList | synchronizedList | Read-heavy, expensive writes |
ConcurrentLinkedQueue | A synchronised queue | Non-blocking, unbounded |
LinkedBlockingQueue | Hand-rolled wait/notify | Blocking, optionally bounded |
ArrayBlockingQueue | Hand-rolled wait/notify | Blocking, fixed capacity |
Blocking queues are the standard producer-consumer tool, and they remove the
need for wait and notify entirely.
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1000);
// Producer: blocks when full, which is back-pressure.
queue.put(task);
// Consumer: blocks when empty, no polling loop needed.
Task task = queue.take();
Key Takeaways
- Creating a thread per task is unbounded, expensive, and unmanageable.
- An executor separates what to run from the policy for running it.
newFixedThreadPoolhas an unbounded queue that can exhaust the heap.- A bounded queue with a rejection policy provides back-pressure.
submitcaptures exceptions in theFuture; they vanish if nobody callsget.- Shut executors down explicitly and await termination.
CompletableFuturecomposes async work; always pass your own executor.- Virtual threads make blocking IO cheap, not CPU work faster.
- Blocking queues implement producer-consumer without
waitandnotify.
🧪 Practice
- Submit 10,000 tasks to a fixed pool with an unbounded queue and watch heap usage.
- Replace it with a bounded queue and a caller-runs policy, and observe the back-pressure.
- Submit a task that throws, never call
get, and confirm nothing is reported.- Build a producer-consumer pipeline with a
BlockingQueueand three consumers.- Run 10,000 blocking IO tasks on a platform thread pool and on virtual threads, and compare.
- Interview: Why should you pass an explicit executor to
CompletableFuture.supplyAsync? (Hint: which pool does the default use, and what else in the JVM depends on it?)
<a id="195-framework-and-ecosystem-questions"></a>
19.5 Framework and Ecosystem Questions
This subchapter covers the questions drawn from chapters 15 to 18, where the interviewer is usually checking whether you understand what the framework does on your behalf rather than only how to call it.
JDBC, Transactions, and ORM
Data access questions are where candidates most often reveal that they have used a framework without understanding it. The questions are practical and the wrong answers have real production consequences.
"Why PreparedStatement over Statement?" should get three reasons, not
one.
1. Security values never become SQL, so injection is impossible 16.2
2. Correctness the driver handles escaping, quoting, and type conversion
3. Performance the database parses and plans once, then reuses it
Most candidates give the first. Giving all three, and explaining that parameterisation works because parsing happens before the value arrives, is the distinguishing answer.
"What do the isolation levels mean?" expects the anomaly each one prevents, not just the names.
| Level | Prevents |
|---|---|
READ_UNCOMMITTED | Nothing |
READ_COMMITTED | Dirty reads |
REPEATABLE_READ | Dirty and non-repeatable reads |
SERIALIZABLE | All three, including phantoms |
The strong addition: SERIALIZABLE on PostgreSQL is optimistic and aborts
conflicting transactions with SQL state 40001, so using it requires a retry
loop. Saying that demonstrates you have actually run it.
"Where do transaction boundaries belong?" is the design question from 16.3.
// WRONG: each repository call is its own transaction, so the operation
// as a whole is not atomic.
public void placeOrder(Order order) {
orderRepository.save(order); // commits
stockRepository.decrement(order); // separate transaction; may fail
}
// RIGHT: one business operation, one transaction.
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
stockRepository.decrement(order);
}
"What is the N+1 problem?" is asked in almost every interview touching Hibernate.
// 1 query for the orders, then 1 per order for its lines: 101 queries.
List<Order> orders = repository.findAll();
orders.forEach(o -> total(o.getLines()));
Name the fixes, in order: a JOIN FETCH query, an entity graph, batch fetching,
or a projection that avoids entities altogether. Adding that marking everything
EAGER is not a fix, because it loads the whole graph on every read, shows
judgement.
"What does @Transactional not do?" is the trap, and the answer is 17.2's
proxy limitations.
@Service
public class OrderService {
public void processAll(List<Order> orders) {
orders.forEach(this::process); // self-invocation: NO transaction
}
@Transactional
public void process(Order order) { ... }
}
Also worth stating: it rolls back on unchecked exceptions and commits on checked ones by default, and it does nothing on a private method.
"Explain the entity lifecycle." Transient, managed, detached, removed, with the crucial detail that a managed entity's changes are written automatically by dirty checking, and that a detached entity's changes are not written at all.
"When would you use JdbcTemplate instead of JPA?" A good answer names
reporting queries, bulk operations, and anything where the generated SQL is not
good enough, and notes that the two coexist on the same transaction.
"What is open-in-view and why disable it?" This is an increasingly common
question and 18.3 answered it: it keeps the persistence context open through
serialisation, which hides N+1 queries in the view layer and holds a connection
for the whole request including slow clients.
Key Takeaways
- Parameterised statements give security, correctness, and plan reuse.
- Name the anomaly each isolation level prevents, and mention retry for serializable.
- Transaction boundaries belong at the business operation, in the service layer.
- N+1 is fixed by join fetch, entity graphs, batching, or projections, never by eager loading.
@Transactionaldoes nothing on self-invoked or private methods.- It commits on checked exceptions unless
rollbackForis set.- Dirty checking writes changes to managed entities; detached entities are not tracked.
- Disable
open-in-viewso lazy loading failures surface during development.
🧪 Practice
- Explain in two minutes why a prepared statement prevents injection, without using the word "escape".
- Write a self-invoked
@Transactionalcall, prove no transaction opens, and fix it.- Demonstrate N+1 with SQL logging and fix it two different ways.
- Describe the four entity states and what a setter does in each.
- Interview: A service method annotated
@Transactionalthrows anIOExceptionand the data is still saved. Bug or expected? (Hint: what is the documented default rollback rule?)
Dependency Injection and Bean Lifecycle
These questions test whether you understand the container as a mechanism or treat it as magic. The most revealing answers are about why the recommendations exist.
"Why constructor injection?" Four reasons, from 17.1.
1. the object is fully initialised the moment it exists
2. fields can be final: immutable and thread-safe
3. dependencies are visible in the signature
4. the class can be constructed with new in a test
Add the fifth, which is the one that shows experience: a constructor growing to ten parameters is visible feedback that a class does too much, and field injection hides it.
"Why is field injection discouraged?" Because tests cannot construct the object, fields cannot be final, and the dependencies are invisible in the public API. The answer that it "uses reflection" is true and beside the point.
"What is the default bean scope, and what does it require of your code?" One instance per container, shared by every thread, therefore the bean must be stateless. Injected final collaborators are fine; mutable request state is not. This is the single most consequential thing to know about Spring beans.
@Service
public class BrokenService {
private Order current; // SHARED across all request threads
public void place(Order o) { this.current = o; ... }
}
"How do you inject a prototype into a singleton?" The trap is that a
prototype is resolved once at singleton construction. ObjectProvider or
@Lookup gives a fresh instance per call.
"What happens when two beans implement the same interface?"
NoUniqueBeanDefinitionException at startup, resolved with @Primary for the
default and @Qualifier at the injection point that needs the exception. The
good addition: if all of them should run, inject a List<T> instead of
choosing.
"Describe the bean lifecycle."
instantiate -> inject dependencies -> BeanPostProcessor (before)
-> @PostConstruct -> afterPropertiesSet -> BeanPostProcessor (after)
-> READY -> @PreDestroy -> destroy
The detail worth adding: proxies are created by a BeanPostProcessor in the
last step, which is exactly why @Transactional and @PreAuthorize work only
on externally invoked calls.
"Why does Spring create singletons eagerly at startup?" So wiring errors fail immediately with a clear message rather than at three in the morning when a rarely used path first runs. Trading startup time for fail-fast behaviour is a deliberate choice.
"What is a circular dependency and what should you do?" Constructor
injection rejects it at startup; setter and field injection tolerate it. The
correct response is to treat it as a design signal and extract the shared
concern, not to reach for @Lazy.
"What is AOP and how does Spring implement it?" Cross-cutting concerns separated from business logic, implemented with runtime proxies rather than bytecode weaving. The consequences to name: only Spring-managed beans, only public methods, and never self-invoked calls.
Key Takeaways
- Constructor injection guarantees full initialisation, allows final fields, and enables plain testing.
- A long constructor is useful feedback that field injection suppresses.
- The default singleton scope requires beans to be stateless.
- A prototype injected into a singleton resolves once; use
ObjectProvider.- Ambiguity is resolved with
@Primaryand@Qualifier, or avoided by injecting a list.- Proxies are added by a post-processor after initialisation.
- Eager singleton creation trades startup time for fail-fast wiring errors.
- Circular dependencies are a design signal, not something to work around.
- Spring AOP uses proxies, so self-invocation and private methods are never advised.
🧪 Practice
- Explain in one minute why field injection makes a class harder to test.
- Add mutable state to a singleton bean, hit it from twenty threads, and describe the failure.
- Inject a prototype into a singleton, prove the instance never changes, and fix it.
- Create a circular dependency with constructors, read the error, then redesign to remove it.
- Interview: Why does
@Transactionalwork when a controller calls a service but not when the service calls itself? (Hint: which object doesthisrefer to inside the target?)
Spring Boot, REST, and Auto-Configuration
Boot questions usually probe whether the candidate can explain the automation or merely benefits from it.
"What does Spring Boot actually add to Spring?" The clean answer: auto-configuration, starter dependencies with managed versions, an embedded server and executable jar, externalised configuration, and actuator. The programming model is unchanged. Saying "Boot replaced Spring" is a wrong answer that comes up often.
"How does auto-configuration work?" Conditional bean registration, discovered from an imports file in every jar, evaluated against the classpath, properties, and beans already defined.
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean // this is why your own bean wins
public DataSource dataSource(DataSourceProperties props) { ... }
}
The follow-up: how do you override it? Define the bean yourself, and
@ConditionalOnMissingBean steps aside. The diagnostic: run with --debug and
read the condition evaluation report.
"What does @SpringBootApplication do?" Three annotations:
@SpringBootConfiguration, @EnableAutoConfiguration, and @ComponentScan
with no arguments, which is why the main class's package determines what is
scanned.
"Which configuration source wins?" Later sources override earlier ones, from
packaged files up to command-line arguments. The practical answer is better than
reciting the list: query /actuator/env/<property> and read the source field.
"How do you design a REST endpoint?" Resources as nouns, HTTP methods as
verbs, correct status codes, pagination by default, and a consistent error
format. The details that show experience: 201 with a Location header for
creation, 409 for a conflict, and never exposing entities directly.
"Why not return entities from a controller?" Four reasons from 18.3: schema coupling, over-exposure of fields, mass assignment on the request side, and lazy loading failures during serialisation.
"How do you version an API?" URL path versioning is the practical default. More important is knowing that most changes need no version: adding response fields and relaxing request validation are compatible; removing fields and tightening validation are not.
"What is @ControllerAdvice for?" Centralised exception handling, so every
endpoint returns the same error shape. The point to add: never return exception
messages or stack traces to a client; log them with a correlation identifier and
return the identifier.
"What is actuator and what would you expose?" Operational endpoints. Expose
health and metrics; keep env, beans, and heapdump off a public port. Then
the distinction that matters: liveness failure restarts the process, readiness
failure stops routing to it, so a database outage belongs in readiness.
Key Takeaways
- Boot adds auto-configuration, starters, an embedded server, configuration, and actuator, leaving Spring's model intact.
- Auto-configuration is conditional registration discovered from jar metadata.
@ConditionalOnMissingBeanis what makes your own bean override a default.@SpringBootApplicationscans from its own package downward.- Configuration precedence runs from packaged files up to command-line arguments.
- Design endpoints around resources with correct status codes and default pagination.
- Never expose entities; use request and response types.
- Most API changes are compatible; adding is safe, removing is not.
- Liveness restarts, readiness deroutes; put dependency outages in readiness.
🧪 Practice
- Explain auto-configuration in ninety seconds without using the word "magic".
- Override an auto-configured bean and prove from the debug report that Boot's no longer applies.
- Set one property in three sources and identify the winner from the actuator environment endpoint.
- List ten changes to an endpoint and classify each as compatible or breaking.
- Interview: You add a library and a bean you never declared appears. Explain the mechanism. (Hint: what file does Boot look for inside every jar on the classpath?)
Build, Test, and Tooling Questions
These questions check professional habits. They are easy to answer well and candidates often neglect them.
"Maven or Gradle?" Both are legitimate; having a reasoned preference matters more than the choice.
| Aspect | Maven | Gradle |
|---|---|---|
| Build file | Declarative XML | Kotlin or Groovy script |
| Flexibility | Convention-bound | Arbitrary logic, easily abused |
| Speed | Slower; no build cache by default | Incremental builds and caching |
| Learning curve | Gentle | Steeper |
| Predictability | High: builds look alike | Lower: each project differs |
"What is the dependency scope for a test library?" test in Maven,
testImplementation in Gradle. The follow-up about provided and
compileOnly, used for a servlet API supplied by a container, checks whether
you have built a WAR.
"How do you resolve a version conflict?" Inspect the tree, then pin the version explicitly.
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
./gradlew dependencies --configuration runtimeClasspath
Maven resolves conflicts by nearest definition wins, which is worth knowing because it surprises people expecting the highest version.
"Why commit the wrapper?" So every developer and the CI server build with the same tool version. Same reasoning as pinning dependency versions.
"What makes a good unit test?" FIRST: fast, isolated, repeatable, self-validating, timely. Plus the habit that matters most: assert observable behaviour, not implementation, so that refactoring does not break tests.
// BRITTLE: asserts how it works.
verify(repository).save(any());
assertEquals(1, cart.getItemsList().size());
// ROBUST: asserts what it promises.
assertEquals(Money.of(1250), cart.total());
"What is your view on code coverage?" A signal, never a target. A test with no assertions gives full coverage and zero confidence. Use it to find untested code, not to certify tested code. Quoting Goodhart's law here is appropriate: once a measure becomes a target, it stops measuring.
"Mock or not?" Mock what you do not own and what is slow or nondeterministic: external services, clocks, randomness. Do not mock value objects or the class under test. Prefer a hand-written fake over a mock when the collaborator has meaningful behaviour, because a fake does not break when the interface gains a method.
"How do you test a Spring Boot application?" The pyramid: plain JUnit for services with constructor injection, slice tests for one layer, and full context tests for critical paths. Add the practical point about context caching: each distinct configuration starts a new context, so per-class property tweaks multiply suite time.
"Why Testcontainers instead of H2?" Because H2 is not the production database. Different SQL dialect, different locking, looser constraints, and migrations that pass in compatibility mode and fail for real.
"What tools do you use for quality?" Name a static analyser, a formatter, a coverage tool, and a dependency vulnerability scanner, and say that they run in CI rather than on request.
Formatter Spotless, google-java-format style is not a review topic
Static analysis SpotBugs, Error Prone, Sonar finds real defect patterns
Coverage JaCoCo a signal, not a gate
Vulnerabilities OWASP Dependency-Check, Snyk known CVEs in dependencies
Key Takeaways
- Have a reasoned preference between Maven and Gradle rather than a default one.
- Know the dependency scopes and how conflicts are resolved.
- Maven resolves version conflicts by nearest definition, not highest version.
- Commit the build wrapper so everyone builds identically.
- FIRST describes a good unit test; asserting behaviour is what keeps tests useful.
- Coverage finds untested code and certifies nothing.
- Mock what you do not own; prefer fakes for collaborators with behaviour.
- Use the test pyramid in Boot and watch context caching.
- Testcontainers exists because H2 is not your production database.
- Quality tooling belongs in CI, not in a review comment.
🧪 Practice
- Find a transitive dependency conflict in a real project and resolve it explicitly.
- Rewrite a test that verifies a mock interaction into one asserting behaviour.
- Add a coverage report and find one class with high coverage and weak assertions.
- Add a formatter and a static analyser to a build and fix everything they report.
- Interview: A team has 90% coverage and frequent production defects. What would you look at? (Hint: what does an executed line tell you about whether anything was verified?)
<a id="196-practical-interview-skills"></a>
19.6 Practical Interview Skills
This subchapter is about performance rather than knowledge: how to behave when you are being watched, how to approach the exercise formats that actually get used, and how to talk about work you have done.
Live Coding and Whiteboard Technique
The most common reason candidates fail a live coding round is not missing knowledge. It is starting to type immediately, going silent, and producing something the interviewer cannot follow.
What is actually being assessed:
30% can you decompose a problem
25% do you communicate while working
20% do you handle edge cases without prompting
15% is the code readable
10% is it optimal
Optimality is the smallest component and candidates optimise for it first.
A five-step structure that works for nearly every exercise:
1. CLARIFY restate the problem; ask about inputs, size, and edge cases
2. EXAMPLE work one small case by hand, including an edge case
3. APPROACH describe your plan and its complexity BEFORE typing
4. IMPLEMENT write it, narrating as you go
5. VERIFY trace your example through the code; discuss improvements
Step one is the one candidates skip and the one interviewers weight most, because ambiguity is deliberate. Good questions to ask:
What is the expected input size? 10 or 10 million changes the answer
Can the input be null or empty?
Are duplicates possible?
Is the input sorted?
Should I optimise for time or memory?
Can I modify the input?
What should happen on invalid input: exception, or a sentinel?
Narrate your reasoning, including the false starts. Silence is unreadable, and an interviewer cannot give credit for thinking they cannot see.
Weak Strong
---- ------
[silence for four minutes] "My first thought is nested loops,
[starts typing] which is O(n squared). Since we only
need to know whether the complement
exists, a set gives O(n) with O(n)
extra space. I'll do that unless memory
is the constraint."
Write the brute force first if you are stuck. A working slow solution plus a stated plan to improve it beats forty minutes of silence toward an optimal one.
// "Let me get something correct first, then improve it."
public int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) { // O(n^2), correct
for (int j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] == target) return new int[]{ i, j };
}
}
throw new IllegalArgumentException("no solution");
}
// "Now, trading space for time:"
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> seen = new HashMap<>(); // value -> index
for (int i = 0; i < nums.length; i++) {
Integer j = seen.get(target - nums[i]); // has the complement
if (j != null) return new int[]{ j, i }; // appeared already?
seen.put(nums[i], i);
}
throw new IllegalArgumentException("no solution");
}
Handle edge cases without being asked. Mentioning them earns credit even if you decide not to code them.
null or empty input
a single element
all elements identical
negative numbers, or zero
integer overflow on a sum
the maximum stated input size
Write code as if it were going to be reviewed, because it is. Meaningful names, small methods, no single-letter variables except loop indices.
Trace your own code before saying you are done. Pick your example, walk through line by line, and say the values aloud. Finding your own bug is a positive signal; having the interviewer find it is not.
If you are completely stuck, say so and say what you have tried. "I'm stuck on how to handle overlapping intervals. I've considered sorting by start time, which gets me this far." Interviewers give hints to candidates who are visibly working, not to silent ones.
Key Takeaways
- Communication and decomposition are weighted far above optimality.
- Clarify the problem before writing anything; ambiguity is deliberate.
- Work one example by hand, including an edge case.
- State your approach and its complexity before typing.
- Narrate continuously, including approaches you reject.
- A working brute force with a stated improvement beats silent optimisation.
- Raise edge cases unprompted.
- Trace your own code before declaring it finished.
- Say what you are stuck on; hints go to visibly working candidates.
🧪 Practice
- Solve a problem while narrating aloud to a recording, then listen for gaps of silence.
- Write down eight clarifying questions for "find duplicates in a list" before writing any code.
- Solve one exercise brute force first, then optimise, timing yourself on each.
- Take a solution you wrote and list every edge case you did not handle.
- Interview: Practise explaining your approach for sixty seconds before touching the keyboard. (Hint: state the data structure, the complexity, and the trade-off.)
Object-Oriented Design Exercises
A design round gives a deliberately open prompt, such as "design a parking garage" or "design a vending machine", and lasts forty minutes. It tests whether you can impose structure on ambiguity, which is most of senior engineering.
A structure that fits the time:
5 min clarify scope and constraints; agree what is OUT of scope
10 min identify entities, value objects, and their relationships
10 min define the key interfaces and operations
10 min walk one scenario through your design end to end
5 min discuss extensions and trade-offs the interviewer raises
Scoping is the first thing assessed. "Design a parking garage" is too large for forty minutes, and saying so is the correct first move.
"Let me scope this. I'll cover: vehicle entry, spot assignment, exit, and
fee calculation. I'll leave out payment processing, reservations, and
multi-site management unless you'd like one of those instead."
Then ask the questions that change the design, not trivia.
Are there different spot sizes and vehicle types? affects assignment
Can a vehicle occupy several spots? affects the model
Is pricing flat, hourly, or tiered? affects the strategy
One garage or many? affects identity
Roughly how many spots and how much traffic? affects the data structure
Sketch the model before any code.
ParkingGarage
+-- Level *
+-- ParkingSpot * (size, occupied by)
Vehicle (abstract)
+-- Motorcycle Car Bus
Ticket (vehicle, spot, entry time)
FeeStrategy (interface)
+-- HourlyFee FlatFee TieredFee
Then the interfaces, which is where design is actually judged.
public interface SpotAssignmentStrategy {
// Returning Optional makes "the garage is full" explicit rather than null.
Optional<ParkingSpot> assign(Vehicle vehicle, List<Level> levels);
}
public interface FeeStrategy {
Money feeFor(Duration duration, VehicleType type);
}
public class ParkingGarage {
private final SpotAssignmentStrategy assignment; // injected: 17.1
private final FeeStrategy fees;
private final Clock clock; // injected: testable, 18.5
public Ticket park(Vehicle vehicle) {
ParkingSpot spot = assignment.assign(vehicle, levels)
.orElseThrow(() -> new GarageFullException(vehicle.type()));
spot.occupy(vehicle); // the spot owns its state
return new Ticket(vehicle, spot, Instant.now(clock));
}
public Money checkOut(Ticket ticket) {
Duration stay = Duration.between(ticket.entryTime(), Instant.now(clock));
ticket.spot().release();
return fees.feeFor(stay, ticket.vehicle().type());
}
}
The two strategy interfaces are doing deliberate work. Assignment and pricing are exactly the parts an interviewer will ask you to change, so putting them behind interfaces means the follow-up is a new class rather than a rewrite.
Anticipate the standard follow-ups, because they are predictable:
| Follow-up | Prepared answer |
|---|---|
| "Add electric charging spots" | A new spot type and assignment rule |
| "Add tiered pricing" | A new FeeStrategy implementation |
| "Two cars arrive at once" | Concurrency: 19.4's check-then-act race |
| "How do you persist this?" | Repository interfaces, 16.4 |
| "Scale to 100 garages" | Garage identity, partitioning |
The concurrency follow-up is asked often and answered poorly.
// Check-then-act: two threads can assign the same spot.
if (spot.isFree()) { spot.occupy(vehicle); }
// Fixed with an atomic operation on the spot itself.
public synchronized boolean tryOccupy(Vehicle v) {
if (occupant != null) return false;
occupant = v;
return true;
}
Common mistakes to avoid: jumping into code before agreeing the scope, modelling everything as data holders with no behaviour, inventing six patterns for a small problem, and designing for a scale nobody mentioned.
Key Takeaways
- Scope the problem explicitly and say what you are excluding.
- Ask only the questions that would change the design.
- Sketch entities and relationships before writing any code.
- Put behaviour on the objects that own the data.
- Place the parts most likely to change behind interfaces.
- Inject the clock and the strategies so the design is testable.
- Walk one complete scenario through the design aloud.
- Prepare for the standard follow-ups, especially concurrency.
- Do not design for unstated scale or invent unnecessary patterns.
🧪 Practice
- Design a vending machine in forty minutes, timing each of the five phases.
- Take that design and add a new payment method without modifying existing classes.
- Identify every check-then-act race in a design you produced.
- Design an elevator controller and list the three questions that most change the answer.
- Interview: Given "design a library system", write your scoping statement in three sentences. (Hint: name what you will cover and what you are deliberately leaving out.)
Debugging and Code Review Exercises
Some interviews hand you broken or questionable code instead of a blank editor. These rounds test whether you read carefully and reason systematically, and they reward a visible method.
A debugging method that works under observation:
1. REPRODUCE what exactly is the wrong behaviour, and when?
2. READ understand what the code intends before changing anything
3. HYPOTHESISE form a specific, falsifiable theory
4. TEST make one change or add one probe
5. FIX address the cause, not the symptom
6. PREVENT what test would have caught this?
Step six is the one that distinguishes a senior answer.
Bugs interviewers plant, all of which this chapter has covered:
// 1. equals without hashCode (19.2)
class Point { public boolean equals(Object o) { ... } } // HashSet misbehaves
// 2. Mutating while iterating (19.3)
for (String s : list) { if (cond) list.remove(s); }
// 3. A lost update (19.4)
public void increment() { count++; } // unsynchronised
// 4. A resource leak (16.1)
Connection c = ds.getConnection(); // no try-with-resources
// 5. String concatenation in a loop (19.2)
for (...) { result += token; }
// 6. Self-invoked @Transactional (17.2)
public void all() { list.forEach(this::one); }
// 7. Comparing strings with == (19.2)
if (status == "ACTIVE") { }
// 8. Integer caching surprise
Integer a = 128, b = 128; if (a == b) { } // false: outside the cache
// 9. An off-by-one boundary
for (int i = 0; i <= list.size(); i++) { }
Integer caching deserves a note since it appears often: the JVM caches boxed
values from minus 128 to 127, so == works for small numbers and fails above
127, which is exactly the intermittent behaviour that makes a bug hard to find.
Say what you notice as you read, in priority order, rather than starting with formatting.
"Reading through: first, this method isn't synchronised but the counter is
shared, so increments will be lost. Second, the connection isn't in a
try-with-resources, so it leaks on the exception path. Third, and much less
important, this variable name could be clearer."
For a code review exercise, use 19.1's priority order and label severity. Offer a concrete alternative rather than only naming a problem.
blocking: line 24 concatenates user input into SQL. A parameterised
statement would fix it and keeps the plan cache useful.
blocking: line 41 catches Exception and continues, so a failed save looks
like a success to the caller.
suggestion: lines 12 to 30 would read better as three methods named after
the three comments already there.
nit: "recieve" is misspelled on line 8.
Fix the cause, not the symptom. If a NullPointerException occurs, adding a
null check may be right, or the real question may be why the value is null at
all. Say which you are doing and why.
When you cannot find it, narrow systematically aloud: binary search the input, add a probe at the midpoint of the flow, check the boundaries. Visible method is worth more than a lucky guess.
Key Takeaways
- Reproduce, read, hypothesise, test, fix, and then prevent.
- Naming the test that would have caught the bug is the senior move.
- The planted bugs are usually the classics from earlier in this chapter.
- Boxed integers are cached from minus 128 to 127, so
==fails intermittently.- Narrate observations in priority order, correctness before style.
- Label review comments by severity and propose a concrete alternative.
- Decide explicitly whether you are fixing the cause or the symptom.
- Narrow systematically and aloud when the cause is not obvious.
🧪 Practice
- Write a class with five deliberate bugs from the list and have someone else find them.
- Review a pull request and order your comments by severity before posting.
- Demonstrate the integer caching behaviour at 127 and at 128.
- Take a bug you fixed recently and write the test that would have caught it.
- Interview: You are given 50 lines with a threading bug. Describe your first three steps. (Hint: what do you do before changing anything?)
Explaining Trade-offs Out Loud
Senior interviews are largely conversation, and the recurring pattern is a question with no correct answer: SQL or NoSQL, monolith or microservices, synchronous or asynchronous. The interviewer is checking whether you can reason about consequences rather than repeat a preference.
The failure mode is answering as though there were a right answer.
Weak "Microservices, they scale better."
Weak "It depends." (true, and useless alone)
Strong "It depends on team size and deployment coupling. With one team
of six I'd start with a modular monolith: you get the domain
boundaries without the operational cost of a distributed system.
I'd split out a service when one part needs to scale differently
or a second team needs to deploy independently."
A four-part structure makes trade-off answers land:
1. name the dimensions that matter what are we actually trading?
2. state the trade-off in each direction what does each choice cost?
3. say what would decide it which fact changes the answer?
4. commit to a recommendation given reasonable assumptions
Step four is where candidates hedge. An interviewer wants a decision they can push back on, not a survey.
Trade-offs worth having ready:
| Choice | For | Against |
|---|---|---|
| Monolith | Simple deploy, easy transactions | Scaling and team coupling |
| Microservices | Independent scaling and deploys | Distributed failure, no easy transactions |
| SQL | Transactions, joins, constraints | Rigid schema, harder sharding |
| NoSQL | Flexible schema, horizontal scale | Weaker guarantees, duplicated data |
| Synchronous call | Simple, immediate result | Coupling, cascading failure |
| Message queue | Decoupling, absorbs spikes | Eventual consistency, complexity |
| Caching | Latency and load | Staleness and invalidation |
| Optimistic locking | No blocking | Retries under contention |
| Pessimistic locking | No retries | Blocking and deadlocks |
| More tests | Confidence, safer change | Time, and brittle tests slow you |
Use concrete numbers when you have them. "A connection takes about twenty milliseconds to open and a simple query about one" is far more convincing than "pooling is faster".
Name what would change your mind. This is the strongest single move in a trade-off answer, because it shows the position is reasoned rather than held.
"I'd stay with the monolith. I'd change my mind if the reporting workload
started affecting checkout latency, or if a second team needed to release
on a different cadence."
Admit uncertainty precisely. "I don't know" is acceptable; "I don't know, here's how I'd find out" is better.
"I haven't used Kafka in production. I've used RabbitMQ, and my
understanding of the difference is the log-based retention model, which
changes replay. I'd want to verify that before committing to a design."
Disagree respectfully when you think the interviewer is wrong. Politely holding a defensible position is a positive signal; folding immediately is not.
"That's fair, and I'd weigh it differently. My concern with eager fetching
is that it loads the whole graph on every read. Is there a constraint I'm
missing that makes that acceptable here?"
Key Takeaways
- Open questions test reasoning about consequences, not a memorised preference.
- Name the dimensions, state both costs, say what decides it, then commit.
- Hedging without a recommendation is a weak answer.
- Concrete numbers are far more persuasive than adjectives.
- Saying what would change your mind demonstrates a reasoned position.
- Admit uncertainty precisely and say how you would resolve it.
- Defend a position politely rather than folding at the first push.
🧪 Practice
- Answer "SQL or NoSQL?" in ninety seconds using the four-part structure.
- Take a technology decision in your current project and write both sides of the trade-off.
- For three positions you hold, write down exactly what evidence would change each.
- Practise saying "I don't know, and here is how I would find out" for a topic you are weak on.
- Interview: Argue for a monolith, then argue for microservices, ninety seconds each. (Hint: the deciding factors are team structure and independent deployability.)
Behavioral and Project Experience Questions
Technical rounds establish that you can do the work. Behavioural rounds establish whether people want to work with you, and they are failed far more often by vagueness than by a lack of impressive stories.
Use the STAR structure, which exists because unstructured answers wander.
Situation the context, in two sentences
Task what you specifically were responsible for
Action what YOU did, in detail, and why
Result the outcome, with a number where one exists
The common failure is spending three minutes on Situation and twenty seconds on Action, when Action is the part being assessed.
Weak
----
"We had performance problems so we fixed them and it got faster."
Strong
------
Situation: "Our order listing endpoint degraded from 200ms to 9 seconds
after a customer's data grew past about 50,000 orders."
Task: "I picked it up as the on-call engineer."
Action: "I enabled SQL logging and found roughly 1,200 queries per
request, a classic N+1 from a lazy association touched during
serialisation. I added a join fetch, and then added a test that
asserts the query count so it couldn't regress silently."
Result: "Latency went to 180ms. The query-count test caught the same
pattern in a different endpoint two months later."
Prepare five or six stories that can be reframed for most questions.
| Have a story about | It also answers |
|---|---|
| A hard technical problem you solved | Greatest achievement, debugging skill |
| A production incident | Pressure, ownership, learning from failure |
| A disagreement with a colleague | Conflict, influence, communication |
| A mistake you made | Failure, accountability, growth |
| Something you led or improved | Leadership, initiative |
| Something you learned quickly | Adaptability, learning style |
Say "I" for your actions and "we" for context. Interviewers cannot assess a team. If the work was genuinely shared, say precisely which part was yours.
The mistake question is the one to prepare hardest, because a weak answer is disqualifying.
Bad "I work too hard." transparent evasion
Bad "I can't think of one." implausible
Bad blaming someone else the worst option
Good a real mistake, its real impact, what you did immediately, and the
specific change you made so it cannot recur
"I ran a migration on production that added a NOT NULL column to a table
with four million rows. It locked the table for about ninety seconds
during business hours. I rolled it back, then re-ran it that evening in
three steps: add nullable, backfill in batches, then add the constraint.
Afterwards I added a rule that any migration touching a table over a
million rows gets tested against a production-sized copy first, and we
put that in the review checklist."
That answer works because it is specific, the impact is real, the fix is technical, and the prevention is systemic.
Prepare questions to ask them, because "no questions" reads as indifference.
How does code get from a developer's machine to production?
What does the on-call rotation look like?
What is the biggest source of technical debt right now?
How are technical decisions made when people disagree?
What would you want someone in this role to have accomplished in six months?
Be honest about what you have not done. Claiming depth you lack collapses under one follow-up question and costs more than the gap would have.
Key Takeaways
- Behavioural rounds are failed through vagueness more than through weak stories.
- Use STAR and spend most of the time on Action.
- Quantify results wherever a number exists.
- Prepare five or six stories that can be reframed across many questions.
- Say "I" for your own actions and be precise about shared work.
- Prepare the mistake question properly: real impact, real fix, systemic prevention.
- Have questions ready, ideally about delivery, on-call, and decision-making.
- Never claim experience you do not have; one follow-up exposes it.
🧪 Practice
- Write out five STAR stories, keeping each under two minutes spoken.
- Rewrite one of them to quantify the result.
- Take one story and reframe it to answer three different questions.
- Prepare your mistake answer in full, including the systemic prevention.
- Write five questions to ask an interviewer that you genuinely want answered.
- Interview: Describe a production incident you were involved in, in ninety seconds. (Hint: keep the context to two sentences and spend the time on what you did.)
<a id="197-exercises"></a>
19.7 Exercises
List of exercises: https://codingdojo.org/kata/
Each exercise below is a specification, not a tutorial. Work through them with tests written first: pick the simplest requirement, write a failing test, make it pass, then refactor before moving to the next. The requirements are ordered so that each one builds on the previous, which is what makes them useful as practice for an interview rather than as puzzles.
1. FizzBuzz
Write a method:
String fizzBuzz(int n)
that returns the FizzBuzz representation of a single number, and a second method that returns the sequence from 1 to n.
- Basic Case: a number that is not divisible by 3 or 5 returns its own decimal
representation:
1 → "1" 7 → "7" - Divisible by 3: return
"Fizz".3 → "Fizz" 9 → "Fizz" - Divisible by 5: return
"Buzz".5 → "Buzz" 20 → "Buzz" - Divisible by both: return
"FizzBuzz".15 → "FizzBuzz" 45 → "FizzBuzz" - The Sequence: a second method returns the values from 1 to n in order.
fizzBuzzUpTo(5) → ["1", "2", "Fizz", "4", "Buzz"] - Invalid Input: numbers below 1 are rejected.
0 → throw IllegalArgumentException("n must be positive") - Extension: make the rules configurable, so that a caller can supply a list of
(divisor, word) pairs and the method still works for the classic case.
The point of this step is the refactor: getting from a chain ofrules = [(3, "Fizz"), (5, "Buzz"), (7, "Bang")] 21 → "FizzBang"ifstatements to a data-driven implementation without breaking any earlier test.
2. CSV Parser
Write a method:
List<List<String>> parse(String input)
that parses comma-separated values into rows of fields. The difficulty is
entirely in the quoting rules, so resist the temptation to reach for split.
- Basic Case: fields separated by commas, rows separated by newlines.
"a,b,c" → [["a", "b", "c"]] "a,b\nc,d" → [["a", "b"], ["c", "d"]] - Empty Fields: consecutive delimiters produce empty strings.
"a,,c" → [["a", "", "c"]] ",," → [["", "", ""]] - Quoted Fields: a field wrapped in double quotes has its quotes removed.
"\"a\",b" → [["a", "b"]] - Delimiters Inside Quotes: a comma or newline inside quotes is data, not a
delimiter.
"\"a,b\",c" → [["a,b", "c"]] "\"line1\nline2\",c" → [["line1\nline2", "c"]] - Escaped Quotes: a doubled quote inside a quoted field represents one literal
quote.
"\"say \"\"hi\"\"\",b" → [["say \"hi\"", "b"]] - Header Row: an overload returns a list of maps keyed by the header names.
"name,age\nAda,36" → [{"name": "Ada", "age": "36"}] - Malformed Input: an unterminated quoted field is an error.
"\"unterminated,b" → throw ParseException("unterminated quote at offset 0") - Extension: accept a configurable delimiter and quote character, and stream rows rather than returning them all, so a file larger than memory can be parsed.
3. Roman Numeral Converter
Write two methods:
String toRoman(int number)
int fromRoman(String numeral)
that convert between Arabic and Roman numerals. The conversion is a good test of whether you can find the pattern rather than enumerate cases.
- Basic Symbols: the seven values have direct representations.
1 → "I" 5 → "V" 10 → "X" 50 → "L" 100 → "C" 500 → "D" 1000 → "M" - Addition: symbols repeat and accumulate, at most three in a row.
3 → "III" 6 → "VI" 23 → "XXIII" - Subtraction: six specific pairs are written as a subtraction.
4 → "IV" 9 → "IX" 40 → "XL" 90 → "XC" 400 → "CD" 900 → "CM" - Combined: larger numbers compose the rules above.
1994 → "MCMXCIV" 3888 → "MMMDCCCLXXXVIII" - Round Trip: parsing your own output must return the original number, for
every value in the supported range.
for (int i = 1; i <= 3999; i++) assert fromRoman(toRoman(i)) == i; - Range Validation: only 1 to 3999 is representable.
0 → throw IllegalArgumentException("out of range: 0") 4000 → throw IllegalArgumentException("out of range: 4000") - Invalid Numerals: parsing rejects malformed input.
The round-trip property in step 5 is the most valuable test here, and is worth writing before the subtraction rules are implemented."IIII" → throw IllegalArgumentException("invalid numeral: IIII") "IC" → throw IllegalArgumentException("invalid numeral: IC")
4. Expression Evaluator (mini calculator with + - * /)
Write a method:
double evaluate(String expression)
that evaluates an arithmetic expression given as a string. This exercise is about parsing, and it is the one most likely to appear in a senior interview.
-
Single Number: an expression may be just a value.
"42" → 42.0 "3.5" → 3.5 -
Addition and Subtraction: evaluated left to right.
"1+2" → 3.0 "10-4-3" → 3.0 -
Multiplication and Division: higher precedence than addition.
"2+3*4" → 14.0 (not 20.0) "20/4-2" → 3.0 -
Parentheses: override precedence and may nest.
"(2+3)*4" → 20.0 "((1+2)*(3+4))" → 21.0 -
Whitespace: spaces anywhere are ignored.
" 2 + 3 * 4 " → 14.0 -
Unary Minus: a leading or post-operator minus negates.
"-5+3" → -2.0 "2*-3" → -6.0 -
Division by Zero: rejected explicitly rather than returning infinity.
"1/0" → throw ArithmeticException("division by zero") -
Malformed Input: report the position where parsing failed.
"2+" → throw ParseException("unexpected end of expression") "2++3" → throw ParseException("unexpected token '+' at position 2") "(2+3" → throw ParseException("missing closing parenthesis") -
Extension: add exponentiation with right associativity, so
"2^3^2"is 512 rather than 64, and add named functions such assqrt(16).Two implementations are worth knowing: the shunting-yard algorithm, which converts to postfix and evaluates with a stack, and a recursive descent parser with one method per precedence level. Implement one, then explain the trade-off between them.
5. Bowling Game Kata
Write a class:
class BowlingGame {
void roll(int pins); // called once per roll
int score(); // called only at the end of the game
}
that scores a game of ten-pin bowling. The kata is famous because the naive frame-by-frame model is much harder than the correct one.
- Gutter Game: twenty rolls of zero.
20 rolls of 0 → score 0 - All Ones: twenty rolls of one pin.
20 rolls of 1 → score 20 - One Spare: two rolls totalling ten in a frame. The next roll counts twice.
5, 5, 3, then 17 rolls of 0 → score 16 (10 + 3) + 3 = 16 - One Strike: ten pins on the first roll of a frame. The next two rolls count
twice.
10, 3, 4, then 16 rolls of 0 → score 24 (10 + 3 + 4) + 3 + 4 = 24 - Perfect Game: twelve strikes.
12 rolls of 10 → score 300 - Tenth Frame: a spare in the tenth frame grants one extra roll, and a strike
grants two. Those extra rolls score once, as bonuses only.
18 rolls of 0, then 10, 10, 10 → score 30 - Validation: reject impossible input.
The insight that makes this easy: store a flat list of rolls and walk it two at a time, rather than modelling frames as objects. If your solution is over forty lines, you have probably modelled frames.roll(11) → throw IllegalArgumentException("invalid pin count: 11") roll(-1) → throw IllegalArgumentException("invalid pin count: -1") score() before the game ends → throw IllegalStateException("game incomplete")
6. Prime Factors Kata
Write a method:
List<Integer> primeFactors(int n)
that returns the prime factors of a number in ascending order, with repetition. This kata is short and is used to demonstrate how tests can drive a solution that is simpler than the one you would have designed.
-
Smallest Cases: one has no factors.
1 → [] 2 → [2] 3 → [3] -
Repeated Factors: a factor appears once per occurrence.
4 → [2, 2] 8 → [2, 2, 2] -
Mixed Factors: ascending order, with repetition.
6 → [2, 3] 12 → [2, 2, 3] 2 * 2 * 3 * 5 * 7 → [2, 2, 3, 5, 7] -
Large Prime: a prime returns only itself.
7919 → [7919] -
Validation: zero and negatives are rejected.
0 → throw IllegalArgumentException("n must be greater than zero") -6 → throw IllegalArgumentException("n must be greater than zero") -
Performance: the method must handle
Integer.MAX_VALUEpromptly. Stop dividing once the candidate exceeds the square root of the remaining value.2147483647 → [2147483647] (it is prime) -
Extension: change the signature to
longand confirm every test still passes. Then write a property test asserting that multiplying the returned factors reproduces the input, for a thousand random values.Add one test at a time and refactor after each. The complete solution is about six lines, and watching it emerge is the point of the exercise.
7. Anagram Finder
Write a method:
List<String> anagramsOf(String word, List<String> candidates)
that returns the candidates that are anagrams of the given word. The exercise is about choosing the right data structure, which makes it a good complexity discussion.
- Basic Case: same letters, different order.
"listen", ["enlists", "google", "inlets", "banana"] → ["inlets"] - Identity: a word is not considered its own anagram.
"stone", ["stone", "tones"] → ["tones"] - Case Insensitivity: comparison ignores case, and the original casing is
preserved in the result.
"Listen", ["Silent", "LISTEN"] → ["Silent"] - No Matches: return an empty list rather than null.
"java", ["python", "kotlin"] → [] - Multiple Matches: order of the results follows the input order.
"abc", ["cab", "bca", "xyz"] → ["cab", "bca"] - Grouping: a second method groups a whole list into sets of mutual anagrams.
["eat", "tea", "tan", "ate", "nat", "bat"] → [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]] - Performance: the grouping method must handle 100,000 words. Compare two keys:
the sorted characters of each word, which is O(k log k) per word, and a
26-element frequency count, which is O(k). Measure both.
key("listen") → "eilnst" sorting key("listen") → "1:0:0:0:1:...:" counting - Extension: support Unicode input, at which point a 26-element array is no longer sufficient, and decide whether accented characters should be treated as equal to their unaccented forms.
8. String Calculator Kata Exercise
Write a method:
int add(String input)
that parses a string containing numbers separated by delimiters and returns their sum. The function must correctly handle default delimiters, custom delimiters, and edge cases.
- Basic Case: the input will contain zero or more integers separated by commas:
"" → return 0 "1" → 1 "1,2,3" → 6 - Support Newline as a Delimiter: the string can also contain newline (\n) characters as valid
delimiters:
"1\n2,3" → 6 - Custom Delimiters: the input may specify one or more custom delimiters at the start using this
format:
Examples://<delimiter>\n<numbers>"//;\n1;2;3" → delimiters = ;, sum = 6 "//|\n5|6|7" → 18 - Custom Delimiters of Any Length: if the delimiter is wrapped in brackets, it can be longer than one
character:
"//[***]\n1***2***3" → 6 - Multiple Custom Delimiters: you may also receive multiple delimiters, each wrapped in
brackets:
"//[*][%]\n1*2%3" → 6 "//[&&][|||]\n4&&5|||6" → 15 - Negative Numbers: if a negative number appears, throw an exception:
If multiple negatives:"1,-2,3" → throw IllegalArgumentException("Negatives not allowed: -2")"1,-2,-5" → "Negatives not allowed: -2, -5" - Ignore Numbers > 1000: numbers larger than 1000 must be ignored.
"2,1001,3" → 5