前言
无论是在Java还是Go中,后端知识系统都需要“高并发”的支持,才能在如今繁冗复杂的场景和应用背景下,成功支持企业和用户的需求。因此,以此夏令营为契机,总结对比一下两种语言中的高并发的差异
并发基础
Java
Java中的并发,我们一般指的是“多线程并发”,简单来说,进程是计算机分配资源的单位,而线程是计算机调度的基本单位。
这也就引出了一个问题,Java的并发是基于线程的,而线程又共享着同一个进程内的所有资源,那么必定会有对资源的访问冲突问题,这就是我们常要处理的死锁、阻塞等等
Go
go的并发采用Communicating Sequential Processes(通信顺序进程)作为基础的通信顺序进程。那么,什么是通信顺序进程呢?
CSP是一种用于描述并发系统中各个进程之间如何进行通信和同步的数学模型,它强调在不同进程之间通过消息传递进行通信,而不是共享内存。这就从本质上与Java有了区分。
CSP模型通过goroutines(轻量级线程)和channels(通道)来实现。每个goroutine都是一个独立的执行单元,它们可以通过channels进行通信和同步。Channels充当了不同goroutines之间数据传递和共享的通道,通过发送和接收消息来实现不同goroutines之间的协作。
通过这种方式,避免传统并发编程中常见的共享内存问题,如竞争条件和死锁。CSP模型强调以消息传递和协作为基础,使得编写并发程序更加安全和清晰。
并发编程基础
Go
在Go语言中,通过使用关键词go就可以非常方便地启动一个协程
那么什么是协程呢,在Go中,协程通常被称为"goroutine",是一种轻量级的并发执行单位,与线程不同,Go的goroutine是由Go运行时系统调度的,而不是由操作系统调度。每个Go程序都有一个主goroutine,而其他的goroutine可以通过关键字go来创建。使用go关键字启动的goroutine会在一个独立的执行环境中并发地运行,而且创建和销毁goroutine的成本非常低。同时,协程之间通过Channel进行通信
例如,我们定义两个方法:
func sum1() {
sum := 0
for i := 1; i <= 3; i++ {
sum += i
}
fmt.Println("线程1输出和", sum)
}
func sum2() {
sum := 0
for i := 3; i <= 5; i++ {
sum += i
}
fmt.Println("线程2输出和", sum)
}
我们首先启动主协程main,然后再其中通过go调用这两个方法:
fmt.Println("启动!")
go sum1()
go sum2()
time.Sleep(3 * time.Second)
fmt.Println("结束!")
经过执行,便可以得到如下结果:
可以看到,协程便启动成功啦
Java
Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。
在Java中创建线程是非常容易的,我们通过Runnable实例的方式,来进行Java中线程的定义:
class MyRunnable1 implements Runnable{
@Override
public void run() {
int sum = 0;
for(int i=1;i<3;++i) sum+=i;
System.out.println("线程1的和为"+sum);
}
}
然后,在main中在定义线程时,传入Runnable实例,如下:
Thread t1 = new Thread(new MyRunnable1());
Thread t2 = new Thread(new MyRunnable2());
最后执行的结果为:
并发数据通信
Go
在Go语言中,通过channel来进行协程之间的信息通信,而关于通道的定义,一般采用如下形式:
intChannel := make(chan string,10)
这句代码中,一下出现了make,chan,10三个关键字。下面我来进行一一介绍
make是一个用于创建切片、映射和通道的内建函数。它的作用是分配并初始化指定类型的数据结构,并返回一个已经准备好的数据结构。make函数的使用有助于避免手动初始化数据结构,以及确保数据结构内部的一些属性被正确设置
感觉上来看,是不是有点像Java里的new操作,在这个函数内部,定义要具体实例化的数据结构等具体信息
chan是通道类型的关键字,用于声明一个通道。通道是用来在不同的goroutine之间传递数据的数据结构。通道可以是同步的,用于协调不同goroutine的执行顺序
string表示通道内传递的数据类型
10表示缓冲队列的长度,针对缓冲队列,详细的定义如下:
无缓冲通道: 要求接受和发送数据的 goroutine 同时准备好,否则将会阻塞.
有缓冲通道: 给予通道一个容量值,只要有值便可以接受数据,有空间便可以发送数据,可以不阻塞的完成.
单向通道: 默认情况通道是双向的,可以接收及发送数据. 也可以创建单向通道
之后,我们定义一个函数,用于发送数据:
func sendData(message chan string) {
fmt.Println("发送消息")
message <- "hello BB"
fmt.Println("结束消息")
}
定义好发送数据的函数之后,用主函数接收数据并且输出:
intChannel := make(chan string)
go sendData(intChannel)
receiveData := <-intChannel
fmt.Println("接收到的数据为:", receiveData)
这样,我们便完成了一个简单的数据通信功能,最后的结果如下:
Java
Java中线程之间的通信通过共享内存和消息队列进行实现
共享内存
我们首先通过lambda语法,定义两个线程,分别表示生产者和消费者:
Thread producer = new Thread(() -> {
for(int i = 0;i<5;i++){
synchronized (sharedList){
sharedList.add(i);
System.out.println("Produced:"+i);
}
}
});
Thread consumer = new Thread(() -> {
synchronized (sharedList){
for (Integer value : sharedList) {
System.out.println("Consumed: " + value);
}
}
});
在使用消息队列时,首先用数组,定义一个共享内存区域
List<Integer> sharedList = new ArrayList<>();
然后使用生产者和消费者进行读写,需要注意的是,synchronized关键字用于创建临界区,确保在同一时间只有一个线程可以进入临界区,从而防止多个线程同时访问共享资源而导致的竞争条件和数据不一致问题,这也是Java并发中的一大特色
消息队列
消息队列可以通过阻塞队列进行实现
阻塞队列,是 Java 中并发编程中的一个接口,位于 java.util.concurrent 包下,用于实现生产者-消费者模型或其他多线程协作的场景。它提供了一种线程安全的队列数据结构,允许多个线程在队列中进行元素的插入和删除操作,同时还支持在队列为空或满时的阻塞操作
它的主要几个方式如下:
put(E element): 将元素插入队列的末尾,如果队列已满,会阻塞等待直到队列有空位。take(): 从队列的头部取出一个元素,如果队列为空,会阻塞等待直到队列有元素。offer(E element): 尝试将元素插入队列的末尾,如果队列已满,立即返回false。poll(): 尝试从队列的头部取出一个元素,如果队列为空,立即返回null。size(): 返回队列中元素的数量。
不难发现,其主要是在线程做一些临界操作时,自动对线程进行阻塞
而且,阻塞队列已经在内部实现中考虑了线程安全和并发访问的问题,因此开发者不需要使用 synchronized 关键字来手动同步。这使得编写多线程程序变得更加简单和可靠
结语
通过对Go语言和Java语言在并发的对待逻辑,以及协程、线程的创建和通信,不难看出这两种语言的区别。Java的线程还是基于OS知识体系下的产物,还维持着古朴的操作系统方式,纯真,但是操作起来也比较复杂。而Go的协程并发机制,感觉是Go语言针对并发做的优化,性能更高的同时,也更加方便编程操作。