Java 锁

Java 锁

java lock


Java 中提供了种类丰富的锁,每种锁因其特性的不同而在适当的场景下会展现出非常高的效率。通过锁的特性或者使用场景可以进行以下归类:

lock

乐观锁 VS 悲观锁

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

  • 乐观锁:

    乐观锁认为自己在使用数据的时候不会有别的线程来修改数据,所以在获取数据时不会加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据,如果该数据没被更新则成功写入,如果被其它线程更新了,则根据不同的实现方式执行不同的操作(如报错或自动重试)。

  • 悲观锁:
    相反,对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定会有别的线程来修改数据,所以在获取数据的时候会先加锁,确保数据不会被别的线程修改。

使用场景:

  • 乐观锁: 乐观锁适合读操作多的场景,不加锁可以使其读操作的性能大幅提升。乐观锁在 java 中是通过无锁编程来实现的,最常采用的是 CAS 算法,java 中原子类的递增操作就是通过 CAS 自旋是实现的。
  • 悲观锁: 悲观锁适合写操作多的场景,加锁可以保证写操作时的数据准确性。java 中 synchronized 关键字和 Lock 的实现类都是悲观锁。
// 乐观锁使用方式
// 保证多个线程使用的是同一个 AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();   

// 悲观锁使用方式
// synchronized 可以作用在方法上或代码块上
public synchronized void test() {
    // 操作同步资源
}

// ReentrantLock
// 需要保证多个线程使用的是同一个 lock
private ReentrantLock lock = new ReentrantLock();
public void test() {
    lock.lock();   // 加锁
    // 操作同步资源
    lock.unlock();   // 释放锁
}

CAS

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

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

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

当且仅当 V 的值等于 A 时,CAS 才会通过原子方式用新值来更新 V 的值(“比较和替换是一个原子操作”),否则不会执行任务操作。一般情况下 替换 是一个不断重试的操作。java.util.concurrent 包中的原子类就是用 CAS 实现了乐观锁。下面以原子类 AtomicInteger 为例进行说明:

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

    // 获取并操作内存的数据
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    
    // 存储 value 在 AtomicInteger 中的偏移量
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    
    // 存储 AtomicInteger 的 int 值,该属性需要借助 volatile 关键字来保证其在多个线程之间是可见的
    private volatile int value;

其 incrementAndGet() 自增方法源码如下:

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

// Unsafe.class
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

// Unsafe.java
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;
}

从 Unsafe.java 中的 getAndAddInt() 方法的源码可以看出,该方法中循环获取对象 o 中偏移量 offset 处的值 v,然后与内存中的值进行比较,若相等则用 v + delta 替换内存中的值,否则就返回 false 继续循环重试,知道替换成功,并返回旧值。整个 比较 + 更新 操作都是封装在 compareAndSwapInt() 方法中完成,在 JNI 是借助于一个 CPU 指令完成的,属于原子操作,可以保证一个变量的修改值在多个线程之间是可见的。

后续操作是 JDK 通过 CPU 的 cmpxchg 操作,比较寄存器中的值 A 与内存中的值 V 是否相等,如果相等则把新值 B 存入内存中;否则将内中中的值 V 赋值给寄存器中的值 A,然后通过 java 代码中的循环再次调用 cmpxchg 指令进行重试,直到设置成功为止。

CAS 存在的三大问题:

  • ABA 问题: CAS 在比较与替换时需要检查内存中的值是否变化,没有发生变化才会更新,但如果内存中的值从 A 变成 B,然后又变成了 A,此时 CAS 在检查的时候认为其是没有发生变化的,但实际上是发生了变化的。ABA 问题的解决思路是在变量前加上版本号,每次对变量的操作都将版本号加 1,这样变成过程就从 A - B - A 变成了 1A - 2B - 3A。
    jdk-1.5 中增加了 AtomicStampedReference 类来解决 ABA 问题,具体使用 compareAndSet() 方法,其先检查 当前引用和当前标志与预期引用和预期标志是否相等,如果相等则以原子的方式将引用值和标志值用给定的更新值替换。
  • 循环时间长开销大问题: CAS 如果长时间操作不成功会导致其一直自旋,这样会给 CPU 带来非常大的开销。
  • 只能保证一个共享变量的原子操作问题: CAS 只能保证一个共享变量的原子操作,对于多个变量的操作其是不支持的。
    jdk-1.5 中增加了 AtomicReference 类来保证引用对象之间的原子操作,可以将多个共享变量放在一个对象中来保证原子操作。

自旋锁 VS 适应性自旋锁

阻塞或唤醒一个 java 线程需要操作系统切换 CPU 状态来完成,这种状态的切换需要消耗处理器时间。如果同步代码块的逻辑比较简单,则这种状态切换所花费的时间可能比执行用户代码的时间还要长。
在很多场景下,同步资源锁定的很短,为了这一小段时间而去切换线程,线程切换和挂起可能会让整个操作得不偿失。这种情况下,我们可以让获取锁失败的线程不放弃 CPU 时间片,稍作等待,等待占有锁的线程释放锁,再去尝试获取锁。这种等待就是自旋,可以避免切换线程的开销。这就是自旋锁。

spin-lock

自旋本身是有缺点的,它不能代替阻塞。自旋等待的目的是避免切换线程的开销,但它会占用 CPU 时间,如果自旋的时间很短,则它的效果就会非常好;如果自旋的时间很长,那么此线程就会一直占用 CPU 资源。所以自旋等待必须要有时间限制(默认是 10 次,可以通过 -XX:PreBlockSpin 参数更改),如果超过自旋次数没有成功获取锁,那么当前线程就应该挂起,释放 CPU 时间片。

自旋锁的实现原理同样也是 CAS,AtomicInteger 中的 Unsafe 的自增操作的源码中的 do-while 块就是自旋操作,如果更新失败则会通过循环来自旋,直至更新成功。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

自旋锁在 jdk-1.4.2 中引入,使用 -XX:+UseSpining 来开启。jdk-1.6 中变为默认开启,并引入了自适应的自旋锁,即适应性自旋锁。

适应性自旋锁中自旋的次数将不再固定,而是由前一次在同一个锁上的自旋时间和该锁的拥有者的状态所决定。如果前一次自旋成功获取到锁,且次线程正在运行中,那么虚拟机就认为这次自旋也很有可能成功获取到锁,进而它会允许当前线程自旋时间稍比前一次自旋时间长一点。如果对于一个锁,通过自旋很少成功获取到锁,那么在以后尝试获取这个锁时将可能省略掉自旋这个过程,直接阻塞线程,避免消耗 CPU 资源。

在自旋中,还有三种锁的形式分别是:TicketLock、CLHLock、MCSLock。

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

这四种锁是指锁的状态,专门针对 synchronized 关键字的。

首先为什么 synchronized 能够实现线程同步?在回答这个问题前我们需要了解两个重要的概念:“Java 对象头” 和 “monitor”。

  • Java 对象头:
    synchronized 是悲观锁,在操作同步资源前需要先加锁,这把锁就存放在 java 对象头里。
    以 Hotspot 虚拟机为例,Mark Word(标记字段)和 Class Pointer(类指针)。
    • Mark Word: 默认存储对象的 hashCode、分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以 Mark Word 被设计成一个非固定的数据结构,以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
    • Class Pointer: 对象指向它的类元数据的指针。虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • Monitor:
    Monitor 可以理解为一种同步工具或同步机制,通常被描述为一个对象。每一个 java 对象都有一把看不见的锁,称为内部锁或 Monitor 锁。
    Monitor 是线程私有的数据结构,每一个线程都有一个可用的 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段用来存放拥有该锁的线程的唯一标识,标识该锁被这个线程占用。

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

在 jdk-6 之前 synchronized 的实现方式类似于 “阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态的转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式是 synchronized 最初实现线程同步的方式,也是其效率低的原因。

这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁,jdk-6 中为了减少获取锁和释放锁所带来的性能消耗,引入了 偏向锁和轻量级锁。所以目前锁一共有四种状态,级别由低到高分别是 无锁、偏向锁、轻量级锁和重量级锁。所的状态只能升级不能降级。

这四种锁状态对应的 Mark Word 中的内容分别是:

锁状态存储内容存储内容
无锁对象的 hashCode、对象分代年龄、是否偏向锁(0)01
偏向锁偏向线程 ID、偏向时间戳、对象分代年龄、是否偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
  • 无锁:
    无锁指的是对同步资源没有锁定,所有线程都能访问并修改同一个资源,但同时只有一个线程能成功。
    无锁的特点是修改操作在循环内进行,线程会不断尝试修改同步资源。如果没有冲突就会修改成功并推出,否则就会继续循环尝试。如果有多个线程修改同一个值,那么必定有一个线程会成功,而其它失败的线程会不断重试直至成功。上面介绍的 CAS 原理及应用即使无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下性能是非常高的。
  • 偏向锁:
    偏向锁是指一段同步代码一直被同一个线程所执行,那么该线程会自动获取锁,降低获取锁的代价。
    在大多数情况下,锁总是由同一线程多次获得,很少会出现多线程竞争的情况,所以就出现了偏向锁。其目标就是在只有一个线程执行同步代码时提高其性能。
    当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步代码块时不再通过 CAS 操作来加锁和解锁,而是检查 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁的执行路径,因为轻量级锁的获取和释放依赖多次 CAS 原子指令,而偏向锁只需要在置换线程 ID 时依赖一次 CAS 原子指令即可。
    偏向锁只有在其它线程尝试 竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放锁。偏向锁的释放(撤销)需要等待全局安全点(此时间点没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后判断锁对象是否处于锁定状态。偏向锁撤销后会恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态。
    偏向锁在 jdk-6 及以后的版本中是默认开启的,可以通过 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 里面存储的是指向重量级锁的指针,此时等待该锁的状态都会进入阻塞状态。
    锁的升级流程为: 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。

综上所述,偏向锁是通过对比 Mark Word 解决加锁问题,避免 CAS 操作;轻量级锁是通过 CAS 和自旋来解决加锁问题,避免线程阻塞和唤醒带来的性能问题;重量级锁是将当前拥有锁的线程除外的所有竞争锁的线程都阻塞。

公平锁 VS 非公平锁

  • 公平锁:
    公平锁是是指多个线程按照竞争锁的顺序来获取锁。线程直接进入队列中排队,队列中的第一个线程将会获取到锁。公平锁的优点是等待锁的线程不会饿死;缺点是整体吞吐效率相对非公平锁较底,等待队列中除第一个线程外的其它线程都会阻塞,CPU唤醒线程的开销要比非公平锁大。
  • 非公平锁:
    非公平锁是多个线程加锁时会直接尝试获取锁,获取不到才会到等待队列的队尾等待。但假如此时锁刚好可用,那么该线程可以不用阻塞而直接获取到锁,所以非公平锁会出现后竞争锁但先获取到锁的场景。非公平锁的优点是可以降低 CPU 唤醒线程的开销,整体吞吐率较高,因为线程有几率不用阻塞而获取到锁;缺点是处于等待队列中的线程可能会饿死或者很久才能获取到锁。

公平锁和非公平锁有点类似于排队和插队(doge)。

java 中可以通过 ReentrantLock 的源码来理解公平锁和非公平锁:

public class ReentrantLock implements Lock, java.io.Serializable {
    
    // 类属性 sync
    private final Sync sync;

    // 内部类 Sync 继承于 AQS
    abstract static class Sync extends AbstractQueuedSynchronizer {...}

    // 子类 NonfairSync 非公平锁
    static final class NonfairSync extends Sync {...}

    // 子类 FairSync 公平锁
    static final class FairSync extends Sync {...}

    // 无参构造方法 默认使用非公平锁
    public ReentrantLock() { sync = new NonfairSync(); }

    // 带参构造方法 可通过 fair 参数来指定使用公平锁还是非公平锁
    public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
}

公平锁与非公平锁加锁的源码:

// 公平锁加锁
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;
}

// 非公平锁加锁
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;
}

通过获取锁的源码可以看出,公平锁与非公平锁在加锁时的唯一区别是公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),该方法的源码如下:

public final boolean hasQueuedPredecessors() {
    // The correctness of this depends on head being initialized
    // before tail and on head.next being accurate if the current
    // thread is first in queue.
    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。

综上所述,公平锁是通过同步队列来实现多个线程按照申请锁的顺序来获取锁;非公平锁是线程不考虑排队等待问题,直接获取,所以存在后申请先获得的场景。

可重入锁 VS 非可重入锁

可重入锁是指同一个线程在外层方法获取到锁时,在进入到该线程的内层方法会自动获取到锁(前提是同一个锁),不会因为之前已经获取过但没释放而阻塞。java 中的 ReentrantLock 和 synchronized 都是可重入锁。可重入锁的一个优点是可以一定程度上避免死锁。

public class Test {
    public synchronized outMethod() {
        sout("out method");
        this.inMethod();
    }
    
    public synchronized inMethod() {
        sout("in method");
    }
}

如上述代码示例,两个方法都被内置锁 synchronized 所修饰,在 outMethod() 方法中调用 inMethod(),由于内置锁是可重入的,所以同一个线程在调用 inMethod() 方法时会直接获取到当前对象的锁。

如果不是可重入锁,则在进入 inMethod() 方法之前需要将调用 outMethod() 方法时获取到的锁释放掉,但实际上该锁已经被当前线程持有,且由于方法未执行完无法释放,所以会造成死锁。

可重入锁类似于带多个桶排队打水,当轮到多桶人打水时就相当于获取到锁了,那么这个人就可以一次性把他带的桶都装满,然后再把位置让给下一个人(锁释放,被下一个线程获取)。

非可重入锁则类似于带一个桶排队打水,当轮到你时接完一桶赶紧走人,别墨迹。

java 中 ReentrantLock 和 synchronized 都是可重入锁。NonReentrantLock 时非可重入锁。下面对比可重入锁 ReentrantLock 与非可重入锁 NonReentrantLock 的源码:

// 可重入锁获取锁
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // 获取同步状态 state 为 AQS 中维护的一个表示同步状态的值 0 表示同步代码块没有被执行(锁没被持有) 1 则相反
    int c = getState();   
    if (c == 0) {   // 如果为 0 则说明当前没有其它线程在执行同步代码
        if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 如果不为 0 则说明当前锁被某个线程持有 并判断该锁的持有者是否为当前线程
    else if (current == getExclusiveOwnerThread()) {
        // 如果当前线程为锁的持有者 则 state + 1 表示当前线程重入一次
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

// 可重入锁释放锁
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;   // state - 1
    // 判断当前线程是否为该锁的持有者
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {   // 如果 state 为 0 则说明锁不再被持有 即可释放
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

// jdk8 中好像没有非可重入锁了?

AQS 中维护了一个 state 来表示同步状态(也可表示同步代码块是否正在被执行,也可表示锁对象是否被持有),用来计数重入次数,初始值为 0。

当线程尝试获取锁时,可重入锁尝试并更新 state 的值,如果其值为 0,则表示当前没有其它线程在执行同步代码块,则把 state 的值置为 1,然后执行代码;如果不为 0 则表示该锁被某个线程持有,则判断该锁的持有者是否为当前线程,如果是,则 state + 1,然后当前线程可再次获取锁。而非可重入锁是直接去获取并尝试更新 state 的值,如果其值不为 0 则会获取锁失败,当前线程阻塞。

当前线程释放锁时,可重入锁依旧是先获取 state 的值,然后在当前线程是该锁的持有者的情况下,如果 state - 1 == 0,则说明当前线程的所有有关获取锁的操作都已执行完成,然后释放锁。而非可重入锁是在确定当前线程是该锁持有者的情况下直接将 state 置为 0,然后释放锁。

独享锁 VS 共享锁

独享锁和共享锁同样是一种概念。

  • 独享锁:
    独享锁也叫排它锁,是指该锁只能被一个线程所持有。如果某个线程对某个数据加上独享锁后,其它线程就不能对该数据加任何锁。获得独享锁的线程既能读取数据也能修改数据。jdk 中的 synchronized 和 juc 中的 Lock 的实现类就是互斥锁。
  • 共享锁:
    共享锁是指该锁可被多个线程持有。如果某个线程对某个数据加上共享锁后,其它线程就只能再对该数据加共享锁了,不能加排它锁。获得共享锁的线程只能读取数据,不能修改数据。

独享锁和共享锁也是通过 AQS 实现的,通过实现不同的方法,来实现独享和共享。ReentrantReadWriteLock 的源码如下所示:

public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
    
    private final ReentrantReadWriteLock.ReadLock readerLock;
    
    private final ReentrantReadWriteLock.WriteLock writerLock;
    
    final Sync sync;

    // 午餐构造方法 默认使用非公平锁
    public ReentrantReadWriteLock() { this(false); }

    // ReentrantReadWriteLock 的带参构造方法 由 fair 来指定公平与非公平
    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }

    // 写锁和读锁的构造方法
    public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
    public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }

    // 内部类 Sync ReentrantReadWriteLock 的同步实现 分为公平实现和非公平实现 也是 AQS 的子类
    abstract static class Sync extends AbstractQueuedSynchronizer {...}

    // 内部类 Sync 的非公平锁实现
    static final class NonfairSync extends Sync {...}

    // 内部类 Sync 的公平锁实现
    static final class FairSync extends Sync {...}

    // 读锁 由内部类 Sync 实现
    public static class ReadLock implements Lock, java.io.Serializable {...}

    // 写锁 由内部类 Sync 实现
    public static class WriteLock implements Lock, java.io.Serializable {...}
}

由源码可知,ReentrantReadWriteLock 有两把锁,分别是 ReadLock 和 WriteLock(即读写锁)。ReadLock 和 WriteLock 是靠 ReentrantReadWriteLock 的内部类 Sync 实现的,Sync 是 AQS 的子类,同时内部类 Sync 也有两个版本的实现,即 FairSync 和 NonFairSync(公平和非公平)。Sync 这种结构在 CountDownlatch、ReentrantLock 和 Semaphore 中也存在。

在 ReentrantReadWriteLock 中,读锁和写锁的锁主体都是 Sync,但读锁和写锁的加锁方式不一样,读锁是共享锁,写锁是排它锁。读锁的共享锁可以保证并发性能非常好,但由于读锁和写锁是分离的且写锁是排它锁,所以读写、写读、写写的过程是互斥的。所以 ReentrantReadWriteLock 的并发性能相比其它互斥锁有了很大的提升。

AQS:

AQS 中维护了一个 state 变量(int 类型,32 位)。一般用来表示有多少线程持有锁。

  • 在独享锁中这个值通常是 0 和 1。
  • 在共享锁中则表示持有锁的线程数量。
  • 在重入锁中表示重入的次数。
  • 但在 ReentrantReadWriteLock 中有两把锁,所以需要在一个整形变量 state 上表示读锁和写锁的数量。具体实现是将 state 按位切割,高 16 位表示读锁的状态(读锁个数),低 16 表示写锁的状态。

读锁的加锁源码:

// 读锁 获取锁的过程
protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();   // 获取当前线程
    int c = getState();   // 获取锁状态 state
    
    // 如果当前锁的写锁已经被非当前线程获取 则当前线程获取读锁失败 线程进入等待状态
    // 其中 exclusiveCount(c) 返回的是写锁状态 即 state 低 16 位的值
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    
    // 获取读锁状态 即 state 高 16 的值
    int r = sharedCount(c);
    
    // 如果当前线程获取了写锁或写锁未被获取 则当前线程依靠 CAS (保证线程安全)增加读锁的状态(即修改 state 高 16 的值) 成功获取读锁
    // 读锁的每次释放均为减少读锁的状态(线程安全 多个线程可同时释放读锁)即修改 state 高 16 位的值 每次减少的值是 1<<16
    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);
}

写锁的加锁过程:

// 写锁 加锁过程
protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();   // 获取当前线程
    int c = getState();   // 获取当前锁状态
    int w = exclusiveCount(c);   // 获取当前锁的写锁状态
    
    // 如果当前锁被持有(说明读锁或写锁被持有)
    if (c != 0) {
        // 如果写锁未被持有或者读锁的持有者不是当前线程 则返回失败
        // 原因是必须确保写锁的操作对读锁的操作可见 如果允许在读锁已经被获取的情况下写锁被获取 则读锁的操作将感知不到写锁的结果
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        
        // 如果写锁被获取 则判断写锁能否被重入 state 低 16 位的值大于 MAX_COUTN(2^16 - 1) 则不能重入 
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        
        // 重入(重入的前提是锁的持有者是当前线程 因为上面判断过 current != getExclusiveOwnerThread())
        // 所以当程序执行到这里则说明 锁的持有者就是当前线程 所以可重入
        setState(c + acquires);
        return true;
    }
    
    
    // 如果写锁数为 0(此时读锁数也为 0 因为上面判断过 c!= 0)且当前线程需要阻塞 或 通过 cas 增加写锁数失败 则返回失败
    if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
        return false;
    
    // 成功获取写锁 则将写锁的持有者设置为当前线程
    setExclusiveOwnerThread(current);
    return true;
}

综上所述,只有当其它线程释放了该锁的读锁部分,当前线程才能获取到写锁,而且一旦写锁被获取,则其它请求写锁的线程将都被阻塞。写锁的释放与 ReentrantLock 的锁释放过程类似,每次释放均减少写锁状态(考虑到同一线程的重入),当写锁状态为 0 时则表示写锁已被释放,然后请求读锁的线程才能获取到读锁,同时前此写线程的修改对后续的读线程可见。

即:

  • 读锁被持有时,写锁不能被获取。
  • 写锁被持有时,读锁不能被获取。
  • 写锁同时只能被一个线程获取(可重入)。

所以,读写、写读、写写是互斥的。

结合 ReentrantReadWriteLock 的 ReadLock 和 WriteLock 与 ReentrantLock 的公平锁和非公平锁比较,则可发现,公平锁与非公平锁都是独享锁,因为在公平锁和非公平锁的源码中(if (current == getExclusiveOwnerThread())),只有当锁的持有者为当前线程时,才可进行重入,如果当前线程不是锁的持有者则失败。所以可以确定对于 ReentrantLock 无论读操作还时写操作其都是独享锁,这也就是为什么 ReentrantReadWriteLock 的效率要比一般互斥锁的高的原因(因为它的读锁是共享锁)。

完结 撒花!!!

听见奶奶说什么了吗?

奶奶总说…

上票

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值