13-高并发-连接池线程池详解

发布时间:2023年12月22日

在应用系统开发过程中,我们经常会用到池化技术,如对象池、连接池、线程池等,通过池化来减少一些消耗,以提升性能。

对象池通过复用对象从而减少创建对象、垃圾回收的开销,但是,池不能太大,太大会影响GC时的扫描时间。连接池如数据库连接池、Redis连接池、HTTP连接池,通过复用TCP连接来减少创建和释放连接的时间来提升性能。线程池也是类似的,通过复用线程提升性能。

也就是说池化的目的就是通过复用技术提升性能。

池化可以使用Apache commons-pool2来实现,比如DBCP、Jedis连接池都是使用commons-pool 2实现的,最新的版本是2.4.2。

本文主要讲解数据库连接池DBCP、HTTP连接池HttpClient和线程池。

数据库连接池

数据库连接池有很多实现,如C3P0、DBCP、Druid等。

验证数据库连接有效性

有三种办法:主动测试(创建连接时、获取连接时、释放连接时)、定时测试(通过定时器定期测试)、关闭孤儿连接。

关闭孤儿连接
如果获取连接后一直没有释放回池中,即该连接泄露了,如果不关闭的话,则会造成数据库连接被用完,因此,可以考虑配置removeAbandoned*进行关闭孤儿连接。不过不建议配置,把代码写健壮吧。

数据库驱动超时实现

MySQL驱动在创建每个连接时会创建一个Timer(每个Timer是一个Thread)。然后每个连接中创建的每个Statement会提交一个TimerTask(超时则每个Task在执行时会创建并启动一个新的Thread)。

也就是说,假设一个数据库连接池创建了500个连接,每个连接执行1个statement,最坏的情况下会创建:
500×1+500×1=1000个线程。

假设一个应用中有三个MySQL数据库连接池,那么最坏情况下有:
1000×3=3000个线程创建。如果数据库采用了分库分表或者读写分离,那么超时带来的影响可想而知。

而Oracle采用不同的策略——每个ClassLoader一个watchdog线程(类似于MySQL的timer)。每个Statement一个Task,而线程是在watchdog需要取消时去触发的,即watchdog发现该Statement需要cancel时,调用其某个方法,该方法快速创建线程并运行。

也就是,说假设我们有500个连接池,每个连接执行1个Statement,最坏的情况下会创建:
1+500×1=501个线程。

假设一个应用中有三个MySQL库,那么最坏情况下有:1+500×3=1501个线程创建。

连接池使用的一些建议

一是要注意网络阻塞/不稳定时的级联效应(比如笔者写的ssdb-client在网络出现故障如网络不可用时,会设置一个时间,在这个时间内的请求全部timemout)。连接池内部应该根据当前网络的状态(比如超时次数太多),对于一定时间内的(如100ms)全部timeout,根本不进行await(maxWait),即有熔断和快速失败机制。

还有就是当前等待连接池的人数,比如现在等待1000个,那么接下来的等待是没有意义的,这样还会造成滚雪球效应。

二是等待超时应该尽可能小点(除非很必要)。即使返回错误页,也比等待并阻塞强。DBCP比较容易出的问题就是设置超时时间太长,造成大量TIMED_WAIT和线程阻塞,而且像滚雪球,一旦出问题很难立即恢复,但可以通过上文中的方案解决。

线程池

线程池的目的类似于连接池,通过减少频繁创建和销毁线程来降低性能损耗。每个线程都需要一个内存栈,用于存储如局部变量、操作栈等信息,可以通过-Xss参数来调整每个线程栈大小(64位系统默认1024KB,可以根据实际情况调小,比如256KB),通过调整该参数可以创建更多的线程,不过JVM不能无限制地创建线程,通过使用线程池可以限制创建的线程数,从而保护系统。

线程池一般配合队列一起工作,使用线程池限制并发处理任务的数量。

然后设置队列的大小,当任务超过队列大小时,通过一定的拒绝策略来处理,这样可以保护系统免受大流量而导致崩溃——只是部分拒绝服务,还是有一部分是可以正常服务的。

线程池一般有核心线程池大小和线程池最大大小配置,当线程池中的线程空闲一段时间时将会被回收,而核心线程池中的线程不会被回收。

在这里插入图片描述
多少个线程合适呢?建议根据实际业务情况来压测决定,或者根据利特尔法则来算出一个合理的线程池大小,其定义是,在一个稳定的系统中,长时间观察到的平均用户数量L,等于长时间观察到的有效到达速率λ与平均每个用户在系统中花费的时间的乘积,即L=λW。但实际情况是复杂的,如存在处理超时、网络抖动都会导致线程花费时间不一样。因此,还要考虑超时机制、线程隔离机制、快速失败机制等,来保护系统免遭大量请求或异常情况的冲击。

Java提供了ExecutorService的三种实现:

  • ThreadPoolExecutor:标准线程池。
  • ScheduledThreadPoolExecutor:支持延迟任务的线程池。
  • ForkJoinPool:类似于ThreadPoolExecutor,但是使用work-stealing模式,其会为线程池中的每个线程创建一个队列,从而用work-stealing(任务窃取)算法使得线程可以从其他线程队列里窃取任务来执行。即如果自己的任务处理完成了,则可以去忙碌的工作线程那里窃取任务执行。

根据任务类型是IO密集型还是CPU密集型、CPU核数,来设置合理的线程池大小、队列大小、拒绝策略,并进行压测和不断调优来决定适合自己场景的参数。

笔者遇到过因为maximumPoolSize设置的过大导致瞬间线程数非常多。还有使用如Executors.newFixedThreadPool时,因没有设置队列大小,默认为Integer.MAX_VALUE,如果有大量任务被缓存到LinkedBlockingQueue中等待线程执行,则会出现GC慢等问题,造成系统响应慢甚至OOM。

因此,在使用线程池时务必须设置池大小、队列大小并设置相应的拒绝策略(RejectedExecutionHandler)。线程池执行情况下无法捕获堆栈上下文,因此任务要记录相关参数,以方便定位提交任务的源头及定位引起问题的源头。

文章来源:https://blog.csdn.net/zhuyufan1986/article/details/135145386
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。