1、并发编程本质
例如,Java 里 synchronized、wait()/notify() 相关的知识很琐碎。但实际上 synchronized、wait()、notify() 不过是操作系统领域里管程模型的一种实现而已,Java SDK 并发包里的条件变量 Condition 也是管程里的概念,synchronized、wait()/notify()、条件变量这些知识如果单独理解,自然是管中窥豹。但是如果站在管程这个理论模型的高度,你就会发现这些知识原来这么简单,同时用起来也就得心应手了。
管程作为一种解决并发问题的模型,是继信号量模型之后的一项重大创新,它与信号量在逻辑上是等价的(可以用管程实现信号量,也可以用信号量实现管程),但是相比之下管程更易用。而且,很多编程语言都支持管程,搞懂管程,对学习其他很多语言的并发编程有很大帮助。然而,很多人急于学习 Java 并发编程技术,却忽略了技术背后的理论和模型,而理论和模型却往往比具体的技术更为重要。
2、并发编程的核心
其实并发编程可以总结为三个核心问题:分工、同步、互斥。
所谓分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥则是保证同一时刻只允许一个线程访问共享资源。Java SDK 并发包很大部分内容都是按照这三个维度组织的,例如 Fork/Join 框架就是一种分工模式,CountDownLatch 就是一种典型的同步方式,而可重入锁则是一种互斥手段。
Java SDK 并发包其余的一部分则是并发容器和原子类,这些比较容易理解,属于辅助工具,其他语言里基本都能找到对应的。
3、内存模型
Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。
4、互斥锁
syncnazy字段就是管程模型的。需要考虑的是是,锁和资源的对应关系。锁只能保护自己范围内的对象。
//细粒度this
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
//大粒度锁Account.class
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
this锁只能保护this.balance,不能保护target.balance;
锁只能保护锁的对象的内部成员,例如this是实例对象,锁保护内部的成员balance,但是不保护target.balance
Account.class对象的锁,保护的是所有Acount.class的成员对象(也就是所有Account.class实例);
Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性
如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
问题:如果使用Accont.class锁,那么所有用户都使用一个锁,就是串行执行了,会带来性能问题,力度太大了。
可以通过增加一个锁来解决。也就是2个锁,来保护2个资源(this.balance和target.balance)
5、多锁带来的问题-死锁
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;
}
}
}
}
}
上面的代码,线程1执行到对象1的1处,线程2执行到对象的1处,实际上,线程2得到的是对象1的2处的锁,就会造成死锁
解决死锁的思路:
1、打破占用且等待。也就是一次性对2个资源申请(需要一个中间协调对象,提供申请和释放锁)申请,要么都申请要么都释放。
2、打破不可抢占,也就是能够主动释放(超时)。synchronzed是不具备自动释放的,原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源你可能会质疑,“Java 作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java 在语言层次确实没有解决这个问题,不过在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的
3、破坏循环等待。也就是对资源能排序,例如根据账户的id字段作为资源排序的依据
class Account {
private int id;
private int balance;
// 转账
void transfer(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 -= amt;
target.balance += amt;
}
}
}
}
}
6、等待-通知机制
在执行synchronize进入到临界区后,如果调用wait()会使得当前线程处于阻塞状态,并加入到等待队列,线程在处于等待队列(互斥锁的等待队列)同时,会释放当前线程已经占用的资源,当前线程释放后,其他线程就有机会获得锁;
notify会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。之所以是“曾经满足过”是因为只能保证当前notify时候满足,而被通知线程的执行时间点和通知时间点基本不会重合,所以当线程执行的时候,很可能条件已经不满足了(可能哟其他线程插入)
上面我们一直强调 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。
资源分配器的经典写法
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();
}
}
尽量用notifyAll()
在上面的代码中,我用的是 notifyAll() 来实现通知机制,为什么不使用 notify() 呢?这二者是有区别的,notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。从感觉上来讲,应该是 notify() 更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列(AB 分配给线程 1,线程 3 要求的条件不满足),线程 4 申请 CD 也会进入等待队列。我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。
(1)ABCD 那个例子真没看懂 线程1释放锁为啥会通知线程4?1和3才是互斥的啊 2和4互斥 按我的理解 3和4 不应该是在同一个等待队列里啊 因为不是通一把锁(准确来时不是同样的两把锁)
答案:都是this这一把锁: synchronized void apply(){}
所以是一个等待队列
就是500个线程,也是同一个等待队列,因为锁的都是this
队列一定是存在的
(2)3怎么可能永远通知不到呢?就算4通知到了不满足条件等待,2走完还是会通知3或者4,就算通知到4了还是会点用notify方法
作者回复: 一个notify对应一个wait,浪费一个wait,自然有一个永远失去机会
(3)提到wait和notify是一一对应的,如果浪费了一个notify,就必然有一个wait永远没机会被唤醒。这句话怎么理解呢?
例子里面 假设之后线程 1 归还了资源 AB,使用 notify() 来通知等待队列中的线程4 申请的是 CD,程 4 还是会继续等待,此时会执行wait()吗?如果执行了,wait和notify还是一一对应的呀。如果没有执行,线程4会怎么执行呢?我看了几次文章了,还是没有理解此处,
如何用 synchronized 实现互斥锁,你应该已经很熟悉了。在下面这个图里,左边有一个等待队列,同一时刻,只允许一个线程进入 synchronized 保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
文章中的左边和右边的两个队列应该改一改名字,不应该都叫等待队列,这样对新手很容易产生误解。如果左边的叫做同步队列,右边的叫做等待队列可能更好。左边的队列是用来争夺锁的,右边的队列是等待队列,是必须被notify的,当被notify之后,就会被放入左边的队列去争夺锁
困惑
1. 对于从来没有获得过互斥锁的线程 所在的等待队列 和 因为wait() 释放锁而进入了等待队列,是否是同一个等待队列?也就是图中左侧和右侧的是否为同一个队列?--答案:不是一个队列
2. notifyAll() 会发通知给等待队列中所有的线程吗?包括那些从未获得过互斥锁的线程吗?--答案:只唤醒右侧的队列
3. 因为wait()被阻塞,而又因为notify()重新被唤醒后,代码是接着在wait()之后执行,还是重新?答案:wait之后
7、管程
java 在1.5之前提供的唯一的并发源语就是管程(monitor)。1.5之后提供的SDK并发包也是以管程为基础,除此之外C/C++、C# 等高级语言也都支持管程。
什么是管程
操作系统告诉我们信号量可以解决并发问题,但是Java并不是,它采用管程技术,一种“等价”信号量的方式,就是管程能够实现信号量,信号量也能实现管程(?),但是管程更加容易使用,且对面向对象非常友好,所以Java选择了管程;Java中的synchronazy关键字和wait、notify、notifyall都是管程的一部分。
简答说,管程就是管理共享变量和管理对共享变量的操作,让他们支持并发,从Java的角度说,就是管理类的成员变量和成员方法,让这个类线程安全;Java采用了mesa管程模型。如下图
管程如何解决互斥?-----将共享变量和对共享变量的操作,使用管程包装起来
如何解决同步?--------使用条件变量+等待队列。
mesa模型区别于其他两个模型(hasen模型和hoare模型)
(1)Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
(2)Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
(3)MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。
Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。
8、java线程
Java线程本质上就是操作系统的线程,一一对应关系;
虽然不同的开发语言对于操作系统线程进行了不同的封装,但是对于线程的生命周期这部分,基本上是雷同的,通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。
(1)这五种状态在不同编程语言里会有简化合并。例如,C 语言的 POSIX Threads 规范,就把初始状态和可运行状态合并了;Java 语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。
(2) 不同语言实现,也可能对线程状态再次分解细化。例如Java里,
BLOCKED(阻塞状态)
WAITING(无时限等待)
TIMED_WAITING(有时限等待)
三种状态都是操作系统的线程里的睡眠状态的细化。他们细化是因为不同的原因导致的休眠状态。
1. RUNNABLE 与 BLOCKED 的状态转换
只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。
如果你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞式 API 时,是否会转换到 BLOCKED 状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在 JVM 层面,Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面此时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。
而我们平时所谓的 Java 在调用阻塞式 API 时,线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。
线程同步:就是指线程互相沟通(通信)
2. RUNNABLE 与 WAITING 的状态转换
有三种情况,可以让runnable转为waiting状态
1、第一种场景,获得 synchronized 隐式锁的线程后,如果未满足某些条件(if(contain(a)||contain(b){wait()},调用无参数的 Object.wait() 方法。其中,wait() 方法我们在上一篇讲解管程的时候已经深入介绍过了,这里就不再赘述。
2、第二种场景,调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
3、第三种场景,调用 LockSupport.park() 方法。其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 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) 方法。
这里你会发现 TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。
9、并发-Lock和condition
Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。
Java里的多线程的可见性是通过happen-before(volite)来实现;synchronize也是通过happen-before的一条规则(加锁happen-before于解锁)
重入锁:reentrantLock
公平锁:创建重入锁时候,构造函数参数如果true标识公平锁,false标识非公平锁
公平锁的意思是,当释放锁,唤起等待队列中一个线程时候,排队时间长的优先获得锁,排队时间短的靠后获取锁;如果非公平锁,那么可能唤起的是一个等待时间短的线程;
非公平锁的场景应该是线程释放锁之后,如果来了一个线程获取锁,他不必去排队直接获取到
class Account {
private int balance;
private final Lock lock= new ReentrantLock();
// 转账
void transfer(Account tar, int amt){
while (true) {
if(this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
} finally {
tar.lock.unlock();
}
}//if
} finally {
this.lock.unlock();
}
}//if
}//while
}//transfer
}
Java中的锁机制主要分为Lock和Synchronized,本文主要分析Java锁机制的使用和实现原理,按照Java锁使用、JDK中锁实现、系统层锁实现的顺序来进行分析;
// 支持中断的 API
void lockInterruptibly() throws InterruptedException;
// 支持超时的 API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 支持非阻塞获取锁的 API-就是获取不到锁,立马返回false不去排队
boolean tryLock();
trylock()和lock()的区别
1、lock()方法:
线程1在执行lock()方法未获得锁的时候,线程1的lock()方法会一直阻塞,直到获得锁。
tryLock():
如果线程2执行tryLock()方法时未获得锁,则会立即返回false,不会阻塞(此方法可设置获得锁的等待时间)
2、lock()方法:
如果线程1在执行lock()方法时被阻塞(因为在等待获得锁),且当前线程此时被中断,则lock()方法仍然会继续阻塞,直到获得锁继续执行。
tryLock():
如果线程2在执行tryLock()方法时等待获得锁(设置等待时间等待获得锁),且线程2此时被中断,则tryLock()方法会抛出异常InterruptedException。
简而言之就是: 1.lock()方法会阻塞,tryLock()方法不会阻塞,在一定时间内一定返回结果
2.lock()方法在当前线程被中断时不会抛出异常,tryLock()在当前线程被中断时会抛出异常
lock() 方法是阻塞获取锁的方式,如果当前锁被其他线程持有,则当前线程会一直阻塞等待获取锁,直到获取到锁或者发生超时或中断等情况才会结束等待。该方法获取到锁之后可以保证线程对共享资源的访问是互斥的,适用于需要确保共享资源只能被一个线程访问的场景。Redisson 的 lock() 方法支持可重入锁和公平锁等特性,可以更好地满足多线程并发访问的需求。
tryLock() 方法是一种非阻塞获取锁的方式,在尝试获取锁时不会阻塞当前线程,而是立即返回获取锁的结果,如果获取成功则返回 true,否则返回 false。Redisson 的 tryLock() 方法支持加锁时间限制、等待时间限制以及可重入等特性,可以更好地控制获取锁的过程和等待时间,避免程序出现长时间无法响应等问题。
10、并发 condition
Condition 实现了管程模型里面的条件变量。
-parameters --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
11、并发-信号量samphore
信号量在 Java 语言里面名气并不算大,但是在其他语言里却是很有知名度的。Java 在并发编程领域走的很快,重点支持的还是管程模型。 管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面,例如用信号量解决我们曾经提到过的阻塞队列问题,就比管程模型麻烦很多,你如果感兴趣,可以课下了解和尝试一下。
信号量是由大名鼎鼎的计算机科学家迪杰斯特拉(Dijkstra)于 1965 年提出,在这之后的 15 年,信号量一直都是并发编程领域的终结者,直到 1980 年管程被提出来,我们才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制,所以学好信号量还是很有必要的。
12、同步-读写锁readWriteLock
一种适合读多写少场景的锁。读锁共享,写锁独占。也即是一个共享锁,一个互斥锁。
reentrantReadWriteLock是实现了可重入锁;
readWriteLock读写锁不可以锁升级(意思就是,读锁后不释放情况下,再加写锁,这种场景是读取不到数据,从数据源获取后写入);但是可以做降级锁,也就是在写不到
ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞
13、同步-stampLock
和readWriteLock一样支持都铎邪少的场景。但是比readwritelock更快
stamplock在读写锁(readwriteLock)基础(读锁、写锁)上,增加了一个锁叫“乐观读”(这里不是乐观读锁,而是乐观读)。其中stamplock的写锁和悲观写锁和读写锁的读锁、写锁是基本一致。
特点:stamplock的写锁和悲观读是互斥的。当获取到锁时候,都会返回一个stamp对象,解锁时候需要将这个stamp传入
乐观读:乐观读是无锁的,这就是快的原因。
注意点:
1、和读写锁不同的,乐观读(不是一种锁),可以升级为悲观锁,这时候,就和读写锁一样了。
2、不支持可重入(名字上也能看出来,没有reentranLock)
可重入:简单说就是,在执行过程中是否可以被中断,操作系统去执行其他任务再返回,还是可以得到正确的结果
不可重入:由于使用了操作系统的资源,例如全局变量,中断向量等,如果被中断可能会出现问题。
一个可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入OS调度下去执行另外一段代码,而返回控制时不会出现什么错误;而不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的**
14、同步-coundownlatch\cyclicBarria
countdownlatch\cyclicbarria是jdk提供的线程同步工具。都提供一个计数器来作为同步计数。
coutdownlatch是一个线程等待多个线程,类比是一个导游等待多个游客
cyclicbarrie是多个线程等一个线程。类比是几个驴友不离不弃等最后一个
除此之外,cyclicbarrie的计数器是可以复用,到0后重新复位;
15、容器
同步容器主要提供了四种类型:List\map\set\queue;
Jdk1.5之前也提供了同步容器,例如synchronizeList(通过Collector.SynchronizedList创建)
List
copyonwriteArrayList:简单说就是写入时候,直接复制一份list,写入复制的list后,再更改对象引用;
Set
CopyOnWriteArraySet:
coccurrentSkipListSet;
Map
concurrentHashmap:和treeMap一样都是用的红黑树。红黑树一个特点就是需要经过左旋或者右旋,来实现平衡;
ConcurrentSkipListMap:能保证key是有序的,底层的跳表实现,这和treemap不同;
两个并发容器的key都不能为空;
Queue
并发容器中queue队列最复杂。可以按照2个维度来分析,一个是阻塞非阻塞,一个单端和双端
阻塞非阻塞是指:阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞
单端双端:单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队
Java 并发包里阻塞队列都用 Blocking 关键字标识,单端队列使用 Queue 标识,双端队列使用 Deque 标识
单端阻塞队列: ArrayBlockingQueue\LinkedBlockingQueue 双端阻塞队列:LinkedBlockingDeque 单端非阻塞队列:ConcurrentLinkedQueue 双端非阻塞队列:ConcurrentLinkedDeque
注意
使用队列时候,要特别注意是否队列是否有界(是指是内部的队列容器是否有容量限制)。实际工作中,不建议使用无界队列,因为队列无界容器导致OOM,上面的几个中,只有ArrayBlockingQueue和LinkedBlockingQueue是直接有界队列;
15、原子类
对于基本数据类型(Integer\Long\Double等),是不支持并发的。使用volatile可以解决可见性,但是解决原子性可以使用互斥锁方案。
jdk提供了更为简单方式:无锁方案。无锁方案针对互斥锁方案最大的好处就是性能,因为互斥锁需要加锁、解锁本身消耗资源性能,还有拿不到资源会进入阻塞,进而触发线程切换,影响性能;
无锁方案:原理就是CAS(compare and swap)
CAS方案,通常会带来自旋;自旋就是不停的尝试。CAS带来的问题,ABA问题,就是从A变为B后,又变为A,其他线程以为没有变化过;大多数情况下,并不关心这个ABA的问题,例如原子类自增,有些是关心的,例如更新的对象,要解决aba问题,需要使用版本号方案,没修改一次就增加版本号
Java提供的AtomicStampedReference就提供了版本号,来解决aba问题。
static AtomicStampedReference<Integer> ai = new AtomicStampedReference<>(4,0);
//四个参数分别是预估内存值,更新值,预估版本号,初始版本号
//只有当预估内存值==实际内存值相等并且预估版本号==实际版本号,才会进行修改
boolean b = ai.compareAndSet(4, 5,0,1);
java中基本数据类型的原子类(Atom)解决了count+1这个不支持并发(不是线程安全)的。我们使用AtomicLong 的getAndIncrement()来实现,内部实现就是CAS原理
jdk提供的原子类对象,分为5中 1、基本数据类型,例如AtomInteger、AtomLong等。操作的方法如下
getAndIncrement() // 原子化 i++
incrementAndGet() // 原子化的 ++i
// 当前值 +=delta,返回 += 前的值
getAndAdd(delta)
// 当前值 +=delta,返回 += 后的值
addAndGet(delta)
//CAS 操作,返回是否成功
compareAndSet(expect, update)
// 以下四个方法
// 新值可以通过传入 func 函数来计算
getAndUpdate(func)
updateAndGet(func)
getAndAccumulate(x,func)
accumulateAndGet(x,func)
2、原子化对象引用:AtomicReference、AtomicStampReference和AtomicMarkableReference,利用他们可实现对象的原子化。不过需要注意的是,对象引用的更新需要重点关注 ABA 问题,AtomicStampedReference 和 AtomicMarkableReference 这两个原子类可以解决 ABA 问题。
3、原子化数字:AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray
4、原子化对象属性更新器
5、原子化的累加器:DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder
15、线程池 之 Excutor
并发包中线程池的核心类:ThreadPoolExecutor,创建一个ThreadPoolExecutor 需要7个参数:核心线程、最大线程、工作队列、自定义线程factory、handle处理拒绝策略(CallerRunsPolicy提交者处理、AbortPolicy默认放弃、DiscardPolicy直接丢弃无异常、DiscardOldestPolicy丢弃最老)
三点需要注意: 1、使用有界队列 2、自定义决绝策略,因为默认的拒绝策略不抛弃异常;实际工作中,自定义的拒绝策略往往和降级策略配合使用。 3、捕获异常,谨防任务没有执行异常了但是没有通知。
16、线程池 之 Future
由于threadPoolExcutor.execute()方法没有返回值(void),为了有现成执行完的返回值,才有了Future
TheadPoolExecutor的3个submit方法和1个FutureTask工具类来达到获取返回值的
// 提交 Runnable 任务
Future<?> submit(Runnable task);
// 提交 Callable 任务
<T> Future<T> submit(Callable<T> task);
// 提交 Runnable 任务及结果引用
<T> Future<T> submit(Runnable task, T result);
上面的submit的返回值都是future对象。Future类有5个函数,isDone()\isCancel()\cancel()\get()\get(timeUnit)
其中最后一个支持超时机制;
future的get方法是获取执行结果,不过这个get是阻塞式的
submit(Runnable task) 这个方法返回的 Future 仅可以用来断言任务已经结束了,这个submit(runnable)类似于 Thread.join()。thread.join()方法: join() 方法的作用就是:将调用join的线程优先执行,当前正在执行的线程阻塞,直到调用join方法的线程执行完毕或者被打断,主要用于线程之间的交互.
submit(Runnable task, T result):这个方法返回的Future对象,如果使用future.get会获取到传参的result的值
FutureTask
实现了runnable接口,同时实现了Future接口;
这个特点,决定了FutureTask的对象可以作为ThreadPoolExcutor的参数执行,也能通过get获取到参数
17、线程池+同步 CompletableFuture
Java 在 1.8 版本提供了 CompletableFuture 来支持异步编程
创建 CompletableFuture 对象有4个方法
// 使用默认线程池
static CompletableFuture<Void> runAsync(Runnable runnable) 没有返回值
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) 有返回值
// 可以指定线程池
static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
runAsync(Runnable runnable)和supplyAsync(Supplier<U> supplier),它们之间的区别是:Runnable 接口的 run() 方法没有返回值,而 Supplier 接口的 get() 方法是有返回值的
后面2个方法:可以自定义Excutor线程池;
前2个方法不指定线程池情况下,默认使用的是ForkJoinPool线程池,这个线程池默认创建线程数是:cpu核心数(也可以通过JVM Option -Djava.util.Forkjoin.paralism来修改默认线程数)
如果所有的都completableFuture都共享一个默认的forkjoinpool线程池,一旦有某个任务执行非常的慢,就容易造成线程饥饿,影响执行效率和性能;所以都建议使用后2个方式,不同业务创建不同的线程池;
创建完 CompletableFuture 对象之后,会自动地异步执行 runnable.run() 方法或者 supplier.get() 方法,
CompletableFuture还实现了CompletionStage 接口
completionStage描述了任务的时序关系。包括串行关系、AND 聚合关系、OR 聚合关系以及异常处理。
1、串行关系
completionStage的描述串行接口:thenApply\thenAccept\thenRun\thenCompose
——thenApply 系列函数里参数 fn 的类型是接口 Function<T, R>,这个接口里与 CompletionStage 相关的方法是 R apply(T t),这个方法既能接收参数也支持返回值,所以 thenApply 系列方法返回的是CompletionStage<R>。
——而 thenAccept 系列方法里参数 consumer 的类型是接口Consumer<T>,这个接口里与 CompletionStage 相关的方法是 void accept(T t),这个方法虽然支持参数,但却不支持回值,所以 thenAccept 系列方法返回的是CompletionStage<Void>。
——thenRun 系列方法里 action 的参数是 Runnable,所以 action 既不能接收参数也不支持返回值,所以 thenRun 系列方法返回的也是CompletionStage<Void>。
——这些方法里面 Async 代表的是异步执行 fn、consumer 或者 action。其中,需要你注意的是 thenCompose 系列方法,这个系列的方法会新创建出一个子流程,最终结果和 thenApply 系列是相同的。
2、And汇聚关系
AND 汇聚关系,主要是 thenCombine、thenAcceptBoth 和 runAfterBoth 系列的接口,这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同。
3. 描述 OR 汇聚关系
CompletionStage 接口里面描述 OR 汇聚关系,主要是 applyToEither、acceptEither 和 runAfterEither 系列的接口,这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同。
18、线程池 之 CompletionService
completionServicet提供了一个阻塞任务队列,是为了解决批量执行,同时不因为某个任务耗时长而阻塞其他任务;
completionService有5个方法
Future<V> submit(Callable<V> task);
Future<V> submit(Runnable task, V result);
Future<V> take() throws InterruptedException;从阻塞队列中获取并移除一个元素
Future<V> poll();从阻塞队列中获取并移除一个元素
Future<V> poll(long timeout, TimeUnit unit) throws InterruptedException;从阻塞队列中获取并移除一个元素
ake()、poll() 都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值
当需要批量提交异步任务的时候建议你使用 CompletionService。CompletionService 将线程池 Executor 和阻塞队列 BlockingQueue 的功能融合在了一起,能够让批量异步任务的管理更简单。除此之外 ,CompletionService 能够让异步任务的执行结果有序化,先执行完的先进入阻塞队列,利用这个特性,你可以轻松实现后续处理的有序性,避免无谓的等待,同时还可以快速实现诸如 Forking Cluster 这样的需求。
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
// 创建 CompletionService
CompletionService<Integer> cs = new ExecutorCompletionService<>(executor);
// 异步向电商 S1 询价
cs.submit(()->getPriceByS1());
// 异步向电商 S2 询价
cs.submit(()->getPriceByS2());
// 异步向电商 S3 询价
cs.submit(()->getPriceByS3());
// 将询价结果异步保存到数据库 并计算最低报价
AtomicReference<Integer> m = new AtomicReference<>(Integer.MAX_VALUE);
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));
});
}
return m
Future、CompletableFuture 和 CompletionService,仔细观察你会发现这些工具类都是在帮助我们站在任务的视角来解决并发问题,而不是让我们纠缠在线程之间如何协作的细节上(比如线程之间如何实现等待、通知等)。对于简单的并行任务,你可以通过“线程池 +Future”的方案来解决;如果任务之间有聚合关系,无论是 AND 聚合还是 OR 聚合,都可以通过 CompletableFuture 来解决;而批量的并行任务,则可以通过 CompletionService 来解决。
18、线程池 之fork、join
fork\join是并行计算分治思想的一个实现;
fork: 分解一个任务 join: 合并一个结果
fork\join的计算框架主要分为2部分: ForkJoinPool线程池和ForkJoinTask任务(关系类似于 ThreadPoolExecutor 和 Runnable 的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型 ForkJoinTask)
任务偷窃是说,当前线程空闲了(线程对应的任务队列里没有任务了),那么会从其他线程的任务队列中寻找任务执行;”任务偷窃“过程中,由于ForkJoinPool中的任务队列是双端队列,‘偷窃’任务和线程正常执行认识分别是从任务队列的不同的端获取;
(1) ForkJoinPool线程池
forkjoinpool本质上是一个生产者-消费者的实现。和ThreadPoolExcutor不同的是,ThreadPoolExcutor有一个任务队列,ForkJoinPool有多个任务队列。
当我们通过ForkJoinPool的submit提交任务时候,他会将这个任务按照根据路由的规则放入到某个队列中,如果任务执行中生成了子任务,那么这个子任务也会放置到当前执行线程的任务队列中
Java 1.8 提供的 Stream API 里面并行流也是以 ForkJoinPool 为基础的。不过需要你注意的是,默认情况下所有的并行流计算都共享一个 ForkJoinPool,这个共享的 ForkJoinPool 默认的线程数是 CPU 的核数;如果所有的并行流计算都是 CPU 密集型计算的话,完全没有问题,但是如果存在 I/O 密集型的并行流计算,那么很可能会因为一个很慢的 I/O 计算而拖慢整个系统的性能。所以建议用不同的 ForkJoinPool 执行不同类型的计算任务。
(2)ForkJoinTask 任务
ForkJoinTask 是一个抽象类,它的方法有很多,最核心的是 fork() 方法和 join() 方法,其中 fork() 方法会异步地执行一个子任务,而 join() 方法则会阻塞当前线程来等待子任务的执行结果
forkjoinTask有2个子类: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<Integer>{
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();
}
}
18、netty
netty中使用零拷贝。先说传统的网络IO读写,网卡复制到系统内核缓冲区,然后复制到Java对内存中,操作完后,再复制到系统内核缓冲区,然后通过网卡发送,这里完成需要进行4次用户态和内核台的切换。
零拷贝的意思是,(1)使用直接内存。网卡复制到内核缓冲区后,不再复制到Java应用堆内存,netty使用了直接内存(directory memory)或者叫 对外内存,其实就是内核空间缓冲区,底层用到的是linux的底层mmap技术,可以直接对内核缓冲区内存操作 (2)、使用bytebuffer;传统是通过操作小buffer对内核缓冲区读写,这里的bytebuffer可以组合多个buffer操作避免了传统的内存拷贝,将几个小buffer合并到大buffer的; (3)、使用transferTo文件传输;底层是linux的sendfile机制,就是直接将文件缓冲区的数据,发送到目标channel(例如socket的数据缓冲区)。避免内核态和用户态的切换
sendfile的工作原理呢?
1、系统调用 sendfile() 通过 DMA 把硬盘数据拷贝到 kernel buffer,然后数据被 kernel 直接拷贝到另外一个与 socket 相关的 kernel buffer。这里没有 用户态和核心态 之间的切换,在内核中直接完成了从一个 buffer 到另一个 buffer 的拷贝。 2、DMA 把数据从 kernel buffer 直接拷贝给协议栈,没有切换,也不需要数据从用户态和核心态,因为数据就在 kernel 里
传统的发送socket方式?
首先我们来看看传统的read/write方式进行socket的传输
过程中发生了四次copy操作。硬盘->内核->用户->socket缓冲区(内核)->协议引擎。
Disruptor
JDK提供的ArrayBlockingQueue 和 LinkedBlockingQueue,他们都基于ReentranceLock实现的,高并发情况下,加锁会导致效率下降,那么有没有更高效的方式?有 ,就是Disruptor
Disruptor是高效的有界、内存队列;目前使用广泛,其中Nginx、log4j等都在使用
高效的原因:
1、数据结果使用RingBuffer存储。ArrayBlockingQueue是数组作为底层的数据存储,而Ringbuffer虽然也是数组,但是是经过改良的,主要是利用了计算机系统的“程序局部性”原理。
// 前:填充 56 字节
class LhsPadding{ long p1, p2, p3, p4, p5, p6, p7; }
class Value extends LhsPadding{
volatile long value;
}
// 后:填充 56 字节
class RhsPadding extends Value{
long p9, p10, p11, p12, p13, p14, p15;
}
class Sequence extends RhsPadding{ // 省略实现 }
由于伪共享问题如此重要,所以 Java 也开始重视它了,比如 Java 8 中,提供了避免伪共享的注解:@sun.misc.Contended,通过这个注解就能轻松避免伪共享(需要设置 JVM 参数 -XX:-RestrictContended)。不过避免伪共享是以牺牲内存为代价的,所以具体使用的时候还是需要仔细斟酌。
2、Disruptor 中的无锁算法;这里主要说的入队;