Java - Synchronization

Hello there, future Java wizards! Today, we're going to dive into one of the most crucial concepts in Java programming: Synchronization. Don't worry if you're new to programming; I'll guide you through this journey step by step, just like I've done for countless students over my years of teaching. So, grab your favorite beverage, get comfy, and let's embark on this exciting adventure together!

Java - Synchronization

What is Synchronization and Why Do We Need It?

Imagine you're in a busy kitchen with multiple chefs trying to prepare a complex dish. If they all reach for ingredients without any coordination, chaos ensues! That's exactly what can happen in a Java program when multiple threads try to access shared resources simultaneously. This is where synchronization comes to the rescue!

Synchronization in Java is like having a traffic cop in that busy kitchen, ensuring that only one chef (thread) can use a particular ingredient (resource) at a time. It helps maintain order and prevents conflicts, making sure our program runs smoothly without any unexpected surprises.

The Need for Thread Synchronization

Let's look at a real-world example to understand why synchronization is so important:

public class BankAccount {
    private int balance = 1000;

    public void withdraw(int amount) {
        if (balance >= amount) {
            System.out.println(Thread.currentThread().getName() + " is about to withdraw...");
            balance -= amount;
            System.out.println(Thread.currentThread().getName() + " has withdrawn " + amount);
        } else {
            System.out.println("Sorry, not enough balance for " + Thread.currentThread().getName());
        }
    }

    public int getBalance() {
        return balance;
    }
}

In this example, we have a simple BankAccount class with a withdraw method. Seems straightforward, right? But what happens when two people try to withdraw money at the same time? Let's find out!

public class UnsynchronizedBankDemo {
    public static void main(String[] args) {
        BankAccount account = new BankAccount();

        Thread john = new Thread(() -> {
            account.withdraw(800);
        }, "John");

        Thread jane = new Thread(() -> {
            account.withdraw(800);
        }, "Jane");

        john.start();
        jane.start();

        try {
            john.join();
            jane.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final Balance: " + account.getBalance());
    }
}

When you run this program, you might see something like this:

John is about to withdraw...
Jane is about to withdraw...
John has withdrawn 800
Jane has withdrawn 800
Final Balance: -600

Wait, what? How did we end up with a negative balance? This, my friends, is what we call a race condition. Both John and Jane checked the balance, saw there was enough money, and withdrew. This is precisely why we need synchronization!

Implementing Synchronization in Java

Now that we've seen the problem, let's look at how Java helps us solve it. Java provides several ways to implement synchronization, but we'll focus on the two most common ones:

  1. Synchronized Methods
  2. Synchronized Blocks

Synchronized Methods

The easiest way to add synchronization is by using the synchronized keyword in the method declaration. Let's modify our BankAccount class:

public class BankAccount {
    private int balance = 1000;

    public synchronized void withdraw(int amount) {
        if (balance >= amount) {
            System.out.println(Thread.currentThread().getName() + " is about to withdraw...");
            balance -= amount;
            System.out.println(Thread.currentThread().getName() + " has withdrawn " + amount);
        } else {
            System.out.println("Sorry, not enough balance for " + Thread.currentThread().getName());
        }
    }

    public int getBalance() {
        return balance;
    }
}

Now, when we run our program with this synchronized method, we get a much more sensible output:

John is about to withdraw...
John has withdrawn 800
Sorry, not enough balance for Jane
Final Balance: 200

Much better! Only one thread can execute the withdraw method at a time, preventing our earlier problem.

Synchronized Blocks

Sometimes, you might not want to synchronize an entire method. In such cases, you can use synchronized blocks. Here's how:

public class BankAccount {
    private int balance = 1000;
    private Object lock = new Object(); // This is our lock object

    public void withdraw(int amount) {
        synchronized(lock) {
            if (balance >= amount) {
                System.out.println(Thread.currentThread().getName() + " is about to withdraw...");
                balance -= amount;
                System.out.println(Thread.currentThread().getName() + " has withdrawn " + amount);
            } else {
                System.out.println("Sorry, not enough balance for " + Thread.currentThread().getName());
            }
        }
    }

    public int getBalance() {
        return balance;
    }
}

This achieves the same result as the synchronized method, but gives us more fine-grained control over which parts of our code are synchronized.

The Importance of Proper Synchronization

Now, you might be thinking, "Great! I'll just synchronize everything!" But hold your horses! Over-synchronization can lead to performance issues. It's like putting a traffic light at every intersection in a small town – it might be safe, but it'll also slow everything down to a crawl.

The key is to synchronize only what needs to be synchronized. In our bank account example, we only need to synchronize the part where we check and update the balance.

Common Synchronization Methods

Java provides several useful methods for working with synchronization. Here's a table of some common ones:

Method Description
wait() Causes the current thread to wait until another thread invokes notify() or notifyAll()
notify() Wakes up a single thread that's waiting on this object's monitor
notifyAll() Wakes up all threads that are waiting on this object's monitor
join() Waits for a thread to die

These methods can be incredibly useful when you need more complex synchronization scenarios.

Conclusion

And there you have it, folks! We've journeyed through the land of Java synchronization, from understanding why we need it to implementing it in our code. Remember, synchronization is like seasoning in cooking – use just enough to enhance your program, but not so much that it overpowers everything else.

As you continue your Java adventure, you'll encounter more complex synchronization scenarios. But fear not! With this foundation, you're well-equipped to tackle whatever multithreading challenges come your way.

Keep coding, keep learning, and most importantly, have fun! After all, programming is as much an art as it is a science. Until next time, may your threads be synchronized and your Java be strong!

Credits: Image by storyset