mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-15 21:53:49 +08:00
mod
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
<audio id="audio" title="38 | 案例分析(一):高性能限流器Guava RateLimiter" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/ba/f6886442618684af26003b3dda798fba.mp3"></audio>
|
||||
|
||||
从今天开始,我们就进入案例分析模块了。 这个模块我们将分析四个经典的开源框架,看看它们是如何处理并发问题的,通过这四个案例的学习,相信你会对如何解决并发问题有个更深入的认识。
|
||||
|
||||
首先我们来看看**Guava RateLimiter是如何解决高并发场景下的限流问题的**。Guava是Google开源的Java类库,提供了一个工具类RateLimiter。我们先来看看RateLimiter的使用,让你对限流有个感官的印象。假设我们有一个线程池,它每秒只能处理两个任务,如果提交的任务过快,可能导致系统不稳定,这个时候就需要用到限流。
|
||||
|
||||
在下面的示例代码中,我们创建了一个流速为2个请求/秒的限流器,这里的流速该怎么理解呢?直观地看,2个请求/秒指的是每秒最多允许2个请求通过限流器,其实在Guava中,流速还有更深一层的意思:是一种匀速的概念,2个请求/秒等价于1个请求/500毫秒。
|
||||
|
||||
在向线程池提交任务之前,调用 `acquire()` 方法就能起到限流的作用。通过示例代码的执行结果,任务提交到线程池的时间间隔基本上稳定在500毫秒。
|
||||
|
||||
```
|
||||
//限流器流速:2个请求/秒
|
||||
RateLimiter limiter =
|
||||
RateLimiter.create(2.0);
|
||||
//执行任务的线程池
|
||||
ExecutorService es = Executors
|
||||
.newFixedThreadPool(1);
|
||||
//记录上一次执行时间
|
||||
prev = System.nanoTime();
|
||||
//测试执行20次
|
||||
for (int i=0; i<20; i++){
|
||||
//限流器限流
|
||||
limiter.acquire();
|
||||
//提交任务异步执行
|
||||
es.execute(()->{
|
||||
long cur=System.nanoTime();
|
||||
//打印时间间隔:毫秒
|
||||
System.out.println(
|
||||
(cur-prev)/1000_000);
|
||||
prev = cur;
|
||||
});
|
||||
}
|
||||
|
||||
输出结果:
|
||||
...
|
||||
500
|
||||
499
|
||||
499
|
||||
500
|
||||
499
|
||||
|
||||
```
|
||||
|
||||
## 经典限流算法:令牌桶算法
|
||||
|
||||
Guava的限流器使用上还是很简单的,那它是如何实现的呢?Guava采用的是**令牌桶算法**,其**核心是要想通过限流器,必须拿到令牌**。也就是说,只要我们能够限制发放令牌的速率,那么就能控制流速了。令牌桶算法的详细描述如下:
|
||||
|
||||
1. 令牌以固定的速率添加到令牌桶中,假设限流的速率是 r/秒,则令牌每 1/r 秒会添加一个;
|
||||
1. 假设令牌桶的容量是 b ,如果令牌桶已满,则新的令牌会被丢弃;
|
||||
1. 请求能够通过限流器的前提是令牌桶中有令牌。
|
||||
|
||||
这个算法中,限流的速率 r 还是比较容易理解的,但令牌桶的容量 b 该怎么理解呢?b 其实是burst的简写,意义是**限流器允许的最大突发流量**。比如b=10,而且令牌桶中的令牌已满,此时限流器允许10个请求同时通过限流器,当然只是突发流量而已,这10个请求会带走10个令牌,所以后续的流量只能按照速率 r 通过限流器。
|
||||
|
||||
令牌桶这个算法,如何用Java实现呢?很可能你的直觉会告诉你生产者-消费者模式:一个生产者线程定时向阻塞队列中添加令牌,而试图通过限流器的线程则作为消费者线程,只有从阻塞队列中获取到令牌,才允许通过限流器。
|
||||
|
||||
这个算法看上去非常完美,而且实现起来非常简单,如果并发量不大,这个实现并没有什么问题。可实际情况却是使用限流的场景大部分都是高并发场景,而且系统压力已经临近极限了,此时这个实现就有问题了。问题就出在定时器上,在高并发场景下,当系统压力已经临近极限的时候,定时器的精度误差会非常大,同时定时器本身会创建调度线程,也会对系统的性能产生影响。
|
||||
|
||||
那还有什么好的实现方式呢?当然有,Guava的实现就没有使用定时器,下面我们就来看看它是如何实现的。
|
||||
|
||||
## Guava如何实现令牌桶算法
|
||||
|
||||
Guava实现令牌桶算法,用了一个很简单的办法,其关键是**记录并动态计算下一令牌发放的时间**。下面我们以一个最简单的场景来介绍该算法的执行过程。假设令牌桶的容量为 b=1,限流速率 r = 1个请求/秒,如下图所示,如果当前令牌桶中没有令牌,下一个令牌的发放时间是在第3秒,而在第2秒的时候有一个线程T1请求令牌,此时该如何处理呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/ce/391179821a55fc798c9c17a6991c1dce.png" alt="">
|
||||
|
||||
对于这个请求令牌的线程而言,很显然需要等待1秒,因为1秒以后(第3秒)它就能拿到令牌了。此时需要注意的是,下一个令牌发放的时间也要增加1秒,为什么呢?因为第3秒发放的令牌已经被线程T1预占了。处理之后如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/87/1a4069c830e18de087ba7f490aa78087.png" alt="">
|
||||
|
||||
假设T1在预占了第3秒的令牌之后,马上又有一个线程T2请求令牌,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/2e/2cf695d0888a93e1e2d020d9514f5a2e.png" alt="">
|
||||
|
||||
很显然,由于下一个令牌产生的时间是第4秒,所以线程T2要等待两秒的时间,才能获取到令牌,同时由于T2预占了第4秒的令牌,所以下一令牌产生时间还要增加1秒,完全处理之后,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/f7/68c09a96049aacda7936c52b801c22f7.png" alt="">
|
||||
|
||||
上面线程T1、T2都是在**下一令牌产生时间之前**请求令牌,如果线程在**下一令牌产生时间之后**请求令牌会如何呢?假设在线程T1请求令牌之后的5秒,也就是第7秒,线程T3请求令牌,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/5c/e3125d72eb3d84eabf6de6ab987e695c.png" alt="">
|
||||
|
||||
由于在第5秒已经产生了一个令牌,所以此时线程T3可以直接拿到令牌,而无需等待。在第7秒,实际上限流器能够产生3个令牌,第5、6、7秒各产生一个令牌。由于我们假设令牌桶的容量是1,所以第6、7秒产生的令牌就丢弃了,其实等价地你也可以认为是保留的第7秒的令牌,丢弃的第5、6秒的令牌,也就是说第7秒的令牌被线程T3占有了,于是下一令牌的的产生时间应该是第8秒,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/fc/baf159d05b2abf650839e29a2399a4fc.png" alt="">
|
||||
|
||||
通过上面简要地分析,你会发现,我们**只需要记录一个下一令牌产生的时间,并动态更新它,就能够轻松完成限流功能**。我们可以将上面的这个算法代码化,示例代码如下所示,依然假设令牌桶的容量是1。关键是**reserve()方法**,这个方法会为请求令牌的线程预分配令牌,同时返回该线程能够获取令牌的时间。其实现逻辑就是上面提到的:如果线程请求令牌的时间在下一令牌产生时间之后,那么该线程立刻就能够获取令牌;反之,如果请求时间在下一令牌产生时间之前,那么该线程是在下一令牌产生的时间获取令牌。由于此时下一令牌已经被该线程预占,所以下一令牌产生的时间需要加上1秒。
|
||||
|
||||
```
|
||||
class SimpleLimiter {
|
||||
//下一令牌产生时间
|
||||
long next = System.nanoTime();
|
||||
//发放令牌间隔:纳秒
|
||||
long interval = 1000_000_000;
|
||||
//预占令牌,返回能够获取令牌的时间
|
||||
synchronized long reserve(long now){
|
||||
//请求时间在下一令牌产生时间之后
|
||||
//重新计算下一令牌产生时间
|
||||
if (now > next){
|
||||
//将下一令牌产生时间重置为当前时间
|
||||
next = now;
|
||||
}
|
||||
//能够获取令牌的时间
|
||||
long at=next;
|
||||
//设置下一令牌产生时间
|
||||
next += interval;
|
||||
//返回线程需要等待的时间
|
||||
return Math.max(at, 0L);
|
||||
}
|
||||
//申请令牌
|
||||
void acquire() {
|
||||
//申请令牌时的时间
|
||||
long now = System.nanoTime();
|
||||
//预占令牌
|
||||
long at=reserve(now);
|
||||
long waitTime=max(at-now, 0);
|
||||
//按照条件等待
|
||||
if(waitTime > 0) {
|
||||
try {
|
||||
TimeUnit.NANOSECONDS
|
||||
.sleep(waitTime);
|
||||
}catch(InterruptedException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果令牌桶的容量大于1,又该如何处理呢?按照令牌桶算法,令牌要首先从令牌桶中出,所以我们需要按需计算令牌桶中的数量,当有线程请求令牌时,先从令牌桶中出。具体的代码实现如下所示。我们增加了一个**resync()方法**,在这个方法中,如果线程请求令牌的时间在下一令牌产生时间之后,会重新计算令牌桶中的令牌数,**新产生的令牌的计算公式是:(now-next)/interval**,你可对照上面的示意图来理解。reserve()方法中,则增加了先从令牌桶中出令牌的逻辑,不过需要注意的是,如果令牌是从令牌桶中出的,那么next就无需增加一个 interval 了。
|
||||
|
||||
```
|
||||
class SimpleLimiter {
|
||||
//当前令牌桶中的令牌数量
|
||||
long storedPermits = 0;
|
||||
//令牌桶的容量
|
||||
long maxPermits = 3;
|
||||
//下一令牌产生时间
|
||||
long next = System.nanoTime();
|
||||
//发放令牌间隔:纳秒
|
||||
long interval = 1000_000_000;
|
||||
|
||||
//请求时间在下一令牌产生时间之后,则
|
||||
// 1.重新计算令牌桶中的令牌数
|
||||
// 2.将下一个令牌发放时间重置为当前时间
|
||||
void resync(long now) {
|
||||
if (now > next) {
|
||||
//新产生的令牌数
|
||||
long newPermits=(now-next)/interval;
|
||||
//新令牌增加到令牌桶
|
||||
storedPermits=min(maxPermits,
|
||||
storedPermits + newPermits);
|
||||
//将下一个令牌发放时间重置为当前时间
|
||||
next = now;
|
||||
}
|
||||
}
|
||||
//预占令牌,返回能够获取令牌的时间
|
||||
synchronized long reserve(long now){
|
||||
resync(now);
|
||||
//能够获取令牌的时间
|
||||
long at = next;
|
||||
//令牌桶中能提供的令牌
|
||||
long fb=min(1, storedPermits);
|
||||
//令牌净需求:首先减掉令牌桶中的令牌
|
||||
long nr = 1 - fb;
|
||||
//重新计算下一令牌产生时间
|
||||
next = next + nr*interval;
|
||||
//重新计算令牌桶中的令牌
|
||||
this.storedPermits -= fb;
|
||||
return at;
|
||||
}
|
||||
//申请令牌
|
||||
void acquire() {
|
||||
//申请令牌时的时间
|
||||
long now = System.nanoTime();
|
||||
//预占令牌
|
||||
long at=reserve(now);
|
||||
long waitTime=max(at-now, 0);
|
||||
//按照条件等待
|
||||
if(waitTime > 0) {
|
||||
try {
|
||||
TimeUnit.NANOSECONDS
|
||||
.sleep(waitTime);
|
||||
}catch(InterruptedException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
经典的限流算法有两个,一个是**令牌桶算法(Token Bucket)**,另一个是**漏桶算法(Leaky Bucket)**。令牌桶算法是定时向令牌桶发送令牌,请求能够从令牌桶中拿到令牌,然后才能通过限流器;而漏桶算法里,请求就像水一样注入漏桶,漏桶会按照一定的速率自动将水漏掉,只有漏桶里还能注入水的时候,请求才能通过限流器。令牌桶算法和漏桶算法很像一个硬币的正反面,所以你可以参考令牌桶算法的实现来实现漏桶算法。
|
||||
|
||||
上面我们介绍了Guava是如何实现令牌桶算法的,我们的示例代码是对Guava RateLimiter的简化,Guava RateLimiter扩展了标准的令牌桶算法,比如还能支持预热功能。对于按需加载的缓存来说,预热后缓存能支持5万TPS的并发,但是在预热前5万TPS的并发直接就把缓存击垮了,所以如果需要给该缓存限流,限流器也需要支持预热功能,在初始阶段,限制的流速 r 很小,但是动态增长的。预热功能的实现非常复杂,Guava构建了一个积分函数来解决这个问题,如果你感兴趣,可以继续深入研究。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
135
极客时间专栏/Java并发编程实战/第四部分:案例分析/39 | 案例分析(二):高性能网络应用框架Netty.md
Normal file
135
极客时间专栏/Java并发编程实战/第四部分:案例分析/39 | 案例分析(二):高性能网络应用框架Netty.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<audio id="audio" title="39 | 案例分析(二):高性能网络应用框架Netty" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/85/068c0e218368700a2bc4befb300b2985.mp3"></audio>
|
||||
|
||||
Netty是一个高性能网络应用框架,应用非常普遍,目前在Java领域里,Netty基本上成为网络程序的标配了。Netty框架功能丰富,也非常复杂,今天我们主要分析Netty框架中的线程模型,而**线程模型直接影响着网络程序的性能**。
|
||||
|
||||
在介绍Netty的线程模型之前,我们首先需要把问题搞清楚,了解网络编程性能的瓶颈在哪里,然后再看Netty的线程模型是如何解决这个问题的。
|
||||
|
||||
## 网络编程性能的瓶颈
|
||||
|
||||
在[《33 | Thread-Per-Message模式:最简单实用的分工方法》](https://time.geekbang.org/column/article/95098)中,我们写过一个简单的网络程序echo,采用的是阻塞式I/O(BIO)。BIO模型里,所有read()操作和write()操作都会阻塞当前线程的,如果客户端已经和服务端建立了一个连接,而迟迟不发送数据,那么服务端的read()操作会一直阻塞,所以**使用BIO模型,一般都会为每个socket分配一个独立的线程**,这样就不会因为线程阻塞在一个socket上而影响对其他socket的读写。BIO的线程模型如下图所示,每一个socket都对应一个独立的线程;为了避免频繁创建、消耗线程,可以采用线程池,但是socket和线程之间的对应关系并不会变化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/e2/e712c37ea0483e9dde0d6efe76e687e2.png" alt="">
|
||||
|
||||
BIO这种线程模型适用于socket连接不是很多的场景;但是现在的互联网场景,往往需要服务器能够支撑十万甚至百万连接,而创建十万甚至上百万个线程显然并不现实,所以BIO线程模型无法解决百万连接的问题。如果仔细观察,你会发现互联网场景中,虽然连接多,但是每个连接上的请求并不频繁,所以线程大部分时间都在等待I/O就绪。也就是说线程大部分时间都阻塞在那里,这完全是浪费,如果我们能够解决这个问题,那就不需要这么多线程了。
|
||||
|
||||
顺着这个思路,我们可以将线程模型优化为下图这个样子,可以用一个线程来处理多个连接,这样线程的利用率就上来了,同时所需的线程数量也跟着降下来了。这个思路很好,可是使用BIO相关的API是无法实现的,这是为什么呢?因为BIO相关的socket读写操作都是阻塞式的,而一旦调用了阻塞式API,在I/O就绪前,调用线程会一直阻塞,也就无法处理其他的socket连接了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/1f/eafed0787b82b0b428e1ec0927029f1f.png" alt="">
|
||||
|
||||
好在Java里还提供了非阻塞式(NIO)API,**利用非阻塞式API就能够实现一个线程处理多个连接了**。那具体如何实现呢?现在普遍都是**采用Reactor模式**,包括Netty的实现。所以,要想理解Netty的实现,接下来我们就需要先了解一下Reactor模式。
|
||||
|
||||
## Reactor模式
|
||||
|
||||
下面是Reactor模式的类结构图,其中Handle指的是I/O句柄,在Java网络编程里,它本质上就是一个网络连接。Event Handler很容易理解,就是一个事件处理器,其中handle_event()方法处理I/O事件,也就是每个Event Handler处理一个I/O Handle;get_handle()方法可以返回这个I/O的Handle。Synchronous Event Demultiplexer可以理解为操作系统提供的I/O多路复用API,例如POSIX标准里的select()以及Linux里面的epoll()。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/40/a7ba3c8d6c49e50d9288baf0c03fa240.png" alt="">
|
||||
|
||||
Reactor模式的核心自然是**Reactor这个类**,其中register_handler()和remove_handler()这两个方法可以注册和删除一个事件处理器;**handle_events()方式是核心**,也是Reactor模式的发动机,这个方法的核心逻辑如下:首先通过同步事件多路选择器提供的select()方法监听网络事件,当有网络事件就绪后,就遍历事件处理器来处理该网络事件。由于网络事件是源源不断的,所以在主程序中启动Reactor模式,需要以 `while(true){}` 的方式调用handle_events()方法。
|
||||
|
||||
```
|
||||
void Reactor::handle_events(){
|
||||
//通过同步事件多路选择器提供的
|
||||
//select()方法监听网络事件
|
||||
select(handlers);
|
||||
//处理网络事件
|
||||
for(h in handlers){
|
||||
h.handle_event();
|
||||
}
|
||||
}
|
||||
// 在主程序中启动事件循环
|
||||
while (true) {
|
||||
handle_events();
|
||||
|
||||
```
|
||||
|
||||
## Netty中的线程模型
|
||||
|
||||
Netty的实现虽然参考了Reactor模式,但是并没有完全照搬,**Netty中最核心的概念是事件循环(EventLoop)**,其实也就是Reactor模式中的Reactor,**负责监听网络事件并调用事件处理器进行处理**。在4.x版本的Netty中,网络连接和EventLoop是稳定的多对1关系,而EventLoop和Java线程是1对1关系,这里的稳定指的是关系一旦确定就不再发生变化。也就是说一个网络连接只会对应唯一的一个EventLoop,而一个EventLoop也只会对应到一个Java线程,所以**一个网络连接只会对应到一个Java线程**。
|
||||
|
||||
一个网络连接对应到一个Java线程上,有什么好处呢?最大的好处就是对于一个网络连接的事件处理是单线程的,这样就**避免了各种并发问题**。
|
||||
|
||||
Netty中的线程模型可以参考下图,这个图和前面我们提到的理想的线程模型图非常相似,核心目标都是用一个线程处理多个网络连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/04/034756f1d76bb3af09e125de9f3c2f04.png" alt="">
|
||||
|
||||
Netty中还有一个核心概念是**EventLoopGroup**,顾名思义,一个EventLoopGroup由一组EventLoop组成。实际使用中,一般都会创建两个EventLoopGroup,一个称为bossGroup,一个称为workerGroup。为什么会有两个EventLoopGroup呢?
|
||||
|
||||
这个和socket处理网络请求的机制有关,socket处理TCP网络连接请求,是在一个独立的socket中,每当有一个TCP连接成功建立,都会创建一个新的socket,之后对TCP连接的读写都是由新创建处理的socket完成的。也就是说**处理TCP连接请求和读写请求是通过两个不同的socket完成的**。上面我们在讨论网络请求的时候,为了简化模型,只是讨论了读写请求,而没有讨论连接请求。
|
||||
|
||||
**在Netty中,bossGroup就用来处理连接请求的,而workerGroup是用来处理读写请求的**。bossGroup处理完连接请求后,会将这个连接提交给workerGroup来处理, workerGroup里面有多个EventLoop,那新的连接会交给哪个EventLoop来处理呢?这就需要一个负载均衡算法,Netty中目前使用的是**轮询算法**。
|
||||
|
||||
下面我们用Netty重新实现以下echo程序的服务端,近距离感受一下Netty。
|
||||
|
||||
## 用Netty实现Echo程序服务端
|
||||
|
||||
下面的示例代码基于Netty实现了echo程序服务端:首先创建了一个事件处理器(等同于Reactor模式中的事件处理器),然后创建了bossGroup和workerGroup,再之后创建并初始化了ServerBootstrap,代码还是很简单的,不过有两个地方需要注意一下。
|
||||
|
||||
第一个,如果NettybossGroup只监听一个端口,那bossGroup只需要1个EventLoop就可以了,多了纯属浪费。
|
||||
|
||||
第二个,默认情况下,Netty会创建“2*CPU核数”个EventLoop,由于网络连接与EventLoop有稳定的关系,所以事件处理器在处理网络事件的时候是不能有阻塞操作的,否则很容易导致请求大面积超时。如果实在无法避免使用阻塞操作,那可以通过线程池来异步处理。
|
||||
|
||||
```
|
||||
//事件处理器
|
||||
final EchoServerHandler serverHandler
|
||||
= new EchoServerHandler();
|
||||
//boss线程组
|
||||
EventLoopGroup bossGroup
|
||||
= new NioEventLoopGroup(1);
|
||||
//worker线程组
|
||||
EventLoopGroup workerGroup
|
||||
= new NioEventLoopGroup();
|
||||
try {
|
||||
ServerBootstrap b = new ServerBootstrap();
|
||||
b.group(bossGroup, workerGroup)
|
||||
.channel(NioServerSocketChannel.class)
|
||||
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
public void initChannel(SocketChannel ch){
|
||||
ch.pipeline().addLast(serverHandler);
|
||||
}
|
||||
});
|
||||
//bind服务端端口
|
||||
ChannelFuture f = b.bind(9090).sync();
|
||||
f.channel().closeFuture().sync();
|
||||
} finally {
|
||||
//终止工作线程组
|
||||
workerGroup.shutdownGracefully();
|
||||
//终止boss线程组
|
||||
bossGroup.shutdownGracefully();
|
||||
}
|
||||
|
||||
//socket连接处理器
|
||||
class EchoServerHandler extends
|
||||
ChannelInboundHandlerAdapter {
|
||||
//处理读事件
|
||||
@Override
|
||||
public void channelRead(
|
||||
ChannelHandlerContext ctx, Object msg){
|
||||
ctx.write(msg);
|
||||
}
|
||||
//处理读完成事件
|
||||
@Override
|
||||
public void channelReadComplete(
|
||||
ChannelHandlerContext ctx){
|
||||
ctx.flush();
|
||||
}
|
||||
//处理异常事件
|
||||
@Override
|
||||
public void exceptionCaught(
|
||||
ChannelHandlerContext ctx, Throwable cause) {
|
||||
cause.printStackTrace();
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Netty是一个款优秀的网络编程框架,性能非常好,为了实现高性能的目标,Netty做了很多优化,例如优化了ByteBuffer、支持零拷贝等等,和并发编程相关的就是它的线程模型了。Netty的线程模型设计得很精巧,每个网络连接都关联到了一个线程上,这样做的好处是:对于一个网络连接,读写操作都是单线程执行的,从而避免了并发程序的各种问题。
|
||||
|
||||
你要想深入理解Netty的线程模型,还需要对网络相关知识有一定的理解,关于Java IO的演进过程,你可以参考[Scalable IO in Java](http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf),至于TCP/IP网络编程的知识你可以参考韩国尹圣雨写的经典教程——《TCP/IP网络编程》。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
192
极客时间专栏/Java并发编程实战/第四部分:案例分析/40 | 案例分析(三):高性能队列Disruptor.md
Normal file
192
极客时间专栏/Java并发编程实战/第四部分:案例分析/40 | 案例分析(三):高性能队列Disruptor.md
Normal file
@@ -0,0 +1,192 @@
|
||||
<audio id="audio" title="40 | 案例分析(三):高性能队列Disruptor" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/b6/b7a92ee600edee299532363de07d36b6.mp3"></audio>
|
||||
|
||||
我们在[《20 | 并发容器:都有哪些“坑”需要我们填?》](https://time.geekbang.org/column/article/90201)介绍过Java SDK提供了2个有界队列:ArrayBlockingQueue 和 LinkedBlockingQueue,它们都是基于ReentrantLock实现的,在高并发场景下,锁的效率并不高,那有没有更好的替代品呢?有,今天我们就介绍一种性能更高的有界队列:Disruptor。
|
||||
|
||||
**Disruptor是一款高性能的有界内存队列**,目前应用非常广泛,Log4j2、Spring Messaging、HBase、Storm都用到了Disruptor,那Disruptor的性能为什么这么高呢?Disruptor项目团队曾经写过一篇论文,详细解释了其原因,可以总结为如下:
|
||||
|
||||
1. 内存分配更加合理,使用RingBuffer数据结构,数组元素在初始化时一次性全部创建,提升缓存命中率;对象循环利用,避免频繁GC。
|
||||
1. 能够避免伪共享,提升缓存利用率。
|
||||
1. 采用无锁算法,避免频繁加锁、解锁的性能消耗。
|
||||
1. 支持批量消费,消费者可以无锁方式消费多个消息。
|
||||
|
||||
其中,前三点涉及到的知识比较多,所以今天咱们重点讲解前三点,不过在详细介绍这些知识之前,我们先来聊聊Disruptor如何使用,好让你先对Disruptor有个感官的认识。
|
||||
|
||||
下面的代码出自官方示例,我略做了一些修改,相较而言,Disruptor的使用比Java SDK提供BlockingQueue要复杂一些,但是总体思路还是一致的,其大致情况如下:
|
||||
|
||||
- 在Disruptor中,生产者生产的对象(也就是消费者消费的对象)称为Event,使用Disruptor必须自定义Event,例如示例代码的自定义Event是LongEvent;
|
||||
- 构建Disruptor对象除了要指定队列大小外,还需要传入一个EventFactory,示例代码中传入的是`LongEvent::new`;
|
||||
- 消费Disruptor中的Event需要通过handleEventsWith()方法注册一个事件处理器,发布Event则需要通过publishEvent()方法。
|
||||
|
||||
```
|
||||
//自定义Event
|
||||
class LongEvent {
|
||||
private long value;
|
||||
public void set(long value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
//指定RingBuffer大小,
|
||||
//必须是2的N次方
|
||||
int bufferSize = 1024;
|
||||
|
||||
//构建Disruptor
|
||||
Disruptor<LongEvent> disruptor
|
||||
= new Disruptor<>(
|
||||
LongEvent::new,
|
||||
bufferSize,
|
||||
DaemonThreadFactory.INSTANCE);
|
||||
|
||||
//注册事件处理器
|
||||
disruptor.handleEventsWith(
|
||||
(event, sequence, endOfBatch) ->
|
||||
System.out.println("E: "+event));
|
||||
|
||||
//启动Disruptor
|
||||
disruptor.start();
|
||||
|
||||
//获取RingBuffer
|
||||
RingBuffer<LongEvent> ringBuffer
|
||||
= disruptor.getRingBuffer();
|
||||
//生产Event
|
||||
ByteBuffer bb = ByteBuffer.allocate(8);
|
||||
for (long l = 0; true; l++){
|
||||
bb.putLong(0, l);
|
||||
//生产者生产消息
|
||||
ringBuffer.publishEvent(
|
||||
(event, sequence, buffer) ->
|
||||
event.set(buffer.getLong(0)), bb);
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## RingBuffer如何提升性能
|
||||
|
||||
Java SDK中ArrayBlockingQueue使用**数组**作为底层的数据存储,而Disruptor是使用**RingBuffer**作为数据存储。RingBuffer本质上也是数组,所以仅仅将数据存储从数组换成RingBuffer并不能提升性能,但是Disruptor在RingBuffer的基础上还做了很多优化,其中一项优化就是和内存分配有关的。
|
||||
|
||||
在介绍这项优化之前,你需要先了解一下程序的局部性原理。简单来讲,**程序的局部性原理指的是在一段时间内程序的执行会限定在一个局部范围内**。这里的“局部性”可以从两个方面来理解,一个是时间局部性,另一个是空间局部性。**时间局部性**指的是程序中的某条指令一旦被执行,不久之后这条指令很可能再次被执行;如果某条数据被访问,不久之后这条数据很可能再次被访问。而**空间局部性**是指某块内存一旦被访问,不久之后这块内存附近的内存也很可能被访问。
|
||||
|
||||
CPU的缓存就利用了程序的局部性原理:CPU从内存中加载数据X时,会将数据X缓存在高速缓存Cache中,实际上CPU缓存X的同时,还缓存了X周围的数据,因为根据程序具备局部性原理,X周围的数据也很有可能被访问。从另外一个角度来看,如果程序能够很好地体现出局部性原理,也就能更好地利用CPU的缓存,从而提升程序的性能。Disruptor在设计RingBuffer的时候就充分考虑了这个问题,下面我们就对比着ArrayBlockingQueue来分析一下。
|
||||
|
||||
首先是ArrayBlockingQueue。生产者线程向ArrayBlockingQueue增加一个元素,每次增加元素E之前,都需要创建一个对象E,如下图所示,ArrayBlockingQueue内部有6个元素,这6个元素都是由生产者线程创建的,由于创建这些元素的时间基本上是离散的,所以这些元素的内存地址大概率也不是连续的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/90/848fd30644355ea86f3f91b06bfafa90.png" alt="">
|
||||
|
||||
下面我们再看看Disruptor是如何处理的。Disruptor内部的RingBuffer也是用数组实现的,但是这个数组中的所有元素在初始化时是一次性全部创建的,所以这些元素的内存地址大概率是连续的,相关的代码如下所示。
|
||||
|
||||
```
|
||||
for (int i=0; i<bufferSize; i++){
|
||||
//entries[]就是RingBuffer内部的数组
|
||||
//eventFactory就是前面示例代码中传入的LongEvent::new
|
||||
entries[BUFFER_PAD + i]
|
||||
= eventFactory.newInstance();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Disruptor内部RingBuffer的结构可以简化成下图,那问题来了,数组中所有元素内存地址连续能提升性能吗?能!为什么呢?因为消费者线程在消费的时候,是遵循空间局部性原理的,消费完第1个元素,很快就会消费第2个元素;当消费第1个元素E1的时候,CPU会把内存中E1后面的数据也加载进Cache,如果E1和E2在内存中的地址是连续的,那么E2也就会被加载进Cache中,然后当消费第2个元素的时候,由于E2已经在Cache中了,所以就不需要从内存中加载了,这样就能大大提升性能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/37/33bc0d35615f5d5f7869871e0cfed037.png" alt="">
|
||||
|
||||
除此之外,在Disruptor中,生产者线程通过publishEvent()发布Event的时候,并不是创建一个新的Event,而是通过event.set()方法修改Event, 也就是说RingBuffer创建的Event是可以循环利用的,这样还能避免频繁创建、删除Event导致的频繁GC问题。
|
||||
|
||||
## 如何避免“伪共享”
|
||||
|
||||
高效利用Cache,能够大大提升性能,所以要努力构建能够高效利用Cache的内存结构。而从另外一个角度看,努力避免不能高效利用Cache的内存结构也同样重要。
|
||||
|
||||
有一种叫做“伪共享(False sharing)”的内存布局就会使Cache失效,那什么是“伪共享”呢?
|
||||
|
||||
伪共享和CPU内部的Cache有关,Cache内部是按照缓存行(Cache Line)管理的,缓存行的大小通常是64个字节;CPU从内存中加载数据X,会同时加载X后面(64-size(X))个字节的数据。下面的示例代码出自Java SDK的ArrayBlockingQueue,其内部维护了4个成员变量,分别是队列数组items、出队索引takeIndex、入队索引putIndex以及队列中的元素总数count。
|
||||
|
||||
```
|
||||
/** 队列数组 */
|
||||
final Object[] items;
|
||||
/** 出队索引 */
|
||||
int takeIndex;
|
||||
/** 入队索引 */
|
||||
int putIndex;
|
||||
/** 队列中元素总数 */
|
||||
int count;
|
||||
|
||||
```
|
||||
|
||||
当CPU从内存中加载takeIndex的时候,会同时将putIndex以及count都加载进Cache。下图是某个时刻CPU中Cache的状况,为了简化,缓存行中我们仅列出了takeIndex和putIndex。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/5c/fdccf96bda79453e55ed75e418864b5c.png" alt="">
|
||||
|
||||
假设线程A运行在CPU-1上,执行入队操作,入队操作会修改putIndex,而修改putIndex会导致其所在的所有核上的缓存行均失效;此时假设运行在CPU-2上的线程执行出队操作,出队操作需要读取takeIndex,由于takeIndex所在的缓存行已经失效,所以CPU-2必须从内存中重新读取。入队操作本不会修改takeIndex,但是由于takeIndex和putIndex共享的是一个缓存行,就导致出队操作不能很好地利用Cache,这其实就是**伪共享**。简单来讲,**伪共享指的是由于共享缓存行导致缓存无效的场景**。
|
||||
|
||||
ArrayBlockingQueue的入队和出队操作是用锁来保证互斥的,所以入队和出队不会同时发生。如果允许入队和出队同时发生,那就会导致线程A和线程B争用同一个缓存行,这样也会导致性能问题。所以为了更好地利用缓存,我们必须避免伪共享,那如何避免呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/27/d5d5afc11fe6b1aaf8c9be7dba643827.png" alt="">
|
||||
|
||||
方案很简单,**每个变量独占一个缓存行、不共享缓存行**就可以了,具体技术是**缓存行填充**。比如想让takeIndex独占一个缓存行,可以在takeIndex的前后各填充56个字节,这样就一定能保证takeIndex独占一个缓存行。下面的示例代码出自Disruptor,Sequence 对象中的value属性就能避免伪共享,因为这个属性前后都填充了56个字节。Disruptor中很多对象,例如RingBuffer、RingBuffer内部的数组都用到了这种填充技术来避免伪共享。
|
||||
|
||||
```
|
||||
//前:填充56字节
|
||||
class LhsPadding{
|
||||
long p1, p2, p3, p4, p5, p6, p7;
|
||||
}
|
||||
class Value extends LhsPadding{
|
||||
volatile long value;
|
||||
}
|
||||
//后:填充56字节
|
||||
class RhsPadding extends Value{
|
||||
long p9, p10, p11, p12, p13, p14, p15;
|
||||
}
|
||||
class Sequence extends RhsPadding{
|
||||
//省略实现
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Disruptor中的无锁算法
|
||||
|
||||
ArrayBlockingQueue是利用管程实现的,中规中矩,生产、消费操作都需要加锁,实现起来简单,但是性能并不十分理想。Disruptor采用的是无锁算法,很复杂,但是核心无非是生产和消费两个操作。Disruptor中最复杂的是入队操作,所以我们重点来看看入队操作是如何实现的。
|
||||
|
||||
对于入队操作,最关键的要求是不能覆盖没有消费的元素;对于出队操作,最关键的要求是不能读取没有写入的元素,所以Disruptor中也一定会维护类似出队索引和入队索引这样两个关键变量。Disruptor中的RingBuffer维护了入队索引,但是并没有维护出队索引,这是因为在Disruptor中多个消费者可以同时消费,每个消费者都会有一个出队索引,所以RingBuffer的出队索引是所有消费者里面最小的那一个。
|
||||
|
||||
下面是Disruptor生产者入队操作的核心代码,看上去很复杂,其实逻辑很简单:如果没有足够的空余位置,就出让CPU使用权,然后重新计算;反之则用CAS设置入队索引。
|
||||
|
||||
```
|
||||
//生产者获取n个写入位置
|
||||
do {
|
||||
//cursor类似于入队索引,指的是上次生产到这里
|
||||
current = cursor.get();
|
||||
//目标是在生产n个
|
||||
next = current + n;
|
||||
//减掉一个循环
|
||||
long wrapPoint = next - bufferSize;
|
||||
//获取上一次的最小消费位置
|
||||
long cachedGatingSequence = gatingSequenceCache.get();
|
||||
//没有足够的空余位置
|
||||
if (wrapPoint>cachedGatingSequence || cachedGatingSequence>current){
|
||||
//重新计算所有消费者里面的最小值位置
|
||||
long gatingSequence = Util.getMinimumSequence(
|
||||
gatingSequences, current);
|
||||
//仍然没有足够的空余位置,出让CPU使用权,重新执行下一循环
|
||||
if (wrapPoint > gatingSequence){
|
||||
LockSupport.parkNanos(1);
|
||||
continue;
|
||||
}
|
||||
//从新设置上一次的最小消费位置
|
||||
gatingSequenceCache.set(gatingSequence);
|
||||
} else if (cursor.compareAndSet(current, next)){
|
||||
//获取写入位置成功,跳出循环
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Disruptor在优化并发性能方面可谓是做到了极致,优化的思路大体是两个方面,一个是利用无锁算法避免锁的争用,另外一个则是将硬件(CPU)的性能发挥到极致。尤其是后者,在Java领域基本上属于经典之作了。
|
||||
|
||||
发挥硬件的能力一般是C这种面向硬件的语言常干的事儿,C语言领域经常通过调整内存布局优化内存占用,而Java领域则用的很少,原因在于Java可以智能地优化内存布局,内存布局对Java程序员的透明的。这种智能的优化大部分场景是很友好的,但是如果你想通过填充方式避免伪共享就必须绕过这种优化,关于这方面Disruptor提供了经典的实现,你可以参考。
|
||||
|
||||
由于伪共享问题如此重要,所以Java也开始重视它了,比如Java 8中,提供了避免伪共享的注解:@sun.misc.Contended,通过这个注解就能轻松避免伪共享(需要设置JVM参数-XX:-RestrictContended)。不过避免伪共享是以牺牲内存为代价的,所以具体使用的时候还是需要仔细斟酌。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
227
极客时间专栏/Java并发编程实战/第四部分:案例分析/41 | 案例分析(四):高性能数据库连接池HiKariCP.md
Normal file
227
极客时间专栏/Java并发编程实战/第四部分:案例分析/41 | 案例分析(四):高性能数据库连接池HiKariCP.md
Normal file
@@ -0,0 +1,227 @@
|
||||
<audio id="audio" title="41 | 案例分析(四):高性能数据库连接池HiKariCP" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4a/2b/4aa22f1ad88a545fba12e6f69a2cbc2b.mp3"></audio>
|
||||
|
||||
实际工作中,我们总会难免和数据库打交道;只要和数据库打交道,就免不了使用数据库连接池。业界知名的数据库连接池有不少,例如c3p0、DBCP、Tomcat JDBC Connection Pool、Druid等,不过最近最火的是HiKariCP。
|
||||
|
||||
**HiKariCP号称是业界跑得最快的数据库连接池**,这两年发展得顺风顺水,尤其是Springboot 2.0将其作为**默认数据库连接池**后,江湖一哥的地位已是毋庸置疑了。那它为什么那么快呢?今天咱们就重点聊聊这个话题。
|
||||
|
||||
## 什么是数据库连接池
|
||||
|
||||
在详细分析HiKariCP高性能之前,我们有必要先简单介绍一下什么是数据库连接池。本质上,数据库连接池和线程池一样,都属于池化资源,作用都是避免重量级资源的频繁创建和销毁,对于数据库连接池来说,也就是避免数据库连接频繁创建和销毁。如下图所示,服务端会在运行期持有一定数量的数据库连接,当需要执行SQL时,并不是直接创建一个数据库连接,而是从连接池中获取一个;当SQL执行完,也并不是将数据库连接真的关掉,而是将其归还到连接池中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/19/0b106876824e43d11750334e86556519.png" alt="">
|
||||
|
||||
在实际工作中,我们都是使用各种持久化框架来完成数据库的增删改查,基本上不会直接和数据库连接池打交道,为了能让你更好地理解数据库连接池的工作原理,下面的示例代码并没有使用任何框架,而是原生地使用HiKariCP。执行数据库操作基本上是一系列规范化的步骤:
|
||||
|
||||
1. 通过数据源获取一个数据库连接;
|
||||
1. 创建Statement;
|
||||
1. 执行SQL;
|
||||
1. 通过ResultSet获取SQL执行结果;
|
||||
1. 释放ResultSet;
|
||||
1. 释放Statement;
|
||||
1. 释放数据库连接。
|
||||
|
||||
下面的示例代码,通过 `ds.getConnection()` 获取一个数据库连接时,其实是向数据库连接池申请一个数据库连接,而不是创建一个新的数据库连接。同样,通过 `conn.close()` 释放一个数据库连接时,也不是直接将连接关闭,而是将连接归还给数据库连接池。
|
||||
|
||||
```
|
||||
//数据库连接池配置
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setMinimumIdle(1);
|
||||
config.setMaximumPoolSize(2);
|
||||
config.setConnectionTestQuery("SELECT 1");
|
||||
config.setDataSourceClassName("org.h2.jdbcx.JdbcDataSource");
|
||||
config.addDataSourceProperty("url", "jdbc:h2:mem:test");
|
||||
// 创建数据源
|
||||
DataSource ds = new HikariDataSource(config);
|
||||
Connection conn = null;
|
||||
Statement stmt = null;
|
||||
ResultSet rs = null;
|
||||
try {
|
||||
// 获取数据库连接
|
||||
conn = ds.getConnection();
|
||||
// 创建Statement
|
||||
stmt = conn.createStatement();
|
||||
// 执行SQL
|
||||
rs = stmt.executeQuery("select * from abc");
|
||||
// 获取结果
|
||||
while (rs.next()) {
|
||||
int id = rs.getInt(1);
|
||||
......
|
||||
}
|
||||
} catch(Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
//关闭ResultSet
|
||||
close(rs);
|
||||
//关闭Statement
|
||||
close(stmt);
|
||||
//关闭Connection
|
||||
close(conn);
|
||||
}
|
||||
//关闭资源
|
||||
void close(AutoCloseable rs) {
|
||||
if (rs != null) {
|
||||
try {
|
||||
rs.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
[HiKariCP官方网站](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)解释了其性能之所以如此之高的秘密。微观上HiKariCP程序编译出的字节码执行效率更高,站在字节码的角度去优化Java代码,HiKariCP的作者对性能的执着可见一斑,不过遗憾的是他并没有详细解释都做了哪些优化。而宏观上主要是和两个数据结构有关,一个是FastList,另一个是ConcurrentBag。下面我们来看看它们是如何提升HiKariCP的性能的。
|
||||
|
||||
## FastList解决了哪些性能问题
|
||||
|
||||
按照规范步骤,执行完数据库操作之后,需要依次关闭ResultSet、Statement、Connection,但是总有粗心的同学只是关闭了Connection,而忘了关闭ResultSet和Statement。为了解决这种问题,最好的办法是当关闭Connection时,能够自动关闭Statement。为了达到这个目标,Connection就需要跟踪创建的Statement,最简单的办法就是将创建的Statement保存在数组ArrayList里,这样当关闭Connection的时候,就可以依次将数组中的所有Statement关闭。
|
||||
|
||||
HiKariCP觉得用ArrayList还是太慢,当通过 `conn.createStatement()` 创建一个Statement时,需要调用ArrayList的add()方法加入到ArrayList中,这个是没有问题的;但是当通过 `stmt.close()` 关闭Statement的时候,需要调用 ArrayList的remove()方法来将其从ArrayList中删除,这里是有优化余地的。
|
||||
|
||||
假设一个Connection依次创建6个Statement,分别是S1、S2、S3、S4、S5、S6,按照正常的编码习惯,关闭Statement的顺序一般是逆序的,关闭的顺序是:S6、S5、S4、S3、S2、S1,而ArrayList的remove(Object o)方法是顺序遍历查找,逆序删除而顺序查找,这样的查找效率就太慢了。如何优化呢?很简单,优化成逆序查找就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/a6/4b5e2ef70e46b087b139b331578a82a6.png" alt="">
|
||||
|
||||
HiKariCP中的FastList相对于ArrayList的一个优化点就是将 `remove(Object element)` 方法的**查找顺序变成了逆序查找**。除此之外,FastList还有另一个优化点,是 `get(int index)` 方法没有对index参数进行越界检查,HiKariCP能保证不会越界,所以不用每次都进行越界检查。
|
||||
|
||||
整体来看,FastList的优化点还是很简单的。下面我们再来聊聊HiKariCP中的另外一个数据结构ConcurrentBag,看看它又是如何提升性能的。
|
||||
|
||||
## ConcurrentBag解决了哪些性能问题
|
||||
|
||||
如果让我们自己来实现一个数据库连接池,最简单的办法就是用两个阻塞队列来实现,一个用于保存空闲数据库连接的队列idle,另一个用于保存忙碌数据库连接的队列busy;获取连接时将空闲的数据库连接从idle队列移动到busy队列,而关闭连接时将数据库连接从busy移动到idle。这种方案将并发问题委托给了阻塞队列,实现简单,但是性能并不是很理想。因为Java SDK中的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。
|
||||
|
||||
```
|
||||
//忙碌队列
|
||||
BlockingQueue<Connection> busy;
|
||||
//空闲队列
|
||||
BlockingQueue<Connection> idle;
|
||||
|
||||
```
|
||||
|
||||
HiKariCP并没有使用Java SDK中的阻塞队列,而是自己实现了一个叫做ConcurrentBag的并发容器。ConcurrentBag的设计最初源自C#,它的一个核心设计是使用ThreadLocal避免部分并发问题,不过HiKariCP中的ConcurrentBag并没有完全参考C#的实现,下面我们来看看它是如何实现的。
|
||||
|
||||
ConcurrentBag中最关键的属性有4个,分别是:用于存储所有的数据库连接的共享队列sharedList、线程本地存储threadList、等待数据库连接的线程数waiters以及分配数据库连接的工具handoffQueue。其中,handoffQueue用的是Java SDK提供的SynchronousQueue,SynchronousQueue主要用于线程之间传递数据。
|
||||
|
||||
```
|
||||
//用于存储所有的数据库连接
|
||||
CopyOnWriteArrayList<T> sharedList;
|
||||
//线程本地存储中的数据库连接
|
||||
ThreadLocal<List<Object>> threadList;
|
||||
//等待数据库连接的线程数
|
||||
AtomicInteger waiters;
|
||||
//分配数据库连接的工具
|
||||
SynchronousQueue<T> handoffQueue;
|
||||
|
||||
```
|
||||
|
||||
当线程池创建了一个数据库连接时,通过调用ConcurrentBag的add()方法加入到ConcurrentBag中,下面是add()方法的具体实现,逻辑很简单,就是将这个连接加入到共享队列sharedList中,如果此时有线程在等待数据库连接,那么就通过handoffQueue将这个连接分配给等待的线程。
|
||||
|
||||
```
|
||||
//将空闲连接添加到队列
|
||||
void add(final T bagEntry){
|
||||
//加入共享队列
|
||||
sharedList.add(bagEntry);
|
||||
//如果有等待连接的线程,
|
||||
//则通过handoffQueue直接分配给等待的线程
|
||||
while (waiters.get() > 0
|
||||
&& bagEntry.getState() == STATE_NOT_IN_USE
|
||||
&& !handoffQueue.offer(bagEntry)) {
|
||||
yield();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过ConcurrentBag提供的borrow()方法,可以获取一个空闲的数据库连接,borrow()的主要逻辑是:
|
||||
|
||||
1. 首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;
|
||||
1. 如果线程本地存储中无空闲连接,则从共享队列中获取。
|
||||
1. 如果共享队列中也没有空闲的连接,则请求线程需要等待。
|
||||
|
||||
需要注意的是,线程本地存储中的连接是可以被其他线程窃取的,所以需要用CAS方法防止重复分配。在共享队列中获取空闲连接,也采用了CAS方法防止重复分配。
|
||||
|
||||
```
|
||||
T borrow(long timeout, final TimeUnit timeUnit){
|
||||
// 先查看线程本地存储是否有空闲连接
|
||||
final List<Object> list = threadList.get();
|
||||
for (int i = list.size() - 1; i >= 0; i--) {
|
||||
final Object entry = list.remove(i);
|
||||
final T bagEntry = weakThreadLocals
|
||||
? ((WeakReference<T>) entry).get()
|
||||
: (T) entry;
|
||||
//线程本地存储中的连接也可以被窃取,
|
||||
//所以需要用CAS方法防止重复分配
|
||||
if (bagEntry != null
|
||||
&& bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
|
||||
return bagEntry;
|
||||
}
|
||||
}
|
||||
|
||||
// 线程本地存储中无空闲连接,则从共享队列中获取
|
||||
final int waiting = waiters.incrementAndGet();
|
||||
try {
|
||||
for (T bagEntry : sharedList) {
|
||||
//如果共享队列中有空闲连接,则返回
|
||||
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
|
||||
return bagEntry;
|
||||
}
|
||||
}
|
||||
//共享队列中没有连接,则需要等待
|
||||
timeout = timeUnit.toNanos(timeout);
|
||||
do {
|
||||
final long start = currentTime();
|
||||
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
|
||||
if (bagEntry == null
|
||||
|| bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
|
||||
return bagEntry;
|
||||
}
|
||||
//重新计算等待时间
|
||||
timeout -= elapsedNanos(start);
|
||||
} while (timeout > 10_000);
|
||||
//超时没有获取到连接,返回null
|
||||
return null;
|
||||
} finally {
|
||||
waiters.decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
释放连接需要调用ConcurrentBag提供的requite()方法,该方法的逻辑很简单,首先将数据库连接状态更改为STATE_NOT_IN_USE,之后查看是否存在等待线程,如果有,则分配给等待线程;如果没有,则将该数据库连接保存到线程本地存储里。
|
||||
|
||||
```
|
||||
//释放连接
|
||||
void requite(final T bagEntry){
|
||||
//更新连接状态
|
||||
bagEntry.setState(STATE_NOT_IN_USE);
|
||||
//如果有等待的线程,则直接分配给线程,无需进入任何队列
|
||||
for (int i = 0; waiters.get() > 0; i++) {
|
||||
if (bagEntry.getState() != STATE_NOT_IN_USE
|
||||
|| handoffQueue.offer(bagEntry)) {
|
||||
return;
|
||||
} else if ((i & 0xff) == 0xff) {
|
||||
parkNanos(MICROSECONDS.toNanos(10));
|
||||
} else {
|
||||
yield();
|
||||
}
|
||||
}
|
||||
//如果没有等待的线程,则进入线程本地存储
|
||||
final List<Object> threadLocalList = threadList.get();
|
||||
if (threadLocalList.size() < 50) {
|
||||
threadLocalList.add(weakThreadLocals
|
||||
? new WeakReference<>(bagEntry)
|
||||
: bagEntry);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
HiKariCP中的FastList和ConcurrentBag这两个数据结构使用得非常巧妙,虽然实现起来并不复杂,但是对于性能的提升非常明显,根本原因在于这两个数据结构适用于数据库连接池这个特定的场景。FastList适用于逆序删除场景;而ConcurrentBag通过ThreadLocal做一次预分配,避免直接竞争共享资源,非常适合池化资源的分配。
|
||||
|
||||
在实际工作中,我们遇到的并发问题千差万别,这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解,只有了解到无谓的性能消耗在哪里,才能对症下药。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user