Strong Java answers connect a language rule to the state or lifecycle it protects. Explain what is immutable, what is shared, which action establishes visibility, who owns cancellation and cleanup, and what evidence would distinguish a performance hypothesis from a cause. The examples use Java 17 as their baseline; virtual threads require Java 21 or later, and the synchronized-block pinning guidance differs in Java 24 and later.
For: Mid-level to senior JVM application engineers; examples use Java 17 unless a later release is named.
Guide scope and versions
The guide treats Java as a language and JVM platform. It distinguishes portable Java contracts from HotSpot-specific diagnostics, and labels virtual threads as a Java 21+ feature. Java 24 changed monitor pinning behavior. It does not prescribe one framework or claim that a language tag identifies a job title.
Question 1 of 10
Do records make their values immutable?
A record makes its component fields final and derives accessors, equality, hashing and display from the component values. That gives a concise value-shaped API, but it does not recursively freeze objects referenced by those fields. A record containing a mutable list is still observably mutable through that list.
Records were finalized in Java 16 and are available in the Java 17 baseline. Their generated equality compares components using their equality semantics; it is not automatically deep equality for arbitrary object graphs. A record is a good fit when the public meaning of the type is the tuple of its declared components and callers benefit from transparent construction and value comparison. It is not a shortcut for a mutable entity whose identity, lifecycle or invariants are controlled by methods.
If a record accepts a mutable collection, decide whether it represents a snapshot or a live view. For a snapshot, copy on input and avoid returning a mutable reference. List.copyOf prevents mutation through the returned list, but it is a shallow copy: mutable elements remain mutable. The invariant belongs at the boundary, not in a comment that callers can ignore. Also distinguish a final reference from immutable state: final List<String> prevents rebinding the reference, not editing the list.
Java 17 · Worked example
record Batch(String id, List<String> labels) {
Batch { labels = List.copyOf(labels); }
}
var source = new ArrayList<>(List.of("new"));
var batch = new Batch("b-7", source);
source.add("late");
// batch.labels() remains ["new"]
batch.labels().add("bad");
// throws UnsupportedOperationException
The compact constructor reassigns the implicit constructor parameter before the generated field assignment, so the record stores a defensive list copy. List.copyOf rejects null elements and produces an unmodifiable list. If elements themselves were mutable objects, mutations to those objects would still be visible through the record.
Compiled and executed in an isolated fixture.
Follow-up: Would List.copyOf make a record safe as a cache key?
Only if the key's equality and hash inputs cannot change. The list structure is protected, but any mutable element can still change its own equality or hash code. Prefer immutable component values and test the exact key contract.
Why can a key disappear from a HashMap after mutation?
A hash-based map chooses a bucket using the key's hash when the entry is inserted. If a field used by equals or hashCode changes later, a lookup computes a different hash and searches another bucket. The entry is still present, but ordinary lookup may no longer find it. Equality and hash inputs should stay stable while an object is a key.
The contract has two parts: equal objects must have equal hash codes, and equality must remain a consistent equivalence relation while the compared state stays unchanged. A map cannot relocate an entry when a key mutates because it does not receive a notification that the key changed. HashMap stores the computed hash alongside the entry and compares candidates in the selected bin. Even if a particular mutation happens to preserve the hash, changing equality can still make behavior surprising or violate assumptions in sets and caches.
Use an immutable key made from stable identity fields, or remove an entry before changing those fields and reinsert it after. Do not mutate objects reachable from a key if their values contribute to its hash. Records help only when their components and equality semantics are stable; a record containing a mutable list can still be an unsafe key. The same reasoning applies to HashSet, which is backed by a map, and to caches whose key behavior spans more than one method call.
Java 17 · Worked example
final class AccountKey {
String tenant;
AccountKey(String tenant) { this.tenant = tenant; }
public boolean equals(Object o) {
return o instanceof AccountKey k && tenant.equals(k.tenant);
}
public int hashCode() { return tenant.hashCode(); }
}
var key = new AccountKey("north");
var map = new HashMap<AccountKey, String>();
map.put(key, "account-9");
key.tenant = "south";
// map.get(key) is typically null: lookup uses the new hash
The map inserted the key using the hash for north. After mutation, lookup hashes south, so it searches according to the new value. The example intentionally uses a mutable class to expose the contract violation; production keys should have controlled, immutable equality state.
Compiled and executed in an isolated fixture.
Follow-up: Can a mutable value safely appear in a key if it is excluded from equals and hashCode?
Yes, if it truly has no effect on equality, hashing or the key's externally promised identity. Keep that exclusion explicit and ensure callers cannot infer that the omitted state changes key identity.
What does type erasure mean, and how do wildcards express variance?
Java checks generic type arguments at compile time, then erases most parameterized types in bytecode to their raw class or bound. Generic collections therefore do not carry their element type as ordinary runtime type information. At API boundaries, ? extends T is useful for reading values as T, while ? super T is useful for writing T values; the wildcard describes permitted use, not a different runtime container.
Erasure preserves compatibility with older bytecode and avoids creating a separate runtime class for each type argument. The compiler inserts casts at reads and may generate bridge methods to preserve overriding. This is why new List<String>[10] is illegal: the runtime cannot reliably enforce the array's parameterized component type. It is also why value instanceof List<String> is not generally available, while a check against List<?> is reifiable. Reflection can expose some generic declaration metadata, but that does not mean each list object remembers its actual type argument.
Java generic types are invariant: a List<Integer> is not a List<Number>, because if it were, a caller could add a Double. Wildcards make safe capabilities explicit. A producer can be List<? extends Number> and yield Number values, but you cannot add a concrete number because the actual subtype is unknown. A consumer can be List<? super Integer> and accept integers, while reads are only guaranteed as Object. The mnemonic PECS is a first approximation; design the API around what it reads and writes, and avoid wildcards when an exact type is clearer.
Java 17 · Worked example
static <T> void copy(List<? extends T> source,
List<? super T> destination) {
for (T value : source) destination.add(value);
}
List<Integer> ids = List.of(2, 5);
List<Number> numbers = new ArrayList<>();
copy(ids, numbers);
// numbers is [2, 5]
The source produces values that are assignable to T; the destination consumes T. Type checking proves the copy is safe without making List<Integer> a subtype of List<Number>. At runtime the list objects do not retain those generic arguments as ordinary class identity.
Compiled and executed in an isolated fixture.
Follow-up: Why can Kotlin use reified type parameters in some inline functions if JVM generics are erased?
Kotlin inlines the function body at the call site and can substitute the concrete type information needed for supported checks there. This is a language/compiler feature with constraints, not evidence that the underlying generic object stores its type argument.
Does `volatile` make a compound update thread-safe?
volatile gives visibility and ordering guarantees for accesses to that variable, but it does not make a read-modify-write sequence atomic. Two threads can both read the same old value and overwrite each other's increment. Use an atomic class for a supported atomic operation, or a lock when several fields must change as one invariant.
The Java Memory Model defines happens-before relationships that constrain which writes another thread may observe. A write to a volatile field happens-before subsequent reads of that field. Unlocking a monitor happens-before a later lock of the same monitor. These are publication and ordering guarantees. Thread.sleep is not a synchronization action, so adding a delay to a polling loop does not safely publish a regular field. A correct answer should identify the communication edge rather than say that the scheduler will eventually make the write visible.
Visibility does not combine multiple operations into one indivisible action. count++ reads count, adds one, then writes the result. Even if each individual volatile read and write is visible, interleaving can lose updates. AtomicInteger.incrementAndGet provides an atomic increment for one counter; it does not make a separate limit check and increment jointly atomic. If the invariant spans multiple variables or conditions, protect the entire transition with one lock or another coordination primitive.
Java 17 · Worked example
volatile int count = 0;
void increment() {
count++; // read, add, write; two callers can lose one update
}
final AtomicInteger safeCount = new AtomicInteger();
void incrementSafely() {
safeCount.incrementAndGet();
}
The volatile field allows readers to observe writes under the memory model but does not make the compound increment indivisible. AtomicInteger offers an atomic operation on the counter itself. If the operation also needs to enforce a bound or update another field, use a larger atomic protocol or a lock around the full invariant.
Compiled and executed in an isolated fixture.
Follow-up: When is a volatile boolean appropriate?
A one-way stop flag can be appropriate when the flag is the only shared state being published and the worker checks it cooperatively. If shutdown also needs to coordinate queues, resource closure or a final result, define those actions' ordering and use a broader lifecycle protocol.
Cancelling a Future requests cancellation; it does not guarantee that the computation has stopped or undo side effects already performed. Interruption-based cancellation works only when the task and the libraries it calls respond to interruption. A reliable design propagates a deadline or cancellation signal, closes owned resources, and makes externally visible work safe to retry or reconcile.
Future.cancel(true) may interrupt the worker if the implementation knows it, while cancel(false) does not. Interruption is cooperative: blocking interruptible methods may throw InterruptedException, and CPU loops need to check the interrupt status. Code that catches InterruptedException should usually restore the interrupt with Thread.currentThread().interrupt() when it cannot propagate the exception. Swallowing the signal can make executor shutdown wait indefinitely. A cancelled future can report cancellation even if user code continues running after ignoring the signal.
Use ExecutorService ownership to define task lifecycle. shutdown stops accepting work and lets submitted tasks finish; it does not wait. shutdownNow makes a best-effort interruption request and returns tasks that never started; it also does not promise active tasks have terminated. Java 21 adds virtual threads as a practical per-task execution option for blocking I/O workloads. They do not make CPU-bound work faster, remove downstream capacity limits, or eliminate cancellation/resource ownership. In Java 21–23, long blocking while pinned inside synchronized can reduce scalability; Java 24's JEP 491 changed monitor behavior so synchronized code no longer pins virtual threads in that way. Native/foreign calls still need attention.
This bounds the caller's wait and requests interruption after the timeout. It is not a proof that the remote operation was cancelled or that the client stops promptly. The client must honor interruption or its own timeout, and any write operation needs idempotency or reconciliation. Closing the executor waits for submitted tasks to terminate, so an uncooperative task can still delay scope exit.
Compiled and executed in an isolated fixture.
Follow-up: Should virtual threads be put in a fixed thread pool to limit downstream load?
Use a separate explicit concurrency limit for the scarce downstream resource, such as a semaphore or the client's connection pool. A virtual-thread-per-task executor represents tasks; it is not a capacity limiter. This keeps the resource bound visible and avoids treating a thread pool as both scheduler and admission-control policy.
How does try-with-resources preserve the primary failure?
Try-with-resources closes each declared resource when the block exits, including when the block throws. If the body fails and closing also fails, the body exception remains primary and close failures are attached as suppressed exceptions. This keeps cleanup errors inspectable without replacing the original cause of the failed operation.
Resources close in the reverse order of their declaration. If resource A depends on resource B, declare B first and A second: A, the dependent, closes first while B is still available, then B closes. AutoCloseable.close() can throw, and cleanup is part of the control flow rather than an incidental detail. Code that catches and discards close failures can hide a partially written file, an unflushed buffer or a failed transaction boundary. The primary exception is available through the normal catch; inspect getSuppressed() when diagnosing additional close failures.
The same ownership reasoning applies to JDBC resources. Close ResultSet, Statement and Connection deterministically, usually with nested or multi-resource try-with-resources. Do not confuse closing a connection with committing a transaction: explicitly choose commit or rollback according to the unit-of-work outcome, and know the pool's contract for returning a connection. A resource scope should match the smallest operation that owns the handle.
Java 17 · Worked example
try (var resource = new AutoCloseable() {
public void close() throws Exception {
throw new IOException("close failed");
}
}) {
throw new IllegalStateException("write failed");
} catch (Exception primary) {
System.out.println(primary.getMessage());
System.out.println(primary.getSuppressed()[0].getMessage());
}
// prints: write failed
// then: close failed
The exception thrown by the try body remains the caught IllegalStateException. The close failure is attached to it and can be inspected through getSuppressed(). Real code should preserve exception types and report the entire cause chain rather than print messages as the example does.
Compiled and executed in an isolated fixture.
Follow-up: If two resources fail while closing, which close exception is primary?
The first close failure encountered is attached as suppressed to the exception already in flight, or becomes the primary exception if the body completed normally. Subsequent close failures are also suppressed. Declaration order determines the reverse closing order.
Why is a database transaction not enough to make a request idempotent?
A database transaction can make a set of database changes atomic within that database, but it cannot atomically include an unrelated email, payment provider or message broker without a protocol spanning those systems. A timeout can leave the caller unsure whether a remote side effect happened. Idempotency keys, durable intent records and reconciliation make retries safe to reason about.
A transaction protects a database unit of work under its configured isolation and durability semantics. It does not guarantee exactly-once execution across a process crash and multiple independent services. For example, committing an order and then publishing an event can fail between the two operations. Publishing first can create the inverse inconsistency if the database transaction rolls back. A common outbox design commits the business row and an event-intent row together, then a separate publisher retries delivery; consumers still need deduplication because delivery can repeat.
Idempotency is a property of the operation's externally visible effect, not merely a retry loop. Store a request key with a uniqueness constraint and the resulting state or response, so a repeated request can return the established outcome instead of applying a second charge or reservation. Define retention, key scope, payload mismatch behavior and recovery of in-progress operations. For database-only effects, a conditional update or unique constraint can enforce the invariant even when concurrent requests race. The correct design depends on the business state transition and provider guarantees; do not promise universal exactly-once delivery.
PostgreSQL 16+ SQL pattern (application-supplied positional parameters) · Worked example
BEGIN;
WITH inserted_order AS (
INSERT INTO orders (id, request_key, request_hash, status)
VALUES ($1, $2, $3, 'accepted')
ON CONFLICT (request_key) DO NOTHING
RETURNING id
)
INSERT INTO outbox (event_id, order_id, type)
SELECT $4, id, 'OrderAccepted'
FROM inserted_order
RETURNING order_id;
COMMIT;
-- If RETURNING yields no row: load the existing order by request_key.
-- If its request_hash matches, return its saved/canonical response;
-- if it differs, reject reuse of the key as a conflict. Do not add
-- another outbox event for a replay. The key must be unique and bound
-- to the request's intended scope and payload semantics.
-- Publisher retries delivery; consumers still deduplicate event_id.
This PostgreSQL-specific pattern inserts an outbox event only from the row returned by a newly inserted order. On a request-key conflict, ON CONFLICT DO NOTHING returns no inserted row, so this statement creates no new event. The application must then read the existing order and compare a canonical request hash (or equivalent stored request identity): a matching retry returns the established result, while the same key with a different payload is rejected. The key's uniqueness scope and retention must match the API contract. Keep that conflict lookup in the transaction/request flow with appropriate isolation and retry handling for serialization or transient failures. The outbox row and new order commit atomically; a publisher can still deliver more than once, so the consumer must be idempotent. The SQL body is executed against temporary PostgreSQL tables in an isolated fixture, which checks the insert, replay, mismatched-key conflict, and single outbox event. A production implementation still needs its surrounding schema and request handler.
Compiled and executed in an isolated fixture.
Follow-up: How would you test a request timeout after the provider accepted a payment?
Use a deterministic fake provider that records the idempotency key and can accept the operation while withholding the response. Retry the same request, assert one provider-side effect and a stable local outcome, then test reconciliation when the provider result remains unknown. Avoid a live charge in a unit test.
Intermediate stream operations such as filter and map are lazy. They build a pipeline but do not traverse the source until a terminal operation begins. A short-circuiting terminal operation can stop once it has enough information, while stateful operations such as sorting may need to inspect or buffer much more input.
A stream is a one-use view of a source, not a stored collection. map returns another stream and normally does not call the mapper immediately. A terminal operation such as count, collect or findFirst triggers traversal. This lets the implementation fuse simple operations into one pass and avoid work after a result is known. It also means side effects placed in an intermediate lambda may happen later than the surrounding code suggests, or fewer times than the source size when a terminal operation short-circuits.
Correct stream functions should be non-interfering and generally stateless. Mutating the source while traversal is underway can cause incorrect results or exceptions. Parallel streams add scheduling and ordering concerns: a side effect may run on different threads, and forEach does not promise encounter order. Use reductions or collectors for aggregation, and choose parallel execution only after measuring a representative workload and confirming the operation can be partitioned safely. A stream is consumed after its terminal operation; obtain a new stream to traverse the source again.
Java 17 · Worked example
var seen = new ArrayList<Integer>();
var pipeline = Stream.of(1, 2, 3)
.peek(seen::add)
.filter(n -> n > 1);
// seen is still []
var first = pipeline.findFirst();
// first is Optional[2]; seen is [1, 2]
The intermediate pipeline has not run before findFirst. During terminal traversal, the first item fails the filter and the second matches, so short-circuiting stops before the third item. peek is used here only to reveal timing; application logic should not rely on stream side effects for correctness.
Compiled and executed in an isolated fixture.
Follow-up: Can the source collection change between stream creation and the terminal operation?
For ordinary collection streams, a modification made before terminal traversal may be reflected, but modifying a non-concurrent source during traversal violates the non-interference requirement. Prefer an explicit snapshot or synchronization if concurrent mutation is part of the use case.
How do you investigate a garbage-collection performance report?
Start with the user-visible symptom and a recording from the same workload window. Separate allocation rate, retained live data, pause time and CPU cost. Garbage collection is an automatic memory-management mechanism, not a diagnosis: changing collectors or heap size before identifying the limiting behavior can hide the cause or move it elsewhere.
First define the symptom: request latency spikes, throughput loss, memory growth, allocation pressure or an out-of-memory failure. Collect bounded evidence with the JDK version, collector, heap settings, workload rate and a time-aligned application recording. Flight Recorder can show garbage-collection phases and pause events. A long collection is not automatically a long application pause because some collectors do substantial work concurrently; total stop-the-world pause time and its relationship to tail latency are often more useful than one duration in isolation.
Look for repeated patterns. A high allocation rate with a stable post-collection live set suggests churn; a steadily rising live set may indicate retention or a workload that genuinely needs more memory. Correlate allocation profiles and heap reachability with the request path. Compare before and after under the same load and JDK, changing one relevant variable at a time. Avoid triggering expensive heap statistics during a recording if the diagnostic itself perturbs the workload. Collector selection involves pause, throughput and footprint trade-offs; evidence from another JDK or machine does not establish your production cause.
Follow-up: What would make you change the collector?
A reproducible workload comparison showing that the current collector's measured pause or throughput behavior misses a stated service objective, while an alternative improves that objective without unacceptable CPU or memory cost. Record the JDK, flags, workload and rollback criterion so the result is interpretable.
How do you test a concurrency invariant without relying on timing?
Test observable state transitions with controlled coordination rather than sleeping and hoping a race occurs. Use latches, barriers or a deterministic fake to force the relevant interleaving, then assert the invariant and cleanup behavior. A stress test can add evidence, but it should not replace a small reproducible test of the contract.
A test such as “start two threads, sleep 50 milliseconds, assert the result” depends on scheduler timing, machine load and test-runner contention. It may pass while the race remains or fail without a product defect. Instead, arrange a barrier so both workers have read the same initial state before either writes, then release them together. For a correct atomic implementation, assert the final count or uniqueness property; for a guarded operation, assert that exactly one contender obtains the scarce resource. Bound every wait so a regression fails instead of hanging the suite.
Test cancellation and resource ownership separately. A task fixture can block on an interruptible latch, then assert that cancellation reaches the task and that its finally block closes a tracked resource. If the code wraps JDBC or HTTP, use a fake or local test double that can inject failure before acceptance, after acceptance and during response delivery. Keep tests at the boundary where the invariant lives; avoid coupling them to a particular thread name, exact scheduling order or undocumented executor internals.
Java 17 test sketch · Worked example
var ready = new CountDownLatch(2);
var release = new CountDownLatch(1);
Runnable contender = () -> {
ready.countDown();
await(release);
reservation.tryAcquire();
};
Future<?> first = executor.submit(contender);
Future<?> second = executor.submit(contender);
assertTrue(ready.await(1, SECONDS));
release.countDown();
first.get(1, SECONDS);
second.get(1, SECONDS);
assertTrue(reservation.acquiredCount() == 1);
Both contenders reach the latch before the test releases them. The two bounded Future.get calls wait for both tasks to complete before the test asserts that exactly one acquired the reservation. This checks the completed outcome rather than a snapshot taken while work is still running.
Compiled and executed in an isolated fixture.
Follow-up: Is a stress test ever useful?
Yes. Repeated randomized schedules can discover interleavings that a focused fixture missed. Keep it bounded and report the seed or trace where possible, while retaining deterministic tests for the invariant and the regression that was found.
These are Hacker News job posts that mention these technologies, not employee earnings or a measure of what a skill adds to pay. The amounts summarize annualized advertised minimums in approximate USD. Groups can overlap and differ in roles, locations and dates.
Approximate USD using ECB daily reference rates as of 2026-10-09. Original currencies and offer ranges remain on the source listings. These figures cover posts tagged Java, rather than every job in the broader technology family.