Topic 14 of 28 · Android Native Developer

Topic 14 : Multithreading in Java

Lesson TL;DRTopic 14: Multithreading in Java 📖 6 min read · 🎯 advanced · 🧭 Prerequisites: operatorsoncollections, miniproject1weekdurationuntillaunchintheplaystore Why this matters Up until now, your Android a...
6 min read·advanced·multithreading · java · concurrency · threads

Topic 14: Multithreading in Java

📖 6 min read · 🎯 advanced · 🧭 Prerequisites: operators-on-collections, mini-project-1-week-duration-until-launch-in-the-playstore

Why this matters

Up until now, your Android app has been doing one thing at a time — each line of code waits for the previous one to finish. That works fine until you try to fetch data from the internet or read a large file. The moment you do that on the main thread, your UI freezes. Users see a blank screen, and Android may show that dreaded "App Not Responding" dialog. That's where multithreading comes in. In this lesson, you'll learn how Java lets your app do multiple things at once — and why getting it wrong leads to bugs like a counter that should read 2000 but shows 1743 instead.

What You'll Learn

  • Understand what a thread is and the three concrete benefits of multithreading in Java
  • Create threads using both the Thread class extension and the Runnable interface
  • Trace the five states of a thread's lifecycle
  • Protect shared state with the synchronized keyword
  • Manage a pool of threads using ExecutorService from java.util.concurrent

The Analogy

Imagine a busy café kitchen. A single cook handles every order one at a time — take order, make coffee, plate food, take next order. That's a single-threaded program: perfectly correct but painfully slow when twenty customers walk in at once. Now the head chef hires a barista, a line cook, and a cashier — three workers running simultaneously, sharing the same oven and the same till. The café hums. But if the barista and the line cook both reach for the cash drawer at the same instant and neither yields, bills go missing. That shared cash drawer is your shared memory, and synchronization is the rule that says one hand in the drawer at a time.

Chapter 1: What Is Multithreading and Why Does It Matter?

A thread is a lightweight sub-process — the smallest unit of processing Java can schedule. The JVM launches your program on a single main thread, but nothing stops you from spawning more. Java ships with built-in support via two pillars:

  • java.lang.Thread — the core class representing a thread
  • java.util.concurrent — a higher-level toolkit of thread pools, locks, and synchronizers

Benefits of Multithreading

  1. Increased Performance — tasks that can run independently (file I/O, network calls, computation) execute concurrently instead of queuing up.
  2. Improved Responsiveness — the UI thread stays free to render frames while background threads do heavy lifting; users never see a frozen screen.
  3. Better Resource Utilization — modern Android devices have multi-core CPUs; multithreading lets your app exploit every core instead of leaving them idle.

Chapter 2: Creating Threads in Java

There are two standard ways to create a thread.

Method 1: Extending the Thread Class

Subclass Thread and override its run() method. Call start() — never run() directly — to hand execution off to the JVM scheduler.

public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread " + Thread.currentThread().getId() + " is running");
    }

    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();
        thread1.start(); // Starts the first thread
        thread2.start(); // Starts the second thread
    }
}

MyThread extends Thread and overrides run(). Calling start() on each instance lets the JVM schedule them independently — you will often see both lines print in interleaved order.

Method 2: Implementing the Runnable Interface

A cleaner approach: implement Runnable (a single-method functional interface) and pass an instance to a Thread constructor. This leaves your class free to extend another class.

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Thread " + Thread.currentThread().getId() + " is running");
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(new MyRunnable());
        Thread thread2 = new Thread(new MyRunnable());
        thread1.start(); // Starts the first thread
        thread2.start(); // Starts the second thread
    }
}

MyRunnable implements Runnable and overrides run(). Instances are handed to Thread objects, which are then started. Because Runnable is a functional interface, you can also supply a lambda: new Thread(() -> System.out.println("hi")).

Chapter 3: The Thread Lifecycle

A Java thread passes through exactly five states from the moment it is created to the moment it dies.

stateDiagram-v2
    [*] --> New : Thread object created
    New --> Runnable : start() called
    Runnable --> Running : CPU time granted
    Running --> Runnable : time-slice ends / yield()
    Running --> Blocked_Waiting : waiting for lock / sleep() / wait()
    Blocked_Waiting --> Runnable : lock acquired / notified / timeout
    Running --> Terminated : run() returns
    Terminated --> [*]
StateMeaning
NewThread object constructed; start() not yet called
RunnableReady to run; waiting for the scheduler to assign CPU time
RunningCurrently executing on a CPU core
Blocked / WaitingPaused — waiting for a lock, an I/O result, sleep(), or wait()
Terminatedrun() has returned or an uncaught exception ended the thread

Understanding this lifecycle is essential for debugging deadlocks and starvation: a thread stuck in Blocked/Waiting indefinitely is almost always waiting for a lock held by another thread that is itself blocked.

Chapter 4: Synchronization

When two threads read and write the same variable without coordination, you get a race condition — the final value depends on which thread wins the CPU lottery, producing unpredictable results. The synchronized keyword forces mutual exclusion: only one thread may execute a synchronized block or method at a time.

Synchronized Method

public class Counter {
    private int count = 0;

    // Synchronized method — only one thread enters at a time
    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        thread1.start();
        thread2.start();

        // Wait for both threads to finish before reading the count
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // Without synchronization this could print anything < 2000
        System.out.println("Final count: " + counter.getCount()); // Always 2000
    }
}

Key points:

  • synchronized on increment() locks the Counter instance's monitor for the duration of the method call.
  • thread1.join() and thread2.join() block the main thread until both worker threads have finished — without this, getCount() might be called before either thread completes.
  • Without synchronized, the count++ operation (which is a read-modify-write under the hood) is not atomic, and the final count can be anywhere from 1001 to 2000.

Chapter 5: The java.util.concurrent Package

Manually creating and managing threads works for simple cases, but real applications need more control. The java.util.concurrent package offers production-grade utilities — thread pools, blocking queues, atomic variables, and more.

ExecutorService and Thread Pools

Creating a new Thread object for every small task is expensive. A thread pool pre-creates a fixed number of worker threads and reuses them across many tasks.

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

public class ThreadPoolExample {
    public static void main(String[] args) {
        // Create a fixed thread pool with 2 threads
        ExecutorService executor = Executors.newFixedThreadPool(2);

        // Submit 5 tasks — the pool queues extras until a thread is free
        for (int i = 0; i < 5; i++) {
            executor.submit(() -> {
                System.out.println("Thread " + Thread.currentThread().getId() + " is running");
            });
        }

        // Signal that no more tasks will be submitted; pool drains and shuts down
        executor.shutdown();
    }
}

What happens here step by step:

  1. Executors.newFixedThreadPool(2) creates a pool with exactly 2 worker threads.
  2. Five tasks are submitted. The first two start immediately; the remaining three queue up.
  3. As each worker finishes a task it picks the next one from the queue.
  4. executor.shutdown() tells the pool to stop accepting new tasks and terminate once the queue is empty.

ExecutorService is the recommended approach for Android background work when you are not using higher-level abstractions like Kotlin Coroutines or RxJava — it prevents unbounded thread creation that would otherwise eat memory and crash the device.

🧪 Try It Yourself

Task: Build a BankAccount class with a deposit(int amount) method protected by synchronized. Spawn 10 threads, each depositing 100 units. Assert the final balance equals 1000.

Success criterion: The program always prints Final balance: 1000 no matter how many times you run it.

Starter snippet:

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

public class BankAccount {
    private int balance = 0;

    public synchronized void deposit(int amount) {
        balance += amount;
    }

    public int getBalance() {
        return balance;
    }

    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount();
        ExecutorService executor = Executors.newFixedThreadPool(5);

        for (int i = 0; i < 10; i++) {
            executor.submit(() -> account.deposit(100));
        }

        executor.shutdown();
        // TODO: wait for all tasks to finish before printing
        System.out.println("Final balance: " + account.getBalance());
    }
}

Hint: executor.awaitTermination(5, TimeUnit.SECONDS) blocks until the pool finishes or the timeout fires. Import java.util.concurrent.TimeUnit.

🔍 Checkpoint Quiz

Q1. Which of the following is NOT a benefit of multithreading listed in this lesson?

A) Increased performance through concurrent task execution
B) Automatic garbage collection of unused threads
C) Improved UI responsiveness
D) Better utilization of multi-core CPUs

Q2. Given the code below, what is the most likely printed output if synchronized is removed from increment()?

Counter counter = new Counter(); // count starts at 0
Thread t1 = new Thread(() -> { for (int i=0;i<1000;i++) counter.increment(); });
Thread t2 = new Thread(() -> { for (int i=0;i<1000;i++) counter.increment(); });
t1.start(); t2.start(); t1.join(); t2.join();
System.out.println(counter.getCount());

A) Always exactly 2000
B) Always exactly 1000
C) Some value between 1001 and 2000, non-deterministically
D) Always 0 because both threads read the initial value

Q3. A thread calls thread1.join(). Which state does the calling thread enter while it waits?

A) New
B) Running
C) Blocked / Waiting
D) Terminated

Q4. You need to execute 100 short background tasks without spawning 100 separate Thread objects. Which java.util.concurrent tool is most appropriate, and what factory method creates one with a capped number of workers?

A1. B) — Automatic garbage collection of unused threads is handled by the JVM independently of multithreading. The three stated benefits are performance, responsiveness, and resource utilization.

A2. C) — Without synchronized, count++ is not atomic. Two threads can read the same value simultaneously, both increment it, and write back the same result, losing an increment. The final value is unpredictable but usually less than 2000.

A3. C) — join() suspends the calling thread in the Blocked/Waiting state until the target thread reaches Terminated.

A4. ExecutorService, created with Executors.newFixedThreadPool(n). Submit tasks via executor.submit(runnable) and call executor.shutdown() when done. The pool reuses its n worker threads instead of allocating a new thread per task.

🪞 Recap

  • A thread is Java's smallest unit of execution; java.lang.Thread and java.util.concurrent are its two support pillars.
  • Threads are created by either extending Thread and overriding run(), or implementing Runnable and passing it to a Thread constructor.
  • Every thread moves through five states: New → Runnable → Running → Blocked/Waiting → Terminated.
  • The synchronized keyword enforces mutual exclusion, preventing race conditions on shared mutable state.
  • ExecutorService with Executors.newFixedThreadPool(n) manages reusable thread pools, avoiding the cost of creating a new thread per task.

📚 Further Reading

Like this topic? It’s one of 28 in Android Native Developer.

Block your seat for ₹2,500 and join the next cohort.