04_并发编程
2021-08-20 21:52:18 6 举报
AI智能生成
并发编程知识梳理,面试可以参考哦
作者其他创作
大纲/内容
并发工具类
AQS
AbstractQueuedSynchronize,抽象队列同步器
核心变量
state变量
双向链表阻塞队列
当前加锁线程
AQS原理
Lock
ReentrantLock
ReentrantReadWriteLock
Condition
并发组件
CountDownLatch
同步等待多个线程完成任务
CycliBarrier
工作任务给多个线程分而治之
Semaphore
等待指定数量的线程完成任务
Exchange
支持两个线程之间进行数据交换
线程池
为什么需要线程池
反复创建线程系统开销比较大,每个线程创建和销毁都需要时间,还可能会导致创建和销毁线程消耗的资源比线程执行任务本身消耗的资源还要大
过多的线程会占用过多的内存等资源,还会带来过多的上下文切换,同时导致系统不稳定
线程池的核心参数
corePoolSize
核心线程数
线程池初始化时线程数默认为 0,当有新的任务提交后,会创建新线程执行任务,如果不做特殊设置,此后线程数通常不会再小于 corePoolSize
核心线程,即便未来可能没有可执行的任务也不会被销毁
workQueue
任务队列
LinkedBlockingQueue
DelayedWorkQueue
SynchronousQueue
maxPoolSize
最大线程数
随着任务量的增加,任务队列满了之后,线程池会进一步创建新线程,最多可以达到 maxPoolSize 来应对任务多的场景
如果未来线程有空闲,大于 corePoolSize 的线程会被合理回收
keepAliveTime+时间单位
线程池中线程数量多于核心线程数时,又没有任务可做,线程池就会通过检测线程的keepAliveTime来决定是否销毁空闲线程
如果超过规定时间,无事可做的线程就会被销毁,减少内存的占用和资源消耗
如果后期任务又多起来,线程池也会根据规则重新创建线程,这是一个可伸缩的过程,比较灵活
ThreadFactory
ThreadFactory 实际上是一个线程工厂,作用是生产线程以便执行任务
可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程
也可以自己定制线程工厂,以方便给线程自定义命名
Handler
任务拒绝策略
AbortPolicy
直接抛出类型为 RejectedExecutionException 的 RuntimeException,让调用者感知到任务被拒绝,根据业务逻辑选择重试或者放弃提交等策略
DiscardPolicy
新任务被提交后直接被丢弃掉,不会给出任何通知,相对而言存在一定的风险,提交任务的时候根本不知道这个任务会被丢弃,可能造成数据丢失
DiscardOldestPolicy
线程池没被关闭且没有能力执行则会丢弃任务队列中的头结点,通常是存活时间最长的任务,腾出空间给新提交的任务,存在一定的数据丢失风险
CallerRunsPolicy
线程池没被关闭且没有能力执行,则把这个新提交的任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务
好处
新提交的任务不会被丢弃,这样也就不会造成业务损失
提交任务的线程负责执行任务,而执行任务又是比较耗时的,提交任务的线程被占用,也就不会再提交新的任务,减缓任务提交的速度,相当于是一个负反馈,期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期
拒绝时机
调用 shutdown 等方法关闭线程池后,即便此时线程池内部依然有没执行完的任务正在执行
线程池没有能力继续处理新提交的任务,也就是工作已经非常饱和的时候,也就是队列已经满了,并且达到最大线程数
线程池工作原理
提交任务后,线程池会检查当前线程数,如果线程数小于核心线程数,则新建核心线程并执行任务,随着任务的不断增加,线程数会逐渐增加并达到核心线程数
达到核心线程数后,如果仍有任务被不断提交,就会被放入 workQueue 任务队列中,等待核心线程执行完当前任务后重新从 workQueue 中提取正在等待被执行的任务
假设任务特别多,达到 workQueue 的容量上限,线程池就会启动后备力量,也就是 maxPoolSize 最大线程数,线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务,假设任务被不断提交,线程池会持续创建线程直到线程数达到 maxPoolSize 最大线程数
如果依然有任务被提交,就会超过线程池的最大处理能力,线程池就会拒绝这些任务
5种常见的线程池
FixedThreadPool
固定线程数的线程池,核心线程数和最大线程数一样
会一直把超出线程处理能力的任务放到任务队列中进行等待
任务队列使用的是LinkedBlockingQueue,容量是Integer.MAX_VALUE,可以认为是无界队列
可能出现 OutOfMemoryError,几乎会影响到整个程序,会造成很严重的后果
CachedThreadPool
缓存线程池
线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1),当线程闲置时还可以对线程进行回收
提交一个任务,线程池会判断已创建的线程中是否有空闲线程,有空闲线程则将任务直接指派给空闲线程,没有空闲线程,则新建线程去执行任务
任务队列使用的是SynchronousQueue,队列的容量为0,实际不存储任何任务,只负责对任务进行中转和传递,效率比较高
任务数量特别多的时候,会导致创建非常多的线程,最终超过操作系统的上限而无法创建新线程,或者导致内存不足
ScheduledThreadPool
支持定时或周期性执行任务,比如每隔 10 秒钟执行一次任务
任务队列使用的是DelayedWorkQueue,内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序
DelayedWorkQueue,是一个延迟队列,同时也是一个无界队列,如果队列中存放过多的任务,也可能导致 OutOfMemoryError
SingleThreadScheduledExecutor
原理同ScheduledThreadPool
SingleThreadExecutor
原理同FixedThreadPool,只不过线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务
非常适合用于所有任务都需要按被提交的顺序依次执行的场景
正确关闭线程池
shutdown()
安全关闭一个线程池
线程池会在执行完正在执行的任务和队列中等待的任务后才彻底关闭
如果还有新的任务被提交,线程池则会根据拒绝策略直接拒绝后续新提交的任务
shutdownNow()
立刻关闭线程池
会给所有线程池中的线程发送 interrupt 中断信号,尝试中断这些任务的执行
会将任务队列中正在等待的所有任务转移到一个 List 中并返回,可以根据返回的任务 List 来进行一些补救的操作,例如记录在案并在后期重试
如果被中断的线程对于中断信号不理不睬,那么依然有可能导致任务不会停止
isShutdown()
判断线程池是否已经开始关闭工作
isTerminated()
检测线程池是否真正“终结”
awaitTermination()
定制自己的线程池
定制自己的线程池和具体的业务是强相关
首先需要掌握每个参数的含义,以及常见的选项
然后根据实际需要,比如说并发量、内存大小、是否接受任务被拒绝等一系列因素去定制一个非常适合自己业务的线程池
既不会导致内存不足,同时又可以用合适数量的线程来保障任务执行的效率,并在拒绝任务时有所记录方便日后进行追溯
并发编程设计模式
ThreadLocal
线程本地副本
源码剖析
多线程基础
单核CPU和多核CPU
都是一个cpu,不同的是每个cpu上的核心数,多核cpu是多个单核cpu的替代方案,多核cpu减小了体积,同时也减少了功耗
一个核心只能同时执行一个线程
串行、并发和并行
串行:多个任务,执行时一个执行完再执行另一个
并发:多个线程在单个核心运行,同一时间一个线程运行,系统不停切换线程,看起来像同时运行,实际上是线程不停切换
每个线程分配给独立的核心,线程同时运行
同步和异步
进程和线程
进程:操作系统进行资源(包括cpu、内存、磁盘IO等)分配的最小单位
线程:线程是CPU调度和分配的基本单位
进程可能有多个子任务,这些子任务就是线程
资源分配给进程,线程共享进程资源
线程切换
CPU给线程分配时间片(也就是分配给线程的时间),执行完时间片后会切换到另一个线程
切换之前会保存线程的状态,下次时间片再给这个线程时可以知道当前状态
从保存线程A的状态再到切换到线程B时,重新加载线程B的状态的这个过程就叫上下文切换,上下切换时会消耗大量的CPU时间
线程开销
上下文切换消耗
线程创建和消亡的开销
线程需要保存维持线程本地栈,会消耗内存
线程数量的选择
计算密集型
程序主要为复杂的逻辑判断和复杂的运算
CPU的利用率高,不用开太多的线程,开太多线程反而会因为线程切换时切换上下文而浪费资源
IO密集型
程序主要为IO操作,比如磁盘IO(读取文件)和网络IO(网络请求)
IO操作会阻塞线程,CPU利用率不高,可以开多点线程,阻塞时可以切换到其他就绪线程,提高CPU利用率
临界区
临界区通常指共享数据,可以被多个线程使用。当有线程进入到临界区时候,其他线程或者进程必须等待
阻塞与非阻塞
线程创建的三种方式
方式一:继承Thread类
方式二:实现Runable接口
方式三:实现Callaable/Future 接口
线程的生命周期
New-初始化状态
高级语言层面,线程被创建,而在操作系统中的线程其实还没被创建,这个时候不会分配CPU执行这个线程
此状态是高级语言独有的,操作系统的线程没这个状态
Runnable-可运行/运行状态
这个状态下是可以分配CPU执行的,在New状态时候调用start()方法,启动线程后,线程就处于这个状态
Java 中的 Runable 状态对应操作系统线程状态中的两种状态,分别是 Running 和 Ready,线程可能是运行状态或者是可运行状态
Blocked-阻塞状态
这个状态下是不能分配CPU执行的,只有一种情况会导致线程阻塞,就是synchronized
被synchronized修饰的方法或者代码块同一时刻只能有一个线程执行,而其他竞争锁的线程就从Runnable到了Blocked状态
当某个线程竞争到锁,它就变成了Runnable状态
并发包中的Lock,是会让线程属于等待状态而不是阻塞,只有synchronized是阻塞
Waiting-无时间限制的等待状态
这个状态下也是不能分配CPU执行
调用无参的Object.wait()方法,等到notifyAll()或者notify()唤醒就会回到Blocked状态,争抢到锁之后再进入Runnable状态
调用无参的Thread.join()方法
调用LockSupport.park()方法,再调用LocakSupport.unpark(Thread thread),会回到Runnable状态
Timed_Waiting-有时间限制的等待状态
这个状态和Waiting的区别就是有没有超时时间参数,这个状态下也是不能分配CPU执行
Object.wait(long timeout),等到超时时间或者等到notifyAll()或者notify()唤醒就会回到Blocked状态,争抢到锁之后再进入Runnable状态
Thread.join(long millis)
Thread.sleep(long millis)
LockSupport.parkNanos(Object blocked,long deadline)
LockSupport.parkUntil(long deadline)
Terminated-终止状态
线程正常run() 方法执行结束之后或者run() 方法执行过程中出现异常了就是终止状态
如果想要确定线程当前的状态,可以通过 getState() 方法,线程在任何时刻只可能处于 1 种状态
一个线程只能有一次 New 和 Terminated 状态,只有处于中间状态才可以相互转换
中断机制
Thread.stop()是可以让线程终止的,但是这个方法已经被废弃了,不推荐使用,因为执行stop() 方法终止线程比较暴力,也不会释放锁
线程中断推荐使用的是Thread.interrupt() 方法
一个安全的中断不应该是强迫终止一个线程,它应该是一种协作机制,通过给线程设置一个中断标志,但是由线程决定如何以及何时中断
Thread.interrupt() 执行中断对线程的影响与线程的状态和在进行的IO操作有关
RUNNABLE
没有执行IO操作,Thread.interrupt() 只是会设置线程的中断标志位,线程应该合适的位置检查中断标志位做相应处理
执行IO操作,会抛出其他异常
WAITING/TIMED_WAITING
Thread.interrupt()会使得线程抛出InterruptedException,需要注意的是,抛出异常后,中断标志位会被清空,而不是被设置为中断
BLOCKED
如果线程在等待锁,Thread/.interrupt()只是会设置线程的中断标志位,线程依然会处于BLOCKED状态
Thread.interrupt()并不能使一个在等待锁的线程真正"中断"
join()
join()方法表示无限等待,一直会阻塞当前线程,直到目标线程执行完毕
join(long millis)方法给出了一个最大的等待时间,如果超过给定时间目标线程还在执行,那么当前线程则不管它,会继续执行下去
并发编程的三大特性
可见性
多个线程对同一共享资源同时读写,可能会有可见性问题
可见性问题产生原因
CPU多级缓存模型
现代的计算机技术,由于内存的读写速度没什么突破,cpu频繁的读写主内存,导致性能较差,计算性能很低,非常不适应现代计算机技术的发展
现代计算机,加入高速缓存,cpu可以直接操作自己对应的高速缓存,不需要直接频繁的跟主内存通信,保证cpu计算的效率非常的高
不一致问题:多线程并发运行,导致各个cpu的本地缓存,跟主内存,没有同步,一个数据,在各个地方,可能都不一样,会导致数据不一致
解决方案
总线加锁机制
基本已经废弃
MESI缓存一致性协议
一整套机制保证cpu缓存模型下,不会出现多线程并发读写一个变量的数据不一致问题
1、变量的值被修改之后,会主动刷回到主内存
2、数据被刷回到主内存后,其它缓存会有嗅探机制,将自己本地的cpu缓存数据过期,重新读取主内存数据
Java内存模型
基于cpu缓存模型来建立java内存模型,只不过java内存模型是标准化的,屏蔽掉底层不同的计算机的区别
read(从主内存读取)
load(将主内存读取到的值写入工作内存)
use(从工作内存读取数据来计算)
assign(将计算好的值重新赋值到工作内存中)
store(将工作内存数据写入主内存)
write(将store过去的变量值赋值给主内存中的变量)
不一致问题:多线程并发运行,导致工作内存和主内存数据没有同步,导致数据的不一致
可见性问题解决方案
volatile关键字
如何保证可见性
1、执行完 assign 操作,加了 volatile 关键字修饰的变量会被强制执行 store 和 write,将变量值刷回到工作内存
2、变量被其它线程修改之后,会嗅探工作内存数据已经过期,重新执行 read 和 load,从主内存读取数据到工作内存
volatile不能保证原子性
volatile保证可见性的底层原理
lock前缀指令
MESI缓存一致性协议
原子性
原子性问题:常见于并发写场景,比如i++,多个线程并发运行来执行这段代码,不能保证原子性
原子性问题产生原因
多线程并发写的线程安全问题
原子性问题解决方案
synchronized
加锁,一旦某个线程获取锁之后,可以保证,其它的线程没办法区读取和修改这个变量的值,同一时间,只有一个线程可以读取和修改这个数据
使用方法
普通同步方法
锁是当前实例对象
同步方法块
锁是括号里面的对象,this表示当前实例对象
静态同步方法
锁是当前类的class对象
底层实现原理
synchronized可以保证可见性
原子类
CAS无锁化思想
Compare And Set,先比较,后设值
乐观锁思想
AtomicIntger源码剖析
final Unsafe unsafe = Unsafe.getUnsafe()
getAndAddInt()代码实现
compareAndSwapInt()
final long valueOffset
volatile int value
Atomic原子类体系的CAS语义存在的三大缺点分析
ABA问题
解决方案:AtomicStampedReference
无限循环问题
解决方案:LongAdder,分段CAS
多变量原子问题
解决方案:AtomicReference
有序性
常见于指令重排,编译器和指令器,为了提高代码执行效率,会将指令重新排序,代码的执行顺序改变
有序性问题解决方案
volatile保证有序性
happens before原则
底层原理:内存屏障
死锁问题
一组互相竞争资源的线程因互相等待,导致‘永久’阻塞的现象
解决死锁最好的办法是规避死锁,死锁发生的四个条件
互斥,共享资源X和Y只能被一个线程使用
占有且等待,线程已经获得共享资源X,在等待获取共享资源Y的时候,不会释放共享资源X
不可抢占,其他线程不能强行抢占线程已经占有的资源
循环等待
规避死锁的发生
互斥的条件必须存在
破坏占有且等待的条件:一次性申请所有资源
破坏不可抢占条件:使用显示锁替换内置锁,内置锁获取不到资源的时候,会阻塞等待
破环循环等待条件:对资源进行排序,保证必须按顺序获取资源
活跃性问题
死锁
死锁是两个或者多个线程,相互占用对方需要的资源,而都不进行释放,导致彼此之间都相互等待对方释放资源,产生了无限制等待的现象
死锁一旦发生,如果没有外力介入,这种等待将永远存在,从而对程序产生严重的影响
饥饿
某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行
比如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作
与死锁相比,饥饿还是有可能在未来一段时间内解决的,比如高优先级的线程已经完成任务,不再疯狂地执行
规避饥饿的发生
保证资源充足
公平的分配资源
避免持有锁的线程长时间执行
活锁
任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试、失败、尝试、失败
通常情况下在使用显示锁的获取多个资源的情况下会出现活锁的情况,可以通过尝试等待一个随机时间来降低活锁发生的概率
用户线程和守护线程
用户线程一般用于执行用户级任务
守护线程也就是“后台线程”,一般用来执行后台任务
Java虚拟机在所有“用户线程”都结束后会自动退出,换句话说,如果只剩下“守护线程”,那么“守护线程”也会跟着JVM进程一起退出
Thread源码剖析
初始化过程
创建线程的线程,就是线程的父线程,如果没有指定 ThreadGroup,线程的 ThreadGroup 就是父线程的 ThreadGroup
默认情况下,线程的 daemon 状态和父线程的 daemon 状态保持一致
默认情况下,线程的优先级和父线程的优先级的优先级保持一致
如果没有指定线程的名称,那么默认就是Thread-0格式的名称
线程id是全局递增的,从1开始
启动过程
一旦启动线程,就不能再重新启动多次调用start()方法,因为启动之后,threadStatus 就是非0状态,此时就不能重新调用
启动线程之后,这个线程就会加入之前处理好的那个线程组
启动一个线程,底层执行是 start0() 方法,这是一个 native 方法,它负责实际的启动一个线程
线程启动之后就会执行 run() 方法
锁
偏向锁/轻量级锁/重量级锁
三种锁特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态
偏向锁
如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行,这就是偏向锁的思想
一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的
第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后尝试获取锁的线程正是偏向锁的拥有者,可以直接获得锁,开销很小
轻量级锁
轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁
线程会通过自旋的形式尝试获取锁,而不会陷入阻塞
重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,开销相对比较大
当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁
重量级锁会让其他申请却拿不到锁的线程进入阻塞状态
可重入锁/非可重入锁
可重入锁指的是线程当前已经持有这把锁,能在不释放这把锁的情况,再次获取这把锁
不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取
对于可重入锁而言,最典型的就是 ReentrantLock
共享锁/独占锁
共享锁指的是同一把锁可以被多个线程同时获得
独占锁指的就是一把锁只能同时被一个线程获得
读写锁最好地诠释了共享锁和独占锁的理念,读写锁中的读锁,是共享锁,写锁是独占锁
公平锁/非公平锁
公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁
非公平锁就不那么“完美”,会在一定情况下,忽略掉已经在排队的线程,发生插队现象
悲观锁/乐观锁
悲观锁:获取资源之前,必须先拿到锁,以便达到“独占”的状态
乐观锁:并不要求在获取资源前拿到锁,也不会锁住资源,乐观锁利用 CAS 理念,在不独占资源的情况下,完成对资源的修改
自旋锁/非自旋锁
自旋锁:如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁,像是线程在“自我旋转”
非自旋锁:如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等
可中断锁/不可中断锁
synchronized 关键字修饰的内置锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路,只能等到拿到锁以后才能进行其他的逻辑处理
ReentrantLock 是一种典型的可中断锁,使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取,可以在中断之后去做其他的事情
面试题精选
创建多少个线程比较合适
对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”最合适
对于 I/O 密集型的计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关,最佳线程数 = 1 +(I/O 耗时 / CPU 耗时)
聊聊锁
聊聊偏向锁,轻量级锁,重量级锁
聊聊乐观锁和悲观锁
乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景
悲观锁适合用于并发写入多、临界区代码复杂、竞争激烈等场景,悲观锁可以避免大量的无用的反复尝试等消耗
数据库中同时拥有悲观锁和乐观锁的思想
聊聊线程池
聊聊wait()方法和sleep()方法的区别
聊聊synchronized的原理
聊聊synchronized和Lock的区别
聊聊Atomic原子类
聊聊ThreadLocal
锁优化
标志位修改等可见性场景优先 volatile,多线程并发读写的场景
数值递增场景优先使用Atomic原子类
数据允许多副本场景优先使用ThreadLocal,对于不需要多个线程同时读写一个共享变量的场景
读多写少需要加锁的场景优先使用读写锁
尽可能减少线程对锁占用的时间,不要在加锁的逻辑里面执行一些耗时的操作,尽量是一些操作内存的数据这样的操作
尽可能减少线程对数据加锁的粒度,对更小的数据或者更少的代码进行加锁
尽可能对不同功能分离锁的使用,根据功能的不同,拆分成多把锁,降低线程竞争锁的冲突,比如阻塞队列,对头使用一把锁,队尾使用一把锁
避免在循环中频繁的加锁以及释放锁
尽量减少高并发场景中线程对锁的争用,比如eureka中的多级缓存机制,其思路就是使用读写缓存来降低对锁的使用频率
收藏
收藏
0 条评论
下一页
为你推荐
查看更多