• 多线程进阶


    常见的锁策略

    一些给锁的实现者来参考的特性

    乐观锁 vs 悲观锁

    悲观锁:做出最坏的打算,预期锁冲突的概率很高,使线程每次拿数据的时候都会上锁
    乐观锁:做出最好的打算,预期锁冲突的概率很低,在数据将要进行更新提交的时候才会检查是否存在并发冲突

    读写锁

    读写锁是将加锁的操作进行了细化,将加锁操作细化成了"加读锁" 和 “加写锁”.
    其中

    1. 读加锁和读加锁之间,不互斥
    2. 写加锁和写加锁之间互斥
    3. 读加锁和写加锁之间互斥.
      读写锁非常适合与频繁读但不频繁写的场景.
      普通的互斥锁: 只要两个线程针对同一个对象加锁,就会产生互斥

    重量级锁 vs 轻量级锁

    重量级锁: 锁的开销比较大,做的工作比较多,其主要应用了操作系统(内核态提供的锁),容易产生线程调度,造成线程阻塞.
    轻量级锁: 锁的开销比较小,做的工作比较少.其主要避免使用操作系统提供的锁,尽量在用户态完成功能

    上述中的悲观锁经常会使重量级锁,乐观锁经常会是轻量级锁

    自旋锁 vs 挂起等待锁

    自旋锁: 自旋锁是轻量级锁的具体实现,也是乐观锁.当发生锁冲突时,线程不会立刻阻塞,反而会再次尝试获取锁.
    特点:

    1. 一旦锁释放,可以第一时间获取到锁
    2. 如果锁一直不释放,就会浪费大量的cpu资源

    挂起等待锁: 挂起等待锁是重量级锁的具体实现,也是悲观锁.但发生锁冲突是,线程会进入阻塞状态.
    特点:

    1. 锁释放后,无法第一时间获取到锁
    2. 在锁被其他线程占用是,会放弃cpu的资源

    公平锁 vs 非公平锁

    公平锁: 遵循"先来后到"的原则,线程谁先来的,谁就先获取到锁
    非公平锁: 不遵循"先来后到"的原则,根据操作系统内部对线程的随机调度.

    可重入锁 vs 不可重入锁

    可重入锁: 一个线程可以多次获取同一把锁
    不可重入锁: 一个线程无法获取同一把锁
    可重入锁在内部记录了这个锁是哪个线程获取到的, 如果发现尝试获取锁的线程和持有锁的线程是一个线程, 就不会产生堵塞, 而是直接获取到锁
    同时还会给锁的内部加上一个计数器,记录当前是第几次加锁,并根据计数器来决定什么时候释放锁.

    synchronized

    1. 开始时是一个乐观锁,当锁冲突激烈时,就转化为一个悲观锁
    2. 开始时是轻量级锁的实现, 当锁被持有的时间较长时,就转化为重量级锁
    3. 轻量级锁的部分基于自旋锁来实现,重量级锁的部分基于挂起等待锁来实现
    4. 非读写锁,是一个互斥锁
    5. 是可重入锁
    6. 是非公平锁

    CAS

    “compare and swap” 全称"比较并交换", 是基于硬件,给JVM提供的一种更轻量的,原子操作的机制
    比较,就是比较内存和寄存器中的值,如果值相同了,就把内存中的值和另一个值交换

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

    1. 比较A和V是否相等(比较)
    2. 如果比较相等,就将B的值写入V(交换)
    3. 返回操作是否相等

    伪代码

    真实的CAS是一个原子指令完成的,这个伪代码只是辅助理解CAS的工作流程

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

    其中address为待比较的值的地址, expectValue为预期内存中的值,swapValue为希望内存变为的值,&address为取出内存中的值作比较.
    上述代码能完成的操作,可有cpu提供的CAS指令实现, 硬件提供了支持,软件方面才得以实现

    基于CAS实现原子类

    标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.

    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.getAndIncrement();
                }
            });
    
            t1.start();
            t2.start();
            t1.join();
            t2.join();;
    
            System.out.println("count = " + count);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    在这里插入图片描述
    CAS 这样的操作,不会造成线程阻塞。比 synchronized 更高效。

    CAS实现自旋锁

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

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

    当owner为null时, CAS成功, 循环结束
    当owner为非null时,说明当前的锁已经被其他线程占用了,则要继续循环.

    如何理解CAS的ABA问题

    CAS是要先比较值,然后完成交换,比较是在比较当前值和旧值是否相同, 如果这两个值相同,就视为没有修改过.但其实这两个值中间过程可能发生了修改,也可能没有发生修改.
    解决这样的问题,我们就要引入版本号,如果发现当前的版本号和之前读到的版本号相同,就执行操作,并修改版本号.如果版本号和预期的不同,则修改失败.

    synchronized 锁优化过程

    JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
    在这里插入图片描述

    锁消除

    JVM和编译器判断锁是否可以消除,如果可以,直接消除.
    有些程序的代码,在单线程的环境下用到了 synchronized.例如单线程下的StringBuffer的append.

    锁的粗化

    一段代码中如果出现多次的加锁解锁,JVM和编译器会自动完成锁的粗化

    // 细化的锁
    for(...){
       synchronized(locker){
       n++;
       }
    }
    
    //粗化的锁
    synchronized(locker){
       for(...){
       n++;
       }
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    当使用细化的锁时,实际可能没有其他的线程来参与竞争,这是JVM会把锁粗化,避免频繁申请释放锁.

    Callable接口

    Callable是一个接口,其内部定义了一个带返回值的call方法. Runnable要描述一个带有返回值的方法很麻烦,所以就有了Callable接口,为了使线程执行Callable中的任务,我们还需要用 FutureTask包装一下Callable实例.

    public static void main(String[] args) {
            // 匿名内部类实现 Callable接口
            Callable<Integer> callable = new Callable<Integer>() {
                @Override
                public Integer call() throws Exception {
                    int sum = 0;
                    for(int i = 0; i < 1000; i++){
                        sum++;
                    }
                    return sum;
                }
            };
    
            //用FutureTask 包装一下 Callable实例
            FutureTask<Integer> task = new FutureTask<Integer>(callable);
    
            Thread t = new Thread(task);
            t.start();
    
            try {
                System.out.println(task.get());
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    ReentrantLock

    ReentrantLock使可重入互斥锁,和synchronized的定位类似,

    ReentrantLock lock = new ReentrantLock();
    lock.lock(); 
    try {  
    // working  
    } finally {  
    lock.unlock()  
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    ReentrantLock 和 synchronized的区别

    1. synchronized是一个关键字,以代码块为单位来进行加锁解锁, ReentrantLock是一个类, 提供了lock方法来加锁, unlock方法来解锁.
    2. synchronized是一个非公平锁, ReentrantLock默认是一个非公平锁,但是可以根据根据构造方法转换成公平锁
    3. synchronized在申请锁失败时,会死等,但是ReentrantLock的trylock方法会在加锁失败后继续往下执行
    4. synchronized搭配的是Object.wait/notify, 唤醒时可以随机唤醒一个, ReentrantLock搭配的是Condition类来等待唤醒,可以随机唤醒,也可以指定唤醒

    信号量 Semaphore

    信号量,用来描述可用资源的个数, 本质上是一个计数器

    当申请的资源比资源数多了之后,就进入阻塞状态

    public static void main(String[] args) {
            //创建Semaphore实例, 参数表示有几个可用资源
            Semaphore semaphore = new Semaphore(4);
    
             Runnable runnable = new Runnable() {
                 @Override
                 public void run() {
                     try {
                         System.out.println("申请资源");
                         semaphore.acquire();
                         System.out.println("我获取到资源了");
                         Thread.sleep(1000);
                         System.out.println("我释放资源");
                         semaphore.release();
                     } catch (InterruptedException e) {
                         e.printStackTrace();
                     }
    
                 }
             };
            //20 个线程 都在申请资源  但只能保证每刻友四个资源正在被使用
            for (int i = 0; i < 20; i++) {
                Thread t = new Thread(runnable);
                t.start();
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    CountDownLatch

    CountDownLatch,相当于将一个大任务分成若干个小任务,来判断这些小任务什么时候执行结束

    public static void main(String[] args) throws InterruptedException {
            // 10个任务需要完成
            CountDownLatch latch = new CountDownLatch(10);
    
            Runnable r = new Runnable() {
                @Override
                public void run() {
                    try{
                        Thread.sleep((long) (Math.random() * 10000));
                        latch.countDown();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
    
            for (int i = 0; i < 10; i++) {
                new Thread(r).start();
            }
            //等待规定的任务数全部执行完
            latch.await();
            System.out.println(" 比赛结束");
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    CopyOnWriteArrayList

    1. 在写数据的时候,会复制出一个新的容器将原本的数据进行拷贝, 这样在修改的同时读取数据,是没有任何影响的
    2. 等修改完毕后,将引用指向新的容器
    3. 在读多写少的场景下, 性能很高, 不需要加锁竞争
    4. 但是占用的内存会较大,不能第一时间获取到修改后的数据

    多线程使用哈希表

    HashMap本身时线程不安全的,
    故在多线程环境下,我们可以使用 Hashtable 和 ConcurrentHashMap

    1. Hashtable
      Hashtable是针对this对象来加锁,这样的话,任意两个线程访问Hashtable都会造成锁冲突.从而使效率降低
      扩容方面,HashTable 来说,只要你这次 put 触发了扩容,就一口气搬完,会导致这次 put 非常卡顿。
    2. ConcurrentHashMap
      相比Hashtable做出了很大的进步,
      2.1 将锁细化了,每个链表的头节点,都加了锁,降低了锁冲突的概率
      2.2 在读的时候没有加锁,在写的时候才加锁
      2.3 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况
      2.4 对于 ConcurrentHashMap 来说,每次操作只搬运一点点,通过多次操作来完成搬运的过程。同时维护一个新的 HashMap 和一个旧的,查找的时候即需要查旧的,也需要查新的,插入的时候只插入新的。直到搬运完毕再销毁旧的。
  • 相关阅读:
    logback--基础--02--配置--configuration
    2022官方发布辐轮王土拨鼠全球十大顶级公路自行车品牌排行榜
    排序——手撕快排
    【多线程那些事儿】如何使用C++写一个线程安全的单例模式?
    你听说过什么是代码本吗? (幽兰代码本初体验)
    PyCharm解决Git冲突
    工程管理系统源码+项目说明+功能描述+前后端分离 + 二次开发
    python聚类分析如何可视化?
    【Web前端】HTML详解(上篇)
    【毕业设计】python+opencv+机器学习车牌识别
  • 原文地址:https://blog.csdn.net/m0_62476684/article/details/126123890