Skip to main content
Java intermediate Lesson 33 of 58

Java 8 Features

Master Java 8's most impactful features — lambda expressions, functional interfaces, the Streams API, method references, Optional, and the Date/Time API.

Java 8 (2014) was the most significant Java release in a decade. It introduced functional programming constructs that changed how Java code is written at every level. Instead of verbose anonymous classes and for-loops, you can now express transformations, filters, and aggregations as concise pipelines — code that reads closer to what it does rather than how it does it.

Lambda Expressions

Before Java 8, passing behavior required creating an anonymous class implementing an interface — a lot of boilerplate for a small piece of logic. Lambdas eliminate that noise. A lambda is an anonymous function that can be assigned to any functional interface variable, passed as an argument, or returned from a method.

// Before Java 8 — anonymous class: verbose for simple logic
Runnable r1 = new Runnable() {
    @Override
    public void run() { System.out.println("Running"); }
};

// Java 8 — lambda: same contract, much less noise
Runnable r2 = () -> System.out.println("Running");

// With parameters and a return value
Comparator<String> byLength = (a, b) -> a.length() - b.length();

// Multi-line body uses braces and an explicit return
Comparator<String> byLengthVerbose = (a, b) -> {
    int diff = a.length() - b.length();
    return diff != 0 ? diff : a.compareTo(b); // tie-break alphabetically
};

Lambda syntax summary:

  • () -> expr — no parameters, expression body
  • x -> expr — one parameter (parens optional)
  • (x, y) -> expr — multiple parameters
  • (x, y) -> { stmts; return val; } — block body

Functional Interfaces

A functional interface has exactly one abstract method. Lambdas implement them. Java provides a rich set of built-in functional interfaces in java.util.function so you rarely need to define your own. Understanding these four core types covers the vast majority of use cases.

import java.util.function.*;

// Function<T, R> — takes T, returns R. The workhorse of transformation pipelines.
Function<String, Integer> length = s -> s.length();
System.out.println(length.apply("hello")); // 5

// Functions compose — andThen chains them left-to-right
Function<Integer, Integer> doubleIt  = x -> x * 2;
Function<Integer, Integer> addThree  = x -> x + 3;
Function<Integer, Integer> doubleThenAdd = doubleIt.andThen(addThree);
System.out.println(doubleThenAdd.apply(5)); // 13

// Predicate<T> — takes T, returns boolean. Used for filtering.
Predicate<String> isEmpty  = String::isEmpty;
Predicate<String> notEmpty = isEmpty.negate();
Predicate<String> longWord = s -> s.length() > 5;
Predicate<String> longAndNotEmpty = longWord.and(notEmpty);

System.out.println(longAndNotEmpty.test("hello"));       // false (length 5, not > 5)
System.out.println(longAndNotEmpty.test("programming")); // true

// Consumer<T> — takes T, returns nothing. Used for side effects like printing or saving.
Consumer<String> printer      = System.out::println;
Consumer<String> upperPrinter = s -> System.out.println(s.toUpperCase());
Consumer<String> both = printer.andThen(upperPrinter);
both.accept("hello"); // prints "hello" then "HELLO"

// Supplier<T> — takes nothing, returns T. Used for lazy evaluation and factory methods.
Supplier<List<String>> listFactory = ArrayList::new;
List<String> newList = listFactory.get();

// BiFunction<T, U, R> — two arguments, one return value
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
System.out.println(repeat.apply("ab", 3)); // "ababab"

// UnaryOperator<T> — a Function where input and output are the same type
UnaryOperator<String> upper = String::toUpperCase;

// BinaryOperator<T> — a BiFunction where all three types are the same
BinaryOperator<Integer> sum = Integer::sum;

Method References

Method references are shorthand for lambdas that do nothing but call an existing method. They make code easier to scan because the method name conveys intent without the -> noise. There are four forms, each covering a different calling pattern.

// 1. Instance method on a specific object
String prefix = "Hello, ";
Function<String, String> greet = prefix::concat;

// 2. Instance method on the parameter type (the parameter becomes the receiver)
Function<String, String>  toUpper = String::toUpperCase;   // s -> s.toUpperCase()
Function<String, Integer> toInt   = Integer::parseInt;     // s -> Integer.parseInt(s)

// 3. Static method
Function<Integer, String> toBinary = Integer::toBinaryString;

// 4. Constructor reference
Supplier<ArrayList<String>>  factory   = ArrayList::new;
Function<String, StringBuilder> sbFactory = StringBuilder::new;

// In practice — method refs make stream pipelines read naturally
List<String> words = List.of("banana", "apple", "cherry");
words.stream()
     .map(String::toUpperCase)    // cleaner than s -> s.toUpperCase()
     .sorted()
     .forEach(System.out::println);

Streams API

A stream is a lazy pipeline of operations on a sequence of elements. Streams don’t store data or modify the source — they describe a computation that runs only when a terminal operation is invoked. This lazy evaluation means intermediate operations on large datasets are efficient: only the elements that reach the terminal operation are fully processed.

Source → Intermediate ops (lazy, describe the pipeline) → Terminal op (triggers processing)
import java.util.stream.*;
import java.util.*;

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// filter keeps elements matching a predicate; map transforms each element; collect materialises the result
List<Integer> result = numbers.stream()
    .filter(n -> n % 2 == 0)     // keep even numbers: 2,4,6,8,10
    .map(n -> n * n)              // square each: 4,16,36,64,100
    .collect(Collectors.toList());
System.out.println(result); // [4, 16, 36, 64, 100]

// reduce aggregates all elements to a single value
int sum = numbers.stream().reduce(0, Integer::sum); // 55
Optional<Integer> max = numbers.stream().max(Integer::compareTo); // Optional[10]

// Primitive streams avoid boxing overhead for numeric operations
long count = numbers.stream().filter(n -> n > 5).count();           // 5
int  total = numbers.stream().mapToInt(Integer::intValue).sum();     // 55
OptionalDouble avg = numbers.stream().mapToInt(Integer::intValue).average(); // 5.5

// Short-circuit terminals stop as soon as the answer is known
boolean hasEven = numbers.stream().anyMatch(n -> n % 2 == 0); // true
boolean allPos  = numbers.stream().allMatch(n -> n > 0);       // true
boolean noNeg   = numbers.stream().noneMatch(n -> n < 0);      // true

Optional<Integer> first = numbers.stream().filter(n -> n > 5).findFirst(); // Optional[6]

flatMap

map produces one output element per input element. flatMap is used when each input element maps to zero or more output elements — it flattens the nested structure into a single stream. This is essential for working with nested collections or splitting strings into tokens.

List<List<Integer>> nested = List.of(List.of(1,2), List.of(3,4), List.of(5,6));
List<Integer> flat = nested.stream()
    .flatMap(Collection::stream)  // each inner list becomes a stream, then flattened
    .collect(Collectors.toList());
System.out.println(flat); // [1, 2, 3, 4, 5, 6]

// Split lines into individual words
List<String> lines = List.of("hello world", "foo bar baz");
List<String> words = lines.stream()
    .flatMap(line -> Arrays.stream(line.split(" ")))
    .collect(Collectors.toList());
// [hello, world, foo, bar, baz]

Collectors

Collectors provides terminal operations that accumulate stream elements into collections, maps, strings, or summary statistics. groupingBy and partitioningBy are particularly powerful for data analysis tasks that would otherwise require loops and temporary maps.

import java.util.stream.Collectors;

List<String> names = List.of("Alice", "Bob", "Charlie", "Anna", "Brian");

// groupingBy — partition into groups by a key function
Map<Character, List<String>> byLetter = names.stream()
    .collect(Collectors.groupingBy(s -> s.charAt(0)));
// {A=[Alice, Anna], B=[Bob, Brian], C=[Charlie]}

// Downstream collectors — apply a second collector to each group
Map<Character, Long> countByLetter = names.stream()
    .collect(Collectors.groupingBy(s -> s.charAt(0), Collectors.counting()));

// joining — concatenate into a string with separator, prefix, and suffix
String csv = names.stream().collect(Collectors.joining(", ", "[", "]"));
// "[Alice, Bob, Charlie, Anna, Brian]"

// partitioningBy — split into exactly two groups: true and false
Map<Boolean, List<String>> partition = names.stream()
    .collect(Collectors.partitioningBy(s -> s.length() > 4));
// {false=[Bob, Anna], true=[Alice, Charlie, Brian]}

// toMap — collect into a Map with key and value extractors
Map<String, Integer> nameLengths = names.stream()
    .collect(Collectors.toMap(s -> s, String::length));
// {Alice=5, Bob=3, Charlie=7, Anna=4, Brian=5}

Optional

Optional<T> is a container that explicitly models “a value that may not be present.” Without it, methods that might return nothing return null, and callers must remember to null-check — forgetting causes NullPointerException, the most common Java runtime error. Optional makes the absence of a value visible in the type system, forcing the caller to handle both cases.

import java.util.Optional;

Optional<String> present  = Optional.of("Hello");
Optional<String> empty    = Optional.empty();
Optional<String> nullable = Optional.ofNullable(null); // same as empty()

// Check and get — get() throws NoSuchElementException if empty; prefer the safe alternatives
System.out.println(present.isPresent()); // true
System.out.println(present.get());       // "Hello"

// Safe alternatives to get() — always prefer these in production code
String value  = empty.orElse("default");                             // "default"
String value2 = empty.orElseGet(() -> computeDefault());             // lazy evaluation
String value3 = empty.orElseThrow(() -> new RuntimeException("Missing value")); // fail fast

// Transform the value if present — map returns Optional<U>
Optional<Integer> length = present.map(String::length);     // Optional[5]
Optional<String>  upper  = present.map(String::toUpperCase); // Optional[HELLO]

// flatMap — when the mapper itself returns Optional (avoids Optional<Optional<T>>)
Optional<Optional<String>> nested = Optional.of(present);
Optional<String> flat = nested.flatMap(o -> o);  // Optional[Hello]

// Conditional action — cleaner than isPresent() + get()
present.ifPresent(System.out::println);  // prints "Hello"
empty.ifPresentOrElse(                   // Java 9+: handles both cases
    System.out::println,
    () -> System.out.println("No value")
);

Date and Time API

The old java.util.Date and Calendar classes were notoriously difficult to use: mutable, poorly named, and full of surprising behavior around time zones and month indexing. java.time (JSR-310), introduced in Java 8, replaces them entirely with immutable, thread-safe types that have a clear, consistent API.

import java.time.*;
import java.time.format.*;
import java.time.temporal.ChronoUnit;

// Three main types for different precision and timezone needs
LocalDate today    = LocalDate.now();      // date only: 2024-06-15
LocalTime now      = LocalTime.now();      // time only: 14:30:45.123
LocalDateTime dt   = LocalDateTime.now();  // date + time, no timezone
ZonedDateTime zdt  = ZonedDateTime.now();  // date + time + timezone

// Create specific values — months are 1-based (unlike the old Calendar)
LocalDate birthday = LocalDate.of(2000, Month.MARCH, 15);
LocalTime alarm    = LocalTime.of(7, 30);
LocalDateTime meeting = LocalDateTime.of(2024, 12, 25, 10, 0);

// Arithmetic — all LocalDate/Time objects are immutable; operations return new instances
LocalDate nextWeek  = today.plusWeeks(1);
LocalDate lastMonth = today.minusMonths(1);
LocalDateTime later = dt.plusHours(2).plusMinutes(30);

// Difference between two dates
long days  = ChronoUnit.DAYS.between(birthday, today);
long years = ChronoUnit.YEARS.between(birthday, today);

// Comparison
boolean isBefore = birthday.isBefore(today); // true
boolean isAfter  = today.isAfter(birthday);   // true

// Fields
System.out.println(today.getYear());       // 2024
System.out.println(today.getMonth());      // JUNE
System.out.println(today.getDayOfWeek()); // SATURDAY
System.out.println(today.isLeapYear());   // true/false

// Formatting and parsing
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy");
String formatted = today.format(fmt);                   // "15/06/2024"
LocalDate parsed = LocalDate.parse("15/06/2024", fmt);  // LocalDate

DateTimeFormatter dtFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
System.out.println(dt.format(dtFmt)); // "2024-06-15 14:30"

Projects

Stream-Based Data Processing

This example shows how streams and collectors combine to replace what used to be nested loops and temporary aggregation maps. The pipeline is declarative — it reads as a description of the computation rather than the mechanics of how to perform it.

import java.util.*;
import java.util.stream.*;

public class SalesAnalytics {

    record Sale(String product, String region, int quantity, double price) {
        double revenue() { return quantity * price; }
    }

    public static void main(String[] args) {
        List<Sale> sales = List.of(
            new Sale("Widget",    "North", 100, 9.99),
            new Sale("Gadget",    "South",  50, 24.99),
            new Sale("Widget",    "South",  75, 9.99),
            new Sale("Gadget",    "North",  30, 24.99),
            new Sale("Doohickey", "North", 200, 4.99)
        );

        // Total revenue — mapToDouble avoids boxing Integer to Double
        double total = sales.stream().mapToDouble(Sale::revenue).sum();
        System.out.printf("Total revenue: $%.2f%n", total);

        // Revenue by product — groupingBy + summingDouble in one pass
        System.out.println("\nRevenue by product:");
        sales.stream()
             .collect(Collectors.groupingBy(Sale::product,
                      Collectors.summingDouble(Sale::revenue)))
             .entrySet().stream()
             .sorted(Map.Entry.<String,Double>comparingByValue().reversed())
             .forEach(e -> System.out.printf("  %-12s $%.2f%n", e.getKey(), e.getValue()));

        // Top selling product by quantity
        sales.stream()
             .collect(Collectors.groupingBy(Sale::product, Collectors.summingInt(Sale::quantity)))
             .entrySet().stream()
             .max(Map.Entry.comparingByValue())
             .ifPresent(e -> System.out.println("\nTop product: " + e.getKey() + " (" + e.getValue() + " units)"));
    }
}

Frequently Asked Questions

What is a lambda expression?
A lambda is an anonymous function — a concise way to pass behavior as data. Instead of creating a class that implements a single-method interface, you write (params) -> body. Lambdas only work where a functional interface (an interface with exactly one abstract method) is expected.
What is the difference between map() and flatMap() in streams?
map() transforms each element one-to-one — it wraps the result in the stream. flatMap() is used when the transformation itself returns a stream or collection — it flattens the nested streams into one. Example: if each line contains multiple words, lines.stream().flatMap(line -> Arrays.stream(line.split(' '))) gives a stream of all words.
Is Optional a replacement for null checks?
It's a better design tool, not a wholesale replacement. Use Optional as a return type when a method might return 'no value' — it forces the caller to handle the empty case explicitly. Don't use Optional as a field type or method parameter — that's overkill and adds overhead without benefit.