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.
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.
The basic syntax is:
(parameters) -> expression
or
(parameters) -> { statements; }
() -> System.out.println("Hello World");
x -> x * 2
(x, y) -> x + y
(x, y) -> {
int sum = x + y;
return sum;
}
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.
@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
.
Lambdas capture variables from their enclosing scope similarly to anonymous classes, but with some differences:
this
keyword inside a lambda refers to the enclosing class instance, not the lambda itself.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();
}
}
Before Java 8, anonymous inner classes were used to pass behavior. Lambdas simplify this pattern:
Runnable r1 = new Runnable() {
@Override
public void run() {
System.out.println("Running with anonymous class");
}
};
r1.run();
Runnable r2 = () -> System.out.println("Running with lambda");
r2.run();
Benefits of Lambdas:
button.addActionListener(e -> System.out.println("Button clicked!"));
This replaces verbose anonymous inner classes in GUI programming.
new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("Thread count: " + i);
}
}).start();
@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
}
}
this
behaves differently.Mastering lambda expressions opens the door to modern, expressive Java programming, making your code cleaner and more maintainable.
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.
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.
These operations transform a stream into another stream and are lazy, meaning they don’t execute until a terminal operation is invoked. Examples include:
filter()
— selects elements based on a conditionmap()
— transforms elementssorted()
— sorts elementsdistinct()
— removes duplicatesBecause they return a stream, intermediate operations can be chained together.
These produce a result or side-effect and trigger the execution of the entire pipeline. Examples include:
collect()
— gathers elements into a collection or other containerforEach()
— performs an action for each elementreduce()
— combines elements into a single valuecount()
— counts elementsOnce a terminal operation runs, the stream pipeline processes all intermediate steps.
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.
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 + ")";
}
}
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]
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)
Calculate the average age:
OptionalDouble avgAge = people.stream()
.mapToInt(Person::getAge)
.average();
avgAge.ifPresent(avg -> System.out.println("Average age: " + avg));
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));
}
}
Streams offer several advantages over traditional loops and collection manipulation:
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.
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
.
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.
There are three main types:
ClassName::staticMethod
instance::instanceMethod
ClassName::instanceMethod
ClassName::new
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.
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.
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.
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<>();
In Java, null
can cause frustrating NullPointerException
s. 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.
Optional.of(value)
— creates an Optional containing a non-null value (throws if value is null).Optional.ofNullable(value)
— creates an Optional that may hold null safely.Optional.empty()
— creates an empty Optional (no value).Optional<String> optionalName = Optional.of("Alice");
optionalName.ifPresent(name -> System.out.println("Name is " + name));
// Prints: Name is Alice
orElse()
Optional<String> emptyOptional = Optional.empty();
String name = emptyOptional.orElse("Default Name");
System.out.println(name); // Prints: Default Name
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.
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! ";
}
}
of()
, empty()
, ifPresent()
, and orElse()
to deal with presence or absence of data.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.
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.
java.time
PackageThe API provides a variety of classes tailored for different use cases. Here are the most commonly used ones:
LocalDate
– Represents a date (year, month, day) without time or timezone, e.g., 2025-06-21
.LocalTime
– Represents a time of day (hour, minute, second, nanosecond) without date or timezone.LocalDateTime
– Combines date and time without timezone.ZonedDateTime
– A date and time with timezone information.Duration
– Represents a time-based amount of time (seconds, nanoseconds), useful for measuring intervals.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
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);
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);
Duration
for Time IntervalsDuration
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
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());
}
}
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.
java.time
) replaces the older, error-prone Date
and Calendar
.LocalDate
, LocalTime
, LocalDateTime
, ZonedDateTime
, and Duration
.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.