【Java】多线程理论

常见的锁策略

乐观锁和悲观锁

乐观和悲观指的是锁对于自身冲突状态的一种预测, 乐观锁就是对当前锁的状态保持乐观态度, 认为这个锁可能不会那么容易冲突, 因此可以少做一些工作. 而悲观锁就是反过来, 认为这个锁可能很容易冲突, 那么就要多做一些工作.

假如张三想约李四打游戏, 并且张三认为李四并不怎么忙, 时间不会冲突, 那么此时张三想打游戏的时候就会直接和张三说来一起玩, 此时就可以看作是一种乐观锁.

但是假如说张三一直这样不固定时间的约李四, 发现李四一直时间冲突, 因此后续想要约李四一起的时候就会提前约时间, 此时就相当于做了更多的一些工作, 就可以看作是一种悲观锁.

重量级锁和轻量级锁

重量级和轻量级则是从锁的开销来区分的, 重量级锁指的就是锁的开销比较大, 比如我们上面的悲观锁会做更多的工作, 那么它通常就属于重量级锁.

而轻量级锁就是反过来, 指的是开销较小的锁, 一般来说对应的也就是乐观锁

自旋锁和挂起等待锁

自旋锁属于轻量级锁的一种实现, 它会一直循环(自旋)请求锁, 直到锁释放为止直接获取锁. 而挂起等待锁则属于是重量级锁的一种实现, 它是请求一次后发现被占用, 就会阻塞等待操作系统来进行唤醒.

此时可能有人要问了:你这自旋锁一直请求开销不应该挺大的吗? 这还轻量?

实际上自旋锁虽然一直在循环请求, 但是这个锁一旦释放, 我们立马就可以获得锁. 并且这个操作都能够在用户态实现, 还不需要进行系统调度的操作, 因为这个线程一直占用着 CPU 没有放开.

而挂起等待锁的阻塞和唤醒的过程, 都是需要操作系统内核去进行操作的, 此时就涉及到了内核态的操作, 开销就相对来说更大了.

不过这两个锁也是有其适用场景的, 例如自旋锁虽然可以不需要系统调度可以直接获取锁, 但是相对的也需要消耗大量的 CPU 资源, 如果锁被长时间占用, 就会一直忙等. 而挂起等待锁则是可以在锁被占用的情况下, 释放自己占用的 CPU 资源, 因此锁被长时间占用的时候, 就不会浪费很多 CPU 资源.

同理, 如果锁被占用的时间不长, 此时自旋锁能够不经过调度快速获取锁, 而挂起等待锁的调度开销反而会相对来说较大.

读写锁

读写锁主要指的就是在读写操作中进行的加锁操作. 读锁指的就是读操作与读操作之间不会进行限制, 但是不能进行写操作. 这是由于只涉及读操作的时候并不会涉及到什么线程安全问题

而写锁则是不能读也不能写, 因为一旦涉及到了写操作, 那么此时就可能会引发读到无效数据, 或者覆盖写的数据的问题, 此时加锁才能保证线程安全.

可重入锁和不可重入锁

可重入和不可重入主要指的就是同一个线程对同一个锁进行多次加锁的应对策略, 可重入锁指的是一个线程可以对一个锁连续加锁多次的时候不会死锁, 而不可重入锁则是如果一个线程对一个锁连续加锁时就会死锁

公平锁和非公平锁

公平锁和非公平锁主要指的就是对于唤醒阻塞线程的顺序策略, 当多个线程来竞争一个同一个锁的时候, 此时如果按照先来后到的顺序来依次获取锁, 此时这个锁就是公平锁. 但是如果占用锁的顺序是随机的, 那么这个锁就是非公平锁.

通常来说, 操作系统默认的加锁 API 就是基于非公平锁实现. 如果希望有一个公平锁, 我们就可以通过手动的维护一个队列来维护线程的先后顺序

synchronized部分原理

基本特点

synchronized的锁是会自适应的, 也就是会自动根据情况变化锁策略的

  1. 对于乐观悲观, 是自适应的
  2. 对于重量轻量, 是自适应的
  3. 对于自旋和挂起等待, 是自适应的
  4. 是不公平锁
  5. 是可重入锁
  6. 不是读写锁

在初始的情况下, synchronized 会预测当前冲突的概率不大, 此时就会以乐观锁的模式运行, 此时是轻量级锁. 但是一旦检测到冲突的概率很大, 那么此时就会转换为以悲观的模式运行, 此时是重量级锁. 其中自旋的操作, 会在轻量级锁到重量级锁的升级过程中出现, 次数也是会自适应变化的, 然后如果失败就会进入阻塞, 也就是挂起等待.

锁升级过程

JVM 将 synchronized 锁的各个状态分为了四种, 从低到高分别是 无锁->偏向锁->轻量级锁->重量级锁

其中无锁就是没有任何锁, 轻量级和重量级我们也了解过了. 那这个偏向锁又是什么东西呢?

实际上偏向锁就和它的名字一样, 偏向于加锁, 但是没有完全加锁. 在初始没有锁竞争的时候, 这个这个锁会偏向于当前的这个线程, 但是并不加锁, 随后一旦发生冲突, 那么此时就会直接进行加锁, 此时就变成了上面说的轻量级锁.

这个过程就很像两个人在跑步比赛, 然后第一个人虽然要到终点了, 但是它故意不过终点, 等到后面的人即将要到终点了, 欸再一脚跨过去.

在这里插入图片描述

那么为什么 synchronized 要做这种看似贱贱的行为呢?

实际上就是为了避免加锁导致的开销, 加锁是一个重量级的操作, 一旦涉及加锁, 那么此时程序就和效率没有关系了. 而偏向锁这种做法, 就是类似于懒汉模式的一种体现, 能不加就不要加, 从而防止不必要的开销

锁消除

锁消除是编译器的一个优化机制, 但是一般是只有在编译器能够保证一定不会产生影响的时候才会优化, 这个优化概率并不是非常的高.

比如当我在单线程程序中使用synchronized, 由于此时加锁没有任何用, 还会增大开销, 那么此时编译器就会消除掉这些锁

锁粗化

锁粗化也是编译器的一个优化机制, 其中这里的这个粗细, 主要指的是锁的粒度. 锁的粒度简单地说就是锁中的代码越多, 锁就越粗,反之锁就越细.

假如一个逻辑中涉及到多次加锁解锁加锁解锁, 那么编译器发现了之后, 就可能会把这些加锁操作变成一个操作, 这个操作就叫锁粗化

在这里插入图片描述

那么为什么要进行这个优化呢?

虽然在锁粒度细的时候, 此时能够并发执行的逻辑更多, 那么就可以更好的利用 CPU 资源. 但是如果粒度细到了一定程度, 此时反而可能导致加锁解锁的开销过大, 锁竞争的开销也大, 那么此时运行速度反而还不如粒度粗的锁.

就类似于老师给张三布置了 5 个任务, 此时张三完成了所有任务, 明明可以一次性全部一起汇报, 但是却分成了 5 次分别汇报. 每一次汇报就相当于给老师进行了加锁, 需要老师是空闲的, 那假如老师本身就比较忙, 那么此时张三可能 5 次汇报根本就凑不到时间汇报, 那不如直接确认一个时间全部汇报完.

CAS

初识CAS

CAS的全称是compare and swap, 它是一个指令, 顾名思义可以帮助我们进行比较和交换值

它主要涉及到了 3 个值的比较交换, 分别是: 1. 内存中的数据 M 2. 旧的预期值 A 3. 需要修改的新值 B

其工作流程如下:

  1. 首先将内存中的数据 M 和旧的预期值 A 比较
  2. 如果相同, 那么就把 M 和 B 的值进行交换, 并且返回 true
  3. 如果不同, 那么就返回 false

实际上, 本质这个操作就是查看一下 M 是否被修改了, 如果没有被修改, 那么就给 M 进行赋值操作. 其实说的是交换, 其实核心目的就是为了赋值. 至于交换完之后 B 里面是什么, 我们不管.

此时我们就可以实现一个伪代码来更好地观察执行逻辑, 它伪代码如下所示

boolean CAS(M, A, B){
    if(M == A){
        M = B;
        return true;
    }else{
        return false;
    }
}

此时可能有人要问了: 这个 CAS 看似似乎也没有什么高深的, 它有啥用呢?

上面我们说到, CAS 是一个指令, 什么是指令? CPU 执行操作的最小单位, 也就是说, 这个三步骤的操作, 天然就是一个原子的操作, 也不需要我们进行加锁. 那么既然它是一个原子操作, 我们就可以基于它来实现一些不需要加锁但是又线程安全的代码.

这种借助CAS来实现线程安全的方式被称为无锁编程

优点: 线程安全, 避免阻塞

缺点: 代码可读性降低, 使用情景少

Java中的原子类

CAS 本质上是一个 CPU 指令, 同时它也被操作系统进行了封装, 随后 Java 也对其进行了封装. 而 Java 中的一些原子类的操作, 就是基于 Java 封装的 CAS 操作实现的.

这些原子类位于java.util.concurrent.atomic包下面

在这里插入图片描述

这些类顾名思义, 它们的操作都是不可拆分的, 也就是一系列的原子操作. 接下来我们就来使用以下其中的一个类

之前我们讲过一个两个线程对一个变量自增的例子, 如果不进行加锁是线程不安全的. 但是我们现在就可以采用原子类中的 AtomicInteger 来使其的自增都是原子操作, 从而达到线程安全的效果

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

        AtomicInteger atomicInteger = new AtomicInteger();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                // 原子自增
                atomicInteger.incrementAndGet();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                atomicInteger.incrementAndGet();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(atomicInteger);
    }
}

可以发现, 我们最后的结果还是100000

在这里插入图片描述

如果我们尝试去看方法的源码, 那么此时可以看到它的代码主要是有一个 unsafe 对象调用实现的.

在这里插入图片描述

在这里插入图片描述

这是由于 Java 中它认为这些涉及到底层操作的东西, 是需要更加谨慎进行操作的, 因此放在了一个名为 Unsafe 的类中.

同时这个类里面的方法大部分都是基于 native 方法实现的, 我们也是看不到源码的

在这里插入图片描述

下面这个代码, 是一个刚刚那个自增操作的伪代码, 我们简单看一下这个逻辑

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        // 读取到老值
        int oldValue = value;
        // 使用 CAS 尝试把 value 更新为 oldValue + 1
        while ( CAS(value, oldValue, oldValue + 1) == false) {
            // CAS失败, 即内存中的 value 和 oldValue 对不上
            // 更新一下
            oldValue = value;
       }
       // CAS 成功, 返回老值, 符合++性质
        return oldValue;
   }
}

下面是一个两个线程分别进行一次自增操作的穿插执行版本

在这里插入图片描述

可以看到, 即使中间被修改, 也可以通过 CAS 的比较来看看值是否被修改过了, 如果被修改过了, 那么此时就可以更新一下值.

下面还有一个基于CAS实现的自旋锁伪代码, 可以简单了解一下

public class SpinLock {
    // 锁的持有者
    private Thread owner = null;
    
    public void lock(){
        // 通过 CAS 看当前锁的持有者是否为 null
        // 不为 null 就把当前线程作为持有者
        while(CAS(this.owner, null, Thread.currentThread()) == false){
            // 如果不为 null, 会一直返回 false 走入循环, 一直判断
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

ABA问题

CAS 看似非常美好, 好像没有什么问题, 但是实际上它是有一个隐蔽的问题存在的, 这个问题就叫做 ABA 问题.

什么是 ABA 问题, 我们举一个日常生活中的例子.

假如现在桌子上面有一个苹果, 然后张三今天正好想吃一个苹果, 于是拿走吃掉了

在这里插入图片描述

随后李四过来, 发现自己刚刚放在桌子上的苹果没了, 于是又放了一个

在这里插入图片描述

随后张三又回来了, 由于刚刚吃了一个带了点东西的苹果, 所以脑子不太好使, 忘记了刚刚自己吃了一个苹果

在这里插入图片描述

其实 ABA 问题, 就类似于上面的这个情景, 虽然张三确实修改了数据, 但是李四又把数据给改回去了, 此时张三就以为这个数据没有修改.

但是这个 ABA 问题, 一般来说并不会导致什么严重的问题, 除了在一些非常巧合的情景下, 例如下面的这个情景

假设张三现在在银行取钱, 此时点了第一次取 500, 发现没有反应, 于是又点了一次, 假设现在触发了两个线程进行扣款操作

在这里插入图片描述

很明显, 此时没有任何的问题, 但是假如说, 此时李四又给张三打了 500 块钱, 并且这个打钱的时间正好在 t1 线程执行 CAS 前

在这里插入图片描述

很明显, 此时由于李四的转账, 张三的钱从扣完的 500 又变回了 1000, 从而导致 t1 线程又进行了一次扣款. 那么此时这个 ABA 问题就产生了 Bug, 不过我们还是可以发现, 这个 Bug 的发生需要一定的巧合, 即要张三连续触发两次操作, 还要李四恰好就在那个时间点进行转账.

不过既然是有 Bug, 那么就是有概率发生的, 那有什么办法可以避免 ABA 问题呢?

实际上, ABA 问题的本质, 就是一个值既可以进行增也可以进行减, 从而导致一个值可以变回原样, 导致误判. 那么此时我们就可以尝试引入一个只能进行增/减的数值, 这里最经典的做法就是引入一个版本号.

这个版本号, 只能增不能减, 每进行一次操作就增加一次, 此时就可以防止版本号来回横跳, 从而避免 ABA 问题

JUC介绍

初识JUC

JUC 全称java.util.concurrent, 实际上就是 Java 提供的一系列关于并发编程的一些工具类以及组件, 我们接下来就来简单的了解一下其中的一些类

Callable接口

初步使用

假设我们现在需要基于基本的线程操作, 创建出一个线程, 计算 1 + 2 + 3 + … + 1000, 并使用主线程打印结果. 那么此时我们就可以使用一个类来接收这个返回值, 后续在主线程中打印即可

代码如下所示

class Return{
    int sum;
}

public class Demo25 {

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

        Return ret = new Return();

        Thread t = new Thread(() -> {
            int sum = 0;
            for (int i = 1; i <= 1000; i++) {
                sum += i;
            }

            ret.sum = sum;
        });

        t.start();
        t.join();
        System.out.println(ret.sum);

    }
}

此时发现还是挺麻烦的, 为了能够接收返回值, 还要专门的去创建一个类来接受. 因此为了能够更方便的实现这个操作, 那么我们就可以使用 Callable 接口

Callable 接口也是一个函数式接口, 他里面有一个 call() 方法, 允许有一个返回值

在这里插入图片描述

但是这个 Callable 接口并不能直接传入到 Thread 的构造方法里面, 而是需要先传入到一个 FutureTask 的构造方法里面, 如下所示

public class CallableDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 通过 lambda 表达式, 创建 Callable 对象
        Callable<Integer> callable = () -> {
            int sum = 0;
            for (int i = 1; i <= 1000; i++) {
                sum += i;
            }
            return sum;
        };
        // 创建 FutureTask 对象
        FutureTask<Integer> tasks = new FutureTask<>(callable);
    }
}

最后我们就可以把这个 FutureTask 对象传给 Thread 的构造方法, 从而创建一个线程. 同时我们在最后调用 FutureTask 对象的 get() 方法, 就可以获取到返回值了

public class CallableDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 通过 lambda 表达式, 创建 Callable 对象
        Callable<Integer> callable = () -> {
            int sum = 0;
            for (int i = 1; i <= 1000; i++) {
                sum += i;
            }
            return sum;
        };
        // 创建 FutureTask 对象
        FutureTask<Integer> tasks = new FutureTask<>(callable);
        
        // 传入 FutureTask 对象
        Thread t = new Thread(tasks);
        t.start();
        // 获取返回值并打印
        System.out.println(tasks.get());
    }
}

当然, 这里可能有人好奇, 为什么这个 FutureTask 可以传入到 Thread 的构造方法里面. 其实我们点进去就能看到实际上它是实现了 Runnable 的.

在这里插入图片描述

在这里插入图片描述

当然, 可能看到这里, 感觉这个 Callable 的使用似乎有一些怪怪的, 为什么和我们之前创建线程的方式不太一样?

实际上, 这里核心不一样的点就在于, 我们多借助了一个类 FutureTask 来创建线程. 那么为什么需要这个类呢?

为什么需要这个类, 本质上就是需要它来帮我们进行返回值的存储. 其实我们去看它的源码可以看到, 实际上它重写的 run() 方法, 就是把 Callable 里面的方法执行了一下, 然后存储一下返回值

在这里插入图片描述

同时, 为了保证线程安全, 它这里的一系列操作都采用了 CAS 来进行, 例如这里的 set() 方法

在这里插入图片描述

线程的创建方式

目前我们了解了非常多的线程创建方式, 因此这里进行一个总结

  1. 直接创建 Thread 对象, 调用 start()
  2. 继承 Thread 类, 重写 run() 方法, 创建子类对象, 调用 start()
  3. 实现 Runnable 接口, 重写 run() 方法, 创建 Thread 对象, , 调用 start()
  4. 实现 Callable 接口, 重写 call() 方法, 通过 FutureTask 对象创建 Thread 对象, 调用 start()

其实要分散开来, 这里还是有非常多其他的方法的, 例如通过 Lambda 表达式, 线程池, 线程工厂

但是无论是上述的哪一种, 本质上都脱离不出 3 个基本的创建方法, 例如 Java 自带的线程池, 实际上就是需要提供线程工厂去创建的, 而线程工厂, 本质上就是通过 new Thread() 来创建出的, 例如我们看一下 Java 中的一个 DefaultThreadFactory

在这里插入图片描述

ReentrantLock

初步使用

除了 synchronized, 其实 Java 中还是提供了一些其他锁的实现的, 这个 ReentrantLock 就是其中之一

这个锁需要我们手动的进行加锁和释放锁操作, 如下所示

public class ReentrantLockDemo {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();

        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Thread 1: 获取锁");
                sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("Thread 1: 释放锁");
            lock.unlock();
        }).start();

        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Thread 2: 获取锁");
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("Thread 2: 释放锁");
            lock.unlock();
        }).start();
    }
}

如果这里不解锁, 那么就会一直占用, 不会释放锁

public class ReentrantLockDemo {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();

        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Thread 1: 获取锁");
                sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("Thread 1: 释放锁");
            // lock.unlock();
        }).start();

        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Thread 2: 获取锁");
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("Thread 2: 释放锁");
            // lock.unlock();
        }).start();
    }
}

在这里插入图片描述

为了能够避免忘记解锁, 此时我们可以采用 finally 来解锁

package demo;

import java.util.concurrent.locks.ReentrantLock;

import static java.lang.Thread.sleep;

public class ReentrantLockDemo {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();

        new Thread(() -> {
            try {
                lock.lock();
                System.out.println("Thread 1: 获取锁");
                sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }finally {
                System.out.println("Thread 1: 释放锁");
                lock.unlock();
            }
        }).start();

        new Thread(() -> {
            try {
                lock.lock();
                System.out.println("Thread 2: 获取锁");
                sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }finally {
                System.out.println("Thread 2: 释放锁");
                lock.unlock();
            }
        }).start();
    }
}

ReentrantLock的优点

  1. ReentrantLock 有一种尝试加锁的方式tryLock(), 可以先尝试加锁
  2. ReentrantLock 提供了公平锁的实现, 但是默认为非公平锁
  3. ReentrantLock 提供了更加强大的等待通知机制, 假如有多个线程在等待通知, 此时 ReentrantLock 允许通知某个特定的线程

虽然看似 ReentrantLock 很强, 但是由于 synchronized 相对来说更加方便, 并且自带一些优化机制, 因此我们大部分情况下还是会优先选择使用 synchronized

信号量

初步了解

信号量是操作系统中涉及到的一个概念, 听起来比较高级, 但是本质上就是一个计数器, 它主要就是用于描述可用资源的个数的. 就好比我们日常生活中经常看到的那种停车位标识, 就类似于这个东西

信号量, 一般来说有两种操作, 一个是 P 操作, 一个是 V 操作. P 操作指的是获取一个资源, V 操作指的是释放一个资源. 此时每个线程就可以分别对这个信号量进行 P 或 V 操作, 从而实现进程间的通信.

同时, 如果信号量为0, 已经被取光了, 如果此时又来其他线程来申请获取资源, 那么这个线程就会阻塞等待.

此时可能有人看到这里, 感觉这个信号量怎么和我们学过的锁有一点点相似呢?

实际上, 锁就是一个可用资源数为 1 的特殊信号量, 当有一个线程获取了后, 那么其他线程想要获取, 就必须进行阻塞等待.

初步使用

Java 中也提供了对应的类, 也就是 Semaphore, 它其中主要包含了两个方法 acquire() 方法和 release() 方法, 分别对应了 P 操作和 V 操作.

下面这个代码就演示了 20 个线程抢占 5 个信号量的过程

public class SemaphoreDemo {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(5);
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                try {
                    // 获取信号量
                    semaphore.acquire();
                    // 打印信息
                    System.out.println(Thread.currentThread().getName() + ": " + semaphore.availablePermits());
                    Thread.sleep(2000);
                    // 释放信号量
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

那此时可能有人就要问了, 你的这个信号量不会发生线程安全问题吗?

实际上, 如果我们通过源码来进去看, 最后可以发现这些方法都是基于 CAS 实现的, 因此都是原子操作

在这里插入图片描述

在这里插入图片描述

CountDownLatch

在某些情况, 我们会将一个大任务拆分成若干个小任务, 同时我们希望能够把这多个小任务交给不同的线程执行.

但是此时就有一个问题, 我怎么衡量这些小任务都完成了呢? 此时通过 join() 或者 wait() & notify() 似乎也可以实现, 但是好像有点麻烦.

那么此时我们就可以使用 CountDownLatch 这个类来计数小任务的完成数, 它就是一个可以在多线程环境下使用的一个同步计数器

下面是一个简单的使用例子

public class CountDownLatchDemo {
    public static void main(String[] args) {
        // 创建一个计数器
        CountDownLatch countDownLatch = new CountDownLatch(5);
        for (int i = 0; i < 5; i++) {
            // 采用一个临时变量, 来实现不同等待时间的效果
            int index = i;
            new Thread(() -> {
                try {
                    Thread.sleep(1000 * index);
                    System.out.println(Thread.currentThread().getName());
                    // 计数 - 1
                    countDownLatch.countDown();
                } catch (InterruptedException e) {

                }
            }).start();
        }

        try {
            // 等待计数器变为0,即所有线程都执行完毕
            countDownLatch.await();
            System.out.println("所有线程都执行完毕");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

多线程与集合类

大部分的集合类都是线程不安全的, 除了 Vector, Stack, HashTable这样的一些远古集合类, 它们为什么能够一定程度上实现线程安全也很简单, 就是所有的方法都加上了 synchronized

在这里插入图片描述

在这里插入图片描述

但是 Java 官方也说明了, 当今如果要使用线程安全的集合类, 更推荐采用一些新版本的解决方案, 例如我们下面要介绍的 synchronizedCollection, CopyOnWriteArrayList 以及 ConcurrentHashMap.

synchronizedCollection()

这个东西并不是一个集合类, 而是一个方法. 这个方法位于 Collections 包下, 是一个用于给 Collection 接口里面的方法套壳的方法, 就相当于给 Collection 的方法全部套上了 synchronized

在这里插入图片描述

另外还有synchronizedList(), synchronizedMap()这样的专门给 Map 接口, List 接口里面的方法去套壳的方法

CopyOnWriteArrayList

这个 ArrayList 相较于原生 ArrayList 的特殊之处就在于, 它会在写入的时候自动拷贝一份, 后续如果在写的过程中, 其他线程读到的 ArrayList 就是从原来的那一份进行读取, 这样就可以避免读取到修改一半的数据. 当对拷贝的写操作完成后, 原数据就会被拷贝直接替代.

但是相对应, 这个 ArrayList 的大小不能非常的大, 否则拷贝的开销反而会过高, 此时效果甚至可能不如进行加锁操作. 同时只能适合一个线程改, 多个线程读的场景, 因为如果有多个线程改, 那么就涉及到了如何合并的新问题.

这个场景就特别适用于服务器配置的更新操作, 一般来说服务器的配置文件并不会特别的大, 同时一般配置项的修改只会有一个线程来进行, 其他线程只会去读取.

ConcurrentHashMap

对于哈希表来说, 实际上并没有必要对一整个哈希表进行加锁, 为什么呢?

一般来说, 我们的哈希表一般是使用一个个的链表来连接而成的(为了防止哈希冲突), 但是我们如果修改读取两个链表, 此时并不一定会引起线程安全问题(如果扩容则不一定)

因为我们通过哈希映射后, 如果哈希函数设计的比较好, 那么此时一般来说两个元素的映射映射到同一个格子上面的概率还是比较低的. 那映射到的都不是一个格子上了, 修改的都不是同一个链表, 此时自然也就不会发生线程安全问题.

因此比起给整个哈希表加锁, 更好的做法是一个链表一个锁

其中 ConcurrentHashMap 最核心的保证线程安全的做法就是上述做法, 它将每个链表的头节点作为锁对象, 实现了每一个链表一个小锁的做法, 大幅降低了锁冲突的概率

另外它还有几点代码上的优化, 我们简单了解一下:

  1. 利用了CAS特性, 把很多需要加锁的环节进行了优化(例如记录hash中的元素个数, 此时可以使用CAS计数)
  2. 对读的操作没有任何加锁操作, 但是从底层编码保证了读写操作都是原子操作, 读取到的值要么是还没改, 要么是改完了, 不会出现那种读取到处理一半的值的情况
  3. 针对扩容操作的单独优化, HashMap正常的扩容操作是要一次性拷贝完全部的元素的, 而ConcurrentHashMap实现了一种分次搬运, 在每一次操作的时候分别搬运一点点, 而不是一次性就全部搬掉, 避免了单次操作过于卡顿

虽然提到了很多东西, 但是本质上这个 ConcurrentHashMap 的使用和 HashMap 没有什么区别, 我们就在可能会发生线程安全问题的位置, 去直接使用这个 ConcurrentHashMap 即可.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值