This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -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&lt;20; i++){
//限流器限流
limiter.acquire();
//提交任务异步执行
es.execute(()-&gt;{
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 &gt; 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 &gt; 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 &gt; 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 &gt; 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构建了一个积分函数来解决这个问题如果你感兴趣可以继续深入研究。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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/OBIO。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里还提供了非阻塞式NIOAPI**利用非阻塞式API就能够实现一个线程处理多个连接了**。那具体如何实现呢?现在普遍都是**采用Reactor模式**包括Netty的实现。所以要想理解Netty的实现接下来我们就需要先了解一下Reactor模式。
## Reactor模式
下面是Reactor模式的类结构图其中Handle指的是I/O句柄在Java网络编程里它本质上就是一个网络连接。Event Handler很容易理解就是一个事件处理器其中handle_event()方法处理I/O事件也就是每个Event Handler处理一个I/O Handleget_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&lt;SocketChannel&gt;() {
@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网络编程》。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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&lt;LongEvent&gt; disruptor
= new Disruptor&lt;&gt;(
LongEvent::new,
bufferSize,
DaemonThreadFactory.INSTANCE);
//注册事件处理器
disruptor.handleEventsWith(
(event, sequence, endOfBatch) -&gt;
System.out.println(&quot;E: &quot;+event));
//启动Disruptor
disruptor.start();
//获取RingBuffer
RingBuffer&lt;LongEvent&gt; ringBuffer
= disruptor.getRingBuffer();
//生产Event
ByteBuffer bb = ByteBuffer.allocate(8);
for (long l = 0; true; l++){
bb.putLong(0, l);
//生产者生产消息
ringBuffer.publishEvent(
(event, sequence, buffer) -&gt;
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&lt;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独占一个缓存行。下面的示例代码出自DisruptorSequence 对象中的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&gt;cachedGatingSequence || cachedGatingSequence&gt;current){
//重新计算所有消费者里面的最小值位置
long gatingSequence = Util.getMinimumSequence(
gatingSequences, current);
//仍然没有足够的空余位置出让CPU使用权重新执行下一循环
if (wrapPoint &gt; 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。不过避免伪共享是以牺牲内存为代价的所以具体使用的时候还是需要仔细斟酌。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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(&quot;SELECT 1&quot;);
config.setDataSourceClassName(&quot;org.h2.jdbcx.JdbcDataSource&quot;);
config.addDataSourceProperty(&quot;url&quot;, &quot;jdbc:h2:mem:test&quot;);
// 创建数据源
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(&quot;select * from abc&quot;);
// 获取结果
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&lt;Connection&gt; busy;
//空闲队列
BlockingQueue&lt;Connection&gt; idle;
```
HiKariCP并没有使用Java SDK中的阻塞队列而是自己实现了一个叫做ConcurrentBag的并发容器。ConcurrentBag的设计最初源自C#它的一个核心设计是使用ThreadLocal避免部分并发问题不过HiKariCP中的ConcurrentBag并没有完全参考C#的实现,下面我们来看看它是如何实现的。
ConcurrentBag中最关键的属性有4个分别是用于存储所有的数据库连接的共享队列sharedList、线程本地存储threadList、等待数据库连接的线程数waiters以及分配数据库连接的工具handoffQueue。其中handoffQueue用的是Java SDK提供的SynchronousQueueSynchronousQueue主要用于线程之间传递数据。
```
//用于存储所有的数据库连接
CopyOnWriteArrayList&lt;T&gt; sharedList;
//线程本地存储中的数据库连接
ThreadLocal&lt;List&lt;Object&gt;&gt; threadList;
//等待数据库连接的线程数
AtomicInteger waiters;
//分配数据库连接的工具
SynchronousQueue&lt;T&gt; handoffQueue;
```
当线程池创建了一个数据库连接时通过调用ConcurrentBag的add()方法加入到ConcurrentBag中下面是add()方法的具体实现逻辑很简单就是将这个连接加入到共享队列sharedList中如果此时有线程在等待数据库连接那么就通过handoffQueue将这个连接分配给等待的线程。
```
//将空闲连接添加到队列
void add(final T bagEntry){
//加入共享队列
sharedList.add(bagEntry);
//如果有等待连接的线程,
//则通过handoffQueue直接分配给等待的线程
while (waiters.get() &gt; 0
&amp;&amp; bagEntry.getState() == STATE_NOT_IN_USE
&amp;&amp; !handoffQueue.offer(bagEntry)) {
yield();
}
}
```
通过ConcurrentBag提供的borrow()方法可以获取一个空闲的数据库连接borrow()的主要逻辑是:
1. 首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;
1. 如果线程本地存储中无空闲连接,则从共享队列中获取。
1. 如果共享队列中也没有空闲的连接,则请求线程需要等待。
需要注意的是线程本地存储中的连接是可以被其他线程窃取的所以需要用CAS方法防止重复分配。在共享队列中获取空闲连接也采用了CAS方法防止重复分配。
```
T borrow(long timeout, final TimeUnit timeUnit){
// 先查看线程本地存储是否有空闲连接
final List&lt;Object&gt; list = threadList.get();
for (int i = list.size() - 1; i &gt;= 0; i--) {
final Object entry = list.remove(i);
final T bagEntry = weakThreadLocals
? ((WeakReference&lt;T&gt;) entry).get()
: (T) entry;
//线程本地存储中的连接也可以被窃取,
//所以需要用CAS方法防止重复分配
if (bagEntry != null
&amp;&amp; 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 &gt; 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() &gt; 0; i++) {
if (bagEntry.getState() != STATE_NOT_IN_USE
|| handoffQueue.offer(bagEntry)) {
return;
} else if ((i &amp; 0xff) == 0xff) {
parkNanos(MICROSECONDS.toNanos(10));
} else {
yield();
}
}
//如果没有等待的线程,则进入线程本地存储
final List&lt;Object&gt; threadLocalList = threadList.get();
if (threadLocalList.size() &lt; 50) {
threadLocalList.add(weakThreadLocals
? new WeakReference&lt;&gt;(bagEntry)
: bagEntry);
}
}
```
## 总结
HiKariCP中的FastList和ConcurrentBag这两个数据结构使用得非常巧妙虽然实现起来并不复杂但是对于性能的提升非常明显根本原因在于这两个数据结构适用于数据库连接池这个特定的场景。FastList适用于逆序删除场景而ConcurrentBag通过ThreadLocal做一次预分配避免直接竞争共享资源非常适合池化资源的分配。
在实际工作中,我们遇到的并发问题千差万别,这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解,只有了解到无谓的性能消耗在哪里,才能对症下药。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。