Java并发编程艺术学习笔记(四)

Java并发编程艺术学习笔记(四)

Java中的锁

主要介绍了Java并发包中关于锁相关的API和组件。

一.Lock接口

在Lock接口出现之前,Java程序是通过synchronized的关键词来实现锁功能的。在JDK5之后并发包中新加入了Lock接口(以及实现类)来实现锁的功能,提供了与synchronized类似的功能,但是可以显式地去获取和释放锁。
synchronized关键词会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放,这显然没有显式的锁获取和释放来的效率高。
Lock接口提供的synchronized关键词所不具备的主要特性:
(1)尝试非阻塞地获取锁:当前的线程尝试获取锁,如果这一时刻锁没有被其他线程获得,就成功获取并且持有锁。
(2)能被中断得获取锁:获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常会被抛出,同时锁会被释放。
(3)超时获取锁:在指定的截止日期前获取锁,如果截止时间到了仍然无法获取锁,就返回。
Lock接口的API如下:
(1)void lock():获取锁
(2)void lockInterruptibly() throws InterruptedException:可中断地获得锁 ,即在锁的获取中可以中断当前的线程。
(3)boolean tryLock():尝试非阻塞的获取锁,获得就返回true,没有获得就返回false。
(4)boolean tryLock(long time,TimeUnit unit) throws InterruptedException:当前线程在以下三种情况会返回:1️⃣当前线程在超时时间内获取了锁。2️⃣当前线程在超时时间内被中断。3️⃣超时时间结束,返回false。
(5)void unlock():释放锁
(6)Condition newCondition():获取等待通知组件,该组件与当前的锁绑定,当前线程只有获得了锁,才能调用改组件的wait()方法,调用后,当前线程将释放锁。
Lock的实现类ReentrantLock,Lock接口的实现基本都是通过聚合了一个同步器的子类来完成线程访问控制的。

二.队列同步器

队列同步器AbstractQueuedSynchronizer,是用来构建锁或者其他同步组件的基础框架,使用了int成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
同步器的主要使用方法是继承,子类通过实现它的抽象方法来管理同步状态,要求对同步状态进行修改,就需要同步器提供的3个方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))。
同步器支持独占地获取同步状态,也可以共享式地获取同步状态。
同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以理解为:锁是面向使用者的,定义了使用者和锁交互的接口,隐藏了实现的细节;而同步器面向的是锁的实现者,简化了锁的实现方式。

Ⅰ.队列同步器的接口和示例

同步器的设计是按照模版方式模式的,模版方式模式可以理解为同步器中的有些方法已经将Lock接口中的方法复写完毕,而如果用户想要重写同步器只需要重写其中的几个方法即可,降低了出错率以及用户的使用成本。
重写同步器指定的方法,需要使用同步器提供的如下3个方法来访问:
(1)getState();(2)setState(int newState)(3)compareAndSetState(int expect,int update)
同步器可以重写的方法有5种:
1️⃣tryAcquire2️⃣tryRelease3️⃣tryAcquireShared4️⃣tryReleaseShared5️⃣isHeldExclusively
而同步器提供了9中方法的模版:
(1)acquire(2)acquireInterruptibly(3)tryAcquireNanos(4)acquireShared
(5)acquireSharedInterruptibly(6)tryAcquireSharedNanos(7)release(8)releaseShared
(9)getQueuedThreads
如果想要实现锁只需要调用同步器中的方法即可,同步器中只需要复写5种方法(假设都用到,其实不会全用到,例如独占锁只会用到1,2,5),而其余模版方法可以直接调用。

Ⅱ.队列同步器的实现分析

接下来会分析同步器具体是如何完成线程同步的,分析对应的源码,包括了同步队列、独占式同步状态获取与释放、共享式状态的获取与释放以及超时获取同步状态等同步器的核心数据结构与模版方法。
1.同步列表
同步器依赖的是内部的同步列表(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成一个节点并且加入同步队列中,同时阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒使其再次尝试获取同步状态。
同步队列中的节点Node用来保存获取同步状态失败的线程引用、等待状态以及前驱节点和后驱节点。
节点的属性有:(1)等待状态(2)前驱节点(3)后继节点(4)等待队列中的后继节点(5)获得同步状态的线程
同步器具有首节点和尾节点,其中没有获取同步状态的线程将会变成尾节点加入该队列的尾部。
在这里插入图片描述
同步队列的形式如上图所示。需要注意的是加入队列的过程必须需要保证线程安全,因此需要CAS来设置尾节点。首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点会在获取同步状态成功时将自己设置成首节点,因为只有一个线程会获得同步状态,所以并不需要CAS来保证头节点的设置。
2.独占式同步状态获取与释放
同步器的acquire可以获取同步状态,这是尝试阻塞地获取锁并且在阻塞过程中不能中断。源码如下:

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

调用acquire方法总共完成了如下几件事:首先调用tryAcquire(非阻塞地获得锁,能立刻返回当前是否能获得锁),如果不能获得锁,就构造同步节点Node.EXCLUSIVE,并且将这个节点通过addWaiter方法加入到同步队列的末尾,最后调用acquireQueued方法,会让该节点先自旋的获得同步状态,如果没有获取到就阻塞线程,再次唤醒线程需要前驱节点的出队或者阻塞线程被中断来实现。
接下来贴一下节点的构造以及加入同步队列方法eaddWaiter方法和enq的源码:

    private Node addWaiter(Node mode) {
    	//构造新节点并且设置为尾节点
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        //CAS设置
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
            Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }

以及enq源码

//可以看到addWaiter源码其实是enq源码的一部分,可以理解为如果addwaiter方法可以成功就不需要再调用enq代码,这样可以提高性能。
    private Node enq(final Node node) {
    //for(;;)意思跟while(1)相同
        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;
                }
            }
        }
    }

上述代码中使用了compareAndSetTail来确保节点能够被线程安全添加。在enq方法中,同步器通过“死循环”来保证了节点的正确添加,只有通过不断CAS设置尾节点成功后才能返回。
节点进入同步队列后就进入了一个自旋的过程,每个节点都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则将一直留在这个自旋过程中(会阻塞节点的线程)。
acquireQueued的源码:

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            //不断自旋获得
            for (;;) {
            //将p设为新节点的前驱节点
                final Node p = node.predecessor();
                //如果前驱节点是头结点并且获得同步状态成功
                if (p == head && tryAcquire(arg)) {
                //设置新的节点为头节点
                    setHead(node);
                    //将原来的头结点的后继节点设为null
                    p.next = null; // help GC
                    //修改变量,意思是成功获取到了同步状态
                    failed = false;
                    将是否被中断变量返回
                    return interrupted;
                }
                //线程进入等待状态
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
        //最后如果失败了就取消获取同步状态
            if (failed)
                cancelAcquire(node);
        }
    }

只有前驱节点是头节点才能尝试获取同步状态的原因:
(1)头节点是成功获得 同步状态的节点,而头结点的线程释放了同步状态以后,将唤醒后继节点,后继节点被唤醒需要检查自己的前驱节点是否是头节点。
(2)维护同步队列的FIFO原则。
综上独占式同步状态获取流程可以如下图所示:
在这里插入图片描述
通过调用release(int arg)可以释放同步状态,该方法释放了同步状态之后,会唤醒其后继节点,源码如下:

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
        //将h设为头节点
            Node h = head;
            判断头节点不是空节点并且头节点的等待状态不是初始状态
            if (h != null && h.waitStatus != 0)
            //唤醒后继节点
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

综上所述,做个小总结:获取同步状态时,同步器会维护一个同步队列,获取状态失败的线程都会被加入队列中并且在队列中自旋,移出队列唯一的方法是前驱节点是头节点并且已经成功获取到了同步状态;释放同步状态的时候,会唤醒头节点的后继节点。
3.共享式同步状态获取与释放
共享式获取与独占式最大的区别是同一时刻能否有多个线程同时获取到同步状态。例如文件的读写,如果一个程序对文件进行读操作,那么同一时刻对于该文件的写操作都被阻塞了,而读操作可以同时进行;写操作则需要对于资源的独占式访问,读操作可以是共享式访问。
acquireShared(int arg)方法可以贡献式地获取同步状态,该方法的源码如下:

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }
    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

源码中tryAcquireShared方法返回的值是int类型,如果大于等于0表示能够获得同步状态,在doAcquireShared方法的自旋过程中,如果当前节点的前驱为头节点,尝试获得同步状态,如果返回值大于等于0说明已经获得同步状态就从自旋状态中退出。
同样的releaseShared的源码如下:

    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

需要注意的是必要确保同步状态线程能被安全释放,一般可以通过循环和CAS来保证。
4.独占式超时获取同步状态
同步器中的doAcquireNanos(int arg,long nanosTimeout)方法可以超时获取同步状态。即如果在规定的时间内获得了锁就返回true,如果没有就返回false。
响应中断的同步状态获取过程:Java5之前,当一个线程获取不到锁而被阻塞在synchronized之外时,对这个线程进行中断操作,这个线程的中断标志位会发生改变,但是线程依旧阻塞在synchronized上,等待获取锁。JDK5之后,同步器提供了acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并且抛出InterruptedException。
而超时获取可以看做是响应中断获取同步状态的增强版。doAcquireNanos方法的源码如下:

    private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (nanosTimeout <= 0L)
            return false;
        final long deadline = System.nanoTime() + nanosTimeout;
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return true;
                }
                nanosTimeout = deadline - System.nanoTime();
                if (nanosTimeout <= 0L)
                    return false;
                if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                    LockSupport.parkNanos(this, nanosTimeout);
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

唯一不同是同步状态获取失败时判断是否超时,如果没有超时重新计算超时间隔没如果超时了就返回。

三.重入锁

重入锁ReentrantLock就是支持重进入的锁,能够支持一个线程对于资源的反复加锁,处理之外还可以选择获取锁的模式是公平的或者不公平的。
synchronized是隐式地支持重进入的,而ReentrantLock的lock方法在已经获取到锁的线程,能够再次调用lock方法获取锁而不被阻塞。
锁获取的公平性指的是在绝对时间上,先进行锁获取请求的线程先满足就是公平锁,相反是不公平的。很显然公平锁会影响锁的性能,但是公平锁的使用可以很好的减少“饥饿”情况的产生。
1.实现重进入
(1)线程再次获得锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是就再次获得锁。
(2)锁的最终释放。线程重复n次获得锁,随后再n次释放该锁后,其他线程能够获取到该锁。
下面贴出了不公平锁的获取源码:

        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;
        }

当状态为0时说明当时并没有线程获得锁,只需要将当前线程获得锁即可,如果状态大于0说明有线程获得锁,这时候判断占据锁的线程是不是当前线程,如果是就将状态值加1。
同样的释放锁的源码:

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

如果锁被获取了n次,那么前面n-1次tryRelease方法必须返回false,只有最后一次释放了确定没有线程占据锁,才返回true。
2.公平与非公平获取锁的区别
如果一个锁是公平的,那么锁的获取顺序应该符合请求的绝对顺序,也就是FIFO顺序。
非公平锁只需要CAS设置同步状态成功,就表示当前线程获取了锁,而公平锁不同,公平锁的源码如下:

        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;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

与非公平获取锁的唯一不同就是加入了hasQueuedPredecessors方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果有就需要等待前面的节点先获取锁再进行后续的操作。

四.读写锁

读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁可以使得并发性比一般排他锁提高很多。
读写锁也是JDK5之后加入的,java并发包提供读写锁的实现是ReentrantReadWriteLock,ReentrantReadWriteLock的特性:
(1)公平性选择,支持用户自己选择锁的公平性。
(2)重进入,该锁支持重进入。
(3)锁降级,遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。

Ⅰ.读写锁的接口与示例

ReadWriteLock定义了获取读锁和写锁的两个方法,即readLock()和writeLock()方法,还提供了一些便于外界监控其内部工作状态的方法,这些方法如下所示:
(1)int getReadLockCount():返回当前读锁被获取的次数,而不是获取读锁的线程数。
(2)int getReadHoldCount():返回当前线程获取读锁的次数。
(3)boolean isWriteLocked():判断写锁是否被获取。
(4)int getWriteHoldCount():返回当前写锁被获取的次数。

Ⅱ.读写锁的实现分析

1.读写状态的设计
读写锁同样依赖了自定义同步锁来实现同步功能,而读写状态就是同步器的同步状态。
如果在一个整型变量上维护多种状态就一定需要“按位切割使用”这个变量,读写锁也被切割成了两个部分,高16位表示读,低16位表示写,划分方式如下图所示:
在这里插入图片描述
上述的状态意思是一个线程已经获得了写锁并且已经重进入了两次,同时也连续获取了两次读锁。可以得到一个推论,当写状态等于0时,读状态大于0,说明读锁已经被获取。
2.写锁的获取与释放
写锁是一个支持重进入的排他锁。如果当前线程已经获取了写锁,则增加写状态;如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获取写锁的线程,则当前线程进入等待状态,获取写锁的代码如下:

        protected final boolean tryAcquire(int acquires) {
            Thread current = Thread.currentThread();
            int c = getState();
            int w = exclusiveCount(c);
            if (c != 0) {
                // (Note: if c != 0 and w == 0 then shared count != 0)
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                // Reentrant acquire
                setState(c + acquires);
                return true;
            }
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            setExclusiveOwnerThread(current);
            return true;
        }

如果存在读锁,那么写锁将不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他线程就无法感知到当前线程的操作,因此只有等待其他线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,其他读写锁的获取都将被阻塞。
3.读锁的获取与释放
读锁是一个支持重进入的共享锁,它是一个能被多个线程同时获取,在没有其他写线程访问的状态时,读锁总能被成功的获取,而所做的也只是增加读状态。获取读锁的实现从Java5到Java6变得复杂了很多,主要原因是新增了一些功能,例如getReadHoldCount()方法,读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自己维护,因此读锁的实现显得更加复杂了。
4.锁降级
锁降级指的是写锁降级为读锁。如果当前线程拥有写锁,然后将其释放,最后再获得读锁,这种分段完成的过程并不能成为锁降级。锁降级指的是把持住(当前拥有的)写锁,在获取到读锁,随后释放先前拥有的写锁的过程。
锁降级中读锁的获取是否是必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻有另一个线程获取了写锁并且修改了数据,那么当前线程将无法感知线程T的数据更新。
**1.锁降级的目的是为了如果某个线程需要长时间的更新一个对象,那么长时间占用写锁必然会造成其他线程的性能损耗,所以就可以采用将写锁降为读锁,这时候其他线程也可以并发读取并且防止了其他线程获取写锁,同时当其他线程读取完成后可以立刻获得写锁再次更新。
2.第二个目的可以理解为数据的可见性,这里的可见性指的是线程T希望读到自己修改后的样子,而不是读到自己修改后再被其他线程修改后的样子,所以需要在写锁还没有释放之前就获得读锁。

五.LockSupport工具

当需要阻塞或者唤醒一个线程的时候,都会使用LockSupport工具类来完成相应的工作。LockSupport定义了一组公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能。这些方法的描述如下:
(1)void park():阻塞当前的线程,如果调用unpark方法或者当前线程被中断,才能从park中返回。
(2)void parkNanos(long nanos):阻塞当前的线程,最长不超过nanos纳秒,返回条件在park基础上增加了超时返回。
(3)void parkUntil(long deadline):阻塞当前的线程,直到deadline。
(4)void unpark(Thread thread):唤醒处在阻塞状态的线程thread。

六.Condition接口

任意的一个Java对象都拥有一组监视器方法,主要包括了wait(),notify()等,这些与synchronized同步关键词配合可以实现等待/通知模式,Condition接口也提供了类似的监控器方法,可以与Lock配合实现等待/通知模式,两者在使用方式以及功能特性上还是有所差异的。具体如下图所示:
在这里插入图片描述

Ⅰ.Condition接口与示例

Condition定义了等待/通知两种类型的方式,当前线程调用这些方法的时候需要首先获取到Condition对象关联的锁,Condition对象是依赖Lock对象创建出来的。
调用await()方法使线程进入等待状态,signal()方法相当于是notify()。

Ⅱ.Condition的实现分析

ConditionObject是同步器AbstractQueuedSynchronizer的内部类,是Condition接口的实现类。每个Condition对象都包含着一个队列(等待队列),该队列是实现Condition对象实现等待/通知的关键。
1.等待队列
等待队列是一个FIFO队列,在队列的每个节点有一个线程引用,如果一个线程调用了Condition.await()方法,那么该线程就会释放锁,构造成节点并且加入等待队列。事实上Condition的节点使用的是AbstractQueuedSynchronizer的Node。一个Condition包含一个等待队列,Condition拥有首节点和尾节点,新增的尾节点只需要保证原来尾节点的nextWaiter指向它即可,上述节点的更新并不用用CAS来保证,原因是调用await方法的线程必定是获取锁的线程。
需要注意的是跟Object的监视器模型相比,Object中一个对象只有一个等待队列,而Lock拥有很多个队列。
2.等待
从队列的角度来看await方法,相当于就是将同步队列的首节点移动到Condition等待队列中。
Condition的await的源码如下所示:

        public final void await() throws InterruptedException {
        //如果线程被中断,抛出异常
            if (Thread.interrupted())
                throw new InterruptedException();
                //将当前线程加入等待队列
            Node node = addConditionWaiter();
            //释放锁
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

3.通知
调用了Condition的signal方法,将会唤醒等待队列中的首节点,调用这个方法的前提是当前线程已经获得锁,等待节点的头节点线程安全地移动到了同步队列。signalAll()方法相当于就是对等待队列都使用了一次Signal()方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值