阿里巴巴Java开发规范——编程规约(4)
编程规约
(六)并发处理
1. 【强制】获取单例对象需要保证线程安全,其中的方法也要保证线程安全。
说明:资源驱动类、工具类、单例工厂类都需要注意。
2. 【强制】创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。
正例:
public class TimerTaskThread extends Thread {public TimerTaskThread() {super.setName("TimerTaskThread");...}
}
3. 【强制】线程资源必须通过线程池供,不允许在应用中自行显式创建线程。
说明:使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决
资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或
者“过度切换”的问题。
4. 【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
说明:Executors 返回的线程池对象的弊端如下:
1)FixedThreadPool 和SingleThreadPool:
允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2)CachedThreadPool 和ScheduledThreadPool:
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
虽然Executors
类提供的便捷方法(如newFixedThreadPool
, newCachedThreadPool
等)易于使用,但它们往往隐藏了配置细节,可能导致资源耗尽或性能问题。因此,直接使用ThreadPoolExecutor
来创建线程池能提供更细粒度的控制和更高的灵活性,是推荐的做法。
正例:使用ThreadPoolExecutor创建线程池
下面是使用ThreadPoolExecutor
直接创建线程池的一个示例,这样可以明确指定线程池的参数,如核心线程数、最大线程数、空闲线程存活时间、任务队列类型及容量等,从而更好地控制资源使用和性能表现。
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.LinkedBlockingQueue;public class CustomThreadPoolExample {public static void main(String[] args) {// 核心线程数int corePoolSize = 5;// 最大线程数int maximumPoolSize = 10;// 空闲线程存活时间long keepAliveTime = 60L;// 时间单位TimeUnit unit = TimeUnit.SECONDS;// 任务队列,这里使用无界队列,可根据实际情况选择有界队列如ArrayBlockingQueue以避免资源耗尽LinkedBlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>();// 创建线程池ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize,maximumPoolSize,keepAliveTime,unit,workQueue);// 设置线程池拒绝策略,当队列满且线程数达到最大时的处理方式executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 这里选择抛出异常,也可选择其他策略// 提交任务到线程池执行的逻辑同上例...// 关闭线程池的逻辑也同上例...}
}
在这个示例中,我们直接通过ThreadPoolExecutor
构造函数指定了线程池的关键参数:
corePoolSize
:线程池的基本大小,即使没有任务执行时也会保持这么多线程。maximumPoolSize
:线程池能够容纳的最大线程数。keepAliveTime
:多余的空闲线程等待新任务的最长时间。unit
:keepAliveTime
的时间单位。workQueue
:用于保存等待执行的任务的阻塞队列,这里使用的是无界队列LinkedBlockingQueue
,但在实际应用中应谨慎选择,以防止因队列无限增长导致内存溢出。
通过这样的方式,开发者能够更清晰地理解线程池的工作原理和配置,从而更好地根据实际需求调整参数,避免资源耗尽风险。
5. 【强制】SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为static,必须加锁,或者使用 DateUtils 工具类。
正例:注意线程安全,使用 DateUtils。亦推荐如下处理:
private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() {@Overrideprotected DateFormat initialValue() {return new SimpleDateFormat("yyyy-MM-dd");}
};
说明:如果是JDK8 的应用,可以使用Instant 代替Date,LocalDateTime 代替Calendar,
DateTimeFormatter 代替SimpleDateFormat,官方给出的解释:simple beautiful strong
immutable thread-safe。
总结来说,为了确保线程安全,避免将SimpleDateFormat定义为静态变量,或者通过同步、使用ThreadLocal、采用线程安全的库类等方式来解决潜在的并发问题。
6. 【强制】高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。
在高并发场景下,锁的使用会对性能产生显著影响,因此选择合适的同步策略至关重要。遵循“锁的最低限度原则”,即尽可能减少锁的使用和降低其带来的性能损耗,是优化并发性能的关键。以下是几个具体的建议:
-
优先考虑无锁数据结构:无锁编程通过使用原子变量(如
AtomicInteger
,AtomicReference
等)或CAS(Compare-and-Swap)操作,可以在不需要显式加锁的情况下实现线程安全。例如,使用AtomicInteger
进行计数操作,相比使用synchronized
或ReentrantLock
通常能提供更好的性能。 -
细粒度锁:尽量减少锁的范围,只在必要的代码块上加锁,而不是整个方法。这样可以减少锁的持有时间,提高并发度。例如,如果一个方法中有多个独立的资源需要修改,应该分别对这些资源加锁,而不是对整个方法加锁。
-
对象锁优于类锁:对象锁(即
synchronized
作用于实例方法或对象实例上的代码块)通常比类锁(即synchronized
作用于静态方法或类对象上的代码块)具有更高的并发能力。因为类锁会限制所有实例对共享资源的访问,而对象锁仅限制同一实例上的访问。因此,如果可能,应优先使用对象锁。 -
使用并发容器:Java并发包(
java.util.concurrent
)提供了许多高性能的并发容器,如ConcurrentHashMap
,CopyOnWriteArrayList
等,这些容器内部已经实现了高效的锁机制或无锁算法,能有效减少锁竞争,提高并发处理能力。 -
锁升级与降级:对于
ReentrantLock
和一些高级并发容器,它们支持锁的升级与降级机制,如从无锁状态升级到偏向锁、轻量级锁,再到重量级锁,以及相应的降级过程,以适应不同的竞争情况,减少不必要的开销。 -
考虑使用乐观锁:在某些场景下,如果数据冲突的概率较低,可以使用乐观锁策略,如通过版本号或时间戳来判断数据是否被其他线程修改过,从而减少锁的使用。
总之,在设计高并发系统时,深入分析业务场景,选择最适合的并发控制策略,合理使用锁,甚至避免使用锁,是提高系统吞吐量和响应速度的关键。
7. 【强制】对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。
说明:线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序
也必须是A、B、C,否则可能出现死锁。
8. 【强制】并发修改同一记录时,避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用 version 作为更新依据。
说明:如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次
数不得小于3 次。
乐观锁常见的实现机制包括版本号(versioning)、时间戳(timestamp)或使用CAS(Compare and Swap)操作等。在每次数据更新时,都会检查数据的版本号或者时间戳是否与读取时一致,如果不一致,则拒绝本次更新。
9. 【强制】多线程并行处理定时任务时,Timer 运行多个TimeTask 时,只要其中之一没有捕获抛出的异常,其它任务便会自动终止运行,使用 ScheduledExecutorService 则没有这个问题。
在Java中,Timer
类和ScheduledExecutorService
都是用于执行定时任务或周期性任务的工具,但它们在异常处理和任务执行的健壮性上有明显的区别。下面通过对比两个示例来说明为什么在多线程并行处理定时任务时,推荐使用ScheduledExecutorService
而非Timer
。
使用Timer
的示例
当使用Timer
时,如果定时任务抛出了未捕获的异常,整个Timer
线程将会终止,导致所有定时任务停止执行。
import java.util.Timer;
import java.util.TimerTask;public class TimerExample {public static void main(String[] args) {Timer timer = new Timer();TimerTask task1 = new TimerTask() {@Overridepublic void run() {System.out.println("Task 1 executing...");throw new RuntimeException("Task 1 failed!"); // 未被捕获的异常}};TimerTask task2 = new TimerTask() {@Overridepublic void run() {System.out.println("Task 2 executing...");}};// 安排任务执行timer.schedule(task1, 1000);timer.schedule(task2, 2000); // 当task1抛出异常后,task2不会执行}
}
在上述代码中,当Task 1
运行时抛出异常,Timer
线程会终止,导致Task 2
无法按计划执行。
使用ScheduledExecutorService
的示例
相比之下,ScheduledExecutorService
提供了更好的异常处理机制,即使某个任务抛出异常,也不会影响其他任务的执行。
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;public class ScheduledExecutorServiceExample {public static void main(String[] args) {ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);Runnable task1 = () -> {System.out.println("Task 1 executing...");throw new RuntimeException("Task 1 failed!"); // 未被捕获的异常};Runnable task2 = () -> {System.out.println("Task 2 executing...");};// 安排任务执行executor.schedule(task1, 1, TimeUnit.SECONDS);executor.schedule(task2, 2, TimeUnit.SECONDS); // 即使task1抛出异常,task2仍会执行// 注意:在实际应用中,建议添加适当的异常处理逻辑,比如使用Future来捕获异常}
}
在这个例子中,即使Task 1
抛出了异常,由于ScheduledExecutorService
为每个任务分配了独立的线程(或复用了线程池中的线程),所以Task 2
依然能够正常执行。此外,ScheduledExecutorService
提供了更灵活的线程管理和任务调度功能,更适合复杂的并发任务处理场景。
10. 【推荐】使用CountDownLatch 进行异步转同步操作,每个线程退出前必须调用 countDown方法,线程执行代码注意 catch 异常,确保 countDown 方法被执行到,避免主线程无法执行至await 方法,直到超时才返回结果。
说明:注意,子线程抛出异常堆栈,不能在主线程 try-catch 到。
11. 【参考】volatile 解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。
如果是count++操作,使用如下类实现
AtomicInteger count = new AtomicInteger(); count.addAndGet(1);
//如果是JDK8,推荐使用LongAdder 对象,比 AtomicLong 性能更好(减少乐观锁的重试次数)。