我们选择Java作为高频交易应用程序

2020-10-26 23:54:44

在高频交易领域,自动化应用程序每天处理数亿个市场信号,并在全球不同的交易所发回数千个订单。

为了保持竞争力,反应时间必须始终如一地保持在微秒级,特别是在“黑天鹅”等不寻常的高峰期。

在典型的体系结构中,金融交易信号将被转换成单一的内部市场数据格式(交易使用各种协议,例如TCP/IP、UDP多播和多种格式,例如二进制、SBE、JSON、FIX等)。

然后,这些标准的ISED消息被发送到算法服务器、统计引擎、用户界面、日志服务器和所有类型的数据库(内存中的、物理的、分布式的)。

这条道路上的任何延迟都可能产生代价高昂的后果,比如根据旧价格做出决策的策略,或者订单到达市场太晚。

为了获得这几个关键的微秒,大多数玩家投资于昂贵的硬件:配备超频液体冷却CPU的服务器池(2020年你可以购买56个内核、5.6 GHz和1TB RAM的服务器)、主要交换数据中心的配置、高端纳秒网络交换机、专用海底线路(Hiberian Express是主要供应商),甚至是微波网络。

常见的情况是,高度定制的Linux内核绕过了操作系统,数据直接从网卡“跳”到应用程序、进程间通信(IPC),甚至FPGA(可编程的单一用途芯片)。

至于编程语言,C++似乎是服务器端应用程序的天然竞争者:它速度很快,尽可能接近机器代码,并且一旦为目标平台编译,就会提供恒定的处理时间。

在过去的14年里,我们一直在用Java编写的FX算法交易空间编码和使用伟大但负担得起的硬件方面展开竞争。

由于团队规模小,资源有限,而且就业市场缺乏熟练的开发人员,Java意味着我们可以快速添加软件改进,因为Java生态系统比C衍生品的上市时间更快。改进可以在上午讨论,下午在生产中实施、测试和发布。

与大公司进行最轻微的软件更新需要几周甚至几个月的时间相比,这是一个关键的优势。在一个一个漏洞可以在几秒钟内抹去一整年利润的领域,我们还没有准备好在质量上妥协。我们使用许多开源库和项目实现了严格的敏捷环境,包括Jenkins、Maven、单元测试、夜间构建和Jira。

使用Java,开发人员可以专注于直观的面向对象的业务逻辑,而不是像在C++中那样调试一些模糊的内存核心转储或管理指针。而且,由于Java强大的内部内存管理,初级程序员也可以在第一天增加价值,风险有限。

有了良好的设计模式和干净的编码习惯,使用Java就有可能达到C++延迟。

例如,Java将优化和编译在应用程序运行期间观察到的最佳路径,但C++会预先编译所有内容,因此即使是未使用的方法也仍将是最终可执行二进制文件的一部分。

然而,有一个问题,也是一个需要引导的主要问题。Java之所以成为如此强大和令人愉快的语言,也是因为它的衰落(至少对于微秒敏感的应用程序而言),即Java虚拟机(JVM):

Java按原样编译代码(Just in Time编译器或JIT),这意味着当它第一次遇到某些代码时,会导致编译延迟。

Java管理内存的方式是在其“堆”空间中分配内存块。每隔一段时间,它就会清理那个空间,移走旧的东西,为新的腾出空间。主要问题是,为了进行准确的计数,应用程序线程需要暂时“冻结”。此过程称为垃圾收集(GC)。

GC是低延迟应用程序开发人员可能先验地抛弃Java的主要原因。

最常见和最标准的是Oracle HotSpot JVM,它在Java社区中广泛使用,主要是由于历史原因。

Zing是标准Oracle HotSpot JVM的强大替代品。Zing同时解决了GC暂停和JIT编译问题。

让我们研究一下使用Java所固有的一些问题和可能的解决方案。

像C++这样的语言被称为编译语言,因为交付的代码完全是二进制的,可以直接在CPU上执行。

PHP或Perl之所以称为解释程序,是因为解释器(安装在目标机器上)在执行过程中编译每一行代码。

Java介于两者之间;它将代码编译成所谓的Java字节码,然后在它认为合适的时候将其编译成二进制代码。

Java不在启动时编译代码的原因与长期性能优化有关。通过观察应用程序运行并分析实时方法调用和类初始化,Java编译频繁调用的代码部分。它甚至可能根据经验做出一些假设(这部分代码永远不会被调用,或者这个对象始终是一个字符串)。

一个方法需要被调用一定的次数才能达到编译阈值,然后才能对其进行优化和编译(该限制是可配置的,但通常在10,000个调用左右)。在此之前,未经优化的代码不会以“全速”运行。在获得更快的编译和获得高质量的编译之间有一个折衷方案(如果假设是错误的,将会有重新编译的成本)。

当Java应用程序重新启动时,我们又回到了起点,必须等待再次达到该阈值。

有些应用程序(像我们的)有一些不太频繁但很关键的方法,这些方法只会被调用几次,但在调用时需要非常快(想想只有在紧急情况下才会调用的风险或止损过程)。

Azul Zing通过让其JVM将编译的方法和类的状态“保存”在它所称的配置文件中来解决这些问题。这项名为ReadyNow!®的独特功能意味着Java应用程序始终以最佳速度运行,即使在重新启动之后也是如此。

当您使用现有配置文件重新启动应用程序时,Azul JVM会立即调用其以前的决策,并直接编译概述的方法,从而解决了Java预热问题。

此外,您可以在开发环境中构建配置文件来模拟生产行为。然后,在知道所有关键路径都已编译和优化的情况下,可以将优化后的配置文件部署到生产中。

下图显示了交易应用程序的最大延迟(在模拟环境中)。

HotSpot JVM的大延迟峰值清晰可见,而Zing的延迟随着时间的推移保持相当恒定。

百分位数分布表明,在1%的时间内,HotSpot JVM产生的延迟是Zing JVM的16倍。

第二个问题是,在垃圾收集期间,整个应用程序可能会冻结几毫秒到几秒之间的任何时间(延迟随着代码复杂性和堆大小而增加),更糟糕的是,您无法控制何时发生这种情况。

虽然暂停应用程序几毫秒甚至几秒钟对于许多Java应用程序来说可能是可以接受的,但对于低延迟应用程序来说,无论是在汽车、航空航天、医疗还是金融领域,这都是一场灾难。

GC的影响在Java开发人员中是一个大话题;完整的垃圾回收通常被称为“停止世界的暂停”,因为它冻结了整个应用程序。

多年来,许多GC算法都试图在吞吐量(有多少CPU花在实际应用程序逻辑上,而不是垃圾收集上)与GC暂停(我可以暂停应用程序多长时间?)之间进行折衷。

从Java9开始,G1收集器一直是默认的GC,其主要思想是根据用户提供的时间目标分割GC暂停。它通常提供较短的暂停时间,但代价是吞吐量较低。此外,暂停时间随着堆的大小而增加。

Java提供了大量设置来调优其垃圾收集(以及一般的JVM),从堆大小到收集算法,以及分配给GC的线程数量。因此,经常会看到Java应用程序配置了过多的自定义选项:

许多开发人员(包括我们的开发人员)已经转向各种技术来完全避免GC。主要是,如果我们创建的对象较少,则稍后要清除的对象也会较少。

一种旧的(仍在使用的)技术是使用可重用对象的对象池。例如,数据库连接池将包含对10个打开的连接的引用,这些连接随时可以根据需要使用。

多线程通常需要锁定,这会导致同步延迟和暂停(特别是在它们共享资源的情况下)。一种流行的设计是环形缓冲区队列系统,其中有许多线程在无锁设置中进行写入和读取(请参阅中断程序)。

出于沮丧,一些专家甚至选择完全覆盖Java内存管理,自己管理内存分配,这在解决一个问题的同时,也带来了更多的复杂性和风险。

在这种情况下,很明显我们应该考虑其他JVM,我们决定尝试Azul Zing JVM。

这是因为Zing使用了一个名为C4(持续并发压缩收集器)的独特收集器,该收集器允许暂停垃圾收集,而不管Java堆大小如何(最高可达8TB)。

这是通过在应用程序仍在运行时并发映射和压缩内存来实现的。

此外,它不需要任何代码更改,并且延迟和速度改进开箱即可见,无需冗长的配置。

在这种情况下,Java程序员可以同时享受Java的简单性(不需要对创建新对象感到偏执)和Zing的底层性能,从而在整个系统中实现高度可预测的延迟。

多亏了GC Easy(一个通用的GC Log分析器),我们可以在一个真实的自动化交易应用程序中(在模拟环境中)快速比较这两个JVM。

在我们的应用程序中,使用Zing的GC比使用标准Oracle HotSpot JVM的GC小180倍。

更令人印象深刻的是,虽然GC暂停通常与实际的应用程序暂停时间相对应,但Zing智能GC通常在最少或没有实际暂停的情况下并行发生。

总而言之,在享受Java的简单性和面向业务的本质的同时,仍然有可能实现高性能和低延迟。虽然C++用于特定的低级组件,如驱动程序、数据库、编译器和操作系统,但大多数现实生活中的应用程序都可以用Java编写,即使是要求最高的应用程序也可以。

这就是为什么,根据甲骨文的说法,Java是排名第一的编程语言,在全球拥有数百万开发人员和超过510亿台Java虚拟机。