并发编程 理论篇

我们为什么要学习并发编程?

我们在日常的开发中,尤其是在开发业务接口的时候,似乎并没有过多的用到并发,最多用个线程池,syschronized​ 似乎就够了,但是真的是这样吗? 如果你只是做做简单的CRUD确实是这样的, 因为原本需要你处理的并发问题,交给了框架,交给了中间件,让你可以更加关注业务。 但是这不是我们不学习并发编程的理由, 因为当业务层出现并发问题时,怎么办呢? 或者叫你对中间件(比如Tomcat,数据库连接池)进行调优的时候,你唯一能参考的就是网上的通用设置,无法针对当前场景定制化。 所以学习并发编程很有必要

我们为什么要用并发来写程序呢?

原因很简单,提升程序的性能,疯狂压榨硬件,提升CPU的利用率以及IO的利用率,既然要提升性能,我们就需要对性能有个定量的认识,也就是要有衡量标准。 主要的衡量标准有两个维度,一个是延迟, 另一个是吞吐量, 延迟: 一个请求发出到响应的时间。 吞吐量: 单位时间内处理的请求数。 我们写并发程序的目的就是 "降低延迟,提高吞吐量", 但是落实到代码我们应该怎么做呢?

我们应该如何写一个优雅的并发程序?

我们先想想,我们在写一个普通的程序的时候,会先干嘛?

  1. 理清要写的这个程序的功能
  2. 设计这些功能,哪些是通用的,哪些又是可以借助工具类,不需要我们自己实现
  3. 落地实现写代码的时候,又有哪些问题需要注意?

当经历了这三步,之后我们就开始写代码了。 写一个并发程序也是这样的,首先我们需要理清哪些任务可以并发,哪些任务没必要并发,这一步我叫做分工, 然后我们需要理清这些任务与任务之间的依赖关系, 这一步我叫做同步, 由于任务与任务之间,不可避免的要交换数据,也就是访问共享变量,所以我们需要注意互斥。

所以写出一个并发程序大致要解决三个核心问题

  1. 分工 哪些任务需要分配给线程
  2. 同步 线程与线程之间如何协作
  3. 互斥 保证在同一时刻,只有一个线程访问共享资源 切记一定是共享资源

而通常我们在写完一个并发程序后,还需要注意这三个方面

  1. 安全性 程序的正确性,程序是否按照我们的预期执行
  2. 活跃性 程序因某个操作无法执行下去
  3. 性能问题 通常是并发程序中串行化过于严重

影响程序安全性的原因大致有三个 可见性问题, 原子性问题, 有序性问题

之所以有可见性的问题,是因为在缓解CPU与内存之间的速度差异时,引入高速缓存而导致的。

比如上面这张图,在内存中有一个数据值为100,CPU-1读取这个值到自己的高速缓存中,然后进行运算后,将值修改为200,但是这个时候CPU-1并不会着急将这个值写回内存,而这个时候,CPU-2又从内存中读取这个值,由于CPU-1没有写回,所以导致CPU-2读取到的值也是100,这样就会导致程序出问题, 没有按照我们的预期执行。 这就是可见性问题。 所以可以看见,产生可见性的原因不仅仅时因为高速缓存还因为有多核CPU。

对于原子性问题, 产生的原因是在缓解CPU与IO之间的速度差异时,引入分时操作系统导致的。 具体而言就是高级语言的一条语句对应低级语言 (比如汇编) 的多条指令。 然后由于IO操作,导致任务切换,或者时间片用完,导致任务切换,从而导致高级语言的一条语句没有在一个时间片内执行完,从而导致原子性问题。

对于有序性问题, 产生的原因是由于编译器为了能够更好的利用缓存,将我们写的代码的顺序优化调整了一下,导致程序的顺序和我们实际的顺序不同,从而导致出现并发问题。

对于这三个问题,Java给出了自己的答案, Java内存模型 + volatile ​解决可见性和有序性问题, 互斥锁(synchronized​)解决原子性问题。 其中volatile​关键字保证了数据的可见性。 而Java内存模型中的Happen Before原则, 指出了在哪些场景下必须保证可见性,约束了Java编译的优化,解决了有序性问题。

对于我们而言比较重要的6个"Happen Before"原则

"Happen Before": 一个操作的结果对于另一个操作是可见的

  1. 在同一个线程中, 上一个操作的结果,对于后续操作都是可见的。 切记一定是在一个线程内部。

  2. 针对volatile​关键字修饰的变量, 对这个变量的写操作,对于后续这个变量的读操作是可见的。 切记这个没有必须要在同一个线程中。

  3. 传递性原则: 如果 操作A Happen Before B, B Happen Before C, 那么 A Happen Before C。 这个规则是整个Happen Before原则中最重要的,串联这些原则的一个枢纽。

  4. 管程中锁的规则: 对于锁的解锁操作 Happen Before 锁的加锁操作

    管程在Java中其实就是 synchronized​

  5. 线程start()的规则: start()操作的可见性,对于启动的子线程是可见的。

    主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作

  6. 线程join()的规则: 子线程中的操作都对join()这个操作可见

    主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B的 join() 方法实现),当子线程 B 完成后(主线程 A 中join() 方法返回),主线程能够看到子线程的操作 当然所谓的“看到”,指的是对共享变量的操作

在分析并发程序中的BUG的时候,就可以利用这些原则。

解决了可见性以及有序性问题之后, Java解决原子性问题的方案就是我们都熟悉的锁模型,也就是我们都知道的synchronized​关键字。 但是其实Java解决原子性问题背后的真正理论模型是管程, 管程是一种基本的同步原语, 具体而言就是将共享资源保护起来,提供访问资源的方法。 管程有三个模型, Hasen 模型、Hoare 模型和 MESA 模型,而Java实现的就是MESA 模型,只不过做了一点简化。 接下来我们来看看MESA 模型的运行原理

当一个线程A,获取锁,进入管程内部后,要去操作共享变量,如果进行这个操作需要满足条件1, 然后他就会检查是否满足条件1,如果检查到不满足,就会释放锁,进入条件变量1的等待队列中,这个时候入口等待队列中的线程开始重新竞争锁,假设线程B 获取到锁进入线程内部,进行一些操作,这次需要检查条件2,于是线程B就会检查条件2是否满足,如果不满足,同样会进入条件变量2的等待队列中。 当某个时刻,条件1满足时, 会唤醒条件1等待队列中的线程,然后这些线程就会进入到入口等待队列中,等待锁资源并去竞争。 同理,如果条件2满足, 也会唤醒在条件变量2等待队列中的线程,这些线程也会进入到入口等待队列,然后去竞争锁资源。

理解了管程模型的运行原理之后,接下来我们来看看Java中对管程的实现 —— synchronized代码块 Java在对其实现的过程中,对其进行了一定的简化,比如锁资源和条件变量绑定,即一个锁资源对应一个条件变量,我们都知道一个synchronized代码块只能上一把锁,所以在代码块中可供操作的条件变量也只有一个,而且还是锁本身。而Java提供的操作就是wait()​ notify()​ notifyAll()​ wait()​的作用就是将需要等待资源的线程放入到条件等待队列中。notify()​ notifyAll()​ 对应的就是唤醒操作了

现在我们知道了并发程序的安全性问题主要是可见性问题,原子性问题,以及有序性问题,可是落实到代码上,有没有什么样的代码形式会出现这三类问题呢?毕竟解决问题的第一步就是识别问题嘛,好在前人都帮我们总结好了。

  1. 要出现并发安全问题,那么一定是多个线程访问一块内存,对应到代码:在多个线程中访问了一个共享变量。所以就有人评价说,没有共享,就没有伤害 虽然有点滑稽,但是他是解决并发问题的终极方案 —— 不共享
  2. 如果多个线程对一个共享变量进行读写操作,就会出现并发安全性问题,由于这种操作太常见了,所以人们给这种情况起了一个名字:数据竞争。
  3. 如果一个程序的执行,依赖一个状态,而这个状态又是共享的,也会引发并发安全性问题,这个情况也过于常见了,所以人们给这种操作去了一个名字:竞态条件

总的来说,只要存在多个线程之间共享变量,就会存在并发问题,前提是对这个共线变量有写操作。

聊完了并发程序中的安全性问题,接下来我们来聊聊活跃性问题,所谓的活跃性问题具体而言就是程序因为某些情况无法执行下去了,相信大家能想到的情况就是死锁,其实除了死锁,还有“活锁”,“饥饿”这些问题也会导致程序无法执行下去。

我们先来聊聊,大家都比较熟悉的死锁问题,如果程序一旦发生死锁,一般情况下,是没有什么好的解决办法,只能重启程序,所以解决死锁的方案只剩下一个了,那就是避免死锁,那么只要我们知道死锁发生的条件,在写程序的时候,刻意避免,就可以了。那么死锁产生的条件究竟有哪些呢?别怕,前人已经总结好了,发生死锁必须同时满足四个条件

  1. 互斥 共享资源X和Y只能被一个线程占用,具体而言:一个操作包含两个共享资源
  2. 占用且等待 线程1占用资源X,等待获取资源Y,但是不释放资源X
  3. 不可抢占 其他线程不能强行抢占线程1占用的资源
  4. 循环等待 线程1占用资源X,等待资源Y,线程2占用资源Y,等待资源X

只有这四个条件同时满足,才会产生死锁,换句话说如果有一个条件不满足,那么就不会发生死锁,所以我们只需要破坏一个条件即可。

针对互斥,这个条件我们必须满足,因为这个是我们写并发程序保证安全性的一个要求。

针对占用且等待,我们可以将资源X和资源Y打包,要么一起占用,要么就不占用,落实到代码,就是封装一个类,包含资源X,资源Y,然后提供两个操作,申请资源,释放资源,并保证这个类是线程安全的就好了

针对不可抢占,如果线程1占用X资源,在申请Y资源的时候,没有申请到,就释放X资源。

针对循环等待,问题产生的原因就是因为资源获取的先后顺序不一样,解决方案就是给资源进行一个排序,每个线程获取资源的时候,必须按照这个顺序获取。

死锁介绍完了,接下来就简单介绍一下“活锁”,“饥饿”问题,对于“活锁”而言,举个例子就明白了,比如在走路的时候,你前面遇到了一个人,挡住了去路,你和他都可能选择让路,如果你们都选择让路,那么就又挡道了,然后你们又让路,结果又挡道了,不过这个过程不会持续很久,因为你们会沟通,但是线程可不会沟通,所以解决办法就是随机等待一段时间之后,再让路。

“饥饿”问题,主要是由于程序中不断有优先级高的线程执行,导致优先级低的线程无法执行,这种现象叫做饥饿,这个问题可以利用公平锁来解决。

活跃性问题简单的介绍了一下,接下来就是并发程序的性能问题了,这也是我们最关心的问题,因为我们写并发程序就是为了提高性能,即“降低延迟,提高吞吐量“,产生性能问题的原因有,串行化占比太大,解决方案就是可以考虑无锁算法,以及尽量减少持有锁的时间,比如细粒度锁。

到此呢,并发程序从设计到编写,以及写好后需要考虑的问题都一一做了介绍,接下来我们还需要一点前置知识,才能写好Java中的并发程序。

Java中的线程模型

在Java中写并发程序的手段就是多线程,所以理解Java的线程模型,有利于我们写出好的多线程程序。

说到线程模型,我们需要从操作系统中的线程模型讲起,然后再讲解Java是如何对操作系统的线程进行一定的封装简化的,最后再简单聊聊如何在Java中利用面向对象的思想写并发程序。

先来聊聊线程的生命周期,从代码的声明需要一个线程,到操作系统真正创建完一个线程这段时间被称为初始状态,只要线程创建完毕,它就会进入到可运行状态,等待CPU资源,当线程获取到CPU资源后,就进入运行状态,如果CPU的时间片用完,就会回到可执行状态,如果在执行的过程中,需要IO操作,这个时候线程会进入到休眠状态,当条件满足时,线程又会从休眠状态回到可执行状态,等待CPU资源。如果线程在运行状态,由于任务执行完毕,或者发送异常就会进入到终止状态。

这是操作系统的线程模型,而Java对这个模型进行一定封装。

对比可以发现,Java中的线程,将可运行状态和运行状态合并了,并细分了休眠状态。

那么我们就来聊聊,Java中线程的生命周期,当程序执行到 new Thread();​的时候,线程处于初始状态,当程序执行到 thread.start()​的时候,线程处于runnable状态,如果在代码中遇到 同步代码块而且还没有获取到锁资源,那么线程会进入到 blocked状态,只有锁释放的时候,线程才会从blocked​状态进入到runnable状态,如果程序中遇到有wait()​,那么线程就会进入到 waiting状态,如果程序中遇到 wait(100)​,sleep(1000)​,线程就会进入到time_waiting状态,如果任务执行完,或者执行过程中发送异常就会进入到terminated状态。

其实进入到waiting状态的方式不止wait()​一种,只要没有时间限制的等待,不是获取锁资源的阻塞,其他的等待都会进入到waiting状态,比如thread1.join()​ 会导致执行这行代码的线程进入到waiting状态

在描述生命周期的时候,我提到了任务这个词,在并发编程中主要由两类任务

  1. IO密集型计算 IO计算远远大于CPU计算
  2. CPU密集型计算 CPU计算远远大于IO计算

针对不同种类的计算,我们设置的线程数也不同,线程数不是越多越好,是需要根据场景来的。CPU密集型计算,意味着该任务会一直使用CPU资源,很少使用IO,这种任务,如果我们要提高性能,只能上多核,提供更多的CPU资源,所以一般的线程数设置在理论上等于机器的CPU核数(逻辑值),因为有些CPU支持超线程技术。而针对IO密集型计算,我们需要估算CPU与IO的耗时比,然后才能判断需要多少个线程,具体的公式如下 CPU个数 * [1 + (IO耗时/CPU耗时)] 这个公式,主要是想让CPU的利用率和IO的利用率达到100%。但是这是理论值,实际还是需要压测。

到此Java的内存模型说清楚了,最后再来谈谈,如何利用面向对象的思想写并发程序。

其实利用面向对象的思想写并发程序,主要还是借鉴管程的思想。

  1. 封装共享变量
  2. 识别共享变量之间的约束关系
  3. 制定共享变量的访问策略

至于如何识别共享变量之间的约束关系,以及如何制定访问策略,这些内容我们下期见。

  • 16
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值