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

原文链接:

Java 到底是值传递还是引用传递? - Intopass的回答 - 知乎 https://www.zhihu.com/question/31203609/answer/50992895

Java 到底是值传递还是引用传递? - Hollis的回答 - 知乎 https://www.zhihu.com/question/31203609/answer/576030121

 

前言:

首先,不要纠结于 Pass By Value 和 Pass By Reference 的字面上的意义,否则很容易陷入所谓的“一切传引用其实本质上是传值”这种并不能解决问题无意义论战中。

更何况,要想知道Java到底是传值还是传引用,起码你要先知道传值和传引用的准确含义吧?可是如果你已经知道了这两个名字的准确含义,那么你自己就能判断Java到底是传值还是传引用。
这就好像用大学的名词来解释高中的题目,对于初学者根本没有任何意义。

关于这个问题,引发过很多广泛的讨论,看来很多程序员对于这个问题的理解都不尽相同,甚至很多人理解的是错误的。还有的人可能知道Java中的参数传递是值传递,但是说不出来为什么。

在开始深入讲解之前,有必要纠正一下大家以前的那些错误看法了。如果你有以下想法,那么你有必要好好阅读本文。

错误理解一:值传递和引用传递,区分的条件是传递的内容,如果是个值,就是值传递。如果是个引用,就是引用传递。

错误理解二:Java是引用传递。

错误理解三:传递的参数如果是普通类型,那就是值传递,如果是对象,那就是引用传递。

 

一:搞清楚 基本类型 和 引用类型 的不同之处

1 int num = 10;
2 String str = "hello";

 

 

 

 

如图所示,num是基本类型,值就直接保存在变量中。

而str是引用类型,变量中保存的只是实际对象在堆中的地址。一般称这种变量为"引用",引用指向实际对象在堆内存中的地址,实际对象中保存着内容。

 

二:搞清楚赋值运算符(=)的作用

 

1 num = 20;
2 str = "java";

 

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

对于引用类型 str,赋值运算符会改变引用中所保存的对象地址,原来的地址被覆盖掉。但是原来的对象不会被改变(重要)

如上图所示,"hello" 字符串对象没有被改变。(没有被任何引用所指向的对象是垃圾,会被垃圾回收器回收)

 

 

三,实参与形参

我们都知道,在Java中定义方法的时候是可以定义参数的。比如Java中的main方法,`public static void main(String[] args)`,这里面的args就是参数。

参数在程序语言中分为形式参数和实际参数。

形式参数:是在定义函数名和函数体的时候使用的参数,目的是用来接收调用该函数时传入的参数。

实际参数:在调用有参函数时,主调函数和被调函数之间有数据传递关系。在主调函数中调用一个函数时,函数名后面括号中的参数称为“实际参数”。

简单举个例子:

 

1 public static void main(String[] args) {
2     ParamTest pt = new ParamTest();
3     pt.sout("Hollis");//实际参数为 Hollis
4 }
5  
6 public void sout(String name) { //形式参数为 name
7     System.out.println(name);
8 }

 

实际参数是调用有参方法的时候真正传递的内容,而形式参数是用于接收实参内容的参数,就是一个形式而已。

 

四:调用方法时发生了什么?

参数传递基本上就是赋值操作。

 

 1 // 第一个例子:基本类型
 2 void foo(int value) {
 3     value = 100;
 4 }
 5 foo(num); // num 没有被改变
 6  
 7 // 第二个例子:没有提供改变自身方法的引用类型
 8 void foo(String text) {
 9     text = "windows";
10 }
11 foo(str); // str 也没有被改变
12  
13 // 第三个例子:提供了改变自身方法的引用类型
14 StringBuilder sb = new StringBuilder("iphone");
15 void foo(StringBuilder builder) {
16     builder.append("4");
17 }
18 foo(sb); // sb 被改变了,变成了"iphone4"。
19  
20 // 第四个例子:提供了改变自身方法的引用类型,但是不使用,而是使用赋值运算符。
21 StringBuilder sb = new StringBuilder("iphone");
22 void foo(StringBuilder builder) {
23     builder = new StringBuilder("ipad");
24 }
25 foo(sb); // sb 没有被改变,还是 "iphone"。

 

 

重点理解为什么,第三个例子和第四个例子结果不同?

下面是第三个例子的图解:

builder.append("4")之后:

 

下面是第四个例子的图解:

 

builder = new StringBuilder("ipad"); 之后,

这个builder变量指向的就是新对象在堆中的内存地址了。

 


五,值传递与引用传递

前边提到了,当我们调用一个有参函数的时候,会把实际参数传递给形式参数。

但是,在程序语言中,这个传递过程中传递的两种情况,即值传递和引用传递。我们来看下程序语言中是如何定义和区分值传递和引用传递的。

值传递(pass by value)是指在调用函数时将实际参数的值复制一份传递到函数中,这样在函数中如果对参数的副本进行修改,将不会影响到原来的实际参数值。

引用传递(pass by reference)是指在调用函数时将实际参数的引用地址(引用的对象在堆中的内存地址)直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。

有了上面的概念,然后就可以写代码实践了,来看看Java中到底是值传递还是引用传递 :

 

 1 public static void main(String[] args) {
 2     ParamTest pt = new ParamTest();
 3     int i = 10;
 4     pt.pass(10);
 5     System.out.println("print in main , i is " + i);
 6 }
 7  
 8 public void pass(int j) {
 9     j = 20;
10     System.out.println("print in pass , j is " + j);
11 }

 

可见,pass方法内部对name的值的修改并没有改变实际参数i的值。那么,按照上面的定义,有人得到结论:Java的方法传递是值传递。

但是,很快就有人提出质疑了。然后,他们会搬出以下代码:

 

 1 public static void main(String[] args) {
 2     ParamTest pt = new ParamTest();
 3     User hollis = new User();
 4     hollis.setName("Hollis");
 5     hollis.setGender("Male");
 6     pt.pass(hollis);
 7     System.out.println("print in main , user is " + hollis);
 8 }
 9  
10 public void pass(User user) {
11     user.setName("hollischuang");
12     System.out.println("print in pass , user is " + user);
13 }

 

同样是一个pass方法,同样是在pass方法内修改参数的值。输出结果如下:

 

1 print in pass , user is User{name='hollischuang', gender='Male'}
2 print in main , user is User{name='hollischuang', gender='Male'}

 

经过pass方法执行后,实参的值竟然被改变了,那按照上面的引用传递的定义,实际参数的值被改变了,这不就是引用传递了么。

于是,根据上面的两段代码,有人得出一个新的结论:

Java的方法中,在传递普通类型的时候是值传递,在传递对象类型的时候是引用传递。

但是,这种表述仍然是错误的。不信你看下面这个参数类型为对象的参数传递:

 1 public static void main(String[] args) {
 2     ParamTest pt = new ParamTest();
 3     String name = "Hollis";
 4     pt.pass(name);
 5     System.out.println("print in main , name is " + name);
 6 }
 7  
 8 public void pass(String name) {
 9     name = "hollischuang";
10     System.out.println("print in pass , name is " + name);
11 }

结果如下:

1 print in pass , name is hollischuang
2 print in main , name is Hollis

这又作何解释呢?同样传递了一个对象,但是原始参数的值并没有被修改,难道传递对象又变成值传递了?

 

六,Java中的值传递

上面,我们举了三个例子,表现的结果却不一样,这也是导致很多初学者,甚至很多高级程序员对于Java的传递类型有困惑的原因。

其实,我想告诉大家的是,上面的概念没有错,只是代码的例子有问题。来,我再来给大家画一下概念中的重点,然后再举几个真正恰当的例子。

值传递(pass by value)是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中对参数的副本进行修改,将不会影响到原来的实际参数。

引用传递(pass by reference)是指在调用函数时将实际引用的对象的内存地址直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际引用的对象

那么,我来给大家总结一下,值传递和引用传递之前的区别的重点是什么。

 

还拿上面的一个例子来举例,我们真正的改变参数,看看会发生什么?

 

 
 1 public static void main(String[] args) {
 2     ParamTest pt = new ParamTest();
 3     User hollis = new User();
 4     hollis.setName("Hollis");
 5     hollis.setGender("Male");
 6     pt.pass(hollis);
 7     System.out.println("print in main , user is " + hollis);
 8 }
 9  
10 public void pass(User user) {
11     user = new User();
12     user.setName("hollischuang");
13     user.setGender("Male");
14     System.out.println("print in pass , user is " + user);
15 }

 

上面的代码中,我们在pass方法中,改变了user对象,输出结果如下:

 

1 print in pass , user is User{name='hollischuang', gender='Male'}
2 print in main , user is User{name='Hollis', gender='Male'}

 

我们来画一张图,看一下整个过程中发生了什么,然后我再告诉你,为啥Java中只有值传递。

 

稍微解释下这张图,当我们在main中创建一个User对象的时候,在堆中开辟一块内存,其中保存了name和gender等数据。然后hollis变量持有该内存的地址0x123456(图1)。

当尝试调用pass方法,并且hollis作为实际参数传递给形式参数user的时候,会把这个地址0x123456交给user,这时,user也指向了这个地址(图2)。

然后在pass方法内对参数进行修改的时候,即user = new User(); 重新开辟一块0X456789的内存,赋值给user后面对user的任何修改都不会改变内存0X123456的内容(图3)。

上面这种传递是什么传递?肯定不是引用传递,如果是引用传递的话,在user=new User()的时候,实际参数的引用也应该改为指向0X456789,但是实际上并没有

通过概念我们也能知道,这里是把实际参数的引用的地址复制了一份,传递给了形式参数。所以,上面的参数其实是值传递,把实参对象引用的地址当做值传递给了形式参数。

所以,值传递和引用传递的区别并不是传递的内容,而是实参到底有没有被复制一份给形参

 

那么,既然这样,为啥上面同样是传递对象,传递的String对象和User对象的表现结果不一样呢?我们在pass方法中使用name = "hollischuang";

试着去更改name的值,阴差阳错的直接改变了name的引用的地址。因为这段代码,会new一个String,在把引用交给name,即等价于name = new String("hollischuang");

而原来的那个”Hollis”字符串并没有受到修改。

所以说,Java中其实还是值传递的,只不过对于对象参数,值的内容是对象的引用。

 

七,总结

无论是值传递还是引用传递,其实都是一种求值策略(Evaluation strategy)。在求值策略中,还有一种叫做按共享传递(call by sharing)。其实Java中的参数传递严格意义上说应该是按共享传递。

按共享传递,是指在调用函数时,传递给函数的是实参的地址的拷贝(如果实参在栈中,则直接拷贝该值)。在函数内部对参数进行操作时,需要先拷贝的地址寻找到具体的值,再进行操作。

如果该值在栈中,那么因为是直接拷贝的值,所以函数内部对参数进行操作不会对外部变量产生影响。

简单点说,Java中的传递,是值传递,而这个值,实际上是对象内存地址的引用。

而按共享传递其实只是按值传递的一个特例罢了,所以我们可以说Java的传递是按共享传递,或者说Java中的传递是值传递。

 


八,补充:各种类型数据在内存中的存储方式:

再补充讲一些各种类型数据在内存中的存储方式。

8.1,从局部变量/方法参数开始讲起:

局部变量和方法参数在jvm中的储存方法是相同的,都是在栈上开辟空间来储存的,随着进入方法开辟,退出方法回收。以32位JVM为例,boolean/byte/short/char/int/float以及引用都是分配4字节空间,long/double分配8字节空间。对于每个方法来说,最多占用多少空间是一定的,这在编译时就可以计算好。

我们都知道JVM内存模型中有,stack和heap的存在,但是更准确的说,是每个线程都分配一个独享的stack,所有线程共享一个heap。对于每个方法的局部变量来说,是绝对无法被其他方法,甚至其他线程的同一方法所访问到的,更遑论修改。

当我们在方法中声明一个 int i = 0,或者 Object obj = null 时,仅仅涉及stack,不影响到heap,当我们 new Object() 时,会在heap中开辟一段内存并初始化Object对象。当我们将这个对象赋予obj变量时,仅仅是stack中代表obj的那4个字节变更为这个对象的地址。

 

8.2,数组类型引用和对象:

当我们声明一个数组时,如int[] arr = new int[10],因为数组也是对象,arr实际上是引用,stack上仅仅占用4字节空间,new int[10]会在heap中开辟一个数组对象,然后arr指向它。

当我们声明一个二维数组时,如 int[][] arr2 = new int[2][4],arr2同样仅在stack中占用4个字节,会在内存中开辟一个长度为2的,类型为int[]的数组,然后arr2指向这个数组。这个数组内部有两个引用(大小为4字节),分别指向两个长度为4的类型为int的数组。

所以当我们传递一个数组引用给一个方法时,数组的元素是可以被改变的,但是无法让数组引用指向新的数组。

你还可以这样声明:int[][] arr3 = new int[3][],这时内存情况如下图

你还可以这样 arr3[0] = new int [5]; arr3[1] = arr2[0];

 

8.3,关于String:

原本回答中关于String的图解是简化过的,实际上String对象内部仅需要维护三个变量:char[] chars, int startIndex, int length。而chars在某些情况下是可以共用的。

但是因为String被设计成为了不可变类型,所以你思考时把String对象简化考虑也是可以的。

String str = new String("hello")

当然某些JVM实现会把"hello"字面量生成的String对象放到常量池中,而常量池中的对象可以实际分配在heap中,有些实现也许会分配在方法区,当然这对我们理解影响不大。 

·


九,结论:

        值传递(pass by value)是指在调用函数时将实际参数的值复制一份传递到函数中,这样在函数中如果对参数的副本进行修改,将不会影响到原来的实际参数值。

        引用传递(pass by reference)是指在调用函数时将实际参数的引用地址(引用的对象在堆中的内存地址)直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。

Java值传递:

        Java中的传递,是值传递,而这个值,实际上是对象内存地址的引用。

        也就是说,在Java中,在进行函数调用的时候,实际上是将实际参数(引用对象的内存地址)复制了一份,传递到函数中

这样,

  1.   如果在函数中对参数的副本(还是原来的引用对象的内存地址)进行了修改,那么也就是直接修改了内存中的同一个对象,那肯定是影响到了原来的实际参数。
  2.   如果在函数中,将参数的副本指向了一个新的对象,则对原来的对象和实际参数无影响。

 

十,结论示例:

示例1:

        如果在函数中对参数的副本(还是原来的引用对象的内存地址)进行了修改,那么也就是直接修改了内存中的同一个对象,那肯定是影响到了原来的实际参数。

 1 public static void main(String[] args) {
 2     User hollis = new User();
 3     hollis.setName("Hollis");
 4     hollis.setGender("Male");
 5  
 6     pass(hollis);
 7     System.out.println("hollis.getName() = " + hollis.getName());
 8     System.out.println("hollis.getGender() = " + hollis.getGender());
 9 }
10  
11 public static void pass(User user) {
12     user.setName("hollischuang");  //修改了name属性
13 }

 

 可以看出,在Java程序中,实际上是值传递,只不过传递的是引用对象的内存地址。是将引用对象的内存地址复制了一份传递了过去。

如图所示:

 在一开始的时候,hollis这个变量指向了一个User对象,该对象的内存地址是'0x001',该对象的name属性值为"Hollis",gender属性值为"Male"。

然后调用了pass(User user)方法,将hollis这个变量(指向的内存地址)复制了一份传入了pass()方法,所以在pass()方法中,user变量指向的内存地址也是'0x001',即同一个User对象。

此时在pass()方法中,修改了name属性的值,也就是修改了同一个User对象的name属性的值,则此时回到原来的函数中,hollis这个变量指向的对象的name属性的值自然也改变了。

所以打印结果为

 

示例2:

        如果在函数中,将参数的副本指向了一个新的对象,则对原来的对象和实际参数无影响。

 1 public static void main(String[] args) {
 2     User hollis = new User();
 3     hollis.setName("Hollis");
 4     hollis.setGender("Male");
 5  
 6     pass(hollis);
 7     System.out.println("hollis.getName() = " + hollis.getName());
 8     System.out.println("hollis.getGender() = " + hollis.getGender());
 9 }
10  
11 public static void pass(User user) {
12     user = new User("hollischuang", "Male");  //指向了一个新的对象
13 }

 

 

如图所示:

 

        在Java程序中,实际上是值传递,只不过传递的是引用对象的内存地址。是将引用对象的内存地址复制了一份传递了过去。

在一开始的时候,hollis这个变量指向了一个User对象,该对象的内存地址是'0x001',该对象的name属性值为"Hollis",gender属性值为"Male"。

然后调用了pass(User user)方法,将hollis这个变量(指向的内存地址)复制了一份传入了pass()方法,所以在pass()方法中,user变量一开始指向的内存地址也是'0x001',即同一个User对象。

但是在pass()方法中,修改了user变量指向的内存地址,也就是让user变量指向了一个新的User对象,那么此时回到原来的函数中,hollis这个变量指向的User对象其实没有受到任何影响。

所以打印结果为:

 

posted @ 2022-09-06 22:34  r1-12king  阅读(253)  评论(0编辑  收藏  举报