目录

前言:

1.常见的锁策略

1.1乐观锁和悲观锁

1.2轻量级锁和重量级锁

1.3自旋锁和挂起等待锁

1.4互斥锁与读写锁

1.5可重入锁与不可重入锁

1.6公平锁与非公平锁

2.CAS

2.1什么是CAS

2.2自旋锁的实现

2.3原子类

3.synchronized

3.1synchronized的原理以及基本特点

3.2偏向锁

3.3轻量级锁

3.4重量级锁

3.5锁消除

3.6锁粗化

4.JUC

4.1JUC中常见到组件

4.1.1callable接口的用法

4.1.2ReentrantLock可重入互斥锁

4.1.3信号量Semaphore

4.1.4CountDownLatch

5.线程安全的集合类

5.1HashTable

5.2ConcurrentHashMap

5.3ConcurrentHashMap与HashMap的区别

6.死锁

6.1什么是死锁

6.2哲学家就餐问题

结束语:


前言:

在上一节中小编主要是与大家分享了多线程中的线程池的概念以及使用方式,在之前的多线程博客中也个大家介绍了多线程中的一些基础知识,希望大家下去之后多多练习,巩固一下,那么从这节开始小编就开始给大家介绍多线程中的最后一点知识,虽然这些东西不常使用,但是我们还是需要稍微的理解一下的。话不多说我们直接步入正题吧!

1.常见的锁策略

以下介绍的锁策略不只是针对java的,别的语言别的工具也会涉及到锁,也同样适合用。

1.1乐观锁和悲观锁

锁的实现者会预测接下来锁冲突的概率是大还是不大,根据这个冲突的概率来决定接下来该咋做。这里的锁的冲突就是锁竞争,两个线程在针对一个对象加锁,另一个就会产生阻塞等待。

这里我们就根据预测锁冲突的概率大小的问题来将锁分为乐观锁和悲观锁。

  • 乐观锁:预测接下来冲突的概率不大。假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式的对数据是否产生并发冲突进行检测,如果发生并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
  • 悲观锁:预测接下来的冲突概率比较大。总是假设最坏的情况,每次去拿数据的时候都认为别人会去修改,所以在每次拿数据的时候都会锁上,这样别人想拿数据就会阻塞直到它拿到锁。

通常来说悲观锁做的工作要多一些,但是效率会更低一些,而乐观锁做的工作少一些,但是工作的效率会高一些。

1.2轻量级锁和重量级锁

  • 轻量级锁:加锁解锁过程更快更高效。少量的内核态用户态切换,不太容易引起线程调度。
  • 重量级锁:加锁解锁过程更慢更低效。大量的内核态用户态切换。很容易引发线程调度。

和乐观和悲观虽然不是一回事,但是也有一定的重合,一个乐观锁很有可能也是一个轻量级锁,一个悲观锁很可能也是一个重量级锁。

1.3自旋锁和挂起等待锁

  • 自旋锁:他是轻量级锁的一种典型实现。在锁竞争中,如果获取锁失败,立即再次尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。
  • 挂起等待锁:是重量级锁的一种典型实现。还是上面的锁竞争,如果第一次尝试加锁失败,就会进入阻塞状态,需要过很久才会再次调度。

自旋锁是一种典型的轻量级锁的实现方式。

  • 优点:没有放弃CPU,不涉及线程阻塞和调度,一旦被释放就能第一时间获取到锁。
  • 缺点:如果锁被其他线程持有的时间比较久,那么就会持续消耗CPU资源(而挂起等待的时候是不消耗CPU的)。

其中我们之前学习的synchronized即是悲观锁也是乐观锁,即是轻量级锁也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。

那么究竟什么时候是乐观锁,什么时候是悲观锁,什么时候是轻量级锁,什么时候是重量级的锁,这里synchronized会根据当前锁竞争的激烈程度自适应。如果锁冲突不激烈,就以轻量级/乐观锁的状态运行,如果锁冲突激烈,以重量级锁/悲观锁的状态运行。

1.4互斥锁与读写锁

  • 互斥锁:像我们之前学习的synchronized就是一种互斥锁,synchronized只有两个操作,进去代码块就加锁,出代码块就解锁。
  • 读写锁:就是对读操作和写操作分别进行加锁。读写锁有三步:给读加锁,给写加锁,解锁。

在读写锁中约定:

  • 读锁和写锁之前不会产生锁竞争,不会产生阻塞等待。
  • 写锁和写锁之间有所竞争。
  • 读锁和写锁之间也有锁竞争。

读写锁特别适合于“频繁读,不频繁写”的场景中。

1.5可重入锁与不可重入锁

  • 可重入锁:如果一个锁,在一个线程中,连续对该锁连续两次或者是多次加锁,不发生死锁,就叫可重入锁。
  • 不可重入锁:如果在上述连续加锁两次或者是多次的情况下发生了死锁就叫不可重入锁。

在我们Java里面只要是以Reentrant开头命名的锁都是可重入锁,而且JDK提供所有现成的Lock实现类,包括synchronized关键字都是可重入锁。

1.6公平锁与非公平锁

  • 公平锁:遵守“先来后到”,就是公平锁。
  • 非公平锁:不遵守“先来后到”的就是非公平锁。

注意:

  • 操纵系统内部的线程调度就可以视为是随机的,如果不作任何额外的限制,锁就是非公平的,如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序。
  • 公平锁和非公平锁没有好坏之分,关键还是看适合场景。
  • 我们之前学习的synchronized就是一个非公平锁。

2.CAS

2.1什么是CAS

CAS的全称是:Compareandswap,字面意思就是“比较并交换”,一个CAS涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。步骤如下所示:

  1. 比较A与V是否相等。(比较)
  2. 如果比较相等,将B写入V。(交换)
  3. 返回操作是否成功。

CAS就相当于是给我们打开了新世界的大门让我们不需要加锁,就能够保证线程的安全。基于CAS可以实现很多操作,具体的我们往下看。

2.2自旋锁的实现

伪代码如下所示:

public class SpinLock {private Thread owner = null;public void lock(){//通过CAS看当前锁是否被某个线程持有。//如果这个锁已经被别的线程持有,那么就自旋等待。//如果这个锁没有被别的线程持有,那么就把owner设为当前尝试加锁的线程。while (!CAS(this.owner,null,Thread.currentThread())){}}public void unlock() {this.owner = null;}}

在上述代码中如果当前owner是null,比较就成功,就把当前线程的引用设置到owner中,加锁完成,循环就会结束,如果比较不成功,意味着owner非空,锁已经有线程持有了,此时CAS就啥也不干,直接返回false,循环继续。此时的循环就会转的飞快,不停的尝试询问这里的锁是不是释放了,它的好处就是一旦释放,就会立即获取到,坏处就是CPU此时就会处于一种忙等的状态。

2.3原子类

在标准库中提供了java.util.concurrent.atomic包,里面的都是基于这种方式来实现的,典型的类就是:AtomicInteger类,其中getAndIncrement就相当于是++操作,他就能够保证在++和 — 的时候线程是安全的。

图例演示:
先读入内存中:

将自增的结果写回到CPU中。

另一个先判断要自增的数据是不是和之前读取到的数据一样,如果不一样则重新读入,再自增。

接下来我们就用代码给大家具体来演示一下:

代码展示:

package Thread;import java.util.concurrent.atomic.AtomicInteger;public class ThreadTest02 {public static void main(String[] args) throws InterruptedException {AtomicInteger num = new AtomicInteger(0);Thread t1 = new Thread(() -> {for (int i = 0; i  {for (int i = 0; i < 5000; i++) {//实现自增num.getAndIncrement();}});t1.start();t2.start();t1.join();t2.join();//get来获取到数值System.out.println(num.get());}}

结果展示:

3.synchronized

3.1synchronized的原理以及基本特点

上面也给大家大概的有提到synchronized的基本特点,这里小编再给大家总结一下:

synchronized的特点:

  • 即是乐观锁又是悲观锁。开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
  • 即是轻量级锁又是重量级锁。开始时轻量级锁,如果锁被持有时间较长,就转换为重量级锁。
  • 轻量级锁基于自旋锁的实现,重量级锁基于挂起等待锁的实现。
  • 不是读写锁。
  • 是可重入锁。
  • 是非公平锁。

JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态,会根据情况进行依次升级。

synchronized的关键策略:锁升级

加锁的工作过程如下所示:

下面我们来给大家来分别解释一下上面的锁都是什么。

3.2偏向锁

第一个尝试加锁的线程,优先进入偏向锁的状态。偏向锁不是真的“加锁”,只是给对象头中做一个“偏向锁的标记”,记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用子啊进行其他操作了(避免了加锁解锁的开销)如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于那哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。

偏向锁的本质上相当于“延迟加锁”,能不加锁就不加锁,“非必要,不加锁”,尽量避免不必要的加锁开销。但是该做的标记还是得做,否则无法区分何时需要真正加锁。

3.3轻量级锁

随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁),此处的轻量级锁就是通过CAS来实现。

  • 通过CAS检查并更新一块内存(比如null =>该线程引用)
  • 如果更新成功,则认为加锁成功。
  • 如果更新失败,则认为锁被占用,继续自旋式的等待(并不放弃CPU)。

3.4重量级锁

如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁。

  • 执行加锁操作,先进入内核态。
  • 在内核态判定当前锁是否已经被占用。
  • 如果该锁没有占用,则加锁成功,并切回用户态。
  • 如果该锁被占用,则加锁失败,此时线程进入锁的等待的队列挂起,等待被操作系统唤醒。
  • 经历了一系列的沧海桑田,这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,尝试重新获取锁。

3.5锁消除

锁消除也是“非必要,不加锁”,他是在编译阶段做的优化手段,用来检测当前代码是否是多线程执行/是否有必要加锁,如果不必要,又已经把锁给写了,那么就会在编译阶段中将锁自动去掉。

3.6锁粗化

首先来解释一下什么是锁是粒度,简单来说就是在synchronized中包含代码的多少,如果包含的代码越多,粒度越粗,越少则粒度越细。一般我们在写代码的时候多数情况下是希望粒度更小一点,(串行执行的代码少,并发执行的代码就多,效率就高)。下面我们可以画个图来解释一下。

就比如说在公司中如果你要给领导汇报工作,两种情况:

情况1:
先打电话汇报工作A的进展,挂电话。

再打电话汇报工作B的进展,挂电话。

最后打电话汇报工作C的进展,挂电话。

情况2:

打一个电话汇报工作A、B、C的进展,挂电话。

上述的情况1就相当于粒度细的,情况2相当于是粒度粗的情况。

实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁,这种情况下JVM就会自动把锁粗化,避免频繁申请释放锁。

4.JUC

JUC是java.util.concurrent的缩写。下面我来看下在里面都有哪些常用到的组件吧!

4.1JUC中常见到组件

4.1.1callable接口的用法

Callable是一个interface,相当于把线程封装了一个“返回值”,方便程序猿借助多线程的方式计算结果。

他会让你重写call方法,在上述中泛型的参数是啥,call反回的就是啥。

下面我们来写一个代码,创建一个线程,用这个线程来计算:1 + 2 + 3…..+1000。

代码展示:

package Thread;import java.util.concurrent.Callable;import java.util.concurrent.ExecutionException;import java.util.concurrent.FutureTask;public class ThreadTest03 {public static void main(String[] args) throws ExecutionException, InterruptedException {//这里只是创建了一个任务Callable callable = new Callable() {@Overridepublic Integer call() throws Exception {int sum = 0;for (int i = 1; i <= 1000; i++) {sum += i;}return sum;}};//创建完任务之后我们还需要找个人来执行这个任务。//Thread不能直接传callable,需要再来包装一层。FutureTask futureTask = new FutureTask(callable);Thread t = new Thread(futureTask);t.start();System.out.println(futureTask.get());}}

结果展示:

在上述代码中需要我们注意的是FutureTask这个类的使用。

这里面它将我们上述创建的任务传递给futuretask,这里的futuretask就像是我在吃饭的时候服务员一般会给我们一张小票,然后等待饭做好之后我们在凭借着这张小票来获取我们的饭菜,这里也一样,执行完任务之后,我们就可以凭借着futuretask来获取我们的任务的结果了。这里的futuretask有一个方法是get,通过get方法就可以获取到我们上述任务call方法的返回值了。

在之前我们给大家交代了三种创建多线程的方法:继承自Thread,实现Runable,基于lambda三种方法,这里我们又学习了Callable。

4.1.2ReentrantLock可重入互斥锁

我们之前学习的synchronized关键字是基于代码块的方式来控制加锁解锁的。ReentrantLock则是提供了lock和unlock独立的方法,来进行加锁和解锁的。虽然我们在大部分情况下使用synchronized就足够了,但是此处的ReentrantLock也是一个重要的补充。

synchronized与ReentrantLock的区别:

  • synchronized只是加锁和解锁,加锁的时候如果发现锁被占用,只能阻塞式等待。ReentrantLock还提供了一个tryLock方法,如果加锁成功,就没啥特别的,如果加锁失败,是不会阻塞的,直接会返回false,直接把问题抛给程序猿,让程序猿来灵活的控制。
  • synchronized是一个非公平锁(概率不等,不遵守先来后到的)。而ReentrantLock提供了公平和非公平两种工作模式(在构造方法中,传入true开启公平锁)。
  • synchronized搭配waitnotify进行等待唤醒,如果多个线程同时wait同一个对象,notify的时候是随机唤醒一个,而ReentrantLock则是搭配Condition这个类,这个类也能起到等待通知,可以使得功能更强大。

4.1.3信号量Semaphore

信号量,用来表示“可用资源的个数”,本质上就是一个计数器。可以把信号量想象成是停车场的展示牌,当前有车位100个,表示有100个可用资源。当有车开进去的时候,就相当于申请一个可用资源,可用车位就-1(这个称为信号量的P操作),当有车开出来的时候,就相当于释放了一个可用资源,可用车位就+1(这个称为信号量的V操作),如果计数器的值已近为0了,还尝试申请资源,就会阻塞等待,直到其他线程释放资源。

Semaphore的PV操作中的加减计数器操作都是原子的,可以子啊多线程环境下直接使用。

4.1.4CountDownLatch

CountDownLatch操作就是等待N个任务执行结束。好像跑步比赛,10位选手依次就位,哨声吹响之后才能出发,当所有选手都通过终点线之后,才算比赛结束。

5.线程安全的集合类

5.1HashTable

我们之前学习过HashMap,他在多线程的环境下是线程不安全的。这里我们就可以使用HashTable来保证线程的安全性。

不过我们进入HashTable的原码中可以发现他只是在关键方法上加上了synchronized。如下所示:

HashTable是针对整个哈希表进行加锁,任何增删查改操作,都会被触发加锁,也就都会可能有锁竞争。

所以接下来我们还有另一种解决办法就是在使用ConcurrentHashMap。

5.2ConcurrentHashMap

相比于HashTable作出了一系列的改进和优化,我们这里以Java1.8为例。

  • 读操作没有加锁,但是使用了volatile保证从内存中读取结果,只对写操作进行加锁,加锁的方式仍然是用synchronized,但是不是整个对象加锁,而是“锁桶”(用每一个链表的头结点作为锁对象),大大降低了锁冲突的概率。
  • 充分利用CAS特性,比如size属性通过CAS来更新,避免出现重量级锁的情况。
  • 优化了扩容的方式:化整为零
  • 发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去。
  • 扩容期间,新老数据同时存在
  • 后续每个来操作ConcurrentHashMap的线程,都会参与搬家的过程,每个操作负责搬运一小部分元素。
  • 搬完最后一个元素在把老数组删掉。
  • 这个期间,插入只往新数组加。
  • 这个期间,查找需要同时查新数组和老数组。

ConcurrentHashMap不只是一把锁,它是将每一个链表的头结点作为一把锁,每一次进行操作,都是针对对应的链表的锁进行加锁,操作不同链表就是针对不同的锁加锁,就不会有锁冲突了。

5.3ConcurrentHashMap与HashMap的区别

  • HashMap:是线程不安全的,key允许为null。
  • HashTable:线程安全,使用synchronized锁HashTable对象,效率比较低,key不允许为null。
  • ConcurrentHashMap:线程安全,使用synchronized锁每一个链表的头结点,锁冲突效率低,充分利用CAS机制,优化了扩容方式,key不允许为null。

6.死锁

6.1什么是死锁

死锁是这样的一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止。

关于死锁的情况:

  • 一个线程,一把锁,可重入锁没事,不可重入锁就会出现死锁。
  • 两个线程,两把锁,即使是可重入锁也会死锁。
  • N个线程,M把锁,线程数量和锁数量更多了就更容易出现死锁了。

6.2哲学家就餐问题

此时就会出现一个经典的问题:哲学家就餐问题。如下所示:

  • 有一个桌子,围着一圈哲学家,桌子中间放着一盘面,每个哲学家两两之间,放着一根筷子,每个哲学家只做两件事情:思考人生或者是吃面,思考人生的时候就放下筷子,吃面条的时候就会拿起左右两边的筷子(先拿起左边,再拿起右边)。

  • 如果哲学家发现筷子拿不起来(被别人占用了),就会阻塞等待。

  • 假设同一时刻,五个哲学家同时拿起左手边的筷子,然后再尝试拿右手边的筷子,就会发现右手边的筷子都被占用了,由于哲学家们都不互让,这个时候就形成了死锁。

死锁的四个必要条件:

  • 互斥使用:一个线程拿到一把锁之后,另一个线程不能使用。(锁的基本特点)
  • 不可抢占:一个线程拿到锁,只能自己主动释放,不能是被其他线程强行占有。(锁的基本特点)
  • 请求和保持:“吃着碗里的,想着锅里的”,即当资源请求者在请求其他资源的同时保持原有资源的占有。
  • 循环等待:即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源,这样就形成了一个等待环路。举一个例子:“家门钥匙锁车里了,车钥匙锁子在家里了”。

注意:上述的死锁的条件缺一不可!!!

死锁是一个比较严重的bug,那么在实践中我们该如何避免出现死锁呢?

一个简单那有效的方法就是破解循环等待的这个条件。我们可以针对锁进行编号,如果需要同时获取多把锁,约定加锁的顺序,务必是先对小的编号加锁,后对大的编号加锁。

比如上述哲学家就餐问题,我们可以对筷子编号,然后让哲学家每次的取的时候先取编号较小的,然后在取编号大的。

如下所示:

如果从5号滑稽开始拿筷子,此时5号滑稽先拿起4号筷子,然后再拿起5号筷子就餐,然后4号滑稽拿起3号筷子,3号滑稽拿起2号筷子,2号滑稽拿起1号筷子,此时1号滑稽要想拿起筷子就需要等到2号滑稽就餐完释放筷子之后在拿起筷子就餐,所以此时1号滑稽就得阻塞等待。此时就不会造成死锁了。

结束语:

这节中小编主要是与大家分享了多线程中的最后一些知识点,可能有些还没有给大家讲解清楚,不影响在后期的学习中小编会一一给大家交代的,希望这节对大家深入了解多线程有一定帮助,想要学习的同学记得关注小编和小编一起学习吧!如果文章中有任何错误也欢迎各位大佬及时为小编指点迷津(在此小编先谢过各位大佬啦!)