先看代码

代码片段一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Data
@AllArgsConstructor
class User
{
private int id;

private String name;

public void print()
{
System.out.println(this.id + " -> " + this.name);
}
}
public class Main
{
public static void main(String[] args)
{
User user = new User(1, "小明");
user.print();
setUserToNull(user);
user.print(); // 不会报NPE
changeUserName(user);
user.print(); // username改变了
}

private static void setUserToNull(User user)
{
user = null;
}

private static void changeUserName(User user)
{
Objects.requireNonNull(user);
user.setName("user name has been changed");
}
}

代码片段二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Main2
{
public static void main(String[] args)
{
int num = 200;
changeNum(num);
System.out.println(num);

StringBuilder builder = new StringBuilder("hi");
changeStringBuilder(builder);
System.out.println(builder.toString());

String str = "hello world";
changeString(str);
System.out.println(str);

changeString2(str);
System.out.println(str);

String str2 = new String("hi hi ");
changeString2(str2);
System.out.println(str2);
}

private static void changeNum(int num)
{
num = 100;
}

private static void changeString(String str)
{
str = "string has been changed";
}

private static void changeString2(String str)
{
str = new String("string has been changed 2");
}

private static void changeStringBuilder(StringBuilder builder)
{
Objects.requireNonNull(builder);
builder.append(" has changed!!!");
}
}

猜想一下以上程序的输出?

程序输出

代码片段一的输出:

1
2
3
1 -> 小明
1 -> 小明
1 -> user name has been changed

代码片段二的输出:

1
2
3
4
5
200
hi has changed!!!
hello world
hello world
hi hi

疑问

为什么代码一中执行完setUserToNull()方法后没有抛出空指针异常?
为什么代码二中执行完changeNum()、changeString()、changeString2()方法后,参数的值没有改变?

解释

我们从内存模型的角度来理解。

1. 基本类型和引用类型的区别

1
2
int num = 200;
String str = "Hello World!";

这里num是基本类型,str是引用类型。

  • 对于基本类型num,值(200)就直接保存在变量num中。
  • 对于引用类型str,变量str中保存的只是实际对象的地址,str称为引用,引用指向实际的对象,实际对象中保存着具体的内容(”Hello World”)。

2. 赋值运算符的作用

1
2
num = 100;
str = "string has been changed";
  • 对于基本类型num ,赋值运算符会直接改变变量的值,原来的值被覆盖掉。
  • 对于引用类型str,赋值运算符会改变引用中所保存的地址,原来的地址被覆盖掉。但是原来的对象不会被改变(没有被任何引用所指向的对象将被GC回收。

3. 局部变量/方法参数

局部变量和方法参数在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个字节变更为这个对象的地址。

4. 内存模型中存储的内容

堆:
存储的是对象,每个对象都包含一个与之对应的class。
JVM只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身。
对象的由垃圾回收器负责回收,因此大小和生命周期不需要确定

栈:
每个线程包含一个栈区,栈中只保存基础数据类型的对象和自定义对象的引用(不是对象)。
每个栈中的数据(原始类型和对象引用)都是私有的。
栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。
数据大小和生命周期是可以确定的,当没有引用指向数据时,这个数据就会自动消失。

方法区:
静态区,跟堆一样,被所有的线程共享。
方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。

5. 方法调用时的细节

代码片段一:

User user = new User(1, “小明”); 这里在栈空间中创建了一个地址x1,在堆中分配了一块内存地址y1,y1中保存了这个User对象,x1中保存的是指向y1的引用(user)。
将user作为参数传递给setUserToNull(user)时,实际上传递的是user的副本(记为user2),也就是在栈空间中新创建了一个地址x2,也指向堆内存y1,这里x2指向y1的引用是user2。在setUserToNull方法中,将入参user置为null,实际上是将user2置为null,修改的是原始引用user的副本,所以原始的引用user不会变,所以setUserToNull()后执行print()方法不会NPE。
执行changeUserName()方法也是一样,栈空间新建地址x3,指向堆内存y1,x3指向y1的引用记为user3,user3这个引用操作的是堆中的原始对象,修改了它的name属性,因此执行完changeUserName()方法后user的name属性会改变。

代码片段二:

int num = 20; 这里在栈空间中创建了一个a地址,a地址中值为20,执行changeNum方法时将num作为参数传递进方法中,实际上是在栈空间中新建b地址,b地址中的值为20,传递给changeNum方法的是b地址(或者说是num的副本),然后该方法修改b地址中的值,与a地址中的值无关。因此执行完这个方法后num仍然是20。

StringBuilder builder = new StringBuilder(“hi”); 这里与User user = new User()类似,也是栈空间里存放指向堆内存的引用,changeStringBuilder()方法入参传递的实际上是builder对象的副本,但是这个副本与原始builder对象都是操作堆中的同一个对象,changeStringBuilder()方法相当于修改了这个对象的属性,所以会改变builder的值。

6. 关于String的疑问

String既然是引用类型,为什么changeString()方法也没有生效呢?

7. 关于String的解释

String被设计为不可变类型,String赋值这里有有个字符串常量池的概念,Java6之前字符串常量池是在方法区的永久代里,Java7字符串在堆里,Java8移除了方法区的永久代,字符串常量池在元空间里(?不确定),这个元空间是在本地内存中的。
String直接赋值(不使用new String),如String str = “Hello World”; 这里在栈中分配了一个空间x1给str,然后在字符串常量池中分配地址y1,将”Hello World”字符串对象放入字符串常量池y1中,然后栈空间x1记录了字符串常量池的y1地址,或者说str持有指向字符串常量池y1地址的引用。
然后调用changeString()方法,将str作为函数入参,实际上是在栈空间新建了空间x2,x2中存放的地址也是y1,然后将”string has been changed”放入字符串常量池地址y2中,将y2的地址返回给x2,因此x2中记录的是y2的地址,而x1没有改变,因此执行完changeString()方法后str的值仍然是Hello World。
以上过程没有在堆中创建对象,对象是在字符串常量池中的,

注意:当对String直接赋值,字符串太大的时候,常量池放不下,仍会放在堆里。

总结

一、
Java传递到方法参数里面都是变量的副本,可以说是值传递,如果是基础类型传递的是这个值的拷贝,如果是引用类型传递是所引用的对象在堆内存中地址的拷贝。

二、
Java的参数传递实际上就是赋值操作,如:

1
2
3
4
5
6
7
8

int num = 200;
changeNum(num);

private static void changeNum(int value)
{
value = 100;
}

这里传递给changeNum()方法,实际上相当于 int value = num; 方法中对value重新赋值自然不会影响num的值。

String text = str; StringBuilder builder = sb也是一样,让text指向新的对象(text = “string has been changed”;),那么自然也不会影响st,还是指向原来的String对象,但若是在builder和sb指向同一对象的时候,通过builder对对象进行了内容更改,那么sb所指向的对象内容也将发生更改,因为两者现在指向的是同一对象。

更多细节

  1. String 直接赋值 和 new String() 的区别?
  2. String 做”+”运算时,编译器做了什么?
  3. Java中字符串常量池机制
  4. String内存模型

Read More

[1]Java 到底是值传递还是引用传递?
[2]java中方法的参数传递机制
[3]Java-String类型的参数传递问题
[4]JAVA中方法参数的引用传递
[5]Java中String类通过new创建和直接赋值字符串的区别
[6]String两种不同的赋值方式
[7]java中的堆、栈、常量池以及String类型的两种声明
[8]Java String 的内存模型
[9]Java String内存模型
[10]简单谈谈Java中String类型的参数传递问题
[11]JVM Memory Model / Structure and Components
[12]JAVA8元空间是什么?
[13]JAVA8 JVM的变化: 元空间(Metaspace)
[14]JAVA8中的元空间到底存了什么?
[15]Guide to Java String Pool
[16]String:字符串常量池
[17]Java内存中的常量池
[18]JDK8 Java字符串常量池在Java堆中而不是方法区
[19]Java中String字符串常量池
[20]Java基础之jdk1.8 JVM内存模型简述,含String常量池简单分析