并发问题的由来与解决
- 一、并发包括哪些问题
- 二、并发的原因
- 三、解决并发的可见性和有序性
- 四、互斥锁解决并发的原子性问题
- 五、死锁的解决方案
- 六、宏观角度下的并发问题
- 七、管程
- 八、Java线程
- 九、JUC——JDK并发包
- 十、利用面向对象,写好并发
- 十一、问题记录
- 十二、参考记录
一、并发包括哪些问题
并发编程的问题来源:可见性,原子性,有序性的困难实现
二、并发的原因
1、缓存导致的可见性:
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
缓存最开始是为了解决CPU单核时代时,CPU和内存交互问题,每个CPU都有自己的缓存,但是在多核时代时,CPU自己的缓存之间是具有独立性的,所以数据从缓存到内存之间是隔离的,这也就导致了内存可见性的原因。
单核时代:
多核时代:
2、线程切换带来的原子性问题:
原子性,即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子性就像数据库里面的事务一样,他们是一个团队,同生共死。
在单核时代,应用要是想进行并行运行,是需要CPU对运行任务每隔一定的“时间片”就进行切片处理的,这就导致了多个线程对同一个资源就行修改的时候发生无原子性的问题。
3、编译优化带来的有序性的问题:
有序性,即程序执行的顺序按照代码的先后顺序执行。
//以此单例模式来描述
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()方法并不完美。问题出在哪里呢?出在new操作上,我们以为的new操作应该是:
分配一块内存M;
在内存M上初始化Singleton对象;
然后M的地址赋值给instance变量。
但是实际上优化后的执行路径却是这样的:
分配一块内存M;
将M的地址赋值给instance变量;
最后在内存M上初始化Singleton对象。
优化后会导致什么问题呢?我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现 instance != null ,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
三、解决并发的可见性和有序性
如果要解决可见性和有序性的问题最快的方法就是对缓存和编译的优化进行禁用。java正是通过volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则来实现此目的的
volatile:原语义就是指对于CPU缓存的禁用,1.5之后加强其语义。完全禁用。
final:final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。在1.5以后Java内存模型对final类型变量的重排进行
了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
Happens-Before 规则:指前一个操作对于后一个操作来说是具有可见性的。
1、程序的顺序性规则,同一个线程中,前一个操作是可见于后一个操作的。
2、volatile变量规则:一个用volatile修饰过的变量的写操作是可见于这个变量后续的读操作的
3、可见性的传递性:如果线程A可见于线程B,线程B可见于线程C,那么线程A是可见于线程C的。
4、管程中锁的规则:管程在Java中的实现就是synchronized,它涉及到了加锁以及解锁的操作。如果一个线程
在管程中对一个资源进行了修改,那么后续加锁管程是可以看到前一个管程的修改结果的。
5、线程 start() 规则:如果主线程启动了一个子线程,那么子线程是可以看到自身启动之前主线程的操作的。
6、线程join()规则:这条是关于线程等待的。它是指主线程A等待子线程B完成(主线程A通过调用子线程B的
join()方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的
“看到”,指的是对共享变量的操作。
四、互斥锁解决并发的原子性问题
早期的单核时代,32位系统要处理64位数据(比如long类型)的时候,会分两次分别对同一个资源的高32位和低32位进行处理以加快处理节奏,但是在多核情况下,是使用两条线程来同时处理高低位的。这样就有概率出现,两条线程同时拿到高位资源导致并发问题。
1、synchronized
在java中通过synchronized来实现lock和unlock,并对于资源来进行互斥保护,所为互斥,其实就是指同一时刻,只有一条线程对受保护资源进行保护,也就是独占的意思
synchronized隐式规则:
1、当修饰符为静态时,其加锁对象是class本身
2、当修饰符为非静态时,其加锁对象为this对象
eg:
class X {
// 修饰非静态方法,此方法的加锁对象是new出来的对象,即具体对象
synchronized void foo() {
// 临界区
}
// 修饰静态方法,此方法放入加锁对象为X.class本身
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
//此方法的加锁对象就是obj
synchronized(obj) {
// 临界区
}
}
}
2、synchronized的可见性问题
class X {
private int value;
int get(){
return value;
}
synchronized void addOne(){
value +=1;
}
}
上述代码中,get方法和addOne方法中的代码块是不互斥的,根据管程中的加锁规则,get方法是对于addOne的方法是不可见得,所以为了达到其可见性需要将get方法也使用synchronized进行修饰。这样锁住的资源this.X才可以可见。
3、锁与受保护资源的关系
受保护资源和锁之间的关联关系是N:1的关系
举个栗子:我锁住了我的钱,就不允许别人锁住我的钱。并且我要是锁住了别人的东西,别人也不允许锁住,我就是这么霸道!
class X {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
在以上代码中,由于加锁的对象不一致,因此get和addOne之间没有互斥关系
class X {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}
而在以上代码中,由于锁的对象是不同的Object,所以代码块中的内容也不是互斥的。
class X {
String value;
String ket;
long get() {
synchronized (key) {
return value;
}
}
void addOne() {
synchronized (value) {
key += 1;
}
}
}
以上代码我不懂
4、重新理解synchronized
在以上的文章中,我们既说了锁的对象,又说了synchronized修饰的代码块,那么synchronized到底是什么,是锁的对象,还是锁的代码块?
由于没有源码,我请教了同事,查询了资料,自己总结出来一些关于synchronized的一些内容:
其实synchronized是Java在语言层面提供的互斥原语,Java里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁/解锁,就属于设计层面的事情了,而加锁解锁的地方叫临界区。
这个图片就是在临界区中对LR锁中的资源R进行了操作,而具体的unlock和lock的操作是在java内部进行操作的。
其次,synchronized利用了加锁对象的引用来保证的临界区的原子性,而且并发问题的可见性,他是不能保证的。基于这些我们分析以下内容:
package com.example.demo;
class Demo1ApplicationTests {
public String value = "value";
public String key = "key";
public String getValue() throws InterruptedException {
synchronized (key){
key = "key2";
System.out.println("sleep start");
Thread.sleep(5000);
System.out.println("sleep end");
return value;
}
}
public String getKey(){
synchronized (key){
System.out.println("read start");
return key;
}
}
public static void main(String[] args) {
Demo1ApplicationTests demo = new Demo1ApplicationTests();
new Thread(() -> {
try {
System.out.println(demo.getValue());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
System.out.println(demo.getKey());
}).start();
}
}
getValue进入之后,立马利用加锁对象key对其临界区进行了保护,但是临界区内,将其加锁对象的引用重新赋值,导致了getKey方法也获取到了对象锁,但是两个方法中的锁引用(也就是本质)是完全不一致的,导致两个代码块可以并发进行。
此时,我们只需要将
key = "key2";
注释掉,保证引用一致就可以实现对代码块的加锁(原子性得以实现)。
并且在以上案例中,锁本身(key)依然可以被第二个线程所获取到,也就是说,synchronized是利用对象作为锁,锁住了代码块,保证了代码块不会被其他线程所执行,进而达到了原子性的目的,而锁住代码块的范围就是所使用锁本身的范围(这个会在第五节的实例中说明)。
基于这个操作我们完全可以实现死锁的情况(下一次面试官再问我相关死锁的解释,我就把代码摔他脸上,好看的除外):
public String getValue() throws InterruptedException {
synchronized (key){
System.out.println("sleep start");
Thread.sleep(5000);
System.out.println("sleep end");
synchronized (value) {
return value;
}
}
}
public String getKey(){
synchronized (value){
synchronized (key) {
System.out.println("read start");
return key;
}
}
}
于此,我们也明白,死锁的本质就是锁的竞争
有心者也能发现:万物皆是锁,这也就是为什么wait()方法是在Object里面的,而不是在Thread里面的。这属于Java的一种设计理念。(在此,感谢我善哥<郭吉善>)
5、用同一把锁,保护多个资源
之前,我们讲过受保护资源和锁之间的关联关系是N:1的关系,那么我们就可以使用同一把锁来保护多个资源了。
package com.example.demo;
class FuQi{
private final String LaoPo = "老婆的指令";
public void laoGongDeQian(){
synchronized (LaoPo){
System.out.println("老公的钱老婆随便花");
}
}
public void laoPoDeQian(){
synchronized (LaoPo){
System.out.println("老婆的钱自己随便花");
}
}
}
在夫妻消费过程中,只有获取到了“老婆的指令”,才可以花钱,不管是“laoGongDeQian”还是“laoPoDeQian”;并且,如果老婆(一条线程)在花钱(获取到锁并且执行的过程中)的时候,老公(另一条线程)必须先获取到“老婆的指令”才可以消费(获取锁并且执行),这是规矩。
以上的例子可以说是使用同一个锁来锁定到无关联的资源(因为不公平)。
6、用同一把锁保护多个关联资源
但是在实际过程中,大部分的逻辑是需要使用锁,来锁定相关资源的。比如:
在转账过程中,我们希望转账过程是原子性的,即只有成功和失败两种情况,并且不允许其他线程对转账进行干预。
package com.example.demo;
public class Account{
private int balance;
public Account(){
this.balance = 100;
}
public void transTo(Account target, int amt) {
if (this.balance >= amt) {
target.balance = target.balance + amt;
this.balance = this.balance - amt;
}
}
public static void main(String[] args) {
Account a = new Account();
Account b = new Account();
Account c = new Account();
a.transTo(b, 100);
b.transTo(c, 100);
System.out.println("a:"+a.balance);
System.out.println("b:"+b.balance);
System.out.println("c:"+c.balance);
}
}
我们希望a给b转账100块,b给c转账100块钱。
结果就是
看似没问题,但是在并发过程之中,我们假设线程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。
也就说是本来应该是原子性的操作,由于多线执行,其代码块原子性被破坏了。
package com.example.demo;
/**
* 写的略微复杂,好多可能发生并发的路子已经被java的执行顺序和语法堵死了,但是不重要,这个只能让我们更清晰的明白原子性破坏的过程
*/
public class Account{
private int balance;
public Account(int balance){
this.balance = balance;
}
public void bTransToC(Account target, int amt, Account b) {
if (this.balance >= amt) {
System.out.println("b->c start");
System.out.println("c="+target.balance);
System.out.println("b="+balance);
target.balance = target.balance + amt;
this.balance = this.balance - amt;
System.out.println("b->c end");
System.out.println("This is b="+balance+",time is "+System.currentTimeMillis());
//TODO 类型赋值和基础类型赋值有什么不一样
b.balance = this.balance;
}
}
public void aTransToB(Account target, int amt, Account b) {
//休眠,保证b->c先执行完成,之后a->b的值压掉b->c的值
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (this.balance >= amt) {
System.out.println("a->b start");
System.out.println("b="+target.balance);
System.out.println("a="+balance);
target.balance = target.balance + amt;
this.balance = this.balance - amt;
System.out.println("a->b end");
System.out.println("This is b="+target.balance+",time is "+System.currentTimeMillis());
//TODO 类型赋值和基础类型赋值有什么不一样
b.balance = target.balance;
}
}
public static void main(String[] args) {
Account a = new Account(100);
Account b = new Account(200);
Account c = new Account(300);
new Thread(() -> {
System.out.println("b->c开始");
//新建变量,快速赋值来保证抢占值得效果
Account b1 = new Account(b.balance);
//线程休眠
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
b1.bTransToC(c, 100, b);
}).start();
new Thread(() ->{
System.out.println("a->b开始");
//新建变量,快速赋值来保证抢占值得效果
Account b2 = new Account(b.balance);
a.aTransToB(b2, 100, b);
}).start();
//等待执行结果
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("结果值为:");
System.out.println("a:"+a.balance);
System.out.println("b:"+b.balance);
System.out.println("c:"+c.balance);
}
}
此时,我们需要给transTo函数进行加锁
例如:
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
但是,此时,synchronized的加锁只是给this进行了加锁,这个锁的粒度太小了,只能让我们保证当前执行方法的对象是安全的,不能保证target对象,也就是说诸如上述案例中的情况还是会发生,所以我们需要将整个Accout类加锁,保证转账的安全,因为Accout.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载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;
}
}
}
}
但是这样一来,转账的操作只能允许一个,如果有其他无关转账的两个账户需要转账时,也必须等待之前的账户,大大拖慢了节奏,此时我们可以使用双重锁只使用转账和被转账的两个用户作为锁来锁定转账方法,但是此时一定要注意有死锁的问题。至于死锁的解决我们稍后继续。
package com.example.demo;
public class Account{
private int balance;
public Account(int balance){
this.balance = balance;
}
public void transTo(Account target, int amt) {
synchronized(this) {
synchronized (target) {
if (this.balance >= amt) {
target.balance = target.balance + amt;
this.balance = this.balance - amt;
}
}
}
}
public static void main(String[] args) {
Account a = new Account(100);
Account b = new Account(200);
Account c = new Account(300);
new Thread(() -> {
System.out.println("b->c开始");
b.transTo(c, 100);
}).start();
new Thread(() ->{
System.out.println("a->b开始");
a.transTo(b, 100);
}).start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("结果值为:");
System.out.println("a:"+a.balance);
System.out.println("b:"+b.balance);
System.out.println("c:"+c.balance);
}
}
执行结果:
7、反观原子性的本质
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在32位的机器上写long型变量有中间状态(只写了64位中的32位),在银行转账的操作中也有中间状态(账户A减少了100,账户B还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
其实,不管是面向cpu还是面向业务逻辑,其原子性的本质是一致的。
五、死锁的解决方案
上面的转账的例子中我们提到,当前的程序有可能发生死锁的情况,那如何解决死锁呢?
解决死锁先要深入了解死锁的原理和构成条件:
我理解:所为死锁,指的是两个互斥线程之间需要共同访问多个资源时,相互占有了不同的资源,并且想获取对方的资源时,循环等待导致线程无法执行的问题。
其构成条件有以下四个:
1、互斥
2、占有且等待
3、不可抢占
4、循环等待
了解了构成条件之后,我们只需要破坏其条件,那死锁就可以解决了。
1、不可解决的互斥
互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。
2、破坏占用且等待条件
破坏这个条件常用的方式是申请资源时将所有资源同时申请
package com.example.demo;
import java.util.ArrayList;
import java.util.List;
public class Account{
private ApplyResource applyResource;
private int balance;
public Account(int balance){
this.balance = balance;
this.applyResource = ApplyResource.getInstance();
}
public void transTo(Account target, int amt) {
if (this.balance >= amt) {
while (!applyResource.apply(this, target));
try {
target.balance = target.balance + amt;
this.balance = this.balance - amt;
}finally {
applyResource.clear(this, target);
}
}
}
public static void main(String[] args) {
Account a = new Account(100);
Account b = new Account(200);
Account c = new Account(300);
new Thread(() -> {
System.out.println("b->c开始");
b.transTo(c, 100);
}).start();
new Thread(() ->{
System.out.println("a->b开始");
a.transTo(b, 100);
}).start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("结果值为:");
System.out.println("a:"+a.balance);
System.out.println("b:"+b.balance);
System.out.println("c:"+c.balance);
}
}
class ApplyResource{
private List accountList;
private static volatile ApplyResource instance = null;
private ApplyResource(){
accountList = new ArrayList();
}
public static ApplyResource getInstance(){
if (instance == null){
synchronized (ApplyResource.class) {
return new ApplyResource();
}
}else{
return instance;
}
}
public synchronized boolean apply(Object from, Object to){
if (accountList.contains(from) || accountList.contains(to)){
return false;
}
accountList.add(from);
accountList.add(to);
return true;
}
public synchronized boolean clear(Object from, Object to){
accountList.remove(from);
accountList.remove(to);
return true;
}
}
3、破坏不可抢占条件
破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
你可能会质疑,“Java作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java在语言层次确实没有解决这个问题,不过在SDK层面还是解决了的,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。关于这个话题,咱们后面会详细讲。
4、破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
public class Account {
public static int curId = 0;
public int id;
public int balance;
public Account(int balance) {
this.balance = balance;
this.id = curId + 1;
}
public void transTo(Account target, int amt) {
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 = this.balance - amt;
target.balance = target.balance + amt;
}
}
}
}
public static void main(String[] args) {
Account a = new Account(100);
Account b = new Account(200);
Account c = new Account(300);
new Thread(() -> {
System.out.println("b->c开始");
b.transTo(c, 100);
}).start();
new Thread(() -> {
System.out.println("a->b开始");
a.transTo(b, 100);
}).start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("结果值为:");
System.out.println("a:" + a.balance);
System.out.println("b:" + b.balance);
System.out.println("c:" + c.balance);
}
}
以第二种和第四种方法来看,二者各有优弊,死循环 while(!actr.apply(this, target))相比synchronized(Account.class)不用切换内核态和用户态,但while会增加cpu的负担(如果长时间循环)。
通常情况下,我们会破坏循环等待条件来解决死锁的问题。
5、用“等待-通知”机制优化循环等待
我们在破坏占用且等待条件的时候,会有一个while循环一直询问是否会放掉需要加锁的锁,并且这个线程一直在获取锁,如果可以让线程等待,之后之前的线程释放是统一通知到等待的线程要他们重新争抢所就好了。
而wait(),notifyAll(),notify()正好提供了这样的逻辑
class ApplyResource{
private List accountList;
private static volatile ApplyResource instance = null;
private ApplyResource(){
accountList = new ArrayList();
}
public static ApplyResource getInstance(){
if (instance == null){
synchronized (ApplyResource.class) {
return new ApplyResource();
}
}else{
return instance;
}
}
public synchronized boolean apply(Object from, Object to){
if (accountList.contains(from) || accountList.contains(to)){
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
accountList.add(from);
accountList.add(to);
return true;
}
public synchronized boolean clear(Object from, Object to){
accountList.remove(from);
accountList.remove(to);
notifyAll();
return true;
}
}
需要注意:
1、wait()和notifyAll()必须在synchronized临界区内,否则会报错,这是因为这两个方法本质上是this.notifyAll()和this.wait(),他必须要和synchronized加锁对象保持一致,也就是说线程进入synchronized临界区后,wait()的其实是进入方法体后,想要获取锁的线程,但是锁本质上并不重要,锁是和我们的判断语句关联的。看不懂我们再看第二点注意事项。
2、为什么使用notifyAll(),是为了防止有些线程永远无法被唤醒。
notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程。
假设我们有资源A、B、C、D,
线程1申请到了AB,
线程2申请到了CD,
此时线程3申请AB,会进入等待队列(AB分配给线程1,线程3要求的条件不满足),
线程4申请CD也会进入等待队列。
再假设之后线程1归还了资源AB,如果使用notify()来通知等待队列中的线程,有可能被通知的是线程4,但线程4申请的是CD,所以此时线程4还是会继续等待,而真正该唤醒的线程3就再也没有机会被唤醒了。
这也就是为什么我把第一点注意上标了着重号,因为虚拟机会将进入这个synchronized的所有线程唤醒,但是有些线程需要拿AB,有些线程需要拿CD,而需要拿CD的会再次进入等待,只有拿AB的才不需要等待。最终的结果就是,不满足条件的继续等待,满足条件的进行后续操作。
六、宏观角度下的并发问题
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
1、安全性问题
什么是线程安全?其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。
分析安全性问题的代码一般属于存在共享数据并且该数据会发生变化的代码,通俗地讲就是有多个线程会同时读写同一数据。
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)。而数据竞争的情况分析一般会依赖于竞状条件。竞态条件,指的是程序的执行结果依赖线程执行的顺序。
那面对数据竞争和竞态条件问题都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。从逻辑上来看,我们可以统一归为:锁。也就是我们上面所说。
2、活跃性问题
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
所谓的“活锁”,其实就是线程虽然没有发生阻塞,但仍然会存在执行不下去的情况。举个栗子:路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
而活锁的解决方案也很简单。谦让时,尝试等待一个随机的时间就可以了。
而所谓“饥饿”,指的是线程因无法访问所需资源而无法执行下去的情况。举个栗子:如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;并且持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:
- 一是保证资源充足,
- 二是公平地分配资源,
- 三就是避免持有锁的线程长时间执行。
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
3、性能问题
需要明确,我们使用多线程的最终目的就是为了提升性能问题。过度的使用锁就会提升其性能问题,粗粒度锁也会导致性能问题。
而我们加锁往往会给竞争中的数据加锁,是不可避免的,解决性能问题一般从以下方面思考:
- 根据业务需求的原子性,尽量的细化锁的粒度来提升性能。
- 减少锁的持有时间,即缩小临界区的计算量
- 使用无锁的算法和数据结构,如:线程本地存储(Thread Local Storage, TLS)、写入时复制(Copy-on-write)、乐观锁等;Java并发包里
面的原子类也是一种无锁的数据结构;Disruptor则是一个无锁的内存队列,性能都非常好……
关于性能的计算这个不做过多表述,有一个性能公式,阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以计算性能:
$S=frac{1}{(1-p)+frac{p}{n}}$
性能指标:
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说
的。例如并发量是1000的时候,延迟是50毫秒。
七、管程
管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
这里我们只需要记住:synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型。其中,现在广泛应用的是MESA模型,并且Java管程的实现参考的也是MESA模型。
1、MESA模型
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
①互斥
互斥的实现实际上就是一种对外封装的作法,将不安全的变量和操作这些变量的方法封装起来,对外提供操作方法,并且保证操作方法自身和彼此之间都是互斥的(即保证只有一个线程可以操作变量)
②同步
同步的理解比较复杂,我们在5.5中的代码可以帮助理解,
class ApplyResource{
private List accountList;
private static volatile ApplyResource instance = null;
private ApplyResource(){
accountList = new ArrayList();
}
public static ApplyResource getInstance(){
if (instance == null){
synchronized (ApplyResource.class) {
return new ApplyResource();
}
}else{
return instance;
}
}
public synchronized boolean apply(Object from, Object to){
if (accountList.contains(from) || accountList.contains(to)){
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
accountList.add(from);
accountList.add(to);
return true;
}
public synchronized boolean clear(Object from, Object to){
accountList.remove(from);
accountList.remove(to);
notifyAll();
return true;
}
}
下图中的共享变量可以理解为入口等待队列,而我们说的条件变量其实在synchronized中代表的就是加锁的this,并且wait()方法是缩写的,全称应该是this.wait()。每一个条件变量都会对应一个等待队列。
- T1需要满足条件变量A,进入入口的等待队列后,满足了条件变量A进入执行,
- T2需要满足条件变量A,进入入口的等待队列后,发现条件变量A不满足其进入方法的要求,于是进入了A对应的等待队列wait()
- 此时,
- T1执行完成,释放条件变量A,并通知所有等待线程,T2再次进入入口等待队列,重新判断是否满足条件变量。
- T2满足了条件变量A,执行。
我是这么理解的,所谓的条件变量其实就是指我们队列阻塞与唤醒的原因。后面会有更详细的介绍
2、到底为什么不用notify()
以上面的代码为例,使用notifyAll(),而不用notify(),主要是为了防止唤醒的线程满足accountList.contains(from) || accountList.contains(to),从而浪费一次唤醒机会。
理解了详细的模型,我们就可以更进一步谅解确认notify()使用的条件:
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
3、Java语言内置的管程
Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。这也就是为什么synchronized后面所填写的参数只有一个的原因吧。
八、Java线程
在Java中,并发的执行依赖于多线程,线程是有必要详细说明的点
1、线程的生命周期
老生常谈了,这里只需要注意,Java中的线程状态细化了休眠状态。
并且,这里的休眠状态不是指java的sleep,而是要比sleep的定义更广。
运行状态的线程如果调用一个阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
2、Java线程的生命周期
Java语言中线程共有六种状态,分别是:
- NEW(初始化状态)
- RUNNABLE(可运行/运行状态)
- BLOCKED(阻塞状态)
- WAITING(无时限等待)
- TIMED_WAITING(有时限等待)
- TERMINATED(终止状态)
其中,BLOCKED、WAITING、TIMED_WAITING可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从RUNNABLE状态转换到这三种状态呢?而这三种状态又是何时转换回RUNNABLE的呢?以及NEW、TERMINATED和RUNNABLE状态是如何转换的?
① RUNNABLE与BLOCKED的状态转换
只有一种场景会触发这种转换,就是线程等待synchronized的隐式锁。synchronized修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从RUNNABLE转换到BLOCKED状态。而当等待的线程获得synchronized隐式锁时,就又会从BLOCKED转换到RUNNABLE状态。
如果你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞式API时,是否会转换到BLOCKED状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在JVM层面,Java线程的状态不会发生变化,也就是说Java线程的状态会依然保持RUNNABLE状态。JVM层面并不关心操作系统调度相关的状态,因为在JVM看来,等待CPU使用权(操作系统层面此时处于可执行状态)与等待I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了RUNNABLE状态。
而我们平时所谓的Java在调用阻塞式API时,线程会阻塞,指的是操作系统线程的状态,并不是Java线程的状态。
② RUNNABLE与WAITING的状态转换
总体来说,有三种场景会触发这种转换。
第一种场景,获得synchronized隐式锁的线程,调用无参数的Object.wait()方法。其中,wait()方法我们在上一篇讲解管程的时候已经深入介绍过了,这里就不再赘述。
第二种场景,调用无参数的Thread.join()方法。其中的join()是一种线程同步方法,例如有一个线程对象thread A,当调用A.join()的时候,执行这条语句的线程会等待thread A执行完,而等待中的这个线程,其状态会从RUNNABLE转换到WAITING。当线程thread A执行完,原来等待它的线程又会从WAITING状态转换到RUNNABLE。
第三种场景,调用LockSupport.park()方法。其中的LockSupport对象,也许你有点陌生,其实Java并发包中的锁,都是基于它实现的。调用LockSupport.park()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpark(Thread thread)可唤醒目标线程,目标线程的状态又会从WAITING状态转换到RUNNABLE。
③ 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)方法。
这里你会发现TIMED_WAITING和WAITING状态的区别,仅仅是触发条件多了超时参数。
④ 从NEW到RUNNABLE状态
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();
⑤ 从RUNNABLE到TERMINATED状态
线程执行完 run() 方法后,会自动转换到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如 run()方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java的Thread类里面倒是有个stop()方法,不过已经标记为@Deprecated,所以不建议使用了。正确的姿势其实是调用interrupt()方法。
那stop()和interrupt()方法的主要区别是什么呢?
stop()方法会真的杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,那其他线程就再也没机会获得ReentrantLock锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有suspend() 和 resume()方法,这两个方法同样也都不建议使用了,所以这里也就不多介绍了。
而interrupt()方法就温柔多了,interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被interrupt的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,我们看这些方法的签名,发现都会throws InterruptedException这个异常。这个异常的触发条件就是:其他线程调用了该线程的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会立即返回。
上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于RUNNABLE状态,并且没有阻塞在某个I/O操作上,例如中断计算圆周率的线程A,这时就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法,那么线程A可以通过isInterrupted()方法,检测是不是自己被中断了。
3、线程数的创建和什么有关
多线程的使用最开始的(单核时代)作用其实是为了平衡CPU设备和IO设备。初衷是为了在IO设备执行过程中,将可以继续执行CPU计算。但在多核时代中,其最主要的作用是**通过增加线程,来提高CPU和IO的利用率。**这是除了优化算法之外唯一可以提升系统性能(降低延迟,提高吞吐量)的方法。
那么创建多线程的目的知道了,那我们就应该可以知道,线程的创建应该和CPU与IO有关。
单核情况下,不管是IO密集型计算场景,还是CPU密集型计算场景,我们都可以使用以下公式来计算线程创建数:
最佳线程数 =1 +(I/O 耗时 / CPU 耗时) |
---|
多核时代下,只需要等比扩大就好了:
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)] |
---|
如果想要简单的测试IO耗时以及CPU耗时可以使用APM来实验。
4、局部变量是如何保证数据安全的?
众所周知,局部变量是线程安全的。那他为什么会安全?
这个理论上和JVM的执行逻辑以及他的内存模型有关。
其实我们每有一个线程被创建时,都会随线程生成一个Java方法调用栈,而每一个方法调用栈的信息均为线程自己独立拥有,而我们的局部变量就是存放在调用栈的方法下的。
假设我们有一条线程需要调用A方法,而A又需要调用B,B又需要调用C。
当A方法被调用时,JVM会将A方法所需要的参数,局部变量,返回地址等均存储在A方法的栈帧下(不会放置具体的方法执行命令,而具体的方法执行命令需要根据方法地址去寻找),BC方法同理,每当有一个方法执行时,CPU首先会根据方法的地址寻找到方法进行执行,执行完毕后,会返回返回地址,将方法移出方法栈,继续下一条地址的执行。
因为每一条线程都有自己独立的方法栈,而每一个方法栈中的变量都是独享的,所以没有共享就没有伤害。
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
九、JUC——JDK并发包
大部分时候,我们不会再次制造并发的轮子来解决问题,而是使用JDK自带的并发包。因为,制造一套成熟而稳定的并发API不是那么容易的事情。至少我不敢说我会,我是垃圾。
1、Lock和Condition
Java SDK并发包通过Lock和Condition两个接口来实现管程,其中Lock用于解决互斥问题,Condition用于解决同步问题。
我们前文说到,synchronized其实就是管程的一种实现,那么为什么JUC要再次利用Lock和Condition来重新实现管程呢?
那要从他们二者的区别开始说。
①Lock
我们前面在讨论死锁问题的时候,提出了一个破坏不可抢占条件方案,但是这个方案synchronized没有办法解决。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。但我们希望的是:对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
Lock提供了三种解决方案,来破坏不可抢占条件:
- 能够响应中断。lockInterruptibly(),通知之前的锁释放资源。 synchronized的问题是,持有锁A后,如果尝试获取锁B失败,那么
线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我
们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。 - 支持超时。tryLock(long time, TimeUnit unit)
。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个
错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。这也就是我们之前所述的“活锁”的解决方案。 - **非阻塞地获取锁。tryLock()。**如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有
锁。这样也能破坏不可抢占条件。这是我们之前所述的“活锁”的形成。
在Lock提供原子性方案的同时,他也具备了可见性的特性。我们之前说,可见性是用Happens-Before规则来保证的。这里其实是使用了三条可见性规则,具体的我们可能需要源码分析(以ReentrantLock为例,最好打开jdk对照)。Lock自建了一个内部类Sync来实现管程。他利用了Sync父类的内部变量private volatile int state来记录管程的状态。在Lock获取锁的时候,会读写state的值;解锁的时候,也会读写state的值(简化后的代码如下面所示)。也就是说,在执行临界区代码之前,程序先读写了一次volatile变量state,在执行临界区代码之后,又读写了一次volatile变量state。Happens-Before规则使用的顺序如下:顺序性规则:-> volatile变量规则 -> 传递性规则(将前两者可见代码块连接起来)
ReentrantLock,既然我们了解到了ReentrantLock,就顺便学习,ReentrantLock指的是可重入锁,所谓可重入锁,顾名思义,指的是线程可以重复获取同一把锁。但是需要注意的是,我们所说的线程指的是同一线程,如果不同线程可以获取到那和堆内存又有什么区别呢。其次我们也要知道,可重入锁需要手动进行加锁和释放锁,并且二者的次数应该是一致的。这个就是private volatile int state的作用。
应该注意到,ReentrantLock的构造方法中有两个构造函数
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair){
sync = fair ? new FairSync()
: new NonfairSync();
}
前者属于非公平锁,后者会根据判断来生成对应的锁。
如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
② Condition
之前我们讨论synchronized时提到,synchronized管程会有一个共享变量和一个条件变量,那如果有多个条件变量怎么办啊?
在由Lock和Condition搭建的管程中解决了这个问题,Condition就是Lock中的条件变量。举个栗子:
我们想实现一个阻塞队列,即当不满足入队或出队条件就让线程阻塞的条件
public class BlockedQueue{
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();
}
// 省略入队操作...
//入队后,通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
代码中就含有两个条件变量,分别是notFull(队列不满)和notEmpty(队列不空)。
当有元素需要入队时,我们会判断队列是否已满,如果队列已满,那么我们就会将队列不满的条件阻塞一下,只有当有元素进行出队操作是,我们才会唤醒notFull下等待队列的线程让他继续执行。
同理,当有元素需要出队时,我们去判断队列是否为空,如果队列已经空了,那么我们会将线程阻塞,知道入队时,将该条件变量下等待队列中的线程唤醒。
要知道Lock和Condition实现的管程,线程等待和通知需要调用await()、signal()、signalAll(),它们的语义和wait()、notify()、notifyAll()是相同的。但是不一样的是,Lock&Condition实现的管程里只能使用前面的await()、signal()、signalAll(),而后面的wait()、notify()、notifyAll()只有在synchronized实现的管程里才能使用。如果一不小心在Lock&Condition实现的管程里调用了wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。
那为什么不使用signalAll()来唤醒所有等待的线程呢,这是因为,我们的条件变量和while的判断是一一对应的,我们之前使用notifyAll()来唤醒所有线程是因为其while判断条件和条件变量是一个多对一的关系。
2、Semaphore
Semaphore,现在普遍翻译为“信号量”,以前也曾被翻译成“信号灯”,因为类似现实生活里的红绿灯,车辆能不能通行,要看是不是绿灯。同样,在编程世界里,线程能不能执行,也要看信号量是不是允许。
①信号量模型
信号量模型里面主要有三个方法,一个计数器,和一个等待队列。
三个方法分别为init()、down()和up(),这三个方法都是原子性的,并且这个原子性是由信号量模型的实现方保证的。在Java SDK里面,信号量模型是由java.util.concurrent.Semaphore实现的,Semaphore这个类能够保证这三个方法都是原子操作。
- init():设置计数器的初始值。
- down():计数器的值减1;如果此时计数器的值小于0,则当前线程将被阻塞,否则当前线程可以继续执行。
- up():计数器的值加1;如果此时计数器的值小于或者等于0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。
②Semaphore的互斥是如何保证的
假设两个线程T1和T2同时访问addOne()方法,当它们同时调用acquire()的时候,由于acquire()是一个原子操作,所以只能有一个线程(假设T1)把信号量里的计数器减为0,另外一个线程(T2)则是将计数器减为-1。对于线程T1,信号量里面的计数器的值是0,大于等于0,所以线程T1会继续执行;对于线程T2,信号量里面的计数器的值是-1,小于0,按照信号量模型里对down()操作的描述,线程T2将被阻塞。所以此时只有线程T1会进入临界区执行count+=1;。
当线程T1执行release()操作,也就是up()操作的时候,信号量里计数器的值是-1,加1之后的值是0,小于等于0,按照信号量模型里对up()操作的描述,此时等待队列中的T2将会被唤醒。于是T2在T1执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。
③应用
为了巩固,试写一个对象池的限流器。
所谓对象池呢,指的是一次性创建出N个对象,之后所有的线程重复利用这N个对象,当然对象在被释放前,也是不允许其他线程使用的。对象池,可以用List保存实例对象,这个很简单。但关键是限流器的设计,这里的限流,指的是不允许多于N个线程同时进入临界区。那如何快速实现一个这样的限流器呢?这种场景,我立刻就想到了信号量的解决方案。
信号量的计数器,在上面的例子中,我们设置成了1,这个1表示只允许一个线程进入临界区,但如果我们把计数器的值设置成对象池里对象的个数N,就能完美解决对象池的限流问题了。下面就是对象池的示例代码。
package com.example.demo;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.Semaphore;
import java.util.function.Function;
public class Account {
public static void main(String[] args) throws InterruptedException {
// 创建对象池
ObjPool pool = new ObjPool(10, 2);
// 通过对象池获取t,之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
}
}
class ObjPool<T, R> {
//池子
final List<T> pool;
// 用信号量实现限流器
final Semaphore sem;
// 构造函数
ObjPool(int size, T t) {
pool = new Vector<>();
for (int i = 0; i < size; i++) {
pool.add(t);
}
sem = new Semaphore(size);
}
// 利用对象池的对象,调用func
R exec(Function<T, R> func) throws InterruptedException {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
}
我们用一个List来保存对象实例,用Semaphore实现限流器。关键的代码是ObjPool里面的exec()方法,这个方法里面实现了限流的功能。在这个方法里面,我们首先调用acquire()方法(与之匹配的是在finally里面调用release()方法),假设对象池的大小是10,信号量的计数器初始化为10,那么前10个线程调用acquire()方法,都能继续执行,相当于通过了信号灯,而其他线程则会阻塞在acquire()方法上。对于通过信号灯的线程,我们为每个线程分配了一个对象 t(这个分配工作是通过pool.remove(0)实现的),分配完之后会执行一个回调函数func,而函数的参数正是前面分配的对象 t ;执行完回调函数之后,它们就会释放对象(这个释放工作是通过pool.add(t)实现的),同时调用release()方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于0,那么说明有线程在等待,此时会自动唤醒等待的线程。
简言之,使用信号量,我们可以轻松地实现一个限流器,使用起来还是非常简单的。
④Semaphore与Lock的区别
参考:并发编程中:Semaphore信号量与lock的区别
Semaphore 可以允许多个线程访问一个临界区。
在Lock中,我们的临界区是lock()与unlock()之间的代码块,但是在Semaphore中我们的临界区其实可以算是acquire()与release()之间的内容,由于Java规定了lock()与unlock()是一对一的,而acquire()与release()不需要,所以,我们认为Semaphore 可以允许多个线程访问一个临界区。只不过,线程是有个数的;满了就只能阻塞了。
3、ReadWriteLock(ReentrantReadWriteLock)
①读写锁的定义与理解
读写锁,并不是Java特有的,而是一个广为通用的技术。符合以下三点特征的,一般就叫读写锁。
- 允许多个线程同时读取共享变量
- 只允许一个线程写共享变量(操作)
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的不同在于,读锁允许多个线程共同获取,但是写锁是不允许的,是互斥的。所以在读多写少的情况下,读写锁的效率是高于互斥锁的。
②读写锁的升级降级
某些情况下,我们会先获取到读锁,在获取写锁,这种情况被叫做锁的升级。但是结合我们对读写锁的理解,我们会轻易发现读写锁是不支持锁的升级的。这是因为读锁还没有释放,此时获取写锁,会导致写锁永久等待,因为写锁要等待之前的读锁释放才能确认不会有并发问题,最终导致相关线程都被阻塞,永远也没有机会被唤醒。**不过,虽然锁的升级是不允许的,但是锁的降级却是允许的。**也就是说,我们可以先获取写锁,之后再获取读锁。
③其他性质
- 读写锁类似于ReentrantLock,也支持公平模式和非公平模式。
- 只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用newCondition()会抛出UnsupportedOperationException异常。
④Demo
以ReentrantReadWriteLock.java中所举的例子,实现一个缓存机制,只允许共同读取,但不允许共同操作:
class Cache<K, V> {
final Map m =
new HashMap<K, V>();
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
final Lock r = rwl.readLock();
final Lock w = rwl.writeLock();
V get(K key) {
V v = null;
//读缓存
r.lock();
try {
v = (V) m.get(key);
} finally {
r.unlock();
}
//缓存中存在,返回
if (v != null) {
return v;
}
//缓存中不存在,查询数据库
w.lock();
try {
//再次验证
//其他线程可能已经查询过数据库
v = (V) m.get(key);
if (v == null) {
//查询数据库
v = getDB(key);
m.put(key, v);
}
} finally {
w.unlock();
}
return v;
}
/**
* 从数据库获取结果值
*/
private V getDB(K key) {
//查询数据库
return (V)new Object();
}
V put(K key, V value) {
w.lock();
try {
return (V) m.put(key, value);
} finally {
w.unlock();
}
}
}
4、StampedLock
除了读写锁之外,还有一种更快的锁方式,叫乐观锁。
StampedLock除了具有读写锁的能力就还具备乐观锁。由于乐观锁的性质不包含锁的概念,允许多个线程同时访问,不限制线程进入临界区的个数,所以他的性能是优于普通的读写锁的。
①StampedLock支持的三种锁模式
- 读写锁模式,这种模式和ReadWriteLock是一致的,但是其不支持条件变量和可重入,这里不过多表述,是实例代码使用。
class StampedLockDemo {
final StampedLock sl =
new StampedLock();
public void demoRead() {
// 获取/释放悲观读锁示意代码
long stamp = sl.readLock();
try {
//省略业务相关代码
int i = 0;
} finally {
sl.unlockRead(stamp);
}
}
public void demoWrite() {
// 获取/释放写锁示意代码
long stamp = sl.writeLock();
try {
//省略业务相关代码
} finally {
sl.unlockWrite(stamp);
}
}
}
- 乐观锁
首先我们要知道,乐观锁是无锁的。正是因为他没有锁的限制,所以他的性能会优于读写锁。
那乐观锁是如何控制共享变量的安全的?
最开始解除到乐观锁是2020年在Mysql中进行的,我们利用字段表示其版本来控制变量安全。有点类似Mysql中的MVCC,但是又不全是
我建议将二者结合起来理解会比较好。
一个乐观锁会将数据version查询前校验,之后规律性的变更version(比如:version++),之后提交前在次校验,保证其数据在操作过程
只会有一个线程修改。
StampedLock的乐观锁通过tryOptimisticRead生成版本号,通过validate()校验版本号,如果校验不通过,再将其升级为悲观锁。
下面是一段以上描述的相关代码(计算一个点到远点的距离):
class Point {
private int x, y;
final StampedLock sl =
new StampedLock();
//计算到原点的距离
double distanceFromOrigin() {
// 乐观读
long stamp =
sl.tryOptimisticRead();
// 读入局部变量,
// 读的过程数据可能被修改
int curX = x, curY = y;
//判断执行读操作期间,
//是否存在写操作,如果存在,
//则sl.validate返回false
if (!sl.validate(stamp)){
// 升级为悲观读锁
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(
curX * curX + curY * curY);
}
}
②StampedLock的其他性质
- StampedLock不支持重入
- StampedLock不支持条件变量
- 如果线程阻塞在StampedLock的readLock()或者writeLock()上时,此时调用该阻塞线程的interrupt()方法,会导致CPU飙升。所以,使用StampedLock一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()。
final StampedLock lock
= new StampedLock();
Thread T1 = new Thread(()->{
// 获取写锁
lock.writeLock();
// 永远阻塞在此处,不释放写锁
LockSupport.park();
});
T1.start();
// 保证T1获取写锁
Thread.sleep(100);
Thread T2 = new Thread(()->
//阻塞在悲观读锁
lock.readLock()
);
T2.start();
// 保证T2阻塞在读锁
Thread.sleep(100);
//中断线程T2
//会导致线程T2所在CPU飙升
T2.interrupt();
T2.join();
5、CountDownLatch和CyclicBarrier
CountDownLatch和CyclicBarrier均是利用计数器效果用来保持线程步调一致的,也就是同步情况,这和我们前面所说限流器有相似之处可以顺便复习。这两个工具类用法的区别在于:CountDownLatch主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;而CyclicBarrier是一组线程之间互相等待,更像是几个驴友之间不离不弃。除此之外CountDownLatch的计数器是不能循环利用的,也就是说一旦计数器减到0,再有线程调用await(),该线程会直接通过。但CyclicBarrier的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到0会自动重置到你设置的初始值。除此之外,CyclicBarrier还可以设置回调函数,可以说是功能丰富。
①CountDownLatch
CountDownLatch的使用在上面的介绍中很好理解。举个代码例子:
public class CountDownLatchDemo{
CountDownLatch latch = new CountDownLatch(2);
public static void main(String[] args){
new Thread(() -> {
//T1 执行异步任务
/**
* 执行中
*/
//执行完成将latch减1
latch.countDown();
}).start();
new Thread(() -> {
//T2 执行异步任务
/**
* 执行中
*/
//执行完成将latch减1
latch.countDown();
}).start();
//T1、T2执行完成前阻塞主线程
latch.await();
//T1、T2执行完成后开始执行之后的任务
/**
* 执行接下来的任务
*/
}
}
如上面的实例中所写,我们设置计数器为2,当T1执行完成,则会将计数器减一,当T2执行完成,则再次减一,此时计算器为0。latch.await()不再阻塞,执行之后的操作。
②CyclicBarrier
CyclicBarrier相比将CountDownLatch而言,他是一个环状的计数器。每当减为0之后会重置。它等待之后的操作,更多地是运行在另一个异步线程中的,而不是运行在主线程中的。
public class CyclicBarrierDemo{
Vector v1 = new Vector();
Vector v2 = new Vector();
final CyclicBarrier barrier = new CyclicBarrier(2, ()->{
new Thread (()->check()).start();
});
public static void main(String[] args){
new Thread(() -> {
while(true){
//T1 执行异步任务
/**
* 执行中
*/
v1.add("异步T1中获取到的数据");
//执行完成将barrier减1
barrier.await();
}
}).start();
new Thread(() -> {
while(true){
//T2 执行异步任务
/**
* 执行中
*/
//执行完成将barrier减1
v2.add("异步T2中获取到的数据");
barrier.await();
}
}).start();
//T1、T2执行完成后开始执行之后的任务
/**
* 执行接下来的任务
*/
}
//T3线程
public void check(){
/**
* 与T1、T2异步执行其他的操作
*/
v1.addAll(v2);
}
}
如上面的实例中所写如上面,T1与T2不断循环获取参数,获取到一组就去T3执行任务,如果任务有限制,则规定好while中的循环次数。
6、并发容器
Java中的容器主要可以分为四个大类,分别是List、Map、Set和Queue,但并不是所有的Java容器都是线程安全的。
①同步容器
在java1.5版本之前,所有的并发容器都是采用同步方式,即将容器内所有的方法均实行串行执行,利用synchronized对容器内所有的方法进行加锁。比如:
SafeArrayList{
//封装ArrayList
List c = new ArrayList<>();
//控制访问路径
synchronized
T get(int idx){
return c.get(idx);
}
synchronized
void add(int idx, T t) {
c.add(idx, t);
}
synchronized
boolean addIfNotExist(T t){
if(!c.contains(t)) {
c.add(t);
return true;
}
return false;
}
}
其实,JDK的开发员在进行开发时已经想到了这一点,我们可以利用Collections来获取容器的同步容器。
List list = Collections.
synchronizedList(new ArrayList());
Set set = Collections.
synchronizedSet(new HashSet());
Map map = Collections.
synchronizedMap(new HashMap());
但是,同步容器使用时会发现,其伴随有严重的性能问题。毕竟将增删改查所有的操作均进行串行是一件很耗费性能的事情。
②并发容器
在1.5之后,介于同步容器的性能问题,JDK开发员提出了并发容器的API。
List并发容器
CopyOnWriteArrayList,当操作元素的时候会进行复制,赋值操作之后再将,复制之后的地址指向原地址。
具体的操作如下图:
每一个CopyOnWriteArrayList中都维系了一个数组array,当我们读取元素时,就在绿色的部分读取,当我们进行修改时,复制一份绿色的元素来进行修改,之后再将array的指针指向新的蓝色数组。
如此,CopyOnWriteArrayList理论上会有两点缺点:
- 场景缺陷:如果使用它,那场景必然是一个读多写少并且可以要求短暂数据不一致的场景。
- 只读缺陷:由于绿的部分永远只是快照,所以我们在迭代器中进行操作时,永远只能查询,增加和修改以及删除在迭代器中是无意义的。
Map并发容器
Map接口的两个实现是ConcurrentHashMap和ConcurrentSkipListMap,主要区别在于ConcurrentHashMap的key是无序的,而ConcurrentSkipListMap的key是有序的。并且ConcurrentSkipListMap使用的是调表结构,而ConcurrentHashMap和HashMap的结构是一致的,所以,在高并发情况下ConcurrentSkipListMap会比ConcurrentHashMap要更快。这是因为:
- skiplist的复杂度和红黑树一样,而且实现起来更简单。
- 在并发环境下skiplist有另外一个优势,红黑树在插入和删除的时候可能需要做一些rebalance的操作,这样的操作可能会涉及到整个树的其他部分,而skiplist的操作显然更加局部性一些,锁需要盯住的节点更少,因此在这样的情况下性能好一些。
另外关于map家族的key-value是否为空也做复习
Set并发容器
Set接口的两个实现是CopyOnWriteArraySet和ConcurrentSkipListSet,他的具体实现和前面所说类似,可以对照查看。
Queue并发容器
我们在实际编写过程中,其实Queue用的完全没有以上容器频繁,偏偏Queue的并发容器反而是最复杂的。当然如果你确信自己不会遇到此类问题,可以跳过。
Java并发包里面Queue这类并发容器是最复杂的,你可以从以下两个维度来分类。
一个维度是阻塞与非阻塞,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。
另一个维度是单端与双端,单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。Java并发包里阻塞队列都用Blocking关键字标识,单端队列使用Queue标识,双端队列使用Deque标识。
- 单端阻塞队列:其实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue和DelayQueue。内部一般会持有一个队列,这个队列可以是数组(其实现是ArrayBlockingQueue)也可以是链表(其实现是LinkedBlockingQueue);甚至还可以不持有队列(其实现是SynchronousQueue),此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合LinkedBlockingQueue和SynchronousQueue的功能,性能比LinkedBlockingQueue更好;PriorityBlockingQueue支持按照优先级出队;DelayQueue支持延时出队。
- 双端阻塞队列:其实现是LinkedBlockingDeque。
- 单端非阻塞队列:其实现是ConcurrentLinkedQueue。
- 双端非阻塞队列:其实现是ConcurrentLinkedDeque。
另外,使用队列时,需要格外注意队列是否支持有界(所谓有界指的是内部的队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致OOM。上面我们提到的这些Queue中,只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的,所以在使用其他无界队列时,一定要充分考虑是否存在导致OOM的隐患。
7、原子类
Java中的原子类型是可以保证变量的可见性和原子性的。并且它是一种无锁的结构。也就是说,他的性能是要高于管程。
那原子类是如何保证其原子性与可见性的呢?
首先原子类通过volatile来实现了变量的可见性。
其次原子类通过实现CAS(比较并交换)指令来实现其原子性。
①CAS与自旋
CAS:CAS指令包含3个参数:共享变量的内存地址A、用于比较的值B和共享变量的新值C;并且只有当内存中地址A处的值等于B时,才能将内存中地址A处的值更新为新值C。作为一条CPU指令,CAS指令本身是能够保证原子性的。我们通过代码来讨论CAS的实现:
class SimulatedCAS{
int count;
synchronized int cas(
int expect, int newValue){
// 读目前count的值
int curValue = count;
// 比较目前count值是否==期望值
if(curValue == expect){
// 如果是,则更新count的值
count = newValue;
}
// 返回写入前的值
return curValue;
}
}
通过上面的代码我们很容易看出,CAS先是有一个预期值,也就是并未修改的值,当需要修改时,将预期值与目前的值相比较,比较相同时再将新的值赋值给当前的值。这样就完成了修改。
在这个更新过程中,会产生两个问题
- 如果预期值不一致怎么办?
java给出的解决办法自旋。使用CAS来解决并发问题,一般都会伴随着自旋,而所谓自旋,其实就是循环尝试。例如,实现一个线程安全的count += 1操作,“CAS+自旋”的实现方案如下所示,首先计算newValue = count+1,如果cas(count,newValue)返回的值不等于count,则意味着线程在执行完代码①处之后,执行代码②处之前,count的值被其他线程更新过。那此时该怎么处理呢?可以采用自旋方案,就像下面代码中展示的,可以重新读count最新的值来计算newValue并尝试再次更新,直到成功。
class SimulatedCAS{
volatile int count;
// 实现count+=1
addOne(){
do {
newValue = count+1; //①
}while(count !=
cas(count,newValue) //②
}
// 模拟实现CAS,仅用来帮助理解
synchronized int cas(
int expect, int newValue){
// 读目前count的值
int curValue = count;
// 比较目前count值是否==期望值
if(curValue == expect){
// 如果是,则更新count的值
count= newValue;
}
// 返回写入前的值
return curValue;
}
}
- ABA问题?
什么叫ABA问题,以上面的代码为例。如果cas(count,newValue)返回的值不等于count,意味着线程在执行完代码①处之后,执行代码②处之前,count的值被其他线程更新过”,那如果cas(count,newValue)返回的值等于count,是否就能够认为count的值没有被其他线程更新过呢?
显然不是的,假设count原本是A,线程T1在执行完代码①处之后,执行代码②处之前,有可能count被线程T2更新成了B,之后又被T3更新回了A,这样线程T1虽然看到的一直是A,但是其实已经被其他线程更新过了,这就是ABA问题。
由于我们使用的是基础类型,所以ABA问题看起来可能会发生,但是不会影响我们的操作,但是如果我们使用了引用类型,引用类型中的属性值发生变化,情况就不像我们理解的那样了,需要注意。解决ABA的问题其实就是我们之前所说的乐观锁机制,只需要在赋值之前再check一下就可以了。既然引用无法保证引用类型的唯一性,那就自己定义一个基础变量来实现。(为什么基础变量可以实现需要了解虚拟机,我只是大概了解,具体的之后我有兴趣研究虚拟机的时候会带上)
②Java中的原子类实现
java中的原子类实现(以AtomicLong.getAndIncrement()为例)的核心方法其实是unsafe.getAndAddLong()方法。
public final long getAndAddLong(
Object o, long offset, long delta){
long v;
do {
// 读取内存中的值
v = getLongVolatile(o, offset);
} while (!compareAndSwapLong(
o, offset, v, v + delta));
return v;
}
//原子性地将变量更新为x
//条件是内存中的值等于expected
//更新成功则返回true
native boolean compareAndSwapLong(
Object o, long offset,
long expected,
long x);
该方法首先会在内存中读取共享变量的值,之后循环调用compareAndSwapLong()方法来尝试设置共享变量的值,直到成功为止。compareAndSwapLong()是一个native方法,只有当内存中共享变量的值等于expected时,才会将共享变量的值更新为x,并且返回true;否则返回fasle。compareAndSwapLong的语义和CAS指令的语义的差别仅仅是返回值不同而已。
getAndIncrement就是利用了CAS和自旋的原理。
③Java原子类概览
这里着重说一些用起来问出问题的原子类型。
- 原子引用类型
类型名称 | 原理 |
---|---|
AtomicReference | 提供的方法和原子化的基本数据类型差不多 |
AtomicStampedReference | 实现的CAS方法就增加了版本号参数 |
AtomicMarkableReference | 实现机制则更简单,将版本号简化成了一个Boolean值 |
- 原子化对象属性更新器
相关实现有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater,利用它们可以原子化地更新对象的属性,这三个方法都是利用反射机制实现的。
需要注意的是,对象属性必须是volatile类型的,只有这样才能保证可见性;如果对象属性不是volatile类型的,newUpdater()方法会抛出IllegalArgumentException这个运行时异常。
你会发现newUpdater()的方法参数只有类的信息,没有对象的引用,而更新对象的属性,一定需要对象的引用,那这个参数是在哪里传入的呢?是在原子操作的方法参数中传入的。例如compareAndSet()这个原子操作,相比原子化的基本数据类型多了一个对象引用obj。原子化对象属性更新器相关的方法,相比原子化的基本数据类型仅仅是多了对象引用参数,所以这里也不再赘述了。 - 原子化累加器
DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder,这四个类仅仅用来执行累加操作,相比原子化的基本数据类型,速度更快,但是不支持compareAndSet()方法。如果你仅仅需要累加操作,使用原子化的累加器性能会更好。
8、Executor与线程池
①线程池的原理
线程是一个重量级的对象,应该避免频繁创建和销毁,所以我们一般会使用线程池。
目前业界线程池的设计,普遍采用的都是生产者-消费者模式。线程池的使用方是生产者,线程池本身是消费者。以下是一个简单实现的线程池。
//简化的线程池,仅用来说明工作原理
class MyThreadPool {
//利用阻塞队列实现生产者-消费者模式
BlockingQueue<Runnable> workQueue;
//保存内部工作线程
List<WorkerThread> threads = new ArrayList<>();
// 构造方法
MyThreadPool(int poolSize, BlockingQueue<Runnable> workQueue) {
this.workQueue = workQueue;
// 创建工作线程
for (int idx = 0; idx < poolSize; idx++) {
WorkerThread work = new WorkerThread();
work.start();
threads.add(work);
}
}
// 提交任务
void execute(Runnable command) {
workQueue.put(command);
}
// 工作线程负责消费任务,并执行任务
class WorkerThread extends Thread {
public void run() { // (1)
// 循环取任务并执行
while (true) {
Runnable task = workQueue.take();
task.run();
}
}
}
}
/** 下面是使用示例 **/
// 创建有界阻塞队列
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(2);
// 创建线程池
MyThreadPool pool = new MyThreadPool(10, workQueue);
// 提交任务
pool.execute(()->{
System.out.println("hello");
});
在MyThreadPool的内部,我们维护了一个阻塞队列workQueue和一组工作线程,工作线程的个数由构造函数中的poolSize来指定。用户通过调用execute()方法来提交Runnable任务,execute()方法的内部实现仅仅是将任务加入到workQueue中。MyThreadPool内部维护的工作线程会消费workQueue中的任务并执行任务。
②线程池的注意事项
- 阻塞队列,为了避免OOM,一般建议使用有界队列
- 拒绝策略,使用有界队列,当任务过多时,线程池会触发执行拒绝策略,线程池默认的拒绝策略会throw RejectedExecutionException 这是个运行时异常,对于运行时异常编译器并不强制catch它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用。
- 异常捕捉,使用线程池,还要注意异常处理的问题,例如通过ThreadPoolExecutor对象的execute()方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;不过,最致命的是任务虽然异常了,但是你却获取不到任何通知,这会让你误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理。
9、Future
Future与FutureTask是用来获取线程池的执行结果的。
以使用ThreadPoolExecutor的时候为例,来获取任务执行结果。
①Future
Java通过ThreadPoolExecutor提供的3个submit()方法和1个FutureTask工具类来支持获得任务执行结果的需求。
// 提交Runnable任务
Future submit(Runnable task);
// 提交Callable任务
Future submit(Callable task);
// 提交Runnable任务及结果引用
Future submit(Runnable task, T result);
这3个submit()方法之间的区别在于方法参数不同
- 提交Runnable任务
submit(Runnable task) :这个方法的参数是一个Runnable接口,Runnable接口的run()方法是没有返回值的,所以 submit(Runnable task) 这个方法返回的Future仅可以用来断言任务已经结束了,类似于Thread.join()。 - 提交Callable任务
submit(Callable task):这个方法的参数是一个Callable接口,它只有一个call()方法,并且这个方法是有返回值的,所以这个方法返回的Future对象可以通过调用其get()方法来获取任务的执行结果。 - 提交Runnable任务及结果引用
submit(Runnable task, T result):这个方法是这么使用的,假设这个方法返回的Future对象是f,f.get()的返回值就是传给submit()方法的参数result。
可以看到,submit方法最后的返回值均是Future。Future是一个接口,Future接口有5个方法,它们分别是:
// 取消任务
boolean cancel(boolean mayInterruptIfRunning);
// 判断任务是否已取消
boolean isCancelled();
// 判断任务是否已结束
boolean isDone();
// 获得任务执行结果
get();
// 获得任务执行结果,支持超时
get(long timeout, TimeUnit unit);
需要注意的是:这两个get()方法都是阻塞式的,如果被调用的时候,任务还没有执行完,那么调用get()方法的线程会阻塞,直到任务执行完才会被唤醒。
②FutureTask
FutureTask是一个实实在在的工具类,这个工具类有两个构造函数,它们的参数和前面介绍的submit()方法类似。
FutureTask(Callable callable);
FutureTask(Runnable runnable, V result);
FutureTask实现了Runnable和Future接口。
由于实现了Runnable接口,所以可以将FutureTask对象作为任务提交给ThreadPoolExecutor去执行,也可以直接被Thread执行;
由于实现了Future接口,所以能用来获得任务的执行结果。
实战:烧水泡茶
烧水泡茶是华罗庚先生在统筹学中举出的一个案例。具体的流程如下:
如何使用多线程来实现这个方案?我们知道,并发编程可以总结为三个核心问题:分工、同步和互斥。
我们按照这三个核心规划:
- 用两个线程T1和T2来完成烧水泡茶程序,
- T1:负责洗水壶、烧开水、泡茶这三道工序,
- T2:负责洗茶壶、洗茶杯、拿茶叶三道工序,
- T1在执行泡茶这道工序时需要等待T2完成拿茶叶的工序。(很多方法可以实现阻塞:Thread.join()、CountDownLatch,甚至阻塞队列,本例中用Future特性来实现)
class Demo {
// 创建任务T2的FutureTask
FutureTask ft2 = new FutureTask<>(new T2Task());
// 创建任务T1的FutureTask
FutureTask ft1 = new FutureTask<>(new T1Task(ft2));
// 线程T1执行任务ft1
Thread T1 = new Thread(ft1);
T1.start();
// 线程T2执行任务ft2
Thread T2 = new Thread(ft2);
T2.start();
// 等待线程T1执行结果
System.out.println(ft1.get());
// T1Task需要执行的任务:
// 洗水壶、烧开水、泡茶
class T1Task implements Callable{
FutureTask ft2;
// T1任务需要T2任务的FutureTask
T1Task(FutureTask ft2){
this.ft2 = ft2;
}
@Override
String call() throws Exception {
System.out.println("T1:洗水壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T1:烧开水...");
TimeUnit.SECONDS.sleep(15);
// 获取T2线程的茶叶
String tf = ft2.get(); //①
System.out.println("T1:拿到茶叶:"+tf);
System.out.println("T1:泡茶...");
return "上茶:" + tf;
}
}
// T2Task需要执行的任务:
// 洗茶壶、洗茶杯、拿茶叶
class T2Task implements Callable {
@Override
String call() throws Exception {
System.out.println("T2:洗茶壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T2:洗茶杯...");
TimeUnit.SECONDS.sleep(2);
System.out.println("T2:拿茶叶...");
TimeUnit.SECONDS.sleep(1);
return "龙井";
}
}
}
可以看到在代码①处,T1的线程被阻塞了,直到拿到T2的线程执行结果,才会去进行接下来的"T1:拿到茶叶:“与"T1:泡茶…”
10、CompletableFuture
CompletableFuture主要用于描述多个线程之间的时序关系,调度各个多线程,使各个线程之间分工并合作的。
它具有其他并发工具所无法替代的作用:
- 无需手工维护线程,没有繁琐的手工维护线程的工作,给任务分配线程的工作也不需要我们关注;
- 语义更清晰,例如 f3 = f1.thenCombine(f2, ()->{}) 能够清晰地表述“任务3要等待任务1和任务2都完成后才能开始”;
- 代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的。
之前我们利用FutureTask模拟了“烧水泡茶”,现在使用CompletableFuture再模拟一次,感受CompletableFuture的特性:
public class CompletableFutureDemo {
public static void main(String[] args) throws InterruptedException {
CompletableFuture f1 = CompletableFuture.runAsync(() -> {
System.out.println(getThreadName()+"洗水壶...");
sleep(1, TimeUnit.SECONDS);
System.out.println(getThreadName()+"烧开水...");
sleep(1, TimeUnit.SECONDS);
});
CompletableFuture f2 =
CompletableFuture.supplyAsync(()->{
System.out.println(getThreadName()+"洗茶壶...");
sleep(1, TimeUnit.SECONDS);
System.out.println(getThreadName()+"洗茶杯...");
sleep(2, TimeUnit.SECONDS);
System.out.println(getThreadName()+"拿茶叶...");
sleep(1, TimeUnit.SECONDS);
return "龙井";
});
CompletableFuture f3 = f1.thenCombine(f2, (__, tf)->{
System.out.println(getThreadName()+"拿到茶叶:" + tf);
System.out.println(getThreadName()+"泡茶...");
return "上茶:" + tf;
});
System.out.println(getThreadName()+f3.join());
}
public static void sleep(int t, TimeUnit u) {
try {
u.sleep(t);
}catch(InterruptedException e){}
}
public static String getThreadName(){
return Thread.currentThread().getName()+": ";
}
}
运行结果如下:
①CompletableFuture的创建
创建CompletableFuture对象主要靠下面代码中展示的这4个静态方法
//使用默认线程池
static CompletableFuture runAsync(Runnable runnable)
static CompletableFuture supplyAsync(Supplier supplier)
//可以指定线程池
static CompletableFuture runAsync(Runnable runnable, Executor executor)
static CompletableFuture supplyAsync(Supplier supplier, Executor executor)
前两个方法和后两个方法的区别在于:后两个方法可以指定线程池参数.
默认情况下CompletableFuture会使用公共的ForkJoinPool线程池,这个线程池默认创建的线程数是CPU的核数(也可以通过JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism来设置ForkJoinPool线程池的线程数)。如果所有CompletableFuture共享一个线程池,那么一旦有任务执行一些很慢的I/O操作,就会导致线程池中所有线程都阻塞在I/O操作上,从而造成线程饥饿,进而影响整个系统的性能。所以,建议你要根据不同的业务类型创建不同的线程池,以避免互相干扰。
当CompletableFuture创建成功时,线程就已经开始了,不需要显式的开始运行。
CompletableFuture实现了Future接口和CompletionStage接口。Future接口不再赘述,CompletionStage是一个很丰富的接口。
②CompletionStage
CompletionStage是用来描述各种分工关系的,线程之间主要的分工关系有串行关系、并行关系、汇聚关系等。
从上到下,依次为简单并行任务、聚合任务和批量并行任务示意图,还有一种串行就是依次执行,这里没有写明。
聚合关系又分为AND聚合关系与OR聚合关系,AND聚合关系指的是前置线程均完成才可以继续完成聚合后的线程任务,OR聚合关系的话指的是前置线程只要有一条完成就可以继续聚合线程任务。
CompletionStage中含有40多个方法,功能丰富单无外乎就是围绕几种线程关系来编写的。这里不多赘述,感兴趣的话可以看API下的CompletionStage规则。
11、CompletionService
在实际开发过程之中,我们总会遇到任务批量并行,或等到所有任务均执行成功,或等到部分任务执行成功就直接返回的情况。CompletionService就可以解决这种情况。CompletionService的实现原理:CompletionService内部维护了一个阻塞队列,当任务执行结束就把任务的执行结果(Future对象)加入到阻塞队列中。
①CompletionService创建
CompletionService接口的实现类是ExecutorCompletionService,它具有两个构造函数:
public ExecutorCompletionService(Executor executor);
public ExecutorCompletionService(Executor executor, BlockingQueue> completionQueue);
completionQueue就是他最后结果所保存的队列,默认情况下,他是一个LinkedBlockingQueue的无边界队列。
实现一个简易的货比三家系统:
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService cs = new
ExecutorCompletionService<>(executor);
// 开始异步向电商S1询价
cs.submit(()->getPriceByS1());
// 开始异步向电商S2询价
cs.submit(()->getPriceByS2());
// 开始异步向电商S3询价
cs.submit(()->getPriceByS3());
// 将询价结果异步保存到数据库
for (int i=0; i<3; i++) {
//通过CompletionService接口提供的take()方法获取一个Future对象
Integer r = cs.take().get();
executor.execute(()->save(r));
}
②CompletionService接口
CompletionService接口提供的方法有5个:
Future submit(Callable task);
//类似于Future submit
Future submit(Runnable task, V result);
Future take() throws InterruptedException;
Future poll();
Future poll(long timeout, TimeUnit unit) throws InterruptedException;
take()、poll()都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值。 poll(long timeout, TimeUnit unit) 方法支持以超时的方式获取并移除阻塞队列头部的一个元素,如果等待了 timeout unit时间,阻塞队列还是空的,那么该方法会返回 null 值。
实例:完善询价系统
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService cs = new ExecutorCompletionService<>(executor);
// 异步向电商S1询价
cs.submit(()->getPriceByS1());
// 异步向电商S2询价
cs.submit(()->getPriceByS2());
// 异步向电商S3询价
cs.submit(()->getPriceByS3());
// 将询价结果异步保存到数据库
// 并计算最低报价
AtomicReference<Integer> m = new AtomicReference<>(Integer.MAX_VALUE);
CountDownLatch latch = new CountDownLatch(3);
for(int i=0; i<3; i++) {
executor.execute(()->{
Integer r = null;
try {
r = cs.take().get();
} catch(Exception e) {}
save(r);
m.set(Integer.min(m.get(), r));
latch.countDown();
});
latch.await();
return m;
}
12、Fork|Join
如果说以上的线程池、Future、CompletableFuture和CompletionService等并发工具是用来处理分工任务的,比如:对于简单的并行任务,你可以通过“线程池+Future”的方案来解决;如果任务之间有聚合关系,无论是AND聚合还是OR聚合,都可以通过CompletableFuture来解决;而批量的并行任务,则可以通过CompletionService来解决。那Fork|Join主要用来处理分治任务的。
分治任务模型可分为两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。下图是一个简化的分治任务模型图,你可以对照着理解。
熟悉Java后我们通常对于这种分治形式的任务采用递归的方式进行计算。递归是将任务拆解,利用压栈的方式对任务逐层分解,之后进行合并。而Fork|Join是采用线程分治的形式对于任务进行并行处理。理论上,压栈需要等待前一个栈帧的释放,但是并行不需要,只需要阻塞父线程即可,理论上并行分治会更加效率。
①Fork|Join
Fork/Join是一个并行计算的框架,主要就是用来支持分治任务模型的,这个计算框架里的Fork对应的是分治任务模型里的任务分解,Join对应的是结果合并。Fork/Join计算框架主要包含两部分,一部分是分治任务的线程池ForkJoinPool,另一部分是分治任务ForkJoinTask。这两部分的关系类似于ThreadPoolExecutor和Runnable的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型ForkJoinTask。
ForkJoinTask是一个抽象类,它的方法有很多,最核心的是fork()方法和join()方法,其中
fork()方法会异步地执行一个子任务,
join()方法则会阻塞当前线程来等待子任务的执行结果。
ForkJoinTask有两个子类——RecursiveAction和RecursiveTask,通过名字你就应该能知道,它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法compute(),不过区别是RecursiveAction定义的compute()没有返回值,而RecursiveTask定义的compute()方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。
下面我们通过实现计算斐波那契数列来演示:
static void main(String[] args){
//创建分治任务线程池
ForkJoinPool fjp = new ForkJoinPool(4);
//创建分治任务
Fibonacci fib = new Fibonacci(30);
//启动分治任务
Integer result = fjp.invoke(fib);
//输出结果
System.out.println(result);
}
//递归任务
static class Fibonacci extends RecursiveTask{
final int n;
Fibonacci(int n){this.n = n;}
protected Integer compute(){
if (n <= 1)
return n;
Fibonacci f1 = new Fibonacci(n - 1);
//创建子任务
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
//等待子任务结果,并合并结果
return f2.compute() + f1.join();
}
}
②ForkJoinPool实现原理
对比于ThreadPoolExecutor:
ThreadPoolExecutor本质上是一个生产者-消费者模式的实现,内部有一个任务队列,这个任务队列是生产者和消费者通信的媒介;ThreadPoolExecutor可以有多个工作线程,但是这些工作线程都共享一个任务队列。
ForkJoinPool本质上也是一个生产者-消费者的实现,但是更加智能。
ThreadPoolExecutor内部只有一个任务队列,而ForkJoinPool内部有多个任务队列,当我们通过ForkJoinPool的invoke()或者submit()方法提交任务时,ForkJoinPool根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。如果任务队列空了,ForkJoinPool还支持一种叫做**“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务,例如图中,线程T2对应的任务队列已经空了,它可以“窃取”线程T1对应的任务队列的任务。如此一来,所有的工作线程都不会闲下来了。所以说:ForkJoinPool支持任务窃取机制,能够让所有线程的工作量基本均衡,不会出现有的线程很忙,而有的线程很闲的状况,所以性能很好。Java 1.8提供的Stream API里面并行流也是以ForkJoinPool为基础的。不过需要你注意的是,默认情况下所有的并行流计算都共享一个ForkJoinPool,这个共享的ForkJoinPool默认的线程数是CPU的核数;如果所有的并行流计算都是CPU密集型计算的话,完全没有问题,但是如果存在I/O密集型的并行流计算,那么很可能会因为一个很慢的I/O计算而拖慢整个系统的性能。所以建议用不同的ForkJoinPool执行不同类型的计算任务。**
另外,ForkJoinPool中的任务队列采用的是**双端队列,**工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。
实战:模拟MapReduce统计单词数量
学习MapReduce有一个入门程序,统计一个文件里面每个单词的数量,下面我们来看看如何用Fork/Join并行计算框架来实现。
我们可以先用二分法递归地将一个文件拆分成更小的文件,直到文件里只有一行数据,然后统计这一行数据里单词的数量,最后再逐级汇总结果,你可以对照前面的简版分治任务模型图来理解这个过程。
思路有了,我们马上来实现。下面的示例程序用一个字符串数组 String[] fc 来模拟文件内容,fc里面的元素与文件里面的行数据一一对应。关键的代码在 compute() 这个方法里面,这是一个递归方法,前半部分数据fork一个递归任务去处理(关键代码mr1.fork()),后半部分数据则在当前任务中递归处理(mr2.compute())。
static void main(String[] args){
String[] fc = {"hello world",
"hello me",
"hello fork",
"hello join",
"fork join in world"};
//创建ForkJoin线程池
ForkJoinPool fjp = new ForkJoinPool(3);
//创建任务
MR mr = new MR(fc, 0, fc.length);
//启动任务
Map result = fjp.invoke(mr);
//输出结果
result.forEach((k, v)->System.out.println(k+":"+v));
}
//MR模拟类
static class MR extends RecursiveTask {
private String[] fc;
private int start, end;
//构造函数
MR(String[] fc, int fr, int to){
this.fc = fc;
this.start = fr;
this.end = to;
}
@Override
protected Map compute(){
if (end - start == 1) {
return calc(fc[start]);
} else {
int mid = (start+end)/2;
MR mr1 = new MR(
fc, start, mid);
mr1.fork();
MR mr2 = new MR(fc, mid, end);
//计算子任务,并返回合并的结果
return merge(mr2.compute(),mr1.join());
}
}
//合并结果
private Map merge(Map r1, Map r2) {
Map result = new HashMap<>();
result.putAll(r1);
//合并结果
r2.forEach((k, v) -> {
Long c = result.get(k);
if (c != null)
result.put(k, c+v);
else
result.put(k, v);
});
return result;
}
//统计单词数量
private Map calc(String line) {
Map result = new HashMap<>();
//分割单词
String [] words = line.split("s+");
//统计单词数量
for (String w : words) {
Long v = result.get(w);
if (v != null)
result.put(w, v+1);
else
result.put(w, 1L);
}
return result;
}
}
十、利用面向对象,写好并发
1、封装共享变量
面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性,这和门票管理模型匹配度相当的高,球场里的座位就是对象属性,球场入口就是对象的公共方法。我们把共享变量作为对象的属性,那对于共享变量的访问路径就是对象的公共方法,所有入口都要安排检票程序就相当于我们前面提到的并发访问策略。
对于不会发生变化的共享变量,建议你用final关键字来修饰。
利用面向对象思想写并发程序的思路,其实就这么简单:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。
2、识别共享变量约束条件
一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。
3、制定并发访问策略
制定并发访问策略,是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上来看,无外乎就是以下“三件事”。
避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
不变模式:这个在Java领域应用的很少,但在其他领域却有着广泛的应用,例如Actor模式、CSP模式以及函数式编程的基础都是不变模式。
管程及其他同步工具:Java领域万能的解决方案是管程,但是对于很多特定场景,使用Java并发包提供的读写锁、并发容器等同步工具会更好。
除了这些方案之外,还有一些宏观的原则需要你了解。这些宏观原则,有助于你写出“健壮”的并发程序。这些原则主要有以下三条。
优先使用成熟的工具类:Java SDK并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
迫不得已时才使用低级的同步原语:低级的同步原语主要指的是synchronized、Lock、Semaphore等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。
4、锁的最佳实例
并发大师Doug Lea《Java并发编程:设计原则与模式》一书中,推荐的三个用锁的最佳实践,它们分别是:
永远只在更新对象的成员变量时加锁
永远只在访问可变的成员变量时加锁
永远不在调用其他对象的方法时加锁
这三条规则,前两条估计你一定会认同,最后一条你可能会觉得过于严苛。但是我还是倾向于你去遵守,因为调用其他对象的方法,实在是太不安全了,也许“其他”方法里面有线程sleep()的调用,也可能会有奇慢无比的I/O操作,这些都会严重影响性能。更可怕的是,“其他”类的方法可能也会加锁,然后双重加锁就可能导致死锁。
并发问题,本来就难以诊断,所以你一定要让你的代码尽量安全,尽量简单,哪怕有一点可能会出问题,都要努力避免。
十一、问题记录
问题描述 | 是否解决 | 解决方案 |
---|---|---|
如果java使用synchronized来保证cpu的执行不中断,那么在一个4核cpu的机器上,只要我开启四个线程并且不关闭,那么不就会使机器上的其他进程无法使用,比如我本身还想运行一个听歌的程序,那么只要我有四个线程不释放锁,那听歌的程序就无法运行了呀? | 是 | 深入解析synchronized实现原理 |
TODO 类型赋值和基础类型赋值有什么不一样(全文搜索),当我把语句换位b = this(b=target)时,原子性问题就被自动修复了 | 是 | 首先,这种情况属于多线程中的“逃逸问题”,其次,JVM会将其优化掉 |