|
| 1 | +--- |
| 2 | +ngtitle: 并发简介 |
| 3 | +date: 2018/05/21 |
| 4 | +categories: |
| 5 | +- javase |
| 6 | +tags: |
| 7 | +- javase |
| 8 | +- concurrent |
| 9 | +--- |
| 10 | + |
| 11 | +# 并发简介 |
| 12 | + |
| 13 | +<!-- TOC depthFrom:2 depthTo:4 --> |
| 14 | + |
| 15 | +- [多线程的优点](#多线程的优点) |
| 16 | +- [多线程的风险](#多线程的风险) |
| 17 | + - [安全性问题](#安全性问题) |
| 18 | + - [线程不安全的示例](#线程不安全的示例) |
| 19 | + - [竞态条件和临界区](#竞态条件和临界区) |
| 20 | + - [活跃性问题](#活跃性问题) |
| 21 | + - [死锁](#死锁) |
| 22 | + - [饥饿和公平](#饥饿和公平) |
| 23 | + - [性能问题](#性能问题) |
| 24 | + - [上下文切换](#上下文切换) |
| 25 | + - [资源限制](#资源限制) |
| 26 | +- [资料](#资料) |
| 27 | + |
| 28 | +<!-- /TOC --> |
| 29 | + |
| 30 | +## 多线程的优点 |
| 31 | + |
| 32 | +* 更好的资源利用 |
| 33 | +* 更简单的编程模型 |
| 34 | +* 程序响应更灵敏 |
| 35 | + |
| 36 | +## 多线程的风险 |
| 37 | + |
| 38 | +凡事有利有弊,引入多线程除了带来的好处以外,也产生了一些问题: |
| 39 | + |
| 40 | +* 安全性问题 |
| 41 | +* 活跃性问题 |
| 42 | +* 性能问题 |
| 43 | + |
| 44 | +### 安全性问题 |
| 45 | + |
| 46 | +什么是线程安全? |
| 47 | + |
| 48 | +线程安全很难定义。我所看到的对于线程安全的定义都太过抽象。 |
| 49 | + |
| 50 | +所以,我觉得有必要反向理解,知道什么是线程不安全,那么就能定义线程安全的边界了。 |
| 51 | + |
| 52 | +在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源。如:同一内存区(变量,数组,或对象)、系统(数据库,web services 等)或文件。 |
| 53 | + |
| 54 | +#### 线程不安全的示例 |
| 55 | + |
| 56 | +```java |
| 57 | +public class Counter { |
| 58 | + protected long count = 0; |
| 59 | + public void add(long value){ |
| 60 | + this.count = this.count + value; |
| 61 | + } |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +想象下线程 A 和 B 同时执行同一个 Counter 对象的 add()方法,我们无法知道操作系统何时会在两个线程之间切换。JVM 并不是将这段代码视为单条指令来执行的,而是按照下面的顺序: |
| 66 | + |
| 67 | +``` |
| 68 | +从内存获取 this.count 的值放到寄存器 |
| 69 | +将寄存器中的值增加 value |
| 70 | +将寄存器中的值写回内存 |
| 71 | +``` |
| 72 | + |
| 73 | +观察线程 A 和 B 交错执行会发生什么: |
| 74 | + |
| 75 | +``` |
| 76 | +this.count = 0; |
| 77 | +A: 读取 this.count 到一个寄存器 (0) |
| 78 | +B: 读取 this.count 到一个寄存器 (0) |
| 79 | +B: 将寄存器的值加 2 |
| 80 | +B: 回写寄存器值(2)到内存. this.count 现在等于 2 |
| 81 | +A: 将寄存器的值加 3 |
| 82 | +A: 回写寄存器值(3)到内存. this.count 现在等于 3 |
| 83 | +``` |
| 84 | + |
| 85 | +两个线程分别加了 2 和 3 到 count 变量上,两个线程执行结束后 count 变量的值应该等于 5。然而由于两个线程是交叉执行的,两个线程从内存中读出的初始值都是 0。然后各自加了 2 和 3,并分别写回内存。最终的值并不是期望的 5,而是最后写回内存的那个线程的值,上面例子中最后写回内存的是线程 A,但实际中也可能是线程 B。如果没有采用合适的同步机制,线程间的交叉执行情况就无法预料。 |
| 86 | + |
| 87 | +#### 竞态条件和临界区 |
| 88 | + |
| 89 | +当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。 |
| 90 | + |
| 91 | +导致竞态条件发生的代码区称作临界区。 |
| 92 | + |
| 93 | +上例中 add()方法就是一个临界区,它会产生竞态条件。在临界区中使用适当的同步就可以避免竞态条件。 |
| 94 | + |
| 95 | +### 活跃性问题 |
| 96 | + |
| 97 | +#### 死锁 |
| 98 | + |
| 99 | +##### 什么是死锁 |
| 100 | + |
| 101 | +多个线程互相等待对方释放锁。 |
| 102 | + |
| 103 | +##### 避免死锁的方法 |
| 104 | + |
| 105 | +(1)加锁顺序 |
| 106 | + |
| 107 | +当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。 |
| 108 | + |
| 109 | +如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。 |
| 110 | + |
| 111 | +按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(译者注:并对这些锁做适当的排序),但总有些时候是无法预知的。 |
| 112 | + |
| 113 | +(2)加锁时限 |
| 114 | + |
| 115 | +另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(译者注:加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。 |
| 116 | + |
| 117 | +(3)死锁检测 |
| 118 | + |
| 119 | +死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。 |
| 120 | + |
| 121 | +每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph 等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。 |
| 122 | + |
| 123 | +当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。 |
| 124 | + |
| 125 | +如果检测出死锁,有两种处理手段: |
| 126 | + |
| 127 | +* 释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(编者注:原因同超时类似,不能从根本上减轻竞争)。 |
| 128 | +* 一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。 |
| 129 | + |
| 130 | +#### 饥饿和公平 |
| 131 | + |
| 132 | +##### 导致饥饿问题的原因 |
| 133 | + |
| 134 | +* 高优先级线程吞噬所有的低优先级线程的 CPU 时间。 |
| 135 | +* 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。 |
| 136 | +* 线程在等待一个本身(在其上调用 wait())也处于永久等待完成的对象,因为其他线程总是被持续地获得唤醒。 |
| 137 | + |
| 138 | +##### 解决饥饿问题的方法 |
| 139 | + |
| 140 | +Java 不可能实现 100% 的公平性,我们依然可以通过同步结构在线程间实现公平性的提高。 |
| 141 | + |
| 142 | +* 使用锁,而不是同步块。 |
| 143 | +* 公平锁。 |
| 144 | + |
| 145 | +### 性能问题 |
| 146 | + |
| 147 | +**并发执行一定比串行执行快吗?** |
| 148 | + |
| 149 | +答案是:不一定。因为有创建线程和线程上下文切换的开销。 |
| 150 | + |
| 151 | +#### 上下文切换 |
| 152 | + |
| 153 | +##### 什么是上下文切换? |
| 154 | + |
| 155 | +当 CPU 从执行一个线程切换到执行另一个线程时,CPU 需要保存当前线程的本地数据,程序指针等状态,并加载下一个要执行的线程的本地数据,程序指针等。这个开关被称为“上下文切换”。 |
| 156 | + |
| 157 | +##### 减少上下文切换的方法 |
| 158 | + |
| 159 | +* 无锁并发编程 - 多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。 |
| 160 | +* CAS 算法 - Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。 |
| 161 | +* 使用最少线程 - 避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。 |
| 162 | +* 使用协程 - 在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。 |
| 163 | + |
| 164 | +#### 资源限制 |
| 165 | + |
| 166 | +##### 什么是资源限制 |
| 167 | + |
| 168 | +资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。 |
| 169 | + |
| 170 | +##### 资源限制引发的问题 |
| 171 | + |
| 172 | +在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。 |
| 173 | + |
| 174 | +##### 如何解决资源限制的问题 |
| 175 | + |
| 176 | +在资源限制情况下进行并发编程,根据不同的资源限制调整程序的并发度。 |
| 177 | + |
| 178 | +* 对于硬件资源限制,可以考虑使用集群并行执行程序。 |
| 179 | +* 对于软件资源限制,可以考虑使用资源池将资源复用。 |
| 180 | + |
| 181 | +## 资料 |
| 182 | + |
| 183 | +* [Java 并发编程实战](https://item.jd.com/10922250.html):第 1 章 简介 |
| 184 | +* [Java 并发编程的艺术](https://item.jd.com/11740734.html):第 1 章 Java 并发编程的挑战 |
| 185 | +* http://tutorials.jenkov.com/java-concurrency/benefits.html |
| 186 | +* http://tutorials.jenkov.com/java-concurrency/costs.html |
| 187 | +* http://tutorials.jenkov.com/java-concurrency/race-conditions-and-critical-sections.html |
| 188 | +* http://tutorials.jenkov.com/java-concurrency/thread-safety.html |
| 189 | +* http://tutorials.jenkov.com/java-concurrency/thread-safety-and-immutability.html |
| 190 | +* http://tutorials.jenkov.com/java-concurrency/deadlock.html |
| 191 | +* http://tutorials.jenkov.com/java-concurrency/deadlock-prevention.html |
| 192 | +* http://tutorials.jenkov.com/java-concurrency/starvation-and-fairness.html |
0 commit comments