No walls of theory. We build a tiny backend, one need at a time. Each need teaches one Java idea, and each idea starts with the problem it solves and a real-world picture.
You compile .java into .class bytecode. That bytecode is not machine code. A program called the JVM runs it and, for the hot parts, quietly recompiles them into fast native code while the app is running.
// MiniChat.java (you write this) public class MiniChat { public static void main(String[] args) { System.out.println("MiniChat is up"); } } // javac MiniChat.java → MiniChat.class (bytecode) // java MiniChat → JVM runs it on THIS machine
Now the words: javac is the compiler. The .class file is bytecode. The JVM (Java Virtual Machine) interprets it and the JIT (Just-In-Time compiler) turns frequently-run methods into native code. "Write once, run anywhere" is the whole point.
render(), no instructions how). An abstract class is a half-built house (walls up, one room left for you to finish). A class is the finished house you can actually move into.A class is a blueprint you create objects from. An interface is a pure contract: "whoever signs this must have these methods." An abstract class is a partial blueprint: it has some real code plus holes subclasses must fill.
interface Renderable { // the contract String render(); } abstract class Message implements Renderable { final String sender; // shared state Message(String s){ sender = s; } String who(){ return sender; } // shared behavior public abstract String render(); // hole to fill } class TextMessage extends Message { final String text; TextMessage(String s,String t){ super(s); text=t; } public String render(){ return sender+": "+text; } }
Now the words: implements takes an interface, extends takes a class. A class can implement many interfaces but extend only one class. Reach for an interface to define "can-do" capabilities; an abstract class when subtypes genuinely share code and state.
#843921 is far too slow for a request. We need lookup in roughly one step, not a million.HashMap: the key is hashed to a bucket, so lookup is nearly instant no matter how many coats.List = ordered, duplicates allowed (the message log). Set = no duplicates (unique active user ids). Map = key → value (id → message). A HashMap keeps an array of buckets; the key's hashCode() picks the bucket. Type a key and watch which bucket it lands in:
hashCode() that returns the same number for everything turns your O(1) map into an O(n) list.Now the words: the hashCode/equals contract: equal objects must return equal hash codes. The map uses hashCode() to find the bucket, then equals() to find the exact entry inside it. Break the contract and you'll put a key but never get it back.
Object and cast on the way out, and a stray String that slipped in blew up at runtime. We want the compiler to catch that mistake.A type parameter <T> lets one class work for any type while staying checked. Wildcards handle "some subtype I don't name": ? extends for reading, ? super for writing.
class Store<T> { // T = whatever type you pick private final List<T> items = new ArrayList<>(); void add(T item){ items.add(item); } T get(int i){ return items.get(i); } } // PRODUCER (you read out of it): ? extends void printAll(List<? extends Message> src){ for(Message m : src) System.out.println(m.render()); } // CONSUMER (you write into it): ? super void fill(List<? super TextMessage> dst){ dst.add(new TextMessage("sys","hi")); }
Now the words: PECS = "Producer Extends, Consumer Super." If a collection produces values you read, use ? extends. If it consumes values you write, use ? super. Generics are erased at runtime (type erasure), so they're a compile-time safety net.
Checked exceptions (extend Exception) must be caught or declared. Unchecked (extend RuntimeException, like NullPointerException) signal programming bugs. try-with-resources auto-closes anything you open.
// connection auto-closes at the end of the block try (Connection c = pool.get()) { c.save(message); } catch (SQLException e) { // checked: must handle log.error("save failed", e); throw new SaveFailedException(e); // unchecked wrapper } // no finally needed — try-with-resources calls c.close()
catch {}. And never catch (Exception e) just to hide it. Log it, wrap it, or rethrow. A silent failure is the worst kind.Now the words: a class in try-with-resources must implement AutoCloseable. The finally block always runs; try-with-resources is sugar that generates the close-in-finally for you.
A stream is a pipeline over a collection. filter keeps some, map transforms each, collect gathers the result. It's lazy: nothing runs until a terminal op like collect, so limit(3) can stop early.
List<String> result = messages.stream() .filter(m -> m.sender().equals("user42")) .filter(m -> !m.text().isBlank()) .map(m -> m.text().toUpperCase()) .limit(3) // lazy: stops after 3 .collect(Collectors.toList());
Now the words: intermediate ops (filter/map/limit) are lazy and return a new stream. terminal ops (collect/forEach/reduce/count) trigger execution. reduce folds the stream into one value (e.g. total character count).
map. Streams are for transforming data, not for mutating outside state.findMessage(id) may find nothing. Return null and every caller forgets a null check exactly once, then production throws NullPointerException. We want "maybe absent" to be impossible to ignore.isPresent / orElse) before using what's inside. The box itself is never null.Optional<T> is a container that holds a value or nothing. It makes absence part of the type, so the compiler nudges callers to deal with it.
public Optional<Message> find(String id){ return Optional.ofNullable(store.get(id)); } // caller — no null check, no NPE: String text = find("m1") .map(Message::text) .orElse("[deleted]");
.get(). Calling get() on an empty Optional throws, which is exactly the NPE you were escaping. Use orElse, orElseGet, orElseThrow, map, or ifPresent instead.Now the words: use Optional as a return type for "might be missing." Don't use it for fields or method parameters, and never wrap a collection in Optional (return an empty list instead).
equals, hashCode, and toString. That's 40 lines of boilerplate for a bag of data. And we want the compiler to know the message kinds are a closed set.switch can be sure it covered everyone.records = one-line immutable data carriers. sealed = restrict who can extend/implement. switch expressions return a value and are exhaustive. var = infer the local type from the right side.
sealed interface Message permits TextMsg, ImageMsg, SystemMsg {} record TextMsg(String sender, String text) implements Message {} record ImageMsg(String sender, String url) implements Message {} record SystemMsg(String text) implements Message {} var m = new TextMsg("user42", "hey"); // var infers TextMsg String preview = switch (m) { // exhaustive, returns a value case TextMsg t -> t.text(); case ImageMsg i -> "📷 " + i.url(); case SystemMsg s -> "⚙ " + s.text(); }; // no default needed — sealed!
Now the words: a record auto-generates the canonical constructor, accessors, equals/hashCode/toString, and is implicitly final and immutable. Because Message is sealed, the compiler knows every case is handled and rejects the switch if you add a type and forget it.
var. var x = getData(); hides the type from the reader. Use it when the right side already names the type (var list = new ArrayList<Message>()).count = count + 1 at the same moment. That line is read, add, write (three steps). Two threads read the same old value and one increment is lost. The counter drifts below the truth.A Thread runs code in parallel. You almost never create raw threads; an ExecutorService is a managed pool that reuses them. To protect shared data, use synchronized/Lock, or an atomic that does read-add-write as one unbreakable step. Run the race:
// UNSAFE: three steps, interleaves, loses updates int count = 0; count++; // SAFE option A — atomic single step AtomicInteger count = new AtomicInteger(); count.incrementAndGet(); // SAFE option B — explicit lock lock.lock(); try { count++; } finally { lock.unlock(); }
CompletableFuture chains async steps without blocking, e.g. fetch messages, then enrich, then return.ExecutorService pool = Executors.newFixedThreadPool(8); CompletableFuture .supplyAsync(() -> repo.load(id), pool) // off-thread .thenApply(Message::render) // transform result .thenAccept(System.out::println); // consume it
Now the words: synchronized = built-in lock on an object's monitor. Lock (ReentrantLock) = flexible explicit lock (tryLock, timeouts). volatile guarantees visibility (one thread's write is seen by others) but not atomicity. Atomics give lock-free atomic updates via CPU compare-and-swap.
volatile int count; count++ is still a race, because ++ is three ops. Use volatile for a simple flag one thread sets and others read; use atomics or locks for read-modify-write.HashSet to dedupe. Both stay. By default Java compares by memory address, so two distinct objects are "different" even with identical fields. Dedup fails.equals, and you must update the fingerprint (hashCode) to match, or the coat check (section 3) files them under different hooks.Override both together. Equal objects must have equal hash codes, always. If you override one and not the other, HashMap/HashSet break in confusing ways.
@Override public boolean equals(Object o){ if (this == o) return true; if (!(o instanceof Message m)) return false; return id.equals(m.id); // equal iff same id } @Override public int hashCode(){ return Objects.hash(id); // MUST match equals fields }
Now the words: the contract: (1) equal objects → equal hashCodes; (2) equals is reflexive, symmetric, transitive, and consistent. Use the same fields in both. A record generates a correct equals/hashCode for you from all its components, another reason to prefer records for data.
equals only. Now two "equal" messages get different buckets, so set.contains(msg) returns false for a message you just added.Make fields final, set them once in the constructor, expose no setters, and copy any mutable input in / mutable output out. Now the object can never change after birth, so no thread can ever see it half-updated.
public final class Message { // final: no subclass sneaks mutability private final String id; private final List<String> tags; public Message(String id, List<String> tags){ this.id = id; this.tags = List.copyOf(tags); // defensive copy → unmodifiable } public List<String> tags(){ return tags; } // already unmodifiable }
Now the words: an immutable object's observable state never changes after construction. It's inherently thread-safe, safe to cache, and safe as a Map key. String and the boxed types (Integer, etc.) are immutable for exactly this reason. Records are shallowly immutable by default.
final field pointing at a mutable list is still mutable through that reference. Wrap with List.copyOf or Collections.unmodifiableList.free() each one and a single miss leaks memory forever. Who cleans up in Java, and how does it avoid pausing the whole service to do it?Local variables and call frames live on the stack (fast, auto-freed on return). Objects live on the heap. The GC automatically reclaims objects nothing points to. Most objects die young, so the heap is split into generations to collect cheaply.
Now the words: the weak generational hypothesis: most objects die young. The GC collects the young generation often and cheaply (minor GC); survivors are promoted to the old generation. G1 (the default since Java 9) splits the heap into regions and does mostly-concurrent, low-pause collection. A StackOverflowError = too-deep recursion; OutOfMemoryError = heap exhausted (often a leak: objects still referenced but never used).
A functional interface has exactly one abstract method, so a lambda can stand in for it. Four you'll use constantly: Function<T,R> (transform), Predicate<T> (test → boolean), Supplier<T> (produce), Consumer<T> (consume, no return).
Predicate<Message> notSpam = m -> !m.text().contains("BUY NOW"); Function<Message,String> toLine = m -> m.sender()+": "+m.text(); Supplier<String> newId = () -> UUID.randomUUID().toString(); Consumer<String> logIt = s -> log.info(s); messages.stream() .filter(notSpam) // Predicate .map(toLine) // Function .forEach(logIt); // Consumer
Now the words: @FunctionalInterface marks a single-abstract-method interface. A lambda m -> ... is shorthand for implementing it. A method reference (Message::text) is even shorter when the lambda just calls one method. This is what makes the Streams API (section 6) read cleanly.
Three layers: Controller handles HTTP (routes, JSON in/out). Service holds business logic. Repository talks to the database. Spring wires them together via dependency injection (constructor injection).
@RestController @RequestMapping("/messages") class MessageController { private final MessageService svc; MessageController(MessageService svc){ this.svc = svc; } // injected @PostMapping Message send(@RequestBody NewMessage req){ return svc.save(req); } @GetMapping("/{id}") Message get(@PathVariable String id){ return svc.find(id).orElseThrow(() -> new NotFound(id)); } } @Service class MessageService { // business rules live here private final MessageRepository repo; MessageService(MessageRepository repo){ this.repo = repo; } Message save(NewMessage r){ return repo.save(new Message(r)); } Optional<Message> find(String id){ return repo.findById(id); } } @Repository interface MessageRepository extends JpaRepository<Message, String> {} // Spring writes the SQL
Now the words: dependency injection means the framework supplies each layer's collaborators instead of the class new-ing them, which makes layers swappable and testable (inject a fake repo in tests). The controller returns POJOs/records; Spring serializes them to JSON automatically.
Optional from section 7. Every earlier idea shows up here.