• 多线程进阶


    常见锁策略

    锁策略:和实现锁的人有关系。和程序员没关系。

    悲观锁 VS 乐观锁

    1. 悲观锁:预期锁冲突的概率很高。做的工作更多,付出的成本更多,更低效。就相当于针对疫情,提前买好菜。
    2. 乐观锁:预期锁冲突的概率很低。做的工作更少,付出的成本更低,更高效。相当于针对疫情,菜应该还能买到,不用太担心。

    读写锁 vs 普通的互斥锁

    1. 读写锁,有三个操作:
      a. 加读锁:如果代码只是进行了读操作,就加读锁。 读锁和读锁之间不存在互斥关系的。
      b. 加写锁:如果代码中进行了修改操作,就加写锁。 读锁和写锁之间,写锁和写锁之间,需要互斥操作。
      c. 解锁。
    2. 普通的互斥锁,只有两个操作:加锁,解锁。只要两个线程针对同一个对象加锁,就会产生互斥。

    重量级锁 vs 轻量级锁

    重量级锁和轻量级锁,和上面的悲观乐观锁有一定重叠。可以认为:悲观锁是重量级锁,乐观锁是轻量级锁。

    1. 重量级锁:就是做了更多的事情,开销更大。一般来说调用系统接口的就认为是重量级锁。
    2. 轻量级锁:做的事情更少,开销更少。一般认为是纯用户态实现的锁就是轻量级锁。

    挂起等待锁 vs 自旋锁

    1. 挂起等待锁:通过内核的一些机制来实现,往往较重,重量级锁的一种典型实现。
    2. 自旋锁:通过用户态代码来实现,往往较轻,轻量级锁的一种典型实现。也就是盲等。

    公平锁 vs 非公平锁

    1. 公平锁:多个线程在等待一把锁的时候,谁是先来的,谁就能先获取到这个锁(先来后到)。要想实现公平锁,就得付出更多的代价(让整个线程来排队进行先来后到)。
    2. 非公平锁:多个线程在等待一把锁的时候,不遵守先来后到。不遵守先来后到,每个线程拿到锁的概率都是一样的。

    可重入锁 vs 不可重入锁

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

    synchronized

    1. synchronized 既是一个乐观锁,也是一个悲观锁。
    2. 不是读写锁,只是一个普通互斥锁。
    3. 既是一个轻量级锁,也是一个重量级锁(根据竞争的激烈程度,自适应)。
    4. 轻量级锁的部分基于自旋锁来实现,重量级锁的部分基于挂起等待锁来实现。
    5. 非公平锁。
    6. 可重入锁。

    CAS

    CAS 就是 (compare and swap)比较和交换。要做的就是拿着 寄存器/某个内存 中的值和另外一个内存的值进行比较。如果值相同了,就把另外一个寄存器/内存的值,和当前这个内存进行交换。

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

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

    伪代码

    如下:

    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 能够实现原子类,针对常用的一些 int long array 进行了封装。并且保证了线程安全。

    基于 CAS 实现原子类

    Atomic 就是原子类,实现原子类的代码如下:

    public static void main(String[] args) throws InterruptedException {
        //这里创建的 num 就是原子的。
        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                //这个方法就相当于 num++
                num.getAndIncrement();
            }
        });
        Thread t2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });
        //++num
        num.incrementAndGet();
        //--num
        num.decrementAndGet();
        //num--
        num.getAndDecrement();
        //+= 10
        num.getAndAdd(10);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //通过 get 方法得到 原子类 内部的的值。比加锁更快,因为这个不存在线程阻塞,线程安全问题。
        System.out.println(num.get());
    }
    
    • 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
    • 28
    • 29

    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

    自旋锁是一个轻量锁,也可以视为是一个乐观锁。

    常见问题:如何理解 CAS 中的 ABA 问题

    CAS 中的关键,是先比较,再交换。比较其实是在比较当前值和旧值是不是相同。把这两个值相同,就视为是中间没有发生过改变。这里的前值和旧值可能被修改过,也可能没有修改过。解决这样的问题,就是引入版本号。在比较的时候,就看版本号一样不一样,如果一样的话,就说明没变,反之就变了。

    基于版本号的方式来进行多线程控制工具,也是一种乐观锁的典型实现:

    1. 数据库里使用
    2. 版本管理工具(SVN,Git)

    synchronized 锁优化

    锁膨胀/锁升级

    锁膨胀/锁升级。体现了 synchronized 能够 “自适应” 这样的能力。就像下面这种:
    在这里插入图片描述

    锁粗化

    一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.。这里的粗化,是指锁的粒度。粒度就是:加锁代码涉及到的范围。加锁代码的范围越大,认为锁的粒度越粗,范围越小,则认为粒度越细。

    1. 如果锁的粒度比较细,多个线程之间的并发性就更高。
    2. 如果锁粒度比较粗,加锁解锁的开销就更小。

    编译器会有一些优化,就是自动判定:

    1. 如果某个地方的代码锁的粒度太细了,就会进行粗化。
    2. 如果两次加锁之间的间隔较大(中间隔得代码多),一般不会进行这种优化。
    3. 如果加锁之间间隔及较少(中间隔的代码少),就很可能会进行优化。

    锁消除

    1. 有些代码,明明不用加锁,结果加上了锁。编译器就会觉得这个锁加的没必要,就直接把锁去掉了。
    2. 有的时候加锁操作不是很明显,稍不留神就会写出这样的代码。
    3. StringBuffer,Vector…在标准库中进行了加锁操作。在单个线程中用到加锁的类,就是单线程进行了加锁解锁。

    Callable 接口

    Callable 是一个 interface 也是一种创建线程的方式。Runnable 不适合让线程计算一个结果的代码。就像让线程从 1 加到 1000,这样的代码基于 Runnable 来实现就会比较麻烦。使用 Callable 就会方便很多。要让线程执行 callable 中的任务,光使用构造方法还不够,需要一个辅助的类,通过 FutureTask 来作为中转。代码如下:

    public static void main(String[] args) {
        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;
            }
        };
        //为了让线程执行 callable 中的任务,光使用构造方法还不够,需要一个辅助的类。
        //通过 FutureTask 来作为中转
        FutureTask<Integer> task = new FutureTask<>(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

    运行结果如下:
    在这里插入图片描述

    ReentrantLock

    ReentrantLock 也是可重入锁。代码如下:

    public static void main(String[] args) {
    	//如果在括号里面加上 true 就是公平锁
        ReentrantLock locker = new ReentrantLock();
        //加锁
        locker.lock();
        //解锁
        locker.unlock();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    ReentrantLock 把加锁和解锁分开了。所以可以通过 try,finally 来保证每次都能解锁。

    和 synchronized 区别

    1. synchronized 是一个关键字,背后逻辑是 JVM 实现的。不需要手动释放锁。
    2. ReentrantLock 是一个标准库中的类,要手动释放锁。要谨防忘记释放。
    3. synchronized 如果竞争锁失败,就会阻塞等待 但是 ReentrantLock 除了阻塞这一个手段之外,还有 trylock,就是失败了直接返回。
    4. synchronized 是一个非公平锁。 ReentrantLock 提供了非公平和公平锁两个版本,在构造方法中,通过参数来指定是公平锁还是非公平锁。
    5. 基于 synchronized 衍生出来的等待机制,是 wait 和 notify。相对功能有限。
    6. 基于 ReentrantLock 衍生出来的等待机制,是 Condition 类(条件变量),功能要更丰富一些。

    信号量

    信号量 Semaphore 是一个更广义的锁。锁是信号量里第一种特殊情况,叫做:二元信号量。每次申请一个可用资源,计数器就-1(就是 P 操作),每次释放一个可用资源,计数器就+1(就是 V 操作)。当信号量的计数已经是 0 了,再次进行 P 操作,就会阻塞等待。锁就可以视为 “二元信号量”,可用资源就一个,计数器的取值,非 0 即 1。

    代码如下:

    public static void main(String[] args) throws InterruptedException {
        //表示可用资源有 4 个
        // 当申请的资源比资源数多了之后,就进入阻塞状态。
        Semaphore semaphore = new Semaphore(4);
        //申请资源,P 操作
        semaphore.acquire(2);
        //释放资源 V 操作
        semaphore.release(2);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    申请资源数比资源数多

    当申请的资源比资源数多了之后,就进入阻塞状态。代码如下:

    public static void main2(String[] args) throws InterruptedException {
        //当申请的资源比资源数多了之后,就进入阻塞状态。
        Semaphore semaphore = new Semaphore(4);
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        //因为资源已经被使用完了,所以这里就是阻塞状态了。
        semaphore.acquire();
        System.out.println("申请成功");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    运行结果如下:
    在这里插入图片描述
    因为资源不够,所以就进入阻塞状态。

    CountDownLatch

    CountDownLatch 就是像一个 终点线 的东西。有不同的东西去终点。就像下载文件,把文件拆分为多个文件,通过多线程下载就可以更快,所有线程都跑完,就下载完了。

    CountDownLatch 的 countDown 方法,给每个线程里面去调用,就表示到达终点了。await 是给等待线程去调用,当所有的任务都要到达终点了,await 就从阻塞中返回,就表示任务完成。

    用代码模拟到达终点的过程:

    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(Thread.currentThread().getName() + "到达终点!");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        //等待所有的线程到达
        latch.await();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    运行结果如下:
    在这里插入图片描述

    CopyOnWriteArrayList

    1. 写时拷贝,就是在修改的时候,会创建一份副本出来。
    2. 这样做的时候,就是修改的同时对于读操作,是没有任何影响的。
    3. 读的时候先读旧的版本。
    4. 等修改完毕,再让旧的版本转正。
    5. 就不会出现:读到了一个“修改了一半”的中间状态。
    6. 也叫做:双缓冲区策略。更新配置的时候用到这个。
    7. 也是适合于,读多,写少 的情况。适合于数据小的情况。
    8. 更新配置数据,经常会用到类似的效果。

    多线程使用哈希表

    HashMap 本身线程不安全。解决方案有两种:

    1. HashTable 不推荐:保证线程安全,就是给关键方法加锁,针对 this 来加锁,当有多个线程来访问这个 HashTable 的时候,无论是啥样的操作,都会引起锁竞争,从而导致效率变低。
    2. ConcurrentHashMap 推荐:就是给每个链表的头加锁。操作元素的时候,是针对这个元素所在的链表的头节点来加锁的,如果两个线程的操作是针对两个不同的链表上的元素,没有线程安全问题,就不必加锁。
    3. ConcurrentHashMap 减少了锁冲突,就让锁加到每个链表的头节点上面(锁桶)。
    4. ConcurrentHashMap 只是针对写操作加锁了,读操作没加锁,而只是使用 volatile。
    5. ConcurrentHashMap 更广泛的使用了 CAS 进一步提高效率(比如维护 size 操作)。
    6. ConcurrentHashMap 针对扩容,进行了巧妙的化整为零,如果元素多了,链表就会长,就会影响 hash 表的效率。元素多了,就需要扩容(增加数组的长度),扩容就需要创建一个更大的数组,然后把之间旧的元素都给搬运过去(非常耗时)对于 HashTable 来说,只要你这次 put 触发了扩容,就一口气搬完,会导致这次 put 非常卡顿。
    7. 对于 ConcurrentHashMap 来说,每次操作只搬运一点点,通过多次操作来完成搬运的过程。同时维护一个新的 HashMap 和一个旧的,查找的时候即需要查旧的,也需要查新的,插入的时候只插入新的。直到搬运完毕再销毁旧的。
  • 相关阅读:
    科技云报道:防患于未然,云安全要像空气和水一样无处不在
    51单片机晶振频率与定时中断产生pwn占空比
    js第八章
    【C++】函数指针 ③ ( 函数指针语法 | 函数名直接调用函数 | 定义函数指针变量 | 使用 typedef 定义函数类型 | 使用 typedef 定义函数指针类型 )
    ETL基本介绍【博学谷学习记录】
    pandas的dataframe批量保存到Oracle数据库
    MySQL数据库 -- 库和表的操作
    Redis-事务/持久化
    全排列问题2
    Oracle自治事务详解
  • 原文地址:https://blog.csdn.net/sjp151/article/details/125596599