1. 从Java到Go,先把心态和模型切换过来
写了七八年Java,因为项目需要,年初把自己按头塞进Go语言里恶补了两周,发现并没有想象中那么痛苦。Java和Go都是静态类型、编译型、带垃圾回收的语言,很多概念可以互相映射,真正需要下功夫的不是语法本身,而是把Java那一套“面向对象建模”的思维惯性降维到Go的“组合优先”世界。今天这篇Java速通Go基础内容的笔记,就是从一个Java工程师视角出发,对比两边的核心差异,帮大家用最短的时间把Go的基础骨架搭起来。
先说结论:Go是一门刻意做减法的语言,它砍掉了继承、异常、泛型(1.18之前连泛型都没有),甚至不允许运算符重载,一开始你可能觉得别扭,但上手后会发现这些限制恰好是工程化的优势。适合谁看?有Java基础,想快速转Go写微服务、写工具、做云原生相关项目的开发同学。如果你完全零基础,这篇可能不够细,建议先过一遍官方A Tour of Go再回来对照。
环境方面,如果还没装Go,去官网下对应系统的安装包,Windows/macOS直接安装,Linux解压tar包然后配置PATH,终端跑一下go version能出来就是装好了。Go安装和Java环境变量配置比起来简单太多,不需要配置JAVA_HOME那一堆东西,这也是我第一次感受到两个语言“性格”不同。
1.1 为什么Java开发者学Go有天然优势
Java开发者学Go最大的优势不是语法,而是已经有了编译型语言和运行时模型的基本认知。你熟悉类加载、垃圾回收、接口多态、异常处理、包管理机制,这些在Go里都能找到对应物,只是表达方式不同。
比如Java的JVM负责内存管理和运行时调度,Go同样有运行时(runtime),它的goroutine调度、垃圾回收都内建在二进制文件里。你不需要知道HotSpot的堆分区细节,但能理解“回收是自动的”这一点就够了。加上Go的GC延迟极低,并发模型又是语言级语法,很多Java里要绕几层框架才能实现的并发控制,Go几行代码就能写完。
另一个优势是你熟悉Maven/Gradle的依赖管理思路,所以理解go mod也会很快。GOPATH时代可能很多人被绕晕过,但现在的Go Modules方案和Maven仓库思想几乎一致:声明依赖、锁定版本、本地缓存、可重复构建。后面第五部分我会详细讲。
1.2 先忘掉“万物皆对象”,Go是组合优先
Java是典型的面向对象语言,class、extends、implements、abstract、interface满天飞,建模思路是“is-a”继承关系。Go不同,它没有class,没有继承,接口也不是靠implements关键字声明的。如果非要用Java的语言去对应,我建议这么理解:
- Java的class近似于Go的struct,但struct只是字段的集合,没有行为属于谁的概念,行为靠方法单独绑定。
- Java的继承体系在Go里建议改成“嵌入字段”的匿名组合,效果上有重合,但语义完全不同。
- Java的接口靠implements显示声明,Go的接口只要方法匹配就自动算实现,不需要你表忠心。
举个最简单的例子,Java里一个子类继承父类,子类自动拥有父类的方法,这是一种“我是你”的关系。Go更倾向于在结构体里嵌入另一个结构体,相当于“我包含你”的关系。后面我会用一个完整的代码对比来说明。
我踩过的第一个坑就是:想用“继承”去设计Go的代码,结果写得很别扭。后来切换到组合思维,所有问题都迎刃而解。所以学Go之前,先把这个心智模型切过来,后面所有内容都会顺畅很多。
2. 语法速览:用Java的语法脑去映射Go
这部分我会用“Java怎么写,Go怎么写”的对照方式,带你把最常用的语法过一遍。不追求穷尽,只讲高频、易混、核心的内容。
2.1 变量、常量与类型声明差异
先看变量。Java声明变量必须是类型 变量名 = 值;,Go有两种方式:一种带var,类型写在变量后面;一种用短变量声明:=自动推断类型。
// Java 写法 private static final String NAME = "hello"; int count = 0; List<String> tags = new ArrayList<>();// Go 写法 const name = "hello" // 常量,包级别或函数内均可 var count int // 变量,会自动赋零值0 tags := []string{} // 短变量声明,自动推断为[]string这里有几个点值得注意。Go的变量如果没有显式赋值,编译器会给你一个零值:int是0,string是空字符串,bool是false,slice是nil,*指针是nil。Java的局部变量可不能这样直接用,这点Go要随性得多。
另一个坑是:=只能在函数内部使用。包级别变量如果想用短变量声明,会直接报错。我看到不少刚转Go的人在这里卡住,习惯性在包级别写name := "x",编译不过就懵了。
类型上,Go有基本类型:int/int8/int16/int32/int64、uint、float32/float64、bool、string、byte、rune,这些大家对照Java记就行。有个关键区别:Go的string不是String类,它是只读的字节序列,可以用下标访问单个字节,但不能直接修改某个字符。Java里String不可变但可以重新赋值,Go同样可以,但底层语义不同。
不需要担心Java的List/Map对应关系。Go的slice对应ArrayList,map对应HashMap,数组array对应Java数组,但Go里日常90%场景用slice而不是数组。slice是动态扩容的,底层是一段连续内存,这点和Java ArrayList几乎一样,只不过Go的扩展是append函数触发的。
2.2 控制流:for是唯一循环,switch自带break
Java有三套循环:for、while、do-while。Go很干脆,只有一个for关键字,但可以通过不同写法模拟所有循环。下面的代码对Java开发者来说几乎零成本:
// 经典for循环 for i := 0; i < 10; i++ { fmt.Println(i) } // 模拟while,Java的while(true) for { if !cond { break } } // 模拟while(cond) for cond { // 执行体 } // 遍历slice,类似Java增强for names := []string{"a", "b", "c"} for i, name := range names { fmt.Println(i, name) }这里最常用的是for range,它可以遍历slice、map、string、channel。你Java里写for (Map.Entry<K,V> e : map.entrySet()),Go里直接:
for key, value := range map { fmt.Println(key, value) }非常爽。
switch这块是个典型陷阱。Java里每个case如果不写break会穿透,我记得很多人因为这个出过bug。Go的switch每个case默认就是break的,不需要写,也不会往下走。而且Go的switch的case值可以是任意类型,甚至可以直接不带表达式:
switch { // 相当于if-else链 case x > 0: fmt.Println("positive") case x < 0: fmt.Println("negative") default: fmt.Println("zero") }还有switch type的用法,这是Java没有的。配合类型断言判断接口动态类型,在做通用处理时非常方便:
var v interface{} = 42 switch t := v.(type) { case int: fmt.Println("int", t) case string: fmt.Println("string", t) }Java 16之后有instanceof pattern matching,思路类似,但Go写起来更直接。
2.3 函数是一等公民:多返回值与defer
Java的方法只能返回一个值,想要返回值+错误,只能封装Result对象或者用异常。Go直接支持多返回值,这在Go里是日常操作。一个函数返回(结果, error)是标准姿势。
func divide(a, b int) (int, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil }调用时:
result, err := divide(10, 0) if err != nil { fmt.Println("出错:", err) return } fmt.Println(result)主打一个错误显式处理,先不讨论好不好,至少一眼就能看出来这个函数可能失败。
defer是Go特别常用的一个关键字,作用是在函数退出前执行某个调用,类似Java的finally,但更灵活。典型场景是关闭文件、释放锁、记录耗时:
func readFile(path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() // 函数返回前自动执行 // 文件操作 return nil }注意defer是栈式执行,也就是后声明先执行。如果函数里defer了多个操作,要注意顺序。还有一点,defer的参数在声明时就会求值,而不是在函数退出时求值。比如:
start := time.Now() defer fmt.Println("耗时", time.Since(start)) // time.Since是在defer执行时才调用? 这里time.Since的参数在声明时求值严格说,如果写defer fmt.Println(time.Since(start)),实际上time.Since(start)的参数start在defer注册时就被求值了,但函数体要等到退出时才真正执行time.Since吗?不对,参数表达式是立即求值的,所以time.Since(start)会立即调用并返回一个Duration存入延迟调用。这会导致耗时看起来是0。正确的写法是用闭包:
defer func() { fmt.Println("耗时", time.Since(start)) }()这是一个很经典的坑,我见过很多新人在这里绕了很久。后面排查心得里再展开。
3. 核心抽象:结构体、接口与组合
基础语法过了之后,最关键的是Go的类型系统。很多人问Go是不是面向对象语言,我的回答是:它是,但和Java不是同一个套路。它有封装、有类型、有接口、有多态,但没有继承和重载。
3.1 结构体与方法:没有类,但有接收者
Java的类既是数据容器也是行为载体。Go把这两件事拆开了:struct只管数据,方法由func (接收者) 方法名(...)定义,接收者就是把函数绑定到某类型上的桥。
type User struct { Name string Age int } // 值接收者 func (u User) SayHi() string { return "Hi, " + u.Name } // 指针接收者 func (u *User) SetAge(age int) { u.Age = age }这里必须搞清楚值和指针的区别。Java里对象本身是引用,你在方法里改User的字段,外面一定生效。Go如果方法使用值接收者,相当于Java里传了一个副本,改了不影响原对象。所以:
- 如果方法需要修改结构体字段,或者结构体很大不想复制,用指针接收者
*User。 - 如果只是读取,值接收者也行,但也要注意复制成本。
我自己养成的习惯是:除了小型不可变对象用值接收者,大部分方法直接定义在指针上,省心。另外注意,指针接收者和值接收者算两个不同类型的方法集,这会影响接口实现,下面说。
3.2 接口:鸭子类型与隐式实现
Java接口必须通过implements显式声明实现关系。Go接口是隐式的,只要一个类型拥有接口定义的所有方法,它就自动实现了该接口,不需要声明。这叫“鸭子类型”,长得像鸭子,行为是鸭子,那它就是鸭子。
type Greeter interface { Greet() string } type User struct { Name string } func (u User) Greet() string { return "Hello " + u.Name } // 不需要写 implements Greeter func printGreeting(g Greeter) { fmt.Println(g.Greet()) } func main() { u := User{Name: "Tom"} printGreeting(u) // 自动满足 }这在写测试的时候特别方便。Java里想mock一个接口,通常要建class去implements,或者用Mockito。Go直接定义一个能凑齐方法的假类型就行,灵活太多。
有一点和Java不同:Java的接口定义的是契约,类型不实现就不能用。Go的接口是“行为结构体”,所以更小、更专一的接口更吃香。一个接口的方法是越多,反而越难通用。Go社区流行“接口小即好”,一个接口一两个方法是最舒服的。
由于接口是隐式实现,指针接收者和值接收者的方法集有差异,一个常见的坑是:
type Greeter interface { Greet() string } type User struct { Name string } func (u *User) Greet() string { return "Hello " + u.Name } func f(g Greeter) {} func main() { u := User{} f(&u) // 可以 f(u) // 编译错误!*User才是Greeter }这个细节要记牢,你看着像“同一个方法”,但接口校验时,值类型只有值接收者的方法,指针类型同时拥有值接收者和指针接收者的方法。更简单记法:如果一个类型的方法都是值接收者,值类型和指针类型都能实现接口;一旦有个方法是指针接收者,那么接口实现要靠指针。
3.3 组合替代继承,嵌出一个好设计
Go没有extends,但可以通过匿名字段嵌入实现类似的字段和方法“提升”。意思很简单:结构体里直接写另一个类型名,不写字段名,该类型的字段和方法就被提升到外层。
type Base struct { ID int Time string } func (b Base) GetID() int { return b.ID } type User struct { Base // 嵌入 Name string } func main() { u := User{Base: Base{ID: 1, Time: "2025-01-01"}, Name: "Tom"} fmt.Println(u.ID) // 提升字段 fmt.Println(u.GetID()) // 提升方法 }功能上有点类似Java的继承复用,但注意语义完全不同。Go不是自动多态,你也不能“重写”Base的方法然后期待调用时动态分发。如果User也定义了GetID,它的方法会覆盖Base的GetID,但这只是普通的方法遮罩,不存在Java的@override概念。
所以在设计上,我建议:
- 把嵌入当作“字段复用”和“方法组合”,而不是继承。
- 想实现多态效果,用接口+结构体组合,不要试图搭建深度继承树。
- 嵌入字段的命名冲突会导致编译错误,要尽量避免同样的字段名在不同层出现。
Java里“组合优于继承”这句话很多人听过但没用上,Go直接让你没得选,只能用组合。这也是我觉得学Go能反过来帮助自己提升Java设计水平的原因。
4. 并发模型:goroutine与channel是Go的杀手锏
Java并发有Thread、ExecutorService、synchronized、Lock、BlockingQueue、CompletableFuture等一套复杂的体系。Go的哲学很简单:goroutine(协程)+ channel(管道)搞定一切。入门门槛非常低。
4.1 goroutine入门:比线程更轻的并发单元
Java创建一个新线程,不管用继承Thread还是实现Runnable,都要写不少样板代码,而且Java线程通常映射到操作系统线程,高并发下数量多了会吃内存、上下文切换成本高。Go的goroutine是由Go运行时自己调度的用户态轻量级协程,初始栈只有几KB,可以随用随扩,开十万个goroutine也很常见。
Go启动一个并发任务只需要一个go关键字:
func main() { go fmt.Println("hello from goroutine") time.Sleep(100 * time.Millisecond) // 大致等一等 }你会发现这里很别扭:main函数不会等待goroutine执行完,程序可能直接退出。Java里main结束前,非守护线程还会继续执行,但Go里main函数结束整个进程就结束了,所有goroutine会被直接杀掉。
所以需要一个等待机制。最基本的用sync.WaitGroup:
func main() { var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() fmt.Println("任务1") }() go func() { defer wg.Done() fmt.Println("任务2") }() wg.Wait() }WaitGroup就是Java里的CountDownLatch,这个思路还是很好理解的。这里提醒一点:goroutine的调度是非确定性的,两个任务打印顺序可能每次都不同,别被“明明我先写的,为什么后执行”迷惑住。
4.2 channel:用通信共享内存,而不是共享内存通信
Go有一个名言:不要通过共享内存通信,要通过通信共享内存。Java传统并发里,多个线程通过synchronized锁住一个共享对象来互斥访问,Go更推荐用channel把数据从一个goroutine传给另一个goroutine,在流动过程中自然完成同步。
channel定义:
ch := make(chan int) // 无缓冲通道,同步 ch2 := make(chan int, 10) // 有缓冲通道,异步无缓冲通道的收发都会阻塞。发送方要等接收方准备好,接收方要等发送方有数据。这个机制天然实现了同步和调度。举个例子:
func main() { ch := make(chan int) go func() { ch <- 42 // 发送,直到main中有人接收才会成功 }() v := <-ch // 接收,会阻塞直到有数据 fmt.Println(v) }有缓冲通道则相当于一个队列,可以在不阻塞发送的情况下先填满缓冲。Java对应的是BlockingQueue + ThreadPool的思路,但Go的channel写法更直接。
遍历channel也很常用,配合close:
ch := make(chan int, 5) for i := 0; i < 5; i++ { ch <- i } close(ch) // 发送方close for v := range ch { fmt.Println(v) }这里注意几个坑:
- 发送端负责close channel,接收端一般不close,否则发送时会panic。
- 对已经close的channel继续发送数据会panic。
- channel的零值nil,收发都会永久阻塞。所以map要初始化,channel要make,否则就卡死。
我实际踩过最典型的坑是:忘了close,然后另一个goroutine用range等着接收,程序直接死锁。排查半天才看到是一个for range没有结束条件。
4.3 select与超时,并发控制的基本功
select语句可以让一个goroutine同时等待多个channel操作,类似于Java的NIO Selector,只要有一个channel就绪就继续。常用它做超时控制、多路复用:
func main() { ch := make(chan int, 1) go func() { time.Sleep(2 * time.Second) ch <- 1 }() select { case v := <-ch: fmt.Println("收到", v) case <-time.After(1 * time.Second): fmt.Println("超时了,先撤") } }这里select从多个case里随机选择一个可执行的,如果所有case都阻塞,就会一直等;如果存在default分支,则立即执行default。这个机制可以做一个非阻塞检查:
select { case v := <-ch: fmt.Println("有数据", v) default: fmt.Println("现在没数据") }Java里要做类似操作,集合类有non-blocking的offer/poll,但多个通道同时等待,就得用CompletableFuture或者复杂的锁,代码量明显多。Go的select对Java开发者来说是个全新的利器,建议多写几个小例子感受一下。
并发安全也要提一句。Go有sync.Mutex对应Java的synchronized/Lock,还有sync.RWMutex对应ReentrantReadWriteLock,更有sync/atomic包的原子操作。但有channel之后,很多互斥场景可以转换为生产者消费者模型,减少锁的使用。
5. 错误处理与包管理:和Java完全不同的哲学
这章要讲清楚两个最让Java开发者不习惯的地方:错误处理和依赖管理。
5.1 错误就是值:if err != nil 的世界
Java的错误处理是异常机制:try/catch/finally,方法签名里throws声明,调用方不处理异常会一路向上抛。这有好有坏,好处是代码可以集中捕获,坏处是异常流常常被打断,很多业务异常也被当作异常抛来抛去。
Go的哲学完全不同:错误就是普通的值,函数明确返回error,调用方必须处理,不处理就是nil被忽略,然后程序说不定在远处爆炸。所以Go代码里你会看到铺天盖地的if err != nil。
f, err := os.Open("file.txt") if err != nil { return err } defer f.Close()这不叫繁琐,这叫明确。每个可能出错的点都显式检查,读取代码的人可以很清楚看到函数哪些地方可能失败。
Java开发者转Go后常见的抱怨是不习惯,但写几天就会明白,这种模式反而能逼你处理边缘情况。不过写多了也可能会觉得重复,解决办法是封装执行函数,减少重复检查,或者使用错误包装:
if err := process(); err != nil { return fmt.Errorf("处理失败: %w", err) }用%w可以包装下层错误,配合errors.Is/As判断错误链,这是Go 1.13之后的推荐做法,类似Java的suppressed/cause异常链。
5.2 panic/recover与异常机制的区别
Go里也有panic和recover,但它们不是让你做业务错误处理的。panic相当于Java里的Error或者RuntimeException,一般是不可恢复的严重问题:数组越界、空指针、状态不一致等。默认情况下panic会让整个程序崩溃并打印堆栈。
Java的try/finally对应Go的defer,但Go不鼓励用recover来模拟try/catch。recover只应该在defer函数里调用,用来做进程级的兜底,比如防止一个goroutine崩溃导致整个服务退出:
func safeRun(fn func()) { defer func() { if r := recover(); r != nil { fmt.Println("recovered from panic:", r) } }() fn() }在Web服务框架里,中间件会做这层兜底。你自己写业务代码时,遇到错误该返回error就返回error,不要用panic+recover搞一套类异常体系的控制流。我见过有人把Java的try/catch习惯带过来,写一堆panic/recover,最后代码逻辑绕成一团,性能也会受影响。
5.3 go mod 包管理与GOPATH时代的结束
Java依赖管理用Maven或Gradle,pom.xml里声明依赖,中央仓库拉取,groupId+artifactId+version三段坐标。Go早期GOPATH强制代码必须放在指定目录,非常难受。现在的go mod已经成熟,使用方式类似Maven。
初始化一个项目:
go mod init example.com/myapp然后写代码时import第三方包,比如:
import "github.com/gin-gonic/gin"接着执行:
go mod tidyGo会自动解析源码里的import,下载最新版本,并把依赖记录到go.mod和go.sum文件中。go.sum类似Maven的checksum校验,保证依赖完整性。
常用命令:
go build # 编译当前包 go run main.go # 直接运行 go test ./... # 跑所有包的测试 go mod tidy # 整理依赖 go get github.com/xxx/yyy # 获取指定依赖或升级这里几个概念要理解:
- 一个模块(module)的根目录有go.mod,模块名下辖多个包(package)。
- 源码文件顶部
package main表示可执行程序入口,其他都是普通包。 - 同一目录下的.go文件必须属于同一个package,这点和Java的包路径不太一样,Java一个目录下可以有不同包,Go不行。
依赖管理还有一个本地代理问题,国内网络下载模块可能慢,这个大家自己想办法,我不展开多说。
6. 实战:用Go重写一个Java小需求
光说不练假把式。我们拿一个常见的业务需求来对比Java和Go的实现:有一组用户,每个用户有姓名、年龄和分数,请统计每个年龄段的平均分,并按平均分降序输出。年龄段按10岁一档:0-9、10-19、20-29以此类推。
6.1 需求描述与Java版本
先看Java版本,用record + Stream分组:
import java.util.*; import java.util.stream.Collectors; public class Main { record User(String name, int age, double score) {} public static void main(String[] args) { List<User> users = List.of( new User("A", 8, 80), new User("B", 12, 90), new User("C", 15, 70), new User("D", 22, 95), new User("E", 25, 85) ); Map<Integer, DoubleSummaryStatistics> stats = users.stream() .collect(Collectors.groupingBy( u -> (u.age() / 10) * 10, Collectors.summarizingDouble(User::score) )); stats.entrySet().stream() .sorted(Map.Entry.<Integer, DoubleSummaryStatistics>comparingByValue( Comparator.comparingDouble(DoubleSummaryStatistics::getAverage) ).reversed()) .forEach(e -> System.out.println(e.getKey() + "岁段平均分=" + e.getValue().getAverage())); } }这在Java里写得还算简洁,主要靠Stream API。
6.2 Go版本实现与差异分析
Go版本没有Stream,但用基础语法一样能完成,而且逻辑更直白:
package main import ( "fmt" "sort" ) type User struct { Name string Age int Score float64 } type AgeAvg struct { AgeRange int Avg float64 } func main() { users := []User{ {"A", 8, 80}, {"B", 12, 90}, {"C", 15, 70}, {"D", 22, 95}, {"E", 25, 85}, } // 1. 分组统计总分和人数 sumMap := make(map[int]float64) countMap := make(map[int]int) for _, u := range users { key := (u.Age / 10) * 10 sumMap[key] += u.Score countMap[key]++ } // 2. 转成slice准备排序 result := make([]AgeAvg, 0, len(sumMap)) for key, sum := range sumMap { result = append(result, AgeAvg{ AgeRange: int(key), Avg: sum / float64(countMap[int(key)]), }) } // 3. 按平均分降序排序 sort.Slice(result, func(i, j int) bool { return result[i].Avg > result[j].Avg }) for _, r := range result { fmt.Printf("%d岁段平均分=%.2f\n", r.AgeRange, r.Avg) } }对比下来,Java的Stream表达更强,但Go的可读性也不差,而且每一步都在明面上,没有隐藏的collector逻辑。如果你是为了做数据管道,两个都能胜任;Go的写法更接近“算法”本身,Java更接近“声明式”。
注意上面有几个关键点:
- 使用map计数时,Go会自动给不存在的key返回零值,所以
sumMap[key] += u.Score不需要先判断key是否存在。这是Go map的便利之一。 - 输出顺序要自己排序。Go的map遍历是无序的,这是语言设计使然,别指望它维持插入顺序。
(u.Age / 10) * 10这个整数运算和Java一样,先除后乘得到段起始值。
6.3 常见坑点与排查心得
最后分享几个我在速通Go时真正踩过的坑,都是写Java时不会预想到的。
nil map写入直接panic。Java里HashMap若不初始化,引用是null,调用put会NPE。Go里声明var m map[string]int后m是nil,直接m["key"]=1会panic。必须用make(map[string]int)初始化,或者用字面量map[string]int{}创建。很多新手一脸懵,这不是NPE,是throw runtime error: assignment to entry in nil map。
切片是引用类型但底层共享,append会坑人。Java里ArrayList的add不影响另一个list引用,除非你共享同一个对象。Go的slice底层是数组,多个slice可能共享同一块底层数组。如果你做了sub := slice[1:3],然后修改sub[0],原slice的值也会变;如果append超过容量,可能重新分配底层数组,原slice又不受影响。这个行为很微妙,刚开始写业务时最好记住:凡是可能扩容的slice,不要假设底层数组不变,也不要在循环里直接append到共享底层数组上。
for range的变量复用。Go 1.22之前,for i, v := range slice里面的i和v在每次循环是复用同一个变量,如果在闭包里引用它们,会得到最后一次循环的值。经典bug:
for _, v := range []int{1, 2, 3} { go func() { fmt.Println(v) // 可能全部打印3 }() }后来版本修改了语义,每次循环会创建新变量,但为了兼容老项目,最好还是显式传参:
go func(v int) { fmt.Println(v) }(v)defer与返回值的关系。如果你写了defer func() { result = result + 1 }(),并且在函数签名里有返回值名,defer可以修改命名返回值。但如果返回值是匿名的,defer里拿不到返回值,只能通过闭包间接改。这个细节在Java的finally里是不存在的,建议单独测一下。
channel忘记close导致的死锁。无缓冲channel如果两边有一方不存在的等待,代码会永久阻塞。Go的runtime会检测到所有goroutine都阻塞时输出fatal error: all goroutines are asleep - deadlock!,但如果你用了select+time.After可能又不会立刻暴露,等到超时才退出。所以写并发代码时,脑子里始终要有一条主线:谁在等谁,谁会唤醒我。Java有超时锁、线程中断,Go的channel如果不用select,默认是没有超时的。
写了这么多,其实就是想把Java转Go路上的思维转换点、语法映射和工程习惯一次性讲清楚。我个人实际体验是:Go基础内容的速通不仅仅是背语法差异,更重要的是接受它的简单哲学。很多东西Java里花了大力气去做框架、做约定,Go直接从小处解决。开始别想太多,老老实实写几个小工具、小接口,很快你就会发现Go的真香点。