多线程面试题

临界资源

临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。属于临界资源的硬件有,打印机,磁带机等;软件有消息队列,变量,数组,缓冲区等。诸进程间采取互斥方式,实现对这种资源的共享。

临界区:

每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。

线程和进程的区别?⭐

  • 进程是系统资源分配的最小单位,线程是程序执行的最小单位
  • 进程使用独立的数据空间,而线程共享线程的数据空间
  • 进程的切换效率比线程低
  • 通信方式不同

进程间通信方式

  1. 无名管道:半双工的,即数据只能在一个方向上流动,只能用于具有亲缘关系的进程之间的通信,可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
  2. FIFO命名管道:FIFO是一种文件类型,可以在无关的进程之间交换数据,与无名管道不同,FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。
  3. 消息队列:消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。
  4. 信号量:信号量是一个计数器,信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
  5. 共享内存:共享内存指两个或多个进程共享一个给定的存储区,一般配合信号量使用。

TODO:简单介绍进程的切换过程

主要考察线程上下文的切换代价,要回答切换会保持寄存器、栈等线程相关的现场,需要由用户态切换到内核态,最后知道可以通过vmstate命令查看上下文的切换状况

线程通信方式

  • volatile 关键字: 使用共享内存的思想,多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
  • 使用 Object 类的wait() 和 notify() 方法
  • 使用 ReentrantLock 结合 Condition的 await() 和 signal() 方法
  • 信号量 Semaphore,可以控制对共享资源的并发访问度,有 accquire() 和 release() 方法
  • CountDownLatch:控制线程等待,计数器功能,可以用来等待多个线程执行任务后进行汇总
  • CyclicBarrier:类似CountDownLatch但更强大,可以重复使用,控制多个线程,一般测试使用
  • 基本LockSupport实现线程间的阻塞和唤醒

死锁的4个必要条件⭐

  1. 互斥条件:一个资源每次只能被一个线程使用;
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
  3. 不剥夺条件:进程已经获得的资源,在未使用完之前,不能强行剥夺;
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何分析是否有线程死锁? ⭐

使用jconsole图形化工具直接检查死锁

使用jstack命令行分析线程Dump信息

理解线程的同步与异步、阻塞与非阻塞

同步与异步的区别是任务是否在同一个线程中执行的 ,阻塞与非阻塞的区别是异步执行任务时线程是不是会阻塞等待结果还是会继续等待后面的逻辑

创建线程有哪几种方式,如何实现?

①. 继承Thread类创建线程类

  • 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
  • 创建Thread子类的实例,即创建了线程对象。
  • 调用线程对象的start()方法来启动该线程。

②. 通过Runnable接口创建线程类

  • 定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
  • 创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
  • 调用线程对象的start()方法来启动该线程。

③. 通过Callable和Future创建线程

  • 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
  • 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
  • 使用FutureTask对象作为Thread对象的target创建并启动新线程。
  • 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

④. 通过线程池创建线程

  • 调用Executors.newFixedThreadPool方法创建线程池。
  • Runnable的匿名内部类创建线程。
  • 结束要调用shutdown关闭线程池。

runnable 和 callable 有什么区别

  • Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;
  • Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。

sleep和wait的区别⭐

本质区别
Thread.sleep只会让出CPU ,不会导致锁行为的改变
Object.wait不仅让出CPU , 还会释放已经占有的同步资源锁

  • wait属于Object类,sleep属于Thread类
  • wait会释放对象锁,而sleep不会
  • wait需要在同步块中使用,sleep可以在任何地方使用
  • sleep需要捕获异常、wait不需要

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

锁池EntiyList:当一个线程需要调用调用此方法时必须获得该对象的锁,而该对象的锁被其他线程占用,该线程就需要在一个地方等待锁释放,这个地方就是锁池。(准备抢锁的池子)
等待池WaitSet:调用了wait方法的线程会释放锁并进入等待池,在等待池的线程不会竞争锁。(休息的池子)

notifyAll会让所有处于等待池的线程全部进入锁池去竞争获取锁的机会

notify只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会。

线程的 run()和 start()有什么区别?/为什么不能直接调用 run() 方法?

每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。

start()方法来启动一个线程,这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码,真正实现了多线程运行。 此时start()方法启动的线程是处于就绪状态, 但并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, run方法运行结束, 此线程终止。然后CPU再调度其它线程。

run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接调用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

线程的生命周期和状态

  • 新建(New) :创建后尚未启动的线程的状态

  • 可运行(Runnable):就绪和运行两种状态统称为运行中

  • 阻塞(Blocked):等待获取排它锁

  • 无限期等待(Waiting): 阻塞和等待的区别在于,阻塞是被动的,而等待是主动的,不会被分配CPU执行时间,需要显式被唤醒

进入方法 退出方法
没有设置 Timeout 参数的 Object.wait() 方法 Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法 被调用的线程执行完毕
LockSupport.park() 方法 LockSupport.unpark(Thread)
  • 限期等待(Timed Waiting):在一定时间后会由系统自动唤醒
进入方法 退出方法
Thread.sleep() 方法 时间结束
设置了 Timeout 参数的 Object.wait() 方法 时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法 时间结束 / 被调用的线程执行完毕
LockSupport.parkNanos() 方法 LockSupport.unpark(Thread)
LockSupport.parkUntil() 方法 LockSupport.unpark(Thread)

调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。

  • 结束(Terminated):已终止线程的状态,线程已经结束执行

线程的各种状态的切换(重要)⭐


  1. 得到一个线程类,new出一个实例线程就进入new状态(新建状态)。
  2. 调用start方法就进入Runnable(可运行状态)
  3. 如果此状态被操作系统选中并获得时间片就进入Running状态
  4. 如果Running状态的线程的时间片用完或者调用yield方法就可能回到Runnable状态
  5. 处于Running状态的线程如果在进入同步代码块/方法就会进入Blocked状态(阻塞状态),锁被其它线程占有,这个时候被操作系统挂起。得到锁后会回到Running状态。
  6. 处于Running状态的线程如果调用了wait/join/LockSupport.park()就会进入等待池(无限期等待状态), 如果没有被唤醒或等待的线程没有结束,那么将一直等待。
  7. 处于Running状态的线程如果调用了sleep(睡眠时间)/wait(等待时间)/join(等待时间)/ LockSupport.parkNanos(等待时间)/LockSupport.parkUntil(等待时间)方法之后进入限时等待状态,等待时间结束后自动回到原来的状态。
  8. 处于Running状态的线程方法执行完毕或者异常退出就会进入死亡状态。

有哪几种实现生产者消费者模式的方法?

锁、信号量、线程通信、阻塞队列。

什么是上下文切换?⭐

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式(程序计数器)。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

创建线程池ThreadPoolExecutor有哪几种方式?

①. newFixedThreadPool(int nThreads)
创建一个固定线程数量的线程池,每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化,新的任务会暂存在任务队列中,待有线程空闲时便处理任务。

②. newCachedThreadPool()
创建一个可缓存的线程池,如果线程池的规模超过了处理需求,将自动回收空闲线程,而当需求增加时,则可以自动添加新线程,线程池的规模不存在任何限制。

③. newSingleThreadExecutor()
这是一个单线程的Executor,它的特点是能确保依照任务在队列中的顺序来串行执行,适用于保证异步执行顺序的场景。

④. newScheduledThreadPool(int corePoolSize)(推荐)
创建了一个固定长度的线程池,以定时的方式来执行任务,适用于定期执行任务的场景。

⑤.newWorkStealingPool
使用ForkJoinPool ,多任务队列的固定并行度,适合任务执行时长不均匀的场景

前四个都是使用 ThreadPoolExecutor() 的不同初始化参数创建的。

场景:大量短期的任务场景适合使用 Cached 线程池,系统资源比较紧张时使用固定线程池。慎用无界队列,有OOM风险。自己项目有一个可能高吞吐量的场景就使用了 Cached 线程池

线程池都有哪些状态

RUNNING :能接受新提交的任务,并且也能处理阻塞队列中的任务
SHUTDOWN :不再接受新提交的任务,但可以处理存量任务
STOP :不再接受新提交的任务,也不处理存量任务
TIDYING :所有的任务都已终止
TERMINATED : 结束方法terminated()执行完后进入该状态

线程池核心参数⭐

  • corePoolSize:线程池里的线程数量,核心线程池大小
  • maxPoolSize:线程池里的最大线程数量
  • workQueue: 任务队列,用于存放提交但是尚未被执行的任务。
  • keepAliveTime:当线程池中的线程数量大于 corePoolSize 时,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁; 参数的时间单位为 unit。
  • 阻塞队列种类
  • threadFactory:线程工厂,用于创建线程,一般可以用默认的
  • handler:拒绝策略

线程池的拒绝策略⭐

  1. ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
  2. ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
  3. ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
  4. ThreadPoolExecutor.CallerRunsPolicy:由提交任务的线程直接处理该任务

如何向线程池提交任务

有2种:分别使用execute 方法和 submit 方法

执行execute()方法和submit()方法的区别是什么呢?

  1. execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
  2. submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以了解任务执行情况,并且可以通过 Future 的 get() 方法来获取返回值,还可以取消任务执行。底层也是通过 execute() 执行的。

线程池常用的阻塞队列?

  • ArrayBlockingQueue:基于数组实现的一个单端阻塞队列,只能从队尾出队。
  • LinkedBlockingQueue:基于链表实现的一个双端阻塞队列,分别从队头队尾操作入队出队。
  • PriorityBlockingQueue:以上2种队列都是先进先出队列,而PriorityBlockingQueue却不是,它会按照元素的优先级对元素进行排序,按照优先级顺序出队,每次出队的元素都是优先级最高的元素。注意,此阻塞队列为无界阻塞队列,即容量没有上限(通过源码就可以知道,它没有容器满的信号标志),前面2种都是有界队列。
  • DelayQueue:基于PriorityQueue,一种延时阻塞队列,DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue也是一个无界队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。

怎么保证多线程的运行安全/保证线程安全的方法⭐

前提是保证下面三个方面:

原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized);

可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized,volatile);

有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before原则)。

可以使用 CAS、Synchronized、Lock、ThreadLocal 来实现。

如何尽可能提高多线程并发性能?

尽量减少临界区范围、使用ThreadLocal、减少线程切换、使用读写锁或CopyOnWrite机制

为什么要使用线程池⭐

  1. 减少创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
  2. 可以根据系统的承受能力,调整线程池中工作线程的数目,放置因为消耗过多的内存,而把服务器累趴下

线程池满了,往线程池里提交任务会发生什么样的情况,具体分几种情况

  • 如果你使用的LinkedBlockingQueue(阻塞队列),也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务;如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了,则会使用拒绝策略RejectedExecutionHandler处理满了的任务,默认是AbortPolicy
  • 线程池的饱和策略:当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略

向线程池提交一个线程的原理/步骤⭐

也叫做ThreadPoolexecutor工作流程

  • 先判断核心线程池是否已满
    • 如果没有满就创建线程
    • 如果满了就判断等待队列是否已满
      • 如果没满就加入等待队列
      • 如果满了就判断最大线程池是否已满
        • 没有满就提交给线程池
        • 满了就执行拒绝策略

如何指定多个线程的执行顺序/ 如何控制线程池线程的优先级 ⭐

  1. 设定一个 orderNum,每个线程执行结束之后,更新 orderNum,指明下一个要执行的线程。并且唤醒所有的等待线程。
  2. 在每一个线程的开始,要 while 判断 orderNum 是否等于自己的要求值,不是,则 wait,是则执行本线程。

线程池的线程数量怎么确定

  1. 一般来说,如果是CPU密集型应用,则线程池大小设置为N+1。
  2. 一般来说,如果是IO密集型应用,则线程池大小设置为2N+1。
  3. 在IO优化中,线程等待时间所占比例越高,需要越多线程,线程CPU时间所占比例越高,需要越少线程。这样的估算公式可能更适合:最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

常用的线程分析工具与方法

jstack 分析线程的运行状态,查看锁对象的持有状况。

jconsole:JDK自带的图形化界面工具

volatile作用⭐

在多线程开发中保证了共享变量的“ 可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

volatile 告诉编译器它修饰的变量会不断的被修改,编译器就会通过强制主内存读写同步,防止指令重排序来保证原子性,可见性和有序性。但不能代替锁,不能保证i++这种复合操作的原子性。

Java 中是如何实现线程同步的?

1.同步方法 synchronized 关键字修饰的方法(悲观锁)

2.使用特殊域变量(volatile)实现线程同步(保持可见性,多线程更新某一个值时,比如说线程安全单例双检查锁)

3.ThreadLocal(每个线程获取的都是该变量的副本)

4.使用重入锁实现线程同步(相对 synchronized 锁粒度更细了,效率高)

​ 一个java.util.concurrent 包来支持同步。

​ ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁

​ ReentrantLock() : 创建一个 ReentrantLock 实例

​ lock() : 获得锁

​ unlock() : 释放锁

5.java.util.concurrent.atomic 包 (乐观锁)

​ 方便程序员在多线程环境下,无锁的进行原子操作

i++是线程安全的吗?

分2种情况

  1. 局部变量肯定是线程安全的(原因:方法内局部变量是线程私有的)
  2. 成员变量多个线程共享时,就不是线程安全的(原因:成员变量是线程共享的,因为 i++ 是三步操作。

介绍 Synchronized

保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

三种使用方式:

synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。

Synchronized 的实现原理

对象在内存中分为对象头,实例数据和对齐填充三个区域。在对象头中保存了锁标志位和指向 Monitor 对象的起始地址。当 Monitor 被某个线程占用后就会处于锁定状态。 synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 synchronized 修饰的方法使用是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

JDK1.6 之后的synchronized 关键字底层做了一些优化

偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化,详见 JVM 篇。

Synchronized与Lock的区别⭐

  • synchronized是java内置关键字在jvm层面,Lock是个java类。
  • synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁,并且可以主动尝试去获取锁。
  • synchronized会自动释放锁,Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁。
  • ReentrantLock更加灵活,提供了超时获取锁,可中断锁,在获取不到锁的情况会自己结束,而synchronized不可以
  • synchronized的锁不可重入、不可中断、非公平,而Lock锁可重入、可判断、可公平(两者皆可)

乐观锁和悲观锁的区别?

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量

如何实现一个乐观锁?

在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加一。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

ReentrantLock如何实现公平和非公平锁

公平锁需要系统维护一个有序队列,获取锁时会判断阻塞队列里是否有线程再等待,若有获取锁就会失败,并且会加入阻塞队列。

非公平锁获取锁时不会判断阻塞队列是否有线程再等待,所以对于已经在等待的线程来说是不公平的,但如果是因为其它原因没有竞争到锁,它也会加入阻塞队列。

进入阻塞队列的线程,竞争锁时都是公平的,因为队列为先进先出(FIFO)。

JUC工具类

Atomic类

所谓原子类说简单点就是具有原子/原子操作特征的类。

基本数据类型的原子类

  • AtomicLong/AtomicInteger/AtomicBoolean:通过底层工具类 unsafe 类实现,基于 CAS。unsafe 类提供了类似 C 的指针操作,都是本地方法。
  • LongAdder/LongAccumulator:基于 Cell 实现,基于分段锁思想,是一种以空间换时间的策略,适合高并发场景。
  • AtomicReference:引用类型原子类,用于原子性对象的读写。
  • AtomicStampedReference/AtomicMarkableReference:解决 ABA 问题的类

Atomic类如何保证原子性⭐

​ CAS,Compare and Swap即比较并交换。主要利用 CAS (compare and swap) + volatile 和 unsafe 类的 底层 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 可能会导致什么问题?

ABA问题,就是在写入时读取到的数据是和预期的一样的 A,但是这个 A 可能已经被其他线程修改成 B 再修改回来了。解决办法:增加额外的标志位或时间戳。

AQS

AQS的全称为(AbstractQueuedSynchronizer),在java.util.concurrent.locks包下面。

AQS定义两种资源共享方式

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:

    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

异步工具类:

Executors
CompletableFuture:支持流式调用,多future组合,可以设置完成时间
FutureTask
ForkJoinPool :分治思想+工作窃取

ThreadLocal 实现原理

每个线程独享的局部变量,ThreadLocal使用弱引用 ThreadLocalMap 保存弱引用的局部变量。使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。

ThreadLocal用来解决什么问题?

ThreadLocak不是用来解决多线程共享变量的问题,而是线程数据隔离的问题