文章目录
主引用自:极客时间《Java并发编程实战》https://time.geekbang.org/column/intro/100023901
前言
一、通用的线程生命周期
- 初始状态:,指的是线程已经被创建,但是还不允许分配 CPU 执行。在编程语言层面已经创建,但是真正的操作系统层面还没有创建
- 可运行状态:操作系统真正创建好了线程,可以被分配CPU执行了
- 运行状态:线程分配到CPU并执行的状态
- 休眠状态:当运行状态的线程 调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。(所以sleep就是一直占用CPU,wait就会让出CPU)
- 终止状态:线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
二、Java中线程的生命周期
Java 线程一共有六种生命状态:
- NEW(初始化状态)
- RUNNABLE(可运行 / 运行状态)
- BLOCKED(阻塞状态)
- WAITING(无限等待)
- TIMED_WAITING(有时限等待)
- TERMINATED(终止状态)
其中 3. BLOCKED(阻塞状态)4. WAITING(无时限等待)5. TIMED_WAITING(有时限等待)在操作系统层面都是属于休眠模式。
三、Java线程的状态转换
实际项目中死锁的线程栈例子:
四、度量程序运行性能指标和多线程意义
- 时间维度:延迟(发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。)
- 空间维度:吞吐(在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。)
对于整个机器而言,其实最主要的还是如何提升CPU 和 I/O 设备综合利用率
在单核时代,多线程主要就是用来平衡 CPU 和 I/O 设备的。如图所示:
如果程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。
比如:需要计算 1+2+… … +100 亿的值,如果在 4 核的 CPU 上利用 4 个线程执行,线程 A 计算[1,25 亿),线程 B 计算[25 亿,50 亿),线程 C 计算[50,75 亿),线程 D 计算[75 亿,100 亿],之后汇总,那么理论上应该比一个线程计算[1,100 亿]快将近 4 倍,响应时间能够降到 25%。一个线程,对于 4 核的 CPU,CPU 的利用率只有 25%,而 4 个线程,则能够将 CPU 的利用率提高到 100%。示意图如下所示:
五、创建多少个线程合适
就拿上面的计算 1+2+… … +100 亿的值的例子来说,创建了四个线程,性能提升了四倍,那我是不是可以多创建几个线程来达到更快的目的。这样问题就来了,创建多少个合适呐,是不是创建的越多越好?如果是六个线程,那么就意味着其中两个核会产生线程切换,这会带来一定的开销,这样想的话,是不是创建的线程数与CPU核的数目要完全对应上?他们之间有什么样子的关系?
其实创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法都是不同的。
-
对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
-
对于 I/O 密集型的计算场景,比如前面我们的例子中,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那多少个线程合适呢?是 3 个线程,如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。
从上面可以看出,对于 I/O 密集型的计算场景,在选取线程数目的时候是根据CPU计算与I/O操作的比例而得出的!计算公式是:
单核:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
多核:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
当然,在工程上,该比例是很难确定并且动态变化的, 所以就需要压测来大致统计该比例。同时,需要重点关注 CPU、I/O 设备的利用率和性能指标(响应时间、吞吐量)之间的关系。
六、为什么局部变量是线程安全的?
每个线程都会有自己的独立的调用栈。所有局部变量不会有并发问题。
七、如何用面向对象思想写好并发程序?
主要有三点:
- 从封装共享变量
- 识别共享变量间的约束条件
- 制定并发访问策略
封装共享变量
将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。例如下面这样:
public class Counter {
private long value;
synchronized long get(){
return value;
}
synchronized long addOne(){
return ++value;
}
}
对于这些不会发生变化的共享变量,建议你用 final 关键字来修饰。
识别共享变量间的约束条件
例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。
public class SafeWM {
// 库存上限
private final AtomicLong upper =
new AtomicLong(0);
// 库存下限
private final AtomicLong lower =
new AtomicLong(0);
// 设置库存上限
void setUpper(long v){
// 检查参数合法性
if (v < lower.get()) {
throw new IllegalArgumentException();
}
upper.set(v);
}
// 设置库存下限
void setLower(long v){
// 检查参数合法性
if (v > upper.get()) {
throw new IllegalArgumentException();
}
lower.set(v);
}
// 省略其他业务代码
}
我们假设库存的下限和上限分别是 (2,10),线程 A 调用 setUpper(5) 将上限设置为 5,线程 B 调用 setLower(7) 将下限设置为 7,如果线程 A 和线程 B 完全同时执行,你会发现线程 A 能够通过参数校验,因为这个时候,下限还没有被线程 B 设置,还是 2,而 5>2;线程 B 也能够通过参数校验,因为这个时候,上限还没有被线程 A 设置,还是 10,而 7<10。当线程 A 和线程 B 都通过参数校验后,就把库存的下限和上限设置成 (7, 5) 了,显然此时的结果是不符合库存下限要小于库存上限这个约束条件的。
那么”正确“的代码应该是:
public class SafeWM {
// 库存上限
private final AtomicLong upper =
new AtomicLong(0);
// 库存下限
private final AtomicLong lower =
new AtomicLong(0);
// 设置库存上限
void setUpper(long v) {
synchronized (this) {
//对对象加锁即可!!!
// 检查参数合法性
if (v < lower.get()) {
throw new IllegalArgumentException();
}
upper.set(v);
}
}
// 设置库存下限
void setLower(long v) {
synchronized (this) {
// 检查参数合法性
if (v > upper.get()) {
throw new IllegalArgumentException();
}
lower.set(v);
}
}
// 省略其他业务代码
}
所以一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。
制定并发访问策略
主要有三种方式:
- 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
- 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
- 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。
有一些原则在编写这类代码时,建议遵守:
- 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
- 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
- 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。