Java 和低延迟

Java 从一开始就被设计为在广泛的硬件和系统架构中以二进制级别可移植。这是通过设计和实现虚拟机(执行平台的抽象模型)并让它执行Java 源编译器的输出来完成的。争论的焦点是迁移到不同类型的硬件平台只需要移植虚拟机。应用程序和库无需修改即可运行(“一次编写,到处运行”的口号)。

但是,具有严格延迟和性能要求的应用程序通常需要在执行时尽可能接近硬件 - 他们希望从硬件中榨取所有可能的性能,并且不想要纯粹为了可移植性或像动态内存管理这样的抽象编程概念阻碍了。

多年来,Java 虚拟机已经发展成为一个极其复杂的执行平台,可以在运行时从 Java 字节码生成机器代码,并根据动态收集的指标优化该代码。这是静态编译语言(如 C++)无法做到的,因为它们没有所需的运行时信息。选择数据结构和算法时的谨慎方法可以最大限度地减少甚至消除垃圾收集的需要——这可能是 Java 运行时环境中最明显​​的一个方面,它阻止了一致的延迟时间。

但归根结底,Java 虚拟机只是——虚拟的——它需要在操作系统之上运行以管理其对硬件平台的访问。无论该操作系统是 Linux(可能是服务器端环境中使用最广泛的)、Windows 还是其他操作系统,问题仍然存在。

Linux 的“问题”
Linux作为 Unix 操作系统家族的一员,多年来一直在发展。Unix 的第一个版本是在 1960 年代后期开发的。它首先在学术界和研究界发展壮大并获得了极大的知名度,然后在商业界以各种形式出现。Linux 已成为 Unix 的主要变体——尽管它仍然保留了许多原始特性。如今,随着基于容器的执行环境和云的出现,它的主导地位几乎已经完全。

但是,从实时或延迟敏感型应用程序的角度来看,Linux/Unix 确实存在问题。这些主要源于 Unix 被设计为分时系统这一基本事实。它最初的硬件平台是微型计算机,同时被许多不同的用户共享。所有用户都有自己的工作要做,而 Unix 竭尽全力确保所有人都能“公平地分享”计算机资源。 

实际上,操作系统会偏爱执行大量 I/O 的用户——包括在终端与系统交互——以牺牲主要执行计算的任务(所谓的 CPU 密集型作业)为代价。当我们考虑到当时的计算机几乎都是单 CPU(单核)时,这是有道理的。

然而,随着多 CPU 计算机的发展,需要对 Unix 操作系统的核心进行一些认真的重新设计,以允许有效地使用这些执行内核。但同样的方法仍然适用,交互式任务总是比 CPU 密集型任务更受欢迎。有了多个内核可用,最终效果仍然是提高整体性能。 

如今,几乎每台计算机都将拥有多个内核,从手机等移动设备到工作站,再到服务器级机器。检查这些环境并查看我们是否可以采取不同的方法来改进平台以更有效地支持实时、延迟敏感的应用程序似乎是有效的。

我们如何解决这些问题?
在我工作的 Chronicle Software,我们已经开发了许多开源库来支持构建针对低延迟进行优化的应用程序,这是基于该领域多年的经验。本文的其余部分描述了我们学到的一些帮助我们实现这一目标的东西。

Java 运行时
影响 Java 应用程序延迟的主要问题是那些与垃圾收集堆管理和使用锁同步访问共享资源有关的问题。存在解决这两个问题的技术,尽管它们确实需要开发人员在一定程度上偏离惯用的 Java 编程风格。理想情况下,我们会使用封装较低级别细节和专门技术的库,但我们确实需要了解“幕后”正在发生的事情。

为低延迟应用程序设计的框架和库青睐的一种方法是绕过 Java 垃圾收集器,利用不属于正常 Java 堆的内存(称为“堆外”内存)。内存使用正常的操作系统机制映射到持久存储,或者通过网络连接复制到其他系统。

使用这种方法的明显优势是对内存的访问不受垃圾收集器的非确定性干预。缺点是管理在这些区域中创建的对象的生命周期成为应用程序或库的责任。 

现代应用程序的通用架构在组件之间包含某种形式的通信,通常基于消息传递。消息在通信过程中被序列化为 JSON 或 YAML 等标准格式或从标准格式反序列化,提供此功能的库通常可以引入高级别的对象分配。经过仔细考虑,可以选择经过精心设计的库,以最大限度地减少新 Java 对象的创建,从而对性能产生积极影响。 

从 Java 的早期开始,对共享可变数据的并发访问就使用互斥锁进行同步。如果一个线程试图获取另一个线程持有的锁,那么它会被阻塞,直到锁被释放。在多核环境中,可以使用不需要获取线程阻塞的替代技术来实现同步,并且已经表明,在大多数情况下,这对减少延迟有积极的影响。 

编写此类代码并不简单,但是,可以在标准 Java 库中的 Lock 接口后面进行封装,甚至可以进一步定义允许通过标准 API 进行安全、无锁并发访问的数据结构。一些标准的 Java Collections 库使用这种方法,尽管这对用户是透明的。

Linux
公平地说,多年来,Unix 的“实时”变体已经为专门的应用程序提供了不同的执行环境。虽然这些通常是利基产品,但如今,这些方法和功能中的许多都可以在 Unix 和 Linux 的主流发行版中使用。

最小化延迟的特性通常分为两类,内存管理和线程调度。

Linux 进程中的所有内存,包括 Java 的垃圾收集堆,都会被临时“换出”到磁盘,以便其他进程可以在需要将内存重新带入之前将 RAM 用于自己的目的。这一切都会发生对进程完全透明,内存中的数据和后备存储中的数据之间的访问时间差异可能有几个数量级。当然,堆外内存也有同样的行为。 

但是,现代 Unix 和 Linux 系统允许标记内存区域,以便操作系统在寻找要从进程中回收的区域时忽略它们。这意味着,对于该进程中的那些内存区域,内存访问时间将是一致的(并且总体上被认为更快)。不得不说,在繁忙的Java应用程序中,访问进程内存的频率会降低该内存被分页的可能性,但风险仍然存在。

以这种方式固定一个进程的内存意味着其他进程的内存更少,这可能会因此受到影响,但在“实时”世界中,我们必须有点自私!

为低延迟而设计的数据结构通常会默认或通过选项提供将其内存锁定或固定在 RAM 中的能力。

Java 程序中的线程,就像来自其他应用程序甚至操作系统任务的线程一样,可以访问由称为调度程序的操作系统组件管理的 CPU。调度程序有一组策略,用于决定选择哪些需要访问 CPU 的线程(称为 Runnable 线程)——通常 Runnable 线程比 CPU 多。 

如前所述,Unix/Linux 中的传统调度策略旨在支持交互式线程而不是 CPU 绑定线程。如果我们试图运行对延迟敏感的应用程序,这对我们没有帮助——我们希望我们的线程以某种方式优先于其他非延迟敏感的线程。

现代 Unix/Linux 系统提供了可以提供这些功能的替代调度策略,通过允许将线程调度优先级固定在高级别,以便它们在可运行时总是从其他线程接管 CPU 资源,这意味着它们可以更多地响应事件迅速地。

但也可以进一步影响调度程序的行为。通常,在管理线程时会使用所有可用的 CPU 资源。如今,可以更改调度程序使用哪些 CPU。我们可以从调度程序可用的 CPU 中完全移除 CPU,并将它们专门用于我们的专用线程。

或者,我们可以将 CPU 分成组,并将一组 CPU 与特定的线程组相关联。此功能是 Linux 更通用的资源管理组件(称为组)的一部分。它构成了 Linux 对虚拟化的支持的一部分,并且是实现容器的关键,例如在现代环境中由 Docker 生成的容器。但是,它可以通过特定的系统调用用于一般应用程序。

就像上面描述的内存锁定一样,我们是自私的,因为这样做显然会对系统的其他部分产生负面影响。需要非常小心地配置以获得最佳结果,因为错误的可能性很高,而且出错的后果可能很严重。

你可能感兴趣的:(java,unix,开发语言)