Java 面试题问与答:编译时与运行时

在开发和设计软件时,我们需要明确区分编译时(Compile-time)运行时(Runtime)以及构建时(Build-time)这三个概念。理解这些概念有助于深入掌握语言底层原理。以下是初学者晋级中级水平需要掌握的核心问题。

Q1. 下面的代码片段中,行 A 和行 B 所标识的代码有什么区别?

public class ConstantFolding {
    static final int number1 = 5;
    static final int number2 = 6;
    static int number3 = 5;
    static int number4 = 6;

    public static void main(String[] args) {
        int product1 = number1 * number2; // line A
        int product2 = number3 * number4; // line B
    }
}

A. 在行 A 的代码中,product1 的值是在编译期计算的;而行 B 则是在运行时计算的。

如果你使用 Java 反编译器(例如 jd-gui)反编译 ConstantFolding.class 文件,会得到如下结果:

public class ConstantFolding {
    static final int number1 = 5;
    static final int number2 = 6;
    static int number3 = 5;
    static int number4 = 6;

    public static void main(String[] args) {
        int product1 = 30;
        int product2 = number3 * number4;
    }
}

常量折叠(Constant Folding)是一种 Java 编译器使用的优化技术。由于 final 变量的值不会改变,编译器可以直接计算其表达式结果并替换到代码中。Java 反编译器和 javap 命令都是查看编译后代码(字节码)的利器。

Q2. 除了代码优化外,在什么情况下查看编译过的代码是很有帮助的?

A. Java 里的泛型(Generics)是在编译时构造的。可以通过查看编译后的 .class 文件来理解泛型的实现机制,也可以借此排查泛型相关的问题(例如类型擦除带来的影响)。

Q3. 下面哪些操作是发生在编译时、运行时,或者两者都有?

1. 方法重载(Overloading)

发生在编译时。

方法重载也被称为编译时多态,因为编译器可以根据参数的类型来选择使用哪个方法。

public class Example {
    // method #1
    public static void evaluate(String param1) {}
    // method #2
    public static void evaluate(int param1) {}
}

如果编译器要编译下面的语句:

evaluate("My Test Argument passed to param1");

它会根据传入的参数是字符串常量,生成调用 #1 方法的字节码。

2. 方法覆盖(Overriding)

发生在运行时。

方法覆盖被称为运行时多态,因为在编译期编译器不知道(也没法知道)该去调用哪个具体实现。JVM 会在代码运行的时候根据对象的实际类型做出决定。

public class A {
    public int compute(int input) { // method #3
        return 3 * input;
    }
}

public class B extends A {
    @Override
    public int compute(int input) { // method #4
        return 4 * input;
    }
}

子类 B 中的 compute(..) 方法重写了父类的 compute(..) 方法。如果编译器遇到下面的代码:

public int evaluate(A reference, int arg2) {
    int result = reference.compute(arg2);
}

编译器无法知道传入的参数 reference 的实际类型是 A 还是 B。因此,只能够在运行时,根据赋给输入变量 reference 的对象的类型(例如 A 或者 B 的实例)来决定调用方法 #3 还是方法 #4

3. 泛型(又称类型检验)

发生在编译期。

编译器负责检查程序中类型的正确性,然后把使用了泛型的代码翻译或者重写成可以执行在当前 JVM 上的非泛型代码。这个技术被称为类型擦除(Type Erasure)。换句话说,编译器会擦除所有在尖括号里的类型信息,来保证和版本 1.4.0 或者更早版本的 JRE 的兼容性。

编译前:

List<String> myList = new ArrayList<String>(10);

编译后(擦除后):

List myList = new ArrayList(10);

4. 注解(Annotation)

你可以使用运行时或者编译时的注解。

public class B extends A {
    @Override
    public int compute(int input) { 
        return 4 * input;
    }
}

@Override 是一个简单的编译时注解,它可以用来捕获类似于在子类中把 toString() 写成 tostring() 这样的错误。在 Java 5 中,用户自定义的注解可以用注解处理工具(Annotation Process Tool —— APT)在编译时进行处理。到了 Java 6,这个功能已经是编译器的一部分了。

public class MyTest {
    @Test
    public void testEmptyness() {
        org.junit.Assert.assertTrue(getList().isEmpty());
    }
    private List getList() {
        return null;
    }
}

@Test 是 JUnit 框架用来在运行时通过反射来决定调用测试类的哪个(些)方法的注解。

@Test(timeout = 100)
public void testTimeout() { 
    while(true);
}

如果运行时间超过 100ms 的话,上面的测试用例就会失败。

@Test(expected = IndexOutOfBoundsException.class)
public void testOutOfBounds() { 
    new ArrayList<Object>().get(1);
}

如果上面的代码在运行时没有抛出 IndexOutOfBoundsException 或者抛出的是其他的异常的话,那么这个用例就会失败。用户自定义的注解可以在运行时通过 Java 反射 API 里新增的 AnnotatedElementAnnotation 元素接口来处理。

5. 异常(Exception)

你可以使用运行时异常或者编译时异常。

5.1 运行时异常(RuntimeException)

也称作未检查的异常(unchecked exception),这表示这种异常不需要编译器来检测。RuntimeException 是所有可以在运行时抛出的异常的父类。一个方法除了要捕获异常外,如果它执行的时候可能会抛出 RuntimeException 的子类,那么它就不需要用 throw 语句来声明抛出的异常。

例如:NullPointerException, ArrayIndexOutOfBoundsException 等。

5.2 受检查异常(Checked Exception)

都是编译器在编译时进行校验的,通过 throws 语句或者 try-catch 语句块来处理。编译器会分析哪些异常会在执行一个方法或者构造函数的时候抛出。

6. 面向切面的编程(Aspect Oriented Programming - AOP)

切面可以在编译时、运行时、加载时织入。

6.1 编译期

编译期织入是最简单的方式。如果你拥有应用的代码,你可以使用 AOP 编译器(例如 ajc – AspectJ 编译器)对源码进行编译,然后输出织入完成的 .class 文件。AOP 编译的过程包含了 weaver 的调用。切面的形式可以是源码的形式也可以是二进制的形式。如果切面需要针对受影响的类进行编译,那么你就需要在编译期织入了。

6.2 编译后

这种方式有时候也被称为二进制织入,它被用来织入已有的 .class 文件和 jar 文件。和编译时织入方式相同,用来织入的切面可以是源码也可以是二进制的形式,并且它们自己也可以被织入切面。

6.3 装载期

这种织入是一种二进制织入,它被延迟到 JVM 加载 .class 文件和定义类的时候。为了支持这种织入方式,需要显式地由运行时环境或者通过一种“织入代理(weaving agent)”来提供一个或者多个“织入类加载器(weaving class loader)”。

6.4 运行时

对已经加载到 JVM 里的类进行织入。

7. 其他分类

  • 继承 – 发生在编译时,因为它是静态的。
  • 代理或者组合 – 发生在运行时,因为它更加具有动态性和灵活性。

Q4. 你有没有听说过“组合优于继承”这样的说法呢?如果听说过的话,那么你是怎么理解的呢?

A. 继承是一种多态工具,而不是一种代码复用工具。有些开发者喜欢用继承的方式来实现代码复用,即使是在没有多态关系的情况下。是否使用继承的规则是:继承只能用在类之间有“父子”关系(is-a)的情况下。

  1. 不要仅仅为了代码复用而继承。 当你使用组合来实现代码复用的时候,是不会产生继承关系的。过度使用继承(通过 extends 关键字)的话,如果修改了父类,会损坏所有的子类。这是因为子类和父类的紧耦合关系是在编译期产生的。
  2. 不要仅仅为了多态而继承。 如果你的类之间没有继承关系,并且你想要实现多态,那么你可以通过接口和组合的方式来实现。这样不仅可以实现代码重用,同时也可以实现运行时的灵活性。

这就是为什么(Gang of Four)的设计模式里更倾向于使用组合而不是继承的原因。面试者会在你的答案里着重关注这几个词语——“耦合”,“静态还是动态”,以及“发生在编译期还是运行时”。运行时的灵活性可以通过组合来实现,因为类可以在运行时动态地根据一个结果有条件或者无条件地进行组合。但是继承却是静态的。

Q5. 你能够通过实例来区别编译期继承和运行时继承,以及指出 Java 支持哪种吗?

A. “继承”表示动作和属性从一个对象传递到另外一个对象的场景。

Java 语言本身只支持编译期继承,它是通过 extends 关键字来产生子类的方式实现的,如下所示:

public class Parent {
    public String saySomething() { 
        return "Parent is called";
    }
}

public class Child extends Parent {
    @Override
    public String saySomething() { 
        return super.saySomething() + ", Child is called";
    }
}

Child 类的 saySomething() 方法的调用会返回 "Parent is called, Child is called",因为子类的调用继承了父类的 "Parent is called"。关键字 super 是用来调用 Parent 类的方法。

运行时继承表示在运行时构建父/子类关系。Java 语言本身不支持运行时继承,但是有一种替代的方案叫做“代理”或者“组合”,它表示在运行时组件一个层次对象的子类。这样可以模拟运行时继承的实现。

在 Java 里,代理的典型实现方式如下:

public class Parent {
    public String saySomething() { 
        return "Parent is called";
    }
}

public class Child {
    public String saySomething() { 
        return new Parent().saySomething() + ", Child is called";
    }
}

子类代理了父类的调用。组合可以按照下面的方式来实现:

public class Child {
    private Parent parent = null;

    public Child() { 
        this.parent = new Parent();
    }

    public String saySomething() { 
        return this.parent.saySomething() + ", Child is called";
    }
}

说明: 本文部分概念(如注解处理工具 APT、泛型类型擦除细节)基于 Java 5/6 时期的特性描述,适用于 Java 5 及以上版本。现代 Java 版本在这些机制上保持兼容,但具体实现细节或工具链可能有所演进。