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

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

前言

  • 什么是线程安全

  • 常见的线程安全问题

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

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

  • 死锁的必要条件

  • 必要条件的模拟

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


什么是线程安全

来说说关于线程安全 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,它是一个标记位,然后我们新建了 o1o2、作为 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 启动。由于线程 2level 值为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 互斥锁,它的锁对象 O1O2 只能同时被一个线程所获得,所以是满足互斥的条件

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

不仅要请求还要保持。从代码我们可以发现,线程 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 方法,会尝试获取两把锁 O1O2,如果获取成功则判断当前余额是否足以转出,如果不足则会 return。如果余额足够则会转出账户并减余额,对应的给被转入的账户加余额,最后打印成功转账'XX'元

在代码中,首先定义了 int 类型的 flag,它是一个标记位,用于控制不同线程执行不同逻辑。然后建了两个 Account 对象 ab,代表账户,它们最初都有 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 大小来决定获取锁的顺序,就可以确保避免死锁。

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

检测与恢复策略

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

那到底如何解除死锁呢?

  • 线程终止

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

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

第一个考量优先级:

当进程线程终止的时候,会终止优先级比较低的线程。如果是前台线程,那么直接影响到界面的显示,这对用户而言是无法接受的,所以通常来说前台线程优先级会高于后台线程。

第二个考量已占有资源,还需要资源:

如果一个线程占有的很多资源,只差百分之一的资源就可以完成任务,那么这个时候系统可能就不会终止这样的线程额,而是会选择终止其他的线程来有限促进该线程的完成

第三个考量已经运行的时间:

如果一个线程运行很长的时间了,很快就要完成任务,那么突然终止这样的一个线程也不是一个明智的选择,我们可以让那些刚刚开始运行的线程终止,并在之后把它们重新启动起来,这样成本更低。

这里会有各种各样的算法和策略,我们根据实际业务去进行调整就可以了。

  • 方法2——资源抢占

其实第一种方式太暴力了,我们只需要把它已经获得的资源进行剥夺,比如让线程回退几步、 释放资源,这样一来就不用终止掉整个线程了,这样造成的后果会比刚才终止整个线程的后果更小一些,成本更低。

不过这样还是有个缺点,如果我们抢占的线程一直是同一个线程,那么线程也扛不住会出现线程饥饿的情况,这个线程一直被剥夺已经得到的资源,那它将长期得不到运行。

鸵鸟策略

还是从名字出发,鸵鸟嘛,有啥特点?就是当遇到危险的时候就会将头埋到沙子里,这样就看不到危险了。

在低并发的情况下,比如很多内部系统,发生死锁的概率很低,如果即使发生了也不会特别严重,那还花这么多心思去处理它,完全没有必要。


哪些场景需要额外注意线程安全问题?

  • 访问共享变量或资源

上面最开始说的 i++ 就是这样的情况,访问共享变量和共享资源,共享缓存等。这些信息被多个线程操作就可以出现并发读写的情况。

  • 依赖时序的操作

如果在开发的过程中,相应的需求或场景依赖于时序的关系,那么在多线程又不能保障执行顺序和预期一致,这个时候依然要考虑线程安全的问题。如下简单代码

这样先检查再执行的操作不是原子性操作,中间任意一个环节都有可能被打断,检查后的结果可能出现无效,过期的情况,所以,想要获取正确的结果可能取决于时序,所以这种情况需要通过枷锁等方式保护保障操作的原子性。

  • 对方没有声明自己是线程安全的

因为有很多内置的库函数,比如集合中的 ArrayList,本身就不是线程安全的,如果多个线程同时对 ArrayList 进行并发的读写,那就自然有可能出现线程安全问题,从而造成数据出错,这个责任不在于 ArrayList,而是因为它本身就不是并发安全的。我们也可以看看源码中的注释

描述的也很清晰,如果我们要使用 ArrayList 在多线程的场景,请在外部使用 synchronized 等保证并发安全。


多线程会有哪些性能问题

我们经常听到的是通过多线程来提升效率,多个线程同时工作,加快程序运行速度,而这里想说的是多线程会带来哪些问题。单线程是个单身汉儿,啥时候自己干,也不和别人牵扯,可多线程不一样,需要和别人协同办公,既然要协同办公,那就涉及到沟通的成本,这样的调度和合作就会带来性能开销。

哪些可能会有性能开销?

性能开销多种多样,其表现形式也多样。常见的响应慢,内存占用过多都属于性能问题。我们通过购买服务器来横向提升服务器的处理能力,通过购买更大的带宽提升网络处理能力,总是用户是上帝,我们需要想尽一切办法让用户有更好的体验,不卸载,勤分享。

多线程带来哪些开销

第一个就是上面说的信息交互涉及的上下文切换,通常我们会指定一定数量的线程,但是 CPU 的核心又比线程数少,所以无法同时照顾到所有的线程,自然就有一部分线程在某个时间点处于等待的状态

操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。

第二个带来的问题是缓存失效。对了,如果我们把经常使用的比如数据线等物品放在固定的地方,下次需要的时候就不会惊慌失措,浪费时间了。同样的,我们把经常访问的数据缓存起来,下次需要的时候直接取就好了。常见的数据库的连接池,线程池等都有类似的思想。

如果没有缓存,一旦进行了线程调度,切换到其他的线程,CPU 就会去执行其他代码,这时候就可能出现缓存失效了,一旦失效,就要重新缓存新的数据,从而引起开销。所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。

更可怕的是,如果多线程频繁的竞争锁或者 IO 读写,就有可能出现大量的阻塞,此时就可能需要更多的上下文切换,即更大的开销

线程协作带来的开销

很多时候多个线程需要共享数据,为了保证线程安全,就会禁止编译器和 CPU 对其进行重排序等优化,正是因为要同步,需要反复把线程工作内存的数据 flush 到主存中,随后将主存的内容 refresh 到其他线程的工作内存中,等等。

这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。


总结

在本篇文章中,我们首先介绍了什么是死锁,接着介绍了死锁在生活中、两个线程中以及多个线程中的例子。然后我们分析了死锁的影响,在 JVM 中如果发生死锁,可能会导致程序部分甚至全部无法继续向下执行的情况,所以死锁在 JVM 中所带来的危害和影响是比较大的,我们需要尽量避免。最后举了一个必然会发生死锁的例子代码,并且对此代码进行了详细的分析。

另外也学习了解决死锁的策略。从线程发生死锁,到保存重要数据,恢复线上服务。最后也提到了三种修复的策略。

一是避免策略,其主要思路就是去改变锁的获取顺序,防止相反顺序获取锁这种情况的发生;二是检测与恢复策略,它是允许死锁发生,但是一旦发生之后它有解决方案;三是鸵鸟策略。

(0)

相关推荐