Java 8 Features Tutorial
Learn Java 8 from the fundamentals with practical examples, modern coding patterns, Stream API operations, functional interfaces, Optional, and the java.time API. This tutorial is designed for beginners who want to understand not only the syntax of Java 8 features, but also when and why to use them in real applications.
Introduction
Java 8, released in March 2014, was one of the most important releases in Java's evolution. It introduced several language and library features that made everyday Java programming more concise, expressive, and functional.
The most important Java 8 features include:
- Lambda expressions
- Functional interfaces
- Stream API
- Method references
Optional- The modern Date and Time API
- Default and static methods in interfaces
Before Java 8, Java code often required anonymous classes and explicit loops for operations such as sorting, filtering, and transforming collections. Java 8 introduced functional programming capabilities that allow many of these operations to be expressed more clearly.
Java 8 concepts are still important because they form the foundation for much of modern Java code, including Spring Boot applications, REST APIs, enterprise systems, and backend services.
In this tutorial, you will learn each major feature through practical examples and gradually combine them into a small employee-processing project.
What You Will Learn
- Why Java 8 was important
- Lambda expressions
- Functional interfaces
- Predicate, Function, Consumer, and Supplier
- Stream API
- Intermediate and terminal stream operations
- Method references
Optional- Date and Time API
- Default and static interface methods
- Stream pipelines
- Best practices
- A practical employee project
- Practice exercises
- Java 8 learning roadmap
Why Java 8 Matters
Consider sorting a list before Java 8.
Before Java 8
1import java.util.ArrayList; 2import java.util.Arrays; 3import java.util.Collections; 4import java.util.Comparator; 5import java.util.List; 6 7public class SortingExample { 8 public static void main(String[] args) { 9 List<String> names = new ArrayList<>( 10 Arrays.asList("Rahul", "Ankit", "Priya") 11 ); 12 13 Collections.sort(names, new Comparator<String>() { 14 @Override 15 public int compare(String first, String second) { 16 return first.compareTo(second); 17 } 18 }); 19 20 System.out.println(names); 21 } 22}
The code works, but the anonymous Comparator adds boilerplate.
Java 8 Approach
1import java.util.ArrayList; 2import java.util.Arrays; 3import java.util.List; 4 5public class SortingExample { 6 public static void main(String[] args) { 7 List<String> names = new ArrayList<>( 8 Arrays.asList("Rahul", "Ankit", "Priya") 9 ); 10 11 names.sort((first, second) -> first.compareTo(second)); 12 13 System.out.println(names); 14 } 15}
Output:
1[Ankit, Priya, Rahul]
Java 8 also allows an even shorter method-reference form:
1names.sort(String::compareTo);
The important lesson is not simply "write fewer lines." The goal is to express the operation clearly while reducing unnecessary implementation detail.
Lambda Expressions
A lambda expression is an anonymous function that can be used where a functional interface is expected.
Basic Syntax
1(parameters) -> expression
For multiple statements:
1(parameters) -> { 2 statements; 3}
A lambda does not have a standalone type by itself. Its target type comes from a functional interface.
Simple Lambda Example
1public class LambdaExample { 2 public static void main(String[] args) { 3 Runnable task = () -> System.out.println("Hello Java 8"); 4 5 task.run(); 6 } 7}
Output:
1Hello Java 8
Runnable has one abstract method, run(), so the lambda provides its implementation.
Lambda with Parameters
1@FunctionalInterface 2interface Calculator { 3 int calculate(int first, int second); 4} 5 6public class CalculatorExample { 7 public static void main(String[] args) { 8 Calculator addition = (first, second) -> first + second; 9 Calculator multiplication = (first, second) -> first * second; 10 11 System.out.println(addition.calculate(10, 20)); 12 System.out.println(multiplication.calculate(10, 20)); 13 } 14}
Output:
130 2200
Lambda with a Block Body
1@FunctionalInterface 2interface NumberProcessor { 3 int process(int number); 4} 5 6public class LambdaBlockExample { 7 public static void main(String[] args) { 8 NumberProcessor square = number -> { 9 int result = number * number; 10 return result; 11 }; 12 13 System.out.println(square.process(5)); 14 } 15}
Output:
125
For a single expression, prefer the concise form:
1number -> number * number
Use a block body when multiple statements are genuinely needed.
Functional Interfaces
A functional interface contains exactly one abstract method.
The @FunctionalInterface annotation is not required, but it is recommended for custom functional interfaces because the compiler can verify that the interface remains functional.
Custom Functional Interface
1@FunctionalInterface 2interface Greeting { 3 void sayHello(String name); 4} 5 6public class GreetingExample { 7 public static void main(String[] args) { 8 Greeting greeting = 9 name -> System.out.println("Welcome, " + name); 10 11 greeting.sayHello("Ankit"); 12 } 13}
Output:
1Welcome, Ankit
Important Functional Interfaces
Java provides many useful functional interfaces in java.util.function.
| Interface | Input | Output | Typical use |
|---|---|---|---|
Predicate<T> | T | boolean | Testing a condition |
Function<T, R> | T | R | Transforming a value |
Consumer<T> | T | void | Performing an action |
Supplier<T> | None | T | Supplying a value |
UnaryOperator<T> | T | T | Transforming same-type values |
BinaryOperator<T> | T, T | T | Combining two same-type values |
Understanding these interfaces makes Stream API code much easier to read.
Predicate
Predicate<T> represents a condition that returns either true or false.
Example
1import java.util.function.Predicate; 2 3public class PredicateExample { 4 public static void main(String[] args) { 5 Predicate<Integer> isEven = number -> number % 2 == 0; 6 7 System.out.println(isEven.test(10)); 8 System.out.println(isEven.test(7)); 9 } 10}
Output:
1true 2false
Predicates are frequently used with filter().
1Predicate<Integer> greaterThanTen = number -> number > 10;
Function
Function<T, R> accepts one value and returns another value.
Example
1import java.util.function.Function; 2 3public class FunctionExample { 4 public static void main(String[] args) { 5 Function<String, Integer> lengthFunction = 6 String::length; 7 8 System.out.println(lengthFunction.apply("Java")); 9 } 10}
Output:
14
The type transformation here is:
1String -> Integer
Functions are commonly used with Stream API's map() operation.
Consumer
Consumer<T> accepts a value and performs an action without returning a result.
Example
1import java.util.function.Consumer; 2 3public class ConsumerExample { 4 public static void main(String[] args) { 5 Consumer<String> printer = 6 value -> System.out.println("Value: " + value); 7 8 printer.accept("Java"); 9 } 10}
Output:
1Value: Java
Consumers are commonly used with forEach().
Supplier
Supplier<T> takes no input and produces a value when get() is called.
Example
1import java.time.LocalDate; 2import java.util.function.Supplier; 3 4public class SupplierExample { 5 public static void main(String[] args) { 6 Supplier<LocalDate> todaySupplier = LocalDate::now; 7 8 System.out.println(todaySupplier.get()); 9 } 10}
The output depends on the current date.
Suppliers are useful when a value should be generated lazily or provided on demand.
Stream API
The Stream API provides a declarative way to process sequences of data.
A stream is not a data structure and does not store elements itself. Instead, it describes a sequence of computations over a source such as a collection or array.
A stream pipeline normally contains:
- A source
- Zero or more intermediate operations
- A terminal operation
Basic Stream Example
Because this tutorial targets Java 8, use Arrays.asList() instead of List.of(), which was introduced later.
1import java.util.Arrays; 2import java.util.List; 3 4public class StreamExample { 5 public static void main(String[] args) { 6 List<String> names = Arrays.asList( 7 "Ankit", 8 "Rahul", 9 "Priya" 10 ); 11 12 names.stream() 13 .forEach(System.out::println); 14 } 15}
Output:
1Ankit 2Rahul 3Priya
Important Stream Characteristics
Streams:
- Do not normally modify the source collection.
- Support lazy intermediate operations.
- Can be sequential or parallel.
- Are generally consumed by a terminal operation.
- Can make collection-processing logic more declarative.
A stream should not be confused with java.io input/output streams. They solve different problems.
Intermediate and Terminal Operations
Intermediate Operations
Intermediate operations return another stream and are generally lazy.
Examples:
filter()map()sorted()distinct()limit()skip()
Terminal Operations
Terminal operations produce a result or side effect and trigger stream processing.
Examples:
forEach()collect()reduce()count()findFirst()anyMatch()allMatch()
Understanding this distinction is essential when learning streams.
filter()
filter() keeps only elements that satisfy a predicate.
Example
1import java.util.Arrays; 2import java.util.List; 3 4public class FilterExample { 5 public static void main(String[] args) { 6 List<Integer> numbers = 7 Arrays.asList(5, 10, 15, 20, 25); 8 9 numbers.stream() 10 .filter(number -> number > 10) 11 .forEach(System.out::println); 12 } 13}
Output:
115 220 325
The original list is not changed by this pipeline.
map()
map() transforms each stream element into another value.
Example
1import java.util.Arrays; 2import java.util.List; 3 4public class MapExample { 5 public static void main(String[] args) { 6 List<String> languages = 7 Arrays.asList("java", "python", "go"); 8 9 languages.stream() 10 .map(String::toUpperCase) 11 .forEach(System.out::println); 12 } 13}
Output:
1JAVA 2PYTHON 3GO
Conceptually:
1java -> JAVA 2python -> PYTHON 3go -> GO
map() is useful when the output representation should differ from the input representation.
sorted()
sorted() returns stream elements in sorted order.
Natural Ordering
1import java.util.Arrays; 2import java.util.List; 3 4public class SortedExample { 5 public static void main(String[] args) { 6 List<Integer> numbers = 7 Arrays.asList(30, 10, 20, 5); 8 9 numbers.stream() 10 .sorted() 11 .forEach(System.out::println); 12 } 13}
Output:
15 210 320 430
Custom Sorting
1import java.util.Arrays; 2import java.util.Comparator; 3import java.util.List; 4 5public class CustomSortingExample { 6 public static void main(String[] args) { 7 List<String> names = 8 Arrays.asList("Ankit", "Rahul", "Priya", "Amit"); 9 10 names.stream() 11 .sorted(Comparator.comparingInt(String::length)) 12 .forEach(System.out::println); 13 } 14}
This sorts names by their length.
collect()
In Java 8, use Collectors.toList() to collect stream results into a list.
1import java.util.Arrays; 2import java.util.List; 3import java.util.stream.Collectors; 4 5public class CollectExample { 6 public static void main(String[] args) { 7 List<String> names = 8 Arrays.asList("java", "python", "go"); 9 10 List<String> upperCaseNames = names.stream() 11 .map(String::toUpperCase) 12 .collect(Collectors.toList()); 13 14 System.out.println(upperCaseNames); 15 } 16}
Output:
1[JAVA, PYTHON, GO]
The Stream.toList() method is from a later Java version, so Collectors.toList() is the appropriate choice when specifically writing Java 8-compatible code.
reduce()
reduce() combines multiple stream elements into a single result.
Sum Example
1import java.util.Arrays; 2 3public class ReduceExample { 4 public static void main(String[] args) { 5 int sum = Arrays.asList(1, 2, 3, 4, 5) 6 .stream() 7 .reduce(0, Integer::sum); 8 9 System.out.println(sum); 10 } 11}
Output:
115
The operation can be viewed conceptually as:
10 + 1 + 2 + 3 + 4 + 5 = 15
For numeric collections, primitive streams such as IntStream can often express the same operation more directly:
1import java.util.stream.IntStream; 2 3public class IntStreamSumExample { 4 public static void main(String[] args) { 5 int sum = IntStream.of(1, 2, 3, 4, 5).sum(); 6 7 System.out.println(sum); 8 } 9}
distinct(), limit(), and skip()
distinct()
Removes duplicate elements from the stream.
1import java.util.Arrays; 2 3public class DistinctExample { 4 public static void main(String[] args) { 5 Arrays.asList(10, 10, 20, 20, 30) 6 .stream() 7 .distinct() 8 .forEach(System.out::println); 9 } 10}
Output:
110 220 330
limit()
Keeps only the first specified number of elements.
1Arrays.asList(10, 20, 30, 40) 2 .stream() 3 .limit(2) 4 .forEach(System.out::println);
Output:
110 220
skip()
Skips the first specified number of elements.
1Arrays.asList(10, 20, 30, 40) 2 .stream() 3 .skip(2) 4 .forEach(System.out::println);
Output:
130 240
Matching and Finding Elements
Streams provide convenient methods for answering questions about a collection.
anyMatch()
1boolean hasLargeNumber = Arrays.asList(5, 10, 25) 2 .stream() 3 .anyMatch(number -> number > 20); 4 5System.out.println(hasLargeNumber);
Output:
1true
allMatch()
1boolean allPositive = Arrays.asList(5, 10, 25) 2 .stream() 3 .allMatch(number -> number > 0); 4 5System.out.println(allPositive);
findFirst()
1String firstName = Arrays.asList("Ankit", "Rahul", "Priya") 2 .stream() 3 .findFirst() 4 .orElse("Unknown"); 5 6System.out.println(firstName);
Output:
1Ankit
These operations can short-circuit, meaning the stream may stop processing once the required answer is determined.
Method References
A method reference provides a concise way to refer to an existing method when its signature matches the target functional interface.
Lambda Version
1names.forEach(name -> System.out.println(name));
Method Reference Version
1names.forEach(System.out::println);
The method-reference version is shorter without changing the meaning.
Common Forms
| Form | Example | Meaning |
|---|---|---|
| Static method | Math::abs | Reference a static method |
| Object instance method | System.out::println | Reference a method on a particular object |
| Instance method of arbitrary object | String::toUpperCase | Invoke on stream/input objects |
| Constructor | Student::new | Reference a constructor |
Constructor Reference
1import java.util.function.Function; 2 3class Student { 4 private final String name; 5 6 Student(String name) { 7 this.name = name; 8 } 9 10 public String getName() { 11 return name; 12 } 13} 14 15public class ConstructorReferenceExample { 16 public static void main(String[] args) { 17 Function<String, Student> studentCreator = Student::new; 18 19 Student student = studentCreator.apply("Ankit"); 20 21 System.out.println(student.getName()); 22 } 23}
Output:
1Ankit
Optional
Optional<T> represents a value that may or may not be present.
It is useful for making absence explicit and reducing careless null handling. It does not automatically eliminate NullPointerException, and it should not be used as a replacement for every nullable value.
Problem with Direct Null Access
1String name = null; 2 3System.out.println(name.length());
This throws:
1NullPointerException
Using Optional
1import java.util.Optional; 2 3public class OptionalExample { 4 public static void main(String[] args) { 5 Optional<String> name = 6 Optional.ofNullable(null); 7 8 System.out.println(name.orElse("Guest")); 9 } 10}
Output:
1Guest
Optional with a Real Value
1Optional<String> name = 2 Optional.of("Ankit"); 3 4System.out.println(name.orElse("Guest"));
Output:
1Ankit
Optional Transformation
map() can transform a value when it is present.
1import java.util.Optional; 2 3public class OptionalMapExample { 4 public static void main(String[] args) { 5 Optional<String> name = 6 Optional.ofNullable("ankit"); 7 8 String upperCaseName = name 9 .map(String::toUpperCase) 10 .orElse("UNKNOWN"); 11 12 System.out.println(upperCaseName); 13 } 14}
Output:
1ANKIT
Common Optional Methods
| Method | Purpose |
|---|---|
of() | Creates an Optional from a non-null value |
ofNullable() | Creates an Optional from a possibly null value |
empty() | Creates an empty Optional |
isPresent() | Checks whether a value exists |
ifPresent() | Performs an action when a value exists |
map() | Transforms a present value |
orElse() | Provides a default value |
orElseGet() | Lazily computes a default value |
orElseThrow() | Throws when no value exists |
orElse() vs orElseGet()
A subtle but important difference is evaluation.
1String value = optionalValue.orElse(createDefaultValue());
The argument to orElse() is evaluated even when the Optional contains a value.
With orElseGet():
1String value = optionalValue.orElseGet( 2 () -> createDefaultValue() 3);
the supplier is evaluated only when the Optional is empty.
Use orElseGet() when creating the fallback is expensive or has side effects.
Date and Time API
Java 8 introduced the java.time package, which provides a clearer and more robust API for date and time operations than the legacy Date and Calendar APIs.
Important classes include:
LocalDateLocalTimeLocalDateTimeZonedDateTimeInstantPeriodDurationDateTimeFormatter
LocalDate
LocalDate represents a date without a time or time zone.
1import java.time.LocalDate; 2 3public class LocalDateExample { 4 public static void main(String[] args) { 5 LocalDate today = LocalDate.now(); 6 7 System.out.println("Today: " + today); 8 System.out.println("Year: " + today.getYear()); 9 System.out.println("Month: " + today.getMonth()); 10 } 11}
The exact output depends on the current date.
Date Arithmetic
1import java.time.LocalDate; 2 3public class DateArithmeticExample { 4 public static void main(String[] args) { 5 LocalDate today = LocalDate.now(); 6 7 LocalDate nextWeek = today.plusWeeks(1); 8 LocalDate previousMonth = today.minusMonths(1); 9 10 System.out.println("Today: " + today); 11 System.out.println("Next week: " + nextWeek); 12 System.out.println("Previous month: " + previousMonth); 13 } 14}
LocalTime
LocalTime represents a time without a date or time zone.
1import java.time.LocalTime; 2 3public class LocalTimeExample { 4 public static void main(String[] args) { 5 LocalTime now = LocalTime.now(); 6 7 System.out.println("Current time: " + now); 8 System.out.println("Hour: " + now.getHour()); 9 } 10}
LocalDateTime
LocalDateTime combines a date and time but does not contain time-zone information.
1import java.time.LocalDateTime; 2 3public class LocalDateTimeExample { 4 public static void main(String[] args) { 5 LocalDateTime now = LocalDateTime.now(); 6 7 System.out.println(now); 8 } 9}
For systems that operate across time zones, prefer an appropriate type such as ZonedDateTime or Instant rather than assuming a local date-time has a universal meaning.
Formatting Dates
Use DateTimeFormatter to format a date for display.
1import java.time.LocalDate; 2import java.time.format.DateTimeFormatter; 3 4public class DateFormattingExample { 5 public static void main(String[] args) { 6 LocalDate date = LocalDate.of(2026, 7, 21); 7 8 DateTimeFormatter formatter = 9 DateTimeFormatter.ofPattern("dd-MM-yyyy"); 10 11 System.out.println(date.format(formatter)); 12 } 13}
Output:
121-07-2026
Using a fixed LocalDate in an educational example makes the output deterministic.
Parsing a Date
Formatting converts a date to text. Parsing converts text into a date.
1import java.time.LocalDate; 2import java.time.format.DateTimeFormatter; 3 4public class DateParsingExample { 5 public static void main(String[] args) { 6 DateTimeFormatter formatter = 7 DateTimeFormatter.ofPattern("dd-MM-yyyy"); 8 9 LocalDate date = 10 LocalDate.parse("21-07-2026", formatter); 11 12 System.out.println(date); 13 } 14}
Output:
12026-07-21
Period and Duration
Period is useful for date-based amounts such as years, months, and days.
1import java.time.LocalDate; 2import java.time.Period; 3 4public class PeriodExample { 5 public static void main(String[] args) { 6 LocalDate start = LocalDate.of(2020, 1, 1); 7 LocalDate end = LocalDate.of(2026, 1, 1); 8 9 Period period = Period.between(start, end); 10 11 System.out.println(period.getYears()); 12 } 13}
Output:
16
Duration is intended for time-based amounts such as seconds and nanoseconds.
1import java.time.Duration; 2import java.time.LocalTime; 3 4public class DurationExample { 5 public static void main(String[] args) { 6 LocalTime start = LocalTime.of(10, 0); 7 LocalTime end = LocalTime.of(12, 30); 8 9 Duration duration = Duration.between(start, end); 10 11 System.out.println(duration.toMinutes()); 12 } 13}
Output:
1150
Default Methods in Interfaces
Java 8 introduced default methods, allowing interfaces to provide method implementations.
1interface Vehicle { 2 void start(); 3 4 default void stop() { 5 System.out.println("Vehicle stopped"); 6 } 7} 8 9public class DefaultMethodExample { 10 public static void main(String[] args) { 11 Vehicle vehicle = new Vehicle() { 12 @Override 13 public void start() { 14 System.out.println("Vehicle started"); 15 } 16 }; 17 18 vehicle.start(); 19 vehicle.stop(); 20 } 21}
Output:
1Vehicle started 2Vehicle stopped
Default methods were especially important for evolving existing interfaces without forcing every implementation to immediately provide a new method body.
Static Methods in Interfaces
Interfaces can also contain static methods.
1interface MathUtils { 2 static int square(int number) { 3 return number * number; 4 } 5} 6 7public class InterfaceStaticMethodExample { 8 public static void main(String[] args) { 9 System.out.println(MathUtils.square(5)); 10 } 11}
Output:
125
The static method is called through the interface name.
Stream Pipeline
A typical Stream API pipeline looks like this:
1Collection 2 | 3 v 4 stream() 5 | 6 v 7 filter() 8 | 9 v 10 map() 11 | 12 v 13 sorted() 14 | 15 v 16 collect()
For example:
1List<String> result = employees.stream() 2 .filter(employee -> employee.getSalary() > 50000) 3 .map(Employee::getName) 4 .sorted() 5 .collect(Collectors.toList());
The important idea is that each intermediate operation describes a transformation, while the terminal operation triggers the pipeline.
Understanding Lazy Stream Processing
Intermediate stream operations are lazy.
Consider:
1Arrays.asList(1, 2, 3, 4, 5) 2 .stream() 3 .filter(number -> { 4 System.out.println("Checking " + number); 5 return number > 3; 6 }) 7 .findFirst();
Because findFirst() can stop once it finds a matching element, the stream does not necessarily process every element.
This is one reason stream pipelines can be expressive and efficient when used appropriately.
Streams and Side Effects
Avoid modifying external mutable state unnecessarily inside a stream.
Less desirable:
1List<Integer> result = new ArrayList<>(); 2 3numbers.stream() 4 .filter(number -> number > 10) 5 .forEach(result::add);
Prefer a collector:
1List<Integer> result = numbers.stream() 2 .filter(number -> number > 10) 3 .collect(Collectors.toList());
The second version describes the intended result more directly and is easier to reason about.
Practice Project: Employee Stream Operations
The following example combines lambdas, functional interfaces, streams, method references, sorting, and collectors.
1import java.util.Arrays; 2import java.util.Comparator; 3import java.util.List; 4import java.util.stream.Collectors; 5 6class Employee { 7 private final String name; 8 private final String department; 9 private final double salary; 10 11 public Employee( 12 String name, 13 String department, 14 double salary 15 ) { 16 this.name = name; 17 this.department = department; 18 this.salary = salary; 19 } 20 21 public String getName() { 22 return name; 23 } 24 25 public String getDepartment() { 26 return department; 27 } 28 29 public double getSalary() { 30 return salary; 31 } 32 33 @Override 34 public String toString() { 35 return name + " (" + department + ", " + salary + ")"; 36 } 37} 38 39public class EmployeeStreamExample { 40 41 public static void main(String[] args) { 42 List<Employee> employees = Arrays.asList( 43 new Employee("Ankit", "Engineering", 60000), 44 new Employee("Rahul", "Engineering", 45000), 45 new Employee("Priya", "HR", 70000), 46 new Employee("Amit", "Sales", 52000) 47 ); 48 49 List<String> highEarners = employees.stream() 50 .filter(employee -> employee.getSalary() > 50000) 51 .sorted(Comparator.comparing(Employee::getName)) 52 .map(Employee::getName) 53 .collect(Collectors.toList()); 54 55 highEarners.forEach(System.out::println); 56 } 57}
Output:
1Amit 2Ankit 3Priya
What Happens in This Pipeline?
The pipeline performs four operations:
1employees 2 | 3 v 4filter salary > 50000 5 | 6 v 7sort by employee name 8 | 9 v 10map Employee -> String 11 | 12 v 13collect into List<String>
This is a good example of how several Java 8 features can work together without requiring explicit indexing or temporary loops.
Calculate an Employee Salary Average
Java's primitive streams are useful for numeric calculations.
1import java.util.Arrays; 2import java.util.List; 3 4public class SalaryAverageExample { 5 public static void main(String[] args) { 6 List<Employee> employees = Arrays.asList( 7 new Employee("Ankit", "Engineering", 60000), 8 new Employee("Rahul", "Engineering", 45000), 9 new Employee("Priya", "HR", 70000) 10 ); 11 12 double averageSalary = employees.stream() 13 .mapToDouble(Employee::getSalary) 14 .average() 15 .orElse(0.0); 16 17 System.out.println(averageSalary); 18 } 19}
The average() operation returns an OptionalDouble, so the example explicitly handles the case where the employee list is empty.
Find the Highest-Paid Employee
1import java.util.Arrays; 2import java.util.Comparator; 3import java.util.List; 4import java.util.Optional; 5 6public class HighestSalaryExample { 7 public static void main(String[] args) { 8 List<Employee> employees = Arrays.asList( 9 new Employee("Ankit", "Engineering", 60000), 10 new Employee("Rahul", "Engineering", 45000), 11 new Employee("Priya", "HR", 70000) 12 ); 13 14 Optional<Employee> highestPaid = employees.stream() 15 .max(Comparator.comparingDouble(Employee::getSalary)); 16 17 highestPaid.ifPresent(employee -> 18 System.out.println(employee.getName()) 19 ); 20 } 21}
Output:
1Priya
Using Optional<Employee> makes the empty-list case explicit.
Group Employees by Department
Grouping is one of the most useful real-world Stream API operations.
1import java.util.Arrays; 2import java.util.List; 3import java.util.Map; 4import java.util.stream.Collectors; 5 6public class GroupingExample { 7 public static void main(String[] args) { 8 List<Employee> employees = Arrays.asList( 9 new Employee("Ankit", "Engineering", 60000), 10 new Employee("Rahul", "Engineering", 45000), 11 new Employee("Priya", "HR", 70000), 12 new Employee("Amit", "Sales", 52000) 13 ); 14 15 Map<String, List<Employee>> byDepartment = 16 employees.stream() 17 .collect(Collectors.groupingBy( 18 Employee::getDepartment 19 )); 20 21 byDepartment.forEach((department, people) -> { 22 System.out.println(department); 23 people.forEach(employee -> 24 System.out.println(" " + employee.getName()) 25 ); 26 }); 27 } 28}
This pattern is commonly useful when processing database results, API data, reports, and business objects.
Common Java 8 Mistakes to Avoid
Using Java Features That Did Not Exist in Java 8
If a project must compile with Java 8, avoid APIs introduced in later versions.
For example:
1List.of("A", "B", "C");
was introduced after Java 8.
For Java 8-compatible examples, use:
1Arrays.asList("A", "B", "C");
Likewise, use:
1.collect(Collectors.toList());
instead of:
1.toList();
when targeting Java 8.
Calling Optional.get() Carelessly
Avoid:
1Optional<String> name = Optional.empty(); 2 3System.out.println(name.get());
This throws NoSuchElementException.
Prefer:
1System.out.println(name.orElse("Guest"));
or:
1name.ifPresent(System.out::println);
Using Parallel Streams Without Measurement
Parallel streams are not automatically faster.
Avoid assuming:
1largeList.parallelStream()
will always improve performance.
Parallel execution has overhead and may be inappropriate for small collections, blocking operations, ordering-sensitive logic, or workloads with shared mutable state.
Measure before choosing parallel processing.
Overusing Streams
A stream is not automatically better than a loop.
For example, complicated multi-step mutable logic may be clearer with a normal loop. Prefer the approach that makes the algorithm easiest to understand, test, and maintain.
Java 8 Best Practices
- Use lambdas for short behavior implementations.
- Use named methods when lambda logic becomes large or complicated.
- Prefer built-in functional interfaces when they accurately describe the operation.
- Use method references when they improve readability.
- Keep stream operations as free from side effects as practical.
- Use
Collectorsfor Java 8-compatible collection results. - Use
Optionalto represent potentially absent return values where it improves the API. - Avoid using
Optionalas a field or parameter by default; choose it based on the API design. - Prefer
java.timefor new date and time code. - Use
ZonedDateTimeorInstantwhen time-zone or global timestamp semantics matter. - Avoid parallel streams until performance measurements justify them.
- Test empty collections and boundary cases.
- Choose readability over clever one-line stream pipelines.
Real-World Uses of Java 8 Features
Java 8 features are useful across many types of applications.
Spring Boot and REST APIs
Streams can transform service-layer data before returning a response.
1List<String> names = employees.stream() 2 .map(Employee::getName) 3 .collect(Collectors.toList());
Data Processing
Filtering and grouping are useful for processing collections returned from databases or external services.
Event Handling
Lambdas can make callback-style operations concise.
1button.setOnAction(event -> handleClick());
Business Logic
Predicates and functions can represent reusable business rules.
1Predicate<Employee> highEarner = 2 employee -> employee.getSalary() > 50000;
Date Calculations
The java.time API is useful for:
- Due dates
- Subscription periods
- Booking dates
- Timestamps
- Scheduling
- Date comparisons
How to Learn Java 8 Effectively
A good learning sequence is:
Step 1: Learn Lambda Syntax
Understand:
1(value) -> expression
and how a lambda maps to a functional interface.
Step 2: Learn Functional Interfaces
Practice:
1Predicate 2Function 3Consumer 4Supplier
Step 3: Learn Stream Fundamentals
Master:
1filter 2map 3sorted 4distinct 5collect 6reduce
Step 4: Learn Advanced Stream Operations
Then study:
1groupingBy 2partitioningBy 3flatMap 4joining 5counting 6mapping 7summarizing
Step 5: Learn Optional
Understand:
1of 2ofNullable 3map 4flatMap 5orElse 6orElseGet 7orElseThrow 8ifPresent
Step 6: Learn java.time
Practice:
1LocalDate 2LocalTime 3LocalDateTime 4Instant 5ZonedDateTime 6Period 7Duration 8DateTimeFormatter
Step 7: Combine Features
Build small applications that use multiple Java 8 features together instead of learning every feature in isolation.
Practice Exercises
Exercise 1: Student Stream Operations
Create a Student class containing a name and marks.
Implement a program that:
- Filters students with marks greater than 80.
- Sorts the students by marks.
- Displays only their names.
- Calculates the average marks.
- Finds the highest-scoring student.
Exercise 2: Product Management
Create a Product class containing a name, category, and price.
Use streams to:
- Filter products above a specified price.
- Calculate the total price.
- Find the cheapest product.
- Sort products alphabetically.
- Group products by category.
Exercise 3: Employee Salary Report
Create an employee list containing:
- Name
- Department
- Salary
Then:
- Find the highest salary.
- Find the average salary.
- Filter high earners.
- Group employees by department.
- Calculate average salary by department.
Exercise 4: Optional Demo
Create a user lookup method that may not find a user.
Return an Optional<User> and safely display:
1User found: ...
or:
1User not found
without directly calling Optional.get().
Exercise 5: Task Management System
Build a small task manager that:
- Uses lambdas for task actions.
- Uses streams to filter and sort tasks.
- Uses method references where appropriate.
- Stores due dates using
java.time. - Uses
Optionalfor optional task descriptions. - Groups tasks by status.
Java 8 Interview Questions to Practice
- What is a lambda expression?
- What is a functional interface?
- What is the difference between
Predicate,Function,Consumer, andSupplier? - What is the difference between
map()andfilter()? - What are intermediate and terminal stream operations?
- Why are stream intermediate operations lazy?
- What is the difference between
map()andflatMap()? - What is the difference between
orElse()andorElseGet()? - Why was
Optionalintroduced? - What is a method reference?
- What are default methods in interfaces?
- What are static methods in interfaces?
- What is the difference between
LocalDate,LocalDateTime, andZonedDateTime? - When should you avoid parallel streams?
- Why should Java 8 code use
Collectors.toList()instead ofStream.toList()?
Summary
Java 8 introduced a major shift toward more expressive and functional Java programming.
In this tutorial, you learned:
- How lambda expressions reduce boilerplate.
- How functional interfaces provide targets for lambda expressions.
- How
Predicate,Function,Consumer, andSuppliermodel common operations. - How the Stream API filters, transforms, sorts, aggregates, and groups data.
- How method references make compatible lambdas shorter.
- How
Optionalmakes potentially absent values explicit. - How the
java.timeAPI handles modern date and time operations. - How default and static interface methods work.
- How to combine Java 8 features in a realistic employee-processing example.
- How to write Java 8-compatible code without accidentally using APIs from later Java releases.
Java 8 remains an important milestone for understanding modern Java development. Once these concepts are comfortable, you can move toward advanced Stream API operations, collectors, concurrency, JDBC, reflection, annotations, Spring Boot, and enterprise Java development.
Java 8 Learning Roadmap
1Java Fundamentals 2 | 3 v 4Lambda Expressions 5 | 6 v 7Functional Interfaces 8 | 9 v 10Predicate / Function / Consumer / Supplier 11 | 12 v 13Stream API 14 | 15 v 16Collectors and Advanced Streams 17 | 18 v 19Method References 20 | 21 v 22Optional 23 | 24 v 25java.time API 26 | 27 v 28Practical Projects 29 | 30 v 31Spring Boot and Advanced Java