网站设计网页设计公司,有哪些可以做兼职的翻译网站,策划网站设计,信息发布网站开发模板文章目录并发概念并发和并行同步和异步阻塞和非阻塞进程和线程竞态条件和临界区管程并发的特点提升资源利用率程序响应更快并发的问题安全性问题缓存导致的可见性问题线程切换带来的原子性问题编译优化带来的有序性问题保证并发安全的思路互斥同步#xff08;阻塞同步#xf…
文章目录并发概念并发和并行同步和异步阻塞和非阻塞进程和线程竞态条件和临界区管程并发的特点提升资源利用率程序响应更快并发的问题安全性问题缓存导致的可见性问题线程切换带来的原子性问题编译优化带来的有序性问题保证并发安全的思路互斥同步阻塞同步非阻塞同步无同步活跃性问题死锁Deadlock什么是死锁避免死锁活锁Livelock什么是活锁避免活锁饥饿Starvation什么是饥饿解决饥饿性能问题上下文切换什么是上下文切换减少上下文切换的方法资源限制什么是资源限制资源限制引发的问题如何解决资源限制的问题小结关键词进程、线程、安全性、活跃性、性能、死锁、饥饿、上下文切换 摘要并发编程并非 Java 语言所独有而是一种成熟的编程范式Java 只是用自己的方式实现了并发工作模型。学习 Java 并发编程应该先熟悉并发的基本概念然后进一步了解并发的特性以及其特性所面临的问题。掌握了这些当学习 Java 并发工具时才会明白它们各自是为了解决什么问题为什么要这样设计。通过这样由点到面的学习方式更容易融会贯通将并发知识形成体系化。 并发概念
并发编程中有很多术语概念相近容易让人混淆。本节内容通过对比分析力求让读者清晰理解其概念以及差异。
并发和并行
并发和并行是最容易让新手费解的概念那么如何理解二者呢其最关键的差异在于是否是同时发生
并发是指具备处理多个任务的能力但不一定要同时。并行是指具备同时处理多个任务的能力。
下面是我见过最生动的说明摘自 并发与并行的区别是什么——知乎的高票答案
你吃饭吃到一半电话来了你一直到吃完了以后才去接这就说明你不支持并发也不支持并行。你吃饭吃到一半电话来了你停了下来接了电话接完后继续吃饭这说明你支持并发。你吃饭吃到一半电话来了你一边打电话一边吃饭这说明你支持并行。
同步和异步
同步是指在发出一个调用时在没有得到结果之前该调用就不返回。但是一旦调用返回就得到返回值了。异步则是相反调用在发出之后这个调用就直接返回了所以没有返回结果。换句话说当一个异步过程调用发出后调用者不会立刻得到结果。而是在调用发出后被调用者通过状态、通知来通知调用者或通过回调函数处理这个调用。
举例来说明
同步就像是打电话不挂电话通话不会结束。异步就像是发短信发完短信后就可以做其他事当收到回复短信时手机会通过铃声或振动来提醒。
阻塞和非阻塞
阻塞和非阻塞关注的是程序在等待调用结果消息返回值时的状态
阻塞是指调用结果返回之前当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞是指在不能立刻得到结果之前该调用不会阻塞当前线程。
举例来说明
阻塞调用就像是打电话通话不结束不能放下。非阻塞调用就像是发短信发完短信后就可以做其他事短信来了手机会提醒。
进程和线程
进程进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。进程是操作系统进行资源分配的基本单位。进程可视为一个正在运行的程序。线程线程是操作系统进行调度的基本单位。
进程和线程的差异
一个程序至少有一个进程一个进程至少有一个线程。线程比进程划分更细所以执行开销更小并发性更高进程是一个实体拥有独立的资源而同一个进程中的多个线程共享进程的资源。 JVM 在单个进程中运行JVM 中的线程共享属于该进程的堆。这就是为什么几个线程可以访问同一个对象。线程共享堆并拥有自己的堆栈空间。这是一个线程如何调用一个方法以及它的局部变量是如何保持线程安全的。但是堆不是线程安全的并且为了线程安全必须进行同步。
竞态条件和临界区 竞态条件Race Condition当两个线程竞争同一资源时如果对资源的访问顺序敏感就称存在竞态条件。 临界区Critical Sections导致竞态条件发生的代码区称作临界区。
管程
管程Monitor是指管理共享变量以及对共享变量的操作过程让他们支持并发。
Java 采用的是管程技术synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的所谓等价指的是用管程能够实现信号量也能用信号量实现管程。
并发的特点
技术在进步CPU、内存、I/O 设备的性能也在不断提高。但是始终存在一个核心矛盾CPU、内存、I/O 设备存在速度差异。CPU 远快于内存内存远快于 I/O 设备。
木桶短板理论告诉我们一只木桶能装多少水取决于最短的那块木板。同理程序整体性能取决于最慢的操作即 I/O 操作所以单方面提高 CPU、内存的性能是无效的。 为了合理利用 CPU 的高性能平衡这三者的速度差异计算机体系结构、操作系统、编译程序都做出了贡献主要体现为
CPU 增加了缓存以均衡与内存的速度差异操作系统增加了进程、线程以分时复用 CPU进而均衡 CPU 与 I/O 设备的速度差异编译程序优化指令执行次序使得缓存能够得到更加合理地利用。
其中进程、线程使得计算机、程序有了并发处理任务的能力。
并发的优点在于
提升资源利用率程序响应更快
提升资源利用率
想象一下一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说从磁盘读取一个文件需要 5 秒处理一个文件需要 2 秒。处理两个文件则需要
5秒读取文件A
2秒处理文件A
5秒读取文件B
2秒处理文件B
---------------------
总共需要14秒从磁盘中读取文件的时候大部分的 CPU 时间用于等待磁盘去读取数据。在这段时间里CPU 非常的空闲。它可以做一些别的事情。通过改变操作的顺序就能够更好的使用 CPU 资源。看下面的顺序
5秒读取文件A
5秒读取文件B 2秒处理文件A
2秒处理文件B
---------------------
总共需要12秒CPU 等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候CPU 会去处理第一个文件。记住在等待磁盘读取文件的时候CPU 大 部分时间是空闲的。
总的说来CPU 能够在等待 IO 的时候做一些其他的事情。这个不一定就是磁盘 IO。它也可以是网络的 IO或者用户输入。通常情况下网络和磁盘的 IO 比 CPU 和内存的 IO 慢的多。
程序响应更快
将一个单线程应用程序变成多线程应用程序的另一个常见的目的是实现一个响应更快的应用程序。设想一个服务器应用它在某一个端口监听进来的请求。当一个请求到来时它去处理这个请求然后再返回去监听。
服务器的流程如下所述
while(server is active) {listen for requestprocess request
}如果一个请求需要占用大量的时间来处理在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候请求才能被接收。另一种设计是监听线程把请求传递给工作者线程(worker thread)然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端。这种设计如下所述
while(server is active) {listen for requesthand request to worker thread
}这种方式服务端线程迅速地返回去监听。因此更多的客户端能够发送请求给服务端。这个服务也变得响应更快。
桌面应用也是同样如此。如果你点击一个按钮开始运行一个耗时的任务这个线程既要执行任务又要更新窗口和按钮那么在任务执行的过程中这个应用程序看起来好像没有反应一样。相反任务可以传递给工作者线程worker thread)。当工作者线程在繁忙地处理任务的时候窗口线程可以自由地响应其他用户的请求。当工作者线程完成任务的时候它发送信号给窗口线程。窗口线程便可以更新应用程序窗口并显示任务的结果。对用户而言这种具有工作者线程设计的程序显得响应速度更快。
并发的问题
任何事物都有利弊并发也不例外。
我们知道了并发带来的好处提升资源利用率、程序响应更快同时也要认识到并发带来的问题主要有
安全性问题活跃性问题性能问题
安全性问题
并发最重要的问题是并发安全问题。
并发安全是指保证程序的正确性使得并发处理结果符合预期。
并发安全需要保证几个基本特性
可见性 - 是一个线程修改了某个共享变量其状态能够立即被其他线程知晓通常被解释为将线程本地状态反映到主内存上volatile 就是负责保证可见性的。原子性 - 简单说就是相关操作不会中途被其他线程干扰一般通过同步机制加锁sychronized、Lock实现。有序性 - 是保证线程内串行语义避免指令重排等。
缓存导致的可见性问题 一个线程对共享变量的修改另外一个线程能够立刻看到称为 可见性。 在单核时代所有的线程都是在一颗 CPU 上执行CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存一个线程对缓存的写对另外一个线程来说一定是可见的。例如在下面的图中线程 A 和线程 B 都是操作同一个 CPU 里面的缓存所以线程 A 更新了变量 V 的值那么线程 B 之后再访问变量 V得到的一定是 V 的最新值线程 A 写过的值。 多核时代每颗 CPU 都有自己的缓存这时 CPU 缓存与内存的数据一致性就没那么容易解决了当多个线程在不同的 CPU 上执行时这些线程操作的是不同的 CPU 缓存。比如下图中线程 A 操作的是 CPU-1 上的缓存而线程 B 操作的是 CPU-2 上的缓存很明显这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。 【示例】线程不安全的示例
下面我们再用一段代码来验证一下多核场景下的可见性问题。下面的代码每执行一次 add10K() 方法都会循环 10000 次 count1 操作。在 calc() 方法中我们创建了两个线程每个线程调用一次 add10K() 方法我们来想一想执行 calc() 方法得到的结果应该是多少呢
public class Test {private long count 0;private void add10K() {int idx 0;while(idx 10000) {count 1;}}public static long calc() {final Test test new Test();// 创建两个线程执行 add() 操作Thread th1 new Thread(()-{test.add10K();});Thread th2 new Thread(()-{test.add10K();});// 启动两个线程th1.start();th2.start();// 等待两个线程执行结束th1.join();th2.join();return count;}
}直觉告诉我们应该是 20000因为在单线程里调用两次 add10K() 方法count 的值就是 20000但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢
我们假设线程 A 和线程 B 同时开始执行那么第一次都会将 count0 读到各自的 CPU 缓存里执行完 count1 之后各自 CPU 缓存里的值都是 1同时写入内存后我们会发现内存中是 1而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值两个线程都是基于 CPU 缓存里的 count 值来计算所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。
循环 10000 次 count1 操作如果改为循环 1 亿次你会发现效果更明显最终 count 的值接近 1 亿而不是 2 亿。如果循环 10000 次count 的值接近 20000原因是两个线程不是同时启动的有一个时差。 线程切换带来的原子性问题
由于 IO 太慢早期的操作系统就发明了多进程操作系统允许某个进程执行一小段时间称为 时间片。
在一个时间片内如果一个进程进行一个 IO 操作例如读个文件这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权待文件读进内存操作系统会把这个休眠的进程唤醒唤醒后的进程就有机会重新获得 CPU 的使用权了。
这里的进程在等待 IO 时之所以会释放 CPU 使用权是为了让 CPU 在这段等待时间里可以做别的事情这样一来 CPU 的使用率就上来了此外如果这时有另外一个进程也读文件读文件的操作就会排队磁盘驱动在完成一个进程的读操作后发现有排队的任务就会立即启动下一个读操作这样 IO 的使用率也上来了。
早期的操作系统基于进程来调度 CPU不同进程间是不共享内存空间的所以进程要做任务切换就要切换内存映射地址而一个进程创建的所有线程都是共享一个内存空间的所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度现在我们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的自然也会涉及到任务切换也许你想不到任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候我们现在基本都使用高级语言编程高级语言里一条语句往往需要多条 CPU 指令完成例如上面代码中的 count 1至少需要三条 CPU 指令。
指令 1首先需要把变量 count 从内存加载到 CPU 的寄存器指令 2之后在寄存器中执行 1 操作指令 3最后将结果写入内存缓存机制导致可能写入的是 CPU 缓存而不是内存。
操作系统做任务切换可以发生在任何一条CPU 指令执行完是的是 CPU 指令而不是高级语言里的一条语句。对于上面的三条指令来说我们假设 count0如果线程 A 在指令 1 执行完后做线程切换线程 A 和线程 B 按照下图的序列执行那么我们会发现两个线程都执行了 count1 的操作但是得到的结果不是我们期望的 2而是 1。 我们潜意识里面觉得 count1 这个操作是一个不可分割的整体就像一个原子一样线程的切换可以发生在 count1 之前也可以发生在 count1 之后但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的而不是高级语言的操作符这是违背我们直觉的地方。因此很多时候我们需要在高级语言层面保证操作的原子性。
编译优化带来的有序性问题
那并发编程里还有没有其他有违直觉容易导致诡异 Bug 的技术呢有的就是有序性。顾名思义有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能有时候会改变程序中语句的先后顺序例如程序中“a6 b7”编译器优化后可能变成“b7 a6”在这个例子中编译器调整了语句的顺序但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
在 Java 领域一个经典的案例就是利用双重检查创建单例对象例如下面的代码在获取实例 getInstance() 的方法中我们首先判断 instance 是否为空如果为空则锁定 Singleton.class 并再次检查 instance 是否为空如果还为空则创建 Singleton 的一个实例。
public class Singleton {static Singleton instance;static Singleton getInstance(){if (instance null) {synchronized(Singleton.class) {if (instance null)instance new Singleton();}}return instance;}
}假设有两个线程 A、B 同时调用 getInstance() 方法他们会同时发现 instance null 于是同时对 Singleton.class 加锁此时 JVM 保证只有一个线程能够加锁成功假设是线程 A另外一个线程则会处于等待状态假设是线程 B线程 A 会创建一个 Singleton 实例之后释放锁锁释放后线程 B 被唤醒线程 B 再次尝试加锁此时是可以加锁成功的加锁成功后线程 B 检查 instance null 时会发现已经创建过 Singleton 实例了所以线程 B 不会再创建一个 Singleton 实例。
这看上去一切都很完美无懈可击但实际上这个 getInstance() 方法并不完美。问题出在哪里呢出在 new 操作上我们以为的 new 操作应该是
分配一块内存 M在内存 M 上初始化 Singleton 对象然后 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径却是这样的
分配一块内存 M将 M 的地址赋值给 instance 变量最后在内存 M 上初始化 Singleton 对象。
优化后会导致什么问题呢我们假设线程 A 先执行 getInstance() 方法当执行完指令 2 时恰好发生了线程切换切换到了线程 B 上如果此时线程 B 也执行 getInstance() 方法那么线程 B 在执行第一个判断时会发现 instance ! null 所以直接返回 instance而此时的 instance 是没有初始化过的如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。 保证并发安全的思路
互斥同步阻塞同步
互斥同步是最常见的并发正确性保障手段。
同步是指在多线程并发访问共享数据时保证共享数据在同一时刻只能被一个线程访问。
互斥是实现同步的一种手段。临界区Critical Sections、互斥量Mutex和信号量Semaphore都是主要的互斥实现方式。
最典型的案例是使用 synchronized 或 Lock 。
互斥同步最主要的问题是线程阻塞和唤醒所带来的性能问题互斥同步属于一种悲观的并发策略总是认为只要不去做正确的同步措施那就肯定会出现问题。无论共享数据是否真的会出现竞争它都要进行加锁这里讨论的是概念模型实际上虚拟机会优化掉很大一部分不必要的加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
非阻塞同步
随着硬件指令集的发展我们可以使用基于冲突检测的乐观并发策略先进行操作如果没有其它线程争用共享数据那操作就成功了否则采取补偿措施不断地重试直到成功为止。这种乐观的并发策略的许多实现都不需要将线程阻塞因此这种同步操作称为非阻塞同步。
为什么说乐观锁需要 硬件指令集的发展 才能进行因为需要操作和冲突检测这两个步骤具备原子性。而这点是由硬件来完成如果再使用互斥同步来保证就失去意义了。
这类乐观锁指令常见的有
测试并设置Test-and-Set获取并增加Fetch-and-Increment交换Swap比较并交换CAS加载链接、条件存储Load-linked / Store-Conditional
Java 典型应用场景J.U.C 包中的原子类基于 Unsafe 类的 CAS 操作
无同步
要保证线程安全不一定非要进行同步。同步只是保证共享数据争用时的正确性如果一个方法本来就不涉及共享数据那么自然无须同步。
Java 中的 无同步方案 有
可重入代码 - 也叫纯代码。如果一个方法它的 返回结果是可以预测的即只要输入了相同的数据就能返回相同的结果那它就满足可重入性当然也是线程安全的。线程本地存储 - 使用 ThreadLocal 为共享变量在每个线程中都创建了一个本地副本这个副本只能被当前线程访问其他线程无法访问那么自然是线程安全的。
活跃性问题
死锁Deadlock
什么是死锁
多个线程互相等待对方释放锁。
死锁是当线程进入无限期等待状态时发生的情况因为所请求的锁被另一个线程持有而另一个线程又等待第一个线程持有的另一个锁。 避免死锁
1按序加锁
当多个线程需要相同的一些锁但是按照不同的顺序加锁死锁就很容易发生。
如果能确保所有的线程都是按照相同的顺序获得锁那么死锁就不会发生。
按照顺序加锁是一种有效的死锁预防机制。但是这种方式需要你事先知道所有可能会用到的锁(注并对这些锁做适当的排序)但总有些时候是无法预知的。
2超时释放锁
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁则会进行回退并释放所有已经获得的锁然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁并且让该应用在没有获得锁的时候可以继续运行(注加锁超时后可以先继续运行干点其它事情再回头来重复之前加锁的逻辑)。
3死锁检测
死锁检测是一个更好的死锁预防机制它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。
每当一个线程获得了锁会在线程和锁相关的数据结构中map、graph 等等将其记下。除此之外每当有线程请求锁也需要记录在这个数据结构中。
当一个线程请求锁失败时这个线程可以遍历锁的关系图看看是否有死锁发生。
如果检测出死锁有两种处理手段
释放所有锁回退并且等待一段随机的时间后重试。这个和简单的加锁超时类似不一样的是只有死锁已经发生了才回退而不会是因为加锁的请求超时了。虽然有回退和等待但是如果有大量的线程竞争同一批锁它们还是会重复地死锁注原因同超时类似不能从根本上减轻竞争。一个更好的方案是给这些线程设置优先级让一个或几个线程回退剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的同一批线程总是会拥有更高的优先级。为避免这个问题可以在死锁发生的时候设置随机的优先级。
活锁Livelock
什么是活锁
活锁是一个递归的情况两个或更多的线程会不断重复一个特定的代码逻辑。预期的逻辑通常为其他线程提供机会继续支持’this’线程。
想象这样一个例子两个人在狭窄的走廊里相遇二者都很礼貌试图移到旁边让对方先通过。但是他们最终在没有取得任何进展的情况下左右摇摆因为他们都在同一时间向相同的方向移动。 如图所示两个线程想要通过一个 Worker 对象访问共享公共资源的情况但是当他们看到另一个 Worker在另一个线程上调用也是“活动的”时它们会尝试将该资源交给其他工作者并等待为它完成。如果最初我们让两名工作人员都活跃起来他们将会面临活锁问题。
避免活锁
解决“活锁”的方案很简单谦让时尝试等待一个随机的时间就可以了。由于等待的时间是随机的所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单却非常有效Raft 这样知名的分布式一致性算法中也用到了它。
饥饿Starvation
什么是饥饿
高优先级线程吞噬所有的低优先级线程的 CPU 时间。线程被永久堵塞在一个等待进入同步块的状态因为其他线程总是能在它之前持续地对该同步块进行访问。线程在等待一个本身(在其上调用 wait())也处于永久等待完成的对象因为其他线程总是被持续地获得唤醒。 饥饿问题最经典的例子就是哲学家问题。如图所示有五个哲学家用餐每个人要获得两把叉子才可以就餐。当 2、4 就餐时1、3、5 永远无法就餐只能看着盘中的美食饥饿的等待着。
解决饥饿
Java 不可能实现 100% 的公平性我们依然可以通过同步结构在线程间实现公平性的提高。
有三种方案
保证资源充足公平地分配资源避免持有锁的线程长时间执行
这三个方案中方案一和方案三的适用场景比较有限因为很多场景下资源的稀缺性是没办法解决的持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢在并发编程里主要是使用公平锁。所谓公平锁是一种先来后到的方案线程的等待是有顺序的排在等待队列前面的线程会优先获得资源。
性能问题
并发执行一定比串行执行快吗线程越多执行越快吗
答案是并发不一定比串行快。因为有创建线程和线程上下文切换的开销。
上下文切换
什么是上下文切换
当 CPU 从执行一个线程切换到执行另一个线程时CPU 需要保存当前线程的本地数据程序指针等状态并加载下一个要执行的线程的本地数据程序指针等。这个开关被称为“上下文切换”。
减少上下文切换的方法
无锁并发编程 - 多线程竞争锁时会引起上下文切换所以多线程处理数据时可以用一些办法来避免使用锁如将数据的 ID 按照 Hash 算法取模分段不同的线程处理不同段的数据。CAS 算法 - Java 的 Atomic 包使用 CAS 算法来更新数据而不需要加锁。使用最少线程 - 避免创建不需要的线程比如任务很少但是创建了很多线程来处理这样会造成大量线程都处于等待状态。使用协程 - 在单线程里实现多任务的调度并在单线程里维持多个任务间的切换。
资源限制
什么是资源限制
资源限制是指在进行并发编程时程序的执行速度受限于计算机硬件资源或软件资源。
资源限制引发的问题
在并发编程中将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行但是如果将某段串行的代码并发执行因为受限于资源仍然在串行执行这时候程序不仅不会加快执行反而会更慢因为增加了上下文切换和资源调度的时间。
如何解决资源限制的问题
在资源限制情况下进行并发编程根据不同的资源限制调整程序的并发度。
对于硬件资源限制可以考虑使用集群并行执行程序。对于软件资源限制可以考虑使用资源池将资源复用。
小结
并发编程可以总结为三个核心问题分工、同步、互斥。
分工是指如何高效地拆解任务并分配给线程。同步是指线程之间如何协作。互斥是指保证同一时刻只允许一个线程访问共享资源。