15 | Java语法糖与Java编译器
下载APP
关闭
渠道合作
推荐作者
15 | Java语法糖与Java编译器
2018-08-24 郑雨迪 来自北京
《深入拆解Java虚拟机》
课程介绍
讲述:郑雨迪
时长09:59大小4.58M
在前面的篇章中,我们多次提到了 Java 语法和 Java 字节码的差异之处。这些差异之处都是通过 Java 编译器来协调的。今天我们便来列举一下 Java 编译器的协调工作。
自动装箱与自动拆箱
首先要提到的便是 Java 的自动装箱(auto-boxing)和自动拆箱(auto-unboxing)。
我们知道,Java 语言拥有 8 个基本类型,每个基本类型都有对应的包装(wrapper)类型。
之所以需要包装类型,是因为许多 Java 核心类库的 API 都是面向对象的。举个例子,Java 核心类库中的容器类,就只支持引用类型。
当需要一个能够存储数值的容器类时,我们往往定义一个存储包装类对象的容器。
对于基本类型的数值来说,我们需要先将其转换为对应的包装类,再存入容器之中。在 Java 程序中,这个转换可以是显式,也可以是隐式的,后者正是 Java 中的自动装箱。
以上图中的 Java 代码为例。我构造了一个 Integer 类型的 ArrayList,并且向其中添加一个 int 值 0。然后,我会获取该 ArrayList 的第 0 个元素,并作为 int 值返回给调用者。这段代码对应的 Java 字节码如下所示:
当向泛型参数为 Integer 的 ArrayList 添加 int 值时,便需要用到自动装箱了。在上面字节码偏移量为 10 的指令中,我们调用了 Integer.valueOf 方法,将 int 类型的值转换为 Integer 类型,再存储至容器类中。
这是 Integer.valueOf 的源代码。可以看到,当请求的 int 值在某个范围内时,我们会返回缓存了的 Integer 对象;而当所请求的 int 值在范围之外时,我们则会新建一个 Integer 对象。
在介绍反射的那一篇中,我曾经提到参数 java.lang.Integer.IntegerCache.high。这个参数将影响这里面的 IntegerCache.high。
也就是说,我们可以通过配置该参数,扩大 Integer 缓存的范围。Java 虚拟机参数 -XX:+AggressiveOpts 也会将 IntegerCache.high 调整至 20000。
奇怪的是,Java 并不支持对 IntegerCache.low 的更改,也就是说,对于小于 -128 的整数,我们无法直接使用由 Java 核心类库所缓存的 Integer 对象。
当从泛型参数为 Integer 的 ArrayList 取出元素时,我们得到的实际上也是 Integer 对象。如果应用程序期待的是一个 int 值,那么就会发生自动拆箱。
在我们的例子中,自动拆箱对应的是字节码偏移量为 25 的指令。该指令将调用 Integer.intValue 方法。这是一个实例方法,直接返回 Integer 对象所存储的 int 值。
泛型与类型擦除
你可能已经留意到了,在前面例子生成的字节码中,往 ArrayList 中添加元素的 add 方法,所接受的参数类型是 Object;而从 ArrayList 中获取元素的 get 方法,其返回类型同样也是 Object。
前者还好,但是对于后者,在字节码中我们需要进行向下转换,将所返回的 Object 强制转换为 Integer,方能进行接下来的自动拆箱。
之所以会出现这种情况,是因为 Java 泛型的类型擦除。这是个什么概念呢?简单地说,那便是 Java 程序里的泛型信息,在 Java 虚拟机里全部都丢失了。这么做主要是为了兼容引入泛型之前的代码。
当然,并不是每一个泛型参数被擦除类型后都会变成 Object 类。对于限定了继承类的泛型参数,经过类型擦除后,所有的泛型参数都将变成所限定的继承类。也就是说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的类。
举个例子,在上面这段 Java 代码中,我定义了一个 T extends Number 的泛型参数。它所对应的字节码如下所示。可以看到,foo 方法的方法描述符所接收参数的类型以及返回类型都为 Number。方法描述符是 Java 虚拟机识别方法调用的目标方法的关键。
不过,字节码中仍存在泛型参数的信息,如方法声明里的 T foo(T),以及方法签名(Signature)中的“(TT;)TT;”。这类信息主要由 Java 编译器在编译他类时使用。
既然泛型会被类型擦除,那么我们还有必要用它吗?
我认为是有必要的。Java 编译器可以根据泛型参数判断程序中的语法是否正确。举例来说,尽管经过类型擦除后,ArrayList.add 方法所接收的参数是 Object 类型,但是往泛型参数为 Integer 类型的 ArrayList 中添加字符串对象,Java 编译器是会报错的。
桥接方法
泛型的类型擦除带来了不少问题。其中一个便是方法重写。在第四篇的课后实践中,我留了这么一段代码:
VIPOnlyMerchant 中的 actionPrice 方法是符合 Java 语言的方法重写的,毕竟都使用 @Override 来注解了。然而,经过类型擦除后,父类的方法描述符为 (LCustomer;)D,而子类的方法描述符为 (LVIP;)D。这显然不符合 Java 虚拟机关于方法重写的定义。
为了保证编译而成的 Java 字节码能够保留重写的语义,Java 编译器额外添加了一个桥接方法。该桥接方法在字节码层面重写了父类的方法,并将调用子类的方法。
在我们的例子中,VIPOnlyMerchant 类将包含一个桥接方法 actionPrice(Customer),它重写了父类的同名同方法描述符的方法。该桥接方法将传入的 Customer 参数强制转换为 VIP 类型,再调用原本的 actionPrice(VIP) 方法。
当一个声明类型为 Merchant,实际类型为 VIPOnlyMerchant 的对象,调用 actionPrice 方法时,字节码里的符号引用指向的是 Merchant.actionPrice(Customer) 方法。Java 虚拟机将动态绑定至 VIPOnlyMerchant 类的桥接方法之中,并且调用其 actionPrice(VIP) 方法。
需要注意的是,在 javap 的输出中,该桥接方法的访问标识符除了代表桥接方法的 ACC_BRIDGE 之外,还有 ACC_SYNTHETIC。它表示该方法对于 Java 源代码来说是不可见的。当你尝试通过传入一个声明类型为 Customer 的对象作为参数,调用 VIPOnlyMerchant 类的 actionPrice 方法时,Java 编译器会报错,并且提示参数类型不匹配。
当然,如果你实在想要调用这个桥接方法,那么你可以选择使用反射机制。
除了前面介绍的泛型重写会生成桥接方法之外,如果子类定义了一个与父类参数类型相同的方法,其返回类型为父类方法返回类型的子类,那么 Java 编译器也会为其生成桥接方法。
我之前曾提到过,class 文件里允许出现两个同名、同参数类型但是不同返回类型的方法。这里的原方法和桥接方法便是其中一个例子。由于该桥接方法同样标注了 ACC_SYNTHETIC,因此,当在 Java 程序中调用 NaiveMerchant.actionPrice 时,我们只会调用到原方法。
其他语法糖
在前面的篇章中,我已经介绍过了变长参数、try-with-resources 以及在同一 catch 代码块中捕获多种异常等语法糖。下面我将列举另外两个常见的语法糖。
foreach 循环允许 Java 程序在 for 循环里遍历数组或者 Iterable 对象。对于数组来说,foreach 循环将从 0 开始逐一访问数组中的元素,直至数组的末尾。其等价的代码如下面所示:
对于 Iterable 对象来说,foreach 循环将调用其 iterator 方法,并且用它的 hasNext 以及 next 方法来遍历该 Iterable 对象中的元素。其等价的代码如下面所示:
字符串 switch 编译而成的字节码看起来非常复杂,但实际上就是一个哈希桶。由于每个 case 所截获的字符串都是常量值,因此,Java 编译器会将原来的字符串 switch 转换为 int 值 switch,比较所输入的字符串的哈希值。
由于字符串哈希值很容易发生碰撞,因此,我们还需要用 String.equals 逐个比较相同哈希值的字符串。
如果你感兴趣的话,可以自己利用 javap 分析字符串 switch 编译而成的字节码。
总结与实践
今天我主要介绍了 Java 编译器对几个语法糖的处理。
基本类型和其包装类型之间的自动转换,也就是自动装箱、自动拆箱,是通过加入[Wrapper].valueOf(如 Integer.valueOf)以及[Wrapper].[primitive]Value(如 Integer.intValue)方法调用来实现的。
Java 程序中的泛型信息会被擦除。具体来说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的具体类。
由于 Java 语义与 Java 字节码中关于重写的定义并不一致,因此 Java 编译器会生成桥接方法作为适配器。此外,我还介绍了 foreach 循环以及字符串 switch 的编译。
今天的实践环节,你可以探索一下 Java 10 的 var 关键字,是否保存了泛型信息?是否支持自动装拆箱?
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 11
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
14 | Java虚拟机是怎么实现synchronized的?
下一篇
16 | 即时编译(上)
精选留言(20)
- 钱2018-08-25本节还是比较容易理解的,也搞清楚了泛型相关的疑惑点,非常感谢。 小结如下: 1:Java语法糖-是一种帮助开发人员提高开发效率的小甜点,原理是将一些繁琐的事情交给编译器来处理,开发人员少做一些事情,当然,本纸上这些事情还必须要做,只是有编译器来做了 2:Java语法糖有那几种呢?如下所示: 2-1:包装类型和基本类型间的转换,自动装箱和拆箱的设计 2-2:泛型的设计 2-3:变长参数的设计 2-4:try-with-resources,关闭资源的设计 2-5:在同一个catch代码块中捕获多种异常 2-6:finally代码块总是被执行的设计 2-7:foreach循环数组的设计 2-8:foreach循环Iterable对象的设计 3:编译器的具体实现细节不是很清楚,猜测是识别出对应的语法然后填充上对应的代码,将语法糖还原成其本质-一些重复繁琐的代码块 4:之前有同事问我泛型是怎么实现的? 我讲不出来,只晓得使用泛型后,不需要写类型强转的代码了,如果类型不对也会有提示且编译失败,现在知道的多一点了,本质上类型强转的工作还是必须要做的,只是不是有开发人员来做了,由编译器来做,并且编译器会擦除掉对应的泛型信息,使用合适的父类型来代替,可能是Object类也可能是声明泛型时指定的继承的类展开
作者回复: 赞
55 - 永烁星光2018-08-25直到这节课逐渐感知到了学习jvm的妙处,我想将这专栏反复看和实践终能消化为自己的知识18
- ^_^2018-11-13C++ 是真泛型,java 较之算是伪泛型
作者回复: 确实
共 3 条评论9 - Shine2018-08-25每次看到示例代码的java字节码就犯懵,觉得很复杂,是不是有必要去了解下字节码
作者回复: 字节码其实不难的。我会专门写一篇来介绍一下。
5 - 曲东方2018-08-26var保存了泛型信息 var定义变量必须直接初始化,基于初始化的值做类型推导,javac编译期间的语法糖 所以不能声明函数的参数为var类型 foreach语法糖,对于实现了迭代器Iterable<T>接口的类型,使用迭代器方法; foreach对于数组和变长参数的处理方式与上述略有不同,先求数组长度,再做类似while循环遍历展开共 1 条评论4
- 剑八2020-06-13java设计也是分内核与外延的设计 内核是整个加载器,执行器,编绎器,堆栈这些是相对稳定的 而语法糖是为了提升开发效率,相当于是多变的外延。 本质上这个语法糖是由编绎器帮开发人员做了转换的工作。 泛型在编绎器转换后会变成字节码层页的object,或者如果泛型有继承某个特定父类则在字节码层面就是这个限定类,最终在存取的时候会做强制转换。展开2
- WL2018-12-14invokestatic Double.valueOf:(D)Ljava/lang/Double; 想请教一下老师这个字节码中的(D)和java前的L的作用是标记什么, 查了半天都没查到, 希望老师回答一下.共 3 条评论2
- 任鹏斌2018-08-24有点落后刚升级到jdk8对10还一无所知
作者回复: Java新版本的语法糖并不多,我印象中10也只有var
2 - 李二木2018-08-24从实现上说可以设计一个int类型的list,而jdk中arrayList是object类型,这样做是不是为了通用型考虑呢?
作者回复: 对的
共 2 条评论2 - 随心而至2019-10-16看老师的课,最好就在电脑边,边看边实践。1
- 随心而至2019-10-16赞,这节课的内容即便全忘了,也完全可以自己跑自己命令,看下字节码文件,知道到底是怎么回事。这就是授人以渔,谢谢老师。1
- angel😇txy🤓2022-08-30 来自上海ava 程序中的泛型信息会被擦除。具体来说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的具体类,具体来说: 若泛型类型没有指定具体类型,用Object作为原始类型; 若有限定类型< T exnteds XClass >,使用XClass作为原始类型; 若有多个限定< T exnteds XClass1 & XClass2 >,使用第一个边界类型XClass1作为原始类型;展开
- 靠人品去赢2020-07-29var应该是保留了泛型,支不支持自动不太确定,但是那个后面的代码add添加的操作是不行的。共 1 条评论
- 有米2020-04-08感觉java越来越不严谨了。。。还是喜欢 int i=0; 不要来var i=0;。。。。。
- test2020-02-22课后题可以编译成功,看了下字节码,var声明的list,add的入参是Object。
- 拯救地球好累2019-09-13大部分语法糖其实都是编译器为我们提供了一些便利,这些代码在编译后会变成一些基础代码 自动拆装箱:编译器会插入诸如Integer.valueof方法 泛型:编译器会根据继承关系做类型擦除
- Zhgdbut2019-05-22老师你好,我遇到了一个这种问题 Long a = null; Long rs = 1=1 ? a: 0L; 报了空指针异常的错误,1=1只是一个条件,表明rs一定等于a! 请问是咋回事呀?我该怎么修改?共 7 条评论
- 4032018-08-26相比来看,c#的泛型是真泛型
作者回复: ;)
- herome2018-08-25求老师画图啊
作者回复: 这一篇貌似没啥地方能够画图的吧?请问你具体对哪一块有疑问?
- 钱2018-08-25奇怪的是,Java 并不支持对 IntegerCache.low 的更改,也就是说,对于小于 -128 的整数,我们无法直接使用由 Java 核心类库所缓存的 Integer 对象。 这个奇怪的现象到底是为啥呢?
作者回复: JDK认为用户不需要缓存小于-128的整数。这当然有可能是错误的。