(四)并发编程带来了哪些问题?

4.1 引入

在一定场景下,使用多线程会给我们日常工作带来很多的便利,但并不是在任何场景下都适用的。错误地使用多线程不仅不能提高效率,还可能使程序崩溃。

以路上开车为例:在一个单向行驶的道路上,如果每辆车都遵守交通规则行驶,这时候的整体通行是正常的。其中,『单向车道』意味着『一个线程』,『多辆车』意味着『一个线程中的多个 job 任务』。

在这里插入图片描述
如果需要提升车辆的同行效率,一般的做法就是扩展车道,对应着程序来说就是『加线程池』,增加线程数。这样,在同一时间内,通行的车辆数就远远大于了单车道。
在这里插入图片描述
然而,车道一旦多起来,也就意味着『加塞』的场景可能就会越来越多。出现碰撞后也会影响整条马路的通行效率。这么一对比,『多车道』确实可能比『单车道』要慢。
在这里插入图片描述
防止汽车频繁变道加塞情况的发生,可以采取在车道间增加『护栏』,以保证多个车道互不影响。

在程序中该如何实现呢?在程序中多线程遇到的问题归纳起来就是三类:线程安全问题活跃性问题性能问题

4.2 线程安全问题

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

01、原子性

银行转账例子:账户 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. 操作2:包含三个操作,读取 i 的值,将 i 加 1,将值赋给 i;
  3. 操作3:读取 j 的值,将 j 的值赋给 i;
  4. 操作4:包含三个操作,读取 i 的值,将 i 加 1,将值赋给 i。

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

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

02、可见性

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 对变量进行了修改,线程 2 没有立即看到 i 的新值。

可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

在这里插入图片描述
如图所示,每个线程都有属于自己的工作内存,工作内存和主内存之间需要通过 store 和 load 等进行交互。

为了解决多线程的可见性问题,Java 语言提供了 volatile 关键字。当一个共享变量被 volatile 关键字修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通共享变量不能保证可见性,因为变量被修改后什么时候回到主存是不确定的,另外一个线程读的可能就是旧值。

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

4.3 活跃性问题

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

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

01、死锁

死锁是指多个线程因为环形的等待锁的关系而永远的阻塞下去。
在这里插入图片描述

02、活锁

死锁是两个线程都在等待对方释放锁导致阻塞。而活锁中的线程并没有阻塞。当多个线程都在运行并且修改各自的状态,而其他线程彼此依赖这个状态,导致任何一个线程都无法继续执行,只能重复着自身的动作和修改自身的状态,这种场景就是发生了活锁。
在这里插入图片描述
如上图所示,两辆车迎面相走并互相谦让,但又有同时走到了一个方向。如果一直这样重复着避让,这两个人就发生了活锁。

03、饥饿

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

常见有几种场景:

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

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

在这里插入图片描述

4.4 性能问题

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

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

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

一般减少上下文切换的方法有:

  1. 无锁并发编程:可以参照 concurrentHashMap 锁分段的思想,不同的线程处理不同的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
  2. CAS 算法,利用 Atomic 下使用 CAS 算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换。
  3. 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态。
  4. 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

总之,多线程用好了可以让程序的效率成倍提升,用不好可能比单线程还要慢。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
第1节你真的了解并发吗? [免费观看][免费观看] 00:27:48分钟 | 第2节理解多线程与并发的之间的联系与区别 [免费观看] 00:11:59分钟 | 第3节解析多线程与多进程的联系以及上下文切换所导致资源浪费问题 [免费观看] 00:13:03分钟 | 第4节学习并发个阶段并推荐学习并发的资料 [免费观看] 00:09:13分钟 | 第5节线程的状态以及各状态之间的转换详解00:21:56分钟 | 第6节线程的初始化,中断以及其源码讲解00:21:26分钟 | 第7节多种创建线程的方式案例演示(一)带返回值的方式00:17:12分钟 | 第8节多种创建线程的方式案例演示(二)使用线程池00:15:40分钟 | 第9节Spring对并发的支持:Spring的异步任务00:11:10分钟 | 第10节使用jdk8提供的lambda进行并行计算00:14:22分钟 | 第11节了解多线程所带来的安全风险00:13:16分钟 | 第12节从线程的优先级看饥饿问题00:18:42分钟 | 第13节从Java字节码的角度看线程安全性问题00:25:43分钟 | 第14节sy nchronized保证线程安全的原理(理论层面)00:13:59分钟 | 第15节synchronized保证线程安全的原理(jvm层面)00:25:03分钟 | 第16节单例问题与线程安全性深入解析00:27:15分钟 | 第17节理解自旋锁,死锁与重入锁00:24:58分钟 | 第18节深入理解volatile原理与使用00:28:30分钟 | 第19节JDK5提供的原子类的操作以及实现原理00:27:10分钟 | 第20节Lock接口认识与使用00:19:54分钟 | 第21节手动实现一个可重入锁00:26:31分钟 | 第22节AbstractQueuedSynchronizer(AQS)详解00:49:04分钟 | 第23节使用AQS重写自己的锁00:31:04分钟 | 第24节重入锁原理与演示00:12:24分钟 | 第25节读写锁认识与原理00:18:04分钟 | 第26节细读ReentrantReadWriteLock源码00:30:38分钟 | 第27节ReentrantReadWriteLock锁降级详解00:13:32分钟 | 第28节线程安全性问题简单总结00:15:34分钟 | 第29节线程之间的通信之wait/notify00:32:12分钟 | 第30节通过生产者消费者模型理解等待唤醒机制00:20:50分钟 | 第31节Condition的使用及原理解析00:17:40分钟 | 第32节使用Condition重写wait/notify案例并实现一个有界队列00:22:05分钟 | 第33节深入解析Condition源码00:21:15分钟 | 第34节实战:简易数据连接池00:24:53分钟 | 第35节线程之间通信之join应用与实现原理剖析00:10:17分钟 | 第36节ThreadLocal 使用及实现原理00:17:41分钟 | 第37节并发工具类CountDownLatch详解00:22:04分钟 | 第38节并发工具类CyclicBarrier 详解00:11:52分钟 | 第39节并发工具类Semaphore详解00:17:27分钟 | 第40节并发工具类Exchanger详解00:13:47分钟 | 第41节CountDownLatch,CyclicBarrier,Semaphore源码解析00:29:57分钟 | 第42节提前完成任务之FutureTask使用00:11:43分钟 | 第43节Future设计模式实现(实现类似于JDK提供的Future)00:19:20分钟 | 第44节Future源码解读00:29:22分钟 | 第45节Fork/Join框架详解00:28:09分钟 | 第46节同步容器与并发容器00:18:44分钟 | 第47节并发容器CopyOnWriteArrayList原理与使用00:15:52分钟 | 第48节并发容器ConcurrentLinkedQueue原理与使用00:31:03分钟 | 第49节Java中的阻塞队列原理与使用00:26:18分钟 | 第50节实战:简单实现消息队列00:11:07分钟 | 第51节并发容器ConcurrentHashMap原理与使用00:38:22分钟 | 第52节线程池的原理与使用00:42:49分钟 | 第53节Executor框架详解00:36:54分钟 | 第54节实战:简易web服务器(一)00:55:34分钟 | 第55节实战:简易web服务器(二)00:24:36分钟 | 第56节JDK8的新增原子操作类LongAddr原理与使用00:17:45分钟 | 第57节JDK8新增锁StampedLock详解00:29:37分钟 | 第58节重排序问题00:23:19分钟 | 第59节happens-before简单概述00:15:17分钟 | 第60节锁的内存语义00:13:54分钟 | 第61节volatile内存语义00:12:04分钟 | 第62节final域的内存语义00:34:07分钟 | 第63节实战:问题定位00:07:48分钟 |

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值