日常工作中,我们直接接触Class文件的时间可能不多,但这不代表了解了Class文件就用处不大。本文将试图回答三个问题,Class文件中字符串的最大长度是多少、Java存在尾递归调用优化吗?、类的初始化顺序是怎样的?。与直接给出答案不同,我们试图从Class文件中找出这个答案背后的道理。我们一一来看一下。
Class文件中字符串的最大长度是多少?
在上篇文章中我们提到,在class文件中,字符串是被存储在常量池中,更进一步来讲,它使用一种UTF-8格式的变体来存储一个常量字符,其存储结构如下:
CONSTANT_Utf8_info { u1 tag;//值为CONSTANT_Utf8_info(1) u2 length;//字节的长度 u1 bytes[length]//内容 }
可以看到CONSTANT_Utf8_info中使用了u2类型来表示长度,当我最开始接触到这里的时候,就在想一个问题,如果我声明了一个超过u2长度(65536)的字符串,是不是就无法编译了。我们来做个实现。
字符串太长就不贴出来,直接贴出在终端上使用javac命令编译后的结果:
果然,编译报错了,看来class文件的确无法存储超过65536字节的字符串。
如果事情到这里为止,并没有太大意思了,但后来我发现了一个有趣的事情。下面的这段代码在eclipse中是可以编译过的:
public class LongString { public static void main(String[] args){ String s = a long long string...; System.out.println(s); } }
这不科学,更不符合我们的认知。eclipse搞了什么名堂?我们拖出class文件看一看:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=2, args_size=1 0: new #16 // class java/lang/StringBuilder 3: dup 4: ldc #18 6: invokespecial #20 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V 9: ldc #23 // String 11: invokevirtual #25 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 14: invokevirtual #29 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 17: invokevirtual #33 // Method java/lang/String.intern:()Ljava/lang/String; 20: astore_1 21: getstatic #38 // Field java/lang/System.out:Ljava/io/PrintStream; 24: aload_1 25: invokevirtual #44 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 28: return LineNumberTable: line 10: 0 line 3212: 21 line 3213: 28 LocalVariableTable: Start Length Slot Name Signature 0 29 0 args [Ljava/lang/String; 21 8 1 STR Ljava/lang/String;
可以看到,上面的超长字符串被eclipse截成两半,#18和#23, 然后通过StringBuilder拼接成完整的字符串。awesome!
但是,如果我们不是在函数中声明了一个巨长的字符串,而是在类中直接声明:
public class LongString { public static final String STR = a long long string...; }
Eclipse会直接进行错误提示:
具体关于在上面两个字符串的初始化时机我们会在第三点里进行阐述,但理论上在类中直接声明也是可以像在普通函数中一样进行优化。具体的原因我们就不得而知了。不过这提醒我们的是在Class文件中,和字符串长度类似的还有类中继承接口的个数、方法数、字段数等等,它们都是存在个数由上限的。
Java存在尾递归调用优化吗?
回答这个问题之前,我们需要了解什么是尾递归呢?借用维基百科中的回答:
- 调用自身函数(Self-called);
- 计算仅占用常量栈空间(Stack Space)
用更容易理解的话来讲,尾递归调用就是函数最后的语句是调用自身,但调用自己的时候,已经不再需要上一个函数的环境了。所以并非所有的递归都属于尾递归,它需要通过上述的规则来编写递归代码。和普通的递归相比,尾递归即使递归调用数万次,它的函数栈也仅为常数,不会出现Stack Overflow
异常。
那么java中存在尾递归优化吗?这个回答现在是否定的,到目前的Java8为止,Java仍然是不支持尾递归的。
但最近class家族的一位成员kotlin
是号称支持尾递归调用的,那么它是怎么实现的呢?我们通过递归实现一个功能来对比Java
与Kotlin
之间生成的字节码的差别。
我们来实现一个对两个整数的开区间内所有整数求和的功能。函数声明如下:
int sum(int start, int end , int acc)
参数start为起始值,参数end为结束值,参数acc为累加值(调用时传入0,用于递归使用)。如sum(2,4,0)会返回9。我们分别用Java
与Kotlin
来实现这个函数。
Java:
public static int sum(int start, int end , int acc){ if(start > end){ return acc; }else{ return sum(start + 1, end, start + acc); } }
Koklin:
tailrec fun sum(start: Int, end: Int, acc: Int): Int{ if (start > end){ return acc } else{ return sum(start+1, end, start + acc) } }
我们对这两个文件编译生成的class文件中的sum函数进行分析:
Java生成的sum函数字节码如下:
我们提取主要信息,在第14个命令上,sum函数又递归的调用了sum函数自己。此时,还没有调用到第17条命令ireturn来退出函数,所以,函数栈会进行累加,如果递归次数过多,就难免不会发生Stack Overflow
异常了。
我们再