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

沉默王二

共 3125字,需浏览 7分钟

 ·

2021-05-20 02:55

大家好,我是二哥呀。

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

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

如果需要提升车辆的通行效率,一般的做法就是扩展车道,对应程序来说就是『加线程池』,增加线程数。这样在同一时间内,通行的车辆数远远大于单车道。

然而成年人的世界没有那么完美,车道一旦多起来,『加塞』的现象就会越来越严重,出现碰撞后也会影响整条马路的通行效率。

防止汽车频繁变道加塞可以采取在车道间增加『护栏』,那在程序的世界该怎么做呢?

程序世界中多线程遇到的问题归纳起来就是三类:『线程安全问题』『活跃性问题』『性能问题』

01、线程安全问题

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

1)原子性

举一个银行转账的例子,比如从账户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:对基本数据类型变量的赋值是原子操作;
  • 操作2:包含三个操作,读取i的值,将i加1,将值赋给i;
  • 操作3:读取j的值,将j的值赋给i;
  • 操作4:包含三个操作,读取i的值,将i加1,将值赋给i;

在单线程环境下上述四个操作都不会出现问题,但是在多线程环境下,如果不加锁的话,往往可能得到意料之外的值。

通常,在Java中,可以使用synchronize或者lock来保证原子性。

2)可见性

talk is cheap,先show一段代码:

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也是可以保证可见性的,加锁可以保证在同一时刻只有一个线程在执行同步代码块,释放锁之前会将变量刷回至主存,这样也就保证了可见性。

02、活跃性问题

为了解决可见性问题,我们可以采取加锁的方式解决,但是如果加锁使用不当也容易引入其他问题,比如『死锁』。

在说『死锁』前我们先说说另外一个问题:活跃性问题

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

活跃性问题具体可以划分为这样几类:死锁活锁饥饿问题

1)死锁

死锁是指多个线程因为环形的等待锁关系而永远的阻塞下去。

2)活锁

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

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

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

3)饥饿

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

常见有2种场景:

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

有一个非常经典的饥饿问题就是哲学家用餐问题,如下图所示,有五个哲学家在用餐,每个人必须要同时拿两把叉子才可以开始就餐,如果哲学家1和哲学家3同时开始就餐,那哲学家2、4、5就得饿肚子等待了。

03、性能问题

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

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

线程创建完之后,还会遇到线程上下文切换

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

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

简单总结下,多线程用好了可以让程序的效率成倍提升,用不好可能比单线程还要慢。用一张图总结一下上面讲的:

我是二哥呀,下期见~


浏览 24
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报