避坑手册 | JAVA编码中容易踩坑的十大陷阱

避坑手册 | JAVA编码中容易踩坑的十大陷阱

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,导致在迭代实例中expectedModCountmodCount的值不相等,抛出ConcurrentModificationException异常。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

错误写法2:

 for (int i = 0; i < userList.size(); i++>) {     if ("男".equals(user.getSex())) {         userList.remove(i);     } } 

错误原因:

删除元素之后,元素下标发生前移,但是指针是不变的,再处理下一个的时候,就可能会有部分元素被漏掉没有处理。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

那正确的方式应该如何处理呢?接着往下看。

正确写法1:

 // 使用迭代器来实现 Iterator iterator = userList.iterator(); while (iterator.hasNext()) {     if ("男".equals(user.getSex())) {         iterator.remove();     } }  

补充说明:
与前面的错误写法1相对比,同样都是基于迭代器的逻辑,为什么这种写法就对了呢?
这是因为迭代器中的remove()add()方法,会在调用集合的remove()add()方法后,将expectedModCount重新赋值为modCount,所以在迭代器中增加、删除元素是可以正常运行的。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

正确写法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编码中容易踩坑的十大陷阱

这里涉及到JAVA中一个值传递和引用传递的概念。
对于一个引用类型而言,参数传递的时候,本质上是将一个引用对象对应内存地址传递过去,参数对象与实际对象指向同一个内存块。对于示例代码中的changeUser()方法,将入参重新赋值了一个新的对象,本质上其实是将user1对应指向的内存地址信息更改了,对于原始的user而言,并没有被改变。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

而对于chageAge()方法而言,对user2的操作,本质上是对user2所指向的具体内存对象进行操作,也即user对应的内存对象数据,所以这种情况下,变更是会生效的。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

所以呢,编码的时候,要注意不能在方法里面对入参进行重新赋值,可以采用返回值的方式返回个新的结果对象,然后进行赋值操作。

字母L的使用

先看一个例子:

避坑手册 | JAVA编码中容易踩坑的十大陷阱

同样相同的三个数字的相乘,L标识的位置不同,得到的结果也不一样,那到底哪个是对的呢?

很明显这个一个JAVA隐式类型转换的问题。

  • 第一个结果显然是不对的,因为三个int值相乘之后结果明显超出int长度范围,所以截断了。
  • 第二个结果,前面两个int相乘,在与第三个long型运算,结果会自动转换为long型,但是根据运算顺序,前面2个int值运算的中间结果也是int类型,且长度超出范围被截断了,截断后的结果与最后一位long进行的运算,所以第二个结果也是错的。
  • 第三个结果是对的,因为long型放在前面,所以前两位运算的时候就先转为long型,再与第三个int运算后,结果依旧是long型,不会出现溢出的情况。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

TIPS:
int运算转long的时候,最好将第一个运算的数字标识为L(long)型,避免中途数据溢出。

再看个例子:

    public static void main(String[] args) {         System.out.println(2 * 2l);     } 

第一眼看完,想一下答案应该是几?是42吗?
其实结果是4,为什么?因为第二个2后面的是个字母l。虽然这种写法对于程序而言没有问题,但是很容易让开发人员混淆,造成认知上的错误。

TIPS:
long数字标识的时候,使用大写字母L来表示。

流/资源的释放

打开的流或者连接,在用完之后需要可靠的退出。但是有一种循环中打开流的场景,需要特别注意,笔者在多年的代码review经历中发现,基本每个项目都会存在循环中打开的流没有全部可靠释放的问题。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

上面的示例代码中,虽然最后finally里面也有执行流的关闭操作,但是try分支中,inputStream是在一个for循环里面被多次创建了,而最终finally分支中仅关闭了最后一个,之前的流都处于未关闭状态,造成资源的泄漏。

日期格式转换的并发场景

很多的项目中都使用SimpleDateFormat来做日期的格式化操作,但是要注意SimpleDataFormat是非线程安全的,所以使用的时候需要注意。

但是实际使用的时候,如果每次需要格式化的时候,都去new SimpleDateFormat()对象,这个成本开销有点大,会对整体的性能造成一定的影响。

所以使用的时候可以采取一些措施,保证线程安全的同时也兼顾其处理性能:

避坑手册 | JAVA编码中容易踩坑的十大陷阱

事务失效场景

JAVA开发中,经常会使用Spring的@Transactional注解来指定事务回滚的相关策略,但是有时候会发现@Transactional并没有生效,下面介绍下可能的几种情况。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

finally分支的数据处理

finally分支一般伴随着try...catch语句一起使用,用来当所有操作退出前执行一些收尾处理逻辑,比如资源释放、连接关闭等等。但是如果使用不当,也会造成我们的业务逻辑不按预期执行,所以使用的时候要注意。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

finally分支中对返回值重新修改

先看下如下代码写法,在try...catch分支中都有return操作,然后再finally中进行返回值修改,最终返回结果并不会被finally中的逻辑修改:

避坑手册 | JAVA编码中容易踩坑的十大陷阱

因为如果存在 finally 代码块,try...catch中的return语句不会立马返回调用者,而是记录下返回值的副本,待 finally代码块执行完毕之后再向调用者返回其值,然后即使在finally中修改了返回值,也不会返回修改后的值。

再看另一种常见的写法:

避坑手册 | JAVA编码中容易踩坑的十大陷阱

与上面的差异点在于,try...catch分支里面并没有return语句,而是finally外面统一执行返回操作,这种情况下就可以生效。其实也很好理解,try...catch...finally这个语句块里面没有return操作,所以也就不会有暂存return副本的逻辑了。

finally分支中直接return

在finally分支里面存在return语句是一个很不好的实践。一般的IDEA中也会智能提示finally里面存return分支。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

finally里面如果存在return分支,则finally里面的返回值会覆盖掉try...catch逻辑中处理后计划返回的结果,也即导致try...catch部分的逻辑失效,容易引起业务逻辑上的问题。

finally分支中抛出异常

一般的编码规范中,都会要求finally分支里面的处理逻辑要增加catch保护,防止其抛出异常。

原因说明:

相对而言,finally里面执行的都是一些资源释放类的操作,而try...catch部分则是业务维度的核心逻辑,人们更关心的是catch部分发生的业务层面的异常,如果finally里面抛出异常,会导致catch中原本应该要往外抛的异常被丢弃,可能会影响上层逻辑的后续处理。

全局变量中的集合类

全局类型的集合类,使用的时候需要注意两个关键点:

  1. 注意下并发场景的线程安全性;
  2. 注意下数据的最大范围、是否有数据淘汰机制,避免内存无限制增加,导致内存溢出。

参考下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()方法,并且保证覆写的equalshashCode方法中一定不能有频繁易变更的字段参与计算。

结语

好啦,关于JAVA中常见的十大陷阱,这里就给大家分享到这里。希望大家在实际编码的时候可以注意提防,避免踩坑。


我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点个关注,也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。

避坑手册 | JAVA编码中容易踩坑的十大陷阱

发表评论

相关文章