体系化深入学习并发编程(五)若有“锁”思

尺有“锁”短”

Lock接口和synchronized对比

显示锁Lock接口是JDK1.5之后引进的,它是也是一个线程同步机制,作用和内部锁synchronized相同,但并不是synchronized的替代品,二者适应于不同的场景。
内部锁是基于代码块的锁,本身是一个关键字,不够灵活。锁的申请和释放只能在一个方法内执行。
而显式锁是基于对象的锁,锁定申请和释放能够在不同的方法中执行。
不过内部锁更加简单易用,不会导致锁泄漏。和synchronized不同,发生异常时显示锁也不会自动释放锁,所以需要finally中释放锁,显示锁如果忘记释放,则会导致锁的泄漏。
显式锁和内部锁同样能保证可见性

显示锁的主要方法

  • void lock()
    获取锁。

  • void unlock()
    释放锁

  • boolean tryLock()
    尝试获取锁,如果获取到则返回true,反之返回false

  • boolean tryLock(long time,TimeUnit unit)
    如果获取到锁,直接返回true;否则进入等待状态,直至三种情况:
    1.获取到锁,返回true;
    2.被其他线程打断,进入catch代码块,清除中断状态
    3.等待时间截止,返回false

  • void lockInterruptibly()
    这个方法相当于无限时间的trylock。如果获取到锁就立即返回,否则进入永久等待状态,直至两种情况:
    1.获取到锁,返回
    2.被其他线程打断,进入catch代码块,清除中断状态

使用lock可能导致死锁的发生。
比如这个例子:
线程1获取锁1后去获取锁2.
线程2获取锁2后去获取锁1

public class LockDeadLock {
    public static final Lock lock1 = new ReentrantLock();
    public static final Lock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        new Thread(()->{
            lock1.lock();
            try {
                System.out.println(Thread.currentThread().getName()+"获取到锁1");
                Thread.sleep(new Random().nextInt(1000));
                System.out.println(Thread.currentThread().getName()+"尝试获取到锁2");
                lock2.lock();
                try {
                    System.out.println(Thread.currentThread().getName()+"获取到锁2");
                    Thread.sleep(new Random().nextInt(1000));
                    System.out.println(Thread.currentThread().getName()+"拥有两把锁");
                }finally {
                    lock2.unlock();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock1.unlock();
            }
        }).start();
        new Thread(()->{
            lock2.lock();
            try {
                System.out.println(Thread.currentThread().getName()+"获取到锁2");
                Thread.sleep(new Random().nextInt(1000));
                System.out.println(Thread.currentThread().getName()+"尝试获取到锁1");
                lock1.lock();
                try {
                    System.out.println(Thread.currentThread().getName()+"获取到锁1");
                    Thread.sleep(new Random().nextInt(1000));
                    System.out.println(Thread.currentThread().getName()+"拥有两把锁");
                }finally {
                    lock1.unlock();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock2.unlock();
            }
        }).start();
    }
}

然后就发生了死锁
lock是感受不到interrupt的,catch中的InterruptedException是针对sleep方法的。所以我们也不能通过interrupt来打断lock的死锁

Thread-1获取到锁2
Thread-0获取到锁1
Thread-0尝试获取到锁2
Thread-1尝试获取到锁1

使用trylock则能避免这种情况的发生
将两个线程分别改为下面这种情况,使用trylock来替代lock

new Thread(()->{
            try {
                if (lock1.tryLock(500,TimeUnit.MILLISECONDS)){
                    try {
                        System.out.println(Thread.currentThread().getName()+"获取到锁1");
                        Thread.sleep(new Random().nextInt(1000));
                        System.out.println(Thread.currentThread().getName()+"尝试获取到锁2");
                        if(lock2.tryLock(500,TimeUnit.MILLISECONDS)){
                            try {
                                System.out.println(Thread.currentThread().getName()+"获取到锁2");
                                Thread.sleep(new Random().nextInt(1000));
                                System.out.println(Thread.currentThread().getName()+"拥有两把锁");
                            }finally {
                                lock2.unlock();
                            }
                        }else {
                            System.out.println(Thread.currentThread().getName()+"获取锁2失败");
                        }
                    }finally {
                        lock1.unlock();
                    }
                }else {
                    System.out.println(Thread.currentThread().getName()+"获取锁1失败");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

打印结果如下:

Thread-1获取到锁2
Thread-0获取到锁1
Thread-0尝试获取到锁2
Thread-1尝试获取到锁1
Thread-0获取锁2失败
Thread-1获取到锁1
Thread-1拥有两把锁

这样就避免了死锁的发生。

寸有“锁”长

细探synchronized

synchronized我们经常用来解决线程安全问题
现在深入synchronized了解一下。

synchronized的两种用法

  • synchronized用于同步方法
    • 成员方法(锁实例对象)
    • 静态方法(锁类对象)
  • synchronized用于同步代码块(类对象|实例对象)

那么这两种方式有什么区别呢?

public class SynchronizedPrincipio {

    public static synchronized void method1(){
        System.out.println(Thread.currentThread().getName());
    }

    public static void method2(){
        synchronized (SynchronizedPrincipio.class){
            System.out.println(Thread.currentThread().getName());
        }
    }

    public static void main(String[] args) {
        method1();
    }
}

通过javap -v 命令反编译:
同步方法method1:
同步方法
同步方法是添加标志ACC_SYNCHRONIZED,如果执行该方法时,会有这个标志。则需要持有monitor才能进入方法,同样方法完成时,释放monitor。

同步代码块method2:
同步代码块

  • 4: monitorenter 入口
  • 18: monitorexit 正常出口
  • 24: monitorexit 异常出口

同步代码块则没有ACC_SYNCHRONIZED标志
而是通过在代码块前后设置monitorentermonitorexit来形成临界区。
分别设置了正常出口和异常出口。
从正常出口出来,执行19行命令,跳转到27行命令,返回
如果发生了异常,就走下面的异常流程(22-26)

monitor

之前我们多次提到了monitor(监视器锁),现在就仔细剖析下这个锁

每个对象都拥有自己的monitor,monitor是线程私有的数据结构,在对象头的MarkWord中的LockWord指向monitor的起始地址。
当同步块或同步方法调用对象时,当前线程必须先获取该对象的monitor才能进入临界区,如果没有获取到monitor的线程将会被阻塞在临界区入口,进入到BLOCKED状态

线程到来时会判断monitor的Owner是不是null,如果是null表示没有其他线程获取,就持有该monitor,并将monitor计数器+1。
每重入一次(monitorenter),就对计数器+1;
同样,每退出一次(monitorexit),就对计数器-1
当monitor计数器=0时,就完全退出

monitor锁是基于操作系统的Mutex Lock(互斥锁),这个锁的特性是独享锁和自旋锁。
其工作流程为:

mutex lock
因为Java的线程是映射到操作系统的线程上,所以每次线程的阻塞和唤醒是需要进行用户态和核心态的转换,让操作系统来执行操作。
这种操作需要CPU切换状态,所以会消耗CPU资源。所以这种依赖于操作系统的mutex lock的synchronized是一个重量级操作。
在1.6之前,高争用场景下,synchronized性能下降比lock下降得更快,而在JDK1.6后,虚拟机对内部锁进行了一些优化,这些优化使得显式锁和内部锁的性能差异已经很小了。

Java对象头

之前在JMM中,提到了关于Java对象模型的概念,也有部分关于对象头的介绍,现在就对Java对象头全面的剖析。

如果对象是非数组类型,则用 2 字宽存储对象头。
也就是Mark Word和Klass Pointer
如果对象是数组类型,则虚拟机用 3 个 Word(字宽)存储对象头。
也就是Mark Word、Klass Pointer和Array length
在32位虚拟机中,一字宽为4个字节,即32bit。也就是每个Word为32位。
而在64位虚拟机中,每个Word为64位(如果开启指针压缩,则Klass Pointer为32位)。

object header

Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object’s layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.

Mark Word

The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.

Klass Pointer

The second word of every object header. Points to another object (a metaobject) which describes the layout and behavior of the original object. For Java objects, the “klass” contains a C++ style “vtable”.

长度内容说明
32/64bitMark Word对象的hashCode或锁信息等
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray length数组的长度

下面是openjdk中markOop源码的注释

32 bits:
hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
size:32 ------------------------------------------>| (CMS free block)
PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)

64 bits:
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
size:64 ----------------------------------------------------->| (CMS free block)
unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

32位虚拟机下的Mark Word

锁状态25bit4bit1bit2bit
23bit2bit对象分代年龄是否偏向锁标志位
无锁状态对象的hashcodeage001
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC标记11
偏向锁线程IDEpochage101

64位虚拟下的Mark Word

锁状态25bit31bit1bit4bit1bit2bit
54bit2bitcms_free分代年龄偏向锁标志位
无锁状态unusedhashcodeunusedage001
轻量级锁ptr points to real header on stack00
重量级锁inflated lock (header is wapped out)10
GC标记used by markSweep to mark an object not valid at any other time11
偏向锁ThreadidEpochunusedage101

偏向锁、轻量级锁、重量级锁

了解完Java对象头,现在就来谈谈JDK1.6之后,对内部锁进行的一系列优化措施。
为了减少锁的获取和释放的性能开销,1.6自后引入了偏向锁轻量级锁

  • 锁的四种状态:
    • 无锁
    • 偏向锁
    • 轻量级锁
    • 重量级锁

锁可以升级但不能降级。

偏向锁

偏向锁(Biased Locking)是JVM对锁的一个优化。这种优化基于JVM开发者观测到的这样一种现象:大多数情况下,锁并不存在多线程竞争,而且总是由同一线程多次获得。
然而JVM执行同步代码块时,需要执行monitorenter和monitorexit操作,这两个操作需要借助一个原子操作(CAS操作),为了减少这个操作所带来的性能消耗,引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,之后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。

偏向锁的加锁

  • 判断对象头的Mark Word内偏向锁的值(是否可偏向?)
    • 如果是无锁,就CAS获取偏向锁,存储当前线程ID
    • 如果已经有偏向锁,再判断偏向锁的线程ID
      • 是当前线程,则当前线程成功获得锁
      • 不是当前线程,则尝试CAS将线程ID修改为当前线程

偏向锁的解锁
线程是不会主动去释放偏向锁,只有当其它线程尝试竞争偏向锁时,持有偏向锁的线程等待全局安全点时才会释放锁

  • 当到达全局安全点时,先暂停偏向锁持有线程,然后检查该线程是否存活(可能该线程已经结束,但是不会主动释放偏向锁)。
    • 如果该线程已结束,则释放偏向锁为无锁,重新偏向
    • 如果该线程正在运行,则偏向锁升级为轻量级锁。

偏向锁流程
偏向锁的关闭:偏向锁优化是默认开启的,要关闭优化,可以添加虚拟机参数(XX:-UseBiasedLocking=false)。

轻量级锁

当锁是偏向锁时,其他线程来访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
轻量级锁是为了在线程近乎交替执行同步块时提高性能,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果多个线程同时进入临界区,会导致轻量级锁膨胀升级重量级锁。

轻量级锁的加锁

  • 获取对象头的Mark Word,判断是否无锁
    • 如果是无锁,JVM先在当前线程的栈帧中建立一个锁记录(Lock Record)的空间,用来存储锁对象目前的Mark Word的拷贝(官方称这个拷贝为:Displaced Mark Word)
      • 然后JVM尝试通过CAS操作将对象的Mark Word改为指向Lock Record的指针
        • CAS成功,表示获得锁,则将锁标志位变成00(表示轻量级锁),然后进入临界区。
        • CAS失败,则进行下面的指针判断。
    • 如果不是无锁,则判断Mark Word是否已经存储了指向当前栈帧Lock Record的指针
      • 已经有指针,表示当前对象已经拥有锁,进入临界区
      • 没有指针,则进入自旋等待状态
        • 当达到自旋界限,或者有第三个线程访问时,轻量级锁膨胀为重量级锁

轻量级锁的解锁

  • 通过CAS操作,将Displaced Mark Word写回对象的Mark Word(替换掉指针)
    • CAS成功:释放锁
    • CAS失败:表示有竞争,膨胀为重量锁,释放的同时唤醒其他等待该锁的线程。

轻量级锁

重量级锁

重量级锁标志的状态值变为10,此时Mark Word中存储的是指向重量级锁的指针,其他所有等待锁的线程都会进入阻塞状态
它的实现就是上面提到的monitor锁。

锁的进化流程:
锁进化

锁消除

锁消除是JIT编译器对内部锁做的一种优化。在动态编译同步代码块时,JIT可以借助逃逸分析的技术判断锁对象是否只有一个线程使用,而没有发布给其他线程。
如果证实了这种情况,JIT认为当前已经是线程安全的,不需要加锁,于是JIT编译时会不生成synchronized的关于锁的申请和释放的机器码。
锁消除

锁粗化

锁消除也是JIT编译器对内部锁做的一种优化。
对于相邻的几个同步代码块,如果都是使用的同一个monitor,JIT会使这几个代码块合并为一个大代码块,减少了锁的申请和释放。

锁粗化

众“锁”周知

Java中往往是按照某个角度来定义锁,多个类型可以并存于一把锁,我们通过特性将锁进行分类对比

乐观锁和悲观锁

乐观锁非互斥同步锁和悲观锁互斥同步锁)是以人的性格来类比的。

悲观锁

而悲观锁则很悲观,它认为自己每次对数据操作时,一定会有其他线程想要来修改数据,所以会提前加锁,保证在自己操作时,没有其他线程捣乱,确保了数据的万无一失。
悲观锁的实现典型就是lock和synchronized
悲观锁

乐观锁

乐观锁的诞生是由于悲观锁的劣势

  • 阻塞和唤醒操作的性能损耗
    比如上下文的切换,用户态和核心态的转换

  • 可能永久阻塞
    比如死锁,活锁等或活跃性问题

  • 优先级反转
    可能优先级低的线程拿到锁,而优先级高的线程不得不等待锁的释放,反而变成了低优先级

乐观锁认为自己在数据操作时不会有其他线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
乐观锁是无锁的,在操作时进行检查,如果没有被修改过,就继续操作。如果发现数据被修改过了,就选择重试、报错、放弃等策略
最常用的实现是CAS算法
乐观锁的实现典例是原子类、并发容器等
数据库中的一些语句操作也有乐观锁的思想
比如update t set id =2 where id =1
它会判断id为1时才进行修改,如果id已经被修改了,就不做修改
乐观锁

两种锁的选择

  • 悲观锁适用场景:
    并发写入操作多,临界区持有锁时间比较长的场景,先加锁可以保证写操作时数据正确,也可以避免大量的无用自旋

  • 乐观锁适用场景:
    并发写入操作少,大多数都是读操作的场景,不加锁能够使其读取操作的性能大幅提升。

阻塞锁和自旋锁

通常一个线程进入临界区,而另一个线程进入阻塞状态,这样的锁就是阻塞锁
根据我们之前的知识可知,线程状态的切换是有性能开销的,一个线程进入阻塞,再从阻塞被唤醒都是会消耗CPU资源的。
假如临界区内部的命令很少,频繁的状态转换才是性能开销的大头,所以也就有了自旋锁的概念

自旋锁和阻塞锁不同,当线程A进入临界区,线程B请求锁时则不会进入阻塞状态,而是依旧持有CPU资源,不停地去询问线程A是否释放了锁,导致线程B这种状态的锁,就是自旋锁
自旋锁避免了切换线程的开销
自旋锁
Atomic包中就多处使用到了自旋锁,它是通过CAS操作+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;
    }

借此我们可以自己制造一个自旋锁。

public class SpinLock {
     private static AtomicReference<Thread> spinlock = new AtomicReference<>();

     private static void lock(){
         Thread current = Thread.currentThread();
         //期待锁的持有者从null变为当前线程
         while (!spinlock.compareAndSet(null,current)){
             System.out.println(Thread.currentThread().getName()+"尝试获取锁");
         }
     }

     private static void unlock(){
         Thread current = Thread.currentThread();
         //将持有锁的当前线程更新为null
         spinlock.compareAndSet(current,null);
     }

    public static void main(String[] args) {
         Runnable r = () -> {
             lock();
             try {
                 System.out.println(Thread.currentThread().getName()+"获取到锁");
                 TimeUnit.NANOSECONDS.sleep(1);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             } finally {
                 System.out.println(Thread.currentThread().getName()+"释放锁");
                 unlock();
             }
         };
         new Thread(r,"线程A").start();
         new Thread(r,"线程B").start();
    }
}

打印结果如下:

线程A获取到锁
线程B尝试获取锁
线程B尝试获取锁
...
线程B尝试获取锁
线程B尝试获取锁
线程A释放锁
线程B尝试获取锁
线程B获取到锁
线程B释放锁

可以看到在线程A获取到锁,休眠1纳秒的时间内,线程B不停地去尝试获取到自旋锁

这就带来了自旋锁的缺点
或许最开始阻塞锁导致的线程切换的消耗要高于自旋锁。
但是如果不停地自旋却获取不到锁,还占用着CPU资源,造成的消耗反而更高。所以自旋必须要有一个限度(默认是10次,可以通过相关命令进行更改)

而在JDK1.6之后引入了自适应自旋锁:

自旋的次数不再固定,而是根据同一把锁的上一次自旋时间和持有锁线程的状态来判断。
如果对于同一个自旋锁,刚刚有自旋成功获得锁的线程,且持有锁的线程正在运行,那么JVM就会判断这次自旋也可能成功,于是允许自旋等待更久一点。
如果对于一个自旋锁,很少有线程自旋成功获得锁,那之后尝试获取该锁的线程可能会不再自旋,直接进入阻塞状态,避免CPU资源的浪费。

可重入锁和不可重入锁

可重入锁又称为递归锁,是指在同一个线程内可以多次获取到同一把锁(锁对象得是同一个对象或者class),不会因为之前获取过但还没释放而导致阻塞。Java中ReentrantLocksynchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
现在举个例子:学校打水。学生需要去楼下开水房打水,但是不同的人携带的水壶数量不一样,比如你带了三个水壶帮室友打水。如果是可重入的,你排队进了开水房,就可以一次打完三个水壶。如果是不可重入,每次打完一个水壶,就必须出来再排队。
现在用两个方法来演示这个案例:

public class GetWater {
    private static final ReentrantLock lock = new ReentrantLock();
    public static synchronized void getWater(int i){
        if (i==0){
            return;
        }
        System.out.println(Thread.currentThread().getName()+"打了1壶水");
        i--;
        getWater(i);
    }
    public static void getWater(){
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"打了1壶水");
            if (lock.getHoldCount()<4){
                getWater();
            }
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(()->{
            getWater(3);
        },"同学A");
        thread.start();
        thread.join();
        new Thread(()->{
            getWater();
        },"同学B").start();
    }
}

打印结果

同学A打了1壶水
同学A打了1壶水
同学A打了1壶水

同学B打了1壶水
同学B打了1壶水
同学B打了1壶水
同学B打了1壶水

可重入锁原理

首先可重入锁和非可重入锁都继承父类AQS,在AQS中维护了一个同步状态初始值为0的status来计数重入次数。
下面直接查看ReentrantLock的源码

//获取锁
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    //如果锁持有为0,通过CAS去尝试获取锁,将state置为1
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //如果持有锁是当前线程
    else if (current == getExclusiveOwnerThread()) {
    	//再次获取锁,state+1
    	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) {
	//先获取state
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    //如果state-1=0,就真正释放锁,将持有锁线程置为null
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
   setState(c);
   return free;
}

不可重入锁原理

下面是从Netty 3.10.5.Final版本中的NonReentrantLock源码

protected boolean tryAcquire(int acquires) {
		//并没有判断,而是直接尝试获取锁
        if (this.compareAndSetState(0, 1)) {
            this.owner = Thread.currentThread();
            return true;
        } else {
            return false;
        }
    }

    protected boolean tryRelease(int releases) {
        if (Thread.currentThread() != this.owner) {
            throw new IllegalMonitorStateException();
        } else {
            this.owner = null;
            //释放锁时也直接将state置为0
            this.setState(0);
            return true;
        }
    }

公平锁和非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程在队列中按申请顺序排队,只有队列中首个线程能获得锁。公平锁的优点是等待锁的线程不会被饿死,公平排队迟早会排到自己。缺点是效率相对非公平锁更低,等待队列中除首个线程外,其他线程都会阻塞,吞吐量更小。而首个线程释放锁后,需要CPU去唤醒下一个线程,CPU唤醒阻塞线程是有开销的。
非公平锁则允许在合适的时机插队(而不是随意插队),因为CPU唤醒阻塞线程是需要时间的,而这段时间没有被利用则造成了资源浪费。非公平锁允许活跃线程来直接获取锁(如果没获取到,就进入队尾排队)。优点是提升了效率,吞吐量更大,缺点则和公平锁相反,可能导致队列中的线程迟迟获取不到锁,导致线程饥饿。

同样举一个例子:高中时,班主任找学生去办公室谈话。办公室每次只能进一个人。每个学生都在教室自习,等待被叫去谈话。
这时学生A被叫到,准备去办公室谈话时,学生B刚谈话出来,发现忘了交作业,准备再去办公室交个作业。
但是此时学生A作为离办公室比较远,学生B离办公室近,A走到办公室是需要一定时间的,B马上就能进办公室
如果是非公平锁,学生B可以利用这段时间进去交作业再出来。
如果是公平锁,学生B只能等到自己被叫到才能去办公室交作业

公平锁演示

现在用公平锁来模拟上述的例子
假设有5个学生,都是进去谈话出门后,发现自己手上拿的作业还没交,准备再进入办公室。
此时是使用公平锁的场景

public static final ReentrantLock lock = new ReentrantLock(true);
public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i <5; i++) {
            new Thread(()->{
                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName()+"进去谈话");
                    TimeUnit.SECONDS.sleep(new Random().nextInt(5)+1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }

                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName()+"进去交作业");
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }).start();
            Thread.sleep(100);
        }
    }

打印结果:

Thread-0进去谈话
Thread-1进去谈话
Thread-2进去谈话
Thread-3进去谈话
Thread-4进去谈话
Thread-0进去交作业
Thread-1进去交作业
Thread-2进去交作业
Thread-3进去交作业
Thread-4进去交作业

公平锁情况:每个学生出来后,必须再次排队才能进去交作业

值得一提的是:trylock()会忽略公平锁的性质,当锁释放时,会被trylock优先获取到。

非公平锁演示

public static final ReentrantLock lock = new ReentrantLock(false);

将fair参数改为false(ReentrantLock 默认为false)
打印结果:

Thread-0进去谈话
Thread-0进去交作业
Thread-1进去谈话
Thread-1进去交作业
Thread-2进去谈话
Thread-2进去交作业
Thread-3进去谈话
Thread-3进去交作业
Thread-4进去谈话
Thread-4进去交作业

源码分析

公平锁:
会执行hasQueuedPredecessors()方法进行判断
该方法主要判断:当前线程是否是同步队列中的首个线程。

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

排他锁和共享锁

排他锁和共享锁是一种概念
排他锁也叫独享锁独占锁,顾名思义,该锁只能被一个线程所持有。一个线程获取到这个锁后,可以对数据进行读写操作,此时其他线程只能等待,比如synchronized。
共享锁是指该锁可以被多个线程所持有。获得共享锁的线程只能读数据,不能写。

读写锁

下面用ReentrantReadWriteLock读写锁来演示。
我们把读写锁看做是一把锁,分别有写锁定读锁定
有这么几种情况:

  • 多个线程可以共同进行读锁定
  • 只能有一个线程能进行写锁定
  • 锁定的转换需要等待持有线程释放锁
    申请写锁定的线程,需要持有写锁定或读锁定的线程释放锁
    申请读锁定的线程,需要持有写锁定的线程释放锁

创建读写锁,获取读写锁的读锁和写锁(看似是两把锁,其实都是用的一把锁)
分别设计一个读方法和写方法

private static  ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static  ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
private static  ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

public static void read(){
    readLock.lock();
    try {
        System.out.println(Thread.currentThread().getName()+"开始读");
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        readLock.unlock();
    }
}
public static void write(){
    writeLock.lock();
    try {
        System.out.println(Thread.currentThread().getName()+"开始写");
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        writeLock.unlock();
    }
}

第一个例子:10个线程同时申请读

for (int i = 0; i <10; i++) {
    new Thread(()->read(),Integer.toString(i)+"线程").start();
    Thread.sleep(1);
}

执行结果在1秒左右,所有线程完成了任务

第二个例子:10个线程同时申请写

for (int i = 0; i <10; i++) {
    new Thread(()->write(),Integer.toString(i)+"线程").start();
    Thread.sleep(1);
}

执行结果在10秒左右

读写锁的交互

读写锁的插队策略

之前我们提到了公平锁和非公平锁的插队现象。
那么读写锁的插队现象呢。
在公平锁的情况下,肯定是要排队的。
那么在不公平锁的情况下呢?
现在的线程是读锁定,这里有两种策略:

  1. 新来的读锁定线程可以插队去读
  2. 新来的读锁定线程“不能插队”,也要去队列里排队

先来分析两种策略的优缺点:
第一种:提升性能,吞吐量提高。但是可能导致队列中的写锁定线程饿死
第二种:避免了不断来读锁定线程导致写锁定线程的饿死

而实际上ReentrantReadWriteLock选择的是第二种,虽然亏损了一些性能,但是保障了写操作的顺利执行。
但是不能插队是加了引号的,具体介绍的在源码分析
这里举出第三个例子:10个线程读写交替

for (int i = 0; i <10; i++) {
    if(i%2==0){
        new Thread(()->read(),Integer.toString(i)+"线程").start();
    }
    if(i%2==1){
        new Thread(()->write(),Integer.toString(i)+"线程").start();
    }
    Thread.sleep(1);
}

执行结果也是10秒左右,论证了上面第二种策略

读写锁的升降级

读锁和写锁是有等级区分的。
比如一个线程需要用10秒的锁,但是只有1秒在写,剩下9秒都在读。
如果一直持有写锁,对于后面9秒的读操作有些浪费。
但是如果释放了锁,又需要重新排队。
通过之前的例子看出写锁的权限要高于读锁,那么读写锁能不能通过升降级来相互转换呢?
下面用两个例子演示:
读锁的升级
假设运行一秒,其中读100毫秒,剩下900毫秒需要写:

public static void readUpToWrite(){
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"获取到读锁");
            Thread.sleep(100);
            System.out.println(Thread.currentThread().getName()+"准备升级写锁");
            writeLock.lock();
            System.out.println(Thread.currentThread().getName()+"升级到写锁,释放读锁");
            readLock.unlock();
            Thread.sleep(900);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName()+"释放写锁");
            writeLock.unlock();
        }
    }

打印结果:

Thread-0获取到读锁
Thread-0准备升级写锁

new Thread(()->readUpToWrite()).start();
在没有其他线程在获取写锁时,读锁依然不能升级为写锁,会被阻塞。
即在ReentrantReadWriteLock中,读锁不能升级为写锁(读锁持有线程不能获取写锁)

写锁的降级级
假设运行一秒,其中读100毫秒,剩下900毫秒需要写:

public static void writeDownToRead(){
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"获取到写锁");
            Thread.sleep(100);
            System.out.println(Thread.currentThread().getName()+"准备降级读锁");
            readLock.lock();
            System.out.println(Thread.currentThread().getName()+"降级到读锁,释放写锁");
            writeLock.unlock();
            Thread.sleep(900);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName()+"释放读锁");
            readLock.lock();
        }
    }

为了演示确实变成了读锁,另外用10个线程获取读锁

public static void read(){
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"获取到读锁");
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
        }
    }

打印如下

Thread-0获取到写锁
Thread-0准备降级读锁
Thread-0降级到读锁,释放写锁
Thread-1获取到读锁
Thread-4获取到读锁
Thread-5获取到读锁
Thread-7获取到读锁
Thread-3获取到读锁
Thread-6获取到读锁
Thread-10获取到读锁
Thread-2获取到读锁
Thread-9获取到读锁
Thread-8获取到读锁
Thread-0释放读锁

可见,在ReentrantReadWriteLock中,写锁能升级为读锁(写锁持有线程能获取读锁)

为什么只允许写锁降级,而不允许读锁升级呢?

这是由于读锁是共享锁写锁是排他锁
读锁升级需要其他持有共享锁的线程释放锁后,自己才能加锁。
而如果同时两个读锁都想升级,都在等对方释放锁,就形成了死锁。所以ReentrantReadWriteLock并不允许读锁升级。
此外,还需要考虑到读写锁之间的可见性问题

读写锁的源码分析

读写锁的加锁

读锁和写锁都是靠内部类Sync实现的,Sync是AQS的一个子类

//读锁
public static class ReadLock implements Lock, java.io.Serializable {
    private final Sync sync;
	protected ReadLock(ReentrantReadWriteLock lock) {
		sync = lock.sync;
	}

//写锁
public static class WriteLock implements Lock, java.io.Serializable {
	private final Sync sync;
	protected WriteLock(ReentrantReadWriteLock lock) {
    	sync = lock.sync;
    }

之前提到AQS的时候,我们了解到了state这个整型变量,因为是整型的所以是4字节,32位,它是描述有多少个线程持有锁(在可重入锁中就是重入多少次)
而独享锁只能被一个线程所独有,所以这个值通常为0或1,如果是可重入锁,则是重入次数
而在共享锁中则是持有共享锁的个数。

由于ReentrantReadWriteLock是读写锁,分别具有读锁(共享锁)和写锁(排他锁)的性质,而state需要来表示两种状态

所以将state切分

  • 高16位表示读锁数量
  • 低16位表示写锁数量

state

static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
//最大数量 2的16次方-1  
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
//和上面一样 1左移16位再-1 等于 1111 1111 1111 1111
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

/** Returns the number of shared holds represented in count  */
//无符号右移16位,高16位变为低16位,获取到共享锁的数量
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
/** Returns the number of exclusive holds represented in count  */
//与低16位最大值进行与运算,得出写锁个数
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

写锁加锁

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.
	 */
	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)
		//如果写锁为0(有读锁)或者当前线程不是锁拥有者,返回false
		if (w == 0 || current != getExclusiveOwnerThread())
			return false;
		//如果写锁数量大于最大数量,65535,抛出Error
		if (w + exclusiveCount(acquires) > MAX_COUNT)
			throw new Error("Maximum lock count exceeded");
		// Reentrant acquire
		//当前线程是写锁拥有者,重入写锁,设置state,返回true
		setState(c + acquires);
		return true;
	}
	//此时c=0,锁个数为0
	//如果该锁应该被阻塞,或者CAS失败,返回false
	if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
		return false;
	//该锁称为写锁拥有者,返回true
	setExclusiveOwnerThread(current);
	return true;
}

除了当前线程是可重入写锁持有线程之外,还增加了判断读锁是否存在。
如果存在读锁,则不能再获取写锁,确保了写锁的操作对读锁是可见的,如果允许读锁在已被获取的情况下再次获取写锁(读锁升级),那么正在运行的其他读线程就无法感知到当前写线程的操作。

读锁加锁

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.
	*/
	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);
}

如果有其他线程获取到了写锁,就进入等待状态
否则当前线程获取了写锁(允许了写锁降级),或者没有线程获取写锁,当前线程通过CAS获取读锁,读锁状态+1。

读写锁的插队

在读写锁的有参构造函数,就通过三元表达式来生成公平和非公平的sync对象。

public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }

而对于读写锁的公平情况,和ReentrantLock相差不大,读锁和写锁都会判断队列中是否有等待线程。

static final class FairSync extends Sync {
	//判断写线程是否应该阻塞
	final boolean writerShouldBlock() {
		return hasQueuedPredecessors();
	}
	//判断读线程是否应该阻塞
	final boolean readerShouldBlock() {
		return hasQueuedPredecessors();
	}
}

而对于非公平情况就比较复杂
apparentlyFirstQueuedIsExclusive方法是判断队列中首个线程是否为排他锁线程(写锁线程)

static final class NonfairSync extends Sync {
	//写线程总是允许插队
	final boolean writerShouldBlock() {
		return false; // writers can always barge
	}
	final boolean readerShouldBlock() {
	/* As a heuristic to avoid indefinite writer starvation,
	* block if the thread that momentarily appears to be head
	* of queue, if one exists, is a waiting writer.  This is
	* only a probabilistic effect since a new reader will not
	* block if there is a waiting writer behind other enabled
	* readers that have not yet drained from the queue.
	*/
	return apparentlyFirstQueuedIsExclusive();
	}
}

我们之前在上面提到的非公平情况下,读锁不能插队是画了引号的。其实是根据判定当前队列首节点是否是写线程,如果是写线程就不能插队。
首节点为写线程不能插队
如果是读线程,则允许插队。
读线程首可以插队
可能会好奇,前面是读锁,为什么读线程3会在队列等待而不是在读呢?
因为读线程3正在准备获取读锁时,这段时间刚好读线程4过来了,就先读线程3一步获取了读锁,但是并不会打乱读线程3获取读锁。
在英文注释中也提到这是一个概率问题
现在通过代码来模拟

public class ReadLockBarge {
    private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(false);
    private static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

    public static void read(){
        System.out.println(Thread.currentThread().getName()+"尝试获取读锁");
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"获取到读锁");
            Thread.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName()+"释放读锁");
            readLock.unlock();
        }
    }
    public static void write(){
        System.out.println(Thread.currentThread().getName()+"尝试获取写锁");
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"获取到写锁");
            Thread.sleep(40);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName()+"释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->write(),"写线程1").start();
        new Thread(()->read(),"读线程1").start();
        new Thread(()->read(),"读线程2").start();
		//设置1000个新的线程不断尝试获取读锁
        new Thread(()->{
            for (int i = 0; i <1000; i++) {
                new Thread(()->read()).start();
            }

        }).start();

    }
}

最开始的启动顺序和代码顺序一致

写线程1尝试获取写锁
写线程1获取到写锁
读线程1尝试获取读锁
读线程2尝试获取读锁
Thread-1尝试获取读锁
...

按照我们的逻辑,在写线程1释放写锁时,队列第一个是读线程1,此时是可以被插队的。同样读线程1出了队列获取锁之后,队首变为了读线程2,此时也可以插队。
也就是在如下两个区间内读锁能插队
[写线程1释放写锁后—读线程1获取读锁前]
[读线程1获取读锁后—读线程2获取读锁前]
区间1:

写线程1释放写锁
Thread-169尝试获取读锁
Thread-169获取到读锁
Thread-172尝试获取读锁
Thread-172获取到读锁
读线程1获取到读锁

区间2:

读线程1获取到读锁
Thread-174尝试获取读锁
Thread-174获取到读锁
Thread-173尝试获取读锁
Thread-178尝试获取读锁
Thread-173获取到读锁
Thread-178获取到读锁
Thread-177尝试获取读锁
Thread-177获取到读锁
Thread-180尝试获取读锁
Thread-180获取到读锁
读线程2获取到读锁

总结
公平锁

  • 不允许插队

非公平锁

  • 写线程可以插队
  • 读线程在队列中等待的第一个为读线程时,可以插队。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值