前段时间在面试过程中被问到了这样一个场景问题:有一个业务场景,需要向多个用户发送手机短信,如果需要同时发送大量的短信你会如何高效实现?对于这个问题当时首先想到的就是使用MQ或者线程池。当提到线程池时,面试官又问道:线程池处理的任务过多,会触发拒绝策略对吧(此处点了点头,心想这又要出什么幺蛾子),如果使用CallerRunsPolicy策略会由提交任务的线程执行,对于这点你有什么优化手段吗?对于这个问题,好在当时再看TL课堂的并发课时,里面提了一嘴说可以自定义拒绝策略并将任务序列化到缓存中。
虽然提到了这个优化点,但是在实际项目中并没有实践过,所以在空闲时间来手动实现这个场景。首先确认分别有哪几个步骤:
- 自定义一个Task任务类,便于序列化。
- 自定义线程池的拒绝策略,将任务缓存到redis,这样可以避免主线程执行任务
- 线程池中使用自定义的拒绝策略
- 定时或循环从redis中拉取任务执行
自定义任务类
自定义一个RejectTask类并包含手机号和短信内容字段,该类需要实现Runnable并重写run方法。
@Slf4j
@Data
public class RejectTask implements Runnable {
//手机号
private String mobile;
// 短信内容
private String msg;
public RejectTask(String mobile, String msg) {
this.mobile = mobile;
this.msg = msg;
}
/**
* 最终发送短信的方法
*/
@Override
public void run() {
log.info("====发送短信内容:{},至{}", this.msg, this.mobile);
}
}
自定义拒绝策略
定义一个MyThreadRejectedExecutionHandler类,该类需要实现RejectedExecutionHandler接口并重写rejectedExecution方法。在rejectedExecution方法中,将被拒绝的任务由主线程序列化到redis中,所以主线程不会再执行该任务了,相对来说效率会好那么一些。
/**
* 自定义线程池拒绝策略
*/
@Slf4j
@Component
public class MyThreadRejectedExecutionHandler implements RejectedExecutionHandler {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 线程池是否被关闭
if (executor.isShutdown()) {
return;
}
log.info("==========>1. 执行自定义线程池拒绝策略<==========");
//获取被拒绝的任务
RejectTask task = (RejectTask)r;
String str = JSONUtil.toJsonStr(task);
//将任务缓存到redis
stringRedisTemplate.opsForList().leftPush("thread::rejected::list", str);
log.info("==========>2. 任务序列化到redis <========== {}",task.getMobile());
}
}
线程池中使用自定义的拒绝策略
@Configuration
public class ThreadConfig {
@Resource
private MyThreadRejectedExecutionHandler myThreadRejectedExecutionHandler;
@Bean(name = "defExecutor")
public ThreadPoolExecutor executor() {
ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
5,
10,
20L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(5),
//自定义的拒绝策略
myThreadRejectedExecutionHandler);
return poolExecutor;
}
}
获取任务
前面拒绝的任务存入的缓存,那么这个时候就需要从缓存中读取任务并执行。一般情况下可以使用定时任务来定期执行这些任务,这里为了方便就采用的while循环的方式。主要的实现方式就是读取缓存中的任务,如果线程池的任务队列还是满的情况下,那么这时就直接执行该任务,否则还是把这个任务交给原线程池执行(个人理解该策略只是一个辅助作用,如果线程池有空闲的情况下还是由线程池本身来执行比较合适,如果觉得不合理也可以不需要)。
@Slf4j
@Order(value = Integer.MAX_VALUE)
@Component
public class RejectedThreadLister {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource(name = "defExecutor")
private ThreadPoolExecutor executor;
private int maxNumber = 20;//最大空循环次数
@PostConstruct
public void rejectedThreadLister() {
new Thread(() -> {
int count = 0;//当前空循环次数
while (!executor.isShutdown()) {
// 线程池的任务队列
ArrayBlockingQueue<Runnable> queue = (ArrayBlockingQueue<Runnable>) executor.getQueue();
// 从缓存中获取被拒绝的任务
String taskJson = stringRedisTemplate.opsForList().leftPop("thread::rejected::list");
if (StrUtil.isNotEmpty(taskJson)) {
count = 0;
RejectTask task = JSONUtil.toBean(taskJson, RejectTask.class);
log.info("==========> redis中获取到任务 <========== {}",task.getMobile());
// 如果任务队列满载,那么直接执行该任务
if (queue.size() == 5) {
task.run();
} else {
try {
//如果任务队列有空闲,那么继续把任务交给线程池执行(也可以不需要)
queue.put(task);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} else {
count++;
// 如果多次循环后并没有拒绝的任务,说明任务此时数据量并不大,那么就可以减少循环次数
if (count >= maxNumber) {
try {
Thread.sleep(10000L);
count = 0;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}, "rejected-lister").start();
}
}
测试
测试代码相对比较随意,方式不固定,只要任务数能够触发拒绝策略即可。
@Slf4j
@RestController
public class TestController {
@Resource(name = "defExecutor")
private ThreadPoolExecutor executor;
@GetMapping("/test")
public void test(@RequestParam("taskCount") int taskCount){
for (int i = 0; i < taskCount; i++) {
executor.execute(new RejectTask(RandomUtil.randomNumbers(11),"测试测试"));
}
}
}
通过日志输出结果可以看到,这个策略确实是有效的。如果你有其他方法,也可以进行讨论。