threads in Java

Mastering Java Threads

Java threads are a fundamental part of the Java programming language, enabling concurrent execution of code and efficient use of system resources. This article explain the basics of Java threads, their lifecycle, synchronization, and how to create and manage them with examples. We’ll also cover the pros and cons of different ways to create threads in Java.

What is a Thread?

A thread is a lightweight process, a separate path of execution within a program. Java supports multithreading, allowing multiple threads to run concurrently, which can significantly improve the performance of applications, especially on multi-core processors.

Thread Lifecycle

A Java thread goes through several states in its lifecycle:

  1. New: A thread that is created but not yet started.
  2. Runnable: A thread that is ready to run but waiting for CPU time.
  3. Blocked: A thread that is waiting for a monitor lock to enter or re-enter a synchronized block/method.
  4. Waiting: A thread that is waiting indefinitely for another thread to perform a particular action.
  5. Timed Waiting: A thread that is waiting for another thread to perform an action for up to a specified waiting time.
  6. Terminated: A thread that has exited.

Creating a Thread

1. Extending Thread Class

public 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();
    }
}

Pros:

  • Simple to implement.
  • Useful when you want to override other methods of the Thread class.

Cons:

  • Inherits the overhead of the Thread class.
  • Cannot extend any other class because Java does not support multiple inheritance.

2. Implementing Runnable Interface

public 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();
    }
}

Pros:

  • Can implement other interfaces and extend a class.
  • More flexible as the same Runnable instance can be shared among multiple threads.

Cons:

  • Slightly more complex to set up compared to extending Thread.

3. Using Callable and Future

The Callable interface is similar to Runnable but can return a result and throw a checked exception.

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 MyCallable implements Callable<Integer> {
    public Integer call() {
        return 123;
    }

    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        MyCallable callable = new MyCallable();
        Future<Integer> future = executor.submit(callable);

        try {
            Integer result = future.get();
            System.out.println("Result: " + result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        executor.shutdown();
    }
}

Pros:

  • Can return a result.
  • Can throw checked exceptions.
  • More powerful and flexible.

Cons:

  • Requires an ExecutorService to manage the thread lifecycle.
  • More complex to implement and manage.

Thread Methods

  • start(): Starts the thread.
  • run(): The entry point for the thread.
  • sleep(long millis): Causes the thread to sleep for a specified time.
  • join(): Waits for the thread to die.
  • interrupt(): Interrupts the thread.

Example:

public class ThreadMethods {
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            public void run() {
                for (int i = 1; i <= 5; i++) {
                    System.out.println(i);
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        System.out.println("Thread interrupted");
                    }
                }
            }
        });

        thread.start();

        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Main thread finished");
    }
}

Synchronization

Synchronization ensures that only one thread can access a resource at a time, preventing thread interference and memory consistency errors.

Synchronized Method

class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class SynchronizedExample {
    public static void main(String[] args) {
        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();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Count: " + counter.getCount());
    }
}
  • Instance Variable count: This is the shared resource that multiple threads will be accessing and modifying.
  • Synchronized Method increment: The synchronized keyword ensures that only one thread can execute this method at a time on a given instance of Counter. This prevents race conditions where multiple threads might try to increment count simultaneously, leading to incorrect results.
  • Method getCount: This method returns the current value of count. It is not synchronized since it’s only reading the value, not modifying it.
  • Creating a Counter Instance: A single Counter object is created and shared between two threads (t1 and t2).
  • Creating Threads: Two threads are created using lambda expressions. Each thread increments the counter 1000 times.
  • Starting Threads: Both threads are started, and they begin executing concurrently.
  • Joining Threads: The join method is called on both threads to ensure the main thread waits for them to finish before proceeding. This is important to ensure that the final System.out.println statement runs only after both threads have completed their execution.
  • Printing the Count: Finally, the value of count is printed. Since the increment method is synchronized, we can be confident that the final value of count will be 2000 (1000 increments from each of the two threads).

Key Points

  • Synchronized Keyword: By using synchronized, we ensure that only one thread at a time can execute the increment method on a particular Counter object, preventing concurrent modifications that could lead to inconsistent states.
  • Thread Safety: The use of synchronized methods is one way to achieve thread safety, ensuring that shared resources are accessed and modified in a controlled manner.
  • Race Conditions: Without synchronization, race conditions can occur where multiple threads try to modify the same variable simultaneously, leading to unpredictable results. Synchronization prevents this by allowing only one thread to access the critical section of code at a time.

Synchronized Block

class Counter {
    private int count = 0;

    public void increment() {
        synchronized (this) {
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}

public class SynchronizedBlockExample {
    public static void main(String[] args) {
        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();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Count: " + counter.getCount());
    }
}

Key Differences and Advantages

  • Granularity: Provides finer control over synchronization. Only the specified block of code is synchronized, not the entire method.
  • Flexibility: Allows synchronization of only the critical section of code, improving performance by reducing the scope of synchronization.
  • Locking on Different Objects: Allows locking on objects other than this, which can be useful in certain designs.

When to Use Which?

  • Use Synchronized Method: When the entire method needs to be synchronized and there is no significant performance concern.
  • Use Synchronized Block: When only a part of the method needs to be synchronized, or you need to lock on a specific object other than this.

Deadlock

Deadlock occurs when two or more threads are blocked forever, waiting for each other. Proper design and resource management can help prevent deadlocks.

class Resource {
    public synchronized void method1(Resource resource) {
        System.out.println(Thread.currentThread().getName() + " is executing method1");
        resource.method2(this);
    }

    public synchronized void method2(Resource resource) {
        System.out.println(Thread.currentThread().getName() + " is executing method2");
    }
}

public class DeadlockExample {
    public static void main(String[] args) {
        Resource resource1 = new Resource();
        Resource resource2 = new Resource();

        Thread t1 = new Thread(() -> resource1.method1(resource2));
        Thread t2 = new Thread(() -> resource2.method1(resource1));

        t1.start();
        t2.start();
    }
}
  • Synchronized Methods: Both method1 and method2 are synchronized, meaning that when a thread holds the lock on an instance of Resource, no other thread can enter any synchronized method on that instance until the lock is released.
  • Nested Calls: method1 in one Resource object calls method2 on another Resource object, and vice versa. This creates the potential for deadlock when two threads are involved.
  • Creating Resources: Two Resource objects, resource1 and resource2, are created.
  • Creating Threads: Two threads, t1 and t2, are created. Each thread calls method1 on one resource, passing the other resource as a parameter.
    • Thread-1 calls resource1.method1(resource2).
    • Thread-2 calls resource2.method1(resource1).

How Deadlock Occurs

  1. Thread-1 Execution:
    • Thread-1 acquires the lock on resource1 and enters method1.
    • Inside method1, Thread-1 attempts to call resource2.method2(this), where this is resource1.
    • To execute resource2.method2(resource1), Thread-1 needs to acquire the lock on resource2.
  2. Thread-2 Execution:
    • Simultaneously, Thread-2 acquires the lock on resource2 and enters method1.
    • Inside method1, Thread-2 attempts to call resource1.method2(this), where this is resource2.
    • To execute resource1.method2(resource2), Thread-2 needs to acquire the lock on resource1.

At this point:

  • Thread-1 holds the lock on resource1 and is waiting for the lock on resource2.
  • Thread-2 holds the lock on resource2 and is waiting for the lock on resource1.

Both threads are now waiting for each other to release the locks they hold, resulting in a deadlock.

Preventing Deadlocks

To prevent deadlocks, follow these strategies:

  1. Avoid Nested Locks: Minimize the use of nested synchronized blocks or methods.
  2. Lock Ordering: Ensure that all threads acquire locks in a consistent, predefined order.
  3. Timeouts: Use timed locks (available in java.util.concurrent.locks package) that give up after a timeout period.
  4. Deadlock Detection: Implement algorithms to detect and recover from deadlocks, although this is complex and resource-intensive.

Java threads are a powerful feature that allows developers to build high-performance, concurrent applications. By understanding the lifecycle, creation, synchronization, and potential pitfalls like deadlocks, you can effectively leverage threads in your Java applications. Properly managing threads can lead to more responsive and efficient programs, essential in today’s multi-core, parallel computing environment.

Leave a Reply

Your email address will not be published. Required fields are marked *