算法 更新于 2026年7月31日

Java 与 Go 传参本质的差异性分析

Java 与 Go 传参本质的差异性分析

本篇文章含大量 AIGC。使用模型为 Gemini 3.1 Flash。

在尝试用 Java 写 LeetCode 49. 字母异位词分组 时,遇到了一点困惑。

后来发现,Java 中只有八大基本数据类型(int、boolean、double等)属于值类型(位于栈内存中,赋值时是纯粹的值拷贝),除此之外所有对象都是"引用"(实例存储在堆内存中,‌赋值或传参时拷贝的是指向堆的这个内存地址‌);Go 中所有对象都是"值"(无论是int这样的基础类型,还是 struct结构体),‌在声明、赋值或传参时默认都会进行内存数据的完整拷贝‌,也就是值传递,但我们可以显式使用指针。只有当传递指针时,复制的是内存地址本身。

特性维度JavaGo
对象默认分配默认在堆(Heap)‌上分配,变量隐式代表引用。默认在栈(Stack)‌上分配,变量本身就是一块内存空间。逃逸分析会自动将其移至堆上。
赋值与传参对象赋值和传参时,拷贝的是引用地址‌(Java中所有传参皆为值传递,传的是引用的值)。赋值和传参时,默认拷贝整个对象的数据‌。如需类似 Java 的引用传递行为,必须显式传递指针(&obj)。
显式指针不存在显式指针,完全由 JVM 自动托管。支持显式指针操作,通过 *T 表示指针,通过 & 取地址。

虽然 Go 声称一切皆为值,但它内部有三个特殊的内置类型,它们在表现上与 Java 的引用类似,底层封装了指向底层数据结构的指针:

  • 切片(Slice)‌
  • 映射(Map)‌
  • 通道(Channel)‌

其中 Map 和 Channel 是纯指针包装,而 Slice 是特殊的"值包装(复合结构体)"。所以 Slice 是没办法像 Java 里的对象那样进行"一次修改,到处同步"的。

Go 官方 runtime 中 Slice 的本质定义是一个结构体:

Go
type slice struct {
    array unsafe.Pointer // 1. 指向底层数组的指针
    len   int            // 2. 长度(值类型)
    cap   int            // 3. 容量(值类型)
}

而结构体本身就是值‌。它本身就以"值"的形式存在于栈上或者它所属的容器(比如哈希表、结构体字段)里。只是它内部的指针域指向了位于堆上的连续数组。

所以有下面的例子:

Go
// ❌ 假设你模仿 Java 写法
var mySlice []string = hashTable[key] // 拿到一个切片结构体的拷贝
if mySlice == nil {
    mySlice = make([]string, 0)
    hashTable[key] = mySlice // 把结构体拷贝进 map
}
mySlice = append(mySlice, s) // ⚠️ 翻车!append 改变了 mySlice 的长度或触发了扩容
// 此时 hashTable[key] 里面存的依然是那个旧的、长度为 0 的切片结构体!

实验

实验界定:变量的"重新赋值(Re-assignment)"测试

在计算机科学中,验证一种语言是"值传递(Pass by Value)"还是"引用传递(Pass by Reference)"的终极测试,不是观察函数能否修改对象的内部属性‌,而是观察函数能否在内部直接修改外部变量的内存指向(即重新赋值)‌‌。

  • 测试逻辑‌:向函数传入一个集合变量,在函数内部对其进行实例化(new / make),并注入新元素。观察函数执行完毕后,主函数中原变量的指向是否发生改变。

Java 实验分析:不可实现"重新赋值"‌

在 Java 中,尝试在方法内部改变外部引用的指向,其测试结果为失败‌(外部引用的指向保持不变)。

示例代码:‌

Java
public class ReferenceTest {
    public static void main(String[] args) {
        List<String> originalList = new ArrayList<>();
        originalList.add("A");
        
        reassignList(originalList);
        
        // 打印结果依然是 [A],函数内部的重新赋值对 originalList 无效
        System.out.println(originalList); 
    }

    public static void reassignList(List<String> targetList) {
        // 尝试断开原有连接,指向堆内存中的新对象
        targetList = new ArrayList<>(); 
        targetList.add("B");
    }
}

内存模型深度解析:‌

  1. 栈内存的地址拷贝‌:originalList 在栈中存储的是堆内存中具体对象的地址(假设为 0x111)。当发生函数调用时,JVM 在 reassignList 的栈帧(Stack Frame)中创建了一个局部变量 targetList,并复制了地址数值 0x111(此过程即为标准的值传递)。
  2. 作用域隔离‌:在方法内部执行 targetList = new ArrayList<>() 时,仅仅是将局部变量 targetList 在栈帧中的数据修改为了新对象的堆地址(假设为 0x222)。
  3. 结果‌:主线程栈帧中的 originalList 依然保存着 0x111。Java 无法在方法内直接修改主线程栈帧中的数据。

💡 工程解决方案:‌

由于 Java 强制隐藏了物理指针,若业务中必须实现此逻辑,通常采用以下两种方案:

  • 方案 A(函数式/返回值覆写)‌‌:通过 originalList = reassignList(originalList),利用显式的赋值语句完成覆盖。
  • 方案 B(指针容器/对象包装器)‌‌:使用 AtomicReference<List<String>> 或自定义的 Holder 类。通过传递外层容器的地址拷贝,在方法内部修改容器的内部属性(即指针域),从而间接改变内部列表的指向。

Go 语言实验分析:利用显式指针实现"物理修改"‌

Go 语言底层同样纯粹基于值传递。但由于其支持显式指针(Explicit Pointer)‌‌,允许开发者将外部变量的栈地址作为数值复制给函数,从而赋予了函数跨栈帧修改数据的能力。

示例代码:‌

Go
package main

import "fmt"

func reassignSlice(slicePtr *[]string) {
	// 通过解引用 (*),直接寻址到 main 栈帧中的原始变量位置进行覆写
	*slicePtr = make([]string, 0)
	*slicePtr = append(*slicePtr, "B")
}

func main() {
	originalSlice := []string{"A"}
	
	// 显式传递原始切片在栈上的内存地址 (&)
	reassignSlice(&originalSlice)
	
	// 输出: [B],成功在函数内部修改了外部变量的指向
	fmt.Println(originalSlice) 
}

内存模型深度解析:‌

  1. 二级寻址‌:在 main 函数中,originalSlice 本身是一个位于 main 栈帧上的结构体。通过 &originalSlice,我们获取了这个结构体在栈上的物理内存地址‌(假设为 0x7fff001)。
  2. 地址值的拷贝‌:Go 语言将 0x7fff001 这个数字作为形参,复制给了 reassignSlice 函数。
  3. 解引用修改‌:在函数内部,通过 *slicePtr(解引用),CPU 直接越界访问并操作 0x7fff001 所在的内存空间,将 main 栈帧里的切片结构体直接重写。

结论

  1. 全量值传递‌:无论是 Java 还是 Go,两者的求值策略(Evaluation Strategy)在底层全部属于按值传递(Pass by value)‌‌。函数入参时发生的都是内存数据的复制行为。
  2. 引用的本质‌:Java 中的"引用"本质上是受限的、无法进行指针算术的普通指针(Object Pointer)‌‌。Java 传递的是"指针变量的副本",因此能通过副本修改其指向的堆内存数据,但无法修改原始指针变量本身的栈数据。
  3. Go 的正交性‌:Go 语言的设计更加接近底层。它将"值类型"与"指针类型"作为正交的语法概念暴露给开发者。通过传递"指针的值",结合解引用语法,Go 能够突破函数栈帧的隔离,实现对外部变量生命周期与指向的直接控制。