每日Java并发面试系列(2):基础篇(wait/sleep,notify/noitfyAll的区别、什么是线程池,如何实现?线程池的核心参数?拒绝策略?线程池工作流程、如何合理分配线程池、线程同步)

Java并发面试系列开坑:系统攻克多线程核心考点
为什么并发是Java工程师的必考题?

Java并发编程是大厂面试的硬核考点,也是开发高性能系统的关键能力。但很多开发者对并发的理解停留在表面,遇到深度问题就难以应对。

Java并发基础(1)

第二期内容

1. wait/sleep、notify/notifyAll 区别

这两个都是用于线程间协作和控制的机制,但存在本质区别。

特性Object.wait() / notify()Thread.sleep()
所属类java.lang.Object 的实例方法java.lang.Thread 的静态方法
作用域必须在同步代码块或同步方法synchronized)中使用可以在任何地方使用
锁的行为释放当前持有的监视器锁(monitor lock)不释放任何锁,抱着锁睡觉
用途线程间通信和协调。一个线程等待某个条件成立,另一个线程在条件成立后通知它。仅仅让当前线程暂停执行指定的时间,与线程间通信无关。
唤醒方式只能由其他线程通过 notify() 或 notifyAll() 唤醒,或者被中断(interrupt())。睡眠时间到期后自动唤醒,或者睡眠期间被中断(interrupt())。
异常调用 wait() 时,需要捕获 InterruptedException调用 sleep() 时,需要捕获 InterruptedException

核心总结wait/notify 是用于线程间协作的机制,且与锁紧密绑定;sleep 是用于让线程自身暂停执行的简单工具,与锁无关。


2. 什么是“虚假唤醒”(Spurious Wakeup)?如何在代码中防范?

虚假唤醒:指的是一个正在等待的线程(例如调用了 wait())在没有被其他线程通过 notify() 或 notifyAll() 显式唤醒,也没有被中断的情况下,莫名其妙地自行恢复了运行。

原因:这种现象是由于底层操作系统线程调度的高度复杂性导致的,在 Linux 和 Windows 等主流平台上都有可能发生。Java 语言规范(JLS)允许这种行为,以平衡性能和语义的精确性。

防范方法永远不要在条件判断中使用 if 语句,而应该使用 while 循环。这样,即使线程被虚假唤醒了,它也会再次检查等待条件是否真正满足。如果不满足,它会继续等待。

正确代码模式

synchronized (lock) {
    // 使用 while 循环检查条件,而不是 if
    while (/* 条件不满足 */) {
        lock.wait();
    }
    // 条件满足,执行后续逻辑
}

3. Condition 接口与 Object 的监视器方法 (wait/notify) 有什么异同?

相同点

  1. 基本功能:两者都提供了让线程等待(await() / wait())和唤醒(signal() / notify()signalAll() / notifyAll())的能力。
  2. 使用前提:都必须先持有对应的锁才能调用。
  3. 等待时释放锁:调用 await() 或 wait() 时,当前线程都会释放已持有的锁。
  4. 唤醒后重获锁:被唤醒的线程在返回前都需要重新竞争锁。

不同点

特性Object 监视器方法Condition 接口
绑定对象一个对象的监视器锁(synchronized)绑定。一个锁只能有一个等待队列。与 Lock 绑定。一个 Lock 可以创建多个 Condition 实例
等待队列只有一个不区分条件的等待队列可以有多个等待队列,每个 Condition 实例管理一个独立的队列。
灵活性低。notify() 会随机唤醒一个等待线程,无法精确控制。。可以使用不同的 Condition 来区分等待不同条件的线程组,实现精确通知(例如,conditionA.signalAll() 只会唤醒所有在 conditionA 上等待的线程)。
功能扩展功能基础。提供更多功能,如:
awaitUninterruptibly():不可中断的等待。
awaitNanos(long):带超时时间的等待。
- 支持公平锁的公平唤醒策略。

核心总结Condition 是 Lock 体系下对传统 wait/notify 机制的增强和替代,它通过多个等待队列提供了更精细、更灵活的线程调度和控制能力。


4. 什么是线程池,如何实现的?

是什么:线程池是一种池化技术,用于统一管理、调度和复用线程。它预先创建一定数量的线程并放入“池”中,当有任务需要执行时,就从池中分配一个空闲线程来运行它,任务完成后线程并不销毁,而是返回池中等待下一个任务。

为什么:频繁地创建和销毁线程开销很大(系统资源、调度开销)。线程池通过复用线程,避免了这些开销,提高了响应速度,并且可以方便地控制并发线程的数量,防止服务器因资源耗尽而崩溃。

如何实现:Java 中线程池的核心实现是 ThreadPoolExecutor 类。其内部工作原理可以概括为:

  1. 它维护了一个工作线程集合HashSet<Worker>)和一个任务队列BlockingQueue<Runnable>)。
  2. 每个 Worker 是一个封装了线程和任务的内部类,它会不断地从任务队列中获取任务并执行(一个 while 循环)。
  3. 当提交新任务时,线程池会根据当前池中线程数量、核心线程数、最大线程数等参数,决定是直接分配给空闲线程、放入队列,还是创建新线程,或者拒绝任务。

5. ThreadPoolExecutor 有哪些核心参数?分别代表什么意义?

创建 ThreadPoolExecutor 最完整的构造函数需要7个参数:

  1. corePoolSize (核心线程数)

    • 线程池中长期维持的线程数量,即使这些线程是空闲的也不会被回收(除非设置了 allowCoreThreadTimeOut)。
  2. maximumPoolSize (最大线程数)

    • 线程池允许创建的最大线程数量。
  3. keepAliveTime (线程空闲时间)

    • 当线程数超过 corePoolSize 时,多余的空闲线程在终止前等待新任务的最长时间。
  4. unit (时间单位)

    • keepAliveTime 的时间单位(如 TimeUnit.SECONDS)。
  5. workQueue (工作队列)

    • 用于保存等待执行的任务的阻塞队列(如 ArrayBlockingQueueLinkedBlockingQueueSynchronousQueue)。
  6. threadFactory (线程工厂)

    • 用于创建新线程的工厂。可以用于设置线程名、优先级、守护线程状态等,便于排查问题。
  7. handler (拒绝策略处理器)

    • 当线程池和队列都已饱和,无法处理新提交的任务时,执行的拒绝策略(如抛出异常、直接丢弃等)。

6. 说说你知道的拒绝策略有哪些,分别用在什么场景?

ThreadPoolExecutor 提供了四种内置策略,都实现了 RejectedExecutionHandler 接口:

  1. AbortPolicy (默认策略)

    • 行为:直接抛出 RejectedExecutionException 异常。
    • 场景:这是最稳妥的策略,可以确保提交失败的行为被调用者感知,以便做后续处理(如重试、记录日志等)。适用于关键业务。
  2. CallerRunsPolicy

    • 行为:不抛弃任务,也不抛异常,而是将任务回退给调用者线程执行(即谁提交的任务,谁自己去运行)。
    • 场景:这是一种温柔的反馈机制。它会让提交任务的客户端线程忙于执行这个任务,从而降低新任务的提交速度,给线程池一个喘息的机会。适用于允许降低吞吐量的场景。
  3. DiscardPolicy

    • 行为:静默地直接丢弃无法处理的任务,不做任何通知。
    • 场景不关心任务是否完成的场景。可能会丢失数据,需谨慎使用。
  4. DiscardOldestPolicy

    • 行为:丢弃队列中最老的(即下一个即将被执行的)任务,然后尝试重新提交当前任务。
    • 场景:允许丢弃一些旧任务以尝试执行新任务。适用于发布消息、心跳等场景,新的消息比老的消息更重要。

7. 线程池的工作流程是怎样的?

当一个任务被提交 (execute(Runnable command)) 时,流程如下:

  1. 核心线程分配:如果当前运行的线程数 < corePoolSize,则立即创建新线程(核心线程)来执行任务。
  2. 入队等待:如果运行的线程数 >= corePoolSize,则尝试将任务放入工作队列 (workQueue)。
  3. 创建非核心线程:如果队列已满,且当前运行的线程数 < maximumPoolSize,则创建新的非核心线程来执行任务。
  4. 拒绝策略:如果以上步骤都失败(队列已满且线程数已达最大值),则触发拒绝策略 (handler) 来处理这个任务。

流程图简化记忆
提交任务 -> 核心线程? -> 入队? -> 创建非核心线程? -> 执行拒绝策略


8. 如何合理配置线程池的大小?

这是一个经验公式,需要根据任务类型进行调整:

  • CPU 密集型任务:任务大部分时间在计算,很少进行I/O操作。

    • 公式线程数 = CPU核心数 + 1
    • 原因:过多的线程会导致频繁的上下文切换,反而降低性能。+1 是为了在某个线程因页缺失等意外原因暂停时,能有一个替补,保证CPU时钟周期不被浪费。
  • I/O 密集型任务:任务大部分时间在等待I/O操作(如数据库查询、网络请求、文件读写)。

    • 公式线程数 = CPU核心数 * (1 + 平均等待时间 / 平均计算时间)
    • 经验值:通常可以设置为 CPU核心数 * 2 或者更高。目的是在某个线程等待I/O时,CPU可以切换到其他线程去执行计算,最大化利用CPU资源。具体的数值需要通过压测来找到最佳点。

通用策略:如果不确定,可以先设置为 CPU核心数 * 2,然后通过监控系统的线程池运行情况(队列长度、活跃线程数、CPU负载等)进行动态调整。


9. 线程同步的方式有哪些

线程同步是为了解决多线程访问共享资源时的数据不一致问题。主要方式有:

  1. synchronized 关键字

    • 机制:JVM 内置锁,基于 monitorenter 和 monitorexit 指令实现。可修饰代码块或方法。
    • 特点:自动加锁、解锁,简单易用,支持锁升级,不可中断。
  2. java.util.concurrent.locks.Lock 接口(如 ReentrantLock):

    • 机制:API 级别的锁,需要显式地调用 lock() 和 unlock()
    • 特点:功能更丰富,支持公平锁可中断的锁获取尝试非阻塞获取锁 (tryLock())、多个条件变量 (Condition)。
  3. ** volatile 关键字**:

    • 机制:保证变量的可见性禁止指令重排序,但不保证操作的原子性。
    • 场景:适用于一写多读的简单状态标志位。
  4. 原子类 (java.util.concurrent.atomic.*):

    • 机制:通过 CAS (Compare-And-Swap) 操作保证单个变量的原子性操作(如 AtomicInteger)。
    • 场景:适用于计数器、累加器等场景,性能通常比锁高。
  5. 不可变对象

    • 机制:使用 final 关键字创建一旦创建就不能被修改的对象(如 String)。
    • 场景:本质上是无同步,因为对象不可变,所以可以被安全地共享和访问。

10. synchronized 锁升级的过程是怎样的?

为了在性能与开销之间取得平衡,synchronized 的锁状态会随着竞争情况而升级,其过程是不可逆的。锁的四种状态从低到高为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

  1. 无锁状态:对象刚创建,还没有任何线程访问。
  2. 偏向锁
    • 场景:假设在大多数情况下,锁不仅不存在竞争,而且总是由同一线程多次获得。
    • 过程:当一个线程第一次访问同步块时,会在对象头和栈帧中记录偏向的线程ID。以后该线程再进入和退出同步块时,不需要进行CAS操作来加锁和解锁,只需简单测试一下对象头里是否存储着指向当前线程的偏向锁。
    • 优点:消除无竞争情况下的同步开销。
  3. 轻量级锁
    • 场景:当有另一个线程来竞争这个锁时,偏向锁就会升级为轻量级锁。
    • 过程:竞争的线程会通过CAS操作去尝试获取锁(在栈中创建锁记录空间,拷贝对象头中的Mark Word),而不会直接阻塞。如果成功,则使用轻量级锁;如果失败,表示存在竞争,会进行自旋(循环尝试)一小段时间。
    • 优点:避免线程在用户态和内核态之间切换,对于执行时间很短的程序块来说,自旋等待的代价往往小于阻塞。
  4. 重量级锁
    • 场景:如果轻量级锁自旋失败(超过一定次数或自旋线程数超过CPU核心数的一半),锁就会膨胀为重量级锁。
    • 过程:此时,未抢到锁的线程会被阻塞,进入操作系统内核的等待队列,等待被唤醒。这个过程中涉及到用户态到内核态的切换,开销最大。
    • 机制:重量级锁依赖于操作系统底层的 mutex lock 实现。

11. ReentrantLock 是如何实现可重入的?

ReentrantLock 的可重入性是通过内部同步器 Sync (继承自 AbstractQueuedSynchronizer,即AQS) 和一个 state 计数器来实现的。

  1. state 字段:在AQS中,state 表示同步状态。

    • 当 state == 0:表示锁未被任何线程持有。
    • 当 state > 0:表示锁被某个线程持有,并且数值表示该线程重复获取此锁的次数(即重入次数)。
  2. 加锁过程 (lock())

    • 当线程A第一次调用 lock() 时,会通过CAS操作将 state 从0设置为1,并记录当前锁的持有者为线程A。
    • 线程A再次调用 lock() 时,发现锁的持有者就是自己,于是简单地将 state 值 +1(无需CAS),即可成功获得锁。
  3. 解锁过程 (unlock())

    • 线程A每次调用 unlock(),都会将 state 值 -1
    • 只有当 state 减到 0 时,才表示线程A已经完全释放了锁,此时才会唤醒等待队列中的其他线程来竞争锁。

通过这种 state 计数器的方式,ReentrantLock 可以精确地记录同一个线程重入锁的次数,从而实现可重入性。


12. ReadWriteLock 和 StampedLock 有什么区别?

两者都是用于提高读多写少场景下的并发性能的锁。

特性ReadWriteLock (如 ReentrantReadWriteLock)StampedLock
锁模式两种模式:读锁(共享)、写锁(排他)。三种模式:写锁(排他)、悲观读锁(共享)、乐观读(无锁)。
乐观读不支持。读操作直接加读锁,是悲观策略。核心特性。尝试进行一次乐观读,不阻塞写线程。读完后需要验证(validate)期间是否有写操作发生,如果发生则升级为悲观读锁重试。
可重入ReentrantReadWriteLock 是可重入的。StampedLock 不是可重入锁,调用者需要防止死锁。
条件变量支持。写锁可以提供 Condition不支持
饥饿问题如果读线程非常多,可能导致写线程饥饿(一直无法获取写锁)。通过提供一个不遵守读写锁约定的“乐观读”模式,在一定程度上缓解了写线程饥饿
API复杂度相对简单。更复杂。需要关注戳(stamp)的管理和验证。
性能在Java 8及以后,StampedLock 的吞吐量通常远高于 ReadWriteLock,特别是在读非常多的情况下。

选择建议

  • 如果你需要可重入、条件变量等复杂功能,或者代码简单易懂更重要,选择 ReadWriteLock
  • 如果你追求极致的读性能,并且是Java 8+,能处理好 stamp 的验证和锁的不可重入性,选择 StampedLock
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值