Java Thread Synchronization Techniques and Implementation

Causes of Thread Safety Issues

In concurrent programming scenarios, multiple threads often interact with shared resources. The typical process involves reading data from JVM memory, modifying it, and writing it back. However, when multiple threads operate concurrently, race conditions can occur. One thread's modifications might be overwritten by another thread, resulting in inconsistent or corrupted data. Thread synchronization mechanisms are designed to prevent these issues.

Synchronization Fundamentals

To ensure data consistency when multiple threads access shared resources, Java provides several synchronization approaches. These mechanisms enforce an orderly access pattern to shared data, preventing race conditions. The primary techniques are based on the "queue and lock" principle, with implementations through language keywords and library classes.

Java Synchronization Methods

The synchronized Keyword

Implementation Principles

Every Java object has an intrinsic lock that forms the basis of synchronization. The synchronized keyword can be applied in three ways:

  • Instance methods: Acquires the lock on the current instance object
  • Static methods: Acquires the lock on the class object
  • Synchronized blocks: Acquires the lock on a specified object

Only one thread can hold the lock on any object at a time, preventing other threads from entering any synchronized block or method on that object.

Code Examples

Without synchronization:

public class Counter implements Runnable {
    private static int sharedValue = 0;
    
    public void increment() {
        sharedValue++;
    }
    
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            increment();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread threadA = new Thread(counter, "Thread-A");
        Thread threadB = new Thread(counter, "Thread-B");
        
        threadA.start();
        threadB.start();
        
        Thread.sleep(2000);
        System.out.println("Final value: " + sharedValue);
    }
}

With synchronized method:

public class Counter implements Runnable {
    private static int sharedValue = 0;
    
    public synchronized void increment() {
        sharedValue++;
    }
    
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            increment();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread threadA = new Thread(counter, "Thread-A");
        Thread threadB = new Thread(counter, "Thread-B");
        
        threadA.start();
        threadB.start();
        
        Thread.sleep(2000);
        System.out.println("Final value: " + sharedValue);
    }
}

Using synchronized block:

public class Counter implements Runnable {
    private static int sharedValue = 0;
    
    public void increment() {
        synchronized (this) {
            sharedValue++;
        }
    }
    
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            increment();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread threadA = new Thread(counter, "Thread-A");
        Thread threadB = new Thread(counter, "Thread-B");
        
        threadA.start();
        threadB.start();
        
        Thread.sleep(2000);
        System.out.println("Final value: " + sharedValue);
    }
}

Using Volatile Variables

Implementation Principles

The volatile keyword provides a lock-free mechanism for accessing variables. It tells the JVM that the varible might be modified by other threads, preventing the use of cached values from registers. However, volatile doesn't provide atomic operations for compound actions and can't be used with final variables.

Code Example

public class SharedData {
    public volatile int counter = 0;
    
    public void increment() {
        counter++;
    }
    
    public static void main(String[] args) throws InterruptedException {
        SharedData data = new SharedData();
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                data.increment();
            }
        }, "Thread-A");
        
        Thread threadB = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                data.increment();
            }
        }, "Thread-B");
        
        threadA.start();
        threadB.start();
        
        threadA.join();
        threadB.join();
        
        System.out.println("Final value: " + data.counter);
    }
}

ReentrantLock Implementation

Implementation Principles

Introduced in Java 5, the ReentrantLock class in the java.util.concurrent package provides a more flexible alternative to synchronized. It's reentrant, mutually exclusive, and implements the Lock interface. Key methods include:

  • ReentrantLock(): Creates a new ReentrantLock instance
  • lock(): Acquires the lock
  • unlock(): Releases the lock

Note: ReentrantLock can create fair locks, but this significantly reduces performance and is not recommended.

Code Example

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SharedCounter {
    private int value = 0;
    private final Lock lock = new ReentrantLock();
    
    public void increment() {
        lock.lock();
        try {
            value++;
        } finally {
            lock.unlock();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        SharedCounter counter = new SharedCounter();
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        }, "Thread-A");
        
        Thread threadB = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        }, "Thread-B");
        
        threadA.start();
        threadB.start();
        
        threadA.join();
        threadB.join();
        
        System.out.println("Final value: " + counter.value);
    }
}

ThreadLocal Variables

Implementation Principles

ThreadLocal provides each thread with its own variable copy, ensuring thread isolation. Each thread can modify its copy without affecting others. Key methods include:

  • ThreadLocal(): Creates a thread-local variable
  • get(): Returns the current thread's value
  • initialValue(): Returns the initial value for the thread
  • set(T value): Sets the value for the current thread

ThreadLocal and synchronization represent different approaches: ThreadLocal trades space for time, while synchronization trades time for space.

Code Example

public class ThreadLocalExample {
    private static ThreadLocal<integer> threadLocalValue = new ThreadLocal<integer>() {
        @Override
        protected Integer initialValue() {
            return 0;
        }
    };
    
    public void increment() {
        threadLocalValue.set(threadLocalValue.get() + 1);
    }
    
    public int getValue() {
        return threadLocalValue.get();
    }
    
    public static void main(String[] args) {
        ThreadLocalExample example = new ThreadLocalExample();
        
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                example.increment();
                System.out.println("Thread-A value: " + example.getValue());
            }
        }, "Thread-A");
        
        Thread threadB = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                example.increment();
                System.out.println("Thread-B value: " + example.getValue());
            }
        }, "Thread-B");
        
        threadA.start();
        threadB.start();
    }
}</integer></integer>

Blocking Queues for Synchronization

Implementation Principles

Blocking queues provide a thread-safe way to implement producer-consumer patterns. The LinkedBlockingQueue is commonly used with these methods:

  • LinkedBlockingQueue(): Creates a queue with maximum capacity
  • put(E e): Adds an element, blocking if the queue is full
  • size(): Returns the number of elements
  • take(): Removes and returns the head element, blocking if empty

Code Example: Producer-Consumer Model

import java.util.Random;
import java.util.concurrent.LinkedBlockingQueue;

public class ProducerConsumerExample {
    private final LinkedBlockingQueue<integer> productQueue = new LinkedBlockingQueue<>(5);
    private static final int PRODUCT_COUNT = 10;
    
    private class Worker implements Runnable {
        private final boolean isProducer;
        
        public Worker(boolean isProducer) {
            this.isProducer = isProducer;
        }
        
        @Override
        public void run() {
            if (isProducer) {
                produce();
            } else {
                consume();
            }
        }
        
        private void produce() {
            try {
                for (int i = 0; i < PRODUCT_COUNT; i++) {
                    int productId = new Random().nextInt(1000);
                    System.out.println("Producing product: " + productId);
                    productQueue.put(productId);
                    System.out.println("Queue size: " + productQueue.size());
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        
        private void consume() {
            try {
                for (int i = 0; i < PRODUCT_COUNT / 2; i++) {
                    int productId = productQueue.take();
                    System.out.println("Consumed product: " + productId);
                    System.out.println("Queue size: " + productQueue.size());
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
    
    public static void main(String[] args) {
        ProducerConsumerExample example = new ProducerConsumerExample();
        
        Thread producerThread = new Thread(example.new Worker(true), "Producer");
        Thread consumerThread = new Thread(example.new Worker(false), "Consumer");
        
        producerThread.start();
        consumerThread.start();
    }
}</integer>

Atomic Variables for Synchronization

Atomic Operations

Atomic operations treat a sequence of operations (read, modify, write) as a single indivisible unit. These operations either complete entirely or not at all. The java.util.concurrent.atomic package provides classes for atomic variables. AtomicInteger, for example, allows atomic integer updates with these methods:

  • AtomicInteger(int initialValue): Creates an AtomicInteger with initial value
  • addAndGet(int delta): Atomically adds the given value to the current value
  • get(): Returns the current value

Code Example

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger atomicValue = new AtomicInteger(10);
    
    public AtomicInteger getAtomicValue() {
        return atomicValue;
    }
    
    public void increment() {
        atomicValue.incrementAndGet();
    }
    
    public void decrement() {
        atomicValue.decrementAndGet();
    }
    
    public static void main(String[] args) {
        AtomicCounter counter = new AtomicCounter();
        
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
                System.out.println("Thread-A incremented, value: " + counter.getAtomicValue().get());
            }
        }, "Thread-A");
        
        Thread threadB = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.decrement();
                System.out.println("Thread-B decremented, value: " + counter.getAtomicValue().get());
            }
        }, "Thread-B");
        
        threadA.start();
        threadB.start();
        
        try {
            threadA.join();
            threadB.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        
        System.out.println("Final atomic value: " + counter.getAtomicValue().get());
    }
}

Tags: java thread-synchronization concurrent-programming Synchronized ReentrantLock

Posted on Mon, 31 Aug 2026 16:02:32 +0000 by wowiz