专栏名称: 架构师社区
架构师小秘圈,聚集10万架构师的小圈子!不定期分享技术干货,行业秘闻,汇集各类奇妙好玩的话题和流行动向!禁止截图,阅后即焚!
目录
相关文章推荐
看看新闻Knews  ·  22岁小伙从上海出发登黄山失联多天!家属发声 ... ·  18 小时前  
看看新闻Knews  ·  22岁小伙从上海出发登黄山失联多天!家属发声 ... ·  18 小时前  
共同体Community  ·  深圳市第三儿童医院,开业时间定了! ·  昨天  
共同体Community  ·  深圳市第三儿童医院,开业时间定了! ·  昨天  
温州晚报  ·  叹息!她于凌晨去世,年仅28岁 ·  2 天前  
51好读  ›  专栏  ›  架构师社区

字节二面 | 26图揭秘线程安全

架构师社区  · 公众号  ·  · 2021-03-16 11:23

正文

想必都知道线程是什么,也知道怎么用了,但是使用线程的时候总是没有达到自己预期的效果,要么是值不对,要么是无限等待,为了尽全力的避免这些问题以及更快定位所出现的问题,下面我们看看线程安全的这一系列问题

前言

  • 什么是线程安全

  • 常见的线程安全问题

  • 在哪些场景下需要特别注意线程安全

  • 多线程也会带来性能问题

  • 死锁的必要条件

  • 必要条件的模拟

  • 多线程会涉及哪些性能问题


什么是线程安全

来说说关于线程安全 what 这一问题,安全对立面即风险,可能存在风险的事儿我们就需要慎重了。之所以会产生安全问题,无外乎分为主观因素和客观因素。

先来看看大佬们是怎么定义线程安全的。《 Java Concurrency In Practice 》的作者 Brian Goetz 对线程安全是这样理解的,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的 调度和交替 执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。

他所表达的意思为:如果对象是线程安全的,那么对于开发人员而言,就不需要考虑方法之间的 协调 问题,说白了都不需要考虑不能同时写入或读写不能并行的问题,更别说使用各种锁来保证线程安全,所以对于线程的安全还是相当的苛刻。

那么平时的开发过程中,通常会遇到哪些线程安全问题呢

  • 运行结果千奇八怪

最典型了莫过于多个线程操作一个变量导致的结果,这是显然的了

执行结果如下所示

此过程中,你会发现结果几乎每次都不一样,这是为啥呢?

这是因为在多线程的情况下,每个线程都有得到运行的机会,而 CPU 的调度是以 时间片 的方式进行分配,意味着每个线程都可以获取时间片,一旦线程的时间片用完,它将让出 CPU 资源给其他的线程,这样就可能出现线程安全问题。

看似 i++ 一行代码,实际上操作了很多步

  • 读取数据

  • 增加数据

  • 保存

看上面这个图,线程 1 先拿到 i=1 的结果,随后进行 +1 操作,刚操作完还没有保存,此时线程 2 插足, CPU 开始执行线程 2 ,和线程 1 的操作一样,执行 i++ 操作,那对于线程 2 而言,此时的 i 是多少呢?其实还是 1,因为线程 1 虽然操作了,但是没有保存结果,所以对于线程 2 而言,就没看到修改后的结果

此时又切换到线程 1 操作,完成接下来保存结果 2,随后再次切换到线程 2 完成 i=2 的保存操作。总上,按道理我们应该是得到结果 3,最后结果却为 2 了,这就是典型的线程安全问题了


活跃性问题

说活跃性问题可能比较陌生,那我说 死锁 你就知道了,因为确实太常见,面试官可能都把死锁嚼碎了吧,不问几个死锁都仿佛自己不是面试官了,随便抛出来几个问题看看

  • 死锁是什么

  • 死锁必要条件

  • 如何避免死锁

  • 写一个死锁案例

如果此时不知道如何回答,当大家看完下面的内容再回头应该就很清楚,不用死记硬背,理解性的记忆一定是会更长久啦。

死锁是什么

两个线程之间相互等待对方的资源,但又都不互让,如下代码所示

死锁有什么危害

首先我们需要知道,使用锁是不让其他线程干扰此次数据的操作,如果对于锁的操作不当,就可能导致死锁。

描述下死锁

说直白一点,占着茅坑不拉屎。死锁是一种 状态 ,两个或多个线程相互持有相互的资源而不放手,导致大家都得不到需要的东西。小 A 和 小 B谈恋爱,毕业了,一个想去北京,一个想去广东,互不相让让,怎么办?可想而知,两者都想挨着家近一点的地方工作,又舍不得如此美好的爱情

再举一个生活上的例子

A :有本事你上来啊

B: 有本事你下来啊

A: 我不下,有本事你上来啊

B: 我不上,你有本事下来啊

线程 A 和 线程 B的例子

上图两个线程,线程 A 和 线程 B ,线程 A 想要获取线程 B 的锁,当然获取不到,因为线程 B 没有释放。同样的线程 B 想要获取线程 A 也不行,因为线程 A 也没有释放,这样一来,线程 A 和线程 B 就发生了死锁,因为它们都相互持有对方想要的资源,却又不释放自己手中的资源,形成相互等待,而且会一直等待下去。

多个线程导致的死锁场景

刚才的两个线程因为相互等待而死锁,多个线程则形成环导致死锁。

线程 1、2、3 分别持有 A B C 。此时线程 1 想要获取锁 B ,当然不行,因为此时的锁 B 在线程 2 手上,线程 2 想要去获取锁 C ,一样的获取不到,因为此时的锁 C 在线程 3 手上,然后线程 3 去尝试获取锁 A ,当然它也获取不到,因为锁 A 现在在线程 1 的手里,这样线程 A B C 就形成了环,所以多个线程仍然是可能发生死锁的

死锁会造成什么后果

死锁可能发生在很多不同的场景,下面举例说几个

  • JVM

在 JVM 中发生死锁的情况,JVM 不会自动的处理,所以一旦死锁发生就会陷入无穷的等待中

  • 数据库

数据库中可能在事务之间发生死锁。假设此时事务 A 需要多把锁,并一直持有这些锁直到事物完成。事物 A 持有的锁在其他的事务中也可能需要,因此这两个事务中就有可能存在死锁的情况

这样的话,两个事务将永远等待下去,但是对于数据库而言,这样的事儿不能发生。通常会选择放弃某一个事务,放弃的事务释放锁,从而其他的事务就可以顺利进行。

虽然有死锁发生的可能性,但并不是 100% 就会发生。假设所有的锁持有时间非常短,那么发生的概率自然就低,但是在高并发的情况下,这种小的累积就会被放大。

所以想要提前避免死锁还是比较麻烦的,你可能会说上线之前经过压力测试,但仍不能完全模拟真实的场景。这样根据发生死锁的职责不同,所造成的问题就不一样。死锁常常发生于高并发,高负载的情况,一旦直接影响到用户,你懂的!

写一个死锁的例子

上图的注释比较详细了,但是在这里还是梳理一下。

可以看到,在这段代码中有一个 int 类型的 level ,它是一个标记位,然后我们新建了 o1 o2 、作为 synchronized 的锁对象。

首先定义一个 level ,类似于 flag ,如果 level 此时为 1,那么会先获取 o1 这把锁,然后休眠 1000ms 再去获取 o2 这把锁并打印出 「线程1获得两把锁」

同样的,如果 level 为 2,那么会先获取 o2 这把锁,然后休眠 1000ms 再去获取 o1 这把锁并打印出「线程1获得两把锁」

然后我们看看 Main 方法,建立两个实例,随后启动两个线程分别去执行这两个 Runnable 对象并启动。

程序的一种执行结果:

从结果我们可以发现,程序并没有停止且一直没有输出线程 1 获得了两把锁或“线程 2 获得了两把锁”这样的语句,此时这里就发生了 死锁

然后我们对死锁的情况进行分析

下面我们对上面发生死锁的过程进行分析:

第一个线程起来的时候,由于此时的 level 值为1,所以会尝试获得 O1 这把锁,随后休眠 1000 毫秒

线程 1 启动一会儿后进入休眠状态,此时线程 2 启动。由于线程 2 level 值为2,所以会进入 level=2 的代码块,即线程 2 会获取 O2 这把锁,随后进入 1000 毫秒的休眠状态。

线程 1 睡醒(休眠)后,还想去尝试获取 O2 这把锁,由于此时的 02 被线程2使用着,自然线程 1 就无法获取 O2

同样的,线程 2 睡醒了后,想去尝试获取 O1 这把锁, O1 被线程 1 使用着,线程 2 自然获取不到 O1 这把锁。

好了,我们总结下上面的情况。应该是很清晰了,线程 1 拿着 O1 的锁想去获取 O2 的锁,线程 2 呢,拿着 O2 的锁想去获取 O1 的锁,这样一来线程 1 和线程 2 就形成了相互等待的局面,从而形成死锁。想必大家这次就很清晰的能理解死锁的基本概念了,这样以来,要死锁,它的必要条件是什么呢?ok,我们继续往下看。

发生死锁的必要条件

  • 互斥条件

如果不是互斥条件,那么每个人都可以拿到想要的资源就不用等待,即不可能发生死锁。

  • 请求与保持条件

当一个线程请求资源阻塞了,如果不保持,而是释放了,就不会发生死锁了。所以,指当一个线程因请求资源而阻塞时,则需对已获得的资源保持不放

  • 不剥夺条件

如果可剥夺,假设线程 A 需要线程 B 的资源,啪的一下抢过来,那怎么会死锁。所以,要想发生死锁,必须满足不剥夺条件,也就是说当现在的线程获得了某一个资源后,别人就不能来剥夺这个资源,这才有可能形成死锁

  • 循环等待条件

只有若干线程之间形成一种头尾相接的循环等待资源关系时,才有可能形成死锁,比如在两个线程之间,这种“循环等待”就意味着它们互相持有对方所需的资源、互相等待;而在三个或更多线程中,则需要形成环路,例如依次请求下一个线程已持有的资源等

案例解析四大必要条件

上面和大家一起走过这个代码,相信大家都很清晰了,我也将必要的注释放在了代码中,需要的童鞋可以再过一遍。现在我们主要通过这份代码,来分析分析死锁的这四个必要条件

  • 第一个必要条件为 互斥条件

在代码中,很明显,我们使用 了 synchronized 互斥锁,它的锁对象 O1 O2 只能同时被一个线程所获得,所以是满足互斥的条件

  • 第二个必要条件为 请求与保持条件

不仅要请求还要保持。从代码我们可以发现,线程 1 获得 O1 这把锁后不罢休,还要尝试获取 O2 这把锁,此时就被阻塞了,阻塞就算了,它也不会释放 O1 这把锁,意味着对已有的资源保持不放。所以第二个条件也满足了。

第 3 个必要条件是 不剥夺条件 ,在我们这个代码程序中,JVM 并不会主动把某一个线程所持有的锁剥夺,所以也满足不剥夺条件。

第 4 个必要条件是循环等待条件,在我们的例子中,这两个线程都想获取对方已持有的资源,也就是说线程 1 持有 o1 去等待 o2 ,而线程 2 则是持有 o2 去等待 o1 ,这是一个环路,此时就形成了一个循环等待。

这样通过代码的形式,更加深刻的了解死锁问题。所以,在以后再遇到死锁的问题,只要破坏任意一个条件就可以消除死锁,这也是我们后面要讲的解决死锁策略中重点要考虑的内容,从这样几个维度去回答是不是更清晰勒。那如果发生了死锁该怎么处理呢?

发生死锁了怎么处理

既然死锁已经发生了,那么现在要做的当然是止损,最好的办法为保存当前 JVM 日志 等数据,然后重启。

为什么要重启?

我们知道发生死锁是有很多前提的,而且通常情况下是在高并发的情况才会发生死锁,所以重启后发生的几率很小且可以暂时保证当前服务的可用,随后根据保存的信息排查死锁原因,修改代码,随后发布

有哪些修复的策略呢

常见的修复策略有三个, 避免策略 检测与恢复策略 以及 鸵鸟策略 。下面分别说说这三种策略

  • 避免策略

发生死锁的原因无外乎是采用了 相反 的顺序去获取锁,那么就要思考如何将方向掉过来。

下面以转账的例子来看看死锁的形成与避免。

在转账之前,为了保证线程安全通常会获取两把锁,分别为转出的账户与转入的账户。说白了,在没有获取这两把锁的时候,是不能对余额做操作的,即只有获取了这两把锁才会进行接下来的转账操作。看看下面的代码

执行结果如下

在这里插入图片描述

通过之前的代码分析,再看这个代码是不是会简单很多。代码中,定义 int 类型的 flag,不同的 flag 对应不同的执行逻辑,随后建立了两个账户 对象 a 和 对象 b,两者账户最初都为 1000 元。

再来看 run 方法,如果此时 flag 值为 1 ,那么代表着 a 账户会向 b账户转账 100 元,如果 flag 为 0 则表示 b 账户往 a 账户转账 100 元。

再来看 transferMoney 方法,会尝试获取两把锁 O1 O2 ,如果获取成功则判断当前余额是否足以转出,如果不足则会 return。如果余额足够则会转出账户并减余额,对应的给被转入的账户加余额,最后打印成功转账"XX"元

在代码中,首先定义了 int 类型的 flag,它是一个标记位,用于控制不同线程执行不同逻辑。然后建了两个 Account 对象 a b ,代表账户,它们最初都有 1000 元的余额。

再看主函数,分别创建两个对象,并设置 flag 值,传入两个线程并启动,结果如下

呀哈,结果完全正确,符合正常逻辑。那是因为此时对锁的持有时间比较短,释放也快,所以在低并发的情况下不容易发生死锁,下面我们将代码做下调整。

我在两个 synchonized 之间加上一个休眠 Thread.sleep(1000) ,就反复模拟银行转账的网络延迟现象。所以此时的 transferMoney 方法变为这样

可以看到 的变化就在于,在两个 synchronized 之间,也就是获取到第一把锁后、获取到第二把锁前,我们加了睡眠 1000 毫秒的语句。此时再运行程序,会有很大的概率发生死锁,从而导致 控制台中不打印任何语句,而且程序也不会停止

为什么加了一句睡眠时间就可能出现死锁呢。原因就在于有了这个休息时间,让其他的线程有了得逞的机会,想一想什么时候是追下女票最快的方式,哈哈哈哈。

这样,两个线程获取锁的方式是相反的,意味着 第一个线程的“转出账户”正是第二个线程的“转入账户” ,所以我们就可以从这个“相反顺序”的角度出发,来解决死锁问题。,

既然是 相反顺序 ,那我们就想办法控制线程间的执行顺序,这里可以使用 HashCode 的方式,来保证线程安全

修复之后的 transferMoney 方法如下:

上面代码,首先计算出 两个 Account 的 HashCode ,随后根据 HashCode 的大小来决定获取锁的顺序。所以,不管哪个线程先执行,也无论是转出和转入,获取锁的顺序都会严格按照 HashCode 大小来决定,也就不会出现获取锁顺序相反的情况,也就避免了死锁。

除了使用 HashCode 的方式决定锁获取顺序以外 ,不过我们知道还是会存在 HashCode 冲突的情况。所以在实际生产中,排序会使用一个实体类,这个实体类有一个主键 ID ,既然是主键,则有唯一,不重复的特点,所以也就没必要再去计算 HashCode ,这样也更加方便,直接使用它主键 ID 进行排序,由主键 ID 大小来决定获取锁的顺序,从而确保避免死锁。

其实,使用 HashCode 方式有个问题,如果出现 Hash 冲突还有有点麻烦,虽然概率比较低。在实际生产上,通常会排序一个实体类,这个实体类有一个主键 ID ,既然是主键 ID ,也就有唯一,不重复的特点,所以所以如果我们这个类包含主键属性的话就方便多了,我们也没必要去计算 HashCode ,直接使用它的主键 ID 来进行排序,由主键 ID 大小来决定获取锁的顺序,就可以确保避免死锁。

以上我们介绍了死锁的避免策略。

检测与恢复策略

检测与恢复策略,从名字可以猜出,大体意思为可以先让死锁发生,只不过会每次调用锁的时候,记录下调用信息并形成锁的调用链路图,然后每隔一段时间就用死锁检测算法检测下,看看这个图中是否存在环路,如果存在即发生了死锁,就可以使用死锁恢复机制,比如剥夺某个资源来解开死锁并进行恢复。

那到底如何解除死锁呢?

  • 线程终止

第一种解开死锁的方式比较直接,直接让线程或进程终止,这样的话,系统会终止已经陷入死锁的线程,线程终止,释放资源,这样死锁就会解开

当然终止也是要讲究顺序的,不是随便随时终止







请到「今天看啥」查看全文