极客时间:Java并发编程实战
1 Actor模型:面向对象原生的并发模型
- 概念
Actor模型本质上是一种计算模型,基本的计算单元称为Actor,Actor之间通过消息进行通信;
在Actor模型中,所有的计算都是在Actor中执行的。
在面向对象编程里面,一切都是对象;
在Actor模型里,一切都是Actor,并且Actor之间是完全隔离的,不会共享任何变量。
在Java领域,除了可以使用Akka来支持Actor模型外,还可以使用Vert.x,不过相对来说Vert.x更像是Actor模型的隐式实现,对应关系不像Akka那样明显,不过本质上也是一种Actor模型。
Actor可以创建新的Actor,这些Actor最终会呈现出一个树状结构,非常像现实世界里的组织结构,所以利用Actor模型来对程序进行建模,和现实世界的匹配度非常高。
Actor模型和现实世界一样都是异步模型,理论上不保证消息百分百送达,也不保证消息送达的顺序和发送的顺序是一致的,甚至无法保证消息会被百分百处理。
虽然实现Actor模型的厂商都在试图解决这些问题,但遗憾的是解决得并不完美,所以使用Actor模型也是有成本的。
- 消息和对象方法的区别
Actor内部有一个邮箱(Mailbox)
接收到的消息都是先放到邮箱里,如果邮箱里有积压的消息,那么新收到的消息就不会马上得到处理
也正是因为Actor使用单线程处理消息,所以不会出现并发问题
你可以把Actor内部的工作模式想象成只有一个消费者线程的生产者-消费者模式
所以,在Actor模型里,发送消息仅仅是把消息发出去而已,接收消息的Actor在接收到消息后,也不一定会立即处理
也就是说Actor中的消息机制完全是异步的。而调用对象方法,实际上是同步的,对象方法return之前,调用方会一直等待
除此之外,调用对象方法,需要持有对象的引用,所有的对象必须在同一个进程中
而在Actor中发送消息,类似于现实中的写信,只需要知道对方的地址就可以,发送消息和接收消息的Actor可以不在一个进程中,也可以不在同一台机器上。因此,Actor模型不但适用于并发计算,还适用于分布式计算。
- 案例:实现累加器
//累加器
static class CounterActor extends UntypedActor {
private int counter = 0;
@Override
public void onReceive(Object message){
//如果接收到的消息是数字类型,执⾏累加操作,
//否则打印counter的值
if (message instanceof Number) {
counter += ((Number) message).intValue();
} else {
System.out.println(counter);
}
}
}
public static void main(String[] args) throws InterruptedException {
//创建Actor系统
ActorSystem system = ActorSystem.create("HelloSystem");
//4个线程⽣产消息
ExecutorService es = Executors.newFixedThreadPool(4);
//创建CounterActor
ActorRef counterActor = system.actorOf(Props.create(CounterActor.class));
//⽣产4*100000个消息
for (int i = 0; i < 4; i++) {
es.execute(() -> {
for (int j = 0; j < 100000; j++) {
//发送1这个值给onReceive
counterActor.tell(1, ActorRef.noSender());
}
});
}
//关闭线程池
es.shutdown();
//等待CounterActor处理完所有消息
Thread.sleep(1000);
//打印结果
counterActor.tell("", ActorRef.noSender());
//关闭Actor系统
system.shutdown();
}
2 软件事务内存:借鉴数据库的并发经验
使用MVCC实现的STM来实现线程安全
手写STM:操作的对象类、当前事务类、操作当前事务的类、实现方法类
VersionedRef
作用:将对象value包装成带版本号的对象;
按照MVCC理论,数据的每一次修改都对应着一个唯一的版本号,所以不存在仅仅改变value或者version的情况,用不变性模式就可以很好地解决这个问题,所以VersionedRef这个类被我们设计成了不可变的
//带版本号的对象引用
public final class VersionedRef<T> {
final T value;
final long version;
//构造方法
public VersionedRef(T value, long version) {
this.value = value;
this.version = version;
}
}
TxnRef:
作用:负责完成事务内的读写操作(所有对数据的读写操作,一定是在一个事务里面)
读写操作委托给了接口Txn
//支持事务的引用
public class TxnRef<T> {
//当前数据,带版本号
volatile VersionedRef curRef;
//构造方法
public TxnRef(T value) {
this.curRef = new VersionedRef(value, 0L);
}
//获取当前事务中的数据
public T getValue(Txn txn) {
return txn.get(this);
}
//在当前事务中设置数据
public void setValue(T value, Txn txn) {
txn.set(this, value);
}
}
Txn:
TxnRef的读写操作委托给了接口Txn,Txn代表的是读写操作所在的当前事务, 内部持有的curRef代表的
是系统中的最新值
public interface Txn {
<T> T get(TxnRef<T> ref);
<T> void set(TxnRef<T> ref, T value);
}
STMTxn:
//事务接口
//STM事务实现类
public final class STMTxn implements Txn {
//事务ID生成器
private static AtomicLong txnSeq = new AtomicLong(0);
//当前事务所有的相关数据(保存当前事务中所有读写的数据的快照)
private Map<TxnRef, VersionedRef> inTxnMap = new HashMap<>();
//当前事务所有需要修改的数据(保存当前事务需要写入的数据)
private Map<TxnRef, Object> writeMap = new HashMap<>();
//当前事务ID
private long txnId;
//构造函数,自动生成当前事务ID
STMTxn() {
txnId = txnSeq.incrementAndGet();
}
//获取当前事务中的数据:
//将需要读取的数据加入inTxnMap,同时保证每次读取的数据都是一个版本
@Override
public <T> T get(TxnRef<T> ref) {
if (!inTxnMap.containsKey(ref)) {
inTxnMap.put(ref, ref.curRef);
}
return (T) inTxnMap.get(ref).value;
}
//在当前事务中修改数据
//将要写入的数据放入writeMap,但如果写入的数据没被读取过,也会将其放入 inTxnMap
@Override
public <T> void set(TxnRef<T> ref, T value) {
if (!inTxnMap.containsKey(ref)) {
inTxnMap.put(ref, ref.curRef);
}
writeMap.put(ref, value);
}
//提交事务
//为了简化实现,使用了互斥锁,所以事务的提交是串行的
//首先检查inTxnMap中的数据是否发生过变化,如果没有发生变化,那么就将writeMap中的数据写入(这里的写入其实就是TxnRef内部持有的curRef);如果发生过变化,那么就不能将writeMap中的数据写入了
boolean commit() {
synchronized (STM.commitLock) {
//是否校验通过
boolean isValid = true;
//校验所有读过的数据是否发生过变化
for (Map.Entry<TxnRef, VersionedRef> entry : inTxnMap.entrySet()) {
VersionedRef curRef = entry.getKey().curRef;
VersionedRef readRef = entry.getValue();
//通过版本号,即事务id来验证数据是否发生过变化
if (curRef.version != readRef.version) {
isValid = false;
break;
}
}
//如果校验通过,则所有更改生效
if (isValid) {
writeMap.forEach((k, v) -> {k.curRef = new VersionedRef(v, txnId);
});
}
return isValid;
}
}
}
实现Multiverse中的原子化操作atomic():使用了类似于CAS的操作,如果事务提交失败,那么就重新创建一个新的事务,重新执行。
@FunctionalInterface
public interface TxnRunnable {
void run(Txn txn);
}
//STM
public final class STM {
//私有化构造方法
private STM() {
//提交数据需要用到的全局锁
static final Object commitLock = new Object();
//原子化提交方法
public static void atomic (TxnRunnable action){
boolean committed = false;
//如果没有提交成功,则一直重试
while (!committed) {
//创建新的事务
STMTxn txn = new STMTxn();
//执行业务逻辑,由使用者编写
action.run(txn);
//提交事务
committed = txn.commit();
}
}
}
}
使用:
class Account {
//余额
private TxnRef<Integer> balance;
//构造方法
public Account(int balance) {
this.balance = new TxnRef<Integer>(balance);
}
//转账操作
public void transfer(Account target, int amt){
STM.atomic((txn)->{
Integer from = balance.getValue(txn);
balance.setValue(from-amt, txn);
Integer to = target.balance.getValue(txn);
target.balance.setValue(to+amt, txn);
});
}
}
如果想增加方法,直接在STM类里,仿照atomic方法进行编写业务即可
3 协程:更轻量级的线程
可以把协程简单地理解为一种轻量级的线程。
从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。
协程虽然也有自己的栈,但是相比线程栈要小得多,典型的线程栈大小差不多有1M,而协程栈的大小往往只有几K或者几十K。所以,无论是从时间维度还是空间维度来看,协程都比线程轻量得多。
4 CSP模型:Golang的主力队员
Golang解决协程协作问题的两种不同的方案:
一种方案支持协程之间以共享内存的方式通信,Golang提供了管程和原子类来对协程进行同步控制,这个方案与Java语言类似;
另一种方案支持协程之间以消息传递(Message-Passing)的方式通信,本质上是要避免共享,Golang的这个方案是基于CSP(CommunicatingSequential Processes)模型实现的,比较推荐使用
“不要以共享内存方式通信,要以通信方式共享内存”,即方案二
CSP模型类似生产者-消费者模型,其中的队列概念可以用channel来实现
容量为0的channel在Golang中被称为无缓冲的channel,容量大于0的则被称为有缓冲的channel
无缓冲的channel类似于Java中提供的SynchronousQueue,主要用途是在两个协程之间做数据交换
- 与Actor对比
相同:以消息传递的方式来避免共享
不同:
第一个最明显的区别就是:Actor模型中没有channel。虽然Actor模型中的 mailbox 和 channel 非常像,看上去都像个FIFO队列,但是区别还是很大的。Actor模型中的mailbox对于程序员来说是“透明”的,mailbox明确归属于一个特定的Actor,是Actor模型中的内部机制;而且Actor之间是可以直接通信的,不需要通信中介。但CSP模型中的 channel 就不一样了,它对于程序员来说是“可见”的,是通信的中介,传递的消息都是直接发送到 channel 中的。
第二个区别是:Actor模型中发送消息是非阻塞的,而CSP模型中是阻塞的。Golang实现的CSP模型,channel是一个阻塞队列,当阻塞队列已满的时候,向channel中发送数据,会导致发送消息的协程阻塞。
第三个区别则是关于消息送达的。Actor模型理论上不保证消息百分百送达,而在Golang实现的CSP模型中,是能保证消息百分百送达的。不过这种百分百送达也是有代价的,那就是有可能会导致死锁。