谈谈Java中的锁

205 阅读34分钟

尺有所短,寸有所长;不忘初心,方得始终。

请关注公众号:星河之码

一、什么是锁

在java中的并发编程中,锁是一个永恒的话题,那么什么是锁呢,下面看一下小案例

  • 单线程执行

    定义一个全局变量count,循环对它累加,在单线线程下,程序顺序执行,一次执行完才会执行下一次,循环累加10次后,最后的结果必然是10。如下

    public class LockTest {
    
        // 计数器
        private Integer count = 0;
        // 累加操作
        public void addOne() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.count = this.count + 1;
        }
        // 累加操作
        public Integer getCount() {
            return this.count;
        }
    
        public static void main(String[] args) throws InterruptedException {
            lockTest lockTest = new lockTest();
            for (int i = 0; i < 10; i++) {
                lockTest.addOne();
                System.out.println(Thread.currentThread().getName() + "-----" + lockTest.getCount());
            }
        }
    }
    

  • 多线程无锁执行

    在多线程下,每个循环都是一个新的子线程单独执行,不会等待上次的结果,如下

    public class lockTest {
    
        // 计数器
        private Integer count = 0;
        // 累加操作
        public void addOne() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.count = this.count + 1;
        }
        // 累加操作
        public Integer getCount() {
            return this.count;
        }
    
        public static void main(String[] args) throws InterruptedException {
            ExecutorService executorService= Executors.newFixedThreadPool(10);
            lockTest lockTest = new lockTest();
            for (int i = 0; i < 10; i++) {
                executorService.execute(new Runnable() {
                    @Override
                    public void run() {
                        lockTest.addOne();
                        System.out.println(Thread.currentThread().getName() + "-----" + lockTest.getCount());
                    }
                });
            }
        }
    }
    

    从下面结果可以看出,多线程无锁的情况下,对共享变量的操作其结果会出错

  • 多线程加锁执行

    接下来我们对通过synchronized关键字对addOne方法加锁,在来看看结果

    public class lockTest {
    
        // 计数器
        private Integer count = 0;
        // 累加操作
        public synchronized void addOne() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.count = this.count + 1;
        }
        // 累加操作
        public Integer getCount() {
            return this.count;
        }
    
        public static void main(String[] args) throws InterruptedException {
            ExecutorService executorService= Executors.newFixedThreadPool(10);
            lockTest lockTest = new lockTest();
            for (int i = 0; i < 10; i++) {
                executorService.execute(new Runnable() {
                    @Override
                    public void run() {
                        lockTest.addOne();
                        System.out.println(Thread.currentThread().getName() + "-----" + lockTest.getCount());
                    }
                });
            }
        }
    }
    

    从结果看,通过synchronized关键字对addOne方法加锁后,每个线程在执行addOne方法的时候都会排队执行,这样保证最终的结果是对的

通过以上的案例,说明在多线程下,我们通过对共享变量加锁的方式,使得线程需要等待其他已经获取到资源的线程先执行完成,然后再执行,实现多线程排队执行,也就是多线程间的同步操作。同步操作的实现,需要给线程共享对象关联一个互斥体,也就是我们说的锁。Java提供了很多不同的锁,它们的实现方式也不一样

二、锁的类型

在Java中有大量的锁的实现与应用,这些锁被分门别类,在各自的引用只中发挥作用,下面张图描述了Java锁的种类与应用

这种图罗列了十几种锁,那么问题来了,我们应该在什么样的场景使用什么样的锁呢,继续看下一张图

通过以上这张图对Java中常用的锁做了一个分类,根据这张图我们就知道在什么样的场景应该使用什么样的锁,接下来看看各个锁分别具体是怎么做的。

这两种图比较,会发现第二张图壁第一张少一些,其实这是在不同场景下的分类取名不一样而已,比如互斥锁/读写锁 就是独享锁/共享锁具体的实现。

2.1 乐观锁 VS 悲观锁

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

2.1.1 悲观锁

对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此它会先加锁锁住资源

  • 悲观锁的优势悲观锁一定能够保证数据的同步

  • 悲观锁的缺点

    • 阻塞和唤醒带来的会消耗性能
    • 可能会造成死锁,使得线程永久阻塞
    • 优先级反转,优先级低的线程拿到锁不释放或释放的比较慢
  • 悲观锁的实现

    Java中悲观锁的实现就是synchronized和接口Lock的实现类

  • 悲观锁的使用场景

    适合并发写入操作较多的情况,先加锁可以保证写操作是数据正确。

  • 代码案例

    上述案例的synchronized就是悲观锁的实现

     public synchronized void addOne() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.count = this.count + 1;
        }
    

    上述案例的synchronized换成Lock的实现类ReentrantLock实现悲观锁

    // 需要保证多个线程使用的是同一个锁
        private ReentrantLock lock = new ReentrantLock();
        // 累加操作
        public void addOne() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            lock.lock();
            this.count = this.count + 1;
            lock.unlock();
        }
    

2.1.2 乐观锁

对于同一个数据的并发操作,乐观锁认为自己在使用数据时不会有别的线程修改数据,因此它不会对资源加锁,仅仅是在更新资源是去判断资源有没有被其他线程更新,若没有更新,则将自己的修改写入,若有更新,则根据不同的实现方式选择报错或者重试等策略

从概念和名字上都可以看出,乐观锁和悲观锁本质上是相反的两个概念

  • 乐观锁的优势乐观锁本质上没有加锁,没有加锁的性能消耗,能够使其读操作的性能大幅提升

  • 乐观锁的缺点

    • CAS只能保证单个变量的原子性,对多个变量无能为力
    • 执行更新时频繁失败,需要不断重试,从而浪费cpu资源
    • 存在ABA问题
  • 乐观锁的实现

    CAS算法, 主要用于多读的场景, 以此来提高数据的吞吐量

  • 乐观锁的使用场景

    适合读操作并发多的场景,不加锁能够使其读操作的性能大幅提升。

  • 代码案例

    在之前的《并发基础(五):ThreadPoolExecutor源码解析》中提到的存储线程池状态和工作线程数量的ctl,就是乐观锁的一种实现(CAS)方式

    CAS算法和ABA问题,这里不展开描述,在后续文章中会详细介绍

2.2 公平锁 VS 非公平锁

公平锁 和 非公平锁也不是一种具体的锁,而是一种思想。 主要说的是多个线程竞争时是否要排队,排队即公平锁,不排队的即非公平锁

2.2.1 公平锁

指多个线程按照申请锁的顺序来获取锁。并发环境中,线程直先接进入队列中排队,队列中的第一个线程才能获得锁,即按照FIFO原则获取锁

公平锁的排队是利用AQS通过CLH 队列锁实现的

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。

AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。

通过模型图,我们发现公平锁的实现就是,只要有新的线程要获取资源,就会直接去队列中等待,而不是直接获取锁,先到先得

  • 公平锁的优势公平锁的线程不会饿死,也就是排到这个线程了就一定会执行

  • 公平锁的缺点

    由于队列排队阻塞,整体吞吐效率

2.2.2 非公平锁

指多个线程加锁时可以直接尝试获取该锁, 获取不到才会进入队列排队, 如果刚好锁可用, 则会直接插队获取锁, 无须阻塞等待, 因此非公平可能会出现后申请先获得的情况

  • 非公平锁的优势可以减少唤醒线程的开销, 整体吞吐量高

  • 非公平锁的缺点

    可能会导致等待队列中的线程要等待很久才会获得锁, 或者饿死(一直获取不到锁)

2.2.3 AQS实现排队

  • AQS概念

    如果被请求的资源是共享的空闲的,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,则需要一套线程阻塞等待以及被唤醒时锁分配的机制(用state 和 CLH 队列锁实现,即将暂时获取不到锁的线程加入到队列)

    简单的说就是:AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

  • 主要思想:FIFO(先进先出队列)

  • 实现算法:CLH队列算法

  • 底层数据结构:双项链表

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改

下面结合ReentrantLock来看看AQS是如何实现的,AQS由三个部分组成,

  • State:当前线程锁的个数。
  • exclusiveOwerThread:当前占有锁的线程 。
  • CLH队列等待运行的线程。

线程1通过CAS算法A=V(state)=0,修改state的值为1 线程1又想获取锁,此时A=V(state)=1,state再加1,无论A想获得多少次,只是state+1 线程2进行CAS比较,发现A不等于V,并且发现state不等于0,直接到CLH列队中等待。 线程3和线程4也一样到CLH队列中等待。如果先来的线程先排队,获取锁的优先权,则为公平锁。如果,无视等待队列,直接尝试获取锁。

2.2.4 公平锁 与 非公平锁的实现

Java中接口Lock的实现类ReentrantLock可以实现公平锁与非公平锁,接下来通过ReentrantLock的源码来看看它是怎么实现公平锁和非公平锁的。

在ReentrantLock有一个有参构造函数,通过这个这函数的参数来判断是公平锁还是非公平锁

public ReentrantLock(boolean fair)

通过源码知道,当fair为true的时候,是公平锁,fair为alse的时候为非公平锁,并且默认为非公平锁

在ReentrantLock构造方法中有一个Sync对象,继续追踪,发现ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),它有公平锁FairSync和非公平锁NonfairSync两个子类,添加锁和释放锁的大部分操作实际上都是在Sync中实现的

公平锁(FairSync)、非公平锁(NonfairSync)都有一个tryAcquire方法,这是实现的关键,并且非公平锁的实现是使用父类中的nonfairTryAcquire方法

接下来看看这两个方法具体是怎么实现公平锁(FairSync)、非公平锁(NonfairSync)的,

通过源码的追踪,可以看到两者的实现除了公平锁的实现多了一个hasQueuedPredecessors()的调用之后是一模一样的,两者都通过compareAndSetState(CAS)方法改变了State的值表示加锁,,所以接下来就来看看hasQueuedPredecessors()做了什么

源码中这个方法的实现很简单,主要就是看当前线程是不是同步队列的首位,是:true、否:false

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

可能会有一个疑问,不是说有一个队列存放线程的吗,上述的源码中没有提到队列啊,哪队列在哪里实现的的呢?这里有两点需要明确

  • 线程存放在队列中是加锁的时候做的,加锁我们就要调用lock方法
  • 在前面我们已经提到了,这里的队列是一个CLH队列,也就是虚拟队列,实际上是不存在的,那么它是怎么实现的呢

针对这点,可以继续看一下源码的lock方法,当我们在程序中调用lock方法时

Lock reentrantLock = new ReentrantLock();
reentrantLock.lock();

实际上会调用公平锁(FairSync)或非公平锁(NonfairSync)中的lock()

而在lock方法中又会调用acquire(1)方法,在acquire方法中最终调用addWaiter()

上述源码中就知道了,addWaiter方法中将当前线程封装了一个Node,每个Node都指向了上一个节点,形成了一个链表,只有链表的第一个Node中的线程才能获取锁,这个链表就是前面说的CLH队列,说是队列,其实就是一个链表,队列本质上是不存在的

至于为啥不直接叫链表,而要整个队列的说法,还是一个虚拟队列(CLH队列),这个可能是因为一说到队列就会让人想到FIFO?或者其他一下考虑吧,这方面就没有深究了,反正即使说链表也说了是一个虚拟队列(CLH队列)

2.3 重入锁(递归锁) VS 不可重入锁

重入锁与不可重入锁本质上也不是指具体的什么类型的锁,也是一种思想,指对于同一个线程能否多次获得同一把锁,Java中ReentrantLock和synchronized都是可重入锁

2.3.1 重入锁(递归锁)

可重入锁又名递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然可以获取该锁(前提锁对象得是同一个对象或者class), 该锁不会因为之前已经获取过还没释放而阻塞

如上模型图,对于可重入锁来说,同一个线程里面的不同地方获取同一把锁不会阻塞造成死锁

  • 重入锁实现原理

    通过组合自定义同步器来实现锁的获取和释放

    • 再次获取锁: 识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取,获取锁后,进行计数自增
    • 释放锁: 释放锁 进行计数自减
  • 重入锁的优势可一定程度避免死锁】因为重入锁有一个持有计数来跟踪对lock方法的嵌套调用。被一个锁保护的代码可以调用另一个使用相同锁的方法。

  • 重入锁的缺点

    必须手动开启和释放锁

  • 重入锁的实现

    ReentrantLock、synchronized修饰的方法或代码段

  • 重入锁的实现案例

    如下通过synchronized来实现可重入锁,Test类中methodA()与methodB()都是被synchronized修饰,methodA()方法中调用methodB()方法。

    public class Test {
        public synchronized void methodA() {
            System.out.println("this is methodA");
            methodB();
        }
        public synchronized void methodB() {
            System.out.println("this is methodB");
        }
    }
    

    这里我们要明确一下,我们使用synchronized获取的锁是什么锁

    • 当synchronized作用在实例方法时

      【监视器锁(monitor)便是对象实例(this)】

    • 当synchronized作用在静态方法时】

      【监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代(方法区),即静态方法锁相当于该类的一个全局锁】

    • 当synchronized作用在某一个对象实例时

      【监视器锁(monitor)便是括号括起来的对象实例】

    基于此,首先methodA()获取锁是Test的对象锁(this),在调用methodB()时,由于时在同一个线程中操作,还是当前的对象,当前线程已经获取了对象锁,所以可以直接调用methodB()。

以上是synchronized实现的一个可重入锁,那么ReentrantLock的一个可重锁实如何实现的呢,其实在之前的源码中已经告诉我们了

2.3.2 不可重入锁

不可重入锁就是一个线程中的多个流程不可以获取同一把锁,当同一个线程在外层获取到锁的时候, 再进入内层,内层如果也要获取这把锁,会导致内层一直在等待外层释放锁, 而外层又在等待内层执行, 无法释放锁, 从而出现死锁

在java中大多数锁都是重入锁,我没有在JDK中找到不可重入锁,如果需要可以自己参考ReentrantLock的实现去写一个不可重入锁

2.4 共享锁 VS 独享锁/独占锁/排他锁

独占锁和共享锁同样也是一种思想,他们指的是在一个线程获取锁了之后,是否能够再次被其他线程获得着把锁,即:多个线程能否共享一把锁

2.4.1 共享锁

共享锁又称为读锁,指该锁可被多个线程所持有

如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。

获得共享锁的线程只能读数据,不能修改数据。

  • 共享锁的优势在读多的场景下,不会阻塞其他线程,能够有效的提高吞吐量

  • 共享锁的缺点

    会阻塞写操作,获得共享锁的线程只能读数据,不能修改数据

  • 共享锁的实现

    ReentrantWriteReadLock中的readLock就是共享锁。

  • 共享锁的使用场景

    适用于读操作比较多的场景

2.4.2 独享锁/互斥锁/排他锁

独享锁也叫互斥锁、排他锁,指该锁一次只能被一个线程所持有

如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁

获得排它锁的线程即能读数据又能修改数据。

  • 独享锁的优势能够保证数据的一致性

  • 独享锁的缺点

    加锁后任何线程试图再次加锁都会被阻塞

  • 独享锁的实现

    synchronized和JUC中Lock的实现类以及ReentrantWriteReadLock中的writeLock都是独享锁。

  • 独享锁的使用场景

    适用于写操作比较多的场景

2.4.3 独享锁与共享锁的实现

synchronized和JUC中Lock的实现类,以及ReentrantWriteReadLock中的writeLock都是独享锁。

ReentrantWriteReadLock中的readLock是共享锁。

synchronized和JUC中Lock的实现类在上述中已经介绍了,接下来就以ReentrantWriteReadLock为例看看独享锁和共享锁是如何实现的

在java中读锁是共享锁写锁是独享锁。在JUC中有一个接口ReadWriteLock,定义两把锁:读锁和写锁,读锁是共享锁,写锁是独享锁

接下来看看其实现类ReentrantWriteReadLock是如何实现的

其实到这里我们就知道,ReentrantWriteReadLock中读锁跟写锁分别其实现方式跟公平锁,非公平锁有点类似。它们通过ReadLock和WriteLock实现

看到这里就我们发现ReadLock和WriteLock都是通过ReentrantReadWriteLock的sync实现的,读锁和写锁的锁主体都是Sync,只是加锁方式不一样,一个是ReadLock一个是WriteLock

protected WriteLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
}
protected ReadLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
}

那么问题来了,既然加锁主体都是Sync,那是怎么区分读锁和写锁的呢?这个时候就要用到【AQS的state字段了(int类型,32位),该字段用来描述有多少线程获持有锁】。

state这个字段虽然是AQS的一个字段,但是它在不同的锁实现中起到的作用不同:

  • 独享锁中这个值通常是0或者1,代表有没有加锁
  • 重入锁中这个值就是重入的次数
  • 共享锁中state就是持有锁的数量

ReentrantReadWriteLock中有读、写两把锁,此时就需要用一个state来分别描述读锁和写锁的数量

为啥不用两个state?

ReentrantReadWriteLock的两把锁都是通过它的Sync实现的,一个ReentrantReadWriteLock对象只有一个Sync,一个Sync对象自然也就只有一个state了

那么一个变量怎么来分别描述读锁和写锁的数量呢?如果看过我之前的文章《并发基础(五):ThreadPoolExecutor源码解析》,就会想到线程池记录线程池状态和工作线程数量的ctl了

这里也是一样的,将32位整型变量state分为高位和低位两部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数),这样就可以通过一个变量分别描述读锁和写锁的数量

跟线程池的实现ctl如出一辙,如果不清楚高位低位的计算的话,可以看看之前的《ThreadPoolExecutor源码解析》

知道怎么区分读锁和写锁,那继续来看源码是怎么加锁的

这里可能有点绕,一会调子类,一会调父类,其实本质上就是sync父类AbstractQueuedSynchronizer时一个抽象类,抽象类无法实例化,子类实现了tryAcquireShared和tryAcquire这两个方法,自然会调自己的

明白了这一点,那就知道看,其实ReentrantReadWriteLock实现的读锁和写锁其实就是通过sync的tryAcquireShared和tryAcquire方法实现的

继续看这两个方法是怎么实现的

  • 写锁的加锁源码

    sync 中的tryAcquire方法实现写锁,通过注释可以知道这个方法主要实现的功能有

    • 如果读计数非零或写计数非零,或者当前线程和持有锁的线程不是同一个,则加锁失败

    • 如果计数饱和,也就是写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error,加锁失败。

    • 如果当前锁的个数和写锁的个数都为0,则加锁成功

      如果当前锁个数不为0,而写锁的个数为0,说明当前说是读锁,已经加了读锁,不能加写锁

    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) {
                    // 如果写锁为0,而此时c != 0,说明存在读锁  或者持有锁的线程不是当前线程  则加锁失败,返回false
                    if (w == 0 || current != getExclusiveOwnerThread())
                        return false;
                    //走到这里,进一步判断锁的个数有没有超过MAX_COUNT
                    // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
                    if (w + exclusiveCount(acquires) > MAX_COUNT)
                        throw new Error("Maximum lock count exceeded");
                    // 走到这里,说明可以加锁,将锁的数量加1,并返回true,加锁成功
                    setState(c + acquires);
                    return true;
                }
        		//如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。
                if (writerShouldBlock() ||
                    !compareAndSetState(c, c + acquires))
                    return false;
                setExclusiveOwnerThread(current);
                return true;
            }
    

    通过上述源码的分析也进一步说明了:写锁的加锁过程,会判断是不是存在读锁,如果存在读锁,则写锁不能被获取,因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞

  • 读锁的加锁源码

    sync 中的tryAcquireShared方法实现读锁

     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();
             // 当前写锁已经被获取 且 获取写锁的线程不是本线程
            // 获取读锁失败
            // 此处判断失败有两种情况
            //  1.写锁没有被获取
            //  2.写锁被本线程持有
            //  这两种情况都可以尝试获取读锁
                if (exclusiveCount(c) != 0 &&
                    getExclusiveOwnerThread() != current)
                    return -1;
                int r = sharedCount(c);
         	// 此处判断成功说明本线程获取读锁成功
                if (!readerShouldBlock() &&
                    r < MAX_COUNT &&
                    compareAndSetState(c, c + SHARED_UNIT)) {
                     // r==0 说明读本线程是第一个获取读锁的线程
                    if (r == 0) {
                        firstReader = current;
                        firstReaderHoldCount = 1;
                    } else if (firstReader == current) {
                         //说明此次获取读锁为firstReader的重入
                        firstReaderHoldCount++;
                    } else {
                        HoldCounter rh = cachedHoldCounter;
                        // 判断成功说明当前缓存的HoldCounter不是本线程的
                       //1.当前线程曾经获取到读锁并释放了,再次获取读锁,且期间没有任何其他线程获取读锁
                       //2.该线程不是第一个获取到读锁的线程,即在该线程获取到读取之前有其他线程已经获取到读锁,而且仍没有退出
                        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方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程增加读状态,成功获取读锁

通过上述的分析:说明在读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥

2.5 自旋锁 VS 适应性自旋锁

2.5.1 为啥要自旋锁

在了解自旋锁之前,先来看看为啥要有自旋锁,我们先来看看下面两个问题

  • 锁的阻塞或唤醒

    我们知道,Java线程的阻塞或唤醒是需要操作系统切换CPU状态来完成的,而CPU的转换是需要耗费处理器时间的

    当我们程序比较简单,执行比较快的时候,CPU切换消耗的时间有可能比用户代码执行的时间还要长。

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


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

  • 为什么阻塞和唤醒线程会消耗大量时间

    因为线程的阻塞与唤醒,CPU其实做了很多工作。也就是我们常说的线程的上下文切换

    案例:线程阻塞,切换线程上下文经历以下几个步骤

    • 第一步:线程1获取CPU时间片执行

    • 第二步:线程1被阻塞,上下文切换

      • 从用户态切换到内核态

        用户是没有权限对内存进行操作,需要切换到内核态才有权对内存进行操作。

      • 把线程1的状态信息保存到内存

        一个线程在运行时的内存模型主要有5个部分组成:

        1. 程序计数器:记录下一条指令的地址

        2. 虚拟机栈:保存函数的信息,例如,局部的变量,函数返回地址,操作数等

        3. 本地方法栈:和虚拟机栈类似,不过其保存的函数的信息是native函数

        4. 方法区:保存类的信息,静态变量等

        5. 堆:实例化的对象

        线程1所涉及的程序计数器、虚拟机栈、方法区、堆等信息都会被保存到内存中。

    • 第三步:线程2抢占CPU时间片执行

    基于以上三步可见上下文切换是非常耗时的

2.5.2 自旋锁

基于以上两个问题,自旋锁就产生了,就是为了解决线程频繁阻塞导致上下文切换的CPU资源消耗

自旋锁:当一个线程要获取锁时,锁已经被其他线程获取,当前线程不阻塞,而是进行自旋(循环获取锁),直到前面的线程释放锁或者自旋完成完成,从而避免切换线程的开销

以上是自旋锁的实现原理图,从图中可以很明显看到自旋锁的优缺点

  • 自旋锁优势

    可以在一定程度上避免线程阻塞,减少线程切换的开销

  • 自旋锁劣势

    当锁被其他线程占用的时间很长,那么自旋的线程会白白浪费处理器资源

    所以一般设置一个阈值,如果自旋超过了限定次数没有成功获得锁,就应当挂起线程,

    但是这样还是会有问题:可能设置的阈值为10,自旋10次后,线程刚被挂起,其他线程就是释放了锁,这个时候又要唤醒当前线程,这样不仅浪费了自旋的开销,还没有避免线程切换的开销

  • 自旋锁的实现

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

2.5.3 适应性自旋锁

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

前面我们说到,为了防止自旋锁一直循环下去占用CPU资源,设置了一个阈值,但是这个阈值是死的,可能刚超过阈值其他线程就释放了锁,所以就引入了适应性自旋锁

适应性自旋锁表示着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。

  • 如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

适应性自旋锁核心思想:自旋阈值不再人为设定,而是由上一次在同一个锁上的自旋时间和锁拥有者的状态决定的

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

这四种锁本质上不是锁,而是锁的状态,主要是指锁的状态,并且是专门针对synchronized的

级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级

  • 无锁

    没有锁住资源, 所有线程都能访问并修改同个资源, 但同一时间里只有一个能修改资源成功, 其他线程会循环重试, 直至修改成功

    无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。

    CAS的原理就是无锁的实现, 无锁无法全面替换有锁, 但无锁在某些场合下的性能非常高.

  • 偏向锁

    偏向锁是JDK6时加入的一种锁优化机制: 指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价

    在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。偏向锁锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作

    偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁

    偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。

    撤销偏向锁后恢复到无锁或轻量级锁的状态。


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

  • 轻量级锁

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

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

  • 重量级锁

    重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低

通过以上的描述,我们知道无锁、偏向锁、轻量级锁和重量级锁其实就是synchronized在不同场景下阻塞线程的方式,我们常常说的锁升级也就是这四个状态的升迁变更,由低到高如下

synchronized状态的升迁变更是如何由无锁一步步变成重量级锁的,在介绍synchronized的时候会详细介绍,这里就不再展开。

2.7分段锁

  • 分段锁其实是一种锁的设计,并不是具体的一种锁,典型的应用就是ConcurrentHashMap,其并发的实现就是通过分段锁的形式来实现高效的并发操作

  • 默认情况下ConcurrentHashMap被细分为16个段(Segment)每次上锁只是锁的每个segment. segment通过继承ReentrntLock来进行加锁,保证每个segment是线程安全的。

    当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

  • 分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

  • JDK8为何又放弃分段锁,是因为多个分段锁浪费内存空间,竞争同一个锁的概率非常小,分段锁反而会造成效率低。

    jdk8 放弃了分段锁而是用了Node锁,减低锁的粒度,提高性能,并使用CAS操作来确保Node的一些操作的原子性,取代了锁。

2.8总结

上述对java中常用的15种锁都做了一个详细的介绍,最后发现这些锁本质上其实都是思想,是一种概念的设计,他们的本质上都是CAS,synchronized和接口Lock的实现类三者来实现的,通过对它们的优化,改进衍生了这15种锁

因此我们在学习这些锁的同时,更多的应该去理解作者的这种设计思想,这样在去理解这些锁,甚至其他的锁的时候就很容易了, 当然光有思想,有概念也不行,CAS,synchronized,Lock能够衍生出这么多锁,也说明了它们本身设计优秀之处,值得我们深入研究。

三、锁消除

锁消除是指在编译期间利用【逃逸分析技术】分析出不存在竞争却加了锁的代码,使锁失效,减少了锁的请求与释放操作而消耗系统资源。锁消除默认开启

  • 案例

    StringBuffer的append是一个synchronized修饰的同步方法

    但是它的锁在下面的情况会失效

    public String bufferTest(){
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 1000; i++) {
            sb.append(i);
        }
        return  sb.toString();
    }
    

    StringBuffer的append()方法中都有一个同步块,虚拟机通过逃逸分析技术发现stringBuffer对象的动态作用域都在bufferTest()方法内部,即stringBuffer的所有引用都不会逃逸出bufferTest()方法,因此这里的锁可以被安全的消除掉

  • 逃逸分析技术

    • 分析指针动态范围的方法称之为逃逸分析

    • 逃逸分析技术可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

    • 通过逃逸分析可以判断对象的引用和使用范围从而决定将这个对象分配到堆上面还是栈上

四、锁粗化

锁粗化是指在编译期间将相邻的同步代码块合并成一个大同步块,以此减少反复申请和释放同一个锁对象导致的系统开销,默认开启。

来看一个案例,

  • 锁粗化之前

    public abstract class Tset {
    
        public synchronized void methodA() {
            System.out.println("this is methodA");
        }
        public synchronized void methodB() {
            System.out.println("this is methodB");
    
        }
    }
    

    由于这两个方法的synchronized作用在实例方法上,他们回获取的Test的对象锁,也就是同一把锁,为避免重复申请和释放同一个锁对象导致的系统开销,就会执行锁粗化

  • 锁粗化之后

    synchronized(monitor){
        methodA();
        methodB();
    }
    

    所以平时开发过程中加锁也要适当合理,比如尽量不要在循环内使用锁

五、类锁和对象锁

要分清对象锁还是类锁,首先要搞清楚在什么地方加了锁,拿到的是什么锁,以synchronized为例:

  • 当synchronized作用在实例方法时

    【监视器锁(monitor)便是对象实例(this)】

  • 当synchronized作用在静态方法时】

    【监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代(方法区),即静态方法锁相当于该类的一个全局锁】

  • 当synchronized作用在某一个对象实例时

    【监视器锁(monitor)便是括号括起来的对象实例】

Class A {
    // ==>对象锁:普通实例方法默认同步监视器就是this,即调用该方法的对象
    public synchronized methodA() {
    }
 
    public  methodB() {     
        // ==>对象锁:this表示是对象锁
        synchronized(this){  
        }
    }
 
    // ==>类锁:修饰静态方法
    public static synchronized methodC() {
    }
 
    
    public methodD(){
        // ==>类锁:A.class说明是类锁
        synchronized(A.class){}
    }
 
    // 普通方法:任何情况下调用时,都不会发生竞争
    public methodE(){
    }
}

对象锁占用的资源是对象级别,类锁占有的资源是类级别,类锁跟对象是相互不会阻塞的。比如上述方法中

  • methodA,和methodB都是对当前对象加锁

    • 那么两个线程同时访问同一个对象的methoA或methodB会发生竞争,阻塞。

    • 如果两个线程访问的是不同对象的methodA和methodB则不会发生竞争,不阻塞。

  • methodC和methodD是对类加锁(同一个类的不同对象都是同一把类锁)

    • 两个线程同时访问同一个对象的methodC和methodD会发生竞争,阻塞。

    • 两个线程同时访问不同对象的methodC和methodD是也会发生竞争,阻塞。

  • 如果一个线程访问methodA或methodB,另一个线程访问methodC或methodD,则这两个线程不会发生竞争。因为一个是类锁另一个是对象锁。类锁和对象锁是两个不一样的锁,控制着不同的区域,互不干扰

5种类锁示例

Class A {
    // 普通字符串属性
    private String val;
    // 静态属性
    private static Object staticObj;
 
    // ==>类锁情况1:synchronized修饰静态方法
    public static synchronized methodA() {
    }
 
    public methodB(){
        // ==>类锁情况2:同步块里的对象是类
        synchronized(A.class){}
    }
 
     public methodC(){
         // ==>类锁情况3:同步块里的对象是字符串
        synchronized("A"){}
    }
 
    public methodD(){
        // ==>类锁情况4:同步块里的对象是静态属性
        synchronized(staticObj){}
    }
 
    public methodE(){
        // ==>类锁情况5:同步块里的对象是字符串属性
        synchronized(val){}
    }
}

六、锁优化

锁的优化能提高系统的吞吐量,除了jvm的锁优化之外,我们在日常开发中也要有意识的去优化加锁的代码,主要方法有以下几点:

  • 尽量不要锁住方法,锁代码快
  • 缩小同步代码块,只锁数据
  • 锁中尽量不要再包含锁
  • 将锁私有化,在内部管理锁
  • 进行适当的锁分解

锁的优化策略有:锁消除、锁偏向、自适应自旋锁、锁粗化

七、死锁

两个或以上的进程因为争夺资源而造成互相等待资源的现象称为死锁

7.1死锁产生的四个必要条件

  • 互斥条件

    当资源被一个线程使用(占有)时,别的线程不能使用

  • 持有并等待条件

    当资源请求者在请求或等待其他的资源的同时保持对原有资源的占有

  • 不可剥夺条件

    资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。

  • 环路条件

    两个或多个进程互相持有某些资源,并希望得到对方的资源,线程获取资源的顺序构成了环形链。

    P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路

当上述四个条件都成立的时候,便形成死锁。同时,打破上述任何一个条件,便可让死锁消失

7.2 死锁案例

public class DeadLock {

    //创建两个对象
    static Object a = new Object();
    static Object b = new Object();

    public static void main(String[] args) {
        new Thread(()->{
            synchronized (a) {
                System.out.println(Thread.currentThread().getName()+" 持有锁a,试图获取锁b");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (b) {
                    System.out.println(Thread.currentThread().getName()+" 获取锁b");
                }
            }
        },"A").start();

        new Thread(()->{
            synchronized (b) {
                System.out.println(Thread.currentThread().getName()+" 持有锁b,试图获取锁a");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (a) {
                    System.out.println(Thread.currentThread().getName()+" 获取锁a");
                }
            }
        },"B").start();
    }
}

7.3 死锁的解决办法

根据生产死锁的四个必要条件,只要使用其中之一不能成立,死锁就不会出现。

死锁的解决办法有四种

  • 预防:通过设置限制条件,破坏产生死锁的四个条件中的一个或者几个,来防止发生死锁。

    由于【互斥条件】是由设备的固有特性所决定的,不仅不能改变,相反还应加以保证,因此实际上只有破坏后续三者之一即可

    • 破坏持有并等待条件

      防止部分分配,系统要求任一线程必须预先申请所需要的全部资源,对线程所需要的所有资源一次性分配,只要有一个资源被其他线程占用,就不给当前线程分配任何资源,线程运行期间,不会再请求新的资源。特点:资源严重浪费,线程延迟运行

    • 破坏不可剥夺条件

      一个已经保持了某些资源的进程,当它再提出新的资源要求而不能立即得到满足时,必须释放它已经保持的所有资源,待以后需要时再重新申请。

      此方式要反复地申请和释放资源,而使进程的执行无限地推迟,延长了周转时间,增加了系统的开销,降低了系统吞吐量

    • 破坏环路条件

      采用资源顺序使用法,把系统中所有资源类型线性排队,并按递增规则赋予每类资源以唯一的编号,进程申请资源时,必须严格按资源编号的递增顺序进行,否则系统不予分配。

  • 避免:系统在分配资源时根据资源的使用情况提前作出预测,从而避免死锁的发生。

  • 检测:由软件检查系统中由进程和资源构成的有向图是否构成一个或多个环路,若是,则存在死锁。

  • 解除:与检测死锁相结合的一种措施,用于将识别出该死锁涉及的有关线程全部撤销。

7.4 如何防范死锁

  • 尽量避免使用多个锁

  • 规范的使用多个锁,并设计好锁的获取顺序。

  • 随用随放。即是,手里有锁,如果还要获得别的锁,必须释放全部资源才能各取所需。

  • 规范好循环等待条件。比如,使用超时循环等待,提高程序可控性

7.5 死锁的排查

JDK为排查死锁提供了有 4 种堆栈跟踪工具,这些工具都在JDK的bin目录下:

  • jstack

    需要使用JDK的另外一个工具jps,jps类似于linux中的ps -ef查看进程号,也在JDK的bin下

  • jconsole

  • jvisualvm

  • jmc