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:
- New: A thread that is created but not yet started.
- Runnable: A thread that is ready to run but waiting for CPU time.
- Blocked: A thread that is waiting for a monitor lock to enter or re-enter a synchronized block/method.
- Waiting: A thread that is waiting indefinitely for another thread to perform a particular action.
- Timed Waiting: A thread that is waiting for another thread to perform an action for up to a specified waiting time.
- 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
: Thesynchronized
keyword ensures that only one thread can execute this method at a time on a given instance ofCounter
. This prevents race conditions where multiple threads might try to incrementcount
simultaneously, leading to incorrect results. - Method
getCount
: This method returns the current value ofcount
. It is not synchronized since it’s only reading the value, not modifying it. - Creating a
Counter
Instance: A singleCounter
object is created and shared between two threads (t1
andt2
). - 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 themain
thread waits for them to finish before proceeding. This is important to ensure that the finalSystem.out.println
statement runs only after both threads have completed their execution. - Printing the Count: Finally, the value of
count
is printed. Since theincrement
method is synchronized, we can be confident that the final value ofcount
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 theincrement
method on a particularCounter
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
andmethod2
are synchronized, meaning that when a thread holds the lock on an instance ofResource
, no other thread can enter any synchronized method on that instance until the lock is released. - Nested Calls:
method1
in oneResource
object callsmethod2
on anotherResource
object, and vice versa. This creates the potential for deadlock when two threads are involved. - Creating Resources: Two
Resource
objects,resource1
andresource2
, are created. - Creating Threads: Two threads,
t1
andt2
, are created. Each thread callsmethod1
on one resource, passing the other resource as a parameter.Thread-1
callsresource1.method1(resource2)
.Thread-2
callsresource2.method1(resource1)
.
How Deadlock Occurs
- Thread-1 Execution:
Thread-1
acquires the lock onresource1
and entersmethod1
.- Inside
method1
,Thread-1
attempts to callresource2.method2(this)
, wherethis
isresource1
. - To execute
resource2.method2(resource1)
,Thread-1
needs to acquire the lock onresource2
.
- Thread-2 Execution:
- Simultaneously,
Thread-2
acquires the lock onresource2
and entersmethod1
. - Inside
method1
,Thread-2
attempts to callresource1.method2(this)
, wherethis
isresource2
. - To execute
resource1.method2(resource2)
,Thread-2
needs to acquire the lock onresource1
.
- Simultaneously,
At this point:
Thread-1
holds the lock onresource1
and is waiting for the lock onresource2
.Thread-2
holds the lock onresource2
and is waiting for the lock onresource1
.
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:
- Avoid Nested Locks: Minimize the use of nested synchronized blocks or methods.
- Lock Ordering: Ensure that all threads acquire locks in a consistent, predefined order.
- Timeouts: Use timed locks (available in
java.util.concurrent.locks
package) that give up after a timeout period. - 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.