开启多个线程,如果保证顺序执行,你知道有哪几种方式实现?

哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:掘金/C站/腾讯云/阿里云/华为云/51CTO(全网同号);欢迎大家常来逛逛,互相学习。

今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。

我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!

前言

在如今项目开发中,并发已经成为必不可少的一部分,特别是在高性能的应用场景中。随着多核处理器的普及,我们有机会让多个线程并行执行,从而显著提升程序的性能和响应速度。但与此同时,这种并行执行也带来了不少挑战,其中最为棘手的之一,就是如何确保多个线程按顺序执行?这个问题想必大家都耳熟能详,那到底如何实现呢?又有哪些方式可以实现?接下来,我们来深入聊聊它。

作为开发者,我们常常会遇到这样的场景:多个线程需要依赖前一个线程的执行结果,或者必须按一定的顺序来完成某项任务。比如说,假设我们有三个线程,线程A负责加载数据,线程B需要在线程A加载完数据之后进行处理,线程C则依赖线程B的处理结果。如果这些线程顺序混乱,结果可能会出错,甚至程序崩溃,最终服务宕机,那如何避免或者实现呢。

记得自己刚开始接触多线程编程时,也曾为线程的执行顺序问题困扰过。并发任务执行时,线程独立、自由地执行,看起来非常高效,但实际操作中,线程之间的协作往往是需要精心设计的。怎么保证线程按照预定的顺序执行呢?这成了一个让我头疼的问题。幸运的是,Java提供了很多工具来帮助我们实现线程间的顺序控制。这些工具,真实帮我太多,接下来我就要给大家隆重介绍一波了。

1. 使用 join() 方法:最基础的同步工具

join() 方法可能是Java中最简单也最常用的控制线程顺序执行的工具。通过调用 join(),我们可以让一个线程等待另一个线程执行完毕后再继续执行。这种方法非常直观,易于实现。在某些情况下,join() 足够满足我们对线程顺序的需求,尤其是线程执行顺序非常简单且没有复杂的依赖关系时。

为什么选择 join()

join() 方法实现起来非常简单,可以让我们在主线程中直接控制子线程的执行顺序。在代码中,只需要在每个线程启动后,调用该线程的 join() 方法,确保前一个线程完成后才启动下一个线程。

示例代码

针对如上示例代码,这里我给大家详细的代码剖析下,以便于帮助大家理解的更为透彻,帮助大家早日掌握。

java 复制代码
package com.demo.java.test;

public class SequentialThreads {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> System.out.println("Thread 1"));
        Thread t2 = new Thread(() -> System.out.println("Thread 2"));
        Thread t3 = new Thread(() -> System.out.println("Thread 3"));

        t1.start();
        t1.join();  // 等待 t1 执行完后再启动 t2

        t2.start();
        t2.join();  // 等待 t2 执行完后再启动 t3

        t3.start();
        t3.join();  // 等待 t3 执行完,程序结束
    }
}

在这个简单的例子中,t1 会先启动并执行,主线程会调用 t1.join(),让主线程等待 t1 完成后再启动 t2。同理,t2 会在执行完成后,主线程再等待它完成,然后启动 t3。通过这种方式,我们能确保线程按顺序执行,代码结构清晰。

案例执行结果

如上案例结果运行展示如下,仅供参考:

用例代码分析

针对如上示例代码,这里我给大家详细的代码剖析下,以便于帮助大家理解的更为透彻,帮助大家早日掌握。

如上这个案例目的是通过创建三个线程,并确保它们按照顺序执行:首先执行 t1,然后等待 t1 执行完毕后再执行 t2,接着等待 t2 执行完毕后再执行 t3,直到所有线程执行完毕,程序才结束。

我们逐行分析这段代码的执行流程:

  1. 创建线程对象:

    java 复制代码
    Thread t1 = new Thread(() -> System.out.println("Thread 1"));
    Thread t2 = new Thread(() -> System.out.println("Thread 2"));
    Thread t3 = new Thread(() -> System.out.println("Thread 3"));

这里创建了三个线程对象 t1t2t3。每个线程对象都传递了一个 Runnable 接口的实现,即一个匿名 Runnable 类,在每个线程中执行 System.out.println 输出各自的线程编号。

  1. 启动线程 t1

    java 复制代码
    t1.start();

t1.start() 启动线程 t1,这个线程会执行 System.out.println("Thread 1"),打印 "Thread 1"。

  1. 等待线程 t1 执行完毕:

    java 复制代码
    t1.join();

这里 t1.join() 让主线程等待线程 t1 执行完毕后再继续执行后面的代码。join() 是一种阻塞主线程的方法,直到 t1 完成执行,主线程才能继续。

  1. 启动线程 t2

    java 复制代码
    t2.start();

一旦 t1 执行完,主线程会启动线程 t2,它会执行 System.out.println("Thread 2"),打印 "Thread 2"。

  1. 等待线程 t2 执行完毕:

    java 复制代码
    t2.join();

这行代码类似于之前的 t1.join(),它会让主线程等待线程 t2 执行完毕再继续执行后面的代码。

  1. 启动线程 t3

    java 复制代码
    t3.start();

t2 执行完毕后,主线程启动线程 t3,它会执行 System.out.println("Thread 3"),打印 "Thread 3"。

  1. 等待线程 t3 执行完毕:

    java 复制代码
    t3.join();

最后,t3.join() 会让主线程等待 t3 执行完毕。所有的线程执行完成后,主线程才会结束,程序运行完毕。

代码执行流程梳理

  1. 线程 t1 启动并输出 "Thread 1"。
  2. 主线程等待 t1 执行完毕。
  3. 线程 t2 启动并输出 "Thread 2"。
  4. 主线程等待 t2 执行完毕。
  5. 线程 t3 启动并输出 "Thread 3"。
  6. 主线程等待 t3 执行完毕,程序结束。

关键点:

  • start() 方法用于启动线程,真正的线程执行会在调用 start() 后异步进行。
  • join() 方法会让当前线程(通常是主线程)等待指定线程执行完毕再继续。这种机制保证了线程的执行顺序。
  • 程序中的三个线程按顺序执行,分别打印 "Thread 1"、"Thread 2" 和 "Thread 3"。

通过使用 join() 方法,确保了线程按照特定顺序执行,主线程不会提前结束,直到所有的子线程执行完毕。

优点:

  • 简单直接 :无需额外的同步工具,直接通过 join() 保证线程顺序。
  • 易于理解:线程按顺序启动和执行,逻辑非常直观。

缺点:

  • 性能瓶颈 :如果线程执行时间较长,join() 会阻塞主线程,这可能影响程序的整体响应性。
  • 局限性 :适用于简单的线程顺序控制,对于更复杂的多线程依赖关系,join() 的局限性就显现出来。

2. 使用 CountDownLatch:适合多个线程的顺序执行

CountDownLatch 是一种更为灵活的线程同步工具,它通过一个计数器来控制线程的顺序。通过调用 await() 方法,线程会等待计数器的值为0,才能继续执行。而通过 countDown() 方法,线程可以减少计数器的值。

为什么选择 CountDownLatch

CountDownLatch 适用于需要多个线程协调执行并按顺序执行的场景。通过使用 CountDownLatch,可以有效地控制多个线程之间的依赖关系,确保一个线程完成任务后再启动下一个线程。

示例代码:

java 复制代码
package com.demo.java.test;

import java.util.concurrent.CountDownLatch;

public class SequentialThreadsWithLatch {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch1 = new CountDownLatch(1);  // 控制线程1和2
        CountDownLatch latch2 = new CountDownLatch(1);  // 控制线程2和3

        Thread t1 = new Thread(() -> {
            System.out.println("Thread 1");
            latch1.countDown();  // 线程1完成,释放 latch1
        });

        Thread t2 = new Thread(() -> {
            try {
                latch1.await();  // 等待线程1完成
                System.out.println("Thread 2");
                latch2.countDown();  // 线程2完成,释放 latch2
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread t3 = new Thread(() -> {
            try {
                latch2.await();  // 等待线程2完成
                System.out.println("Thread 3");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();
    }
}

在这个例子中,t1 会首先执行,t2 必须等待 t1 完成后才能执行,而 t3 又要等到 t2 执行完成后才开始执行。通过 CountDownLatchawait()countDown() 方法,确保线程按照预定顺序执行。

结果演示

如上案例结果运行展示如下,仅供参考:

代码分析

针对如上示例代码,这里我给大家详细的代码剖析下,以便于帮助大家理解的更为透彻,帮助大家早日掌握。

如上这段案例代码使用了 CountDownLatch 来控制多个线程的顺序执行。CountDownLatch 是一个同步工具,允许一个或多个线程等待其他线程完成某些操作,然后再继续执行。

  1. 定义 CountDownLatch 对象:

    java 复制代码
    CountDownLatch latch1 = new CountDownLatch(1);  // 控制线程1和2
    CountDownLatch latch2 = new CountDownLatch(1);  // 控制线程2和3

    这里定义了两个 CountDownLatch 对象:

    • latch1 用于控制 t1t2 的执行顺序。
    • latch2 用于控制 t2t3 的执行顺序。   每个 CountDownLatch 的初始值为 1,意味着当 countDown() 被调用一次时,latch 的计数就会减少 1。
  2. 创建线程 t1

    java 复制代码
    Thread t1 = new Thread(() -> {
        System.out.println("Thread 1");
        latch1.countDown();  // 线程1完成,释放 latch1
    });

t1 线程在启动时首先打印 "Thread 1"。执行完后,调用 latch1.countDown(),这会将 latch1 的计数减少 1,从而释放其他等待 latch1 的线程,尤其是 t2 线程。

  1. 创建线程 t2

    java 复制代码
    Thread t2 = new Thread(() -> {
        try {
            latch1.await();  // 等待线程1完成
            System.out.println("Thread 2");
            latch2.countDown();  // 线程2完成,释放 latch2
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });

t2 线程在启动时首先调用 latch1.await(),它会阻塞,直到 latch1 的计数变为 0(即 t1 执行完并调用 countDown())。一旦 t1 执行完毕并释放 latch1t2 继续执行,打印 "Thread 2",然后调用 latch2.countDown(),减少 latch2 的计数,释放其他等待 latch2 的线程,尤其是 t3 线程。

  1. 创建线程 t3

    java 复制代码
    Thread t3 = new Thread(() -> {
        try {
            latch2.await();  // 等待线程2完成
            System.out.println("Thread 3");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });

t3 线程在启动时首先调用 latch2.await(),它会阻塞,直到 latch2 的计数变为 0(即 t2 执行完并调用 countDown())。一旦 t2 执行完毕并释放 latch2t3 继续执行,打印 "Thread 3"。

  1. 启动线程:

    java 复制代码
    t1.start();
    t2.start();
    t3.start();

这三行代码启动了 t1t2t3 线程。由于 t2t3 都会依赖 CountDownLatch 来控制它们的启动顺序,t1 必须先执行完毕才能启动 t2,而 t2 必须执行完毕才能启动 t3

  1. 等待所有线程执行完:

    java 复制代码
    t1.join();
    t2.join();
    t3.join();

主线程通过 join() 方法等待所有子线程完成,确保 t1t2t3 都执行完毕后主线程才结束。join() 会阻塞当前线程,直到目标线程执行完毕。

执行流程梳理:

  1. t1 被启动,执行并打印 "Thread 1",然后调用 latch1.countDown()
  2. t2 被启动,但它在执行前会等待 latch1 释放(即 t1 执行完)。t1 执行完后,t2 继续执行并打印 "Thread 2",然后调用 latch2.countDown()
  3. t3 被启动,但它在执行前会等待 latch2 释放(即 t2 执行完)。t2 执行完后,t3 继续执行并打印 "Thread 3"。
  4. 所有线程执行完毕后,主线程结束。

关键点:

  • CountDownLatch 用于控制线程的执行顺序。
  • await() 会使当前线程阻塞,直到 latch 的计数值为 0。
  • countDown() 会减少 latch 的计数值。
  • 使用 join() 确保主线程等待所有子线程执行完毕后再退出。

优点:

  • 灵活性高:适用于多个线程之间有依赖关系的情况,可以精确控制线程的启动顺序。
  • 适应性强:可以支持更多的线程协调,允许更加复杂的控制。

缺点:

  • 稍微复杂 :比起简单的 join()CountDownLatch 的使用和理解需要更多的学习和实践。
  • 不可重用CountDownLatch 一旦计数器变为0,就不能再使用。如果需要多次复用,需要使用其他工具,如 CyclicBarrier

3. 使用 CyclicBarrier:适用于多线程同步

CyclicBarrier 是另一种同步工具,它允许一组线程在某个特定的同步点等待,直到所有线程都到达该点。与 CountDownLatch 不同的是,CyclicBarrier 可以在多次任务中复用,这使得它更适合处理循环或需要复用的同步场景。

为什么选择 CyclicBarrier

当多个线程需要在某个特定点进行同步时,CyclicBarrier 提供了一种有效的解决方案。通过使用 CyclicBarrier,可以确保每个线程都在指定时刻到达同步点,然后一同继续执行。

示例代码:

java 复制代码
package com.demo.java.test;

import java.util.concurrent.CyclicBarrier;

public class SequentialThreadsWithCyclicBarrier {
    public static void main(String[] args) throws InterruptedException {
        CyclicBarrier barrier1 = new CyclicBarrier(2); // 线程1和线程2同步
        CyclicBarrier barrier2 = new CyclicBarrier(2); // 线程2和线程3同步

        Thread t1 = new Thread(() -> {
            try {
                System.out.println("Thread 1");
                barrier1.await();  // 等待 t2
            } catch (Exception e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                barrier1.await();  // 等待 t1
                System.out.println("Thread 2");
                barrier2.await();  // 等待 t3
            } catch (Exception e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread t3 = new Thread(() -> {
            try {
                barrier2.await();  // 等待 t2
                System.out.println("Thread 3");
            } catch (Exception e) {
                Thread.currentThread().interrupt();
            }
        });

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();
    }
}

在这里,CyclicBarrier 保证了线程 t2t1 完成后开始执行,且线程 t3 会等到 t2 完成后再开始执行。与 CountDownLatch 相比,CyclicBarrier 更适用于需要复用屏障的场景。

优点:

  • 高复用性:适用于多次同步的场景,特别是需要循环或反复控制线程同步的场合。
  • 线程同步:确保所有线程在同一个时间点到达同步点,然后一起执行。

缺点:

  • 稍复杂 :比 CountDownLatch 的应用稍复杂,尤其是在有多次同步需求时需要更小心地处理线程的状态。

结果演示

如上案例结果运行展示如下,仅供参考:

4. 使用 ExecutorService 和任务依赖:线程池管理

如果你希望在大规模并发环境中有效地管理线程并处理任务依赖关系,ExecutorService 是一个非常好的选择。通过线程池,你可以提交多个任务,并通过 Future 对象确保任务按顺序执行。这种方式更加灵活,也适用于大规模并发和任务调度。

为什么选择 ExecutorService

ExecutorService 提供了一个线程池,可以高效地调度线程任务。通过 Future.get() 方法,可以确保任务按顺序执行。它不仅适用于线程的顺序控制,还可以有效管理线程资源。

示例代码:

java 复制代码
package com.demo.java.test;

import java.util.concurrent.*;

public class SequentialExecutorService {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        ExecutorService executor = Executors.newFixedThreadPool(3);

        Future<?> future1 = executor.submit(() -> System.out.println("Thread 1"));
        future1.get();  // 确保线程1完成后执行线程2

        Future<?> future2 = executor.submit(() -> System.out.println("Thread 2"));
        future2.get();  // 确保线程2完成后执行线程3

        Future<?> future3 = executor.submit(() -> System.out.println("Thread 3"));
        future3.get();  // 等待线程3完成

        executor.shutdown();
    }
}

通过 ExecutorServiceFuture.get() 方法,我们可以精确控制任务的执行顺序,确保线程按预期的顺序执行。

优点:

  • 高效管理线程:通过线程池管理线程,可以提高并发任务的处理效率。
  • 灵活性强Future.get() 可以确保任务按顺序执行,适用于大规模并发环境。

缺点:

  • 实现较复杂 :相对于 join()CountDownLatchExecutorService 的代码稍微复杂,需要管理线程池和任务。

结果演示

如上案例结果运行展示如下,仅供参考:

总结

回想起来,确保多线程按照顺序执行的需求其实很常见,无论是在开发一个小型的后台服务,还是在一个大型的分布式系统中,这个问题都会不时出现。在我的开发过程中,join() 方法是我最常用的工具之一。它非常简单,只需要调用 join(),线程就会等待另一个线程执行完再继续执行。这样的机制适用于任务顺序比较简单的场景,比如有几个任务按顺序依次完成,它能够帮我们有效地保证线程间的执行顺序。

但随着需求越来越复杂,CountDownLatch 和 CyclicBarrier 这两个工具开始变得更加重要。当多个线程之间有复杂依赖关系时,CountDownLatch 和 CyclicBarrier 就像是多线程世界中的"协调员"。CountDownLatch 让我们能够精确控制线程在完成某些任务后再继续执行,而 CyclicBarrier 则更适用于多个线程需要在某个时刻汇聚在一起的场景,确保它们不会提前或延迟执行。这些工具让我真正体验到并发编程的强大和灵活。

另外,ExecutorService 是我在处理大规模并发任务时的得力助手。它不仅能帮我管理线程池,还能通过 Future.get() 等方法确保任务按照顺序执行。想想以前我手动管理线程的日子,代码凌乱、复杂,还容易出错。而现在,借助线程池和任务调度,我能更好地组织多线程工作,既高效又清晰。

每种工具都有其适用的场景,join() 简单直接,适合一些顺序执行的场景;CountDownLatch 和 CyclicBarrier 更适合复杂的线程依赖;而 ExecutorService 则适用于大规模的任务管理。通过这些工具,我们不仅能解决线程顺序问题,还能提升系统的稳定性和性能。随着开发经验的积累,我越来越能体会到并发编程中的精妙之处。多线程世界并不只是简单的"并行"与"异步"而已,它关乎线程之间的协调、资源管理、错误处理等等方方面面,掌握了这些工具,就像是掌握了进入并发世界的钥匙。

总的来说,线程的顺序执行在多线程编程中是一个非常重要的需求,正确地选择和使用合适的同步工具,能够让我们的程序更加高效、稳定。在这个充满挑战和机遇的多线程世界里,掌握了这些技巧和工具,我们就能在开发过程中游刃有余,避免常见的并发问题,写出更加健壮的代码。随着技术不断进步,我相信未来会有更多更灵活的工具帮助我们应对多线程带来的挑战,让我们在高并发环境下的开发起来会更加灵活更加轻松,这也是我们所共同期待的。

... ...

文末

好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。

... ...

学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!

wished for you successed !!!


⭐️若喜欢我,就请关注我叭。

⭐️若对您有用,就请点赞叭。

⭐️若有疑问,就请评论留言告诉我叭。


版权声明:本文由作者原创,转载请注明出处,谢谢支持!

相关推荐
ゞ 正在缓冲99%…1 分钟前
leetcode167.两数之和||
java·算法·leetcode·双指针
键盘不能没有CV键16 分钟前
【日志链路】⭐️SpringBoot 整合 TraceId 日志链路追踪!
java·git·intellij-idea
CloudWeGo19 分钟前
Kitex Release v0.13.0正式发布!
后端·架构·github
路在脚下@24 分钟前
RabbitMQ惰性队列的工作原理、消息持久化机制、同步刷盘的概念、延迟插件的使用方法
java·rabbitmq
快乐源泉29 分钟前
【设计模式】状态模式,为何状态切换会如此丝滑?
后端·设计模式·go
衝衝29 分钟前
Spring Data JPA技术深度解析
后端
玛奇玛丶31 分钟前
java的Random居然是假随机
后端
麓殇⊙31 分钟前
Mybatis-缓存详解
java·缓存·mybatis
码农小站32 分钟前
Elasticsearch 深度分页踩坑指南:从报错到终极解决方案
后端
InsightFuture32 分钟前
Java金额转换实战:从数字到中文大写金额的完整实现
后端