Production-depth explanations, real interview Q&A, code internals, architecture tradeoffs — everything a Lead/Senior Java Engineer needs to crack the interview.
| GC | Use Case | STW Pauses | Throughput | Latency |
|---|---|---|---|---|
| Serial GC | Small apps | High | Low | High |
| Parallel GC | Batch processing | Medium | High | Medium |
| CMS | Low latency (deprecated) | Low | Medium | Low |
| G1GC | Microservices (default Java 9+) | Predictable | High | Low |
| ZGC | Ultra-low latency, large heaps | < 1ms | Medium | Ultra-low |
| Shenandoah | Red Hat, concurrent evacuation | < 10ms | Medium | Very Low |
Why is G1GC preferred in microservices over CMS?
G1GC divides heap into equal-sized regions (~1–32MB) and collects the regions with most garbage first — hence "Garbage-First". Key advantages over CMS:
In payment microservices, a 2-second GC pause can cause SLA breach. G1GC gives predictable sub-200ms pauses.
Don't say "G1GC is always better". For batch jobs that tolerate pauses, Parallel GC gives higher throughput. Know the tradeoff.
# G1GC for microservice (recommended) -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m -Xms2g -Xmx4g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/heapdump.hprof # ZGC for ultra-low latency -XX:+UseZGC -Xmx16g -XX:+UnlockExperimentalVMOptions
Without volatile, Thread 2 reads stale value from its CPU cache
Why does volatile NOT guarantee thread safety?
volatile provides:
But NOT atomicity. Example: count++ is 3 ops — READ, INCREMENT, WRITE. Two threads can read the same value simultaneously → race condition.
Use AtomicInteger or synchronized for compound operations.
// ❌ NOT thread safe — race condition volatile int count = 0; count++; // READ + INCREMENT + WRITE = 3 ops // ✅ Thread safe — atomic CAS AtomicInteger count = new AtomicInteger(0); count.incrementAndGet(); // ✅ ReentrantLock — more control than synchronized ReentrantLock lock = new ReentrantLock(); try { lock.lock(); // critical section } finally { lock.unlock(); // ALWAYS in finally! } // ✅ CompletableFuture — async orchestration CompletableFuture.supplyAsync(() -> fetchPayment()) .thenApplyAsync(p -> validatePayment(p)) .thenAcceptAsync(p -> processPayment(p)) .exceptionally(e -> { log(e); return null; });
| Parameter | Meaning | Interview Insight |
|---|---|---|
| corePoolSize | Min threads always alive | Set = CPU cores for compute tasks |
| maxPoolSize | Max threads under load | For IO tasks, can be higher than cores |
| Queue type | LinkedBlockingQueue (unbounded!) vs SynchronousQueue | Unbounded queue = OOM risk in high load! |
| RejectionPolicy | AbortPolicy, CallerRunsPolicy, DiscardPolicy | CallerRunsPolicy = natural backpressure |
| keepAliveTime | Time excess threads idle before kill | Tune for burst traffic patterns |
What is a deadlock and how do you prevent it in a payment system?
Deadlock: Thread A holds Lock1 waits for Lock2. Thread B holds Lock2 waits for Lock1 → circular wait.
In payments: use database row-level locking (pessimistic) or optimistic locking with version column rather than Java locks for cross-service scenarios.
Java 7: Segment-based locking — 16 segments, each a mini hashtable. Max 16 concurrent writes.
Java 8+: Node-level CAS + synchronized on bucket head. No segments. Lock only contended bucket. Uses Red-Black tree when bucket size > 8 (same as HashMap).
Interview Gold: get() is completely lock-free in Java 8 — uses volatile reads.
How does Spring Boot auto-configuration work internally?
Example: If DataSource class is on classpath + no user-defined DataSource bean → Spring Boot auto-creates one from application.properties.
Why does @Transactional fail when called from within the same class (self-invocation)?
Spring @Transactional uses AOP proxies. When you inject a bean, you get the proxy, not the real object.
Solutions: Inject self (@Autowired ApplicationContext ctx; ctx.getBean(this.getClass())) or extract to separate bean, or use AspectJ mode (compile-time weaving).
This is a very common production bug in banking apps. A method calling another transactional method in the same service = silent data consistency failure.
@Service public class PaymentService { // ❌ This @Transactional will be IGNORED public void processPayment(Payment p) { this.savePayment(p); // self-invocation! proxy bypassed } @Transactional public void savePayment(Payment p) { // no transaction! repo.save(p); } // ✅ Fix: inject self or move to another bean @Autowired private PaymentService self; // inject proxy public void processPayment(Payment p) { self.savePayment(p); // goes through proxy ✅ } }
| Scope | Lifecycle | Thread Safe? | Use Case |
|---|---|---|---|
| singleton | One per app context | No — shared state is risky | Stateless services, repos |
| prototype | New per injection | Yes | Stateful beans |
| request | Per HTTP request | Yes | Web layer, user context |
| session | Per HTTP session | Yes | User session state |
Singleton beans are not inherently thread-safe. If you inject a singleton bean with a mutable field, you have a race condition. Always use stateless services or ThreadLocal for per-request state.
How do you handle distributed transactions across Payment Service and Account Service? What patterns would you use at Wells Fargo?
Two-Phase Commit (2PC) — avoid in microservices: tight coupling, coordinator is SPOF, blocking protocol.
Saga Pattern (preferred for payments):
Payment Saga Example:
@SagaOrchestrator public class PaymentSaga { @StartSaga @SagaEventHandler(associationProperty = "paymentId") public void handle(InitiatePaymentCmd cmd) { commandGateway.send(new DebitAccountCmd(cmd.getFromAccount())); } @SagaEventHandler(associationProperty = "paymentId") public void handle(AccountDebitedEvent event) { commandGateway.send(new CreditAccountCmd(event.getToAccount())); } // Compensating transaction — rollback on failure @SagaEventHandler(associationProperty = "paymentId") public void handle(PaymentFailedEvent event) { commandGateway.send(new ReverseDebitCmd(event.getPaymentId())); } }
How do you prevent duplicate payment processing? (Client retries same payment twice)
| System | C | A | P | Trade-off |
|---|---|---|---|---|
| Kafka | ✗ | ✓ | ✓ | AP — eventual consistency |
| Zookeeper | ✓ | ✗ | ✓ | CP — rejects during partition |
| Cassandra | ✗ | ✓ | ✓ | AP — tunable consistency |
| Oracle RDBMS | ✓ | ✓ | ✗ | CA — single node, no partition |
| Redis Cluster | ✗ | ✓ | ✓ | AP — async replication |
| Level | Dirty Read | Non-Repeatable | Phantom Read | Use Case |
|---|---|---|---|---|
| READ_UNCOMMITTED | Yes | Yes | Yes | Never use in banking |
| READ_COMMITTED | No | Yes | Yes | Default Oracle — balance reads |
| REPEATABLE_READ | No | No | Yes | Consistent reports |
| SERIALIZABLE | No | No | No | Financial transfers (high lock cost) |
Your query was fast initially but became slow after 3 months with 10M rows. What would you investigate?
How do you implement distributed locking with Redis?
Use Redlock algorithm (Redis distributed lock):
How does Kafka guarantee message ordering? How do you maintain order in payment processing?
For payments: partition by accountId. Consumer processes payment events (INITIATED → PROCESSING → COMPLETED) in correct order.
Difference between at-least-once and exactly-once in Kafka?
| Semantic | How | Risk | Use In Payments? |
|---|---|---|---|
| At-most-once | Commit before process | Data loss | Never |
| At-least-once | Commit after process | Duplicates | With idempotency |
| Exactly-once | Kafka transactions | Higher latency | Ideal for payments |
Enable: enable.idempotence=true + isolation.level=read_committed on consumer + Kafka transactions API.
// Exactly-once producer config props.put("enable.idempotence", "true"); props.put("acks", "all"); // wait for all ISR props.put("retries", Integer.MAX_VALUE); props.put("max.in.flight.requests.per.connection", 5); props.put("transactional.id", "payment-producer-1"); // Partition by accountId for ordering producer.send(new ProducerRecord<>( "payments", payment.getAccountId(), // KEY = partition selector payment.serialize() ));
| Strategy | How It Works | Downtime | Rollback Speed | Best For |
|---|---|---|---|---|
| Rolling | Replace pods one-by-one | None | Slow | Normal deploys |
| Blue-Green | Deploy to new env, switch traffic | Near-zero | Instant | Critical payment APIs |
| Canary | Route 5% traffic to new version | None | Fast | Feature validation |
| Recreate | Kill all, start new | Yes | N/A | Dev/test only |
For payment APIs at a bank: Blue-Green deployment is preferred — instant rollback if new version has issues. Canary used for gradual feature rollout with A/B metrics monitoring (payment success rate, latency). Never use Recreate in production.
apiVersion: apps/v1 kind: Deployment metadata: name: payment-service spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 1 extra pod during update maxUnavailable: 0 # zero downtime template: spec: containers: - name: payment-service resources: requests: {memory: "512Mi", cpu: "500m"} limits: {memory: "1Gi", cpu: "1000m"} readinessProbe: httpGet: {path: /actuator/health, port: 8080} initialDelaySeconds: 30
When designing: start with requirements → scale estimate → high-level design → deep dive → tradeoffs → failure modes → monitoring. Always discuss what you'd do differently given different constraints. Interviewers want to see architect thinking, not just coding knowledge.
Tell me about a major production outage you handled. Walk me through your decision-making.
Always end with what systemic change you made to prevent recurrence. That's what separates a lead from a developer.
Tell me about a time you disagreed with a technical decision. How did you handle it?
How do you mentor junior engineers?
This is the first elimination round — master it.
Heart of the role — speak like an architect.
Infrastructure depth that separates leads.
The final round — this decides selection.