github编辑

并发编程


线程


线程的6种状态及切换

  1. 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。

    实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。

  2. 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。 线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。

    1. 就绪状态(RUNNABLE之READY) 就绪状态只是说你资格运行,调度程序没有挑选到你,你就永远是就绪状态。 调用线程的start()方法,此线程进入就绪状态。当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。 当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。 锁池里的线程拿到对象锁后,进入就绪状态。

    2. 运行中状态(RUNNABLE之RUNNING) 线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一的一种方式。

  3. 阻塞(BLOCKED):表示线程阻塞于锁。

    阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。

  4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。

    处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

  5. 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。

    处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

  6. 终止(TERMINATED):表示该线程已经执行完毕。

    当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。 在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

th

Callable和Runnable接口有什么区别

  1. 返回值

    • Runnablerun()方法没有返回值,仅用于执行无返回结果的任务。

    • Callablecall() 方法返回泛型类型的结果,适用于需要返回值的场景。

  2. 异常处理

    • Runnablerun() 方法不能抛出检查型异常(checked exceptions),必须在内部处理或转为非检查型异常(如RuntimeException)。

    • Callablecall() 方法可以抛出检查型异常,调用方可通过Future.get()捕获异常。

  3. 应用场景

    • Runnable:适用于不需要返回结果或异常处理的简单任务,例如日志记录、异步更新UI等。

    • Callable:适用于需要返回值或需要显式处理异常的任务,例如计算密集型任务或远程调用。

  4. 与线程池的交互

    • Runnable

    • 使用execute(Runnable)提交任务,无返回结果。

    • 使用submit(Runnable)会返回Future<?>,但Future.get()返回null。

    • Callable

    • 必须通过submit(Callable)提交任务,返回的Future<T>可以获取实际结果。

    • 支持任务取消、超时等操作(通过Future接口)。

  5. 版本与设计目的

    • Runnable:Java 1.0引入,设计初衷是定义通用的任务接口,兼容性更广(如配合Thread类使用)。

    • Callable:Java 5随Executor框架引入,旨在增强对返回值、异常处理的支持,与Future结合使用。

  6. 函数式接口特性

    • Runnable:无参数、无返回值的函数式接口,Lambda表达式形式:() -> {}

    • Callable:无参数、有返回值的函数式接口,Lambda表达式需显式返回结果:() -> { return value; }


Java中止线程的三种方式

  1. 使用标志位中止线程(推荐) 在 run() 方法执行完毕后,该线程就中止了。但是在某些特殊的情况下,run() 方法会被一直执行;比如在服务端程序中可能会使用 while(true) { ... } 这样的循环结构来不断的接收来自客户端的请求。此时就可以用修改标志位的方式来结束 run() 方法。

  2. 使用 stop() 中止线程(不推荐) Thread.stop()方法可以强行中止线程的执行。然而,这种方法是不安全的,因为它不保证线程资源的正确释放和清理,可能导致数据不一致和资源泄露等问题,因此已被官方弃用。

    • 调用 stop() 方法会立刻停止 run() 方法中剩余的全部工作,包括在 catch 或 finally 语句中的,并抛出ThreadDeath异常(通常情况下此异常不需要显示的捕获),因此可能会导致一些清理性的工作的得不到完成,如文件,数据库等的关闭。

    • 调用 stop() 方法会立即释放该线程所持有的所有的锁,导致数据得不到同步,出现数据不一致的问题。

  3. 使用 interrupt() 中断线程 interrupt() 方法并不像在 for 循环语句中使用 break 语句那样干脆,马上就停止循环。调用 interrupt() 方法仅仅是在当前线程中打一个停止的标记,并不是真的停止线程。 也就是说,线程中断并不会立即中止线程,而是通知目标线程,有人希望你中止。至于目标线程收到通知后会如何处理,则完全由目标线程自行决定。这一点很重要,如果中断后,线程立即无条件退出,那么我们又会遇到 stop() 方法的老问题。 事实上,如果一个线程不能被 interrupt,那么 stop 方法也不会起作用。

    • interrupt() 是给线程设置中断标志;

    • interrupted() 是检测中断并清除中断状态;

    • isInterrupted() 只检测中断。

    如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常。仅此而已。 如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。


父子线程之间如何共享传递数据

  1. 使用InheritableThreadLocal ThreadLocal是一种特殊的Java对象,它为每个线程提供了独立的变量副本。通过ThreadLocal可以实现线程范围内的变量隔离,但不直接用于父子线程数据共享。不过,父线程可以在线程启动之前将数据放到InheritableThreadLocal,子线程可以读取到这些数据。

  2. 使用Concurrent Collections Java提供了一些线程安全的集合类,如ConcurrentHashMap、CopyOnWriteArrayList等,可以安全并发地访问和修改,适用于需要较多线程共享数据的场景。

  3. 使用消息队列 在复杂的多线程环境下,使用Java的阻塞队列(如BlockingQueue)可以在父子线程之间传递数据,并控制线程的执行顺序。


线程池


线程池中提交一个任务的流程

简单来说:corePoolSize > workQueue > maximumPoolSize > 拒绝策略 2


线程池有几种状态

状态
介绍

RUNNING

会接收新任务并且会处理队列中的任务

SHUTDOWN

不会接收新任务并且会处理队列中的任务,任务处理完后会中断所有线程

STOP

不会接收新任务并且不会处理队列中的任务,并且会直接中断所有线程

TIDYING

所有线程都停止了之后,线程池的状态就会转为TIDYING,一旦达到此状态,就会调用线程池的terminated()

TERMINATED

terminated()执行完之后就会转变为TERMINATED

当前
转变为
触发条件

RUNNING

SHUTDOWN

手动调用shutdown()触发,或者线程池对象GC时会调用finalize()从而调用shutdown()

RUNNING

STOP

手动调用shutdownNow()触发

SHUTDOWN

STOP

手动先调用shutdown()紧着调用shutdownNow()触发

SHUTDOWN

TIDYING

线程池所有线程都停止后自动触发

STOP

TIDYING

线程池所有线程都停止后自动触发

TIDYING

TERMINATED

线程池自动调用terminated()后触发


线程池拒绝策略

  1. AbortPolicy(默认):丢弃任务并抛出RejectedExecutionException异常。

  2. CallerRunsPolicy :只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。

  3. DiscardOldestPolicy :丢弃队列最前面的任务,然后重新提交被拒绝的任务。

  4. DiscardPolicy :丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃。


线程池的核心线程数、最大线程数该如何设置

线程池负责执行的任务分为三种情况

  1. CPU密集型任务,比如找出1-1000000中的素数 CPU密集型任务的特点是,线程在执行任务时会一直利用CPU,所以对于这种情况,就尽可能避免发生线程上下文切换。 所以对于CPU密集型任务,线程数最好就等于CPU核心数,可以通过以下API拿到你电脑的核心数:

    只不过,为了应对线程执行过程发生缺页中断或其他异常导致线程阻塞的请求,我们可以额外在多设置一个线程,这样当某个线程暂时不需要CPU时,可以有替补线程来继续利用CPU。

    所以,对于CPU密集型任务,我们可以设置线程数为:

    CPU核心数+1

  2. IO密集型任务,比如文件IO、网络IO 线程在执行IO型任务时,可能大部分时间都阻塞在IO上,假如现在有10个CPU,如果我们只设置了10个线程来执行IO型任务,那么很有可能这10个线程都阻塞在了IO上,这样这10个CPU就都没活干了,所以,对于IO型任务,我们通常会设置线程数为:

    2*CPU核心数

    不过,就算是设置为了2*CPU核心数,也不一定是最佳的,比如,有10个CPU,线程数为20,那么也有可能这20个线程同时阻塞在了IO上,所以可以再增加线程,从而去压榨CPU的利用率。

    通常,如果IO型任务执行的时间越长,那么同时阻塞在IO上的线程就可能越多,我们就可以设置更多的线程,但是,线程肯定不是越多越好,我们可以通过以下这个公式来进行计算:

    *线程数 = CPU核心数 ( 1 + 线程等待时间 / 线程运行总时间 )

    线程等待时间:指的就是线程没有使用CPU的时间,比如阻塞在了IO 线程运行总时间:指的是线程执行完某个任务的总时间

    可以利用jvisualvm抽样来估计这两个时间 3

  3. 混合型任务 一个应用中,可能有多个线程池,除开线程池中的线程可能还有很多其他线程,或者除开这个应用还是一些其他应用也在运行,所以实际工作中如果要确定线程数,最好是压测。

总结

  • CPU密集型任务:CPU核心数+1,这样既能充分利用CPU,也不至于有太多的上下文切换成本

  • IO型任务:建议压测,或者先用公式计算出一个理论值(理论值通常都比较小)

  • 对于核心业务(访问频率高),可以把核心线程数设置为我们压测出来的结果,最大线程数可以等于核心线程数,或者大一点点,比如我们压测时可能会发现500个线程最佳,但是600个线程时也还行,此时600就可以为最大线程数

  • 对于非核心业务(访问频率不高),核心线程数可以比较小,避免操作系统去维护不必要的线程,最大线程数可以设置为我们计算或压测出来的结果。


判断线程池任务执行完成的方式

Thread线程是否执行完成,我们可以调用join方法然后等待线程执行完成;那在使用线程池的时候,我们如何知道线程已经执行完成了?以下五种判断的方式:

  • isTerminated() 方式,在执行 shutdown() ,关闭线程池后,判断是否所有任务已经完成。

    • 优点 :操作简单。

    • 缺点 :需要关闭线程池。并且日常使用是将线程池注入到Spring容器,然后各个组件中统一用同一个线程池,不能直接关闭线程池。

  • ThreadPoolExecutor 的 getCompletedTaskCount() 方法,判断完成任务数和全部任务数是否相等。

    • 优点 :不必关闭线程池,避免了创建和销毁带来的损耗。

    • 缺点 :使用这种判断存在很大的限制条件;必须确定在循环判断过程中没有新的任务产生。

  • CountDownLatch计数器,使用闭锁计数来判断是否全部完成。

    • 优点 :代码优雅,不需要对线程池进行操作。

    • 缺点 :需要提前知道线程数量;性能较差;还需要在线程代码块内加上异常判断,否则在 countDown之前发生异常而没有处理,就会导致主线程永远阻塞在 await。

  • 手动维护一个公共计数 ,原理和闭锁类似,就是更加灵活。

    • 优点 :手动维护方式更加灵活,对于一些特殊场景可以手动处理。

    • 缺点 :和CountDownLatch相比,一样需要知道线程数目,但是代码实现比较麻烦。

  • 使用submit向线程池提交任务,Future判断任务执行状态。

    • 优点 :使用简单,不需要关闭线程池。

    • 缺点 :每个提交给线程池的任务都会关联一个Future对象,这可能会引入额外的内存开销。如果需要处理大量的任务,可能会占用较多的内存。


三个线程T1,T2,T3,如何保证顺序执行

  • 使用 join() 方法: 可以在每个线程内部使用 join() 方法来等待前一个线程执行完成。具体操作是在线程 T2 的 run() 方法中调用 T1.join(),在线程 T3 的 run() 方法中调用 T2.join()。这样可以确保 T1 在 T2 之前执行,T2 在 T3 之前执行。

  • 使用 CountDownLatch: 可以使用 CountDownLatch 来控制线程的执行顺序。创建两个 CountDownLatch 对象,设置初始计数为 1,分别在 T2 和 T3 的线程内等待计数器减少到 0,然后按顺序执行。

  • 使用 LockSupport: 可以使用LockSupport的park和unpark来控制线程的执行顺序。


ThreadLocal

ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

ThreadLocal如何防止内存泄漏

从图中我们可以当线程使用threadlocal 时,是将threadlocal当做当前线程thread的属性ThreadLocalMap 中的一个Entry的key值,实际上存放的变量是Entry的value值,我们实际要使用的值是value值。value值为什么不存在并发问题呢,因为它只有一个线程能访问。threadlocal我们可以当做一个索引看待,可以有多个threadlocal 变量,不同的threadlocal对应于不同的value值,他们之间互不影响。ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。

ThreadLocal 变量的内存泄漏问题主要是由于 ThreadLocalMap 中的 Entry 没有被及时清理导致的。ThreadLocalMap 是 ThreadLocal 的底层数据结构,它用于存储每个线程独立的变量副本。

  • 使用完 ThreadLocal 后及时调用 remove() 方法 在不再需要使用 ThreadLocal 存储的数据时,手动调用 ThreadLocal.remove() 方法将该数据从当前线程的 ThreadLocalMap 中清除。这样可以确保 ThreadLocalMap 不会持有对对象的引用,从而帮助垃圾回收器正常回收不再需要的对象。

  • 使用 try-with-resources 或 try-finally 块 如果你的 ThreadLocal 变量在需要清理的资源管理上下文中使用,可以使用 try-with-resources(自动清理)或 try-finally(手动清理)块来确保及时清理。

  • 使用 InheritableThreadLocal 如果需要在子线程中访问父线程的 ThreadLocal 变量,并且确保在子线程中正确清理,可以考虑使用 InheritableThreadLocal。这个类允许子线程继承父线程的 ThreadLocal 变量,并在子线程完成后自动清理。


ReentrantLock中的公平锁和非公平锁的底层实现

首先不管是公平锁和非公平锁,它们的底层实现都会使用AQS来进行排队,它们的区别在于:线程在使用lock()方法加锁时,如果是公平锁,会先检查AQS队列中是否存在线程在排队,如果有线程在排队,则当前线程也进行排队,如果是非公平锁,则不会去检查是否有线程在排队,而是直接竞争锁。 不管是公平锁还是非公平锁,一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程,所以非公平锁只是体现在了线程加锁阶段,而没有体现在线程被唤醒阶段。


Synchronized与ReentrantLock的区别

相似之处

  1. 线程安全:两者都可以用于实现对临界区的保护,从而实现线程安全。

  2. 可重入性:两者都支持可重入性,允许一个线程多次获取同一把锁而不会发生死锁。

  3. 互斥锁:本质上都是互斥锁,确保在同一时刻只有一个线程能执行被同步的代码块。

区别

  1. 灵活性

    • synchronized是Java语言的内置特性,使用简单,但功能有限。

    • ReentrantLock是一个类,提供了更高级的锁功能,例如:可中断的锁获取、超时获取锁、非阻塞尝试获取锁以及可实现更复杂的同步结构。

  2. 性能

    • 在较低竞争时,synchronized会自动使用优化,比如锁消除和锁粗化,使得它的性能在某些情况下可能高于ReentrantLock

    • ReentrantLock可能在高竞争下表现更好,因为它可以提供非公平和公平锁模式,公平模式会严格按照请求锁的顺序来分配锁。

  3. 实现的功能

    • ReentrantLock提供了更多控制功能,如lock()unlock()方法,可在任何位置灵活调用。而synchronized在语法上是强制块结束时锁自动释放。

    • ReentrantLock提供tryLock()lockInterruptibly()方法,以响应中断和超时。

  4. 条件变量

    • ReentrantLock具有与之关联的Condition对象,可以搭配lock来更细粒度的控制线程通信。

    • synchronized配合Object的wait()和notify()/notifyAll()来进行线程之间的通信,但不如Condition灵活。

适用场景

  • synchronized:适用于简单的同步需求。由于其语法简单且嵌入在Java语言中,特别适合锁定范围与方法等价的情况。小规模、多线程竞争不高的情况下表现优异。适合开发者不想处理锁的复杂生命周期时使用。

  • ReentrantLock:适用于需要更高级的同步控制,或者锁定范围与方法不同时。特别是在需要公平锁、可中断锁操作、尝试获取带超时功能的锁,或者需要多个条件等待时,应选择ReentrantLock。当系统规模较大、线程数较多,且具有复杂同步需求的情境时表现突出。


CAS

CAS(Compare and Swap,比较并交换) 是一种原子操作,用于实现无锁操作。核心思想是通过比较变量的当前值和期望值,如果一致,则将其更新为新值;否则操作失败。

  1. 读取当前值(旧值)。

  2. 比较当前值是否等于期望值。

  3. 如果相等,则更新为新值;否则,操作失败。


ABA问题

ABA问题是指在并发环境中,某线程读取到的值A在操作过程中被其他线程修改为值B,然后又改回值A,导致CAS操作错误地认为值未改变。

  1. 初始状态:栈中有3个元素:A -> B -> C,top指向A。

  2. 线程1执行出栈,读取top为A,准备更新为B,此时线程1被挂起。

  3. 线程2依次出栈A和B,再将A重新入栈,top仍指向A。

  4. 线程1恢复,CAS操作认为top未改变,更新成功,但栈已被破坏。

逻辑错误:线程1错误认为移除了A,实际数据已被修改。 数据丢失:中间节点可能被断开。

解决 ABA 问题

引入版本号,每次修改共享变量时,同时更新版本号,确保CAS检查值和版本号的一致性。 Java中的原子类(如AtomicInteger、AtomicReference)内部使用版本号机制,直接避免ABA问题。


AQS

AQS(AbstractQueuedSynchronizer)是Java并发编程中的一个重要组件,它是一个抽象类,提供了线程同步的底层实现机制。AQS的作用是实现线程的同步和互斥操作,它提供了两种主要的锁机制,分别是排他锁和共享锁。

AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH(虚拟的双向队列)队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。

排他锁也称为独占锁,在多个线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,即多个线程中只有一个线程获得锁资源。在AQS中,排他锁是通过内置的同步状态来实现的。当同步状态为0时,表示锁是未被获取的;当同步状态大于0时,表示锁已经被获取且被占用;当同步状态小于0时,表示锁已经被获取但是处于等待状态。

共享锁允许多个线程同时获得锁资源,但是在同一时刻只有一个线程可以获取到锁的拥有权,其他线程需要等待该线程释放锁。在AQS中,共享锁的实现与排他锁类似,也是通过内置的同步状态来实现的。

AQS通过一个内置的FIFO(先进先出)等待队列来实现线程的排队和调度。当线程需要获取锁资源时,如果锁已经被其他线程获取,则该线程会被加入到等待队列中等待。当锁被释放时,等待队列中的第一个线程会获得锁资源并继续执行。

在实现AQS时,需要继承自AQS类并实现其抽象方法。其中比较重要的方法包括:tryAcquire()和tryRelease()方法,用于实现锁的获取和释放;acquire()和release()方法,用于实现阻塞和唤醒操作;isHeldExclusively()方法,用于判断是否是排他锁。

总之,AQS是Java并发编程中的重要组件之一,它提供了线程同步的底层实现机制。在使用AQS时,需要根据具体的应用场景选择合适的锁机制来实现线程的同步和互斥操作。


CountDownLatch

CountDownLatch是Java中用于多线程协作的辅助类,它可以让一个或多个线程等待其他线程完成某个任务后再继续执行。 CountDownLatch通过一个计数器来实现,计数器的初始值可以设置为等待的线程数量。每个线程在完成任务后都会调用countDown()方法来减少计数器的值。当计数器的值减至0时,等待在CountDownLatch上的线程就会被唤醒,可以继续执行后续的操作。 CountDownLatch的主要作用是协调多个线程的执行顺序,使得某个线程(或多个线程)必须等待其他线程完成后才能继续执行。它常用于以下场景:

  1. 主线程等待多个子线程完成任务:主线程可以使用await()方法等待所有子线程完成,然后进行结果的汇总或其他操作。

  2. 多个线程等待外部事件的发生:多个线程可以同时等待某个共同的事件发生,比如等待某个资源准备就绪或者等待某个信号的触发。

  3. 控制并发任务的同时开始:在某些并发场景中,需要等待所有线程都准备就绪后才能同时开始执行任务,CountDownLatch提供了一种便捷的方式来实现这一需求。

需要注意的是,CountDownLatch的计数器是不能被重置的,也就是说它是一次性的。一旦计数器减至0,它将无法再次使用。如果需要多次使用可重置的计数器,则可以考虑使用CyclicBarrier。


CyclicBarrier

CyclicBarrier是Java中的一个多线程协作工具,它可以让多个线程在一个屏障点等待,并在所有线程都到达后一起继续执行。与CountDownLatch不同,CyclicBarrier可以重复使用,并且可以指定屏障点后执行的额外动作。 CyclicBarrier的主要特点有三个。

  • 首先,它可以重复使用,这意味着当所有线程都到达屏障点后,屏障会自动重置,可以用来处理多次需要等待的任务。

  • 其次,CyclicBarrier可以协调多个线程同时开始执行,这在分阶段任务和并发游戏等场景中非常有用。

  • 最后,CyclicBarrier还提供了可选的动作,在所有线程到达屏障点时执行,可以实现额外的逻辑。

需要注意的是,在创建CyclicBarrier时需要指定参与线程的数量。一旦所有参与线程都到达屏障点后,CyclicBarrier解除阻塞,所有线程可以继续执行后续操作。

最后更新于

这有帮助吗?