Module 08: Multithreading and Concurrency - Exercises

Practice exercises to master concurrent programming and thread management in Java

Practice Exercises

Exercise 1: Basic Thread Creation

Practice creating and running threads using different approaches.

Requirements:

  • Create a thread by extending the Thread class
  • Create a thread by implementing the Runnable interface
  • Create a thread using lambda expressions
  • Start multiple threads and observe their execution
  • Use Thread.sleep() to simulate work
Sample Code:
public class ThreadCreationPractice {
    // Extending Thread class
    static class MyThread extends Thread {
        @Override
        public void run() {
            for (int i = 1; i <= 5; i++) {
                System.out.println("MyThread: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    // Implementing Runnable interface
    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            for (int i = 1; i <= 5; i++) {
                System.out.println("MyRunnable: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    public static void main(String[] args) {
        // Using Thread class
        MyThread thread1 = new MyThread();
        thread1.start();
        
        // Using Runnable interface
        Thread thread2 = new Thread(new MyRunnable());
        thread2.start();
        
        // Using lambda expression
        Thread thread3 = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                System.out.println("Lambda Thread: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread3.start();
    }
}

Exercise 2: Thread Synchronization

Practice using synchronized methods and blocks to prevent race conditions.

Requirements:

  • Create a shared counter class with synchronized methods
  • Create multiple threads that increment the counter
  • Demonstrate race conditions without synchronization
  • Use synchronized blocks for fine-grained control
  • Compare the results with and without synchronization
Sample Code:
public class SynchronizationPractice {
    static class SharedCounter {
        private int count = 0;
        
        // Synchronized method
        public synchronized void increment() {
            count++;
        }
        
        // Synchronized block for fine-grained control
        public void incrementWithBlock() {
            // Some non-critical code here
            synchronized (this) {
                count++;
            }
            // More non-critical code here
        }
        
        public synchronized int getCount() {
            return count;
        }
    }
    
    static class IncrementerThread implements Runnable {
        private SharedCounter counter;
        private int increments;
        
        public IncrementerThread(SharedCounter counter, int increments) {
            this.counter = counter;
            this.increments = increments;
        }
        
        @Override
        public void run() {
            for (int i = 0; i < increments; i++) {
                counter.increment();
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        SharedCounter counter = new SharedCounter();
        
        // Create multiple threads
        Thread thread1 = new Thread(new IncrementerThread(counter, 1000));
        Thread thread2 = new Thread(new IncrementerThread(counter, 1000));
        
        thread1.start();
        thread2.start();
        
        // Wait for both threads to complete
        thread1.join();
        thread2.join();
        
        System.out.println("Final count: " + counter.getCount());
    }
}

Exercise 3: Thread Communication

Practice using wait(), notify(), and notifyAll() for thread communication.

Requirements:

  • Create a producer-consumer scenario using wait/notify
  • Implement a bounded buffer with synchronization
  • Use wait() when the buffer is full or empty
  • Use notify() to wake up waiting threads
  • Handle multiple producers and consumers
Sample Code:
public class ThreadCommunicationPractice {
    static class BoundedBuffer {
        private final int[] buffer;
        private int count = 0;
        private int putIndex = 0;
        private int takeIndex = 0;
        
        public BoundedBuffer(int size) {
            this.buffer = new int[size];
        }
        
        public synchronized void put(int value) throws InterruptedException {
            while (count == buffer.length) {
                System.out.println("Buffer full, producer waiting...");
                wait();
            }
            
            buffer[putIndex] = value;
            putIndex = (putIndex + 1) % buffer.length;
            count++;
            System.out.println("Produced: " + value);
            notify();
        }
        
        public synchronized int take() throws InterruptedException {
            while (count == 0) {
                System.out.println("Buffer empty, consumer waiting...");
                wait();
            }
            
            int value = buffer[takeIndex];
            takeIndex = (takeIndex + 1) % buffer.length;
            count--;
            System.out.println("Consumed: " + value);
            notify();
            return value;
        }
    }
    
    static class Producer implements Runnable {
        private BoundedBuffer buffer;
        private int items;
        
        public Producer(BoundedBuffer buffer, int items) {
            this.buffer = buffer;
            this.items = items;
        }
        
        @Override
        public void run() {
            for (int i = 0; i < items; i++) {
                try {
                    buffer.put(i);
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    static class Consumer implements Runnable {
        private BoundedBuffer buffer;
        private int items;
        
        public Consumer(BoundedBuffer buffer, int items) {
            this.buffer = buffer;
            this.items = items;
        }
        
        @Override
        public void run() {
            for (int i = 0; i < items; i++) {
                try {
                    buffer.take();
                    Thread.sleep(150);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

Exercise 4: Executor Framework

Practice using the Executor framework for thread management.

Requirements:

  • Create a fixed thread pool using Executors.newFixedThreadPool()
  • Submit multiple tasks to the executor
  • Use Callable interface to return results from threads
  • Handle Future objects to get results
  • Shutdown the executor properly
Sample Code:
import java.util.concurrent.*;

public class ExecutorFrameworkPractice {
    static class Task implements Callable {
        private int id;
        
        public Task(int id) {
            this.id = id;
        }
        
        @Override
        public Integer call() throws Exception {
            System.out.println("Task " + id + " started");
            Thread.sleep(1000); // Simulate work
            System.out.println("Task " + id + " completed");
            return id * 10;
        }
    }
    
    public static void main(String[] args) {
        // Create a fixed thread pool
        ExecutorService executor = Executors.newFixedThreadPool(3);
        
        try {
            // Submit multiple tasks
            Future future1 = executor.submit(new Task(1));
            Future future2 = executor.submit(new Task(2));
            Future future3 = executor.submit(new Task(3));
            
            // Get results
            System.out.println("Result 1: " + future1.get());
            System.out.println("Result 2: " + future2.get());
            System.out.println("Result 3: " + future3.get());
            
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        } finally {
            // Shutdown the executor
            executor.shutdown();
            try {
                if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                    executor.shutdownNow();
                }
            } catch (InterruptedException e) {
                executor.shutdownNow();
            }
        }
    }
}

Exercise 5: Thread Safety and Atomic Operations

Practice using atomic classes and thread-safe collections.

Requirements:

  • Use AtomicInteger for thread-safe counter operations
  • Use ConcurrentHashMap for thread-safe map operations
  • Use CopyOnWriteArrayList for thread-safe list operations
  • Compare performance with synchronized alternatives
  • Create a thread-safe bank account class
Sample Code:
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

public class ThreadSafetyPractice {
    // Thread-safe counter using AtomicInteger
    static class AtomicCounter {
        private AtomicInteger count = new AtomicInteger(0);
        
        public void increment() {
            count.incrementAndGet();
        }
        
        public void decrement() {
            count.decrementAndGet();
        }
        
        public int getCount() {
            return count.get();
        }
    }
    
    // Thread-safe bank account
    static class BankAccount {
        private AtomicInteger balance;
        
        public BankAccount(int initialBalance) {
            this.balance = new AtomicInteger(initialBalance);
        }
        
        public void deposit(int amount) {
            balance.addAndGet(amount);
        }
        
        public boolean withdraw(int amount) {
            while (true) {
                int currentBalance = balance.get();
                if (currentBalance < amount) {
                    return false; // Insufficient funds
                }
                if (balance.compareAndSet(currentBalance, currentBalance - amount)) {
                    return true; // Withdrawal successful
                }
                // If CAS failed, retry
            }
        }
        
        public int getBalance() {
            return balance.get();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        // Test atomic counter
        AtomicCounter counter = new AtomicCounter();
        
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("Final count: " + counter.getCount());
        
        // Test thread-safe collections
        ConcurrentHashMap map = new ConcurrentHashMap<>();
        CopyOnWriteArrayList list = new CopyOnWriteArrayList<>();
        
        // These operations are thread-safe
        map.put("key1", 1);
        list.add("item1");
    }
}