JAVA 多线程与锁
JAVA 多线程与锁
线程与线程池
线程安全可能出现的场景
- 共享变量资源多线程间的操作。
- 依赖时序的操作。
- 不同数据之间存在绑定关系。
- 没有声明是线程安全的。
多线程性能问题
线程调度
- 线程上下文切换
- CPU 缓存失效
- 锁竞争、IO频繁 会造成上下文切换的频繁。
线程协作
- 线程间共享数据的频繁Flush 刷盘(保证数据一致性)。
- 保证线程安全而取舍了CPU 对指令重排的优化。
每个任务都创建一个线程带来的问题
- 反复创建线程造成系统开销比较大,线程创建和销毁都需要时间,如果任务是比较简单的,那么创建和销毁线程所消耗的资源可能比要处理的任务本身还要大,这是极其不合理的。
- 过多的线程会占用内存资源,如过一个线程处理的任务耗时比较长,那么就会有大量的空闲线程处于线程饥饿 线程间上下文切换也会对CPU 带来压力, 同时也可能造成系统的不稳定。
线程池解决线程资源过多的思路
- 线程池用固定数量的线程一直保持工作状态,执行任务。
- 根据需要创建线程,控制线程的总数量,避免过多的线程占用资源。
线程池的优点
- 线程池中的线程是可以复用的,避免了(创建、销毁)线程生命周期的系统开销。 线程池中的线程一直保持工作状态,可以直接处理任务,避免了创建线程带来的延迟。
- 线程池可以统筹内存和CPU的使用,使资源可以合理分配利用。 线程池会根据配置和任务数量灵活控制线程数量。
- 线程池可以统一管理资源,可以统一协调和管理任务资源和线程。
线程池参数
参数名 | 含义 |
---|---|
corePoolSize | 核心线程数 |
maxPoolSize | 最大可创建线程数 |
KeepAliveTime | 空闲线程的存活时间 |
ThreadFactory | 线程工厂,定义创建新线程<br />的规则 |
workQueue | 用于存放任务的队列 |
Handler | 按任务拒绝策略 处理被拒绝任务 |
线程池任务调度流程图
soket Program-线程池任务处理.png线程池的特点
- 希望保持较少的线程数,且指在负载较大时才增加线程。
- 只有在任务阻塞队列满的情况下才在 corePoolSize 基础上创建多的线程,如果采用无界队列(LinkBlockQueue) 则永远不会超出corePoolSize 的线程数限制。
- corePoolSize 和 maxPoolSize 大小相同,创建固定大小的线程池。
- maxPoolSize 值设置 Integer.MAX_VALUE 创建更多的线程。
线程池拒绝任务的时机
- 程序调用 shutdown 方法关闭线程池,此时即使线程池中还有线程在处理任务, 但新提交的任务仍被拒绝执行。
- 线程池已经饱和,任务阻塞队列已满,线程数达到最大线程数 maxPoolSize 上限, 新提交的任务就会拒绝执行。
线程池的拒绝策略
-
AbortPolicy: 拒绝策略在拒绝任务时会直接抛出 RejectedExecutionException 异常 , 可以捕获异常并根据业务逻辑选择重试或放弃提交。
-
DiscardPolicy: 新任务被提交后直接被丢弃 也不会给任何异常通知, 风险比较大,可能造成无感知的数据丢失。
-
DiscardOldestPolicy: 丢弃掉任务队列的中存活时间最长的任务,存在一定的数据丢失的风险。
-
CallerRunsPolicy: 把任务交于提交任务的线程执行,谁提交任务谁负责执行。
- 新提交的任务不会丢弃,保证了业务完整性和数据完整性。
- 提交任务的线程执行新任务, 不会再提交任务给线程池,线程池处理执行任务队列的任务, 腾出阻塞队列空间。
常见的6种线程池
- FixedThreadPool
- CacheThreadPool
- ScheduledThreadPool
- SingleThreadExecutor
- SingleThreadScheduledExecutor
- ForkJoinPool
线程池 | corePoolSize | maxPoolSize | keepAliveTime |
---|---|---|---|
FixedThreadPool | 构造函数传入 | 同corePoolSize | 0 |
CacheThreadPool | 0 | Integer.MAX_VALUE | 60s |
ScheduledThreadPool | 构造函数传入 | Integer.MAX_VALUE | 0 |
SingleThreadExecutor | 1 | 1 | 0 |
SingleThreadScheduledExecutor | 1 | Integer.MAX_VALUE | 0 |
FixedThreadPool
核心线程数(corePoolSzie) 和最大线程数(maxPoolSize) 一样, 可以看做是固定线程数的线程池, 特点是线程池中的线程从0 开始增加,到corePoolSize 线程数上限,就不再增加。
CacheThreadPool
可以称为可缓存线程池, 它的线程数是几乎可说是不设上限,(Integer.MAX_VALUE 2^31-1),它的任务队列是SynchronousQueue 队列容量为0 , 不存储任务,只负责任务的中转与传递,效率比较高。
当提交一个新任务时线程池会判断是否存在空闲线程,如果有空闲线程就直接分配任务给线程去执行,没有则新建线程去执行任务。
ScheduledThreadPool
支持定时和周期性的执行任务。
ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
#1 每隔10s 钟执行一次任务。
service.schedule(new Task(), 10, TimeUnit.SECONDS);
#2 延迟10s 执行第一次任务,之后(从任务开始执行时间计时) 每延迟10s 执行一次任务。
service.scheduleAtFixedRate(new Task(), 10, 10, TimeUnit.SECONDS);
#3 延迟10s 执行第一次任务,之后(从任务结束时间开始计时)每延迟10s 执行一次任务。
service.scheduleWithFixedDelay(new Task(), 10, 10, TimeUnit.SECONDS);
SingleThreadExecutor
只存在一个线程去执行任务,如果线程执行任务过程中发生异常,线程池会创建新的线程去执行后续的任务。适合要求任务按提交顺序执行的场景。
SingleThreadScheduledExecutor
与 ThreadScheduledExecutor 类似,只是它的核心线程数设为了1,只有一个线程去执行任务。
ForkJoinPool
适用于递归场景,例如树的遍历。。
与上述线程池最大的不同点在于
-
适合执行可以产生子线程的任务。比如一个任务Task 产生三个子任务 subTask, 那么三个子任务并行执行互不影响,充分利用CPU 多核优势。 主任务执行分为两部分
- fork: 将任务分裂出子任务。
- join: 汇总子任务的执行结果。
-
内部结构不同,上述线程池所有的线程公用一个任务队列, 但是 ForkJoinPool 线程池中除了有一个公用的任务队列外, 每个线程都自己独立的双端任务队列 Deque。 线程分裂出来的子任务放入自己的Deque 任务队列中,线程可以直接在直接的独立队列中获取任务执行(LIFO),减少了线程间竞争和切换。
-
work-stealing 当一个线程忙,而一个线程空闲时,空闲线程就会偷 任务放入自己的Deque 中执行(FIFO)。
合适的线程数量
CPU 密集型
加密、解密、压缩、计算等一系列需要大量消耗CPU 资源的任务, 这样的任务最大线程数为 CPU core*(1~2)。 因为计算任务是比较繁重的,会占用大量的CPU 资源, 申请过多线程容易造成线程的上下文切换,甚至会导致性能的下降。
IO 密集型
数据库数据读写、 文件内容读写、网络通信等任务,这种任务的特点是并不会特别消耗CPU 资源,但是IO 操作耗时,会占用比较多的时间。 线程数=CPU core * (1+ 平均等待时间/平均工作时间)。
线程池的关闭
shutdown()
- 安全关闭线程池, 调用 shutdown() 方法后并不是立即关闭线程池,而是等正在执行任务的线程 和 任务队列里的任务执行完毕后,再关闭,
- 此时不在接受新提交的任务,新提交任务按拒绝策略拒绝掉。
shutdownNow()
执行shutdownNow() 方法后,会执行以下步骤
- 给线程池中的所有线程发送 Interrupt 中断信号,尝试中断任务的执行。
- 将任务阻塞队列里的任务转移到一个列表list 返回,可根据业务需求自行对返回的任务做后续的补救操作或记录。
isShutdown()
检测线程池是否已经开始了关闭流程(执行了shutdown() shutdownNow()), Boolean 类型,返回为true 也只是表明线程池开始了关闭工作。
isTerminated()
检测线程池是否已经终结关闭掉,Boolean 类型,返回为true 意味者线程池中任务队列里的任务都已经执行完毕,线程池被关闭。
awaitTermination()
用来判断线程池状态,在等待时间截止可能三种情况产生。
- 等待时间内,若线程池中所有提交的你任务都已执行,线程池已关闭,返回true。
- 等待时间内线程被中断,会抛出 InterruptException()。
- 等待时间结束后,线程并未终结返回 false。
多线程的线程复用原理
-
通过 Wroker 的findTask 或 getTask 从 workqueue 中获取待执行的任务。
-
直接调用task 的 run 方法,执行具体任务,不是新建线程。
常见的各种锁
锁的7 大分类
- 偏向锁/轻量级锁/重量级锁
- 可重入锁/不可重入锁
- 共享锁/排他锁
- 公平锁/非公平锁
- 悲观锁/乐观锁
- 自旋锁/非自旋锁
- 可中断锁/不可中断锁
偏向锁/轻量级锁/重量级锁
对于synchronized 关键字加monitor 锁的对象,在对象头中标明锁的状态。
-
偏向锁
如果自始至终,对于这把锁都不存在竞争,那么没必要上锁,只需要在对象头的锁标记位打一个标记就行,这是偏向锁的思想,若对象初始化后没有任何线程来获取它的锁,那么它是可偏向的,当有第一个线程访问并尝试获取锁的时候,会将这个线程记录下来, 之后尝试获取锁的线程是偏向锁的拥有者,那么就直接获得锁,开销很小,性能好。
-
轻量级锁
轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在锁竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋+ CAS 的形式,尝试获得锁,而不会陷入阻塞
-
重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,开销很大, 当多个线程之间存在锁的竞争,且线程任务执行耗时比较长,竞争的锁就会长时间陷入自旋等待获得锁, JVM 处于对资源的平衡和合理利用, 这时锁就会膨胀为重量级锁, 重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。
-
锁升级
偏向锁的性能最好,此时没有出现多线程的竞争,轻量级锁利用自旋+CAS 操作避免了重量级带来的线程阻塞和唤醒,性能中等,重量级锁则会把获取不到线程的锁阻塞,性能最差。
可重入锁/不可重入锁
- 可重入锁:指的是线程当前持有这把锁,在不释放锁情况下再次获得这把锁, 例如ReentrantLock
- 不可重入锁: 虽然线程当前持有了这把锁,也必须要释放锁后才能再次获得这把锁。
共享锁/排他锁
- 共享锁: 可以被多个线程同时获得,例如读写锁中的读锁(Read Lock)。
- 排他锁: 锁只能被一个线程持有,例如读写锁中的写锁(Write Lock)。
公平锁/非公品锁
如果线程线程在尝试获取锁的时候获取不到锁,就会陷入阻塞等待,开始排队,在等待队列里等待长的优先获得锁,先到先得,而非公平锁在一定情况下会忽略掉正在排队的线程,发生插队现象。
悲观锁/乐观锁
-
悲观锁:是指在获取资源前必须先拿到锁,以便达到 独占状态,当前线程在操作资源时, 其他线程拿不到锁,不会影响当前线程的操作。
synchronized 关键字 Lock 相关接口
适用于并发写入多,临界区业务复杂处理比较耗时,竞争激烈的场景。
-
乐观锁: 它并不要求在获取资源的前拿到锁,也不会锁住资源,相反乐观锁利用CAS 理念,在不独占资源的情况下,完成对资源的修改。
原子类 AtomicInteger AtomicLong ..等
适用于读多写少, 或 读写都很多,但是并发竞争不严重,临界区任务处理较快等场景,不加锁的特定能大幅度提高性能。
自旋锁/非自旋锁
-
自旋锁
-
如果线程现在拿不到锁,并不会直接陷入阻塞也不会释放CPU 资源,而是循环不停的尝试获得锁,这个过程就被形象的称为自旋
-
自旋锁用循环不停地尝试获得锁,让线程始终处于Runable 状态,节省了线程状态切换(休眠 ->唤醒 恢复现场) 带来的开销
-
自旋锁避免了线程状态切换开销,但是因为不停地尝试获取同步资源的锁,如果锁一直不释放,那频繁的尝试过程也是对处理器资源的浪费,甚至这种开销在后期可能超过线程切换带来的开销。
-
适用于并发场景不是很高,临界区程序比较简单。
-
-
非自旋锁
如果拿不到锁就直接放弃,或进入阻塞排队。
-
图示
可中断锁/不可中断锁
- 不可中断锁:在java 中 synchorized 修饰的锁是不可中断锁,一旦线程申请了锁就只能等拿到锁之后才能进行其他 的逻辑处理。
- 可中断锁: 而 ReentranLock 是一种典型的可中断锁, 例如使用 lockInterruptibly 方法,在申请获取锁的过程中,突然不想获取了,那么也可以中断之后去处理其他的任务。不用一直等待获取锁之后才能离开。
synchronized vs Lock
相同点
- synchronized 和 Lock 都是用来保护资源线程访问的安全。
- 都可以保证可见性
- synchronized 和 ReentrantLock 都拥有可重入的特点,都是可重入锁。
不同点
- 用法上的区别
- 加解锁的顺序性不同
- synchronized 锁不够灵活
- synchronized 锁只能同时被一个线程拥有,但是Lock 锁没有这个限制。
- 原理区别,sychronized 是内置锁,由JVM 实现获取锁和释放锁的原理。
synchronized 不能设置公平和非公平 - 性能区别
如何选择
- 推荐使用JUC并发工具包,不推荐使用 synchronized 和 Lock.
- 若synchronized 适合的程序,那么推荐使用synchronized ,因为使用简洁,不容易出错,(ReentrantLock 需要显示的在 finally 块中 lock.unlock 解锁)
- 需要Lock 的特殊功能,比如可中断,公平锁,非公平锁等功能,才使用Lock。
JVM 对锁的优化
自适应自旋锁
- jdk1.6 中引入了自适应自旋锁来解决长时间自旋的问题,会根据最近自旋尝试的成功率、失败率、以及当前锁的拥有者状态等多种因素决定,自旋的时间是变化的,比如最近尝试自旋获得锁成功了,那么下次还会使用自旋,且允许更长时间的自旋,如果失败了那可能会减少自旋时间,甚至放弃自旋。
锁粗化
- 把几个同步代码块合并为一个,节省了频繁加锁解锁的性能开销,扩大了临界区,适用于非循环的场景。
锁消除
- 在经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据属于本线程,是线程安全的不需要加锁,这样就会自动把锁消除掉。
偏向锁/轻量级锁/重量级锁
参见上文