Dev Learning Lab

The labs on this site need JavaScript to run. The same content is written out below.

Fundamentals › Java

Locks (synchronized)

What is a lock, and what happens without one?

A lock is a mark that says "only one thread runs this section at a time." In this code, the part that sets the lock is the synchronized before the declaration of the add method. What add does is the single line count++, and this one line is three steps: read the value of count, add 1, and write the added value to count. If two threads change the same count at the same time, another thread can change count after one thread has read the value and before it writes, and one thread's increment can be lost. When synchronized is attached, a thread must acquire the lock before entering add, and only one thread can hold the lock at a time, so the other thread waits until the previous thread leaves add. In this lab you add and remove synchronized and switch between 2 threads and 1 thread. The same code is compiled and really run, and you see the count printed at the end. (Creating threads and handling them with start and join is covered in the lab "Threads". static is read as meaning that count and add exist only once in the program, and it is not covered in detail here.)

Checked against real execution · Compiled 4 combinations with javax.tools on JDK 21, ran each 30 times as a child java process, and checked the final count (with 2 threads and no lock, all 30 runs were below 200000)

Concepts in this lab

Locks and monitors
Every object in Java is associated with a monitor, and a thread can lock or unlock that monitor. This locking is commonly called a lock. Only one thread at a time can lock a monitor, and any other thread that tries to lock the same monitor is blocked and waits until it can lock it. A synchronized method acquires a monitor before it executes: an instance method uses the monitor associated with this, and a static method uses the monitor associated with the Class object of its class. When the method ends, the lock is released automatically.
synchronized
This Java keyword marks a critical section. Only one thread at a time can run a method or block marked with it, and a thread that wants to run it must first acquire the monitor lock of the object involved. It is simple, but a thread that waits for the lock is stopped, so keep the scope only as large as needed.
The three steps of count++
An expression such as count++, which changes the value of a variable and writes it back, is not one operation even though it is one line. It is split into three steps: read the current value, add 1 to the value you read, and write the added value to the variable. When you look at count++ on a static int field with javap on JDK 21, the order is getstatic, iconst_1 and iadd, and putstatic. If the three steps of two threads mix, and one thread adds 1 to a stale value it had read and writes it, the increment that another thread made in the meantime can be overwritten and lost. If you wrap the three steps in a lock so that no other thread can enter until one thread finishes, nothing is lost.
Race condition
This is a situation in which two or more threads read and write the same data at the same time, and the result depends on the order of execution. count++ consists of three steps: read the value, add 1, and write it back. If the steps of two threads are interleaved, one of the increments is lost. It typically stays hidden in tests and appears under load.

Try these

Running with the default settings

  1. You run it with java Main. The line static int count = 0; at the top creates one variable named count, with the value 0. This variable is the only one, so every thread in the program reads and writes the same count. The main thread runs the main method from the top.
  2. main creates a Runnable job. job is the work "call add() 100000 times." main hands the same job to Thread t1 and t2 and calls t1.start() and t2.start(). Now two threads each start calling add() 100000 times. main waits at t1.join() and t2.join() for both to finish.
  3. The single line count++ inside add() is not one piece but three steps. 1) Read the current value of count. 2) Add 1 to the value you read. 3) Write the added value to count. Because this add has synchronized, a thread that calls it must acquire the lock before going in. The lock of a static method is the single lock that belongs to the Main class, and only one thread can hold a lock at a time. When the thread holding the lock leaves add, the lock is released.
  4. Follow one case with the lock (the numbers are an example). Say count is 5 in the middle of the 100000 calls each. t1 calls add() first. The lock is free, so t1 acquires the lock and goes in, and reads count, getting 5.
  5. At this moment t2 also calls add(). The lock is held by t1. t2 cannot go into add and waits until the lock is released. It does not even read count.
  6. t1 adds 1 to the 5 it read, writes the 6 to count, and leaves add(). It finished the three steps of read, add, and write with nothing stepping in between, and the lock is released as it leaves.
  7. When the lock is released, t2 acquires the lock and goes into add. This time the count it reads is the 6 that t1 wrote, and it writes 7 after adding 1. It releases the lock as it leaves. Both increments were reflected in count, so it went from 5 to 7.
  8. Each time add() was called, only one thread was inside, so no increment was lost. After both threads finish (t1.join() and t2.join()), println(count) prints 200000. It is the same value however many times you run it. In exchange, the other thread has to wait until the thread holding the lock leaves.

Result of this run

Code

public class Main {
    // A variable that two threads change together. There is only one in the program
    static int count = 0;

    // Lock: with synchronized, only one thread runs at a time
    static synchronized void add() {
        // count++ is three steps: read, add, write
        count++;
    }

    public static void main(String[] args) throws Exception {
        // The work of a thread: call add() 100000 times
        Runnable job = () -> {
            for (int i = 0; i < 100000; i++) {
                add();
            }
        };
        Thread t1 = new Thread(job);
        // The second thread: does the same work
        Thread t2 = new Thread(job);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

Assumptions of this model

The compiler and runtime are JDK 21.0.12.1 (javac 21). All 4 combinations were checked against real runs: the same source as the code on the screen (identical line by line) was compiled with javax.tools, and the java of this JDK was started as a child process and actually run 30 times for each combination, and the final count that was printed was collected and compared. In all 120 runs the exit code was 0 and the standard error was empty. For the two combinations with synchronized, the output was 200000 in all 30 runs with 2 threads and 100000 in all 30 runs with 1 thread. The combination without synchronized and with 1 thread was also 100000 in all 30 runs. The combination without synchronized and with 2 threads was smaller than 200000 in all 30 runs, and there were 30 different values (between 119684 and 155244). So the result cell for this combination is not a fixed number but "A value smaller than 200000 (different on each run)". This is what was seen on the PC used to write this lab (12 logical processors), and how many increments are lost can differ between computers and between runs, so the screen does not state the amount lost as a fixed fact. The numbers 5, 6, and 7 in the scenes and the order in which t1 and t2 step in are one example chosen to show the principle, and that order was not observed in a real run. In the real runs, many more increments were lost than in this example's single one (44756 to 80316 short of the expected value), and I did not break down separately why that many are lost. What this lab checked is the fact that increments are lost and the fact that the operation is split into three steps, and it did not check how those two facts produce the amount that is lost. The facts that the step sentences rely on were checked with separately written source. First, when I looked at count++ inside add() of the same source with javap, the order was getstatic (read), iconst_1 and iadd (add 1), and putstatic (write). Second, while t1 held the lock inside a static synchronized method and t2 called the same method, the state of t2 was BLOCKED, Thread.holdsLock(Main.class) showed true, which shows that the lock belongs to the Class object of Main, and after both finished, count was 2 (the same in all 30 runs). For the documentation, I opened the Java Language Specification (Java SE 21) myself. That only one thread at a time can hold the lock of a monitor and other threads stop until they can hold it, and that a static method uses the monitor of the Class object, follow section 17.1; that a synchronized method acquires the monitor before it executes, and that a static method uses the monitor of the Class object and an instance method uses the monitor of this, follow section 8.4.3.6; and that the lock is released automatically when the method ends follows the explanation at the beginning of chapter 8. Section 8.4.3.6 itself did not state when the lock is released. That a lock release by one thread is ordered before a later acquisition of the same lock by another thread is the content of section 17.4.5, and this lab did not cover the problem of whether values become visible. That count++ is divided into three steps of getting the value, adding 1, and storing it back, and that when the steps of two threads mix, an update can be lost, I confirmed by opening Thread Interference and Synchronized Methods in the Synchronization part of the Java Tutorials. However, no document specifies how many times it is lost in this code, and that is the value from running it on this JDK. I read the documents through web page summaries and did not read the whole original text myself. The code on the screen uses only classes inside java.lang, so it has no import line. Beyond that, synchronized blocks, the lock that each object has separately (instance methods), volatile and AtomicInteger, the order in which locks are released and fairness, and deadlock, in which threads wait for each other and stop, are not covered. Why increments are lost and a comparison of locks are covered in the in-depth lab "Race condition", and deadlock in "Deadlock".

Previous lab: Threads · Next lab: Thread pools

Concepts in this lab

What this lab shows

Settings

Code


      
    

Diagram

Run comparison

Try these