Index

Java 8 and Beyond: Modern Features

Java for Beginners

12.1 Lambda Expressions in Depth

Java 8 introduced lambda expressions — a powerful feature that brings functional programming capabilities to Java. Lambdas enable you to write more concise, readable, and flexible code, especially when working with functional interfaces. In this section, we’ll explore lambda syntax, target types, scoping rules, and compare lambdas with anonymous inner classes through practical examples.

What Is a Lambda Expression?

A lambda expression is essentially an anonymous function — a block of code you can pass around as an object. Unlike traditional methods, lambdas don’t have names and can be used wherever a functional interface is expected.

Lambda Syntax Variations

The basic syntax is:

(parameters) -> expression

or

(parameters) -> { statements; }

Examples:

() -> System.out.println("Hello World");
x -> x * 2
(x, y) -> x + y
(x, y) -> {
    int sum = x + y;
    return sum;
}

Target Types and Functional Interfaces

A lambda expression must match the target type — usually a functional interface (an interface with exactly one abstract method). The lambda’s parameters and return type must be compatible with that method.

Example of a functional interface:

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);
}

You can assign a lambda to a Calculator:

Calculator add = (a, b) -> a + b;
System.out.println(add.calculate(5, 3)); // Outputs 8

Java 8 provides many built-in functional interfaces like Runnable, Callable, Comparator, Consumer, Supplier, and Function.

Scope Rules in Lambdas

Lambdas capture variables from their enclosing scope similarly to anonymous classes, but with some differences:

Example:

public class LambdaScope {
    private int value = 10;

    public void demonstrate() {
        int localVar = 20;
        Runnable r = () -> {
            System.out.println("value = " + value);
            System.out.println("localVar = " + localVar);
            System.out.println("this.value = " + this.value);
        };
        r.run();
    }
}

Comparing Lambdas with Anonymous Inner Classes

Before Java 8, anonymous inner classes were used to pass behavior. Lambdas simplify this pattern:

Anonymous Inner Class Example:

Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("Running with anonymous class");
    }
};
r1.run();

Equivalent Lambda:

Runnable r2 = () -> System.out.println("Running with lambda");
r2.run();

Benefits of Lambdas:

Practical Examples

Example 1: Event Listener

button.addActionListener(e -> System.out.println("Button clicked!"));

This replaces verbose anonymous inner classes in GUI programming.

Example 2: Runnable Task

new Thread(() -> {
    for (int i = 0; i < 5; i++) {
        System.out.println("Thread count: " + i);
    }
}).start();

Example 3: Functional Interface Implementation

@FunctionalInterface
interface StringChecker {
    boolean check(String s);
}

public class LambdaExample {
    public static void main(String[] args) {
        StringChecker isEmpty = s -> s.isEmpty();
        StringChecker startsWithA = s -> s.startsWith("A");

        System.out.println(isEmpty.check(""));     // true
        System.out.println(startsWithA.check("Apple")); // true
    }
}

Summary

Mastering lambda expressions opens the door to modern, expressive Java programming, making your code cleaner and more maintainable.

Index

12.2 Stream API Basics and Pipelines

Java 8 introduced the Stream API, a powerful tool for processing sequences of elements in a functional style. Streams make it easy to write concise, readable, and expressive code for manipulating collections and other data sources. In this section, we will explore how streams work, the distinction between intermediate and terminal operations, lazy evaluation, and how to build pipelines for efficient data processing.

What Is a Stream?

A stream represents a sequence of elements supporting various operations to perform computations in a declarative way. Unlike collections, streams don’t store data—they convey data from a source (like a List) through a pipeline of operations.

Streams allow operations like filtering, mapping, sorting, and reducing, typically expressed as a chain of method calls.

Intermediate vs Terminal Operations

Intermediate Operations

These operations transform a stream into another stream and are lazy, meaning they don’t execute until a terminal operation is invoked. Examples include:

Because they return a stream, intermediate operations can be chained together.

Terminal Operations

These produce a result or side-effect and trigger the execution of the entire pipeline. Examples include:

Once a terminal operation runs, the stream pipeline processes all intermediate steps.

Lazy Evaluation and Pipelines

Streams use lazy evaluation—intermediate operations are not executed until a terminal operation is called. This allows optimization, such as short-circuiting (e.g., stopping processing early when possible).

A pipeline is a sequence of stream operations: a data source, followed by zero or more intermediate operations, and terminated by a terminal operation.

Practical Examples

Consider a list of Person objects:

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

class Person {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }

    @Override
    public String toString() {
        return name + " (" + age + ")";
    }
}

Example 1: Filtering and Collecting

Find all people older than 18 and collect their names:

List<Person> people = Arrays.asList(
    new Person("Alice", 23),
    new Person("Bob", 17),
    new Person("Charlie", 19)
);

List<String> adults = people.stream()
    .filter(p -> p.getAge() > 18)      // Intermediate operation
    .map(Person::getName)               // Intermediate operation
    .collect(Collectors.toList());     // Terminal operation

System.out.println(adults);  // Output: [Alice, Charlie]

Example 2: Sorting and Printing

Sort people by age and print each:

people.stream()
    .sorted(Comparator.comparingInt(Person::getAge))  // Intermediate
    .forEach(System.out::println);                     // Terminal

Output:

Bob (17)
Charlie (19)
Alice (23)

Example 3: Combining Operations with Reduction

Calculate the average age:

OptionalDouble avgAge = people.stream()
    .mapToInt(Person::getAge)
    .average();

avgAge.ifPresent(avg -> System.out.println("Average age: " + avg));
Click to view full runnable Code

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

public class Main {
    static class Person {
        String name;
        int age;

        Person(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() { return name; }
        public int getAge() { return age; }

        @Override
        public String toString() {
            return name + " (" + age + ")";
        }
    }

    public static void main(String[] args) {
        List<Person> people = Arrays.asList(
            new Person("Alice", 23),
            new Person("Bob", 17),
            new Person("Charlie", 19)
        );

        // Example 1: Filtering and collecting names of adults
        List<String> adults = people.stream()
            .filter(p -> p.getAge() > 18)
            .map(Person::getName)
            .collect(Collectors.toList());
        System.out.println("Adults: " + adults);

        // Example 2: Sorting by age and printing each person
        System.out.println("\nSorted by age:");
        people.stream()
            .sorted(Comparator.comparingInt(Person::getAge))
            .forEach(System.out::println);

        // Example 3: Average age
        OptionalDouble avgAge = people.stream()
            .mapToInt(Person::getAge)
            .average();
        avgAge.ifPresent(avg -> System.out.println("\nAverage age: " + avg));
    }
}

Why Use Streams?

Streams offer several advantages over traditional loops and collection manipulation:

Summary

Try experimenting by modifying filters, sorting criteria, or collecting into different data structures like sets or maps. Streams provide a modern approach to data processing that’s both powerful and intuitive.

Index

12.3 Method References and Optional

Java 8 introduced many modern features to write more concise, readable, and safer code. Two such features are method references and the Optional class. Method references provide a shorthand way to write lambdas when you just want to call an existing method. Optional helps safely handle potentially null values, reducing the risk of NullPointerException.

Method References: A Shortcut for Lambdas

Lambda expressions are great for passing behavior, but sometimes they just call an existing method without adding any extra logic. Method references simplify this by letting you refer to methods directly.

Syntax Variants of Method References

There are three main types:

Example 1: Static Method Reference

Suppose you want to print elements of a list:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// Using lambda
names.forEach(name -> System.out.println(name));

// Using method reference
names.forEach(System.out::println);

Here, System.out::println is a reference to the static method println of the PrintStream instance.

Example 2: Instance Method Reference (Particular Object)

If you have an instance of a class and want to refer to its method:

class Greeter {
    public void greet(String name) {
        System.out.println("Hello, " + name);
    }
}

Greeter greeter = new Greeter();

List<String> names = Arrays.asList("Alice", "Bob");
names.forEach(greeter::greet);

Here, greeter::greet calls the greet method on the specific greeter object for each name.

Example 3: Instance Method Reference (Arbitrary Object)

This form is used when you want to call an instance method on each element of the stream or collection:

List<String> words = Arrays.asList("apple", "banana", "cherry");

words.stream()
    .map(String::toUpperCase)  // Calls toUpperCase() on each string
    .forEach(System.out::println);

Here, String::toUpperCase means for each String object, invoke its toUpperCase method.

Example 4: Constructor Reference

Constructor references create new objects:

Supplier<List<String>> listSupplier = ArrayList::new;
List<String> newList = listSupplier.get();

This is equivalent to:

Supplier<List<String>> listSupplier = () -> new ArrayList<>();

Optional: Handling Nullable Values Safely

In Java, null can cause frustrating NullPointerExceptions. The Optional<T> class is a container that may or may not hold a non-null value. It encourages explicit handling of the presence or absence of values.

Creating Optionals

Example 1: Creating and Using Optional

Optional<String> optionalName = Optional.of("Alice");
optionalName.ifPresent(name -> System.out.println("Name is " + name));  
// Prints: Name is Alice

Example 2: Avoiding Null Checks with orElse()

Optional<String> emptyOptional = Optional.empty();

String name = emptyOptional.orElse("Default Name");
System.out.println(name);  // Prints: Default Name

Example 3: Chaining Optional Operations

Optional<String> optional = Optional.ofNullable(getUserInput());

optional
    .map(String::trim)         // Transform the value if present
    .filter(s -> !s.isEmpty()) // Filter out empty strings
    .ifPresent(System.out::println); // Print if a valid non-empty string

This avoids manual null and empty checks by chaining methods.

Click to view full runnable Code

import java.util.*;
import java.util.function.Supplier;
import java.util.stream.*;
import java.util.Optional;

public class Main {

    // Example 2: Greeter class for method reference
    static class Greeter {
        public void greet(String name) {
            System.out.println("Hello, " + name);
        }
    }

    public static void main(String[] args) {
        // Example 1: Static method reference
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        System.out.println("Example 1: Static method reference");
        names.forEach(System.out::println);

        // Example 2: Instance method reference (particular object)
        System.out.println("\nExample 2: Instance method reference (particular object)");
        Greeter greeter = new Greeter();
        names.forEach(greeter::greet);

        // Example 3: Instance method reference (arbitrary object)
        System.out.println("\nExample 3: Instance method reference (arbitrary object)");
        List<String> words = Arrays.asList("apple", "banana", "cherry");
        words.stream()
             .map(String::toUpperCase)
             .forEach(System.out::println);

        // Example 4: Constructor reference
        System.out.println("\nExample 4: Constructor reference");
        Supplier<List<String>> listSupplier = ArrayList::new;
        List<String> newList = listSupplier.get();
        newList.add("New");
        newList.add("List");
        newList.forEach(System.out::println);

        // Optional examples
        System.out.println("\nOptional example 1: Creating and using Optional");
        Optional<String> optionalName = Optional.of("Alice");
        optionalName.ifPresent(name -> System.out.println("Name is " + name));

        System.out.println("\nOptional example 2: Avoiding null checks with orElse()");
        Optional<String> emptyOptional = Optional.empty();
        String name = emptyOptional.orElse("Default Name");
        System.out.println(name);

        System.out.println("\nOptional example 3: Chaining operations");
        Optional<String> optionalInput = Optional.ofNullable(getUserInput());
        optionalInput
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .ifPresent(System.out::println);
    }

    // Dummy input simulation
    private static String getUserInput() {
        return "   Hello Optional!   ";
    }
}

Benefits of Method References and Optional

Summary

Try replacing some of your existing lambdas with method references for cleaner code, and experiment with Optional to handle potential nulls gracefully in your projects.

Index

12.4 Date and Time API (java.time)

Before Java 8, handling dates and times in Java was mainly done using the java.util.Date and java.util.Calendar classes. These classes were often confusing, mutable, and not thread-safe, leading to bugs and complicated code. Java 8 introduced a completely new, modern Date and Time API under the package java.time, designed to overcome these issues with a clean, fluent, and immutable approach inspired by the popular Joda-Time library.

Core Classes in the java.time Package

The API provides a variety of classes tailored for different use cases. Here are the most commonly used ones:

Creating Dates and Times

You can create instances using factory methods:

LocalDate today = LocalDate.now();  // Current date
LocalDate specificDate = LocalDate.of(2025, 6, 21); // June 21, 2025

LocalTime currentTime = LocalTime.now();
LocalTime specificTime = LocalTime.of(14, 30, 0); // 2:30 PM

LocalDateTime now = LocalDateTime.now();
LocalDateTime birthday = LocalDateTime.of(1990, 12, 15, 10, 0);

ZonedDateTime zonedNow = ZonedDateTime.now(); // Current date/time with timezone

Parsing and Formatting Dates and Times

You can easily parse date/time strings and format them back to strings using DateTimeFormatter.

// Parsing a date string to LocalDate
String dateStr = "2025-06-21";
LocalDate date = LocalDate.parse(dateStr);

// Formatting LocalDate to a string
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd MMM yyyy");
String formattedDate = date.format(formatter);  // "21 Jun 2025"
System.out.println(formattedDate);

Manipulating Dates and Times

The API makes it simple to add or subtract time units:

LocalDate tomorrow = today.plusDays(1);
LocalDate lastWeek = today.minusWeeks(1);

LocalTime oneHourLater = currentTime.plusHours(1);
LocalDateTime nextMonth = now.plusMonths(1);

System.out.println("Tomorrow: " + tomorrow);
System.out.println("One hour later: " + oneHourLater);
System.out.println("Next month: " + nextMonth);

Using Duration for Time Intervals

Duration measures time between two points or represents fixed time spans:

LocalTime start = LocalTime.of(9, 0);
LocalTime end = LocalTime.of(17, 30);

Duration workDuration = Duration.between(start, end);
System.out.println("Work duration in hours: " + workDuration.toHours());  // 8 hours
Click to view full runnable Code

import java.time.*;
import java.time.format.DateTimeFormatter;

public class Main {
    public static void main(String[] args) {
        // Creating Dates and Times
        LocalDate today = LocalDate.now();
        LocalDate specificDate = LocalDate.of(2025, 6, 21);

        LocalTime currentTime = LocalTime.now();
        LocalTime specificTime = LocalTime.of(14, 30, 0);

        LocalDateTime now = LocalDateTime.now();
        LocalDateTime birthday = LocalDateTime.of(1990, 12, 15, 10, 0);

        ZonedDateTime zonedNow = ZonedDateTime.now();

        System.out.println("Today: " + today);
        System.out.println("Specific date: " + specificDate);
        System.out.println("Current time: " + currentTime);
        System.out.println("Specific time: " + specificTime);
        System.out.println("Now: " + now);
        System.out.println("Birthday: " + birthday);
        System.out.println("Zoned now: " + zonedNow);

        System.out.println("\n--- Parsing and Formatting ---");
        String dateStr = "2025-06-21";
        LocalDate parsedDate = LocalDate.parse(dateStr);
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd MMM yyyy");
        String formattedDate = parsedDate.format(formatter);
        System.out.println("Formatted: " + formattedDate);

        System.out.println("\n--- Manipulating Dates and Times ---");
        LocalDate tomorrow = today.plusDays(1);
        LocalDate lastWeek = today.minusWeeks(1);
        LocalTime oneHourLater = currentTime.plusHours(1);
        LocalDateTime nextMonth = now.plusMonths(1);
        System.out.println("Tomorrow: " + tomorrow);
        System.out.println("Last week: " + lastWeek);
        System.out.println("One hour later: " + oneHourLater);
        System.out.println("Next month: " + nextMonth);

        System.out.println("\n--- Duration Example ---");
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(17, 30);
        Duration workDuration = Duration.between(start, end);
        System.out.println("Work duration in hours: " + workDuration.toHours());
        System.out.println("In minutes: " + workDuration.toMinutes());
    }
}

Thread Safety and Immutability

All classes in the java.time API are immutable—once created, their values cannot change. Instead, any manipulation returns a new instance. This immutability makes them inherently thread-safe, meaning you can share date/time objects across threads without synchronization concerns.

This design dramatically improves reliability and simplifies multithreaded applications compared to the old Date and Calendar classes.

Summary

Explore these classes and try formatting, parsing, and calculating dates and times in your programs to master Java’s modern approach to date/time handling.

Index