JUC
什么是线程池?
线程池(Thread Pool)是一种基于池化技术设计,用于管理线程的生命周期、执行任务的并发集合。它旨在减少线程创建和销毁的开销,提高程序性能和资源利用率。在并发编程中,线程池是处理多个并发任务的一种有效方式,特别是在需要频繁创建和销毁线程的应用场景中。
线程池的工作原理可以概括如下:
- 初始化:在程序启动时,根据需求创建一定数量的线程,并将这些线程放入到一个池中。这些线程处于等待状态,准备执行被分配的任务。
- 任务分配:当新的任务(通常是实现了Runnable或Callable接口的对象)被提交给线程池时,线程池会检查是否有空闲的线程可用。如果有,就将任务分配给该线程执行;如果没有,线程池会根据其配置的拒绝策略(如丢弃任务、抛出异常、将任务放入等待队列等)来处理这个新任务。
- 任务执行:被分配的线程会执行其任务,并返回结果(对于实现了Callable接口的任务)。对于实现了Runnable接口的任务,通常不返回结果。
- 线程复用:线程完成任务后,不会立即销毁,而是会回到池中等待下一个任务的到来。这样,线程可以被多次复用,减少了创建和销毁线程的开销。
- 关闭:当所有任务都执行完毕,或者需要释放线程池占用的资源时,可以关闭线程池。关闭后的线程池不能再接受新的任务,但已经提交的任务会继续执行直到完成。
线程池的主要优点包括:
- 减少资源消耗:通过重用已存在的线程,可以减少线程的创建和销毁开销。控制最大并发数。
- 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性:线程池可以统一管理线程,包括线程的创建、调度、执行、销毁等。
- 隔离线程环境:可以配置独立线程池,将较慢的线程与较快的隔离开,避免相互影响。
- 实现任务线程队列缓冲策略和拒绝机制。
- 实现某些与时间相关的功能,如定时执行、周期执行等。
线程池的创建方式:
- 通过ThreadPoolExecutor构造函数来创建(推荐)。
- 通过 Executor 框架的工具类 Executors 来创建。
ThreadPoolExecutor 创建线程池对象有哪些参数?
ThreadPoolExecutor 是 Java 中用于创建线程池的一个类,它位于 java.util.concurrent 包中。创建 ThreadPoolExecutor 线程池对象时,通常需要指定以下参数:
corePoolSize(核心线程数):
线程池中保持最小活动数的线程数量。
即使没有任务需要执行,核心线程也会一直存活。
当线程数小于核心线程数时,即使有线程空闲,线程池也会优先创建新线程来处理任务。
如果为 0,当执行完任务没有任何请求时会消耗线程池;如果大于 0,即使本地任务执行完,核心线程也不会被销毁。
设置 allowCoreThreadTimeout=true(默认 false)时,核心线程会超时关闭。
该值设置过大会浪费资源,过小会导致线程的频繁创建与销毁。
maximumPoolSize(最大线程数):
线程池中允许的最大线程数。
当工作队列满了并且活动线程数达到最大线程数时,如果还有新任务提交,线程池将创建新的线程来处理任务(但会受到最大线程数的限制)。
最小为 1,最大线程数受属性CAPACITY的限制,最大为(2^29)-1(约5亿)。
如果与核心线程数设置相同代表固定大小线程池。
keepAliveTime(线程空闲时间):
非核心线程在没有任务执行时的最长存活时间。
当线程池中的线程数超过核心线程数且空闲时间达到设定值时,多余的线程将被终止,直到线程池中的线程数不超过核心线程数。
空闲时间可以通过 setKeepAliveTime(long, TimeUnit) 方法进行修改。
unit(时间单位):
用于表示核心线程数和空闲线程存活时间的单位。
常见的时间单位包括秒(TimeUnit.SECONDS)、毫秒(TimeUnit.MILLISECONDS)等。
workQueue(任务队列):
用于存储待执行的任务的阻塞队列。
当线程池中的线程都在忙碌时,新提交的任务将被添加到工作队列中等待执行。
Java 提供了多种类型的 BlockingQueue 实现,如 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue 等,可以根据具体需求选择合适的队列。
threadFactory(线程工厂):
用于创建线程的工厂。
如果没有指定 ThreadFactory,则默认使用 Executors.defaultThreadFactory() 来创建线程。
可以自定义 ThreadFactory 来创建具有特定名称、优先级等的线程。
rejectedExecutionHandler(任务拒绝处理器):
当线程池无法处理新任务时(即线程数已达到最大线程数且任务队列已满),会调用任务拒绝处理器来处理该任务。
Java 提供了几种预定义的任务拒绝策略,如 AbortPolicy(默认,直接抛出异常)、CallerRunsPolicy(由调用者执行)、DiscardPolicy(忽略任务)、DiscardOldestPolicy(丢弃队列中最老的任务)等。
也可以实现 RejectedExecutionHandler 接口来自定义处理器。
这些参数共同决定线程池的行为和性能。合理配置这些参数,可以优化线程池的使用,提高程序的并发处理能力和资源利用率。
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,
long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
Executors 有哪些创建线程池的方法?
Executors 类在 Java 中提供了多种便捷的静态方法来快速创建不同类型的线程池。
创建固定大小的线程池
方法:Executors.newFixedThreadPool(int nThreads)
描述:创建一个包含固定数量线程(由 nThreads 指定)的线程池。核心线程数也是最大线程数,不存在空闲线程,keepAliveTime = 0。该线程池使用的工作队列是无界阻塞队列 LinkedBlockingQueue。
适用场景:适用于需要控制并发线程数,且任务量相对稳定的场景。
创建可缓存的线程池
方法:Executors.newCachedThreadPool()
描述:maximumPoolSize 设置为 Integer 最大值,是高度可伸缩的线程池。该线程池使用的工作队列是没有容量的 SynchronousQueue,如果主线程提交任务的速度高于线程处理的速度,线程池会不断创建新线程,极端情况下会创建过多线程而耗尽CPU 和内存资源。
适用场景:适用于执行大量短时间异步任务的场景或负载较轻的服务器。,可以显著提高程序处理大量并发任务的效率。
创建单线程的线程池
方法:Executors.newSingleThreadExecutor()
描述:创建一个仅包含一个线程的线程池,所有任务按照提交顺序依次执行。
适用场景:适用于需要保证任务顺序执行的场景,如任务之间有依赖关系时。
创建可以执行延迟任务的线程池
方法:Executors.newScheduledThreadPool(int corePoolSize)
描述:创建一个支持定时及周期性任务执行的线程池,核心线程数由 corePoolSize 指定。除了执行常规任务外,还可以安排任务在未来某个时间点执行一次或者定期执行。
适用场景:适用于需要执行定时或周期性任务的场景,如定时清理缓存、定时检查数据等。
创建单线程的可以执行延迟任务的线程池
方法:Executors.newSingleThreadScheduledExecutor()
描述:与 newScheduledThreadPool 类似,但它是单线程的,可以执行延迟任务和周期性任务。
适用场景:与 newScheduledThreadPool 类似,但更适用于需要单线程执行定时或周期性任务的场景。
创建具有并行级别的 work-stealing 线程池(JDK 1.8 及以上)
方法:Executors.newWorkStealingPool(int parallelism)
描述:创建一个具有并行级别的 work-stealing 线程池。如果未指定并行级别,则默认为可用处理器的数量。这种线程池适用于工作窃取算法,可以提高多线程环境下的任务执行效率。
适用场景:适用于高并发环境下的任务执行,可以显著提高程序的并发性能。
为什么不推荐使用Executors 去创建线程池?
隐藏关键配置参数
- Executors提供的便捷方法通常会隐藏线程池的重要配置参数,比如线程池的大小、工作队列类型及容量、拒绝策略等。这限制了开发者对线程池行为的精确控制和优化,可能导致资源使用不当或性能问题,且难以进行进一步的定制和优化。
潜在的资源问题
内存耗尽风险:newFixedThreadPool和newSingleThreadExecutor使用的是无界队列(通常为LinkedBlockingQueue),如果生产任务的速度超过消费速度,队列会无限增长,最终可能导致内存耗尽(Out Of Memory Error)。
不合理的线程数目:newCachedThreadPool创建的线程池理论上可以无限制地创建线程(虽然实际受限于系统资源),当大量短期异步任务提交时,可能会迅速创建大量线程,消耗过多系统资源。
大厂最佳实践
- 阿里巴巴Java开发手册等业界指南明确不建议使用Executors创建线程池,而是推荐直接通过ThreadPoolExecutor来显式地指定线程池参数,以提高代码的健壮性和可维护性。这一最佳实践反映了在生产环境中对线程池配置精细控制的需求。
异常处理策略
- Executors创建的线程池默认使用一种默认的异常处理策略,通常只会将异常打印到标准输出或记录到日志中,而不会提供更多的控制。这可能导致异常被忽略或无法及时处理,从而影响程序的稳定性和可靠性。
不支持动态调整
- 某些线程池应该支持动态调整线程数量以应对不同的负载情况。然而,Executors创建的线程池通常是固定大小的,不容易进行动态调整,这限制了线程池在负载变化时的适应性和灵活性。
综上所述,虽然Executors提供了快速创建线程池的简便方法,但由于其潜在的问题和局限性,对于生产环境中的应用程序,更推荐直接使用ThreadPoolExecutor来创建线程池,并根据实际需求进行细致的配置和调整。
在Executor、ExecutorService、Executors的区别?
功能差异
Executor:是Java中的一个接口,它代表了一种异步执行任务的方式。Executor接口定义了一个execute(Runnable command)方法,用于接收一个实现了Runnable接口的任务并执行。
ExecutorService:是Executor的子接口,它在Executor的基础上增加了更多的功能。ExecutorService接口不仅继承了Executor的execute方法,还定义了如submit、shutdown、shutdownNow等用于任务提交、线程池关闭等的方法。
Executors:是Java并发库java.util.concurrent中的一个工具类,提供了多种静态方法来创建不同类型的ExecutorService实例。这些静态方法使得创建线程池变得简单快捷。
使用场景
Executor:适用于简单的任务执行场景,当不需要复杂的线程池管理功能时,可以直接使用Executor接口的实现类(如ThreadPoolExecutor)来执行任务。
ExecutorService:在需要线程池管理功能的场景下,如需要控制线程池的生命周期、获取任务执行结果等,应该使用ExecutorService接口的实现类。
Executors:在快速创建线程池的场景下,可以使用Executors类提供的工厂方法来创建ExecutorService实例。这些工厂方法根据传入的参数不同,可以创建出固定大小、可缓存、单线程等多种类型的线程池。
线程池中阻塞队列的作用?
- 缓冲任务:阻塞队列可以暂时缓存提交的任务,保持线程池的稳定性和可控性。
- 控制任务提交速率:阻塞队列的容量限制了可以提交的任务数量,通过控制队列的大小可以限制任务的提交速率,避免任务过多导致系统负载过重。这有助于更好地平衡系统资源的利用,防止系统被任务压垮。
- 保持任务顺序性:线程池中的阻塞队列可以按照一定的规则(如先进先出FIFO或优先级等)来决定任务的执行顺序,保证任务按照一定的顺序执行,避免任务之间的乱序执行导致的问题。
- 资源管理:当线程池中没有任务执行时,线程池可以利用阻塞队列的阻塞特性挂起线程,使其进入等待(wait)状态,从而释放CPU资源。这种机制有助于管理线程的生命周期,避免线程在无任务时持续占用CPU资源。
为什么线程池中先添加任务到队列而不是先创建最大线程数?
- 节约资源:创建线程是一个相对消耗资源的操作。如果一开始就创建大量线程,而实际任务量并未达到需要这么多线程的程度,将会浪费系统资源。通过先将任务添加到队列中,可以尽可能地利用现有的线程来处理任务,避免无谓地创建新的线程。
- 控制线程数量:线程池设计的初衷是为了有效地利用系统资源和控制线程池的运行状态。通过在添加任务时优先考虑将任务添加到队列中,可以限制线程数量在一定范围内,避免线程数量无限增长。这有助于降低系统资源消耗和提高系统的稳定性。
- 避免线程爆炸:如果一开始就创建大量的线程,可能会导致线程数量过多,处理不过来,进而引发“线程爆炸”现象,即系统负载过重,甚至崩溃。通过先添加到队列的方式,可以有效地控制线程数量,避免系统崩溃。
- 提高系统响应性:在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率,可能会导致系统响应变慢。而将任务先放入队列中,可以让现有的线程继续执行,同时队列中的任务也可以逐步被处理,从而提高系统的响应性。
线程池中的阻塞队列和先添加任务到队列的策略共同协作,有助于提高线程池的效率、性能和稳定性。
线程池常用的阻塞队列有哪些?
线程池中常用的阻塞队列主要包括以下几种:
ArrayBlockingQueue
特点:
是一个由数组支持的有界阻塞队列。
此队列按 FIFO(先进先出)原则对元素进行排序。
需要在创建时指定容量,且一旦创建后不能更改。
使用单个锁来控制插入和移除操作,这可能导致这两种操作不能完全并行。
适用场景:
- 任务量有限且已知的情况,可以根据需求设置合理的容量,避免内存占用过大。
LinkedBlockingQueue
特点:
是一个由链表结构支持的可选有界队列。
吞吐量通常要高于 ArrayBlockingQueue。
内部使用两个锁,一个用于入队操作,一个用于出队操作,允许这两个操作并行进行,提高了队列在并发环境中的吞吐量。
默认和最大长度为 Integer.MAX_VALUE,相当于无界(值非常大:2^31-1)。
适用场景:
适用于任务量不断增加的情况,可以无限制地添加任务,适合使用在不限制任务数量的场景。
但也需要注意,无界队列可能会导致内存占用过大,因此需要谨慎使用。
SynchronousQueue
特点:
是一个没有容量的阻塞队列。
每个插入操作必须等待相应的删除操作,反之亦然。
插入操作必须等待消费者来获取任务,反之亦然。
性能通常高于 ArrayBlockingQueue 和 LinkedBlockingQueue。
适用场景:
CachedThreadPool
适用于任务执行的过程需要严格的同步,任务的执行和处理是一对一的关系。
如果没有立即找到匹配的生产者或消费者,插入和删除操作都会被阻塞。
PriorityBlockingQueue
特点:
是一个支持优先级排序的无界阻塞队列。
队列中元素的排序可以根据自然排序,或者根据构造时提供的 Comparator 来进行。
适用场景:
- 适用于需要按照优先级处理任务的场景。
DelayedWorkQueue
特点:
内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序。
内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。
添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程
适用场景:
- 适用于延迟处理任务的场景。
在选择线程池中的阻塞队列时,需要根据实际的应用场景和需求来选择合适的队列。如果任务量有限且已知,可以选择 ArrayBlockingQueue;如果任务量不断增加且无限制,可以选择 LinkedBlockingQueue,但需要注意内存管理;如果任务执行需要严格的同步,可以选择 SynchronousQueue;如果任务需要按照优先级处理,可以选择 PriorityBlockingQueue。同时,也需要注意选择合适的队列容量,避免队列满或资源浪费的问题。
阻塞队列有哪些?
阻塞队列支持阻塞插入和移除,当队列满时,阻塞插入元素的线程直到队列不满。当队列为空时,获取元素的线程会被阻塞直到队列非空。阻塞队列常用于生产者和消费者的场景,阻塞队列就是生产者用来存放元素,消费者用来获取元素的容器。
- ArrayBlockingQueue,由数组组成的有界阻塞队列,默认情况下不保证线程公平,有可能先阻塞的线程最后才访问队列。插入和移除操作使用单个锁,因此性能可能不如某些其他实现。
- LinkedBlockingQueue,由链表结构组成的有界阻塞队列,队列的默认和最大长度为 Integer 最大值。使用两个锁(一个用于入队,一个用于出队)来提高并发性能。吞吐量通常高于ArrayBlockingQueue。
- PriorityBlockingQueue,支持优先级的无界阻塞队列,默认情况下元素按照升序排序。可自定义 compareTo 方法指定排序规则,或者初始化时指定 Comparator 排序,不能保证同优先级元素的顺序。
- DelayQueue,支持延时获取元素的无界阻塞队列,使用优先级队列实现。创建元素时可以指定多久才能从队列中获取当前元素,只有延迟期满时才能从队列中获取元素,适用于缓存和定时调度。
- SynchronousQueue,不存储元素的阻塞队列,每一个 put 必须等待一个 take,反之亦然**。默认使用非公平策略**,也支持公平策略,适用于传递性场景,吞吐量高。
- LinkedTransferQueue,链表组成的无界阻塞队列,相对于其他阻塞队列多了 tryTransfer 和 transfer 方法。transfer方法:如果当前有消费者正等待接收元素,可以把生产者传入的元素立刻传输给消费者,否则会将元素放在队列的尾节点并等到该元素被消费者消费才返回。tryTransfer 方法用来试探生产者传入的元素能否直接传给消费者,如果没有消费者等待接收元素则返回 false,和 transfer 的区别是无论消费者是否消费都会立即返回。
- LinkedBlockingDeque,链表组成的双向阻塞队列,可从队列的两端插入和移出元素,多线程同时入队时减少了竞争。
实现原理:使用通知模式实现,生产者往满的队列里添加元素时会阻塞,当消费者消费后,会通知生产者当前队列可用。当往队列里插入一个元素,如果队列不可用,阻塞生产者主要通过 LockSupport 的 park 方法实现,不同操作系统中实现方式不同,在 Linux 下使用的是系统方法 pthread_cond_wait 实现。
选择哪种阻塞队列取决于具体的应用场景和性能需求。例如,如果你需要严格控制队列的大小,并且希望生产者在没有可用空间时等待,那么ArrayBlockingQueue或指定容量的LinkedBlockingQueue可能是合适的选择。如果你需要传递性任务处理,那么SynchronousQueue可能是最佳选择。如果你需要按优先级处理任务,那么PriorityBlockingQueue将是理想的选择。
线程池任务拒绝策略有哪些?
线程池任务拒绝策略是当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize时,如果还有任务到来就会采取的策略。Java中线程池的拒绝策略主要有以下几种:
AbortPolicy(直接抛出异常)
行为:丢弃任务并抛出RejectedExecutionException异常。
特点:这是线程池默认的拒绝策略。
使用场景:适用于关键业务场景,以便在系统不能承载更大并发量时,通过异常及时发现并处理。
DiscardPolicy(丢弃任务)
行为:丢弃任务,但是不抛出异常。
特点:静默地丢弃后续提交的任务,可能导致无法发现系统的异常状态。
使用场景:适用于那些无关紧要的业务场景,如统计阅读量的博客网站等。
DiscardOldestPolicy(丢弃最老的任务)
行为:丢弃队列最前面的任务,然后重新提交被拒绝的任务。
特点:这种策略会丢弃队列中等待时间最长的任务,以腾出空间给新任务。但也存在数据丢失的风险。
使用场景:适用于允许丢弃老任务的业务场景,如消息发布和修改的场景中,当更新的消息到来时,可以丢弃未执行且版本较低的消息。
CallerRunsPolicy(调用者运行)
行为:由调用线程(提交任务的线程)处理该任务。
特点:如果线程池未关闭,则直接在调用线程中执行任务,避免了任务的丢弃。但可能会影响调用线程的性能。
使用场景:适用于不允许任务失败、对性能要求不高且并发量较小的场景。
注意事项
- 拒绝策略的选择应根据具体业务场景和需求来决定。
- 线程池中的三个重要参数(corePoolSize、workQueue、maximumPoolSize)会影响拒绝策略的执行。
- 当线程池关闭或达到饱和状态时,新提交的任务将会被拒绝,并触发相应的拒绝策略。
线程池处理任务的流程?
线程池处理任务的流程可以概括为以下几个步骤,这些步骤确保了线程池能够高效地管理和执行提交给它的任务:
- 任务提交
- 当向线程池提交一个任务时,线程池会开始处理这个任务的流程。任务的提交可以通过execute()或submit()方法完成,其中submit()方法能够返回Future对象以便获取任务执行结果。
- 判断空闲线程
- 线程池首先会检查当前是否有空闲线程。如果有空闲线程,则直接分配一个空闲线程来执行新任务。
- 核心线程数判断
- 核心线程数未满:如果当前没有空闲线程,线程池会进一步检查当前“存活线程数”是否小于核心线程数(corePoolSize)。如果是,线程池会创建一个新的核心线程来执行新任务。
- 工作队列判断
- 核心线程数已满:如果当前存活线程数已经等于核心线程数,但仍有新任务提交,线程池会检查其工作队列(用于存放等待执行的任务)是否已满。
- 工作队列未满:如果工作队列未满,新任务会被放入工作队列中等待执行。一旦有空闲线程出现,就会按照“先进先出”的规则从工作队列中取出任务执行。
- 工作队列已满:如果工作队列已满,线程池会进入下一步判断。
- 最大线程数判断
- 最大线程数判断:当工作队列已满时,线程池会检查当前存活线程数是否已经达到最大线程数(maximumPoolSize)。
- 未达到最大线程数:如果未达到最大线程数,线程池会创建一个新的非核心线程来执行新任务。
- 达到最大线程数:如果已经达到最大线程数,线程池会采取拒绝策略来处理新任务。
- 拒绝策略
- 当线程池无法处理新任务时(即工作队列已满且已达到最大线程数),它会根据配置的拒绝策略来处理新任务。Java中提供了几种预定义的拒绝策略,如AbortPolicy(默认,直接抛出异常)、DiscardPolicy(静默丢弃任务)、DiscardOldestPolicy(丢弃队列中最老的任务,然后尝试提交新任务)、CallerRunsPolicy(由提交任务的线程来执行该任务)。
- 线程池状态管理
- 线程池的状态管理也是其处理任务流程中的一个重要方面。线程池的状态可以分为RUNNING(运行状态)、SHUTDOWN(关闭状态)、STOP(停止状态)、TIDYING(整理状态)、TERMINATED(终止状态)。线程池的状态变化会影响其对新任务的接受和处理方式。
如何判断是 CPU 密集任务还是 IO 密集任务?
判断一个任务是CPU密集型还是IO密集型,主要依据是任务在执行过程中,CPU和IO资源的使用情况。
CPU使用率
CPU密集型任务:在执行过程中,会长时间占用CPU资源,导致CPU使用率较高。这类任务通常需要大量的计算,如视频编码、图像处理、复杂的数学计算等。
IO密集型任务:虽然也会占用CPU时间,但大部分时间都在等待IO操作完成(如磁盘读写、网络通信等),因此CPU使用率相对较低,大部分时间CPU是空闲的。
等待时间
IO密集型任务:在执行过程中,会有大量的时间花在等待IO操作完成上。例如,从磁盘读取数据、等待网络响应等。
CPU密集型任务:等待时间相对较少,大部分时间都在执行计算。
并发能力
IO密集型任务:由于IO操作可以并行处理,因此这类任务通常具有较好的并发能力。例如,网络服务器可以同时处理多个客户端的请求。
CPU密集型任务:由于CPU资源有限,同时运行的CPU密集型任务数量会受到限制。过多的CPU密集型任务会导致CPU过载,降低系统性能。
监控工具
使用性能监控工具(如Linux下的top、htop、vmstat、iostat,或Windows的性能监视器)来观察CPU使用率和IO等待时间等指标。
分析应用程序的日志和调试信息,了解程序在执行过程中的具体行为。
编程模式
CPU密集型任务:通常涉及大量的循环和计算,如算法的实现、数据的处理等。
IO密集型任务:通常涉及文件操作、网络通信等,程序中会有大量的IO调用。
实际应用场景
CPU密集型任务:科学计算、图像处理、视频编码、加密解密等。
IO密集型任务:数据库操作、文件服务器、Web服务器等。
判断一个任务是CPU密集型还是IO密集型,需要综合考虑CPU使用率、等待时间、并发能力、监控数据以及编程模式等多个方面。在实际开发中,了解任务的类型有助于选择合适的编程模型、优化策略以及系统配置,从而提高程序的性能和效率。
如何设定线程池的大小?
线程池大小设置过大或者过小都会有问题,合适的才是最好。
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
有一个简单并且适用面比较广的经验公式:
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
线程数更严谨的计算的方法应该是:
最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),
其中 WT(线程等待时间)= 线程运行总时间 - ST(线程计算时间)。
线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。
我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。
CPU 密集型任务的 WT/ST 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。
IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程)。
考虑其他因素
- 系统负载:如果系统已经处于高负载状态,增加线程池的大小可能会加剧系统负担,导致性能下降。因此,在设定线程池大小时需要考虑系统的当前负载情况。
- 内存大小:线程池的大小也受到系统可用内存的限制。过多的线程会消耗大量的内存资源,可能导致内存溢出等问题。因此,在设定线程池大小时需要确保系统有足够的内存来支持线程的运行。
- 任务执行时间:短任务可以适当增加线程池的大小以减少任务之间的等待时间;而长任务则不宜设置过大的线程池,以免造成过多的线程竞争CPU资源。
注意事项
- 在设置线程池大小时,需要综合考虑多种因素,并根据实际情况进行调整。
- 不要盲目追求大线程池,因为过大的线程池可能会导致资源浪费和性能下降。
- 线程池的核心线程数和最大线程数需要根据任务类型和系统负载进行设置,以确保线程池能够高效地处理任务。
总之,设定线程池的大小是一个需要根据实际情况进行调整的过程。通过理解任务类型、考虑其他因素、使用经验法则和公式以及动态调整等方法,可以设定一个合理的线程池大小以提高系统性能。
如何动态修改线程池的参数?
美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:
- corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
- maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- workQueue**😗* 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
格外需要注意的是corePoolSize, 程序运行期间的时候,我们调用 setCorePoolSize()这个方法的话,线程池会首先判断当前工作线程数是否大于corePoolSize,如果大于的话就会回收工作线程。
另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。
如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:
- Hippo4j:异步线程池框架,支持线程池动态变更&监控&报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。
- Dynamic TP:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。
如何设计一个能够根据任务的优先级来执行的线程池?
- 定义任务优先级
首先,你需要定义一个表示任务优先级的枚举或整数。例如,可以使用Integer来表示优先级,其中较小的数字表示较高的优先级。
public interface PrioritizedTask extends Runnable {
int getPriority();
}
- 使用优先级队列
在ThreadPoolExecutor中使用PriorityBlockingQueue,这是一个支持优先级的无界阻塞队列。但是,请注意PriorityBlockingQueue要求队列中的元素实现Comparable接口或构造时提供Comparator。
PriorityBlockingQueue<Runnable> workQueue = new PriorityBlockingQueue<>(11, Comparator.comparingInt(task -> {
if (task instanceof PrioritizedTask) {
return ((PrioritizedTask) task).getPriority();
}
// 默认优先级,或者抛出异常
return Integer.MAX_VALUE;
}));
注意:这里假设所有通过线程池提交的任务都实现了PrioritizedTask接口。如果可能提交非优先级任务,则需要在Comparator中进行适当的处理。
- 自定义线程池
你可以通过扩展ThreadPoolExecutor来创建一个自定义的线程池,其中使用上面定义的优先级队列。
public class PriorityThreadPoolExecutor extends ThreadPoolExecutor {
public PriorityThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
// 你可以在这里添加其他自定义逻辑,比如拒绝策略等
}
- 使用自定义线程池
PriorityThreadPoolExecutor executor = new PriorityThreadPoolExecutor(
5, // 核心线程数
10, // 最大线程数
60L, // 空闲线程存活时间
TimeUnit.SECONDS,
new PriorityBlockingQueue<>(10, Comparator.comparingInt(task -> {
if (task instanceof PrioritizedTask) {
return ((PrioritizedTask) task).getPriority();
}
return Integer.MAX_VALUE;
}))
);
// 提交任务
executor.execute(new PrioritizedTask() {
@Override
public void run() {
// 任务逻辑
}
@Override
public int getPriority() {
// 返回任务优先级
return 1; // 假设1是最高优先级
}
});
注意事项
- 优先级队列可能会导致饥饿问题,即低优先级的任务可能永远得不到执行(如果一直有高优先级的任务提交)。确保你的系统中有适当的机制来处理这种情况。
- 优先级反转是另一个潜在问题,其中低优先级任务持有高优先级任务所需的资源。在设计系统时,请考虑这种情况。
- 线程池的大小(核心线程数和最大线程数)应根据你的应用需求和系统资源进行调整。
- 线程池的参数(如空闲线程存活时间)也应根据你的应用场景进行调整,以优化性能和资源利用率。
- PriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 OOM。
- 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。
对于 OOM 这个问题的解决可以继承PriorityBlockingQueue 并重写一下 offer 方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。
饥饿问题这个可以通过优化设计来解决,比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
线程池中 submit()和 execute()方法有什么区别?
ThreadPoolExecutor 中的 submit() 和 execute() 方法都用于向线程池提交任务以供执行,但它们之间存在一些关键的区别,主要体现在任务的处理方式和返回值上。
execute() 方法
- 无返回值:execute() 方法用于提交不需要返回值的任务给线程池,它接受一个实现了 Runnable 接口的任务对象。
- 异常处理:如果任务执行时抛出了异常,并且该异常没有被任务内部捕获,那么这个异常将由 ThreadPoolExecutor 捕获。这个异常默认情况下会被忽略,除非在创建 ThreadPoolExecutor 时指定了 UncaughtExceptionHandler。
- 用途:适用于执行那些不需要知道执行结果的任务,如后台数据清理、日志记录等。
submit() 方法
- 有返回值:submit() 方法用于提交需要返回值的任务给线程池。它接受一个实现了 Callable 接口的任务对象,Callable 可以返回一个结果,并且可能抛出一个异常。
- Future 对象:submit() 方法返回一个 Future 对象,可以通过这个对象来判断任务是否完成,等待任务完成(并获取结果),或者取消任务。如果任务完成,你可以通过 Future.get() 方法来获取结果,但这个方法会阻塞当前线程直到任务完成。
- 异常处理:如果任务执行时抛出了异常,并且该异常没有被任务内部捕获,那么这个异常会被封装在返回的 Future 对象中。当你调用 Future.get() 方法时,如果任务执行时抛出了异常,那么这个方法会重新抛出这个异常(作为 ExecutionException 的原因)。
- 用途:适用于执行那些需要知道执行结果的任务,如数据库查询、远程服务调用等。
如何关闭线程池?
关闭线程池通常涉及两个步骤:首先,优雅地停止接受新任务;然后,等待当前正在执行的任务完成。在Java中,ThreadPoolExecutor 类提供了几种方法来帮助关闭线程池。
shutdown() 方法
调用此方法后,线程池将不再接受新任务。但是,它会等待所有已提交的任务(包括正在执行的和在队列中等待的任务)完成。
ExecutorService executor = Executors.newFixedThreadPool(10);
// ... 提交任务到线程池
// 关闭线程池
executor.shutdown();
// 等待所有任务完成
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
// 超时后尝试停止正在执行的任务
executor.shutdownNow();
}
} catch (InterruptedException e) {
// 当前线程在等待过程中被中断
executor.shutdownNow();
Thread.currentThread().interrupt(); // 保留中断状态
}
shutdownNow() 方法
shutdownNow() 方法尝试立即停止所有正在执行的任务,停止处理等待的任务,并返回等待执行的任务列表。这个方法不会等待正在执行的任务完成。
ExecutorService executor = Executors.newFixedThreadPool(10);
// ... 提交任务到线程池
// 尝试立即关闭线程池
List<Runnable> droppedTasks = executor.shutdownNow();
// droppedTasks 包含了那些被丢弃的任务
// 通常,你可能需要对 droppedTasks 进行一些处理,比如记录日志
注意事项
- 在调用 shutdown() 或 shutdownNow() 方法之后,应该避免再向线程池提交新的任务。
- 如果你的应用需要优雅地关闭线程池并等待所有任务完成,那么通常应该使用 shutdown() 方法,并可能配合 awaitTermination() 方法来等待线程池真正关闭。
- 如果你的应用需要立即停止所有任务(即使它们还没有完成),那么可以使用 shutdownNow() 方法。但是,请注意,这可能会导致数据丢失或其他问题,因为正在执行的任务可能会被突然中断。
- 调用 shutdown() 或 shutdownNow() 方法后,ExecutorService 将不再接受新任务,但它本身仍然是一个有效的对象,你可以用它来检查关闭状态或等待任务完成。然而,一旦所有的任务都完成了,线程池中的所有线程都将被终止,并且 ExecutorService 对象将不再可用。如果你尝试在所有任务完成后提交新任务,将会抛出 RejectedExecutionException 异常。
线程池都有哪些状态?
线程池共有五种状态,这些状态定义了线程池如何接收和处理任务。
RUNNING(运行状态)
状态说明:线程池处于RUNNING状态时,它能够接收新任务,也能够对已经添加的任务进行处理。线程池一被创建,其状态就是RUNNING。
状态转换:线程池被创建后即进入RUNNING状态,如果不手动调用关闭方法,线程池在整个程序运行期间都会保持这个状态。
SHUTDOWN(关闭状态)
状态说明:线程池已经被关闭了,不再接收新任务,但是还会继续处理队列中剩余的任务。
状态切换:调用线程池的shutdown()方法后,线程池的状态就会由RUNNING转为SHUTDOWN。
后续处理:在SHUTDOWN状态下,线程池不会接受新的任务,但会继续执行队列中已有的任务,直到这些任务都执行完毕。
STOP(停止状态)
状态说明:线程池处于STOP状态,此时线程池不再接收新任务,不处理已经添加进来的任务,并且会中断正在处理的任务。
状态切换:调用线程池的shutdownNow()方法后,线程池的状态就会由RUNNING或SHUTDOWN转为STOP。
后续处理:在STOP状态下,线程池会尝试停止所有正在执行的任务,并忽略队列中等待的任务。如果任务在执行过程中不能被中断,那么这些任务可能会继续执行直到完成。
TIDYING(整理状态)
状态说明:当线程池处于SHUTDOWN或STOP状态,并且所有的任务都执行完毕后(包括任务队列中的任务),线程池会进入TIDYING状态。此时,线程池中的活动线程数降为0。
后续处理:在TIDYING状态下,会执行线程池的terminated()钩子函数。这个方法是空的,但用户可以通过重载这个方法来在线程池变为TIDYING时执行特定的操作。
TERMINATED(销毁状态)
状态说明:线程池彻底终止,进入TERMINATED状态。一旦线程池达到TERMINATED状态,就不能再重新启动了。
状态切换:线程池处在TIDYING状态时,执行完terminated()方法后,线程池状态就会由TIDYING转为TERMINATED。
线程池的选择策略有哪些?
根据任务性质:CPU 密集型、IO 密集型和混合型。
CPU 密集型任务应配置尽可能小的线程,如配置 N CPU + 1 个线程的线程池。
IO 密集型任务线程并不是一直在执行任务,应配置尽可能多的线程,如 2*N CPU。
混合型的任务,如果可以拆分,将其拆分为一个 CPU 密集型任务和一个 IO 密集型任务,两个任务执行的时间相差不大那么分解后的吞吐量将高于串行执行的吞吐量,如果相差太大则没必要分解。
根据任务优先级。优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 处理。
根据任务执行时间。执行时间不同的任务可以交给不同规模的线程池处理,或者使用优先级队列让执行时间短的任务先执行。
根据任务依赖性:是否依赖其他资源,如数据库连接。依赖数据库连接池的任务,由于线程提交 SQL 后需要等待数据库返回的结果,等待的时间越长 CPU 空闲的时间就越长,因此线程数应该尽可能地设置大一些,提高 CPU 的利用率。
使用线程池要注意些什么 ?
使用线程池时,需要注意以下几个方面以确保线程池能够高效、稳定地运行,同时避免潜在的问题:
线程池类型与需求匹配
- 根据应用的实际需求选择合适的线程池类型(如FixedThreadPool、CachedThreadPool、SingleThreadExecutor、ScheduledThreadPool等)。
- 考虑任务的性质(CPU密集型、IO密集型)、任务的优先级、任务的执行时间等因素来选择线程池。
合理配置线程池参数
- 核心线程数(corePoolSize):根据CPU核心数、任务量等因素来设置,确保线程池有足够的线程来处理任务。
- 最大线程数(maximumPoolSize):根据系统资源和任务量来设置,避免过多的线程导致资源竞争和浪费。
- 线程空闲时间(keepAliveTime):对于非核心线程,设置合适的空闲时间以避免长时间占用资源。
- 任务队列(workQueue):选择合适的队列类型,根据任务的执行速度和提交速度来平衡队列的容量。
- 给线程池线程命名,传入ThreadFactory并制定给线程池,在实现的ThreadFactory中设定计数和调用
Thread.setName()
。
监控与调优
- 监控线程池状态:通过JMX或自定义监控工具来监控线程池的状态,包括线程数、任务队列长度、拒绝任务数等指标。
- 动态调整参数:根据监控数据和业务需求,动态调整线程池的参数,如核心线程数、最大线程数等。
- 性能调优:通过调整任务调度策略、优化任务执行逻辑等方式来提高线程池的性能。
避免资源耗尽
- 限制任务提交速度:避免过快地提交任务到线程池,导致任务堆积和内存溢出。
- 合理设置队列容量:避免使用无界队列,因为无界队列可能导致内存耗尽。
- 处理拒绝策略:合理设置拒绝策略,避免在任务被拒绝时导致系统崩溃或数据丢失。
线程安全问题
- 确保任务线程安全:如果任务需要访问共享资源,需要确保任务的线程安全性,使用同步机制(如synchronized、Lock)来保护共享资源。
- 避免死锁:合理设计锁的获取和释放顺序,避免产生死锁。
优雅关闭线程池
- **使用****shutdown()或shutdownNow()**方法:在应用程序关闭或不再需要线程池时,应使用这些方法来关闭线程池,确保所有任务都被正确处理。
- 等待线程池关闭:在调用shutdown()后,应使用awaitTermination()方法来等待线程池完全关闭,避免在线程池还未关闭时就退出应用程序。
异常情况处理
- 处理任务异常:在任务执行过程中可能发生的异常需要被捕获和处理,避免异常导致线程终止或影响其他任务的执行。
- 监控线程池异常:监控线程池本身的异常,如任务拒绝异常、线程创建失败异常等,并及时处理这些异常。
不建议使用 Executors 创建线程池
- Executors 提供的很多方法默认使用的都是无界的 LinkedBlockingQueue,高负载情境下,无界队列很容易导致 OOM,而 OOM 会导致所有请求都无法处理,这是致命问题。所以强烈建议使用有界队列。
总之,使用线程池时需要注意线程池的选择、配置、监控、调优、资源耗尽、线程安全、优雅关闭和异常情况处理等方面的问题,以确保线程池能够高效、稳定地运行。
Future 类有什么用?
Future类是Java并发包中的一个接口,主要用于表示一个异步计算的结果。以下是Future类的主要用途和特性:
- 异步任务管理
异步计算:Future提供了一种方式,使得我们可以在计算进行的同时获取计算的结果。它允许程序在等待耗时操作(如数据库查询、复杂计算等)完成时继续执行其他任务。
任务提交与监控:通过Future对象,可以提交一个任务给线程池或其他执行器执行,并立即返回一个Future对象。这个Future对象代表了任务的执行结果,并允许我们监控任务的执行状态。
结果获取与状态检查
结果获取:Future接口提供了get()方法,用于获取异步计算的结果。如果计算尚未完成,调用get()方法的线程将被阻塞,直到计算完成并返回结果。此外,get()方法还有一个带超时参数的版本,允许在等待一定时间后如果任务仍未完成,则抛出TimeoutException。
状态检查:Future还提供了isDone()和isCancelled()方法,分别用于检查任务是否已经完成和是否被取消。这些方法允许我们在不阻塞当前线程的情况下,了解任务的执行状态。
任务取消
取消任务:Future接口允许我们尝试取消正在执行或等待执行的任务。通过调用cancel(boolean mayInterruptIfRunning)方法,我们可以请求取消任务。如果任务尚未开始执行,则此请求将保证任务不会被执行。如果任务已经开始执行,并且mayInterruptIfRunning参数为true,则尝试中断正在执行任务的线程。但是,需要注意的是,并非所有任务都可以被取消,这取决于任务的具体实现。
结合Callable接口
Callable与Future:Future通常与Callable接口一起使用。与Runnable接口不同,Callable接口允许任务有返回值。当我们向线程池提交一个实现了Callable接口的任务时,线程池会返回一个Future对象,该对象表示异步计算的结果。
提高程序效率
异步编程思想:通过将耗时任务提交给线程池异步执行,并通过Future监控和获取结果,我们可以显著提高程序的运行效率。这种异步编程的思想在现代软件开发中越来越受欢迎,因为它能够更好地利用多核处理器的计算能力,并减少不必要的等待时间。
综上所述,Future类是Java并发编程中一个非常重要的接口,它提供了异步任务管理、结果获取与状态检查、任务取消等功能,并结合Callable接口实现了任务的返回值处理。这些特性使得Future在并发编程中发挥着重要的作用。
Callable 和 Future 有什么异同?
可以通过 FutureTask 来理解 Callable 和 Future 之间的关系。
FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
FutureTask 不光实现了 Future接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行。
FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。
FutureTask相当于对Callable 进行了封装,管理着任务执行的情况,存储了 Callable 的 call 方法的任务执行结果。
CompletableFuture 类有什么用?
CompletableFuture 类是 Java 8 引入的一个功能强大的类,主要用于异步编程和并发任务处理。它实现了 Future 和 CompletionStage 接口,提供了一套丰富的方法来处理异步操作和多个任务的结果。以下是 CompletableFuture 类的主要用途和特性:
异步执行
非阻塞执行:CompletableFuture 允许任务在后台线程中异步执行,不会阻塞主线程,从而提高了应用程序的响应性和性能。
灵活的任务提交:通过 supplyAsync 和 runAsync 方法,可以方便地提交有返回值和无返回值的异步任务。这些方法还可以接受自定义的 Executor 来管理任务的执行线程。
链式操作
构建复杂的任务依赖关系:CompletableFuture 提供了多种链式操作的方法,如 thenApply、thenAccept、thenCompose 等,允许开发者以函数式编程的方式构建复杂的任务依赖关系,实现高效的任务调度和执行。
结果转换和组合:通过链式操作,可以方便地对异步任务的结果进行转换和组合,从而构建出更加复杂和强大的异步逻辑。
异常处理
丰富的异常处理方法:CompletableFuture 提供了 exceptionally 和 handle 等方法,允许开发者在任务执行失败时以异步的方式处理异常,实现灵活的错误处理和回退机制。
异常传播:在链式操作中,如果某个任务执行失败并抛出了异常,这个异常会被自动传播到链中的下一个任务,除非使用了异常处理方法进行了拦截和处理。
多任务组合
并发执行和结果汇总:CompletableFuture 支持多个任务的并发执行和结果组合。通过 allOf 和 anyOf 方法,可以等待所有或任意一个任务完成,并将结果汇总起来进行后续处理。
灵活的任务调度:结合链式操作和多任务组合,CompletableFuture 可以实现更加灵活和高效的任务调度策略,满足各种复杂的并发需求。
提高代码的可读性和可维护性
函数式编程风格:CompletableFuture 的设计灵感来源于函数式编程中的 Promises/Futures 模式,其 API 以函数式编程风格为主,使得异步代码的编写更加简洁和直观。
减少回调地狱:相比于传统的回调机制,CompletableFuture 通过链式调用和函数式接口,减少了回调函数的嵌套层次,从而避免了回调地狱(Callback Hell)的问题。
AQS 是什么?
AQS,全称AbstractQueuedSynchronizer,即抽象的队列式同步器,是一种用来构建锁和同步器的框架。它定义了一套多线程访问共享资源的同步器框架,许多我们常用的同步器都是基于它来实现的,如ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier等并发类。
一、AQS的基本框架
- 同步状态管理:AQS使用一个
volatile int state
变量作为共享资源成员变量来表示同步状态,通过内置的FIFO(先进先出)队列来完成获取资源线程的排队工作。 - CLH队列:AQS通过内部类Node来实现CLH队列,这是一种虚拟的双向队列,用于存储等待获取资源的线程。
- 资源共享方式:AQS支持两种资源共享方式,即独占(Exclusive)和共享(Share)。独占模式只允许一个线程获取同步状态,而共享模式允许多个线程同时获取同步状态。
二、AQS的工作原理
- 获取资源:当线程尝试获取资源时,首先会调用自定义同步器的tryAcquire方法(对于独占模式)或tryAcquireShared方法(对于共享模式)来尝试直接获取资源。如果获取成功,则线程直接返回;如果获取失败,则线程会被包装成一个Node节点并加入到等待队列中。
- 等待与唤醒:在等待队列中的线程会不断地循环尝试获取资源,条件是当前节点为head节点的直接后继节点时才会尝试。如果获取失败,线程会阻塞自己直到被唤醒。持有锁的线程在释放锁时,会唤醒队列中的后继线程。
三、AQS的优势
- 简化同步器的实现:AQS解决了在实现同步器时涉及的大量细节问题,如自定义标准同步状态、FIFO同步队列等,使得开发者可以更加专注于同步逻辑的实现。
- 提高性能:AQS使用CAS(Compare-And-Swap)操作对同步状态进行原子更新,减少了锁的竞争和线程切换的开销,从而提高了并发性能。
四、AQS的应用场景
- 锁的实现:如ReentrantLock等基于AQS实现的锁,能够支持可重入、公平锁/非公平锁等特性。
- 同步器的实现:如Semaphore、CountDownLatch、CyclicBarrier等同步器,也是基于AQS实现的,用于控制线程之间的协作和通信。
五、注意事项
- AQS的子类在实现时,需要根据具体的同步需求来实现tryAcquire、tryRelease(独占模式)或tryAcquireShared、tryReleaseShared(共享模式)等模板方法。
- AQS的独占模式是不响应中断的,即加入到同步队列中的线程如果因为中断而被唤醒的话,不会立即返回,而是会再次尝试获取锁或挂起。
AQS是Java并发包中一个非常重要的组件,它提供了一种高效且灵活的同步机制,使得开发者可以更加方便地实现各种同步器和锁。
ASQ 怎样表示维护锁的状态?
同步状态(state):ASQ使用一个volatile修饰的int类型的成员变量
private volatile int state;
来表示同步状态。这个状态是锁的核心,用于表示锁是否被某个线程持有,以及持有的重入次数等。获取和修改状态:
getState()和setState()方法采用final修饰,限制AQS的子类重写它们两。
compareAndSetState方法使用CAS(Compare-And-Swap)操作来保证状态的更新是原子的,也是采用final修饰的,不允许子类重写。
状态值的含义:
在独占锁中,状态值为0表示没有线程持有锁,非0值表示有线程持有锁,且值大于1时表示锁被重入。
在共享锁中,状态值表示持有锁的线程数量或其他共享资源的状态。
AQS中exclusiveOwnerThread属性的值即为当前持有锁的线程:
private transient Thread exclusiveOwnerThread;
AQS的同步队列有什么特点?
AQS(AbstractQueuedSynchronizer)的同步队列是AQS实现同步机制的核心组件之一:
双向链表结构
AQS依赖CLH同步队列(一个双向链表结构)来完成同步状态的管理。当线程获取同步状态失败时,AQS会将该线程以及等待状态等信息构造成一个节点(Node),并将其加入到这个双向链表的队尾,同时阻塞该线程。
通过节点head和tail记录队首和队尾元素,它的head节点永远是一个哑结点(dummy node), 它不代表任何线程(某些情况下可以看做是代表了当前持有锁的线程),head所指向的Node的thread属性永远是null。
等待锁的线程队列
- 同步队列可以简单地理解为“等待锁的线程队列”。线程在尝试获取锁失败后,会被加入到这个队列中等待,直到其他线程释放锁后,队列中的线程才有机会重新尝试获取锁。
支持公平锁和非公平锁
AQS的同步队列支持公平锁和非公平锁两种模式:
公平锁:线程按照申请锁的顺序来获取锁,即队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死,但缺点是整体吞吐效率相对较低,因为除了队列中的第一个线程外,其他所有线程都会阻塞,CPU唤醒阻塞线程的开销较大。
非公平锁:多个线程在加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。非公平锁的优点是可以减少唤起线程的开销,整体吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。但缺点是等待队列中的线程可能会饿死,或者需要等待较长时间才能获得锁。
原子操作和线程安全
- 为了保证对同步状态的原子操作,AQS使用CAS(Compare-And-Swap)机制来更新同步状态。当线程尝试获取或释放锁时,会通过CAS操作来更新AQS中的state字段,以确保操作的线程安全性。
阻塞和唤醒机制
- 当线程获取锁失败并被加入到同步队列后,该线程会被阻塞。当其他线程释放锁时,AQS会唤醒同步队列中的首节点(对于公平锁)或队列中的某个线程(对于非公平锁),使其重新尝试获取锁。
灵活性和可扩展性
- AQS的设计使得它非常灵活和可扩展。通过继承AQS并重写其指定方法,可以方便地实现各种同步机制,如锁(Lock)、信号量(Semaphore)、屏障(CyclicBarrier)等。
AQS的同步队列是一个基于双向链表结构的线程等待队列,它支持公平锁和非公平锁两种模式,通过CAS机制保证操作的原子性和线程安全性,并具有灵活性和可扩展性。这些特点使得AQS成为构建同步机制的重要基础框架。
AQS使用的什么方法阻塞的当前线程?
AQS使用LockSupport来阻塞线程。java.util.concurrent.LockSupport
是用来创建锁和基本线程阻塞操作的原语,它的实现也是依托sun.misc.Unsafe
,其阻塞线程的关键代码如下所示:
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
U.park(false, 0L);
setBlocker(t, null);
}
private static void setBlocker(Thread t, Object arg) {
// Even though volatile, hotspot doesn't need a write barrier here.
U.putObject(t, PARKBLOCKER, arg);
}
public static void unpark(Thread thread) {
if (thread != null)
U.unpark(thread);
}
unsafe.putObject()
用于为Thread设置parkBlocker,parkBlocker这个对象用于记录该线程的阻塞者是谁,通常情况下是提供给线程分析工具使用的。
unsafe.park()
用于阻塞当前线程,是阻塞的核心方法,它有两个参数:
- 第一个参数是布尔值,为true表示相对时间,false表示绝对时间
- 第二个参数是long型整数,表示时间长度。
这两个参数需要组合使用,当第一个参数为true时,表达的是“线程阻塞,直到指定毫秒的时间”,当第一个参数为false时,表达的是“线程阻塞指定纳秒”。
LockSupport.unpark()
提供了法:中断阻塞,由于被阻塞的线程肯定是无法中断自己的阻塞的,所以该方法需要由其他线程调用,需要中断阻塞的线程由参数传入。
注意事项
- LockSupport的park()和unpark()方法是成对出现的,但unpark()方法可以在park()之前被调用。如果先调用了unpark(),那么当线程随后调用park()时,它可能会立即返回,而不会实际阻塞。
- LockSupport的许可(permit)是不可重入的,即每个线程只能拥有一个许可。如果线程已经拥有许可,那么再次调用park()将会导致线程阻塞,直到再次获得许可。
AQS通过LockSupport.park()方法阻塞当前线程,并通过LockSupport.unpark(Thread thread)方法唤醒等待的线程。这种机制是Java并发编程中实现锁和其他同步组件的基础。
AQS为什么只有前驱节点是头节点时才能尝试获取同步状态?
在AQS中,只有当前驱节点是头节点时才能尝试获取同步状态,这一设计主要基于以下几个原因:
维护FIFO(先进先出)的队列规则
AQS中的同步队列是一个基于FIFO规则的双向链表。每个节点代表一个等待获取同步状态的线程。
当一个节点的前驱节点是头节点时,这意味着头节点(即当前持有同步状态的节点)已经或即将释放同步状态。此时,后继节点(即当前尝试获取同步状态的节点)可以尝试获取同步状态,从而确保线程按照它们进入队列的顺序被处理,维护了队列的FIFO特性。
防止插队行为
- 如果没有这个规则,即任何节点都可以随时尝试获取同步状态,那么可能会出现插队行为,即后来加入的线程可能先于之前加入的线程获取同步状态,这违反了FIFO的队列规则。
唤醒机制的实现
在AQS中,当头节点释放同步状态时,它会唤醒其后继节点(即当前前驱节点为头节点的节点)来尝试获取同步状态。
如果允许非头节点的后继节点尝试获取同步状态,那么在头节点唤醒后继节点后,这些非头节点的后继节点可能会立即尝试获取同步状态,这可能会导致不必要的竞争和性能问题。
线程安全性的保障
AQS通过CAS(Compare-And-Swap)等原子操作来确保线程安全地添加节点到队列、更新头尾节点以及尝试获取同步状态等操作。
只有当前驱节点是头节点时才能尝试获取同步状态的规则,是这些线程安全操作的一部分,有助于防止多个线程同时尝试获取同步状态而导致的冲突和混乱。
AQS中只有当前驱节点是头节点时才能尝试获取同步状态的设计,是为了维护FIFO的队列规则、防止插队行为、实现合理的唤醒机制以及保障线程安全性的需要。这一设计使得AQS成为一个高效、可靠且易于使用的同步组件构建框架。
AQS 共享式式获取/释放锁的原理?
共享式获取锁的原理
尝试获取锁:
当线程尝试以共享模式获取锁时,会首先调用tryAcquireShared(int arg)方法。这个方法需要由AQS的子类实现,用于根据当前的同步状态和给定的参数(如请求的许可数)来判断线程是否可以获取锁。
如果tryAcquireShared方法返回的值大于等于0,表示线程成功获取了锁,并且返回值可能表示当前剩余的许可数(对于某些类型的共享锁)。如果返回值小于0,则表示获取锁失败。
加入同步队列:
- 如果线程未能通过tryAcquireShared方法获取锁,那么它会被封装成一个Node节点,并以共享模式(Node.SHARED)加入到AQS的同步队列中。这个队列是一个基于FIFO(先进先出)规则的双向链表。
自旋尝试获取锁:
线程在加入同步队列后,会进入一个自旋循环,不断尝试获取锁。在自旋过程中,线程会检查其前驱节点是否为头节点。只有当前驱节点是头节点时,线程才会再次尝试调用tryAcquireShared方法获取锁。
如果线程成功获取了锁,它会成为新的头节点,并可能唤醒其后继节点(如果有的话)去尝试获取锁。
挂起与唤醒:
- 如果线程在自旋过程中未能获取锁,并且满足某些条件(如前驱节点的状态等),它可能会被挂起(通过LockSupport.park等方法)。当锁被释放时,被挂起的线程可能会被唤醒并继续尝试获取锁。
共享式释放锁的原理
尝试释放锁:
当线程完成了对共享资源的访问后,它会调用releaseShared(int arg)方法来释放锁。这个方法同样需要由AQS的子类实现,用于根据当前的同步状态和给定的参数来释放锁。
releaseShared方法通常会调用tryReleaseShared(int arg)方法来判断是否可以释放锁,并更新同步状态。
更新同步状态:
- 如果线程成功释放了锁,tryReleaseShared方法会更新AQS的同步状态(如减少许可数)。同时,它可能会唤醒等待在同步队列中的其他线程。
唤醒后继节点:
- 在释放锁的过程中,如果同步队列中有等待的线程,并且当前节点的状态允许(如waitStatus为SIGNAL或PROPAGATE),那么会唤醒队列中的后继节点。这有助于确保等待的线程能够及时获取锁并继续执行。
AQS的共享式获取和释放锁原理主要依赖于其内部的同步队列和状态变量。通过tryAcquireShared和tryReleaseShared方法,AQS的子类可以实现具体的锁获取和释放逻辑。同时,AQS提供的自旋、挂起和唤醒机制有助于高效地管理线程对共享资源的访问。这些机制共同确保了AQS在并发环境下的正确性和高效性。
FairSync 和NonfairSync 的异同?
FairSync(公平锁)和NonfairSync(非公平锁)是Java并发包中ReentrantLock类提供的两种锁实现方式,它们的主要区别在于获取锁的策略上。以下是它们之间的异同点:
相同点
- 基础实现:FairSync和NonfairSync都继承自ReentrantLock的内部抽象类Sync,而Sync又继承自AbstractQueuedSynchronizer(AQS),因此它们都是基于AQS框架来实现的。
- 功能相同:两者都提供了基本的互斥锁功能,确保在任一时刻只有一个线程可以访问被保护的资源。
- 可重入性:无论是FairSync还是NonfairSync,都支持锁的可重入性,即同一个线程可以多次获取同一把锁。
不同点
获取锁的策略:
FairSync(公平锁):在尝试获取锁时,会先检查等待队列中是否有其他线程正在等待。如果有,当前线程会将自己添加到等待队列的末尾,并等待自己的前驱线程释放锁。这种策略保证了线程获取锁的顺序与它们请求锁的顺序一致,从而实现了公平性。
NonfairSync(非公平锁):在尝试获取锁时,不会检查等待队列中是否有其他线程正在等待。如果当前锁未被占用,它会直接尝试占用锁。如果占用失败,则会将自己添加到等待队列的末尾。这种策略可能会导致新到达的线程比已经等待的线程更快地获取锁,从而牺牲了公平性以提高性能。
性能差异:
在高并发场景下,非公平锁的性能通常优于公平锁。因为非公平锁允许新到达的线程立即尝试获取锁,减少了线程在队列中等待的时间。然而,这也可能导致某些线程长时间无法获取锁,造成“线程饥饿”现象。
公平锁则通过保证线程获取锁的顺序与请求锁的顺序一致,避免了线程饥饿的问题,但可能会降低系统的整体性能。
使用场景:
公平锁适用于那些对线程获取锁的顺序有严格要求的场景,如需要保证任务按照提交顺序执行的场景。
非公平锁则适用于那些对性能有较高要求,且可以容忍一定不公平性的场景。
Semaphore 是什么?
Semaphore(信号量)是一个用于控制同时访问某个特定资源或执行某段代码的线程数量的工具。Semaphore通过维护一个许可(permit)计数器来工作,这个计数器表示可用的许可数量。线程在访问共享资源或执行特定代码段之前,必须先获得许可。如果许可数量大于0,则线程可以获得许可并继续执行,同时许可数量减少;如果许可数量为0,则线程必须等待,直到有其他线程释放许可。
主要方法
Semaphore类提供了几个关键的方法来操作许可:
- acquire()/acquire(int permits):从此信号量获取一个/多个许可,在获取到令牌或者被其他线程调用中断之前线程一直处于阻塞状态。
- release()/release(int permits):释放一个/多个令牌给信号量,唤醒一个获取令牌不成功的阻塞线程。
- availablePermits():返回此信号量中当前可用的许可数。
- acquireUninterruptibly() :获取一个令牌,在获取到令牌之前线程一直处于阻塞状态(忽略中断)。
- tryAcquire():尝试获得令牌,返回获取令牌成功或失败,不阻塞线程。
- tryAcquire(long timeout, TimeUnit unit):尝试获得令牌,在超时时间内循环尝试获取,直到尝试获取成功或超时返回,不阻塞线程。
- hasQueuedThreads():等待队列里是否还存在等待线程。
- getQueueLength():获取等待队列里阻塞的线程数。
- drainPermits():清空令牌把可用令牌数置为0,返回清空令牌的数量。
构造方法
Semaphore的构造方法允许你指定初始的许可数量,并可以选择是否使用公平策略。公平策略意味着等待时间最长的线程将最先获得许可。
- Semaphore(int permits):创建一个具有给定许可数和非公平策略的Semaphore。
- Semaphore(int permits, boolean fair):创建一个具有给定许可数和指定公平策略的Semaphore。
常见使用场景
- 连接池管理:在数据库连接池、线程池等资源池管理中,Semaphore可以控制可用资源的数量,限制同时处理的连接或线程数量,避免资源过度使用。
- 限流控制:在网络应用中,可以使用Semaphore实现流量控制,限制请求的处理速率,防止系统过载。
- 并发访问控制:在某些场景下,需要限制同时访问某个资源的线程数量,例如限制文件的并发读写、限制同时下载文件的数量等。
- 多任务协作:在一些多阶段任务中,需要控制各个阶段的并发执行,Semaphore可以用来实现阶段间的同步。
- 实现有界容器:例如使用Semaphore实现有界的阻塞队列,控制队列中元素的数量,防止内存溢出或者资源耗尽。
- 实现读写锁:Semaphore也可以用来实现读写锁的功能,用于读操作和写操作之间的互斥。
- 任务并行处理:在一些并行计算场景中,可以使用Semaphore来控制并行处理的任务数量,控制计算资源的使用。
- 分布式系统协调:在分布式系统中,Semaphore可以用来实现资源的分配和协调,保证分布式任务的有序执行。
Semaphore是Java并发包中的一个强大工具,它提供了一种灵活的方式来控制对共享资源的并发访问。通过适当地配置Semaphore的许可数量和公平策略,开发者可以有效地管理并发线程,避免资源冲突和死锁等问题。
Semaphore 的原理是什么?
Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。
调用semaphore.acquire() ,线程尝试获取许可证
- 如果 state >= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。
- 如果 state<0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。
调用semaphore.release(),线程尝试释放许可证
- 并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。
- 被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。
CountDownLatch 是什么?
CountDownLatch 用于允许一个或多个线程等待一组其他线程完成某项操作后再继续执行。
CountDownLatch 的工作原理是,它初始化时设置一个计数值(count),任何线程调用该 CountDownLatch 的 await() 方法时,如果当前计数值大于 0,则该线程会被阻塞,直到计数值变为 0。
当其他线程调用 countDown() 方法时,计数值会减 1。当计数值减至 0 时,所有因调用 await() 方法而被阻塞的线程都将被唤醒,并继续执行。
CountDownLatch 的一个典型应用场景是,在程序启动时,主线程需要等待多个子线程完成初始化工作后再继续执行。此时,可以在主线程中创建一个 CountDownLatch 实例,设置其计数值为需要等待的子线程数量。然后,在每个子线程执行完毕后调用 countDown() 方法。主线程则通过调用 await() 方法等待所有子线程执行完毕。
CountDownLatch 的一些关键特性包括:
- 一次性:CountDownLatch 的计数值只能被设置一次。一旦 CountDownLatch 被初始化,其计数值就不能被修改(除了通过调用 countDown() 方法来减少)。
- 非负性:CountDownLatch 的计数值必须是非负的。如果尝试将计数值设置为负数,将会抛出异常。
- 不可重用性:一旦 CountDownLatch 的计数值减至 0,并且所有等待的线程都被唤醒,这个 CountDownLatch 实例就不能再被重用了。如果需要再次等待,必须创建新的 CountDownLatch 实例。
CyclicBarrier 是什么?
CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。CyclicBarrier 循环屏障是基于同步到达某个点的信号量触发机制,作用是让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障才会解除。 构造方法中的参数表示拦截线程数量,每个线程调用**await**()
方法告诉 CyclicBarrier 自己已到达屏障,然后被阻塞。还支持在构造方法中传入一个 Runnable 任务,当线程到达屏障时会优先执行该任务。适用于多线程计算数据,最后合并计算结果的应用场景。 CyclicBarrier 的计数器可使用 reset 方法重置。可接受一个线程作为构造参数,在线程全部到达时触发线程执行。
实现原理
CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
//每次拦截的线程数
private final int parties;
//计数器
private int count;
CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。
当调用 CyclicBarrier 对象调用
await()
方法时,实际上调用的是dowait(false, 0L)
方法。 await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。
CyclicBarrier和CountDownLatch的区别?
CyclicBarrier和CountDownLatch都是Java并发包中的同步工具,它们都能够实现线程间的同步,但两者在设计和使用上存在一些关键的区别。
计数器的作用和变化
CountDownLatch:其计数器只能递减,用于表示等待完成的事件数量。每次调用countDown()方法时,计数器减1。一旦计数器归零,等待的线程就会被唤醒,继续执行。但计数器归零后,CountDownLatch不能被重置。
CyclicBarrier:其计数器用于表示需要等待的线程数量。每个线程在到达屏障点时调用await()方法,会使计数器减1。当计数器减至0时,所有等待的线程都会被唤醒,并且计数器会被重置(如果设置了重置行为),以便重用。
使用场景
CountDownLatch:适用于一组线程等待另一组线程完成后再执行。它通常用于主线程等待多个子线程完成初始化或任务执行的情况。一旦所有子线程完成任务并调用countDown()方法,主线程就可以继续执行。
CyclicBarrier:适用于多个线程相互等待,直到所有线程都到达一个公共的屏障点后再继续执行。它常用于并行迭代任务,其中每个迭代阶段都需要等待所有线程完成当前阶段的任务后才能进入下一个阶段。
是否可重用
CountDownLatch:不可重用。一旦计数器归零,CountDownLatch就不能再次被用来等待新的线程集合。
CyclicBarrier:可重用。每当所有线程都通过屏障点后,计数器会被重置,CyclicBarrier可以再次用于新的线程集合的同步。
屏障操作
CyclicBarrier:支持在最后一个线程到达屏障点时执行一个特定的操作(通过构造函数中的Runnable参数指定)。这个操作在所有线程都被释放之前执行一次。
CountDownLatch:不提供这样的屏障操作。它仅仅用于等待计数器归零,并不支持在计数器归零时执行额外的操作。
线程执行顺序
CountDownLatch:主线程(或等待线程)会在所有计数器归零后继续执行,而执行countDown()方法的线程(即完成工作的线程)则可能继续执行其他任务,无需等待其他线程。
CyclicBarrier:所有线程都会在屏障点相互等待,直到所有线程都到达屏障点。然后,它们会同时被释放,继续执行后续操作。
阻塞行为
CountDownLatch:等待线程在调用await()方法时会阻塞,直到计数器归零。但执行countDown()方法的线程不会被阻塞。
CyclicBarrier:所有调用await()方法的线程都会在屏障点阻塞,直到所有线程都到达屏障点。
ReentrantLock 是什么?
ReentrantLock是Java中的一种可重入的互斥锁(Mutex),也被称为“独占锁”。与 synchronized 关键字类似,提供了比synchronized关键字更高级的锁定机制。
一、基本概念
- 可重入性:ReentrantLock支持可重入性,即同一个线程可以多次获得同一个锁。当线程重复获取锁时,锁的内部计数器会递增;线程每次释放锁时,计数器会递减。只有当计数器为0时,锁才会被完全释放,其他线程才能获取该锁。
- 互斥性:ReentrantLock是互斥锁,即在同一时刻,只有一个线程能持有该锁。如果多个线程尝试获取同一个锁,那么只有获得锁的线程才能继续执行,其他线程必须等待。
二、特性与功能
公平性:ReentrantLock支持公平锁和非公平锁两种模式。默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。
公平锁:按照线程请求锁的顺序来获取锁,即先请求的线程先获得锁。这种模式可以减少“线程饥饿”的发生概率,但可能会降低程序的效率。
非公平锁:线程尝试获取锁时,不管自己是否在等待队列中,只要锁是可用的,就尝试获取锁。这种模式的效率通常比公平锁高,但可能导致某些线程长时间等待锁。
可中断性:与synchronized不同,ReentrantLock支持中断等待锁的线程。如果线程在等待锁的过程中被中断,它会立即从锁的等待状态返回,并抛出InterruptedException。
超时尝试获取锁:ReentrantLock允许线程在尝试获取锁时指定一个等待时间。如果在指定的时间内线程没有获得锁,那么它会返回而不会永远等待。
条件变量:ReentrantLock提供了与synchronized关键字中的wait/notify机制相对应的条件变量(Condition)。条件变量允许线程在达到某个条件之前挂起,并在条件满足时被唤醒。
ReentrantLock提供了比synchronized关键字更灵活和高级的锁定功能。通过支持可重入性、公平性、可中断性和超时尝试获取锁等特性,ReentrantLock能够满足更复杂的同步需求。在使用ReentrantLock时,需要注意正确地获取和释放锁,以避免死锁和其他同步问题。
ReentrantLock 的可重入是怎么实现的?
ReentrantLock的可重入性是通过其内部实现机制来确保的,主要依赖于以下几个方面:
一、内部状态与线程标识
- 锁的拥有者(Owner):ReentrantLock内部会记录当前持有锁的线程。当一个线程首次成功获取锁时,它会将当前线程设置为锁的拥有者。
- 重入次数:为了支持可重入性,ReentrantLock还维护了一个重入次数的计数器。每当锁的拥有者线程再次获取锁时,这个计数器就会递增;当锁的拥有者线程释放锁时,计数器会递减。
二、重入逻辑
获取锁:当一个线程尝试获取锁时,ReentrantLock会检查当前线程是否是锁的拥有者。
如果是,且重入次数未超过限制(通常是一个很大的正整数,以避免溢出),则允许重入,并将重入次数加1。
如果不是,或者锁已被其他线程持有,则当前线程会根据锁的公平性设置(公平锁或非公平锁)进入等待队列或尝试直接获取锁。
释放锁:当锁的拥有者线程调用unlock()方法释放锁时,ReentrantLock会将重入次数减1。
- 如果重入次数减为0,说明当前线程已经释放了所有它持有的锁,此时会将锁的拥有者设置为null,并唤醒等待队列中的下一个线程(如果有的话)。
三、实现细节
- AbstractQueuedSynchronizer(AQS):ReentrantLock是基于AQS实现的。ReentrantLock通过继承AQS并实现其相关方法来提供锁的功能。AQS内部使用了一个int类型的状态字段(state)来表示锁的状态,以及一个Thread类型的字段(exclusiveOwnerThread)来表示当前持有锁的线程。
ReentrantLock的可重入性是通过维护锁的拥有者线程和重入次数来实现的。当一个线程多次获取锁时,ReentrantLock会允许重入,并递增重入次数;当线程释放锁时,重入次数会递减,直到减为0时才真正释放锁。这种机制避免了死锁和其他并发问题,使得在锁保护的代码块内部可以安全地再次获取锁。
Lock和 synchronized 有什么区别?
Lock和synchronized是Java中用于控制多个线程对共享资源访问的两种主要机制,以下是Lock和synchronized之间的主要区别:
本质与实现方式
Lock:Lock是一个接口,它提供了比synchronized更广泛的锁定操作。Lock它提供了更高的灵活性。ReentrantLock底层调用的是Unsafe的park方法加锁。
synchronized:synchronized是Java中的一个关键字,用于实现方法或代码块的同步。它是JVM层面的一种内置语言实现,无法被继承和重写。
锁的获取与释放
Lock:Lock需要显式地获取和释放锁。这通常通过调用lock()方法来获取锁,并在finally块中调用unlock()方法来释放锁,以确保锁最终会被释放,避免死锁。
synchronized:synchronized的锁获取和释放是隐式的。当线程进入synchronized代码块或方法时,会自动获取锁;当线程退出synchronized代码块或方法时,会自动释放锁。
锁的公平性
Lock:Lock可以支持公平锁和非公平锁。公平锁会按照线程请求锁的顺序来授予锁,从而减少了线程饥饿的可能性。ReentrantLock通过构造函数中的boolean参数来设置是否使用公平锁。
synchronized:synchronized只支持非公平锁,即线程获取锁的顺序是不确定的,这可能导致某些线程长时间等待锁。
锁的响应中断
Lock:Lock支持响应中断的锁获取方式,即线程在等待锁的过程中可以被中断。这通过调用lockInterruptibly()方法来实现,如果线程在等待锁的过程中被中断,它会抛出InterruptedException异常。
synchronized:synchronized不支持响应中断的锁获取方式,即线程在等待锁的过程中不能被中断,必须一直等待直到获取到锁。
锁的尝试获取
Lock:Lock提供了tryLock()方法,它尝试获取锁,如果锁在当前可用,则获取锁并返回true;如果锁不可用,则立即返回false,而不会使线程进入等待状态。此外,tryLock(long timeout, TimeUnit unit)方法还允许线程在指定的等待时间内尝试获取锁。
synchronized:synchronized不提供类似的尝试获取锁的功能,线程一旦进入等待状态,就必须等待直到获取到锁。
锁的绑定条件
Lock:Lock可以绑定多个条件(Condition),这允许线程在特定的条件下等待和唤醒。Condition接口提供了比Object的wait/notify更灵活的线程间通信方式。
synchronized:synchronized使用Object的wait/notify/notifyAll方法进行线程间通信,这种方式相对简单但不够灵活。
性能与适用性
Lock:由于Lock提供了更多的灵活性和控制,因此在某些情况下(如需要公平锁、响应中断的锁获取、尝试获取锁等)可能比synchronized更适用。然而,Lock的使用也更复杂,需要显式地获取和释放锁,增加了出错的可能性。
synchronized:synchronized由于其简单性和内置性,在大多数情况下都能满足需求。它不需要显式地获取和释放锁,减少了出错的可能性。但是,它不支持公平锁和响应中断的锁获取等高级功能。
一般优先考虑使用 synchronized:
- synchronized 是语法层面的同步,足够简单。
- Lock 必须确保在 finally 中释放锁,否则一旦抛出异常有可能永远不会释放锁。使用 synchronized 可以由 JVM 来确保即使出现异常锁也能正常释放。
- 从长远来看 JVM 更容易针对synchronized 优化,因为 JVM 可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 Lock 的话 JVM 很难得知具体哪些锁对象是由特定线程持有的。
ConditionObject 的作用是什么?
synchronized控制同步的时候,可以配合Object的wait()、notify(),notifyAll() 系列方法可以实现等待/通知模式。而Lock 提供了条件Condition接口,配合await(),signal(),signalAll() 等方法也可以实现等待/通知机制。
ConditionObject实现了Condition接口,给AQS提供条件变量的支持 。
提供等待/通知机制
- ConditionObject提供了类似于传统线程同步中的等待/通知机制,用于在线程之间进行协调和通信。这一机制允许线程在特定条件未满足时挂起等待,并在条件满足时被唤醒继续执行。ConditionObject对象都维护了一个单独的等待队列。
支持多个等待集合
- 与传统的Object监视器(synchronized)只能有一个等待队列不同,ConditionObject允许多个线程等待在不同的条件上。通过使用Condition接口的多个实例,可以为不同的线程组设置不同的等待条件,并通过signalAll()方法同时唤醒它们。这种灵活性使得ConditionObject在复杂并发场景中更加适用。
条件谓词支持
- ConditionObject允许使用条件谓词来进行更复杂的等待和通知。条件谓词是一个布尔表达式,用于描述在满足某些条件之前线程应该等待的条件。通过使用await()方法的重载版本,可以在等待期间检查条件谓词,并在条件不满足时继续等待。
公平性保证
- ConditionObject支持公平性保证。当一个线程调用await()方法时,它会加入等待集合,并在调用signal()或signalAll()方法时按照先进先出的顺序唤醒等待的线程。这种公平性有助于减少线程饥饿现象。
精确控制线程的等待和唤醒
- 通过ConditionObject,可以更精确地控制线程的等待和唤醒。与synchronized的隐式锁获取和释放不同,ConditionObject要求显式地获取和释放锁,并在适当的时机调用await()、signal()或signalAll()方法来控制线程的等待和唤醒。这种精确控制使得ConditionObject在需要复杂同步逻辑的并发编程中更加有用。
与Lock接口结合使用
- ConditionObject通常与Lock接口的实现类(如ReentrantLock)一起使用,以提供更灵活的线程同步和通信机制。Lock接口提供了比synchronized更丰富的锁操作,如尝试非阻塞地获取锁、可中断地获取锁以及超时获取锁等。ConditionObject与Lock接口的结合使用,使得Java并发编程更加灵活和强大。
什么是ReadWriteLock?
ReadWriteLock是Java中的一个接口,用于管理一组锁,具体包括一个读锁和一个写锁。这一机制特别适用于读操作远多于写操作的并发场景,能够有效提升程序的并发性能和资源的利用率。
一、基本概念
- 读锁(Read Lock):允许多个线程同时持有读锁,以并发地读取共享资源。只要没有线程持有写锁,多个读线程就可以同时获得读锁。
- 写锁(Write Lock):写锁是独占的,每次只能有一个线程持有写锁进行写入操作。当写锁被占用时,其他线程无论是读线程还是写线程都无法获得相应的锁。
二、主要特性
- 读锁与读锁不互斥:多个读线程可以同时持有读锁,从而并发地读取共享资源。
- 读锁与写锁互斥:如果有一个线程持有了写锁,那么其他线程(无论是读线程还是写线程)都无法获得锁,直到写锁被释放。
- 写锁与写锁互斥:写锁是独占的,每次只能有一个线程持有写锁进行写入操作。
三、应用场景
由于 ReadWriteLock 既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下, 能够明显提升系统性能。
- 缓存系统:在缓存系统中,多个线程可能同时读取缓存中的数据,但只有一个线程可以进行缓存的更新或写入操作。使用ReadWriteLock可以允许多个线程同时进行读取操作,提高缓存的读取性能,同时确保只有一个线程可以进行写入操作,以维护缓存的一致性。
- 数据库访问:在多线程应用程序中,如果多个线程需要同时读取数据库中的数据,但只有一个线程可以进行写入操作(如执行更新、插入或删除记录的操作),可以使用ReadWriteLock来控制对数据库的访问。
- 文件系统操作:在文件系统中,多个线程可能需要同时读取文件的内容,但只有一个线程可以进行写入操作(如修改文件内容或更新文件属性)。使用ReadWriteLock可以确保多个线程可以同时进行读取操作,提高文件读取的并发性能,同时保证只有一个线程可以进行写入操作,以确保文件的一致性。
- 消息队列:在一个多线程的消息队列中,多个线程可以同时从队列中读取消息,但只有一个线程可以进行向队列中添加或删除消息的操作。使用ReadWriteLock可以允许多个线程同时进行读取操作,提高消息读取的效率,同时确保只有一个线程可以进行写入操作,以维护队列的完整性。
四、实现
- Java中的ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现。它提供了重入性,即允许同一个线程多次获得读锁或写锁,而不会导致死锁。同时,它也支持锁的降级(从写锁降级为读锁)和升级(从读锁升级到写锁),但升级通常是不被推荐的,因为它可能导致死锁。ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。
- 读写锁依赖 AQS 来实现同步功能,读写状态就是其同步器的同步状态。读写锁的自定义同步器需要在同步状态,即一个 int 变量上维护多个读线程和一个写线程的状态。读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。
- 写锁是可重入排他锁,如果当前线程已经获得了写锁则增加写状态,如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获得写锁的线程则进入等待。写锁的释放与 ReentrantLock 的释放类似,每次释放减少写状态,当写状态为 0 时表示写锁已被释放。
- 读锁是可重入共享锁,能够被多个线程同时获取,在没有其他写线程访问时,读锁总会被成功获取。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取则进入等待。读锁每次释放会减少读状态,减少的值是(1<<16),读锁的释放是线程安全的。
- 锁降级,是指把持住当前拥有的写锁,再获取读锁,随后释放先前拥有的写锁。锁降级中读锁的获取是必要的,这是为了保证数据可见性,如果当前线程不获取读锁而直接释放写锁,假设此刻另一个线程 A 获取写锁修改了数据,当前线程无法感知线程 A 的数据更新。如果当前线程获取 读锁,遵循锁降级的步骤,A 将被阻塞,直到当前线程使用数据并释放读锁之后,线程 A 才能获取写锁进行数据更新。
线程持有读锁还能获取写锁吗?
- 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
- 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。
读锁为什么不能升级为写锁?
写锁可以降级为读锁,但是读锁却不能升级为写锁。
- 如果允许读锁直接升级为写锁,那么在同一时刻可能有多个读线程尝试升级为写锁。这将导致严重的竞争问题,因为写锁是独占的,所有尝试升级的读线程都会争夺写锁,这可能导致大量的线程阻塞和上下文切换,从而降低系统的整体性能。
- 在某些情况下,如果读锁升级为写锁的过程处理不当,还可能导致死锁。例如,如果系统允许读锁在持有读锁的同时请求写锁升级,并且写锁的获取依赖于其他资源的锁定,那么就可能形成循环等待条件,从而导致死锁。
StampedLock 是什么?
StampedLock 它提供了一种比传统的 ReadWriteLock 在某些场景下更快的读写锁实现。StampedLock 的设计旨在优化读多写少的并发场景,通过允许在读操作过程中允许后续的写操作,并在读取数据时采用乐观读的方式来避免数据不一致的问题,从而提高了性能。StampedLock 不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。
主要特性
三种锁模式:
写锁:与 ReadWriteLock 中的写锁类似,只允许一个线程获取写锁,并在持有写锁时独占对共享资源的访问,不可重入的。
读锁(悲观读):与 ReadWriteLock 中的读锁类似,允许多个线程同时获取读锁,但不允许在这些线程中的任何一个线程执行写操作。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。
乐观读:StampedLock 独有的特性,允许读操作在不被阻塞的情况下进行,同时允许一个写线程获取写锁。但在使用读到的数据前需要检查这些数据在读取过程中是否被其他线程修改过。
StampedLock 还支持这三种锁在一定条件下进行相互转换 。
long tryConvertToWriteLock(long stamp);
long tryConvertToReadLock(long stamp);
long tryConvertToOptimisticRead(long stamp);
锁标记(Stamp):
- 获取读锁或写锁后,StampedLock 会返回一个 long 类型的标记(Stamp),这个标记用于后续的锁释放操作,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。
乐观读验证:
- 在使用乐观读获取的数据之前,需要调用 validate(long stamp) 方法来验证这些数据在读取过程中是否被其他线程修改过。如果数据被修改过,则 validate 方法会返回 false,此时可以选择重新进行读操作(可能使用悲观读锁),或者采取其他措施。
使用场景
- StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock的替代品,性能更好。需要注意的是StampedLock不可重入,不支持条件变量 Conditon,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。
- 相比于传统读写锁多出来的乐观读是StampedLock比 ReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。
注意事项
- 避免锁升级:StampedLock 不支持从读锁(无论是乐观读还是悲观读)直接升级到写锁,这是为了避免潜在的死锁问题。如果需要从读操作转变为写操作,需要先释放读锁,然后尝试获取写锁。
- 乐观读的风险:虽然乐观读可以提高性能,但它也带来了数据一致性的风险。因此,在使用乐观读时,需要仔细考虑数据的更新频率和读取数据的准确性要求。
- 性能优化:StampedLock 的性能优化是基于读多写少的假设的。如果实际应用场景中的写操作较为频繁,那么 StampedLock 的性能优势可能会减弱甚至消失。
StampedLock 是一种在读多写少时性能优于传统 ReadWriteLock 的锁机制。然而,它也需要在使用时仔细考虑数据一致性和性能之间的平衡。
有哪些原子类?
Java中的原子类是指具有原子操作特征的类,它们提供了一种线程安全的方式来更新变量的值。这些原子类都位于java.util.concurrent.atomic包中。以下是Java中常见的原子类及其分类:
一、基本原子类
- AtomicInteger:原子更新整型。
- AtomicLong:原子更新长整型。
- AtomicBoolean:原子更新布尔型。
这些基本原子类提供了如incrementAndGet()、decrementAndGet()、getAndAdd(int delta)、compareAndSet(int expect, int update)等原子操作方法,用于在多线程环境下安全地更新整型、长整型或布尔型变量的值。
二、数组原子类
- AtomicIntegerArray:原子更新整型数组的元素。
- AtomicLongArray:原子更新长整型数组的元素。
- AtomicReferenceArray:原子更新引用类型数组的元素。
这些数组原子类提供了对数组元素进行原子操作的方法,如getAndIncrement(int i)、compareAndSet(int i, E expect, E update)等,其中i是数组元素的索引。
三、原子引用类
- AtomicReference:原子更新引用类型。
- AtomicMarkableReference:带有更新标记位的原子引用类型。
- AtomicStampedReference:带有更新版本号的原子引用类型。
这些原子引用类提供了对引用类型变量进行原子操作的方法,如compareAndSet(V expect, V update)、weakCompareAndSet(V expect, V update)等。其中,AtomicStampedReference通过引入版本号的概念,解决了CAS操作中可能出现的ABA问题。
四、字段更新原子类
- AtomicIntegerFieldUpdater:原子更新整型字段的更新器。
- AtomicLongFieldUpdater:原子更新长整型字段的更新器。
- AtomicReferenceFieldUpdater:原子更新引用类型中的字段。
这些字段更新原子类允许对指定类的指定volatile字段进行原子更新。它们通过反射机制实现,因此字段必须是public volatile的。使用这些类时,需要调用静态方法newUpdater()来创建一个更新器实例,并指定要更新的类及其字段。
Java中的原子类提供了一种无锁的线程安全机制,使得在多线程环境下对变量进行更新时能够保持操作的完整性和可见性。这些原子类通过CAS操作等底层机制实现,相比传统的基于锁的同步方式,具有更高的并发性能和更好的可扩展性。在编写多线程应用程序时,应当优先考虑使用原子类来实现线程安全的操作。
AtomicInteger实现原子更新的原理是什么?
Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性**。** 更新的原理主要基于CAS(Compare-And-Swap,即比较并交换)技术。CAS操作包含三个参数:内存位置V、预期的原值A和新值B。如果内存位置V的值等于预期原值A,则将该位置的值更新为新值B。否则,不做任何操作。整个比较和交换的过程是原子的,不需要加锁。
AtomicInteger是对int类型的一个封装,提供原子性的访问和更新操作。其内部实现依赖于Unsafe类提供的一些底层能力,以及使用volatile关键字来确保变量的可见性。
- Unsafe类:Unsafe是Java提供的一个类,它提供了一系列直接操作内存的方法,包括CAS操作。AtomicInteger利用Unsafe类来执行CAS操作,从而实现原子性更新。
- volatile关键字:AtomicInteger中的value字段被声明为volatile,这意味着该字段的修改对所有线程都是可见的。这确保了当一个线程修改了value的值时,其他线程能够立即看到最新的值。
AtomicInteger提供了多种原子操作方法,如incrementAndGet()、decrementAndGet()、compareAndSet()等。这些方法的实现都基于CAS操作。
- incrementAndGet():该方法首先将当前值增加1,然后使用compareAndSet()方法将增加后的值设置为新值。如果compareAndSet()方法失败(即其他线程已经修改了value的值),则不断重试直到成功为止。这个过程使用了自旋锁机制,即循环重试直到成功。底层调用
**Unsafe**
类里的compareAndSwapInt()
。HotSpot 编译的结果是一条平台相关的处理器 CAS 指令。Unsafe 类不是给用户程序调用的类,因此 JDK9 前只有 Java 类库可以使用 CAS。 - decrementAndGet():与incrementAndGet()方法类似,只是将当前值减少1。
- compareAndSet():该方法比较当前值是否等于预期值,如果是,则将当前值更新为新值并返回true;否则,不做任何操作并返回false。这个方法没有使用自旋锁机制,而是直接利用了CAS的乐观锁特性。
CAS操作的优缺点
优点:
性能较好:CAS操作是一种无锁的同步机制,它避免了加锁和解锁的开销,因此在并发量较高的情况下性能较好。
简单易用:CAS操作相对简单,易于理解和使用。
缺点:
自旋消耗CPU性能:CAS操作在自旋过程中会消耗CPU性能,如果竞争激烈,可能会导致CPU资源的大量浪费。
ABA问题:CAS操作在比较时只关注值是否相等,而不关心值的变化过程。如果一个值从A变为B再变回A,CAS操作会认为该值没有变化,这可能会导致问题。不过,Java提供了AtomicStampedReference等工具类来解决ABA问题。
volatile 变量和 atomic 变量有什么区别?
volatile变量和atomic变量在多线程编程中都有其独特的作用和区别,主要体现在以下几个方面:
可见性
volatile变量:使用volatile关键字修饰的变量可以确保对该变量的读取和写入操作对其他线程是可见的。当一个线程修改了volatile变量的值,其他线程会立即看到最新的值。这是通过禁止指令重排序和确保每次读取都直接从主内存中获取最新值来实现的。
atomic变量:atomic变量同样具有可见性。Atomic类提供了一组原子操作方法,这些操作在执行时也会从主内存中读取最新值,并将更新后的值写回主内存,从而保证了其他线程能够看到最新的值。
原子性
volatile变量:volatile关键字只能保证对单个volatile变量的读取和写入操作的原子性,即这些操作是不可分割的。但是,它不能保证复合操作的原子性,比如自增(++)或自减(--)操作,这些操作实际上包含读取、修改和写入三个步骤,volatile无法确保这三个步骤的原子性。
atomic变量:Atomic类提供了一组原子操作方法,这些方法可以保证对变量的操作是原子的,即要么全部完成,要么全部未完成。例如,AtomicInteger类提供了incrementAndGet()方法,该方法可以原子地进行自增操作,从而避免了使用volatile时可能出现的并发问题。
使用场景
volatile变量:适用于对变量的读取和写入操作都是简单的赋值操作,并且需要保证对其他线程的可见性。例如,用于标记线程是否终止的标志位。
atomic变量:适用于需要进行复合操作的场景,如计数器、累加器等。Atomic类提供了一组原子操作方法,可以避免使用锁机制,提高并发性能。
性能开销
一般来说,volatile变量的性能开销相对较小,因为它只是简单地保证了变量的可见性和单个操作的原子性。
而atomic变量的性能开销相对较大,因为它需要通过CAS等复杂机制来保证复合操作的原子性。但是,在并发量较高的场景下,使用atomic变量可以避免锁的竞争,从而提高整体性能。
底层实现
volatile变量:主要通过内存屏障(Memory Barrier)来禁止指令重排序,并确保每次读取都直接从主内存中获取最新值。
atomic变量:通常使用底层的CAS(Compare and Swap)算法来实现原子操作。CAS算法通过比较当前值与预期值是否相等来决定是否进行更新操作,从而保证了操作的原子性。