首页 > 其他分享 >使用线程池你应该知道的知识点

使用线程池你应该知道的知识点

时间:2024-08-02 17:27:31浏览次数:18  
标签:知识点 队列 可以 ThreadLocal 任务 线程 使用 应该

多线程编程是每一个开发必知必会的技能,在实际项目中,为了避免频繁创建和销毁线程,我们通常使用池化的思想,用线程池进行多线程开发。线程池在开发中使用频率非常高,也包含不少知识点,是一个高频面试题,本篇总结线程池的使用经验和需要注意的问题,更好的应对日常开发和面试。如有更多知识点,欢迎补充~

异常处理

正如我们在异常处理机制所讲,如果你没有对提交给线程池的任务进行异常捕获,那么异常信息将会丢失,不利于问题排查。通常异常处理要么是手动处理掉,要么是往上抛由全局异常处理器统一处理,切勿吃掉异常。在实际开发中,我们可以使用装饰器模式对TheradPoolExecutor进行封装,重写它的execute和submit方法,进行try-catch处理,打印日志,防止开发同学直接使用ThreadPoolExecutor提交任务而漏了异常处理。

traceid

完整的日志链路对日志分析,问题排查是至关重要的,否则拿到一堆日志没有关联性,根本无从下手。一个完整的请求可能经过很多个方法调用,服务调用,mq消息发送等,要串联起来需要一个全局id,称为traceid。例如我们使用spring cloud sleuth链路跟踪,它就会在上下文(MDC)塞一个traceid,并不断传递下去。很遗憾,如果你在请求过程使用线程池(直接new ThreadPoolExecutor),那么traceid将会丢失,例如你会看到如下日志:

很明显,线程池里打印的日志跟外面的关联不起来了,这会影响我们分析排查问题。解决方案,可以使用spring提供的ThreadPoolTaskExecutor,它内部也包装了ThreadPoolExecutor,提供更多功能。将其注册到spring容器中,使用spring cloud sleuth时,它会判断如果实现了ExecutorService接口的bean,就会进行动态代理为TraceableExecutorService,它会将当前上下文的traceid传递给线程池的线程,那么就可以关联起来了。如:

当然你也可以像前面说的,封装自己的ThreadPoolExecutor注册到spring容器,也一样会被代理。关于traceid我们还在xxl这边有写到,可以参考给xxl新增traceId和spanId

ThreadLocal

ThreadLocal是线程内一块数据结构(Thread类内有一个ThreadLocal.ThreadLocalMap),线程间互不干扰,没有并发问题。上面我们提到使用MDC可以在各个位置打印traceid,实际就是利用了ThreadLocal,如使用logback:

ThreadLocal在线程内传递数据是没有问题的,但涉及到子线程怎么办呢?这个时候就无法传递过去了,不过Thread类内还有一个ThreadLocal.ThreadLocalMap inheritableThreadLocals,当创建子线程的时候会把父线程的ThreadLocal“继承”过来。

但使用线程池场景又不太一样了,因为线程池里的线程是只创建一次,后续复用的,而前面说的“继承”是创建时一次性传递过来,后续就不会更改,很明显不符合线程池的场景,使用线程池时希望线程每次都从父线程获取最新的ThreadLocal。解决方案,可以使用阿里的transmittable-thread-local,它的原理也不复杂,就是在每次提交任务给线程池的时候,拷贝ThreadLocal。 关于ThreadLocal我们之前有过介绍,有兴趣可以看下面试再也不怕问ThreadLocal了ThreadLocal扩展

核心参数

这是入门级八股文了吧,基本烂到面试官都不问了,但有一些点我仍想发掘一下亮点。

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

如上是参数最全的构造方法,参数解释:

  • corePoolSize

核心线程数,默认是不初始化创建核心线程,也不回收。可以通过prestartCoreThread/prestartAllCoreThreads方法对线程池进行预热,也可以通过allowCoreThreadTimeOut方法对核心线程进行回收。

  • maximumPoolSize

最大线程数,当核心线程开到最大,且任务队列也满了,还有任务提交,就会继续开线程到直到最大线程数。

  • keepAliveTime

当线程数大于核心线程数,线程最大空闲时间,超过就会被销毁回收。

  • unit

keepAliveTime的时间单位。

  • workQueue

队列,核心线程满了,任务会暂存到队列,等待执行。

  • threadFactory

线程工厂,默认是Executors.defaultThreadFactory(),线程名称由:pool-数字-thread 组成。

  • RejectedExecutionHandler

拒绝策略,当队列满了,最大线程数也满了,还提交任务就会触发拒绝策略,jdk提供了4种拒绝策略,默认是AbortPolicy,也可以自定义拒绝策略。

需要提到的点是:

  • 根据业务定义不同的线程池

有的人喜欢定义一个所谓“通用”的线程池来处理各种业务,这种做法不好,每种业务的核心参数需求不一样,且会相互竞争资源,正确的做法应该是每种业务定义一个线程池。

  • 有意义的线程名称

从上面可以看到默认的线程名称不是特别友好,我们可以根据业务取一个有意义的名称。这里我用到guava里的ThreadFactoryBuilder,例如:

new ThreadFactoryBuilder()
    .setNameFormat(“taskDispatchPool” + "-%d")
    .build();

  • 合适的队列长度

过长的队列长度可能导致应用OOM,实际要根据具体情况指定,不宜过大,禁止使用无界队列。

jdk的Executors辅助类创建的线程池队列长度很多都是无界的,稍有不慎就会导致内存溢出,这也是为什么阿里java开发规范明确禁止使用Executors创建线程池的原因。

  • 与tomcat/hystirx线程池的区别

jdk的线程池是在核心线程满了,任务先进入队列,队列满了再继续创建线程到最大线程数。

而tomcat/hystrix的线程池策略是,核心线程满了,就继续创建线程到最大线程数,再有任务就进入队列。

  • 设置合适的核心参数

一般任务可以分为cpu密集型或io密集型,对于cpu密集型比较容易设置,一般设置为cpu核数即可,不宜过大,因为cpu密集型线程过大会有大量的线程切换,反而降低性能。

io密集型就不好估算了,而且大部分情况下都是io密集型,没有万能公式,只能根据经验,测试,生产运行观察,调整,才能达到一个比较合适的值,这就要求我们的线程池要支持动态调整参数,下面还会说到。

如果跟面试官提这些点,说明你不是单纯背八股文,是有真的在思考总结~

动态线程池

上面我们说到线程池的核心参数(主要是线程数和队列长度)不太好估算,设置过小可能任务处理不过来导致阻塞,设置过大可能影响整体服务或影响下游服务,所以生产环境的线程池要支持动态调整。幸运的是ThreadPoolExecutor提供了方法可以直接对核心参数进行修改,例如setCorePoolSize,setMaximumPoolSize,我们可以在运行过程进行设置,线程池内部就会根据参数调整线程了。笔者所在的项目一份代码会部署在各个国家(环境),每个环境的业务,数据量不一样,机器配置也不一样,所以需要根据环境设置不同的参数。在实际运行过程中,有时候为了提升处理效率设置比较大的线程数,而忽略对下游服务的影响,导致下游服务被压垮。也有时候开始估算太小,后面业务增长,导致处理不过来的情况。所以需要动态调整,我们把线程池参数配置接入到apollo,随时可以调整生效,同时我们也把线程池的运行情况上报到grafana,可以进行监控,告警。关于动态线程池,之前也写过,可以参考:加强版ThreadPoolExecutor升级Java线程池实现原理及其在美团业务中的实践hippo4j如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答

任务统计

有时候我们需要对提交给线程池的任务进行统计,例如本次执行成功多少,失败多少,过滤多少。线程池就没有提供这种实现了,因为它是一直运行的状态,区分不了业务上的东西,只能简单获取总体完成成功次数(getCompletedTaskCount),或触发拒绝策略次数(getRejectCount)。业务上的统计我们就可以结合CountDownLatch来进行计数,主线程提交完要进行await等待线程池所有任务完成,每个线程完成一次任务就countDown一次,任务计数可以使用LongAdder统计,相比AtomicLong更加高效。这也回应了我们上面说要根据业务定义不同的线程池的原因,不同类型的统计不会相互影响。后来我们有个同学提醒jdk还有个Phaser类,比CountDownLatch好用,也可以用它来完成,具体参见:并发工具类Phaser

默认线程池

你是否在你的团队见过如下代码:

	@Test
	public void test4() throws InterruptedException {
		List<String> list = Lists.newArrayList();
		list.parallelStream().forEach(item->{
			//run async
		});

		CompletableFuture.runAsync(()->{
			//run async
		});
	}

这些写法确实都是异步的,但底层都是用了系统默认的线程池ForkJoinPool.commonPool(),默认线程数是Runtime.getRuntime().availableProcessors() - 1。如果是cpu密集型任务,这么使用也没啥问题。如果是io密集型,就会相互影响。如下,业务2的任务会卡住,知道业务1执行完成。

		//业务1
		for (int i = 0; i < ForkJoinPool.commonPool().getParallelism(); i++) {
			CompletableFuture.runAsync(() -> {
				try {
					Thread.sleep(5000);
				} catch (InterruptedException e) {
				}
			});
		}

		//业务2
		for (int i = 0; i < ForkJoinPool.commonPool().getParallelism(); i++) {
			CompletableFuture.runAsync(() -> {
				System.out.println("running...");
			});
		}

父子线程公用一个线程池

这也是一个实际案例,在我们团队有同学这么使用线程池,逻辑很简单,想要查一批数据的时间和还款概率,实际情况还查了更多信息,做了简化,这些查询需要调用外部接口,为了提升接口性能,把这些查询都丢到线程池里去并发执行,通过Future.get获取结果。同时主线程希望这两个操作也可以并发执行,所以通过CompletableFuture也提交到这个线程池里,最终通过CompletableFuture.join等待所有任务完成。实际代码比较复杂,简化后如下:

        //查一个时间
	CompletableFuture<Void> timeFuture = CompletableFuture.runAsync(() -> {
		initTime(pageResult);
	}, executor);

        //预测还款概率
	CompletableFuture<Void> repayDesireFuture = CompletableFuture.runAsync(() -> {
		initRepayDesire(pageResult);
	}, executor);
		
        //主线程等待
	CompletableFuture.allOf(timeFuture, repayDesireFuture).join();

        private void initTime(List<JobListVo> data) {
		List<List<JobListVo>> lists = Lists.partition(data, CommonConst.PAGE_SIZE_50);
		List<Future<Result<List<TimeInfo>>>> futures = new ArrayList<>();
		lists.forEach(item -> futures.add(executor.submit(() -> client.getTimeList(ids))));
		futures.forEach(
			item -> {
				try {
					Result<List<TimeInfo>> result = item.get();
				} catch (Exception e) {
					log.error("fetch error", e);
				}
			}
		);
	    }

        private void initRepayDesire(List<JobListVo> data) {
		List<List<JobListVo>> lists = Lists.partition(data, CommonConst.PAGE_SIZE_50);
		List<Future<Result<List<RepayDesire>>>> futures = new ArrayList<>();
		lists.forEach(item -> futures.add(executor.submit(() -> client.queryRepayDesire(ids))));
		futures.forEach(
			item -> {
				try {
                            		Result<List<RepayDesire>> result = item.get();
                        	} catch (Exception e) {
                            		log.error("fetch error", e);
                        	}
                    	}
            	);
        }

看到这段代码,可能有的人会说主线程那两个操作并发的意义不大,串行执行即可。但实际分析还是有意义的,假如只有一个请求,查询时间的任务提交到线程池后,线程池资源还有剩余,这个时候并发执行还款概率的任务,是可以加速整个查询速度的。笔者在review这段代码的时候,感觉总是怪怪的,但逻辑上又好像说得通,直到我看到这边文章线程池遇到父子任务,有大坑,要注意!,这不就跟我们的场景几乎一样吗,确实可能会有问题。

根据文章所述,父子任务提交到同一个线程池可能导致父子任务相互等待,最终卡死。极端一点,假设线程池核心线程数是1,队列长度是1,现在主线程提交了一个任务,使用了这个核心线程,开始执行,并等待子线程执行完成。它的执行逻辑是在子线程内再提交一个任务给线程池,由于只有一个核心线程,所以这个任务进入队列等待。等到什么时候呢,等到主线程那个任务完成线程才能释放,主线程又什么时候完成呢,等待子线程执行完成,死循环了...

总结:父子任务,不要公用一个线程池。

shutdown/shutdownNow

这两个方法都是关闭线程池的,区别是shutdown是不再接收新任务,但提交的任务还会执行,而shutdownNow除了不再接收新任务,已提交的任务也不会执行,正在执行中的任务会终止。一般在线程池不再使用或应用下线前,就会调用线程关闭方法。如果关闭后再提交任务,就会触发拒绝策略。最好确保在关闭后不再有任务提交给线程池,否则可能会有问题,笔者之前就遇到线程池关闭后还有请求(服务下线前)进来,导致报TimeoutException错误,具体分析在这篇/线程池shutdown引发TimeoutException,有兴趣的可以看下。

内存泄漏

将线程池变量定义为局部变量时,可能会发生内存泄漏。如下:

	@GetMapping(value = "/test")
	public Result<Void> info() {
		ExecutorService executorService = Executors.newFixedThreadPool(1);
		executorService.submit(() -> System.out.println("active thread"));
		return Result.success();
	}

在我们印象中,executorService作为一个局部变量,在方法返回时,生命周期就结束了,这个时候应该是可以被gc回收的,怎么会发生内存泄漏呢?使用线程池时有点不同,因为这里有个隐含的条件是,虽然方法返回结束了,但线程仍存活这,而存活的线程是可以作为gc root的。我们知道线程池里的线程会被包装为Worker对象,这是定义在ThreadPoolExecutor里的非静态内部类。如下代码,Worker也实现了Runnable接口,把它作为参数传递给Thread对象。

活跃的线程作为gc root的,也就是不会被垃圾回收,而这个线程又引用着这个Worker对象,所以它也不会被回收。那个线程池对象又有什么关系呢?上面说到Worker是作为ThreadPoolExecutor里的非静态内部类,非静态内部类有一个规则就是持有外部类的引用,例如我们可以在InnerClass里调用外部类的方法。或者通过编译后查看class文件也可以看到InnerClass持有外部类的this引用。

public class OuterClass {

	public OuterClass(Runnable runnable) {
	}
	
	public void outterFunction() {

	}

	class InnerClass {
		public InnerClass() {
			outterFunction();
		}
	}
}

所以,由于Worker持有ThreadPoolExecutor的引用,所以它也不会被回收,用一张图表示就是:

解决方案,1.不要使用局部线程池变量,定义为全局变量。2.调用shutdown/shutdownNow关闭线程池,关闭后线程就会被回收,线程池也可以被回收。

附chatgpt:jdk8中,哪些对象可以作为gc root?

虚拟线程

在jdk21以前,我们在java里使用的线程都称为平台线程,与内核线程是一对一的关系,开篇就说到,平台线程的使用成本比较高,所以才使用线程线程池来缓存复用它。

jdk21虚拟线程成为正式功能,可以投入生产使用。java里的虚拟线程类似于goland中的协程,虚拟线程与内核线程不再是一对一的关系,而是多对一,在jvm层面进行调度,可以大大内核线程的数量。如图,可以看到多个虚拟线程在底层还是公用一个内核线程,它们之间的执行调度由jvm自动完成,虚拟线程的创建成本非常低,可以创建的虚拟线程数量可以远大于平台线程。当我们的程序遇到io的时候,以往的方式是将当前线程挂起,cpu进行线程切换,执行另外的任务。使用虚拟线程则不需要,任务的调度执行是在jvm层面完成的,cpu还是一直在执行同一个线程。

代码示例:

Thread.ofVirtual().start(()->{});

springboot3.2 tomcat开启虚拟线程:

spring.threads.virtual.enabled = true

那么使用虚拟线程,还需要线程池吗?答案是不需要的,我们之所以池化是因为对象的创建、销毁成本较高,每次使用都创建,用完就丢弃,太浪费了,但虚拟线程的使用成本很低,所以它不需要池化了。虚拟线程的设计虽然不是为了取代传统线程和编程方式,可以看到jdk是通过扩展支持虚拟线程的,我们依然可以像以前一样编程开发,但在高并发场景虚拟线程是更好的选择,这也是大势所趋,说不定以后哪一天线程池也会被无情的标记上@Deprecated。

文章转载自:jtea

原文链接:https://www.cnblogs.com/jtea/p/18329735

体验地址:引迈 - JNPF快速开发平台_低代码开发平台_零代码开发平台_流程设计器_表单引擎_工作流引擎_软件架构

标签:知识点,队列,可以,ThreadLocal,任务,线程,使用,应该
From: https://blog.csdn.net/dsgdauigfs/article/details/140775919

相关文章

  • SpringBoot入门、进阶、强化、扩展、知识体系完善等知识点学习、性能优化、源码分析专
    场景作为一名Java开发者,SpringBoot已经成为日常开发所必须。势必经历过从入门到自学、从基础到进阶、从学习到强化的过程。当经历过几年企业级开发的磨炼,再回头看之前的开发过程、成长阶段发现确实是走了好多的弯路。作为一名终身学习的信奉者,秉承Java体系需持续学习、持续优......
  • Java入门、进阶、强化、扩展、知识体系完善等知识点学习、性能优化、源码分析专栏分享
    场景作为一名Java开发者,势必经历过从入门到自学、从基础到进阶、从学习到强化的过程。当经历过几年企业级开发的磨炼,再回头看之前的开发过程、成长阶段发现确实是走了好多的弯路。作为一名终身学习的信奉者,秉承Java体系需持续学习、持续优化的信念。不惜耗费无数个日日夜夜,耗......
  • Java:进程和线程
    文章目录进程线程的概念和区别总结如何创建线程1.继承Thread重写run2.实现Runnable重写run3.继承Thread重写run,通过匿名内部类来实现虚拟线程并发编程:通过写特殊的代码,把多个CPU核心都利用起来,这样的代码就称为“并发编程”。多进程编程,就是一种典型的并发编程......
  • 多线程编程
    目录思维导图:学习内容:1. 多线程基本概念2.多线程编程2.1 pthread_create:创建线程 2.2 pthread_self线程号的获取2.3 pthread_exit:线程退出函数课外作业:1、使用两个线程完成两个文件的拷贝,分支线程1拷贝前一半,分支线程2拷贝后一半,主线程回收两个分支线程的资......
  • x264 中多线程相关编码参数详细介绍
    多线程编码相关参数参数名称参数类型参数含义cpuuint32_tcpu型号i_threadsint并行编码线程数i_lookahead_threadsint在lookahead分析中使用多线程b_deterministicint当开启多线程时是否允许非确定性优化b_sliced_threadsint是否使用基于......
  • 枚举知识点(完结)
    枚举文章目录枚举枚举定义枚举类的实现枚举类的属性枚举类的创建创建代码常用方法枚举定义Java枚举是一个特殊类,一边表示一组常量,比如一年的4个季节,一年的12个月份,一个星期的七天,方向有东南西北等类的对象只有有限个,确定的性别:男,女枚举类的实现JDK1.5之前需要......
  • 使用snapshot_download配置代理多线程下载模型
    snapshot_downloadhuggingface官方提供了snapshot_download方法下载完整模型,参数众多、比较完善。支持断点续传、多线程、指定路径、配置代理、排除特定文件等功能。然而有两个缺点:1))该方法依赖于transformers库,而这个库是个开发用的库,对于自动化运维有点重;2)该方法调用......
  • Java多线程编程详解:从基础到高级
    Java多线程编程详解:从基础到高级大家好,我是微赚淘客系统3.0的小编,是个冬天不穿秋裤,天冷也要风度的程序猿!Java的多线程编程允许程序同时执行多个任务,提高了应用的性能和响应能力。本文将从基础到高级,全面介绍Java中的多线程编程,包括线程的创建、线程池、同步机制及并发工具的使用......
  • Java并发—Java内存模型以及线程安全
    目录 一、Java内存模型JMM的核心概念二、什么是线程安全? 1、原子性2、有序性3、可见性三、如何确保线程安全?1、sychronized关键字2、Lock接口和其实现3、volatile关键字4、Atomic原子类5、ThreadLocal6、不可变对象7、并发集合类8、并发工具类9、Future和Ca......
  • Linux系统中 “管理基本存储” 中的部分相关重要知识点
    将持续更新发布,留下个关注吧!1.对Linux磁盘进行分区时有哪两种方案?MBR方案:支持最多四个主分区,可以使用扩展分区和逻辑分区创建最多15个分区,对于32位分区大小,使用此分区的磁盘最多可达2TiBGPT方案:最多提供128个分区,64位存储分区大小。最大磁盘分区大小可以达到8ZiB2.创......