thread-bring-some-problem.md 12.1 KB
Newer Older
沉默王二's avatar
沉默王二 已提交
1
---
沉默王二's avatar
沉默王二 已提交
2
title: 并发编程(多线程)带来了哪些问题?
沉默王二's avatar
沉默王二 已提交
3 4
shortTitle: 多线程带来了哪些问题?
description: 多线程技术有很多好处,比如说多线程可以充分利用多核 CPU 的计算能力,但如果使用不慎,就会出现『线程安全问题』『活跃性问题』『性能问题』等等。
沉默王二's avatar
沉默王二 已提交
5 6 7
category:
  - Java核心
tag:
沉默王二's avatar
沉默王二 已提交
8 9 10 11 12
  - Java并发编程
head:
  - - meta
    - name: keywords
      content: Java,并发编程,多线程,Thread
沉默王二's avatar
沉默王二 已提交
13 14
---

沉默王二's avatar
沉默王二 已提交
15
# 第六节:多线程带来了哪些问题?
沉默王二's avatar
沉默王二 已提交
16

沉默王二's avatar
沉默王二 已提交
17
前面我们了解到,[多线程技术有很多好处](https://tobebetterjavaer.com/thread/why-need-thread.html),比如说多线程可以充分利用多核 CPU 的计算能力,那多线程难道就没有一点缺点吗?
沉默王二's avatar
沉默王二 已提交
18

沉默王二's avatar
沉默王二 已提交
19
有。
沉默王二's avatar
沉默王二 已提交
20

沉默王二's avatar
沉默王二 已提交
21 22 23
多线程很难掌握,稍不注意,就容易使程序崩溃。我们以在路上开车为例:

在一个单向行驶的道路上,每辆汽车都遵守交通规则,这时候整体通行是正常的。『单向车道』意味着『一个线程』,『多辆车』意味着『多个 job 任务』。
沉默王二's avatar
沉默王二 已提交
24

沉默王二's avatar
沉默王二 已提交
25
![单线程顺利同行](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-c0a03b79-36d8-4120-888e-0597aa66ca5b.png)
沉默王二's avatar
沉默王二 已提交
26 27 28

如果需要提升车辆的同行效率,一般的做法就是扩展车道,对应程序来说就是『加线程池』,增加线程数。这样在同一时间内,通行的车辆数远远大于单车道。

沉默王二's avatar
沉默王二 已提交
29
![多线程顺利同行](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-a65346bc-7b8b-4883-9d85-d07859df2e69.png)
沉默王二's avatar
沉默王二 已提交
30

沉默王二's avatar
沉默王二 已提交
31
然而车道一旦多起来,『加塞』的场景就会越来越多,出现碰撞后也会影响整条马路的通行效率。这么一对比下来『多车道』就比『单车道』慢多了。
沉默王二's avatar
沉默王二 已提交
32

沉默王二's avatar
沉默王二 已提交
33
![多线程故障](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-532930da-03fe-4a59-aee8-0b97b5f1a966.png)
沉默王二's avatar
沉默王二 已提交
34

沉默王二's avatar
沉默王二 已提交
35
防止汽车频繁变道加塞可以在车道间增加『护栏』,那在程序的世界里该怎么做呢?
沉默王二's avatar
沉默王二 已提交
36

沉默王二's avatar
沉默王二 已提交
37
多线程遇到的问题归纳起来就三类:`『线程安全问题』``『活跃性问题』``『性能问题』`
沉默王二's avatar
沉默王二 已提交
38

沉默王二's avatar
沉默王二 已提交
39
## 线程安全问题
沉默王二's avatar
沉默王二 已提交
40

沉默王二's avatar
沉默王二 已提交
41
有时候我们会发现,明明在单线程环境中正常运行的代码,在多线程环境中就会出现意料之外的结果,这就是大家常说的『线程不安全』。那到底什么是线程不安全呢?
沉默王二's avatar
沉默王二 已提交
42

沉默王二's avatar
沉默王二 已提交
43
### 原子性
沉默王二's avatar
沉默王二 已提交
44

沉默王二's avatar
沉默王二 已提交
45
举一个银行转账的例子,比如从账户 A 向账户 B 转 1000 元,那么必然包括 2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元,两个操作都成功才意味着一次转账最终成功。
沉默王二's avatar
沉默王二 已提交
46

沉默王二's avatar
沉默王二 已提交
47
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-eba43c92-e42d-4318-a40c-b9365c32d922.png)
沉默王二's avatar
沉默王二 已提交
48

沉默王二's avatar
沉默王二 已提交
49
试想一下,如果这两个操作不具备原子性,从 A 的账户扣减了 1000 元之后,操作突然终止了,账户 B 没有增加 1000 元,那问题就大了。
沉默王二's avatar
沉默王二 已提交
50

沉默王二's avatar
沉默王二 已提交
51
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-c22ae9be-bd80-4613-9c7e-3feb83c6c83f.png)
沉默王二's avatar
沉默王二 已提交
52

沉默王二's avatar
沉默王二 已提交
53
银行转账有两个步骤,出现意外后导致转账失败,说明没有原子性。
沉默王二's avatar
沉默王二 已提交
54

沉默王二's avatar
沉默王二 已提交
55 56
> - 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
> - 原子操作:即不会被线程调度机制打断的操作,没有上下文切换。
沉默王二's avatar
沉默王二 已提交
57 58 59 60

在并发编程中很多操作都不是原子操作,出个小题目:

```java
沉默王二's avatar
沉默王二 已提交
61
int i = 0; // 操作1
沉默王二's avatar
沉默王二 已提交
62
i++;   // 操作2
沉默王二's avatar
沉默王二 已提交
63
int j = i; // 操作3
沉默王二's avatar
沉默王二 已提交
64 65 66
i = i + 1; // 操作4
```

沉默王二's avatar
沉默王二 已提交
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
上面这四个操作中哪些是原子操作,哪些不是呢?

有些小伙伴可能认为这些都是原子操作,其实只有操作 1 是原子操作。

- 操作 1:这是原子操作,因为它是一个单一的、不可分割的步骤。
- 操作 2:这不是原子操作。这实际上是一个 "read-modify-write" 操作,它包括了读取 i 的值,增加 i,然后写回 i。
- 操作 3:这是原子操作,因为它是一个单一的、不可分割的步骤。
- 操作 4:这不是原子操作。和 i++ 一样,这也是一个 "read-modify-write" 操作。

在单线程环境下上述四个操作都不会出现问题,但是在多线程环境下,如果不加锁的话,可能会得到意料之外的值。我们来测试一下,看看输出结果。

```java
public class YuanziDeo {
    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        int numThreads = 2;
        int numIncrementsPerThread = 100000;

        Thread[] threads = new Thread[numThreads];

        for (int j = 0; j < numThreads; j++) {
            threads[j] = new Thread(() -> {
                for (int k = 0; k < numIncrementsPerThread; k++) {
                    i++;
                }
            });
            threads[j].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Final value of i = " + i);
        System.out.println("Expected value = " + (numThreads * numIncrementsPerThread));
    }
}
```
沉默王二's avatar
沉默王二 已提交
106

沉默王二's avatar
沉默王二 已提交
107
输出如下:
沉默王二's avatar
沉默王二 已提交
108

沉默王二's avatar
沉默王二 已提交
109 110 111 112
```
Final value of i = 102249
Expected value = 200000
```
沉默王二's avatar
沉默王二 已提交
113

沉默王二's avatar
沉默王二 已提交
114
i 期望的值为 200000,但实际跑出来的是 102249,这证明 i++ 不是一个原子操作,对吧?
沉默王二's avatar
沉默王二 已提交
115

沉默王二's avatar
沉默王二 已提交
116
### 可见性
沉默王二's avatar
沉默王二 已提交
117

沉默王二's avatar
沉默王二 已提交
118
talk is cheap,show me code,来看这段代码:
沉默王二's avatar
沉默王二 已提交
119 120 121 122 123

```java
class Test {
  int i = 50;
  int j = 0;
沉默王二's avatar
沉默王二 已提交
124

沉默王二's avatar
沉默王二 已提交
125 126 127 128
  public void update() {
    // 线程1执行
    i = 100;
  }
沉默王二's avatar
沉默王二 已提交
129

沉默王二's avatar
沉默王二 已提交
130 131 132 133 134 135 136 137
  public int get() {
    // 线程2执行
    j = i;
    return j;
  }
}
```

沉默王二's avatar
沉默王二 已提交
138
假如有两个线程,线程 1 执行 update 方法将 i 赋值为 100,一般情况下线程 1 会在自己的工作内存中完成赋值操作,但不会及时将新值刷新到主内存中。
沉默王二's avatar
沉默王二 已提交
139

沉默王二's avatar
沉默王二 已提交
140
这个时候线程 2 执行 get 方法,首先会从主内存中读取 i 的值,然后加载到自己的工作内存中,此时读到 i 的值仍然是 50,再将 50 赋值给 j,最后返回 j 的值就是 50 了。原本期望返回 100,结果返回 50,这就是可见性问题,线程 1 对变量 i 进行了修改,线程 2 并没有立即看到 i 的新值。
沉默王二's avatar
沉默王二 已提交
141

沉默王二's avatar
沉默王二 已提交
142
> 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
沉默王二's avatar
沉默王二 已提交
143

沉默王二's avatar
沉默王二 已提交
144
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-d91ca0c2-4f39-4e98-90e2-8acb793eb983.png)
沉默王二's avatar
沉默王二 已提交
145

沉默王二's avatar
沉默王二 已提交
146
如上图,每个线程都有属于自己的工作内存,工作内存和主内存间需要通过 store 和 load 等进行交互。
沉默王二's avatar
沉默王二 已提交
147

沉默王二's avatar
沉默王二 已提交
148
为了解决多线程的可见性问题,Java 提供了`volatile`这个关键字。当一个共享变量被 volatile 修饰时,它会保证修改的值立即更新到主存当中,这样的话,当有其他线程需要读取时,就会从内存中读到新值。普通的共享变量不能保证可见性,因为变量被修改后什么时候刷回到主存是不确定的,因此另外一个线程读到的可能就是旧值。
沉默王二's avatar
沉默王二 已提交
149

沉默王二's avatar
沉默王二 已提交
150
当然 Java 的锁机制如 synchronized 和 lock 也是可以保证可见性的。
沉默王二's avatar
沉默王二 已提交
151

沉默王二's avatar
沉默王二 已提交
152
### 活跃性问题
沉默王二's avatar
沉默王二 已提交
153

沉默王二's avatar
沉默王二 已提交
154
上面讲到为了解决`可见性`的问题,我们可以采取加锁的方式来解决,但如果加锁使用不当也容易引入其他问题,比如『死锁』。
沉默王二's avatar
沉默王二 已提交
155

沉默王二's avatar
沉默王二 已提交
156
在讲『死锁』之前,我们需要先引入另外一个概念:`活跃性问题`
沉默王二's avatar
沉默王二 已提交
157

沉默王二's avatar
沉默王二 已提交
158
> 活跃性是指某件正确的事情最终会发生,但当某个操作无法继续下去的时候,就会发生活跃性问题。
沉默王二's avatar
沉默王二 已提交
159

沉默王二's avatar
沉默王二 已提交
160
概念可能有点拗口,活跃性问题一般有这样几类:`死锁``活锁``饥饿问题`
沉默王二's avatar
沉默王二 已提交
161

沉默王二's avatar
沉默王二 已提交
162
#### 死锁
沉默王二's avatar
沉默王二 已提交
163

沉默王二's avatar
沉默王二 已提交
164
死锁是指多个线程因为环形等待锁的关系而永远地阻塞下去。
沉默王二's avatar
沉默王二 已提交
165

沉默王二's avatar
沉默王二 已提交
166
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-d4e65d5f-3de1-4a1c-8ae1-02cb3bfb528c.png)
沉默王二's avatar
沉默王二 已提交
167

沉默王二's avatar
沉默王二 已提交
168
#### 活锁
沉默王二's avatar
沉默王二 已提交
169

沉默王二's avatar
沉默王二 已提交
170
死锁是两个线程都在等待对方释放锁导致阻塞。而`活锁`的意思是线程没有阻塞,还活着呢。当多个线程都在运行并且都在修改各自的状态,而其他线程又依赖这个状态,就导致任何一个线程都无法继续执行,只能重复着自身的动作,于是就发生了活锁。
沉默王二's avatar
沉默王二 已提交
171

沉默王二's avatar
沉默王二 已提交
172
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-d1f9e916-0985-46fe-bf87-63fccfd27bae.png)
沉默王二's avatar
沉默王二 已提交
173

沉默王二's avatar
沉默王二 已提交
174
举一个生活中的例子,大家平时在走路的时候,迎面走来一个人,两个人互相让路,但是又同时走到了一个方向,如果一直这样重复着避让,这俩人就发生了活锁,学到了吧,嘿嘿。
沉默王二's avatar
沉默王二 已提交
175

沉默王二's avatar
沉默王二 已提交
176
#### 饥饿
沉默王二's avatar
沉默王二 已提交
177

沉默王二's avatar
沉默王二 已提交
178
如果一个线程无其他异常却迟迟不能继续运行,那基本上是处于饥饿状态了。
沉默王二's avatar
沉默王二 已提交
179

沉默王二's avatar
沉默王二 已提交
180
常见的有几种场景:
沉默王二's avatar
沉默王二 已提交
181

沉默王二's avatar
沉默王二 已提交
182
- 高优先级的线程一直在运行消耗 CPU,所有的低优先级线程一直处于等待;
沉默王二's avatar
沉默王二 已提交
183 184
- 一些线程被永久堵塞在一个等待进入同步块的状态,而其他线程总是能在它之前持续地对该同步块进行访问;

沉默王二's avatar
沉默王二 已提交
185
有一个非常经典的饥饿问题就是`哲学家用餐问题`,如下图所示,有五个哲学家在用餐,每个人必须要同时拿两把叉子才开始就餐,如果哲学家 1 和哲学家 3 同时开始就餐,那哲学家 2、4、5 就得饿肚子等待了。
沉默王二's avatar
沉默王二 已提交
186

沉默王二's avatar
沉默王二 已提交
187
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-314a47df-c953-4b7d-831c-007173981819.png)
沉默王二's avatar
沉默王二 已提交
188

沉默王二's avatar
沉默王二 已提交
189
## 性能问题
沉默王二's avatar
沉默王二 已提交
190

沉默王二's avatar
沉默王二 已提交
191
前面讲到了线程安全和死锁、活锁这些问题,如果这些都没有发生,多线程并发一定比单线程串行执行快吗?答案是不一定,因为多线程有`创建线程``线程上下文切换`的开销。
沉默王二's avatar
沉默王二 已提交
192

沉默王二's avatar
沉默王二 已提交
193
创建线程是直接向系统申请资源的,对操作系统来说,创建一个线程的代价是十分昂贵的,需要给它分配内存、列入调度等。
沉默王二's avatar
沉默王二 已提交
194

沉默王二's avatar
沉默王二 已提交
195
线程创建完之后,还会遇到线程`上下文切换`
沉默王二's avatar
沉默王二 已提交
196

沉默王二's avatar
沉默王二 已提交
197
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-d125d0b9-3b60-46cd-a79f-a26dd5210b44.png)
沉默王二's avatar
沉默王二 已提交
198

沉默王二's avatar
沉默王二 已提交
199
CPU 是很宝贵的资源,速度非常快,为了保证雨露均沾,通常会给不同的线程分配`时间片`,当 CPU 从执行一个线程切换到执行另一个线程时,CPU 需要保存当前线程的本地数据,程序指针等状态,并加载下一个要执行线程的本地数据,程序指针等,也就是『上下文切换』。
沉默王二's avatar
沉默王二 已提交
200 201 202

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

沉默王二's avatar
沉默王二 已提交
203 204 205 206
- 无锁并发编程:可以参照 [ConcurrentHashMap](https://javabetter.cn/thread/ConcurrentHashMap.html) 锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
- CAS 算法,利用 [Atomic](https://javabetter.cn/thread/atomic.html) + [CAS](https://javabetter.cn/thread/cas.html) 算法来更新数据,采用乐观锁的方式,可以有效减少一部分不必要的锁竞争带来的上下文切换。
- 使用最少线程:避免创建不必要的线程,如果任务很少,但创建了很多的线程,这样就会造成大量的线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
沉默王二's avatar
沉默王二 已提交
207

沉默王二's avatar
沉默王二 已提交
208
## 小结
沉默王二's avatar
沉默王二 已提交
209 210 211

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

沉默王二's avatar
沉默王二 已提交
212
用一张图来总结一下上面讲的:
沉默王二's avatar
沉默王二 已提交
213

沉默王二's avatar
沉默王二 已提交
214
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/thread-bring-some-problem-119223c9-83a9-42e1-9a0c-f9c706a1e793.png)
沉默王二's avatar
沉默王二 已提交
215

沉默王二's avatar
沉默王二 已提交
216
> 编辑:沉默王二,编辑前的内容来自于朋友雷小帅的开源仓库[Java 八股文](https://github.com/CoderLeixiaoshuai/java-eight-part),内容很不错,强烈推荐。
沉默王二's avatar
jvm  
沉默王二 已提交
217

沉默王二's avatar
沉默王二 已提交
218
---
沉默王二's avatar
沉默王二 已提交
219

沉默王二's avatar
9000+  
沉默王二 已提交
220
GitHub 上标星 9000+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 9000+ 的 Java 教程](https://javabetter.cn/overview/)
沉默王二's avatar
沉默王二 已提交
221 222

微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。
沉默王二's avatar
沉默王二 已提交
223

沉默王二's avatar
沉默王二 已提交
224
![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png)