【聊聊Java】Java到底是值传递还是引用传递?

1,823 阅读11分钟

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

写在前边

  • 上次聊到Java8新特性 lambda时,有小伙伴在评论区提及到了lambda对于局部变量的引用,补充着博客的时候,知识点一发散就有了这篇对于值传递还是引用传递的思考。关于这个问题为何会有如此多的误区,这篇就来破解ta!

果然知识网的发散是无止境的!
文中的一些定义可能带有个人见解,引发了歧义,决定重新补充一下(如有错误还望指出!)

知识储备--堆和栈

  • 堆是指动态分配内存的一块区域,一般由程序员手动分配,比如 Java 中的 new、c里边的malloc。
  • 栈是编译器帮我们分配好的区域,一般用于存放函数的参数值,局部变量等

有关堆栈的相关知识在 迷途指针 中有所提及。

数据类型

Java中除了基本数据类型,其他的均是引用类型,包括数组等等。

基本数据类型和引用类型的区别

先看一下这两个变量的区别

void test1(){
      int cnt = 0;
      String str = new String("melo");
}

image.png

  1. cnt是基本类型,值就直接保存在变量中(存放在栈上)
  2. 而str是引用类型,变量中保存的只是实际对象的地址。一般称这种变量为"引用",引用指向实际对象,实际对象中保存着内容

比如我们创建了一个 Student student = new Student("Melo");

  • 在堆中开辟一块内存(真正的对象存放在堆上),其中保存了name等数据 , 而student只是保存了该对象的地址(存放在栈上)

当我们修改变量时

void test1(){
      int cnt = 0;
      cnt=1;
      String str = new String("melo");
      str="Melo";
}

image.png 对于基本类型 cnt,赋值运算符会直接改变变量的值,原来的值直接被覆盖掉了。

ta无依无靠,不像下边一样有房子可以住。

对于引用类型 str,赋值运算符只会改变引用中所保存的地址,虽然原来的地址被覆盖掉了,str指向了一个新的对象,但是原来的那个老对象没有发生变化,他还是老老实实待在原来的地方!!!

有学过c语言的同学应该很清楚,这里借助c语言中的“指针”打个比喻。

  • 引用类型str就相当于一个指针(旗子),插在了一个房子门口。现在给这个旗子挪个位置,只是让这个旗子放置在了另一个新的房子,原本的老房子还在那里,不会说因为你改变了旗子的位置,房子就塌了。

当然,原来那个房子没有旗子插着了,没有人住了。也不能总是放任ta在那占着空间,过段时间也许就会有人来把他给拆了回收了(JVM)。 这种没有地方引用到的对象就称为垃圾对象。

对于传递的错误理解(11-6补充)

错误理解一:值传递和引用传递,区分的条件是传递的内容,如果是个值,就是值传递。如果是个引用,就是引用传递。 错误理解二:传递的参数如果是普通类型,那就是值传递,如果是对象,那就是引用传递。

值传递(11-6补充)

  • 看到比较多的解释: 在方法被调用时,实参通过形参把它的内容副本传入方法内部,此时形参接收到的内容是实参值的一个拷贝,因此在方法内对形参的任何操作,都仅仅是对这个副本的操作,不影响原始值的内容。

我们上次聊到lambda的时候,提及到了值传递,那里的拷贝副本,就是我们这里要说的值传递

  • 如果我们这里的方法块访问了外部的变量,而这个变量只是一个普通数据类型的话,相当于只是访问到了一份副本。当外部对这个变量进行修改时,lambda内部(只有副本)是无法感知到这个变量的修改的。

我们只是将实参传递给了方法的形参,**将cnt值复制一份,赋值给形参val,**所以,函数内对形参的操作完全不会影响到实参真正存活的区域!而伴随着函数调用的结束,形参区域和其内的局部变量也会被释放。(方法栈的回收)

//基本类型的值传递
void unChange(int val) {
    val = 100;
}
unChange(cnt); // cnt 并没有被改变

引用传递(11-5修改!!!)

实参传递给形参时,形参其实用的就是实参本身(而不再单纯只是拷贝一份副本出来了),当该形参变量被修改时,实参变量也会同步修改

看到比较多的解释: ~~~~引用传递(pass by reference)是指在调用函数时将实际参数的地址直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。

形参相当于是实参的“别名”,对形参的操作其实就是对实参的操作,在引用传递过程中,被调函数的形式参数虽然也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。 被调函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。 正因为如此,被调函数对形参做的任何操作都影响了主调函数中的实参变量。

  • 而不仅仅说成 : 传递的是地址就说明是引用传递。(说成这样我们就误以为传递引用参数,传递指针变量就是引用传递了)

C++实例

#include <iostream>
 
using namespace std;
 
int main()
{
    //&标识符
    void swap(int& x,int& y); 

    int a = 5;
    int b = 8;
   
    swap(a,b);
    return 0;
}

void swap(int& a,int& b){
    int temp;
    temp = a;
    a = b;
    b = temp;
}

值传递和引用传递的区别

这里我们需要注意的是一个方法可以修改引用传递所对应的变量值,而不能修改值传递所对应的变量值

注意看清楚,这里说的是引用传递所对应的变量值

什么意思呢?引用传递所对应的变量值是指什么。我们先看下边两个例子 "内卷实例"和"反内卷实例"。

Java中到底是引用传递还是值传递呢?

内卷实例

//内卷
    void involution(Student temp){
        temp.setScore(100);
    }

    public static void main(String[] args) {
        Student student = new Student();
        student.setName("Melo");
        student.setScore(0);
        System.out.println("躺平时的成绩->"+student.getScore());
        new TestQuote().involution(student);
        System.out.println("卷了几天后的成绩->"+student.getScore());
    }

image.png

  • 这里看起来,好像符合我们引用传递的定义诶?
    • 对形参temp的修改,会反馈到外部实参student那里去?看起来操作的是同一个变量的样子

反内卷实例

看下边这段"反内卷"的代码实例

//反内卷
    void againInvolution(Student temp){
        temp = new Student();
        temp.setScore(100);
    }

    public static void main(String[] args) {
        Student student = new Student();
        student.setName("Melo");
        student.setScore(0);
        System.out.println("企图内卷前的成绩->"+student.getScore());
        new TestQuote().againInvolution(student);
        System.out.println("遭受反内卷后的成绩->"+student.getScore());
    }

image.png

  • 细心的同学可能发现了,我们这里多了一步操作 --> **temp = new Student(); **

先给出答案吧,Java里边其实只有值传递?为什么这么说 其实我们这里的形参temp,只是拷贝了一份student的地址。可以理解为temp拷贝了这条指针,他也指向了student所指向的对象。

  • 也就是说,temp只是跟temp同样指向了一个对象而已,在第一个例子中,我们没有去重新修改temp的指向,所以会造成一种假象:我们对temp的修改似乎等价于对student的修改? 其实只是刚好两个指向了同一个对象而已

image.png

  • 而如果我们对temp重新赋值了呢, temp = new Student();

image.png

  • 对temp重新赋值后,此时temp就指向了另一个区域了,后续再对temp修改,根本不会影响原来的student指向的区域

小结

  • 所以一个方法可以修改引用传递所对应的变量值,而不能修改值传递所对应的变量值,这里边说到的引用传递对应的变量值 , 实际上就是那个栈中的student吧(个人见解)。
  • 我们这里修改temp,并没有办法修改到student。我们temp存放的并不是主调函数放进来的实参变量student的地址(在栈中的地址)

回过头看前边引用传递的概念:

  • 形参相当于是实参的“别名”,对形参的操作其实就是对实参的操作,在引用传递过程中,被调函数的形式参数虽然也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。 被调函数对形参的任何操作都被处理成间接寻址,即通过方法栈中存放的地址访问主调函数中的实参变量。 正因为如此,被调函数对形参做的任何操作都影响了主调函数中的实参变量。
  • 所以如果这里是引用传递的话,那temp应该存放的是main栈中实参变量student在栈中的地址,然后通过间接寻址,可以访问到实际的对象。

指针传递(C语言)

形参为指向实参地址的指针,当**对形参的指向操作(不改变其原本指向的情况下)**时,就相当于对实参本身进行的操作。​

  • 拿最老套的C语言手写swap来讲
#include <stdio.h>
 
void swap(int *a, int *b) {
    int temp;
    temp = *a;
    *a = *b;
    *b = temp;
}
 
int main() {
    int a = 5;
    int b = 8;
    //需要传递地址
    swap(&a, &b);
    printf("a = %d\n", a);
    printf("b = %d", b);
}

指针和引用区别(11-5补充)

引用传递和指针传递是不同的,虽然它们都是在被调函数栈空间上的一个局部变量,但是引用传递的话,任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量

  • 而对于指针传递的参数,如果改变被调函数中的指针地址,它将影响不到主调函数的相关变量。

类似于Java里边让temp = new Student();

指针实例

#include<stdio.h>

void do_something(int* x,int* y)
{
	int x1,y1;
	x=&x1;
	y=&y1;
	
	(*x)=100;
	(*y)=200;
}

int main()
{
	int x=1,y=2;
	do_something(&x,&y);
	printf("the value: x=%d, y=%d\n",x,y);
} 

就指针传递而言,确实是值传递,像这里只是拷贝了指针,形参和实参指向同一个区域 但是拷贝终究只是拷贝,如果修改了形参的指向,那形参和实参就毫无瓜葛了。。

这里类似上边Java中的反内卷实例,是大同小异的,都是改变了形参的指向后,形参和实参就毫无关系了.

但引用传递呢(引用传递其实跟指针传递不太一样),比如下边这个例子:

  • 最后输出的x和y是 100 和 200 ,我们在方法里去修改了形参x和y,外边的实参x和y也同样被改变了指向(形参和实参是共存活的,形参就相当于实参)

C++引用传递实例

#include<stdio.h>

//引用传值(跟指针传值不太一样)
void do_something(int& x, int& y)
{
	int x1, y1;
	x1 = 100;
	y1 = 200;
	x = x1;
	y = y1;
}

int main()
{
	int x = 1, y = 2;
	do_something(x, y);
	printf("the value: x=%d, y=%d\n", x, y);
}

总结一下

如果是对引用类型的数据进行操作,分两种情况,

  • 一种是形参和实参保持指向同一个对象地址,则形参的操作,会影响实参指向的对象的内容。
  • 一种是对形参改动使得其指向新的对象地址(如重新赋值引用 = new xxx( ) ),则形参的操作,不会影响实参指向的对象的内容。

为什么会有误区呢?

  • 其实还是因为Java中数据类型的问题,基本数据类型看起来就像是值传递,而引用传递因为存放了地址,让我们能够访问到实参所指向的对象,容易让我们误以为我们的形参其实就等价于实参。

最后

  • 其实关于Java到底是引用传递还是值传递这个问题。我们只需要理解好本质就好了,通过上边的那两幅图,理解好本质才是关键,万变不离其宗。