2023-12-11
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://www.skjava.com/mianshi/tiji/4090262427

线程池

为什么要使用线程池?

  1. 资源管理:线程池可以管理和复用线程,而不是为每个任务都创建新线程。这降低了线程创建和销毁的开销,节省了系统资源,尤其是内存和 CPU 时间。
  2. 提高性能:线程池可以根据需要动态地调整线程的数量,确保系统中的线程数量在合理范围内,避免了过多线程导致的性能下降(例如上下文切换成本增加)。
  3. 控制并发度:线程池允许您限制并发执行的任务数量,这有助于避免资源竞争、死锁和过度并发等问题。通过调整线程池的大小,可以根据系统资源和负载来控制并发度。
  4. 提高代码可维护性:线程池将任务的创建和执行分离开来,使代码更易于维护。任务的执行逻辑独立于线程管理,可以专注于任务本身的逻辑。
  5. 避免线程泄漏:线程池可以确保线程的正确释放和回收,避免了线程泄漏问题,因为线程池会自动回收不再使用的线程。
  6. 提高系统稳定性:通过合理配置线程池,可以避免过度消耗系统资源和系统崩溃等问题,提高系统的稳定性。

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

  1. 基本构造函数
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue)

这是 ThreadPoolExecutor 的基本构造函数,它允许你手动指定线程池的核心线程数、最大线程数、线程空闲时间、时间单位以及工作队列。

这个构造函数提供了最大的灵活性,可以根据需要自定义线程池的各个参数。
  1. Executors** 工厂方法**:

    Java 还提供了 Executors 类,它包含一些静态工厂方法,可以更方便地创建不同类型的线程池。例如:

    • Executors.newFixedThreadPool(int n):创建一个固定大小的线程池,核心线程数和最大线程数都是 n,没有空闲线程。
    • Executors.newCachedThreadPool():创建一个可缓存的线程池,核心线程数为 0,最大线程数为 Integer.MAX_VALUE,适用于短期异步任务。
    • Executors.newSingleThreadExecutor():创建一个单线程的线程池,核心线程数和最大线程数都是 1,用于顺序执行任务。
  2. Executors.newScheduledThreadPool(int corePoolSize)

    这个工厂方法创建一个定时执行任务的线程池,可以执行定时任务和周期性任务。核心线程数是指定的 corePoolSize,最大线程数为 Integer.MAX_VALUE。

  3. Executors.newWorkStealingPool()

    这个工厂方法创建一个工作窃取线程池,用于执行大量耗时任务。这个线程池会根据需要自动增加或减少线程数,以充分利用多核处理器。

Executors 类创建三种类型的线程池有哪些缺陷?

Executors 类提供了三种类型的线程池:newFixedThreadPool、newCachedThreadPool 和 newSingleThreadExecutor。各自缺陷如下:

newFixedThreadPool(固定大小线程池)的缺陷:

  • 资源浪费: 固定大小线程池创建了固定数量的线程,这意味着即使在任务较少的情况下,线程池的所有线程也会一直存在,造成资源浪费。
  • 任务排队问题: 如果任务提交速度大于线程池处理速度,任务会积压在队列中,可能导致队列溢出(OutOfMemoryError)。此外,由于线程数量是固定的,可能无法应对突发的任务量。

newCachedThreadPool(缓存线程池)的缺陷:

  • 线程无限增长: 缓存线程池可以无限制地创建新线程,如果任务量非常大,可能导致线程数量过多,耗尽系统资源,最终导致应用程序崩溃(OutOfMemoryError)。
  • 线程销毁问题: 线程池中的线程默认会在空闲一定时间后被销毁,但如果任务一直持续不断地到达,线程池中的线程将不会被销毁,可能导致线程资源的浪费。

newSingleThreadExecutor(单线程线程池)的缺陷:

  • 性能问题: 单线程线程池只有一个线程,不能并行执行多个任务。如果有大量任务需要并行执行,使用单线程线程池可能会导致性能瓶颈。
  • 无法应对任务失败: 如果线程在执行任务时出现未捕获的异常而终止,线程池会创建一个新线程来替代,但这可能会导致不断重复相同的失败。

线程池都有哪些状态

  1. RUNNING(运行中):线程池处于正常工作状态,可以接受并处理任务。在这种状态下,线程池会根据需要创建新的线程或者重用空闲线程来执行任务。
  2. SHUTDOWN(关闭中):线程池不再接受新的任务,但会继续处理已经提交的任务。在这种状态下,ThreadPoolExecutor 不会再创建新的线程,而是等待现有线程执行完任务。
  3. STOP(停止):线程池停止接受新任务,并且会尝试中断所有正在执行的任务。这个状态表示线程池已经停止工作,不再处理任何任务。
  4. TIDYING(整理中):线程池正在进行清理工作,清理工作包括中断已经停止的工作线程并回收资源。在线程池处于这个状态时,可能会有一些线程正在执行清理工作。
  5. TERMINATED(终止):线程池的所有任务已经完成,线程池已经被彻底终止。线程池不再可用。

线程池各个核心参数的含义是什么?

corePoolSize(核心线程数)

  • 含义:corePoolSize 是线程池中维护的核心线程数量,即线程池在任何时候都会保持这么多线程处于活动状态,即使它们没有正在执行任务。
  • 作用:核心线程用于处理任务队列中的任务,当有新任务提交时,如果核心线程数尚未达到 corePoolSize,线程池会创建新线程来处理任务,而不是将任务放入队列中。

maximumPoolSize(最大线程数)

  • 含义:maximumPoolSize 是线程池允许的最大线程数量,包括核心线程和非核心线程。
  • 作用:当任务队列中的任务数量超过 corePoolSize 且小于 maximumPoolSize 时,线程池会创建新的非核心线程来处理任务。这个参数限制了线程池的最大并发执行能力。

keepAliveTime(线程空闲时间)

  • 含义:keepAliveTime 是非核心线程的空闲时间,即当非核心线程在处理完任务后空闲超过这个时间时,它们可能会被终止并从线程池中移除。
  • 作用:通过设置合适的 keepAliveTime 可以控制非核心线程的生命周期,避免无限制地保持空闲线程。

unit(时间单位)

  • 含义:unit 是用于表示 keepAliveTime 的时间单位,可以是毫秒、秒、分钟等。
  • 作用:确定了 keepAliveTime 参数的时间单位,确保正确设置线程的空闲时间。

workQueue(工作队列)

  • 含义:工作队列用于存储待执行的任务,它可以是一个阻塞队列或其他类型的队列,比如 LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue 等。
  • 作用:工作队列决定了线程池的任务调度策略。不同类型的队列具有不同的特性,如有界或无界,按照先进先出或优先级等。

threadFactory(线程工厂)

  • 含义:ThreadFactory 用于创建线程池中的线程对象,它允许您自定义线程的创建过程,例如为线程设置有意义的名称、设置线程优先级等。
  • 作用:通过自定义 ThreadFactory,可以更好地识别线程池中的线程,方便调试和监控。

handler(拒绝策略)

  • 含义:当线程池已经达到最大线程数且任务队列已满时,新提交的任务将会被拒绝执行。拒绝策略定义了在线程池无法接受新任务时的处理方式。
  • 作用:选择合适的拒绝策略可以避免任务丢失或影响系统稳定性。Java 提供了几种内置的拒绝策略,例如 ThreadPoolExecutor.AbortPolicy(默认策略,抛出异常)、ThreadPoolExecutor.DiscardPolicy(丢弃任务)等,同时您也可以自定义拒绝策略。

线程池的拒绝策略有哪些?

当线程池在面临无法处理新任务时,可以使用不同的拒绝策略来处理这种情况。以下是常见的线程池拒绝策略:

AbortPolicy(默认策略)

  • 描述:当线程池无法处理新任务时,抛出 RejectedExecutionException 异常,不执行新任务。
  • 使用场景:这是默认的拒绝策略,它会在任务被拒绝时立即抛出异常,通常用于要求严格的任务处理。

CallerRunsPolicy(调用者运行策略)

  • 描述:当线程池无法处理新任务时,将任务交给调用线程来执行,而不会启动新线程。
  • 使用场景:如果不能接受任务被丢弃,可以选择这个策略,但请注意,如果调用线程也忙于处理任务,可能会导致任务处理速度变慢。

DiscardPolicy(丢弃策略)

  • 描述:当线程池无法处理新任务时,直接丢弃掉这个任务,不做任何处理。
  • 使用场景:如果对任务的处理要求不高,可以选择这个策略,但需要注意任务丢失的可能性。

DiscardOldestPolicy(丢弃最旧策略)

  • 描述:当线程池无法处理新任务时,丢弃队列中最旧的任务(即最先进入队列的任务),然后尝试执行新任务。
  • 使用场景:如果希望保留最新提交的任务,而丢弃较旧的任务,可以选择这个策略。

自定义策略(实现 RejectedExecutionHandler 接口):

  • 描述:您可以自定义拒绝策略,实现 RejectedExecutionHandler 接口,并在实现中定义自己的拒绝逻辑。例如,您可以将任务记录到日志中或将其放入其他队列中等等。

向线程池中提交任务有几种方式?他们的区别是什么?

  • 使用 execute() 方法提交任务
Executor executor = Executors.newFixedThreadPool(5); // 创建一个固定大小的线程池
executor.execute(new RunnableTask()); // 提交一个实现了 Runnable 接口的任务

  • 使用 submit() 方法提交任务并获取 Future 对象:
ExecutorService executorService = Executors.newCachedThreadPool(); // 创建一个可缓存的线程池
Future<Integer> future = executorService.submit(new CallableTask()); // 提交一个实现了 Callable 接口的任务,并返回 Future 对象

区别如下:

返回值类型

  • execute() 方法没有返回值,它用于提交实现了 Runnable 接口的任务,这些任务不返回结果,通常用于执行一些无返回值的操作。
  • submit() 方法返回一个 Future 对象,您可以使用这个 Future 对象来获取任务的执行结果,取消任务的执行,或者检查任务是否已经完成。这对于需要获取任务执行结果的情况非常有用,因为 Future 可以包含任务的执行结果或异常信息。

异常处理

  • execute() 方法不会抛出任务执行过程中的异常,如果任务内部抛出异常,线程池将捕获并记录异常,但不会将其传递给调用方。这可能导致难以诊断任务执行中发生的问题。
  • submit() 方法可以捕获并返回任务执行过程中抛出的异常。您可以使用 Future 对象的 get() 方法来获取任务的结果,如果任务抛出了异常,get() 方法将抛出该异常,使得调用方能够更容易地处理任务执行中的异常。

适用场景

  • execute() 方法适用于不需要获取任务执行结果的情况,比如并发执行一组操作,或者简单的线程执行任务。
  • submit() 方法更适用于需要获取任务执行结果、取消任务或处理任务异常的情况,比如需要执行一个有返回值的任务,或者需要监控任务的执行状态。

如果您只是需要执行一些简单的操作,不需要关心任务的返回结果或异常处理,那么 execute() 可能更方便。但如果您需要获取任务的结果、进行异常处理,或者取消任务的执行,那么 submit() 更适合。

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

如果你使用的LinkedBlockingQueue(阻塞队列),也就是无界队列的话,继续添加任务到阻塞队列中等待执行,因为LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务;如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了,则会使用拒绝策略RejectedExecutionHandler处理满了的任务,默认是AbortPolicy

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

分为计算密集型和IO密集型

计算密集型

这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

IO密集型

这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 : 核心线程数 = CPU核心数量 * 2

volatile

volatile作用

可见性

  • 当一个变量被声明为 volatile 时,它保证了变量的写操作对于其他线程是可见的。具体来说,如果一个线程修改了 volatile 变量的值,其他线程将立即看到这个变化,而不会使用本地缓存的值。
  • 这个特性对于多线程之间共享的状态非常有用。例如,一个线程修改了一个共享的标志位,其他线程可以使用 volatile 修饰的变量来检测这个标志位的状态,以协调各个线程的操作。而如果不使用 volatile,可能会出现线程之间的数据不一致性问题。

禁止指令重排序

  • volatile 变量的读写操作会禁止编译器和处理器对其进行指令重排序。这确保了在多线程环境下,volatile 变量的赋值操作不会被重排序到其他指令之前或之后。
  • 这个特性对于确保代码的执行顺序是如预期一样的非常重要。在不使用 volatile 的情况下,编译器和处理器可能会对指令进行重排序,导致代码的执行顺序不同于代码中的编写顺序,从而引发意外的行为。

需要注意的是,volatile 只能用于修饰成员变量(类的属性)以及静态成员变量(类的静态属性),而不能用于修饰局部变量。此外,虽然 volatile 可以保证可见性和禁止指令重排序,但它并不能保证原子性。

i++是线程安全的吗?

分为两种情况:

  1. 局部变量肯定是线程安全的(原因:方法内局部变量是线程私有的)
  2. 成员变量多个线程共享时,就不是线程安全的。因为它不是一个原子操作,而是由多个步骤组成的操作,包括读取变量的当前值、增加这个值,然后将结果写回变量。在多线程环境下,这个操作可能会出现竞态条件,导致不确定的结果。比如:
    1. 线程 A 读取变量 i 的值为 1。
    2. 线程 B 读取变量 i 的值也为 1。
    3. 线程 A 增加 i 的值并计算结果,得到 2。
    4. 线程 B 也增加 i 的值并计算结果,得到 2。
    5. 线程 A 将结果 2 写回变量 i
    6. 线程 B 也将结果 2 写回变量 i

在 Java 中有多少种方法可以实现可见性?

  • 使用volatile关键字: 声明一个变量为volatile可以确保该变量的读写操作对所有线程都是可见的。volatile变量的值在一个线程中被修改后,会立即刷新到主内存,以便其他线程能够看到最新的值。
private volatile boolean flag = false;

  • 使用**synchronized**关键字: 使用synchronized关键字来对代码块或方法进行同步,确保只有一个线程可以访问同步块内的代码。这不仅保证了互斥性,还确保了可见性,因为进入同步块之前的所有修改都会被刷新到主内存,而在同步块内的操作都会从主内存中读取最新的值。
synchronized (lockObject) {
    // 访问共享变量
}
  • 使用**java.util.concurrent**工具: Java提供了一些并发工具,如java.util.concurrent包中的Atomic类(如AtomicIntegerAtomicBoolean等),这些类提供了原子性操作,确保对变量的修改对所有线程都是可见的。
private AtomicInteger count = new AtomicInteger(0);

// 原子性的增加操作
count.incrementAndGet();

  • 使用**Lock**接口: 使用java.util.concurrent.locks.Lock接口及其实现类(如ReentrantLock)可以实现精细的线程控制,同时也确保了可见性。在进入Lock的临界区之前,线程会将本地工作内存中的修改刷新到主内存,从而保证其他线程能够看到最新的值。
Lock lock = new ReentrantLock();
lock.lock();
try {
    // 访问共享变量
} finally {
    lock.unlock();
}

  • 使用**java.util.concurrent**工具中的高级类: Java的并发库还提供了许多高级工具,如CountDownLatchCyclicBarrierSemaphore等,这些工具可以帮助线程之间协同工作,确保可见性。
  • 使用**Thread.join()**方法: 当一个线程调用另一个线程的join()方法时,它会等待被调用线程执行完毕,从而确保对共享数据的修改在后续操作中可见。

正确使用 volatile 的条件是什么?

您只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  1. 对变量的写操作不依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

实际上,这两个条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(x++ )看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。实现正确的操作需要使 x的值在操作期间保持不变,而 volatile 变量无法实现这点。(然而,如果将值调整为只从单个线程写入,那么可以忽略第一个条件。

Synchronized

Synchronized 的实现原理

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

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

从JDK1.6版本之后,synchronized本身也在不断优化锁的机制,有些情况下他并不会是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。

锁的状态从低到高依次为无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到高,降级在一定条件也是有可能发生的。

  1. 适应性自旋

    在早期的版本中,synchronized 锁在发生竞争时会导致线程进入阻塞状态,这会引入较大的性能开销。从 JDK 1.6 开始,引入了适应性自旋的概念。当线程尝试获取锁时,如果锁没有被其他线程占用,它会自旋一段时间,而不会立即阻塞。自旋的时间会逐渐增加或逐渐减少,以便更好地适应锁的竞争情况。

  2. 锁膨胀

    JDK 1.6 以后的版本中,JVM 对连续的 synchronized 块进行了锁膨胀优化。如果多个 synchronized 块之间没有竞争关系,JVM 可以将它们合并成一个更大的同步块,从而减少锁的粒度,提高性能。

  3. 锁消除

    如果分析器确定某些锁不会发生竞争,JVM 可以在运行时将这些锁消除掉,以减少不必要的同步操作。这个优化主要针对局部变量,因为局部变量的作用范围较小,通常不会被多个线程访问。

  4. 偏向锁

    JDK 1.6 引入了偏向锁的概念,用于提高单线程场景下的性能。偏向锁会在锁对象的对象头中记录下拥有锁的线程标识,当线程再次访问这个锁时,无需进行竞争,而是直接获取锁。这对于短暂的同步操作非常有用,因为它减少了竞争。

  5. 轻量级锁

    JVM的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁。

Synchronized与Lock的区别

实现方式

  • synchronized 是 Java 语言内置的关键字,它的实现是由 Java 虚拟机自动处理的,不需要程序员手动释放锁。使用 synchronized 时,锁的获取和释放都由 JVM 隐式管理。
  • Lock 是一个接口,它在 java.util.concurrent.locks 包中定义。Lock 的实现需要程序员显式地获取和释放锁,这给了程序员更多的灵活性和控制权。

锁的粒度

  • synchronized 锁的粒度较粗,通常用于对整个方法或代码块进行同步。
  • Lock 可以实现更细粒度的锁定,允许程序员选择在代码中的特定部分加锁,从而提高并发性能。

可中断性

  • synchronized 不支持锁的中断。一旦一个线程进入了 synchronized 块,其他线程无法中断它,只能等待锁的释放。
  • Lock 支持锁的中断。在使用 Lock 时,其他线程可以通过调用 lockInterruptibly() 方法来中断等待锁的线程。

超时等待

  • Lock 可以支持锁的超时等待。通过 tryLock(long time, TimeUnit unit) 方法,可以设置等待锁的超时时间。
  • synchronized 不支持锁的超时等待。

多条件(Condition)

  • Lock 可以支持多个条件(Condition),每个条件可以实现不同的等待和通知机制。
  • synchronized 不支持多条件等待,它只提供了单一的等待和通知机制。

性能

  • 在低竞争情况下,synchronized 通常性能较好,因为它由 JVM 紧密控制,不需要额外的线程切换和上下文切换。
  • 在高度竞争的多线程环境中,Lock 可能表现得更好,因为它允许更细粒度的控制,减少了锁争用。

乐观锁和悲观锁的区别?

悲观锁

  • 思想:悲观锁认为在并发环境下,数据很可能会被其他线程修改,因此它在访问共享资源之前会先获取锁,并假设其他线程会对数据进行修改。如果锁被其他线程持有,当前线程会被阻塞,直到获取到锁才能访问数据。
  • 适用场景:适用于写操作较多的情况,因为悲观锁可以确保数据的一致性和安全性。常见的悲观锁包括数据库中的行级锁和表级锁。
  • 缺点:悲观锁可能导致并发性能下降,因为它在访问数据之前需要获取锁,可能会引发大量的阻塞。

乐观锁(Optimistic Locking)

  • 思想:乐观锁认为在并发环境下,数据修改的冲突较少,因此它不会立即获取锁,而是直接进行操作。当更新数据时,它会检查数据的版本号或标记,如果在操作过程中数据被其他线程修改,则操作会失败,需要重试。乐观锁相信冲突较少,因此不会立即对数据进行加锁。
  • 适用场景:适用于读操作较多的情况,因为乐观锁不会引起阻塞,只在冲突发生时进行回滚和重试。常见的乐观锁实现包括版本号控制和 CAS(Compare and Swap)操作。
  • 缺点:乐观锁需要处理冲突,可能需要进行多次尝试,因此在高并发写入的情况下,可能会导致性能下降。

什么是自旋锁?它有什么优缺点?

自旋锁(Spin Lock)是一种基于忙等待的锁,它不会让线程阻塞,而是会让线程在一定的时间内反复尝试获取锁。自旋锁的主要思想是,如果锁已经被其他线程占用,那么当前线程将自旋等待,不断尝试获取锁,直到成功为止。自旋锁通常适用于锁被占用的时间非常短暂的情况,以减少线程切换和上下文切换的开销。

自旋锁的优点:

  1. 低延迟**:** 自旋锁不会让线程阻塞,因此不需要进行线程的挂起和恢复操作,避免了线程切换的开销。对于锁的占用时间很短的情况,自旋锁的性能往往比较好。
  2. 简单轻量**:** 自旋锁的实现通常比较简单,不依赖于操作系统的特性,因此比较轻量。
  3. 可避免死锁**:** 自旋锁不会造成死锁,因为线程在获取锁失败后会一直尝试,直到成功为止。

自旋锁的缺点:

  1. 高 CPU 开销**:** 自旋锁需要不断地尝试获取锁,如果锁的占用时间较长,那么自旋的线程会浪费大量的 CPU 时间,导致系统的 CPU 使用率升高。
  2. 无法处理线程数过多**:** 当并发线程数非常大时,自旋锁可能不适用,因为过多的线程在自旋等待锁时会竞争 CPU 时间,降低系统的整体性能。

因此,自旋锁适用于锁的占用时间短、线程竞争不激烈的情况,但在其他情况下,可能需要考虑使用其他类型的锁,如互斥锁或读写锁。

什么是适应性自旋锁?相比自旋锁它有什么优点?

适应性自旋锁是一种自旋锁的变种,它具有动态调整自旋等待时间的能力。适应性自旋锁旨在解决自旋锁在不同场景下的性能问题,以提高锁的效率。

适应性自旋锁的主要思想是根据锁的争用情况来动态调整自旋等待的时间。它通常会跟踪自旋等待的次数,当锁经常被争用时,自旋等待的时间会逐渐延长,以减少无效的自旋,降低 CPU 的浪费。当锁很少被争用时,自旋等待的时间会逐渐减少,以提高响应速度。

相比自旋锁,适应性自旋锁的优点有如下几个:

  1. 适应性: 适应性自旋锁能够根据实际的锁争用情况自动调整自旋等待时间,从而更好地适应不同的工作负载和并发情况。
  2. 降低 CPU 开销: 通过动态调整自旋等待时间,适应性自旋锁能够降低 CPU 的浪费,提高系统的性能。
  3. 提高响应速度: 当锁很少被争用时,适应性自旋锁会减少自旋等待时间,从而提高对锁的竞争响应速度。

自旋锁的开启

  • JDK1.6 中-XX:+UseSpinning 开启; -XX:PreBlockSpin=10 为自旋次数;
  • JDK1.7 后,去掉此参数,由 jvm 控制;

对比 synchronized ,Lock 有哪些优势?

更灵活的锁定方式

  • synchronized 关键字只支持一种锁定方式,即排他锁。而 Lock 接口的实现类(如 ReentrantLock)提供了更多的灵活性,可以实现不同种类的锁定方式,包括可重入锁、读写锁、公平锁、非公平锁等,可以根据具体需求选择合适的锁。

更强大的线程等待/通知机制

  • Lock 接口提供了条件变量(Condition),可以实现更复杂的线程等待和通知机制。这使得线程可以更精细地控制等待和唤醒的条件,而 synchronized 关键字只支持 wait()notify(),相对较简单。

可中断的锁

  • Lock 接口的锁可以响应中断,即在一个线程等待锁的过程中,其他线程可以通过调用 interrupt() 方法中断等待线程。这在处理死锁等情况时很有用,而 synchronized 无法中断等待线程。

超时获取锁

  • Lock 接口允许线程尝试获取锁一段时间后放弃,而不是无限期等待。这可以防止线程因无法获取锁而一直阻塞。

可替代性

  • Lock 接口的实现类提供了可替代 synchronized 的锁定方式,因此可以用于替代旧的代码中的 synchronized 关键字,而不需要改变整体的代码结构。

性能优化

  • 在高并发情况下,Lock 的实现类通常具有更好的性能,因为它们采用了一些优化技术,如分段锁、自旋锁等,以减少线程竞争和上下文切换。

什么是ReadWriteLock?

ReadWriteLock 是 Java 中用于支持读写锁机制的接口。

读写锁允许多个线程同时读取共享资源,但在写操作时需要排他锁,即只有一个线程可以写入。这种机制适用于读多写少的场景,可以提高并发性能,因为多个线程可以同时读取共享数据,而写操作仍然是互斥的。

ReadWriteLock 接口定义了两个基本方法:

  1. readLock()****: 返回一个读锁,多个线程可以同时持有读锁,只要没有线程持有写锁。读锁允许并发读取操作,不会阻塞其他读线程,只有在有写线程持有写锁时才会阻塞。
  2. writeLock()****: 返回一个写锁,写锁是独占的,只有一个线程可以持有写锁。当有线程持有写锁时,其他线程无法获取读锁或写锁,从而保证了写操作的互斥性。

AQS

什么是AQS?

AQS(AbstractQueuedSynchronizer)是 Java 中用于构建同步器的抽象基类,它提供了一种基于队列的、可扩展的同步机制的框架。AQS是Java并发包(java.util.concurrent)中重要的一部分,它被广泛用于实现各种同步工具,如锁、信号量、倒计数器、读写锁等。

AQS的主要作用是提供了一个框架,使得开发者可以相对容易地构建自定义的同步器。它实现了底层的线程排队和等待机制,开发者只需关注同步状态的管理和线程的排队等待即可,无需重复编写底层的线程调度逻辑。

AQS的核心思想是将线程排队在一个FIFO(先进先出)的队列中,每个线程都是一个Node节点,线程需要获得同步资源时,会加入队列,并且以一种非常高效的方式排队。当同步资源可用时,AQS会按照FIFO的顺序唤醒队列中的线程。

AQS的子类需要实现以下两种操作来管理同步状态:

  1. 获取(acquire):当一个线程请求获取同步资源时,如果资源不可用,线程会进入等待状态,并排队在AQS的等待队列中,直到资源可用。获取操作通常会涉及到线程的排队和等待。
  2. 释放(release):当一个线程释放同步资源时,AQS会负责唤醒等待队列中的线程,并将同步资源分配给其中一个线程。释放操作通常会导致等待线程中的某一个被唤醒并获得资源。

AQS 支持哪几种同步方式?

独占锁(Exclusive Locking):

  • 通过独占锁方式,只有一个线程能够获得锁,其他线程需要等待该线程释放锁才能获取。
  • ReentrantLock 是 AQS 最常见的独占锁的实现。

共享锁(Shared Locking):

  • 共享锁方式允许多个线程同时获取锁,常用于读多写少的场景,可以提高并发性。
  • ReadWriteLock 是 AQS 提供的支持共享锁的接口,ReentrantReadWriteLock 是其常见的实现。

CAS

CAS的原理呢?

CAS(Compare and Swap,比较并交换)主要是通过处理器的指令来保证操作的原子性。

CAS操作有三个操作数:

  1. 变量内存地址,V表示
  2. 旧的预期值,A表示
  3. 准备设置的新值,B表示

当执行CAS指令时,只有当V等于A时,才会用B去更新V的值,否则就不会执行更新操作。

CAS操作是一种乐观锁机制,它可以在不使用锁的情况下实现线程安全。

CAS操作的原子性是由硬件提供的,通常是利用处理器的原子指令实现的,这确保了在多线程环境中,只有一个线程能够成功执行CAS操作,其他线程需要重试。CAS操作通常用于实现无锁数据结构,如并发队列、计数器、自旋锁等。

CAS有什么缺点吗?

  1. ABA问题:ABA问题是CAS的一个主要缺点。如果一个共享变量的值从A变为B,然后又从B变回A,在CAS中无法检测到这种变化,因为只比较了最新值与期望值。这可能导致错误的结果,特别是在一些数据结构中,如无锁栈或队列,ABA问题可能会破坏数据结构的一致性。为了解决ABA问题,可以使用版本号、时间戳等方式来增加额外的检查。
  2. 循环时间长开销大:CAS操作在失败时需要重试,这会导致线程进入自旋等待状态,浪费CPU资源。如果CAS操作经常失败,自旋的开销可能会变得很大。
  3. 只能保证一个共享变量的原子性:CAS操作通常只能应用于单个共享变量的原子操作。如果需要在多个变量之间执行复合操作,CAS可能不够强大。
  4. 容易造成性能问题:过度使用CAS可能会导致性能问题。在高并发环境下,大量线程重试CAS操作可能会导致竞争激烈,性能下降。

ThreadLocal

ThreadLocal的核心原理是什么?

ThreadLocal 提供了一种线程本地存储的机制,允许每个线程拥有自己独立的变量副本。 其核心原理如下:

每个线程拥有自己的变量副本

当你创建一个 ThreadLocal 变量时,实际上创建了一个线程局部变量。每个线程都有自己独立的变量副本,这个副本存储在线程的线程栈中。这意味着不同线程可以同时访问同一个 ThreadLocal 变量,但它们访问的是各自线程内部的副本。

ThreadLocalMap 存储变量副本

ThreadLocal 内部维护了一个 ThreadLocalMap,它是一个哈希表,用于存储线程本地变量的副本。ThreadLocal 对象是哈希表的键,而每个线程的变量副本则存储在哈希表的值中。这样可以确保每个线程访问 ThreadLocal 变量时都能获得自己的副本。

线程获取变量时使用 ThreadLocal 的引用

当线程访问 ThreadLocal 变量时,实际上是通过 ThreadLocal 对象的引用来获取自己线程内部的变量副本。这个引用充当了键,用于在 ThreadLocalMap 中查找对应的线程本地变量副本。

自动内存管理

ThreadLocal 变量的生命周期受到线程的生命周期的限制。一旦线程终止,与之关联的线程本地变量副本也会被垃圾回收,从而避免内存泄漏。

ThreadLocal什么时候会出现OOM的情况?为什么?

  1. 长时间不清理: 如果你在 ThreadLocal 中存储大量数据,而且不在使用完后调用 remove() 方法来清理线程局部变量,这些数据可能会一直存活在线程的上下文中,导致内存泄漏。
  2. 线程池中的线程: 当使用线程池时,线程在执行完任务后可能会被重新用于执行其他任务,但 ThreadLocal 中的数据不会自动清理。如果线程池中的线程长时间存在,并且 ThreadLocal 中的数据不断累积,可能会导致内存泄漏。
  3. 使用过多的ThreadLocal: 如果在应用程序中滥用 ThreadLocal,为每个线程创建大量的 ThreadLocal 变量,可能会导致内存消耗过大,最终触发 OutOfMemoryError
  4. 没有及时清理线程局部变量: 如果你没有在使用完 ThreadLocal 变量后及时调用 remove() 方法,那么线程局部变量中的数据将保持活动状态,可能会导致内存泄漏。

为避免 ThreadLocal 导致内存泄漏或 OutOfMemoryError,应该遵循以下最佳实践:

  1. 在使用完 ThreadLocal 变量后调用 remove() 方法: 在适当的时候,通常是在任务执行结束后,手动调用 ThreadLocal 的 remove() 方法来清理线程局部变量,以释放资源。
  2. 避免滥用 ThreadLocal: 不要过度使用 ThreadLocal,只在有必要的情况下使用,确保每个线程只创建少量的 ThreadLocal 变量。
  3. 小心使用线程池: 如果使用线程池,请确保在执行线程任务前清理线程局部变量,以防止线程池中的线程重用导致的数据混乱。
  4. 使用弱引用(WeakReference): 如果你需要在某些情况下存储大量数据,可以考虑使用弱引用来管理 ThreadLocal 变量,以便更容易进行垃圾回收。

并发工具类

CyclicBarrier 和 CountDownLatch 的区别

用途不同:

  • CountDownLatch 用于一个或多个线程等待其他线程完成一组操作,然后继续执行。通常,一个或多个线程等待一个计数器减到零。
  • CyclicBarrier 用于一组线程相互等待,直到所有线程都达到某个同步点,然后继续执行。它通常用于一组线程分阶段地执行某个任务,每个阶段都需要等待所有线程完成,然后进入下一个阶段。

计数方式不同:

  • CountDownLatch 是一种单次计数器,一旦计数达到零,就不能再次使用。每次调用countDown()方法减少计数。
  • CyclicBarrier 是一种可重用的计数器,可以在重置后再次使用。当所有线程到达栅栏点后,栅栏会自动重置,计数器重新开始计数。

异常处理不同:

  • CountDownLatch 不提供异常处理机制。如果等待线程在计数减为零之前抛出异常,它将无法被捕获。
  • CyclicBarrier 可以选择在构造时传递一个Runnable,当栅栏打破时,该Runnable会被执行,可以用于处理异常情况。

适用场景不同:

  • CountDownLatch 适用于一组线程中的某个线程需要等待其他线程执行完特定任务后再执行的场景,或者用于等待某些资源初始化完成。
  • CyclicBarrier 适用于一组线程需要在某个同步点相互等待,然后一起继续执行的场景,常用于多阶段任务的并行化执行。

使用方式不同:

  • CountDownLatch 中的等待线程通过调用await()方法等待计数器减为零。
  • CyclicBarrier 中的线程通过调用await()方法告诉栅栏它已经到达,然后等待其他线程也到达,当所有线程都到达后,栅栏会自动打破,线程继续执行。

Future

什么是 Future?

Future 是 Java 中的一个接口,用于表示一个异步计算的结果。它允许你在一个线程中提交一个任务,然后在其他线程中等待该任务的执行结果。Future 提供了一种异步编程的方式,可以在执行计算任务的同时执行其他操作,而不必等待计算完成。

什么是FutureTask?

FutureTask 是 Java 中一个用于表示一个异步计算任务的实用类。它实现了 Future 接口,同时也可以被用作 Runnable,因此它可以作为一个可执行的任务被提交给线程池执行,也可以用于获取异步计算的结果。

FutureTask 的主要作用有两个:

  1. 表示异步计算的结果: 你可以将一个计算任务封装到 FutureTask 中,然后将 FutureTask 提交给一个线程池或线程执行器来执行。通过 FutureTask,你可以在任务执行完成后获取到任务的执行结果。
  2. 提供了异步计算的控制: FutureTask 本身实现了 Runnable 接口,因此可以被提交给线程池执行。你可以使用 isDone() 方法来检查任务是否已完成,使用 get() 方法来等待并获取计算结果,或者使用 cancel() 方法来取消任务的执行。
阅读全文