线程池

2020/10/13 并发 共 7120 字,约 21 分钟
梦境迷离

线程状态

  • NEW 初始状态,线程刚刚被创建,并且start()方法还未被调用。
  • RUNNABLE 运行状态,表示线程正在java虚拟机中执行,但是可能正在等待操作系统的其他资源,比如CPU。
  • BLOCKED 阻塞状态,表示线程正在等待监视器锁。表示线程正在等待获取监视器锁,以便进入同步方法或者同步代码快,也有可能是从wait()方法被唤醒而等待再次进入同步方法或者同步代码块。
  • WAITING 等待状态,表示当前线程需要等待其他线程执行一些特殊操作,比如当前线程调用了a.wait()方法,它正在等待其他线程调用a.notify或a.notifyAll方法;如果当前线程调用了thread.join(),那么它在等待thread a执行完成,触发条件:
    • Object.wait() 不设置超时时间
    • Thread.join() 不设置超时时间
    • LockSupport.park() 不设置超时时间
  • TIMED_WAITING 超时等待,与WAITING的不同在于,该状态有超时时间,触发条件:
    • Object.wait(time)
    • Thread.join(time)
    • Thread.sleep(time)
    • LockSupport.parkNanos(time)
    • LockSupport.parkUntil(time)
  • TERMINATED 终止状态,表示当前线程已经执行完毕(如果线程被持久持有,可能不会被回收)。

线程池

Executor

  • Executors 线程池管理工具
  • ExecutorService 线程池对象
  • Callable 执行的具体任务
  • Future 执行结果
  • RejectedExecutionHandler 拒绝执行处理策略

Executors创建线程池

  • newSingleThreadExecutor 单个线程的线程池,即线程池中每次只有一个线程工作,单线程串行执行任务。
  • newFixedThreadExecutor 固定数量的线程池,没提交一个任务就是一个线程,直到达到线程池的最大数量,然后后面进入等待队列直到前面的任务完成才继续执行。
  • newCacheThreadExecutor 可缓存线程池,当线程池大小超过了处理任务所需的线程,那么就会回收部分空闲(一般是60秒无执行)的线程,当有任务来时,又智能的添加新线程来执行。
  • newScheduleThreadExecutor 大小无限制的线程池,支持定时和周期性的执行线程。

Executors创建线程池对象的弊端

  • FixedThreadPool和SingleThreadPool允许的请求队列(底层实现是LinkedBlockingQueue)长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
  • CachedThreadPool和ScheduledThreadPool允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。

关闭

  • shutdownNow:强行关闭 关闭当前正在执行的任务 并返回未启动的任务清单
  • shutdown:正常关闭 任务都执行完后才关闭

Thread.setUncaughtExceptionHandler 设置默认异常处理器,类似双亲委派会传递到顶层的ThreadGroup并委托给默认的系统异常处理器(默认为空),否则输出到控制台

拒绝策略

  • AbortPolicy:executor抛出RejectedExecutionException,调用者捕获这个异常,然后自己编写能满足自己需求的处理代码。
  • DiscardRunsPolicy:遗弃最旧的,选择丢弃的任务是本应接下来就执行的任务。
  • DiscardPolicy:遗弃会默认放弃最新提交的任务(这个任务不能进入队列等待执行时)。
  • CallerRunsPolicy:交由调用者运行,既不会丢弃哪个任务,也不会抛出任何异常,把一些任务推回到调用者那里,以此减缓新任务流。

它不会在线程池中执行最新提交的任务,但它会在一个调用了execute的线程中执行。

提交

  • submit 返回信息 异常被Future.get封装在ExecutionException重新抛出 客户端可以得到详细堆栈,无论是否检查型异常
  • execute 任务抛出的异常会交给未捕获异常处理器,也就是setUncaughtExceptionHandler设置的处理器

JVM关闭

  • 正常关闭:System.exit(),最后一个普通线程结束,sigint信号,键入ctrl+c
  • 强行关闭:Runtime.halt,sigkill信号
  • 关闭钩子:通过Runtime.addShutdownHook注册的但未开始的线程,正常关闭JVM才会执行关闭钩子

所有关闭钩子执行结束时,如果runFinalizersOnExit为true,那么将执行终结器。 JVM退出时,发现只有守护线程,则会停止,守护线程将被抛弃,并不会执行finally代码块,回卷栈。

ThreadPoolExecutor

  • corePoolSize:核心线程数,会一直存活,即使没有任务,线程池也会维护线程的最少数量。
  • maximumPoolSize:线程池维护线程的最大数量。
  • keepAliveTime:线程池维护线程所允许的空闲时间,当线程空闲时间达到keepAliveTime,该线程会退出,直到线程数量等于corePoolSize。如果allowCoreThreadTimeout设置为true,则所有线程均会退出直到线程数量为0。
  • unit:线程池维护线程所允许的空闲时间的单位、可选参数值为:TimeUnit中的几个静态属性:
    • NANOSECONDS
    • MICROSECONDS
    • MILLISECONDS
    • SECONDS
  • workQueue: 线程池所使用的缓冲队列,常用的是:java.util.concurrent.ArrayBlockingQueue、LinkedBlockingQueue。
  • handler: 线程池中的数量大于maximumPoolSize,对拒绝任务的处理策略,默认值ThreadPoolExecutor.AbortPolicy()。

Timer的问题

  • Timer是基于绝对时间的。容易受系统时钟的影响。
  • Timer只新建了一个线程来执行所有的TimeTask,所有TimeTask可能会相关影响。
  • Timer不会捕获TimerTask的异常,只是简单地停止。这样势必会影响其他TimeTask的执行。

建议使用ScheduledThreadPoolExecutor代替Timer。它基本上解决了上述问题:采用相对时间,用线程池来执行TimerTask,会处理TimerTask异常。

线程池大小的选择

  • 避免线程太少(减少 CPU 等待时间消耗)或太多(增加线程切换消耗)。线程池的最佳大小取决于可用处理器的数目以及工作队列中的任务的性质。若在一个具有 N 个处理器的系统上只有一个工作队列,其中全部是计算性质的任务,在线程池具有 N 或 N+1 个线程时一般会获得最大的 CPU 利用率。 对于那些可能需要等待 I/O 完成的任务(例如从套接字读取 HTTP 请求的任务),需要让池的大小超过可用处理器的数目,因为并不是所有线程都一直在工作。通过使用概要分析,您可以估计某个典型请求的等待时间(WT)与服务时间(ST)之间的比例。如果我们将这一比例称之为 WT/ST,那么对于一个具有 N 个处理器的系统需要设置大约 N*(1+WT/ST) 个线程来保持处理器得到充分利用。 处理器利用率不是调整线程池大小过程中的唯一考虑事项。随着线程池的增长,您可能会碰到可用内存、套接字、打开的文件句柄、数据库连接等的数目。
    • CPU密集型一般线程池数量为N+1,加的这个1是充分利用CPU(如当某个线程出现空闲时或一个线程计算结束切换新任务时能够充分利用这部分资源)。
    • IO密集型,一般设置为2N+1,但是这并不是绝对的,要考虑到IO的响应时间,如果响应时间比较长会导致CPU空闲的情况可以适当增加线程数。

N为CPU数目,即逻辑处理器个数,也就是现代处理器的核数。Java中获取:

Runtime.getRuntime().availableProcessors(); //在容器化中,谨慎使用该方法

《Java Concurrency in Practice》中给出的公式

线程数 = 可用核心数 * (1 + Wait time / Service time)

可以粗略的使用IO时间替代Wait time,使用CPU计算时间替代Service time。Wait time与Service time的比值通常称为阻塞系数。

利特尔法则(Little’s Law)

公式:L=λW

  • L 系统中存在的平均请求数量。
  • λ 请求有效到达速率。例如:5/s 表示每秒有5个请求到达系统。
  • W 请求在系统中的平均等待执行时间。

上述公式可得:λ=L/W,使用平均线程数/平均延迟就可以计算出服务在稳定的响应时间内每秒可以处理的请求数。

守护线程

守护线程和用户线程的区别在于:守护线程依赖于创建它的线程,而用户线程则不依赖。

如果在main线程中创建了一个守护线程,当main方法运行完毕之后,守护线程也会随着消亡。而用户线程则不会,用户线程会一直运行直到其运行完毕。在JVM中,像垃圾收集器线程就是守护线程。

  • thread.setDaemon(true)必须在thread.start()之前设置,否则会抛出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。
  • 在Daemon线程中产生的新线程也是Daemon的。
  • 守护线程不能用于去访问固有资源,比如读写操作或者计算逻辑。因为它会在任何时候甚至在一个操作的中间发生中断。
  • Java自带的多线程框架,比如ExecutorService,会将守护线程转换为用户线程,所以如果要使用后台线程就不能用Java的线程池。

线程控制与协作

  • holdsLock方法 static方法,当且仅当当前线程拥有某个具体对象的锁时返回true。
  • start方法 用来启动一个线程,run方法会为相应的线程分配线程栈、寄存器、程序计数器等线程私有的必需品。
  • sleep方法 让线程睡眠,交出CPU。但sleep方法不会释放锁,也就是说如果当前线程持有对某个对象的锁,即使调用sleep方法,其他线程也无法访问这个对象。该方法可能抛出中断异常,即可响应中断。
  • wait方法 Object的实例方法,可响应中断,可指定等待时间,与notify配置在同步快中使用。
  • yield方法 交出CPU权限,但不会释放锁,不能控制具体的交出CPU的时间,并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取CPU执行时间。
    • 只能让拥有相同优先级的线程有获取CPU执行时间的机会。
  • join方法 假如在main线程中,调用thread.join方法,则main方法会等待thread线程执行完毕或者等待一定的时间。
    • 实际上调用join方法是调用了Object的wait方法,这个可以通过查看源码得知,也可响应中断。
  • interrupt方法 实例方法,底层调用native的interrupt0。
    • 可以使得处于阻塞状态的线程抛出一个异常。若是非阻塞或不能响应中断的线程则仅仅设置标记位。
  • interrupted方法 static方法,底层调用native的isInterrupted(true)。
    • 表示清除状态位,只有当前线程才能清除自己的中断位。
  • isInterrupted方法 实例方法,作用于调用该方法的线程对象所对应的线程。
    • 底层调用native的isInterrupted(false),表示不清除状态位,即对中断状态不产生影响
  • fork join框架
    • fork join框架是JDK7中出现的一款高效的工具,Java开发人员可以通过它充分利用现代服务器上的多处理器。它是专门为了那些可以递归划分成许多子模块设计的,目的是将所有可用的处理能力用来提升程序的性能。
    • fork join框架一个巨大的优势是它使用了工作窃取算法,可以完成更多任务的工作线程可以从其它线程中窃取任务来执行。

Java并发工具包

AQS

  • CountDownLatch 是能使一个(组)线程等另一组线程全都跑完了再继续跑。
  • CyclicBarrier 能够使一组线程在一个时间点上达到同步,然后再一起开始下个阶段的执行。
  • Semaphore 只允许一定数量的线程同时执行一段任务。即将应用在我们的流量控制模块中。
  • Exchanger 可以在两个线程之间交换数据,只能是2个线程,他不支持更多的线程之间互换数据。

Collections

  • LinkedBlockingQueue 实现了先进先出等特性,是作为生产者消费者的首选。
  • ConcurrentHashMap 引入了“分段锁”的概念,Segment是一种可重入锁ReentrantLock。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素, 当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。Java8开始使用synchronized、CAS、链表&红黑树、锁表头(Segment的存在是为了兼容Java7)。
  • CopyOnWriteArrayList 一个线程安全的变体,其中所有可变操作(add、set等等)都是通过对底层数组进行一次新的复制来实现的。这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更有效。 在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。“快照”风格的迭代器方法在创建迭代器时使用了对数组状态的引用。

阻塞队列不接受空值,当你尝试向队列中添加空值的时候,它会抛出NullPointerException。阻塞队列的实现都是线程安全的,所有的查询方法都是原子的并且使用了内部锁或者其他形式的并发控制。

Atomic

  • AtomicBoolean 、AtomicInteger、 AtomicLong等原子计数器
  • AtomicReference、 AtomicReferenceArray等原子引用

通过Unsafe的CAS功能实现

线程问题排查

  • 跟踪线程 通过jps –lmv命令查找java进程id,然后使用jstack输出线程列表,跟踪tid、nid可以找到问题线程。jps参数:
    • q 只输出LVMID,一般是JVM进程的操作系统进程ID,参考https://stackoverflow.com/questions/4375028/what-is-lvmid-in-java
    • m 输出虚拟机启动时传递给main函数的参数
    • l 输出主类的全名
    • v 输出虚拟机启动时JVM参数
  • 发现有线程进入BLOCKED,而且持续好久,这说明性能瓶颈存在于synchronized块等同步块中。也就这个synchronized块中的逻辑处理速度比较慢,一直持有锁,其他线程无法进入。
  • 发现有线程进入WAITING,而且持续好久,说明性能瓶颈存在于触发notify的那部分逻辑。往往是产生WAIT的速度大于NOTIFY消耗的速度。
  • 线程进入TIME_WAITING状态且持续好久的, 跟WAITING的排查方式一样。

内存溢出问题

线上场景 单点登录接口,做加密操作时,创建了大量的BouncyCastleProvider对象,错误代码如下:

Cipher.getInstance("RSA", new BouncyCastleProvider()); // 正确做法是:这里只需要创建一个全局BouncyCastleProvide对象就可以

紧急解决方案

  • 扩容,重启机器增加堆内存分配。

分析

  • 前提是有配置JVM参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/jvm/ (OOM时自动jmap dump内存快照到指定路径)。
  • 使用jvisualvm打开该快照,找到其中可疑的对象以及内存增长比较高的时间段和该时间段内比较频繁接口。
  • 使用jmeter压测问题接口:
    • 查看压测的接口响应情况,表现:响应时间越来越长,到最后完全无响应,对应报错OOM。
    • 压测时打开jdk自带jvisualvm工具,查看其内存与垃圾回收情况。垃圾回收情况如下:
      • 刚刚开始压测一小段时间,老年代已经在持续增长了,eden已经出现了多次minorGC。
      • 压测一段时间后,eden执行了非常多次minorGC,而老年代一直在持续增长,已经到达了最大内存。
      • 压测较长后,eden执行了非常多次,而老年代在到达了最大内存后也执行了一次fullGC。
      • 压测很久很久后,可是老年代在一次fullGC后,并没有缓解情况,后续还在持续增长,探后再次打满内存,导致OOM。
    • 观测堆快照,定位内存泄漏的是哪个对象。
    • 观测线程快照,定位内存溢出的是哪个线程方法。

多线程中的死锁

如何预防死锁

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不可剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

根据四个必要条件,预防死锁最简单的方法就是阻止循环等待条件,将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序(升序或降序)做操作来避免死锁。

想要避免死锁,可以使用银行家算法。

文档信息

Search

    Table of Contents