Java并发编程基础小结
[toc]
# 写在文章开头
这也算是笔者一直重构梳理的一篇文章,不同的阶段对于并发编程的禅修都有不一样的理解,而本次的进阶将更多维度是去强调并发编程所需要关注的一些基础问题和本质,希望对你有帮助。 我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 并发编程中的一些核心思想
# 为什么需要多线程
计算机发展初期都是以进程为维度分配内存、文件句柄以及安全证书等资源,同时多个进程之间采用一些比较粗粒度的通信机制来交换数据,包括:
- 套接字
- 信号处理器
- 共享内存
基于并发编程实战的思想:
高效做事的人,总能在串行性和异步性之间找到一个合理的平衡点,程序也是如此。
于是操作系统就引入多进程运行的调度机制,例如下面这个步骤:
- 在一个单核的计算机上进程1得到CPU执行权,随后进入IO读取任务阻塞挂起
- 处于操作系统就绪队列的进程2被唤醒得到CPU时间片执行任务
- 进程1在IO读取完成后收到中断响应也随后进入就绪队列,等待CPU执行权

基于上述基础上,考虑到每一个进程都独有各自的内存空间和文件句柄等资源,以如此庞大级别的单位处理一些单一的工作而在CPU之间进行频繁切换开销是非常不客观的,于是就有了轻量级调度单位——多线程。
以多线程调度为例,假设进程1、进程2分别对应读取定时读取网络数据、定时写入数据到网络系统日志,按照多线程维度将二者合并,最终的进程交由CPU执行,我们就可以得到这样一个场景:
- CPU执行到线程1,读取网络数据,IO阻塞,让出CPU。
- 线程2写入之前的网络系统日志到磁盘,进行write调用时切换到内核态,让出CPU。
- 线程1完成数据,进程终端输出结果,让出CPU。
- 线程2write调用返回,继续进行下一次的写入......

# 多线程有哪些优势
如上面所说,多线程存在如下优势:
- 轻量:以线程为单位构成进程,共享进程范围内的资源,例如内存、文件句柄等。
- 返回多核处理器的强大能力:操作系统以更轻量级的线程为单位进行高效的调度和切换,在设计合理的情况下,可以大大提升CPU的利用率。
- 建模简单性:利用多线程技术,可以将复杂的异步任务组合的同步工作流
(例如JDK8中的CompleteFuture工具类),并利用多线程分别执行这些任务,在指定时机进行同步交互。 - 异步事件简化处理:有了多线程的概念之后,早期尝试过用BIO技术即一个线程分配一个客户端socket,好在现代Unix系统提出epoll、io_uring的良好设计,使得多线程技术有了更好的发挥。
# 并发编程需要关注的问题
# 安全性问题
首先是线程安全性问题,因为多线程共享了一块进程的数据,如果没有充分的做好线程间的同步,就会出现一些意外的情况,就例如下面这段代码,多线程操作一个num,因为自增操作非复合操作且多线程操作彼此不可见,出现意外结果:
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100_0000; i++) {
num++;
}
countDownLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100_0000; i++) {
num++;
}
countDownLatch.countDown();
}).start();
countDownLatch.await();
System.out.println(num);//输出1499633
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
同样的,如果没有良好的同步机制,编译器、处理器都可以针对指令进行任意顺序和时间执行,同时在处理器或者寄存器缓存线程变量的情况下的修改操作,其他处理器的线程是无法看到其修改操作,也会导致逻辑运算上的错乱:

# 活跃性问题
线程活跃性问题即线程未能按照预期的时许执行,导致线程持续的活跃最典型的表现就是无限循环,打满CPU。例如并发环境下两个CPU分别执行线程0和线程1的逻辑,即:
- 线程0执行无限循环,只要val变为true则终止无限循环,
- 线程1休眠一段时间后将val修改为true。
对于java并发编程而言,如果没有添加保证可见性的关键字进行修饰,线程1的修改操作对于线程0来说是不可见的,此时就会出现下图所示的线程1修改仅对自己可见,并不会即使刷新到CPU多核共享内存L3 Cache,进而导致线程0无限循环,也就是我们所说的活跃性问题:

对应我们也可以出示例代码,同时笔者也会在后续的文章中来补充说明这一点的解决方案:
private static boolean val = false;
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
while (!val) {//下方线程操作对于线程1不可见,进行无限循环
}
System.out.println("thread-1 executed finished");
countDownLatch.countDown();
}).start();
new Thread(() -> {
ThreadUtil.sleep(5, TimeUnit.SECONDS);
val = true;
System.out.println("设置val为true");
countDownLatch.countDown();
}).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
通常来说活跃性问题都是由以下几种错误导致:
- 死锁:即两个线程互相等待对象持有的资源进入阻塞
- 活锁:上述的活跃性问题就是最经典的活锁
- 线程饥饿:因为线程过多或者某些原因导致某个线程长时间未能分配到CPU时间片,导致任务迟迟无法结束,这就是典型的线程饥饿问题
# 性能问题
这一点是老生常态了,应对并发安全的手段就是保证可见性和互斥,这涉及CPU缓存更新和临界资源维度的把控和并发运算技巧,一般来说导致多线程性能瓶颈的几种原因可分为:
- 同步机制抑制了某些编译器的优化,例如
synchronized关键字。 - 共享变量在多处理器之间不同线程执行,线程切换时处理器的缓存数据局部性失效,使得开销大部分时间都在处理线程调度而非运算,这也会导致程序的执行性能下降。
- 多线程并发处理时切换线程时产生保存和恢复上下文的开销。
# JVM视角下的进程和线程
如下图所示,可以看出线程是比进程更小的单位,进程是独立的,彼此之间不会干扰,但是线程在同一个进程中共享堆区和方法区,虽然开销较小,但是资源之间管理和分配处理相对于进程之间要更加小心。

# 多线程常见问题
# 程序计数器、虚拟机栈、本地方法栈为什么线程中是各自独立的
- 程序计数器私有的原因:学过计算机组成原理的小伙伴应该都知晓,程序计数器用于记录当前下一条要执行的指令的单元地址,
JVM也一样,有了程序计数器才能保证在多线程的情况下,这个线程被挂起再被恢复时,我们可以根据程序计数器找到下一次要执行的指令的位置。 - 虚拟机栈私有的原因:每一个
Java线程在执行方法时,都会创建一个栈帧用于保存局部变量、常量池引用、操作数栈等信息,在这个方法调用到完成前,它对应的信息都会基于栈帧保存在虚拟机栈上。 - 本地方法栈私有的原因:和虚拟机栈类似,只不过本地方法栈保存的
native方法的信息。
所以为了保证局部变量不被别的线程访问到,虚拟机栈和本地方法栈都是私有的,这就是我们解决某些线程安全问题时,常会用到一个叫栈封闭技术。
关于栈封闭技术如下所示,将变量放在局部,每个线程都有自己的虚拟机栈,线程安全
public class StackConfinement implements Runnable {
//全部变量 多线操作会有现场问题
int globalVariable = 0;
public void inThread() {
//栈封闭技术,将变量放在局部,每个线程都有自己的虚拟机栈 线程安全
int neverGoOut = 0;
synchronized (this) {
for (int i = 0; i < 10000; i++) {
neverGoOut++;
}
}
System.out.println("栈内保护的数字是线程安全的:" + neverGoOut);//栈内保护的数字是线程安全的:10000
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
globalVariable++;
}
inThread();
}
public static void main(String[] args) throws InterruptedException {
StackConfinement r1 = new StackConfinement();
Thread thread1 = new Thread(r1);
Thread thread2 = new Thread(r1);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(r1.globalVariable); //13257
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 并发和并行的区别是什么?
并发:并发我们可以理解为,两个线程先后执行,但是从宏观角度来看,他们几乎是并行的。并行:并行我们可以理解为两个线程同一时间都在运行。
# 同步和异步是什么意思?
同步:同步就是一个调用没有结果前,不会返回,直到有结果的才返回。异步:异步即发起一个调用后,不等结果如何直接返回。
# 为什么需要多线程,多线程解决了什么问题
从宏观角度来看:线程可以理解为轻量级进程,切换开销远远小于进程,所以在多核CPU的计算机下,使用多线程可以更好的利用计算机资源从而提高计算机利用率和效率来应对现如今的高并发网络环境。
从微观场景下来说: 单核场景,在单核CPU情况下,假如一个线程需要进行IO才能执行业务逻辑,若只有单线程,这就意味着IO期间发生阻塞线程却只能干等。假如我们使用多线程的话,在当前线程IO期间,我们可以将其挂起,让出CPU时间片让其他线程工作。
多核场景下,假如我们有一个很复杂的任务需要进程各种IO和业务计算,假如只有一个线程的话,无论我们有多少个CPU核心,因为单线程的缘故他永远只能利用一个CPU核心,假如我们使用多线程,那么这些线程就会映射到不同的CPU核心上,做到最好的利用计算机资源,提高执行效率,执行事件约为单线程执行事件/CPU核心数。
# 创建线程方式有哪些
直接继承Thread启动运行:
public static void main(String[] args) {
new Task().start();
}
/**
* 继承thread重写run方法
*/
private static class Task extends Thread {
@Override
public void run() {
Console.log("{} is running", Thread.currentThread().getName());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
通过继承Runable实现run方法并提交给thread运行:
public static void main(String[] args) {
new Thread(new Task()).start();
}
/**
* 继承Runnable重写run方法
*/
private static class Task implements Runnable {
@Override
public void run() {
Console.log("{} is running", Thread.currentThread().getName());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 为什么需要Runnable接口实现多线程
由于Java为避免棱形问题所以只支持单继承,当一个类已有继承类时,某个函数需要实现异步功能的时候只能通过接口进行拓展,所以才有了Runnable接口。
# Thread和Runnable使用的区别
- 继承Thread:线程代码存放在
Thread子类的run方法中,调用start()即可实现调用。 - Runnable:线程代码存在接口子类的
run方法中,需要实例化一个线程对象Thread并将其作为参数传入,才能调用到run方法。
# Thread类中run()和start()的区别
- run:仅仅是方法,在线程实例化之后使用
run等于一个普通对象的直接调用。 - start:开启了线程并执行线程中的
run方法,这期间程序才真正执行从用户态到内核态,创建线程的动作。
# Java线程有哪几种状态
新建(NEW):新创建的了一个线程对象,该对象并没有调用start()。
可运行(RUNNABLE):线程对象创建后,并调用了start方法,等待分配CPU时间执行代码逻辑。
阻塞(BLOCKED):阻塞状态,等待锁的释放。当线程在synchronized 中被wait,然后再被唤醒时,若synchronized 有其他线程在执行,那么它就会进入BLOCKED状态。
等待(WAITING):因为某些原因被挂起,等待其他线程通知或者唤醒。
超时等待(TIME_WAITING):等待时间后自行返回,而不像WAITING那样没有通知就一直等待。
终止(TERMINATED):该线程执行完毕,终止状态了。
public enum State {
//线程尚未启动
NEW,
//可运行的线程状态,该状态代表的是操作系统中线程状态的ready或者running状态
RUNNABLE,
//阻塞等待监视器(synchronized底层的monitor lock实现)或者主动调用wait后被唤醒等待获取监视锁也会处于该状态
BLOCKED,
//调用wait挂起等待notify或者notifyAll
WAITING,
//设置时限的wait调用挂起,可能是调用下面某个方法
//Thread.sleep
//Object.wait with timeout
//Thread.join with timeout
//LockSupport.parkNanos
//LockSupport.parkUntil
TIMED_WAITING,
//线程已完成执行并终止
TERMINATED;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 和操作系统的线程状态的区别
如下图所示,实际上操作系统层面可将RUNNABLE分为Running以及Ready,Java设计者之所以没有区分那么细是因为现代计算机执行效率非常高,这两个状态在宏观角度几乎无法感知。现代操作系统对多线程采用时间分片的抢占式调度算法,使得每个线程得到CPU在10-20ms 处于运行状态,然后在让出CPU时间片,在不久后又会被调度执行,所以对于这种微观状态区别,Java设计者认为没有必要为了这么一瞬间进行这么多的状态划分。

# 什么是上下文切换
线程在执行过程中都会有自己的运行条件和状态,这些运行条件和状态我们就称之为线程上下文,这些信息例如程序计数器、虚拟机栈、本地方法栈等信息。当出现以下几种情况的时候就会从占用CPU状态中退出:
- 线程主动让出
CPU,例如调用wait或者sleep等方法。 - 线程的CPU 时间片用完 而退出
CPU占用状态(因为操作系统为了避免某些线程独占CPU导致其他线程饥饿的情况就设定的例如时间分片算法)。 - 线程调用了阻塞类型的系统中断,例如
IO请求等。 - 线程被终止或者结束运行。
上述的前三种情况都会发生上下文切换。为了保证线程被切换在恢复时能够继续执行,所以上下文切换都需要保存线程当前执行的信息,并恢复下一个要执行线程的现场。这种操作就会占用CPU和内存资源,频繁的进行上下文切换就会导致整体效率低下。
# 线程死锁问题
如下图所示,两个线程各自持有一把锁,必须拿到对方手中那把锁才能释放自己的锁,正是这样一种双方僵持的状态就会导致线程死锁问题。

翻译称代码就如下图所示:
public class DeadLockDemo {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1){
System.out.println("线程1获得锁1,准备获取锁2");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2){
System.out.println("线程1获得锁2");
}
}
}).start();
new Thread(() -> {
synchronized (lock2){
System.out.println("线程2获得锁2,准备获取锁1");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1){
System.out.println("线程2获得锁1");
}
}
}).start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
输出结果
线程1获得锁1,准备获取锁2
线程2获得锁2,准备获取锁1
2
符合以下4个条件的场景就会发生死锁问题:
- 互斥:一个资源任意时间只能被一个线程获取。
- 请求与保持条件:一个线程拿到资源后,在获取其他资源而进入阻塞期间,不会释放已有资源。
- 不可剥夺条件:该资源被线程使用时,其他线程无法剥夺该线程使用权,除非这个线程主动释放。
- 循环等待条件:若干线程获取资源时,取锁的流程构成一个头尾相接的环,如上图。
预防死锁的3种方式
- 破坏请求与保持条件:以上面代码为例,我们要求所有线程必须一次性获得两个锁才能进行业务处理。即要求线程一次性获得所有资源才能进行逻辑处理。
- 破坏不可剥夺:资源被其他线程获取时,我们可以强行剥夺使用权。
- 破坏循环等待:这个就比较巧妙了,例如我们上面lock1 id为1,lock2id为2,我们让每个线程取锁时都按照lock的id顺序取锁,这样就避免构成循环队列。
- 操作系统思想(银行家算法):这个就涉及到操作系统知识了,大抵的意思是在取锁之前对资源分配进行评估,如果在给定资源情况下不能完成业务逻辑,那么就避免这个线程取锁,感兴趣的读者可以
# sleep和wait方法区别
sleep不会释放锁,只是单纯休眠一会。而wait则会释放锁。sleep单纯让线程休眠,在给定时间后就会苏醒,而wait若没有设定时间的话,只能通过notify或者notifyAll唤醒。sleep是Thread的方法,而wait是Object的方法wait常用于线程之间的通信或者交互,而sleep单纯让线程让出执行权。
# 为什么sleep会定义在Thread
因为sleep要做的仅仅是让线程休眠,所以不涉及任何锁释放等逻辑,放在Thread上最合适。
# 为什么wait会定义在Object 上
我们都知道使用wait时就会释放锁,并让对象进入WAITING 状态,会涉及到资源释放等问题,所以我们需要将wait放在Object 类上。
# 可以直接调用 Thread 类的 run 方法吗?
若我们编写run方法,然后调用Thread 的start方法,线程就会从用户态转内核态创建线程,并在获取CPU时间片的时候开始运行,然后运行run方法。
若直接调用run方法,那么该方法和普通方法没有任何差别,它仅仅是一个名字为run的普通方法。
# 假如在进程中, 已经开辟了多个线程, 其中一个线程怎么中断其它线程?
找到线程对应线程组并基于线程id即可定位到线程,然后调用interrupt将其打断即可:
public static Thread getThreadById(long threadId) {
//获取线程对应线程组
ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
//比对id定位线程
if (threadGroup != null) {
Thread[] threads = new Thread[(int) (threadGroup.activeCount() * 1.2)];
//获取线程组中获取的线程数
int count = threadGroup.enumerate(threads, true);
for (int i = 0; i < count; i++) {
if (threads[i].getId() == threadId) {
return threads[i];
}
}
}
throw new RuntimeException("未找到线程");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
对应的我们也给出使用示例,感兴趣的读者可自行参阅注释了解实现细节:
//创建含有2个线程的线程池
private static final ExecutorService threadPool = Executors.newFixedThreadPool(2);
//记录用于打断的线程id
private static Long threadId;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
//线程1无限休眠,直到被打断
threadPool.execute(() -> {
Console.log("线程池线程启动执行,线程id:{}", Thread.currentThread().getId());
threadId = Thread.currentThread().getId();
try {
TimeUnit.DAYS.sleep(1);
} catch (InterruptedException e) {
Console.error("当前线程被打断,线程id:{}", Thread.currentThread().getId(), e);
} finally {
countDownLatch.countDown();
}
});
//线程2用于打断线程1
threadPool.execute(() -> {
while (true) {
if (threadId != null) {
Console.log("打断线程,线程id:{}", threadId);
getThreadById(threadId).interrupt();
countDownLatch.countDown();
break;
}
ThreadUtil.sleep(5000);
}
});
countDownLatch.await();
threadPool.shutdownNow();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
对应输出结果如下,可以看到threadId 非空时,线程2就会将休眠的线程1打断:

# IO阻塞的线程会占用CPU资源吗?如何避免线程霸占CPU?
由于该问题的篇幅比较大,笔者专门写了一篇文章来讨论这两个问题,感兴趣的朋友可以看看: IO任务与CPU调度艺术:https://mp.weixin.qq.com/s/le3nBV0JpUhW6rzVtvBHmw (opens new window)
# 小结
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 参考
Java并发编程:volatile关键字解析:https://www.cnblogs.com/dolphin0520/p/3920373.html (opens new window)
我是一个线程:https://mp.weixin.qq.com/s/IkNfuE541Mqqbv2iLIhMRQ (opens new window)
Java 并发常见面试题总结(上):https://javaguide.cn/java/concurrent/java-concurrent-questions-01.html#什么是线程和进程 (opens new window)
创建线程几种方式_线程创建的四种方式及其区别:https://cloud.tencent.com/developer/article/2135189 (opens new window)
《Java并发编程实战》
揭秘 CPU 缓存:L1、L2 和 L3 的性能秘密:https://www.sysgeek.cn/what-is-cpu-cache/ (opens new window)