Skip to content

Latest commit

 

History

History
861 lines (615 loc) · 36.7 KB

README.md

File metadata and controls

861 lines (615 loc) · 36.7 KB

Before Java 8

Wikipedia: Java version history, OpenJDK projects

Most tutorials start Java at version 8, with lambdas and streams. That is a strange place to start, because every one of those features was invented to fix a problem that already existed. Lambdas exist because anonymous inner classes were painful. Streams exist because looping over a collection to filter and sum was repetitive. Optional exists because people kept returning null.

So this folder is the part that comes first. If you can read the code here, Java 8 will feel like a set of shortcuts rather than a new language.

The package is called beforejava8, with no dashes, because a folder name here also has to be a legal Java package name.

Running the demos

Everything in this folder runs on any JDK from 8 upwards. There are six programs, and each one has a main:

java src/beforejava8/CollectionsFramework.java
java src/beforejava8/Generics.java
java src/beforejava8/AnonymousInnerClasses.java
java src/beforejava8/Java5Features.java
java src/beforejava8/ConcurrencyBeforeJava8.java
java src/beforejava8/Java7Features.java

There are no comments in the code. Each main is a list of small method calls, and the method names match the section headings below, so you can read a heading here and then go and look at the method with the same name.


A quick timeline

Release Year What it is remembered for
JDK 1.0 1996 The language itself
JDK 1.1 1997 Inner classes, anonymous classes, reflection, JDBC
J2SE 1.2 1998 The Collections Framework, Swing
J2SE 1.3 2000 The HotSpot JVM
J2SE 1.4 2002 assert, regular expressions, NIO, exception chaining
J2SE 5.0 2004 Generics, enums, annotations, autoboxing, varargs, for-each, java.util.concurrent
Java SE 6 2006 Mostly speed. Very few new language features
Java SE 7 2011 Diamond operator, try-with-resources, multi-catch, strings in switch, NIO.2, fork/join

Two of these matter far more than the others. Java 5 changed how Java is written, and almost every line of modern Java uses something from it. Java 7 added a set of small conveniences that you will use every day. The rest is mostly history.


Inner and anonymous classes (JDK 1.1)

Demo: AnonymousInnerClasses.java

This is the most important section in the folder, because a Java 8 lambda is really just a short way of writing an anonymous class.

There are four kinds of class that live inside another class:

Kind How you write it Does it remember the outer object?
Static nested static class Inner {} No
Inner (non-static) class Inner {} Yes, as Outer.this
Local declared inside a method Yes, plus the local variables it uses
Anonymous new Runnable() { ... } Yes, plus the local variables it uses

A static nested class is just a class that happens to be written inside another one, usually to keep related things together. You create it on its own: new Outer.StaticNested().

An inner class is different. Every instance secretly holds a reference to the outer object, which is why you have to create it through an instance with the odd outer.new Inner() syntax. That hidden reference is also a real problem: as long as the inner object is alive, the outer object cannot be garbage collected. If you do not need the outer object, make the class static.

The anonymous class, and the lambda that replaced it

Before Java 8, if you wanted to pass a piece of behaviour to a method, you had to wrap it in an object:

Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("running");
    }
};

Java 8 writes the same thing like this:

Runnable task = () -> System.out.println("running");

Look at the first version again. Six lines, and only one of them says anything. That ratio is the whole reason lambdas were added.

But the two are not identical, and the differences matter:

  • this means different things. Inside an anonymous class, this is the anonymous object, so you have to write Outer.this to reach the enclosing object. Inside a lambda, this is already the enclosing object. A lambda does not create a new scope for it.
  • An anonymous class can extend a class. new Thread() { ... } works. A lambda can only implement an interface that has exactly one abstract method.
  • An anonymous class can hold state. It can declare fields, and the demo has one that counts how many times it was called. A lambda has nowhere to put a field.
  • They compile to different things. An anonymous class produces a real class file on disk, named something like AnonymousInnerClasses$7.class, and the JVM loads it like any other class. A lambda produces no class file at all. It becomes an invokedynamic instruction that builds the object the first time the line runs. The demo prints both type names so you can see the difference.

Capturing local variables

An anonymous or local class can use local variables from the method around it, but only if those variables never change:

int captured = 10;
Runnable reader = () -> System.out.println(captured);
// captured = 11;   this line would make the code above stop compiling

The reason is simple once you know what the compiler does. The value is copied into the generated class. If you could then change the original variable, the copy would still hold the old value, and nobody would be able to guess which one they were looking at. Java avoids the whole argument by refusing to compile it.

Before Java 8 you had to write final on the variable yourself. Java 8 kept the rule but stopped asking for the keyword: if the compiler can see that you never reassign the variable, that is enough. This is what "effectively final" means.

When people genuinely needed a value that changes, they used a trick that still shows up in old code — put the value in a one-element array. The array reference never changes, so it is final; the contents are free to change:

final int[] counter = {0};
Runnable increment = () -> counter[0]++;

The Collections Framework (J2SE 1.2)

Demo: CollectionsFramework.java

Before 1.2, Java had Vector, Hashtable, Stack and Enumeration, and that was it. They shared no common interface, so you could not write a method that accepted "any collection". They also synchronised every single method, which made them slow when only one thread was using them, and still not safe enough when several were.

Version 1.2 replaced them with a set of interfaces that everything since has been built on:

Iterable
└── Collection
    ├── List   → ArrayList, LinkedList
    ├── Set    → HashSet, LinkedHashSet, TreeSet
    └── Queue  → ArrayDeque, PriorityQueue, LinkedList

Map (on purpose, not a Collection) → HashMap, LinkedHashMap, TreeMap

Why Map is not a Collection

A Collection is a group of single things. A Map is a group of pairs. Forcing one to be the other would have made both worse, so Map stands on its own and offers three views instead: keySet(), values() and entrySet().

The word view is important. A view is not a copy. It is a window onto the same map, so changing the view changes the map. The demo removes a key through keySet() and then shows that it disappeared from the map itself.

Picking an implementation

Choosing a collection is choosing which operation you want to be fast.

  • ArrayList keeps the elements in one array. Reading by index is instant. Inserting in the middle has to shift everything after it. This is the right default in almost every case.
  • LinkedList keeps each element in its own node with links to its neighbours. Reading by index has to walk the chain, so get(5000) is slow. Inserting is instant, but only if you already hold the position. In practice its best use is as a Deque.
  • HashMap finds a key in roughly one step, and does not keep any order.
  • LinkedHashMap costs the same and keeps the order in which you inserted the keys.
  • TreeMap keeps the keys sorted, and every operation costs a few more steps as the map grows.
  • HashSet, LinkedHashSet and TreeSet are the same three trade-offs, for sets.

Arrays.asList is not a normal list

Arrays.asList("a", "b", "c") gives you a fixed-size view over an array. You can replace an element with set, but add throws UnsupportedOperationException, because there is nowhere to put the new element. Java 9's List.of(...) is different again: it is fully immutable, so even set fails.

Sorting: Comparable and Comparator

Comparable is the type's own opinion about order. You implement compareTo inside the class, and you get one ordering. In the demo, Employee says its natural order is by age.

Comparator is somebody else's opinion. It lives outside the class, and you can write as many as you need. Before Java 8 you wrote one as an anonymous class:

Collections.sort(staff, new Comparator<Employee>() {
    @Override
    public int compare(Employee a, Employee b) {
        return Double.compare(b.salary, a.salary);
    }
});

Java 8 turns that into one line: staff.sort(Comparator.comparingDouble(Employee::getSalary).reversed()).

One thing worth remembering: never write return this.age - other.age. It looks fine and it is wrong. Integer subtraction can overflow, so for very large or very negative numbers the result flips sign and your ordering silently breaks. Use Integer.compare(a, b).

Also note that Collections.sort is stable: two employees with the same age keep the order they were already in. The demo shows Sara staying ahead of Ali for exactly that reason.

equals and hashCode

Hash-based collections find an object in two steps. First they use hashCode() to pick a bucket, then they use equals() to check the objects in that bucket. So the two methods have to agree, and the rule is:

If two objects are equal, they must have the same hash code.

The demo has two identical classes, BadPoint and GoodPoint. BadPoint overrides neither method, so it inherits Object's versions, which compare object identity. Put two BadPoint(1, 1) objects in a HashSet and you get a set of size 2, which is not what a set is supposed to do. GoodPoint overrides both, and the set has size 1.

Getting this wrong in one direction is even worse. If you override equals but forget hashCode, two equal objects can land in different buckets, so map.get(key) returns null for a key that is equal to one you stored. Java 16's record generates both methods for you, which removes the whole problem.

Iterators, and the exception everyone meets once

You cannot add or remove elements while a for-each loop is running:

for (Integer n : numbers) {
    if (n % 2 == 0) {
        numbers.remove(n);   // throws ConcurrentModificationException
    }
}

The list counts its own structural changes. The iterator remembers what that count was when it started, and checks it on every step. When the numbers no longer match, it throws. Despite the name, this has nothing to do with threads — one single thread is enough to cause it.

The fix before Java 8 was to remove through the iterator, which knows how to keep its own bookkeeping straight:

Iterator<Integer> iterator = numbers.iterator();
while (iterator.hasNext()) {
    if (iterator.next() % 2 == 0) {
        iterator.remove();
    }
}

Java 8 shortens this to numbers.removeIf(n -> n % 2 == 0).

The Collections utility class

Collections (with an s) is a class full of static helpers: sort, reverse, binarySearch, frequency, max, emptyList, unmodifiableList, synchronizedList. Two notes on it:

  • binarySearch only works on a list that is already sorted. On an unsorted list it returns nonsense, quietly.
  • unmodifiableList returns a read-only view, not a copy. If someone still holds the original list, they can change it, and your "unmodifiable" view will change too. Java 10's List.copyOf makes a real copy.
  • synchronizedList wraps each method in a lock, but iteration is still several method calls, so you have to synchronise the whole loop yourself. This is a classic bug.

Small but useful: J2SE 1.4

Version 1.4 has no demo file, but four things from it are worth knowing.

assert checks an internal assumption: assert index >= 0 : "index was " + index;. Assertions are switched off unless you run the JVM with -ea, so never use them to validate the arguments of a public method — in production they simply will not run.

Regular expressions arrived as java.util.regex, with Pattern and Matcher. Compiling a Pattern is the expensive part, so compile it once into a static final field and reuse it.

NIO (java.nio) added buffers and channels for non-blocking I/O. Note that Java 7's "NIO.2" is a completely different thing despite the name — that one is about files.

Chained exceptions added new RuntimeException("could not load config", cause) and getCause(). Before this, when you caught an exception and threw a different one, the original stack trace was simply lost, and you had no idea what actually went wrong.


Generics (J2SE 5.0)

Demo: Generics.java

The problem generics solved

Before Java 5, a collection held Object, so you cast on the way out, and nothing stopped you from putting the wrong thing in:

List list = new ArrayList();
list.add("a string");
list.add(42);
String s = (String) list.get(1);   // compiles, then throws ClassCastException

Generics move that failure from run time to compile time. List<String> will not accept an Integer, and get gives you a String with no cast. This is the whole point: the same bugs, found earlier.

You can still write List with no type argument. That is called a raw type, it turns all the checking off, and it only exists so that code written before 2004 still compiles.

Erasure, and everything that follows from it

Generics are checked by the compiler and then thrown away. After compilation, List<String> and List<Integer> are both just List. This is called type erasure, and it was done so that new code and old code could run together on the same JVM.

The demo proves it in one line: strings.getClass() == integers.getClass() prints true.

Almost everything confusing about generics comes from this one decision:

  • You cannot write new T() or new T[10]. At run time there is no T to build.
  • You cannot write x instanceof List<String>. The type argument is not there any more. Only x instanceof List works.
  • You cannot overload f(List<String>) and f(List<Integer>). After erasure both become f(List), which is the same method twice.
  • Static fields are shared by every version of the class. There is only one Box class, so Box<String> and Box<Integer> share one copy of every static field. The demo creates one of each and prints a shared counter of 2. This is also why a static method cannot use the class's type parameter: T belongs to an instance's type, and static methods do not have one.

The compiler also has to insert casts for you, and generate hidden bridge methods so that overriding still works after erasure. If Child implements Comparable<Child> with a compareTo(Child) method, the compiler quietly adds a compareTo(Object) that casts and calls it, because that is the signature the interface really has.

The standard way around "I cannot create an array of T" is to pass the class in and use reflection, which is what newArray does in the demo:

static <T> T[] newArray(Class<T> type, int length) {
    return (T[]) Array.newInstance(type, length);
}

Bounded type parameters

<T> on its own means "any type". Add a bound and you can actually use the type inside the method:

static <T extends Comparable<T>> T max(List<T> list) { ... }
static <T extends Number> double sum(List<T> list) { ... }

T extends Comparable<T> looks strange the first time. Read it as "T is a type that can be compared to other Ts". It is a standard shape and you will see it often.

Wildcards, and why generics are strict about subtypes

A List<Integer> is not a List<Number>, even though an Integer is a Number. Students almost always find this wrong at first. Here is why:

List<Integer> integers = List.of(1, 2, 3);
List<Number> numbers = integers;   // imagine this compiled
numbers.add(1.5);                  // now a List<Integer> contains a Double

Generics are invariant — that just means List<Integer> and List<Number> are unrelated types — precisely to stop that last line.

Arrays made the opposite choice back in Java 1.0, and it was a mistake they could not undo:

Object[] objects = new Integer[]{1, 2, 3};
objects[0] = "not an integer";   // compiles, then throws ArrayStoreException at run time

Same bug, found much later. The demo runs this and catches the exception.

Wildcards give back the flexibility that invariance takes away:

List<? extends Number> producer;   // you can read Numbers out; you cannot add anything
List<? super Integer>  consumer;   // you can add Integers; reads only give you Object

Why can you not add to a List<? extends Number>? Because ? might really be Double, and adding an Integer would break it. The compiler does not know which, so it refuses everything.

The rule for remembering which to use is PECS: Producer extends, Consumer super. If the parameter gives you values, use extends. If it takes values from you, use super. The signature in the JDK that shows both at once is:

static <T> void copy(List<? super T> destination, List<? extends T> source)

When the type genuinely does not matter, use the unbounded ?. Note that Collection<?> is not the same as Collection<Object>: you can read from it as Object, but the only thing you can add is null.

Varargs and heap pollution

A T... parameter is really a T[]. Arrays keep their element type at run time and generics do not, so mixing them lets a List<Integer> end up inside something declared as List<String>[]. That situation has a name, heap pollution, and it shows up much later as a ClassCastException far from the real cause.

That is why the compiler warns at every call to a generic varargs method. @SafeVarargs (Java 7) turns the warning off. It does not check anything — it is you promising that the method never writes into that array and never lets it escape. It is only allowed on static, final and private methods, because anyone overriding the method could break the promise.


enum (J2SE 5.0)

Demo: Java5Features.java, methods enumsCarryStateAndBehaviour onwards

Before Java 5, a fixed set of values was written as integer constants:

public static final int PLANET_MERCURY = 1;
public static final int PLANET_VENUS = 2;

Nothing stopped you from passing 47, printing one gave you a number instead of a name, and there was no way to attach data to a constant.

A Java enum is a real class. It can have fields, a constructor and methods, and each constant is one instance of it. The demo's Planet stores a mass and a radius and calculates surface gravity from them.

You also get a lot for free: values(), valueOf(String), name(), ordinal(), correct equals and hashCode, sensible toString, and serialization that cannot accidentally create a second copy of a constant.

Two more things worth knowing:

Constant-specific bodies. Each constant can override a method, which effectively makes it its own little subclass:

enum Operation {
    PLUS("+") { int apply(int x, int y) { return x + y; } },
    MINUS("-") { int apply(int x, int y) { return x - y; } };
    abstract int apply(int x, int y);
}

EnumSet and EnumMap. Because the set of constants is known and small, these are backed by a bit vector and a plain array. They are much faster than HashSet and HashMap for enum keys, and EnumMap always iterates in declaration order.

Do not store ordinal() in a database or a file. It is just the position in the source code, so the day somebody adds a constant in the middle, all your old data means something different. Store name().

Finally, a one-constant enum is the simplest correct singleton in Java. The demo's Registry is one.


Autoboxing (J2SE 5.0)

Demo: Java5Features.java, methods autoboxing, autoboxingTraps, theRemoveOverloadTrap

Autoboxing means the compiler quietly converts between int and Integer for you, so you can put primitives into a List<Integer> without writing Integer.valueOf(...) every time.

It is convenient, and it has three traps that never go away.

1. == on boxed values. This surprises everybody once:

Integer a = 127, b = 127;   a == b   // true
Integer c = 128, d = 128;   c == d   // false

Integer.valueOf keeps a cache of the values from −128 to 127, so the small ones really are the same object. Above 127 you get a new object each time, and == compares references. Always use equals for boxed types.

2. Unboxing null. This line looks harmless and throws NullPointerException:

Integer maybeNull = map.get("missing");
int value = maybeNull;   // becomes maybeNull.intValue()

3. list.remove(...) on a List<Integer>. List has both remove(int index) and remove(Object o). With a List<Integer> they look identical at the call site:

list.remove(1);                    // removes the element AT INDEX 1
list.remove(Integer.valueOf(1));   // removes the VALUE 1

There is also a performance cost. Every += on a Long allocates a new object, so a loop that adds a million values allocates a million objects. Use the primitive type when you are just counting.


varargs, for-each and static imports (J2SE 5.0)

Demo: Java5Features.java, methods varargs, forEachLoop, staticImports

varargs lets a method take any number of arguments: static int sum(int... numbers). Inside the method, numbers is an ordinary int[]. A method can have at most one varargs parameter and it has to be last. If two overloads could match a call, the one without varargs always wins — varargs is the compiler's last resort.

The for-each loop works on anything that implements Iterable, and on arrays. It is shorter and harder to get wrong than an index loop. You still need the old form when you need the index itself, or when you want to remove elements.

Static imports let you write max(3, 9) instead of Math.max(3, 9). Useful for Math and for constants. Use them sparingly — a reader who does not know where a name came from has to go and look.


Annotations (J2SE 5.0)

Demo: Java5Features.java, method readingAnnotationsAtRuntime

An annotation attaches information to a class, method or field without changing what it does. Java ships with a few: @Override (the compiler checks you really are overriding something), @Deprecated and @SuppressWarnings.

You can declare your own with @interface:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@interface Since {
    int value();
    String[] authors() default {};
}

Two meta-annotations control it. @Target says where it may be used. @Retention says how long it survives:

  • SOURCE — thrown away by the compiler. Good for things only tools read, like @Override.
  • CLASS — stored in the class file, but not visible at run time. This is the default.
  • RUNTIME — readable through reflection while the program runs.

Only RUNTIME annotations can be found with method.getAnnotation(Since.class), which is what the demo does. This is exactly how JUnit finds your @Test methods and how Spring finds your @Service classes — there is no magic in it.

One small syntax rule: a member called value can be given without naming it, so @Since(5) and @Since(value = 5) mean the same thing.


Concurrency: from Thread to java.util.concurrent

Demo: ConcurrencyBeforeJava8.java

The starting point

Java has had threads since 1.0. You create one with a Runnable and call start(). Calling run() yourself does nothing special — it just runs the method on the current thread, which is a common beginner mistake.

Why you need synchronisation at all

value++ looks like one step. It is three: read the field, add one, write it back. Two threads can read the same value before either writes, and one update disappears.

The demo runs two threads that each increment a plain int 100,000 times. The correct answer is 200,000. The actual answer is usually somewhere around 120,000, and it is different every run.

Adding synchronized fixes it:

synchronized void increment() {
    value++;
}

synchronized locks an object's monitor — think of it as a "one thread at a time" sign on that object. On an instance method the object is this. On a static method it is the class object. Those are two different locks, and mixing them up is a quiet source of races.

Notice that the demo also marks the getter synchronized. Locking is not only about mutual exclusion; it is also what makes one thread's writes visible to another.

volatile is about visibility, not atomicity

Without any synchronisation, a thread is allowed to keep a field in a register and never re-read it. A loop like while (running) { ... } can therefore run forever after another thread sets running = false.

Marking the field volatile fixes that: every read really reads, and every write is immediately visible.

What volatile does not give you is atomicity. volatile int i; i++; still loses updates, because it is still three steps. Use volatile for a flag. Use AtomicInteger or a lock for a counter.

wait and notify

The original way for threads to hand work to each other. One thread calls lock.wait(), which releases the lock and sleeps; another calls lock.notifyAll() to wake it up. Both must be called while holding the lock.

Always wait inside a loop, never an if:

synchronized (lock) {
    while (box.isEmpty()) {
        lock.wait();
    }
    ...
}

Two reasons. The specification allows spurious wakeups, where a thread wakes up for no reason at all. And with notifyAll, several threads wake up but only one of them gets the item. Either way you must re-check the condition after waking.

You will rarely write this today — BlockingQueue does the same job and is far harder to get wrong — but it is a common interview question.

java.util.concurrent (Java 5)

Written by Doug Lea, and one of the best things ever added to the JDK. It replaced "start a thread and hope" with real tools.

ExecutorService separates what to run from the thread that runs it. You submit tasks; a pool of threads picks them up. You get reuse, a bounded number of threads, a queue, and a proper shutdown.

Shutting down matters. A pool's threads are non-daemon by default, which means the JVM will not exit while they exist. The standard sequence, which the demo uses, is:

pool.shutdown();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
    pool.shutdownNow();
}

Callable and Future. A Runnable returns nothing and cannot throw a checked exception. A Callable<V> returns a V and may throw. Submitting one gives you a Future<V>, and future.get() blocks until the answer is ready.

If the task threw, get() throws ExecutionException, and the real exception is inside it as getCause(). The demo shows this.

Future is also where Java 8's CompletableFuture came from. Future can only be waited on; CompletableFuture can be chained, combined and completed by hand.

Atomics. AtomicInteger and friends use a CPU instruction called compare-and-swap: "set this to 5, but only if it is still 3, and tell me whether it worked". No lock is involved. The demo runs the same two-thread test as before and gets exactly 200,000.

Concurrent collections.

  • ConcurrentHashMap locks small parts of the map rather than all of it, and gives you compound operations that are atomic on their own — putIfAbsent, replace, compute. With a normal map, "check then put" is two steps and another thread can slip in between them.
  • CopyOnWriteArrayList copies the whole array on every write. That sounds terrible, and it is, unless writes are rare and reads are constant — a list of event listeners is the usual example. Readers never lock and never see a ConcurrentModificationException.
  • BlockingQueue is the readable replacement for wait/notify. put waits if the queue is full, take waits if it is empty, and that is the entire producer-consumer pattern.

Explicit locks. ReentrantLock does what synchronized does, and adds things synchronized cannot: a tryLock with a timeout, a lock you can interrupt, fairness, and several wait conditions per lock. The cost is that you must unlock in a finally block yourself. ReadWriteLock allows many readers or one writer.

Coordination. CountDownLatch waits until N things have happened, and cannot be reset. CyclicBarrier is the reusable version. Semaphore limits how many threads may hold a permit at once.

Fork/join (Java 7)

ForkJoinPool is a thread pool built for splitting one big task into smaller ones. Each thread has its own queue, and an idle thread steals work from a busy one, which is where the name work stealing comes from.

You write a RecursiveTask, split until the piece is small enough, and compute directly:

if (to - from <= THRESHOLD) {
    return sumDirectly();
}
SumTask left = new SumTask(data, from, middle);
SumTask right = new SumTask(data, middle, to);
left.fork();
long rightResult = right.compute();
return rightResult + left.join();

Notice that only the left half is forked. Computing the right half on the current thread instead of forking both is the standard shape — forking both just makes the pool do extra work for nothing.

This matters for Java 8 because list.parallelStream() runs on the same shared pool, ForkJoinPool.commonPool(). That is why parallel streams only pay off for CPU-heavy work over a source that splits easily, and why blocking inside one is a bad idea.


Java SE 6

Nothing here changes the language. It was mostly a performance release, and the JVM work in it (biased locking, escape analysis, lock coarsening) is the reason synchronized stopped being expensive.

The API additions were the scripting API (javax.script), the compiler API (javax.tools, which lets you call javac from code), JDBC 4.0, and a built-in web services stack that was removed again in Java 11. One small language detail did land: @Override finally became legal on a method that implements an interface.


Java SE 7

Demo: Java7Features.java

Java 7 collected a set of small language changes under the name Project Coin. None of them is deep, and you will use all of them.

The diamond operator

Map<String, List<Integer>> map = new HashMap<String, List<Integer>>();   // Java 5 and 6
Map<String, List<Integer>> map = new HashMap<>();                        // Java 7

The compiler already knows the type arguments from the left-hand side, so repeating them was pure noise. Java 9 extends the diamond to anonymous classes, and Java 10's var attacks the same problem from the other side.

try-with-resources

Any object that implements AutoCloseable can be declared in the try header, and it will be closed for you — at the end of the block, on an exception, on a return, always. Several resources are closed in reverse order of declaration.

try (BufferedReader reader = Files.newBufferedReader(path)) {
    return reader.readLine();
}

The old way hid a nasty bug:

BufferedReader reader = null;
try {
    reader = Files.newBufferedReader(path);
    return reader.readLine();
} finally {
    if (reader != null) reader.close();
}

If the body throws and close() also throws, the exception from close() replaces the real one, and you spend an afternoon debugging the wrong problem.

try-with-resources fixes it with suppressed exceptions. The body's exception is the one that propagates, and close()'s exception is attached to it. You read it back with getSuppressed(), which the demo prints.

Java 9 relaxes the syntax slightly: if the variable is already effectively final, you can write try (reader) { } without redeclaring it.

Multi-catch and precise rethrow

try {
    ...
} catch (IOException | SQLException ex) {
    ...
}

One block for several unrelated exception types, with no need to catch their common supertype and no duplicated code. The variable is implicitly final, so you cannot reassign it.

Precise rethrow is a smaller change that is easy to miss. The compiler now tracks which exceptions the try block can actually throw, so this method can be declared throws IOException:

static void preciseRethrow() throws IOException {
    try {
        throw new IOException("...");
    } catch (Exception ex) {
        throw ex;
    }
}

Before Java 7 the compiler only looked at the declared type of ex, so you had to write throws Exception and every caller suffered for it.

Strings in switch, and nicer number literals

switch (command) {
    case "start": return "starting";
    case "stop":
    case "halt":  return "stopping";
    default:      return "unknown";
}

This is not a chain of ifs in disguise. The compiler switches on hashCode() to find a candidate and then calls equals() to confirm it, so it stays fast as the number of cases grows. A null selector throws NullPointerException — writing case null only became legal in Java 21.

int permissions = 0b111_101_101;
long cardNumber = 1234_5678_9012_3456L;
int million     = 1_000_000;

Binary literals with 0b, and underscores anywhere in the middle of a number. Underscores cannot start or end a literal, and cannot sit next to the decimal point or the L suffix.

java.util.Objects

A small class of null-safe helpers that saves a lot of boilerplate: Objects.equals(a, b) (no exception if either is null), Objects.hashCode(o), Objects.toString(o, "fallback"), Objects.hash(a, b, c) for writing hashCode, and Objects.requireNonNull(x, "message") for failing fast with a useful message.

NIO.2, the file API

java.io.File had a serious weakness: when something failed, it returned false. Not why it failed — just false.

Java 7 replaced it with java.nio.file: Path for the location, Files for the operations, plus FileVisitor, WatchService and real support for symbolic links and file attributes. When something goes wrong you get a specific exception, and the demo shows NoSuchFileException telling you exactly which file it could not find.

Walking a directory tree before Java 8 meant writing a visitor:

Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) {
        found.add(file.getFileName().toString());
        return FileVisitResult.CONTINUE;
    }
});

Java 8 adds Files.walk(root), which gives you a Stream<Path> instead. Files.lines() (Java 8) and Files.readString() (Java 11) are later additions to the same API.

invokedynamic

Java 7 added one new JVM instruction, invokedynamic. Instead of naming the method to call, it names a bootstrap method that decides, the first time the line runs, what should be called. After that first time it is optimised like any ordinary call.

It was added for languages like JRuby and Groovy, and then Java itself became its biggest user:

  • Java 8 — every lambda is an invokedynamic call site
  • Java 9 — string concatenation with +
  • Java 21 — pattern matching in switch

The benefit is that these features do not need new bytecode, so their implementation can be improved without recompiling anybody's code.


What Java 8 changes about all of this

Now that you have seen the old way, here is what the next folder replaces:

The pre-8 way The Java 8 way
An anonymous class with one method A lambda
A loop that filters, maps and sums A stream pipeline
Returning null and hoping the caller checks Optional<T>
A hand-written Comparator Comparator.comparing(...).thenComparing(...)
Adding a method to an interface and breaking everyone A default method
Date, Calendar and SimpleDateFormat java.time
Future.get(), which only blocks CompletableFuture, which composes
Splitting work across threads by hand parallelStream()
Files.walkFileTree with a visitor Files.walk returning a stream