避坑手册 | JAVA编码中容易踩坑的十大陷阱
JAVA编码中存在一些容易被人忽视的陷阱,稍不留神可能就会跌落其中,给项目的稳定运行埋下隐患。此外,这些陷阱也是面试的时候面试官比较喜欢问的问题。
本文对这些陷阱进行了统一的整理,让你知道应该如何避免落入陷阱中,下面就一起来了解下吧。
循环中操作目标list
遍历List然后对list中符合条件的元素进行删除操作,这是项目里面非常常见的一个场景。
先看下两种典型的错误写法:
错误写法1:
for (User user : userList) {
if ("男".equals(user.getSex())) {
userList.remove(user);
}
}
错误原因:
在循环或迭代时,会首先创建一个迭代实例,这个迭代实例的expectedModCount
赋值为集合的modCount
。而每当迭代器使⽤ hashNext()
/ next()
遍历下⼀个元素之前,都会检测 modCount
变量与expectedModCount
值是否相等,相等的话就返回遍历;否则就抛出异常ConcurrentModificationException
,终⽌遍历。
如果在循环中添加或删除元素,是直接调用集合的add()
,remove()
方法,导致了modCount
增加或减少,但这些方法不会修改迭代实例中的expectedModCount
,导致在迭代实例中expectedModCount
与 modCount
的值不相等,抛出ConcurrentModificationException异常。
错误写法2:
for (int i = 0; i < userList.size(); i++>) {
if ("男".equals(user.getSex())) {
userList.remove(i);
}
}
错误原因:
删除元素之后,元素下标发生前移,但是指针是不变的,再处理下一个的时候,就可能会有部分元素被漏掉没有处理。
那正确的方式应该如何处理呢?接着往下看。
正确写法1:
// 使用迭代器来实现
Iterator iterator = userList.iterator();
while (iterator.hasNext()) {
if ("男".equals(user.getSex())) {
iterator.remove();
}
}
补充说明:
与前面的错误写法1相对比,同样都是基于迭代器的逻辑,为什么这种写法就对了呢?
这是因为迭代器中的remove()
,add()
方法,会在调用集合的remove()
,add()
方法后,将expectedModCount
重新赋值为modCount
,所以在迭代器中增加、删除元素是可以正常运行的。
正确写法2:
// 使用Lambda表达式实现
userList.removeIf(user -> "男".equals(user.getSex());
正确写法3:
// 使用removeAll实现
List<User> maleUsers = new ArrayList<>();
for (User user : userList) {
if ("男".equals(user.getSex())) {
maleUsers.add(user);
}
}
userList.removeAll(maleUsers);
将对象作为参数传递并重新赋值
看个例子:
这里涉及到JAVA中一个值传递和引用传递的概念。
对于一个引用类型而言,参数传递的时候,本质上是将一个引用对象对应内存地址传递过去,参数对象与实际对象指向同一个内存块。对于示例代码中的changeUser()
方法,将入参重新赋值了一个新的对象,本质上其实是将user1对应指向的内存地址信息更改了,对于原始的user而言,并没有被改变。
而对于chageAge()
方法而言,对user2的操作,本质上是对user2所指向的具体内存对象进行操作,也即user对应的内存对象数据,所以这种情况下,变更是会生效的。
所以呢,编码的时候,要注意不能在方法里面对入参进行重新赋值,可以采用返回值的方式返回个新的结果对象,然后进行赋值操作。
字母L的使用
先看一个例子:
同样相同的三个数字的相乘,L标识的位置不同,得到的结果也不一样,那到底哪个是对的呢?
很明显这个一个JAVA隐式类型转换的问题。
- 第一个结果显然是不对的,因为三个int值相乘之后结果明显超出int长度范围,所以截断了。
- 第二个结果,前面两个int相乘,在与第三个long型运算,结果会自动转换为long型,但是根据运算顺序,前面2个int值运算的中间结果也是int类型,且长度超出范围被截断了,截断后的结果与最后一位long进行的运算,所以第二个结果也是错的。
- 第三个结果是对的,因为long型放在前面,所以前两位运算的时候就先转为long型,再与第三个int运算后,结果依旧是long型,不会出现溢出的情况。
TIPS:
int运算转long的时候,最好将第一个运算的数字标识为L(long)型,避免中途数据溢出。
再看个例子:
public static void main(String[] args) {
System.out.println(2 * 2l);
}
第一眼看完,想一下答案应该是几?是42吗?
其实结果是4,为什么?因为第二个2后面的是个字母l
。虽然这种写法对于程序而言没有问题,但是很容易让开发人员混淆,造成认知上的错误。
TIPS:
long数字标识的时候,使用大写字母L
来表示。
流/资源的释放
打开的流或者连接,在用完之后需要可靠的退出。但是有一种循环中打开流的场景,需要特别注意,笔者在多年的代码review经历中发现,基本每个项目都会存在循环中打开的流没有全部可靠释放的问题。
上面的示例代码中,虽然最后finally里面也有执行流的关闭操作,但是try分支中,inputStream是在一个for循环里面被多次创建了,而最终finally分支中仅关闭了最后一个,之前的流都处于未关闭状态,造成资源的泄漏。
日期格式转换的并发场景
很多的项目中都使用SimpleDateFormat
来做日期的格式化操作,但是要注意SimpleDataFormat
是非线程安全的,所以使用的时候需要注意。
但是实际使用的时候,如果每次需要格式化的时候,都去new SimpleDateFormat()
对象,这个成本开销有点大,会对整体的性能造成一定的影响。
所以使用的时候可以采取一些措施,保证线程安全的同时也兼顾其处理性能:
事务失效场景
JAVA开发中,经常会使用Spring的@Transactional
注解来指定事务回滚的相关策略,但是有时候会发现@Transactional
并没有生效,下面介绍下可能的几种情况。
finally分支的数据处理
finally
分支一般伴随着try...catch
语句一起使用,用来当所有操作退出前执行一些收尾处理逻辑,比如资源释放、连接关闭等等。但是如果使用不当,也会造成我们的业务逻辑不按预期执行,所以使用的时候要注意。
finally分支中对返回值重新修改
先看下如下代码写法,在try...catch
分支中都有return操作,然后再finally中进行返回值修改,最终返回结果并不会被finally中的逻辑修改:
因为如果存在 finally 代码块,try...catch
中的return语句不会立马返回调用者,而是记录下返回值的副本,待 finally代码块执行完毕之后再向调用者返回其值,然后即使在finally中修改了返回值,也不会返回修改后的值。
再看另一种常见的写法:
与上面的差异点在于,try...catch
分支里面并没有return语句,而是finally外面统一执行返回操作,这种情况下就可以生效。其实也很好理解,try...catch...finally
这个语句块里面没有return操作,所以也就不会有暂存return副本的逻辑了。
finally分支中直接return
在finally分支里面存在return语句是一个很不好的实践。一般的IDEA中也会智能提示finally里面存return分支。
finally里面如果存在return分支,则finally里面的返回值会覆盖掉try...catch
逻辑中处理后计划返回的结果,也即导致try...catch
部分的逻辑失效,容易引起业务逻辑上的问题。
finally分支中抛出异常
一般的编码规范中,都会要求finally分支里面的处理逻辑要增加catch保护,防止其抛出异常。
原因说明:
相对而言,finally里面执行的都是一些资源释放类的操作,而try…catch部分则是业务维度的核心逻辑,人们更关心的是catch部分发生的业务层面的异常,如果finally里面抛出异常,会导致catch中原本应该要往外抛的异常被丢弃,可能会影响上层逻辑的后续处理。
全局变量中的集合类
全局类型的集合类,使用的时候需要注意两个关键点:
- 注意下并发场景的线程安全性;
- 注意下数据的最大范围、是否有数据淘汰机制,避免内存无限制增加,导致内存溢出。
参考下redis之类的依赖内存的缓存中间件,都有一个绕不开的兜底策略,即数据淘汰机制。对于业务类编码实现的时候,如果使用Map等容器类来实现全局缓存的时候,应该要结合实际部署情况,确定内存中允许的最大数据条数,并提供超出指定容量时的处理策略。比如我们可以基于LinkedHashMap来定制一个基于LRU策略的缓存Map,来保证内存数据量不会无限制增长。
public class FixedLengthLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
private static final long serialVersionUID = 1287190405215174569L;
private int maxEntries;
public FixedLengthLinkedHashMap(int maxEntries, boolean accessOrder) {
super(16, 0.75f, accessOrder);
this.maxEntries = maxEntries;
}
/**
* 自定义数据淘汰触发条件,在每次put操作的时候会调用此方法来判断下
*/
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxEntries;
}
}
浮点数据计算
看一段很简单的代码:
public static void main(String[] args) {
System.out.println(0.1 + 0.2);
}
上面的执行后,输出结果应该是多少?很明显应该是0.3
呀!然而实际上,运行之后,输出结果为:
0.30000000000000004
这是因为浮点数是不精确的,因为浮点数是存在小数点位的,而十进制的0.1
换算为二进制是一个无限循环小数,所以实际上存储的其实是一个近似0.1
的值、而不是精确的0.1
。
也正是这个原因,一般实现中,判断两个float是否相等时,往往不用==
,而是判断两个浮点数之差绝对值是否小于一个很小的数。
对于一些需要精确计算的场景,显然是不能使用浮点数来运算的,比如一些银行金融领域涉及金钱数额相关的场景,是绝对不允许使用float或者double进行运算,而是推荐使用BigDecimal
来替代。
使用Object作为HashMap的key
大家都知道在JAVA中,HashMap的key是不可以重复的,相同的key对应值会进行覆盖。但是,如果使用自定义对象作为HashMap的key,就要小心了,因为如果操作不当,很容易造成内存泄漏的问题。
如果一定要使用,确保此Object一定是覆写了hashCode()
和equals()
方法,并且保证覆写的equals
和hashCode
方法中一定不能有频繁易变更的字段参与计算。
结语
好啦,关于JAVA中常见的十大陷阱,这里就给大家分享到这里。希望大家在实际编码的时候可以注意提防,避免踩坑。
我是悟道,聊技术、又不仅仅聊技术~
如果觉得有用,请点个关注,也可以关注下我的公众号【架构悟道】,获取更及时的更新。
期待与你一起探讨,一起成长为更好的自己。