多线程与锁

转自:https://tech.meituan.com/2018/11/15/java-lock.html
文章转载时对部分内容参考其他博客进行了批注和修改,由于本人技术不到位,可能难免有理解错的地方,希望大家看到能及时指出
参考:

  1. https://editor.csdn.net/md/?articleId=110790203
  2. https://www.cnblogs.com/shemlo/p/11605681.html

下面是这章内容的总体导图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Eiyx2yFs-1607418761282)(7752F5FA74704BC3914FA791216EB365)]

1、乐观锁与悲观锁

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

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

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

乐观锁在Java中采用无锁编程来实现,最常用的是采用CAS算法,Java原子类中的递增操作就是通过CAS自旋实现的

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升
//------------悲观锁得到调用方式--------------
public synchronized void testMethods1(){
    //操作同步资源
}

public void testMethod2(){
    synchronized (this) {
        //操作同步资源
    }
}

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

//------------乐观锁得到调用方式--------------
//由于JDK9之前并不允许我们直接使用Unsafe类里面的CAS方法,所以只能通过API间接使用
AtomicInteger atomicInteger = new AtomicInteger(0); //需要保证多个线程使用同一个AtomicInteger
atomicInteger.getAndIncrement();//先获取再+1

为什么乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?
CAS全程Compare And Swap(比较与交换),是一种无所算法,在不使用锁(没有线程阻塞)的情况下实现多线程之间的变量同步,J。U。C包里面的原子类就是通过CAS实现了乐观锁

CAS指令需要三个操作数,分别是内存位置(在Java中可以简单的理解为变量的内存地址,用V表示),旧的预期值(用A表示),和准备设置的新值(用B表示),CAS指令执行时,当且仅当V符合A时,处理器才会用B的值更新V的值,否则他就不执行更新,但是不管是否更新了V的值,都会返回V的旧值,上述操作是一个原子操作,在执行过程不会被其他线程中断,这个原子操作依赖硬件指令集的发展,CAS在硬件上就是一个看似多步但其实是原子操作的指令

下面是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关键字保证其在线程间是可见的

下面是AtomicInteger的源码

//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;
}
//OpenJDK 8 
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;
}

根据OpenJDK8的源码可以看出,getAndAddInt()循环获取给定对象o中offset偏移量处的值v,然后用这个值去compareAndSwapInt,如果设置成功,就结束循环,返回v,否则循环重试,直到设置成功,整个compareAndSwapInt是利用JNI里一个CPU指令完成的

CAS虽然很高效,但是有一些问题也需要注意一下
1。 ABA问题,CAS在操作值的时候检查内存值是否发生变化,没有变化就更新内存值,但假如一开始他是A,后来变成了A,然后又变成了A,那么CAS检查时就检查不到这个值发生了变化,ABA问题的解决思路就是在变量前面增加版本号控制,JDK5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中,compareAndSet()首先会检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的新值
2。 循环时间开销大,CAS操作如果长时间不成功,会导致一直自旋,给CPU带来非常大的开销
3。 只能保证一个共享变量的原子操作,但是对多个共享变量CAS是无法保证操作的原子性的,不过JDK5开始,Java提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里进行CAS操作

2、自旋锁和适应性自旋锁

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FCj3G4WH-1607418761285)(6C96153C89F84A86AC25D605AE3FD24A)]

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

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自选操作

do {
    v = getIntVolatile(o, offset);
} while(!compareAndSwapInt(o, offset, v, v + delta));

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

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

这四种是指锁的状态,并且都是针对synchronized,synchronized能实现线程同步主要考Java对象头和Monitor锁

对象头: synchronized是悲观锁,每次操作之前都需要给对象加锁,这个锁就加在对象头里,HotSpot的对象头主要包括两部分,Mark Word和Klass Pointer

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

Klass Pointer:Klass是用来描述对象实例的具体类型,存储在元空间(方法区),虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor: monitor作为一个同步工具,在同一时间,只有一个线程/进程能进入monitor所定义的临界区,这使得monitor能够实现互斥的效果。 无法进入monitor的临界区的进程/线程,应该被阻塞,并且在适当的时候被唤醒

使用monitor主要是为了互斥进入临界区,在为了能够阻塞无法进入临界区的进程,线程,需要一个monitor object来协助,在上述synchronized关键字被使用时,往往需要指定一个对象与之关联,例如synchronized(this),总之,synchronized需要管理一个对象,这个对象就是monitor object,如果某个线程获得了锁,就成为当前锁的拥有者(Owner),Monitor里面有个owner字段用来保存当前线程的ID

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

所以目前锁一共有4种状态,无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,锁只能升级,不能降级

无锁: 无锁是对资源没有锁定,无锁的特点就是修改数据是在一个循环内完成的,如果没有冲突就修改成功退出,否则就继续循环尝试,如果有多个线程修改一个值,必定会有一个线程修改成功,修改失败的线程会不断重试,最常用的CAS原理就是无锁的实现

偏向锁: 如果某一个锁总是由同一个线程获取到,那么获取到锁之后就不主动释放,这样可以在下一次访问时就不需要再获取锁和释放锁,可以直接执行

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

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

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

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“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不必唤醒所有线程。 缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

可以通过ReentrantLock源码看看公平锁和非公平锁的实现有什么区别
在这里插入图片描述

根据代码可知,ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。 它有公平锁FairSync和非公平锁NonfairSync两个子类。 ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

//公平锁
        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;
        }
//公平锁中排队的方法
/**true if there is a queued thread preceding the current thread,and false if the current thread is at the head of the queue or the queue is empty
上面这行英文是源码对返回值的解释,大致意思是说如果当前线程之前存在一个排队的线程,则为true;如果当前线程位于队列的头部或队列为空,则为false*/
    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());
    }

通过源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。 可以看到该方法主要做一件事情: 主要是判断当前线程是否位于同步队列中的第一个。 如果是则返回false,否则返回true,对于原链接这里有一点点争议的地方,作者说如果是返回的是true,与实际代码结果不符,其实我觉得原作者可能是将这个方法的返回值与调用地方的 “!” 操作结合后表达式的结果,这样也更便于读者在逻辑上去理解

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

5、可重入锁VS非可重入锁

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

class Test {
    public synchronized void doSomething() {
        //...
        doOthers();
    }

    public synchronized void doOthers(){
        //...
    }
    
}

在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。

如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。

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

//可重入锁加锁
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //这里判断当前线程是锁拥有的线程之后才将state+1, 并返回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;
    //释放锁时也要判断state值为0, 才将锁释真正释放
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

//非可重入锁加锁
      protected boolean tryAcquire(int acquires) {
            if (this.compareAndSetState(0, 1)) {
                this.owner = Thread.currentThread();
                return true;
            } else {
                return false;
            }
        }
//非可重入锁释放
      protected boolean tryRelease(int release) {
            if (Thread.currentThread() != this.owner) {
                throw new IllegalMonitorStateException();
            } else {
                this.owner = null;
                this.setState(0);
                return true;
            }
        }

首先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,将锁释放。

6、独享锁 VS 共享锁

独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。

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

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

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

下图为ReentrantReadWriteLock的部分源码:
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。

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

那读锁和写锁的具体加锁方式有什么区别呢?在了解源码之前我们需要回顾一下其他知识。 在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。

在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的

下面是写锁的代码

protected final boolean tryAcquire(int acquires) {
    /*
     * Walkthrough:
     * 1. If read count nonzero or write count nonzero
     *    and owner is a different thread, fail.
     * 2. If count would saturate, fail. (This can only
     *    happen if count is already nonzero.)
     * 3. Otherwise, this thread is eligible for lock if
     *    it is either a reentrant acquire or
     *    queue policy allows it. If so, update state
     *    and set owner.
     * 上面注释的意思是说
     * 1. 如果读锁的个数是非零的或者写锁的个数是非零且锁不属于当前线程就失败
     * 2. 如果总数是饱和的, 直接失败, 因为半个state是16位, 所以最大值是65535
     * 3. 否则, 如果这个锁是可重入的或者队列策略允许的就可以获取锁
     */
    Thread current = Thread.currentThread();
    int c = getState();  //获取锁的个数, 这个是读写锁全部的个数
    //exclusiveCount这个方法直接返回了c & 65535的值
    //65535的二进制表示是16个0和16个1 
    //高16位与0后都是0,低16位与1后维持原值, 就可以得到写锁(写锁用低16位表示)的个数
    int w = exclusiveCount(c); //w是写锁的个数
    if (c != 0) { //如果当前锁的个数不等于0,就表示存在锁, 但可能是读锁也可能是写锁
        // (Note: if c != 0 and w == 0 then shared count != 0)
        if (w == 0 || current != getExclusiveOwnerThread()) //如果写锁的个数为0(那么一定是读锁)或者当前线程不是锁的拥有者就加锁失败
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT) //如果写入锁的数量大于最大值65535,抛出一个错误
            throw new Error("Maximum lock count exceeded");
        // Reentrant acquire,此时c>0, w>0,且是当前线程获取过锁,就直接增加锁的次数
        setState(c + acquires);
        return true;
    }
    //到这里说明当前没有锁,那么如果当前线程应该阻塞或者使用CAS加锁失败就返回失败
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}
  • 这段代码首先获取到了锁的全部个数,包括读锁和写锁, 因为写锁是低16位,所以这里通过锁的个数与65535求&,65535的低16位是1,高16位是0,通过求&可以得到低16位的值, 也就是写锁的个数
  • 在取到写锁线程的个数后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
  • 如果写入锁的个数大于65535,就抛出一个错误
  • 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
  • 如果c=0,w=0,则设置当前线程或锁的拥有者,如果c>0,w>0(重入)则更新锁的个数,并返回成功!

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

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

下面是读锁加锁的代码

protected final int tryAcquireShared(int unused) {
    /*
     * Walkthrough:
     * 1. If write lock held by another thread, fail.
     * 2. Otherwise, this thread is eligible for
     *    lock wrt state, so ask if it should block
     *    because of queue policy. If not, try
     *    to grant by CASing state and updating count.
     *    Note that step does not check for reentrant
     *    acquires, which is postponed to full version
     *    to avoid having to check hold count in
     *    the more typical non-reentrant case.
     * 3. If step 2 fails either because thread
     *    apparently not eligible or CAS fails or count
     *    saturated, chain to version with full retry loop.
     * 上面英文的意思大概是:
     * 1. 如果写锁被其他线程持有,加锁失败
     * 2. 自身技术不到位,理解不了有些词该怎么解释......
     * 3. ......
     */
    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”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥

可以再回头看看前面ReentrantLock的公平锁和非公平锁,它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁

转自:https://tech.meituan.com/2018/11/15/java-lock.html
文章转载时对部分内容参考其他博客进行了批注和修改,由于本人技术不到位,可能难免有理解错的地方,希望大家看到能及时指出
参考:

  1. https://editor.csdn.net/md/?articleId=110790203
  2. https://www.cnblogs.com/shemlo/p/11605681.html
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值