Java并发编程实战①-并发理论基础
[TOC]
Java并发编程实战-并发理论基础
Java并发编程实战是学习《并发编程实战》和极客时间《Java并发编程》的记录。
并发理论基础-总览
第一部分:并发理论基础
- 并发编程的三个核心问题:分工、同步和互斥
- 可见性、原子性、有序性问题:并发编程Bug的源头
- Java内存模型:看Java如何解决可见性和有序性问题
- 互斥锁(上):解决原子性问题
- 互斥锁(下):如何用一把锁保护多个资源
- 一不小心就死锁了,怎么办?
- 用”等待-通知”机制优化循环等待
- 安全性、活跃性以及性能问题
- 管程:并发编程的万能钥匙
- Java线程(上):Java线程的生命周期
- Java线程(中):创建多少线程才是适合的?
- Java线程(下):为什么局部变量是线程安全的?
- 如何用面向对象思想写好并发程序
- 并发理论总结
并发编程的三个核心问题:分工、同步和互斥
分工
分工就是把一个大问题,拆分为多个任务。
代码领域实现
Java SDK 并发包里的 Executor、Fork/Join、 Future 本质上都是一种分工方法。除此之外,并发编程领域还总结了一些设计模式,基本 上都是和分工方法相关的,例如生产者 - 消费者、Thread-Per-Message、Worker Thread 模式等都是用来指导你如何分工的。
同步
同步是在分好工之后,在具体执行任务时,任务之间是有依赖关系的,也就是任务之间需要沟通。
在并发编程领域里的同步,主要指的就是线程之间的协作。本质是一个线程执行完了一个任务,如何通知执行后续任务的线程开工。
- 协作一般是和分工相关的。
- 例如,用 Future 可以发起一个异步调 用,当主线程通过 get() 方法取结果时,主线程就会等待,当异步执行的结果返回时, get() 方法就自动返回了。
- 除此之外,Java SDK 里提供的 CountDownLatch、CyclicBarrier、Phaser、 Exchanger 也都是用来解决线程协作问题的。
- 自己来处理线程之间的协作:当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。
- 例如,在生产者 - 消费者 模型里,也有类似的描述,“当队列满时,生产者线程等待,当队列不满时,生产者线程 需要被唤醒执行;当队列空时,消费者线程等待,当队列不空时,消费者线程需要被唤醒 执行。”
- 管程是一种解决并发问题的通用模型,除了能解决线程协作问 题,还能解决下面我们将要介绍的互斥问题。可以这么说,管程是解决并发问题的万能钥 匙。
互斥
分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语 叫“线程安全”。所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。
- 并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确 定的。不确定,则意味着可能正确,也可能错误,事先是不知道的。而导致不确定的主要 源头是可见性问题、有序性问题和原子性问题。
- 为了解决这三个问题,Java 语言引入了内 存模型,内存模型提供了一系列的规则,利用这些规则,我们可以避免可见性问题、有序 性问题。
- 但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。
- 实现互斥的核心技术就是锁,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决 互斥问题。
- 那如何保证安全性的同 时又尽量提高性能呢?
- 可以分场景优化,Java SDK 里提供的 ReadWriteLock、 StampedLock 就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构,例如 Java SDK 里提供的原子类都是基于无锁技术实现的。
- 原理是不共享变量或者变量只允许读。这方面,Java 提 供了 Thread Local 和 final 关键字,还有一种 Copy-on-write 的模式。
- 那如何保证安全性的同 时又尽量提高性能呢?
分工、同步和互斥全景图
可见性、原子性、有序性问题:并发编程Bug的源头
并发程序硬件优化导致的问题
cpu、内存、I/O设备三者都在不断迭代,但是这三者的速度差异是非常大的。根据木桶理论原理,程序整体的性能取决于最慢的操作—读写I/O设备,也就是说单方面提高CPU的性能是无效的。
- 为了合理利用CPU的高性能,平衡这三者的速度差异,有下面手段。
- CPU增加了缓存,以均衡与内存的速度差异
- 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异。
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
可见性:缓存导致的可见性问题
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们成为可见性。
- 在单核时期,采用cpu缓存机制在多个线程修改和访问共享变量是没问题的。但是在多核时期,每个CPU都有自己的缓存,这导致cpu缓存和内存的数据一致性就有问题了。
原子性:线程切换带来原子性问题
原子性:我们把一个或者多个操作在cpu执行的过程中不被中断的特性。
时间片:操作系统允许某个进程执行一小段时间(占用cpu),例如 50 毫秒,过了 50 毫秒操作系统就会重新选 择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。
线程切换示意图(任务切换=线程切换)
任务切换带来的并发问题:我们在高级语言里一条语句往往需要多条CPU指令完成,如何没执行完cpu指令,就发生任务切换。如执行
count+=1;
至少需要三条cpu指令- 指令1:首先,需要把变量count从内存加载到cpu寄存器
- 指令2:之后,在寄存器中执行+1操作
- 指令3:最后,将结果写入内存(缓存机制可能写入的是cpu缓存而不是内存)
有序性:编译优化带来的有序性问题
有序性:编译器和解析器自动为我们程序自动做的优化,带来的问题。
Java双重检查创建单例对象
源码:
/**
* 描述:
* 有序性:导致的并发问题,双重检查举例
*
* @author Noah
* @create 2019-09-23 09:35
*/
public class _1_Order {
private static _1_Order instace;
private _1_Order() {
}
public static _1_Order getInstance() {
if (instace == null) {
synchronized (_1_Order.class) {
if (instace == null) {
instace = new _1_Order();
}
}
}
return instace;
}
}解析:
理论上是没什么问题,这段程序。但是new 操作上面却有问题。如何new操作(优化后),在第二步发生了线程切换,在另外一个线程获取到instace时没有完成初始化的,在该线程获取成员变量的时候,就会发生空指针异常。
- new操作(去掉优化)
- 分配一块内存M
- 在内存M上初始化Singleton对象
- 然后M地址赋值给instance对象
- 程序new操作(优化后)
- 分配一块内存M
- 将M的地址赋值给instance变量
- 最后在内存M上初始化Singleton对象
Java内存模型:看Java如何解决可见性和有序性问题
什么是Java内存模型
定义:Java内存模型是个很复杂的规范,站在程序员的视角:本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
具体:这些方法包括 volatile、synchronized 和 final 三个关键字,以及六 项 Happens-Before 规则。
任何一门语言,在解决并发问题的时候都是围绕着可见性、原子性、有序性去解决的。可见性、有序性二者解决方案:按需的禁用缓存以及编译优化。
什么Volatile关键字
volatile关键字的含义就是禁用cpu缓存,读写数据都是在内存上面操作。解决的问题是:可见性、有序性。在JDK1.5才加上的特性。
Happens-Before六项规则:前面一个操作的结果对后续操作是可见的
什么是Happens-Before:前面一个操作的结果对后续操作是可见的。解决的问题是:可见性、有序性。比较正式的说法是:Happens-Before 约束 了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens- Before 规则。
- 程序的顺序性规则
- 这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意 操作。
- volatile变量规则
- 这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile变量的读操作。
- 传递性
- 这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
- 管程中锁的规则
- 这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
- 线程start()规则
- 这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
- 线程join()规则
什么是管程
管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
理解final
final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。
使用final修饰要注意逃逸问题
互斥锁(上):解决原子性问题
原子性问题的源头就是线程切换,禁用线程切换等价于禁用CPU中断。
互斥:同一时刻只有一个线程执行。
保证对共享变量的修改是互斥的,无论是单核还是多核CPU,都能保证原子性。
锁的模型
注意点:
- 在锁LR和受保护资源之间,用一条线关联。保证我们不会用锁去保护其他资源的情况。
- 受保护资源和锁之间的关联关系是 N:1 的关系
- 并发领域:不能用多把锁来保护同一资源。(错误!!)
- 但是可以使用同一把锁来保护多个资源,对应到现实世界就是”包场”。
锁技术:Synchronized
Synchronized的隐式规则:
- 当修饰静态方法时候,锁定的是当前类的Class对象。
- 当修饰是非静态方法的时候,锁定的是当前实例对象this。
互斥锁(下):如何用一把锁保护多个资源
保护没有关联关系的多个资源
private Integer balance;
private String password;
/**
* 锁:保护余额
*/
public static final Object balLock = new Object();
/**
* 锁:保护密码
*/
public static final Object pwLock = new Object();用不同的锁对受 保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
同一把锁保护多个资源,可能会导致所有的操作都是串行化的。性能很差。
保护有关联关系的多个资源
最经典的场景就是:转账场景。假设A给B转账,B给C转账。
public void transfer(UnSalfeAccount target, Integer amt) {
synchronized (this) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}上面的实例代码,并不能解决转账并发问题。因为锁的细粒度太小,出现了用自家的锁保护他人资源的情况。
我们假设线程 1 执行账户 A 转账户 B 的操作,线程 2 执行账户 B 转账户 C 的操作。这两 个线程分别在两颗 CPU 上同时执行,那它们是互斥的吗?我们期望是,但实际上并不是。 因为线程 1 锁定的是账户 A 的实例(A.this),而线程 2 锁定的是账户 B 的实例 (B.this),所以这两个线程可以同时进入临界区 transfer()。同时进入临界区的结果是什 么呢?线程 1 和线程 2 都会读到账户 B 的余额为 200,导致最终账户 B 的余额可能是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),可能 是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖), 就是不可能是 200。
- 锁能覆盖所有受保护资源
- 使用类级别的锁,能够解决上面的这个问题。但是性能较差,所有的转账操作都是串行化的。
- 锁的细粒度大,才能同时保护这个两个资源。
一不小心就死锁了,怎么办?
优化转账类级别锁
前面举例子:转账操作:A->B,B->C;因为我们使用的类级别的锁,导致所有的转账操作都是串行的,性能非常差。
如果优化呢?我们对所有并发问题,都可以在现实中找到对应的场景来解决问题(建模)。
转账:使用细粒度更小的锁
```java
public void transfer(UnSalfeAccount target, Integer amt) {
//锁定转出账号
synchronized (this) {
//锁定转入账号
synchronized (target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
- 其实用两把锁就实现了,转出账本一把,转入账本另 一把。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后 尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这 个逻辑可以图形化为下图这个样子。
- **细 粒度锁**。**使用细粒度锁可以提高并行度,是性能优化的一个重要手段**。**使用细粒度锁是有代价的,这个代价就是可能会导致死锁。**
- 转账死锁场景:就是账号A给B转账(线程1),正好B也给A转账(线程2)。此时线程1获取到了A的锁,等待B的锁。然而此时线程2获取到了B的锁,等待A的锁。故此:就是死锁。
### 死锁
> **死锁的一个比较专业的定义是:一组互相竞争资源 的线程因互相等待,导致“永久”阻塞的现象。**
#### 死锁产生的四个条件
1. 互斥,共享资源X和Y只能被一个线程占用
2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X
3. 不可抢占,其他线程不能强行抢占线程T1占有的资源
4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等 待。
### 解决死锁问题
也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。条件1是不可破坏的。
1. 对于"占有且等待"这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
2. 对于"不可抢占"这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
3. 对于"循环等待"这个条件,可以靠按序申请资源来预防。所谓**按序申请**,是指资源是有线性
顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不
存在循环了。
#### 破坏"占有且等待"
> ```java
> /**
> * 描述:
> * 单例:资源分配器
> * <p>
> * 解决死锁1:破坏占有且等待
> * <p>
> * 类比一个资源管理人员(账本管理人员),一次同时申请转入账号和转出账号
> * <p>
> * 该类必须为单例:只能由一个人来分配资源
> *
> * @author Noah
> * @create 2019-09-26 09:40
> */
> public enum Allocator {
>
> /**
> * 获取到单例对象
> */
> INSTANCE;
>
> private List<Object> als = new ArrayList<>();
>
> /**
> * 同时申请多个资源的锁
> *
> * @param lock1
> * @param lock2
> * @return
> */
> public synchronized boolean apply(Object lock1, Object lock2) {
> if (als.contains(lock1) || als.contains(lock2)) {
> return false;
> } else {
> als.add(lock1);
> als.add(lock2);
> }
> return true;
> }
>
> /**
> * 释放资源
> *
> * @param lock1
> * @param lock2
> */
> public synchronized void free(Object lock1, Object lock2) {
> als.remove(lock1);
> als.remove(lock2);
> }
>
> public static Allocator getInstance() {
> return INSTANCE;
> }
>
>
> }
>
资源分配器:单例模式,一次申请多个资源。
但是性能不好,死循环获取资源。
破坏”不可抢占条件”
Synchronized
关键字无法坐到释放资源,但是Lock关键字可以。
破坏”循环等待条件”
/**
* 解决死锁:破坏循环等待
* <p>
* 通过业务实现逻辑控制:对资源进行排序,按序申请资源
*/
public void D_CycleWaiting_Transfer(UnSalfeAccount target, Integer amt) {
UnSalfeAccount left = this;
UnSalfeAccount right = target;
if (left.id > right.id) {
left = target;
right = this;
}
synchronized (left) {
synchronized (right) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
用”等待-通知”机制优化占有且等待(循环等待)
在我们解决死锁问题:采用破坏”占有且等待”条件的时候,写了个死循环同时去获取两把锁。
如果该方法耗时长和程序并发量大的时候,不适用。采用Java等待-通知机制来解决该问题更好。
利用现实世界的模型来构思程序
java等待-通知机制类比现实世界的就医流程很适合。
一个完整的等待-通知机制:线程首先获取到互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态。当要求的条件满足时,通知等待的线程,重新获取互斥锁。
用Synchronized实现等待-通知机制
实现等待-通知机制有多种方式,方式一:Java语言内置的Synchronized配合wati()、notify()、notufyAll()这三个方法。
wait():这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己的独立等待队列。
- 调用了wait()方法之后,当前线程会被阻塞,释放持有的互斥锁。
notify():通知等待队列中的线程,告诉它条件曾经满足过。
需要强调的是调用wait()、notify()、notifyAll()方法操作的是互斥锁的等待队列。如果synchronized锁定的是this,就不能调用target对应这三个方法,并且这三个方法的调用是在synchronized内部被调用,否则jvm会抛出一个异常:java.lang.IllegalMonitorStateException。
更好的资源分配器
使用范式来编写代码:解决条件曾经满足过。
- ```java
while(条件不满足){
wait();
}
- 尽量使用notifyAll():因为使用notify()是随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程。
### 总结
使用线程等待-通知机制是一种非常普遍的线程间协作方式,资源分配器我们从之前来轮询的方式等待某个状态,现在优化为等待-通知机制优化。
## 安全性、活跃性以及性能问题
> 本章是对前面6章的总结
>
> 并发编程有很多问题,但是主要还是三个方面:安全性问题、活跃性问题、性能问题。
### 安全性问题
- 程序没有按照我们期望的执行。我们需要解决的问题是:可见性、原子性、有序性这三个问题。
- 什么线程不安全(数据竞争):**存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同事读写同一数据。**
- 竞态条件:指的是程序的执行结果依赖线程执行的顺序。
- 解决数据竞争和竞态条件:使用互斥,也就是锁。
### 活跃性问题
- 常见活跃性问题:"死锁"、"活锁"、"饥饿"等
- 活锁:
- **有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”**
- 现实世界建模:路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦 让。
- 解决方案:谦让时,尝试等待一个随机的时间就可以了。
- 饥饿:
- **“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况**。
- 出现情况:
- 线程优先级不均
- 持有锁的时间太长
- 解决方案:
- 保证资源充足(难)
- 公平地分配资源(可行)
- 避免持有锁的时间太长(难)
### 性能问题
- 如果锁的细粒度太大或者过度小心使用锁,可能导致程序串行化。就没办法发挥多线程的性能。
- 使用锁(串行化)会导致多线程程序性能大幅度下降,如何解决呢?
- **Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域 的性能**。
- 解决方案:
- 既然使用锁会带来性能问题,那我们可以使用无锁的算法和数据结构:例如线程本地存储(Thread Local Storage,TLS)、写入时复制(copy-on-write)、乐观锁等。java并发包的原子类是一种无锁的数据结构,disruptor是一个无锁的内存队列。
- 减少锁的持有时间:互斥锁的本质是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的世界。例如:使用细粒度的锁,分段锁(ConcurrentHashMap)、读写锁等。
- 性能三个指标
- 吞吐量:指的是单位时间内处理的请求数量。
- 延迟:指的是从发出请求到响应的时间
- 并发量:同事处理请求数量。
### 总结
并发编程问题:从微观角度出发是:可见性、原子性、有序性这三者问题,宏观角度出发:安全性、活跃性、性能问题。
## 管程:并发编程的万能钥匙
### 是什么是管程
> 管程和信号量的关系:在操作系统课程告诉我们,所有的并发问题都可以使用信号量来解决。
>
> 但是Java使用的管程技术,synchronized关键字、wait()、notify()、notifyAll()都是管程的组成部分。
>
> 管程和信号量是等价的。
>
> Monitor(管程):指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。
>
> 翻译为 Java 领 域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
>
> 管程模型:Hasen模型、Hoare模型、MESA模型(主流)
### MESA管程模型
> 并发编程三个核心问题:互斥、同步。
- 互斥:就是将共享变量及其对共享变量的操作统一封装起来。
- 同步
> ![image-20190930085114139](https://tva1.sinaimg.cn/large/006y8mN6gy1g7h9aq8s1xj30cc0eqdgz.jpg)
>
> **每个条件变量都对应有一个等待队列**:Java 语言内置的管程(Synchronized)里只有一个条件变量
- wait()正确姿势:
> ```java
> while(条件不满足){
> wait();
> }上面的编程范式:是mesa管程特有的。
三个模型的核心区别就是条件满足的时候,如果通知相关线程(管程要求同一时刻只允许一个线程执行)。
- MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变 量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也 没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的 条件,现在已经不满足了,所以需要以循环方式检验条件变量。
- ```java
notify()正确使用:
除非 经过深思熟虑,否则尽量使用 notifyAll()
满足下面三个条件才能使用:
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
Java线程(上):Java线程的生命周期
通用的线程生命周期
五种状态:初始状态、可运行状态、运行状态、休眠状态、终止状态。
Java中线程的生命周期
六种状态:NEW(初始化状态)、RUNNABLE(可运行/运行状态)、BLOCKED(阻塞状态)、WAITING(无时限等待)、TIME_WAITING(有时限等待)、TERMINATED(终止状态)
- RUNNABLE与BLOCKED的状态转换
- 只有一种场景会触发这种转换,等待synchronized的隐式锁。
- 线程调用阻塞式API时,是否会转换到BLOCKED状态?
- 操作系统层面:线程会转换到休眠状态。
- JVM层面:线程还是依然保持RUNNABLE状态,JVM层面并不关心操作系统相关的调度。
- 总结:等待CPU使用权(操作系统:可执行状态)与等待I/O(操作系统:休眠状态),在JVM层面看来没有区别的,都是在等待某个资源都归为RUNNABLE状态
- RUNNABLE与WAITING的状态转换
- 场景一:在synchronized隐式锁的线程中,调用无参数的Object.wait()方法。
- 场景二:调用无参数的Thread.join()线程同步的时候。
- 场景三:调用LockSupport.park()方法,会从runnable到waiting状态。调用LockSupport.unpark(Thread thread)唤醒。
- RUNNABLE与TIMED_WAITING的状态转换
- 场景一:调用带有超时参数的Thread.sleep(long millis)方法
- 场景二:在synchronized隐式锁的线程,调用带有超时参数的Object.wait(long timeout)方法
- 场景三:调用带有超时参数的Thread.join(long mills)方法
- 场景四:调用带有超时参数的LockSupport.parkNanos(Object blocker,long deadline)
- 场景五:调用带有超时参数的LockSupport.parkUntil(long deadline)
- NEW到RUNNABLE状态
- new就是创建一个线程的方式,常见的有两种方式,一个是extends Thread,另外一个是implements Runable
- 从new到runnable的状态,调用start()方法
- RUNNABLE到TERMINATED状态
- 线程执行完run()方法之后,会自动转换到terminated状态。
- 但是在执行run()方法的时候抛出异常,也会导致线程终止。
- 我们可以强制中断run()方法,调用interrupt()方法,stop()不建议使用。
- stop()和interrupt()方法的区别
- stop()方法是真的杀死线程,不给线程喘息的机会,如果持有synchronized隐式锁也不会释放。类似的方法还有suspend()和resume()方法。都不建议使用了。
- interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同事也可以无视这个通知。被interrupt的线程,是怎么收到通知的呢?
- 一种是异常
- 另外是主动检测
Java线程(中):创建多少线程才是适合的?
要回答创建多少线程才是适合的?
首先要分析两个问题:
- 为什么要使用多线程?
- 多线程的应用场景?
为什么要使用多线程?
- 多线程实现并发程序的主要手段,能够提升程序性能
- 度量程序性能指标:延迟和吞吐量
- 延迟(时间维度):发出请求到收到响应这个过程的时间。
- 吞吐量(空间维度):单位时间内处理请求的数量。
- 降低延迟,提高吞吐量。
多线程的应用场景
如何实现”降低延迟,提高吞吐量”?
- 优化算法
- 将硬件的性能发挥到极致
- 总结:在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来讲,就是提升I/O的利用率和cup的利用率。
- 操作系统层面帮我们解决了单一硬件设备的硬件利用率问题,但是cpu和I/O设备综合利用率的问题,需要我们自己解决,操作系统提供了方案:多线程。
上面示意图:单线程,I/O设备利用率和cpu利用率只有50%。
多线程,把I/O设备和cpu的利用率都提升到了100%。
如果 CPU 和 I/O 设备的利用率都很低,那么可以尝试通过增加线程来 提高吞吐量。
创建多少线程适合?
需要看应用场景:I/O密集型计算和CPU密集型计算。
- 对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工 程上,线程的数量一般会设置为“CPU 核数 +1”
- 对于I/O密集型计算场景:
- 单核:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
- 多核:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
Java线程(下):为什么局部变量是线程安全的?
多个线程同时访问共享变量的时候,会导致并发问题。但是局部变量不共享,因此是线程安全的。没有共享,就没有伤害。
cpu通过cpu的堆栈寄存器的调用栈(里面是栈帧):来找到方法的参数和返回地址。
栈帧和方法是同生共死。局部变量是放在调用栈里的。一个变量如果想跨越方法的边界, 就必须创建在堆里。
每个线程都有自己独立的调用栈
线程封闭:仅在单线程内访问数据。数据库连接池获取connection就是使用这种解决方案。
如何用面向对象思想写好并发程序
面向对象思想和并发编程本来是没有关系的,是两个领域。但是在java语言里,面向对象思想能够让并发编程变得更简单。从三个方面:
- 封装共享变量
- 识别共享变量间的约束条件
- 指定并发访问策略
封装共享变量
- 面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对 象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性。
- 将共享变量作为对象属性封装在内部, 对所有公共方法制定并发访问策略。
- 对于 这些不会发生变化的共享变量,建议你用 final 关键字来修饰。
识别共享变量间的约束条件
- 这些约束条件,决定了并发访问策略
- 一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定 的并发访问策略南辕北辙。
- 基本上都会有 if 语句,所以,一定要特别注意竞态条 件。
指定并发访问策略
宏观并发访问策略
- 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需 要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便 就能发明成功的。
- 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、 Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
- 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开 发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能 瓶颈不是你想预估就能预估的。
Java并发访问策略”三件事”
避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模
式、CSP 模式以及函数式编程的基础都是不变模式。
管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java
并发包提供的读写锁、并发容器等同步工具会更好。
并发理论总结
todo