当一个常量并不是真正的常量时 
  
  
作者:Vladimir Roubtsov 
  
Q:在Java中使用“循环定义”(cyclic definitions)会产生什么负面作用? 
    注:循环定义(如a = b, b = c, c = a) 
  
A:通常,Java编译结果都是动态输出的:你可以只重新编译一个类,其余类将会自动获得这个改变。这是因为.class在进行类与类之间操作时采用动态链接的形式,并且链接只会在类加载的时候才被确定。 
  
在本文中,我将为大家讲述一个众所周知又非常有意义的异常,但它能产生很细微的并难以发现的错误。“循环定义”操作将导致后面程序中产生一些很难确定的异常。 
  
内嵌常量(Inlined static final constants) 
考虑以下两个例子 
  
public class Main implements InterfaceA 
{ 
    public static void main (String [] args) 
    { 
        System.out.println (A); 
    }  
  
} // End of class 
  
public interface InterfaceA 
{ 
    public static final int A = 1; 
  
} // End of interface 
  
依照Java规范,所有的static final域(field)在编译时不是将表达式作为其值初始化,而是先将表达式计算后使用表达式值进行初始化。换句话说,在运行时,类Main不会动态的去获取InterfaceA中A的值。而是直接使用“1”代替“A”编译进Main.main()中。你可以用下面的方法检查Javap dump,来证实这一点: 
  
Method void main(java.lang.String[]) 
   0 getstatic #23 <Field java.io.PrintStream out> 
   3 iconst_1 
   4 invokevirtual #29 <Method void println(int)> 
   7 return 
  
上面的iconst_1在调用System.out.println()之前指令将整数“1”推(Push)进JVM操作堆栈中。将这个值嵌入字节码中,而没有使用Interface.A。如果你重新编译InterfaceA.java,将A改为“2”,但不要重新编译Main.java,这时Main.main()输出的值仍和以前一样。 
  
任何一个有经验的Java程序员都知道以上这些内容。这只是Java在调用时的一些微不足道的特点。当我们使用一个可以仅根据源文件修改时间进行增量重编译的Java编译工具时需要特别注意这个特性。(见Note1)因为,这个特点有时会导致编译器编译出一个不理想的版本。 
  
它看上去像一个常量,但它不是 
注意,前文所示在某种情况下可能会产生不同的效果。例如,当用于初始化域的表达式只可以在运行时被求值的话,不会出现前文所示的情况。 
  
public interface InterfaceA 
{ 
    public static final int A = new java.util.Random ().nextInt (); 
  
} // End of interface 
  
  
InterfaceA改变后, Main.main()的字节码将变成: 
  
Method void main(java.lang.String[]) 
   0 getstatic #27 <Field java.io.PrintStream out> 
   3 getstatic #31 <Field int A> 
   6 invokevirtual #37 <Method void println(int)> 
   9 return 
  
注意,字节码中出现了对于A的动态引用。 
  
上面的Interface.A改变是十分明显的。然而,想象一下在一个应用程序中使用下列三个接口会是什么情况: 
  
public interface InterfaceA 
{ 
    public static final int A = 2 * InterfaceB.B; 
  
} // End of interface 
  
public interface InterfaceB 
{ 
    public static final int B = InterfaceC.C + 1; 
  
} // End of interface 
  
public interface InterfaceC extends InterfaceA 
{ 
    public static final int C = A + 1; 
  
} // End of interface 
  
  
试用这个版本的main():  
  
  
public class Main implements InterfaceA, InterfaceB, InterfaceC 
{ 
    public static void main (String [] args) 
    { 
        System.out.println (A + B + C); 
    }  
  
} // End of class 
  
打印结果为7。暂时忘掉这个值并改变main()中一个看起来并不重要的地方: 
  
public class Main implements InterfaceA, InterfaceB, InterfaceC 
{ 
    public static void main (String [] args) 
    { 
        System.out.println (C + B + A); // The sum is still the same, right? 
    }  
  
} // End of class 
  
  
现在结果是6. 重新安排求和的次序后结果似乎被改变了. 你想要看到这样的结果么?让我们分析一下为什么会这样。 
  
虽然,A、B、C表面看想来好像在编译时可以由表达式值初始化,其实并非如此,因为这是个“循环定义”,其中A依赖B,而B依赖C,最后C又依赖A。 
  
结果,编译器不能在加载/初始化三个域时做任何替换静态初始化代码的行为。原因是三个域中各个域都要依靠重载另一个域才能求出值。(见Note2)计算出的第一个main()结果后,我注意到InterfaceA是第一个被加载的(加法计算法则的顺序是从左向右加),于是InterfaceC是第一个被完整初始化的(依据三者间的依赖关系)。InterfaceC依赖InterfaceA,而此时A还未被初始化,(因此A的值是0)。这时C的值是1,B的值是2,A的值当然就是4了,相加得出结果7。第二个版本就作为读者的练习吧。(提示:三个值都将有所变化) 
  
或许我这个例子举的不够好。然而,想像一下相同的三个接口分散在一个庞大的代码库中不同的包中会是一个什么样的情形:“循环定义”可并不简单。看上去每个都好像已在内部被定义,不仔细的查看是不会看出什么问题的。但是,这些问题将在以后导致很难以排除的问题:尽管一些表达式的值在你的应用程序的版本中可被复写,但它及可能在某处导致不同的类的载入顺序和无法预见的执行顺序。例如,在并发线程队列里不可预知的改变可能会导致不同的类加载顺序。不幸地是,大多数编译器不会考虑这些错误甚至连一个对程序员的警告都没有。 
  
关于作者: Vladimir Roubtsov他从1995年起开始接触Java,有超过13年的多语言编程经验. 现在,他是Trilogy公司的高级工程师,主要工作是开发企业软件。 
资源:  
   
 
  |