博客记录-day52-JIT即时编译器+Linux 收发网络包

135 阅读35分钟

一、沉默王二-JVM

1、JIT即时编译器

Java 代码首先被编译为字节码,JVM 在运行时通过解释器执行字节码。当某部分的代码被频繁执行时,JIT 会将这些热点代码编译为机器码,以此来提高程序的执行效率。

那为什么 JIT 就能提高程序的执行效率呢,解释器不也是将字节码翻译为机器码交给操作系统执行吗?

解释器在执行程序时,对于每一条字节码指令,都需要进行一次解释过程,然后执行相应的机器指令。这个过程在每次执行时都会重复进行,因为解释器不会记住之前的解释结果。

与此相对,JIT 会将频繁执行的字节码编译成机器码。这个过程只发生一次。一旦字节码被编译成机器码,之后每次执行这部分代码时,直接执行对应的机器码,无需再次解释。

除此之外,JIT 生成的机器码更接近底层,能够更有效地利用 CPU 和内存等资源,同时,JIT 能够在运行时根据实际情况对代码进行优化(如内联、循环展开、分支预测优化等),这些优化是在机器码级别上进行的,可以显著提升执行效率。

换句话说,解释器是一个循规蹈矩的人,每次都要按照规则来执行,而 JIT 是一个“偷奸耍滑”的人,他会根据实际情况来做出最优的选择。

好,我们再来梳理一下。

Java 的执行过程分为两步,第一步由 javac 将源码编译成字节码,在这个过程中会进行词法分析、语法分析、语义分析。

第二步,解释器会逐行解释字节码并执行,在解释执行的过程中,JVM 会对程序运行时的信息进行收集,在这些信息的基础上,JIT 会逐渐发挥作用,它会把字节码编译成机器码,但不是所有的代码都会被编译,只有被 JVM 认定为热点代码,才会被编译。

怎么样才会被认为是热点代码呢

JVM 中有一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被认定为热点代码,然后编译存入 codeCache 中。当下次执行时,再遇到这段代码,就会从 codeCache 中直接读取机器码,然后执行,以此来提升程序运行的性能。

整体的执行过程大致如下图所示:

1.1 JVM 的编译器

JVM 中集成了两种编译器,一种是 Client Compiler,另外一种是 Server Compiler

Client Compiler 注重启动速度和局部的优化,Server Compiler 则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会慢一些。

两种编译器相辅相成,互为臂膀,共同把 JVM 的性能带到了一个新的高度。

1.1.1 Client Compiler

就那虚拟机中的太子 HotSpot 来说吧,它就带有一个 Client Compiler,被称为 C1 编译器,启动速度极快。

C1 通常会做这三件事:

①、局部简单可靠的优化,比如在字节码上进行一些基础优化,方法内联、常量传播等。

我们来举例看一下什么是方法内联,假设我们有两个简单的方法:

public class Example {
    public int add(int a, int b) {
        return a + b;
    }

    public void run() {
        int result = add(5, 3);
        System.out.println(result);
    }
}

在执行 run 方法时,会调用 add 方法,方法内联优化后,会将 add 方法的字节码直接插入到 run 方法中,这样就不用再去调用 add 方法了,直接执行 run 方法就可以了。

public class Example {
    public void run() {
        int a = 5;
        int b = 3;
        int result = a + b;  // 这里是内联后的 add 方法体
        System.out.println(result);
    }
}

②、将字节码编译成 HIR(High-level Intermediate Representation),别计较它中文名叫什么,我觉得与其死板的翻译,不如就记住它叫 HIR,一种比较接近源代码的形式。

通过借助 HIR 我们可以实现冗余代码消除、死代码删除等编译优化工作,我们同样通过代码来看一下。

public class OptimizationExample {
    public int calculate(int x, int y) {
        int a = x + y;
        int b = x + y;  // 冗余计算
        return a * b;
    }
}

很明显,上面的代码中,b 的值是可以直接通过 a 的值计算出来的,所以 b 的计算就是冗余的,我们可以通过 HIR 来消除这种冗余计算。

public class OptimizationExample {
    public int calculate(int x, int y) {
        int a = x + y;
        return a * a;  // 使用一个计算结果
    }
}

在 HIR 优化阶段,编译器识别到 x + y 的计算是冗余的,因此它将第二次计算的结果用第一次的结果替换。

③、最后将 HIR 转换成 LIR(Low-level Intermediate Representation),比较接近机器码了。这期间会做一些寄存器分配、窥孔优化等。

寄存器分配是指在编译时将程序中的变量分配到 CPU 的寄存器上。由于寄存器的访问速度远快于内存,因此合理的寄存器分配可以显著提高程序的执行效率。

来看这段代码:

int a = 5;
int b = 10;
int c = a + b;
System.out.println(c);

在没有寄存器优化的情况下,编译器会将变量 a、b、c 分配到内存中,然后在执行时,再从内存中读取变量的值。有了寄存器分配优化呢?

R1 = 5     // 将 5 赋值给寄存器 R1
R2 = 10    // 将 10 赋值给寄存器 R2
R3 = R1 + R2 // 将 R1 和 R2 的和赋值给寄存器 R3

这样,变量 a、b、c 就被分配到了寄存器 R1、R2、R3 上,而不是内存中,寄存器的访问速度远快于内存,所以这样的优化可以提高程序的执行效率。

窥孔优化(Peephole Optimization)是一种在生成机器码阶段进行的局部优化技术。编译器“窥视”一小段生成的机器码,并尝试找出并替换更高效的指令序列。

假设有这样一段简单的机器码:

MOV R1, 0
ADD R1, 5

这段代码首先将寄存器 R1 置零,然后再将 5 加到 R1 上,窥孔优化会将这两条指令合并成一条:

MOV R1, 5

这样,仅用一条指令就完成了同样的操作,显然会提高代码执行的效率。

1.1.2 Server Compiler

Server Compiler 主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化。这种编译器的启动时间长,适用于长时间运行的后台程序,它的性能通常比 Client Compiler 高 30%以上。目前,Hotspot 虚拟机中使用的 Server Compiler 有两种:C2 和 Graal。

1.1.3 C2 Compiler

Hotspot 中,默认的 Server Compiler 是 C2 编译器。

C2 编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为 Ideal Graph,我愿称之为“理想图”。

Ideal Graph 表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构,某些优化步骤(尤其是涉及浮动代码块的优化步骤)会变得不那么复杂。

解析字节码的时候,C2 会向一个空的 Graph 中添加节点,Graph 中的节点通常对应一个指令块,每个指令块包含多条相关联的指令,JVM 会利用一些优化技术对这些指令进行优化,比如 Global Value Numbering、常量折叠等,解析结束后,还会进行一些死代码剔除的操作。

生成 Ideal Graph 后,会在这个基础上结合收集到的程序运行信息来进行一些全局的优化。

无论是否进行全局优化,Ideal Graph 都会被转化为一种更接近机器层面的 MachNode Graph,最后编译的机器码就是从 MachNode Graph 中得到的。

1.1.4 Graal Compiler

从 JDK 9 开始,Hotspot 中集成了一种新的 Server Compiler,也就是 Graal 编译器。相比 C2,Graal 有这样几种关键特性:

①、JVM 会在解释执行的时候收集程序运行的各种信息,然后根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal 比 C2 更加青睐这种优化,所以 Graal 的峰值性能通常要比 C2 更好。

②、与 C2(主要用 C++ 编写)不同,Graal 使用 Java 语言编写。这样做的好处是,Graal 可以直接使用 JVM 的内存管理机制,不需要像 C2 那样自己实现内存管理,这样就可以避免一些内存管理上的问题。

③、Graal 引入了许多现代化的编译优化技术,例如更复杂的内联策略、循环优化等,这些在某些情况下可以比 C2 产生更优化的代码。

④、改进的逃逸分析有助于更好地进行栈上分配和锁消除,从而提升性能。

⑤、Graal 不仅能作为 JIT 编译器使用,还支持 Ahead-of-Time(AOT)编译,这有助于减少 Java 应用的启动时间和内存占用。

Graal 编译器可以通过 JVM 参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 启用。当启用时,它将替换掉 HotSpot 中的 C2,并响应原本由 C2 负责的编译请求。

1.2 JVM 的分层编译

Java 7 引入了分层编译的概念,它结合了 C1 和 C2 的优势,追求启动速度和峰值性能的一个平衡。分层编译将 JVM 的执行状态分为了五个层次。五个层级分别是:

Java 7 中引入的分层编译(Tiered Compilation)确实是一种结合了 C1 编译器(Client Compiler)和 C2 编译器(Server Compiler)优势的技术。分层编译旨在优化 Java 程序的启动速度和长期运行时的性能。这一机制通过在不同的层级应用不同的编译策略,以达到快速启动和最高性能的平衡。在 HotSpot JVM 中,分层编译将程序执行状态分为五个层次:

  1. 层级 0 - 解释器(Interpreter) :这是程序最初执行的阶段,代码通过解释器逐行解释执行。这一阶段的目的是尽快开始执行而不等待编译完成。
  2. 层级 1 - C1 编译器带有轻量级优化(C1 with Simple Optimizations) :在这一层级,代码首次由 C1 编译器编译,应用了一些基本的优化,如方法内联。这一阶段的编译速度较快,能迅速提供优于解释执行的性能。
  3. 层级 2 - C1 编译器带有完整优化(C1 with Full Optimizations) :此层级仍由 C1 编译器处理,但应用了更多优化技术,如逃逸分析。虽然这些优化需要更长的编译时间,但能进一步提升运行性能。
  4. 层级 3 - C1 编译器带有分析数据收集(C1 with Profiling) : 在这个层级,C1 编译器除了执行优化,还收集方法执行的详细分析数据(如分支频率、热点代码等)。这些数据将用于 C2 编译器的后续优化。
  5. 层级 4 - C2 编译器优化(C2 Optimizations) :最终阶段由 C2 编译器处理,它使用收集的分析数据进行深入优化。C2 编译器的优化更加彻底和复杂,适用于长时间运行的代码,能够提供最佳的运行性能。

下图中列举了几种常见的编译路径:

1)图中第 ① 条路径,代表编译的一般情况,热点方法从解释执行到被 3 层的 C1 编译,最后被 4 层的 C2 编译。

2)如果方法比较小(比如 getter/setter),3 层的 profiling 没有收集到有价值的数据,JVM 就会断定该方法对于 C1 代码和 C2 代码的执行效率相同,就会执行图中第 ② 条路径。

在这种情况下,JVM 会在 3 层编译之后,放弃进入 C2 编译,直接选择用 1 层的 C1 编译运行。

3)在 C1 忙碌的情况下,执行图中第 ③ 条路径,在解释执行过程中对程序进行 profiling,根据信息直接由第 4 层的 C2 编译。

4)C1 中的执行效率是 1 层>2 层>3 层,第 3 层一般要比第 2 层慢 35%以上,所以在 C2 忙碌的情况下,执行图中第 ④ 条路径。这时方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。

5)如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行,图中第 ⑤ 条执行路径代表的就是反优化。

分层编译通过在不同的阶段应用不同程度的优化,既提供了较快的应用启动时间,又确保了长时间运行的应用能达到峰值性能。这种动态适应的编译策略是 Java 平台持续优化性能的关键手段之一。

从 JDK 8 开始,JVM 默认开启分层编译。

1.3 JIT 的触发

JVM 根据方法的调用次数以及循环回边的执行次数来触发 JIT

循环回边是一个控制流图中的概念,程序中可以简单理解为往回跳转的指令,比如下面这段代码:

public void nlp(Object obj) {
  int sum = 0;
  for (int i = 0; i < 200; i++) {
    sum += i;
  }
}

上面这段代码经过编译生成下面的字节码。

public void nlp(java.lang.Object);
    Code:
       0: iconst_0
       1: istore_1
       2: iconst_0
       3: istore_2
       4: iload_2
       5: sipush        200
       8: if_icmpge     21
      11: iload_1
      12: iload_2
      13: iadd
      14: istore_1
      15: iinc          2, 1
      18: goto          4
      21: return

其中,偏移量为 18 的字节码将往回跳至偏移量为 4 的字节码中。在解释执行时,每当运行一次该指令,JVM 便会将该方法的循环回边计数器加 1。

在即时编译过程中,编译器会识别循环的头部和尾部。上面这段字节码中,循环体的头部和尾部分别为偏移量为 11 的字节码和偏移量为 15 的字节码。编译器将在循环体结尾增加循环回边计数器的代码,来对循环进行计数。

当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时,就会触发即时编译。

开启分层编译的情况下,-XX:CompileThreshold 参数设置的阈值将会失效,触发及时编译会由以下的条件来判断:

  • 方法调用次数大于由参数-XX:TierXInvocationThreshold 指定的阈值乘以系数。
  • 方法调用次数大于由参数-XX:TierXMINInvocationThreshold 指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold 指定的阈值乘以系数时。

分层编译触发条件公式(i 为调用次数,b 是循环回边次数,s 是系数):

满足其中一个条件就会触发即时编译,并且 JVM 会根据当前的编译方法数以及编译线程数动态调整系数 s。

1.4 JIT 的编译优化

即时编译器会对正在运行的程序进行一系列优化,包括:

  • 字节码解析过程中的分析
  • 根据编译过程中代码的一些中间形式来做局部优化
  • 根据程序依赖图进行全局优化

最后才会生成机器码。

1.4.1 中间表达形式

在编译原理中,通常会把编译器分为前端和后端,前端编译经过词法分析、语法分析、语义分析生成中间表达形式 IR(Intermediate Representation),后端会对 IR 进行优化,生成目标代码。

Java 字节码就是一种 IR,但是字节码的结构复杂,也不适合做全局的分析优化。

现代编译器一般采用图结构的 IR,也就是所谓的静态单赋值——Static Single Assignment,SSA 是目前比较常用的一种 IR。这种 IR 的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。

举个例子:

{
  a = 1;
  a = 2;
  b = a;
}

我们可以轻易地发现 a = 1 的赋值是冗余的。传统的编译器需要借助数据流分析,从后至前依次确认哪些变量的值被覆盖掉了。不过,如果借助了 SSA IR,编译器则可以很容易识别冗余赋值。

上面代码的 SSA IR 形式的伪代码可以表示为:

{
  a_1 = 1;
  a_2 = 2;
  b_1 = a_2;
}

由于 SSA IR 中每个变量只能赋值一次,所以代码中的 a 在 SSA IR 中会分成 a_1、a_2 两个变量来赋值,这样编译器就可以很容易通过扫描这些变量来发现 a_1 的赋值后并没有使用,由此认定该赋值是冗余的。

除此之外,SSA IR 对其他优化方式也有很大的帮助,例如下面这个死代码删除(Dead Code Elimination)的例子:

public void DeadCodeElimination{
  int a = 2;
  int b = 0
  if(2 > 1){
    a = 1;
  } else{
    b = 2;
  }
  add(a,b)
}

可以得到 SSA IR 伪代码:

a_1 = 2;
b_1 = 0
if true:
  a_2 = 1;
else
  b_2 = 2;
add(a,b)

编译器通过执行字节码可以发现 else 分支不会被执行。经过死代码删除后就可以得到代码:

public void DeadCodeElimination{
  int a = 1;
  int b = 0;
  add(a,b)
}

我们可以将编译器的每一种优化看成一个图优化算法,它接收一个 IR 图,并输出经过转换后的 IR 图。编译器优化的过程就是一个个图节点的优化串联起来的。

1.4.2 C1 的 HIR

C1 编译器内部使用高级中间表达形式 HIR,低级中间表达形式 LIR 来进行各种优化,这两种 IR 都是 SSA 形式的。

HIR 是由很多基本块(Basic Block)组成的控制流图结构,每个块包含很多 SSA 形式的指令。基本块的结构如下图所示:

其中,predecessors 表示前驱基本块,由于前驱可能是多个,所以是 BlockList 结构,由多个 BlockBegin 组成的可扩容数组。

同样,successors 表示多个后继基本块 BlockEnd。

除了这两部分就是主体块,里面包含程序执行的指令和一个 next 指针,指向下一个执行的主体块。

从字节码到 HIR 的构造最终调用的是 GraphBuilder,GraphBuilder 会遍历字节码,将所有代码基本块存储为一个链表结构,但是这个时候的基本块只有 BlockBegin,不包括具体的指令。

第二步 GraphBuilder 会用一个 ValueStack 作为操作数栈和局部变量表,模拟执行字节码,构造出对应的 HIR,填充之前空的基本块,这里给出简单字节码块构造 HIR 的过程示例,如下所示:

字节码                     Local Value             operand stack              HIR
5: iload_1                  [i1,i2]                 [i1]
6: iload_2                  [i1,i2]                 [i1,i2]
                          ................................................   i3: i1 * i2
7: imul
8: istore_3                 [i1,i2,i3]              [i3]

可以看出,当执行 iload_1 时,操作数栈压入变量 i1,执行 iload_2 时,操作数栈压入变量 i2,执行相乘指令 imul 时弹出栈顶两个值,构造出 HIR i3 : i1 * i2,生成的 i3 入栈。

C1 编译器的大部分优化工作都是在 HIR 之上完成的。当优化完成之后它会将 HIR 转化为 LIR,LIR 和 HIR 类似,也是一种编译器内部用到的 IR,HIR 通过优化消除一些中间节点就可以生成 LIR,形式上更加简化。

1.4.3 C2 的 Sea-of-Nodes IR

C2 编译器中的 Ideal Graph 采用的是一种名为 Sea-of-Nodes 中间表达形式,同样也是 SSA 形式。

它最大的特点是去除了变量的概念,直接采用值来进行运算。为了方便理解,可以利用 IR 可视化工具 Ideal Graph Visualizer(IGV),来展示具体的 IR 图。比如下面这段代码:

public static int foo(int count) {
  int sum = 0;
  for (int i = 0; i < count; i++) {
    sum += i;
  }
  return sum;
}

对应的 IR 图如下所示:

B0 基本块中 0 号 Start 节点是方法入口,B3 中 21 号 Return 节点是方法出口。

红色加粗线条为控制流,蓝色线条为数据流,其他颜色的线条则是特殊的控制流或数据流。

被控制流所连接的是固定节点,其他的则是浮动节点。

依赖于这种图结构,通过收集程序运行的信息,JVM 可以通过 Schedule 那些浮动节点,从而获得最好的编译效果。

1.4.4 方法内联

来看下面这段代码:

public static boolean flag = true;
public static int value0 = 0;
public static int value1 = 1;
​
public static int foo(int value) {
    int result = bar(flag);
    if (result != 0) {
        return result;
    } else {
        return value;
    }
}

public static int bar(boolean flag) {
    return flag ? value0 : value1;
}

来看一下 bar 方法的 IR 图:

内联后的 IR 图:

内联将被调用方法的 IR 图节点复制到调用者方法的 IR 图中。在这个例子中,bar 方法的 IR 图中的 0 号 Start 节点被复制到了 foo 方法的 IR 图中,从而避免了方法调用的开销。

1.4.5 逃逸分析

逃逸分析是 JIT 用于优化内存管理和同步操作的重要技术。通过分析对象是否逃逸到方法或线程的外部,编译器可以做出更智能的存储和同步决策。

逃逸分析通常是在方法内联的基础上进行的,JIT 可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。

下面这段代码的就是对象未逃逸的例子:

public class Example {
    public static void main(String[] args) {
        example();
    }

    public static void example() {
        Foo foo = new Foo();
        Bar bar = new Bar();
        bar.setFoo(foo);
    }
}

class Foo {}

class Bar {
    private Foo foo;
    public void setFoo(Foo foo) {
        this.foo = foo;
    }
}

在这个例子中,example 方法创建了两个对象:Foo 和 Bar。然后,Bar 对象通过 setFoo 方法引用了 Foo 对象。

  1. Foo 对象的逃逸情况

    • Foo 对象被创建并传递给 Bar 对象的 setFoo 方法。
    • 一旦 setFoo 方法被调用,Foo 对象的引用存储在 Bar 对象的实例变量 foo 中。
    • 但是,Bar 对象本身在 example 方法结束后就不再使用。
    • 这意味着即使 Foo 对象的引用被存储在另一个对象中,但由于 Bar 对象本身也不会逃逸出 example 方法,因此 Foo 对象实际上也没有逃逸。
  2. Bar 对象的逃逸情况

    • Bar 对象在 example 方法中被创建并使用,但之后没有被传递到其他方法或返回。
    • 因此,Bar 对象也没有逃逸出 example 方法。

根据逃逸分析的结果,JIT 可能做出以下优化决策:

①、锁消除:如果 Foo 或 Bar 类中有同步块(使用 synchronized),由于对象没有逃逸,编译器可以安全地消除这些锁操作。

②、栈上分配:由于 Foo 和 Bar 对象都没有逃逸到方法之外,编译器可以选择在栈上分配这两个对象,而非在堆上分配。这样可以提高内存分配的效率,并减少垃圾收集器的压力。

我们都知道 Java 的对象是在堆上分配的,而堆是对所有对象可见的。同时,JVM 需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。

如果逃逸分析能够证明某些新建的对象不逃逸,那么 JVM 完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈来自动回收所分配的内存空间

这样一来,我们便无须借助垃圾收集器来处理不再被引用的对象。

不过 Hotspot 并没有进行实际的栈上分配,而是使用了标量替换的技术。

③、标量替换(Scalar Replacement)

标量替换是一种优化技术,其中编译器将一个聚合对象分解为其各个字段。如果这个对象没有逃逸出方法,那么它的各个字段可以视为独立的局部变量

这种技术允许编译器进行更细粒度的优化,如更好的寄存器分配和减少不必要的内存分配。

考虑这段代码:

public class Example {

    @AllArgsConstructor
    static class Cat {
        int age;
        int weight;
    }

    public static void example() {
        Cat cat = new Cat(1, 10);
        addAgeAndWeight(cat.age, cat.weight);
    }

    public static void addAgeAndWeight(int age, int weight) {
        // 对年龄和体重进行一些操作
    }

    public static void main(String[] args) {
        example();
    }
}
  1. 对象的使用范围

    • 在 example 方法中创建了一个 Cat 对象,并将其字段 age 和 weight 传递给了 addAgeAndWeight 方法。
    • Cat 对象在 example 方法中创建且只在该方法中使用,没有被传递到方法外部或赋值给外部引用。
  2. 逃逸分析

    • 由于 Cat 对象在方法外部没有引用,它没有逃逸出 example 方法的作用域。
    • 这意味着 Cat 对象是一个局部对象,适合进行标量替换
  3. 标量替换的应用

    • JVM 的 JIT 编译器会分析 Cat 对象的使用情况。基于逃逸分析,编译器可以决定不在堆上分配 Cat 对象,而是将其分解为两个独立的局部变量 age 和 weight
    • 这样,原本由 Cat 对象占用的堆空间就被节省下来,而且减少了垃圾回收的压力。
  4. 优化后的执行

    • 在执行 example 方法时,Cat 对象的字段 age 和 weight 直接作为栈上的局部变量处理,避免了堆分配。

标量替换后的伪代码如下所示:

public class Example {
    public static void example() {
        int catAge = 1;
        int catWeight = 10;
        addAgeAndWeight(catAge, catWeight);
    }

    public static void addAgeAndWeight(int age, int weight) {
        // 方法实现
    }
}

可以看到,Cat 对象被分解为两个局部变量 catAge 和 catWeight,并且直接作为参数传递给了 addAgeAndWeight 方法。

1.4.6 窥孔优化与寄存器分配

窥孔优化就是将编译器所生成的中间代码中的某些组合替换为效率更高的指令组,比如强度削减、常数合并等,看下面这个例子就是一个强度削减的例子:

y1=x1*3  
经过强度削减后得到  
y1=(x1<<1)+x1

编译器使用移位和加法削减乘法的强度,使用更高效率的指令组。

寄存器分配也是一种编译的优化手段,在 C2 编译器中普遍的使用。它是通过把频繁使用的变量保存在寄存器中,CPU 访问寄存器的速度比内存快得多,可以提升程序的运行速度。

经过寄存器分配和窥孔优化之后,程序就会被转换成机器码保存在 codeCache 中。

二、小林-图解网络-Linux 系统是如何收发网络包的

网络模型

为了使得多种设备能通过网络相互通信,和为了解决各种不同设备在网络互联中的兼容性问题,国际标准化组织制定了开放式系统互联通信参考模型(Open System Interconnection Reference Model),也就是 OSI 网络模型,该模型主要有 7 层,分别是应用层、表示层、会话层、传输层、网络层、数据链路层以及物理层。

每一层负责的职能都不同,如下:

  • 应用层,负责给应用程序提供统一的接口;
  • 表示层,负责把数据转换成兼容另一个系统能识别的格式;
  • 会话层,负责建立、管理和终止表示层实体之间的通信会话;
  • 传输层,负责端到端的数据传输;
  • 网络层,负责数据的路由、转发、分片;
  • 数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址;
  • 物理层,负责在物理网络中传输数据帧;

由于 OSI 模型实在太复杂,提出的也只是概念理论上的分层,并没有提供具体的实现方案。

事实上,我们比较常见,也比较实用的是四层模型,即 TCP/IP 网络模型,Linux 系统正是按照这套网络模型来实现网络协议栈的。

TCP/IP 网络模型共有 4 层,分别是应用层、传输层、网络层和网络接口层,每一层负责的职能如下:

  • 应用层,负责向用户提供一组应用程序,比如 HTTP、DNS、FTP 等;
  • 传输层,负责端到端的通信,比如 TCP、UDP 等;
  • 网络层,负责网络包的封装、分片、路由、转发,比如 IP、ICMP 等;
  • 网络接口层,负责网络包在物理网络中的传输,比如网络包的封帧、 MAC 寻址、差错检测,以及通过网卡传输网络帧等;

TCP/IP 网络模型相比 OSI 网络模型简化了不少,也更加易记,它们之间的关系如下图:

不过,我们常说的七层和四层负载均衡,是用 OSI 网络模型来描述的,七层对应的是应用层,四层对应的是传输层。

1、Linux 网络协议栈

我们可以把自己的身体比作应用层中的数据,打底衣服比作传输层中的 TCP 头,外套比作网络层中 IP 头,帽子和鞋子分别比作网络接口层的帧头和帧尾。

在冬天这个季节,当我们要从家里出去玩的时候,自然要先穿个打底衣服,再套上保暖外套,最后穿上帽子和鞋子才出门,这个过程就好像我们把 TCP 协议通信的网络包发出去的时候,会把应用层的数据按照网络协议栈层层封装和处理。

你从下面这张图可以看到,应用层数据在每一层的封装格式。

其中:

  • 传输层,给应用数据前面增加了 TCP 头
  • 网络层,给 TCP 数据包前面增加了 IP 头
  • 网络接口层,给 IP 数据包前后分别增加了帧头和帧尾

这些新增的头部和尾部,都有各自的作用,也都是按照特定的协议格式填充,这每一层都增加了各自的协议头,那自然网络包的大小就增大了,但物理链路并不能传输任意大小的数据包,所以在以太网中,规定了最大传输单元(MTU)是 1500 字节,也就是规定了单次传输的最大 IP 包大小。

当网络包超过 MTU 的大小,就会在网络层分片,以确保分片后的 IP 包不会超过 MTU 大小,如果 MTU 越小,需要的分包就越多,那么网络吞吐能力就越差,相反的,如果 MTU 越大,需要的分包就越少,那么网络吞吐能力就越好。

知道了 TCP/IP 网络模型,以及网络包的封装原理后,那么 Linux 网络协议栈的样子,你想必猜到了大概,它其实就类似于 TCP/IP 的四层结构:

从上图的的网络协议栈,你可以看到:

  • 应用程序需要通过系统调用,来跟 Socket 层进行数据交互;
  • Socket 层的下面就是传输层、网络层和网络接口层;
  • 最下面的一层,则是网卡驱动程序和硬件网卡设备;

2、Linux 接收网络包的流程

网卡是计算机里的一个硬件,专门负责接收和发送网络包,当网卡接收到一个网络包后,会通过 DMA 技术,将网络包写入到指定的内存地址,也就是写入到 Ring Buffer ,这个是一个环形缓冲区,接着就会告诉操作系统这个网络包已经到达。

那应该怎么告诉操作系统这个网络包已经到达了呢?

最简单的一种方式就是触发中断,也就是每当网卡收到一个网络包,就触发一个中断告诉操作系统

但是,这存在一个问题,在高性能网络场景下,网络包的数量会非常多,那么就会触发非常多的中断,要知道当 CPU 收到了中断,就会停下手里的事情,而去处理这些网络包,处理完毕后,才会回去继续其他事情,那么频繁地触发中断,则会导致 CPU 一直没完没了的处理中断,而导致其他任务可能无法继续前进,从而影响系统的整体效率。

所以为了解决频繁中断带来的性能开销,Linux 内核在 2.6 版本中引入了 NAPI 机制,它是混合「中断和轮询」的方式来接收网络包,它的核心概念就是不采用中断的方式读取数据,而是首先采用中断唤醒数据接收的服务程序,然后 poll 的方法来轮询数据。

因此,当有网络包到达时,会通过 DMA 技术,将网络包写入到指定的内存地址,接着网卡向 CPU 发起硬件中断,当 CPU 收到硬件中断请求后,根据中断表,调用已经注册的中断处理函数。

硬件中断处理函数会做如下的事情:

  • 需要先「暂时屏蔽中断」,表示已经知道内存中有数据了,告诉网卡下次再收到数据包直接写内存就可以了,不要再通知 CPU 了,这样可以提高效率,避免 CPU 不停的被中断。
  • 接着,发起「软中断」,然后恢复刚才屏蔽的中断。

至此,硬件中断处理函数的工作就已经完成。

硬件中断处理函数做的事情很少,主要耗时的工作都交给软中断处理函数了。

软中断的处理

内核中的 ksoftirqd 线程专门负责软中断的处理,当 ksoftirqd 内核线程收到软中断后,就会来轮询处理数据。

ksoftirqd 线程会从 Ring Buffer 中获取一个数据帧,用 sk_buff 表示,从而可以作为一个网络包交给网络协议栈进行逐层处理。

网络协议栈

首先,会先进入到网络接口层,在这一层会检查报文的合法性,如果不合法则丢弃,合法则会找出该网络包的上层协议的类型,比如是 IPv4,还是 IPv6,接着再去掉帧头和帧尾,然后交给网络层。

到了网络层,则取出 IP 包,判断网络包下一步的走向,比如是交给上层处理还是转发出去。当确认这个网络包要发送给本机后,就会从 IP 头里看看上一层协议的类型是 TCP 还是 UDP,接着去掉 IP 头,然后交给传输层。

传输层取出 TCP 头或 UDP 头,根据四元组「源 IP、源端口、目的 IP、目的端口」 作为标识,找出对应的 Socket,并把数据放到 Socket 的接收缓冲区。

最后,应用层程序调用 Socket 接口,将内核的 Socket 接收缓冲区的数据「拷贝」到应用层的缓冲区,然后唤醒用户进程。

至此,一个网络包的接收过程就已经结束了,你也可以从下图左边部分看到网络包接收的流程,右边部分刚好反过来,它是网络包发送的流程。

3、Linux 发送网络包的流程

如上图的右半部分,发送网络包的流程正好和接收流程相反。

首先,应用程序会调用 Socket 发送数据包的接口,由于这个是系统调用,所以会从用户态陷入到内核态中的 Socket 层,内核会申请一个内核态的 sk_buff 内存,将用户待发送的数据拷贝到 sk_buff 内存,并将其加入到发送缓冲区

接下来,网络协议栈从 Socket 发送缓冲区中取出 sk_buff,并按照 TCP/IP 协议栈从上到下逐层处理。

如果使用的是 TCP 传输协议发送数据,那么先拷贝一个新的 sk_buff 副本 ,这是因为 sk_buff 后续在调用网络层,最后到达网卡发送完成的时候,这个 sk_buff 会被释放掉。而 TCP 协议是支持丢失重传的,在收到对方的 ACK 之前,这个 sk_buff 不能被删除。所以内核的做法就是每次调用网卡发送的时候,实际上传递出去的是 sk_buff 的一个拷贝,等收到 ACK 再真正删除。

接着,对 sk_buff 填充 TCP 头。这里提一下,sk_buff 可以表示各个层的数据包,在应用层数据包叫 data,在 TCP 层我们称为 segment,在 IP 层我们叫 packet,在数据链路层称为 frame。

你可能会好奇,为什么全部数据包只用一个结构体来描述呢?协议栈采用的是分层结构,上层向下层传递数据时需要增加包头,下层向上层数据时又需要去掉包头,如果每一层都用一个结构体,那在层之间传递数据的时候,就要发生多次拷贝,这将大大降低 CPU 效率。

于是,为了在层级之间传递数据时,不发生拷贝,只用 sk_buff 一个结构体来描述所有的网络包,那它是如何做到的呢?是通过调整 sk_buff 中 data 的指针,比如:

  • 当接收报文时,从网卡驱动开始,通过协议栈层层往上传送数据报,通过增加 skb->data 的值,来逐步剥离协议首部。
  • 当要发送报文时,创建 sk_buff 结构体,数据缓存区的头部预留足够的空间,用来填充各层首部,在经过各下层协议时,通过减少 skb->data 的值来增加协议首部。

你可以从下面这张图看到,当发送报文时,data 指针的移动过程。

至此,传输层的工作也就都完成了。

然后交给网络层,在网络层里会做这些工作:选取路由(确认下一跳的 IP)、填充 IP 头、netfilter 过滤、对超过 MTU 大小的数据包进行分片。处理完这些工作后会交给网络接口层处理。

网络接口层会通过 ARP 协议获得下一跳的 MAC 地址,然后对 sk_buff 填充帧头和帧尾,接着将 sk_buff 放到网卡的发送队列中。

这一些工作准备好后,会触发「软中断」告诉网卡驱动程序,这里有新的网络包需要发送,驱动程序会从发送队列中读取 sk_buff,将这个 sk_buff 挂到 RingBuffer 中,接着将 sk_buff 数据映射到网卡可访问的内存 DMA 区域,最后触发真实的发送。

当数据发送完成以后,其实工作并没有结束,因为内存还没有清理。当发送完成的时候,网卡设备会触发一个硬中断来释放内存,主要是释放 sk_buff 内存和清理 RingBuffer 内存。

最后,当收到这个 TCP 报文的 ACK 应答时,传输层就会释放原始的 sk_buff

发送网络数据的时候,涉及几次内存拷贝操作?

第一次,调用发送数据的系统调用的时候,内核会申请一个内核态的 sk_buff 内存,将用户待发送的数据拷贝到 sk_buff 内存,并将其加入到发送缓冲区。

第二次,在使用 TCP 传输协议的情况下,从传输层进入网络层的时候,每一个 sk_buff 都会被克隆一个新的副本出来。副本 sk_buff 会被送往网络层,等它发送完的时候就会释放掉,然后原始的 sk_buff 还保留在传输层,目的是为了实现 TCP 的可靠传输,等收到这个数据包的 ACK 时,才会释放原始的 sk_buff 。

第三次,当 IP 层发现 sk_buff 大于 MTU 时才需要进行。会再申请额外的 sk_buff,并将原来的 sk_buff 拷贝为多个小的 sk_buff。