说明:Java & Go 并发编程序列的文章,根据每篇文章的主题或细分标题,分别演示 Java 和 Go 语言当中的相关实现。更多该系列文章请查看:Java & Go 并发编程系列

说到 Go 语言的并发编程,就不得不说 Go 语言自带的支持并发安全的数据类型channel(通道),这种类型理解和使用起来非常简单,同时也是 Go 语言并发编程思想的重要体现。

<-

channel 分为缓冲通道和非缓冲通道(容量为0)。

本文将从以下维度做一个类比。以下工具都是并发安全的,其中SynchronousQueue 和 LinkedBlockingQueue 均实现了 BlockingQueue 接口。

零容量有限容量
Gounbuffered channelbuffered channel
JavaSynchronousQueueLinkedBlockingQueue

SynchronousQueue VS 非缓冲通道

Go 语言的非缓冲通道,只有在发送操作和接收操作配对上了,发送方和接收方才能得以继续执行,否则将会阻塞在发送或者接收操作。本质上就是以同步的方式来传递数据。这正是 Java 中的 SynchronousQueue 具有的特性。

代码场景:以下代码模拟 A 和 B 两个人参与一项接力任务,由 A 把接力棒交给 B。先用 Java 的方式实现,然后是 Go 语言来实现。

「Java」SynchronousQueue

SynchronousQueue 默认的构造函数,是基于非公平的访问策略。通过指定入参为 true,表示基于公平的访问策略,即最早被阻塞的线程会先获得执行的机会。这也更符合 Go 语言 channel 的特性。

// 注意这里声明了 fair 参数为 true
SynchronousQueue<Integer> queue = new SynchronousQueue<>(true);

// 新起一个线程代表接力成员A
new Thread(() -> {
    System.out.printf("[%s] A准备好接力\n", LocalTime.now());
    try {
        queue.put(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.printf("[%s] A送出接力棒\n", LocalTime.now());
}).start();

// 主线程代表接力成员B
TimeUnit.SECONDS.sleep(1);// B延迟1秒准备接力
System.out.printf("[%s] B准备好接力\n", LocalTime.now());
queue.take();
System.out.printf("[%s] B拿到接力棒\n", LocalTime.now());

运行结果:

[07:43:09.769] A准备好接力
[07:43:10.748] B准备好接力
[07:43:10.748] B拿到接力棒
[07:43:10.749] A送出接力棒

从运行结果可以看出,A送出接力棒的时间依赖于B拿到接力棒的时间。

下面看看 Go 语言的实现方式。

「Go」非缓冲通道(unbuffered channel)

// 初始化一个非缓冲通道
ch := make(chan int)

// 启用一个 goroutine 代表接力成员A
go func() {
    fmt.Printf("[%s] A准备好接力\n", time.Now().Format(time.StampMilli))
    ch <- 1
    fmt.Printf("[%s] A送出接力棒\n", time.Now().Format(time.StampMilli))
}()

// 主 goroutine 代表接力成员B
time.Sleep(time.Second)
fmt.Printf("[%s] B准备好接力\n", time.Now().Format(time.StampMilli))
<-ch
fmt.Printf("[%s] B拿到接力棒\n", time.Now().Format(time.StampMilli))

运行结果:

[Aug  9 07:47:13.806] A准备好接力
[Aug  9 07:47:14.811] B准备好接力
[Aug  9 07:47:14.811] B拿到接力棒
[Aug  9 07:47:14.811] A送出接力棒

同上,A送出接力棒的时间依赖于B拿到接力棒的时间

LinkedBlockingQueue VS 缓冲通道

缓冲通道,顾名思义,就是能起到缓冲作用的数据类型。相对于非缓冲通道发送操作如果没有配对的接收操作则会阻塞的情况,缓冲通道在容量未满的时候允许发送操作发送成功之后立即执行后续的操作而不阻塞。Java 中的 LinkedBlockingQueue 也具有这一特性,从命名来看就是底层基于链表的阻塞队列。

「Java」LinkedBlockingQueue

通过把上面例子的 SynchronousQueue 换成 LinkedBlockingQueue 来看下运行结果,其他代码保持不变。

// 创建一个容量为1的阻塞队列,注意该容量不能为0
LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<>(1);

运行结果:

[08:00:25.439] A准备好接力
[08:00:25.452] A送出接力棒
[08:00:26.417] B准备好接力
[08:00:26.418] B拿到接力棒

从运行结果看出,A送出接力棒的时间不再依赖于B拿到接力棒的时间,这种场景更像是在接力处有一个盒子,A在盒子上放下接力棒就走了,然后B再去盒子上取。而这里的盒子的容量,对应阻塞队列的容量。

下面看看 Go 语言的实现方式。

「Go」缓冲通道(buffered channel)

通过把上面例子的非缓冲通道换成缓冲通道来看下运行结果,其他代码保持不变。

// 初始化一个容量为1的缓冲通道
ch := make(chan int, 1)

运行结果:

[Aug  9 08:19:39.481] A准备好接力
[Aug  9 08:19:39.481] A送出接力棒
[Aug  9 08:19:40.487] B准备好接力
[Aug  9 08:19:40.487] B拿到接力棒

同上,A送出接力棒的时间不再依赖于B拿到接力棒的时间。

特别说明

对于 Go 语言的缓冲通道,当发送操作在执行的时候,正好有等待的接收操作,且此时通道是空的,那么它会直接把元素值复制给接收方。

这个特性不是 LinkedBlockingDeque 所具有的。从这个意义来讲, Java 中的另一个阻塞队列 LinkedTransferQueue 更符合 Go 语言的缓冲通道的这个特性。但由于 LinkedTransferQueue 是无界队列,无法设置队列的大小,这一点跟 Go 语言的缓冲通道有着本质上的区别,所以本文依然采用特性更加接近的 LinkedBlockingDeque 来做类比。

下一篇将会进一步介绍阻塞队列与通道的一些基本操作:
2.阻塞队列与通道——BlockingDeque VS channel(下篇)

更多该系列文章请查看:Java & Go 并发编程系列