JUC《Java并发编程实战》重点

绪论

其实并发编程可以总结为三个核心问题:分工、同步、互斥。

  • 分工指的是如何高效地拆解任务并分配给线程。即如何分工。
  • 同步指的是线程之间如何协作、通信。即如何合作。解决协作问题的核心技术是管程
  • 互斥则是保证同一时刻只允许一个线程访问共享资源。即如何解决冲突。解决线程安全问题的核心方案还是互斥,即加锁。导致不确定的主要源头是可见性问题有序性问题原子性问题,为了解决这三个问题,Java 语言引入了内存模型。除此之外还有无锁方案。

并发理论基础

1.可见性、原子性和有序性问题:并发编程Bug的源头

CPU、内存、I/O 设备的技术不断在快速迭代,但有一个核心矛盾一直存在,就是这三者的速度差异。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构操作系统编译程序都做出了贡献,主要体现为:

  1. CPU 增加了缓存,以均衡CPU与内存的速度差异;
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  3. 编译程序优化指令执行次序,使得从而更加合理地利用缓存

同时以上三个优化也导致了以下三个问题

  1. 缓存导致了可见性问题。CPU自己的缓存和内存不同步。
  2. 线程切换导致了原子性问题
  3. 编译优化导致了有序性问题

2.Java内存模型:看Java如何解决可见性和有序性问题

Java内存模型解决了可见性问题和有序性问题。既然导致可见性的原因是缓存,导致有序性的原因是编译优化,合理的方案应该是按需禁用缓存以及编译优化

如何按需禁用,Java的方案是 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before 规则

Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的

比较正式的说法是:Happens-Before 约束了编译器的优化行为

以下是和程序员相关的六条规则:

  1. 顺序性同一个线程中,前面的结果对后面可见。在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
  2. 传递性不同线程间,Happens-Before具有传递性。如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。这一条很重要,意味着如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。
  3. volatile 变量规则不同线程间,volatile 的写对读可见。对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
  4. synchronized规则不同线程间,同一个锁解锁操作对后续的加锁可见。即对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。管程中的锁在 Java 里是隐式实现的,加锁以及释放锁都是编译器帮我们实现的。
  5. 线程 start() 规则:这条是关于线程启动的。执行start()之前的共享变量结果对被start()的线程可见。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
  6. 线程 join() 规则:这条是关于线程等待的。join()线程的结果对主线程的后续操作可见。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

面向JVM的规则还有两个:

  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

3.互斥锁(上):解决原子性问题

锁和受保护资源的关系受保护资源和锁之间的关联关系是 N:1 的关系可以一把锁保护多个资源,但不能多把锁保护一个资源。这里把锁看作是访问资源的途径会更好理解一点。

4.互斥锁(下):如何用一把锁保护多个资源?

细粒度锁用不同的锁对受保护资源进行精细化管理,能够提升性能

要保证锁能覆盖所有受保护资源,特别是保护有关联关系的多个资源时。

解决原子性问题,本质是要保证中间状态对外不可见

5.死锁

死锁问题:两个线程都各自拿了一把锁,然后等对方释放。

Coffman总结了发生死锁的四个条件

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;规则:所有的盘子只能一个人用
  2. 占有等待,即占有且等待。线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;规则:拿了一个盘子就一定要等着,直到拿到所有的剩下盘子。即拿了就不放。
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;规则:别人拿了就不能抢
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。情景:相互在等待对方的盘子

解决死锁问题:互斥条件肯定没法破坏,因为我们用锁就是为了互斥。

  1. 对于“占有且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。即一次拿完,那占有后就无需等待。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。不给循环等待的机会。

6.用“等待-通知”机制优化循环等待

用 synchronized 实现等待 - 通知机制:使用Java 语言内置的 synchronized 配合 wait()notify()notifyAll() 即可。

建议用以下经典的代码范式,因为线程被唤醒时,只是表示条件曾经满足过,所以要再次检验条件满不满足:

while(条件不满足) {
    wait();
  }

解锁时尽量使用notifyAll()。

7.安全性、活跃性以及性能问题

  • 安全性问题:用锁解决。或者无锁方案,如线程本地存储(Thread Local Storage,TLS)、不变模式等等。
  • 活跃性问题:死锁、活锁、饥饿(使用公平锁)。
  • 性能问题:“锁”的过度使用可能导致串行化的范围过大。优化思路是无锁、减少锁持有时间(细粒度锁,分段锁等)。

串行对性能的影响:

阿姆达尔(Amdahl)定律,(1-p)就是串行百分比了,也就是我们假设的 5%。我们再假设 CPU 的核数(也就是 n)无穷大,那加速比 S 的极限就是 20。

image-20201023164820021

性能的指标:

  1. 吞吐量:指的是单位时间内能处理的请求数量。单位“个/秒”。吞吐量越高,说明性能越好。
  2. 延迟:指的是从发出请求到收到响应的时间。单位“秒”。延迟越小,说明性能越好。
  3. 并发量:指的是能同时处理的请求数量。单位:“个”。一般来说随着并发量的增加、延迟也会增加(因为排队的人多了?)。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。

8.管程

管程和信号量是等价的

管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发

管程有三个模型:Hasen 模型Hoare 模型MESA 模型

MESA 模型:

  • 互斥问题:将共享变量及其对共享变量的操作统一封装起来。

  • 同步问题:使用两个队列:

    • 入口等待队列:当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
    • 条件变量等待队列:管程里引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。线程进入管程后,条件不满足时进入等待队列等待。条件满足后接到notifyAll(),重新进入入口等待队列。(因为可能会被其他线程敲定,所以只是表示条件曾经满足过,要重新到入口等待队列排队)。

    image-20201027104515269

synchronized以及wait()notify()notifyAll()就相当于是只有一个条件变量的MESA模型。

MESA 管程编程范式:

while(条件不满足) {
  wait();
}

三种管程的区别:

  • Hasen 模型:线程A比较自私。线程A自己执行完再通知线程B。即notify()放代码最后一行。此时可以保证一个线程执行。
  • Hoare 模型:线程A是老好人。线程A立即通知线程B,自己阻塞,线程B立刻执行。线程B执行完后唤醒线程A。此时可以保证一个线程执行。但比Hasen模型多一次阻塞唤醒。
  • MESA 模型:线程A比较灵活。线程A通知线程B,然后线程A继续自己玩自己的。线程B从条件变量等待队列进入到入口等待队列,可以重新排队啦。此时可以保证一个线程执行。好处是编程灵活,不用一定要把notify()放最后一行。缺点是线程B重新执行时,条件就不一定满足了,所以要用前面的while()范式循环检测条件变量。另外MESA多了notifyAll()的便利,但是缺点时前两个模式线程都会被执行到,而MESA由于一直等待,不一定会立刻被执行到。所以多了带超时时间的wait()。

9.Java线程(上):Java线程的生命周期

通用的线程生命周期基本上可以用五态模型”来描述。分别是:初始状态、可运行状态、运行状态、休眠状态终止状态

image-20201027144421557

对比上图通用生命周期,Java生命周期简化如下,分为6种:

img

10.Java线程(中):创建多少线程合适?

性能有两个核心指标:一个是时间维度延迟,另外一个是空间维度吞吐量

操作系统层面已经解决了单一的硬件设备的利用率问题,而我们的并发程序,往往需要 CPU 和 I/O 设备相互配合工作,也就是说,我们需要解决 CPU 和 I/O 设备综合利用率的问题综合利用率的问题,操作系统虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程

创建多少线程合适?

  • 对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,用于当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
  • 对于 I/O 密集型的计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,我们可以总结出这样一个公式:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)。针对多核,**最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]**。具体工程上,我们要估算这个比例,然后做各种不同场景下的压测来验证我们的估计。压测时,我们需要重点关注 CPU、I/O 设备的利用率和性能指标(响应时间、吞吐量)之间的关系。

11.Java线程(下):为什么局部变量是线程安全的

方法里的局部变量,因为不会和其他线程共享。

ThreadLocal原理?待补充

12.如何用面向对象思想写好并发程序(略)

13.总结串讲(略)

并发工具类

14.Lock和Condition(上):隐藏在并发包中的管程

本篇主要介绍Lock的使用。

Java SDK 并发包最核心的还是其对管程的实现。

Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题

Java 语言本身提供的 synchronized 也是管程的一种实现,那为什么我们还要“重复”造轮子呢?

  • Lock 比起 synchronized:
    1. 可以防止死锁,破坏不可抢占条件。如果申请不到,可以主动释放它占有的资源。
      • 非阻塞地获取锁直接不等待,之后可以选择自己放弃锁
      • 支持超时等待时可以自己醒,之后可以选择自己放弃锁
      • 能够响应中断等待时可以被唤醒,之后可以选择自己放弃锁
    2. 管程模型中,可以有多个condition

Lock编程范式要遵守try{} finally{},在finally里释放锁:

class X {
  private final Lock rtl =
  new ReentrantLock();
  int value;
  public void addOne() {
    // 获取锁
    rtl.lock();  
    try {
      value+=1;
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
}

三个用锁的最佳实践,它们分别是:

  1. 永远只在更新对象的成员变量时加锁
  2. 永远只在访问可变的成员变量时加锁
  3. 永远调用其他对象的方法时加锁

15.Lock和Condition(下):Dubbo如何用管程实现异步转同步?

这一节讲Java SDK 并发包里的 Condition,Condition 实现了管程模型里面的条件变量

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();
    }  
  }
}

同步和异步的区别:通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步

16.Semaphore:信号量:如何快速实现一个限流器

Semaphore:信号量

  • **init()*:设置计数器的初始值。表示能让几个线程同时进入临界区,互斥则是1*。
  • *down()*:计数器的值减 1(进入临界区*);如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。P操作。*
  • *up()*:计数器的值加 1(离开临界区*);如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。V操作。*

17.ReadWriteLock:读写锁

18.StampedLock :读写锁 + 乐观读

  • 乐观读原理:获取一个版本号,看版本号有没有被修改,没有修改则读取的结果有效。有修改则重新读或升级悲观读锁
  • 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。

19.CountDownLatch和CyclicBarrier:线程池版join

  • 主要用来做线程间的通信。实现类似 threadObj.join()的功能。因为用线程池的时候是没有join()方法的。

20.并发容器

同步容器所有方法都用synchronized 来保证互斥,串行度太高了。所以1.5后使用了并发容器。

并发容器也是包括List、Map、Set 和 Queue四大类。下图基本包括了所有常用的并发容器:

image-20201111225045079

  • List:CopyOnWriteArrayList。CopyOnWrite,就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁要求使用时能够容忍读写的短暂不一致。因为写入的新元素并不能立刻被遍历到。
  • Map :ConcurrentHashMapConcurrentSkipListMap。前者无序,后者有序。它们的 key 和 value 都不能为空
  • Set :CopyOnWriteArraySetConcurrentSkipListSet。前者无序,后者有序。
  • Queue:阻塞与非阻塞单端与双端两个维度,分为4类Queue。Java 并发包里阻塞队列都用 Blocking关键字标识,单端队列使用 Queue 标识,双端队列使用 Deque 标识

单端阻塞队列

内部一般会持有一个队列,常用的以下6个:

  • ArrayBlockingQueue:队列是数组。支持有界
  • LinkedBlockingQueue:队列是链表。支持有界
  • SynchronousQueue:不持有队列,此时生产者线程的入队操作必须等待消费者线程的出队操作。
  • LinkedTransferQueue:融合 LinkedBlockingQueue 和 SynchronousQueue 。性能比 LinkedBlockingQueue 更好。
  • PriorityBlockingQueue :支持按照优先级出队。
  • DelayQueue :支持延时出队。

双端阻塞队列:实现是LinkedBlockingDeque

单端非阻塞队列:实现是 ConcurrentLinkedQueue

双端非阻塞队列:实现是 ConcurrentLinkedDeque

实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致 OOM。以上 Queue 中,只有 ArrayBlockingQueue 和 LinkedBlockingQueue是支持有界的。

在选对容器时,甚至根本不会触发Java 容器的快速失败机制(Fail-Fast)

扩展:Java7中的HashMap在执行put操作时会涉及到扩容,由于扩容时链表并发操作会造成链表成环,所以可能导致cpu飙升100%。

21.原子类:无锁工具类的典范

解决简单的可见性和原子性问题,除了使用volatilesynchronized互斥锁方案外,还可以使用无锁方案

JUC将这些无锁方案进行提炼后,实现了一系列的原子类

无锁方案的实现原理:就是CPU的硬件支持。CPU提供了 CAS 指令(CAS,全称是 Compare And Swap,即“比较并交换”)来解决并发问题。作为一条 CPU 指令,CAS 指令本身能够保证原子性

ABA问题:一般问题不大。

Java如何实现CAS:调用unsafe.getAndAddLong(this, valueOffset, 1L),进一步调用native boolean compareAndSwapLong(Object o, long offset, long expected, long x)

原子类概览

JUC的原子类可分为五类:

  • 原子化的基本数据类型
  • 原子化的对象引用类型
  • 原子化数组
  • 原子化对象属性更新器
  • 原子化的累加器

它们提供的方法是类似的。以下是概览图

image-20201112110154330

22.Executor与线程池:如何创建正确的线程池?

线程池是一种生产者 - 消费者模式:线程池的使用方是生产者,线程池本身是消费者。

原理概述:在 MyThreadPool 的内部,我们维护了一个阻塞队列 workQueue一组工作线程,工作线程的个数由构造函数中的 poolSize 来指定。用户通过调用 execute() 方法来提交 Runnable 任务,execute() 方法的内部实现仅仅是将任务加入到 workQueue 中。MyThreadPool 内部维护的工作线程会消费 workQueue 中的任务并执行任务

包括:核心线程数,最大线程数,超时时间,线程工厂,阻塞队列,拒绝策略。

  • corePoolSize:核心线程数,即最小线程数。
  • maximumPoolSize:最大线程数。
  • keepAliveTime & unit:超时时间及单位。即线程空闲多久被回收。
  • workQueue:同前文的工作队列。
  • threadFactory:线程工厂,自定义如何创建线程,例如给他们取名字。
  • handler:拒绝策略。当所有线程都有活,且工作队列已满(有界工作队列),此时提交任务,线程池如何拒绝。总共4种:
    • CallerRunsPolicy:提交任务的线程自己去执行该任务。
    • AbortPolicy:默认的拒绝策略,直接抛异常 throws RejectedExecutionException。
    • DiscardPolicy:直接丢弃任务,不抛异常。
    • DiscardOldestPolicy:丢弃最老的任务,就是把最早进入工作队列的任务丢弃,再把新任务加入到工作队列。

Java 在 1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时。

JUC提供了 Executors快速创建线程池。不过不建议使用 Executors 创建,最重要的原因是:Executors 提供的很多方法默认使用的都是无界的 LinkedBlockingQueue。

线程池默认的拒绝策略会 throw RejectedExecutionException 这是个运行时异常,对于运行时异常编译器并不强制 catch 它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。在实际工作中,自定义的拒绝策略往往和降级策略配合使用。

使用线程池,还要注意异常处理的问题。待补充……

23.Future:获取任务执行结果

线程池处理:

  • 无需结果时:直接提交ThreadPoolExecutorexecute()即可。

  • 需要结果时:通过 ThreadPoolExecutor 提供的 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);
      
    • Future接口:

      // 取消任务
      boolean cancel(boolean mayInterruptIfRunning);
      // 判断任务是否已取消  
      boolean isCancelled();
      // 判断任务是否已结束
      boolean isDone();
      // 获得任务执行结果
      get();
      // 获得任务执行结果,支持超时
      get(long timeout, TimeUnit unit);
      

    FutureTask 工具类:FutureTask 实现了 RunnableFuture 接口,可同时作为执行任务和结果。

24.CompletableFuture:异步编程

CompletableFuture 类实现了 Future 接口和 CompletionStage 接口

  • 线程间的分工协作可由CompletionStage 接口来完成。
  • 获取结果则可以通过 Future 接口来解决。

创建CompletableFuture:

// 使用默认的forkJoin线程池
static CompletableFuture<Void> runAsync(Runnable runnable)	      // Runnable 接口的 run() 方法没有返回值
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) // Supplier 接口的 get() 方法有返回值
    
// 也可以指定线程池  
static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)  

描述串行关系:主要是 thenApply、thenAccept、thenRun 和 thenCompose 这四个系列的接口,几个系列方法的命名其实就是根据参数的执行方法名来命名的。参数分为三种,Funtion、Consumer、Runnable,分别表示有入参有返回有入参无返回无入参无返回三种情况。异常处理支持链式编程方式。

25.CompletionService:批量执行异步任务

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;

26.Fork/Join:解决分治场景

前文中,对于简单的并行任务,可以通过“线程池 +Future”的方案来解决;如果任务之间有聚合关系,无论是 AND 聚合还是 OR 聚合,都可以通过 CompletableFuture 来解决;而批量的并行任务,则可以通过 CompletionService 来解决。

Fork/Join 计算框架主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTaskFork 对应的是分治任务模型里的任务分解,Join 对应的是结果合并

ForkJoinPool 内部有多个任务队列。ForkJoinPool 支持一种叫做“任务窃取”的机制。任务队列采用的是双端队列

默认情况下所有的并行流计算都共享一个 ForkJoinPool,建议用不同的 ForkJoinPool 执行不同类型的计算任务

Java几种任务函数Runnable、Callable、Consumer、Fucntion,分别是无参无返回、无参有返回、有参无返回、有参有返回。*

其他并发模型

  • Actor模型:并发问题的根源就在于共享变量,而 Actor 模型中 Actor 之间不共享变量。 Java 语言本身并不支持 Actor 模型,想在 Java 语言里使用 Actor 模型,需要借助类库,如Akka。Actor 模型和面向对象编程契合度非常高,完全可以用 Actor 类比面向对象编程里面的对象,而且 Actor 之间的通信方式完美地遵守了消息机制,而不是通过对象方法来实现对象之间的通信。Actor 中的消息机制,可以类比这现实世界里的写信。Actor 内部有一个邮箱(Mailbox),接收到的消息都是先放到邮箱里,如果邮箱里有积压的消息,那么新收到的消息就不会马上得到处理。不保证消息送达的顺序和发送的顺序是一致的,甚至无法保证消息会被百分百处理
  • 软件事务内存:借鉴MVCC,也就是多版本并发控制。缺点是 I/O 操作是很难支持回滚的
  • 协程:Golang。从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。协程也有自己的栈,但是相比线程栈要更小,典型的线程栈大小差不多有 1M,而协程栈的大小往往只有几 K 或者几十 K。Java OpenSDK 中 Loom 项目的目标就是支持协程,未来Java也将支持协程。
  • CSP模型:Golang的另一个模型。

Golang 提供了两种不同的方案解决线程/协程间的协作问题:

  • 一种方案支持协程之间以共享内存的方式通信,Golang 提供了管程和原子类来对协程进行同步控制,这个方案与 Java 语言类似;
  • 另一种方案支持协程之间以消息传递(Message-Passing)的方式通信,本质上是要避免共享,Golang 的这个方案是基于CSP(Communicating Sequential Processes)模型实现的。Golang 比较推荐的方案是后者。

Golang 程序员中有句格言:“不要以共享内存方式通信,要以通信方式共享内存

CSP 模型与 Actor 模型的区别

  • Actor 模型中没有 channel。Actor 模型中的 mailbox 对于程序员来说是“透明”的,mailbox 明确归属于一个特定的 Actor。 Actor 之间是可以直接通信的,不需要通信中介。
  • Actor 模型中发送消息是非阻塞的,而 CSP 模型中是阻塞的。当阻塞队列已满的时候,向 channel 中发送数据,会导致发送消息的协程阻塞。
  • Actor 模型理论上不保证消息百分百送达,而在 Golang 实现的CSP 模型中,是能保证消息百分百送达的。不过这种百分百送达也是有代价的,那就是有可能会导致死锁

Java 领域可以借助第三方的类库JCSP来支持 CSP 模型,不过JCSP 并没有经过广泛的生产环境检验,所以并不建议在生产环境中使用。

CSP 模型是托尼·霍尔(Tony Hoare)在 1978 年提出的,霍尔在并发领域还有一项重要成就,那就是提出了霍尔管程模型,Java 领域解决并发问题的理论基础就是它。


转载请注明来源