多线程常见面试题

news2024/11/15 8:48:28

常见的锁策略

这里讨论的锁策略,不仅仅局限于 Java

乐观锁 vs 悲观锁

锁冲突: 两个线程尝试获取一把锁,一个线程能获取成功,另一个线程阻塞等待。

乐观锁: 预该场景中,不太会出现锁冲突的情况。后续做的工作会更少。
悲观锁: 预测该场景,非常容易出现锁冲突。后续做的工作会更多。

重量级锁 vs 轻量级锁

重量级锁: 加锁的开销是比较大的(花的时间多,占用系统资源多)

轻量级锁: 加锁开销比较小的,(花的时间少,占用系统资源少)

一个悲观锁,很可能是重量级锁(不绝对)。一个乐观锁,也很可能是轻量级锁(不绝对)

悲观乐观,是在加锁之前,对锁冲突概率的预测,决定工作的多少。重量轻量,是在加锁之后,考量实际的锁的开销。正是因为这样的概念存在重合,针对一个具体的锁,可能把它叫做乐观锁,也可能叫做轻量级锁。

自旋锁(Spin Lock)vs 挂起等待锁

自旋锁:是轻量级锁的一种典型实现

  • 在用户态下,通过自旋的方式**(while 循环)**实现类似于加锁的效果的
  • 这种锁,会消耗一定的 cpu 资源,但是可以做到最快速度拿到锁。

挂起等待锁:是重量级锁的一种典型实现

  • 通过内核态,借助系统提供的锁机制。
  • 当出现锁冲突的时候,使冲突的线程出现挂起**(阻塞等待)**。挂起等待不会消耗CPU
  • 这种方式,消耗的 cpu 资源是更少的。也就无法保证第一时间拿到锁。

读写锁 VS 互斥锁

读写锁:把读操作加锁和写操作加锁分开了

一个事实: 多线程同时去读同一个变量,不涉及到线程安全问题。

如果两个线程, 一个线程读加锁,另一个线程也是读加锁,不会产生锁竞争。(并发执行效率更高了)
如果两个线程,一个线程写加锁,另一个线程也是写加锁,会产生锁竞争。
如果两个线程, 一个线程写加锁,另一个线程读加锁,也会产生锁竞争。

实际开发中,读操作的频率,往往比写操作,高很多。Java 标准库里,也提供了现成的读写锁。ReentrantReadWriteLock 。

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行
    加锁解锁。
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进
    行加锁解锁。

互斥锁:Synchronized 这种只有单纯的加锁解锁两个操作。

公平锁 vs 非公平锁

公平锁:是遵守先来后到的锁。B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁 。

非公平锁看起来是概率均等,但是实际上是不公平.(每个线程阻塞时间是不一样的)。

操作系统自带的锁 (pthread mutex) 属于是非公平锁。要想实现公平锁,就需要有一些额外的数据结构来支持。比如需要有办法记录每个线程的阻塞等待时间。

可重入锁 vs 不可重入锁

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

public synchronized void increase(){
     synchronized(locker){
         count++;
     }
}

1.调用方法,先针对 this 加锁. 此时假设加锁成功了
2.接下来往下执行到 代码块 中的 synchronized。此时,还是针对 this 来进行加锁。

此时就会产生锁竞争.当前 this 对象已经处于加锁状态了。此时,该线程就会阻塞,一直阻塞到锁被释放,才能有机会拿到锁。

此时,由于 this 的锁没法释放。这个代码就卡在这里了,因此这个线程就僵住了。此时就产生了死锁。

这里的关键在于,两次加锁,都是“同一个线程"。第二次尝试加锁的时候,该线程已经有了这个锁的权限了, 这个时候不应该加锁失败的,不应该阻塞等待的。

不可重入锁:这把锁不会保存,是哪个线程对它加的锁。只要它当前处于加锁状态之后,收到了"加锁”这样的请求 就会拒绝当前加锁。而不管当下的线程是哪个。就会产生死锁。
可重入锁:是会让这个锁保存,是哪个线程加上的锁。后续收到加请求之后,就会先对比一下,看看加锁的线程是不是当前持有自己这把锁的线程,这个时候就可以灵活判定了。

synchronized本身是一个可重入锁, 实际上不会产生上述的死锁情况。

死锁

死锁概念

死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。多个线程被无限期阻塞,导致线程不可能正常终止

死锁的三种典型情况:

  1. 一个线程,一把锁,但是是不可重入锁.该线程针对这个锁连续加锁两次,就会出现死锁
  2. 两个线程,两把锁.这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁。
  3. N 个线程 M 把锁,哲学家就餐问题。

两个线程两把锁问题:

就相当于一个在疫情时期的一个段子。健康码坏了,程序员要进去修,但是程序员不能出示健康码不能进去修,要想有健康码就得修好了才能出示。

public class ThreadDemo {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1两把锁加锁成功");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker1){
                    System.out.println("t2两把锁加锁成功");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

上面代码就会出现死锁问题。每个线程都卡在了第二次加锁的过程。

如果是一个服务器程序,出现死锁。死锁的线程就僵住了,就无法继续工作了, 会对程序造成严重的影响。

N 个线程 M 把锁,哲学家就餐问题:

image-20230824104605454

每个哲学家,主要要做两件事:

1.思考人生.会放下筷子
2.吃面.会拿起左手和右手的筷子,再去夹面条吃。

其他设定:
1.每个哲学家,啥时候思考人生,啥时候吃面条,都不确定的

2.每个哲学家一旦想吃面条了,就会非常固执的完成吃面条的操作。如果此时,他的筷子被别人使用了,就会阻塞等待,而且等待过程中不会放下手里已经拿着的筷子。

基于上述的模型设定,绝大部分情况下,这些哲学家都是可以很好的工作的。但是,如果出现了极端情况,就会出现死锁。比如:同一时刻,五个哲学家都想吃面,并且同时伸出 左手 拿起左边的筷子。再尝试伸右手拿右边的筷子。此时就会哪个哲学家都不会吃上面条了,这里五个哲学家无根筷子相当于5个线程5把锁。

避免死锁

死锁产生的必要条件:

  1. 互斥使用:一个线程获取到一把锁之后,别的线程不能获取到这个锁
    • 实际使用的锁,一般都是互斥的(锁的基本特性)
  2. 不可抢占锁: 只能是被持有者主动释放,而不能是被其他线程直接抢走
    • 也是锁的基本的特性
  3. 请求和保持: 一个线程去尝试获取多把锁,在请求获取第二把锁的过程中,会保持对第一把锁的获取状态。
    • 取决于代码结构(很可能会影响到需求)
  4. 循环等待: t1 尝试获取 locker2,需要 等待 t2 执行完,释放 locker2。t2 尝试获取 locker1,需要 等待 t1 执行完,释放 locker1。
    • 取决于代码结构

缺一不可,只要能够破坏其中的任意一个条件,都可以避免出现死锁。

解决死锁问题的最关键要点:破除循环等待。

破除循环等待:针对锁进行编号。并且规定加锁的顺序。比如,约定,每个线程如果要获取多把锁,必须先获取 编号小的锁,后获取编号大的锁。只要所有线程加锁的顺序,都严格遵守上述顺序,就一定不会出现环等待。

image-20230824112257551

针对上面死锁代码进行加锁编号,来解决死锁问题:

public class ThreadDemo {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1两把锁加锁成功");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t2两把锁加锁成功");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

Synchronized 原理

Synchronized的锁策略

synchronized 具体是采用了哪些锁策略呢?

  • 1.synchronized 既是悲观锁, 也是乐观锁
  • 2.synchronized 既是重量级锁,也是轻量级锁.(自适应)
  • 3.synchronized 重量级锁部分是基于系统的互斥锁实现的; 轻量级锁部分是基于自旋锁实现的
  • 4.synchronized 是非公平锁(不会遵守先来后到 锁释放之后,哪个线程拿到锁,各凭本事)
  • 5.synchronized 是可重入锁.(内部会记录哪个线程拿到了锁,记录引用计数)
  • 6.synchronized 不是读写锁,是互斥锁。

synchronized 加锁过程

代码中写了一个 synchronized 之后,这里可能会产生一系列的“自适应的过程”,锁升级(锁膨胀)。

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

偏向锁:不是真的加锁,而只是做了一个”标记“。如果有别的线程来竞争锁了,才会真的加锁。如果没有别的线程竞争,就自始至终都不会真的加锁了。**加锁本身,有一定开销。能不加,就不加。非得是有人来竞争了,才会真的加锁。**偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量)。一旦有别的线程尝试进行加锁,就会立即把偏向锁,升级成真正加锁的状态,让别人只能阻塞等待。

轻量级锁:sychronized 通过自旋锁的方式来实现轻量级锁。我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了。但是,后续,如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量级锁,升级成重量级锁。

轻量级锁的操作是比较消耗 CPU 的。 如果能够比较快速的拿到锁,多消耗点 CPU 也不亏。但是,随着竞争更加激烈,即使前一个线程释放锁 ,也不一定能拿到锁,啥时候能拿到,时间可能会比较久了。

synchronized 的优化操作

锁消除:编译器,会智能的判定,当前这个代码,是否有必要加锁。如果,你写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉。

锁粗化:关于"锁的粒度",如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越粗。一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化 。

image-20230824163339974

有的时候,希望锁的粒度小比较好,并发程度更高。有的时候,也希望锁的粒度大比较好,因为加锁解锁本身也有开销。

CAS

CAS的概念

CAS: 全称Compare and swap,字面意思:”比较并交换“。能够比较和交换 某个寄存器 中的值 和 内存 中的值,看是否相等。如果相等,则把另外一个寄存器中的值和内存进行交换。

boolean CAS(address, expectValue, swapValue) {
    if (&address == expectedValue) {
        &address = swapValue;
        //此处,严格的说,是把 address 内存的值,和 swapValue 寄存器里的值, 进行交换。
        //但是一般我们重点关注的是内存中的值。
        //寄存器往往作为保存临时数据的方式,这里的值是啥,很多时候就忽略了。
        return true;
    }
    return false;	
}

address:内存地址

expectValue, swapValue:寄存器中的值

上面一段逻辑,是通过一条 cpu 指令完成的(原子的)。这个就给我们编写线程安全代码,打开了新世界的大门。基于 CAS 又能衍生出一套"无锁编程“。但是CAS 的使用范围具有一定局限性的。

CAS的实现是:硬件予以了支持,软件层面才能做到

CAS的应用

1. 实现原子类

比如,多线程针对一个 count 变量进行 ++,在java 标准库中基于CAS,已经提供了一组原子类。

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

以 AtomicInteger 举例,常见方法有 :

  • addAndGet(int delta); 相当于 i += delta;

  • getAndIncrement 相当于 i++ 操作。

  • incrementAndGet 相当于 ++i 操作。

  • getAndDecrement 相当于 i-- 操作。

  • decrementAndGet 相当于 --i 操作。

public class ThreadDemo26 {
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                //Java 不像 C++  Python 能支持运算符重载,这里必须通过调用方法的方式来完成自增
                count.getAndIncrement();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count.get());
    }
}

上述的原子类,就是基于 CAS 来实现的。

伪代码实现:

class AtomicInteger {
    private int value;//很可能有个别的线程穿插在这俩代码之间,把 value 给改.
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
        }
        return oldValue;
    }
}

oldValue:也可以是寄存器中的值,由于以往学过的 C/Java 里头,并没有啥办法定义一个“寄存器”的变量。

image-20230824173550007

这里的比较value和oldValue相等,其实就是在检查当前 value 是不是变了。是不是被别的线程穿插进来做出修改了。进一步就发现了当前的 ++ 操作不是一气呵成的原子操作了。一旦发现出现其他线程穿插的情况,立即重新读取内存的值准备下一次尝试。

加锁保证线程安全: 通过锁,强制避免出现穿插

原子类/CAS 保证线程安全: 借助 CAS 来识别当前是否出现其他线程"穿插”的情况。如果没穿插,此时直接修改 就是安全的。如果出现穿插了,就重新读取内存中的最新的值,再次尝试修改。

2. 实现自旋锁

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

自旋锁伪代码:

public class SpinLock {
    private Thread owner = null;
    //此处使用 owner 表示当前是哪个线程持有的这把锁.null 解锁状态
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有.
        // 如果这个锁已经被别的线程持有, 那么就自旋等待.
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
        while(!CAS(this.owner, null, Thread.currentThread())){
            //Thread.currentThread()获取当前线程引用
			//哪个线程调用 lock,这里得到的结果就是哪个线程的引用
        }
        //当该锁已经处于加锁状态,这里就会返回 false, 
        //CAS 不会进行实际的交换操作.接下来循环条件成立,继续进入下一轮循环
    }
    public void unlock (){
    	this.owner = null;
    }
}

CAS 的 ABA 问题

CAS 关键要点,是比较 寄存器1 和 内存 的值。通过这里的是否相等,来判定 内存的值 是否发生了改变。如果内存的值变了,存在其他线程进行了修改如果内存的值没变没有别的线程修改,接下来进行的修改就是安全的。

ABA 的问题: 另一个线程,把 变量的值从 A -> B,又从 B -> A。此时本线程区分不了,这个值是始终没变,还是出现变化又回来了的情况。

大部分情况下,就算是出现 ABA 问题,也没啥太大影响。但是如果遇到一些极端的场景可能会出现问题:

账户 100 ,希望取款50,还剩50。假设出现极端问题:按第一下取款的时候,卡了一下, 我又按了一下。产生了两个“取款”请求,ATM 使用两个线程来处理这俩请求。假设按照 CAS 的方式进行取款,每个线程这样操作:

  1. 读取账户余额.放到变量 M 中。
  2. 使用 CAS 判定当前实际余额是否还是 M。如果是,就把实际余额修改成 M-50。如果不是,就放弃当前操作(操作失败)。

image-20230825145230880

上面这个ABA问题属于非常巧合的情况,取款的时候卡了 + 碰巧这个时候有人给你转了50

虽然上述操作,概率比较小,也需要去考虑。ABA问题的解决方式:

ABA 问题,CAS 基本的思路是 没有问题 的,但是主要是修改操作能够进行反复改变,就容易让咱们 cas 的判定失效。CAS 判定的是“值相同”,实际上期望的是“值没有变化过"。比如约定,值只能单向变化(比如只能增长,不能减小)。虽余额不能只增张不减少,但是衡量余额是否改变的标准可以是看版本号。给账户余额安排一个 其他属性版本号(只增加,不减少)。使用 CAS 判定版本号,如果版本号相同,则数据一定是没有修改过的,如果数据修改过版本号一定要增加

JUC(java.util.concurrent) 的常见类

juc中的类是为了并发编程准备的。java官方文档

Callable interface

也是一种创建线程的方式
Runnable 能表示一个任务 (run 方法),返回 void
Callable 也能表示一个任务 (call 方法),返回一个具体的值,类型可以通过泛型参数来指定(Object)。
如果进行多线程操作,如果你只是关心多线程执行的过程,使用 Runnable 即可如。果是关心多线程的计算结果,使用 Callable 更合适。

通过多线程的方式计算一个公式,比如创建一个线程,让这个线程计算 1 + 2 + 3 +…+ 1000,使用Callable解决更合适。

  • 使用 Callable 不能直接作为 Thread 的构造方法参数
  • 借助FutureTask 来作为Thread的构造方法参数
public class ThreadDemo {
    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 = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        //使用 Callable 不能直接作为 Thread 的构造方法参数
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        //获取 call 方法的返结果get ,类似于join 一样, 如果 call 方法没执行完,会阻塞等待
        Integer result = futureTask.get();
        System.out.println(result);
    }
}

ReentrantLock

可重入锁,这个锁 没有 synchronized 那么常用,但是也是一个可选的加锁的组件。这个锁在使用上更接近于 C++ 里的锁。

  • lock() 加锁
  • unlock() 解锁

分开操作,就容易出现unlock 调用不到的情况,容易遗漏。比如,中间 return / 抛出异常了。ReentrantLock 具有一些特点,是 synchronized 不具备的功能(优势):

  • 提供了一个 tryLock 方法进行加锁

    • 对于 lock 操作,如果加锁不成功,就会阻塞等待(死等)
    • 对于 tryLock,如果加锁失败,直接返回 false/也可以设定等待时间。
    • tryLock 给加锁操作提供了更多的可操作空间。
  • ReentrantLock 有两种模式。可以工作在公平锁状态下,也可以工作在非公平锁的状态下。

    • 构造方法中通过参数设定的 公平/非公平模式。
  • ReentrantLock 也有等待通知机制,搭配 Condition 这样的类来完成这里的等待通知。要比 wait notify 功能更强

虽然ReentrantLock有上述这些优点,但是 ReentrantLock 劣势也很明显(比较致命),unlock 容易遗漏使用 finally 来执行 unlock。

synchronized 锁对象是任意对象。ReentrantLock 锁对象就是自己本身。如果你多个线程针对不同的 ReentrantLock 调用 lock 方法,此时是不会产生锁竞争的。实际开发中,进行多线程开发,用到锁还是首选 synchronized。

原子类

原子类的应用场景:

计数请求:播放量,点赞量,投币量,转发量,收藏量。同一个视频,有很多人都在同时的播放/点赞/收藏

统计效果:
统计出现错误的请求数目。—> 使用原子类,记录出错的请求的数目。—> 另外写一个监控服务器,获取到线上服务器的这些错误计数,并且以曲线图的方式绘制到页面上。

某次发布程序之后,发现,突然这里的错误数大幅度上升,说明你这个新版本代码大概率存在 bug。

统计收到的请求总数(衡量服务器的压力)。统计每个请求的响应时间 => 平均的响应时间(衡量服务器的运行效率)。
最低 1% 的响应时间是多少(1% low 帧)。线上服务器通过这些统计内容,进行简单计数 =>实现监控服务器,获取/统计/展示/报警。

信号量 Semaphore

Semaphore 是并发编程中的一个重要的概念/组件。准确来说,Semaphore 是一个计数器(变量),描述了**"可用资源"的个数**。描述的是,当前这个线程,是否**“有临界资源可以用“**。

  • P 操作:申请了一个可用资源 - 1。accquire (申请)
  • V 操作:释放了一个可用资源 +1。release (释放)

当计数器数值为 0 的时候,继续进行 P 操作,就会阻塞等待,一直等待到其他线程执行了 V 操作,释放了一个空闲
资源为止。锁,本质上是一个特殊的信号量(里面的数值,非 0 即 1二元信号量)。信号量要比锁更广义,不仅仅可以描述一个资源,还可以描述 N 个资源。虽然概念上更广泛,实际开发中,还是锁更多一些(二元信号量的场景是更常见的)。

//信号量
public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法中, 就可以用来指定计数器的初始值.
        Semaphore semaphore = new Semaphore(4);
        semaphore.acquire();//计数器-1
        System.out.println("执行p操作");
        semaphore.acquire();//计数器-1
        System.out.println("执行p操作");
        semaphore.acquire();//计数器-1
        System.out.println("执行p操作");
        semaphore.acquire();//计数器-1
        System.out.println("执行p操作");
        semaphore.acquire();//计数器-1
        System.out.println("执行p操作");
    }
}

CountDownLatch

针对特定场景一个组件。同时等待 N 个任务执行结束

下载某个东西:有的时候,下载一个比较大的文件,比较慢(慢不是因为你家里的网速限制,往往是人家服务器这边的限制)。有一些多线程下载器”,把一个大的文件,拆分成多个小的部分,使用多个线程分别下载。每个线程负责下载一部分,每个线程分别是一个网络连接。就会大幅度提高下载速度。假设,分成 10个线程,10个部分来下载。 10个部分都下载完了,整体才算完成。

//CountDownLatch
public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法中, 指定创建几个任务.
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            int id = i;
            Thread t = new Thread(()-> {
                System.out.println("线程" + id + "开始工作");
                try {
                    // 使用 sleep 代指某些耗时操作, 比如下载.
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程" + id + "结束工作");
                // 每个任务执行结束这里, 调用一下方法
                // 把 10 个线程想象成短跑比赛的 10 个运动员. countDown 就是运动员撞线了.
                countDownLatch.countDown();
            });
            t.start();
        }
        // 主线程如何知道上述所有的任务都完成了呢??
        // 难道要在主线程中调用 10 次 join 嘛?
        // 万一要是任务结束, 但是线程不需要结束, join 不就也不行了嘛。
        // 主线程中可以使用 countDownLatch 负责等待任务结束.
        // a => all 等待所有任务结束. 当调用 countDown 次数 < 初始设置的次数, await 就会阻塞.
        countDownLatch.await();
        System.out.println("多个线程的所有任务都执行完毕了!!");
    }
}

线程安全的集合类

多个线程同时操作这个集合类,不会会产生问题就是线程安全的。

Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.

在关键的方法中,使用了 synchronized。Vector 和 HashTable 属于是 Java 上古时期,搞出来的集合类。加了锁,不一定就线程安全。不加锁也不一定就线程不安全 => 要具体问题具体分析。

虽然 get 和 set 方法加了 synchronized ,但是如果不能正确使用,也可能会出现线程安全问题:

  1. 如果是多个线程,并发执行 set 操作,由于 synchronized 限制,是线程安全。
  2. 如果多个线程进行一些更复杂的操作,比如判定 get 的值是 xxx,再进行 set,可能会线程不安全。

image-20230826102212453

即使把这里的 get 和 set 分别进行加锁。如果不能正确的使用,也可能产生线程安全问题。考虑到实际的逻辑中,哪些代码是要作为一个整体的(原子的)。

线程安全下使用ArrayList

Collections.synchronizedList(new ArrayList);

ArrayList 本身没有使用 synchronized。但是你又不想自己加锁,就可以使用上面这个东西,相当于让 ArrayList 像 Vector 一样工作。(很少会用)

使用 CopyOnWriteArrayList 写时复制

多个线程同时修改同一个变量,如果多个线程修改不同变量,就会安全了。

如果多线程去读取,本身就不会有任何线程安全问。一旦有线程修改,就会把自身复制一份。尤其是修改比较耗时的话,其他线程还是旧的数据上读取。一旦修改完成,使用新的 ArrayList 替换目的 ArrayList (本质上就是一个引用的重新赋值速度极快,并且又是原子的)
这个过程中,没有引入任何的加锁操作。使用了创建副本 => 修改副本 => 使用副本替换。

线程安全下使用HashMap

ConcurrentHashMap 线程安全的 hash 表。

  • HashTable 是在方法上直接加上 synchronized,就相当于针对 this 加锁。

如果两个修改操作,是针对两个不同的链表进行修改,不会存在线程安全问题。既然这里没有线程安全问题,但是锁又不能完全不加,因为两个修改可能在同一个链表中同一个位置进行插入操作。

为了解决上面的问题:给每个链表都加一把锁。

一个hash表上面的链表个数这么多,两个线程正好在同时操作同一个链表的概率本身就是比较低的,整体锁的开销就大大降低了。由于 synchronized 随便拿个对象都可以用来加锁,就可以简单的使用每个链表的头结点,作为锁对象即可。

ConcurrentHashMap 改进:

  1. [核心] 减小了锁的粒度,每个链表有一把锁。大部分情况下都不会涉及到锁冲突。
  2. 广泛使用了 CAS 操作(比如size++)
  3. 写操作进行了加锁(链表级),读操作,不加锁了。
  4. 针对扩容操作进行了优化,浙进式扩容。

HashTable 一旦触发扩容, 就会立即的一口气的完成所有元素的搬运,这个过程相当耗时。大部分请求都很顺畅,突然某个请求就卡了比较久。化整为零,当需要进行扩容的时候,会创建出另一个更大的数组,然后把旧的数组上的数据逐渐的往新的数组上搬运。会出现一段时间,旧数组和新数组同时存在。

  • 新增元素,往新数组上插入。
  • 删除元素,把旧数组的元素给删掉即可。
  • 查找元素,新数组旧数组都得查找。
  • 修改元素,统一把这个元素给搞到新数组上。

与此同时,每个操作都会触发一定程度搬运。每次搬运一点,就可以保证整体的时间不是很长。积少成多之后,逐渐完成搬运了,也就可以把之前的旧数组彻底销毁了。

介绍下 ConcurrentHashMap的锁分段技术?

Java 8 之前,ConcurrentHashMap 是使用分段锁,从 Java 8 开始,就是每个链表自己一把锁了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/930465.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【宝藏系列】一文带你梳理 Linux 的五种 IO 模型

【宝藏系列】一文带你梳理 Linux 的五种 IO 模型 文章目录 【宝藏系列】一文带你梳理 Linux 的五种 IO 模型&#x1f468;‍&#x1f3eb;前言1️⃣用户态和核心态1️⃣1️⃣用户态和核心态的切换 2️⃣进程切换3️⃣进程阻塞4️⃣文件描述符(fd, File Descriptor)5️⃣缓存I/O…

jemter连接数据json断言

文章目录 一、jmeter连接数据库1、加载JDBC驱动2、连接数据3、SQL Query的Query Type使用方法&#xff1a;4、Variable Name使用方法&#xff1a;5、Result variable name使用方法&#xff1a; 二、Json响应断言1、添加 》 断言 》 JSON断言2、JSON断言界面参数说明&#xff1a…

Leetcode80. 删除有序数组中的重复项 II

给你一个有序数组 nums &#xff0c;请你 原地 删除重复出现的元素&#xff0c;使得出现次数超过两次的元素只出现两次 &#xff0c;返回删除后数组的新长度。 不要使用额外的数组空间&#xff0c;你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。 class Solu…

2023水果编曲软件fl studio 21.1.0 .3713官方中文直装破解版

fl studio 21.1.0 .3713官方中文直装破解版是一个完整的软件音乐制作环境或数字音频工作站&#xff08;DAW&#xff09;。它代表了 25 多年的创新发展&#xff0c;将您创作、编曲、录制、编辑、混音和掌握专业品质音乐所需的一切集于一身。 fl studio 21.1.0 .3713官方中文直装…

windows10系统安装docker desktop超常见问题

问题报错&#xff1a; An unexpected error was encountered while executing a WSLcommand. Common causes include access rights issues, which occurafter waking the computer or not being connected to your domain/active directory. Please try shutting WSL down (w…

YOLOv5算法改进(6)— 添加SOCA注意力机制

前言&#xff1a;Hello大家好&#xff0c;我是小哥谈。SOCA&#xff08;self-organizing competitive attention&#xff09;是一种注意力机制&#xff0c;它模拟了人类视觉系统中的竞争性注意力机制。在视觉场景中&#xff0c;我们通常只关注某些特定的区域&#xff0c;而忽略…

浏览器开发者工具平台js代码开启展开收起

1、如下js左侧可以展开和收起段落&#xff0c;需要打开右上角的设置 2、Preferences这里勾选Code folding 即可像上面那张图展开和收起js段落代码 3、然后重新打开开发者工具&#xff0c;随意打开一个js文件&#xff0c;这里就有缩放了

5G R17R18技术解读

欢迎关注微信公众号“我想我思”

Hystrix: Dashboard流监控

接上两张服务熔断 开始搭建Dashboard流监控 pom依赖 <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://maven.apache.org/POM/4.0.0"xmlns:xsi"http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocat…

codeforce 894

A. Gift Carpet &#xff08;模拟&#xff09; 题意&#xff1a; 给出n*m的矩阵&#xff0c;从左到右每列最多取一个字母&#xff0c;问能否取出"vika" 思路&#xff1a; 直接模拟。 const int N1e610; char g[25][25]; void solve(){int n,m; cin>>n>>…

Vue2向Vue3过度核心技术路由

目录 1 路由介绍1.思考2.路由的介绍3.总结 2 路由的基本使用1.目标2.作用3.说明4.官网5.VueRouter的使用&#xff08;52&#xff09;6.代码示例7.两个核心步骤8.总结 3 组件的存放目录问题1.组件分类2.存放目录3.总结 4 路由的封装抽离5 Vue路由-重定向1.问题2.解决方案3.语法4…

openGauss学习笔记-51 openGauss 高级特性-列存储

文章目录 openGauss学习笔记-51 openGauss 高级特性-列存储51.1 语法格式51.2 参数说明51.3 示例 openGauss学习笔记-51 openGauss 高级特性-列存储 openGauss支持行列混合存储。行存储是指将表按行存储到硬盘分区上&#xff0c;列存储是指将表按列存储到硬盘分区上。 行、列…

最新本地大模型进展#Chinese-LLaMA-2支持16k长上下文

‍‍ Hi&#xff0c;今天为大家介绍最新的本地中文语言模型进展。 [2023/08/25] Chinese-LLaMA-2发布了新的更新&#xff1a; 长上下文模型Chinese-LLaMA-2-7B-16K和Chinese-LLaMA-2-13B-16K&#xff0c;支持16K上下文&#xff0c;并可通过NTK方法进一步扩展至24K。 这意味着在…

Lazada商品详情接口 获取Lazada商品详情数据 Lazada商品价格接

一、引言 随着电子商务的迅速发展和普及&#xff0c;电商平台之间的竞争也日趋激烈。为了提供更好的用户体验和更高效的后端管理&#xff0c;Lazada作为东南亚最大的电商平台之一&#xff0c;开发了一种商品详情接口&#xff08;Product Detail API&#xff09;。该接口允许第…

【附安装包】Vred2023安装教程

软件下载 软件&#xff1a;Vred版本&#xff1a;2023语言&#xff1a;简体中文大小&#xff1a;2.39G安装环境&#xff1a;Win11/Win10/Win8/Win7硬件要求&#xff1a;CPU2.0GHz 内存4G(或更高&#xff09;下载通道①百度网盘丨64位下载链接&#xff1a;https://pan.baidu.com…

Leetcode78. 子集

给你一个整数数组 nums &#xff0c;数组中的元素 互不相同 。返回该数组所有可能的子集&#xff08;幂集&#xff09;。 解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。 回溯法 class Solution {public List<List<Integer>> subsets(int[] nums) {List…

记录帖子-开发过程中遇到的问题和感悟记录

记录帖子1:2023年08月25日结束开发 前端规范 1.关于计算属性 计算属性关联的变量不可以过多&#xff0c;同时要保证关联的变量在代码中的变换次数不可过多 例如这段代码的this.options内部数据变化过多&#xff0c;导致计算属性调用次数过多导致页面卡顿 2.关于自定义v-mod…

空时自适应处理用于机载雷达——机载阵列雷达信号环境(Matla代码实现)

&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️&#x1f4a5;&#x1f4a5; &#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清晰&#xff0c;为了方便读者。 ⛳️座右铭&a…

【【萌新的STM32学习-17 中断的基本概念2】】

萌新的STM32学习-17 中断的基本概念2 STM32中断优先级的基本概念 抢占优先级&#xff1a; 高抢占优先级可以打断正在执行的低抢占优先级中断 响应优先级&#xff1a; 这个也叫子优先级 抢占优先级相同&#xff0c;响应优先级高的中断不能打断响应优先级低的中断。还有一种情况…

Linux常用命令——dhclient命令

在线Linux命令查询工具 dhclient 动态获取或释放IP地址 补充说明 dhclient命令使用动态主机配置协议动态的配置网络接口的网络参数。 语法 dhclient(选项)(参数)选项 0&#xff1a;指定dhcp客户端监听的端口号&#xff1b; -d&#xff1a;总是以前台方式运行程序&#x…