Java's Collections Framework, added in Java 2 and massively enhanced in Java 8, is the toolkit every Java developer uses daily. Combined with Generics (type-safe containers) and the Stream API (functional-style data processing), these features let you write concise, powerful, and readable code. This guide covers every major concept with working code.


Step 1 — Java Collections Framework Overview

The Collections Framework is a unified architecture for storing and manipulating groups of objects. All collections are in the java.util package.

Core Interfaces and Their Implementations

  • List (ordered, allows duplicates): ArrayList, LinkedList, Vector, Stack
  • Set (no duplicates): HashSet, LinkedHashSet, TreeSet
  • Map (key-value pairs): HashMap, LinkedHashMap, TreeMap, Hashtable
  • Queue (FIFO ordering): LinkedList, ArrayDeque, PriorityQueue
  • Deque (double-ended queue): ArrayDeque, LinkedList

Step 2 — List: ArrayList vs LinkedList

ListExamples.javajava
import java.util.*;

public class ListExamples {
    public static void main(String[] args) {

        // ============================================
        // ARRAYLIST: backed by a dynamic array
        // Best for: random access by index, frequent reads
        // Worst for: frequent insertions/deletions in the middle
        // ============================================
        List<String> fruits = new ArrayList<>(); // always program to interface!
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
        fruits.add(0, "Avocado");  // insert at index 0 (O(n) — shifts elements)

        System.out.println(fruits);         // [Avocado, Apple, Banana, Cherry]
        System.out.println(fruits.get(2));  // Banana (O(1) random access)
        System.out.println(fruits.size());  // 4
        System.out.println(fruits.contains("Apple")); // true (O(n) linear search)
        System.out.println(fruits.indexOf("Banana"));  // 2

        fruits.remove("Apple");   // remove by value (O(n))
        fruits.remove(0);         // remove by index (O(n))
        System.out.println(fruits); // [Banana, Cherry]

        // Sorting
        List<Integer> nums = new ArrayList<>(Arrays.asList(5, 2, 8, 1, 9, 3));
        Collections.sort(nums);           // natural order: [1, 2, 3, 5, 8, 9]
        nums.sort(Comparator.reverseOrder()); // [9, 8, 5, 3, 2, 1]
        System.out.println(nums);

        // Creating a fixed-size list from an array (IMMUTABLE!)
        List<String> immutable = List.of("one", "two", "three"); // Java 9+
        // immutable.add("four"); // throws UnsupportedOperationException

        // ============================================
        // LINKEDLIST: backed by a doubly linked list
        // Best for: frequent insertions/deletions at head or tail
        // Worst for: random access by index (O(n))
        // ============================================
        LinkedList<String> queue = new LinkedList<>();
        queue.addFirst("first");   // O(1) — add to head
        queue.addLast("second");   // O(1) — add to tail
        queue.addFirst("zero");    // O(1) — add to head

        System.out.println(queue.getFirst()); // zero
        System.out.println(queue.getLast());  // second
        queue.removeFirst();                  // O(1)
        System.out.println(queue);            // [first, second]
    }
}

Step 3 — Set: HashSet, LinkedHashSet, TreeSet

SetExamples.javajava
import java.util.*;

public class SetExamples {
    public static void main(String[] args) {

        // ============================================
        // HASHSET: no duplicates, NO guaranteed order
        // Backed by HashMap. O(1) add/remove/contains.
        // ============================================
        Set<String> hashSet = new HashSet<>();
        hashSet.add("Banana");
        hashSet.add("Apple");
        hashSet.add("Cherry");
        hashSet.add("Apple"); // duplicate — silently ignored
        System.out.println(hashSet.size());          // 3
        System.out.println(hashSet.contains("Banana")); // true (O(1))
        System.out.println(hashSet); // [Banana, Cherry, Apple] — ORDER NOT GUARANTEED

        // ============================================
        // LINKEDHASHSET: no duplicates, INSERTION ORDER maintained
        // ============================================
        Set<String> linkedSet = new LinkedHashSet<>();
        linkedSet.add("Banana");
        linkedSet.add("Apple");
        linkedSet.add("Cherry");
        linkedSet.add("Apple"); // ignored
        System.out.println(linkedSet); // [Banana, Apple, Cherry] — insertion order!

        // ============================================
        // TREESET: no duplicates, SORTED order (natural or Comparator)
        // O(log n) add/remove/contains — backed by Red-Black Tree
        // ============================================
        TreeSet<Integer> treeSet = new TreeSet<>();
        treeSet.add(5); treeSet.add(2); treeSet.add(8); treeSet.add(1);
        System.out.println(treeSet);          // [1, 2, 5, 8] — always sorted
        System.out.println(treeSet.first());  // 1 (smallest)
        System.out.println(treeSet.last());   // 8 (largest)
        System.out.println(treeSet.headSet(5));  // [1, 2] — elements < 5
        System.out.println(treeSet.tailSet(5));  // [5, 8] — elements >= 5

        // Set operations: union, intersection, difference
        Set<Integer> a = new HashSet<>(Arrays.asList(1, 2, 3, 4));
        Set<Integer> b = new HashSet<>(Arrays.asList(3, 4, 5, 6));

        Set<Integer> union = new HashSet<>(a);
        union.addAll(b);           // {1,2,3,4,5,6}

        Set<Integer> intersection = new HashSet<>(a);
        intersection.retainAll(b); // {3,4}

        Set<Integer> difference = new HashSet<>(a);
        difference.removeAll(b);   // {1,2}

        System.out.println("Union: " + union);
        System.out.println("Intersection: " + intersection);
        System.out.println("Difference: " + difference);
    }
}

Step 4 — Map: HashMap, LinkedHashMap, TreeMap

MapExamples.javajava
import java.util.*;

public class MapExamples {
    public static void main(String[] args) {

        // ============================================
        // HASHMAP: key-value pairs, NO order, O(1) average
        // Keys must properly implement equals() and hashCode()
        // ============================================
        Map<String, Integer> wordCount = new HashMap<>();
        wordCount.put("java", 10);
        wordCount.put("python", 7);
        wordCount.put("go", 5);
        wordCount.put("java", 15); // overwrites — keys are unique!

        System.out.println(wordCount.get("java"));           // 15
        System.out.println(wordCount.get("rust"));           // null (key not found)
        System.out.println(wordCount.getOrDefault("rust", 0)); // 0 — safe!
        System.out.println(wordCount.containsKey("python")); // true
        System.out.println(wordCount.size());                // 3

        // putIfAbsent: only puts if key doesn't exist
        wordCount.putIfAbsent("java", 99); // ignored: 'java' already exists
        wordCount.putIfAbsent("rust", 3);  // added: 'rust' was absent

        // merge: powerful for counting/accumulating
        String text = "apple banana apple cherry apple banana";
        Map<String, Integer> freq = new HashMap<>();
        for (String word : text.split(" ")) {
            freq.merge(word, 1, Integer::sum); // key, initial value, merge function
        }
        System.out.println(freq); // {banana=2, cherry=1, apple=3}

        // compute: update value based on existing value
        freq.compute("apple", (k, v) -> v == null ? 1 : v + 10);
        System.out.println(freq.get("apple")); // 13

        // Iterating a Map — three ways
        // 1. entrySet() — most efficient, gives key AND value
        for (Map.Entry<String, Integer> entry : wordCount.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }

        // 2. keySet() — then get value
        for (String key : wordCount.keySet()) {
            System.out.println(key + " -> " + wordCount.get(key));
        }

        // 3. forEach (lambda — Java 8+)
        wordCount.forEach((k, v) -> System.out.println(k + ": " + v));

        // ============================================
        // LINKEDHASHMAP: insertion order maintained
        // ============================================
        Map<String, String> capitals = new LinkedHashMap<>();
        capitals.put("India", "New Delhi");
        capitals.put("USA", "Washington D.C.");
        capitals.put("Japan", "Tokyo");
        System.out.println(capitals); // {India=New Delhi, USA=Washington D.C., Japan=Tokyo}

        // ============================================
        // TREEMAP: sorted by key (natural order or Comparator)
        // ============================================
        TreeMap<String, Integer> sorted = new TreeMap<>(wordCount);
        System.out.println(sorted);              // alphabetically sorted
        System.out.println(sorted.firstKey());   // smallest key
        System.out.println(sorted.lastKey());    // largest key
        System.out.println(sorted.headMap("p")); // keys strictly less than "p"
    }
}

Step 5 — Queue and Deque

QueueDeque.javajava
import java.util.*;

public class QueueDeque {
    public static void main(String[] args) {

        // ============================================
        // QUEUE (FIFO — First In, First Out)
        // Use ArrayDeque for a simple queue (faster than LinkedList)
        // ============================================
        Queue<String> queue = new ArrayDeque<>();
        queue.offer("Task-1"); // enqueue (add to tail)
        queue.offer("Task-2");
        queue.offer("Task-3");

        System.out.println(queue.peek());  // "Task-1" — look at head WITHOUT removing
        System.out.println(queue.poll());  // "Task-1" — remove and return head
        System.out.println(queue.size());  // 2

        // Note: add() throws exception on failure; offer() returns false.
        // remove() throws exception if empty; poll() returns null.
        // element() throws exception; peek() returns null.

        // ============================================
        // PRIORITYQUEUE: retrieves elements by priority (min-heap by default)
        // ============================================
        PriorityQueue<Integer> pq = new PriorityQueue<>(); // min-heap
        pq.offer(5); pq.offer(1); pq.offer(3); pq.offer(2);

        while (!pq.isEmpty()) {
            System.out.print(pq.poll() + " "); // 1 2 3 5 — always dequeues the SMALLEST
        }
        System.out.println();

        // Max-heap: use reverse comparator
        PriorityQueue<Integer> maxPQ = new PriorityQueue<>(Comparator.reverseOrder());
        maxPQ.offer(5); maxPQ.offer(1); maxPQ.offer(3);
        System.out.print(maxPQ.poll()); // 5 — largest first

        // ============================================
        // DEQUE (double-ended queue) — use as Stack or Queue
        // ============================================
        Deque<String> deque = new ArrayDeque<>();
        deque.addFirst("B");  // add to front
        deque.addLast("C");   // add to back
        deque.addFirst("A");  // add to front

        System.out.println(deque); // [A, B, C]
        System.out.println(deque.pollFirst()); // A
        System.out.println(deque.pollLast());  // C

        // Using Deque as a Stack (LIFO)
        Deque<String> stack = new ArrayDeque<>();
        stack.push("first");   // addFirst
        stack.push("second");  // addFirst
        stack.push("third");   // addFirst
        System.out.println(stack.pop()); // "third" — LIFO
        System.out.println(stack.pop()); // "second"
    }
}

Step 6 — Generics: Type-Safe Code

Generics allow you to write code that works with any type while maintaining type safety at compile time. They eliminate the need for casting and prevent ClassCastException at runtime.

Generics.javajava
import java.util.*;

// Generic class: T is a type parameter (placeholder)
public class Pair<T, U> {
    private T first;
    private U second;

    public Pair(T first, U second) {
        this.first  = first;
        this.second = second;
    }

    public T getFirst()  { return first; }
    public U getSecond() { return second; }

    @Override
    public String toString() {
        return "(" + first + ", " + second + ")";
    }
}

// Generic method: type parameter declared before return type
class Utils {

    // Swap two elements in a list
    public static <T> void swap(List<T> list, int i, int j) {
        T temp = list.get(i);
        list.set(i, list.get(j));
        list.set(j, temp);
    }

    // Return the maximum of two Comparable objects
    public static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
        // <T extends Comparable<T>> means T must implement Comparable
    }

    // Bounded wildcard: ? extends Number — reads a list of any Number subtype
    public static double sumList(List<? extends Number> list) {
        double sum = 0;
        for (Number n : list) sum += n.doubleValue();
        return sum;
        // Why not <T extends Number>? Because List<Integer> is NOT a List<Number>
        // ? extends Number (upper-bounded wildcard) handles this correctly
    }

    // Lower-bounded wildcard: ? super Integer — writes Integers into a list
    public static void addNumbers(List<? super Integer> list) {
        for (int i = 1; i <= 5; i++) list.add(i);
    }
}

class GenericsDemo {
    public static void main(String[] args) {
        // Using generic class with different types
        Pair<String, Integer> p1 = new Pair<>("age", 25);
        Pair<Double, Boolean> p2 = new Pair<>(3.14, true);
        System.out.println(p1); // (age, 25)
        System.out.println(p2); // (3.14, true)

        // String getFirst() — type safe, no casting needed!
        String name = p1.getFirst(); // type is String at compile time

        // Generic method
        List<String> words = new ArrayList<>(Arrays.asList("a", "z", "m"));
        Utils.swap(words, 0, 2);
        System.out.println(words); // [m, z, a]

        System.out.println(Utils.max(10, 20));     // 20
        System.out.println(Utils.max("apple", "banana")); // banana

        // Upper-bounded wildcard
        List<Integer> ints    = Arrays.asList(1, 2, 3);
        List<Double>  doubles = Arrays.asList(1.5, 2.5);
        System.out.println(Utils.sumList(ints));    // 6.0
        System.out.println(Utils.sumList(doubles)); // 4.0
    }
}

Step 7 — Lambda Expressions and Functional Interfaces

Lambda expressions (Java 8) let you write anonymous functions concisely. A lambda can be used anywhere a functional interface (an interface with exactly one abstract method) is expected.

Lambdas.javajava
import java.util.*;
import java.util.function.*;

public class Lambdas {
    public static void main(String[] args) {

        // --- Lambda syntax ---
        // (parameters) -> expression          // single expression, implicit return
        // (parameters) -> { statements; }     // block body, explicit return

        // === Built-in Functional Interfaces (java.util.function) ===

        // Runnable: () -> void
        Runnable r = () -> System.out.println("Running!");
        r.run();

        // Supplier<T>: () -> T  — produces a value, takes no input
        Supplier<String> greeting = () -> "Hello, World!";
        System.out.println(greeting.get()); // Hello, World!

        Supplier<List<String>> listFactory = ArrayList::new; // constructor reference
        List<String> list = listFactory.get();

        // Consumer<T>: T -> void  — consumes a value, produces nothing
        Consumer<String> printer  = s -> System.out.println(">> " + s);
        Consumer<String> upper    = s -> System.out.println(s.toUpperCase());
        Consumer<String> combined = printer.andThen(upper); // chains consumers
        combined.accept("hello"); // >> hello \n HELLO

        // BiConsumer<T, U>: (T, U) -> void
        BiConsumer<String, Integer> printPair = (k, v) ->
            System.out.println(k + " = " + v);
        printPair.accept("count", 42);

        // Function<T, R>: T -> R  — transforms input to output
        Function<String, Integer>  strLen  = s -> s.length();
        Function<Integer, Boolean> isEven  = n -> n % 2 == 0;
        // compose: isEven(strLen(input))
        Function<String, Boolean> isEvenLength = strLen.andThen(isEven);
        System.out.println(isEvenLength.apply("Hello")); // false (length 5)
        System.out.println(isEvenLength.apply("Java"));  // true  (length 4)

        // BiFunction<T, U, R>: (T, U) -> R
        BiFunction<String, String, String> concat = (a, b) -> a + " " + b;
        System.out.println(concat.apply("Hello", "Java")); // Hello Java

        // Predicate<T>: T -> boolean  — test a condition
        Predicate<String> isLong    = s -> s.length() > 5;
        Predicate<String> startsWithJ = s -> s.startsWith("J");
        Predicate<String> combined2 = isLong.and(startsWithJ); // AND
        Predicate<String> either    = isLong.or(startsWithJ);  // OR
        Predicate<String> notLong   = isLong.negate();         // NOT

        System.out.println(combined2.test("JavaScript")); // true
        System.out.println(notLong.test("Hi"));           // true

        // UnaryOperator<T>: T -> T  (special Function where input and output same type)
        UnaryOperator<String> trim  = String::trim;        // method reference
        UnaryOperator<String> lower = String::toLowerCase;
        Function<String, String> normalize = trim.andThen(lower);
        System.out.println(normalize.apply("  HELLO WORLD  ")); // hello world

        // BinaryOperator<T>: (T, T) -> T
        BinaryOperator<Integer> multiply = (a, b) -> a * b;
        System.out.println(multiply.apply(6, 7)); // 42

        // === Method References: shorthand for lambdas ===
        // Type 1: Static method reference
        Function<String, Integer> parse = Integer::parseInt; // same as s -> Integer.parseInt(s)

        // Type 2: Instance method reference on a specific instance
        String prefix = "Hello";
        Predicate<String> startsWith = prefix::startsWith; // unusual but valid

        // Type 3: Instance method reference on arbitrary instance of type
        Function<String, String> toUpper = String::toUpperCase; // s -> s.toUpperCase()

        // Type 4: Constructor reference
        Supplier<ArrayList<String>> newList = ArrayList::new;

        // Practical use: sorting with lambda vs method reference
        List<String> names = new ArrayList<>(Arrays.asList("Charlie", "Alice", "Bob"));
        names.sort((a, b) -> a.compareTo(b)); // lambda
        names.sort(String::compareTo);         // method reference (same thing)
        System.out.println(names); // [Alice, Bob, Charlie]
    }
}

Step 8 — Stream API: Functional Data Processing

The Stream API (Java 8) allows you to process collections of data in a functional, declarative style. Streams are lazy — intermediate operations are only executed when a terminal operation is called.

StreamAPI.javajava
import java.util.*;
import java.util.stream.*;

public class StreamAPI {
    record Employee(String name, String dept, double salary, int age) {}

    public static void main(String[] args) {
        List<Employee> employees = List.of(
            new Employee("Alice",   "Engineering", 95000, 28),
            new Employee("Bob",     "Marketing",   65000, 35),
            new Employee("Charlie", "Engineering", 85000, 32),
            new Employee("Diana",   "Engineering", 110000, 29),
            new Employee("Eve",     "Marketing",   72000, 26),
            new Employee("Frank",   "HR",           58000, 40)
        );

        // ============================================
        // INTERMEDIATE OPERATIONS (return Stream — lazy)
        // ============================================

        // filter(): keep elements matching a predicate
        List<String> engineers = employees.stream()
            .filter(e -> "Engineering".equals(e.dept()))
            .map(Employee::name)    // map(): transform each element
            .collect(Collectors.toList());
        System.out.println(engineers); // [Alice, Charlie, Diana]

        // sorted(): sort by a field
        List<Employee> bySalary = employees.stream()
            .sorted(Comparator.comparingDouble(Employee::salary).reversed())
            .collect(Collectors.toList());
        bySalary.forEach(e -> System.out.printf("%s: $%.0f%n", e.name(), e.salary()));

        // distinct(), limit(), skip()
        List<Integer> nums = List.of(1, 2, 2, 3, 3, 3, 4, 5);
        List<Integer> result = nums.stream()
            .distinct()   // remove duplicates: [1,2,3,4,5]
            .skip(1)      // skip first element: [2,3,4,5]
            .limit(3)     // take only 3: [2,3,4]
            .collect(Collectors.toList());
        System.out.println(result); // [2, 3, 4]

        // flatMap(): flatten nested collections into one stream
        List<List<Integer>> nested = List.of(List.of(1,2), List.of(3,4), List.of(5));
        List<Integer> flat = nested.stream()
            .flatMap(Collection::stream) // each inner list becomes a stream, then merged
            .collect(Collectors.toList());
        System.out.println(flat); // [1, 2, 3, 4, 5]

        // ============================================
        // TERMINAL OPERATIONS (trigger execution, return non-Stream)
        // ============================================

        // count()
        long engCount = employees.stream()
            .filter(e -> "Engineering".equals(e.dept()))
            .count();
        System.out.println("Engineers: " + engCount); // 3

        // reduce(): fold all elements into one value
        double totalSalary = employees.stream()
            .mapToDouble(Employee::salary)
            .sum(); // specialized IntStream/DoubleStream for primitives (avoids boxing)
        System.out.printf("Total salary: $%.0f%n", totalSalary);

        OptionalDouble avgSalary = employees.stream()
            .mapToDouble(Employee::salary)
            .average();
        avgSalary.ifPresent(a -> System.out.printf("Average salary: $%.0f%n", a));

        // min() / max()
        Optional<Employee> highestPaid = employees.stream()
            .max(Comparator.comparingDouble(Employee::salary));
        highestPaid.ifPresent(e -> System.out.println("Highest paid: " + e.name()));

        // anyMatch / allMatch / noneMatch
        boolean anyOver100k = employees.stream().anyMatch(e -> e.salary() > 100000);
        boolean allAdult    = employees.stream().allMatch(e -> e.age() >= 18);
        boolean noneUnder20 = employees.stream().noneMatch(e -> e.age() < 20);
        System.out.println(anyOver100k + " " + allAdult + " " + noneUnder20);

        // findFirst() / findAny()
        Optional<Employee> firstEng = employees.stream()
            .filter(e -> "Engineering".equals(e.dept()))
            .findFirst();
        firstEng.ifPresent(e -> System.out.println("First engineer: " + e.name()));

        // ============================================
        // COLLECTORS: grouping, partitioning, joining
        // ============================================

        // groupingBy: group employees by department
        Map<String, List<Employee>> byDept = employees.stream()
            .collect(Collectors.groupingBy(Employee::dept));
        byDept.forEach((dept, emps) ->
            System.out.println(dept + ": " + emps.stream().map(Employee::name).toList()));

        // groupingBy with downstream collector: count per department
        Map<String, Long> countByDept = employees.stream()
            .collect(Collectors.groupingBy(Employee::dept, Collectors.counting()));
        System.out.println(countByDept); // {Engineering=3, Marketing=2, HR=1}

        // groupingBy with average salary per dept
        Map<String, Double> avgByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::dept,
                Collectors.averagingDouble(Employee::salary)));
        System.out.println(avgByDept);

        // partitioningBy: split into two groups (true/false)
        Map<Boolean, List<Employee>> partition = employees.stream()
            .collect(Collectors.partitioningBy(e -> e.salary() > 80000));
        System.out.println("High earners: " +
            partition.get(true).stream().map(Employee::name).toList());

        // joining: concatenate strings
        String namesList = employees.stream()
            .map(Employee::name)
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println(namesList); // [Alice, Bob, Charlie, Diana, Eve, Frank]

        // toMap: convert to map
        Map<String, Double> nameSalaryMap = employees.stream()
            .collect(Collectors.toMap(Employee::name, Employee::salary));
        System.out.println(nameSalaryMap.get("Alice")); // 95000.0
    }
}

Step 9 — Optional: Null-Safe Values

Optional<T> is a container that may or may not contain a non-null value. It forces you to handle the missing-value case explicitly, eliminating NullPointerExceptions when used at API boundaries.

OptionalExample.javajava
import java.util.Optional;

public class OptionalExample {

    // Return Optional instead of null — communicates clearly that value may be absent
    static Optional<String> findUserById(int id) {
        if (id == 1) return Optional.of("Kuldeep");  // value is present
        return Optional.empty();                       // no value
    }

    static Optional<String> getEmailForUser(String name) {
        if ("Kuldeep".equals(name)) return Optional.of("kuldeep@example.com");
        return Optional.empty();
    }

    public static void main(String[] args) {

        // Creating Optional
        Optional<String> present = Optional.of("Hello");         // must be non-null
        Optional<String> empty   = Optional.empty();             // no value
        Optional<String> nullable = Optional.ofNullable(null);   // safe: wraps null as empty

        // Checking and retrieving
        System.out.println(present.isPresent()); // true
        System.out.println(empty.isPresent());   // false
        System.out.println(present.isEmpty());   // false (Java 11+)

        // get(): unsafe — throws NoSuchElementException if empty. Avoid in production.
        // System.out.println(empty.get()); // throws!

        // orElse(): provide a default value
        String value = empty.orElse("default");
        System.out.println(value); // default

        // orElseGet(): lazy default — supplier called only if empty (preferred for expensive operations)
        String value2 = empty.orElseGet(() -> "computed default");

        // orElseThrow(): throw if empty (Java 10+)
        // empty.orElseThrow(() -> new RuntimeException("Not found"));

        // ifPresent(): action only if value exists (like Consumer)
        present.ifPresent(v -> System.out.println("Found: " + v)); // Found: Hello

        // ifPresentOrElse() (Java 9+)
        empty.ifPresentOrElse(
            v -> System.out.println("Value: " + v),
            () -> System.out.println("No value found")
        ); // No value found

        // map(): transform value if present (still returns Optional)
        Optional<Integer> length = present.map(String::length);
        System.out.println(length); // Optional[5]

        // filter(): keep value only if condition is met
        Optional<String> filtered = present.filter(s -> s.length() > 3);
        System.out.println(filtered); // Optional[Hello]

        // flatMap(): when the mapper itself returns an Optional (avoids Optional<Optional<T>>)
        Optional<String> email = findUserById(1)
            .flatMap(OptionalExample::getEmailForUser);
        System.out.println(email.orElse("No email")); // kuldeep@example.com

        Optional<String> noEmail = findUserById(99)
            .flatMap(OptionalExample::getEmailForUser);
        System.out.println(noEmail.orElse("No email")); // No email

        // or() (Java 9+): provide alternative Optional if empty
        Optional<String> alternative = empty.or(() -> Optional.of("fallback"));
        System.out.println(alternative.get()); // fallback
    }
}