十张图告诉你Java多线程那些破事

这是我参与更文挑战的第N天,活动详情查看: 更文挑战java

头发不少的程序员:『师父,这个批量处理接口太慢了,有什么办法能够优化?』

架构师:『试试使用多线程优化』

次日

头发不少的程序员:『师父,我已经使用了多线程,为何接口还变慢了?』

架构师:『去给我买杯咖啡,我写篇文章告诉你』

……吭哧吭哧买咖啡去了
复制代码

在实际工做中,错误使用多线程非但不能提升效率还可能使程序崩溃。以在路上开车为例:git

在一个单向行驶的道路上,每辆汽车都遵照交通规则,这时候总体通行是正常的。『单向车道』意味着『一个线程』,『多辆车』意味着『多个job任务』。程序员

单线程顺利同行
单线程顺利同行

若是须要提高车辆的同行效率,通常的作法就是扩展车道,对应程序来讲就是『加线程池』,增长线程数。这样在同一时间内,通行的车辆数远远大于单车道。面试

多线程顺利同行
多线程顺利同行

然而成年人的世界没有那么完美,车道一旦多起来『加塞』的场景就会愈来愈多,出现碰撞后也会影响整条马路的通行效率。这么一对比下来『多车道』确实可能比『单车道』要慢。算法

多线程故障
多线程故障

防止汽车频繁变道加塞能够采起在车道间增长『护栏』,那在程序的世界该怎么作呢?编程

程序世界中多线程遇到的问题概括起来就是三类:『线程安全问题』『活跃性问题』『性能问题』,接下来会讲解这些问题,以及问题对应的解决手段。安全

线程安全问题

有时候咱们会发现,明明在单线程环境中正常运行的代码,在多线程环境中可能会出现意料以外的结果,其实这就是你们常说的『线程不安全』。那到底什么是线程不安全呢?往下看。markdown

原子性多线程

举一个银行转帐的例子,好比从帐户A向帐户B转1000元,那么必然包括2个操做:从帐户A减去1000元,往帐户B加上1000元,两个操做都成功才意味着一次转帐最终成功。架构

试想一下,若是这两个操做不具有原子性,从A的帐户扣减了1000元以后,操做忽然终止了,帐户B没有增长1000元,那问题就大了。

银行转帐这个例子有两个步骤,出现了意外后致使转帐失败,说明没有原子性。

原子性:即一个操做或者多个操做 要么所有执行而且执行的过程不会被任何因素打断,要么就都不执行。

原子操做:即不会被线程调度机制打断的操做,没有上下文切换。

在并发编程中不少操做都不是原子操做,出个小题目:

i = 0// 操做1
i++;   // 操做2
i = j; // 操做3
i = i + 1// 操做4
复制代码

上面这四个操做中有哪些是原子操做,哪些不是的?不熟悉的人可能认为这些都是原子操做,其实只有操做1是原子操做。

  • 操做1:对基本数据类型变量的赋值是原子操做;
  • 操做2:包含三个操做,读取i的值,将i加1,将值赋给i;
  • 操做3:读取j的值,将j的值赋给i;
  • 操做4:包含三个操做,读取i的值,将i加1,将值赋给i;

在单线程环境下上述四个操做都不会出现问题,可是在多线程环境下,若是不经过加锁操做,每每可能获得意料以外的值。

在Java语言中经过可使用synchronize或者lock来保证原子性。

可见性

talk is cheap,先show一段代码:

/**
* Author: leixiaoshuai
*/

class Test {
  int i = 50;
  int j = 0;
  
  public void update() {
    // 线程1执行
    i = 100;
  }
  
  public int get() {
    // 线程2执行
    j = i;
    return j;
  }
}
复制代码

线程1执行update方法将 i 赋值为100,通常状况下线程1会在本身的工做内存中完成赋值操做,却没有及时将新值刷新到主内存中。

这个时候线程2执行get方法,首先会从主内存中读取i的值,而后加载到本身的工做内存中,这个时候读取到i的值是50,再将50赋值给j,最后返回j的值就是50了。本来指望返回100,结果返回50,这就是可见性问题,线程1对变量i进行了修改,线程2没有当即看到i的新值。

可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其余线程可以当即看获得修改的值。

如上图每一个线程都有属于本身的工做内存,工做内存和主内存间须要经过store和load等进行交互。

为了解决多线程可见性问题,Java语言提供了volatile这个关键字。当一个共享变量被volatile修饰时,它会保证修改的值会当即被更新到主存,当有其余线程须要读取时,它会去内存中读取新值。而普通共享变量不能保证可见性,由于变量被修改后何时刷回到主存是不肯定的,另一个线程读的可能就是旧值。

固然Java的锁机制如synchronize和lock也是能够保证可见性的,加锁能够保证在同一时刻只有一个线程在执行同步代码块,释放锁以前会将变量刷回至主存,这样也就保证了可见性。

关于线程不安全的表现还有『有序性』,这个问题会在后面的文章中深刻讲解。

活跃性问题

上面讲到为了解决可见性问题,咱们能够采起加锁方式解决,可是若是加锁使用不当也容易引入其余问题,好比『死锁』。

在说『死锁』前咱们先引入另一个概念:活跃性问题

活跃性是指某件正确的事情最终会发生,当某个操做没法继续下去的时候,就会发生活跃性问题。

概念是否是有点拗口,若是看不懂也不要紧,你能够记住活跃性问题通常有这样几类:死锁活锁饥饿问题

(1)死锁

死锁是指多个线程由于环形的等待锁的关系而永远的阻塞下去。一图胜千语,很少解释。

(2)活锁

死锁是两个线程都在等待对方释放锁致使阻塞。而活锁的意思是线程没有阻塞,还活着呢。

当多个线程都在运行而且修改各自的状态,而其余线程彼此依赖这个状态,致使任何一个线程都没法继续执行,只能重复着自身的动做和修改自身的状态,这种场景就是发生了活锁。

![](/Users/ray/Library/Application Support/typora-user-images/image-20210408232019843.png)

若是你们还有疑惑,那我再举一个生活中的例子,你们平时在走路的时候,迎面走来一我的,两我的互相让路,可是又同时走到了一个方向,若是一直这样重复着避让,这俩人就是发生了活锁,学到了吧,嘿嘿。

(3)饥饿

若是一个线程无其余异常却迟迟不能继续运行,那基本是处于饥饿状态了。

常见有几种场景:

  • 高优先级的线程一直在运行消耗CPU,全部的低优先级线程一直处于等待;
  • 一些线程被永久堵塞在一个等待进入同步块的状态,而其余线程老是能在它以前持续地对该同步块进行访问;

有一个很是经典的饥饿问题就是哲学家用餐问题,以下图所示,有五个哲学家在用餐,每一个人必需要同时拿两把叉子才能够开始就餐,若是哲学家1和哲学家3同时开始就餐,那哲学家二、四、5就得饿肚子等待了。

性能问题

前面讲到了线程安全和死锁、活锁这些问题会影响多线程执行过程,若是这些都没有发生,多线程并发必定比单线程串行执行快吗,答案是不必定,由于多线程有建立线程线程上下文切换的开销。

建立线程是直接向系统申请资源的,对操做系统来讲建立一个线程的代价是十分昂贵的,须要给它分配内存、列入调度等。

线程建立完以后,还会遇到线程上下文切换

CPU是很宝贵的资源速度也很是快,为了保证雨露均沾,一般为给不一样的线程分配时间片,当CPU从执行一个线程切换到执行另外一个线程时,CPU 须要保存当前线程的本地数据,程序指针等状态,并加载下一个要执行的线程的本地数据,程序指针等,这个开关被称为『上下文切换』。

通常减小上下文切换的方法有:无锁并发编程CAS 算法使用协程等。

有态度的总结

多线程用好了可让程序的效率成倍提高,用很差可能比单线程还要慢。

用一张图总结一下上面讲的:

image-20210412234350204

文章讲了多线程并发会遇到的问题,你可能也发现了,文章中并无给出具体的解决方案,由于这些问题在Java语言设计过程当中大神都已经为你考虑过了。

Java并发编程学起来有必定难度,但这也是从初级程序员迈向中高级程序员的必经道路,接下来的文章会带领你们逐个击破!

做者:雷小帅

Github 『Java八股文』开源项目做者,专一Java面试套路,Java进阶学习,打破内卷拿大厂Offer,升职加薪!

做者简介:

☕读过几年书:华中科技大学硕士毕业;

😂浪过几个大厂:华为、网易、百度……

😘一直坚信技术能改变世界,愿保持初心,加油技术人!

相关文章
相关标签/搜索