深度解析

值传递 & 引用传递

从内存模型出发,彻底理解两种传递机制的本质,掌握 Go / PHP / Python / Java 中的每一个细节

🧠 根本原理

在讨论"值传递"与"引用传递"之前,必须先搞清楚一个基础问题:变量本质上是什么?

📦 变量的本质

变量 = 名字 + 内存地址 + 存储的值。理解传递机制,必须从这三者入手。

变量名
a = 42
存储在某个内存地址,例如 0xc0001234
地址
0xc0001234
0x0000002A (即十进制 42)
📋 值传递 (Pass by Value)

函数调用时,将 实参的值复制一份,传给形参。函数内对形参的修改 不会影响 实参。

// 调用前
a = 42 <-- 0xAAA

// 调用时:复制 42
param = 42 <-- 0xBBB (新地址!)

// 函数内修改
param = 100 <-- 只改了 0xBBB

// 调用后
a = 42 ✓ 没有变化
🔗 引用传递 (Pass by Reference)

函数调用时,将 实参的内存地址 传给形参。函数内通过地址直接操作原始数据,修改 会影响 实参。

// 调用前
a = 42 <-- 0xAAA

// 调用时:传递地址
param = 0xAAA (指向 a)

// 函数内修改
*param = 100 <-- 改了 0xAAA

// 调用后
a = 100 ⚠ 已被修改!
最关键的认知误区:传递「引用类型的变量」≠ 引用传递!例如 Java、Python 中把对象传给函数,传的是「对象引用的副本(值传递)」,而不是「变量本身的引用(引用传递)」。这个区别是无数 Bug 的根源。
🔑 终极判断标准

只需问一个问题:

「在函数内部,将形参重新赋值为另一个对象,调用者的变量会改变吗?」
❌ 不会改变 → 值传递
形参拿到的是一个副本,重新赋值只影响副本
✅ 会改变 → 引用传递
形参就是实参的别名,两者指向同一内存
💾 内存模型

从栈(Stack)与堆(Heap)的视角,看清值传递与引用传递的内存布局差异

🗂 栈 vs 堆
栈 (Stack)
  • 函数调用时自动分配,返回时自动回收
  • 存放:局部变量、函数参数、返回地址
  • 访问速度极快(LIFO 结构)
  • 大小有限(通常 1-8MB)
  • 值传递的副本在这里
堆 (Heap)
  • 动态分配,需手动/GC 回收
  • 存放:对象实例、动态数组、大型数据
  • 访问需通过指针/引用间接寻址
  • 大小较大(受物理内存限制)
  • 引用类型的实际数据在这里
📊 值传递的内存布局
内存地址
存储值
说明
─── 调用者栈帧 ───
0xFFFF_1000
42
变量 a(原始值)
─── 被调函数栈帧 ───
0xFFFF_0FF8
42
形参 param复制的值,独立地址)
0xFFFF_0FF8
100
函数内修改 param → 只改了副本,原始 a 不变
💡
值传递:两个地址,两份数据。修改副本对原始数据零影响
📊 引用传递 / 传递指针的内存布局
内存地址
存储值
说明
─── 调用者栈帧 ───
0xFFFF_1000
42
变量 a(原始值)
─── 被调函数栈帧 ───
0xFFFF_0FF8
0xFFFF_1000
形参 param(存的是 a 的地址
0xFFFF_1000
100
*param = 100 → 通过地址直接修改,a 变为 100 ⚠
⚠️
引用/指针传递:两个变量(原始变量 + 指针),但指向同一块内存。通过指针修改数据会影响原始变量。
📊 传递「引用类型」的内存布局(Java/Python 的常见情况)
区域 / 地址
存储值
说明
─── 栈 (Stack) ───
Stack: 0x0100
Heap: 0x8000
变量 obj(存的是堆地址,即「引用」)
Stack: 0x00F8
Heap: 0x8000
形参 param复制了引用的值,也指向 0x8000)
─── 堆 (Heap) ───
Heap: 0x8000
{x: 1}
实际对象数据
param.x = 99修改堆上的字段,obj.x 也变,因为两者指向同一堆对象
param = new Object()改变了副本指针,obj 的指向不变,原对象不受影响
🔑
这就是 「值传递,但传的是引用的拷贝」。可以通过引用修改对象内部状态,但无法让调用者的变量指向新对象。
🎮 交互演示

通过可视化演示,直观感受两种传递机制的差异

演示 1 — 值传递:传递基本类型
调用者作用域
变量 a
42
地址: 0xFFFF_1000
函数 modify() 内部
形参 param
地址: 未分配
// 点击「运行」查看值传递效果
演示 2 — 引用传递:通过指针/引用修改对象字段
调用者作用域
变量 obj(引用)
{x: 1}
引用 → Heap: 0x8000
函数 modifyField() 内部
形参 param(引用副本)
引用副本 → —
// param.x = 99:修改字段(会影响原始对象)
演示 3 — 重新赋值形参:证明引用类型也是值传递
调用者作用域
变量 obj
{x: 1}
引用 → Heap: 0x8000
函数 reassign() 内部
形参 param
// param = {x: 999}:重新赋值形参(原始 obj 不变)
🌐 语言对比

选择语言,查看各语言的传递规则与代码示例

💡
Go 的核心原则:一切皆值传递。Go 没有引用传递,但你可以显式传递指针(*T)来达到修改原始值的效果。
值传递的情况(所有基本类型 + 数组 + 结构体)
  • 基本类型:int, float64, bool, string
  • 数组:[5]int(注意:数组不是切片!
  • 结构体:struct(整个结构体被复制)
显式传指针(等效于引用传递效果)
  • 传递 *int, *string, *MyStruct 等指针类型
  • 在函数内通过 *ptr 解引用后修改,影响原始变量
⚠ 特殊类型:slice、map、channel(传的是描述符)
  • slice:传的是描述符(ptr + len + cap),修改元素会影响原始 slice;但 append 超出容量后不影响
  • map:传的是哈希表的指针,函数内 add/delete 键会影响原始 map
  • channel:传的是内部指针,共享同一个通道
Go — 值传递 vs 指针传递 vs slice
package main import "fmt" // ① 值传递 —— 修改不影响原始值 func modifyInt(n int) { n = 100 } // ② 指针传递 —— 修改影响原始值 func modifyIntPtr(n *int) { *n = 100 } // ③ 结构体值传递 —— 整体复制 type Point struct { X, Y int } func modifyPoint(p Point) { p.X = 999 } // ④ 结构体指针 —— 修改影响原始 func modifyPointPtr(p *Point) { p.X = 999 } // ⑤ slice —— 修改元素影响原始,append超容量不影响 func modifySlice(s []int) { s[0] = 99 // ✅ 影响原始 slice s = append(s, 1000) // ❌ 超容量后不影响原始 } // ⑥ map —— 共享底层哈希表 func modifyMap(m map[string]int) { m["key"] = 999 // ✅ 影响原始 map } func main() { a := 42 modifyInt(a) fmt.Println(a) // 42 ← 没变 modifyIntPtr(&a) fmt.Println(a) // 100 ← 改了 p := Point{1, 2} modifyPoint(p) fmt.Println(p.X) // 1 ← 没变(结构体是副本) modifyPointPtr(&p) fmt.Println(p.X) // 999 ← 改了 s := []int{1, 2, 3} modifySlice(s) fmt.Println(s) // [99 2 3] ← 元素被改了,但 append 部分没有 }
Go 设计哲学:显式优于隐式。需要修改原始值,就明确传指针 &x,不存在隐式引用传递的歧义。
💡
PHP 默认值传递,使用 & 符号显式开启引用传递。对象比较特殊:传的是「对象标识符的副本」,行为类似引用但本质是值传递。
值传递的情况
  • 标量类型:int, float, string, bool
  • 数组:函数内修改不影响原数组(写时复制 COW)
  • 对象重新赋值:$param = new Foo() 不影响调用者
引用传递的情况
  • 函数签名使用 &$param:显式引用传递,修改形参影响实参
  • 全局变量通过 global 关键字(本质是引用)
  • foreach 使用 &$v
⚠ 对象:传递「对象标识符」(Object Handle)
  • 传递对象时,复制的是「对象标识符」(不是对象本身,也不是真正的引用)
  • 函数内 $param->prop = 1影响原始对象(两个标识符指向同一对象)
  • 函数内 $param = new Foo()不影响原始变量
PHP — 值传递 vs 引用传递 vs 对象传递
// ① 值传递(标量) function modifyInt($n) { $n = 100; } $a = 42; modifyInt($a); echo $a; // 42 ← 没变 // ② 引用传递(& 符号) function modifyRef(&$n) { $n = 100; } modifyRef($a); echo $a; // 100 ← 改了 // ③ 数组 —— 写时复制(COW) function modifyArray($arr) { $arr[0] = 99; // 触发 COW,生成副本 } $arr = [1, 2, 3]; modifyArray($arr); print_r($arr); // [1, 2, 3] ← 没变 // ④ 对象:传递标识符副本 class Box { public $val = 1; } function modifyProp($obj) { $obj->val = 99; // ✅ 修改属性 —— 影响原对象 } function reassignObj($obj) { $obj = new Box(); // ❌ 重新赋值 —— 不影响原变量 } $box = new Box(); modifyProp($box); echo $box->val; // 99 ← 字段被改了 reassignObj($box); echo $box->val; // 99 ← $box 本身没换
💡
Python 的机制叫做 "Pass by Object Reference"(对象引用传递),也可以理解为「值传递,传的是对象引用的副本」。关键在于对象是否 可变(mutable)
不可变类型(Immutable)— 行为像值传递
  • int, float, complex, bool
  • str(字符串)
  • tuple(元组)
  • frozenset
  • 函数内 x = 100 只是让局部变量指向新对象,原始变量不受影响
可变类型(Mutable)— 修改内部状态会影响原对象
  • list(列表)
  • dict(字典)
  • set(集合)
  • 自定义 class 实例
  • 通过引用修改内部字段/元素 → 影响原始对象
  • 重新赋值形参 → 不影响原始变量
Python — 不可变 vs 可变类型
# ① 不可变类型 —— 函数内修改不影响外部 def modify_int(n): n = 100 # 只是让 n 指向新对象 100 print(id(n)) # 地址变了 a = 42 print(id(a)) # 某个地址,如 140234567890 modify_int(a) print(a) # 42 ← 没变 # ② 可变类型 list —— 修改元素影响原始 def modify_list(lst): lst[0] = 99 # ✅ 修改元素 —— 影响原始列表 def reassign_list(lst): lst = [9, 9, 9] # ❌ 重新赋值 —— 不影响原始变量 nums = [1, 2, 3] modify_list(nums) print(nums) # [99, 2, 3] ← 改了 reassign_list(nums) print(nums) # [99, 2, 3] ← 没变 # ③ 字符串(不可变)—— += 创建新对象 def modify_str(s): s += " world" # 创建新字符串对象 msg = "hello" modify_str(msg) print(msg) # hello ← 没变! # ④ 自定义对象 —— 可变,修改属性影响原始 class Point: def __init__(self, x): self.x = x def modify_obj(p): p.x = 999 # ✅ 修改属性 —— 影响原始对象 pt = Point(1) modify_obj(pt) print(pt.x) # 999 ← 改了 # ⑤ 验证:用 id() 查看对象地址 x = [1, 2] print(id(x)) # 原始列表地址 def check_id(lst): print(id(lst)) # 相同地址 —— 传的是引用的副本(同一对象) check_id(x)
⚠️
Python 经典陷阱:默认参数用可变类型!def f(lst=[]) 中的 [] 只创建一次,多次调用共享同一个列表对象,导致行为异常。正确写法:def f(lst=None): if lst is None: lst = []
💡
Java 一切皆值传递(James Gosling 亲口确认)。基本类型传值的副本,对象传引用的副本。Java 没有引用传递,C++ 中的 & 引用传递在 Java 中不存在。
值传递(基本类型 — 8种)
  • byte, short, int, long
  • float, double
  • char
  • boolean
  • 直接存储在栈上,传递时完整复制
传递引用副本(所有引用类型)
  • 所有 Object 子类(String, ArrayList, 自定义类...)
  • 数组(int[], String[] 等)
  • 传递的是堆对象的引用(地址)的副本
  • 可以修改对象的字段,不能让调用者的变量指向新对象
⚠ String 特殊:不可变 + 字符串常量池
  • String 是引用类型,但内容不可变(final char[])
  • 函数内 s = s + "world" 创建新对象,原始变量不变
  • 需要在函数内改变字符串并返回,应使用 StringBuilder 或返回新值
Java — 基本类型 vs 引用类型 vs String
public class PassDemo { // ① 基本类型 —— 值传递,完整复制 static void modifyInt(int n) { n = 100; // 只修改局部副本 } // ② 对象 —— 传引用副本,可修改字段 static void modifyField(int[] arr) { arr[0] = 99; // ✅ 修改数组元素 —— 影响原数组 } // ③ 重新赋值形参 —— 不影响调用者变量 static void reassign(int[] arr) { arr = new int[]{9, 9, 9}; // ❌ 只改了局部引用副本 } // ④ 自定义对象 static class Point { int x; Point(int x) { this.x = x; } } static void modifyPoint(Point p) { p.x = 999; // ✅ 影响原始对象 } static void reassignPoint(Point p) { p = new Point(0); // ❌ 不影响调用者 } // ⑤ String —— 不可变类型 static void modifyString(String s) { s = s + " world"; // 创建新对象,原始不变 } public static void main(String[] args) { int a = 42; modifyInt(a); System.out.println(a); // 42 ← 没变 int[] arr = {1, 2, 3}; modifyField(arr); System.out.println(arr[0]); // 99 ← 改了 reassign(arr); System.out.println(arr[0]); // 99 ← 没换(reassign无效) Point pt = new Point(1); modifyPoint(pt); System.out.println(pt.x); // 999 ← 改了 reassignPoint(pt); System.out.println(pt.x); // 999 ← 没变 String msg = "hello"; modifyString(msg); System.out.println(msg); // hello ← 没变 } }
Java 口诀:基本类型 → 传值,对象类型 → 传引用副本。能改字段,不能换对象。String 虽是对象,但不可变,行为等同基本类型。
⚠️ 经典陷阱

这些是面试高频考点,也是日常开发中最容易踩的坑

🪤 陷阱 1:Go 的 slice append

slice 的 append 行为取决于是否超出容量

Go
func appendSlice(s []int) { s = append(s, 999) // 若超出 cap,创建新底层数组! } s := make([]int, 3, 3) // len=3, cap=3 appendSlice(s) fmt.Println(s) // [0 0 0] ← 没有 999! // 若要让 append 生效,必须返回新 slice: func appendSliceOK(s []int) []int { return append(s, 999) }
🪤 陷阱 2:Python 可变默认参数
Python
# ❌ 错误:默认参数 [] 只创建一次 def add_item(item, lst=[]): lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2] ← 惊!共享了同一个列表! # ✅ 正确写法 def add_item_ok(item, lst=None): if lst is None: lst = [] lst.append(item) return lst
🪤 陷阱 3:Java 的 Integer 缓存
Java
// Integer 缓存范围 -128 ~ 127 Integer a = 127; Integer b = 127; System.out.println(a == b); // true ← 缓存池,同一对象 Integer c = 128; Integer d = 128; System.out.println(c == d); // false ← 超出缓存,新对象 System.out.println(c.equals(d)); // true ← 比较值要用 equals // 装箱拆箱中的陷阱 static void swap(Integer a, Integer b) { Integer t = a; a = b; b = t; // ❌ 只是交换局部变量 } // 交换完全无效!Integer 不可变,传的是引用副本
🪤 陷阱 4:PHP foreach 引用残留
PHP
// ❌ foreach 引用不 unset,最后一个元素会被覆盖 $arr = [1, 2, 3]; foreach ($arr as &$v) { $v *= 2; } // $v 还是引用着 $arr[2]! var_dump($arr); // 再遍历 $arr 时最后一个会被改 // ✅ 正确做法:foreach 后立即 unset 引用 foreach ($arr as &$v) { $v *= 2; } unset($v); // ✅ 必须!
🪤 陷阱 5:Python 的深拷贝 vs 浅拷贝
Python
import copy matrix = [[1, 2], [3, 4]] # 浅拷贝:外层新列表,内层仍共享引用 shallow = copy.copy(matrix) shallow[0][0] = 99 print(matrix) # [[99, 2], [3, 4]] ← 内层被改了! # 深拷贝:完全独立的副本 deep = copy.deepcopy(matrix) deep[0][0] = 0 print(matrix) # [[99, 2], [3, 4]] ← 不影响原始
📋 速查表

四种语言的传递规则速查

语言 基本/值类型 对象/引用类型 特殊说明
Go 值传递
int/float/bool/string/struct/array 完整复制
值传递
显式传 *T 指针才能修改原始值
slice/map/chan 传描述符副本;修改元素影响原始,重新赋值不影响
PHP 值传递
int/float/string/bool/array 默认复制
对象标识符副本
可改属性,不能替换整个对象
函数签名加 & 开启引用传递;array 写时复制(COW)
Python 对象引用副本
int/str/tuple 不可变,行为像值传递
对象引用副本
list/dict/set 可变,修改内容影响原始
用 id() 验证对象身份;deepcopy 创建完全独立副本
Java 值传递
8 种基本类型:byte/short/int/long/float/double/char/boolean
引用副本
传引用的副本,可改字段,不能替换对象
String 虽为对象但不可变;没有真正的引用传递;== 比较引用,equals 比较值
🔵 Go
📋所有类型:值传递(复制副本)
🔗传 *T 指针 → 修改原始值
slice 改元素 ✅ / append 超容 ❌
map 共享哈希表,增删影响原始
🟣 PHP
📋标量/数组:默认值传递
🔗函数签名 &$x → 引用传递
对象传标识符:改属性 ✅ / 换对象 ❌
foreach &$v 后必须 unset
🟡 Python
📋不可变类型:行为像值传递
🔗可变类型:修改内部影响原始
重新赋值形参:任何类型都不影响原始
默认参数别用可变类型!
🟠 Java
📋8 种基本类型:纯值传递
🔗对象:传引用副本,改字段有效
String 不可变,操作创建新对象
无引用传递,无法通过函数换对象
🎯 终极口诀
📦
传基本类型
所有语言都是值传递
函数内改了,外面没变
🔗
传对象/引用类型
改内部字段 → 原始受影响
换整个对象 → 原始不受影响
Go:一切值传递,指针需显式
PHP:标量值传递,& 开启引用
Python:引用副本传递,可变/不可变决定行为
Java:一切值传递,对象传引用副本