Java Concurrency

Multi-threading and Concurrency in Java

Multi-threading and concurrency are essential aspects of modern programming that allow programs to perform multiple tasks simultaneously, improving performance and responsiveness. Java provides robust support for multi-threading and concurrency through various mechanisms and utilities.

Thread Lifecycle

A thread in Java goes through several states during its lifecycle:

  1. New: The thread is created but not yet started.
  2. Runnable: The thread is ready to run and waiting for CPU time.
  3. Running: The thread is currently executing.
  4. Blocked: The thread is waiting for a monitor lock to enter a synchronized block/method.
  5. Waiting: The thread is waiting indefinitely for another thread to perform a particular action.
  6. Timed Waiting: The thread is waiting for another thread to perform a particular action within a specified waiting time.
  7. Terminated: The thread has finished its execution.

Creating Threads

There are two main ways to create a thread in Java:

  1. Extending the Thread class:
Java
   class MyThread extends Thread {
       public void run() {
           System.out.println("Thread is running...");
       }

       public static void main(String[] args) {
           MyThread thread = new MyThread();
           thread.start();
       }
   }
  1. Implementing the Runnable interface:
Java
   class MyRunnable implements Runnable {
       public void run() {
           System.out.println("Thread is running...");
       }

       public static void main(String[] args) {
           Thread thread = new Thread(new MyRunnable());
           thread.start();
       }
   }

Synchronization

Synchronization in Java is used to control the access of multiple threads to shared resources. It ensures that only one thread can access the resource at a time, preventing data inconsistency.

Synchronized Methods

A synchronized method locks the object for any thread that enters the method.

    Java
      class Counter {
          private int count = 0;
    
          public synchronized void increment() {
              count++;
          }
    
          public int getCount() {
              return count;
          }
      }
    
      public class SynchronizedMethodExample {
          public static void main(String[] args) throws InterruptedException {
              Counter counter = new Counter();
    
              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("Final count: " + counter.getCount());
          }
      }

    Synchronized Blocks

    A synchronized block locks a specific object, reducing the scope of synchronization and potentially improving performance.

    • Example:
    Java
      class Counter {
          private int count = 0;
          private final Object lock = new Object();
    
          public void increment() {
              synchronized (lock) {
                  count++;
              }
          }
    
          public int getCount() {
              return count;
          }
      }
    
      public class SynchronizedBlockExample {
          public static void main(String[] args) throws InterruptedException {
              Counter counter = new Counter();
    
              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("Final count: " + counter.getCount());
          }
      }

    Concurrency Utilities

    Executors

    The Executor framework provides a way to manage and control thread execution. The ExecutorService interface extends Executor to provide methods for managing the termination of tasks and tracking their progress.

      Java
        import java.util.concurrent.ExecutorService;
        import java.util.concurrent.Executors;
      
        public class ExecutorExample {
            public static void main(String[] args) {
                ExecutorService executor = Executors.newFixedThreadPool(2);
      
                Runnable task1 = () -> {
                    System.out.println("Executing Task 1");
                };
      
                Runnable task2 = () -> {
                    System.out.println("Executing Task 2");
                };
      
                executor.submit(task1);
                executor.submit(task2);
      
                executor.shutdown();
            }
        }

      Future and Callable

      The Future interface represents the result of an asynchronous computation. The Callable interface is similar to Runnable but can return a result and throw a checked exception.

        Java
          import java.util.concurrent.Callable;
          import java.util.concurrent.ExecutionException;
          import java.util.concurrent.ExecutorService;
          import java.util.concurrent.Executors;
          import java.util.concurrent.Future;
        
          public class FutureCallableExample {
              public static void main(String[] args) {
                  ExecutorService executor = Executors.newFixedThreadPool(2);
        
                  Callable<Integer> task = () -> {
                      Thread.sleep(1000);
                      return 123;
                  };
        
                  Future<Integer> future = executor.submit(task);
        
                  try {
                      System.out.println("Future result: " + future.get());
                  } catch (InterruptedException | ExecutionException e) {
                      e.printStackTrace();
                  } finally {
                      executor.shutdown();
                  }
              }
          }

        Locks

        The Lock interface provides more extensive locking operations than synchronized methods and blocks. It is part of the java.util.concurrent.locks package.

        • Example:
        Java
          import java.util.concurrent.locks.Lock;
          import java.util.concurrent.locks.ReentrantLock;
        
          class Counter {
              private int count = 0;
              private final Lock lock = new ReentrantLock();
        
              public void increment() {
                  lock.lock();
                  try {
                      count++;
                  } finally {
                      lock.unlock();
                  }
              }
        
              public int getCount() {
                  return count;
              }
          }
        
          public class LockExample {
              public static void main(String[] args) throws InterruptedException {
                  Counter counter = new Counter();
        
                  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("Final count: " + counter.getCount());
              }
          }

        Best Practices for Multi-threading and Concurrency

        1. Minimize Synchronization: Synchronize only the critical sections of your code to reduce contention and improve performance.
        2. Use High-level Concurrency Utilities: Prefer using Executors, Locks, and other high-level concurrency utilities over low-level Thread and synchronized.
        3. Avoid Deadlocks: Ensure that your code does not create circular dependencies that lead to deadlocks.
        4. Use Thread-safe Collections: Use concurrent collections like ConcurrentHashMap instead of synchronizing manually.
        5. Use Atomic Variables: For simple operations, use atomic variables like AtomicInteger for better performance and readability.
        6. Handle InterruptedException: Always handle InterruptedException appropriately, either by propagating it or restoring the interrupt status.
        7. Gracefully Shutdown Executors: Always shut down your ExecutorService using shutdown() or shutdownNow() to free up resources.

        Summary

        Multi-threading and concurrency in Java allow you to write efficient and responsive applications. Understanding the thread lifecycle, creating threads using Thread and Runnable, synchronizing shared resources, and leveraging concurrency utilities like Executors, Future, Callable, and Locks are essential for writing robust concurrent programs. By following best practices, you can effectively manage concurrency and avoid common pitfalls such as deadlocks and race conditions.

        Scroll to Top