The Thinking Logic of Computer Programs (71) - Explicit Lock

Keywords: Java Programming less jvm

stay 66 quarter In this section, we discuss explicit locks in Java concurrent packages, which can solve the limitations of synchronized.

The explicit lock interfaces and classes in Java concurrent packages are located under the package java.util.concurrent.locks. The main interfaces and classes are:

  • Lock interface, the main implementation class is ReentrantLock
  • ReadWriteLock interface, the main implementation class is ReentrantReadWriteLock

This section mainly introduces the interface Lock and the implementation class ReentrantLock. We will introduce the read-write locks in the following chapters.

Interface Lock

The explicit lock interface Lock is defined as:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

Let's explain:

  • lock()/unlock(): is a common method of acquiring and releasing locks, and lock() blocks until it succeeds.
  • lockInterruptibly(): Unlike lock(), it can respond to interruptions and throw InterruptedException if interrupted by other threads.
  • tryLock(): Just try to get the lock, return it immediately, without blocking. If it succeeds, return true, otherwise return false.
  • tryLock(long time, TimeUnit unit): First try to acquire the lock, if it succeeds, return true immediately. Otherwise, block the waiting, but the longest waiting time is the specified parameter. While waiting, respond to the interruption. If an interruption occurs, throw the InterruptedException. If the lock is acquired within the waiting time, return true, otherwise return false.
  • New Condition: Create a new condition. A Lock can associate multiple conditions. We'll leave it to the next section.  

As you can see, compared with synchronized, explicit locks support non-blocking access to locks, can respond to interrupts, and can be time-limited, which makes it more flexible.

ReentrantLock ReentrantLock

Basic Usage

The main implementation class of Lock interface is ReentrantLock. Its basic usage, lock/unlock, implements the same semantics as synchronized, including:

  • Re-entrant. A thread can continue to acquire a lock on the premise that it holds a lock.
  • It can solve the problem of competitive condition.
  • Memory visibility is guaranteed

ReentrantLock has two constructions:

public ReentrantLock()
public ReentrantLock(boolean fair) 

The parameter fair denotes whether fairness is guaranteed. If it is not specified, it defaults to false, indicating that fairness is not guaranteed. Fairness means that the thread with the longest waiting time gets the lock first. Guaranteeing fairness affects performance and is generally not required, so default is not guaranteed and synchronized locks are not guaranteed fairness. We will analyze the implementation details later.

When using explicit locks, it is important to remember to call unlock. Generally speaking, code after lock should be wrapped in try statement and unlocked in final statement. For example, using ReentrantLock to implement Counter, the code can be:

public class Counter {
    private final Lock lock = new ReentrantLock();
    private volatile int count;

    public void incr() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        return count;
    }
}

Use tryLock to avoid deadlocks

Deadlock can be avoided by using tryLock(). When holding one lock, acquiring another lock and not acquiring it, you can release the held lock, give other threads the opportunity to acquire the lock, and then retry to acquire all locks.

Let's look at an example. Transfers between bank accounts are represented by Account-like accounts. The code is as follows:

public class Account {
    private Lock lock = new ReentrantLock();
    private volatile double money;

    public Account(double initialMoney) {
        this.money = initialMoney;
    }

    public void add(double money) {
        lock.lock();
        try {
            this.money += money;
        } finally {
            lock.unlock();
        }
    }

    public void reduce(double money) {
        lock.lock();
        try {
            this.money -= money;
        } finally {
            lock.unlock();
        }
    }

    public double getMoney() {
        return money;
    }

    void lock() {
        lock.lock();
    }

    void unlock() {
        lock.unlock();
    }

    boolean tryLock() {
        return lock.tryLock();
    }
}

money in the Account represents the current balance, and add/reduce is used to modify the balance. Transfer between accounts requires both accounts to be locked. If you don't use tryLock, you can use lock directly. The code looks like this:

public class AccountMgr {
    public static class NoEnoughMoneyException extends Exception {}

    public static void transfer(Account from, Account to, double money)
            throws NoEnoughMoneyException {
        from.lock();
        try {
            to.lock();
            try {
                if (from.getMoney() >= money) {
                    from.reduce(money);
                    to.add(money);
                } else {
                    throw new NoEnoughMoneyException();
                }
            } finally {
                to.unlock();
            }
        } finally {
            from.unlock();
        }
    }
}

But this is a problem. If both accounts transfer money to each other at the same time, the first lock is acquired first, then a deadlock will occur. We write a code to simulate this process:

public static void simulateDeadLock() {
    final int accountNum = 10;
    final Account[] accounts = new Account[accountNum];
    final Random rnd = new Random();
    for (int i = 0; i < accountNum; i++) {
        accounts[i] = new Account(rnd.nextInt(10000));
    }

    int threadNum = 100;
    Thread[] threads = new Thread[threadNum];
    for (int i = 0; i < threadNum; i++) {
        threads[i] = new Thread() {
            public void run() {
                int loopNum = 100;
                for (int k = 0; k < loopNum; k++) {
                    int i = rnd.nextInt(accountNum);
                    int j = rnd.nextInt(accountNum);
                    int money = rnd.nextInt(10);
                    if (i != j) {
                        try {
                            transfer(accounts[i], accounts[j], money);
                        } catch (NoEnoughMoneyException e) {
                        }
                    }
                }
            }
        };
        threads[i].start();
    }
}

The above code creates 10 accounts, 100 threads, each thread executes 100 cycles, in each cycle, randomly select two accounts for transfer. On my computer, every time I execute this code, a deadlock occurs. Readers can modify these values for experimentation.

We use tryLock to modify it. First, we define a tryTransfer method:

public static boolean tryTransfer(Account from, Account to, double money)
        throws NoEnoughMoneyException {
    if (from.tryLock()) {
        try {
            if (to.tryLock()) {
                try {
                    if (from.getMoney() >= money) {
                        from.reduce(money);
                        to.add(money);
                    } else {
                        throw new NoEnoughMoneyException();
                    }
                    return true;
                } finally {
                    to.unlock();
                }
            }
        } finally {
            from.unlock();
        }
    }
    return false;
}

If both locks are available and the transfer is successful, return true, otherwise return false. Anyway, the end releases all locks. The transfer method can be called iteratively to avoid deadlocks, and the code can be:

public static void transfer(Account from, Account to, double money)
        throws NoEnoughMoneyException {
    boolean success = false;
    do {
        success = tryTransfer(from, to, money);
        if (!success) {
            Thread.yield();
        }
    } while (!success);
}

Getting Lock Information

In addition to implementing the methods in the Lock interface, ReentrantLock has other methods through which information about locks can be obtained, which can be used for monitoring and debugging purposes, such as:

//Whether the lock is held or not, returns as long as the thread holds it true,Not necessarily held by the current thread
public boolean isLocked()

//Is the lock held by the current thread?
public boolean isHeldByCurrentThread()

//Number of locks held by the current thread, 0 denotes that they are not held by the current thread
public int getHoldCount()

//Is Lock Waiting Fair
public final boolean isFair()

//Is there a thread waiting for the lock?
public final boolean hasQueuedThreads()

//Specified threads thread Are you waiting for the lock?
public final boolean hasQueuedThread(Thread thread)

//Number of threads waiting for the lock
public final int getQueueLength()

Realization principle

The use of ReentrantLock is relatively simple. How does it work? At the bottom, it depends on Upper segment In addition, it relies on some methods in the class LockSupport.

LockSupport

The class LockSupport is also located under the package java.util.concurrent.locks. Its basic methods are:

public static void park()
public static void parkNanos(long nanos)
public static void parkUntil(long deadline)
public static void unpark(Thread thread)

park causes the current thread to abandon the CPU and enter a waiting state (WAITING). The operating system no longer schedules it. When will it be scheduled again? There are other threads that call unpark on it, and unpark needs to specify a thread, which will restore it to a runnable state. Let's take an example:

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread (){
        public void run(){
            LockSupport.park();
            System.out.println("exit");
        }
    };
    t.start();    
    Thread.sleep(1000);
    LockSupport.unpark(t);
}

When the thread t starts calling park, it will abandon the CPU. After the main thread sleeps for one second, it calls unpark. The thread t resumes running and outputs exit.

park is different from Thread.yield(), which only tells the operating system that it can let other threads run first, but it is still in a runnable state. park will give up the scheduling qualification and make the threads enter the WAITING state.

It should be noted that parks respond to interrupts, and when an interrupt occurs, parks return and the interrupt status of threads is set. In addition, it is also necessary to note that parks may return for no reason, and the program should re-check whether the conditions for parks to wait are met.

park has two variants:

  • parkNanos: You can specify the maximum waiting time, the number of nanoseconds relative to the current time.
  • parkUntil: You can specify when the longest waiting time is, the parameter is absolute time, the number of milliseconds relative to the epoch.  

They also return when waiting for a timeout.

There are also some variants of these park methods that can specify an object, indicating that the object is waiting for debugging. Usually the value passed is this. These methods include:

public static void park(Object blocker)
public static void parkNanos(Object blocker, long nanos)
public static void parkUntil(Object blocker, long deadline)

LockSupport has a method that returns a thread's blocker object:

public static Object getBlocker(Thread t)

How are these park/unpark methods implemented? Like CAS methods, they also call corresponding methods in Unsafe classes, which ultimately call the API of the operating system. From the programmer's point of view, we can think of these methods in LockSupport as basic operations.

AQS (AbstractQueuedSynchronizer)

The basic methods provided by CAS and LockSupport can be used to implement ReentrantLock. But there are many other concurrent tools in Java, such as ReentrantReadWriteLock, Semaphore, CountDownLatch. Their implementation has many similarities. In order to reuse code, Java provides an abstract class AbstractQueued Synchronizer, which we call AQS for short. It simplifies the implementation of concurrent tools. The overall implementation of AQS is relatively complex. We mainly introduce the use of ReentrantLock as an example.

AQS encapsulates a state, providing a way for subclasses to query and set the state:

private volatile int state;
protected final int getState()
protected final void setState(int newState)
protected final boolean compareAndSetState(int expect, int update) 

When used to implement locks, AQS can save the current thread holding the locks and provide methods for querying and setting:

private transient Thread exclusiveOwnerThread;
protected final void setExclusiveOwnerThread(Thread t)
protected final Thread getExclusiveOwnerThread() 

A waiting queue is maintained in AQS, and the non-blocking algorithm is updated by CAS method.

Next, we take the use of ReentrantLock as an example to briefly introduce the principle of AQS.

ReentrantLock

ReentrantLock uses AQS internally and has three internal classes:

abstract static class Sync extends AbstractQueuedSynchronizer
static final class NonfairSync extends Sync
static final class FairSync extends Sync

Sync is an abstract class, NonfairSync is a class used when fair is false, and FairSync is a class used when fire is true. There is a Sync member inside ReentrantLock:

private final Sync sync;

In the constructor, sync is assigned, such as:

public ReentrantLock() {
    sync = new NonfairSync();
}

Let's look at the implementation of the basic method lock/unlock in ReentrantLock. First, look at the lock method. The code is as follows:

public void lock() {
    sync.lock();
}

NonfairSync's lock code is:

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

ReentrantLock uses state to indicate whether it is locked and how many holdings it has. If it is not currently locked, it immediately acquires the lock. Otherwise, acquire(1) is called to acquire the lock. acquire is a method in AQS. The code is:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

It calls tryAcquire to get locks, tryAcquire must be overridden by subclasses, and NonfairSync is implemented as follows:

protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

nonfairTryAcquire is implemented in sync with the code:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

This code should be easy to understand, if it is not locked, then use CAS to lock, otherwise, if it has been locked by the current thread, increase the number of locks.

If tryAcquire returns false, AQS calls:

acquireQueued(addWaiter(Node.EXCLUSIVE), arg)

Among them, addWaiter will create a new node Node, representing the current thread, and then join the internal waiting queue, limited to space, the specific code is not listed. After putting in the waiting queue, call acquireQueued to try to get the lock. The code is:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

The main body is a dead cycle. In each cycle, the first check is whether the current node is the first waiting node. If it can get a lock, the current node is removed from the waiting queue and returned. Otherwise, the final call LockSupport.park abandons the CPU, enters the waiting, wakes up, checks whether an interrupt has occurred, records the interrupt flag, and returns when the final method returns. Interruption sign. If an interrupt occurs, the acquire method will eventually call the selfInterrupt method to set the interrupt flag bit. The code is:

private static void selfInterrupt() {
    Thread.currentThread().interrupt();
}

Above is the basic process of lock method, which can get locks immediately. Otherwise, join the waiting queue, check if you are the first waiting thread after waking up, if you can get locks, then return, otherwise continue to wait. If there is an interruption in this process, lock will record the interruption flag bit, but will not return or throw an exception in advance.

The code for the unlock method of ReentrantLock is:

public void unlock() {
    sync.release(1);
}

release is a method defined in AQS, coded as:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

The tryRelease method modifies the state release lock, and unparkSuccessor calls LockSupport.unpark to wake up the first waiting thread, without enumerating the specific code.

The main difference between FairSync and NonfairSync is that when acquiring a lock, that is, in the tryAcquire method, if it is not currently locked, that is, c==0, FairSync has more than one check, as follows:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    ...

This check means that unless there are no other threads that wait longer, it will attempt to acquire the lock.

Isn't it good to be fair? Why does default not guarantee fairness? The reason for the low overall performance of fairness is not that this check is slow, but that active threads will not be locked, enter the waiting state, cause context switching, and reduce the overall efficiency. Usually, it does not matter who runs first, and it runs for a long time. From the statistical point of view, although it does not guarantee fairness, it is basically fair.

It should be noted that even if the fair parameter is true, the tryLock method without parameters in ReentrantLock does not guarantee fairness. It does not check whether there are other threads with longer waiting time. Its code is:

public boolean tryLock() {
    return sync.nonfairTryAcquire(1);
}

ReentrantLock versus synchronized

Compared with synchronized, ReentrantLock can achieve the same semantics as synchronized, but it also supports non-blocking access to locks, can respond to interrupts, and can be time-bound, which is more flexible.

However, synchronized is simpler to use, less code to write, and less error-prone.

synchronized represents a declarative programming. Programmers express more a synchronous declaration. Java system is responsible for the specific implementation. Programmers do not know the details of its implementation. Explicit locks represent an imperative programming. Programmers implement all the details.

The benefits of declarative programming lie not only in its simplicity but also in its performance. ReentrantLock and synchronized performance are similar in the newer version of JVM, but Java compilers and virtual machines can continuously optimize the implementation of synchronized. For example, the use of synchronized can be automatically analyzed, and the call to lock acquisition/release can be automatically omitted for scenarios without lock competition.

In summary, we can use synchronized to use synchronized, which does not meet the requirements, and then consider ReentrantLock.

Summary

This section mainly introduces the explicit lock ReentrantLock, its usage and implementation principle. In terms of usage, we focus on using tryLock to avoid deadlock. In principle, ReentrantLock uses CAS, LockSupport and AQS. Finally, we compare ReentrantLock and synchronized, and recommend using synchronized first.

In the next section, let's look at explicit conditions.

(As in other chapters, all the code in this section is located at https://github.com/swiftma/program-logic)

----------------

To be continued, check the latest articles, please pay attention to the Wechat public number "Lao Ma Says Programming" (scanning the two-dimensional code below), from the entry to advanced, in-depth shallow, Lao Ma and you explore the essence of Java programming and computer technology. Be original and reserve all copyright.

Posted by m!tCh on Fri, 12 Apr 2019 13:15:31 -0700