多线程面试

多线程

线程的基础知识

线程与进程的区别

程序由指令和数据组成,但指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令允许过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理IO的

当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程

  • 进程是正在运行的程序的实例。进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

并行与并发的区别

现在都是多核CPU,在多核CPU下

  • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力,多个线程轮流使用一个或多个CPU
  • 并行(parallel)是同一时间动手做(doing)多件事情的能力,4 核CPU同时执行 4 个线程

举例

  • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
  • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
  • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
单核CPU
  • 单核CPU下程序实力还是串行执行的
  • 操作系统中有一个组件叫做任务调度器,将CPU的时间片(windows下时间片最小约为15毫秒)分给不同的程序使用,只是由于CPU在线程间(时间片很短)的切换非常快,人类感觉是同时执行的
  • 总结为一句话就是:微观串行,宏观并行
  • 一般会将这种线程轮流使用CPU的做法成为并发
多核CPU
  • 每个核(core)都可以调度运行线程,这时候线程可以是并行的

线程创建的方式有哪些

  • 继承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();
 
     }
 }
  • 实现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();
 
     }
 
 }
  • 实现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);
 
     }
 
 }
  • 线程池创建线程(项目中使用方式)
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();
 
     }
 
 }

RunnableCallable的区别

  1. Runnable接口的run方法是没有返回值
  2. Callable接口call方法有返回值,是个泛型,和FutureFutureTask配合可以用来捕获异步执行的结果
  3. Callable接口的call方法允许抛出异常;而Runnable接口的run方法只能在内部消化,不能继续上抛

线程的 run()start() 的区别

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

线程包括那些状态,状态之间是如何变换的

  1. 新建(NEW)
  2. 可执行(RUNNABLE)
  3. 阻塞(BLOCKED)
  4. 等待(WAITING)
  5. 时间等待(TIMED_WAITING)
  6. 死亡(TREMINATED)
public enum State {
         /**
          * 尚未启动的线程的线程状态
          /
         NEW,
 
         /**
          * 可运行线程的线程状态。处于可运行状态的线程正在 Java 虚拟机中执行,但它可能正在等待来自        
          * 操作系统的其他资源,例如处理器。
          /
         RUNNABLE,
 
         /**
          * 线程阻塞等待监视器锁的线程状态。处于阻塞状态的线程正在等待监视器锁进入同步块/方法或在调          
          * 用Object.wait后重新进入同步块/方法。
          /
         BLOCKED,
 
         /**
          * 等待线程的线程状态。由于调用以下方法之一,线程处于等待状态:
          * Object.wait没有超时
          * 没有超时的Thread.join
          * LockSupport.park
          * 处于等待状态的线程正在等待另一个线程执行特定操作。
          * 例如,一个对对象调用Object.wait()的线程正在等待另一个线程对该对象调用Object.notify()         
          * 或Object.notifyAll() 。已调用Thread.join()的线程正在等待指定线程终止。
          /
         WAITING,
 
         /**
          * 具有指定等待时间的等待线程的线程状态。由于以指定的正等待时间调用以下方法之一,线程处于定          * 时等待状态:
          * Thread.sleep
          * Object.wait超时
          * Thread.join超时
          * LockSupport.parkNanos
          * LockSupport.parkUntil
          * </ul>
          /
         TIMED_WAITING,
         
         /**
          * 已终止线程的线程状态。线程已完成执行
          */
         TERMINATED;
     }

线程状态之间变换

  • 创建线程对象是新建状态
  • 调用了 start() 方法转变为可执行状态
  • 线程获取到了CPU的执行权,执行结束是终止状态
  • 在可执行状态的过程中,如果没有获取CPU的执行权,可能会切换到其他状态
    • 如果没有获取锁(synchronized 或 lock)进入阻塞状态,获得锁再切换为可执行状态
    • 如果线程调用了 wait() 方法进入等待状态,其他线程调用 notify() 唤醒后可切换为可执行状态
    • 如果线程调用了 sleep(50) 方法,进入计时等待状态,到时间后可切换为可执行状态

线程怎么按顺序执行

可以使用线程中的 join() 方法,等待线程执行结束

public class JoinTest {
 
     public static void main(String[] args) {
 
         // 创建线程对象
         Thread t1 = new Thread(() -> {
             System.out.println("t1");
         }) ;
 
         Thread t2 = new Thread(() -> {
             try {
                 t1.join();                          // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
             System.out.println("t2");
         }) ;
 
 
         Thread t3 = new Thread(() -> {
             try {
                 t2.join();                              // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
             System.out.println("t3");
         }) ;
 
         // 启动线程
         t1.start();
         t2.start();
         t3.start();
 
     }
 
 }

notify() notifyAll()的区别

  • notifyAll() 唤醒所有 wait 线程
  • notify() 随机唤醒一个 wait 线程

waitsleep的区别

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

停止一个正在运行的线程

  • 使用退出标志,使线程正常退出,也就是当 run 方法完成后线程终止
  • 使用 stop 方法强行终止(不推荐,方法已作废)
  • 使用 interrupt 方法
    • 打断阻塞的线程(sleep,wait,join)的线程,线程会抛出 InterruptedException 异常
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

线程中并发安全

Synchronized关键字的底层原理

  • Synchronized【对象锁】采用互斥的方法让同一时刻最多只有一个线程能持有【对象锁】
  • 它的底层是有 monitor 实现的,monitor 是 JVM 级别的对象(c++)实现,线程获得锁需要使用对象(锁)关联monitor
  • monitor内部由三个属性,分别是ownerentrylistwaitset
  • 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于waiting状态的线程
Monitor实现的锁属于重量级锁,锁升级

synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁之被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况

  1. 重量级锁:
    • 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
  2. 轻量级锁:
    • 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级锁修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性。
  3. 偏向锁:
    • 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令。

一旦锁发生了竞争,都会升级为重量级锁

JMM(Java内存模型)

  • JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而确保指令的正确性
  • JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享内存(主内存)
  • 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存

CAS

  • CAS的全程是:Compart Anderson Swap(比较再交换);它体现的使用乐观锁的思想,在无所状态下保证线程操作数据的原子性
  • CAS使用到的地方很多;AQS框架、AtomicXXX
  • 在操作共享变量的时候使用的自旋锁,效率上更高一些
  • CAS的底层是调用Unsafe类中的方法,都是操作系统提供的,其它语言实现

AQS

全称是AbstractQueuedSunchronizer,即抽象队列同步器,它是构建锁或者其他同步组件的基础框架

synchronizedAQS
关键字,c++语言实现java语言实现
悲观锁,自动释放锁悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差锁竞争激烈的情况下,提供了多种解决方案
  • AQS是多线程中的队列同步器,是一种锁机制,它是作为一个基础框架使用的,像RentrantLockSemaphore都是基于AQS实现的
  • AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程
  • 在AQS内部华友一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相当于获取了资源
  • 在state修改的时候使用的CAS操作,保证多个线程修改的情况下原子性

ReentrantLock 的实现原理

ReentrantLock翻译过来就是可重入锁

  • ReentrantLock标识支持重新进入的锁,调用lock方法获取锁之后,在次调用lock,是不会再阻塞
  • ReentrantLock主要了利用CAS+AQS队列来实现
  • 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁

synchronizedLock 的区别

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

死锁产生的条件

死锁产生的条件
  • 一个线程需要同时获取多个锁,这就容易发生死锁

当程序发现死锁现象,我们可以使用JDK自带的工具:jpsjstack

诊断死锁
  • JConsole:用于输出JVM中运行的进程状态信息。
  • jstack:可以查看Java进程中线程的堆栈信息、日志等,检查是否有死锁现象。如果有死锁,需要通过分析具体的代码来确定原因并进行修复。
  • 可视化工具:如jconsoleVisualVM也可以用来检查死锁问题。

如何进行死锁诊断

volatile

  1. 保证线程间的可见性

    用volatile修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见;

  2. 禁止进行指令重排序

    指令重排:用volatile修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。

ConcurrentHashMap底层原理

  1. 底层数据结构
    • JDK1.7底层采用分段的数组+链表
    • JDK1.8采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑树
  2. 加锁的方式
    • JDK1.7采用Segment分段锁,底层使用的是ReentrantLock
    • JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑树的首届点,相对 分段锁颗粒度更细,性能更好

导致并发程序出现问题的根本原因

  1. 原子性:synchronized、lock
  2. 内存可见性:volatile、synchronized、lock
  3. 有序性:volatile

线程池

线程池的核心参数

  1. corePoolSize 核心线程数目:这是线程池中的核心线程数量,它们在任务到来时会被创建且不会被销毁(除非设置了允许回收)。
  2. maximumPoolSize 最大线程数目 = (核心线程 + 救急线程的最大数目):这是线程池能够容纳的最大线程数,包括核心线程和救急线程。
  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放:当线程池中的线程超过 corePoolSize 时,多余的线程会在 keepAliveTime 时间段内保持活动状态;如果没有新的任务分配给它,它将被终止以节省资源。
  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等:这个参数指定了 keepAliveTime 的单位,可以是秒、毫秒或其他时间单位。
  5. workQueue 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务:这是一个阻塞队列,用于存放等待被执行的任务。当所有核心线程都在忙于处理其他任务时,新来的任务就会被放入 workQueue 中等待。
  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等:这个接口可以用来自定义线程的创建过程,比如为每个线程设置名称或标记其为守护线程。
  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略:当线程池已经达到最大容量,而 workQueue 也已满时,无法再接受新的任务,这时就会调用 handler 实现的拒绝策略来决定如何处理这种情况。常见的拒绝策略包括丢弃任务、抛出异常等。

线程池中的阻塞队列

  1. ArrayBlockingQueue:基于数组结构的有界阻塞队列,先进先出
  2. LinkedBlockingQueue:基于链表结构的有界阻塞队列,先进先出
  3. DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
  4. SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
LinkedBlockingQueueArrayBlockingQueue
默认无界,支持有界强制有界
底层是链表底层是数组
是懒惰的,创建节点的时候添加数据提前初始化Node数组
入队会生成新NodeNode需要是提前创建好的
两把锁(头尾)一把锁

如何确定核心线程数

  • IO密集型任务(核心线程数大小设置为2N+1)

    文件读写、DB读写、网络请求等

  • CPU密集型任务(核心线程数大小设置为N+1)

    计算机代码、Bitmap 转换、Json转换等

线程池的种类有哪些

  1. newFixedThreadPool:创建一个定长线程池,可以控制线程最大并发数,超出的线程会在队列中等待
  2. newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按指定顺序(先进先出)执行
  3. newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
  4. newScheduledThreadPool:可以执行延迟任务的线程池,支持定时及周期性任务执行

为什么不建议用Executors创建线程池

线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

说明:Executors返回的线程池对象的弊端如下:

  1. FixedThreadPoolSingleThreadPool
    • 允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM(内存溢出)。
  2. CachedThreadPool
    • 允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM(内存溢出)。

使用场合

线程池的使用场景

  • 批量导入/同步:使用线程池+CountDownLatch批量把数据库中的数据导入到了ES/Redis中,避免了OOM(内存溢出)
    • 同步订单数据
  • 数据汇总:调用多个接口来汇总数据,如果接口之间没有依赖关系,就可以使用线程池+furure来提升性能
    • 门户主页需要大量汇总数据,可以使用线程池+furure提升性能
  • 异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑),可以使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间
    • 保存操作日志
    • 保存数据日志

如何控制某个方法允许并发访问线程的数量

在多线程中提供了一个工具类Semaphore,信号量。在并发的情况下,可以控制方法的访问量

  • 创建Semaphore对象,可以给一个容量
  • acquire()方法可以请求一个信号量,这时候的信号量个数 -1
  • release()释放一个信号量,此时信号量个数 +1

ThreadLocal底层原理

  1. ThreadLocal可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引起的线程安全问题
  2. ThreadLocal同时实现了线程内的资源共享
  3. 每个线程内有一个ThreadLocalMap类型的成员变量,用来存储资源对象
    1. 调用 set 方法,就是ThreadLoacl自己作为 key,资源对象作为 value,放入当前线程的ThreadLocalMap集合中
    2. 调用 get 方法,就是以ThreadLoacl自己作为 key,到当前线程中查找关联的资源值
    3. 调用 remove 方法,就是以ThreadLocal自己作为 key,移除当前线程关联的资源值
  4. ThreadLocal内存泄漏问题
    • ThreadLocalMap中的 key 是弱引用,值为强引用,key 会被 GC 释放内存,关联 value 的内存并不会释放,建议制动 remove 释放 key,value
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值