专业在线打字练习软件-巧手打字通,只输出有价值的知识。

前言

本次巧手打字通课堂将和您一起聊一聊,java程序员日常开发过程中最容易踩坑的10大致命陷阱。

1 资源泄漏风险

在Java中,对文件或任何需要手动关闭的资源(如数据库连接、文件输入/输出流等)进行操作时,确实需要确保资源在使用完毕后被正确释放,以防止资源泄露。

  • 需要手动释放的资源及链接时,要在finally中进行了释放,防止抛出异常,导致资源释放的逻辑没有走到,导致资源泄漏;
try{
    //io操作:InputStream,Socket等
    //out.flush();
}catch(XxxException e){
    //异常处理
    throw e;
}finally{
    if(in != null){
        in.close();
    }
}
  • Java 7 引入了 try-with-resources 语句,这是一个自动管理资源的非常便利的特性,它确保每个资源在语句结束时都会自动关闭,无论是正常结束还是由于异常而结束。
try (
        InputStream fis = new FileInputStream(source);
        OutputStream fos = new FileOutputStream(target)) {
} catch (Exception e) {
    e.printStackTrace();
}

具体实现是对于实现AutoCloseable接口的类的实例,将其放到try后面,在try结束的时候,会自动将这些资源关闭(调用close方法)。

2 空指针异常(NPE)问题

在程序抛出的所有异常里面,空指针异常应该是最常见的一类了。具体示例如下:

public static void main(String[] args) {
    // 异常示例1:空值比较
    String nullString = null;

    if (nullString.equals("targetString")) {
        // 这里建议使用:Objects.equals(nullString,"targetString")或者"targetString".equals(nullString)两种方式
    }
    
    // 异常示例2:自动拆包
    Integer nullInt = null;
    // do something
    int num = nullInt;// 自动拆包,导致出现异常

    // 异常示例3:容器规则
    Map<String,String> map = new ConcurrentHashMap();
    map.put(nullString,"");// 不允许key为null

    // 异常示例4:异常数据
    List<Integer> haveNullElementList = new ArrayList();
    haveNullElementList.add(1);
    haveNullElementList.add(null);
    for (int i : haveNullElementList) { // 没有空值判断,导致异常
    }
}
任何NPE问题,都由使用者来保证。原因是在开发过程中,很难约束上游严格按照各种约定执行。

3 死循环、递归调用风险

java中的方法指令运行栈上,栈的大小一般在1-2M左右(取决于操作系统和JVM)。如果方法执行出现了死循环或者递归函数没有出口,不管栈空间多大,栈溢出是必然的。

public class Test {
    private static long loop(int n) {
        return n * loop(n - 1);
    }
    public static void main(String[] args) {
        loop(100000);
    }
}

以上示例代码就会抛出:Exception in thread "main" java.lang.StackOverflowError。递归层数比较多的场景下,建议使用循环替代递归。

4 数值越界风险

  • 首先,使用Integer时要知道,虽然-128到127之间的值因为IntegerCache的缓存机制会复用对象,可以用==来比较它们是否相等,但超出这个范围后,每次都会在堆上创建新的Integer实例,这时就不能再用==来比较了。为了避免出现潜在的逻辑错误,无论在什么情况下,还是建议统一使用equals方法来进行数值比较。
  • 其次,设计业务系统的主键生成策略时,特别是选择int类型作为主键时,得仔细考虑其最大值(Integer.MAX_VALUE)的限制。你得确保业务增长不会碰到这个天花板,否则数据可能会溢出,或者出现类型不匹配的问题。
  • 再者,数据库设计与Java对象之间的映射也是个需要注意的点。如果数据库中的id字段是bigint unsigned类型,但Java类里对应的属性却是Integer类型,那么随着数据越来越多,id的值有可能会超过Integer能表示的范围,变成负数。这听起来就很糟糕,对吧?所以,要确保Java类中的属性类型能和数据库字段类型相匹配,比如用Long类型来安全地存储大范围的整数。
  • 最后,在电商或金融类系统中,订单价格的计算绝对是个重头戏。如果计算逻辑出错,或者精度处理不当,价格可能会变成负数,这不仅会让用户感到困惑和不满,还可能引发严重的商业问题。因此,在开发这类系统时,一定要非常仔细地检查和测试价格计算的逻辑和精度处理,确保无论什么情况下都能得到准确无误的结果。

5 数值精度丢失风险

当我们直接使用double或float这样的浮点类型参数进行数值计算时,有时会遇到计算结果不够精确的问题,这主要是因为计算机内部是基于二进制系统工作的,而并非所有的浮点数都能被二进制完美地精确表示。这种表示上的局限性,就导致了在计算过程中可能会出现精度丢失的情况。简单来说,就是计算机在处理这些浮点数时,有时候无法完全准确地存储或计算它们,从而导致了结果的不精确。

public class Test {
    public static void main(String[] args) {
        System.out.println(0.06+0.01); // 精度丢失,结果:0.06999999999999999
        System.out.println(303.1/1000); // 精度丢失,结果:0.30310000000000004
    }
}

这在计算订单金额,描述产品重量等关键业务场景是不能够接受的。在大多数的商业计算中,一般采用java.math.BigDecimal 类来进行精确计算。

在使用BigDecimal时,建议使用BigDecimal(String var) 的String参数构造方法来构建对象。不要用BigDecimal(double var)的double参数的构造方法。这是因为0.1无法准确地表示为 double类型。

6 对象拷贝导致的风险

在业务实现的过程中,对象的拷贝或转换是很常见的应用场景。对象拷贝实现方式有两种,一种是通过原生支持的Cloneable接口实现,另一种是自定义实现。

对于实现了Cloneable接口的方案,以下几个陷阱:

  1. 这种方式并不是通常所理解的深拷贝,而是浅拷贝;
  2. 浅拷贝的一个主要问题是新的对象和原对象共享实例变量的值。原对象对共享实例变量对象的引用被修改了,那么新对象也会受到影响;

对于自定义实现对象的拷贝,需要注意一下几点:

  1. 序列化(Serialization):将对象序列化到一个字节流中,然后再从字节流中反序列化出新的对象。陷阱是必须有可序列化的接口(Serializable),而且如果对象中包含不可序列化的成员变量,那么在序列化过程中就可能抛出异常;
  2. 非序列化对象的深拷贝,需要自己实现深拷贝。如果对象之间存在循环引用,那么在进行深拷贝时,可能会出现无限递归的问题;

除此之外,对象拷贝还需要考虑性能问题,深拷贝操作可能会非常消耗性能,特别是在处理大型对象时。因此在需要频繁进行深拷贝的场景下,可能需要使用其他方式进行处理。

7 编码格式导致的风险

相信吗?编码习惯和风格的不统一往往也会导致bug。最典型的就是逻辑执行语句后面是否可以省略大括号,看下面示例:

public class Test {
    public static void main(String[] args) {
        int random = new Random().nextInt(10);
        if (random > 5)
            random = random - 5;
        random = random * 2;
        // 以上在语句,if作用域里面和外面的计算逻辑,将会大不相同,而这种问题往往不太容易被人察觉是真实的逻辑就这样,还是写出来了bug
    }
}

与示例代码中类似的还有for,switch default语句,都有编码风格导致程序逻辑出现问题的情况。最好都使用大括号进行作用域的圈定。

8 集合容器并发与性能风险

如果多个线程同时修改同一个集合或映射,可能会导致数据的不一致性,出现数据丢失或者不可预知的行为。所以,多个线程操作同一个Collcetion或Map时务必要保证线程安全!

比如在有并发场景下的使用的对象容器,可以选择实现了线程安全的ConcurrentHashMap,CopyOnWriteArrayList等。

另外,也要关注容器的实现原理,以方便我们进行性能优化,看下面例子。

arrayList.removeAll(set)的速度远高于arrayList.removeAll(list)。

这是因为前者的删除,使用了HashSet的contain方法,复杂度是O(1),后者使用的是循环遍历,复杂度是O(n),在要删除的数据比较多的情况下,性能差距就会变得非常明显。知道了它们之间的差距了,就可以写出更好的实现了,可以将subList封装为HashSet:arrayList.removeAll(new HashSet(subList))。

同样的道理,在进行HashMap的创建时,为什么建议指定initialCapacity大小呢?这是因为我们往容器里添加元素时,数组的大小会自动增长。数组的大小每次增长都导致内存重新分配和复制,那么就会带来性能的开销。预先合理设置HashMap的大小,就可以避免这种开销。

9 集合容器操作异常风险

  1. 对于Arrays.asList,Collections.EMPTY_LIST等方法,不要在有调整数组对象的场景下使用。举例:
List<String> list = Arrays.asList("a","b","c");
list.add("d");// 错误:原因是Arrays.asList返回的List对象是一个内部类,其实现不支持数组的更新操作。
// 如果非要进行更新操作,可以使用ArrayList包装一下;
list = new ArrayList<>(list);
  1. 不能在遍历Collection,Map容器的过程中,对容器内的元素进行新增或删除操作。
public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("a");
    list.add("b");
    // 错误用例
    for (String t : list) {
        if ("b".equals(t)) {
            list.remove(t);// 错误(ConcurrentModificationException):不能在遍历的过程中对数组进行新增或删除操作
        }
    }
    // 正常用例
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        String t = iterator.next();
        if ("b".equals(t)) {
            iterator.remove();// 可以使用迭代器方式进行操作
        }
    }
}

10 ThreadLocal使用风险

1. 内存泄漏风险

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。

  1. 使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致的内存泄漏。
  2. 分配使用了ThreadLocal又不再调用get(),set(),remove()方法,那么就会导致内存泄漏。

避免内存泄露解决方案:ThreadLocal要在接口结束时,通过finally进行remove操作,可以防止内存泄漏;

2. ThreadLocal跨线程传递问题

ThreadLocal的子类InheritableThreadLocal可以实现父子线程之间的数据传递,但它仅适用于 new Thread 手动创建线程的时候!实际上,日常更多的是使用线程池,线程池里面的线程都预创建好的,就没法直接用InheritableThreadLocal了。

如何往线程池内的线程传递 ThreadLocal?

JDK的类库没提供这个功能,可以自己实现或者使用第三方库TransmittableThreadLocal实现跨线程参数传递。

总结

本文探讨了软件开发中常见的十大风险,涵盖了从资源管理到并发编程的多个方面。

  • 强调了资源泄漏风险,提醒开发者需妥善管理资源以避免内存泄露等问题。
  • 空指针异常(NPE)作为常见错误之一,要求编程时进行充分的空值检查。
  • 还指出了死循环和递归调用可能导致的性能瓶颈甚至程序崩溃风险。
  • 数值处理方面,数值越界和精度丢失风险不容忽视,需根据具体场景选择合适的数据类型。
  • 对象拷贝时需注意浅拷贝与深拷贝的区别,避免数据共享引发的问题。
  • 编码格式不一致也可能带来业务逻辑的歧义和错误,统一代码发风格还是很有必要的。
  • 集合容器的并发访问和不当操作则可能导致数据不一致或性能下降。
  • ThreadLocal的滥用也可能导致内存泄漏等风险,需谨慎使用。

最后,希望本文对读者有所启发和帮助。


用户bPdd2O9
6 声望0 粉丝