0 CPU 使用率

使用 vmstat 命令查看 CPU 使用率,即 us\sy\id 三个参数,用户、系统、空闲使用 CPU 的时间。

  1. 检查应用性能时,首先应该审查 CPU 时间(尤其是多线程,CPU 的上下文切换报告)
  2. 优化代码的目的是提升而不是降低(更短时间段内的)CPU 使用率。
  3. 在试图深入优化应用前,应该先弄清楚为何 CPU 使用率低。

1 JIT 编译器

java文件->编译->class字节码文件->JVM编译解释成平台相关的二进制文件。
而 JIT 编译器属于最后的 JVM 编译过程,也可以称为后端编译器,这样便于理解
Java 应用汇被编译——但不是编译成特定 CPU 所专用的二进制代码,而是被编译成一种理想化的汇编语言(即 .class 字节码文件),它专用于 JVM 所执行。这个编译时在程序执行时进行的,即编译同时执行,(C 这种编译语言会先编译成 .o 或者 .obj 再执行),而 Java 是直接执行编译代码(JVM 执行)。Java 是一种半编译半解释语言(先编译成 .class,再让 JVM 解释成特定 CPU 的指令),而 Java 的表面直接执行其实内部 JVM 帮我们做了编译解释,不像 C 用户手动编译再执行,因为 C 的编译后的 .o 文件是针对特定的 CPU ,也许在下个 CPU 就需要重新编译了。参见:https://www.zhihu.com/question/21486706

由于编译成 .class 这个行为是在程序执行的时候进行的,因为被称为“即时编译”(即JIT,just in time)。你也可以先 javac 编译后,再 java 命令 执行。

1.热点编译

官方的 java 实现是 Oracle 的 HotSpot JVM。HotSpot 的名字来与它看待代码编译的方式。对于程序来说,通常只有一部分代码被经常执行,而应用的性能就取决于这些代码执行得有多快。这些关键代码段被称为应用的热点,代码执行得越多就被认为是越热。

因此 JVM 执行代码时,并不会立即编译代码。原因1:如果代码只执行一次,那编译完全就是浪费精力。对于只执行一次的代码,解释执行 Java 字节码比先编译然后执行的速度快。原因2:JVM 执行特定方法或者循环的次数越多,它就会越了解这段代码,使得 JVM 可以在编译代码时进行大量优化。

例,equals() 方法,存在每个 Java 对象中,并且经常被子类重写。当解释器遇到 b = obj1.equals(obj2) 语句时,为了知道该执行哪个 equals() ,必须先查找 obj1 的类。这个动态查找的过程有点消耗时间。

寄存器和主内存:

1
2
3
4
5
6
7
8
9
>public class RegisterTest {
> private int sum;
> public void calculateSum(int n) {
> for(int i = 0; i < n; i++) {
> sum += i;
> }
> }
>}
>

实例变量如果一直存在主内存中,但是从主内存获取数据是非常昂贵的操作,需要花费多个时钟周期才能完成,这样性能就会比较低,编译器就不会这么做,它会将 sum 的初始值装入寄存器,用寄存器中的值执行循环,然后(某个不确定时刻)将最终的结果从寄存器写回主内存。
使用寄存器是编译器普遍采用的优化方法,当开启逃逸分析(escape analysis)时,寄存器的使用更为频繁(详见本章尾)。

比如,随着时间流逝, JVM 发现每次执行这条语句时,obj1 的类型都是 java.lang.String。于是 JVM 就可以生成直接调用 String.equals() 的编译代码。现在代码更快乐,不仅是因为被编译,也是因为跳过了查找该调用哪个方法的步骤。
不过没那么简单,下次执行代码时,obj1 完全有可能是别的类型而不是 String ,所以 JVM 必须生成编译代码处理这种可能,尽管如此,由于跳过了方法查找的步骤,这里的编译代码整体性能仍然要快(至少和 obj1 一直是 String时同样快)。这种优化只有在代码运行过一段时间观察它如何做之后才能使用:这是为何 JIT 编译器等待代码编译的第二个原因。

2 调优入门:选择编译器类型(client/server或两者同用)

有两种 JIT 编译器,client 和 server。两者编译器的最主要的差别在于编译代码的时机不同。
client 编译器开启编译比 server 编译器要早。意味着在代码执行的开始阶段,client 编译器比 server 编译器要快,因为它编译代码相比 server 编译器而言要多。
server 编译器等待编译的时候是否还能做更有价值的事:server 编译器在编译代码时可以更好地进行优化。最终,server 编译器生成的代码要比 client 编译器快。
此处的问题:为什么需要人来做这种选择?为什么 JVM 不能在启动用 client 编译器,然后随着代码变热使用 server 编译器?这种技术被称为分层编译。java7 的分层编译容易超出 JVM 代码缓存的大小,默认关闭。在 java8 分层编译默认为开启。
即时应用永远运行, server 编译器也不可能编译它的所有代码,但是任何程序都有一小部分代码很少执行,最好是编译这些代码——即便编译不是最好的方法——而不是以解释模式运行。

对于长时间运行的应用来说,应该一直使用 server 编译器,最好配合分层编译器。

3 Java 和 JIT 编译器版本

JIT 编译器有 3 种版本:

  1. 32位 client 编译器(-client)
  2. 32位 server 编译器(-server)
  3. 64位 server 编译器(-d64)

4 编译器中级调优

1.调优代码缓存

JVM 编译代码时,会在代码缓存中保存编译之后的汇编语言指令集。代码缓存一旦填满,JVM 就不能编译更多代码了(只能解释执行其余代码了)。
但是,如果设置过多,例如设置代码缓存为 1GB,JVM 就会保留 1GB 的本地内存空间。然后这部分内存在需要时才会分配,但它仍然是保留的,这意味着为了满足保留内存,你的机器必须有足够的虚拟内存。
此外,如果是 32位 JVM,则进程占用的总内存不能超过 4GB。这包括 Java堆、JVM 自身所有嗲吗占用的空间(包括它的本地库和线程栈)、分配给应用的本地内存(或者 NIO 库的直接内存),当然还有代码缓存。
代码缓存: -XX:ReservedCodeCacheSize=N ,可以设置代码缓存的最大值。

2.编译阈值

编译时基于两种 JVM 计数器的:方法调用计数器和方法中的循环回边计数器。回边实际上可看作是循环完成执行的次数。
JVM 执行某个 Java 方法时,会检查该方法的两种计数器总数,然后判定该方法是否适合编译。如果适合就进入编译队列。被称为标准编译。
如果循环真的很长——或因包含所有程序逻辑而永远不退出,JVM 不等方法被调用就会编译循环。所以循环每完成一轮,回边计数器就会增加并被检测。如果循环的回边计数器超过阈值,那这个循环(不是整个方法)就可以被编译。被称为栈上替换(On-Stack Replacement,OSR)。

标准编译由: -XX:CompileThreshold=N 标志触发。

5 高级编译器调优

前面说道,当方法(或循环)适合编译时,就会进入到编译队列。队列则由一个或多个后台线程处理。这是件好事,意味着编译过程是异步的,这使得即便是代码正在编译的时候,程序也能持续执行。如果是用标准编译所编译的方法,那下次调用该方法时就会执行编译后的方法;如果是用 OSR 编译的循环,那下次循环迭代时就会执行编译后的代码。

编译队列并不严格遵守先进先出的原则:调用次数多的方法有更高的优先级(非公平更好使)。

5.1 逃逸分析

开启逃逸分析: -XX:DoEscapeAnalysis,默认为 true。server 编译器将会执行一些非常激进的优化措施,例如, for 循环中的新建变量,如果对象只在循环中引用,JVM 会毫不犹豫地对这个对象进行一系列优化。
包括,锁去除,值存储在寄存器而不是内存中,甚至不需要分配实际的对象,可以只追踪这个对象的个别字段。

6 逆优化

有两种逆优化的情形:代码状态分别为“made not entrant”(代码被丢弃)和“made zombie”(产生僵尸代码)时。

6.1 代码被丢弃

当一个接口有多重实现,在使用switch 进行工厂模式的创建时,可能上次的编译器内联,在下次就必须使用解释执行了,因为对象变了,要开始新的编译,而上次的编译代码就属于丢弃代码。
另一种情况就是,分层编译。先使用 client 编译,再使用 server 编译,那么在第二次编译时,第一次编译的一些代码就要被丢弃,属于丢弃代码。

6.2 逆优化僵尸代码

  1. 逆优化使得编译器可以回到之前版本的编译代码。
  2. 先前的优化不再有效时(例,所设计的对象类型发生了更改),才会发生代码逆优化。
  3. 代码逆优化时,会对性能产生一些小而短暂的影响。

7 小结

  1. 不用担心小方法,特别是 getter 和 setter ,因为它们很容易内联。编译器会修复这些问题。
  2. 需要编译的代码在编译队列中。队列中代码越多,程序达到最佳性能的时间越久。
  3. 虽然代码缓存的大小(也应该)调整,但它仍然是有限的资源。
  4. 代码越简单,优化越多。分析反馈和逃逸分析可以使代码更快,但复杂的循环结果和大方法限制了它的有效性。

很多时候,我们没有机会重写代码,又要面临需要提高 Java 应用性能的压力,这种情况下对垃圾收集器的调优就变得至关重要。

  1. Serial 收集器(常用于单 CPU环境)。
  2. Throughput(或者 Parallel)收集器。
  3. Concurrent 收集器(CMS)。
  4. G1 收集器。

1 垃圾收集概述

简单来说,垃圾收集由两步构成:查找不再使用的对象,以及释放这些对象所管理的内存。 JVM 从查找不再使用的对象(垃圾对象)入手。有时,这也被称为查找不再有任何对象引用的对象(暗指采用“引用计数”的方式统计对象引用)。

例,如下场景:一个程序要分配大小为 1000 字节的数组,紧接着又分配一个大小为 24 字节的数组,并在一个循环中持续进行这样的分配。最终程序会耗尽整个堆,结果如下图的第一行所示:堆空间被沾满,分配的数组间隔地分布于整个堆内:
![][1]
堆内存用尽会触发 JVM 回收不再使用的数组空间。假设所有大小为 24 字节的数组都不再被使用,而大小为 1000 字节的数组还继续使用,这样就形成了上图的第二行的场景。
虽然堆内部有足够的空闲空间,却找不到任何一个大于 24 字节的连续空间,除非 JVM 移动所有大小为 1000 字节的数组,让它们连续存储,把空闲的空间整合成一块更大的连续空间,供其他的内存分配使用(如上图的第三行)。

而垃圾收集的性能就是由这些基本操作所决定的:找到不再使用的对象、回收它们使用的内存、对堆的内存布局进行压缩整理。完成这些操作时不同的收集器采用了不同的方法,这也是不同垃圾收起表现出不同性能特征的原因。
通常垃圾收集器自身往往也是多线程的。接下来的讨论中,我们从逻辑上将县城分成了两组,分别是应用程序线程和处理垃圾收集的线程。垃圾收集器回收对象,或者在内存中移动对象时,必须确保应用程序线程不再继续使用这些对象。这一点在收集器移动对象时尤其重要:在操作过程中,对象的内存地址会发生变化,因此这个过程中任何应用线程都不应再访问该对象。

所有应用线程都停止运行所产生的停顿被称为时空停顿(stop-the-world)。通常这些停顿对应用的性能影响对打,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。

1.1 分代垃圾收集器

虽然实现的细节千差万别,但所有的垃圾收集器都遵循了同一个方式,即根据情况将堆划分成不同的代(Generation)。这些代被称为“老年代”(Old Generation 或 Tenured Generation)和“新生代”(Young Generation)。新生代又被进一步划分为不同的区段,分别称为 Eden 空间和 survivor 空间(不过 Eden 有时会被错误地用于指代整个新生代)。

新生代被填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其它地方。这种操作被称为 Minor GC。
采用这种设计有两个性能上的优势:

  1. 新生代仅是堆的一部分,这意味着应用线程停顿的时间更短。但是更加频繁。
  2. 对象分配与 Eden 空间,垃圾收集时,新生代空间被清空,Eden 空间的对象要么被移走,要么移动到另一个 Survivor 空间,要么被移动到老年代。这就相当于自动的进行了一次压缩整理。

所有的垃圾收集算法在对新生代回收时都存在“时空停顿”现象。

JVM 需要找出老年代中不再使用的对象,并对它们进行回收。而这便是垃圾收集算法差异最大的地方。简单的:停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对对空间进行整理。这个过程称为 Full GC。这通常会导致应用线程长时间的停顿。

另,通过更复杂的计算,我们还有可能在应用线程运行的同时找出不再使用的对象;
CMS 和 G1 收集器就是通过这种方式进行垃圾收集的。由于它们不需要停止应用线程就能找出不再用的对象, CMS 和 G1 收集器被称为 Concurrent 垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿(Low-Pause)收集器。Concurrent 收集器也使用各种不同的方法对老年代空间进行压缩。

使用 CMS 和 G1 收集器时,应用程序经历的停顿会更少(也更短),代价是会消耗更多的 CPU。

  1. 所有的 GC 算法都将堆划分成了老年代和新生代。
  2. 所有的 GC 算法在清理新生代对象时,都使用了“时空停顿”(stop-the-world)方式的垃圾收集方法。

1.2 GC 算法

JVM 提供了以下四种不同的垃圾收集算法。

1.Serial 垃圾收集器

它是单线程清理堆的内容。使用 Serial 垃圾收集器,无论是进行 Minor GC 还是 Full GC ,清理堆空间时,所有的应用线程都会被暂停。

2.Throughput 垃圾收集器

Throughput 收集器是 Server 级虚拟机(多 CPU)的默认收集器。
使用多线程回收新生代空间, Minoc GC 的速度比使用 Serial 收集器快得多。处理老年代在 JDK7 之后默认也是多线程。因为其使用多线程,也被称为 Parallel 收集器。
在 Minor GC 和 Full GC 时会暂停所有的应用线程,同时在 Full GC 过程中会对老年代空间进行压缩整理。

3.CMS 收集器

CMS 收集器设计的初衷是为了消除 Throughput 收集器和 Serial 收集器 Full GC 周期中的长时间停顿。 CMS 收集器在 Minor GC 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。
它不使用 Throughput 的收集算法(-XX:+UseParallelGC),而是用新的算法(-XX:+UseParNewGC)来收集新生代对象。
它在 Full GC 不再暂停应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象。这种算法使 CMS 成为一个低延迟的收集器:应用线程只在 Minor GC 以后后台线程扫描老年代时发生极其短暂的停顿。
代价是额外的 CPU 使用率。而且后台线程不再进行任何压缩整理的工作,这意味着逐渐碎片化,碎片化一定程度, CMS 会降级为 Serial 收集器:暂停所有应用线程,使用单线程回收。之后再恢复到并发回收。(这种思想在写锁降级为读锁也有体现)。

4.G1 收集器

G1 垃圾收集器(垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于4 GB)时产生的停顿。G1 收集算法将老年堆划分为若干个区域(Region),不过它依旧属于分代收集器。这些区域中的一部分包含新生代,新生代的垃圾收集仍然采用暂停所有应用线程的方法,将存活对象移动到老年代或者 Survivor 空间,这也是多线程完成的。

G1 收集器属于 Concurrent 收集器:老年代的垃圾收集工作由后台线程完成,大多数的工作不需要暂停应用线程。由于老年代被划分到不同的区域,G1 收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作,这也意味着在正常的处理过程中,G1 收集器实现了堆的压缩整理(至少是部分的整理)。因此,使用G1 收集器的堆不大容易发生碎片化——虽然这种问题无法避免。

通常情况下垃圾收集是由 JVM 在需要的时候触发:新生代用尽时会触发 Minor GC,老年代用尽时会触发 Full GC,或者堆空间即将填满时会触发 Concurrent 垃圾收集。
System.gc() 让应用程序强制进行 GC, Full GC。应用程序线程会因此而停顿相当长的一段时间。同时,调用这个方法也不会让应用程序更高效,它会让 GC 更早的开始,但那实际只是将性能的影响往后推迟而已。

2 GC 调优基础

2.1 调整堆的大小

如果分配的堆过于小,程序的大部分时间可能都消耗在 GC 上。
如果分配的过于大也不行,GC 停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长,这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。还有一个风险是,操作系统使用虚拟内存机制管理机器的物理内存。一台机器可能有 8G 的物理内存,不过操作系统可能让你感觉有更多的可用内存。虚拟内存的数量取决于操作系统的设置,譬如操作系统可能让你感觉它的内存达到了 16G 。操作系统通过名为“交换”(swapping)(或者称之为分页,虽然两者技术存在差异)。你可以载入需要 16G 内存的应用程序,操作系统在需要时会将程序运行时不活跃的数据由内存复制到磁盘。再次需要这部分内存的内容时,操作系统再将它们由磁盘重新载入到内存(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)。

系统中运行着大量不同的应用程序时,这个流程工作的很顺畅,因为大多数的应用程序不会同时处于活跃状态。但是,对于 Java 应用,它工作得并不那么好。如果一个 Java 应用使用了这个系统上大约 12G 的堆,操作系统可能在 RAM 上分配了 8G 的堆空间,另外 4G 的空间存在于磁盘。这样操作系统需要将相当一部分的数据由磁盘交换到内存,而发生 Full GC 时,因为 JVM 必须访问整个堆的内容,如果系统发生内存交换,停顿时间会更长。

堆的大小由 2 个参数值控制:初始值(-Xms)、最大值(-Xmx)。

2.2 代空间的调整

一旦堆的大小确定下来,JVM 就需要决定分配多少堆给新生代空间,多少给老年代空间。
必须清楚:如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。但是老年代相对比较小,容易填满,会更频繁的触发 Full GC。

  1. -XX:NewRatio=N:设置新生代与老年代的空间占用比率
  2. -XX:NewSize=N:设置新生代空间的初始大小
  3. -XX:MaxNewSize=N:设置新生代空间的最大大小
  4. -XmnN:将 NewSize 和 MaxNewSize 设定为同一个值的快捷方法。

2.3 永久代和元空间的调整

JVM 载入类的时候,它需要记录这些类的元数据。这部分数据被保存在一个单独的堆空间中。在 Java7 里,这部分空间被称为永久代(Permgen),在 Java8 中,它们被称为元空间(Metaspace)。
永久代和元空间并不完全一样。Java7 中永久代还保存了一些与类数据无关的杂项对象;这些对象在 Java8 中被挪到了普通的堆空间内。它们保存的信息只对编译器或者 JVM 的运行时有用。
通过 -XX:PermSize=N、-XX:MaxPerSize=N 来调整永久代大小。
通过 -XX:MetaspaceSize=N、-XX:MaxMetaspaceSize=N 来调整元空间的大小。

调整这些区间会触发 Full GC ,所以是一种代价昂贵的操作。如果程序在启动时发生大量的 Full GC(因为需要载入数量巨大的类),通常都是由于永久代或者元空间发生了大小调整。

3 垃圾回收工具

开启 GC 的日志功能:使用 -verbose:gc 或 -XX:+PrintGC 的任意一个能创建基本的 GC 日志。使用 -XX:+PrintGCDetails 创建更详细的 GC 日志。

4 小结

多说无益,多尝试。

![1]: http://leran2deeplearnjavawebtech.oss-cn-beijing.aliyuncs.com/learn/Java_performance_definitive_guide/5_1.png

1 原则1:测试真实应用

应该在产品实际使用的环境中进行性能测试。

1.1 微基准测试

1. 必须使用被测的结果

例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void doTest() {
int nLoops = 50;
double l;
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
l = fibImpl1(50);
}
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));
}

private double fibImpl1(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n == 0) return 0d;
if (n == 1) return 1d;
double d = fibImpl1(n - 2) + fibImpl1(n - 1);
if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
return d;
}

这段代码的问题在于,它实际永远都不会改变程序的任何状态。因为斐波那契的计算结果从来没有被使用,所以编译器可以很放心地去除计算结果。智能的编译器(包括当前的 Java7 和 Java8)最终执行的以下代码:

1
2
3
long then = System.currentTimeMillis();
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));

解决在于,确保读取被测结果,而不只是简单地写。实际上,将局部变量 l 的定义改为实例变量(并用关键字 volatile 声明)就能测试这个方法的性能了。(必须声明 volatile 的原因参见第九章)。

2. 不要包括无关的操作

多余的循环迭代的多余的,如果编译器足够智能的话,就能发现这个问题,从而只执行一遍循环。
另外,fibImpl(1000) 的性能可能与 fibImpl(1) 相差很大。如果目的是为了比较不同实现的性能,测试的输入就应该考虑用一系列数据。如下:

1
2
3
for(int i = 0; i < nLoops; i++) {
l = fibImpl1(random.nextInteger());
}

但是,微基准测试中的输入值必须事先计算好。

3. 必须输入合理的参数

此时还有第三个隐患:任意选择的随机输入值对于这段被测代码的用法来说并不具有代表性,实际用户可能只输入 100 以下的值。输入参数大于 1476 时,会抛出异常,因为此时计算出的是 double 类所能表示的最大斐波那契数。考虑如下实现:

1
2
3
4
5
public double fibImplSlow(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n > 1476) throw new ArithmeticException("Must be < 1476");
return verySlowImpl(n);
}

虽然很难想象会有比原先用递归更慢的实现,但我们假定有这个实现。通过大量输入值比较这两种实现(fibImplSlow 和 verySlowImpl),发现前者比后者快得多——仅仅因为在方法开始时进行了范围检查。
如果在真实场景中,用户只会传入小于 100 的值,那这个比较就是不正确的。(仅仅在原先的实现上添加了边界测试就使得性能变好,通常这是不可能的)。

Java 的一个特点就是代码执行的越多性能越好,第四章详解。基于这点,微基准测试应该包括热身期,使得编译器能生成优化的代码。
微基准测试需要热身期,否则测量的是编译而不是被测代码的性能了。

综上,正确的微基准测试代码可能是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class FibonacciTest {
private volatile double l;
private int nLoops;
private int[] input;

public static void main(String[] args) {
FibonacciTest ft = new FibonacciTest(Integer.parseInt(args[0]));
ft.doTest(true);
ft.doTest(false);
}

private FibonacciTest(int n) {
nLoops = n;
input = new int[nLoops];
Random r = new Random();
for (int i = 0; i < nLoops; i++) {
input[i] = r.nextInt(100);
}
}

private void doTest(boolean isWarmup) {
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
l = fibImpl1(input[1]);
}
if (!isWarmup) {
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));
}
}

private double fibImpl1(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n == 0) return 0d;
if (n == 1) return 1d;
double d = fibImpl1(n - 2) + fibImpl1(n - 1);
if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
return d;
}
}

调用 fibImpl1() 的循环和方法开销,将每个结果都写入 volatile 变量中的额外开销导致测量结果有出入。
此外还要留意编译效应:频繁调用的方法、调用时的栈深度、方法参数的实际类型等,它还依赖代码实际运行的环境。
这里的基准测试,有大量的循环,整体时间以秒计,但每轮循环迭代通常是纳秒级。纳秒累计起来,“极少成都”就会成为频繁出现的性能问题。
特别是在做回归测试的时候,追踪级别设为纳秒很有意义。如果集合操作每次都节约几纳秒,日积月累下来意义就很重大了(第十二章详解),但是,对于那些不频繁的操作来说,例,同时只需要处理一个请求的 servlet ,修复微基准测所发现的纳秒级性能衰弱就是浪费时间(后者才是过度优化!)。

2 原则2:理解批处理流逝时间,吞吐量和响应时间

在客户端——服务器的吞吐量测试中,并不考虑客户端的思考时间。客户端向服务器发送请求,当它收到响应时,立刻发送新的请求。
指标常常被称为每秒事务数(TPS)、每秒请求数(RPS)、每秒操作数(OPS)。