Study Notes
Language Fundamentalscore

Java 8 Features

Default & static methods in interfaces, functional interfaces, lambda expressions, method references, and the Streams API for declarative data processing.

30 min read·verified Java 21·reviewed 2026-08-29

Assumes you know

After this you should be able to

  • Use a functional interface
  • Judge when a lambda improves clarity
LambdasStreamsFunctional Interfaces

Self-contained study resource. Section I is a hand-copy cheat sheet; Section II is the full explanation; Section III has runnable code for every concept; Section IV is a hands-on lab.

Default & static interface methods @FunctionalInterface Lambdas Supplier / Consumer / Function / Predicate Stream API Lazy evaluation

+ tag = my own enrichment / correction beyond the core material. Everything else is the backbone.

I. Notebook Summary (copy by hand) #

Terse, keyword-first, monospace blocks. Fully derivable from Section II.

                    JAVA 8 INTERFACE CHANGES
========================
Before J8 : iface = only public ABSTRACT methods, no body
            add method  -> ALL implementers break (must update)
J8+       : + static methods (have body)
            + default methods (have body)
            => add methods WITHOUT breaking old code (backward compat)

STATIC METHOD in iface
  - belongs to IFACE itself, not instance
  - call: InterfaceName.method()
  - NOT inherited, NOT overridable
  - use: utility/helper, no instance data
  - cannot touch instance vars/methods

DEFAULT METHOD in iface  (keyword: default)
  - has body; INHERITED by impl classes
  - impl class MAY override
  - purpose: evolve iface safely

doodle: box "interface" with 3 slots: [abstract |open]
        [default |filled, copyable] [static |locked to box]
                
                    STATIC vs DEFAULT  (interface methods)
+-----------+----------------+------------------+
|           | STATIC         | DEFAULT          |
+-----------+----------------+------------------+
| has body  | yes            | yes              |
| called on | Iface.m()      | instance.m()     |
| inherited | NO             | YES              |
| override  | NO             | YES (optional)   |
| use for   | helpers/util   | safe evolution   |
+-----------+----------------+------------------+
                
FUNCTIONAL INTERFACE (FI)
  = exactly ONE abstract method (SAM = Single Abstract Method)
    + any # of static + default + Object methods (don't count)
  @FunctionalInterface = COMPILE-TIME check only (optional) if >1 abstract -> compile error
  reason to exist -> enable LAMBDAS / functional programming

LAMBDA = short anon block: (params) -> body
  = an INSTANCE of a functional interface
  takes params, returns value
  forms:  () -> expr        (no args) x  -> x*2         (1 arg, parens optional)(a,b) -> a+b      (2 args)(x) -> { ...; return v; }   (block body needs return)

[+] method ref = lambda shorthand:  Class::method , obj::method , Class::new

doodle: lambda = arrow gun  (params)-->[body]  loaded into FI socket 
                    BUILT-IN FUNCTIONAL INTERFACES  (java.util.function)
+-------------+---------------------+-----------+-----------+
| Iface       | abstract method     | in -> out | example   |
+-------------+---------------------+-----------+-----------+
| Supplier<T>| T   get()           | () -> T   | ()->rand  |
| Consumer<T>| void accept(T)      | T  -> ()  | print     |
| BiConsumer  | void accept(T,U)    | T,U-> ()  | put(k,v)  |
| Function<T,R>| R   apply(T)        | T  -> R   | len(s)    |
| BiFunction  | R   apply(T,U)      | T,U-> R   | a+b       |
| Predicate<T>| boolean test(T)     | T  -> bool| x>0       |
+-------------+---------------------+-----------+-----------+
mnemonic: SUPPLY-out, CONSUME-in, FUNCTION-transform, PREDICATE-judge
[+] also: UnaryOperator<T>(Function T->T), BinaryOperator<T>(BiFunction T,T->T)
[+] default combos: Predicate.and/or/negate ; Function.andThen/compose
doodle: Supplier=tap(->water) Consumer=drain(water->) Predicate=gate(?)
                
                    IMPERATIVE vs DECLARATIVE
+--------------+--------------------+--------------------+
|              | IMPERATIVE         | DECLARATIVE        |
+--------------+--------------------+--------------------+
| you write    | HOW (steps)        | WHAT (intent)      |
| state        | you manage (vars)  | library manages    |
| flow control | you (loops/if)     | hidden             |
| ex           | for+if+counter     | filter.limit.collect|
| analogy      | recipe steps       | order the dish     |
+--------------+--------------------+--------------------+
                
                    STREAM API
  Stream = sequence of elements from a SOURCE (not a data store!)
  pipeline = SOURCE -> intermediate ops* -> terminal op
  [+] does NOT mutate source; single-use; can be sequential/parallel

  PIPELINE SHAPE:
    collection.stream()        // source
       .filter(..)  .map(..)   // intermediate (lazy, return Stream)
       .sorted() .distinct()
       .collect(..) / forEach  // terminal (eager, ends stream)

doodle: balls ->[filter red]->[map melt->triangle]->[filter big]->[reduce sum]
        client ->[pipe: worker|worker|worker] ->result
                
                    STREAM CREATION
  list.stream()              // from Collection
  Arrays.stream(arr)         // from array
  Stream.of(a,b,c)           // from values
  Stream.iterate(0,n->n+1)   // infinite -> need limit
  Stream.generate(Math::random) // infinite -> need limit
  IntStream.range(1,5)       // 1,2,3,4  (rangeClosed = incl end)
  str.chars()                // IntStream of chars

INTERMEDIATE OPS (lazy, return Stream)
  filter(pred)   keep matching
  map(fn)        transform each   (mapToInt/Long/Double for primitives)
  sorted()/(cmp) order
  limit(n)       first n          skip(n) drop first n
  distinct()     dedupe (uses equals/hashCode)
  peek(consumer) side-effect only (debug/log) -- DON'T mutate

TERMINAL OPS (eager, end stream)
  forEach , collect(Collectors..) , count , reduce
  anyMatch/allMatch/noneMatch , findFirst/findAny (Optional)
  min/max(cmp) , toList() [J16+] , toArray , sum/average (primitive)
                
                    RECOMMENDED OP ORDER (perf)
  filter  ->  map  ->  distinct  ->  sorted  ->  limit/skip
  (cut elements early; sort/limit on smallest set)
  rule: cheap+shrinking ops FIRST, expensive (sort) LAST

LAZY EVALUATION
  intermediate ops do NOTHING until a TERMINAL op runs
  then elements flow ONE-AT-A-TIME through whole chain (vertical)
  => short-circuit: limit/findFirst stop early
  doodle: faucet OFF until terminal turns it ON; each drop runs full pipe

TERMINAL = ONE SHOT
  after terminal, stream is CONSUMED / dead
  reuse -> IllegalStateException ("stream has already been operated upon")
  [+] fix: create a fresh stream each time (or use a Supplier<Stream>)
                
                    GOTCHAS (memory hooks)
  - lambda captures vars that are final / effectively final ONLY [+]
  - static iface method NOT inherited (Iface.m only) [+]
  - default method DIAMOND: 2 ifaces same default -> MUST override [+]
  - peek = look, don't touch ; forEach order undefined if parallel [+]
  - findFirst vs findAny: findAny faster in parallel [+]
  - distinct/sorted are STATEFUL (buffer elements) [+]
                

II. Detailed Study Notes #

1. Interface evolution: why default & static methods exist #

Before Java 8 an interface was a pure contract; after Java 8 it can also ship behavior, so you can grow an interface without breaking everyone who implements it.

Originally an interface could only declare public abstract methods — names and signatures, no bodies. Every implementing class had to supply the body. That created a brittle situation: the moment you added a new method to a published interface, every existing class that implemented it stopped compiling until its author added the new method. For a widely-used interface (think List, implemented by hundreds of classes across the world), that is a non-starter.

Java 8 fixed this with two new kinds of interface members that carry their own implementation: static methods and default methods. Because they have bodies, adding one does not force implementers to write anything — old code keeps compiling and running. + The flagship real example: Java 8 added default Stream<E> stream() and default void forEach(...) to the Collection /Iterable interfaces. Every collection in existence instantly gained .stream() without a single library breaking. That is the whole feature in one sentence.

Why it matters This is interface evolution. It is the reason the entire Stream API could be bolted onto pre-existing collections. In interviews, "why were default methods added?" has exactly one correct headline answer: backward-compatible API evolution.

2. Static methods in an interface #

A static interface method belongs to the interface type itself — like a namespaced utility function — and is never inherited by implementers.

A static method in an interface is conceptually identical to a static method on a class: it is tied to the type, not to any object. You call it as InterfaceName.staticMethod(). Its natural job is a helper / factory / utility that logically belongs with the interface but does not need any particular instance's data — for example a factory that builds a default implementation, or a comparator builder.

  • Not inherited: implementing classes do not receive static interface methods. You cannot call them through an instance or through the class that implements the interface — only through the interface name.
  • Not overridable: since they are not inherited, there is nothing to override. A same-named static method in an implementing class is a completely separate, unrelated method.
  • No instance access: like any static context, it cannot read instance variables or call instance methods of implementers.
Pitfall People expect myImpl.staticMethod() or ImplClass.staticMethod() to work — it does not compile. Always go through the interface: Iface.staticMethod().
Interview angle "Can a static interface method be overridden?" → No; static methods are not polymorphic and interface statics are not even inherited.

3. Default methods in an interface #

A default method is an interface method with a body, marked by the default keyword, that implementers inherit for free and may override.

Where a static method stays attached to the interface, a default method is genuinely inherited by every implementing class. If a class says nothing, it gets the default behavior automatically; if it wants different behavior, it overrides the method just like any inherited method. This is what makes safe evolution possible: ship a sensible default body, and existing classes silently pick it up.

Trade-offs Pros: backward-compatible API growth; lets interfaces provide convenience methods (e.g. List.sort, Map.getOrDefault patterns). Cons / limits: default methods cannot hold state (no instance fields in interfaces); overusing them turns interfaces into half-baked abstract classes and can muddy the "interface = pure contract" mental model.
Pitfall — the diamond problem + If a class implements two interfaces that both declare the same default method, the compiler cannot pick a winner and forces you to override it explicitly. Inside the override you can delegate with the special syntax InterfaceName.super.method().
Interview angle Resolution rules when a method exists in multiple places: (1) a concrete class/superclass method always wins over any interface default; (2) the more specific sub-interface's default wins over a parent interface's; (3) otherwise it is ambiguous and you must override. + Mnemonic: class beats interface, child beats parent, else you decide.

4. Functional interface &@FunctionalInterface #

A functional interface has exactly one abstract method (SAM) — that single method is what a lambda implements.

"Functional interface" (FI) is a category, not a keyword: any interface with exactly one abstract method qualifies, regardless of annotation. The single abstract method is often called the SAM (Single Abstract Method). The interface may additionally have any number of static and default methods — those have bodies, so they are not abstract and do not break the "one abstract method" rule. + Methods that merely redeclare public methods of java.lang.Object (like equals, toString ) also do not count toward the limit.

The @FunctionalInterface annotation is optional documentation + a compile-time guard. It does not make an interface functional; it asks the compiler to verify the interface really has exactly one abstract method, and to fail the build with a clear error if someone later adds a second one. Think of it as a unit test baked into the type.

Why it matters FIs are the bridge between Java's object world and functional programming. A lambda or method reference is only legal where the target type is a functional interface — the compiler infers which SAM you are implementing.
Common misconception "You must add @FunctionalInterface to use a lambda." False — the annotation is optional; Runnable, Comparator, etc. work as lambda targets either way. The annotation just protects the interface from accidentally gaining a second abstract method.

5. Lambda expressions #

A lambda is a compact anonymous function — (params) ->body — that supplies the body for a functional interface's single abstract method.

Before lambdas, passing behavior meant writing a verbose anonymous inner class. A lambda is the same idea distilled to parameters, an arrow, and a body. The compiler matches it against the expected functional interface and treats the lambda as an instance of that interface.

Syntax forms#

ShapeExampleMeaning
No params () ->42 Supplier-style, returns 42
One param x ->x * 2 parens optional for single param
Typed params (int x) ->x * 2 explicit type (rarely needed)
Multiple (a, b) ->a + b parens required
Block body x ->{ log(x); return x*2; } braces ⇒ explicit return
Pitfall — variable capture + A lambda may use local variables from the enclosing scope only if they are final or effectively final (assigned once). Trying to reassign a captured local is a compile error. It can freely read/mutate instance and static fields, though. Also note: a lambda does not create a new this scope — this inside a lambda refers to the enclosing object, unlike an anonymous inner class.
Interview angle "Lambda vs anonymous inner class?" Key differences: lambda is only for functional interfaces; lambda's this = enclosing instance (anon class's this = the anon object); lambdas compile to invokedynamic rather than a separate .class file, so they are lighter. +

6. + Method references (lambda shorthand) #

+ When a lambda does nothing but call one existing method, a method reference (:: ) says the same thing more cleanly.

This is not in the core list but it is inseparable from lambdas in practice and on interviews, so it's worth knowing. There are four flavors:

KindSyntaxEquivalent lambda
Static method Integer::parseInt s ->Integer.parseInt(s)
Instance, specific object System.out::println x ->System.out.println(x)
Instance, arbitrary object String::toUpperCase s ->s.toUpperCase()
Constructor ArrayList::new () ->new ArrayList<>()
Why it matters Readability. .map(String::toUpperCase) reads like English. They are verified to be 100% standard and behaviour-equivalent to the lambdas above.

7. The six most-used built-in functional interfaces #

Java ships ready-made functional interfaces in java.util.function so you rarely write your own; learn the shape of each (inputs → output).

InterfaceAbstract methodSignatureOne-liner
Supplier<T> T get() () → TProduces a value, takes nothing
Consumer<T> void accept(T) T → ()Takes a value, returns nothing (side-effect)
BiConsumer<T,U> void accept(T,U) T,U → ()Two inputs, no output (e.g. map put)
Function<T,R> R apply(T) T → RTransforms input to output
BiFunction<T,U,R> R apply(T,U) T,U → RTwo inputs, one output
Predicate<T> boolean test(T) T → booleanTests a condition (yes/no)

+ A few close cousins worth knowing because they appear constantly:

  • UnaryOperator<T> — a Function<T,T> where input and output share a type (e.g. x ->x+1 ).
  • BinaryOperator<T> — a BiFunction<T,T,T>; this is what reduce uses (e.g. (a,b) ->a+b ).
  • Primitive specializations like IntSupplier, ToIntFunction, IntPredicate avoid boxing for performance.

+ Several of these carry useful default combinators:

  • Predicate: .and(), .or(), .negate() to compose conditions.
  • Function: .andThen(g) (this then g) and .compose(g) (g then this).
  • Consumer: .andThen() to chain side-effects.
Recall hook S-C-F-P: Supply (give), Consume (take), Function (transform), Predicate (judge). "Bi" just means two inputs.
Interview angle Be ready to map a use-case to the right interface: random generator → Supplier; logging → Consumer; length-of-string → Function; is-even → Predicate; sum-two → BinaryOperator /BiFunction.

8. Imperative vs declarative #

Imperative code spells out how step by step and manages its own state; declarative code states what you want and lets the library handle the how.

Suppose you want the first five people over 18. The imperative way: write a for loop, an if age check, a result list, a counter, and a break. You own every step and every variable. The declarative way: people.stream().filter(p ->p.age >18).limit(5).collect(toList()). You named the intent — filter, limit, collect — and the Stream API decided how to iterate, where to stop, and how to accumulate.

Analogy Imperative = a recipe with every step ("dice onion, heat oil, sauté 3 min…"). Declarative = ordering at a restaurant ("I'll have the risotto") and trusting the kitchen.
Trade-offs Declarative is shorter, less error-prone, and reads as intent; it also unlocks easy parallelism (.parallelStream() ). Costs: a debugger steps through it less obviously, and naïve heavy chains can be slower than a hand-tuned loop. For hot, simple loops a plain for can still win.

9. Stream API & the pipeline #

A Stream is a sequence of elements from a source that you process through a pipeline of operations — it is a view over data, not a new data structure.

The Stream API lets you filter, transform, and aggregate collection data with readable method calls instead of hand-written loops and conditionals. Two definitions to lock in: a stream is a sequence of elements coming from a source (a collection, array, file, generator…); a pipeline is the chain of operations that runs on that stream to produce a result.

A pipeline always has three parts:

  1. Source — e.g. list.stream().
  2. Zero or more intermediate operations — filter, map, sorted … each returns a new stream, so they chain.
  3. Exactly one terminal operation — collect, forEach, count … it triggers execution and yields the final result.
Analogy — the factory line Think of a conveyor of plastic balls: filter out the red ones, melt each into a triangle, filter out the small triangles, then a reducer adds up all the circumferences. The balls are the source; each station is an operation; the final sum is the terminal result.

+ Key properties that distinguish streams from collections: a stream does not store elements (it pulls them from the source on demand), it does not modify the source, it is traversable once, and it can run sequentially or in parallel. A Collection is about storing data; a Stream is about computing over it.

Common misconception "A stream is like a collection / it holds my data." No — it is a lazy pipeline. Once consumed, it's gone; to reprocess, build a fresh stream from the source.

10. Creating streams #

Streams can come from collections, arrays, explicit values, infinite generators, or numeric ranges.

SourceHow
Collection list.stream() / set.stream()
Array Arrays.stream(arr)
Explicit values Stream.of("a","b","c")
Infinite (function) Stream.iterate(0, n ->n+1) — needs limit
Infinite (supplier) Stream.generate(Math::random) — needs limit
Numeric range + IntStream.range(1,5) → 1,2,3,4; rangeClosed(1,5) → 1..5
Chars of a string + "abc".chars() → IntStream
Pitfall Stream.iterate and Stream.generate are infinite. Without a short-circuiting op like limit(n) a terminal operation will run forever.

11. Intermediate operations #

Intermediate ops are lazy transformations that each return a new stream, so they chain into a pipeline; nothing runs until a terminal op arrives.

Each intermediate operation is "a worker on the conveyor": it takes the stream, does its job, and hands back a new stream for the next worker.

OpDoesNote
filter(pred) keep elements matching a Predicate shrinks the stream
map(fn) transform each element via a Function 1-to-1; + use flatMap to flatten nested streams
sorted() / sorted(cmp) order naturally or by comparator + stateful — buffers all elements
limit(n) keep first n short-circuiting
skip(n) + drop first n complements limit
distinct() remove duplicates uses equals /hashCode; stateful
peek(consumer) run an action per element as it passes does not change elements; for logging/debug only
Pitfall — peek peek is for observation, not transformation. Mutating elements inside peek is a code smell, and because of laziness peek only fires for elements the terminal op actually pulls — so a peek with no terminal prints nothing.

12. Recommended operation order (performance) #

Order ops to shrink the stream as early as possible: filter → map → distinct → sorted → limit/skip.

  1. Filter first — cuts the element count so every later op processes fewer items.
  2. Map after filter — only transform the elements you're keeping.
  3. Distinct after map — dedupe on the transformed values.
  4. Sort after distinct — sorting is expensive (O(n log n) and stateful); do it on the smallest possible set.
  5. Limit / skip last — control output size once everything else is done.
Why it matters The guiding rule: cheap, shrinking ops early; expensive, stateful ops (sort) late. Putting sorted() before filter() sorts elements you're about to throw away — wasted work.
Nuance + Because streams are lazy and short-circuit, the runtime is often smart about this, but writing the pipeline in this order makes the intent and the cost obvious, and guarantees the win.

13. Lazy evaluation #

Intermediate operations do nothing until a terminal operation runs; then elements flow one at a time through the entire chain.

This is the most important mental model in the Stream API. When you write .filter(...).map(...), no filtering or mapping happens yet. You have only described a plan. The work starts the instant a terminal operation (collect, forEach, count …) is invoked. At that point the stream pulls each element through the whole pipeline vertically — element 1 goes through filter, then map, then the terminal; then element 2; and so on — rather than running filter over the entire list, then map over the entire list.

Why it matters — short-circuiting Vertical, on-demand processing means ops like limit, findFirst, and anyMatch can stop early. Stream.iterate(1, n->n+1).filter(odd).limit(3) works on an infinite source because it stops the moment it has 3 results.
Recall hook The faucet is OFF until the terminal op turns it ON. Each drop of water runs the full length of pipe before the next drop is released.

14. Terminal operations & the single-use rule #

A terminal operation consumes the stream, produces the final result, and leaves the stream dead — reusing it throws IllegalStateException.

Terminal operations are what actually return something other than a stream — a list, a number, a boolean, an Optional, or a side-effect. After a terminal op runs, the stream is consumed: you cannot add more operations or call another terminal on the same stream object. Doing so throws IllegalStateException: stream has already been operated upon or closed.

CategoryExamplesReturns
Collect / reduce collect(Collectors.toList()), reduce(0,Integer::sum) aggregate / single value
Iterate forEach, forEachOrdered void (side-effect)
Count / match count, anyMatch, allMatch, noneMatch long / boolean
Find findFirst, findAny Optional<T>
Min / max min(cmp), max(cmp) Optional<T>
To array + toArray(), toList() (Java 16+) array / immutable list
Pitfall & fix + Storing a stream in a variable and using it twice fails. If you need to process the same data repeatedly, recreate the stream from the source each time, or wrap creation in a Supplier <Stream<T>> and call .get() for a fresh stream.
Interview angle findFirst vs findAny +: identical for sequential streams; in a parallel stream findAny can return any matching element (faster, no ordering guarantee) while findFirst must respect encounter order.

III. Code Samples (runnable, standard library only) #

Target: Java 17 (LTS). Single-file programs — compile with javac File.java and run with java File. Java 11+ lets you skip compilation: java File.java.

Interface with static & default methods (↔ §1–3) #

Greeter.javajava Greeter.java
interface Greeter { // abstract: implementers MUST provide this String name(); // default: inherited + overridable, has a body default String greet() { return "Hello, " + name() + "!"; // can call abstract method } // static: belongs to the interface, NOT inherited static Greeter of(String n) { // factory helper return () -> n; // lambda implements name() } } public class GreeterDemo { public static void main(String[] a) { Greeter g = Greeter.of("Abhishek"); // static via Iface name System.out.println(g.greet()); // uses default method // override the default inline Greeter shout = new Greeter() { public String name() { return "World"; } public String greet() { return name().toUpperCase() + "!!"; } }; System.out.println(shout.greet()); } }
$ java GreeterDemo.java
Expected output Hello, Abhishek! WORLD!!
Why it behaves this way Greeter.of(...) only works through the interface name — g.of(...) would not compile, proving static interface methods aren't inherited. The default greet() is inherited automatically and freely overridden in the second instance.

Diamond resolution for default methods (↔ §3) #

Diamond.javajava Diamond.java
interface A { default String hi() { return "A"; } } interface B { default String hi() { return "B"; } } // A and B both define hi() -> compiler forces an override public class Diamond implements A, B { @Override public String hi() { return A.super.hi() + B.super.hi(); // explicit delegation } public static void main(String[] x) { System.out.println(new Diamond().hi()); } }
$ java Diamond.java
Expected output AB
Why Without the override the class would not compile ("inherits unrelated defaults"). A.super.hi() reaches the specific interface's default.

Custom functional interface (↔ §4) #

CalcDemo.javajava CalcDemo.java
@FunctionalInterface // compile-time guard: exactly 1 abstract method interface Calc { int op(int a, int b); // the single abstract method (SAM) default Calc thenNegate() { return (a,b) -> -op(a,b); } // allowed static Calc add() { return (a,b) -> a + b; } // allowed } public class CalcDemo { public static void main(String[] x) { Calc sum = (a,b) -> a + b; // lambda fills the SAM Calc mul = (a,b) -> a * b; System.out.println(sum.op(3,4)); // 7 System.out.println(mul.op(3,4)); // 12 System.out.println(sum.thenNegate().op(3,4)); // -7 } }
$ java CalcDemo.java
Expected output 7 12 -7
Try this to see the guard Add a second abstract method int op2(int a) and the build fails: "Calc is not a functional interface… multiple non-overriding abstract methods." That is @FunctionalInterface doing its job.

Lambda forms (↔ §5) #

Lambdas.javajava Lambdas.java
import java.util.function.*; public class Lambdas { public static void main(String[] x) { Supplier<String>noArg = () -> "hi"; // no params Function<Integer,Integer>one = n -> n * 2; // 1 param, no parens BiFunction<Integer,Integer,Integer>two = (a,b) -> a + b; Function<Integer,Integer>block = n -> { // block body int r = n * n; return r + 1; // braces require return }; int factor = 10; // effectively final -> capturable Function<Integer,Integer>cap = n -> n * factor; // factor = 11; // <-- uncommenting this breaks the capture (compile error) System.out.println(noArg.get()); System.out.println(one.apply(5)); System.out.println(two.apply(3,4)); System.out.println(block.apply(4)); System.out.println(cap.apply(3)); } }
$ java Lambdas.java
Expected output hi 10 7 17 30
Why the capture rule factor must be effectively final because the lambda may outlive the method's stack frame; Java captures a copy of the value, so allowing reassignment would create confusing inconsistency.

Method references (↔ §6) + #

MethodRefs.javajava MethodRefs.java
import java.util.*; import java.util.function.*; public class MethodRefs { public static void main(String[] x) { Function<String,Integer>parse = Integer::parseInt; // static Consumer<String>print = System.out::println; // bound instance Function<String,String>upper = String::toUpperCase; // unbound instance Supplier<List<String>>make = ArrayList::new; // constructor print.accept("value = " + parse.apply("41")); print.accept(upper.apply("stream")); List<String>list = make.get(); list.add("created via ArrayList::new"); print.accept(list.toString()); } }
$ java MethodRefs.java
Expected output value = 41 STREAM [created via ArrayList::new]

All six built-in functional interfaces (↔ §7) #

BuiltIns.javajava BuiltIns.java
import java.util.function.*; import java.util.*; public class BuiltIns { public static void main(String[] x) { Supplier<String>sup = () -> "produced"; // () -> T Consumer<String>con = s -> System.out.println("got: " +s); // T -> () BiConsumer<String,Integer>bicon = (k,v) -> System.out.println(k+"=" +v); Function<String,Integer>fn = s -> s.length(); // T -> R BiFunction<Integer,Integer,Integer>bifn = (a,b) -> a+b; // T,U -> R Predicate<Integer>pred = n -> n >0; // T -> boolean con.accept(sup.get()); bicon.accept("age", 30); System.out.println("len = " + fn.apply("hello")); System.out.println("sum = " + bifn.apply(4,6)); System.out.println("5>0 ? " + pred.test(5)); // [+] default combinators Predicate<Integer>big = n -> n >100; System.out.println("pos AND big (50): " + pred.and(big).test(50)); Function<Integer,Integer>plus1 = n -> n+1, times2 = n -> n*2; System.out.println("andThen (3): " + plus1.andThen(times2).apply(3)); // (3+1)*2 } }
$ java BuiltIns.java
Expected output got: produced age=30 len = 5 sum = 10 5>0 ? true pos AND big (50): false andThen (3): 8

Imperative vs declarative side-by-side (↔ §8) #

FindPeople.javajava FindPeople.java
import java.util.*; import java.util.stream.*; public class FindPeople { record Person(String name, int age) {} // Java 16+ record public static void main(String[] x) { List<Person>people = List.of(new Person("Amy",15), new Person("Ben",22), new Person("Cara",19), new Person("Dan",40), new Person("Eve",17), new Person("Fay",30), new Person("Gus",25)); // ---- IMPERATIVE: you manage list, counter, flow ---- List<String>imp = new ArrayList<>(); int count = 0; for (Person p : people) { if (p.age() >18) { imp.add(p.name()); if (++count == 3) break; } } // ---- DECLARATIVE: state intent, library handles it ---- List<String>dec = people.stream().filter(p -> p.age() >18).limit(3).map(Person::name).collect(Collectors.toList()); System.out.println("imperative: " + imp); System.out.println("declarative: " + dec); } }
$ java FindPeople.java
Expected output imperative: [Ben, Cara, Dan] declarative: [Ben, Cara, Dan]
Why identical results Both keep the first three people older than 18 in encounter order. The declarative version's limit(3) short-circuits exactly like the imperative break — thanks to lazy evaluation it never inspects Fay or Gus.

Full pipeline example (↔ §9) #

Pipeline.javajava Pipeline.java
import java.util.*; import java.util.stream.*; public class Pipeline { public static void main(String[] x) { List<String>words = List.of("apple","banana","avocado","cherry","apricot"); String result = words.stream() // SOURCE .filter(w -> w.startsWith("a")) // intermediate .map(String::toUpperCase) // intermediate .sorted() // intermediate .collect(Collectors.joining(", ")); // TERMINAL System.out.println(result); } }
$ java Pipeline.java
Expected output APPLE, APRICOT, AVOCADO

Stream creation (↔ §10) #

Create.javajava Create.java
import java.util.*; import java.util.stream.*; public class Create { public static void main(String[] x) { Stream.of("a","b","c").forEach(System.out::print); // abc System.out.println(); int[] arr = {1,2,3}; System.out.println(Arrays.stream(arr).sum()); // 6 // infinite source + limit (REQUIRED or it runs forever) List<Integer>firstFive = Stream.iterate(0, n -> n + 2).limit(5).toList(); System.out.println(firstFive); // [0,2,4,6,8] System.out.println(IntStream.rangeClosed(1,5).sum()); // 15 System.out.println("abc".chars().count()); // 3 } }
$ java Create.java
Expected output abc 6 [0, 2, 4, 6, 8] 15 3

Each intermediate op (↔ §11) #

Intermediate.javajava Intermediate.java
import java.util.*; import java.util.stream.*; public class Intermediate { public static void main(String[] x) { List<Integer>n = List.of(5,3,5,8,1,3,9,2); System.out.println("filter even: " + n.stream().filter(i -> i%2 ==0).toList()); System.out.println("map *10: " + n.stream().map(i -> i*10).toList()); System.out.println("sorted: " + n.stream().sorted().toList()); System.out.println("distinct: " + n.stream().distinct().toList()); System.out.println("limit 3: " + n.stream().limit(3).toList()); System.out.println("skip 3: " + n.stream().skip(3).toList()); // peek: side-effect only, does NOT alter elements List<Integer>out = n.stream().filter(i -> i >3).peek(i -> System.out.println(" passed: " + i)).map(i -> i + 100).toList(); System.out.println("after peek: " + out); } }
$ java Intermediate.java
Expected output filter even: [8, 2] map *10: [50, 30, 50, 80, 10, 30, 90, 20] sorted: [1, 2, 3, 3, 5, 5, 8, 9] distinct: [5, 3, 8, 1, 9, 2] limit 3: [5, 3, 5] skip 3: [8, 1, 3, 9, 2] passed: 5 passed: 5 passed: 8 passed: 9 after peek: [105, 105, 108, 109]
Why peek prints interleaved Because of laziness, each element flows fully through filter → peek → map before the next starts — that vertical flow is why the "passed:" lines appear as elements stream past, not all at once up front.

Laziness & short-circuit (↔ §13) #

Lazy.javajava Lazy.java
import java.util.stream.*; public class Lazy { public static void main(String[] x) { // 1) No terminal op -> nothing runs, peek never fires Stream.of(1,2,3).peek(i -> System.out.println("seen " +i)); System.out.println("(nothing printed above this line)"); // 2) Infinite stream made finite by short-circuiting limit String r = Stream.iterate(1, i -> i + 1).peek(i -> System.out.println("checking " +i)).filter(i -> i % 3 == 0).limit(2) // stops after 2 matches .map(String::valueOf).collect(Collectors.joining(",")); System.out.println("result = " + r); } }
$ java Lazy.java
Expected output (nothing printed above this line) checking 1 checking 2 checking 3 checking 4 checking 5 checking 6 result = 3,6
Why it stops at 6 The infinite source is pulled one element at a time. After 3 and 6 satisfy the filter, limit(2) is full and short-circuits — so 7, 8, … are never generated. Part 1 proves intermediate ops without a terminal do literally nothing.

Terminal ops & the reuse error (↔ §14) #

Terminal.javajava Terminal.java
import java.util.*; import java.util.stream.*; import java.util.function.*; public class Terminal { public static void main(String[] x) { List<Integer>n = List.of(4,8,15,16,23,42); System.out.println("count >10: " + n.stream().filter(i -> i >10).count()); System.out.println("sum: " + n.stream().reduce(0, Integer::sum)); System.out.println("anyMatch >40: " + n.stream().anyMatch(i -> i >40)); Optional<Integer>first = n.stream().filter(i -> i >10).findFirst(); System.out.println("findFirst >10: " + first.get()); // THE single-use trap Stream<Integer>s = n.stream(); s.forEach(i -> {}); // 1st terminal: consumes the stream try { s.count(); // 2nd terminal on same stream -> boom } catch (IllegalStateException e) { System.out.println("reuse error: " + e.getMessage()); } // [+] fix: a Supplier hands you a FRESH stream each time Supplier<Stream<Integer>>fresh = n::stream; System.out.println("reuse via supplier: " + fresh.get().count() + " then " + fresh.get().max(Integer::compare).get()); } }
$ java Terminal.java
Expected output count >10: 4 sum: 108 anyMatch >40: true findFirst >10: 15 reuse error: stream has already been operated upon or closed reuse via supplier: 6 then 42

IV. Hands-On Lab (practice) #

Setup (one time)
terminalbash
# 1. Check you have Java 11+ (17 LTS recommended) java -version # expect: openjdk version "17.x" or higher # 2. Make a working folder mkdir java8-lab && cd java8-lab # 3. Single-file run (Java 11+): no separate compile step java Exercise1.java # 3b. Classic two-step (any version): javac Exercise1.java # produces Exercise1.class java Exercise1 # run (no .java extension)

Each exercise is one self-contained .java file with a public class matching the filename. Predict the output before running.

Warm-up · Exercise 1

Default + static interface methods#

Goal: Build an interface Shape with an abstract area(), a default describe(), and a static factory for a unit square.

  1. Declare interface Shape { double area(); }.
  2. Add default String describe() returning "area=" + area().
  3. Add static Shape unitSquare() returning a lambda whose area is 1.
  4. In main, create a circle (radius 2) as a lambda and print both shapes' describe().
Predict, then verify What will Shape.unitSquare().describe() print? Could you call circle.unitSquare() through the instance? (Try it — note the compile error.)
Solution
Exercise1.javajava Exercise1.java
interface Shape { double area(); // abstract SAM default String describe() { return "area=" + area(); } static Shape unitSquare() { return () -> 1.0; } // factory } public class Exercise1 { public static void main(String[] a) { double r = 2; Shape circle = () -> Math.PI * r * r; // captures r (effectively final) System.out.println(circle.describe()); System.out.println(Shape.unitSquare().describe()); // static via Iface name // circle.unitSquare(); // would NOT compile: static not inherited } }
Expected output area=12.566370614359172 area=1.0
Warm-up · Exercise 2

Pick the right built-in functional interface#

Goal: Wire up one each of Supplier, Predicate, Function, and Consumer to process a single number.

  1. Supplier<Integer> that returns 7.
  2. Predicate<Integer> that tests "is odd".
  3. Function<Integer,String> that maps a number to "odd"/"even" using the predicate.
  4. Consumer<String> that prints the label. Chain them in main.
Predict, then verify Before running, write down the four method names you'll call (get /test /apply /accept ). Mixing them up is the classic beginner slip.
Solution
Exercise2.javajava Exercise2.java
import java.util.function.*; public class Exercise2 { public static void main(String[] a) { Supplier<Integer>src = () -> 7; Predicate<Integer>odd = n -> n % 2 != 0; Function<Integer,String>label = n -> odd.test(n) ? "odd": "even"; Consumer<String>out = System.out::println; int v = src.get(); // 7 out.accept("value " + v + " is " + label.apply(v)); } }
Expected output value 7 is odd
Core · Exercise 3

Build a stream pipeline#

Goal: From a list of words, get the distinct lengths of words longer than 3 chars, sorted descending.

  1. Start with List.of("sky","tree","ocean","sun","river","tree").
  2. filter length >3, map to length, distinct, sorted reversed.
  3. collect to a list and print.
Predict, then verify Words >3 chars: tree(4), ocean(5), river(5), tree(4). Distinct lengths: {4,5}. Sorted desc → ? Write your guess, then run.
Solution
Exercise3.javajava Exercise3.java
import java.util.*; import java.util.stream.*; public class Exercise3 { public static void main(String[] a) { List<String>w = List.of("sky","tree","ocean","sun","river","tree"); List<Integer>lengths = w.stream().filter(s -> s.length() >3) // shrink first .map(String::length) // 4,5,5,4 .distinct() // 4,5 .sorted(Comparator.reverseOrder()) // 5,4 .collect(Collectors.toList()); System.out.println(lengths); } }
Expected output [5, 4]
Core · Exercise 4

Prove lazy evaluation & short-circuiting#

Goal: Use peek to show that only the elements needed by findFirst are processed.

  1. Stream 1..1_000_000 via IntStream.rangeClosed boxed.
  2. peek to print each element examined.
  3. filter for the first multiple of 7 that is also >10.
  4. findFirst and print the result.
Predict, then verify If laziness works, how many "examined" lines print — a million, or just a handful? What's the first int >10 divisible by 7?
Solution
Exercise4.javajava Exercise4.java
import java.util.stream.*; public class Exercise4 { public static void main(String[] a) { int result = IntStream.rangeClosed(1, 1_000_000).boxed().peek(i -> System.out.println("examined " + i)).filter(i -> i >10 && i % 7 == 0).findFirst().get(); System.out.println("first = " + result); } }
Expected output examined 1 examined 2 examined 3 examined 4 examined 5 examined 6 examined 7 examined 8 examined 9 examined 10 examined 11 examined 12 examined 13 examined 14 first = 14

Only 14 elements are examined out of a million — findFirst short-circuits the instant the filter matches 14 (first int >10 divisible by 7).

Challenge · Exercise 5

Reproduce the single-use exception, then fix it#

Goal: Trigger IllegalStateException by reusing a stream, catch it, then refactor to a reusable design.

  1. Assign words.stream() to a variable, run count(), then try forEach on the same variable.
  2. Catch the exception and print its message.
  3. Refactor: wrap stream creation in a Supplier <Stream<String>> and run two terminals successfully.
Predict, then verify What exact message does the JVM throw? Why does the Supplier version avoid it? (Hint: each .get() returns a brand-new stream.)
Solution
Exercise5.javajava Exercise5.java
import java.util.*; import java.util.stream.*; import java.util.function.*; public class Exercise5 { public static void main(String[] a) { List<String>words = List.of("a","bb","ccc"); Stream<String>s = words.stream(); System.out.println("count = " + s.count()); // consumes s try { s.forEach(System.out::println); // reuse -> throws } catch (IllegalStateException e) { System.out.println("caught: " + e.getMessage()); } // FIX: a fresh stream per use Supplier<Stream<String>>src = words::stream; System.out.println("len sum = " + src.get().mapToInt(String::length).sum()); System.out.println("longest = " + src.get().max(Comparator.comparingInt(String::length)).get()); } }
Expected output count = 3 caught: stream has already been operated upon or closed len sum = 6 longest = ccc
Challenge · Exercise 6

Imperative → declarative refactor with grouping#

Goal: Convert a loop that groups names by first letter into a one-line declarative pipeline, and confirm both produce the same map.

  1. Names: List.of("Amy","Ann","Bob","Cara","Carl","Bea").
  2. Imperative: loop, build a Map <Character,List<String>> with computeIfAbsent.
  3. Declarative: reproduce it with Collectors.groupingBy.
  4. Print both maps and verify they're equal.
Predict, then verify How many keys will the map have (A/B/C)? Which Collector replaces the entire manual loop in one call?
Solution
Exercise6.javajava Exercise6.java
import java.util.*; import java.util.stream.*; public class Exercise6 { public static void main(String[] a) { List<String>names = List.of("Amy","Ann","Bob","Cara","Carl","Bea"); // imperative Map<Character,List<String>>imp = new TreeMap<>(); for (String n : names) imp.computeIfAbsent(n.charAt(0), k -> new ArrayList<>()).add(n); // declarative — one call replaces the whole loop Map<Character,List<String>>dec = names.stream().collect(Collectors.groupingBy(n -> n.charAt(0), TreeMap::new, Collectors.toList())); System.out.println("imperative: " + imp); System.out.println("declarative: " + dec); System.out.println("equal? " + imp.equals(dec)); } }
Expected output imperative: {A=[Amy, Ann], B=[Bob, Bea], C=[Cara, Carl]} declarative: {A=[Amy, Ann], B=[Bob, Bea], C=[Cara, Carl]} equal? true

groupingBy with a map-factory + downstream collector is the declarative equivalent of computeIfAbsent in a loop. TreeMap::new keeps keys sorted so the two maps match exactly.

Self-check rubric — how to know you've got it
  • Interfaces: I can say, without looking, why default methods exist (backward-compatible evolution) and why Iface.staticMethod() is the only way to call a static interface method.
  • Functional interfaces: I can name the SAM rule and explain what @FunctionalInterface does (and doesn't) do.
  • Lambdas: I can write all five syntax forms and explain the effectively-final capture rule.
  • Built-ins: Given a use-case, I instantly pick Supplier / Consumer / Function / Predicate (+ Bi-/Unary-/Binary- variants) and call the right method (get/accept/apply/test).
  • Streams: I can identify source / intermediate / terminal in any pipeline and order ops for performance (filter→map→distinct→sorted→limit).
  • Laziness: I can predict that intermediate ops don't run without a terminal, and that limit /findFirst short-circuit infinite or huge sources.
  • Single-use: I can reproduce the IllegalStateException on purpose and fix it with a Supplier<Stream>.
  • Stretch: I refactored Exercise 6 from a loop to groupingBy with no peeking at the solution.

★ Key Takeaways #

  1. Default & static interface methods exist for one reason: evolve interfaces without breaking implementers. Default = inherited & overridable; static = interface-only, not inherited.
  2. A functional interface has exactly one abstract method. @FunctionalInterface is an optional compile-time guard, not a requirement.
  3. A lambda is an instance of a functional interface — (params) ->body — and captures only effectively-final locals. Method references are the cleaner shorthand. +
  4. Memorize the six built-ins by shape: Supplier (()→T), Consumer (T→()), BiConsumer (T,U→()), Function (T→R), BiFunction (T,U→R), Predicate (T→boolean).
  5. Declarative beats imperative for readability and parallelism: say what, not how.
  6. A stream is a lazy pipeline, not a data store: source → intermediate ops (lazy, return streams) → one terminal op (eager, ends the stream).
  7. Order ops to shrink early: filter → map → distinct → sorted → limit/skip.
  8. Lazy evaluation enables short-circuiting: nothing runs until the terminal op; limit /findFirst /anyMatch can stop early, even over infinite sources.
  9. Streams are single-use: a second terminal op throws IllegalStateException; recreate the stream (a Supplier<Stream> helps). +

End of notes · Java 8 interfaces, functional interfaces, lambdas & the Stream API. + marks my own additions and corrections.

Choose your route#

Java 8 turns behavior into values. Build a readable refactor in Learn deeply, or rehearse when a lambda clarifies the code and when it hides intent.

Learning goal

Shared core#

A functional interface is a contract with one abstract operation. Lambdas implement that contract; method references make an existing operation readable. The choice is about expressing intent, not removing every loop.

Learn deeply: refactor with evidence#

Refactor a filtering loop into a named predicate, then compare the two versions with a test and keep the clearer one.

Prepare for interviews: explain the choice#

Explain lambda capture, readability, and one case where a loop is the better answer. Handle a follow-up about checked exceptions.

Exit check#

  1. What qualifies as a functional interface?
  2. When does a method reference improve clarity?
  3. Why can a loop be the better choice?

Use lambdas as the vocabulary for a stream pipeline, then inspect where laziness changes execution.

Apply the lambda trade-off to stream execution, a frequent follow-up in Java interviews.

Next: Java Streams, Decoded · Switch paths from this topic