先上两个面试中经常遇见的问题:
1.给出以下代码执行后的打印输出:
public class lll {
static void change(int i){
i=10;
}
static int changeReturn(int i){
i = 10;
return i;
}
static class User{
int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
static void change(User u){
u.setAge(10);
}
static User changeReturn(User u){
User user = new User();
user.setAge(11);
return u=user;
}
public static void main(String[] args) {
int i=1;
User u = new User();
u.setAge(1);
change(i);
change(u);
System.out.println(i);
System.out.println(u.getAge());
System.out.println(changeReturn(i));
System.out.println(changeReturn(u).getAge());
System.out.println(u.getAge());
}
}
2.说出以下代码执行后的输出:
public class lll2 {
static int print(){
int i = 1;
try {
i=2;
return i;
}catch (Exception e){
i=3;
}finally {
i=4;
}
return i;
}
static class User{
int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
static User printUser(){
User u = new User();
try {
u.setAge(1);
return u;
}catch (Exception e){
u.setAge(2);
}finally {
u.setAge(3);
}
return u;
}
public static void main(String[] args) {
System.out.println(print());
System.out.println(printUser().getAge());
}
}
上面这两种类型的题,乍一看跟标题没什么关系。一个考察java的参数传递,一个考察java的异常。但其实这两个问题的本质,包括标题要讲的==和equals的区别,都与java中的引用息息相关。
引用,是java中一个极其普通,但其实又最容易被人忽略的知识点。我们都知道java中没有指针的概念,取而代之的是封装好的引用。引用的本质,是一个指向对象存储内存地址的数据结构。我们知道java分两种数据类型,基本数据类型和引用数据类型,对于基本数据类型,其赋值一般都是在栈中直接分配,对于下面的语句:
int i=10;
我们在程序中访问变量i,会直接访问10所在的内存空间。而对于下面的语句:
User user = new User();
首先我们要知道,这条语句并不是一个原子操作,其实这里分了三步:
1.新建一个引用user
2.内存中分配空间存储new出来的User对象实例
3.将引用user指向User对象实例的内存空间
不仅如此,java中new对象的操作还可能遇见指令重排序现象,对于虚拟机而言,1,2,3的执行顺序并不能保证,可以存在1,3,2现象,也就是新建引用后,直接指向要分配给User对象的内存空间,然后再执行User对象的实例化操作。这也是单例模式双重锁无法保证线程安全的原因。
java为何要多此一举,设计一个如此复杂的引用指向对象模式?java在设计之初便规定了8中基本数据类型,对于每种数据类型,其大小有固定的上下范围,便于jvm控制。而对于引用类型,由于在运行期会不断变化,很难在编译期去判定一个对象的大小。这也是java中数组分配在堆而不是栈的原因。引用本身只存储对象的内存地址,大小可控。基于此,jvm巧妙的设计出了堆内存区域,将基本数据类型和引用类型存放于栈中,无法预测的数组与对象存放于堆中。所以针对以上两个变量,i,user。我们在访问i的时候,是直接访问分配在栈中的10的内存空间,而访问user,是访问user所指向的,存储在堆中的User对象实例的内存地址。
上面啰里啰嗦说的这些,可能或多或少每个java程序员都接触过。但是深入理解引用和基本数据类型的区别,非常重要。基于此,我们先解决一个java面试中的老大难问题,也就是题目一,java传递参数的方式。
编程语言传递参数通常有两种方式,值传递和引用传递。java采用的是值传递可能很多人都知道,但是如何理解这个值传递?如何解释change(int i)方法跟changeReturn(int i)方法对i变量的操作?
首先记住一点,所谓的值传递,就是把当前变量的值复制一份,传入到一个方法中。如果当前变量是一个基本变量,那么复制变量i的操作等价于复制10这个数字,然后传入复制出的10。对于引用类型也是一样,复制变量user的操作就是在内存中开辟一块空间,将user所指向的内存的地址复制一遍,然后传入复制后的引用。
所以对于方法change(int i)而言,传入的i,与之前main方法中的i,完全是在两块内存中,我们在change(int i)方法中对i进行操作后,改变的是当前i的值,与之前main方法中i的值毫无关系:
当然实际栈中执行方法的顺序不是这样的,这里只是简单的描述了一下两块内存中不同的i,这里的复制i也就是change(int i)中的参数i,可以看到两个操作完全是在不同的内存区域操作,所以change(int i)方法并不能改变main方法中i的值。
那么对于引用类型,情况有什么不同呢?与i的操作类似,都是要先复制然后操作:
区别非常明显,因为引用中只存储了对象的内存地址,所以change(User u)和main方法中的user,指向的是一个对象,对这两个引用的任何操作最终都是在一块内存区域中完成的,所以change(User u)方法可以改变main方法中user的age值。
回过头来,我们继续分析changeReturn(int i)方法,我们已经知道,由于java的采用值传递的传参方式,change(int i)方法无法改变main方法中的i值,那为何加一个返回值以后,i的值就可以被改变了呢?大家很容易想到,带有返回值后,原先i的值被覆盖掉了。这个覆盖操作是如何完成的?最终的答案,交给编译后的字节码输出解决:
static void change(int);
descriptor: (I)V
flags: ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: bipush 10
2: istore_0
3: return
LineNumberTable:
line 6: 0
line 7: 3
static int changeReturn(int);
descriptor: (I)I
flags: ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: bipush 10
2: istore_0
3: iload_0
4: ireturn
LineNumberTable:
line 9: 0
line 10: 3
通过javap命令输出后的字节码指令,我们可以清晰的看到,changeReturn比change方法多出的iload_0这个指令,以及最终的返回指令,return与ireturn。iload_0用来将当前局部变量表的0位置元素入栈,也就是i,ireturn弹出栈顶元素也就是复制后的i,之后main方法再对i进行操作时,拿到的是新的pop出的内存区域,也就是数据1存放的内存区域:
讲到这里大家可以思考一下最后一个方法,changeReturn(User u),其实原理是一致的,changeReturn方法中新new出了一个User,那么直接调用changeReturn方法返回的user引用,会使用新的User,但是即便我们在方法中将参数u的引用更新为新地址,由于这个参数u与main方法中的u引用并不是一个引用,所以main方法中的u引用指向的依旧是以前的User对象:
可以看到最终方法执行结束后,main方法中指向的User对象依旧是之前的User对象。
这里插一句题外话,很多面试者不喜欢一些java基础的题目考察,尤其是笔试题。但通过这道题可以充分考察出一个程序员对java内存模型的熟悉程度。很多题目并不是面经能够给你解决的,只有深入了解其背后的原理,才能真的融会贯通。
继续看第二道题,看起来考察的是java中的异常机制,但其实要真的理解发生异常后的返回值,只需要理解两点:
1.java中方法的退出机制。
2.java中引用和基本类型的区别。
首先我们要明白java中对方法的执行,归根结底是jvm对字节码的执行,我们手写的代码,与最终编译后的字节码往往差异很大,所以很多方法执行顺序问题只要我们去看一眼字节码便一目了然。针对第一点,java中方法的退出机制,其实这也是异常体系中一个核心知识点:
static int print();
descriptor: ()I
flags: ACC_STATIC
Code:
stack=1, locals=3, args_size=0//操作数栈深度只有1,局部变量表长度为3,
0: iconst_1 //
1: istore_0 //执行i=1,此时i保存在局部变量表下标为0的位置
2: iconst_2 //
3: istore_0 //执行i=2
4: iload_0 //复制i的值入栈
5: istore_1 //复制并且保存给当前返回值,注意这里是istore_1而不是istore_0
6: iconst_4 //
7: istore_0 //i=4
8: iload_1 //复制当前位置1的值入栈,也就是上面istore_1保存的值2
9: ireturn //返回2
10: astore_1 //保存一个异常对象到局部变量表1,下面是catch逻辑
11: iconst_3 //
12: istore_0 // i=3
13: iconst_4 //
14: istore_0 // i=4
15: goto 23
18: astore_2 //保存一个异常对象局部变量表2,下面执行的是如果catch中无法捕获的异常发生
19: iconst_4 //
20: istore_0 //i=4
21: aload_2 //
22: athrow //抛出
23: iload_0 //此时再加载0位置的值,最后一条istore的值是4
24: ireturn //返回4
Exception table:
from to target type
2 6 10 Class java/lang/Exception
2 6 18 any
10 13 18 any
关于字节码如何阅读,这里暂时不展开讲,可以翻看我之前转载的另一篇博文。通过字节码可以清晰的看到,遇见java中的return语句,jvm会先复制一份当前的返回值,然后进行finally中的操作。由于i代表的是int基本数据类型,这里复制操作复制的是真实的数值,所以finally中对i的操作不会影响当前方法的返回值。
如果这里不是很好理解,大家可以继续去想一下返回值是对象的情况,也就是printUser的字节码:
static com.guttv.bms.dao.lll2$User printUser();
descriptor: ()Lcom/guttv/bms/dao/lll2$User;
flags: ACC_STATIC
Code:
stack=2, locals=3, args_size=0
0: new #3 // class com/guttv/bms/dao/lll2$User
3: dup
4: invokespecial #4 // Method com/guttv/bms/dao/lll2$User."":()V
7: astore_0
8: aload_0
9: iconst_1
10: invokevirtual #5 // Method com/guttv/bms/dao/lll2$User.setAge:(I)V
13: aload_0
14: astore_1
15: aload_0
16: iconst_3
17: invokevirtual #5 // Method com/guttv/bms/dao/lll2$User.setAge:(I)V
20: aload_1
21: areturn
22: astore_1
23: aload_0
24: iconst_2
25: invokevirtual #5 // Method com/guttv/bms/dao/lll2$User.setAge:(I)V
28: aload_0
29: iconst_3
30: invokevirtual #5 // Method com/guttv/bms/dao/lll2$User.setAge:(I)V
33: goto 44
36: astore_2
37: aload_0
38: iconst_3
39: invokevirtual #5 // Method com/guttv/bms/dao/lll2$User.setAge:(I)V
42: aload_2
43: athrow
44: aload_0
45: areturn
Exception table:
from to target type
8 15 22 Class java/lang/Exception
8 15 36 any
22 28 36 any
整体流程与print方法类似,遇见return语句时,jvm同样是复制了当前的返回值,但注意这里的返回值是一个引用,jvm进行复制的时候,只是复制了引用的值,也就是对象的内存地址。复制前后的引用都指向一个对象,所以在finally中对user进行操作,依然会修改复制前的user对象,导致最终返回值的变化。
这里做一个简单总结:
1.java中return语句会触发程序复制当前的返回值
2.引用存储的永远都是对象的内存地址,对引用的复制只是对内存地址的复制,并不能复制一个新的对象产生。
java中判断两个对象是否相等常用的两个方法就是==与equals,注意hashcode方法由于碰撞冲突的存在,不是一个特别的好的判定方案。equals方法比较简单,他是Object类的一个方法,默认由==实现。java中自带的包装类以及String类等一般都重写了该方法的具体实现。
==比较的是值,如果是基本类型,比较的就是具体的数值。如果是引用类型,比较的是引用的值。由于引用中保存的是对象的内存地址,所以==对两个引用的比较,实际上比较的就是两个引用是否指向同一个对象。
值得注意的是,在重写equals方法的实现时,如果涉及类型比较,要注意getClass方法与instanceof关键字的区别。简单来说,instanceof用来比较类型,getClass用来比较类。假设有如下类关系:
class Father{}
class Son extends Father{}
Son与Girl调用getClass比较返回false,调用instanceof返回true。
涉及对象引用还有一个常见的问题就是浅拷贝与深拷贝,所谓的浅拷贝,其实就是只拷贝了对象引用,拷贝前后的引用还是指向了同一个内存对象。深拷贝与之对应,是重新开辟了新的内存空间,所以如果想要实现深拷贝,就必须要手动执行new对象的过程。java中Object类的clone方法是一个浅拷贝方法,深拷贝需要自己实现。需要深拷贝的地方必须自行实现对象的创建复制过程。