一个简单代码的不简单实现

注:这个问题其实就是Java里面的参数传递都是值传递而非引用传递。这里的值传递包括两部分,1、基础类型;2、对象类型。实际上Java根本不存在真正意义上的引用传递,我们先从值传递和引用传递的概念说起。

  在c/c++里面,值传递,就是拷贝一份数据传给参数,比如基本类型,不管你在函数里面如何修改参数,被传递的数据依然不会改变;而引用传递,也叫做指针传递,传递的其实是内存的地址,那么当我们改变内存地址所对应的值的时候,真正的参数也就被改变了,也就是所谓的实参(实际参数),而形参和实参都是同一个指针。这里是c/c++的参数传递。

  而到了Java里面,基本类型的参数传递和c/c++里面是一样的,而对象类型,传递的依然是栈空间中的变量拷贝,我们称作“引用”,这里的引用指的是对堆内存的引用,这个引用和上面说的引用传递的“引用”不是同一个东西,上面说的引用其实就是指针。既然传递的是栈内存的引用拷贝,那么结果就是实参和形参指向了同一个对象,此时如果调用这个对象本身的方法来改变对象内部的数据,那么就会改变实参的值,因为都指向同一个,然而,如果此时对形参做赋值操作,那么就仅仅只会改变形参的指向,而不会改变实参的内部结构。所以下的t1方法会改变map的值,而t2就不行。

public class ParamTest {

    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        map.put("0", "0");
        
        t1(map);
        System.err.println(map);
        t2(map);
        System.err.println(map);
    }
    
    public static void t1(Map<String, String> map) {
        map.put("1", "1");
    }
    
    public static void t2(Map<String, String> map) {
        map = new HashMap<>();
    }
}

  那么,我们如何来区别和记忆Java里面的参数传递呢。这里给个简单的方法,当我们在传递参数的时候,我们脑海时刻要记住一幅画面,那就是左边是栈内存,右边堆内存,实参和形参分别存在于左边的占空间,是2个不同的变量,都同时指向一个堆内存,当调用对象本身的方法进行内部数据改变的时候,会真正改变值,而进行赋值操作的时候,只不过把拷贝的占空间中的变量指向了另外一个对象,而不会改变实参本身的内部数据。大概就是下面这幅场景。

而c/c++里面的指针传递,非常的彻底,操作的直接就是内存单元的值,所以一定会改变。

那么,c/c++和Java为什么会存在这样的差异呢?我的理解是,这两种语言的内存模型不一样,c/c++是可以直接操作内存地址的计算机语言,而Java是基于栈结构的语言,c/c++是编译型语言,编译完成之后直接就是计算机能识别的二进制,可以直接操作内存,而Java是基于JVM的的解释型语言(抛开JIT技术),Java根本就不提供直接操作内存的能力,甚至可以说Java根本没有办法提供,因为当运行到某一行代码的时候,需要解释,然后通过转换符号链接为真实地址,Java有一套自己的指令集,而这套指令集cpu根本不认识(当然如果JVM的设计者想要实现这种功能或许有可能实现),是通过jvm转换成cpu认识的指令,在这个转换动作之前谁都不知道他指向内存的什么位置,因此就无法直接操作内存,所以说,内存模型导致了本质的区别。

值传递和引用传递的区别在何处?针对c/c++而言,因为Java根本就只存在值传递。如果传递的参数是一个int类型(4字节),那么拷贝一个参数可能没什么,如果传递的参数是1G的对象,那区别就大了,首先负值这个对象需要消耗更多的时间,然后会消耗更多的内存。因此值引用传递性能更好一点。当然还有一个区别,那就是我们方法调用只能最多返回一个结果,return xxx,而如果传递的参数里面有多个指针,在方法调用里面给这多个指针赋值,由于形参和实参是同一个地址,实参也就改变了,相当于可以返回多个值。

事实上,引用传递是c++里面提出的语法概念,甚至在c语言里面都是没有这种概念的,提出这个东西最基本的原因是:c++是c的超级,他比c强大,方便,当在c/c++里面操作指针的时候,会带有各种型号*,比如*p = xxx,那么为了去掉随处可见的型号,就增加了一类引用类型,目的就是为了减少各种星号操作,本质上是和指针是一回事,就类似于一种语法糖,更好用一点。我们在理解上面完全可以把他等同于指针(事实上本来就是),所以说引用传递和指针传递本质上是一回事。他和windows上的快捷方式,Linux上的链接是一个道理,理解成别名也行。

题外话:这种问题其实不太清楚在平时的开发当中也不会有太多问题,事实上很多人确实不理解或者不知道,但是也不影响开发,而这个概念在面试的时候可能会碰到,特别面试高级开发,那么,如果没有c/c++的背景,很有可能对于一个Java程序员,到死那天都不一定会明白。这也就是没有c/c++背景而只有Java这种高级语言背景的程序员的短板,所以一直以来c/c++程序员看不起Java程序员,觉得Java程序员不够底层(一直以来的偏见:越是底层就越牛逼)。

而这篇帖子接下来的部分,虽然解决了交换值,但是导致的后果就是直接破坏了JDK的Integer的常量池,牺牲了JVM的正确性,当然帖子也仅仅是给了个解决方法,并不是要我们去这样子做。

前几天看有人贴了一个java代码的问题,实在有意思,今天拿出来和大家分享分享。题目是这样的:给定两个Integer类型的变量a和b,要求实现一个函数swap,交换他们的值。代码如下:

====想一想的分割线 ====

大家用30秒钟想想怎么样实现呢?

====时间到的分割线 ====

估摸着好多盆友一看这个题目,第一反应是:擦,这么简单的题,让我来做,是不是在侮辱我的智商!!!

最简单的实现:

这题目初看一眼,确实好简单,可能只需要10秒钟就可以完成(主要时间花在打字上):

好了,这就是实现代码,三行!那我们来看看结果:

before : a = 1, b = 2

after : a = 1, b = 2

怎么样,这个结果你猜对了嘛?就是完全没有交换。那是为什么呢?老王画了一张图:

在我们的main函数里,有两个对象变量a和b,他们分别指向堆上的两个Integer对象,地址分别为:0x1234和0x1265,值分别为1和2。在java里,Object a = new Object()这句话执行类似于c++里面的CObj* c = new CObj(),这里a和c实际都是指针(java称做引用),a和c的值,实际是一个内存地址,而不是1、2、3这样的具体的数字。

所以,在做swap函数调用的时候,传递的是值,也就是i1得到的a的值:一个内存地址,指向0x1234。同理,i2得到的也是b的值:另外一个内存地址:0x1265。

好了,现在swap入栈,i1、i2、tmp都是指针:

tmp = i1;    // tmp得到i1的值:0x1234

i1    = i2;     // i1得到i2的值:0x1265

i2    = tmp; // i2得到tmp的值:0x1234

可以看到,在swap里面,i1和i2做了一个指针交换,最后的结果如下:

 

最终,a和b还是指向对应的内存区域,而这个内存区域的值还是不变。所以,swap这个函数等于啥都没干,完全是浪费表情...

那这个题目似乎看起来就是无解的,对嘛?(谁这么无聊搞一个无解的题目来浪费表情!!!)

换值,解题的曙光:

在准备放弃之前,我们发现了有一个解法似乎可以做:如果把地址0x1234和0x1265中的值1和2对换,a和b的值就变化了,对吧!

那我们就聚焦到用什么方法可以改变这个值呢?

如果Integer提供一个函数,叫做setIntValue(int value),那就万事大吉了。我们可以实现这样的代码:

public static void swap(Integer i1, Integer i2)

{

    // 第二种可能的实现

    int tmp = i1.getIntValue()

    i1.setIntValue(i2.getIntValue());

    i2.setIntValue(tmp);

}

于是,我们就去查阅java.lang.Integer的代码实现。可惜的是,他没有这个函数...我们的梦想、我们的曙光,就这样破灭了...

反射,又燃起新的曙光:

在我们快要绝望的时候,我们突然发现了这个东东:

/**

* The value of the {@code Integer}.

*

* @serial

*/

private final int value;

java的Integer实现,实际内部将整数值存放在一个叫int类型的value变量里。他虽然有get函数,但是却没有set函数。因为他是final的(不可修改)!

那怎么办呢?哦,我们差点忘了java里有一个神器:反射!我们可以用反射把取到这个变量,并赋值给他,对吧!

于是,我们写下了如上的代码。我们从Integer类里面,取出value这个属性,然后分别设置上对应的值。哈哈哈,这下总该完美了吧!run一把:

sad... 我们得到了这样的异常:私有的、final的成员是不准我们访问的!

看起来似乎真的没办法了。

老王的绝杀:

这时候,老王从口袋里掏出了以前存起来的绝杀武器:反射访问控制变量:

AccessibleObject.setAccessible(boolean flag)

Field这个类是从AccessibleObject集成下来的,而AccessibleObject提供了一个方法,叫做setAccessible,他能让我们改变对于属性的访问控制。


 

他会将override变量设置为我们想要的值,然后在Field类里面:

只要这个override的只被设置成true,我们就可以顺利调用set函数啦,于是,我们就简单改一下实现代码:

就只加了这一句话,我们就成功了!哈哈哈哈!!! 来看结果吧:

before : a = 1, b = 2

after  : a = 2, b = 2

等等等等, 好像a已经变了,但是b似乎还没变! 这是怎么搞的?同样的实现方法,a变了,b没变,完全说不通啊,难道java虚拟机出问题了?这个时候,心里真是一万头草泥马奔过...

看似只差一步,实际还有万里之遥:

那问题到底出在哪儿呢?那我们重头开始看看这段代码。

在函数的一开始,我们就定义了两个变量:Integer a = 1; Integer b = 2; 这里1和2是主类型,换句话说他们是int类型,而a和b是Integer类型。他们是等价的嘛?回答是:NO!!!

装箱

那如果类型不等价,为啥编译的时候不出错呢?这里就要谈到一个java编译器的一个特性:装箱。这个是个什么东东?

按道理说,我们给a赋值的时候,应该是这样写:Integer a = new Integer(1),这才是标准的写法,对吧。不过这样写多麻烦啊,于是,java编译器给大家做了一个方便的事儿,就是你可以Integer a = 1这样写,然后由编译器来帮你把剩下的东西补充完整(java编译器真是可爱,他还有很多其他的糖衣,以后有机会老王专门来介绍)。

那编译器给我们做了什么事情呢?难道是:

a = 1 === 编译 ===> a = new Integer(1) ?

老王最初也认为是这样的,不过后来发现,错了,他做的操作是:

a = 1 === 编译 ===> a = Integer.valueOf(1)

上面这个过程像不像把1这个int类型放入到Integer的箱子里呢?

这是怎么确认的呢?很简单,我们用javap来查看编译后的Swap.class代码即可:

看,我们的main函数第一行,定义Integer a = 1,实际上是做了 Integer a = Integer.valueOf(1)。这个确实是让人出乎意料。那这个函数做了什么事情呢?

这个函数的参数是一个int,然后如果这个int在IntegerCache的low和high之间,就从IntegerCache里面获取,只有超出这个范围,才新建一个Integer类型。

这是IntegerCache的实现,默认在-128和127之间的数,一开始就被新建了,所以他们只有一个实例。老王画了下面的示意图(为了让大家看的清楚,没有画完所有的内存)


 

我们可以这样来验证:

Integer i1 = 1;

Integer i2 = 1;

      

Integer i3 = 128;

Integer i4 = 128;

      

System.out.println(i1 == i2);

System.out.println(i3 == i4);

大家猜到答案了么? 结果是:true, false

因为Integer i1 = 1; 实际是Integer i1 = Integer.valueOf(1),在cache里,我们找到了1对应的对象地址,然后就直接返回了;同理,i2也是cache里找到后直接返回的。这样,他们就有相同的地址,因而双等号的地址比较就是相同的。i3和i4则不在cache里,因此他们分别新建了两个对象,所以地址不同。

好了,做了这个铺垫以后,我们再回到最初的问题,看看swap函数的实现。

这个函数的入参:i1和i2分别指向a和b对应的内存地址,这个时候,将i1的值(也就是value)传递给int型的tmp,则tmp的值为整数值1,然后我们想把i2的整数值2设置给i1:f.set(i1, i2.intValue()); 这个地方看起来很正常吧?

我们来看看这个函数的原型吧:public void set(Objectobj, Object value) 他需要的传入参数是两个Object,而我们传入的是什么呢? Integer的i1,和int的i2.intValue()。对于第一个参数,是完全没问题的;而第二个参数,编译器又给我们做了一次装箱,最终转化出来的代码就像这样:

i1.value = Integer.valueOf(i2.intValue()).intValue();

那我们手动执行一下,

a、i2.intValue() -> 2

b、Integer.valueOf(2) -> 0x1265

c、0x1265.intValue() -> 2

d、i1.value -> 2

所以这个时候,内存里的数据就是这样的了:0x1234被改成2了!!!


 

接着,我们执行下一句:f.set(i2, tmp); 按照上面的步骤,我们先展开:

i2.value = Integer.valueOf(tmp).intValue();

这里tmp等于1,于是分步执行如下:

a、Integer.valueOf(1) -> 0x1234

b、0x1234.intValue() -> 2

c、i2.value -> 2

注意步骤b的值就是上一步从1改成2的那个值,因此最终内存的值就是:


 

所以,我们才看到最后a和b输出的都是2。终于终于,我们分析清楚了结果了~~

那要达到最后我们要求的交换,怎么样修改呢?我们有两种方法

1、不要让Integer.valueOf装箱发挥作用,避免使用cache,因此可以这样写:

我们用new Integer代替了Integer.valueOf的自动装箱,这样tmp就分配到了一个不同的地址;

2、我们使用setInt函数代替set函数,这样,需要传入的就是int型,而不是Integer,就不会发生自动装箱



so...问题解决了!

==== 总结的分割线 ====

看看,就是这么简单的一个代码实现,却隐藏了这么不简单的实现,包含了:

1、函数调用的值传递;

2、对象引用的值乃是内存地址;

3、反射的可访问性;

4、java编译器的自动装箱;

5、Integer装箱的对象缓存。

原文地址:https://www.cnblogs.com/dreamroute/p/5640666.html