多线程常见面试题

常见的锁策略

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值