Java编码易疏忽的十个问题
在 Java 编码实践中,开发者容易犯一些错误,也容易疏忽某些细节。笔者对日常编码中遇到的经典情形进行了归纳整理,形成以下十个常见问题,以供共同探讨与避坑。
1. 纠结的同名
现象
很多类的命名相同(例如:常见于异常、常量、日志等类),导致在 import 时容易张冠李戴。这种错误有时候很隐蔽,因为往往同名的类功能也类似,IDE 可能不会提示警告(Warning)。
解决
写完代码时,扫视一下 import 部分,检查有没有不熟悉的类。替换成正确导入后,要注意注释是否也作了相应修改。
启示
命名尽量避开重复名,特别要避开与 JDK 中的类重名,否则容易导入错误。同时,存在大量重名类时,查找也需要更多的辨别时间。
2. 想当然的 API
现象
有时候调用 API 时,会想当然地通过名字直接自信满满地调用,导致出现一些令人惊讶的错误。
示例一:flag 是 true 吗?
boolean flag = Boolean.getBoolean("true");结果可能老是 false。因为 Boolean.getBoolean 读取的是系统属性(System Property),而非字符串解析。
示例二:这是去年的今天吗(今年是 2012 年)?
Calendar calendar = GregorianCalendar.getInstance();
calendar.roll(Calendar.DAY_OF_YEAR, -365);结果还是 2012 年。roll 方法只改变指定字段,不影响更大字段(如年份)。
下面的才是去年:
calendar.add(Calendar.DAY_OF_YEAR, -365); 解决办法
问自己几个问题:这个方法我很熟悉吗?有没有类似的 API?区别是什么?就示例一而言,需要区别如下方法:
Boolean.valueOf(b)Boolean.parseBoolean(b)Boolean.getBoolean(b)
启示
名字起得更详细点,注释写得更清楚点。不要不经了解、测试就想当然地使用一些 API。如果时间有限,用自己最为熟悉的 API。
3. 有时候溢出并不难
现象
有时候溢出并不难发现,虽然不常复现。
示例一:
long x = Integer.MAX_VALUE + 1;
System.out.println(x);x 是多少?竟然是 -2147483648。明明加上 1 之后还在 long 的范围,但因为 Integer.MAX_VALUE 是 int,运算先在 int 范围内进行,导致溢出后再赋值给 long。类似的经常出现在时间计算或多数字连乘中:
数字 1 × 数字 2 × 数字 3… 示例二:
在检查是否为正数的参数校验中,为了避免重载,选用参数 number,于是下面代码结果小于 0,也是因为溢出导致:
Number i = Long.MAX_VALUE;
System.out.println(i.intValue() > 0);解决
- 让第一个操作数是
long型,例如加上L或者l(不建议小写字母l,因为和数字1太相似了); - 不确定时,还是使用重载吧。即使使用
doubleValue(),当参数是BigDecimal时,也可能无法完全解决问题。
启示
对数字运用要保持敏感:涉及数字计算就要考虑溢出;涉及除法就要考虑被除数是 0;实在容纳不下了可以考虑 BigDecimal 之类。
4. 日志跑哪了?
现象
有时候觉得 log 都打了,怎么找不到?
示例一:没有 Stack Trace!
} catch (Exception ex) {
log.error(ex);
}示例二:找不到 log!
} catch (ConfigurationException e) {
e.printStackTrace();
}解决
- 替换成
log.error(ex.getMessage(), ex);,确保异常堆栈被记录; - 换成普通的 log4j 吧,而不是
System.out。
启示
- API 定义应该避免让人犯错,如果多加个重载的
log.error(Exception)自然没有错误发生; - 在产品代码中,使用的一些方法要考虑是否有效,使用
e.printStackTrace()要想下终端(Console)在哪。
5. 遗忘的 volatile
现象
在 DCL(Double-Checked Locking,双重检查锁定)模式中,总是忘记加一个 volatile。
private static CacheImpl instance; // lose volatile
public static CacheImpl getInstance() {
if (instance == null) {
synchronized (CacheImpl.class) {
if (instance == null) {
instance = new CacheImpl();
}
}
}
return instance;
}解决
毋庸置疑,加上一个 volatile 吧。synchronized 锁的是一块代码(整个方法或某个代码块),保证的是这“块”代码的可见性及原子性,但是 instance == null 第一次判断时不在锁范围内。所以可能读出的是过期的 null。
启示
我们总是觉得某些低概率的事件很难发生,例如某个时间并发的可能性、某个异常抛出的可能性,所以不加控制。但是如果可以,还是按照前人的“最佳实践”来写代码吧。至少不用过多解释为啥另辟蹊径。
6. 不要影响彼此
现象
在释放多个 IO 资源时,都会抛出 IOException,于是可能为了省事如此写:
public static void inputToOutput(InputStream is, OutputStream os,
boolean isClose) throws IOException {
BufferedInputStream bis = new BufferedInputStream(is, 1024);
BufferedOutputStream bos = new BufferedOutputStream(os, 1024);
// ...
if (isClose) {
bos.close();
bis.close();
}
}假设 bos 关闭失败抛出异常,bis 还能关闭吗?当然不能!
解决办法
虽然抛出的是同一个异常,但是还是各自捕获各的为好。否则第一个失败,后一个就没有机会去释放资源了。
启示
代码/模块之间可能存在依赖,要充分识别对相互的依赖,确保资源释放的独立性。
7. 用断言取代参数校验
现象
如题所提,作为防御式 编程 常用的方式:断言,写在产品代码中做参数校验等。例如:
private void send(List<Event> eventList) {
assert eventList != null;
}解决
换成正常的统一的参数校验方法。因为断言默认是关闭的,所以起不起作用完全在于配置。如果采用默认配置,经历了 eventList != null 结果还没有起到作用,徒劳无功。
启示
有的时候,代码起不起作用,不仅在于用例,还在于配置,例如断言是否启用、log 级别等,要结合真实环境做有用编码。
8. 用户认知负担有时候很重
现象
先来比较三组例子,看看哪些看着更顺畅?
示例一:
public void caller(int a, String b, float c, String d) {
methodOne(d, c, b);
methodTwo(b, c, d);
}
public void methodOne(String d, float c, String b)
public void methodTwo(String b, float c, String d)示例二:
public boolean remove(String key, long timeout) {
Future<Boolean> future = memcachedClient.delete(key);
public boolean delete(String key, long timeout) {
Future<Boolean> future = memcachedClient.delete(key);示例三:
public static String getDigest(String filePath, DigestAlgorithm algorithm)
public static String getDigest(String filePath, DigestAlgorithm digestAlgorithm)解决
- 保持参数传递顺序;
remove变成了delete,显得突兀了点,统一表达更好;- 保持表达,少缩写也会看起来流畅点。
启示
在编码过程中,不管是参数的顺序还是命名都尽量统一,这样用户的认知负担会很少,不要让用户容易犯错或迷惑。例如用枚举代替 String 从而不让用户迷惑到底传什么 String,诸如此类。
9. 忽视日志记录时机、级别
现象
存在下面两则示例:
示例一:该不该记录日志?
catch (SocketException e) {
LOG.error("server error", e);
throw new ConnectionException(e.getMessage(), e);
} 示例二:记什么级别日志?
在用户登录系统中,每次失败登录:
LOG.warn("Failed to login by " + username);解决
- 移除日志记录:在遇到需要 re-throw 的异常时,如果每个人都按照先记录后 throw 的方式去处理,那么对一个错误会记录太多的日志,所以不推荐如此做;但是如果 re-throw 出去的 exception 没有带完整的 trace(即 cause),那么最好还是记录下。
- 调整级别:如果恶意登录,那系统内部会出现太多
WARN,从而让管理员误以为是代码错误。可以反馈用户以错误,但是不要记录用户错误的行为,除非想达到控制的目的。
启示
日志改不改记?记成什么级别?如何记?这些都是问题,一定要根据具体情况,需要考虑:
- 是用户行为错误还是代码错误?
- 记录下来的日志,能否在不造成过多干扰的前提下,提供有用的信息以快速定位问题。
10. 忘设初始容量
现象
在 Java 中,我们常用 Collection 中的 Map 做 Cache,但是我们经常会遗忘设置初始容量。
cache = new LRULinkedHashMap<K, V>(maxCapacity);解决
初始容量的影响有多大?拿 LinkedHashMap 来说,初始容量如果不设置默认是 16,超过 16 × LOAD_FACTOR 会 resize(2 * table.length),扩大 2 倍:采用 Entry[] newTable = new Entry[newCapacity]; transfer(newTable),即整个数组 Copy。那么对于一个需要做大容量 CACHE 来说,从 16 变成一个很大的数量,需要做多少次数组复制可想而知。
如果初始容量就设置很大,自然会减少 resize。不过可能会担心,初始容量设置很大时,没有 Cache 内容仍然会占用过大体积。其实可以参考以下表格简单计算下,初始时还没有 cache 内容,每个对象仅仅是 4 字节引用而已。
- memory for reference fields (4 bytes each);
- memory for primitive fields
| Java type | Bytes required |
|---|---|
| boolean | 1 |
| byte | 1 |
| char | 2 |
| short | 2 |
| int | 4 |
| float | 4 |
| long | 8 |
| double | 8 |
启示
不仅是 Map,还有 StringBuffer 等,都有容量 resize 的过程。如果数据量很大,就不能忽视初始容量,可以考虑设置下,否则不仅有频繁的 resize 还容易浪费容量。
结语
在 Java 编程中,除了上面枚举的一些容易忽视的问题,日常实践中还存在很多。相信通过不断的总结和努力,可以将我们的程序完美呈现给读者。
说明
- 本文部分示例基于 Java 早期版本编写(如手动关闭 IO 资源、DCL 模式等)。
- 在 Java 7 及以上版本中,建议优先使用
try-with-resources语法自动管理 IO 资源。 - 关于
volatile的语义保证,主要适用于 Java 5 及以上内存模型。
版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/java-bian-ma-yi-shu-hu-de-shi-ge-wen-ti.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。