《Java并发编程的艺术》第五章——Java中的锁

知识点:

  1. Lock接口。
  2. AQS队列同步器。
  3. 重入锁和读写锁。
  4. LockSupport工具。
  5. Condition接口。

1.Lock接口。
在Lock接口出现之前,Java程序靠synchronized关键字实现锁功能,而Java SE5之后,并发包中新增了Lock接口用来实现锁功能,他提供了与synchronized关键字类似的同步功能。但在使用时需要显示的获取和释放锁。虽然他缺少了隐式获取和释放锁的便捷性,但却拥有了锁获取与释放的可操作性、可中断性及超时获取锁等synchronized关键字不具备的同步特性。
Lock接口提供的synchronized关键字不具备的主要特性:
这里写图片描述
Lock定义的获取锁和释放锁的基本操作:
这里写图片描述
Lock是一个接口,其常用的实现ReentrantLock会在下文进行讲解。
【备注】:如果在获取锁时发生了异常,异常抛出的同时也会导致锁无故释放。


2.AQS队列同步器
队列同步器AbstractQueuedSynchronizer(AQS),是用来构建锁和其他同步组件的基础框架,他使用一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,他仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件来使用,同步器既可以支持独占式的获取同步状态,也可以支持共享式的获取同步状态,这样就可以方便实现不同类型的同步组件:RenntrantLock、RenntrantReadWriterLock、CountDownLatch等。
【备注】:同步器和锁的关系:锁的面向使用者,它定义了使用者与锁交互的接口,隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。
同步器的设计师是基于模板方法模式的,使用者需要继承同步器并重写制定的方法,并将同步器组合在自定义同步组件的实现中,当调用同步器的模板方法时,这些模板方法会调用使用者重写的方法。
同步器可重写的方法:
这里写图片描述
同步器提供的模板方法:
这里写图片描述
同步器提供的模板方法基本分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。


实例一:

package com.lipeng.fifth;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;

/**
 * 自定义独占式同步组件Demo
 * @author LiPeng
 *
 */
public class MutexDemo {
    public static class Sync extends AbstractQueuedSynchronizer{
        //尝试获取独占同步状态
        @Override
        protected boolean tryAcquire(int arg) {
            if(compareAndSetState(0, 1)){
                //设置独占同步状态所有者为当前线程
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
        //尝试释放独占同步状态
        @Override
        protected boolean tryRelease(int arg) {
            if(getState()==0)
                throw new IllegalMonitorStateException();
            setState(0);
            setExclusiveOwnerThread(null);
            return true;
        }
        //是否被当前线程独占
        @Override
        protected boolean isHeldExclusively() {
            return getState()==1;
        }
    } 

    private final Sync sync=new Sync();

    /**
     * 获取同步状态
     */
    public void Lock(){
        sync.acquire(1);
    }
    /**
     * 释放同步状态
     * @return
     */
    public boolean unLock(){
        return sync.release(0);
    }
    /**
     * 尝试获取同步状态
     * @return
     */
    public boolean tryLock(){
        return sync.tryAcquire(1);
    }
    /**
     * 尝试释放同步状态
     * @return
     */
    public boolean tryUnLock(){
        return sync.tryRelease(1);
    }
    /**
     * 检测当前线程是否独占同步状态
     * @return
     */
    public boolean isHeldExclusively(){
        return sync.isHeldExclusively();
    }
    /**
     * 带超时功能的获取同步状态
     * @param time
     * @param unit
     * @return
     * @throws InterruptedException
     */
    public boolean lockWithTimeout(long time,TimeUnit unit) throws InterruptedException{
        return sync.tryAcquireNanos(1, unit.toNanos(time));
    }
    /**
     * 响应中断的获取同步状态
     * @throws InterruptedException
     */
    public void lockWithInterrupted() throws InterruptedException{
         sync.acquireInterruptibly(1);
    }


}

队列同步器的实现分析
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称如下图:
这里写图片描述
同步队列的基本结构如下:
这里写图片描述
当一个线程成功获取同步状态后,其他线程无法获取到同步状态时,获取同步状态失败的线程被构造成节点,并以线程安全的方式加入同步队列。
【备注】:compareAndSetTail(Node expect,Node update)方法是基于CAS的设置尾节点的方法,它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
设置尾节点的流程如下:
这里写图片描述
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,会唤醒后继节点,后继节点在成功获取同步状态后,将自己设置为首节点。
设置首节点的流程如下:
这里写图片描述
因为同一时间只有一个线程可以获取到同步状态,所以设置首节点的方法并不需要CAS来保证,只需要将原首节点的后继节点设置为新首节点即可。


独占式同步状态获取与释放
这里写图片描述
在独占式同步组件中,同一时刻只能有一个线程成功获取同步状态,如果其他线程获取同步状态失败,则构造同步节点并通过addWaiter(Node node)方法将该节点加入同步队列的尾部。然后调用acquireQueued(Node node,int arg)方法,以“死循环”的方式获取同步状态,如果获取不到则阻塞节点中的线程,直到被其前驱节点唤醒或被中断。
这里写图片描述
当前驱节点为首节点并成功获取同步状态后,设置自身为新的首节点,并跳出死循环。
独占式同步组件获取同步状态流程如下:
这里写图片描述
由上图可知,只有当前驱节点为首节点时,才会尝试获取同步状态,这是因为:
- 只有前驱节点为首节点,其在释放同步状态后,才会唤醒后继节点,后继节点被唤醒后需要检查自己的前驱节点是否为首节点。
- 维护同步队列FIFO原则并处理过早的通知。
【备注】:过早通知是指前驱节点不是首节点的线程由于中断而被唤醒。
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态后,会唤醒后继节点(unparkSuccessor(Node node))。
这里写图片描述


独占式超时获取同步状态
通过调用同步器的doAcquireNanos(int arg,long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。该方法提供了传统Java同步操作(如:Synchronized关键字)所不具备的特性。
Synchronized关键字不会响应中断,即如果一个线程阻塞在Synchronized上,等待获取锁,如果对该线程进行中断操作,其中断标志位会被修改,但线程仍然会阻塞。
在Java5中,同步器提供了响应中断的获取同步状态的方法:acquireInterruptibly(int arg),此方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException。
而超时获取同步状态的实现原理就是超时时间加上响应中断的获取同步状态。
这里写图片描述
该方法在自旋过程中,当节点的前驱节点为首节点时尝试获取同步状态,如果获取成功则从方法返回并设置自身为新的首节点。如果获取失败,则判断是否超时,如果没有超时,重新计算超时间隔nanosTimeout,如果nanosTimeout小于等于spinForTimeoutThreshold(1000纳秒)时,则进入快速自旋过程,否则使当前线程等待nanosTimeout纳秒。
【备注】:之所以在小于等于1000纳秒就会“提前”进入快速自旋过程的原因在于,非常短的超时等待无法做到十分精确,如果这是再进行超时等待,会让nanosTimeout的超时从整体上表现得不精确。
流程图如下:
这里写图片描述


共享式同步状态获取与释放
共享式同步组件与独占式同步组件最主要的区别是,共享式同步组件允许在同一时刻有多个线程同时获取到同步状态。以文件的读写为例,如果一个程序在对文件进行读操作,那么同一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问。
这里写图片描述
通过调用同步器的acquireShared(int arg)方法可以共享式的获取同步状态。在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,如果当前节点的前驱为首节点,并尝试获取同步状态时返回值大于等于0,则表示获取同步状态成功并退出自旋过程。
共享式同步组件通过调用releaseShared(int arg)方法释放同步状态:
这里写图片描述
该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。它与独占式同步组件区别在于,必须确保同步状态线程安全的释放,因为释放同步状态的操作可能同时来自多个线程,一般是通过循环和CAS来保证。


实例一:

package com.lipeng.fifth;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
/**
 * 自定义同步组件
 * 同一时刻只允许两个线程获取同步状态
 * @author LiPeng
 *
 */
public class SharedLock {
    private final Sync sync=new Sync(2);
    private static class Sync extends AbstractQueuedSynchronizer{
        Sync(int count){
            if(count<=0)
                throw new IllegalArgumentException("count must large than 0");
            setState(count);
        }

        @Override
        protected int tryAcquireShared(int reduceCount) {
            for(;;){
                int current=getState();
                int newCount=current-reduceCount;
                //允许尝试获取同步状态数量大等0,且成功获取同步状态后,跳出自旋
                if(newCount>=0&&compareAndSetState(current, newCount)){
                    return newCount;
                }
            }
        }
        @Override
        protected boolean tryReleaseShared(int returnCount) {
            for(;;){
                int current=getState();
                int newCount=current+returnCount;
                if(compareAndSetState(current, newCount)){
                    return true;
                }
            }
        }
    }
    public void lock(){
        sync.tryAcquireShared(1);
    }
    public void unLock(){
        sync.tryReleaseShared(1);
    }


    //测试
    public static void main(String[] args) {
        final SharedLock sharedLock=new SharedLock();
        for(int i=0;i<10;++i){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    sharedLock.lock();
                    try {
                        //睡眠3秒
                        System.out.println(Thread.currentThread().getName()+" get lock");
                        TimeUnit.SECONDS.sleep(3);
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
                    sharedLock.unLock();
                }
            },"Thread-"+i).start();;
        }
    }

}

3.重入锁和读写锁
3.1 重入锁
重入锁顾名思义就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁,除此之外,该锁还支持获取锁时的公平和非公平性选择,即如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。ReentrantLock提供一个构造函数来控制锁是否是公平的。
实现重入锁,必须解决以下问题:
- 线程再次获取锁:锁需要识别获取锁的进程是否是当前占用锁的进程,如果是,则获取成功。
- 锁的释放:线程重复获取了N次锁,随后在第N次释放锁后,其他线程才能够获取到该锁。锁在被获取时进行计数自增,在释放时,计数自减,当计数等于0时表示锁成功释放。
非公平的重入锁获取同步状态:
这里写图片描述
如果无线程占用锁,则使用CAS算法设置新的状态,若成功,则成功获取锁。否则,判断当前线程是否是占用锁的线程,如果是,则设置新的状态并成功获取锁。
公平的重入锁获取同步状态:
这里写图片描述
与非公平的重入锁相比,公平锁唯一的改变首次获取同步状态时,会通过调用hasQueuedPredecessors()方法来判断当前节点是否包含前驱节点,以此保证公平的获取锁。
重入锁释放同步状态:
这里写图片描述
【备注】:公平锁虽然保证了锁的获取按照FIFO原则,但代价就是会进行大量的线程切换,导致性能下降。而非公平锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
3.2 读写锁
上面介绍的锁基本都是排它锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但在写进程访问时,所有的读进程和其他写进程均被阻塞。读写锁通过分离读锁和写锁,使并发性相比一般的排他锁有了很大提升。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock。它提供的特性如下:
这里写图片描述
ReadWriteLock接口仅定义了获取读锁和写锁的两个方法,即readLock()和writeLock()方法,而其实现:ReentrantReadWriteLock除此之外还提供了便于外界监控其工作状态的方法:
这里写图片描述


实例一:

package com.lipeng.fifth;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock;
/**
 * 使用读写锁的缓存Demo
 * @author LiPeng
 *
 */
public class CacheDemo {
    private static ReentrantReadWriteLock lock=new ReentrantReadWriteLock();
    private static WriteLock writeLock=lock.writeLock();
    private static ReadLock readLock=lock.readLock();
    private static Map<String,String> cache=new HashMap<String,String>();

    //读锁
    public static String getValByKey(String key){
        try {
            System.out.println(Thread.currentThread().getName()+" try get read lock ,TimeStrap:"+System.currentTimeMillis());
            readLock.lock();
            System.out.println(Thread.currentThread().getName()+" got read lock ,TimeStrap:"+System.currentTimeMillis());
            TimeUnit.SECONDS.sleep(10);
            return cache.get(key);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }finally{
            readLock.unlock();
        }
    }

    //写锁
    public static String setVal(String key,String val){
        try {
            System.out.println(Thread.currentThread().getName()+" try get write lock ,TimeStrap:"+System.currentTimeMillis());
            writeLock.lock();
            System.out.println(Thread.currentThread().getName()+" got write lock ,TimeStrap:"+System.currentTimeMillis());
            return cache.put(key, val);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }finally{
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        Thread thread1=new Thread(new Runnable() {
            @Override
            public void run() {
                CacheDemo.getValByKey("1");
            }
        },"Thread-1");
        Thread thread2=new Thread(new Runnable() {
            @Override
            public void run() {
                CacheDemo.getValByKey("2");
            }
        },"Thread-2");
        Thread thread3=new Thread(new Runnable() {
            @Override
            public void run() {
                CacheDemo.setVal("1","1");
            }
        },"Thread-3");
        try {
            thread1.start();
            TimeUnit.SECONDS.sleep(2);
            thread2.start();
            TimeUnit.SECONDS.sleep(2);
            thread3.start();
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

运行截图:
这里写图片描述


读写锁实现的分析
读写锁同样依赖AQS实现,其读写状态就是同步器的同步状态。因为同步器中表示同步状态的是一个整形变量,于是需要“按位切割使用”,高16位表示读状态,低16位表示写状态。
如下图:
这里写图片描述
写锁的获取与释放
写锁是一个支持重进入的排它锁。如果当前线程已经获取写锁,则增加状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是当前占用写锁的线程,则进入等待状态。如果允许在读写被获取的情况下,仍然可获取写锁,那么正在运行的线程将无法感知获取写锁线程的操作。
这里写图片描述
写锁的释放与重入锁的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放。
读锁的获取和释放
读锁是一个支持重入的共享锁,它能被多个线程同时获取,在没有其他写线程访问时,读锁总会被成功获取,并线程安全的增加读状态。如果有线程获取了写锁,则会进入等待状态。
这里写图片描述
在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程增加读状态,成功获取读锁。
【备注】:同一线程,在已经获取写锁的情况下,仍然可获取读写,因为其“写”动作可以被“读”感知到。
锁降级
锁降级是指写锁降级成为读锁。线程在获取写锁后,再获取读锁,随后释放写锁,则写锁降级为读锁。
之所以是先获取读锁,再释放写锁的原因是,如果不获取读锁,直接释放写锁,那么如果此时有其他线程获取写锁并修改数据后,当前线程无法感知数据的更新。如果当前线程先获取读锁再释放写所得话,其他线程在当前线程占有读锁时,无法获取到写锁,直到当前线程释放。
RentrantReadWriteLock不支持锁升级(在占用读锁时,获取写锁,然后释放读锁),目的同样是为了保证数据的可见性。如果读锁已经被多个线程获取,其中任意线程获取写锁并更新数据后,更新后的数据对其他获取到读锁的线程不可见。
4. LockSupport工具
当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作。LockSupport定义了一组公共静态方法,提供最基本的线程阻塞和唤醒功能。LockSupport也是构建同步组件的基础工具。
这里写图片描述
Java6中,LockSupport增加了park(Object blocker)、parkNanos(Object blocker,long nanos)和parkUtil(Object blocker,long deadline)3个方法,用于实现阻塞当前线程的功能,其中参数blocker用来标识当前线程在等待的对象,为线程dump提供监控使用。
5. Condition接口
任意一个Java对象,都拥有一组监视器方法(wait及notify等),与synchronized关键字配合,可以实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式。两者在使用方式及功能特性上差别如下:
这里写图片描述
Condition定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁,Condition对象是由Lock对象创建出来的,换句话说,Condition是依赖Lock对象的。


实例一:

package com.lipeng.fifth;

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

/**
 * Condition 实现等待/通知模式
 * @author LiPeng
 *
 */
public class WaitAndNoifyDemo {

    static Lock lock=new ReentrantLock();
    static Condition condition=lock.newCondition();

    public static void main(String[] args) {
        try {
            new Thread(new WaitAction(lock,condition),"WaitAndNoifyDemo-WaitThread").start();
            //sleep 3s
            TimeUnit.SECONDS.sleep(3);
            new Thread(new NotifyAction(lock,condition),"WaitAndNoifyDemo-NotifyThread").start();   
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

    }
}
class WaitAction implements Runnable{
    Lock lock;
    Condition condition;
    public WaitAction(Lock lock, Condition condition) {
        super();
        this.lock = lock;
        this.condition = condition;
    }

    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName()+" try lock...");
            lock.lock();
            System.out.println(Thread.currentThread().getName()+" get lock and await...");
            condition.await();
            System.err.println(Thread.currentThread().getName()+" notify and start do something...");
            lock.unlock();
            System.out.println(Thread.currentThread().getName()+" unlock...");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}
class NotifyAction implements Runnable{
    Lock lock;
    Condition condition;
    public NotifyAction(Lock lock, Condition condition) {
        super();
        this.lock = lock;
        this.condition = condition;
    }
    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName()+" try lock...");
            lock.lock();
            System.out.println(Thread.currentThread().getName()+" get lock and signal other thread...");
            condition.signal();
            lock.unlock();
            System.out.println(Thread.currentThread().getName()+" unlock...");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}



Condition的实现分析
ConditionObject是同步器AQS的内部类,因为Condition的操作需要获取相关的所。每个Condition对象都包含一个队列(即等待队列),该队列是Condition对象实现等待/通知功能的关键。
等待队列
等待队列是一个FIFO的队列,在队列中的每个节点都包含一个线程引用,该线程就是在Condition对象上的等待的线程,如果一个线程调用了Condition.await()方法,则该线程会释放锁、构造节点加入等待队列并进入等待状态。此节与同步器中节点定义一致,均是AbstractQueuedSynchronizer.Node。
一个Condition包含一个等待队列,并拥有首节点和尾节点的引用,当线程调用了Condition.await()方法,将会构造节点,加入队列尾部,并将原尾结点nextWaiter指向它,并更新尾节点。此过程不需要CAS保证,因为执行此操作的线程,必定是获取了锁的线程。
其基本结构如下:
这里写图片描述
在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(确切的说是同步器)拥有一个同步队列和多个等待队列 ,其对应关系如下:
这里写图片描述
等待
调用Condition的await()方法,会使当前线程进入等待队列并释放锁,然后唤醒同步队列中的后继节点,最后当前线程变为等待状态。
如果从队列角度看await()方法,当调用await()方法时,相当于同步队列的首节点移动到了Condition的等待队列中。
这里写图片描述
当前线程加入等待队列示意图:
这里写图片描述
通知
调用Condition的signal()方法,会将等待队列中的首节点移动到同步队列中,并将其唤醒,被唤醒的线程尝试获取同步状态。(如果不是同步调用signal()方法唤醒,而是对等待线程进行终端,则会抛出InterruptException),如果成功获取锁,则被唤醒的线程从await()方法返回。
这里写图片描述
节点从等待队列移动到同步队列示意图:
这里写图片描述
Condition的signalAll()方法,相当于等待队列中的每个节点均被执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。

【备注】:本文图片均摘自《Java并发编程的艺术》·方腾飞,若本文有错或不恰当的描述,请各位不吝斧正。谢谢!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值