2

显示锁

有了synchronized关键字,为什么还需要提供显示的Lock

  • Java程序是靠synchronized关键字实现锁功能的,使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放,而不需要我们手动的进行获取和释放,使用起来也更加方便,也不用担心由于某种异常场景没有释放锁
  • Lock接口和synchronized的比较,没有特殊需求,比如trylock,获取锁超时退出,还是使用synchronized
  • 在JDK1.8之前的版本中synchronized没有优化,是直接获取操作系统层面的锁mutex,太重、效率不高,因此有了一套显示的Lock。JDK1.8之后synchronized做了大量的优化(锁升级),所以性能方面已经不比显示锁差了,所以没有特殊需求,推荐使用synchronized

Lock接口和核心方法

方法名称 描述
lock() 阻塞获取锁
unlock() 释放锁
tryLock() 尝试非阻塞获取锁,返回boolean,可以设置超时时间
lockInterruptibly() 可中断的获取锁,和lock不同之处在于会响应中断,即在锁的获取中可以中断当前线程

Lock的标准用法

lock.lock();
try{
    //TODO
}finally{
    lock.unlock();
}

注意:

finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放。不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放

ReentrantLock(可重入锁)

锁的可重入

简单地讲就是:“同一个线程对于已经获得到的锁,可以多次继续申请到该锁的使用权”。而synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。ReentrantLock在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞

公平和非公平锁

如果在时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。 ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。事实上,公平的锁机制往往没有非公平的效率高

在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程A持有一个锁,并且线程B请求这个锁。由于这个锁已被线程A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此会再次尝试获取锁。与此同时,如果C也请求这个锁,那么C很可能会在B被完全唤醒之前获得(B的唤醒设计线程上下文切换需要时间)、使用以及释放这个锁。这样的情况是一种“双赢”的局面:B获得锁的时刻并没有推迟,C更早地获得了锁,并且吞吐量也获得了提高

示例代码:

/**
 * reentrantlock用于替代synchronized
 * 由于m1锁定this,只有m1执行完毕的时候,m2才能执行
 * 这里是复习synchronized最原始的语义
 * 
 * 使用reentrantlock可以完成同样的功能
 * 需要注意的是,必须要必须要必须要手动释放锁(重要的事情说三遍)
 * 使用syn锁定的话如果遇到异常,jvm会自动释放锁,但是lock必须手动释放锁,因此经常在finally中进行锁的释放
 * 
 * 使用reentrantlock可以进行“尝试锁定”tryLock,这样无法锁定,或者在指定时间内无法锁定,线程可以决定是否继续等待
 * 
 * 使用ReentrantLock还可以调用lockInterruptibly方法,可以对线程interrupt方法做出响应,
 * 在一个线程等待锁的过程中,可以被打断
 * 
 * ReentrantLock还可以指定为公平锁
 * 
 */

import java.util.concurrent.locks.ReentrantLock;

public class T05_ReentrantLock5 extends Thread {
        
    private static ReentrantLock lock=new ReentrantLock(true); //参数为true表示为公平锁,请对比输出结果
    public void run() {
        for(int i=0; i<5; i++) {
            lock.lock();
            try{
                System.out.println(Thread.currentThread().getName()+"获得锁");
            }finally{
                lock.unlock();
            }
        }
    }
    public static void main(String[] args) {
        T05_ReentrantLock5 rl=new T05_ReentrantLock5();
        Thread th1=new Thread(rl);
        Thread th2=new Thread(rl);
        th1.start();
        th2.start();
    }
}

运行结果:

Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁

读写锁ReentrantReadWriteLock(读多写少场景)

之前提到锁(如MutexReentrantLock)基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升

除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见

在没有读写锁支持的(Java 5之前)时候,如果需要完成上述工作就要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了

一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量

ReentrantReadWriteLock其实实现的是ReadWriteLock接口
示例代码:

import java.util.Random;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class T10_TestReadWriteLock {
    static Lock lock = new ReentrantLock();
    private static int value;

    static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    static Lock readLock = readWriteLock.readLock();
    static Lock writeLock = readWriteLock.writeLock();

    public static void read(Lock lock) {
        try {
            lock.lock();
            Thread.sleep(1000);
            System.out.println("read over!");
            //模拟读取操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void write(Lock lock, int v) {
        try {
            lock.lock();
            Thread.sleep(1000);
            value = v;
            System.out.println("write over!");
            //模拟写操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        //Runnable readR = ()-> read(lock);
        Runnable readR = ()-> read(readLock);

        //Runnable writeR = ()->write(lock, new Random().nextInt());
        Runnable writeR = ()->write(writeLock, new Random().nextInt());

        for(int i=0; i<10; i++) new Thread(readR).start();
        for(int i=0; i<2; i++) new Thread(writeR).start();


    }
}

运行结果:

read over!
read over!
read over!
read over!
read over!
read over!
read over!
read over!
read over!
read over!
write over!
write over!

Condition接口

任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式await()、signal()、signalAll()
示例代码:

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

public class T01_00_Question {
    private char[] a1 = {'1','2','3','4','5'};
    private char[] a2 = {'A','B','C','D','E'};
    private ReentrantLock lock = new ReentrantLock();
    private Condition c1 = lock.newCondition();
    private Condition c2 = lock.newCondition();

    public void m1(){
        int count = 0;
        lock.lock();
        while(count <= 4){

            System.out.print(a2[count++]);

            try {
                c2.signal();
                c1.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        c2.signal();
        lock.unlock();

    }

    public void m2(){
        int count = 0;
        lock.lock();
        while(count <= 4){

            System.out.print(a1[count++]);
            try {
                c1.signal();
                c2.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        c1.signal();
        lock.unlock();
    }
    public static void main(String[] args) {
        //要求用线程顺序打印A1B2C3....Z26
        T01_00_Question t = new T01_00_Question();
        new Thread(()->{
            t.m1();
        }).start();
        new Thread(()->{
            t.m2();
        }).start();
    }
}

运行结果:

A1B2C3D4E5

LockSupport

LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具

LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程

示例代码:

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

public class T13_TestLockSupport {
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            for (int i = 0; i < 10; i++) {
                System.out.println(i);
                if(i == 5) {
                    LockSupport.park();
                }
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t.start();
        //如果提前执行unpark会发现,也可以,线程运行不产生阻塞
        //LockSupport.unpark(t);

        try {
            TimeUnit.SECONDS.sleep(8);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("after 8 senconds!");

        LockSupport.unpark(t);

    }
}

运行结果:

0
1
2
3
4
5
after 8 senconds!
6
7
8
9

注意:unpark可以先于park调用

LockSupport增加了3个方法,用于实现阻塞当前线程的功能,其中参数blocker是用来标识当前线程在等待的对象(以下称为阻塞对象),该对象主要用于问题排查和系统监控

  • park(Object blocker)
  • parkNanos(Object blocker,long nanos)
  • parkUntil(Object blocker,long deadline)

CLH队列锁

CLH队列锁即Craig, Landin, and Hagersten (CLH) locks

CLH队列锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋

当一个线程需要获取锁时:

  • 创建一个的QNode,将其中的locked设置为true表示需要获取锁,myPred表示对其前驱结点的引用

image.png

  • 线程A对tail域调用getAndSet方法,使自己成为队列的尾部,同时获取一个指向其前驱结点的引用myPred

image.png

  • 线程B需要获得锁,同样的流程再来一遍

image.png

  • 线程就在前驱结点的locked字段上旋转,直到前驱结点释放锁(前驱节点的锁值 locked == false)
  • 当一个线程需要释放锁时,将当前结点的locked域设置为false,同时回收前驱结点

image.png

如上图所示,前驱结点释放锁,线程A的myPred所指向的前驱结点的locked字段变为false,线程A就可以获取到锁。

CLH队列锁的优点是空间复杂度低(如果有n个线程,L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+n),n个线程有n个myNode,L个锁有L个tail)。CLH队列锁常用在SMP体系结构下

Java中的AQS是CLH队列锁的一种变体实现,其中使用的是双向链表

AQS(AbstractQueuedSynchronizer抽象队列同步器)

AQS中的设计模式

AQS中是用模版方法模式,AQS的使用只要是继承,实现其中的模版方法

AQS中的模版方法

实现自定义同步组件时,将会调用同步器(AQS)提供的模版方法
image.png
这些模板方法同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态共享式获取与释放同步状态和查询同步队列中的等待线程情况

可重写方法

image.png
image.png

访问或修改同步状态的方法

重写AQS指定的方法时,需要使用AQS提供的如下3个方法来访问或修改同步状态

  • getState():获取当前同步状态
  • setState(int newState):设置当前同步状态
  • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性

AQS中的数据结构-节点和同步队列以及STATE

AQS是CLH队列锁的一种变体实现

线程的2种等待模式:

  • SHARED:表示线程以共享的模式等待锁(如ReadLock)
  • EXCLUSIVE:表示线程以互斥的模式等待锁(如ReetrantLock),互斥就是一把锁只能由一个线程持有,不能同时存在多个线程使用同一个锁

image.png

AQS同步器中维护了一个volatile state,和一个由Node双向链表节点组成的等待队列,state的更新支持可重入,只要是重入就+1
ReadWriteLock的实现把state按高16位和低16位进行了拆分,高16位表示读,低16位表示写,这个state状态用来表示有几个读写进程抢占到锁

那么问题来了,读state是获取读锁的线程数,读锁的重入如何解决?
答案是保存在ThreadLocal中,有线程自身维护

节点加入到同步队列

当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,也就是获取同步状态失败,AQS会将这个线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列的尾部。而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联
image.png

如果是非公平锁,线程先去抢占更新state,如果更新失败则进入同步队列,如果是公平锁,那么线程直接进入同步队列
首节点变化

首节点是获取同步状态成功的节点,

  • 首节点的线程在释放同步状态时,将会唤醒后继节点
  • 后继节点将会在获取同步状态成功时将自己设置为首节点。设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可

image.png

独占式同步状态获取与释放

image.png

共享式同步状态获取与释放

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以读写为例,如果一个程序在进行读操作,那么这一时刻写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问

acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)方法返回值大于等于0。可以看到,在doAcquireShared(int arg)方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功并从自旋过程中退出

该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。

Condition分析

一个Condition包含一个等待队列

一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的

image.png

同步队列与等待队列

Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列

调用Conditionawait()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。

如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException
image.png

await方法

如图所示,同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中
image.png

signal方法
  • 调用Conditionsignal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中
  • 调用该方法的前置条件是当前线程必须获取了锁,可以看到signal()方法进行了isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程。接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程
  • 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程
  • 被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法返回true,节点已经在同步队列中,进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中
  • 成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此时该线程已经成功地获取了锁。
  • ConditionsignalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程

image.png


DragonflyDavid
182 声望19 粉丝

尽心,知命


引用和评论

0 条评论