../down-the-rabbit-hole

兔子洞下-高性能揭秘(Down the Rabbit Hole)

Published:

HikariCP

原文链接-Down-the-Rabbit-Hole

译者注: Brett Wooldridge edited this page on Apr 11, 2018 · 48 revisions
基于 deepL 的翻译,做了微调。


这就是我们透露秘方的地方。当你带着像我们这样的基准来的时候,有一定量的怀疑主义必须被解决。如果你想到性能和连接池,你可能会被诱惑,认为连接池是性能方程中最重要的部分。其实不然。与其他JDBC操作相比,getConnection() 操作的数量很少。大量的性能提升来自于对包装 ConnectionStatement 等的 "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

为了生成 ConnectionStatementResultSet 实例的代理,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

这里有三件事值得注意。


¯\_(ツ)_/¯ 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 里还有两篇内容值得一看。

相关链接