并发相关的一些技术
并发
并发是一个比较难的问题,java算是一种并发技术中属于走得比较前的语言,对各种并发问题有一套完整的解决方案。
虽然博主主要使用的语言是C++,但是解决并发问题的思想是没有语言边界的。所以有些地方会向java寻求解决方案。
并发追求的目标
并发编程兴起原因
并发编程兴起的主要原因是多核CPU。
多核CPU是我们现在CPU的主流,其多个核心让我们做到了使程序能够真正一起运行。然而,也就是因为多个线程要一起分工完成一些事情,这给我们的程序带来了很多问题。
并发编程的目标
我认为,并发编程主要的任务是
通过分工、同步,互斥的手段,使我们的程序在做到正确的同时,做到尽可能的快。
分工的目的是将一个完整的任务中可以并行进行的部分,拆分给多个线程一起执行。
同步的目的是解决多个线程中一些任务的协调问题。
比如我们同时有两个任务A,B,任务A有两部分,后一部分需要等待任务B做完之后才能继续。当我们线程A先完成了前半部分任务的时候,这个时候就得陷入等待,等待任务B完成才能继续做后半部分的任务。但是A要怎样才能知道B已经完成了呢?A在前半部分做完之后是一直等待呢,还是先休眠等待B的完成呢?这些属于同步解决的问题
互斥解决的问题是共享资源导致的一些冲突,比如线程A与线程B共享一个整数,A在修改这个整数的同时,B同样修改这个整数,这样就乱套了。好在这个问题同样提供了一些解决方案。
并发中容易忽视的问题
并发编程中,博主所知道的关于并发编程中隐藏较深的,需要我们格外小心的问题主要有3个。
1.缓存导致的可见性问题
2.指令的非原子性问题
3.指令的优化乱序问题
缓存的可见性问题
缓存的可见性问题是由多核CPU带来的,因为每个CPU核心有属于自己的一级缓存。假设我们有两个线程,每个线程都让a = a +1循环100次,可能在循环的过程中会出现这种情况。
某一时刻a = 10。线程1将a从内存中读取出来放到其对应的一级缓存中,线程2同样将a从内存中读出来,放到其对应的一级缓存中。线程1将a加一后放到内存中,线程2也做同样的操作。这时,内存中的a为11,但我们实际上做了2次加法操作,正确情况a的值应该为12,这就是缓存导致的可见性问题。
由于缓存属于硬件,这个问题对我们程序员来说没什么好的办法,只能在并发编程中对那些有多个线程共享的变量禁用缓存了。
禁用缓存的办法为在变量面前加上volatile关键字,与之相对的是final关键字,说明修饰的变量不会变,可以在这个变量上做各种优化。
指令的非原子性问题
指令的非原子性导致的并发问题是由于线程切换带来的。有时候,我们要完成看上去简单的指令实际上是进行了很多步骤的。
比如我们要实现一个a = a + 1指令。
我们一般分以下三步完成它。
1.将内存中a的值加载到cpu中。
2.在cpu对a进行加1操作。
3.将cpu中的a再次存放到内存中。
由于我们现在的操作系统都是多任务调度操作系统,在程序的运行中免不了线程切换。
试想这样一种情况:
我们在两个线程中都对a进行加一操作。
假设a = 10,线程1执行到了第2步,在cpu中对a进行了加1,但没有将a放回内存中,此时,cpu中的a为11,内存中的a为10。这时,发生了线程切换,由线程2执行,线程2将3步全部进行完毕,此时,内存中
的a为11,这时再切换回线程1。将a存回内存。此时,内存中的a依旧为11,但是实际上已经进行了两次加一操作了。
解决原子性问题的方法有硬件提供支持,将操作变为原子性。但这样实际上并不怎么现实,一般是利用提供的锁来实现互斥访问,不过上锁其实也是一门学问,如何上锁才能导致程序正确,同时并发度不会下降很多,需要的方法都是不同的。
java中提供的锁为synchronized同步语义。
synchronized锁定的范围有3种
synchronized修饰代码块时锁定的是当前代码块的obj对象
synchronized修饰非静态方法的时候,锁定的是当前实例对象 this
synchronized修饰静态方法的时候,锁定的是当前类的 Class 对象,也就是整个类
指令的优化乱序问题
第三个容易忽视的问题是指令乱序问题。在现代编译器和处理器中,为了优化程序的性能,常常对程序作出一些优化,然而,这种优化有时会带来问题。
指令优化乱序问题有很多,其中最典型的一个就是单例模式中的双检查锁了。这个问题我之前的博客也有写到过。用java代码描述如下。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
在if里面再加锁的原因是为了提高并发效率。
加入同时有两个线程进入了第一个if,这时由于加锁,只能有一个线程进入第二个if,另一个if陷入阻塞。
进入第二个if的线程完成初始化后退出,这时第二个if检测到instance不再是null了,同样不会进入第二个if。
这个程序看上去没有问题,但是编译器有时会对构造器函数做出一些优化。
正常的构造器步骤为
1.分配内存
2.对内存初始化
3.返回指向内存的指针
但是优化后的构造器可能的步骤是这样的。
1.分配内存
2.返回指向内存的指针
3.对内存初始化
这时若是进入第二个if的线程执行到第二步切换了,则第二个线程就得到了一个指向没有初始化的内存的指针,若是去使用这块内存的话就会出现错误。
编译器优化导致的这个问题同样没有办法解决,我们能做的就是在可能出现线程安全的地方禁用编译器优化了。
指令重排序出现的地方
来具体说一下指令重排序出现的地方
指令重排序问题。
下面是一些指令重排序可能导致的问题。
指令序列的重排序:
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。(但是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑)
举例
在这个例子中,我们正常的思维是线程a执行1,2操作。线程b执行3,4操作,只有线程a执行了a=1之后才会执行flag = true。可在实际上。因为线程a中1,2操作没有数据依赖性,由于指令间并行的重排序,有可能会出现flag=true先执行,a=1后执行,这样线程b中可能在执行4的时候a就不是我们想要的值了,这就乱套了。
这个问题在java中定义了happens-before模式来规范指令的乱序优化来解决的。
java中的解决方案-java内存模型
java解决这三大问题的方法是定义自己的内存模型。
Java内存模型目的是为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
其规范了Java虚拟机与计算机内存是如何协同工作的:
规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
java内存模型实际上就是在已有的操作系统和硬件上自己搞一套自己程序运行时必须遵循的规范,免除了由于操作系统和硬件问题带来的一些干扰,通过这样的方式,java很好地解决了指令乱序问题和缓存的可见性问题,让程序员可以专注于解决共享数据带来的并发问题,当然哈,java也提供了一些工具以解决共享数据带来的并发问题,让我们更方便的完成开发任务。在后面会介绍一些。
java内存模型定义了8种操作。
lock和unlock:lock锁定主内存变量,使主内存变量只能被一个线程访问。unlock解锁主内存变量
read和load:read读取主内存变量的值,load将read读取的主内存的值存放到工作内存的某个变量中,两个操作一般一起执行。
store和write:store将工作内存的变量传到主内存中,write将store传来的变量存储到某个主内存变量中。
use和assign:use将工作内存中的变量送给执行引擎,assign将执行引擎中的值送给工作内存中的变量。
解决可见性问题:
1.使用volatice关键字
volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去
2.解决指令重排序问题
volatile关键字同样包含了禁止指令重排序的语义
自己加上volatice关键字其实是一个比较麻烦的事的负担,所以,jmm也定义了一些程序执行必须遵循的规范即happens-before来解决可见性问题和乱序问题。
如下:
在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
其实happens规则对应于可能导致程序出问题的编译器和处理器的重排序规则。
比如程序顺序规则其实就是对指令并行乱序的规范。
一些常见的并发问题及解决方案
利用并发程序解决问题有很多需要考虑的地方,下面是一些常见的问题模型
一个对象中存在多个资源该如何保护?
最粗暴的方法是直接锁住整个实例对象,但这样效率太低。较好的办法是为每个实例变量都提供一个锁。
class Example{
int A;
int B;
private final Object ALock = new Object();
private final Object BLock = new Object();
void modifyA(){
synchronized(ALock){
A = A - 1;
}
}
void modifyB(){
synchronized(BLock){
B = B - 1;
}
}
}
一个方法中有多个关联对象
下面的例子中方法涉及两个对象实例,target和this,要实现并发操作的正确性,我们需要锁住两个对象。
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
解决办法一
利用同一个lock对象来锁住包含两个对象的临界区,但该代码需要创建Account时传入同一个对象。
解决办法二
在临界区锁住一个class对象,这样就没有创建Account传入同一个对象的问题了,但这样导致整个转账过程变为了串行执行
class Account {
private Object lock;
private int balance;
private Account();
// 创建 Account 时传入同一个 lock 对象
public Account(Object lock) {
this.lock = lock;
}
// 转账
void transfer(Account target, int amt){
// 此处检查所有对象共享的锁
synchronized(lock) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
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对象,再锁target对象。
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) {
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
但是这样的解决办法是可能导致死锁。
如果账户A和账户B同时向对方执行转账操作,
账户A锁住了账户B的对象,账户B锁住了A的对象,两者都无法继续进行,导致死锁。
可以从死锁产生的条件来避免死锁
死锁有4个条件。
互斥,占有并等待,不可抢占,循环等待
可以从后面三个入手
1.占有并等待
我们可以拿到所有资源之后再继续进行程序处理。
在这个问题的具体操作为在锁this和target前再加一把锁lock,锁住了this和target后就解锁lock。
2.不可抢占条件
我们可以在无法申请到资源之后立刻释放我们已拥有的资源,由于synchronized申请失败之后立刻陷入阻塞,无法实现。但可以在java中可以利用java.util.concurrent提供的lock中实现。该方法虽然不会导致死锁,但可能会导致活锁。解决办法为释放资源之后随机等待一个时间再去申请资源。
3.循环等待条件
将资源进行排序,所有对象都按资源序号小的先拿,这样就不会产生环形等待条件了。
在这个问题中可以给count提供一个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;
}
}
}
}
}
上面的一些方法已经能很好的解决问题了,如果要效率再高一点,可以使用等待通知机制。
使用一个资源分配器,当this和target没人用时可以申请成功,this和target有线程用时则陷入休眠,等待使用的线程使用完this和target后释放。
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();
}
}
共享变量间有约束条件
共享变量间有约束条件一般会转化为if,所以看到有if的并发程序要格外小心。
一个关于设置库存上限和下限的程序。
public class SafeWM {
// 库存上限
private final AtomicLong upper =
new AtomicLong(0);
// 库存下限
private final AtomicLong lower =
new AtomicLong(0);
// 设置库存上限
void setUpper(long v){
// 检查参数合法性
if (v < lower.get()) {
throw new IllegalArgumentException();
}
upper.set(v);
}
// 设置库存下限
void setLower(long v){
// 检查参数合法性
if (v > upper.get()) {
throw new IllegalArgumentException();
}
lower.set(v);
}
// 省略其他业务代码
}
在这个程序中是无法做到线程安全的,考虑一种情况。
当前库存的下限和上限分别是 (2,10),线程 A 调用 setUpper(5) 将上限设置为 5,线程 B 调用 setLower(7) 将下限设置为 7,两者同时执行,就把库存的下限和上限设置成 (7, 5) 了。
这个问题的解决办法:
1.对整个this对象加锁就行(对setUpper和setLower方法分别加锁)
2.使setupper变量和setLower变量共用一把lock锁就行了。(一个lock锁两个变量)
3.在意性能的话可以将lower对象和upper对象封装成一个对象,增加setBoundary方法。
public class Boundary {
private final lower;
private final upper;
public Boundary(long lower, long upper) {
if(lower >= upper) {
// throw exception
}
this.lower = lower;
this.upper = upper;
}
}
需要满足某些条件才能继续执行
一个并发的阻塞队列的例子,队列满时不能够入队,队列空时不能够出队。
解决方案是利用条件变量,先锁住队列,入队时判断队列是否满,若队列满了则休眠,若队列没满则入队,同时唤醒出队队列的休眠队列。
出队操作同理
public class BlockedQueue<T>{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
// 入队后, 通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
// 出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
多个线程访问一个临界区(但限制线程不能超过某个数目)
对象池,连接池,线程池等都属于这种情况。
这个问题可以利用信号量很好的解决。
下面是个对象池的例子.
每次要使用一个对象时,则使信号量减一,使用完之后让信号量加一。
class ObjPool<T, R> {
final List<T> pool;
// 用信号量实现限流器
final Semaphore sem;
// 构造函数
ObjPool(int size, T t){
pool = new Vector<T>(){};
for(int i=0; i<size; i++){
pool.add(t);
}
sem = new Semaphore(size);
}
// 利用对象池的对象,调用 func
R exec(Function<T,R> func) {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
}
// 创建对象池
ObjPool<Long, String> pool =
new ObjPool<Long, String>(10, 2);
// 通过对象池获取 t,之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
读多写少的场景
读多写少的典型场景就是缓存了。
缓存一般有两种加载方法,数据量少时则将所有数据加载到缓存中,数据量多时则加一部分数据到缓存中,然后不断根据实际的使用情况维护缓存。
缓存这种场景一般使用读写锁来解决。
读写锁一般分为写锁,悲观读锁,乐观读锁。乐观读是利用版本字段来实现的。
一个使用写锁和悲观读锁实现缓存的例子
class Cache<K,V> {
final Map<K, V> m =
new HashMap<>();
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
V put(String key, Data v) {
w.lock();
try { return m.put(key, v); }
finally { w.unlock(); }
}
}
任务存在汇聚关系
任务存在汇聚关系指的是某些任务要等前面的任务做完之后才能进行。
一个对账系统的例子
我们最直接的写法如下
while(存在未对账订单){
// 查询未对账订单
pos = getPOrders();
// 查询派送单
dos = getDOrders();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
效率不高,可以用如下方法
1.让查订单和派送单的操作并行执行(可以创建线程池避免线程的创建和销毁的开销)
while(存在未对账订单){
// 查询未对账订单
Thread T1 = new Thread(()->{
pos = getPOrders();
});
T1.start();
// 查询派送单
Thread T2 = new Thread(()->{
dos = getDOrders();
});
T2.start();
// 等待 T1、T2 结束
T1.join();
T2.join();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
2.创建线程后该如何判断前面的线程已经执行完了(即如何实现线程间的通知)?
做一个计数器,初始值设置成 2,当执行完pos = getPOrders();这个操作之后将计数器减 1,执行完dos = getDOrders();之后也将计数器减 1,在主线程里,等待计数器等于 0;当计数器等于 0 时,说明这两个查询操作执行完了。
3.实现线程的进一步并行
在getPOrders()和getDOrders()执行完之后继续去查询下一单。
做3个线程,1个线程执行getPOrders(),1个线程getDOrders(),1个线程去查询差异,写入差异库
实现:需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据。
线程 T1 和 T2 如何做到步调一致?如何通知线程 T3?
做一个计数器,计数器初始化为 2,线程 T1 和 T2 生产完一条数据都将计数器减 1,如果计数器大于 0 则线程 T1 或者 T2 等待。如果计数器等于 0,则通知线程 T3,并唤醒等待的线程 T1 或者 T2,与此同时,将计数器重置为 2,这样线程 T1 和线程 T2 生产下一条数据的时候就可以继续使用这个计数器了。
4.使用消息队列和异步优化性能
线程3在check差异之后,由于save操作太耗时,可以将diff丢入一个消息队列中,让其他线程去存库,这样又增大了并行量。
线程安全的数据结构
解决思路:把非线程安全的容器封装在对象内部,然后控制好访问路径。
一个ArrayList例子,SafeArrayList 内部持有一个 ArrayList 的实例 c,所有访问 c 的方法我们都增加了 synchronized 关键字.
SafeArrayList<T>{
// 封装 ArrayList
List<T> 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;
}
}
并发中的设计模式
前面博客写了一些关于并发中常见问题和方法的解答,这里说一些并发中的最佳实践。
避免共享
Immutability模式
使得对象一旦被创建之后,状态就不再发生变化。
需要修改对象时则创建一个新的不可变对象。
可以利用享元模式避免创建重复对象。
Copy-on-Write模式
利用Copy-on-Write 方法解决不可变对象的写操作。
Copy-on-Write是一种延时策略,只有在真正需要复制的时候才复制,而不是提前复制好。
需要注意性能问题
ThreadLocal模式
利用ThreadLocal模式,将变量存储在线程本地
多线程版本 IF 的设计模式
Guarded Suspension
某个条件不满足时则等待这个条件满足时继续执行
get() 方法通过条件变量的 await() 方法实现等待,onChanged() 方法通过条件变量的 signalAll() 方法实现唤醒功能。
Balking 模式
当状态满足某个条件时,执行某个业务逻辑,不满足时直接返回,不会等待。
最经典的方案是对条件变量加锁,如果需要性能的话可以使用双检查锁(单例模式),但一定要注意
分工模式
Thread-Per-Message
收到一个消息(完成某任务的消息)开一个线程委托其处理消息,将收消息逻辑和处理任务的逻辑分开了。
Worker Thread
和Thread-Per-Message有点相似,不过限定了线程的数量,使用线程池的方法来处理。
注意的点
创建有界队列接收任务
注意线程任务中是否有死锁问题
提交到线程池中的任务应该相对独立,没有依赖关系
优雅的关闭线程池
两种方法
1.拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池 java实现 shutdown()
2.会拒绝接收新的任务,同时还会中断线程池中正在执行的任务 java实现shutdownNow()
生产者消费者模式
利用生产者 - 消费者模式实现批量执行。
消费者消费速度应该快于生产者生产速度
分阶段提交(利用消息队列和异步实现处理消息的性能)就利用生产者、消费者模式
极客时间 《java并发编程实战》
Java内存模型(JMM)https://zhuanlan.zhihu.com/p/29881777
图解系统 小林