Java自动拆装箱为什么不起作用了

背景

有Java基础的同学都知道Java中有Primitive Type(原始类型),比如int、short。作为面向对象的语言,Java同时提供了每个原始类型的包装类型(本质是引用类型Reference Type),比如Integer、Long、Boolean.

为了方便大家写代码,JDK 5以后引入了自动拆装箱的机制. 比如对于函数:

add(Integer a)

我们在调用的时候,传一个Integer对象并不是必须的,有时直接传一个原始类型即可:

//int型变量直接传
int i  = 1;
add(i);

//或者数字字面量,本身也是int型
add(5);

Java会自动将int装换成Integer,这个过程称为装箱,由于是Java自动做的,所以叫自动装箱(autoboxing),反之如果是将Integer自动装换成int,则称为自动拆箱(autounboxing)。有了自动拆装箱,平时在写代码的时候很Happy,瞬间觉得世界真美好~

意外

然而,事情并不总是很顺利,比如我们有时会遇到这种场景(演示实例来自同事琛总):

//A.java有类A,A调了B的方法add(int i),这时传的是个原始类型, 完美匹配
class A {
    public static void main(String[] args) {
        B.add(1);
    }
}

//B.java
class B {   
    public static void add(int i) {
        System.out.println(i);
    }
}

执行命令

javac A.java  //这时会同时生成A.class和B.class
java A //运行成功

然后我们做一件事,把B稍作修改,让B的add方法接受Integer包装类型

//B.java
class B {   
    public static void add(Integer i) { // 这里把int i 改成 Integer i后重新编译
        System.out.println(i);
    }
}

接着重新编译B.java文件,注意:只重新编译B.java,相当于B类做了升级,而调用方A并不做任何改变,A.class也不重新生成。然后,我们执行java A命令运行,结果却并没有像我们想象中的那样,而是报了如下错误

Exception in thread "main" java.lang.NoSuchMethodError: B.add(I)V
    at A.main(A.java:4)

说好的自动拆装箱呢

NoSuchMethodError的错误报出来的时候,一脸的黑人问号:不是有个add(Integer i)方法吗?怎么会说找不到方法?说好的自动拆装箱呢?

说好的自动拆装箱呢

肯定是哪里出了问题!带着问题搜到了知乎R大的一个关于Java自动拆装箱的回答

RednaxelaFX一语道破真谛

根据R大的解释,Java的自动拆装箱发生在编译期,即javac编译的那一刻,而不是在运行期!笔者的潜意识里认为,自动拆装箱会发生在运行期,所以会觉得NoSuchMethodError的错误简直不可思议。

如果编译器发现需要自动拆装箱,会用语法糖的方法自动给你加上Integer.valueOf(),即将A类里面的1变成Integer.valueOf(1),然后生成在A.class文件里。但是我们编译A文件的时候,B的add方法接受的是int型,所以A.class文件里并没有Integer.valueOf(1)这一步,A.class文件里要调用的还是add(int i)。

后来,我们把B文件的add(int i)方法变成了add(Integer i), 本质上相当于删除了一个旧方法,添加了一个全新的方法,这个时候A.class还是老的样子,一旦运行java A,java虚拟机就去找B中的add(int i)方法,然而它已经找不到这个方法了,因为已经被删除了,只留下了add(Integer i)方法,所以会报NoSuchMethodError.

继续扒开自动拆装箱的底裤

我们执行以下命令来查看A.class的具体信息

javap -verbose A.class

当B类的方法为add(int i)时,A.class的信息如下:

class A
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#13         // java/lang/Object."<init>":()V
   #2 = Methodref          #14.#15        // B.add:(I)V
   #3 = Class              #16            // A
   #4 = Class              #17            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               main
  #10 = Utf8               ([Ljava/lang/String;)V
  #11 = Utf8               SourceFile
  #12 = Utf8               A.java
  #13 = NameAndType        #5:#6          // "<init>":()V
  #14 = Class              #18            // B
  #15 = NameAndType        #19:#20        // add:(I)V
  #16 = Utf8               A
  #17 = Utf8               java/lang/Object
  #18 = Utf8               B
  #19 = Utf8               add
  #20 = Utf8               (I)V
{
  A();
    descriptor: ()V
    flags:
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: iconst_1
         1: invokestatic  #2                  // Method B.add:(I)V
         4: return
      LineNumberTable:
        line 4: 0
        line 5: 4
}
SourceFile: "A.java"

重点关注下1: invokestatic #2这段,即对应A.java中的add(1)要调用add方法的逻辑,#2指向常量池#2 = Methodref #14.#15, 然后把 #14.#15继续展开,即 #18.#19:#20 , 最后展开的样子其实就是注释的样子 B.add:(I)V,这说明到了汇编这一层,运行期找的就是add(int i)方法。

然后,如果我们把B类的方法改为add(Integer i)时,重新编译后的A.class的信息如下:

class A
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#14         // java/lang/Object."<init>":()V
   #2 = Methodref          #15.#16        // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
   #3 = Methodref          #17.#18        // B.add:(Ljava/lang/Integer;)V
   #4 = Class              #19            // A
   #5 = Class              #20            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               main
  #11 = Utf8               ([Ljava/lang/String;)V
  #12 = Utf8               SourceFile
  #13 = Utf8               A.java
  #14 = NameAndType        #6:#7          // "<init>":()V
  #15 = Class              #21            // java/lang/Integer
  #16 = NameAndType        #22:#23        // valueOf:(I)Ljava/lang/Integer;
  #17 = Class              #24            // B
  #18 = NameAndType        #25:#26        // add:(Ljava/lang/Integer;)V
  #19 = Utf8               A
  #20 = Utf8               java/lang/Object
  #21 = Utf8               java/lang/Integer
  #22 = Utf8               valueOf
  #23 = Utf8               (I)Ljava/lang/Integer;
  #24 = Utf8               B
  #25 = Utf8               add
  #26 = Utf8               (Ljava/lang/Integer;)V
{
  A();
    descriptor: ()V
    flags:
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: iconst_1
         1: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         4: invokestatic  #3                  // Method B.add:(Ljava/lang/Integer;)V
         7: return
      LineNumberTable:
        line 4: 0
        line 5: 7
}
SourceFile: "A.java"

重点在这里

     0: iconst_1
     1: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
     4: invokestatic  #3                  // Method B.add:(Ljava/lang/Integer;)V
     7: return

明显多了一行invokestatic #2, 而这一行显然就是Integer.valueOf(1)的过程,即自动装箱的过程,也就是编译器自动帮我们加上的一段代码,之后4: invokestatic #3才去调用B类的add(Integer i) 方法。

所以,我们潜意识里以为会在运行期执行的拆装箱的过程,其实在编译期就做好了;在运行期JVM会严格按照class文件中的执行过程来寻找相应的匹配方法,而add(int i)和add(Integer i)方法显然不是同一个方法,当然会报NoSuchMethodError.

聊聊代码兼容问题

以上案例是我们刻意设计出来的,其实在真实的场景中是会碰到这种问题的。比如我们在自己的项目中引用了两个不同项目jar1 和jar2,而jar1同时引用了jar2的方法,如果jar2把方法add(int i)改为add(Integer i),而我们引用的jar1还是老的,这个时候我们项目再去调jar1里的方法,jar1再调新jar2的方法add(int),就会报NoSuchMethodError.

其实这类问题背后反映的是代码兼容性的问题,比如:

//B类version1
class B {
    public static void add(int i) {
        System.out.println(i);
    }
}

//B类version2
class B {
    public static void add(Integer i) {
        System.out.println(i);
    }
}

B类从version1 到 version2的升级,并不是一个兼容性的升级, add(int i)方法和 add(Integer i)不是同一个方法,version2的版本相当于删除了原来的方法,新加了一个方法,如果有历史jar包还在调用老的方法而且没有重新编译,而且JVM中加载的又是version2的B类,那么最终的结果一定是报错。

兼容性的升级是重载这个方法:

//B类version3
class B {
    public static void add(int i) {
        System.out.println(i);
    }
    public static void add(Integer i) {
        System.out.println(i);
    }
}

类似的代码兼容性问题还有很多,比如我们给别人提供的RPC方法中,显然是不能随便删除字段的,这个很容易理解,删除字段后,别人在线上跑的应用用的还是旧的API,他们获取不到想要的字段肯定是会出问题的。

而添加字段就是一个对兼容友好的升级行为,我们添加的字段,使用旧API的消费方虽然看不到新字段的存在,但是老字段依然还是可用的。比如服务端给客户端提供的JSON API,客户端只关心自己需要的字段,服务端添加字段上线,并不会影响老版本的客户端的使用,因为老版本客户端在做JSON反序列化的时候只根据字段名反序列化。

而服务端通信协议Thrift的反序列化和JSON又不一样,Thrift反序列化过程并不以是字段名为参考,而是和顺序强相关,比如对于Thrfit 的 struct类型:

//Person version1
struct Person{
 1:i32 id
 2:string name
}

//Person version2
struct Person{
 1:i32 id
 2:i32 age
 3:string name
}

//Person version3
struct Person{
 1:i32 id
 2:string name
 3:i32 age
}

如果服务提供方将Person升级到version2,那么对于还在使用version1 的消费者来说,Person实例请求回来要反序列化的时候,会把第二个age反序列化成name,显然这不是我们想要的结果,而对兼容友好的升级应该是version3那种,不影响前面字段的排序,在后面添加字段,这样就不会对老版本的API造成反序列的错乱。

软件行业兼容性的典型案例就是微软家的Windows操作系统,有人尝试过过把Windows 3.1的很多程序放到Windows XP上去安装使用,竟然发现还能正常运行,甚至很流畅,真是惊叹Windows对兼容性的执着!有人说,Windows几乎是业界兼容性做的最好的OS,而这一点也许是Windows在桌面市场领域能独占鳌头的重要原因之一。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容