跳到主要内容

多线程面试题

1. 如何预防死锁

  • 死锁发生的必要条件
    1. 互斥条件:同一时间只能有一个线程获取资源
    2. 不可剥夺条件:一个线程已经占有的资源,在释放之前不会被其他线程抢占
    3. 请求和保持条件:线程等待过程中不会释放已占有的资源
    4. 循环等待条件:多个线程互相等待对方释放资源
  • 预防死锁
    预防死锁,则需要破坏四个必要条件
    1. 资源互斥是资源使用的固有特性,无法改变
    2. 破坏不可剥夺条件
      一个进程不能获得所需要的全部资源时便处于等待状态,等待期间它占有的资源将被隐式的释放并重新加入到系统的资源列表中,可以被其他进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动、执行
    3. 破坏请求和保持条件
      1. 静态分配:每个进程在开始执行时就申请它所需要的全部资源
      2. 动态分配:每个进程在申请所需要的资源时它本身就不占用系统资源
    4. 破坏循环等待条件
      资源有序分配,基本思想是将系统中所有资源顺序编号,将紧缺、稀少的资源采用较大编号,在申请资源时必须按照编号顺序进行,一个线程只有获得较小编号的进程才能申请较大编号的进程

2. 多线程的创建方式

  1. 实现 Runnable,Runnable 规定的方法是 run(),无返回值,无法抛出异常
  2. 实现 Callable,Callable 规定的方法是 call(),任务执行后有返回值,可以抛出异常
  3. 继承 Thread 类创建多线程
    继承 java.lang.Thread 类,重写 Thread 类的 run() 方法,在 run() 方法中实现运行在线程上的代码,调用 start() 方法开启线程
    Thread 类本质上是实现了 Runnable 接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过 Thread 类的 start() 实例方法。start() 方法是一个 native 方法,它将启动一个新线程并执行 run() 方法
  4. 通过线程池创建线程
    线程和数据库连接都是非常宝贵的资源,每次需要的时候创建,使用完销毁非常浪费资源。可以采用缓存的策略,即线程池

3. 描述一下线程安全活跃态问题和竞态条件

  • 线程安全的活跃态问题可以分为死锁、活锁、饥饿
    1. 活锁
      有时线程中虽然没有发生阻塞,但是仍然会存在执行不下去的情况,活锁不会阻塞线程,线程会一直重复执行某个相同的操作,并且会一直失败重试
      比如异步消息队列,在消息队列的消费端如果没有正确的 ack 消息,并且执行过程中报错了,就会再次放回队列头,然后拿出来执行,一直循环往复的失败,这个问题除了正确的 ack 之外,往往都是通过将失败的消息放入延时队列中,等待一定的时间再进行重试来解决
      解决方案:尝试等待一个随机的时间即可,或者按照时间轮重试
    2. 饥饿
      线程无法访问所需资源而无法执行下去的情况
      饥饿的两种情况
      1. 有线程在临界区做了无限循环或者无限制等待资源的操作,让其他的线程一直不能拿到锁进入临界区,对于其他线程来说,就进入了饥饿状态
      2. 因为线程优先级分配不合理,导致部分线程始终无法获取到 CPU 资源而一直无法执行
        解决方案
      3. 保证资源充足,很多场景下,资源的稀缺性无法保证
      4. 公平分配资源,在并发编程中使用公平锁,例如 FIFO 策略,线程等待是有顺序的,排在等待队列面前的线程会优先获得资源
      5. 避免持有锁的线程长时间执行,很多场景下,持有锁的线程的执行时间很难缩短
    3. 死锁
      线程在对同一把锁进行竞争的时候,未抢占到锁的线程会等待持有锁的线程释放锁后继续抢占,如果有两个或两个以上的线程互相持有对方将要抢占的锁,互相等待对方先行释放锁。就会进入到一个循环等待的过程,这个过程就叫死锁
  • 竞态条件
    当某个计算的正确性取决于多个线程的交替执行次序时,竞态条件就会发生。通俗地讲:程序的正确性取决于运气
    最常见的竞态条件发生在 先检查后执行(Check-Then-Act) 类型的代码中,因为线程可能会基于已经失效的观测结果执行下一步动作
    • 示例:延迟初始化
      先检查后执行的一种典型场景就是延迟初始化。延迟初始化的本意是让创建成本高昂的对象被推迟到只有在真正需要时才会被加载
      class LazyInitRace {
      // 实际上,这个 Object 可能是一个创建代价昂贵的类型。
      private Object instance = null;
      public Object getInstance(){
      // if() 是一个明显的观测动作
      if(instance == null) instance = new Object();
      return instance;
      }
      }
      线程 A 在访问 getInstance() 方法时,会率先观察 instance 是否为 null,然后决定是初始化,还是直接返回引用,另一个到达的线程 B 要做相同的检查。但现在,他所观察到 instance 实际是否为空取决于不可预测的时序,还包括线程 A 需要花费多长时间来初始化 instance。如果线程 B 无意中做出了误判,那么整个流程会错误的创建出两个 instance 实例

4. wait 和 sleep 之间的区别和联系

  • 所属类 wait 和 sleep 分来来自 Thread 和 Object
    • sleep 方法属于 Thread 类中的方法,表示让一个线程进入睡眠状态,等待一定的时间之后,自动醒来进入到可运行状态,不会马上进入运行状态,因为线程调度机制恢复线程的运行也需要时间,一个线程对象调用了 sleep 方法之后并不会释放所持有的所有对象锁,因此也就不会影响其他进程对象的运行。但在 sleep 的过程中有可能被其他的对象调用它的 interrupt(),产生 interruptException 异常,如果程序不捕获这个异常,线程就会异常终止,进入 TERMINATED 状态,如果程序捕获了这个异常,那么程序就会继续执行 catch 语句块(可能还存在 finally 语句块)以及后面的代码块
    • 调用 wait 方法时,线程会马上释放对象锁,进入等待此对象的等待锁定池,只有针对此对象调用 notify() 方法后本线程才进入对象锁定池处于准备状态
  • 作用范围
    sleep 方法没有释放锁,只是休眠
    wait 释放了锁,使得其他线程可以使用同步控制块或者方法
  • 使用范围
    wait、notify、notifyALl 只能在同步控制方法或者同步控制块里面使用
    sleep 可以在任何地方使用
  • 异常范围
    sleep 必须捕获异常,wait、notify、notifyALl 不需要捕获异常

5. 进程和线程

6. Java 线程的生命周期

  • 大致包括五个阶段
    Java 线程的生命周期

    1. 新建:刚使用 new 方法 new 出来的线程
    2. 就绪:调用了线程的 start() 方法后,此时线程处于等待 CPU 分配资源的阶段,谁抢到 CPU 资源,谁就开始执行
    3. 运行:当就绪的线程被调度并获得 CPU 资源时,便进入运行状态,run 方法定义了线程的操作和功能
    4. 阻塞:在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如 sleep()、wait() 之后线程就处于了阻塞状态,这个时候需要其他线程将处于阻塞状态的线程唤醒,比如调用 notify() 或者 notifyAll() 方法。唤醒的线程并不会立即执行 run 方法,它们需要再次等待 CPU 分配资源进入运行状态
    5. 销毁:如果线程正常执行完毕、线程被提前强制性的终止,或者出现异常导致结束,那么线程就要被销毁,释放资源
  • 从 JDK 源码的角度分析,Thread 的状态分为以下几种
    Thread 的状态

    1. NEW:尚未启动的线程的线程状态
    2. RUNNABLE:处于可运行状态的线程正在 Java 虚拟机中执行,但它可能正在等待来自操作系统(例如处理器)的其他资源
    3. BLOCKED:线程的线程状态被阻塞,等待监视器锁定。处于阻塞状态的线程正在等待监视器锁定,以输入同步块的方式或在调用后重新输入同步的块方法,通过 Object.wait() 进入阻塞
    4. WAITING:处于等待状态的线程正在等待另一个线程执行特定操作,例如:在对象上调用了 Object.wait() 的线程正在等待另一个线程调用 Object.notify() 或者 Object.notifyAll()、调用了 Thread.join() 的线程正在等待指定的线程终止
    5. TIMED_WAITING:具有指定等待时间的等待线程的线程状态。由于以指定的等待时间调用以下方法之一,因此线程处于等待状态
      • Thread.sleep(long)
      • Object.wait(long)
      • Thread.join(long)
      • LockSupport.parkNanos(long)
      • LockSupport.parkUntil(long)

7. notify 和 notifyAll 的区别

  • 前置:锁池等待池的概念

    • 锁池
      假设线程 A 已经拥有了某个对象(不是类)的锁,而其他的线程想要调用这个对象的某个 synchronized 方法(或者 synchronized 块),由于这些线程在进入对象的 synchronized 方法之前必须先获得该对象的锁的所有权,但是该对象的锁目前正在被线程 A 拥有,所以这些线程就进入了该对象的锁池中
    • 等待池
      假设一个线程 A 调用了某个对象的 wait 方法,线程 A 就会释放该对象的锁(因为 wait() 方法必须先在 synchronized 中,这样自然在执行 wait() 方法之前线程 A 就拥有了该对象的锁),同时线程 A 就进入到了该对象的等待池中。
      如果另外一个线程调用了相同对象的 notifyAll() 方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。
      如果另外一个对象调用了相同对象的 notify() 方法,那么仅仅只有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池
  • 如果线程调用了对象的 wait() 方法,那么线程就会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁

  • 当有线程调用了对象的 notifyALl() 方法(唤醒所有 wait 线程)或 notify() 方法(只随机唤醒一个 wait 线程),被唤醒的线程就会进入该对象的锁池中准备竞争该对象的锁
    也就是说调用了 notify 后只有一个线程会由等待池进入锁池,而 notifyALl 会将该对象等待池内的所有线程移动到锁池等待锁竞争,竞争成功者继续执行,失败者留在锁池等待锁被释放后再次参与竞争

  • 所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池

8. synchronized 和 lock 的区别

区别

9. ABA 问题

10. 线程池问题