java多线程 并发基础

目录

说说并发与并行的区别?

为什么要使用多线程呢?

使用多线程可能带来什么问题?

互斥与同步

互斥

同步

并发编程的四大性

原子性

原子性问题的产生的原因

原子性问题的案例

可见性

可见性问题产生的原因

可见性问题的案例

有序性

有序性问题产生的原因

有序性问题的案例

活跃性

线程死锁

死锁简介

活锁

饥饿

如何预防和避免线程死锁?

信号量 vs 管程

信号量

管程


注意:本文参考  docs/java/concurrent/java-concurrent-questions-01.md · SnailClimb/JavaGuide - Gitee.com

学妹问我,并发问题的根源到底是什么?

敖丙稳住了多线程翻车的现场

2w字 + 40张图带你参透并发编程!

说说并发与并行的区别?

并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);

并发意味着应用程序会执行多个的任务,但是如果计算机只有一个 CPU 的话,那么应用程序无法同时执行多个的任务,但是应用程序又需要执行多个任务,所以计算机在开始执行下一个任务之前,它并没有完成当前的任务,只是把状态暂存,进行任务切换,CPU 在多个任务之间进行切换,直到任务完成。如下图所示

java多线程 并发基础_第1张图片

 并行: 单位时间内,多个任务同时执行。

并行是指应用程序将其任务分解为较小的子任务,这些子任务可以并行处理,例如在多个CPU上同时进行。

java多线程 并发基础_第2张图片

为什么要使用多线程呢?

先从总体上来说:

从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。

从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

单核时代: 在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。

多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。

操作系统实现多个程序同时运行解决了单个程序无法做到的问题,主要有下面三点

资源利用率,我们上面说到,单个进程存在资源浪费的情况,举个例子,当你在为某个文件夹赋予权限的时候,输入程序无法接受外部的输入字符,只有等到权限赋予完毕后才能接受外部输入。总的来讲,就是在等待程序时无法执行其他工作。如果在等待程序时可以运行另一个程序,那么将会大大提高资源的利用率。(资源并不会觉得累)因为它不会划水~

公平性,不同的用户和程序都能够使用计算机上的资源。一种高效的运行方式是为不同的程序划分时间片来使用资源,但是有一点需要注意,操作系统可以决定不同进程的优先级。虽然每个进程都有能够公平享有资源的权利,但是当有一个进程释放资源后的同时有一个优先级更高的进程抢夺资源,就会造成优先级低的进程无法获得资源,进而导致进程饥饿。

便利性,单个进程是是不用通信的,通信的本质就是信息交换,及时进行信息交换能够避免信息孤岛,做重复性的工作;任何并发能做的事情,单进程也能够实现,只不过这种方式效率很低,它是一种顺序性的。

使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

安全性还有有序性

java多线程 并发基础_第3张图片

互斥与同步

互斥

竞争条件(race condition,当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate

由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。

我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。

java多线程 并发基础_第4张图片

另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。

同步

互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个进程/线程进入了临界区,其他试图想进入临界区的进程/线程都会被阻塞着,直到第一个进程/线程离开了临界区。

我们都知道在多线程里,每个线程并一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个线程能密切合作,以实现一个共同的任务。

例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。

所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步

举个生活的同步例子,你肚子饿了想要吃饭,你叫妈妈早点做菜,妈妈听到后就开始做菜,但是在妈妈没有做完饭之前,你必须阻塞等待,等妈妈做完饭后,自然会通知你,接着你吃饭的事情就可以进行了。

 java多线程 并发基础_第5张图片

注意,同步与互斥是两种不同的概念:

同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等;

互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」;

并发编程的四大性

并发编程三大核心基础理论:原子性、可见性、有序性,还有活跃性

原子性

先来看下什么叫原子性

第一种理解:原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意 为“不可被中断的一个或一系列操作”

第二种理解:原子性,即一个操作或多个操作,要么全部执行并且在执行的过程中不被打断,要么全部不执行。(提供了互斥访问,在同一时刻只有一个线程进行访问)

原子,在物理学中定义是组成物体的不可分割的最小的单位。在 java 并发编程中我们可以将其理解为:一组要么成功要么失败的操作

由Java内存模型来直接保证的原子性变量操作包括read、 load、assign、use、store和write这六个,我们大致可以认为基本数据类型的访问读写 是具备原子性的(long和double的非原子性协定例外,知道这件事情就可 以了,无须太过在意这些儿乎不会发生的例外情况)。

如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放 给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地 使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

原子性问题的产生的原因

原子性问题产生的根本原因是什么?我们只要知道了症状才能准确的对症下药,本小节,我们就来一起探讨下原子性问题的由来。

我们都知道,程序在执行的时候,一定是以线程为单位在执行的,因为线程是 CPU 进行任务调度的基本单位

电脑的 CPU 会根据不同的任务调度算法去执行线程的调度,将时间分片并派分给各个线程。

当某个线程获得CPU的时间片之后就获取了CPU的执行权,就可以执行任务,当时间片耗尽之后,就会失去CPU使用权。

进而本任务会暂时的停止执行。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题

其实就是,某一段代码,应该单独执行,但是由于交替执行,导致出问题了

看完理论似乎并不能直观的理解原子性问题。下面我们就通过代码的方式来具体阐述下原子性问题的产生原因。

原子性问题的案例

我们以常见的 i++ 为例,这是一个老生常谈的原子性问题了,先来看下代码

public class AtomicDemo {

    private int count = 0;

    public void add() {

        count++;

    }

    public int get() {

        return count;

    }

    public static void main(String[] args) throws InterruptedException {

        CountDownLatch countDownLatch = new CountDownLatch(100);

        AtomicDemo atomicDemo = new AtomicDemo();

        IntStream.rangeClosed(0, 100).forEach(item -> {

            new Thread(() -> {

                IntStream.rangeClosed(1, 100).forEach(i -> {

                    atomicDemo.add();

                });

            }).start();

            countDownLatch.countDown();

        });

        countDownLatch.await();

        System.out.println(atomicDemo.get());

    }

}

上面 代码的作用是将初始值为0的 count 变量,通过100线程每个线程累加100次的方式来累加。想要得到一个结果为 10000 的值。但是实际上结果很难达到10000。

产生这个问题的原因:

count++ 的执行实际上这个操作不是原子性的,因为 count++ 会被拆分成以下三个步骤执行(这样的步骤不是虚拟的,而是真实情况就是这么执行的)

第一步:读取 count 的值;

第二步:计算 +1 的结果;

第三步:将 +1 的结果赋值给 count变量

那问题又来了。分三步又咋样?让他执行完不就行了?

理论上是这样子的,大家都很友好,你执行完我执行,我执行完你继续。你想象的可能是这样的”乌托邦图“

java多线程 并发基础_第6张图片

但是实际上这些线程已经”黑化”了。他们绝不可能互相谦让。CPU或者是程序的世界观里面。大家做任何事情都是在”争抢“。我们来看下面这张图: 

java多线程 并发基础_第7张图片

上图详细分析:

第一步:A线程从主内存中读取 count 的值 0;

第二步:A线程开始对 count 值进行累加;

第三步:B线程从主内存中读取 count 的值 0(PS:具体第三步从哪里开始都不是重点,重点是:A线程将 count 值写入主内存之前 B 线程就开始读取 count 并执行此时 B线程 读取到的 count 值依旧是还未被操作过的原始值);

第四步:(PS:到这里其实已经不重要了。因为不管 A线程和B线程现在怎么操作。结果已经不可逆转,已经错了)B线程开始对 count 值进行累加;

第五步:A 线程将累加后的结果赋值给 count 结果为 1;

第六步:B 线程将累加后的结果赋值给 count 结果为 1;

第七步:A 线程将结果 count =1 刷回到主内存;

第八步:B 线程将结果 count =1 刷回到主内存;

相信大家此时已经非常清晰地分析出了原子性产生的根本原因了。

至于解决方案可以通过锁或者是 CAS 的方式。具体方案就不再这里赘述了。

可见性

万丈高楼平地起,再复杂的技术我们也需要从基本的概念看起来:

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

不能看到别人对某个变量的修改,导致自己读取变量还是老变量,或者覆盖了别人对它的修改,会导致出问题了

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量

如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程 操作时变量的可见性,而普通变量则不能保证这点。

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和 final。

同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回内存中(执行sotre和write操作)”这条规则获得的。

而final关键字的可见性是指: 被final修饰的字段在构造器中一且被初始化完成,并且构造器没有把"this"的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初 始化了一半"的对象),那么在其他线程中就能看见final字段的值。它们无须同步就能被其他线程正确地访问。

可见性问题产生的原因

在很多年前,那个嫁妆只需要一个手电筒的年代你或许还不会出现可见性这样的问题,因为大家都是单核处理器,不存在并发的情况。

而对于现在“视金钱如粪土”的年代。多核处理器已经是现代超级计算机的基础硬件。高速的CPU处理器和缓慢的内存之前数据的通信成了矛盾。

所以为了解决和缓和这样的情况,每个CPU和线程都有自己的本地缓存,所谓本地缓存即该缓存仅仅对它所在的处理器可见,CPU缓存与内存的数据不容易保证一致。

为了避免这种因为写数据速度不一致而导致 CPU 的性能浪费的情况,处理器通过使用写缓冲区来临时保存待写入主内存的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会立即将数据刷新到主内存中

缓存不能及时刷新到主内存就是导致可见性问题产生的根本原因。

可见性问题的案例

public class AtomicDemo {

    private int count = 0;

    public void add() {

        count++;

    }

    public int get() {

        return count;

    }

    public static void main(String[] args) throws InterruptedException {

        CountDownLatch countDownLatch = new CountDownLatch(100);

        AtomicDemo atomicDemo = new AtomicDemo();

        IntStream.rangeClosed(0, 100).forEach(item -> {

            new Thread(() -> {

                IntStream.rangeClosed(1, 100).forEach(i -> {

                    atomicDemo.add();

                });

            }).start();

            countDownLatch.countDown();

        });

        countDownLatch.await();

        System.out.println(atomicDemo.get());

    }

}

“what * *”,怎么和上面代码一样。。。结果就不截图了,必然不是10000。

我们来看下执行的流程图(PS:不要纠结于为什么和上面的不一样,特定问题特定分析。在阐述一种问题的时候,一定会在某些层面上屏蔽另外一种问题的干扰)

java多线程 并发基础_第8张图片

假设 A 线程和 B 线程同时开始执行,首先 A 线程和 B 线程会将主内存中的 count 的值加载/缓存到自己的本地内存中。然后会读取各自的内存中的值去执行操作,也就是说此时 A 线程和 B 线程就好像是两个世界的人,彼此不会产生任何关联。

操作完之后 A 线程将结果写回到自己的本地内存中,同样 B 线程将结果写回到自己的本地内存中。然后回来某个时机各自将结果刷回到主内存。那最终必然是一方的数据被另一方覆盖。这就是缓存的可见性问题

有序性

不积跬步无以至千里,我们还是先来看概念

有序性:程序执行的顺序按照代码的先后顺序执行。

各种重排序导致程序执行的结果出问题了(本线程内没事,但是如果是多线程,导致执行结果不符合预期)

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

volatile关键字本身就包含了禁止指令重排序的语义。

而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作“这条规则获得的,这个规则决定了持同一个锁的两个同步块只能串行地进入。

有序性问题产生的原因

实际上编译器为了提高程序执行的性能。会改变我们代码的执行顺序的。即你写在前面的代码不一定是先被执行完的。

例如:int a = 1;int b =4;从表面和常规角度来看,程序的执行应该是先初始化 a ,然后初始化 b 。但是实际上非常有可能是先初始化 b,然后初始化 a。因为在编译器看了来,先初始化谁对这两个变量不会有任何影响。即这两个变量之间没有任何的数据依赖。

指令重排序有三种类型,分别为:

1  编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2  指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。

内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上 去可能是在乱序执行。

有序性问题的案例

有序性的案例最常见的就是 DCL了(double check lock)就是单例模式中的双重检查锁功能。先来看下代码

public class SingletonDclDemo {

    private SingletonDclDemo(){}

    private static SingletonDclDemo instance;

    public static SingletonDclDemo getInstance(){

        if (Objects.isNull(instance)) {

            synchronized (SingletonDclDemo.class) {

                if (Objects.isNull(instance)) {

                    instance = new SingletonDclDemo();

                }

            }

        }

        return instance;

    }

    public static void main(String[] args) {

        IntStream.rangeClosed(0,100).forEach(item->{

            new Thread(SingletonDclDemo::getInstance).start();

        });

    }

}

这个代码还是比较简单的。

在获取对象实例的方法中,程序首先判断 instance 对象是否为空,如果为空,则锁定SingletonDclDemo.class 并再次检查instance是否为空,如果还为空则创建 Singleton的一个实例。看似很完美,既保证了线程完全的初始化单例,又经过判断 instance 为 null 时再用 synchronized 同步加锁。但是还有问题!

instance = new SingletonDclDemo(); 创建对象的代码,分为三步:① 分配内存空间;② 初始化对象SingletonDclDemo;③ 将内存空间的地址赋值给instance;

但是这三步经过重排之后:① 分配内存空间 ② 将内存空间的地址赋值给instance ③ 初始化对象SingletonDclDemo

会导致什么结果呢?

线程 A 先执行 getInstance() 方法,当执行完指令②时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行 getInstance() 方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。

继续来张图来更直观的理解下:

java多线程 并发基础_第9张图片

具体的执行流程在上面已经分析了。相信这张图片一定能让你彻底理解。

活跃性

多线程还会带来活跃性问题,如何定义活跃性问题呢?活跃性问题关注的是 「某件事情是否会发生」

「如果一组线程中的每个线程都在等待一个事件的发生,而这个事件只能由该组中正在等待的线程触发,这种情况会导致死锁」

简单一点来表述一下,就是每个线程都在等待其他线程释放资源,而其他资源也在等待每个线程释放资源,这样没有线程抢先释放自己的资源,这种情况会产生死锁,所有线程都会无限的等待下去。

线程死锁

死锁简介

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

java多线程 并发基础_第10张图片

下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):

下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》): public class DeadLockDemo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}
 

Output 
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
 1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。

学过操作系统的朋友都知道产生死锁必须具备以下四个条件:

1 互斥条件:该资源任意一个时刻只由一个线程占用。

2 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

3 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。

4 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

活锁

某些情况下,当线程意识到它不能获取所需要的下一个锁时,就会尝试礼貌的释放已经获得的锁,然后等待非常短的时间再次尝试获取。可以想像一下这个场景:当两个人在狭路相逢的时候,都想给对方让路,相同的步调会导致双方都无法前进。

现在假想有一对并行的线程用到了两个资源。它们分别尝试获取另一个锁失败后,两个线程都会释放自己持有的锁,再次进行尝试,这个过程会一直进行重复。很明显,这个过程中没有线程阻塞,但是线程仍然不会向下执行,这种状况我们称之为 活锁(livelock)

饥饿

如果一个线程无其他异常,但是却迟迟不能继续运行,那基本是处于饥饿状态了

常见的场景:

高优先级的线程一直在运行,消耗cpu资源,所有的低优先级线程一直处于等待

一些线程被永久阻塞在一个等待进入同步快的状态,而其他线程总能在他之前持续的对同步块进行访问

有一个经典的饥饿问题:哲学家用餐问题:如图所示:有5个哲学家在用餐,每个人必须要同时拿两把叉子才可以开始用餐,如果1和3同时开始用餐,那么2,4,5就需要饿肚子等待了

java多线程 并发基础_第11张图片

如何预防和避免线程死锁?

如何预防死锁? 破坏死锁的产生的必要条件即可:

破坏请求与保持条件 :一次性申请所有的资源。

破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

安全状态 指的是系统能够按照某种进程推进顺序(P1、P2、P3.....Pn)来为每个进程分配所需资源,直到满足每个进程对资源的最大需求,使每个进程都可顺利完成。称序列为安全序列。

我们对线程 2 的代码修改成下面这样就不会产生死锁了。

        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 2").start();

Output 

Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2

Process finished with exit code 0

我们分析一下上面的代码为什么避免了死锁的发生?

线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

信号量 vs 管程

在并发编程领域,有两大核心问题:互斥同步,互斥即同一时刻只允许一个线程访问共享资源,同步,即线程之间如何通信、协作,一般这两大问题可以通过信号量管程来解决。

信号量

信号量(Semaphore)是操作系统提供的一种进程间常见的通信方式,主要用来协调并发程序对共享资源的访问,操作系统可以保证对信号量操作的原子性。它是怎么实现的呢。

信号量由一个共享整型变量 S 和两个原子操作 PV 组成,S 只能通过 P 和 V 操作来改变

P 操作:即请求资源,意味着 S 要减 1,如果 S <  0, 则表示没有资源了,此时线程要进入等待队列(同步队列)等待

V 操作:  即释放资源,意味着 S 要加 1, 如果 S 小于等于 0,说明等待队列里有线程,此时就需要唤醒线程。

示意图如下

java多线程 并发基础_第12张图片

信号量机制的引入解决了进程同步和互斥问题,但信号量的大量同步操作分散在各个进程中不便于管理,还有可能导致系统死锁。如:生产者消费者问题中将P、V颠倒可能死锁(见文末参考链接),另外条件越多,需要的信号量就越多,需要更加谨慎地处理信号量之间的处理顺序,否则很容易造成死锁现象。

基于信号量给编程带来的隐患,于是有了提出了对开发者更加友好的并发编程模型-管程

管程

Dijkstra 于 1971 年提出:把所有进程对某一种临界资源的同步操作都集中起来,构成一个所谓的秘书进程。凡要访问该临界资源的进程,都需先报告秘书,由秘书来实现诸进程对同一临界资源的互斥使用,这种机制就是管程。

管程是一种在信号量机制上进行改进的并发编程模型,解决了信号量在临界区的 PV 操作上配对的麻烦,把配对的 PV 操作集中在一起而形成的并发编程方法理论,极大降低了使用和理解成本。

管程由四部分组成:

1 管程内部的共享变量。

2 管程内部的条件变量。

3 管程内部并行执行的进程。

4 对于局部与管程内部的共享数据设置初始值的语句。

由此可见,管程就是一个对象监视器。任何线程想要访问该资源(共享变量),就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。

需要注意的事,信号量和管程两者是等价的,信号量可以实现管程,管程也可以实现信号量,只是两者的表现形式不同而已,管程对开发者更加友好。

两者的区别如下

 java多线程 并发基础_第13张图片

管程为了解决信号量在临界区的 PV 操作上的配对的麻烦,把配对的 PV 操作集中在一起,并且加入了条件变量的概念,使得在多条件下线程间的同步实现变得更加简单。

怎么理解管程中的入口等待队列,共享变量,条件变量等概念,有时候技术上的概念较难理解,我们可以借助生活中的场景来帮助我们理解,就以我们的就医场景为例来简单说明一下,正常的就医流程如下:

1 病人去挂号后,去侯诊室等待叫号

2 叫到自己时,就可以进入就诊室就诊了

3 就诊时,有两种情况,一种是医生很快就确定病人的病,并作出诊断,诊断完成后,就通知下一位病人进来就诊,一种是医生无法确定病因,需要病人去做个验血 / CT 检查才能确定病情,于是病人就先去验个血 /  CT

4 病人验完血 / 做完 CT 后,重新取号,等待叫号(进入入口等待队列)

5 病人等到自己的号,病人又重新拿着验血 / CT 报告去找医生就诊

整个流程如下

 java多线程 并发基础_第14张图片

那么管程是如何解决互斥同步的呢

首先来看互斥,上文中医生即共享资源(也即共享变量),就诊室即为临界区,病人即线程,任何病人如果想要访问临界区,必须首先获取共享资源(即医生),入口一次只允许一个线程经过,在共享资源被占有的情况下,如果再有线程想占有共享资源,就需要到等待队列去等候,等到获取共享资源的线程释放资源后,等待队列中的线程就可以去竞争共享资源了,这样就解决了互斥问题,所以本质上管程是通过将共享资源及其对共享资源的操作(线程安全地获取和释放)封装起来来保证互斥性的。

再来看同步,同步是通过文中的条件变量及其等待队列实现的,同步的实现分两种情况

1 病人进入就诊室后,无需做验血 / CT 等操作,于是医生诊断完成后,就会释放共享资源(解锁)去通知(notify,notifyAll)入口等待队列的下一个病人,下一个病人听到叫号后就能看医生了。

2 如果病人进入就诊室后需要做验血 / CT 等操作,会去验血 / CT 队列(条件队列)排队, 同时释放共享变量(医生),通知入口等待队列的其他病人(线程)去获取共享变量(医生),获得许可的线程执行完临界区的逻辑后会唤醒条件变量等待队列中的线程,将它放到入口等待队列中 ,等到其获取共享变量(医生)时,即可进入入口(临界区)处理。

在 Java 里,锁大多是依赖于管程来实现的,以大家熟悉的内置锁 synchronized 为例,它的实现原理如下。

java多线程 并发基础_第15张图片

可以看到 synchronized 锁也是基于管程实现的,只不过它只有且只有一个条件变量(就是锁对象本身)而已,这也是为什么JDK 要实现 Lock 锁的原因之一,Lock 支持多个条件变量。

通过这样的类比,相信大家对管程的工作机制有了比较清晰的认识,为啥要花这么大的力气介绍管程呢,一来管程是解决并发问题的万能钥匙,二来 AQS 是基于 Java 并发包中管程的一种实现,所以理解管程对我们理解 AQS 会大有帮助

你可能感兴趣的:(java多线程,java,开发语言,后端)