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 没有线程池"理解成语言能力上的绝对区别——Go 完全可以实现 worker pool,Java 也完全可以不用传统线程池。真正的区别是默认的编程模型不同。
写到最后
以前学 Java 并发时,总觉得线程就是线程:创建一个 Thread,扔进线程池,执行任务。后来接触 Go,第一次看到 go func(){ ... }() 时会觉得这东西好像太随意了。再回头看才发现,Go 并不是把线程"做得更便宜"这么简单,而是把大量并发任务交给 Runtime 管理,让开发者不必时刻盯着底层 OS Thread。
Java 也在变化:从平台线程,到线程池,再到 Java 21 的虚拟线程,"线程是稀缺资源"这套旧思维正在被逐渐改写。所以现在再问"Java Thread 和 Go Goroutine 到底有什么区别",可能已经不能只回答"Thread 是线程,Goroutine 是协程"——更准确的理解是:它们都是并发执行任务的抽象,但背后连接着完全不同的 Runtime 与调度模型。而随着虚拟线程的出现,这两条原本走向不同方向的路,正在"轻量级并发任务"这个点上慢慢靠近。