美文网首页
JAVA 多线程与锁

JAVA 多线程与锁

作者: 三石_5f43 | 来源:发表于2020-07-17 10:20 被阅读0次

    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 处于对资源的平衡和合理利用, 这时锁就会膨胀为重量级锁, 重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。

    • 锁升级

    soket Program-锁升级.jpg

    ​ 偏向锁的性能最好,此时没有出现多线程的竞争,轻量级锁利用自旋+CAS 操作避免了重量级带来的线程阻塞和唤醒,性能中等,重量级锁则会把获取不到线程的锁阻塞,性能最差。

    可重入锁/不可重入锁
    • 可重入锁:指的是线程当前持有这把锁,在不释放锁情况下再次获得这把锁, 例如ReentrantLock
    • 不可重入锁: 虽然线程当前持有了这把锁,也必须要释放锁后才能再次获得这把锁。
    共享锁/排他锁
    • 共享锁: 可以被多个线程同时获得,例如读写锁中的读锁(Read Lock)。
    • 排他锁: 锁只能被一个线程持有,例如读写锁中的写锁(Write Lock)。
    公平锁/非公品锁

    如果线程线程在尝试获取锁的时候获取不到锁,就会陷入阻塞等待,开始排队,在等待队列里等待长的优先获得锁,先到先得,而非公平锁在一定情况下会忽略掉正在排队的线程,发生插队现象。

    悲观锁/乐观锁
    • 悲观锁:是指在获取资源前必须先拿到锁,以便达到 独占状态,当前线程在操作资源时, 其他线程拿不到锁,不会影响当前线程的操作。

      synchronized 关键字 Lock 相关接口

      适用于并发写入多,临界区业务复杂处理比较耗时,竞争激烈的场景。

    • 乐观锁: 它并不要求在获取资源的前拿到锁,也不会锁住资源,相反乐观锁利用CAS 理念,在不独占资源的情况下,完成对资源的修改。

      原子类 AtomicInteger AtomicLong ..等

      适用于读多写少, 或 读写都很多,但是并发竞争不严重,临界区任务处理较快等场景,不加锁的特定能大幅度提高性能。

    自旋锁/非自旋锁
    • 自旋锁

      • 如果线程现在拿不到锁,并不会直接陷入阻塞也不会释放CPU 资源,而是循环不停的尝试获得锁,这个过程就被形象的称为自旋

      • 自旋锁用循环不停地尝试获得锁,让线程始终处于Runable 状态,节省了线程状态切换(休眠 ->唤醒 恢复现场) 带来的开销

      • 自旋锁避免了线程状态切换开销,但是因为不停地尝试获取同步资源的锁,如果锁一直不释放,那频繁的尝试过程也是对处理器资源的浪费,甚至这种开销在后期可能超过线程切换带来的开销。

      • 适用于并发场景不是很高,临界区程序比较简单。

    • 非自旋锁

      如果拿不到锁就直接放弃,或进入阻塞排队。

    • 图示

    soket Program-自旋锁非自旋锁.jpg
    可中断锁/不可中断锁
    • 不可中断锁:在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 中引入了自适应自旋锁来解决长时间自旋的问题,会根据最近自旋尝试的成功率、失败率、以及当前锁的拥有者状态等多种因素决定,自旋的时间是变化的,比如最近尝试自旋获得锁成功了,那么下次还会使用自旋,且允许更长时间的自旋,如果失败了那可能会减少自旋时间,甚至放弃自旋。
    锁粗化
    • 把几个同步代码块合并为一个,节省了频繁加锁解锁的性能开销,扩大了临界区,适用于非循环的场景。
    锁消除
    • 在经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据属于本线程,是线程安全的不需要加锁,这样就会自动把锁消除掉。
    偏向锁/轻量级锁/重量级锁

    参见上文

    相关文章

      网友评论

          本文标题:JAVA 多线程与锁

          本文链接:https://www.haomeiwen.com/subject/pegwhktx.html