DOCODIVE
Advanced Free Learning Path

Java Advanced Course

Master the hardest parts of Java with 25 advanced lessons. Deep-dive into streams, lambdas, concurrency, Optional, reflection, records, serialization, and more — each with code, exact output, and official Oracle documentation links.

5–7 weeks 23 lessons 3 projects 1 capstone Intermediate knowledge required
Start Learning
01

Lambda Expressions

A lambda is a concise way to represent a single-method interface (functional interface). Syntax: (parameters) -> expression. Lambdas enable functional programming in Java and remove the boilerplate of anonymous inner classes.

Java
import java.util.List;
import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("Ali");
        names.add("Sara");
        names.add("Zain");

        names.forEach(name -> System.out.println(name));
    }
}
forEach accepts a Consumer functional interface. The lambda 'name -> System.out.println(name)' is shorthand for a method that takes one argument and prints it. The lambda is passed directly as an argument, replacing an anonymous inner class.
terminal
Ali Sara Zain
forEach called the lambda once per element, passing each name. The lambda printed it. This is cleaner than writing a loop and shows how lambdas make code more concise.
02

Functional Interfaces

A functional interface has exactly one abstract method. Java provides several built-in ones: Predicate (test), Function (apply), Consumer (accept), and Supplier (get). These are the building blocks of the Stream API.

Java
import java.util.function.Function;
import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> square = x -> x * x;
        Predicate<Integer> isEven = x -> x % 2 == 0;

        System.out.println(square.apply(5));
        System.out.println(isEven.test(10));
    }
}
Function<Integer, Integer> takes an Integer and returns an Integer; the lambda squares it. Predicate<Integer> takes an Integer and returns boolean; the lambda tests evenness. apply() and test() invoke the lambda.
terminal
25 true
square.apply(5) returned 25, and isEven.test(10) returned true. Using standard functional interfaces means you rarely need to define your own.
03

Method References

A method reference is shorthand for a lambda that calls an existing method. Syntax: Class::method. There are four kinds: static (Class::staticMethod), instance (obj::instanceMethod), arbitrary object (Class::instanceMethod), and constructor (Class::new).

Java
import java.util.List;
import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("ali");
        names.add("sara");

        names.replaceAll(String::toUpperCase);
        names.forEach(System.out::println);
    }
}
String::toUpperCase is an arbitrary-object method reference — it calls toUpperCase() on each element. System.out::println is an instance method reference that prints each element. Both are shorthand for lambdas.
terminal
ALI SARA
replaceAll applied toUpperCase to each element, and forEach printed each. Method references made the code shorter and more readable than equivalent lambdas.
04

Streams Basics

A Stream is a sequence of elements that supports functional-style operations like filter, map, and collect. Streams are lazy and do not modify the source. They process data in a pipeline without explicit loops.

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

public class Main {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);

        List<Integer> evensSquared = nums.stream()
                .filter(n -> n % 2 == 0)
                .map(n -> n * n)
                .collect(Collectors.toList());

        System.out.println(evensSquared);
    }
}
stream() opens a pipeline. filter keeps even numbers. map squares each. collect gathers the results into a List. Each operation returns a new stream, and the original list is unchanged.
terminal
[4, 16, 36]
Even numbers 2, 4, 6 were kept and squared to 4, 16, 36. The pipeline expressed the transformation declaratively without a loop.
05

Stream map & filter

map transforms each element, and filter selects elements matching a predicate. Both are intermediate operations that return a new stream. They can be chained to build complex transformations.

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

public class Main {
    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "cherry", "date");

        List<Integer> lengths = words.stream()
                .filter(w -> w.length() > 5)
                .map(String::length)
                .collect(Collectors.toList());

        System.out.println(lengths);
    }
}
filter keeps words longer than 5 characters (banana, cherry). map converts each to its length using String::length. collect gathers the lengths into a list.
terminal
[6, 6]
banana and cherry both have length 6. The filter removed the shorter words, and map extracted the lengths. Chaining makes the intent clear.
06

Stream reduce

reduce combines all elements into a single result using an accumulator. For example, summing numbers or finding the max. reduce is a terminal operation that produces a value from a stream.

Java
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5);

        int sum = nums.stream().reduce(0, Integer::sum);
        int max = nums.stream().reduce(Integer.MIN_VALUE, Integer::max);

        System.out.println(sum);
        System.out.println(max);
    }
}
reduce(0, Integer::sum) starts at 0 and adds each element. reduce(Integer.MIN_VALUE, Integer::max) starts small and keeps the larger value. Both produce a single final result.
terminal
15 5
Sum is 1+2+3+4+5 = 15, and max is 5. reduce folds the entire stream into one value using the provided accumulator.
07

Optional

Optional is a container that may or may not hold a value, eliminating null checks and NullPointerException. Methods like orElse, ifPresent, and map handle the empty case safely.

Java
import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        Optional<String> present = Optional.of("Hello");
        Optional<String> empty = Optional.empty();

        System.out.println(present.orElse("Fallback"));
        System.out.println(empty.orElse("Fallback"));
        present.ifPresent(v -> System.out.println(v));
    }
}
Optional.of creates an Optional holding a value; Optional.empty creates one with nothing. orElse returns the value or a fallback. ifPresent runs a lambda only when a value exists.
terminal
Hello Fallback Hello
present returned its value, empty returned the fallback, and ifPresent printed Hello. Optional makes null-handling explicit and safe.
08

Threads Basics

A thread is a separate path of execution. You create one by extending Thread or implementing Runnable. start() launches the thread, and the JVM runs it concurrently with the main thread.

Java
class Worker extends Thread {
    @Override
    public void run() {
        System.out.println("Worker running");
    }
}

public class Main {
    public static void main(String[] args) {
        Worker t = new Worker();
        t.start();
        System.out.println("Main running");
    }
}
Worker extends Thread and overrides run(). t.start() launches the thread, which executes run() concurrently. The main thread continues to the next print. Order between the two outputs is not guaranteed.
terminal
Main running Worker running
The exact order can vary between runs because both threads execute concurrently. This non-deterministic ordering is the essence of multithreading.
09

Runnable Interface

Runnable is a functional interface with a single run() method. Implementing Runnable is preferred over extending Thread because Java supports single inheritance — your class can extend something else while still being runnable.

Java
class Task implements Runnable {
    @Override
    public void run() {
        System.out.println("Task running");
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new Task());
        t.start();
    }
}
Task implements Runnable and overrides run(). A Thread is created with the Runnable and started. The Thread delegates to Task's run() method when it begins execution.
terminal
Task running
The thread ran the Runnable's run() method. Using Runnable decouples the task from the thread mechanics, which is cleaner design.
10

Synchronization

When multiple threads access shared data, race conditions can corrupt it. The synchronized keyword ensures only one thread enters a critical section at a time, making operations atomic.

Java
class Counter {
    private int count = 0;

    synchronized void increment() {
        count++;
    }

    int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter c = new Counter();

        Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) c.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) c.increment(); });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(c.getCount());
    }
}
increment() is synchronized, so only one thread runs it at a time. Two threads each increment 1000 times. join() waits for both to finish before reading. Without synchronized, the final count could be less than 2000 due to race conditions.
terminal
2000
Synchronization protected the shared counter, so every increment was recorded. The final count is exactly 2000. This demonstrates how synchronized prevents data corruption.
11

ExecutorService

ExecutorService manages a pool of threads and executes submitted tasks. It abstracts thread creation, improving resource reuse. shutdown() stops accepting new tasks, and awaitTermination waits for completion.

Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        pool.submit(() -> System.out.println("Task A"));
        pool.submit(() -> System.out.println("Task B"));
        pool.submit(() -> System.out.println("Task C"));

        pool.shutdown();
    }
}
newFixedThreadPool(2) creates a pool of two threads. submit adds three tasks, which run on the two threads as they become free. shutdown stops accepting new tasks and allows running tasks to finish.
terminal
Task A Task B Task C
Three tasks ran on a two-thread pool. The pool reused threads, running tasks as threads became available. ExecutorService simplifies concurrent task management.
12

Future & Callable

Callable is like Runnable but returns a value. submit() returns a Future that represents the pending result. get() blocks until the result is available, retrieving the return value.

Java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newSingleThreadExecutor();

        Callable<Integer> task = () -> {
            int sum = 0;
            for (int i = 1; i <= 100; i++) sum += i;
            return sum;
        };

        Future<Integer> future = pool.submit(task);
        System.out.println(future.get());
        pool.shutdown();
    }
}
Callable computes the sum 1 to 100. submit returns a Future. future.get() blocks until the task completes and returns the result (5050). The pool is then shut down.
terminal
5050
The task computed 5050 in a background thread. get() retrieved the result. Future lets you launch a task and collect its result when ready.
13

Annotations

Annotations attach metadata to code. Built-in ones include @Override (verifies overriding), @Deprecated (marks outdated), and @SuppressWarnings (silences warnings). Frameworks and tools read annotations for behavior.

Java
class Parent {
    void print() {
        System.out.println("Parent");
    }
}

class Child extends Parent {
    @Override
    void print() {
        System.out.println("Child");
    }
}

public class Main {
    public static void main(String[] args) {
        Child c = new Child();
        c.print();
    }
}
@Override tells the compiler that Child.print() is meant to override Parent.print(). If the method signature did not match, the compiler would error. The annotation documents intent and catches mistakes.
terminal
Child
Child's print() ran because it correctly overrode Parent's method. @Override provided compile-time safety that the override is valid.
14

Reflection

Reflection lets a program inspect classes, methods, and fields at runtime. Class.forName loads a class, and getDeclaredMethods lists its methods. Reflection powers frameworks, serialization, and testing tools.

Java
import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception {
        Class<?> cls = Class.forName("java.lang.String");
        System.out.println("Class: " + cls.getName());
        for (Method m : cls.getDeclaredMethods()) {
            if (m.getName().equals("toUpperCase")) {
                System.out.println("Method: " + m.getName());
            }
        }
    }
}
Class.forName loads the String class. getName returns its fully qualified name. getDeclaredMethods returns all methods, and the loop filters for toUpperCase. This inspects the class at runtime.
terminal
Class: java.lang.String Method: toUpperCase
Reflection successfully inspected String and found the toUpperCase method. This runtime introspection is impossible with normal compile-time code.
15

Generics Wildcards

Wildcards (?), <? extends T>, and <? super T> make generic code more flexible. ? extends T accepts T or subclasses (reading), ? super T accepts T or superclasses (writing). This is the PECS principle: Producer Extends, Consumer Super.

Java
import java.util.List;
import java.util.ArrayList;

public class Main {
    static double sum(List<? extends Number> numbers) {
        double total = 0;
        for (Number n : numbers) total += n.doubleValue();
        return total;
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
        System.out.println(sum(ints));
    }
}
sum accepts List<? extends Number>, meaning a list of Number or any subclass (Integer, Double). It reads values as Number and sums them. This lets one method work for different numeric list types.
terminal
6.0
The Integer list was accepted and summed to 6.0. The wildcard made the method flexible across numeric types while keeping type safety.
16

try-with-resources Deep Dive

try-with-resources automatically closes resources that implement AutoCloseable. Declaring resources in the try parentheses guarantees close() is called after the block, even on exception. This replaces verbose finally blocks.

Java
import java.io.BufferedReader;
import java.io.FileReader;

public class Main {
    public static void main(String[] args) {
        try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
            System.out.println(br.readLine());
        } catch (Exception e) {
            System.out.println("File not found or error");
        }
    }
}
BufferedReader is declared inside the try parentheses. It is automatically closed when the block exits, even if an exception occurs. The catch handles the FileNotFoundException when the file is missing.
terminal
File not found or error
Because file.txt does not exist, the catch ran. Even so, the reader would have been closed automatically if it had opened. try-with-resources removes manual close() risk.
17

File I/O & NIO

java.nio.file provides the modern Files and Path APIs. Files.readString reads an entire file, Files.writeString writes one, and Path represents a file location. NIO is simpler and more efficient than the old File API.

Java
import java.nio.file.Files;
import java.nio.file.Path;

public class Main {
    public static void main(String[] args) throws Exception {
        Path path = Path.of("note.txt");
        Files.writeString(path, "Hello Java");
        String content = Files.readString(path);
        System.out.println(content);
        Files.delete(path);
    }
}
Path.of creates a Path. Files.writeString writes text to the file. Files.readString reads it back. Files.delete removes the file. All operations are concise NIO calls.
terminal
Hello Java
The file was written, read back, and printed. NIO's Files and Path APIs made file handling much simpler than old streams.
18

Serialization

Serialization converts an object into bytes for storage or transmission; deserialization reconstructs it. A class must implement Serializable. ObjectOutputStream writes objects, and ObjectInputStream reads them back.

Java
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

class Student implements Serializable {
    String name;
    int age;

    Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        Student s = new Student("Ali", 25);

        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("student.ser"))) {
            out.writeObject(s);
        }

        System.out.println("Serialized: " + s.name + " " + s.age);
    }
}
Student implements Serializable, making it savable. ObjectOutputStream writes the object to student.ser. try-with-resources closes the stream. The object state is persisted as bytes.
terminal
Serialized: Ali 25
The Student object was serialized to a file. The name and age are now stored as bytes that could be read back later with ObjectInputStream.
19

Date & Time API (java.time)

The java.time package (Java 8+) provides immutable date-time classes. LocalDate for dates, LocalTime for times, and LocalDateTime for both. These replace the old mutable and confusing Date and Calendar.

Java
import java.time.LocalDate;

public class Main {
    public static void main(String[] args) {
        LocalDate today = LocalDate.now();
        LocalDate birthday = LocalDate.of(2000, 5, 15);
        System.out.println(today.getYear());
        System.out.println(birthday.getMonth());
    }
}
LocalDate.now() returns today's date. LocalDate.of creates a specific date. getYear() extracts the year, and getMonth() returns the month enum. The classes are immutable and thread-safe.
terminal
2026 MAY
The current year was printed (varies), and birthday's month was MAY. java.time is clearer and safer than the legacy date classes.
20

Enum Deep Dive

An enum is a fixed set of constants. Enums can have fields, constructors, and methods. They are type-safe and ideal for representing a fixed domain like days, directions, or statuses.

Java
enum Day {
    MONDAY("Weekday"), SATURDAY("Weekend");

    private final String type;

    Day(String type) {
        this.type = type;
    }

    String type() {
        return type;
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Day.MONDAY.type());
        System.out.println(Day.SATURDAY.type());
    }
}
Day has two constants, each with a type field. The constructor sets the field, and type() returns it. Each constant is a full object with behavior, not just a simple name.
terminal
Weekday Weekend
Each enum constant returned its own type. Enums with fields and methods carry rich, type-safe data.
21

Records (Java 16+)

A record is a special class for immutable data. It automatically generates the constructor, getters, equals, hashCode, and toString. Records are ideal for simple data carriers.

Java
record Point(int x, int y) {}

public class Main {
    public static void main(String[] args) {
        Point p = new Point(3, 4);
        System.out.println(p.x());
        System.out.println(p);
    }
}
Point is a record with two components, x and y. The compiler auto-generates the constructor, x() and y() accessors, and toString. p.x() reads the x component, and p uses the generated toString.
terminal
3 Point[x=3, y=4]
The record provided accessor x() and a readable toString without boilerplate. Records drastically reduce code for immutable data classes.
22

Default Methods in Interfaces

Default methods give interfaces concrete implementations. They use the default keyword. This allows adding methods to interfaces without breaking existing implementations.

Java
interface Greeting {
    default void greet() {
        System.out.println("Hello!");
    }
}

class Person implements Greeting {
}

public class Main {
    public static void main(String[] args) {
        Person p = new Person();
        p.greet();
    }
}
Greeting has a default greet() method with a body. Person implements Greeting without overriding greet, inheriting the default implementation. Calling p.greet() runs the default method.
terminal
Hello!
Person inherited the default method. Default methods let interfaces provide shared behavior without forcing every implementer to write it.
23

Advanced Recap + Capstone

You now know lambdas, functional interfaces, method references, streams, Optional, threads, synchronization, ExecutorService, Future/Callable, annotations, reflection, generics wildcards, NIO, serialization, the Date-Time API, enums, records, and default methods. Apply them in a capstone that combines streams, records, and concurrency.

Java
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

record Product(String name, double price) {}

public class Main {
    public static void main(String[] args) {
        List<Product> products = List.of(
                new Product("Apple", 1.5),
                new Product("Book", 12.0),
                new Product("Pen", 2.0)
        );

        double total = products.stream()
                .mapToDouble(Product::price)
                .sum();

        System.out.printf("Total: %.2f%n", total);

        ExecutorService pool = Executors.newSingleThreadExecutor();
        pool.submit(() -> System.out.println("Done processing"));
        pool.shutdown();
    }
}
Product is a record. The stream maps each product to its price and sums them. printf formats the total. An ExecutorService submits a small task. This combines records, streams, and concurrency.
terminal
Total: 15.50 Done processing
Prices summed to 15.50 (1.5 + 12.0 + 2.0). The executor ran the background task. The capstone shows advanced features working together.

Java Advanced Projects

Apply streams, concurrency, and advanced features. Click each project to open its full guide.

About this project

Analyze sales records using streams — filter, map, reduce, and collect. Compute total revenue, count high-value sales, and extract product names.

What you'll practice
streams filter map reduce records
Step-by-step: How it works
  1. A record Sale holds product and amount.
  2. A list of sales is created.
  3. mapToDouble extracts amounts, and sum() computes total.
  4. filter keeps sales above a threshold.
  5. map extracts product names, and collect builds a list.
Java
import java.util.List;
import java.util.stream.Collectors;

record Sale(String product, double amount) {}

public class SalesAnalyzer {
    public static void main(String[] args) {
        List sales = List.of(
                new Sale("Laptop", 1200),
                new Sale("Mouse", 25),
                new Sale("Keyboard", 75),
                new Sale("Monitor", 300)
        );

        double total = sales.stream()
                .mapToDouble(Sale::amount)
                .sum();

        List bigSales = sales.stream()
                .filter(s -> s.amount() > 100)
                .map(Sale::product)
                .collect(Collectors.toList());

        System.out.printf("Total revenue: $%.2f%n", total);
        System.out.println("Big sales: " + bigSales);
    }
}
Sample Run
terminal
Total revenue: $1600.00 Big sales: [Laptop, Monitor]

About this project

Execute multiple tasks concurrently using a thread pool and collect their results with Future. Each task computes a different sum.

What you'll practice
ExecutorService Callable Future thread pool
Step-by-step: How it works
  1. A fixed thread pool of 3 threads is created.
  2. Three Callable tasks are submitted.
  3. Each task returns a computed sum.
  4. Future.get() retrieves each result.
  5. The pool shuts down after all tasks complete.
Java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ParallelTasks {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(3);

        Callable sumTo100 = () -> {
            int s = 0;
            for (int i = 1; i <= 100; i++) s += i;
            return s;
        };

        Callable sumTo200 = () -> {
            int s = 0;
            for (int i = 1; i <= 200; i++) s += i;
            return s;
        };

        Callable sumTo300 = () -> {
            int s = 0;
            for (int i = 1; i <= 300; i++) s += i;
            return s;
        };

        Future f1 = pool.submit(sumTo100);
        Future f2 = pool.submit(sumTo200);
        Future f3 = pool.submit(sumTo300);

        System.out.println(f1.get());
        System.out.println(f2.get());
        System.out.println(f3.get());

        pool.shutdown();
    }
}
Sample Run
terminal
5050 20100 45150

About this project

Read a text file using NIO and analyze it with streams: count lines, count words, and find the longest word.

What you'll practice
Files.readString streams split max
Step-by-step: How it works
  1. Files.readString reads the whole file.
  2. lines() streams the lines for counting.
  3. split("\\\\s+") splits text into words.
  4. max() finds the longest word.
  5. Results are printed.
Java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Comparator;

public class TextAnalyzer {
    public static void main(String[] args) throws Exception {
        Path path = Path.of("sample.txt");
        Files.writeString(path, "hello world\\njava programming\\n");

        String content = Files.readString(path);
        long lines = content.lines().count();
        String[] words = content.split("\\\\s+");

        String longest = Arrays.stream(words)
                .max(Comparator.comparingInt(String::length))
                .orElse("");

        System.out.println("Lines: " + lines);
        System.out.println("Words: " + words.length);
        System.out.println("Longest word: " + longest);
    }
}
Sample Run
terminal
Lines: 2 Words: 3 Longest word: programming

Advanced Capstone Project

Combine records, streams, concurrency, and Optional into one complete project.

About this project

Build an employee analytics system using records and streams — compute average salary, find the highest-paid employee, and filter by department using Optional.

What you'll practice
records streams Optional map/filter reduce
Step-by-step: How it works
  1. An Employee record stores name, department, and salary.
  2. mapToDouble + average() computes average salary.
  3. max() finds the highest-paid employee as an Optional.
  4. filter selects employees from a department.
  5. Results are printed using Optional's orElse.
Java
import java.util.List;
import java.util.Optional;

record Employee(String name, String department, double salary) {}

public class EmployeeAnalytics {
    public static void main(String[] args) {
        List employees = List.of(
                new Employee("Ali", "IT", 75000),
                new Employee("Sara", "HR", 60000),
                new Employee("Zain", "IT", 90000),
                new Employee("Mia", "Finance", 80000)
        );

        double avg = employees.stream()
                .mapToDouble(Employee::salary)
                .average()
                .orElse(0);

        Optional highest = employees.stream()
                .max((a, b) -> Double.compare(a.salary(), b.salary()));

        long itCount = employees.stream()
                .filter(e -> e.department().equals("IT"))
                .count();

        System.out.printf("Average salary: $%.2f%n", avg);
        System.out.println("Highest paid: " + highest.map(Employee::name).orElse("None"));
        System.out.println("IT employees: " + itCount);
    }
}
Sample Run
terminal
Average salary: $76250.00 Highest paid: Zain IT employees: 2
You've completed all 23 lessons. Ready to continue?

Reinforce everything with Java Practice Resources — exercises and quizzes.

📱 Scan this QR code with your phone camera to instantly open this page.

Works on iOS, Android, and any modern device. No app installation required.

Account Verified!

Your email has been verified successfully.