springboot第77集:深入浅出Java多线程

科技   2024-10-14 07:37   广东  

全栈架构师Java | 微服务集群方向

四种常⻅的线程池

Java 提供了多种线程池实现,用于高效管理线程的创建和调度。常见的线程池包括:

  1. **FixedThreadPool**:固定大小的线程池。
  2. **CachedThreadPool**:可缓存的线程池,根据需要动态创建线程。
  3. **ScheduledThreadPool**:定时调度的线程池。
  4. **SingleThreadExecutor**:单一线程的线程池。

为什么要⼆次检查线程池的状态?

⾸先去执⾏创建这个worker时就有的任务,当执⾏完这个任务后,worker的⽣命周

期并没有结束,在 while 循环中,worker会不断地调⽤ getTask ⽅法从阻塞队列中

获取任务然后调⽤ task.run() 执⾏任务,从⽽达到复⽤线程的⽬的。只

要 getTask ⽅法不返回 null ,此线程就不会退出。

当然,核⼼线程池中创建的线程想要拿到阻塞队列中的任务,先要判断线程池的状

态,如果STOP或者TERMINATED,返回 null 。

image.png

线程池本身有⼀个调度线程,这个线程就是⽤于管理布控整个线程池⾥的各种任务

和事务,例如创建线程、销毁线程、任务队列管理、线程队列管理等等。

故线程池也有⾃⼰的状态。 ThreadPoolExecutor 类中定义了⼀个 volatile int 变

量runState来表示线程池的状态 ,分别为RUNNING、SHURDOWN、STOP、

TIDYING 、TERMINATED。

线程池创建后处于RUNNING状态。

调⽤shutdown()⽅法后处于SHUTDOWN状态,线程池不能接受新的任务,清

除⼀些空闲worker,会等待阻塞队列的任务完成。

调⽤shutdownNow()⽅法后处于STOP状态,线程池不能接受新的任务,中断

所有线程,阻塞队列中没有被执⾏的任务全部丢弃。此时,poolsize=0,阻塞队

列的size也为0。

当所有的任务已终⽌,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。

接着会执⾏terminated()函数。

ThreadPoolExecutor中有⼀个控制状态的属性叫ctl,它是⼀个

AtomicInteger类型的变量。

线程池处在TIDYING状态时,执⾏完terminated()⽅法之后,就会由 TIDYING

-> TERMINATED, 线程池被设置为TERMINATED状态。

RejectedExecutionHandler handler

拒绝处理策略,线程数量⼤于最⼤线程数就会采⽤拒绝处理策略,四种拒绝处

理的策略为 :

  1. ThreadPoolExecutor.AbortPolicy:默认拒绝处理策略,丢弃任务并抛

出RejectedExecutionException异常。

  1. ThreadPoolExecutor.DiscardPolicy:丢弃新来的任务,但是不抛出异

常。

  1. ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列头部(最旧的)

的任务,然后重新尝试执⾏程序(如果再次失败,重复此过程)。

  1. ThreadPoolExecutor.CallerRunsPolicy:由调⽤线程处理该任务。
image.png

ThreadFactory threadFactory

创建线程的⼯⼚ ,⽤于批量创建线程,统⼀在创建线程时设置⼀些参数,如是

否守护线程、线程的优先级等。如果不指定,会新建⼀个默认的线程⼯⼚。

常⽤的⼏个阻塞队列

  1. LinkedBlockingQueue 链式阻塞队列,底层数据结构是链表,默认⼤⼩是 Integer.MAX_VALUE , 也可以指定⼤⼩。 2. ArrayBlockingQueue 数组阻塞队列,底层数据结构是数组,需要指定队列的⼤⼩。 3. SynchronousQueue 同步队列,内部容量为0,每个put操作必须等待⼀个take操作,反之亦 然。 4. DelayQueue 延迟队列,该队列中的元素只有当其指定的延迟时间到了,才能够从队列 中获取到该元素 。

int corePoolSize:该线程池中核⼼线程数最⼤值

核⼼线程:线程池中有两类线程,核⼼线程和⾮核⼼线程。核⼼线程默

认情况下会⼀直存在于线程池中,即使这个核⼼线程什么都不⼲(铁饭

碗),⽽⾮核⼼线程如果⻓时间的闲置,就会被销毁(临时⼯)。

int maximumPoolSize:该线程池中线程总数最⼤值 。

该值等于核⼼线程数量 + ⾮核⼼线程数量。

long keepAliveTime:⾮核⼼线程闲置超时时⻓。

⾮核⼼线程如果处于闲置状态超过该值,就会被销毁。如果设置

allowCoreThreadTimeOut(true),则会也作⽤于核⼼线程。

TimeUnit unit:keepAliveTime的单位。

ThreadPoolExecutor提供的构造⽅法

⼀共有四个构造⽅法

使⽤线程池主要有以下三个原因:

  1. 创建/销毁线程需要消耗系统资源,线程池可以复⽤已创建的线程。

  2. 控制并发的数量。并发数量过多,可能会导致资源消耗过多,从⽽造成服务器

崩溃。(主要原因)

  1. 可以对线程做统⼀管理。

线程池的原理

Java中的线程池顶层接⼝是 Executor 接⼝, ThreadPoolExecutor 是这个接⼝的实

现类。

线程同步队列(BlockingQueue

BlockingQueue 是一个支持线程安全操作的队列,适用于生产者-消费者模型中的线程同步。生产者线程往队列中放入数据,消费者线程从队列中获取数据,如果队列为空,消费者线程会阻塞等待数据的到来。

  • 使用 ArrayBlockingQueue 创建一个固定大小的阻塞队列,容量为 5。

  • 生产者线程每隔 500 毫秒向队列中放入一个整数,使用 queue.put() 方法,该方法如果队列满了会阻塞线程。

  • 消费者线程每隔 1000 毫秒从队列中取出一个整数,使用 queue.take() 方法,该方法如果队列为空也会阻塞线程。

  • 线程的阻塞机制使得生产者和消费者之间的操作是同步的,保证线程安全。

  1. 使用 LinkedBlockingDeque 创建了一个线程安全的双向队列 deque
  2. 前端操作:使用 addFirst() 方法将元素插入到队列的前端,使用 pollFirst() 从前端移除元素。
  3. 后端操作:使用 addLast() 方法将元素插入到队列的后端,使用 pollLast() 从后端移除元素。
  4. 该双向队列可以实现双端插入和删除操作,非常适合需要在两端频繁操作的场景。

总结

  • 线程同步队列 使用 BlockingQueue 实现,它能够自动处理线程间的同步问题,适合生产者-消费者模型。
  • 双向队列 使用 Deque 接口及其实现类 LinkedBlockingDeque,提供线程安全的双向队列操作。

单向队列(也称为FIFO 队列,First In First Out)是一种只能从一端插入元素,从另一端移除元素的数据结构。队列遵循先进先出(FIFO)原则,首先加入队列的元素会首先被取出。在 Java 中,可以使用 Queue 接口及其常见实现类(如 LinkedListArrayDeque)来实现单向队列。

单向队列的主要方法

  • offer(E e): 将指定元素插入队列的尾部,如果成功返回 true,否则返回 false
  • poll(): 移除并返回队列头部的元素,如果队列为空则返回 null
  • peek(): 返回队列头部的元素但不移除,如果队列为空则返回 null
  • isEmpty(): 判断队列是否为空。

在Java内存模型 JMM有⼀个主内存,每个线程有⾃⼰私有的⼯作

内存,⼯作内存中保存了⼀些变量在主内存的拷⻉。

内存可⻅性,指的是线程之间的可⻅性,当⼀个线程修改了共享变量时,另⼀个线

程可以读取到这个修改后的值。

为优化程序性能,对原有的指令执⾏顺序进⾏优化重新排序。重排序可能发⽣在多

个阶段,⽐如编译重排序、CPU重排序等。

happens-before规则是⼀个给程序员使⽤的规则,只要程序员在写代码的时候遵循happens-before规

则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。

在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:

保证变量的内存可⻅性

禁⽌volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强

的volatile内存语义”)

volatile 的作用

volatile 关键字的作用是保证变量在多线程之间的可见性。具体来说,它确保了以下两点:

  • 可见性:当一个线程修改了 volatile 变量的值时,修改会立即刷新到主内存中,其他线程可以立刻读取到更新后的值。
  • 禁止指令重排序优化:在使用 volatile 修饰变量时,编译器和处理器不会对该变量的读写操作进行重排序。因此,在代码执行过程中,volatile 变量的操作不会与前后的操作发生重排序。这在此代码中非常关键,因为它可以保证 step 1 (a = 1) 必须发生在 step 2 (flag = true) 之前。

在JSR-133之前的旧的Java内存模型中,是允许volatile变量与普通变量重排序的。

那上⾯的案例中,可能就会被重排序成下列时序来执⾏:

  1. 线程A写volatile变量,step 2,设置flag为true;

  2. 线程B读同⼀个volatile,step 3,读取到flag为true;

  3. 线程B读普通变量,step 4,读取到 a = 0;

  4. 线程A修改普通变量,step 1,设置 a = 1;

可⻅,如果volatile变量与普通变量发⽣了重排序,虽然volatile变量能保证内存可⻅

性,也可能导致普通变量读取错误。

所以在旧的内存模型中,volatile的写-读就不能与锁的释放-获取具有相同的内存语

义了。为了提供⼀种⽐锁更轻量级的线程间的通信机制,JSR-133专家组决定增强

volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序。

编译器还好说,JVM是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实

现的。

什么是内存屏障?硬件层⾯,内存屏障分两种:读屏障(Load Barrier)和写屏障

(Store Barrier)。内存屏障有两个作⽤:

  1. 阻⽌屏障两侧的指令重排序;

  2. 强制把写缓冲区/⾼速缓存中的脏数据等写回主内存,或者让缓存中相应的数据

失效。

注意这⾥的缓存主要指的是CPU缓存,如L1,L2等

编译器在⽣成字节码时,会在指令序列中插⼊内存屏障来禁⽌特定类型的处理器重

排序。编译器选择了⼀个⽐较保守的JMM内存屏障插⼊策略,这样可以保证在任何

处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:

在每个volatile写操作前插⼊⼀个StoreStore屏障;

在每个volatile写操作后插⼊⼀个StoreLoad屏障;

在每个volatile读操作后插⼊⼀个LoadLoad屏障;

在每个volatile读操作后再插⼊⼀个LoadStore屏障。


在功能上,锁⽐volatile更强⼤;在性能上,volatile更有优势。

对volatile做了增强后,volatile的禁⽌重排序功能还是⾮常有⽤的。


⼀个对象其实

有四种锁状态,它们级别由低到⾼依次是:

  1. ⽆锁状态

  2. 偏向锁状态

  3. 轻量级锁状态

  4. 重量级锁状态


当对象状态为偏向锁时, Mark Word 存储的是偏向的线程ID;当状态为

轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重

量级锁时, Mark Word 为指向堆中的monitor对象的指针。

锁不仅不存在多线程竞争,⽽且

总是由同⼀线程多次获得

偏向锁在

资源⽆竞争情况下消除了同步语句,连CAS操作都不做了,提⾼了程序的运⾏性

能。

⼀个线程在第⼀次进⼊同步块时,会在对象头和栈帧中的锁记录⾥存储锁的偏向的

线程ID。当下次该线程进⼊这个同步块时,会去检查锁的Mark Word⾥⾯是不是放

的⾃⼰的线程ID。

如果是,表明该线程已经获得了锁,以后该线程在进⼊和退出同步块时不需要花费

CAS操作来加锁和解锁 ;如果不是,就代表有另⼀个线程来竞争这个偏向锁。这

个时候会尝试使⽤CAS来替换Mark Word⾥⾯的线程ID为新线程的ID,这个时候要

分两种情况:

成功,表示之前的线程不存在了, Mark Word⾥⾯的线程ID为新线程的ID,锁

不会升级,仍然为偏向锁;

失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为

0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的⽅式进⾏竞争

锁。

撤销偏向锁

偏向锁使⽤了⼀种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁

时, 持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程

看起来容易,实则开销还是很⼤的,⼤概的过程如下:

  1. 在⼀个安全点(在这个时间点上没有字节码正在执⾏)停⽌拥有锁的线程。

  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成

⽆锁状态。

  1. 唤醒被停⽌的线程,将当前锁升级成轻量级锁。

所以,如果应⽤程序⾥所有的锁通常出于竞争状态,那么偏向锁就会是⼀种累赘,

对于这种情况,我们可以⼀开始就把偏向锁这个默认功能给关闭:

-XX:UseBiasedLocking=false。

JVM会为每个线程在当前线程的栈帧中创建⽤于存储锁记录的空间,我们称为

Displaced Mark Word。如果⼀个线程获得锁的时候发现是轻量级锁,会把锁的

Mark Word复制到⾃⼰的Displaced Mark Word⾥⾯。

然后线程尝试⽤CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前

线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明

在与其它线程竞争锁,当前线程就尝试使⽤⾃旋来获取锁。

⾃旋:不断尝试去获取锁,⼀般⽤循环来实现。

⾃旋是需要消耗CPU的,如果⼀直获取不到锁的话,那该线程就⼀直处在⾃旋状

态,⽩⽩浪费CPU资源。解决这个问题最简单的办法就是指定⾃旋的次数,例如让

其循环10次,如果还没获取到锁就进⼊阻塞状态。

但是JDK采⽤了更聪明的⽅式——适应性⾃旋,简单来说就是线程如果⾃旋成功

了,则下次⾃旋的次数会更多,如果⾃旋失败了,则⾃旋的次数就会减少。

⾃旋也不是⼀直进⾏下去的,如果⾃旋到⼀定程度(和JVM、操作系统相关),依

然没有获取到锁,称为⾃旋失败,那么这个线程会阻塞。同时这个锁就会升级成重

量级锁。

轻量级锁的释放:

在释放锁时,当前线程会使⽤CAS操作将Displaced Mark Word的内容复制回锁的

Mark Word⾥⾯。如果没有发⽣竞争,那么这个复制的操作会成功。如果有其他线

程因为⾃旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释

放锁并唤醒被阻塞的线程。

重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,⽽操作系统中线程间状态的

转换需要相对⽐较⻓的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗

CPU。

当⼀个线程尝试获得锁时,如果该锁已经被占⽤,则会将该线程封装成⼀

个 ObjectWaiter 对象插⼊到Contention List的队列的队⾸,然后调⽤ park 函数挂

起当前线程。

当线程释放锁时,会从Contention List或EntryList中挑选⼀个线程唤醒,被选中的

线程叫做 Heir presumptive 即假定继承⼈,假定继承⼈被唤醒后会尝试获得锁,

但 synchronized 是⾮公平的,所以假定继承⼈不⼀定能获得锁。这是因为对于重

量级锁,线程先⾃旋尝试获得锁,这样做的⽬的是为了减少执⾏操作系统同步操作

带来的开销。如果⾃旋不成功再进⼊等待队列。这对那些已经在等待队列中的线程

来说,稍微显得不公平,还有⼀个不公平的地⽅是⾃旋线程可能会抢占了Ready线

程的锁。

果线程获得锁后调⽤ Object.wait ⽅法,则会将线程加⼊到WaitSet中,当

被 Object.notify 唤醒后,会将线程从WaitSet移动到Contention List或EntryList中

去。需要注意的是,当调⽤⼀个锁对象的 wait 或 notify ⽅法时,如当前锁的状

态是偏向锁或轻量级锁则会先膨胀成重量级锁。

自旋锁(Spinlock)是一种轻量级的锁机制,用于在多线程环境中同步共享资源。与传统的锁(如 synchronizedReentrantLock)不同,自旋锁在获取锁的过程中不会立即阻塞线程,而是通过不断循环检查锁的状态来等待锁的释放。这种“自旋”的方式避免了线程的上下文切换,但可能会消耗大量的 CPU 资源。

自旋锁的基本原理

自旋锁的工作方式类似于一个忙等待的机制:

  1. 一个线程尝试获取锁时,首先检查锁是否已被其他线程持有。
  2. 如果锁未被持有,线程成功获取锁,进入临界区。
  3. 如果锁已被其他线程持有,线程不会进入阻塞状态,而是在一个循环中反复检查锁的状态,直到锁被释放。
  4. 当锁被释放后,线程继续获取锁,执行临界区代码。
  5. 自旋锁的优缺点

优点:

  1. 避免上下文切换的开销:由于线程在等待时不会被操作系统挂起或切换上下文,因此在多核 CPU 系统中,如果锁竞争的时间很短,自旋锁可以减少操作系统调度的开销。
  2. 适合短时间的锁持有场景:如果锁的持有时间非常短,自旋锁的效率会比传统锁更高,因为线程避免了从阻塞到唤醒的过程。

缺点:

  1. 浪费 CPU 资源:如果锁竞争时间较长,自旋锁会导致线程长时间自旋,占用 CPU 时间,可能导致资源浪费。
  2. 不适合长时间锁等待:对于长时间持有锁的情况,自旋锁的性能会非常低,传统的锁(如 synchronizedReentrantLock)在这种情况下更合适,因为它们会让线程进入等待状态而不是浪费 CPU 资源。

自旋锁的应用场景

自旋锁一般适用于以下场景:

  • 锁竞争较少:当大多数情况下锁可以快速获得时,自旋锁的效率非常高。
  • 锁持有时间短:如果每个线程持有锁的时间非常短(例如在微秒级别),自旋锁可以有效减少线程的阻塞时间。

在 Java 中,java.util.concurrent 包下的 ReentrantLock 提供了类似自旋锁的机制,在某些实现中会在阻塞之前先自旋尝试获取锁,从而提升短时锁竞争下的性能。

总结

自旋锁是一种用于多线程同步的轻量级锁机制,适用于锁竞争较少且持有时间短的场景。它通过让线程在获取锁失败时自旋等待而不是阻塞,减少了上下文切换的开销。但对于长时间持有锁或锁竞争激烈的场景,自旋锁的效率会较低,可能会导致 CPU 资源的浪费,因此需要谨慎选择使用场合。

乐观锁与悲观锁的概念

锁可以从不同的⻆度分类。其中,乐观锁和悲观锁是⼀种分类⽅式。

悲观锁:

悲观锁就是我们常说的锁。对于悲观锁来说,它总是认为每次访问共享资源时会发

⽣冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同⼀时间只能有⼀

个线程在执⾏。

乐观锁:

乐观锁⼜称为“⽆锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问

没有冲突,线程可以不停地执⾏,⽆需加锁也⽆需等待。⽽⼀旦多个线程发⽣冲

突,乐观锁通常是使⽤⼀种称为CAS的技术来保证线程执⾏的安全性。

由于⽆锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天⽣

免疫死锁。

乐观锁多⽤于“读多写少“的环境,避免频繁加锁影响性能;⽽悲观锁多⽤于”写多读

少“的环境,避免频繁失败和重试影响性能。

CAS的概念

CAS的全称是:⽐较并交换(Compare And Swap)。在CAS中,有这样三个值:

V:要更新的变量(var)

E:预期值(expected)

N:新值(new)

⽐较并交换的过程如下:

判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程

更新了V,则当前线程放弃更新,什么都不做。

所以这⾥的预期值E本质上指的是“旧值”。

当多个线程同时使⽤CAS操作⼀个变量时,只有⼀个会胜出,并成功更新,其余均

会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然

也允许失败的线程放弃操作。

资源共享模式

资源有两种共享模式,或者说两种同步⽅式:

独占模式(Exclusive):资源是独占的,⼀次只能⼀个线程获取。如

ReentrantLock。

共享模式(Share):同时可以被多个线程获取,具体的资源个数可以通过参

数指定。如Semaphore/CountDownLatch。

加群联系作者vx:xiaoda0423

仓库地址:https://github.com/webVueBlog/JavaGuideInterview

算法猫叔
程序员:进一寸有一寸的欢喜
 最新文章