Java Mastery¶
JVM, JIT (C1/C2/Graal), GC (G1/ZGC), Loom, modern idioms.
Printing this page
Use your browser's Print → Save as PDF. The print stylesheet hides navigation, comments, and other site chrome; pages break cleanly at section boundaries; advanced content stays included regardless of beginner-mode state.
Java Mastery Blueprint - A 24-Week Master-Level Syllabus¶
Authoring lens: Senior Staff Software Engineer / JVM Performance Architect.
Target outcome: A graduate of this curriculum should be capable of (a) submitting non-trivial PRs against the OpenJDK (HotSpot, javac, or java.base), (b) owning a high-throughput JVM-based platform (Kafka-class, Cassandra-class, or a Spring/Quarkus control plane), or (c) operating a hyperscale fleet of JVM services with a coherent GC, JFR, and observability story.
This is not "Java in 24 Hours" stretched to weeks. It assumes the reader can already write a working program in some language. The premise: most Java bugs at scale are not language bugs - they are JIT, GC, classloader, and memory-model bugs in disguise, plus an industrial sediment of pre-Java-8 idioms still living in production. This curriculum surfaces all of those.
Repository Layout¶
| File | Purpose |
|---|---|
00_PRELUDE_AND_PHILOSOPHY.md |
The "Java-ness" of Java; the JVM-as-platform mindset; the cost model; reading list. |
01_MONTH_LANGUAGE_AND_TOOLCHAIN.md |
Weeks 1–4. Modern syntax, records, sealed types, pattern matching, build tools, JPMS, JUnit 5. |
02_MONTH_JVM_AND_BYTECODE.md |
Weeks 5–8. Class loading, bytecode, tiered JIT (C1/C2/Graal), AOT (Leyden), GraalVM native-image. |
03_MONTH_MEMORY_AND_GC.md |
Weeks 9–12. Heap layout, compact object headers, G1, ZGC (generational), Shenandoah, JFR-driven tuning. |
04_MONTH_CONCURRENCY_AND_LOOM.md |
Weeks 13–16. JMM, java.util.concurrent, virtual threads, structured concurrency, scoped values, VarHandle. |
05_MONTH_PRODUCTION_DISTRIBUTED.md |
Weeks 17–20. Spring Boot 3 / Quarkus, Micrometer + OpenTelemetry, gRPC, resilience, hardened testing. |
06_MONTH_CAPSTONE.md |
Weeks 21–24. Consensus, distributed storage, perf tuning, capstone defense. |
APPENDIX_A_PRODUCTION_HARDENING.md |
jcmd, JFR, async-profiler, heap dumps, JMH, GC logs, container-aware flags. |
APPENDIX_B_LEGACY_JAVA.md |
The sediment: Vector/Hashtable, Date/Calendar, raw types, Thread-spawning, finalize, JNI, JUnit 4, J2EE → Jakarta, removed features. |
APPENDIX_C_CONTRIBUTING_TO_OPENJDK.md |
JBS, mailing lists, jcheck, Skara, webrev, first-patch playbook. |
CAPSTONE_PROJECTS.md |
Three terminal projects: Raft KV store on virtual threads, gRPC mesh, streaming pipeline. |
How Each Week Is Structured¶
Every weekly module follows the same five-section format:
- Conceptual Core - the why, with a mental model.
- Mechanical Detail - the how, down to JVM source where relevant (
hotspot/share/runtime/,hotspot/share/gc/,java.base/share/classes/). - Lab - a hands-on exercise that cannot be completed without internalizing the concept.
- Idiomatic & Static-Analysis Drill - read 2–3 SpotBugs/ErrorProne/IntelliJ-inspection rules, refactor a sample to silence them, understand why each lint exists.
- Production Hardening Slice - a JFR /
jcmd/ async-profiler / JMH micro-task that compounds into a publishable hardening template.
Each week is sized for ~12–16 focused hours. Skip the labs at your peril.
Progression Strategy¶
The phases form a dependency DAG, not a linear track:
Language & Toolchain ──► JVM & Bytecode ──► Memory & GC ──► Concurrency & Loom
│ │ │ │
└──────────────────────┴────────┬─────────┴──────────────────┘
▼
Production & Distributed Systems
│
▼
Capstone & Defense
The Production Hardening slice is intentionally orthogonal - it accumulates a hardening/ template that, by week 24, is a publishable JVM service starter (JFR profile, GC log parser, container-aware flags, k8s manifest, OpenTelemetry wiring).
Non-Goals¶
- This curriculum does not make Spring the protagonist. Spring Boot 3 and Quarkus appear in Month 5 as integration surfaces;
java.net.http,jdk.httpserver, and the JDK's HTTP/2 client are sufficient through Month 4. - Android Java is out of scope; the JVM here is HotSpot/GraalVM on the server, not ART on a phone.
- "Why Java is better than X" advocacy is explicitly avoided. The reader should finish the program able to argue against using Java when it is the wrong tool (cold-start-sensitive serverless without native-image; embedded/real-time without Azul Zing-class tooling; small CLIs where Go or Rust dominate).
Capstone Tracks (pick one in Month 6)¶
- Distributed Storage Track - a Raft-replicated key-value store built on virtual threads with structured concurrency, linearizable reads, snapshot/restore, multi-region demo.
- Service Mesh Track - a gRPC microservices mesh with a custom service registry, health checking, deadline propagation, outlier ejection, and OpenTelemetry spans end-to-end.
- Streaming Pipeline Track - a Kafka-protocol-compatible ingestion + stream-processing pipeline with at-least-once delivery, replay, and JFR-driven backpressure tuning.
Details in CAPSTONE_PROJECTS.md.
Versioning Note¶
This curriculum targets Java 25 LTS as the baseline. By 25 LTS, the following are stable (no longer preview/incubator):
- Virtual threads (final since 21), structured concurrency (final by 25), scoped values (final by 25).
- Pattern matching for
switch, record patterns, unnamed patterns/variables. - Sealed classes, records, text blocks,
var, switch expressions. - Generational ZGC (default since 23+), ZGC as a first-class GC.
- The Class-File API (JEP 484) replacing internal ASM usage.
- The Foreign Function & Memory API (Panama) - replaces JNI for almost all new work.
- The Vector API (still incubating through 25 - flagged as such in Month 2).
- Compact object headers (JEP 450, stable by 24/25) - material to Month 3 sizing math.
Preview/incubator in the 25 timeframe (flagged when used): Vector API, Stable Values, Valhalla value classes (still preview at 25 - treat as "the future you should know about", not the daily driver).
Do not start this curriculum on a JDK older than 21 - too many of the modern idioms (virtual threads, pattern matching, records-as-DTOs, sealed hierarchies) will be unavailable, and you will accidentally learn 2014-era Java.
Legacy reality check: roughly half the Java code you will be paid to maintain is Java 8 or 11. APPENDIX_B_LEGACY_JAVA.md is not optional - it is the field guide for the sediment.
Prelude - The Philosophy Behind the Syllabus¶
Sit with this document for an evening before week 1. The rest of the curriculum is mechanically dense; this is the only chapter where we step back and define the shape of the discipline.
1. Java Is a Platform, Not a Language¶
The most damaging misconception a Java engineer can hold is that "Java is just C++ with garbage collection." A working master-level practitioner thinks the inverse:
Java is a platform - a sophisticated bytecode VM with a tiered JIT, a concurrent garbage collector, a class-loading model with security boundaries, and a 30-year-old standard library - that ships with a deliberately conservative language attached.
Almost every interesting performance bug in production Java has its root in the JVM (JIT deoptimization, GC pause, classloader leak, allocation-rate spike, false-shared cache lines), not in the language semantics. Almost every elegant high-throughput Java architecture is a thin layer over JVM primitives: MethodHandle, VarHandle, ForkJoinPool, VirtualThread, MemorySegment, JFR.
Internalize this and the rest of the curriculum makes sense.
2. The Five-Axis Cost Model¶
A working Java engineer reasons about every line of code along five axes simultaneously:
| Axis | Question to ask |
|---|---|
| Allocation | Does this allocate? On TLAB or outside? Can the JIT scalar-replace it via escape analysis? |
| JIT shape | Will C2 inline this? Is the call site monomorphic, bimorphic, or megamorphic? Will a profile pollution force a deopt? |
| GC pressure | How much live data does this add? Young-gen churn or tenured? Pointer-heavy or primitive-packed? |
| Concurrency safety | Is this safely published? Visible across threads? Synchronized, volatile, VarHandle, or accidentally lock-free-and-wrong? |
| Failure | What happens on InterruptedException? On OutOfMemoryError? On a classloader leak? On a StackOverflowError deep in a virtual thread? |
Beginner courses teach axis 4 only (and incompletely, via synchronized). This curriculum forces all five into your hands by week 12.
3. The "Java Way" - Aesthetic as Engineering Constraint¶
Java's design ethic, post-Java-8, is "readable, refactorable, profileable." That phrase is doing more work than newcomers think. Specifically:
- Composition over inheritance, finally. Sealed hierarchies + records + pattern matching deliver algebraic data types. Reach for
extendsonly when truly modeling an "is-a" with shared state; reach forsealed interface+recordfor everything else. - Exceptions are control flow you reluctantly accept. Checked exceptions remain controversial; the modern stance is "checked exceptions for recoverable I/O at boundaries, runtime exceptions inside business logic, never
throws Exception." - Concurrency by structure, not by threads. "Don't spawn threads; submit tasks; and prefer structured concurrency to free-floating
CompletableFutures." Virtual threads (Loom) make this affordable. - The JDK is the framework - until it isn't.
java.net.http,java.time,java.util.concurrent,java.lang.foreign,java.util.stream,System.Loggercover ~70% of any service. Spring/Quarkus exist because dependency injection, configuration, and HTTP routing are not in the JDK - not because the JDK is weak. - Tooling is part of the platform.
javac,jcmd,jfr,jstack,jmap, JMH, async-profiler,jlink,jpackage,jdeps. A Java engineer who does not know these is half-trained.
If you fight these defaults, you will fight the platform. If you internalize them, you will write code that any other Java engineer can pick up in under a day, and you will know which knob to turn when a service goes sideways at 3 AM. That is the actual deliverable Java optimizes for.
4. The Reading List¶
These are referenced throughout the curriculum. They are pinned tabs, not pre-requisites.
Primary - Effective Java, 3rd ed. (Bloch). The canonical text. Re-read items 1–17 quarterly. - Java Concurrency in Practice (Goetz et al.). Pre-Loom but the JMM chapter is timeless. Pair with the JEPs on virtual threads (425, 444, 491) and structured concurrency (462/480/505). - Optimizing Java (Evans, Gough, Newland). The book on JIT, GC, and JFR for practitioners. - The Well-Grounded Java Developer, 2nd ed. (Evans, Verburg) - modern (post-17) idioms. - Java Language Specification (JLS) and Java Virtual Machine Specification (JVMS) - normative; read the chapters relevant to whatever bug is in front of you.
JVM & internals
- The HotSpot source itself: src/hotspot/share/runtime/, src/hotspot/share/gc/, src/hotspot/share/opto/ (C2), src/hotspot/share/c1/ (C1). Treat as primary literature.
- OpenJDK JEP archive (openjdk.org/jeps/). Read every JEP that ships in the LTS you target. JEPs are the design docs of the platform.
- Aleksey Shipilëv's blog (shipilev.net) - the JMM, GC, microbenchmarking. JMH is his.
- Cliff Click's A JVM Does That? talks; Gil Tene's Understanding Latency (the GC-pause-as-coordinated-omission talk).
- The Garbage Collection Handbook (Jones, Hosking, Moss).
Distributed systems canon (not Java-specific, but mandatory) - Lamport, Time, Clocks, and the Ordering of Events. - Ongaro, In Search of an Understandable Consensus Algorithm (Raft). Read in week 21. - Brewer, CAP Twelve Years Later. - Kleppmann, Designing Data-Intensive Applications. Chapters 5–9 in the back half.
Adjacent canon - Drepper, What Every Programmer Should Know About Memory (re-read in week 9 for cache effects on object headers and false sharing). - Herlihy & Shavit, The Art of Multiprocessor Programming, chapters 7, 9, 13.
5. Curriculum Philosophy: "Read the JEP, Profile the Lab"¶
Three rules govern every module:
- JEP first, blog second. When the curriculum says "study virtual threads," it means read JEP 444 (final spec), then
java.lang.VirtualThreadsource, then the blog post. Blogs go stale; JEPs are dated and tracked. - One lab per concept, one artifact per phase. By the end of each month, the reader has produced one open-source-quality artifact (library, JMH benchmark suite, JFR analysis, or upstream OpenJDK contribution) - not a notebook of toy snippets.
- JFR and async-profiler are the teachers. When you do not understand why a program misbehaves, the first response is
jcmd <pid> JFR.start, the second isasync-profiler -e cpu,alloc, and only the third is to ask another human.System.out.printlnis not in the top three.
6. What Java Is Not For¶
A graduate of this curriculum should be able to argue these points in a design review without sounding ideological:
- Cold-start-sensitive serverless. A JVM start is ~100ms+ minimum; a Spring Boot start is seconds. GraalVM
native-image(or Leyden once stable) closes the gap but trades peak throughput and reflection ergonomics. If your p99 cold-start budget is <50ms, look at Go or Rust before fighting native-image. - Hard-real-time systems. Even ZGC's sub-millisecond pauses are not real-time guarantees. Audio DSP, motor control, kernel paths - wrong tool. Azul Zing pushes the envelope but is a commercial appliance.
- Tiny CLIs. A 30MB
java -jarfor a tool that runs for 200ms is silly.jlink+jpackagehelp; native-image helps more; but if it's a one-shot tool, Go and Rust win on ergonomics. - Code where the team will demand pointer arithmetic. Panama (Foreign Memory API) gives controlled off-heap access, but it is not C. If your problem is "I need SIMD-tight numerical kernels and full control of layout," call into Rust via Panama or use the Vector API (still incubating) and accept the constraints.
The signal that Java is the right tool: you have a long-running-service, large-team, observability-rich, library-ecosystem-leveraging constraint, and the workload is throughput-shaped, not cold-start-shaped.
7. A Note on AI-Assisted Workflows¶
Modern Java authors use LLM tooling. Four rules:
- Never accept generated concurrent code without the JMM in mind. The most common failure mode of generated Java is plausible-looking but unsafe publication: a non-
finalfield read across threads, a "double-checked locking" withoutvolatile, anArrayListmutated from aparallelStream. Read the JMM, then audit. - Verify imports and JDK version. Models conflate Java 8, 11, 17, 21, and 25 freely. They will suggest
Stream.toList()on Java 8, orvarin an interface, or virtual threads on Java 17. Pin the target JDK in your prompt and verify. - Watch for deprecated/removed APIs. Models still suggest
new Date(),Vector,Hashtable,Thread.stop,SecurityManager,finalize(),sun.misc.Unsafe, raw types, and JNI when Panama is appropriate.APPENDIX_B_LEGACY_JAVA.mdis the antidote. - Treat generated exception handling skeptically. The most common Java anti-pattern in generated code is
catch (Exception e) { e.printStackTrace(); }. Make a habit of replacing it with deliberate, narrow, logged handling at boundaries, and propagation everywhere else.
You are now ready for Week 1. Open 01_MONTH_LANGUAGE_AND_TOOLCHAIN.md.
Month 1 - Language & Toolchain (Weeks 1–4)¶
The goal of Month 1 is not "learn Java syntax." It is to install the modern Java mental model - records, sealed types, pattern matching, virtual threads as a normal default - and the toolchain literacy (Maven/Gradle, JPMS, JUnit 5, javac, jshell, jlink) that every subsequent month assumes.
Baseline JDK: Java 25 LTS. Install via SDKMAN (sdk install java 25-tem). If your shop is on 21 LTS, every example here still works; flagged where it doesn't.
Weeks¶
- Week 1 - Modern Syntax and the Type System
- Week 2 - Build Tools, Dependencies, and JPMS
- Week 3 - Collections, Streams, and
java.time - Week 4 - Testing, Logging, and the Definition-of-Done
Month 1 Exit Criteria¶
You can:
- Read modern Java (records, sealed, pattern matching, text blocks) without translating in your head.
- Set up a Maven or Gradle project from scratch with JUnit 5, AssertJ, SLF4J + Logback, ErrorProne.
- Explain JPMS in three sentences and decide whether your project needs it (almost certainly: no).
- Write a jshell snippet to verify any small Java behavior in seconds.
- Justify every dependency in your pom.xml / build.gradle.kts.
You are now ready for Month 2 - the JVM itself.
Week 1 - Modern Syntax and the Type System¶
Conceptual Core¶
Java post-17 is a different language than Java 8. The unit of design is no longer "a class with getters and setters" but records for data, sealed interfaces for choices, pattern matching to destructure. If you write a POJO with Lombok by reflex, you are writing 2014 Java.
Mechanical Detail¶
record Point(int x, int y) {}- compiler-generatedequals,hashCode,toString, accessors. Components arefinal. Compact constructor for validation.sealed interface Shape permits Circle, Square, Triangle {}- closed hierarchy; the compiler can prove exhaustiveness inswitch.switchexpressions with pattern matching:- Record patterns:
case Point(int x, int y) -> .... Unnamed patterns:case Point(_, int y) -> ...(final in 22+). varfor local type inference - only locals, never fields, never parameters. Read JEP 286 rationale.- Text blocks (
"""...""") - multi-line strings, indentation-stripped at compile time.
Lab¶
Re-implement a small JSON-shaped expression evaluator: sealed interface Expr permits Lit, Add, Mul, Neg. Use record patterns + exhaustive switch. No instanceof chains, no visitors, no enum faking ADTs.
Idiomatic Drill¶
Run IntelliJ's "Inspect Code" (or ErrorProne). Resolve every "can be converted to switch expression," "can be replaced with record," "raw use of parameterized class." Read the rationale on each.
Production Hardening Slice¶
Set up jshell and use it for the lab's REPL exploration. Run javac --release 25 -Xlint:all and resolve every warning. This is the baseline for the rest of the course.
Week 2 - Build Tools, Dependencies, and JPMS¶
Conceptual Core¶
You will live in Maven or Gradle for the rest of your career. Pick one and learn it deeply; the other is then 80% transferable. Switching mid-project loses 2-4 weeks of velocity - pick once.
JPMS (Java Platform Module System, JEP 261) is the thing every Java developer knows exists and nobody outside library authors uses. Most applications never need it. The tax: recognizing when a module-system issue (an --add-opens requirement, an "unnamed module" warning, a missing requires in module-info.java) explains a failure you'd otherwise spend hours debugging.
Mechanical Detail¶
- Maven lifecycle:
validate → compile → test → package → verify → install → deploy. Each phase runs itself plus all earlier ones.mvn packageis the daily driver.mvn dependency:treeshows transitives;mvn dependency:analyzeflags declared-but-unused and used-but-undeclared deps. - Maven inheritance: every project inherits from
super-pom. Multi-module projects add aparentpom for shared config. BOMs (e.g.spring-boot-dependencies) manage versions of an artifact constellation; import via<dependencyManagement>+<scope>import</scope>. - Gradle: task graph (
gradle Xruns X's dependency tasks first). The configuration cache (org.gradle.configuration-cache=true) skips readingbuild.gradleon unchanged builds - large speedup, but breaks plugins that do I/O at configuration time. Version catalogs (gradle/libs.versions.toml) centralize versions across modules. - JPMS:
module-info.javadeclaresrequires,exports,opens.requires transitivere-exports a dependency.openspermits reflective access (Jackson, Spring, JPA need it). Two failure symptoms: "illegal reflective access" (needsopensor--add-opens); "module X not found" (needsrequiresor to be on the module path). jdeps Yourapp.jarlists modules needed;jlink --add-modules <list> --output runtime --strip-debug --no-man-pagesproduces a 35-50MB JRE instead of the 200MB stock JDK. Useful for containers.
The trap
Not pinning Maven plugin versions. The default resolves "whatever is latest available" - a build that worked yesterday may behave differently today. Always pin <version> in pluginManagement and let Renovate update them deliberately.
Lab¶
Take any small library. Modularize it: write module-info.java, run jdeps to find required modules, jlink --add-modules <list> --output runtime/ to build a custom runtime. Measure size before (du -sh $JAVA_HOME) vs after (du -sh runtime/). Then mvn install to a local repo and consume from a separate Gradle project via mavenLocal().
Idiomatic Drill¶
Read 10 random pom.xml files from popular libraries (Caffeine, Resilience4j, Micrometer, Jackson). Spot the patterns: BOMs via <dependencyManagement>, explicit <scope> (compile / test / provided / runtime), plugin versions in pluginManagement not inline.
Production Hardening Slice¶
For reproducible builds, lock down:
- Java version via <maven.compiler.release>21</...> (or Gradle JavaLanguageVersion.of(21)).
- Parameter names via -parameters (Maven <maven.compiler.parameters>true</...>) so Spring / Jackson can read them at runtime.
- Every plugin version in pluginManagement.
- Renovate or Dependabot for dependency PRs.
Week 3 - Collections, Streams, and java.time¶
Conceptual Core¶
The collections framework is older than most engineers reading this. The modern stance:
List.of/Map.offor immutable literals.Streamfor transformations under ~1M elements; readable, JIT-friendly past warmup.- plain
forloops for hot paths where allocation matters. - never
Vector/Hashtable/Stack(synchronized, legacy).
java.time (JSR-310) has been the right answer since Java 8 (2014). Date/Calendar exist only to torment you in legacy code - convert at the boundary.
Mechanical Detail¶
- Collection hierarchy:
List(ordered, indexed),Set(unique),Map(key→value),Deque(double-ended).SequencedCollection(JEP 431, Java 21+) addsreversed(),getFirst(),getLast()to the relevant subtypes - a uniform endpoint API. - Pick by access pattern: lookup by key →
HashMap; insertion-ordered iteration →LinkedHashMap; sorted iteration →TreeMap; cheap append/iterate →ArrayList; cheap deque ops →ArrayDeque(notLinkedList, which is slower in practice for almost everything). - Immutable factories:
List.of(a, b, c),Map.of(k1, v1, k2, v2),Map.ofEntries(Map.entry(k, v), ...). They rejectnulland throwUnsupportedOperationExceptionon mutation - by design. Streamcost model: lazy until terminal (collect,forEach,count); auto-boxes primitives unless you useIntStream/LongStream/DoubleStream(which expose.sum(),.average(),.summaryStatistics()without boxing).Stream.toList()(16+) returns unmodifiable;Collectors.toList()returns mutable.parallelStreamis a footgun in 99% of cases - uses the sharedForkJoinPool.commonPool(); one slow task blocks every other parallel stream in the JVM. Default to virtual-thread executors (Month 4) instead.java.timeessentials:Instant(machine time, UTC),Duration(machine-scale gap),LocalDate/LocalTime/LocalDateTime(no zone),ZonedDateTime(with zone),OffsetDateTime(with offset only),Period(human-scale gap, like "3 months"). At the boundary with legacy APIs, convert:Date.from(instant)/instant.atZone(...).
The trap
Collectors.toMap(keyFn, valueFn) throws IllegalStateException on duplicate keys. Always provide the merge function: Collectors.toMap(keyFn, valueFn, (a, b) -> a) to keep first, (a, b) -> b to keep last.
Lab¶
Take a CSV file of timestamped events. Compute per-hour aggregates two ways:
1. Stream + Collectors.groupingBy(e -> e.timestamp().truncatedTo(HOURS), Collectors.counting()).
2. Explicit for loop + HashMap<Instant, Long>.
JMH them in Week 8. Also measure peak memory (-Xlog:gc*=info + young-gen allocation rate).
Idiomatic Drill¶
Find any code using SimpleDateFormat. Replace with DateTimeFormatter. Explain why the old one isn't thread-safe (mutable Calendar field, parsed state held internally). Audit any static SimpleDateFormat field for concurrent-use bugs.
Production Hardening Slice¶
Read every method on Optional. Then read Brian Goetz's rule: "Optional is for return types, not parameters and not fields." Internalize.
Related: Stream.findFirst() is deterministic; Stream.findAny() lets the parallel implementation short-circuit. Use the one that matches your intent.
Week 4 - Testing, Logging, and the Definition-of-Done¶
Conceptual Core¶
"Done" is not "compiles." Done is four invariants:
- Tested at multiple levels (unit, integration, property-based).
- Logs are structured and queryable (JSON, with a trace ID).
- Errors carry context (wrap, don't swallow).
- The artifact is reproducible - another engineer can build and run with one command.
Month 1 ends when your build produces a JAR (or jlink image) that lands on someone else's machine and starts cleanly.
Mechanical Detail¶
- JUnit 5 (Jupiter):
@Test,@ParameterizedTest(with@ValueSource,@MethodSource,@CsvSource),@Nestedfor grouping,@DisplayNamefor readable output. Lifecycle:@BeforeEach/@AfterEach(per test),@BeforeAll/@AfterAll(per class - needs@TestInstance(PER_CLASS)for instance methods). Extensions via@ExtendWith(MockitoExtension.class)etc. @Tagfor grouping: mark slow tests@Tag("slow"), run only fast tests in pre-commit via Surefire'sgroups/excludedGroups.- AssertJ over Hamcrest over JUnit's bare
assertEquals- fluent, readable, great failure messages. Idioms:assertThat(list).containsExactly(a, b, c),.extracting(Person::name),.filteredOn(p -> p.age() > 18). - Mockito 5+: mock collaborators via constructor injection (Mockito 5 cannot
@InjectMocksintofinalfields, which records and well-written classes use). Never mock what you don't own -Connection,HttpClient, framework types. Wrap in your own interface, mock that. - Testcontainers for real dependencies:
@Container PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"). Test cost: 3-5s spin-up; pays for itself in caught bugs that mocks would hide. In CI: Docker-in-Docker or a Testcontainers Cloud worker. - Property-based with jqwik:
@Property void roundTrips(@ForAll String input) { ... }. Shrinks failing cases to a minimal reproducer automatically. - Logging: SLF4J as the facade, Logback or Log4j2 as the backend. Structured JSON via
logstash-logback-encoder(Logback) or Log4j2'sJsonLayout. MDC (Mapped Diagnostic Context) for per-request fields (trace ID, user ID); set in a request filter, used implicitly by every log line. NeverSystem.out; nevere.printStackTrace().
The trap
catch (Exception e) { log.error("error", e); } without context is barely better than printStackTrace. Always include what you were doing: log.error("validating order {} for customer {}", orderId, customerId, e).
Lab¶
Take Week 3's CSV aggregator (or any small evaluator). Write:
- 10 unit tests (JUnit 5 + AssertJ).
- 3 parameterized tests covering edge cases (empty input, single row, malformed timestamp).
- 1 property-based test (@ForAll List<Event> events -> aggregator.process(events).size() <= events.size()).
- Structured JSON logging at boundaries (input received, output produced).
Idiomatic Drill¶
Configure ErrorProne or SpotBugs or IntelliJ's "Inspect Code" action in your build. Resolve every warning on your Month 1 codebase.
Production Hardening Slice¶
Produce a Makefile (or justfile):
build: mvn package
test: mvn test
run: java -jar target/app.jar
image: jlink --add-modules $(jdeps --print-module-deps target/app.jar) --output runtime/
hardening/ template.
Month 2 - JVM & Bytecode (Weeks 5–8)¶
Month 1 made you fluent in modern Java. Month 2 makes you literate in what java actually does when it runs your code. By the end you should be able to read a -XX:+PrintCompilation log, decode a javap -v listing, and explain why your hot loop got deoptimized.
Weeks¶
- Week 5 - Class Loading and Bytecode
- Week 6 - The JIT: C1, C2, Graal, Tiered Compilation
- Week 7 - Method Handles, VarHandles, and Reflection
- Week 8 - JMH and Microbenchmarking
Month 2 Exit Criteria¶
You can:
- Read javap -v for any method and trace its execution on the JVM stack machine.
- Explain tiered compilation, inlining, escape analysis, and deoptimization without notes.
- Pick the right tool from {reflection, MethodHandle, VarHandle, Panama} for a given problem.
- Write a non-embarrassing JMH benchmark and defend the methodology.
- Articulate the trade-off between JIT-warmed HotSpot and GraalVM native-image for a specific workload.
You are now ready for Month 3 - memory and garbage collection.
Week 5 - Class Loading and Bytecode¶
Conceptual Core¶
Java compiles to bytecode, an idealized stack-machine ISA, which the JVM loads, verifies, links, and (eventually) JIT-compiles to native. A class is not just source-level - it's a runtime resource with an identity: the <defining classloader, FQN> pair. Two classes with the same fully-qualified name loaded by two different classloaders are different classes and not assignment-compatible. Internalize this and most production classloader bugs become obvious.
Class loading is lazy, hierarchical (parent-delegation), and the source of a whole genre of production bugs: ClassNotFoundException, NoClassDefFoundError, LinkageError, and metaspace classloader leaks.
The trap
Assuming "the class is loaded" is one event. It's three phases: load → link → initialize. The init phase runs your static {} lazily, the first time the class is actively used. Reading a static final constant does NOT initialize it; calling a static method does. JLS §12.4 enumerates triggers - read them before debugging your next "static block ran twice" bug.
Mechanical Detail¶
- Class-file format (JVMS Ch. 4): magic
CAFEBABE, constant pool, access flags, fields, methods, attributes. Read it:javap -v -p YourClass.class. - Bytecode is a stack machine - ~200 opcodes, you'll meet ~30 daily.
invokedynamic(7+) is how lambdas, switch-on-strings, and pattern matching are implemented under the hood. - Linking sub-phases: verify (bytecode well-formed), prepare (static field storage), resolve (symbolic refs → direct). Then initialize (
<clinit>). Init failure throwsExceptionInInitializerErrorand bricks the class for the rest of its loader's life. - Loader hierarchy (9+): bootstrap → platform → app → custom.
Thread.currentThread().getContextClassLoader()exists becauseServiceLoader/JDBC/JNDI need to look down the hierarchy, which parent-delegation forbids; the TCCL is the workaround. - Custom classloaders power plugin systems, hot reload, multi-tenant isolation, and bytecode-rewriting frameworks (Hibernate, Mockito).
- Class-File API (JEP 484, stable in 24+) -
java.lang.classfilereads/writes class files declaratively. Replaces ASM for new code.
Lab¶
Take a 10-line Java method. Compile it. Read the javap -v -p output line by line. Then build a class with the same bytecode using the Class-File API, load it via a custom ClassLoader, invoke via reflection, confirm output matches.
Idiomatic Drill¶
Find a real classloader leak (search "metaspace leak Tomcat"). The canonical pattern: a ThreadLocal<T> whose T is loaded by a webapp classloader, kept alive by a long-lived worker thread that survives redeploy. Fix: ThreadLocal.remove() in a finally - or ScopedValue (final in 25).
Production Hardening Slice¶
Add to hardening/:
-Xlog:class+load=info,class+unload=info:file=/var/log/jvm-classload.log
-XX:MaxMetaspaceSize=256m
-XX:+HeapDumpOnOutOfMemoryError
Week 6 - The JIT: C1, C2, Graal, Tiered Compilation¶
Conceptual Core¶
HotSpot runs your bytecode in an interpreter at first, profiles it, then compiles hot methods with C1 (fast, dumb) and very hot ones with C2 (slow, smart). This is tiered compilation. Graal is an alternative C2 written in Java itself. Almost every "Java is slow" claim dies on first contact with steady-state JIT output.
Mechanical Detail¶
- Compilation tiers 0–4. Read
-XX:+PrintCompilation,-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining. - Inlining heuristics: small methods, monomorphic call sites, profile-guided. The infamous
MaxInlineLevel(default 9) andMaxInlineSize(default 35 bytes). - Escape analysis and scalar replacement - the JIT can prove an object doesn't escape and stack-allocate (effectively) by inlining its fields into locals. This is why "premature optimization" advice exists: most allocations the JIT eliminates anyway.
- Deoptimization - when a speculation fails (a call site that was monomorphic gets a second type), C2 throws away its compiled code and falls back to the interpreter.
-XX:+PrintCompilationshowsmade not entrant/made zombie. - JVMCI and Graal: enable with
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -XX:+EnableJVMCI. - Project Leyden (in flight) - AOT condensers, ahead-of-time class loading + linking + (eventually) compilation, in the OpenJDK proper. Watch JEPs 483, 514, 515.
- GraalVM
native-image- full AOT to a native binary. No JIT, no warmup, ~10x smaller. Trade: reflection/dynamic proxies need explicit hints, peak throughput is often lower than warmed-up C2.
Lab¶
Write a polymorphic dispatch site (Shape.area() over 1 / 2 / 3 implementations). Run with -XX:+PrintInlining for each cardinality. Observe monomorphic → bimorphic → megamorphic transitions. Reproduce a deopt by introducing a 4th type after warmup.
Idiomatic Drill¶
Read Cliff Click's "A Crash Course in Modern Hardware" talk. Understand why "what the JIT can do" depends on what the CPU can do.
Production Hardening Slice¶
Build the same simple Spring Boot endpoint two ways: standard JVM and GraalVM native-image. Measure: cold start, p50/p99 latency under load, RSS, image size. Document the trade in a one-pager.
Week 7 - Method Handles, VarHandles, and Reflection¶
Conceptual Core¶
Three generations of "do something dynamically" in the JVM, each with a different cost model:
java.lang.reflect(1.1+) - original.Method.invokeis a native dispatcher, opaque to the JIT, ~10-100× slower than a direct call.MethodHandle(7+) - typed handles, JIT-inlinable. Substrate for lambdas, Spring 5+, Hibernate 6+, Jackson 2+.VarHandle(9+) - typed handles to variables with explicit memory-ordering modes. Replaces almost every legitimate use ofsun.misc.Unsafe.
The mental model: reflection for tooling, MethodHandle for hot-path dynamic dispatch, VarHandle for concurrent code. Pick wrong, hot path is 50× slower than necessary.
The trap
Caching a Method in a static field thinking it's "fast now." It isn't - every .invoke() still pays the dispatcher cost. The 7+ idiom: lookup.unreflect(method) to convert to a MethodHandle, cache that, call .invokeExact(args...).
Mechanical Detail¶
MethodHandles.Lookupis capability-based: it encodes the access rights of the class that obtained it.lookup.findVirtual(...),lookup.findStatic(...). To reach private members in another class, useMethodHandles.privateLookupIn(target, lookup)- modern replacement forsetAccessible(true).invokeExactvsinvoke:invokeExactrequires the call-site signature to exactly match the handle'sMethodType(including return type).invokeallows asType conversions but loses inlining. AlwaysinvokeExactin hot paths.VarHandlememory modes:Plain,Opaque,Acquire/Release,Volatile- map 1:1 to C++20relaxed/opaque/acquire+release/seq_cst. Use the weakest that proves correctness.- Foreign Function & Memory API (
java.lang.foreign, stable in 22+) -MemorySegment,MemoryLayout,Linker.nativeLinker()returns aMethodHandleto a C function. Replaces JNI for new code.MethodHandleis the substrate. - The strong-encapsulation saga: Java 9 introduced modules; 17 made
--illegal-access=denypermanent. Old libraries reaching intojava.baseneed--add-opens java.base/java.lang=ALL-UNNAMED. Fix path: prefer Panama/VarHandle/privateLookupInover--add-opens.
Lab¶
Build a tiny DI container (~150 lines): scan a package for @Inject constructors, topologically sort by dependencies, instantiate with MethodHandle (lookup.unreflectConstructor(ctor).invokeExact(deps)). JMH vs Constructor.newInstance(deps). Expect MethodHandle ~5-10× faster after warmup. Stretch: use LambdaMetafactory to get within 1.5× of a direct call.
Idiomatic Drill¶
Find every setAccessible(true) in a codebase you maintain. Classify: legitimately needed (→ MethodHandles.privateLookupIn), replaceable with a public API, or a leak waiting to happen.
Production Hardening Slice¶
--add-opens java.base/java.lang=ALL-UNNAMED # only for known legacy
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintModuleResolution
--add-opens requirement, the diff tells you which package needs review.
Week 8 - JMH and Microbenchmarking¶
Conceptual Core¶
You will be wrong about Java performance until you measure with JMH. Hand-rolled benchmarks lie systematically, in five specific ways:
- Dead-code elimination - JIT removes computation whose result is unused.
- Constant folding - JIT replaces
compute(42)with the precomputed constant. - On-stack replacement (OSR) - long-running loops get JIT-compiled mid-flight; the average across iterations is meaningless.
- Warmup curves - interpreter → C1 → C2 takes thousands of iterations. Anything earlier is dominated by compilation cost.
- GC noise - allocations trigger minor GCs at unpredictable points.
JMH generates per-benchmark wrapper code that defeats every one of these. Hand-rolled benchmarks are not "less accurate" - they are systematically wrong in a direction that flatters your code.
The trap
Running JMH from your IDE and trusting the output. JMH needs the annotation processor to run AND it needs to fork its own JVMs. IDE-launched JMH may skip both. Always run via mvn/gradle jmh or java -jar bench/target/benchmarks.jar.
Mechanical Detail¶
- Project setup: separate Maven/Gradle module. Use the shade plugin to produce
benchmarks.jar, the canonical runner. - Core annotations:
@Benchmark,@State(Scope.{Benchmark,Thread}),@Setup/@TearDown,@Warmup(iterations=5, time=1),@Measurement(iterations=10, time=1),@Fork(value=3),@BenchmarkMode({Throughput, AverageTime}),@OutputTimeUnit(NANOSECONDS). Blackhole.consume(result)defeats DCE. Or justreturnthe value - JMH consumes it.@CompilerControl(DONT_INLINE)forces a real call when measuring call-site overhead.- Profilers:
-prof gc(allocation rate),-prof stack(sampling),-prof async(flame graphs),-prof perfasm(Linux - JIT assembly withperfannotations).perfasmis the killer when a result surprises you. - The hard rules: always 3+ forks (single-fork sees one GC schedule, one thermal state - never trust it). Error/score < 5% is trustworthy; > 20% is noise.
taskset -c 3to pin CPU; disable Turbo Boost for reproducibility.
Lab¶
JMH the Week 3 Stream vs for-loop comparison. Skeleton:
@State(Scope.Benchmark)
public static class Inputs {
@Param({"100", "10000", "1000000"}) int n;
List<Event> events;
@Setup public void setup() { events = generate(n); }
}
@Benchmark public Map<Hour, Long> streamWay(Inputs in) { /* ... */ }
@Benchmark public Map<Hour, Long> forWay(Inputs in) { /* ... */ }
-prof gc. There IS a crossover - find and explain it.
Idiomatic Drill¶
Read Aleksey Shipilëv's "JMH - Like a Boss" (slides + talk). Audit one of your own benchmarks against the warmup, Blackhole, and "don't return raw Object" checklist.
Production Hardening Slice¶
Add a bench/ module to hardening/ with one canonical @Benchmark example and a README.md showing how to run it. CI: a nightly job that publishes JSON results as an artifact. Do not gate on absolute numbers - microbenchmark CI noise is too high. Track trends; a 15% regression sustained over a week is real, a 30% one-run spike is noise.
Month 3 - Memory & Garbage Collection (Weeks 9–12)¶
The JVM's defining feature, and the cause of more 3 AM pages than any other subsystem. By the end of Month 3 you should be able to choose a GC, size a heap, read a GC log, and triage an OOM from a heap dump without panic.
Weeks¶
- Week 9 - Object Layout, Headers, and Cache Effects
- Week 10 - The Generational Hypothesis and the Modern GCs
- Week 11 - Heap Sizing, GOMEMLIMIT-Equivalents, and Container Awareness
- Week 12 - JFR, Heap Dumps, and Allocation Profiling
Month 3 Exit Criteria¶
You can:
- Predict the size of a Java object using JOL, including header and alignment.
- Pick a GC for a given workload and defend the choice with two concrete reasons.
- Read a GC log and an -Xlog:gc* output without consulting a cheat sheet.
- Budget every memory pool in a containerized JVM and explain RSS to a confused SRE.
- Drive JFR, async-profiler, and Eclipse MAT to diagnose a real production-shape memory problem.
You are now ready for Month 4 - concurrency, the Java Memory Model, and Project Loom.
Week 9 - Object Layout, Headers, and Cache Effects¶
Conceptual Core¶
A Java object is not free. Every object has a header (mark word + class pointer), every reference is a pointer, and the layout interacts directly with CPU cache lines. "Compact" data structures in Java require understanding what HotSpot does to your fields.
Mechanical Detail¶
- Object header: mark word (8 bytes, holds lock state, identity hash, GC age, biased-locking bits in older JVMs) + klass pointer (typically 4 bytes with compressed oops). JEP 450 (Compact Object Headers), stable in 24+: reduces header to 8 bytes total. Material for any heap-sizing math.
- Field reordering: HotSpot reorders fields for alignment. Use JOL (Java Object Layout) -
java -jar jol-cli.jar internals com.foo.MyClass- to see the actual layout. - Compressed oops (
-XX:+UseCompressedOops, default ≤32GB heap). What happens above 32GB and how-XX:ObjectAlignmentInBytesextends the range. - Cache lines (typically 64 bytes). False sharing between adjacent fields written by different threads.
@Contended(JDK-internal injdk.internal.vm.annotation, requires-XX:-RestrictContended). - Primitive arrays are contiguous and dense; object arrays are arrays of pointers. Why
int[]is 4× smaller thanInteger[].
Lab¶
Use JOL to measure the size of: an empty object, a String, a HashMap with 0/1/10/100 entries, an ArrayList vs LinkedList of 1000 Integers. Predict each before measuring.
Idiomatic Drill¶
Read Aleksey Shipilëv's "Java Object Header" series.
Production Hardening Slice¶
Enable compact object headers (-XX:+UseCompactObjectHeaders if on 24+). Measure heap usage delta on a real app.
Week 10 - The Generational Hypothesis and the Modern GCs¶
Conceptual Core¶
"Most objects die young." This is the weak generational hypothesis, and it is what every modern GC except non-generational ZGC (deprecated as default in 23+) and Epsilon is built on. You need to know the family tree: Serial → Parallel → CMS (removed) → G1 → ZGC → Shenandoah → Generational ZGC.
Mechanical Detail¶
- Serial GC (
-XX:+UseSerialGC) - single-threaded; only for tiny heaps / containers. Default in some container configs. - Parallel GC (
-XX:+UseParallelGC) - throughput-optimized, stop-the-world. Default before 9. - G1 GC (
-XX:+UseG1GC, default since 9) - region-based, mostly-concurrent, predictable pause-time goal (-XX:MaxGCPauseMillis=200default). The general-purpose default. - ZGC (
-XX:+UseZGC) - concurrent, region-based, colored pointers, sub-millisecond pauses. Generational ZGC (JEP 439, GA in 21) is the modern form; on 23+ it's the default flavor of-XX:+UseZGC. Use for latency-sensitive services with multi-GB heaps. - Shenandoah (Red Hat,
-XX:+UseShenandoahGC) - concurrent compaction via Brooks pointers / load-reference barriers. Comparable use case to ZGC. - Epsilon (
-XX:+UseEpsilonGC) - no-op collector. For short-lived batch jobs or GC-perf testing. - Picking: G1 by default; ZGC for latency-sensitive ≥4GB heaps; Parallel for throughput-only batch; Serial for tiny containers; Shenandoah if your stack is RHEL-flavored and you've measured it wins.
Lab¶
Take a sample allocation-heavy app (a small JSON parser benchmark works). Run it under all four major GCs with identical heap. Collect logs with -Xlog:gc*=info:file=gc.log:time,uptime. Plot pause times.
Idiomatic Drill¶
Read JEPs 439 (Generational ZGC) and 248 (Make G1 the Default).
Production Hardening Slice¶
Add a GC-log parser to your hardening/ template (or wire up gceasy.io / GCViewer). One command from gc.log to a chart.
Week 11 - Heap Sizing, GOMEMLIMIT-Equivalents, and Container Awareness¶
Conceptual Core¶
Heap sizing in a container is a recurring failure mode. The JVM has been container-aware by default since 10, but flags and defaults still bite. Off-heap memory (direct byte buffers, Panama segments, metaspace, code cache, GC structures) is not in -Xmx and is the usual cause of "my pod got OOMKilled but the JVM said the heap was fine."
Mechanical Detail¶
-Xms/-Xmx- initial / max heap. Setting them equal avoids GC behavior changes mid-life.-XX:MaxRAMPercentage=75.0(default 25% - far too low) - the modern way to size in containers. Pair with-XX:InitialRAMPercentage.- Metaspace - class metadata, off-heap, defaults to unbounded.
-XX:MaxMetaspaceSize=256mis sane. Classloader leaks show up here. - Direct memory (
ByteBuffer.allocateDirect) - capped by-XX:MaxDirectMemorySize, defaults to ~heap size. Netty leans on this heavily. - Code cache -
-XX:ReservedCodeCacheSize=256m. Fills up on long-running apps with tons of JIT'd code; when full, JIT stops and your app slows. - GC overhead - G1 has ~10% memory overhead for its remembered sets; ZGC ~3× the live set for forwarding tables (acceptable; the trade is sub-ms pauses).
- Total RSS budget =
Xmx+ Metaspace + DirectMemory + CodeCache + thread stacks (-Xss× thread count) + GC + native libraries. Budget every term in a container.
Lab¶
Take your week-10 app. Run it in a 1GB container with default flags, observe the headroom. Then explicitly size every memory pool and run again. Measure RSS over time.
Idiomatic Drill¶
Read the Netty memory docs (PooledByteBufAllocator). Understand why a server with heavy I/O has a non-trivial direct-memory footprint.
Production Hardening Slice¶
Add a "memory budget" checklist to your hardening/ template: every JVM flag with a justification and a value. Wire -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof.
Week 12 - JFR, Heap Dumps, and Allocation Profiling¶
Conceptual Core¶
Java has the best production-grade profiler of any mainstream language: JFR (Java Flight Recorder), free and open-source since 11. Combined with async-profiler for CPU/alloc sampling and Eclipse MAT for heap-dump analysis, you can diagnose almost any memory problem post-mortem.
Mechanical Detail¶
- JFR:
-XX:StartFlightRecording=duration=60s,filename=app.jfr,settings=profileor live viajcmd <pid> JFR.start name=foo duration=60s filename=foo.jfr. - Open in JDK Mission Control (JMC) or via
jfr print --events jdk.GCPhasePause app.jfr. - Key event categories: GC (pauses, allocation), JIT (compilation, deopt), thread (parking, blocking), I/O, allocation outside TLAB (the "humongous allocation" signal).
- async-profiler (1-package native):
asprof -e cpu,alloc -d 30 -f flame.html <pid>. Flame graphs are the right default visualization. - Heap dumps:
jcmd <pid> GC.heap_dump /tmp/heap.hprofor automatic on OOM. Analyze in Eclipse MAT - "Dominator Tree" and "Leak Suspects" are the only two views you need 90% of the time. - The pattern for a leak hunt: heap dump → MAT dominator tree → find the unexpected retainer → trace GC roots → fix.
Lab¶
Write a deliberate memory leak (a static Map that accumulates request contexts). Run it, take a heap dump after some traffic, identify the leak in MAT. Then fix it, re-run, re-dump, confirm.
Idiomatic Drill¶
Capture a 60-second JFR of a real local service (your week 4 lab, scaled up). Open in JMC. Identify the top three allocation sites.
Production Hardening Slice¶
Add to hardening/: a script that starts JFR continuously in chunked rotation (maxsize=200M,maxage=24h). This is "always-on profiling," now standard at every major Java shop.
Month 4 - Concurrency & Project Loom (Weeks 13–16)¶
Java's concurrency story has two eras: pre-Loom (Thread, synchronized, ExecutorService, CompletableFuture, reactive frameworks like RxJava/Reactor) and post-Loom (virtual threads, structured concurrency, scoped values). You must be fluent in both - because every production codebase is somewhere on the transition.
Weeks¶
- Week 13 - The Java Memory Model and
java.util.concurrentFoundations - Week 14 - Executors,
CompletableFuture, and the Pre-Loom World - Week 15 - Virtual Threads, Structured Concurrency, and Scoped Values
- Week 16 - Lock-Free Patterns,
VarHandleMemory Modes, andjcstress
Month 4 Exit Criteria¶
You can:
- Reason about visibility and ordering from first principles using happens-before.
- Pick the right concurrency primitive ({synchronized, ReentrantLock, VarHandle, virtual thread, structured scope}) for a given problem.
- Migrate a CompletableFuture or Reactor chain to virtual threads + structured concurrency, and explain when not to.
- Validate a concurrent data structure with jcstress.
- Diagnose pinning, contention, and starvation from JFR.
You are now ready for Month 5 - production and distributed systems.
Week 13 - The Java Memory Model and java.util.concurrent Foundations¶
Conceptual Core¶
The JMM defines what a thread can see of another thread's writes, and when. Without it, every multi-threaded program is undefined behavior. The model is built on happens-before - synchronization actions create happens-before edges; without an edge, no visibility guarantee.
Mechanical Detail¶
- Happens-before sources: program order within a thread;
synchronizedunlock → subsequent lock;volatilewrite → subsequent read;Thread.start→ first action in the new thread; thread termination →joinreturn; final-field freeze at constructor exit. volatile: visibility + ordering, not atomicity.volatile long/doublegot atomic in modern specs; pre-JMM they could tear.synchronizedblocks vsReentrantLock. The lock is nowLockSupport.park-backed and cooperates with virtual threads (Loom).java.util.concurrent:ConcurrentHashMap,CopyOnWriteArrayList,BlockingQueuefamily,Semaphore,CountDownLatch,CyclicBarrier,Phaser. Read the Javadoc; Doug Lea writes the best Javadoc in the language.- Atomics:
AtomicInteger/AtomicLong/AtomicReference. Pre-9:Atomic*FieldUpdater. Post-9:VarHandleis preferred. ThreadLocal- the perennial classloader-leak source in app servers. Avoid when possible; prefer scoped values (week 15).
Lab¶
Implement a single-producer single-consumer ring buffer two ways: with synchronized+wait/notify, and with VarHandle + acquire/release. JMH them. Run with -XX:+PrintAssembly (HotSpot debug build, or use the hsdis plugin on a normal build) and find the membar instructions.
Idiomatic Drill¶
Read JSR-133 (the JMM) cookbook. Re-read every volatile and synchronized use in a codebase you maintain.
Production Hardening Slice¶
Enable the race detector - wait, Java doesn't have one. The substitutes: ThreadSanitizer via the OpenJDK TSAN port (experimental), Lincheck (formal model-checker for concurrent classes), jcstress (Shipilëv's tool, for memory-model edge cases). Add a jcstress test for your ring buffer.
Week 14 - Executors, CompletableFuture, and the Pre-Loom World¶
Conceptual Core¶
Before virtual threads, the standard answer to "how do I run a lot of concurrent work without 100,000 OS threads" was asynchronous programming: CompletableFuture, reactive streams (Reactor / RxJava), or Netty's event loop. These still exist, still ship, and still appear in every framework. You must read them fluently - even though Loom obsoletes most reasons to write them.
Mechanical Detail¶
ExecutorServiceandThreadPoolExecutor- core/max pool size, queue choice (theLinkedBlockingQueuedefault is unbounded and the cause of half of all Java OOMs).ForkJoinPool- work-stealing, the substrate forparallelStreamand (originally)CompletableFuture.async*. The common pool is shared - don't park forever on it.CompletableFuture:supplyAsync,thenApply,thenCompose,thenCombine,exceptionally,handle. The "monadic composition" model. Pitfalls: exceptions wrapped inCompletionException, easy to forget the executor, chains that span threads with no structured cancellation.- Reactive Streams + Project Reactor (
Mono,Flux): backpressure, schedulers, the operator zoo. Spring WebFlux is built on this. The honest summary in 2026: most teams that adopted WebFlux for scaling reasons should now seriously consider virtual threads + plain blocking code; reactive remains right where you need explicit backpressure or operator fusion over data streams.
Lab¶
Implement the same web-scraper-with-fan-out three ways: blocking ExecutorService, CompletableFuture chain, Reactor Flux.flatMap. Compare lines of code, readability, and error handling. Keep them; you will re-do the same task with virtual threads next week.
Idiomatic Drill¶
Find a CompletableFuture chain in any large codebase. Trace which thread executes each stage. (Almost nobody gets this right on first read - that's the point.)
Production Hardening Slice¶
Audit every ExecutorService in your code: bounded queue? rejection policy? named threads (ThreadFactory)? graceful shutdown (shutdown + awaitTermination + shutdownNow fallback)?
Week 15 - Virtual Threads, Structured Concurrency, and Scoped Values¶
Conceptual Core¶
Project Loom changes the cost model of concurrency in the JVM. A virtual thread is a JVM-managed continuation scheduled onto a small pool of carrier OS threads. You can have millions cheaply. Blocking I/O is no longer expensive. This obsoletes the primary motivation for reactive programming in most server-side workloads.
Mechanical Detail¶
Thread.ofVirtual().start(runnable),Thread.startVirtualThread,Executors.newVirtualThreadPerTaskExecutor(). Always use the executor in production code.- A virtual thread parks when it hits a JDK-blocking call (
Socket.read,Files.read,LockSupport.park,Thread.sleep). The carrier thread is freed to run another virtual thread. The JVM handles continuation save/restore. - Pinning - the one footgun: a virtual thread inside
synchronizedcannot unmount (largely fixed in 24+; JEP 491 eliminates synchronized pinning). Before 24, preferReentrantLockfor any code path that may block under the lock. - Structured concurrency (JEPs 428 → 453 → 462 → 480 → 505, final in 25):
try (var scope = StructuredTaskScope.open()) { ... }. All forked subtasks are joined or cancelled at scope exit. Eliminates orphan tasks and "where did thisCompletableFutureexception go" debugging. - Scoped values (JEPs 429 → 446 → 487 → final in 25): immutable per-thread bindings that propagate through structured scopes. Replaces most
ThreadLocaluses, cleanly compatible with virtual threads, no leak risk.
Lab¶
Redo week 14's web scraper with Executors.newVirtualThreadPerTaskExecutor() + StructuredTaskScope. Compare lines of code and readability to the three previous versions. Stress to 100k concurrent requests. Watch with JFR's virtual-thread events.
Idiomatic Drill¶
Find every ThreadLocal in a codebase. Classify: replaceable with ScopedValue? required by a legacy API (MDC, security context)? Genuinely needed?
Production Hardening Slice¶
Add JFR events jdk.VirtualThreadPinned and jdk.VirtualThreadSubmitFailed to your monitoring. Any pinning over a few ms is an alert.
Week 16 - Lock-Free Patterns, VarHandle Memory Modes, and jcstress¶
Conceptual Core¶
You almost never need lock-free code. When you do - high-contention counters, single-producer single-consumer queues, latency-critical paths where mutex acquire-cost is intolerable - VarHandle's memory-ordering modes are the right tool, and jcstress is the only way to validate.
The decision rule: if synchronized or ReentrantLock gives you the latency and throughput you need, stop. Lock-free code is one to two orders of magnitude harder to get right than locked code. The few legitimate reasons to write it: documented hot-path contention (>100k ops/sec/thread on the same data), latency tail caused by lock convoy, or memory-ordering needs that the lock primitives don't express.
Mechanical Detail¶
-
VarHandlemodes in order of strictness:Plain- no ordering, no atomicity beyond hardware natural.Opaque- per-variable ordering (no reordering with itself), no inter-variable.Acquire/Release- one-way ordering (acquire-load sees all writes before the matching release-store).Volatile- full sequential consistency.
Map directly to C++20:
relaxed/relaxed+opaque/acquire+release/seq_cst. Use the weakest that proves correctness. - The classical patterns: lock-free SPSC queue (single-producer single-consumer - LMAX Disruptor is the famous example), Treiber stack (CAS on head), Michael-Scott queue (lock-free FIFO), hazard pointers (safe memory reclamation), RCU-style epoch reclamation. -LongAddervsAtomicLong: under contention,AtomicLongthrashes the cache line;LongAdderis striped across multiple cells, summed on read. The right choice for high-contention counters (request counts, metrics). -jcstress(org.openjdk.jcstress) is Shipilëv's harness: declare small concurrent fragments with@JCStressTest, annotate expected outcomes (@Outcome(id = "1, 1", expect = ACCEPTABLE)), and the harness exhaustively explores interleavings under multiple memory models. The unit-test framework for the JMM.
The trap
Using Plain mode and hoping it's "atomic enough." It isn't. Plain reads can return stale values forever; plain writes can be reordered with anything. Reach for Volatile or Acquire/Release; drop to Opaque only with a documented justification; never use Plain for shared state.
Lab¶
Implement a Treiber stack with VarHandle.compareAndSet (single VarHandle on the head pointer). Write three jcstress tests:
1. Linearizability - concurrent push + pop produces an ordering consistent with some serial schedule.
2. No lost pops - every pushed element is popped exactly once.
3. ABA exposure - under contention, a pop-then-push cycle can corrupt a CAS; document the scenario even if you don't fix it (the standard fix is hazard pointers or versioned pointers).
Run under all available -m modes (default, sequential consistency, relaxed).
Idiomatic Drill¶
Read the source of ConcurrentHashMap (Doug Lea). You will not understand all of it. Understand enough - specifically, how the table is striped, how resize hand-off works, and why every shared field is volatile. This is the gold standard for production lock-free Java code.
Production Hardening Slice¶
Add a "concurrency review" checklist to your hardening/ template:
- Every mutable shared field is justified (why not local + return?).
- Every lock has a documented invariant ("
monitorprotectscache.size <= maxSize"). - Every
volatilehas a documented happens-before edge ("write ininit()happens-before reads inprocess()"). - Every
Future/CompletableFuturechain has a defined cancellation path (no orphan continuations on the common pool). - Every
VarHandleuse names its memory mode in a comment.
Month 5 - Production & Distributed Systems (Weeks 17–20)¶
The first four months were the JVM and the language. Month 5 is everything between main and a paying customer: frameworks (Spring Boot 3 / Quarkus), observability (Micrometer + OpenTelemetry), RPC (gRPC), resilience (Resilience4j), data (JDBC / JPA / R2DBC / jOOQ), containerization, and the testing pyramid that makes any of it safe to ship.
Weeks¶
- Week 17 - Spring Boot 3 and Quarkus
- Week 18 - Observability: Logs, Metrics, Traces
- Week 19 - Persistence, RPC, and Resilience
- Week 20 - Containers, Native Images, and Deployment
Month 5 Exit Criteria¶
You can: - Stand up a production-shaped JVM service in either Spring Boot 3 or Quarkus. - Wire full observability (logs/metrics/traces, correlated) and explain every component. - Choose persistence (JPA vs jOOQ vs Spring Data JDBC) and RPC (REST vs gRPC) with reasons. - Apply resilience patterns (circuit breaker, retry, bulkhead) without cargo-culting. - Pick a deployment artifact (JRE/jlink/Buildpacks/native) for a specific operational profile.
You are now ready for Month 6 - capstone.
Week 17 - Spring Boot 3 and Quarkus¶
Conceptual Core¶
Spring Boot is the de facto JVM web framework: vast ecosystem, opinionated defaults, deep autoconfiguration. Quarkus is the modern challenger: designed around GraalVM native-image from day one, faster cold-starts, leaner footprint. You should know both well enough to choose, and choose for reasons, not vibes.
The trade is roughly: - Spring Boot wins on ecosystem breadth, hireability, and time-to-first-working-feature for any well-known integration (databases, queues, OAuth, anything off-the-shelf). - Quarkus wins on cold start (~30ms native vs ~3s JVM), RSS (~30MB native vs ~250MB JVM), and consistency of dev-loop (live-reload built in, not retro-fitted via DevTools).
Mechanical Detail¶
- Spring Boot 3 uses Jakarta EE 9+ namespaces -
jakarta.servlet.*, notjavax.servlet.*. This break stranded every pre-2022 library that hadn't migrated; verify your transitives. - Spring autoconfiguration: starter dependencies (
spring-boot-starter-web) pull in defaults;@ConditionalOn*annotations gate beans on classpath / property / missing-bean conditions.application.ymlwith profiles (spring.profiles.active=prod). Prefer@ConfigurationProperties(typed) over@Value(untyped) for non-trivial config. Slice tests (@WebMvcTest,@DataJpaTest) load only the relevant slice - much faster than@SpringBootTest. - Spring's three controller models: blocking MVC (now on virtual threads since 3.2 with
spring.threads.virtual.enabled=true- almost always the right default for new projects), reactive WebFlux (Reactor-based; pick only if you need explicit backpressure or operator fusion over streams),RouterFunctionDSL (functional alternative to@Controller). - Quarkus: build-time DI/AOP - the design choice that makes
native-imagetractable (reflection metadata is resolved at build, not runtime).@QuarkusTest, live reload via./mvnw quarkus:dev, the extensions catalog (quarkus-jdbc-postgresql,quarkus-resteasy-reactive, ...). Native build:./mvnw package -Pnative. - Micronaut (briefly): third option, same build-time-DI philosophy as Quarkus. Smaller ecosystem; pick if you specifically need its features (compile-time AOP, GraalVM-first cloud SDK integrations).
- Honest 2026 default: Spring Boot on virtual threads for established teams; Quarkus for greenfield + serverless + native-image.
The trap
Enabling spring.threads.virtual.enabled=true while still using synchronized blocks that wrap I/O. Pre-JDK 24, that pins the virtual thread to its carrier and erases the benefit (see Week 15). Audit your dependencies - JDBC drivers were notable offenders until ~2024.
Lab¶
Build the same small REST service (book CRUD, JSON over HTTP, Postgres backend) in both Spring Boot 3 and Quarkus. Measure:
- Build time (time ./mvnw package)
- Image size (du -sh target/quarkus-app vs the Spring jar)
- Cold start (time-to-first-response after launch)
- Warm p99 latency under load (k6 or wrk, 100 RPS for 60s)
- RSS at steady state (ps -o rss -p $(pgrep -f yourapp))
Document the trade in a one-pager.
Idiomatic Drill¶
Read the autoconfiguration source for one Spring starter (spring-boot-starter-web is the canonical one). Trace how @SpringBootApplication discovers it via META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, and how @ConditionalOnMissingBean lets your @Bean override the default.
Production Hardening Slice¶
Add Spring Boot Actuator endpoints to your template:
- /actuator/health (liveness + readiness probes)
- /actuator/metrics + /actuator/prometheus (Micrometer + Prometheus scrape)
- /actuator/info (build info - git.properties, META-INF/build-info.properties)
Quarkus equivalents: quarkus-smallrye-health, quarkus-micrometer-registry-prometheus, quarkus-info.
Lock down Actuator: expose only health and info publicly; gate metrics, env, loggers behind authentication or a private network.
Week 18 - Observability: Logs, Metrics, Traces¶
Conceptual Core¶
Three pillars, one mental model: - Logs are events. High cardinality, structured, expensive at scale, sampled in production. - Metrics are aggregates. Low cardinality, cheap, always-on, dashboards-and-alerts. - Traces are causal chains. Per-request, sampled, the only pillar that answers "what did this specific call do?"
In 2026 the cross-language standard is OpenTelemetry for traces (and increasingly for metrics and logs). The JVM-idiomatic metrics library is Micrometer. Logs stay on SLF4J + Logback / Log4j2 - OTel logs is shipping but adoption is still incremental.
Mechanical Detail¶
- Logs: SLF4J as the facade, Logback or Log4j2 as the backend. JSON layout via
logstash-logback-encoderor Log4j2'sJsonLayout. MDC (Mapped Diagnostic Context) for request-scoped fields - set in a request filter, read implicitly by every log line. Sample high-volume events (every 100thGET /healthz); never sample errors. - Metrics (Micrometer):
MeterRegistryis the entry point. Core meter types:Counter(monotonic),Gauge(point-in-time),Timer(latency histograms),DistributionSummary(non-time histograms),LongTaskTimer(in-flight long ops). Pluggable backends: Prometheus, Datadog, CloudWatch, Dynatrace, etc. Bind JVM metrics:JvmMemoryMetrics,JvmGcMetrics,JvmThreadMetrics,ProcessorMetrics,JvmHeapPressureMetrics,ClassLoaderMetrics. - Traces (OpenTelemetry Java agent):
-javaagent:opentelemetry-javaagent.jarauto-instruments JDBC, HTTP clients/servers, Kafka, gRPC, Redis, and 100+ other libraries without code changes. Manual spans viaGlobalOpenTelemetry.getTracer(...).spanBuilder("name").startSpan(). Attribute names follow OTel semantic conventions (http.request.method,db.system.name, etc.) - adhere to them so dashboards built for one service work for another. - Correlation: the OTel agent injects
trace_idandspan_idinto MDC automatically. Configure your log layout to include them (%X{trace_id}in Logback). Now traces and logs are joinable in the backend (Grafana, Datadog, Honeycomb) by trace ID - the single highest-leverage observability investment you can make.
The trap
High-cardinality tags on metrics. Adding user_id or request_id as a Micrometer tag explodes Prometheus storage (each unique value is a separate time-series). Tags should be bounded sets: HTTP method, status class, endpoint pattern (not URL). Per-user data belongs in traces or logs, not metrics.
Lab¶
Wire all three pillars into your Week 17 service:
- OTel agent: download opentelemetry-javaagent.jar, set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317.
- Micrometer Prometheus: add micrometer-registry-prometheus, expose /actuator/prometheus.
- Logback JSON: replace the default text layout with LogstashEncoder + %X{trace_id} in the pattern.
Bring up a local stack via docker-compose: Prometheus + Tempo (traces) + Loki (logs) + Grafana. Generate load with k6 run script.js or wrk -t4 -c100 -d60s. In Grafana, find a slow request: open its trace, click through to the matching log lines via trace ID.
Idiomatic Drill¶
Add three custom Micrometer metrics that would help an SRE during a real incident - examples:
- request_queue_depth (gauge) - work waiting for a thread.
- db_connection_wait_seconds (timer) - time spent waiting for a Hikari connection.
- outbound_retry_count (counter, tagged by target service) - Resilience4j retry hits.
For each, write the one-line justification: "this metric tells me X when Y is failing."
Production Hardening Slice¶
Document an "observability contract" for every service in your template:
- Health endpoint at
/actuator/healthdistinguishing liveness vs readiness. - Metrics endpoint at
/actuator/prometheusin Prometheus exposition format, JVM + HTTP + custom metrics included. - Traces exported to a configurable OTLP endpoint via the OTel agent.
- Logs JSON-structured to stdout (let the container runtime ship them), with
trace_idinjected.
One README section per item. Any service that doesn't meet all four is not production-ready.
Week 19 - Persistence, RPC, and Resilience¶
Conceptual Core¶
Three independent topics, taught together because they're the substrate of every backend service. Persistence - how data is stored. RPC - how services talk. Resilience - how the system survives the inevitable failure of both.
Mechanical Detail¶
- Persistence:
- JDBC - the substrate. HikariCP is the connection pool default; size it (
maximumPoolSize) deliberately - too large hurts the DB more than your service. - JPA / Hibernate - the heavyweight ORM. Knows everything; surprises you with lazy-loading N+1s and
LazyInitializationException. Use with discipline (DTO projections,@EntityGraph, query logging in dev). - jOOQ - typed SQL DSL, no magic. The pragmatic alternative to JPA for teams that prefer SQL to mappings.
- Spring Data JDBC - a middle ground: aggregate-root oriented, no lazy loading, no proxies, no caches. Worth a hard look.
- R2DBC - reactive JDBC. Use only if you're committed to reactive end-to-end; otherwise virtual threads + JDBC is simpler.
- Flyway or Liquibase for schema migrations. Versioned, in source control, applied at startup or by CI.
- gRPC:
.protodefinitions, the protoc Java plugin, generated stubs (blocking, async, reactive).- Deadlines (
stub.withDeadlineAfter(...)), interceptors (auth, tracing, metrics), retries (built-in via service config). - gRPC + virtual threads = excellent fit; gRPC + reactive = also fine.
- Resilience4j: circuit breaker, retry, rate limiter, bulkhead, time limiter. Compose them. Spring Cloud Circuit Breaker is a thin abstraction over it.
Lab¶
Add to your week-17 service: a Postgres backend via Spring Data JDBC + Flyway, a downstream gRPC dependency (a fake "pricing service") with a Resilience4j circuit breaker, and 95th-percentile latency-bound retries. Chaos-test by killing the downstream and watching the breaker behavior in metrics.
Idiomatic Drill¶
Read every Resilience4j configuration knob for the circuit breaker. Set them with intent for your lab.
Production Hardening Slice¶
Add to hardening/: a Hikari config block with comments justifying every setting; a BaseGrpcInterceptors class wiring tracing, metrics, and deadlines.
Week 20 - Containers, Native Images, and Deployment¶
Conceptual Core¶
The artifact you ship is a container image (and a Kubernetes manifest, usually). Java has gained excellent first-class container support - jlink runtime images, Buildpacks, GraalVM native-image, Spring Boot's bootBuildImage. Picking among them is now a design decision, not an afterthought.
Mechanical Detail¶
- Plain JRE image - fast to build, large (~200MB+ for a base + app). Fine for internal apps.
jlinkcustom runtime -jdepsto find required modules,jlink --add-modules ... --strip-debug --no-man-pages --no-header-files --compress=2to produce a ~50MB JRE.- Buildpacks (
spring-boot:build-image,packCLI) - opinionated, layered, reproducible. The CNCF default. - GraalVM
native-image(orquarkus:native) - single static binary, ~30-50MB, sub-100ms cold start, lower peak throughput. Reflection/dynamic proxies needreachability-metadata.json(often auto-collected via tracing agent). - Project Leyden AOT artifacts (early stages in 25): training run, write AOT cache (
-XX:AOTMode=record/-XX:AOTMode=create), load on next start (-XX:AOTMode=on). Closes some of the gap to native-image without giving up the JIT. - Kubernetes specifics: requests/limits aligned with
-XX:MaxRAMPercentage, liveness vs readiness probes (don't conflate), graceful shutdown viaSIGTERMandserver.shutdown=gracefulin Spring Boot.
Lab¶
Produce four images of your service: plain JRE Dockerfile; jlink-trimmed; Buildpacks; native-image. Tabulate size, cold start, warm p99, RSS. Pick a winner for an explicit deployment profile (e.g., "always-on internal API" vs "scale-to-zero per-request webhook").
Idiomatic Drill¶
Write a sane Dockerfile from scratch - multi-stage, non-root user, tini as PID 1, -XX:MaxRAMPercentage=75.0, JFR enabled. Then realize Buildpacks gave you all of that and consider when each is right.
Production Hardening Slice¶
Add to hardening/: a Dockerfile, a pack build script, a native profile, a sample Kubernetes manifest with probes/limits/shutdown configured.
Month 6 - Capstone (Weeks 21–24)¶
The first five months were instrumented practice. Month 6 is one substantial project, defended end-to-end. Pick one of the three tracks in CAPSTONE_PROJECTS.md. The schedule below is track-agnostic - the deliverables differ, the cadence doesn't.
Estimated effort: 60–80 hours over four weeks. Real engineering, not toy code.
Weeks¶
- Week 21 - Design and Foundation
- Week 22 - Core Implementation
- Week 23 - Failure, Observability, and Operations
- Week 24 - Defense
Month 6 Exit Criteria¶
You have: - One non-trivial system you built end-to-end, in modern Java, on the JVM you understand from runtime to deployment. - A repo, a runbook, a writeup, and a published artifact. - A defensible answer to "why Java for this?" - and an equally defensible answer to "where Java was the wrong choice."
You are now a master-level Java engineer. The curriculum is done. The career is not.
After Month 6¶
Pick one of:
- Contribute upstream. OpenJDK (see
APPENDIX_C_CONTRIBUTING_TO_OPENJDK.md), or a major framework (Spring, Quarkus, Micronaut), or an infrastructure project (Kafka, Cassandra, Elasticsearch, Trino - all heavyweight JVM codebases that train you on real-world performance work). - Specialize. Pick one of: JIT/Graal compiler, GC research (ZGC, Shenandoah, Generational ZGC), Loom continuations, Panama/FFI, Valhalla value classes, Vector API.
- Cross-pollinate. Take what you know about the JVM into Kotlin (still a JVM language; Kotlin coroutines vs Loom is an instructive contrast), or learn a non-JVM language with deep contrast: Rust (no GC, ownership), Go (
GO_LEARNIN_PLAN/- you have a 24-week roadmap for that too), or a strict-FP language (OCaml, Haskell).
The deliverable of mastery is not "I know Java." It is "I can pick the right tool, and when it's Java, I know which knob to turn."
Week 21 - Design and Foundation¶
Conceptual Core¶
A capstone that isn't designed first becomes a tutorial. Spend the first week producing a written design document - what you are building, what you are not building, what invariants you guarantee, how you will know it works.
Deliverables¶
- Design doc (3–8 pages): goals, non-goals, system diagram, data model, failure model, observability story, testing strategy.
- Skeleton repo: build, lint, format, CI, JUnit 5, JMH module, JFR config, Dockerfile, OpenTelemetry wiring. Use your
hardening/template from months 1–5 as the starting point. - Walking skeleton: end-to-end path through every major component, even if each component is a stub. "Hello world from every box on the diagram."
Track-specific notes¶
- Distributed storage: read the Raft paper (Ongaro). Decide on snapshot strategy and log compaction up front. Pick a wire protocol (gRPC is the default).
- Service mesh: decide on service discovery (in-memory registry for the lab; etcd or Consul if ambitious). Define the deadline-propagation contract.
- Streaming pipeline: decide on persistence (segmented append-only files like Kafka; or RocksDB). Define the delivery contract (at-least-once is realistic).
Hardening slice¶
CI runs: build + test + JMH smoke + integration test in containers. Push the green button before week 22.
Week 22 - Core Implementation¶
Conceptual Core¶
Build the core happy path. Cut corners on operations (you'll fix in week 23), cut corners on edge cases (you'll fix in week 24), but the core algorithm must be correct and tested.
Deliverables¶
- Distributed storage: leader election + log replication + linearizable single-key reads, with a 3-node integration test.
- Service mesh: end-to-end gRPC call through registry + load-balancer + deadline propagation, with at least two backend services.
- Streaming pipeline: producer → broker → consumer with persistent log and at-least-once semantics.
Testing posture¶
- Unit tests at the algorithm level (Raft state machine transitions, mesh routing decisions, log segment append/read).
- Integration tests with Testcontainers (3-node cluster, 2-service mesh, broker + N consumers).
- One property-based test (jqwik) per core invariant.
- One
jcstresstest if your project has any custom concurrency (it probably does).
Hardening slice¶
JFR continuous recording on. Structured logs through OpenTelemetry. Prometheus metrics for every operation.
Week 23 - Failure, Observability, and Operations¶
Conceptual Core¶
Make it survive failure. Make it diagnosable. This is the week that separates a project from a system.
Deliverables¶
- Failure injection: kill a node mid-operation. Network partitions (use
iptablesrules in Testcontainers, or Toxiproxy). Slow disk. Verify the documented invariants hold or, where they cannot, document the user-visible behavior. - Observability dashboards: Grafana dashboards (committed as JSON) for the three pillars. One "is it healthy" dashboard. One "what's happening" dashboard.
- Runbook: a
RUNBOOK.mdwith: top 5 alerts, what each means, what to check, how to mitigate. Treat it as the on-call handoff document. - Capacity test: one published JMH or k6 run showing throughput and latency under steady load and under fault.
Track-specific notes¶
- Distributed storage: partition the leader. Verify a new leader is elected within the documented timeout and reads return the latest committed write.
- Service mesh: kill a backend mid-RPC. Verify retry + circuit breaker + outlier ejection.
- Streaming pipeline: kill a consumer mid-batch. Verify replay from last committed offset; no data loss; bounded duplication.
Hardening slice¶
Promote your hardening/ template to a public template repo. README + make new-service scaffolding script.
Week 24 - Defense¶
Conceptual Core¶
A capstone that isn't defended is unfinished. Write up what you built, present it (even if only to a rubber duck or an LLM playing skeptic), and rehearse the answers to the questions you do not yet have answers to.
Deliverables¶
- Postmortem-style writeup (5–15 pages): goals, what you built, design choices and their alternatives, surprises, things you would do differently, things you would do next.
- Demo script (10–20 minutes): walk through the system, run a chaos scenario live, show the dashboards reacting, recover.
- Open issues file: every known limitation, with a triage tag (
won't fix,next version,bug,tech debt). - Publish: GitHub-public the repo. Write a blog post. (If you finish the curriculum and don't publish, the curriculum did not finish you.)
The defense questions to rehearse¶
- "Why this design and not the alternative I'm thinking of?"
- "What happens at 10x the load? At 100x?"
- "What's the worst bug still in here?"
- "How would you re-architect this knowing what you know now?"
- "What did you learn about the JVM that you couldn't have learned without building this?"
Hardening slice¶
Final commit on the hardening/ template includes a "capstone-grade checklist" - every box your project ticked.
Appendix A - Production Hardening¶
The tools and recipes that turn a working JVM service into one you can debug at 3 AM. This appendix is referenced from every month's "Production Hardening Slice." By the end of Month 6, the hardening/ directory in your repo should contain runnable versions of everything below.
A.1 The Diagnostic Toolbox¶
| Tool | Where it lives | When to reach for it |
|---|---|---|
jcmd |
JDK bin/ |
Always-on Swiss-army knife: heap dump, JFR start/stop, thread dump, GC commands, flag inspection. |
jstack |
JDK bin/ |
One-shot thread dump. Usually jcmd <pid> Thread.print is cleaner. |
jmap |
JDK bin/ |
Heap dumps. Largely subsumed by jcmd <pid> GC.heap_dump. |
jhsdb |
JDK bin/ |
Serviceability agent - post-mortem core dump analysis. Last resort but invaluable. |
jfr (CLI) |
JDK bin/ |
Process JFR recordings without JMC. jfr print --events ... file.jfr. |
| JDK Mission Control (JMC) | Separate download | JFR GUI. Read recordings produced by jcmd JFR.dump. |
| async-profiler | github.com/async-profiler | CPU, alloc, lock, wall-clock sampling. Flame graphs. The single most useful add-on tool for the JVM. |
| Eclipse MAT | eclipse.org/mat | Heap-dump analysis. Dominator tree + leak suspects. |
| JMH | openjdk.org/projects/code-tools/jmh | Microbenchmarks. The only correct way. |
jcstress |
openjdk.org/projects/code-tools/jcstress | JMM stress tests for concurrent code. |
| GCViewer / gceasy.io | Third-party | GC log visualization. |
| VisualVM | visualvm.github.io | Older, lighter GUI for live monitoring. Useful for dev. |
hsdis |
OpenJDK extension | Disassembly plugin for -XX:+PrintAssembly. |
Install them all. Know which to reach for in 30 seconds.
A.2 The Always-On JVM Flags¶
A defensible default set for a containerized service:
# Memory
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=75.0
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:ReservedCodeCacheSize=256m
# GC (pick one)
-XX:+UseG1GC
# or for latency-sensitive multi-GB heaps:
# -XX:+UseZGC
# Diagnostics
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heap.hprof
-XX:+ExitOnOutOfMemoryError
-Xlog:gc*=info,safepoint=info:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=10M
# JFR - continuous recording
-XX:StartFlightRecording=filename=/var/log/jfr/app.jfr,maxsize=200M,maxage=24h,settings=profile,dumponexit=true
# Compact object headers (24+, when stable in your JDK)
# -XX:+UseCompactObjectHeaders
# Modern JIT
# -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler (Graal as C2)
Every flag has a justification comment in your hardening/ template. No copy-paste-and-forget.
A.3 The Continuous-JFR Pattern¶
JFR has negligible overhead (~1% by default) and is the gold-standard production profiler. Two ways to run it:
- Boot-time: the
-XX:StartFlightRecording=...flag above. - On-demand:
jcmd <pid> JFR.start name=adhoc duration=120s filename=/tmp/adhoc.jfr settings=profile.
Pattern: keep a rotating buffer always on; when an alert fires, snapshot the buffer (jcmd <pid> JFR.dump name=adhoc filename=/tmp/snapshot.jfr) and ship it to S3 (or equivalent). Now you can diagnose a transient incident from the recording, not from speculation.
Events worth alerting on:
- jdk.GCPauseTime > 200ms (G1) or > 10ms (ZGC).
- jdk.OldObjectSample growth (slow leak).
- jdk.VirtualThreadPinned > 20ms (Loom pinning).
- jdk.CPULoad sustained > 0.9.
- jdk.SocketRead duration p99 above your SLO.
A.4 Heap-Dump Triage in Five Steps¶
When you have a hprof file:
- Open in MAT. Choose "Leak Suspects" report. Read it first - it's right surprisingly often.
- Open the Dominator Tree, sorted by retained heap. The top 5 entries explain 80%+ of memory.
- For each suspicious dominator, right-click → "Path to GC Roots → exclude weak/soft references". This tells you why it's alive.
- If the retainer is a framework collection (
HashMap,ConcurrentHashMap), open it and inspect entries - usually the keys reveal the leak (e.g., per-request keys that never expire). - Fix, redeploy, re-dump after the same load to confirm.
A.5 The CPU-Flame-Graph Pattern (async-profiler)¶
# Attach for 30s, sample CPU, emit interactive flamegraph
asprof -e cpu -d 30 -f cpu.html <pid>
# Allocation profile (TLAB + outside-TLAB)
asprof -e alloc -d 30 -f alloc.html <pid>
# Wall-clock (great for "where is my service waiting")
asprof -e wall -d 30 -f wall.html <pid>
# Lock contention
asprof -e lock -d 30 -f lock.html <pid>
Wall-clock flame graphs are underrated - they show you blocked time, which CPU profiles miss entirely. Run them whenever a service is "slow but the CPU is fine."
A.6 JMH Conventions¶
A defensible JMH suite:
- Separate Maven/Gradle module so the JMH annotation processor doesn't pollute your main artifact.
- One class per benchmarked scenario, with
@State(Scope.Benchmark)orScope.Threadchosen deliberately. @Fork(value = 3, jvmArgsAppend = {"-XX:+UseG1GC"})minimum - three forks gives noise estimates.@Warmup(iterations = 5, time = 1),@Measurement(iterations = 10, time = 1)as baseline.- Always emit
Mode.ThroughputandMode.AverageTimefor the same benchmark - they reveal different things. - Profile with
-prof gcto see allocation rate;-prof async:output=flamegraphfor flame graphs. - CI gates on regression vs baseline, never absolute numbers (CI is too noisy for absolutes).
A.7 The Pre-Production Checklist¶
Before any new service goes live, walk this list:
-
MaxRAMPercentageset; container request/limit aligned. - GC chosen with a documented reason.
- Heap dump on OOM enabled, write path writable.
- JFR continuous recording enabled, rotation configured.
- GC logs to disk with rotation.
- All
ExecutorServices are bounded, named, gracefully shutdown. - All
ThreadLocals justified or replaced withScopedValue. - OpenTelemetry traces flowing to the platform's collector.
- Micrometer Prometheus endpoint exposed.
- Logs JSON-structured, with trace-id correlation.
- Liveness and readiness probes distinct, with sane timeouts.
-
SIGTERMtriggers graceful shutdown; tested. - Dependencies pinned; SBOM generated (CycloneDX).
- Security scan passes (Trivy/Grype on the container).
- Runbook exists with top-5 alerts and mitigation steps.
- One synthetic load test recorded as a baseline.
The list is the artifact. Put it in hardening/CHECKLIST.md and tick boxes by hand for every release until you automate it.
Appendix B - The Legacy Java Field Guide¶
Roughly half the Java you will be paid to read was written before Java 8. The other half was written by someone who learned Java before Java 8. This appendix is the field guide to the sediment - what each legacy idiom is, why it exists, why it's wrong now, and what to replace it with.
If you finish the main curriculum without this appendix, you'll trip over Vector on your first day at a real job. Don't.
B.1 Collections and Data Types¶
Vector, Hashtable, Stack¶
- What: Synchronized collections from Java 1.0. Every method takes a lock.
- Why it exists: Java 1.0 had no
java.util.concurrent. - Why it's wrong: Lock overhead per call; coarse-grained; iteration is not atomic anyway.
- Replace with:
ArrayList+ explicit synchronization, orCollections.synchronizedList, or - almost always the right answer -ConcurrentHashMap/CopyOnWriteArrayList/ArrayDeque.
Enumeration<E>¶
- What: Pre-
Iteratoriteration interface.hasMoreElements()/nextElement(). - Replace with:
Iterator(or, better, an enhancedforloop, or aStream).
Date, Calendar, SimpleDateFormat¶
- What: Java 1.0/1.1 date/time API.
- Why it's wrong: Mutable (
Date.setTime); not thread-safe (SimpleDateFormathas a mutableCalendarfield - every shop has been bitten by this); confused semantics (Dateis "instant" but pretends to be calendar;Calendarlives in default timezone; months are 0-indexed). - Replace with:
java.time(JSR-310, since 8).Instant,Duration,LocalDate,LocalDateTime,ZonedDateTime,OffsetDateTime,Period,DateTimeFormatter. Never mix the two in new code; for boundaries with legacy APIs, convert at the seam (Date.from(instant),instant.atZone(...))..
Raw types (List, Map without generics)¶
- What: Pre-Java-5 collections.
- Why it's wrong: No compile-time type safety; "unchecked" warnings; possible heap pollution.
- Replace with: Parameterized types. If maintaining old code, add generics gradually; the compiler will guide you.
Arrays-of-Object as poor-man's tuples¶
- Replace with:
record(since 14, final 16). Arecord Pair<A,B>(A a, B b) {}is the answer.
B.2 Concurrency¶
Thread t = new Thread(...); t.start();¶
- What: Direct OS-thread spawning.
- Why it's wrong: Unbounded creation kills servers; no naming, no lifecycle, no result, no cancellation.
- Replace with: An
ExecutorService(now usuallyExecutors.newVirtualThreadPerTaskExecutor()since 21), orThread.startVirtualThreadfor fire-and-forget tasks.
Thread.stop, Thread.suspend, Thread.resume¶
- What: Deprecated since Java 1.2 (!).
Thread.stopremoved in 20+. - Replace with: Cooperative cancellation via
volatile boolean running, orThread.interrupt()+ handlingInterruptedException.
synchronized everything¶
- What: The intrinsic lock attached to every Java object.
- Why it's complicated now: Pre-24, virtual threads inside
synchronizedblocks pin to the carrier thread. JEP 491 (24+) fixes this for most cases. - Replace with for blocking code paths:
ReentrantLock(orStampedLockfor read-heavy). For Loom-friendly code on pre-24 JVMs, preferReentrantLockoversynchronizedif the critical section may block. - Keep
synchronizedfor: small, non-blocking critical sections; pre-existing public APIs; cases where you know virtual threads aren't in play.
wait() / notify() / notifyAll()¶
- What: The intrinsic-lock-bound condition-variable mechanism.
- Why it's wrong: Fragile (spurious wakeups, lost notifications, signal-all-vs-signal); no
tryWait; hard to compose. - Replace with:
BlockingQueuefor producer/consumer,Semaphorefor resource pools,CountDownLatchfor one-shot signals,Phaserfor multi-phase barriers,Conditionon aReentrantLockif you must.
sun.misc.Unsafe¶
- What: Internal API for low-level memory ops; widely abused for 20 years.
- Why it's wrong: Internal, unsupported, increasingly restricted, deprecation-for-removal in progress (JEP 471 + follow-ups).
- Replace with:
VarHandle(9+) for atomic ops and ordering,MemorySegment(Panama, stable in 22) for off-heap memory.
ThreadLocal everywhere¶
- What: Per-thread storage.
- Why it's complicated: Classloader leaks in app servers; doesn't compose with virtual threads cheaply (each VT has its own); semantically a hidden global.
- Replace with:
ScopedValue(final in 25) for bounded scopes. KeepThreadLocalonly where required by external API (SLF4J's MDC, Spring'sSecurityContextHolderuntil they migrate).
B.3 Exceptions and Error Handling¶
throws Exception on every method¶
- Why it's wrong: Defeats checked-exception type safety; forces callers to catch-all.
- Replace with: Specific checked exceptions for genuinely recoverable I/O at boundaries;
RuntimeExceptionsubclasses inside business logic.
catch (Exception e) { e.printStackTrace(); }¶
- The most common production code smell in Java. Replace with:
- Logged, contextualized:
log.error("failed to X for {}", id, e); - Or rethrown with context:
throw new MyDomainException("X failed for " + id, e); - Never swallowed silently.
Checked exceptions in stream pipelines¶
- What:
Stream.map(x -> doIO(x))doesn't compile ifdoIOthrows checked. - Replace with: Wrap in a
RuntimeException, or use a library (jOOλ,Vavr), or - increasingly - skip streams and use an enhancedforloop with virtual threads behind it.
finally { ... close(); ... }¶
- Replace with: try-with-resources (since 7). Any
AutoCloseable. Resources are closed in reverse order; suppressed exceptions are attached automatically.
finalize()¶
- What: Deprecated since 9; for-removal trajectory (JEP 421 deprecates for removal).
- Why it's wrong: Unpredictable timing, can resurrect objects, performance disaster.
- Replace with:
try-with-resources+ explicitclose(). For native resources requiring eventual cleanup,java.lang.ref.Cleaner(since 9).
B.4 I/O and Serialization¶
java.io.Serializable¶
- Why it's wrong: Notorious source of security vulnerabilities (deserialization gadgets); fragile schema; coupled to internal field names.
- Replace with: JSON (Jackson), protobuf, Avro, or any explicit schema. Never accept untrusted
Serializableinput. JEP 154 (Serialization 2.0 / record-based serialization) has been explored but not delivered as of 25.
FileInputStream / FileOutputStream patterns¶
- Replace with:
java.nio.file.Files.readAllBytes,Files.lines,Files.newBufferedReader. ThePath-based API is the modern one.
Reading text without specifying charset¶
- Why it's wrong: Platform-default charset varies (and JEP 400 changed it to UTF-8 in 18, but legacy assumptions remain).
- Replace with: Always specify
StandardCharsets.UTF_8.
JNI¶
- What: The original native-interop mechanism.
- Why it's complicated: Boilerplate (
javah/javac -h), unsafe by construction, slow ergonomics. - Replace with: Panama / Foreign Function & Memory API (
java.lang.foreign, stable in 22+). Bind a C library declaratively, no C code on your side.
B.5 Frameworks and Ecosystem¶
J2EE / Java EE → Jakarta EE¶
- What: In 2018 Oracle transferred Java EE to the Eclipse Foundation, renamed "Jakarta EE." All
javax.*packages becamejakarta.*starting Jakarta EE 9 (2020). Spring Boot 3 (2022) made the jump. - Consequence: Mixing pre-Spring-Boot-3 libraries (still
javax.servlet) with Spring Boot 3 (which expectsjakarta.servlet) does not work. This is the migration headache of the early 2020s and still appears in stale codebases.
EJB¶
- Replace with: Spring or Quarkus or Micronaut. Nobody writes new EJBs.
JSP / JSTL / Struts / JSF¶
- Replace with: A modern templating engine (Thymeleaf, Freemarker) or a SPA frontend with a JSON API behind it.
Applets / Java Web Start¶
- Removed. If you see these, the project is older than the engineer maintaining it.
Log4j 1.x, java.util.logging (JUL) directly¶
- Replace with: SLF4J facade + Logback or Log4j2. (Log4j 1.x is EOL and had its own CVE history.)
JUnit 4 (@RunWith, @Rule)¶
- Replace with: JUnit 5 (Jupiter). Migration is mostly mechanical (
@Before→@BeforeEach, etc.). Vintage engine can run both side-by-side during migration.
Mockito 1.x style (@InjectMocks-everywhere)¶
- Modernize to Mockito 5+, prefer constructor injection in tests, use
@ExtendWith(MockitoExtension.class).
B.6 Language Features That Aged¶
enum as "the only ADT we have"¶
- Modernize with:
sealed interface+ records for true sum types.
Visitor pattern for ADT dispatch¶
- Replace with: Pattern matching for
switchover sealed types.
Anonymous inner classes for callbacks¶
- Replace with: Lambdas (since 8) and method references.
Telescoping constructors / Builder boilerplate¶
- For DTOs: records (with compact constructor for validation).
- For complex objects: keep the builder pattern, but consider
record+withers(the new pattern in modern stdlib code).
Lombok everywhere¶
- The 2026 stance: records replace
@Data/@Valuefor immutables.@Builderstill has no JDK equivalent.@Slf4jis convenience. Use Lombok sparingly and only where the JDK has no answer. Be aware: Lombok is a build-time bytecode-rewriter and breaks IDE/tooling pipelines occasionally.
Reflection-driven everything¶
- Modernize with:
MethodHandle/VarHandlefor performance; build-time DI (Quarkus, Micronaut) when native-image matters.
B.7 Removed / Deprecated for Removal in Modern JDKs¶
Keep this list mental - when you see them in old code, you know you're rewriting:
- CMS GC: removed in 14. ParallelOldGC: removed in 15. Code that sets
-XX:+UseConcMarkSweepGCwill fail to start on modern JVMs. - Nashorn (the JS engine in the JDK): removed in 15.
- Java applets,
Applet,JApplet: removed in 17. SecurityManager: deprecated for removal in 17 (JEP 411); removal in progress. Most code that relied on it had no business doing so.- RMI activation: removed in 17.
sun.misc.Unsafememory-access methods: deprecated for removal (JEP 471). Migrate toVarHandle+MemorySegment.- Pre-
java.timedate API: not removed, but treated as legacy; converted at the boundary only. finalize(): deprecated for removal.- The "old" Reflective access to
java.baseinternals: progressively closed since 9; by 17,--illegal-access=denyis enforced.
B.8 Migration Strategy¶
When inheriting a Java 8 (or 11) codebase, in order:
- Bump the JDK to 21 LTS (or 25 LTS). Don't refactor; just upgrade. Fix breakage at the seam (
javax.*→jakarta.*if you're moving to Spring Boot 3, removed flags, removed APIs). - Run the static analyzers (ErrorProne, SpotBugs, IntelliJ inspections). Knock down the warning count by 80% in low-risk passes.
- Modernize at the boundary first. New code uses records, sealed types, pattern matching, virtual threads. Old code stays until you have a reason to touch it.
- Migrate
Date/Calendaronly at the seams. Internal code goesInstantend-to-end; only at the external API does conversion happen. - Replace
Thread-spawning with executors. Bound them. Name them. Shut them down. - Bring up observability before refactoring concurrency. You cannot safely change concurrency you cannot measure.
- Then, and only then, consider larger refactors (Spring Boot 2 → 3, JPA → jOOQ, reactive → Loom, etc.).
The legacy is not the enemy. The legacy is what's keeping the lights on. Modernize with respect - and with tests.
Appendix C - Contributing to OpenJDK¶
The deliverable of master-level Java is the credible claim that you can debug, patch, and (modestly) extend the JDK itself. This appendix is the entrance guide: how the project is structured, where to start, and how to land your first change.
It is not a substitute for the official OpenJDK developer guide (openjdk.org/guide/) - it is the on-ramp to it.
C.1 The Project Topology¶
- OpenJDK is the reference implementation. Owned by Oracle, governed by the OpenJDK community, vendored by Adoptium, Amazon Corretto, Azul Zulu, Microsoft, Red Hat, BellSoft, Eclipse Temurin, etc. The same source - different builds and support contracts.
- The canonical repo:
github.com/openjdk/jdk(the mainline) plus update repos (jdk21u,jdk25u) for LTS maintenance. - Smaller related repos:
openjdk/jdk-sandbox(experimental), per-project repos (Loom historically lived inopenjdk/loom, etc.; many have been integrated upstream now).
The directory layout you care about:
src/
java.base/share/classes/ # Core class library (java.lang.*, java.util.*, java.io.*, ...)
java.base/share/native/ # Native bits supporting java.base
hotspot/share/runtime/ # The JVM runtime - threads, frames, monitors
hotspot/share/gc/ # Garbage collectors (g1/, z/, parallel/, serial/, shenandoah/)
hotspot/share/opto/ # C2 compiler
hotspot/share/c1/ # C1 compiler
hotspot/share/classfile/ # Class loading & verifier
jdk.compiler/share/classes/ # javac
...
test/
jdk/ # JTReg tests for the JDK class library
hotspot/jtreg/ # JTReg tests for HotSpot
micro/ # JMH benchmarks in-tree
If you don't know which directory your change belongs in, you don't yet understand the change. Read until you do.
C.2 Process: From Bug to Patch¶
- JBS (bugs.openjdk.org) is the issue tracker. Search there before reporting. Most "bugs" turn out to be JEP-tracked design choices.
- Mailing lists. Every component has one.
hotspot-dev,core-libs-dev,loom-dev,panama-dev,compiler-dev, etc. Subscribe to the one matching your interest and lurk for at least a month before posting. Read tone before contributing tone. - Author the change. Build the JDK yourself (
bash configure && make images). Run the relevant test groups (make test TEST=tier1). - Submit via Skara. OpenJDK uses a GitHub-bot pipeline called Skara. You open a PR against
openjdk/jdk. The bot enforces: - Signed OCA (Oracle Contributor Agreement) - required for non-trivial changes. Trivial fixes (typos, comments) often go through without OCA, but check the bot's response.
- Reviewer requirements (most areas require two Reviewers; "Reviewer" is a formal role).
- JBS issue linkage.
- Clean commit history (one logical change, well-described).
- Reviews are technical and pointed. Don't take it personally. Address every comment in the PR or in the mailing-list discussion.
- Integration. Once approved, you (or a Reviewer with integration rights) issue
/integrate. The bot lands it in the right branch.
C.3 The First-Patch Playbook¶
Pick from this in roughly ascending difficulty:
Tier 0 - Documentation and Javadoc¶
Find a Javadoc error, clarification opportunity, or broken {@link}. File JBS, submit PR. Surfaces you to the workflow without engineering risk.
Tier 1 - Test improvements¶
The test base is large and uneven. Look for @ignore or TODO-flagged tests. Or: add a regression test for a recently fixed bug that didn't get one (common). Reviewers love these.
Tier 2 - Small java.base improvements¶
The classic on-ramp: a missing @Override annotation; a for loop that could be enhanced; a string concat in a hot path; a StringBuilder reuse. Look at recent commits to java.base/share/classes for the style of acceptable small changes.
Tier 3 - HotSpot bugfixes from JBS¶
Filter JBS by priority = P4 and affects-version = openjdk25 (or current). Read the bug. If you can reproduce it locally and the fix is < 50 lines, it's a first-patch candidate. Read the related code first; HotSpot has rich conventions (NULL checks, ResourceMark, HandleMark, mutex order) you must respect.
Tier 4 - Garbage collector internals¶
Don't start here. After a year on tier-3 patches, with a clear interest in GC research, follow hotspot-gc-dev.
Tier 5 - Compiler (C2 / Graal) work¶
Don't start here either. C2 has a steep on-ramp; expect 6+ months of reading before a credible patch.
C.4 Building OpenJDK Locally¶
git clone https://github.com/openjdk/jdk
cd jdk
# Linux deps (Debian/Ubuntu): autoconf, build-essential, libx11-dev, libxext-dev,
# libxrender-dev, libxrandr-dev, libxtst-dev, libxt-dev, libcups2-dev, libfontconfig1-dev,
# libasound2-dev, libfreetype6-dev, zip, unzip.
bash configure --with-boot-jdk=$JAVA_HOME --enable-ccache
make images
# Resulting JDK:
./build/linux-x86_64-server-release/images/jdk/bin/java --version
For day-to-day hacking: make hotspot (just the VM, ~3 min incremental) is faster than make images. Use --with-debug-level=fastdebug for development - assertions and verifier on, optimization on, build time tolerable.
Run tests:
make test TEST="tier1"
make test TEST="jtreg:test/hotspot/jtreg/runtime/InvocationTests"
make test TEST="micro:org.openjdk.bench.java.util.HashMapBench"
JTReg is the JDK's test harness; you'll learn it by reading existing tests in test/.
C.5 Reading the Code¶
Three habits that compound:
- Follow one bug from JBS to integration. Pick a recent fix. Read the JBS entry, the mailing-list discussion, the PR, the commits. Do this monthly until the patterns of "what gets accepted" are obvious.
- Annotate as you read. HotSpot in particular is unforgivingly terse. Keep a private notebook of "what I learned from
mutex.cpptoday." Re-read your own notes weekly. - Compile and instrument. When you don't understand a code path, add
tty->print_cr("hello from %s", __FUNCTION__);(HotSpot) orSystem.err.println(java.base, in your local build), rebuild, run a test, observe. This is the single fastest way to learn.
C.6 The Etiquette¶
- Lurk before posting. The mailing lists have decades of context. Posting "I want to contribute, what should I do?" generates eye-rolls.
- Don't bikeshed. Disagreements happen - make them on technical merits, drop them after the second exchange.
- OCA before non-trivial PR. Sign the Oracle Contributor Agreement at
oca.opensource.oracle.combefore you have a patch ready, so it's done. - Credit and copyright headers. The build cares; don't break the conventions.
- Reviewers are paid in patience. Yours, and theirs. Make changes easy to review: one logical change per PR, clean diff, descriptive title with the JBS ID (
8312345: Make HashMap.resize() handle X correctly).
C.7 Beyond the JDK¶
Some adjacent JVM-ecosystem projects that compound your OpenJDK fluency:
- GraalVM (
oracle.com/graalvm, source on GitHub) - Truffle, Native Image, the Graal compiler. Different governance, OCA-like CLA. - Eclipse OpenJ9 / OMR - IBM's JVM. Different runtime, different GC family, useful contrast.
- Adoptium / Eclipse Temurin - build/QA work for the OpenJDK; high-value, lower-engineering on-ramp.
- Major OSS JVM frameworks - Netty, Spring, Quarkus, Apache Kafka, Cassandra. Patching any of these at depth trains the same muscles as patching the JDK.
The path from "I wrote a Spring app" to "I committed to java.base" is real and walkable. Most who walk it spend 6–18 months on the on-ramp before their first integration. Plan accordingly, and start with a Tier-0 contribution this month.
Capstone Projects¶
Three tracks. Pick one. Each is sized for the four-week Month 6 schedule in 06_MONTH_CAPSTONE.md. Each forces the full curriculum into one artifact.
Common requirements across tracks:
- Built on Java 25 LTS, virtual threads + structured concurrency where appropriate.
- gRPC or HTTP/2 over java.net.http for cross-component RPC.
- Full observability: Micrometer Prometheus metrics, OpenTelemetry traces, structured JSON logs with trace-ID correlation.
- Continuous JFR recording, GC logs to disk, heap-dump-on-OOM.
- Testcontainers-based integration tests, jqwik property tests for core invariants, jcstress for custom concurrency.
- One JMH benchmark suite covering at least one hot path.
- Public GitHub repo, README, design doc, runbook, demo script.
Track 1 - Distributed Storage: Raft-Backed Key-Value Store¶
Goal¶
A 3-to-5-node replicated KV store. Linearizable single-key reads/writes. Snapshot/restore. Membership changes (add/remove node) optional but encouraged.
Required reading¶
- Diego Ongaro, In Search of an Understandable Consensus Algorithm (the Raft paper).
- Ongaro's PhD thesis chapters on log compaction and membership.
- One mature Raft implementation for reference: Atomix, jraft (SOFAStack), or Hashicorp's
raft(Go) - read it, don't copy it.
Core scope (must-have)¶
- Leader election with randomized timeouts.
- Log replication with majority commit.
- Linearizable reads via read-index or leader leases.
- Persistent log on disk (append-only segments + index).
- gRPC for inter-node communication and client API.
- Snapshot + restore.
Stretch scope¶
- Joint-consensus membership changes (JEP-style "stable until I touch it" config).
- Leader leases for fast reads without read-index.
- Multi-Raft (sharding by key range).
- Linearizability checker (Jepsen-style; e.g. Knossos invoked from CI).
Failure scenarios to test¶
- Kill leader mid-write. New leader within timeout. No committed entries lost.
- Network partition isolating the leader. Minority side cannot make progress; majority elects new leader.
- Slow disk on a follower. Throughput degrades to the slowest acknowledged majority; does not stall the cluster.
- Restart an entire node. Recovers from disk log + snapshot.
Why this track¶
Forces you into: virtual threads under load, structured concurrency for fan-out RPCs, careful JMM reasoning for the consensus state machine, gRPC, persistence, JFR-driven tuning, failure injection.
Track 2 - Service Mesh: gRPC Microservice Mesh with Custom Control Plane¶
Goal¶
A working microservices mesh with multiple backend services, a service registry, a load-balancing client-side proxy, deadline propagation, circuit-breaker-driven outlier ejection, and end-to-end OpenTelemetry tracing.
Required reading¶
- The gRPC documentation on name resolution, load balancing, and retries.
- The Envoy/Istio docs on the data-plane / control-plane split (for terminology; you don't need to use Envoy).
- Resilience4j docs.
- Sam Newman, Building Microservices, 2nd ed., chapters on resilience and observability.
Core scope (must-have)¶
- A registry service (in-memory, persisted, or backed by etcd via jetcd) where backends register/deregister with TTL.
- At least three backend services with non-trivial dependencies among them (e.g.,
inventory→pricing→tax). - A client-side proxy library (gRPC interceptors) that:
- Resolves a service name to instances from the registry.
- Load-balances across them (round-robin minimum; weighted/pick-the-shortest-queue stretch).
- Propagates deadlines and trace context.
- Circuit-breaks per-instance based on error/latency.
- Ejects outliers from the LB pool.
- Full OpenTelemetry trace per top-level request, end-to-end through all services.
- Prometheus metrics: per-service request count, latency histogram, error rate, circuit-breaker state.
Stretch scope¶
- mTLS between services (use
java.securityand a local CA). - A "canary" feature: route N% of traffic to a different version of a backend.
- Outlier detection based on per-instance success-rate (Envoy's algorithm is a fine reference).
- Throttling / rate limiting at the proxy.
Failure scenarios to test¶
- Kill one backend instance mid-request. Client retries to another instance; the failed instance is ejected from the LB pool.
- Slow backend (inject 5s sleeps). Deadline propagation cancels the request through the chain.
- Cascading failure: backend B fails; circuit breaker opens; backend A degrades gracefully (returns cached / partial data, not stalls).
- Registry restart. Services re-register; client refreshes its view.
Why this track¶
Forces you into: gRPC depth, virtual-thread-friendly RPC patterns, Resilience4j composition, deadline propagation (subtle), OpenTelemetry instrumentation depth, multi-service operations.
Track 3 - Streaming Pipeline: Kafka-Style Ingest with Replay¶
Goal¶
A single-broker (stretch: multi-broker) message-streaming system with a producer API, a consumer API, durable segmented log storage, consumer groups, and at-least-once delivery with replay from offset.
Required reading¶
- Kafka documentation: the log abstraction, segments, indexes, consumer groups, offset commits.
- Jay Kreps, The Log: What every software engineer should know about real-time data's unifying abstraction.
- LMAX Disruptor paper (for the in-broker hot path inspiration).
Core scope (must-have)¶
- A broker process with TCP wire protocol (custom, or simplified Kafka-compatible).
- Topics with partitions; each partition is a segmented append-only log on disk.
- Producer API: send batched, acknowledged messages.
- Consumer API with offset commit (manual + auto).
- Consumer groups with partition rebalancing on join/leave.
- At-least-once delivery: bounded duplication on consumer restart; no loss.
- Replay from arbitrary offset.
Stretch scope¶
- Multi-broker with leader-per-partition (effectively, Raft per partition - combines with Track 1 ideas).
- Stream processing API (map / filter / window / aggregate) on top of the broker.
- Compaction (latest-value-per-key) topics.
- Schema registry integration (Avro / protobuf).
Failure scenarios to test¶
- Kill a consumer mid-batch. Restart; consumer resumes from last committed offset; no loss; bounded duplication.
- Broker crash. Replay log; producer's unacknowledged sends retried; consumer's view of committed offsets preserved.
- Slow consumer. Lag grows; producer is not blocked (broker-side buffering is bounded; producers acknowledge at-send-time, not at-consumer-receipt).
- Disk full. Backpressure to producers; no corruption.
Why this track¶
Forces you into: file I/O at performance (segment management, mmap or FileChannel), virtual threads for many concurrent connections, careful JMM reasoning for the hot path, JMH-driven optimization, backpressure design, JFR-driven tuning under sustained load.
Track-Independent Defense Checklist¶
By end of Month 6, regardless of track, you have:
- A public repo with CI green.
- A README that lets a stranger build and run the system in five commands.
- A design doc (3–8 pages) explaining choices and rejected alternatives.
- A runbook (
RUNBOOK.md) with the top 5 alerts and mitigation steps. - A postmortem-style writeup (5–15 pages): what you built, what surprised you, what you would do differently.
- A demo script (10–20 min) walking through happy path + at least one chaos scenario, with live dashboards.
- JMH results for at least one critical operation, with notes on what limits throughput.
- A JFR recording from a steady-state load run, committed in
docs/perf/, with annotations on what to look at. - A
hardening/checklist fromAPPENDIX_Aticked through.
If you finish early¶
Pick one and do it:
- Submit a tiny upstream patch to OpenJDK (see APPENDIX_C) using something you learned during the capstone.
- Write a blog post on one specific surprise from your work (e.g., "what I learned about virtual-thread pinning building a Raft implementation").
- Port the project to GraalVM native-image and document the trade-offs.
- Migrate one component from blocking to reactive (or vice versa) and JMH the difference.
The capstone is the end of the curriculum, not the end of the work. Treat it as the launch pad.
Worked example - Week 16: a lock-free counter, then the ABA bug¶
Companion to Java Mastery → Month 04 → Week 16: Lock-Free Patterns. The week introduces compare-and-swap (CAS) and the building blocks of lock-free data structures. This page walks one concrete example end-to-end: build a counter with CAS, then deliberately introduce the ABA hazard so you can see what it looks like.
A naive shared counter¶
The wrong way:
Two threads calling increment() concurrently lose updates. The ++ decomposes into three bytecodes (iload, iconst_1, iadd, istore) and another thread can land between any of them. Classic data race.
The textbook fix is synchronized, but that takes a lock. Lock-free programming asks: can we do this with hardware atomics instead?
CAS to the rescue¶
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private final AtomicInteger value = new AtomicInteger(0);
public int increment() {
int prev, next;
do {
prev = value.get();
next = prev + 1;
} while (!value.compareAndSet(prev, next));
return next;
}
}
The body is a classic CAS loop. Read it carefully:
prev = value.get()- snapshot the current value.next = prev + 1- compute the new value off-line, without holding anything.value.compareAndSet(prev, next)- atomically: if the current value still equalsprev, replace it withnext; otherwise leave it alone. Returnstrueif it swapped.while (!...)- if the swap failed, somebody else beat us to it. Loop and try again with a fresh snapshot.
This compiles down to the CPU's LOCK CMPXCHG instruction (on x86). It's wait-free for the single-CAS case; under heavy contention threads can retry forever, but that's a liveness issue, not a correctness one.
Note what we did not do: we never held a lock. No thread can block another by being slow inside increment(). If a thread is paused by the OS scheduler mid-CAS-loop, every other thread still makes progress.
Now make it break: the ABA bug¶
The CAS-loop pattern is safe when the only thing that matters is the current value. It breaks when the value is == to a previous value but the state behind it has changed. This is the ABA problem.
Concrete demo. Suppose we use CAS to pop from a singly-linked stack:
public class LockFreeStack<T> {
private final AtomicReference<Node<T>> top = new AtomicReference<>(null);
record Node<T>(T value, Node<T> next) {}
public void push(T v) {
Node<T> oldTop, newTop;
do {
oldTop = top.get();
newTop = new Node<>(v, oldTop);
} while (!top.compareAndSet(oldTop, newTop));
}
public T pop() {
Node<T> oldTop, newTop;
do {
oldTop = top.get();
if (oldTop == null) return null;
newTop = oldTop.next();
} while (!top.compareAndSet(oldTop, newTop));
return oldTop.value();
}
}
This looks fine. CAS guarantees we only swap if top is still the node we saw. But consider:
- Thread A reads
topand sees nodeX(X.next = Y). About to CAS. - Thread A is descheduled for a moment.
- Thread B pops
X. Top is nowY. - Thread B pops
Y. Top is nownull. - Thread B reuses node
X's memory to push a new value; top is nowXagain, but withX.next = null(a fresh state). - Thread A wakes up. CAS sees top ==
X(the same reference!), succeeds, and sets top toY. ButYis in nobody's list anymore. Catastrophe.
The CAS check passed because the reference is equal, but the state behind it changed and snapped back. That's the A → B → A in "ABA."
What fixes it¶
In Java the standard fix is a tagged pointer - pair the reference with a counter, so each push/pop increments the counter, and the CAS is over the (ref, counter) tuple:
import java.util.concurrent.atomic.AtomicStampedReference;
private final AtomicStampedReference<Node<T>> top =
new AtomicStampedReference<>(null, 0);
public T pop() {
int[] stamp = new int[1];
Node<T> oldTop, newTop;
do {
oldTop = top.get(stamp);
if (oldTop == null) return null;
newTop = oldTop.next();
} while (!top.compareAndSet(oldTop, newTop, stamp[0], stamp[0] + 1));
return oldTop.value();
}
Now even if a reference is reused, the stamp will be different, and the CAS will fail. This is one of two common fixes (the other is hazard pointers / epoch-based reclamation - heavier-weight, used in JVM-level data structures like ConcurrentLinkedQueue).
The trap¶
It's tempting to assume "compare-and-swap = thread-safe = done." It's neither. CAS is a building block. Building correct lock-free data structures on top of CAS requires reasoning about the entire state space, not just the field you're swapping. ABA is the canonical mistake; there are subtler ones (linearizability violations, memory reclamation hazards, weak-memory-model surprises on non-x86).
The pragmatic advice: don't write your own lock-free data structures unless you have to. Use java.util.concurrent.atomic.*, ConcurrentHashMap, ConcurrentLinkedQueue, LongAdder. Those have had a decade of stress testing under jcstress. Your version hasn't.
Exercise¶
- Write the broken stack from the second example. Wrap
push/popin a loop driven by multiple threads. Usejcstress(referenced in the dense Go week on testing - the JVM equivalent) to provoke and observe the ABA bug. - Apply the
AtomicStampedReferencefix. Re-run. Confirm the test passes. - Read the source of
java.util.concurrent.ConcurrentLinkedQueue. Find thecasItemandcasNextcalls. How does the JDK avoid ABA there?
Related reading¶
- The main Week 16 chapter covers CAS, memory ordering, and Java's concurrency utilities.
- The Concurrency cross-topic page puts CAS next to Rust's atomics and Go's channels.
- The Memory models cross-topic page explains the happens-before guarantees that make CAS correct on weak-memory hardware.
- Glossary entries: CAS, Memory ordering, Happens-before in the main glossary.
Worked example - Week 5: reading bytecode¶
Companion to Java Mastery → Month 02 → Week 5: Class Loading and Bytecode. The week explains what bytecode is and how class loading works. This page walks one tiny method through the toolchain so you can see every step concretely.
The source¶
Three lines that do something. Compile it:
Adder.class is the bytecode. It's not text - it's a binary format. We never read it as bytes; we use javap (a JDK tool) to print a human-readable disassembly.
The bytecode, narrated¶
(-v is verbose; -p shows private members too.)
The interesting part:
public int addOne(int);
descriptor: (I)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: iload_1
1: iconst_1
2: iadd
3: ireturn
LineNumberTable:
line 3: 0
Walk it line by line:
-
descriptor: (I)I- the method's type, written in JVM internal notation.(I)Imeans "takes one int, returns an int."(Ljava/lang/String;)Vwould mean "takes a String, returns void." The shorthand letters never go away; you'll see them in stack traces and bytecode forever. -
stack=2, locals=2- at runtime this method needs at most 2 slots on the operand stack and uses 2 local variable slots. The JVM verifier checked these limits when the class was loaded; if they're wrong the class won't load. -
args_size=2- two arguments:this(instance methods get an implicitthisas local 0) andx(local 1). Astaticmethod would haveargs_size=1for this signature. -
0: iload_1- push local variable 1 (x) onto the operand stack. Theiprefix is "integer."iload_1is a single-byte opcode optimized for the common case "load int from local 1." -
1: iconst_1- push the integer constant1. Again a single-byte opcode for a tiny common case. -
2: iadd- pop the top two ints from the stack, add them, push the result. After this instruction the stack has one int:x + 1. -
3: ireturn- pop the top int and return it to the caller.
That's the whole method: 4 bytes of bytecode plus a 1-entry line number table.
What this tells you¶
- The JVM is a stack machine. Operations consume and produce values on an evaluation stack, not registers. The JIT will later allocate those stack slots to real CPU registers, but the bytecode itself is stack-based for portability.
- There are dozens of "constant int" opcodes.
iconst_0throughiconst_5, plusiconst_m1,bipush(one-byte int),sipush(two-byte),ldc(load from constant pool). Each is one byte; the verbose ones are last-resort. thisis just local 0. Instance methods are static methods with an extra implicit argument. Theinvokevirtualopcode you'd see at a call site pushesthisfirst, then the arguments.
The trap¶
You might assume the JVM interprets this bytecode and that's why Java is "slower than C." That's true for the first ~10,000 calls. After that, HotSpot's C2 compiler typically inlines addOne into the caller and emits one CPU instruction (add eax, 1 or similar). The bytecode is the source for the JIT, not the runtime program.
This is why the curriculum's later weeks on the JIT matter: bytecode is the contract, but it's almost never what actually runs.
Exercise¶
- Write a class with two methods:
addOne(int)andaddOne(long). Compile andjavap -v -p. Compare the bytecode. What changed? - Add a third method
addOne(Integer). Predict the bytecode before runningjavap. Then look. What does autoboxing produce? - Use the new Class-File API (JDK 22+,
java.lang.classfile) to generate the sameAdder.classprogrammatically and write it to disk. Confirmjavapoutput matches the compiler's.
Related reading¶
- The main Week 5 chapter covers the loading process around bytecode.
- The senior Glossary defines Bytecode, Class loader, Constant pool.
- The Type systems cross-topic page puts JVM descriptors next to Rust's monomorphization and Go's interfaces.
Worked example - Week 8: a JMH benchmark, line by line¶
Companion to Java Mastery → Month 02 → Week 8: JMH and Microbenchmarking. JMH (Java Microbenchmark Harness) is the only sane way to benchmark JVM code; everything else lies because of JIT, dead-code elimination, escape analysis, and GC variance. This page walks one tiny benchmark from setup to interpreting output.
The question we're measuring¶
"Is String.format("%d", n) slower than Integer.toString(n), and by how much?" A plausible-sounding question that's nearly impossible to answer with System.nanoTime() wrappers.
The naive approach (don't do this)¶
long t0 = System.nanoTime();
for (int i = 0; i < 10_000_000; i++) {
String s = String.format("%d", i);
}
long t1 = System.nanoTime();
System.out.println((t1 - t0) / 10_000_000.0 + " ns/op");
Four ways this lies:
- JIT warmup. The first ~10,000 iterations run interpreted. Average is dominated by interpreter time.
- Dead code elimination.
sis never used. C2 may prove the entire loop body has no side effects and delete it. You'd measure 0. - Constant folding. If the loop variable is provably bounded, C2 may unroll and precompute.
- No statistical anything. One run, one number, no error bars.
JMH fixes all four.
The same question, with JMH¶
// FormatBench.java
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(value = 2)
public class FormatBench {
@Param({"42", "1234567"})
public int n;
@Benchmark
public String stringFormat() {
return String.format("%d", n);
}
@Benchmark
public String integerToString() {
return Integer.toString(n);
}
}
Walk the annotations:
@BenchmarkMode(Mode.AverageTime)- measure mean time per operation. Alternatives:Throughput,SampleTime,SingleShotTime. AverageTime is what you usually want for "how slow is this call."@OutputTimeUnit(NANOSECONDS)- report numbers in ns. Without this, microseconds is the default, which buries small differences.@State(Scope.Benchmark)- the benchmark instance is reused across iterations within a fork; one instance per JVM.Scope.Threadgives one per benchmarking thread (use it when state shouldn't be shared).@Warmup(iterations = 3, time = 1)- run 3 warmup iterations of 1 second each before measuring. Lets the JIT compile, profile, recompile, and reach steady state.@Measurement(iterations = 5, time = 1)- 5 measured iterations of 1 second each. Used to compute mean + standard deviation.@Fork(value = 2)- run the entire benchmark in 2 separate JVMs. This is critical: a single JVM's JIT decisions are path-dependent. Two forks reveal variance you'd otherwise miss.@Param({"42", "1234567"})- JMH will run the benchmark twice, once withn=42and once withn=1234567. Lets you see whether input size matters.@Benchmark return String- returning the result is how you prevent dead-code elimination. JMH has a special infrastructure (Blackhole) that consumes return values so the compiler can't prove they're dead.
That's the whole correct benchmark. Run it:
The output, narrated¶
After a couple of minutes:
Benchmark (n) Mode Cnt Score Error Units
FormatBench.integerToString 42 avgt 10 7.823 ± 0.214 ns/op
FormatBench.integerToString 1234567 avgt 10 12.404 ± 0.301 ns/op
FormatBench.stringFormat 42 avgt 10 421.302 ± 18.547 ns/op
FormatBench.stringFormat 1234567 avgt 10 428.119 ± 15.221 ns/op
Read it row by row:
Scoreis the mean time per operation.Erroris the 99.9% confidence half-width - the value with±is the margin where you'd expect the true mean to fall.Cntis the total measured-iteration count (5 iterations × 2 forks = 10).
What it tells you:
- Integer.toString(42) takes ~8 ns. Integer.toString(1234567) takes ~12 ns. The difference is the extra digit-conversion work.
- String.format("%d", n) takes ~420 ns regardless of n. Input size doesn't matter because the formatter's overhead dominates: parser, allocator, internal StringBuilder, locale lookup.
- String.format is ~50× slower than Integer.toString. The error bars don't overlap (8±0.2 vs 421±19), so the difference is real, not noise.
That's a real answer. Production code: don't use String.format for trivial integer conversions in hot paths. Use it for the cases where its formatting power earns its overhead.
What can still trick you¶
Even JMH can lie if you don't think about:
- Coordinated omission. If your code is event-loop-blocked (your JMH thread waits on something else), JMH measures wall-clock time per op but masks the queueing effect. Real production latency is higher.
- Scope.Benchmark sharing. Two threads on
Scope.Benchmarkstate will produce contention not visible to single-threaded benchmarks. - Power-saving / thermal throttling. Use a dedicated benchmark machine; disable turbo boost or accept the noise; don't benchmark on a laptop on battery.
- JVM flags. Default flags are not production flags. Pass the same
-XX:+UseG1GC -Xmx4getc. you'd use in prod.
The trap¶
Reading the output as gospel. A 50× difference between two operations measured for 1 nanosecond each may be a real algorithmic gap or may be JIT measurement noise. Always check: - Confidence intervals don't overlap. - Multiple forks agree. - The result is stable across machines.
If any of those don't hold, you have a benchmark problem, not a code problem.
Exercise¶
- Run the benchmark above. Confirm the order-of-magnitude difference.
- Add a third benchmark:
String.valueOf(n). Predict its result first. Then run. Were you right? - Add a fourth using
StringBuilder().append(n).toString(). Predict, then measure. - For each, look at
-prof gc(java -jar benchmarks.jar -prof gc FormatBench). Compare allocation rates. TheString.formatallocator overhead should be visible. - (Hard) Add
-prof perfasm(Linux only). Inspect the assembly emitted for the hot path ofintegerToString. How many instructions per call?
Related reading¶
- The main Week 8 chapter covers JMH's design and standard pitfalls.
- The Performance methodology cross-topic page places JMH in the broader context of measuring without lying.
- Glossary: Microbenchmark, Dead code elimination, JIT in the main glossary.