A Beginner's Guide to Core Java 8 Features

Glimpse in the History:

Throughout Java's history, from JDK 1.0 released in 1996 to J2SE 1.4 released in 2002, the primary focus was building APIs. However, between Java 5.0 (also known as Java 1.5) and Java 8 (or Java 1.8), the focus shifted to the programming level itself, with the clear intention of achieving more functionality with less code.

Introduction:

Java 8 introduces several significant changes to interfaces, including features like default methods, static methods, and support for functional programming with lambda expressions. While these interface enhancements are notable, other key features in Java 8 focus on broader areas of core Java, such as the Stream API, method references, and the Date & Time API.

Java 8: A Turning Point in Addressing Interface Limitations

Before delving into individual Java 8 features, let's explore why they mark a turning point and how they address challenges in Java 7.

In Java 7, interfaces could only contain abstract methods, which all implementing classes had to define. Otherwise, the class had to be abstract. Consider an interface named Security with abstract methods patternLock and secretKeyLock, implemented by classes Version1 and Version2.

public interface Security{
    abstract void patternLock();
    abstract void secretKeyLock();
}
public class Version1 implements Security{

    @Override
    public void patternLock() {
        // TODO Auto-generated method stub
        // Some logic related to patternLock 
        System.out.println("Some patternLock logic");
    }

    @Override
    public void secretKeyLock() {
        // TODO Auto-generated method stub
        // Some logic related to secretKeyLock
        System.out.println("Some Logic related to secretKeyLock");
    }

}
public class Version2 implements Security {

    @Override
    public void patternLock() {
        // TODO Auto-generated method stub
        // Some logic related to patternLock
        System.out.println("Some patternLock logic");
    }

    @Override
    public void secretKeyLock() {
        // TODO Auto-generated method stub
        // Some logic related to secretKeyLock
        System.out.println("Some Logic related to secretKeyLock");
    }

}

When you later decide to add a mandatory fingerPrint method, all existing classes would throw an error.

public interface Security{
    abstract void patternLock();
    abstract void secretKeyLock();
    abstract void fingerPrint();
}

Java 7 offered limited solutions. Either you'd define the method in all implementing classes, or declare it abstract and make the class itself abstract, preventing object creation.

Introducing Default Method: Flexibility in Interface Evolution

Java 8 introduces default methods as a powerful solution to address the limitations of interfaces in Java 7. Default methods are methods within interfaces that have a concrete implementation. This means they provide a default behavior that implementing classes can inherit without being forced to override.

Here's a key advantage of default methods:

  • Evolving interfaces without breaking existing code: You can add new methods to interfaces without requiring all implementing classes to implement them immediately. This allows for seamless interface enhancements without disrupting existing codebases. The keyword default is must be used while defining the method.

    Here's the syntax for a default method:

      interface Demo {
          default void test() {
              System.out.println("A default method in interface");
          }
      }
    

    In this example, any class that implements interface Demo will automatically inherit the test() method and its default behavior, unless it explicitly overrides it.

Introducing Static Methods in Interfaces: Utility Without Objects

Java 8 extends interfaces' capabilities by allowing them to house static methods. Static methods in interfaces are utility methods that aren't tied to any specific object instance. They belong to the interface itself, and you can call them directly using the interface name, without the need to create an object.

Key characteristics:

  • Object independence: Static methods don't operate on object state, making them ideal for general utility functions that don't require object-specific data.

  • Direct invocation: Call static interface methods using the interface name, not through implementing classes or objects.

  • Compile-time error prevention: Attempting to call a static interface method using a class name or object will result in a compile-time error.

Syntax example:

interface Demo {
    public static void display() {
        System.out.println("Interface static Method");
    }
}
public class Main implements Demo{
    public static void main(String[] args){
        Main obj = new Main();
        obj.display(); //  These will cause compile-time errors:
        Main.display(); //  These will cause compile-time errors:

        Demo.display(); //The correct way to call
    }
}

Fun fact: Since Java 8 introduced static methods in interfaces, you can now declare the main method within an interface. However, it's important to note that interfaces cannot execute code directly, so the main method would need to be implemented in a separate class that implements the interface.

Functional Interfaces: More Than Just a Name

While the term "functional interface" was introduced in Java 8, the concept itself existed earlier. Essentially, a functional interface is an interface with only one abstract method. This allows us to treat it as a function type, enabling powerful features like lambda expressions and method references.

Before Java 8, several existing interfaces effectively functioned as functional interfaces, even though they weren't explicitly named as such. Examples include Comparable, Runnable, and Callable.

Here are some key points to remember about functional interfaces:

  1. Single Abstract Method: They must have exactly one abstract method.

  2. Default and Static Methods Allowed: They can have multiple default and static methods in addition to the single abstract method.

  3. Automatic Detection: Even without the @FunctionalInterface annotation, the JVM typically recognizes interfaces with a single abstract method as functional.

     //This will be recognize as functional interface by JVM
     interface Demo{
         abstract void displayMessage();
     }
    
  4. Inheritance Rules:

    • Interfaces with no abstract methods extending FunctionalInterface are still considered functional.

        @FunctionalInterface
        interface Demo{
            abstract void message();
        }
      
        interface Main extends Demo{} 
        //The above interface Main will be considered as Functional interface by JVM
      
    • Interfaces with multiple abstract methods, even if extending FunctionalInterface, are not considered functional interface.

        @FunctionalInterface
        interface Demo{
            abstract void display();
        }
      
        interface Main extends Demo{
            abstract void message();
        } //This will not be considered as FunctionalInterface
      

By understanding these rules, you can effectively leverage functional interfaces to write concise and expressive code in Java 8 and beyond.

In Java 8, the java.util.function package provides several predefined functional interfaces. These interfaces are categorized into four main groups, each serving a specific purpose:

  1. Supplier: This interface is used to represent a supplier of a value. It has a single abstract method get() that returns a value of the specified type.

     @FunctionalInterface
     public interface Supplier<T>{
         T get();
     }
    
  2. Consumer: This interface represents an operation that takes an argument but doesn't return a value. It has a single abstract method accept(T t) that consumes an argument of type T.

     @FunctionalInterface
     public interface Consumer<T>{
         void accept(T t);
     }
    
  3. Predicate: This interface represents a test that evaluates an argument of type T and returns a boolean value. It has a single abstract method test(T t) that returns true if the predicate holds for the argument, and false otherwise.

     @FunctionalInterface
     public interface Predicate<T>{
         boolean test(T t);
     }
    
  4. Function: This interface represents a function that takes an argument of type T and returns a value of type R. It has a single abstract method apply(T t) that performs the function on the argument and returns the result.

     @FunctionalInterface
     public interface Function<T, R>{
         R apply(T t);
     }
    

Introducing Lambdas: Tiny Functions with Big Benefits

Lambda expressions are anonymous functions introduced in Java 8 that offer a concise and elegant way to write short blocks of code. They lack a formal name, access modifiers, and sometimes a return type, focusing solely on the function's logic.

Understanding Lambdas Through Examples:

Normal Code:

public void display() {
  System.out.println("Hello World");
}

Lambda Equivalent:

() -> System.out.println("Hello World");

Notice how the lambda eliminates the method name, modifiers, and even the return type in this simple case.

Another Example:

Normal Code:

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

Lambda Equivalent:

(int a, int b) -> {
  return a + b;
}

For one-line expressions, curly braces{} and return type declarations become optional. Additionally, omitting parameter data types defines the lambda as a "type inference" lambda, where the compiler deduces the types based on context.

Lambda Equivalent:

(a,b) -> a+b;

Lambda Connection to Functional Interfaces:

Lambdas can only be assigned to and executed through an interface with exactly one abstract method, known as a functional interface. This ensures compatibility between the lambda's functionality and the interface's expected behavior.

Code Example:

interface Calculator {
  public int add(int a, int b);
}

class Main {
  public static void main(String[] args) {
    Calculator c = (a, b) -> a + b;
    System.out.println(c.add(1, 2));
  }
}

Here, the lambda is assigned to the Calculator interface variable and invoked like any other method.

Remember:

  • Lambdas offer a compact way to write functions within interfaces.

  • They require a functional interface for execution.

  • Lambdas can omit types and braces for simple expressions.

Method References: Pointing to Methods with Ease

Java 8 introduces method references, a concise way to refer to existing methods using their names. They offer a more streamlined syntax compared to lambda expressions, especially when you're simply calling a method without any modifications.

Format: Method references are denoted using the :: symbol, followed by the method name or constructor name.

Types of Method References:

  1. Reference to a Static Method:

    • Refers to a static method within a class.

    • Syntax: ClassName::staticMethodName

  2. Reference to an Instance Method:

    • Refers to an instance method of an object.

    • Syntax: containingObject::instanceMethodName

  3. Reference to a Constructor:

    • Refers to a constructor of a class.

    • Syntax: ClassName::new

Key Points:

  • Method references provide a compact alternative to lambda expressions when you're directly calling a method without additional logic.

  • They offer different types to accommodate various method calls and object creation scenarios.

  • By understanding method references, you can write cleaner and more expressive code in Java 8 and beyond.

Clarifying Stream Types: java.io.stream vs. java.util.stream

Before diving into streams, it's essential to distinguish between two often confused concepts: java.io.stream and java.util.stream. While they share similarities in their names, they serve entirely different purposes.

Key Differences:

  • Function:

    • java.io.stream (Input/Output Stream): Handles byte-level reading and writing of data, primarily used for files and network connections.

    • java.util.stream (Stream): Designed for processing data structures like arrays and collections, allowing operations like filtering, mapping, and more.

  • Data Type:

    • java.io.stream works with bytes.

    • java.util.stream operates on objects of a specific type (T).

Understanding Streams:

Streams offer a powerful approach to manipulate collections efficiently. Unlike traditional loops, streams don't directly modify the original data. Instead, they create a temporary sequence of elements for processing, enabling various operations without affecting the underlying collection.

Stream Operations:

Streams provide two main stages:

  1. Configuration: Here, you define how to process the stream elements. Common methods include:

    • filter(Predicate<T> p): Filters elements based on a condition.

    • map(Function<T, R>): Transforms each element into a new object using a mapping function.

  2. Processing: Once configured, you can perform various operations on the stream, such as:

    • collect(): Aggregates stream elements into a collection or other data structure.

    • count(): Returns the number of elements in the stream.

    • sorted(): Sorts elements using a specified order (default or custom).

    • min(Comparator c) and max(Comparator c): Find the minimum and maximum values respectively, based on a comparator.

    • forEach(Consumer<T> c): Iterates through each element and applies a consumer operation.

    • Stream.of(): Creates a stream from a fixed set of values or an array.

Remember: When working with streams, keep in mind that they are for processing data, not modifying it. The original collection remains unchanged after stream operations.

Stream Examples in Action

Here are a few code examples to illustrate how streams work in Java:

Example 1: Filtering and Collecting Strings

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Mini Hulk", "Hulk", "Thor", "Captain Marvel", "Captain America");

        // Filter names with length greater than 6 and collect them into a new list
        List<String> longNames = names.stream()
                .filter(s -> s.length() > 6)
                .collect(Collectors.toList());

        // Print each element of the filtered list
        longNames.forEach(System.out::println);

        System.out.println("----------------------");

        // Convert names to uppercase and print them
        names.stream()
                .map(String::toUpperCase)
                .forEach(System.out::println);

        // Count elements with length greater than 6 (without creating a new list)
        long count = names.stream()
                .filter(s -> s.length() > 6)
                .count();
        System.out.println("Count: " + count);
    }
}

Example 2: Finding Minimum and Maximum Values (Integers)

import java.util.Arrays;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(2, 0, 4, 1, 6);

        // Find and print the minimum and maximum values
        Integer min = numbers.stream()
                .min(Integer::compareTo)
                .get();
        Integer max = numbers.stream()
                .max(Integer::compareTo)
                .get();

        System.out.println("Minimum Value: " + min);
        System.out.println("Maximum Value: " + max);

        System.out.println("-------------------------");

        // Sort the list and print each element (no need to collect)
        numbers.stream()
                .sorted()
                .forEach(System.out::println);
    }
}

Java 8 Date and Time API: A Modern Approach to Date/Time Handling

The Java 8 Date and Time API simplifies and streamlines date and time handling compared to the older java.util.Date and java.util.Calendar classes. Here's a brief introduction with code examples:

Key Features:

  • Immutability: Objects are immutable, preventing accidental modification.

  • Clarity and Readability: Classes like LocalDate, LocalTime, and LocalDateTime make code more intuitive.

  • Easier Manipulation: Methods for common operations like adding, subtracting, and formatting dates/times are readily available.

Here are few examples to illustrate how Date and Time works in Java 8:

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

public class Main {

    public static void main(String[] args) {

        // 1. Get current date and time:
        LocalDate today = LocalDate.now();
        LocalTime currentTime = LocalTime.now();

        System.out.println("Current Date: " + today);
        System.out.println("Current Time: " + currentTime);

        // 2. Create a specific date and time:
        LocalDateTime specificDateTime = LocalDateTime.of(2024, 2, 25, 15, 30);

        System.out.println("Specific Date and Time: " + specificDateTime);

        // 3. Add or subtract days from a date:
        LocalDate nextWeek = today.plusDays(7);

        System.out.println("Date next week: " + nextWeek);

        // 4. Format a date in a specific pattern:
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy");
        String formattedDate = formatter.format(today);

        System.out.println("Formatted Date: " + formattedDate);

        // 5. Calculate difference between two dates:
        LocalDate startDate = LocalDate.of(2023, 12, 31);
        long daysBetween = ChronoUnit.DAYS.between(startDate, today);

        System.out.println("Days between two dates: " + daysBetween);
    }
}

Explanation:

  1. Get current date and time: LocalDate.now() and LocalTime.now() are used to retrieve the current date and time, respectively.

  2. Create a specific date and time: LocalDateTime.of() is used to create a specific date and time object.

  3. Add or subtract days: plusDays() method adds a specified number of days to a LocalDate object.

  4. Format a date: DateTimeFormatter is used to format a date object into a specific format string.

  5. Calculate difference between dates: ChronoUnit.DAYS.between() calculates the number of days between two LocalDate objects.

Conclusion:

Java 8 introduced significant changes that revolutionized the way developers write Java code. The introduction of features like lambda expressions, default methods, and the Stream API streamlined common tasks and enabled a more concise and functional programming style. Additionally, the Java 8 Date and Time API provided a clear and efficient way to handle dates and times, replacing the older and more complex methods. These enhancements have had a lasting impact on Java development, making it more efficient and expressive for building modern applications.

Disclaimer:

While this blog post has introduced you to some of Java 8's most impactful features, each one offers a deeper well of exploration. To truly master their potential, I encourage you to delve into further resources and discover the depths that lie beneath the surface.

10
Subscribe to my newsletter

Read articles from Dhanjeet Kumar Thakur directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dhanjeet Kumar Thakur
Dhanjeet Kumar Thakur

I'm Dhanjeet Kumar Thakur, a dedicated backend developer skilled in Core Java, Spring frameworks, Hibernate, and web tech like HTML, CSS, and JavaScript. With a sharp eye for detail, I create strong backend solutions for seamless user experiences. Git and GitHub are my allies for effective collaboration and code integrity. Always learning, I'm on a mission to elevate my skills and make an impact in Java backend development.