我们为什么要学习并发编程?
我们在日常的开发中,尤其是在开发业务接口的时候,似乎并没有过多的用到并发,最多用个线程池,syschronized 似乎就够了,但是真的是这样吗? 如果你只是做做简单的CRUD确实是这样的, 因为原本需要你处理的并发问题,交给了框架,交给了中间件,让你可以更加关注业务。 但是这不是我们不学习并发编程的理由, 因为当业务层出现并发问题时,怎么办呢? 或者叫你对中间件(比如Tomcat,数据库连接池)进行调优的时候,你唯一能参考的就是网上的通用设置,无法针对当前场景定制化。 所以学习并发编程很有必要
我们为什么要用并发来写程序呢?
原因很简单,提升程序的性能,疯狂压榨硬件,提升CPU的利用率以及IO的利用率,既然要提升性能,我们就需要对性能有个定量的认识,也就是要有衡量标准。 主要的衡量标准有两个维度,一个是延迟, 另一个是吞吐量, 延迟: 一个请求发出到响应的时间。 吞吐量: 单位时间内处理的请求数。 我们写并发程序的目的就是 "降低延迟,提高吞吐量", 但是落实到代码我们应该怎么做呢?
我们应该如何写一个优雅的并发程序?
我们先想想,我们在写一个普通的程序的时候,会先干嘛?
- 理清要写的这个程序的功能
- 设计这些功能,哪些是通用的,哪些又是可以借助工具类,不需要我们自己实现
- 落地实现写代码的时候,又有哪些问题需要注意?
当经历了这三步,之后我们就开始写代码了。 写一个并发程序也是这样的,首先我们需要理清哪些任务可以并发,哪些任务没必要并发,这一步我叫做分工, 然后我们需要理清这些任务与任务之间的依赖关系, 这一步我叫做同步, 由于任务与任务之间,不可避免的要交换数据,也就是访问共享变量,所以我们需要注意互斥。
所以写出一个并发程序大致要解决三个核心问题
- 分工 哪些任务需要分配给线程
- 同步 线程与线程之间如何协作
- 互斥 保证在同一时刻,只有一个线程访问共享资源 切记一定是共享资源
而通常我们在写完一个并发程序后,还需要注意这三个方面
- 安全性 程序的正确性,程序是否按照我们的预期执行
- 活跃性 程序因某个操作无法执行下去
- 性能问题 通常是并发程序中串行化过于严重
影响程序安全性的原因大致有三个 可见性问题, 原子性问题, 有序性问题
之所以有可见性的问题,是因为在缓解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": 一个操作的结果对于另一个操作是可见的
-
在同一个线程中, 上一个操作的结果,对于后续操作都是可见的。 切记一定是在一个线程内部。
-
针对volatile关键字修饰的变量, 对这个变量的写操作,对于后续这个变量的读操作是可见的。 切记这个没有必须要在同一个线程中。
-
传递性原则: 如果 操作A Happen Before B, B Happen Before C, 那么 A Happen Before C。 这个规则是整个Happen Before原则中最重要的,串联这些原则的一个枢纽。
-
管程中锁的规则: 对于锁的解锁操作 Happen Before 锁的加锁操作
管程在Java中其实就是 synchronized
-
线程start()的规则: start()操作的可见性,对于启动的子线程是可见的。
主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
-
线程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() 对应的就是唤醒操作了
现在我们知道了并发程序的安全性问题主要是可见性问题,原子性问题,以及有序性问题,可是落实到代码上,有没有什么样的代码形式会出现这三类问题呢?毕竟解决问题的第一步就是识别问题嘛,好在前人都帮我们总结好了。
- 要出现并发安全问题,那么一定是多个线程访问一块内存,对应到代码:在多个线程中访问了一个共享变量。所以就有人评价说,没有共享,就没有伤害 虽然有点滑稽,但是他是解决并发问题的终极方案 —— 不共享
- 如果多个线程对一个共享变量进行读写操作,就会出现并发安全性问题,由于这种操作太常见了,所以人们给这种情况起了一个名字:数据竞争。
- 如果一个程序的执行,依赖一个状态,而这个状态又是共享的,也会引发并发安全性问题,这个情况也过于常见了,所以人们给这种操作去了一个名字:竞态条件
总的来说,只要存在多个线程之间共享变量,就会存在并发问题,前提是对这个共线变量有写操作。
聊完了并发程序中的安全性问题,接下来我们来聊聊活跃性问题,所谓的活跃性问题具体而言就是程序因为某些情况无法执行下去了,相信大家能想到的情况就是死锁,其实除了死锁,还有“活锁”,“饥饿”这些问题也会导致程序无法执行下去。
我们先来聊聊,大家都比较熟悉的死锁问题,如果程序一旦发生死锁,一般情况下,是没有什么好的解决办法,只能重启程序,所以解决死锁的方案只剩下一个了,那就是避免死锁,那么只要我们知道死锁发生的条件,在写程序的时候,刻意避免,就可以了。那么死锁产生的条件究竟有哪些呢?别怕,前人已经总结好了,发生死锁必须同时满足四个条件
- 互斥 共享资源X和Y只能被一个线程占用,具体而言:一个操作包含两个共享资源
- 占用且等待 线程1占用资源X,等待获取资源Y,但是不释放资源X
- 不可抢占 其他线程不能强行抢占线程1占用的资源
- 循环等待 线程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状态
在描述生命周期的时候,我提到了任务这个词,在并发编程中主要由两类任务
- IO密集型计算 IO计算远远大于CPU计算
- CPU密集型计算 CPU计算远远大于IO计算
针对不同种类的计算,我们设置的线程数也不同,线程数不是越多越好,是需要根据场景来的。CPU密集型计算,意味着该任务会一直使用CPU资源,很少使用IO,这种任务,如果我们要提高性能,只能上多核,提供更多的CPU资源,所以一般的线程数设置在理论上等于机器的CPU核数(逻辑值),因为有些CPU支持超线程技术。而针对IO密集型计算,我们需要估算CPU与IO的耗时比,然后才能判断需要多少个线程,具体的公式如下 CPU个数 * [1 + (IO耗时/CPU耗时)] 这个公式,主要是想让CPU的利用率和IO的利用率达到100%。但是这是理论值,实际还是需要压测。
到此Java的内存模型说清楚了,最后再来谈谈,如何利用面向对象的思想写并发程序。
其实利用面向对象的思想写并发程序,主要还是借鉴管程的思想。
- 封装共享变量
- 识别共享变量之间的约束关系
- 制定共享变量的访问策略
至于如何识别共享变量之间的约束关系,以及如何制定访问策略,这些内容我们下期见。