• 简述CompletableFuture异步任务编排


    前言

    在之前的项目开发中,都没怎么使用过CompletableFuture的功能,只听说过和异步编程有关。为了能够在将来有需要的时候用得上,这两天花了点时间学习了一下,并简单地总结一下如何使用CompletableFuture完成异步任务编排。

    先创建一个自定义的线程池,后续所有代码都会使用到:

    1. private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(3, 5, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadFactory() {
    2. private final AtomicInteger THREAD_NUM = new AtomicInteger(1);
    3. @Override
    4. public Thread newThread(Runnable r) {
    5. Thread t = new Thread(r);
    6. // 设置为守护线程,main线程结束就跟着一起结束,否则main函数结束jvm还在
    7. t.setDaemon(true);
    8. t.setName("completable-future-test-Thread-" + THREAD_NUM.incrementAndGet());
    9. return t;
    10. }
    11. }, new ThreadPoolExecutor.AbortPolicy());
    12. 复制代码

    同步串行

    同步串行代表任务1、任务2、任务3按时间先后顺序执行,并且都是同一个线程来执行。

    示例代码如下:

    1. CompletableFuture
    2. .supplyAsync(
    3. () -> {
    4. Thread currentThread = Thread.currentThread();
    5. String ThreadName = currentThread.getName();
    6. String taskName = "task1";
    7. System.out.println(ThreadName + "开始执行任务:" + taskName);
    8. System.out.println("正在执行任务" + taskName);
    9. System.out.println(taskName + "执行结束");
    10. return taskName;
    11. }, THREAD_POOL_EXECUTOR)
    12. .thenApply(
    13. (task1Result) -> {
    14. Thread currentThread = Thread.currentThread();
    15. String ThreadName = currentThread.getName();
    16. String taskName = "task2";
    17. System.out.println(ThreadName + "开始执行任务:" + taskName);
    18. System.out.println("正在执行任务" + taskName);
    19. System.out.println("拿到上一个任务的返回值:" + task1Result);
    20. System.out.println(taskName + "执行结束");
    21. return taskName;
    22. })
    23. .thenAccept(
    24.             (task2Result) -> {
    25.   Thread currentThread = Thread.currentThread();
    26.   String ThreadName = currentThread.getName();
    27.   String taskName = "task3";
    28.   System.out.println(ThreadName + "开始执行任务:" + taskName);
    29.   System.out.println("正在执行任务" + taskName);
    30.   System.out.println("拿到上一个任务的返回值:" + task2Result);
    31.   System.out.println(taskName + "执行结束");
    32.   });
    33. 复制代码

    执行结果:

    1. completable-future-test-Thread-2开始执行任务:task1
    2. 正在执行任务task1
    3. task1执行结束
    4. completable-future-test-Thread-2开始执行任务:task2
    5. 正在执行任务task2
    6. 拿到上一个任务的返回值:task1
    7. task2执行结束
    8. completable-future-test-Thread-2开始执行任务:task3
    9. 正在执行任务task3
    10. 拿到上一个任务的返回值:task2
    11. task3执行结束
    12. 复制代码

    1.入口函数supplyAsync()代表一个异步的有返回值的函数,之所以异步,是与主线程区别,从线程池中的拿一个线程来执行。

    2.thenApply()thenAccept()没有Async,意味着是和前面的任务共用一个线程,从执行结果上我们也可以看到线程名称相同。

    3.thenApply()需要接收上一个任务的返回值,并且自己也要有返回值。

    4.thenAccept()需要接收上一个任务的返回值,但是它不需要返回值。

    异步串行

    异步串行代表任务1、任务2、任务3按时间先后顺序执行,并由不同的线程来执行。

    示例代码如下:

    1. CompletableFuture
    2.        // 有返回值
    3. .supplyAsync(
    4. () -> {
    5. Thread currentThread = Thread.currentThread();
    6. String ThreadName = currentThread.getName();
    7. String taskName = "task1";
    8. System.out.println(ThreadName + "开始执行任务:" + taskName);
    9. System.out.println("正在执行任务" + taskName);
    10. System.out.println(taskName + "执行结束");
    11. return taskName;
    12. }, THREAD_POOL_EXECUTOR)
    13.        // 需要上一个任务的返回值,并且自身有返回值
    14. .thenApplyAsync(
    15. (task1Result) -> {
    16. Thread currentThread = Thread.currentThread();
    17. String ThreadName = currentThread.getName();
    18. String taskName = "task2";
    19. System.out.println(ThreadName + "开始执行任务:" + taskName);
    20. System.out.println("正在执行任务" + taskName);
    21. System.out.println("拿到上一个任务的返回值:" + task1Result);
    22. System.out.println(taskName + "执行结束");
    23. return taskName;
    24. }, THREAD_POOL_EXECUTOR)
    25.        // 不需要上一个任务的返回值,自身也没有返回值
    26. .thenRunAsync(
    27. () -> {
    28. Thread currentThread = Thread.currentThread();
    29. String ThreadName = currentThread.getName();
    30. String taskName = "task3";
    31. System.out.println(ThreadName + "开始执行任务:" + taskName);
    32. System.out.println("正在执行任务" + taskName);
    33. System.out.println("thenRunAsync()不需要上一个任务的返回值");
    34. System.out.println(taskName + "执行结束");
    35. }, THREAD_POOL_EXECUTOR);
    36. 复制代码

    执行结果如下:

    1. completable-future-test-Thread-2开始执行任务:task1
    2. 正在执行任务task1
    3. task1执行结束
    4. completable-future-test-Thread-3开始执行任务:task2
    5. 正在执行任务task2
    6. 拿到上一个任务的返回值:task1
    7. task2执行结束
    8. completable-future-test-Thread-4开始执行任务:task3
    9. 正在执行任务task3
    10. thenRunAsync()不需要上一个任务的返回值
    11. task3执行结束
    12. 复制代码

    1.入口函数依然是supplyAsync(),需要传入一个有返回值的函数作为参数;如果想要没有返回值的函数传进来的话,可以使用CompletableFuture.runAsync();

    2.thenApplyAsync()thenRunAsync()分别表示里面的任务都是异步执行的,和执行前面的任务不是同一个线程;

    3.thenRunAsync()需要传入一个既不需要参数,也没有返回值的任务;

    并行任务

    并行代表任务1、任务2、任务3没有依赖关系,分别由不同的线程执行;

    示例代码如下:

    1. CompletableFuture<String> future1 = CompletableFuture
    2. .supplyAsync(
    3. () -> {
    4. Thread currentThread = Thread.currentThread();
    5. String ThreadName = currentThread.getName();
    6. String taskName = "task1";
    7. System.out.println(ThreadName + "开始执行任务:" + taskName);
    8. System.out.println("正在执行任务" + taskName);
    9. System.out.println(taskName + "执行结束");
    10. return taskName;
    11. }, THREAD_POOL_EXECUTOR);
    12. CompletableFuture<Void> future2 = CompletableFuture
    13. .runAsync(
    14. () -> {
    15. Thread currentThread = Thread.currentThread();
    16. String ThreadName = currentThread.getName();
    17. String taskName = "task2";
    18. System.out.println(ThreadName + "开始执行任务:" + taskName);
    19. System.out.println("正在执行任务" + taskName);
    20. System.out.println(taskName + "执行结束");
    21. }, THREAD_POOL_EXECUTOR);
    22. CompletableFuture<String> future3 = CompletableFuture
    23. .supplyAsync(
    24. () -> {
    25. Thread currentThread = Thread.currentThread();
    26. String ThreadName = currentThread.getName();
    27. String taskName = "task3";
    28. System.out.println(ThreadName + "开始执行任务:" + taskName);
    29. System.out.println("正在执行任务" + taskName);
    30. System.out.println(taskName + "执行结束");
    31. return taskName;
    32. }, THREAD_POOL_EXECUTOR);
    33. 复制代码

    执行结果如下:

    1. completable-future-test-Thread-4开始执行任务:task3
    2. completable-future-test-Thread-2开始执行任务:task1
    3. completable-future-test-Thread-3开始执行任务:task2
    4. 正在执行任务task3
    5. task3执行结束
    6. 正在执行任务task2
    7. 正在执行任务task1
    8. task2执行结束
    9. task1执行结束
    10. 复制代码

    一看执行结果,明显是乱序的,并且三个任务分别由三个线程执行,符合咱们的预期;注意异步的方法后面都是带有Async关键字的;

    多任务结果合并计算

    • 两个任务结果的合并

    任务3的执行依赖于任务1、任务2的返回值,并且任务1和任务3由同一个线程执行,任务2单独一个线程执行;

    示例代码如下:

    1. CompletableFuture
    2. // 任务1
    3. .supplyAsync(
    4. () -> {
    5. Thread currentThread = Thread.currentThread();
    6. String ThreadName = currentThread.getName();
    7. String taskName = "task1";
    8. System.out.println(ThreadName + "开始执行任务:" + taskName);
    9. System.out.println("正在执行任务" + taskName);
    10. System.out.println(taskName + "执行结束");
    11. return taskName;
    12. }, THREAD_POOL_EXECUTOR)
    13. .thenCombine(
    14. CompletableFuture
    15. // 任务2
    16. .supplyAsync(
    17. () -> {
    18. Thread currentThread = Thread.currentThread();
    19. String ThreadName = currentThread.getName();
    20. String taskName = "task2";
    21. System.out.println(ThreadName + "开始执行任务:" + taskName);
    22. System.out.println("正在执行任务" + taskName);
    23. System.out.println(taskName + "执行结束");
    24. return taskName;
    25. }, THREAD_POOL_EXECUTOR),
    26. // 任务3
    27. (task1Result, task2Result) -> {
    28. Thread currentThread = Thread.currentThread();
    29. String ThreadName = currentThread.getName();
    30. String taskName = "task3";
    31. System.out.println(ThreadName + "开始执行任务:" + taskName);
    32. System.out.println("task1结果:" + task1Result + "\ttask2结果:" + task2Result);
    33. System.out.println("正在执行任务" + taskName);
    34. System.out.println(taskName + "执行结束");
    35. return taskName;
    36. });
    37. 复制代码

    执行结果如下:

    1. completable-future-test-Thread-3开始执行任务:task2
    2. completable-future-test-Thread-2开始执行任务:task1
    3. 正在执行任务task1
    4. 正在执行任务task2
    5. task2执行结束
    6. task1执行结束
    7. completable-future-test-Thread-2开始执行任务:task3
    8. task1结果:task1 task2结果:task2
    9. 正在执行任务task3
    10. task3执行结束
    11. 复制代码

    CompletableFuture提供了thenCombine()来合并另一个CompletableFuture的执行结果,所以thenCombine()需要两个参数,第一个参数是另一个CompletableFuture,第二个参数会收集前两个任务的返回值,类似下面这样:

    1. (result1,result2)->{
    2.  // 执行业务逻辑
    3.  return result3;
    4. }
    5. 复制代码

    如果小伙伴们想要实现任务3也是单独的线程执行的话,可以使用thenCombineAsync()这个方法。代码如下:

    1. CompletableFuture
    2. // 任务1
    3. .supplyAsync(
    4. () -> {
    5. Thread currentThread = Thread.currentThread();
    6. String ThreadName = currentThread.getName();
    7. String taskName = "task1";
    8. System.out.println(ThreadName + "开始执行任务:" + taskName);
    9. System.out.println("正在执行任务" + taskName);
    10. System.out.println(taskName + "执行结束");
    11. return taskName;
    12. }, THREAD_POOL_EXECUTOR)
    13. .thenCombineAsync(
    14. CompletableFuture
    15. // 任务2
    16. .supplyAsync(
    17. () -> {
    18. Thread currentThread = Thread.currentThread();
    19. String ThreadName = currentThread.getName();
    20. String taskName = "task2";
    21. System.out.println(ThreadName + "开始执行任务:" + taskName);
    22. System.out.println("正在执行任务" + taskName);
    23. System.out.println(taskName + "执行结束");
    24. return 2;
    25. }, THREAD_POOL_EXECUTOR),
    26. // 任务3
    27. (task1Result, task2Result) -> {
    28. Thread currentThread = Thread.currentThread();
    29. String ThreadName = currentThread.getName();
    30. String taskName = "task3";
    31. System.out.println(ThreadName + "开始执行任务:" + taskName);
    32. System.out.println("task1结果:" + task1Result + "\ttask2结果:" + task2Result);
    33. System.out.println("正在执行任务" + taskName);
    34. System.out.println(taskName + "执行结束");
    35. return 2L;
    36. }, THREAD_POOL_EXECUTOR);
    37. 复制代码

    如果任务3中不需要返回结果,可以使用thenAcceptBoth()thenAcceptBothAsync(),使用方式与thenCombineAsync()类似;

    • 多任务结果合并

    示例代码如下:

    1. CompletableFuture future1 = CompletableFuture
    2. // 任务1
    3. .supplyAsync(
    4. () -> {
    5. Thread currentThread = Thread.currentThread();
    6. String ThreadName = currentThread.getName();
    7. String taskName = "task1";
    8. System.out.println(ThreadName + "开始执行任务:" + taskName);
    9. System.out.println("正在执行任务" + taskName);
    10. System.out.println(taskName + "执行结束");
    11. return taskName;
    12. }, THREAD_POOL_EXECUTOR);
    13. CompletableFuture future2 = CompletableFuture
    14. // 任务2
    15. .supplyAsync(
    16. () -> {
    17. Thread currentThread = Thread.currentThread();
    18. String ThreadName = currentThread.getName();
    19. String taskName = "task2";
    20. System.out.println(ThreadName + "开始执行任务:" + taskName);
    21. System.out.println("正在执行任务" + taskName);
    22. System.out.println(taskName + "执行结束");
    23. return taskName;
    24. }, THREAD_POOL_EXECUTOR);
    25. CompletableFuture future3 = CompletableFuture
    26. // 任务3
    27. .supplyAsync(
    28. () -> {
    29. Thread currentThread = Thread.currentThread();
    30. String ThreadName = currentThread.getName();
    31. String taskName = "task3";
    32. System.out.println(ThreadName + "开始执行任务:" + taskName);
    33. System.out.println("正在执行任务" + taskName);
    34. System.out.println(taskName + "执行结束");
    35. return taskName;
    36. }, THREAD_POOL_EXECUTOR);
    37. CompletableFuture[] futures = new CompletableFuture[]{future1, future2, future3};
    38. CompletableFuture.allOf(futures)
    39. // 任务4
    40. .whenCompleteAsync(
    41. (v, e) -> {
    42. List<Object> values = new ArrayList<>();
    43. for (CompletableFuture future : futures) {
    44. try {
    45. values.add(future.get());
    46. } catch (Exception ex) {
    47. }
    48. }
    49. Thread currentThread = Thread.currentThread();
    50. String ThreadName = currentThread.getName();
    51. String taskName = "task4";
    52. System.out.println(ThreadName + "开始执行任务:" + taskName);
    53. System.out.println("前面任务的处理结果:" + values);
    54. System.out.println("正在执行任务" + taskName);
    55. System.out.println(taskName + "执行结束");
    56. }, THREAD_POOL_EXECUTOR);
    57. 复制代码

    执行结果如下:

    1. completable-future-test-Thread-3开始执行任务:task2
    2. completable-future-test-Thread-4开始执行任务:task3
    3. completable-future-test-Thread-2开始执行任务:task1
    4. 正在执行任务task2
    5. 正在执行任务task3
    6. 正在执行任务task1
    7. task2执行结束
    8. task3执行结束
    9. task1执行结束
    10. completable-future-test-Thread-2开始执行任务:task4
    11. 前面任务的处理结果:[task1, task2, task3]
    12. 正在执行任务task4
    13. task4执行结束
    14. 复制代码

    之所以最后任务4的线程是completable-future-test-Thread-2,那是因为线程池的核心线程数设置为3,线程数设置高一点就会创建新的线程处理;

    从上述代码示例中,我们可以收获到另一个知识点:allOf(),它的作用是要求所有的任务全部完成才能执行后面的任务。

    任一任务完成

    在一批任务中,只要有一个任务完成,那么就可以向后继续执行其他任务。

    为了代码演示无异议,后续代码中,我们把线程数提升到4。

    示例代码如下:

    1. CompletableFuture future1 = CompletableFuture
    2. // 任务1
    3. .supplyAsync(
    4. () -> {
    5. Thread currentThread = Thread.currentThread();
    6. String ThreadName = currentThread.getName();
    7. String taskName = "task1";
    8. System.out.println(ThreadName + "开始执行任务:" + taskName);
    9. System.out.println("正在执行任务" + taskName);
    10. System.out.println(taskName + "执行结束");
    11. return taskName;
    12. }, THREAD_POOL_EXECUTOR);
    13. CompletableFuture future2 = CompletableFuture
    14. // 任务2
    15. .supplyAsync(
    16. () -> {
    17. Thread currentThread = Thread.currentThread();
    18. String ThreadName = currentThread.getName();
    19. String taskName = "task2";
    20. System.out.println(ThreadName + "开始执行任务:" + taskName);
    21. System.out.println("正在执行任务" + taskName);
    22. System.out.println(taskName + "执行结束");
    23. return taskName;
    24. }, THREAD_POOL_EXECUTOR);
    25. CompletableFuture future3 = CompletableFuture
    26. // 任务3
    27. .supplyAsync(
    28. () -> {
    29. Thread currentThread = Thread.currentThread();
    30. String ThreadName = currentThread.getName();
    31. String taskName = "task3";
    32. System.out.println(ThreadName + "开始执行任务:" + taskName);
    33. System.out.println("正在执行任务" + taskName);
    34. System.out.println(taskName + "执行结束");
    35. return taskName;
    36. }, THREAD_POOL_EXECUTOR);
    37. CompletableFuture.anyOf(future1, future2, future3)
    38. .thenApplyAsync((taskResult) -> {
    39. Thread currentThread = Thread.currentThread();
    40. String ThreadName = currentThread.getName();
    41. String taskName = "task4";
    42. System.out.println(ThreadName + "开始执行任务:" + taskName);
    43. System.out.println("前面任务的处理结果:" + taskResult);
    44. System.out.println("正在执行任务" + taskName);
    45. System.out.println(taskName + "执行结束");
    46. return taskName;
    47. }, THREAD_POOL_EXECUTOR);
    48. 复制代码

    执行结果如下:

    1. completable-future-test-Thread-2开始执行任务:task1
    2. completable-future-test-Thread-4开始执行任务:task3
    3. completable-future-test-Thread-3开始执行任务:task2
    4. 正在执行任务task3
    5. 正在执行任务task2
    6. 正在执行任务task1
    7. task1执行结束
    8. task3执行结束
    9. task2执行结束
    10. completable-future-test-Thread-5开始执行任务:task4
    11. 前面任务的处理结果:task1
    12. 正在执行任务task4
    13. task4执行结束
    14. 复制代码

    可以看到,任务1第一个结束,所以任务4中接收到的执行结果就是任务1的返回值。

    快速失败

    在一批任务当中,只要有任意一个任务执行产生异常了,那么就直接结束;否则就要等待所有任务成功执行完毕。

    示例代码如下:

    1. CompletableFuture future1 = CompletableFuture
    2. // 任务1
    3. .supplyAsync(
    4. () -> {
    5. Thread currentThread = Thread.currentThread();
    6. String ThreadName = currentThread.getName();
    7. String taskName = "task1";
    8. System.out.println(ThreadName + "开始执行任务:" + taskName);
    9. System.out.println("正在执行任务" + taskName);
    10. System.out.println(taskName + "执行结束");
    11. return taskName;
    12. }, THREAD_POOL_EXECUTOR);
    13. CompletableFuture future2 = CompletableFuture
    14. // 任务2
    15. .supplyAsync(
    16. () -> {
    17. Thread currentThread = Thread.currentThread();
    18. String ThreadName = currentThread.getName();
    19. String taskName = "task2";
    20. System.out.println(ThreadName + "开始执行任务:" + taskName);
    21. System.out.println("正在执行任务" + taskName);
    22. System.out.println(taskName + "执行结束");
    23. throw new RuntimeException("任务2异常!");
    24. }, THREAD_POOL_EXECUTOR);
    25. CompletableFuture future3 = CompletableFuture
    26. // 任务3
    27. .supplyAsync(
    28. () -> {
    29. Thread currentThread = Thread.currentThread();
    30. String ThreadName = currentThread.getName();
    31. String taskName = "task3";
    32. System.out.println(ThreadName + "开始执行任务:" + taskName);
    33. System.out.println("正在执行任务" + taskName);
    34. System.out.println(taskName + "执行结束");
    35. throw new RuntimeException("任务3异常!");
    36. }, THREAD_POOL_EXECUTOR);
    37. CompletableFuture[] futures = new CompletableFuture[]{future1, future2, future3};
    38. CompletableFuture allCompletableFuture = CompletableFuture.allOf(futures);
    39. // 创建一个任务来监听异常
    40. CompletableFuture<?> anyException = new CompletableFuture<>();
    41. for (CompletableFuture<?> completableFuture : futures) {
    42. completableFuture.exceptionally((t) -> {
    43. // 任何一个任务异常都会让anyException任务完成
    44. anyException.completeExceptionally(t);
    45. return null;
    46. });
    47. }
    48. // 要么allCompletableFuture全部成功,要么一个出现异常就结束任务
    49. CompletableFuture.anyOf(allCompletableFuture, anyException)
    50. .whenComplete((value, exception) -> {
    51. if (Objects.nonNull(exception)) {
    52. System.out.println("产生异常,提前结束!");
    53. exception.printStackTrace();
    54. return;
    55. }
    56. System.out.println("所有任务正常完成!");
    57. });
    58. 复制代码

    执行结果如下:

    1. completable-future-test-Thread-2开始执行任务:task1
    2. completable-future-test-Thread-3开始执行任务:task2
    3. completable-future-test-Thread-4开始执行任务:task3
    4. 正在执行任务task2
    5. 正在执行任务task3
    6. 正在执行任务task1
    7. task2执行结束
    8. task1执行结束
    9. task3执行结束
    10. 产生异常,提前结束!
    11. java.util.concurrent.CompletionException: java.lang.RuntimeException: 任务2异常!
    12. at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:314)
    13. at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:319)
    14. at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1702)
    15. at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
    16. at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
    17. at java.base/java.lang.Thread.run(Thread.java:834)
    18. Caused by: java.lang.RuntimeException: 任务2异常!
    19. at com.example.awesomerocketmq.completable.CompletableFutureTest.lambda$t$1(CompletableFutureTest.java:53)
    20. at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1700)
    21. ... 3 more
    22. 复制代码

    CompletableFuture没有现成的api实现快速失败的功能,所以我们只能结合allOf()anyOf()来逻辑来自定义方法完成快速失败的逻辑;

    1.我们需要额外创建一个CompletableFuture来监听所有的CompletableFuture,一旦其中一个CompletableFuture产生异常,我们就设置额外的CompletableFuture立即完成。

    2.把所有的CompletableFuture和额外的CompletableFuture放在anyOf()方法中,这样一旦额外的CompletableFuture完成,说明产生异常了;否则就需要等待所有的CompletableFuture完成。

    注意

    • 异常处理

    最后需要注意的是,所有的CompletableFuture任务一定要加上异常处理:

    1. CompletableFuture
    2. // 任务1
    3. .supplyAsync(
    4. () -> {
    5. Thread currentThread = Thread.currentThread();
    6. String ThreadName = currentThread.getName();
    7. String taskName = "task1";
    8. System.out.println(ThreadName + "开始执行任务:" + taskName);
    9. System.out.println("正在执行任务" + taskName);
    10. System.out.println(taskName + "执行结束");
    11. return taskName;
    12. }, THREAD_POOL_EXECUTOR)
    13. .whenComplete((v,e)->{
    14. if(Objects.nonNull(e)){
    15. // todo
    16. // 处理异常
    17. }
    18. if(Objects.nonNull(v)){
    19. // todo
    20. }
    21. });
    22. 复制代码

    还可以通过另外两个方法处理:exceptionally()或者handle()

    • 自定义线程池

    CompletableFuture默认的线程池是ForkJoinThreadPool,建议大家在使用的时候尽可能地使用自定义线程池,这样方便后续的代码优化以及相关的日志查看。

  • 相关阅读:
    Excel - 插入空白行
    详解节流防抖
    LeetCode 64.最小路径和(开辟额外空间(二维)、不开辟额外空间(二维)、优化(一维))
    如何在IDEA中配置指定JDK版本?轻松解决!!!
    JavaMail发送和接收邮件API(详解)
    管理器注册类方法和调用类函数方法
    OncoGPT1:肿瘤学领域大模型
    jira搜索search issue条目rest实用脚本
    单链表在线OJ题二(详解+图解)
    opencv(python)视频按帧切片/cv2.VideoCapture()用法
  • 原文地址:https://blog.csdn.net/m0_73311735/article/details/127963257