一. 背景知识1:Java同步调用
我们写的Java代码,大多数都是同步调用,只启动一个线程。所谓同步调用,举个简单的例子:
- 某个程序一开始执行方法A(),此时线程进入A方法中依次或循环向下执行逻辑,程序计数器(Program Counter Register)所存的数据就是当前线程执行到的代码位置;
- 当线程执行到某处,需要调用另一个方法B()时,程序会进入这个方法B()中继续执行其中的代码;虚拟机栈(VM Stack)会将原先方法A()的栈帧会暂时挂起,在上面压栈方法B()的栈帧,程序计数器也会指向方法B()内部,在方法B()中向下执行;
- 当方法B()执行完毕,返回结果后,程序才会再跳回原先方法A()调用B()的位置,继续向下执行;虚拟机栈对应的操作是退栈B()对应的栈帧,程序计数器又指向了当前在栈顶的方法A()中原先的代码位置,进而继续向下执行。
从同步调用的整个步骤来看,线程执行代码是有很明确的先后顺序的:当B()执行时,A()会暂时挂起不再执行;只有B()执行完毕后,A()才会继续执行
二. 背景知识2:Java异步调用
当我们想多线程执行程序,即执行A()的过程中,也同步启动B()方法的逻辑,A()/B()两个进程同步执行,就式异步调用方法。
异步调用在Java SE中,一般是写Thread()进程,或者继承Runnable接口重写run()方法。同时,Java SE还提供了很多线程池工具ThreadPool,可以选择将Runnable对象放在线程池中托管,让主进程根据线程池的配置与资源,自动执行多线程方法。
但Java SE的异步调用方式相对来说还是有些繁琐,需要新写类继承Runnable;重写run()方法;用Thread或者线程池启动多线程;=。而这些代码很“套路”,重复的“样板代码”冗余较多,直接拿来使用,开发效率往往并不理想。
三. Spring 异步调用方法
Spring作为流行的容器框架,封装了Java传统的异步调用方法,并新提供了更加简洁的异步编程方式。
Spring通过使用 @Async注解 标记任何一个B()方法,可以很容易的将此方法的同步调用,变成异步执行,进而实现需求,大大提高了开发效率。
四. 开发方式
1. 启动Spring异步调用开关:@EnableAsync
要使用@Async异步注解,需要开启Spring对此注解的识别开关,@Async注解才会生效。
Spring开启异步调用的开关是 @EnableAsync注解,只要将此注解配置在@Configuration配置类上,就会生效,令Spring识别@Async注解
@EnableAsync //此注解会开启Spring异步调用开关
@SpringBootApplication
public class SpringApplication {
public static void main(String[] args) {
SpringApplication.run(SpringApplication.class, args);
}
}
2. 标记异步方法:@Async注解
@Slf4j
@Component
public class TaskService {
public static Random random = new Random();
/**
* 任务一
*/
@Async
public void taskOne() throws Exception {
int time = random.nextInt(1000);
log.info("Task1,预计执行时间:{}", time);
long start = System.currentTimeMillis();
Thread.sleep(time);
String msg = "Task1 Finished,耗时:" + (System.currentTimeMillis() - start) + "ms";
log.info(msg);
return ;
}
/**
* 任务二
* @throws Exception
*/
@Async
public void taskTwo() throws Exception {
int time = random.nextInt(1000);
log.info("Task2,预计执行时间:{}", time);
long start = System.currentTimeMillis();
Thread.sleep(time);
String msg = "Task2 Finished,耗时:" + (System.currentTimeMillis() - start) + "ms";
log.info(msg);
return ;
}
/**
* 任务三
* @throws Exception
*/
@Async
public void taskThree() throws Exception {
int time = random.nextInt(1000);
log.info("Task3,预计执行时间:{}", time);
long start = System.currentTimeMillis();
Thread.sleep(time);
String msg = "Task3 Finished,耗时:" + ( System.currentTimeMillis() - start) + "毫秒";
log.info(msg);
return ;
}
}
以上即定义三个异步方法,可以看到三个异步方法都用@Async标记
3. 异步调用
下面的代码开始进行异步调用
@Slf4j
@RestController
@RequestMapping("/task")
public class TaskController {
@Autowired
private TaskService task;
@RequestMapping("/start")
public void start() throws Exception {
long begin = System.currentTimeMillis();
task.taskOne(); //异步调用
task.taskTwo(); //异步调用
task.taskThree(); //异步调用
log.info("主线程总执行时间:{}", System.currentTimeMillis() - begin);
}
}
可以看到调用了task的三个方法,看上去就像常规的同步调用一样,但Spring会自动将这三个方法的调用启动为异步的。输出信息如下:
INFO 8516 — [nio-8080-exec-1] 主线程总执行时间:6
INFO 8516 — [ task-1] Task1,预计执行时间:578
INFO 8516 — [ task-2] Task2,预计执行时间:112
INFO 8516 — [ task-3] Task3,预计执行时间:515
INFO 8516 — [ task-2] Task2 Finished,耗时:112ms
INFO 8516 — [ task-3] Task3 Finished,耗时:517毫秒
INFO 8516 — [ task-1] Task1 Finished,耗时:578ms
由结果分析可知:主线程首先执行完成,只用了6毫秒;task1/2/3也启动了各自的线程,彼此之间没有影响,三个进程按各自的预设执行完毕
五. 异步调用分析
1. 异步同步差异
从上例可以看出,Spring异步调用与同步调用在编程上十分相似,只是
- 异步执行方法上多加了@Async注解;
- 启动类加@EnableAsync注解
- 其他调用方式与同步都没有区别
如果我们注释掉@EnableAsync注解,异步调用则不再生效,变成了同步调用,结果如下:
//@EnableAsync //注释掉@EnableAsync
@SpringBootApplication
public class JavaLearnApplication {
public static void main(String[] args) {
SpringApplication.run(JavaLearnApplication.class, args);
}
}
打印结果如下,分析可知执行为同步调用:Task1/2/3按顺序进行;并且总线程等待三个Task执行完成后,才最终执行完毕
[nio-8080-exec-2] Task1,预计执行时间:555
[nio-8080-exec-2] Task1 Finished,耗时:555ms
[nio-8080-exec-2] Task2,预计执行时间:747
[nio-8080-exec-2] Task2 Finished,耗时:747ms
[nio-8080-exec-2] Task3,预计执行时间:788
[nio-8080-exec-2] Task3 Finished,耗时:788毫秒
[nio-8080-exec-2] 主线程总执行时间:2094
2. Spring线程池
对于@Async异步线程,Spring自然不会仅仅使用Thread.start()方式去启动与管理异步调用。Spring容器在启动时,会自动创造一个TaskExecutor的bean实例,用此实例来管理整个异步调用。
Spring支持开发者设计自己的线程池以替换Spring自带线程池。替换方式也十分简单容易,只需在Spring容器内注入自己定义的ThreadPoolTaskExecutor对象即可。
具体使用可以参考另一篇文章《Spring(SpringBoot)线程池ThreadPoolTaskExecutor》
在本例中,我们使用如下代码自定义一个线程池
//定义自己的线程池,name为myThreadPoolTaskExecutor
@Bean
public ThreadPoolTaskExecutor myThreadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
return executor;
}
执行结果如下:
21:00:26.836 [nio-8080-exec-2] : 主线程总执行时间:0
21:00:26.836 [lTaskExecutor-1] : Task1,预计执行时间:661
21:00:26.844 [lTaskExecutor-2] : Task2,预计执行时间:590
21:00:27.434 [lTaskExecutor-2] : Task2 Finished,耗时:590ms
21:00:27.434 [lTaskExecutor-2] : Task3,预计执行时间:833
21:00:27.498 [lTaskExecutor-1] : Task1 Finished,耗时:661ms
21:00:28.268 [lTaskExecutor-2] : Task3 Finished,耗时:833毫秒
可以看到:
- 服务还是异步调用没有变
- 因为我们设置的corePoolSize为2,所以每次启动两个线程,多余则进入queueCapacity中缓存
- 初始阶段,Task1、Task2执行,Task3缓存
- Task2执行完毕后,Task3开始,此时Task1还没结束
- 最终Task1按照预定时间执行完毕,Task3页按照预定时间执行完毕
- 三个Task总执行时间为21:00:28.268-21:00:26.836=1432毫秒,几乎为Task2+Task3的执行时间
由此可知,线程池的缓存起到了作用