JavaEE初阶---多线程(面试常用)

这篇文章 , 我将主要介绍多线程进阶部分的内容 . 主要涉及到一些在面试中常考的内容。

一:常见的锁策略

1.1乐观锁和悲观锁

乐观锁 : 预测接下来发生锁冲突的可能性不大 , 而进行的一类操作;
悲观锁 : 预测接下来发生锁冲突的可能性很大 , 而进行的一类操作.
  • 乐观锁 : 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则会返回用户错误的信息,让用户决定如何去做。乐观锁适用于多读的应用类型,这样可以提高吞吐量 .一般的实现乐观锁的方式就是记录数据版本(version)或者是使用时间戳,其中使用版本记录是最常用的。
  • 悲观锁 : 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁 . 可以理解为牺牲效率提高了安全性.

举例:

在这里插入图片描述

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.

在这里插入图片描述
在这里插入图片描述

假设我们需要多线程修改 “瑞士银行账户余额”.设当前余额为 1万亿. 引入一个版本号 version, 初始值为 1. 并且我们规定 “提交版本必须大于记录当前版本才能执行更新余额” .

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
说到乐观锁,就必须提到一个概念:CAS
什么是CAS呢?Compare-and-Swap,即比较并替换,也有叫做Compare-and-Set的,比较并设置。
1、比较:读取到了一个值A,在将其更新为B之前,检查原值是否仍为A(未被其他线程改动)。
2、设置:如果是,将A更新为B,结束。[1]如果不是,则什么都不做。
上面的两步操作是原子性的,可以简单地理解为瞬间完成,在CPU看来就是一步操作。
有了CAS,就可以实现一个乐观锁,允许多个线程同时读取(因为根本没有加锁操作),但是只有一个线程可以成功更新数据,并导致其他要更新数据的线程回滚重试。 CAS利用CPU指令,从硬件层面保证了操作的原子性,以达到类似于锁的效果。
ava中真正的CAS操作调用的native方法
因为整个过程中并没有“加锁”和“解锁”操作,因此乐观锁策略也被称为无锁编程。换句话说,乐观锁其实不是“锁”,它仅仅是一个循环重试CAS的算法而已,但是CAS有一个问题那就是会产生ABA问题,什么是ABA问题,以及如何解决呢?

ABA 问题:
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

ABA 问题解决:
我们需要加上一个版本号(Version),在每次提交的时候将版本号+1操作,那么下个线程去提交修改的时候,会带上版本号去判断,如果版本修改了,那么线程重试或者提示错误信息~

我们之前提到过的synchronized既是一个悲观锁 , 也是一个乐观锁 , 我们称之为== “自适应锁” == .
如果发现当前锁冲突概率不大 , 就会以乐观锁的方式运行 , 往往是纯用户态执行的 ; 一旦发现锁冲突的概率比较大了 , 就会以悲观锁的方式运行 , 往往要进入内核 , 对当前线程进行挂起等待.

挂起等待 : 线程的挂起操作实质上就是线程进入"非可执行"状态下,在这个状态下CPU不会分给线程时间片,进入这个状态可以用来暂停一个线程的运行。线程挂起后,可以通过重新唤醒线程来使之恢复运行。
挂起的原因可能有 :

  1. 通过调用sleep()方法使线程进入休眠状态,线程在指定时间内不会运行。
  2. 通过调用join()方法使线程挂起,自己等待另一个线程的结果,直到另一个线程执行完毕为止
  3. 通过调用wait()方法使线程挂起,直到线程得到了notify()和notifyAll()消息,线程才会进入“可执行”状态。

关于乐观锁和悲观锁的实现详情 , 我推荐大家阅读这篇文章 , 是基于数据库展开的 :悲观锁与乐观锁的实现(详情图解)

1.2普通的互斥锁和读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
  • 读写锁 , 顾名思义 , 就是在执行加锁操作时要表明"读"还是"写" , 如果是读 , 读者之间并不互斥 ; 如果是写 , 那么要求与任何人互斥 .
  • 互斥锁 :每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。
    在这里插入图片描述
    读写锁就是把读操作和写操作区分对待. Java 标准库提供了
    ReentrantReadWriteLock 类, 实现了读写锁.
  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

读写锁特别适合于"频繁读, 不频繁写"的场景中.

synchronized就是普通的互斥锁 , 两个加锁操作之间会发生竞争 !

1.3重量级锁和轻量级锁

重量级锁 : 锁开销比较大 , 做的工作比较多 ;
轻量级锁 : 锁开销比较小 , 做的工作比较少 . 
悲观锁   : 经常会是重量级锁 ; 
乐观锁   : 经常会是轻量级锁 .
  • 重量级锁 : 主要是依赖了操作系统提供的锁 , 加锁机制重度依赖了 OS 提供的 mutex ~~ .使用这种锁 , 容易产生阻塞等待 ; 有大量的内核态用户态切换 , 很容易引发线程的调度 .
  • 轻量级锁 : 主要尽量的避免使用操作系统提供的锁 , 而在用户态完成功能~~ 使用这种锁 , 可以尽量避免用户态和内核态的切换 , 尽量避免挂起等待 ; 有少量的内核态用户态切换 , 不容易引发线程的调度 .
    在这里插入图片描述

注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作 .

synchronized是自适应锁 , 既是轻量级锁 , 又是重量级锁 , 根据锁冲突的情况而定 .

  • 冲突不高 :轻量级锁
  • 冲突很高 :重量级锁

1.4自旋锁和挂起等待锁

自旋锁    : 是轻量级锁的具体实现 , 是乐观锁 ;
挂起等待锁 : 是重量级锁的具体实现 , 是悲观锁 .

举个栗子 , 帮我们理解自旋锁和挂起等待锁 :

想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~

挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).

自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位 . 我本人是强烈谴责这种行为的 !
在这里插入图片描述

synchronized是自适应锁 , 作为轻量级锁时 , 内部是自旋锁 ; 作为重量级锁时 , 内部是挂起等待锁 .

1.5公平锁和非公平锁

公平锁   : 遵循"先来后到"的规则 ; 
非公平锁 : 不遵循"先来后到"的规则 .

假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.

当线程 A 释放锁的时候, 会发生啥呢?

  • 公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
  • 非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.

sychronized是非公平锁 .

1.6可重入锁和不可重入锁

可重入锁   : "可以重新进入的锁",即允许同一个线程多次获取同一把锁。(可递归锁)
不可重入锁 : "不可以重新进入的锁",若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到 , 被阻塞。(非递归锁)

二者的区别是 : 同一个线程可以多次获取同一个递归锁,不会产生死锁。而如果一个线程多次获取同一个非递归锁,则会产生死锁。

这里谈到一个概念 : 死锁 . 什么是死锁 ?

当两个任务都在等待被对方持有的资源时,两个任务都无法再继续执行,这种情况就被称为死锁。

即一个线程没有释放锁 , 然后又尝试再次加锁 .

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();

按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 无法进行解锁操作. 这时候就会 死锁.

死锁肯定不好 , 为了避免这个问题 , 就引入了"可重入锁" , 一个线程可以多次获取同一把锁 , 反复多次加锁 , 也没事 ! 因为"可重入锁" , 会在内部记录这个锁是哪个线程获取到的 . 如果发现当前加锁的线程和持有锁的线程是同一个 , 则不挂起等待 , 而是直接获取到锁 . 同时还会给锁内部加上计数器 , 记录当前是第几次加锁了 . (通过计数器来控制啥时候释放锁) .

Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁.

注意 : synchronized属于"可重入锁".

二:CAS

2.1CAS简介

是操作系统/硬件,给JVM提供的一种更轻量的原子操作的机制.全称Compare and swap,字面意思:"比较并交换",是CPU提供的一个特殊指令 . 

一个CAS包含以下操作步骤 :

假设内存中的原数据是V, 旧的预期值是A,需要修改的新值是B.

  1. 比较A与V是否相等 ;
  2. 如果比较相等 , 将B写入V;(交换)
  3. 返回操作是否成功.

在这里插入图片描述

注意:这一系列操作都是由一个CPU指令来完成的.

想到个死锁的例子,健康码(没码进不去,进不去修不了,修不了没码,没码进不去).

2.2 CAS应用场景

2.2.1 使用CAS实现原子类

2.2.1.1什么是原子类?
  1. 一个操作是不可中断的,即使是多线程的情况下也可以保证 .通常用于实现原子地进行++ , --等操作.
  2. 在Java中原子类都被保存在 java.util.concurrent.atomic包里 .
2.2.1.2具体实例

以count++为例 , 实际上是由1.读取 2.加一 3.写入 三步组成的,这是个复合类的操作(所以我们之前提到过的volatile是无法解决num++的原子性问题的) , 在并发环境下 , 如果不做任何同步处理,就会有线程安全问题.最直接的处理方式就是加锁 .

加锁操作意味着同一时刻只能有一个线程持有锁 , 其他线程则阻塞等待 , 线程的挂起恢复会带来很大的性能开销 .

AtomicInteger 类同样能够保证数据的同步性 , 我们来看看它是如何使用的 .

package Thread;

import java.util.concurrent.atomic.AtomicInteger;

public class Demo33 {
    public static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //这个操作相当于count++
                count.getAndIncrement();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //这个操作相当于count++
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = "+count);
    }
}

在这里插入图片描述

说明 :

AtomicInteger内部并没有加锁 , 而是基于CAS实现了原子类的操作 .那么 , 原子类是怎么基于CAS进行实现的呢 ?

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

通过"原子类" , 就可以在不加锁的情况下高效地完成多线程的自增操作 .

2.2.2 使用CAS实现自旋锁

在这里插入图片描述

2.3 CAS之ABA问题

什么是ABA问题?

在这里插入图片描述
一部手机 , 你无法确定它是新机还是翻新机 , 其实这就是ABA问题 .

在CAS中 , 你也无法确定数据一直是A , 还是从A -> B -> A 的.

现在有这样一个场景 , 滑稽老哥有100块存款 , 他想取50 , 取款机就创建了两个线程 , 并行地执行-50操作 . 期望结果是 : 一个线程-50成功 , 一个线程-50失败 .
在这里插入图片描述

正常过程 :

  1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
  3. 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.

异常过程 :

  1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
  3. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !
  4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作 !

此时扣款操作被执行了两次 , 这显然不是我们期望的结果 !

如何解决ABA问题呢 ?

上述CAS时是比较余额 , 因为余额可大可小 , 所以才会导致出现ABA问题 ; 如果能引入一个向唯一方向变化的值 , 就可解决ABA问题 .具体做法是 :

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

三.详解synchronized

3.1synchronized使用的锁策略

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁 ;
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁 ;
  3. 轻量级锁大概率基于自旋锁实现 , 重量级锁大概率基于挂起等待锁实现 ;
  4. 是非公平锁 ;
  5. 是可重入锁 ;
  6. 不是读写锁 .

3.2synchronized在加锁时会经历几个阶段

在这里插入图片描述

  1. 无锁状态 : 没什么好说的 .
  2. 偏向锁:不是"真正加锁",只是给对象头标记一个状态,表示"这个锁是我的了",直到其他线程来竞争锁之前,都保持这个状态;当有其他线程来竞争锁时,才真正加锁.类似于单例模式中的"懒汉模式",必要时才加锁.
  3. 轻量级锁 :一旦有其他线程参与了竞争 , 那么偏向锁状态就被消除 , 进入"轻量级锁".(自旋锁) .其具体行为是 :
  • 通过 CAS 检查并更新一块内存;
  • 如果更新成功, 则认为加锁成功;
  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
  1. 重量级锁 : 如果竞争进一步激烈 , 那么"轻量级锁"就会膨胀为"重量级锁" .此处的重量级锁就是指用到内核提供的 mutex.其具体行为是 :
  • 执行加锁操作, 先进入内核态;
  • 在内核态判定当前锁是否已经被占用;
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态;
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒;
  • 直到该锁被其他线程释放了, 操作系统就唤醒这个线程, 并尝试重新获取锁.

3.3其他的优化操作

synchronized除了锁升级 , 还有其他的优化操作 .

3.3.1锁消除

编译器+JVM自动判定,认为当前代码没必要加锁,就会自动进行锁消除.

有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁 . 但如果只是在单线程中执行这个代码 , 那么这些加锁解锁操作是没有必要的 , 白白浪费了一些资源开销 . 所以在这种情况下 , JVM+编译器通过判定 , 就把这些锁消除了 .

3.3.2 锁粗化

一段代码逻辑中多次出现加锁解锁操作,编译器+JVM会自动进行锁粗化,即让synchronized包含的代码范围更大一些.

在这里插入图片描述
实际开发过程中 , 使用细粒度锁 , 是期望释放锁的时候其他线程能使用锁.但是实际上可能并没有其他线程来抢占这个锁 . 这种情况 JVM 就会自动把锁粗化 , 避免频繁申请释放锁.

对于synchronized , 要求 :

1.能够理解synchronized基本执行过程 , 理解锁对象,理解锁竞争;
2.能够知道synchronized的基本策略;
3.能够理解synchronized 内部的一些锁优化的过程 (锁升级,锁消除,锁粗化)

四:Callable 接口

Callable接口与Runnable接口有类似之处,都可以在创建线程的时候指定一个具体的任务.
区别:Callable指定的任务是带有返回值的,Runnable指定的任务是不带返回值的.

4.1 代码示例

创建线程 , 计算1+2+3+4+…+1000.

package Thread;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo34 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };

        /**
         * Thread t = new Thread(callable);
         * 这种写法是错误的!不能把callable加到Thread的构造方法中,而是需要套娃.
         */

        //套上一层, 目的是为了获取到后续的结果.
        FutureTask<Integer> task = new FutureTask<>(callable);
        Thread t = new Thread(task);
        t.start();

        // 在线程 t 执行结束之前, get 会阻塞. 直到 t 执行完,
        // get 才能返回. 返回值就是 call 方法 return 的内容.
        System.out.println(task.get());

    }
}

在这里插入图片描述

可使用lambda表达式简化代码 , 如下所示 :

package Threading;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

//编写代码, 基于 Callable 实现 1+2+3+...+1000
public class Test12 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = () -> {
            int sum = 0;
            for (int i = 1; i < 1001; i++) {
                sum += i;
            }
            return sum;
        };
        //要注意得在外面套上一层
        FutureTask<Integer> task = new FutureTask<>(callable);
        Thread t = new Thread(task);
        t.start();
        System.out.println(task.get());
    }
}

4.2 Callable总结

  1. Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务.
  2. Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定 . FutureTask 就可以负责这个等待结果出来的工作.
  3. 类似于你去学校食堂买麻辣烫 , 人家就会给你一个牌子 , 到时候叫到你的号 ,你去取就行了.这个"牌子"就是FutureTask.

五:JUC相关类

5.1 ReentrantLock

是一个可重入锁.

5.1.1 ReentrantLock的特点

ReentrantLock和synchronized的区别 :
在这里插入图片描述

  • 大部分情况下 , 还是使用synchronized为主 .
  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock ,可以更灵活控制加锁的行为.
  • 如果需要使用公平锁, 使用 ReentrantLock.

5.1.2 代码示例

package Thread;

import java.util.concurrent.locks.ReentrantLock;

public class Demo35 {
    public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock(true);
        try{
            //加锁
            locker.tryLock();
        } finally {
            //解锁
            locker.unlock();
        }
    }
}

5.2 原子类

其内部使用CAS实现,性能远高于使用加锁操作实现i++.

通常包括:

  • AtomicBoolean
  • AtomicInteger
  • AtomicInterArray
  • AtomicLong
  • AtomicReference
  • AtomicAtampedReference

5.3线程池

虽然创建销毁线程比创建销毁进程更轻量, 但是频繁创建销毁线程,还是比较低效.线程池就是为了解决这个问题.如果某个线程不再使用了,并不是真正把线程释放,而是放到一个 "池子"中, 下次如果需要用到线程就直接从池子中取,不必通过系统来创建了.

标准库中最核心的线程池类 , 就是ThreadPoolExecutor .我们需要了解其构造方法 . 打开Java官方文档 , 可以发现ThreadPoolExecutor类提供了很多种构造方法 , 我们主要来看其参数最多的构造方法 .

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

如果把创建线程类比为开一家公司 , 每个员工相当于一个线程 , 那么 :
在这里插入图片描述
在这里插入图片描述

Q : 当我们使用线程池的时候 , 线程数目设置多少比较合适 ?
A :
一种明显的错误回答 : 设置XXX(具体数字).
另一种明显的错误回答 : 设置为CPU核心数 XXX(具体数字) .
漏 , 大漏特漏 !
不同场景 , 不同的程序 , 不同的主机配置 , 都会有差异 ! 一种比较合适的回答是 , 我们有找到合适线程数的方法 . 就是压测(性能测试).
针对当前程序进行性能测试 , 设置不同的线程数目 , 分别测试 , 在测试过程中 , 记录程序的时间 ,CPU占用 , 内存占用等等 …根据压测结果 , 来选择最适合当前场景的线程数目 .

程序:

  1. CPU密集型(线程数最多也就是CPU核心数)
  2. IO密集型(线程数可以超过CPU核心数,等待IO过程不吃CPU)

实际开发中,一个程序既需要CPU也需要等待IO , 此时就根据这二者不同的时间比例 , 结合压测 , 得出线程数设置多少合适即可 .

线程池的工作流程 :

在这里插入图片描述
代码示例 :

package Threading;

import java.util.concurrent.*;

/**
 * 使用ThreadPoolExecutor创建一个忽略最新任务的线程池,创建规则:
 * 1.核心线程数为5
 * 2.最大线程数为10
 * 3.任务队列为100
 * 4.拒绝策略为忽略最新任务
 */
public class Test14 {
    public static void main(String[] args) {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                5, //核心线程数
                10, //最大线程数
                1000,//最大空闲时间
                TimeUnit.MILLISECONDS,//时间单位
                new LinkedBlockingQueue<Runnable>(100),//任务队列
                Executors.defaultThreadFactory(),//线程工厂
                new ThreadPoolExecutor.DiscardPolicy());//拒绝策略为忽略最新任务
    }
}

5.4 信号量Semaphore

5.4.1 基本概念

信号量,用于标识可用资源的个数,本质上是一个计数器.

Semaphore : /ˈseməfɔː®/

申请一个可用资源 , 信号量就 -= 1 , 称为P操作 .
释放一个可用资源 , 信号量就 += 1, 称为V操作 .

  • Semaphore可以直接用于多线程线程安全的控制 ;
  • 可以把信号量视为一个更广义的锁 , 当信号量取值仅为0或1时 , 就退化为了一个普通的锁.

在这里插入图片描述

5.4.2代码示例

package Thread;

import java.util.concurrent.Semaphore;

public class Demo36 {
    public static void main(String[] args) throws InterruptedException {
        //构造方法传入有效资源的个数
        Semaphore semaphore = new Semaphore(5);

        //P操作,申请资源
        semaphore.acquire();
        System.out.println("申请资源1");
        semaphore.acquire();
        System.out.println("申请资源2");
        semaphore.acquire();
        System.out.println("申请资源3");
        semaphore.acquire();
        System.out.println("申请资源4");
        semaphore.acquire();
        System.out.println("申请资源5");
        semaphore.acquire();
        System.out.println("申请资源6");
        //V操作,释放资源
        semaphore.release();
    }
}

在这里插入图片描述

构造方法传入有效资源的个数为5 , 所以申请第6个资源时 , 会阻塞等待 .

package Thread;

import java.util.concurrent.Semaphore;

public class Demo36 {
    public static void main(String[] args) throws InterruptedException {
        //构造方法传入有效资源的个数
        Semaphore semaphore = new Semaphore(5);

        //P操作,申请资源
        semaphore.acquire();
        System.out.println("申请资源1");
        semaphore.acquire();
        System.out.println("申请资源2");
        semaphore.acquire();
        System.out.println("申请资源3");
        semaphore.acquire();
        System.out.println("申请资源4");
        semaphore.acquire();
        System.out.println("申请资源5");
        //V操作,释放资源
        semaphore.release();
        semaphore.acquire();
        System.out.println("申请资源6");

    }
}

在这里插入图片描述

构造方法传入有效资源的个数为5 , 先释放一个资源后 , 再次申请 , 可以成功 .

5.5CountDownLatch

5.5.1基本概念

同时等待N个任务执行结束.

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

进行一次多线程下载 , CountDownLatch描述当前所有线程都下载完毕 .

进行一场跑步比赛 , CountDownLatch描述当前所以选手都到达终点 .

在这里插入图片描述

5.4.2代码示例

package Thread;

import java.util.concurrent.CountDownLatch;

public class Demo37 {
    public static void main(String[] args) throws InterruptedException {
        //模拟跑步比赛
        //构造方法中设定参赛选手的个数
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(()->{
                try {
                    Thread.sleep(3000);
                    System.out.println("到达终点");
                    //countDown相当于撞线
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        /**
         *await等待所有的线程"撞线"
         * 调用countDown的次数达到初始化时候设定的值,await就返回
         * 否则await就阻塞等待!
         */
        latch.await();
        System.out.println("比赛结束!");
    }


}

在这里插入图片描述

相关面试题?

Q:线程同步的方式有哪些?
A:synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.

Q:为什么有了 synchronized 还需要 juc 下的 lock?
A:以 juc 的 ReentrantLock 为例,

  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启公平锁模式.
  • synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

Q: AtomicInteger 的实现原理是什么?
A:基于CAS实现 . 伪代码如下:

class AtomicInteger{
  private int value;  
  public int getAndIncrement(){
     int oldValue = value;
     while(CAS(value,oldValue,oldValue+1) != true) {
          oldValue = value;
     } 
    return oldValue;
  }
}

Q:信号量听说过么?之前都用在过哪些场景下?
A:信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作 .

六 : 线程安全的集合类

我们学过的集合类 , 大部分是线程不安全的 , 例如 :

ArrayList

LinkedeList

TreeSet

TreeMap

HashSet

HahsMap

Queue

当然也有线程安全的 :

Vector : 上古时期Java内置的顺序表
Stack : 继承自Vector , 巧了 , 才线程安全的
HashTable : 不推荐使用 , 就是无脑加synchronized
ConcurrentHashMap : 推荐使用

6.1 多线程环境使用ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)
  2. Collections.synchronizedList(new ArrayList) ; synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
    synchronizedList 的关键操作上都带有 synchronized
  3. 使用 CopyOnWriteArrayList . CopyOnWrite容器即写时复制的容器。当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器 . 这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素 , 所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器 .
    在这里插入图片描述

6.2 多线程环境使用队列

  1. ArrayBlockingQueue
    基于数组实现的阻塞队列
  2. LinkedBlockingQueue
    基于链表实现的阻塞队列
  3. PriorityBlockingQueue
    基于堆实现的带优先级的阻塞队列
  4. TransferQueue
    最多只包含一个元素的阻塞队列

6.3 多线程环境使用哈希表

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

经典面试题 : 谈谈HashMap,HashTable,ConcurrentHashMap之间的区别?

七 : 死锁

7.1 死锁的类型

死锁:尝试加锁的时候发现上次锁没有及时释放(bug),导致加锁加不上.

在这里插入图片描述
哲学家就餐问题 :

由Dijkstra提出并解决的哲学家就餐问题是典型的死锁问题。该问题描述的是五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替的进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。

解决方案:

  1. 哲学家要进餐时,要么同时拿起两支筷子,要么一支筷子都不拿.
  2. 对筷子进行编号 , 并约定一种拿筷子的规则 , 比如每次都从编号较小的筷子开始拿 .

7.2 死锁产生的必要条件

在这里插入图片描述
打破循环等待的方法 , 可以针对多把锁进行编号 . 约定在获取多把锁时 ,要明确获取锁的顺序 , 比如从小到大获取 . 只要所有线程都遵守这个顺序 , 就不会出现死锁 !

Q : 常考面试题 : 什么是死锁 ?

A : 按照如下思路回答:
1.一句话概括什么是死锁 .
2.产生死锁的三个典型场景:

  • 1个线程1把锁
  • 2个线程2把锁
  • N个线程M把锁

3.死锁产生的必要条件 .
4.从循环等待的角度切入 , 对锁进行编号 , 并按顺序加锁 , 就可以破坏循环等待的条件, 进而打破死锁 !

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值