Java优化【Optimizing Java】:硬件和操作系统

为什么Java开发人员要关心硬件?
多年来,计算机行业一直受到摩尔定律(Moore 's Law)的推动。摩尔定律是英特尔(Intel)创始人戈登•摩尔(Gordon Moore)对处理器性能长期趋势提出的假设。定律(实际上是一种观察或推断)可以用多种方式来构建,但最常见的一种方式是:

大规模生产的芯片上的晶体管的数量大约每18个月翻一番。

这种现象代表了计算机能力随时间的指数增长。它最初是在1965年被引用的,因此代表了一种难以置信的长期趋势,几乎是人类发展史上前所未有的。摩尔定律的影响在现代世界的许多(如果不是大多数)领域产生了革命性的影响。
NOTE:几十年来,摩尔定律的死亡一再被宣告。 然而,我们有充分的理由假设,出于所有实际目的,芯片技术的这一令人难以置信的进步已经(终于)结束了。

硬件变得越来越复杂,以便充分利用现代计算机中可用的“晶体管预算”。在硬件上运行的软件平台也增加了开发新功能的复杂性,因此,虽然软件拥有更大的能力,但它已经开始依赖复杂的基础来访问性能的提高。

普通应用程序开发人员所能获得的性能大幅提高的最终结果是复杂软件的大量涌现。软件应用现在遍及全球社会的各个方面。

或者,换句话说:

软件正在吞噬世界。                   -Marc Andreessen

正如我们将要看到的,Java已经成为计算机能力不断增强的受益者。 语言和运行时的设计非常适合(或幸运地)利用处理器能力的这种趋势。 但是,真正注重性能的Java程序员需要了解支撑该平台的原则和技术,以便充分利用可用资源。

在后面的章节中,我们将探讨现代JVM的软件体系结构和在平台和代码级别优化Java应用程序的技术。 但在转向这些主题之前,让我们快速浏览一下现代硬件和操作系统,因为对这些主题的理解将有助于完成所有后续工作。

3.1 现代硬件

许多关于硬件架构的大学课程仍然教授简单易懂,经典的硬件视图。 这种“十全十美的【motherhood and apple pie】”的硬件视图侧重于基于寄存器的机器的简单视图,包括算术,逻辑以及加载和存储操作。 因此,与CPU实际相比,它过分强调C编程作为事实的来源。 简单地说,这是现代一种事实上不正确的世界观。

自20世纪90年代以来,应用程序开发人员的世界在很大程度上围绕着英特尔x86 / x64架构。 这是一个经历了彻底改变的技术领域,许多先进的功能现在已成为景观的重要组成部分。 处理器操作的简单心理模型现在完全不正确,基于它的直观推理可能导致完全错误的结论。

为了解决这个问题,在本章中,我们将讨论CPU技术的一些进步。 我们将从内存行为开始,因为这对于现代Java开发人员来说是最重要的。

2.2 Memory

随着摩尔定律的推进,最初使用指数级增加的晶体管可以实现更快,更快的时钟速度。 原因很明显:更快的时钟速度意味着每秒完成更多指令。 因此,处理器的速度已经大大提高,我们今天拥有的2 + GHz处理器比第一台IBM PC中的原始4.77 MHz芯片快了数百倍。

然而,不断增长的时钟速度揭示了另一个问题。更快的芯片需要更快的数据流来处理。如图3-1所示,随着时间的推移,主存不能满足处理器核心对新数据的需求。
在这里插入图片描述
图3 - 1 内存速度和晶体管计数(Hennessy and Patterson, 2011)

这会导致一个问题:如果CPU正在等待数据,那么更快的周期没有帮助,因为CPU将不得不空闲,直到所需的数据到达。

Memory Caches

为了解决这个问题,引入了CPU缓存。这些是CPU上的内存区域,它们比CPU寄存器慢,但是比主内存快。其思想是让CPU用经常访问的内存位置的副本填充缓存,而不是不断地重新寻址主内存。

现代CPU通常都有好几层缓存,访问最频繁的缓存位于靠近处理器核心的位置。最接近CPU的缓存通常称为L1(“level 1 cache”),下一个称为L2,以此类推。不同的处理器体系结构具有不同数量和配置的高速缓存,但是通常的选择是每个执行核心都有一个专用的、私有的L1和L2缓存,以及一个在部分或所有cores之间共享的L3缓存。这些缓存加速访问时间的效果如图3-2.2所示:
在这里插入图片描述
图3 - 2 各种类型内存的访问时间

这种缓存架构方法可以缩短访问时间,并有助于保持核心充足的数据以便进行操作。 由于时钟速度与访问时间的差距,更多的晶体管预算用于现代CPU上的缓存。

最终的设计如图3-3所示。 这显示了L1和L2高速缓存(每个CPU核心专用)和CPU上所有核心共用的共享L3高速缓存。 主存是通过北桥组件访问的,它是通过这个总线访问的,这导致了访问主存时间的大幅下降。
在这里插入图片描述
图3 - 3 总体CPU和内存架构

虽然添加缓存架构极大地提高了处理器的吞吐量,但它引入了一系列新问题。 这些问题包括确定如何从缓存中获取和写回内存。 此问题的解决方案通常称为缓存一致性协议【cache consistency protocols】。

NOTE:在并行处理环境中应用这种类型的缓存时,还会出现其他问题,我们将在本书后面看到这一点。

在最低级别,通常在各种处理器上都可以找到称为MESI(及其变体)的协议。 它为缓存中的任何一行定义了四种状态。 每行(通常为64字节)是:

  • Modified :已修改(但尚未刷新到主存)
  • Exclusive :独占(仅在此缓存中存在,但与主存匹配)
  • Shared :共享(也可能存在于其他缓存中;匹配主存)
  • Invalid :无效(不得使用;会尽快取消)

协议的思想是多个处理器可以同时处于Shared 状态。但是,如果一个处理器转换到任何其他有效状态(Modified 或Exclusive ),那么这将迫使所有其他处理器进入Invalid 状态。如表3-1所示:

MESI
M---y
E---y
S--yy
Iyyyy

表3 - 1 处理器之间允许的状态

该协议通过广播处理器改变状态的意图来工作。通过共享存储器总线发送电信号,并使其他处理器知晓。 状态转换的完整逻辑如图3-4所示:
在这里插入图片描述
图3 - 4 MESI状态转移图

最初,处理器将每个缓存操作直接写入主存。 这被称为透写【write-through】行为,但它是非常低效的,并且需要大量的内存带宽。 最近的处理器还实现了回写【write-back】行为,其中当缓存块被替换时,处理器仅将修改的(脏)高速缓存块写入内存,从而显著的减少返回主存储器的流量。

缓存技术的总体效果是大大提高数据写入或读取内存的速度。而这是用内存的带宽来表示的。 突发速率【burst rate】,或理论最大值,是基于以下几个因素:

  • 存储器时钟频率
  • 内存总线的宽度(通常为64位)
  • 接口数量(在现代机器中通常是两个)

在DDR RAM的情况下,这要乘以2(DDR表示“双倍数据速率”,它在时钟信号的两端进行通信)。 将公式应用于2015年的商品硬件,理论上最大写入速度为8-12 GB / s。 当然,在实践中,这可能受到系统中许多其他因素的限制。 就目前而言,这提供了一个适度有用的价值,使我们能够看到硬件和软件的接近程度。
让我们编写一些简单的代码来练习缓存硬件,如示例3-1所示:

public class Caching {

    private final int ARR_SIZE = 2 * 1024 * 1024;
    private final int[] testData = new int[ARR_SIZE];

    private void run() {
        System.err.println("Start: "+ System.currentTimeMillis());
        for (int i = 0; i < 15_000; i++) {
            touchEveryLine();
            touchEveryItem();
        }
        System.err.println("Warmup finished: "+ System.currentTimeMillis());
        System.err.println("Item     Line");
        for (int i = 0; i < 100; i++) {
            long t0 = System.nanoTime();
            touchEveryLine();
            long t1 = System.nanoTime();
            touchEveryItem();
            long t2 = System.nanoTime();
            long elItem = t2 - t1;
            long elLine = t1 - t0;
            double diff = elItem - elLine;
            System.err.println(elItem + " " + elLine +" "+  (100 * diff / elLine));
        }
    }

    private void touchEveryItem() {
        for (int i = 0; i < testData.length; i++)
            testData[i]++;
    }

    private void touchEveryLine() {
        for (int i = 0; i < testData.length; i += 16)
            testData[i]++;
    }

    public static void main(String[] args) {
        Caching c = new Caching();
        c.run();
    }
}

例子3-1 缓存例子

直观地说,touchEveryItem()做的工作是touchEveryLine()的16倍,必须更新的数据项是touchEveryLine()的16倍。然而,这个简单示例的目的是展示在处理JVM性能时,直觉会多么糟糕地将我们引入歧途。让我们看看Caching类的一些示例输出,如图3-5所示。
在这里插入图片描述
图3 - 5 缓存例子所需的时间

该图显示了每个函数的100次运行,旨在显示几种不同的效果。 首先,请注意两个函数的结果在时间上非常相似,因此对“16倍工作量”的直观预期显然是错误的。

相反,这段代码的主要作用是练习内存总线,通过将数组的内容从主存储器传输到缓存,以便通过touchEveryItem()和touchEveryLine()进行操作。

在数据统计方面,虽然结果是合理一致的,但是个别的离群值与中值有30-35%的差异。

总的来说,我们可以看到简单内存函数的每次迭代需要大约3毫秒(平均2.86毫秒)来遍历100 MB的内存块,从而提供不到3.5 GB / s的有效内存带宽。 这小于理论最大值,但仍然是一个合理的数字。
NOTE:现代CPU有一个硬件预取器,它可以检测数据访问中的可预测模式(通常只是对数据进行常规的“跨越”)。在这个例子中,我们利用这个事实来接近内存访问带宽的实际最大值。

Java性能中的一个关键主题是应用程序对对象分配速率的敏感性。我们将多次回到这一点,但是这个简单的示例为我们提供了一个基本的标准,来判断分配率可以上升到多高。

2.3 高级处理器特性

硬件工程师有时会把由于摩尔定律而成为可能的新特性称为“花费晶体管预算”。随着晶体管数量的不断增加,内存缓存是最明显的用途,但多年来也出现了其他技术。

Translation Lookaside Buffer:转译后备缓冲器

一个非常重要的用途是在不同类型的缓存中,即Translation Lookaside Buffer(TLB)。 这充当了将虚拟内存地址映射到物理地址的页表的缓存,这极大地加速了对虚拟地址下面的物理地址的非常频繁的操作访问。

NOTE:JVM的内存相关软件功能也有缩写TLB(我们稍后会看到)。 当您看到提到的TLB时,请务必检查正在讨论的功能。

如果没有TLB,所有虚拟地址查找将花费16个周期,即使页面表保存在L1缓存中。性能将是不可接受的,所以TLB基本上是所有现代芯片的基础。
PS:建议读者阅读下Operating System Concept Chpater9,以了解逻辑地址、物理地址、页表以及TLB(Translation Lookaside Buffer)。通俗的讲,每个进程中都维护着一个页面(page table),页表中的每一个条目中记录的是逻辑页码所对应的物理起始地址。没有TLB时,我们需要访问内存中的页表,以确定物理地址,而有了TLB,它充当我们的缓存中间件,但是TLB比较昂贵,所以并不能完全的缓存所有的页表项。

Branch Prediction and Speculative Execution

出现在现代处理器上的一个高级处理器技巧是分支预测。这用于防止处理器不得不等待计算条件分支所需的值。现代处理器有多级指令管道。这意味着单个CPU周期的执行被分解为几个独立的阶段。一次可以有多个指令处于飞行状态【in flight】(在执行的不同阶段)。

这个模型中,条件分支是有问题的,因为在评估条件之前,不知道分支之后的下一条指令是什么。 这可能导致处理器停顿若干周期(实际上,最多20个),因为它有效地清空了分支后面的多级管道。
NOTE:众所周知,推测性执行【Speculative execution】是2018年初发现的影响大量cpu的重大安全问题的原因。

为了避免这种情况,处理器可以用晶体管构建一个启发式的算法来决定哪个分支更有可能被采用。使用这个猜测,CPU将基于一次赌博填充管道。如果它能工作,那么CPU就会像什么都没发生过一样继续工作。如果错误,则转储部分执行的指令,CPU必须付出清空管道的代价。

Hardware Memory Models

关于内存的核心问题必须在多核系统中得到解答:“多个不同的CPU如何能够始终如一地访问相同的内存位置?”

这个问题的答案是高度依赖硬件的,但一般来说,javac,JIT编译器和CPU都可以更改代码执行的顺序。 这取决于任何更改不会影响当前线程所观察到的结果的规定。

例如,假设我们有一段这样的代码:

myInt = otherInt;
intChanged = true;

这两个赋值之间没有代码,因此执行线程不需要关心它们发生的顺序,因此环境可以随意更改指令的顺序。

但是,这可能意味着在另一个具有这些数据项可见性的线程中,顺序可能会发生变化,而另一个线程读取的myInt值可能是旧值,尽管intChanged被认为是true。

这种类型的重新排序(stores moved after stores)在x86芯片上是不可能的,但是如表3-2所示,还有其他架构可以发生并确实发生。

MESIII
Loads moved after loadsYY----
Loads moved after storesYY----
Stores moved after storesYY----
Stores moved after loadsYYYYYY
Atomic moved with loadsYY----
Atomic moved with storesYY----
Incoherent instructionsYYYY-Y

表3 - 2 硬件内存支持

在Java环境中,Java内存模型(JMM)被显式设计为弱模型,以考虑处理器类型之间内存访问一致性的差异。正确使用锁和易失性访问是确保多线程代码正常工作的主要部分。这是一个非常重要的话题,我们将在第12章中讨论。

近年来,软件开发人员有一种趋势,即寻求对硬件工作原理的更深入理解,以便获得更好的性能。Martin Thompson和其他人创造了“机械同情【mechanical sympathy】”这个术语来描述这种方法,特别是用于低延迟和高性能空间。这可以在最近对无锁算法和数据结构的研究中看到,我们将在本书的最后进行讨论。

2.4 Operating Systems

操作系统的要点是控制对必须在多个执行进程之间共享的资源的访问。 所有资源都是有限的,并且所有流程都是贪婪的,因此需要中央系统进行仲裁和计量访问是必不可少的。 在这些稀缺资源中,最重要的两个通常是内存和CPU时间。

通过内存管理单元(MMU)及其页表进行虚拟寻址是实现内存访问控制的关键功能,可防止一个进程损坏另一个进程拥有的内存区域。

本章前面介绍的TLBs是一种硬件特性,可以改善物理内存的查找时间。缓冲区的使用提高了软件对内存访问时间的性能。 但是,MMU通常太低级别,开发人员无法直接影响或了解它。相反,让我们更仔细地看看OS进程调度器,因为它控制对CPU的访问,并且是操作系统内核中用户可见的部分。

调度器

进程调度程序控制对CPU的访问。 这使用称为运行队列【run queue】的队列作为有资格运行但必须等待CPU的线程或进程的等待区域。 在现代系统上,想要运行的线程/进程总是比能够运行的线程/进程多,因此这种CPU争用需要一种机制来解决。种机制来解决它。

调度器的工作是响应中断,并管理对CPU核心的访问。 Java线程的生命周期如图3-6所示。 理论上,Java规范允许线程模型,在这种模型中,Java线程不一定对应于操作系统线程。 然而,在实践中,这种“green threads”方法未被证明是有用的,并且已经被放弃在主流操作环境中。
在这里插入图片描述
图3 - 6 线程的生命周期

在这个相对简单的视图中,OS调度器将线程移入和移出系统中的单个处理器核心【core】。 在时间段结束时(在较旧的操作系统中通常为10毫秒或100毫秒),调度程序将线程移动到运行队列【【run queue】】的后面等待,直到它到达队列的前面并有资格再次运行。

如果一个线程想要自愿放弃它的时间段,如果一个线程想自愿放弃它的时间量,它可以在一段固定的时间内这样做(通过sleep()),或者直到满足一个条件(使用wait())。

当您第一次遇到此模型时,考虑只有一个执行核心的计算机可能会有所帮助。 当然,真正的硬件更加复杂,实际上任何现代机器都有多个核心,这允许同时执行多个执行路径。 这意味着在真正的多处理环境中执行的推理非常复杂且违反直觉。

操作系统经常被忽视的一个特性是,就其本质而言,这种结构会引入一段代码未在CPU上运行的时间段。 已完成其时间段的进程将不再返回CPU,直到它再次到达运行队列【run queue】的前面。 再加上CPU是一种稀缺资源,这意味着代码等待的时间比运行的时间要长。

这意味着我们希望从我们实际想要观察的进程生成的统计信息受到系统上其他进程的行为的影响。 这种“抖动”和调度开销是观察结果中产生噪声的主要原因。 我们将在第5章讨论统计特性和实际结果的处理。

查看调度程序的操作和行为的最简单方法之一是尝试观察操作系统实施调度所产生的开销。 以下代码执行1,000个单独的1 ms睡眠。 这些休眠中的每一个都将涉及将线程发送到运行队列【run queue】的后面,并且必须等待新的时间段。 因此,代码的总运行时间让我们对一个典型进程的调度开销有了一些概念:

 long start = System.currentTimeMillis();
        for (int i = 0; i < 2_000; i++) {
            Thread.sleep(2);
        }
        long end = System.currentTimeMillis();
        System.out.println("Millis elapsed: " + (end - start) / 4000.0);

根据操作系统的不同,运行这段代码会导致截然不同的结果。大多数unix将报告大约10-20%的开销。由于早期版本的Windows臭名昭著的较差的调度器,一些版本的Windows XP报告的调度开销高达180%(因此1000毫秒的1毫秒睡眠需要2.8秒),甚至有报道称某些专有操作系统供应商已在其版本中插入代码,以便检测基准测试运行并欺骗指标。

时序【Timing】对于性能测量,进程调度以及应用程序堆栈的许多其他部分至关重要,因此让我们快速了解Java平台如何处理时序【Timing】(以及更深入地了解它如何支持它) JVM和底层操作系统)。

A Question of Time

尽管存在诸如POSIX之类的行业标准,但是不同的OS可以具有非常不同的行为。 例如,考虑os::javaTimeMillis()函数。 在OpenJDK中,这包含特定于操作系统的调用,这些调用实际上完成了工作并最终提供了最终由Java的System.currentTimeMillis()方法返回的值。

正如我们在“线程和Java内存模型”中讨论的那样,因为它依赖于必须由主机操作系统提供的功能,所以os::javaTimeMillis()必须作为本地方法实现。 这是在BSD Unix上实现的函数(例如,用于Apple的macOS操作系统):

jlong os::javaTimeMillis() {
  timeval time;
  int status = gettimeofday(&time, NULL);
  assert(status != -1, "bsd error");
  return jlong(time.tv_sec) * 1000  +  jlong(time.tv_usec / 1000);
}

Solaris、Linux甚至AIX的版本都非常类似于BSD,但是Microsoft Windows的代码完全不同:

jlong os::javaTimeMillis() {
  if (UseFakeTimers) {
    return fake_time++;
  } else {
    FILETIME wt;
    GetSystemTimeAsFileTime(&wt);
    return windows_to_java_time(wt);
  }
}

Windows使用64位FILETIME类型来存储自1601年初以来经过的时间,单位为100 ns,而不是Unix时间结构。 Windows还具有系统时钟“实际精度”的概念,具体取决于可用的物理时序硬件。 因此,来自Java的定时调用的行为在不同的Windows机器上可能变化很大。

操作系统之间的差异不会随着时间而结束,我们将在下一节中看到。

上下文切换

上下文切换是OS调度器移除当前正在运行的线程或任务并将其替换为正在等待的线程或任务的过程。 有几种不同类型的上下文切换,但从广义上讲,它们都涉及交换执行指令和线程的堆栈状态。

无论在用户线程之间还是从用户模式到内核模式(有时称为模式切换)之间,上下文切换都是一项代价高昂的操作。 后一种情况特别重要,因为用户线程可能需要交换到内核模式,以便在其时间片的中途执行某些功能。 但是,此切换将强制清除指令和其他缓存,因为用户空间代码访问的内存区域通常与内核没有任何共同之处。

上下文切换到内核模式将使TLBs和其他缓存失效。当调用返回时,这些缓存将不得不重新填充,因此即使在控制返回到用户空间之后,内核模式切换的效果仍然存在。这将导致系统调用的真实成本被掩盖,如图3-7所示。
在这里插入图片描述
图3 - 7 系统调用的影响(Soares和Stumm, 2010)

为了尽可能减轻这种影响,Linux提供了一种称为vDSO(虚拟动态共享对象)的机制。 这是用户空间中的一个内存区域,用于加速实际上不需要内核特权的系统调用。 它通过不实际执行上下文切换到内核模式来实现这种速度增加。 让我们看一个示例,看看它如何与真正的系统调用一起工作。

一个非常常见的Unix系统调用是gettimeofday()。 这将返回操作系统所理解的“挂钟时间【wallclock time】”。 在幕后,它实际上只是读取内核数据结构以获得系统时钟时间。 由于这是无副作用的,因此实际上并不需要特权访问。

如果我们可以使用vDSO来安排将这个数据结构映射到用户进程的地址空间,那么就不需要执行到内核模式的切换。因此,不必支付图3-7所示的重新填充罚款。

如果我们可以使用vDSO来安排将此数据结构映射到用户进程的地址空间,那么就不需要执行到内核模式的切换。 因此,无需支付图3-7中所示的重新填充的惩罚。

鉴于大多数Java应用程序需要访问时序数据的频率,这是一个受欢迎的性能提升。vDSO机制对这个示例进行了轻微的一般化,它可能是一种有用的技术,即使它仅在Linux上可用.

3.7 A Simple System Model

在本节中,我们将介绍一个简单的模型,用于描述可能的性能问题的基本来源。该模型以基本子系统的操作系统可观察量表示,并且可以直接与标准Unix命令行工具的输出相关联。

该模型基于在Unix或类Unix操作系统上运行的Java应用程序的简单概念。 图3-8显示了模型的基本组件,包括:

  • 应用程序运行的硬件和操作系统
  • 应用程序运行的JVM(或容器)
  • 应用程序代码本身
  • 应用程序调用的任何外部系统
  • 正在访问应用程序的传入请求流量
    在这里插入图片描述
    图3 - 8 简单的系统模型

系统的任何这些方面都可能导致性能问题。 有一些简单的诊断技术可用于缩小或隔离系统的特定部分,作为性能问题的潜在罪魁祸首,我们将在下一节中看到。

3.8 Basic Detection Strategies

性能良好的应用程序的一个定义是有效地利用系统资源。这包括CPU使用、内存和网络或I/O带宽。
TIP:如果应用程序导致命中一个或多个资源限制,则结果将是性能问题。

任何性能诊断的第一步是识别正在命中哪个资源限制。 如果不处理资源短缺(通过增加可用资源或使用效率),我们就无法调优适当的性能指标。

同样值得注意的是,操作系统本身通常不应成为是系统利用率的主要影响因素。 操作系统的作用是代表用户进程管理资源,而不是使用它们本身。这个规则的唯一真正的例外是,当资源如此稀缺,以至于操作系统很难分配足够的资源来满足用户需求时。对于大多数现代的服务器类硬件,只有当I/O(或偶尔的内存)需求远远超过其能力时才会出现这种情况。

Utilizing the CPU

应用程序性能的一个关键指标是CPU利用率。CPU周期通常是应用程序所需要的最关键的资源,因此有效地使用它们对于良好的性能至关重要。应用程序的目标应该是在高负载期间尽可能接近100%的使用。
TIP:在分析应用程序性能时,系统必须有足够的负载来运行它。空闲应用程序的行为通常对性能工作毫无意义。

每个性能工程师都应该了解的两个基本工具是vmstat和iostat。在Linux和其他unix上,这些命令行工具分别提供了对虚拟内存和I / O子系统的当前状态提供了即时且通常非常有用的信息。这些工具只提供整个主机级别的数字,但这通常足以指出更详细的诊断方法。让我们看看如何使用vmstat作为一个例子:

$ vmstat 1
 r  b swpd  free    buff  cache   si   so  bi  bo   in   cs us sy  id wa st
 2  0   0 759860 248412 2572248    0    0   0  80   63  127  8  0  92  0  0
 2  0   0 759002 248412 2572248    0    0   0   0   55  103 12  0  88  0  0
 1  0   0 758854 248412 2572248    0    0   0  80   57  116  5  1  94  0  0
 3  0   0 758604 248412 2572248    0    0   0  14   65  142 10  0  90  0  0
 2  0   0 758932 248412 2572248    0    0   0  96   52  100  8  0  92  0  0
 2  0   0 759860 248412 2572248    0    0   0   0   60  112  3  0  97  0  0

vmstat后面的参数1表明,我们希望vmstat提供持续的输出(直到通过Ctrl-C中断),而不是单个快照。 每秒打印一个新的输出行,这使得性能工程师可以在执行初始性能测试时保持输出运行(或将其捕获到日志中)

vmstat的输出相对容易理解,并包含大量有用的信息,分为几个部分:

  1. 前两列显示了可运行【runnable 】(r )和阻塞【blocked 】(b)的进程的数量。
  2. 在内存部分中,显示了交换和可用内存的数量,然后是用作缓冲区和缓存的内存。在内存部分中,显示了交换和可用内存的数量,然后是用作缓冲区和缓存的内存。
  3. 交换【swap】部分显示了从磁盘交换进来和从磁盘交换出去的内存(si等等)。现代的服务器类机器通常不会经历太多交换活动。
  4. bi【block in】和bo【block out】的计数显示已从I / O设备接收和发送到I / O设备的512字节块的数量。
  5. 在系统【system】部分中,显示的是每秒钟内的中断数(in)和上下文切换次数(cs)
  6. CPU部分包含许多直接相关的指标,表示为CPU时间的百分比。依次是用户时间【user time】(us)、内核时间【kernel time】(sy,表示“系统时间”)、空闲时间【idle time 】(id)、等待时间【 waiting time】(wa)和“窃取的时间【stolen time】”(st,用于虚拟机)。

在本书余下的部分中,我们将遇到许多其他更复杂的工具。然而,重要的是不要忽视我们可以使用的基本工具。复杂的工具通常有可能误导我们的行为,而操作接近进程和操作系统的简单工具可以清晰、整洁地显示系统的实际行为。

让我们考虑一个例子。在“上下文切换”中,我们讨论了上下文切换的影响,并在图3-7中看到了完整上下文切换对内核空间的潜在影响。然而,无论是在用户线程之间还是在内核空间中,上下文切换都会不可避免地造成CPU资源的浪费。

一个经过良好调优的程序应该最大限度地利用它的资源,特别是CPU。对于主要依赖于计算的工作负载(“CPU限制”问题),目标是为用户空间工作实现接近100%的CPU利用率。

换句话说,如果我们观察到CPU利用率没有接近100%的用户时间,那么下一个明显的问题是,“为什么不呢?”是什么原因导致这个项目无法实现这一点?非自愿上下文切换是由锁引起的问题吗?是由于I/O争用引起的阻塞吗?

在大多数操作系统(尤其是Linux)上,vmstat工具可以显示发生的上下文切换次数,因此vmstat 1运行允许分析人员查看上下文切换的实时效果。 无法实现100%用户空间CPU使用率并且还显示较高的上下文切换速率的进程,那么它很可能在I/O上被阻塞,或者出现线程锁争用。

但是,vmstat输出不足以完全消除这些情况本身的歧义。 vmstat可以帮助分析师检测I / O问题,因为它提供了I / O操作的原始视图,但是要实时检测线程锁争用,应该使用VisualVM等工具来显示正在运行的进程中的线程状态。另一个常用工具是统计线程分析器,它对堆栈进行采样,以提供阻塞代码的视图。

Garbage Collection

正如我们将在第6章中看到的,在HotSpot JVM(迄今为止最常用的JVM)中,内存在启动时分配,并在用户空间内进行管理。这意味着不需要系统调用(例如sbrk())来分配内存。反过来,这意味着垃圾收集的内核切换活动非常少。

因此,如果一个系统的系统CPU使用率很高,那么它肯定不会在GC上花费大量时间,因为GC活动会消耗用户空间CPU周期,不会影响内核空间利用率。

另一方面,如果JVM进程在用户空间中使用100%(或接近)CPU,那么垃圾收集通常是罪魁祸首。在分析性能问题时,如果简单工具(例如vmstat)显示100%的CPU使用率,但几乎所有周期都被用户空间占用,那么我们应该问:“是JVM还是用户代码负责这个利用率?“在几乎所有情况下,JVM的高用户空间利用率都是由GC子系统引起的,因此一个有用的经验法则是检查GC日志并查看新条目的添加频率。

JVM中的垃圾收集日志非常便宜,以至于即使最精确的总体成本度量也无法可靠地将其与随机背景噪声区分开来。GC日志作为分析数据的来源也非常有用。因此,必须为所有JVM进程启用GC日志,尤其是在生产环境中。

在本书的后面,我们将对GC和生成的日志进行大量的讨论。但是,在这一点上,我们鼓励读者咨询他们的操作人员,并确认GC日志记录是否在生产中。如果不是,那么第七章的重点之一就是建立一个策略来实现这一点。

I/O

传统上,文件I/O是整个系统性能中比较模糊的方面之一。这在一定程度上是因为它与凌乱的物理硬件有着更密切的关系,工程师们会拿“旋转生锈”之类的话开玩笑,但这也是因为I/O缺乏像操作系统中其他地方那样清晰的抽象。

在内存的情况下,虚拟内存作为一种分离机制的优雅工作得很好。但是,I/O没有类似的抽象为应用程序开发人员提供适当的隔离。

幸运的是,虽然大多数Java程序都涉及一些简单的I/O,但是大量使用I/O子系统的应用程序的种类相对较小,特别是,大多数应用程序不会同时尝试在CPU或内存达到I/O饱和。

不仅如此,已建立的运营实践已经形成了一种文化,在这种文化中,生产工程师已经意识到I / O的局限性,并积极监控大量I / O使用的流程。

对于性能分析师/工程师来说,了解我们的应用程序的I / O行为就足够了。 诸如iostat(甚至vmstat)之类的工具具有一些基本的计数器(例如,blocks in或blocks out),这通常是我们基本诊断所需要的,特别是如果我们假设每个主机只有一个I/ o密集型应用程序。

最后,值得一提的是I / O的一个方面正在越来越广泛地应用于一类依赖于I / O但同时也依赖于严格性能应用程序的Java应用程序。

最后,I/O值得一提的一个方面是,它在依赖于I/O但也依赖于严格性能应用程序的Java应用程序类中得到了越来越广泛的应用。

KERNEL BYPASS I/O

对于某些高性能应用程序,使用内核从例如网卡上的缓冲区复制数据并将其放入用户空间区域的成本非常高。 相反,使用专门的硬件和软件将数据直接从网卡映射到用户可访问的区域。 这种方法避免了“双重复制”以及跨越用户空间和内核之间的边界,如图3-9所示。

但是,Java不提供对此模型的特定支持,而是希望使用它的应用程序依赖于自定义(本机)库来实现所需的语义。 它可以是一个非常有用的模式,并且越来越多地在需要非常高性能I/O的系统中实现。
在这里插入图片描述
图3 - 9 内核绕过I / O
NOTE:在某些方面,这让人想起Java的New I/O (NIO) API,它允许Java I/O绕过Java堆,直接使用本机内存和底层I/O。

到目前为止,我们已经讨论了在“裸机”之上运行的操作系统。然而,越来越多的系统在虚拟化环境中运行,因此在本章结束时,让我们简要了解一下虚拟化如何从根本上改变我们对Java应用程序性能的看法。

Mechanical Sympathy:机械同情

机械同情是这样一种观点:对于那些我们需要挤出额外性能的情况,拥有对硬件的欣赏是无价的。

你不一定要成为一名工程师才能成为一名赛车手,但你必须有机械上的同情心。  ---Jackie Stewart

这句话最初由Martin Thompson创造,直接引用了Jackie Stewart和他的汽车。但是,除了极端情况之外,在处理生产问题并着眼于提高应用程序的整体性能时,对本章中概述的问题进行基线了解也很有用。

对于许多Java开发人员来说,机械同情是一个值得关注的问题。这是因为JVM提供了远离硬件的抽象级别,从而使开发人员免受各种性能问题的困扰。通过了解JVM及其与硬件的交互,开发人员可以在高性能和低延迟空间中非常成功地使用Java和JVM。需要注意的一点是,JVM实际上对性能和机械同情的推理更加困难,因为还有更多需要考虑的因素。在第14章中,我们将描述高性能日志记录和消息传递系统如何工作以及如何理解机械同情。

让我们看一个例子:缓存行的行为。
在本章中,我们讨论了处理器缓存的好处。高速缓存行的使用使得能够获取内存块。在多线程环境中,当有两个线程尝试读取或写入位于同一缓存行上的变量时,缓存行可能会导致问题。

两个线程现在试图修改同一缓存行时,就会发生竞争。第一个线程将使第二个线程上的缓存行无效,从而使其从内存中重新读取。一旦第二个线程执行了该操作,它将使第一个线程中的高速缓存行无效。这种乒乓行为导致了一种被称为“假共享”的性能下降——但是如何才能解决这个问题呢?

机械的同情会告诉我们,首先我们需要明白这正在发生,只有在那之后,我们才能决定如何解决它。在Java中,不保证对象中的字段布局,这意味着很容易找到共享缓存行的变量。解决这个问题的一种方法是在变量周围添加内边距/填充【padding】,将它们强制放到另一条缓存线上。我们将在第14章节介绍如何使用Agrona项目中的低级队列来实现。

3.10 Virtualization

虚拟化有多种形式,但最常见的一种是在已经运行的操作系统上以单个进程的形式运行操作系统的副本。这导致了图3-10所示的情况,其中虚拟环境作为在裸机上执行的非虚拟化(或“真实”)操作系统内的进程运行。

如果对虚拟化、相关理论及其对应用程序性能调优的影响进行全面的讨论,那就太离题太远了。然而,提到虚拟化所带来的差异似乎是合适的,特别是考虑到在虚拟或云环境中运行的应用程序的数量在不断增加。
在这里插入图片描述
图3 - 10 操作系统虚拟化
尽管虚拟化最早是在20世纪70年代在IBM大型机环境中开发的,但是直到最近x86体系结构才能够支持“真正的”虚拟化。这通常有以下三种情况:

  • 在虚拟化操作系统上运行的程序应该与在裸机上运行时(即,未虚拟化)的程序基本相同。
  • 管理程序【hypervisor 】必须调解对硬件资源的所有访问。
  • 虚拟化的开销必须尽可能小,并且不占执行时间的很大一部分。

在普通的非虚拟化系统中,OS内核以特殊的特权模式运行(因此需要切换到内核模式)。 这使得操作系统可以直接访问硬件。 但是,在虚拟化系统中,禁止客户操作系统直接访问硬件。

一种常见的方法是根据非特权指令重写特权指令。 另外,一些OS内核的数据结构需要被“遮蔽【shadowed】”以防止在上下文切换期间过多的高速缓存刷新(例如,TLB)。

一些与Intel兼容的现代CPU具有旨在提高虚拟化操作系统性能的硬件特性。然而,很明显,即使有硬件辅助,在虚拟环境中运行也会为性能分析和调整带来额外的复杂性。

3.11 The JVM and the Operating System

JVM通过提供Java代码的公共接口,提供了独立于操作系统的可移植执行环境。但是,对于一些基本的服务,比如线程调度(甚至像从系统时钟获取时间这样普通的事情),必须访问底层操作系统。

此功能由本地方法【native method】提供,本地方法由关键字native表示。它们是用C编写的,但是可以作为普通Java方法访问。这个接口称为Java本地接口(JNI)。例如,java.lang.Object声明这些非私有本地方法:

public final native Class<?> getClass();
public native int hashCode();
protected native Object clone() throws CloneNotSupportedException;
public final native void notify();
public final native void notifyAll();
public final native void wait(long timeout) throws InterruptedException;

由于所有这些方法都处理相对低级的平台问题,让我们看一个更简单和熟悉的示例:获取系统时间。

考虑os::javaTimeMillis()函数。这是负责实现Java System.currentTimeMillis()静态方法的(特定于系统的)代码。完成实际工作的代码是用c++实现的,但是可以通过C代码的“桥”从Java访问。让我们看看这段代码在HotSpot中是如何实际调用的。

如图3-11所示,本地System.currentTimeMillis()方法映射到JVM入口点方法JVM_CurrentTimeMillis()。该映射是通过java/lang/System.c中包含的JNI java_java_lang_system_registernative()机制实现的。
在这里插入图片描述
图3-11 HotSpot 调用栈

JVM_CurrentTimeMillis()是对VM入口点方法的调用。它以C函数的形式出现,但实际上是使用C调用约定导出的C ++函数。调用可以归结为调用os::javaTimeMillis(),它封装在两个OpenJDK宏中。

这个方法是在os命名空间中定义的,并不奇怪,它依赖于操作系统。此方法的定义由OpenJDK中特定于os的源代码子目录提供。这提供了一个简单的示例,说明Java中独立于平台的部分如何调用底层操作系统和硬件提供的服务。

3.12 Summary

在过去的20年中,处理器设计和现代硬件发生了巨大变化。 受摩尔定律和工程限制(特别是内存速度相对较慢)的驱动下,处理器设计的进步已经变得有些深奥。 缓存未命中率已经成为衡量应用程序性能的最明显的领先指标。

在Java空间中,JVM的设计允许它使用额外的处理器核心,即使对于单线程应用程序代码也是如此。 这意味着与其他环境相比,Java应用程序从硬件趋势中获得了显着的性能优势。

随着摩尔定律的淡出,人们的注意力将再次转向软件的相对性能。注重性能的工程师至少需要理解现代硬件和操作系统的基本要点,以确保他们能够充分利用自己的硬件,而不是与之对抗。

在下一章中,我们将介绍性能测试的核心方法。我们将讨论性能测试的主要类型、需要执行的任务以及性能工作的整个生命周期。我们还将在性能空间中列出一些常见的最佳实践(和反模式)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值