值传递还是引用传递?解密Java中的参数传递机制的神秘面纱

一、引言

在编程中,我们经常会遇到方法调用的问题,也就是如何把一个变量或对象作为参数传递给另一个方法。这里有两种常见的传递方式,分别是值传递和引用传递。想要彻底理解Java中它们的区别,首先需要解决以下几个问题:

  • 它们的区别和优缺点是什么呢?
  • Java中值传递和引用传递对应什么数据类型时会出现?
  • Java中的方法调用是如何进行的?

本文将为你解答这些问题,并通过代码示例和思考题来加深你的理解。

值传递和引用传递的概念区别

它们的主要区别在于

  • 值传递是把参数的值复制一份,然后传递给方法
  • 而引用传递是把参数的地址复制一份,然后传递给方法

我们可以用下面的图来形象地表示这两种传递方式:

值传递还是引用传递?解密Java中的参数传递机制的神秘面纱_第1张图片

从图中可以看出,值传递和引用传递的优缺点如下

值传递

  • **优点:**可以保证程序的安全性和可靠性,避免在方法中对参数的修改导致原来的变量出现意想不到的错误和难以调试的问题。
  • **缺点:**会带来一些性能的损失,因为每次调用方法时,都需要复制参数的值,这会占用一定的内存和时间。

引用传递

  • **优点:**可以提高程序的效率和灵活性,因为不需要复制参数的值,只需要复制参数的地址,这会节省一定的内存和时间,而且可以在方法中对参数的修改反馈到原来的变量,实现一些复杂的功能。
  • **缺点:**会降低程序的安全性和可靠性,因为在方法中对参数的修改会影响到原来的变量,这可能会导致一些意想不到的错误和难以调试的问题。
引用传递是否本质上就是值传递?

这个问题感觉很难回答,我认为两个观点都是对的

两者本质上不同:

  • 值传递只占用一块内存空间存储数据
  • 引用传递占用两块内存空间存储数据,一块为实际存储的数据,一块为指向数据的引用(可以理解为C语言的指针概念),当修改数据时需要先通过指针寻址然后才能修改数据

注意指针寻址这个过程

两者本质上相同:

  • 引用传递实际上传递的是指向数据的引用,而这个引用本身也是一个值。参数传递时值传递直接传递数据的副本,而引用传递传递指针的副本,这两个过程其实都是复制一份副本的过程

Java中值传递和引用传递对应什么数据类型

Java中的数据类型分为两大类,分别是基本数据类型和对象类型。

  • 基本类型包括八种,分别是byte,short,int,long,float,double,char,和boolean,它们存储的是具体的数值,而不是地址
  • 其他类型都是对象类型,它们存储的是对象的地址。

可以简单的通过首字母是否大写区分,首字母大写的均为对象,小写的即为基本数据类型。

Java中的方法调用是如何进行的

那么,Java中的方法调用是采用值传递还是引用传递呢?

答案是,Java中的方法调用都是值传递,也就是说,只会传递参数的副本,而不会影响原来的参数。

引用传递的效果是如何实现的

对于引用类型,方法参数拷贝的副本存储的是对象的地址,当在方法中修改了该对象里的内容(对象的属性等),实际上是通过对象地址的副本找到了实际存储的数据位置,然后修改实际存储的数据(对象属性等),这就造成了一种类似于引用传递的效果。

如何证明方法调用是值传递

然而,如果在方法中让该参数副本直接指向了新的对象,那么副本指向新的对象后再进行指针寻址找到的就是新的对象了,这时不论怎么修改原对象的参数就自然不会受到影响(因为该方法中已经没有途径找到原对象了),这就体现了值传递的特点。

我们在后面的代码示例中会看到,这种情况的具体表现和原理。

String类型数据使用”+“ 运算符时如何工作的

在Java中,我们可以使用“+”运算符来连接字符串和其他类型的数据,比如:

String s = "Hello";
s += "World"; // s的值是"HelloWorld"

但是,你知道对于String类型“+”运算符是如何工作的吗?实际上,当我们使用+运算符来连接字符串时,Java会自动调用StringBuilder类的append方法将这些字符串都添加进去拼接起来,然后使用StringBuilder类的toString方法返回一个新的String对象。

这就意味着,每次使用“+”运算符来连接字符串时,都会创建一个新的字符串对象,而不是修改原来的字符串对象。

这是因为String类是不可变的,也就是说,一旦创建了一个字符串对象,它的内容就不能被改变。这样可以保证字符串的安全性和效率,但是也会带来一些内存的开销。我们在后面的代码示例中会看到,这个特性会影响到方法调用的结果。

二、代码示例

为了更好地理解Java中的值传递和引用传递,我们来看如下代码示例,如下所示:

public class TestMain {

    public static void main(String[] args) {
      List<Integer> list = new ArrayList<Integer>();
      for (int i = 0; i < 10; i++) {
        list.add(i);
      }
      add(list);
      for (Integer j : list) {
          System.err.print(j+",");;
      }
      
      System.err.println("");
      System.err.println("*********************");
      
      String a="A";
      append(a);
      System.err.println(a);
      
      int num = 5;
      addNum(num);
      System.err.println(num);
    }

    static void add(List<Integer> list){
        list.add(100);
    }

    static void append(String str){
        str+="is a";
    }
    static void addNum(int a){
        a=a+10;
    }
}

这段代码中,我们定义了一个List类型的变量list,一个String类型的变量a,和一个int类型的变量num,然后分别调用了add,append,和addNum三个方法,把这三个变量作为参数传递进去。你能猜出这段代码的运行结果是什么吗?我们来看一下:

0,1,2,3,4,5,6,7,8,9,100,
*********************
A
5

你可能会感到奇怪,为什么list的内容被修改了,而a和num的内容没有被修改呢?这不是很不一致吗?

  • 这是因为Java中的方法调用都是值传递,也就是说,只会传递参数的副本,而不会影响原来的参数。
  • 但是对于引用类型,由于它们存储的是对象的地址,所以在方法中通过对象指针可以修改对象属性的内容。

我们来分析一下这段代码的运行过程:

(1)list变量的变化过程

  1. 在main方法中,我们创建了一个List类型的变量list,它是一个引用类型的变量,也就是说,它存储的是一个对象的地址,而不是对象本身。我们可以把它想象成一个指针,指向一个存储了10个整数的数组。
  2. 然后,我们调用了add方法,把list作为参数传递进去。注意,这里传递的是list的指针副本,也就是说,add方法中的list参数和main方法中的list变量是两个不同的变量,但是它们指向的是同一个数组对象。
  3. 所以,当我们在add方法中对list进行修改,比如添加一个元素100,实际上是修改了它指向的ArrayList对象中的数组这个属性,而不是list这个指针本身。
  4. 这就导致了main方法中的list变量也能感知到这个修改,因为它也指向同一个ArrayList对象。所以,当我们打印list的内容时,会发现多了一个元素100。

(2)字符串对象a变量的变化过程

  1. 接着,我们创建了一个String类型的变量a,它的值是"A"。String也是一个引用类型的变量,它也存储的是一个对象的地址,指向一个存储了"A"这个字符串的对象。
  2. 然后,我们调用了append方法,把a作为参数传递进去。同样,这里传递的是a的指针副本,也就是说,append方法中的str参数和main方法中的a变量是两个不同的指针变量,但是它们指向的是同一个字符串对象。
  3. 所以当我们在append方法中对str进行修改,比如拼接一个字符串"is a",实际上是创建了一个新的字符串对象,并让str指向新对象,而不是修改了原来的字符串对象里的字符串属性。这就导致了main方法中的a变量并没有感知到这个修改,因为main中的a变量还是指向原来的字符串对象。
  4. 所以当我们打印a的内容时,还是"A",而不是"Ais a"。这里要注意的是,String类是不可变的,也就是说,一旦创建了一个字符串对象,它的内容就不能被改变。所以,每次使用+运算符或concat方法来连接字符串时,都会创建一个新的字符串对象,而不是修改原来的字符串对象。这样可以保证字符串的安全性和效率,但是也会带来一些内存的开销。

(3)num变量的变化过程

  1. 最后,我们创建了一个int类型的变量num,它的值是5。int是一个基本类型的变量,它存储的是一个数值,而不是一个地址。
  2. 然后,我们调用了addNum方法,把num作为参数传递进去。这里传递的是num的副本,也就是说,addNum方法中的a参数和main方法中的num变量是两个不同的变量,但是它们存储的是同一个数值5而不是存储的指针。
  3. 所以当我们在addNum方法中对a进行修改,比如加上10,实际上是修改了a本身,而不是num。这就导致了main方法中的num变量并没有感知到这个修改,因为它还是存储着原来的数值5。所以当我们打印num的内容时,还是5,而不是15。

三、结论

通过上面的分析,我们可以得出一个结论。

  1. Java中的方法调用,无论是基本类型还是引用类型,都是值传递,也就是说,只会传递参数的副本,而不会影响原来的参数。
  2. 但是对于引用类型,由于它们存储的是对象的地址,传递的也是对象的地址,所以如果在方法中修改了它们指向的对象的内容,那么原来的参数指向的对象的内容也会受到影响,这就造成了一种类似于引用传递的效果。
  3. 如果在方法中让它们指向了新的对象,那么改变的就是新对象的内容,原来的参数指向的对象内容就不会受到影响,这就体现了值传递的特点。

这样的设计是为了保证程序的安全性和可靠性,避免在方法中对参数的修改导致原来的变量出现意想不到的错误和难以调试的问题。当然,这也会带来一些性能的损失,因为每次调用方法时,都需要复制参数的值,这会占用一定的内存和时间。但是,这种损失是可以接受的,相比于程序的安全性和可靠性,这些性能的损失是次要的。

四、思考题

为了加深你的理解,我为你准备了一些思考题,让你自己尝试运行和分析代码,看看结果是否符合你的预期。如果你遇到了困难,你也可以向我提问,我会尽力帮助你。思考题如下:

  • 如果在方法中对引用类型的参数进行了重新赋值,会不会影响原来的变量?

  • 如果在方法中对基本类型的参数进行了重新赋值,会不会影响原来的变量?

  • 如果在方法中对引用类型的参数的属性进行了修改,会不会影响原来的变量?

  • 如果在方法中对String类型的参数进行了修改,会不会影响原来的变量?为什么?

如果你有任何的反馈或建议,欢迎告诉我。如果你对我的文章感到满意,也可以给我一个好评或者分享给你的朋友。感谢你的阅读和支持。

你可能感兴趣的:(Java基础,java,面试)