我看谁还不懂多线程之间的通信+基础入门+实战教程+详细介绍+附源码

时间:2022-11-05 15:08:08

1、多线程概念介绍

多线程概念

  • 在我们的程序层面来说,多线程通常是在每个进程中执行的,相应的附和我们常说的线程与进程之间的关系。线程与进程的关系:线程可以说是进程的儿子,一个进程可以有多个线程。但是对于线程来说,只属于一个进程。再说说进程,每个进程的有一个主线程作为入口,也有自己的唯一标识PID,它的PID也就是这个主线程的线程ID

  • 对于我们的计算机硬件来说,线程是进程中的一部分,也是进程的的实际运作单位,它也是操作系统中的最小运算调度单位。多线程可以提高CPU的处理速度。当然除了单核CPU,因为单核心CPU同一时间只能处理一个线程。在多线程环境下,对于单核CP来说,并不能提高响应速度,而且还会因为频繁切换线程上下文导致性能降低。多核心CPU具有同时并行执行线程的能力,因此我们需要注意使用环境。线程数超出核心数时也会引起线程切换,并且操作系统对我们线程切换是随机的。

2、线程之间如何通信

引入

  • 对于我们Java语言来说,多线程编程也是它的特性之一。我们需要利用多线程操作同一共享资源,从而实现一些特殊任务。上面说了,多线程在进行切换时CPU随机调度的,假如我们直接运行多个线程操作共享资源的话,势必会引起一些不可控错误因素。
  • 接下来,我们就需要让这些不可控变为可控 !这个时候就引出了本文的重点线程通信。线程通信就是为了解决多线程对同一共享变量的争夺

Java 线程通信的方式

  • 共享内存机制
    • 比如说Java的volatile关键字就是基于内存屏障解决变量的可见性,从而实现其他线程访问共享变量都是必须从主存中获取(对应其他线程对变量的更新也得及时的刷新到主存)。
    • synchronized 关键字基于对象锁这种方式实现线程互斥,可以通知对方有其他的线程正在执行这部分代码。
  • 消息传递模式
    • wait() 和 notify()/notifyAll() 等待通知方式实现线程的阻塞就绪状态之间的转换。
    • park、unpark
    • join() 阻塞【底层也是依赖wait实现】。
    • interrupt()打断阻塞状态。
    • 管道输入/输出。

3、线程通信方法详细介绍

主要介绍wait/notify,也有ReentrantLock的Condition条件变量的await/signal,LockSupport的park/unpark方法,也能实现线程之间的通信。主要是阻塞/唤醒通信模式。

首先说明这种方法一般都是作用于调用方法的所在线程。比如在主线程执行wait方法,就是将主线程阻塞了。

wait/notify机制

  • wait()、notify方法在Java中是Object提供给我们的。又因为所有的类都默认隐式继承了Object类,进而我们的每一个对象都具有wait和notify。
    • wait方法含义:一个线程一旦调用了任意对象obj.wait()方法,它就释放了所持有的监视器对象(obj)上的锁,并转为非运行状态(阻塞)。
    • notify方法含义:一个线程若执行obj.notify方法,则随机唤醒obj对象上监视器(操作系统也称为管程)monitor的阻塞队列waitset中一个线程。
    • wait和notify方法的使用同时必须配合synchronized关键字使用。同时也需要成对出现。就是说wait和notify必须得在同步代码块内部使用,大致原因就是需要保证同时只有一个线程可以去执行wait,使该线程阻塞。

await/signal

  • 要想使用await/signal首先是需要借用Condition条件变量,要想获取Condition条件变量,就必须通过ReentrantLock锁获取。
  • ReentrantLock和Synchronized类似,都是可重入锁,并且大多都是当做重量级锁使用。
    • 区别:ReentrantLock是API层面实现的,我们可以根据自己随意调用定制,但是Synchronized是JVM底层实现,我们无需关心他上锁解锁的流程。
  • await/signal使用时需要配合ReentrantLock锁对象的lock和unlock方法加锁解锁。就像wait/notify在synchronized在同步代码块中使用一样。他们都需要保证当前线程是唯一执行这段逻辑的线程。防止出现多线程造成的线程安全问题。

park/unpark

二、线程通信过程中需要注意的问题

1、唤醒丢失

如果一个线程先于被通知线程调用wait()前调用了notify(),等待的线程将错过这个信号。

  • 唤醒丢失主要是在我们使用wait 和 notify的过程中的时序问题。比如说我们线程二在执行某个对象notify的时候,线程一还没有执行该对象的wait方法。那么这次的唤醒就会丢失,我们就不能让线程二得notify方法起作用,自然而然线程一就不会被唤醒。
  • 举个例子吧,这就好比我们平常在宿舍每天都会有叫醒服务,但是这次 因为一些原因(通宵···)我一整晚都没有睡觉,而且当第二天早上的叫醒服务来的 时候也是醒着的。那么叫醒服务就会以为你已经醒来了,就会视而不见。没想到吧,叫醒服务刚走我就躺下来睡着了,所以我错过了这次叫醒服务。就能好好的睡亿觉了。这看起来没有什么大问题,但是你仔细想想若是每个睡着的人都需要被叫醒服务才能醒过来,外加上只有一次叫醒服务的机会。那么你就可以沉睡万年了,开心不。
  • 哈哈哈···
  • 这在程序中也是一样 的,如果错过notify那么就会一直wait。
    • 所以我们必须预防这种问题,比如说每隔一段时间去唤醒,也就是隔两分钟就去叫醒睡着的人。但是这种缺点就是太累了,对于程序来说是消耗性能和内存。实现也简单就是写入while循环体中,不停地尝试即可。
    • 我们也可以使用一个标志位完美的实现。初始化设置flag=FALSE表示还没wait,在wait之前将设置flag=TRUE,在notify之后设置flag=FALSE。每次notify唤醒之前都判断flag=true是否已经wait,在wait中判断flag=false是否已经notify。

核心代码演示

  • 首先使用线程池创建线程一使自己进入阻塞态,然后再调用LOCK1的notify方法唤醒线程一
	    // 线程一使用LOCK1对象调用wait方法阻塞自己
        executor.execute(new ThreadTest("线程一",LOCK1,LOCK2));

        synchronized (LOCK1) {
            System.out.println("main执行notify方法让线程一醒过来");
            LOCK1.notify();
        }
  • 但是他很有可能醒不来,因为主线程调用LOCK1对象的notify方法,可能主线程已经执行完了,上面线程还没创建完成,也就是没有进入wait状态。就醒不来了。

  • 解决方式:使用信号量标志进行判断是否已经进入wait

            synchronized (LOCK1) {
                while (true) {
                    if (FLAG.getFlag()) {
                        System.out.println("main马上执行notify方法让线程一醒过来" + "flag = " + FLAG.getFlag());
                        LOCK1.notify();
                        // 将标志位变为FALSE
                        FLAG.setFlag(Constants.WaitOrNoWait.NO_WAIT.getFlag());
                        System.out.println("main执行notify方法完毕" + "flag = " + FLAG.getFlag());
                        break;
                    }
                }
            }
    

2、假唤醒

由于莫名其妙的原因,线程有可能在没有调用过notify()和notifyAll()的情况下醒来。

  • 其实在上面的代码中已经解决了假唤醒的问题,因为我们只需要不断去尝试获取标志位信息即可。

3、多线程唤醒

  • 多个线程执行时,防止notifyAll全部唤醒之后就结束运行,我们的需求是只能唤醒一个线程,当其他线程被唤醒之后需要重新判断标志位是否为FALSE,也就是需要判断是否有其他线程执行了唤醒操作,因为一次只能叫醒一个人,需要排队,他们就可以继续自旋判断。
		synchronized (waitName) {
            while (!flag.getFlag()) {
                try {
                    // 将标志位设置为TRUE
                    flag.setFlag(Constants.WaitOrNoWait.WAIT.getFlag());
                    System.out.println("name;"+name+" 我睡着了进入阻塞状态" + "flag = " + flag.getFlag());
                    waitName.wait();
                    System.out.println("name;"+name+" 我醒来了" + "flag = " + flag.getFlag());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
  • 大家如果使用的是new Thread()方式创建线程的话,要想保证安全的话还可以给该标志位加上volatile关键字,可以时刻保证该标志位的可见性。
  • 我这里使用的标志位是使用传递引用的方式,使用同一个对象,将标志位定义为该对象中的属性,然后再结合枚举类进行设置标志位的值。因为我使用线程池创建对象,并且自定义线程类,这里是无法设置全局变量,传递给线程类。包装类也不行哦。(感兴趣可以亲自试一下)
  • 大体代码结构如下所示:
	private final static Object LOCK1 = new Object();
    private final static Object LOCK2 = new Object();
    private final  static Constants.WaitStatus FLAG = new Constants.WaitStatus(false);
    public static void main(String[] args) throws InterruptedException {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 5, 1, TimeUnit.DAYS, new ArrayBlockingQueue<>(4), new ThreadPoolExecutor.AbortPolicy());
        executor.execute(new ThreadTest("线程一",LOCK1,LOCK2, FLAG));
        // ···唤醒
    }

class ThreadTest implements Runnable { //阻塞··· }

完整代码可以看这[Gitee仓库完整代码][https://gitee.com/malongfeistudy/javabase/tree/master/Java多线程_Study/src/main/java/com/mlf/thread/demo_wait_notify]

三、线程通信实战

前置知识:线程池的使用方法

  • 首先复习一下创建线程的几种方式和其的优缺点:

    • 通过new Thread()
    • 继承Thread():和new Thread没啥区别,就是耦合度低了
      • 定义线程类继承Thread类并且重写run方法即可。
      • 优点是简洁方便
      • 缺点是占用了该类的单继承位置,无法继承其他父类
    • 实现Runnable接口
    • 实现Callable接口
      • 和实现Runnable接口类似
      • 优点:
        • 实现接口,不占用继承的位置;
        • 耦合度降低,并且可定化程度提高。各个模块之间的调用关系更加清晰
      • 缺点:
        • 实现起来稍微麻烦
  • 使用线程池的步骤

    • 线程池初始化方式:
      • 使用Executor初始化线程池
        • 优点:方便快捷,适用于自己测试时使用
        • 缺点:在实际开发中无法判断细节
      • new ThreadPoolExecutor()构造器创建(本文使用方式)
        • 优点:可以清晰地定制出适合自己的线程池,不会造成资源浪费
        • 缺点:麻烦
  • 在主线程自定义线程池使用实例,这里需要根据实际情况定义锁对象,因为我们需要使用这些锁对象控制多线程之间的运行顺序以及线程之间的通信。在Java中每个对象都会在初始化的时候拥有一个监视器,我们需要利用好他进行并发编程。这种创建线程池的方法也是阿里巴巴推荐的方式,想想以阿里的体量多年总结出来的总没有错,大家还是提前约束自己的编码习惯等。安装一个阿里代码规范的插件对自己的程序员道路是比较nice的。

    /**
     * 每个使用对应唯一的对象作为监视器对象锁。
     */
    public static final Object A_O = new Object();
    public static final Object B_O = new Object(); 
        /** 参数:
         * int corePoolSize,                     核心线程数
         * int maximumPoolSize,                  最大线程数
         * long keepAliveTime,                   救急存活时间
         * TimeUnit unit,                        单时间位
         * BlockingQueue<Runnable> workQueue,    阻塞队列
         * RejectedExecutionHandler handler      拒绝策略
         **/
        // 使用阿里巴巴推荐的创建线程池的方式
        // 通过ThreadPoolExecutor构造函数自定义参数创建
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                3,
                5,
                1,
                TimeUnit.DAYS,
                new ArrayBlockingQueue<>(2),
                new ThreadPoolExecutor.AbortPolicy());
  • 接下来需要自定义线程类,我们可以自定义线程类,并且在该线程类中定义自己需要的共享资源(锁对象,属性等),在run方法中写尽自己的线程运行逻辑即可。
class ThreadDiy implements Runnable {

    private final String name;

    /**
     * 阻塞锁对象  等待标记
     **/
    private final Object waitFor;

    /**
     * 执行锁对象  下一个标记
     **/
    private final Object next;

    public AlternateThread(String name, Object waitFor, Object next) {
    }

    @Override
    public void run() {
        // 线程的代码逻辑···
    }

}

1、控制两个线程之间的执行顺序

题目:现在有两个线程,不论线程的启动顺序,我需要指定线程一先执行,然后线程二再执行。

  • 初始化两个对象锁作为线程监视器。

        private final static Object ONE_LOCK = new Object();
        private final static Object TWO_LOCK = new Object();
    
  • 接下来初始化线程池,上面有具体的介绍,在这就不多说了

  • 使用线程池去执行我们的两个线程,在这里我们需要分析的是

        // 使用线程池创建线程
        executor.execute(new DiyThread(1, ONE_LOCK, TWO_LOCK));
        executor.execute(new DiyThread(2, TWO_LOCK, ONE_LOCK));

        synchronized (ONE_LOCK) {
            ONE_LOCK.notify();
        }

创建线程类

  • 我们使用继承Runnable的方式去创建线程对象,需要在这个类中实现每个线程执行的逻辑,我们根据题目可以得出,我们要控制每个线程的执行顺序,怎么办?那么就要实现所有线程之间的通信,通信方式采用wait-notify的方式即可。我们使用wait-notify的时候必须结合synchronized,那么就需要控制两个对象锁。因为我们不光是控制自己,还有另一个线程。

  • 我们再分析一下题意,首先需要指定先后执行的顺序,那么就需要实现两个线程之间的通信。其次呢,我们得控制两个线程,那么就需要两个监视器去监视这两个线程。

  • 我们定义这两个监视器对象为own和other。然后再新增一个属性threadId来标识自己。

        private final int threadId;
        private final Object own;
        private final Object other;
    
  • 接下来就是编写Run方法了

  • 每个线程首先需要阻塞自己,等待唤醒。然后唤醒之后,再去唤醒另外一个线程。这样就实现了自定义顺序。至于先唤醒哪个线程,交给我们的主线程去完成。

  • 这里需要注意的是,如果我们只是单纯地执行了多个线程对象,但是主线程没有主动去唤醒其中一个,这样就会形成类似于死锁的循环等待。你需要我唤醒,我需要你唤醒。这个时候需要主线程去插手唤醒其中的任意一个线程。

    • 第一步阻塞自己own

              synchronized (own) {
                  try {
                      own.wait();
                      System.out.println(num);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
              }
      
    • 第二步唤醒other

              synchronized (other) {
                  other.notify();
              }
      

2、多线程交替打印输出

题目需求:现在需要使用三个线程轮流打印输出。说白了也就是多线程轮流执行罢了,和问题一控制两个线程打印顺序没什么区别

  • 还是老步骤,首先需要定义线程类,我们需要控制当前线程和下一个线程即可。我们这里需要两个对象,一个是阻塞锁对象用来阻塞当前线程。另一个是唤醒锁对象,用来唤醒下一个对象。
    /**
     * 阻塞锁对象  等待标记
     **/
    private final Object waitFor;
    /**
     * 唤醒锁对象  下一个标记
     **/
    private final Object next;
  • run方法的逻辑和上面的基本一样。 一个线程一旦调用了任意对象的wait()方法,它就释放了所持有的监视器对象上的锁,并转为非运行状态。

  • 每个线程首先会调用 waitFor对象的 wait()方法,随后该线程进入阻塞状态,等待其他线程执行自己引用的该 waitFor对象的 notify()方法即可。

    		while (true) {
                synchronized (waitFor) {
                    try {
                        waitFor.wait();
                        System.out.println(name + " 开始执行");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                synchronized (next) {
                    next.notify();
                }
            }
    
  • 主线程需要初始化线程池、执行三个线程,并且最后需要打破僵局,因为此时每个线程都是阻塞状态,他们没法阻塞/唤醒循环下去。

            synchronized (A_O) {
                A_O.notify();
            }
    
  • 模拟执行流程

/**
 * 模拟执行流程
 * 打印名(name)    等待标记(waitFor)   下一个标记(next)
 *      1                 A                  B
 *      2                 B                  C
 *      3                 C                  A
 * 
 * 像不像Spring的循环依赖:确实很像,Spring中的循环依赖就是 BeanA 依赖 BeanB,BeanB 依赖 BeanA;
 * 他们实例化过程中都需要先属性注入对方的实例,倘若刚开始的时候都没有实例化,初始化就会死等。类似于死锁。
 **/

3、多线程顺序打印同一个自增变量

使用多线程轮流打印 01234····

  • 思路:使用自增原子变量AtomicInteger和多线程配合打印。

具体代码请移步到Gitee仓库:[顺序打印自增变量][https://gitee.com/malongfeistudy/javabase/blob/master/Java多线程_Study/src/main/java/com/mlf/thread/print/AddNumberPrint2.java]

条件变量Condition的使用

  • Condition是一个 LOCK 实例出来的,他们获取的都是一个 LOCK 的锁,而如果要调用 object的 wait和notify 方法,首先要获取对应的object的锁,如果要调用Condition 的await、signal方法,必须先获取Lock锁(Lock.lock)。
  • 多线程的初衷就是操作共享资源,然后我们需要保证共享资源同一时刻只能被一个线程所修改。那么就需要一把锁来控制这些线程之间互斥条件。这里使用一个ReentrantLock锁作为我们的Lock对象。通过同一个 Lock锁 获取的每个Condition 就可以作为每个线程自己的阻塞条件和唤醒条件。

如有问题,请留言评论。