Java不为人知的10个真相

Published: 19 Nov 2014 Category: java

你是不是一开始就用Java来编程的呢?还记得当年它还被称为"Oak",OO还是热门的话题,C++的用户觉得Java没有前景,applets还只是个小玩意,菊花也还是一种花的时候吗?

我敢打赌下面至少有一半是你不清楚的。这周我们来看一下跟Java的内部实现相关的一些神奇的事情。

  1. 其实根本没有受检查异常这回事

没错!JVM压根儿就不知道有这个东西,它只存在于Java语言里。

如今大家都承认受检查异常就是个错误。正如Bruce Eckel最近在布拉格的的GeeCON会议上所说的,除了Java外没有别的语言会使用受检查异常这种东西,即使是Java 8的新的Streams API中也不再使用这一异常了(不然当你的lambda表达式中用到IO或者JDBC的话就得痛苦死了)。

如何能证实JVM确实不知道这个异常?看下下面这段代码:

public class Test {

    // No throws clause here
    public static void main(String[] args) {
        doThrow(new SQLException());
    }

    static void doThrow(Exception e) {
        Test.<RuntimeException> doThrow0(e);
    }

    @SuppressWarnings("unchecked")
    static <E extends Exception> 
    void doThrow0(Exception e) throws E {
        throw (E) e;
    }
}

这不仅能通过编译,而且也的确会抛出SQLException异常,并且完全不需要用到Lombok的@SneakyThrows注解。

更进一步的分析可以看下这篇文章,或者Stack Overflow上的这个问题

  1. 不同的返回类型也可以进行方法重载

这个应该是编译不了的吧?

class Test {
    Object x() { return "abc"; }
    String x() { return "123"; }
}

是的。Java语言并不允许同一个类中出现两个重写等价("override-equivalent")的方法,不管它们的throws子句和返回类型是不是不同的。

不过等等。看下Java文档中的Class.getMethod(String, Class...)是怎么说的。里面写道:

尽管Java语言不允许一个类中的多个相同签名的方法返回不同的类型,但是JVM并不禁止,所以一个类中可能会存在多个相同签名的方法。这添加了虚拟机的灵活性,可以用来实现许多语言特性。比如说,可以通过bridge方法来实现协变返回(covariant return,即虚方法可以返回子类而不一定得是基类),bridge方法和被重写的方法拥有相同的签名,但却返回不同的类型。 哇,这倒有点意思。事实上,下面这段代码就会触发这种情况:

abstract class Parent<T> {
    abstract T x();
}

class Child extends Parent<String> {
    @Override
    String x() { return "abc"; }
}

看一下Child类所生成的字节码:

// Method descriptor #15 ()Ljava/lang/String;
// Stack: 1, Locals: 1
java.lang.String x();
  ldc <String "abc"> [16]
  areturn
    Line numbers:
      [pc: 0, line: 7]
    Local variable table:
      [pc: 0, pc: 3] local: this index: 0 type: Child

// Method descriptor #18 ()Ljava/lang/Object;
// Stack: 1, Locals: 1
bridge synthetic java.lang.Object x();
  aload_0 [this]
  invokevirtual Child.x() : java.lang.String [19]
  areturn
    Line numbers:
      [pc: 0, line: 1

在字节码里T其实就是Object而已。这理解起来就容易多了。

synthetic bridge方法是由编译器生成的,因为在特定的调用点Parent.x()签名的返回类型应当是Object类型。如果使用了泛型却没有这个bridge方法的话,代码的二进制形式就无法兼容了。因此,修改JVM以支持这个特性貌似更容易一些(这顺便还实现了协变返回),看起来还挺不错 的吧?

你有深入了解过Java语言的规范和内部实现吗?这里有许多很有意思的东西。

  1. 它们都是二维数组!
class Test {
    int[][] a()  { return new int[0][]; }
    int[] b() [] { return new int[0][]; }
    int c() [][] { return new int[0][]; }
}

是的,没错。也许你无法马上说出上述方法的返回类型是什么,但它们的确都是一样的!同样的还有下面这段代码:

class Test {
    int[][] a = ;
    int[] b[] = ;
    int c[][] = ;
}

你是不是觉得这有点疯狂?相像一下如果再用上JSR-308/Java 8中的类型注解吧。各种写法数不胜数。

@Target(ElementType.TYPE_USE)
@interface Crazy {}
class Test {
    @Crazy int[][]  a1 = ;
    int @Crazy [][] a2 = ;
    int[] @Crazy [] a3 = ;

    @Crazy int[] b1[]  = ;
    int @Crazy [] b2[] = ;
    int[] b3 @Crazy [] = ;

    @Crazy int c1[][]  = ;
    int c2 @Crazy [][] = ;
    int c3[] @Crazy [] = ;
}

类型注解。神秘之极,强大之极。 或者这么说:

亲爱的同事,提交完这段代码下月我就要休假了:

image

这些写法到底有什么用,这个就留给你自己慢慢探索吧。

  1. 你根本就不懂条件表达式

那么,你以为条件表达式你就很了解了吗?我告诉你吧,你压根就不懂。很多人都认为下面两段代码是一样的:

Object o1 = true ? new Integer(1) : new Double(2.0);

它和下面这个是一样的吧?

Object o2;

if (true)
    o2 = new Integer(1);
else
    o2 = new Double(2.0);

不是的。我们来测试下。

System.out.println(o1);
System.out.println(o2);

这段代码会输出:

.0

没错,条件操作符在"必要"的时候会进行数值类型的提升,这个“必要”得加上一个重重的引号。你能想到下面这段程序居然会抛出一个空指针异常吗?

Integer i = new Integer(1);
if (i.equals(1))
    i = null;
Double d = new Double(2.0);
Object o = true ? i : d; // NullPointerException!
System.out.println(o);

想了解更多请参考这里

  1. 你也不懂复合赋值操作符

很奇怪吧?我们再来看下这两段代码:

i += j;
i = i + j;

显然他们都是一样的嘛。不过,其实不然。Java语言规范中是这么说的:

E1 op= E2形式的复合赋值表达式等价于E = (T)((E1) op (E2)),这里的T的类型是E1,E1仅会进行一次求值。

这太奇妙了。我想引用一下Peter Lawre在Stack Overflow上面的一个回答:

这种类型的类型转换通过*=或者/=可以很容易地说明:

byte b = 10;
b *= 5.7;
System.out.println(b); // prints 57

或者

byte b = 100;
b /= 2.5;
System.out.println(b); // prints 40

或者

char ch = '0';
ch *= 1.1;
System.out.println(ch); // prints '4'

又或者:

char ch = 'A';
ch *= 1.5;
System.out.println(ch); // prints 'a'

那么这么做到底有什么用?你可以在你的程序里试一下char类型的类型转换和乘法操作。因为你懂的,这够装逼。。

  1. 随机整数

这更像是一个脑筋急转弯。先不要着急看答案。看你能不能自己搞定。当运行如下这段程序时:

for (int i = 0; i < 10; i++) {
  System.out.println((Integer) i);
}

“偶尔”它会输出这样的结果:

这特么可能么?

. . . . . . . . .

要剧透了,答案就要来了。。

. . . . . . . .

好吧,答案在这里,你得通过反射重写掉JDK的Integer里面的缓存,然后再使用自动装箱和拆箱的功能。不要真的这么做!换句话说,我们再强调一次:

亲爱的同事,提交完这段代码,我下月就休假了哦(你看不懂可别怪我):

image

  1. GOTO

这是我最津津乐道的。Java其实也有GOTO!敲下代码看看

int goto = 1;

它的输出是

Test.java:44: error: <identifier> expected
    int goto = 1;

这是因为goto是一个预留关键字,万一以后有用呢。。。

不过这还不是重点。有意思的是你可以通过break,continue以及标签块来实现goto的功能:

跳转到前面:

label: {
  // do stuff
  if (check) break label;
  // do more stuff
}

它的字节码是:

  iload_1 [check]
  ifeq 6          // Jumping forward
  ..

跳转到后面:

label: do {
  // do stuff
  if (check) continue label;
  // do more stuff
  break label;
} while(true);

它的字节码是:

  iload_1 [check]
  ifeq 9
  goto 2          // Jumping backward
  ..
  1. Java也有类型别名

在其它语言中(比如说Ceylon),定义类型别名非常简单:

interface People => Set<Person>;

这样所定义出来的People类型可以和Set交替使用:

People?      p1 = null;
Set<Person>? p2 = p1;
People?      p3 = p2;

Java中无法在最外层实现类型别名。不过我们可以在类或者方法的作用域内来实现这点。假设我们现在觉得Integer, Long的名字看着不爽了,希望能更简短些:I以及L。这很简单:

class Test<I extends Integer> {
    <L extends Long> void x(I i, L l) {
        System.out.println(
            i.intValue() + ", " +
            l.longValue()
        );
    }
}

上面这段程序中,在Test类的作用域内,Integer的别名是I,而在x方法内,Long的是L。我们可以这样来调用上面的方法:

new Test().x(1, 2L);

这当然不是什么正式的别名的方式。在本例中,Integer和Long都是final类型,也就是说类型I和L就是真正意义上的别名(算是吧。因为它只有一种赋值相容(assignment-compatibility)的方式)。如果你使用的是非final类型时(比如说Object),那你用的其实就是泛型而已了。

这种雕虫小技就先说到这吧。现在我们来讲点有意义的!

  1. 某些类型关系是无法确定的

好吧,这个就有点费脑了,先喝杯咖啡集中下精神吧。假设有下面两种类型:

// A helper type. You could also just use List
interface Type<T> {}

class C implements Type<Type<? super C>> {}
class D<P> implements Type<Type<? super D<D<P>>>> {}

那么,C类型和D类型到底是什么?

这看起来是有点像递归了,java.lang.Enum看起来也是类似的递归(尽管略有不同)。看下它的定义:

public abstract class Enum<E extends Enum<E>> { ... }

知道了上面这个规范之后,我们就明白了,枚举的实现其实不过是个语法糖罢了:

// This
enum MyEnum {}

// Is really just sugar for this
class MyEnum extends Enum<MyEnum> { ... }

记住这点,我们再回来看一下这两个类型。下面这段代码能通过编译吗?

class Test {
    Type<? super C> c = new C();
    Type<? super D<Byte>> d = new D<Byte>();
}

这个问题有点难,不过Ross Tate曾经回答过它。这个问题其实是无解的:

C是Type<? super C>的子类吗?

Step 0) C Step 1) Type> 还有

D是Type<? super D>的子类型吗?

Step 0) D > Step 1) Type>>> > Step 2) D >> Step 3) List>> > Step 4) D> >> Step . . . (expand forever)

试试在你的Eclispe里编译一下,这会让它崩溃的!(别担心,这个BUG我已经提交了)

要想彻底搞清楚这点。。。

Java中的某些类型是不确定的 如果你对Java的这个罕见的奇怪行为感兴趣,可以读下Ross Tate的这篇论文“如何驾驭Java类型系统中的通配符”(与Alan Leung及Sorin Lerner共同发表),或者看下我们的几点愚见“论子类型多态与泛型多态的关联关系”

  1. 类型交集

Java有一个非常古怪的特性叫做类型交集(虽然乍看上去有点像并集)。你可以声明一个泛型,它其实是两个类型的交集。比如说:

class Test<T extends Serializable & Cloneable> {
}

绑定到Test类中的这个泛型参数T必须同时实现Serializable接口以及Cloneable接口。比方说,String就没有同时实现这两个接口,但Date是:

// Doesn't compile
Test<String> s = null;

// Compiles
Test<Date> d = null;

Java 8中也用到了这一特性,你可以将某个类型转换成两个类型的交集。这有什么用?好像是没啥用,但是如果你要把一个lambda表达式强转成这样一个类型的话,除此之外就别无它法了。假设你的方法里面有这么一个看似疯狂的约束条件:

<T extends Runnable & Serializable> void execute(T t) {}

你希望Runnable对象同时还实现了Serializable接口,这样你既可以去执行它,也可以将它发送出去。Lambda表达式和序列化?这看上去有点基情。

Lambda表达式其实是可以进行序列化的:

如果Lambda表达式的目标类型和参数都是可序列化的,那么它也是可序列化的。

尽管这是可以实现的,但它并没有直接实现Serializable这个接口。为了能适配成这个类型,你必须得进行类型转换。但是如果你只转成Serializable了的话:

execute((Serializable) (() -> {}));

那么这个Lambda表达式就不是一个Runnable类型了。

那么,

就只能转成两个类型了:

execute((Runnable & Serializable) (() -> {}));

英文原文链接