• Labs icon Lab
  • Core Tech
Labs

Guided: Java SE 21 Developer (Exam 1Z0-830) - Concurrent Programming

Gain a solid understanding of concurrent programming in Java with this hands-on Guided Lab. Learn to create, manage, and synchronize threads while preventing race conditions and deadlocks. Explore ExecutorService for efficient thread management and leverage virtual threads for high-performance concurrency. By the end, you'll be able to build scalable, thread-safe applications.

Labs

Path Info

Level
Clock icon Beginner
Duration
Clock icon 49m
Published
Clock icon Mar 18, 2025

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Introduction

    In this lab, you will explore multi-threading and synchronization in Java. You will learn how to create and manage threads, apply synchronization techniques, and handle race conditions and deadlocks effectively.

    By the end of this lab, you will have a solid understanding of Java’s concurrency utilities and best practices.


    Concurrent Programming

    Modern applications often need to handle multiple tasks simultaneously to enhance performance and responsiveness.

    A Thread is the smallest unit of execution in a program. By default, Java programs run on a single main thread. However, creating multiple threads allows tasks to be executed concurrently, leading to improved efficiency.

    Benefits of Multi-threading:

    • Optimizes CPU utilization by running multiple tasks in parallel.
    • Enhances application responsiveness, particularly in UI-based environments.
    • Improves performance on multi-core processors through concurrent execution.

    Next, you will learn how to create threads in Java.

  2. Challenge

    Creating Threads

    Creating Threads in Java

    Java offers two primary ways to create a thread:

    1. Extending the Thread class

    A class can extend Thread and override the run() method to define its behavior.

    class MyThread extends Thread {
      @Override
      public void run() {
        System.out.println("Using Thread Class");
      }
    }
    

    An instance of this class can then be created to instantiate this thread.

    // Creating Thread using Thread class
    MyThread thread1 = new MyThread();
    

    2. Implementing the Runnable Interface

    A class can implement Runnable and provide a run() method for execution.

    class MyRunnable implements Runnable {
      @Override
      public void run() {
        System.out.println("Using Runnable Interface");
      }
    }
    

    An instance of this class can then be created and passed as an argument to the Thread class constructor.

    // Creating Thread using Runnable
    Thread thread = new Thread(new MyRunnable());
    

    Now, you will create two threads:

    • thread1 using the MyThread class.
    • thread2 using the MyRunnable class.

    To achieve this:

    • Modify MyThread to extend the Thread class.
    • Modify MyRunnable to implement the Runnable interface. Next, implement the Runnable interface. Next, Instantiate thread1 using the MyThread class. Now you will instantiate the thread2. Now you'll start both the threads. You can now test your program by typing following commands in the Terminal :
    javac CreatingThreads.java
    
    java CreatingThreads
    

    You should see an output :

    Thread running.
    Runnable running.
    

    In this step, you explored how to create threads. Next, you'll learn about race conditions and discover techniques to prevent them.

  3. Challenge

    Synchronizing Threads

    Race Condition

    When multiple threads access and modify a shared resource concurrently, it can lead to unpredictable results. This issue is known as a race condition. Since threads run independently, they may overlap in execution, causing inconsistencies in shared data.

    To prevent such conflicts, Java provides synchronization mechanisms.


    Synchronization in Java

    Synchronization ensures that only one thread can access a shared resource at a time. This is achieved using the synchronized keyword, which can be applied to methods or code blocks.

    Key Benefits of Synchronization:

    • Prevents race conditions by allowing only one thread to modify a shared resource at a time.
    • Ensures data integrity and consistency in multi-threaded programs.
    • Helps avoid unpredictable behavior caused by concurrent modifications. ---

    In the program SynchronizingThreads.java, a shared counter is modified by two threads concurrently. Each thread attempts to increment the counter 1000 times, but without synchronization, some increments are lost, resulting in inconsistent final values.

    When executed multiple times, the output varies due to lost increments:

    Final Counter Value: 1659
    Final Counter Value: 1520
    Final Counter Value: 2000
    Final Counter Value: 1874
    

    This occurs because multiple threads modify the counter simultaneously, leading to missed updates. --- Execute the SyncronizingThreads class and observe the value of counter.

    Now, you will use synchronized methods to ensure that only one thread can access and modify the counter at a time. This prevents race conditions, ensuring data consistency and accurate results.

    Using the synchronized Method

    In Java, declaring a method as synchronized guarantees that only one thread can execute it at any given time, preventing concurrent modifications to shared resources.

    Example:

    class BankAccount {
        private int balance = 0;
    
        // Synchronized Method
        public synchronized void deposit(int amount) {  
            balance += amount;
            System.out.println("Deposited $" + amount + ", Balance: $" + balance);
        }
    }
    

    By marking deposit() as synchronized, Java ensures that multiple threads cannot update the balance simultaneously, preventing data inconsistency. --- Now you will update the increment() method to make it synchronized. Now compile and excute the file. You should get an output:

    Final Counter Value: 2000
    

    In this step, you used synchronized methods to prevent race conditions, ensuring that only one thread modifies a shared resource at a time. This helps maintain data consistency and prevents unpredictable results caused by concurrent modifications.

    Next, you'll explore synchronized blocks and other locking techniques, such as ReentrantLock, for more fine-grained control over synchronization.

  4. Challenge

    Using Locks

    Synchronized Blocks

    While synchronized methods ensure thread safety, they lock the entire method, which may be inefficient if only a small portion of the code requires synchronization. Synchronized blocks allow you to lock only the critical section, improving performance by minimizing contention.

    A synchronized block allows you to lock a specific section of code using an object reference (this or a separate lock object).

    Example :

    class BankAccount {
      private int balance = 0;
      // Lock object
      private final Object lock = new Object(); 
    
      public void deposit(int amount) {
        System.out.println("Preparing to deposit $" + amount);
    
        // Only balance update is locked
        synchronized (lock) { 
          balance += amount;
          System.out.println("Deposited $" + amount + ", Balance: $" + balance);
        }
    
        System.out.println("Completed the transaction.");
      }
    }
    
    

    Now you will update the SyncronizingWithLocks.java to use lock. You can now test your program by typing following commands in the Terminal :

    javac SynchronizingWithLocks.java
    
    java SynchronizingWithLocks
    

    You should see an output :

    Final Counter Value: 2000
    

    In this step, you implemented synchronized blocks to prevent race conditions while allowing better performance by locking only critical sections instead of entire methods. This approach ensures thread safety while minimizing contention.

    Next, you'll explore ReentrantLock, which provides more flexibility and control over thread synchronization.

  5. Challenge

    Reentrant Lock

    ReentrantLock provides greater control over thread synchronization compared to synchronized. It allows explicit locking and unlocking, giving more flexibility.

    In this step, we will explore two approaches:

    • Using lock() – Forces threads to wait until they acquire the lock.
    • Using tryLock() – Allows threads to attempt acquiring the lock and proceed if unsuccessful.

    1. Using ReentrantLock with lock()

    This method blocks a thread until it acquires the lock.

    class BankAccount {
      private int balance = 0;
      private final Lock lock = new ReentrantLock();
    
      public void deposit(int amount) {
        // Acquire the lock before modifying the balance
        lock.lock();
        try {
          // Update balance after acquiring the lock
          balance += amount;
          System.out.println("Deposited $" + amount + ", Balance: $" + balance);
        } finally {
          // Ensure the lock is always released
          lock.unlock();
        }
      }
    }
    

    How lock() Works

    • A thread waits indefinitely until it acquires the lock.
    • Guarantees that only one thread can execute the critical section at a time.
    • If a thread holding the lock crashes or hangs, other threads remain blocked indefinitely.

    2. Using ReentrantLock with tryLock()

    This method attempts to acquire the lock but does not wait indefinitely if it is unavailable.

    class BankAccount {
      private int balance = 0;
      private final Lock lock = new ReentrantLock();
    
      public void deposit(int amount) {
        // Print message before attempting to acquire the lock
        System.out.println("Preparing to deposit $" + amount);
    
        // Attempt to acquire the lock without waiting indefinitely
        if (lock.tryLock()) {
          try {
            // Update balance after acquiring the lock
            balance += amount;
            System.out.println("Deposited $" + amount + ", Balance: $" + balance);
          } finally {
            // Ensure the lock is always released
            lock.unlock();
          }
        } else {
          // If lock is not available, skip the deposit
          System.out.println("Could not acquire lock, deposit skipped.");
        }
      }
    }
    

    info> In the above example, skipping the deposit when the lock is unavailable may seem incorrect. However, in real-world applications, this is often handled by notifying the user or implementing a retry mechanism to ensure the operation completes successfully without causing performance issues or deadlocks.

    How tryLock() Works

    • A thread attempts to acquire the lock without blocking.
    • If the lock is held by another thread, the method immediately returns false, allowing the thread to do something else instead of waiting.
    • Prevents deadlocks by allowing threads to skip execution instead of indefinitely waiting for a lock. ---

    Comparison: lock() vs. tryLock()

    | Feature | lock() | tryLock() | |----------------------|-------------------------|----------------------------| | Blocking Behavior | Waits indefinitely for the lock. | Attempts to acquire the lock but does not wait if unavailable. | | Deadlock Prevention | Can cause deadlocks if locks are not released properly. | Helps prevent deadlocks by allowing threads to proceed if the lock is unavailable. | | Performance Impact | Can reduce performance in high-contention environments. | Improves responsiveness as threads don’t block indefinitely. | | Best Use Case | When waiting for a lock is necessary (e.g., sequential execution). | When tasks should proceed even if the lock is held by another thread. |

    Review the file DeadlockScenario.java, which contains a program where two threads acquire locks in an inconsistent order, resulting in a deadlock. Running this file will show how both threads become stuck, preventing further execution.

    Why Does This Code Cause a Deadlock?

    • Task1 locks lock1 first, then attempts to acquire lock2.
    • Task2 locks lock2 first, then tries to acquire lock1.
    • If both threads acquire their first lock and wait for the second, neither can proceed, causing a deadlock. --- As you can see, the program encounters a deadlock and fails to complete. You can terminate the execution by pressing CTRL + C in the Terminal.

    Next, you will use tryLock() to resolve deadlocks.

    Open HandlingDeadlock.java and review the existing code.

    The existing code in HandlingDeadlock.java follows the deadlock scenario but lacks proper tryLock() implementation. Threads may still block indefinitely. Your task is to update specific lines to ensure locks are released and retried instead of causing deadlocks. Next, you will ensure that the lock is released if the dependent lock cannot be acquired. In this step, you learned about ReentrantLock, which provides greater control over thread synchronization compared to synchronized. You implemented tryLock() to improve concurrency by allowing threads to attempt lock acquisition without unnecessary blocking, enhancing responsiveness and preventing thread starvation.

    In the next step you'll learn about ExecutorService.

  6. Challenge

    ExecutorService

    Manually managing threads using Thread objects can be inefficient, leading to high resource usage and complex scalability issues. Java’s ExecutorService provides a structured way to manage thread execution, offering better resource management and performance optimization.

    With ExecutorService, you can:

    • Reuse a pool of threads, reducing overhead from creating and destroying threads.
    • Control concurrency levels by specifying the number of threads in the pool.
    • Submit tasks for execution without manually handling thread creation and lifecycle management.

    Example: Using ExecutorService

    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    
    class Task implements Runnable {
        public void run() {
            System.out.println("Executing task in: " + Thread.currentThread().getName());
        }
    }
    
    public class ExecutorServiceExample {
        public static void main(String[] args) {
            // Create a fixed thread pool with 2 threads
            ExecutorService executor = Executors.newFixedThreadPool(2);
    
            // Submit tasks for execution
            for (int i = 0; i < 5; i++) {
                executor.submit(new Task());
            }
    
            // Shut down the executor after all tasks complete
            executor.shutdown();
        }
    }
    

    Breaking Down the Code

    1. Creating a Thread Pool

    ExecutorService executor = Executors.newFixedThreadPool(2);
    
    • Creates an ExecutorService with a fixed thread pool of 2 threads.
    • These threads are reused for executing multiple tasks, reducing performance overhead.

    2. Submitting Tasks for Execution

    executor.submit(new Task());
    
    • Submits tasks to the thread pool, allowing them to execute concurrently.
    • Tasks are automatically assigned to available threads in the pool.

    3. Shutting Down the Executor

    executor.shutdown();
    
    • Prevents new tasks from being submitted.
    • Allows existing tasks to complete before shutting down the thread pool.

    Consider that you have two printers and a large print job that needs to be completed efficiently.

    You want to optimally utilize both printers by distributing the print tasks between them, ensuring that no printer remains idle while tasks are pending.

    Now you'll implement the missing parts of PrintExecutor.java using the ExecutorService.

    Now, submit the job to the executor. info> When executor.shutdown() is called, the executor stops accepting new tasks. It allows already submitted tasks to complete before shutting down. In this step, you used ExecutorService to efficiently manage thread execution. Instead of manually creating and starting threads, you utilized a fixed thread pool to handle multiple tasks concurrently. This approach improves resource utilization, prevents excessive thread creation, and simplifies task execution management.

  7. Challenge

    Virtual Threads in Java 21

    Virtual Threads were introduced in Java 21 to enhance concurrent task execution without the overhead of platform threads. Unlike traditional threads, they are JVM-managed, making them highly scalable and efficient for handling high-throughput applications.

    Why Use Virtual Threads?

    • Highly Scalable – Supports millions of concurrent tasks with minimal memory overhead.
    • Non-blocking Execution – Ideal for I/O-heavy applications like web servers and database connections.
    • Simplifies Thread Management – Avoids the complexity of managing thread pools manually.

    Example: Creating Virtual Threads

    public class VirtualThreadExample {
        public static void main(String[] args) {
            System.out.println("Step 7: Using Virtual Threads");
    
            // Create and start a virtual thread
            Thread.startVirtualThread(() -> {
                System.out.println("Running in virtual thread: " + Thread.currentThread());
            });
    
            // Create multiple virtual threads in a loop
            for (int i = 0; i < 5; i++) {
                Thread.startVirtualThread(() -> {
                    System.out.println("Virtual thread execution: " + Thread.currentThread());
                });
            }
        }
    }
    

    You can also create virtual threads using ExectorService.

     // Create an ExecutorService using virtual threads
     ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    

    info>Virtual Threads are lightweight and managed by the JVM, which automatically determines the optimal number of carrier threads. As a result, you cannot manually set the number of virtual threads.

    Key Features of Virtual Threads :

    • Ultra-lightweight - Each virtual thread consumes very little memory.
    • Optimized for I/O-bound operations - Threads are not blocked, improving efficiency.
    • JVM-managed - No need to manually handle thread pooling or lifecycle management.
    • Behave as daemon threads - Virtual threads do not prevent JVM shutdown, meaning they may terminate if the main thread exits.

    Virtual Threads are best suited for applications with a high number of concurrent, short-lived tasks, such as web request handling or background processing.

  8. Challenge

    Conclusions and Next Steps

    In this lab, you implemented key concurrent programming techniques in Java, including multi-threading, synchronization, deadlock prevention, and task execution using ExecutorService.

    By applying these concepts, you are now able to:

    • Efficiently create and manage threads.
    • Prevent race conditions and deadlocks using synchronization and tryLock().
    • Optimize thread management using ExecutorService.

    Next Steps

    Enhance your concurrency skills by exploring:

    • Asynchronous Task Execution
      Use Callable and Future to retrieve and manage results from concurrent tasks.

    • Thread-Safe Collections
      Learn how ConcurrentHashMap and CopyOnWriteArrayList provide safe multi-threaded access.

    • Advanced Synchronization
      Explore Semaphore and ReadWriteLock to control resource access efficiently.

    • Parallel Streams
      Utilize Java’s parallel streams to process large datasets with improved performance.

    • CompletableFuture
      Simplify asynchronous programming by chaining and managing dependent tasks.

    Mastering these concepts will enable you to build scalable, high-performance, and thread-safe applications!

Amar Sonwani is a software architect with more than twelve years of experience. He has worked extensively in the financial industry and has expertise in building scalable applications.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.