昨天实验室同门问了个关于Java参数传递的问题,虽然很基础。但是却挺有意思的,也时初学者可能比较疑惑的地方,今天就花点时间总结一下。如有错误还请批评指正。
就是这样一个简单的问题,我在类中定义了一个全局的String str和char数组chars,并且在类的实例化中调用了**exchange()**方法,最后对原始str和chars的内容进行了输出。这时候读者不妨停下简单思考下此刻的输出应该时是什么呢?
import java.util.Arrays;
public class ParameterTransferTest {
public static void main(String[] args) {
ParameterTransfer p1 = new ParameterTransfer();
p1.exchange(p1.str, p1.chars);
System.out.println("str="+p1.str+",chars="+ Arrays.toString(p1.chars));
}
}
class ParameterTransfer {
public String str = "good";
public char[] chars = {'a','b','c'};
public void exchange(String str,char[] chars){
str = "hello";
chars[0] = 'd';
}
}
也许你想说,str被更改成了"hello",chars[0]改成了‘d’,输出不就是:
str=hello,chars=[d,b,c]
其实不然,实际的输出是:
str=good,chars=[d,b,c]
也就是说,String的str并没有被改变而chars数组被改变了,为什么会这样子呢?这就不得不说到Java的参数传递方式了。
1.基本数据类型作为参数传递时,是传递值的拷贝。
2.对象作为参数传递时,将对象在内存中的地址拷贝了一份传递给了参数。
我们分析一下前面代码的输出的原因。咦,不对啊,String和char数组不都是引用类型么,同样作为对象参数传递时,为什么一个可以更改一个不能更改呢?
这时候我们就不得不提到Java中String类型的特殊性了。
在Java中,String类型的是依靠数组来实现的,而由于数组本身属于定长的数据类型。这就表明了其实String类型的对象,一旦生成就不可更改!
这时候,你可能就会很疑惑了,String声明了就不可更改?那我那些字符串的题目对传入的字符串str都是可以更改的啊?
举个栗子:
String str1 = "hello";
str1 = str1+"world";
System.out.println(str1);
输出结果:
helloworld
你看原始的str内容是hello,经过修改之后不就是变成了helloworld吗?这里其实Java中字符串对象的内容的修改都是通过引用地址的变化而实现的,而不是修改原始对象的内容!
我们知道,整个jvm的内存模型如下:
其中:本地方法栈时运行c++native方法的栈区;程序计数器是指向程序当前运行的位置(以上两部分是java程序员比较少关心的两个区域);方法区是存储一些元数据信息,在jdk7之前又被叫做永久代,jdk8之后被叫做元数据空间,主要是存储一些静态的static方法、变量或者是类加载器classloader这样一些全局的变量;栈区是用来存储函数运行的一些临时的变量(本地方法栈、程序计数器和栈是线程私有的,线程私有的意思就是每个线程在开辟和运行的过程中都会单独的创建这样一份内存);堆区主要是用来存储对象(堆区和方法区是全局共享的)
下面以上面的栗子代码,分析一下Jvm内存中String类型改变的过程,示例图如下:
String str1 = “hello”;这句执行时会首先在栈内存中开辟一个叫str1的引用类型,str1是个指针,或者说str1是地址都是一样的意思,其本质是一个占用4字节的int数地址
然后执行到后面的"hello"时,由于其没有采用new关键字开辟内存(这里之后会出专门的blog说直接用字符串进行String初始化和new关键字进行初始化的区别),其会首先到堆上的字符串常量池寻找是否有"hello"这个常量,没有的话就在堆上新开辟个空间并且把堆上的地址传给栈中的str1引用;如果常量池中已经有了"hello",就把它的地址传给栈中的str1
那么当执行str1 = str1+"world"时的值到底是怎么样改变的呢?示意图如下:
综上所述,原始被创建的字符串"hello“在内存中全程没有被更改!所谓的改变其实是通过创建新的字符串并且改变了栈内的str1指向的引用地址来实现的。这也是所谓的String类型一旦被创建,就无法再进行修改。当更改str1指向别的对象时,如果没有其他再指向”hello“(其就会成为匿名对象),一段时间后该对象就会被jvm的GC自动回收。
知道了字符串类型的修改方式之后,我们再来分析下原问题:
如果原始问题修改为如下,输出又应该是什么结果呢?
import java.util.Arrays;
public class ParameterTransferTest {
public static void main(String[] args) {
ParameterTransfer p1 = new ParameterTransfer();
p1.exchange();
System.out.println("str="+p1.str+",chars="+ Arrays.toString(p1.chars));
}
}
class ParameterTransfer {
public String str = "good";
public char[] chars = {'a','b','c'};
public void exchange(){
str = "hello";
chars[0] = 'd';
}
}
看似和之前没什么区别,只是exchange方法变成无输入参数的方法了,但时运行后的结果却是如下结果:
str=hello,chars=[d, b, c]
也就是原始定义的类属性str在执行过exchange方法后,确实是被改变了。这也就我同门疑惑的第二个点,他平时刷题也会使用到这种处理方法,定义一个全局类属性然后之后方法里也可以更改这个值。那这到底时什么原因呢?有什么区别呢?
下面来具体分析下原始问题和进阶问题在内存上究竟有什么区别:
初始问题时:
step1:首先当运行到新建ParameterTransfer对象p1时,会首先在栈上生成p1的引用实际指向堆内的对象地址,由于p1的两个属性也是对象引用类型,str和chars会分别在指向堆内的其他两个对象的地址,示意图如下:
step2:当执行到exchange方法时,会现在栈区内生成临时变量str和chars,其都为4byte的int地址数据,分别指向了堆内的字符串"good"和chars数组的位置(下图绿色箭头所指位置);
step3:当执行str = “hello"时有上分析可知,会先判断堆内常量池是否有"hello"的字符串,没有就新创建并将临时变量str的指针指向该新位置(如图红色箭头所示),由于原始==p1的str属性还是指向"good”==所以并不会被更改;
step4:当执行到chars[0] = 'd’时,由于其不像String类型创建就无法修改,其是直接修改临时变量chars位置的原始值,将*‘a’替换为‘d’*,并且原始的类的属性chars指向的内存中相同的位置,所以输出p1.chars时会显示也被更改;
step5:当exchange()方法执行完成后,临时变量str和chars都会被删除,若没有其他引用指向"hello",起就会成为匿名对象,一段时间后就被被GC清除。
进阶问题时:
进阶问题时,主要的区别就是exchange()方法不再需要形参str和chars,没有形参传递的过程。那么实际在执行的过程中
str = "hello";
chars[0] = 'd';
都时更改的原始引用的位置,示意图如下:
此时在exchange()方法执行时,str和chars都是使用的全局的类属性,而不是经过参数传递的临时变量,因此更改会生效。
由于java参数传递的特性,原始数据类型如int,float等在参数传递时只是将原始数据的值拷贝给了形参,之后的一切修改其实和原始数据是没有任何关系的。那我们怎么样可以做到堆原始数据的更改呢?
比方说我们在进行dfs(深度优先遍历)时,可能需要带着一个int类型的长度参数,就可以将原始的int型长度传递到一个长度为1的int型数组中,再将数组传递进dfs参数中,这样这个int的数值就可以随着函数的调用而更改。