Java虚拟机(十五)------编译期优化之Java语法糖

语法糖

语法糖(Syntactic Sugar),也称糖衣语法,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。 通常来说,使用语法糖能够增加程序的可读性,它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。

Java中最常用的语法糖主要是前面提到过的泛型(泛型并不一定都是语法糖实现,如C#的泛型就是直接由CLR支持的)、 变长参数、 自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖

1. 泛型与类型擦除

泛型是JDK 1.5的一项新增特性,它的本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。 这种参数类型可以用在类、 接口和方法的创建中,分别称为泛型类泛型接口泛型方法

在Java语言处于还没有出现泛型的版本时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。 例如,在哈希表的存取中,JDK 1.5之前使用HashMap的get()方法,返回值就是一个Object对象,由于Java语言里面所有的类型都继承于java.lang.Object,所以Object转型成任何对象都是有可能的。 但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个Object到底是个什么类型的对象。 在编译期间,编译器无法检查这个Object的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多ClassCastException的风险就会转嫁到程序运行期之中。

真实泛型和伪泛型

泛型技术在C#和Java之中的使用方式看似相同,但实现上却有着根本性的分歧。

  • C#里面泛型无论在程序源码中、 编译后的IL(中间语言,这时候泛型是一个占位符)中,或是运行期的CLR中,都是切实存在的,List与List就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

  • Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型),并且在相应的地方插入了强制转型代码,对于运行期的Java语言来说,ArrayList与ArrayList就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。

类型擦除

下面一段Java泛型的例子,我们可以看一下它编译后的结果是怎样的。

  • 泛型擦除前的代码
public static void main(String[] args){
    Map map=new HashMap();
    map.put("hello","你好"):
    map.put("how are you?","吃了没?");
    System.out.println(map.get("hello"));
    System.out.println(map.get("how are you?"));
}

将代码编译成Class文件,然后用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了原生类型。

  • 泛型擦除后的代码
public static void main(String[] args){
    Map map=new HashMap();
    map.put("hello","你好");
    map.put("how are you?","吃了没?");
    System.out.println((String)map.get("hello"));
    System.out.println((String)map.get("how are you?"));
}

为了更详细地说明类型擦除,再看如下代码:

  • 当泛型遇见重载1
import java.util.List;
public class FanxingTest{
    public void method(List list){
        System.out.println("List String");
    }
    public void method(List list){
        System.out.println("List Int");
    }
}

显然这段代码是不能被编译的,因为参数List和List编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样。 初步看来,无法重载的原因已经找到了,但真的就是如此吗只能说,泛型擦除成相同的原生类型只是无法重载的其中一部分原因,接着看下一段代码中的内容。

  • 当泛型遇见重载2
public class GenericTypes{
    public static String method(Listlist){
        System.out.println("invoke method(Listlist)");
        return "";
    }
    public static int method(Listlist){
        System.out.println("invoke method(Listlist)");
        return 1;
    }
    public static void main(String[] args){
        method(new ArrayList());
        method(new ArrayList());
    }
}

执行结果:

invoke method(Listlist)
invoke method(Listlist)

上面两段代码的差别是两个method方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行了。 这是对Java语言中返回值不参与重载选择的基本认知的挑战吗?

第二段代码的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个method()方法加入了不同的返回值后才能共存在一个Class文件之中。Class文件方法表(method_info)的数据结构中方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。 也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的。

2. 自动装箱、 拆箱与遍历循环

从纯技术的角度来讲,自动装箱、 自动拆箱与遍历循环(Foreach循环)这些语法糖,无论是实现上还是思想上都不能和上文介绍的泛型相比,两者的难度和深度都有很大差距,但是它们是Java语言里使用得最多的语法糖。

  • 自动装箱、 拆箱与遍历循环编译前代码:
public static void main(String[] args){
    Listlist=Arrays.asList(1,2,3,4);
    //如果在JDK 1.7中,还有另外一颗语法糖
    //能让上面这句代码进一步简写成List list=[1,2,3,4];
    int sum=0;
    for(int i:list){
        sum+=i;
    }
    System.out.println(sum);
}
  • 自动装箱、 拆箱与遍历循环编译后代码:
public static void main(String[] args){
    List list=Arrays.asList(new Integer[]{
        Integer.valueOf(1);
        Integer.valueOf(2);
        Integer.valueOf(3);
        Integer.valueOf(4)
    });
    int sum=0;
    for(Iterator localIterator=list.iterator();localIterator.hasNext();){
        int i=((Integer)localIterator.next()).intValue();
        sum+=i;
    }
    System.out.println(sum);
}

上面代码中一共包含了泛型、 自动装箱、 自动拆箱、 遍历循环与变长参数5种语法糖,泛型就不必说了,自动装箱、 拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。 最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,就是使用数组来完成类似功能的。

这些语法糖虽然看起来很简单,但也不见得就没有任何值得我们注意的地方,下面的代码演示了自动装箱的一些错误用法:

  • 自动装箱的陷阱
public static void main(String[] args){
    Integer a=1;
    Integer b=2;
    Integer c=3;
    Integer d=3;
    Integer e=321;
    Integer f=321;
    Long g=3L;
    System.out.println(c==d);//true
    System.out.println(e==f);//false
    System.out.println(c==(a+b));//true
    System.out.println(c.equals(a+b));//true
    System.out.println(g==(a+b));//true
    System.out.println(g.equals(a+b));//false
}

反编译后的代码:

public static void main(String args[])
{
    Integer a = Integer.valueOf(1);
    Integer b = Integer.valueOf(2);
    Integer c = Integer.valueOf(3);
    Integer d = Integer.valueOf(3);
    Integer e = Integer.valueOf(321);//超过-128~127会重新创建一个对象
    Integer f = Integer.valueOf(321);
    Long g = Long.valueOf(3L);
    System.out.println(c == d);
    System.out.println(e == f);
    System.out.println(c.intValue() == a.intValue() + b.intValue());
    System.out.println(c.equals(Integer.valueOf(a.intValue() + b.intValue())));
    System.out.println(g.longValue() == (long)(a.intValue() + b.intValue()));
    System.out.println(g.equals(Integer.valueOf(a.intValue() + b.intValue())));
}

鉴于包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们equals()方法不处理数据转型的关系,建议在实际编码中尽量避免这样使用自动装箱与拆箱。

3. 条件编译

许多程序设计语言都提供了条件编译的途径,如C和C++中使用预处理器指示符(#ifdef)来完成条件编译。 C与C++的预处理器最初的任务是解决编译时的代码依赖关系(如非常常用的#include预处理命令),而在Java语言之中并没有使用预处理器,因为Java语言天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用预处理器。 那Java语言是否有办法实现条件编译呢?

Java语言当然也可以进行条件编译,方法就是使用条件为常量的if语句。 如下面条件编译的代码所示,其中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码之中只包括"System.out.println("block 1");"一条语句,并不会包含if语句及另外一个分子中的"System.out.println("block 2");"

public static void main(String[] args){
    if(true){
        System.out.println("block 1");
    }else{
        System.out.println("block 2");
    }
}

Class文件的反编译结果:

public static void main(String[] args){
    System.out.println("block 1");
}

只能使用条件为常量的if语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如下所示的代码就会被编译器拒绝编译。

public static void main(String[] args){
    //编译器将会提示“Unreachable code”
    while(false){
        System.out.println("");
    }
}

Java语言中条件编译也是一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower类中)完成。 由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。

当然除了本文介绍的泛型、 自动装箱、 自动拆箱、 遍历循环、 变长参数和条件编译之外,Java语言还有不少其他的语法糖,如内部类枚举类断言语句对枚举和字符串的switch支持(JDK 1.7后)、try语句中定义和关闭资源(JDK 1.7后)`等,通过跟踪Javac源码、 反编译Class文件等方式了解它们的本质实现。

你可能感兴趣的:(JVM)