【2023】JAVA多线程,不同线程如何实现通信

一、线程间通信

线程间两个关键问题

  • 线程间如何通信?即:线程之间以何种机种来交换信息;
  • 线程间如何同步?即:线程以何种机制来控制不同线程间操作发送的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下表所示:

在这里插入图片描述

java内存模型的抽象结构

运行时内存的划分

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OqDDhj0P-1688461734883)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/c78ee9c6-8f0a-484b-847c-39db7368fbe2/Untitled.png)]

对于每一个线程来说,栈都是私有的,而堆是共有的。

也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性(下文会说到)的问题,也不受内存模型的影响。而在堆中的变量是共享的,本文称为共享变量。

所以,内存可见性是针对的共享变量

既然堆是共享的,为什么在堆中会有内存不可见问题?

这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为cpu访问缓存区比访问内存要快得多。

线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是java内存模型的一个抽象概念,并不真实存在。它覆盖了缓存、写缓存区、寄存器等。

在这里插入图片描述

从图中可以看出:

  1. 所有的共享变量都存在主内存中。
  2. 每个线程都保存了一份该线程使用到的共享变量的副本。
  3. 如果线程A与线程B之间要通信的话,必须经历下面2个步骤:
    1. 线程A将本地内存A中更新过的共享变量刷新到主内存中去。
    2. 线程B到主内存中去读取线程A之前已经更新过的共享变量。

所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。

注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取

所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证

Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。

二、线程通信类

位于java.util.concurrent包下

作用
Semaphore 限制线程的数量
Exchanger 两个线程交换数据
CountDownLatch 线程等待直到计数器减为0时开始工作
CyclicBarrier 作用跟CountDownLatch类似,但是可以重复使用
Phaser 增强的CyclicBarrier
AtomicBoolean 在多线程环境下,保证原子性,一次只会有一个线程进行更改状态
CountDownLatch 一个定时器,在多线程下使用,当数为0时停止等待

1、Semaphore

Semaphore翻译过来就是信号的意思。顾名思义,这个工具类提供的功能就是多个线程彼此”打信号“。而这个”信号“是一个int类型的数据,也可以看成是一种”资源“。

最主要的方法是acquire方法和release方法。acquire()方法会申请一个permit,而release方法会释放一个permit。当然,你也可以申请多个acquire(int permits)或者释放多个release(int permits)。

每次acquire,permits就会减少一个或者多个。如果减少到了0,再有其他线程来acquire,那就要阻塞这个线程直到有其它线程release permit为止。

2、Exchanger

**Exchanger类用于两个线程交换数据。**它支持泛型,也就是说你可以在两个线程之间传送任何数据。

public class ExchangerDemo {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Exchanger<String> exchanger = new Exchanger<>();

        new Thread(() -> {
    
    
            try {
    
    
                System.out.println("这是线程A,得到了另一个线程的数据:"
                        + exchanger.exchange("这是来自线程A的数据"));
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
        }).start();
        System.out.println("这个时候线程A是阻塞的,在等待线程B的数据");
        Thread.sleep(1000);
        new Thread(() -> {
    
    
            try {
    
    
                System.out.println("这是线程B,得到了另一个线程的数据:"
                        + exchanger.exchange("这是来自线程B的数据"));
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
        }).start();
    }
}

输出:

这个时候线程A是阻塞的,在等待线程B的数据

这是线程B,得到了另一个线程的数据:这是来自线程A的数据

这是线程A,得到了另一个线程的数据:这是来自线程B的数据

可以看到,当一个线程调用exchange方法后,它是处于阻塞状态的,只有当另一个线程也调用了exchange方法,它才会继续向下执行。看源码可以发现它是使用park/unpark来实现等待状态的切换的,但是在使用park/unpark方法之前,使用了CAS检查,估计是为了提高性能。

Exchanger一般用于两个线程之间更方便地在内存中交换数据,因为其支持泛型,所以我们可以传输任何的数据,比如IO流或者IO缓存。根据JDK里面的注释的说法,可以总结为一下特性:

  • 此类提供对外的操作是同步的;
  • 用于成对出现的线程之间交换数据;
  • 可以视作双向的同步队列;
  • 可应用于基因算法、流水线设计等场景。

Exchanger类还有一个有超时参数的方法,如果在指定时间内没有另一个线程调用exchange,就会抛出一个超时异常。

public V exchange(V x, long timeout, TimeUnit unit)

那么问题来了,Exchanger只能是两个线程交换数据吗?那三个调用同一个实例的exchange方法会发生什么呢?答案是只有前两个线程会交换数据,第三个线程会进入阻塞状态。

需要注意的是,exchange是可以重复使用的。也就是说。两个线程可以使用Exchanger在内存中不断地再交换数据

3、CountDownLatch

其实CountDownLatch类的原理挺简单的,内部同样是一个继承了AQS的实现类Sync,且实现起来还很简单,可能是JDK里面AQS的子类中最简单的实现了,有兴趣的读者可以去看看这个内部类的源码。

需要注意的是构造器中的计数值(count)实际上就是闭锁需要等待的线程数量。这个值只能被设置一次,而且CountDownLatch没有提供任何机制去重新设置这个计数值

4、CyclicBarrier

CyclicBarrier没有分为await()countDown(),而是只有单独的一个await()方法。

5、Phaser

Phaser这个单词是“移相器,相位器”的意思(好吧,笔者并不懂这是什么玩意,下方资料来自百度百科)。这个类是从JDK 1.7 中出现的。

名词解释:

  • party:对应一个线程,数量可以通过register或者构造参数传入;
  • arrive:对应一个party的状态,初始时是unarrived,当调用arriveAndAwaitAdvance()或者 arriveAndDeregister()进入arrive状态,可以通过getUnarrivedParties()获取当前未到达的数量;
  • register:注册一个party,每一阶段必须所有注册的party都到达才能进入下一阶段;
  • deRegister:减少一个party。
  • phase:阶段,当所有注册的party都arrive之后,将会调用Phaser的onAdvance()方法来判断是否要进入下一阶段。

6、AtomicBoolean

AtomicBoolean是java.util.concurrent.atomic包下的原子变量,这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。

  • 创建
    • AtomicBoolean LOGIN = new AtomicBoolean(false);
  • 使用
    • LOGIN.set(true);

7、CountDownLatch

CountDownLatch可以使一个获多个线程等待其他线程各自执行完毕后再执行。

CountDownLatch 定义了一个计数器,和一个阻塞队列, 当计数器的值递减为0之前,阻塞队列里面的线程处于挂起状态,当计数器递减到0时会唤醒阻塞队列所有线程,这里的计数器是一个标志,可以表示一个任务一个线程,也可以表示一个倒计时器,CountDownLatch可以解决那些一个或者多个线程在执行之前必须依赖于某些必要的前提业务先执行的场景。

  • 创建

CountDownLatch WAIT = new CountDownLatch(1);

  • 使用
    • CountDownLatch(int count); //构造方法,创建一个值为count 的计数器。
    • await();//阻塞当前线程,将当前线程加入阻塞队列。
    • await(long timeout, TimeUnit unit);//在timeout的时间之内阻塞当前线程,时间一过则当前线程可以执行,
    • countDown();//对计数器进行递减1操作,当计数器递减至0时,当前线程会去唤醒阻塞队列里的所有线程。

猜你喜欢

转载自blog.csdn.net/weixin_52315708/article/details/131539120