Contents

Java 8 Features

Lambda Expressions

Lambda expressions are used to write concise, functional-style code by representing anonymous functions. They enable passing code as parameters or assigning it to variables, resulting in cleaner and more readable programs.

  • Lambda expressions implement a functional interface
  • Enable passing code as data (method arguments).
  • Allow defining behavior without creating separate classes.
interface Add {
    int addition(int a, int b);
}

public class Test { 
    public static void main(String[] args){
        Add add = (a, b) -> a + b;
        int result = add.addition(10, 20);
    }
}
interface Func {
    int fact(int n);
}

class Test {
    public static void main(String[] args) {
        // block lambda expression : more than 1 line
        Func f = (n) -> {
            int res = 1;
            for (int i = 1; i <= n; i++)
                res = i * res;
            return res;
        };
        int result = f.fact(5);
    }
}

A lambda expression can access variables that are defined outside its own block, such as variables from the surrounding method or class. This process is called variable capturing.

  • A lambda can directly access instance variables of its enclosing class.
  • Static variables belong to the class rather than any instance, and Lambda Expression can access and modify them freely.
  • Lambda Expression can capture local variables declared in the enclosing method. However, these variables must be effectively final, meaning they are not modified after being assigned. Lambda expression can outlive their defining method. To ensure consistency, they can capture only local variables that are final or effectively final, guaranteeing predictable, unchanging values.
Info

💡

  • Lambda expressions work with enclosing scope. We can’t hide variables from the enclosing scope inside the lambda’s body.
  • Adding a type to the parameters is optional and can be omitted. A compiler, in most cases, is able to resolve the type of lambda parameters with the help of type inference.

Functional Interfaces

A functional interface has exactly one abstract method. @FunctionalInterface is an informative annotation for the compiler, so while not necessary using it is preferred as it can trigger compiler errors if the interface does not satisfy the requirements.

Definition:

@FunctionalInterface
public interface Foo {
    String method(String string);
}

Usage

Function<String, String> fn = parameter -> parameter + " from lambda";
String result = useFoo.add("Message ", fn);
Info

💡

  • Since default methods have an implementation, they are not abstract and can be present in functional interfaces
  • If an interface declares an abstract method overriding one of the public methods of java.lang.Object, that also does not count toward the interface’s abstract method count since any implementation of the interface will have an implementation from java.lang.Object or elsewhere.
  • Instances of functional interfaces can be created with lambda expressions, method references, or constructor references.

Types

Consumer

Consumer interface of the functional interface is the one that accepts only one argument. It is used for performing an action, such as printing or logging. There are also functional variants of the Consumer DoubleConsumer, IntConsumer and LongConsumer.

Consumer<Integer> consumer = (value) -> System.out.println(value);

Predicate

Predicate interface represents a boolean-valued function of one argument. It is commonly used for filtering operations in streams. There are also functional variants of the Predicate IntPredicate, DoublePredicate and LongPredicate.

Predicate<Integer> predicate = (value) -> value != null;

Function

The function interface takes one argument and returns a result. It is commonly used for transforming data.

Several variations exist:

  • Bi-Function: Takes two arguments and returns a result.
  • Unary Operator: Input and output are of the same type.
  • Binary Operator: Like BiFunction but with same input/output type.
Function<Integer, Integer> function = (value) -> value * value;

Supplier

Supplier functional interface does not take any input or argument and yet returns a single output. The different extensions of the Supplier functional interface hold many other suppliers functions like BooleanSupplier, DoubleSupplier, LongSupplier and IntSupplier.

Supplier<String> supplier = () -> "Hello, World!";

Runnable

Since Runnable is a functional interface, it can be written using lambda expressions.

 Runnable task = () -> {
      System.out.println("Thread running using lambda");
  };

  Thread t = new Thread(task);
  t.start();

Comparable

It allows objects of a class to be compared to each other, which is useful for sorting. Return value:

  • Negative Value (< 0): Current object is less than the specified object.
  • Zero (0): Current object is equal to the specified object.
  • Positive Value (> 0): Current object is greater than the specified object.

Definition:

public interface Comparable<T> {
    int compareTo(T obj);
}

Usage:

class User implements Comparable<User> {
    int age;
    String name;

    @Override
    public int compareTo(User other) {
        return this.age - other.age; 
    }
}

Callable

The Callable interface is a part of the java.util.concurrent package, introduced in Java 5. It represents a task that can be executed by multiple threads and return a result. Unlike the Runnable interface, Callable can return a value and throw checked exceptions.

  • Used with ExecutorService for asynchronous or concurrent execution.
  • The result of a Callable is obtained using a Future object.
  • It’s a functional interface, so you can use lambda expressions.
public interface Callable<V> {
		V call() throws Exception;
}

Usage:

ExecutorService executor = Executors.newSingleThreadExecutor();

Callable<Integer> task = () ->{
    System.out.println("Calculating...");
    Thread.sleep(1000);
    return 10 * 2;
};
Future<Integer> future = executor.submit(task);
System.out.println("Result: " + future.get());
executor.shutdown();

Method References

Method References are a shorthand way to refer to an existing method without invoking it. They were introduced in Java 8 to make lambda expressions shorter, cleaner, and more readable. Method references use the double colon (::) operator and are mainly used with functional interfaces.

list.stream().anyMatch(User::isRealUser);
  • For static method use: ClassName::staticMethodName
  • For instance method of an object, use: objectReference::instanceMethod
  • For instance method of an arbitrary object, use: ClassName::instanceMethod
  • For constructor, use: ClassName::new

Streams

Java 8 introduced the Stream API, which allows developers to process collections of data in a functional and declarative way. Streams make it easier to perform operations such as filtering, mapping, reducing and collecting data without writing complex loops. Features of Streams:

  • Declarative: Write concise and readable code using functional style.
  • Lazy Evaluation: Operations are executed only when needed (terminal operation).
  • Parallel Execution: Supports parallel streams to leverage multi-core processors.
  • Reusable Operations: Supports chaining of operations like map(), filter(), sorted().
  • No Storage: Streams don’t store data; they only process it.

Steps of processing in a stream:

  • Create a Stream: From collections, arrays or static methods.
  • Apply Intermediate Operations: Transform data (e.g., filter(), map(), sorted()).
  • Apply Terminal Operation: Produce a result (e.g., forEach(), collect(), reduce()).

Creating streams

  1. From a Collection: Create a stream directly from a List, Set or any Collection using stream()
  2. From an Array: Use Arrays.stream(array) to convert an array into a stream.
  3. Using Stream.of(): Create a stream from a fixed set of values using Stream.of().
  4. Infinite Stream: Generate an unbounded sequence using Stream.iterate() or Stream.generate()
// collection
List<String> list = Arrays.asList("Java", "Python", "C++");
Stream<String> stream1 = list.stream();

// array
String[] arr = {"A", "B", "C"};
Stream<String> stream2 = Arrays.stream(arr);

// using Stream.of()
Stream<Integer> stream3 = Stream.of(1, 2, 3, 4, 5);

// infinite Stream (limit to avoid infinite loop)
Stream<Integer> stream4 = Stream.iterate(1, n -> n + 1).limit(5);
stream4.forEach(System.out::println);

Intermediate operations

Intermediate operations transform a stream into another stream. Some common intermediate operations include:

  1. filter(): Filters elements based on a specified condition.
  2. map(): Transforms each element in a stream to another value.
  3. flatMap(): Transforms each element in a stream into a stream and flattens all streams into a single stream.
  4. Sorted(): Sorts the elements of a stream.
  5. Distinct(): Remove duplicates.
  6. Skip(): Skip first n elements.

Terminal operations

Terminal Operations are the operations that on execution return a final result as an absolute value.

  • forEach(): It iterates all the elements in a stream.
  • collect(Collectors.toList()): It collects stream elements into a list (or other collections like set/map).
  • reduce(): It reduces stream elements into a single aggregated result.
  • count(): It returns the total number of elements in a stream.
  • anyMatch() ****/ ****allMatch() ****/ noneMatch(): They check whether elements match a given condition.
  • findFirst() / findAny(): They return the first or any element from a stream.
List<String> names = Arrays.asList("Amit", "Riya", "Rohan", "Amit");

// collect into Set (removes duplicates)
Set<String> uniqueNames = names.stream().collect(Collectors.toSet());
System.out.println(uniqueNames);

// count names starting with 'R'
long count = names.stream().filter(n -> n.startsWith("R")).count();
System.out.println("Names starting with R: " + count);

// reduce (concatenate names)
String result = names.stream().reduce("", (a, b) -> a + b + " ");
System.out.println(result);

Parallel Streams

Parallel Streams are the type of streams that can perform operations concurrently on multiple threads. These Streams are meant to make use of multiple processors or cores available to speed us the processing speed. There are two methods to create parallel streams are mentioned below:

  1. Using the parallel() method on a stream
  2. Using parallelStream() on a Collection
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
numbers
    .parallelStream()
    .filter(n -> n % 2 == 0)
    .forEach(System.out::println);

Examples

List<Integer> numbers = Arrays.asList(5, 10, 20, 10, 30, 40);
numbers.stream()
       .filter(n -> n > 10)   // keep > 10
       .map(n -> n * 2)       // double them
       .distinct()            // remove duplicates
       .sorted()              // sort ascending
       .forEach(System.out::println);

Fibonacci series with streams

  • new int[]{0, 1} represents the first two numbers of the sequence.
  • f -> new int[]{f[1], f[0] + f[1]}
    • The next “first” number is the current “second” number (f[1]).
    • The next “second” number is the sum of the current two (f[0]+f[1]).
  • Since each element in our stream is an int[], we use .map(f -> f[0]) to extract the actual Fibonacci number.
int n = 10;
List<Integer> fib = Stream.iterate(new int[]{0, 1}, f -> new int[]{f[1], f[0] + f[1]})
      .limit(n)
      .map(f -> f[0])
      .collect(Collectors.toList());

A real-world example of filtering, sorting, mapping and collecting transactions using Java Streams.

class Transaction {
    private int id;
    private int value;
    private String type;

    // contractors, getters, setters
}

public class StreamExample {
    public static void main(String[] args) {
        List<Transaction> transactions = Arrays.asList(
            new Transaction(1, 100, "GROCERY"),
            new Transaction(3, 80, "GROCERY"),
            new Transaction(6, 120, "GROCERY"),
            new Transaction(7, 40, "ELECTRONICS"),
            new Transaction(10, 50, "GROCERY")
        );

        List<Integer> transactionIds = transactions.stream()
                .filter(t -> t.getType().equals("GROCERY"))       // keep only groceries
                .sorted(Comparator.comparing(Transaction::getValue).reversed()) // sort by value desc
                .map(Transaction::getId)                         // map to id
                .collect(Collectors.toList());                   // collect as list

        System.out.println(transactionIds); 
    }
}

Map vs FlatMap

flatMap is used to transform a stream of collections into a stream of individual elements.

  • Map: Transform each element into its own stream (e.g., turning a List of words into a Stream of characters).
  • Flatten: Take all those individual streams and merge them into one single, flat stream.
// convert a list of list to a single list
List<List<String>> company = Arrays.asList(
    Arrays.asList("Alice", "Bob"),
    Arrays.asList("Charlie", "David")
);
// Using flatMap to flatten the nested lists into a single stream
List<String> allEmployees = company.stream()
    .flatMap(list -> list.stream()) 
    .collect(Collectors.toList());

// Result: ["Alice", "Bob", "Charlie", "David"]

// split strings: get unique words in sentance
List<String> sentences = Arrays.asList("Hello world", "Java streams are cool");

List<String> words = sentences.stream()
    .map(sentence -> sentence.split(" ")) // Returns Stream<String[]>
    .flatMap(Arrays::stream)              // Flattens String[] into Stream<String>
    .distinct()
    .collect(Collectors.toList());

// Result: ["Hello", "world", "Java", "streams", "are", "cool"]

Comparable and Comparator

  • Comparable: It is used to define the natural ordering of the objects within the class. (or default sort order)
  • Comparator: It is used to define custom sorting logic externally.

Example:

public class Employee implements Comparable<Employee> {
    private int id;
    private String name;

    public Employee(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public int compareTo(Employee other) {
        // Natural order: Ascending by ID
        // Returns negative if this.id < other.id
        // Returns zero if they are equal
        // Returns positive if this.id > other.id
        return Integer.compare(this.id, other.id);
    }

    @Override
    public String toString() {
        return id + ": " + name;
    }
}

public class EmployeeNameComparator implements Comparator<Employee> {
    @Override
    public int compare(Employee e1, Employee e2) {
        if (e1.getName() == null) return -1;
        return e1.getName().compareTo(e2.getName());
    }
}

Usage:

List<Employee> list = new ArrayList<>();
list.add(new Employee(103, "Charlie"));
list.add(new Employee(101, "Alice"));

// uses comparable for default sorting by ID
Collections.sort(list); // note: will give compilation error without Comparable interface
System.out.println(list); // Output: [101: Alice, 103: Charlie]

// sort by name by comparator
// uses lambda defined comparator
list.sort((e1, e2) -> e1.getName().compareTo(e2.getName()));
// uses comparator helper
list.sort(Comparator.comparing(Employee::getName));
// sort by name then id
list.sort(Comparator.comparing(Employee::getName).thenComparingInt(Employee::getId));
// uses separate comparator class
Collections.sort(list, new EmployeeNameComparator());
FeatureComparableComparator
Packagejava.langjava.util
MethodcompareTo(obj)compare(obj1, obj2)
ImplementationInside the class itself.Separate class or Lambda.
FlexibilityOnly one natural ordering.Multiple different sorting strategies.
Use CaseDefault sort (e.g., ID or Name).Custom sort (e.g., sort by Salary, then Age).
ContextCompares this to otherCompares two independent objects

Optional Class

Optional class in the java.util package to handle the problem of null values more gracefully. Instead of risking NullPointerException (NPE), Optional provides a container object that may or may not hold a non-null value.

Creation:

  • Optional.empty(): Returns an empty Optional.
  • Optional.of(value): Returns an Optional containing the given non-null value.
  • Optional.ofNullable(value): Returns an Optional describing the value if non-null, otherwise empty

Usage

Optional<User> user = Optional.ofNullable(getUser());
String result = user
  .map(User::getAddress)
  .map(Address::getStreet)
  .orElse("not specified");
String value = null;
Optional<String> valueOpt = Optional.ofNullable(value);
String result = valueOpt.orElseThrow(CustomException::new).toUpperCase();

Other methods:

  • isPresent()
  • get(): Returns the value if present, else throws NoSuchElementException. (usually called after isPresent() check)
  • hashCode()
  • orElse(T other): Returns the value if present, otherwise returns the provided default value.
  • orElseGet(Supplier<? extends T> other): Returns the value if present, otherwise invokes the supplier and returns its result.
  • orElseThrow(Supplier<? extends X> exceptionSupplier): Returns the value if present, otherwise throws an exception provided by the supplier

Date/Time API

Java 8 introduced a brand-new Date and Time API under the package java.time to overcome the limitations of the old java.util.Date and java.util.Calendar classes.

Why New Date-Time API?

  1. Not thread safe: Unlike old java.util.Date, which is not thread safe, the new date-time API is immutable and doesn’t have setter methods.
  2. Fewer operations: In the old API, there are only a few date operations, but the new API provides us with many date operations.
  3. Confusing design: Mixing of date, time and timezone handling.

Core Classes in java.time

  • Local API: LocalDate, LocalTime, LocalDateTime (when timezone is not required).
  • Zoned API: ZonedDateTime, ZoneId (when working with timezones).
  • Period and Duration: Represent date-based and time-based amounts of time.
  • ChronoUnit Enum: Replace integer constants with type-safe units like DAYS, WEEKS, YEARS.
  • TemporalAdjusters: Utility for common date manipulations (like first day of month, next Saturday).

Basic usage:

// current date: default ISO format (yyyy-MM-dd)
LocalDate date = LocalDate.now();
LocalDate date2 = LocalDate.parse("2015-02-20");

// current time
LocalTime time = LocalTime.now();
LocalTime sixThirty = LocalTime.parse("06:30");
                    
// current date and time
LocalDateTime current = LocalDateTime.now();
LocalDateTime parsedDate = LocalDateTime.parse("2015-02-20T06:30:00");

DateTimeFormatter format = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss");  
String formatedDateTime = current.format(format);  
System.out.println(formatedDateTime);

// get months days and seconds
Month month = current.getMonth();
int day = current.getDayOfMonth();
int seconds = current.getSecond();

// printing a specified date
LocalDate date2 = LocalDate.of(1950, 1, 26);

// get the current timezone
ZonedDateTime currentZone = ZonedDateTime.now();
ZoneId tokyo = ZoneId.of("Asia/Tokyo");
ZonedDateTime inTokyoTimeZone = currentZone.withZoneSameInstant(tokyo);
ZonedDateTime parsed = ZonedDateTime.parse("2015-05-03T10:15:30+01:00[Europe/Paris]");

Date and time calculations

LocalTime time1 = LocalTime.now();

// add 5 hours time
LocalTime time2 = time1.plus(Duration.ofHours(5)); 

// get difference
long thirty = Duration.between(time1, time2).getSeconds();
long thirty2 = ChronoUnit.SECONDS.between(time1, time2);

LocalDate date = LocalDate.now();

// adding 1 day
LocalDate tomorrow = date.plusDays(1);
LocalDate finalDate = date.plus(Period.ofDays(1));

// adding 2 years
LocalDate year = date.plus(2, ChronoUnit.YEARS);

// adding 1 month
LocalDate nextMonth = date.plus(1, ChronoUnit.MONTHS);

// get difference
int days = Period.between(date, tomorrow).getDays();
long dayDiff = ChronoUnit.DAYS.between(date, tomorrow);

// to get the first day of next month
LocalDate dayOfNextMonth = date.with(TemporalAdjusters.firstDayOfNextMonth());

// get the next saturday
LocalDate nextSaturday = date.with(TemporalAdjusters.next(DayOfWeek.SATURDAY));

// first day of current month
LocalDate firstDay = date.with(TemporalAdjusters.firstDayOfMonth());

// last day of current month
LocalDate lastDay = date.with(TemporalAdjusters.lastDayOfMonth());

// before / after date check
boolean notBefore = LocalDate.parse("2025-06-12").isBefore(LocalDate.parse("2025-06-11"));
boolean isAfter = LocalDate.parse("2025-06-12").isAfter(LocalDate.parse("2025-06-11"));

// with datetime
LocalDateTime now = LocalDateTime.now();

// adding 1 day
LocalDateTime tomorrowDt = now.plusDays(1);

// minus 2 hours
LocalDateTime hours = now.minusHours(2);

// get day of week
DayOfWeek day = now.getMonth();

Misc

Interface Default and Static Methods

Before Java 8, interfaces could have only public abstract methods. It was not possible to add new functionality to the existing interface without forcing all implementing classes to create an implementation of the new methods, nor was it possible to create interface methods with an implementation.

Starting with Java 8, interfaces can have static and default methods that, despite being declared in an interface, have a defined behavior. This makes also interfaces more flexible and backward-compatible. Note: while default methods can be overridden, the static method can not be overridden by static class. This is because they are resolved at compile time (static binding) and belong to the interface itself, not to the objects of the classes that implement it. Method overriding, by definition, relies on runtime polymorphism (dynamic binding), which works on object instances.

Client-side TLS 1.2 enabled by default

References