• 07.JAVAEE之线程5


    与面试相关

    1.常见的锁策略

    不是一把具体的锁,而是一类锁

    1.1 乐观锁 vs 悲观锁

    悲观乐观,是对后续锁冲突是否激烈(频繁)给出的预测

    如果预测接下来锁冲突的概率不大,就可以少做一些工作. 就称为 乐观锁

    如果预测接下来锁冲突的概率很大,就应该多做一些工作. 就称为 悲观锁 

    1.2 重量级锁 vs 轻量级锁

    • 轻量级锁,锁的开销比较小
    • 重量级锁,锁的开销比较大.
    • 和刚才的乐观悲观是有关联的,
    • 一个是预测锁冲突的概率,,一个是实际消耗的开销

    1.3 自旋锁和挂起等待锁 

    此处的忙等带来了更高的效率。

    1.4 读写锁

    把加锁,分成两种了:

    读加锁:读的时候, 能读, 但是不能写

    写加锁:读的时候,不能读, 也不能写

    两个线程加锁过程中:

    1.读锁和读锁之间, 不会产生竞争(多线程读同一个东西,有线程安全)

    2.读锁和写锁之间,有竞争

    3.写锁和写锁之间, 也有竞争

    实际开发中,遇到的场景,往往是"读多,写少"

    1.5 可重入锁 vs 不可重入锁

    一个线程针对同一把锁,连续加锁两次,不会死锁, 就是可重入锁,会死锁, 就是不可重入锁 

    1.6 公平锁 vs 非公平锁

    当很多线程去尝试加一把锁的时候,一个线程能够拿到锁,其他线程阻塞等待一旦第一个线程释放锁之后,接下来是哪个线程能够拿到锁呢?
    公平锁: 就是按照"先来后到”顺序.
    非公平锁: 则是剩下的线程以"均等"的概率,来重新竞争锁,

    操作系统提供的加锁 apì 默认情况, 就属于"非公平锁"如果要想实现公平锁,你还需要引入额外的队列,维护这些线程的加锁顺序

    1.7 Synchronized属于那种锁呢??? 

    1. 对于"悲观乐观”自适应的,
    2. 对于"重量轻量"自适应的.
    3. 对于"自旋挂起等待”自适应的,
    4. 不是读写锁
    5. 是可重入锁
    6. 是非公平锁
    • 什么是自适应呢???
    • 初始情况下,synchronized 会预测当前的锁冲突的概率不大,此时以乐观锁的模式来运行.(此时也就是轻量级锁,基于自旋锁的方式实现)
      在实际使用过程中,如果发现锁冲突的情况比较多。synchronized 就会升级成 悲观锁 (也就是重量级锁, 基于挂起等待的方式实现)

    2.CAS 

    2.1 什么是CAS

    Compare and Swap比较和交换寄存器

    2.2 CAS 伪代码

    下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解
    CAS 的工作流程. 

    1. boolean CAS(address, expectValue, swapValue) {
    2. if (&address == expectedValue) {
    3.   &address = swapValue;
    4.        return true;
    5.   }
    6.    return false;
    7. }

    CAS 其实是一个 cpu 指令
    (一个 cpu 指令,就能完成上述比较交换的逻辑)
    单个的 cpu 指令,是原子的!! 就可以使用 CAS 完成一些操作(给编写线程安全的代码,引入了新的思路~~),进一步的替代"加锁”

    基于 CAS 实现线程安全的方式,也称为"无锁编程

    优点: 保证线程安全, 同时避免阻塞.(效率)缺点:
    1.代码会更复杂,不好理解
    2.只能够适合一些特定场景, 不如加锁方式更普适,

    CAS 本质上是 cpu 提供的指令 =>又被操作系统封装,提供成 api =>又被 JVM 封装,也提供成 api =>程序员使用了 

    2.3 CAS 是怎么实现的

    针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

    • java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
    • unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
    • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

    简而言之,是因为硬件予以了支持,软件层面才能做到。

    2.4 CAS 有哪些应用

    1) 实现原子类 

    标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 
    典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

    1. import java.util.concurrent.atomic.AtomicInteger;
    2. public class Demo29 {
    3. public static AtomicInteger count = new AtomicInteger(0);
    4. public static void main(String[] args) throws InterruptedException {
    5. Thread t1 = new Thread(() -> {
    6. for (int i = 0; i < 50000; i++) {
    7. // count++;
    8. count.getAndIncrement();
    9. // ++ count;
    10. // count.incrementAndGet();
    11. // count--
    12. // count.getAndDecrement();
    13. // --count
    14. // count.decrementAndGet();
    15. }
    16. });
    17. Thread t2 = new Thread(() -> {
    18. for (int i = 0; i < 50000; i++) {
    19. count.getAndIncrement();
    20. }
    21. });
    22. t1.start();
    23. t2.start();
    24. t1.join();
    25. t2.join();
    26. System.out.println(count.get());
    27. }
    28. }

    结论: 原子类里面是基于 CAS 来实现的

    伪代码的实现:

    前面说"线程不安全"本质上是进行自增的过程中,穿插执行了
    CAS 也是让这里的自增,不要家插执行,核心思路和加锁是类似的.

    • 加锁是通过阻塞的方式,避免穿插.
    • CAS 则是会通过重试的方式,避免穿插.

    2) 实现自旋锁

    基于 CAS 实现更灵活的锁, 获取到更多的控制权

    伪代码 

    1. public class SpinLock {
    2.    private Thread owner = null;
    3.    public void lock(){
    4.        // 通过 CAS 看当前锁是否被某个线程持有.
    5.        // 如果这个锁已经被别的线程持有, 那么就自旋等待.
    6.        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
    7.        while(!CAS(this.owner, null, Thread.currentThread())){
    8.       }
    9.   }
    10.    public void unlock (){
    11.        this.owner = null;
    12.   }
    13. }

    缺点:会消耗CPU。

    CAS 也是多线程编程中的一种重要技巧,虽然开发中直接使用 CAS 的概率不大,但是经常会用到一些 内部封装了 CAS 的操作. 

    2.5 CAS 的 ABA 问题

    CAS 进行操作的关键,是通过 值"没有发生变化"来作为"没有其他线程穿插执行" 判定依据

    但是,这种判定方式,不够严谨.

    更极端的情况下,可能有另一个线程穿插进来,把值从 A ->B ->A
    针对第一个线程来说,看起来好像是这个值,没变,但是实际上已经被穿插执行了

    一般来说ABA这样没有什么问题

    但是存在极端问题

    假设这个场景,我去 ATM 取钱. 我本身的 账户 1000我想要取 500
    我在取钱的过程中, 出现 bug 了.
    我按下取钱按钮, 没反应,我又按了一下.此时就产生了两个线程进行扣款操作!!

    未涉及ABA时

    加入t3线程(别人同时进行转钱操作,即就会出现bug)

    2.5.1 ABA问题的解决方案 

    只要让判定的数值,按照一个方向增长即可,(不要反复横跳)有增有减,就可能出现 ABA只是增加,或者只是减少;
    针对像账户余额这样的概念,本身就应该要能增能减
    可以引入一个额外的变量,版本号.
    约定每次修改余额,都要让版本号自增.
    此时在使用 CAS 判定的时候,就不是直接判定余额了,而是判定版本号,看版本号是否是变化了如果版本号不变,注定没有线程穿插执行了

    3.Synchronized原理 

    1.基本特点

    结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
    1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
    2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁. 
    3. 实现轻量级锁的时候大概率用到的自旋锁策略
    4. 是一种不公平锁
    5. 是一种可重入锁
    6. 不是读写锁 

    2.synchronized 几个重要的机制

    • 锁升级
    • 锁消除
    • 锁粗化

    2.1 锁升级

    JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。(是单向的不能降级)

     偏向锁不是真正的加锁,只是进行了一个标记。

    偏向锁的核心思想, 我们以前讲过, 就是"懒汉模式"另一种体现(没有线程竞争,就不加锁,有别的线程竞争,就抢先一步进行加锁)

    能不加锁, 就尽量不加锁.加锁意味着有开销

    2.2 锁消除

    锁消除.也是一种编译器优化的手段

    编译器会自动针对你当前写的 加锁的代码,做出判定,

    如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized 给优化掉. 

    • StringBuilder不带 synchronized
    • StringBuffer带有 synchronized
    • 如果是在单个线程中使用 StringBuffer,此时编译器就会自动的把 synchronized 给优化掉编译器只会在自己非常有把握的时候,才会进行锁消除~~(触发的概率不算很高)

    2.3 锁粗化 

    锁的粒度.

    synchronized 里头,代码越多,就认为锁的粒度越粗代码越少,锁的粒度越细

    粒度细的时候,能够并发执行的逻辑更多,更有利于充分利用多核 CPU 资源

    但是,如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁.(涉及到反复的锁竞争)

    3.JUC常见类

    3.1.Callable

    Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果.  

    也是一种创建线程的方式,
    适合于,想让某个线程执行一个逻辑,并且返回结果的时候。相比之下, Runnable 不关注结果

    1. import java.util.concurrent.Callable;
    2. import java.util.concurrent.ExecutionException;
    3. import java.util.concurrent.FutureTask;
    4. public class Demo30 {
    5. public static void main(String[] args) throws ExecutionException, InterruptedException {
    6. // 定义了任务.
    7. Callable callable = new Callable() {
    8. @Override
    9. public Integer call() throws Exception {
    10. int sum = 0;
    11. for (int i = 0; i <= 1000; i++) {
    12. sum += i;
    13. }
    14. return sum;
    15. }
    16. };
    17. // 把任务放到线程中进行执行.
    18. FutureTask futureTask = new FutureTask<>(callable);
    19. Thread t = new Thread(futureTask);
    20. t.start();
    21. // 此处的 get 就能获取到 callable 里面的返回结果.
    22. // 由于线程是并发执行的. 执行到主线程的 get 的时候, t 线程可能还没执行完.
    23. // 没执行完的话, get 就会阻塞.
    24. System.out.println(futureTask.get());
    25. }
    26. }

    //call()这个是 Callable 中的核心方法返回值就是 Integer, 期望这个线程能够返回一个整数,

    小结: 线程的创建方式
    1.继承 Thread, 重写 run(创建单独的类,也可以匿名内部类)
    2.实现 Runnable, 重写 run(创建单独的类, 也可以匿名内部类3. 实现 Callable, 重写 cal (创建单独的类, 也可以匿名内部类)
    4. 使用 lambda 表达式
    5.ThreadFactory 线程工厂
    6.线程池 

    3.2 ReentrantLock (Reentrant可重入)

    可重入互斥锁 . synchronized 定位类似 , 都是用来实现互斥效果 , 保证线程安全 .
    ReentrantLock lock = new ReentrantLock ();
    -----------------------------------------
    lock . lock ();  
    try {    
    // working    
    } finally {    
    lock.unlock()    //不能忘记此操作
    }  

    优势:
    1..ReentrantLock,在加锁的时候,有两种方式.lock, tryLock.
    给了咱们更多的可操作空间
    2.ReentrantLock,提供了 公平锁 的实现.(默认情况下是非公平锁)

    3.ReentrantLock 提供了更强大的等待通知机制. 搭配了 Condition 类,实现等待通知的

    虽然 ReentrantLock 有上述优势,但是咱们在加锁的时候,还是首选:synchronized 

    但是很明显, ReentrantLock 使用更加复杂,尤其是容易忘记解锁

    另外 synchronized 背后还有一系列的优化手段~~

    3.3 信号量 semaphore

    也是操作系统课程中,比较重要的概念, 

    信号量, 就是一个计数器,描述了"可用资源"的个数
    每次申请一个可用资源,就需要让计数器-1->P操作 acquire

    操作每次释放一个可用资源,就需要让计数器 +1->V 操作 release

    (这里的 +1和-1 都是原子的)

    信号量,假设初始情况下 数值是 10

    每次进行 P操作,数值就 -1

    当我已经进行了 10 次 P操作之后,数值就是 0了
    如果我继续进行 P操作,会咋样呢?=>阻塞等待!! 

    1. import java.util.concurrent.Semaphore;
    2. public class Demo31 {
    3. public static void main(String[] args) throws InterruptedException {
    4. Semaphore semaphore = new Semaphore(4);
    5. semaphore.acquire();
    6. System.out.println("P 操作");
    7. semaphore.acquire();
    8. System.out.println("P 操作");
    9. semaphore.acquire();
    10. System.out.println("P 操作");
    11. semaphore.acquire();
    12. System.out.println("P 操作");
    13. semaphore.acquire();
    14. System.out.println("P 操作");
    15. // semaphore.release();
    16. }
    17. }

    Semaphore semaphore=new semaphore(4);
    开发中如果遇到了需要申请资源的场景,就可以使用信号量来实现了, 

    3.4 CountDownLatch

    这个东西,主要是适用于,多个线程来完成一系列任务的时候,用来衡量任务的进度是否完成比如需要把一个大的任务,拆分成多个小的任务,让这些任务并发的去执行,就可以使用 countDownLatch 来判定说当前这些任务是否全都完成了

    CountDownLatch 主要有两个方法.
    1.await,调用的时候就会阻塞,就会等待其他的线程完成任务,所有的线程都完成了任务之后,此时这个 await 才会返回, 才会继续往下走.
    2.countDown,告诉countDownLatch,我当前这一个子任务已经完成了

    1. public class Demo32 {
    2. public static void main(String[] args) throws InterruptedException {
    3. // 10 个选手参赛. await 就会在 10 次调用完 countDown 之后才能继续执行.
    4. CountDownLatch countDownLatch = new CountDownLatch(10);
    5. for (int i = 0; i < 9; i++) {
    6. int id = i;
    7. Thread t = new Thread(() -> {
    8. System.out.println("thread " + id);
    9. try {
    10. Thread.sleep(500);
    11. } catch (InterruptedException e) {
    12. throw new RuntimeException(e);
    13. }
    14. // 通知说当前的任务执行完毕了.
    15. countDownLatch.countDown();
    16. });
    17. t.start();
    18. }
    19. // a => all
    20. countDownLatch.await();
    21. System.out.println("所有的任务都完成了. ");
    22. }
    23. }

     4.线程安全的集合类

    数据结构中大部分的集合类,都是线程不安全的.
    Vector,Hashtable ,(前面两个是上古时期遗留的产物,现在已经不建议使用了)Stack, 线程安全 =>(加了synchronized)

    针对这些线程不安全的集合类,要想在多线程环境下使用, 就需要考虑好 线程安全问题了(核心问题是加锁)

    同时,标准库,也给我们提供了一些搭配的组件,保证线程安全

    Collections.synchronizedList(new ArrayList);

    这个东西会返回一个新的对象,这个新的对象,就相当于给 ArrayList 套了一层壳这层壳就是在方法上直接使用 synchronized 的

    CopyOnWriteArrayList写时拷贝

    比如,两个线程使用同一个 ArrayList可能会读,也可能会修改.
    如果要是两个线程读,就直接读就好了 

    如果要是两个线程读,就直接读就好了.
    如果某个线程需要进行修改,就把 ArrayList, 复制出一份副本.修改线程,就修改这个副本.与此同时,另一个线程仍然可以读取数据(从原来的数据上进行读取)
    一旦这边修改完毕,就会使用修改好的这份数据,替代掉原来的数据,(往往就是一个引用赋值)
    1.当前操作的 ArrayList 不能太大.(拷贝成本, 不能太高)
    2.更适合于一个线程去修改,而不能是多个线程同时修改,(多个线程读,一个线程修改)

    这种场景特别适合于 服务器的配置更新~~可以通过配置文件,来描述配置的详细内容.(本身就不会很大)
    配置的内容会被读到内存中,再由其他的线程,读取这里的内容但是修改这个配置内容,往往只有一个线程来修改,
    使用某个命令让服务器重新加载配置,就可以使用写时拷贝的方式了

    ConcurrentHashMap

    Hashtable 保证线程安全,主要就是给关键方法,加上 synchronized.直接加到方法上的.(相当于给 this加锁)
    只要两个线程,在操作同一个 Hashtable 就会出现锁冲突~~
    但是,实际上,对于哈希表来说,锁不一定非得这么加,有些情况,其实是不涉及到线程安全问题的~~ 

    //两个不同的 key 映射到同一个数组下标上,就会出现hash 冲突.

    (数组+链表避免hash冲突,此时是否会发生线程安全)

    按照上述这样的方式来操作,并且在不考虑 触发扩容 的前提下操作不同的链表的时候就是线程安全的.

    如果两个线程,操作的是不同的链表,就根本不用加锁.只有说操作的同一个链表才需要加锁.

    1.ConcurrentHashMap 最核心的改进,就是把一个全局的大锁,改进成了 每个链表独立的一把小锁.
    这样做,大幅度降低了锁冲突的概率.
    1个 hash 表,有很多这样的链表.两个线程恰好同时访问一个 链表 情况,本身就比较少

    2.充分利用到了 CAS 特性,把一些不必要加锁的环节给省略加锁了

    比如,需要使用变量记录 hash 表中的元素个数此时,就可以使用原子操作(CAS) 修改元素个数

    3.ConcurrentHashMap, 还有一个激进的操作,针对读操作没有加锁,读和读之间.读和写之间, 都不会有锁竞争.(读和写之间有加锁)

    • 是否会存在这种“读到一个 修改了一半 的数值呢?ConcurrentHashMap 在底层编码过程中,比较谨慎的处理了一些细节修改的时候会避免使用 ++-- 这种非原子的操作!使用 =进行修改,本身就是原子的.
    • 读的时候,要么读到的是写之前的旧值,要么是读到写之后的新值,不会出现读到一个 一半的值

    4.ConcurrentHashMap 针对扩容操作, 做出了单独的优化.

    本身 Hashtable 或者 HashMap 在扩容的时候,都是需要把所有的元素都拷贝一遍的(如果元素很多,拷贝就比较耗时)
    用户访问 1000 次,999 次都很流畅,其中有一次就 卡了.(正好这一次触发扩容,导致出现卡顿)

    【解决方案】化整为零
    一旦需要扩容,确实需要搬运,不是在一次操作中搬运完成,而是分成多次,来搬运,每次只搬运一部分数据避免这单次操作过于卡顿

    ConcurrentHashMap 基本的使用方法和普通的 HashMap 完全一样

     

  • 相关阅读:
    【CV】各种库安装报错及解决办法
    从一到无穷大 #3 对象存储.浅谈
    移动通信:数字调制技术(BPSK, DPSK, QPSK, Π/4 QPSK,BFSK, MSK, GMSK, M-ary)学习笔记
    Vue 3 中的 toRef 和 toRefs 函数
    YOLOv8改进RepVGG结构:简单但功能强大的卷积神经网络架构
    一文读懂MP4封装格式
    DirectX12 - Swap Chain(交换链)
    centos安装NIS
    非递归中序遍历二叉树
    Git代码仓库管理系统的配置方法之:Gitlab
  • 原文地址:https://blog.csdn.net/m0_47017197/article/details/138180696