跳至主要內容

③Java 锁机制(Java中的锁)

holic-x...大约 53 分钟JAVA基础

③Java 锁机制(Java中的锁)

学习核心

  • Lock接口
  • AQS队列同步器
  • ReentrantReadWritrLock读写锁
  • ReentrantLock可重入锁
  • LockSupport
  • Condition

学习资料

锁(常见题型)

  • Lock
    • Lock接口 VS synchronized同步 优缺点
    • Lock和AQS的关系如何理解?
  • AQS
    • 什么是AQS?
    • AQS是如何实现同步管理的?底层数据结构
    • AQS的核心方法
  • ReentrantLock
    • 什么是可重入可重入锁?
    • 公平锁和非公平锁有什么去呗?
    • 为什么非公平锁比公平锁性能更好?
    • ReentrantLock是如何实现公平锁和非公平锁的?(原理分析、源码拆解)
    • ReentrantLock VS Synchronized
  • ReentrantReadWriteLock
    • 什么是ReentrantReadWriteLock?
    • 共享锁和独占锁的区别
    • 线程持有读锁还能获取写锁码?
    • 什么是锁的升降级?ReentrantReadWriteLock为什么不支持锁升级?
    • ReentrantReadWriteLock底层如何设计

常见锁分类

​ Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率,结合使用场景进行举例,介绍主流锁的知识点,以及不同的锁的适用场景。

​ Java中往往是按照是否含有某一特性来定义锁,通过特性将锁进行分组归类,再使用对比的方式进行介绍

img

1.乐观锁 VS 悲观锁

​ 乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。

悲观锁:在获取数据的时候先加锁后操作,确保数据不会被其他线程修改

​ 对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁

乐观锁:只有在更新数据的时候判断之前有没有别的线程更新了这个数据,进而采取不同的操作

​ 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的

img

乐观锁和悲观锁应用场景

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升

常见的乐观锁和悲观锁

  • 悲观锁:synchronized、Lock
  • 乐观锁:AtomicInteger(最常见是基于CAS算法实现)
// 1.乐观锁 VS 悲观锁
class OPLock{

    // ---------- 悲观锁:synchronized、Lock -----------
    public synchronized void testSynchronized(){
        System.out.println("普通实例方法");
    }

    public void testLock(){
        Lock lock = new ReentrantLock();
        // 使用前加锁
        lock.lock();
        try{
            System.out.println("do sth");
        }finally {
            // 解锁
            lock.unlock();
        }
    }

    // ---------- 乐观锁:AtomicInteger ----------
    private AtomicInteger atomicInteger = new AtomicInteger();  // 需要保证多个线程使用的是同一个AtomicInteger
    public void testAtomic(){
        atomicInteger.incrementAndGet(); //执行自增1
    }
}

​ 可以进一步结合调用方式实例理解,悲观锁都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。

​ 进一步分析为何乐观锁可以做到不锁定同步资源也可以正确地实现线程同步?(此处以乐观锁的主要实现方式“CAS”的技术原理进一步解读)

CAS原理(乐观锁的实现)

​ CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 要写入的新值 B

当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。

AtomicInteger源码分析

​ java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁,可以进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
    
    ......
}
  • unsafe: 获取并操作内存的数据。
  • valueOffset: 存储value在AtomicInteger中的偏移量。
  • value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的

​ 查看自增函数incrementAndGet一步步跟踪源码实现

// AtomicInteger 自增方法
public final int incrementAndGet() {
  return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// Unsafe.class getAndAddInt
public final int getAndAddInt(Object o, long offset, int delta) {
  int v;
  do {
    v = getIntVolatile(o, offset);
  } while (!compareAndSwapInt(o, offset, v, v + delta));
  return v;
}

​ getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值

​ 后续JDK通过CPU的cmpxchg指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止

CAS存在的问题

​ CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:

【1】ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

  • JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值

【2】循环时间长开销大:CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

【3】只能保证一个共享变量的原子操作:对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

​ Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

2.自旋锁 VS 适应性自旋锁

自旋锁概念

​ 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

​ 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

​ 而为了让当前线程“稍等一下”,需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁

image-20240527173536808

​ 自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

​ 自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

// Unsafe.class getAndAddInt
public final int getAndAddInt(Object o, long offset, int delta) {
  int v;
  do {
    v = getIntVolatile(o, offset);
  } while (!compareAndSwapInt(o, offset, v, v + delta));
  return v;
}

​ 自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

​ 自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

​ 在自旋锁中 另有三种常见的锁形式:TicketLock、CLHlock和MCSlock

3.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

核心概念

​ 这四种锁指的是所得状态,是专门针对synchronized的。此处首先要理解synchronized为什么可以实现线程同步(了解核心概念:Java对象头、Monitor)

Java对象头

​ synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

​ 以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor

​ Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

​ Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

synchronized如实现线程同步?引出这4种锁状态

​ synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步

synchronized最初(JDK6之前)实现同步的方式是依赖于操作系统Mutex Lock实现的重量级锁;JDK6为了减少加锁、释放锁的性能消耗,引入了偏向锁和轻量级锁;构成了此处的4种锁状态

​ 如同在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

​ 所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级

锁状态思路拆解

四种锁状态对应的Mark Word的内容

锁状态存储内容存储内容
无锁对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

​ 无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。前面的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

​ 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价

​ 在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能

​ 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

​ 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

​ 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

​ 是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

​ 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

​ 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

​ 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

​ 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

​ 升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

整体的锁状态升级流程如下:

​ 整体锁状态升级流程:无锁=》偏向锁=》轻量级锁=》重量级锁

4.公平锁 VS 非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。

  • 优点:等待锁的线程不会饿死
  • 缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。

  • 优点:可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
  • 处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

​ 简单理解:公平锁是多个线程按顺序排队申请;非公平锁是多个线程直接尝试获取锁,可能出现后申请先获取到锁的情况

场景分析:打水场景

​ 假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁

image-20240527181859925

​ 对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。

image-20240527182020400

案例分析:结合Lock的ReentrantLock源码理解公平锁和非公平锁

​ ReentrantLock中有一个内部类Sync(继承AQS,AbstractQueuedSynchronizer),添加锁和释放锁的操作大部分都是在Sync中实现的,

​ Sync有两个子类(公平锁FairSync和非公平锁NonfairSync),ReentrantLock默认使用非公平锁,也可通过构造器指定锁类型

image-20240527182528270

​ 对比公平锁和非公平锁加锁方法的实现,可以看到这两者的唯一区别在于公平锁在获取同步状态的时候多了一个限制条件hasQueuedPredecessors()

public final boolean hasQueuedPredecessors() {
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

// 该方法核心:判断当前线程是否位于同步队列中的第一个,如果是返回true;否则返回false

​ 综上:

  • 公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性
  • 非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况

5.可重入锁 VS 非可重入锁

​ 可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLocksynchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:

public class WidgetDemo {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

​ 结合上述案例分析:doSomething和doOthers两个方法都是经过synchronized修饰的实例方法,锁的是当前对象。

​ 可重入:对于同一个线程,在调用doSomething的时候已经获取到当前这个对象锁,在方法中调用了doOthers方法,此时对象锁已经被当前线程占用且还没有释放。基于可重入的概念(参考synchronized的monitor计数器概念),此处对于同一个线程可以重复获取对象锁,因此可以进一步访问doOthers方法执行操作

​ 不可重入:假设此处场景是基于不可重入的预设,当同一个线程拿到对象锁访问doSomething方法,此时锁还没释放,在内部访问doOthers方法的时候就会出现矛盾:doOthers等待对象锁释放才能执行,而doSomething需要执行完doOthers才能释放对象锁,进而陷入死循环。

问题思考:为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?

场景分析:打水案例

​ 有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁

image-20240527184140289

​ 非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒

image-20240527184212774

​ ReentrantLock和synchronized都是重入锁,通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。

​ 首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

​ 当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。

​ 而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

​ 释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

image-20240527184418508

6.独享锁 VS 共享锁

​ 独享锁和共享锁同样是一种概念。此处通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。

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

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

​ 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享

image-20240527184626671

​ ReentrantReadWriteLock有两把锁:ReadLock(读锁)和WriteLock(写锁),ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。

​ 在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

​ 读锁和写锁的具体加锁方式有什么区别呢?

​ AQS概念中的state字段(int类型,32位),该字段用来描述有多少线程获持有锁

​ 在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。如下图所示:

image-20240527185147755

写锁源码

protected final boolean tryAcquire(int acquires) {
	Thread current = Thread.currentThread();
	int c = getState(); // 取到当前锁的个数
	int w = exclusiveCount(c); // 取写锁的个数w
	if (c != 0) { // 如果已经有线程持有了锁(c!=0)
    // (Note: if c != 0 and w == 0 then shared count != 0)
		if (w == 0 || current != getExclusiveOwnerThread()) // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败
			return false;
		if (w + exclusiveCount(acquires) > MAX_COUNT)    // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
      throw new Error("Maximum lock count exceeded");
		// Reentrant acquire
    setState(c + acquires);
    return true;
  }
  if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。
		return false;
	setExclusiveOwnerThread(current); // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者
	return true;
}
  • 这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w = exclusiveCount©; ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
  • 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
  • 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
  • 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
  • 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!

​ tryAcquire()除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。

​ 因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见

读锁源码

protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState();
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;                                   // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态
    int r = sharedCount(c);
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        compareAndSetState(c, c + SHARED_UNIT)) {
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

​ 可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥

7.分段锁(一种锁设计)

​ 分段锁其实是一种锁的设计,并不是具体的一种锁。所谓分段锁,就是把锁的对象分成多段,每段独立控制,使得锁粒度更细,减少阻塞开销,从而提高并发性。这其实很好理解,就像高速公路上的收费站,如果只有一个收费口,那所有的车只能排成一条队缴费;如果有多个收费口,就可以分流了。

Hashtable 使用 synchronized 修饰方法来保证线程安全性,那么面对线程的访问,Hashtable 就会锁住整个对象,所有的其它线程只能等待,这种阻塞方式的吞吐量显然很低。

​ Java 1.7 以前的 ConcurrentHashMap 就是分段锁的典型案例。ConcurrentHashMap 维护了一个 Segment 数组,一般称为分段桶。

final Segment<K,V>[] segments;

​ 当有线程访问 ConcurrentHashMap 的数据时,ConcurrentHashMap 会先根据 hashCode 计算出数据在哪个桶(即哪个 Segment),然后锁住这个 Segment

AQS(AbstractQueuedSynchronizer)详解

1.AQS原理

​ AQS 使用一个整型的 volatile 变量来 维护同步状态。状态的意义由子类赋予。

​ AQS 维护了一个 FIFO 的双链表,用来存储获取锁失败的线程。

​ AQS 围绕同步状态提供两种基本操作“获取”和“释放”,并提供一系列判断和处理方法,简单说几点:

  • state 是独占的,还是共享的;
  • state 被获取后,其他线程需要等待;
  • state 被释放后,唤醒等待线程;
  • 线程等不及时,如何退出等待。

至于线程是否可以获得 state,如何释放 state,就不是 AQS 关心的了,要由子类具体实现

2.AQS数据结构

​ AQS(AbstractQueuedSynchronizer)继承自AbstractOwnableSynchronizer

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {

    /** 等待队列的队头,懒加载。只能通过 setHead 方法修改。 */
    private transient volatile Node head;
    /** 等待队列的队尾,懒加载。只能通过 enq 方法添加新的等待节点。*/
    private transient volatile Node tail;
    /** 同步状态 */
    private volatile int state;
}
  • state:一个整形的volatile变量,用于维护同步状态

  • headtail:AQS维护了一个一个Node类型(AQS内部类)的双链表来完成同步状态的管理(双向的FIFO队列),通过head、tail指针进行访问。当有线程获取锁失败会后,就被添加到队尾

image-20240527191416166

​ 继续分析Node的源码:

static final class Node {
    /** 该等待同步的节点处于共享模式 */
    static final Node SHARED = new Node();
    /** 该等待同步的节点处于独占模式 */
    static final Node EXCLUSIVE = null;

    /** 线程等待状态,状态值有: 0、1、-1、-2、-3 */
    volatile int waitStatus;
    static final int CANCELLED =  1;
    static final int SIGNAL    = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;

    /** 前驱节点 */
    volatile Node prev;
    /** 后继节点 */
    volatile Node next;
    /** 等待锁的线程 */
    volatile Thread thread;

  	/** 和节点是否共享有关 */
    Node nextWaiter;
}
属性说明
waitStatusNode中使用一个整形的volatile变量来维护同步队列中线程节点的状态
CANCELLED1状态表示该节点的线程可能由于超时或被中断而 处于被取消(作废)状态,一旦处于这个状态,表示这个节点应该从等待队列中移除
SIGNAL-1状态表示后继节点会被挂起,因此在当前节点释放锁或被取消之后,必须唤醒(unparking)其后继结点
CONDITION-2状态表示该节点的线程 处于等待条件状态,不会被当作是同步队列上的节点,直到被唤醒(signal),设置其值为 0,再重新进入阻塞状态
PROPAGATE-3状态表示下一个 acquireShared 应无条件传播
0非以上状态

3.独占锁的获取和释放

获取独占锁

AQS 中使用 acquire(int arg) 方法获取独占锁,其大致流程如下:

  • 先尝试获取同步状态,如果获取同步状态成功,则结束方法,直接返回。

  • 如果获取同步状态不成功,AQS 会不断尝试利用 CAS 操作将当前线程插入等待同步队列的队尾,直到成功为止。

  • 接着,不断尝试为等待队列中的线程节点获取独占锁

image-20240527192209364

​ 详细流程分析:

img

释放独占锁

AQS 中使用 release(int arg) 方法释放独占锁,其大致流程如下:

  1. 先尝试获取解锁线程的同步状态,如果获取同步状态不成功,则结束方法,直接返回。
  2. 如果获取同步状态成功,AQS 会尝试唤醒当前线程节点的后继节点。

获取可中断的独占锁

AQS 中使用 acquireInterruptibly(int arg) 方法获取可中断的独占锁。

acquireInterruptibly(int arg) 实现方式相较于获取独占锁方法( acquire)非常相似,区别仅在于它会通过 Thread.interrupted 检测当前线程是否被中断,如果是,则立即抛出中断异常(InterruptedException)。

获取超时等待式的独占锁

AQS 中使用 tryAcquireNanos(int arg) 方法获取超时等待的独占锁。

doAcquireNanos 的实现方式 相较于获取独占锁方法( acquire)非常相似,区别在于它会根据超时时间和当前时间计算出截止时间。在获取锁的流程中,会不断判断是否超时,如果超时,直接返回 false;如果没超时,则用 LockSupport.parkNanos 来阻塞当前线程。

共享锁的获取和释放

获取共享锁

AQS 中使用 acquireShared(int arg) 方法获取共享锁。

acquireShared 方法和 acquire 方法的逻辑很相似,区别仅在于自旋的条件以及节点出队的操作有所不同。

成功获得共享锁的条件如下:

  • tryAcquireShared(arg) 返回值大于等于 0 (这意味着共享锁的 permit 还没有用完)。
  • 当前节点的前驱节点是头结点。
释放共享锁

AQS 中使用 releaseShared(int arg) 方法释放共享锁。

releaseShared 首先会尝试释放同步状态,如果成功,则解锁一个或多个后继线程节点。释放共享锁和释放独享锁流程大体相似,区别在于:

对于独享模式,如果需要 SIGNAL,释放仅相当于调用头节点的 unparkSuccessor

获取可中断的共享锁

AQS 中使用 acquireSharedInterruptibly(int arg) 方法获取可中断的共享锁。

acquireSharedInterruptibly 方法与 acquireInterruptibly 几乎一致,不再赘述。

获取超时等待式的共享锁

AQS 中使用 tryAcquireSharedNanos(int arg) 方法获取超时等待式的共享锁。

tryAcquireSharedNanos 方法与 tryAcquireNanos 几乎一致,不再赘述

4.死锁概念

什么是死锁

​ 死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也 可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。

如何定位死锁

​ 定位死锁最常见的方式就是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以在图形界面进行有限的死锁检测。

如果我们是开发自己的管理工具,需要用更加程序化的方式扫描服务进程、定位死锁,可以考虑使用 Java 提供的标准管理 API,ThreadMXBean,其直接就提供了 findDeadlockedThreads() 方法用于定位。

如何避免死锁

基本上死锁的发生是因为:

  • 互斥,类似 Java 中 Monitor 都是独占的
  • 长期保持互斥,在使用结束之前,不会释放,也不能被其他线程抢占
  • 循环依赖,多个个体之间出现了锁的循环依赖,彼此依赖上一环释放锁

由此,我们可以分析出避免死锁的思路和方法

(1)避免一个线程同时获取多个锁

(2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源

(3)尝试使用定时锁 lock.tryLock(timeout),避免锁一直不能释放

(4)对于数据库锁,加锁和解锁必须在一个数据库连接中里,否则会出现解锁失败的情况

Lock接口

为什么因为引入Lock、Condition

​ 并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题

​ synchronized 是管程的一种实现,既然如此,何必再提供 Lock 和 Condition。

​ JDK 1.6 以前,synchronized 还没有做优化,性能远低于 Lock。但是,性能不是引入 Lock 的最重要因素。真正关键在于:synchronized 使用不当,可能会出现死锁。

​ synchronized 无法通过破坏不可抢占条件来避免死锁。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

​ 与内置锁 synchronized 不同的是,Lock 提供了一组无条件的、可轮询的、定时的以及可中断的锁操作,所有获取锁、释放锁的操作都是显式的操作。

  • 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。
  • 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
  • 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件

1.Lock接口核心

Lock接口使用

​ 锁主要是能用来控制多个线程访问共享变量的方式,在Lock接口出现之前,Java主要是靠synchronized关键字来实现锁功能的,在Java SE 5之后,开始提供Lock接口来实现锁的功能。他有与synchronized关键字类似的同步功能,只是说有一些差别,Lock需要显示的进行加锁和解锁操作。虽然损失了一些隐式加解锁的便捷性,但是增强了可操作性。另外Lock还提供了一些特有的可中断获取锁、超时获取锁等同步特性。

Lock lock = new ReentrantLock();
// 需显式定义加锁和解锁
lock.lock();
try {
  // 业务逻辑
}finally {
  lock.unlock();
}
  • 解锁过程放入finally块,确保锁能够释放
  • 加锁过程不要写在try块,因为如果加锁过程发生异常,也会进入finally块导致锁无故释放

Lock接口提供的API

​ Lock是一个接口,它定义了锁的获取和释放的基本操作。

方法名称描述
void lock()获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回
void lockInterruptibly() throws InterruptedException可中断的获取锁,该方法可以在获取锁时响应中断当前线程
boolean tryLock()尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false
boolead tryLock(long time, TimeUnit unit) throws InterruptedException超时的获取锁,当前线程在以下3种情况会返回:
1. 当前线程在超时时间内获得锁;
2. 当前线程在超时时间内被中断;
3. 超时时间结束,返回false
void unLock()释放锁
Condition newCondition()获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的 wait() 方法,而调用后,当前线程将释放锁

​ 实现Lock接口的API基本都是基于AQS提供的能力来实现的,例如可重入锁ReentrantLock就是对外提供了上面Lock定义的API,实现方式就是内部聚合了一个AQS同步器,即api的功能基本都是调用AQS的方法来包装实现的。

​ Lock与AQS关系理解:Lock是面向锁的使用者的,他定义了使用者与锁的交互接口,隐藏了实现细节。而AQS是面向锁的实现者的,它简化了锁的实现方式,屏蔽了同步状态的管理,线程的排队,等待与唤醒等底层操作。锁和同步器很好的隔离 了使用者和实现者所需关注的领域。 ​

2.Lock VS Synchronized

Lock VS Synchronized 区别

  • 相同点
    • synchronized和Lock都是用于保护线程资源安全的
    • 两种都可以保证可见性和具备可重入的特点
  • 不同点
    • 加解锁控制:
      • synchronized的加解锁由JVM控制(内置锁);
      • Lock的加解锁通过手动控制(lock、unlock方法)
    • 线程访问:
      • synchronized锁只能同时被一个线程拥有
      • Lock锁没有这个限制(例如读写锁中的读锁可以被多个线程同时拥有)
    • 灵活性:
      • synchronized:一个线程获取到锁之后,其他线程要想获取锁只能等待(进入阻塞状态等待,直到持有锁的线程释放锁,这个过程可能会持续很久)
      • lock:可以使用lockInterruptibly()方法,不想等了可以中断退出;也可以使用tryLock方法获取线程,如果不获取线程也可以去干别的事情,更具备灵活性
    • 是否可以设置公平/非公平:公平锁是指多个线程在等待同一个锁时,根据先来后到的原则依次获得锁
      • synchronized:无法设置
      • lock:ReentrantLock等Lock实现类可以根据需求设置公平/非公平
    • 实现方式不同等方面

方案选择

JAVA并发编程实战和JAVA核心技术中建议:

  • 一般情况下建议不用Lock、synchronized,多数情况下可以使用JUC机制(java.util.concurrent),它会处理所有加锁解锁操作,推荐使用工具类来进行加解锁
  • 如果要是用synchronized,减少代码编写数量,且其加解锁是由JVM控制
  • 如果需要使用Lock提供的特殊功能则引入Lock,例如尝试获取锁、可中断、超时功能场景,需记得手动finally中进行解锁

3.重入锁ReentrantLock

如何实现重入?

重入:重入是指任意线程在获取到锁之后可以多次获取该锁而不会被锁所阻塞

重入实现需要解决两个问题

  • 线程再次获取锁:锁需要识别获取锁的现场是否为当前占据锁的线程,如果是则再次成功获取
  • 锁的最终释放:线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增(在QAS的同步状态state上累加目队列同步器(AQS)详解),计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。

公平和非公平获取锁的实现

​ ReentrantLock类定义:实现了Lock接口

public class ReentrantLock implements Lock, java.io.Serializable {}

​ 其内部类结构关系如下所示,分别是Sync、NonfairSync、FairSync

image-20240527090912559

构造函数

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

public ReentrantLock(boolean fair) {
  sync = fair ? new FairSync() : new NonfairSync();
}

  • 默认构造函数:默认采用非公平锁
  • 带参构造函数:可指定参数确定采用公平策略(true)、非公平策略(fair)
Sync

Sync类的核心方法和作用分析:lock、nonfairTryAcquire、isHeldExclusively、isLocked

abstract static class Sync extends AbstractQueuedSynchronizer {
  private static final long serialVersionUID = -5179523762034025860L;

  abstract void lock();

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

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

  protected final boolean isHeldExclusively() {
    // While we must in general read state before owner,
    // we don't need to do so to check if current thread is owner
    return getExclusiveOwnerThread() == Thread.currentThread();
  }

  final ConditionObject newCondition() {
    return new ConditionObject();
  }

  // Methods relayed from outer class

  final Thread getOwner() {
    return getState() == 0 ? null : getExclusiveOwnerThread();
  }

  final int getHoldCount() {
    return isHeldExclusively() ? getState() : 0;
  }

  final boolean isLocked() {
    return getState() != 0;
  }


  private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    s.defaultReadObject();
    setState(0); // reset to unlocked state
  }
}
  • lock:加锁(抽象方法,Sync类并未实现),由子类FairSync、NonFairSync按照自身特性实现,也是区别公平锁和非公平锁的主要方法
  • nonfairTryAcquire:非公平获取锁(与公平锁的tryAcquire对比)
  • isHeldExclusively:判断资源是否被当前线程占有
  • isLocked:资源是否被占用
NonfairSync类

​ NonfairSync继承了Sync类,表示采用非公平策略获取锁(实现了Sync类中抽象的lock方法)

static final class NonfairSync extends Sync {
  private static final long serialVersionUID = 7316153563782823691L;

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

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

NonfairSync的tryAcquire方法直接调用了父类Sync的nonfairTryAcquire方法

FairSync类

​ FairSync继承了Sync类,表示采用公平策略获取锁(实现了Sync类中抽象的lock方法)

static final class FairSync extends Sync {
  private static final long serialVersionUID = -3000897897090466540L;

  final void lock() {
    acquire(1);
  }

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

​ 重点关注tryAcquire方法,该方法与nonfairTryAcquire方法进行比较,不同之处在于判断条件多了hasQueuedPredecessors方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true则表示有线程比当前线程更早地请求获取锁,需要等待前驱线程获取并释放锁之后才能继续获取锁。

4.读写锁

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

​ Java并发包中提供的读写锁是ReentrantReadWriteLock,具有如下特性

  • 公平性选择:支持公平和非公平(默认)的锁获取方式
  • 支持重入:同一线程获取读锁之后能再次获取;同一线程获取写锁之后能再次获取写锁,并且还能获取读锁
  • 锁降级:按照获取写锁,再获取读锁,再释放写锁的顺序,写锁能够降级为读锁

读写锁接口示例

​ ReentrantReadWriteLock实现了ReadWriteLock接口,ReadWriteLock接口中定义了两个方法

  • readLock():获取读锁
  • writeLock():获取写锁

​ ReentrantReadWriteLock实现了接口中的两个方法,还通了查看其内部状态的方法

API方法描述
int getReadLockCount()返回当前读锁被获取的次数(该次数不一定等于读锁的线程数,考虑“重入”场景,例如同一个线程重进入了N次读锁,则占据读锁的线程数是1,但方法返回N)
int getReadHoldCount()返回当前线程获取读锁的次数(该方法是在Java6中加入的,使用ThreadLocal保存当前线程获取的次数)
boolean isWriteLocked()判断写锁是否被获取
int getWriteHoldCount()返回当前写锁被获取的次数(基于重入特性,同一个线程可以连续获取多次写锁)
// 模拟Cache实现:利用读写锁来保证并发状态下对应Cache的读get、写out、清除clear操作的安全性
public class CacheThreadDemo {

    // 定义Map存储数据
    static Map<String,Object> map = new HashMap<>();

    // 定义读写锁确保并发状态下map的读写安全
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    static Lock readLock = rwl.readLock();
    static Lock writeLock = rwl.writeLock();

    // 读:获取key对应的value
    public static final Object get(String key) {
        // 加锁
        readLock.lock();
        try {
            return map.get(key);
        }finally {
            // 解锁
            readLock.unlock();
        }
    }

    // 写:设置指定key的value,并返回旧value
    public static final Object put(String key,Object value) {
        // 加锁
        writeLock.lock();
        try{
            return map.put(key,value);
        }finally {
            // 解锁
            writeLock.unlock();
        }
    }

    // 清理所有的内容
    public static final void clear(){
        // 加锁
        writeLock.lock();
        try{
            map.clear();
        }finally {
            // 解锁
            writeLock.unlock();
        }
    }
}

读写锁实现分析

(1)读写状态设计

​ 读写锁依然是组合了一个自定义的同步器(AQS)来实现同步功能,而读写状态就是其同步器的状态,再次查看同步器的详解可知,同步状态state是一个int类型的整数。前面ReentrantLock实现重入性是在state上面进行累加,例如同一线程重复获取N次,那state的值为N。 现在对于读写锁来说同样只能在这个int类型的同步状态上维护多个读线程和一个写线程的状态,所以此时累加已经不行了,只能将一个32位的int变量进行切割,其中高16位维护读状态,低16位维护写状态

image-20240527104930772

​ 解析:当前状态表示一个线程已经获取了写锁,并且重入了两次,所以写转改为3,并且获取了2次读锁,所以读锁为2

(2)写锁的获取和释放

​ 获取:写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当当 前线程在获取写锁时,前线程进入等待状态

​ 释放:写锁释放与ReentrantLock的锁释放类似,因为支持可重入,所以这里每释放一次则写状态减1,当写状态减为0时,才真正释放写锁,别的线程才可以获取

(3)读锁的获取和释放

​ 读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经状态为0)时,获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。

​ 读锁是一个支持重进入的共享锁,并且支持多个线程同时获取。所以在能够获取读锁的状态下不管是同一线程再次获取读锁,还是其他线程获取读锁,都只是在读状态增加。

​ 但是,获取读锁的实现从Java 5到Java 6变得复杂许多,主要原因是新增了一 些功能(看上面接口示例),例如getReadHoldCount()方法,作用是返回当前线程获取读锁的次数。读状态是记录所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中(关于ThreadLocal可以查看目Java并发线程基础),由线程自身维护。

(4)锁降级

​ 锁降级(支持):指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。

​ 锁升级(不支持):指读锁升级为写锁。流程遵循先获取读锁,再获取写锁,再释放读锁

5.LockSupport工具

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

​ LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程。这些方法及描述如下:

API方法描述
void park()阻塞当前线程,如果调用unpack(Thread thread)方法或者当前线程被中断,才能从park()返回
parkNanos( long nanos)阻塞当前线程,最长不超过nanos纳秒,返回条件在park()的基础上增加了超时返回
parkUntil( long deadline)阻塞当前线程,直到deadline时间(从1970-deadline的毫秒数)
void unpark(Thread thread)唤醒处于阻塞状态的线程thread

6.Condition接口

​ 参考Synchronized原理分析,任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()、notifyAll()方法,这些方法和Synchronized同步关键字配合,可以实现等待/通知模式的通信机制。类似地,Condition接口也提供了类似Object的监视器方法,和Lock配合实现等待/通知模式

案例示例

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

public void conditionWait() throws InterruptedException {
  lock.lock();
  try{
    // 等待
    condition.await();
  }finally {
    lock.unlock();
  }
}

public void conditionSignal() throws InterruptedException {
  lock.lock();
  try{
    // 通知
    condition.signal();
  }finally {
    lock.unlock();
  }
}
  • Condition对象的获取是通过Lock对象的newCondition方法
  • await():调用await方法后,当前线程会释放锁并在此等待
  • signal():其他线程调用Condition对象的signal方法,通知当前线程后,当前线程从等待状态进去锁的同步队列,尝试获取锁,如果获取到锁则从await方法返回

Condition实现分析

ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键。

(1)等待队列

​ 等待队列是一个FIFO的队列,并且同步队列中的节点复用了

​ AbstractQueuedSynchronizer.Node(目队列同步器(AQS)详解open in new window),与同步队列中的节点一致。

​ 加入等待队列之后,可以通过下面这个图来了解同步器AQS与同步队列,等待队列的对应关系。

image-20240527111744397

总结分析

  • 一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)、尾节点(lastWaiter)
  • 一个同步器(AQS)拥有一个同步队列和多个等待队列,因为Condition对象可以创建多个(对比来看,Object监视器模型上,一个对象拥有一个同步队列和一个等待队列)
(2)等待

​ 调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。

​ 当从await()方法返回时,当前线程一定获取了Condition相关联的锁。

​ 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。

image-20240527112056232

​ 源码分析:

​ 调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。

​ 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。

(3)通知

​ 调用Condition的**signal()**方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中。

​ Condition的signal()方法:

image-20240527112213203

​ 成功获取锁之后,被唤醒的线程将从先前调用的await()方法返回,继续执行。Condition的signalAlI()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效 果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3