雨滴.
雨滴.
技术分享

Java线程和Go协程:同样是并发,为什么写法完全不同?

如果同时接触过 Java 和 Go,很容易发现一个有意思的区别。

聊 Java 并发时,经常会想到 Thread、线程池、ExecutorService、CompletableFuture、synchronized、Lock、AQS——为了控制线程数量,还要考虑线程池该开多大、任务放进哪个池子。到了 Go 这边,事情突然简单很多:想让一个函数并发执行,前面加个 go 就行。

Java:任务 → 线程池 → Thread → OS Thread → 执行
Go:  任务 → goroutine → Go Runtime → OS Thread → 执行

刚学 Go 时很容易有个疑问:Java 有 Thread,Go 有 Goroutine,不都是"线程"吗?严格来说并不是。它们都是并发执行的基本单位,但背后的运行机制不一样,真正理解这一点,很多 Java 和 Go 在并发上的差异就会变得很自然。

一、Java 的 Thread,本质是 OS Thread 的一层抽象

传统 Java 模型里最熟悉的是平台线程(Platform Thread)——它是 JVM 对操作系统线程的封装,运行期间绑定在底层 OS Thread 上,调度最终受操作系统调度器影响。所以以前常说 Java Thread ≈ OS Thread,这个说法不严格,但作为理解传统模型的第一层抽象是够用的。

问题也出在这里:OS 线程不是廉价资源。一个线程要维护自己的栈、寄存器状态、调度信息,数量一多,创建、销毁、调度、上下文切换的成本都会上来。所以 Java 服务端很少"一个请求创建一个 Thread、用完就销毁",而是更常见的"大量请求 → 线程池 → 有限数量的 Thread → 复用处理任务"。这就是为什么 Java 开发者对线程池如此熟悉——线程是相对昂贵的资源,需要被管理。于是问题就变成:线程池到底该开多少线程?CPU 密集型任务不能无限加线程,I/O 密集型任务又希望有更多并发线程。很多 Java 并发编程经验,本质上都是围绕**"有限的线程资源下,怎么尽可能提高并发能力"**这一个问题展开的。

二、Go 为什么敢让你随便创建 Goroutine

Go 的思路不一样。go doSomething() 就能启动一个 Goroutine——它是由 Go Runtime 管理的轻量级并发执行单元,而不是简单地"一条 Goroutine 对应一条 OS Thread"。

Go Runtime 会把大量 Goroutine 调度到少量 OS 线程上运行,官方文档也明确说明:Goroutine 会被复用到多个 OS Thread 上,当某个 Goroutine 因 I/O 阻塞时,其他 Goroutine 仍能继续运行。

Goroutine  Goroutine  Goroutine  Goroutine
    └──────── Go Scheduler ─────────┘
                    ↓
            OS Thread / M(少量)
                    ↓
                   CPU

关键在于:Goroutine 和 OS Thread 不是一一对应的,可能是 1000 个 Goroutine 跑在几十个 OS Thread 上,且这个数字还能继续往上加,底层仍由 Runtime 负责调度。这就带来一个本质区别:Java 传统模型更像"管理线程",Go 模型更像"创建任务,交给 Runtime 调度"。

三、真正的区别:谁负责调度

理解两者,最关键的不是"哪个更轻量",而是到底是谁在调度它们。

传统 Java 平台线程主要依赖操作系统调度:Java Thread → JVM → OS Thread → 操作系统调度器 → CPU。而 Goroutine 中间多了一层 Go Runtime Scheduler:Goroutine → Go Scheduler → OS Thread → CPU。

Go Runtime 实际上承担了一部分原本由操作系统承担的调度工作——调度器负责把处于可运行状态的 Goroutine 分配给工作线程执行(Go 源码里 Scheduler 的职责描述就是"将 ready-to-run 的 Goroutine 分配到 worker threads 上")。这也是 Goroutine 能做得很轻量的原因:不需要每创建一个 Goroutine 就创建一个对应的 OS Thread。

换个角度看:创建 10000 个 Goroutine,并不是对应 10000 个 OS Thread,而是 10000 个 Goroutine 经 Go Runtime Scheduler 调度到有限数量的 OS Thread 上。这意味着开发者可以把注意力放在"我现在有多少个并发任务"上,而不是"我要不要创建这么多线程"——这也是 Go 并发代码给人感觉"很轻"的原因之一。

四、Java 21 之后,这个区别还成立吗

如果只写到这里,已经不够准确了——Java 21 正式引入了虚拟线程(Virtual Thread)。它依然是 Thread,但实现方式和传统平台线程不同:

平台线程:Java Thread → OS Thread
虚拟线程:Virtual Thread → JVM Scheduler → Carrier/OS Thread → CPU

虚拟线程不会在整个生命周期内绑定某一个 OS Thread,执行阻塞 I/O 时会被挂起,让底层的 Carrier Thread 去执行其他虚拟线程。所以到了 Java 21,Thread 分成了 Platform Thread 和 Virtual Thread 两条路。这也是现在讨论 Java 和 Go 并发时容易混淆的地方——Java 已经有了类似 Goroutine 的轻量级并发能力,但两者的设计并不完全相同。

五、虚拟线程和 Goroutine,区别到底在哪

两者确实很像:都能大量创建,都由运行时参与调度,都能避免让每个并发任务长期占用一个 OS Thread。设计目标已经非常接近——让开发者能以较低成本创建大量并发任务,把底层线程调度交给 Runtime。

但不能直接说"Java 虚拟线程就是 Goroutine",它们只是解决了相似的问题,具体的调度器实现、运行时机制、API 和生态设计仍然不同。Java 官方对虚拟线程的定位也很明确:主要解决高吞吐场景下大量并发任务的问题,而不是让单个任务跑得更快。

六、为什么 Go 的并发代码看起来特别自然

这和 Goroutine 本身关系不大,更重要的是 Go 从语言层面就把并发作为核心设计的一部分——go doSomething()、ch <- data、data := <-ch。Go 官方经典的并发思想是:不要通过共享内存来通信,而应该通过通信来共享内存。

这不意味着 Go 完全不用共享内存或锁——sync.Mutex、sync.RWMutex、sync.Once、sync/atomic 一应俱全,只是 Channel、Goroutine 让"任务之间如何协作"变成了语言生态里很自然的一部分。所以很多 Go 并发代码能很自然地表达成"任务 A → Goroutine → Channel → Goroutine → 任务 B",而不是所有事情都围绕"加锁 → 访问共享变量 → 解锁"展开。

七、Java 是不是就只能靠锁

当然不是。除了 synchronized、Lock、ReentrantLock,Java 还有 volatile、Atomic、CAS、ConcurrentHashMap、BlockingQueue、CompletableFuture、Executor、ForkJoinPool、Virtual Thread——Java 更倾向于提供一整套并发工具,让开发者按场景选择不同的抽象。

所以 Java 和 Go 的差别,并不是"Java 会加锁,Go 不加锁",而更接近:"Java 给了你非常丰富的并发工具,让你自己选择并发模型;Go 则从语言和 Runtime 层面,把 Goroutine + Channel 这套模型做得非常自然。"

八、并发 ≠ 并行

无论 Java 还是 Go,这一点都一样。并发意味着多个任务在时间维度上交错推进(A、B、C 共享调度器);并行则意味着多个任务在不同 CPU Core 上真正同时执行。

所以 Goroutine 多,不代表一定有那么多 CPU Core 同时执行;Java 创建很多线程,也不代表这些线程会同时运行。最终能真正并行执行多少计算任务,仍受 CPU 核数和运行时调度策略影响。Go 的 GOMAXPROCS 就是控制同时执行 Go 代码的 CPU 并行度的关键参数,官方 FAQ 也明确提到了 Goroutine 调度器与线程之间的关系。

九、I/O 密集型:两种模型差异最明显的地方

假设一个服务:请求 → 查 MySQL → 调 Redis → 调 HTTP 服务 → 返回结果。真正执行 CPU 计算的时间可能并不长,大量时间都在等待网络、数据库、磁盘。

传统 Java 平台线程模型下,线程发起 I/O 后进入等待,这期间没真正执行业务代码,但这个 OS Thread 仍是需要维护的资源。虚拟线程就是为这种场景设计的:等待 I/O 时挂起,把 Carrier Thread 让给其他任务继续执行。Java 官方也明确指出,虚拟线程特别适合大量并发、主要时间花在等待 I/O 的任务,目标是提高吞吐能力而非让单个线程跑得更快。Goroutine 同样天生适合这种模型:某个 Goroutine 等待 I/O 时,其他 Goroutine 继续执行。所以在高并发 I/O 服务中,两种语言现在其实越来越接近。

十、CPU 密集型:故事又不一样了

图片压缩、视频编码、复杂计算、大规模数据处理这类任务,核心问题已经不是"线程是不是太贵",而是"CPU 到底够不够用"。机器只有 8 核,创建 8000 个 Goroutine 也不会凭空拥有 8000 个核心——大量 CPU 密集型任务最终仍要争夺有限的 CPU 资源。

所以:I/O 密集型场景下,轻量线程模型优势明显;CPU 密集型场景下,核心问题变成 CPU 并行度,线程/Goroutine 数量不是越多越好。这也是为什么"Goroutine 可以创建很多"不能简单理解成"创建越多性能越好"。

一张表说清楚

对比项

Java

Go

传统并发单位

Platform Thread

Goroutine

轻量并发单位

Virtual Thread(21+)

Goroutine

底层 OS Thread

平台线程绑定 OS Thread

多路复用到 OS Thread

调度参与者

OS + JVM

Go Runtime

并发通信

锁、队列、Future 等

Channel + sync 等

线程池

平台线程常用;虚拟线程通常不需要池化

一般不以 Goroutine 池作为基本并发模型

I/O 并发

平台线程成本较高,虚拟线程改善明显

天生适合

CPU 密集型

受 CPU 核数限制

同样受 CPU 核数限制

生态特点

企业级生态成熟

云原生、基础设施领域强

有一个细节值得注意:不要把"Java 有线程池"和"Go 没有线程池"理解成语言能力上的绝对区别——Go 完全可以实现 worker pool,Java 也完全可以不用传统线程池。真正的区别是默认的编程模型不同。

写到最后

以前学 Java 并发时,总觉得线程就是线程:创建一个 Thread,扔进线程池,执行任务。后来接触 Go,第一次看到 go func(){ ... }() 时会觉得这东西好像太随意了。再回头看才发现,Go 并不是把线程"做得更便宜"这么简单,而是把大量并发任务交给 Runtime 管理,让开发者不必时刻盯着底层 OS Thread。

Java 也在变化:从平台线程,到线程池,再到 Java 21 的虚拟线程,"线程是稀缺资源"这套旧思维正在被逐渐改写。所以现在再问"Java Thread 和 Go Goroutine 到底有什么区别",可能已经不能只回答"Thread 是线程,Goroutine 是协程"——更准确的理解是:它们都是并发执行任务的抽象,但背后连接着完全不同的 Runtime 与调度模型。而随着虚拟线程的出现,这两条原本走向不同方向的路,正在"轻量级并发任务"这个点上慢慢靠近。

豫ICP备2026047996号 公安备案 豫公网安备41072702000373号