☰
值传递与引用传递底层内存拆解:从栈帧看函数参数拷贝真相
2026/9/30 3:43:28 网站建设 项目流程

在排查一个“明明在函数里改了值,出来之后却什么都没变”的bug时,很多人都会一脸懵地搜索“值传递”和“引用传递”。网上教程翻了一大堆,名词都背熟了,一上手还是踩坑。原因很简单——绝大多数讲解停留在“基本类型是值传递、对象是引用传递”这种表皮口诀上,完全没有带你走进底层内存去看看到底发生了什么。这篇博文就用庖丁解牛的思路,把函数调用时内存里的每一个动作拆开揉碎,从栈帧到堆空间,从地址拷贝到指针间接修改,彻彻底底解开“值传递与引用传递”的底层内存迷局。不管你是刚接触编程的萌新,还是被同事的代码坑过多次的职场老手,这篇内容都能帮你把这块硬骨头啃下来。

1. 传递的本质:先搞清楚函数调用时内存发生了什么

1.1 变量不是盒子,是门牌号

很多初学者有个根深蒂固的误解:变量就是一个装着数据的盒子。你往盒子里放一个数字 10,变量就等于这个盒子。这个认知在低级语言里还好用,一旦遇到函数传递就全面崩塌。

真实情况是,变量只是内存地址的“门牌号”,真正的数据躺在内存单元里。你在代码里写int a = 10;,操作系统在内存里划出4个字节的空间,把这4个字节的起始地址登记为变量a的地址,同时把这4个字节的内容写入数字10。a本身不是那个装着10的盒子,a只是被编译器用来定位那块内存的标签。

打个比方,你家住在幸福路1号,“幸福路1号”这个地址是一个门牌号,但“幸福路1号”不是你家本身。变量就是这门牌号,内存单元才是你家房子。理解了这一点,后面讲值传递和引用传递就顺理成章了——传递的到底是门牌号本身,还是门牌号指向的房子里的东西?

1.2 函数调用与栈帧的兴起衰亡

每次函数被调用,操作系统都会在当前线程的栈上分配一块区域,专门给这次函数调用用,这块区域叫栈帧(Stack Frame)。栈帧里存什么?函数参数、局部变量、返回地址、一些寄存器现场数据。

当外部函数准备调用内部函数时,编译器会生成指令,把实参的值“压栈”(push),也就是把数据复制到新的栈帧参数区。然后把返回地址压栈,再跳转到被调函数的代码入口。被调函数执行完毕后,栈帧被整体销毁,参数和局部变量随之消失。

这个过程中最重要的一句话:实参进入被调函数的方式,本质上都是“拷贝”。区别只在于,你拷贝的是“数据本身”,还是“数据的地址”。这就是值传递和引用传递在底层拉开差距的起点。

2. 值传递的底层内存拆解

2.1 值传递的完整流程:每一步都发生了拷贝

看一段最经典的C语言代码:

void inc(int tmp) { tmp = tmp + 1; printf("inside: %d\n", tmp); } int main() { int a = 10; inc(a); printf("outside: %d\n", a); return 0; }

运行结果:inside: 11,outside: 10。为什么inside里明明加了1,外面却没变?我们一行一行看内存发生了什么。

main函数的栈帧里,变量a被分配在某个地址,比如0x7ffe00a0,这块内存里存储了值10。调用inc(a)时,编译器做了这么几件事:

  1. 读取a对应的内存,拿到值10;
  2. 在inc的栈帧里新分配一个变量tmp的存储空间;
  3. 把值10复制到tmp的空间里。

现在栈上有两个独立的内存单元:地址0x7ffe00a0存着10,地址0x7ffe0080也存着10。tmp = tmp + 1只修改了0x7ffe0080那块,跟0x7ffe00a0一点关系都没有。

所以值传递的底层本质是:被调函数的参数是实参的一个副本,两者分别占用不同的栈内存,修改副本不会影响原始内存。内存有一个很大的特点,只要你不知道该地址,就不可能绕过它去修改。inc只知道tmp的地址0x7ffe0080,不知道a的地址0x7ffe00a0,自然无法改动它。

2.2 为什么在函数内部修改值参数无效

继续用上面的例子深挖一层。你可能想问:那tmp和a都是存10,凭什么我改了tmp,a没事?

关键在于编译器的符号表。tmp和a是两个符号,编译器把tmp映射到地址0x7ffe0080,把a映射到地址0x7ffe00a0。你在代码里写tmp = 1,编译器生成的机器指令是“把数字1写入地址0x7ffe0080”,而不是“找到所有存着10的地址都改成1”。地址不同,修改就天然隔离。

这个“地址隔离”是栈帧设计的结果。每个栈帧是独立的内存块,被调函数只能访问自己的栈帧和通过指针间接访问到的内存。C语言里,函数可以访问全局变量和外部指针,那是例外;可一旦参数是值传递,被调函数手里只有那份副本的内存地址,修来修去都在副本上打转。

2.3 值传递的适用场景和性能代价

值传递适合什么时候用?内置整型、浮点型、布尔型、字符型,以及一些很小的结构体或类对象。

比如int、long、double,一次参数传递就是复制4到8个字节,成本可以忽略不计。而且值传递有个好处:隔离性极强,被调函数怎么折腾副本都不会影响外部状态,这在多线程并发场景下能减少很多心智负担。

但值传递并非免费。如果你传递的是一个包含大量数据的大结构体,比如一个含100万个元素的数组结构体,值传递会把这100万个元素全部复制一份到新栈帧。内存消耗、CPU消耗都会暴涨。

typedef struct { int data[1000000]; } HugeStruct; void process(HugeStruct param) { ... } // 百万级元素的完整复制,开销极大

正确的做法是改用指针或引用传递,这样拷贝的只是4字节的地址(64位系统上是8字节),而不是整个100万个元素。记住这个原则:小数据值传递,大数据地址传递。判断标准很简单——拷一份和拷8个字节比,哪个划算?

3. 引用传递与指针的底层内存拆解

3.1 C语言的指针传递:传的依然是“值”

C语言没有严格意义上的引用传递,只有指针传递。指针传递本质上仍然是值传递——它传递的是“地址值”。

void inc(int *tmp) { (*tmp) = (*tmp) + 1; printf("inside: %d\n", *tmp); } int main() { int a = 10; inc(&a); printf("outside: %d\n", a); return 0; }

运行结果:inside: 11,outside: 11。这次为什么有效?因为调用inc(&a)时,压栈的不是10,而是a的地址0x7ffe00a0。inc栈帧里的参数tmp存着0x7ffe00a0。

执行(*tmp) = (*tmp) + 1时,编译器做的动作是:先从tmp的内存里取出地址值0x7ffe00a0,然后找到这个地址对应的内存单元,读出来是10,加1得到11,再写回0x7ffe00a0这块内存。

注意:这次最终写入的地址不是tmp自己栈帧的地址,而是tmp里存着的那个外部地址0x7ffe00a0。所以外部a被修改了。

这说明一个关键点:所谓“引用传递”,在底层不过是传递了一个地址副本。你传递的是“门牌号”这个数据本身,但因为门牌号指向的是别人的房子,所以你拿着门牌号就可以去别人房子里搬家具换家具。值传递是只拿到了一个数据副本,引用传递是拿到了数据地址副本,然后通过地址去摸真实内存。

3.2 C++的引用传参:语法糖背后的真面目

C++引入了真正的引用语法:

void inc(int &tmp) { tmp = tmp + 1; }

表面上,tmp看起来就是a的别名,改tmp就是改a。底层其实也一样:编译器在inc的栈帧里为tmp分配了一个地址大小的空间,存放a的地址0x7ffe00a0。当你写tmp = tmp + 1时,编译器自动帮你解引用:从tmp的内存取出地址,再到地址指向的内存执行读取和写入。

引用传参对比指针传参的好处是,语法上你不需要写*tmp来解引用,也不容易犯取址弄错的问题。底层两者几乎没有性能差异,都是8字节地址的拷贝。但有一个容易忽略的坑:引用参数必须绑定到一个存在的对象,不能传递字面量。

void inc(int &tmp); inc(10); // 编译错误,不能把引用绑定到临时值

在底层这很好理解:字面量10没有明确的栈地址,编译器无法在一个“没有地址的临时值”上为你持久保存地址副本。

3.3 Java、Python这类语言的“引用传递谜案”

Java和Python是面试里争论最多的战场。很多人说Java对象是引用传递,基本类型是值传递。这种说法有迷惑性——更准确的说法是:Java所有参数都是值传递,但对象变量的值是一个引用(地址),所以你看上去像是引用传递。

用一段代码说话:

public class Demo { static void changeValue(int num) { num = 5; } static void changeArray(int[] arr) { arr[0] = 5; arr = new int[]{10, 20}; } public static void main(String[] args) { int a = 1; changeValue(a); System.out.println(a); // 1 int[] arr = {0, 0}; changeArray(arr); System.out.println(arr[0]); // 5,而不是10 } }

changeValue(a)是经典值传递,不多说。changeArray(arr)值得细品:

  1. main里的arr变量本质是一个8字节的引用,它指向堆里的一个数组对象,数组对象包含两个int元素。
  2. 调用changeArray(arr)时,把arr这个8字节的引用值(比如0x6ffe1020)复制到changeArray的参数区,参数名也叫arr。
  3. 所以此时有两个栈位置都存着地址0x6ffe1020:main的arr和changeArray的arr。
  4. arr[0] = 5通过地址0x6ffe1020找到了同一个数组对象,修改了对象的第一个元素。
  5. arr = new int[]{10, 20}则是在changeArray栈帧的arr空间里写入一个新地址0x7ffe4488,跟main里的arr没有任何关系。

所以外部打印arr[0]是5,不是10。这个例子完美展示了:你知道地址就能改这块内存里的内容,但你在函数里“重新给参数赋值新的地址”,并不会原路返回去修改调用者的参数变量。理解这个核心陷阱后,Java引用传递的争论就终结了。

4. 底层视角看特殊场景:数组、切片、字符串、Map

4.1 C数组传参:名字就是地址的骗局

C语言里,数组一旦作为函数参数,就退化为指针,这是新手最容易踩的坑。

void test(int arr[100]) { printf("%zu\n", sizeof(arr)); // 8,不是400 }

看起来传的是“有100个元素的数组”,实际上编译器把形参调整成了int *arr。所以数组传参是严格意义上的地址传递,底层只复制了首元素地址。

这个“退化”设计不是拍脑袋决定的。数组作为值拷贝会太昂贵,C语言选择效率优先,把函数参数里的数组一律解释为指针。所以在C语言里,你写void test(int arr[100])和void test(int arr[])和void test(int *arr),三种写法在编译器眼里一模一样。

4.2 Go切片:你把切片结构体复制了一遍

Go语言里也有类似的陷阱。看这个代码:

func appendSlice(s []int) { s = append(s, 100) } func main() { src := make([]int, 2, 2) src[0] = 1 src[1] = 2 appendSlice(src) fmt.Println(src) // [1 2] }

为什么append的结果没变化?因为Go的切片是一个包含三个字段的结构体:指向底层数组的指针、长度len、容量cap。值传递时,这个结构体被整体复制了一份。函数参数s是另一个结构体副本,它里面的指针仍然指向同一个底层数组,但s的长度和容量是独立的。

append操作往拷贝的切片结构体里追加元素,当容量不够时,Go会分配一个更大的新底层数组,并把s的指针字段更新为新数组地址。这个更新只发生在函数内部的副本结构体上,外部src的指针字段还指着旧数组。所以外部的切片长度和数组都没变。

如果希望append的修改对外可见,必须返回新的切片,或者传切片指针:

func appendSlice(s *[]int) { *s = append(*s, 100) }

底层来看,这是把切片结构体的地址(而不是结构体副本)传了进去,append后才能把新结构体写回原地址。

4.3 Java String的不可变性与引用陷阱

Java的String是个特别的引用类型。大家都说String是引用传递,但还是会踩这种坑:

static void changeString(String s) { s = s + " world"; } public static void main(String[] args) { String msg = "hello"; changeString(msg); System.out.println(msg); // hello }

底层逻辑一拆就清楚:msg是8字节的引用,指向字符串常量池里的"hello"对象。调用changeString时,引用值被复制到参数栈帧。s + " world"创建了一个全新的String对象"hello world",然后把新对象的地址写进s这个参数变量。外部msg还指着原来的"hello",自然不会变。

所以那句“对象是引用传递”的正确理解方式是:对象的字段可以通过引用被修改(如果这个对象可变),但重新给引用变量赋值不会影响调用方。String因为不可变,连字段都修不了,就更加顽固了。

Map是另一个典型。往Map里put新键值对,外部能看到变化,因为put是通过引用找到Map对象,在对象内部新增了元素,并没有替换Map对象本身。但如果有人写了map = new HashMap(),外部就完全不受影响。这一点在多方法调用链里尤其容易阴沟翻船。

5. 高频面试题与避坑清单

5.1 五道经典必考题,看看你能否一次答对

第一题:C语言中,void swap(int a, int b)能交换外部两个变量的值吗?

不能。a和b是外部变量值的副本,交换的只是两个副本。要实现交换必须传地址:void swap(int *a, int *b),函数内部通过指针解引用交换。

第二题:Java里Integer是引用类型,为什么changeInteger(Integer num) { num = 100; }改不了外部值?

Integer是不可变类,num = 100实际上是自动拆箱又装箱,创建了一个新的Integer(100),并把新对象地址写入参数变量。外部引用还指着旧对象。除非用AtomicInteger或自定义可变包装类,否则改不了。

第三题:Go的map是引用传递吗?

Go的map本质上是一个指针,指向运行时哈希表结构。把map传给函数,内部m[k]=v可以影响外部。但如果你在函数里执行m = make(map...)并用新map赋值,外部不受影响,因为参数里的map变量是拷贝的指针字段。

第四题:C++里vector传引用和传值,内存代价差多少?

传值要完整拷贝vector内部的所有元素,包括可能涉及堆内存分配和元素拷贝构造;传引用只拷贝8字节的地址。一个含有10万个元素的vector,传值的光拷贝开销可能就是毫秒级以上的差距,在高频调用场景下可能直接卡死。

第五题:Python里list传参算是值传递还是引用传递?

Python的参数传递官方叫“传对象引用”。对于可变对象list,在函数内lst.append(x)可以改到外部list;但在函数内lst = lst + [x]会创建新列表并重新绑定局部变量,外部不变。核心逻辑和Java对象的引用谜案一模一样。

5.2 排查“为什么改了没生效”的调试套路

如果你真的遇到了“改不生效”的问题,不要瞎猜,按这个套路快速定位:

第一步,打印地址。在调用函数前后,打印参数的地址和函数内部参数变量的地址。如果两个地址不同(按值传递),那修改无效是因为你改的是副本。如果地址相同(按引用传递),那修改无效可能是副对象被重新赋值了。

第二步,打印对象内部内容。比如数组传递后,在外部打印arr[0],内部打印arr[0],看是哪一层丢的。

第三步,画出栈与堆的关系图。把调用方栈帧和被调函数栈帧分别画出来,用箭头表示引用指向,用叉表示重新绑定。大多数问题在这个图里画出来就原形毕露了。

这里必须强调一个极其实用的经验:判断修改是否生效,不看参数类型,看修改动作。如果操作是“调用了对象的成员方法去修改对象内部状态”,通常生效;如果操作是“对参数变量重新赋值”,不管赋值的是数字、对象还是指针,通常都不生效。这个规则简单粗暴,十有八九能提前预判结果。

6. 实践中的独家心得总结

我在带新人和做代码审查时,发现真正把值传递和引用传递吃透的人都有一个共同点:他们脑海中始终带着“栈帧图”和“地址指向图”。建议你也养成这个习惯——在遇到复杂传参问题时,不要直接看代码眼瞪眼,花两分钟画一个栈和堆的草图。

还有一个特别容易忽视的场景是跨语言调用,比如C#调用C++ DLL,或者Go通过cgo调用C库。因为每种语言都有自己的传递规则,跨语言边界时,地址对齐、值类型和引用类型的内存布局都可能出现偏差。我自己曾在一个cgo环节里,把C的char *误当成Go字符串直接传,结果就是内存被改写,整个进程崩溃。排查到最后,本质问题还是对“底层内存中传递的是地址值”这个事实不够警觉。

至于前面贴出来的那些例子,全部用到了同一个底层基石——一切传递都是拷贝,拷贝的内容不同决定了行为差异。值传递拷贝数据本身,引用传递拷贝地址。把这个基石牢记,再看任何语言、任何参数的传递问题,都能一眼看穿。这也是庖丁解牛的终极体会:技术再复杂,刀往关节处一剖,肌肉纹理就清清楚楚了。希望这篇底层内存拆解真能帮你把值传递和引用传递这块硬骨头啃碎,啃透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询