DEV Community

DevCorner
DevCorner

Posted on

Thread Synchronization in Java: Techniques with Code Examples

In a multithreaded environment, multiple threads may try to access shared resources concurrently, leading to race conditions, data inconsistency, or deadlocks. To prevent such issues, Java provides several thread synchronization techniques to control access to shared resources.

In this blog, we'll explore the most commonly used synchronization techniques in Java with practical code examples. πŸš€


1. Synchronized Keyword (Locks / Mutexes)

The synchronized keyword ensures that only one thread can access a synchronized block or method at a time.

Example: Synchronized Method

class SharedResource {
    private int count = 0;

    public synchronized void increment() { // Method-level synchronization
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

public class SynchronizedExample {
    public static void main(String[] args) throws InterruptedException {
        SharedResource resource = new SharedResource();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) resource.increment();
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) resource.increment();
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Final count: " + resource.getCount());
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Output: Final count: 2000 (ensures correct shared access)


2. Synchronized Block

Instead of synchronizing an entire method, we can synchronize only a critical section inside a method to improve performance.

Example: Synchronized Block

class SharedCounter {
    private int count = 0;

    public void increment() {
        synchronized (this) { // Synchronizing only critical section
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ“ Use case: Useful when only part of the method needs synchronization.


3. Using ReentrantLock

ReentrantLock is a more flexible alternative to the synchronized keyword. It allows tryLock() for non-blocking attempts and lockInterruptibly() to respond to interrupts.

Example: ReentrantLock

import java.util.concurrent.locks.ReentrantLock;

class SharedData {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock(); // Lock acquired
        try {
            count++;
        } finally {
            lock.unlock(); // Lock released
        }
    }

    public int getCount() {
        return count;
    }
}

public class ReentrantLockExample {
    public static void main(String[] args) throws InterruptedException {
        SharedData data = new SharedData();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) data.increment();
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) data.increment();
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Final count: " + data.getCount());
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”Ή Why use ReentrantLock?

  • Provides fair locking (first-come, first-served basis).
  • Allows lock timeout with tryLock().
  • Can be manually unlocked, unlike synchronized.

4. Using Semaphore

Semaphore controls access to a resource with a fixed number of permits.

Example: Semaphore for Limited Access

import java.util.concurrent.Semaphore;

class SharedPrinter {
    private final Semaphore semaphore = new Semaphore(1); // 1 permit

    public void print(String message) {
        try {
            semaphore.acquire(); // Acquire lock
            System.out.println(Thread.currentThread().getName() + " printing: " + message);
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release(); // Release lock
        }
    }
}

public class SemaphoreExample {
    public static void main(String[] args) {
        SharedPrinter printer = new SharedPrinter();

        Runnable task = () -> printer.print("Hello from " + Thread.currentThread().getName());

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Use case: Useful in rate-limiting and resource pooling.


5. Read-Write Locks (ReentrantReadWriteLock)

When multiple threads only read data, they don’t need exclusive access. ReadWriteLock allows multiple readers but only one writer.

Example: Read-Write Lock

import java.util.concurrent.locks.ReentrantReadWriteLock;

class SharedDataRW {
    private int data = 0;
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public void write(int value) {
        lock.writeLock().lock();
        try {
            data = value;
            System.out.println("Written: " + value);
        } finally {
            lock.writeLock().unlock();
        }
    }

    public int read() {
        lock.readLock().lock();
        try {
            return data;
        } finally {
            lock.readLock().unlock();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”Ή Use case: Improves performance in read-heavy applications.


6. Using Atomic Variables

Instead of locks, AtomicInteger ensures lock-free thread-safe operations.

Example: Atomic Integer

import java.util.concurrent.atomic.AtomicInteger;

class SharedAtomic {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Faster than locks for simple counters


7. Using Countdown Latch

A CountDownLatch waits for a specified number of threads to complete before proceeding.

Example: Countdown Latch

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(3);

        Runnable task = () -> {
            System.out.println(Thread.currentThread().getName() + " completed");
            latch.countDown();
        };

        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();

        latch.await(); // Wait for all threads to finish
        System.out.println("All threads finished!");
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Use case: Ensuring all prerequisites complete before proceeding.


Conclusion

Java offers multiple thread synchronization techniques, each with its advantages:

  • synchronized: Simple but may cause contention.
  • ReentrantLock: More flexible locking mechanism.
  • Semaphore: Controls access with limited permits.
  • ReadWriteLock: Efficient for read-heavy operations.
  • Atomic variables: Best for lock-free counters.
  • CountDownLatch: Useful for waiting for multiple threads.

By choosing the right technique, you can avoid race conditions, improve performance, and build reliable concurrent applications. πŸš€

What’s your favorite way to synchronize threads in Java? Let me know in the comments! 😊

Top comments (0)