硅基计划6.0 JavaEE 贰 多线程八股文

1755615271516



一、常见锁策略

以下集中锁策略与编程语言无关,属于是通用的那种

  • 乐观锁&悲观锁:在加锁的时候我们就去预测这把锁的竞争是大还是小,如果是大并且实现原理复杂,那就是悲观锁,反之就是乐观锁
  • 重量级锁&轻量级锁:对于重量级锁来说,加锁这个操作开销大并且它容易触发线程调度,反之就是轻量级锁
  • 自旋锁&挂起等待锁:挂起等待锁是重量级锁的典型实现,当遇到锁冲突的时候,线程会进入阻塞状态,等待未来的某个时间唤醒,由于操作系统内部线程调度是随机的,开销比较大;自旋锁是轻量级锁的典型实现,遇到锁冲突时,线程会先重试获取锁,等到其他线程把锁释放了我们当前线程就可以得到锁了,并不会涉及到线程调度和CPU内核
  • 公平锁&非公平锁:我们把先来先得到锁称为公平锁,反之如果是各凭本事得到就是非公平锁
  • 可重入锁&不可重入锁:一个线程针对同一把锁,连续加锁多次不会发生死锁,就称之为可重入锁,会发生死锁就叫不可重入锁
  • 读写锁&普通互斥锁:读操作的锁叫做读锁,写入操作的锁叫做写锁,读锁与读锁之间并不会冲突,反观读锁和写锁或者是写锁和写锁之间会产生冲突

二、synchronized锁

我们这把锁是根据当前锁竞争程度来自动调整锁策略的,我们感知不到也不能干预,因此它又被称之为智能锁

底层大致实现过程

进入代码块 进入代码块        阻塞线程数量到一定程度
  无锁 --> 偏向锁 --> 自旋锁 --> 重量级锁

我们来说说什么是偏向锁

其实偏向锁并不是真的上锁了,只是去做个标记,开销比较小
如果使用的时候其他线程没有来竞争这把锁,就始终是一种标记状态,一直到锁释放了就解除标记了,这种就是锁消除
反之如果有其他线程竞争这把锁,在这个线程竞争这把锁想要得到这把锁之前,就把这把锁升级,因此这个竞争这把锁的线程只能进入阻塞状态,如果有更多的线程来竞争,就升级为重量级锁

我们之前说过锁的粒度,越大其实锁的粗度也就越大


三、CAS

我们在实现线程安全的时候,是通过加锁去解决的,但是现在,我们有另一种解决思路
CAS全称为compare and swap

将内存中的某个变量当前值与我期望它现在是多少进行比较
如果相等,说明从我读取这个值到现在,没有其他线程修改过它,那我就安全地更新为新值
如果不相等,说明值已经被其他线程改过,那我这次 CAS 操作就失败,可以选择重试(自旋)或放弃

image-20251106000017276

上述我们的一系列操作属于是原子性操作,内部实现了自旋锁

1. 原子类

我们在开发中使用,比如我们上一篇文章最开始的时候,对于int变量count++问题上会产生线程安全问题,因此此时我们使用原子类就可以很大程度上避免这个问题

public class Demo1 {
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 100; i++) {
                count.incrementAndGet();//相当于count++
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 100; i++) {
                count.incrementAndGet();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

除了使用incrementAndGet()或者是getAndIncrement()是变量自增
还可以使用getAndDecrement()或者是decrementAndGet()是变量自减

2. 伪代码解释

private int value;
getAndIncrement(){
    //把内存中的值读取到寄存器中
    int oldValue = value;
    //判断value值和oldValue值是否相同,相同就触发交换,判断为true,循环进不来
    //执行CAS操作,把寄存器value+1位置的值和内存中的值交换
    //反之当其他线程穿插执行的时候,我们先不赋予值
    //把其他线程穿插的值再重新加载到寄存器上,
    while(CAS(value,oldValue,oldValue+1) != true){
        //再次加载值,再进行CAS判断
        oldValue = value;
    }
}

3. 自旋锁

我们之前讲得锁升级的过程,其实synchronized锁内部实现了CAS,其本质是轻量级锁
因此比起阻塞状态等待,我们还不如循环等待,开销就会小很多,下面我写个循环等待的伪代码

private Thread owner;//用于记录是哪个线程持有这把锁
public void lock(){
	//如果我当前锁持有对象是空的,我就把这把锁赋予到当前线程
	while(!CAS(this.owner,null,Thread.currentThread())){
		//如果当前锁对象被另一个对象持有了,我们就进入循环体内部等待
		//直到另一个线程释放了锁,我们再去CAS判断  
	}
}
public void unlock(){
	//解锁就是把持有锁的对象设为空
	this.owner = null;
}

4. CAS中的ABA问题

可能存在一种情况,一个线程把一个变量值从A改到了B,但是另一个线程又把同一个变量值从B改回了A,此时在CAS看来,这个值好像没有改过一样,因此触发ABA问题

我们来举一个转钱的例子

int oldMoney = money;
CAS(money,oldmoney,oldmoney-500)

image-20251106082806378

面对这种问题,本质上是不能让其他线程同时修改,我们可以约定一个版本号,余额可以加减,但是版本号只能加不能减,每次改动余额的时候我们都原子性的把版本号+1,我们在用CAS判断版本号是否被修改过就好

四、JUC常见类

就是在Java.Uitl.concurrent包下常见的关于线程的类

1. callable接口

这个接口类似于runnable,但是它的call方法可以直接去接收线程的返回值

public class Demo2 {
    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 = 0; i < 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        //因为对于Thread类来说无法直接接收Callable类的返回值
        //需要一个类表示Callable的未来会接收到的结果
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        //此时get方法如果我们Callable中没有执行完就进入阻塞状态
        //等到执行完了我们就使用get方法获取值
        System.out.println(futureTask.get());
    }
}

2. Reentrant可重入锁

这个是上古时期的手动加锁,因为那个时候synchronized还没有那么智能,因此那时候普遍使用这个类表示可重入锁,需要自己手动的加锁和解锁

同时也支持trylock,对比lock加锁不成功就进入阻塞等待状态,trylock就是加锁不成功可以主动返回或者是等待指定时间

而且,我们synchronized中的waitnotify只能随机唤醒一个,但是我们Reentrant可以搭配Condition指定唤醒哪个线程

如果我们在new ReentrantLock()参数中填入true,就表示是公平锁
原则:先来先服务,按照线程请求锁的顺序分配
实现:内部维护一个等待队列,新来的线程排队等待

public class Demo3 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock locker = new ReentrantLock();
        //由于我们在lock和unlock之间可能存在throw异常或者是return返回
        //因此我们使用try-finally,在try中上锁,为了保证解锁一定会执行
        //我们在finally中解锁
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 1000; i++) {
                try {
                    locker.lock();
                    count++;
                }finally{
                    locker.unlock();
                }
            } 
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 1000; i++) {
                try {
                    locker.lock();
                    count++;
                }finally {
                    locker.unlock();
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

3. 信号量

类名是Semaphore,是Java对操作系统提供的机制进行了进一步的封装,本质上就是一个计数器,描述了当前系统“可用资源”的个数
最多减少到0,如果此时线程进行资源申请(P操作)就会造成阻塞,如果有其他线程释放了资源(U操作)当前线程就会从阻塞状态重新变成就绪状态

public static void main(String[] args) throws InterruptedException {
        //我们参数决定其初识资源个数
        Semaphore s = new Semaphore(2);
        //进行几次p操作再进行u操作
        s.acquire();
        System.out.println("获取资源");
        s.acquire();
        System.out.println("获取资源");
        s.release();//此时释放了资源,重新进入就绪状态
        System.out.println("添加了资源");
        s.acquire();//到这里因为资源耗尽,陷入阻塞
        System.out.println("再次获取到资源");
    }

我们还可以实现类似于“锁”的效果,我们让资源只有一个,当一个线程在执行的时候,把资源获取
此时资源就是空,其他线程只能阻塞,等到当前线程执行完毕后,再把资源添加回去,让其他线程执行

public static int count = 0;

public static void main(String[] args) throws InterruptedException {
    Semaphore s = new Semaphore(1);
    Thread t1 = new Thread(()->{
        for (int i = 0; i < 500; i++) {
            try {
                s.acquire();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            count++;
            s.release();
        }
    });
    Thread t2 = new Thread(()->{
        for (int i = 0; i < 500; i++) {
            try {
                s.acquire();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            count++;
            s.release();
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count);//1000
}

因此到目前为止,解决线程安全问题我们有了几个策略

  • 避免多线程修改同一个变量
  • 使用synchronized
  • 使用ReentrantLock
  • 使用Semaphore信号量中资源值特性
  • CAS或使用原子类

4. CountDownLatch

比如我们要执行一个大任务,我们可以把大任务分成小任务,然后让每一个线程去执行这个小任务
只有当所有线程执行完自己小任务之后,这个大任务才算完成

public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
        //参数表示任务数量,我们把主线程的大任务拆分成小任务
        CountDownLatch c = new CountDownLatch(5);
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(()->{
                //假设我们每个任务都要执行两秒钟
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //两秒后完成任务,并且向c提交
                c.countDown();
            });
            t.start();
        }
        //我们主线程要等所有线程的小任务执行完毕
        //可以使用join,我们使用CountDownLatch中的方法
        c.await();
    }
}

这种我们的场景就是多线程下载内容,把一个大内容拆分成许多个小内容,我们再让子线程分别去执行一个小内容,当所有子线程内容都下载完毕后,主线程把所有内容进行整合即可

5. 线程安全集合类

我们很多类型都是线程不安全的,天生线程安全的有Vector,HashTable,Stack,String等待
但是对于线程不安全的类我们可以通过几种方式使其线程安全

  1. 添加synchroinzed关键字,之前讲过
  2. 通过Collections.synchroinzed(new ArrayList),但是不常用

接下来就是重量级嘉宾,使用copyOnWriteArrayList,即写时拷贝
在对一个变量进行修改的时候,我们先复制一份,然后在这个复制后的副本内修改值,再把修改后的副本值覆盖原来的值,避免了那种修改了但是只修改了一半的情况,即脏数据

6. 多线程哈希表

我们之前说过HashMap线程不安全,虽然HashTable天生线程安全,但是它的加锁策略有很大问题

image-20251106111319911

因此我们使用concurrentHashMap就可以避免这种情况,它是有很多把锁,针对每一个下标(即每一个下标的链表头节点)进行加锁
这样就可以避免上述那种情况,并且对每个链表头节点加锁开销不是很大,我们把这种结构称之为锁通,因此这个类又叫做哈希桶
而且对于哈希表中的size表示的键值对总数,我们使用CAS让其线程安全,避免了加锁


如果后续我们想扩容concurrentHashMap不会一次性把所有数据都拷贝到新的哈希数组
因为如果哈希数组很大的话并且每个链表长度比较长,一次性复制开销会非常大,因此concurrentHashMap同时维护新数组和旧数组,分成几个部分进行拷贝


文章可能有错误欢迎指出,这些八股文面试中经常考到


END♪٩(´ω`)و♪`
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值