PART I. 编程规约
6. 并发处理
6.1 线程池
-
线程资源 必须通过 线程池 提供,
不允许在应用中自行显式创建线程 。【说明】
线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。
如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
-
线程池 不允许使用 Executors 去创建,
而是通过 ThreadPoolExecutor 的方式,
这样的处理方式让写的同学更加明确 线程池的运行规则, 规避资源耗尽 的风险。【说明】
Executors 返回的线程池对象的弊端如下:
1> FixedThreadPool 和 SingleThreadPool
允许的请求队列的长度为 Integer.MAX_VALUE,可能会堆积大量请求,从而导致 OOM。
2> CachedThreadPool
允许的创建线程数量为 Integer.MAX_VALUE, 可能会创建大量的线程,从而导致 OOM。
-
创建 线程或线程池 时
请指定 有意义的线程名称 ,方便出错时回溯。【正例】
自定义线程工厂,并且根据外部特征进行分组,
比如,来自同一机房的调用,把机房编号赋值给 whatFeatureOfGrouppublic class UserThreadFactory implements ThreadFactory { private final String namePrefix; private final AtomicInteger nextId = new AtomicInteger(1); // 定义线程组名称,在利用 jstack 来排查问题时,非常有帮助 UserThreadFactory(String whatFeatureOfGroup) { namePrefix = "From UserThreadFactory's " + whatFeatureOfGroup + "-Worker-"; } @Override public Thread newThread(Runnable task) { String name = namePrefix + nextId.getAndIncrement(); Thread thread = new Thread(null, task, name, 0, false); System.out.println(thread.getName()); return thread; } }
6.2 线程安全
-
SimpleDateFormat 是 线程不安全 的类,
一般不要定义为 static 变量,
如果定义为 static,必须加锁,或者使用 DateUtils 工具类。【正例】
private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() { @Override protected DateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd"); } };
【说明】
如果是 JDK8 的应用,
可以使用 Instant 代替 Date,
LocalDateTime 代替 Calendar,
DateTimeFormatter 代替 SimpleDateFormat,
官方给出的解释:simple beautiful strong immutable thread-safe。
-
获取 单例对象 需要保证 线程安全,
其中的方法 也要保证 线程安全。【说明】
资源驱动类、工具类、单例工厂类都需要注意。
-
避免 Random 实例被多线程使用,
虽然共享该实例是 线程安全 的,但会因竞争同一 seed 导致性能下降。【说明】
Random 实例包括 java.util.Random 的实例或者 Math.random()的方式。
【正例】
在 JDK7 之后,可以直接使用 API ThreadLocalRandom,
而在 JDK7 之前,需要编码保证每个线程持有一个单独的 Random 实例。
6.3 锁
-
高并发时,同步调用应该去考量 锁的性能损耗 。
能用 无锁数据结构,就不要用 锁 ;
能 锁区块 ,就不要锁 整个方法体 ;
能用 对象锁 ,就不要用 类锁 。【说明】
尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC 方法。
-
在使用阻塞等待获取锁的方式中,
必须在 try 代码块之外,并且在加锁方法与 try 代码块之间没有任何可能抛出异常的方法调用,
避免加锁成功后,在 finally 中 无法解锁 。【说明一】
如果在 lock 方法与 try 代码块之间的方法调用抛出异常,那么无法解锁,造成其它线程无法成功。
【说明二】
如果 lock 方法在 try 代码块之内,可能由于其它方法抛出异常,导致在 finally 代码块中,unlock 对未加锁的对象解锁,
它会调用 AQS 的 tryRelease 方法(取决于具体实现类),抛出 IllegalMonitorStateException 异常。【说明三】
在 Lock 对象的 lock 方法实现中可能抛出 unchecked 异常,产生的后果与说明二相同。
【正例】
Lock lock = new XxxLock(); // ... lock.lock(); try { doSomething(); doOthers(); } finally { lock.unlock(); }
【反例】
Lock lock = new XxxLock(); // ... try { // 如果此处抛出异常,则直接执行 finally 代码块 doSomething(); // 无论加锁是否成功,finally 代码块都会执行 lock.lock(); doOthers(); } finally { lock.unlock(); }
-
在使用 尝试机制 来获取锁的方式中,进入业务代码块之前,必须先 判断当前线程是否持有锁 。
锁的释放规则与锁的阻塞等待方式相同。【说明】
Lock 对象的 unlock 方法在执行时,
它会调用 AQS 的 tryRelease 方法(取决于具体实现类),
如果当前线程不持有锁,则抛出 IllegalMonitorStateException 异常。【正例】
Lock lock = new XxxLock(); // ... boolean isLocked = lock.tryLock(); if (isLocked) { try { doSomething(); doOthers(); } finally { lock.unlock(); } }
-
并发修改同一记录时,避免 更新丢失 ,需要 加锁 。
要么在 应用层 加锁,要么在 缓存 加锁,要么在 数据库层 使用乐观锁,使用 version 作为更新依据。【说明】
如果每次访问冲突概率 小于 20%,推荐使用乐观锁,
否则使用悲观锁。
乐观锁的重试次数不得小于 3 次。
-
资金相关的 金融敏感信息 ,使用 悲观锁 策略。
【说明】
乐观锁在获得锁的同时已经完成了更新操作,校验逻辑容易出现漏洞,
另外,乐观锁对冲突的解决策略有较复杂的要求,处理不当容易造成系统压力或数据异常,
所以资金相关的金融敏感信息不建议使用乐观锁更新。【正例】
悲观锁遵循一锁、二判、三更新、四释放的原则。
-
对多个资源、数据库表、对象 同时加锁 时,
需要保持 一致的加锁顺序 ,否则可能会造成 死锁 。【说明】
线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,
那么线程二的加锁顺序也必须是 A、 B、C,
否则可能出现死锁。
6.4 java.util.concurrent.*
-
使用 CountDownLatch 进行异步转同步操作,
每个线程 退出前必须调用 countDown 方法 ,
线程执行代码注意 catch 异常 ,
确保 countDown 方法被执行到,避免主线程无法执行至 await 方法,直到 超时 才返回结果。【说明】
注意,子线程抛出异常堆栈,不能在主线程 try-catch 到。
6.5 ThreadLocal
-
必须 回收 自定义 的 ThreadLocal 变量,
尤其在线程池场景下,线程经常会被复用,
如果不清理 自定义 的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄露等问题。
尽量在代理中使用 try-finally 块进行回收。【正例】
objectThreadLocal.set(userInfo); try { // ... } finally { objectThreadLocal.remove(); }
-
ThreadLocal 对象使用 static 修饰,ThreadLocal 无法解决共享对象的更新问题。
【说明】
这个变量是针对一个线程内所有操作共享的,所以设置为静态变量。
所有此类实例共享此静态变量,也就是说在类第一次被使用时装载,只分配一块存储空间,
所有此类的对象(只要是这个线程内定义的)都可以操控这个变量。