多线程

多线程

一. 进程

进程指计算机中运行的单元。不同的程序在单独的进程中运行,程序本身只是指令、数据以及组织形式,进程才是程序的真正运行实例。

1.1 进程的特性

  • 面向进程的系统中,进程是程序的基本执行实体;面向线程的系统中,进程不是基本运行单位,而是线程的容器
  • 同一个程序可以生成多个进程(一对多关系),以允许同时有多位用户运行同一程序,却不会相冲突(多CPU)。
  • 促使进程出现的因素:
    • 资源利用率:程序等待某个操作的同时可以做其他事情。
    • 公平性:不同的程序对资源有同等的使用权。通过粗粒度的时间分片使用户和程序能共享计算机资源。
    • 便利性:计算多个任务时,编写多个程序每个执行一个任务,在必要时进行通信,这比一个程序实现所有要容易实现。

操作系统为进程分配各种资源(内存、文件句柄以及安全证书等),进程间通过粗粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量以及文件等。

操作系统为了支持多个应用同时运行,需要保证不同进程之间相对独立(一个进程的崩溃不会影响其他的进程 , 恶意进程不能直接读取和修改其他进程运行时的代码和数据)。 因此操作系统内核需要拥有高于普通进程的权限, 以此来调度和管理用户的应用程序。

于是内存空间被划分为两部分,一部分为内核空间,一部分为用户空间,内核空间存储的代码和数据具有更高级别的权限。内存访问的相关硬件在程序执行期间会进行访问控制(Access Control),使得用户空间的程序不能直接读写内核空间的内存。


1.2 多核

多核指CPU拥有多个处理器核心,这些核心可以分别独立的运行程序指令,利用并行计算的能力来加快程序的运行效率。当然这需要程序支持多核处理,目前市场上已几乎没有单核CPU。

多核意味着一个进程可以把多个线程分别在不同的核心上运行,通过缓存一致性协议来保证一致性。


二. 线程

2.1 什么是线程

线程允许一个进程中同时存在多个程序控制流。操作系统运行一个程序时,会为其创建一个进程,线程是一个独立执行的调用序列,是一个进程中独立运行的子任务。线程是操作系统调度的最小单元,一个进程可以创建多个线程,多个线程可以被同时调度到多个CPU上运行。

线程会共享进程范围内的资源(内存句柄和文件句柄),每个线程拥有自己的程序计数器(记录要执行的下一条指令)、一组寄存器(保存当前线程的工作变量)、堆栈(记录执行历史,其中每一帧保存了一个已经调用但未返回的过程)、局部变量等属性,能够访问共享的内存变量。

同一个进程的线程会共享一些系统资源也能访问同一个进程所创建的对象资源(内存资源)。同个进程内的所有线程共享进程的内存地址空间,因此线程都能访问相同的变量并在同一个堆上分配对象,这需要实现一种比在进程间共享数据粒度更细的数据共享机制,否则会造成不可预测的线程不安全问题。

2.2 优缺点

优点:

  • 发挥多处理器的强大能力:因为基本调度单位是线程,如果程序中只有一个线程,最多只能在一个处理器上运行。提高用户界面的响应速度,服务程序的资源利用率以及系统吞吐率。
  • 建模简单:当要处理不同类型的任务时,需要管理任务间的优先级和执行时间,并进行切换。多线程可以将复杂且异步的工作流进一步分解为一组简单且同步的工作流,每个工作流在单独的线程中运行,并且在特定的同步位置进行交互。
  • 异步事件的简单处理:同时处理多个请求,单线程必须使用非阻塞I/O,其复杂度远远高于同步I/O。
  • 响应更灵敏的用户界面:当事件线程执行的任务耗时较久,需要等待较长时间获得响应,界面会被冻结,并且即使有取消按钮也无法中断任务,而任务在一个单独的线程中运行,程序可以处理界面的点击事件从而有更高的灵敏度。

缺点:

  • 安全性问题:安全性指永远不会发生糟糕的事情。线程间交替操作可能会导致数据出错,线程间共享内存地址空间,且并发执行,会访问或修改其它线程在使用的变量(这是比线程间通信机制更方便的数据共享方式)带来了极大的风险。在串行模型中引入了非串行因素,必须对共享变量的访问操作进行协同(同步机制)才能保证线程间不相互干扰。
  • 活跃性问题:活跃性指某件正确的事情最终会发生。串行中常见的活跃性问题如无意中造成的无限循环,导致循环后的程序无法执行。线程则表现为,线程A等待线程B释放资源,但线程B永远都不释放。
  • 性能问题:性能则关注正确的事情尽快的发生。性能问题包括:服务时间过长、响应不灵敏、吞吐率过低、资源消耗过高、可伸缩性较低等。
    • 多线程程序中,当线程调度器临时挂起活跃线程转而执行其它线程时,会频繁出现上下文切换操作,带来极大的开销:保存和恢复执行上下文,丢失局部性,并且CPU时间更多的花在调度而不是线程运行上。
    • 线程共享数据时,必须使用同步机制,这些机制往往会抑制某些编译器优化,使内存缓存区的数据无效,以及增加共享内存总线的同步流量。

2.1 多线程

多线程程序即可以同时执行多个任务的程序。每个程序至少有一个线程,即作为JVM启动参数而运行在主类main方法中的主线程,其他线程都是用户线程。Java本身即多线程程序,除了main线程也会同时执行其他线程。

进程拥有自己的一套变量,线程则共享数据,所以线程间的通信相比进程要更方便,且线程比较轻量开销小。在实际应用中,比如浏览器要同时下载多个文件,服务器处理并发的请求等都需要用到多线程的概念。

多线程本质即解决两个问题:

  • 线程间如何通信
  • 线程间如何同步

(1)为什么要用多线程?

为了更好的利用CPU的空闲时间,让程序运行的更快,处理器会在线程间高速切换,让使用者感觉线程间在同时执行。CPU核心越来越多,单线程只能同时运行在一个核心,多线程更契合多核的发展趋势。对于一些复杂业务流程,可以将一些数据一致性不强的操作派发给其他线程或消息队列,减少服务响应时间。

(2)多线程一定会比单线程更快吗?

不是,测试当并发执行计数器时,若未达到百万级别,多线程要慢于单线程,因为线程的创建和上下文切换造成了一定的开销。


2.2 上下文切换/线程切换

通常任务数(线程)要大于CPU核心数,单个CPU核心只能运行一个任务,所以需要有机制来充分的利用CPU的工作时间,这使得单核处理器也可以支持多线程

CPU会给每个线程分配时间片,而每个时间片很短,CPU可以快速的切换线程执行,所以让用户有种CPU在同时处理很多线程任务的错觉。

CPU通过时间片分配算法来循环的执行各线程的任务,当时间片用完就中断当前线程并切换到另一个线程,选择下一个线程可能会考虑到线程优先级,这种抢占式调度在PC等设备比较常见,而手机这类设备则通常是协作式调度,线程只有在一些情况下才(调用yeild方法,被阻塞或等待)会失去控制权。

上下文切换就是指CPU从一个进程/线程切换到另一个进程/线程(上下文指某个时间节点CPU寄存器和计数器的内容)。大概过程可以简单的理解为:

  • 挂起当前线程(存储当前上下文)。
  • 恢复一个线程(找到一个合适的上下文并恢复到寄存器)。
  • 跳转到程序计数器指向的位置(即线程被中断时的代码行)。

在切换线程时需要记录执行状态,以便切回时可以继续执行。因为上下文切换会为并发编程带来无谓的开销(包括切换所需CPU操作的直接开销和多核缓存共享的间接开销),所以实际编程中应需要考虑怎样来减少上下文切换。

在Linux系统下可以使用vmstat命令来查看上下文切换的次数。

(1)导致上下文切换的原因

  1. 中断处理:CPU收到中断请求,会进行一次发起者和被中断者间的上下文切换,中断的来源有多种。
  2. 多任务处理:多任务处理系统中,CPU会不停的根据分配的时间片切换,所以会在时间片间隔中进行上下文切换。
  3. 用户态切换:对于部分操作系统,进行用户态切换时也会进行上下文切换,虽然不是必须的。

对于常用的计算机这种抢占式调度的操作系统,导致上下文切换的原因:当前时间片用完、当前执行任务遇到I/O阻塞被挂起、多个任务抢夺锁但当前任务未抢到导致被挂起、开发者手动挂起当前任务、硬件中断

(2)减少上下文切换的方法

  1. 无锁并发编程: 多线程之间竞争锁时,会引发上下文切换,所以可以通过一些方法(Hash算法对数据ID取模分段,不同线程处理各自分段)来避免使用锁。

  2. CAS算法: Java的Atomic包使用CAS算法来更新数据,不需要加锁。可参考CAS原理

  3. 使用最少线程: 避免创建不必要的线程。

  4. 使用协程: 在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。


2.3 线程优先级

线程优先级决定了线程调度时分配线程的先后,每个线程都有优先级,默认继承其父线程的优先级。Java通过整型变量priority控制优先级,可以通过 setPriority() 调整在 min(1)max(10) 之间,默认为5。

当线程调度器要选择新线程时会优先选择高优先级的线程,使用优先级时应该慎重考虑会不会造成低优先级线程彻底”饿死”。

一般的策略是:将频繁阻塞的线程设置高优先级,比较占用CPU资源的高计算量的线程设置低优先级。线程优先级实现非常依赖于操作系统,不同的OS,Java映射的优先级不同,甚至有些OS会忽略优先级的配置。


2.4 线程状态

状态 名称 示例 介绍
New 初始状态 new Thread(runnable) 线程还未开始运行,处于创建状态
Runnable 运行状态 thread.start() 可运行但未必在运行,取决于OS分配的运行时间,即就绪和运行两状态的合并。
Blocked 阻塞状态 请求内部的对象锁,但锁被其他线程持有
Waiting 等待状态 Object.wait() / Thread.join() / 等待Lock或Condition 当线程等待另一个线程通知调度器某个条件时,处于此状态
Timed waiting 超时等待 Object.wait() / Thread.join() / Thread.sleep() / Lock.tryLock() / Condition.await() 调用有些有超时参数的方法时,可以指定时间自行返回
Terminated 终止状态 不要使用stop() 要么因为run方法正常结束而终止,要么因为没有捕获的异常终止了run方法而结束

线程随着代码的执行,在上述状态中切换,而非固定的处于某个状态。

线程状态

可通过jstack工具查看代码运行时的线程信息

一个经历了所有状态的线程可能会经历以下流程:线程创建后调用start()方法开始,状态由NEW->RUNNABLE。当线程执行wait()后,线程进入WAITING状态。处于WAITING状态的线程需要等待其他线程的通知才可以恢复RUNNABLE状态,而TIME_WAITING则是在WAITING上增加了时间限制,在超时后会自动返回RUNNABLE状态。当线程调用同步方法时,在无法获得锁的情况下,线程进入BLOCKED状态。线程在执行run()方法后将进入终止状态。

Java将操作系统中的运行和就绪两个状态合并为运行状态。阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块时的状态,但要注意阻塞在java.concurrent包中Lock接口的线程状态却是等待状态,因为java.concurrent包中Lock接口对于阻塞的实现均使用了LockSupport类中的相关方法。


2.5 守护线程

守护线程是给其他线程提供服务的线程,主要用作程序中后台调度以及支持性的工作,比如计时线程,因为这点所以守护线程没必要脱离其他线程而存在,所以守护线程不能访问固定的资源,如文件或数据库,因为守护线程容易在操作中间被中断。

1
setDaemon(boolean isDaemon);//标识此线程为守护线程或用户线程,需要在线程启动前配置。

当JVM中只剩下了守护线程,就意味着JVM要退出了,JVM退出时守护线程内的finally块不一定会执行,因为守护线程会随着JVM推出而立即终止,所以此时就不能通过finally块来保证资源一定会关闭之类的。


2.6 线程安全

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何的额外的同步或协同,这个类都表现出正确的行为,那么就称这个类是线程安全的。无状态的对象一定是线程安全的

数据因为要多线程共享所以才有了线程安全问题。如果资源或数据具有互斥性,那么就一定是线程安全的。一般不会允许多个线程同时进行写操作,而如不可变资源因为无法被修改,所以不用考虑线程安全问题。

线程工作时不会直接对共享变量进行操作,而是先拷贝副本,在自己的单独工作空间进行操作,在某个时间点再把数据同步回源数据,这就导致了可见性问题。

并发编程中,由于不恰当的执行时序而出现不正确的结果叫竞态条件(Race Condition)。最常见的就是先检查后执行(Check-Then-Act)操作,观察到的结果可能会失效,从而导致执行的依据丧失,导致各种无法预知的问题。举例一种场景,如延迟初始化(LazyInit),在被调用时再做初始化操作,且保证只初始化一次:

1
2
3
4
5
6
7
8
9
10
11
12
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;

public ExpensiveObject getInstance() {
// 竞态条件,多个线程同时调用方法时,多个线程可能都观察到为null并进行实例化
if (instance == null) {
instance = new ExpensiveObject();
}
return instance;
}
}

将复合操作改造为原子操作来保证线程安全:

1
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
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;

public long getCount() {
return count;
}

public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}

// 将复合操作改造为原子操作
@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);

public long getCount() {
return count.get();
}

public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}

要保持状态的一致性,需要在单个原子操作中更新所有相关的状态变量。

当然,也可以通过锁机制来保证线程安全。


三. Java线程

3.1 创建线程

3.1.1 创建方式

Java实现多线程有以下方式:

  1. 继承Thread类,重写run(),创建线程对象。
  2. 实现Runnable接口,重写run(),创建线程对象。
  3. 实现Callable接口,重写call(),创建实例对象并作为构造器参数创建FutureTask对象,再使用FutureTask对象作为构造器参数创建线程对象。
  4. 使用Executor框架创建线程池。

3.1.2 运行实例

学习原理之前,可以先跑起来一些实例,观察其运行效果。

(1) Runnable

Runnable是一个函数式接口,可以通过Lambda表达式来创建对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyRunnable implements Runnable {
@Override
public void run() {//实现run方法
System.out.println(Thread.currentThread().getName());
}
}

......

public static void main(String[] args) {
//测试通过Runnable构建线程
//Runnable r = () -> {};
Runnable runnable = () -> {System.out.println(Thread.currentThread().getName());};
//Runnable runnable = new MyRunnable();
//Runnable作为初始参数构造Thread
Thread thread = new Thread(runnable);
thread.start();
}
......

//运行结果:
Thread-0

从结果可知,可以通过实现一个Runnable接口来作为构造参数启动一个线程。

(2) Thread

通过实现Thread的子类,直接重写run方法来创建一个线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MyThread extends Thread {
@Override
public void run() {//重写run()方法
super.run();
System.out.println("own thread is running");
}
}

public class ThreadTest {
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
System.out.println("over");
}
}

//运行结果:
over
own thread is running
(3) Callable+FutureTask

通过实现Callable接口,通过FutureTask调度来创建线程。

1
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class MyCallable implements Callable<Integer> {
private int count = 0;
@Override
public Integer call() {
int sum = 0;
for(;count < 100;count++){
System.out.println(Thread.currentThread().getName() + " " + count);
sum++;
}
return sum;
}
}

......
public static void main(String[] args) {
Callable<Integer> myCallable = new MyCallable();
//Callable作为初始参数构造FutureTask
FutureTask<Integer> futureTask = new FutureTask<Integer>(myCallable);

//循环打印主线程,并在中途开启新线程
for(int i = 0;i < 100;i++){
System.out.println(Thread.currentThread().getName() + " " + i);
if(i == 30){
//FutureTask作为初始参数构造Thread
Thread thread = new Thread(futureTask);
thread.start();
}
}
try{
int sum = futureTask.get();
System.out.println("sum = " + sum);
}catch (InterruptedException | ExecutionException ex){
ex.printStackTrace();
}
}
......

//控制台输出结果
main 0
...
main 75
main 76
Thread-0 0
main 77
Thread-0 1
main 78
...
Thread-0 5
main 82
Thread-0 6
...
Thread-0 65
Thread-0 66
main 83
Thread-0 67
...
Thread-0 74
main 87
Thread-0 75
main 88
Thread-0 76
...
Thread-0 98
Thread-0 99
main 89
...
main 98
main 99
sum = 100
(4) 线程池

直接通过线程池来创建线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    public static void main(String[] args) {
//线程池里面的线程数会动态变化,并可在线程线被移除前重用
ExecutorService threadPool = Executors.newCachedThreadPool();
for (int i = 1; i <= 3; i ++) {
final int task = i;
//execute()通过Runnable实例创建线程
threadPool.execute(new Runnable() {
public void run() {
System.out.println(Thread.currentThread().getName() + " task: "+task);
}
});
}
}

//运行结果:
pool-1-thread-1 task: 1
pool-1-thread-2 task: 2
pool-1-thread-3 task: 3

3.3 原理剖析

3.3.1 Runnable剖析

不要直接调用Thread或Runnable的run()方法,这样只会执行同个线程的任务,而不会开启新的线程。应该调用start()创建一个新线程。以下是部分源码,可以看出一些其内部细节。

1
2
3
4
5
6
7
8
@FunctionalInterface
public interface Runnable {

/**
* 如果接口的实现对象用来创建了线程,启动线程会在此线程中调用对象的run()方法
*/
public abstract void run();
}

3.3.2 Thread剖析

从范例可以理解,Thread即对线程的抽象实体,通过源码来学习其设计和原理:Thread类源码剖析

3.3.3 Callable+FutureTask剖析

Future接口,常见的线程池中的FutureTask实现

3.3.4 Executors剖析

Executor框架


四. 线程实际应用

线程实际应用


五. 线程中断和终止

线程中断和终止


六. 线程间通信

线程间通信


七. 未捕获异常的处理器

线程的 run() 方法不能抛出任何的受查异常,但非受查异常会导致线程终止,在线程死亡前要先把异常传递到一个用于未捕获异常的处理器。处理器必须属于实现了 Thread.UncaughtExceptionHandler 接口,可以通过 setUncaughtExceptionHandler() 方法为任意线程安装处理器,也可以通过 Thread.setDefaultUncaughtExceptionHandler() 为所有线程安装默认处理器。当线程因未捕获的异常而终止,需要将其记录到日志中。

线程组ThreadGroup,用于统一管理线程集合,现在有更好的特性来操作线程集合,所以不建议使用线程组。

1
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
43
@FunctionalInterface
public interface UncaughtExceptionHandler {
/**
* Method invoked when the given thread terminates due to the
* given uncaught exception.
* <p>Any exception thrown by this method will be ignored by the
* Java Virtual Machine.
* @param t the thread
* @param e the exception
*/
void uncaughtException(Thread t, Throwable e);
}

// null unless explicitly set
private volatile UncaughtExceptionHandler uncaughtExceptionHandler;

// null unless explicitly set
private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;

public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(
new RuntimePermission("setDefaultUncaughtExceptionHandler")
);
}

defaultUncaughtExceptionHandler = eh;
}

public static UncaughtExceptionHandler getDefaultUncaughtExceptionHandler(){
return defaultUncaughtExceptionHandler;
}

public UncaughtExceptionHandler getUncaughtExceptionHandler() {
return uncaughtExceptionHandler != null ?
uncaughtExceptionHandler : group;
}

public void setUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
checkAccess();
uncaughtExceptionHandler = eh;
}

参考:

🔗《Java核心技术 卷Ⅰ》

🔗《Java并发编程的艺术》

🔗《Java并发编程实战》

🔗 《啃碎并发(三):Java线程上下文切换