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:
如果在函数中对参数的副本(还是原来的引用对象的内存地址)进行了修改,那么也就是直接修改了内存中的同一个对象,那肯定是影响到了原来的实际参数。
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对象其实没有受到任何影响。
所以打印结果为: