干货系列 -- 盘点Java的大大小小的"坑"

  • 前言
  • 判断奇数
  • 一天的时间
  • 注释的欺骗
  • Integer 的内存分布
  • 总结


前言

记得有个大神说了一句话,“永远不要相信你的程序” ,这句话深深的影响着我,每次我认为我的程序无误的时候,都会留心多检查几遍,多写一些测试用例跑程序。
本文就来盘点一下,Java中各式各样的"坑",说是坑,其实只是那些代码实现底层被我们忽略的细节。

判断奇数

相信在初学Java的时候,老师都大概会让你写一个程序判断是否奇数。或者作为一道作业让你思考。聪明的你很快就想到,只要把需要判断的数除以2取余,如果余数是1的话,就说明是奇数,于是很快就写出了代码:

public boolean isOdd(int i) {
	return i % 2 == 1;
}

写出这样 “优雅” 的代码,你心里应该有些沾沾自喜了。于是写了测试数据,也成正确的返回了对应的true和false,但是细心的你发现这个程序是有问题的,那就是它判断不了负奇数。

这个问题的原因在于,Java对取余操作符%的定义要满足以下恒等式:

(a / b) * b + (a % b) == a

其中 a 等于所有的int数值,b 等于所有非0的int有效数值 。这个公式可以用一句话表示:
如果用 b 整除 a ,将商乘以 b ,然后加上余数,应该得到最初的a。

是不是很绕,那举个栗子吧,我们用101和2代入公式做个测试。当 a = 101 ,b = 2 时
(a / b) * b = 100
(a % b) = 1
100 + 1 = a
所以,这个测试用例是满足 a % b == 1的

再做个负数的测试。当 a = -101 ,b = 2 时
(a / b) * b = -100
这时 a 的值是 -101 所以 (a % b) 只能等于 -1 才满足上面的恒等式 。所以
(a % b) = -1

结合这个用例,再去看上面的那个程序,当 i 是一个负奇数时 i % 2 的结果是 -1 而不是 1 ,所以会导致判断失败。
那要修改这个问题,其实很简单,只需要把 i % 2 的结果跟 0 比较就行了,如果结果不是 0 那么肯定就是个奇数。于是你很快写出了代码。

public boolean isOdd(int i) {
	return i % 2 != 0;
}

很 “优雅” 和 “完美” 的代码,但如果你的isOdd方法在生产线上被调用了千万次,老板让你优化这个方法的性能,你会怎么做呢。
聪明的你很快又想到了,我们只需要判断 i 的二进制的最右位数字是 0 还是 1 就行了,如果是1就说明是奇数,0 则是偶数。算法思想已经想出来了,代码该如何写呢。
聪明的你很快想到了,这下就要使用我们的位与运算符 & 了 ,我们用 i 与 1做位与运算 ,如果得到的结果是 0 说明是偶数,不等于 0 说明是奇数。
于是你很快写出了代码。

public boolean isOdd(int i) {
	return (i & 1) != 0;
}

好啦,我们再来看看下个 “坑”

一天的时间

你偶然接到一个活,要计算一天中有多少微秒,于是你灵光一闪,这不是只需要用

24小时 * 60分钟 * 60秒 * 1000毫秒 * 1000微秒

这个公式就能算出来吗,于是嘴角露出了微笑,于是你很快就写出了代码

public long toDayMicros() {
	return 24 * 60 * 60 * 1000 * 1000;
}

作为一个负责任的程序员,你当然会自己跑一次程序啦,计算结果:

500654080

你惊呆了,很明显这是计算溢出后的结果,那问题出在哪里呢,方法声明的返回值明明就是long呀,计算的结果不会超出 long 的范围才对。
于是你上网查资料才知道,当运算中,所有的元素都是 int 类型时,相乘的结果也是 int 类型,在计算完成时,才被向上转型成 long ,但是已经晚了,在 int 计算时,结果已经溢出了,再转成 long 也是一个溢出值。
聪明的你想到了解决方案:只需要把相乘的元素申明为 long 那么结果就不会溢出了。
你当然清楚,最好是设置第一个相乘的元素为 long 类型,这样很清楚的表明它们的运算不会溢出
于是你更改了程序:

public long toDayMicros() {
	return 24L * 60 * 60 * 1000 * 1000;
}

Bug成功修复,再看看下一个 “坑”

注释的欺骗

作为一个 Java 开发,我们都知道,注释是不会运行的,仅为开发人远提供解释说明。事实真的是这样吗?来看一段代码:

public static void main(String[] args) {
	int i= 0;
	// \u000a i=1;
	System.out.println(i);
}

这段代码运行结果是多少呢?相信你应该在犹豫答案吧。提示一下: \u000a 是转义字符的换行。
这里我就不公布答案了,希望你能 copy 或自己手写运行一下,手动跑一下程序感触更深。
编译上面这段代码,可以看到如下字节码:

public static void main(java.lang.String[]);
    Code:
       0: iconst_0							// 把 0 压入栈顶 
       1: istore_1							// 给局部变量表添加索引1
       2: iconst_1                        	// 把 1 压入栈顶
       3: istore_1							// 给局部变量表添加索引1
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: iload_1								// 加载局部变量表索引为1的值
       8: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
      11: return								//返回

从上面的字节码中,可以得出,其实注释里面的代码已经被运行了,那是怎么回事呢,不是说好的注释的部分不会运行吗,其实根本原因是因为,注释的部分不会运行,但会编译,再加上 \u000a 是换行,所以这个程序编译过后相当于这样:

public static void main(String[] args) {
	int i= 0;
	// 
	i=1;
	System.out.println(i);
}

这样的话,相信你也知道答案了吧。我们再继续看看其他的 “坑”

Integer 的内存分布

先来看一段代码吧:

public static void main(String[] args) {
	Integer i = 100;
	Integer j = 100;
	
	System.out.println(i == j);
}

看完这段代码,相信你心里已经知晓会输出什么结果了。但是如果有人问你,其中对象 i 与 j 是被分配在哪块内存呢,你能回答得上来吗。
这个小结就说一下,Integer 的内存分布情况。
如果你运行了这段代码,就知道结果输出是:true,我们又知道 == 比较的是对象的引用,那么说明 i 与 j 这两个对象指向的是同一个引用,那你可能会疑问,对象明明都是在堆上分配的,为什么 new 了两个不同的对象然而指向的是同一个引用
带着这个疑问,再来看一段代码:

public static void main(String[] args) {
	Integer i = 200;
	Integer j = 200;
	
	System.out.println(i == j);
}

这段代码几乎与第一段代码没什么区别,只是把 100 改成了 200,结果又会是多少呢。

你可以copy一下这段代码,然后得到运行结果:false

那么,问题来了,为何只是把 100 改成了 200 结果就编程了false呢?

其实这是因为,JVM维护了一个常量池,这个常量池的值范围是 -128~127 ,当Integer的值在这个范围时,JVM会直接把引用指向常量值,100在这个范围,所以引用的是常量池的数据,然而200不在这个范围,所以被创建在了堆中,引用当然不一样。我画了个图,大概就是这样

hh的坑 java java常见的坑_jvm


最后补充一点,对象的值比较一定要用equals,一定要用equals,一定要用equals。

总结

总结一下吧,Java 那些容易被忽略的细节实在太多了,这也成为了我们日常开发中容易遇到的一个个 “坑” ,其实坑还有很多,如果大家感兴趣的话,给我留言吧,以后的博客中会多讲一些各式各样的坑。