多线程笔记
秋水 Lv5

基本概念

进程

进程是程序运行资源分配的最小单位

线程

cpu调度的最小单位

上下文切换

上下文切换是指 CPU 从一个线程转到另一个线程时,需要保存当前线程的上下文状态,恢复另一个线程的上下文状态,以便于下一次恢复执行该线程时能够正确地运行。

在多线程编程中,上下文切换是一种常见的操作,上下文切换通常是指在一个 CPU 上,由于多个线程共享 CPU 时间片,当一个线程的时间片用完后,需要切换到另一个线程运行。此时需要保存当前线程的状态信息,包括程序计数器、寄存器、栈指针等,以便下次继续执行该线程时能够恢复到正确的执行状态。同时,需要将切换到的线程的状态信息恢复,以便于该线程能够正确运行。

在多线程中,上下文切换的开销比直接用单线程大,因为在多线程中,需要保存和恢复更多的上下文信息。过多的上下文切换会降低系统的运行效率,因此需要尽可能减少上下文切换的次数。

面试:进程之间的通信IPC

  1. 管道 : 匿名管道(主要用于父子进程之间),命名管道(无亲缘之间的通信)
  2. 信号通信
  3. 消息队列
  4. 共享内存
  5. 信号量
  6. 套接字

并发和并行

并发: 应用能够交替执行不同的任务

并行:应用能够同时执行不同的任务

新启动线程有几种方式

官方的说法是在java中有两种创建方式,一种是继承Thread类,另一种是实现Runnable接口,是在Thread类的注释中写道的。

当然本质上实现线程只有一种方式,都是通过New Thread()创建线程对象,调用Thread.start启动线程。

至于基于callable接口的方式,因为最终是要把实现了的callable接口的对象通过FutureTask包装成runnable,最后交给Thread执行,所以这个其实我认为属于Runnable接口的,至于线程池,本质上是一种池化技术,是资源的复用,和新启动线程没什么关系。

线程结束的方式

安全的方式

其他线程通过调用某个线程的Interrupt()方法对其进行中断操作,线程会检查自身的中断标志位是否被设置为true来进行响应。

线程通过IsInterrupt()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()来判断当前线程是否被中断,不过Thread.interrupted()会同时将中断标识位改写为false。

如果一个线程处于阻塞状态(比如线程调用了 sleep,jon,wait等) ,则在线程检查中断标识时如果发现中断标识位为true,则会在阻塞方法中抛出InterruptedException异常,并且在抛出异常后会立即将线程的中断标识位清楚,重新设置为false。

​ 不建议自定义一个取消标识位来阻止线程的运行,因为run方法里有阻塞调用时会无法很快检测到取消标识,线程必须从阻塞调用返回后,才会检查这个取消标识,这种情况,使用中断更好,因为

  1. 一般的阻塞方法,如sleep本身就支持中断的检查
  2. 检查中断位的状态和取消标识位没什么区别,用中断位的状态还可以避免声明取消标识位,减少资源的消耗。

线程的调度

线程调度是指系统为线程分配cpu使用权的过程,主要调度方式有两种

  1. 协同式线程调度
  2. 抢占式线程调度

协同式线程调度

一个线程进来后,执行时间由线程自己本身决定,执行任务到结束后,才会主动通知下一个线程开始执行,好处就是实现简单,由于线程要把自己的事情做完才会通知系统进行线程切换,所以没有线程同步的问题,坏处就是如果一个线程出现了问题,系统就会一直阻塞。

抢占式线程调度

抢占式线程调度是指,每个线程的执行时间以及是否切换由系统决定,在这个情况下,线程的执行时间不可控,所以不会有一个线程导致整个进程阻塞的问题。

对象锁和类锁的区别

​ 对象锁事用于对象实例方法,或者一个对象实例上的,类锁事用于类的静态方法或者一个类的class对象上。

volatile

volatile保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

试用场景

​ 一个线程写,多个线程读取

同步块

同步代码块的语法如下:

synchronized (对象) {

// 需要同步的代码块

}

在同步代码块中,我们需要指定一个对象作为锁

ThreadLocal

ThreadLocal 是 Java 提供的一个类,用于创建线程局部变量。它为每个使用该变量的线程都提供一个独立的变量副本。这种机制在多线程编程中非常有用,因为它可以避免多个线程同时访问和修改同一个变量,从而防止竞争条件和数据不一致问题。

ThreadLocal内部是如何实现的

ThreadLocal内部每一个线程都有自己的ThreadLocalMap,用于存储和当前线程相关的ThreadLocal变量,

ThreadLocalMap类
  1. ThreadLocalMapThreadLocal 内部的一个静态类,负责存储每个线程的局部变量。它类似于一个定制的哈希表,使用 ThreadLocal 对象作为键,存储线程局部变量的值。

  2. ThreadLocalMap 的键是 ThreadLocal 对象的弱引用。这意味着如果没有其他强引用指向 ThreadLocal 对象,当垃圾回收发生时,这些键可以被回收,以防止内存泄漏。

  3. ThreadLocalMap 使用开放地址法处理哈希冲突,即当发生哈希冲突时,通过线性探测寻找下一个空槽。

ThreadLocal是实现保证事务的呢

是因为ThreadLocal内部传入name参数,创建了线程副本,实现了与线程进行绑定。

ThreadLocal 在运行过程中可能会产生内存泄漏

什么是内存泄漏呢,线程跑完了,gc没有清理掉强引用的数据,所以这个时候如果在执行完全部的代码的时候,可以使用remove()方法。

CAS

CAS(Compare and Swap)是一种并发控制机制,通常用于多线程编程或并发环境中。它用于解决多个线程同时修改同一内存位置可能引发的竞态条件(race condition)问题。

cas操作包括三个步骤:

  1. 比较:首先,他会比较内存中的某个值与预期的值是否相等
  2. 交换:如果相等,就把新的值写入内存位置,如果不想等,则不做任何操作
  3. 返回结果:CAS操作会返回操作是否成功的标志,通常是一个布尔值,表示是否成功更新了内存中的值。

优缺点

优点

  1. 高效,避免了线程阻塞和上下文切换的开销 但实际java实现的时候尝试到一定次数失败之后就会自动阻塞
  2. 避免了传统锁带来的死锁,优先级反转等问题

缺点

  1. 自旋开销,多个线程频繁竞争一个共享资源,cpu资源浪费
  2. ABA问题

CAS会出现的三个大问题

  1. ABA问题,版本戳,增加一个标志位,每次更改都更新标志
  2. 循环时间太长,cpu占用内存高
  3. 只能保证一个共享变量的原子操作

关于CAS和Syn场景问题:

如果是一个迅速响应1000个线程的情况下,Syn会比CAS更快,大部分CAS线程操作不成功,在空转,占用了大量的CPU,Syn会让线程进入阻塞,这种情况下,CAS速度不如Syn快。CAS偏向于轻量级锁。

原子操作在处理并发更新同一个数值时确实可能会导致写热点问题

写热点指的是高并发情况下,多个线程频繁地对同一个数据进行写操作,导致性能下降或产生竞争瓶颈。为了解决这一问题,可以采取以下几种策略:

  1. 使用分段锁(Segmented Locks)
  2. 热点数据缓存
  3. 分散热点(Sharding/Partitioning)
  4. 无锁数据结构

java提高原子操作的办法

关于LongAdder和AtmoicLong相比

LongAdder 通过将计数器分解成多个独立的变量(cells),并由多个线程分别对这些变量进行更新,从而减少竞争。当需要获取总和时,它将所有变量的值累加起来。因此,在高并发情况下,LongAdder 通常比 AtomicLong 更高效

特性

  1. 高并发性能:在高并发场景下,LongAdder 通过减少线程间的竞争来提高性能。

  2. 最终一致性LongAdder 提供的值是一个估计值,随着时间的推移,它会趋近于准确值。

  3. 线程安全LongAdder 是线程安全的,可以在多个线程中并发使用

#####. LongAdderjava.util.concurrent 包中提供的一种高效计数器,用于解决高并发场景下的写竞争问题。与 AtomicLong 相比,LongAdder 通过分段累加来减少竞争,从而提高并发性能。然而,LongAdder 在某些场景下可能无法完全替代 AtomicLong

LongAdder 的工作原理

LongAdder 通过将计数器分解成多个独立的变量(cells),并由多个线程分别对这些变量进行更新,从而减少竞争。当需要获取总和时,它将所有变量的值累加起来。因此,在高并发情况下,LongAdder 通常比 AtomicLong 更高效。

LongAdder 的特性

  • 高并发性能:在高并发场景下,LongAdder 通过减少线程间的竞争来提高性能。
  • 最终一致性LongAdder 提供的值是一个估计值,随着时间的推移,它会趋近于准确值。
  • 线程安全LongAdder 是线程安全的,可以在多个线程中并发使用。

示例代码

下面是一个使用 LongAdder 的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java
复制代码
import java.util.concurrent.atomic.LongAdder;

public class LongAdderExample {
public static void main(String[] args) {
LongAdder adder = new LongAdder();

// 启动多个线程来进行计数
for (int i = 0; i < 1000; i++) {
new Thread(() -> adder.increment()).start();
}

// 等待所有线程完成
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 获取计数值
System.out.println("Counter value: " + adder.sum());
}
}

LongAdder 与 AtomicLong 的比较

  • 性能:在高并发情况下,LongAdder 的性能通常优于 AtomicLong,因为它减少了竞争。
  • 准确性AtomicLong 提供精确的值,而 LongAdder 在高并发场景下可能提供一个估计值,但最终会趋近于准确值。
  • 适用场景:如果需要精确的计数,且并发量不高,AtomicLong 更适合;如果并发量很高,且允许有小幅度的估计误差,LongAdder 是更好的选择
具体场景

多个并发用户同时修改同一行数据,使用CAS会增加冲突重试的次数,而传统的数据库锁机制(如行级锁或表级锁)能够更有效地管理并发访问。

线程安全

实现线程安全的方法

  1. 线程封闭
    1. 栈封闭
    2. ThreadLocal
  2. 使用无状态的类
  3. 让类不可变
  4. 加锁和CAS

死锁

是指两个或者两个以上的进程,由于竞争资源或者由于彼此通信从而造成一种阻塞状态,若无外力作用,他们都将无法进行下去,此时称系统处于死锁或者产生了死锁。

总结

  1. 死锁是必然发生在多个操作者争夺多个资源的情况,单线程不会有死锁,单资源也不会
  2. 争夺资源的顺序不对,如果顺序一样,也不会死锁
  3. 争夺者对拿到的资源不放手。

解决方法

  1. 打破顺序: 动态顺序死锁(自己写个判断)让他一定按照顺序
  2. 显示锁,里面有个方法trylock()拿到锁就全部执行,拿不到就全部释放锁

活锁

线程饥饿

低优先级的线程,拿不到锁。

高并发

高并发写

  1. 数据分片

公平锁和非公平锁

公平锁

线程在获取锁的时候,按照等待的顺序获取锁

非公平锁

线程在获取锁时,不按照等待的先后顺序获取锁,而是随机获取锁,ReentrantLock默认是非公平锁。

1
2
private final ReentrantLock lock = new ReentrantLock(); //默认非公平
private final ReentrantLock lock = new ReentrantLock(true);//公平锁

可重入锁

可重入锁,又名递归锁,是指同一个线程在外层方法获取锁的时候,在进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象),不会因为之前已经获取过还没有释放而阻塞,Java中ReentrantLock和Synchronized都是可重入锁,可重入锁的一个优点就是可一定程度避免死锁。在实际开发中,可重入锁常常应用于递归操作,调用同一个类中的其他方法,锁嵌套等场景中。

等待唤醒机制

  1. Synchronizd + object.wait()/object.notifyAll()
  2. ReentrantLock + condition.await()/ contain.signal/signalAll

AQS

AQS(AbstractQueuedSynchronizer)是Java中的一个基础框架,用于构建锁和同步器。ReentrantLock是基于AQS实现的.

ReentrantLock 是如何实现的

一个线程进来,获取cas之后,其他线程进行cas操作,如果我们使用了可重入锁,在判断阶段会有判断,如果没有,别的线程会一直自旋,到一定次数之后,就进入阻塞队列(双向链表实现的)。

流程

线程1 ->. cas操作,加锁成功 -> state = 1 -> exclusiveOwnerThread = 线程一(可重入锁的判断依据)

线程2 -> cas操作 加锁失败. -> 入队阻塞 -> 阻塞前需要设置前一个线程的waitState=-1 ->. LockSupport.park(this)阻塞

特性

  1. 阻塞等待队列
  2. 共享/独占
  3. 公平/非公平
  4. 可重入
  5. 允许中断

AQS获取的下一个阻塞的线程是头节点吗

AQS获取的下一个阻塞的线程不是头节点,是头节点的下一个节点,然后断开链接,后续让gc

读写锁ReadAndWrite

AQS

互斥 : tryAcquire,tryRelease 写锁

共享 : tryAcquireShared, tryReleaseShared 读锁

读写锁的判断以及如何实现可重入锁

首先 写锁和读锁是互斥的,为了判断读写锁的状态,我们可以检查int的高低位,int 4个字节 ,32位,高16位为读锁,低16位为写锁

读写锁降级

写 的过程中可以获取读锁, 写锁修改缓冲之后,读锁可以读取到最新的数据

检查步骤

写锁获取锁之前,首先检查数是否大于0 如果大于0且低16位为0,则说明有写锁,然后可重入写锁,是通过ThreadLocal定义线程持有者,然后检查是否是同一个线程,是的话重入次数+1

读写锁

StampedLock锁

读锁,乐观读(无锁),支持锁升级

如何实现:

  1. 首先获取乐观锁(无锁状态读取数据),在最后判断是否有其他写锁发生,如果有加一个悲观读锁,然后更新数据。

MESA

管程模型 JAVA中的AQS模型就采用了管程模型

管程模型

并发容器

同步容器

同步容器简单的理解为通过Synchronized来实现同步的容器

并发容器

Map

  • ConcurrentHashMap
  • ConcurrentSkipListMap

List

  • CopyOnWriteArrayList

Set

  • CopyOnWriteArraySet
  • ConcurrentSkipListSet

CopyOnWriteArrayList应用场景

copyonwriteArrayList是java中的一种线程安全的list,他是一个可变的数组,支持并发读和写,与普通的arraylist不同,他的曲度操作不需要加锁,因为具有很高的并发性能。

读取过程内部,其实就是复制原有数组,并且扩大容量,然后加入新的元素

  1. 读多写少
  2. 不需要实时更新的数据

缺点

  1. 由于写操作的时候,需要拷贝数组 ,消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc
  2. 不能用于实时读的场景,像拷贝数组,新增元素都需要时间,所以调用一个set操作后,读取到的数据可能还是旧的,能做到最终一致性,但没法满足实时性要求。
  3. 数据太多的话,容易引起故障

ConcurrentHashMap

ConcurrentHashMap是java中线程安全的哈希表,他支持高并发并且能够同时进行读写操作。

底层原理

java 1.7 之前是一个Segment数组 —(加锁)—> 到一个Entry数组 ,entry数组后续接的是链表。(实现了一个写分散)

java1.8之后抛弃了Segment数组 变成了 数组+链表+红黑树的结构,在并发方面使用CAS和Syn保证数据的一致性

链表转红黑树的要求

  1. 链表的节点数量大于等于树化阈值8
  2. Node数组的长度大于等于最小树化容量值64

应用场景

  1. 贡献数据的线程安全: 在多线程编程中,如果要进行共享数据的读写,可以使用ConcurrentHashMap保证线程安全

ConcurrentSkipListMap

基于跳表实现的有序映射Map数据结构,实现了对TreeMap的兵法实现,适用于需要高并发性能,支持有序性和区间查询的场景。

电商场景中并发容器的选择

案例1 : 电商网站中记录一次活动下各个商品的售卖的数量

场景分析: 频繁按照商品id做get和set但是商品id的数量相对稳定不会频繁增删

解决:

初级方案 :用HashMap 但会出现线程安全问题,出现数据丢失。

选型: 用ConcurrentHashMap

阻塞队列

ArrayBlockingQueue

有界阻塞队列,先进先出,存取相互排斥

数据结构 静态数组,必须指定容量,没有扩容机制,没有元素位置也占用空间,被null占位

ReentrantLock 存取是同一把锁,操作的是同一个数组对象,存取互相排斥

阻塞对象

  1. NotEmpty - 出队 队列count = 0 无元素可取时,阻塞该对象
  2. NotFull - 入队 队列 count = length 放不进去元素时, 阻塞该对象

入队 从队首开时添加元素,记录putIndex。到队尾时设置为0 唤醒NotEmpty

出队 从队首开时取出元素,记录takeIndex, 到队尾设置为0,唤醒NotFull

优点: 可以实现消息削峰, 生产者消息放入队列,消费者消费消息出队列。

LinkedBlockingQueue

无界阻塞队列,可指定容量,默认为Integer.MAX_VALUE, 先进先出,存取互不干扰,

数据结构 链表 存取互不干扰,存取操作是不同的node对象

takeLock 取Node节点保证前驱后继不会乱

putLock 存Node节点保证前驱后继不会乱

删除元素的时候两个锁在一起

入队 队尾入队

出队 队首出队

DelayQueue

一个使用优先级队列实现的无界阻塞队列

数据结构 : PriorityQueue - 与PriorityBlockingQueue类似,不过没有阻塞功能

锁: ReentrantLock

入队: 不阻塞,无界队列,与优先级队列入队相同

出队:

  1. 为空时阻塞
  2. 检查堆顶元素过期时间
    1. 小于等于0出队
    2. 大于0,说明没过期,阻塞,判断leader线程是否为空
      1. 不为空(已经有线程阻塞,直接阻塞)
      2. 为空,则当前线程设置为leader,并按照过期时间进行阻塞

应用场景:

  1. 商场订单超时关闭 ———— 淘宝订单业务:下单之后如果20分钟内没有付款就自动取消订单
  2. 异步短信通知功能———— 饿了么订单通知功能,下单成功后60s之后给用户发送短信通知
  3. 关闭空链接
  4. 缓存过期清除
  5. 任务超时处理,在网络协议滑动窗口请求应答式交互时,处理超时未响应的请求等。

选择策略

功能

容量

能否扩容

内存结构

性能

线程池

线程池的核心一般需要设置多少

  1. cpu密集型任务 ,线程在执行的过程中会一直用cpu,所以我们对于这种情况,就按照核数设置

  2. io密集型任务。比如文件io,网络io ,一般设置为任务执行时间*2+线程上下文切换时间

    如果io型人物执行时间越长,那么同时阻塞咋io上的线程可能就越多,我们就可以设置更多的线程,但是线程不是越多越好,我们可以通过计算公式

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

在实际工作过程中,可能有多个线程池,除了线程池中的线程,还有其他线程,所以实际工作中,我会选择,用Postman进行压力测试

核心线程数, 线程数,

队列长度 : 要看你接受的排队的时间有多久。

  1. 首先要考虑业务是核心业务还是边缘业务

线程池内部

如果队列为空,线程会读取之后进入阻塞状态,所以任务进队,之后,空闲线程就回去获取队列的任务,然后执行

如果任务满了,增加到最大线程数,如果还有任务进来,但队列满了,就无法处理了,有四种处理办法

非核心线程,会把队列的任务全部执行完,才会把非核心线程淘汰掉,通过cas操作消失掉,循环,继续消失,直到剩下核心线程数。

如果一个线程异常退出,就会增加一个线程。

tomcat线程池

  1. 需要等到最大线程池全部都创建出来,然后才开始队列入队
  2. 获取当前空闲线程,小于最大线程数,然后开始入队。
  3. 如果我们没有最大线程数,我们会优先创建线程

线程池线程退出

  1. cas 退出
  2. 执行出异常,退一个,补一个。

线程池的五个状态

  1. running: 接受新的任务和处理队列人物
  2. shutdown: 不能接受新任务,但是能处理队列任务
  3. stop:不能接受新任务,不能处理队列任务。
  4. tidying:所有任务都已经终止,线程池中没有线程了,这时线程池的状态会变成tidying,一旦达到这个状态,就会调用线程池额度terminaterd方法
  5. terminated: terminated()执行完之后就会转变为terminated

深入理解并发可见性,有序行,原子性与JMM内存模型

并发的三大行 原子性,可见性,有序性

如何保证原子性

  1. 通过Synchronized关键字保证原子性
  2. 通过Lock锁保证原子性
  3. 通过CAS保证原子性

如何保证可见性

  1. 通过volatile关键字保证可见性
  2. 通过内存屏障 UnsafeFactory.getUnsafe().storeFence(); sleep 有调用内存屏障
  3. 通过Synchronized关键字保证可见性
  4. 通过Lock锁保证可见性

java内存模型

在并发编程中,需要处理两个关键问题:

  1. 多线程之间如何通信
  2. 多线程之间如何同步

线程之间常用的通信机制有两种,共享内存和消息传递,java采用的是共享内存模型。

并发常用的设计模式

​ 1. 线程T1如何 优雅的终止线程T2

错误思路1: stop方法会真正的杀死线程,如果这时线程锁住了共享资源,那么他被杀死后没办法释放锁,其他线程永远无法获取锁

错误思路2: System。exit方法停止线程: 目的仅是停止一个线程,但这种做法会让整个程序都停止。

正确思路:两阶段终止模式

1.1 两阶段终止模式

第一阶段 在java中如何发送终止请求

java线程进入终止状态的前提是线程进入Runnable状态,而实际上线程也可能处于休眠状态,也就是说,我们想要终止一个线程,首先要把线程的状态从休眠状态专程Runnable状态,利用线程中断机制的interrupt()方法,可以让线程从休眠状态转到Runnable状态。

第二阶段

利用标志位,调用interrupt()方法

注意 一个检查终止标志位是不够的,因为线程的皇台可能处于休眠。

一个中断状态也是不够的,例如Thread。sleep()方法会清空中断状态,所以需要两个。

好处

  1. 避免终止线程带来副作用
  2. 安全性: 两段终止模式可以在线程终止前执行必要的清理工作
  3. 灵活性: 两段终止模式可以根据具体情况灵活的设置终止条件和清理工作。

1.2避免共享的设计模式

1.2.1不变性模式

  1. 缓存:缓存数据是共享的,通常使用不变形模式来确保缓存数据的不变性。
  2. 值对象: 在一些系统中,需要定义一些值对象来表示一些常量或者不可变的对象。
  3. 配置信息: 在一些系统中,需要读取一些配置信息来配置系统参数和行为,由于配置信息通常是不变的,可以使用不变性模式来确保配置信息的不变性。

如何做到不变性

  1. final关键字和私有
  2. 写时复制 CopyOnWrite 用于读多写少
  3. 线程本地存储 适用于:保存上下文信息,保存线程安全,使用ThreadLocal (注意线程泄漏)

多线程版本的if else

守护挂起

  1. syn wait + notify
  2. ReentrantLock await single
  3. cas+park / unpark

避免执行模式

如果不适合执行这个操作,或者没有必要执行这个操作,就停止处理,直接返回。

使用场景:

  1. sychronized轻量级锁膨胀逻辑, 只需要一个线程膨胀获取monitor对象
  2. DCL单例实现
  3. 服务组件的初始化
  • Post title:多线程笔记
  • Post author:秋水
  • Create time:2024-06-22 21:58:36
  • Post link:tai769.github.io2024/06/22/多线程笔记/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.