前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java中的锁

Java中的锁

作者头像
胖虎
发布2020-12-08 14:48:01
3410
发布2020-12-08 14:48:01
举报
文章被收录于专栏:晏霖

曾经有人关注了我

后来他有了女朋友

本章节介绍Lock接口的应用,以及Lock最核心的队列同步器AbstractQueuedSynchronizer(AQS)源码浅析,进而分析AQS的典型实现ReentrantLock(重入锁)、ReentrantReadWriteLock(读写锁)的使用方法和场景,以及和synchronized进行对比分析。本章先会带领大家分析AQS,然后才会介绍与Lock相关的API和工具,本着先原理后应用的顺序,学习AQS会有些抽象,理论地方试着多读或抽象出模型来更好理解记忆。

2.7.1 Lock接口

我们在上一章节刚刚学会了synchronized的原理,我们知道synchronized是隐式同步的,在Java5.0之前共享对象访问时只能使用synchronized和volatile,这个时候synchronized还没有被优化,性能还是差强人意,但在Java5.0开始,Java提供了一个新的线程同步机制,可以通过Lock接口,显式定义同步锁对象来实现同步,这点和synchronized机制恰恰相反。Lock提供可选择的高级功能,可以处理synchronized无法处理的复杂环境,这也就说明Lock使用比synchronized注意的点要多。

下面我们看一下Lock接口提供的方法,便于更好的阅读,请读者可先查看java.util.concurrent.locks.Lock中的源码,跟着源码中的注释一起理解接口中的方法。如下表2-10所示是Lock的API。

表2-10 Lock的API

方法

描述

void lock();

获取锁

void lockInterruptibly() throws InterruptedException;

获取锁(没有其他线程持有锁,或当前线程已持有锁)并立即返回。如果其他线程持有锁,则当前线程将处于不可用状态以达到于线程调度目的,并且休眠直到下面两个事件中的一个发生:①当前线程获取到锁②其他线程中断当前线程 如果当前线程获取到锁,则将锁计数设置为1。如果当前线程在方法条目上设置了中断状态或者在请求锁的时候被中断,将抛出中断异常。

boolean tryLock();

获取锁(如果可用)并立即返回true,如果锁不可用,则此方法将立即返回false

boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

在给定等待时间获取锁,有以下3种情况返回:①在给定时间内获取到的锁。②在给定时间内被中断。③超时结束返回false。

void unlock();

释放锁

Condition newCondition();

该实例绑定到此锁的实例。当前线程只有获取了锁,才能调用该组件的wait()方法,调用后自动释放锁。

该Lock接口以上方法大家一定要记住的void lock();、boolean tryLock();、boolean tryLock(long time, TimeUnit unit) throws InterruptedException;、void unlock();这个4个方法,是我们经常使用的,例如一下代码2-22是一个简单的Lock应用。

代码清单 2-22 LockCase.java

代码语言:javascript
复制
public class LockCase {
    private Lock lock = new ReentrantLock();

    public void method() {
        lock.lock();
        try {
            // manipulate protected state
        } finally {
            lock.unlock();
        }
    }
}

以上代码是Lock推荐的使用方法,在Lock.java注释中有体现。必须在finally块中释放锁,保证在获取到锁后,最终都能释放锁。也必须在try块外获取锁,这样在获取锁失败也不会导致锁无故释放。

2.7.2 队列同步器AQS

本章节是介绍Java中的锁,也可以换个说法是讲Java中的同步组件,典型代表有ReentrantLock、CountDownLatch、ReentrantReadWriteLock等,以上这些是我们比较常用的同步组件,本章节我们就要对这些同步组件进行原理刨析。读者可以把这几个类在源码中找到,可以发现他们都会定义一个private final Sync sync;这样的对象,这个Sync是当前同步组件实现同步的核心对象,而Sync在当前同步组件是一个内部类,并且都继承了AbstractQueuedSynchronizer,也就是说所有同步的方法都是这个父类提供的。下面我们进入AbstractQueuedSynchronizer类中让我们开始了解他。进入这个类我们发现AQS提供类这么多的方法,我们该如何下手?我们先把这个类大概过一遍,其实不难发现,很多方法名很类似,甚至我们可以找出共同点,他们都是成对存在的,例如有tryAcquire()方法就会有tryAcquireShared()方法,这样我们暂可把方法分成两类,这种带有Shared单词的方法成为共享方式,不带Shared单词自然就是独享式,由于篇幅原因这里简易介绍共享式与独占式,我们把独占式和共享式用独占锁和共享锁模型来理解。

l 独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据,例如JDK中的synchronized。

l 共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据,例如ReentrantReadWriteLock中读锁就是是共享锁。

细心的小伙伴可以发现还有一种维度可以将其内部重要的方法分为两部分,一种是方法名带try前缀的,一种不带try。这是因为同步器的设计是基于模版方法模式的,也就是说使用者需要继承AQS并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模版方法,而这些模版方法将会调用使用者重写的方法。通俗一点说是什么意思呢?我们用ReentrantLock来举一个例子,这里的ReentrantLock对象我们称之为同步器的使用者,在使用ReentrantLock实例中的lock();方法时其实是调用的AQS中acquire();方法,而AQS中acquire();方法继续调用的是具体使用者重写的tryAcquire();方法。在所有AQS的使用者里基本上都会重写tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared中某些方法,也就是说实际使用者执行的是自己重写的方法,这些使用者在重写这些方法的代码也是略有不同的。

而同步器中又一重要的内容是他用一个volatile 来修饰整型变量state,即private volatile int state;来维护同步器的同步状态,同时还提供来3个方法来访问或修改同步状态。

l getState() :获取同步状态。

l setState(int newState) :设置同步状态。

l compareAndSetState(int expect, int update) :使用CAS设置当前状态,该方法具有原子性。

根据以上描述我们可以把同步器提供的模版方法基本分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待情况。以下是把同步器中部分重要的方法按照模版方法和可重写方法分类进行方法介绍。

表2-11 AQS可重写方法

方法名

描述

protected boolean tryAcquire(int arg)

独占式获取同步状态,获取同步状态成功后,其他线程需要等待该线程释放同步状态才能获取同步状态;返回值true代表获取成功,false代表获取失败

protected boolean tryRelease(int arg)

独占式释放同步状态,这时等待获取同步状态的线程才有机会获取到同步状态。返回值true代表获取成功,false代表获取失败

protected int tryAcquireShared(int arg)

共享式获取同步状态,返回值大于等于0则表示获取成功,否则获取失败

protected boolean tryReleaseShared(int arg)

共享式释放同步状态;

protected boolean isHeldExclusively()

当前同步器是否在独占式模式下被线程占用,一般该方法表示是否被当前线程所独占;

表2-12 AQS模版方法

方法名

描述

public final void acquire(int arg)

独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用可重写的tryAcquire(int arg)方法;

public final void acquireInterruptibly(int arg)

与acquire(int arg)相同,但是该方法响应中断,当前线程为获取到同步状态而进入到同步队列中,如果当前线程被中断,则该方法会抛出InterruptedException异常并返回;

public final boolean tryAcquireNanos(int arg, long nanosTimeout)

超时获取同步状态,如果当前线程在nanos时间内没有获取到同步状态,那么将会返回false,已经获取则返回true;

public final boolean release(int arg)

独占式释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒;

public final void acquireShared(int arg)

共享式获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式的主要区别是在同一时刻可以有多个线程获取到同步状态;

public final void acquireSharedInterruptibly(int arg)

共享式获取同步状态,响应中断;

public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout)

共享式获取同步状态,增加超时限制;

public final boolean releaseShared(int arg)

共享式释放同步状态;

public final Collection<Thread> getQueuedThreads()

返回等待同步队列上的线程集合;

以上方法比较多,读者不要死记硬背,上面我已经介绍来两种维度的关键字来帮助我们更好的区分记忆,读到这里我们才刚刚开始介绍AQS的原理,只有掌握来同步器的工作原理才能更好的理解这些基于AQS而开发的各种并发组件。

下面我们从实现的角度来分析同步器是如何使线程同步的,当我们翻开AQS的源码,不难看出同步器其实内部是依赖一个先进先出的双向的同步队列,就是这个同步队列来管理同步状态的。看到AQS的数据结构这让我想起了学习HashMap的时候,也是一个Node作为数据的存储空间。其实无论是学习一个集合也好,对象也好,还是一个队列也好,首先要清楚他的数据结构是怎样的,万物的拓展都离不开最基本的原理,换句话说如果了解其实现原理就可以举一反三。我们言归正传,让我们看看这个Node里面都是些什么?如代码2-23所示描述的是AbstractQueuedSynchronizer.java部分内容。

代码清单2-23 AbstractQueuedSynchronizer.java

代码语言:javascript
复制
static final class Node {
    // 标记⼀个结点(对应的线程)在共享模式下等待
    static final Node SHARED = new Node();
   // 标记⼀个结点(对应的线程)在独占模式下等待
    static final Node EXCLUSIVE = null;
   // waitStatus的值,表示该结点(对应的线程)已被取消
    static final int CANCELLED =  1;
    // waitStatus的值,表示后继结点(对应的线程)需要被唤醒
static final int SIGNAL    = -1;
    //waitStatus的值,表示该结点(对应的线程)在等待某⼀条件
    static final int CONDITION = -2;
    //该值表示有资源可⽤,新head结点需要继续唤醒后继结点(共享模式下,多线程并行)
    static final int PROPAGATE = -3;
  // 等待状态,取值范围,-3,-2,-1,0,1
    volatile int waitStatus;

    volatile Node prev;//前驱节点
    volatile Node next;//后继节点
    volatile Thread thread;//节点对应线程
  //等待队列中的后继节点,如果当前节点是共享的,那么这个字段是一个SHARED常量,也就是说节  //点类型和等待队列中的后继节点共用一个字段。
    Node nextWaiter;
  //判断共享模式的方法
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

   //返回上一个节点,如果为空抛空指针
    final Node predecessor() {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }

    //建立初始头部或共享标记
    Node() {}

    //addWaiter使用的构造函数
    Node(Node nextWaiter) {
        this.nextWaiter = nextWaiter;
        THREAD.set(this, Thread.currentThread());
    }

    //addConditionWaiter使用的构造函数
    Node(int waitStatus) {
        WAITSTATUS.set(this, waitStatus);
        THREAD.set(this, Thread.currentThread());
    }

    //使用CAS设置 waitStatus字段
    final boolean compareAndSetWaitStatus(int expect, int update) {
        return WAITSTATUS.compareAndSet(this, expect, update);
    }

    //使用CAS设置next字段
    final boolean compareAndSetNext(Node expect, Node update) {
        return NEXT.compareAndSet(this, expect, update);
    }
//忽略其他代码
  }

节点是构成该同步队列的基础,同步队列拥有首节点(head)和尾节点(tail),没有成功获取到同步状态的线程将通过CAS的方式加入队列的尾部,同时阻塞当前线程,当同步状态释放时,把首节点线程唤醒,使其再次尝试获取同步状态。我们可以在AQS源码中可以看到该队列的雏形,为了方便阅读我们将其优化,如图2-19所示。

图 2-19 同步队列的结构

假设这样一个情景,一个线程获取同步状态时没有获取成功,被添加到了尾节点。在代码实现上,既调用了acquire(int arg)方法,我们来看一下这个方法都做了什么,我们把此方法相关调用都贴了出来.如代码2-24。

代码清单2-24 AbstractQueuedSynchronizer.java

代码语言:javascript
复制
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
private Node addWaiter(Node mode) {
//生成该线程都Node节点
        Node node = new Node(Thread.currentThread(), mode);
        // 快速尝试在队列尾部添加
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
//使用CAS尝试添加,成功则返回
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
//如果等待队列为空或上述CAS失败,则自旋CAS插入
        enq(node);
        return node;
    }
 
 //自旋CAS插入等待队列
private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
 
 
  final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
//自旋
            for (;;) {
//获取上一个节点
                final Node p = node.predecessor();
// 如果node的前驱结点p是head,表示node是第⼆个结点,就可以尝试去获取资源了
                if (p == head && tryAcquire(arg)) {
// 拿到资源后,将head指向该结点。
// 所以head所指的结点,就是当前获取到资源的那个结点或null。
                    setHead(node);
//节点资源获取成功,将之前head设置引用null,帮助GC回收
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
// 如果⾃⼰可以休息了,就进⼊waiting状态,直到被unpark()
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

调用acquire(int arg)方法首先调用tryAcquire(arg)尝试去获取资源。前面提到了这个⽅法是在子类具体实现的。如果获取资源失败,就通过addWaiter(Node.EXCLUSIVE)方法把这个线程插入到等待队列中。其中传入的参数代表要插入的Node是独占式的,即上述代码所示,上面的addWaiter(Node mode)、enq(final Node node) 两个函数结合注释阅读很好理解,就是在队列的尾部插入新的Node节点,但是需要注意的是由于AQS中会存在多个线程同时争夺资源的情况,因此肯定会出现多个线程同时插入节点的操作,在这里是通过CAS自旋的方式保证了操作的线程安全性。 这时已经把⼀个Node节点放到等待队列尾部了。而处于等待队列的结点是从头结点⼀个⼀个去获取资源的。具体的实现我们来看看acquireQueued()方法。acquireQueued()方法会让该节点线程自旋一次,通过node.predecessor();方法返回当前节点的父节点,如果父节点是head节点,那么当前节点再次执行tryAcquire(arg) 方法,若返回 success 则获取锁,失败则调用parkAndCheckInterrupt()方法将当前线程进入阻塞状态,只有该节点的前驱节点出队或被中断才可以唤醒被阻塞的线程。每一个线程在被阻塞之前都处于一个自旋的过程,达到条件就可以退出自旋,否则最终将会被阻塞。下图为独占式同步状态获取流程流程图参考《Java并发编程的艺术》。

图2-20 独占式同步状态获取流程

当同步状态获取成功,即线程从acquire(int arg)方法返回时对于AQS使用者来说。代表者获取到了锁,当获取锁的线程执行完逻辑后,就需要释放同步状态,这样后继节点才能继续尝试获取同步状态。在AQS源码中通过调用release(int arg)方法可以释放同步状态,如下2-25代码清单所示.

代码清单2-25 AbstractQueuedSynchronizer.java

代码语言:javascript
复制
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

其中tryRelease(arg)方法调用的是子类重写的方法,它会唤醒等待队列里的其他线程来获取资源,如果下一个节点不是null,并且状态不是0,则调用unparkSuccessor()方法。如代码2-26代码清单所示。

代码清单2-26 AbstractQueuedSynchronizer.java

代码语言:javascript
复制
private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
//设置当前线程的初始状态为0
        node.compareAndSetWaitStatus(ws, 0);
  
    Node s = node.next;//找到head下一个需要唤醒的节点,   
 if (s == null || s.waitStatus > 0) {//如果下一个节点被取消了(>0),则从tail往前找
        s = null;
        for (Node p = tail; p != node && p != null; p = p.prev)
            if (p.waitStatus <= 0)
                s = p;
    }
    if (s != null)
//unpark()唤醒线程
        LockSupport.unpark(s.thread);
}

unparkSuccessor(Node node) 方法用来将 Head(当前持有锁的 Node)节点的 waitStatus 的值重置为 0,然后找到 Head 的下一个未取消 (cancel)的节点找出来并唤醒(因此唤醒的节点便可以继续在 acquireQueued 中自旋获取锁)。

以上所述内容就是独占式同步状态获取与释放,是AQS中较简单也是最重要的和最典型的内容。下面简述的就是共享式同步状态获取与释放,共享式与独占式实现逻辑有很多相似处。共享式获取与独占式获取主要区别在于同一时间能否有多个线程同时获取同步状态。举个例子,文件读写时,既保证高效有保证不被脏读的方法就是,写操作对资源独占访问,读操作可以共享访问。所以大家更好理解为什么 ReentrantReadWriteLock 的读可以共享了。共享式同步状态的释放和独占式的区别在于tryReleaseShared(int arg)方法必须确保拥有资源的线程安全释放,因为共享式可能会有多个线程参与同步状态,所以一般通过CAS来保证。

2.7.3 显示锁

本小结介绍Java中两个比较重要的显示锁的使用,一个是ReentrantLock,另一个是ReentrantReadWriteLock。可能读者会有疑问,因为几乎所有资料都会把ReentrantLock视为重入锁,没错,从名字上看他确实是可重入性的锁,之所以小标题使用显示锁是因为,我们知道在JAVA中ReentrantLock 和synchronized 都是可重入锁,这也是为了区分synchronized而定的,ReentrantLock的最大优点是可重入吗?并不是,在JDK1.7之前他和synchronized 关键字最大的区别有两点,第一点是性能原因,那时候的synchronized 显得格外的重,性能上还是较差一些。第二点是ReentrantLock在某些情况下加锁机制更灵活一些,例如可中断的特性等。但是现如今随着JVM发展,锁优化机制越来越多,在性能上我们不必纠结这两种锁,但是在功能上synchronized 确实有些局限性。在2.7.2章节中我们已经分析了ReentrantLock的原理,下面我们要对他的使用等方面进行分析。

重入锁,顾名思义,是有重入性的,这里面有两个角色,一个是谁可以重新进入,另一个是他可以重新进入哪里。我们可以这样理解,重入是指线程在获取锁后可以在此获取到该锁而不会被阻塞的特性,称为重入性。我们来举个例子,请看示例代码2-27.

代码清单2-27 ReEntrantLock.java

代码语言:javascript
复制
public class ReEntrantLock {
    private Lock lock = new ReentrantLock();

    public void m() {
        lock.lock();
        lock.lock();
        try {
            // ... method body
        } finally {
            lock.unlock();
            lock.unlock();
        }
    }
}

上述代码线程可以重复多次获取锁,随后线程也要释放n次锁,其他线程才能有机会获取到锁。我们在ReentrantLock源码中nonfairTryAcquire()方法,代码示例2-28所示。线程获取到锁state加1,如果还是同一个线程重入,state在此基础上自增,每释放一次计数自减,当计数等于0表示锁已经成功释放。这种重入的次数在ReentrantLock源码中明确说明,可以重入2147483647次,然而他仅仅是个理论值,这是因为我们的state就是用int来声明的,实际情况根本不会重入这个数字的。源码中释放锁的方法大家可以自行去源码中阅读,篇幅原因,不贴出。

代码清单2-28 ReEntrantLock.java

代码语言:javascript
复制
@ReservedStackAccess
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;
}

上述内容我们已经很好的解释了ReentrantLock 和synchronized重入性的问题,下面我们再说这两者还有另外一个区别,就是在缺省的情况,两者均为非公平性,但ReentrantLock 提供了一个构造方法传入boolean来改变公平性。下面我们来对比ReentrantLock 公平与非公平在资源竞争下线程的执行顺序如何,首先我们自定义一个类来重写AQS的getQueuedThreads() 方法,该方法返回的是阻塞线程集合,也就是等待获取锁的线程列表,然后使用我们自定义的类创建锁对象,这就好比我们把JDK自带的ReentrantLock封装类一层,代码如下所示2-29。

代码清单2-29 ReentrantLock2.java

代码语言:javascript
复制
public class ReentrantLock2 extends ReentrantLock {
    /***
     * @函数功能:构造函数
     * @param fair: 公平性
     * @return:
     */
    public ReentrantLock2(boolean fair) {
        super(fair);
    }

    @Override
    protected Collection<Thread> getWaitingThreads(Condition condition) {
        return super.getWaitingThreads(condition);
    }

    @Override
    protected Collection<Thread> getQueuedThreads() {
        ArrayList<Thread> list = new ArrayList<>(super.getQueuedThreads());
        Collections.reverse(list);
        return list;
    }
}

准备工作已经就绪,现在让我们使用自定义的ReentrantLock2来实现公平与非公平的执行顺序如何,下面我们写一个示例代码,如示例代码2-30所示

代码清单2-30 ReentrantLock2.java

代码语言:javascript
复制
@Slf4j
public class FairAndUnfair {
    private static ReentrantLock fair = new ReentrantLock2(true);
    private static ReentrantLock unfair = new ReentrantLock2(false);

    public static void main(String[] args) {
        log.info("公平锁测试");
//        log.info("非公平锁测试");
        for (int i = 0; i < 5; i++) {
            Thread thread = new Job(fair);
//            Thread thread = new Job(unfair);
            thread.setName("" + i);
            thread.start();
        }
     }

    private static class Job extends Thread {
        private ReentrantLock lock;

        public Job(ReentrantLock lock) {
            this.lock = lock;
        }

        @Override
        public void run() {
            for (int i = 0; i < 2; i++) {
                lock.lock();
                ArrayList<Thread> list= (ArrayList) ((ReentrantLock2) lock).getQueuedThreads();
                try {
    Thread.sleep(500);
                    List<String> tName=new LinkedList<>();
                    list.forEach(thread -> tName.add(thread.getName()));
                    log.info("Lock by [{}], Waiting by [{}}",Thread.currentThread().getName(), tName.toString());
                } finally {
                    lock.unlock();
                }
            }
        }
    }
}

我们分别执行注释和放开相应的代码,把控制台数据整理如下表2-13所示。

表2-13 公平与非公平输出结果

公平

非公平

Lock by [0], Waiting by [[]]

Lock by [0], Waiting by [[]]

Lock by [1], Waiting by [[2, 3, 4, 0]]

Lock by [0], Waiting by [[2, 1, 3, 4]]

Lock by [2], Waiting by [[3, 4, 0, 1]]

Lock by [2], Waiting by [[1, 3, 4]]

Lock by [3], Waiting by [[4, 0, 1, 2]]

Lock by [2], Waiting by [[1, 3, 4]]

Lock by [4], Waiting by [[0, 1, 2, 3]]

Lock by [1], Waiting by [[3, 4]]

Lock by [0], Waiting by [[1, 2, 3, 4]]

Lock by [1], Waiting by [[3, 4]]

Lock by [1], Waiting by [[2, 3, 4]]

Lock by [3], Waiting by [[4]]

Lock by [2], Waiting by [[3, 4]]

Lock by [3], Waiting by [[4]]

Lock by [3], Waiting by [[4]]

Lock by [4], Waiting by [[]]

Lock by [4], Waiting by [[]]

Lock by [4], Waiting by [[]]

为了体现非公平的效果我们在加锁后使用sleep()来模拟业务执行消耗的时间。根据上表所示,表中每一个数字代表线程的name,我们发现公平性的锁执行是顺序性,并且阻塞情况要比非公平明显。而非公平性的锁执行线程并不是连续的,我们还会发现同一个线程基本是连续执行,这是因为刚刚释放锁的线程再次获取到锁的几率很大。

有些同学可能会有疑问,非公平与非公平差别在哪?而且ReentrantLock和synchronized如何选择呢?下面我们就来让大家看一下,使用公平与非公平之间性能的差异性,和显示锁和隐式锁之间如何选择。我们准备测试代码例2-31所示。

代码清单2-31 PerformanceTest.java

代码语言:javascript
复制
@Slf4j
public class PerformanceTest {
    private static volatile int count;
    //    private static Lock lock = new ReentrantLock2(false);    //非公平锁
    private final static Lock lock = new ReentrantLock2(true);   //公平锁

    public static void main(String[] args) throws BrokenBarrierException, InterruptedException {
//        String lockType = "非公平锁";
//        String lockType = "公平锁";
        String lockType = "synchronized";
        long start = System.currentTimeMillis();
        //该构造方法的最后一个线程达到栏栅时,会执行Time线程
        CyclicBarrier cyclicBarrier = new CyclicBarrier(128, new Time(lockType, start));
        for (int i = 0; i < 128; i++) {
            Thread thread = new Thread(new Job(lock, cyclicBarrier)) {
            };
            thread.start();
        }
    }

    private static class Job implements Runnable {
        private Lock lock;
        private CyclicBarrier cyclicBarrier;

        public Job(Lock lock, CyclicBarrier cyclicBarrier) {
            this.lock = lock;
            this.cyclicBarrier = cyclicBarrier;
        }

        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
//                lock.lock();
//                try {
//                    count++;
//                } finally {
//                    lock.unlock();
//                }
                synchronized (lock) {
                    count++;
                }
            }
            try {
                cyclicBarrier.await();  //计数器+1,直到10个线程都到达
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
        }
    }

    private static class Time implements Runnable {
        private long start;
        private String lockType;

        public Time(String lockType, long start) {
            this.start = start;
            this.lockType = lockType;
        }

        @Override
        public void run() {
            log.info("count:{},{}耗时:{}毫秒", count, lockType, (System.currentTimeMillis() - start));

        }
    }
}

我们利用上述代码分别测试了ReentrantLock的公平与非公平的和synchronized之间的性能,我的电脑是Mac Pro Intel Core i5 8GB,测试场景分别用8、16、32、64、128个线程,每个线程获取并释放1000次锁,对一个int的复合操作,运行结果绘制下图如2-21所示。

图 2-21 Java锁之间的性能对比

根据上图数据对比可以发现ReentrankLock在公平性下,锁的效率会随着线程数的增多性能急剧降低,这是由于公平性保证了线程的先入先出,即保证了线程等待时间的公平性,公平性由于在挂起线程和恢复线程时存在开销而极大降低性能,可是并非由于性能低就无用武之地,在一些顺序性消费的场景还是可以考虑使用他的。我们再来将ReentrankLock非公平性与synchronized之间比较,在作者测试阶段也是经过测试,发现ReentrankLock非公平性稳定性略低于synchronized,不难看出synchronized是根据线程数增多平滑上升的,而且当前测试环境是JDK11,可见synchronized性能优化后已经略胜于ReentrankLock的非公平性了,当然,这些结论仅是本实验案例中,作者并未多维度测试,例如在IO密集型和CPU密集型等情况下。所以综上所述,得出以下结论,首先在性能方面我们不必纠结ReentrankLock非公平性和synchronized之间选择,而且synchronized的性能略高,在使用的便利性来讲,synchronized的使用比较简单,加锁和解锁都是隐式的,虚拟机会帮你很好的控制,而ReentrankLock的使用是显式的,而且加锁在try块外,解锁必须在finally块内,如果没有这样做,将会在项目中埋下巨大的炸弹,对于不熟悉他的人来说尽量避免使用,但其拥有功能性强,所以根据自己业务判断是否需要而选择使用即可。当然笔者在使用时本着一个原则,这也是适应大多数场景的,就是如果当前只要一个简单同步手段的话优先选择synchronized。

2.7.4 读写锁

上小结我们刚刚认识了ReentrankLock,他是一个标准的互斥锁,他所同步代码块每次只有一个线程可以执行,避免了“写/写”和“写/读”的冲突,但也将“读/读”操作互斥了。其实在大多情况下“读操作”较多,虽然过程中可能会被改变。如果一个锁可以满足对“写操作”同步,对“读操作”放宽要求,允许同时多个线程访问的话,那么可以大大提升并发的性能。这就是读写锁,即ReentrantReadWriteLock,是ReadWriteLock的实例。

读写锁有3个特点。

公平性可选:和ReentrankLock一样,通过构造方法选择其公平性。

重入性:这个锁允许读线程和写线程以ReentrantLock的语法重新获取读写锁,读线程获取读后,能够再次获取读锁。写线程获取写锁后能再次获取写锁,同时可以获取读锁。

锁降级:泛泛说,写锁变为读锁,下面我们来详细介绍。

锁降级是当一个线程获取到写锁,并且没有释放的时候,变为了读锁的过程称为锁降级。

下面用伪代码表示锁降级的大概写法:writeLock.lock(); readLock.lock(); writeLock.unlock(); readLock.unlock();如果代码是这样的顺序的话,当前线程获取到了写锁,然后又获取到了读锁,如果没有锁降级的优化,上面的场景是当前线程获取写锁,然后获取读锁的时候就会在等待写锁释放,出现了死锁的状态。锁降级还有一个目的就是同一个线程从写锁变为读锁,说白了是一种重入机制,通过这种重入,可以减少一步流程——释放写锁后再次获取读锁。

使用读写锁也是非常简单的,由于我们对ReentrankLock了如指掌,在学习ReentrantReadWriteLock也会很轻松,无论是接口命名还是实现原理,都与ReentrankLock有很多相似之处,在使用上最大对区别在于ReentrantReadWriteLock定义了读锁和写锁两个方法,也就是我们使用他就要操作两个锁的加锁与解锁,可以对照下面代码2-32来简单了解其使用方式,读者也可以去ReentrantReadWriteLock源码中查看其注释,也是有案例的。

代码清单2-32 ReadWriteLockCase.java

代码语言:javascript
复制
public class ReadWriteLockCase {
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock r = rwl.readLock();
    static Lock w = rwl.writeLock();

    public static final Object get(String key) {
        r.lock();
        try {
            return null;

        } finally {
            r.unlock();
        }
    }

    public static final Object clean() {
        w.lock();
        try {
            return null;

        } finally {
            w.unlock();
        }
    }
}

读写锁一般使用在多读少写的并发环境下,一般对一个数据容器的读取与更新,例如我们可以在并发场景下从数据库中读取数据,供其他系统读取、更新等。我们可以发现,在实际开发中ReentrantReadWriteLock应用场景并不多,他在有读写并发的场景下确实比ReentrantLock吞吐量要高,但由于使用繁琐很多人放弃了他,使用不当这无疑在给自己写Bug,还有一个重要原因就是他多数情况在并发场景维护一个数据容器,其实如今Java发展到现在涌现了许多优秀的自带并发安全的数据容器,例如ConcurrentHashMap等,并且这些并发容器等吞吐率不逊于ReentrantReadWriteLock,并且使用也很简单,导致读写锁变成一个冷门的工具。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-12-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 晏霖 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档