Thread And Synchronization in Java

public class Main{
	public static void main(String...args){
		System.out.println("Hello, World!");
	}
}

Let’s understand what happens When we run this Java program.

  • OS creates a new process for JVM
  • JVM starts and loads the class Main
  • JVM creates a thread (main thread) within this process
  • main thread starts execution, starting from the main method of the class.

From this, we can understand that each process is associated with one thread (main thread) by default in Java.

We can also create threads other than the main thread to handle multiple tasks simultaneously. Ability to handle multiple tasks simultaneously also referred to as concurrency. So we can say that thread helps in achieving concurrency in Java.

Different ways to create Thread in Java

  • Extends Thread class
    public class Main {
        public static void main(String[] args) {
            new MyThread().start();
        }
    }
    
    class MyThread extends Thread{
        @Override
        public void run() {
            for(int i=0;i<10;i++){
                System.out.println(i);
                try {
                    Thread.sleep(500L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
  • Implements Runnable interface
    public class Main {
        public static void main(String[] args) {
            new Thread(new MyRunnable()).start();
        }
    }
    
    class MyRunnable implements Runnable{
        @Override
        public void run() {
            for(int i=0;i<10;i++){
                System.out.println(i);
                try {
                    Thread.sleep(500L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
  • Using anonymous class.
    public class Main {
        public static void main(String[] args) {
            new Thread(){
                @Override
                public void run() {
                    for(int i=0;i<10;i++){
                        System.out.println(i);
                        try {
                            Thread.sleep(500L);
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
            }.start();
        }
    }
  • Using Lambda expression.
    public class Main {
        public static void main(String[] args) {
            new Thread(() -> {
                for (int i = 0; i < 10; i++) {
                    System.out.println(i);
                    try {
                        Thread.sleep(500L);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }).start();
        }
    }

Now let’s understand some commonly used methods provided by Thread class.

  • start(): When we call the start() method, it creates a new OS-level thread, and using that thread invokes the run() method. Once a thread has started and completed its execution (either by finishing its run() method or by being interrupted), it cannot be started again. Attempting to start it again by calling start() will result in an IllegalThreadStateException.
  • run(): method holds the logic to be performed by a thread. If we call the run() method directly, then it executes the logic in the main thread without creating any new thread.
  • sleep(): it pauses the execution of the current thread for the specified number of milliseconds.
    public class SleepExample {
        public static void main(String[] args) {
            System.out.println("Starting...");
            try {
                Thread.sleep(2000); // Sleep for 2 seconds
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Done.");
        }
    }
  • join(): using it, a thread can wait till the execution of another thread completes.
    • Let's understand this if we don’t use join. In the below code output main thread stopped before the new thread started execution.
      public class Main {
      	public static void main(String[] args) {		
      		Thread t1 = new Thread(()->{
      			for(int i=0;i<10;i++) {
      				System.out.printf(" i : %d\n",i);
      				try {
      					Thread.sleep(1000);
      				}
      				catch(InterruptedException e) {
      					e.printStackTrace();
      				}
      			}
      		});
      		
      		System.out.println("main thread running...");
      		t1.start();
      		System.out.println("main thread stopped");
      		
      	}
      }
      
      /***** output ******
      the main thread running...
      main thread stopped
       i : 0
       i : 1
       i : 2
       i : 3
       i : 4
       i : 5
       i : 6
       i : 7
       i : 8
       i : 9
      */
      
    • Now we want that main thread stopped after the execution of the new thread, for that we will use join().
      public class Main {
      	public static void main(String[] args) {
      		
      		Thread t1 = new Thread(()->{
      			for(int i=0;i<10;i++) {
      				System.out.printf(" i : %d\n",i);
      				try {
      					Thread.sleep(1000);
      				}
      				catch(InterruptedException e) {
      					e.printStackTrace();
      				}
      			}
      		});
      		
      		System.out.println("main thread running...");
      		
      		t1.start();
      		
      		try {
      			t1.join();
      		} catch (InterruptedException e) {
      			e.printStackTrace();
      		}
      		System.out.println("main thread stopped");
      		
      	}
      }
      
      /*** Output ***
      the main thread running...
       i : 0
       i : 1
       i : 2
       i : 3
       i : 4
       i : 5
       i : 6
       i : 7
       i : 8
       i : 9
      main thread stopped
      */
  • setName(String name) and getName() :
    • setName(String name) is used to set the name of the thread.
    • getName() is used to get the name of the thread.
      public class Main {	
      	public void doSomething() {
      		for(int i=0;i<5;i++) {
      			System.out.printf("%s i : %d\n",Thread.currentThread().getName(),i);
      			try {
      				System.out.println(Thread.currentThread().getName()+" going to sleep");
      				Thread.sleep(1000);
      				System.out.println(Thread.currentThread().getName()+" waking up");
      			}
      			catch(InterruptedException e) {
      				e.printStackTrace();
      			}
      		}
      	}
      	
      	public static void main(String[] args) {
      		Thread t1 = new Thread(()->new Main().doSomething());
      		t1.setName("thread-t1");
      		Thread t2 = new Thread(()->new Main().doSomething());
      		t2.setName("thread-t2");
      		t1.start();
      		t2.start();		
      	}
      }
      
      /*** Output ***
      thread-t2 i : 0
      thread-t1 i : 0
      thread-t2 going to sleep
      thread-t1 going to sleep
      thread-t1 waking up
      thread-t1 i : 1
      thread-t1 going to sleep
      thread-t2 waking up
      thread-t2 i : 1
      thread-t2 going to sleep
      thread-t2 waking up
      thread-t2 i : 2
      thread-t2 going to sleep
      thread-t1 waking up
      thread-t1 i : 2
      thread-t1 going to sleep
      thread-t2 waking up
      thread-t2 i : 3
      thread-t1 waking up
      thread-t1 i : 3
      thread-t1 going to sleep
      thread-t2 going to sleep
      thread-t1 waking up
      thread-t1 i : 4
      thread-t2 waking up
      thread-t2 i : 4
      thread-t2 going to sleep
      thread-t1 going to sleep
      thread-t1 waking up
      thread-t2 waking up
      */
  • interrupt(): used to interrupt the thread. It sets the interrupted flag of the thread, causing methods like sleep(), wait(), or join() to throw an InterruptedException.
    public class Main {	
    	public void doSomething() {
    		try {
    			System.out.printf("%s started :-) ....\n", Thread.currentThread().getName());
    			for (int i = 0; i < 5; i++) {
    				System.out.printf("%s doing work :-| ... do not interrupt\n", Thread.currentThread().getName());
    				Thread.sleep(1000);
    			}
    			System.out.printf("%s done :-)....\n", Thread.currentThread().getName());
    
    		} catch (InterruptedException e) {
    			System.out.printf("%s somebody interrupted me :-( . stopping execution\n", Thread.currentThread().getName());
    		}
    	}
    	
    	public static void main(String[] args) {
    		Thread t1 = new Thread(()->new Main().doSomething());
    		t1.setName("thread-t1");
    		t1.start();
    		
    		try {
    			Thread.sleep(2000);
    			t1.interrupt();
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    	}
    }
    
    /*** Output ***/
    thread-t1 started :-) ....
    thread-t1 doing work :-| ... do not interrupt
    thread-t1 doing work :-| ... do not interrupt
    thread-t1 somebody interrupted me :-( . stopping execution
    */

Synchronization

So far we understood about thread and some of its method. Now let's understand how to work with multiple threads. Let's discuss synchronization, what’s it, and why we need it.

When multiple threads access and modify shared data concurrently without proper synchronization, it can lead to data corruption or an inconsistent state. Below is a code example.

public class Main {

	static int count = 0;
	
	static void incrementCounter() {
		count++;
	}

	static void runTest(int iteration) {
		Thread t1 = new Thread(() -> {
			for (int i = 0; i < 1000; i++) {
				incrementCounter();
			}
		});
		Thread t2 = new Thread(() -> {
			for (int i = 0; i < 1000; i++) {
				incrementCounter();
			}
		});
		t1.start();
		t2.start();
		try {
			t1.join();
			t2.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.printf("Test %d result count : %d\n", iteration, count);
	}

	public static void main(String[] args) {
		int testCount = 10;
		for(int i=0;i<testCount;i++) {
			count = 0;
			runTest(i);
		}
	}
}

/*** Output ***
Test 0 result count : 1480
Test 1 result count : 1092
Test 2 result count : 1035
Test 3 result count : 2000
Test 4 result count : 2000
Test 5 result count : 2000
Test 6 result count : 1568
Test 7 result count : 2000
Test 8 result count : 2000
Test 9 result count : 1576
*/

As shown above, output data is inconsistent, every time it should have come as 2000 but it's not happening like that. To handle such issues we need synchronization.

Synchronization is necessary in multi-threaded environments to ensure thread safety and prevent data corruption or inconsistent behavior when multiple threads access shared resources concurrently.

Different ways to implement synchronization in Java

  • synchronized methods:
    • When a thread calls a synchronized method on an object, it first acquires an intrinsic lock associated with that object. If a lock is available, the thread acquires it else it will wait until it can acquire the lock.
    • Once the synchronized method completes its execution or encounters a return statement or an exception the lock associated with it will release.
    • Synchronized method synchronized on the instance of the class (object), this means if one thread executes a synchronized method on a particular instance of the class, other threads cannot execute synchronized methods on the same instance concurrently.
      • One more interesting point to understand with the help of an example, let's say we have 2 synchronized methods (method1 & method2 ) within the same class, now we have 2 threads thread1 & thread2. If the same instance of the class is used, thread1 accessing synchronized method1 will acquire a lock on the instance of the class, if thread2 also wants to access synchronized method1 or method2, thread2 needs to wait until thread1 lock release.
    • In case the synchronized method is static, it acquires a lock associated with the class (e.g. Main.class) rather than the class instance (object). Here is the difference, acquiring a lock on a class (ClassName.class) synchronizes access to static methods and class-level synchronization, affecting all instances and threads accessing those methods. Acquiring a lock on an instance object (this) synchronizes access to instance methods and instance-level synchronization, affecting only the threads accessing synchronized methods or blocks within that particular instance.
    • One good example is that HashTable has methods like get, put, isEmpty, and size are synchronized methods, this helps HashTable to be threadsafe in a multithreaded environment. Also as we understood, each thread acquires a lock on the whole HashTable therefore other threads need to wait, so performance-wise it's not good.
    • We fixed our previous code example as below using a synchronized method.
    public class Main {
    
    	static int count = 0;
    	
    	static synchronized void incrementCounter() {
    		count++;
    	}
    
    	static void runTest(int iteration) {
    		Thread t1 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		Thread t2 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		t1.start();
    		t2.start();
    		try {
    			t1.join();
    			t2.join();
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		System.out.printf("Test %d result count : %d\n", iteration, count);
    	}
    
    	public static void main(String[] args) {
    		int testCount = 10;
    		for(int i=0;i<testCount;i++) {
    			count = 0;
    			runTest(i);
    		}
    	}
    }
    
    /*** Output ***
    Test 0 result count : 2000
    Test 1 result count : 2000
    Test 2 result count : 2000
    Test 3 result count : 2000
    Test 4 result count : 2000
    Test 5 result count : 2000
    Test 6 result count : 2000
    Test 7 result count : 2000
    Test 8 result count : 2000
    Test 9 result count : 2000
    */
  • synchronized block :
    • It helps to provide a lock only on the specific section of the method body rather than the whole method and thus provides more control as compared to the synchronized method.
    • When a thread enters a synchronized block, it acquires the lock associated with a specified object or class only for the duration of the synchronized block execution.
      • Let’s understand this point in detail with the help of an example. Below code, lock1 is specified as an argument in a synchronized block, so the lock will acquire at the lock1 object.
      • If there instead of lock1, we passed Main.class lock will acquire on class.
    public class Main {
    
    	static int count = 0;
    	
    	private static Object lock1 = new Object();
    
    	static void incrementCounter() {
    		synchronized(lock1) {
    			count++;
    		}
    	}
    	
    	static void incrementCounter2() {
    		synchronized(lock1) {
    			count++;
    		}
    	}
    
    	static void runTest(int iteration) {
    		Thread t1 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		Thread t2 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter2();
    			}
    		});
    		t1.start();
    		t2.start();
    		try {
    			t1.join();
    			t2.join();
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		System.out.printf("Test %d result count : %d\n", iteration, count);
    	}
    
    	public static void main(String[] args) {
    		int testCount = 10;
    		for (int i = 0; i < testCount; i++) {
    			count = 0;
    			runTest(i);
    		}
    	}
    }
    
    /*** Output ***
    Test 0 result count : 2000
    Test 1 result count : 2000
    Test 2 result count : 2000
    Test 3 result count : 2000
    Test 4 result count : 2000
    Test 5 result count : 2000
    Test 6 result count : 2000
    Test 7 result count : 2000
    Test 8 result count : 2000
    Test 9 result count : 2000
    */
    • Now let's understand one more point, let's create a lock2 object and 2nd thread will acquire the lock associated with it, doing so leads to data inconsistency we can see that in the below output, this is because each thread acquires a lock on the different object but changing same variable which creating race conditions and causing data corruption so we can see in some case output is different than 2000.
      public class Main {
      
      	static int count = 0;
      	
      	private static Object lock1 = new Object();
      	private static Object lock2 = new Object();
      
      	static  void incrementCounter() {
      		synchronized(lock1) {
      			count++;
      		}
      	}
      	
      	static synchronized void incrementCounter2() {
      		synchronized(lock2) {
      			count++;
      		}
      	}
      
      	static void runTest(int iteration) {
      		Thread t1 = new Thread(() -> {
      			for (int i = 0; i < 1000; i++) {
      				incrementCounter();
      			}
      		});
      		Thread t2 = new Thread(() -> {
      			for (int i = 0; i < 1000; i++) {
      				incrementCounter2();
      			}
      		});
      		t1.start();
      		t2.start();
      		try {
      			t1.join();
      			t2.join();
      		} catch (InterruptedException e) {
      			e.printStackTrace();
      		}
      		System.out.printf("Test %d result count : %d\n", iteration, count);
      	}
      
      	public static void main(String[] args) {
      		int testCount = 10;
      		for (int i = 0; i < testCount; i++) {
      			count = 0;
      			runTest(i);
      		}
      	}
      }
      
      /*** Output ***
      Test 0 result count : 1838
      Test 1 result count : 2000
      Test 2 result count : 2000
      Test 3 result count : 1872
      Test 4 result count : 2000
      Test 5 result count : 2000
      Test 6 result count : 1841
      Test 7 result count : 1956
      Test 8 result count : 2000
      Test 9 result count : 2000
      */
  • Reentrant Lock
    • Name reentrant means that a thread that acquired a lock can re-enter or reacquire the lock multiple times without causing deadlock, for example considering recursion. This behavior is called reentrancy.
    • The same reentrancy behavior is also provided by synchronized, then what is the difference?
      • So difference is using synchronized we acquired intrinsic lock but using reentrant lock we acquired extrinsic lock with more fine-grained control in our hand.
      • java.util.concurrent.locks.ReentrantLock implements java.util.concurrent.locks.Lock interface, In the Reentrant class we have a constructor that takes the Boolean value, which decides whether to use a fairness policy or not. There is no such policy provided by synchronized.
        • Here using a fairness policy reentrant lock gives access to the longest waiting thread.
      • ReentrantLock provides a method tryLock() it returns Boolean, using which a thread can check if it can acquire a lock or not on a critical section and in case the lock is not acquired, can perform some other action without blocking or waiting for the lock release.
    • Now let us come back to our example where we want to synchronize threads using ReentrantLock so that there is no data corruption or inconsistent behavior of count variables, below is our improved code.
    public class Main {
    
    	static int count = 0;
    	private static ReentrantLock lock = new ReentrantLock();
    
    	static void incrementCounter() {
    		try {
    			lock.lock();
    			count++;
    		} finally {
    			lock.unlock();
    		}
    	}
    
    	static void runTest(int iteration) {
    		Thread t1 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		Thread t2 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		t1.start();
    		t2.start();
    		try {
    			t1.join();
    			t2.join();
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		System.out.printf("Test %d result count : %d\n", iteration, count);
    	}
    
    	public static void main(String[] args) {
    		int testCount = 10;
    		for (int i = 0; i < testCount; i++) {
    			count = 0;
    			runTest(i);
    		}
    	}
    }
    
    /*** Output ***
    Test 0 result count : 2000
    Test 1 result count : 2000
    Test 2 result count : 2000
    Test 3 result count : 2000
    Test 4 result count : 2000
    Test 5 result count : 2000
    Test 6 result count : 2000
    Test 7 result count : 2000
    Test 8 result count : 2000
    Test 9 result count : 2000
    
    */
  • Atomic Variables
    • It provided a way to perform thread-safe operations on a single variable without the need for explicit synchronization.
    • Using Atomic variables, the thread does not have to wait for the lock to be released, this way it provides better performance.
    • One important point we should understand is that Atomic Variables are good for operations on a single variable in a multithreading environment. For more complex synchronization we should use other mechanisms like synchronized, and lock.
    • We have various types of atomic variables
      • AtomicInteger
      • AtomicLong
      • AtomicBoolean
      • AtomicReference
    • let's implement the Atomic Variable in our example
    public class Main {
    
    	static AtomicInteger count = new AtomicInteger(0); 
    
    	static void incrementCounter() {
    		count.incrementAndGet();
    	}
    
    	static void runTest(int iteration) {
    		Thread t1 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		Thread t2 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		t1.start();
    		t2.start();
    		try {
    			t1.join();
    			t2.join();
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		System.out.printf("Test %d result count : %d\n", iteration, count.get());
    	}
    
    	public static void main(String[] args) {
    		int testCount = 10;
    		for (int i = 0; i < testCount; i++) {
    			count = new AtomicInteger(0);
    			runTest(i);
    		}
    	}
    }
    
    /*** Output ***
    Test 0 result count : 2000
    Test 1 result count : 2000
    Test 2 result count : 2000
    Test 3 result count : 2000
    Test 4 result count : 2000
    Test 5 result count : 2000
    Test 6 result count : 2000
    Test 7 result count : 2000
    Test 8 result count : 2000
    Test 9 result count : 2000
    
    */
  • ReadWriteLock
    • java.util.concurrent.locks.ReadWriteLock is an interface, its implementation is provided by java.util.concurrent.locks.ReentrantReadWriteLock.
    • Using this lock multiple threads can acquire a read lock on a shared resource at a time, but only one thread can acquire a write lock on a shared resource. When a thread acquires a write lock no other threads can acquire a read or write lock on a shared resource.
    • We can say in ReadWriteLock, that read lock is shared but write lock is mutually exclusive.
    • This is useful in scenarios where read operation is more frequent than write operation.
    public class Main {
    
    	static int count = 0;
    	private static ReadWriteLock lock = new ReentrantReadWriteLock();
    
    	static void incrementCounter() {
    		try {
    			lock.writeLock().lock();
    			count++;	
    		}
    		finally {
    			lock.writeLock().unlock();
    		}
    	}
    
    	static void runTest(int iteration) {
    		Thread t1 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		Thread t2 = new Thread(() -> {
    			for (int i = 0; i < 1000; i++) {
    				incrementCounter();
    			}
    		});
    		t1.start();
    		t2.start();
    		try {
    			t1.join();
    			t2.join();
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		System.out.printf("Test %d result count : %d\n", iteration, count);
    	}
    
    	public static void main(String[] args) {
    		int testCount = 10;
    		for (int i = 0; i < testCount; i++) {
    			count = 0;
    			runTest(i);
    		}
    	}
    }
    
    /*** Output ***
    Test 0 result count : 2000
    Test 1 result count : 2000
    Test 2 result count : 2000
    Test 3 result count : 2000
    Test 4 result count : 2000
    Test 5 result count : 2000
    Test 6 result count : 2000
    Test 7 result count : 2000
    Test 8 result count : 2000
    Test 9 result count : 2000
    
    */

So far we covered this much but there are many other Locks available in Java.

Comments