MelonBlog

java线程和go语言的goroutine的差异

java和go我平常都经常会写,这两种语言各有各的特点。java的优势就是生态好,而且java每次升级都有一些惊喜,例如最新的java21,更新了一种轻量级线程特性,对标go的goroutine,持续迭代实用的特性是java长久不衰最重要的原因之一。

今天讲讲goroutine比java线程好在哪里,也是为什么java21要出一个轻量级线程的原因。

Java Thread

java的线程是一对一映射内核线程,每创建一个java线程,都需要再操作系统层面,创建一个内核线程。并且java线程的调度,完全依赖操作系统的调度策略。

image


这种依赖内核线程和操作系统调度的实现方式有如下问题:

每创建一个内核线程,都要为这个线程分配栈空间,如果内存或SWAP区的空间不够大,能够创建的线程是有限的,虽然可以根据-Xss参数设置这个栈的大小,但是过少的栈空间,很容易引发程序的stack over flow异常。
如果线程数量比较多,上下文切换(Context Switch)的开销非常大,因为操作系统的调度器会尽可能的公平的为每一个线程分配时间片,所以会在这么多线程中来回切换,切换的时候,会把当前线程的所有状态都保存到栈里,然后读取要切换的线程的状态到寄存器。如果这种切换非常频繁,那么cpu可能会花费大量时间执行切换操作,而不是执行程序的代码。

Goroutine

和java线程不同的是,goroutine和内核线程不是一对一的映射关系。go实现了自己的调度器,然后将一个内核线程映射一组goroutine,java线程和内核线程是N比N的关系,goroutine则是N比M的关系,M可以远远大于N。

image

如上图所示,每一个内核线程都映射了一个goroutine队列,这个队列是一个FIFO队列。每一个线程都会每隔10ms将当前执行的goroutine放回队列,然后从队列里读取一个新的goroutine,这样就完成了一次公平的切换操作。

线程不仅仅可以从自己的goroutine队列里读取goroutine,还可以读取全局队列里面的goroutine,甚至还可以读取其他线程的goroutine,这个操作称为work-stealing

go使用GOMAXPROCS参数来设置线程的数量,也就是N比M中N的数量,默认值为本机的核心数量。

动态伸缩的栈空间

和java中使用的固定大小的栈空间策略不一样的是,go最开始给每一个goroutine分配4k的空间,如果goroutine运行过程中要使用超过4k的空间,go会动态的扩容这个goroutine的占空间。

动态创建线程

你可能会想到,如果某个线程因为系统调用被阻塞了,例如读取磁盘操作,那整个goroutine队列不都被阻塞住了吗?

image

上图中的场景就是,大部分内核线程都被系统调用阻塞住了,只有一个内核线程是可执行的,这种情况下程序的性能肯定会大打折扣。


go设计一个一个动态创建线程的特性来防止上面的情况出现。当一个线程被阻塞了,go会创建一个新的线程来继续处理这个被阻塞线程对应的goroutine队列,并且会立即把执行系统调用线程的控制权交给这个新创建的线程。

image

如果你是华生,你一定发现了盲点。如果一个go程序大量使用System Call,那这个程序会创建大量的内核线程,导致goroutine会降级成java thread。

总结

对于java线程,goroutine在绝大部分场景下,确实会轻量级很多,低成本的上下文切换和动态的栈空间使goroutine在应付绝大部分常规的并发场景都非常完美,虽然某些极端情况下,例如大量的系统调用场景,会导致goroutine失去它的优势,但是总体来讲,goroutine是一个非常好的并发编程解决方案。这也是为什么java21要在上轻量级线程的原因,善于学习其他语言的优秀特性相比于固步自封,更加值得点赞。




参考:https://medium.com/@genchilu/javas-thread-model-and-golang-goroutine-f1325ca2df0c