并发编程可以总结为三个核心问题:
核心矛盾
CPU、内存、I/O 设备的速度差异
cpu >>> 内存 >>> I/O 设备
CPU 增加了缓存,以均衡与内存的速度差异
操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用
可见性: 一个线程对共享变量的修改,另外一个线程能够立刻看到
多核时代,每颗 CPU 都有自己的缓存
当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存
这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了
多进程: 单核的 CPU可以同时执行多个任务
时间片: 某个进程执行的一小段时间, 之后进行任务切换
一个时间片内, 某个进程IO可以先休眠, 让出CPU使用权, 读入内存后再由OS唤醒该进程, 可以同时提高CPU和IO的使用率
一个进程创建的所有线程共享一个内存空间的,不需要切换内存映射地址
count += 1, 三条CPU指令:
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条 CPU 指令
执行完
假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1
原子性: 一个或者多个操作在 CPU 执行的过程中不被中断的特性
应该这样写
class Single{
private static volatile Single s = null; //禁止重排序
private Single(){}
public static Single getInstance(){
if(null==s){
synchronized(Single.class){
if(null==s)
s = new Single();
}
}
return s;
}
}
双重检查创建单例对象
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);
线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例
getInstance 还是存在问题 – 重排序 – volatile!!!
new 操作:
优化后的执行顺序:
产生问题: 空指针异常
假设线程 A 先执行 getInstance() 方法,当执行完指令 2
时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常
。
双重检查创建单例的异常执行路径
在 32 位的机器上对 long 型变量进行加减操作存在并发隐患
long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性
,所以并发的时候会出问题
通过volatile, synchronized, final关键字和happens-before规则
禁用 CPU 缓存
volatile int x = 0:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入
Happens-Before 规则: 前面一个操作的结果对后续操作是可见的, 约束了编译器的优化行为
前一个线程的解锁操作对后一个线程的加锁操作可见
synchronized (this) { //此处自动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 例如此处对共享变量修改,则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改, 在主线程调用B.join()之后皆可见
// 此例中,var==66
volatile 为的是禁用缓存以及编译优化
final 关键字: 这个变量生而不变
利用双重检查方法创建单例
,构造函数的错误重排导致线程可能看到 final 变量的值会变化
final int x;
// 错误的构造函数
// 在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?
1.声明共享变量abc,并使用volatile关键字修饰abc
2.声明共享变量abc,在synchronized关键字对abc的赋值代码块加锁,由于Happen-before管程锁的规则,可以使得后续的线程可以看到abc的值。
3.A线程启动后,使用A.JOIN()方法来完成运行,后续线程再启动,则一定可以看到abc==3
对称多处理器
的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行
。在Java中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序
。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序
。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。线程中断规则
:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。对象终结规则
:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。内存屏障(memory barrier)
禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障。原子性问题: 线程切换
操作系统做线程切换是依赖 CPU 中断
的,所以禁止 CPU 发生中断就能够禁止线程切换
eg: 32 位 CPU 上执行 long 型变量的写操作
明明已经把变量成功写入内存,重新读出来却不是自己写入的
在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断
,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性
但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断
,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行
,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现诡异 Bug : 明明已经把变量成功写入内存,重新读出来却不是自己写入的
同一时刻只有一个线程执行
保证对共享变量的修改是互斥
受保护的资源 R
要保护资源 R 就得为它创建一把锁 LR
针对这把锁 LR,在进出临界区时添上加锁操作和解锁操作
锁 LR 和受保护资源之间,保护自家的资源
!!
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
当修饰非静态方法的时候,锁定的是当前实例对象 this。
synchronized 修饰静态方法相当于:
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
修饰非静态方法,相当于:
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
addOne方法:
1. 原子性
addOne() 方法,被 synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,
所以一定能保证原子操作
2. 可见性
2.1 synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码
2.2 管程(synchronized)中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
前一个线程的解锁操作对后一个线程的加锁操作可见
2.3 综合 Happens-Before 的传递性原则
前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的
问题: 执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?这个可见性是没法保证的
get() 方法并没有加锁操作,所以可见性没法保证
问题的解决: get() 方法也 synchronized 一下
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
保护临界区 get() 和 addOne() 的示意图
合理的关系应该是: 受保护资源和锁之间的关联关系是 N:1 的关系
使用一把保护多个资源
class SafeCalc {
static long value = 0L;
synchronized long get() { --- 非静态方法
return value;
}
synchronized static void addOne() { --- 静态方法
value += 1;
}
}
conflict: 一个静态方法, 一个非静态方法, 两个锁保护一个资源
受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class
由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系
临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了
两把锁保护一个资源的示意图
下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}
加锁本质就是在锁对象的对象头中写入当前线程id
,但是new object每次在内存中都是新对象,所以加锁无效。
经过JVM逃逸分析的优化后,这个sync代码直接会被优化掉,所以在运行时该代码块是无锁的
sync锁的对象monitor指针指向一个ObjectMonitor对象,所有线程加入他的entrylist里面,去cas抢锁,更改state加1拿锁,执行完代码,释放锁state减1,和aqs机制差不多,只是所有线程不阻塞,cas抢锁,没有队列,属于非公平锁。
wait的时候,线程进waitset休眠,等待notify唤醒
两把不同的锁,不能保护临界资源。而且这种new出来只在一个地方使用的对象,其它线程不能对它解锁,这个锁会被编译器优化掉。和没有syncronized代码块效果是相同的
受保护资源和锁之间合理的关联关系应该是 N:1 的关系
可以用一把锁来保护多个资源
,但是不能用多把锁来保护一个资源
不同的资源用不同的锁保护
细粒度锁: 用不同的锁对受保护资源进行精细化管理,能够提升性能
class Account {
// 锁:保护账户余额
private final Object balLock = new Object();
// 账户余额
private Integer balance;
// 锁:保护账户密码
private final Object pwLock = new Object();
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balLock) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 查看余额
Integer getBalance() {
synchronized(balLock) {
return balance;
}
}
// 更改密码
void updatePassword(String pw){
synchronized(pwLock) {
this.password = pw;
}
}
// 查看密码
String getPassword() {
synchronized(pwLock) {
return password;
}
}
}
class Account {
private int balance;
// 转账
synchronized void transfer(Account target, int amt) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
在这段代码中,临界区内有两个资源,分别是转出账户的余额 this.balance 和转入账户的余额 target.balance,并且用的是一把锁 this,符合我们前面提到的,多个资源可以用一把锁来保护
,这看上去完全正确呀。真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?
问题就出在 this 这把锁上,this 这把锁可以保护自己的余额 this.balance,却保护不了别人的余额 target.balance,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
用锁 this 保护 this.balance 和 target.balance 的示意图
假设有 A、B、C 三个账户,余额都是 200 元,我们用两个线程分别执行两个转账操作:账户 A 转给账户 B 100 元,账户 B 转给账户 C 100 元,最后我们期望的结果应该是账户 A 的余额是 100 元,账户 B 的余额是 200 元, 账户 C 的余额是 300 元
线程 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。
同一把锁来保护多个资源: 锁能覆盖所有受保护资源
上面的例子中,this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁,如何让 A 对象和 B 对象共享一把锁
?
class Account {
private Object lock;
private int balance;
private Account();
// 创建Account时传入同一个lock对象 -- key point
public Account(Object lock) {
this.lock = lock;
}
// 转账
void transfer(Account target, int amt){
// 此处检查所有对象共享的锁
synchronized(lock) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建 Account 对象的时候必须传入同一个对象,如果创建 Account 对象时,传入的 lock 不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中,创建 Account 对象的代码很可能分散在多个工程中,传入共享的 lock 真的很难
。
另一种方案:
用 Account.class 作为共享的锁
。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了,代码更简单
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
使用共享的锁 Account.class 来保护不同对象的临界区
对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见
。
例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见
能否账户余额用 this.balance 作为互斥锁,账户密码用 this.password 作为互斥锁
class Account {
// 锁:保护账户余额
private final Object balLock = new Object();
// 账户余额
private Integer balance;
// 锁:保护账户密码
private final Object pwLock = new Object();
// 账户密码
private String password;
}
不能用可变对象
做锁
用this.balance 和this.password 都不行。
在同一个账户多线程访问时候,A线程取款进行this.balance-=amt
的时候, 此时this.balance对应的值已经发生变换,线程B再次取款时拿到的balance对应的值并不是A线程中的
,也就是说不能把可变的对象当成一把锁
问题的产生:
用 Account.class 作为互斥锁,来解决银行业务里面的转账问题
虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的
,例如账户 A 转账户 B、账户 C 转账户 D 这两个转账操作现实世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差
转账操作串行化 性能太差
真实世界的解决方案: 每个账户对应一个账本
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) {
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
相对于用 Account.class 作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多
使用细粒度锁
可以提高并行度,是性能优化的一个重要手段。
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this){ ①
// 锁定转入账户
synchronized(target){ ②
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
线程a拿到账本a,
线程b拿到账本b
死锁: 一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象
资源分配图
资源分配图是个有向图,它可以描述资源和线程的状态
资源用方形节点
表示,线程用圆形节点
表示;
资源中的点指向线程的边表示线程已经获得该资源
,线程指向资源的边则表示线程请求资源,但尚未得到
发生死锁的四个条件:
避免死锁的三个方法: 互斥条件不可破坏
一次性申请所有的资源
,这样就不存在等待了。申请不到,可以主动释放它占有的资源
,这样不可抢占这个条件就破坏掉了。按序申请资源
来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。资源管理员
, 只允许管理员对资源进行操作
对于同时申请多个资源, 如果有的资源被占用, 并不会申请成功
只有申请的资源没有被占用才会申请成功
// 同时申请资源 apply() 和同时释放资源 free(), 用来管理这个临界区
// 当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;
// 当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源
class Allocator {
private List<Object> als = new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply(Object from, Object to){
if(als.contains(from) || als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 归还资源
synchronized void free(Object from, Object to){
als.remove(from);
als.remove(to);
}
}
class Account {
// actr应该为单例, 只能由一个人来分配资源
private Allocator actr;
private int balance;
// 转账
void transfer(Account target, int amt){
// 一次性申请转出账户和转入账户,直到成功 -- 死循环
while(!actr.apply(this, target))
;
try{
// 锁定转出账户
synchronized(this){
// 锁定转入账户
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
synchronized 无法实现
synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态
了,而线程进入阻塞状态, 啥也不能干
java.util.concurrent 这个包下面提供的 Lock, 留待后续
class Account {
private int id;
private int balance;
// 转账
void transfer(Account target, int amt){
// 假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请
Account left = this ①
Account right = target; ②
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 锁定序号小的账户
synchronized(left){
// 锁定序号大的账户
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
上面这个例子中, 破坏占用且等待条件
的成本就比破坏循环等待条件
的成本高,
破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));方法,不过好在 apply() 这个方法基本不耗时。
在转账这个例子中,破坏循环等待条件
就是成本最低的一个方案。
破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));这个方法,那它比 synchronized(Account.class) 有没有性能优势呢?
锁了Account类相关的所有操作
。相当于文中说的包场了,只要与Account有关联,通通需要等待当前线程操作完成。只锁定了当前操作的两个相关的对象
。两种影响到的范围不同。破坏占用且等待条件
如果资源被占用, 用死循环的方式来循环等待
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
;
循环上万次才能获取到锁
,太消耗 CPU 了更好的方案:
使用线程阻塞的方式就能避免循环等待消耗 CPU 的问题
。就医流程
保证同一时刻大夫只为一个患者服务,而且还能够保证大夫和患者的效率
大夫叫下一个患者,这个步骤我们在前面的等待 - 通知机制中忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁
。患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待 - 通知机制中也忽视了
。一个完整的等待 - 通知机制:
Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll()
这三个方法就能轻松实现
上面这个图中,左边的等待队列
左边有一个等待队列,同一时刻,只允许一个线程进入 synchronized 保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列
。
上面这个图中, 右边的等待队列
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。
上面这个图,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列
。(注意, 和前一个等待队列不同, 这个是互斥锁的等待队列)
线程在进入等待队列的同时,会释放持有的互斥锁
,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
当线程要求的条件满足时, 调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程
,告诉它条件曾经满足过
。
为什么说是曾经满足过
呢?因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合
,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点需要格外注意。
被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)
上面我们一直强调 wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列
,
所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();
如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。
而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁
,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的
。
如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException
。
解决一次性申请转出账户和转入账户的问题
考虑:
Allocator 需要是单例的,所以我们可以用 this 作为互斥锁
。另外注意:
利用这种范式可以解决上面提到的条件曾经满足过这个问题. 范式,意味着是经典做法
因为当 wait() 返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。
while(条件不满足) {
wait();
}
对比 循环等待:
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
;
// 使用this作为互斥锁, 保证allocator单例
class Allocator {
private List<Object> als;
// 一次性申请所有资源
synchronized void apply(Object from, Object to){
// 经典写法
while(als.contains(from) || als.contains(to)){
try{
wait();
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
// 归还资源
synchronized void free(Object from, Object to){
als.remove(from);
als.remove(to);
notifyAll();
}
}
对比一下之前的实现:
// 同时申请资源 apply() 和同时释放资源 free(), 用来管理这个临界区
// 当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;
// 当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源
class Allocator {
private List<Object> als = new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply(Object from, Object to){
if(als.contains(from) || als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 归还资源
synchronized void free(Object from, Object to){
als.remove(from);
als.remove(to);
}
}
class Account {
// actr应该为单例, 只能由一个人来分配资源
private Allocator actr;
private int balance;
// 转账
void transfer(Account target, int amt){
// 一次性申请转出账户和转入账户,直到成功 -- 死循环
while(!actr.apply(this, target))
;
try{
// 锁定转出账户
synchronized(this){
// 锁定转入账户
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程
从感觉上来讲,应该是 notify() 更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到
等待 - 通知机制是一种非常普遍的线程间协作的方式。
可以代替工作中使用的轮询
方式
wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?
wait与sleep区别在于:
两者相同点:都会释放CPU执行时间,等待再次调度!
wait()方法与sleep()方法的不同之处在于,
释放对象的“锁标志”
。当调用某一对象的wait()方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中,直到调用了notify()方法后,将从对象等待池中移出任意一个线程并放入锁标志等待池
中,只有锁标志等待池中的线程可以获取锁标志
,它们随时准备争夺锁的拥有权。当调用了某个对象的notifyAll()方法,会将对象等待池中的所有线程都移动到该对象的锁标志等待池。sleep()方法不会释放“锁标志”
,也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据。public class MyLock {
// 测试转账的main方法
public static void main(String[] args) throws InterruptedException {
Account src = new Account(10000);
Account target = new Account(10000);
CountDownLatch countDownLatch = new CountDownLatch(9999);
for (int i = 0; i < 9999; i++) {
new Thread(()->{
src.transactionToTarget(1,target);
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
System.out.println("src="+src.getBanalce() );
System.out.println("target="+target.getBanalce() );
}
static class Account{ //账户类
private Integer banalce;
public Account(Integer banalce) {
this.banalce = banalce;
}
public Integer getBanalce() {
return banalce;
}
public void setBanalce(Integer banalce) {
this.banalce = banalce;
}
//转账方法
public void transactionToTarget(Integer money, Account target){
Allocator.getInstance().apply(this,target);
this.banalce -= money;
target.setBanalce(target.getBanalce()+money);
Allocator.getInstance().release(this,target);
}
}
//单例锁类
static class Allocator {
private List<Account> locks = new ArrayList<>();
// 单例
private Allocator(){
}
public static Allocator getInstance(){
return AllocatorSingle.install;
}
static class AllocatorSingle{
public static Allocator install = new Allocator();
}
public synchronized void apply(Account src,Account tag){
while (locks.contains(src)||locks.contains(tag)) {
try {
this.wait();
} catch (InterruptedException e) {
}
}
locks.add(src);
locks.add(tag);
}
public synchronized void release(Account src,Account tag){
locks.remove(src);
locks.remove(tag);
this.notifyAll();
}
}
}
原子性问题、可见性问题和有序性
其实只需要考虑一种情况:
存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据
如果不共享数据或者数据状态不发生变化,就能保证线程的安全性:
对于必须共享数据:
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)
add10K()方法, 当多个线程调用时候就会发生数据竞争
public class Test {
private long count = 0;
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
}
// 使用 synchronized 修饰 get() 和 set() 方法
// 所有访问共享变量 value 的地方,我们都增加了互斥锁,此时是不存在数据竞争的
但是add10K() 方法并不是线程安全的 -- 依然不安全
// 假设 count=0,当两个线程同时执行 get() 方法时,get() 方法会返回相同的值 0,
get 执行是一前一后,但是这两都在 set 执行前,所以 get 到的值都一样
两个线程执行 get()+1 操作,结果都是 1,之后两个线程再将结果 1 写入了内存。你本来期望的是 2,而结果却是 1。
public class Test {
private long count = 0;
synchronized long get(){
return count;
}
synchronized void set(long v){
count = v;
}
void add10K() {
int idx = 0;
while(idx++ < 10000) {
set(get()+1)
}
}
}
竞态条件
,指的是程序的执行结果依赖线程执行的顺序
例如上面的例子,如果两个线程完全同时执行,那么结果是 1;如果两个线程是前后执行,那么结果就是 2。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大 Bug。
竞态条件
, 在并发场景中,程序的执行依赖于某个状态变量
if (状态变量 满足 执行条件) {
执行操作
}
状态变量满足执行条件后,开始执行操作
;其他线程同时修改了状态变量,导致状态变量不满足执行条件了
。当然很多场景下,这个条件不是显式的,例如前面 addOne 的例子中,set(get()+1) 这个复合操作,其实就隐式依赖 get() 的结果
。
面对数据竞争和竞态条件问题, 可以用互斥这个技术方案,而实现互斥的方案有很多,
CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:锁。
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁
”和“饥饿
”
活锁
有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。
类比现实世界例子:
路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
这种情况,基本上谦让几次就解决了,因为人会交流啊。
可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”
解决“活锁”的方案:
路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;
同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。
由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。
“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况
解决“饥饿”问题的方案
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁
。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的
,排在等待队列前面的线程会优先获得资源。
“锁”的过度使用可能导致串行化的范围过大
,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?假设串行百分比是 5%,我们用多核多线程
相比单核单线程
能提速多少呢?
阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
公式里的 n 可以理解为 CPU 的核数,p 可以理解为并行百分比,那(1-p)就是串行百分比了,也就是我们假设的 5%。我们再假设 CPU 的核数(也就是 n)无穷大,那加速比 S 的极限就是 20。也就是说,如果我们的串行率是 5%,那么我们无论采用什么技术,最高也就只能提高 20 倍的性能
。
所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢?这个问题很复杂,Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。
解决方案:
Java 语言提供的 Vector 是一个线程安全的容器,下面的代码,是否存在并发问题呢?
void addIfNotExist(Vector v, Object o){
if(!v.contains(o)) {
v.add(o);
}
}
vector是线程安全,指的是它方法单独执行的时候没有并发正确性问题,并不代表把它的操作组合在一起问木有,而这个程序有竞态条件问题
Vector实现线程安全是通过给主要的写方法加了synchronized,类似contains这样的读方法并没有synchronized
,该题的问题就出在不是线程安全的contains方法,两个线程如果同时执行到if(!v.contains(o)) 是可以都通过的,这时就会执行两次add方法,重复添加。也就是竞态条件
。
class Vector {
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
}
Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程
但是管程更容易使用,所以 Java 选择了管程
管程,对应的英文是 Monitor
,很多 Java 领域的同学都喜欢将其翻译成“监视器”,这是直译。
操作系统领域一般都翻译成“管程”,这个是意译。
所谓管程,指的是管理共享变量以及对共享变量的操作过程
,让他们支持并发。
翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的
。那管程是怎么管的呢?
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型
。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。
在并发编程领域,有两大核心问题:
互斥
,即同一时刻只允许一个线程访问共享资源;同步
,即线程之间如何通信、协作。这两大问题,都可以使用管程解决。
解决互斥问题
的思路将共享变量及其对共享变量的操作统一封装起来
。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
如图,管程 X 将共享变量 queue 这个线程不安全的队列
和相关的操作入队操作 enq()、出队操作 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程
。
管程模型和面向对象高度契合的
解决线程间的同步问题
图中最外层的框就代表封装的意思 - 共享变量和对共享变量的操作是被封装起来的
框的上面只有一个入口,并且在入口旁边还有一个入口等待队列 - 当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待
每个条件变量都对应有一个等待队列, 即条件变量等待队列
, 用来解决线程同步问题
.
注意阻塞队列和等待队列是不同的:
用管程来实现线程安全的 --> 阻塞队列
管程内部的 --> 等待队列
阻塞队列不空
这个前提条件对应的就是管程里的条件变量。条件变量对应的等待队列
里面等。此时线程 T1 就去“队列不空”这个条件变量的等待队列中等待。允许其他线程进入管程
的。这和你去验血的时候,医生可以给其他患者诊治,道理都是一样的。重新分诊
。线程 T1 发现“阻塞队列不空”这个条件不满足,需要进到对应的等待队列里等待, 通过调用 wait()实现
如果我们用对象 A 代表“阻塞队列不空”这个条件
,那么线程 T1 需要调用 A.wait()
。
同理当“阻塞队列不空”这个条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程
,此时这个等待队列里面只有线程 T1。至于 notifyAll() 这个方法,它可以通知等待队列中的所有线程。
// 用管程实现一个线程安全的阻塞队列
// 注意: 这个阻塞队列和管程内部的等待队列没关系
// 入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口
public class BlockedQueue<T>{
final Lock lock = new ReentrantLock();
// 条件变量:阻塞队列不满
final Condition notFull = lock.newCondition();
// 条件变量:阻塞队列不空
final Condition notEmpty = lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
// 对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await()
notFull.await();
}
// 省略入队操作...
// 入队后,通知可出队
// 如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列。
notEmpty.signal(); ---- 满足使用signal的三个条件, 相同的等待条件, 唤醒后执行相同的操作, 只需要唤醒一个线程
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
// 对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了notEmpty.await();。
notEmpty.await();
}
// 省略出队操作...
// 出队后,通知可入队
// 如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满notFull对应的等待队列
notFull.signal();
}finally {
lock.unlock();
}
}
}
注意: await() 和前面我们提到的 wait() 语义是一样的;signal() 和前面我们提到的 notify() 语义是一样的
MESA 管程特有的编程范式: 在一个 while 循环里面调用 wait()
while(条件不满足) {
wait();
}
Hasen 模型、Hoare 模型和 MESA 模型的一个核心区别
就是当条件满足后,如何通知相关线程。
管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行呢?
从条件变量的等待队列进到入口等待队列里面
。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用
,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量
。当线程被唤醒后,是从wait命令后开始执行的(不是从头开始执行该方法,示意图容易让人产生歧义),而执行时间点往往跟唤醒时间点不一致,所以条件变量此时不一定满足了,所以通过while循环可以再验证
,
而if条件却做不到,它只能从wait命令后开始执行,所以要用while
------- point
除非经过深思熟虑,否则尽量使用 notifyAll()
使用notify()需要满足以下三个条件:
所有等待线程拥有相同的等待条件
;所有等待线程被唤醒后,执行相同的操作
;只需要唤醒一个线程
。比如上面阻塞队列的例子中,对于“阻塞队列不满
”这个条件变量,其等待线程都是在等待“阻塞队列不满
”这个条件,反映在代码里就是下面这 3 行代码。对所有等待线程来说,都是执行这 3 行代码,重点是 while 里面的等待条件是完全相同的。
入队 --> 阻塞队列已满 --> 线程去 等待队列不满条件 的等待队列里等待
while (阻塞队列已满){
// 等待队列不满 -- 这里的条件变量: '阻塞'队列不满
notFull.await();
}
------------- 参考代码
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
// 对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await()
notFull.await();
}
// 省略入队操作...
// 入队后,通知可出队
// 如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列。
notEmpty.signal();
}finally {
lock.unlock();
}
}
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
// 省略入队操作...
// 入队后,通知可出队
notEmpty.signal();
同时也满足第 3 条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用 signal() 是可以的。
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量
。具体如下图所示。
仅支持一个条件变量
;并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。
1.管程是一种概念,任何语言都可以通用。
2.在java中,每个加锁的对象都绑定着一个管程(监视器)
3.线程访问加锁对象,就是去拥有一个监视器的过程。如一个病人去门诊室看医生,医生是共享资源,门锁锁定医生,病人去看医生,就是访问医生这个共享资源,门诊室其实是监视器(管程)。
4.所有线程访问共享资源,都需要先拥有监视器。就像所有病人看病都需要先拥有进入门诊室的资格。
5.监视器至少有两个等待队列。一个是进入监视器的等待队列一个是条件变量对应的等待队列。后者可以有多个。就像一个病人进入门诊室诊断后,需要去验血,那么它需要去抽血室排队等待。另外一个病人心脏不舒服,需要去拍胸片,去拍摄室等待。
6.监视器要求的条件满足后,位于条件变量下等待的线程需要重新在门诊室门外排队,等待进入监视器。就像抽血的那位,抽完后,拿到了化验单,然后,重新回到门诊室等待,然后进入看病,然后退出,医生通知下一位进入。
总结起来就是,管程就是一个对象监视器。任何线程想要访问该资源,就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。
参考:
北大-操作系统-管程课程
https://www.coursera.org/lecture/os-pku/mesaguan-cheng-Fya0t
wait() 方法,在 Hasen 模型和 Hoare 模型里面,都是没有参数的,而在 MESA 模型里面,增加了超时参数,你觉得这个参数有必要吗?
wait() 不加超时参数,相当于得一直等着别人叫你去门口排队,
加了超时参数,相当于等一段时间,再没人叫的话,我就受不了自己去门口排队了,这样就诊的机会会大一点
就诊机会不一定大,但是能避免没人叫的时候傻等
初始状态
,指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。可运行状态
,指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。运行状态
。休眠状态
,同时释放 CPU 使用权
,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。终止状态
,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。这五种状态在不同编程语言里会有简化合并。
例如,C 语言的 POSIX Threads 规范
,就把初始状态和可运行状态合并了;
Java 语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。
除了简化合并,这五种状态也有可能被细化,比如,Java 语言里就细化了休眠状态(这个下面会详细讲解)。
Java 语言中线程共有六种状态,分别是:
在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING
是一种状态,即前面我们提到的休眠状态
。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权
BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因
只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁
。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。
总体来说,有三种场景会触发这种转换。
LockSupport.park()
方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread)
可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。有五种场景会触发这种转换:
注意: TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。
Java 刚创建出来的 Thread 对象就是 NEW 状态,而创建 Thread 对象主要有两种方法。
一种是继承 Thread 对象,重写 run() 方法。
// 自定义线程对象
class MyThread extends Thread {
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
MyThread myThread = new MyThread();
另一种是实现 Runnable 接口,重写 run() 方法,并将该实现类作为创建 Thread 对象的参数。
// 实现Runnable接口
class Runner implements Runnable {
@Override
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
Thread thread = new Thread(new Runner());
NEW 状态的线程,不会被操作系统调度,因此不会执行。Java 线程要执行,就必须转换到 RUNNABLE 状态。从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法就可以了
MyThread myThread = new MyThread();
// 从NEW状态转换到RUNNABLE状态
myThread.start();
线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断 run() 方法的执行,例如 run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java 的 Thread 类里面倒是有个 stop() 方法,不过已经标记为 @Deprecated,所以不建议使用了。正确的姿势其实是调用 interrupt() 方法
。
那 stop() 和 interrupt() 方法的主要区别:
interrupt() 方法仅仅是通知线程
,线程有机会执行一些后续操作,同时也可以无视这个通知。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测
。当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常
。
上面我们提到RUNNABLE 状态转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 Object.wait()、Thread.join()、Thread.sleep()
这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法
。
下面这两种情况属于被中断的线程通过异常
的方式获得了通知:
还有一种是主动检测
,如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断
了。
理解 Java 线程的各种状态以及生命周期对于诊断多线程 Bug 非常有帮助,多线程程序很难调试,出了 Bug 基本上都是靠日志,靠线程 dump 来跟踪问题,分析线程 dump
的一个基本功就是分析线程状态,大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。同时,本文介绍的线程生命周期具备很强的通用性,对于学习其他语言的多线程编程也有很大的帮助。
你可以通过 jstack 命令
或者Java VisualVM
这个可视化工具将 JVM 所有的线程栈信息导出来,完整的线程栈信息不仅包括线程的当前状态、调用栈,还包括了锁的信息。例如,一个死锁的程序,导出的线程栈明确告诉发生了死锁,并且将死锁线程的调用栈信息清晰地显示出来了(如下图)。导出线程栈,分析线程状态是诊断并发问题的一个重要工具。
发生死锁的线程栈
下面代码的本意是当前线程被中断之后,退出while(true),你觉得这段代码是否正确呢?
Thread th = Thread.currentThread();
while(true) {
if(th.isInterrupted()) {
break;
}
// 省略业务代码无数
try {
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
可能出现无限循环,线程在sleep期间被打断了,抛出一个InterruptedException异常,try catch捕捉此异常,应该重置一下中断标示
,因为抛出异常后,中断标示会自动清除掉
!
Thread th = Thread.currentThread();
while(true) {
if(th.isInterrupted()) {
break;
}
// 省略业务代码无数
try {
Thread.sleep(100);
}catch (InterruptedException e){
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
度量性能的指标:
降低延迟,提高吞吐量:
操作系统已经解决了磁盘和网卡的利用率问题,利用中断机制还能避免 CPU 轮询 I/O 状态,也提升了 CPU 的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备,而我们的并发程序,往往需要 CPU 和 I/O 设备相互配合工作,也就是说,我们需要解决 CPU 和 I/O 设备综合利用率的问题 – 多线程
如何利用多线程来提升 CPU 和 I/O 设备的利用率?
程序按照 CPU 计算和 I/O 操作交叉执行的方式运行,而且 CPU 计算和 I/O 操作的耗时是 1:1
如果只有一个线程,执行 CPU 计算的时候,I/O 设备空闲;执行 I/O 操作的时候,CPU 空闲,所以 CPU 的利用率和 I/O 设备的利用率都是 50%
单线程执行示意图
当线程 A 执行 CPU 计算的时候,线程 B 执行 I/O 操作;当线程 A 执行 I/O 操作的时候,线程 B 执行 CPU 计算,这样 CPU 的利用率和 I/O 设备的利用率就都达到了 100%。
两个线程执行示意图
此时CPU 的利用率和 I/O 设备的利用率都提升到了 100%, 单位时间处理的请求数量翻了一番,也就是说吞吐量提高了 1 倍。此时可以逆向思维一下,如果 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%
程序一般都是 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 耗时)
令 R=I/O 耗时 / CPU 耗时,综合上图,可以这样理解:当线程 A 执行 IO 操作时,另外 R 个线程正好执行完各自的 CPU 计算。这样 CPU 的利用率就达到了 100%
不过上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:
**最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]**
原则: 将硬件的性能发挥到极致
对于 I/O 密集型计算场景,I/O 耗时和 CPU 耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计
需要重点关注 CPU、I/O 设备的利用率和性能指标(响应时间、吞吐量)之间的关系
有些同学对于最佳线程数的设置积累了一些经验值,认为对于 I/O 密集型应用,最佳线程数应该为:2 * CPU 的核数 + 1,你觉得这个经验值合理吗?
个人觉得公式话性能问题有些不妥,定性的io密集或者cpu密集很难在定量的维度上反应出性能瓶颈,而且公式上忽略了线程数增加带来的cpu消耗,性能优化还是要定量比较好,这样不会盲目,比如io已经成为了瓶颈,增加线程或许带来不了性能提升,这个时候是不是可以考虑用cpu换取带宽,压缩数据,或者逻辑上少发送一些。最后一个问题,我的答案是大部分应用环境是合理的,老师也说了是积累了一些调优经验后给出的方案,没有特殊需求,初始值我会选大家都在用伪标准
本来就是分CPU密集型和IO密集型的,尤其是IO密集型更是需要进行测试和分析而得到结果,差别很大,比如IO/CPU的比率很大,比如10倍,2核,较佳配置:2*(1+10)=22个线程,而2*CPU核数+1 = 5
// 返回斐波那契数列
int[] fibonacci(int n) {
// 创建结果数组
int[] r = new int[n];
// 初始化第一、第二个数
r[0] = r[1] = 1; // ①
// 计算2..n
for(int i = 2; i < n; i++) {
r[i] = r[i-2] + r[i-1];
}
return r;
}
多个线程调用 fibonacci() 方法的情景,假设多个线程执行到 ① 处,多个线程都要对数组 r 的第 1 项和第 2 项赋值,这里看上去感觉是存在数据竞争的
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,
对此还有一个专业的术语,叫做数据竞争(Data Race)
局部变量不存在数据竞争
每个方法的调用对应着一次栈帧的创建,栈帧中包含了局部变量表
int a = 7;
int[] b = fibonacci(a);
int[] c = b;
当调用 fibonacci(a) 的时候,CPU 要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后 CPU 执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c=b;
的地址,再跳转到这个地址去执行。
找到调用方法的下一条语句的地址
:
通过 CPU 的堆栈寄存器
。CPU 支持一种栈结构,栈就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为调用栈
例如,有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧
,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。
调用栈结构
利用栈结构来支持方法调用: java虚拟机, CPU 里内置了栈寄存器
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。
–> 局部变量就是放到了调用栈里
new 出来的对象是在堆里,局部变量是在栈里
局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里
两个线程可以同时用不同的参数调用相同的方法, 每个线程都有自己独立的调用栈
每个线程都有自己独立的调用栈,线程之间互相不干扰. 当两个线程同时调用一个方法时,是对应两个调用栈
线程与调用栈的关系图
Java 方法里面的局部变量是否存在并发问题?一点问题都没有。
因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。
再次重申一遍:没有共享,就没有伤害。
仅在单线程内访问数据
由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程
,从而保证了 Connection 不会有并发问题
递归调用太深,可能导致栈溢出。
栈溢出原因:
因为每调用一个方法就会在栈上创建一个栈帧,方法调用结束后就会弹出该栈帧,而栈的大小不是无限的,所以递归调用次数过多的话就会导致栈溢出。而递归调用的特点是每递归一次,就要创建一个新的栈帧,而且还要保留之前的环境(栈帧),直到遇到结束条件。所以递归调用一定要明确好结束条件,不要出现死循环,而且要避免栈太深。
解决方法:
尾递归
,尾递归是指在方法返回时只调用自己本身,且不能包含表达式。编译器或解释器会把尾递归做优化,使递归方法不论调用多少次,都只占用一个栈帧,所以不会出现栈溢出。然鹅,Java没有尾递归优化。在 Java 语言里,面向对象思想能够让并发编程变得更简单
封装: 将属性和实现细节封装在对象内部, 外界对象只能
通过目标对象提供的公共方法来间接访问这些内部属性
将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略
计数器程序共享变量只有一个,就是 value,我们把它作为 Counter 类的属性,并且将两个公共方法 get() 和 addOne() 声明为同步方法,这样 Counter 类就成为一个线程安全的类了
public class Counter {
private long value;
synchronized long get(){
return value;
}
synchronized long addOne(){
return ++value;
}
}
对于这些不会发生变化的共享变量,建议你用 final 关键字来修饰
这些约束条件,决定了并发访问策略
库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限
原子类是线程安全的,所以这两个成员变量的 set 方法就不需要同步了
public class SafeWM {
// 库存上限
private final AtomicLong upper = new AtomicLong(0);
// 库存下限
private final AtomicLong lower = new AtomicLong(0);
// 设置库存上限
void setUpper(long v){
upper.set(v);
}
// 设置库存下限
void setLower(long v){
lower.set(v);
}
// 省略其他业务代码
}
忽视了一个约束条件,就是库存下限要小于库存上限,这个约束条件能够直接加到上面的 set 方法上吗
库存下限要小于库存上限, 在 setUpper() 和 setLower() 中增加了参数校验, 会存在并发问题 -- 竞态条件
代码里出现 if 语句的时候,就应该立刻意识到可能存在竞态条件
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) 了,
显然此时的结果是不符合库存下限要小于库存上限这个约束条件的
在没有识别出库存下限要小于库存上限这个约束条件之前,我们制定的并发访问策略是利用原子类
,但是这个策略,完全不能保证库存下限要小于库存上限这个约束条件。
所以说,在设计阶段,我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句
,所以,一定要特别注意竞态条件。
写出“健壮”的并发程序的宏观原则:
低级的同步原语主要指的是 synchronized、Lock、Semaphore
等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。首先要保证安全,出现性能瓶颈后再优化
。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的对共享变量进行封装,要避免“逸出”,所谓“逸出”简单讲就是共享变量逃逸到对象的外面
类 SafeWM 不满足库存下限要小于库存上限这个约束条件,那你来试试修改一下,让它能够在并发条件下满足库存下限要小于库存上限这个约束条件
public class Boundary {
private final lower;
private final upper;
public Boundary(long lower, long upper) {
if(lower >= upper) {
// throw exception
}
this.lower = lower;
this.upper = upper;
}
}
移除 SafeVM 的 setUpper() 跟 setLower() 方法,并增入 setBoundary(Boundary boundary) 方法。
– 01节
起源是一个硬件的核心矛盾:CPU 与内存、I/O 的速度差异,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题,这三个问题就是很多并发程序的 Bug 之源
1、CPU 增加了缓存,以均衡与内存的速度差异;
2、操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3、编译程序优化指令执行次序,使得缓存能够得到更加合理地利用
Java 内存模型,以应对可见性和有序性问题; – 02节
原子性问题, 用互斥锁才是关键 – 0304节
虽说互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,死锁的产生原因以及解决方案 – 05节
线程间协作的问题,介绍线程间的协作机制:等待 - 通知 – 06节
站在宏观的角度重新审视并发编程相关的概念和理论,同时也是对前六篇文章的查漏补缺 – 07节
管程,是 Java 并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的 – 08节
线程相关的知识 – 091011节
面向对象思想写好并发程序 – 12节
– 03节思考
下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}
每次调用方法 get()、addOne() 都创建了不同的锁,相当于无锁
一个合理的受保护资源与锁之间的关联关系应该是 N:1, 只有共享一把锁才能起到互斥的作用
JVM 开启逃逸分析之后,synchronized (new Object()) 这行代码在实际执行的时候会被优化掉,也就是说在真实执行的时候,这行代码压根就不存在
– 04节思考
class Account {
// 账户余额
private Integer balance;
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balance) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 更改密码
void updatePassword(String pw){
synchronized(password) {
this.password = pw;
}
}
}
一个是锁有可能会变化
,另一个是 Integer 和 String 类型的对象不适合做锁
。
如果锁发生变化,就意味着失去了互斥功能。 Integer 和 String 类型的对象在 JVM 里面是可能被重用的,除此之外,JVM 里可能被重用的对象还有 Boolean,那重用
意味着什么呢?意味着你的锁可能被其他代码使用
,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。
锁,应是私有的、不可变的、不可重用的
《Java 安全编码标准》
// 普通对象锁
private final Object lock = new Object();
// 静态对象锁
private static final Object lock = new Object();
– 05节思考
比较while(!actr.apply(this, target));
这个方法和synchronized(Account.class)
的性能哪个更好
这个要看具体的应用场景,不同应用场景它们的性能表现是不同的。在这个思考题里面,如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许 A->B、C->D 这种转账业务的并行
。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。
contains() 和 add() 方法虽然都是线程安全的,但是组合在一起却不是线程安全的
void addIfNotExist(Vector v, Object o){
if(!v.contains(o)) {
v.add(o);
}
}
Vector实现线程安全是通过给主要的写方法加了synchronized,类似contains这样的读方法并没有synchronized,该题的问题就出在不是线程安全的contains方法,两个线程如果同时执行到if(!v.contains(o)) 是可以都通过的,这时就会执行两次add方法,重复添加。也就是竞态条件。
解决方法:
将共享变量 v 封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对 Vector v 变量的滥用,从而导致并发问题
class SafeVector{
private Vector v;
// 所有公共方法增加同步控制
synchronized void addIfNotExist(Object o){
if(!v.contains(o)) {
v.add(o);
}
}
}
set(get()+1);这条语句是进入 set() 方法之后才执行 get() 方法,其实并不是这样的。方法的调用,是先计算参数
,然后将参数压入调用栈之后才会执行方法体
while(idx++ < 10000) {
set(get()+1);
}
先计算参数
这个事情也是容易被忽视的细节。例如,下面写日志的代码,如果日志级别设置为 INFO,虽然这行代码不会写日志,但是会计算"The var1:" + var1 + ", var2:" + var2
的值,因为方法调用前会先计算参数。
logger.debug("The var1:" + var1 + ", var2:" + var2);
更好地写法应该是下面这样,这种写法仅仅是讲参数压栈,而没有参数的计算。使用{}占位符是写日志的一个良好习惯。
logger.debug("The var1:{}, var2:{}", var1, var2);
调用 Java 对象的 wait() 方法或者线程的 sleep() 方法时,需要捕获并处理 InterruptedException 异常
Thread th = Thread.currentThread();
while(true) {
if(th.isInterrupted()) {
break;
}
// 省略业务代码无数
try {
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
希望:
通过 isInterrupted() 检查线程是否被中断, 如果中断了就退出 while 循环
当其他线程通过调用th.interrupt().来中断 th 线程时,会设置 th 线程的中断标志位,
从而使th.isInterrupted()返回 true,这样就能退出 while 循环
这看上去一点问题没有,实际上却是几乎起不了作用。原因是这段代码在执行的时候,大部分时间都是阻塞在 sleep(100) 上
,当其他线程通过调用th.interrupt().来中断 th 线程时,大概率地会触发 InterruptedException 异常,在触发 InterruptedException 异常的同时,JVM 会同时把线程的中断标志位清除
,所以这个时候th.isInterrupted()返回的是 false。
正确的处理方式应该是捕获异常之后重新设置中断标志位
,也就是下面这样:
try {
Thread.sleep(100);
}catch(InterruptedException e){
// 重新设置中断标志位
th.interrupt();
}
最佳线程 =2 * CPU 的核数 + 1
从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O 耗时 / CPU 耗时”不太容易确定的系统来说,却是一个很好到初始值。
最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O 耗时 / CPU 耗时”
往往都大于 1,所以基本上都是在这个初始值的基础上增加
增加的过程中,应关注线程数是如何影响吞吐量和延迟的。
随着线程数的增加,吞吐量会增加,延迟也会缓慢增加
;实际工作中,不同的 I/O 模型对最佳线程数的影响非常大,例如大名鼎鼎的 Nginx 用的是非阻塞 I/O,采用的是多进程单线程结构,Nginx 本来是一个 I/O 密集型系统,但是最佳进程数设置的却是 CPU 的核数,完全参考的是 CPU 密集型的算法。所以,理论我们还是要活学活用。