多线程常见面试题

2023-11-02

常见的锁策略

这里讨论的锁策略,不仅仅局限于 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 开始,就是每个链表自己一把锁了。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

多线程常见面试题 的相关文章

  • 蓝牙发送和接收文本数据

    我是 Android 开发新手 我想制作一个使用蓝牙发送和接收文本的应用程序 我得到了有关发送文本的所有内容逻辑工作 但是当我尝试在手机中测试它时 我看不到界面 这是Main Activity Code import android sup
  • Django 模型字段默认基于另一个模型字段

    我使用 Django Admin 构建一个管理站点 有两张表 一张是ModelA其中有数据 另一个是ModelB里面什么也没有 如果一个模型字段b b in ModelB为None 可以显示在网页上 值为ModelA的场a b 我不知道该怎
  • 如何将类添加到 LinkML 中的 SchemaDefinition?

    中的图表https linkml io linkml model docs SchemaDefinition https linkml io linkml model docs SchemaDefinition and https link
  • 在 Java 中获取并存储子进程的输出

    我正在做一些需要我开始子处理 命令提示符 并在其上执行一些命令的事情 我需要从子进程获取输出并将其存储在文件或字符串中 这是我到目前为止所做的 但它不起作用 public static void main String args try R
  • 在 iPython/pandas 中绘制多条线会生成多个图

    我试图了解 matplotlib 的状态机模型 但在尝试在单个图上绘制多条线时遇到错误 据我了解 以下代码应该生成包含两行的单个图 import pandas as pd import pandas io data as web aapl
  • Java Swing - 如何禁用 JPanel?

    我有一些JComponents on a JPanel我想在按下 开始 按钮时禁用所有这些组件 目前 我通过以下方式显式禁用所有组件 component1 setEnabled false 但是有什么办法可以一次性禁用所有组件吗 我尝试禁用
  • pandas 中连续数据的平行坐标图

    pandas 的 parallel coordinates 函数非常有用 import pandas import matplotlib pyplot as plt from pandas tools plotting import par
  • 在seaborn中对箱线图x轴进行排序

    我的数据框round data看起来像这样 error username task path 0 0 02 n49vq14uhvy93i5uw33tf7s1ei07vngozrzlsr6q6cnh8w 39 png 1 0 10 n49vq
  • 部署 .war 时出现 Glassfish 服务器错误:部署期间发生错误:准备应用程序时出现异常:资源无效

    我正在使用以下内容 NetBeans IDE 7 3 内部版本 201306052037 爪哇 1 7 0 17 Java HotSpot TM 64 位服务器虚拟机 23 7 b01 NetBeans 集成 GlassFish Serve
  • 如何分析组合的 python 和 c 代码

    我有一个由多个 python 脚本组成的应用程序 其中一些脚本正在调用 C 代码 该应用程序现在的运行速度比以前慢得多 因此我想对其进行分析以查看问题所在 是否有工具 软件包或只是一种分析此类应用程序的方法 有一个工具可以将 python
  • 计算 pyspark df 列中子字符串列表的出现次数

    我想计算子字符串列表的出现次数 并根据 pyspark df 中包含长字符串的列创建一个列 Input ID History 1 USA UK IND DEN MAL SWE AUS 2 USA UK PAK NOR 3 NOR NZE 4
  • Hibernate 本机查询 - char(3) 列

    我在 Oracle 中有一个表 其中列 SC CUR CODE 是 CHAR 3 当我做 Query q2 em createNativeQuery select sc cur code sc amount from sector cost
  • 在java中以原子方式获取多个锁

    我有以下代码 注意 为了可读性 我尽可能简化了代码 如果我忘记了任何关键部分 请告诉我 public class User private Relations relations public User relations new Rela
  • java 中的蓝牙 (J2SE)

    我是蓝牙新手 这就是我想做的事情 我想获取连接到我的电脑上的蓝牙的设备信息并将该信息写入文件中 我应该使用哪个 api 以及如何实现 我遇到了 bluecove 但经过几次搜索 我发现 bluecove 不能在 64 位电脑上运行 我现在应
  • 在 HDF5 (PyTables) 中存储 numpy 稀疏矩阵

    我在使用 PyTables 存储 numpy csr matrix 时遇到问题 我收到此错误 TypeError objects of type csr matrix are not supported in this context so
  • Java 正则表达式中的逻辑 AND

    是否可以在 Java Regex 中实现逻辑 AND 如果答案是肯定的 那么如何实现呢 正则表达式中的逻辑 AND 由一系列堆叠的先行断言组成 例如 foo bar glarch 将匹配包含所有三个 foo bar 和 glarch 的任何
  • 子类构造函数(JAVA)中的重写函数[重复]

    这个问题在这里已经有答案了 为什么在派生类构造函数中调用超类构造函数时 id 0 当创建子对象时 什么时候在堆中为该对象分配内存 在基类构造函数运行之后还是之前 class Parent int id 10 Parent meth void
  • Log4j2 ThreadContext 映射不适用于parallelStream()

    我有以下示例代码 public class Test static System setProperty isThreadContextMapInheritable true private static final Logger LOGG
  • 如何更改matplotlib中双头注释的头大小?

    Below figure shows the plot of which arrow head is very small 我尝试了下面的代码 但它不起作用 它说 引发 AttributeError 未知属性 s k 属性错误 未知属性头宽
  • Android View Canvas onDraw 未执行

    我目前正在开发一个自定义视图 它在画布上绘制一些图块 这些图块是从多个文件加载的 并将在需要时加载 它们将由 AsyncTask 加载 如果它们已经加载 它们只会被绘制在画布上 这工作正常 如果加载了这些图片 AsyncTask 就会触发v

随机推荐

  • 链路追踪zipkin

    目录 链路追踪介绍 zipkin整合mysql和mq 链路追踪介绍 链路追踪主要用于分布式系统 服务出现级联调用 能够提供调用的时间且能结算出网络延迟时间 gt 将服务还原成链路 链路数据模型有三个点 Trace 一个完整的链路 用的是我们
  • 不习惯的Vue3起步 の 一:<script setup>

    序 Vue3虽然说是Vue2的升级版 但里面不一样的地方还是挺多的 并且相比Vue2能更好的使用typescript了 先从网上找视频学习 https www bilibili com video BV1gf4y1W783 目录 Vue T
  • Elasticsearch实战(八)--- 词条为中心的 CrossFields 多字段搜索策略

    Elasticsearch实战 词条为中心的Cross Fields 搜索策略 文章目录 Elasticsearch实战 词条为中心的Cross Fields 搜索策略 1 字段中心实现方式及问题 1 1 准备数据 1 2 字段中心的Mos
  • [SQL系列] 从头开始学PostgreSQL 自增 权限和时间

    SQL系列 从头开始学PostgreSQL 事务 锁 子查询 Edward W的博客 CSDN博客https blog csdn net u013379032 article details 131841058上一篇介绍了事务 锁 子查询
  • Linux操作系统之基础命令

    文章目录 一 初识LInux操作系统 Linux操作系统和Windows操作系统的区别 Linux 分为内核版本和发行版本 目录结构命令 二 常用命令 1 ls命令 查看路径下所存在的文件 2 cd命令 切换路径 3 clear 清屏命令
  • 网络工程师工作经验分享

    网络 点击打开链接1 点击打开链接2 点击打开链接3 点击打开链接
  • Docker容器-cgroups资源配置

    目录 Cgroup的概述 使用stress工具测试CPU和内存 CPU周期限制 CPU Core控制 对内存限额 对 Block IO的限制 bps 和iops 的限制 Cgroups如何工作的 cgroup对cpu限制小结 cgroup对
  • 【其他】MacOS Homebrew安装与卸载

    打开terminal 输入 usr bin ruby e curl fsSL https raw githubusercontent com Homebrew install master install 输入sudo密码之后等待一会 之后
  • 电子拼图思维逻辑机的破解思路

    最近孩子去朋友家做客 喜欢上了一个玩具 网上找了好久都没找到 后来问朋友给了个链接 发现是上市不久的益智类游戏玩具 先上个图 开机之后有500关 难度越来越大 在朋友家玩的第3关 小孩子们都拼不上 然后我们家长大孩子们也一起参与 搞了几分钟
  • 前端使用FormData实现上传文件

    场景 用户通过点击图片弹出上传文件的框框 然后选择将要替换的图片 选择后实时预览 点击确定后通过ajax上传到服务器 前端html div div
  • 面试官都在问

    面试官都在问 Linux命令之gdb 0 简述 GDB GNU symbolic debugger 简单地说就是一个调试工具 它是一个受通用公共许可证即GPL保护的自由软件 一般来说 GDB主要帮助你完成下面四个方面的功能 1 启动你的程序
  • Python数据可视化

    Python数据可视化 Python地理区域发展分布热力图 目录 Python数据可视化 Python地理区域发展分布热力图 基本介绍 环境准备 程序设计 参考资料 基本介绍 Python数据可视化 Python地理区域发展分布热力图 环境
  • qtxml生成与解析

    目录 xml生成 xml解析 xml生成 void Qxml setTml QDomDocument doc xml文档树的创建 xml文档树的指令版本必有的 QDomProcessingInstruction pi doc createP
  • LINUX安装nginx详细步骤,部署web前端项目

    1 安装依赖包 一键安装上面四个依赖 yum y install gcc zlib zlib devel pcre devel openssl openssl devel 2 下载并解压安装包 可以去https nginx org down
  • 华为OD机试真题- 跳房子I-2023年OD统一考试(B卷)

    题目描述 跳房子 也叫跳飞机 是一种世界性的儿童游戏 游戏参与者需要分多个回合按顺序跳到第1格直到房子的最后一格 跳房子的过程中 可以向前跳 也可以向后跳 假设房子的总格数是count 小红每回合可能连续跳的步数都放在数组steps中 请问
  • java 多线程提高大数据量的读写效率

    对于多线程来说 刚开始是比较蒙的 不了解其中的运行机制 最近项目中需要用多线程解决一个加载缓慢的问题 特此写了一个例子 供大家参考 如有建议 请多指教 哈哈哈 那么 话不多说 先说下需求 此接口供xxx公司调用 实现对数据库的读取和修改 而
  • 官宣了!Apache ECharts 毕业成为 Apache 软件基金会顶级项目!

    2021 年 1 月 26 日 德克萨斯州威明顿市 Apache 软件基金会 ASF 是 350 多个开源项目和计划的全志愿开发者 管理者和孵化者 今天宣布 Apache ECharts 成为顶级项目 TLP Apache ECharts
  • Python爬虫的urlib的学习(学习于b站尚硅谷)

    目录 一 页面结构的介绍 1 学习目标 2 为什么要了解页面 html 3 html中的标签 仅介绍了含表格 无序列表 有序列表 超链接 4 本节的演示 二 Urllib 1 什么是互联网爬虫 2 爬虫核心 3 爬虫的用途 4 爬虫的分类
  • IDEA如何导出导入配置文件

    导出配置 打开工具 找到 file gt export setting 选择路径即可 导出的是setting jar文件 导入配置 file gt import setttings gt 选则jar文件 gt 一路确认 gt 重启
  • 多线程常见面试题

    常见的锁策略 这里讨论的锁策略 不仅仅局限于 Java 乐观锁 vs 悲观锁 锁冲突 两个线程尝试获取一把锁 一个线程能获取成功 另一个线程阻塞等待 乐观锁 预该场景中 不太会出现锁冲突的情况 后续做的工作会更少 悲观锁 预测该场景 非常容