๐Ÿชก Thread & Thread Safety

Gunhoยท2024๋…„ 10์›” 13์ผ
1

๐Ÿชก Thread

๐Ÿชก Thread is a unit of a flow where resources are assigned by a process.

๐Ÿงต Process is a unit of a job where resources are assigned by the OS (Operating System).

A thread or, more accurately, a multi-threading is a critical concept heavily related to creating a highly functioning programme that is specifically capable of handling a considerable volume of user traffic.

In Java, specifically, under the multi-threading context, the invocation to generate a single thread incurs the creation of a JVM stack inside the stack inside the Running Data Areas. A JVM stack is responsible for creating and deleting a frame in which a JVM stack upon the method invocation pushes a frame and the frame records the overall method execution (for more details, please read this).

Data stored in the heap memory and method areas, such as objects and statically declared fields, is accessible to all functioning threads by its design. This means that each thread can concurrently access these shared data, enabling simultaneous operations across multiple threads. This concurrent execution of multiple threads leads directly to increased application performance, as tasks are handled in parallel, improving efficiency and responsiveness. Hence, designing a Java programme as a multi-threading programme may be essential under the high-performance requirements.

However, the inappropriate implementation of multi-threading into a Java programme could cause issues such as:

  • ๐Ÿฆ  Synchronisation Problem (Data Corruption)
  • ๐Ÿ”“ DeadLock

where these essentially results in the program errors and poor performance levels.


๐Ÿงธ Example

A thread in Java can be implemented either via:

  • Thread Class
  • Runnable Interface

Thread Class implements a Runnable Interface and hence, a run() method in the Runnable Interface has to be implemented in order to execute a thread then a start() method within the Thread Class has to run.

The below is a good example of how thread can be implemented in Java via two above methods.

Threads

public class SampleThread extends Thread {
	@Override
    public void run() {
    	... 
    }
}

public class SampleRunnable implements Runnable {
	@Override
    public void run() {
    	... 
    }
}

Results

public class Main {
	public static void main(String[] args) {
    	Main main = new Main();
        main.runThread();
    }
    
    public runThread() {
    	Thread thread1 = new SampleThread();
        Runnable runnable = new SampleRunnable();
        
        Thread thread2 = new Thread(runnable);
        
        thread1.start(); // starts run() method
        thread2.start(); // starts run() method
    }
}

๐Ÿฆ  Synchronisation Problem (Data Corruption)

Synchronisation Problem refers to multiple threads being able to concurrently access the common resource often stored in heap memory or method area in Java context. This often results in Data Corruption where the anticipated results of the method execution do not appear.

For instance, given the two threads invoking the same method that adds the parameter to the instance int variable count:

Calculator Class

public class Calculator {
	private int counter;
    
    public Calculator() {
    	counter = 0;
    }
    
    public void add(int value) {
    	counter += value;
    }
    
    public int getCounter() {
    	return counter;
    }
}

Thread Class

public class SampleThread extends Thread {
	private Calculator calculator;
    
    public SampleThread() {
    }
    
    public SampleThread(Calculator calculator) {
    	this.calculator = calculator;
    }
    
    @Override
    public run() {
    	for (int i = 0; i < 10000; i++) {
        	calculator.add(1);
        }
    }
}

Results

public class Main {
	public static void main(String[] args) {
    	Main main = new Main();
        main.runCalculator();
    }
    
    public runCalculator() {
    	Calculator calc = new Calculator();
    	Thread thread1 = new SampleThread(calc);
        Thread thread2 = new SampleThread(calc);
        
        thread1.start();
        thread2.start();
        
        try {
        	thread1.join();
            thread2.join();
        	System.out.println("Total Counter Value: " + calc.getCount());    
        } catch(InterruptedException e) {
        	e.printStackTrace();
        }
    }
}

the following result of executing the above Main class, is very well anticipated to be 20000, the actual computational outcomes from this execution, however, vary every single time in which the outcome could be any numbers between 0 ~ 20000. This heavily underlies the fact that a thread by its design, if several become implemented works in parallel than sequential.

Such characteristic strictly results in the two different threads accessing the identical values of counter at the same time, perhaps before the computation of one thread is over, then overriding the counter value to different values repetitively.

Overall, leaving the Synchronization Problem unaddressed can make a program more error-prone such that this have to be addressed under the context of multi-threading programme.

๐Ÿ”“ Deadlock

๐Ÿ”“ A deadlock refers to a situation in computing where none of the threads or processes can further proceed followed by each waiting for the other to release resources.

A deadlock can occur in a system only when all of the following conditions are met simultaneously.

  • ๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ Mutual Exclusion
    - each process tries to use a resource exclusively, preventing other processes from accessing that resource.

  • ๐Ÿ‘ฉโ€๐Ÿฆฝ Hold and Wait
    - a process holds one resource while waiting for another resource.

  • ๐Ÿ˜ก No Preemption
    - once a resource is allocated to a process, it cannot be forcibly taken away until the process has finished using it.

  • โญ•๏ธ Circular Wait
    - each process holds a resource that the next process in the cycle requires, creating a circular dependency.

It is generally considered that addressing any one of the above conditions is sufficient to prevent a deadlock. These conditions can be mitigated through the following approaches:

  • ๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ Mutual Exclusion
    - design resources to be shared across all process.

  • ๐Ÿ‘ฉโ€๐Ÿฆฝ Hold and Wait
    - assign all the required resources at once.

  • ๐Ÿ˜ก No Preemption
    - release the resource and allocate it under the priorities.

  • โญ•๏ธ Circular Wait
    - assign the executing orders of the processes.

A deadlock can harm the system's performance and can leave the system to be error-prone in which addressing is necessary.

๐Ÿงค Thread Safety

๐Ÿงค Thread safety refers to the property in multi-threaded programming where a function, variable, or object can be accessed by multiple threads simultaneously without causing the above issues (Synchronisation Problems and Deadlock) in the program's execution.

More specifically, it is defined as a situation where, if a function is being called and executed by one thread, another thread can call and execute the same function at the same time, and the results of the function's execution in each thread will still be correct.

In Java, there are two approaches to achieving thread safety.

  • ๐Ÿ” Synchronisation & Lock
  • ๐Ÿงฎ ThreadLocal

where these will be discussed in greater detail in the following sections.

๐Ÿ” Synchronisation & Lock

๐Ÿ” A synchronization is a mechanism where access to the shared resource becomes restricted to only a single thread until the flow execution is complete.

In Java, specifically, synchronization can be mostly done in two approaches, either over the methods or the user-specified blocks. These two approaches are termed as the following:

  • ๐Ÿญ synchronized methods
  • ๐Ÿงฑ synchronized blocks

These either come with a tentative lock that comes by default or a user-set lock, where the lock becomes the mechanism controlling the access of a thread.

๐Ÿญ Synchronized Methods

Synchronized Methods can be simply implemented via the synchronized keyword followed by a usual method declaration as below.

Synchronized Method

public synchronized void synchronizedMethod() {
    // task execution over the shared resource... 
}

The above synchronised method allows only a single thread to access the internal logic and resources within the method.

There also exists a tentative lock over this method approach where the thread entrance is controlled at the instance level. This implies that given the two objects instantiated by the same class, two hypothetical threads accessing these two different instances will not have any influence from the method synchronisation.

Sample Class

public class Sample {
	public synchronized void synchronizedMethod() {
      // task execution over the shared resource... 
	}
}

Results

public class Main {
	public static void main(String[] args) {
    	Main main = new Main();
        main.runSample();
    }
    
    public void runSample() {
        Sample sample1 = new Sample();
        Sample sample2 = new Sample();
        
        // synchronisation allows one thread to enter. 
        Thread thread1 = new Thread(() -> {
        	sample1.synchronizedMethod();
        });
        
        Thread thread2 = new Thread(() -> {
        	sample1.synchronizedMethod();
        });
        
        // won't have any impact of methods being synchronised (synchronised at instance level). 
        Thread thread3 = new Thread(() -> {
        	sample2.synchronizedMethod();
        });
    }
}

The above could be a good practical example of method synchronisation at the instance level.

Static Synchronized Methods

public static synchronized void synchronizedStaticMethod() {
    // task execution over the shared resource... 
}

A method can be also statically declared and accompanied by the same synchronized keyword. This makes a static synchronized method and it being statically declared implies that the method is stored in the method (static) area. This further implies that the logic and resources inside the method can be accessible at the class level, also referring to a tentative lock existing at the class level. Hence, all threads accessing to the static synchronized methods from the objects instantiated by an identical class will not gain access like it was from the above synchrnozed methods.

Sample Class

public class Sample {
	public static synchronized void synchronizedMethod() {
      // task execution over the shared resource... 
	}
}

Results

public class Main {
	public static void main(String[] args) {
    	Main main = new Main();
        main.runSample();
    }
    
    public void runSample() {
        Sample sample1 = new Sample();
        Sample sample2 = new Sample();
        
        // static synchronisation allows one thread to enter over all instances.
        // hence thread2 should wait thread1 or vice versa. 
        Thread thread1 = new Thread(() -> {
        	sample1.synchronizedMethod();
        });
        
        Thread thread2 = new Thread(() -> {
        	sample2.synchronizedMethod();
        });
    }
}

The above could be a good practical example of static method synchronisation at the class level.


๐ŸงฑSynchronized Block

A synchronized block consists of the synchronized keyword followed by parentheses () and curly brackets {}. It was designed to enhance efficiency and performance by addressing the inefficiencies of the synchronized method, which can unnecessarily block multiple threads from accessing the entire method. A synchronized block provides developers with greater flexibility, allowing them to synchronise only the critical sections of code that need to be thread-safe.

Another noteworthy aspect of a synchronized block could be that the access level can be customised by specifying it in the parenthesis (). Both instance and class level access can be customised, and a specified object or class are viewed as an explicit lock.

Synchronized Block (Instance Level)

public class SampleBlock {
	public void method() {
    	...
    	synchronized(this) {
          // task execution over the shared resource...
      	} 
        ...
	}
}

Synchronized Block (Instance Level)

public class SampleBlock {
	private Object lock1 = new Object();
    private Object lock2 = new Object();
    
	public void method1() {
    	synchronized(lock1) {
          // task execution over the shared resource...
      	} 
	}
    
    public void method2() {
    	synchronized(lock2) {
          // task execution over the shared resource...
      	} 
	}
}

In the example of the SampleBlock class and its method(), a synchronized block can be implemented wherever thread safety is necessary. Using this inside the parentheses () defines the current instance as the lock.

Instance-level synchronization can also be achieved by providing an explicit object as a member variable, an external parameter, or during instantiation via a constructor. This allows developers to control which objects serve as locks for synchronization and hence enhance flexibility. This approach is particularly useful when two methods need to be thread-safe but should not interfere with each other, as it enables separate synchronization for each method.

A good practical example of this could be a banking application, where withdrawal and deposit actions must be individually thread-safe, but should still be able to execute concurrently without blocking each other.

๐Ÿ’ก An object specified in the () is termed as a monitor object.

Synchronized Block (Class Level)

public class SampleBlock {
	public void method() {
    	synchronized(SampleBlock.class) {
          // task execution over the shared resource...
      	} 
	}
}

Finally, class-level synchronisation can be achieved by referencing a class where a method belongs to namely, CLASS_NAME.class. The synchronized block then functions identically to that of the static synchronized method.


Overall, synchronisation implemented via synchronized keyword in Java, ensures that the synchronised sections become thread-safe. This, however, comes at the cost of lowered performances as any other thread accessing the synchronised section has to wait for the former thread.

๐Ÿงฎ ThreadLocal

ThreadLocal is a mechanism that allows each thread to have its own unique data storage.

ThreadLocal is useful for maintaining data consistency and preventing conflicts between threads in a multi-threaded environment, as each thread can store a unique value for the same variable, which is not accessible by other threads. This reduces the need for shared resources and helps ensure thread-safety.

Technically, ThreadLocal uses each thread as a key and maps a user-specified value to that thread. It's important to note that after a thread is finished, the value mapped to that thread must be cleared to prevent newly created threads with the same name or heap address from accessing or manipulating the previous data.

A context in the Spring Security is a good use-case of a ThreadLocal.

๐Ÿ”‘ Others

๐Ÿšช Reentrant Lock

Another key noteworthy point is Reentrant Lock, the alternative to the synchronized keyword in Java for achieving synchronisation. This was introduced to introduce features that was not available from synchronisation via methods and blocks.

These are suggestibly:

  • Reentrancy
    - a thread can acquire the same lock it holds.
  • Fairness
    - optionally, the thread that has waited for the longest gains priority to get access to the locked resources.
  • Condition Support
    - for advanced signaling, Condition objects specifying the signaling condition can be created.

๐Ÿ’ก Reentrancy is also a characteristic that is implicitly supported by the synchronized keyword in Java, allowing a thread that currently holds a lock to re-acquire it without blocking itself. This built-in feature enables thread-safe recursive method calls, nested synchronized methods, and modularisation without incurring deadlocks.

A Reentrant Lock can be concisely implemented as below. For the implementation of the extra features, including Fairness and Conditions, please refer to the references below.

Reentrant Lock

  ReentrantLock lock = new ReentrantLock();
  lock.lock();
  try {
      // task execution over the shared resource...
  } finally {
      lock.unlock();
  }

โ˜ ๏ธ Deadlock Example

The below could be a good example of a deadlock and could be solved by chaning the order of a locks in either of one method, however not both.

public methodA(){
   synchronized(lockA){
     synchronized(lockB){
     }
   }
}

public methodB(){
   synchronized(lockB){
     synchronized(lockA){
     }
   }
}

๐Ÿ“š References

์ž๋ฐ”์˜ ์‹ 
F-Lab (1)
F-Lab (2)
Geeks for Geeks

profile
Hello

0๊ฐœ์˜ ๋Œ“๊ธ€