Java并发编程
2021-08-03 10:06:35 1 举报
AI智能生成
Java并发编程
作者其他创作
大纲/内容
并发设计模式
Immutability模式
Copy-on-Write模式
线程本地存储模式
GuardedSuspension模式
Balking模式
Thread-Per-Message模式
WorkerThread模式
两阶段终止模式
生产者-消费者模式
并发案例分析
高性能限流器GuavaRateLimiter
高性能网络应用框架Netty
高性能队列Disruptor
高性能数据库连接池HiKariCP
其他并发模型
CSP模型:Golang的主力队员
协程:更轻量级的线程
软件事务内存:借鉴数据库的并发经验
Actor模型:面向对象原生的并发模型
并发理论知识
为什么出现并发?
计算机硬件的CPU、内存、I/O等功能是有限制的,我们需要使用复杂的代码去突破限制这就是并发了。
并发编程的掌握过程并不容易:并发编程的第一原则,那就是不要写并发程序
管程作为一种解决并发问题的模型:是继信号量模型之后的一项重大创新,它与信号量在逻辑上是等价的(可以用管程实现信号量,也可以用信号量实现管程),但是相比之下管程更易用
其实并发编程可以总结为三个核心问题:分工、同步、互斥
如何才能学好并发?
分工
所谓分工,类似于现实中一个组织完成一个项目,项目经理要拆分任务,安排合适的成员去完成。
在并发编程领域,你就是项目经理,线程就是项目组成员。可以用华罗庚曾用"烧水泡茶"的例子来说明一下。
最简单的显示世界对比方式就是:生产者-消费者模式
同步
主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。
在Java并发编程领域,解决协作问题的核心技术是管程,上面提到的所有线程协作技术底层都是利用管程解决的
在现实中生产者-消费者模型里,也有类似的描述,"当队列满时,生产者线程等待,当队列不满时,生产者线程需要被唤醒执行;当队列空时,消费者线程等待,当队列不空时,消费者线程需要被唤醒执行。"
互斥
指的是同一时刻,只允许一个线程访问共享变量。
实现互斥的核心技术就是锁,Java语言里synchronized、SDK里的各种Lock都能解决互斥问题。
跳出来,看全景
把所学知识串联起来
并发编程
分工
Executor与线程池
Fork/Join
Futrue
Guarded Suspension模式
Balking模式
Thread-Per-Message模式
生产者-消费者模式
Worker Thread模式
两阶段终止模式
互斥
无锁
不变模式
线程本地存储
CAS
Copy-on-Write
原子类
互斥锁
synchronized
Lock
读写锁
协作
信号量(Semaphore)
管程(Monitor)
Lock$Condition
synchronized
countDownlatch
CyclicBarrier
Phaser
Exchanger
钻进去,看本质
知其然知其所以然,学习技术的理论
并发工具类
并发Bug的源头
CPU、内存、I/O设备问题
性能差异
CPU是天上一天,内存是地上一年;
内存是天上一天,I/O设备是地上十年;
解决性能差异问题
CPU增加了缓存,以均衡与内存的速度差异;
操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
缓存导致的可见性问题
单核时代
单核时代所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。
因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。
因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
多核时代
多核时代,每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决了。
当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。
当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。
线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存;
很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了
很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了
线程切换带来的原子性问题
文件读取
在一个时间片内一个进程进行一个IO操作,进程会释放CPU的使用权;
待文件读进内存,操作系统会唤起进程获得CPU使用权。
待文件读进内存,操作系统会唤起进程获得CPU使用权。
进程在等待IO时会释放CPU使用权是为了让CPU在等待时间里可以做别的事情,CPU的使用率就提高了
这时如果有另外一个进程也读文件,读文件的操作就会排队;
磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。
磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。
线程切换
Java并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异Bug的源头之一;
任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成;
比如count+=1至少需要三条CPU指令
指令1:首先,需要把变量count从内存加载到CPU的寄存器;
指令2:之后,在寄存器中执行+1操作;
指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
指令1:首先,需要把变量count从内存加载到CPU的寄存器;
指令2:之后,在寄存器中执行+1操作;
指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。
CPU保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。
因此,很多时候我们需要在高级语言层面保证操作的原子性。
CPU保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。
因此,很多时候我们需要在高级语言层面保证操作的原子性。
编译优化带来的有序性问题
那并发编程里还有没有其他有违直觉容易导致诡异Bug的技术呢?有的,就是有序性。
顾名思义,有序性指的是程序按照代码的先后顺序执行。
顾名思义,有序性指的是程序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”
在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
在获取实例getInstance()的方法中,我们首先判断instance是否为空,
如果为空,则锁定Singleton.class并再次检查instance是否为空,
如果还为空则创建Singleton的一个实例。
如果为空,则锁定Singleton.class并再次检查instance是否为空,
如果还为空则创建Singleton的一个实例。
假设有两个线程A、B同时调用getInstance()方法,他们会同时发现instance==null,于是同时对Singleton.class加锁,
此时JVM保证只有一个线程能够加锁成功(假设是线程A),另外一个线程则会处于等待状态(假设是线程B);
此时JVM保证只有一个线程能够加锁成功(假设是线程A),另外一个线程则会处于等待状态(假设是线程B);
线程A会创建一个Singleton实例,之后释放锁,锁释放后,线程B被唤醒,线程B再次尝试加锁,此时是可以加锁成功的,
加锁成功后,线程B检查instance==null时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。
加锁成功后,线程B检查instance==null时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。
这看上去一切都很完美,无懈可击,但实际上这个getInstance()方法并不完美。问题出在哪里呢?
出在new操作上,我们以为的new操作应该是:
出在new操作上,我们以为的new操作应该是:
- 分配一块内存M;
- 在内存M上初始化Singleton对象;
- 然后M的地址赋值给instance变量。
- 分配一块内存M;
- 将M的地址赋值给instance变量;
- 最后在内存M上初始化Singleton对象。
优化后会导致什么问题呢?
我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;
如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,
而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。
我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;
如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,
而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。
Java内存模型
Java内存模型
你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化;
那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。
那么,如何做到“按需禁用”呢?
对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。
所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法
三个关键字
volatile
synchronized
final
六项Happens-Before规则
使用volatile的困惑
volatile关键字并不是Java语言的特产,古老的C语言里也有,它最原始的意义就是禁用CPU缓存。
例如,我们声明一个volatile变量volatile int x=0,它表达的是:
告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。
这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。
告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。
这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。
直觉上看,应该是42,那实际应该是多少呢?
这个要看Java的版本,如果在低于1.5版本上运行,x可能是42,也有可能是0;
如果在1.5以上的版本上运行,x就是等于42。
这个要看Java的版本,如果在低于1.5版本上运行,x可能是42,也有可能是0;
如果在1.5以上的版本上运行,x就是等于42。
分析一下,为什么1.5以前的版本会出现x=0的情况呢?
我相信你一定想到了,变量x可能被CPU缓存而导致可见性问题。
这个问题在1.5版本已经被圆满解决了。Java内存模型在1.5版本对volatile语义进行了增强。
怎么增强的呢?答案是一项Happens-Before规则。
我相信你一定想到了,变量x可能被CPU缓存而导致可见性问题。
这个问题在1.5版本已经被圆满解决了。Java内存模型在1.5版本对volatile语义进行了增强。
怎么增强的呢?答案是一项Happens-Before规则。
Happens-Before规则
如何理解Happens-Before呢?
如果望文生义(很多网文也都爱按字面意思翻译成“先行发生”),那就南辕北辙了;
Happens-Before并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:
前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到
前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到
Happens-Before规则就是要保证线程之间的这种“心灵感应”。
比较正式的说法是:Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。
程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作。
程序前面对某个变量的修改一定是对后续操作可见的。
volatile变量规则
指对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。
这个就有点费解了,对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见,这怎么看都是禁用缓存。
貌似和1.5版本以前的语义没有变化啊?
如果单看这个规则,的确是这样,但是如果我们关联一下规则3.3,就有点不一样的感觉了。
貌似和1.5版本以前的语义没有变化啊?
如果单看这个规则,的确是这样,但是如果我们关联一下规则3.3,就有点不一样的感觉了。
传递性
这条规则是指如果AHappens-BeforeB,且BHappens-BeforeC,那么AHappens-BeforeC。
“x=42”Happens-Before写变量“v=true”,这是规则3.1的内容;
写变量“v=true”Happens-Before读变量“v=true”,这是规则3.2的内容。
写变量“v=true”Happens-Before读变量“v=true”,这是规则3.2的内容。
再根据这个传递性规则,我们得到结果:“x=42”Happens-Before读变量“v=true”。这意味着什么呢?
如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。
也就是说,线程B能看到“x==42”,有没有一种恍然大悟的感觉?
如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。
也就是说,线程B能看到“x==42”,有没有一种恍然大悟的感觉?
这就是1.5版本对volatile语义的增强,这个增强意义重大。
1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的,这个在后面的内容中会详细介绍。
1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的,这个在后面的内容中会详细介绍。
管程中锁的规则
这条规则是指对一个锁的解锁Happens-Before于后续对这个锁的加锁。
要理解这个规则,就首先要了解“管程指的是什么”。
管程是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。
假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),
线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。完全符合我们直觉很容易理解
线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。完全符合我们直觉很容易理解
线程start()
规则这条是关于线程启动的。它是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
换句话说就是,如果线程A调用线程B的start()方法(即在线程A中启动线程B),那么该start()操作Happens-Before于线程B中的任意操作。
线程join()规则
它是指主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),
当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。
当然所谓的“看到”,指的是对共享变量的操作。
当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。
当然所谓的“看到”,指的是对共享变量的操作。
ThreadB=newThread(()->{
//此处对共享变量var修改
var=66;
});
//例如此处对共享变量修改,
//则这个修改结果对线程B可见
//主线程启动子线程
B.start();
B.join()
//子线程所有对共享变量的修改
//在主线程调用B.join()之后皆可见
//此例中,var==66
//此处对共享变量var修改
var=66;
});
//例如此处对共享变量修改,
//则这个修改结果对线程B可见
//主线程启动子线程
B.start();
B.join()
//子线程所有对共享变量的修改
//在主线程调用B.join()之后皆可见
//此例中,var==66
换句话说就是,如果在线程A中,调用线程B的join()并成功返回,那么线程B中的任意操作Happens-Before于该join()操作的返回
被我们忽视的final
前面都是volatile为的是禁用缓存以及编译优化,有没有办法告诉编译器优化得更好一点呢?这个可以有,就是final关键字。
final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。
Java编译器在1.5以前的版本的确优化得很努力,以至于都优化错了。
Java编译器在1.5以前的版本的确优化得很努力,以至于都优化错了。
利用双重检查方法创建单例,构造函数的错误重排导致线程可能看到final变量的值会变化。
当然了,在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将this赋值给了全局变量global.obj,
这就是“逸出”,线程通过global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。
这就是“逸出”,线程通过global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。
//以下代码来源于【参考1】
finalintx;
//错误的构造函数
publicFinalFieldExample(){
x=3;
y=4;
//此处就是讲this逸出,
global.obj=this;
}
finalintx;
//错误的构造函数
publicFinalFieldExample(){
x=3;
y=4;
//此处就是讲this逸出,
global.obj=this;
}
互斥锁
解决原子性问题
如何解决原子问题
原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?
而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。
而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。
单核CPU场景
同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,
也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,
所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,
所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
多核CPU场景
同一时刻有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,
此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行。
如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。
此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行。
如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。
如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。
如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。
简易锁模型
互斥执行的代码叫做临界区。
线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;
否则呢就等待,直到持有锁的线程解锁;
持有锁的线程执行完临界区的代码后,执行解锁unlock()。
错误理解例子
我很长一段时间认为,这个过程非常像办公室里高峰期抢占坑位,
每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。
每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。
这样理解本身没有问题,但却很容易让我们忽视两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?
改进后的锁模型
现实世界中
锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。
并发编程中
锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;
其次,我们要保护资源R就得为它创建一把锁LR;
最后,针对这把锁LR,我们还需在进出临界区时添上加锁操作和解锁操作。
其次,我们要保护资源R就得为它创建一把锁LR;
最后,针对这把锁LR,我们还需在进出临界区时添上加锁操作和解锁操作。
另外,在锁LR和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。
很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,
这样的Bug非常不好诊断,因为潜意识里我们认为已经正确加锁了。
很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,
这样的Bug非常不好诊断,因为潜意识里我们认为已经正确加锁了。
Java相关锁技术
锁是一种通用的技术方案,Java语言提供的synchronized关键字,就是锁的一种实现。
synchronized关键字可以用来修饰方法,也可以用来修饰代码块
这个和我们上面提到的模型有点对不上号啊,加锁lock()和解锁unlock()在哪里呢?
其实这两个操作都是有的,只是这两个操作是被Java默默加上的,
Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),
Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),
这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的,
毕竟忘记解锁unlock()可是个致命的Bug(意味着其他线程只能死等下去了)。
毕竟忘记解锁unlock()可是个致命的Bug(意味着其他线程只能死等下去了)。
那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢?
上面的代码我们看到只有修饰代码块的时候,
锁定了一个obj对象,那修饰方法的时候锁定的是什么呢?
锁定了一个obj对象,那修饰方法的时候锁定的是什么呢?
这个也是Java的一条隐式规则:
当修饰静态方法的时候,锁定的是当前类的Class对象,在上面的例子中就是ClassX;
当修饰非静态方法的时候,锁定的是当前实例对象this。
举例说明:用synchronized解决count+=1
SafeCalc这个类有两个方法:一个是get()方法,用来获得value的值;
另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰。
那么我们使用的这两个方法有没有并发问题呢?
另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰。
那么我们使用的这两个方法有没有并发问题呢?
我们先来看看addOne()方法,首先可以肯定,被synchronized修饰后,无论是单核CPU还是多核CPU,
只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题呢?
只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题呢?
管程中锁的规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁。
管程就是我们这里的synchronized,我们知道synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;
而所谓“对一个锁解锁Happens-Before后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,
综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),
对后续进入临界区(该操作在加锁之后)的线程是可见的。
而所谓“对一个锁解锁Happens-Before后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,
综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),
对后续进入临界区(该操作在加锁之后)的线程是可见的。
按照这个规则,如果多个线程同时执行addOne()方法,可见性是可以保证的,
也就说如果有1000个线程执行addOne()方法,最终结果一定是value的值增加了1000。
看到这个结果,我们长出一口气,问题终于解决了。
也就说如果有1000个线程执行addOne()方法,最终结果一定是value的值增加了1000。
看到这个结果,我们长出一口气,问题终于解决了。
但也许,你一不小心就忽视了get()方法。执行addOne()方法后,value的值对get()方法是可见的吗?
这个可见性是没法保证的。
管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。那如何解决呢?
很简单,就是get()方法也synchronized一下,详见代码
这个可见性是没法保证的。
管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。那如何解决呢?
很简单,就是get()方法也synchronized一下,详见代码
上面的代码转换为我们提到的锁模型,就是下面图示。
get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。
线程要进入临界区get()和addOne(),必须先获得this这把锁,这样get()和addOne()也是互斥的。
get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。
线程要进入临界区get()和addOne(),必须先获得this这把锁,这样get()和addOne()也是互斥的。
这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是Java类里的方法,
而门票就是用来保护资源的“锁”,Java的检票工作是由synchronized解决的。
而门票就是用来保护资源的“锁”,Java的检票工作是由synchronized解决的。
锁和受保护资源之间的关系
受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?
一个合理的关系是:
受保护资源和锁之间的关联关系是N:1的关系。
还拿前面球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,
如果多发了重复的票,那就要打架了。
受保护资源和锁之间的关联关系是N:1的关系。
还拿前面球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,
如果多发了重复的票,那就要打架了。
现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。
不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的“包场”了。
如果你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。
由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。
如何用一把锁保护多个资源?
保护没有关联关系的多个资源
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,
这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各的
这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各的
同样这对应到编程领域,也很容易解决。
例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,
我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,
我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
账户类Account有两个成员变量,分别是账户余额balance和账户密码password。
取款withdraw()和查看余额getBalance()操作会访问账户余额balance,我们创建一个final对象balLock作为锁(类比球赛门票);
而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password,我们创建一个final对象pwLock作为锁(类比电影票)。
不同的资源用不同的锁保护,各自管各自的,很简单。
取款withdraw()和查看余额getBalance()操作会访问账户余额balance,我们创建一个final对象balLock作为锁(类比球赛门票);
而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password,我们创建一个final对象pwLock作为锁(类比电影票)。
不同的资源用不同的锁保护,各自管各自的,很简单。
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用this这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字synchronized就可以了,这里我就不一一展示了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。
而我们用两把锁,取款和修改密码是可以并行的。
用不同的锁对受保护资源进行精细化管理,能够提升性能。
这种锁还有个名字,叫细粒度锁。
而我们用两把锁,取款和修改密码是可以并行的。
用不同的锁对受保护资源进行精细化管理,能够提升性能。
这种锁还有个名字,叫细粒度锁。
保护有关联关系的多个资
如果多个资源是有关联关系的,那这个问题就有点复杂了。
。例如银行业务里面的转账操作,账户A减少100元,账户B增加100元。这两个账户就是有关联关系的。
那对于像转账这种有关联关系的操作,我们应该怎么去解决呢?先把这个问题代码化。我们声明了个账户类:
Account,该类有一个成员变量余额:balance,
还有一个用于转账的方法:transfer(),然后怎么保证转账操作transfer()没有并发问题呢?
Account,该类有一个成员变量余额:balance,
还有一个用于转账的方法:transfer(),然后怎么保证转账操作transfer()没有并发问题呢?
相信你的直觉会告诉你这样的解决方案:
用户synchronized关键字修饰一下transfer()方法就可以了,
于是你很快就完成了相关的代码
用户synchronized关键字修饰一下transfer()方法就可以了,
于是你很快就完成了相关的代码
在这段代码中,临界区内有两个资源,分别是转出账户的余额this.balance和转入账户的余额target.balance,
并且用的是一把锁this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。
真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?
并且用的是一把锁this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。
真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?
问题就出在this这把锁上,this这把锁可以保护自己的余额this.balance,却保护不了别人的余额target.balance,
就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
下面我们具体分析一下,假设有A、B、C三个账户,余额都是200元,我们用两个线程分别执行两个转账操作:
账户A转给账户B100元,账户B转给账户C100元,
最后我们期望的结果应该是账户A的余额是100元,账户B的余额是200元,账户C的余额是300元。
账户A转给账户B100元,账户B转给账户C100元,
最后我们期望的结果应该是账户A的余额是100元,账户B的余额是200元,账户C的余额是300元。
我们假设线程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。
同时进入临界区的结果是什么呢?
线程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对象共享一把锁呢?
稍微开动脑筋,你会发现其实方案还挺多的,比如可以让所有对象都持有一个唯一性的对象,这个对象在创建Account时传入。
方案有了,完成代码就简单了。
方案有了,完成代码就简单了。
我们把Account默认构造函数变为private,同时增加一个带Objectlock参数的构造函数,
创建Account对象时,传入相同的lock,这样所有的Account对象都会共享这个lock了。
创建Account对象时,传入相同的lock,这样所有的Account对象都会共享这个lock了。
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建Account对象的时候必须传入同一个对象,
如果创建Account对象时,传入的lock不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。
在真实的项目场景中,创建Account对象的代码很可能分散在多个工程中,传入共享的lock真的很难。
如果创建Account对象时,传入的lock不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。
在真实的项目场景中,创建Account对象的代码很可能分散在多个工程中,传入共享的lock真的很难。
上面的方案缺乏实践的可行性,我们需要更好的方案。
还真有,就是用Account.class作为共享的锁。
Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创建的,
所以我们不用担心它的唯一性。
使用Account.class作为共享的锁,我们就无需在创建Account对象时传入了,代码更简单。
还真有,就是用Account.class作为共享的锁。
Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创建的,
所以我们不用担心它的唯一性。
使用Account.class作为共享的锁,我们就无需在创建Account对象时传入了,代码更简单。
子主题
死锁
向现实世界要答案
现实世界里,账户转账操作是支持并发的,
而且绝对是真正的并行,银行所有的窗口都可以做转账操作。
而且绝对是真正的并行,银行所有的窗口都可以做转账操作。
如果没有信息化,账户的存在形式真的就是一个账本,
而且每个账户都有一个账本,这些账本都统一存放在文件架上。
而且每个账户都有一个账本,这些账本都统一存放在文件架上。
银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。
文件架上恰好有转出账本和转入账本,那就同时拿走;
如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,
同时等着其他柜员把另外一个账本送回来;
同时等着其他柜员把另外一个账本送回来;
转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
编程世界里,其实用两把锁就实现了,转出账本一把,转入账本另一把。
在transfer()方法内部,我们首先尝试锁定转出账户this(先把转出账本拿到手),
然后尝试锁定转入账户target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。
然后尝试锁定转入账户target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。
经过这样的优化后,账户A转账户B和账户C转账户D这两个转账操作就可以并行了。
没有免费的午餐
A中看似很完美,并且也算是将锁用得出神入化了。
相对于用Account.class作为互斥锁,锁定的范围太大,
而我们锁定两个账户范围就小多了,这样的锁叫细粒度锁。
使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
相对于用Account.class作为互斥锁,锁定的范围太大,
而我们锁定两个账户范围就小多了,这样的锁叫细粒度锁。
使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
使用细粒度锁这么简单,有这样的好事,是不是也要付出点什么代价啊?
编写并发程序就需要这样时时刻刻保持谨慎。
的确,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
死锁的一个比较专业的定义是:
一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
如果有客户找柜员张三做个转账业务:账户A转账户B100元,此时另一个客户找柜员李四也做个转账业务:
账户B转账户A100元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本A,李四拿到了账本B。
张三拿到账本A后就等着账本B(账本B已经被李四拿走),
而李四拿到账本B后就等着账本A(账本A已经被张三拿走),他们要等多久呢?
他们会永远等待下去…因为张三不会把账本A送回去,李四也不会把账本B送回去。我们姑且称为死等吧。
账户B转账户A100元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本A,李四拿到了账本B。
张三拿到账本A后就等着账本B(账本B已经被李四拿走),
而李四拿到账本B后就等着账本A(账本A已经被张三拿走),他们要等多久呢?
他们会永远等待下去…因为张三不会把账本A送回去,李四也不会把账本B送回去。我们姑且称为死等吧。
上面转账的代码是怎么发生死锁的呢?
我们假设线程T1执行账户A转账户B的操作,账户A.transfer(账户B);同时线程T2执行账户B转账户A的操作,账户B.transfer(账户A)。
当T1和T2同时执行完①处的代码时,T1获得了账户A的锁(对于T1,this是账户A),而T2获得了账户B的锁(对于T2,this是账户B)。
之后T1和T2在执行②处的代码时,T1试图获取账户B的锁时,发现账户B已经被锁定(被T2锁定),所以T1开始等待;
T2则试图获取账户A的锁时,发现账户A已经被锁定(被T1锁定),所以T2也开始等待。
于是T1和T2会无期限地等待下去,也就是我们所说的死锁了。
我们假设线程T1执行账户A转账户B的操作,账户A.transfer(账户B);同时线程T2执行账户B转账户A的操作,账户B.transfer(账户A)。
当T1和T2同时执行完①处的代码时,T1获得了账户A的锁(对于T1,this是账户A),而T2获得了账户B的锁(对于T2,this是账户B)。
之后T1和T2在执行②处的代码时,T1试图获取账户B的锁时,发现账户B已经被锁定(被T2锁定),所以T1开始等待;
T2则试图获取账户A的锁时,发现账户A已经被锁定(被T1锁定),所以T2也开始等待。
于是T1和T2会无期限地等待下去,也就是我们所说的死锁了。
资源用方形节点表示,线程用圆形节点表示;
资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。
资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。
如何预防死锁
破坏占用且等待条件
从理论上讲,要破坏这个条件,可以一次性申请所有资源。
在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,
另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?
另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?
可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,
也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。
也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。
例如,张三同时申请账本A和B,账本管理员如果发现文件架上只有账本A,
这个时候账本管理员是不会把账本A拿下来给张三的,只有账本A和B都在的时候才会给张三。
这样就保证了“一次性申请所有资源”。
这个时候账本管理员是不会把账本A拿下来给张三的,只有账本A和B都在的时候才会给张三。
这样就保证了“一次性申请所有资源”。
对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java里面的类)来管理这个临界区,
我们就把这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。
我们就把这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。
账户Account类里面持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。
当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;
当转账操作执行完,释放锁之后,我们需通知Allocator同时释放转出账户和转入账户这两个资源。
当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;
当转账操作执行完,释放锁之后,我们需通知Allocator同时释放转出账户和转入账户这两个资源。
破坏不可抢占条件
破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。
原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,
而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
你可能会质疑,“Java作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java在语言层次确实没有解决这个问题,
不过在SDK层面还是解决了的,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。
不过在SDK层面还是解决了的,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。
破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。
这个实现非常简单,我们假设每个账户都有不同的属性id,这个id可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。
比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
“等待-通知”优化循环
完美的就医流程
现实世界中,有着完美等待-通知机制的就医流程
患者先去挂号,然后到就诊门口分诊,等待叫号;
当叫到自己的号时,患者就可以找大夫就诊了;
就诊过程中,大夫可能会让患者去做检查,同时叫下一位患者;
当患者做完检查后,拿检测报告重新分诊,等待叫号;
当大夫再次叫到自己的号时,患者再去找大夫就诊。
不能忽视等待-通知机制的就医流程的一些细节
患者到就诊门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了;
大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的条件没有满足;
患者去做检查,类似于线程进入等待状态;然后大夫叫下一个患者,这个步骤我们在前面的等待-通知机制中忽视了,
这个步骤对应到程序里,本质是线程释放持有的互斥锁;
这个步骤对应到程序里,本质是线程释放持有的互斥锁;
患者做完检查,类似于线程要求的条件已经满足;
患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待-通知机制中也忽视了。
患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待-通知机制中也忽视了。
结论:
线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
用synchronized实现等待-通知机制
在Java语言里,等待-通知机制可以有多种实现方式,比如Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法就能轻松实现。
如何用synchronized实现互斥锁
图中左边有一个等待队列,同一时刻,只允许一个线程进入synchronized保护的临界区(这个临界区可以看作大夫的诊室),
当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。
这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。
这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
如何使用wait()方法实现互斥锁
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java对象的wait()方法就能够满足这种需求。
如上图所示,当调用wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
如何notify()和notifyAll()方法实现互斥锁
线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是Java对象的notify()和notifyAll()方法。
图里为你大致描述了这个过程,当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
为什么说是曾经满足过呢?
因为notify()只能保证在通知时间点,条件是满足的。
而被通知线程的执行时间点和通知的时间点基本上不会重合,
所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点需要格外注意。
而被通知线程的执行时间点和通知的时间点基本上不会重合,
所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点需要格外注意。
还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用wait()时已经释放了)
资源分配器
等待-通知机制需要考虑以下四个要素
互斥锁:上一篇文章我们提到Allocator需要是单例的,所以我们可以用this作为互斥锁。
线程要求的条件:转出账户和转入账户都没有被分配过。
何时等待:线程要求的条件不满足就等待。
何时通知:当有线程释放账户时就通知。
考虑完四要素我们可以使用
利用这种范式可以解决上面提到的条件曾经满足过这个问题。
因为当wait()返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。
范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。
因为当wait()返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。
范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。
尽量使用notifyAll()
我们经常使用notifyAll()来实现通知机制,为什么不使用notify()呢
这二者是有区别的,notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程。
从感觉上来讲,应该是notify()更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。
但那所谓的感觉往往都蕴藏着风险,实际上使用notify()也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
假设我们有资源A、B、C、D,线程1申请到了AB,线程2申请到了CD,
此时线程3申请AB,会进入等待队列(AB分配给线程1,线程3要求的条件不满足),线程4申请CD也会进入等待队列。
此时线程3申请AB,会进入等待队列(AB分配给线程1,线程3要求的条件不满足),线程4申请CD也会进入等待队列。
我们再假设之后线程1归还了资源AB,如果使用notify()来通知等待队列中的线程,有可能被通知的是线程4,
但线程4申请的是CD,所以此时线程4还是会继续等待,而真正该唤醒的线程3就再也没有机会被唤醒了。
但线程4申请的是CD,所以此时线程4还是会继续等待,而真正该唤醒的线程3就再也没有机会被唤醒了。
因此,除非经过深思熟虑,否则尽量使用notifyAll()。
并发编程主要问题
安全性问题
那什么是线程安全呢?
其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。
那如何才能写出线程安全的程序呢?
理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢?
当然不是,其实只有一种情况需要:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。
那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程的安全性了嘛!
有不少技术方案都是基于这个理论的,例如线程本地存储(ThreadLocalStorage,TLS)、不变模式等等,
但是,现实生活中,必须共享会发生变化的数据,这样的应用场景还是很多的。
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,
如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做数据竞争(DataRace)。
如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做数据竞争(DataRace)。
那是不是在访问数据的地方,我们加个锁保护一下就能解决所有的并发问题了呢?显然没有这么简单。
对于上面示例,我们稍作修改,增加两个被synchronized修饰的get()和set()方法,
add10K()方法里面通过get()和set()方法来访问value变量,修改后的代码如下所示。
对于修改后的代码,所有访问共享变量value的地方,我们都增加了互斥锁,此时是不存在数据竞争的。
但很显然修改后的add10K()方法并不是线程安全的。
add10K()方法里面通过get()和set()方法来访问value变量,修改后的代码如下所示。
对于修改后的代码,所有访问共享变量value的地方,我们都增加了互斥锁,此时是不存在数据竞争的。
但很显然修改后的add10K()方法并不是线程安全的。
假设count=0,当两个线程同时执行get()方法时,get()方法会返回相同的值0,两个线程执行get()+1操作,结果都是1,
之后两个线程再将结果1写入了内存。你本来期望的是2,而结果却是1。
之后两个线程再将结果1写入了内存。你本来期望的是2,而结果却是1。
上面的问题有个官方的称呼,叫竞态条件(RaceCondition)。所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序。
例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2。
在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大Bug。
转账操作里面有个判断条件——转出金额不能大于账户余额,但在并发环境里面,
如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。
假设账户A有余额200,线程1和线程2都要从账户A转出150,在下面的代码里,有可能线程1和线程2同时执行到第6行,
这样线程1和线程2都会发现转出金额150小于账户余额200,于是就会发生超额转出的情况。
如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。
假设账户A有余额200,线程1和线程2都要从账户A转出150,在下面的代码里,有可能线程1和线程2同时执行到第6行,
这样线程1和线程2都会发现转出金额150小于账户余额200,于是就会发生超额转出的情况。
所以你也可以按照下面这样来理解竞态条件。
在并发场景中,程序的执行依赖于某个状态变量:
在并发场景中,程序的执行依赖于某个状态变量:
某个线程发现状态变量满足执行条件后,开始执行操作;
可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。
当然很多场景下,这个条件不是显式的,例如前面addOne的例子中,set(get()+1)这个复合操作,其实就隐式依赖get()的结果。
可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。
当然很多场景下,这个条件不是显式的,例如前面addOne的例子中,set(get()+1)这个复合操作,其实就隐式依赖get()的结果。
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?
其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。
从逻辑上来看,我们可以统一归为:锁。
活跃性问题
指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程永久地“阻塞”了。
但有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。
可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,
路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
这种情况,基本上谦让几次就解决了,因为人会交流啊。
可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
这种情况,基本上谦让几次就解决了,因为人会交流啊。
可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。
例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;
同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。
由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。
“等待一个随机时间”的方案虽然很简单,却非常有效,Raft这样知名的分布式一致性算法中也用到了它。
同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。
由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。
“等待一个随机时间”的方案虽然很简单,却非常有效,Raft这样知名的分布式一致性算法中也用到了它。
那“饥饿”该怎么去理解呢?
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。
“不患寡,而患不均”,如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;
持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。
倒是方案二的适用场景相对来说更多一些。
倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢?
在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。
“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?
有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?
公式里的n可以理解为CPU的核数,p可以理解为并行百分比,那(1-p)就是串行百分比了,也就是我们假设的5%。
我们再假设CPU的核数(也就是n)无穷大,那加速比S的极限就是20。
也就是说,如果我们的串行率是5%,那么我们无论采用什么技术,最高也就只能提高20倍的性能。
我们再假设CPU的核数(也就是n)无穷大,那加速比S的极限就是20。
也就是说,如果我们的串行率是5%,那么我们无论采用什么技术,最高也就只能提高20倍的性能。
那怎么才能避免锁带来的性能问题呢?
这个问题很复杂,JavaSDK并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。
方案层面解决这个问题
既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。
例如
线程本地存储(ThreadLocalStorage,TLS)、写入时复制(Copy-on-write)、乐观锁等;
Java并发包里面的原子类也是一种无锁的数据结构;
Disruptor则是一个无锁的内存队列,性能都非常好
线程本地存储(ThreadLocalStorage,TLS)、写入时复制(Copy-on-write)、乐观锁等;
Java并发包里面的原子类也是一种无锁的数据结构;
Disruptor则是一个无锁的内存队列,性能都非常好
减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。
例如
使用细粒度的锁,一个典型的例子就是Java并发包里的ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);
还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
使用细粒度的锁,一个典型的例子就是Java并发包里的ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);
还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
性能方面的度量指标有很多,我觉得有三个指标非常重要
吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是1000的时候,延迟是50毫秒。
管程
什么是管程
为什么Java在1.5之前仅仅提供了synchronized关键字及wait()、notify()、notifyAll()这三个看似从天而降的方法?
我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。
后来我找到了原因:Java采用的是管程技术,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。
而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以Java选择了管程。
管程对应的英文是Monitor,很多Java领域的同学都喜欢将其翻译成“监视器”。操作系统领域一般都翻译成“管程”,这个是意译,而我自己也更倾向于使用“管程”。
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
MESA模型
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型。
现在广泛应用的是MESA模型,并且Java管程的实现参考的也是MESA模型
在并发编程领域,有两大核心问题:
一个是互斥,即同一时刻只允许一个线程访问共享资源;
另一个是同步,即线程之间如何通信、协作。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。
假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:
将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
管程X将共享变量queue这个线程不安全的队列和相关的操作入队操作enq()、出队操作deq()都封装起来了;
线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq()、deq()方法来实现;enq()、deq()保证互斥性,只允许一个线程进入管程。
线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq()、deq()方法来实现;enq()、deq()保证互斥性,只允许一个线程进入管程。
那管程如何解决线程间的同步问题呢?
这个比较复杂不过可以借鉴一下我们曾经提到过的就医流程,它可以帮助你快速地理解这个问题。
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。
框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。
当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。
当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,条件变量A和条件变量B分别都有自己的等待队列。
条件变量和条件变量等待队列的作用是什么呢?
其实就是解决线程同步问题。
如果线程T1进入管程后恰好发现阻塞队列是空的,那怎么办呢?等待啊,去哪里等呢?
就去条件变量对应的等待队列里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。
wait()、notify()、notifyAll()这三个操作
再假设之后另外一个线程T2执行阻塞队列的入队操作,入队操作执行成功之后,
“阻塞队列不空”这个条件对于线程T1来说已经满足了,此时线程T2要通知T1,告诉它需要的条件已经满足了。
当线程T1得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。
这个过程类似你验血完,回来找大夫,需要重新分诊。
“阻塞队列不空”这个条件对于线程T1来说已经满足了,此时线程T2要通知T1,告诉它需要的条件已经满足了。
当线程T1得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。
这个过程类似你验血完,回来找大夫,需要重新分诊。
前面提到线程T1发现“阻塞队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用wait()来实现的。
如果我们用对象A代表“阻塞队列不空”这个条件,那么线程T1需要调用A.wait()。
同理当“阻塞队列不空”这个条件满足时,线程T2需要调用A.notify()来通知A等待队列中的一个线程,此时这个等待队列里面只有线程T1。
同理当“阻塞队列不空”这个条件满足时,线程T2需要调用A.notify()来通知A等待队列中的一个线程,此时这个等待队列里面只有线程T1。
至于notifyAll()这个方法,它可以通知等待队列中的所有线程。
下面的代码用管程实现了一个线程安全的阻塞队列。
阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await();
对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了notEmpty.await();
如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列
如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满notFull对应的等待队列
在这段示例代码中,我们用了Java并发包里面的Lock和Condition,如果你看着吃力,也没关系,
后面我们还会详细介绍,这个例子只是先让你明白条件变量及其等待队列是怎么回事。
后面我们还会详细介绍,这个例子只是先让你明白条件变量及其等待队列是怎么回事。
需要注意的是:await()和前面我们提到的wait()语义是一样的;signal()和前面我们提到的notify()语义是一样的。
wait()的正确姿势
对于MESA管程来说,有一个编程范式,就是需要在一个while循环里面调用wait()。这个是MESA管程特有的
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。
管程要求同一时刻只允许一个线程执行,
那当线程T2的操作使线程T1等待的条件满足时,T1和T2究竟谁可以执行呢?
那当线程T2的操作使线程T1等待的条件满足时,T1和T2究竟谁可以执行呢?
Hasen模型里面:要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
Hoare模型里面:T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2,也能保证同一时刻只有一个线程执行。
但是相比Hasen模型,T2多了一次阻塞唤醒操作。
但是相比Hasen模型,T2多了一次阻塞唤醒操作。
MESA管程里面:T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。
这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。
但是也有个副作用,就是当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。
但是也有个副作用,就是当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
notify()何时可以使用
那什么时候可以使用notify()呢?需要满足以下三个条件:
所有等待线程拥有相同的等待条件;
所有等待线程被唤醒后,执行相同的操作;
只需要唤醒一个线程。
比如上面阻塞队列的例子中,对于“阻塞队列不满”这个条件变量,其等待线程都是在等待“阻塞队列不满”这个条件,反映在代码里就是下面这3行代码。
对所有等待线程来说,都是执行这3行代码,重点是while里面的等待条件是完全相同的。
对所有等待线程来说,都是执行这3行代码,重点是while里面的等待条件是完全相同的。
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
同时也满足第3条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用signal()是可以的。
Java线程
Java线程的生命周期
通用的线程生命周期
用的线程生命周期基本上可以用下图这个“五态模型”来描述
初始状态
指的是线程已经被创建,但是还不允许分配 CPU 执行。
这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,
而在操作系统层面,真正的线程还没有创建。
这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,
而在操作系统层面,真正的线程还没有创建。
可运行状态
指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,
所以可以分配 CPU 执行。当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,
被分配到 CPU 的线程的状态就转换成了运行状态。
所以可以分配 CPU 执行。当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,
被分配到 CPU 的线程的状态就转换成了运行状态。
运行状态
运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),
那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。
那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。
休眠状态
当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
终止状态
线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,
进入终止状态也就意味着线程的生命周期结束了。
进入终止状态也就意味着线程的生命周期结束了。
这五种状态在不同编程语言里会有简化合并
C 语言的 POSIX Threads 规范,就把初始状态和可运行状态合并了
Java 语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,
而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。
而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。
Java 中线程的生命周期
Java 语言中线程共有六种状态
NEW(初始化状态)
RUNNABLE(可运行 / 运行状态)
BLOCKED(阻塞状态)
WAITING(无时限等待)
TIMED_WAITING(有时限等待)
TERMINATED(终止状态)
但其实在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。
Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
1)RUNNABLE 与 BLOCKED 的状态转换
只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。
线程调用阻塞式 API 时,是否会转换到 BLOCKED 状态呢?
在操作系统层面,线程是会转换到休眠状态的,但是在 JVM 层面,
Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。
Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。
JVM层面并不关心操作系统调度相关的状态,比如等待CPU使用权与等待 I/O没有区别,都是在等待某个资源,所以都归入了RUNNABLE状态。
2)RUNNABLE 与 WAITING 的状态转换
第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
wait() 方法上面有描述,这里就不再赘述。
第二种场景,调用无参数的 Thread.join() 方法。
其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,
执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。
当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。
当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
第三种场景,调用 LockSupport.park() 方法。
其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。
调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。
调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。
调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
3)RUNNABLE 与 TIMED_WAITING 的状态转换
调用带超时参数的 Thread.sleep(long millis) 方法;
获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
调用带超时参数的 Thread.join(long millis) 方法;
调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
4)从 NEW 到 RUNNABLE 状态
一种是继承 Thread 对象,重写 run() 方法
另一种是实现 Runnable 接口,重写 run() 方法,并将该实现类作为创建 Thread 对象的参数
NEW 状态的线程,不会被操作系统调度,因此不会执行。Java 线程要执行,就必须转换到 RUNNABLE 状态。
从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法就可以了
从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法就可以了
5)从 RUNNABLE 到 TERMINATED 状态
线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。
有时候我们需要强制中断 run() 方法的执行,例如 run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?
Java 的 Thread 类里面倒是有个 stop() 方法,不过已经标记为 @Deprecated,所以不建议使用了。
正确的姿势其实是调用 interrupt() 方法。
正确的姿势其实是调用 interrupt() 方法。
那stop()和 interrupt()方法的主要区别是什么呢?
stop()方法会真的杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,
被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,
那其他线程就再也没机会获得ReentrantLock锁,这实在是太危险了。
被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,
那其他线程就再也没机会获得ReentrantLock锁,这实在是太危险了。
所以该方法就不建议使用了,类似的方法还有suspend()和resume()方法,
这两个方法同样也都不建议使用了,所以这里也就不多介绍了。
这两个方法同样也都不建议使用了,所以这里也就不多介绍了。
而interrupt()方法就温柔多了,interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。
被interrupt的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
被interrupt的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,
会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。
会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。
上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,
我们看这些方法的签名,发现都会throwsInterruptedException这个异常。
我们看这些方法的签名,发现都会throwsInterruptedException这个异常。
这个异常的触发条件就是:其他线程调用了该线程的interrupt()方法。
当线程A处于RUNNABLE状态时,并且阻塞在java.nio.channels.InterruptibleChannel上时,
如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;
而阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。
如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;
而阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。
上面五个完美回答了下面的问题,BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因。
那具体是哪些情形会导致线程从 RUNNABLE 状态转换到这三种状态呢?
而这三种状态又是何时转换回 RUNNABLE 的呢?
以及 NEW、TERMINATED 和 RUNNABLE 状态是如何转换的?
那具体是哪些情形会导致线程从 RUNNABLE 状态转换到这三种状态呢?
而这三种状态又是何时转换回 RUNNABLE 的呢?
以及 NEW、TERMINATED 和 RUNNABLE 状态是如何转换的?
创建多少线程才是合适的?
多线程的应用场景
创建多少线程合适?
为什么局部变量是线程安全的?
方法是如何被执行的
局部变量存哪里?
调用栈与线程
线程封闭
并发编程
并发基础
Lock和Condition
Semaphore
ReadWriteLock
StampedLock
CountDownLatch和CyclicBarrier
并发容器
原子类
Executor与线程池
Future
CompletableFuture
CompletionService
Fork/Join
0 条评论
下一页