Java 8 Features

Java 8 is one of the most important releases in Java history. It introduced functional programming, stream processing, lambda expressions, and major API enhancements.

1. Lambda Expressions

What is the use of Lambda Expressions?

👉 To write cleaner, shorter, more readable code

👉 Makes Collections Processing Easy

Lambda expressions are used to write cleaner, shorter, and more readable code.

They allow passing behavior as a parameter and enable functional programming using the Stream API.

Lambdas reduce boilerplate, simplify multithreading, and work with functional interfaces like Predicate, Function, and Consumer. Default and static methods do not affect this rule.

We cannot use lambda expressions on normal interfaces that have multiple abstract methods.

Syntax:

(parameters) -> expression

(parameters) -> { statements }

What is the use of a Functional Interface?

2. Functional Interface

A Functional Interface is an interface with exactly has one abstract method.

To support lambda expressions: Lambda requires exactly one abstract method, so it can map the lambda to that method.

Functional interfaces allow lambdas, enable functional programming, reduce boilerplate, and allow behavior to be passed around in code.

Example:

@FunctionalInterface

interface MyInterface {

    void test();

}

--

Examples:

Runnable

Callable

Comparator

Predicate

Function

Consumer

Supplier

What is the use of Default Methods?

A default method is a method with a body inside an interface.

Why are default methods introduced?

✔ 1. To avoid breaking existing implementations

Before Java 8: Adding a new method in interface → breaks ALL implementing classes

After Java 8:

✔ New method can be added as default

✔ Existing classes don't break

✔ To provide common functionality: Interfaces can now provide reusable code 

What is the use of Static Methods in Interface?

Static methods inside interfaces belong only to the interface, not implementing classes.

Example:

interface Vehicle {

    default void start() {

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

    }

    static void stop() {

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

    }

}


3. Method Reference

What is the use of Method Reference?

Method references are used to make lambda expressions more concise and readable.

They reuse existing methods instead of writing logic again in a lambda.

We can apply method references in four cases:

1. Static methods - Class::staticMethod,

2. Instance methods of an object - Class::instanceMethod,

3. Instance methods of any object of a class - object::instanceMethod,

4. Constructors - Class::new.

4. Stream API

What is a Stream in Java?

A Stream is pipeline-based data processing on a sequence of elements, and used to perform functional-style programming. 

It allows to filter, transform, aggregate, and collect data without modifying the underlying source.

Key Characteristics:

👉Not a data structure — it doesn’t store elements

👉Immutable — operations do not change source data

👉Lazy evaluation — intermediate operations execute only when a terminal operation runs

👉Internal iteration — Java controls looping, not the developer

👉Functional — uses lambdas, method references

👉Composable — supports pipeline chaining

👉Single-use — once consumed, cannot be reused

Stream Pipeline Structure:

source → intermediate operations → terminal operation

Types of Stream Operations:

Intermediate (lazy, return Stream):

filter()

map()

sorted()

distinct()

limit()

peek()

Terminal (trigger execution):

collect()

forEach()

reduce()

count()

findFirst()

Example:

Find top 3 highest salaries using Stream

List<Integer> top3 = salaries.stream()

                             .sorted(Comparator.reverseOrder())

                             .limit(3)

                             .collect(Collectors.toList());


What is parallelStream()?

parallelStream() processes elements concurrently using multiple threads

internally backed by ForkJoinPool.commonPool().

Example:

List<String> names = List.of("A", "B", "C", "D");

names.parallelStream()

     .forEach(System.out::println);

How Parallel Stream Works Internally:

✔ Uses ForkJoin Framework

✔ Splits the data into sub-tasks via Spliterator

✔ Each task runs on a different worker thread

✔ Results merged using reduce/combine





5. Optional 

What is Optional in Java?

Optional is used to avoid null pointer exceptions and write safer, cleaner code.

It represents a value that may or may not exist.

Optional provides methods like of(), ofNullable(), isPresent(), ifPresent(), orElse(), orElseGet(), orElseThrow(), map(), flatMap(), and filter() to handle missing values without explicit null checks.

Example:

Optional<String> name = Optional.ofNullable(null);

name.orElse("default");     // returns "default"

name.orElseThrow(() -> new RuntimeException("Value missing"));

6. Date & Time API (java.time)

Core Conceptual Difference between util.Date and java.time

Aspect

java.util.Date

Java 8 Date/Time (`java.time.*`)

Package

java.util

java.time

Design

Old, flawed

Modern, well-designed 

Mutability

 ✅ Mutable

 ❌ Immutable 

Thread Safety

 ❌ Not thread-safe  

 ✅ Thread-safe

Time representation

Milliseconds since Epoch

Diverse, explicit date/time models

Time Zone handling

Implicit, confusing

Explicit, clear

Formatting/Parsing

`SimpleDateFormat` (not thread-safe)

`DateTimeFormatter` (thread-safe)

API style

Procedural

Fluent, functional

Range & precision

Milliseconds

Nanoseconds

Domain accuracy

Weak

Strong domain separation

 Mutability:

        Date date = new Date(); // Sun Nov 23 13:22:39 IST 2025

        date.setTime(0); // modifies original object

        System.out.println(date); // given - Thu Jan 01 05:30:00 IST 1970


        LocalDate localDate = LocalDate.now(); // 2025-11-23

        localDate.plusDays(1); // returns new object

        System.out.println(localDate); // same result - 2025-11-23

        LocalDate update = localDate.plusDays(1); // return new object - 2025-11-23

        

Timezone Confusion:

java.util.Date always prints in system timezone


Java 8 separates concepts clearly:

LocalDate → only date

LocalTime → only time

LocalDateTime → date & time, no timezone

ZonedDateTime → date & time WITH timezone

Instant → UTC timestamp

Precision:

java.util.Date - gives till milliseconds

Instant, LocalDateTime - gives nanoseconds

Thread Safety:

Date, Calendar, SimpleDateFormat - Mutable

java.time classes are immutable

Calendar Exists Because Date Is Broken:

util.Date API did NOT support:

> adding days, months, years

> subtracting dates

> getting month, year, day

> localization

> timezone conversions

To over come this we use util.Calendar

        Calendar cal = Calendar.getInstance();

        cal.add(Calendar.DAY_OF_MONTH, 5);

Issue with Calendar: it consider months from 0-11


In java.time

LocalDate localDate = LocalDate.now();

localDate.plusDays(1);


Period, Duration Calculation:

java.util.Date and java.util.Calendar do NOT have dedicated time-span types.

Before Java 8, developers had to compute differences manually

In java8

Duration: Represents time-based amount

LocalTime start = LocalTime.of(10, 30);

LocalTime end = LocalTime.of(13, 10);

Duration duration = Duration.between(start, end);

Period: Represents date-based amount

LocalDate join = LocalDate.of(2020, 1, 15);

LocalDate today = LocalDate.of(2025, 11, 22);

Period period = Period.between(join, today);




Java OOPs Concepts

  1. Abstraction
  2. Encapsulation
  3. Inheritance
  4. Polymorphism

Abstraction:

Abstraction is a process that hides the implementation and displays only the information needed. In other words “hide the internal implementation and shows the functionality".

We can achieve abstraction using abstract classes and interfaces.

Abstraction helps in reducing programming complexity and efforts.

In the below example users can choose payment option and amount, he doesn’t know the how the payment is done internally.

public class Application {
   
public static void main(String[] args) {
       
// pay option and pay amount choose by user
       
String payOption = "netbanking";
       
double payAmount = 1000.00;
       
if(payOption.equals("credit")){
            Payment payment =
new CreditCard();
            payment.payAmount(payAmount);
        }
else if(payOption.equals("netbanking")){
            Payment payment =
new NetBanking();
            payment.payAmount(payAmount);
        }

    }
}

/**
 * Output:
 * pay amount through net-banking : 1000.0
 */

public interface Payment {
   
void payAmount(double payAmount);
}

public class CreditCard implements Payment{
   
@Override
   
public void payAmount(double payAmount) {
        System.
out.println("pay amount using credit card : "+ payAmount);
    }
}

public class NetBanking implements Payment{
   
@Override
   
public void payAmount(double payAmount) {
        System.
out.println("pay amount through net-banking : "+payAmount);
    }
}

Encapsulation:

The encapsulation binds the data and code together into a single unit. Hence, it is also known as data hiding.

Encapsulation acts as a protective wrapper that prevents the code and data from being accessed by outsiders.

We can achieve this by Java bean is the fully encapsulated class because all the data members are private here and we can access by the setter and getter methods.

We can make the class read-only or write-only by the getter or setter methods.

Suppose if we don't provide setter methods then the outside person can't set the value.

public class Item {

   
private String item = "pen";
   
private int quantity = 10;

   
public String getItem() {
       
return item;
    }

   
public int getQuantity() {
       
return quantity;
    }
}

We can control the data by adding restrictions in the setter methods. In the below example outsiders can set the quantity if it is <= 10

public class Item {

   
private String item = "pen";
   
private int quantity = 10;

   
public String getItem() {
       
return item;
    }
   
public int getQuantity() {
       
return quantity;
    }
   
public void setItem(String item) {
       
this.item = item;
    }
   
public void setQuantity(int quantity) {
       
if(quantity <= 10){
           
this.quantity = quantity;
        }
    }
}

public class Application {
   
public static void main(String[] args) {
        Item item =
new Item();
        item.setQuantity(
5);
        System.
out.println(item.getQuantity());
    }
}

/**
 * Output:
 * 5
 */

 

Inheritance:

One object acquires/inherits another object’s properties and behavior.

We can create a new class by extending the parent class then we can reuse methods and fields of the parent class.

Inheritance represents hierarchical classification.

Loan.java is Abstract class, which extends by HomeLoan class and PersonalLoan class

public abstract class Loan {
   
abstract void loanType();
}

public class HomeLoan extends Loan{
    @Override
    void loanType() {
        System.out.println("Home loan");
    }
}
public class PersonalLoan extends Loan{
    @Override
    void loanType() {
        System.out.println("Personal Loan");
    }
}
public class Application {
    public static void main(String[] args) {
        Loan loan = new HomeLoan();
        loan.loanType();
    }
}
/**
 * Output:
 * Home loan
 */

 

Polymorphism:

Polymorphism means many forms, it performs a single action in different ways.

Two different types:

1. Compile-time polymorphism

It is resolved at compile-time which is achieved through Method Overloading.

public class Application {
   
public static void main(String[] args) {
        Employee employee =
new Employee();
        employee.getEmployeeDetails(
101);
        employee.getEmployeeDetails(
"narendar");
    }
}

/**
 * Output:
 * get details by employee Id : 101
 * get details by employee Name : narendar
 */

class Employee{
   
public void getEmployeeDetails(Integer employeeId){
        System.
out.println("get details by employee Id : "+employeeId);
    }
   
public void getEmployeeDetails(String employeeName){
        System.
out.println("get details by employee Name : "+employeeName);
    }
   
public void getEmployeeDetails(Integer employeeId, String employeeName){
        System.
out.println("get details by employee Id and Name : "+employeeId+" , "+employeeName);
    }
}

 

2. Runtime polymorphism.

It is resolved at run-time which is achieved through Method Overriding.

public class Application {
   
public static void main(String[] args) {
        DeveloperDept developerDept =
new DeveloperDept();
        developerDept.getEmployeeDetails();

        HrDept hrDept =
new HrDept();
        hrDept.getEmployeeDetails(
101);
        hrDept.getEmployeeDetails();
    }
}

/**
 * Output:
 * get developer details
 * get details by employee Id : 101
 * get HR details
 */

class Employee{
   
public void getEmployeeDetails(Integer employeeId){
        System.
out.println("get details : "+employeeId);
    }
   
public void getEmployeeDetails(){
        System.
out.println("get details");
    }
}
class DeveloperDept extends Employee{
   
@Override
   
public void getEmployeeDetails(){
        System.
out.println("get developer details");
    }
}
class HrDept extends Employee{
   
@Override
   
public void getEmployeeDetails(){
        System.
out.println("get HR details");
    }
   
@Override
   
public void getEmployeeDetails(Integer employeeId){
        System.
out.println("get details by employee Id : "+employeeId);
    }
}

 

Configure Multiple Database’s in SpringBoot application

We can customize the configurations in the .yml file as shown in the below

 YML File

#### DB2 Properties
db2:
 
datasource:
   
jdbc-url: jdbc:db2://SERVER:PORT/DBNAME
   
driver-class-name: com.ibm.db2.jcc.DB2Driver
   
testWhileIdle: true
   
validationQuery: SELECT 1
   
username:
   
passwrod:

#### Oracle properties
oracle:
 
datasource:
   
jdbc-url: jdbc:oracle:thin:@SERVER:PORT:DBNAME
   
driver-class-name: oracle.jdbc.OracleDriver
   
username:
   
password:

 

Map the above YML properties to DataSource programmatically for each Database

DB2DatabaseConfig.java

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import javax.sql.DataSource;
@Configuration @ConfigurationProperties(prefix = "db2.datasource") public class DB2DatabaseConfig extends HikariConfig {
   
@Bean     @Qualifier("db2")     @Primary
   
public DataSource db2DataSource() {         return new HikariDataSource(this);     }

   
@Bean     @Qualifier("db2")     public NamedParameterJdbcTemplate db2JdbcTemplate() {         return new NamedParameterJdbcTemplate(db2DataSource());     } }

 

OracleDatabaseConfig.java

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.sql.SQLException;
@Configuration @ConfigurationProperties(prefix = "oracle.datasource") public class OracleDatabaseConfig extends HikariConfig {
   
@Bean     @Qualifier("oracle")     public DataSource oracleDataSource() {         return new HikariDataSource(this);     }
   
@Bean     @Qualifier("oracle")     public JdbcTemplate oracleJdbcTemplate() throws SQLException {         return new JdbcTemplate(oracleDataSource());     } }

 

As usual, we can use the above configurations in the repository class as below for Oracle DB

@Repository
public class LocationRepository {

   
@Autowired     @Qualifier("oracle")     JdbcTemplate oracleJdbcTemplate;
    public void getLocation(){
// write code here     }
}

We can connect DB2 as below

@Mapper
public interface TestMapper {
   
// Write your code here
}

 

Singleton Design Pattern

Singleton Design Pattern says that a class should have only a single instance and that single object can be used by all other classes.

It Saves memory because the object is not created for each request. An only single instance is reused.

It is mostly used in thread pools, caching, configuration settings, etc.

We have to follow the below steps to create the singleton

Static member: It gets memory only

Private constructor: It will prevent instantiating the Singleton class from outside the class.

Static factory method: This provides the global point of access.

There are two ways of achieving singleton design pattern

Early Instantiation: instance create early if required or not

public class EagerSingleton {
   
private EagerSingleton(){}
   
private static final EagerSingleton singleton = new EagerSingleton();
   
public static EagerSingleton getSingleton(){
       
return singleton;
    }
}

Lazy Instantiation: instance create on demand

public class LazySingleton {
    private LazySingleton(){
    }
    private static LazySingleton singleton;
    public static LazySingleton getSingleton(){
        if(singleton == null){
            return singleton = new LazySingleton();
        }
        else{
            return singleton;
        }
    }
}

 

We can validate as below whether it is created single instance or not

public class SingletonTest {
    public static void main(String[] args) {
        LazySingleton obj1 = LazySingleton.getSingleton();
        LazySingleton obj2 = LazySingleton.getSingleton();
        System.out.println(obj1.hashCode()+", "+obj2.hashCode());
    }
}