Go 语言开发指南
写给 Java 开发者的完整迁移手册 —— 从语法到工程,从并发到哲学
🎯01 · 设计哲学对比
要学好 Go,首先要理解它与 Java 在设计哲学上的根本分歧。这些分歧不是随意的,而是源于不同的时代背景和目标场景。
| 维度 | Java | Go |
|---|---|---|
| 诞生年代 | 1995 年,企业级后端 | 2009 年,云原生基础设施 |
| 核心目标 | "一次编写,到处运行"(WORA) | 在多核时代简化并发编程 |
| 类型系统 | 重型 OOP,类继承体系,抽象类/接口 | 极简 OOP,无继承,组合优于继承 |
| 并发模型 | 线程 + 锁 + 线程池,共享内存 | Goroutine + Channel,"不要通过共享内存通信" |
| 错误处理 | 异常(try/catch/finally) | 多返回值 + error 接口,显式检查 |
| 编译产物 | JVM 字节码,需运行时 | 静态编译为单一原生二进制 |
| 泛型 | 类型擦除泛型(JDK 5) | 编译期单态化泛型(Go 1.18) |
| 哲学 | 功能完备,库丰富,抽象层次多 | 少即是多,一种方式做一件事 |
| GC | 分代 GC,可调参数多 | 低延迟并发 GC,亚毫秒级停顿 |
从 Java 到 Go 最大的思维转变:从"构建类继承体系"转向"用小类型组合 + 接口"。不要试图在 Go 里复刻 Java 的抽象类、继承层级、设计模式工厂。Go 的代码会比你预期的更扁平、更直白——这是特性,不是缺陷。
⚙️02 · 开发环境
安装与版本管理
Java 用 JDK + Maven/Gradle;Go 的工具链是官方自带的 go 命令,集编译、依赖、测试、格式化、文档于一体,无需第三方构建工具。
# 安装后查看版本
go version # go version go1.22.x
# 环境变量(自动设置,通常无需改)
go env GOROOT GOPATH
# 设置国内代理(必做,否则下载依赖很慢)
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=sum.golang.google.cn
| 概念 | Java | Go |
|---|---|---|
| SDK 路径 | JAVA_HOME | GOROOT |
| 工作区 | 项目目录 | GOPATH(模块化后基本不用了) |
| 包仓库 | Maven Central | 直接从 Git 仓库拉取 |
| 构建工具 | Maven / Gradle | 内置 go build |
| 格式化 | IDE 配置 | 内置 gofmt / goimports |
| 文档生成 | Javadoc | 内置 go doc + 注释 |
推荐 IDE
- GoLand(JetBrains)—— Java 开发者最熟悉的选择,体验类似 IntelliJ
- VS Code + Go 扩展 —— 免费、轻量,官方推荐
- Neovim + gopls —— 重度终端用户
Go 社区强制使用 gofmt 统一代码风格,没有风格之争。保存即格式化,团队不需要配置 .editorconfig 或 Checkstyle。这是 Go "一种方式做一件事"哲学的体现。
👋03 · Hello World
从最简单的程序开始,已经能看到 Go 与 Java 的诸多差异。
// Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
// 编译运行:
// javac Hello.java && java Hello
// main.go
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
// 编译运行:
// go run main.go
// 或编译为二进制:go build
关键差异
- 无类包裹:Go 的
main函数直接放在package main下,不需要class Hello包裹。 - 无
public static void:func main()直接定义,入口固定签名。 - 大写即公开:
Println首字母大写表示它是导出的(公开的);小写开头是私有的。Go 没有public/private/protected关键字。 - 必须导入:用了
fmt就必须import,未使用的导入会编译报错(不是警告)。 - 左大括号不能换行:Go 强制
{必须与func同行,否则编译失败。
从 Java 过来很容易写成
func main()
{ // ❌ 编译错误!
...
}
Go 编译器会强制要求 { 紧跟函数签名同行。这是 gofmt 的硬性规定,避免了 Java 社区多年的风格之争。
import 详解
Java 用 import 包名.类名; 导入单个类;Go 用 import "路径" 导入整个包,然后通过 包名.成员 访问。这是两者最根本的差异——Java 导入的是"符号",Go 导入的是"命名空间"。
// 导入单个类
import java.util.List;
import java.util.ArrayList;
// 导入整个包(通配符)
import java.util.*;
// 静态导入(导入常量/方法)
import static java.lang.Math.PI;
import static java.lang.Math.sqrt;
List<String> list = new ArrayList<>();
double r = sqrt(PI); // 直接用,无前缀
// 导入整个包,用包名.成员访问
import "fmt"
import "strings"
// 多个包用括号分组(推荐)
import (
"fmt"
"strings"
)
fmt.Println(strings.ToUpper("hi"))
// Go 没有 import *,也没有静态导入
// 一律用 包名.成员 访问
import 的五种形式
Go 的 import 比 Java 灵活,有五种形式应对不同场景。Java 开发者最需要理解的是空白导入和别名导入。
// ① 标准导入:用包名访问
import "fmt"
fmt.Println("hi")
// ② 多包分组导入(最常用,gofmt 会排序)
import (
"fmt"
"os"
"strings"
)
// ③ 别名导入:给包起短名/避免冲突
import (
f "fmt" // 别名 f,用 f.Println
strutil "strings" // 别名 strutil
)
f.Println("hi")
// ④ 空白导入:只执行 init(),不导入任何名称
import _ "github.com/go-sql-driver/mysql"
// 不用 mysql.XXX,但 mysql 包的 init() 会注册驱动
// ⑤ 点导入:直接用包内名称,不加前缀(慎用)
import . "fmt"
Println("hi") // 直接用 Println,无需 fmt. 前缀
| 形式 | 语法 | Java 等价 | 使用场景 |
|---|---|---|---|
| 标准 | import "fmt" | import pkg.* | 最常用 |
| 分组 | import ( "a" "b" ) | 多条 import | 多包时必用 |
| 别名 | import f "fmt" | 无直接等价 | 包名冲突 / 太长 |
| 空白 | import _ "pkg" | 无 | 注册驱动/插件 |
| 点导入 | import . "fmt" | import static * | 测试/DSL(慎用) |
别名导入 —— 解决包名冲突
Java 靠全限定类名解决冲突(java.util.Date vs java.sql.Date);Go 用别名导入。
// 两个同名 Date 类
import java.util.Date;
// java.sql.Date 只能用全限定名
Date utilDate = new Date();
java.sql.Date sqlDate =
new java.sql.Date(System.currentTimeMillis());
import (
utiltime "time" // 标准库 time
xt "github.com/x/time" // 第三方,也叫 time
)
t1 := utiltime.Now()
t2 := xt.Now()
// 别名也用于简化长包名
import v "github.com/go-playground/validator/v10"
v.New() // 比写全名简洁
空白导入 _ —— Go 独有的注册模式
这是 Java 完全没有的特性。import _ "pkg" 不导入任何名称,只执行目标包的 init() 函数。这是 Go 实现"插件注册"的核心模式——类似 Java 的 SPI,但不需要配置文件。
import (
"database/sql"
// 空白导入:执行 mysql 包的 init(),向 database/sql 注册驱动
_ "github.com/go-sql-driver/mysql"
// 同理注册 PostgreSQL 驱动
_ "github.com/lib/pq"
)
// 使用时只需 database/sql,不需要直接引用驱动包
db, err := sql.Open("mysql", dsn)
驱动包的 init() 中调用 sql.Register("mysql", &MySQLDriver{}) 把自己注册到 database/sql 的全局注册表。你 import 它(即使不直接用),init() 就会执行,注册就完成了。这比 Java 的 SPI(需要 META-INF/services 配置文件)更简洁。image/jpeg、encoding/json 等标准库也用这个模式注册格式处理器。
点导入 . —— 类似 Java 静态导入
import . "fmt" 把包内所有导出名称直接引入当前作用域,不加 fmt. 前缀。类似 Java 的 import static。但 Go 社区不推荐日常使用,因为它模糊了名称来源。
// 测试文件中常用,让断言函数直接可用
import . "github.com/stretchr/testify/assert"
func TestAdd(t *testing.T) {
Equal(t, 5, Add(2, 3)) // 直接用 Equal,无需 assert.Equal
NotNil(t, result)
True(t, ok)
}
import 分组规范
Go 社区约定:import 按三组排列,组间空行分隔。goimports 工具会自动整理。
import (
// 第一组:标准库
"fmt"
"os"
"strings"
// 第二组:第三方库
"github.com/gin-gonic/gin"
"gorm.io/gorm"
// 第三组:本项目内部包
"myapp/internal/handler"
"myapp/internal/model"
)
Java 只是警告(IDE 标灰),Go 直接编译失败。导入了一个包但没用其中任何名称,必须删除该 import。例外:空白导入 import _ 不受此限制(因为不导入名称)。这是 Go "零冗余"哲学的体现。goimports 工具可以自动增删 import,建议配置到 IDE 保存时自动执行。
Java 允许类之间互相引用(A 依赖 B,B 依赖 A);Go 禁止包之间循环导入——A 包 import B,B 包就不能 import A,否则编译报错。这是 Go 有意为之的约束,强制你把共享逻辑提取到第三个包。看似不便,实则让依赖关系始终是有向无环图(DAG),可维护性远好于 Java 的互相依赖。
Go 没有 Java 的 "Organize Imports" IDE 功能之争——goimports 是官方工具,保存时自动:① 添加缺失的 import;② 删除未使用的 import;③ 按三组规范排序。GoLand 和 VS Code 都支持配置为保存时自动运行。这比 Java 的 import 管理更省心。
import 路径规则
| 类型 | 路径 | Java 对应 | 示例 |
|---|---|---|---|
| 标准库 | 无域名前缀 | java.util | "fmt"、"net/http" |
| 第三方库 | 仓库完整 URL | Maven 坐标 | "github.com/gin-gonic/gin" |
| 本项目包 | 模块名 + 子路径 | 项目包路径 | "myapp/internal/handler" |
| internal 包 | 含 internal/ 段 | 无 | 只能被 internal 父级目录导入 |
| vendored 包 | vendor/ 目录下 | — | 离线构建用 |
Go 的 import 路径是字符串(如 "github.com/gin-gonic/gin"),但包名是路径最后一段(gin)。代码中用 gin.Get() 而非全路径。别名导入可以改变这个默认包名。这与 Java 不同——Java 的 import 路径和类名是一体的。
📦04 · 变量与类型
变量声明
Java 的类型在左、Go 的类型在右——这是最直观的差异。Go 还提供了多种简写形式。
int x = 10;
String name = "Alice";
final double PI = 3.14;
// 多变量
int a = 1, b = 2, c = 3;
// 完整声明
var x int = 10
var name string = "Alice"
// 类型推断(省略类型)
var x = 10
var name = "Alice"
// 常量(无 final 关键字)
const PI = 3.14
// 短变量声明(只能在函数内)
y := 20
city := "Beijing"
// 多变量
a, b, c := 1, 2, 3
:= 短声明是 Go 的标志函数内 90% 的变量声明都用 :=,它会自动推断类型。var 主要用于:① 包级变量(:= 不能用);② 需要显式指定类型;③ 零值初始化。
零值 vs 默认值
Java 的局部变量必须显式初始化才能使用,否则编译错误;成员变量有默认值(0、null、false)。Go 统一为"零值"概念——所有变量声明后即被赋予零值,无需初始化也能使用。
| 类型 | Java 默认 | Go 零值 |
|---|---|---|
| 整数 | 0 | 0 |
| 浮点 | 0.0 | 0.0 |
| 布尔 | false | false |
| 字符串 | null | ""(空串,不是 nil) |
| 引用/对象 | null | nil(指针、slice、map、channel、interface、function) |
| 数组/结构体 | — | 逐元素/逐字段的零值 |
Go 的 string 是值类型,零值是空串 "",永远不为 nil。这与 Java 的 String(引用类型,可为 null)完全不同。如果你需要"可能没有字符串"的语义,用 *string 指针。
类型系统对比
| 概念 | Java | Go |
|---|---|---|
| 整数 | byte/short/int/long,有符号 | int8/16/32/64、uint8/16/32/64、int/uint(平台相关) |
| 字节 | byte(-128~127,有符号!) | byte(= uint8,0~255,无符号) |
| 字符 | char(16 位 Unicode) | rune(= int32,Unicode 码点) |
| 浮点 | float/double | float32/float64 |
| 布尔 | boolean | bool |
| 字符串 | String(引用,可变 via StringBuilder) | string(值,不可变) |
| 类型转换 | 隐式 widening,显式 narrowing | 全部显式,无任何隐式转换 |
int i = 100;
long l = i; // 隐式 widening ✅
double d = i; // 隐式 ✅
int n = (int) 3.14; // 显式 narrowing
var i int = 100
var l int64 = int64(i) // 必须显式
var d float64 = float64(i) // 必须显式
var n int = int(3.14) // 显式
类型别名 Type Alias
Go 允许为类型起别名,提升可读性。
type UserID int // 新类型,与 int 不兼容
type Score = int // 别名,完全等价于 int
var uid UserID = 42
var n int = int(uid) // UserID 需显式转 int
🔀05 · 控制流
条件语句
if (x > 0) {
...
} else if (x == 0) {
...
} else {
...
}
// 条件必须是 boolean
if (obj != null) { ... }
if x > 0 { // 无括号!
...
} else if x == 0 {
...
} else {
...
}
// 初始化语句(Go 特色)
if v, err := compute(); err != nil {
return err
} else {
use(v)
}
- 无括号:Go 的
if、for条件不带(),但{必须同行。 - 初始化语句:
if x := getValue(); x > 0 {...}是 Go 惯用模式,变量x的作用域仅限该 if-else 块。 - 没有三元运算符:Go 故意没有
?:,必须用完整 if-else。
switch
Go 的 switch 与 Java 差异较大——默认不穿透(不需要 break),反而需要 fallthrough 才穿透。
switch (day) {
case "Sat":
case "Sun": // 穿透
weekend();
break;
default:
weekday();
}
// Java 12+ switch 表达式
var r = switch (n) {
case 1 -> "one";
default -> "many";
};
switch day {
case "Sat", "Sun": // 多值一行
weekend()
default:
weekday()
}
// 无表达式 switch = if-else 链
switch {
case x < 0:
negative()
case x == 0:
zero()
default:
positive()
}
// 强制穿透
switch x {
case 1:
a()
fallthrough // 显式穿透
case 2:
b()
}
循环:只有 for
Go 砍掉了 while 和 do-while,全部用 for 一种形式表达。
// 经典 for
for (int i = 0; i < 10; i++) { ... }
// while
while (cond) { ... }
// do-while
do { ... } while (cond);
// 增强 for
for (String s : list) { ... }
// 经典 for
for i := 0; i < 10; i++ { ... }
// while 风格
for cond { ... }
// 无限循环
for { ... }
// range(增强 for)
for i, v := range slice {
// i 索引, v 值
}
for _, v := range slice { // 忽略索引
...
}
for k, v := range m { // map 遍历
...
}
defer —— Go 独有
Go 没有 try-finally,但有 defer。defer 语句在函数返回时执行(LIFO 后进先出),常用于资源释放。
BufferedReader br = new BufferedReader(...);
try {
return br.readLine();
} finally {
br.close(); // 必须在 finally
}
// 或 try-with-resources
try (var br = new BufferedReader(...)) {
return br.readLine();
}
f, err := os.Open("file.txt")
if err != nil {
return err
}
defer f.Close() // 紧跟 Open,确保释放
// 多个 defer,LIFO 执行
defer fmt.Println("1")
defer fmt.Println("2")
// 输出:2 然后 1
"打开资源后立刻 defer 关闭"——这样不会忘记释放。注意 defer 的参数在声明时求值,不是执行时。
🔧06 · 函数
函数定义语法
Java 的方法必须写在 class 内部,带修饰符(public/static/final 等);Go 的函数是包级的,用 func 关键字声明,没有修饰符——可见性靠首字母大小写控制。
public class MathUtil {
// 修饰符 返回类型 方法名(参数)
public static int add(int a, int b) {
return a + b;
}
// 实例方法
public String greet(String name) {
return "Hi, " + name;
}
// 方法重载(同名不同参)
public int add(int a, int b, int c) {
return a + b + c;
}
}
// func 函数名(参数) 返回类型
func Add(a, b int) int { // 同类型参数可合并
return a + b
}
// 大写 Add = 公开(包外可调用)
// 小写 greet = 包内私有
func greet(name string) string {
return "Hi, " + name
}
// ❌ Go 不支持方法重载!
// 下面的会编译报错:Add redeclared
func Add(a, b, c int) int {
return a + b + c
}
定义语法要点
- 关键字
func:所有函数(含方法)都用func声明,无def/function/void。 - 参数名在前、类型在后:与变量声明一致,
name string而非String name。 - 同类型参数可合并:
func f(a, b, c int)等价于func f(a int, b int, c int)。 - 返回类型在参数列表后:多返回值用
(T1, T2)括起来。 - 没有修饰符:无
public/private/static/final。可见性看首字母大小写;Go 也没有"实例方法 vs 静态方法"之分——包级函数就是"静态方法",方法(带接收者)就是"实例方法"。 - 不支持方法重载:同名函数只能有一个签名。需要不同参数?换名字(
add2、add3)或用变参。 - 不支持默认参数值:Go 故意砍掉。需要可选参数用 Builder 模式或 functional options。
从 Java 过来会觉得这两点很不方便。但 Go 的观点是:重载让代码阅读时需要跳转查签名,默认参数让调用语义不透明。显式优于隐式。替代方案:① 用不同函数名;② 用变参 ...;③ 用 functional options 模式(高级)。
参数传递:Go 只有值传递
这是 Java 开发者最容易误解的地方。Go 和 Java 一样,所有参数都是值传递——传递的是值的副本。但两者对"值"的理解不同,导致行为差异。
| 参数类型 | Java 行为 | Go 行为 |
|---|---|---|
| 基本类型 | 值拷贝,函数内修改不影响外部 | 同 Java |
| 对象/结构体 | 传引用的副本(可改字段,不能换对象) | 整体值拷贝(函数内改字段不影响外部!) |
| slice | — | 拷贝 slice 头(指针+len+cap),共享底层数组 |
| map | — | 拷贝指针,共享底层数据 |
| 想修改外部 | 无法选择(对象天然引用) | 显式传 *T 指针 |
class User {
int age;
}
void grow(User u) {
u.age++; // ✅ 改的是原对象
}
User u = new User();
u.age = 18;
grow(u);
System.out.println(u.age); // 19
type User struct { Age int }
func grow(u User) {
u.Age++ // ❌ 改的是副本!
}
u := User{Age: 18}
grow(u)
fmt.Println(u.Age) // 仍然是 18
// ✅ 正确:传指针
func grow(u *User) {
u.Age++ // 通过指针改原对象
}
grow(&u)
fmt.Println(u.Age) // 19
slice 和 map 传给函数后,函数内修改能影响外部——这不是引用传递,而是它们内部持有指针,拷贝的是"头部结构"而非底层数据。所以:append 可能扩容导致原 slice 不变(新数组),但 s[i] = x 修改元素会反映到外部(共享底层)。理解这一点能避免 90% 的 slice 陷阱。
多返回值 —— Go 的招牌
Go 函数可以返回多个值,这是 Java 没有的。最常见的就是 (result, error) 双返回模式。
// 用异常表示错误
public String readFile(String path)
throws IOException {
...
return content;
}
// 或返回包装类
public Result<String> readFile(String p) {
...
}
func readFile(path string) (string, error) {
...
return content, nil
}
// 调用:必须处理 error
content, err := readFile("a.txt")
if err != nil {
return err
}
use(content)
// 忽略 error(不推荐)
content, _ := readFile("a.txt")
命名返回值
Go 允许给返回值命名,相当于在函数顶部声明并零值初始化,return 可裸写。
func divide(a, b int) (result int, err error) {
if b == 0 {
err = errors.New("divide by zero")
return // 裸 return,返回当前 result 和 err
}
result = a / b
return // 同上
}
函数是一等公民
Java 用函数式接口 + Lambda;Go 直接把函数当值传递,无需函数式接口包裹。
// 需要函数式接口
@FunctionalInterface
interface IntFunc {
int apply(int x);
}
IntFunc square = x -> x * x;
int r = square.apply(5);
// 高阶函数
public IntFunc compose(IntFunc f, IntFunc g) {
return x -> f.apply(g.apply(x));
}
// 函数类型直接写
type IntFunc func(int) int
square := func(x int) int { return x * x }
r := square(5)
// 高阶函数
func compose(f, g IntFunc) IntFunc {
return func(x int) int {
return f(g(x))
}
}
闭包
Go 闭包会捕获外部变量,但要注意——捕获的是变量引用,不是值的快照。
func main() {
var funcs []func()
for i := 0; i < 3; i++ {
funcs = append(funcs, func() {
fmt.Println(i) // 捕获 i 的引用!
})
}
for _, f := range funcs {
f() // 输出 3 3 3,不是 0 1 2
}
}
// 正确写法:每次循环复制一份
for i := 0; i < 3; i++ {
i := i // 局部副本
funcs = append(funcs, func() {
fmt.Println(i)
})
}
可变参数 Variadic
Java 用 int... 表示可变参数(内部是数组);Go 用 ...int(内部是 slice)。语法位置相反——Java 的 ... 在类型后,Go 的 ... 在类型前。
// ... 在类型后
public int sum(int... nums) {
int total = 0;
for (int n : nums) total += n;
return total;
}
sum(1, 2, 3); // 直接传
int[] arr = {1, 2, 3};
sum(arr); // 直接传数组
// ... 在类型前
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum(1, 2, 3) // 直接传
arr := []int{1, 2, 3}
sum(arr...) // slice 要展开!
...Java 直接把数组传给可变参数;Go 必须用 arr... 展开 slice,否则编译器会认为你传了一个 slice 类型的参数(类型不匹配)。append 就是个变参函数:append(s, elems...)。
Go 社区常用变参实现"可选行为",最经典的是 fmt.Printf 和 functional options:
// functional options 模式(替代默认参数)
type Option func(*Config)
func NewServer(addr string, opts ...Option) *Server {
cfg := &Config{Addr: addr} // 默认值
for _, o := range opts {
o(cfg) // 应用每个选项
}
return &Server{cfg: cfg}
}
// 调用:NewServer(":8080", WithTimeout(30), WithTLS())
init 函数 —— Go 独有
Go 的每个源文件可以有一个或多个 init 函数,它们自动在 main 之前执行,用于包初始化。Java 最接近的概念是静态初始化块(static { ... }),但 init 函数更灵活。
public class Config {
private static final Map<String, String> map;
static { // 类加载时执行一次
map = new HashMap<>();
map.put("k", "v");
}
}
// config.go
package config
var cfg map[string]string
// 可有多个 init,按声明顺序执行
func init() {
cfg = make(map[string]string)
cfg["k"] = "v"
}
func init() {
// 第二个 init,做其他初始化
log.Println("config ready")
}
init 执行顺序
- 导入的包先初始化(递归向下)
- 包级变量按声明顺序初始化
- 同一文件内的多个
init按出现顺序执行 - 同包多文件的
init按文件名字母序执行 - 最后执行
main函数
init 函数没有参数、没有返回值、无法显式调用、无法测试——副作用隐藏在"魔法"中。Go 社区建议:能用普通函数显式调用的,就不要用 init。init 仅用于:① 注册驱动(如 import _ "github.com/go-sql-driver/mysql");② 无法避免的全局状态初始化。复杂初始化逻辑请写成普通函数,在 main 中显式调用。
函数 vs 方法:一句话区分
Go 中"函数"和"方法"是两个概念,Java 开发者常混淆:
- 函数(Function):包级,无接收者。
func Add(a, b int) int - 方法(Method):绑定到类型,有接收者。
func (c Calc) Add(a, b int) int
Java 里它们都叫"方法"(写在 class 里);Go 区分两者——方法在第 11 节详讲。简单说:带 (recv Type) 的就是方法,不带的就是函数。
📜07 · 数组与切片
这是 Go 与 Java 差异最大的地方之一。Java 只有动态数组 ArrayList;Go 区分数组(定长、值类型)和切片(变长、引用语义)。99% 情况下你用的都是切片。
数组 vs 切片
| 特性 | 数组 [N]T | 切片 []T |
|---|---|---|
| 长度 | 固定,编译期确定 | 动态,运行时可变 |
| 类型 | 值类型(赋值=复制) | 引用类型(底层指针) |
| 使用频率 | 极低,几乎不用 | 极高,相当于 Java 的 ArrayList |
// 定长数组
int[] arr = new int[5];
int[] arr2 = {1, 2, 3};
// 动态列表
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
int x = list.get(0);
int n = list.size();
// 定长数组(很少用)
var arr [5]int
arr2 := [3]int{1, 2, 3}
arr3 := [...]int{1, 2, 3} // ...自动推断长度
// 切片(最常用)
s := []int{1, 2, 3}
s = append(s, 4) // 追加
s = append(s, 5, 6)
x := s[0] // 取值
n := len(s) // 长度
// make 创建
s2 := make([]int, 0, 10) // len=0, cap=10
切片的底层结构
理解切片必须理解它的三件套:{指针, 长度, 容量}。这是 Java 没有的概念。
// 切片本质是一个结构体:
type slice struct {
array unsafe.Pointer // 指向底层数组
len int // 当前长度
cap int // 容量(底层数组大小)
}
s := make([]int, 3, 5)
// [1, 2, 3, _, _] len=3, cap=5
// ^--- s 指向这里
s = append(s, 4) // len=4, cap=5(未扩容)
s = append(s, 5) // len=5, cap=5
s = append(s, 6) // len=6, cap=10(扩容 2 倍)
切片截取 —— 共享底层数组
这是 Java 完全没有的特性,也是最容易踩坑的地方。
s := []int{1, 2, 3, 4, 5}
sub := s[1:3] // [2, 3],与 s 共享底层!
sub[0] = 99 // 修改 sub 也会改 s!
fmt.Println(s) // [1, 99, 3, 4, 5]
// 要独立副本,用 copy
dst := make([]int, len(sub))
copy(dst, sub)
当 append 触发扩容时,会分配新数组,原切片和 append 返回的切片就不再共享底层了。很多人在这里栽跟头。
a := []int{1, 2, 3}
b := append(a, 4) // 扩容,b 指向新数组
b[0] = 99
fmt.Println(a[0]) // 仍然是 1,不是 99
sub := big[1:2] 会让 big 整个数组无法被 GC(因为 sub 还指着它)。修复:copy 出来,或用 big[1:2:2] 限定容量。
🗺️08 · Map
Go 的 map 对应 Java 的 HashMap,但有几个关键差异需要注意。
Map<String, Integer> m = new HashMap<>();
m.put("a", 1);
m.put("b", 2);
if (m.containsKey("a")) { ... }
int v = m.getOrDefault("c", 0);
// 遍历(顺序不定)
m.forEach((k, v) -> ...);
// 字面量
m := map[string]int{
"a": 1,
"b": 2,
}
// make 创建
m = make(map[string]int)
m["a"] = 1
m["b"] = 2
// 双返回值判断存在性
v, ok := m["a"]
if ok { ... }
// 不存在返回零值(不会 panic)
v = m["c"] // v = 0
// 遍历(顺序不定)
for k, v := range m { ... }
// 删除
delete(m, "a")
Go 的 map 不是 Java 的 ConcurrentHashMap。并发读写同一个 map 会直接 panic(fatal error),不是数据错乱,是程序崩溃!并发场景请用 sync.Map 或 sync.RWMutex 加锁。
var m map[string]int 得到的是 nil map,读取返回零值,但写入会 panic。必须用 make 初始化后才能写。
🗂️09 · 集合框架对比
Java 拥有庞大的集合框架(java.util 包下数十个类),Go 则刻意保持极简——标准库只内置 slice 和 map 两种,其余用组合实现。本节是 Java 集合 → Go 的完整映射手册。
全景对应表
| Java | Go 对应 | 说明 |
|---|---|---|
ArrayList | []T(slice) | 最常用,动态数组(第 07 节详讲) |
LinkedList | container/list | 双向链表,极少使用 |
HashSet | map[T]struct{} | 用 map 模拟 Set |
TreeSet | 无内置,sort + []T | 排序 slice 替代 |
LinkedHashSet | 无 | 需第三方库或自封装 |
HashMap | map[K]V | 内置(第 08 节详讲) |
TreeMap | 无 | 需第三方(如 ordered map) |
LinkedHashMap | 无 | Go map 本身无序 |
ConcurrentHashMap | sync.Map | 并发安全 map |
PriorityQueue | container/heap | 堆接口,需自己实现 |
ArrayDeque | 无内置 | 用 slice 模拟 |
Stack | 无内置 | 用 slice(append + [len-1:]) |
Collections.unmodifiableList | 无 | 需自封装或返回切片副本 |
Stream API | 无 | 用 for 循环 + if |
Iterator | range | 语言内置遍历 |
Java 有 40+ 集合类,Go 只有 slice + map 两个。看起来 Go"缺"了很多,但实践中 90% 的场景 slice + map 就够了。需要 Set?用 map[T]struct{}。需要队列?用 channel 或 slice。需要优先队列?用 container/heap。Go 认为"一种方式做一件事"比"提供所有可能的容器"更重要。
Set —— 用 map 模拟
Java 有 HashSet、TreeSet、LinkedHashSet;Go 没有内置 Set,用 map[T]struct{} 模拟。struct{} 是空结构体,不占内存(0 字节)。
Set<String> set = new HashSet<>();
set.add("a");
set.add("b");
set.add("a"); // 重复,自动忽略
boolean has = set.contains("a"); // true
int size = set.size(); // 2
set.remove("a");
// 遍历
for (String s : set) {
System.out.println(s);
}
// 集合运算
set1.addAll(set2); // 并集
set1.retainAll(set2); // 交集
set1.removeAll(set2); // 差集
set := map[string]struct{}{}
set["a"] = struct{}{}
set["b"] = struct{}{}
set["a"] = struct{}{} // 重复,自动覆盖
_, has := set["a"] // has = true
size := len(set) // 2
delete(set, "a")
// 遍历(无序)
for k := range set {
fmt.Println(k)
}
// Go 1.18+ 泛型封装 Set
type Set[T comparable] map[T]struct{}
func (s Set[T]) Has(v T) bool {
_, ok := s[v]
return ok
}
有了泛型,可以封装一个通用的 Set[T] 类型,添加 Add/Has/Remove/Union/Intersect 等方法,达到 Java HashSet 的便利度。推荐第三方库 golang.org/x/exp/maps 和 golang.org/x/exp/slices(标准库扩展)提供常用集合操作。
队列与栈 —— 用 slice 或 channel
Java 有 Queue、Deque、Stack、PriorityQueue 一整套体系;Go 只内置 channel(并发队列)和 container/heap(堆),栈和普通队列用 slice 模拟。
// 栈
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);
stack.push(2);
int top = stack.pop(); // 2 (LIFO)
// 队列
Queue<Integer> queue = new LinkedList<>();
queue.offer(1);
queue.offer(2);
int head = queue.poll(); // 1 (FIFO)
// 优先队列
PriorityQueue<Integer> pq =
new PriorityQueue<>();
pq.offer(3);
pq.offer(1);
int min = pq.poll(); // 1 (最小堆)
// 栈:用 slice(LIFO)
stack := []int{}
stack = append(stack, 1) // push
stack = append(stack, 2)
top := stack[len(stack)-1] // peek
stack = stack[:len(stack)-1] // pop → 2
// 队列:用 slice(FIFO,效率低)
queue := []int{}
queue = append(queue, 1)
queue = append(queue, 2)
head := queue[0] // peek
queue = queue[1:] // dequeue → 1
// 队列:用 channel(并发安全,推荐)
ch := make(chan int, 100)
ch <- 1
head := <-ch // dequeue
// 优先队列:container/heap
import "container/heap"
// 需实现 heap.Interface(较繁琐)
queue = queue[1:] 出队时,底层数组的第一个元素不会被回收,且不断前移指针。大量出队场景请用 container/list(链表)或 channel(并发队列)。
排序
Java 用 Collections.sort() / List.sort() / stream.sorted(),可传 Comparator;Go 用 sort 包,支持基本类型和自定义比较器。
List<User> users = new ArrayList<>();
users.add(new User("Bob", 30));
users.add(new User("Alice", 25));
// 按年龄排序
users.sort(Comparator.comparing(User::getAge));
// 倒序
users.sort(Comparator.comparing(User::getAge).reversed());
// 多字段排序
users.sort(Comparator
.comparing(User::getAge)
.thenComparing(User::getName));
// 基本类型
int[] nums = {3, 1, 2};
Arrays.sort(nums);
users := []User{
{Name: "Bob", Age: 30},
{Name: "Alice", Age: 25},
}
// 基本类型
nums := []int{3, 1, 2}
sort.Ints(nums) // 升序
sort.Sort(sort.Reverse(sort.IntSlice(nums)))
// 自定义排序(Go 1.21+ 推荐)
slices.SortFunc(users, func(a, b User) int {
if a.Age != b.Age {
return a.Age - b.Age // 年龄升序
}
return strings.Compare(a.Name, b.Name)
})
// 旧写法(仍然支持)
sort.Slice(users, func(i, j int) bool {
return users[i].Age < users[j].Age
})
slices 与 maps 标准库包详解(Go 1.21+)
Go 1.21 是集合操作的分水岭——之前要实现"判断 slice 是否包含某元素"都得手写 for 循环,之后标准库终于内置了 slices 和 maps 两个泛型包。它们相当于 Java 的 Collections 工具类 + Stream 的部分能力,但以独立函数而非链式 API 的形式提供。
Java 把集合操作绑在 List / Collections / Stream 上;Go 把它们拆成独立泛型函数,任何 []T 都能用。不需要继承 AbstractList,不需要实现 sort.Interface,不需要创建 Stream 中间对象——直接调用,编译期类型安全。
slices 包函数全览
| 分类 | 函数 | Java 等价 | 说明 |
|---|---|---|---|
| 查找 | slices.Index(s, v) | list.indexOf(v) | 返回首个匹配索引,未找到返回 -1 |
slices.IndexFunc(s, f) | — | 按条件查找索引 | |
slices.Contains(s, v) | list.contains(v) | 是否包含 | |
slices.BinarySearch(s, v) | Collections.binarySearch | 二分查找(需已排序) | |
| 排序 | slices.Sort(s) | Collections.sort(list) | 升序排序(需 cmp.Ordered) |
slices.SortFunc(s, cmp) | list.sort(comparator) | 自定义比较器排序 | |
slices.SortStableFunc(s, cmp) | Collections.stableSort | 稳定排序(相等元素保持原序) | |
slices.IsSorted(s) | — | 判断是否已排序 | |
slices.Reverse(s) | Collections.reverse(list) | 原地反转 | |
| 修改 | slices.Insert(s, i, v...) | list.add(i, v) | 在索引处插入 |
slices.Delete(s, i, j) | list.subList(i,j).clear() | 删除索引 [i,j) 区间 | |
slices.DeleteFunc(s, f) | list.removeIf(f) | 按条件删除(Java 8 风格) | |
slices.Replace(s, i, j, v...) | — | 替换区间为新值 | |
slices.Compact(s) | — | 压缩连续重复元素 | |
| 复制与拼接 | slices.Clone(s) | new ArrayList(list) | 浅拷贝 |
slices.Concat(s...) | Stream.concat | 拼接多个 slice | |
slices.Grow(s, n) | new ArrayList(capacity) | 预分配容量 | |
slices.Clip(s) | — | 裁剪容量到长度 | |
| 比较 | slices.Equal(s1, s2) | list.equals(list2) | 逐元素相等 |
slices.Compare(s1, s2) | — | 字典序比较(-1/0/1) | |
slices.EqualFunc(s1, s2, f) | — | 按条件判断相等 |
查找与判断
List<String> list = List.of("a", "b", "c");
// 包含
boolean has = list.contains("b"); // true
// 索引
int idx = list.indexOf("c"); // 2
// 条件查找
int i = -1;
for (int k = 0; k < list.size(); k++) {
if (list.get(k).startsWith("b")) {
i = k; break;
}
}
// 二分查找(需已排序)
int pos = Collections.binarySearch(
sortedList, "b");
s := []string{"a", "b", "c"}
// 包含
has := slices.Contains(s, "b") // true
// 索引
idx := slices.Index(s, "c") // 2
// 条件查找索引
i := slices.IndexFunc(s, func(v string) bool {
return strings.HasPrefix(v, "b")
})
// 二分查找(需已排序)
pos, found := slices.BinarySearch(s, "b")
// pos=1, found=true
Java 的 binarySearch 返回 int(找到返回索引,未找到返回负数 -(插入点)-1),需要额外计算。Go 的 slices.BinarySearch 返回 (pos int, found bool) 双值,语义清晰,不会搞混。
修改操作
List<String> list = new ArrayList<>(
List.of("a", "b", "c", "d"));
// 插入
list.add(1, "x"); // [a, x, b, c, d]
// 删除索引
list.remove(2); // [a, x, c, d]
// 条件删除
list.removeIf(s -> s.startsWith("a"));
// 拷贝
List<String> copy = new ArrayList<>(list);
// 拼接
List<String> all = new ArrayList<>();
all.addAll(list1);
all.addAll(list2);
s := []string{"a", "b", "c", "d"}
// 插入(返回新 slice,原 slice 不变)
s = slices.Insert(s, 1, "x")
// [a, x, b, c, d]
// 删除索引 [2, 3)
s = slices.Delete(s, 2, 3)
// [a, x, c, d]
// 条件删除(类似 removeIf)
s = slices.DeleteFunc(s, func(v string) bool {
return strings.HasPrefix(v, "a")
})
// 拷贝
cp := slices.Clone(s)
// 拼接多个 slice
all := slices.Concat(s1, s2, s3)
// 压缩连续重复元素
dup := []int{1, 1, 2, 3, 3, 3}
dup = slices.Compact(dup) // [1, 2, 3]
Java 的 list.add(i, v) 原地修改;Go 的 slices.Insert / Delete / Replace 返回新 slice,必须用 s = slices.Insert(s, ...) 接收返回值。忘记接收会导致修改不生效。这是值语义的必然结果——slice 头是值拷贝,函数内改了长度不会影响外部。
排序详解
slices.Sort 要求元素类型实现 cmp.Ordered 约束(即支持 < 比较的类型:整数、浮点、字符串)。自定义结构体排序用 SortFunc。
import ("slices"; "cmp")
// 1. 基本类型排序(cmp.Ordered)
nums := []int{3, 1, 2}
slices.Sort(nums) // [1, 2, 3] 升序
slices.Sort(nums) // 原地,无返回值
strings_ := []string{"c", "a", "b"}
slices.Sort(strings_) // [a, b, c]
// 2. 结构体排序(SortFunc + 比较函数)
type User struct { Name string; Age int }
users := []User{{"Bob", 30}, {"Alice", 25}, {"Bob", 20}}
// 比较函数返回 int:负数=小于,0=相等,正数=大于
slices.SortFunc(users, func(a, b User) int {
if a.Age != b.Age {
return a.Age - b.Age // 年龄升序
}
return cmp.Compare(a.Name, b.Name) // 同年龄按名字
})
// [{Bob 20} {Alice 25} {Bob 30}]
// 3. 用 cmp.Reverse 倒序
slices.SortFunc(users, func(a, b User) int {
return b.Age - a.Age // 简单倒序:交换 a 和 b
})
// 4. 稳定排序(相等元素保持原始顺序)
slices.SortStableFunc(users, func(a, b User) int {
return a.Age - b.Age
})
// 5. 判断是否已排序
sorted := slices.IsSorted(nums) // true
// 6. 反转(原地)
slices.Reverse(nums) // [3, 2, 1]
Java 的 Comparator.compare(a, b) 和 Go 的 SortFunc 比较函数都返回 int(负/零/正),但旧版 sort.Slice 用的是 bool(less 函数)。迁移时注意:slices.SortFunc 的回调签名是 func(a, b T) int,不是 func(i, j int) bool。
maps 包函数全览
与 slices 同期发布的 maps 包提供 map 的泛型操作,填补了"取出所有 key""判断两个 map 是否相等"等常用操作的空白。
| maps 函数 | Java 等价 | 说明 |
|---|---|---|
maps.Keys(m) | map.keySet() | 返回所有 key 的 slice(无序) |
maps.Values(m) | map.values() | 返回所有 value 的 slice(无序) |
maps.Clone(m) | new HashMap(map) | 浅拷贝 |
maps.Equal(m1, m2) | map.equals(map2) | 逐 key-value 比较 |
maps.Copy(dst, src) | dst.putAll(src) | 将 src 合并到 dst |
maps.DeleteFunc(m, f) | — | 按条件删除 key(原地) |
maps.EqualFunc(m1, m2, f) | — | 按条件判断 value 相等 |
Map<String, Integer> m = new HashMap<>();
m.put("a", 1);
m.put("b", 2);
// 取所有 key
Set<String> keys = m.keySet();
// 取所有 value
Collection<Integer> vals = m.values();
// 拷贝
Map<String, Integer> copy =
new HashMap<>(m);
// 合并
copy.putAll(other);
// 判断相等
boolean eq = m1.equals(m2);
// 条件删除(Java 无直接方法)
m.entrySet().removeIf(
e -> e.getValue() == 0
);
m := map[string]int{"a": 1, "b": 2}
// 取所有 key(返回 slice,无序)
keys := maps.Keys(m) // []string{"a", "b"}(顺序不定)
// 取所有 value
vals := maps.Values(m) // []int{1, 2}(顺序不定)
// 拷贝
cp := maps.Clone(m)
// 合并(将 src 合并到 dst)
maps.Copy(cp, other)
// 判断相等(逐 key-value)
eq := maps.Equal(m1, m2)
// 条件删除(原地,无需遍历)
maps.DeleteFunc(m, func(k string, v int) bool {
return v == 0
})
Go 的 map 不能在 range 遍历时删除元素(会 panic)。以前要删除满足条件的 key,需要先收集再删;现在 maps.DeleteFunc 一步搞定,内部安全处理了遍历中删除的问题。
用 slices + maps 替代 Stream 操作
有了 slices 和 maps,很多原本需要手写 for 循环的集合操作现在可以一行完成。下表展示 Java Stream 常见操作如何用这两个包替代:
| Java Stream | Go slices/maps 替代 | 说明 |
|---|---|---|
.filter().collect() | slices.DeleteFunc | 按条件保留/删除 |
.contains() | slices.Contains | 是否包含 |
.indexOf() | slices.Index | 查找索引 |
.sorted() | slices.Sort / SortFunc | 排序 |
.distinct() | slices.Sort + Compact | 先排序再压缩(需排序) |
Collectors.toList() | 直接用 slice | 无需转换 |
map.keySet() | maps.Keys(m) | 取出 key 列表 |
map.values() | maps.Values(m) | 取出 value 列表 |
list1.equals(list2) | slices.Equal(s1, s2) | 逐元素比较 |
list.removeIf() | slices.DeleteFunc | 条件删除 |
new ArrayList(list) | slices.Clone(s) | 浅拷贝 |
Collections.reverse() | slices.Reverse | 原地反转 |
Stream.concat() | slices.Concat | 拼接 |
slices 和 maps 提供离散的集合操作函数,但无法像 Stream 那样链式组合。Java 可以写 list.stream().filter().map().sorted().collect() 一行搞定;Go 需要多步操作,每步产生中间 slice。这是 Go 有意为之——显式优于隐式,中间过程看得见、可调试、无隐藏开销。
cmp 包:比较的基石
Go 1.21 同时引入了 cmp 包(注意不是 cmp 泛型约束的旧包),提供 Compare 和 Or 两个实用函数。
import "cmp"
// Compare:比较两个 Ordered 值,返回 -1/0/1
r := cmp.Compare(3, 5) // -1
r = cmp.Compare("a", "a") // 0
r = cmp.Compare(5, 3) // 1
// Or:返回第一个非零值(类似 SQL COALESCE)
name := cmp.Or(input, "default")
// input 非空返回 input,否则返回 "default"
config := cmp.Or(envValue, flagValue, "8080")
// 依次尝试,取第一个非零值
cmp.Or(a, b, c) 返回第一个非零值,相当于 Java 的 Optional.orElse() 链或 SQL 的 COALESCE。对于"配置优先级"场景(环境变量 → 命令行参数 → 默认值)特别简洁:port := cmp.Or(os.Getenv("PORT"), os.Getenv("SERVER_PORT"), "8080")。
Stream API 对应 —— Go 用 for 循环
Java 8 的 Stream API 是集合操作的利器:filter / map / reduce / collect。Go 没有等价的流式 API——社区共识是用 for 循环 + if,更直白也更快。
List<String> names = users.stream()
.filter(u -> u.getAge() > 18)
.map(User::getName)
.sorted()
.collect(Collectors.toList());
// 分组
Map<String, List<User>> byCity =
users.stream()
.collect(Collectors.groupingBy(User::getCity));
// 统计
double avgAge = users.stream()
.mapToInt(User::getAge)
.average().orElse(0);
// 去重
List<String> unique = users.stream()
.map(User::getName)
.distinct()
.collect(Collectors.toList());
// filter + map + sort
var names []string
for _, u := range users {
if u.Age > 18 {
names = append(names, u.Name)
}
}
sort.Strings(names)
// 分组(groupingBy)
byCity := map[string][]User{}
for _, u := range users {
byCity[u.City] = append(byCity[u.City], u)
}
// 统计(average)
var total int
for _, u := range users {
total += u.Age
}
avgAge := float64(total) / float64(len(users))
// 去重(distinct)
seen := map[string]struct{}{}
var unique []string
for _, u := range users {
if _, ok := seen[u.Name]; !ok {
seen[u.Name] = struct{}{}
unique = append(unique, u.Name)
}
}
Go 团队认为:① Stream 的链式调用隐藏了控制流,降低可读性;② for 循环性能更好(无中间集合、无 lambda 捕获开销);③ for 循环可以用 break / continue 精确控制,Stream 不能。Go 的哲学是"代码读起来像什么就做什么",for 循环最符合这个原则。Java 开发者一开始会不习惯,但很快会发现 for 循环其实更直白。
不可变集合
Java 9+ 提供了 List.of() / Set.of() / Map.of() 创建不可变集合;Go 没有内置不可变集合——slice 和 map 总是可变的。要实现"只读",只能靠约定或自封装。
// Java 9+ 工厂方法
List<String> list = List.of("a", "b", "c");
list.add("d"); // ❌ UnsupportedOperationException
Map<String, Integer> map =
Map.of("a", 1, "b", 2);
// 防御性拷贝
public List<String> getNames() {
return List.copyOf(this.names);
}
// Go 没有内置不可变集合
// 方式1:返回副本(防御性拷贝)
func (u *User) GetTags() []string {
cp := make([]string, len(u.tags))
copy(cp, u.tags)
return cp
}
// 方式2:返回只读接口(无法 append)
type ReadOnlyList[T any] interface {
Get(i int) T
Len() int
}
// 方式3:第三方库(如 go-immutable)
线程安全集合
Java 有 ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue 等一系列并发容器;Go 只内置 sync.Map,其余用 sync.RWMutex + 普通集合组合实现。
ConcurrentHashMap<String, Integer> map =
new ConcurrentHashMap<>();
map.put("a", 1); // 线程安全
map.computeIfAbsent("b",
k -> 2);
CopyOnWriteArrayList<String> list =
new CopyOnWriteArrayList<>();
list.add("x"); // 写时复制
// 方式1:sync.Map(高并发读多写少)
var m sync.Map
m.Store("a", 1)
v, ok := m.Load("a")
m.LoadOrStore("b", 2) // 类似 computeIfAbsent
// 方式2:RWMutex + map(更灵活)
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
func (s *SafeMap[K, V]) Get(k K) (V, bool) {
s.mu.RLock(); defer s.mu.RUnlock()
v, ok := s.m[k]; return v, ok
}
sync.Map 没有泛型(返回 any 需类型断言)、没有 len()、不能 range(要用 Range 回调)。它专为"多读少写"或"多个独立 key 写"场景优化。普通并发 map 用 sync.RWMutex + map 更灵活、类型更安全。详见第 18 节。
常用操作速查
| 操作 | Java | Go |
|---|---|---|
| 创建列表 | new ArrayList<>() | []T{} 或 make([]T, 0) |
| 添加元素 | list.add(e) | s = append(s, e) |
| 按索引取 | list.get(i) | s[i] |
| 按索引删 | list.remove(i) | s = append(s[:i], s[i+1:]...) |
| 长度 | list.size() | len(s) |
| 包含 | list.contains(e) | slices.Contains(s, e)(1.21+) |
| 查找索引 | list.indexOf(e) | slices.Index(s, e)(1.21+) |
| 清空 | list.clear() | s = s[:0](保留底层数组) |
| 拷贝 | new ArrayList(list) | copy(dst, src) 或 slices.Clone(s) |
| 转数组 | list.toArray() | slice 本身就是数组 |
| 创建 Map | new HashMap<>() | make(map[K]V) |
| 判断 key | map.containsKey(k) | _, ok := m[k] |
| 遍历 keys | map.keySet() | maps.Keys(m)(1.21+) |
| 遍历 values | map.values() | maps.Values(m)(1.21+) |
| 删除 | map.remove(k) | delete(m, k) |
| 合并 | map.putAll(other) | for k, v := range other { m[k] = v } |
| 判空 | map.isEmpty() | len(m) == 0 |
- 没有 Set:用
map[T]struct{}模拟,需自行封装常用操作。 - 没有 Stream:filter/map/reduce 全用 for 循环手写,更啰嗦但更可控。
- 没有不可变集合:slice/map 总是可变的,"只读"靠约定或返回副本。
适应这三点后,你会发现 Go 的集合虽然"少",但每个操作都透明可控——没有 Stream 的隐藏中间集合、没有迭代器并发修改异常、没有懒加载陷阱。
🏗️10 · 结构体
Go 没有 class 关键字,用 struct 表示数据结构。struct 只含字段,行为通过"方法"附加(下一节讲)。
public class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public void setAge(int a) { age = a; }
}
User u = new User("Alice", 30);
type User struct {
Name string // 大写=公开
Age int // 大写=公开
email string // 小写=包内私有
}
// 字面量初始化(多种)
u1 := User{Name: "Alice", Age: 30}
u2 := User{"Alice", 30, ""} // 按顺序
u3 := User{Name: "Bob"} // Age=0, email=""
// 取指针(new 或 &)
u4 := &User{Name: "Carol"}
u5 := new(User) // *User,零值
// 访问字段(Go 自动解引用)
u1.Name = "Alice2"
u4.Age = 25 // u4 是指针,但直接用 . 访问
Go 没有 getter/setter 关键字
Go 字段公开就直接访问,私有就用方法包装。约定:getter 名字就是字段名(不要加 Get 前缀),setter 用 Set 前缀。
type User struct {
name string
}
// getter:名为 Name,不是 GetName
func (u User) Name() string { return u.name }
// setter
func (u *User) SetName(name string) { u.name = name }
结构体标签(Tag)详解
Java 用注解(@JsonProperty、@Column、@NotNull)给字段附加元数据;Go 没有"注解"语法,而是用结构体标签——字段类型后用反引号 ` 包裹的字符串。两者目的一样:让序列化库、ORM、校验库在运行时通过反射读取这些元数据。
public class User {
@JsonProperty("name")
@Column(name = "user_name")
@NotBlank
private String name;
@JsonProperty("age")
@JsonIgnore(if == 0) // 伪代码
private int age;
@JsonIgnore
private String password;
}
type User struct {
Name string `json:"name" db:"user_name" validate:"required"`
Age int `json:"age,omitempty"`
Pass string `json:"-"` // 序列化时忽略
}
Tag 语法规则
- 反引号包裹:用
`(反引号)而非单引号'。反引号内是原始字符串,可含双引号、换行,无需转义。 - 格式:
key:"value" key2:"value2",多个 tag 之间用空格分隔。 - 每个 key 对应一个库:
json→ encoding/json,gorm→ GORM,validate→ validator。各库独立解析自己的 key。 - value 内部用逗号分隔多个选项:
json:"age,omitempty"表示"映射为 age + 零值省略"。 - 编译器不检查 tag:拼写错误不会报错,只会让对应库"读不到"该 tag(静默失败)。
这是最常见的低级错误:
// ❌ 错误:json 和 gorm 之间没空格
Name string `json:"name"gorm:"column:name"`
// ✅ 正确:中间有空格
Name string `json:"name" gorm:"column:name"`
没空格时,reflect.StructTag.Get 解析会把 json:"name"gorm:"column:name" 当成一个整体,gorm tag 就读不到了——且不会有任何报错。
JSON Tag 详解(最常用)
标准库 encoding/json 是 Go 中 tag 使用频率最高的场景。Java 开发者可以把它类比为 Jackson 的 @JsonProperty + @JsonInclude + @JsonIgnore 三合一。
| Tag 写法 | Java 等价 | 效果 |
|---|---|---|
json:"name" | @JsonProperty("name") | 字段序列化时映射为 name |
json:",omitempty" | @JsonInclude(NON_NULL) | 保留原字段名,零值时省略 |
json:"name,omitempty" | 两者组合 | 重命名 + 零值省略 |
json:"-" | @JsonIgnore | 该字段永远不参与序列化/反序列化 |
json:"-," | — | 字段名就叫 -(转义,罕见) |
json:",string" | — | 数字/布尔转成字符串输出(如 "42") |
json:",inline" | @JsonUnwrapped | 内嵌结构体字段打平到外层(1.20+) |
| 无 tag | — | 默认用字段名(区分大小写) |
type User struct {
ID int `json:"id"`
Name string `json:"name"` // → "name": "Alice"
Age int `json:"age,omitempty"` // 0 时省略
Email string `json:"email,omitempty"` // "" 时省略
Active bool `json:"active,omitempty"` // false 时省略
Password string `json:"-"` // 永远不输出
Internal int `json:"-,"` // 字段名就是 "-"
Score float64 `json:"score,string"` // 输出 "95.5"(字符串)
}
u := User{ID: 1, Name: "Alice", Password: "secret"}
b, _ := json.Marshal(u)
// 输出:{"id":1,"name":"Alice"}
// age=0 被省略,password 被 - 忽略
omitempty 对零值生效——但 0、""、false、nil 在 Go 里都是零值。如果你的业务里 0 是有效值(比如"年龄 0 岁"表示婴儿),omitempty 会把它省略掉,导致 JSON 里丢失字段。解决方案:用指针 *int,nil 才省略,0 不省略。
type User struct {
Age *int `json:"age,omitempty"` // nil 省略,0 不省略
}
age := 0
u := User{Age: &age}
// 输出:{"age":0} ← 0 被保留
常见 Tag 速查表
Go 生态没有统一注解标准,各库自定义 tag key。下面是主流库的对照:
| Tag key | 对应库 | Java 等价注解 | 典型用法 |
|---|---|---|---|
json | encoding/json(标准库) | Jackson @JsonProperty | `json:"name,omitempty"` |
xml | encoding/xml(标准库) | JAXB @XmlElement | `xml:"name,attr"` |
yaml | gopkg.in/yaml.v3 | SnakeYAML @YamlProperty | `yaml:"name"` |
db | jmoiron/sqlx | — | `db:"user_name"` |
gorm | gorm.io/gorm | JPA @Column / @Table | `gorm:"column:name;type:varchar(50);uniqueIndex"` |
validate | go-playground/validator | Bean Validation @NotNull | `validate:"required,min=1,max=100"` |
mapstructure | HashiCorp mapstructure | — | `mapstructure:"user_name"` |
bson | go.mongodb.org/mongo-driver | Morphia @Property | `bson:"_id,omitempty"` |
ini | gopkg.in/ini.v1 | — | `ini:"server_port"` |
env | caarlos0/env | Spring @Value | `env:"PORT"` |
url | google/go-querystring | — | `url:"page"` |
一个结构体可以同时挂多个库的 tag,互不干扰:
type User struct {
Name string `json:"name" gorm:"column:user_name;not null" validate:"required,min=2" yaml:"name"`
}
这样同一个 User 既能 JSON 序列化、又能写数据库、又能做参数校验、还能读 YAML 配置——无需定义多个 DTO。Java 里这通常需要多个类(UserEntity / UserDTO / UserVO)或一堆注解堆叠。
GORM Tag 示例(最常用的 ORM)
GORM 的 tag 语法用分号分隔多个选项(注意与 JSON 的逗号不同),选项内部用冒号传参。
type User struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"column:user_name;type:varchar(50);uniqueIndex;not null"`
Email string `gorm:"uniqueIndex;size:100"`
Age int `gorm:"default:18;check:age > 0"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (User) TableName() string {
return "t_user" // 自定义表名
}
validate 校验 Tag 示例
go-playground/validator 是 Go 版的 Bean Validation,语法与 Java 的 @NotNull / @Size 类似。
public class RegisterReq {
@NotBlank(message = "用户名必填")
@Size(min = 2, max = 20)
private String username;
@Email
private String email;
@Min(18)
@Max(120)
private int age;
@Pattern(regexp = "^1\\d{10}$")
private String phone;
}
type RegisterReq struct {
Username string `validate:"required,min=2,max=20"`
Email string `validate:"required,email"`
Age int `validate:"min=18,max=120"`
Phone string `validate:"required,e164"`
// 或用 regex:
// `validate:"required,regexp=^1\\d{10}$"`
}
import "github.com/go-playground/validator/v10"
v := validator.New()
err := v.Struct(req) // 返回校验错误
Tag 的读取原理:reflect
所有 tag 都是通过 reflect 包在运行时读取的。理解原理有助于调试 tag 不生效的问题。
import "reflect"
type User struct {
Name string `json:"name" validate:"required"`
}
t := reflect.TypeOf(User{})
f, _ := t.FieldByName("Name")
fmt.Println(f.Tag.Get("json")) // "name"
fmt.Println(f.Tag.Get("validate")) // "required"
fmt.Println(f.Tag.Get("notexist")) // ""(空串,找不到)
// Lookup 返回是否存在的布尔值
val, ok := f.Tag.Lookup("json")
fmt.Println(val, ok) // name true
reflect.StructTag 本质是 type StructTag string,但它实现了 Get / Lookup 方法来按 key:"value" 格式解析。这意味着即使你把 tag 写成一坨,只要格式对就能解析出来——但也意味着格式错误时不会报错,只会返回空串。调试时可以用 go vet 检查常见 tag 格式问题。
Java 注解 vs Go Tag 总结对比
| 维度 | Java 注解 | Go Tag |
|---|---|---|
| 语法 | @Annotation("x") | `key:"x"`(反引号) |
| 类型安全 | 编译期检查(注解有类型定义) | 无检查,字符串任意写 |
| 多注解 | 堆叠:@A @B @C | 空格分隔:`a:"1" b:"2" c:"3"` |
| 读取方式 | 反射 API / 注解处理器 | reflect.StructTag.Get |
| 运行时保留 | 需 @Retention(RUNTIME) | 永远保留(字符串就在字段上) |
| 编译期生成代码 | 注解处理器(APT) | go generate + 工具(如 sqlc) |
| 错误提示 | 编译报错 | 静默失败,需 go vet / 手动排查 |
| 自定义 | 定义 @interface | 无需定义,直接用字符串 key |
Tag 写错不会报错,排查靠三点:① 用 go vet 检查格式;② 打印 f.Tag 看原始字符串;③ 对比库文档确认 key 名和选项拼写。最常犯的错:把 omitempty 拼成 omitEmpty(大小写)、把分号 ; 写成逗号 ,(GORM vs JSON 语法不同)。
🎯11 · 方法
Go 的方法不写在 class 内部,而是"附加"到类型上。这是与 Java 最大的语法差异之一。
值接收者 vs 指针接收者
这是 Java 没有的概念——方法可以绑定到值,也可以绑定到指针。选择哪种会影响:①能否修改原对象;②每次调用的复制开销;③接口实现的方式。
public class Counter {
private int count;
public void inc() { // 隐式 this
this.count++;
}
public int get() {
return count;
}
}
Counter c = new Counter();
c.inc(); // 引用传递,修改原对象
type Counter struct {
count int
}
// 指针接收者:能修改原对象
func (c *Counter) Inc() {
c.count++
}
// 值接收者:操作副本,不改原对象
func (c Counter) Get() int {
return c.count
}
c := &Counter{} // 或 Counter{},Go 自动取址
c.Inc()
n := c.Get()
| 场景 | 推荐接收者 | 原因 |
|---|---|---|
| 需要修改对象状态 | *T 指针 | 值接收者只改副本 |
| 大结构体 | *T | 避免每次调用复制 |
| 小结构体/只读 | T 值 | 更简洁,无 nil 风险 |
| 一致性 | 整型统一用一种 | 混用会导致接口实现混乱 |
不确定时默认用指针接收者。除非结构体很小且不可变(如 type Point struct{X, Y float64}),否则一律指针。
🔌12 · 接口
Go 的接口是它最优雅的设计之一,与 Java 的"显式实现"完全相反——Go 是"隐式实现"(鸭子类型,但编译期检查)。
核心差异:隐式 vs 显式
interface Speaker {
void speak();
}
class Dog implements Speaker {
@Override
public void speak() {
System.out.println("Woof");
}
}
Speaker s = new Dog();
s.speak();
type Speaker interface {
Speak()
}
type Dog struct{}
// 只要 Dog 有 Speak() 方法,就自动实现 Speaker
// 无需 "implements" 关键字!
func (d Dog) Speak() {
fmt.Println("Woof")
}
var s Speaker = Dog{}
s.Speak()
你可以为别人写的类型实现自己定义的接口,反过来也行。例如:你可以让标准库的 *os.File 满足你的接口——这在 Java 中不可能(除非包装一层)。这就是"接受接口,返回结构体"这条 Go 谚语的根基。
空接口 interface{}
Go 1.18 之前,interface{} 是"任意类型",相当于 Java 的 Object。1.18 后推荐用别名 any。
func printAny(v any) { // any = interface{}
fmt.Println(v)
}
printAny(42)
printAny("hello")
printAny([]int{1, 2})
类型断言与 type switch
从接口取出具体类型,用类型断言。
var i any = "hello"
// 单断言
s, ok := i.(string)
if ok {
fmt.Println(len(s))
}
// type switch
switch v := i.(type) {
case string:
fmt.Println("string:", v)
case int:
fmt.Println("int:", v)
default:
fmt.Printf("unknown: %T\n", v)
}
这是 Go 最隐蔽的陷阱。一个接口值实际是 (类型, 值) 二元组。即使持有的值是 nil,只要类型非空,接口本身就不等于 nil。这会导致 if err != nil 误判。
func getErr() error {
var p *MyErr = nil
return p // 返回 (类型=*MyErr, 值=nil)
}
err := getErr()
fmt.Println(err == nil) // false!看似 nil 实则不是
修复:返回前显式判 nil,返回 nil(无类型的 nil)而非具体类型的 nil。
🧩13 · 组合与继承
Go 没有继承,没有 extends,没有 abstract class。Go 的复用方式是组合,通过"嵌入"(embedding)实现。
嵌入 —— Go 的"继承"
class Animal {
void breathe() {
System.out.println("呼吸");
}
}
class Dog extends Animal {
void bark() {
System.out.println("汪汪");
}
}
Dog d = new Dog();
d.breathe(); // 继承自 Animal
d.bark();
type Animal struct{}
func (a Animal) Breathe() {
fmt.Println("呼吸")
}
type Dog struct {
Animal // 嵌入(无字段名)
}
func (d Dog) Bark() {
fmt.Println("汪汪")
}
d := Dog{}
d.Breathe() // 直接调用,像继承
d.Bark()
// 也可显式访问
d.Animal.Breathe()
嵌入是"has-a"(包含),不是"is-a"(是一个)。Dog 不是 Animal,不能用 var a Animal = Dog{}(除非 Dog 通过方法实现了 Animal 接口)。多态只能通过接口实现,不能通过嵌入。
接口也能嵌入
type Reader interface {
Read([]byte) (int, error)
}
type Writer interface {
Write([]byte) (int, error)
}
// 组合接口
type ReadWriter interface {
Reader
Writer
}
标准库 io.ReadWriter 正是这样定义的。这种小接口组合大接口的方式,正是 Go "接口要小"哲学的体现——Java 的 Closeable + Flushable 等如果这样设计会更优雅。
⚠️14 · 错误处理
错误处理是 Java 转 Go 最不适应的地方。Java 用异常控制流,Go 用返回值显式传递错误。
核心模式:(value, error)
public String read(String path) throws IOException {
File f = new File(path);
return Files.readString(f.toPath());
}
try {
String s = read("a.txt");
} catch (IOException e) {
log.error("失败", e);
}
func read(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(b), nil
}
s, err := read("a.txt")
if err != nil {
log.Printf("失败: %v", err)
return
}
use(s)
error 是一个接口
type error interface {
Error() string
}
// 最简单的创建
err := errors.New("not found")
err = fmt.Errorf("user %d not found", uid)
// 自定义错误类型
type NotFoundError struct {
Name string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s not found", e.Name)
}
错误包装与解包(Go 1.13+)
类似 Java 的异常链,Go 用 %w 包装错误,用 errors.Is / errors.As 判断。
var ErrNotFound = errors.New("not found")
func findUser(id int) (*User, error) {
if id == 0 {
// 包装,保留原始错误
return nil, fmt.Errorf("findUser %d: %w", id, ErrNotFound)
}
return &User{}, nil
}
u, err := findUser(0)
if errors.Is(err, ErrNotFound) { // 检查是否是某错误
fmt.Println("用户不存在")
}
var nfe *NotFoundError
if errors.As(err, &nfe) { // 提取具体类型
fmt.Println(nfe.Name)
}
panic / recover —— 不是异常
Go 有 panic 和 recover,但它们不是 Java 的异常。panic 用于不可恢复的严重错误(如数组越界、nil 解引用),recover 仅在 defer 中有效,用于"兜底"。
func safeCall() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("something bad") // 抛出
}
Java 开发者很容易把 panic 当 throw 用——这是反模式。panic 跨函数边界会让代码难以维护,且 recover 的性能开销大。Go 社区共识:业务错误一律用 error 返回,panic 只用于真正不可能恢复的场景(程序 bug、初始化失败)。
📌15 · 指针与内存
Java 没有显式指针(只有引用),Go 有。但别怕——Go 的指针比 C 简单得多:不能做指针运算,只是"取地址 / 解引用"。
User u = new User("Alice");
User u2 = u; // 两个引用指向同一对象
u2.name = "Bob";
System.out.println(u.name); // Bob
// 基本类型是值拷贝
int a = 10;
int b = a;
b = 20;
// a 仍然是 10
u := &User{Name: "Alice"}
u2 := u // 两个指针指向同一对象
u2.Name = "Bob"
fmt.Println(u.Name) // Bob
// 所有类型赋值都是值拷贝
a := 10
b := a
b = 20
// a 仍然是 10
// 想让函数修改外部变量,传指针
func inc(n *int) { *n++ }
x := 10
inc(&x)
// x == 11
值 vs 指针:何时用
| 场景 | 选择 |
|---|---|
| 函数内修改外部变量 | 传指针 *T |
| 大结构体避免复制 | 传指针 *T |
| 表示"可能为空" | 用指针(nil 表示空) |
| 小结构体、不可变数据 | 传值 T,更安全 |
| map、slice 本身就是引用 | 直接传值即可,无需指针 |
Go 与 Java 都有 GC,但策略不同:Java 用分代 GC,吞吐量优先;Go 用并发三色标记,延迟优先,STW 通常在 1ms 以内。这意味着 Go 不需要像 Java 那样疯狂避免短命对象分配——但大型服务仍要注意分配压力。Go 没有 finalize(已废弃),资源释放靠 defer。
Go 编译器会做逃逸分析:如果局部变量的指针被返回到外部,它会"逃逸"到堆上。用 go build -gcflags="-m" 可以查看逃逸情况,是性能优化的关键工具。
🚀16 · Goroutine
这是 Go 相对 Java 最大的优势。Java 的线程是 OS 线程(1:1),创建成本高(约 1MB 栈);Go 的 goroutine 是用户态轻量级线程(M:N 调度),初始栈仅 2KB,可以轻松开十万级。
// 方式1:继承 Thread
new Thread(() -> {
doWork();
}).start();
// 方式2:线程池
ExecutorService pool =
Executors.newFixedThreadPool(10);
pool.submit(() -> doWork());
// Java 21 虚拟线程(接近 goroutine)
Thread.startVirtualThread(() -> {
doWork();
});
// 加一个 go 关键字即可!
go doWork()
// 匿名函数
go func() {
doWork()
}()
// 带参数(注意传值,不要闭包捕获循环变量)
for i := 0; i < 100; i++ {
go process(i)
}
GMP 调度模型
Go 的调度器用 GMP 模型管理 goroutine:
- G (Goroutine):用户协程
- M (Machine):OS 线程,真正执行代码
- P (Processor):逻辑处理器,持有本地 G 队列,数量 =
GOMAXPROCS(默认等于 CPU 核数)
当某个 G 阻塞(如系统调用),M 会与 P 分离,P 转给另一个 M 继续调度其他 G——这就是 Go 高并发的秘密。Java 21 的虚拟线程也有类似机制,但生态成熟度远不及 Go。
goroutine 启动后如果永远阻塞(比如等一个没人发的 channel),就会泄漏,永远占用内存。生产代码必须配套 context 或超时机制。用 runtime.NumGoroutine() 监控数量,或用 pprof 排查。
📡17 · Channel
Channel 是 goroutine 间通信的管道。Go 的核心理念:"不要通过共享内存通信,而是通过通信共享内存"。这与 Java 的"共享内存 + 锁"思路相反。
BlockingQueue<Integer> q =
new LinkedBlockingQueue<>();
// 生产者
new Thread(() -> {
q.put(42);
}).start();
// 消费者
new Thread(() -> {
int v = q.take(); // 阻塞
}).start();
// 无缓冲 channel(同步)
ch := make(chan int)
// 有缓冲 channel(异步)
ch := make(chan int, 10)
go func() {
ch <- 42 // 发送
}()
v := <-ch // 接收(阻塞直到有数据)
// 关闭 channel
close(ch)
// 遍历直到关闭
for v := range ch {
use(v)
}
无缓冲 vs 有缓冲
- 无缓冲
make(chan T):发送和接收必须同时就绪,相当于"会合"(rendezvous)。强同步。 - 有缓冲
make(chan T, N):缓冲满前发送不阻塞。适合解耦生产消费速度。
select 多路复用
类似 Java NIO 的 Selector,但语法更简洁。用于同时等待多个 channel。
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
case ch3 <- 42:
fmt.Println("sent")
default:
fmt.Println("no activity") // 非阻塞
}
// 超时模式
select {
case v := <-ch:
use(v)
case <-time.After(3 * time.Second):
fmt.Println("timeout")
}
- 信号:
chan struct{}(空结构体,不占内存) - 结果传递:goroutine 计算完发回主流程
- 扇出/扇入:多个 worker → 一个合并 channel
- 限流:缓冲 channel 当令牌桶
- 退出信号:
chan struct{}+close广播退出
close(ch) 后,接收会得到零值且不阻塞,但发送会直接 panic。所以"谁创建谁关闭"是铁律——生产者关闭,消费者只读。如果多个生产者,绝不能直接关闭,要用额外的 done channel 协调。
🔒18 · 并发同步原语
虽然 Go 倡导 channel,但标准库 sync 包也提供了传统同步原语。某些场景下用锁比 channel 更直接。
| Go | Java 对应 | 用途 |
|---|---|---|
sync.Mutex | ReentrantLock(不可重入) | 互斥锁 |
sync.RWMutex | ReentrantReadWriteLock | 读写锁 |
sync.WaitGroup | CountDownLatch | 等待一组 goroutine 完成 |
sync.Once | 双重检查锁单例 | 只执行一次 |
sync.Cond | Condition.await/signal | 条件变量 |
sync.Pool | ThreadLocal 池化 | 对象复用 |
sync.Map | ConcurrentHashMap | 并发 map |
sync/atomic | AtomicInteger 等 | 原子操作 |
WaitGroup —— 最常用
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
final int id = i;
new Thread(() -> {
try {
doWork(id);
} finally {
latch.countDown();
}
}).start();
}
latch.await(); // 等待全部完成
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done() // 推荐用 defer
doWork(id)
}(i)
}
wg.Wait() // 等待全部完成
context 包 —— 并发控制的核心
这是 Java 没有但极其重要的概念。context 用于在 goroutine 间传递取消信号、超时、值。生产 Go 代码几乎每个函数第一个参数都是 ctx context.Context。
func doWork(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
return nil
case <-ctx.Done(): // 被取消或超时
return ctx.Err()
}
}
// 带超时的 context
ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second)
defer cancel() // 务必调用,否则泄漏
doWork(ctx)
- context 作为函数第一个参数,命名为
ctx - 不要把 context 存到 struct 字段里(少数框架除外)
- cancel 函数一定要 defer 调用,否则会泄漏 goroutine
📚19 · 包管理与项目结构
Go Modules vs Maven/Gradle
| 维度 | Java | Go |
|---|---|---|
| 依赖描述 | pom.xml / build.gradle | go.mod |
| 锁定文件 | 无 / gradle.lockfile | go.sum(强校验) |
| 包坐标 | group:artifact:version | 直接用 Git URL:github.com/lib/pq |
| 版本规则 | SNAPSHOT / RELEASE | SemVer:v1.2.3 |
| 私有仓库 | 配置 settings.xml | GOPRIVATE 环境变量 |
<project>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
</dependencies>
</project>
module github.com/example/myapp
go 1.22
require (
github.com/google/uuid v1.6.0
golang.org/x/sync v0.6.0
)
# 常用命令
# go mod init github.com/example/myapp
# go get github.com/google/uuid@latest
# go mod tidy # 清理未用依赖
# go mod vendor # 供应商模式
项目结构约定
Go 社区有 Standard Project Layout 约定,但不是强制的。常见结构:
myapp/
├── go.mod
├── go.sum
├── main.go # 入口
├── cmd/ # 多个可执行命令
│ └── server/
│ └── main.go
├── internal/ # 只能本模块导入(编译器强制)
│ ├── handler/
│ ├── service/
│ └── repository/
├── pkg/ # 对外公开的库代码
│ └── client/
├── api/ # API 定义(OpenAPI、proto)
├── configs/
├── scripts/
└── test/ # 集成测试
internal/ 是 Go 编译器强制的访问控制——其中的包只能被 internal 父级目录的代码导入。这是 Java 没有的强约束,天然防止内部实现被外部依赖。
🔍20 · 反射
Go 有反射(reflect 包),但比 Java 弱,且社区共识是"能不用就不用"。性能差、类型安全差。
Class<?> clazz = obj.getClass();
Field f = clazz.getDeclaredField("name");
f.setAccessible(true);
String name = (String) f.get(obj);
Method m = clazz.getMethod("speak");
m.invoke(obj);
t := reflect.TypeOf(obj)
v := reflect.ValueOf(obj)
// 读字段
f := v.FieldByName("Name")
name := f.String()
// 调用方法
m := v.MethodByName("Speak")
m.Call(nil)
// 修改字段(需传指针)
v = reflect.ValueOf(&obj).Elem()
v.FieldByName("Name").SetString("Bob")
反射最常见的合法用途:
- encoding/json:标准库内部用反射实现序列化
- ORM:如 gorm 映射结构体到表
- 依赖注入框架(少数场景)
需要高性能时,用 go generate + 工具(如 stringer、mockgen、easyjson)在编译期生成代码,运行时无反射开销。这是 Go 性能优于 Java 动态代理的关键之一。
🧬21 · 泛型
Go 1.18(2022 年)才加入泛型,比 Java 晚了 17 年。语法和实现机制都不同。
public class Box<T> {
private T value;
public T get() { return value; }
public void set(T v) { value = v; }
}
Box<String> b = new Box<>();
b.set("hi");
// 运行时 T 被擦除为 Object
// 不能 new T()、不能 new T[]
type Box[T any] struct {
value T
}
func (b *Box[T]) Get() T { return b.value }
func (b *Box[T]) Set(v T) { b.value = v }
b := &Box[string]{}
b.Set("hi")
// 类型约束
func Sum[T constraints.Ordered](xs []T) T {
var s T
for _, x := range xs { s += x }
return s
}
类型约束
Java 用 <T extends Number> 限定上界;Go 用 [T constraints],约束是一个接口。
// 内置约束
type Number interface {
~int | ~int64 | ~float64 // ~ 表示包含底层类型
}
func Max[T Number](a, b T) T {
if a > b { return a }
return b
}
// comparable 约束(可比较 == !=)
func Contains[T comparable](xs []T, v T) bool {
for _, x := range xs {
if x == v { return true }
}
return false
}
Java 是类型擦除:运行时 Box<String> 和 Box<Integer> 是同一个类,无法在运行时获知 T 的具体类型。Go 是编译期单态化(部分):不同类型参数会生成不同代码,运行时能保留类型信息,性能更好,但二进制体积略增。
Go 社区对泛型持谨慎态度。官方建议:先写具体类型版本,确认有真实复用需求再抽象成泛型。不要为了用而用——Go 哲学依然是"简单优先"。
🧪22 · 测试
Go 把测试作为一等公民,内置在工具链中,无需 JUnit/TestNG。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
void add() {
var calc = new Calculator();
assertEquals(5, calc.add(2, 3));
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void test(int n) { ... }
}
// calculator_test.go(文件名必须 _test.go)
package calc
import "testing"
func TestAdd(t *testing.T) {
calc := &Calculator{}
got := calc.Add(2, 3)
if got != 5 {
t.Errorf("Add(2,3)=%d, want 5", got)
}
}
// 表驱动测试(Go 特色)
func TestAdd_Table(t *testing.T) {
cases := []struct {
a, b, want int
}{
{1, 2, 3},
{0, 0, 0},
{-1, 1, 0},
}
for _, c := range cases {
got := calc.Add(c.a, c.b)
if got != c.want {
t.Errorf("Add(%d,%d)=%d, want %d",
c.a, c.b, got, c.want)
}
}
}
运行测试
go test ./... # 运行所有包测试
go test -v ./... # 详细输出
go test -run TestAdd ./... # 过滤
go test -cover ./... # 覆盖率
go test -race ./... # 竞态检测(强烈推荐)
go test -bench=. ./... # 基准测试
go test -fuzz=FuzzXxx # 模糊测试(1.18+)
基准测试 Benchmark
Java 需要 JMH 框架;Go 内置。
func BenchmarkConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
s := "a" + "b" + "c"
_ = s
}
}
// go test -bench=. -benchmem
CI 中一定要加 -race 标志,它会检测数据竞争。Go 的 race detector 比任何 Java 工具都强——能在测试时精确报告哪两行代码并发访问了同一变量。Java 找并发 bug 往往要靠生产事故。
🛠️23 · 工具链
Go 的工具链是其竞争力之一——一个 go 命令搞定 Java 需要 Maven + Checkstyle + JaCoCo + JMH + FindBugs 才能做的事。
| 任务 | Java | Go |
|---|---|---|
| 构建 | mvn package | go build |
| 运行 | java -jar | go run / 直接执行二进制 |
| 测试 | mvn test + JUnit | go test |
| 格式化 | IDE / Checkstyle | gofmt / goimports |
| 依赖 | mvn dependency:tree | go mod graph |
| 文档 | Javadoc | go doc / pkg.go.dev |
| 静态检查 | SpotBugs / SonarQube | go vet / golangci-lint |
| 性能分析 | VisualVM / async-profiler | go tool pprof |
| 竞态检测 | 无内置 | -race |
| 代码生成 | 注解处理器 | go generate |
| 交叉编译 | 需对应 JDK | GOOS=linux GOARCH=arm64 go build |
Java 要交叉编译到不同平台很麻烦;Go 一行命令搞定:GOOS=linux GOARCH=amd64 go build。这是 Docker 流行的助推器——Go 写的 CLI 工具可以一个二进制塞进 alpine 镜像,几 MB 搞定。
推荐的第三方工具
- golangci-lint:聚合几十种 linter,CI 必备
- gorename:安全重命名(IDE 自带)
- delve (dlv):调试器,比 gdb 更懂 Go
- air:热重载开发
- mockgen / moq:mock 生成
- protoc-gen-go:gRPC 代码生成
⚡24 · 性能优化
字符串拼接
Go 的 string 不可变,循环拼接用 strings.Builder(类似 Java 的 StringBuilder)。
StringBuilder sb = new StringBuilder();
for (String s : list) {
sb.append(s);
}
String r = sb.toString();
var sb strings.Builder
for _, s := range list {
sb.WriteString(s)
}
r := sb.String()
// 已知数量的拼接用 Join 更快
r = strings.Join(list, ",")
预分配切片容量
// 差:多次扩容
var s []int
for i := 0; i < 1000; i++ {
s = append(s, i)
}
# 好:预分配
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
pprof 性能分析
# CPU profile
go test -cpuprofile=cpu.out ./...
go tool pprof cpu.out
(pprof) top
(pprof) list FunctionName
(pprof) web # 生成调用图(需 graphviz)
# 内存 profile
go test -memprofile=mem.out ./...
# 在线服务
import _ "net/http/pprof"
# 然后访问 http://localhost:6060/debug/pprof/
其他优化要点
- 逃逸分析:
go build -gcflags="-m"查看哪些变量逃逸到堆 - sync.Pool:复用临时对象,减少 GC 压力(如 buffer 池)
- 避免 interface{} 装箱:泛型比 interface{} 快
- 字节数组优于字符串:IO 处理用
[]byte,避免 string 转换 - 结构体字段顺序:按大小降序排列,减少内存对齐浪费
- GOGC:默认 100(堆翻倍触发 GC),延迟敏感可调高(如 200),内存敏感调低
💣25 · Java 开发者常见陷阱
这是 Java 转 Go 最容易踩的坑,建议反复阅读。
1. 循环变量捕获
for i := 0; i < 3; i++ {
go func() {
fmt.Print(i, " ") # 可能输出 3 3 3
}()
}
# Go 1.22+ 已修复:每次循环 i 是新变量
# 但旧版本需手动 i := i
2. nil 切片可以 append,nil map 不能写
var s []int
s = append(s, 1) # ✅ 可以
var m map[string]int
m["a"] = 1 # ❌ panic: assignment to entry in nil map
m = make(map[string]int)
m["a"] = 1 # ✅
3. interface 持有 nil 不等于 nil
详见第 12 节。这是最隐蔽的坑。
4. 数组是值类型
func modify(arr [3]int) {
arr[0] = 99 # 改的是副本
}
a := [3]int{1, 2, 3}
modify(a)
fmt.Println(a[0]) # 仍然是 1
# 想修改传指针,或用切片
5. 字符串遍历按 rune 不是 byte
s := "你好"
fmt.Println(len(s)) # 6(字节数)
for i := 0; i < len(s); i++ {
fmt.Print(s[i], " ") # 按字节:228 189 160 ...
}
for i, r := range s {
fmt.Printf("%d:%c ", i, r) # 按 rune:0:你 3:好
}
6. 没有 try-with-resources,别忘 defer
Java 的 try-with-resources 自动关闭资源;Go 必须手动 defer。打开文件/数据库连接后立刻 defer 关闭,不要等到函数中间。
7. switch 默认不穿透
Java 的 switch 默认穿透需要 break;Go 默认不穿透需要 fallthrough。完全相反。
8. defer 在 return 之后执行
func f() int {
defer func() {
fmt.Println("defer") # 在 return 后执行
}()
return 42 # 先求值
}
9. goroutine 启动不等于执行
主函数返回时,所有 goroutine 直接被杀死,不管是否完成。必须用 WaitGroup 或 channel 等待。
10. JSON 标签的 omitempty
omitempty 对零值生效——0、""、false、nil 都会被省略。如果 0 是有效业务值(如年龄 0),会出问题。需要用指针 *int 区分"未设置"和"0"。
🎓26 · 迁移建议与学习路径
思维转变清单
- 放下继承:不要找"基类",用组合 + 接口。设计模式中的 Template Method、Strategy、Observer 在 Go 里形态完全不同。
- 放下异常:每个返回 error 的函数都要检查。烦但安全。用
errors.Is/As替代instanceof。 - 放下注解魔法:Go 没有 Spring 那种注解驱动。配置用 struct + env,依赖注入用手写或 wire。
- 放下 Lombok:没有自动 getter/setter。要么字段公开,要么手写方法。可用工具生成。
- 放下 Optional:Go 用零值 + ok 模式或指针表示"可能没有"。
- 放下 Stream API:Go 没有链式 Stream。用 for 循环——更直白也更快。
- 拥抱 goroutine:不要怕开 goroutine,它们很便宜。但要配套 context 和超时。
Java 概念到 Go 的映射速查
| Java | Go |
|---|---|
class | struct + 方法 |
interface + implements | interface(隐式实现) |
extends | 嵌入(embedding) |
abstract class | 无对应,用接口 + 默认结构体 |
static 方法/字段 | 包级函数/变量 |
null | nil(仅指针/接口/slice/map/channel/function) |
Optional<T> | *T 或 (T, bool) |
throws Exception | (T, error) 返回值 |
try-catch-finally | if err != nil + defer |
synchronized | sync.Mutex / channel |
Thread / ExecutorService | goroutine + channel |
ConcurrentHashMap | sync.Map |
CountDownLatch | sync.WaitGroup |
enum | type X int + const iota |
注解 @Override | 无(编译器自动检查) |
Lombok @Data | 手写或工具生成 |
Spring @Autowired | 手动构造 / wire / fx |
| JPA / Hibernate | gorm / sqlx / ent |
| Jackson / Gson | encoding/json(标准库) |
| Logback / Log4j | log/slog(标准库 1.21+) |
| JUnit | testing(标准库) |
| Maven / Gradle | Go modules |
| Spring Boot | Gin / Echo / Fiber / 标准库 net/http |
推荐学习路径
- A Tour of Go(tour.go.dev)—— 官方互动教程,半天搞定语法
- Effective Go(go.dev/doc/effective_go)—— Go 惯用法
- Go by Example(gobyexample.com)—— 代码示例查询
- 读标准库源码:
io、fmt、net/http、context都是范本 - 实践项目:写一个 HTTP API 服务(用 Gin 或标准库)
- 进阶:100 Go Mistakes and How to Avoid Them(书)
推荐技术栈对应
| 领域 | Java | Go |
|---|---|---|
| Web 框架 | Spring Boot | Gin / Echo / 标准库 net/http |
| ORM | JPA / MyBatis | gorm / sqlx / ent / sqlc |
| 依赖注入 | Spring DI | 手写 / wire / fx |
| 配置 | Spring Config | viper / envconfig |
| 日志 | SLF4J + Logback | slog / zap / zerolog |
| gRPC | grpc-java | grpc-go |
| 消息队列 | Kafka Client | sarama / kafka-go |
| Redis | Lettuce / Jedis | go-redis |
| 测试 | JUnit + Mockito | testing + testify / gomock |
| CLI | Picocli | cobra / urfave/cli |
不要试图把 Java 的设计模式搬到 Go。Go 的代码会比 Java 短 30%~50%,结构更扁平。学会"用最简单的方式解决问题"——这就是 Go 的哲学。当你写的 Go 代码读起来像 Java,说明你还没真正切换思维。
- 官方文档 go.dev/doc
- 包文档 pkg.go.dev
- Go Wiki (GitHub)
- 书:《Go 程序设计语言》(Donovan & Kernighan)
- 书:《Concurrency in Go》(Katherine Cox-Buday)
Go 语言开发指南 · Java 开发者专属
愿你在 Go 的世界走得轻快 🐹