3

避坑手册 | JAVA编码中容易踩坑的十大陷阱 - 架构悟道

 1 year ago
source link: https://www.cnblogs.com/softwarearch/p/16427413.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
202206291737482.png

JAVA编码中存在一些容易被人忽视的陷阱,稍不留神可能就会跌落其中,给项目的稳定运行埋下隐患。此外,这些陷阱也是面试的时候面试官比较喜欢问的问题。

本文对这些陷阱进行了统一的整理,让你知道应该如何避免落入陷阱中,下面就一起来了解下吧。

202206262046724.png

循环中操作目标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异常。

202206281413920.png

错误写法2:


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

错误原因:

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

202206281413502.png

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

正确写法1:


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

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

202206281414514.png

正确写法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);

将对象作为参数传递并重新赋值

看个例子:

202206261906686.png

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

202206281415372.png

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

202206281416456.png

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

字母L的使用

先看一个例子:

202206261621216.png

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

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

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

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

再看个例子:

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

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

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

流/资源的释放

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

202206262052200.png

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

日期格式转换的并发场景

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

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

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

202206262127012.png

事务失效场景

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

202206262044889.png

finally分支的数据处理

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

202206262049128.png

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

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

202206262013779.png

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

再看另一种常见的写法:

202206262016926.png

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

finally分支中直接return

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

202206262021285.png

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中常见的十大陷阱,这里就给大家分享到这里。希望大家在实际编码的时候可以注意提防,避免踩坑。


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

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

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

gongzhonghao2.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK