Java · lesson 33 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Java 8 Features
Master Java 8's most impactful features — lambda expressions, functional interfaces, the Streams API, method references, Optional, and the Date/Time API.
By SkillByExample · Updated
Java 8 (2014) added lambdas, the Streams API, Optional and java.time, and changed how everyday
Java is written: transformations, filters and aggregations became short pipelines instead of
loops and anonymous classes. This lesson tours each feature with a runnable example; streams,
Optional and dates each have a full lesson of their own. Examples run on JDK 21 with the source
launcher (java File.java).
Lambda expressions
A lambda is an anonymous function that implements a single-method interface. It replaces the anonymous class you needed before Java 8.
import java.util.*;
public class Lambdas {
public static void main(String[] args) {
Runnable before = new Runnable() {
@Override public void run() { System.out.println("anonymous class"); }
};
Runnable after = () -> System.out.println("lambda");
before.run();
after.run();
List<String> words = new ArrayList<>(List.of("pear", "fig", "banana", "kiwi", "apple"));
words.sort((a, b) -> a.length() - b.length());
System.out.println(words);
words.sort((a, b) -> {
int diff = a.length() - b.length();
return diff != 0 ? diff : a.compareTo(b); // tie-break alphabetically
});
System.out.println(words);
}
}
$ java Lambdas.java
anonymous class
lambda
[fig, pear, kiwi, apple, banana]
[fig, kiwi, pear, apple, banana]
Forms: () -> expr, x -> expr, (x, y) -> expr, and (x, y) -> { statements; return value; }.
pear and kiwi have the same length: the first sort leaves them in their original order
(the list sort is stable), while the second breaks the tie alphabetically.
The built-in functional interfaces
java.util.function provides the common shapes, so you rarely declare your own.
import java.util.*;
import java.util.function.*;
public class Functions {
public static void main(String[] args) {
Function<Integer, Integer> doubleIt = x -> x * 2;
Function<Integer, Integer> addThree = x -> x + 3;
System.out.println(doubleIt.andThen(addThree).apply(5) + " " + doubleIt.compose(addThree).apply(5));
Predicate<String> longWord = s -> s.length() > 5;
Predicate<String> startsWithP = s -> s.startsWith("p");
System.out.println(longWord.and(startsWithP).test("program") + " " + longWord.negate().test("program"));
Consumer<String> print = System.out::println;
print.andThen(s -> System.out.println(s.toUpperCase())).accept("hello");
Supplier<List<String>> newList = ArrayList::new;
BiFunction<String, Integer, String> repeat = String::repeat;
UnaryOperator<String> trim = String::trim;
BinaryOperator<Integer> sum = Integer::sum;
System.out.println(newList.get() + " " + repeat.apply("ab", 3) + " [" + trim.apply(" x ") + "] " + sum.apply(2, 3));
}
}
$ java Functions.java
13 16
true false
hello
HELLO
[] ababab [x] 5
andThen runs left to right (5×2+3 = 13); compose runs the argument first ((5+3)×2 = 16).
Function transforms, Predicate tests, Consumer acts, Supplier produces;
UnaryOperator and BinaryOperator are Function/BiFunction with one type throughout.
Method references
When a lambda only calls an existing method, a method reference names that method instead.
import java.util.*;
import java.util.function.*;
public class MethodRefs {
public static void main(String[] args) {
Function<String, Integer> parse = Integer::parseInt; // static method
Function<String, String> upper = String::toUpperCase; // method of the argument
String prefix = "Hello, ";
Function<String, String> greet = prefix::concat; // method of one object
Function<String, StringBuilder> builder = StringBuilder::new; // constructor
System.out.println(parse.apply("42") + 1);
System.out.println(upper.apply("java") + " " + greet.apply("World"));
System.out.println(builder.apply("abc").reverse());
List.of("banana", "apple", "cherry").stream().map(String::toUpperCase).sorted().forEach(System.out::println);
}
}
$ java MethodRefs.java
43
JAVA Hello, World
cba
APPLE
BANANA
CHERRY
parse.apply("42") + 1 is 43, not “421”: the reference returns an Integer. String::toUpperCase
means “call toUpperCase on whatever string is passed”, while prefix::concat always calls it
on the one string prefix.
Streams: filter, map, reduce
A stream describes a pipeline over a source. Intermediate operations are lazy; nothing runs until a terminal operation asks for a result.
import java.util.*;
import java.util.stream.*;
public class Streams {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> evenSquares = numbers.stream().filter(n -> n % 2 == 0).map(n -> n * n).toList();
System.out.println(evenSquares);
System.out.println("sum " + numbers.stream().reduce(0, Integer::sum)
+ ", max " + numbers.stream().max(Integer::compare).get()
+ ", avg " + numbers.stream().mapToInt(Integer::intValue).average().getAsDouble());
System.out.println("any even? " + numbers.stream().anyMatch(n -> n % 2 == 0)
+ ", first > 5: " + numbers.stream().filter(n -> n > 5).findFirst());
Stream<Integer> once = numbers.stream();
System.out.println(once.count());
System.out.println(once.count());
}
}
$ java Streams.java
[4, 16, 36, 64, 100]
sum 55, max 10, avg 5.5
any even? true, first > 5: Optional[6]
10
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229)
at java.base/java.util.stream.ReferencePipeline.count(ReferencePipeline.java:709)
at Streams.main(Streams.java:18)
The source list never changes. A stream, though, is single-use: the second count() fails. Call
numbers.stream() again for each pipeline. (toList() arrived in Java 16; Java 8 code writes
collect(Collectors.toList()).)
Laziness you can see
import java.util.List;
public class Lazy {
public static void main(String[] args) {
var result = List.of("one", "two", "three", "four", "five").stream()
.filter(s -> { System.out.println("filter " + s); return s.length() > 3; })
.map(s -> { System.out.println("map " + s); return s.toUpperCase(); })
.findFirst();
System.out.println(result);
}
}
$ java Lazy.java
filter one
filter two
filter three
map three
Optional[THREE]
Elements flow through the pipeline one at a time, and findFirst stops it once it has an answer:
four and five are never examined, and map ran exactly once.
flatMap and Collectors
flatMap turns each element into a stream and flattens the results. Collectors gathers a
stream into maps, strings and counts.
import java.util.*;
import java.util.stream.*;
public class Collecting {
public static void main(String[] args) {
List<String> lines = List.of("hello world", "foo bar baz");
List<String> words = lines.stream().flatMap(l -> Arrays.stream(l.split(" "))).toList();
System.out.println(words);
List<String> names = List.of("Alice", "Bob", "Charlie", "Anna", "Brian");
Map<Character, List<String>> byLetter = names.stream().collect(Collectors.groupingBy(s -> s.charAt(0), TreeMap::new, Collectors.toList()));
Map<Character, Long> counts = names.stream().collect(Collectors.groupingBy(s -> s.charAt(0), TreeMap::new, Collectors.counting()));
Map<Boolean, List<String>> longOrShort = names.stream().collect(Collectors.partitioningBy(s -> s.length() > 4));
String joined = names.stream().collect(Collectors.joining(", ", "[", "]"));
Map<String, Integer> lengths = names.stream().collect(Collectors.toMap(s -> s, String::length, (a, b) -> a, LinkedHashMap::new));
System.out.println(byLetter + "\n" + counts + "\n" + longOrShort + "\n" + joined + "\n" + lengths);
}
}
$ java Collecting.java
[hello, world, foo, bar, baz]
{A=[Alice, Anna], B=[Bob, Brian], C=[Charlie]}
{A=2, B=2, C=1}
{false=[Bob, Anna], true=[Alice, Charlie, Brian]}
[Alice, Bob, Charlie, Anna, Brian]
{Alice=5, Bob=3, Charlie=7, Anna=4, Brian=5}
groupingBy takes a key function and, optionally, a map factory and a downstream collector —
TreeMap::new makes the key order predictable. toMap with a merge function and
LinkedHashMap::new keeps the source order; plain toMap throws on a duplicate key. Each result
goes into a typed variable first: passed straight into println, the groupingBy and toMap
calls that take a map factory (TreeMap::new, LinkedHashMap::new) fail to compile, because
println is overloaded and the compiler can’t infer which map type to build. The simpler forms,
such as joining, compile fine inline.
Optional
Optional<T> makes “maybe no value” part of a method’s return type, so the caller must decide
what to do when it’s empty.
import java.util.*;
public class Optionals {
static Optional<String> findEmail(String user) {
return Map.of("alice", "alice@example.com").containsKey(user)
? Optional.of(Map.of("alice", "alice@example.com").get(user))
: Optional.empty();
}
public static void main(String[] args) {
System.out.println(findEmail("alice").map(String::toUpperCase).orElse("none"));
System.out.println(findEmail("bob").map(String::toUpperCase).orElse("none"));
findEmail("alice").ifPresent(e -> System.out.println("send to " + e));
findEmail("bob").ifPresentOrElse(e -> System.out.println("send to " + e), () -> System.out.println("no email for bob"));
System.out.println(Optional.ofNullable(null).isPresent());
System.out.println(findEmail("bob").orElseThrow());
}
}
$ java Optionals.java
ALICE@EXAMPLE.COM
none
send to alice@example.com
no email for bob
false
Exception in thread "main" java.util.NoSuchElementException: No value present
at java.base/java.util.Optional.orElseThrow(Optional.java:377)
at Optionals.main(Optionals.java:16)
map only runs when a value is present, so the empty case flows straight to orElse.
orElseThrow() (or the older get()) on an empty Optional throws — use them only when absence
really is a bug.
The java.time API
Immutable date and time types replaced Date and Calendar. Months count from 1, and every
operation returns a new object.
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
public class Dates {
public static void main(String[] args) {
LocalDate birthday = LocalDate.of(2000, Month.MARCH, 15);
LocalDate on = LocalDate.of(2026, 10, 7);
System.out.println(birthday.getDayOfWeek() + ", leap year: " + birthday.isLeapYear());
System.out.println("days alive: " + ChronoUnit.DAYS.between(birthday, on) + ", age: " + Period.between(birthday, on).getYears());
LocalDateTime meeting = LocalDateTime.of(2026, 12, 24, 16, 30);
System.out.println(meeting.plusHours(9) + " / " + meeting.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
System.out.println(LocalDate.parse("15/06/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy")));
}
}
$ java Dates.java
WEDNESDAY, leap year: true
days alive: 9702, age: 26
2026-12-25T01:30 / 24/12/2026 16:30
2026-06-15
Adding nine hours to 16:30 on 24 December rolls into the next day, and LocalDateTime handles
it. Fixed dates are used here so the output is repeatable; LocalDate.now() gives today.
Project: sales analytics with streams
Grouping, summing and sorting that used to need nested loops and temporary maps.
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));
System.out.printf("Total revenue: $%.2f%n", sales.stream().mapToDouble(Sale::revenue).sum());
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(" %-10s $%.2f%n", e.getKey(), e.getValue()));
sales.stream()
.collect(Collectors.groupingBy(Sale::region, TreeMap::new, Collectors.summingInt(Sale::quantity)))
.forEach((region, qty) -> System.out.println(" " + region + ": " + qty + " units"));
}
}
$ java SalesAnalytics.java
Total revenue: $4745.45
Gadget $1999.20
Widget $1748.25
Doohickey $998.00
North: 330 units
South: 125 units
Each statement reads as a description of the report: group by product, sum revenue, sort
descending. The TreeMap::new factory makes the region order deterministic.
Practice
- From
List.of("apple", "banana", "cherry", "date"), produce the lengths of the words that contain ana, as a list.
Solution
import java.util.List;
public class Lengths {
public static void main(String[] args) {
List<Integer> lengths = List.of("apple", "banana", "cherry", "date").stream()
.filter(w -> w.contains("a"))
.map(String::length)
.toList();
System.out.println(lengths);
}
}
$ java Lengths.java
[5, 6, 4]
cherry is filtered out before map ever sees it.
- Count word frequencies in
"the cat and the hat and the bat"withgroupingByandcounting, sorted by word.
Solution
import java.util.*;
import java.util.stream.*;
public class WordFreq {
public static void main(String[] args) {
Map<String, Long> freq = Arrays.stream("the cat and the hat and the bat".split(" "))
.collect(Collectors.groupingBy(w -> w, TreeMap::new, Collectors.counting()));
System.out.println(freq);
}
}
$ java WordFreq.java
{and=2, bat=1, cat=1, hat=1, the=3}
Arrays.stream turns the array into a stream; the identity key function w -> w groups equal words.
- Write
Optional<Integer> parse(String)that returns empty instead of throwing, and sum only the valid numbers in["1", "x", "3"].
Solution
import java.util.List;
import java.util.Optional;
public class SafeParse {
static Optional<Integer> parse(String s) {
try { return Optional.of(Integer.parseInt(s)); }
catch (NumberFormatException e) { return Optional.empty(); }
}
public static void main(String[] args) {
int sum = List.of("1", "x", "3").stream().map(SafeParse::parse).flatMap(Optional::stream).mapToInt(Integer::intValue).sum();
System.out.println(sum);
}
}
$ java SafeParse.java
4
Optional::stream (Java 9) turns a present value into a one-element stream and an empty one into
nothing, so flatMap drops the failures.
- Use a
Supplier<Stream<String>>to run two terminal operations on “the same” stream without theIllegalStateException.
Solution
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;
public class StreamSupplier {
public static void main(String[] args) {
Supplier<Stream<String>> words = () -> List.of("a", "bb", "ccc").stream();
System.out.println(words.get().count());
System.out.println(words.get().filter(w -> w.length() > 1).toList());
}
}
$ java StreamSupplier.java
3
[bb, ccc]
Each get() creates a fresh stream from the list, so each terminal operation has its own.