代码扫描常见问题盘点-并发处理类/异常类

作者:FIN技术铺

文章分类:研发技术工具/SONAR扫描    投稿日期: 2024-7-19

SONAR系统基于项目配置的扫描规则识别交付代码当中存在的问题。本文针对日常扫描环节经常出现的问题进行梳理,盘点日常用户支持过程中发现典型案例,向用户解释为什么会被识别以及识别之后如何修改的问题。文章从风格&格式类、OOP类、集合类等典型场景进行归类,盘点各类场景下的典型问题,针对被规则命中的问题进行解释、基于示例说明原因并且给出解决办法。在与用户进行讨论过程当中,有意识就问题的根本原因进行分析记录,并摘选成文,供编码参考

01 并发处理类

1. 禁止在运行时显式创建线程;如果你需要通过线程来提升性能,请使用线程池

【解释】:在Java等编程语言中,显式创建线程是一种常见的做法,用于并发执行任务。但是,在实践中,过度使用显式线程创建可能会导致以下问题:

  1. 资源消耗:每个线程都需要分配一定的内存资源,如栈空间。大量线程的创建和销毁会导致资源消耗增加,甚至可能引发OutOfMemoryError。
  2. 性能下降:线程的创建和销毁都需要时间,如果频繁进行这些操作,会导致性能下降。
  3. 管理困难:大量的线程会增加线程管理的复杂性,容易导致线程间同步和通信的问题。

为了解决这些问题,推荐使用线程池来管理线程。线程池是一种用于管理和优化线程使用的技术。以下是使用线程池的一些优点:

  1. 资源复用:线程池中的线程可以被多个任务复用,避免了频繁创建和销毁线程带来的开销。
  2. 性能提升:由于线程的创建和销毁被减少,系统性能得到提升。
  3. 易于管理:线程池提供了一种统一的方式来管理线程,使得线程的同步和通信更加简单。

下面是一个使用Java中的ExecutorService(一种线程池实现)的示例:


import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;



public class ThreadPoolExample {

public static void main(String[] args) {

// 创建一个固定大小的线程池

ExecutorService executor = Executors.newFixedThreadPool(5);



// 提交任务给线程池执行

for (int i = 0; i < 10; i++) {

executor.submit(() -> {

// 在这里编写任务的代码

System.out.println("Task executed by " + Thread.currentThread().getName());

});

}



// 关闭线程池

executor.shutdown();

}

}

在这个示例中,我们创建了一个固定大小为5的线程池,然后提交了10个任务给线程池执行。由于线程池的大小是固定的,所以同时执行的任务数量最多为5个。当一个任务完成后,线程池会复用该线程来执行下一个任务,直到所有任务都被执行完毕。最后,我们调用shutdown方法来关闭线程池。

2. 禁止在运行时显式创建线程池;线程池应该是一个公共对象,并在项目启动时初始化

【解释】:在软件开发中,避免在运行时显式创建线程池,并将其作为一个公共对象在项目启动时初始化,是一个较好的实践,理由如下:

  1. 资源管理:
    • 在运行时频繁地创建和销毁线程池会导致资源的不必要消耗。线程池的创建涉及到分配内存和其他系统资源,这些操作都是有成本的。
    • 通过在项目启动时初始化线程池,可以确保资源被有效地管理和复用,从而减少资源浪费。
  2. 性能优化:
    • 线程池的创建和销毁都有一定的时间成本。如果每次需要执行并发任务时都创建一个新的线程池,这会导致性能下降。
    • 使用预先初始化的线程池可以避免这种性能开销,因为线程池中的线程可以被多个任务复用。
  3. 配置一致性:
    • 如果在代码的多个地方都显式创建线程池,很难确保所有线程池的配置都是一致的。这可能会导致系统行为的不一致性和不可预测性。
    • 通过使用公共的、预先配置的线程池对象,可以确保整个应用程序中使用的是相同配置的线程池,从而提高系统的稳定性和可维护性。
  4. 易于监控和调试:
    • 当线程池是公共对象时,它更容易被监控和管理。可以更容易地跟踪线程池的状态、当前活动的线程数、队列中的任务数等。
    • 这有助于在出现问题时快速定位和解决问题。

以下是一个示例,说明如何在项目启动时初始化一个公共的线程池对象:


import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;



public class Application {

// 公共的线程池对象

public static ExecutorService executor;



public static void main(String[] args) {

// 在项目启动时初始化线程池

executor = Executors.newFixedThreadPool(5);



// ... 其他应用程序代码 ...

}

}

在这个示例中,Application 类有一个公共的静态 ExecutorService 对象 executor。在项目启动时(在 main 方法中),我们初始化这个 executor 对象为一个固定大小的线程池。之后,应用程序中的其他部分可以使用这个公共的 executor 对象来提交任务,而不需要自己创建新的线程池。

3. 禁止使用JDK的Executors创建线程池,应该通过ThreadPoolExecutor的方式创建

【解释】:在Java中,java.util.concurrent.Executors 类提供了一些便捷的工厂方法来创建线程池。然而,在实际生产环境中,通常推荐使用 ThreadPoolExecutor 类直接创建线程池,而不是使用 Executors 类。以下是几个原因:

  1. 可配置性:
    • 使用 ThreadPoolExecutor 可以更灵活地配置线程池的各种参数,如核心线程数、最大线程数、线程空闲时间、任务队列等。
    • 相比之下,Executors 提供的工厂方法创建的线程池往往具有固定的配置,可能无法满足特定的性能或资源需求。
  2. 资源控制:
    • Executors 的某些方法(如 newCachedThreadPool)创建的线程池可能会无限制地创建新线程,这在高负载情况下可能导致资源耗尽。
    • 通过 ThreadPoolExecutor,你可以明确设置最大线程数,从而防止系统资源的过度消耗。
  3. 队列选择:
    • ThreadPoolExecutor 允许你选择不同的任务队列实现,如 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue 等。
    • 正确的队列选择对于线程池的性能和行为至关重要。例如,一个有界队列可以帮助防止任务堆积,而无界队列在不当使用时可能导致内存耗尽。
  4. 线程工厂:
    • ThreadPoolExecutor 允许你提供一个自定义的 ThreadFactory,这可以用来创建具有特定属性(如名称、优先级、守护状态等)的线程;这有助于更好地管理和监控线程。
  5. 拒绝策略:
    • 当线程池无法处理更多任务时(例如,队列已满且所有线程都在工作),它需要一个策略来处理新提交的任务。
    • ThreadPoolExecutor 允许你指定一个 RejectedExecutionHandler 来处理这种情况,而 Executors 的默认策略可能不适合所有用例。

以下是一个使用 ThreadPoolExecutor 创建线程池的示例:


import java.util.concurrent.*;



public class CustomThreadPoolExample {

public static void main(String[] args) {

// 创建一个具有自定义配置的 ThreadPoolExecutor

ThreadPoolExecutor executor = new ThreadPoolExecutor(

5, // 核心线程数

10, // 最大线程数

60L, // 线程空闲时间(秒)

TimeUnit.SECONDS, // 时间单位

new ArrayBlockingQueue<>(100), // 任务队列

new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略       

);



// 使用线程池执行任务...

}

}

在这个示例中,我们创建了一个具有自定义配置的 ThreadPoolExecutor。我们指定了核心和最大线程数、线程空闲时间、任务队列以及一个拒绝策略。这种方式的创建提供了更大的灵活性和控制力,使得线程池能更好地适应应用程序的需求。

4. 调度线程池(ScheduledThreadPoolExecutor)中的周期性任务要捕获Throwable异常

【解释】:在Java的 ScheduledThreadPoolExecutor 中调度周期性任务时,捕获 Throwable 异常是非常重要的。以下是几个原因:

  1. 防止任务终止:如果周期性任务抛出未捕获的异常,该任务可能会被提前终止,导致后续的计划执行被取消。通过捕获 Throwable,你可以确保即使任务出现异常,也不会中断其周期性执行。
  2. 资源泄露和系统稳定性:未捕获的异常可能导致资源泄露或其他不稳定行为。例如,如果异常导致线程终止,而线程持有某些资源(如数据库连接或文件句柄),则这些资源可能不会被适当释放。
  3. 错误处理和恢复:通过捕获异常,你可以实现自定义的错误处理逻辑。例如,你可能希望在出现异常时记录错误、通知管理员或尝试以不同的方式执行任务。
  4. 透明度和可诊断性:捕获并记录异常可以提高系统的可诊断性。通过查看日志或其他记录,你可以更容易地识别和解决问题。

以下示例,说明如何在 ScheduledThreadPoolExecutor 中捕获 Throwable 异常:

import java.util.concurrent.*;



public class ScheduledTaskExample {

public static void main(String[] args) {

ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);



Runnable task = () -> {

try {

// 模拟一个可能抛出异常的任务

riskyOperation();

} catch (Throwable t) {

// 捕获并处理异常

System.err.println("Caught exception in scheduled task: " + t);

}

};



// 安排周期性任务

executor.scheduleAtFixedRate(task, 0, 10, TimeUnit.SECONDS);

}



private static void riskyOperation() throws Exception {

// 这里模拟一个可能抛出异常的操作

throw new Exception("Simulated exception in risky operation");

}

}

在这个示例中,我们创建了一个 ScheduledThreadPoolExecutor 并安排了一个周期性任务。任务中调用了 riskyOperation() 方法,该方法模拟了一个可能抛出异常的操作。通过使用 try-catch 块,我们捕获了任何可能由 riskyOperation() 方法抛出的 Throwable 异常,并打印了相应的错误消息。这样做可以确保即使出现异常,周期性任务也会继续执行,并且异常会被适当地处理。

5. JDK中基于AQS的并发对象ReentrantLock和CountDownLatch要在finally代码块中执行其逆向操作

【解释】:在JDK中,ReentrantLock 和 CountDownLatch 是基于 AbstractQueuedSynchronizer(AQS)的并发对象。当使用这些对象时,通常需要在 finally 代码块中执行其逆向操作。原因如下:

  1. 保证资源释放:无论是 ReentrantLock 的 lock() 和 unlock() 方法,还是 CountDownLatch 的 countDown() 方法,它们都涉及到对共享资源的操作。如果在获取资源后发生异常,而没有在 finally 块中释放资源,可能会导致资源泄露或其他线程无法正确访问资源。
  2. 异常安全性:在Java中,finally 块中的代码总是会被执行,无论 try 块中是否发生异常。因此,在 finally 块中释放资源可以确保资源总是被适当地管理,即使在异常情况下也是如此。
  3. 避免死锁:对于 ReentrantLock,如果在获取锁后没有在 finally 块中释放锁,可能会导致死锁。其他等待该锁的线程将永远被阻塞,因为锁永远不会被释放。
  4. 维持程序的正确性:对于 CountDownLatch,countDown() 方法用于减少计数器的值。如果在使用 CountDownLatch 时没有正确处理异常,可能会导致计数器的值不正确,从而影响程序的逻辑和并发行为。

以下是使用 ReentrantLock 的示例,说明为什么需要在 finally 块中释放锁:


import java.util.concurrent.locks.ReentrantLock;



public class ReentrantLockExample {

private final ReentrantLock lock = new ReentrantLock();



public void performTask() {

lock.lock(); // 获取锁

try {

// 执行任务...

} finally {

lock.unlock(); // 在finally块中释放锁

}

}

}

在这个示例中,我们在 try 块中获取了锁,并在 finally 块中释放了锁。这样做可以确保无论 try 块中的代码是否成功执行或抛出异常,锁总是会被释放。

同样地,对于 CountDownLatch,也需要在适当的位置(通常是在 finally 块中)调用 countDown() 方法来确保计数器的正确管理。

6. ThreadLocal对象用完必须回收,否则可能造成内存泄露

【解释】:ThreadLocal 是 Java 提供的一个线程局部变量,它可以让每个线程都拥有自己的变量副本,这样就避免了多个线程之间共享变量的问题。然而,如果不正确使用 ThreadLocal,确实可能导致内存泄露。

以下是使用 ThreadLocal 可能导致内存泄露的原因:

  1. 引用链持续存在:当一个 ThreadLocal 变量被设置后,它会被当前线程持有。如果线程持续运行并不终止,那么 ThreadLocal 变量也不会被垃圾收集。特别是,在 Web 容器(如 Tomcat)中的线程池,线程可能会长时间存在,导致 ThreadLocal 变量持续存在。
  2. 无界的数据增长:如果代码持续地往 ThreadLocal 里放数据,但没有删除,那么数据会持续增长。例如,每次请求都往 ThreadLocal 里放数据,但请求结束后没有清理,那么随着请求的增加,内存消耗也会增加。
  3. 意外的线程共享:有时开发者可能误以为 ThreadLocal 的值是跨线程共享的,从而在不应该的地方使用它。这可能导致意外的对象保留和内存泄露。
  4. 静态引用:如果 ThreadLocal 被声明为静态的,并且其生命周期比应用本身还要长,那么它的生命周期就和应用的生命周期绑定了。这意味着,只要应用还在运行,ThreadLocal 就不会被垃圾收集。

以下是一个可能导致内存泄露的示例:


import java.util.concurrent.locks.ReentrantLock;



public class ReentrantLockExample {

private final ReentrantLock lock = new ReentrantLock();



public void performTask() {

lock.lock(); // 获取锁

try {

// 执行任务...

} finally {

lock.unlock(); // 在finally块中释放锁

}

}

}





public class MemoryLeakExample {

private static ThreadLocal threadLocal = new ThreadLocal<>();



public void process() {

Object bigObject = new Object(); // 假设这是一个大对象

threadLocal.set(bigObject);

// ... 执行一些操作 ...

// 忘记清除 threadLocal

}

} 
  

在上述代码中,如果 process 方法被频繁调用,并且每次调用都创建一个大对象并将其放入 threadLocal 中,但从未清除,那么内存消耗会迅速增长。

为了避免这种情况,最佳实践是:

  1. 始终在使用完 ThreadLocal 后调用其 remove() 方法,确保数据被清除。
  2. 避免在长时间运行的线程(如线程池中的线程)中使用 ThreadLocal,除非你能确保适当地管理其生命周期。
  3. 考虑使用 try-finally 结构来确保清理,这样即使在出现异常的情况下也能保证资源的释放。

7. volatile仅能保证变量的可见性,不能完全保证线程安全;不要用来替代锁的使用场景

【解释】:volatile是Java中提供的一种轻量级的同步机制,用于确保多线程之间变量的可见性。当一个共享变量被volatile修饰时,它会保证所有线程看到这个变量的值是一致的。然而,尽管volatile有这样的特性,但它并不能完全保证线程安全。以下是几个原因:

  1. 原子性问题:volatile关键字能保证可见性但不能保证原子性。原子性意味着一个操作或者一组操作要么全部完成,要么全部不完成,并且操作在执行过程中不会被线程调度机制打断。例如,对于volatile int count,count++这个操作并不是原子的,它包括读取变量的原始值、进行加1操作、写回新的值这三个步骤。在多线程环境下,有可能出现一个线程在读取原始值后、写回新值前被暂停,另一个线程进行了加1操作,导致结果出现错误。
  2. 复合操作问题:对于多个volatile变量的复合操作,volatile关键字并不能保证这些操作的原子性。例如,检查一个范围或者更新两个相关的volatile变量等操作,这些都需要额外的同步措施来保证线程安全。
  3. 顺序性问题:虽然volatile关键字禁止了指令重排序优化,但仍无法解决一些复杂的顺序问题。比如一个线程先写了一个volatile变量,然后再读另一个volatile变量,另一个线程可能先读了第二个volatile变量,然后再读第一个volatile变量,这种情况下就无法保证线程安全。

举个例子来说:


public class Counter {

public volatile int count = 0;



public void increment() {

count++; // 非原子操作

}

}

在上面的例子中,如果有多个线程同时调用increment()方法,那么最终的结果可能会小于实际调用的次数,因为count++这个操作并不是原子的。

因此,虽然volatile在某些场合下可以提供一定程度的线程安全保证,但在涉及复杂操作或需要保证原子性的情况下,应该使用更为强大的同步机制,如synchronized关键字或Lock接口等。

02 异常日志类

1. 采用占位符的方式记录日志,禁止使用字符串拼接

【解释】:在Java中记录日志时,使用占位符而不是字符串拼接有以下几个主要原因:

  1. 性能:字符串拼接涉及创建新的字符串对象,这在内存和性能方面是昂贵的,特别是当日志级别被设置为不打印该条日志时。使用占位符可以避免不必要的字符串拼接操作。
  2. 可读性:占位符通常使日志消息更具可读性。例如,使用logger.debug("Processing user with ID: {}", userId);比使用字符串拼接更清晰。
  3. 延迟计算:有时,日志的参数计算可能是昂贵的。使用占位符可以确保只有在真正需要记录日志时才计算这些参数。
  4. 格式化:占位符提供了一种简洁的方式来格式化日志消息。例如,可以轻松地控制数字的格式、日期的格式等。
  5. 避免敏感数据泄露:在某些情况下,直接拼接字符串可能导致敏感数据(如密码或密钥)被意外记录。使用占位符可以更安全地处理这些数据。
  6. 支持不同的日志框架:大多数现代的日志框架(如SLF4J, Log4j, Logback等)都支持占位符语法,使其成为一种通用和可移植的做法。

举一个例子来说明为什么应该避免字符串拼接:


// 不推荐的方式:字符串拼接

if (logger.isDebugEnabled()) {

logger.debug("Processing user with ID: " + userId);

}



// 推荐的方式:使用占位符

logger.debug("Processing user with ID: {}", userId);

在第一个例子中,即使isDebugEnabled()返回false,字符串拼接仍然会发生,这是浪费的。而在第二个例子中,只有当debug级别的日志被启用时,才会进行必要的字符串格式化操作。

2. debug级别的日志,应使用isDebugEnabled()作为日志输出的判断条件

【解释】:在Java的日志记录中,使用isDebugEnabled()作为debug级别日志输出的判断条件是一种推荐的做法。以下是使用isDebugEnabled()的原因和示例:

【原因】:

  1. 性能优化:日志记录,特别是当涉及到字符串拼接或复杂对象转换时,可能会带来一定的性能开销。如果debug级别的日志未启用(通常在生产环境中是禁用的),那么通过使用isDebugEnabled()作为条件,可以避免不必要的日志处理和相关性能开销。
  2. 可读性和代码清晰度:使用isDebugEnabled()可以使日志记录代码更加清晰和易于理解。它明确表明只有在debug级别启用时才执行特定的日志记录逻辑。
  3. 灵活性:通过使用isDebugEnabled(),可以轻松地启用或禁用debug级别的日志,而无需修改实际的日志记录语句。这对于调试和性能分析非常有用,因为你可以根据需要快速切换日志级别。

【示例】:

假设你有一个方法,该方法处理用户请求并可能记录一些调试信息:

import org.slf4j.Logger;

import org.slf4j.LoggerFactory;



public class UserService {

private static final Logger logger = LoggerFactory.getLogger(UserService.class);



public void processUserRequest(User user) {

// ... 处理用户请求的逻辑 ...



if (logger.isDebugEnabled()) {

logger.debug("Processing user request for user: {}", user);

}



// ... 更多的处理逻辑 ...

}

}

在上面的示例中,通过检查isDebugEnabled()的返回值,我们可以确保只有在debug级别启用时才执行logger.debug()语句。这样可以避免在不需要时执行日志记录代码,从而提高性能。同时,这也使得代码更加清晰,明确地表明了这是调试级别的日志记录。

03 结语

限于篇幅原因,问题盘点计划按照多个批次内容进行输出,针对Sonar用户日常扫描过程当中识别的5大类场景常见的问题进行盘点,针对问题的原因进行分析总结,结合具体实现案例帮忙大家理解并且在日常的工作中规避这些问题。

你可能感兴趣的:(python,java,开发语言)