基本概念
进程
进程是程序运行资源分配的最小单位
线程
cpu调度的最小单位
上下文切换
上下文切换是指 CPU 从一个线程转到另一个线程时,需要保存当前线程的上下文状态,恢复另一个线程的上下文状态,以便于下一次恢复执行该线程时能够正确地运行。
在多线程编程中,上下文切换是一种常见的操作,上下文切换通常是指在一个 CPU 上,由于多个线程共享 CPU 时间片,当一个线程的时间片用完后,需要切换到另一个线程运行。此时需要保存当前线程的状态信息,包括程序计数器、寄存器、栈指针等,以便下次继续执行该线程时能够恢复到正确的执行状态。同时,需要将切换到的线程的状态信息恢复,以便于该线程能够正确运行。
在多线程中,上下文切换的开销比直接用单线程大,因为在多线程中,需要保存和恢复更多的上下文信息。过多的上下文切换会降低系统的运行效率,因此需要尽可能减少上下文切换的次数。
面试:进程之间的通信IPC
- 管道 : 匿名管道(主要用于父子进程之间),命名管道(无亲缘之间的通信)
- 信号通信
- 消息队列
- 共享内存
- 信号量
- 套接字
并发和并行
并发: 应用能够交替执行不同的任务
并行:应用能够同时执行不同的任务
新启动线程有几种方式
官方的说法是在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方法里有阻塞调用时会无法很快检测到取消标识,线程必须从阻塞调用返回后,才会检查这个取消标识,这种情况,使用中断更好,因为
- 一般的阻塞方法,如sleep本身就支持中断的检查
- 检查中断位的状态和取消标识位没什么区别,用中断位的状态还可以避免声明取消标识位,减少资源的消耗。
线程的调度
线程调度是指系统为线程分配cpu使用权的过程,主要调度方式有两种
- 协同式线程调度
- 抢占式线程调度
协同式线程调度
一个线程进来后,执行时间由线程自己本身决定,执行任务到结束后,才会主动通知下一个线程开始执行,好处就是实现简单,由于线程要把自己的事情做完才会通知系统进行线程切换,所以没有线程同步的问题,坏处就是如果一个线程出现了问题,系统就会一直阻塞。
抢占式线程调度
抢占式线程调度是指,每个线程的执行时间以及是否切换由系统决定,在这个情况下,线程的执行时间不可控,所以不会有一个线程导致整个进程阻塞的问题。
对象锁和类锁的区别
对象锁事用于对象实例方法,或者一个对象实例上的,类锁事用于类的静态方法或者一个类的class对象上。
volatile
volatile保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
试用场景
一个线程写,多个线程读取
同步块
同步代码块的语法如下:
synchronized (对象) {
// 需要同步的代码块
}
在同步代码块中,我们需要指定一个对象作为锁
ThreadLocal
ThreadLocal
是 Java 提供的一个类,用于创建线程局部变量。它为每个使用该变量的线程都提供一个独立的变量副本。这种机制在多线程编程中非常有用,因为它可以避免多个线程同时访问和修改同一个变量,从而防止竞争条件和数据不一致问题。
ThreadLocal内部是如何实现的
ThreadLocal内部每一个线程都有自己的ThreadLocalMap,用于存储和当前线程相关的ThreadLocal变量,
ThreadLocalMap类
ThreadLocalMap
是ThreadLocal
内部的一个静态类,负责存储每个线程的局部变量。它类似于一个定制的哈希表,使用ThreadLocal
对象作为键,存储线程局部变量的值。ThreadLocalMap
的键是ThreadLocal
对象的弱引用。这意味着如果没有其他强引用指向ThreadLocal
对象,当垃圾回收发生时,这些键可以被回收,以防止内存泄漏。ThreadLocalMap
使用开放地址法处理哈希冲突,即当发生哈希冲突时,通过线性探测寻找下一个空槽。
ThreadLocal是实现保证事务的呢
是因为ThreadLocal内部传入name参数,创建了线程副本,实现了与线程进行绑定。
ThreadLocal 在运行过程中可能会产生内存泄漏
什么是内存泄漏呢,线程跑完了,gc没有清理掉强引用的数据,所以这个时候如果在执行完全部的代码的时候,可以使用remove()方法。
CAS
CAS(Compare and Swap)是一种并发控制机制,通常用于多线程编程或并发环境中。它用于解决多个线程同时修改同一内存位置可能引发的竞态条件(race condition)问题。
cas操作包括三个步骤:
- 比较:首先,他会比较内存中的某个值与预期的值是否相等
- 交换:如果相等,就把新的值写入内存位置,如果不想等,则不做任何操作
- 返回结果:CAS操作会返回操作是否成功的标志,通常是一个布尔值,表示是否成功更新了内存中的值。
优缺点
优点
- 高效,避免了线程阻塞和上下文切换的开销 但实际java实现的时候尝试到一定次数失败之后就会自动阻塞
- 避免了传统锁带来的死锁,优先级反转等问题
缺点
- 自旋开销,多个线程频繁竞争一个共享资源,cpu资源浪费
- ABA问题
CAS会出现的三个大问题
- ABA问题,版本戳,增加一个标志位,每次更改都更新标志
- 循环时间太长,cpu占用内存高
- 只能保证一个共享变量的原子操作
关于CAS和Syn场景问题:
如果是一个迅速响应1000个线程的情况下,Syn会比CAS更快,大部分CAS线程操作不成功,在空转,占用了大量的CPU,Syn会让线程进入阻塞,这种情况下,CAS速度不如Syn快。CAS偏向于轻量级锁。
原子操作在处理并发更新同一个数值时确实可能会导致写热点问题
写热点指的是高并发情况下,多个线程频繁地对同一个数据进行写操作,导致性能下降或产生竞争瓶颈。为了解决这一问题,可以采取以下几种策略:
- 使用分段锁(Segmented Locks)
- 热点数据缓存
- 分散热点(Sharding/Partitioning)
- 无锁数据结构
java提高原子操作的办法
关于LongAdder和AtmoicLong相比
LongAdder
通过将计数器分解成多个独立的变量(cells),并由多个线程分别对这些变量进行更新,从而减少竞争。当需要获取总和时,它将所有变量的值累加起来。因此,在高并发情况下,LongAdder
通常比 AtomicLong
更高效
特性
高并发性能:在高并发场景下,
LongAdder
通过减少线程间的竞争来提高性能。最终一致性:
LongAdder
提供的值是一个估计值,随着时间的推移,它会趋近于准确值。线程安全:
LongAdder
是线程安全的,可以在多个线程中并发使用
#####. LongAdder
是 java.util.concurrent
包中提供的一种高效计数器,用于解决高并发场景下的写竞争问题。与 AtomicLong
相比,LongAdder
通过分段累加来减少竞争,从而提高并发性能。然而,LongAdder
在某些场景下可能无法完全替代 AtomicLong
。
LongAdder 的工作原理
LongAdder
通过将计数器分解成多个独立的变量(cells),并由多个线程分别对这些变量进行更新,从而减少竞争。当需要获取总和时,它将所有变量的值累加起来。因此,在高并发情况下,LongAdder
通常比 AtomicLong
更高效。
LongAdder 的特性
- 高并发性能:在高并发场景下,
LongAdder
通过减少线程间的竞争来提高性能。 - 最终一致性:
LongAdder
提供的值是一个估计值,随着时间的推移,它会趋近于准确值。 - 线程安全:
LongAdder
是线程安全的,可以在多个线程中并发使用。
示例代码
下面是一个使用 LongAdder
的示例代码:
1 | java |
LongAdder 与 AtomicLong 的比较
- 性能:在高并发情况下,
LongAdder
的性能通常优于AtomicLong
,因为它减少了竞争。 - 准确性:
AtomicLong
提供精确的值,而LongAdder
在高并发场景下可能提供一个估计值,但最终会趋近于准确值。 - 适用场景:如果需要精确的计数,且并发量不高,
AtomicLong
更适合;如果并发量很高,且允许有小幅度的估计误差,LongAdder
是更好的选择
具体场景
多个并发用户同时修改同一行数据,使用CAS会增加冲突重试的次数,而传统的数据库锁机制(如行级锁或表级锁)能够更有效地管理并发访问。
线程安全
实现线程安全的方法
- 线程封闭
- 栈封闭
- ThreadLocal
- 使用无状态的类
- 让类不可变
- 加锁和CAS
死锁
是指两个或者两个以上的进程,由于竞争资源或者由于彼此通信从而造成一种阻塞状态,若无外力作用,他们都将无法进行下去,此时称系统处于死锁或者产生了死锁。
总结
- 死锁是必然发生在多个操作者争夺多个资源的情况,单线程不会有死锁,单资源也不会
- 争夺资源的顺序不对,如果顺序一样,也不会死锁
- 争夺者对拿到的资源不放手。
解决方法
- 打破顺序: 动态顺序死锁(自己写个判断)让他一定按照顺序
- 显示锁,里面有个方法trylock()拿到锁就全部执行,拿不到就全部释放锁
活锁
线程饥饿
低优先级的线程,拿不到锁。
高并发
高并发写
- 数据分片
公平锁和非公平锁
公平锁
线程在获取锁的时候,按照等待的顺序获取锁
非公平锁
线程在获取锁时,不按照等待的先后顺序获取锁,而是随机获取锁,ReentrantLock默认是非公平锁。
1 | private final ReentrantLock lock = new ReentrantLock(); //默认非公平 |
可重入锁
可重入锁,又名递归锁,是指同一个线程在外层方法获取锁的时候,在进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象),不会因为之前已经获取过还没有释放而阻塞,Java中ReentrantLock和Synchronized都是可重入锁,可重入锁的一个优点就是可一定程度避免死锁。在实际开发中,可重入锁常常应用于递归操作,调用同一个类中的其他方法,锁嵌套等场景中。
等待唤醒机制
- Synchronizd + object.wait()/object.notifyAll()
- 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)阻塞
特性
- 阻塞等待队列
- 共享/独占
- 公平/非公平
- 可重入
- 允许中断
AQS获取的下一个阻塞的线程是头节点吗
AQS获取的下一个阻塞的线程不是头节点,是头节点的下一个节点,然后断开链接,后续让gc
读写锁ReadAndWrite
AQS
互斥 : tryAcquire,tryRelease 写锁
共享 : tryAcquireShared, tryReleaseShared 读锁
读写锁的判断以及如何实现可重入锁
首先 写锁和读锁是互斥的,为了判断读写锁的状态,我们可以检查int的高低位,int 4个字节 ,32位,高16位为读锁,低16位为写锁
读写锁降级
写 的过程中可以获取读锁, 写锁修改缓冲之后,读锁可以读取到最新的数据
检查步骤
写锁获取锁之前,首先检查数是否大于0 如果大于0且低16位为0,则说明有写锁,然后可重入写锁,是通过ThreadLocal定义线程持有者,然后检查是否是同一个线程,是的话重入次数+1
StampedLock锁
读锁,乐观读(无锁),支持锁升级
如何实现:
- 首先获取乐观锁(无锁状态读取数据),在最后判断是否有其他写锁发生,如果有加一个悲观读锁,然后更新数据。
MESA
管程模型 JAVA中的AQS模型就采用了管程模型
并发容器
同步容器
同步容器简单的理解为通过Synchronized来实现同步的容器
并发容器
Map
- ConcurrentHashMap
- ConcurrentSkipListMap
List
- CopyOnWriteArrayList
Set
- CopyOnWriteArraySet
- ConcurrentSkipListSet
CopyOnWriteArrayList应用场景
copyonwriteArrayList是java中的一种线程安全的list,他是一个可变的数组,支持并发读和写,与普通的arraylist不同,他的曲度操作不需要加锁,因为具有很高的并发性能。
读取过程内部,其实就是复制原有数组,并且扩大容量,然后加入新的元素
- 读多写少
- 不需要实时更新的数据
缺点
- 由于写操作的时候,需要拷贝数组 ,消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc
- 不能用于实时读的场景,像拷贝数组,新增元素都需要时间,所以调用一个set操作后,读取到的数据可能还是旧的,能做到最终一致性,但没法满足实时性要求。
- 数据太多的话,容易引起故障
ConcurrentHashMap
ConcurrentHashMap是java中线程安全的哈希表,他支持高并发并且能够同时进行读写操作。
底层原理
java 1.7 之前是一个Segment数组 —(加锁)—> 到一个Entry数组 ,entry数组后续接的是链表。(实现了一个写分散)
java1.8之后抛弃了Segment数组 变成了 数组+链表+红黑树的结构,在并发方面使用CAS和Syn保证数据的一致性
链表转红黑树的要求
- 链表的节点数量大于等于树化阈值8
- Node数组的长度大于等于最小树化容量值64
应用场景
- 贡献数据的线程安全: 在多线程编程中,如果要进行共享数据的读写,可以使用ConcurrentHashMap保证线程安全
ConcurrentSkipListMap
基于跳表实现的有序映射Map数据结构,实现了对TreeMap的兵法实现,适用于需要高并发性能,支持有序性和区间查询的场景。
电商场景中并发容器的选择
案例1 : 电商网站中记录一次活动下各个商品的售卖的数量
场景分析: 频繁按照商品id做get和set但是商品id的数量相对稳定不会频繁增删
解决:
初级方案 :用HashMap 但会出现线程安全问题,出现数据丢失。
选型: 用ConcurrentHashMap
阻塞队列
ArrayBlockingQueue
有界阻塞队列,先进先出,存取相互排斥
数据结构 静态数组,必须指定容量,没有扩容机制,没有元素位置也占用空间,被null占位
锁 ReentrantLock 存取是同一把锁,操作的是同一个数组对象,存取互相排斥
阻塞对象
- NotEmpty - 出队 队列count = 0 无元素可取时,阻塞该对象
- NotFull - 入队 队列 count = length 放不进去元素时, 阻塞该对象
入队 从队首开时添加元素,记录putIndex。到队尾时设置为0 唤醒NotEmpty
出队 从队首开时取出元素,记录takeIndex, 到队尾设置为0,唤醒NotFull
优点: 可以实现消息削峰, 生产者消息放入队列,消费者消费消息出队列。
LinkedBlockingQueue
无界阻塞队列,可指定容量,默认为Integer.MAX_VALUE, 先进先出,存取互不干扰,
数据结构 链表 存取互不干扰,存取操作是不同的node对象
takeLock 取Node节点保证前驱后继不会乱
putLock 存Node节点保证前驱后继不会乱
删除元素的时候两个锁在一起
入队 队尾入队
出队 队首出队
DelayQueue
一个使用优先级队列实现的无界阻塞队列
数据结构 : PriorityQueue - 与PriorityBlockingQueue类似,不过没有阻塞功能
锁: ReentrantLock
入队: 不阻塞,无界队列,与优先级队列入队相同
出队:
- 为空时阻塞
- 检查堆顶元素过期时间
- 小于等于0出队
- 大于0,说明没过期,阻塞,判断leader线程是否为空
- 不为空(已经有线程阻塞,直接阻塞)
- 为空,则当前线程设置为leader,并按照过期时间进行阻塞
应用场景:
- 商场订单超时关闭 ———— 淘宝订单业务:下单之后如果20分钟内没有付款就自动取消订单
- 异步短信通知功能———— 饿了么订单通知功能,下单成功后60s之后给用户发送短信通知
- 关闭空链接
- 缓存过期清除
- 任务超时处理,在网络协议滑动窗口请求应答式交互时,处理超时未响应的请求等。
选择策略
功能
容量
能否扩容
内存结构
性能
线程池
线程池的核心一般需要设置多少
cpu密集型任务 ,线程在执行的过程中会一直用cpu,所以我们对于这种情况,就按照核数设置
io密集型任务。比如文件io,网络io ,一般设置为任务执行时间*2+线程上下文切换时间
如果io型人物执行时间越长,那么同时阻塞咋io上的线程可能就越多,我们就可以设置更多的线程,但是线程不是越多越好,我们可以通过计算公式
线程数=。cpu核心数*(1+线程等待时间/线程运行总时间),
在实际工作过程中,可能有多个线程池,除了线程池中的线程,还有其他线程,所以实际工作中,我会选择,用Postman进行压力测试
核心线程数, 线程数,
队列长度 : 要看你接受的排队的时间有多久。
- 首先要考虑业务是核心业务还是边缘业务
线程池内部
如果队列为空,线程会读取之后进入阻塞状态,所以任务进队,之后,空闲线程就回去获取队列的任务,然后执行
如果任务满了,增加到最大线程数,如果还有任务进来,但队列满了,就无法处理了,有四种处理办法
非核心线程,会把队列的任务全部执行完,才会把非核心线程淘汰掉,通过cas操作消失掉,循环,继续消失,直到剩下核心线程数。
如果一个线程异常退出,就会增加一个线程。
tomcat线程池
- 需要等到最大线程池全部都创建出来,然后才开始队列入队
- 获取当前空闲线程,小于最大线程数,然后开始入队。
- 如果我们没有最大线程数,我们会优先创建线程
线程池线程退出
- cas 退出
- 执行出异常,退一个,补一个。
线程池的五个状态
- running: 接受新的任务和处理队列人物
- shutdown: 不能接受新任务,但是能处理队列任务
- stop:不能接受新任务,不能处理队列任务。
- tidying:所有任务都已经终止,线程池中没有线程了,这时线程池的状态会变成tidying,一旦达到这个状态,就会调用线程池额度terminaterd方法
- terminated: terminated()执行完之后就会转变为terminated
深入理解并发可见性,有序行,原子性与JMM内存模型
并发的三大行 原子性,可见性,有序性
如何保证原子性
- 通过Synchronized关键字保证原子性
- 通过Lock锁保证原子性
- 通过CAS保证原子性
如何保证可见性
- 通过volatile关键字保证可见性
- 通过内存屏障 UnsafeFactory.getUnsafe().storeFence(); sleep 有调用内存屏障
- 通过Synchronized关键字保证可见性
- 通过Lock锁保证可见性
java内存模型
在并发编程中,需要处理两个关键问题:
- 多线程之间如何通信
- 多线程之间如何同步
线程之间常用的通信机制有两种,共享内存和消息传递,java采用的是共享内存模型。
并发常用的设计模式
1. 线程T1如何 优雅的终止线程T2
错误思路1: stop方法会真正的杀死线程,如果这时线程锁住了共享资源,那么他被杀死后没办法释放锁,其他线程永远无法获取锁
错误思路2: System。exit方法停止线程: 目的仅是停止一个线程,但这种做法会让整个程序都停止。
正确思路:两阶段终止模式
1.1 两阶段终止模式
第一阶段 在java中如何发送终止请求
java线程进入终止状态的前提是线程进入Runnable状态,而实际上线程也可能处于休眠状态,也就是说,我们想要终止一个线程,首先要把线程的状态从休眠状态专程Runnable状态,利用线程中断机制的interrupt()方法,可以让线程从休眠状态转到Runnable状态。
第二阶段
利用标志位,调用interrupt()方法
注意 一个检查终止标志位是不够的,因为线程的皇台可能处于休眠。
一个中断状态也是不够的,例如Thread。sleep()方法会清空中断状态,所以需要两个。
好处
- 避免终止线程带来副作用
- 安全性: 两段终止模式可以在线程终止前执行必要的清理工作
- 灵活性: 两段终止模式可以根据具体情况灵活的设置终止条件和清理工作。
1.2避免共享的设计模式
1.2.1不变性模式
- 缓存:缓存数据是共享的,通常使用不变形模式来确保缓存数据的不变性。
- 值对象: 在一些系统中,需要定义一些值对象来表示一些常量或者不可变的对象。
- 配置信息: 在一些系统中,需要读取一些配置信息来配置系统参数和行为,由于配置信息通常是不变的,可以使用不变性模式来确保配置信息的不变性。
如何做到不变性
- final关键字和私有
- 写时复制 CopyOnWrite 用于读多写少
- 线程本地存储 适用于:保存上下文信息,保存线程安全,使用ThreadLocal (注意线程泄漏)
多线程版本的if else
守护挂起
- syn wait + notify
- ReentrantLock await single
- cas+park / unpark
避免执行模式
如果不适合执行这个操作,或者没有必要执行这个操作,就停止处理,直接返回。
使用场景:
- sychronized轻量级锁膨胀逻辑, 只需要一个线程膨胀获取monitor对象
- DCL单例实现
- 服务组件的初始化
- 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.