Exploring Java 8: A Complete Feature Guide

Bikash NishankBikash Nishank
46 min read

Table of Contents

  1. Introduction

    • Overview of Java 8 Features
  2. Lambda Expressions

    • What is a Lambda Expression?

    • Example 1: Traditional Way vs. Lambda Expression

    • Example 2: Filtering a List

  3. Stream API

    • What is a Stream? (java.util.stream API)

    • Stream API Architecture Diagram

    • Internal Working of Stream API with Examples

      • Creating Streams

        • From Collections

        • From Arrays

        • Using Stream.of()

      • Stream Operations

        • Intermediate Operations

          • filter()

          • map()

          • distinct()

          • sorted()

        • Terminal Operations

          • forEach()

          • collect()

          • reduce()

          • count()

      • How Stream API Works

        • Stream Creation

        • Building the Pipeline

        • Triggering Execution

        • Processing and Collecting Results

  4. Functional Interfaces

    • What is a Functional Interface?

    • Key Concepts of Functional Interfaces

      • Single Abstract Method (SAM)

      • @FunctionalInterface Annotation

      • Default and Static Methods

    • Predefined Functional Interfaces in Java

      • Predicate

      • Consumer

      • Function

      • Supplier

      • UnaryOperator

      • BinaryOperator

      • BiPredicate

      • BiConsumer

      • BiFunction

    • Primitive Specialisations

      • IntPredicate

      • IntConsumer

      • IntSupplier

      • IntFunction

      • ToIntFunction

      • IntUnaryOperator

      • IntBinaryOperator

  5. Stream Methods with Functional Interfaces

    • forEach

    • filter

    • map

    • reduce

    • collect

    • anyMatch

    • allMatch

    • noneMatch

    • findFirst

    • findAny

    • sorted

    • peek

    • flatMap

    • distinct

    • limit

    • skip

    • reduce

    • toArray

    • count

    • max

    • min

  6. Default Methods

    • What are Default Methods?

    • Why Use Default Methods?

      • Maintain Backward Compatibility

      • Support Multiple Inheritance

      • Reduce Boilerplate Code

    • Syntax of Default Methods

      • Example: Basic Usage of Default Methods

      • Defining an Interface with Default Method

      • Implementing the Interface

      • Using the Default Method

      • Overriding Default Methods

      • Testing the Override

      • Multiple Interfaces with Default Methods

      • Resolving Conflicts

      • Practical Use Case: Adding Methods to Interfaces

    • Advanced Features of Default Methods

      • Static Methods in Interfaces

      • Default Methods with Lambda Expressions

    • Comparing Java 7 and Java 8 Interfaces

  7. Nashorn JavaScript Engine

    • Key Features of Nashorn

    • Using Nashorn

      • Executing JavaScript from Java

      • Invoking Java Methods from JavaScript

      • Accessing JavaScript Variables from Java

      • Interoperability between Java and JavaScript

      • Working with JSON

      • Using JavaScript Libraries

    • JavaScript Shell (jjs)

    • Nashorn Limitations

  8. CompletableFuture

    • Why CompletableFuture?

    • Benefits of CompletableFuture

    • How to Create a CompletableFuture

      • Basic Creation

      • Using Factory Methods

    • Asynchronous Computations

      • runAsync()

      • supplyAsync()

    • Chaining Tasks

      • thenApply()

      • thenAccept()

      • thenRun()

      • thenCombine()

    • Exception Handling

      • exceptionally()

      • handle()

      • whenComplete()

    • Combining Multiple Futures

      • allOf()

      • anyOf()

Introduction : Overview of Java 8 Features -

Java 8 is a significant update that enhances Java programming with powerful new features. Here's a brief overview of what we'll explore step by step:

  • Lambda Expressions: Write shorter and more readable code for tasks like filtering and sorting.

  • Stream API: Efficiently process collections of data with operations like filtering, mapping, and reducing.

  • Functional Interfaces: Simplify function passing by using predefined or custom functional interfaces.

  • Method References: Simplify code by referring to methods directly.

  • Optional Class: Handle missing values more safely and concisely.

  • New Date and Time API: Work with dates and times using a more intuitive and flexible API.

  • Default Methods: Add new methods to interfaces without breaking existing implementations.

  • Nashorn JavaScript Engine: Execute JavaScript code within Java applications more efficiently.

  • CompletableFuture: Write asynchronous code more easily with improved handling of future results.

These features help you write cleaner and more efficient code, making Java 8 a significant improvement over previous versions. We'll cover each feature in detail, learning how they can make Java development more efficient and expressive.

1. Lambda Expression?

A lambda expression is a quick way to create methods (functions) without needing to write a full method implementation. Instead of creating a whole class with a method, you can use lambda expressions to define these methods in a shorter, more readable format.

In simple terms:

  • Lambda expressions let you write methods in a more compact way.

  • They are especially useful when working with functional interfaces, which have only one method to implement.

  • Example 1:

    Instead of writing a lot of code to define a simple function, you can use a lambda expression to do it in one line.

    Traditional Way:

      new Thread(new Runnable() {
          @Override
          public void run() {
              System.out.println("Hello from Runnable");
          }
      }).start();
    

    Using Lambda Expression:

new Thread(() -> System.out.println("Hello from Runnable")).start();

Example 2: Filtering a List

If you want to filter a list of numbers to keep only the even numbers:

Using Anonymous Class:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = new ArrayList<>();
for (Integer number : numbers) {
    if (number % 2 == 0) {
        evenNumbers.add(number);
    }
}

Using Lambda Expression with Streams:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = numbers.stream()
                                    .filter(number -> number % 2 == 0)
                                    .collect(Collectors.toList());

What is a Stream? (java.util.stream API**)**

A stream is a collection of elements that can be processed parallel or sequentially. Think of a stream as a pipeline that processes data. Streams allow you to perform complex data manipulation tasks like filtering, mapping, and reducing in a more readable and concise way compared to traditional loops.

Stream API Architecture Diagram

Here is a simplified architecture diagram for the Stream API:

java streams

Internal Working of Stream API with Examples

  1. Creating Streams

    • From Collections: You can create a stream from collections like List, Set, etc.

        List<String> names = Arrays.asList("Arjun", "Balaram", "chaitanya");
        Stream<String> stream = names.stream();
      
    • From Arrays: You can create a stream from arrays.

        String[] namesArray = {"Arjun", "Balaram", "chaitanya"};
        Stream<String> stream = Arrays.stream(namesArray);
      
    • Using Stream.of(): You can create a stream from a set of values.

        Stream<String> stream = Stream.of("Arjun", "Balaram", "chaitanya");
      
  2. Stream Operations

    • Intermediate Operations: These operations transform the stream into another stream. They are lazy, meaning they are not executed until a terminal operation is invoked.

      • filter(): Filters elements based on a condition.

           Stream<String> filteredStream = stream.filter(name -> name.startsWith("A"));
        
      • map(): Transforms each element into another form.

          Stream<Integer> lengthStream = stream.map(String::length);
        
      • distinct(): Removes duplicate elements.

          Stream<String> distinctStream = stream.distinct();
        
      • sorted(): Sorts elements.

          Stream<String> sortedStream = stream.sorted();
        
    • Terminal Operations: These operations produce a result or a side effect. They trigger the processing of the stream.

      • forEach(): Performs an action for each element.

          stream.forEach(name -> System.out.println(name));
        
      • collect(): Collects the elements into a collection like List, Set, or Map.

          List<String> list = stream.collect(Collectors.toList());
        
      • reduce(): Combines elements to produce a single result.

          Optional<String> concatenated = stream.reduce((s1, s2) -> s1 + s2);
        
      • count(): Counts the number of elements.

          long count = stream.count();
        

How Stream API Works

  1. Stream Creation:

    • You start by creating a stream from a source. This can be done using methods like stream() on collections, Arrays.stream(), or Stream.of().
  2. Building the Pipeline:

    • Apply intermediate operations to the stream. These operations are not executed immediately but are recorded to form a processing pipeline.
  3. Triggering Execution:

    • When a terminal operation is invoked, the stream pipeline is processed. The elements are passed through each intermediate operation in the pipeline.
  4. Processing and Collecting Results:

    • The terminal operation processes the elements according to the pipeline’s configuration and produces a result, such as a list, a single value, or a side effect.

What is a Functional Interface?

A functional interface in Java is an interface with only one abstract method. This makes it easy to represent instances of these interfaces using lambda expressions, method references, or constructor references. These features enable functional programming in Java, making the code more concise and readable.

Key Concepts of Functional Interfaces

  1. Single Abstract Method (SAM):

    • A functional interface must have exactly one abstract method. This is the core method that defines the interface's behavior.

    • For example, Runnable is a functional interface with the single abstract method run().

  2. @FunctionalInterface Annotation:

    • This annotation is optional but recommended. It tells the compiler that the interface is intended to be a functional interface. If the interface does not meet the requirements (e.g., it has more than one abstract method), the compiler will generate an error.
    @FunctionalInterface
    public interface MyFunctionalInterface {
        void execute(String message);
    }
  1. Default and Static Methods:

    • Functional interfaces can also have default and static methods. These methods do not count towards the single abstract method requirement.
    @FunctionalInterface
    public interface MyFunctionalInterface {
        void execute(String message);

        default void print(String message) {
            System.out.println("Printing: " + message);
        }

        static void display(String message) {
            System.out.println("Displaying: " + message);
        }
    }

Predefined Functional Interfaces in Java

Java 8 provides many built-in functional interfaces in the java.util.function package. Some commonly used ones include:

1. Predicate<T>

Explanation: Represents a boolean-valued function that tests an input. Commonly used for filtering or matching conditions.

  • Abstract Method:boolean test(T t)

  • Default Methods:and, or, negate

import java.util.function.Predicate;

public class PredicateExample {
    public static void main(String[] args) {
        Predicate<String> isLongerThanFive = s -> s.length() > 5;
        Predicate<String> startsWithH = s -> s.startsWith("H");

        System.out.println(isLongerThanFive.test("Hello")); // false
        System.out.println(isLongerThanFive.test("Hello, World!")); // true

        // Using default methods
        Predicate<String> combined = isLongerThanFive.and(startsWithH);
        System.out.println(combined.test("Hello, World!")); // true
    }
}

2. Consumer<T>

Explanation: Represents an operation that accepts a single input argument and returns no result. Often used for performing actions like printing or logging.

  • Abstract Method:void accept(T t)

  • Default Methods:andThen

import java.util.function.Consumer;

public class ConsumerExample {
    public static void main(String[] args) {
        Consumer<String> print = s -> System.out.println(s);
        Consumer<String> printUpperCase = s -> System.out.println(s.toUpperCase());

        print.accept("Hello, World!"); // Hello, World!

        // Using default methods
        Consumer<String> combined = print.andThen(printUpperCase);
        combined.accept("Hello, World!"); // Hello, World! \n HELLO, WORLD!
    }
}

3. Function<T, R>

Explanation: Represents a function that accepts one argument and produces a result. Useful for mapping or transforming data.

  • Abstract Method:R apply(T t)

  • Default Methods:andThen, compose

import java.util.function.Function;

public class FunctionExample {
    public static void main(String[] args) {
        Function<String, Integer> length = s -> s.length();
        Function<Integer, String> toString = i -> "Length: " + i;

        System.out.println(length.apply("Hello")); // 5

        // Using default methods
        Function<String, String> combined = length.andThen(toString);
        System.out.println(combined.apply("Hello")); // Length: 5
    }
}

4. Supplier<T>

Explanation: Represents a supplier of results that provides values without taking any input. Commonly used for lazy generation of values.

  • Abstract Method:T get()
import java.util.function.Supplier;

public class SupplierExample {
    public static void main(String[] args) {
        Supplier<String> helloSupplier = () -> "Hello, World!";

        System.out.println(helloSupplier.get()); // Hello, World!
    }
}

5. UnaryOperator<T>

Explanation: Represents an operation on a single operand that produces a result of the same type. Useful for operations like incrementing or squaring.

  • Abstract Method:T apply(T t)
import java.util.function.UnaryOperator;

public class UnaryOperatorExample {
    public static void main(String[] args) {
        UnaryOperator<Integer> square = x -> x * x;

        System.out.println(square.apply(5)); // 25
    }
}

6. BinaryOperator<T>

Explanation: Represents an operation upon two operands of the same type, producing a result of the same type. Commonly used for combining values, like summing or multiplying.

  • Abstract Method:T apply(T t1, T t2)
import java.util.function.BinaryOperator;

public class BinaryOperatorExample {
    public static void main(String[] args) {
        BinaryOperator<Integer> sum = (a, b) -> a + b;

        System.out.println(sum.apply(5, 3)); // 8
    }
}

7. BiPredicate<T, U>

Explanation: Represents a boolean-valued function of two arguments. Useful for comparisons or matching conditions involving two values.

  • Abstract Method:boolean test(T t, U u)

  • Default Methods:and, or, negate

import java.util.function.BiPredicate;

public class BiPredicateExample {
    public static void main(String[] args) {
        BiPredicate<String, Integer> isLongerThan = (s, length) -> s.length() > length;

        System.out.println(isLongerThan.test("Hello", 5)); // false
        System.out.println(isLongerThan.test("Hello, World!", 5)); // true

        // Using default methods
        BiPredicate<String, Integer> startsWithH = (s, length) -> s.startsWith("H");
        BiPredicate<String, Integer> combined = isLongerThan.and(startsWithH);
        System.out.println(combined.test("Hello, World!", 5)); // true
    }
}

8. BiConsumer<T, U>

Explanation: Represents an operation that accepts two input arguments and returns no result. Often used for operations involving two inputs, like printing paired values.

  • Abstract Method:void accept(T t, U u)

  • Default Methods:andThen

import java.util.function.BiConsumer;

public class BiConsumerExample {
    public static void main(String[] args) {
        BiConsumer<String, Integer> printStringAndLength = (s, length) -> System.out.println(s + " has length " + length);

        printStringAndLength.accept("Hello", 5); // Hello has length 5

        // Using default methods
        BiConsumer<String, Integer> printUpperCase = (s, length) -> System.out.println(s.toUpperCase() + " has length " + length);
        BiConsumer<String, Integer> combined = printStringAndLength.andThen(printUpperCase);
        combined.accept("Hello", 5); // Hello has length 5 \n HELLO has length 5
    }
}

9. BiFunction<T, U, R>

Explanation: Represents a function that accepts two arguments and produces a result. Useful for operations that combine or transform two inputs into a single output.

  • Abstract Method:R apply(T t, U u)

  • Default Methods:andThen

import java.util.function.BiFunction;

public class BiFunctionExample {
    public static void main(String[] args) {
        BiFunction<String, Integer, String> appendLength = (s, length) -> s + " has length " + length;

        System.out.println(appendLength.apply("Hello", 5)); // Hello has length 5

        // Using default methods
        BiFunction<String, Integer, Integer> addLengths = (s, length) -> s.length() + length;
        BiFunction<String, Integer, String> combined = appendLength.andThen(result -> "Result: " + result);
        System.out.println(combined.apply("Hello", 5)); // Result: Hello has length 5
    }
}

Primitive Specialisations

IntPredicate

Explanation: Represents a boolean-valued function that tests an int value. Commonly used for filtering or matching integer conditions.

  • Abstract Method:boolean test(int value)
import java.util.function.IntPredicate;

public class IntPredicateExample {
    public static void main(String[] args) {
        IntPredicate isEven = x -> x % 2 == 0;

        System.out.println(isEven.test(4)); // true
        System.out.println(isEven.test(5)); // false
    }
}

IntConsumer

Explanation: Represents an operation that accepts a single int argument and returns no result. Often used for performing actions like printing integer values.

  • Abstract Method:void accept(int value)

  • Default Methods:andThen

import java.util.function.IntConsumer;

public class IntConsumerExample {
    public static void main(String[] args) {
        IntConsumer printInt = x -> System.out.println(x);

        printInt.accept(5); // 5

        // Using default methods
        IntConsumer printSquare = x -> System.out.println(x * x);
        IntConsumer combined = printInt.andThen(printSquare);
        combined.accept(5); // 5 \n 25
    }
}

IntSupplier

Explanation: Represents a supplier of int results that provides values without taking any input. Commonly used for lazy generation of integer values.

  • Abstract Method:int getAsInt()
import java.util.function.IntSupplier;

public class IntSupplierExample {
    public static void main(String[] args) {
        IntSupplier randomInt = () -> (int) (Math.random() * 100);

        System.out.println(randomInt.getAsInt()); // Random int value
    }
}

IntFunction<R>

Explanation: Represents a function that accepts an int argument and produces a result of type R. Useful for transforming an integer input into a different type.

  • Abstract Method:R apply(int value)
import java.util.function.IntFunction;

public class IntFunctionExample {
    public static void main(String[] args) {
        IntFunction<String> intToString = x -> "Number: " + x;

        System.out.println(intToString.apply(5)); // Number: 5
    }
}

ToIntFunction<T>

Explanation: Represents a function that accepts an argument of type T and produces an int result. Commonly used for extracting an integer property from an object.

  • Abstract Method:int applyAsInt(T value)
import java.util.function.ToIntFunction;

public class ToIntFunctionExample {
    public static void main(String[] args) {
        ToIntFunction<String> stringLength = s -> s.length();

        System.out.println(stringLength.applyAsInt("Hello")); // 5
    }
}

IntUnaryOperator

Explanation: Represents an operation on a single int operand that produces an int result. Useful for operations like incrementing or doubling an integer.

  • Abstract Method:int applyAsInt(int operand)
import java.util.function.IntUnaryOperator;

public class IntUnaryOperatorExample {
    public static void main(String[] args) {
        IntUnaryOperator increment = x -> x + 1;

        System.out.println(increment.applyAsInt(5)); // 6
    }
}

IntBinaryOperator

Explanation: Represents an operation upon two int operands and produces an int result. Commonly used for combining two integer values, like summing or multiplying.

  • Abstract Method:int applyAsInt(int left, int right)
import java.util.function.IntBinaryOperator;

public class IntBinaryOperatorExample {
    public static void main(String[] args) {
        IntBinaryOperator multiply = (a, b) -> a * b;

        System.out.println(multiply.applyAsInt(5, 3)); // 15
    }
}

Stream Methods with Functional Interfaces

  1. forEach

    • Functional Interface Used:Consumer<T>

    • Description: Performs an action for each element of the stream.

    • Explanation: The forEach method takes a Consumer<T>, which is a functional interface that accepts a single input argument and returns no result. It iterates over each element and applies the provided action.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        names.forEach(name -> System.out.println(name));
        // Output: Alice, Bob, Charlie
      
  2. filter

    • Functional Interface Used:Predicate<T>

    • Description: Filters elements based on a condition.

    • Explanation: The filter method takes a Predicate<T>, which tests each element against a condition and only includes elements that satisfy the predicate.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        List<String> filteredNames = names.stream()
                                          .filter(name -> name.startsWith("A"))
                                          .collect(Collectors.toList());
        System.out.println(filteredNames);  // Output: [Alice]
      
  3. map

    • Functional Interface Used:Function<T, R>

    • Description: Transforms each element to another type.

    • Explanation: The map method takes a Function<T, R>, which applies a function to each element and produces a new element of type R.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        List<Integer> nameLengths = names.stream()
                                         .map(String::length)
                                         .collect(Collectors.toList());
        System.out.println(nameLengths);  // Output: [5, 3, 7]
      
  4. reduce

    • Functional Interface Used:BinaryOperator<T>

    • Description: Combines elements to produce a single result.

    • Explanation: The reduce method uses a BinaryOperator<T>, which takes two elements and combines them into one. It accumulates elements from the stream into a single result.

    • Example:

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        int sum = numbers.stream()
                         .reduce(0, (a, b) -> a + b);
        System.out.println(sum);  // Output: 15
      
  5. collect

    • Functional Interface Used:Collector<T, A, R>

    • Description: Collects the elements of the stream into a collection or another result container.

    • Explanation: The collect method uses a Collector<T, A, R>, which accumulates the elements into a final result, such as a list, set, or map.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        List<String> upperCaseNames = names.stream()
                                          .map(String::toUpperCase)
                                          .collect(Collectors.toList());
        System.out.println(upperCaseNames);  // Output: [ALICE, BOB, CHARLIE]
      
  6. anyMatch

    • Functional Interface Used:Predicate<T>

    • Description: Checks if any elements match a condition.

    • Explanation: The anyMatch method uses a Predicate<T> to test if at least one element in the stream satisfies the condition. If any element matches, it returns true.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        boolean hasNameStartingWithA = names.stream()
                                            .anyMatch(name -> name.startsWith("A"));
        System.out.println(hasNameStartingWithA);  // Output: true
      
  7. allMatch

    • Functional Interface Used:Predicate<T>

    • Description: Checks if all elements match a condition.

    • Explanation: The allMatch method uses a Predicate<T> to test if all elements in the stream satisfy the condition. It returns true only if every element matches.

    • Example:

        List<Integer> numbers = Arrays.asList(2, 4, 6, 8);
        boolean allEven = numbers.stream()
                                 .allMatch(n -> n % 2 == 0);
        System.out.println(allEven);  // Output: true
      
  8. noneMatch

    • Functional Interface Used:Predicate<T>

    • Description: Checks if no elements match a condition.

    • Explanation: The noneMatch method uses a Predicate<T> to test if no elements in the stream satisfy the condition. It returns true if all elements do not match.

    • Example:

        List<Integer> numbers = Arrays.asList(2, 4, 6, 8);
        boolean noneOdd = numbers.stream()
                                 .noneMatch(n -> n % 2 != 0);
        System.out.println(noneOdd);  // Output: true
      
  9. findFirst

    • Functional Interface Used: None

    • Description: Returns the first element in the stream.

    • Explanation: The findFirst method returns an Optional<T> containing the first element of the stream, if available. It does not use a functional interface because it performs a simple retrieval.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        Optional<String> firstName = names.stream()
                                          .findFirst();
        firstName.ifPresent(System.out::println);  // Output: Alice
      
  10. findAny

    • Functional Interface Used: None

    • Description: Returns any element in the stream.

    • Explanation: The findAny method returns an Optional<T> containing any element from the stream. It doesn’t require a functional interface as it returns an element without applying a condition.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        Optional<String> anyName = names.stream()
                                        .findAny();
        anyName.ifPresent(System.out::println);  // Output: (Could be any element, e.g., Alice)
      
  11. sorted

    • Functional Interface Used:Comparator<T>

    • Description: Returns a stream with elements sorted.

    • Explanation: The sorted method uses a Comparator<T> to sort elements in natural order or according to a provided comparator.

    • Example:

        List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
        List<String> sortedNames = names.stream()
                                        .sorted()
                                        .collect(Collectors.toList());
        System.out.println(sortedNames);  // Output: [Alice, Bob, Charlie]
      
  12. peek

    • Functional Interface Used:Consumer<T>

    • Description: Performs an action for each element and returns the same stream.

    • Explanation: The peek method takes a Consumer<T> to perform an action on each element of the stream, such as logging or debugging, and returns the stream itself for further operations.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        names.stream()
             .peek(name -> System.out.println("Processing: " + name))
             .map(String::toUpperCase)
             .forEach(System.out::println);
        // Output: Processing: Alice, ALICE
        //         Processing: Bob, BOB
        //         Processing: Charlie, CHARLIE
      
  13. flatMap

    • Functional Interface Used:Function<T, Stream<R>>

    • Description: Transforms each element to a stream and flattens the result.

    • Explanation: The flatMap method takes a Function<T, Stream<R>> that maps each element to a stream of new elements, then flattens these streams into a single stream.

    • Example:

        List<List<String>> namesNested = Arrays.asList(
            Arrays.asList("Alice", "Bob"),
            Arrays.asList("Charlie", "David"),
            Arrays.asList("Edward")
        );
        List<String> namesFlatStream = namesNested.stream()
                                                  .flatMap(List::stream)
                                                  .collect(Collectors.toList());
        System.out.println(namesFlatStream);  // Output: [Alice, Bob, Charlie, David, Edward]
      
  14. distinct

    • Functional Interface Used: None

    • Description: Returns a stream with distinct elements (i.e., removes duplicates).

    • Explanation: The distinct method removes duplicate elements from the stream without requiring any functional interface.

    • Example:

        List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 4, 4, 5);
        List<Integer> distinctNumbers = numbers.stream()
                                               .distinct()
                                               .collect(Collectors.toList());
        System.out.println(distinctNumbers);  // Output: [1, 2, 3, 4, 5]
      
  15. limit

    • Functional Interface Used: None

    • Description: Limits the stream to a specified number of elements.

    • Explanation: The limit method takes an integer argument specifying the maximum number of elements to retain in the stream, and does not use a functional interface.

    • Example:

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
        List<Integer> limitedNumbers = numbers.stream()
                                              .limit(3)
                                              .collect(Collectors.toList());
        System.out.println(limitedNumbers);  // Output: [1, 2, 3]
      
  16. skip

    • Functional Interface Used: None

    • Description: Skips the first n elements of the stream.

    • Explanation: The skip method takes an integer argument specifying the number of elements to skip and does not use a functional interface.

    • Example:

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
        List<Integer> skippedNumbers = numbers.stream()
                                              .skip(3)
                                              .collect(Collectors.toList());
        System.out.println(skippedNumbers);  // Output: [4, 5, 6]
      
  17. reduce

    • Functional Interface Used:BinaryOperator<T>

    • Description: Reduces the elements to a single result using a binary operator.

    • Explanation: The reduce method uses a BinaryOperator<T>, which takes two elements and combines them into one. It accumulates elements from the stream into a single result.

    • Example:

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        int sum = numbers.stream()
                         .reduce(0, (a, b) -> a + b);
        System.out.println(sum);  // Output: 15
      
  18. toArray

    • Functional Interface Used: None

    • Description: Converts the stream to an array.

    • Explanation: The toArray method converts the stream’s elements into an array. It does not use a functional interface, but provides methods to specify the array type.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        String[] namesArray = names.stream()
                                   .toArray(String[]::new);
        System.out.println(Arrays.toString(namesArray));  // Output: [Alice, Bob, Charlie]
      
  19. count

    • Functional Interface Used: None

    • Description: Counts the number of elements in the stream.

    • Explanation: The count method returns the number of elements in the stream as a long. It does not use a functional interface.

    • Example:

        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
        long count = names.stream()
                          .count();
        System.out.println(count);  // Output: 3
      
  20. max

    • Functional Interface Used:Comparator<T>

    • Description: Finds the maximum element according to a comparator.

    • Explanation: The max method uses a Comparator<T> to determine the maximum element in the stream.

    • Example:

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        Optional<Integer> max = numbers.stream()
                                       .max(Integer::compareTo);
        max.ifPresent(System.out::println);  // Output: 5
      
  21. min

    • Functional Interface Used:Comparator<T>

    • Description: Finds the minimum element according to a comparator.

    • Explanation: The min method uses a Comparator<T> to determine the minimum element in the stream.

    • Example:

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        Optional<Integer> min = numbers.stream()
                                       .min(Integer::compareTo);
        min.ifPresent(System.out::println);  // Output: 1
      

Summary

  • Methods like forEach, peek, and collect use functional interfaces such as Consumer<T> and Collector<T, A, R>.

  • Methods like filter, anyMatch, allMatch, and noneMatch use the Predicate<T> functional interface.

  • Methods like map and flatMap use the Function<T, R> interface.

  • Methods like reduce, max, and min use BinaryOperator<T> and Comparator<T> respectively.

  • Methods like limit, skip, distinct, findFirst, findAny, toArray, and count do not use a functional interface.

Method References

There are four types of method references:

  1. Reference to a static method

  2. Reference to an instance method of a particular object

  3. Reference to an instance method of an arbitrary object of a particular type

  4. Reference to a constructor

1. Reference to a Static Method

Syntax:ClassName::staticMethodName

Example:

import java.util.function.BiFunction;

public class StaticMethodReferenceExample {
    public static void main(String[] args) {
        // Creating a method reference to the static method 'add' of the 'MethodReferences' class
        BiFunction<Integer, Integer, Integer> adder = MethodReferences::add;

        // Using the method reference to call the 'add' method
        int result = adder.apply(10, 20);

        // Printing the result
        System.out.println("Sum: " + result);
    }
}

class MethodReferences {
    // Static method to add two integers
    public static int add(int a, int b) {
        return a + b;
    }
}

Explanation:

In this example, we have a static method add in the MethodReferences class that takes two integers and returns their sum. We create a method reference to this static method using MethodReferences::add and assign it to a BiFunction functional interface. The apply method of BiFunction calls the add method, resulting in the sum of 10 and 20 being printed.

2. Reference to an Instance Method of a Particular Object

Syntax:object::instanceMethodName

Example:

import java.util.function.Function;

public class InstanceMethodReferenceExample {
    public static void main(String[] args) {
        // Creating a string object
        String str = "Hello, World!";

        // Creating a method reference to the instance method 'toUpperCase' of the 'str' object
        Function<String, String> toUpperCase = str::toUpperCase;

        // Using the method reference to call the 'toUpperCase' method
        System.out.println(toUpperCase.apply(str));
    }
}

Explanation:

Here, we have a String object str with the value "Hello, World!". We create a method reference to the toUpperCase instance method of this str object using str::toUpperCase. This method reference is assigned to a Function functional interface. When we call the apply method of Function, it invokes the toUpperCase method on the str object, converting the string to uppercase.

3. Reference to an Instance Method of an Arbitrary Object of a Particular Type

Syntax:ClassName::instanceMethodName

Example:

import java.util.Arrays;
import java.util.List;

public class ArbitraryObjectMethodReferenceExample {
    public static void main(String[] args) {
        // Creating a list of names
        List<String> names = Arrays.asList("John", "Paul", "George", "Ringo");

        // Using method reference to the 'println' method of 'System.out' for each element of the list
        names.forEach(System.out::println);
    }
}

Explanation:

In this example, we have a list of names. We use the forEach method of the List interface, which accepts a Consumer functional interface. We provide a method reference System.out::println to the println method of the System.out object. This means that for each element in the list, the println method will be called, printing each name to the console.

4. Reference to a Constructor

Syntax:ClassName::new

Example:

import java.util.function.Supplier;

public class ConstructorReferenceExample {
    public static void main(String[] args) {
        // Creating a method reference to the 'Person' class constructor
        Supplier<Person> personSupplier = Person::new;

        // Using the method reference to create a new instance of 'Person'
        Person person = personSupplier.get();

        // Printing the person object
        System.out.println(person);
    }
}

class Person {
    String name;

    // Constructor initializing the name field
    public Person() {
        this.name = "John Doe";
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "'}";
    }
}

Explanation:

In this example, we have a Person class with a default constructor that initializes the name field. We create a method reference to this constructor using Person::new and assign it to a Supplier functional interface. The get method of Supplier calls the constructor, creating a new Person object. We then print this object, which invokes the toString method to display the name.

Additional Concepts

Method References and Functional Interfaces

Method references work seamlessly with functional interfaces, which are interfaces with a single abstract method. Java provides several functional interfaces in the java.util.function package, such as Function, BiFunction, Supplier, Consumer, and more. Method references can be used wherever these interfaces are expected, making the code more concise and readable.

Chaining Method References

You can chain method references to perform a sequence of operations. For example:

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;

public class ChainingMethodReferenceExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("john", "paul", "george", "ringo");

        // Using method reference chaining to process the names list
        names.stream()
             .map(String::toUpperCase)    // Convert each name to uppercase
             .forEach(System.out::println); // Print each name
    }
}

Explanation:

In this example, we use a stream to process the list of names. The map method converts each name to uppercase using the method reference String::toUpperCase. The forEach method then prints each name using the method reference System.out::println.

Overloaded Methods and Method References

When dealing with overloaded methods, it's important to ensure that the functional interface's method signature matches exactly with the method reference.

Example:

import java.util.function.BiFunction;
import java.util.function.Function;

public class OverloadedMethodsExample {
    public static void main(String[] args) {
        // Method reference to an overloaded static method
        BiFunction<String, String, Integer> compareStrings = OverloadedMethods::compare;
        System.out.println(compareStrings.apply("apple", "banana"));

        // Method reference to another overloaded static method
        Function<String, Integer> getStringLength = OverloadedMethods::compare;
        System.out.println(getStringLength.apply("apple"));
    }
}

class OverloadedMethods {
    // Overloaded static methods
    public static int compare(String a, String b) {
        return a.compareTo(b);
    }

    public static int compare(String a) {
        return a.length();
    }
}

Capturing Variables in Method References

Method references can capture variables from their enclosing scope, similar to lambda expressions.

Example:

import java.util.function.Function;

public class CapturingVariableExample {
    public static void main(String[] args) {
        String prefix = "Hello, ";

        // Method reference that captures a variable from the enclosing scope
        Function<String, String> greeter = prefix::concat;

        // Using the method reference
        System.out.println(greeter.apply("World!"));
    }
}

Using Method References with Custom Functional Interfaces

You can define your own functional interfaces and use method references with them.

Example:

public class CustomFunctionalInterfaceExample {
    public static void main(String[] args) {
        // Method reference to an instance method
        String str = "Custom functional interface example";
        MyFunctionalInterface<String> toUpperCase = str::toUpperCase;
        System.out.println(toUpperCase.apply());

        // Method reference to a static method
        MyBiFunction<Integer, Integer, Integer> adder = CustomFunctionalInterfaceExample::add;
        System.out.println(adder.apply(10, 20));
    }

    public static int add(int a, int b) {
        return a + b;
    }
}

// Custom functional interface with no parameters
@FunctionalInterface
interface MyFunctionalInterface<T> {
    T apply();
}

// Custom functional interface with two parameters
@FunctionalInterface
interface MyBiFunction<T, U, R> {
    R apply(T t, U u);
}

Type Inference with Method References

Java's type inference can often determine the appropriate types for method references, making the code cleaner.

Example:

import java.util.function.BiFunction;

public class TypeInferenceExample {
    public static void main(String[] args) {
        // The types of the parameters are inferred from the context
        BiFunction<String, String, String> concat = String::concat;
        System.out.println(concat.apply("Hello, ", "World!"));
    }
}

Method References in Streams and Collections

Method references are commonly used in conjunction with streams and collections to perform operations in a more concise manner.

Example:

import java.util.Arrays;
import java.util.List;

public class StreamMethodReferencesExample {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("hello", "world", "method", "references");

        // Using method references with streams
        words.stream()
             .map(String::toUpperCase)
             .filter(word -> word.startsWith("M"))
             .forEach(System.out::println);
    }
}

Explanation:

In this example, the stream method is used to create a stream from the list of words. The map method converts each word to uppercase using the method reference String::toUpperCase. The filter method filters the words starting with "M". The forEach method prints each word using the method reference System.out::println.

What is Optional?

In Java, a common problem is dealing with null values. If a variable is null and you try to use it, it will cause a NullPointerException. The Optional class helps to avoid this by providing a way to represent a value that might be missing.

Why Use Optional?

  • Avoid NullPointerException: Provides a safer alternative to returning null. Null checks can be error-prone and can lead to NullPointerException.

  • Expressive Code: Makes the presence or absence of a value explicit in the code. The intention behind an Optional return type is clear: it indicates that the value might be missing.

  • Functional Style: Allows for more functional programming constructs, enabling more readable and declarative code using methods like map(), flatMap(), filter(), etc.

Creating an Optional

There are three main ways to create an Optional object.

  1. Empty Optional:

     Optional<String> emptyOptional = Optional.empty();
     System.out.println(emptyOptional); // Output: Optional.empty
    
    • This creates an Optional that does not contain any value.
  2. Optional with a Non-Null Value:

     Optional<String> nonEmptyOptional = Optional.of("Hello, World!");
     System.out.println(nonEmptyOptional); // Output: Optional[Hello, World!]
    
    • This creates an Optional that contains the specified non-null value. If you pass null to Optional.of(), it will throw a NullPointerException.
  3. Optional with a Possibly Null Value:

     Optional<String> nullableOptional = Optional.ofNullable(null);
     System.out.println(nullableOptional); // Output: Optional.empty
    
    • This creates an Optional that can contain either a non-null value or null. If the value is null, it returns an empty Optional.

Checking for a Value

  1. isPresent():

     Optional<String> optional = Optional.ofNullable("Hello");
     if (optional.isPresent()) {
         System.out.println("Value is present: " + optional.get()); // Output: Value is present: Hello
     } else {
         System.out.println("Value is absent");
     }
    
    • This checks if a value is present. If it is, you can get the value using get(). Otherwise, it prints "Value is absent".
  2. ifPresent():

     optional.ifPresent(value -> System.out.println("Value is present: " + value)); // Output: Value is present: Hello
    
    • This does the same check but in a more functional style. It executes the given action (printing the value) if a value is present.

Retrieving the Value

  1. get():

     Optional<String> optional = Optional.of("Hello");
     String value = optional.get();
     System.out.println(value); // Output: Hello
    
    • This method returns the value if present. Be careful: if the Optional is empty, it throws NoSuchElementException.
  2. orElse():

     Optional<String> optional = Optional.ofNullable(null);
     String value = optional.orElse("Default Value");
     System.out.println(value); // Output: Default Value
    
    • This returns the value if present, otherwise returns the provided default value.
  3. orElseGet():

     Optional<String> optional = Optional.ofNullable(null);
     String value = optional.orElseGet(() -> "Default Value from Supplier");
     System.out.println(value); // Output: Default Value from Supplier
    
    • Similar to orElse(), but uses a Supplier to generate the default value. The Supplier is only called if the Optional is empty.
  4. orElseThrow():

     Optional<String> optional = Optional.ofNullable(null);
     try {
         String value = optional.orElseThrow(() -> new IllegalArgumentException("Value not present"));
     } catch (Exception e) {
         System.out.println(e.getMessage()); // Output: Value not present
     }
    
    • Returns the value if present, otherwise throws an exception created by the provided Supplier.

Transforming the Value

  1. map():

     Optional<String> optional = Optional.of("hello");
     Optional<String> upperCase = optional.map(String::toUpperCase);
     System.out.println(upperCase); // Output: Optional[HELLO]
    
    • This applies a function to the value if present, transforming it. If the Optional is empty, map() returns an empty Optional.
  2. flatMap():

     Optional<String> optional = Optional.of("hello");
     Optional<Integer> length = optional.flatMap(value -> Optional.of(value.length()));
     System.out.println(length); // Output: Optional[5]
    
    • Similar to map(), but the function itself returns an Optional. This "flattens" the result, avoiding nested Optional objects.

Filtering the Value

  1. filter():

     Optional<String> optional = Optional.of("hello");
     Optional<String> filtered = optional.filter(value -> value.startsWith("h"));
     System.out.println(filtered); // Output: Optional[hello]
    
     Optional<String> filteredAbsent = optional.filter(value -> value.startsWith("x"));
     System.out.println(filteredAbsent); // Output: Optional.empty
    
    • Filters the value if present, only keeping it if it matches the given predicate. Otherwise, returns an empty Optional.

Practical Examples

  1. UsingOptional in a Method:

     public Optional<String> getUserEmail(User user) {
         return Optional.ofNullable(user.getEmail());
     }
    
     // Assuming user.getEmail() returns "user@example.com"
     User user = new User("user@example.com");
     Optional<String> email = getUserEmail(user);
     System.out.println(email); // Output: Optional[user@example.com]
    
    • If the email is present, returns it wrapped in an Optional. If the email is null, returns an empty Optional.
  2. UsingOptional with Streams:

     List<String> list = Arrays.asList("apple", "banana", "cherry");
     Optional<String> firstWithB = list.stream()
                                       .filter(value -> value.startsWith("b"))
                                       .findFirst();
     System.out.println(firstWithB); // Output: Optional[banana]
    
    • Finds the first element in the list that starts with "b" and returns it wrapped in an Optional.
  3. AvoidingNullPointerException:

     String name = Optional.ofNullable(getName())
                           .map(String::toUpperCase)
                           .orElse("No Name Provided");
     System.out.println(name); // Output: No Name Provided (if getName() returns null) or JOHN (if getName() returns "john")
    
    • Transforms the name to uppercase if it is present. If the name is null, returns "No Name Provided".

Advanced Usage

  1. Optional Chaining:

     Optional<String> result = Optional.of("Java")
                                       .filter(value -> value.length() > 2)
                                       .map(String::toUpperCase);
     System.out.println(result); // Output: Optional[JAVA]
    
    • Chains multiple Optional operations to process the value conditionally and transform it.
  2. Combining Optionals:

     Optional<String> optional1 = Optional.of("Hello");
     Optional<String> optional2 = Optional.of("World");
    
     Optional<String> combined = optional1.flatMap(value1 -> 
         optional2.map(value2 -> value1 + " " + value2));
     System.out.println(combined); // Output: Optional[Hello World]
    
    • Combines two Optional values by using flatMap() and map().

Utility Methods

  1. equals() and hashCode():

     Optional<String> optional1 = Optional.of("value");
     Optional<String> optional2 = Optional.of("value");
     System.out.println(optional1.equals(optional2)); // Output: true
     System.out.println(optional1.hashCode() == optional2.hashCode()); // Output: true
    
    • Compares Optional objects and provides hash codes, ensuring they can be used in collections.
  2. toString():

     Optional<String> optional = Optional.of("Hello");
     System.out.println(optional.toString()); // Output: Optional[Hello]
    
    • Provides a human-readable string representation of the Optional instance.

Summary

  • Creation: Use Optional.empty(), Optional.of(), or Optional.ofNullable() to create an Optional.

  • Checking: Use isPresent() or ifPresent() to check if a value is present.

  • Retrieving: Use get(), orElse(), orElseGet(), or orElseThrow() to retrieve the value.

  • Transforming: Use map() or flatMap() to transform the value.

  • Filtering: Use filter() to conditionally keep the value.

Key Classes in the New Date and Time API

  1. LocalDate

  2. LocalTime

  3. LocalDateTime

  4. ZonedDateTime

  5. Period

  6. Duration

  7. Instant

  8. DateTimeFormatter

LocalDate

LocalDate represents a date without a time-zone in the ISO-8601 calendar system (e.g., 2007-12-03).

Creating LocalDate

  1. Current Date:

     LocalDate currentDate = LocalDate.now();
     System.out.println(currentDate); // Output: Current date (e.g., 2024-07-18)
    
    • Creates a LocalDate representing the current date.
  2. Specific Date:

     LocalDate specificDate = LocalDate.of(2020, Month.JANUARY, 1);
     System.out.println(specificDate); // Output: 2020-01-01
    
    • Creates a LocalDate representing January 1, 2020.
  3. Parsing a Date:

     LocalDate parsedDate = LocalDate.parse("2020-01-01");
     System.out.println(parsedDate); // Output: 2020-01-01
    
    • Parses a String to create a LocalDate.

Manipulating LocalDate

  1. Add/Subtract Days, Months, Years:

     LocalDate date = LocalDate.of(2020, Month.JANUARY, 1);
     LocalDate nextWeek = date.plusWeeks(1);
     LocalDate previousMonth = date.minusMonths(1);
     System.out.println(nextWeek); // Output: 2020-01-08
     System.out.println(previousMonth); // Output: 2019-12-01
    
  2. Get Day of Week/Month/Year:

     DayOfWeek dayOfWeek = date.getDayOfWeek();
     int dayOfMonth = date.getDayOfMonth();
     int year = date.getYear();
     System.out.println(dayOfWeek); // Output: WEDNESDAY
     System.out.println(dayOfMonth); // Output: 1
     System.out.println(year); // Output: 2020
    

LocalTime

LocalTime represents a time without a time-zone in the ISO-8601 calendar system (e.g., 10:15:30).

Creating LocalTime

  1. Current Time:

     LocalTime currentTime = LocalTime.now();
     System.out.println(currentTime); // Output: Current time (e.g., 10:15:30)
    
  2. Specific Time:

     LocalTime specificTime = LocalTime.of(10, 15, 30);
     System.out.println(specificTime); // Output: 10:15:30
    
  3. Parsing a Time:

     LocalTime parsedTime = LocalTime.parse("10:15:30");
     System.out.println(parsedTime); // Output: 10:15:30
    

Manipulating LocalTime

  1. Add/Subtract Hours, Minutes, Seconds:

     LocalTime time = LocalTime.of(10, 15, 30);
     LocalTime nextHour = time.plusHours(1);
     LocalTime previousMinute = time.minusMinutes(1);
     System.out.println(nextHour); // Output: 11:15:30
     System.out.println(previousMinute); // Output: 10:14:30
    
  2. Get Hour, Minute, Second:

     int hour = time.getHour();
     int minute = time.getMinute();
     int second = time.getSecond();
     System.out.println(hour); // Output: 10
     System.out.println(minute); // Output: 15
     System.out.println(second); // Output: 30
    

LocalDateTime

LocalDateTime combines date and time, but still without a time-zone (e.g., 2007-12-03T10:15:30).

Creating LocalDateTime

  1. Current Date and Time:

     LocalDateTime currentDateTime = LocalDateTime.now();
     System.out.println(currentDateTime); // Output: Current date and time (e.g., 2024-07-18T10:15:30)
    
  2. Specific Date and Time:

     LocalDateTime specificDateTime = LocalDateTime.of(2020, Month.JANUARY, 1, 10, 15, 30);
     System.out.println(specificDateTime); // Output: 2020-01-01T10:15:30
    
  3. Parsing a Date and Time:

     LocalDateTime parsedDateTime = LocalDateTime.parse("2020-01-01T10:15:30");
     System.out.println(parsedDateTime); // Output: 2020-01-01T10:15:30
    

ZonedDateTime

ZonedDateTime represents a date and time with a time-zone (e.g., 2007-12-03T10:15:30+01:00[Europe/Paris]).

Creating ZonedDateTime

  1. Current Date and Time with Zone:

     ZonedDateTime currentZonedDateTime = ZonedDateTime.now();
     System.out.println(currentZonedDateTime); // Output: Current date and time with time-zone
    
  2. Specific Date and Time with Zone:

     ZonedDateTime specificZonedDateTime = ZonedDateTime.of(2020, Month.JANUARY, 1, 10, 15, 30, 0, ZoneId.of("Europe/Paris"));
     System.out.println(specificZonedDateTime); // Output: 2020-01-01T10:15:30+01:00[Europe/Paris]
    
  3. Parsing a Date and Time with Zone:

     ZonedDateTime parsedZonedDateTime = ZonedDateTime.parse("2020-01-01T10:15:30+01:00[Europe/Paris]");
     System.out.println(parsedZonedDateTime); // Output: 2020-01-01T10:15:30+01:00[Europe/Paris]
    

Period

Period represents a quantity of time in terms of years, months, and days (e.g., "2 years, 3 months and 4 days").

Creating Period

  1. Between Two Dates:

     LocalDate startDate = LocalDate.of(2020, Month.JANUARY, 1);
     LocalDate endDate = LocalDate.of(2022, Month.APRIL, 1);
     Period period = Period.between(startDate, endDate);
     System.out.println(period); // Output: P2Y3M
    
  2. Specific Period:

     Period twoYearsThreeMonths = Period.of(2, 3, 0);
     System.out.println(twoYearsThreeMonths); // Output: P2Y3M
    

Duration

Duration represents a quantity of time in terms of seconds and nanoseconds (e.g., "34.5 seconds").

Creating Duration

  1. Between Two Times:

     LocalTime startTime = LocalTime.of(10, 15, 30);
     LocalTime endTime = LocalTime.of(12, 15, 30);
     Duration duration = Duration.between(startTime, endTime);
     System.out.println(duration); // Output: PT2H
    
  2. Specific Duration:

     Duration twoHours = Duration.ofHours(2);
     System.out.println(twoHours); // Output: PT2H
    

Instant

Instant represents a point in time (e.g., "1970-01-01T00:00:00Z").

Creating Instant

  1. Current Instant:

     Instant currentInstant = Instant.now();
     System.out.println(currentInstant); // Output: Current timestamp (e.g., 2024-07-18T08:15:30.00Z)
    
  2. Specific Instant:

     Instant specificInstant = Instant.ofEpochSecond(1629453600);
     System.out.println(specificInstant); // Output: 2021-08-20T00:00:00Z
    

DateTimeFormatter

DateTimeFormatter is used to format and parse date-time objects.

Formatting and Parsing

  1. Formatting:

     LocalDate date = LocalDate.now();
     DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
     String formattedDate = date.format(formatter);
     System.out.println(formattedDate); // Output: 18/07/2024
    
  2. Parsing:

     String dateString = "18/07/2024";
     DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
     LocalDate parsedDate = LocalDate.parse(dateString, formatter);
     System.out.println(parsedDate); // Output: 2024-07-18
    

Summary

  • LocalDate, LocalTime, LocalDateTime: Used to represent date, time, and both date and time without a time-zone.

  • ZonedDateTime: Represents date and time with a time-zone.

  • Period, Duration: Used to represent a quantity of time in different units.

  • Instant: Represents a point in time.

  • DateTimeFormatter: Used for formatting and parsing date-time objects.

Default Methods?

Default methods are methods defined in interfaces with a default keyword. They allow interfaces to have method implementations, not just method declarations. This feature was introduced in Java 8 to facilitate backward compatibility while enabling the addition of new methods to interfaces.

Why Use Default Methods?

  1. Maintain Backward Compatibility:

    • You can add new methods to interfaces without breaking existing implementations.
  2. Support Multiple Inheritance:

    • Allows interfaces to provide method implementations, enabling classes to inherit behavior from multiple interfaces.
  3. Reduce Boilerplate Code:

    • Promotes code reuse by providing default implementations of methods that can be shared across multiple classes.

Syntax of Default Methods

interface MyInterface {
    // Abstract method
    void abstractMethod();

    // Default method
    default void defaultMethod() {
        System.out.println("This is a default method.");
    }
}

Example: Basic Usage of Default Methods

Let's start with a basic example to understand how default methods work.

Defining an Interface with Default Method

interface Vehicle {
    void start();  // Abstract method

    default void stop() {
        System.out.println("Vehicle is stopping.");
    }
}

Here, Vehicle has an abstract method start() and a default method stop().

Implementing the Interface

class Car implements Vehicle {
    @Override
    public void start() {
        System.out.println("Car is starting.");
    }
}

class Bike implements Vehicle {
    @Override
    public void start() {
        System.out.println("Bike is starting.");
    }
}
  • Car and Bike classes implement the Vehicle interface and provide their implementations for the start method. They inherit the default stop method.

Using the Default Method

public class Main {
    public static void main(String[] args) {
        Vehicle car = new Car();
        car.start();  // Output: Car is starting.
        car.stop();   // Output: Vehicle is stopping.

        Vehicle bike = new Bike();
        bike.start(); // Output: Bike is starting.
        bike.stop();  // Output: Vehicle is stopping.
    }
}

Overriding Default Methods

If a class needs a specific implementation of a default method, it can override the default method.

class Car implements Vehicle {
    @Override
    public void start() {
        System.out.println("Car is starting.");
    }

    @Override
    public void stop() {
        System.out.println("Car is stopping quickly.");
    }
}

Now, the Car class provides its own implementation of stop.

Testing the Override

public class Main {
    public static void main(String[] args) {
        Vehicle car = new Car();
        car.start();  // Output: Car is starting.
        car.stop();   // Output: Car is stopping quickly.
    }
}

Multiple Interfaces with Default Methods

Java 8 supports multiple inheritance of behavior through interfaces. Default methods in interfaces can be used together.

Two Interfaces with Default Methods

interface Engine {
    default void start() {
        System.out.println("Engine is starting.");
    }
}

interface Vehicle {
    default void start() {
        System.out.println("Vehicle is starting.");
    }
}
  • Engine and Vehicle both have a default start method.

Implementing Both Interfaces

class Car implements Vehicle, Engine {
    @Override
    public void start() {
        Vehicle.super.start();
        Engine.super.start();
    }
}
  • The Car class overrides start to call both Vehicle and Engine default methods.

Testing Multiple Interface Inheritance

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.start();
        // Output: 
        // Vehicle is starting.
        // Engine is starting.
    }
}

Resolving Conflicts

If two interfaces have conflicting default methods, the implementing class must resolve the conflict explicitly.

Interfaces with Conflicting Default Methods

interface Vehicle {
    default void start() {
        System.out.println("Vehicle is starting.");
    }
}

interface Engine {
    default void start() {
        System.out.println("Engine is starting.");
    }
}

Implementing with Conflict Resolution

class Car implements Vehicle, Engine {
    @Override
    public void start() {
        Vehicle.super.start();
        Engine.super.start();
    }
}
  • The Car class overrides start and explicitly calls Vehicle.super.start() and Engine.super.start().

Testing Conflict Resolution

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.start();
        // Output:
        // Vehicle is starting.
        // Engine is starting.
    }
}

Practical Use Case: Adding Methods to Interfaces

Default methods are particularly useful for evolving APIs without breaking existing code.

Initial Interface

interface Drawable {
    void draw();
}

Adding a Default Method in Java 8

interface Drawable {
    void draw();

    default void resize() {
        System.out.println("Resizing the drawable.");
    }
}

Implementing the Interface

class Circle implements Drawable {
    @Override
    public void draw() {
        System.out.println("Drawing a circle.");
    }
}

class Rectangle implements Drawable {
    @Override
    public void draw() {
        System.out.println("Drawing a rectangle.");
    }
}

Using the Default Method

public class Main {
    public static void main(String[] args) {
        Drawable circle = new Circle();
        circle.draw();   // Output: Drawing a circle.
        circle.resize(); // Output: Resizing the drawable.

        Drawable rectangle = new Rectangle();
        rectangle.draw();   // Output: Drawing a rectangle.
        rectangle.resize(); // Output: Resizing the drawable.
    }
}

Advanced Features of Default Methods

  1. Static Methods in Interfaces

    • Interfaces can have static methods, which are not inherited by implementing classes.
    interface MathOperations {
        static int add(int a, int b) {
            return a + b;
        }
    }
    public class Main {
        public static void main(String[] args) {
            int sum = MathOperations.add(5, 10);
            System.out.println(sum); // Output: 15
        }
    }
  1. Default Methods with Lambda Expressions

    • Default methods can be overridden with lambda expressions in Java 8.
    interface Greeting {
        default void sayHello() {
            System.out.println("Hello, World!");
        }
    }

    public class Main {
        public static void main(String[] args) {
            Greeting greeting = () -> System.out.println("Hello from Lambda!");
            greeting.sayHello();
        }
    }

Comparing Java 7 and Java 8 Interfaces

FeatureJava 7Java 8
Abstract MethodsYesYes
Default MethodsNoYes
Static MethodsNoYes
Backward CompatibilityChallenging (Adding methods breaks implementations)Easy (Adding default methods doesn't break implementations)
Multiple Inheritance of BehaviorNot possiblePossible through default methods
Code ReuseLimitedEnhanced with default methods
Lambda ExpressionsNot applicableCan be used to implement default methods

Summary

  • Default Methods: These are methods in interfaces with the default keyword, providing a default implementation.

  • Backward Compatibility: You can add new methods to interfaces without breaking the existing code that implements the interface.

  • Multiple Inheritance: Classes can inherit behavior from multiple interfaces, making code more flexible and reusable.

  • Conflict Resolution: If two interfaces have conflicting default methods, the implementing class must resolve the conflict explicitly.

  • Static Methods: Interfaces can also contain static methods, which are not inherited by implementing classes.

  • Lambda Expressions: You can override default methods using lambda expressions for concise and readable code.

Nashorn JavaScript Engine

Nashorn is a JavaScript engine developed by Oracle, which is included in the Java Development Kit (JDK) starting from Java 8. It allows developers to execute JavaScript code from within Java applications. Nashorn provides improved performance and compatibility with the ECMAScript 5.1 specification.

Key Features of Nashorn

  1. ECMAScript Compliance: Nashorn is designed to comply with the ECMAScript 5.1 specification, providing a modern JavaScript runtime.

  2. Integration with Java: It allows for tight integration between Java and JavaScript, enabling developers to call Java code from JavaScript and vice versa.

  3. Improved Performance: Nashorn uses the invokedynamic feature of the JVM to achieve better performance compared to its predecessor, Rhino.

  4. JavaScript Shell: It provides a command-line tool (jjs) for executing JavaScript code.

Using Nashorn

Executing JavaScript from Java

To execute JavaScript code within a Java application, you can use the javax.script package. The key classes involved are ScriptEngineManager and ScriptEngine.

Example: Basic JavaScript Execution
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

public class NashornExample {
    public static void main(String[] args) {
        // Create a ScriptEngineManager
        ScriptEngineManager manager = new ScriptEngineManager();

        // Get a Nashorn ScriptEngine
        ScriptEngine engine = manager.getEngineByName("nashorn");

        // Evaluate JavaScript code
        try {
            engine.eval("print('Hello, Nashorn');"); // Output: Hello, Nashorn
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Invoking Java Methods from JavaScript

Nashorn allows JavaScript code to call Java methods and access Java objects.

Example: Calling Java Methods
javaCopy codeimport javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

public class NashornExample {
    public static void main(String[] args) {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("nashorn");

        try {
            engine.eval("var javaString = 'Hello from JavaScript';");
            engine.eval("print(javaString);"); // Output: Hello from JavaScript
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Accessing JavaScript Variables from Java

You can also access and manipulate JavaScript variables from Java.

Example: Accessing JavaScript Variables
import javax.script.*;

public class NashornExample {
    public static void main(String[] args) {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("nashorn");

        try {
            engine.eval("var number = 10;");
            engine.eval("var greeting = 'Hello, Nashorn';");

            // Access JavaScript variables from Java
            int number = (int) engine.get("number");
            String greeting = (String) engine.get("greeting");

            System.out.println("Number: " + number); // Output: Number: 10
            System.out.println("Greeting: " + greeting); // Output: Greeting: Hello, Nashorn
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Interoperability between Java and JavaScript

Nashorn makes it easy to work with Java objects in JavaScript and vice versa.

Example: Using Java Collections in JavaScript

import javax.script.*;
import java.util.*;

public class NashornExample {
    public static void main(String[] args) {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("nashorn");

        List<String> list = new ArrayList<>();
        list.add("Apple");
        list.add("Banana");
        list.add("Cherry");

        engine.put("list", list);

        try {
            engine.eval("list.forEach(function(item) { print(item); });");
            // Output:
            // Apple
            // Banana
            // Cherry
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Working with JSON

Nashorn makes it simple to parse and generate JSON data.

Example: Parsing JSON

import javax.script.*;

public class NashornExample {
    public static void main(String[] args) {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("nashorn");

        String jsonString = "{ \"name\": \"John\", \"age\": 30 }";
        engine.put("jsonString", jsonString);

        try {
            engine.eval("var obj = JSON.parse(jsonString);");
            engine.eval("print('Name: ' + obj.name);"); // Output: Name: John
            engine.eval("print('Age: ' + obj.age);"); // Output: Age: 30
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Example: Generating JSON

import javax.script.*;

public class NashornExample {
    public static void main(String[] args) {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("nashorn");

        try {
            engine.eval("var obj = { name: 'John', age: 30 };");
            engine.eval("var jsonString = JSON.stringify(obj);");
            String jsonString = (String) engine.get("jsonString");
            System.out.println(jsonString); // Output: {"name":"John","age":30}
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}

Using JavaScript Libraries

Nashorn can load and use JavaScript libraries, allowing for more complex scripting within Java applications.

Example: Loading and Using a JavaScript Library

import javax.script.*;

public class NashornExample {
    public static void main(String[] args) {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("nashorn");

        try {
            engine.eval(new java.io.FileReader("path/to/library.js"));
            engine.eval("var result = libraryFunction();");
            System.out.println(engine.get("result"));
        } catch (ScriptException | java.io.FileNotFoundException e) {
            e.printStackTrace();
        }
    }
}

JavaScript Shell (jjs)

The jjs tool is a command-line utility for executing JavaScript code using the Nashorn engine. This tool can be useful for testing and quick scripting tasks.

Example: Running jjs

$ jjs
jjs> print('Hello, Nashorn');
Hello, Nashorn

You can also execute JavaScript files directly using jjs.

$ jjs script.js

Nashorn Limitations

While Nashorn is powerful, it has some limitations:

  1. Limited ECMAScript Support: Nashorn primarily supports ECMAScript 5.1, with some features from ECMAScript 6.

  2. Performance: While faster than Rhino, Nashorn may still be slower than native Java code for some tasks.

  3. Deprecation: Starting from JDK 11, Nashorn is deprecated, and it was removed in JDK 15.

Summary

  • Nashorn is a JavaScript engine introduced in Java 8 for executing JavaScript code within Java applications.

  • Key Features: ECMAScript 5.1 compliance, integration with Java, improved performance, and a JavaScript shell (jjs).

  • Usage: Execute JavaScript code, call Java methods from JavaScript, access JavaScript variables from Java, and work with JSON.

  • Interoperability: Allows seamless interaction between Java and JavaScript.

  • Limitations: Limited ECMAScript support, performance considerations, and eventual deprecation in later Java versions.

Why CompletableFuture?

CompletableFuture is introduced to help write asynchronous, non-blocking code. Asynchronous programming allows tasks to run in the background, so the main program can continue executing without waiting for these tasks to complete. This is useful for tasks like web requests, database queries, or any other long-running operations.

Benefits of CompletableFuture:

  1. Non-blocking: Main thread is not blocked while waiting for the task to complete.

  2. Callback-based: You can specify actions to take once the task completes.

  3. Flexible and Composable: Easily combine multiple asynchronous tasks.

How to Create a CompletableFuture

You can create a CompletableFuture using different methods. Let's go through them step by step.

1. Basic Creation

You can create an incomplete CompletableFuture and complete it manually.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        // Create an incomplete CompletableFuture
        CompletableFuture<String> future = new CompletableFuture<>();

        // Complete the future manually
        future.complete("Hello, World!");

        // Retrieve the result
        System.out.println(future.join()); // Output: Hello, World!
    }
}

2. Using Factory Methods

CompletableFuture provides factory methods to create futures that are already completed or run tasks asynchronously.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        // Completed future with a value
        CompletableFuture<String> completedFuture = CompletableFuture.completedFuture("Completed");
        System.out.println(completedFuture.join()); // Output: Completed

        // Run an asynchronous task
        CompletableFuture<Void> asyncFuture = CompletableFuture.runAsync(() -> {
            System.out.println("Running asynchronously");
        });

        asyncFuture.join(); // Wait for the task to complete
    }
}

Asynchronous Computations

You can run tasks asynchronously using runAsync() and supplyAsync().

runAsync()

Executes a Runnable task asynchronously (a task that does not return a value).

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            System.out.println("Task executed asynchronously");
        });

        future.join(); // Wait for the task to complete
    }
}

supplyAsync()

Executes a Supplier task asynchronously (a task that returns a value).

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            return "Result of the asynchronous computation";
        });

        System.out.println(future.join()); // Output: Result of the asynchronous computation
    }
}

Chaining Tasks

You can chain multiple tasks together using methods like thenApply(), thenAccept(), thenRun(), and thenCombine().

thenApply()

Transforms the result of a future.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello")
            .thenApply(result -> result + ", World!");

        System.out.println(future.join()); // Output: Hello, World!
    }
}

thenAccept()

Consumes the result of a future without returning a new result.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> "Hello")
            .thenAccept(result -> System.out.println("Result: " + result)); // Output: Result: Hello
    }
}

thenRun()

Runs a Runnable task after the future completes.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> "Hello")
            .thenRun(() -> System.out.println("Computation finished")); // Output: Computation finished
    }
}

thenCombine()

Combines the results of two futures.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");

        CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + " " + result2);

        System.out.println(combinedFuture.join()); // Output: Hello World
    }
}

Exception Handling

You can handle exceptions in CompletableFuture using methods like exceptionally(), handle(), and whenComplete().

exceptionally()

Handles exceptions and provides a default value.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            if (true) {
                throw new RuntimeException("Exception occurred");
            }
            return "Result";
        }).exceptionally(ex -> "Default Value");

        System.out.println(future.join()); // Output: Default Value
    }
}

handle()

Handles both result and exception.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            if (true) {
                throw new RuntimeException("Exception occurred");
            }
            return "Result";
        }).handle((result, ex) -> {
            if (ex != null) {
                return "Default Value";
            }
            return result;
        });

        System.out.println(future.join()); // Output: Default Value
    }
}

whenComplete()

Runs a callback after the future completes, handling both success and failure.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Result")
            .whenComplete((result, ex) -> {
                if (ex != null) {
                    System.out.println("Exception: " + ex);
                } else {
                    System.out.println("Result: " + result); // Output: Result: Result
                }
            });

        future.join();
    }
}

Combining Multiple Futures

allOf()

Waits for all futures to complete.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");

        CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(future1, future2);

        combinedFuture.join(); // Wait for all to complete
        System.out.println(future1.join() + " " + future2.join()); // Output: Hello World
    }
}

anyOf()

Returns when any one of the futures completes.

import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Result 1";
        });

        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Result 2");

        CompletableFuture<Object> firstFinished = CompletableFuture.anyOf(future1, future2);

        System.out.println(firstFinished.join()); // Output: Result 2
    }
}

Summary

  • Asynchronous Execution: Use runAsync() and supplyAsync() to run tasks in the background.

  • Chaining Tasks: Link tasks using methods like thenApply(), thenAccept(), thenRun(), and thenCombine().

  • Exception Handling: Manage errors with exceptionally(), handle(), and whenComplete().

  • Combining Futures: Use allOf() to wait for all tasks and anyOf() to get the result of the first completed task.

CompletableFuture makes it easier to write asynchronous code in Java, allowing you to handle background tasks more efficiently and cleanly.

Conclusion

Java 8 brings a wealth of new features that greatly enhance the language's capabilities. With the introduction of lambda expressions, the Stream API, default methods, and CompletableFuture, developers can write code that is more concise, readable, and efficient. The Nashorn JavaScript engine further expands Java's functionality by enabling seamless integration with JavaScript.

These features not only modernize the language but also ensure backward compatibility, making Java 8 a crucial update for both new and existing projects. By mastering these enhancements, developers can boost their productivity and create more robust applications.

As you delve deeper into these features, you'll discover that Java 8 provides a more versatile and powerful toolkit for addressing a wide array of programming challenges. Enjoy your journey with Java 8!......

0
Subscribe to my newsletter

Read articles from Bikash Nishank directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Bikash Nishank
Bikash Nishank