../down-the-rabbit-hole
兔子洞下-高性能揭秘(Down the Rabbit Hole)
Published:
译者注: Brett Wooldridge edited this page on Apr 11, 2018 · 48 revisions
基于 deepL 的翻译,做了微调。
这就是我们透露秘方的地方。当你带着像我们这样的基准来的时候,有一定量的怀疑主义必须被解决。如果你想到性能和连接池,你可能会被诱惑,认为连接池是性能方程中最重要的部分。其实不然。与其他JDBC操作相比,getConnection()
操作的数量很少。大量的性能提升来自于对包装 Connection
、Statement
等的 "delegates" 的优化。
译者注:基准测试参阅项目基准测试说明。
🧠 We're in your bytecodez
为了使 HikariCP
变得如此之快,我们进行了字节码级别的工程,甚至更多。我们拿出了我们所知道的所有技巧来帮助 JIT
,帮助你。我们研究了编译器的字节码输出,甚至是 JIT 的汇编输出,以限制关键的例程小于 JIT 的 inline-threshold
。我们扁平化了继承层次,隐藏了成员变量,消除了转换。
译者注: inline-threadhold 是多少?
🔬 Micro-optimizations
HikariCP 包含了许多微观的优化,这些优化单独来看几乎无法衡量,但结合起来就能提升整体性能。其中一些优化是以以零点几毫秒的时间来衡量,并在数百万次的调用中摊销。
ArrayList
一个微不足道的(性能上的)优化是在 ConnectionProxy
中取消使用 ArrayList<Statement>
实例,用于跟踪打开的 Statement
实例。当一个 Statement
被关闭时,它必须从这个集合中删除,当 Connection
被关闭时,它必须遍历这个集合并关闭任何打开的 Statement
实例,最后必须清除这个集合。Java 的 ArrayList
在每次调用 `get(int index)' 时都会进行范围检查,这对于一般用途来说是明智的。然而,因为我们可以为我们的范围提供保证,这个检查只是开销。
另外,remove(Object)
的实现从头到尾进行扫描,然而 JDBC 编程中常见的模式是在使用后立即关闭语句,或者按照打开的相反顺序。对于这些情况,从尾部开始的扫描将表现得更好。因此,ArrayList<Statement>
被一个自定义类 FastList
所取代,该类消除了范围检查,从尾部到头部执行移除扫描。
ConcurrentBag
HikariCP 包含一个自定义的无锁集合,称为 ConcurrentBag。这个想法借鉴了 C# .NET 的 ConcurrentBag 类,但内部实现完全不同。ConcurrentBag 提供了...
- 一个无锁的设计
- 线程本地缓存
- 窃取队列
- 直接交接的优化
...导致高度的并发性,极低的延迟,以及最小化的 false sharing 的发生。
调用: invokevirtual
vs invokestatic
为了生成 Connection
、Statement
和 ResultSet
实例的代理,HikariCP 最初使用了一个单例工厂,在 ConnectionProxy
的情况下,被保存在一个静态字段(PROXY_FACTORY)。
在代码库中大概像这样。
public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
return PROXY_FACTORY.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}
使用原始的单例工厂,生成的字节码看起来像这样。
public final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
stack=5, locals=3, args_size=3
0: getstatic #59 // Field PROXY_FACTORY:Lcom/zaxxer/hikari/proxy/ProxyFactory;
3: aload_0
4: aload_0
5: getfield #3 // Field delegate:Ljava/sql/Connection;
8: aload_1
9: aload_2
10: invokeinterface #74, 3 // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
15: invokevirtual #69 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
18: return
你可以看到,首先有一个 getstatic
调用,以获得静态字段 PROXY_FACTORY
的值,以及(最后)对 ProxyFactory
实例的 getProxyPreparedStatement()
的 invokevirtual
调用。
我们取消了单例工厂(由 Javassist 生成),取而代之的是一个具有 static
方法的final
类(其主体由 Javassist 生成)。Java 代码变成了。
public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
return ProxyFactory.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}
其中 getProxyPreparedStatement()
是 ProxyFactory
类中定义的一个 static
方法。由此产生的字节码是:
private final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
stack=4, locals=3, args_size=3
0: aload_0
1: aload_0
2: getfield #3 // Field delegate:Ljava/sql/Connection;
5: aload_1
6: aload_2
7: invokeinterface #72, 3 // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
12: invokestatic #67 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
15: areturn
这里有三件事值得注意。
getstatic
调用没有了。invokevirtual
调用被替换为invokestatic
调用,更容易被 JVM 优化。- 最后,第一眼可能没有注意到的是,堆栈大小从 5 个元素减少到 4 个元素。这是因为在
invokevirtual
的情况下,ProxyFactory 的实例被隐含地传递到堆栈中(即this
),当getProxyPreparedStatement()
被调用时,还有一个额外的(看不见的)从堆栈中取出的值。 总的来说,这个变化消除了一个静态字段访问,从堆栈中推送和弹出,并使调用更容易被 JIT 优化,因为调用点被保证不会改变。
¯\_(ツ)_/¯
Yeah, but still...
在我们的基准中,我们显然是针对一个 stub JDBC
驱动实现运行的,所以 JIT 做了大量的内联。然而,同样的内联在存根级发生在基准的其他池上。因此,对我们来说没有固有的优势。
但是,即使是在使用真正的驱动程序时,内联也肯定是一个重要的部分,这就把我们带到了另一个话题......
Scheduler quanta
Some light reading.
TL;DR 当你同时运行 400 个线程时,你并没有真正同时运行它们,除非你有 400 个内核。操作系统使用 N 个 CPU 核心,在你的线程之间进行切换,给每个线程一小块 "片" 的时间来运行,称为 quantum
。
在很多应用程序中,有很多线程在运行,当你的时间片用完时(作为一个线程),在调度器给你一个机会再次运行之前,可能会有 "很长一段时间"。因此,至关重要的是,一个线程要在其时间片内尽可能多地完成工作,并避免锁迫使它放弃该时间片,否则就要付出性能上的代价。而且不是一个小数目。
这给我们带来了...
🐌 CPU 缓存行失效
当你不能在一个 quanta
内完成工作时,另一个很大的打击是 CPU 缓存行的失效。如果你的线程被调度抢占了,当它有机会再次运行时,它经常访问的所有数据很可能已经不在核心的 L1 或核心对 L2 缓存中了。更有可能的是,你无法控制下一个被调度到哪个核上。
备注
本节内容为补充内容,非原文翻译。
HikariCP wiki 里还有两篇内容值得一看。
-
My benchmark doesn't show a difference
内容主要说明其他连接池在默认情况下是性能优先与可靠性,在配置为可靠的情况下,性能大幅度下降,而 HikariCP 是在可靠性优先的前提下取得了高性能。 -
About Pool Sizing
如何合理的配置线程池,线程池越大越好吗?