在Java应用开发中经常会用到连接池、线程池等池化技术。池化(pool)技术的本质是通过复用对象、连接等资源,减少创建对象/连接,降低垃圾回收(GC)的开销,适当使用池化相关技术能够显著提高系统效率,优化性能
池化技术
概念
池化技术:把一些能够复用的东西(比如说数据库连接、线程)放到池中,避免重复创建、销毁的开销,从而极大提高性能。
在开发过程中我们会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心,我就以数据库连接池为例,来说明一下连接池管理的关键点。
数据库连接池
数据库连接池有两个最重要的配置:最小连接数和最大连接数,它们控制着从连接池中获取连接的流程:
如果当前连接数小于最小连接数,则创建新的连接处理数据库请求
如果线程池中有空闲连接,则使用空闲连接
如果没有空闲连接,并且当前连接数小于最大连接数,则继续创建新的连接
如果当前连接数大于等于最大连接数,并且没有空闲连接了,则请求按照超时时间等待旧连接可用。、
超时之后,则获取数据库连接失败
对于数据库连接池,根据我的经验,一般在线上我建议最小连接数控制在 10 左右,最大连接数控制在 20~30 左右即可。
数据库故障的原因可能有下面几种:
数据库的域名对应的 IP 发生了变更,池子的连接还是使用旧的 IP,当旧的 IP 下的数据库服务关闭后,再使用这个连接查询就会发生错误;
MySQL 有个参数是“wait_timeout”,控制着当数据库连接闲置多长时间后,数据库会主动的关闭这条连接。这个机制对于数据库使用方是无感知的,所以当我们使用这个被关闭的连接时就会发生错误。
那么怎么去避免这种错误呢?
启动一个线程来定期检测连接池中的连接是否可用,比如使用连接发送“select 1”的命令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。目前 C3P0 连接池可以采用这种方式来检测连接是否可用,也是我比较推荐的方式。
在获取到连接之后,先校验连接是否可用,如果可用才会执行 SQL 语句。比如 DBCP 连接池的 testOnBorrow 配置项,就是控制是否开启这个验证。这种方式在获取连接时会引入多余的开销,在线上系统中还是尽量不要开启,在测试服务上可以使用。
线程池
JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它有两个重要的参数:coreThreadCount 和 maxThreadCount,这两个参数控制着线程池的执行过程。它的执行原理类似如下:
如果线程池中的线程数少于 coreThreadCount 时,处理新的任务时会创建新的线程
如果线程数大于 coreThreadCount 则把任务丢到一个队列里面,由当前空闲的线程执行
当队列中的任务堆积满了的时候,则继续创建线程,直到达到 maxThreadCount
当线程数达到 maxTheadCount 时还有新的任务提交,那么我们就不得不将它们丢弃了
这个任务处理流程看似简单,实际上有很多坑,你在使用的时候一定要注意。
首先, JDK 实现的这个线程池优先把任务放入队列暂存起来,而不是创建更多的线程,**它比较适用于执行 CPU 密集型的任务,也就是需要执行大量 CPU 运算的任务。**这是为什么呢?因为执行 CPU 密集型的任务时 CPU 比较繁忙,因此只需要创建和 CPU 核数相当的线程就好了,多了反而会造成线程上下文切换,降低任务执行效率。所以当当前线程数超过核心线程数时,线程池不会增加线程,而是放在队列里等待核心线程空闲下来。
但是,我们平时开发的 Web 系统通常都有大量的 IO 操作,比方说查询数据库、查询缓存等等。任务在执行 IO 操作的时候 CPU 就空闲了下来,这时如果增加执行任务的线程数而不是把任务暂存在队列中,就可以在单位时间内执行更多的任务,大大提高了任务执行的吞吐量。所以你看 Tomcat 使用的线程池就不是 JDK 原生的线程池,而是做了一些改造,当线程数超过 coreThreadCount 之后会优先创建线程,直到线程数到达 maxThreadCount,这样就比较适合于 Web 系统大量 IO 操作的场景了,你在实际运用过程中也可以参考借鉴。
其次,线程池中使用的队列的堆积量也是我们需要监控的重要指标,对于实时性要求比较高的任务来说,这个指标尤为关键。
我在实际项目中就曾经遇到过任务被丢给线程池之后,长时间都没有被执行的诡异问题。最初,我认为这是代码的 Bug 导致的,后来经过排查发现,是因为线程池的 coreThreadCount 和 maxThreadCount 设置的比较小,导致任务在线程池里面大量的堆积,在调大了这两个参数之后问题就解决了(任务早早被放到队列中堆积着,并且由于coreThreadCount比较小,导致工作线程比较少,处理速度较慢)。跳出这个坑之后,我就把重要线程池的队列任务堆积量,作为一个重要的监控指标放到了系统监控大屏上。
最后,如果你使用线程池请一定记住不要使用无界队列(即没有设置固定大小的队列)。也许你会觉得使用了无界队列后,任务就永远不会被丢弃,只要任务对实时性要求不高,反正早晚有消费完的一天。但是,大量的任务堆积会占用大量的内存空间,一旦内存空间被占满就会频繁地触发 Full GC,造成服务不可用,我之前排查过的一次 GC 引起的宕机,起因就是系统中的一个线程池使用了无界队列。
这时,你回顾一下这两种技术,会发现它们都有一个共同点:它们所管理的对象,无论是连接还是线程,它们的创建过程都比较耗时,也比较消耗系统资源。所以,我们把它们放在一个池子里统一管理起来,以达到提升性能和资源复用的目的。
这是一种常见的软件设计思想,叫做池化技术,它的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本,总之是好处多多。
不过,池化技术也存在一些缺陷,比方说存储池子中的对象肯定需要消耗多余的内存,如果对象没有被频繁使用,就会造成内存上的浪费。再比方说,池子中的对象需要在系统启动的时候就预先创建完成,这在一定程度上增加了系统启动时间。
可这些缺陷相比池化技术的优势来说就比较微不足道了,只要我们确认要使用的对象在创建时确实比较耗时或者消耗资源,并且这些对象也确实会被频繁地创建和销毁,我们就可以使用池化技术来优化。
java性能优化,通常要考虑GC, 线程上下文切换,网络IO操作的影响;池化技术可在一定场景下很好的规避这些问题,如对象(内存)池,线程池,连接池等; 本文讲几个典型案例;
一. 规避GC--对象池
apache common-pool对象池,对象复用,完整的状态管理;
二. 规避线程上下文切换损失---线程池
1 线程池主要类型:
newCachedThreadPool如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程;newFixedThreadPool创建一个指定工作线程数量的线程池,不能处理任务暂时放如队列;newSingleThreadExecutor创建一个单线程,发生异常,新建一个来替代,保证所有任务按照指定顺序(FIFO, LIFO,优先级)执行newScheduleThreadPool创建一个定长的线程池,而且支持定时的以及周期性的任务执行
2 线程池使用注意事项
线程安全选择合适的构造参数,如区分是计算cpu密集型还是io密集型,前者选择核心线程数可根据cpu cores;不要将异常直接抛给线程;合理关闭,防止拖垮操作系统资源
三. 规避io网络连接 ---链接池
1 druid数据源 :
优点--维持到指定数据库的多个可用连接池;包含调用统计、慢查询统计、断路重连;曾遇到问题:拿不到链接; 排查原因:慢查询太多,maxActive设置太低;解决方法,临时增加maxActive值,后续解决掉慢查询;
2 httpClient:
优点:维持到指定路由的http连接池,省去了tcp的3次握手和4次挥手的时间,极大降低请求响应的时间
适合场景:服务之间的互相调用,且调用比较频繁;
注意项: 不要直接关闭http链接,而是交给连接池来处理;
3 lettuce
每个节点保持一个链接,不存在线程安全问题,基于netty维持链接池;多路复用,事件驱动;
四. 规避native内存申请和GC---本地内存池
1 . JVM直接内存的特征
a.申请:相对于堆内存,申请效率较低b.GC:JVM中的直接内存,通常在老年区满了触发FullGC时,才会触发回收;JVM中的直接内存,存在堆内存中其实就是DirectByteBuffer类,和实际内存就是引用关系;DirectByteBuffer熬过了几次younggc之后,会进入老年代。当老年代满了之后,会触发FullGC。因为本身很小,很难占满老年代,因此基本不会触发FullGC,带来的后果是大量堆外内存一直占着不放,无法进行内存回收; 每次申请直接内存,都先看看是否超限 —— 直接内存的限额默认(可用-XX:MaxDirectMemorySize重新设定)。如果超过限额,就会主动执行System.gc(),这样会带来一个影响,系统会中断100ms。如果没有成功回收直接内存,并且还是超过直接内存的限额,就会抛出OOM——内存溢出。c.好处: 减少内次copy
2. JVM直接内容使用优化方案---直接内存池
3. netty内存池
a.内存分级从上到下主要分为:Arena,ChunkList,Chunk,Page,SubPage五级
b.内存池内存分配入口是PoolByteBufAllocator类,该类最终将内存分配委托给PoolArena进行;为了减少高并发下多线程内存分配碰撞带来的性能影响,PoolByteBufAllocator维护着一个PoolArena数组,线程通过轮询获取其中一个进行内存分配,进而实现锁分离;
c.内存分配的基本单元是PoolChunk,从PoolArena中分配获取一个PoolChunk,一个PoolChunk包含多个Page内存页,通过完全二叉树维护多个内存页用于内存分配--请参照slab分配,Buddy(伙伴)分配;
4. netty可回收对象池
Netty自己实现了一套轻量级的对象池。在Netty中,通常会有多个IO线程独立工作,基于NioEventLoop的实现,每个IO线程轮询单独的Selector实例来检索IO事件,并在IO来临时开始处理。最常见的IO操作就是读写,具体到NIO就是从内核缓冲区拷贝数据到用户缓冲区或者从用户缓冲区拷贝数据到内核缓冲区。这里会涉及到大量的创建和回收Buffer,Netty对Buffer进行了池化从而降低系统开销。
a.借用:首先判断池中是否存在对象,如果由则优先从本地线程stack中获取Object,如果stack为空时,再将其他线程queue集合的所有对象根据一定的规则转移到本地线程stack中(1/7规则),然后再从stack获取Object并返回.如果池中不存在对象,创建对象并返回。
b.回收:如果当前回收的线程是原始线程(创建对象的线程),如果超过Stack的容量(默认4*1024)或者1/7规则 就drop(由jvm回收释放)
总结
连接池和线程池你并不陌生,不过你可能对它们的原理和使用方式上还存在困惑或者误区,我在面试时,就发现有很多的同学对线程池的基本使用方式都不了解。借用这节课,我想再次强调的重点是:
池子的最大值和最小值的设置很重要,初期可以依据经验来设置,后面还是需要根据实际运行情况做调整。
池子中的对象需要在使用之前预先初始化完成,这叫做池子的预热,比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。
池化技术核心是一种空间换时间优化方法的实践,所以要关注空间占用情况,避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。
最后希望大家能从文章中得到帮助获得收获,也可以评论出你想看哪方面的技术。文章会持续更新,希望能帮助到大家,哪怕是让你灵光一现。喜欢的朋友可以点点赞和关注,也可以分享出去让更多的人看见,一起努力一起进步!
网友评论