Go 语言开发指南

写给 Java 开发者的完整迁移手册 —— 从语法到工程,从并发到哲学

Java 17+ ↔ Go 1.22+ 25 个核心主题 100+ 代码对比 实战陷阱清单

🎯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 命令,集编译、依赖、测试、格式化、文档于一体,无需第三方构建工具。

Shell · 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
概念JavaGo
SDK 路径JAVA_HOMEGOROOT
工作区项目目录GOPATH(模块化后基本不用了)
包仓库Maven Central直接从 Git 仓库拉取
构建工具Maven / Gradle内置 go build
格式化IDE 配置内置 gofmt / goimports
文档生成Javadoc内置 go doc + 注释

推荐 IDE

✅ 强制格式化

Go 社区强制使用 gofmt 统一代码风格,没有风格之争。保存即格式化,团队不需要配置 .editorconfig 或 Checkstyle。这是 Go "一种方式做一件事"哲学的体现。

👋03 · Hello World

从最简单的程序开始,已经能看到 Go 与 Java 的诸多差异。

☕ Java
// Hello.java
public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

// 编译运行:
// javac Hello.java && java Hello
🐹 Go
// main.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

// 编译运行:
// go run main.go
// 或编译为二进制:go build

关键差异

⚠️ 陷阱:大括号位置

从 Java 过来很容易写成

func main()
{                       // ❌ 编译错误!
    ...
}

Go 编译器会强制要求 { 紧跟函数签名同行。这是 gofmt 的硬性规定,避免了 Java 社区多年的风格之争。

import 详解

Java 用 import 包名.类名; 导入单个类;Go 用 import "路径" 导入整个包,然后通过 包名.成员 访问。这是两者最根本的差异——Java 导入的是"符号",Go 导入的是"命名空间"。

☕ Java · import 类
// 导入单个类
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);   // 直接用,无前缀
🐹 Go · import 包
// 导入整个包,用包名.成员访问
import "fmt"
import "strings"

// 多个包用括号分组(推荐)
import (
    "fmt"
    "strings"
)

fmt.Println(strings.ToUpper("hi"))

// Go 没有 import *,也没有静态导入
// 一律用 包名.成员 访问

import 的五种形式

Go 的 import 比 Java 灵活,有五种形式应对不同场景。Java 开发者最需要理解的是空白导入和别名导入。

🐹 Go · import 五种形式
// ① 标准导入:用包名访问
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 用别名导入。

☕ Java · 全限定名
// 两个同名 Date 类
import java.util.Date;
// java.sql.Date 只能用全限定名

Date utilDate = new Date();
java.sql.Date sqlDate =
    new java.sql.Date(System.currentTimeMillis());
🐹 Go · 别名
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,但不需要配置文件。

🐹 Go · 空白导入注册数据库驱动
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 社区不推荐日常使用,因为它模糊了名称来源。

🐹 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 工具会自动整理。

🐹 Go · import 分组
import (
    // 第一组:标准库
    "fmt"
    "os"
    "strings"

    // 第二组:第三方库
    "github.com/gin-gonic/gin"
    "gorm.io/gorm"

    // 第三组:本项目内部包
    "myapp/internal/handler"
    "myapp/internal/model"
)
⚠️ 未使用的 import 会编译报错

Java 只是警告(IDE 标灰),Go 直接编译失败。导入了一个包但没用其中任何名称,必须删除该 import。例外:空白导入 import _ 不受此限制(因为不导入名称)。这是 Go "零冗余"哲学的体现。goimports 工具可以自动增删 import,建议配置到 IDE 保存时自动执行。

⚠️ 不支持循环导入

Java 允许类之间互相引用(A 依赖 B,B 依赖 A);Go 禁止包之间循环导入——A 包 import B,B 包就不能 import A,否则编译报错。这是 Go 有意为之的约束,强制你把共享逻辑提取到第三个包。看似不便,实则让依赖关系始终是有向无环图(DAG),可维护性远好于 Java 的互相依赖。

✅ goimports 工具

Go 没有 Java 的 "Organize Imports" IDE 功能之争——goimports 是官方工具,保存时自动:① 添加缺失的 import;② 删除未使用的 import;③ 按三组规范排序。GoLand 和 VS Code 都支持配置为保存时自动运行。这比 Java 的 import 管理更省心。

import 路径规则

类型路径Java 对应示例
标准库无域名前缀java.util"fmt""net/http"
第三方库仓库完整 URLMaven 坐标"github.com/gin-gonic/gin"
本项目包模块名 + 子路径项目包路径"myapp/internal/handler"
internal 包internal/只能被 internal 父级目录导入
vendored 包vendor/ 目录下离线构建用
💡 包名 vs 导入路径

Go 的 import 路径是字符串(如 "github.com/gin-gonic/gin"),但包名是路径最后一段(gin)。代码中用 gin.Get() 而非全路径。别名导入可以改变这个默认包名。这与 Java 不同——Java 的 import 路径和类名是一体的。

📦04 · 变量与类型

变量声明

Java 的类型在左、Go 的类型在右——这是最直观的差异。Go 还提供了多种简写形式。

☕ Java
int x = 10;
String name = "Alice";
final double PI = 3.14;

// 多变量
int a = 1, b = 2, c = 3;
🐹 Go
// 完整声明
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 零值
整数00
浮点0.00.0
布尔falsefalse
字符串null""(空串,不是 nil
引用/对象nullnil(指针、slice、map、channel、interface、function)
数组/结构体逐元素/逐字段的零值
⚠️ 字符串的零值不是 nil

Go 的 string 是值类型,零值是空串 ""永远不为 nil。这与 Java 的 String(引用类型,可为 null)完全不同。如果你需要"可能没有字符串"的语义,用 *string 指针。

类型系统对比

概念JavaGo
整数byte/short/int/long,有符号int8/16/32/64uint8/16/32/64int/uint(平台相关)
字节byte(-128~127,有符号!)byte(= uint8,0~255,无符号)
字符char(16 位 Unicode)rune(= int32,Unicode 码点)
浮点float/doublefloat32/float64
布尔booleanbool
字符串String(引用,可变 via StringBuilder)string(值,不可变
类型转换隐式 widening,显式 narrowing全部显式,无任何隐式转换
☕ Java
int i = 100;
long l = i;          // 隐式 widening ✅
double d = i;       // 隐式 ✅

int n = (int) 3.14;  // 显式 narrowing
🐹 Go
var i int = 100
var l int64 = int64(i)   // 必须显式
var d float64 = float64(i) // 必须显式

var n int = int(3.14)       // 显式

类型别名 Type Alias

Go 允许为类型起别名,提升可读性。

🐹 Go
type UserID int            // 新类型,与 int 不兼容
type Score = int          // 别名,完全等价于 int

var uid UserID = 42
var n int = int(uid)      // UserID 需显式转 int

🔀05 · 控制流

条件语句

☕ Java
if (x > 0) {
    ...
} else if (x == 0) {
    ...
} else {
    ...
}

// 条件必须是 boolean
if (obj != null) { ... }
🐹 Go
if x > 0 {            // 无括号!
    ...
} else if x == 0 {
    ...
} else {
    ...
}

// 初始化语句(Go 特色)
if v, err := compute(); err != nil {
    return err
} else {
    use(v)
}
  • 无括号:Go 的 iffor 条件不带 (),但 { 必须同行。
  • 初始化语句if x := getValue(); x > 0 {...} 是 Go 惯用模式,变量 x 的作用域仅限该 if-else 块。
  • 没有三元运算符:Go 故意没有 ?:,必须用完整 if-else。

switch

Go 的 switch 与 Java 差异较大——默认不穿透(不需要 break),反而需要 fallthrough 才穿透。

☕ Java
switch (day) {
    case "Sat":
    case "Sun":              // 穿透
        weekend();
        break;
    default:
        weekday();
}

// Java 12+ switch 表达式
var r = switch (n) {
    case 1 -> "one";
    default -> "many";
};
🐹 Go
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 砍掉了 whiledo-while,全部用 for 一种形式表达。

☕ Java
// 经典 for
for (int i = 0; i < 10; i++) { ... }

// while
while (cond) { ... }

// do-while
do { ... } while (cond);

// 增强 for
for (String s : list) { ... }
🐹 Go
// 经典 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 后进先出),常用于资源释放。

☕ Java
BufferedReader br = new BufferedReader(...);
try {
    return br.readLine();
} finally {
    br.close();   // 必须在 finally
}

// 或 try-with-resources
try (var br = new BufferedReader(...)) {
    return br.readLine();
}
🐹 Go
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 关闭"——这样不会忘记释放。注意 defer 的参数在声明时求值,不是执行时。

🔧06 · 函数

函数定义语法

Java 的方法必须写在 class 内部,带修饰符(public/static/final 等);Go 的函数是包级的,用 func 关键字声明,没有修饰符——可见性靠首字母大小写控制。

☕ Java · 方法定义
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;
    }
}
🐹 Go · 函数定义
// 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 静态方法"之分——包级函数就是"静态方法",方法(带接收者)就是"实例方法"。
  • 不支持方法重载:同名函数只能有一个签名。需要不同参数?换名字(add2add3)或用变参。
  • 不支持默认参数值:Go 故意砍掉。需要可选参数用 Builder 模式或 functional options。
⚠️ 没有重载、没有默认参数

从 Java 过来会觉得这两点很不方便。但 Go 的观点是:重载让代码阅读时需要跳转查签名,默认参数让调用语义不透明。显式优于隐式。替代方案:① 用不同函数名;② 用变参 ...;③ 用 functional options 模式(高级)。

参数传递:Go 只有值传递

这是 Java 开发者最容易误解的地方。Go 和 Java 一样,所有参数都是值传递——传递的是值的副本。但两者对"值"的理解不同,导致行为差异。

参数类型Java 行为Go 行为
基本类型值拷贝,函数内修改不影响外部同 Java
对象/结构体传引用的副本(可改字段,不能换对象)整体值拷贝(函数内改字段不影响外部!)
slice拷贝 slice 头(指针+len+cap),共享底层数组
map拷贝指针,共享底层数据
想修改外部无法选择(对象天然引用)显式传 *T 指针
☕ Java · 对象自动引用
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
🐹 Go · 结构体是值拷贝
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 看似引用传递

slice 和 map 传给函数后,函数内修改影响外部——这不是引用传递,而是它们内部持有指针,拷贝的是"头部结构"而非底层数据。所以:append 可能扩容导致原 slice 不变(新数组),但 s[i] = x 修改元素会反映到外部(共享底层)。理解这一点能避免 90% 的 slice 陷阱。

多返回值 —— Go 的招牌

Go 函数可以返回多个值,这是 Java 没有的。最常见的就是 (result, error) 双返回模式。

☕ Java
// 用异常表示错误
public String readFile(String path)
        throws IOException {
    ...
    return content;
}

// 或返回包装类
public Result<String> readFile(String p) {
    ...
}
🐹 Go
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 可裸写。

🐹 Go
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 直接把函数当值传递,无需函数式接口包裹。

☕ Java
// 需要函数式接口
@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));
}
🐹 Go
// 函数类型直接写
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 闭包会捕获外部变量,但要注意——捕获的是变量引用,不是值的快照。

🐹 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 的 ... 在类型

☕ Java
// ... 在类型后
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);                // 直接传数组
🐹 Go
// ... 在类型前
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 要展开!
⚠️ 传 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 函数更灵活。

☕ Java · 静态初始化块
public class Config {
    private static final Map<String, String> map;

    static {                // 类加载时执行一次
        map = new HashMap<>();
        map.put("k", "v");
    }
}
🐹 Go · init 函数
// 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 执行顺序

  1. 导入的包先初始化(递归向下)
  2. 包级变量按声明顺序初始化
  3. 同一文件内的多个 init 按出现顺序执行
  4. 同包多文件的 init 按文件名字母序执行
  5. 最后执行 main 函数
⚠️ init 函数要克制使用

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
☕ Java
// 定长数组
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();
🐹 Go
// 定长数组(很少用)
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 没有的概念。

🐹 Go · 切片内部结构
// 切片本质是一个结构体:
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 完全没有的特性,也是最容易踩坑的地方。

🐹 Go · 切片共享底层
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 触发扩容时,会分配新数组,原切片和 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,但有几个关键差异需要注意。

☕ Java
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) -> ...);
🐹 Go
// 字面量
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")
⚠️ map 不是并发安全的

Go 的 map 不是 Java 的 ConcurrentHashMap。并发读写同一个 map 会直接 panic(fatal error),不是数据错乱,是程序崩溃!并发场景请用 sync.Mapsync.RWMutex 加锁。

⚠️ map 的零值不能写入

var m map[string]int 得到的是 nil map,读取返回零值,但写入会 panic。必须用 make 初始化后才能写。

🗂️09 · 集合框架对比

Java 拥有庞大的集合框架(java.util 包下数十个类),Go 则刻意保持极简——标准库只内置 slicemap 两种,其余用组合实现。本节是 Java 集合 → Go 的完整映射手册。

全景对应表

JavaGo 对应说明
ArrayList[]T(slice)最常用,动态数组(第 07 节详讲
LinkedListcontainer/list双向链表,极少使用
HashSetmap[T]struct{}用 map 模拟 Set
TreeSet无内置,sort + []T排序 slice 替代
LinkedHashSet需第三方库或自封装
HashMapmap[K]V内置(第 08 节详讲
TreeMap需第三方(如 ordered map)
LinkedHashMapGo map 本身无序
ConcurrentHashMapsync.Map并发安全 map
PriorityQueuecontainer/heap堆接口,需自己实现
ArrayDeque无内置用 slice 模拟
Stack无内置用 slice(append + [len-1:]
Collections.unmodifiableList需自封装或返回切片副本
Stream APIfor 循环 + if
Iteratorrange语言内置遍历
💡 Go 的哲学:少即是多

Java 有 40+ 集合类,Go 只有 slice + map 两个。看起来 Go"缺"了很多,但实践中 90% 的场景 slice + map 就够了。需要 Set?用 map[T]struct{}。需要队列?用 channel 或 slice。需要优先队列?用 container/heap。Go 认为"一种方式做一件事"比"提供所有可能的容器"更重要。

Set —— 用 map 模拟

Java 有 HashSetTreeSetLinkedHashSet;Go 没有内置 Set,用 map[T]struct{} 模拟。struct{} 是空结构体,不占内存(0 字节)。

☕ Java · HashSet
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);  // 差集
🐹 Go · map 模拟 Set
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
}
✅ Go 1.18 泛型 Set 封装

有了泛型,可以封装一个通用的 Set[T] 类型,添加 Add/Has/Remove/Union/Intersect 等方法,达到 Java HashSet 的便利度。推荐第三方库 golang.org/x/exp/mapsgolang.org/x/exp/slices(标准库扩展)提供常用集合操作。

队列与栈 —— 用 slice 或 channel

Java 有 QueueDequeStackPriorityQueue 一整套体系;Go 只内置 channel(并发队列)和 container/heap(堆),栈和普通队列用 slice 模拟。

☕ Java · 栈与队列
// 栈
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 (最小堆)
🐹 Go · slice 模拟
// 栈:用 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(较繁琐)
⚠️ slice 做队列效率低

queue = queue[1:] 出队时,底层数组的第一个元素不会被回收,且不断前移指针。大量出队场景请用 container/list(链表)或 channel(并发队列)。

排序

Java 用 Collections.sort() / List.sort() / stream.sorted(),可传 Comparator;Go 用 sort 包,支持基本类型和自定义比较器。

☕ Java · 排序
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);
🐹 Go · sort 包
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 循环,之后标准库终于内置了 slicesmaps 两个泛型包。它们相当于 Java 的 Collections 工具类 + Stream 的部分能力,但以独立函数而非链式 API 的形式提供。

💡 slices 包的定位

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)按条件判断相等

查找与判断

☕ Java
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");
🐹 Go · slices 包
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
✅ BinarySearch 返回双值

Java 的 binarySearch 返回 int(找到返回索引,未找到返回负数 -(插入点)-1),需要额外计算。Go 的 slices.BinarySearch 返回 (pos int, found bool) 双值,语义清晰,不会搞混。

修改操作

☕ Java
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);
🐹 Go · slices 包
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]
⚠️ slices 函数返回新 slice,需接收

Java 的 list.add(i, v) 原地修改;Go 的 slices.Insert / Delete / Replace 返回新 slice,必须用 s = slices.Insert(s, ...) 接收返回值。忘记接收会导致修改不生效。这是值语义的必然结果——slice 头是值拷贝,函数内改了长度不会影响外部。

排序详解

slices.Sort 要求元素类型实现 cmp.Ordered 约束(即支持 < 比较的类型:整数、浮点、字符串)。自定义结构体排序用 SortFunc

🐹 Go · 排序全示例
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]
⚠️ SortFunc 比较函数返回 int 不是 bool

Java 的 Comparator.compare(a, b) 和 Go 的 SortFunc 比较函数都返回 int(负/零/正),但旧版 sort.Slice 用的是 boolless 函数)。迁移时注意: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 相等
☕ Java · Map 操作
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
);
🐹 Go · maps 包
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
})
✅ maps.DeleteFunc 是并发安全的写法替代

Go 的 map 不能在 range 遍历时删除元素(会 panic)。以前要删除满足条件的 key,需要先收集再删;现在 maps.DeleteFunc 一步搞定,内部安全处理了遍历中删除的问题。

用 slices + maps 替代 Stream 操作

有了 slicesmaps,很多原本需要手写 for 循环的集合操作现在可以一行完成。下表展示 Java Stream 常见操作如何用这两个包替代:

Java StreamGo 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

slicesmaps 提供离散的集合操作函数,但无法像 Stream 那样链式组合。Java 可以写 list.stream().filter().map().sorted().collect() 一行搞定;Go 需要多步操作,每步产生中间 slice。这是 Go 有意为之——显式优于隐式,中间过程看得见、可调试、无隐藏开销。

cmp 包:比较的基石

Go 1.21 同时引入了 cmp 包(注意不是 cmp 泛型约束的旧包),提供 CompareOr 两个实用函数。

🐹 Go · cmp 包
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 替代 if-else 链

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,更直白也更快。

☕ Java · Stream API
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());
🐹 Go · for 循环
// 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 API

Go 团队认为:① Stream 的链式调用隐藏了控制流,降低可读性;② for 循环性能更好(无中间集合、无 lambda 捕获开销);③ for 循环可以用 break / continue 精确控制,Stream 不能。Go 的哲学是"代码读起来像什么就做什么",for 循环最符合这个原则。Java 开发者一开始会不习惯,但很快会发现 for 循环其实更直白。

不可变集合

Java 9+ 提供了 List.of() / Set.of() / Map.of() 创建不可变集合;Go 没有内置不可变集合——slice 和 map 总是可变的。要实现"只读",只能靠约定或自封装。

☕ Java · 不可变集合
// 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 · 返回副本或用接口限制
// 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 有 ConcurrentHashMapCopyOnWriteArrayListConcurrentLinkedQueue 等一系列并发容器;Go 只内置 sync.Map,其余用 sync.RWMutex + 普通集合组合实现。

☕ Java · 并发容器
ConcurrentHashMap<String, Integer> map =
    new ConcurrentHashMap<>();
map.put("a", 1);      // 线程安全
map.computeIfAbsent("b",
    k -> 2);

CopyOnWriteArrayList<String> list =
    new CopyOnWriteArrayList<>();
list.add("x");          // 写时复制
🐹 Go · sync.Map / Mutex
// 方式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 不是 HashMap 的替代品

sync.Map 没有泛型(返回 any 需类型断言)、没有 len()、不能 range(要用 Range 回调)。它专为"多读少写"或"多个独立 key 写"场景优化。普通并发 map 用 sync.RWMutex + map 更灵活、类型更安全。详见第 18 节

常用操作速查

操作JavaGo
创建列表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 本身就是数组
创建 Mapnew HashMap<>()make(map[K]V)
判断 keymap.containsKey(k)_, ok := m[k]
遍历 keysmap.keySet()maps.Keys(m)(1.21+)
遍历 valuesmap.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
⚠️ Java→Go 集合最大的三个不适应
  1. 没有 Set:用 map[T]struct{} 模拟,需自行封装常用操作。
  2. 没有 Stream:filter/map/reduce 全用 for 循环手写,更啰嗦但更可控。
  3. 没有不可变集合:slice/map 总是可变的,"只读"靠约定或返回副本。

适应这三点后,你会发现 Go 的集合虽然"少",但每个操作都透明可控——没有 Stream 的隐藏中间集合、没有迭代器并发修改异常、没有懒加载陷阱。

🏗️10 · 结构体

Go 没有 class 关键字,用 struct 表示数据结构。struct 只含字段,行为通过"方法"附加(下一节讲)。

☕ Java
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);
🐹 Go
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 前缀。

🐹 Go
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、校验库在运行时通过反射读取这些元数据。

☕ Java · 注解
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;
}
🐹 Go · 结构体标签
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(静默失败)。
⚠️ 多 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默认用字段名(区分大小写)
🐹 Go · JSON 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 与"零值即有效值"的冲突

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 等价注解典型用法
jsonencoding/json(标准库)Jackson @JsonProperty`json:"name,omitempty"`
xmlencoding/xml(标准库)JAXB @XmlElement`xml:"name,attr"`
yamlgopkg.in/yaml.v3SnakeYAML @YamlProperty`yaml:"name"`
dbjmoiron/sqlx`db:"user_name"`
gormgorm.io/gormJPA @Column / @Table`gorm:"column:name;type:varchar(50);uniqueIndex"`
validatego-playground/validatorBean Validation @NotNull`validate:"required,min=1,max=100"`
mapstructureHashiCorp mapstructure`mapstructure:"user_name"`
bsongo.mongodb.org/mongo-driverMorphia @Property`bson:"_id,omitempty"`
inigopkg.in/ini.v1`ini:"server_port"`
envcaarlos0/envSpring @Value`env:"PORT"`
urlgoogle/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 的逗号不同),选项内部用冒号传参。

🐹 Go · GORM 标签
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 类似。

☕ Java · Bean Validation
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;
}
🐹 Go · validator
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 不生效的问题。

🐹 Go · 手动读取 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
💡 StructTag 是结构体,不是普通字符串

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 调试技巧

Tag 写错不会报错,排查靠三点:① 用 go vet 检查格式;② 打印 f.Tag 看原始字符串;③ 对比库文档确认 key 名和选项拼写。最常犯的错:把 omitempty 拼成 omitEmpty(大小写)、把分号 ; 写成逗号 ,(GORM vs JSON 语法不同)。

🎯11 · 方法

Go 的方法不写在 class 内部,而是"附加"到类型上。这是与 Java 最大的语法差异之一。

值接收者 vs 指针接收者

这是 Java 没有的概念——方法可以绑定到值,也可以绑定到指针。选择哪种会影响:①能否修改原对象;②每次调用的复制开销;③接口实现的方式。

☕ Java
public class Counter {
    private int count;

    public void inc() {      // 隐式 this
        this.count++;
    }

    public int get() {
        return count;
    }
}

Counter c = new Counter();
c.inc();              // 引用传递,修改原对象
🐹 Go
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 显式

☕ Java · 显式 implements
interface Speaker {
    void speak();
}

class Dog implements Speaker {
    @Override
    public void speak() {
        System.out.println("Woof");
    }
}

Speaker s = new Dog();
s.speak();
🐹 Go · 隐式实现
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

🐹 Go
func printAny(v any) {   // any = interface{}
    fmt.Println(v)
}

printAny(42)
printAny("hello")
printAny([]int{1, 2})

类型断言与 type switch

从接口取出具体类型,用类型断言。

🐹 Go
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)
}
⚠️ nil 接口 ≠ 接口持有 nil 值

这是 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 的"继承"

☕ Java · 继承
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();
🐹 Go · 嵌入
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 接口)。多态只能通过接口实现,不能通过嵌入。

接口也能嵌入

🐹 Go · 接口组合
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)

☕ Java · 异常
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);
}
🐹 Go · 返回 error
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 是一个接口

🐹 Go · 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 判断。

🐹 Go · 错误链
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 中有效,用于"兜底"。

🐹 Go · panic/recover
func safeCall() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    panic("something bad")   // 抛出
}
⚠️ 不要用 panic 当异常用

Java 开发者很容易把 panic 当 throw 用——这是反模式。panic 跨函数边界会让代码难以维护,且 recover 的性能开销大。Go 社区共识:业务错误一律用 error 返回,panic 只用于真正不可能恢复的场景(程序 bug、初始化失败)。

📌15 · 指针与内存

Java 没有显式指针(只有引用),Go 有。但别怕——Go 的指针比 C 简单得多:不能做指针运算,只是"取地址 / 解引用"。

☕ Java · 引用
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
🐹 Go · 指针
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 本身就是引用直接传值即可,无需指针
💡 GC 差异

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,可以轻松开十万级。

☕ Java · 线程
// 方式1:继承 Thread
new Thread(() -> {
    doWork();
}).start();

// 方式2:线程池
ExecutorService pool =
    Executors.newFixedThreadPool(10);
pool.submit(() -> doWork());

// Java 21 虚拟线程(接近 goroutine)
Thread.startVirtualThread(() -> {
    doWork();
});
🐹 Go · goroutine
// 加一个 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 泄漏

goroutine 启动后如果永远阻塞(比如等一个没人发的 channel),就会泄漏,永远占用内存。生产代码必须配套 context 或超时机制。用 runtime.NumGoroutine() 监控数量,或用 pprof 排查。

📡17 · Channel

Channel 是 goroutine 间通信的管道。Go 的核心理念:"不要通过共享内存通信,而是通过通信共享内存"。这与 Java 的"共享内存 + 锁"思路相反。

☕ Java · 阻塞队列
BlockingQueue<Integer> q =
    new LinkedBlockingQueue<>();

// 生产者
new Thread(() -> {
    q.put(42);
}).start();

// 消费者
new Thread(() -> {
    int v = q.take();  // 阻塞
}).start();
🐹 Go · channel
// 无缓冲 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。

🐹 Go · select
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")
}
✅ Channel 的典型用法
  • 信号chan struct{}(空结构体,不占内存)
  • 结果传递:goroutine 计算完发回主流程
  • 扇出/扇入:多个 worker → 一个合并 channel
  • 限流:缓冲 channel 当令牌桶
  • 退出信号chan struct{} + close 广播退出
⚠️ 向已关闭的 channel 发送会 panic

close(ch) 后,接收会得到零值且不阻塞,但发送会直接 panic。所以"谁创建谁关闭"是铁律——生产者关闭,消费者只读。如果多个生产者,绝不能直接关闭,要用额外的 done channel 协调。

🔒18 · 并发同步原语

虽然 Go 倡导 channel,但标准库 sync 包也提供了传统同步原语。某些场景下用锁比 channel 更直接。

GoJava 对应用途
sync.MutexReentrantLock(不可重入)互斥锁
sync.RWMutexReentrantReadWriteLock读写锁
sync.WaitGroupCountDownLatch等待一组 goroutine 完成
sync.Once双重检查锁单例只执行一次
sync.CondCondition.await/signal条件变量
sync.PoolThreadLocal 池化对象复用
sync.MapConcurrentHashMap并发 map
sync/atomicAtomicInteger原子操作

WaitGroup —— 最常用

☕ Java · CountDownLatch
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();  // 等待全部完成
🐹 Go · WaitGroup
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

🐹 Go · 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 三原则
  1. context 作为函数第一个参数,命名为 ctx
  2. 不要把 context 存到 struct 字段里(少数框架除外)
  3. cancel 函数一定要 defer 调用,否则会泄漏 goroutine

📚19 · 包管理与项目结构

Go Modules vs Maven/Gradle

维度JavaGo
依赖描述pom.xml / build.gradlego.mod
锁定文件无 / gradle.lockfilego.sum(强校验)
包坐标group:artifact:version直接用 Git URL:github.com/lib/pq
版本规则SNAPSHOT / RELEASESemVer:v1.2.3
私有仓库配置 settings.xmlGOPRIVATE 环境变量
☕ Java · pom.xml
<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>
🐹 Go · go.mod
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 目录

internal/ 是 Go 编译器强制的访问控制——其中的包只能被 internal 父级目录的代码导入。这是 Java 没有的强约束,天然防止内部实现被外部依赖。

🔍20 · 反射

Go 有反射(reflect 包),但比 Java 弱,且社区共识是"能不用就不用"。性能差、类型安全差。

☕ 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);
🐹 Go · reflect
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 + 工具(如 stringermockgeneasyjson)在编译期生成代码,运行时无反射开销。这是 Go 性能优于 Java 动态代理的关键之一。

🧬21 · 泛型

Go 1.18(2022 年)才加入泛型,比 Java 晚了 17 年。语法和实现机制都不同。

☕ Java · 类型擦除泛型
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[]
🐹 Go · 编译期单态化
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],约束是一个接口。

🐹 Go · 约束
// 内置约束
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。

☕ Java · JUnit 5
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) { ... }
}
🐹 Go · 内置 testing
// 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)
        }
    }
}

运行测试

Shell
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 内置。

🐹 Go
func BenchmarkConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := "a" + "b" + "c"
        _ = s
    }
}
// go test -bench=. -benchmem
✅ -race 必开

CI 中一定要加 -race 标志,它会检测数据竞争。Go 的 race detector 比任何 Java 工具都强——能在测试时精确报告哪两行代码并发访问了同一变量。Java 找并发 bug 往往要靠生产事故。

🛠️23 · 工具链

Go 的工具链是其竞争力之一——一个 go 命令搞定 Java 需要 Maven + Checkstyle + JaCoCo + JMH + FindBugs 才能做的事。

任务JavaGo
构建mvn packagego build
运行java -jargo run / 直接执行二进制
测试mvn test + JUnitgo test
格式化IDE / Checkstylegofmt / goimports
依赖mvn dependency:treego mod graph
文档Javadocgo doc / pkg.go.dev
静态检查SpotBugs / SonarQubego vet / golangci-lint
性能分析VisualVM / async-profilergo tool pprof
竞态检测无内置-race
代码生成注解处理器go generate
交叉编译需对应 JDKGOOS=linux GOARCH=arm64 go build
💡 交叉编译是 Go 的杀手锏

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)。

☕ Java
StringBuilder sb = new StringBuilder();
for (String s : list) {
    sb.append(s);
}
String r = sb.toString();
🐹 Go
var sb strings.Builder
for _, s := range list {
    sb.WriteString(s)
}
r := sb.String()

// 已知数量的拼接用 Join 更快
r = strings.Join(list, ",")

预分配切片容量

🐹 Go
// 差:多次扩容
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 性能分析

Shell · 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. 循环变量捕获

⚠️ Go
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 不能写

⚠️ Go
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. 数组是值类型

⚠️ Go
func modify(arr [3]int) {
    arr[0] = 99     # 改的是副本
}

a := [3]int{1, 2, 3}
modify(a)
fmt.Println(a[0])   # 仍然是 1

# 想修改传指针,或用切片

5. 字符串遍历按 rune 不是 byte

⚠️ Go
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 之后执行

⚠️ Go
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 · 迁移建议与学习路径

思维转变清单

  1. 放下继承:不要找"基类",用组合 + 接口。设计模式中的 Template Method、Strategy、Observer 在 Go 里形态完全不同。
  2. 放下异常:每个返回 error 的函数都要检查。烦但安全。用 errors.Is/As 替代 instanceof
  3. 放下注解魔法:Go 没有 Spring 那种注解驱动。配置用 struct + env,依赖注入用手写或 wire。
  4. 放下 Lombok:没有自动 getter/setter。要么字段公开,要么手写方法。可用工具生成。
  5. 放下 Optional:Go 用零值 + ok 模式或指针表示"可能没有"。
  6. 放下 Stream API:Go 没有链式 Stream。用 for 循环——更直白也更快。
  7. 拥抱 goroutine:不要怕开 goroutine,它们很便宜。但要配套 context 和超时。

Java 概念到 Go 的映射速查

JavaGo
classstruct + 方法
interface + implementsinterface(隐式实现)
extends嵌入(embedding)
abstract class无对应,用接口 + 默认结构体
static 方法/字段包级函数/变量
nullnil(仅指针/接口/slice/map/channel/function)
Optional<T>*T(T, bool)
throws Exception(T, error) 返回值
try-catch-finallyif err != nil + defer
synchronizedsync.Mutex / channel
Thread / ExecutorServicegoroutine + channel
ConcurrentHashMapsync.Map
CountDownLatchsync.WaitGroup
enumtype X int + const iota
注解 @Override无(编译器自动检查)
Lombok @Data手写或工具生成
Spring @Autowired手动构造 / wire / fx
JPA / Hibernategorm / sqlx / ent
Jackson / Gsonencoding/json(标准库)
Logback / Log4jlog/slog(标准库 1.21+)
JUnittesting(标准库)
Maven / GradleGo modules
Spring BootGin / Echo / Fiber / 标准库 net/http

推荐学习路径

  1. A Tour of Go(tour.go.dev)—— 官方互动教程,半天搞定语法
  2. Effective Go(go.dev/doc/effective_go)—— Go 惯用法
  3. Go by Example(gobyexample.com)—— 代码示例查询
  4. 读标准库源码:iofmtnet/httpcontext 都是范本
  5. 实践项目:写一个 HTTP API 服务(用 Gin 或标准库)
  6. 进阶:100 Go Mistakes and How to Avoid Them(书)

推荐技术栈对应

领域JavaGo
Web 框架Spring BootGin / Echo / 标准库 net/http
ORMJPA / MyBatisgorm / sqlx / ent / sqlc
依赖注入Spring DI手写 / wire / fx
配置Spring Configviper / envconfig
日志SLF4J + Logbackslog / zap / zerolog
gRPCgrpc-javagrpc-go
消息队列Kafka Clientsarama / kafka-go
RedisLettuce / Jedisgo-redis
测试JUnit + Mockitotesting + testify / gomock
CLIPicoclicobra / urfave/cli
✅ 最后的忠告

不要试图把 Java 的设计模式搬到 Go。Go 的代码会比 Java 短 30%~50%,结构更扁平。学会"用最简单的方式解决问题"——这就是 Go 的哲学。当你写的 Go 代码读起来像 Java,说明你还没真正切换思维。

📚 进阶资源

Go 语言开发指南 · Java 开发者专属

愿你在 Go 的世界走得轻快 🐹