一、背景
技术群里有一个老铁分享了一段 commons-lang 的 StringUtils 工具类的代码:
public static boolean isBlank(final CharSequence cs) {
int strLen;
if (cs == null || (strLen = cs.length()) == 0) {
return true;
}
for (int i = 0; i < strLen; i++) {
if (Character.isWhitespace(cs.charAt(i)) == false) {
return false;
}
}
return true;
}
得出的结论是:
老外的代码风格和咱们真的不一样,人家判断某个布尔值是否等于 false,居然这么写,咱们都是取反判断的。
真的是这样吗?
从这段代码中我们还发现,人家的参数用 final 修饰.
What are you 弄啥哩?
平凡之处见真章,本文将以这个简单的问题入手,带着大家熟悉反编译和反汇编,带着大家分析问题。
二、布尔判断问题
2.1 真的是老外都这么写?
2.1.1 拉最新版本
那么真的老外都是这么写的吗?我们拉取 commons-lang 最新版的代码,发现并非如此。
master 分支 commitId 为 fe44a99852719ff842ff5 的源码:
public static boolean isBlank(final CharSequence cs) {
int strLen = length(cs);
if (strLen == 0) {
return true;
}
for (int i = 0; i < strLen; i++) {
if (!Character.isWhitespace(cs.charAt(i))) {
return false;
}
}
return true;
}
已经改成取反的方式了。
那么问题来了,大家可以想想,为啥改了呢?
很显然,源码改成这种写法应该是这种写法更好,否则没必要改啊,对吧。
那么为啥这种写法更好呢?
我们可以借助 IDEA 的检查工具。
其实如果平时你写代码的时候能够关注 IDEA 的警告,就会发现 “条件 == false” 这种写法会给出下面警告:
因此我们可知道, IDEA 不推荐这种写法,认为另外一种写法是更简化的形式。
那么我们如何知道作者的用意呢?
直接拉源码,查看该函数或者该类的修改历史即可。
可以从修改历史的提交注释中找到原因。
可以看出修改原因为, 根据 IDEA 提示进行重构,在 #276 编号的 PR 中引入进来的。
我们可以到该项目的 pull requests 中修饰该编号:
这里有修改的详细描述。
另外我们在研究这个问题的时候又有了新的发现:
我们发现 overlay 函数在此次提交时,将 StringBuilder 拼接的字符串的方式改为了直接用加号拼接,大家可以思考下为什么。可以评论区给出自己的看法。
2.1.2 看其他项目
我们还可以用专栏里强力推荐的 codota 查看其他外国的知名开源项目有没有这种写法。
发现有很多类似的写法,包括 spring-framework:
2.2 研究两者的差别
为了更好地研究这个问题,咱们自己写一个字符串工具类,Copy一下代码:
public class StringUtils {
public static boolean isBlank(final CharSequence cs) {
int strLen;
if (cs == null || (strLen = cs.length()) == 0) {
return true;
}
for (int i = 0; i < strLen; i++) {
if (Character.isWhitespace(cs.charAt(i)) == false) {
return false;
}
}
return true;
}
}
对该类进行编译,然后通过 IDEA 自带的反编译工具进行反编译,得到下面的代码:
public class StringUtils {
public StringUtils() {
}
public static boolean isBlank(final CharSequence cs) {
int strLen;
if (cs != null && (strLen = cs.length()) != 0) {
for(int i = 0; i < strLen; ++i) {
if (!Character.isWhitespace(cs.charAt(i))) {
return false;
}
}
return true;
} else {
return true;
}
}
}
我们看到反编译后的代码,还是对 Character.isWhitespace 的判断取反。
我们可以查反汇编代码:
public class com.chujianyun.libs.commons.lang3.StringUtils {
public com.chujianyun.libs.commons.lang3.StringUtils();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static boolean isBlank(java.lang.CharSequence);
Code:
0: aload_0
1: ifnull 15
4: aload_0
5: invokeinterface #2, 1 // InterfaceMethod java/lang/CharSequence.length:()I
10: dup
11: istore_1
12: ifne 17
15: iconst_1
16: ireturn
17: iconst_0
18: istore_2
19: iload_2
20: iload_1
21: if_icmpge 45
24: aload_0
25: iload_2
26: invokeinterface #3, 2 // InterfaceMethod java/lang/CharSequence.charAt:(I)C
31: invokestatic #4 // Method java/lang/Character.isWhitespace:(C)Z
34: ifne 39
37: iconst_0
38: ireturn
39: iinc 2, 1
42: goto 19
45: iconst_1
46: ireturn
}
我们可以看到 isBlank 反编译的代码的 31 行处,调用 java.lang.Character#isWhitespace(char) 返回了 boolean 值。
31: invokestatic #4 // Method java/lang/Character.isWhitespace:(C)Z
JVMS 2.3.4 节对 boolean 类型有如下描述:
JVM 中用 1 表示 true , 0 表示 false。
Java 编程语言中 boolean 类型的值会被编译器编译成 JVM 所需的整数类型。
因此面执行的结果为 0 或者 1 。
然后执行 ifne
34: ifne 39
ifne success if and only if value ≠ 0
只有值不等于 0 则为成功
如果值为 1 则跳转到 39 行,将局部变量表索引为 2 的变量即 i 加一,然后和 strLen 比较,然后…
如果值为 0 即上述结果为 false ,则执行
iconst_0 // 将常量 0 压如操作数栈
ireturn // 将栈顶元素作为返回弹出
即等价于 return false。
然后我们将代码改成另外一种形式:
public class StringUtils {
public static boolean isBlank(final CharSequence cs) {
int strLen;
if (cs == null || (strLen = cs.length()) == 0) {
return true;
}
for (int i = 0; i < strLen; i++) {
if (!Character.isWhitespace(cs.charAt(i)) ) {// 这里不同
return false;
}
}
return true;
}
}
发现编译后的反编译代码相同,反汇编后的代码也相同(此处就不再重复贴出代码了)。
因此可以得出一个结论,两种写法编译后的字节码相同。
都是通过 ifne 判断上面表达式的boolean 结果来决定执行再次循环或者返回的逻辑。
三、final 参数问题
参数声明为 final 的目的是啥呢?
JLS 4.12.4 final variables 讲到:
变量可以声明为 final。 final 变量只能被赋值一次。
一个 final 变量,除非之前该变量是明确未被赋值,否则再次赋值会报编译时错误。
一旦 final 变量被赋值,那么它就是始终保持同一个值。
如果 final 类型的变量持有一个对象的引用,对象的状态可以由对象提供的函数修改,但是变量总是引用相同的对象。
这个原则同样适用于数组,因为数组包含多个对象;如果一个 final 变量持有数组对象,数组的元素可以修改,但这个变量引用同一个数组对象。
也有一些变量虽然不声明为 final ,也会被认为 effectively final(和 final 等效)。
局部变量声明时即初始化,如果满足以下几种情况,则为 effectively final
- 没有声明为 final。
- 它永远不会出现在赋值表达式的左侧。 (注意:局部变量声明符包含初始化但不能是赋值表达式。)
- 它永远不会作为前缀或后缀递增或递减运算符的操作数出现。
2 局部变量声明时如果没有初始化,如果满足以下几种情况,则为 effectively final
- 没有声明为 final
- 当它出现在赋值表达式的左边时,它肯定是未赋值的,而且在赋值之前也没有明确赋值;
也就是说,它绝对是未赋值的,也不是绝对赋值在赋值表达式的右边(§16(明确赋值))。- 它永远不会作为前缀或后缀递增或递减运算符的操作数出现。
3 方法、构造器、lambda 或异常的参数被视作有初始化器的局部变量,目的是为了判断这些参数是否为 effectively final 的。
另外Java 语言手册还有这样一段描述:
如果变量是 effectively final ,那么为其添加 final 修饰符不会有任何错误。一个合法的 final 局部变量或者参数删除 final 修饰符,会变成 effectively final。
有了这些知识储备之后,我们再看这个问题就简单多了。
因为 lambda 表达式和匿名内部类中使用的变量要求是 final 或 effectively final类型。
从语言角度:
只要满足以上条件,参数上可以不显式声明 final, 也可以在 lambda 表达式或者匿名内部类中使用。
显式声明还有一个好处是,在函数内部引用不能发生改变。
从功能角度:
从功能角度来讲, isBlank 函数是判断该字符序列是否为空字符串、null 或者包含空格。
因此参数传入后不希望也不需要在函数内部对引用进行修改。
因此显式加上 final 声明更稳妥。
so ,问题解决了??
No, 上面讲到如果final 变量持有对象的引用,如果不允许修改对象的属性怎么办?
可以使用不可变对象。如 String。
那么不可变对象是如何实现的呢?
我们以 String 为例:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
// 将参数字符串追加到当前字符串后
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
// 其他属性和函数略
}
建议大家自行思考 String 是如何实现不可变的,这个面试中也可能会问到。
- 存储字符数组的 value 成员变量用 final 修饰,赋值后引用不能改变。
- 所有修改对象的属性或状态的方法返回的都是新的字符串对象。
因此我们编写不可变对象时可以参考这种思路。
那么如果引用不可变也不允许改变对象的属性怎么办?
此时可以 final + 不可变对象一起起作用。
public class MapTest {
private static final Map<String, Integer> MAP;
static {
Map<String, Integer> data = new HashMap<>();
data.put("a", 1);
data.put("b", 2);
MAP = MapUtils.unmodifiableMap(data);
}
@Test
public void test2() {
// 报错 java.lang.UnsupportedOperationException
MAP.put("c", 3);
System.out.println(MAP);
}
}
这样,引用不可变,map 的值也不可修改。
四、启示
本文内容并不难,但是希望通过本问向大家传达一些理念。
实践是检验真理的标准。没实践不要轻易下结论。我们在下结论之前进行对比,进行调研,不要看到孤立的例子就立马下结论。
学习时要多动手。大家学习技术时要尽量自己写简单的DEMO 验证自己的想法,可以调试细节。
善用工具。 本文用到的 codota 是编程利器,还有很多超好用的插件在本的博客中或专栏里有专门的推荐。 IDEA 的语法警告、错误提示是我们养成好的编程习惯,避免犯错的极佳助手。 GIT 也是我们学习源码的重要工具。
更多以好用的 IDEA 插件和好用的效率工具可以看这篇文章。
善用反编译和反汇编。通过反编译可以破解一些语法糖,通过反汇编可以从字节码层面学习知识。可以透过源码看到更本质的东西,推荐大家去重点掌握。
细微之处见真章。有些看似简单的问题背后隐藏着很多可学的知识,然而很多人会忽略这些问题。面试中一些简单问题,能否回答的全面,回答的有深度,都是一个人专业是否扎实的表现。
看源码。看源码有很多思维和方法。比如以设计者的角度学习源码;比如通过设计模式的角度学源码;比如通过调试学源码等等,专栏有专门章节详细介绍。在这里提醒大家的是,看源码一定要多思考。
思考它为什么这么写,不这么写行不行?这点很重要,比如本文提到的 为啥源码某个版本 if 条件 用 == false 判断,为啥参数带 final 等等。可以将知识串起来,加深对知识的理解。
Java 语言规范 和 Java 虚拟机规范是最权威的参考。很多人习惯看博客来学习知识,更希望大家转向从 Java 的语言和虚拟机层面来学习知识,而《Java 语言规范》和 《Java 虚拟机规范》则是官方出的权威参考。
是什么?为什么?怎么做? 这是一个非常重要的思维方式。然而很多人喜欢记忆结论。导致记住容易遗忘,记住不会用。
五、写在最后
发现很多人学习技术总是喜欢强调努力,强调多看书,多看源码。
就我个人而言,更喜欢大家如果自己的学习效果不是特别满意,多去学习和运用一些新的思维和方法。
因为新的思维和方法对技术的提升速度影响更大。
多看书也没错,但是看什么书?怎么看?多看源码也没错,看哪些源码?怎么看?有哪些思维和方法?
这些才是问题的关键,使用不同的方法看不同的内容,最后效果的差距也非常大。
总之希望大家学习时不要忽略基础,希望大家多探索一些好的方法,能够从更深的层面去学习和理解源码。
如果你觉得本文对你有帮助,欢迎点赞、转发、评论,你的支持是我创作的最大动力。
另外想学习,更多开发和避坑技巧,少走弯路,请关注我的专栏:《阿里巴巴Java 开发手册》详解专栏