• 多线程面试相关知识点


    (一) 进程线程和协程的区别

    程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
    当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

    一个进程之内可以分为一到多个线程。
    一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行。
    Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows中进程是不活动的,只是作为线程的容器

    image.png

    协程:
    协程,英文Coroutines,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。协程与线程主要区别是它将不再被内核调度,而是交给了程序自己而线程是将自己交给内核调度,所以也不难理解golang中调度器的存在。

    定义:协程是轻量级线程
    在一个用户线程上可以跑多个协程,这样就提高了单核的利用率。协程不像进程或者线程,可以让系统负责相关的调度工作,协程是处于一个线程中,系统是无感知的,所以需要在该线程中阻塞某个协程的话,就需要手工进行调度。

    总结:
    多进程的出现是为了提升CPU的利用率,特别是I/O密集型运算,不管是多核还是单核,开多个进程必然能有效提升CPU的利用率。而多线程则可以共享同一进程地址空间上的资源,为了降低线程创建和销毁的开销,又出现了线程池的概念,最后,为了提升用户线程的最大利用效率,又提出了协程的概念。

    创建线程的4种方式

    共有四种方式可以创建线程,分别是:继承Thread类、实现runnable接口、实现Callable接口,线程池创建线程。

    1. 继承Thread类

    public class MyThread extends Thread {
    @Override
    public void run() {
    System.out.println("MyThread...run...");
    }
    public static void main(String[] args) {
    // 创建MyThread对象
    MyThread t1 = new MyThread() ;
    MyThread t2 = new MyThread() ;
    // 调用start方法启动线程
    t1.start();
    t2.start();
    }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    2. 实现runnable接口

    public class MyRunnable implements Runnable{
    @Override
    public void run() {
    System.out.println("MyRunnable...run...");
    }
    public static void main(String[] args) {
    // 创建MyRunnable对象
    MyRunnable mr = new MyRunnable() ;
    // 创建Thread对象
    Thread t1 = new Thread(mr) ;
    Thread t2 = new Thread(mr) ;
    // 调用start方法启动线程
    t1.start();
    t2.start();
    }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    3. 实现Callable接口

    public class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
    System.out.println("MyCallable...call...");
    return "OK";
    ④ 线程池创建线程
    }
    public static void main(String[] args) throws
    ExecutionException, InterruptedException {
    // 创建MyCallable对象
    MyCallable mc = new MyCallable() ;
    // 创建F
    FutureTask<String> ft = new FutureTask<String>(mc) ;
    // 创建Thread对象
    Thread t1 = new Thread(ft) ;
    Thread t2 = new Thread(ft) ;
    // 调用start方法启动线程
    t1.start();
    // 调用ft的get方法获取执行结果
    String result = ft.get();
    // 输出
    System.out.println(result);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    4. 线程池创建

    public class MyExecutors implements Runnable{
    @Override
    public void run() {
    System.out.println("MyRunnable...run...");
    }
    public static void main(String[] args) {
    // 创建线程池对象
    
    ExecutorService threadPool =Executors.newFixedThreadPool(3);
    threadPool.submit(new MyExecutors()) ;
    // 关闭线程池
    threadPool.shutdown();
    }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    runnable 和 callable 有什么区别

    1. Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
    2. Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
    3. Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛

    线程的 run()和 start()有什么区别?

    start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

    run(): 封装了要被线程执行的代码,可以被调用多次。

    线程之间的状态变化

    image.png

    notify()和 notifyAll()有什么区别?

    notifyAll:唤醒所有wait的线程
    notify:只随机唤醒一个 wait 线程

    java 中 wait 和 sleep 方法的不同?

    共同点

    • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
      不同点
    • 方法归属不同
      sleep(long) 是 Thread 的静态方法
      而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
    • 醒来时机不同
      执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
      wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
      它们都可以被打断唤醒
    • 锁特性不同(重点)
      wati方法的调用必须先获取wait对象的锁,而sleep无此限制
      wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃cpu,但你们还可以用)
      而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁

    (二) 线程中并发锁

    1)synchronized

    synchronized关键字的底层原理?

    Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程再想获取这个对象锁时,就会阻塞住。

    Synchronized的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象锁关联monitor。

    在使用了synchornized代码块时需要指定一个对象,所以synchornized也被称为对象锁
    monitor主要就是跟这个对象产生关联,如下图

    image.png

    Monitor内部具体的存储结构:

    • Owner:存储当前获取锁的线程的,只能有一个线程可以获取
    • EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
    • WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程
      具体的流程:
    • 代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否有线程持有
    • 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
    • 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)
    • 如果代码块中调用了wait方法,则会进去waitSet中进行等待

    synchronized关键字的底层原理-进阶

    Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
    在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

    对象的内存结构:
    image.png

    MarkWord

    image.png

    2) CAS

    CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
    在JUC( java.util.concurrent )包下实现的很多类都用到了CAS,

    • AbstractQueuedSynchronizer
    • AtomicXXX

    image.png
    一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,则通过自旋的方式等待并再次尝试,知道成功。

    CAS 底层实现
    CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令

    image.png

    ReentrantLock中的一段CAS代码
    image.png

    乐观锁和悲观锁

    • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
    • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上来锁你们都别想改,我改完了解开锁,你们才有机会

    3) volatile

    一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那
    么就具备了两层语义:

    • 保证线程间的可见性
    • 禁止进行指令重排序

    4) AQS

    全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架,它是构建锁和其他同步组件的基础框架

    image.png

    AQS常见的实现类

    • ReentrantLock 阻塞式锁
    • Semaphore 信号量
    • CountDownLatch 倒计时锁

    工作机制:

    image.png

    • 线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
    • 线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待,
    • FIFO是一个双向队列,head属性表示头结点,tail表示尾结点

    5) ReentranLock

    ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:

    • 可中断
    • 可以设置超时时间
    • 可以设置公平锁
    • 支持多个条件变量
    • 与synchronized一样,都支持重入

    实现原理
    ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

    构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
    查看ReentrantLock源码中的构造方法:
    提供了两个构造方法,不带参数的默认为非公平

    image.png
    而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的。

    工作流程:
    image.png

    6) synchronized和Lock的区别

    • 语法层面
      • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
      • Lock 是接口,源码由 jdk 提供,用 java 语言实现
      • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
    • 功能层面
      • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
      • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
      • Lock 有适合不同场景的实现,如 ReentrantLock,ReentrantReadWriteLock
    • 性能层面
      • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
      • 在竞争激烈时,Lock 的实现通常会提供更好的性能

    7) ConcurrentHashMap

    ConcurrentHashMap 是一种线程安全的高效Map集合
    底层数据结构:
    JDK1.7底层采用分段的数组+链表实现
    JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

    image.png

    存储流程
    image.png

    先去计算key的hash值,然后确定segment数组下标
    再通过hash值确定hashEntry数组中的下标存储数据
    在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行
    操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁是被会使
    用cas自旋锁进行尝试

    (2) JDK1.8中concurrentHashMap

    image.png
    在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表
    采用 CAS + Synchronized来保证并发安全进行实现CAS控制数组节点的添加
    synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发问题,效率得到提升

    8) 导致并发出现的根本原因

    1)原子性

    一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行

    解决方案:
    1.synchronized:同步加锁
    2.JUC里面的lock:加锁

    2)内存可见性

    内存可见性:让一个线程对共享变量的修改对另一个线程可见

    解决方案:
    synchronized
    volatile
    LOCK

    3)有序性

    指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

    解决方案:
    volatile

    (三) 线程池

    1)线程池的核心参数

    image.png

    • corePoolSize 核心线程数目
    • maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
    • keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
    • unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
    • workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
    • threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
    • handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

    工作流程:
    image.png

    拒绝策略:
    1.AbortPolicy:直接抛出异常,默认策略;
    2.CallerRunsPolicy:用调用者所在的线程来执行任务;
    3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    4.DiscardPolicy :直接丢弃任务

    线程池中有哪些常见的阻塞队列

    workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
    比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue
    1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
    2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
    3.DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
    4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

    2)如何确定核心线程数

    在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型

    • IO密集型任务
      一般来说:文件读写、DB读写、网络请求等
      推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)
    • CPU密集型任务
      一般来说:计算型代码、Bitmap转换、Gson转换等
      推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)

    查看CPU核数:
    image.png

    3) 线程池的种类

    1. 创建使用固定线程数的线程池
      image.png

    不建议用Executors创建线程池

    image.png

    (四) 对ThreadLocal的理解

    ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享

    案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。

    image.png

    ThreadLocal基本使用

    三个主要方法:

    • set(value) 设置值
    • get() 获取值
    • remove() 清除值

    ThreadLocal的实现原理&源码解析

    ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离

    image.png
    在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap。ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置

    Set方法:
    image.png

    get方法和Remove方法
    image.png

    在使用ThreadLocal的时候,强烈建议:务必手动remove

  • 相关阅读:
    EMNLP 2023 | DeepMind提出大模型In-Context Learning的可解释理论框架
    初识面向对象上
    电脑大文件删除了能恢复吗 电脑大文件删除了怎么恢复
    centos7.8手动部署php环境 01 nginx1.8.0编译安装
    sci论文写法
    基础架构之Redis
    Linux综合使用练习
    QT 中 QFileDialog::getOpenFileName 获取到文件路径,并打开这个文件。
    Spark 之ExecutorLostFailure in Apache Spark
    0基础学习VR全景平台篇第118篇:利用动作录制器功能避免重复操作 - PS教程
  • 原文地址:https://blog.csdn.net/qq_40905284/article/details/134089472