如何避免重复创建线程?创建线程池的方式有哪些?各自优缺点有哪些?
1、案例分析
我们在使用一些app的时候,应该都收到过消息推送,它们往往依赖消息推送服务实现。事实上,互联网大厂都有自己的消息推送服务(又名Message Push Server),通过消息推送服务实现对App的消息推送,不关注掉技术实现的细节,大致过程如图
如果让你来实现一个消息推送服务,要求保证消息推送的实时性,你会怎么设计呢?
你可能会想到,用多线程技术去做,为每个客户端都创建一个线程,然后启动线程,每来一条需要推送的消息就用创建好的线程发送出去。如图
每当有一个客户端与服务端建立连接,就创建一个线程来执行消息推送服务
这种方案看起来挺不错,但是可行性如何?其实,这种方案是无法在生产环境实战的。原因就在于不断创建线程这个操作本身是不合理的。
不慌,我们先看一段代码,在循环中一直创建线程,看看会出现什么情况,如图3所示。
可以看到控制台出现了java.lang.OutOfMemoryError报错,即内存已用尽。出现这个错误的原因就在于我们创建了太多的线程,线程对象本身以及线程的调用栈都是要占用内存的,而操作系统的内存是有限的,这决定了我们能创建的线程数也是有限制的,而无限制的创建线程会使内存不断消耗最终超过内存上限从而报错。
事实上,能创建多少线程数是有一个计算公式的:可创建的线程数 =(进程的最大内存 – JVM分配的内存 – 操作系统保留的内存)/ 线程栈大小。如图所示,粉红色的部分就是可分配线程的内存大小,如果不显式设置-Xss或-XX:ThreadStackSize参数的时候,在Linux x64上ThreadStackSize的默认值就是1024K,也就是1MB大小,如图
这里还要强调的一点是,在Java语言中,当我们每创建一个线程的时候,Java虚拟机就会在JVM内存中创建出一个Thread对象,与此同时创建一个操作系统的线程,最终在系统底层映射的是操作系统的本地线程(Native Thread),在windows系统中是1对1映射(即一个Java线程映射一个操作系统线程),在Linux系统是N对M映射(即多个Java线程映射多个操作系统线程,N与M不完全相等),这里就仅做了解,不详细展开了,感兴趣的同学可以去看一下操作系统的线程部分的知识。需要明确的是,这里说的映射关系是系统自动完成的,不需要用户主手动操作
我们还要记住一点,那就是操作系统的线程使用的内存并不是JVM分配的内存,而是系统中剩余的内存,也就是公式中的(进程的最大内存 – JVM分配的内存 – 操作系统保留的内存)。这么一来,你给JVM分配内存越多,那你能创建的线程就越少,也就越容易发生Exception in thread “main” java.lang.OutOfMemoryError: unable to create new native thread这样的异常。
回到文章开头的那个案例,如果使用app的移动设备非常多,达到数百万甚至上千万台,那么通过为每个客户端创建一个线程的方式执行推送就是不现实的,Java进程会因为占用过多内存而大量报错,代码执行效率很低。那怎么办才能既不会大量创建线程导致java.lang.OutOfMemoryError,又能实现业务需求呢?
这时候,我们就需要用到线程池这个神器了。通过线程池可以避免重复创建线程,此时发送推送消息的方式如图6所示;
我们通过创建了一个消息推送线程池,对线程进行复用,让这些固定数量的线程去执行不断产生的消息推送任务。事实上,线程池就像一个加工厂,加工厂里有一个队列对待处理的任务进行存储,同时加工厂里还装了固定数量的工作线程去队列中获取任务进行执行。
就是说,应用程序不断的往线程池中提交任务,有的任务就被工作线程直接执行了,如果工作线程都是处于繁忙的执行状态,那么应用程序就先把任务提交到任务队列里缓存起来,然后工作线程会从队列里取任务进行处理。
这就好比是说,你去食堂打饭,点了一份水饺,因为你去的比较早,不用排队。可以直接和食堂大妈说,我要一份猪肉大葱馅儿的水饺。大妈就会盛一份做好的水饺给你,你直接结账端去吃就行。
假如有一天,你因为处理线上问题导致吃饭时间稍微耽误了,到了食堂就会发现,排了几十人的队伍。可是这家水饺实在太好吃,只能自觉排队等着轮到自己。
在这个案例中,去得早直接让大妈盛一份水饺,就相当于是说直接让工作线程把任务执行了,去的迟了就相当于是说任务需要进队列,然后等待工作线程执行任务。在这个场景里,食堂大妈就相当于工作线程,等待过程排的长队就相当于任务队列。
2、创建线程池的方式
事实上,Java已经有现成的线程池供我们使用,也就是大名鼎鼎的Executors框架。它提供了多种创建线程池的方法。
Executors框架提供了多种类型的线程池,我们依次介绍一下:
- newSingleThreadExecutor()方法:它返回的线程池实例中只有一个工作线程,如果提交超过一个任务到线程池中,那么任务会被保存在队列中。等工作线程空闲了就从队列中取出其他任务进行执行。获取任务遵循队列的先进先出原则
- newCachedThreadPool()方法:它返回的线程池中的线程数量是可变的,理论上可以创建Integer.MAX_VALUE个线程。当然,如果有空闲的线程能够被复用,就还是优先使用可被复用的线程。当目前所有的线程都处于工作状态,但是仍然有新任务被提交了,那么就会创建新的线程来调度新任务。
- newFixedThreadPool()方法: 它返回一个带固定数量线程的线程池。这个线程池中的线程数量从线程池一开始创建就固定不变。如果提交一个任务到这个线程池里,线程池中恰好有空闲的线程,那么就会立即执行任务;否则,没有空闲的工作线程,那么新提交的任务就只能被暂存在一个任务队列里面,等待空闲线程去处理任务队列中的任务。
- newSingleThreadScheduledExecutor():这个方法返回的是ScheduledExecutorService对象实例。学习源码我们会发现,ScheduledExecutorService实际上是继承了接口ExecutorService,并扩展出了周期性调度任务的能力。
- newScheduledThreadPool():和newSingleThreadScheduledExecutor()类似,它也是返回一个ScheduledExecutorService对象实例,只不过它能够指定线程的数量。
这些线程池的使用方法也很简单,我们以newCachedThreadPool()为例,简单展示一下如何向线程池中提交任务,如图
我们模拟向线程池中提交请求,打印请求id和当前线程名称,执行该测试代码,观察一下日志打印,运行结果如图
可以看到,通过submit(Runnable task)方法向 newCachedThreadPool()提交任务,新的任务都是被一个新的线程所执行,如果前面创建的线程空闲了,才会被复用。我们一共提交了10个任务,分配了9个线程进行调度,最后一个任务被执行完任务再度空闲的线程所执行。
那么我们回到开头的案例,如果要使用线程池来实现发送消息推送的需求,我们应该如何去做呢?
首先就排除掉newCachedThreadPool()这种方式!原因在于newCachedThreadPool()方式是一个最大线程数为Integer.MAX_VALUE的线程池,这意味我们最多可以一次性创建2147483647个线程!足足21亿4000万个!如果系统面临同时并发发送大量的任务的场景,而且任务本身执行速度不是很快的情况下,系统是有可能会开启大量线程进行处理的,这么多线程会在短时间快速耗尽系统资源,造成系统抛出java.lang.OutOfMemoryError这个恐怖的异常!而且newCachedThreadPool()持有的是一个SynchronousQueue,后面的文章我们会详细讲解这部分内容,现在只需要知道SynchronousQueue是一个缓存值为1的阻塞队列,通俗的说,它根本就没有缓冲任务的能力,具体的原理如图
那么我们就猜想,是不是应该控制一下线程池的数量,不要让系统无休止的创建新线程呢?
答案也确实如此。如果我们使用Executors.newFixedThreadPool()指定固定数量的线程池,那么系统至少不会无休止的创建线程了,比方说,我们指定了100个线程,那么就是通过Executors.newFixedThreadPool(100)的方式创建线程池。这么做就可以高枕无忧了吗?
可别得意的太早,newFixedThreadPool()确实是控制了线程数量,但是线程池里还有一个东西叫做任务队列!这玩意儿也是要占用内存空间的。我们来看看newFixedThreadPool()方法吧,如下图
看到了吧,它用的队列是new LinkedBlockingQueue(),这是个无界队列。这意味着,就算我们限制了线程数量是固定不能一直创建,当海量并发任务提交过来的时候,会因为线程数不够而将任务入队列,要看到这个队列是无界队列,所以这就是说,任务会一直入队。但是这在现实中是不可能发生的,因为内存是有限的啊,如果一直提交任务,内存肯定是会被耗尽的!
通过上文的分析,我们发现,线程池的核心是工作线程和任务队列。现在用一张图来总结一下线程池的基本架构,如图
回到解决开头的案例的思路中来,让我们继续看看Executors框架还有没有其他合适的方法能够供我们高效的进行消息推送。到目前为止,就剩newSingleThreadExecutor()这个线程池创建方式了。它的方法签名如图
可以看到,它本质上就是newFixedThreadPool(1),对吧,那其实它也会面临newFixedThreadPool()的问题,那就是无界队列可能会因为海量任务一直提交入队,消耗内存最后导致内存爆满,原理如图