Why Java for OO, and not C++. C++ is multi-paradigm and modern C++ has largely moved away from inheritance towards values and RAII — learning object orientation there means learning it from a language that is quietly ambivalent about it. Java has exactly one organising idea and applies it without exception. That makes it the honest place to find out what OO actually buys and where it starts costing.
Why 8 specifically. Released March 2014 and still, a decade later, what Oracle's own platform history calls "the most popular version in Java's history." It is also the version where the language changed character: the JLS 8 preface calls it "the single largest evolution of the Java language in its history" — lambdas,
Stream,Optional, and default methods all arrived at once, and everything Java has done since is a continuation of that turn. The follow-on tracks are 17 and 21 — not 20; the long-term-support line runs 8 → 11 → 17 → 21 → 25, and the non-LTS releases are six-month previews nobody deploys.
James Gosling, Sun Microsystems, 1995–96. Designed for set-top boxes, marketed for applets,
and it found its actual home in the place nobody was aiming at: server-side enterprise
applications. The first-party account is Gosling and McGilton's 1996 white paper,
The Java Language Environment,
and the chapter list alone is a design document: alongside "Architecture Neutral and
Portable" sits a section titled Features Removed from C and C++ — no pointers, no multiple
inheritance, no operator overloading, no goto, no automatic coercions. The set-top-box
detour and the Green Project are told in Oracle's
history of Java technology
(archived; the live page is gone).
The design constraint was portability across hardware you do not control — hence the JVM, hence bytecode, hence "write once, run anywhere". The second constraint, and the one that shaped the language more, was large teams of mixed-ability programmers maintaining code for decades. That is why it is verbose and why its culture prizes explicitness over brevity. Those are not accidents or failures of taste; they are the requirement.
Who pays for it today: banking and insurance core systems, Android, large-scale data infrastructure (Hadoop, Kafka, Spark, Elasticsearch, Cassandra — all JVM), and most enterprise backends that are not startups. The JVM is also the most sophisticated general-purpose managed runtime in existence, which matters more than the language: Kotlin, Scala, and Clojure all bet on the runtime rather than the language.
Further reading: the 1996 white paper for the original rationale; dev.java's evolution page for what has changed since 8 and why.
The JDK on this machine is 17, compiling to 8:
javac --release 8 -d build LogAgg.java
java -cp build LogAgg bench/data/access.log
--release 8 is the correct flag and -source 8 -target 8 is the wrong one, because they
control different things: -source sets the language level, -target sets the bytecode
version, and only --release additionally constrains the platform API to that release's
(see the javac reference,
"Compiling for Earlier Releases"). So calling String.repeat (Java 11) is a compile error
here rather than a NoSuchMethodError on a real Java 8 production JVM — the distinction has
caused a great many production incidents. Note the flag itself is newer than 8: the JDK 8
javac does not know --release; this machine is doing the period-accurate thing of
compiling down to 8.
LogAgg.java ──javac──► LogAgg.class ────────────► java (the JVM)
source bytecode, class v52.0 load → verify → link
the portable artifact interpret → JIT-compile hot paths
────────────────────► ────────────────────────────────────
ships in a jar native code, this machine only
Compare the C track: there the binary ships and the toolchain is done; here the artifact is portable bytecode and the JVM is part of the deployment — its version and flags are part of your configuration, and the GC tuning guide for one collector family is longer than most languages' entire toolchain documentation.
Real projects do not invoke javac directly. They use Maven (declarative XML,
convention-heavy, ubiquitous in enterprise) or Gradle (imperative, dominant in Android).
Both resolve dependencies from Maven Central — the first widely adopted package registry
of this kind, and the model every later one (npm, PyPI, crates.io) copied, including the
supply-chain problems: a single namespace of transitively resolved artifacts whose
trustworthiness is the publisher's.
Further reading: javac man page;
the JDK 8 tools index for the full
toolchain — jcmd, jmc, jvisualvm are part of the same install.
A variable is a name for either a primitive value or a reference to an object — never for the object itself. The spec draws the line at §4.1: eight primitives and everything else is a reference. Java has no user-defined value types (Project Valhalla has been chasing them for a decade; value objects finally reached preview in JDK 28), and no way to say "put this struct on the stack".
That split is the one real seam in the type system:
| Primitives | References | |
|---|---|---|
| Examples | int, long, double, char, boolean |
Everything else |
| Stored | Directly | As a reference to the heap |
| Nullable | No | Yes, always |
| In a collection | Not allowed — boxed to Integer, Long |
Naturally |
logagg runs into this immediately: the Stats accumulators are long (primitive, fast, no
allocation), but Map<String, Stats> cannot hold a long key or value without
boxing. A
Map<String, Long> would allocate a Long object per update — which is why the code holds a
mutable Stats object per service rather than a map of counters. Generics compound the seam:
they are real at compile time and erased
at runtime — List<String> and List<Integer> are the same class once running, so
new T[] is illegal and instanceof List<String> does not compile.
Memory is garbage collected, and this is the largest practical difference from the C
track. logagg.java contains no cleanup code at all — no free, no ownership question about
whether the map may keep the key it was handed. The picture, drawn for this program:
stack (main) heap
───────── ──────────────────────────────────────────
services ──────────────────────► HashMap ──► Node[] table
raw ───────────────────────────► String (one per line; collectable next pass)
│
└─► Node ──┬─► String "nginx" ← immutable,
│ shared, NO copy unlike strdup
└─► Stats ──► long count, errors,
sumMicros, maxMicros
(primitives, inside the object)
The Stats.service field and the map key point at the same String object — String is
immutable, so sharing a reference is safe, and the problem that forced strdup in C
does not exist. What you pay for that: you cannot control when memory is released, GC
pauses are a real operational concern at scale, and try-with-resources exists because GC
handles memory and nothing else — file handles, sockets, and locks still need explicit
release.
Two representation facts worth knowing about Java 8 specifically. A Java 8 String is a
char[] of UTF-16 code units — two bytes per character even for pure ASCII, until
JEP 254 (Compact Strings, Java 9) moved it to byte[] plus a
coder flag because most real strings are Latin-1. And Java 8 replaced PermGen with
Metaspace: class metadata moved out of the fixed-size permanent generation into native
memory, retiring the PermGen space OutOfMemoryError that haunted every long-running
Java ≤ 7 app server.
Further reading: JLS §4 for the type split and erasure; JEP 254 for the string representation change; the GC tuning guide for what the collectors actually are.
The class. Exclusively and without exception, which is why half_up — a free function in
both C and Python — had to become static long halfUp inside LogAgg. The constraint is
structural, not stylistic: JLS §7.6
requires at most one public top-level class per file, named for the file. LogAgg.java could
not be called anything else.
The vocabulary, and what each is actually for:
| Construct | What it gives you |
|---|---|
class |
State + behaviour + identity. The unit of everything. |
interface |
A contract with no state. The unit of polymorphism. |
abstract class |
Partial implementation. Use sparingly; you get only one. |
package |
Namespace and the default visibility boundary |
enum |
A closed set of instances — far more capable than C's enum, can have methods |
The rule worth internalising: inheritance for identity, interfaces for capability,
composition for reuse. Java 8's default methods on interfaces
(§9.4.3, and JSR-335's
"virtual extension methods") blurred that line deliberately — they exist so Collection
could gain stream() without breaking every existing implementation. Brian Goetz's
State of the Lambda: Libraries Edition
is explicit that this is why the feature exists at all: interface evolution, not a second
kind of inheritance. The spec had to invent diamond-resolution rules (class beats interface,
more-specific beats less) to make it safe — the cost of adding a capability mechanism to a
twenty-year-old hierarchy.
logagg is deliberately restrained: two private static nested classes (Stats, Bad) and
static methods. The nested classes are worth a word: a static nested class has no enclosing
instance — it is a top-level class in hiding, so Stats can be self-contained. A
non-static inner class would secretly carry a LogAgg.this reference and everything that
entails. A more "enterprise" version would have a LogParser interface, a
StatsAggregator, and a ReportRenderer — which for 200 lines would be pure ceremony, and
for 200,000 lines would be the difference between a maintainable system and a
LogAggUtilsHelperImpl. Knowing which side of that line you are on is most of Java
judgement.
Further reading: State of the Lambda: Libraries Edition for default methods as an evolution mechanism; JLS §8 and §9 for classes and interfaces.
Exceptions, with a split that no other mainstream language kept.
| Checked | Unchecked | |
|---|---|---|
| Extends | Exception |
RuntimeException / Error |
| Compiler | Forces you to catch or declare | Says nothing |
| Examples | IOException, SQLException |
NullPointerException, IllegalArgumentException |
The split is normative in JLS §11.1.1:
the RuntimeException and Error subtrees are unchecked; everything else must be caught or
declared. Checked exceptions are Java's most distinctive and most criticised idea: the
compiler enforces that recoverable failures are visibly handled. The canonical criticism is
Anders Hejlsberg's 2003 interview — versionability
(adding a checked exception to a throws clause is a breaking change) and scalability
(throws clauses balloon as you compose subsystems). The canonical defence is
Gosling's own: "there's no plausible
deniability when it comes to checked exceptions" — you can ignore them, but you have to do it
on purpose. In practice it produced a generation of catch (Exception e) {
e.printStackTrace(); } and, once Java 8 lambdas arrived, an outright incompatibility — a
lambda that throws a checked exception cannot be passed to Function, so Stream code
either wraps everything in UncheckedIOException
(the JDK's own concession, added in 8 itself) or abandons streams. No language designed
since has adopted the feature.
In logagg, the same ordered validation returns a nullable String reason rather than
throwing, exactly as the C version does. That is a deliberate choice, and the right one:
a malformed line is expected input, not an exceptional condition. The rule of thumb —
exceptions for the unexpected, values for the expected — is the single most useful thing to
take from this section.
The one place an exception is right is the file open, and there the try-with-resources
block handles both the failure and the cleanup:
try (BufferedReader in = Files.newBufferedReader(path, StandardCharsets.US_ASCII)) {
§14.20.3 specifies the
semantics exactly, including the subtlety that an exception thrown by close() while another
is in flight is suppressed and attached to the primary exception — a mechanism C's goto
done cannot express. Compare to the C track's goto done: same problem, same solution,
moved into the language.
Further reading: JLS §11; the Hejlsberg and Gosling interviews read as a pair — the entire checked-exceptions debate in two primary sources.
OS threads, plus the best-designed memory model of its generation.
Java 5's JSR-133 memory model — spec lead William
Pugh, with Doug Lea on the expert group — was the first time a mainstream language specified
precisely what a concurrent program is allowed to observe: what volatile guarantees, what
happens-before means, and what the compiler may reorder. The normative text is
JLS §17.4; the authors' own
reference page collects the spec, the FAQ,
and Lea's implementation cookbook. C++ copied the approach in C++11. Before it, "correct"
multithreaded code was folklore — the JSR's own proposal says the old rules outlawed idioms
that books recommended and prohibited optimisations every JVM already performed.
The toolkit, in the order you should reach for it:
java.util.concurrent — ExecutorService, ConcurrentHashMap, AtomicLong,
CountDownLatch. Written by Doug Lea (the file headers say so), and the reason you should
almost never write synchronized yourself. The
package docs
also contain the Memory Consistency Properties section — the API-level statement of
which actions establish happens-before edges.synchronized / volatile — the language primitives. Correct, coarse, easy to deadlock.parallelStream() — trivially easy, backed by a
shared ForkJoinPool.commonPool(),
and therefore trivially easy to misuse in a server where every request does it: one
caller's "parallel" work is silently queued behind every other caller's.For --shard N (SPEC 6), the Java 8 answer is an ExecutorService with N workers each
returning a partial Map<String, Stats>, merged on the main thread:
ExecutorService (fixed pool, N threads)
┌─────────┬─────────┬─────────┐
access.log ────► │worker 1 │worker 2 │worker N │
└────┬────┴────┬────┴────┬────┘
each returns Future<Map<String,Stats>>
│ │ │
main thread: merge partial maps → sort → format
Private accumulators, no shared map, no locks — the same shape as the C track's shard design,
except the merge value comes back through a Future instead of a pthread_join out-param.
The parallelStream one-liner is tempting and worth writing and then measuring — for a
200-line file it will be slower than the serial version, and finding that out yourself is the
lesson.
The Java 21 track revisits all of this, because virtual threads (JEP 444) change the answer completely: the "one thread per request, blocking code" model that Java abandoned for callbacks and reactive frameworks becomes correct again — JEP 444's own motivation is that OS threads are the throughput ceiling, not a programming convenience.
Further reading: JLS §17.4
and the JSR-133 page; the
java.util.concurrent package docs;
JEP 444 for where it goes next.
logaggLogAgg.java · annotated walkthrough in NOTES.md
PASS java8
222 lines against C's 404, Go's 265, and Python's 188. The entire C hash-table section collapses to:
Stats s = services.computeIfAbsent(f[1], k -> new Stats(k));
computeIfAbsent
is itself a Java 8 default method; the Java 7 version is four lines and two map lookups.
Small, but representative of what the release did — a large number of small frictions removed
at once.
The HashMap underneath it is also not the data structure the C track built. Since
JEP 180 — a Java 8 change — a bucket that overflows past the
treeification threshold stops being a linked list and becomes a red-black tree:
table (Node[]) bucket overflow:
┌───┬───┬───┬───┬───┐ ≥ 8 nodes in one bucket
│ │ ● │ │ ● │ │ (and table ≥ 64)
└───┴─┼─┴───┴─┼─┴───┘ ──► list becomes a
│ │ red-black tree:
▼ ▼ worst case O(n) → O(log n)
Node Node─►Node─►Node (JEP 180, new in 8)
nginx api web db
The C version's worst case under adversarial keys is a long chain; Java 8's is a balanced
tree. An implementation detail with a specification-visible consequence: hash-flooding
resistance came free to every HashMap user in 8.
Three lines where Java forced a decision the other tracks did not:
String.format(Locale.ROOT, "%d.%03d", micros / 1000, micros % 1000);
Locale.ROOT is not optional. The default locale comes from the machine. Under a German
or Turkish locale the default Formatter renders a decimal comma and applies grouping, and
your byte-exact diff fails on a colleague's laptop and passes on yours. It has a famous
cousin in String.toLowerCase() — which under a Turkish locale maps I to ı, breaking
case-insensitive comparison of ASCII identifiers; the trap is documented in the
String.toLowerCase javadoc
itself. Always Locale.ROOT for machine-facing text.
System.out.write(out.toString().getBytes(StandardCharsets.US_ASCII));
System.out.println emits the platform line separator — \r\n on Windows — and fails the
diff. Same class of problem as the C track's _setmode, different shape. There is no way to
tell System.out to use \n; you write the bytes yourself. (The wider version of this bug —
the default charset — was not fixed until
JEP 400 made UTF-8 the default in Java 18.)
.thenComparing((Stats s) -> s.service)
The explicit (Stats s) is needed because thenComparing is overloaded for both Comparator
and Function, and after .reversed() the inference is ambiguous — the lab notes have the
full story. Type inference paying its bill: the JLS 8 preface credits Dan Smith with "an
entirely new chapter on type inference" written precisely for lambdas, and this is what it
costs when it cannot close.
Further reading: the Java 8 API docs — Map,
Comparator, String are the three pages this program leans on; JEP 180
for the map internals.
Ranked by professional cost, not tutorial frequency:
NullPointerException. Any reference can be null and nothing in the type system says
which. Optional (Java 8) helps at API boundaries — its
javadoc scopes it to
return types, not fields or parameters. Java 14's
JEP 358 finally made NPEs describe which variable was
null. Kotlin's entire pitch is fixing this.equals and hashCode. Override one without the other and your object silently fails
to be found in the HashMap you just put it in. No warning, no error, wrong answer. Java
16's record finally makes the correct version the
default — twenty-five years late.List from a getter lets callers
mutate your object's state. Collections.unmodifiableList is the defensive habit; the real
fix is immutability.Map<String, Long> allocates an object per update; long in a
field does not. Invisible until it is 30% of your allocation rate.new
String(bytes) uses the platform charset — on Windows a codepage like windows-1252, per
JEP 400 — which differed between your machine and the
server for Java's entire history until 18 made UTF-8 the default.The tooling layer, because "how it fails" professionally includes "what catches it":
| Layer | Tool | Notes |
|---|---|---|
| Compiler | -Xlint |
On by default for real work; the specific keys are in the javac man page |
| Static analysis | SpotBugs | 400+ bug patterns, the FindBugs successor; runs on JDK 11+, analyzes bytecode of any version — correct for a Java 8 codebase |
| Compiler plugin | Error Prone | Google's, hooks into javac; catches real bugs (its site shows it found a ConcurrentHashMap bug in the JDK itself). Requires JDK 11+ to run |
| Runtime profiling | Java Flight Recorder + JMC, async-profiler | Caveat: JFR was a commercial feature of Oracle JDK 8 (-XX:+UnlockCommercialFeatures); open-sourced in JDK 11. async-profiler is the free, low-overhead alternative |
| Observability | jcmd, jmap, GC logs |
Ship in the same JDK — the tooling is part of the platform, not a third-party install |
The pattern to notice: Java's failure modes are rarely undefined — they are silent. The
map fails to find, the locale quietly differs, the Long quietly allocates. The defence is
not careful reasoning about what the hardware might do (the C track's problem) but tooling
and convention, because the JVM guarantees you a wrong answer that is at least deterministic.
Recommended: java.util.ArrayList
— read it in the JDK source (src.zip in your JDK directory, so no download needed; the link
is the OpenJDK 8u mirror of the same file). It is short — 1,470 lines for the most-used class
in the ecosystem — you already know exactly what it does, and the gap between what you assume
and what is actually written is the whole lesson: modCount and fail-fast iteration, the
elementData growth policy (grow by half, not double), Arrays.copyOf doing the actual
work, and the transient field with hand-written serialization.
Then: java.util.concurrent.ConcurrentHashMap
— 6,310 lines, and the file header credits "Doug Lea with assistance from members of JCP
JSR-166 Expert Group". Among the most carefully written concurrent code in any language. Do
not expect to follow all of it. Read the class javadoc and putVal, and note how much of the
complexity is about avoiding locks rather than taking them — CAS loops, volatile reads on
the fast path, and locking only the bin that needs it.
Further reading: the ConcurrentHashMap javadoc
before the source — the memory-consistency guarantees it promises are the reason the code
looks like that.
logagg makes this concrete:
a malformed line returns a reason string, a missing file throws. Once you have the
distinction, Go's error returns and Rust's Result stop looking like primitive
alternatives to exceptions and start looking like the same judgement made at the type level.free for GC tuning, a static
binary for a JVM version matrix, and predictable latency for throughput. The C track makes
the other side of that trade visible; neither is the default correct answer.Locale.ROOT. Not a Java lesson: a general one. Any time a machine reads what your
program writes, the user's locale must not be in the path. Every language has this bug
available; Java just makes it easy to find.