程序员cxuan

程序员cxuan 查看完整档案

石家庄编辑河北地质学院  |  信息管理与信息系统 编辑111  |  Java开发 编辑 Joincxuan.github.io 编辑
编辑

Java后端开发,欢迎关注个人微信公众号 Java建设者 及时关注最新技术文章。

个人动态

程序员cxuan 发布了文章 · 10月26日

硬肝4.4w字为你写成Java开发手册

先来看一下本篇文章的思维导图吧,我会围绕下面这些内容进行讲解。内容很干,小伙伴们看完还希望不吝转发。(高清思维导图版本关注作者公众号 Java建设者 回复 Java666 获取,其他思维导图获取方式在文末)。

image.png

下面开始我们的文章。

Java 概述

什么是 Java?

Java 是 Sun Microsystems 于1995 年首次发布的一种编程语言和计算平台。编程语言还比较好理解,那么什么是 计算平台 呢?

计算平台是在电脑中运行应用程序(软件)的环境,包括硬件环境软件环境。一般系统平台包括一台电脑的硬件体系结构、操作系统、运行时库。

Java 是快速,安全和可靠的。 从笔记本电脑到数据中心,从游戏机到科学超级计算机,从手机到互联网,Java 无处不在!Java 主要分为三个版本

  • JavaSE(J2SE)(Java2 Platform Standard Edition,java平台标准版)
  • JavaEE(J2EE)(Java 2 Platform,Enterprise Edition,java平台企业版)
  • JavaME(J2ME)(Java 2 Platform Micro Edition,java平台微型版)。

Java 的特点

  • Java 是一门面向对象的编程语言

什么是面向对象?面向对象(Object Oriented) 是一种软件开发思想。它是对现实世界的一种抽象,面向对象会把相关的数据和方法组织为一个整体来看待。

相对的另外一种开发思想就是面向过程的开发思想,什么面向过程?面向过程(Procedure Oriented) 是一种以过程为中心的编程思想。举个例子:比如你是个学生,你每天去上学需要做几件事情?

起床、穿衣服、洗脸刷牙,吃饭,去学校。一般是顺序性的完成一系列动作。

class student {
        void student_wakeUp(){...}
      void student_cloth(){...}
      void student_wash(){...}
      void student_eating(){...}
      void student_gotoSchool(){...}
} 

而面向对象可以把学生进行抽象,所以这个例子就会变为

class student(){
      void wakeUp(){...}
      void cloth(){...}
      void wash(){...}
      void eating(){...}
      void gotoSchool(){...}
} 

可以不用严格按照顺序来执行每个动作。这是特点一。

  • Java 摒弃了 C++ 中难以理解的多继承、指针、内存管理等概念;不用手动管理对象的生命周期,这是特征二。
  • Java 语言具有功能强大和简单易用两个特征,现在企业级开发,快速敏捷开发,尤其是各种框架的出现,使 Java 成为越来越火的一门语言。这是特点三。
  • Java 是一门静态语言,静态语言指的就是在编译期间就能够知道数据类型的语言,在运行前就能够检查类型的正确性,一旦类型确定后就不能再更改,比如下面这个例子。
public void foo() {
    int x = 5;
    boolean b = x;
} 

静态语言主要有 Pascal, Perl, C/C++, JAVA, C#, Scala 等。

相对应的,动态语言没有任何特定的情况需要指定变量的类型,在运行时确定的数据类型。比如有Lisp, Perl, Python、Ruby、JavaScript 等。

从设计的角度上来说,所有的语言都是设计用来把人类可读的代码转换为机器指令。动态语言是为了能够让程序员提高编码效率,因此你可以使用更少的代码来实现功能。静态语言设计是用来让硬件执行的更高效,因此需要程序员编写准确无误的代码,以此来让你的代码尽快的执行。从这个角度来说,静态语言的执行效率要比动态语言高,速度更快。这是特点四。

  • Java 具有平台独立性和可移植性

Java 有一句非常著名的口号: Write once, run anywhere,也就是一次编写、到处运行。为什么 Java 能够吹出这种牛批的口号来?核心就是 JVM。我们知道,计算机应用程序和硬件之间会屏蔽很多细节,它们之间依靠操作系统完成调度和协调,大致的体系结构如下

YKwycV.png

那么加上 Java 应用、JVM 的体系结构会变为如下

YKws10.png

Java 是跨平台的,已编译的 Java 程序可以在任何带有 JVM 的平台上运行。你可以在 Windows 平台下编写代码,然后拿到 Linux 平台下运行,该如何实现呢?

首先你需要在应用中编写 Java 代码;

Eclipse 或者 javac 把 Java 代码编译为 .class 文件;

然后把你的 .class 文件打成 .jar 文件;

然后你的 .jar 文件就能够在 Windows 、Mac OS X、Linux 系统下运行了。不同的操作系统有不同的 JVM 实现,切换平台时,不需要再次编译你的 Java 代码了。这是特点五。

  • Java 能够容易实现多线程

Java 是一门高级语言,高级语言会对用户屏蔽很多底层实现细节。比如 Java 是如何实现多线程的。从操作系统的角度来说,实现多线程的方式主要有下面这几种

在用户空间中实现多线程

在内核空间中实现多线程

在用户和内核空间中混合实现线程

而我认为 Java 应该是在 用户空间 实现的多线程,内核是感知不到 Java 存在多线程机制的。这是特点六。

  • Java 具有高性能

我们编写的代码,经过 javac 编译器编译称为 字节码(bytecode),经过 JVM 内嵌的解释器将字节码转换为机器代码,这是解释执行,这种转换过程效率较低。但是部分 JVM 的实现比如 Hotspot JVM 都提供了 JIT(Just-In-Time) 编译器,也就是通常所说的动态编译􏱆器,JIT 能够在运行时将􏲀热点代码编译机器码,这种方式运行效率比较高,这是编译执行。所以 Java 不仅仅只是一种解释执行的语言。这是特点七。

  • Java 语言具有健壮性

Java 的强类型机制、异常处理、垃圾的自动收集等是 Java 程序健壮性的重要保证。这也是 Java 与 C 语言的重要区别。这是特点八。

  • Java 很容易开发分布式项目

Java 语言支持 Internet 应用的开发,Java 中有 net api,它提供了用于网络应用编程的类库,包括URL、URLConnection、Socket、ServerSocket等。Java的 RMI(远程方法激活)机制也是开发分布式应用的重要手段。这是特点九。

Java 开发环境

JDK

JDK(Java Development Kit)称为 Java 开发包或 Java 开发工具,是一个编写 Java 的 Applet 小程序和应用程序的程序开发环境。JDK是整个Java的核心,包括了Java运行环境(Java Runtime Environment),一些Java 工具Java 的核心类库(Java API)

YKwgnU.png

我们可以认真研究一下这张图,它几乎包括了 Java 中所有的概念,我使用的是 jdk1.8,可以点进去 Description of Java Conceptual Diagram, 可以发现这里面包括了所有关于 Java 的描述

YKw2BF.png

Oracle 提供了两种 Java 平台的实现,一种是我们上面说的 JDK,Java 开发标准工具包,一种是 JRE,叫做Java Runtime Environment,Java 运行时环境。JDK 的功能要比 JRE 全很多。

JRE

JRE 是个运行环境,JDK 是个开发环境。因此写 Java 程序的时候需要 JDK,而运行 Java 程序的时候就需要JRE。而 JDK 里面已经包含了JRE,因此只要安装了JDK,就可以编辑 Java 程序,也可以正常运行 Java 程序。但由于 JDK 包含了许多与运行无关的内容,占用的空间较大,因此运行普通的 Java 程序无须安装 JDK,而只需要安装 JRE 即可。

Java 开发环境配置

这个地方不再多说了,网上有很多教程配置的资料可供参考。

Java 基本语法

在配置完 Java 开发环境,并下载 Java 开发工具(Eclipse、IDEA 等)后,就可以写 Java 代码了,因为本篇文章是从头梳理 Java 体系,所以有必要从基础的概念开始谈起。

数据类型

在 Java 中,数据类型只有四类八种

  • 整数型:byte、short、int、long

byte 也就是字节,1 byte = 8 bits,byte 的默认值是 0 ;

short 占用两个字节,也就是 16 位,1 short = 16 bits,它的默认值也是 0 ;

int 占用四个字节,也就是 32 位,1 int = 32 bits,默认值是 0 ;

long 占用八个字节,也就是 64 位,1 long = 64 bits,默认值是 0L;

所以整数型的占用字节大小空间为 long > int > short > byte

  • 浮点型

浮点型有两种数据类型:float 和 double

float 是单精度浮点型,占用 4 位,1 float = 32 bits,默认值是 0.0f;

double 是双精度浮点型,占用 8 位,1 double = 64 bits,默认值是 0.0d;

  • 字符型

字符型就是 char,char 类型是一个单一的 16 位 Unicode 字符,最小值是 u0000 (也就是 0 ),最大值是 uffff (即为 65535),char 数据类型可以存储任何字符,例如 char a = 'A'。

  • 布尔型

布尔型指的就是 boolean,boolean 只有两种值,true 或者是 false,只表示 1 位,默认值是 false。

以上 x 位都指的是在内存中的占用。

YKwR74.png

基础语法

  • 大小写敏感:Java 是对大小写敏感的语言,例如 Hello 与 hello 是不同的,这其实就是 Java 的字符串表示方式
  • 类名:对于所有的类来说,首字母应该大写,例如 MyFirstClass
  • 包名:包名应该尽量保证小写,例如 my.first.package
  • 方法名:方法名首字母需要小写,后面每个单词字母都需要大写,例如 myFirstMethod()

运算符

运算符不只 Java 中有,其他语言也有运算符,运算符是一些特殊的符号,主要用于数学函数、一些类型的赋值语句和逻辑比较方面,我们就以 Java 为例,来看一下运算符。

  • 赋值运算符

赋值运算符使用操作符 = 来表示,它的意思是把 = 号右边的值复制给左边,右边的值可以是任何常数、变量或者表达式,但左边的值必须是一个明确的,已经定义的变量。比如 int a = 4

但是对于对象来说,复制的不是对象的值,而是对象的引用,所以如果说将一个对象复制给另一个对象,实际上是将一个对象的引用赋值给另一个对象

  • 算数运算符

算数运算符就和数学中的数值计算差不多,主要有

YKw4hR.png

算数运算符需要注意的就是优先级问题,当一个表达式中存在多个操作符时,操作符的优先级顺序就决定了计算顺序,最简单的规则就是先乘除后加减,() 的优先级最高,没必要记住所有的优先级顺序,不确定的直接用 () 就可以了。

  • 自增、自减运算符

这个就不文字解释了,解释不如直接看例子明白

int a = 5;
b = ++a;
c = a++; 
  • 比较运算符

比较运算符用于程序中的变量之间,变量和自变量之间以及其他类型的信息之间的比较。

比较运算符的运算结果是 boolean 型。当运算符对应的关系成立时,运算的结果为 true,否则为 false。比较运算符共有 6 个,通常作为判断的依据用于条件语句中。

YKwfAJ.png

  • 逻辑运算符

逻辑运算符主要有三种,与、或、非

YKwhN9.png

下面是逻辑运算符对应的 true/false 符号表

YKwI91.png

  • 按位运算符

按位运算符用来操作整数基本类型中的每个比特位,也就是二进制位。按位操作符会对两个参数中对应的位执行布尔代数运算,并最终生成一个结果。

YKwo1x.png

如果进行比较的双方是数字的话,那么进行比较就会变为按位运算。

按位与:按位进行与运算(AND),两个操作数中位都为1,结果才为1,否则结果为0。需要首先把比较双方转换成二进制再按每个位进行比较

按位或:按位进行或运算(OR),两个位只要有一个为1,那么结果就是1,否则就为0。

按位非:按位进行异或运算(XOR),如果位为0,结果是1,如果位为1,结果是0。

按位异或:按位进行取反运算(NOT),两个操作数的位中,相同则结果为0,不同则结果为1。

  • 移位运算符

移位运算符用来将操作数向某个方向(向左或者右)移动指定的二进制位数。

YKwTc6.png

  • 三元运算符

三元运算符是类似 if...else... 这种的操作符,语法为:条件表达式?表达式 1:表达式 2。问号前面的位置是判断的条件,判断结果为布尔型,为 true 时调用表达式 1,为 false 时调用表达式 2。

Java 执行控制流程

Java 中的控制流程其实和 C 一样,在 Java 中,流程控制会涉及到包括 if-else、while、do-while、for、return、break 以及选择语句 switch。下面以此进行分析

条件语句

条件语句可根据不同的条件执行不同的语句。包括 if 条件语句与 switch 多分支语句。

if 条件语句

if 语句可以单独判断表达式的结果,表示表达的执行结果,例如

int a = 10;
if(a > 10){
  return true;
}
return false; 

if...else 条件语句

if 语句还可以与 else 连用,通常表现为 如果满足某种条件,就进行某种处理,否则就进行另一种处理

int a = 10;
int b = 11;
if(a >= b){
  System.out.println("a >= b");
}else{
  System.out.println("a < b");
} 

if 后的 () 内的表达式必须是 boolean 型的。如果为 true,则执行 if 后的复合语句;如果为 false,则执行 else 后的复合语句。

if...else if 多分支语句

上面中的 if...else 是单分支和两个分支的判断,如果有多个判断条件,就需要使用 if...else if

int x = 40;
if(x > 60) {
  System.out.println("x的值大于60");
} else if (x > 30) {
  System.out.println("x的值大于30但小于60");
} else if (x > 0) {
  System.out.println("x的值大于0但小于30");
} else {
  System.out.println("x的值小于等于0");
} 

switch 多分支语句

一种比 if...else if 语句更优雅的方式是使用 switch 多分支语句,它的示例如下

switch (week) {
  case 1:
    System.out.println("Monday");
    break;
  case 2:
    System.out.println("Tuesday");
    break;
  case 3:
    System.out.println("Wednesday");
    break;
  case 4:
    System.out.println("Thursday");
    break;
  case 5:
    System.out.println("Friday");
    break;
  case 6:
    System.out.println("Saturday");
    break;
  case 7:
    System.out.println("Sunday");
    break;
  default:
    System.out.println("No Else");
    break;
} 

循环语句

循环语句就是在满足一定的条件下反复执行某一表达式的操作,直到满足循环语句的要求。使用的循环语句主要有 for、do...while() 、 while

while 循环语句

while 循环语句的循环方式为利用一个条件来控制是否要继续反复执行这个语句。while 循环语句的格式如下

while(布尔值){
  表达式
} 

它的含义是,当 (布尔值) 为 true 的时候,执行下面的表达式,布尔值为 false 的时候,结束循环,布尔值其实也是一个表达式,比如

int a = 10;
while(a > 5){
  a--;
} 

do...while 循环

while 与 do...while 循环的唯一区别是 do...while 语句至少执行一次,即使第一次的表达式为 false。而在 while 循环中,如果第一次条件为 false,那么其中的语句根本不会执行。在实际应用中,while 要比 do...while 应用的更广。它的一般形式如下

int b = 10;
// do···while循环语句
do {
  System.out.println("b == " + b);
  b--;
} while(b == 1); 

for 循环语句

for 循环是我们经常使用的循环方式,这种形式会在第一次迭代前进行初始化。它的形式如下

for(初始化; 布尔表达式; 步进){} 

每次迭代前会测试布尔表达式。如果获得的结果是 false,就会执行 for 语句后面的代码;每次循环结束,会按照步进的值执行下一次循环。

逗号操作符

这里不可忽略的一个就是逗号操作符,Java 里唯一用到逗号操作符的就是 for 循环控制语句。在表达式的初始化部分,可以使用一系列的逗号分隔的语句;通过逗号操作符,可以在 for 语句内定义多个变量,但它们必须具有相同的类型

for(int i = 1;j = i + 10;i < 5;i++, j = j * 2){} 

for-each 语句

在 Java JDK 1.5 中还引入了一种更加简洁的、方便对数组和集合进行遍历的方法,即 for-each 语句,例子如下

int array[] = {7, 8, 9};

for (int arr : array) {
     System.out.println(arr);
} 

跳转语句

Java 语言中,有三种跳转语句: break、continue 和 return

break 语句

break 语句我们在 switch 中已经见到了,它是用于终止循环的操作,实际上 break 语句在for、while、do···while循环语句中,用于强行退出当前循环,例如

for(int i = 0;i < 10;i++){
    if(i == 5){
    break;
  }
} 

continue 语句

continue 也可以放在循环语句中,它与 break 语句具有相反的效果,它的作用是用于执行下一次循环,而不是退出当前循环,还以上面的例子为主

for(int i = 0;i < 10;i++){
  
  System.out.printl(" i = " + i );
    if(i == 5){
    System.out.printl("continue ... ");
    continue;
  }
} 

return 语句

return 语句可以从一个方法返回,并把控制权交给调用它的语句。

public void getName() {
    return name;
} 

面向对象

下面我们来探讨面向对象的思想,面向对象的思想已经逐步取代了过程化的思想 --- 面向过程,Java 是面向对象的高级编程语言,面向对象语言具有如下特征

  • 面向对象是一种常见的思想,比较符合人们的思考习惯;
  • 面向对象可以将复杂的业务逻辑简单化,增强代码复用性;
  • 面向对象具有抽象、封装、继承、多态等特性。

面向对象的编程语言主要有:C++、Java、C#等。

所以必须熟悉面向对象的思想才能编写出 Java 程序。

类也是一种对象

现在我们来认识一个面向对象的新的概念 --- 类,什么是类,它就相当于是一系列对象的抽象,就比如书籍一样,类相当于是书的封面,大多数面向对象的语言都使用 class 来定义类,它告诉你它里面定义的对象都是什么样的,我们一般使用下面来定义类

class ClassName {
    // body;
} 

代码段中涉及一个新的概念 // ,这个我们后面会说。上面,你声明了一个 class 类,现在,你就可以使用 new 来创建这个对象

ClassName classname = new ClassName(); 

一般,类的命名遵循驼峰原则,它的定义如下

骆驼式命名法(Camel-Case)又称驼峰式命名法,是电脑程式编写时的一套命名规则(惯例)。正如它的名称 CamelCase 所表示的那样,是指混合使用大小写字母来构成变量和函数的名字。程序员们为了自己的代码能更容易的在同行之间交流,所以多采取统一的可读性比较好的命名方式。

对象的创建

在 Java 中,万事万物都是对象。这句话相信你一定不陌生,尽管一切都看作是对象,但是你操纵的却是一个对象的 引用(reference)。在这里有一个很形象的比喻:你可以把车钥匙和车看作是一组对象引用和对象的组合。当你想要开车的时候,你首先需要拿出车钥匙点击开锁的选项,停车时,你需要点击加锁来锁车。车钥匙相当于就是引用,车就是对象,由车钥匙来驱动车的加锁和开锁。并且,即使没有车的存在,车钥匙也是一个独立存在的实体,也就是说,你有一个对象引用,但你不一定需要一个对象与之关联,也就是

Car carKey; 

这里创建的只是引用,而并非对象,但是如果你想要使用 s 这个引用时,会返回一个异常,告诉你需要一个对象来和这个引用进行关联。一种安全的做法是,在创建对象引用时同时把一个对象赋给它。

Car carKey = new Car(); 

在 Java 中,一旦创建了一个引用,就希望它能与一个新的对象进行关联,通常使用 new 操作符来实现这一目的。new 的意思是,给我一个新对象,如果你不想相亲,自己 new 一个对象就好了。祝你下辈子幸福。

属性和方法

类一个最基本的要素就是有属性和方法。

属性也被称为字段,它是类的重要组成部分,属性可以是任意类型的对象,也可以是基本数据类型。例如下

class A{
  int a;
  Apple apple;
} 

类中还应该包括方法,方法表示的是 做某些事情的方式。方法其实就是函数,只不过 Java 习惯把函数称为方法。这种叫法也体现了面向对象的概念。

方法的基本组成包括 方法名称、参数、返回值和方法体, 下面是它的示例

public int getResult(){
  // ...
  return 1;
} 

其中,getResult 就是方法名称、() 里面表示方法接收的参数、return 表示方法的返回值,注意:方法的返回值必须和方法的参数类型保持一致。有一种特殊的参数类型 --- void 表示方法无返回值。{} 包含的代码段被称为方法体。

构造方法

在 Java 中,有一种特殊的方法被称为 构造方法,也被称为构造函数、构造器等。在 Java 中,通过提供这个构造器,来确保每个对象都被初始化。构造方法只能在对象的创建时期调用一次,保证了对象初始化的进行。构造方法比较特殊,它没有参数类型和返回值,它的名称要和类名保持一致,并且构造方法可以有多个,下面是一个构造方法的示例

class Apple {
  
  int sum;
  String color;
  
  public Apple(){}
  public Apple(int sum){}
  public Apple(String color){}
  public Apple(int sum,String color){}
  
} 

上面定义了一个 Apple 类,你会发现这个 Apple 类没有参数类型和返回值,并且有多个以 Apple 同名的方法,而且各个 Apple 的参数列表都不一样,这其实是一种多态的体现,我们后面会说。在定义完成构造方法后,我们就能够创建 Apple 对象了。

class createApple {

    public static void main(String[] args) {
        Apple apple1 = new Apple();
        Apple apple2 = new Apple(1);
        Apple apple3 = new Apple("red");
        Apple apple4 = new Apple(2,"color");

    }
} 

如上面所示,我们定义了四个 Apple 对象,并调用了 Apple 的四种不同的构造方法,其中,不加任何参数的构造方法被称为默认的构造方法,也就是

Apple apple1 = new Apple(); 

如果类中没有定义任何构造方法,那么 JVM 会为你自动生成一个构造方法,如下

class Apple {
  
  int sum;
  String color;
  
}

class createApple {

    public static void main(String[] args) {
        Apple apple1 = new Apple();

    }
} 

上面代码不会发生编译错误,因为 Apple 对象包含了一个默认的构造方法。

默认的构造方法也被称为默认构造器或者无参构造器。

这里需要注意一点的是,即使 JVM 会为你默认添加一个无参的构造器,但是如果你手动定义了任何一个构造方法,JVM 就不再为你提供默认的构造器,你必须手动指定,否则会出现编译错误

YKwbnO.png

显示的错误是,必须提供 Apple 带有 int 参数的构造函数,而默认的无参构造函数没有被允许使用。

方法重载

在 Java 中一个很重要的概念是方法的重载,它是类名的不同表现形式。我们上面说到了构造函数,其实构造函数也是重载的一种。另外一种就是方法的重载

public class Apple {

    int sum;
    String color;

    public Apple(){}
    public Apple(int sum){}
    
    public int getApple(int num){
        return 1;
    }
    
    public String getApple(String color){
        return "color";
    }

} 

如上面所示,就有两种重载的方式,一种是 Apple 构造函数的重载,一种是 getApple 方法的重载。

但是这样就涉及到一个问题,要是有几个相同的名字,Java 如何知道你调用的是哪个方法呢?这里记住一点即可,每个重载的方法都有独一无二的参数列表。其中包括参数的类型、顺序、参数数量等,满足一种一个因素就构成了重载的必要条件。

请记住下面重载的条件

  • 方法名称必须相同。
  • 参数列表必须不同(个数不同、或类型不同、参数类型排列顺序不同等)。
  • 方法的返回类型可以相同也可以不相同。
  • 仅仅返回类型不同不足以成为方法的重载。
  • 重载是发生在编译时的,因为编译器可以根据参数的类型来选择使用哪个方法。

方法的重写

方法的重写与重载虽然名字很相似,但却完全是不同的东西。方法重写的描述是对子类和父类之间的。而重载指的是同一类中的。例如如下代码

class Fruit {
 
    public void eat(){
    System.out.printl('eat fruit');
  }
}

class Apple extends Fruit{
  
  @Override
  public void eat(){
    System.out.printl('eat apple');
  }
} 

上面这段代码描述的就是重写的代码,你可以看到,子类 Apple 中的方法和父类 Fruit 中的方法同名,所以,我们能够推断出重写的原则

  • 重写的方法必须要和父类保持一致,包括返回值类型,方法名,参数列表 也都一样。
  • 重写的方法可以使用 @Override 注解来标识
  • 子类中重写方法的访问权限不能低于父类中方法的访问权限。

初始化

类的初始化

上面我们创建出来了一个 Car 这个对象,其实在使用 new 关键字创建一个对象的时候,其实是调用了这个对象无参数的构造方法进行的初始化,也就是如下这段代码

class Car{
  public Car(){}
} 

这个无参数的构造函数可以隐藏,由 JVM 自动添加。也就是说,构造函数能够确保类的初始化。

成员初始化

Java 会尽量保证每个变量在使用前都会获得初始化,初始化涉及两种初始化。

  • 一种是编译器默认指定的字段初始化,基本数据类型的初始化

    YKw7jK.png

    一种是其他对象类型的初始化,String 也是一种对象,对象的初始值都为 null ,其中也包括基本类型的包装类。

  • 一种是指定数值的初始化,例如
int a = 11 

也就是说, 指定 a 的初始化值不是 0 ,而是 11。其他基本类型和对象类型也是一样的。

构造器初始化

可以利用构造器来对某些方法和某些动作进行初始化,确定初始值,例如

public class Counter{
  int i;
  public Counter(){
    i = 11;
  }
} 

利用构造函数,能够把 i 的值初始化为 11。

初始化顺序

首先先来看一下有哪些需要探讨的初始化顺序

  • 静态属性:static 开头定义的属性
  • 静态方法块: static {} 包起来的代码块
  • 普通属性: 非 static 定义的属性
  • 普通方法块: {} 包起来的代码块
  • 构造函数: 类名相同的方法
  • 方法: 普通方法
public class LifeCycle {
    // 静态属性
    private static String staticField = getStaticField();
    // 静态方法块
    static {
        System.out.println(staticField);
        System.out.println("静态方法块初始化");
    }
    // 普通属性
    private String field = getField();
    // 普通方法块
    {
        System.out.println(field);
    }
    // 构造函数
    public LifeCycle() {
        System.out.println("构造函数初始化");
    }

    public static String getStaticField() {
        String statiFiled = "Static Field Initial";
        return statiFiled;
    }

    public static String getField() {
        String filed = "Field Initial";
        return filed;
    }   
    // 主函数
    public static void main(String[] argc) {
        new LifeCycle();
    }
} 

这段代码的执行结果就反应了它的初始化顺序

静态属性初始化
静态方法块初始化
普通属性初始化
普通方法块初始化
构造函数初始化

数组初始化

数组是相同类型的、用一个标识符名称封装到一起的一个对象序列或基本类型数据序列。数组是通过方括号下标操作符 [] 来定义使用。

一般数组是这么定义的

int[] a1;

//或者

int a1[]; 

两种格式的含义是一样的。

  • 直接给每个元素赋值 : int array[4] = {1,2,3,4};
  • 给一部分赋值,后面的都为 0 : int array[4] = {1,2};
  • 由赋值参数个数决定数组的个数 : int array[] = {1,2};

可变参数列表

Java 中一种数组冷门的用法就是可变参数 ,可变参数的定义如下

public int add(int... numbers){
  int sum = 0;
  for(int num : numbers){
    sum += num;
  }
  return sum;
} 

然后,你可以使用下面这几种方式进行可变参数的调用

add();  // 不传参数
add(1);  // 传递一个参数
add(2,1);  // 传递多个参数
add(new Integer[] {1, 3, 2});  // 传递数组 

对象的销毁

虽然 Java 语言是基于 C++ 的,但是它和 C/C++ 一个重要的特征就是不需要手动管理对象的销毁工作。在著名的一书 《深入理解 Java 虚拟机》中提到一个观点

YKwqBD.png

在 Java 中,我们不再需要手动管理对象的销毁,它是由 Java 虚拟机进行管理和销毁的。虽然我们不需要手动管理对象,但是你需要知道 对象作用域 这个概念。

对象作用域

J多数语言都有作用域(scope) 这个概念。作用域决定了其内部定义的变量名的可见性和生命周期。在 C、C++ 和 Java 中,作用域通常由 {} 的位置来决定,例如

{
  int a = 11;
  {
    int b = 12;
  }
} 

a 变量会在两个 {} 作用域内有效,而 b 变量的值只能在它自己的 {} 内有效。

虽然存在作用域,但是不允许这样写

{
  int x = 11;
  {
    int x = 12;
  }
} 

这种写法在 C/C++ 中是可以的,但是在 Java 中不允许这样写,因为 Java 设计者认为这样写会导致程序混乱。

this 和 super

this 和 super 都是 Java 中的关键字

this 表示的当前对象,this 可以调用方法、调用属性和指向对象本身。this 在 Java 中的使用一般有三种:指向当前对象

public class Apple {

    int i = 0;

    Apple eatApple(){
        i++;
        return this;
    }

    public static void main(String[] args) {
        Apple apple = new Apple();
        apple.eatApple().eatApple();
    }
} 

这段代码比较精妙,精妙在哪呢,我一个 eatApple() 方法竟然可以调用多次,你在后面还可以继续调用,这就很神奇了,为啥呢?其实就是 this 在作祟了,我在 eatApple 方法中加了一个 return this 的返回值,也就是说哪个对象调用 eatApple 方法都能返回对象的自身。

this 还可以修饰属性,最常见的就是在构造方法中使用 this ,如下所示

public class Apple {

    private int num;
    
    public Apple(int num){
        this.num = num;
    }

    public static void main(String[] args) {
        new Apple(10);
    }
} 

main 方法中传递了一个 int 值为 10 的参数,它表示的就是苹果的数量,并把这个数量赋给了 num 全局变量。所以 num 的值现在就是 10。

this 还可以和构造函数一起使用,充当一个全局关键字的效果

public class Apple {

    private int num;
    private String color;

    public Apple(int num){
        this(num,"红色");
    }
    
    public Apple(String color){
        this(1,color);
    }

    public Apple(int num, String color) {
        this.num = num;
        this.color = color;
    }
    
} 

你会发现上面这段代码使用的不是 this, 而是 this(参数)。它相当于调用了其他构造方法,然后传递参数进去。这里注意一点:this() 必须放在构造方法的第一行,否则编译不通过

YKwLHe.png

如果你把 this 理解为指向自身的一个引用,那么 super 就是指向父类的一个引用。super 关键字和 this 一样,你可以使用 super.对象 来引用父类的成员,如下

public class Fruit {

    int num;
    String color;

    public void eat(){
        System.out.println("eat Fruit");
    }
}

public class Apple extends Fruit{

    @Override
    public void eat() {
        super.num = 10;
        System.out.println("eat " + num + " Apple");
    }

} 

你也可以使用 super(参数) 来调用父类的构造函数,这里不再举例子了。

下面为你汇总了 this 关键字和 super 关键字的比较。

YKwXAH.png

访问控制权限

访问控制权限又称为封装,它是面向对象三大特性中的一种,我之前在学习过程中经常会忽略封装,心想这不就是一个访问修饰符么,怎么就是三大特性的必要条件了?后来我才知道,如果你信任的下属对你隐瞒 bug,你是根本不知道的

访问控制权限其实最核心就是一点:只对需要的类可见。

Java中成员的访问权限共有四种,分别是 public、protected、default、private,它们的可见性如下

YKwjNd.png

继承

继承是所有 OOP(Object Oriented Programming) 语言和 Java 语言都不可或缺的一部分。只要我们创建了一个类,就隐式的继承自 Object 父类,只不过没有指定。如果你显示指定了父类,那么你继承于父类,而你的父类继承于 Object 类。

YKw6XT.png

继承的关键字是 extends ,如上图所示,如果使用了 extends 显示指定了继承,那么我们可以说 Father 是父类,而 Son 是子类,用代码表示如下

class Father{}

class Son extends Father{} 

继承双方拥有某种共性的特征

class Father{
  
  public void feature(){
    System.out.println("父亲的特征");
  }
}

class Son extends Father {
} 

如果 Son 没有实现自己的方法的话,那么默认就是用的是父类的 feature 方法。如果子类实现了自己的 feature 方法,那么就相当于是重写了父类的 feature 方法,这也是我们上面提到的重写了。

多态

多态指的是同一个行为具有多个不同表现形式。是指一个类实例(对象)的相同方法在不同情形下具有不同表现形式。封装和继承是多态的基础,也就是说,多态只是一种表现形式而已。

如何实现多态?多态的实现具有三种充要条件

  • 继承
  • 重写父类方法
  • 父类引用指向子类对象

比如下面这段代码

public class Fruit {

    int num;

    public void eat(){
        System.out.println("eat Fruit");
    }
}

public class Apple extends Fruit{

    @Override
    public void eat() {
        super.num = 10;
        System.out.println("eat " + num + " Apple");
    }

    public static void main(String[] args) {
        Fruit fruit = new Apple();
        fruit.eat();
    }
} 

你可以发现 main 方法中有一个很神奇的地方,Fruit fruit = new Apple(),Fruit 类型的对象竟然指向了 Apple 对象的引用,这其实就是多态 -> 父类引用指向子类对象,因为 Apple 继承于 Fruit,并且重写了 eat 方法,所以能够表现出来多种状态的形式。

组合

组合其实不难理解,就是将对象引用置于新类中即可。组合也是一种提高类的复用性的一种方式。如果你想让类具有更多的扩展功能,你需要记住一句话多用组合,少用继承

public class SoccerPlayer {
    
    private String name;
    private Soccer soccer;
    
}

public class Soccer {
    
    private String soccerName;    
} 

代码中 SoccerPlayer 引用了 Soccer 类,通过引用 Soccer 类,来达到调用 soccer 中的属性和方法。

组合和继承是有区别的,它们的主要区别如下。

YKwv4A.png

关于继承和组合孰优孰劣的争论没有结果,只要发挥各自的长处和优点即可,一般情况下,组合和继承也是一对可以连用的好兄弟。

代理

除了继承和组合外,另外一种值得探讨的关系模型称为 代理。代理的大致描述是,A 想要调用 B 类的方法,A 不直接调用,A 会在自己的类中创建一个 B 对象的代理,再由代理调用 B 的方法。例如如下代码

public class Destination {

    public void todo(){
        System.out.println("control...");
    }
}

public class Device {

    private String name;
    private Destination destination;
    private DeviceController deviceController;

    public void control(Destination destination){
        destination.todo();
    }

}

public class DeviceController {

    private Device name;
    private Destination destination;

    public void control(Destination destination){
        destination.todo();
    }
} 

向上转型

向上转型代表了父类与子类之间的关系,其实父类和子类之间不仅仅有向上转型,还有向下转型,它们的转型后的范围不一样

  • 向上转型:通过子类对象(小范围)转化为父类对象(大范围),这种转换是自动完成的,不用强制。
  • 向下转型 : 通过父类对象(大范围)实例化子类对象(小范围),这种转换不是自动完成的,需要强制指定。

static

static 是 Java 中的关键字,它的意思是 静态的,static 可以用来修饰成员变量和方法,static 用在没有创建对象的情况下调用 方法/变量。

  • 用 static 声明的成员变量为静态成员变量,也成为类变量。类变量的生命周期和类相同,在整个应用程序执行期间都有效。
static String name = "cxuan"; 
  • 使用 static 修饰的方法称为静态方法,静态方法能够直接使用类名.方法名 进行调用。由于静态方法不依赖于任何对象就可以直接访问,因此对于静态方法来说,是没有 this 关键字的,实例变量都会有 this 关键字。在静态方法中不能访问类的非静态成员变量和非静态方法,
static void printMessage(){
  System.out.println("cxuan is writing the article");
} 

static 除了修饰属性和方法外,还有静态代码块 的功能,可用于类的初始化操作。进而提升程序的性能。

public class StaicBlock {
    static{
        System.out.println("I'm A static code block");
    }
} 

由于静态代码块随着类的加载而执行,因此,很多时候会将只需要进行一次的初始化操作放在 static 代码块中进行。

final

final 的意思是最后的、最终的,它可以修饰类、属性和方法。

  • final 修饰类时,表明这个类不能被继承。final 类中的成员变量可以根据需要设为 final,但是要注意 final 类中的所有成员方法都会被隐式地指定为 final 方法。
  • final 修饰方法时,表明这个方法不能被任何子类重写,因此,如果只有在想明确禁止该方法在子类中被覆盖的情况下才将方法设置为 final。
  • final 修饰变量分为两种情况,一种是修饰基本数据类型,表示数据类型的值不能被修改;一种是修饰引用类型,表示对其初始化之后便不能再让其指向另一个对象。

接口和抽象类

接口

接口相当于就是对外的一种约定和标准,这里拿操作系统举例子,为什么会有操作系统?就会为了屏蔽软件的复杂性和硬件的简单性之间的差异,为软件提供统一的标准。

在 Java 语言中,接口是由 interface 关键字来表示的,比如我们可以向下面这样定义一个接口

public interface CxuanGoodJob {} 

比如我们定义了一个 CxuanGoodJob 的接口,然后你就可以在其内部定义 cxuan 做的好的那些事情,比如 cxuan 写的文章不错。

public interface CxuanGoodJob {

    void writeWell();
} 

这里隐含了一些接口的特征:

  • interface 接口是一个完全抽象的类,他不会提供任何方法的实现,只是会进行方法的定义。
  • 接口中只能使用两种访问修饰符,一种是 public,它对整个项目可见;一种是 default 缺省值,它只具有包访问权限。
  • 接口只提供方法的定义,接口没有实现,但是接口可以被其他类实现。也就是说,实现接口的类需要提供方法的实现,实现接口使用 implements 关键字来表示,一个接口可以有多个实现。
class CXuanWriteWell implements CxuanGoodJob{

    @Override
    public void writeWell() {
        System.out.println("Cxuan write Java is vary well");
    }
} 
  • 接口不能被实例化,所以接口中不能有任何构造方法,你定义构造方法编译会出错。
  • 接口的实现比如实现接口的全部方法,否则必须定义为抽象类,这就是我们下面要说的内容

抽象类

抽象类是一种抽象能力弱于接口的类,在 Java 中,抽象类使用 abstract 关键字来表示。如果把接口形容为狗这个物种,那么抽象类可以说是毛发是白色、小体的品种,而实现类可以是具体的类,比如说是博美、泰迪等。你可以像下面这样定义抽象类

public interface Dog {

    void FurColor();

}

abstract class WhiteDog implements Dog{

    public void FurColor(){
        System.out.println("Fur is white");
    }

    abstract void SmallBody();
} 

在抽象类中,具有如下特征

  • 如果一个类中有抽象方法,那么这个类一定是抽象类,也就是说,使用关键字 abstract 修饰的方法一定是抽象方法,具有抽象方法的类一定是抽象类。实现类方法中只有方法具体的实现。
  • 抽象类中不一定只有抽象方法,抽象类中也可以有具体的方法,你可以自己去选择是否实现这些方法。
  • 抽象类中的约束不像接口那么严格,你可以在抽象类中定义 构造方法、抽象方法、普通属性、方法、静态属性和静态方法
  • 抽象类和接口一样不能被实例化,实例化只能实例化具体的类

异常

异常是程序经常会出现的,发现错误的最佳时机是在编译阶段,也就是你试图在运行程序之前。但是,在编译期间并不能找到所有的错误,有一些 NullPointerExceptionClassNotFoundException 异常在编译期找不到,这些异常是 RuntimeException 运行时异常,这些异常往往在运行时才能被发现。

我们写 Java 程序经常会出现两种问题,一种是 java.lang.Exception ,一种是 java.lang.Error,都用来表示出现了异常情况,下面就针对这两种概念进行理解。

认识 Exception

Exception 位于 java.lang 包下,它是一种顶级接口,继承于 Throwable 类,Exception 类及其子类都是 Throwable 的组成条件,是程序出现的合理情况。

在认识 Exception 之前,有必要先了解一下什么是 Throwable

什么是 Throwable

Throwable 类是 Java 语言中所有错误(errors)异常(exceptions)的父类。只有继承于 Throwable 的类或者其子类才能够被抛出,还有一种方式是带有 Java 中的 @throw 注解的类也可以抛出。

Java规范中,对非受查异常和受查异常的定义是这样的:

The unchecked exception classes are the run-time exception classes and the error classes.

The checked exception classes are all exception classes other than the unchecked exception classes. That is, the checked exception classes are Throwable and all its subclasses other than RuntimeException and its subclasses and Errorand its subclasses.

也就是说,除了 RuntimeException 和其子类,以及error和其子类,其它的所有异常都是 checkedException

那么,按照这种逻辑关系,我们可以对 Throwable 及其子类进行归类分析

YKwz9I.png

可以看到,Throwable 位于异常和错误的最顶层,我们查看 Throwable 类中发现它的方法和属性有很多,我们只讨论其中几个比较常用的

// 返回抛出异常的详细信息
public string getMessage();
public string getLocalizedMessage();

//返回异常发生时的简要描述
public public String toString();
  
// 打印异常信息到标准输出流上
public void printStackTrace();
public void printStackTrace(PrintStream s);
public void printStackTrace(PrintWriter s)

// 记录栈帧的的当前状态
public synchronized Throwable fillInStackTrace(); 

此外,因为 Throwable 的父类也是 Object,所以常用的方法还有继承其父类的getClass()getName() 方法。

常见的 Exception

下面我们回到 Exception 的探讨上来,现在你知道了 Exception 的父类是 Throwable,并且 Exception 有两种异常,一种是 RuntimeException ;一种是 CheckedException,这两种异常都应该去捕获

下面列出了一些 Java 中常见的异常及其分类,这块面试官也可能让你举出几个常见的异常情况并将其分类

RuntimeException

YK0S3t.png

UncheckedException

YK0pgP.png

与 Exception 有关的 Java 关键字

那么 Java 中是如何处理这些异常的呢?在 Java 中有这几个关键字 throws、throw、try、finally、catch 下面我们分别来探讨一下

throws 和 throw

在 Java 中,异常也就是一个对象,它能够被程序员自定义抛出或者应用程序抛出,必须借助于 throwsthrow 语句来定义抛出异常。

throws 和 throw 通常是成对出现的,例如

static void cacheException() throws Exception{

  throw new Exception();

} 

throw 语句用在方法体内,表示抛出异常,由方法体内的语句处理。
throws 语句用在方法声明后面,表示再抛出异常,由该方法的调用者来处理。

throws 主要是声明这个方法会抛出这种类型的异常,使它的调用者知道要捕获这个异常。
throw 是具体向外抛异常的动作,所以它是抛出一个异常实例。

try 、finally 、catch

这三个关键字主要有下面几种组合方式 try...catch 、try...finally、try...catch...finally

try...catch 表示对某一段代码可能抛出异常进行的捕获,如下

static void cacheException() throws Exception{

  try {
    System.out.println("1");
  }catch (Exception e){
    e.printStackTrace();
  }

} 

try...finally 表示对一段代码不管执行情况如何,都会走 finally 中的代码

static void cacheException() throws Exception{
  for (int i = 0; i < 5; i++) {
    System.out.println("enter: i=" + i);
    try {
      System.out.println("execute: i=" + i);
      continue;
    } finally {
      System.out.println("leave: i=" + i);
    }
  }
} 

try...catch...finally 也是一样的,表示对异常捕获后,再走 finally 中的代码逻辑。

什么是 Error

Error 是程序无法处理的错误,表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。这些错误是不可检查的,因为它们在应用程序的控制和处理能力之 外,而且绝大多数是程序运行时不允许出现的状况,比如 OutOfMemoryErrorStackOverflowError异常的出现会有几种情况,这里需要先介绍一下 Java 内存模型 JDK1.7。

YK09jf.png

其中包括两部分,由所有线程共享的数据区和线程隔离的数据区组成,在上面的 Java 内存模型中,只有程序计数器是不会发生 OutOfMemoryError 情况的区域,程序计数器控制着计算机指令的分支、循环、跳转、异常处理和线程恢复,并且程序计数器是每个线程私有的。

什么是线程私有:表示的就是各条线程之间互不影响,独立存储的内存区域。

如果应用程序执行的是 Java 方法,那么这个计数器记录的就是虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)

除了程序计数器外,其他区域:方法区(Method Area)虚拟机栈(VM Stack)本地方法栈(Native Method Stack)堆(Heap) 都是可能发生 OutOfMemoryError 的区域。

  • 虚拟机栈:如果线程请求的栈深度大于虚拟机栈所允许的深度,将会出现 StackOverflowError 异常;如果虚拟机动态扩展无法申请到足够的内存,将出现 OutOfMemoryError
  • 本地方法栈和虚拟机栈一样
  • 堆:Java 堆可以处于物理上不连续,逻辑上连续,就像我们的磁盘空间一样,如果堆中没有内存完成实例分配,并且堆无法扩展时,将会抛出 OutOfMemoryError。
  • 方法区:方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

在 Java 中,你可以把异常理解为是一种能够提高你程序健壮性的机制,它能够让你在编写代码中注意这些问题,也可以说,如果你写代码不会注意这些异常情况,你是无法成为一位硬核程序员的。

内部类

距今为止,我们了解的都是普通类的定义,那就是直接在 IDEA 中直接新建一个 class 。

YK0Pu8.png

新建完成后,你就会拥有一个 class 文件的定义,这种操作太简单了,时间长了就会枯燥,我们年轻人多需要更新潮和骚气的写法,好吧,既然你提到了那就使用 内部类吧,这是一种有用而且骚气的定义类的方式,内部类的定义非常简单:可以将一个类的定义放在另一个类的内部,这就是内部类

内部类是一种非常有用的特性,定义在类内部的类,持有外部类的引用,但却对其他外部类不可见,看起来就像是一种隐藏代码的机制,就和 弗兰奇将军 似的,弗兰奇可以和弗兰奇将军进行通讯,但是外面的敌人却无法直接攻击到弗兰奇本体。

YK0iDS.png

下面我们就来聊一聊创建内部类的方式。

创建内部类

定义内部类非常简单,就是直接将一个类定义在外围类的里面,如下代码所示

public class OuterClass {
    private String name ;
    private int age;
    
    class InnerClass{
        public InnerClass(){
            name = "cxuan";
            age = 25;
        }
    }
} 

在这段代码中,InnerClass 就是 OuterClass 的一个内部类。也就是说,每个内部类都能独立地继承一个(接口的)实现,所以无论外围类是否已经继承了某个(接口的)实现,对于内部类都没有影响。这也是隐藏了内部实现细节。内部类拥有外部类的访问权

内部类不仅仅能够定义在类的内部,还可以定义在方法和作用域内部,这种被称为局部内部类,除此之外,还有匿名内部类、内部类可以实现 Java 中的 多重继承。下面是定义内部类的方式

  • 一个在方法中定义的类(局部内部类) 
  • 一个定义在作用域内的类,这个作用域在方法的内部(成员内部类) 
  • 一个实现了接口的匿名类(匿名内部类) 
  • 一个匿名类,它扩展了非默认构造器的类 
  • 一个匿名类,执行字段初始化操作 
  • 一个匿名类,它通过实例初始化实现构造 

由于每个类都会产生一个 .class 文件,其中包含了如何创建该类型的对象的全部信息,那么,如何表示内部类的信息呢?可以使用 $ 来表示,比如 OuterClass$InnerClass.class

集合

集合在我们的日常开发中所使用的次数简直太多了,你已经把它们都用的熟透了,但是作为一名合格的程序员,你不仅要了解它的基本用法,你还要了解它的源码;存在即合理,你还要了解它是如何设计和实现的,你还要了解它的衍生过程。

这篇博客就来详细介绍一下 Collection 这个庞大集合框架的家族体系和成员,让你了解它的设计与实现。

是时候祭出这张神图了

YK0AEQ.png

首先来介绍的就是列表爷爷辈儿的接口- Iterator

Iterable 接口

实现此接口允许对象成为 for-each 循环的目标,也就是增强 for 循环,它是 Java 中的一种语法糖

List<Object> list = new ArrayList();
for (Object obj: list){} 

除了实现此接口的对象外,数组也可以用 for-each 循环遍历,如下:

Object[] list = new Object[10];
for (Object obj: list){} 

其他遍历方式

jdk 1.8之前Iterator只有 iterator 一个方法,就是

Iterator<T> iterator(); 

实现次接口的方法能够创建一个轻量级的迭代器,用于安全的遍历元素,移除元素,添加元素。这里面涉及到一个 fail-fast 机制。

总之一点就是能创建迭代器进行元素的添加和删除的话,就尽量使用迭代器进行添加和删除。

也可以使用迭代器的方式进行遍历

for(Iterator it = coll.iterator(); it.hasNext(); ){
    System.out.println(it.next());
} 

顶层接口

Collection 是一个顶层接口,它主要用来定义集合的约定

List 接口也是一个顶层接口,它继承了 Collection 接口 ,同时也是 ArrayList、LinkedList 等集合元素的父类

Set 接口位于与 List 接口同级的层次上,它同时也继承了 Collection 接口。Set 接口提供了额外的规定。它对add、equals、hashCode 方法提供了额外的标准。

Queue 是和 List、Set 接口并列的 Collection 的三大接口之一。Queue 的设计用来在处理之前保持元素的访问次序。除了 Collection 基础的操作之外,队列提供了额外的插入,读取,检查操作。

SortedSet 接口直接继承于 Set 接口,使用 Comparable 对元素进行自然排序或者使用 Comparator 在创建时对元素提供定制的排序规则。set 的迭代器将按升序元素顺序遍历集合。

Map 是一个支持 key-value 存储的对象,Map 不能包含重复的 key,每个键最多映射一个值。这个接口代替了Dictionary 类,Dictionary 是一个抽象类而不是接口。

ArrayList

ArrayList 是实现了 List 接口的可扩容数组(动态数组),它的内部是基于数组实现的。它的具体定义如下:

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {...} 
  • ArrayList 可以实现所有可选择的列表操作,允许所有的元素,包括空值。ArrayList 还提供了内部存储 list 的方法,它能够完全替代 Vector,只有一点例外,ArrayList 不是线程安全的容器。
  • ArrayList 有一个容量的概念,这个数组的容量就是 List 用来存储元素的容量。
  • ArrayList 不是线程安全的容器,如果多个线程中至少有两个线程修改了 ArrayList 的结构的话就会导致线程安全问题,作为替代条件可以使用线程安全的 List,应使用 Collections.synchronizedList
List list = Collections.synchronizedList(new ArrayList(...)) 
  • ArrayList 具有 fail-fast 快速失败机制,能够对 ArrayList 作出失败检测。当在迭代集合的过程中该集合在结构上发生改变的时候,就有可能会发生 fail-fast,即抛出 ConcurrentModificationException 异常。

Vector

Vector 同 ArrayList 一样,都是基于数组实现的,只不过 Vector 是一个线程安全的容器,它对内部的每个方法都简单粗暴的上锁,避免多线程引起的安全性问题,但是通常这种同步方式需要的开销比较大,因此,访问元素的效率要远远低于 ArrayList。

还有一点在于扩容上,ArrayList 扩容后的数组长度会增加 50%,而 Vector 的扩容长度后数组会增加一倍。

LinkedList 类

LinkedList 是一个双向链表,允许存储任何元素(包括 null )。它的主要特性如下:

  • LinkedList 所有的操作都可以表现为双向性的,索引到链表的操作将遍历从头到尾,视哪个距离近为遍历顺序。
  • 注意这个实现也不是线程安全的,如果多个线程并发访问链表,并且至少其中的一个线程修改了链表的结构,那么这个链表必须进行外部加锁。或者使用
List list = Collections.synchronizedList(new LinkedList(...)) 

Stack

堆栈是我们常说的后入先出(吃了吐)的容器 。它继承了 Vector 类,提供了通常用的 push 和 pop 操作,以及在栈顶的 peek 方法,测试 stack 是否为空的 empty 方法,和一个寻找与栈顶距离的 search 方法。

第一次创建栈,不包含任何元素。一个更完善,可靠性更强的 LIFO 栈操作由 Deque 接口和他的实现提供,应该优先使用这个类

Deque<Integer> stack = new ArrayDeque<Integer>() 

HashSet

HashSet 是 Set 接口的实现类,由哈希表支持(实际上 HashSet 是 HashMap 的一个实例)。它不能保证集合的迭代顺序。这个类允许 null 元素。

  • 注意这个实现不是线程安全的。如果多线程并发访问 HashSet,并且至少一个线程修改了set,必须进行外部加锁。或者使用 Collections.synchronizedSet() 方法重写。
  • 这个实现支持 fail-fast 机制。

TreeSet

TreeSet 是一个基于 TreeMap 的 NavigableSet 实现。这些元素使用他们的自然排序或者在创建时提供的Comparator 进行排序,具体取决于使用的构造函数。

  • 此实现为基本操作 add,remove 和 contains 提供了 log(n) 的时间成本。
  • 注意这个实现不是线程安全的。如果多线程并发访问 TreeSet,并且至少一个线程修改了 set,必须进行外部加锁。或者使用
SortedSet s = Collections.synchronizedSortedSet(new TreeSet(...)) 
  • 这个实现持有 fail-fast 机制。

LinkedHashSet 类

LinkedHashSet 继承于 Set,先来看一下 LinkedHashSet 的继承体系:

YK0FHg.png

LinkedHashSet 是 Set 接口的 Hash 表和 LinkedList 的实现。这个实现不同于 HashSet 的是它维护着一个贯穿所有条目的双向链表。此链表定义了元素插入集合的顺序。注意:如果元素重新插入,则插入顺序不会受到影响。

  • LinkedHashSet 有两个影响其构成的参数: 初始容量和加载因子。它们的定义与 HashSet 完全相同。但请注意:对于 LinkedHashSet,选择过高的初始容量值的开销要比 HashSet 小,因为 LinkedHashSet 的迭代次数不受容量影响。
  • 注意 LinkedHashSet 也不是线程安全的,如果多线程同时访问 LinkedHashSet,必须加锁,或者通过使用
Collections.synchronizedSet 
  • 该类也支持fail-fast机制

PriorityQueue

PriorityQueue 是 AbstractQueue 的实现类,优先级队列的元素根据自然排序或者通过在构造函数时期提供Comparator 来排序,具体根据构造器判断。PriorityQueue 不允许 null 元素。

  • 队列的头在某种意义上是指定顺序的最后一个元素。队列查找操作 poll,remove,peek 和 element 访问队列头部元素。
  • 优先级队列是无限制的,但具有内部 capacity,用于控制用于在队列中存储元素的数组大小。
  • 该类以及迭代器实现了 Collection、Iterator 接口的所有可选方法。这个迭代器提供了 iterator() 方法不能保证以任何特定顺序遍历优先级队列的元素。如果你需要有序遍历,考虑使用 Arrays.sort(pq.toArray())
  • 注意这个实现不是线程安全的,多线程不应该并发访问 PriorityQueue 实例如果有某个线程修改了队列的话,使用线程安全的类 PriorityBlockingQueue

HashMap

HashMap 是一个利用哈希表原理来存储元素的集合,并且允许空的 key-value 键值对。HashMap 是非线程安全的,也就是说在多线程的环境下,可能会存在问题,而 Hashtable 是线程安全的容器。HashMap 也支持 fail-fast 机制。HashMap 的实例有两个参数影响其性能:初始容量 和加载因子。可以使用 Collections.synchronizedMap(new HashMap(...)) 来构造一个线程安全的 HashMap。

TreeMap 类

一个基于 NavigableMap 实现的红黑树。这个 map 根据 key 自然排序存储,或者通过 Comparator 进行定制排序。

  • TreeMap 为 containsKey,get,put 和remove方法提供了 log(n) 的时间开销。
  • 注意这个实现不是线程安全的。如果多线程并发访问 TreeMap,并且至少一个线程修改了 map,必须进行外部加锁。这通常通过在自然封装集合的某个对象上进行同步来实现,或者使用 SortedMap m = Collections.synchronizedSortedMap(new TreeMap(...))
  • 这个实现持有fail-fast机制。

LinkedHashMap 类

LinkedHashMap 是 Map 接口的哈希表和链表的实现。这个实现与 HashMap 不同之处在于它维护了一个贯穿其所有条目的双向链表。这个链表定义了遍历顺序,通常是插入 map 中的顺序。

  • 它提供一个特殊的 LinkedHashMap(int,float,boolean) 构造器来创建 LinkedHashMap,其遍历顺序是其最后一次访问的顺序。
  • 可以重写 removeEldestEntry(Map.Entry) 方法,以便在将新映射添加到 map 时强制删除过期映射的策略。
  • 这个类提供了所有可选择的 map 操作,并且允许 null 元素。由于维护链表的额外开销,性能可能会低于HashMap,有一条除外:遍历 LinkedHashMap 中的 collection-views 需要与 map.size 成正比,无论其容量如何。HashMap 的迭代看起来开销更大,因为还要求时间与其容量成正比。
  • LinkedHashMap 有两个因素影响了它的构成:初始容量和加载因子。
  • 注意这个实现不是线程安全的。如果多线程并发访问LinkedHashMap,并且至少一个线程修改了map,必须进行外部加锁。这通常通过在自然封装集合的某个对象上进行同步来实现 Map m = Collections.synchronizedMap(new LinkedHashMap(...))
  • 这个实现持有fail-fast机制。

Hashtable 类

Hashtable 类实现了一个哈希表,能够将键映射到值。任何非空对象都可以用作键或值。

  • 此实现类支持 fail-fast 机制
  • 与新的集合实现不同,Hashtable 是线程安全的。如果不需要线程安全的容器,推荐使用 HashMap,如果需要多线程高并发,推荐使用 ConcurrentHashMap

IdentityHashMap 类

IdentityHashMap 是比较小众的 Map 实现了。

  • 这个类不是一个通用的 Map 实现!虽然这个类实现了 Map 接口,但它故意违反了 Map 的约定,该约定要求在比较对象时使用 equals 方法,此类仅适用于需要引用相等语义的极少数情况。
  • 同 HashMap,IdentityHashMap 也是无序的,并且该类不是线程安全的,如果要使之线程安全,可以调用Collections.synchronizedMap(new IdentityHashMap(...))方法来实现。
  • 支持fail-fast机制

WeakHashMap 类

WeakHashMap 类基于哈希表的 Map 基础实现,带有弱键。WeakHashMap 中的 entry 当不再使用时还会自动移除。更准确的说,给定key的映射的存在将不会阻止 key 被垃圾收集器丢弃。

  • 基于 map 接口,是一种弱键相连,WeakHashMap 里面的键会自动回收
  • 支持 null 值和 null 键。
  • fast-fail 机制
  • 不允许重复
  • WeakHashMap 经常用作缓存

Collections 类

Collections 不属于 Java 框架继承树上的内容,它属于单独的分支,Collections 是一个包装类,它的作用就是为集合框架提供某些功能实现,此类只包括静态方法操作或者返回 collections。

同步包装

同步包装器将自动同步(线程安全性)添加到任意集合。 六个核心集合接口(Collection,Set,List,Map,SortedSet 和 SortedMap)中的每一个都有一个静态工厂方法。

public static  Collection synchronizedCollection(Collection c);
public static  Set synchronizedSet(Set s);
public static  List synchronizedList(List list);
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m);
public static  SortedSet synchronizedSortedSet(SortedSet s);
public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m); 

不可修改的包装

不可修改的包装器通过拦截修改集合的操作并抛出 UnsupportedOperationException,主要用在下面两个情景:

  • 构建集合后使其不可变。在这种情况下,最好不要去获取返回 collection 的引用,这样有利于保证不变性
  • 允许某些客户端以只读方式访问你的数据结构。 你保留对返回的 collection 的引用,但分发对包装器的引用。 通过这种方式,客户可以查看但不能修改,同时保持完全访问权限。

这些方法是:

public static  Collection unmodifiableCollection(Collection<? extends T> c);
public static  Set unmodifiableSet(Set<? extends T> s);
public static  List unmodifiableList(List<? extends T> list);
public static <K,V> Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m);
public static  SortedSet unmodifiableSortedSet(SortedSet<? extends T> s);
public static <K,V> SortedMap<K, V> unmodifiableSortedMap(SortedMap<K, ? extends V> m); 

线程安全的Collections

Java1.5 并发包 (java.util.concurrent) 提供了线程安全的 collections 允许遍历的时候进行修改,通过设计iterator 为 fail-fast 并抛出 ConcurrentModificationException。一些实现类是CopyOnWriteArrayListConcurrentHashMapCopyOnWriteArraySet

Collections 算法

此类包含用于集合框架算法的方法,例如二进制搜索,排序,重排,反向等。

集合实现类特征图

下图汇总了部分集合框架的主要实现类的特征图,让你能有清晰明了看出每个实现类之间的差异性

YK0ENj.png

还有一种类型是关于强引用、弱引用、虚引用的文章,请参考

https://mp.weixin.qq.com/s/ZflBpn2TBzTNv_-G-zZxNg

泛形

在 Jdk1.5 中,提出了一种新的概念,那就是泛型,那么什么是泛型呢?

泛型其实就是一种参数化的集合,它限制了你添加进集合的类型。泛型的本质就是一种参数化类型。多态也可以看作是泛型的机制。一个类继承了父类,那么就能通过它的父类找到对应的子类,但是不能通过其他类来找到具体要找的这个类。泛型的设计之处就是希望对象或方法具有最广泛的表达能力。

下面来看一个例子说明没有泛型的用法

List arrayList = new ArrayList();
arrayList.add("cxuan");
arrayList.add(100);

for(int i = 0; i< arrayList.size();i++){
    String item = (String)arrayList.get(i);
        System.out.println("test === ", item);
} 

这段程序不能正常运行,原因是 Integer 类型不能直接强制转换为 String 类型

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String 

如果我们用泛型进行改写后,示例代码如下

List<String> arrayList = new ArrayList<String>();

arrayList.add(100); 

这段代码在编译期间就会报错,编译器会在编译阶段就能够帮我们发现类似这样的问题。

泛型的使用

泛型的使用有多种方式,下面我们就来一起探讨一下。

用泛型表示类

泛型可以加到类上面,来表示这个类的类型

//此处 T 可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
public class GenericDemo<T>{ 
    //value 这个成员变量的类型为T,T的类型由外部指定 
    private T value;

    public GenericDemo(T value) {
        this.value = value;
    }

    public T getValue(){ //泛型方法getKey的返回值类型为T,T的类型由外部指定
        return value;
    }
 
         public void setValue(T value){
          this.value = value
    }
} 

用泛型表示接口

泛型接口与泛型类的定义及使用基本相同。

//定义一个泛型接口
public interface Generator<T> {
    public T next();
} 

一般泛型接口常用于 生成器(generator) 中,生成器相当于对象工厂,是一种专门用来创建对象的类。

泛型方法

可以使用泛型来表示方法

public class GenericMethods {
  public <T> void f(T x){
    System.out.println(x.getClass().getName());
  }
} 

泛型通配符

List 是泛型类,为了 表示各种泛型 List 的父类,可以使用类型通配符,类型通配符使用问号(?)表示,它的元素类型可以匹配任何类型。例如

public static void main(String[] args) {
    List<String> name = new ArrayList<String>();
    List<Integer> age = new ArrayList<Integer>();
    List<Number> number = new ArrayList<Number>();
    name.add("cxuan");
    age.add(18);
    number.add(314);
    generic(name);
    generic(age);
    generic(number);   
}

public static void generic(List<?> data) {
    System.out.println("Test cxuan :" + data.get(0));
} 

下界通配符 : <? extends ClassType> 该通配符为 ClassType 的所有子类型。它表示的是任何类型都是 ClassType 类型的子类。

上界通配符: <? super ClassType> 该通配符为 ClassType 的所有超类型。它表示的是任何类型的父类都是 ClassType。

反射

反射是 Java 中一个非常重要同时也是一个高级特性,基本上 Spring 等一系列框架都是基于反射的思想写成的。我们首先来认识一下什么反射。

Java 反射机制是在程序的运行过程中,对于任何一个类,都能够知道它的所有属性和方法;对于任意一个对象,都能够知道调用它的任意属性和方法,这种动态获取信息以及动态调用对象方法的功能称为java语言的反射机制。(来源于百度百科)

Java 反射机制主要提供了以下这几个功能

  • 在运行时判断任意一个对象所属的类
  • 在运行时构造任意一个类的对象
  • 在运行时判断任意一个类所有的成员变量和方法
  • 在运行时调用任意一个对象的方法

这么一看,反射就像是一个掌控全局的角色,不管你程序怎么运行,我都能够知道你这个类有哪些属性和方法,你这个对象是由谁调用的,嗯,很屌。

在 Java 中,使用 Java.lang.reflect包实现了反射机制。Java.lang.reflect 所设计的类如下

YK0V4s.png

下面是一个简单的反射类

public class Person {
    public String name;// 姓名
    public int age;// 年龄
 
    public Person() {
        super();
    }
 
    public Person(String name, int age) {
        super();
        this.name = name;
        this.age = age;
    }
 
    public String showInfo() {
        return "name=" + name + ", age=" + age;
    }
}

public class Student extends Person implements Study {
    public String className;// 班级
    private String address;// 住址
 
    public Student() {
        super();
    }
 
    public Student(String name, int age, String className, String address) {
        super(name, age);
        this.className = className;
        this.address = address;
    }
 
    public Student(String className) {
        this.className = className;
    }
 
    public String toString() {
        return "姓名:" + name + ",年龄:" + age + ",班级:" + className + ",住址:"
                + address;
    }
 
    public String getAddress() {
        return address;
    }
 
    public void setAddress(String address) {
        this.address = address;
    }
}

public class TestRelect {
 
    public static void main(String[] args) {
        Class student = null;
        try {
            student = Class.forName("com.cxuan.reflection.Student");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
 
        // 获取对象的所有公有属性。
        Field[] fields = student.getFields();
        for (Field f : fields) {
            System.out.println(f);
        }
        System.out.println("---------------------");
        // 获取对象所有属性,但不包含继承的。
        Field[] declaredFields = student.getDeclaredFields();
        for (Field df : declaredFields) {
            System.out.println(df);
        }
      
          // 获取对象的所有公共方法
        Method[] methods = student.getMethods();
        for (Method m : methods) {
            System.out.println(m);
        }
        System.out.println("---------------------");
        // 获取对象所有方法,但不包含继承的
        Method[] declaredMethods = student.getDeclaredMethods();
        for (Method dm : declaredMethods) {
            System.out.println(dm);
        }
                
          // 获取对象所有的公共构造方法
        Constructor[] constructors = student.getConstructors();
        for (Constructor c : constructors) {
            System.out.println(c);
        }
        System.out.println("---------------------");
        // 获取对象所有的构造方法
        Constructor[] declaredConstructors = student.getDeclaredConstructors();
        for (Constructor dc : declaredConstructors) {
            System.out.println(dc);
        }
      
          Class c = Class.forName("com.cxuan.reflection.Student");
          Student stu1 = (Student) c.newInstance();
          // 第一种方法,实例化默认构造方法,调用set赋值
        stu1.setAddress("河北石家庄");
        System.out.println(stu1);

        // 第二种方法 取得全部的构造函数 使用构造函数赋值
        Constructor<Student> constructor = c.getConstructor(String.class, 
                                                            int.class, String.class, String.class);
        Student student2 = (Student) constructor.newInstance("cxuan", 24, "六班", "石家庄");
        System.out.println(student2);

        /**
        * 獲取方法并执行方法
        */
        Method show = c.getMethod("showInfo");//获取showInfo()方法
        Object object = show.invoke(stu2);//调用showInfo()方法
          
 
    }
} 

有一些是比较常用的,有一些是我至今都没见过怎么用的,下面进行一个归类。

与 Java 反射有关的类主要有

Class 类

在 Java 中,你每定义一个 java class 实体都会产生一个 Class 对象。也就是说,当我们编写一个类,编译完成后,在生成的 .class 文件中,就会产生一个 Class 对象,这个 Class 对象用于表示这个类的类型信息。Class 中没有公共的构造器,也就是说 Class 对象不能被实例化。下面来简单看一下 Class 类都包括了哪些方法

toString()

public String toString() {
  return (isInterface() ? "interface " : (isPrimitive() ? "" : "class "))
    + getName();
} 

toString() 方法能够将对象转换为字符串,toString() 首先会判断 Class 类型是否是接口类型,也就是说,普通类和接口都能够用 Class 对象来表示,然后再判断是否是基本数据类型,这里判断的都是基本数据类型和包装类,还有 void 类型。

所有的类型如下

  • java.lang.Boolean : 代表 boolean 数据类型的包装类
  • java.lang.Character: 代表 char 数据类型的包装类
  • java.lang.Byte: 代表 byte 数据类型的包装类
  • java.lang.Short: 代表 short 数据类型的包装类
  • java.lang.Integer: 代表 int 数据类型的包装类
  • java.lang.Long: 代表 long 数据类型的包装类
  • java.lang.Float: 代表 float 数据类型的包装类
  • java.lang.Double: 代表 double 数据类型的包装类
  • java.lang.Void: 代表 void 数据类型的包装类

然后是 getName() 方法,这个方法返回类的全限定名称。

  • 如果是引用类型,比如 String.class.getName() -> java.lang.String
  • 如果是基本数据类型,byte.class.getName() -> byte
  • 如果是数组类型,new Object[3]).getClass().getName() -> [Ljava.lang.Object

toGenericString()

这个方法会返回类的全限定名称,而且包括类的修饰符和类型参数信息。

forName()

根据类名获得一个 Class 对象的引用,这个方法会使类对象进行初始化。

例如 Class t = Class.forName("java.lang.Thread") 就能够初始化一个 Thread 线程对象

在 Java 中,一共有三种获取类实例的方式

  • Class.forName(java.lang.Thread)
  • Thread.class
  • thread.getClass()

newInstance()

创建一个类的实例,代表着这个类的对象。上面 forName() 方法对类进行初始化,newInstance 方法对类进行实例化。

getClassLoader()

获取类加载器对象。

getTypeParameters()

按照声明的顺序获取对象的参数类型信息。

getPackage()

返回类的包

getInterfaces()

获得当前类实现的类或是接口,可能是有多个,所以返回的是 Class 数组。

Cast

把对象转换成代表类或是接口的对象

asSubclass(Class clazz)

把传递的类的对象转换成代表其子类的对象

getClasses()

返回一个数组,数组中包含该类中所有公共类和接口类的对象

getDeclaredClasses()

返回一个数组,数组中包含该类中所有类和接口类的对象

getSimpleName()

获得类的名字

getFields()

获得所有公有的属性对象

getField(String name)

获得某个公有的属性对象

getDeclaredField(String name)

获得某个属性对象

getDeclaredFields()

获得所有属性对象

getAnnotation(Class annotationClass)

返回该类中与参数类型匹配的公有注解对象

getAnnotations()

返回该类所有的公有注解对象

getDeclaredAnnotation(Class annotationClass)

返回该类中与参数类型匹配的所有注解对象

getDeclaredAnnotations()

返回该类所有的注解对象

getConstructor(Class...<?> parameterTypes)

获得该类中与参数类型匹配的公有构造方法

getConstructors()

获得该类的所有公有构造方法

getDeclaredConstructor(Class...<?> parameterTypes)

获得该类中与参数类型匹配的构造方法

getDeclaredConstructors()

获得该类所有构造方法

getMethod(String name, Class...<?> parameterTypes)

获得该类某个公有的方法

getMethods()

获得该类所有公有的方法

getDeclaredMethod(String name, Class...<?> parameterTypes)

获得该类某个方法

getDeclaredMethods()

获得该类所有方法

Field 类

Field 类提供类或接口中单独字段的信息,以及对单独字段的动态访问。

这里就不再对具体的方法进行介绍了,读者有兴趣可以参考官方 API

这里只介绍几个常用的方法

equals(Object obj)

属性与obj相等则返回true

get(Object obj)

获得obj中对应的属性值

set(Object obj, Object value)

设置obj中对应属性值

Method 类

invoke(Object obj, Object... args)

传递object对象及参数调用该对象对应的方法

ClassLoader 类

反射中,还有一个非常重要的类就是 ClassLoader 类,类装载器是用来把类(class) 装载进 JVM 的。ClassLoader 使用的是双亲委托模型来搜索加载类的,这个模型也就是双亲委派模型。ClassLoader 的类继承图如下

YK0eCn.png

枚举

枚举可能是我们使用次数比较少的特性,在 Java 中,枚举使用 enum 关键字来表示,枚举其实是一项非常有用的特性,你可以把它理解为具有特定性质的类。enum 不仅仅 Java 有,C 和 C++ 也有枚举的概念。下面是一个枚举的例子。

public enum Family {

    FATHER,
    MOTHER,
    SON,
    Daughter;

} 

上面我们创建了一个 Family的枚举类,它具有 4 个值,由于枚举类型都是常量,所以都用大写字母来表示。那么 enum 创建出来了,该如何引用呢?

public class EnumUse {

    public static void main(String[] args) {
        Family s = Family.FATHER;
    }
} 

枚举特性

enum 枚举这个类比较有意思,当你创建完 enum 后,编译器会自动为你的 enum 添加 toString() 方法,能够让你方便的显示 enum 实例的具体名字是什么。除了 toString() 方法外,编译器还会添加 ordinal() 方法,这个方法用来表示 enum 常量的声明顺序,以及 values() 方法显示顺序的值。

public static void main(String[] args) {

  for(Family family : Family.values()){
    System.out.println(family + ", ordinal" + family.ordinal());
  }
} 

enum 可以进行静态导入包,静态导入包可以做到不用输入 枚举类名.常量,可以直接使用常量,神奇吗? 使用 ennum 和 static关键字可以做到静态导入包

YK0m3q.pngYK0ng0.png

上面代码导入的是 Family 中所有的常量,也可以单独指定常量。

枚举和普通类一样

枚举就和普通类一样,除了枚举中能够方便快捷的定义常量,我们日常开发使用的 public static final xxx 其实都可以用枚举来定义。在枚举中也能够定义属性和方法,千万不要把它看作是异类,它和万千的类一样。

public enum OrdinalEnum {

    WEST("live in west"),
    EAST("live in east"),
    SOUTH("live in south"),
    NORTH("live in north");

    String description;

    OrdinalEnum(String description){
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public static void main(String[] args) {
        for(OrdinalEnum ordinalEnum : OrdinalEnum.values()){
            System.out.println(ordinalEnum.getDescription());
        }
    }
} 

一般 switch 可以和 enum 一起连用,来构造一个小型的状态转换机。

enum Signal {
  GREEN, YELLOW, RED
}

public class TrafficLight {
    Signal color = Signal.RED;

    public void change() {
        switch (color) {
        case RED:
            color = Signal.GREEN;
            break;
        case YELLOW:
            color = Signal.RED;
            break;
        case GREEN:
            color = Signal.YELLOW;
            break;
        }
    }
} 

是不是代码顿时觉得优雅整洁了些许呢?

枚举神秘之处

在 Java 中,万事万物都是对象,enum 虽然是个关键字,但是它却隐式的继承于 Enum 类。我们来看一下 Enum 类,此类位于 java.lang 包下,可以自动引用。

YK0ng0.png

此类的属性和方法都比较少。你会发现这个类中没有我们的 values 方法。前面刚说到,values() 方法是你使用枚举时被编译器添加进来的 static 方法。可以使用反射来验证一下

除此之外,enum 还和 Class 类有交集,在 Class 类中有三个关于 Enum 的方法

YK0uvV.png

前面两个方法用于获取 enum 常量,isEnum 用于判断是否是枚举类型的。

枚举类

除了 Enum 外,还需要知道两个关于枚举的工具类,一个是 EnumSet ,一个是 EnumMap

EnumSet 和 EnumMap

EnumSet 是 JDK1.5 引入的,EnumSet 的设计充分考虑到了速度因素,使用 EnumSet 可以作为 Enum 的替代者,因为它的效率比较高。

EnumMap 是一种特殊的 Map,它要求其中的 key 键值是来自一个 enum。因为 EnumMap 速度也很快,我们可以使用 EnumMap 作为 key 的快速查找。

总的来说,枚举的使用不是很复杂,它也是 Java 中很小的一块功能,但有时却能够因为这一个小技巧,能够让你的代码变得优雅和整洁。

I/O

创建一个良好的 I/O 程序是非常复杂的。JDK 开发人员编写了大量的类只为了能够创建一个良好的工具包,想必编写 I/O 工具包很费劲吧。

IO 类设计出来,肯定是为了解决 IO 相关操作的,最常见的 I/O 读写就是网络、磁盘等。在 Java 中,对文件的操作是一个典型的 I/O 操作。下面我们就对 I/O 进行一个分类。

YK0MuT.png

公号回复 IO获取思维导图

I/O 还可以根据操作对象来进行区分:主要分为

YK0QDU.png

除此之外,I/O 中还有其他比较重要的类

File 类

File 类是对文件系统中文件以及文件夹进行操作的类,可以通过面向对象的思想操作文件和文件夹,是不是很神奇?

文件创建操作如下,主要涉及 文件创建、删除文件、获取文件描述符等

class FileDemo{
   public static void main(String[] args) {
       File file = new File("D:file.txt");
       try{
         f.createNewFile(); // 创建一个文件
         
         // File类的两个常量
         //路径分隔符(与系统有关的)<windows里面是 ; linux里面是 : >
        System.out.println(File.pathSeparator);  //   ;
        //与系统有关的路径名称分隔符<windows里面是  linux里面是/ >
        System.out.println(File.separator);      //  
         
         // 删除文件
         /*
         File file = new File(fileName);
         if(f.exists()){
             f.delete();
         }else{
             System.out.println("文件不存在");
         }   
         */

         
       }catch (Exception e) {
           e.printStackTrace();
       }
    }
} 

也可以对文件夹进行操作

class FileDemo{
  public static void main(String[] args) {
    String fileName = "D:"+ File.separator + "filepackage";
    File file = new File(fileName);
    f.mkdir();
    
        // 列出所有文件
    /*
    String[] str = file.list();
    for (int i = 0; i < str.length; i++) {
      System.out.println(str[i]);
    }
    */
    
    // 使用 file.listFiles(); 列出所有文件,包括隐藏文件
    
    // 使用 file.isDirectory() 判断指定路径是否是目录
  }
} 

上面只是举出来了两个简单的示例,实际上,还有一些其他对文件的操作没有使用。比如创建文件,就可以使用三种方式来创建

File(String directoryPath);
File(String directoryPath, String filename);
File(File dirObj, String filename); 

directoryPath 是文件的路径名,filename 是文件名,dirObj 是一个 File 对象。例如

File file = new File("D:javafile1.txt");  //双是转义
System.out.println(file);
File file2 = new File("D:java","file2.txt");//父路径、子路径--可以适用于多个文件的!
System.out.println(file2);
File parent = new File("D:java");
File file3 = new File(parent,"file3.txt");//File类的父路径、子路径
System.out.println(file3); 

现在对 File 类进行总结

YK0lbF.png

基础 IO 类和相关方法

虽然. IO 类有很多,但是最基本的是四个抽象类,InputStream、OutputStream、Reader、Writer。最基本的方法也就是 read()write() 方法,其他流都是上面这四类流的子类,方法也是通过这两类方法衍生而成的。而且大部分的 IO 源码都是 native 标志的,也就是说源码都是 C/C++ 写的。这里我们先来认识一下这些流类及其方法

InputStream

InputStream 是一个定义了 Java 流式字节输入模式的抽象类。该类的所有方法在出错条件下引发一个IOException 异常。它的主要方法定义如下

YK03E4.png

OutputStream

OutputStream 是定义了流式字节输出模式的抽象类。该类的所有方法返回一个void 值并且在出错情况下引发一个IOException异常。它的主要方法定义如下

YK08UJ.png

Reader 类

Reader 是 Java 定义的流式字符输入模式的抽象类。类中的方法在出错时引发 IOException 异常。

YK0YCR.png

Writer 类

Writer 是定义流式字符输出的抽象类。 所有该类的方法都返回一个 void 值并在出错条件下引发 IOException 异常

YK0t81.png

InputStream 及其子类

FileInputStream 文件输入流: FileInputStream 类创建一个能从文件读取字节的 InputStream 类

ByteArrayInputStream 字节数组输入流 : 把内存中的一个缓冲区作为 InputStream 使用

PipedInputStream 管道输入流: 实现了pipe 管道的概念,主要在线程中使用

SequenceInputStream 顺序输入流:把多个 InputStream 合并为一个 InputStream

FilterOutputStream 过滤输入流:其他输入流的包装。

ObjectInputStream 反序列化输入流 : 将之前使用 ObjectOutputStream 序列化的原始数据恢复为对象,以流的方式读取对象

DataInputStream : 数据输入流允许应用程序以与机器无关方式从底层输入流中读取基本 Java 数据类型。

PushbackInputStream 推回输入流: 缓冲的一个新颖的用法是实现推回 (pushback) 。 Pushback 用于输入流允许字节被读取然后返回到流。

OutputStream 及其子类

FileOutputStream 文件输出流: 该类实现了一个输出流,其数据写入文件。

ByteArrayOutputStream 字节数组输出流 :该类实现了一个输出流,其数据被写入由 byte 数组充当的缓冲区,缓冲区会随着数据的不断写入而自动增长。

PipedOutputStream 管道输出流 :管道的输出流,是管道的发送端。

ObjectOutputStream 基本类型输出流 :该类将实现了序列化的对象序列化后写入指定地方。

FilterOutputStream 过滤输出流:其他输出流的包装。

PrintStream 打印流 通过 PrintStream 可以将文字打印到文件或者网络中去。

DataOutputStream : 数据输出流允许应用程序以与机器无关方式向底层输出流中写入基本 Java 数据类型。

Reader 及其子类

FileReader 文件字符输入流 : 把文件转换为字符流读入

CharArrayReader 字符数组输入流 : 是一个把字符数组作为源的输入流的实现

BufferedReader 缓冲区输入流 : BufferedReader 类从字符输入流中读取文本并缓冲字符,以便有效地读取字符,数组和行

PushbackReader : PushbackReader 类允许一个或多个字符被送回输入流。

PipedReader 管道输入流: 主要用途也是在线程间通讯,不过这个可以用来传输字符

Writer 及其子类

FileWriter 字符输出流 : FileWriter 创建一个可以写文件的 Writer 类。

CharArrayWriter 字符数组输出流: CharArrayWriter 实现了以数组作为目标的输出流。

BufferedWriter 缓冲区输出流 : BufferedWriter是一个增加了flush( ) 方法的Writer。 flush( )方法可以用来确保数据缓冲器确实被写到实际的输出流。

PrintWriter : PrintWriter 本质上是 PrintStream 的字符形式的版本。

PipedWriter 管道输出流: 主要用途也是在线程间通讯,不过这个可以用来传输字符

Java 的输入输出的流式接口为复杂而繁重的任务提供了一个简洁的抽象。过滤流类的组合允许你动态建立客户端流式接口来配合数据传输要求。继承高级流类 InputStream、InputStreamReader、 Reader 和 Writer 类的 Java 程序在将来 (即使创建了新的和改进的具体类)也能得到合理运用。

注解

Java 注解(Annotation) 又称为元数据 ,它为我们在代码中添加信息提供了一种形式化的方法。它是 JDK1.5 引入的,Java 定义了一套注解,共有 7 个,3 个在 java.lang 中,剩下 4 个在 java.lang.annotation 中。

作用在代码中的注解有三个,它们分别是

  • @Override: 重写标记,一般用在子类继承父类后,标注在重写过后的子类方法上。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。
  • @Deprecated :用此注解注释的代码已经过时,不再推荐使用
  • @SuppressWarnings: 这个注解起到忽略编译器的警告作用

元注解有四个,元注解就是用来标志注解的注解。它们分别是

  • @Retention: 标识如何存储,是只在代码中,还是编入class文件中,或者是在运行时可以通过反射访问。

RetentionPolicy.SOURCE:注解只保留在源文件,当 Java 文件编译成class文件的时候,注解被遗弃;

RetentionPolicy.CLASS:注解被保留到 class 文件,但 jvm 加载 class 文件时候被遗弃,这是默认的生命周期;

RetentionPolicy.RUNTIME:注解不仅被保存到 class 文件中,jvm 加载 class 文件之后,仍然存在;

  • @Documented: 标记这些注解是否包含在 JavaDoc 中。
  • @Target: 标记这个注解说明了 Annotation 所修饰的对象范围,Annotation 可被用于 packages、types(类、接口、枚举、Annotation类型)、类型成员(方法、构造方法、成员变量、枚举值)、方法参数和本地变量(如循环变量、catch参数)。取值如下
public enum ElementType {
    TYPE,
    FIELD,
    METHOD,
    PARAMETER,
    CONSTRUCTOR,
    LOCAL_VARIABLE,
    ANNOTATION_TYPE,
    PACKAGE,
    TYPE_PARAMETER,
    TYPE_USE 
  • @Inherited : 标记这个注解是继承于哪个注解类的。

从 JDK1.7 开始,又添加了三个额外的注解,它们分别是

  • @SafeVarargs :在声明可变参数的构造函数或方法时,Java 编译器会报 unchecked 警告。使用 @SafeVarargs 可以忽略这些警告
  • @FunctionalInterface: 表明这个方法是一个函数式接口
  • @Repeatable: 标识某注解可以在同一个声明上使用多次。
注意:注解是不支持继承的。

关于 null 的几种处理方式

对于 Java 程序员来说,空指针一直是恼人的问题,我们在开发中经常会受到 NullPointerException 的蹂躏和壁咚。Java 的发明者也承认这是一个巨大的设计错误。

那么关于 null ,你应该知道下面这几件事情来有效的了解 null ,从而避免很多由 null 引起的错误。

YK0Ngx.png

大小写敏感

首先,null 是 Java 中的关键字,像是 public、static、final。它是大小写敏感的,你不能将 null 写成 Null 或 NULL,编辑器将不能识别它们然后报错。

YK0Uv6.png

这个问题已经几乎不会出现,因为 eclipse 和 Idea 编译器已经给出了编译器提示,所以你不用考虑这个问题。

null 是任何引用类型的初始值

null 是所有引用类型的默认值,Java 中的任何引用变量都将null作为默认值,也就是说所有 Object 类下的引用类型默认值都是 null。这对所有的引用变量都适用。就像是基本类型的默认值一样,例如 int 的默认值是 0,boolean 的默认值是 false。

下面是基本数据类型的初始值

YK0dKK.png

null 只是一种特殊的值

null 既不是对象也不是一种类型,它仅是一种特殊的值,你可以将它赋予任何类型,你可以将 null 转换为任何类型

public static void main(String[] args) {
  String str = null;
  Integer itr = null;
  Double dou = null;

  Integer integer = (Integer) null;
  String string = (String)null;

  System.out.println("integer = " + integer);
  System.out.println("string = " + string);
} 

你可以看到在编译期和运行期内,将 null 转换成任何的引用类型都是可行的,并且不会抛出空指针异常。

null 只能赋值给引用变量,不能赋值给基本类型变量

持有 null 的包装类在进行自动拆箱的时候,不能完成转换,会抛出空指针异常,并且 null 也不能和基本数据类型进行对比

public static void main(String[] args) {
  int i = 0;
  Integer itr = null;
  System.out.println(itr == i);
} 

使用了带有 null 值的引用类型变量,instanceof 操作会返回 false

public static void main(String[] args) {
  Integer isNull = null;
  // instanceof = isInstance 方法
  if(isNull instanceof Integer){
    System.out.println("isNull is instanceof Integer");
  }else{
    System.out.println("isNull is not instanceof Integer");
  }
} 

这是 instanceof 操作符一个很重要的特性,使得对类型强制转换检查很有用

静态变量为 null 调用静态方法不会抛出 NullPointerException。因为静态方法使用了静态绑定

使用 Null-Safe 方法

你应该使用 null-safe 安全的方法,java 类库中有很多工具类都提供了静态方法,例如基本数据类型的包装类,Integer , Double 等。例如

public class NullSafeMethod {

    private static String number;

    public static void main(String[] args) {
        String s = String.valueOf(number);
        String string = number.toString();
        System.out.println("s = " + s);
        System.out.println("string = " + string);
    }
} 

number 没有赋值,所以默认为null,使用String.value(number) 静态方法没有抛出空指针异常,但是使用 toString() 却抛出了空指针异常。所以尽量使用对象的静态方法。

null 判断

你可以使用 == 或者 != 操作来比较 null 值,但是不能使用其他算法或者逻辑操作,例如小于或者大于。跟SQL不一样,在Java中 null == null 将返回 true,如下所示:

public class CompareNull {

    private static String str1;
    private static String str2;

    public static void main(String[] args) {
        System.out.println("str1 == str2 ? " + str1 == str2);
        System.out.println(null == null);
    }
} 

关于思维导图

我把一些常用的 Java 工具包的思维导图做了汇总,方便读者查阅。

Java.IO

YK0wDO.png

Java.lang

YK00bD.png

Java.math

YK0DVe.png

Java.net

YK0rUH.png

这本 PDF 的百度网盘我已经给你放出来了,你可以直接下载

链接:https://pan.baidu.com/s/1mYAeS9hIhdMFh2rF3FDk0A密码: p9rs

查看原文

赞 25 收藏 20 评论 0

程序员cxuan 发布了文章 · 10月20日

计算机网络基础知识总结

我把自己以往的文章汇总成为了 Github ,欢迎各位大佬 star
https://github.com/crisxuan/b...

如果说计算机把我们从工业时代带到了信息时代,那么计算机网络就可以说把我们带到了网络时代。随着使用计算机人数的不断增加,计算机也经历了一系列的发展,从大型通用计算机 -> 超级计算机 -> 小型机 -> 个人电脑 -> 工作站 -> 便携式电脑 -> 智能手机终端等都是这一过程的产物。计算机网络也逐渐从独立模式演变为了 网络互联模式

可以看到,在独立模式下,每个人都需要排队等待其他人在一个机器上完成工作后,其他用户才能使用。这样的数据是单独管理的。

现在切换到了网络互联模式,在这种模式下,每个人都能独立的使用计算机,甚至还会有一个服务器,来为老大哥、cxuan 和 sonsong 提供服务。这样的数据是集中管理的。

计算机网络按规模进行划分,有 WAN(Wide Area Network, 广域网)LAN(Local area Network, 局域网)。如下图所示

上面是局域网,一般用在狭小区域内的网络,一个社区、一栋楼、办公室经常使用局域网。

距离较远的地方组成的网络一般是广域网。

最初,只是固定的几台计算机相连在一起形成计算机网络。这种网络一般是私有的,这几台计算机之外的计算机无法访问。随着时代的发展,人们开始尝试在私有网络上搭建更大的私有网络,逐渐又发展演变为互联网,现在我们每个人几乎都能够享有互联网带来的便利。

计算机网络发展历程

批处理

就和早期的计算机操作系统一样,最开始都要先经历批处理(atch Processing)阶段,批处理的目的也是为了能让更多的人使用计算机。

批处理就是事先将数据装入卡带或者磁带,并且由计算机按照一定的顺序进行读入。

当时这种计算机的价格比较昂贵,并不是每个人都能够使用的,这也就客观暗示着,只有专门的操作员才能使用计算机,用户把程序提交给操作员,由操作员排队执行程序,等一段时间后,用户再来提取结果。

这种计算机的高效性并没有很好的体现,甚至不如手动运算快。

分时系统

在批处理之后出现的就是分时系统了,分时系统指的是多个终端与同一个计算机连接,允许多个用户同时使用一台计算机。分时系统的出现实现了一人一机的目的,让用户感觉像是自己在使用计算机,实际上这是一种 独占性 的特性。

分时系统出现以来,计算机的可用性得到了极大的改善。分时系统的出现意味着计算机越来越贴近我们的生活。

还有一点需要注意:分时系统的出现促进了像是 BASIC 这种人机交互语言的诞生。

分时系统的出现,同时促进者计算机网络的出现。

计算机通信

在分时系统中,每个终端与计算机相连,这种独占性的方式并不是计算机之间的通信,因为每个人还是在独立的使用计算机。

到了 20 世纪 70 年代,计算机性能有了高速发展,同时体积也变得越来越小,使用计算机的门槛变得更低,越来越多的用户可以使用计算机。

没有一个计算机是信息孤岛促使着计算机网络的出现和发展。

计算机网络的诞生

20 世纪 80 年代,一种能够互连多种计算机的网络随之诞生。它能够让各式各样的计算机相连,从大型的超级计算机或主机到小型电脑。

20 世纪 90 年代,真正实现了一人一机的环境,但是这种环境的搭建仍然价格不菲。与此同时,诸如电子邮件(E-mail)万维网(WWW,World Wide Web) 等信息传播方式如雨后春笋般迎来了前所未有的发展,使得互联网从大到整个公司小到每个家庭内部,都得以广泛普及。

计算机网络的高速发展

现如今,越来越多的终端设备接入互联网,使互联网经历了前所未有的高潮,近年来 3G、4G、5G 通信技术的发展更是互联网高速发展的产物。

许多发展道路各不相同的网络技术也都正在向互联网靠拢。例如,曾经一直作为通信基础设施、支撑通信网络的电话网。随着互联网的发展,其地位也随着时间的推移被 IP(Internet Protocol) 网所取代,IP 也是互联网发展的产物。

网络安全

正如互联网也具有两面性,互联网的出现方便了用户,同时也方便了一些不法分子。互联网的便捷也带来了一些负面影响,计算机病毒的侵害、信息泄漏、网络诈骗层出不穷。

在现实生活中,通常情况下我们挨揍了会予以反击,但是在互联网中,你被不法分子攻击通常情况下是无力还击的,只能防御,因为还击需要你精通计算机和互联网,这通常情况下很多人办不到。

通常情况下公司和企业容易被作为不法分子获利的对象,所以,作为公司或者企业,要想不受攻击或者防御攻击,需要建立安全的互联网连接。

互联网协议

协议这个名词不仅局限于互联网范畴,也体现在日常生活中,比如情侣双方约定好在哪个地点吃饭,这个约定也是一种协议,比如你应聘成功了,企业会和你签订劳动合同,这种双方的雇佣关系也是一种 协议。注意自己一个人对自己的约定不能成为协议,协议的前提条件必须是多人约定。

那么网络协议是什么呢?

网络协议就是网络中(包括互联网)传递、管理信息的一些规范。如同人与人之间相互交流是需要遵循一定的规矩一样,计算机之间的相互通信需要共同遵守一定的规则,这些规则就称为网络协议

没有网络协议的互联网是混乱的,就和人类社会一样,人不能想怎么样就怎么样,你的行为约束是受到法律的约束的;那么互联网中的端系统也不能自己想发什么发什么,也是需要受到通信协议约束的。

我们一般都了解过 HTTP 协议, HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范

但是互联网又不只有 HTTP 协议,它还有很多其他的比如 IP、TCP、UDP、DNS 协议等。下面是一些协议的汇总和介绍

网络体系结构协议主要用途
TCP/IPHTTP、SMTP、TELNET、IP、ICMP、TCP、UDP 等主要用于互联网、局域网
IPX/SPXIPX、NPC、SPX主要用于个人电脑局域网
AppleTalkAEP、ADP、DDP苹果公司现有产品互联

ISO 在制定标准化的 OSI 之前,对网络体系结构相关的问题进行了充分的探讨,最终提出了作为通信协议设计指标的 OSI 参考模型。这一模型将通信协议中必要的功能分为了 7 层。通过这 7 层分层,使那些比较复杂的协议简单化。

在 OSI 标准模型中,每一层协议都接收由它下一层所提供的特定服务,并且负责为上一层提供服务,上层协议和下层协议之间通常会开放 接口,同一层之间的交互所遵守的约定叫做 协议

OSI 标准模型

上图只是简单的介绍了一下层与层之间的通信规范和上层与下层的通信规范,并未介绍具体的网络协议分层,实际上,OSI 标准模型将复杂的协议整理并分为了易于理解的 7 层。如下图所示

互联网的通信协议都对应了 7 层中的某一层,通过这一点,可以了解协议在整个网络模型中的作用,一般来说,各个分层的主要作用如下

  • 应用层:应用层是 OSI 标准模型的最顶层,是直接为应用进程提供服务的。其作用是在实现多个系统应用进程相互通信的同时,完成一系列业务处理所需的服务。包括文件传输、电子邮件远程登录和远端接口调用等协议。
  • 表示层: 表示层向上对应用进程服务,向下接收会话层提供的服务,表示层位于 OSI 标准模型的第六层,表示层的主要作用就是将设备的固有数据格式转换为网络标准传输格式。
  • 会话层:会话层位于 OSI 标准模型的第五层,它是建立在传输层之上,利用传输层提供的服务建立和维持会话。
  • 传输层:传输层位于 OSI 标准模型的第四层,它在整个 OSI 标准模型中起到了至关重要的作用。传输层涉及到两个节点之间的数据传输,向上层提供可靠的数据传输服务。传输层的服务一般要经历传输连接建立阶段,数据传输阶段,传输连接释放阶段 3 个阶段才算完成一个完整的服务过程。
  • 网络层:网络层位于 OSI 标准模型的第三层,它位于传输层和数据链路层的中间,将数据设法从源端经过若干个中间节点传送到另一端,从而向运输层提供最基本的端到端的数据传送服务。
  • 数据链路层:数据链路层位于物理层和网络层中间,数据链路层定义了在单个链路上如何传输数据。
  • 物理层:物理层是 OSI 标准模型中最低的一层,物理层是整个 OSI 协议的基础,就如同房屋的地基一样,物理层为设备之间的数据通信提供传输媒体及互连设备,为数据传输提供可靠的环境。

TCP/IP 协议簇

TCP/IP 协议是我们程序员接触最多的协议,实际上,TCP/IP 又被称为 TCP/IP 协议簇,它并不特指单纯的 TCP 和 IP 协议,而是容纳了许许多多的网络协议。

OSI 模型共有七层,从下到上分别是物理层、数据链路层、网络层、运输层、会话层、表示层和应用层。但是这显然是有些复杂的,所以在TCP/IP协议中,它们被简化为了四个层次

和 OSI 七层网络协议的主要区别如下

  • 应用层、表示层、会话层三个层次提供的服务相差不是很大,所以在 TCP/IP 协议中,它们被合并为应用层一个层次。
  • 由于数据链路层和物理层的内容很相似,所以在 TCP/IP 协议中它们被归并在网络接口层一个层次里。
我们的主要研究对象就是 TCP/IP 的四层协议。

下面 cxuan 和你聊一聊 TCP/IP 协议簇中都有哪些具体的协议

IP 协议

IP 是 互联网协议(Internet Protocol) ,位于网络层。IP是整个 TCP/IP 协议族的核心,也是构成互联网的基础。IP 能够为运输层提供数据分发,同时也能够组装数据供运输层使用。它将多个单个网络连接成为一个互联网,这样能够提高网络的可扩展性,实现大规模的网络互联。二是分割顶层网络和底层网络之间的耦合关系。

ICMP 协议

ICMP 协议是 Internet Control Message Protocol, ICMP 协议主要用于在 IP 主机、路由器之间传递控制消息。ICMP 属于网络层的协议,当遇到 IP 无法访问目标、IP 路由器无法按照当前传输速率转发数据包时,会自动发送 ICMP 消息,从这个角度来说,ICMP 协议可以看作是 错误侦测与回报机制,让我们检查网络状况、也能够确保连线的准确性。

ARP 协议

ARP 协议是 地址解析协议,即 Address Resolution Protocol,它能够根据 IP 地址获取物理地址。主机发送信息时会将包含目标 IP 的 ARP 请求广播到局域网络上的所有主机,并接受返回消息,以此来确定物理地址。收到消息后的物理地址和 IP 地址会在 ARP 中缓存一段时间,下次查询的时候直接从 ARP 中查询即可。

TCP 协议

TCP 就是 传输控制协议,也就是 Transmission Control Protocol,它是一种面向连接的、可靠的、基于字节流的传输协议,TCP 协议位于传输层,TCP 协议是 TCP/IP 协议簇中的核心协议,它最大的特点就是提供可靠的数据交付。

TCP 的主要特点有 慢启动、拥塞控制、快速重传、可恢复

UDP 协议

UDP 协议就是 用户数据报协议,也就是 User Datagram Protocol,UDP 也是一种传输层的协议,与 TCP 相比,UDP 提供一种不可靠的数据交付,也就是说,UDP 协议不保证数据是否到达目标节点,也就是说,当报文发送之后,是无法得知其是否安全完整到达的。UDP 是一种无连接的协议,传输数据之前源端和终端无需建立连接,不对数据报进行检查与修改,无须等待对方的应答,会出现分组丢失、重复、乱序等现象。但是 UDP 具有较好的实时性,工作效率较 TCP 协议高。

FTP 协议

FTP 协议是 文件传输协议,英文全称是 File Transfer Protocol,应用层协议之一,是 TCP/IP 协议的重要组成之一,FTP 协议分为服务器和客户端两部分,FTP 服务器用来存储文件,FTP 客户端用来访问 FTP 服务器上的文件,FTP 的传输效率比较高,所以一般使用 FTP 来传输大文件。

DNS 协议

DNS 协议是 域名系统协议,英文全称是 Domain Name System,它也是应用层的协议之一,DNS 协议是一个将域名和 IP 相互映射的分布式数据库系统。DNS 缓存能够加快网络资源的访问。

SMTP 协议

SMTP 协议是 简单邮件传输协议,英文全称是 Simple Mail Transfer Protocol,应用层协议之一,SMTP 主要是用作邮件收发协议,SMTP 服务器是遵循 SMTP 协议的发送邮件服务器,用来发送或中转用户发出的电子邮件

SLIP 协议

SLIP 协议是指串行线路网际协议(Serial Line Internet Protocol) ,是在串行通信线路上支持 TCP/IP 协议的一种点对点(Point-to-Point)式的链路层通信协议。

PPP 协议

PPP 协议是 Point to Point Protocol,即点对点协议,是一种链路层协议,是在为同等单元之间传输数据包而设计的。设计目的主要是用来通过拨号或专线方式建立点对点连接发送数据,使其成为各种主机、网桥和路由器之间简单连接的一种共通的解决方案。

网络核心概念

传输方式

网络根据传输方式可以进行分类,一般分成两种 面向连接型和面向无连接型

  • 面向连接型中,在发送数据之前,需要在主机之间建立一条通信线路。
  • 面向无连接型则不要求建立和断开连接,发送方可用于任何时候发送数据。接收端也不知道自己何时从哪里接收到数据。

分组交换

在互联网应用中,每个终端系统都可以彼此交换信息,这种信息也被称为 报文(Message),报文是一个集大成者,它可以包括你想要的任何东西,比如文字、数据、电子邮件、音频、视频等。为了从源目的地向端系统发送报文,需要把长报文切分为一个个小的数据块,这种数据块称为分组(Packets),也就是说,报文是由一个个小块的分组组成。在端系统和目的地之间,每个分组都要经过通信链路(communication links)分组交换机(switch packets) ,分组要在端系统之间交互需要经过一定的时间,如果两个端系统之间需要交互的分组为 L 比特,链路的传输速率问 R 比特/秒,那么传输时间就是 L / R秒。

一个端系统需要经过交换机给其他端系统发送分组,当分组到达交换机时,交换机就能够直接进行转发吗?不是的,交换机可没有这么无私,你想让我帮你转发分组?好,首先你需要先把整个分组数据都给我,我再考虑给你发送的问题,这就是存储转发传输

存储转发传输

存储转发传输指的就是交换机再转发分组的第一个比特前,必须要接受到整个分组,下面是一个存储转发传输的示意图,可以从图中窥出端倪

由图可以看出,分组 1、2、3 向交换器进行分组传输,并且交换机已经收到了分组1 发送的比特,此时交换机会直接进行转发吗?答案是不会的,交换机会把你的分组先缓存在本地。这就和考试作弊一样,一个学霸要经过学渣 A 给学渣 B 传答案,学渣 A 说,学渣 A 在收到答案后,它可能直接把卷子传过去吗?学渣A 说,等我先把答案抄完(保存功能)后再把卷子给你。

排队时延和分组丢失

什么?你认为交换机只能和一条通信链路进行相连?那你就大错特错了,这可是交换机啊,怎么可能只有一条通信链路呢?

所以我相信你一定能想到这个问题,多个端系统同时给交换器发送分组,一定存在顺序到达排队的问题。事实上,对于每条相连的链路,该分组交换机会有一个输出缓存(output buffer)输出队列(output queue) 与之对应,它用于存储路由器准备发往每条链路的分组。如果到达的分组发现路由器正在接收其他分组,那么新到达的分组就会在输出队列中进行排队,这种等待分组转发所耗费的时间也被称为 排队时延,上面提到分组交换器在转发分组时会进行等待,这种等待被称为 存储转发时延,所以我们现在了解到的有两种时延,但是其实是有四种时延。这些时延不是一成不变的,其变化程序取决于网络的拥塞程度。

因为队列是有容量限制的,当多条链路同时发送分组导致输出缓存无法接受超额的分组后,这些分组会丢失,这种情况被称为 丢包(packet loss),到达的分组或者已排队的分组将会被丢弃。

下图说明了一个简单的分组交换网络

在上图中,分组由三位数据平板展示,平板的宽度表示着分组数据的大小。所有的分组都有相同的宽度,因此也就有相同的数据包大小。下面来一个情景模拟: 假定主机 A 和 主机 B 要向主机 E 发送分组,主机 A 和 B 首先通过100 Mbps以太网链路将其数据包发送到第一台路由器,然后路由器将这些数据包定向到15 Mbps 的链路。如果在较短的时间间隔内,数据包到达路由器的速率(转换为每秒比特数)超过15 Mbps,则在数据包在链路输出缓冲区中排队之前,路由器上会发生拥塞,然后再传输到链路上。例如,如果主机 A 和主机 B 背靠背同时发了5包数据,那么这些数据包中的大多数将花费一些时间在队列中等待。实际上,这种情况与许多普通情况完全相似,例如,当我们排队等候银行出纳员或在收费站前等候时。

转发表和路由器选择协议

我们刚刚讲过,路由器和多个通信线路进行相连,如果每条通信链路同时发送分组的话,可能会造成排队和丢包的情况,然后分组在队列中等待发送,现在我就有一个问题问你,队列中的分组发向哪里?这是由什么机制决定的?

换个角度想问题,路由的作用是什么?把不同端系统中的数据包进行存储和转发 。在因特网中,每个端系统都会有一个 IP 地址,当原主机发送一个分组时,在分组的首部都会加上原主机的 IP 地址。每一台路由器都会有一个 转发表(forwarding table),当一个分组到达路由器后,路由器会检查分组的目的地址的一部分,并用目的地址搜索转发表,以找出适当的传送链路,然后映射成为输出链路进行转发。

那么问题来了,路由器内部是怎样设置转发表的呢?详细的我们后面会讲到,这里只是说个大概,路由器内部也是具有路由选择协议的,用于自动设置转发表。

电路交换

在计算机网络中,另一种通过网络链路和路由进行数据传输的另外一种方式就是 电路交换(circuit switching)。电路交换在资源预留上与分组交换不同,什么意思呢?就是分组交换不会预留每次端系统之间交互分组的缓存和链路传输速率,所以每次都会进行排队传输;而电路交换会预留这些信息。一个简单的例子帮助你理解:这就好比有两家餐馆,餐馆 A 需要预定而餐馆 B 不需要预定,对于可以预定的餐馆 A,我们必须先提前与其进行联系,但是当我们到达目的地时,我们能够立刻入座并选菜。而对于不需要预定的那家餐馆来说,你可能不需要提前联系,但是你必须承受到达目的地后需要排队的风险。

下面显示了一个电路交换网络

在这个网络中,4条链路用于4台电路交换机。这些链路中的每一条都有4条电路,因此每条链路能支持4条并行的链接。每台主机都与一台交换机直接相连,当两台主机需要通信时,该网络在两台主机之间创建一条专用的 端到端的链接(end-to-end connection)

分组交换和电路交换的对比

分组交换的支持者经常说分组交换不适合实时服务,因为它的端到端时延时不可预测的。而分组交换的支持者却认为分组交换提供了比电路交换更好的带宽共享;它比电路交换更加简单、更有效,实现成本更低。但是现在的趋势更多的是朝着分组交换的方向发展。

分组交换网的时延、丢包和吞吐量

因特网可以看成是一种基础设施,该基础设施为运行在端系统上的分布式应用提供服务。我们希望在计算机网络中任意两个端系统之间传递数据都不会造成数据丢失,然而这是一个极高的目标,实践中难以达到。所以,在实践中必须要限制端系统之间的 吞吐量 用来控制数据丢失。如果在端系统之间引入时延,也不能保证不会丢失分组问题。所以我们从时延、丢包和吞吐量三个层面来看一下计算机网络

分组交换中的时延

计算机网络中的分组从一台主机(源)出发,经过一系列路由器传输,在另一个端系统中结束它的历程。在这整个传输历程中,分组会涉及到四种最主要的时延:节点处理时延(nodal processing delay)、排队时延(queuing delay)、传输时延(total nodal delay)和传播时延(propagation delay)。这四种时延加起来就是 节点总时延(total nodal delay)

如果用 dproc dqueue dtrans dpop 分别表示处理时延、排队时延、传输时延和传播时延,则节点的总时延由以下公式决定: dnodal = dproc + dqueue + dtrans + dpop。

时延的类型

下面是一副典型的时延分布图,让我们从图中进行分析一下不同的时延类型

分组由端系统经过通信链路传输到路由器 A,路由器A 检查分组头部以映射出适当的传输链路,并将分组送入该链路。仅当该链路没有其他分组正在传输并且没有其他分组排在该该分组前面时,才能在这条链路上自由的传输该分组。如果该链路当前繁忙或者已经有其他分组排在该分组前面时,新到达的分组将会加入排队。下面我们分开讨论一下这四种时延

节点处理时延

节点处理时延分为两部分,第一部分是路由器会检查分组的首部信息;第二部分是决定将分组传输到哪条通信链路所需要的时间。一般高速网络的节点处理时延都在微妙级和更低的数量级。在这种处理时延完成后,分组会发往路由器的转发队列中

排队时延

在队列排队转发过程中,分组需要在队列中等待发送,分组在等待发送过程中消耗的时间被称为排队时延。排队时延的长短取决于先于该分组到达正在队列中排队的分组数量。如果该队列是空的,并且当前没有正在传输的分组,那么该分组的排队时延就是 0。如果处于网络高发时段,那么链路中传输的分组比较多,那么分组的排队时延将延长。实际的排队时延也可以到达微秒级。

传输时延

队列 是路由器所用的主要的数据结构。队列的特征就是先进先出,先到达食堂的先打饭。传输时延是理论情况下单位时间内的传输比特所消耗的时间。比如分组的长度是 L 比特,R 表示从路由器 A 到路由器 B 的传输速率。那么传输时延就是 L / R 。这是将所有分组推向该链路所需要的时间。正是情况下传输时延通常也在毫秒到微妙级

传播时延

从链路的起点到路由器 B 传播所需要的时间就是 传播时延。该比特以该链路的传播速率传播。该传播速率取决于链路的物理介质(双绞线、同轴电缆、光纤)。如果用公式来计算一下的话,该传播时延等于两台路由器之间的距离 / 传播速率。即传播速率是 d/s ,其中 d 是路由器 A 和 路由器 B 之间的距离,s 是该链路的传播速率。

传输时延和传播时延的比较

计算机网络中的传输时延和传播时延有时候难以区分,在这里解释一下,传输时延是路由器推出分组所需要的时间,它是分组长度和链路传输速率的函数,而与两台路由器之间的距离无关。而传播时延是一个比特从一台路由器传播到另一台路由器所需要的时间,它是两台路由器之间距离的倒数,而与分组长度和链路传输速率无关。从公式也可以看出来,传输时延是 L/R,也就是分组的长度 / 路由器之间传输速率。传播时延的公式是 d/s,也就是路由器之间的距离 / 传播速率。

排队时延

在这四种时延中,人们最感兴趣的时延或许就是排队时延了 dqueue。与其他三种时延(dproc、dtrans、dpop)不同的是,排队时延对不同的分组可能是不同的。例如,如果10个分组同时到达某个队列,第一个到达队列的分组没有排队时延,而最后到达的分组却要经受最大的排队时延(需要等待其他九个时延被传输)。

那么如何描述排队时延呢?或许可以从三个方面来考虑:流量到达队列的速率、链路的传输速率和到达流量的性质。即流量是周期性到达还是突发性到达,如果用 a 表示分组到达队列的平均速率( a 的单位是分组/秒,即 pkt/s)前面说过 R 表示的是传输速率,所以能够从队列中推出比特的速率(以 bps 即 b/s 位单位)。假设所有的分组都是由 L 比特组成的,那么比特到达队列的平均速率是 La bps。那么比率 La/R 被称为流量强度(traffic intensity),如果 La/R > 1,则比特到达队列的平均速率超过从队列传输出去的速率,这种情况下队列趋向于无限增加。所以,设计系统时流量强度不能大于1

现在考虑 La / R <= 1 时的情况。流量到达的性质将影响排队时延。如果流量是周期性到达的,即每 L / R 秒到达一个分组,则每个分组将到达一个空队列中,不会有排队时延。如果流量是 突发性 到达的,则可能会有很大的平均排队时延。一般可以用下面这幅图表示平均排队时延与流量强度的关系

横轴是 La/R 流量强度,纵轴是平均排队时延。

丢包

我们在上述的讨论过程中描绘了一个公式那就是 La/R 不能大于1,如果 La/R 大于1,那么到达的排队将会无穷大,而且路由器中的排队队列所容纳的分组是有限的,所以等到路由器队列堆满后,新到达的分组就无法被容纳,导致路由器 丢弃(drop) 该分组,即分组会 丢失(lost)

计算机网络中的吞吐量

除了丢包和时延外,衡量计算机另一个至关重要的性能测度是端到端的吞吐量。假如从主机 A 向主机 B 传送一个大文件,那么在任何时刻主机 B 接收到该文件的速率就是 瞬时吞吐量(instantaneous throughput)。如果该文件由 F 比特组成,主机 B 接收到所有 F 比特用去 T 秒,则文件的传送平均吞吐量(average throughput) 是 F / T bps。

单播、广播、多播和任播

在网络通信中,可以根据目标地址的数量对通信进行分类,可以分为 单播、广播、多播和任播

单播(Unicast)

单播最大的特点就是 1 对 1,早期的固定电话就是单播的一个例子,单播示意图如下

广播(Broadcast)

我们一般小时候经常会跳广播体操,这就是广播的一个事例,主机和与他连接的所有端系统相连,主机将信号发送给所有的端系统。

多播(Multicast)

多播与广播很类似,也是将消息发送给多个接收主机,不同之处在于多播需要限定在某一组主机作为接收端。

任播(Anycast)

任播是在特定的多台主机中选出一个接收端的通信方式。虽然和多播很相似,但是行为与多播不同,任播是从许多目标机群中选出一台最符合网络条件的主机作为目标主机发送消息。然后被选中的特定主机将返回一个单播信号,然后再与目标主机进行通信。

物理媒介

网络的传输是需要介质的。一个比特数据包从一个端系统开始传输,经过一系列的链路和路由器,从而到达另外一个端系统。这个比特会被转发了很多次,那么这个比特经过传输的过程所跨越的媒介就被称为物理媒介(phhysical medium),物理媒介有很多种,比如双绞铜线、同轴电缆、多模光纤榄、陆地无线电频谱和卫星无线电频谱。其实大致分为两种:引导性媒介和非引导性媒介。

双绞铜线

最便宜且最常用的引导性传输媒介就是双绞铜线,多年以来,它一直应用于电话网。从电话机到本地电话交换机的连线超过 99% 都是使用的双绞铜线,例如下面就是双绞铜线的实物图

双绞铜线由两根绝缘的铜线组成,每根大约 1cm 粗,以规则的螺旋形状排列,通常许多双绞线捆扎在一起形成电缆,并在双绞馅的外面套上保护层。一对电缆构成了一个通信链路。无屏蔽双绞线一般常用在局域网(LAN)中。

同轴电缆

与双绞线类似,同轴电缆也是由两个铜导体组成,下面是实物图

借助于这种结构以及特殊的绝缘体和保护层,同轴电缆能够达到较高的传输速率,同轴电缆普遍应用在在电缆电视系统中。同轴电缆常被用户引导型共享媒介。

光纤

光纤是一种细而柔软的、能够引导光脉冲的媒介,每个脉冲表示一个比特。一根光纤能够支持极高的比特率,高达数十甚至数百 Gbps。它们不受电磁干扰。光纤是一种引导型物理媒介,下面是光纤的实物图

一般长途电话网络全面使用光纤,光纤也广泛应用于因特网的主干。

陆地无线电信道

无线电信道承载电磁频谱中的信号。它不需要安装物理线路,并具有穿透墙壁、提供与移动用户的连接以及长距离承载信号的能力。

卫星无线电信道

一颗卫星电信道连接地球上的两个或多个微博发射器/接收器,它们称为地面站。通信中经常使用两类卫星:同步卫星和近地卫星。

后记

这是计算机网络的第一篇文章,也是属于基础前置知识,后面会陆续更新计算机网络的内容。

如果文章还不错,希望小伙伴们可以点赞、在看、留言、分享,这就是最好的白嫖 。

另外,我输出了 六本 PDF,全集 PDF 如下。

链接: https://pan.baidu.com/s/1mYAe... 密码: p9rs

查看原文

赞 46 收藏 35 评论 0

程序员cxuan 发布了文章 · 10月15日

十一假期淦了八天寄存器的相关知识

我把自己以往的文章汇总成为了 Github ,欢迎各位大佬 star
https://github.com/crisxuan/b...

下面我们就来介绍一下关于寄存器的相关内容。我们知道,寄存器是 CPU 内部的构造,它主要用于信息的存储。除此之外,CPU 内部还有运算器,负责处理数据;控制器控制其他组件;外部总线连接 CPU 和各种部件,进行数据传输;内部总线负责 CPU 内部各种组件的数据处理。

那么对于我们所了解的汇编语言来说,我们的主要关注点就是 寄存器

为什么会出现寄存器?因为我们知道,程序在内存中装载,由 CPU 来运行,CPU 的主要职责就是用来处理数据。那么这个过程势必涉及到从存储器中读取和写入数据,因为它涉及通过控制总线发送数据请求并进入存储器存储单元,通过同一通道获取数据,这个过程非常的繁琐并且会涉及到大量的内存占用,而且有一些常用的内存页存在,其实是没有必要的,因此出现了寄存器,存储在 CPU 内部。

认识寄存器

寄存器的官方叫法有很多,Wiki 上面的叫法是 Processing Register, 也可以称为 CPU Register,计算机中经常有一个东西多种叫法的情况,反正你知道都说的是寄存器就可以了。

认识寄存器之前,我们首先先来看一下 CPU 内部的构造。

CPU 从逻辑上可以分为 3 个模块,分别是控制单元、运算单元和存储单元,这三部分由 CPU 内部总线连接起来。

几乎所有的冯·诺伊曼型计算机的 CPU,其工作都可以分为5个阶段:取指令、指令译码、执行指令、访存取数、结果写回

  • 取指令阶段是将内存中的指令读取到 CPU 中寄存器的过程,程序寄存器用于存储下一条指令所在的地址
  • 指令译码阶段,在取指令完成后,立马进入指令译码阶段,在指令译码阶段,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类别以及各种获取操作数的方法。
  • 执行指令阶段,译码完成后,就需要执行这一条指令了,此阶段的任务是完成指令所规定的各种操作,具体实现指令的功能。
  • 访问取数阶段,根据指令的需要,有可能需要从内存中提取数据,此阶段的任务是:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算。
  • 结果写回阶段,作为最后一个阶段,结果写回(Write Back,WB)阶段把执行指令阶段的运行结果数据写回到 CPU 的内部寄存器中,以便被后续的指令快速地存取;

计算机架构中的寄存器

寄存器是一块速度非常快的计算机内存,下面是现代计算机中具有存储功能的部件比对,可以看到,寄存器的速度是最快的,同时也是造价最高昂的。

我们以 intel 8086 处理器为例来进行探讨,8086 处理器是 x86 架构的前身。在 8086 后面又衍生出来了 8088 。

在 8086 CPU 中,地址总线达到 20 根,因此最大寻址能力是 2^20 次幂也就是 1MB 的寻址能力,8088 也是如此。

在 8086 架构中,所有的内部寄存器、内部以及外部总线都是 16 位宽,可以存储两个字节,因为是完全的 16 位微处理器。8086 处理器有 14 个寄存器,每个寄存器都有一个特有的名称,即

AX,BX,CX,DX,SP,BP,SI,DI,IP,FLAG,CS,DS,SS,ES

这 14 个寄存器有可能进行具体的划分,按照功能可以分为三种

  • 通用寄存器
  • 控制寄存器
  • 段寄存器

下面我们分别介绍一下这几种寄存器

通用寄存器

通用寄存器主要有四种 ,即 AX、BX、CX、DX 同样的,这四个寄存器也是 16 位的,能存放两个字节。 AX、BX、CX、DX 这四个寄存器一般用来存放数据,也被称为 数据寄存器。它们的结构如下

8086 CPU 的上一代寄存器是 8080 ,它是一类 8 位的 CPU,为了保证兼容性,8086 在 8080 上做了很小的修改,8086 中的通用寄存器 AX、BX、CX、DX 都可以独立使用两个 8 位寄存器来使用。

在细节方面,AX、BX、CX、DX 可以再向下进行划分

  • AX(Accumulator Register) : 累加寄存器,它主要用于输入/输出和大规模的指令运算。
  • BX(Base Register):基址寄存器,用来存储基础访问地址
  • CX(Count Register):计数寄存器,CX 寄存器在迭代的操作中会循环计数
  • DX(data Register):数据寄存器,它也用于输入/输出操作。它还与 AX 寄存器以及 DX 一起使用,用于涉及大数值的乘法和除法运算。

这四种寄存器可以分为上半部分和下半部分,用作八个 8 位数据寄存器

  • AX 寄存器可以分为两个独立的 8 位的 AH 和 AL 寄存器;
  • BX 寄存器可以分为两个独立的 8 位的 BH 和 BL 寄存器;
  • CX 寄存器可以分为两个独立的 8 位的 CH 和 CL 寄存器;
  • DX 寄存器可以分为两个独立的 8 位的 DH 和 DL 寄存器;

除了上面 AX、BX、CX、DX 寄存器以外,其他寄存器均不可以分为两个独立的 8 位寄存器

如下图所示。

合起来就是

AX 的低位(0 - 7)位构成了 AL 寄存器,高 8 位(8 - 15)位构成了 AH 寄存器。AH 和 AL 寄存器是可以使用的 8 位寄存器,其他同理。

在认识了寄存器之后,我们通过一个示例来看一下数据的具体存储方式。

比如数据 19 ,它在 16 位存储器中所存储的表示如下

寄存器的存储方式是先存储低位,如果低位满足不了就存储高位,如果低位能够满足,高位用 0 补全,在其他低位能满足的情况下,其余位也用 0 补全。

8086 CPU 可以一次存储两种类型的数据

  • 字节(byte): 一个字节由 8 bit 组成,这是一种恒定不变的存储方式
  • 字(word):字是由指令集或处理器硬件作为单元处理的固定大小的数据,对于 intel 来说,一个字长就是两个字节,字是计算机一个非常重要的特征,针对不同的指令集架构来说,计算机一次处理的数据也是不同的。也就是说,针对不同指令集的机器,一次能处理不用的字长,有字、双字(32位)、四字(64位)等。

AX 寄存器

我们上面探讨过,AX 的另外一个名字叫做累加寄存器或者简称为累加器,其可以分为 2 个独立的 8 位寄存器 AH 和 AL;在编写汇编程序中,AX 寄存器可以说是使用频率最高的寄存器。

下面是几段汇编代码

mov ax,20        /* 将 20 送入寄存器 AX*/
mov ah,80   /* 将 80 送入寄存器 AH*/
add ax,10      /* 将寄存器 AX 中的数值加上 8 */
这里注意下:上面代码中出现的是 ax、ah ,而注释中确是 AX、AH ,其实含义是一样的,不区分大小写。

AX 相比于其他通用寄存器来说,有一点比较特殊,AX 具有一种特殊功能的使用,那就是使用 DIV 和 MUL 指令式使用。

DIV 是 8086 CPU 中的除法指令。

MUL 是 8086 CPU 中的乘法指令。

BX 寄存器

BX 被称为数据寄存器,即表明其能够暂存一般数据。同样为了适应以前的 8 位 CPU ,而可以将 BX 当做两个独立的 8 位寄存器使用,即有 BH 和 BL。BX 除了具有暂存数据的功能外,还用于 寻址,即寻找物理内存地址。BX 寄存器中存放的数据一般是用来作为偏移地址 使用的,因为偏移地址当然是在基址地址上的偏移了。偏移地址是在段寄存器中存储的,关于段寄存器的介绍,我们后面再说。

CX 寄存器

CX 也是数据寄存器,能够暂存一般性数据。同样为了适应以前的 8 位 CPU ,而可以将 CX 当做两个独立的 8 位寄存器使用,即有 CH 和 CL。除此之外,CX 也是有其专门的用途的,CX 中的 C 被翻译为 Counting 也就是计数器的功能。当在汇编指令中使用循环 LOOP 指令时,可以通过 CX 来指定需要循环的次数,每次执行循环 LOOP 时候,CPU 会做两件事

  • 一件事是计数器自动减 1
  • 还有一件就是判断 CX 中的值,如果 CX 中的值为 0 则会跳出循环,而继续执行循环下面的指令,

    当然如果 CX 中的值不为 0 ,则会继续执行循环中所指定的指令 。

DX 寄存器

DX 也是数据寄存器,能够暂存一般性数据。同样为了适应以前的 8 位 CPU ,DX 的用途其实在前面介绍 AX 寄存器时便已经有所介绍了,那就是支持 MUL 和 DIV 指令。同时也支持数值溢出等。

段寄存器

CPU 包含四个段寄存器,用作程序指令,数据或栈的基础位置。实际上,对 IBM PC 上所有内存的引用都包含一个段寄存器作为基本位置。

段寄存器主要包含

  • CS(Code Segment) : 代码寄存器,程序代码的基础位置
  • DS(Data Segment): 数据寄存器,变量的基本位置
  • SS(Stack Segment): 栈寄存器,栈的基础位置
  • ES(Extra Segment): 其他寄存器,内存中变量的其他基本位置。

索引寄存器

索引寄存器主要包含段地址的偏移量,索引寄存器主要分为

  • BP(Base Pointer):基础指针,它是栈寄存器上的偏移量,用来定位栈上变量
  • SP(Stack Pointer): 栈指针,它是栈寄存器上的偏移量,用来定位栈顶
  • SI(Source Index): 变址寄存器,用来拷贝源字符串
  • DI(Destination Index): 目标变址寄存器,用来复制到目标字符串

状态和控制寄存器

就剩下两种寄存器还没聊了,这两种寄存器是指令指针寄存器和标志寄存器:

  • IP(Instruction Pointer): 指令指针寄存器,它是从 Code Segment 代码寄存器处的偏移来存储执行的下一条指令
  • FLAG : Flag 寄存器用于存储当前进程的状态,这些状态有

    • 位置 (Direction):用于数据块的传输方向,是向上传输还是向下传输
    • 中断标志位 (Interrupt) :1 - 允许;0 - 禁止
    • 陷入位 (Trap) :确定每条指令执行完成后,CPU 是否应该停止。1 - 开启,0 - 关闭
    • 进位 (Carry) : 设置最后一个无符号算术运算是否带有进位
    • 溢出 (Overflow) : 设置最后一个有符号运算是否溢出
    • 符号 (Sign) : 如果最后一次算术运算为负,则设置 1 =负,0 =正
    • 零位 (Zero) : 如果最后一次算术运算结果为零,1 = 零
    • 辅助进位 (Aux Carry) :用于第三位到第四位的进位
    • 奇偶校验 (Parity) : 用于奇偶校验

物理地址

我们大家都知道, CPU 访问内存时,需要知道访问内存的具体地址,内存单元是内存的基本单位,每一个内存单元在内存中都有唯一的地址,这个地址即是 物理地址。而 CPU 和内存之间的交互有三条总线,即数据总线、控制总线和地址总线。

CPU 通过地址总线将物理地址送入存储器,那么 CPU 是如何形成的物理地址呢?这将是我们接下来的讨论重点。

现在,我们先来讨论一下和 8086 CPU 有关的结构问题。

cxuan 和你聊了这么久,你应该知道 8086 CPU 是 16 位的 CPU 了,那么,什么是 16 位的 CPU 呢?

你可能大致听过这个回答,16 位 CPU 指的是 CPU 一次能处理的数据是 16 位的,能回答这个问题代表你的底层还不错,但是不够全面,其实,16 位的 CPU 指的是

  • CPU 内部的运算器一次最多能处理 16 位的数据
运算器其实就是 ALU,运算控制单元,它是 CPU 内部的三大核心器件之一,主要负责数据的运算。
  • 寄存器的最大宽度为 16 位
这个寄存器的最大宽度值得就是通用寄存器能处理的二进制数的最大位数
  • 寄存器和运算器之间的通路为 16 位
这个指的是寄存器和运算器之间的总线,一次能传输 16 位的数据

好了,现在你应该知道为什么叫做 16 位 CPU 了吧。

在你知道上面这个问题的答案之后,我们下面就来聊一聊如何计算物理地址。

8086 CPU 有 20 位地址总线,每一条总线都可以传输一位的地址,所以 8086 CPU 可以传送 20 位地址,也就是说,8086 CPU 可以达到 2^20 次幂的寻址能力,也就是 1MB。8086 CPU 又是 16 位的结构,从 8086 CPU 的结构看,它只能传输 16 位的地址,也就是 2^16 次幂也就是 64 KB,那么它如何达到 1MB 的寻址能力呢?

原来,8086 CPU 的内部采用两个 16 位地址合成的方式来传输一个 20 位的物理地址,如下图所示

叙述一下上图描述的过程

CPU 中相关组件提供两个地址:段地址和偏移地址,这两个地址都是 16 位的,他们经由地址加法器变为 20 位的物理地址,这个地址即是输入输出控制电路传递给内存的物理地址,由此完成物理地址的转换。

地址加法器采用 物理地址 = 段地址 * 16 + 偏移地址 的方法用段地址和偏移地址合成物理地址。

下面是地址加法器的工作流程

其实段地址 16 ,就是左移 4 位。在上面的叙述中,物理地址 = 段地址 16 + 偏移地址,其实就是基础地址 + 偏移地址 = 物理地址 寻址模式的一种具体实现方案。基础地址其实就等于段地址 * 16。

你可能不太清楚 的概念,下面我们就来探讨一下。

什么是段

段这个概念经常出现在操作系统中,比如在内存管理中,操作系统会把不同的数据分成 来存储,比如 代码段、数据段、bss 段、rodata 段 等。

但是这些的划分并不是内存干的,cxuan 告诉你是谁干的,这其实是幕后 Boss CPU 搞的,内存当作了声讨的对象。

其实,内存没有进行分段,分段完全是由 CPU 搞的,上面聊过的通过基础地址 + 偏移地址 = 物理地址的方式给出内存单元的物理地址,使得我们可以分段管理 CPU。

如图所示

这是两个 16 KB 的程序分别被装载进内存的示意图,可以看到,这两个程序的段地址的大小都是 16380。

这里需要注意一点, 8086 CPU 段地址的计算方式是段地址 * 16,所以,16 位的寻址能力是 2^16 次方,所以一个段的长度是 64 KB。

段寄存器

cxuan 在上面只是简单为你介绍了一下段寄存器的概念,介绍的有些浅,而且介绍段寄存器不介绍段也有不知庐山真面目的感觉,现在为你详细的介绍一下,相信看完上面的段的概念之后,段寄存器也是手到擒来。

我们在合成物理地址的那张图提到了 相关部件 的概念,这个相关部件其实就是段寄存器,即 CS、DS、SS、ES 。8086 的 CPU 在访问内存时,由这四个寄存器提供内存单元的段地址。

CS 寄存器

要聊 CS 寄存器,那么 IP 寄存器是你绕不过去的曾经。CS 和 IP 都是 8086 CPU 非常重要的寄存器,它们指出了 CPU 当前需要读取指令的地址。

CS 的全称是 Code Segment,即代码寄存器;而 IP 的全称是 Instruction Pointer ,即指令指针。现在知道这两个为什么一起出现了吧!

在 8086 CPU 中,由 CS:IP 指向的内容当作指令执行。如下图所示

说明一下上图

在 CPU 内部,由 CS、IP 提供段地址,由加法器负责转换为物理地址,输入输出控制电路负责输入/输出数据,指令缓冲器负责缓冲指令,指令执行器负责执行指令。在内存中有一段连续存储的区域,区域内部存储的是机器码、外面是地址和汇编指令。

上面这幅图的段地址和偏移地址分别是 2000 和 0000,当这两个地址进入地址加法器后,会由地址加法器负责将这两个地址转换为物理地址

然后地址加法器负责将指令输送到输入输出控制电路中

输入输出控制电路将 20 位的地址总线送到内存中。

然后取出对应的数据,也就是 B8、23、01,图中的 B8、BB 都是操作数。

控制输入/输出电路会将 B8 23 01 送入指令缓存器中。

此时这个指令就已经具备执行条件,此时 IP 也就是指令指针会自动增加。我们上面说到 IP 其实就是从 Code Segment 也就是 CS 处偏移的地址,也就是偏移地址。它会知道下一个需要读取指令的地址,如下图所示

在这之后,指令执行执行取出的 B8 23 01 这条指令。

然后下面再把 2000 和 0003 送到地址加法器中再进行后续指令的读取。后面的指令读取过程和我们上面探讨的如出一辙,这里 cxuan 就不再赘述啦。

通过对上面的描述,我们能总结一下 8086 CPU 的工作过程

  • 段寄存器提供段地址和偏移地址给地址加法器
  • 由地址加法器计算出物理地址通过输入输出控制电路将物理地址送到内存中
  • 提取物理地址对应的指令,经由控制电路取回并送到指令缓存器中
  • IP 继续指向下一条指令的地址,同时指令执行器执行指令缓冲器中的指令

什么是 Code Segment

Code Segment 即代码段,它就是我们上面聊到就是 CS 寄存器中存储的基础地址,也就是段地址,段地址其本质上就是一组内存单元的地址,例如上面的 mov ax,0123H 、mov bx, 0003H。我们可以将长度为 N 的一组代码,存放在一组连续地址、其实地址为 16 的倍数的内存单元中,我们可以认为,这段内存就是用来存放代码的。

DS 寄存器

CPU 在读写一个内存单元的时候,需要知道这个内存单元的地址。在 8086 CPU 中,有一个 DS 寄存器,通常用来存放访问数据的段地址。如果你想要读取一个 10000H 的数据,你可能会需要下面这段代码

mov bx,10000H
mov ds,bx
mov a1,[0]

上面这三条指令就把 10000H 读取到了 a1 中。

在上面汇编代码中,mov 指令有两种传送方式

  • 一种是把数据直接送入寄存器
  • 一种是将一个寄存器的内容送入另一个寄存器

但是不仅仅如此,mov 指令还具有下面这几种表达方式

描述举例
mov 寄存器,数据比如:mov ax,8
mov 寄存器,寄存器比如:mov ax,bx
mov 寄存器,内存单元比如:mov ax,[0]
mov 内存单元,寄存器比如:mov[0], ax
mov 段寄存器,寄存器比如:mov ds,ax

栈我相信大部分小伙伴已经非常熟悉了,是一种具有特殊的访问方式的存储空间。它的特殊性就在于,先进入栈的元素,最后才出去,也就是我们常说的 先入后出

它就像一个大的收纳箱,你可以往里面放相同类型的东西,比如书,最先放进收纳箱的书在最下面,最后放进收纳箱的书在最上面,如果你想拿书的话, 必须从最上面开始取,否则是无法取出最下面的书籍的。

栈的数据结构就是这样,你把书籍压入收纳箱的操作叫做压入(push),你把书籍从收纳箱取出的操作叫做弹出(pop),它的模型图大概是这样

入栈相当于是增加操作,出栈相当于是删除操作,只不过叫法不一样。栈和内存不同,它不需要指定元素的地址。它的大概使用如下

// 压入数据
Push(123);
Push(456);
Push(789);

// 弹出数据
j = Pop();
k = Pop();
l = Pop();

在栈中,LIFO 方式表示栈的数组中所保存的最后面的数据(Last In)会被最先读取出来(First Out)。

栈和 SS 寄存器

下面我们就通过一段汇编代码来描述一下栈的压入弹出的过程

8086 CPU 提供入栈和出栈指令,最基本的两个是 PUSH(入栈)POP(出栈)。比如 push ax 会把 ax 寄存器中的数据压入栈中,pop ax 表示从栈顶取出数据送入 ax 寄存器中。

这里注意一点:8086 CPU 中的入栈和出栈都是以字为单位进行的。

我这里首先有一个初始的栈,没有任何指令和数据。

然后我们向栈中 push 数据后,栈中数据如下

涉及的指令有

mov ax,2345H
push ax
注意,数据会用两个单元存放,高地址单元存放高 8 位地址,低地址单元存放低 8 位。

再向栈中 push 数据

其中涉及的指令有

mov bx,0132H
push bx

现在栈中有两条数据,现在我们执行出栈操作

其中涉及的指令有

pop ax
/* ax = 0132H */

再继续取出数据

涉及的指令有

pop bx
/* bx = */

完整的 push 和 pop 过程如下

现在 cxuan 问你一个问题,我们上面描述的是 10000H ~ 1000FH 这段空间来作为 push 和 pop 指令的存取单元。但是,你怎么知道这个栈单元就是 10000H ~ 1000FH 呢?也就是说,你如何选择指定的栈单元进行存取?

事实上,8086 CPU 有一组关于栈的寄存器 SSSP。SS 是段寄存器,它存储的是栈的基础位置,也就是栈顶的位置,而 SP 是栈指针,它存储的是偏移地址。在任意时刻,SS:SP 都指向栈顶元素。push 和 pop 指令执行时,CPU 从 SS 和 SP 中得到栈顶的地址。

现在,我们可以完整的描述一下 push 和 pop 过程了,下面 cxuan 就给你推导一下这个过程。

上面这个过程主要涉及到的关键变化如下。

当使用 PUSH 指令向栈中压入 1 个字节单元时,SP = SP - 1;即栈顶元素会发生变化;

而当使用 PUSH 指令向栈中压入 2 个字节的字单元时,SP = SP – 2 ;即栈顶元素也要发生变化;

当使用 POP 指令从栈中弹出 1 个字节单元时, SP = SP + 1;即栈顶元素会发生变化;

当使用 POP 指令从栈中弹出 2 个字节单元的字单元时, SP = SP + 2 ;即栈顶元素会发生变化;

栈顶越界问题

现在我们知道,8086 CPU 可以使用 SS 和 SP 指示栈顶的地址,并且提供 PUSH 和 POP 指令实现入栈和出栈,所以,你现在知道了如何能够找到栈顶位置,但是你如何能保证栈顶的位置不会越界呢?栈顶越界会产生什么影响呢?

比如如下是一个栈顶越界的示意图

第一开始,SS:SP 寄存器指向了栈顶,然后向栈空间 push 一定数量的元素后,SS:SP 位于栈空间顶部,此时再向栈空间内部 push 元素,就会出现栈顶越界问题。

栈顶越界是危险的,因为我们既然将一块区域空间安排为栈,那么在栈空间外部也可能存放了其他指令和数据,这些指令和数据有可能是其他程序的,所以如此操作会让计算机懵逼

我们希望 8086 CPU 能自己解决问题,毕竟 8086 CPU 已经是个成熟的 CPU 了,要学会自己解决问题了。

然鹅(故意的),这对于 8086 CPU 来说,这可能是它一辈子的 夙愿 了,真实情况是,8086 CPU 不会保证栈顶越界问题,也就是说 8086 CPU 只会告诉你栈顶在哪,并不会知道栈空间有多大,所以需要程序员自己手动去保证。。。

另外,我输出了 六本 PDF,已免费提供下载,如下所示

链接: pan.baidu.com/s/1mYAeS9hI… 密码: p9rs

查看原文

赞 6 收藏 2 评论 3

程序员cxuan 发布了文章 · 5月31日

一个 static 还能难得住我?

static 是我们日常生活中经常用到的关键字,也是 Java 中非常重要的一个关键字,static 可以修饰变量、方法、做静态代码块、静态导包等,下面我们就来具体聊一聊这个关键字,我们先从基础开始,从基本用法入手,然后分析其原理、优化等。

初识 static 关键字

static 修饰变量

static 关键字表示的概念是 全局的、静态的,用它修饰的变量被称为静态变量

public class TestStatic {
    
    static int i = 10; // 定义了一个静态变量 i 
}

静态变量也被称为类变量,静态变量是属于这个类所有的。什么意思呢?这其实就是说,static 关键字只能定义在类的 {} 中,而不能定义在任何方法中。

image.png

就算把方法中的 static 关键字去掉也是一样的。

image.png

static 属于类所有,由类来直接调用 static 修饰的变量,它不需要手动实例化类进行调用

public class TestStatic {

    static int i = 10;

    public static void main(String[] args) {
        System.out.println(TestStatic.i);
    }
}

这里你需要理解几个变量的概念

  • 定义在构造方法、代码块、方法的变量被称为实例变量,实例变量的副本数量和实例的数量一样。
  • 定义在方法、构造方法、代码块的变量被称为局部变量;
  • 定义在方法参数的变量被称为参数。

static 修饰方法

static 可以修饰方法,被 static 修饰的方法被称为静态方法,其实就是在一个方法定义中加上 static 关键字进行修饰,例如下面这样

static void sayHello(){}

《Java 编程思想》在 P86 页有一句经典的描述

static 方法就是没有 this 的方法,在 static 内部不能调用非静态方法,反过来是可以的。而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用 static 方法,这实际上是 static 方法的主要用途

其中有一句非常重要的话就是 static 方法就是没有 this 的方法,也就是说,可以在不用创建对象的前提下就能够访问 static 方法,如何做到呢?看下面一段代码

image.png

在上面的例子中,由于 staticMethod 是静态方法,所以能够使用 类名.变量名进行调用。

因此,如果说想在不创建对象的情况下调用某个方法,就可以将这个方法设置为 static。平常我们见的最多的 static 方法就是 main方 法,至于为什么 main 方法必须是 static 的,现在应该很清楚了。因为程序在执行 main 方法的时候没有创建任何对象,因此只有通过类名来访问。

static 修饰方法的注意事项

  • 首先第一点就是最常用的,不用创建对象,直接类名.变量名 即可访问;
  • static 修饰的方法内部不能调用非静态方法;

image.png

  • 非静态方法内部可以调用 static 静态方法。

image.png

static 修饰代码块

static 关键字可以用来修饰代码块,代码块分为两种,一种是使用 {} 代码块;一种是 static {} 静态代码块。static 修饰的代码块被称为静态代码块。静态代码块可以置于类中的任何地方,类中可以有多个 static 块,在类初次被加载的时候,会按照 static 代码块的顺序来执行,每个 static 修饰的代码块只能执行一次。我们会面会说一下代码块的加载顺序。下面是静态代码块的例子

image.png

static 代码块可以用来优化程序执行顺序,是因为它的特性:只会在类加载的时候执行一次。

static 用作静态内部类

内部类的使用场景比较少,但是内部类还有具有一些比较有用的。在了解静态内部类前,我们先看一下内部类的分类

  • 普通内部类
  • 局部内部类
  • 静态内部类
  • 匿名内部类

静态内部类就是用 static 修饰的内部类,静态内部类可以包含静态成员,也可以包含非静态成员,但是在非静态内部类中不可以声明静态成员。

静态内部类有许多作用,由于非静态内部类的实例创建需要有外部类对象的引用,所以非静态内部类对象的创建必须依托于外部类的实例;而静态内部类的实例创建只需依托外部类;

并且由于非静态内部类对象持有了外部类对象的引用,因此非静态内部类可以访问外部类的非静态成员;而静态内部类只能访问外部类的静态成员;

  • 内部类需要脱离外部类对象来创建实例
  • 避免内部类使用过程中出现内存溢出
public class ClassDemo {
  
    private int a = 10;
    private static int b = 20;

    static class StaticClass{
        public static int c = 30;
        public int d = 40;
      
        public static void print(){
            //下面代码会报错,静态内部类不能访问外部类实例成员
            //System.out.println(a);
     
            //静态内部类只可以访问外部类类成员
            System.out.println("b = "+b);
            
        }
      
        public void print01(){
            //静态内部内所处的类中的方法,调用静态内部类的实例方法,属于外部类中调用静态内部类的实例方法
            StaticClass sc = new StaticClass();
            sc.print();
        }   
    }
}

静态导包

不知道你注意到这种现象没有,比如你使用了 java.util 内的工具类时,你需要导入 java.util 包,才能使用其内部的工具类,如下

image.png

但是还有一种导包方式是使用静态导包,静态导入就是使用 import static 用来导入某个类或者某个包中的静态方法或者静态变量。

import static java.lang.Integer.*;

public class StaticTest {

    public static void main(String[] args) {
        System.out.println(MAX_VALUE);
        System.out.println(toHexString(111));
    }
}

static 进阶知识

我们在了解了 static 关键字的用法之后,来看一下 static 深入的用法,也就是由浅入深,慢慢来,前戏要够~

关于 static 的所属类

static 所修饰的属性和方法都属于类的,不会属于任何对象;它们的调用方式都是 类名.属性名/方法名,而实例变量和局部变量都是属于具体的对象实例。

static 修饰变量的存储位置

首先,先来认识一下 JVM 的不同存储区域。

image.png

  • 虚拟机栈 : Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈种创建一个 栈帧(stack frame)
  • 本地方法栈: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用 native 关键字修饰的方法所存储的区域
  • 程序计数器:程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。
  • 方法区:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据,也就是说,static 修饰的变量存储在方法区中
  • : 堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例,包括实例变量都在堆上进行相应的分配。

static 变量的生命周期

static 变量的生命周期与类的生命周期相同,随类的加载而创建,随类的销毁而销毁;普通成员变量和其所属的生命周期相同。

static 序列化

我们知道,序列化的目的就是为了 把 Java 对象转换为字节序列。对象转换为有序字节流,以便其能够在网络上传输或者保存在本地文件中。

声明为 static 和 transient 类型的变量不能被序列化,因为 static 修饰的变量保存在方法区中,只有堆内存才会被序列化。而 transient 关键字的作用就是防止对象进行序列化操作。

类加载顺序

我们前面提到了类加载顺序这么一个概念,static 修饰的变量和静态代码块在使用前已经被初始化好了,类的初始化顺序依次是

加载父类的静态字段 -> 父类的静态代码块 -> 子类静态字段 -> 子类静态代码块 -> 父类成员变量(非静态字段)

-> 父类非静态代码块 -> 父类构造器 -> 子类成员变量 -> 子类非静态代码块 -> 子类构造器

static 经常用作日志打印

我们在开发过程中,经常会使用 static 关键字作为日志打印,下面这行代码你应该经常看到

private static final Logger LOGGER = LogFactory.getLoggger(StaticTest.class);

然而把 static 和 final 去掉都可以打印日志

private final Logger LOGGER = LogFactory.getLoggger(StaticTest.class);
private Logger LOGGER = LogFactory.getLoggger(StaticTest.class);

但是这种打印日志的方式存在问题

对于每个 StaticTest 的实例化对象都会拥有一个 LOGGER,如果创建了1000个 StaticTest 对象,则会多出1000个Logger 对象,造成资源的浪费,因此通常会将 Logger 对象声明为 static 变量,这样一来,能够减少对内存资源的占用。

static 经常用作单例模式

由于单例模式指的就是对于不同的类来说,它的副本只有一个,因此 static 可以和单例模式完全匹配。

下面是一个经典的双重校验锁实现单例模式的场景

public class Singleton {
  
    private static volatile Singleton singleton;
 
    private Singleton() {}
 
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

来对上面代码做一个简单的描述

使用 static 保证 singleton 变量是静态的,使用 volatile 保证 singleton 变量的可见性,使用私有构造器确保 Singleton 不能被 new 实例化。

使用 Singleton.getInstance() 获取 singleton 对象,首先会进行判断,如果 singleton 为空,会锁住 Singletion 类对象,这里有一些小伙伴们可能不知道为什么需要两次判断,这里来解释下

如果线程 t1 执行到 singleton == null 后,判断对象为 null,此时线程把执行权交给了 t2,t2 判断对象为 null,锁住 Singleton 类对象,进行下面的判断和实例化过程。如果不进行第二次判断的话,那么 t1 在进行第一次判空后,也会进行实例化过程,此时仍然会创建多个对象。

类的构造器是否是 static 的

这个问题我相信大部分小伙伴都没有考虑过,在 Java 编程思想中有这么一句话 类的构造器虽然没有用 static 修饰,但是实际上是 static 方法,但是并没有给出实际的解释,但是这个问题可以从下面几个方面来回答

  • static 最简单、最方便记忆的规则就是没有 this 引用。而在类的构造器中,是有隐含的 this 绑定的,因为构造方法是和类绑定的,从这个角度来看,构造器不是静态的。
  • 从类的方法这个角度来看,因为 类.方法名不需要新创建对象就能够访问,所以从这个角度来看,构造器也不是静态的
  • 从 JVM 指令角度去看,我们来看一个例子
public class StaticTest {

    public StaticTest(){}

    public static void test(){

    }

    public static void main(String[] args) {
        StaticTest.test();
        StaticTest staticTest = new StaticTest();
    }
}

我们使用 javap -c 生成 StaticTest 的字节码看一下

public class test.StaticTest {
  public test.StaticTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void test();
    Code:
       0: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #2                  // Method test:()V
       3: new           #3                  // class test/StaticTest
       6: dup
       7: invokespecial #4                  // Method "<init>":()V
      10: astore_1
      11: return
}

我们发现,在调用 static 方法时是使用的 invokestatic 指令,new 对象调用的是 invokespecial 指令,而且在 JVM 规范中 https://docs.oracle.com/javas... 说到

image.png

image.png

从这个角度来讲,invokestatic 指令是专门用来执行 static 方法的指令;invokespecial 是专门用来执行实例方法的指令;从这个角度来讲,构造器也不是静态的。

查看原文

赞 9 收藏 4 评论 0

程序员cxuan 赞了文章 · 4月23日

HashMap 源码详细分析(JDK1.8)

1.概述

本篇文章我们来聊聊大家日常开发中常用的一个集合类 - HashMap。HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。

在本篇文章中,我将会对 HashMap 中常用方法、重要属性及相关方法进行分析。需要说明的是,HashMap 源码中可分析的点很多,本文很难一一覆盖,请见谅。

2.原理

上一节说到 HashMap 底层是基于散列算法实现,散列算法分为散列再探测和拉链式。HashMap 则使用了拉链式的散列算法,并在 JDK 1.8 中引入了红黑树优化过长的链表。数据结构示意图如下:

对于拉链式的散列算法,其数据结构是由数组和链表(或树形结构)组成。在进行增删查等操作时,首先要定位到元素的所在桶的位置,之后再从链表中定位该元素。比如我们要查询上图结构中是否包含元素35,步骤如下:

  1. 定位元素35所处桶的位置,index = 35 % 16 = 3
  2. 3号桶所指向的链表中继续查找,发现35在链表中。

上面就是 HashMap 底层数据结构的原理,HashMap 基本操作就是对拉链式散列算法基本操作的一层包装。不同的地方在于 JDK 1.8 中引入了红黑树,底层数据结构由数组+链表变为了数组+链表+红黑树,不过本质并未变。好了,原理部分先讲到这,接下来说说源码实现。

3.源码分析

本篇文章所分析的源码版本为 JDK 1.8。与 JDK 1.7 相比,JDK 1.8 对 HashMap 进行了一些优化。比如引入红黑树解决过长链表效率低的问题。重写 resize 方法,移除了 alternative hashing 相关方法,避免重新计算键的 hash 等。不过本篇文章并不打算对这些优化进行分析,本文仅会分析 HashMap 常用的方法及一些重要属性和相关方法。如果大家对红黑树感兴趣,可以阅读我的另一篇文章 - 红黑树详细分析

3.1 构造方法

3.1.1 构造方法分析

HashMap 的构造方法不多,只有四个。HashMap 构造方法做的事情比较简单,一般都是初始化一些重要变量,比如 loadFactor 和 threshold。而底层的数据结构则是延迟到插入键值对时再进行初始化。HashMap 相关构造方法如下:

/** 构造方法 1 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/** 构造方法 2 */
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/** 构造方法 3 */
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

/** 构造方法 4 */
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

上面4个构造方法中,大家平时用的最多的应该是第一个了。第一个构造方法很简单,仅将 loadFactor 变量设为默认值。构造方法2调用了构造方法3,而构造方法3仍然只是设置了一些变量。构造方法4则是将另一个 Map 中的映射拷贝一份到自己的存储结构中来,这个方法不是很常用。

上面就是对构造方法简单的介绍,构造方法本身并没什么太多东西,所以就不说了。接下来说说构造方法所初始化的几个的变量。

3.1.2 初始容量、负载因子、阈值

我们在一般情况下,都会使用无参构造方法创建 HashMap。但当我们对时间和空间复杂度有要求的时候,使用默认值有时可能达不到我们的要求,这个时候我们就需要手动调参。在 HashMap 构造方法中,可供我们调整的参数有两个,一个是初始容量 initialCapacity,另一个负载因子 loadFactor。通过这两个设定这两个参数,可以进一步影响阈值大小。但初始阈值 threshold 仅由 initialCapacity 经过移位操作计算得出。他们的作用分别如下:

名称用途
initialCapacityHashMap 初始容量
loadFactor负载因子
threshold当前 HashMap 所能容纳键值对数量的最大值,超过这个值,则需扩容

相关代码如下:

/** The default initial capacity - MUST be a power of two. */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/** The load factor used when none specified in constructor. */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

final float loadFactor;

/** The next size value at which to resize (capacity * load factor). */
int threshold;

如果大家去看源码,会发现 HashMap 中没有定义 initialCapacity 这个变量。这个也并不难理解,从参数名上可看出,这个变量表示一个初始容量,只是构造方法中用一次,没必要定义一个变量保存。但如果大家仔细看上面 HashMap 的构造方法,会发现存储键值对的数据结构并不是在构造方法里初始化的。这就有个疑问了,既然叫初始容量,但最终并没有用与初始化数据结构,那传这个参数还有什么用呢?这个问题我先不解释,给大家留个悬念,后面会说明。

默认情况下,HashMap 初始容量是16,负载因子为 0.75。这里并没有默认阈值,原因是阈值可由容量乘上负载因子计算而来(注释中有说明),即threshold = capacity * loadFactor。但当你仔细看构造方法3时,会发现阈值并不是由上面公式计算而来,而是通过一个方法算出来的。这是不是可以说明 threshold 变量的注释有误呢?还是仅这里进行了特殊处理,其他地方遵循计算公式呢?关于这个疑问,这里也先不说明,后面在分析扩容方法时,再来解释这个问题。接下来,我们来看看初始化 threshold 的方法长什么样的的,源码如下:

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

上面的代码长的有点不太好看,反正我第一次看的时候不明白它想干啥。不过后来在纸上画画,知道了它的用途。总结起来就一句话:找到大于或等于 cap 的最小2的幂。至于为啥要这样,后面再解释。我们先来看看 tableSizeFor 方法的图解:

上面是 tableSizeFor 方法的计算过程图,这里cap = 536,870,913 = 2<sup>29</sup> + 1,多次计算后,算出n + 1 = 1,073,741,824 = 2<sup>30</sup>。通过图解应该可以比较容易理解这个方法的用途,这里就不多说了。

说完了初始阈值的计算过程,再来说说负载因子(loadFactor)。对于 HashMap 来说,负载因子是一个很重要的参数,该参数反应了 HashMap 桶数组的使用情况(假设键值对节点均匀分布在桶数组中)。通过调节负载因子,可使 HashMap 时间和空间复杂度上有不同的表现。当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键的键之间产生的碰撞会下降,链表长度变短。此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。相反,如果增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,效率也随之降低,这种情况是拿时间换空间。至于负载因子怎么调节,这个看使用场景了。一般情况下,我们用默认值就可以了。

3.2 查找

HashMap 的查找操作比较简单,查找步骤与原理篇介绍一致,即先定位键值对所在的桶的位置,然后再对链表或红黑树进行查找。通过这两步即可完成查找,该操作相关代码如下:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 1. 定位键值对所在桶的位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // 2. 如果 first 是 TreeNode 类型,则调用黑红树查找方法
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                
            // 2. 对链表进行查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

查找的核心逻辑是封装在 getNode 方法中的,getNode 方法源码我已经写了一些注释,应该不难看懂。我们先来看看查找过程的第一步 - 确定桶位置,其实现代码如下:

// index = (n - 1) & hash
first = tab[(n - 1) & hash]

这里通过(n - 1)& hash即可算出桶的在桶数组中的位置,可能有的朋友不太明白这里为什么这么做,这里简单解释一下。HashMap 中桶数组的大小 length 总是2的幂,此时,(n - 1) & hash 等价于对 length 取余。但取余的计算效率没有位运算高,所以(n - 1) & hash也是一个小的优化。举个例子说明一下吧,假设 hash = 185,n = 16。计算过程示意图如下:

上面的计算并不复杂,这里就不多说了。

在上面源码中,除了查找相关逻辑,还有一个计算 hash 的方法。这个方法源码如下:

/**
 * 计算键的 hash 值
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

看这个方法的逻辑好像是通过位运算重新计算 hash,那么这里为什么要这样做呢?为什么不直接用键的 hashCode 方法产生的 hash 呢?大家先可以思考一下,我把答案写在下面。

这样做有两个好处,我来简单解释一下。我们再看一下上面求余的计算图,图中的 hash 是由键的 hashCode 产生。计算余数时,由于 n 比较小,hash 只有低4位参与了计算,高位的计算可以认为是无效的。这样导致了计算结果只与低位信息有关,高位数据没发挥作用。为了处理这个缺陷,我们可以上图中的 hash 高4位数据与低4位数据进行异或运算,即 hash ^ (hash >>> 4)。通过这种方式,让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中。此时的计算过程如下:

在 Java 中,hashCode 方法产生的 hash 是 int 类型,32 位宽。前16位为高位,后16位为低位,所以要右移16位。

上面所说的是重新计算 hash 的一个好处,除此之外,重新计算 hash 的另一个好处是可以增加 hash 的复杂度。当我们覆写 hashCode 方法时,可能会写出分布性不佳的 hashCode 方法,进而导致 hash 的冲突率比较高。通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性。这也就是为什么 HashMap 不直接使用键对象原始 hash 的原因了。

3.3 遍历

和查找查找一样,遍历操作也是大家使用频率比较高的一个操作。对于 遍历 HashMap,我们一般都会用下面的方式:

for(Object key : map.keySet()) {
    // do something
}

for(HashMap.Entry entry : map.entrySet()) {
    // do something
}

从上面代码片段中可以看出,大家一般都是对 HashMap 的 key 集合或 Entry 集合进行遍历。上面代码片段中用 foreach 遍历 keySet 方法产生的集合,在编译时会转换成用迭代器遍历,等价于:

Set keys = map.keySet();
Iterator ite = keys.iterator();
while (ite.hasNext()) {
    Object key = ite.next();
    // do something
}

大家在遍历 HashMap 的过程中会发现,多次对 HashMap 进行遍历时,遍历结果顺序都是一致的。但这个顺序和插入的顺序一般都是不一致的。产生上述行为的原因是怎样的呢?大家想一下原因。我先把遍历相关的代码贴出来,如下:

public Set<K> keySet() {
    Set<K> ks = keySet;
    if (ks == null) {
        ks = new KeySet();
        keySet = ks;
    }
    return ks;
}

/**
 * 键集合
 */
final class KeySet extends AbstractSet<K> {
    public final int size()                 { return size; }
    public final void clear()               { HashMap.this.clear(); }
    public final Iterator<K> iterator()     { return new KeyIterator(); }
    public final boolean contains(Object o) { return containsKey(o); }
    public final boolean remove(Object key) {
        return removeNode(hash(key), key, null, false, true) != null;
    }
    // 省略部分代码
}

/**
 * 键迭代器
 */
final class KeyIterator extends HashIterator 
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
}

abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry 
            // 寻找第一个包含链表节点引用的桶
            do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

    public final boolean hasNext() {
        return next != null;
    }

    final Node<K,V> nextNode() {
        Node<K,V>[] t;
        Node<K,V> e = next;
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        if ((next = (current = e).next) == null && (t = table) != null) {
            // 寻找下一个包含链表节点引用的桶
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }
    //省略部分代码
}

如上面的源码,遍历所有的键时,首先要获取键集合KeySet对象,然后再通过 KeySet 的迭代器KeyIterator进行遍历。KeyIterator 类继承自HashIterator类,核心逻辑也封装在 HashIterator 类中。HashIterator 的逻辑并不复杂,在初始化时,HashIterator 先从桶数组中找到包含链表节点引用的桶。然后对这个桶指向的链表进行遍历。遍历完成后,再继续寻找下一个包含链表节点引用的桶,找到继续遍历。找不到,则结束遍历。举个例子,假设我们遍历下图的结构:

HashIterator 在初始化时,会先遍历桶数组,找到包含链表节点引用的桶,对应图中就是3号桶。随后由 nextNode 方法遍历该桶所指向的链表。遍历完3号桶后,nextNode 方法继续寻找下一个不为空的桶,对应图中的7号桶。之后流程和上面类似,直至遍历完最后一个桶。以上就是 HashIterator 的核心逻辑的流程,对应下图:

遍历上图的最终结果是 19 -> 3 -> 35 -> 7 -> 11 -> 43 -> 59,为了验证正确性,简单写点测试代码跑一下看看。测试代码如下:

/**
 * 应在 JDK 1.8 下测试,其他环境下不保证结果和上面一致
 */
public class HashMapTest {

    @Test
    public void testTraversal() {
        HashMap<Integer, String> map = new HashMap(16);
        map.put(7, "");
        map.put(11, "");
        map.put(43, "");
        map.put(59, "");
        map.put(19, "");
        map.put(3, "");
        map.put(35, "");

        System.out.println("遍历结果:");
        for (Integer key : map.keySet()) {
            System.out.print(key + " -> ");
        }
    }
}

遍历结果如下:

在本小节的最后,抛两个问题给大家。在 JDK 1.8 版本中,为了避免过长的链表对 HashMap 性能的影响,特地引入了红黑树优化性能。但在上面的源码中并没有发现红黑树遍历的相关逻辑,这是为什么呢?对于被转换成红黑树的链表该如何遍历呢?大家可以先想想,然后可以去源码或本文后续章节中找答案。

3.4 插入

3.4.1 插入逻辑分析

通过前两节的分析,大家对 HashMap 低层的数据结构应该了然于心了。即使我不说,大家也应该能知道 HashMap 的插入流程是什么样的了。首先肯定是先定位要插入的键值对属于哪个桶,定位到桶后,再判断桶是否为空。如果为空,则将键值对存入即可。如果不为空,则需将键值对接在链表最后一个位置,或者更新键值对。这就是 HashMap 的插入流程,是不是觉得很简单。当然,大家先别高兴。这只是一个简化版的插入流程,真正的插入流程要复杂不少。首先 HashMap 是变长集合,所以需要考虑扩容的问题。其次,在 JDK 1.8 中,HashMap 引入了红黑树优化过长链表,这里还要考虑多长的链表需要进行优化,优化过程又是怎样的问题。引入这里两个问题后,大家会发现原本简单的操作,现在略显复杂了。在本节中,我将先分析插入操作的源码,扩容、树化(链表转为红黑树,下同)以及其他和树结构相关的操作,随后将在独立的两小结中进行分析。接下来,先来看一下插入操作的源码:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 如果桶中不包含键值对节点引用,则将新键值对节点的引用存入桶中即可
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
        else if (p instanceof TreeNode)  
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 对链表进行遍历,并统计链表长度
            for (int binCount = 0; ; ++binCount) {
                // 链表中不包含要插入的键值对节点时,则将该节点接在链表的最后
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度大于或等于树化阈值,则进行树化操作
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                
                // 条件为 true,表示当前链表包含要插入的键值对,终止遍历
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 判断要插入的键值对是否存在 HashMap 中
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 键值对数量超过阈值时,则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

插入操作的入口方法是 put(K,V),但核心逻辑在V putVal(int, K, V, boolean, boolean) 方法中。putVal 方法主要做了这么几件事情:

  1. 当桶数组 table 为空时,通过扩容的方式初始化 table
  2. 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
  3. 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
  4. 判断键值对数量是否大于阈值,大于的话则进行扩容操作

以上就是 HashMap 插入的逻辑,并不是很复杂,这里就不多说了。接下来来分析一下扩容机制。

3.4.2 扩容机制

在 Java 中,数组的长度是固定的,这意味着数组只能存储固定量的数据。但在开发的过程中,很多时候我们无法知道该建多大的数组合适。建小了不够用,建大了用不完,造成浪费。如果我们能实现一种变长的数组,并按需分配空间就好了。好在,我们不用自己实现变长数组,Java 集合框架已经实现了变长的数据结构。比如 ArrayList 和 HashMap。对于这类基于数组的变长数据结构,扩容是一个非常重要的操作。下面就来聊聊 HashMap 的扩容机制。

在详细分析之前,先来说一下扩容相关的背景知识:

在 HashMap 中,桶数组的长度均是2的幂,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。

HashMap 的扩容机制与其他变长集合的套路不太一样,HashMap 按当前桶数组长度的2倍进行扩容,阈值也变为原来的2倍(如果计算过程中,阈值溢出归零,则按阈值公式重新计算)。扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。以上就是 HashMap 的扩容大致过程,接下来我们来看看具体的实现:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 如果 table 不为空,表明已经初始化过了
    if (oldCap > 0) {
        // 当 table 容量超过容量最大值,则不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } 
        // 按旧容量和阈值的2倍计算新容量和阈值的大小
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    } else if (oldThr > 0) // initial capacity was placed in threshold
        /*
         * 初始化时,将 threshold 的值赋值给 newCap,
         * HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
         */ 
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        /*
         * 调用无参构造方法时,桶数组容量为默认容量,
         * 阈值为默认容量与默认负载因子乘积
         */
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // newThr 为 0 时,按阈值计算公式进行计算
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 创建新的桶数组,桶数组的初始化也是在这里完成的
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 如果旧的桶数组不为空,则遍历桶数组,并将键值对映射到新的桶数组中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 重新映射时,需要对红黑树进行拆分
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表,并将链表节点按原顺序进行分组
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 将分组后的链表映射到新桶中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

上面的源码有点长,希望大家耐心看懂它的逻辑。上面的源码总共做了3件事,分别是:

  1. 计算新桶数组的容量 newCap 和新阈值 newThr
  2. 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的
  3. 将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。

上面列的三点中,创建新的桶数组就一行代码,不用说了。接下来,来说说第一点和第三点,先说说 newCap 和 newThr 计算过程。该计算过程对应 resize 源码的第一和第二个条件分支,如下:

// 第一个条件分支
if ( oldCap > 0) {
    // 嵌套条件分支
    if (oldCap >= MAXIMUM_CAPACITY) {...}
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY) {...}
} 
else if (oldThr > 0) {...}
else {...}

// 第二个条件分支
if (newThr == 0) {...}

通过这两个条件分支对不同情况进行判断,进而算出不同的容量值和阈值。它们所覆盖的情况如下:

分支一:

条件覆盖情况备注
oldCap > 0桶数组 table 已经被初始化
oldThr > 0threshold > 0,且桶数组未被初始化调用 HashMap(int) 和 HashMap(int, float) 构造方法时会产生这种情况,此种情况下 newCap = oldThr,newThr 在第二个条件分支中算出
oldCap == 0 && oldThr == 0桶数组未被初始化,且 threshold 为 0调用 HashMap() 构造方法会产生这种情况。

这里把oldThr > 0情况单独拿出来说一下。在这种情况下,会将 oldThr 赋值给 newCap,等价于newCap = threshold = tableSizeFor(initialCapacity)。我们在初始化时传入的 initialCapacity 参数经过 threshold 中转最终赋值给了 newCap。这也就解答了前面提的一个疑问:initialCapacity 参数没有被保存下来,那么它怎么参与桶数组的初始化过程的呢?

嵌套分支:

条件覆盖情况备注
oldCap >= 230桶数组容量大于或等于最大桶容量 230这种情况下不再扩容
newCap < 230 && oldCap > 16新桶数组容量小于最大值,且旧桶数组容量大于 16该种情况下新阈值 newThr = oldThr << 1,移位可能会导致溢出

这里简单说明一下移位导致的溢出情况,当 loadFactor小数位为 0,整数位可被2整除且大于等于8时,在某次计算中就可能会导致 newThr 溢出归零。见下图:

分支二:

条件覆盖情况备注
newThr == 0第一个条件分支未计算 newThr 或嵌套分支在计算过程中导致 newThr 溢出归零

说完 newCap 和 newThr 的计算过程,接下来再来分析一下键值对节点重新映射的过程。

在 JDK 1.8 中,重新映射节点需要考虑节点类型。对于树形节点,需先拆分红黑树再映射。对于链表类型节点,则需先对链表进行分组,然后再映射。需要的注意的是,分组后,组内节点相对位置保持不变。关于红黑树拆分的逻辑将会放在下一小节说明,先来看看链表是怎样进行分组映射的。

我们都知道往底层数据结构中插入节点时,一般都是先通过模运算计算桶位置,接着把节点放入桶中即可。事实上,我们可以把重新映射看做插入操作。在 JDK 1.7 中,也确实是这样做的。但在 JDK 1.8 中,则对这个过程进行了一定的优化,逻辑上要稍微复杂一些。在详细分析前,我们先来回顾一下 hash 求余的过程:

上图中,桶数组大小 n = 16,hash1 与 hash2 不相等。但因为只有后4位参与求余,所以结果相等。当桶数组扩容后,n 由16变成了32,对上面的 hash 值重新进行映射:

扩容后,参与模运算的位数由4位变为了5位。由于两个 hash 第5位的值是不一样,所以两个 hash 算出的结果也不一样。上面的计算过程并不难理解,继续往下分析。

假设我们上图的桶数组进行扩容,扩容后容量 n = 16,重新映射过程如下:

依次遍历链表,并计算节点 hash & oldCap 的值。如下图所示

如果值为0,将 loHead 和 loTail 指向这个节点。如果后面还有节点 hash & oldCap 为0的话,则将节点链入 loHead 指向的链表中,并将 loTail 指向该节点。如果值为非0的话,则让 hiHead 和 hiTail 指向该节点。完成遍历后,可能会得到两条链表,此时就完成了链表分组:

最后再将这两条链接存放到相应的桶中,完成扩容。如下图:

从上图可以发现,重新映射后,两条链表中的节点顺序并未发生变化,还是保持了扩容前的顺序。以上就是 JDK 1.8 中 HashMap 扩容的代码讲解。另外再补充一下,JDK 1.8 版本下 HashMap 扩容效率要高于之前版本。如果大家看过 JDK 1.7 的源码会发现,JDK 1.7 为了防止因 hash 碰撞引发的拒绝服务攻击,在计算 hash 过程中引入随机种子。以增强 hash 的随机性,使得键值对均匀分布在桶数组中。在扩容过程中,相关方法会根据容量判断是否需要生成新的随机种子,并重新计算所有节点的 hash。而在 JDK 1.8 中,则通过引入红黑树替代了该种方式。从而避免了多次计算 hash 的操作,提高了扩容效率。

本小节的内容讲就先讲到这,接下来,来讲讲链表与红黑树相互转换的过程。

3.4.3 链表树化、红黑树链化与拆分

JDK 1.8 对 HashMap 实现进行了改进。最大的改进莫过于在引入了红黑树处理频繁的碰撞,代码复杂度也随之上升。比如,以前只需实现一套针对链表操作的方法即可。而引入红黑树后,需要另外实现红黑树相关的操作。红黑树是一种自平衡的二叉查找树,本身就比较复杂。本篇文章中并不打算对红黑树展开介绍,本文仅会介绍链表树化需要注意的地方。至于红黑树详细的介绍,如果大家有兴趣,可以参考我的另一篇文章 - 红黑树详细分析

在展开说明之前,先把树化的相关代码贴出来,如下:

static final int TREEIFY_THRESHOLD = 8;

/**
 * 当桶数组容量小于该值时,优先进行扩容,而不是树化
 */
static final int MIN_TREEIFY_CAPACITY = 64;

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}

/**
 * 将普通节点链表转换成树形节点链表
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 桶数组容量小于 MIN_TREEIFY_CAPACITY,优先进行扩容而不是树化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd 为头节点(head),tl 为尾节点(tail)
        TreeNode<K,V> hd = null, tl = null;
        do {
            // 将普通节点替换成树形节点
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);  // 将普通链表转成由树形节点链表
        if ((tab[index] = hd) != null)
            // 将树形链表转换成红黑树
            hd.treeify(tab);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

在扩容过程中,树化要满足两个条件:

  1. 链表长度大于等于 TREEIFY_THRESHOLD
  2. 桶数组容量大于等于 MIN_TREEIFY_CAPACITY

第一个条件比较好理解,这里就不说了。这里来说说加入第二个条件的原因,个人觉得原因如下:

当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。

回到上面的源码中,我们继续看一下 treeifyBin 方法。该方法主要的作用是将普通链表转成为由 TreeNode 型节点组成的链表,并在最后调用 treeify 是将该链表转为红黑树。TreeNode 继承自 Node 类,所以 TreeNode 仍然包含 next 引用,原链表的节点顺序最终通过 next 引用被保存下来。我们假设树化前,链表结构如下:

HashMap 在设计之初,并没有考虑到以后会引入红黑树进行优化。所以并没有像 TreeMap 那样,要求键类实现 comparable 接口或提供相应的比较器。但由于树化过程需要比较两个键对象的大小,在键类没有实现 comparable 接口的情况下,怎么比较键与键之间的大小了就成了一个棘手的问题。为了解决这个问题,HashMap 是做了三步处理,确保可以比较出两个键的大小,如下:

  1. 比较键与键之间 hash 的大小,如果 hash 相同,继续往下比较
  2. 检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较
  3. 如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder(大家自己看源码吧)

tie break 是网球术语,可以理解为加时赛的意思,起这个名字还是挺有意思的。

通过上面三次比较,最终就可以比较出孰大孰小。比较出大小后就可以构造红黑树了,最终构造出的红黑树如下:

橙色的箭头表示 TreeNode 的 next 引用。由于空间有限,prev 引用未画出。可以看出,链表转成红黑树后,原链表的顺序仍然会被引用仍被保留了(红黑树的根节点会被移动到链表的第一位),我们仍然可以按遍历链表的方式去遍历上面的红黑树。这样的结构为后面红黑树的切分以及红黑树转成链表做好了铺垫,我们继续往下分析。

红黑树拆分

扩容后,普通节点需要重新映射,红黑树节点也不例外。按照一般的思路,我们可以先把红黑树转成链表,之后再重新映射链表即可。这种处理方式是大家比较容易想到的,但这样做会损失一定的效率。不同于上面的处理方式,HashMap 实现的思路则是上好佳(上好佳请把广告费打给我)。如上节所说,在将普通链表转成红黑树时,HashMap 通过两个额外的引用 next 和 prev 保留了原链表的节点顺序。这样再对红黑树进行重新映射时,完全可以按照映射链表的方式进行。这样就避免了将红黑树转成链表后再进行映射,无形中提高了效率。

以上就是红黑树拆分的逻辑,下面看一下具体实现吧:

// 红黑树转链表阈值
static final int UNTREEIFY_THRESHOLD = 6;

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    /* 
     * 红黑树节点仍然保留了 next 引用,故仍可以按链表方式遍历红黑树。
     * 下面的循环是对红黑树节点进行分组,与上面类似
     */
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }

    if (loHead != null) {
        // 如果 loHead 不为空,且链表长度小于等于 6,则将红黑树转成链表
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            /* 
             * hiHead == null 时,表明扩容后,
             * 所有节点仍在原位置,树结构不变,无需重新树化
             */
            if (hiHead != null) 
                loHead.treeify(tab);
        }
    }
    // 与上面类似
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

从源码上可以看得出,重新映射红黑树的逻辑和重新映射链表的逻辑基本一致。不同的地方在于,重新映射后,会将红黑树拆分成两条由 TreeNode 组成的链表。如果链表长度小于 UNTREEIFY_THRESHOLD,则将链表转换成普通链表。否则根据条件重新将 TreeNode 链表树化。举个例子说明一下,假设扩容后,重新映射上图的红黑树,映射结果如下:

红黑树链化

前面说过,红黑树中仍然保留了原链表节点顺序。有了这个前提,再将红黑树转成链表就简单多了,仅需将 TreeNode 链表转成 Node 类型的链表即可。相关代码如下:

final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    // 遍历 TreeNode 链表,并用 Node 替换
    for (Node<K,V> q = this; q != null; q = q.next) {
        // 替换节点类型
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}

上面的代码并不复杂,不难理解,这里就不多说了。到此扩容相关内容就说完了,不知道大家理解没。

3.5 删除

如果大家坚持看完了前面的内容,到本节就可以轻松一下。当然,前提是不去看红黑树的删除操作。不过红黑树并非本文讲解重点,本节中也不会介绍红黑树相关内容,所以大家不用担心。

HashMap 的删除操作并不复杂,仅需三个步骤即可完成。第一步是定位桶位置,第二步遍历链表并找到键值相等的节点,第三步删除节点。相关源码如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 1. 定位桶位置
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果键的值与链表第一个节点相等,则将 node 指向该节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {  
            // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 2. 遍历链表,找到待删除节点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 3. 删除节点,并修复链表或红黑树
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

删除操作本身并不复杂,有了前面的基础,理解起来也就不难了,这里就不多说了。

3.6 其他细节

前面的内容分析了 HashMap 的常用操作及相关的源码,本节内容再补充一点其他方面的东西。

被 transient 所修饰 table 变量

如果大家细心阅读 HashMap 的源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。我们再回到源码中,考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不序列化的话,别人还怎么还原呢?

这里简单说明一下吧,HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容。这样做是有原因的,试问一句,HashMap 中存储的内容是什么?不用说,大家也知道是键值对。所以只要我们把键值对序列化了,我们就可以根据键值对数据重建 HashMap。有的朋友可能会想,序列化 table 不是可以一步到位,后面直接还原不就行了吗?这样一想,倒也是合理。但序列化 talbe 存在着两个问题:

  1. table 多数情况下是无法被存满的,序列化未使用的部分,浪费空间
  2. 同一个键值对在不同 JVM 下,所处的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能会发生错误。

以上两个问题中,第一个问题比较好理解,第二个问题解释一下。HashMap 的get/put/remove等方法第一步就是根据 hash 找到键所在的桶位置,但如果键没有覆写 hashCode 方法,计算 hash 时最终调用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能会有不同的实现,产生的 hash 可能也是不一样的。也就是说同一个键在不同平台下可能会产生不同的 hash,此时再对在同一个 table 继续操作,就会出现问题。

综上所述,大家应该能明白 HashMap 不序列化 table 的原因了。

3.7 总结

本章对 HashMap 常见操作相关代码进行了详细分析,并在最后补充了一些其他细节。在本章中,插入操作一节的内容说的最多,主要是因为插入操作涉及的点特别多,一环扣一环。包含但不限于“table 初始化、扩容、树化”等,总体来说,插入操作分析起来难度还是很大的。好在,最后分析完了。

本章篇幅虽比较大,但仍未把 HashMap 所有的点都分析到。比如,红黑树的增删查等操作。当然,我个人看来,以上的分析已经够了。毕竟大家是类库的使用者而不是设计者,没必要去弄懂每个细节。所以如果某些细节实在看不懂的话就跳过吧,对我们开发来说,知道 HashMap 大致原理即可。

好了,本章到此结束。

4.写在最后

写到这里终于可以松一口气了,这篇文章前前后后花了我一周多的时间。在我写这篇文章之前,对 HashMap 认识仅限于原理层面,并未深入了解。一开始,我觉得关于 HashMap 没什么好写的,毕竟大家对 HashMap 多少都有一定的了解。但等我深入阅读 HashMap 源码后,发现之前的认知是错的。不是没什么可写的,而是可写的点太多了,不知道怎么写了。JDK 1.8 版本的 HashMap 实现上比之前版本要复杂的多,想弄懂众多的细节难度还是不小的。仅自己弄懂还不够,还要写出来,难度就更大了,本篇文章基本上是在边读源码边写的状态下完成的。由于时间和能力有限,加之文章篇幅比较大,很难保证不出错分析过程及配图不出错。如果有错误,希望大家指出来,我会及时修改,这里先谢谢大家。

好了,本文就到这里了,谢谢大家的阅读!

参考

本文在知识共享许可协议 4.0 下发布,转载请注明出处
作者:coolblog
为了获得更好的分类阅读体验,
请移步至本人的个人博客:http://www.coolblog.xyz

cc
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

查看原文

赞 129 收藏 87 评论 39

程序员cxuan 发布了文章 · 4月17日

Java真实面经总结

从春节过后,我,一位双非渣本的大三学生,便踏上了实习之旅,面试了不下三十场,虽然很菜,但是也相应地拿了一些 offer ,例如京东金融、人人车等五六家 offer

总结一下春招就是一个字:难。

没学历,技术还凑合,简历能过但是面试就有点困难。这期间收到了 N 个面试官的歧视,有些面试官感觉骨子里瞧不起我们这些双非的人。一下内容仅凭记忆回想起,还有一些必问的东西,总结在这里,希望能帮到大家!

算法

这个真的就只能靠刷题,不敢说每家公司对于笔试的重视程度怎么样,反正笔试基本上是必须要过的一关

  • 队列。
  • 数组。
  • 栈。
  • 链表。
  • 树。
  • 散列表(哈希表)。
  • 堆。
  • 图。
  • 无序树:树中任意节点的子结点之间没有顺序关系、这种树称为无序树、也称为自由树。
  • 有序树:树中任意节点的子结点之间有顺序关系、这种树称为有序树。
  • 二叉树:每个节点最多含有两个子树的树称为二叉树。
  • 完全二叉树。
  • 满二叉树。
  • 斜树。
  • 平衡二叉树。
  • 霍夫曼树:带权路径最短的二叉树称为哈夫曼树或最优二叉树。
  • 红黑树。

以及各种遍历方式、按层打印、统计距离等等。

树是基础,基本的数据结构还包括图、图的遍历方式。

DFS、BFS以及各种优缺点、贪心算法、回溯、以及建模等等等等,只能靠刷题来提升。

计算机网络

  • GET/POST 区别。
  • UDP/TCP区别。
  • TCP 三次握手。以及衍生出来一些列的 TCP 的问题:什么是 TIME-WAIT、为什么可以是三次挥手、为什么不能是两次握手、流量控制、滑动窗口、Nagle 算法、糊涂窗口综合症、拥塞控制、慢启动、拥塞避免、快重传、快恢复、长连接 VS 短连接、应用场景是什么。
  • HTTP 1.0、1.1、2.0。
  • 说一下 HTTPS 的流程、SSL 是什么、什么是非对称加密、对称加密、RSA 具体实现。
  • OSI 七层模型是什么、都有哪些协议、TCP/IP 四层是什么。
  • DNS、ARP 协议原理。
  • 地址栏输入 URL 发生了什么。
  • WebSocket 是什么。
  • 一些网络安全问题、比如 DOS 攻击如何解决、DNS 欺骗如何解决、ARP欺骗、SQL注入、XSS、CSRF、iframe 安全问题、本地存储数据问题、第三方依赖的安全性问题。
  • HTTP 是不保存状态的协议、那么如何保存用户状态。
  • Cookie 的作用是什么、和 Session 有什么区别。
  • Session 的实现机制是什么、分布式环境下有什么注意事项、如果注销 Session、设置 Session 的时长如何操作、默认时长是多少。
  • HTTP 1.0 和 HTTP 1.1 的主要区别是什么。
  • 各种协议与 HTTP 协议之间的关系。
  • Servlet、Filter 和 Listener 分别是什么,用在什么地方,JSP 页面如何进行处理。
  • 请求转发、URL 重定向和包含有什么区别,如何实现。
  • 如何判断远程机器上某个端口是否开启,项目中需要查看域名在本地的解析 IP ,如何操作。
  • Servlet 中,调用 JSP 展示元素和返回 String(即 api,一般是 json 数据)有什么区别。
  • nginx + tomcat 模式下,服务器段如何获取客户端请求 IP 。
  • Servlet 的生命周期是什么。
  • Servlet 是否是线程安全的。

Java基础

  • 描述一下值传递和引用传递的区别。
  • == 和 equals 区别是什么、String 中的 equals 方法是如何重写的、为什么要重写 equals 方法、为什么要重写 hashCode 方法。
  • String s1 = new String("abc")、String s2 = "abc"、s1 == s2 。语句1在内存中创建了几个对象。
  • String 为什么是不可变的、jdk源码中的 String 如何定义的、为什么这么设计。
  • 请描述一下 static 关键字和 final 关键字的用法。
  • 接口和抽象类的区别是什么。
  • 重载和重写的区别。
  • 面向对象的三大特性,谈谈你对 xx 的理解。
  • 考察的是基本类型的转换,及原码反码补码的运算。
  • byte 的取值范围是多少、怎么计算出来的。
  • HashMap 相关、HashMap 和 Hashtable 的区别、HashMap 和 HashSet 区别、HashMap 底层实现、HashMap 的长度为什么是 2 的幂次方、HashMap 多线程操作导致死循环问题、HashMap 的线程安全实现有哪些、ConcurrentHashMap 的底层实现。
  • Integer 缓存池。
  • UTF-8 和 Unicode 的关系。
  • 项目为 UTF-8 环境,char c = ‘中’,是否合法。
  • Arrays.asList 获得的 List 使用时需要注意什么。
  • Collection 和 Collections 区别。
  • 你知道 fail-fast 和 fail-safe 吗。
  • ArrayList 和 LinkedList 和 Vector 的区别。
  • Set 和 List 区别、Set 如何保证元素不重复。
  • UTF-8 与 GBK 互转、为什么会乱码。
  • 重载和重写的区别。
  • 为什么 Java 是解释性语言。
  • ConcurrentHashMap 1.7和1.8的区别:整体结构;put()方法、get()方法、resize()方法、size()方法
  • 地址栏输入 URL 发生了什么。
  • 组合和聚合的区别。
  • 讲一下 CMS 垃圾回收器。
  • JDK 动态代理和 GClib 动态代理、JDK 动态代理具体实现原理、CGLib 动态代理、两者对比。
  • Threadlocal 内存泄漏问题。
  • StringBuilder 安全怎么实现的、详细描述怎么扩容的。

MyBatis

  • Mybatis 执行流程。
  • Mybatis缓存。
  • Mybatis用到的设计模式。

Java锁

  • 锁类型
  • 悲观锁 VS 乐观锁

    悲观锁代表 Synchronized 关键字。

    Synchronized 关键字实现方法。

    乐观锁代表 CAS 操作。

    CAS 带来的 ABA 问题。

    CAS 带来的循环时间长开销大问题。

    CAS 带来的只能保证一个共享变量的原子操作问题。

    CAS 是如何保证原子操作的。

    AtomticXXX 实现的原理。

    volatile 关键字。

    内存可见性的原因。

    禁止指令重排序的原因。

    volatile 关键字不能保证原子操作的原因。

    关于 volatile 关键字的讨论。

    happen-before 规则介绍。

  • 可重入锁、 可中断锁、公平锁、读写锁

    谈谈对 AQS 的理解。

    可重入锁。

    可中断锁。

    公平锁。

    读写锁。

  • 偏向锁/轻量级锁/重量级锁 升级过程。
  • 补充

    自旋锁。

    分段锁。

    轻量级锁就一定比重量级锁快吗。

Java多线程

  • 线程与进程的区别

    线程的状态。

    Notify 和 wait 。

    Thread.sleep() 和 Thread.yield() 的异同。

    死锁的概念。

    并发和并行的区别。

    线程安全三要素。

    如何实现线程安全。

    保证线程安全的机制。

    谈谈对对多线程的理解。

    run 和 Start 方法的区别。

  • 多线程

    创建线程的方法。

    线程池创建线程。

    ThreadPoolExecutor介绍。

    BlockingQueue。

    ArrayBlockingQueue。

    LinkedBlockingQueue。

    LinkedBlockingQueue 和 ArrayBlockingQueue 的主要区别。

    handler 拒绝策略。

    线程池五种状态。

    深入理解 ThreadPoolExecutor。

    线程池中 ctl 属性的作用是什么。

    shutdownNow 和 shutdown 的区别。

    线程复用原理。

    灵魂拷问:你如何设置你的线程池参数。

    CountDownLatch 和 CyclicBarrier 区别。

  • 多线程间通信的几种方式

    使用 volatile 关键字。

    锁机制。

    final 关键词。

    ThreadLocal 类。

    JUC 包中的相关 lock 类

Jvm内存模型

  • JVM内存模型

    程序计数器(记录当前线程)。

    Java栈(虚拟机栈)。

    本地方法栈。

    堆。

    方法区。

    直接内存。

  • JVM 垃圾回收

    垃圾判断标准。

    引用计数法。

    可达性分析算法(根索法)。

  • 垃圾回收算法

    标记清除。

    复制算法。

    标记整理。

    分代回收。

    GC 垃圾回收器。

  • 垃圾收集器

    Serial 垃圾收集器(单线程、复制算法) (新生代)。

    ParNew 垃圾收集器(Serial+多线程) (新生代)。

    Parallel Scavenge 收集器(多线程复制算法、高效) (新生代)。

    Serial Old 收集器(单线程标记整理算法 ) (老年代)。

    Parallel Old 收集器(多线程标记整理算法)(老年代)。

    CMS 收集器(多线程标记清除算法) (老年代)。

    G1垃圾回收器。

  • 目前 web 应用中的垃圾收集器。
  • 吞吐优先与响应优先。
  • Minor GC 和 Full GC。
  • Full Gc 触发条件。
  • 对象内存布局。
  • 为什么新生代存在两个 survivor 区。
  • 一个对象真正不可用,要经历两次标记过程。

MySQL

  • 什么是数据库事务、数据库事务的四个特性是什么。
  • 请分别举例说明幻读和不可重复读、并描述一下它们之间的区别。
  • MySQL 的默认隔离级别是什么。
  • 为什么要使用索引。
  • 索引这么多优点,为什么不对表中每个字段都创建索引呢。
  • 索引是如何提升查询速度的。
  • 请说出你知道的索引失效的几种情况。
  • 什么是聚簇索引与非聚簇索引
  • MySQL 索引主要使用的数据结构有哪些。
  • 谈谈 MyISAM 和 InnoDb 实现 BTree 索引方式的区别。
  • 什么是覆盖索引、请举例说明。
  • 谈谈你对最左前缀原则的理解。
  • MySQL 中 InnoDb 和 MyISAM 有什么区别。
  • 谈谈如何对SQL进行优化。
  • 如何用 explain 分析 SQL 执行效率。
  • explain 显示的字段具体解释下。
  • 请举出可能形成数据库死锁的原因、如何能避免死锁。
  • 数据库中的乐观锁和悲观锁有什么区别、各适用于什么场景。
  • 请结合你的开发经历,谈谈数据库中的乐观锁和悲观锁是具体如何被应用的。
  • 索引的本质。
  • MySQL 存储引擎。
  • MySQL 索引

    数据结构,B-Tree 和 B+Tree。

    带有顺序访问指针的 B+Tree

    索引的物理存储。

    与 B-Tree 相比,B+Tree 有什么不同。

    为什么 B+Tree 更适合做文件索引。

    为什么不用 AVL 树或者红黑树做索引。

    两种引擎的索引存储机制。

    MyISAM 索引实现。

    InnoDB 索引实现。

    索引失效条件。

    索引类型

    哈希索引。

    有序数组。

    B+ 树索引(InnoDB)。

    联合索引。

    最左前缀原则。

    覆盖索引。

    索引下推。

Spring

  • Spring bean 的生命周期

    初始化容器。

    Bean 属性注入、更改以及初始化。

    Bean 的使用。

    关闭容器、销毁 Bean。

  • Spring如何解决 bean 的循环依赖

    容器循环依赖。

    setter循环依赖。

    构造器循环依赖

  • Bean 的加载过程
  • BeanFactory 和 FactoryBean 的区别
  • Bean 注册与使用
  • Spring 三级缓存如何解决循环依赖。
  • Spring事务、原理、传播行为、失效条件。
  • AOP
  • IOC
  • SpringBoot 自动注入原理、stater原理、启动流程。
  • Spring 事务管理原理。

分布式

  • Dubbo 支持哪些协议、每种协议的应用场景、优缺点。
  • Dubbo 超时时间怎样设置。
  • Dubbo 有些哪些注册中心。
  • Dubbo 集群的负载均衡有哪些策略。
  • Dubbo 的主要应用场景。
  • Dubbo 的核心功能。
  • Dubbo 的核心组件。
  • Dubbo 服务注册与发现的流程。
  • Dubbo 的服务调用流程。
  • Dubbo 支持哪些协议、每种协议的应用场景、优缺点。
  • Dubbo 的注册中心集群挂掉,发布者和订阅者之间还能通信么。
  • Dubbo与 Spring 的关系。
  • Dubbo 使用的是什么通信框架。
  • Dubbo 的集群容错方案有哪些。
  • Dubbo 支持哪些序列化方式。

zookpeer

  • zookpeer 节点类型。
  • zookpeer 的作用。
  • zookpeer 的 watcher 机制。
  • zookpeer 如何实现分布式锁。
  • zookpeer 选举算法。
  • Paxos 算法。
  • Raft 算法。
  • ZAB 协议。
  • 什么是分布式事务。

    分布式事务解决方案。

    了解 seata 吗。

    一致性哈希?

    哈希槽、以及为什么是2^14。

  • SpringCloud组件?
  • 什么是 Hystrix、它如何实现容错。
  • 什么是 RestTemplate。
  • 什么是 Ribbn。
  • nacos/Eureka 的对比。
  • 什么是 zuul。
  • 什么是 Getway。
  • 什么是 Config。
  • 什么是微服务

    什么是SOA。

    SOA和微服务的区别。

  • 为什么要用微服务。
  • 使用微服务存在的问题以及解决办法。
  • 微服务之间如何通信。
  • 微服务如何发现。
  • 微服务挂了、如何解决。
  • 重试机制、幂等性。

    限流

    熔断、降级

Liunx

  • linux 常用命令有哪些、分别举例。
  • 查询 3306 端口占用情况的 linux 指令如何写。
  • linux 中查看某个 java 进行的进程号 pid、如何操作呢。
  • 进程通信方式。
  • 进程、线程、协程。
  • 进程调度算法。
  • Liunx下的 I/O 模型。
  • 用户态、内核态。
  • 如何减少内核态到用户态的拷贝(mmap)。
  • 常用的命令。
  • 查看日志。

如何复习

Java笼统一点来讲,无非是:JUC、多线程、锁、集合、基础知识、框架、分布式。

一个知识体系一定要一块学,

比如 JUC,这个是一个很大的包,系统学习会比较消耗时间,但是收益也是比较不错的,能够吧一些细节的点都串联起来,这样记忆比较更深刻一些

比如 HashMap 可以揉碎了学习,为什么0.75的负载因子,为什么要无符号右移16位?为什么是2的倍数?为什么是8而不是7、9?

工具类的东西很容易被替代,曾今的 SSH 现在的 Spring-Boot、Cloud,也许过几年之后又是新花样,但技术底层是差不多的原理,了解了底层,不仅有助于问题的排查,对于程序猿的整个晋升的道路而言,更是不错的一种思维、学习方式。

忌讳东一榔头,西一棒槌的学习,那样知识为了应付面试,面试过了,很容易就会忘。

一般这样的一个顺序:

  • 看源码,抠细节
  • 看博客、公众号的相应解释
  • 自己总结一遍,写到自己的MD文件或者博客里
  • 一周之后,或者几天之后在复习一遍,(艾宾浩斯遗忘曲线)温故而知新

刷题

刷题两个好地方:

  • 牛客,也是我推荐大家去的,所有题目免费,而且基本上都有大佬们讨论
  • LeetCode,这个也可以,但是相应地会收取一定的费用,VIP之类的

字节跳动对于算法十分钟爱,几乎每一面都会至少两到算法题,所以,要想进字节,至少俩月算法题刷起来。

不要扯什么算法不重要,程序猿搞不定算法就像厨子不会颠勺,司机不会挂挡。

查看原文

赞 4 收藏 3 评论 1

程序员cxuan 赞了文章 · 4月9日

MySQL存储引擎详解

微信搜索公众号:”菜鸟封神记“,定期分享一线大厂常用技术干货。

一、MySQL常用存储引擎及特点

1、InnoDB存储引擎

从MySQL5.5版本之后,MySQL的默认内置存储引擎已经是InnoDB了,他的主要特点有:

(1)灾难恢复性比较好;
(2)支持事务。默认的事务隔离级别为可重复度,通过MVCC(并发版本控制)来实现的。
(3)使用的锁粒度为行级锁,可以支持更高的并发;
(4)支持外键;
(5)配合一些热备工具可以支持在线热备份;
(6)在InnoDB中存在着缓冲管理,通过缓冲池,将索引和数据全部缓存起来,加快查询的速度;
(7)对于InnoDB类型的表,其数据的物理组织形式是聚簇表。所有的数据按照主键来组织。数据和索引放在一块,都位于B+数的叶子节点上;

2、MyISAM存储引擎
在5.5版本之前,MyISAM是MySQL的默认存储引擎,该存储引擎并发性差,不支持事务,所以使用场景比较少,主要特点为:

(1)不支持事务;
(2)不支持外键,如果强行增加外键,不会提示错误,只是外键不其作用;
(3)对数据的查询缓存只会缓存索引,不会像InnoDB一样缓存数据,而且是利用操作系统本身的缓存;
(4)默认的锁粒度为表级锁,所以并发度很差,加锁快,锁冲突较少,所以不太容易发生死锁;
(5)支持全文索引(MySQL5.6之后,InnoDB存储引擎也对全文索引做了支持),但是MySQL的全文索引基本不会使用,对于全文索引,现在有其他成熟的解决方案,比如:ElasticSearch,Solr,Sphinx等。
(6)数据库所在主机如果宕机,MyISAM的数据文件容易损坏,而且难恢复;

3、MEMORY存储引擎
将数据存在内存中,和市场上的Redis,memcached等思想类似,为了提高数据的访问速度,主要特点:

(1)支持的数据类型有限制,比如:不支持TEXT和BLOB类型,对于字符串类型的数据,只支持固定长度的行,VARCHAR会被自动存储为CHAR类型;
(2)支持的锁粒度为表级锁。所以,在访问量比较大时,表级锁会成为MEMORY存储引擎的瓶颈;
(3)由于数据是存放在内存中,所以在服务器重启之后,所有数据都会丢失;
(4)查询的时候,如果有用到临时表,而且临时表中有BLOB,TEXT类型的字段,那么这个临时表就会转化为MyISAM类型的表,性能会急剧降低;

4、ARCHIVE存储引擎
ARCHIVE存储引擎适合的场景有限,由于其支持压缩,故主要是用来做日志,流水等数据的归档,主要特点:

(1)支持Zlib压缩,数据在插入表之前,会先被压缩;
(2)仅支持SELECT和INSERT操作,存入的数据就只能查询,不能做修改和删除;
(3)只支持自增键上的索引,不支持其他索引;

5、CSV存储引擎
数据中转试用,主要特点:

(1)其数据格式为.csv格式的文本,可以直接编辑保存;
(2)导入导出比较方便,可以将某个表中的数据直接导出为csv,试用Excel办公软件打开;

二、InnoDB和MyISAM的对比

1、由于锁粒度的不同,InnoDB比MyISAM支持更高的并发;
2、InnoDB为行级锁,MyISAM为表级锁,所以InnoDB相对于MyISAM来说,更容易发生死锁,锁冲突的概率更大,而且上锁的开销也更大,因为需要为每一行加锁;
3、在备份容灾上,InnoDB支持在线热备,有很成熟的在线热备解决方案;
4、查询性能上,MyISAM的查询效率高于InnoDB,因为InnoDB在查询过程中,是需要维护数据缓存,而且查询过程是先定位到行所在的数据块,然后在从数据块中定位到要查找的行;而MyISAM可以直接定位到数据所在的内存地址,可以直接找到数据;
5、SELECT COUNT(*)语句,如果行数在千万级别以上,MyISAM可以快速查出,而InnoDB查询的特别慢,因为MyISAM将行数单独存储了,而InnoDB需要朱行去统计行数;所以如果使用InnoDB,而且需要查询行数,则需要对行数进行特殊处理,如:离线查询并缓存;
6、MyISAM的表结构文件包括:.frm(表结构定义),.MYI(索引),.MYD(数据);而InnoDB的表数据文件为:.ibd和.frm(表结构定义);

三、如何选择合适的存储引擎

1、使用场景是否需要事务支持;
2、是否需要支持高并发,InnoDB的并发度远高于MyISAM;
3、是否需要支持外键;
4、是否需要支持在线热备;
5、高效缓冲数据,InnoDB对数据和索引都做了缓冲,而MyISAM只缓冲了索引;
6、索引,不同存储引擎的索引并不太一样;

注:文章属原创,如果转发,请标注出处。

后续更多文章将更新在个人小站上,欢迎查看。

另外提供一些优秀的IT视频资料,可免费下载!如需要请查看https://www.592xuexi.com

查看原文

赞 6 收藏 4 评论 0

程序员cxuan 赞了回答 · 3月27日

gitbook安装好之后,gitbook -V一直报错

环境变量问题
1、npm config get prefix 查询npm全局安装的路径
2、获取bin目录完整路径,如 /usr/local/Cellar/node/12.5.0/bin
3、设置环境变量 export PATH=$PATH:/usr/local/Cellar/node/12.5.0/bin
4、再次运行 gitbook -v

关注 4 回答 4

程序员cxuan 发布了文章 · 3月14日

看完这篇 HTTPS,和面试官扯皮就没问题了

下面我们来一起学习一下 HTTPS ,首先问你一个问题,为什么有了 HTTP 之后,还需要有 HTTPS ?我突然有个想法,为什么我们面试的时候需要回答标准答案呢?为什么我们不说出我们自己的想法和见解,却要记住一些所谓的标准回答呢?技术还有正确与否吗

HTTPS 为什么会出现

一个新技术的出现必定是为了解决某种问题的,那么 HTTPS 解决了 HTTP 的什么问题呢?

HTTPS 解决了什么问题

一个简单的回答可能会是 HTTP 它不安全。由于 HTTP 天生明文传输的特性,在 HTTP 的传输过程中,任何人都有可能从中截获、修改或者伪造请求发送,所以可以认为 HTTP 是不安全的;在 HTTP 的传输过程中不会验证通信方的身份,因此 HTTP 信息交换的双方可能会遭到伪装,也就是没有用户验证;在 HTTP 的传输过程中,接收方和发送方并不会验证报文的完整性,综上,为了结局上述问题,HTTPS 应用而生。

image.png

什么是 HTTPS

你还记得 HTTP 是怎么定义的吗?HTTP 是一种 超文本传输协议(Hypertext Transfer Protocol) 协议,它 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范,那么我们看一下 HTTPS 是如何定义的

HTTPS 的全称是 Hypertext Transfer Protocol Secure,它用来在计算机网络上的两个端系统之间进行安全的交换信息(secure communication),它相当于在 HTTP 的基础上加了一个 Secure 安全的词眼,那么我们可以给出一个 HTTPS 的定义:HTTPS 是一个在计算机世界里专门在两点之间安全的传输文字、图片、音频、视频等超文本数据的约定和规范。 HTTPS 是 HTTP 协议的一种扩展,它本身并不保传输的证安全性,那么谁来保证安全性呢?在 HTTPS 中,使用传输层安全性(TLS)安全套接字层(SSL)对通信协议进行加密。也就是 HTTP + SSL(TLS) = HTTPS。

image.png

HTTPS 做了什么

HTTPS 协议提供了三个关键的指标

  • 加密(Encryption), HTTPS 通过对数据加密来使其免受窃听者对数据的监听,这就意味着当用户在浏览网站时,没有人能够监听他和网站之间的信息交换,或者跟踪用户的活动,访问记录等,从而窃取用户信息。
  • 数据一致性(Data integrity),数据在传输的过程中不会被窃听者所修改,用户发送的数据会完整的传输到服务端,保证用户发的是什么,服务器接收的就是什么。
  • 身份认证(Authentication),是指确认对方的真实身份,也就是证明你是你(可以比作人脸识别),它可以防止中间人攻击并建立用户信任。

有了上面三个关键指标的保证,用户就可以和服务器进行安全的交换信息了。那么,既然你说了 HTTPS 的种种好处,那么我怎么知道网站是用 HTTPS 的还是 HTTP 的呢?给你两幅图应该就可以解释了。

image.png

HTTPS 协议其实非常简单,RFC 文档很小,只有短短的 7 页,里面规定了新的协议名,默认端口号443,至于其他的应答模式、报文结构、请求方法、URI、头字段、连接管理等等都完全沿用 HTTP,没有任何新的东西。

也就是说,除了协议名称和默认端口号外(HTTP 默认端口 80),HTTPS 协议在语法、语义上和 HTTP 一样,HTTP 有的,HTTPS 也照单全收。那么,HTTPS 如何做到 HTTP 所不能做到的安全性呢?关键在于这个 S 也就是 SSL/TLS

什么是 SSL/TLS

认识 SSL/TLS

TLS(Transport Layer Security)SSL(Secure Socket Layer) 的后续版本,它们是用于在互联网两台计算机之间用于身份验证加密的一种协议。

注意:在互联网中,很多名称都可以进行互换。

我们都知道一些在线业务(比如在线支付)最重要的一个步骤是创建一个值得信赖的交易环境,能够让客户安心的进行交易,SSL/TLS 就保证了这一点,SSL/TLS 通过将称为 X.509 证书的数字文档将网站和公司的实体信息绑定到加密密钥来进行工作。每一个密钥对(key pairs) 都有一个 私有密钥(private key)公有密钥(public key),私有密钥是独有的,一般位于服务器上,用于解密由公共密钥加密过的信息;公有密钥是公有的,与服务器进行交互的每个人都可以持有公有密钥,用公钥加密的信息只能由私有密钥来解密。

什么是 X.509:X.509 是公开密钥证书的标准格式,这个文档将加密密钥与(个人或组织)进行安全的关联。

X.509 主要应用如下

  • SSL/TLS 和 HTTPS 用于经过身份验证和加密的 Web 浏览
  • 通过 S/MIME 协议签名和加密的电子邮件
  • 代码签名:它指的是使用数字证书对软件应用程序进行签名以安全分发和安装的过程。

image.png

通过使用由知名公共证书颁发机构(例如SSL.com)颁发的证书对软件进行数字签名,开发人员可以向最终用户保证他们希望安装的软件是由已知且受信任的开发人员发布;并且签名后未被篡改或损害。

HTTPS 的内核是 HTTP

HTTPS 并不是一项新的应用层协议,只是 HTTP 通信接口部分由 SSL 和 TLS 替代而已。通常情况下,HTTP 会先直接和 TCP 进行通信。在使用 SSL 的 HTTPS 后,则会先演变为和 SSL 进行通信,然后再由 SSL 和 TCP 进行通信。也就是说,HTTPS 就是身披了一层 SSL 的 HTTP。(我都喜欢把骚粉留在最后。。。)

image.png

SSL 是一个独立的协议,不只有 HTTP 可以使用,其他应用层协议也可以使用,比如 SMTP(电子邮件协议)Telnet(远程登录协议) 等都可以使用。

探究 HTTPS

我说,你起这么牛逼的名字干嘛,还想吹牛批?你 HTTPS 不就抱上了 TLS/SSL 的大腿么,咋这么牛批哄哄的,还想探究 HTTPS,瞎胡闹,赶紧改成 TLS 是我主,赞美我主。

SSL 即安全套接字层,它在 OSI 七层网络模型中处于第五层,SSL 在 1999 年被 IETF(互联网工程组)更名为 TLS ,即传输安全层,直到现在,TLS 一共出现过三个版本,1.1、1.2 和 1.3 ,目前最广泛使用的是 1.2,所以接下来的探讨都是基于 TLS 1.2 的版本上的。

TLS 用于两个通信应用程序之间提供保密性和数据完整性。TLS 由记录协议、握手协议、警告协议、变更密码规范协议、扩展协议等几个子协议组成,综合使用了对称加密、非对称加密、身份认证等许多密码学前沿技术(如果你觉得一项技术很简单,那你只是没有学到位,任何技术都是有美感的,牛逼的人只是欣赏,并不是贬低)。

说了这么半天,我们还没有看到 TLS 的命名规范呢,下面举一个 TLS 例子来看一下 TLS 的结构(可以参考 https://www.iana.org/assignme...

ECDHE-ECDSA-AES256-GCM-SHA384

这是啥意思呢?我刚开始看也有点懵啊,但其实是有套路的,因为 TLS 的密码套件比较规范,基本格式就是 密钥交换算法 - 签名算法 - 对称加密算法 - 摘要算法 组成的一个密码串,有时候还有分组模式,我们先来看一下刚刚是什么意思

使用 ECDHE 进行密钥交换,使用 ECDSA 进行签名和认证,然后使用 AES 作为对称加密算法,密钥的长度是 256 位,使用 GCM 作为分组模式,最后使用 SHA384 作为摘要算法。

TLS 在根本上使用对称加密非对称加密 两种形式。

对称加密

在了解对称加密前,我们先来了解一下密码学的东西,在密码学中,有几个概念:明文、密文、加密、解密

  • 明文(Plaintext),一般认为明文是有意义的字符或者比特集,或者是通过某种公开编码就能获得的消息。明文通常用 m 或 p 表示
  • 密文(Ciphertext),对明文进行某种加密后就变成了密文
  • 加密(Encrypt),把原始的信息(明文)转换为密文的信息变换过程
  • 解密(Decrypt),把已经加密的信息恢复成明文的过程。

对称加密(Symmetrical Encryption)顾名思义就是指加密和解密时使用的密钥都是同样的密钥。只要保证了密钥的安全性,那么整个通信过程也就是具有了机密性。

image.png

TLS 里面有比较多的加密算法可供使用,比如 DES、3DES、AES、ChaCha20、TDEA、Blowfish、RC2、RC4、RC5、IDEA、SKIPJACK 等。目前最常用的是 AES-128, AES-192、AES-256 和 ChaCha20。

DES 的全称是 Data Encryption Standard(数据加密标准) ,它是用于数字数据加密的对称密钥算法。尽管其 56 位的短密钥长度使它对于现代应用程序来说太不安全了,但它在加密技术的发展中具有很大的影响力。

3DES 是从原始数据加密标准(DES)衍生过来的加密算法,它在 90 年代后变得很重要,但是后面由于更加高级的算法出现,3DES 变得不再重要。

AES-128, AES-192 和 AES-256 都是属于 AES ,AES 的全称是Advanced Encryption Standard(高级加密标准),它是 DES 算法的替代者,安全强度很高,性能也很好,是应用最广泛的对称加密算法。

ChaCha20 是 Google 设计的另一种加密算法,密钥长度固定为 256 位,纯软件运行性能要超过 AES,曾经在移动客户端上比较流行,但 ARMv8 之后也加入了 AES 硬件优化,所以现在不再具有明显的优势,但仍然算得上是一个不错算法。

(其他可自行搜索)

加密分组

对称加密算法还有一个分组模式 的概念,对于 GCM 分组模式,只有和 AES,CAMELLIA 和 ARIA 搭配使用,而 AES 显然是最受欢迎和部署最广泛的选择,它可以让算法用固定长度的密钥加密任意长度的明文。

最早有 ECB、CBC、CFB、OFB 等几种分组模式,但都陆续被发现有安全漏洞,所以现在基本都不怎么用了。最新的分组模式被称为 AEAD(Authenticated Encryption with Associated Data),在加密的同时增加了认证的功能,常用的是 GCM、CCM 和 Poly1305。

比如 ECDHE_ECDSA_AES128_GCM_SHA256 ,表示的是具有 128 位密钥, AES256 将表示 256 位密钥。GCM 表示具有 128 位块的分组密码的现代认证的关联数据加密(AEAD)操作模式。

我们上面谈到了对称加密,对称加密的加密方和解密方都使用同一个密钥,也就是说,加密方必须对原始数据进行加密,然后再把密钥交给解密方进行解密,然后才能解密数据,这就会造成什么问题?这就好比《小兵张嘎》去送信(信已经被加密过),但是嘎子还拿着解密的密码,那嘎子要是在途中被鬼子发现了,那这信可就是被完全的暴露了。所以,对称加密存在风险。

非对称加密

非对称加密(Asymmetrical Encryption) 也被称为公钥加密,相对于对称加密来说,非对称加密是一种新的改良加密方式。密钥通过网络传输交换,它能够确保及时密钥被拦截,也不会暴露数据信息。非对称加密中有两个密钥,一个是公钥,一个是私钥,公钥进行加密,私钥进行解密。公开密钥可供任何人使用,私钥只有你自己能够知道。

image.png

使用公钥加密的文本只能使用私钥解密,同时,使用私钥加密的文本也可以使用公钥解密。公钥不需要具有安全性,因为公钥需要在网络间进行传输,非对称加密可以解决密钥交换的问题。网站保管私钥,在网上任意分发公钥,你想要登录网站只要用公钥加密就行了,密文只能由私钥持有者才能解密。而黑客因为没有私钥,所以就无法破解密文。

非对称加密算法的设计要比对称算法难得多(我们不会探讨具体的加密方式),常见的比如 DH、DSA、RSA、ECC 等。

其中 RSA 加密算法是最重要的、最出名的一个了。例如 DHE_RSA_CAMELLIA128_GCM_SHA256。它的安全性基于 整数分解,使用两个超大素数的乘积作为生成密钥的材料,想要从公钥推算出私钥是非常困难的。

ECC(Elliptic Curve Cryptography)也是非对称加密算法的一种,它基于椭圆曲线离散对数的数学难题,使用特定的曲线方程和基点生成公钥和私钥, ECDHE 用于密钥交换,ECDSA 用于数字签名。

TLS 是使用对称加密非对称加密 的混合加密方式来实现机密性。

混合加密

RSA 的运算速度非常慢,而 AES 的加密速度比较快,而 TLS 正是使用了这种混合加密方式。在通信刚开始的时候使用非对称算法,比如 RSA、ECDHE ,首先解决密钥交换的问题。然后用随机数产生对称算法使用的会话密钥(session key),再用公钥加密。对方拿到密文后用私钥解密,取出会话密钥。这样,双方就实现了对称密钥的安全交换。

image.png

现在我们使用混合加密的方式实现了机密性,是不是就能够安全的传输数据了呢?还不够,在机密性的基础上还要加上完整性身份认证的特性,才能实现真正的安全。而实现完整性的主要手段是 摘要算法(Digest Algorithm)

摘要算法

如何实现完整性呢?在 TLS 中,实现完整性的手段主要是 摘要算法(Digest Algorithm)。摘要算法你不清楚的话,MD5 你应该清楚,MD5 的全称是 Message Digest Algorithm 5,它是属于密码哈希算法(cryptographic hash algorithm)的一种,MD5 可用于从任意长度的字符串创建 128 位字符串值。尽管 MD5 存在不安全因素,但是仍然沿用至今。MD5 最常用于验证文件的完整性。但是,它还用于其他安全协议和应用程序中,例如 SSH、SSL 和 IPSec。一些应用程序通过向明文加盐值或多次应用哈希函数来增强 MD5 算法。

什么是加盐?在密码学中,就是一项随机数据,用作哈希数据,密码或密码的单向函数的附加输入。盐用于保护存储中的密码。例如

image.png

什么是单向?就是在说这种算法没有密钥可以进行解密,只能进行单向加密,加密后的数据无法解密,不能逆推出原文。

我们再回到摘要算法的讨论上来,其实你可以把摘要算法理解成一种特殊的压缩算法,它能够把任意长度的数据压缩成一种固定长度的字符串,这就好像是给数据加了一把锁。

image.png

除了常用的 MD5 是加密算法外,SHA-1(Secure Hash Algorithm 1) 也是一种常用的加密算法,不过 SHA-1 也是不安全的加密算法,在 TLS 里面被禁止使用。目前 TLS 推荐使用的是 SHA-1 的后继者:SHA-2

SHA-2 的全称是Secure Hash Algorithm 2 ,它在 2001 年被推出,它在 SHA-1 的基础上做了重大的修改,SHA-2 系列包含六个哈希函数,其摘要(哈希值)分别为 224、256、384 或 512 位:SHA-224, SHA-256, SHA-384, SHA-512。分别能够生成 28 字节、32 字节、48 字节、64 字节的摘要。

有了 SHA-2 的保护,就能够实现数据的完整性,哪怕你在文件中改变一个标点符号,增加一个空格,生成的文件摘要也会完全不同,不过 SHA-2 是基于明文的加密方式,还是不够安全,那应该用什么呢?

安全性更高的加密方式是使用 HMAC,在理解什么是 HMAC 前,你需要先知道一下什么是 MAC。

MAC 的全称是message authentication code,它通过 MAC 算法从消息和密钥生成,MAC 值允许验证者(也拥有秘密密钥)检测到消息内容的任何更改,从而保护了消息的数据完整性。

HMAC 是 MAC 更进一步的拓展,它是使用 MAC 值 + Hash 值的组合方式,HMAC 的计算中可以使用任何加密哈希函数,例如 SHA-256 等。

image.png

现在我们又解决了完整性的问题,那么就只剩下一个问题了,那就是认证,认证怎么做的呢?我们再向服务器发送数据的过程中,黑客(攻击者)有可能伪装成任何一方来窃取信息。它可以伪装成你,来向服务器发送信息,也可以伪装称为服务器,接受你发送的信息。那么怎么解决这个问题呢?

认证

如何确定你自己的唯一性呢?我们在上面的叙述过程中出现过公钥加密,私钥解密的这个概念。提到的私钥只有你一个人所有,能够辨别唯一性,所以我们可以把顺序调换一下,变成私钥加密,公钥解密。使用私钥再加上摘要算法,就能够实现数字签名,从而实现认证。

image.png

到现在,综合使用对称加密、非对称加密和摘要算法,我们已经实现了加密、数据认证、认证,那么是不是就安全了呢?非也,这里还存在一个数字签名的认证问题。因为私钥是是自己的,公钥是谁都可以发布,所以必须发布经过认证的公钥,才能解决公钥的信任问题。

所以引入了 CA,CA 的全称是 Certificate Authority,证书认证机构,你必须让 CA 颁布具有认证过的公钥,才能解决公钥的信任问题。

全世界具有认证的 CA 就几家,分别颁布了 DV、OV、EV 三种,区别在于可信程度。DV 是最低的,只是域名级别的可信,EV 是最高的,经过了法律和审计的严格核查,可以证明网站拥有者的身份(在浏览器地址栏会显示出公司的名字,例如 Apple、GitHub 的网站)。不同的信任等级的机构一起形成了层级关系。

image.png

通常情况下,数字证书的申请人将生成由私钥和公钥以及证书签名请求(CSR)组成的密钥对。CSR是一个编码的文本文件,其中包含公钥和其他将包含在证书中的信息(例如域名,组织,电子邮件地址等)。密钥对和 CSR生成通常在将要安装证书的服务器上完成,并且 CSR 中包含的信息类型取决于证书的验证级别。与公钥不同,申请人的私钥是安全的,永远不要向 CA(或其他任何人)展示。

生成 CSR 后,申请人将其发送给 CA,CA 会验证其包含的信息是否正确,如果正确,则使用颁发的私钥对证书进行数字签名,然后将其发送给申请人。

image.png

总结

本篇文章我们主要讲述了 HTTPS 为什么会出现 ,HTTPS 解决了 HTTP 的什么问题,HTTPS 和 HTTP 的关系是什么,TLS 和 SSL 是什么,TLS 和 SSL 解决了什么问题?如何实现一个真正安全的数据传输?

文章参考:

https://www.ssl.com/faqs/what...

https://www.ibm.com/support/k...

https://en.wikipedia.org/wiki...

https://en.wikipedia.org/wiki...

https://www.quora.com/What-do...

https://hpbn.co/transport-lay...

https://www.ssl2buy.com/wiki/...

https://crypto.stackexchange....

https://en.wikipedia.org/wiki...

https://www.comparitech.com/b...

《极客时间-透析 HTTP 协议》

https://www.tutorialsteacher....

https://baike.baidu.com/item/...

https://baike.baidu.com/item/...

https://www.ssl.com/faqs/faq-...

https://en.wikipedia.org/wiki...

https://support.google.com/we...

https://www.cloudflare.com/le...

https://www.cisco.com/c/en/us...

https://www.freecodecamp.org/...

查看原文

赞 96 收藏 73 评论 1

程序员cxuan 发布了文章 · 2月24日

风物长宜放眼量,人间正道是沧桑 - 一位北美 IT 技术人破局

引言

我对于本科时光的印象,还停留在那所普通 211 大学的建筑物之间,我坐在大学的时光长廊里,满眼望去,都是经历的过的故事。可毕业后回首,却很少有人能说,自己从来没有迷茫过。迷茫,仿佛就是一团乌云,笼罩在每一个心中怀有抱负的人的头上。每当夜深人静,思绪归于对自己人生未来的严肃思考,不知去往何处的苦闷,再加之不断迫近的升学/就业选择的压力,尤其是一些看似周密的计划,由于想把每一环都做的尽善尽美,往往不仅减少了反馈(一切的目标、报偿都在最后)、还因为人生的不确定性而很容易失败:以保研为例,我常常见到一些平常学习认真、热心参加学术活动,但是不关心所在院系制度的人,到保研时因为一些硬性规定(如必须完成某些并不喜欢的所谓保研必修课)、或者是不公平的排名标准(比如说活动成绩、竞赛成绩计算很高,导致成绩上产生的差距在计算时几乎没什么权重)导致原本计划了几年的保研机会失之交臂,而此时离考研也已不远,若无提前准备,也往往容易落榜。在这样的过程中,亲历者绝望、愤懑甚至因而抑郁的,我都见过,以至于都有些麻木了:太阳照常升起,一个人的悲哀苦痛,回到这个宏大的时代与社会中,连一粒尘埃也算不上;而那些看多了这样的故事的人,也难免兔死狐悲,故而更难聚集其一股欲与天公试比高的拼搏的意志。

2019年7月29日,《中国青年报》刊发《大学生抑郁症发病率逐年攀升 大一和大三高发》引发读者广泛关注。有31.2万网民参与了中国青年报微博发起的 大学生抑郁症发病率逐年攀升,你觉得自己有抑郁倾向吗的网络投票,其中认为自己有抑郁倾向且情况很严重的达到了8.6万,占比27.6%,若是统计为有过低沉的倾向,则有约18万人,占比为60.8%。

一些市面上的流行语录,似乎也从侧面说出了这种感觉:什么我所得到的不过是侥幸,纵然得到了一时的世俗意义上的成功,由于从来没有对自己的人生命运有一个通盘的规划与目标,即使通过搜集到的一些信息、经验走了一些捷径,但是短暂的兴奋后,却又会重新回到迷茫与空虚中来。我忍不住还是要发问:上了好学校、找了好工作、赚了不少钱,那然后呢?倘若有一步,没能像这样环环相扣地被达成,那又该怎么办呢?纯粹的人往往能在一个方向做出不俗的成绩,可过于纯粹,就难以承受突如其来的打击,难以接受自己的规划被命运玩弄,付之东流。

回想我的大学生活,也的确是这样:一起朝夕相处的同学朋友们,虽然可以一起感慨未来的未知与自身的无力,可自己的命运最终还是只能自己把握,总要面对那条只能一个人走完的路。而在我在大学期间认识的大约几百人里,真正能有坚定的三观信仰,又努力去做的(即所谓知行合一的),实在是凤毛麟角,而且这往往出自他们不断地痛苦地思考与试错,有时甚至还需要一点运气。

人本应该是越长大越坚强越成熟的,可在大学期间因迷茫和各种诱惑堕落的大多数人,其心志能力,往往连高中时代都不如,既失去了当初的纯粹与坚定,又没有真正获得一些面对问题、解决问题的勇气与能力。这种普遍的迷茫,不只是存在于那些混吃等死的人中,我认识的无数的名列前茅、努力拼搏的同学,也还是深受其苦。笔者也是前两年,才逐渐开始想明白。像这样大环境的精神空虚与迷茫,究竟是谁的责任呢?

毕业晚会时,一曲谁的青春不迷茫唱出了我的心声,歌词非常写实,也写出了很多人的青春,可是让我有些近乎条件反射般的讨厌:谁的青春又想迷茫呢?

一个人、一小群人的迷茫,尚且可以认为是个人心理问题,抑或是环境,甚至是遗传;可成批的学生都怀疑人生、看不到未来的出路、不知道自己要干什么,这究竟是谁的责任呢?辗转反侧后我觉得需要用文字来阐述一下我的观点。

那么究竟为什么会变成这样呢?就有没有什么合理的办法、科学的想法可以借鉴呢?作为个人,我们是不是也应该参考古今中外的真正的大人物、向那些慧人借鉴呢?

我的青春并不想迷茫,我相信大家的青春也都不想迷茫。虽然因为运气我有了一点成绩,但是我觉得这不少都得归于时运的赐予,不把这样的经验分享给更多还在痛苦思考中继续前行的人,我无法获得良心的安宁。

本文将从成因开始着手分析迷茫这个问题,从问题的产生到表现、再到教育制度、人性的缺点、再到我们可以锻炼的能力以及可以采取的想法(由于笔者也算是半个做技术的,相关的能力将主要以技术为基础)。

迷茫问题的定义与分析

在报刊或者一些网络评论中,偶尔也会看到老一代的人批判现在的年轻人事多,是的,当代的大学生,与过去的时代,是有一些不同的。

1993年,全国取消了粮票制度,这也意味着,这一代人基本是没有经历过饥饿与那种迫切的生存的问题。

相反的,更多的人考虑的是我活着有什么意义,表现在流行文化上,就是讲求个性,讲究表现自我,想寻找不同寻常的意义,而不只是活着。因此带来了更加自由的发展,以及求而不得的各种迷茫。

不过,为什么又一定是大学生呢?中学生的阶段、我们并不是没有其他想法,但是的确就没有那种难忍的迷茫。

因为对于大多数普通学生而言,大学以前的内驱力是很明显的。

由于中国绝大多数中学都在以高考成绩作为其神圣而不可动摇的目标,一系列神奇的口号也能体现这一点,譬如某些中学曾经打出的没有高考,你拼得过官/富二代吗?提高一分,干掉千人!不苦不累,高三无味;不拼不搏,等于白活!等等。

从现实的功利,到亢奋的鸡血,到想压人一头的野心,这些口号就是这种高考文化的最好体现。

从报刊书籍到街谈巷议,从学校老师到家长学生,能不受这种文化影响的,很少很少,或多或少都要受此裹挟。

所以一切的其他梦想并没有被真的解决了,而是以一句你高考考好了,未来都能实现的,其实是某些落榜生的绝望,也是受到了这句话的影响:那我要考不好,我的人生是不是就废了?

将一次考试完全的神化,对于人的长期热情,实际上起到了杀鸡取卵的作用。

因为这种热情,随着高考的结束,在大多数人身上重新消失了(既然高考都是所谓的最后一战了,之后岂不是就应该安享人生了?)。

而且往往因为此前压力太大,还会引起加倍的反弹,究其原因,还是因为大学没有了那种统一的价值标杆,正如一句近年来很火的评论高中忙的理所当然,大学忙的莫名其妙(说一句题外话,这句话原本是我一个大学同学随手写的说说,写完当晚转发破万)。

即使是清华北大,年年都有人因为沉迷游戏退学的,至于我自己的所见所闻,沉迷游戏的人,甚至可能不止百分之二十,每个年级因严重沉迷而退学的,也屡见不鲜。

严重者如此,症状较浅者因为没有目标而沉迷游戏,透支自我,意志消沉的,那就不计其数了。人生尚有许多美好故事可以经历,而他们却早早地选择了这样的透支

不管怎么样,随着高考的结束,这种受全社会标榜的奋斗价值仿佛就告一段落了一样。一群被社会舆论、集体主义裹挟着完成了高考的人,却一下子被投入了一个多元化价值的环境里,什么都好像是有意义的,什么又都好像是没有意义的,那种确定性的价值的丧失,让人产生了巨大的恐惧与不适应。

这就是迷茫的定义了:由于价值多元化,不能找到并坚守属于自己信仰的价值并坚定决心的去追求

这种迷茫的弥漫与试图消解,也构成了当代文化的一个大类,或者与之交织起来,形成了更深层的恶性循环。

读书

被认为最好的方法一般来说是多读书。可即使是读书,如果读不到一定深度,往往也并没有什么特别的价值;即使是似乎对对口解决这个问题的哲学,其最新的发展,也就是现代西方哲学,依旧有一个重要的问题,那就是虚无主义

西方的主流精神史说来也好笑,过去的几千年一直迷信上帝(或者神及其等价物),一切价值都可以从那里推出来;

然后到文艺复兴与启蒙运动,短暂地迷信了一会人类理性的作用;

结果因为理性的滥用,尤其是试图用理性来论证人生的意义,就走到了问题的反面——人生的意义不是浅薄的理性可以论证的,所以尼采说上帝死了(狂热的信仰神的意义是不能用逻辑论证的)。

这就是现代西方哲学让人陷入的所谓精神荒漠,凡是想要真诚研究探讨这些问题的,就越是觉得人生没有意义。

至于其他种类的书籍,学心理学本指望能搞清楚自己为什么抑郁,结果一堆又大又空洞的人格模型性格模型之类的名词扔来,解释什么内容都好像有点道理,但是想更直接的使用分析具体问题,发现总归又差了点,只看书肯定是分析不了的;

学社会学本指望能搞清楚社会如何运行,结果大杂烩似的讲一堆似是而非的社会建构理论,然后分析各种人群的特性,感觉明白了好多东西,但是这跟我应该做什么又有什么关系呢?

至于经济学、政治学,都是讲了一堆有时都不一定能自圆其说的道理,且不提理解上往往难度较高,即使真的理解了,也很少存在什么让人能一定要坚持的道理。

虽然口头上和心里觉得很多理论是不错,但是为了这些抽象的信念而献身,似乎又并没有那么强的内驱力,而且执行起来也不知从何着手。

是的,市面上大多数的关于读书的宣传,往往也就只能把书读到这个程度了,读书当然不是坏事,浅阅读才是,所谓浅阅读,就是不能将书籍与自己的思考、经验、对社会实际的认知结合,所以有一句流行语就反映了对这样的理论的嘲讽:有什么卵用呢?是啊,什么东西只知道这么肤浅的内容,看着样样都知道,其实呢:学了金融学也不懂金融体系的运行,学了投资学炒股还是一样亏本,看了社会学经济学,问他为什么现在房价这么高、中国国民经济的体系结构究竟是怎么样的,未来将会发展的产业又是什么……不用问,只是读到上面那种程度,是根本不可能答得出这些问题的。

可是,读书并不是目的本身,很多名著也不是为了写作而写作的,马克思写《资本论》,是为了分析清楚在资本主义条件下,社会的各种成员从商品到企业再到产业的价值分配问题,最终揭示整个经济体系运作的机理,从而教育劳动者为了自己的合理利益而斗争。

而很多营销号,就往往只会售卖这些廉价的杂学知识、小技巧、人生社会的一些或者正确而无用的废话,很少能真的讲出一些具有可操作性的深度思,而这是古往今来一切有价值的好书的共性:好书不仅仅是议论本身,它们本质上都是作者在自己认识世界、改造世界的过程中积累而成的经验,由于作者的时空所限、因为文字本身表达的模糊性所限,自然而然会出现老子所谓道可道,非常道(真理如果能被文字表达出来,那么就不会是永久的真理)的现象。而判断什么对、什么不对,都需要读书者自己努力思考、在社会中进行实践。知易行难,这就是肤浅的、不去把一些似是而非的东西真正考证清楚的书籍、学问往往真的没什么卵用的本质。

俗世标准的成功门槛太单一

回想我的中学时代(这种县中的应试教育模式,应该也能让很多人同样想起自己的中学时代吧)

每天我们会被要求早上六点五十之前到校,然后开始早读,并开始一天的灌输性的课程,然后完成每门课要求的各种作业、试卷。

其中主要的创造性、思考性的工作,可能也就在跟老师一起思考问题以及做作业中了,至于课外的内容,即使不是完全不涉及,能花的时间也是很少的。

由此带来的结果,就是虽然获得了不错的高考成绩,考上了不错的大学,但是上了大学后却发现学不进去了很不适应新的上课方法,因为没有那么多针对很细节的知识点都做出来的广大的题库了(高中时,即使是很小的知识点,肯定当晚的作业习题就会做个好几题),而自己把一个个知识点弄得条理分明的能力,并没有得到过有针对性的锻炼。

大量的学生,即使认真学习,也不知从何下手,往往到大二大三,可能才渐渐适应大学的课程松散而作业不多的情况。 但是,很多人上了大学之后,也还是摆脱不了这种鸡血的生活,强迫症似的逼自己变得优秀,一心追求更高的分数、更多的竞赛等等与高中时代别无二致的评价体系中的成功

再演变下去,那就是更好的研究生名额、工作,更高的薪水......

那再然后呢?为什么一定要这样?比别人好就行了吗

于是在学会反思之后,很多人也不能摆脱这样的迷茫:这样做自然不会让自己变的很坏,可是离那种指点江山、意气风发的少年英雄的形象,似乎也实在是看不到什么接点,活着难道就是为了时时刻刻在这种标准中比别人好那么一点吗?

而且最重要的是,不像谁都会关心你的高考一样,上了大学之后,除了父母,几乎真的没什么人会很关心的你的成绩如何了,于是很多人渐渐选择了退出这种竞争。

当然,除此以外也可以追求一些别的成功:去做各种各样的活动、志愿者等等,除了少数真的明白自己在做什么的人,大多数人也还是多了些经历、少了些激情,因为没有明确的目标指引,没有明确的意义的驱动,这些平庸的优秀实在不能让人满意。

表现在工作上,也就是近来被人批判的所谓奋斗逼,不是奋斗不对,是为了奋斗而奋斗,实在是很可悲的。

可是,人之所以高贵于动物,在于人会反思、会追问意义,如果一个人被突然要求像西西弗斯一样,每天把一个大石头推上山、一旦到了山上就推下去,于是周而复始,而没有一个明确的目标与意义,无论是多么坚定的人,都难免发问:像这样活着,有什么意义

现代很多男性,为物欲与社会的虚名浮利所迷,觉得只需要迎合所谓丈母娘的爱好,有一个好工作(或者年薪够高)、有车有房,人生好像就圆满了,于是人的价值几乎就只剩下了钱,若是碰巧得到,方还好,可欲壑难填,赚了这么多,总是会一山望着一山高,永远得不到真正的意义。而且,不客气的说一句,像这样的成功男士,除非运气极佳,大多难守那么多的财富,也很难获得真正的精神上的完满。当他有一天开始反问自己:像这样活着,我为了什么?此时就是所谓中年精神危机的开始了。

追求世俗的成功并没有错,但是单纯追求别人口中的成功,不过是把自己信仰的主宰权交给别人,而一个有独立意志的人(在古书中往往就是所谓君子、大丈夫之类)是绝不应该满足于此的。就像作为一个刻板印象出现的程序员群体,就是这种化身:钱多话少死的早,除了像机器一样被劳动异化为非人(实际上没有独立意志的人),什么东西都交给别人、社会去评判、去决定,自己心中没有对于真爱、真理、正义的绝不放下的那种热情与信念。像这样的人,哪怕年纪很大,也不过是一个大男孩罢了,甚至用这些年更火的词来说,就是巨婴

古时的君子、英雄,为了改变他们眼中不合理、不完美的世界,投入各种变革、忍受千难万险、不断提高工作能力,他们清醒地认识到自己的能力的界限,只是耐心地、智慧地、一点一滴地往那个目标前进,如果有必要,他们还会舍生取义,这是因为在他们的生命中,有比他们的生命乃至一切都更重要的东西。而大男孩们,平常嘻嘻哈哈,遇到风浪,却不能挺得住各种打击,为自己理应守护的人或者理念战斗到底,这与所谓仗义每从屠狗辈,读书多是负心人颇有些不谋而合。这样的人只要能获得一些钱财,就可以赞美为成功,甚至飘飘然自以为大丈夫,不亦悲乎?

享受

那,既不想每天过的那么劳劳碌碌,也不去读书,如何呢?

自然更是不行。

为什么?

现代社交媒体、信息平台太多了,一个年轻人至少可以从微博、微信公众号文章、今日头条类的新闻推送工具、知乎等APP(我认为99%的人至少用过上述一种或几种)上获取信息,这些信息还是会潜移默化的教育人,让人自然而然形成对人生意义,或者再不济,什么是成功的思考。

由于执笔者自身往往局限性就很大,往往不过是一无所成的白面书生,所以写出来的文字往往也根本没有清晰的思想,只是迎合各种欲望和热点,或者空洞地谈论一些自己根本不能践行到底的思想。 所以阅读这些文字的人,潜移默化受其影响。

故而得不到自己的意义,就觉得自己很失败,又不知有什么路可以走,若是心气不够高、能力又不足,就容易放弃治疗(放弃有条理的努力),变得很(觉得人生无意义又无可奈何,只能通过嘲笑自己与人生的荒谬而活下去)。

每个看似荒谬的社会现象的背后,总有这种其实让人心酸的绝望,如果不是没办法、找不到出路,谁又想那么丧呢?

此外,消费主义文化在资本的驱动下,时时刻刻在想办法侵蚀年轻人,抖音、快手之类的短视频,兜售口红、美食等等所谓精致生活。(校园贷之类的在较好的大学还不算太猖獗,但是借花呗严重提高不必要的消费还是家常便饭)

其实就是把人需要的几种欲望包装一下:食欲(网红美食、奶茶)甚至性欲(各种小姐姐短视频的本质往往不过是软色情)。

这些无所谓的东西,如果大量花时间沉迷其中,不仅消磨了人的时间,更是腐化了人的斗志,而且假借时尚与娱乐的名义,让一切对于它们的严肃批判都显得很反潮流

虽然它或许是短期见效最快的缓解迷茫的办法,但是长远来看,这也是危害最大的方法,不过饮食宴乐,谁又能说一点不沾呢,适度的话,也有利于放松身心。

更重要的是,它们降低了人们获取快感、反馈的阈值,让人很容易就想葛优躺,只想躺着获得直接的快感,从而渐渐陷入了一个难以振作的怪圈之中,导致所谓的行为成瘾,从精神到肉体都逐渐萎靡,身材走样还是小事,毕竟还能健身改变,养成了这样散漫乏力的精神习惯,志趣也便越来越卑下猥琐,要知道,人是一种很容易文过饰非的动物,一旦人开始不断地为自己的不作为寻找借口,比如说拖延症、比如说努力也是一种才能,我没有才能等等,我看很少有能真的治好的:反正我都有病了,你们就别指责我了,让我爱咋咋地吧!

波伏娃曾经评论过:男人的极大幸运在于,他,不论在成年还是在小时候,必须踏上一条极为艰苦的道路,不过这是一条最可靠的道路;女人的不幸则在于被几乎不可抗拒的诱惑包围着;她不被要求奋发向上,只被鼓励滑下去到达极乐。当她发觉自己被海市蜃楼愚弄时,已经为时太晚,她的力量在失败的冒险中已被耗尽

她是用这句话讽刺欧洲的女性被社会文化鼓励不去努力工作、开拓事业的,但是如果把主语的男人、女人,改换成不迷茫的人与迷茫的人,或许也并没有太大语病。

小结

以上三者,如果我们归结一下,有一个共同的本质:找不到人生的乐趣,因而产生不了对一些本值得热爱的东西的激情(即西方所谓passion)。

信念与热情之所以重要,就在于其对于人的近乎绝对的强制作用,它就像康德口中的道德律令一样,一切恶习(尤其是懒惰与萎靡)在它面前都不能成为借口。有拖延症?没时间?没精力?脑子不够聪明?没钱没地位?都不能构成一个理由。倘若真的觉得一件事物是对的,比如说正义,自然就得为其负上各种各样的责任与义务,不论时间、不论空间,不论正在从事什么工作,一旦有了机会条件,就一定会把自己的才能、经验、资源统统用在这上面,这就是所谓激情,也就是驱动人去完成一些不得不做的事情的本源。

人与人之间虽然可能的确有一些天赋、智商等等的差异,但是绝对没有想象中那么大,只是达到一个行业前 5%,都是完全可以通过努力解决的,所谓努力,不是说闷头搬砖,举一个极端的例子:当年轰动一时的暴走妈妈体重很胖,但是孩子手术需要移植她的器官,她就每天暴走二十几公里,恐怕她以前一个月都未必会运动这时一天这么多;对于很多需要智力的工作,如果真的到了非做不可的地步,查书、查资料、把能问的人、能用的资源统统用上,于是突破过去的眼界和能力,得到巨大的进步。而很多人呢,只是稍微遇到了一点挫折,就坐在地上,实在是没什么值得同情的:毕竟,什么英雄不是这样,一次一次超越自己的极限,才能获得最终的成功呢?

所谓的领导气质(leadership),我觉得也就是基于这样磅礴的热情:百折不挠,一定要完成心中的伟大目标,并引领后来人一起走上这样的道路,关心他人的发展,关心团队的进步,这才是一个合格的领导。当他走的是一条能为了全人类谋福利的大路,并能走在人们的前面,高举旗帜、披荆斩棘,他就是人民的领袖,就是历史的巨人。这样的人的伟名也终将流芳百世,当人们重新呼唤起这些名字时,他们会明白,这就是伟岸的巨人,他们将永远俯视那些匍匐着的卑下的灵魂,而面对他们的骨灰,高尚的人们将洒下热泪

对于教育制度的反思

大科学家钱学森曾经有过著名的钱学森之问:为什么我们的学校总是培养不出杰出的人才?

流行的说法是将这个问题理解为为何我国正规教育体系近年来没有培养出大师,但是,很多大师本来就出自个人的热情与努力,本身就不是能像模板一样培养出来的,而武汉大学前校长刘道玉对这个问题则认识颇为独到。他说,钱老的意思是,为什么我们的教育不能培养出真正的“创新型人才”?

什么是创新型人才?甚至说,什么是创新?我不敢直接对其下一个定义,但是以我个人的经验来看,所谓创新型人才,与我们一直提倡的素质教育以及真正的高等教育,至少具有包含的关系,这又如何理解呢?

现在的大学教育,自从大学扩招之后,一直是广受诟病的,一个概括性的形容词就是教育水平严重滑坡,简单来说,就是与扩招之前、与国际一流水平差距很大。

在这一部分,我将以个人经历为主(本科在国内、有美国交换生经历,研究生也在美国),从三个方面,梳理一下我对国内工科教育体系不足之处的思考以及关于个人、体制可以做的一些补救的措施的理解。

工科技术教学是否滑坡了?——与国外高校以及国内过去情况对比

从当前国内高校的教育体系来看,很多课程出现了这样的问题:教学分离、教考分离。本科期间我修了大概六十门课程,自学的课程数,恐怕不低于半数,大量的老师上课只能照本宣科或者略微做一点阐发,且不提什么融会贯通、讲讲实际在工程、科研中的应用了,大多数情况下,也就只能保证讲的东西不出错,至于说有没有更好的办法、如何从初学者的角度提出便于思考理解的方法,一般来说,大多数课程都做不到这个层次。

对于一门课程,我认为大致有三重境界:第一层是不出错,把需要讲述的课程以正确的形式展现其主干;第二层是一气呵成,老师能以自己的一套便于初学者理解的观点形成连贯、生动的讲解,在保证正确的前提下,使用多种多样的形式进行阐发,使得更多人能理解的更深刻清晰;第三层则是融会贯通,一个老师本身理应是这门课程相关领域的专家,那么自然应该有一些与其专家身份相称的高观点,更加深刻、灵活地阐述这些知识点在具体问题、尤其是一般人不能解决的问题上的合理应用,如何利用知识解决一些有实际意义的问题,这才是一门课理想的境地。

但是正如这个分类,很多老师还停留在第一阶段,连像高中老师一样达到第二阶段的都往往称得上名师教学能手了,这在原本应该是有大师的大学,真是一件奇怪而讽刺的事情。

与之完全不同的是,美国的工程教育则是世界有名的优秀。我举一个略极端的例子,我交换的美国一所州立旗舰大学,其学生数理基础平均水平,以国内高考衡量,应只有二本左右的水平(我曾不止一次在教他们题目时,遇到他们连高二左右的数学题都想不出来的情况,这个情况如果在我本科的学校,我可能已经直接骂了,而本校的新生,绝对会觉得不好意思而不是理所当然)。

但是只从大三左右的专业课谈起,对比州立大学的EE(电气工程)专业与我本科院校的自动化专业(我校王牌专业之一)的学生,能熟练使用 matlab 仿真一个电路/控制系统,并在单片机等类似硬件设备上实现的比例,显然是州立大学的学生远高于本校的学生。不仅如此,美国学生主动提出问题、解决问题的热情,更是远远高于国内。

因为对于州立大学来说,使用 matlab 仿真一个课程相关的内容,那往往只是一门课程的小作业要求(有时候作业的解释并不很详细),普通学生,不论是跟同学讨论、请教助教或者谷歌寻找相关的教程,还是会尽量自己独立完成作业的;而对于我们自动化的同学来说,这种东西只有大佬学霸才会,最多不超过百分之十的人会,往往就是拿着某几个大佬甚至是几届以上的代代传来应付作业(甚至还可以用一用万能的淘宝代做,这里就点到为止了,不能再传播这样教人学坏的方法了)。

那么跟国内以前比怎么样呢?以自动化专业的控制系列课程为例,对比的对象主要是目前我的本科学校自动化从工程数学、信号与系统到控制工程基础、现代控制理论,与过去哈工大一位老师大约十年前的一门《控制系统设计》课程(我只看到了那门课的一场 45 分钟的节选)。

如果是一个行家来分析我们的教学结果,可以说是有些可耻的:大多数学生学完了这几门课程了,由于上课时往往只是对着 ppt 或者课本讲几个例题(本质上只是算术题,连数学都不配称呼,因为缺少起码的技巧与思想),结果就是学生连傅里叶变换的内涵都不能理解,更不要说进一步推广到频谱分析等等应用上了,至于所谓课程之间的融会贯通,更是天方夜谭一般。

反观哈工大这位老师的 45 分钟的课程录像,他简单的从波特图(bode plot)入手,从高低频分别讲解了设计的要点,并以自己从事的课题飞行器设计为例,提到工作频率大约在 20Hz 左右,随后又讲解了截止频率,并与模拟电路上的运算放大器之所以存在所谓 1MHz 这样的提法无缝衔接。

这就叫深入浅出,不仅简明扼要地讲明白了问题的关键,还理清了各门课之间的关系,并能联系到工程实际。虽然这只是一门理论课,但是高下立判。

跟一些老教授探讨后,他们都表示,二三十年前的课程,基本都能达到上述的那种深入浅出的要求。这也从微观上印证了市面上流传的所谓大学扩招后教学质量的严重滑坡这一论断。

作为一个从事过科学研究类的脑力劳动工作的人,如果相信什么各种观点/事物都是各有所长,所以应该等量齐观/平等看待的观点,是可笑的,思想与境界就是有高下之分的,因为高观点可以包含低观点的内容,并解释说明其不能说明的内容

为什么会这样呢?

我们可以从世界名校斯坦福大学的成长历史来重新思考一下这个问题。

二战后,斯坦福大学为了合理使用多余的校园土地(并获得一定盈利),租出了大量的土地给高新产业公司使用,这就是后来大名鼎鼎的硅谷的雏形。

硅谷在大量人才、资本涌入后,几乎变成了全球高新技术的代名词;而斯坦福大学,也从那时一个建校才五十年左右的二流大学(当时哈佛、MIT 的获得的政府拨款都是上亿美元甚至十亿美元级别,斯坦福只有区区六百万美元拨款,还是用于培训教师的)一跃成为了如今世界上数一数二的顶尖大学。

为什么会这样?塔勒布在他的《反脆弱》(Anti-Fragile) 一书中提到:现代教育带来的最大的错觉就是,以为产业的发展是因为教育带动的,实际上恰恰相反,教育是由产业带动的。

1950年代,菲律宾的识字率是中国台湾的两倍,可从六十年代开始,(台湾也进行了所谓的十大建设)台湾的人均 GDP 以及各方面工业产业的发展,却远超过了菲律宾,这个优势一直持续到了今天(相反的,菲律宾大量的受教育人口,却带来了一个特殊产业—菲佣)。

科学研究或许不完全依照这个规律,但是工程技术的发展,几乎都是靠产业带动的。

举一个大家更熟悉的例子,华为在通信方面建树很大,它即使是给普通员工的薪资,在目前的就业环境中也是可观的。

因此当前在中国,电子及其相关的专业是一个很热门的专业,中国的通信技术也是世界领先的;

相比之下,生物学,生物虽然一度被吹嘘为所谓二十一世纪是生物的世纪,但是大量的中国生物毕业生,只能从事很低级的工作,薪资也很低,也便谈不上什么真正有意义的创新了。

同样的事情在美国则不然,生物信息学,由于有很多创业公司的投资,是美国名列前茅的高薪资工作专业之一,美国的生物技术发展,也的确走在世界前列。没有脱离产业的所谓先进科学。

无产业的所谓科学,往往不过是大量高影响因子的论文灌水,对国家甚至只是个人的发展,都是效益甚微的。

那么回到教育上,我们也可以类似地得出结论:没有脱离产业的所谓先进教学,一切不结合工程实际的教学目标与教学方法,基本上都是空谈,劳而无功,严重脱离实际,教学的实际效果也很差。

本文姑且不谈中国教育应该如何向这个方向改革,但是,个人在这样的教育环境下,应该树立怎样的发展自己的观点,还是值得探讨一下的。

刚刚来到大学时,我也遇到过各种各样很常见的问题:大学究竟该学点什么不同于高中的东西?我应该怎么适应大学这种较为松散的教学、工作环境?

另外当然就是顺便思考自己应该何去何从,也会经常在深夜辗转反侧,因为自己的无能为力而痛苦、愤怒,人最怕的不是辛苦,而是这种明知处于一种讨厌的状态,却无法摆脱的无能为力

对于第一个问题,在有了一些自学的经验之后,这时我也看了《智识分子》(作者万维刚,毕业于中科大,现为美国科罗拉多大学研究员)、《深度工作》(作者毕业于麻省理工,现为乔治城大学年轻的终身教授)等系统性总结学习工作方法的书籍,这才渐渐领悟了一个道理:

要想在各国现行高等教育下获得成功,或者更广泛一点地说,在当代越来越普遍的脑力劳动工作中取得成功,人必须得学会深度工作的能力(当然,也有人会把它通俗化说成所谓的学习能力)。

也就是说,学会针对特定的智力问题,摆脱外界打扰,刻苦钻研、自我学习分析的能力。这个能力是抽象的,但是也是具体的。

因为任何能每天专注工作思考自学很多个小时的人,对于大多数脑力劳动工作,都是可以很快掌握并熟练的,这就像一个大教授并不可能对一个新的研究方向感到完全束手无策一样,即使他可能以前从未了解过这个课题。

这种对自己学习研究能力的自信,我觉得就是高等教育或者说素质教育,理应培养的内容。

对于这个能力的培养,有一个小技巧:马克思说,商品的价值来自社会必要劳动时间,所谓必要时间,就是无差别的人类劳动;把这个观点推广到学习,或者更一般的脑力劳动上,就是要学会统计自己学习的有效劳动时间。大家应该都有过这样的经历:当看书或者做题很投入的时候,仿佛世界上只剩下了自己跟题目一样,全神贯注,用中国话说叫有点物我两忘,用现代的理论说,叫进入了心流的状态。在这种状态下的学习效率是很高的,可能一两个小时,抵得上漫无目的乱翻书几个小时甚至一天。因此,统计自己是否工作,就必须统计自己的有效的工作时间,一般看来,八到十个小时大约是普通人的极限了,再多下去基本上很难维持集中力(当然,根据每天的精神身体状态也会产生波动),所以如果做到比如说八个小时,就问心无愧地回去休息吧,一张一弛,文武之道,适当的休息才能保证后续工作的连续有效率。

当然了,对于我们做技术的人来说,光讲这些抽象的方法论还是不够的,如果具体到行为上,还是得落实到一些具体的技术上。

我的一个学弟,说起来还是非cs科班专业的,早早就意识到会一门硬技术的重要性。于是,他从大一时就开始混迹各大技术论坛,于是开始从Linux操作系统上手,从基础操作、到编写相关的脚本、再到阅读理解分析底层源码,对于Unix/Linux的理解很早就远超同龄人了。下面是他的故事

最初的故事还是从单片机开始的。

进入大学两个月,学院举办采访班导师的班级活动。班导师说大家学习完 C++ 课程后可以去学学单片机。我记在了心里。

大一第一个学期结束后的寒假,我凭借自己扎实的 C++ 基础学习了 C51 单片机,熟悉了使用 keil 软件的开发单片机的流程,了解了中断,定时,串口,I2C 总线,按键以及 LCD1602 液晶屏......由于了解到单片机也可以用汇编开发,所以顺道也学习了王爽老师编写的《汇编语言》, 甚至至今仍然记得书中的汇编程序do0

学习完后寒假还剩几天,我闲来无事,上网看看有没有什么和单片机相关的,可以继续学习。在随意翻看网页时我看到了树莓派,于是马上入手了。

从此,为了征服树莓派,我走上了 linux 学习的道路,期间不断学习 linux c 编程,python语言,bash脚本,mysql 数据库 ....... 期间做过很多好玩的开源项目,自娱自乐,也受到了一些已经工作了的技术人员的认同。

直到大二上,对编程的过度关注影响了我的学业。我也开始怀疑自己编程下去到底对不对,毕竟一开始我只是想做硬件而已。所以当时暂缓了编程的进一步学习,重新投入到自己的专业课中。大二下在学习数电,模电以及信号与系统的过程中,我接触到了verilog 硬件描述语言与 fpga,便开始了我的数字生涯。大三在不断学习物理以及数学的空闲时间里,我自学了数字信号处理以及 systemverilog 验证,同时也做过一些相关的项目。

尽管我的学业很顺利,自己凭借个人努力在大学中也学到了很多内容。

但我一直疑惑,我自己要的到底是什么,甚至经常熬夜思考。

直到有一晚上,我发现自己对探寻事物的本质有谜一般的兴趣。学习计算机语言,看 linux 内核的代码,学习数字逻辑以及物理学习都是我探寻事物本质的一些表现。所以我决定走上电子学和物理的道路。

当我亲眼看到粒子加速器的那一刻,我有种感觉——这就是我想要的

而且我觉得更有意思的一点就是,随着他几年的努力工作学习研究,他终于意识到自己的兴趣点其实在于研究更为底层的理论,目前已被某名校国家实验室录取,但是技术的成长不会背叛他,不仅在录取过程中让导师非常欣赏,并且在后续的研究工作中,也让他左右逢源,做什么事情都有自己的一套技术上的解决思路。即使不能直接用上,也能快速上手学习、应用一个技术达到一定程度,已经融入了他的血液中。

独立思考与批判性思维的缺失

其实,就技术谈技术,还是略显肤浅,毕竟授人以鱼不如授人以渔,实际上中国大学生普遍出现的通病就是做事被动:非要等老师、大佬说好才去做。此外,在社交方面,更是一个令人头疼的问题。很多擅长做技术的同学,对于搞人际关系,甚至是有点自傲的,觉得自己搞不好人际关系和是跟自己技术很强是互补一样。但是,作为一个优秀的人,成长应该是全面的,即使是为了做出更大的项目,寻找合适的人合作也是必不可少的,这点实在是很少有人能提及。

这是一种陋习,而这个陋习不得不说很大程度来自我们的教育体系甚至于文化:中学乃至于一定程度到了大学,管理者们总是以一种颇为军事的想法来思考如何管理学生,简单来说就是听话,一切要为了高考。是啊,考纲既然已经如此固定,在教学体系下保证最大程度的人能得到更多的分数,自然是很难容得下一些刺头的,因为他们或许有一些才能,但是会破坏正常的教学秩序。在这样的教育要求下,即使一些很叛逆的人,也渐渐磨平了棱角,至于本来就没什么想法的人,就更有些不敢越雷池一步了。

这种听话思维的延续,与一些诸如枪打出头鸟之类的文化结合,让人不要去乱想乱动。现在国家说我们需要专业人才,高考总体上却还是综合性的考试;我们需要创新性人才,可中学教育时往往又让学生不要违规,实在是有一点缘木求鱼的感觉。

美国著名认知学家侯世达在他的《表象与本质》中提出:类比与联想是人发展出新的思考的重要方法,伽利略发现木星的卫星,本质上不在于只有他第一次看见了卫星,而在于卫星这个词在英文中 (moon) 的原意特指地球的卫星月亮,如果不能突破这种当时会被教会认为是异端的思维的禁锢,这一伟大的科学发现就不可能出现。

所以,要习惯于创新,就要敢想敢做,有鲁迅先生所谓拿来主义的魄力,敢于对一切想法进行独立而科学的思考。

实际上,不仅是思考、做事,对于人际关系的发展,这点中国人大多要向美国人学习,当我第一天来到研究生学校时,我们第一天的课程居然是:教你如何使用领英,如何求职并进行 networking(我认为仅仅使用社交一词翻译似乎不太贴切,翻译为构筑自己的社会关系网似更准确)。正如面子之于中国人,social/networking 之于美国人也是一种家常便饭。他们教育人们要学会合理地结交有效的人脉,如果有志于相关方向的工作,就努力从亲戚、朋友、校友等等人处认识,完成一些原本只通过自己个人的想象不可能想到或者完成的事情。

这点国内也渐渐开始有了这样的氛围,尤其是在商业环境下。但是,还不够。甚至在文化上容易受到钻营之类的诟病。这点就又是一种对人与人性的合理发展的限制了。只要不违反规则与道德,人应该积极努力地为了改变自己的命运而努力,我觉得这是无可非议的。

对于还在高校内的学生也是,学校内往往也有科研训练、毕业设计或者纯粹地去加入一些知名教授的研究组从事一些科研或者工程工作的机会。如果能提前调查一下自己想研究的方向以及学校内教授们研究内容,大胆地写邮件或者用其他方式联系教授,也未必不能获得教授的指导和帮助,做出一些不错的工作来。或许在与教授的交往中,得到他的很多指导建议,从此又走上了一条更宽广的路,这种事情也并不鲜见。(小技巧,如果不清楚怎么找,也可以参考一下往年的优秀毕业设计名单等,往届能稳定带出成果的老师,往往较为擅长带学生)

当然,校外也有各种各样的项目、实习、工作等等机会,重要的还是走出自己的舒适区,给自己新的挑战,有时候多逼自己一把,也许事情就成了,人生也就打开了新的局面。

克服人性的劣根——人没有梦想,跟咸鱼有什么区别!

上一部分说了那么多制度上的问题,只是希望大家能跳出圈子来看问题,并不是为了给大家一个我xxxx都是社会的错的印象,实际上,真正的大佬,那都是些出淤泥而不染的人物,而要想成为一个技术大佬,也得努力克服自己身上存在的各种缺点,努力使自己成为一个更好的人,而不是因为找到了一些客观理由就当做救命稻草,为自己的无能作借口。

这个部分我们将着重于分析做好技术,个人的性格以及能力上重要的几个方面。

执行力的培养

执行力,在信息发达的当下,一定程度上已经变成了决定年轻人成绩的决定性因素了。因为很多人只是单纯去说xx技术好,我想学xx技术,然后学个两天,会打 hello world 就说自己学习过了,可是正如代码界名言 talk is cheap, show me the code 所表达的一样,水平没有真的提高,只是学会了几个术语,骗人可以,骗不了计算机的,总归真要用的时候是拿不出结果来的。

对于这一点,弥补的办法主要是坚持知行合一 的想法,就是永远要在实践中检查自己做的事情是不是真的配得上自己吹嘘的内容。王阳明在《传习录》中写道:人须在事上磨,方立得住,只有一次一次坚持把自己想做的事情落到实处,克服在这个过程中的厌恶、疲劳等等不利因素,才能在自控力上真正有点进步,能控制住自己的人,在有需要时,执行力自然高。

当然,这方面也有一些小技巧,比如说把困难的(准确的说是思维上最困难的,就是最厌恶最害怕做的麻烦事,不一定实际上做起来是最难的)事情最先做,这样,就像势如破竹一般,不会让自己陷入一直在简单工作的舒适区中徘徊的窘境,而复杂任务的进度也就能保证了。

当然,除了工作,适当地进行一些合理的享受,比如说欣赏音乐,或者参加各种体育活动,比起单纯地宅在家里,对身心意志健康的恢复,也自然会好很多。

这里需要重点提一句,体育锻炼除了增强体质、磨炼意志以外,我觉得对人的精神状态也有很积极的作用,对培养一种阳刚、向上的精神面貌也大有裨益。实际上,儒家的六艺里射(射箭)、御(骑马)都是体育运动;而在美国各大高校,健身房也几乎遍地都是,健身的文化非常浓郁。显然,经常参加体育锻炼不只是只有增强体质这一看似肤浅的效果的。(实际上,经常锻炼带来的体力进步,也有助于集中力的提高,很多人表示,看书可能一天最多只能集中六个小时以下,我个人在大学期间经常长跑,体质也好了很多,集中力也明显地上升到了后来能看到 8-10 小时)

执行力的提高,有一个心理误区是一定要克服的:同一个项目,他划水一点做,能得 85 分,我认真做,也就 88、90 分,有什么区别呢?无论什么样的进步,都是积少成多的,有时候分数虽然只是高一点,你对这个项目的理解的通透程度就是不一样的,或者你对这个技术能做到的一些边界条件、一些可能会在实际过程中出现的细节抓的很清楚,这样以后负责重大项目时,别人不行的东西,你行;别人做的不够完美的地方,你做的尽善尽美,这就是你的核心价值,一段升迁可能就源自于这么看似寻常的优秀:让这个人办事,稳重,放心,不需要太多的过问,省事,这不就是值得被拔擢的品质吗?

此外,正如史蒂芬·柯维先生在名著《高效能人士的七个习惯》所言,想提高自己的执行力,重要的是将自己的精力聚拢,多关心自己能改变的东西,把能量聚焦于可影响区,不要总是为了一些自己无力改变的事情去咸吃萝卜淡操心,一则是无用,只是乱想空发感慨,什么事也不会改变,一则是局外人、非专业人士对于相关的问题发表的见解,大多都是不准确甚至是完全错误的,如果总是把时间精力浪费在一些看似高尚实则跟自己无关的事情上,实际上反而影响了自己的不断进步以及本能期待的影响力、执行力的成长。

技术眼光的培养

这一点其实有点像做科研,对于一个技术领域,有条件的话,要搞清楚相关的技术的来龙去脉,有哪些技术路线,分为哪些派别,各个派别之间有什么优点和缺点,你更喜欢哪种,哪种更有可能继续发展下去?对于这些问题的回答,决定了你在相关的技术领域的深耕是否会在未来带来更大的价值。

当然,对于新入门或者还没有摸到门道的人来说,最快的方法还是想办法获取业界大佬的有针对性的观点。其实很多人都是,会花很多时间去寻找xx技术的 好老师 ,殊不知比起某个专门技术的老师更重要的,是寻找一个真正能在各方面教育你的导师,这样的人,不仅拥有对于技术的品味,更重要的是,他们往往对于做事、认识这个产业乃至于世界都是自己清晰完整的三观,这些方面的学习才是更潜移默化的成长,从这样的大佬学习经验、思考、工作的方法,从长远来看,才更有可能产生更加脱胎换骨的变化,而不仅仅是学到某一特定技术。

当然,做技术跟搞科研,甚至搞艺术是没有本质区别的,都是对未知、对真理的一种探索,永远都应该保持对于技术的热爱与敬畏。因为热爱,所以永远充满了学习的热情;因为敬畏,所以永远不敢以自己知道的小小一隅而自满自得,觉得自己不需要再学习了,殊不知技术的迭代与进步,实在是快到短短几年之间,就有可能发生整个观念上的改变的情况,如果不能 stay hungry, stay foolish ,则实在是很容易被淘汰掉。

就个人的进步而言,技术能力的培养可以来自两个方面,一则是在工作岗位上努力解决技术上的瓶颈、难题,另一则是在高校中通过接受正规的科研培训,提高自己分析、解决问题的能力。

先谈工作岗位,我看过一些技术大佬回忆自己的成长故事,除了极少数天才,大多也并不是一开始就天赋异禀或者说仅仅看看书、学学理论知识就超越常人的。但是他们回忆起自己成长的经历时,大多都是从回忆公司的项目开始的,比如说之前看一个腾讯大佬的回忆,就是进厂时被要求用纯 C 语言写一个能运行的并发服务器,其中不允许直接使用已经写好的框架,必须一点点自己查底层的内置标准库;后来是解决一个更大的服务器配置、优化的升级,于是他仔细阅读分析过去的源码,在看过去的大佬代码中学会了很多设计思想,然后实现了这个项目……在完成这些项目之前,其实他的水平也就跟一般人差不多,即使是完成之后,在某些细节知识体系等等上,他可能依旧是不如很多人的,但是这就是实践的魅力了,实践会逼着你删繁就简、直击要害,其实很多人还是有太多的学生思维,觉得很多事情不学个通透就不想、不敢着手去做,其实很多时候,有个大概理解,针对一个具体问题不断去调查、钻研、学习,才是更本质的、更迅速的能力,而且会逼着你把看似不相关的知识融会贯通,否则也就不能形成问题的体系了。

再讲讲正规的科研培训,实话说,中国除了少数名校或者知名研究组,大多在科研培训这块是很不如美国高校的。当然,这可能也与国内很多科研还是盲目跟风、关注的更多的是国际上 SCI 发表的情况有关。而在美国,很多论文的发表,需求往往出自一些公司,所以研究的结果经常能有直接的应用和衡量标准,从而较不容易陷入自嗨的境地,只是自己做一些理论结果。

而落实到个人,虽然在学校中做的内容,如果不是读博士、专业从事相关内容,大多数人实际上毕业后很难直接做到与自己的研究直接相关的工作。但是,这是不是就是说,认真研究就不重要呢?我觉得也不是,科研本质上是培养一种符合科学范式的工作、研究方法论。比如说,初到一个新的领域,如何调查、分析这个领域的发展情况,研究兴趣,是否有进一步研究的价值;对于一个问题,如何寻找不同的研究切入方法;对于一些方法,如何合理地进行批评与改进,等等等等。这些都不是一朝一夕就能学会的,都得在长期的工作中反复打磨,才能越来越提高自己的思考、研究能力。

其实,以代码为例,我认为计算机科学的进步很少有什么特别巨大的跃进,真正核心的理念其实也就那么多:比如说操作系统、算法等等,框架虽然会不断地在之前的基础上进行改进,但是创新不是一拍脑袋就能出现的,它总还得符合基本的原理,比如说谷歌的著名分布式框架 MapReduce,原理上还是用了一个分治法的思想。在学习的过程中,不满足于知识本身,更多地探究其为什么能想到、还能在其他地方怎么灵活运用,你也可以成为下一个化腐朽为神奇的创新者。

内驱力的培养

上面讲了很多热情相关的内容,这种热情其实就是所谓的内驱力,就是由人的思考、信念产生的驱使人不断克服厌倦、疲劳去克服一件件困难的精神。内驱力不足,归根结底是自己缺乏深厚的文化或者情感积淀而成的信念。

不过,人的意志力也的确是有限的,指望人能一直靠某个信念或者想法驱动自己,也实在是很难的事情,对于这个问题,主要的弥补办法是:读好书以及交益友,好的书籍往往都是各方面建树很大的大佬写出的充沛了各种信念与思考的产物,益友一般来说就是那些理想信念坚定、能力很强的人,人说起来是复杂的动物,但是有时也并不那么复杂,比如说人总归会下意识地模仿身边人的行为,因此如果朋友们都努力工作、积极向上,一个人也实在很难长时间保持很颓废的状态,这就是益友带来的好处了。

在这里,我想讲一个我个人认为很正确的信念,这也是无数中国古籍、无数仁人志士们都信奉的观点君子不器

所谓的,往往被解读为出人头地,因此家长们对孩子的期望都是成大器,本身自然没什么大问题。而且,如果不能成器,人往往在社会上不能得意,甚至连亲朋故旧的尊重往往都得不到。但是其实器也可以不这样理解,器的意思是不被一切世俗的规则与思想所奴役,获得不受约束的自由。(这与无数程序员羡慕的财务自由梦想在一定程度上是同义词)

自由永远是一把双刃剑,人不受各方面的约束,就容易堕落,容易为物所役,贪腐挥霍,整个人就堕落下去了。所以,虽然不器不是无器(不成器),但是如果沉迷于器,即使成了大器,之后也必然堕落为小人,心中永远要有对真理、对正义、对社会公正的那种良知,对还在遭受苦难的不幸的人们的怜悯,得到了要施于人,学到了要教给人,这样的人,才能得到人们真正的尊重,也才真的实现了人的自我实现。

其实,只有心中有这种不能割舍的热爱的人,才能不以物喜,不以己悲,在一次次失败与低谷中,不以个人的得失为意,坚持自己的信念,修身以俟,夭寿不二,达到那个理想的彼岸。

人的发展本质上是非线性的,这种让人无法预测的伟大进步,才是人类与人性真正伟大而令人感动的地方,也是教育真正可贵的地方。

即使是一个很无知很粗鄙的人(实际上,大家在很小的时候不都是这样的吗),如果寻到正道,努力修养锻炼自己数年,完全有可能变成一个有文化有思想有能力的充分发展的人才。

我觉得一切理想主义者的本质就是相信人类与人性的可能性,相信即使在无常的命运与世界中,人作为整体,总有一些个体,能与其不确定性抗争,并成长到可以面对命运、认清命运、抗击命运,而作为共同承认的一些信念,也就在这些人身上得到了传承,这也是教育最可贵的地方

虽然谁也不能说种下的种子一定会发芽成大树,但是谁又能说种下的种子就一定不可能发芽成参天大树呢?

人真的是一种一加一加一就能等于一百的动物,对自己、对身边的人都充满这样的信念与希望吧,人生的美好、人性的伟大,不也正在于此吗?

结语

本文字数虽长,但是都是长期思考的结果,不过鉴于很难一次性读完全部的要点,这里再做一次简要总结:

  1. 阅读书籍是与伟人们对话的过程,也是理解他们如何认识世界、改造世界的过程。从书籍中可以汲取他们的思想,领悟他们的品格,找到自己的定位,并指导自己的行为。
  2. 学会自省,构建自己的信仰价值体系,为自己真正需要坚守的价值燃烧自己的生命
  3. 机会总会到来,但是更重要的是在日常生活中关注细小的事情,不要老是做低快感阈值的事情,要多做一些具有长期价值的要事,克服行为成瘾
  4. 学会真正的深度工作的方法,提高自己工作的效率与产出
  5. *独立思考、独立思考、独立思考!再怎么强调它的重要性也不为过
  6. 走出舒适区,与更大的世界建立联系
  7. 人须在事上磨,方立得住
  8. 积极投身实践,在实践中抓住做事情的要害
  9. 君子不器,先成器再不器
  10. 相信人类与人性的可能性,发自真心关爱别人的成长

如果没有读出上述这些观点的,可以返回前面,再看一看,思考思考。

两年前,我偶然读到陈先发先生的《不做空心人》这篇演讲稿,文中的那种慷慨激昂的意气让我非常感动,读到之后,我甚至专门找了个本子,提笔把这篇文章工工整整抄了一遍。这里,我也想摘录几段我非常喜欢的句子(建议自行搜索全文):

越是在众声喧哗中,越需要一颗真正安静下来的心。越是快速变化的时代,越需要一颗真正慢下来的心。

世界上所有的美,都需要一种高度的专注和漫长时间的淬火。读书、求知,当然更不例外。有过乡村生活经验的人知道,长得过快的树,是空心的,其材质不堪大用。

在时代的屏幕上瞬间自生自灭的文字,越是浅陋粗鄙,就越需要有人能以坐得十年冷板凳的勇气,舍弃眼前之利、萤火之光,创造出能昭示一个时代良心和品质的精神产品,穿透这个时代流传下去。

越是有人觉得心里空荡荡的,就越需要另一群人懂得,应该往这种空荡荡中填补些什么

越是有人不再确信什么,觉得爱、理想、信仰成了过时的、陈旧而虚张的概念,就越是需要另一群人把这些词,高高地举在头顶。对内心贫乏的人来说,这些词是空的。而对内心丰富的人来说,它们永远是有血有肉的,是新鲜的。

要把求知、求学上的怀疑精神,与对生命价值的确认和信仰的形成融为一体。把对科学的冷眼观察、冷静探索,与做人做事的古道热肠融为一体。把探求真理的坚韧不拔,与生活中怜小怜弱、恤残恤孤的生命柔情融为一体。只有这样一颗有所爱的心,才不会空掉。我们才不致沦为一个空心人

世界上所有的美,都需要一种高度的专注和漫长时间的淬火。读书、求知,当然更不例外。有过乡村生活经验的人知道,长得过快的树,是空心的,其材质不堪大用。

在时代的屏幕上瞬间自生自灭的文字,越是浅陋粗鄙,就越需要有人能以“坐得十年冷板凳”的勇气,舍弃眼前之利、萤火之光,创造出能昭示一个时代良心和品质的精神产品,穿透这个时代流传下去。

越是有人觉得心里空荡荡的,就越需要另一群人懂得,应该往这种空荡荡中填补些什么

越是有人不再确信什么,觉得“爱”、“理想”、“信仰”成了过时的、陈旧而虚张的概念,就越是需要另一群人把这些词,高高地举在头顶。对内心贫乏的人来说,这些词是空的。而对内心丰富的人来说,它们永远是有血有肉的,是新鲜的。

要把求知、求学上的怀疑精神,与对生命价值的确认和信仰的形成融为一体。把对科学的冷眼观察、冷静探索,与做人做事的古道热肠融为一体。把探求真理的坚韧不拔,与生活中怜小怜弱、恤残恤孤的生命柔情融为一体。只有这样一颗有所爱的心,才不会空掉。我们才不致沦为一个空心人

谁的青春不迷茫呢?迷茫大概是无可避免的情况,但是就像鲁迅所说不满足是向上的车轮,重要的是不抛弃、不放弃,在逆境时的坚守,就像《周易》中所谓潜龙勿用,假以时日,也总有飞龙在天的一天。

借用一句毛主席的诗词风物长宜放眼量,事情总会慢慢起变化的,朋友们,勉之!

如有不同见解,欢迎戳下面二维码进行交流。

查看原文

赞 27 收藏 14 评论 5

程序员cxuan 发布了文章 · 2月18日

一文带你怼明白进程和线程通信原理

进程间通信

进程是需要频繁的和其他进程进行交流的。例如,在一个 shell 管道中,第一个进程的输出必须传递给第二个进程,这样沿着管道进行下去。因此,进程之间如果需要通信的话,必须要使用一种良好的数据结构以至于不能被中断。下面我们会一起讨论有关 进程间通信(Inter Process Communication, IPC) 的问题。

关于进程间的通信,这里有三个问题

  • 上面提到了第一个问题,那就是一个进程如何传递消息给其他进程。
  • 第二个问题是如何确保两个或多个线程之间不会相互干扰。例如,两个航空公司都试图为不同的顾客抢购飞机上的最后一个座位。
  • 第三个问题是数据的先后顺序的问题,如果进程 A 产生数据并且进程 B 打印数据。则进程 B 打印数据之前需要先等 A 产生数据后才能够进行打印。

需要注意的是,这三个问题中的后面两个问题同样也适用于线程

第一个问题在线程间比较好解决,因为它们共享一个地址空间,它们具有相同的运行时环境,可以想象你在用高级语言编写多线程代码的过程中,线程通信问题是不是比较容易解决?

另外两个问题也同样适用于线程,同样的问题可用同样的方法来解决。我们后面会慢慢讨论这三个问题,你现在脑子中大致有个印象即可。

竟态条件

在一些操作系统中,协作的进程可能共享一些彼此都能读写的公共资源。公共资源可能在内存中也可能在一个共享文件。为了讲清楚进程间是如何通信的,这里我们举一个例子:一个后台打印程序。当一个进程需要打印某个文件时,它会将文件名放在一个特殊的后台目录(spooler directory)中。另一个进程 打印后台进程(printer daemon) 会定期的检查是否需要文件被打印,如果有的话,就打印并将该文件名从目录下删除。

假设我们的后台目录有非常多的 槽位(slot),编号依次为 0,1,2,...,每个槽位存放一个文件名。同时假设有两个共享变量:out,指向下一个需要打印的文件;in,指向目录中下个空闲的槽位。可以把这两个文件保存在一个所有进程都能访问的文件中,该文件的长度为两个字。在某一时刻,0 至 3 号槽位空,4 号至 6 号槽位被占用。在同一时刻,进程 A 和 进程 B 都决定将一个文件排队打印,情况如下

image.png

墨菲法则(Murphy) 中说过,任何可能出错的地方终将出错,这句话生效时,可能发生如下情况。

进程 A 读到 in 的值为 7,将 7 存在一个局部变量 next_free_slot 中。此时发生一次时钟中断,CPU 认为进程 A 已经运行了足够长的时间,决定切换到进程 B 。进程 B 也读取 in 的值,发现是 7,然后进程 B 将 7 写入到自己的局部变量 next_free_slot 中,在这一时刻两个进程都认为下一个可用槽位是 7 。

进程 B 现在继续运行,它会将打印文件名写入到 slot 7 中,然后把 in 的指针更改为 8 ,然后进程 B 离开去做其他的事情

现在进程 A 开始恢复运行,由于进程 A 通过检查 next_free_slot也发现 slot 7 的槽位是空的,于是将打印文件名存入 slot 7 中,然后把 in 的值更新为 8 ,由于 slot 7 这个槽位中已经有进程 B 写入的值,所以进程 A 的打印文件名会把进程 B 的文件覆盖,由于打印机内部是无法发现是哪个进程更新的,它的功能比较局限,所以这时候进程 B 永远无法打印输出,类似这种情况,即两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件(race condition)。调试竞态条件是一种非常困难的工作,因为绝大多数情况下程序运行良好,但在极少数的情况下会发生一些无法解释的奇怪现象。不幸的是,多核增长带来的这种问题使得竞态条件越来越普遍。

临界区

不仅共享资源会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢?或许一句话可以概括说明:禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。换句话说,我们需要一种 互斥(mutual exclusion) 条件,这也就是说,如果一个进程在某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)。上面问题的纠结点在于,在进程 A 对共享变量的使用未结束之前进程 B 就使用它。在任何操作系统中,为了实现互斥操作而选用适当的原语是一个主要的设计问题,接下来我们会着重探讨一下。

避免竞争问题的条件可以用一种抽象的方式去描述。大部分时间,进程都会忙于内部计算和其他不会导致竞争条件的计算。然而,有时候进程会访问共享内存或文件,或者做一些能够导致竞态条件的操作。我们把对共享内存进行访问的程序片段称作 临界区域(critical region)临界区(critical section)。如果我们能够正确的操作,使两个不同进程不可能同时处于临界区,就能避免竞争条件,这也是从操作系统设计角度来进行的。

尽管上面这种设计避免了竞争条件,但是不能确保并发线程同时访问共享数据的正确性和高效性。一个好的解决方案,应该包含下面四种条件

  1. 任何时候两个进程不能同时处于临界区
  2. 不应对 CPU 的速度和数量做任何假设
  3. 位于临界区外的进程不得阻塞其他进程
  4. 不能使任何进程无限等待进入临界区

image.png

从抽象的角度来看,我们通常希望进程的行为如上图所示,在 t1 时刻,进程 A 进入临界区,在 t2 的时刻,进程 B 尝试进入临界区,因为此时进程 A 正在处于临界区中,所以进程 B 会阻塞直到 t3 时刻进程 A 离开临界区,此时进程 B 能够允许进入临界区。最后,在 t4 时刻,进程 B 离开临界区,系统恢复到没有进程的原始状态。

忙等互斥

下面我们会继续探讨实现互斥的各种设计,在这些方案中,当一个进程正忙于更新其关键区域的共享内存时,没有其他进程会进入其关键区域,也不会造成影响。

屏蔽中断

在单处理器系统上,最简单的解决方案是让每个进程在进入临界区后立即屏蔽所有中断,并在离开临界区之前重新启用它们。屏蔽中断后,时钟中断也会被屏蔽。CPU 只有发生时钟中断或其他中断时才会进行进程切换。这样,在屏蔽中断后 CPU 不会切换到其他进程。所以,一旦某个进程屏蔽中断之后,它就可以检查和修改共享内存,而不用担心其他进程介入访问共享数据。

这个方案可行吗?进程进入临界区域是由谁决定的呢?不是用户进程吗?当进程进入临界区域后,用户进程关闭中断,如果经过一段较长时间后进程没有离开,那么中断不就一直启用不了,结果会如何?可能会造成整个系统的终止。而且如果是多处理器的话,屏蔽中断仅仅对执行 disable 指令的 CPU 有效。其他 CPU 仍将继续运行,并可以访问共享内存。

另一方面,对内核来说,当它在执行更新变量或列表的几条指令期间将中断屏蔽是很方便的。例如,如果多个进程处理就绪列表中的时候发生中断,则可能会发生竞态条件的出现。所以,屏蔽中断对于操作系统本身来说是一项很有用的技术,但是对于用户线程来说,屏蔽中断却不是一项通用的互斥机制。

锁变量

作为第二种尝试,可以寻找一种软件层面解决方案。考虑有单个共享的(锁)变量,初始为值为 0 。当一个线程想要进入关键区域时,它首先会查看锁的值是否为 0 ,如果锁的值是 0 ,进程会把它设置为 1 并让进程进入关键区域。如果锁的状态是 1,进程会等待直到锁变量的值变为 0 。因此,锁变量的值是 0 则意味着没有线程进入关键区域。如果是 1 则意味着有进程在关键区域内。我们对上图修改后,如下所示

image.png

这种设计方式是否正确呢?是否存在纰漏呢?假设一个进程读出锁变量的值并发现它为 0 ,而恰好在它将其设置为 1 之前,另一个进程调度运行,读出锁的变量为0 ,并将锁的变量设置为 1 。然后第一个线程运行,把锁变量的值再次设置为 1,此时,临界区域就会有两个进程在同时运行。

image.png

也许有的读者可以这么认为,在进入前检查一次,在要离开的关键区域再检查一次不就解决了吗?实际上这种情况也是于事无补,因为在第二次检查期间其他线程仍有可能修改锁变量的值,换句话说,这种 set-before-check 不是一种 原子性 操作,所以同样还会发生竞争条件。

严格轮询法

第三种互斥的方式先抛出来一段代码,这里的程序是用 C 语言编写,之所以采用 C 是因为操作系统普遍是用 C 来编写的(偶尔会用 C++),而基本不会使用 Java 、Modula3 或 Pascal 这样的语言,Java 中的 native 关键字底层也是 C 或 C++ 编写的源码。对于编写操作系统而言,需要使用 C 语言这种强大、高效、可预知和有特性的语言,而对于 Java ,它是不可预知的,因为它在关键时刻会用完存储器,而在不合适的时候会调用垃圾回收机制回收内存。在 C 语言中,这种情况不会发生,C 语言中不会主动调用垃圾回收回收内存。有关 C 、C++ 、Java 和其他四种语言的比较可以参考 链接

进程 0 的代码

while(TRUE){
  while(turn != 0){
    /* 进入关键区域 */
    critical_region();
    turn = 1;
    /* 离开关键区域 */
    noncritical_region();
  }
}

进程 1 的代码

while(TRUE){
  while(turn != 1){
    critical_region();
    turn = 0;
    noncritical_region();
  }
}

在上面代码中,变量 turn,初始值为 0 ,用于记录轮到那个进程进入临界区,并检查或更新共享内存。开始时,进程 0 检查 turn,发现其值为 0 ,于是进入临界区。进程 1 也发现其值为 0 ,所以在一个等待循环中不停的测试 turn,看其值何时变为 1。连续检查一个变量直到某个值出现为止,这种方法称为 忙等待(busywaiting)。由于这种方式浪费 CPU 时间,所以这种方式通常应该要避免。只有在有理由认为等待时间是非常短的情况下,才能够使用忙等待。用于忙等待的锁,称为 自旋锁(spinlock)

进程 0 离开临界区时,它将 turn 的值设置为 1,以便允许进程 1 进入其临界区。假设进程 1 很快便离开了临界区,则此时两个进程都处于临界区之外,turn 的值又被设置为 0 。现在进程 0 很快就执行完了整个循环,它退出临界区,并将 turn 的值设置为 1。此时,turn 的值为 1,两个进程都在其临界区外执行。

突然,进程 0 结束了非临界区的操作并返回到循环的开始。但是,这时它不能进入临界区,因为 turn 的当前值为 1,此时进程 1 还忙于非临界区的操作,进程 0 只能继续 while 循环,直到进程 1 把 turn 的值改为 0 。这说明,在一个进程比另一个进程执行速度慢了很多的情况下,轮流进入临界区并不是一个好的方法。

这种情况违反了前面的叙述 3 ,即 位于临界区外的进程不得阻塞其他进程,进程 0 被一个临界区外的进程阻塞。由于违反了第三条,所以也不能作为一个好的方案。

Peterson 解法

荷兰数学家 T.Dekker 通过将锁变量与警告变量相结合,最早提出了一个不需要严格轮换的软件互斥算法,关于 Dekker 的算法,参考 链接

后来, G.L.Peterson 发现了一种简单很多的互斥算法,它的算法如下

#define FALSE 0
#define TRUE  1
#define N     2                                /* 进程数量 */

int turn;                                        /* 现在轮到谁 */
int interested[N];                                /* 所有值初始化为 0 (FALSE) */

void enter_region(int process){                    /* 进程是 0 或 1 */
  
  int other;                                    /* 另一个进程号 */
  
  other = 1 - process;                            /* 另一个进程 */
  interested[process] = TRUE;                    /* 表示愿意进入临界区 */
  turn = process;
  while(turn == process 
        && interested[other] == true){}                          /* 空循环 */
  
}

void leave_region(int process){
  
  interested[process] == FALSE;                 /* 表示离开临界区 */
}

在使用共享变量时(即进入其临界区)之前,各个进程使用各自的进程号 0 或 1 作为参数来调用 enter_region,这个函数调用在需要时将使进程等待,直到能够安全的临界区。在完成对共享变量的操作之后,进程将调用 leave_region 表示操作完成,并且允许其他进程进入。

现在来看看这个办法是如何工作的。一开始,没有任何进程处于临界区中,现在进程 0 调用 enter_region。它通过设置数组元素和将 turn 置为 0 来表示它希望进入临界区。由于进程 1 并不想进入临界区,所以 enter_region 很快便返回。如果进程现在调用 enter_region,进程 1 将在此处挂起直到 interested[0] 变为 FALSE,这种情况只有在进程 0 调用 leave_region 退出临界区时才会发生。

那么上面讨论的是顺序进入的情况,现在来考虑一种两个进程同时调用 enter_region 的情况。它们都将自己的进程存入 turn,但只有最后保存进去的进程号才有效,前一个进程的进程号因为重写而丢失。假如进程 1 是最后存入的,则 turn 为 1 。当两个进程都运行到 while 的时候,进程 0 将不会循环并进入临界区,而进程 1 将会无限循环且不会进入临界区,直到进程 0 退出位置。

TSL 指令

现在来看一种需要硬件帮助的方案。一些计算机,特别是那些设计为多处理器的计算机,都会有下面这条指令

TSL RX,LOCK    

称为 测试并加锁(test and set lock),它将一个内存字 lock 读到寄存器 RX 中,然后在该内存地址上存储一个非零值。读写指令能保证是一体的,不可分割的,一同执行的。在这个指令结束之前其他处理器均不允许访问内存。执行 TSL 指令的 CPU 将会锁住内存总线,用来禁止其他 CPU 在这个指令结束之前访问内存。

很重要的一点是锁住内存总线和禁用中断不一样。禁用中断并不能保证一个处理器在读写操作之间另一个处理器对内存的读写。也就是说,在处理器 1 上屏蔽中断对处理器 2 没有影响。让处理器 2 远离内存直到处理器 1 完成读写的最好的方式就是锁住总线。这需要一个特殊的硬件(基本上,一根总线就可以确保总线由锁住它的处理器使用,而其他的处理器不能使用)

为了使用 TSL 指令,要使用一个共享变量 lock 来协调对共享内存的访问。当 lock 为 0 时,任何进程都可以使用 TSL 指令将其设置为 1,并读写共享内存。当操作结束时,进程使用 move 指令将 lock 的值重新设置为 0 。

这条指令如何防止两个进程同时进入临界区呢?下面是解决方案

enter_region:
        TSL REGISTER,LOCK                      | 复制锁到寄存器并将锁设为1
          CMP REGISTER,#0              | 锁是 0 吗?
          JNE enter_region                  | 若不是零,说明锁已被设置,所以循环
          RET                              | 返回调用者,进入临界区
    
    
leave_region:
            MOVE LOCK,#0              | 在锁中存入 0 
          RET                              | 返回调用者

我们可以看到这个解决方案的思想和 Peterson 的思想很相似。假设存在如下共 4 指令的汇编语言程序。第一条指令将 lock 原来的值复制到寄存器中并将 lock 设置为 1 ,随后这个原来的值和 0 做对比。如果它不是零,说明之前已经被加过锁,则程序返回到开始并再次测试。经过一段时间后(可长可短),该值变为 0 (当前处于临界区中的进程退出临界区时),于是过程返回,此时已加锁。要清除这个锁也比较简单,程序只需要将 0 存入 lock 即可,不需要特殊的同步指令。

现在有了一种很明确的做法,那就是进程在进入临界区之前会先调用 enter_region,判断是否进行循环,如果lock 的值是 1 ,进行无限循环,如果 lock 是 0,不进入循环并进入临界区。在进程从临界区返回时它调用 leave_region,这会把 lock 设置为 0 。与基于临界区问题的所有解法一样,进程必须在正确的时间调用 enter_region 和 leave_region ,解法才能奏效。

还有一个可以替换 TSL 的指令是 XCHG,它原子性的交换了两个位置的内容,例如,一个寄存器与一个内存字,代码如下

enter_region:
        MOVE REGISTER,#1                            | 把 1 放在内存器中
        XCHG REGISTER,LOCK                        | 交换寄存器和锁变量的内容
        CMP REGISTER,#0                            | 锁是 0 吗?
        JNE enter_region                                    | 若不是 0 ,锁已被设置,进行循环
        RET                                            | 返回调用者,进入临界区
    
leave_region:                                        
        MOVE LOCK,#0                                | 在锁中存入 0 
        RET                                            | 返回调用者

XCHG 的本质上与 TSL 的解决办法一样。所有的 Intel x86 CPU 在底层同步中使用 XCHG 指令。

睡眠与唤醒

上面解法中的 Peterson 、TSL 和 XCHG 解法都是正确的,但是它们都有忙等待的缺点。这些解法的本质上都是一样的,先检查是否能够进入临界区,若不允许,则该进程将原地等待,直到允许为止。

这种方式不但浪费了 CPU 时间,而且还可能引起意想不到的结果。考虑一台计算机上有两个进程,这两个进程具有不同的优先级,H 是属于优先级比较高的进程,L 是属于优先级比较低的进程。进程调度的规则是不论何时只要 H 进程处于就绪态 H 就开始运行。在某一时刻,L 处于临界区中,此时 H 变为就绪态,准备运行(例如,一条 I/O 操作结束)。现在 H 要开始忙等,但由于当 H 就绪时 L 就不会被调度,L 从来不会有机会离开关键区域,所以 H 会变成死循环,有时将这种情况称为优先级反转问题(priority inversion problem)

现在让我们看一下进程间的通信原语,这些原语在不允许它们进入关键区域之前会阻塞而不是浪费 CPU 时间,最简单的是 sleepwakeup。Sleep 是一个能够造成调用者阻塞的系统调用,也就是说,这个系统调用会暂停直到其他进程唤醒它。wakeup 调用有一个参数,即要唤醒的进程。还有一种方式是 wakeup 和 sleep 都有一个参数,即 sleep 和 wakeup 需要匹配的内存地址。

生产者-消费者问题

作为这些私有原语的例子,让我们考虑生产者-消费者(producer-consumer) 问题,也称作 有界缓冲区(bounded-buffer) 问题。两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者(producer),将信息放入缓冲区, 另一个是消费者(consumer) ,会从缓冲区中取出。也可以把这个问题一般化为 m 个生产者和 n 个消费者的问题,但是我们这里只讨论一个生产者和一个消费者的情况,这样可以简化实现方案。

如果缓冲队列已满,那么当生产者仍想要将数据写入缓冲区的时候,会出现问题。它的解决办法是让生产者睡眠,也就是阻塞生产者。等到消费者从缓冲区中取出一个或多个数据项时再唤醒它。同样的,当消费者试图从缓冲区中取数据,但是发现缓冲区为空时,消费者也会睡眠,阻塞。直到生产者向其中放入一个新的数据。

这个逻辑听起来比较简单,而且这种方式也需要一种称作 监听 的变量,这个变量用于监视缓冲区的数据,我们暂定为 count,如果缓冲区最多存放 N 个数据项,生产者会每次判断 count 是否达到 N,否则生产者向缓冲区放入一个数据项并增量 count 的值。消费者的逻辑也很相似:首先测试 count 的值是否为 0 ,如果为 0 则消费者睡眠、阻塞,否则会从缓冲区取出数据并使 count 数量递减。每个进程也会检查检查是否其他线程是否应该被唤醒,如果应该被唤醒,那么就唤醒该线程。下面是生产者消费者的代码

#define N 100                                        /* 缓冲区 slot 槽的数量 */
int count = 0                                                /* 缓冲区数据的数量 */
  
// 生产者
void producer(void){
  int item;
  
  while(TRUE){                                             /* 无限循环 */
    item = produce_item()                                                /* 生成下一项数据 */
    if(count == N){
      sleep();                                                    /* 如果缓存区是满的,就会阻塞 */
    }
    
    insert_item(item);                                                    /* 把当前数据放在缓冲区中 */
    count = count + 1;                                                    /* 增加缓冲区 count 的数量 */
    if(count == 1){
      wakeup(consumer);                                            /* 缓冲区是否为空? */
    }
  }
}

// 消费者
void consumer(void){
  
  int item;
  
  while(TRUE){                                            /* 无限循环 */
      if(count == 0){                                            /* 如果缓冲区是空的,就会进行阻塞 */
      sleep();
    }
       item = remove_item();                                                /* 从缓冲区中取出一个数据 */
    count = count - 1                                               /* 将缓冲区的 count 数量减一 */
    if(count == N - 1){                                                   /* 缓冲区满嘛? */
      wakeup(producer);        
    }
    consumer_item(item);                                                /* 打印数据项 */
  }
  
}

为了在 C 语言中描述像是 sleepwakeup 的系统调用,我们将以库函数调用的形式来表示。它们不是 C 标准库的一部分,但可以在实际具有这些系统调用的任何系统上使用。代码中未实现的 insert_itemremove_item 用来记录将数据项放入缓冲区和从缓冲区取出数据等。

现在让我们回到生产者-消费者问题上来,上面代码中会产生竞争条件,因为 count 这个变量是暴露在大众视野下的。有可能出现下面这种情况:缓冲区为空,此时消费者刚好读取 count 的值发现它为 0 。此时调度程序决定暂停消费者并启动运行生产者。生产者生产了一条数据并把它放在缓冲区中,然后增加 count 的值,并注意到它的值是 1 。由于 count 为 0,消费者必须处于睡眠状态,因此生产者调用 wakeup 来唤醒消费者。但是,消费者此时在逻辑上并没有睡眠,所以 wakeup 信号会丢失。当消费者下次启动后,它会查看之前读取的 count 值,发现它的值是 0 ,然后在此进行睡眠。不久之后生产者会填满整个缓冲区,在这之后会阻塞,这样一来两个进程将永远睡眠下去。

引起上面问题的本质是 唤醒尚未进行睡眠状态的进程会导致唤醒丢失。如果它没有丢失,则一切都很正常。一种快速解决上面问题的方式是增加一个唤醒等待位(wakeup waiting bit)。当一个 wakeup 信号发送给仍在清醒的进程后,该位置为 1 。之后,当进程尝试睡眠的时候,如果唤醒等待位为 1 ,则该位清除,而进程仍然保持清醒。

然而,当进程数量有许多的时候,这时你可以说通过增加唤醒等待位的数量来唤醒等待位,于是就有了 2、4、6、8 个唤醒等待位,但是并没有从根本上解决问题。

信号量

信号量是 E.W.Dijkstra 在 1965 年提出的一种方法,它使用一个整形变量来累计唤醒次数,以供之后使用。在他的观点中,有一个新的变量类型称作 信号量(semaphore)。一个信号量的取值可以是 0 ,或任意正数。0 表示的是不需要任何唤醒,任意的正数表示的就是唤醒次数。

Dijkstra 提出了信号量有两个操作,现在通常使用 downup(分别可以用 sleep 和 wakeup 来表示)。down 这个指令的操作会检查值是否大于 0 。如果大于 0 ,则将其值减 1 ;若该值为 0 ,则进程将睡眠,而且此时 down 操作将会继续执行。检查数值、修改变量值以及可能发生的睡眠操作均为一个单一的、不可分割的 原子操作(atomic action) 完成。这会保证一旦信号量操作开始,没有其他的进程能够访问信号量,直到操作完成或者阻塞。这种原子性对于解决同步问题和避免竞争绝对必不可少。

原子性操作指的是在计算机科学的许多其他领域中,一组相关操作全部执行而没有中断或根本不执行。

up 操作会使信号量的值 + 1。如果一个或者多个进程在信号量上睡眠,无法完成一个先前的 down 操作,则由系统选择其中一个并允许该程完成 down 操作。因此,对一个进程在其上睡眠的信号量执行一次 up 操作之后,该信号量的值仍然是 0 ,但在其上睡眠的进程却少了一个。信号量的值增 1 和唤醒一个进程同样也是不可分割的。不会有某个进程因执行 up 而阻塞,正如在前面的模型中不会有进程因执行 wakeup 而阻塞是一样的道理。

用信号量解决生产者 - 消费者问题

用信号量解决丢失的 wakeup 问题,代码如下

#define N 100                                            /* 定义缓冲区槽的数量 */
typedef int semaphore;                                        /* 信号量是一种特殊的 int */
semaphore mutex = 1;                                        /* 控制关键区域的访问 */
semaphore empty = N;                                        /* 统计 buffer 空槽的数量 */
semaphore full = 0;                                        /* 统计 buffer 满槽的数量 */

void producer(void){ 
  
  int item;  
  
  while(TRUE){                                            /* TRUE 的常量是 1 */
    item = producer_item();                                        /* 产生放在缓冲区的一些数据 */
    down(&empty);                                            /* 将空槽数量减 1  */
    down(&mutex);                                            /* 进入关键区域  */
    insert_item(item);                                        /* 把数据放入缓冲区中 */
    up(&mutex);                                            /* 离开临界区 */
    up(&full);                                                /* 将 buffer 满槽数量 + 1 */
  }
}

void consumer(void){
  
  int item;
  
  while(TRUE){                                            /* 无限循环 */
    down(&full);                                            /* 缓存区满槽数量 - 1 */
    down(&mutex);                                            /* 进入缓冲区 */    
    item = remove_item();                                    /* 从缓冲区取出数据 */
    up(&mutex);                                            /* 离开临界区 */
    up(&empty);                                            /* 将空槽数目 + 1 */
    consume_item(item);                                    /* 处理数据 */
  }
  
}

为了确保信号量能正确工作,最重要的是要采用一种不可分割的方式来实现它。通常是将 up 和 down 作为系统调用来实现。而且操作系统只需在执行以下操作时暂时屏蔽全部中断:检查信号量、更新、必要时使进程睡眠。由于这些操作仅需要非常少的指令,因此中断不会造成影响。如果使用多个 CPU,那么信号量应该被锁进行保护。使用 TSL 或者 XCHG 指令用来确保同一时刻只有一个 CPU 对信号量进行操作。

使用 TSL 或者 XCHG 来防止几个 CPU 同时访问一个信号量,与生产者或消费者使用忙等待来等待其他腾出或填充缓冲区是完全不一样的。前者的操作仅需要几个毫秒,而生产者或消费者可能需要任意长的时间。

上面这个解决方案使用了三种信号量:一个称为 full,用来记录充满的缓冲槽数目;一个称为 empty,记录空的缓冲槽数目;一个称为 mutex,用来确保生产者和消费者不会同时进入缓冲区。Full 被初始化为 0 ,empty 初始化为缓冲区中插槽数,mutex 初始化为 1。信号量初始化为 1 并且由两个或多个进程使用,以确保它们中同时只有一个可以进入关键区域的信号被称为 二进制信号量(binary semaphores)。如果每个进程都在进入关键区域之前执行 down 操作,而在离开关键区域之后执行 up 操作,则可以确保相互互斥。

现在我们有了一个好的进程间原语的保证。然后我们再来看一下中断的顺序保证

  1. 硬件压入堆栈程序计数器等
  2. 硬件从中断向量装入新的程序计数器
  3. 汇编语言过程保存寄存器的值
  4. 汇编语言过程设置新的堆栈
  5. C 中断服务器运行(典型的读和缓存写入)
  6. 调度器决定下面哪个程序先运行
  7. C 过程返回至汇编代码
  8. 汇编语言过程开始运行新的当前进程

在使用信号量的系统中,隐藏中断的自然方法是让每个 I/O 设备都配备一个信号量,该信号量最初设置为0。在 I/O 设备启动后,中断处理程序立刻对相关联的信号执行一个 down 操作,于是进程立即被阻塞。当中断进入时,中断处理程序随后对相关的信号量执行一个 up操作,能够使已经阻止的进程恢复运行。在上面的中断处理步骤中,其中的第 5 步 C 中断服务器运行 就是中断处理程序在信号量上执行的一个 up 操作,所以在第 6 步中,操作系统能够执行设备驱动程序。当然,如果有几个进程已经处于就绪状态,调度程序可能会选择接下来运行一个更重要的进程,我们会在后面讨论调度的算法。

上面的代码实际上是通过两种不同的方式来使用信号量的,而这两种信号量之间的区别也是很重要的。mutex 信号量用于互斥。它用于确保任意时刻只有一个进程能够对缓冲区和相关变量进行读写。互斥是用于避免进程混乱所必须的一种操作。

另外一个信号量是关于同步(synchronization)的。fullempty 信号量用于确保事件的发生或者不发生。在这个事例中,它们确保了缓冲区满时生产者停止运行;缓冲区为空时消费者停止运行。这两个信号量的使用与 mutex 不同。

互斥量

如果不需要信号量的计数能力时,可以使用信号量的一个简单版本,称为 mutex(互斥量)。互斥量的优势就在于在一些共享资源和一段代码中保持互斥。由于互斥的实现既简单又有效,这使得互斥量在实现用户空间线程包时非常有用。

互斥量是一个处于两种状态之一的共享变量:解锁(unlocked)加锁(locked)。这样,只需要一个二进制位来表示它,不过一般情况下,通常会用一个 整形(integer) 来表示。0 表示解锁,其他所有的值表示加锁,比 1 大的值表示加锁的次数。

mutex 使用两个过程,当一个线程(或者进程)需要访问关键区域时,会调用 mutex_lock 进行加锁。如果互斥锁当前处于解锁状态(表示关键区域可用),则调用成功,并且调用线程可以自由进入关键区域。

另一方面,如果 mutex 互斥量已经锁定的话,调用线程会阻塞直到关键区域内的线程执行完毕并且调用了 mutex_unlock 。如果多个线程在 mutex 互斥量上阻塞,将随机选择一个线程并允许它获得锁。

image.png

由于 mutex 互斥量非常简单,所以只要有 TSL 或者是 XCHG 指令,就可以很容易地在用户空间实现它们。用于用户级线程包的 mutex_lockmutex_unlock 代码如下,XCHG 的本质也一样。

mutex_lock:
            TSL REGISTER,MUTEX                        | 将互斥信号量复制到寄存器,并将互斥信号量置为1
            CMP REGISTER,#0                        | 互斥信号量是 0 吗?
            JZE ok                                    | 如果互斥信号量为0,它被解锁,所以返回
            CALL thread_yield                            | 互斥信号正在使用;调度其他线程
            JMP mutex_lock                            | 再试一次
ok:     RET                                                | 返回调用者,进入临界区

mutex_unlcok:
            MOVE MUTEX,#0                            | 将 mutex 置为 0 
            RET                                        | 返回调用者

mutex_lock 的代码和上面 enter_region 的代码很相似,我们可以对比着看一下

image.png

上面代码最大的区别你看出来了吗?

  • 根据上面我们对 TSL 的分析,我们知道,如果 TSL 判断没有进入临界区的进程会进行无限循环获取锁,而在 TSL 的处理中,如果 mutex 正在使用,那么就调度其他线程进行处理。所以上面最大的区别其实就是在判断 mutex/TSL 之后的处理。
  • 在(用户)线程中,情况有所不同,因为没有时钟来停止运行时间过长的线程。结果是通过忙等待的方式来试图获得锁的线程将永远循环下去,决不会得到锁,因为这个运行的线程不会让其他线程运行从而释放锁,其他线程根本没有获得锁的机会。在后者获取锁失败时,它会调用 thread_yield 将 CPU 放弃给另外一个线程。结果就不会进行忙等待。在该线程下次运行时,它再一次对锁进行测试。

上面就是 enter_region 和 mutex_lock 的差别所在。由于 thread_yield 仅仅是一个用户空间的线程调度,所以它的运行非常快捷。这样,mutex_lockmutex_unlock 都不需要任何内核调用。通过使用这些过程,用户线程完全可以实现在用户空间中的同步,这个过程仅仅需要少量的同步。

我们上面描述的互斥量其实是一套调用框架中的指令。从软件角度来说,总是需要更多的特性和同步原语。例如,有时线程包提供一个调用 mutex_trylock,这个调用尝试获取锁或者返回错误码,但是不会进行加锁操作。这就给了调用线程一个灵活性,以决定下一步做什么,是使用替代方法还是等候下去。

Futexes

随着并行的增加,有效的同步(synchronization)锁定(locking) 对于性能来说是非常重要的。如果进程等待时间很短,那么自旋锁(Spin lock) 是非常有效;但是如果等待时间比较长,那么这会浪费 CPU 周期。如果进程很多,那么阻塞此进程,并仅当锁被释放的时候让内核解除阻塞是更有效的方式。不幸的是,这种方式也会导致另外的问题:它可以在进程竞争频繁的时候运行良好,但是在竞争不是很激烈的情况下内核切换的消耗会非常大,而且更困难的是,预测锁的竞争数量更不容易。

有一种有趣的解决方案是把两者的优点结合起来,提出一种新的思想,称为 futex,或者是 快速用户空间互斥(fast user space mutex),是不是听起来很有意思?

image.png

futex 是 Linux 中的特性实现了基本的锁定(很像是互斥锁)而且避免了陷入内核中,因为内核的切换的开销非常大,这样做可以大大提高性能。futex 由两部分组成:内核服务和用户库。内核服务提供了了一个 等待队列(wait queue) 允许多个进程在锁上排队等待。除非内核明确的对他们解除阻塞,否则它们不会运行。

image.png

对于一个进程来说,把它放到等待队列需要昂贵的系统调用,这种方式应该被避免。在没有竞争的情况下,futex 可以直接在用户空间中工作。这些进程共享一个 32 位整数(integer) 作为公共锁变量。假设锁的初始化为 1,我们认为这时锁已经被释放了。线程通过执行原子性的操作减少并测试(decrement and test) 来抢占锁。decrement and set 是 Linux 中的原子功能,由包裹在 C 函数中的内联汇编组成,并在头文件中进行定义。下一步,线程会检查结果来查看锁是否已经被释放。如果锁现在不是锁定状态,那么刚好我们的线程可以成功抢占该锁。然而,如果锁被其他线程持有,抢占锁的线程不得不等待。在这种情况下,futex 库不会自旋,但是会使用一个系统调用来把线程放在内核中的等待队列中。这样一来,切换到内核的开销已经是合情合理的了,因为线程可以在任何时候阻塞。当线程完成了锁的工作时,它会使用原子性的 增加并测试(increment and test) 释放锁,并检查结果以查看内核等待队列上是否仍阻止任何进程。如果有的话,它会通知内核可以对等待队列中的一个或多个进程解除阻塞。如果没有锁竞争,内核则不需要参与竞争。

Pthreads 中的互斥量

Pthreads 提供了一些功能用来同步线程。最基本的机制是使用互斥量变量,可以锁定和解锁,用来保护每个关键区域。希望进入关键区域的线程首先要尝试获取 mutex。如果 mutex 没有加锁,线程能够马上进入并且互斥量能够自动锁定,从而阻止其他线程进入。如果 mutex 已经加锁,调用线程会阻塞,直到 mutex 解锁。如果多个线程在相同的互斥量上等待,当互斥量解锁时,只有一个线程能够进入并且重新加锁。这些锁并不是必须的,程序员需要正确使用它们。

下面是与互斥量有关的函数调用

image.png

向我们想象中的一样,mutex 能够被创建和销毁,扮演这两个角色的分别是 Phread_mutex_initPthread_mutex_destroy。mutex 也可以通过 Pthread_mutex_lock 来进行加锁,如果互斥量已经加锁,则会阻塞调用者。还有一个调用Pthread_mutex_trylock 用来尝试对线程加锁,当 mutex 已经被加锁时,会返回一个错误代码而不是阻塞调用者。这个调用允许线程有效的进行忙等。最后,Pthread_mutex_unlock 会对 mutex 解锁并且释放一个正在等待的线程。

除了互斥量以外,Pthreads 还提供了第二种同步机制: 条件变量(condition variables) 。mutex 可以很好的允许或阻止对关键区域的访问。条件变量允许线程由于未满足某些条件而阻塞。绝大多数情况下这两种方法是一起使用的。下面我们进一步来研究线程、互斥量、条件变量之间的关联。

下面再来重新认识一下生产者和消费者问题:一个线程将东西放在一个缓冲区内,由另一个线程将它们取出。如果生产者发现缓冲区没有空槽可以使用了,生产者线程会阻塞起来直到有一个线程可以使用。生产者使用 mutex 来进行原子性检查从而不受其他线程干扰。但是当发现缓冲区已经满了以后,生产者需要一种方法来阻塞自己并在以后被唤醒。这便是条件变量做的工作。

下面是一些与条件变量有关的最重要的 pthread 调用

image.png

上表中给出了一些调用用来创建和销毁条件变量。条件变量上的主要属性是 Pthread_cond_waitPthread_cond_signal。前者阻塞调用线程,直到其他线程发出信号为止(使用后者调用)。阻塞的线程通常需要等待唤醒的信号以此来释放资源或者执行某些其他活动。只有这样阻塞的线程才能继续工作。条件变量允许等待与阻塞原子性的进程。Pthread_cond_broadcast 用来唤醒多个阻塞的、需要等待信号唤醒的线程。

需要注意的是,条件变量(不像是信号量)不会存在于内存中。如果将一个信号量传递给一个没有线程等待的条件变量,那么这个信号就会丢失,这个需要注意

下面是一个使用互斥量和条件变量的例子

#include <stdio.h>
#include <pthread.h>

#define MAX 1000000000                                /* 需要生产的数量 */
pthread_mutex_t the_mutex;
pthread_cond_t condc,condp;                            /* 使用信号量 */
int buffer = 0;

void *producer(void *ptr){                                /* 生产数据 */
  
  int i;
  
  for(int i = 0;i <= MAX;i++){
    pthread_mutex_lock(&the_mutex);                                /* 缓冲区独占访问,也就是使用 mutex 获取锁 */
    while(buffer != 0){
      pthread_cond_wait(&condp,&the_mutex);
    }
    buffer = i;                                            /* 把他们放在缓冲区中 */
    pthread_cond_signal(&condc);                            /* 唤醒消费者 */
    pthread_mutex_unlock(&the_mutex);                            /* 释放缓冲区 */
  }
  pthread_exit(0);
  
}

void *consumer(void *ptr){                                /* 消费数据 */
  
  int i;
  
  for(int i = 0;i <= MAX;i++){
    pthread_mutex_lock(&the_mutex);                                /* 缓冲区独占访问,也就是使用 mutex 获取锁 */
    while(buffer == 0){
      pthread_cond_wait(&condc,&the_mutex);
    }
    buffer = 0;                                            /* 把他们从缓冲区中取出 */
    pthread_cond_signal(&condp);                            /* 唤醒生产者 */
    pthread_mutex_unlock(&the_mutex);                            /* 释放缓冲区 */
  }
  pthread_exit(0);
  
}                              

管程

为了能够编写更加准确无误的程序,Brinch Hansen 和 Hoare 提出了一个更高级的同步原语叫做 管程(monitor)。他们两个人的提案略有不同,通过下面的描述你就可以知道。管程是程序、变量和数据结构等组成的一个集合,它们组成一个特殊的模块或者包。进程可以在任何需要的时候调用管程中的程序,但是它们不能从管程外部访问数据结构和程序。下面展示了一种抽象的,类似 Pascal 语言展示的简洁的管程。不能用 C 语言进行描述,因为管程是语言概念而 C 语言并不支持管程。

monitor example
    integer i;
    condition c;
    
    procedure producer();
    .
    .
    .
    end;
    
    
    procedure consumer();
    .
    end;
end monitor;

管程有一个很重要的特性,即在任何时候管程中只能有一个活跃的进程,这一特性使管程能够很方便的实现互斥操作。管程是编程语言的特性,所以编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。通常情况下,当进程调用管程中的程序时,该程序的前几条指令会检查管程中是否有其他活跃的进程。如果有的话,调用进程将被挂起,直到另一个进程离开管程才将其唤醒。如果没有活跃进程在使用管程,那么该调用进程才可以进入。

进入管程中的互斥由编译器负责,但是一种通用做法是使用 互斥量(mutex)二进制信号量(binary semaphore)。由于编译器而不是程序员在操作,因此出错的几率会大大降低。在任何时候,编写管程的程序员都无需关心编译器是如何处理的。他只需要知道将所有的临界区转换成为管程过程即可。绝不会有两个进程同时执行临界区中的代码。

即使管程提供了一种简单的方式来实现互斥,但在我们看来,这还不够。因为我们还需要一种在进程无法执行被阻塞。在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放在管程程序中,但是生产者在发现缓冲区满的时候该如何阻塞呢?

解决的办法是引入条件变量(condition variables) 以及相关的两个操作 waitsignal。当一个管程程序发现它不能运行时(例如,生产者发现缓冲区已满),它会在某个条件变量(如 full)上执行 wait 操作。这个操作造成调用进程阻塞,并且还将另一个以前等在管程之外的进程调入管程。在前面的 pthread 中我们已经探讨过条件变量的实现细节了。另一个进程,比如消费者可以通过执行 signal 来唤醒阻塞的调用进程。

Brinch Hansen 和 Hoare 在对进程唤醒上有所不同,Hoare 建议让新唤醒的进程继续运行;而挂起另外的进程。而 Brinch Hansen 建议让执行 signal 的进程必须退出管程,这里我们采用 Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。

如果在一个条件变量上有若干进程都在等待,则在对该条件执行 signal 操作后,系统调度程序只能选择其中一个进程恢复运行。

顺便提一下,这里还有上面两位教授没有提出的第三种方式,它的理论是让执行 signal 的进程继续运行,等待这个进程退出管程时,其他进程才能进入管程。

条件变量不是计数器。条件变量也不能像信号量那样积累信号以便以后使用。所以,如果向一个条件变量发送信号,但是该条件变量上没有等待进程,那么信号将会丢失。也就是说,wait 操作必须在 signal 之前执行

下面是一个使用 Pascal 语言通过管程实现的生产者-消费者问题的解法

monitor ProducerConsumer
        condition full,empty;
        integer count;
        
        procedure insert(item:integer);
        begin
                if count = N then wait(full);
                insert_item(item);
                count := count + 1;
                if count = 1 then signal(empty);
        end;
        
        function remove:integer;
        begin
                if count = 0 then wait(empty);
                remove = remove_item;
                count := count - 1;
                if count = N - 1 then signal(full);
        end;
        
        count := 0;
end monitor;

procedure producer;
begin
            while true do
      begin 
                  item = produce_item;
                  ProducerConsumer.insert(item);
      end
end;

procedure consumer;
begin 
            while true do
            begin
                        item = ProducerConsumer.remove;
                        consume_item(item);
            end
end;

读者可能觉得 wait 和 signal 操作看起来像是前面提到的 sleep 和 wakeup ,而且后者存在严重的竞争条件。它们确实很像,但是有个关键的区别:sleep 和 wakeup 之所以会失败是因为当一个进程想睡眠时,另一个进程试图去唤醒它。使用管程则不会发生这种情况。管程程序的自动互斥保证了这一点,如果管程过程中的生产者发现缓冲区已满,它将能够完成 wait 操作而不用担心调度程序可能会在 wait 完成之前切换到消费者。甚至,在 wait 执行完成并且把生产者标志为不可运行之前,是不会允许消费者进入管程的。

尽管类 Pascal 是一种想象的语言,但还是有一些真正的编程语言支持,比如 Java (终于轮到大 Java 出场了),Java 是能够支持管程的,它是一种 面向对象的语言,支持用户级线程,还允许将方法划分为类。只要将关键字 synchronized 关键字加到方法中即可。Java 能够保证一旦某个线程执行该方法,就不允许其他线程执行该对象中的任何 synchronized 方法。没有关键字 synchronized ,就不能保证没有交叉执行。

下面是 Java 使用管程解决的生产者-消费者问题

public class ProducerConsumer {
  static final int N = 100;                                        // 定义缓冲区大小的长度
  static Producer p = new Producer();                                            // 初始化一个新的生产者线程
  static Consumer c = new Consumer();                                    // 初始化一个新的消费者线程
  static Our_monitor mon = new Our_monitor();                                         // 初始化一个管程
  
  static class Producer extends Thread{
    public void run(){                                            // run 包含了线程代码
      int item;
      while(true){                                                // 生产者循环
        item = produce_item();
        mon.insert(item);
      }
    }
    private int produce_item(){...}                                                // 生产代码
  }
  
  static class consumer extends Thread {
    public void run( ) {                                            // run 包含了线程代码
           int item;
      while(true){
        item = mon.remove();
                consume_item(item);
      }
    }
    private int produce_item(){...}                                                // 消费代码
  }
  
  static class Our_monitor {                                            // 这是管程
    private int buffer[] = new int[N];
    private int count = 0,lo = 0,hi = 0;                                                    // 计数器和索引
    
    private synchronized void insert(int val){
      if(count == N){
        go_to_sleep();                                            // 如果缓冲区是满的,则进入休眠
      }
            buffer[hi] = val;                                    // 向缓冲区插入内容
      hi = (hi + 1) % N;                                             // 找到下一个槽的为止
      count = count + 1;                                            // 缓冲区中的数目自增 1 
      if(count == 1){
        notify();                                                    // 如果消费者睡眠,则唤醒
      }
    }
    
    private synchronized void remove(int val){
      int val;
      if(count == 0){
        go_to_sleep();                                            // 缓冲区是空的,进入休眠
      }
      val = buffer[lo];                                            // 从缓冲区取出数据
      lo = (lo + 1) % N;                                            // 设置待取出数据项的槽
      count = count - 1;                                            // 缓冲区中的数据项数目减 1 
      if(count = N - 1){
        notify();                                                    // 如果生产者睡眠,唤醒它
      }
      return val;
    }
    
    private void go_to_sleep() {
      try{
        wait( );
      }catch(Interr uptedExceptionexc) {};
    }
  }
      
}

上面的代码中主要设计四个类,外部类(outer class) ProducerConsumer 创建并启动两个线程,p 和 c。第二个类和第三个类 ProducerConsumer 分别包含生产者和消费者代码。最后,Our_monitor 是管程,它有两个同步线程,用于在共享缓冲区中插入和取出数据。

在前面的所有例子中,生产者和消费者线程在功能上与它们是相同的。生产者有一个无限循环,该无限循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环,该无限循环用于从缓冲区取出数据并完成一系列工作。

程序中比较耐人寻味的就是 Our_monitor 了,它包含缓冲区、管理变量以及两个同步方法。当生产者在 insert 内活动时,它保证消费者不能在 remove 方法中运行,从而保证更新变量以及缓冲区的安全性,并且不用担心竞争条件。变量 count 记录在缓冲区中数据的数量。变量 lo 是缓冲区槽的序号,指出将要取出的下一个数据项。类似地,hi 是缓冲区中下一个要放入的数据项序号。允许 lo = hi,含义是在缓冲区中有 0 个或 N 个数据。

Java 中的同步方法与其他经典管程有本质差别:Java 没有内嵌的条件变量。然而,Java 提供了 wait 和 notify 分别与 sleep 和 wakeup 等价。

通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性。但是管程也有缺点,我们前面说到过管程是一个编程语言的概念,编译器必须要识别管程并用某种方式对其互斥作出保证。C、Pascal 以及大多数其他编程语言都没有管程,所以不能依靠编译器来遵守互斥规则。

与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问共享内存的一个或多个 CPU 上的互斥问题的。通过将信号量放在共享内存中并用 TSLXCHG 指令来保护它们,可以避免竞争。但是如果是在分布式系统中,可能同时具有多个 CPU 的情况,并且每个 CPU 都有自己的私有内存呢,它们通过网络相连,那么这些原语将会失效。因为信号量太低级了,而管程在少数几种编程语言之外无法使用,所以还需要其他方法。

消息传递

上面提到的其他方法就是 消息传递(messaage passing)。这种进程间通信的方法使用两个原语 sendreceive ,它们像信号量而不像管程,是系统调用而不是语言级别。示例如下

send(destination, &message);

receive(source, &message);

send 方法用于向一个给定的目标发送一条消息,receive 从一个给定的源接受一条消息。如果没有消息,接受者可能被阻塞,直到接受一条消息或者带着错误码返回。

消息传递系统的设计要点

消息传递系统现在面临着许多信号量和管程所未涉及的问题和设计难点,尤其对那些在网络中不同机器上的通信状况。例如,消息有可能被网络丢失。为了防止消息丢失,发送方和接收方可以达成一致:一旦接受到消息后,接收方马上回送一条特殊的 确认(acknowledgement) 消息。如果发送方在一段时间间隔内未收到确认,则重发消息。

现在考虑消息本身被正确接收,而返回给发送着的确认消息丢失的情况。发送者将重发消息,这样接受者将收到两次相同的消息。

image.png

对于接收者来说,如何区分新的消息和一条重发的老消息是非常重要的。通常采用在每条原始消息中嵌入一个连续的序号来解决此问题。如果接受者收到一条消息,它具有与前面某一条消息一样的序号,就知道这条消息是重复的,可以忽略。

消息系统还必须处理如何命名进程的问题,以便在发送或接收调用中清晰的指明进程。身份验证(authentication) 也是一个问题,比如客户端怎么知道它是在与一个真正的文件服务器通信,从发送方到接收方的信息有可能被中间人所篡改。

用消息传递解决生产者-消费者问题

现在我们考虑如何使用消息传递来解决生产者-消费者问题,而不是共享缓存。下面是一种解决方式

#define N 100                                /* buffer 中槽的数量 */

void producer(void){
  
  int item;
  message m;                                    /* buffer 中槽的数量 */
  
  while(TRUE){
    item = produce_item();                        /* 生成放入缓冲区的数据 */
    receive(consumer,&m);                        /* 等待消费者发送空缓冲区 */
    build_message(&m,item);                        /* 建立一个待发送的消息 */
    send(consumer,&m);                                /* 发送给消费者 */
  }
  
}

void consumer(void){
  
  int item,i;
  message m;
  
  for(int i = 0;i < N;i++){                                /* 循环N次 */
    send(producer,&m);                            /* 发送N个缓冲区 */
  }
  while(TRUE){
    receive(producer,&m);                        /* 接受包含数据的消息 */
      item = extract_item(&m);                    /* 将数据从消息中提取出来 */
    send(producer,&m);                            /* 将空缓冲区发送回生产者 */
    consume_item(item);                        /* 处理数据 */
  }
  
}

假设所有的消息都有相同的大小,并且在尚未接受到发出的消息时,由操作系统自动进行缓冲。在该解决方案中共使用 N 条消息,这就类似于一块共享内存缓冲区的 N 个槽。消费者首先将 N 条空消息发送给生产者。当生产者向消费者传递一个数据项时,它取走一条空消息并返回一条填充了内容的消息。通过这种方式,系统中总的消息数量保持不变,所以消息都可以存放在事先确定数量的内存中。

如果生产者的速度要比消费者快,则所有的消息最终都将被填满,等待消费者,生产者将被阻塞,等待返回一条空消息。如果消费者速度快,那么情况将正相反:所有的消息均为空,等待生产者来填充,消费者将被阻塞,以等待一条填充过的消息。

消息传递的方式有许多变体,下面先介绍如何对消息进行 编址

  • 一种方法是为每个进程分配一个唯一的地址,让消息按进程的地址编址。
  • 另一种方式是引入一个新的数据结构,称为 信箱(mailbox),信箱是一个用来对一定的数据进行缓冲的数据结构,信箱中消息的设置方法也有多种,典型的方法是在信箱创建时确定消息的数量。在使用信箱时,在 send 和 receive 调用的地址参数就是信箱的地址,而不是进程的地址。当一个进程试图向一个满的信箱发送消息时,它将被挂起,直到信箱中有消息被取走,从而为新的消息腾出地址空间。

屏障

最后一个同步机制是准备用于进程组而不是进程间的生产者-消费者情况的。在某些应用中划分了若干阶段,并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段,可以通过在每个阶段的结尾安装一个 屏障(barrier) 来实现这种行为。当一个进程到达屏障时,它会被屏障所拦截,直到所有的屏障都到达为止。屏障可用于一组进程同步,如下图所示

image.png

在上图中我们可以看到,有四个进程接近屏障,这意味着每个进程都在进行运算,但是还没有到达每个阶段的结尾。过了一段时间后,A、B、D 三个进程都到达了屏障,各自的进程被挂起,但此时还不能进入下一个阶段呢,因为进程 B 还没有执行完毕。结果,当最后一个 C 到达屏障后,这个进程组才能够进入下一个阶段。

避免锁:读-复制-更新

最快的锁是根本没有锁。问题在于没有锁的情况下,我们是否允许对共享数据结构的并发读写进行访问。答案当然是不可以。假设进程 A 正在对一个数字数组进行排序,而进程 B 正在计算其平均值,而此时你进行 A 的移动,会导致 B 会多次读到重复值,而某些值根本没有遇到过。

然而,在某些情况下,我们可以允许写操作来更新数据结构,即便还有其他的进程正在使用。窍门在于确保每个读操作要么读取旧的版本,要么读取新的版本,例如下面的树

image.png

上面的树中,读操作从根部到叶子遍历整个树。加入一个新节点 X 后,为了实现这一操作,我们要让这个节点在树中可见之前使它"恰好正确":我们对节点 X 中的所有值进行初始化,包括它的子节点指针。然后通过原子写操作,使 X 称为 A 的子节点。所有的读操作都不会读到前后不一致的版本

image.png

在上面的图中,我们接着移除 B 和 D。首先,将 A 的左子节点指针指向 C 。所有原本在 A 中的读操作将会后续读到节点 C ,而永远不会读到 B 和 D。也就是说,它们将只会读取到新版数据。同样,所有当前在 B 和 D 中的读操作将继续按照原始的数据结构指针并且读取旧版数据。所有操作均能正确运行,我们不需要锁住任何东西。而不需要锁住数据就能够移除 B 和 D 的主要原因就是 读-复制-更新(Ready-Copy-Update,RCU),将更新过程中的移除和再分配过程分离开。

文章参考:

《现代操作系统》

《Modern Operating System》forth edition

https://www.encyclopedia.com/...

https://j00ru.vexillium.org/s...

https://www.bottomupcs.com/pr...

https://en.wikipedia.org/wiki...

https://en.wikipedia.org/wiki...

image.png

查看原文

赞 32 收藏 23 评论 0

程序员cxuan 发布了文章 · 2月7日

什么叫操作系统啊 | 战术后仰

操作系统

现代操作系统由一个或多个处理器、主存、打印机、键盘、鼠标、显示器、网络接口以及各种输入/输出设备构成。计算机操作系统是一个复杂的系统。

image.png

然而,程序员不会直接和这些硬件打交道,而且每位程序员不可能会掌握所有操作系统的细节,这样我们就不用再编写代码了,所以在硬件的基础之上,计算机安装了一层软件,这层软件能够通过响应用户输入的指令达到控制硬件的效果,从而满足用户需求,这种软件称之为 操作系统,它的任务就是为用户程序提供一个更好、更简单、更清晰的计算机模型。

我们一般常见的操作系统主要有 Windows、Linux、FreeBSD 或 OS X ,这种带有图形界面的操作系统被称为 图形用户界面(Graphical User Interface, GUI),而基于文本、命令行的通常称为 Shell。下面是我们所要探讨的操作系统的部件

image.png

这是一个操作系统的简化图,最下面的是硬件,硬件包括芯片、电路板、磁盘、键盘、显示器等我们上面提到的设备,在硬件之上是软件。大部分计算机有两种运行模式:内核态用户态,软件中最基础的部分是操作系统,它运行在 内核态 中,内核态也称为 管态核心态,它们都是操作系统的运行状态,只不过是不同的叫法而已。操作系统具有硬件的访问权,可以执行机器能够运行的任何指令。软件的其余部分运行在 用户态 下。

用户接口程序(shell 或者 GUI) 处于用户态中,并且它们位于用户态的最低层,允许用户运行其他程序,例如 Web 浏览器、电子邮件阅读器、音乐播放器等。而且,越靠近用户态的应用程序越容易编写,如果你不喜欢某个电子邮件阅读器你可以重新写一个或者换一个,但你不能自行写一个操作系统或者是中断处理程序。这个程序由硬件保护,防止外部对其进行修改。

计算机硬件简介

操作系统与运行操作系统的内核硬件关系密切。操作系统扩展了计算机指令集并管理计算机的资源。因此,操作系统因此必须足够了解硬件的运行,这里我们先简要介绍一下现代计算机中的计算机硬件。

image.png

从概念上来看,一台简单的个人电脑可以被抽象为上面这种相似的模型,CPU、内存、I/O 设备都和总线串联起来并通过总线与其他设备进行通信。现代操作系统有着更为复杂的结构,会设计很多条总线,我们稍后会看到。暂时来讲,这个模型能够满足我们的讨论。

CPU

CPU 是计算机的大脑,它主要和内存进行交互,从内存中提取指令并执行它。一个 CPU 的执行周期是从内存中提取第一条指令、解码并决定它的类型和操作数,执行,然后再提取、解码执行后续的指令。重复该循环直到程序运行完毕。

每个 CPU 都有一组可以执行的特定指令集。因此,x86 的 CPU 不能执行 ARM 的程序并且 ARM 的 CPU 也不能执行 x86 的程序。由于访问内存获取执行或数据要比执行指令花费的时间长,因此所有的 CPU 内部都会包含一些寄存器来保存关键变量和临时结果。因此,在指令集中通常会有一些指令用于把关键字从内存中加载到寄存器中,以及把关键字从寄存器存入到内存中。还有一些其他的指令会把来自寄存器和内存的操作数进行组合,例如 add 操作就会把两个操作数相加并把结果保存到内存中。

除了用于保存变量和临时结果的通用寄存器外,大多数计算机还具有几个特殊的寄存器,这些寄存器对于程序员是可见的。其中之一就是 程序计数器(program counter),程序计数器会指示下一条需要从内存提取指令的地址。提取指令后,程序计数器将更新为下一条需要提取的地址。

另一个寄存器是 堆栈指针(stack pointer),它指向内存中当前栈的顶端。堆栈指针会包含输入过程中的有关参数、局部变量以及没有保存在寄存器中的临时变量。

还有一个寄存器是 PSW(Program Status Word) 程序状态字寄存器,这个寄存器是由操作系统维护的8个字节(64位) long 类型的数据集合。它会跟踪当前系统的状态。除非发生系统结束,否则我们可以忽略 PSW 。用户程序通常可以读取整个PSW,但通常只能写入其某些字段。PSW 在系统调用和 I / O 中起着重要作用。

操作系统必须了解所有的寄存器。在时间多路复用(time multiplexing) 的 CPU 中,操作系统往往停止运行一个程序转而运行另外一个。每次当操作系统停止运行一个程序时,操作系统会保存所有寄存器的值,以便于后续重新运行该程序。

为了提升性能, CPU 设计人员早就放弃了同时去读取、解码和执行一条简单的指令。许多现代的 CPU 都具有同时读取多条指令的机制。例如,一个 CPU 可能会有单独访问、解码和执行单元,所以,当 CPU 执行第 N 条指令时,还可以对 N + 1 条指令解码,还可以读取 N + 2 条指令。像这样的组织形式被称为 流水线(pipeline)

image.png

比流水线更先进的设计是 超标量(superscalar) CPU,下面是超标量 CPU 的设计

image.png

在上面这个设计中,存在多个执行单元,例如,一个用来进行整数运算、一个用来浮点数运算、一个用来布尔运算。两个或者更多的指令被一次性取出、解码并放入缓冲区中,直至它们执行完毕。只要一个执行单元空闲,就会去检查缓冲区是否有可以执行的指令。如果有,就把指令从缓冲区中取出并执行。这种设计的含义是应用程序通常是无序执行的。在大多数情况下,硬件负责保证这种运算的结果与顺序执行指令时的结果相同。

除了用在嵌入式系统中非常简单的 CPU 之外,多数 CPU 都有两种模式,即前面已经提到的内核态和用户态。通常情况下,PSW 寄存器中的一个二进制位会控制当前状态是内核态还是用户态。当运行在内核态时,CPU 能够执行任何指令集中的指令并且能够使用硬件的功能。在台式机和服务器上,操作系统通常以内核模式运行,从而可以访问完整的硬件。在大多数嵌入式系统中,一部分运行在内核态下,剩下的一部分运行在用户态下。

用户应用程序通常运行在用户态下,在用户态下,CPU 只能执行指令集中的一部分并且只能访问硬件的一部分功能。一般情况下,在用户态下,有关 I/O 和内存保护的所有指令是禁止执行的。当然,设置 PSW 模式的二进制位为内核态也是禁止的。

为了获取操作系统的服务,用户程序必须使用 系统调用(system call),系统调用会转换为内核态并且调用操作系统。TRAP 指令用于把用户态切换为内核态并启用操作系统。当有关工作完成之后,在系统调用后面的指令会把控制权交给用户程序。我们会在后面探讨操作系统的调用细节。

需要注意的是操作系统在进行系统调用时会存在陷阱。大部分的陷阱会导致硬件发出警告,比如说试图被零除或浮点下溢等你。在所有的情况下,操作系统都能得到控制权并决定如何处理异常情况。有时,由于出错的原因,程序不得不停止。

多线程和多核芯片

Intel Pentinum 4也就是奔腾处理器引入了被称为多线程(multithreading)超线程(hyperthreading, Intel 公司的命名) 的特性,x86 处理器和其他一些 CPU 芯片就是这样做的。包括 SSPARC、Power5、Intel Xeon 和 Intel Core 系列 。近似地说,多线程允许 CPU 保持两个不同的线程状态并且在纳秒级(nanosecond) 的时间完成切换。线程是一种轻量级的进程,我们会在后面说到。例如,如果一个进程想要从内存中读取指令(这通常会经历几个时钟周期),多线程 CPU 则可以切换至另一个线程。多线程不会提供真正的并行处理。在一个时刻只有一个进程在运行。

对于操作系统来讲,多线程是有意义的,因为每个线程对操作系统来说都像是一个单个的 CPU。比如一个有两个 CPU 的操作系统,并且每个 CPU 运行两个线程,那么这对于操作系统来说就可能是 4 个 CPU。

除了多线程之外,现在许多 CPU 芯片上都具有四个、八个或更多完整的处理器或内核。多核芯片在其上有效地承载了四个微型芯片,每个微型芯片都有自己的独立CPU。

image.png

image.png

如果要说在绝对核心数量方面,没有什么能赢过现代 GPU(Graphics Processing Unit),GPU 是指由成千上万个微核组成的处理器。它们擅长处理大量并行的简单计算。

内存

计算机中第二个主要的组件就是内存。理想情况下,内存应该非常快速(比执行一条指令要快,从而不会拖慢 CPU 执行效率),而且足够大且便宜,但是目前的技术手段无法满足三者的需求。于是采用了不同的处理方式,存储器系统采用一种分层次的结构

image.png

顶层的存储器速度最高,但是容量最小,成本非常高,层级结构越向下,其访问效率越慢,容量越大,但是造价也就越便宜。

寄存器

存储器的顶层是 CPU 中的寄存器,它们用和 CPU 一样的材料制成,所以和 CPU 一样快。程序必须在软件中自行管理这些寄存器(即决定如何使用它们)

高速缓存

位于寄存器下面的是高速缓存,它多数由硬件控制。主存被分割成高速缓存行(cache lines) 为 64 字节,内存地址的 0 - 63 对应高速缓存行 0 ,地址 64 - 127 对应高速缓存行的 1,等等。使用最频繁的高速缓存行保存在位于 CPU 内部或非常靠近 CPU 的高速缓存中。当应用程序需要从内存中读取关键词的时候,高速缓存的硬件会检查所需要的高速缓存行是否在高速缓存中。如果在的话,那么这就是高速缓存命中(cache hit)。高速缓存满足了该请求,并且没有通过总线将内存请求发送到主内存。高速缓存命中通常需要花费两个时钟周期。缓存未命中需要从内存中提取,这会消耗大量的时间。高速缓存行会限制容量的大小因为它的造价非常昂贵。有一些机器会有两个或者三个高速缓存级别,每一级高速缓存比前一级慢且容量更大。

缓存在计算机很多领域都扮演了非常重要的角色,不仅仅是 RAM 缓存行。

随机存储器(RAM): 内存中最重要的一种,表示既可以从中读取数据,也可以写入数据。当机器关闭时,内存中的信息会 丢失

大量的可用资源被划分为小的部分,这些可用资源的一部分会获得比其他资源更频繁的使用权,缓存经常用来提升性能。操作系统无时无刻的不在使用缓存。例如,大多数操作系统在主机内存中保留(部分)频繁使用的文件,以避免重复从磁盘重复获取。举个例子,类似于 /home/ast/projects/minix3/src/kernel/clock.c 这样的场路径名转换成的文件所在磁盘地址的结果也可以保存缓存中,以避免重复寻址。另外,当一个 Web 页面(URL) 的地址转换为网络地址(IP地址)后,这个转换结果也可以缓存起来供将来使用。

在任何缓存系统中,都会有下面这几个噬需解决的问题

  • 何时把新的内容放进缓存
  • 把新的内容应该放在缓存的哪一行
  • 在需要空闲空间时,应该把哪块内容从缓存中移除
  • 应该把移除的内容放在某个较大存储器的何处

并不是每个问题都与每种缓存情况有关。对于 CPU 缓存中的主存缓存行,当有缓存未命中时,就会调入新的内容。通常通过所引用内存地址的高位计算应该使用的缓存行。

缓存是解决问题的一种好的方式,所以现代 CPU 设计了两种缓存。第一级缓存或者说是 L1 cache 总是位于 CPU 内部,用来将已解码的指令调入 CPU 的执行引擎。对于那些频繁使用的关键字,多数芯片有第二个 L1 cache 。典型的 L1 cache 的大小为 16 KB。另外,往往还设有二级缓存,也就是 L2 cache,用来存放最近使用过的关键字,一般是兆字节为单位。L1 cache 和 L2 cache 最大的不同在于是否存在延迟。访问 L1 cache 没有任何的延迟,然而访问 L2 cache 会有 1 - 2 个时钟周期的延后。

什么是时钟周期?计算机处理器或 CPU 的速度由时钟周期来确定,该时钟周期是振荡器两个脉冲之间的时间量。一般而言,每秒脉冲数越高,计算机处理器处理信息的速度就越快。 时钟速度以 Hz 为单位测量,通常为兆赫(MHz)或千兆赫(GHz)。 例如,一个4 GHz处理器每秒执行4,000,000,000个时钟周期。

计算机处理器可以在每个时钟周期执行一条或多条指令,这具体取决于处理器的类型。 早期的计算机处理器和较慢的 CPU 在每个时钟周期只能执行一条指令,而现代处理器在每个时钟周期可以执行多条指令。

主存

在上面的层次结构中再下一层是主存,这是内存系统的主力军,主存通常叫做 RAM(Random Access Memory),由于 1950 年代和 1960 年代的计算机使用微小的可磁化铁氧体磁芯作为主存储器,因此旧时有时将其称为核心存储器。所有不能再高速缓存中得到满足的内存访问请求都会转往主存中。

除了主存之外,许多计算机还具有少量的非易失性随机存取存储器。它们与 RAM 不同,在电源断电后,非易失性随机访问存储器并不会丢失内容。ROM(Read Only Memory) 中的内容一旦存储后就不会再被修改。它非常快而且便宜。(如果有人问你,有没有什么又快又便宜的内存设备,那就是 ROM 了)在计算机中,用于启动计算机的引导加载模块(也就是 bootstrap )就存放在 ROM 中。另外,一些 I/O 卡也采用 ROM 处理底层设备控制。

EEPROM(Electrically Erasable PROM,)闪存(flash memory) 也是非易失性的,但是与 ROM 相反,它们可以擦除和重写。不过重写它们需要比写入 RAM 更多的时间,所以它们的使用方式与 ROM 相同,但是与 ROM 不同的是他们可以通过重写字段来纠正程序中出现的错误。

闪存也通常用来作为便携性的存储媒介。闪存是数码相机中的胶卷,是便携式音乐播放器的磁盘。闪存的速度介于 RAM 和磁盘之间。另外,与磁盘存储器不同的是,如果闪存擦除的次数太多,会出现磨损。

还有一类是 CMOS,它是易失性的。许多计算机都会使用 CMOS 存储器保持当前时间和日期。

磁盘

下一个层次是磁盘(硬盘),磁盘同 RAM 相比,每个二进制位的成本低了两个数量级,而且经常也有两个数量级大的容量。磁盘唯一的问题是随机访问数据时间大约慢了三个数量级。磁盘访问慢的原因是因为磁盘的构造不同

image.png

磁盘是一种机械装置,在一个磁盘中有一个或多个金属盘片,它们以 5400rpm、7200rpm、10800rpm 或更高的速度旋转。从边缘开始有一个机械臂悬横在盘面上,这类似于老式播放塑料唱片 33 转唱机上的拾音臂。信息会写在磁盘一系列的同心圆上。在任意一个给定臂的位置,每个磁头可以读取一段环形区域,称为磁道(track)。把一个给定臂的位置上的所有磁道合并起来,组成了一个柱面(cylinder)

image.png

每个磁道划分若干扇区,扇区的值是 512 字节。在现代磁盘中,较外部的柱面比较内部的柱面有更多的扇区。机械臂从一个柱面移动到相邻的柱面大约需要 1ms。而随机移到一个柱面的典型时间为 5ms 至 10ms,具体情况以驱动器为准。一旦磁臂到达正确的磁道上,驱动器必须等待所需的扇区旋转到磁头之下,就开始读写,低端硬盘的速率是50MB/s,而高速磁盘的速率是 160MB/s

需要注意,固态硬盘(Solid State Disk, SSD)不是磁盘,固态硬盘并没有可以移动的部分,外形也不像唱片,并且数据是存储在存储器(闪存)中,与磁盘唯一的相似之处就是它也存储了大量即使在电源关闭也不会丢失的数据。

许多计算机支持一种著名的虚拟内存机制,这种机制使得期望运行的存储空间大于实际的物理存储空间。其方法是将程序放在磁盘上,而将主存作为一部分缓存,用来保存最频繁使用的部分程序,这种机制需要快速映像内存地址,用来把程序生成的地址转换为有关字节在 RAM 中的物理地址。这种映像由 CPU 中的一个称为 存储器管理单元(Memory Management Unit, MMU) 的部件来完成。

image.png

缓存和 MMU 的出现是对系统的性能有很重要的影响,在多道程序系统中,从一个程序切换到另一个程序的机制称为 上下文切换(context switch),对来自缓存中的资源进行修改并把其写回磁盘是很有必要的。

I/O 设备

CPU 和存储器不是操作系统需要管理的全部,I/O 设备也与操作系统关系密切。可以参考上面这个图片,I/O 设备一般包括两个部分:设备控制器和设备本身。控制器本身是一块芯片或者一组芯片,它能够控制物理设备。它能够接收操作系统的指令,例如,从设备中读取数据并完成数据的处理。

在许多情况下,实际控制设备的过程是非常复杂而且存在诸多细节。因此控制器的工作就是为操作系统提供一个更简单(但仍然非常复杂)的接口。也就是屏蔽物理细节。任何复杂的东西都可以加一层代理来解决,这是计算机或者人类社会很普世的一个解决方案

I/O 设备另一部分是设备本身,设备本身有一个相对简单的接口,这是因为接口既不能做很多工作,而且也已经被标准化了。例如,标准化后任何一个 SATA 磁盘控制器就可以适配任意一种 SATA 磁盘,所以标准化是必要的。ATA 代表 高级技术附件(AT Attachment),而 SATA 表示串行高级技术附件(Serial ATA)

AT 是啥?它是 IBM 公司的第二代个人计算机的高级技术成果,使用 1984 年推出的 6MHz 80286 处理器,这个处理器是当时最强大的。

像是高级这种词汇应该慎用,否则 20 年后再回首很可能会被无情打脸。

现在 SATA 是很多计算机的标准硬盘接口。由于实际的设备接口隐藏在控制器中,所以操作系统看到的是对控制器的接口,这个接口和设备接口有很大区别。

每种类型的设备控制器都是不同的,所以需要不同的软件进行控制。专门与控制器进行信息交流,发出命令处理指令接收响应的软件,称为 设备驱动程序(device driver)。 每个控制器厂家都应该针对不同的操作系统提供不同的设备驱动程序。

为了使设备驱动程序能够工作,必须把它安装在操作系统中,这样能够使它在内核态中运行。要将设备驱动程序装入操作系统,一般有三个途径

  • 第一个途径是将内核与设备启动程序重新连接,然后重启系统。这是 UNIX 系统采用的工作方式
  • 第二个途径是在一个操作系统文件中设置一个入口,通知该文件需要一个设备驱动程序,然后重新启动系统。在重新系统时,操作系统回寻找有关的设备启动程序并把它装载,这是 Windows 采用的工作方式
  • 第三个途径是操作系统能够在运行时接收新的设备驱动程序并立刻安装,无需重启操作系统,这种方式采用的少,但是正变得普及起来。热插拔设备,比如 USB 和 IEEE 1394 都需要动态可装载的设备驱动程序。

每个设备控制器都有少量用于通信的寄存器,例如,一个最小的磁盘控制器也会有用于指定磁盘地址、内存地址、扇区计数的寄存器。要激活控制器,设备驱动程序回从操作系统获取一条指令,然后翻译成对应的值,并写入设备寄存器中,所有设备寄存器的结合构成了 I/O 端口空间

在一些计算机中,设备寄存器会被映射到操作系统的可用地址空间,使他们能够向内存一样完成读写操作。在这种计算机中,不需要专门的 I/O 指令,用户程序可以被硬件阻挡在外,防止其接触这些存储器地址(例如,采用基址寄存器和变址寄存器)。在另一些计算机中,设备寄存器被放入一个专门的 I/O 端口空间,每个寄存器都有一个端口地址。在这些计算机中,特殊的 INOUT 指令会在内核态下启用,它能够允许设备驱动程序和寄存器进行读写。前面第一种方式会限制特殊的 I/O 指令但是允许一些地址空间;后者不需要地址空间但是需要特殊的指令,这两种应用都很广泛。

实现输入和输出的方式有三种

  • 在最简单的方式中,用户程序会发起系统调用,内核会将其转换为相应驱动程序的程序调用,然后设备驱动程序启动 I/O 并循环检查该设备,看该设备是否完成了工作(一般会有一些二进制位用来指示设备仍在忙碌中)。当 I/O 调用完成后,设备驱动程序把数据送到指定的地方并返回。然后操作系统会将控制权交给调用者。这种方式称为 忙等待(busy waiting),这种方式的缺点是要一直占据 CPU,CPU 会一直轮询 I/O 设备直到 I/O 操作完成。
  • 第二种方式是设备驱动程序启动设备并且让该设备在操作完成时发生中断。设备驱动程序在这个时刻返回。操作系统接着在需要时阻塞调用者并安排其他工作进行。当设备驱动程序检测到该设备操作完成时,它发出一个 中断 通知操作完成。

在操作系统中,中断是非常重要的,所以这需要更加细致的讨论一下。

image.png

如上图所示,这是一个三步的 I/O 过程,第一步,设备驱动程序会通过写入设备寄存器告诉控制器应该做什么。然后,控制器启动设备。当控制器完成读取或写入被告知需要传输的字节后,它会在步骤 2 中使用某些总线向中断控制器发送信号。如果中断控制器准备好了接收中断信号(如果正忙于一个优先级较高的中断,则可能不会接收),那么它就会在 CPU 的一个引脚上面声明。这就是步骤3

image.png

在第四步中,中断控制器把该设备的编号放在总线上,这样 CPU 可以读取总线,并且知道哪个设备完成了操作(可能同时有多个设备同时运行)。

一旦 CPU 决定去实施中断后,程序计数器和 PSW 就会被压入到当前堆栈中并且 CPU 会切换到内核态。设备编号可以作为内存的一个引用,用来寻找该设备中断处理程序的地址。这部分内存称作中断向量(interrupt vector)。一旦中断处理程序(中断设备的设备驱动程序的一部分)开始后,它会移除栈中的程序计数器和 PSW 寄存器,并把它们进行保存,然后查询设备的状态。在中断处理程序全部完成后,它会返回到先前用户程序尚未执行的第一条指令,这个过程如下

image.png

  • 实现 I/O 的第三种方式是使用特殊的硬件:直接存储器访问(Direct Memory Access, DMA) 芯片。它可以控制内存和某些控制器之间的位流,而无需 CPU 的干预。CPU 会对 DMA 芯片进行设置,说明需要传送的字节数,有关的设备和内存地址以及操作方向。当 DMA 芯片完成后,会造成中断,中断过程就像上面描述的那样。我们会在后面具体讨论中断过程

当另一个中断处理程序正在运行时,中断可能(并且经常)发生在不合宜的时间。 因此,CPU 可以禁用中断,并且可以在之后重启中断。在 CPU 关闭中断后,任何已经发出中断的设备,可以继续保持其中断信号处理,但是 CPU 不会中断,直至中断再次启用为止。如果在关闭中断时,已经有多个设备发出了中断信号,中断控制器将决定优先处理哪个中断,通常这取决于事先赋予每个设备的优先级,最高优先级的设备优先赢得中断权,其他设备则必须等待。

总线

上面的结构(简单个人计算机的组件图)在小型计算机已经使用了多年,并用在早期的 IBM PC 中。然而,随着处理器核内存变得越来越快,单个总线处理所有请求的能力也达到了上线,其中也包括 IBM PC 总线。必须放弃使用这种模式。其结果导致了其他总线的出现,它们处理 I/O 设备以及 CPU 到存储器的速度都更快。这种演变的结果导致了下面这种结构的出现。

image.png

上图中的 x86 系统包含很多总线,高速缓存、内存、PCIe、PCI、USB、SATA 和 DMI,每条总线都有不同的传输速率和功能。操作系统必须了解所有的总线配置和管理。其中最主要的总线是 PCIe(Peripheral Component Interconnect Express) 总线。

Intel 发明的 PCIe 总线也是作为之前古老的 PCI 总线的继承者,而古老的 PCI 总线也是为了取代古董级别的 ISA(Industry Standard Architecture) 总线而设立的。数十 Gb/s 的传输能力使得 PCIe 比它的前身快很多,而且它们本质上也十分不同。直到发明 PCIe 的 2004 年,大多数总线都是并行且共享的。共享总线架构(shared bus architeture) 表示多个设备使用一些相同的电线传输数据。因此,当多个设备同时发送数据时,此时你需要一个决策者来决定谁能够使用总线。而 PCIe 则不一样,它使用专门的端到端链路。传统 PCI 中使用的并行总线架构(parallel bus architecture) 表示通过多条电线发送相同的数据字。例如,在传统的 PCI 总线上,一个 32 位数据通过 32 条并行的电线发送。而 PCIe 则不同,它选用了串行总线架构(serial bus architecture) ,并通过单个连接(称为通道)发送消息中的所有比特数据,就像网络数据包一样。这样做会简化很多,因为不再确保所有 32 位数据在同一时刻准确到达相同的目的地。通过将多个数据通路并行起来,并行性仍可以有效利用。例如,可以使用 32 条数据通道并行传输 32 条消息。

在上图结构中,CPU 通过 DDR3 总线与内存对话,通过 PCIe 总线与外围图形设备 (GPU)对话,通过 DMI(Direct Media Interface)总线经集成中心与所有其他设备对话。而集成控制中心通过串行总线与 USB 设备对话,通过 SATA 总线与硬盘和 DVD 驱动器对话,通过 PCIe 传输以太网络帧。

不仅如此,每一个核

USB(Univversal Serial Bus) 是用来将所有慢速 I/O 设备(比如键盘和鼠标)与计算机相连的设备。USB 1.0 可以处理总计 12 Mb/s 的负载,而 USB 2.0 将总线速度提高到 480Mb/s ,而 USB 3.0 能达到不小于 5Gb/s 的速率。所有的 USB 设备都可以直接连接到计算机并能够立刻开始工作,而不像之前那样要求重启计算机。

SCSI(Small Computer System Interface) 总线是一种高速总线,用在高速硬盘,扫描仪和其他需要较大带宽的设备上。现在,它们主要用在服务器和工作站中,速度可以达到 640MB/s 。

计算机启动过程

那么有了上面一些硬件再加上操作系统的支持,我们的计算机就可以开始工作了,那么计算机的启动过程是怎样的呢?下面只是一个简要版的启动过程

在每台计算机上有一块双亲板,也就是母板,母板也就是主板,它是计算机最基本也就是最重要的部件之一。主板一般为矩形电路板,上面安装了组成计算机的主要电路系统,一般有 BIOS 芯片、I/O 控制芯片、键盘和面板控制开关接口、指示灯插接件、扩充插槽、主板及插卡的直流电源供电接插件等元件。

在母板上有一个称为 基本输入输出系统(Basic Input Output System, BIOS)的程序。在 BIOS 内有底层 I/O 软件,包括读键盘、写屏幕、磁盘I/O 以及其他过程。如今,它被保存在闪存中,它是非易失性的,但是当BIOS 中发现错误时,可以由操作系统进行更新。

在计算机启动(booted)时,BIOS 开启,它会首先检查所安装的 RAM 的数量,键盘和其他基础设备是否已安装并且正常响应。接着,它开始扫描 PCIe 和 PCI 总线并找出连在上面的所有设备。即插即用的设备也会被记录下来。如果现有的设备和系统上一次启动时的设备不同,则新的设备将被重新配置。

蓝后,BIOS 通过尝试存储在 CMOS 存储器中的设备清单尝试启动设备

CMOS是 Complementary Metal Oxide Semiconductor(互补金属氧化物半导体)的缩写。它是指制造大规模集成电路芯片用的一种技术或用这种技术制造出来的芯片,是电脑主板上的一块可读写的 RAM 芯片。因为可读写的特性,所以在电脑主板上用来保存 BIOS 设置完电脑硬件参数后的数据,这个芯片仅仅是用来存放数据的。

而对 BIOS 中各项参数的设定要通过专门的程序。BIOS 设置程序一般都被厂商整合在芯片中,在开机时通过特定的按键就可进入 BIOS 设置程序,方便地对系统进行设置。因此 BIOS 设置有时也被叫做 CMOS 设置。

用户可以在系统启动后进入一个 BIOS 配置程序,对设备清单进行修改。然后,判断是否能够从外部 CD-ROM 和 USB 驱动程序启动,如果启动失败的话(也就是没有),系统将从硬盘启动,boots 设备中的第一个扇区被读入内存并执行。该扇区包含一个程序,该程序通常在引导扇区末尾检查分区表以确定哪个分区处于活动状态。然后从该分区读入第二个启动加载程序,该加载器从活动分区中读取操作系统并启动它。

然后操作系统会询问 BIOS 获取配置信息。对于每个设备来说,会检查是否有设备驱动程序。如果没有,则会向用户询问是否需要插入 CD-ROM 驱动(由设备制造商提供)或者从 Internet 上下载。一旦有了设备驱动程序,操作系统会把它们加载到内核中,然后初始化表,创建所需的后台进程,并启动登录程序或GUI。

操作系统博物馆

操作系统已经存在了大半个世纪,在这段时期内,出现了各种类型的操作系统,但并不是所有的操作系统都很出名,下面就罗列一些比较出名的操作系统

大型机操作系统

高端一些的操作系统是大型机操作系统,这些大型操作系统可在大型公司的数据中心找到。这些计算机的 I/O 容量与个人计算机不同。一个大型计算机有 1000 个磁盘和数百万 G 字节的容量是很正常,如果有这样一台个人计算机朋友会很羡慕。大型机也在高端 Web 服务器、大型电子商务服务站点上。

服务器操作系统

下一个层次是服务器操作系统。它们运行在服务器上,服务器可以是大型个人计算机、工作站甚至是大型机。它们通过网络为若干用户服务,并且允许用户共享硬件和软件资源。服务器可提供打印服务、文件服务或 Web 服务。Internet 服务商运行着许多台服务器机器,为用户提供支持,使 Web 站点保存 Web 页面并处理进来的请求。典型的服务器操作系统有 Solaris、FreeBSD、Linux 和 Windows Server 201x

多处理器操作系统

获得大型计算能力的一种越来越普遍的方式是将多个 CPU 连接到一个系统中。依据它们连接方式和共享方式的不同,这些系统称为并行计算机,多计算机或多处理器。他们需要专门的操作系统,不过通常采用的操作系统是配有通信、连接和一致性等专门功能的服务器操作系统的变体。

个人计算机中近来出现了多核芯片,所以常规的台式机和笔记本电脑操作系统也开始与小规模多处理器打交道,而核的数量正在与时俱进。许多主流操作系统比如 Windows 和 Linux 都可以运行在多核处理器上。

个人计算机系统

接下来一类是个人计算机操作系统。现代个人计算机操作系统支持多道处理程序。在启动时,通常有几十个程序开始运行,它们的功能是为单个用户提供良好的支持。这类系统广泛用于字处理、电子表格、游戏和 Internet 访问。常见的例子是 Linux、FreeBSD、Windows 7、Windows 8 和苹果公司的 OS X 。

掌上计算机操作系统

随着硬件越来越小化,我们看到了平板电脑、智能手机和其他掌上计算机系统。掌上计算机或者 PDA(Personal Digital Assistant),个人数字助理 是一种可以握在手中操作的小型计算机。这部分市场已经被谷歌的 Android 系统和苹果的 IOS 主导。

嵌入式操作系统

嵌入式操作系统用来控制设备的计算机中运行,这种设备不是一般意义上的计算机,并且不允许用户安装软件。典型的例子有微波炉、汽车、DVD 刻录机、移动电话以及 MP3 播放器一类的设备。所有的软件都运行在 ROM 中,这意味着应用程序之间不存在保护,从而获得某种简化。主要的嵌入式系统有 Linux、QNX 和 VxWorks

传感器节点操作系统呢

有许多用途需要配置微小传感器节点网络。这些节点是一种可以彼此通信并且使用无线通信基站的微型计算机。这类传感器网络可以用于建筑物周边保护、国土边界保卫、森林火灾探测、气象预测用的温度和降水测量等。

每个传感器节点是一个配有 CPU、RAM、ROM 以及一个或多个环境传感器的实实在在的计算机。节点上运行一个小型但是真是的操作系统,通常这个操作系统是事件驱动的,可以响应外部事件。

实时操作系统

另一类操作系统是实时操作系统,这些系统的特征是将时间作为关键参数。例如,在工业过程控制系统中,工厂中的实时计算机必须收集生产过程的数据并用有关数据控制机器。如果某个动作必须要在规定的时刻发生,这就是硬实时系统。可以在工业控制、民用航空、军事以及类似应用中看到很多这样的系统。另一类系统是 软实时系统,在这种系统中,虽然不希望偶尔违反最终时限,但仍可以接受,并不会引起任何永久性损害。数字音频或多媒体系统就是这类系统。智能手机也是软实时系统。

智能卡操作系统

最小的操作系统运行在智能卡上。智能卡是一种包含一块 CPU 芯片的信用卡。它有非常严格的运行能耗和存储空间的限制。有些卡具有单项功能,如电子支付;有些智能卡是面向 Java 的。这意味着在智能卡的 ROM 中有一个 Java 虚拟机(Java Virtual Machine, JVM)解释器。

操作系统概念

大部分操作系统提供了特定的基础概念和抽象,例如进程、地址空间、文件等,它们是需要理解的核心内容。下面我们会简要介绍一些基本概念,为了说明这些概念,我们会不时的从 UNIX 中提出示例,相同的示例也会存在于其他系统中,我们后面会进行介绍。

文章参考:

《现代操作系统》第四版

https://baike.baidu.com/item/...

《Modern Operating System》forth edition

http://faculty.cs.niu.edu/~hu...

https://www.computerhope.com/...

《B站-操作系统》

https://www.bilibili.com/vide...

image.png

查看原文

赞 4 收藏 2 评论 0

程序员cxuan 发布了文章 · 2月5日

你要问我应用层?我就和你扯扯扯

网络应用是计算机网络存在的理由,一批早起的网络应用主要有电子邮件、远程访问、文件传输等,但是随着计算机网络的发展和人类无穷无尽的需求,越来越多的网络应用被开发出来,例如即时通讯和对等(P2P)文件共享,IP 电话、视频会议等。还有一些多方在线游戏被开发出来如《魔兽世界》等,可以说计算机网络是一切应用演变出来的基础。人要怀有一颗感恩的心,感谢这些前辈的努力,才让我们现在的生活如此丰富多彩。但是我们作为程序员,不仅要能够享受这些成果,还要知道为什么,这样生活才会和谐。

应用层协议原理

研发网络应用程序的核心是写出能够运行在不同的端系统和通过网络彼此通信的程序。例如,在网络应用程序中,有两个互相通信的不同程序:一个是运行在用户主机上的浏览器程序;另一个是运行在 Web 服务器主机上的 Web 服务器程序

网络应用程序体系结构

网络应用程序的体系结构(application architecture)主要有两种,一种是 客户-服务器体系结构(client-server architecture) ,在客户-服务器体系结构中,有一个持续打开,等待连接的主机称为服务器,它服务于来自许多其他称为 客户 的主机请求。比如 Web 服务器总会等待来自浏览器(运行在客户主机上)的请求。注意这种客户-服务器体系结构中,客户之间是不会彼此交流信息的,它们只与相应的服务器进行通信。还有一点是服务器具有固定的 IP 地址。下图显示了这种体系结构

image.png

这种客户-服务器体系结构存在弊端,那就是有的时候服务器的响应跟不上客户请求速度的情况,鉴于此,这种体系结构需要经常配备数据中心(data center)用来创建更强大的服务器。例如搜索引擎(谷歌、Bing和百度)互联网商店(亚马逊、e-Bay 和阿里巴巴)、基于 Web 的电子邮件(Gmail 和 雅虎)社交网络(脸书、Instagram、推特和微信),就是用了多个数据中心。

另外一种体系结构是 P2P体系结构(P2P architecture),相对于对数据中心有过多依赖的客户-服务器体系结构,P2P 体系结构则直接通过两台相连的主机直接通信,这些主机称为对等方。典型的 P2P 体系结构的应用包括 文件共享(BitTorrent)下载器(迅雷)互联网电话和视频会议(Skype),下图显示了 P2P 体系结构图

image.png

P2P 体系结构最重要的一个特性就是它的自扩展性(self-scalability)。例如,在一个 P2P 文件共享的应用中,尽管每个对等方都由于请求文件产生工作负载,但每个对等方通过向其他对等方分发文件也为系统增加服务器能力。

进程通信

我们上面说到了两种体系结构,一种是客户-服务器模式,一种是P2P 对等模式。我们都知道一个计算机允许同时运行多个应用程序,在我们看起来这些应用程序好像是同时运行的,那么它们之间是如何通信的呢?不可能存在同是一个母亲,兄弟俩不交流的情况吧。

用操作系统的术语来说,进行通信实际上是 进程(process)而不是程序。一个进程可以被认为是运行在端系统中的程序。当多个进程运行在相同的端系统上,它们使用进程间的通信机制相互通信。进程间的通信规则由操作系统来确定。我们暂不关心运行在同一主机上不同应用程序是如何通信的,我们主要探讨的目标是不同端系统中两个进程是如何通信的。还是分为两种结构来探讨。

客户和服务器进程

网络应用程序由成对的进程组成,这些进程通过网络相互发送报文。例如,在 Web 应用程序中,文件从一个对等方中的进程传输到另一个对等方中的进程。而在每对通信的进程中,都会有一对客户(client)服务器(server) 存在。比如我们上面提到的 Web ,对于 Web 来说,浏览器是一个客户进程,而 Web 服务器是一台服务器进程。也许你也应该能猜到,在 P2P 体系结构中,一个进程能够扮演两种角色,既是客户又是服务器的情况。但是在实际通信的过程中,我们还是很容易区分的,我们通常通过下面这种方式进行区分。

在一对进程之间的通信会话场景中,发起通信(即在会话开始时发起与其他进程的联系)的进程称为客户,在会话开始时等待联系的被称为服务器

进程与计算机网络之间的接口

计算机是庞大且繁杂的,计算机网络也是,应用程序不可能只有一个进程组成,它同样是多个进程共同作用协商运行,然而,分布在多个端系统之间的进程是如何进行通信的呢?实际上,每个进程之间会有一个 套接字(socket) 的软件接口存在,套接字是应用程序的内部接口,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序将 I/O 插入到网络中,并与网络中的其他应用程序进行通信。

通过一个实例来简单类比一下套接字和网络进程:进程可类比一座房子,而它的套接字相当于是房子的门,当一个进程想要与其他进程进行通信时,它会把报文推出门外,然后通过运输设备把报文运输到另外一座房子,通过门进入房子内部使用。

下图是一个通过套接字进行通信的流程图

image.png

从图可以看到,Socket 属于主机或者服务进程的内部接口,由应用程序开发人员进行控制,两台端系统之间进行通信会通过 TCP 的缓冲区经由网络传输到另一个端系统的 TCP 缓冲区,Socket 从 TCP 缓冲区读取报文供应用程序内部使用。

套接字是建立网络应用程序的可编程接口,因此套接字也被称为应用程序和网络之间的 应用程序编程接口(Application Programming Interface,API)。应用程序开发人员可以控制套接字内部细节,但是无法控制运输层的传输,只能对运输层的传输协议进行选择,还可以对运输层的传输参数进行选择,比如最大缓存和最大报文长度等。

进程寻址

我们上面提到网络应用程序之间会相互发送报文,那么你怎么知道你应该向哪里发送报文呢?是不是存在某种机制能够让你知道你能够发到哪里?这就好比你要发送电子邮件,你写好了内容但是你不知道发发往哪里,所以这个时候必须要有一种知道对方地址的机制,这种机制能够辨明对方唯一的一个地址,这种地址就是 IP地址。我们会在后面的文章中详细讨论 IP 地址的内容,目前只需要知道 IP 是一个32比特的量并且能够唯一标示互联网中任意一台主机的地址就可以了。

只知道 IP 地址是否就可以了呢?我们知道一台计算机可能回运行多个网络应用程序,那么如何确定是哪个网络应用程序接受发送过来的报文呢?所以这时候还需要知道网络应用程序的 端口号(port number)。例如, Web 应用程序需要用 80 端口来标示,邮件服务器程序需要使用 25 来标示。

应用程序如何选择运输服务

我们知道应用程序是属于互联网四层协议的 应用层 协议,并且四层协议必须彼此协助共同完成工作。好了,这时候我们只有应用层协议,我们需要发送报文,我们如何发送报文呢?这就好比你知道目的地是哪里了,你该如何到达目的地呢?是走路,公交,地铁还是打车?

应用程序发送报文的交通工具的选择也有很多,我们可以从 数据传输是否可靠、吞吐量、定时和安全性 来考虑,下面是你需要考虑的具体内容。

  • 数据传输是否可靠

我们之前探讨过,分组在计算机网络中会存在丢包问题,丢包问题的严重性跟网络应用程序的性质有关,如果像是电子邮件、文件传输、远程主机、Web 文档传输的过程中出现问题,数据丢失可能会造成非常严重的后果。如果像是网络游戏,多人视频会议造成的影响可能比较小。鉴于此,数据传输的可靠性也是首先需要考虑的问题。因此,如果一个协议提供了这样的确保数据交付的服务,就认为提供了 可靠数据传输(reliable data transfer),能够忍受数据丢失的应用被称为 容忍丢失的应用(loss-tolerant application)

  • 吞吐量

在之前的文章中我们引入了吞吐量的概念,吞吐量就是在网络应用中数据传输过程中,发送进程能够向接收进程交付比特的速率。具有吞吐量要求的应用程序被称为 带宽敏感的应用(bandwidth-sensitive application)。带宽敏感的应用具有特定的吞吐量要求,而 弹性应用(elastic application) 能够根据当时可用的带宽或多或少地利用可供使用的吞吐量。

  • 定时

定时是什么意思?定时能够确保网络中两个应用程序的收发是否能够在指定的时间内完成,这也是应用程序选择运输服务需要考虑的一个因素,这听起来很自然,你网络应用发送和接收数据包肯定要加以时间的概念,比如在游戏中,你一包数据迟迟发送不过去,对面都推塔了你还卡在半路上呢。

  • 安全性

最后,选择运输协议一定要能够为应用程序提供一种或多种安全性服务。

因特网能够提供的运输服务

说完运输服务的选型,接下来该聊一聊因特网能够提供哪些服务了。实际上,因特网为应用程序提供了两种运输层的协议,即 UDPTCP,下面是一些网络应用的选择要求,可以根据需要来选择适合的运输层协议。

应用数据丢失带宽时间敏感
文件传输不能丢失弹性不敏感
电子邮件不能丢失弹性不敏感
Web 文档不能丢失弹性不敏感
因特网电话/视频会议容忍丢失弹性敏感,100ms
流式存储音频/视频容忍丢失弹性敏感,几秒
交互式游戏容忍丢失弹性是,100ms
智能手机消息不能丢失弹性无所谓

下面我们就来聊一聊这两种运输协议的应用场景

TCP

TCP 服务模型的特性主要有下面几种

  • 面向连接的服务

在应用层数据报发送后, TCP 让客户端和服务器互相交换运输层控制信息。这个握手过程就是提醒客户端和服务器需要准备好接受数据报。握手阶段后,一个 TCP 连接(TCP Connection) 就建立了。这是一条全双工的连接,即连接双方的进程都可以在此连接上同时进行收发报文。当应用程序结束报文发送后,必须拆除连接。

  • 可靠的数据传输

通信进程能够依靠 TCP,无差错、按适当顺序交付所有发送的数据。应用程序能够依靠 TCP 将相同的字节流交付给接收方的套接字,没有字节的丢失和冗余。

  • 拥塞控制

TCP 的拥塞控制并不一定为通信进程带来直接好处,但能为因特网带来整体好处。当接收方和发送方之间的网络出现拥塞时,TCP 的拥塞控制会抑制发送进程(客户端或服务器),我们会在后面具体探讨拥塞控制

UDP

UDP 是一种轻量级的运输协议,它仅提供最小服务。UDP 是无连接的,因此在两个进程通信前没有握手过程。UDP 也不会保证报文是否传输到服务端,它就像是一个撒手掌柜。不仅如此,到达接收进程的报文也可能是乱序到达的。

下面是上表列出来的一些应用所选择的协议

应用应用层协议支撑的运输协议
电子邮件SMTPTCP
远程终端访问TelnetTCP
WebHTTPTCP
文件传输FTPTCP
流式多媒体HTTPTCP
因特网电话SIP、RTPTCP或UDP

应用层协议

现在我们会探讨一些应用层协议,首先来认识一下什么是 应用层协议,应用层协议(application-layer protocol) 定义了运行在不同端系统上的应用进程如何相互传递报文。

应用层协议会定义

  • 交换的报文类型,如请求报文和响应报文;
  • 各种报文类型的语法,如报文中的各个字段公共详细描述;
  • 字段的语义,即包含在字段中信息的含义;
  • 进程何时、如何发送报文及对报文进行响应。

应用层协议分类

  • 域名系统(Domain Name System, DNS):用于实现网络设备名字到 IP 地址映射的网络服务。
  • 文件传输协议(File Transfer Protocol,FTP):用于实现交互式文件传输功能。
  • 邮件传送协议(Simple Mail Transfer Protocol, SMTP):用于实现电子邮箱传送功能。
  • 超文本传输协议(HyperText Transfer Protocol,HTTP):用于实现 Web 服务。
  • 远程登录协议(Telnet):用于实现远程登录功能。

Web 和 HTTP

Web(World Wide Web)即全球广域网,也就是 URL 为 www 开头的网络,它是 HTTP 协议的主要载体,是建立在 Internet 上的一种网络服务,我们一般讲的 Web ,其实就是指的 HTTP 协议,HTTP 协议作为 web 程序员必须要掌握并理解的一门协议,有必要好好了解一下。

超文本传输协议可以进行文字分割:超文本(Hypertext)、传输(Transfer)、协议(Protocol),它们之间的关系如下

image.png

按照范围的大小 协议 > 传输 > 超文本。下面就分别对这三个名次做一个解释。

什么是超文本

在互联网早期的时候,我们输入的信息只能保存在本地,无法和其他电脑进行交互。我们保存的信息通常都以文本即简单字符的形式存在,文本是一种能够被计算机解析的有意义的二进制数据包。而随着互联网的高速发展,两台电脑之间能够进行数据的传输后,人们不满足只能在两台电脑之间传输文字,还想要传输图片、音频、视频,甚至点击文字或图片能够进行超链接的跳转,那么文本的语义就被扩大了,这种语义扩大后的文本就被称为超文本(Hypertext)

什么是传输

那么我们上面说到,两台计算机之间会形成互联关系进行通信,我们存储的超文本会被解析成为二进制数据包,由传输载体(例如同轴电缆,电话线,光缆)负责把二进制数据包由计算机终端传输到另一个终端的过程(对终端的详细解释可以参考 你说你懂互联网,那这些你知道么?这篇文章)称为传输(transfer)

通常我们把传输数据包的一方称为请求方,把接到二进制数据包的一方称为应答方。请求方和应答方可以进行互换,请求方也可以作为应答方接受数据,应答方也可以作为请求方请求数据,它们之间的关系如下

image.png

如图所示,A 和 B 是两个不同的端系统,它们之间可以作为信息交换的载体存在,刚开始的时候是 A 作为请求方请求与 B 交换信息,B 作为响应的一方提供信息;随着时间的推移,B 也可以作为请求方请求 A 交换信息,那么 A 也可以作为响应方响应 B 请求的信息。

什么是协议

协议这个名词不仅局限于互联网范畴,也体现在日常生活中,比如情侣双方约定好在哪个地点吃饭,这个约定也是一种协议,比如你应聘成功了,企业会和你签订劳动合同,这种双方的雇佣关系也是一种 协议。注意自己一个人对自己的约定不能成为协议,协议的前提条件必须是多人约定。

那么网络协议是什么呢?

网络协议就是网络中(包括互联网)传递、管理信息的一些规范。如同人与人之间相互交流是需要遵循一定的规矩一样,计算机之间的相互通信需要共同遵守一定的规则,这些规则就称为网络协议。

没有网络协议的互联网是混乱的,就和人类社会一样,人不能想怎么样就怎么样,你的行为约束是受到法律的约束的;那么互联网中的端系统也不能自己想发什么发什么,也是需要受到通信协议约束的。

那么我们就可以总结一下,什么是 HTTP?可以用下面这个经典的总结回答一下: HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范

持久性连接和非持久性连接

HTTP 是可以使用持久性连接和非持久性连接的,下面我们着重探讨一下这两种方式

非持久性连接

我们首先来探讨一下持久性连接的 HTTP

你是不是很好奇,当你在浏览器中输入网址后,到底发生了什么事情?你想要的内容是如何展现出来的?让我们通过一个例子来探讨一下,我们假设访问的 URL 地址为 http://www.someSchool.edu/someDepartment/home.index,当我们输入网址并点击回车时,浏览器内部会进行如下操作

  • DNS 服务器会首先进行域名的映射,找到访问www.someSchool.edu所在的地址,然后 HTTP 客户端进程在 80 端口发起一个到服务器 www.someSchool.edu 的 TCP 连接(80 端口是 HTTP 的默认端口)。在客户和服务器进程中都会有一个套接字与其相连。
  • HTTP 客户端通过它的套接字向服务器发送一个 HTTP 请求报文。该报文中包含了路径 someDepartment/home.index 的资源,我们后面会详细讨论 HTTP 请求报文。
  • HTTP 服务器通过它的套接字接受该报文,进行请求的解析工作,并从其存储器(RAM 或磁盘)中检索出对象 www.someSchool.edu/someDepartment/home.index,然后把检索出来的对象进行封装,封装到 HTTP 响应报文中,并通过套接字向客户进行发送。
  • HTTP 服务器随即通知 TCP 断开 TCP 连接,实际上是需要等到客户接受完响应报文后才会断开 TCP 连接。
  • HTTP 客户端接受完响应报文后,TCP 连接会关闭。HTTP 客户端从响应中提取出报文中是一个 HTML 响应文件,并检查该 HTML 文件,然后循环检查报文中其他内部对象。
  • 检查完成后,HTTP 客户端会把对应的资源通过显示器呈现给用户。

至此,键入网址再按下回车的全过程就结束了。上述过程描述的是一种简单的请求-响应全过程,真实的请求-响应情况可能要比上面描述的过程复杂很多。

上面的步骤举例说明了非持久性连接的使用,其中每个 TCP 链接都在服务器发送完成后关闭。每个 TCP 连接只传输一个请求报文和响应报文。

持久性连接的 HTTP

非持久性连接有一些缺点。第一,必须为每个请求的对象建立和维护一个全新的连接。对于每个这样的连接来说,在客户端和服务器中都要分配 TCP 的缓冲区和保持 TCP 变量,这给 Web 服务器带来了严重的负担。因为一台 Web 服务器可能要同时服务于数百甚至上千个客户请求。

在采用 HTTP 1.1 持续连接的情况下,服务器在发送响应后保持该 TCP 连接打开不关闭。在相同的客户与服务器之间,后续的请求和响应报文能够通过相同的连接进行传送。一般来说,如果一跳连接经过一定的时间间隔(可配置)后仍未使用,HTTP 服务器就应该关闭其连接。

HTTP 报文格式

我们上面描述了一下 HTTP 的请求响应过程,流程比较简单,但是凡事就怕认真,你这一认真,就能拓展出很多东西,比如 HTTP 报文是什么样的,它的组成格式是什么? 下面就来探讨一下

HTTP 协议主要由三大部分组成:

  • 起始行(start line):描述请求或响应的基本信息;
  • 头部字段(header):使用 key-value 形式更详细地说明报文;
  • 消息正文(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据。

其中起始行和头部字段并成为 请求头 或者 响应头,统称为 Header;消息正文也叫做实体,称为 body。HTTP 协议规定每次发送的报文必须要有 Header,但是可以没有 body,也就是说头信息是必须的,实体信息可以没有。而且在 header 和 body 之间必须要有一个空行(CRLF),如果用一幅图来表示一下的话,我觉得应该是下面这样

image.png

我们使用上面的那个例子来看一下 http 的请求报文

image.png

如图,这是 http://www.someSchool.edu/someDepartment/home.index 请求的请求头,通过观察这个 HTTP 报文我们就能够学到很多东西,首先,我们看到报文是用普通 ASCII 文本书写的,这样保证人能够可以看懂。然后,我们可以看到每一行和下一行之间都会有换行,而且最后一行(请求头部后)再加上一个回车换行符。

每个报文的起始行都是由三个字段组成:方法、URL 字段和 HTTP 版本字段

image.png

HTTP 请求方法

HTTP 请求方法一般分为 8 种,它们分别是

  • GET 获取资源,GET 方法用来请求访问已被 URI 识别的资源。指定的资源经服务器端解析后返回响应内容。也就是说,如果请求的资源是文本,那就保持原样返回;
  • POST 传输实体,虽然 GET 方法也可以传输主体信息,但是便于区分,我们一般不用 GET 传输实体信息,反而使用 POST 传输实体信息,
  • PUT 传输文件,PUT 方法用来传输文件。就像 FTP 协议的文件上传一样,要求在请求报文的主体中包含文件内容,然后保存到请求 URI 指定的位置。

    但是,鉴于 HTTP 的 PUT 方法自身不带验证机制,任何人都可以上传文件 , 存在安全性问题,因此一般的 W eb 网站不使用该方法。若配合 W eb 应用程序的验证机制,或架构设计采用 REST(REpresentational State Transfer,表征状态转移)标准的同类 Web 网站,就可能会开放使用 PUT 方法。

  • HEAD 获得响应首部,HEAD 方法和 GET 方法一样,只是不返回报文主体部分。用于确认 URI 的有效性及资源更新的日期时间等。
  • DELETE 删除文件,DELETE 方法用来删除文件,是与 PUT 相反的方法。DELETE 方法按请求 URI 删除指定的资源。
  • OPTIONS 询问支持的方法,OPTIONS 方法用来查询针对请求 URI 指定的资源支持的方法。
  • TRACE 追踪路径,TRACE 方法是让 Web 服务器端将之前的请求通信环回给客户端的方法。
  • CONNECT 要求用隧道协议连接代理,CONNECT 方法要求在与代理服务器通信时建立隧道,实现用隧道协议进行 TCP 通信。主要使用 SSL(Secure Sockets Layer,安全套接层)和 TLS(Transport Layer Security,传输层安全)协议把通信内容加 密后经网络隧道传输。

我们一般最常用的方法也就是 GET 方法和 POST 方法,其他方法暂时了解即可。下面是 HTTP1.0 和 HTTP1.1 支持的方法清单

image.png

HTTP 请求 URL

HTTP 协议使用 URI 定位互联网上的资源。正是因为 URI 的特定功能,在互联网上任意位置的资源都能访问到。URL 带有请求对象的标识符。在上面的例子中,浏览器正在请求对象 /somedir/page.html 的资源。

我们再通过一个完整的域名解析一下 URL

比如 http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument 这个 URL 比较繁琐了吧,你把这个 URL 搞懂了其他的 URL 也就不成问题了。

首先出场的是 http

image.png

http://告诉浏览器使用何种协议。对于大部分 Web 资源,通常使用 HTTP 协议或其安全版本,HTTPS 协议。另外,浏览器也知道如何处理其他协议。例如, mailto: 协议指示浏览器打开邮件客户端;ftp:协议指示浏览器处理文件传输。

第二个出场的是 主机

image.png

www.example.com 既是一个域名,也代表管理该域名的机构。它指示了需要向网络上的哪一台主机发起请求。当然,也可以直接向主机的 IP address 地址发起请求。但直接使用 IP 地址的场景并不常见。

第三个出场的是 端口

image.png

我们前面说到,两个主机之间要发起 TCP 连接需要两个条件,主机 + 端口。它表示用于访问 Web 服务器上资源的入口。如果访问的该 Web 服务器使用HTTP协议的标准端口(HTTP为80,HTTPS为443)授予对其资源的访问权限,则通常省略此部分。否则端口就是 URI 必须的部分。

上面是请求 URL 所必须包含的部分,下面就是 URL 具体请求资源路径

第四个出场的是 路径

image.png

/path/to/myfile.html 是 Web 服务器上资源的路径。以端口后面的第一个 / 开始,到 ? 号之前结束,中间的 每一个/ 都代表了层级(上下级)关系。这个 URL 的请求资源是一个 html 页面。

紧跟着路径后面的是 查询参数

image.png

?key1=value1&key2=value2 是提供给 Web 服务器的额外参数。如果是 GET 请求,一般带有请求 URL 参数,如果是 POST 请求,则不会在路径后面直接加参数。这些参数是用 & 符号分隔的键/值对列表。key1 = value1 是第一对,key2 = value2 是第二对参数

紧跟着参数的是锚点

image.png

#SomewhereInTheDocument 是资源本身的某一部分的一个锚点。锚点代表资源内的一种“书签”,它给予浏览器显示位于该“加书签”点的内容的指示。 例如,在HTML文档上,浏览器将滚动到定义锚点的那个点上;在视频或音频文档上,浏览器将转到锚点代表的那个时间。值得注意的是 # 号后面的部分,也称为片段标识符,永远不会与请求一起发送到服务器。

更多有关 HTTP1.1 的内容可以参考博主的这三篇博文,我感觉已经把 HTTP 讲清楚了

看完这篇HTTP,跟面试官扯皮就没问题了

你还在为 HTTP 的这些概念头疼吗?

震惊 | HTTP 在疫情期间把我吓得不敢出门了

因特网中的电子邮件

自从有了因特网,电子邮件就在因特网上流行起来。与普通邮件一样,电子邮件是一种异步通信媒介,即人们方便的情况下就可以和他人进行邮件往来,而不必与他人进行沟通后在发送。现代电子邮件具有许多强大的特性,包括具有附件、超链接、HTML 格式文本和图片的报文。下面是电子邮件系统的总体概览

image.png

从图中我们可以看到它有三个主要组成部分:用户代理(user agent)邮件服务器(mail server)、和简单邮件传输协议(Simple Mail Transfer Protocol,SMTP)。下面我们就来描述一下邮件收发的过程。

用户代理允许用户阅读、回复、转发、保存和撰写报文。微软的 OutlookApple Mail 是电子邮件用户代理的例子。当用户编写完邮件时,他的用户代理向邮件服务器发送邮件,此时用户发送的邮件会放在邮件服务器的外出消息队列(Outgoing message queue)中,当接收方用户想要阅读邮件时,他的用户代理直接从外出消息队列中去取得该报文。

邮件服务器构成了整个邮件系统的核心。每个接收方在其中的邮件服务器上会有一个邮箱(mailbox) 存在。用户的邮箱管理和维护发送给他的报文。一个典型的邮件发送过程是:从发送方的用户代理开始,传输到发送方的邮件服务器,再传输到接收方的邮件服务器,然后在这里被分发到接收方的邮箱中。用接收方的用户想要从邮箱中读取邮件时,他的邮件服务器会对用户进行认证。如果发送方发送的邮件无法正确交付给接收方的服务器,那么发送方的用户代理会把邮件存储在一个报文队列(message queue)中,并在以后尝试再次发送,通常每30分钟发送一次,如果一段时间后还发送不成功,服务器就会删除报文队列中的邮件并以电子邮件的方式通知发送方。

SMTP 是因特网电子邮件中的主要的应用层协议。SMTP 也使用 TCP 作为运输层协议,保证数据传输的可靠性。

SMTP 协议传输过程

为了描述 SMTP 的基本操作,我们观察一下下面这种常见的情景。

image.png

我们假设 Alice 想给 Bob 发送一封简单的 ASCII 报文

  • Alice 调用她的邮件代理程序并提供 Bob 的邮件地址 (例如 bob@someschool.edu),编写邮件报文,然后指示用户代理发送该报文
  • Alice 的用户代理把报文发送给她的邮件服务器,在那里该报文被放在消息队列中。
  • 运行在 Alice 的邮件服务器上的 SMTP 客户端发现了报文队列中的邮件,它就创建一个到运行在 Bob 邮件服务器上的 SMTP 服务器的 TCP 连接
  • 在经过一些初始化 SMTP 握手后,SMTP 客户端通过该 TCP 连接发送 Alice 的邮件。
  • 在 Bob 的邮件服务器上,SMTP 的服务端接收该邮件,Bob 的邮件服务器将邮件放在 Bob 的邮箱中
  • 在 Bob 想要看邮件时,他会调用用户代理阅读该邮件
上面说的邮件其实就是报文,指的就是一系列 ASCII 码,SMTP 传输邮件之前,需要将二进制多媒体数据编码为 ASCII 码进行传输。

SMTP 一般不使用中间邮件服务器发送邮件,即使这两个邮件服务器位于地球的两端也是这样的。TCP 连接通常直接连接 Alice 的邮件服务器和 Bob 的邮件服务器。

现在你知道了两台邮件服务器邮件发送的大体过程,那么,SMTP 是如何将邮件从 Alice 邮件服务器发送到 Bob 的邮件服务器的呢?主要分为下面三个阶段

  • 建立连接:在这一阶段,SMTP 客户请求与服务器的25端口建立一个 TCP 连接。一旦连接建立,SMTP 服务器和客户就开始相互通告自己的域名,同时确认对方的域名。
  • 邮件传送:一旦连接建立后,就开始邮件传输。SMTP 依靠 TCP 能够将邮件准确无误地传输到接收方的邮件服务器中。SMTP 客户将邮件的源地址、目的地址和邮件的具体内容传递给 SMTP 服务器,SMTP 服务器进行相应的响应并接收邮件。
  • 连接释放:SMTP 客户发出退出命令,服务器在处理命令后进行响应,随后关闭 TCP 连接。

下面我们分析一个实际的 SMTP 邮件发送过程,以下统称为 SMTP客户(C)SMTP服务器(S)。客户的主机名为 crepes.fr,服务器的主机名为 hamburger.edu。以 C: 开头的 ASCII 码文本就是客户交给 TCP 套接字的那些行,以 S: 开头的 ASCII 码则是服务器发送给其 TCP 套接字的那些行。一旦创建了连接,就开始了如下过程

S: 220 hamburger.edu
C: HELO crepes.fr
S: 250 Hello crepes.fr, pleased to meet you
C: MAIL FROM: <alice@crepes.fr>
S: 250 alice@crepes.fr ... Sender ok
C: RCPT TO: <bob@hamburger.edu>
S: 250 bob@hamburder.edu ... Recipient ok
C: DATA
S: 354 Enter mail, end with "." on a line by itself
C: Do you like ketchup?
C: How about pickles?
C: .
S: 250 Message accepted for delivery
C: QUIT
S: 221 hamburger.edu closing connection

在上述例子中,客户从邮件服务器 crepes.fr 向邮件服务器 hamburger.edu 发送了一个报文 (" Do you like ketchup? How about pickles? ") 。作为对话的一部分,该客户发送了 5 条命令: HELO(是 HELLO 的缩写)MAMIL FROMRCPT TODATA 以及 QUIT。这些命令都是自解释的。

什么是自解释,就是不需要再进行解释了,命令自己就能解释自己所要表述的功能。

上面是一个简单的 SMTP 交换过程,包括了连接建立、邮件传送和连接释放三个具体过程

首先建立 TCP 连接、SMTP 调用 TCP 协议的25号端口监听连接请求,然后客户端发送 HELO 指令用来表明自己是发送方的身份,然后服务端作出响应。然后,客户端发送 MAIL FROM 命令,表明客户端的邮件地址是 <alice@crepes.fr> ,服务器以 OK 作为响应,表明准备接收。客户端发送 RCPT TO 表明接收方的电子邮件地址,可以有多个 RCPT 行,即一份邮件可以同时发送给多个收件人。服务器端则表示是否愿意为收件人接收邮件。协商结束后,客户端用 DATA 命令发送信息,结束标志是CRLF.CRLF ,也就是 回车换行.回车换行。最后,控制交互的任一端可选择终止会话,为此它发出一个 QUIT 命令,另一端用命令221响应,表示同意终止连接,双方将关闭连接。

上述过程中会涉及几个类似 HTTP 的状态码。250 就表示 OK ,类似 HTTP 的 200。在命令成功时,服务器返回代码250,如果失败则返回代码550(命令无法识别)、451(处理时出错)、452(存储空间不够)、421(服务器不可用)等,354则表示开始信息输入。

SMTP 的报文会有局限性,SMTP 的局限性表现在只能发送 ASCII 码格式的报文,不支持中文、法文、德文等,它也不支持语音、视频的数据。通过 MIME协议,对 SMTP 补充。MIME 使用网络虚拟终端(NVT)标准,允许非ASCII码数据通过SMTP传输。

SMTP 与 HTTP 的对比

HTTP 是我们学习的第一个应用层协议,SMTP 是我们学习的第二个应用层协议,那么我们就对这两个协议进行比对。

这两个协议都用于从一台主机向另一台主机传送文件:HTTP 从 Web 服务器向 Web 客户端(通常是浏览器)传送文件,SMTP 是从一个邮件服务器向另一个邮件服务器传送文件(即电子邮件报文)。

这两个协议也会有几个重要的区别

  • 首先,HTTP 是一个 拉协议(pull protocol),客户端发送请求,请求获取服务端的资源,然后服务端进行响应,把需要下载的文件传输给客户端;而 SMTP 是一个 推协议(push protocol),SMTP 的客户端会主动把邮件推送给 SMTP 的服务端。
  • 第二个区别是,SMTP 要求每个报文都采用 7 比特的 ASCII 码格式,如果某报文包含了非 7 比特的 ASCII 自负或二进制数据,则该报文必须按照7比特 ASCII 码进行编码。HTTP 数据则不受这种限制。
  • 第三个区别是如何处理一个既包含文本又包含图形的文档,HTTP 把每个对象封装到它自己的 HTTP 响应报文中,而 SMTP 则把所有报文对象放在一个报文之中。

DNS 因特网目录服务协议

试想一个问题,我们人类可以有多少种识别自己的方式?可以通过身份证来识别,可以通过社保卡号来识别,也可以通过驾驶证来识别,尽管我们有多种识别方式,但在特定的环境下,某种识别方法可能比另一种方法更为适合。因特网上的主机和人类一样,可以使用多种识别方式进行标识。互联网上主机的一种标识方法是使用它的 主机名(hostname) ,如 www.facebook.com、 www.google.com 等。但是这是我们人类的记忆方式,路由器不会这么理解,路由器喜欢定长的、有层次结构的 IP地址,so,还记得 IP 是什么吗?

IP 地址现在简单表述一下,就是一个由 4 字节组成,并有着严格的层次结构。例如 121.7.106.83 这样一个 IP 地址,其中的每个字节都可以用 . 进行分割,表示了 0 - 255 的十进制数字。(具体的 IP 我们会在后面讨论)

然而,路由器喜欢的是 IP 地址进行解析,我们人类却便于记忆的是网址,那么路由器如何把 IP 地址解析为我们熟悉的网址地址呢?这时候就需要 DNS 出现了。

image.png

DNS 的全称是 Domain Name System,DNS ,它是一个由分层的 DNS 服务器(DNS server)实现的分布式数据库;它还是一个使得主机能够查询分布式数据库的应用层协议。DNS 服务器通常是运行 BIND(Berkeley Internet Name Domain) 软件的 UNIX 机器。DNS 协议运行在 UDP 之上,使用 53 端口。

DNS 基本概述

与 HTTP、FTP 和 SMTP 一样,DNS 协议也是应用层的协议,DNS 使用客户-服务器模式运行在通信的端系统之间,在通信的端系统之间通过下面的端到端运输协议来传送 DNS 报文。但是 DNS 不是一个直接和用户打交道的应用。DNS 是为因特网上的用户应用程序以及其他软件提供一种核心功能。

DNS 通常不是一门独立的协议,它通常为其他应用层协议所使用,这些协议包括 HTTP、SMTP 和 FTP,将用户提供的主机名解析为 IP 地址。

下面根据一个示例来描述一下这个 DNS 解析过程,这个和你输入网址后,浏览器做了什么操作有异曲同工之处

你在浏览器键入 www.someschool.edu/index.html 时会发生什么现象?为了使用户主机能够将一个 HTTP 请求报文发送到 Web 服务器 www.someschool.edu ,会经历如下操作

  • 同一台用户主机上运行着 DNS 应用的客户端
  • 浏览器从上述 URL 中抽取出主机名 www.someschool.edu ,并将这台主机名传给 DNS 应用的客户端
  • DNS 客户向 DNS 服务器发送一个包含主机名的请求。
  • DNS 客户最终会收到一份回答报文,其中包含该目标主机的 IP 地址
  • 一旦浏览器收到目标主机的 IP 地址后,它就能够向位于该 IP 地址 80 端口的 HTTP 服务器进程发起一个 TCP 连接。

除了提供 IP 地址到主机名的转换,DNS 还提供了下面几种重要的服务

  • 主机别名(host aliasing),有着复杂的主机名的主机能够拥有一个或多个其他别名,比如说一台名为 relay1.west-coast.enterprise.com 的主机,同时会拥有 enterprise.com 和 www.enterprise.com 的两个主机别名,在这种情况下,relay1.west-coast.enterprise.com 也称为 规范主机名,而主机别名要比规范主机名更加容易记忆。应用程序可以调用 DNS 来获得主机别名对应的规范主机名以及主机的 IP地址。
  • 邮件服务器别名(mail server aliasing),同样的,电子邮件的应用程序也可以调用 DNS 对提供的主机名进行解析。
  • 负载分配(load distribution),DNS 也用于冗余的服务器之间进行负载分配。繁忙的站点例如 cnn.com 被冗余分布在多台服务器上,每台服务器运行在不同的端系统之间,每个都有着不同的 IP 地址。由于这些冗余的 Web 服务器,一个 IP 地址集合因此与同一个规范主机名联系。DNS 数据库中存储着这些 IP 地址的集合。由于客户端每次都会发起 HTTP 请求,所以 DNS 就会在所有这些冗余的 Web 服务器之间循环分配了负载。

DNS 工作概述

DNS 是一个复杂的系统,我们在这里只是就其运行的主要方面进行学习,下面给出一个 DNS 工作过程的总体概述

假设运行在用户主机上的某些应用程序(如 Web 浏览器或邮件阅读器) 需要将主机名转换为 IP 地址。这些应用程序将调用 DNS 的客户端,并指明需要被转换的主机名。用户主机上的 DNS 收到后,会使用 UDP 通过 53 端口向网络上发送一个 DNS 查询报文,经过一段时间后,用户主机上的 DNS 会收到一个主机名对应的 DNS 回答报文。因此,从用户主机的角度来看,DNS 就像是一个黑盒子,其内部的操作你无法看到。但是实际上,实现 DNS 这个服务的黑盒子非常复杂,它由分布于全球的大量 DNS 服务器以及定义了 DNS 服务器与查询主机通信方式的应用层协议组成。

DNS 最早的一种简单设计只是在因特网上使用一个 DNS 服务器。该服务器会包含所有的映射。这是一种集中式的设计,这种设计并不适用于当今的互联网,因为互联网有着数量巨大并且持续增长的主机,这种集中式的设计会存在以下几个问题

  • 单点故障(a single point of failure),如果 DNS 服务器崩溃,那么整个网络随之瘫痪。
  • 通信容量(traaffic volume),单个 DNS 服务器不得不处理所有的 DNS 查询,这种查询级别可能是上百万上千万级
  • 远距离集中式数据库(distant centralized database),单个 DNS 服务器不可能 邻近 所有的用户,假设在美国的 DNS 服务器不可能临近让澳大利亚的查询使用,其中查询请求势必会经过低速和拥堵的链路,造成严重的时延。
  • 维护(maintenance),维护成本巨大,而且还需要频繁更新。

所以 DNS 不可能集中式设计,它完全没有可扩展能力,因此采用分布式设计,所以这种设计的特点如下

分布式、层次数据库

首先分布式设计首先解决的问题就是 DNS 服务器的扩展性问题,因此 DNS 使用了大量的 DNS 服务器,它们的组织模式一般是层次方式,并且分布在全世界范围内。没有一台 DNS 服务器能够拥有因特网上所有主机的映射。相反,这些映射分布在所有的 DNS 服务器上。

大致来说有三种 DNS 服务器:根 DNS 服务器顶级域(Top-Level Domain, TLD) DNS 服务器权威 DNS 服务器 。这些服务器的层次模型如下图所示

image.png

假设现在一个 DNS 客户端想要知道 www.amazon.com 的 IP 地址,那么上面的域名服务器是如何解析的呢?首先,客户端会先根服务器之一进行关联,它将返回顶级域名 com 的 TLD 服务器的 IP 地址。该客户则与这些 TLD 服务器之一联系,它将为 amazon.com 返回权威服务器的 IP 地址。最后,该客户与 amazom.com 权威服务器之一联系,它为 www.amazom.com 返回其 IP 地址。

我们现在来讨论一下上面域名服务器的层次系统

  • 根 DNS 服务器 ,有 400 多个根域名服务器遍及全世界,这些根域名服务器由 13 个不同的组织管理。根域名服务器的清单和组织机构可以在 https://root-servers.org/ 中找到,根域名服务器提供 TLD 服务器的 IP 地址。
  • 顶级域 DNS 服务器,对于每个顶级域名比如 com、org、net、edu 和 gov 和所有的国家级域名 uk、fr、ca 和 jp 都有 TLD 服务器或服务器集群。所有的顶级域列表参见 https://tld-list.com/ 。TDL 服务器提供了权威 DNS 服务器的 IP 地址。
  • 权威 DNS 服务器,在因特网上具有公共可访问的主机,如 Web 服务器和邮件服务器,这些主机的组织机构必须提供可供访问的 DNS 记录,这些记录将这些主机的名字映射为 IP 地址。一个组织机构的权威 DNS 服务器收藏了这些 DNS 记录。

一般域名服务器的层次结构主要是以上三种,除此之外,还有另一类重要的 DNS 服务器,它是 本地 DNS 服务器(local DNS server)。严格来说,本地 DNS 服务器并不属于上述层次结构,但是本地 DNS 服务器又是至关重要的。每个 ISP(Internet Service Provider) 比如居民区的 ISP 或者一个机构的 ISP 都有一台本地 DNS 服务器。当主机和 ISP 进行连接时,该 ISP 会提供一台主机的 IP 地址,该主机会具有一台或多台其本地 DNS 服务器的 IP地址。通过访问网络连接,用户能够容易的确定 DNS 服务器的 IP地址。当主机发出 DNS 请求后,该请求被发往本地 DNS 服务器,它起着代理的作用,并将该请求转发到 DNS 服务器层次系统中。

DNS 缓存

DNS 缓存(DNS caching) 有时也叫做 DNS 解析器缓存,它是由操作系统维护的临时数据库,它包含有最近的网站和其他 Internet 域的访问记录。也就是说, DNS 缓存只是计算机为了满足快速的响应速度而把已加载过的资源缓存起来,再次访问时可以直接快速引用的一项技术和手段。那么 DNS 的缓存是如何工作的呢?

DNS 缓存的工作流程

在浏览器向外部发出请求之前,计算机会拦截每个请求并在 DNS 缓存数据库中查找域名,该数据库包含有最近的域名列表,以及 DNS 首次发出请求时 DNS 为它们计算的地址。

DNS 记录和报文

共同实现 DNS 分布式数据库的所有 DNS 服务器存储了资源记录(Resource Record, RR),RR 提供了主机名到 IP 地址的映射。每个 DNS 回答报文中会包含一条或多条资源记录。RR 记录用于回复客户端查询。

资源记录是一个包含了下列字段的 4 元组

(Name, Value, Type, TTL)

RR 会有不同的类型,下面是不同类型的 RR 汇总表

DNS RR 类型解释
A 记录IPv4 主机记录,用于将域名映射到 IPv4 地址
AAAA 记录IPv6 主机记录,用于将域名映射到 IPv6 地址
CNAME 记录别名记录,用于映射 DNS 域名的别名
MX 记录邮件交换器,用于将 DNS 域名映射到邮件服务器
PTR 记录指针,用于反向查找(IP地址到域名解析)
SRV 记录SRV记录,用于映射可用服务。

DNS 报文

DNS 有两种报文,一种是查询报文,一种是响应报文,并且这两种报文有着相同的格式,下面是 DNS 的报文格式

image.png

下面对报文格式进行解释

  • 前 12 个报文是 首部区域,也就是说首部区域有 12 个字节,第一个字段(标识符)是一个 16 比特的数,用于标示该查询。这个标识符会被复制到对查询的回答报文中,以便让客户用它来匹配发送的请求和接受到的回答。 标志字段含有若干标志,标志字段表示为 1 比特,它用于指出报文是 0-查询报文还是 1-响应报文。
  • 问题区域包含着正在进行的查询信息。这个区域包括:1) 名字字段,包含正在被查询的主机名字;2) 类型字段,指出有关该名字的正被询问的问题类型,例如主机地址是与一个名字相关联(类型 A)还是与某个名字的邮件服务器相关联(类型 MX)。
  • 在来自 DNS 服务器的回答中,回答区域包含了对最初请求的名字的资源记录。上面说过 DNS RR记录是个四元组,而且元组中的 Type 会有不同的类型。在回答报文的回答区域中可以包含多条 RR,因此一个主机名能够有多个 IP 地址。
  • 权威区域 包含了其他权威服务器的记录
  • 附加区域 包含了其他有帮助的记录。

关于具体 DNS 记录的详细介绍我会出一篇文章专门探讨。

P2P 文件分发

我们上面探讨的协议 HTTP、SMTP、DNS 都采用了客户-服务器 模式,这种模式会极大依赖总是打开的基础设施服务器。而 P2P 是客户端与客户端模式,对总是打开的基础设施服务器有最小的依赖。

P2P 的全称是 Peer-to-peer, P2P ,是一种分布式体系结构的计算机网络。在 P2P 体系中,所有的计算机和设备都被称为对等体,他们互相交换工作。对等网络中的每个对等方都等于其他对等方。网络中没有特权对等体,也没有主管理员设备。

image.png

从某种意义上说,对等网络是计算机世界中最平等的网络。每个对等方都相等,并且每个对等方具有与其他对等方相同的权利和义务。对等体同时是客户端和服务器。

实际上,对等网络中可用的每个资源都是在对等之间共享的,而无需任何中央服务器。P2P 网络中的共享资源可以是诸如处理器使用率,磁盘存储容量或网络带宽等。

P2P 用来做什么

P2P 的主要目标是共享资源并帮助计算机和设备协同工作,提供特定服务或执行特定任务。如前面说到的,P2P 用于共享各种计算资源,例如网络带宽或磁盘存储空间。 但是,对等网络最常见的例子是 Internet 上的文件共享。 对等网络非常适合文件共享,因为它们允许连接到它们计算机等同时接收文件和发送文件。

BitTorrent 是 P2P 使用的主要协议。

P2P 网络的作用

P2P 网络具有一些使它们有用的特征

  • 很难完全掉线,即使其中的一个对等方掉线,其他对等方仍在运行并进行通信。 为了使 P2P(对等)网络停止工作,你必须关闭所有对等网络。对等网络具有很强的可扩展性。 添加新的对等节点很容易,因为你无需在中央服务器上进行任何中央配置。
  • 当涉及到文件共享时,对等网络越大,速度越快。 在 P2P 网络中的许多对等点上存储相同的文件意味着当某人需要下载文件时,该文件会同时从多个位置下载。

视频流和内容分发网

因特网视频

在流式存储视频应用中,最基础的媒体是预先录制的视频例如电影、电视节目、录制好的体育事件或者用户生成的视频。这些预先录制好的视频会放置在服务器上,用户按需向服务器发送请求来观看视频。许多因特网公司现在提供流式视频,这些公司包括 Netflix、YouTube 、亚马逊和优酷等。

视频式一系列的图像,通常会以一种恒定的速率(如每秒 24 或 30 张图像)来展现。一幅未压缩、数字编码的图像由像素阵列组成,其中每个像素又一些比特编码来表示亮度和颜色。视频的一个重要特征是它能够被压缩、因而可用比特率来权衡视频质量。

HTTP 流和 DASH

在 HTTP 流中,视频只是存储在 HTTP 服务器中的一个文件,每个文件有特定的 URL。当用户想要看视频时,客户与服务器创建一个 TCP 连接并发送该 URL 的 HTTP GET 请求。服务器则以底层网络协议和流量条件允许的尽可能快的速率,在一个 HTTP 响应中发送该文件视频。

尽管 HTTP 流在实践中已经得到广泛部署,但是它由严重缺陷,即所有客户接收到相同编码的视频,但是对于客户而言,带宽时动态变化的,在不同的时间,带宽大小有很大不同。这种情况导致了一种新型 HTTP 流的研发,它常常被称为 经 HTTP 的动态适应性流(Dynamic Adaptive Streaming over HTTP, DASH)。在 DASH 中,视频编码为几个不同的版本,每个版本对应不同的比特率。

DASH 允许客户使用不同的以太网接入速率流失播放具有不同编码速率的视频。使用 3G 连接的客户能够接受一个低比特率的版本,使用光纤能够接受高比特率的版本。

使用 DASH 后,每个视频版本存储在 HTTP 中,每个版本都有一个不同的 URL。HTTP 服务器也会有一个 告示文件(manifest file),为每个版本提供了一个 URL 及其比特率。

内容分发网

现如今,许多因特网视频公司日复一日地向数以百万计的用户按需分发每秒数兆比特的流。对于一个因特网视频公司,或许提供流式视频服务最为直接的方法是建立一个单一的超大规模的数据中心。在数据中心内部存储所有视频,然后把视频返回到全世界范围内的客户。这种方式存在三个问题

  • 如果客户远离数据中心,服务器到客户的分组将跨越许多通信链路并可能通过很多 ISP,造成通信延迟
  • 流式视频可能经过相同的链路发送了许多次,造成带宽和资源浪费。
  • 单点问题,如果单一结点故障,这可能是灾难性的。

为了应对向分布于去啊按时接的用户分发巨量视频数据的挑战,几乎所有主要的视频流公司都利用 内容分发网(Content Distribution Network, CDN)。 CDN 管理分布在多个地理位置上的服务器,在它的服务器上存储视频副本,并且所有试图将每个用户请求定向到一个提供最好用户体验的 CDN 位置。那么服务器如何选址呢?事实上有两种服务器安置原则

  • 深入,它的主要目标是靠近用户,通过减少端用户和 CDN 集群之间链路和路由器的数量,从而改善了用户感受的时延和吞吐量。
  • 邀请做客,这个原则是通过在少量(例如 10 个)关键位置建造大集群来邀请 ISP 来做客,与深入设计原则相比,邀请做客设计通常产生较低的维护和管理开销。

CDN 可以是专用 CDN(private CDN), 即它由内容提供商自己所拥有;另一种 CDN 是 第三方 CDN(third-party CDN),它代表多个内容提供商分发内容。

CDN 分发过程

上面我们探讨了一下 CDN 的选址过程,那么 CDN 是如何工作的呢?

当用户主机中的一个浏览器指令检索一个特定的视频(由 URL 标识)时,CDN 必须能够截获请求,来进行下面的操作

  • 确定此时适用于该客户的 CDN 服务器集群
  • 将客户的请求重定向到集群中的某台服务器上

大多数 CDN 利用 DNS 协议来截获和重定向请求。

下面是 CDN 的具体工作流程

假设一个内容提供商 NetCinema ,雇用了第三方 CDN 公司 KingCDN 来向它的客户分发视频。在 NetCinema 的 Web 网页上,它的每个视频都被指派了一个 URL,该 URL 包括了字符串 video 以及视频本身的标识符。下面要访问 http://video.netcinema.com/6Y7B23V ,它的工作过程如下

image.png

  1. 用户访问位于 NetCinema 的 Web 网页
  2. 当用户点击链接 http://video.netcinema.com/6Y7B23V 时,该用户主机发送了对于 video.netcinema.com 的 DNS 请求
  3. 用户本地 DNS 服务器(LDNS, Local DNS) 将该 DNS 请求中继到一台用于 NetCinema 的权威 DNS 服务器,该服务器观察到主机名 video.netcinema.com 中的字符串 video。为了将该 DNS 请求移交给 KingCDN,NetCinema 权威 DNS 服务器并不返回一个 IP 地址,而是向 LDNS 返回一个 KingCDN 域的主机名,如 a1105.kingcdn.com
  4. 从此时起,DNS 请求就会进入 KingCDN 专用 DNS 基础设施,用户的 LDNS 则发送第二个请求,此时是对 a1105.kingcdn.com 的 DNS 请求,KingCDN 的 DNS 系统最终向 LDNS 返回 KingCDN 内容服务器的 IP 地址。所以正是这里,在 KingCDN 的 DNS 系统中,指定了 CDN 服务器,客户将能够从这台服务器接收它的内容
  5. LDNS 向用户主机转发内容服务 CDN 节点的 IP 地址
  6. 一旦客户收到 KingCDN 内容服务器的 IP 地址,它与具有该 IP 地址的服务器创建一条 TCP 连接,并且发出对该视频的 HTTP GET 请求。如果使用了 DASH,服务器将首先向客户发送具有 URL 列表的告示文件,每个 URL 对应视频的每个版本,并且客户将动态的选择来自不同版本的块。

CDN 的集群选择策略

任何 CDN 的部署,其核心是 集群选择策略(cluster selection strategy), 即动态的将客户定向到 CDN 中某个服务器集群或数据中心的机制。一种简单的策略是指派客户到 地理上最为临近(geographically closest) 的集群。这种选择策略忽略了时延和可用带宽随因特网路径时间而变化,总是为特定的客户指派相同的集群;还有一种选择策略是 实时测量(real-time measurement),该机制是基于集群和客户之间的时延和丢包性能执行周期性检查。

文章参考

《计算机网络-自顶向下方法》

https://baike.baidu.com/item/...

https://developer.mozilla.org...

https://baike.baidu.com/item/...

https://baike.baidu.com/item/...

https://baike.baidu.com/item/...

https://www.jianshu.com/p/3dd...

DNS原理及其解析过程

https://en.wikipedia.org/wiki...

https://en.wikipedia.org/wiki...

https://www.lifewire.com/what...

https://blog.csdn.net/tianxuh...

https://www.omnisecu.com/tcpi...

https://www.digitalcitizen.li...

image.png

查看原文

赞 11 收藏 9 评论 0

程序员cxuan 发布了文章 · 2月1日

面试官问你MyBatis SQL是如何执行的?把这篇文章甩给他

初识 MyBatis

MyBatis 是第一个支持自定义 SQL、存储过程和高级映射的类持久框架。MyBatis 消除了大部分 JDBC 的样板代码、手动设置参数以及检索结果。MyBatis 能够支持简单的 XML 和注解配置规则。使 Map 接口和 POJO 类映射到数据库字段和记录。

MyBatis 的特点

那么 MyBatis 具有什么特点呢?或许我们可以从如下几个方面来描述

  • MyBatis 中的 SQL 语句和主要业务代码分离,我们一般会把 MyBatis 中的 SQL 语句统一放在 XML 配置文件中,便于统一维护。

image.png

  • 解除 SQL 与程序代码的耦合,通过提供 DAO 层,将业务逻辑和数据访问逻辑分离,使系统的设计更清晰,更易维护,更易单元测试。SQL 和代码的分离,提高了可维护性。

image.png

  • MyBatis 比较简单和轻量

本身就很小且简单。没有任何第三方依赖,只要通过配置 jar 包,或者如果你使用 Maven 项目的话只需要配置 Maven 以来就可以。易于使用,通过文档和源代码,可以比较完全的掌握它的设计思路和实现。

  • 屏蔽样板代码

MyBatis 回屏蔽原始的 JDBC 样板代码,让你把更多的精力专注于 SQL 的书写和属性-字段映射上。

  • 编写原生 SQL,支持多表关联

image.png

MyBatis 最主要的特点就是你可以手动编写 SQL 语句,能够支持多表关联查询。

  • 提供映射标签,支持对象与数据库的 ORM 字段关系映射
ORM 是什么?对象关系映射(Object Relational Mapping,简称ORM) ,是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。本质上就是将数据从一种形式转换到另外一种形式。
  • 提供 XML 标签,支持编写动态 SQL。

你可以使用 MyBatis XML 标签,起到 SQL 模版的效果,减少繁杂的 SQL 语句,便于维护。

MyBatis 整体架构

MyBatis 最上面是接口层,接口层就是开发人员在 Mapper 或者是 Dao 接口中的接口定义,是查询、新增、更新还是删除操作;中间层是数据处理层,主要是配置 Mapper -> XML 层级之间的参数映射,SQL 解析,SQL 执行,结果映射的过程。上述两种流程都由基础支持层来提供功能支撑,基础支持层包括连接管理,事务管理,配置加载,缓存处理等。

image.png

接口层

在不与Spring 集成的情况下,使用 MyBatis 执行数据库的操作主要如下:

InputStream is = Resources.getResourceAsStream("myBatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(is);
sqlSession = factory.openSession();

其中的SqlSessionFactory,SqlSession是 MyBatis 接口的核心类,尤其是 SqlSession,这个接口是MyBatis 中最重要的接口,这个接口能够让你执行命令,获取映射,管理事务。

数据处理层

  • 配置解析

在 Mybatis 初始化过程中,会加载 mybatis-config.xml 配置文件、映射配置文件以及 Mapper 接口中的注解信息,解析后的配置信息会形成相应的对象并保存到 Configration 对象中。之后,根据该对象创建SqlSessionFactory 对象。待 Mybatis 初始化完成后,可以通过 SqlSessionFactory 创建 SqlSession 对象并开始数据库操作。

  • SQL 解析与 scripting 模块

Mybatis 实现的动态 SQL 语句,几乎可以编写出所有满足需要的 SQL。

Mybatis 中 scripting 模块会根据用户传入的参数,解析映射文件中定义的动态 SQL 节点,形成数据库能执行的SQL 语句。

  • SQL 执行

SQL 语句的执行涉及多个组件,包括 MyBatis 的四大核心,它们是: ExecutorStatementHandlerParameterHandlerResultSetHandler。SQL 的执行过程可以用下面这幅图来表示

image.png

MyBatis 层级结构各个组件的介绍(这里只是简单介绍,具体介绍在后面):

  • SqlSession: ,它是 MyBatis 核心 API,主要用来执行命令,获取映射,管理事务。接收开发人员提供 Statement Id 和参数。并返回操作结果。
  • Executor :执行器,是 MyBatis 调度的核心,负责 SQL 语句的生成以及查询缓存的维护。
  • StatementHandler : 封装了JDBC Statement 操作,负责对 JDBC Statement 的操作,如设置参数、将Statement 结果集转换成 List 集合。
  • ParameterHandler : 负责对用户传递的参数转换成 JDBC Statement 所需要的参数。
  • ResultSetHandler : 负责将 JDBC 返回的 ResultSet 结果集对象转换成 List 类型的集合。
  • TypeHandler : 用于 Java 类型和 JDBC 类型之间的转换。
  • MappedStatement : 动态 SQL 的封装
  • SqlSource : 表示从 XML 文件或注释读取的映射语句的内容,它创建将从用户接收的输入参数传递给数据库的 SQL。
  • Configuration: MyBatis 所有的配置信息都维持在 Configuration 对象之中。

基础支持层

  • 反射模块

Mybatis 中的反射模块,对 Java 反射进行了很好的封装,提供了简易的 API,方便上层调用,并且对反射操作进行了一系列的优化,比如,缓存了类的 元数据(MetaClass)和对象的元数据(MetaObject),提高了反射操作的性能。

  • 类型转换模块

Mybatis 的别名机制,能够简化配置文件,该机制是类型转换模块的主要功能之一。类型转换模块的另一个功能是实现 JDBC 类型与 Java 类型的转换。在 SQL 语句绑定参数时,会将数据由 Java 类型转换成 JDBC 类型;在映射结果集时,会将数据由 JDBC 类型转换成 Java 类型。

  • 日志模块

在 Java 中,有很多优秀的日志框架,如 Log4j、Log4j2、slf4j 等。Mybatis 除了提供了详细的日志输出信息,还能够集成多种日志框架,其日志模块的主要功能就是集成第三方日志框架。

  • 资源加载模块

该模块主要封装了类加载器,确定了类加载器的使用顺序,并提供了加载类文件和其它资源文件的功能。

  • 解析器模块

该模块有两个主要功能:一个是封装了 XPath,为 Mybatis 初始化时解析 mybatis-config.xml 配置文件以及映射配置文件提供支持;另一个为处理动态 SQL 语句中的占位符提供支持。

  • 数据源模块

Mybatis 自身提供了相应的数据源实现,也提供了与第三方数据源集成的接口。数据源是开发中的常用组件之一,很多开源的数据源都提供了丰富的功能,如连接池、检测连接状态等,选择性能优秀的数据源组件,对于提供ORM 框架以及整个应用的性能都是非常重要的。

  • 事务管理模块

一般地,Mybatis 与 Spring 框架集成,由 Spring 框架管理事务。但 Mybatis 自身对数据库事务进行了抽象,提供了相应的事务接口和简单实现。

  • 缓存模块

Mybatis 中有一级缓存二级缓存,这两级缓存都依赖于缓存模块中的实现。但是需要注意,这两级缓存与Mybatis 以及整个应用是运行在同一个 JVM 中的,共享同一块内存,如果这两级缓存中的数据量较大,则可能影响系统中其它功能,所以需要缓存大量数据时,优先考虑使用 Redis、Memcache 等缓存产品。

  • Binding 模块

在调用 SqlSession 相应方法执行数据库操作时,需要制定映射文件中定义的 SQL 节点,如果 SQL 中出现了拼写错误,那就只能在运行时才能发现。为了能尽早发现这种错误,Mybatis 通过 Binding 模块将用户自定义的Mapper 接口与映射文件关联起来,系统可以通过调用自定义 Mapper 接口中的方法执行相应的 SQL 语句完成数据库操作,从而避免上述问题。注意,在开发中,我们只是创建了 Mapper 接口,而并没有编写实现类,这是因为 Mybatis 自动为 Mapper 接口创建了动态代理对象。

MyBatis 核心组件

在认识了 MyBatis 并了解其基础架构之后,下面我们来看一下 MyBatis 的核心组件,就是这些组件实现了从 SQL 语句到映射到 JDBC 再到数据库字段之间的转换,执行 SQL 语句并输出结果集。首先来认识 MyBatis 的第一个核心组件

SqlSessionFactory

对于任何框架而言,在使用该框架之前都要经历过一系列的初始化流程,MyBatis 也不例外。MyBatis 的初始化流程如下

String resource = "org/mybatis/example/mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
sqlSessionFactory.openSession();

上述流程中比较重要的一个对象就是SqlSessionFactory,SqlSessionFactory 是 MyBatis 框架中的一个接口,它主要负责的是

  • MyBatis 框架初始化操作
  • 为开发人员提供SqlSession 对象

image.png

SqlSessionFactory 有两个实现类,一个是 SqlSessionManager 类,一个是 DefaultSqlSessionFactory 类

  • DefaultSqlSessionFactory : SqlSessionFactory 的默认实现类,是真正生产会话的工厂类,这个类的实例的生命周期是全局的,它只会在首次调用时生成一个实例(单例模式),就一直存在直到服务器关闭。
  • SqlSessionManager : 已被废弃,原因大概是: SqlSessionManager 中需要维护一个自己的线程池,而使用MyBatis 更多的是要与 Spring 进行集成,并不会单独使用,所以维护自己的 ThreadLocal 并没有什么意义,所以 SqlSessionManager 已经不再使用。

SqlSessionFactory 的执行流程

下面来对 SqlSessionFactory 的执行流程来做一个分析

首先第一步是 SqlSessionFactory 的创建

SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

从这行代码入手,首先创建了一个 SqlSessionFactoryBuilder 工厂,这是一个建造者模式的设计思想,由 builder 建造者来创建 SqlSessionFactory 工厂

然后调用 SqlSessionFactoryBuilder 中的 build 方法传递一个InputStream 输入流,Inputstream 输入流中就是你传过来的配置文件 mybatis-config.xml,SqlSessionFactoryBuilder 根据传入的 InputStream 输入流和environmentproperties属性创建一个XMLConfigBuilder对象。SqlSessionFactoryBuilder 对象调用XMLConfigBuilder 的parse()方法,流程如下。

image.png

XMLConfigBuilder 会解析/configuration标签,configuration 是 MyBatis 中最重要的一个标签,下面流程会介绍 Configuration 标签。

MyBatis 默认使用 XPath 来解析标签,关于 XPath 的使用,参见 https://www.w3school.com.cn/x...

parseConfiguration 方法中,会对各个在 /configuration 中的标签进行解析

image.png

重要配置

说一下这些标签都是什么意思吧

  • properties,外部属性,这些属性都是可外部配置且可动态替换的,既可以在典型的 Java 属性文件中配置,亦可通过 properties 元素的子元素来传递。
<properties>
    <property name="driver" value="com.mysql.jdbc.Driver" />
    <property name="url" value="jdbc:mysql://localhost:3306/test" />
    <property name="username" value="root" />
    <property name="password" value="root" />
</properties>

一般用来给 environment 标签中的 dataSource 赋值

<environment id="development">
  <transactionManager type="JDBC" />
  <dataSource type="POOLED">
    <property name="driver" value="${driver}" />
    <property name="url" value="${url}" />
    <property name="username" value="${username}" />
    <property name="password" value="${password}" />
  </dataSource>
</environment>

还可以通过外部属性进行配置,但是我们这篇文章以原理为主,不会介绍太多应用层面的操作。

  • settings ,MyBatis 中极其重要的配置,它们会改变 MyBatis 的运行时行为。

settings 中配置有很多,具体可以参考 https://mybatis.org/mybatis-3... 详细了解。这里介绍几个平常使用过程中比较重要的配置

属性描述
cacheEnabled全局地开启或关闭配置文件中的所有映射器已经配置的任何缓存。
useGeneratedKeys允许 JDBC 支持自动生成主键,需要驱动支持。 如果设置为 true 则这个设置强制使用自动生成主键。
lazyLoadingEnabled延迟加载的全局开关。当开启时,所有关联对象都会延迟加载。
jdbcTypeForNull当没有为参数提供特定的 JDBC 类型时,为空值指定 JDBC 类型。 某些驱动需要指定列的 JDBC 类型,多数情况直接用一般类型即可,比如 NULL、VARCHAR 或 OTHER。
defaultExecutorType配置默认的执行器。SIMPLE 就是普通的执行器;REUSE 执行器会重用预处理语句(prepared statements); BATCH 执行器将重用语句并执行批量更新。
localCacheScopeMyBatis 利用本地缓存机制(Local Cache)防止循环引用(circular references)和加速重复嵌套查询。 默认值为 SESSION,这种情况下会缓存一个会话中执行的所有查询。 若设置值为 STATEMENT,本地会话仅用在语句执行上,对相同 SqlSession 的不同调用将不会共享数据
proxyFactory指定 Mybatis 创建具有延迟加载能力的对象所用到的代理工具。
mapUnderscoreToCamelCase是否开启自动驼峰命名规则(camel case)映射,即从经典数据库列名 A_COLUMN 到经典 Java 属性名 aColumn 的类似映射。

一般使用如下配置

<settings>
  <setting name="cacheEnabled" value="true"/>
  <setting name="lazyLoadingEnabled" value="true"/>
</settings>
  • typeAliases,类型别名,类型别名是为 Java 类型设置的一个名字。 它只和 XML 配置有关。
<typeAliases>
  <typeAlias alias="Blog" type="domain.blog.Blog"/>
</typeAliases>

当这样配置时,Blog 可以用在任何使用 domain.blog.Blog 的地方。

  • typeHandlers,类型处理器,无论是 MyBatis 在预处理语句(PreparedStatement)中设置一个参数时,还是从结果集中取出一个值时, 都会用类型处理器将获取的值以合适的方式转换成 Java 类型。

org.apache.ibatis.type 包下有很多已经实现好的 TypeHandler,可以参考如下

image.png

你可以重写类型处理器或创建你自己的类型处理器来处理不支持的或非标准的类型。

具体做法为:实现 org.apache.ibatis.type.TypeHandler 接口, 或继承一个很方便的类 org.apache.ibatis.type.BaseTypeHandler, 然后可以选择性地将它映射到一个 JDBC 类型。

  • objectFactory,对象工厂,MyBatis 每次创建结果对象的新实例时,它都会使用一个对象工厂(ObjectFactory)实例来完成。默认的对象工厂需要做的仅仅是实例化目标类,要么通过默认构造方法,要么在参数映射存在的时候通过参数构造方法来实例化。如果想覆盖对象工厂的默认行为,则可以通过创建自己的对象工厂来实现。
public class ExampleObjectFactory extends DefaultObjectFactory {
  public Object create(Class type) {
    return super.create(type);
  }
  public Object create(Class type, List<Class> constructorArgTypes, List<Object> constructorArgs) {
    return super.create(type, constructorArgTypes, constructorArgs);
  }
  public void setProperties(Properties properties) {
    super.setProperties(properties);
  }
  public <T> boolean isCollection(Class<T> type) {
    return Collection.class.isAssignableFrom(type);
  }
}

然后需要在 XML 中配置此对象工厂

<objectFactory type="org.mybatis.example.ExampleObjectFactory">
  <property name="someProperty" value="100"/>
</objectFactory>
  • plugins,插件开发,插件开发是 MyBatis 设计人员给开发人员留给自行开发的接口,MyBatis 允许你在已映射语句执行过程中的某一点进行拦截调用。MyBatis 允许使用插件来拦截的方法调用包括:Executor、ParameterHandler、ResultSetHandler、StatementHandler 接口,这几个接口也是 MyBatis 中非常重要的接口,我们下面会详细介绍这几个接口。
  • environments,MyBatis 环境配置,MyBatis 可以配置成适应多种环境,这种机制有助于将 SQL 映射应用于多种数据库之中。例如,开发、测试和生产环境需要有不同的配置;或者想在具有相同 Schema 的多个生产数据库中 使用相同的 SQL 映射。

    这里注意一点,虽然 environments 可以指定多个环境,但是 SqlSessionFactory 只能有一个,为了指定创建哪种环境,只要将它作为可选的参数传递给 SqlSessionFactoryBuilder 即可。

    SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(reader, environment);
    SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(reader, environment, properties);

    环境配置如下

    <environments default="development">
      <environment id="development">
        <transactionManager type="JDBC">
          <property name="..." value="..."/>
        </transactionManager>
        <dataSource type="POOLED">
          <property name="driver" value="${driver}"/>
          <property name="url" value="${url}"/>
          <property name="username" value="${username}"/>
          <property name="password" value="${password}"/>
        </dataSource>
      </environment>
    </environments>
  • databaseIdProvider ,数据库厂商标示,MyBatis 可以根据不同的数据库厂商执行不同的语句,这种多厂商的支持是基于映射语句中的 databaseId 属性。

    <databaseIdProvider type="DB_VENDOR">
      <property name="SQL Server" value="sqlserver"/>
      <property name="DB2" value="db2"/>
      <property name="Oracle" value="oracle" />
    </databaseIdProvider>
  • mappers,映射器,这是告诉 MyBatis 去哪里找到这些 SQL 语句,mappers 映射配置有四种方式

    <!-- 使用相对于类路径的资源引用 -->
    <mappers>
      <mapper resource="org/mybatis/builder/AuthorMapper.xml"/>
      <mapper resource="org/mybatis/builder/BlogMapper.xml"/>
      <mapper resource="org/mybatis/builder/PostMapper.xml"/>
    </mappers>
    
    <!-- 使用完全限定资源定位符(URL) -->
    <mappers>
      <mapper url="file:///var/mappers/AuthorMapper.xml"/>
      <mapper url="file:///var/mappers/BlogMapper.xml"/>
      <mapper url="file:///var/mappers/PostMapper.xml"/>
    </mappers>
    
    <!-- 使用映射器接口实现类的完全限定类名 -->
    <mappers>
      <mapper class="org.mybatis.builder.AuthorMapper"/>
      <mapper class="org.mybatis.builder.BlogMapper"/>
      <mapper class="org.mybatis.builder.PostMapper"/>
    </mappers>
    
    <!-- 将包内的映射器接口实现全部注册为映射器 -->
    <mappers>
      <package name="org.mybatis.builder"/>
    </mappers>

上面的一个个属性都对应着一个解析方法,都是使用 XPath 把标签进行解析,解析完成后返回一个 DefaultSqlSessionFactory 对象,它是 SqlSessionFactory 的默认实现类。这就是 SqlSessionFactoryBuilder 的初始化流程,通过流程我们可以看到,初始化流程就是对一个个 /configuration 标签下子标签的解析过程。

SqlSession

在 MyBatis 初始化流程结束,也就是 SqlSessionFactoryBuilder -> SqlSessionFactory 的获取流程后,我们就可以通过 SqlSessionFactory 对象得到 SqlSession 然后执行 SQL 语句了。具体来看一下这个过程

image.png

在 SqlSessionFactory.openSession 过程中我们可以看到,会调用到 DefaultSqlSessionFactory 中的 openSessionFromDataSource 方法,这个方法主要创建了两个与我们分析执行流程重要的对象,一个是 Executor 执行器对象,一个是 SqlSession 对象。执行器我们下面会说,现在来说一下 SqlSession 对象

SqlSession 对象是 MyBatis 中最重要的一个对象,这个接口能够让你执行命令,获取映射,管理事务。SqlSession 中定义了一系列模版方法,让你能够执行简单的 CRUD 操作,也可以通过 getMapper 获取 Mapper 层,执行自定义 SQL 语句,因为 SqlSession 在执行 SQL 语句之前是需要先开启一个会话,涉及到事务操作,所以还会有 commitrollbackclose 等方法。这也是模版设计模式的一种应用。

MapperProxy

MapperProxy 是 Mapper 映射 SQL 语句的关键对象,我们写的 Dao 层或者 Mapper 层都是通过 MapperProxy 来和对应的 SQL 语句进行绑定的。下面我们就来解释一下绑定过程

image.png

这就是 MyBatis 的核心绑定流程,我们可以看到 SqlSession 首先调用 getMapper 方法,我们刚才说到 SqlSession 是大哥级别的人物,只定义标准(有一句话是怎么说的来着,一流的企业做标准,二流的企业做品牌,三流的企业做产品)。

SqlSession 不愿意做的事情交给 Configuration 这个手下去做,但是 Configuration 也是有小弟的,它不愿意做的事情直接甩给小弟去做,这个小弟是谁呢?它就是 MapperRegistry,马上就到核心部分了。MapperRegistry 相当于项目经理,项目经理只从大面上把握项目进度,不需要知道手下的小弟是如何工作的,把任务完成了就好。最终真正干活的还是 MapperProxyFactory。看到这段代码 Proxy.newProxyInstance ,你是不是有一种恍然大悟的感觉,如果你没有的话,建议查阅一下动态代理的文章,这里推荐一篇 (https://www.jianshu.com/p/959...

也就是说,MyBatis 中 Mapper 和 SQL 语句的绑定正是通过动态代理来完成的。

通过动态代理,我们就可以方便的在 Dao 层或者 Mapper 层定义接口,实现自定义的增删改查操作了。那么具体的执行过程是怎么样呢?上面只是绑定过程,别着急,下面就来探讨一下 SQL 语句的执行过程。

image.png

有一部分代码被遮挡,代码有些多,不过不影响我们看主要流程

MapperProxyFactory 会生成代理对象,这个对象就是 MapperProxy,最终会调用到 mapperMethod.execute 方法,execute 方法比较长,其实逻辑比较简单,就是判断是 插入更新删除 还是 查询 语句,其中如果是查询的话,还会判断返回值的类型,我们可以点进去看一下都是怎么设计的。

image.png

很多代码其实可以忽略,只看我标出来的重点就好了,我们可以看到,不管你前面经过多少道关卡处理,最终都逃不过 SqlSession 这个老大制定的标准。

我们以 selectList 为例,来看一下下面的执行过程。

image.png

这是 DefaultSqlSession 中 selectList 的代码,我们可以看到出现了 executor,这是什么呢?我们下面来解释。

Executor

还记得我们之前的流程中提到了 Executor(执行器) 这个概念吗?我们来回顾一下它第一次出现的位置。

image.png

由 Configuration 对象创建了一个 Executor 对象,这个 Executor 是干嘛的呢?下面我们就来认识一下

Executor 的继承结构

每一个 SqlSession 都会拥有一个 Executor 对象,这个对象负责增删改查的具体操作,我们可以简单的将它理解为 JDBC 中 Statement 的封装版。 也可以理解为 SQL 的执行引擎,要干活总得有一个发起人吧,可以把 Executor 理解为发起人的角色。

首先先从 Executor 的继承体系来认识一下

image.png

如上图所示,位于继承体系最顶层的是 Executor 执行器,它有两个实现类,分别是BaseExecutorCachingExecutor

BaseExecutor 是一个抽象类,这种通过抽象的实现接口的方式是适配器设计模式之接口适配 的体现,是Executor 的默认实现,实现了大部分 Executor 接口定义的功能,降低了接口实现的难度。BaseExecutor 的子类有三个,分别是 SimpleExecutor、ReuseExecutor 和 BatchExecutor。

SimpleExecutor : 简单执行器,是 MyBatis 中默认使用的执行器,每执行一次 update 或 select,就开启一个Statement 对象,用完就直接关闭 Statement 对象(可以是 Statement 或者是 PreparedStatment 对象)

ReuseExecutor : 可重用执行器,这里的重用指的是重复使用 Statement,它会在内部使用一个 Map 把创建的Statement 都缓存起来,每次执行 SQL 命令的时候,都会去判断是否存在基于该 SQL 的 Statement 对象,如果存在 Statement 对象并且对应的 connection 还没有关闭的情况下就继续使用之前的 Statement 对象,并将其缓存起来。因为每一个 SqlSession 都有一个新的 Executor 对象,所以我们缓存在 ReuseExecutor 上的 Statement作用域是同一个 SqlSession。

BatchExecutor : 批处理执行器,用于将多个 SQL 一次性输出到数据库

CachingExecutor: 缓存执行器,先从缓存中查询结果,如果存在就返回之前的结果;如果不存在,再委托给Executor delegate 去数据库中取,delegate 可以是上面任何一个执行器。

Executor 的创建和选择

我们上面提到 Executor 是由 Configuration 创建的,Configuration 会根据执行器的类型创建,如下

image.png

这一步就是执行器的创建过程,根据传入的 ExecutorType 类型来判断是哪种执行器,如果不指定 ExecutorType ,默认创建的是简单执行器。它的赋值可以通过两个地方进行赋值:

  • 可以通过<settings>标签来设置当前工程中所有的 SqlSession 对象使用默认的 Executor
<settings>
 <!--取值范围 SIMPLE, REUSE, BATCH -->
    <setting name="defaultExecutorType" value="SIMPLE"/>
</settings>
  • 另外一种直接通过Java对方法赋值的方式
session = factory.openSession(ExecutorType.BATCH);

Executor 的具体执行过程

Executor 中的大部分方法的调用链其实是差不多的,下面是深入源码分析执行过程,如果你没有时间或者暂时不想深入研究的话,给你下面的执行流程图作为参考。

image.png

我们紧跟着上面的 selectList 继续分析,它会调用到 executor.query 方法。

当有一个查询请求访问的时候,首先会经过 Executor 的实现类 CachingExecutor ,先从缓存中查询 SQL 是否是第一次执行,如果是第一次执行的话,那么就直接执行 SQL 语句,并创建缓存,如果第二次访问相同的 SQL 语句的话,那么就会直接从缓存中提取。

image.png

上面这段代码是从 selectList -> 从缓存中 query 的具体过程。可能你看到这里有些觉得类都是什么东西,我想鼓励你一下,把握重点,不用每段代码都看,从找到 SQL 的调用链路,其他代码想看的时候在看,看源码就是很容易发蒙,容易烦躁,但是切记一点,把握重点。

image.png

上面代码会判断缓存中是否有这条 SQL 语句的执行结果,如果没有的话,就再重新创建 Executor 执行器执行 SQL 语句,注意, list = doQuery 是真正执行 SQL 语句的过程,这个过程中会创建我们上面提到的三种执行器,这里我们使用的是简单执行器。

到这里,执行器所做的工作就完事了,Executor 会把后续的工作交给 StatementHandler 继续执行。下面我们来认识一下 StatementHandler

StatementHandler

StatementHandler 是四大组件中最重要的一个对象,负责操作 Statement 对象与数据库进行交互,在工作时还会使用 ParameterHandlerResultSetHandler对参数进行映射,对结果进行实体类的绑定,这两个组件我们后面说。

我们在搭建原生 JDBC 的时候,会有这样一行代码

Statement stmt = conn.createStatement(); //也可以使用PreparedStatement来做

这行代码创建的 Statement 对象或者是 PreparedStatement 对象就是由 StatementHandler 进行管理的。

StatementHandler 的继承结构

image.png

有没有感觉和 Executor 的继承体系很相似呢?最顶级接口是四大组件对象,分别有两个实现类 BaseStatementHandlerRoutingStatementHandler ,BaseStatementHandler 有三个实现类, 他们分别是 SimpleStatementHandler、PreparedStatementHandler 和 CallableStatementHandler。

RoutingStatementHandler : RoutingStatementHandler 并没有对 Statement 对象进行使用,只是根据StatementType 来创建一个代理,代理的就是对应Handler的三种实现类。在MyBatis工作时,使用的StatementHandler 接口对象实际上就是 RoutingStatementHandler 对象。

BaseStatementHandler : 是 StatementHandler 接口的另一个实现类,它本身是一个抽象类,用于简化StatementHandler 接口实现的难度,属于适配器设计模式体现,它主要有三个实现类

  • SimpleStatementHandler: 管理 Statement 对象并向数据库中推送不需要预编译的SQL语句。
  • PreparedStatementHandler: 管理 Statement 对象并向数据中推送需要预编译的SQL语句。
  • CallableStatementHandler:管理 Statement 对象并调用数据库中的存储过程。
这里注意一下,SimpleStatementHandler 和 PreparedStatementHandler 的区别是 SQL 语句是否包含变量,是否通过外部进行参数传入。

SimpleStatementHandler 用于执行没有任何参数传入的 SQL

PreparedStatementHandler 需要对外部传入的变量和参数进行提前参数绑定和赋值。

StatementHandler 的创建和源码分析

我们继续来分析上面 query 的调用链路,StatementHandler 的创建过程如下

image.png

MyBatis 会根据 SQL 语句的类型进行对应 StatementHandler 的创建。我们以预处理 StatementHandler 为例来讲解一下

image.png

执行器不仅掌管着 StatementHandler 的创建,还掌管着创建 Statement 对象,设置参数等,在创建完 PreparedStatement 之后,我们需要对参数进行处理了。

如果用一副图来表示一下这个执行流程的话我想是这样

image.png

这里我们先暂停一下,来认识一下第三个核心组件 ParameterHandler

ParameterHandler

ParameterHandler 介绍

ParameterHandler 相比于其他的组件就简单很多了,ParameterHandler 译为参数处理器,负责为 PreparedStatement 的 sql 语句参数动态赋值,这个接口很简单只有两个方法

image.png

ParameterHandler 只有一个实现类 DefaultParameterHandler , 它实现了这两个方法。

  • getParameterObject: 用于读取参数
  • setParameters: 用于对 PreparedStatement 的参数赋值

ParameterHandler 的解析过程

上面我们讨论过了 ParameterHandler 的创建过程,下面我们继续上面 parameterSize 流程

image.png

这就是具体参数的解析过程了,下面我们来描述一下

public void setParameters(PreparedStatement ps) {
  ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
  // parameterMappings 就是对 #{} 或者 ${} 里面参数的封装
  List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
  if (parameterMappings != null) {
    // 如果是参数化的SQL,便需要循环取出并设置参数的值
    for (int i = 0; i < parameterMappings.size(); i++) {
      ParameterMapping parameterMapping = parameterMappings.get(i);
      // 如果参数类型不是 OUT ,这个类型与 CallableStatementHandler 有关
      // 因为存储过程不存在输出参数,所以参数不是输出参数的时候,就需要设置。
      if (parameterMapping.getMode() != ParameterMode.OUT) {
        Object value;
        // 得到 #{}  中的属性名
        String propertyName = parameterMapping.getProperty();
        // 如果 propertyName 是 Map 中的key
        if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
          // 通过key 来得到 additionalParameter 中的value值
          value = boundSql.getAdditionalParameter(propertyName);
        }
        // 如果不是 additionalParameters 中的key,而且传入参数是 null, 则value 就是null
        else if (parameterObject == null) {
          value = null;
        }
        // 如果 typeHandlerRegistry 中已经注册了这个参数的 Class 对象,即它是 Primitive 或者是String 的话
        else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
          value = parameterObject;
        } else {
          // 否则就是 Map
          MetaObject metaObject = configuration.newMetaObject(parameterObject);
          value = metaObject.getValue(propertyName);
        }
        // 在通过 SqlSource 的parse 方法得到parameterMappings 的具体实现中,我们会得到parameterMappings 的 typeHandler
        TypeHandler typeHandler = parameterMapping.getTypeHandler();
        // 获取 typeHandler 的jdbc type
        JdbcType jdbcType = parameterMapping.getJdbcType();
        if (value == null && jdbcType == null) {
          jdbcType = configuration.getJdbcTypeForNull();
        }
        try {
          typeHandler.setParameter(ps, i + 1, value, jdbcType);
        } catch (TypeException e) {
          throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
        } catch (SQLException e) {
          throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
        }
      }
    }
  }
}

下面用一个流程图表示一下 ParameterHandler 的解析过程,以简单执行器为例

image.png

我们在完成 ParameterHandler 对 SQL 参数的预处理后,回到 SimpleExecutor 中的 doQuery 方法

image.png

上面又引出来了一个重要的组件那就是 ResultSetHandler,下面我们来认识一下这个组件

ResultSetHandler

ResultSetHandler 简介

ResultSetHandler 也是一个非常简单的接口

image.png

ResultSetHandler 是一个接口,它只有一个默认的实现类,像是 ParameterHandler 一样,它的默认实现类是DefaultResultSetHandler

ResultSetHandler 解析过程

MyBatis 只有一个默认的实现类就是 DefaultResultSetHandler,DefaultResultSetHandler 主要负责处理两件事

  • 处理 Statement 执行后产生的结果集,生成结果列表
  • 处理存储过程执行后的输出参数

按照 Mapper 文件中配置的 ResultType 或 ResultMap 来封装成对应的对象,最后将封装的对象返回即可。

public List<Object> handleResultSets(Statement stmt) throws SQLException {
  ErrorContext.instance().activity("handling results").object(mappedStatement.getId());

  final List<Object> multipleResults = new ArrayList<Object>();

  int resultSetCount = 0;
  // 获取第一个结果集
  ResultSetWrapper rsw = getFirstResultSet(stmt);
  // 获取结果映射
  List<ResultMap> resultMaps = mappedStatement.getResultMaps();
  // 结果映射的大小
  int resultMapCount = resultMaps.size();
  // 校验结果映射的数量
  validateResultMapsCount(rsw, resultMapCount);
  // 如果ResultSet 包装器不是null, 并且 resultmap 的数量  >  resultSet 的数量的话
  // 因为 resultSetCount 第一次肯定是0,所以直接判断 ResultSetWrapper 是否为 0 即可
  while (rsw != null && resultMapCount > resultSetCount) {
    // 从 resultMap 中取出 resultSet 数量
    ResultMap resultMap = resultMaps.get(resultSetCount);
    // 处理结果集, 关闭结果集
    handleResultSet(rsw, resultMap, multipleResults, null);
    rsw = getNextResultSet(stmt);
    cleanUpAfterHandlingResultSet();
    resultSetCount++;
  }

  // 从 mappedStatement 取出结果集
  String[] resultSets = mappedStatement.getResultSets();
  if (resultSets != null) {
    while (rsw != null && resultSetCount < resultSets.length) {
      ResultMapping parentMapping = nextResultMaps.get(resultSets[resultSetCount]);
      if (parentMapping != null) {
        String nestedResultMapId = parentMapping.getNestedResultMapId();
        ResultMap resultMap = configuration.getResultMap(nestedResultMapId);
        handleResultSet(rsw, resultMap, null, parentMapping);
      }
      rsw = getNextResultSet(stmt);
      cleanUpAfterHandlingResultSet();
      resultSetCount++;
    }
  }

  return collapseSingleResultList(multipleResults);
}

其中涉及的主要对象有:

ResultSetWrapper : 结果集的包装器,主要针对结果集进行的一层包装,它的主要属性有

  • ResultSet : Java JDBC ResultSet 接口表示数据库查询的结果。 有关查询的文本显示了如何将查询结果作为java.sql.ResultSet 返回。 然后迭代此ResultSet以检查结果。
  • TypeHandlerRegistry: 类型注册器,TypeHandlerRegistry 在初始化的时候会把所有的 Java类型和类型转换器进行注册。
  • ColumnNames: 字段的名称,也就是查询操作需要返回的字段名称
  • ClassNames: 字段的类型名称,也就是 ColumnNames 每个字段名称的类型
  • JdbcTypes: JDBC 的类型,也就是 java.sql.Types 类型
  • ResultMap: 负责处理更复杂的映射关系

在 DefaultResultSetHandler 中处理完结果映射,并把上述结构返回给调用的客户端,从而执行完成一条完整的SQL语句。

文章参考:

MyBatis的优缺点以及特点

mybatis基础,mybatis核心配置文件properties元素

https://mybatis.org/mybatis-3...

深入浅出Mybatis系列(十)---SQL执行流程分析(源码篇)

https://www.jianshu.com/p/196...

http://www.mybatis.org/mybati...

https://www.cnblogs.com/virgo...

https://blog.csdn.net/Roger_C...

https://blog.csdn.net/qq92486...

[mybatis 源码分析(八)ResultSetHandler 详解](

image.png

查看原文

赞 8 收藏 5 评论 0

程序员cxuan 发布了文章 · 1月31日

一文带你了解 HTTP 黑科技

这是 HTTP 系列的第三篇文章,此篇文章为 HTTP 的进阶文章。

在前面两篇文章中我们讲述了 HTTP 的入门,HTTP 所有常用标头的概述,这篇文章我们来聊一下 HTTP 的一些 黑科技

HTTP 内容协商

什么是内容协商

在 HTTP 中,内容协商是一种用于在同一 URL 上提供资源的不同表示形式的机制。内容协商机制是指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言、字符集、编码方式等作为判断的标准。

image.png

内容协商的种类

内容协商主要有以下3种类型:

  • 服务器驱动协商(Server-driven Negotiation)

这种协商方式是由服务器端进行内容协商。服务器端会根据请求首部字段进行自动处理

  • 客户端驱动协商(Agent-driven Negotiation)

这种协商方式是由客户端来进行内容协商。

  • 透明协商(Transparent Negotiation)

是服务器驱动和客户端驱动的结合体,是由服务器端和客户端各自进行内容协商的一种方法。

内容协商的分类有很多种,主要的几种类型是 Accept、Accept-Charset、Accept-Encoding、Accept-Language、Content-Language

一般来说,客户端用 Accept 头告诉服务器希望接收什么样的数据,而服务器用 Content 头告诉客户端实际发送了什么样的数据。

为什么需要内容协商

我们为什么需要内容协商呢?在回答这个问题前我们先来看一下 TCP 和 HTTP 的不同。

在 TCP / IP 协议栈里,传输数据基本上都是 header+body 的格式。但 TCP、UDP 因为是传输层的协议,它们不会关心 body 数据是什么,只要把数据发送到对方就算是完成了任务。

而 HTTP 协议则不同,它是应用层的协议,数据到达之后需要告诉应用程序这是什么数据。当然不告诉应用这是哪种类型的数据,应用也可以通过不断尝试来判断,但这种方式无疑十分低效,而且有很大几率会检查不出来文件类型。

所以鉴于此,浏览器和服务器需要就数据的传输达成一致,浏览器需要告诉服务器自己希望能够接收什么样的数据,需要什么样的压缩格式,什么语言,哪种字符集等;而服务器需要告诉客户端自己能够提供的服务是什么。

所以我们就引出了内容协商的几种概念,下面依次来进行探讨

内容协商标头

Accept

接受请求 HTTP 标头会通告客户端自己能够接受的 MIME 类型

那么什么是 MIME 类型呢?在回答这个问题前你应该先了解一下什么是 MIME

MIME: MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。MIME 消息能包含文本、图像、音频、视频以及其他应用程序专用的数据。

也就是说,MIME 类型其实就是一系列消息内容类型的集合。那么 MIME 类型都有哪些呢?

文本文件: text/html、text/plain、text/css、application/xhtml+xml、application/xml

图片文件: image/jpeg、image/gif、image/png

视频文件: video/mpeg、video/quicktime

应用程序二进制文件: application/octet-stream、application/zip

比如,如果浏览器不支持 PNG 图片的显示,那 Accept 就不指定image/png,而指定可处理的 image/gif 和 image/jpeg 等图片类型。

一般 MIME 类型也会和 q 这个属性一起使用,q 是什么?q 表示的是权重,来看一个例子

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

这是什么意思呢?若想要给显示的媒体类型增加优先级,则使用 q= 来额外表示权重值,没有显示权重的时候默认值是1.0 ,我给你列个表格你就明白了

qMIME
1.0text/html
1.0application/xhtml+xml
0.9application/xml
0.8 /

也就是说,这是一个放置顺序,权重高的在前,低的在后,application/xml;q=0.9 是不可分割的整体。

Accept-Charset

Accept-charset 属性规定服务器处理表单数据所接受的字符编码;Accept-charset 属性允许你指定一系列字符集,服务器必须支持这些字符集,从而得以正确解释表单中的数据。

Accept-Charset 没有对应的标头,服务器会把这个值放在 Content-Type中用 charset=xxx来表示,

例如,浏览器请求 GBK 或 UTF-8 的字符集,然后服务器返回的是 UTF-8 编码,就是下面这样

Accept-Charset: gbk, utf-8
Content-Type: text/html; charset=utf-8

Accept-Language

首部字段 Accept-Language 用来告知服务器用户代理能够处理的自然语言集(指中文或英文等),以及自然语言集的相对优先级。可一次指定多种自然语言集。和 Accept 首部字段一样,按权重值 q= 来表示相对优先级。

Accept-Language: en-US,en;q=0.5

Accept-Encoding

表示 HTTP 标头会标明客户端希望服务端返回的内容编码,这通常是一种压缩算法。Accept-Encoding 也是属于内容协商 的一部分,使用并通过客户端选择 Content-Encoding 内容进行返回。

即使客户端和服务器都能够支持相同的压缩算法,服务器也可能选择不压缩并返回,这种情况可能是由于这两种情况造成的:

  • 要发送的数据已经被压缩了一次,第二次压缩并不会导致发送的数据更小
  • 服务器过载,无法承受压缩带来的性能开销,通常,如果服务器使用 CPU 超过 80% ,Microsoft 则建议不要使用压缩

下面是 Accept-Encoding 的使用方式

Accept-Encoding: gzip
Accept-Encoding: compress
Accept-Encoding: deflate
Accept-Encoding: br
Accept-Encoding: identity
Accept-Encoding: *
Accept-Encoding: deflate, gzip;q=1.0, *;q=0.5

上面的几种表述方式就已经把 Accept-Encoding 的属性列全了

  • gzip: 由文件压缩程序 gzip 生成的编码格式,使用 Lempel-Ziv编码(LZ77)和32位CRC的压缩格式,感兴趣的同学可以读一下 (https://en.wikipedia.org/wiki...
  • compress: 使用Lempel-Ziv-Welch(LZW)算法的压缩格式,有兴趣的同学可以读 (https://en.wikipedia.org/wiki...
  • deflate: 使用 zlib 结构和 deflate 压缩算法的压缩格式,参考 (https://en.wikipedia.org/wiki...) 和 (https://en.wikipedia.org/wiki...
  • br: 使用 Brotli 算法的压缩格式,参考 (https://en.wikipedia.org/wiki...
  • 不执行压缩或不会变化的默认编码格式
  • * : 匹配标头中未列出的任何内容编码,如果没有列出 Accept-Encoding ,这就是默认值,并不意味着支

    持任何算法,只是表示没有偏好

  • ;q= 采用权重 q 值来表示相对优先级,这点与首部字段 Accept 相同。

Content-Type

Content-Type 实体标头用于指示资源的 MIME 类型。作为响应,Content-Type 标头告诉客户端返回的内容的内容类型实际上是什么。Content-type 有两种值 : MIME 类型和字符集编码,例如

Content-Type: text/html; charset=UTF-8
在某些情况下,浏览器将执行 MIME 嗅探,并且不一定遵循此标头的值;为防止此行为,可以将标头 X-Content-Type-Options 设置为 nosniff。

Content-Encoding

Content-Encoding 实体标头用于压缩媒体类型,它让客户端知道如何进行解码操作,从而使客户端获得 Content-Type 标头引用的 MIME 类型。表示如下

Content-Encoding: gzip
Content-Encoding: compress
Content-Encoding: deflate
Content-Encoding: identity
Content-Encoding: br
Content-Encoding: gzip, identity
Content-Encoding: deflate, gzip

Content-Language

Content-Language 实体标头用于描述面向受众的语言,以便使用户根据用户自己的首选语言进行区分。例如

Content-Language: de-DE
Content-Language: en-US
Content-Language: de-DE, en-CA

下面根据内容协商对应的请求/响应标头,我列了一张图供你参考,注意其中 Accept-Charset 没有对应的 Content-Charset ,而是通过 Content-Type 来表示。

image.png

HTTP 认证

HTTP 提供了用于访问控制和身份认证的功能,下面就对 HTTP 的权限和认证功能进行介绍

通用 HTTP 认证框架

RFC 7235 定义了 HTTP 身份认证框架,服务器可以根据其文档的定义来检查客户端请求。客户端也可以根据其文档定义来提供身份验证信息。

请求/响应的工作流程如下:服务器以401(未授权) 的状态响应客户端告诉客户端服务器需要认证信息,客户端提供至少一个 www-Authenticate 的响应标头进行授权信息的认证。想要通过服务器进行身份认证的客户端可以在请求标头字段中添加认证标头进行身份认证,一般的认证过程如下

image.png

首先客户端发起一个 HTTP 请求,不带有任何认证标头,服务器对此 HTTP 请求作出响应,发现此 HTTP 信息未带有认证凭据,服务器通过 www-Authenticate标头返回 401 告诉客户端此请求未通过认证。然后客户端进行用户认证,认证完毕后重新发起 HTTP 请求,这次 HTTP 请求带有用户认证凭据(注意,整个身份认证的过程必须通过 HTTPS 连接保证安全),到达服务器后服务器会检查认证信息,如果不符合服务器认证信息,会返回 403 Forbidden 表示用户认证失败,如果满足认证信息,则返回 200 OK

我们知道,客户端和服务器之间的 HTTP 连接可以被代理缓存重新发送,所以认证信息也适用于代理服务器。

代理认证

由于资源认证和代理认证可以共存,因此需要不同的头和状态码,在代理的情况下,会返回状态码 407(需要代理认证)Proxy-Authenticate 响应头包含至少一个适用于代理的情况,Proxy-Authorization请求头用于将证书提供给代理服务器。下面分别来认识一下这两个标头

Proxy-Authenticate

HTTP Proxy-Authenticate 响应标头定义了身份验证方法,应使用该身份验证方法来访问代理服务器后面的资源。它将请求认证到代理服务器,从而允许它进一步发送请求。例如

Proxy-Authenticate: Basic
Proxy-Authenticate: Basic realm="Access to the internal site"

Proxy-Authorization

这个 HTTP 请求标头和上面的 Proxy-Authenticate 拼接很相似,但是概念不同,这个标头用于向代理服务器提供凭据,例如

Proxy-Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l

下面是代理服务器的请求/响应认证过程

image.png

这个过程和通用的过程类似,我们就不再详细展开描述了。

禁止访问

如果代理服务器收到的有效凭据不足以获取对给定资源的访问权限,则服务器应使用403 Forbidden状态代码进行响应。与 401 Unauthorized407 Proxy Authorization Required 不同,该用户无法进行身份验证。

WWW-Authenticate 和 Proxy-Authenticate 头

WWW-AuthenticateProxy-Authenticate 响应头定义了获得对资源访问权限的身份验证方法。他们需要指定使用哪种身份验证方案,以便希望授权的客户端知道如何提供凭据。它们的一般表示形式如下

WWW-Authenticate: <type> realm=<realm>
Proxy-Authenticate: <type> realm=<realm>

我想你从上面看到这里一定会好奇 <type>realm是什么东西,现在就来解释下。

  • <type> 是认证协议,Basic 是下面协议中最普遍使用的
RFC 7617 中定义了Basic HTT P身份验证方案,该方案将凭据作为用户ID /密码对传输,并使用 base64 进行编码。(感兴趣的同学可以看看 https://tools.ietf.org/html/r...

其他的认证协议主要有

认证协议参考来源
Basic查阅 RFC 7617,base64编码的凭据
Bearer查阅 RFC 6750,承载令牌来访问受 OAuth 2.0保护的资源
Digest查阅 RFC 7616,Firefox仅支持md5哈希,请参见错误bug 472823以获得SHA加密支持
HOBA查阅 RFC 7486
Mutual查阅 RFC 8120
AWS4-HMAC-SHA256查阅 AWS docs
  • realm 用于描述保护区或指示保护范围,这可能是诸如 Access to the staging site(访问登陆站点) 或者类似的,这样用户就可以知道他们要访问哪个区域。

Authorization 和 Proxy-Authorization 标头

Authorization 和 Proxy-Authorization 请求标头包含用于通过代理服务器对用户代理进行身份验证的凭据。在此,再次需要类型,其后是凭据,取决于使用哪种身份验证方案,可以对凭据进行编码或加密。一般表示如下

Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l
Proxy-Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l

HTTP 缓存

通过把请求/响应缓存起来有助于提升系统的性能,Web 缓存减少了延迟和网络传输量,因此减少资源获取锁需要的时间。由于链路漫长,网络时延不可控,浏览器使用 HTTP 获取资源的成本较高。所以,非常有必要把数据缓存起来,下次再请求的时候尽可能地复用。当 Web 缓存在其存储中具有请求的资源时,它将拦截该请求并直接返回资源,而不是到达源服务器重新下载并获取。这样做可以实现两个小目标

  • 减轻服务器负载
  • 提升系统性能

下面我们就一起来探讨一下 HTTP 缓存都有哪些

不同类型的缓存

HTTP 缓存有几种不同的类型,这些可以分为两个主要类别:私有缓存共享缓存

  • 共享缓存:共享缓存是一种缓存,它可以存储多个用户重复使用的请求/响应。
  • 私有缓存:私有缓存也称为专用缓存,它只适用于单个用户。
  • 不缓存过期资源:所有的请求都会直接到达服务器,由服务器来下载资源并返回。
我们主要探讨浏览器缓存代理缓存,但真实情况不只有这两种缓存,还有网关缓存,CDN,反向代理缓存和负载平衡器,把它们部署在 Web 服务器上,可以提高网站和 Web 应用程序的可靠性,性能和可伸缩性。

不缓存过期资源

不缓存过期资源即浏览器和代理不会缓存过期资源,客户端发起的请求会直接到达服务器,可以使用 no-cache 标头代表不缓存过期资源。

image.png

no-cache 属于 Cache-Control 通用标头,其一般的表示方法如下

Cache-Control: no-cache

也可以使用 max-age = 0 来实现不缓存的效果。

Cache-Control: max-age=0

私有缓存

私有缓存只用来缓存单个用户,你可能在浏览器设置中看到了 缓存,浏览器缓存包含服务器通过 HTTP 下载下来的所有文档。这个高速缓存用于使访问的文档可以进行前进/后退,保存操作而无需重新发送请求到源服务器。

image.png

可以使用 private 来实现私有缓存,这与 public 的用法相反,缓存服务器只对特定的客户端进行缓存,其他客户端发送过来的请求,缓存服务器则不会返回缓存。它的一般表示方法如下

Cache-Control: private

共享缓存

共享缓存是一种用于存储要由多个用户重用的响应缓存。共享缓存一般使用 public 来表示,public 属性只出现在客户端响应中,表示响应可以被任何缓存所缓存。一般表示方法如下

Cache-Control: public

image.png

缓存控制

HTTP/1.1 中的 Cache-Control 常规标头字段用于执行缓存控制,使用此标头可通过其提供的各种指令来定义缓存策略。下面我们依次介绍一下这些属性

不缓存

no-store 才是真正意义上的不缓存,每次服务器接受到客户端的请求后,都会返回最新的资源给客户端。

Cache-Control: no-store

缓存但需要验证

同上面的 不缓存过期资源

私有和共享缓存

同上

缓存过期

缓存中一个很重要的指令就是max-age,这是资源被视为新鲜的最长时间 ,与 Expires 相反,此指令是相对于请求时间的。对于应用程序中不会更改的文件,通常可以添加主动缓存。下面是 mag-age 的表示

Cache-Control: max-age=31536000

缓存验证

must-revalidate 表示缓存必须在使用之前验证过时资源的状态,并且不应使用过期的资源。

Cache-Control: must-revalidate

下面是一个缓存验证图

image.png

什么是新鲜的数据

一旦资源存储在缓存中,理论上就可以永远被缓存使用。但是不管是浏览器缓存还是代理缓存,其存储空间是有限的,所以缓存会定期进行清除,这个过程叫做 缓存回收(cache eviction) (自译)。另一方面,服务器上的缓存也会定期进行更新,HTTP 作为应用层的协议,它是一种客户-服务器模式,HTTP 是无状态的协议,因此当资源发生更改时,服务器无法通知缓存和客户端。因此服务器必须通过某种方式告知客户端缓存已经被更新。服务器会提供过期时间这个概念,告知客户端在此到期时间之前,资源是新鲜的,也就是未更改过的。在此到期时间的范围之外,资源已过时。过期算法(Eviction algorithms) 通常会将新资源优先于陈旧资源使用。

这里需要注意一下,过期的资源并不会被回收或忽略,当高速缓存接收到过期资源时,它会使用 If-None-Match 转发此请求,以检查它是否仍然有效。如果有效,服务器会返回 304 Not Modified响应头并且没有任何响应体,从而节省了一些带宽。

下面是使用共享缓存代理的过程

image.png

这个图应该比较好理解,只说一下 Age 的作用,Age 是 HTTP 响应标头告诉客户端源服务器在多久之前创建了响应,它的单位为,Age 标头通常接近于0,如果是0则可能是从源服务器获取的,如果不是表示可能是由代理服务器创建,那么 Age 的值表示的是缓存后的响应再次发起认证到认证完成的时间值

缓存的有效性是由多个标头来共同决定的,而并非某一个标头来决定。如果指定了 Cache-control:max-age=N ,那么缓存会保存 N 秒。如果这个通用标头不存在的话,则会检查是否存在 Expires 标头。如果 Exprires 标头存在,那么它的值减去 Date 标头的值就可以确定其有效性。最后,如果max-ageexpires 都不存在,就去寻找 Last-Modified 标头,如果存在此标头,则高速缓存的有效性等于 Date 标头的值减去 Last-modified 标头的值除以10。

缓存验证

当到达缓存资源的有效期时,将对其进行验证或再次获取。仅当服务器提供了强验证器弱验证器时,才可以进行验证。

当用户按下重新加载按钮时,将触发重新验证。如果缓存的响应包含 Cache-control:must-revalidate标头,则在正常浏览下也会触发该事件。另一个因素是 高级 -> 缓存首选项 面板中的缓存验证首选项。有一个选项可在每次加载文档时强制进行验证。

Etag

我们上面提到了强验证器和弱验证器,实现验证器功能的标头正式 Etag 的作用,这意味着 HTTP 用户代理(例如浏览器)不知道该字符串表示什么,并且无法预测其值。如果 Etag 标头是资源响应的一部分,则客户端可以在未来请求的标头中发出 If-None-Match,以验证缓存的资源。

Last-Modified 响应标头可以用作弱验证器,因为它只有1秒可以分辨的时间。如果响应中存在 Last-Modified 标头,则客户端可以发出 If-Modified-Since 请求标头来验证缓存资源。(关于 Etag 更多我们会在条件请求介绍)

避免碰撞

通过使用 Etag 和 If-Match 标头,你可以检测避免碰撞。

例如,在编辑 MDN 时,将对当前 Wiki 内容进行哈希处理并将其放入响应中的 Etag 中

Etag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

当将更改保存到 Wiki 页面(发布数据)时,POST 请求将包含 If-Match 标头,其中包含 Etag 值以检查有效性。

If-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

如果哈希值不匹配,则表示文档已在中间进行了编辑,并返回 412 Precondition Failed 错误。

缓存未占用资源

Etag 标头的另一个典型用法是缓存未更改的资源,如果用户再次访问给定的 URL(已设置Etag),并且该 URL过时,则客户端将在 If-None-Match 标头字段中发送其 Etag 的值

If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

服务器将客户端的 Etag(通过 If-None-Match 发送)与 Etag 进行比较,以获取其当前资源版本,如果两个值都匹配(即资源未更改),则服务器会发回 304 Not Modified 状态,没有主体,它告诉客户端响应的缓存仍然可以使用。

HTTP CROS 跨域

CROS 的全称是 Cross-Origin Resource Sharing(CROS),中文译为 跨域资源共享,它是一种机制。是一种什么机制呢?它是一种让运行在一个域(origin)上的 Web 应用被准许访问来自不同源服务器上指定资源的机制。在搞懂这个机制前,你需要线了解什么是 域(origin)

Origin

Web 概念中域(Origin) 的内容由scheme(protocol) - 协议host(domain) - 主机和用于访问它的 URL port - 端口定义。仅仅当 scheme 、host、port 都匹配时,两个对象才有相同的来源。这种协议相同,域名相同,端口相同的安全策略也被称为 同源策略(Same Origin Policy)。某些操作仅限于具有相同来源的内容,可以使用 CORS 取消此限制。

跨域的特点

  • 下面是跨域问题的例子,看看你是否清楚什么是跨域了
(1) http://example.com/app1/index.html
(2) http://example.com/app2/index.html

上面这两个 URL 是否具有跨域问题呢?

上面两个 URL 是不具有跨域问题的,因为这两个 URL 具有相同的协议(scheme)主机(host)

  • 那么下面这两个是否具有跨域问题呢?
http://Example.com:80
http://example.com

这两个 URL 也不具有跨域问题,为什么不具有,端口不一样啊。其实它们两个端口是一样的。

或许你会认为这两个 URL 是不一样的,放心,关于一样不一样的论据我给你抛出来了

协议和域名部分是不区分大小写的,但是路径部分则根据服务器平台而定。Windows 和 Mac OS X 系统是不区分大小写的,而采用UNIX和Linux系的服务器系统是区分大小写的,

也就是说上面的 Example.comexample.com 其实是一个网址,并且由于两个地址具有相同的 scheme 和 host ,默认情况下服务器通过端口80传递 HTTP 内容,所以上面这两个地址也是相同的。

  • 下面这两个 URL 地址是否具有跨域问题?
http://example.com/app1
https://example.com/app2

这两个 URL 的 scheme 不同,所以这两个 URL 具有跨域问题

  • 再看下面这三个 URL 是否具有跨域问题
http://example.com
http://www.example.com
http://myapp.example.com

这三个 URL 也是具有跨域问题的,因为它们隶属于不通服务器的主机 host。

  • 下面这两个 URL 是否具有跨域问题
http://example.com
http://example.com:8080

这两个 URL 也是具有跨域问题,因为这两个 URL 的默认端口不一样。

同源策略

处于安全的因素,浏览器限制了从脚本发起跨域的 HTTP 请求。 XMLHttpRequest 和其他 Fetch 接口 会遵循 同源策略(same-origin policy)。也就是说使用这些 API 的应用程序想要请求相同的资源,那么他们应该具有相同的来源,除非来自其他来源的响应包括正确的 CORS 标头也可以。

同源策略是一种很重要的安全策略,它限制了从一个来源加载的文档或脚本如何与另一个来源的资源进行交互。 它有助于隔离潜在的恶意文档,减少可能的攻击媒介。

我们上面提到,如果两个 URL 具有相同的协议、主机和端口号(如果指定)的话,那么两个 URL 具有相同的来源。下面有一些实例,你判断一下是不是具有相同的来源

目标来源 http://store.company.com/dir/page.html

URLOutcomeReason
http://store.company.com/dir2...相同来源只有path不同
http://store.company.com/dir/...相同来源只有path不同
https://store.company.com/pag...不同来源协议不通
http://store.company.com:81/dir/page.html不同来源默认端口不同
http://news.company.com/dir/p...不同来源主机不同

现在我带你认识了两遍不同的源,现在你应该知道如何区分两个 URL 是否属于同一来源了吧!

好,你现在知道了什么是跨域问题,现在我要问你,哪些请求会产生跨域请求呢?这是我们下面要讨论的问题

跨域请求

跨域请求可能会从下面这几种请求中发出:

  1. 调用 XMLHttpRequest 或者 Fetch api。

XMLHttpRequest 是什么?(我是后端程序员,前端不太懂,简单解释下,如果解释的不好,还请前端大佬们不要胖揍我)

所有的现代浏览器都有一个内置的 XMLHttpReqeust 对象,这个对象可以用于从服务器请求数据。

XMLHttpReqeust 对于开发人员来说很重要,XMLHttpReqeust 对象可以用来做下面这些事情

  • 更新网页无需重新刷新页面
  • 页面加载后从服务器请求数据
  • 页面加载后从服务端获取数据
  • 在后台将数据发送到服务器

使用 XMLHttpRequest(XHR) 对象与服务器进行交互,你可以从 URL 检索数据从而不必刷新整个页面,这使网页可以更新页面的一部分,而不会中断用户的操作。XMLHttpRequest 在 AJAX 异步编程中使用很广泛。

再来说一下 Fetch API 是什么,Fetch 提供了请求和响应对象(以及其他网络请求)的通用定义。它还提供了相关概念的定义,例如 CORS 和 HTTP Origin 头语义,并在其他地方取代了它们各自的定义。

  1. Web 字体(用于 CSS 中@ font-face中的跨域字体使用),以便服务器可以部署 TrueType 字体,这些字体只能由允许跨站点加载和使用的网站使用。
  2. WebGL 纹理
  3. 使用 drawImage() 绘制到画布上的图像/视频帧
  4. 图片的 CSS 形状

跨域功能概述

跨域资源共享标准通过添加新的 HTTP 标头来工作,这些标头允许服务器描述允许哪些来源从 Web 浏览器读取信息。另外,对于可能导致服务器数据产生副作用的 HTTP 请求方法(尤其是 GET 或者具有某些 MIME 类型 POST 方法以外 HTTP 方法),该规范要求浏览器预检请求,使用 HTTP OPTIONS 请求方法从服务器请求受支持的方法,然后在服务器批准后发送实际请求。服务器还可以通知客户端是否应与请求一起发送凭据(例如 Cookies 和 HTTP 身份验证)。

注意:CORS 故障会导致错误,但是出于安全原因,该错误的详细信息不适用于 JavaScript。 所有代码都知道发生了错误。 确定具体出问题的唯一方法是查看浏览器的控制台以获取详细信息。

访问控制

下面我会和大家探讨三种方案,这些方案都演示了跨域资源共享的工作方式。所有这些示例都使用XMLHttpRequest,它可以在任何支持的浏览器中发出跨站点请求。

简单请求

一些请求不会触发 CORS预检 (关于预检我们后面再介绍)。简单请求是满足一下所有条件的请求

  • 允许以下的方法:GETHEADPOST
  • 除了由用户代理自动设置的标头(例如 Connection、User-Agent 或者在 Fetch 规范中定义为禁止标头名称的其他标头)外,唯一允许手动设置的标头是那些 Fetch 规范将其定义为 CORS安全列出的请求标头 ,它们是:

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(下面会介绍)
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  • Content-Type 标头的唯一允许的值是

    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 没有在请求中使用的任何 XMLHttpRequestUpload 对象上注册事件侦听器;这些可以使用XMLHttpRequest.upload 属性进行访问。
  • 请求中未使用 ReadableStream对象。

    例如,假定 web 内容 https://foo.example 想要获取 https://bar.other 域的资源,那么 JavaScript 中的代码可能会像下面这样写

    const xhr = new XMLHttpRequest();
    const url = 'https://bar.other/resources/public-data/';
       
    xhr.open('GET', url);
    xhr.onreadystatechange = someHandler;
    xhr.send(); 

这使用 CORS 标头来处理特权,从而在客户端和服务器之间执行某种转换。

image.png

让我们看看在这种情况下浏览器将发送到服务器的内容,并让我们看看服务器如何响应:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

注意请求的标头 Origin ,它表明调用来自于 https://foo.example。让我们看看服务器是如何响应的

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…XML Data…]

服务端发送 Access-Control-Allow-Origin 作为响应。使用 Origin 标头和 Access-Control-Allow-Origin 展示了最简单的访问控制协议。在这个事例中,服务端使用 Access-Control-Allow-Origin 作为响应,也就说明该资源可以被任何域访问。

如果位于https://bar.other的资源所有者希望将对资源的访问限制为仅来自https://foo.example的请求,他们应该发送如下响应

Access-Control-Allow-Origin: https://foo.example

现在除了 https://foo.example 之外的任何域都无法以跨域方式访问到 https://bar.other 的资源。

预检请求

和上面探讨的简单请求不同,预检请求首先通过 OPTIONS 方法向另一个域上的资源发送 HTTP 请求,用来确定实际请求是否可以安全的发送。跨站点这样被预检,因为它们可能会影响用户数据。

下面是一个预检事例

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bar.other/resources/post-here/');
xhr.setRequestHeader('X-PINGOTHER', 'pingpong');
xhr.setRequestHeader('Content-Type', 'application/xml');
xhr.onreadystatechange = handler;
xhr.send('<person><name>Arun</name></person>'); 

上面的事例创建了一个 XML 请求体用来和 POST 请求一起发送。此外,设置了非标准请求头 X-PINGOTHER ,这个标头不是 HTTP/1.1 的一部分,但通常对 Web 程序很有用。由于请求的 Content-Type 使用 application/xml,并且设置了自定义标头,因此该请求被预检。如下图所示

image.png

如下所述,实际的 POST 请求不包含 Access-Control-Request- * 标头;只有 OPTIONS 请求才需要它们。

下面我们来看一下完整的客户端/服务器交互,首先是预检请求/响应

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

上面的1 -11 行代表预检请求,预检请求使用 OPYIIONS 方法,浏览器根据上面的 JavaScript 代码段所使用的请求参数确定是否需要发送此请求,以便服务器可以响应是否可以使用实际请求参数发送请求。OPTIONS 是一种 HTTP / 1.1方法,用于确定来自服务器的更多信息,并且是一种安全的方法,这意味着它不能用于更改资源。请注意,与 OPTIONS 请求一起,还发送了另外两个请求标头(分别是第9行和第10行)

Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

Access-Control-Request-Method 标头作为预检请求的一部分通知服务器,当发送实际请求时,将使用POST 请求方法发送该请求。

Access-Control-Request-Headers 标头通知服务器,当发送请求时,它将与X-PINGOTHER 和 Content-Type 自定义标头一起发送。服务器可以确定这种情况下是否接受请求。

下面的 1 - 11行是服务器发回的响应,表示POST 请求和 X-PINGOTHER 是可以接受的,我们着重看一下下面这几行

Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

服务器完成响应表明源 http://foo.example 是可以接受的 URL,能够允许 POST、GET、OPTIONS 进行请求,允许自定义标头 X-PINGOTHER, Content-Type。最后,Access-Control-Max-Age 以秒为单位给出一个值,这个值表示对预检请求的响应可以缓存多长时间,在此期间内无需发送其他预检请求。

完成预检请求后,将发送实际请求:

POST /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache

<person><name>Arun</name></person>
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain

[Some GZIP'd payload]

正式响应中很多标头我们在之前的文章已经探讨过了,本篇不再做详细的介绍,读者可以参考
你还在为 HTTP 的这些概念头疼吗? 查阅

带凭证的请求

XMLHttpRequest 或 Fetch 和 CORS 最有趣的功能就是能够发出知道 HTTP Cookie 和 HTTP 身份验证的 凭证 请求。默认情况下,在跨站点 XMLHttpRequest 或 Fetch 调用中,浏览器将不发送凭据。调用 XMLHttpRequest对象或 Request 构造函数时必须设置一个特定的标志。

在下面这个例子中,最初从 http://foo.example 加载的内容对设置了 Cookies 的 http://bar.other 上的资源进行了简单的 GET 请求, foo.example 上可能的代码如下

const invocation = new XMLHttpRequest();
const url = 'http://bar.other/resources/credentialed-content/';
    
function callOtherDomain() {
  if (invocation) {
    invocation.open('GET', url, true);
    invocation.withCredentials = true;
    invocation.onreadystatechange = handler;
    invocation.send(); 
  }
}

第7行显示 XMLHttpRequest 上的标志,必须设置该标志才能使用 Cookie 进行调用。默认情况下,调用是不在使用 Cookie 的情况下进行的。由于这是一个简单的 GET 请求,因此不会进行预检,但是浏览器将拒绝任何没有 Access-Control-Allow-Credentials 的响应:标头为true,指的是响应不会返回 web 页面的内容。

上面的请求用下图可以表示

image.png

这是客户端和服务器之间的示例交换:

GET /resources/access-control-with-credentials/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Referer: http://foo.example/examples/credential.html
Origin: http://foo.example
Cookie: pageAccess=2
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain


[text/plain payload]

上面第10行包含指向http://bar.other 上的内容 Cookie,但是如果 bar.other 没有以 Access-Control-Allow-Credentials:true 响应(下面第五行),响应将被忽略,并且不能使用网站返回的内容。

请求凭证和通配符

当回应凭证请求时,服务器必须在 Access-Control-Allow-Credentials 中指定一个来源,而不能直接写* 通配符

因为上面示例代码中的请求标头包含 Cookie 标头,如果 Access-Control-Allow-Credentials 中是指定的通配符 * 的话,请求会失败。

注意上面示例中的 Set-Cookie 响应标头还设置了另外一个值,如果发生故障,将引发异常(取决于所使用的API)。

HTTP 响应标头

下面会列出一些服务器跨域共享规范定义的 HTTP 标头,上面简单概述了一下,现在一起来认识一下,主要会介绍下面这些

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Methods
  • Access-Control-Expose-Headers
  • Access-Control-Max-Age
  • Access-Control-Request-Headers
  • Access-Control-Request-Method
  • Origin

Access-Control-Allow-Origin

Access-Control-Allow-Origin 是 HTTP 响应标头,指示响应是否能够和给定的源共享资源。Access-Control-Allow-Origin 指定单个资源会告诉浏览器允许指定来源访问资源。对于没有凭据的请求 *通配符,告诉浏览器允许任何源访问资源。

例如,如果要允许源 https://mozilla.org 的代码访问资源,可以使用如下的指定方式

Access-Control-Allow-Origin: https://mozilla.org
Vary: Origin

如果服务器指定单个来源而不是*通配符,则服务器还应在 Vary 响应标头中包含该来源。

Access-Control-Allow-Credentials

Access-Control-Allow-Credentials 是 HTTP 的响应标头,这个标头告诉浏览器,当包含凭证请求(Request.credentials)时是否将响应公开给前端 JavaScript 代码。

这时候你会问到 Request.credentials 是什么玩意?不要着急,来给你看一下,首先来看 Request 是什么玩意,

实际上,Request 是 Fetch API 的一类接口代表着资源请求。一般创建 Request 对象有两种方式

  • 使用 Request() 构造函数创建一个 Request 对象
  • 还可以通过 FetchEvent.request api 操作来创建

再来说下 Request.credentials 是什么意思,Request 接口的凭据只读属性指示在跨域请求的情况下,用户代理是否应从其他域发送 cookie。(其他 Request 对象的方法详见 https://developer.mozilla.org...

当发送的是凭证模式的请求包含 (Request.credentials)时,如果 Access-Control-Allow-Credentials 值为 true,浏览器将仅向前端 JavaScript 代码公开响应。

Access-Control-Allow-Credentials: true

凭证一般包括 cookie、认证头和 TLS 客户端证书

当用作对预检请求响应的一部分时,这表明是否可以使用凭据发出实际请求。注意简单的 GET 请求不会进行预检。

可以参考一个实际的例子 https://www.jianshu.com/p/ea4...

Access-Control-Allow-Headers

Access-Control-Allow-Headers 是一个响应标头,这个标头用来响应预检请求,它发出实际请求时可以使用哪些HTTP标头。

示例

  • 自定义标头

这是 Access-Control-Allow-Headers 标头的示例。它表明除了像 CROS 安全列出的请求标头外,对服务器的 CROS 请求还支持名为 X-Custom-Header 的自定义标头。

Access-Control-Allow-Headers: X-Custom-Header
  • 多个标头

这个例子展示了 Access-Control-Allow-Headers 如何使用多个标头

Access-Control-Allow-Headers: X-Custom-Header, Upgrade-Insecure-Requests
  • 绕过其他限制

尽管始终允许使用 CORS 安全列出的请求标头,并且通常不需要在 Access-Control-Allow-Headers 中列出这些标头,但是无论如何列出它们都将绕开适用的其他限制。

Access-Control-Allow-Headers: Accept

这里你可能会有疑问,哪些是 CORS 列出的安全标头?(别嫌累,就是这么麻烦)

有下面这些 Accep、Accept-Language、Content-Language、Content-Type ,当且仅当包含这些标头时,无需在 CORS 上下文中发送预检请求。

Access-Control-Allow-Methods

Access-Control-Allow-Methods 也是响应标头,它指定了哪些访问资源的方法可以使用预检请求。例如

Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Methods: *

Access-Control-Expose-Headers

Access-Control-Expose-Headers 响应标头表明哪些标头可以作为响应的一部分公开。默认情况下,仅公开6个CORS安全列出的响应标头,分别是

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

如果希望客户端能够访问其他标头,则必须使用 Access-Control-Expose-Headers 标头列出它们。下面是示例

要公开非 CORS 安全列出的请求标头,可以像如下这样指定

Access-Control-Expose-Headers: Content-Length

要另外公开自定义标头,例如 X-Kuma-Revision,可以指定多个标头,并用逗号分隔

Access-Control-Expose-Headers: Content-Length, X-Kuma-Revision

在不是凭证请求中,你还可以使用通配符

Access-Control-Expose-Headers: *

但是,这不会通配 Authorization 标头,因此如果需要公开它,则需要明确列出

Access-Control-Expose-Headers: *, Authorization

Access-Control-Max-Age

Access-Control-Max-Age 响应头表示预检请求的结果可以缓存多长时间,例如

Access-Control-Max-Age: 600 

表示预检请求可以缓存10分钟

Access-Control-Request-Headers

浏览器在发出预检请求时使用 Access-Control-Request-Headers 请求标头,使服务器知道在发出实际请求时客户端可能发送的 HTTP 标头。

Access-Control-Request-Headers: X-PINGOTHER, Content-Type

Access-Control-Request-Method

同样的,Access-Control-Request-Method 响应标头告诉服务器发出预检请求时将使用那种 HTTP 方法。此标头是必需的,因为预检请求始终是 OPTIONS,并且使用的方法与实际请求不同。

Access-Control-Request-Method: POST

Origin

Origin 请求标头表明匹配的来源,它不包含任何信息,仅仅包含服务器名称,它与 CORS 请求以及 POST 请求一起发送,它类似于 Referer 标头,但与此标头不同,它没有公开整个路径。例如

Origin: https://developer.mozilla.org

HTTP 条件请求

HTTP 具有条件请求的概念,通过比较资源更新生成的值与验证器的值进行比较,来确定资源是否进行过更新。这样的请求对于验证缓存的内容、条件请求、验证资源的完整性来说非常重要。

原则

HTTP 条件请求是根据特定标头的值执行不同的请求,这些标头定义了一个前提条件,如果前提条件匹配或不匹配,则请求的结果将有所不同。

  • 对于 安全 的方法,像是 GET、用于请求文档的资源,仅当条件请求的条件满足时发回文档资源,所以,这种方式可以节约带宽。
什么是安全的方法,对于 HTTP 来说,安全的方法是不会改变服务器状态的方法,换句话说,如果方法只是只读操作,那么它肯定是安全的方法,比如说 GET 请求,它肯定是安全的方法,因为它只是请求资源。几种常见的方法肯定是安全的,它们是 GET、HEAD和 OPTIONS。所有安全的方法都是幂等的(这他妈幂等又是啥意思?)但不是所有幂等的方法都是安全的,例如 PUT 和 DELETE 都是幂等的,但不安全。

幂等性:如果相同的客户端发起一次或者多次 HTTP 请求会得到相同的结果,则说明 HTTP 是幂等的。(我们这次不深究幂等性)

  • 对于 非安全 的方法,像是 PUT,只有原始文档与服务器上存储的资源相同时,才可以使用条件请求来传输文档。(PUT 方法通常用来传输文件,就像 FTP 协议的文件上传一样)

验证

所有的条件请求都会尝试检查服务器上存储的资源是否与某个特定版本的资源相匹配。为了满足这种情况,条件请求需要指示资源的版本。由于无法和整个文件逐个字符进行比较,因此需要把整个文件描绘成一个值,然后把此值和服务器上的资源进行比较,这种方式称为比较器,比较器有两个条件

  • 文档的最后修改日期
  • 一个不透明的字符串,用于唯一标识每个版本,称为实体标签或 Etag

比较两个资源是否时相同的版本有些复杂,根据上下文,有两种相等性检查

  • 当期望的是字节对字节进行比较时,例如在恢复下载时,使用强 Etag 进行验证
  • 当用户代理需要比较两个资源是否具有相同的内容时,使用若 Etag 进行验证

HTTP 协议默认使用 强验证,它指定何时进行弱验证

强验证

强验证保证的是字节 级别的验证,严格的验证非常严格,可能在服务器级别难以保证,但是它能够保证任何时候都不会丢失数据,但这种验证丢失性能。

要使用 Last-Modified 很难实现强验证,通常,这是通过使用带有资源的 MD5 哈希值的 Etag 来完成的。

弱验证

弱验证不同于强验证,因为如果内容相等,它将认为文档的两个版本相同,例如,一个页面与另一个页面的不同之处仅在于页脚的日期不同,因此该页面被认为与其他页面相同。而使用强验证时则被认为这两个版本是不同的。构建一个若验证的 Etag 系统可能会非常复杂,因为这需要了解每个页面元素的重要性,但是对于优化缓存性能非常有用。

下面介绍一下 Etag 如何实现强弱验证。

Etag 响应头是特定版本的标识,它能够使缓存变得更高效并能够节省带宽,因为如果缓存内容未发生变更,Web 服务器则不需要重新发送完整的响应。除此之外,Etag 能够防止资源同时更新互相覆盖。

image.png

如果给定 URL 上的资源发生变更,必须生成一个新的 Etag 值,通过比较它们可以确定资源的两个表示形式是否相同。

Etag 值有两种,一种是强 Etag,一种是弱 Etag;

  • 强 Etag 值,无论实体发生多么细微的变化都会改变其值,一般的表示如下
Etag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
  • 弱 Etag 值,弱 Etag 值只用于提示资源是否相同。只有资源发生了根本改变,产生差异时才会改变 Etag 值。这时,会在字段值最开始处附加 W/。
Etag: W/"0815"

下面就来具体探讨一下条件请求的标头和 Etag 的关系

条件请求

条件请求主要包含的标头如下

  • If-Match
  • If-None-Match
  • If-Modified-Since
  • If-Unmodified-Since
  • If-Range

If-Match

对于 GETPOST 方法,服务器仅在与列出的 Etag(响应标头) 之一匹配时才返回请求的资源。这里又多了一个新词 Etag,我们稍后再说 Etag 的用法。对于像是 PUT 和其他非安全的方法,在这种情况下,它仅仅将上传资源。

下面是两种常见的案例

  • 对于 GETPOST 方法,会结合使用 Range 标头,它可以确保新发送请求的范围与上一个请求的资源相同,如果不匹配的话,会返回 416 响应。
  • 对于其他方法,特别是 PUT 方法,If-Match 可以防止丢失更新,服务器会比对 If-Match 的字段值和资源的 Etag 值,仅当两者一致时,才会执行请求。反之,则返回状态码 412 Precondition Failed 的响应。例如
If-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"
If-Match: *

If-None-Match

条件请求,它与 If-Match 的作用相反,仅当 If-None-Match 的字段值与 Etag 值不一致时,可处理该请求。对于GETHEAD ,仅当服务器没有与给定资源匹配的 Etag 时,服务器将返回 200 OK作为响应。对于其他方法,仅当最终现有资源的 Etag 与列出的任何值都不匹配时,才会处理请求。

GETPOST 发送的 If-None-MatchEtag 匹配时,服务器会返回 304

If-None-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"
If-None-Match: W/"67ab43", "54ed21", "7892dd"
If-None-Match: *

If-Modified-Since

If-Modified-Since 是 HTTP 条件请求的一部分,只有在给定日期之后,服务端修改了请求所需要的资源,才会返回 200 OK 的响应。如果在给定日期之后,服务端没有修改内容,响应会返回 304 并且不带任何响应体。If-Modified-Since 只能使用 GETHEAD 请求。

If-Modified-Since 与 If-None-Match 结合使用时,它将被忽略,除非服务器不支持 If-None-Match。一般表示如下

If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT 
注意:这是格林威治标准时间。 HTTP 日期始终以格林尼治标准时间表示,而不是本地时间。

If-Range

If-Range 也是条件请求,如果满足条件(If-Range 的值和 Etag 值或者更新的日期时间一致),则会发出范围请求,否则将会返回全部资源。它的一般表示如下

If-Range: Wed, 21 Oct 2015 07:28:00 GMT 
If-Range: bfc13a64729c4290ef5b2c2730249c88ca92d82d

If-Unmodified-Since

If-Unmodified-Since HTTP 请求标头也是一个条件请求,服务器只有在给定日期之后没有对其进行修改时,服务器才返回请求资源。如果在指定日期时间后发生了更新,则以状态码 412 Precondition Failed 作为响应返回。

If-Unmodified-Since: Wed, 21 Oct 2015 07:28:00 GMT 

条件请求示例

缓存更新

条件请求最常见的示例就是更新缓存,如果缓存是空或没有缓存,则以200 OK的状态发送回请求的资源。如下图所示

image.png

客户端第一次发送请求没有,缓存为空并且没有条件请求,服务器在收到客户端请求后,设置验证器 Last-ModifiedEtag 标签,并把这两个标签随着响应一起发送回客户端。

下一次客户端再发送相同的请求后,会直接从缓存中提取,只要缓存没有过期,就不会有任何新的请求到达服务器重新下载资源。但是,一旦缓存过期,客户端不会直接使用缓存的值,而是发出条件请求。 验证器的值用作 If-Modified-Since If-Match标头的参数。

缓存过期后客户端重新发起请求,服务器收到请求后发现如果资源没有更改,服务器会发回 304 Not Modified响应,这使缓存再次刷新,并让客户端使用缓存的资源。 尽管有一个响应/请求往返消耗一些资源,但是这比再次通过有线传输整个资源更有效。

image.png

如果资源已经发生更改,则服务器仅使用新版本的资源返回 200 OK 响应,就像没有条件请求,并且客户端会重新使用新的资源,从这个角度来讲,缓存是条件请求的前置条件

image.png

断点续传

HTTP 可以支持文件的部分下载,通过保留已获得的信息,此功能允许恢复先前的操作,从而节省带宽和时间。

image.png

支持断点续传的服务器通过发送 Accept-Ranges 标头广播此消息,一旦发生这种情况,客户端可以通过发送缺少范围的 Ranges 标头来恢复下载

image.png

这里你可能有疑问 RangesContent-Range是什么,来解释一下

Range

Range HTTP 请求标头指示服务器应返回文档指定部分的资源,可以一次请求一个 Range 来返回多个部分,服务器会将这些资源返回各个文档中。如果服务器成功返回,那么将返回 206 响应;如果 Range 范围无效,服务器返回416 Range Not Satisfiable错误;服务器还可以忽略 Range 标头,并且返回 200 作为响应。

Range: bytes=200-1000, 2000-6576, 19000-

还有一种表示是

Range: bytes=0-499, -500 

它们分别表示请求前500个字节和最后500个字节,如果范围重叠,则服务器可能会拒绝该请求。

Content-Range

HTTP 的 Content-Range 响应标头是针对范围请求而设定的,返回响应时使用首部字段 Content-Range,能够告知客户端响应实体的哪部分是符合客户端请求的,字段以字节为单位。它的一般表示如下

Content-Range: bytes 200-1000/67589 

上段代码表示从所有 67589 个字节中返回 200-1000 个字节的内容

那么上面的 Content-Range你也应该知道是什么意思了

断点续传的原理比较简单,但是这种方式存在潜在的问题:如果在两次下载资源的期间进行了资源更新,那么获得的范围将对应于资源的两个不同版本,并且最终文档将被破坏。

为了阻止这种情况的出现,就会使用条件请求。对于范围来说,有两种方法可以做到这一点。一种方法是使用 If-Modified-SinceIf-Match,如果前提条件失败,服务器将返回错误;然后客户端从头开始重新下载。

image.png

即使此方法有效,当文档资源发生改变时,它也会添加额外的 响应/请求 交换。这会降低性能,并且 HTTP 具有特定的标头来避免这种情况 If-Range

image.png

该解决方案效率更高,但灵活性稍差一些,因为在这种情况下只能使用一个 Etag。

通过乐观锁避免丢失更新

Web 应用程序中最普遍的操作是资源更新。这在任何文件系统或应用程序中都很常见,但是任何允许存储远程资源的应用程序都需要这种机制。

使用 put 方法,你可以实现这一点,客户端首先读取原始文件对其进行修改,然后把它们发送到服务器。

image.png

上面这种请求响应存在问题,一旦考虑到并发性,事情就会变得不准确。当客户端在本地修改资源打算重新发送之前,第二个客户端可以获取相同的资源并对资源进行修改操作,这样就会造成问题。当它们重新发送请求到服务器时,第一个客户端所做的修改将被第二次客户端的修改所覆盖,因为第二次客户端修改并不知道第一次客户端正在修改。资源提交并更新的一方不会传达给另外一方,所以要保留哪个客户的更改,将随着他们提交的速度而变化; 这取决于客户端,服务器的性能,甚至取决于人工在客户端编辑文档的性能。 例如下面这个流程

image.png

如果没有两个用户同时操作服务器,也就不存在这个问题。但是,现实情况是不可能只有单个用户出现的,所以为了规避或者避免这个问题,我们希望客户端资源在更新时进行提示或者修改被拒绝时收到通知。

条件请求允许实现乐观锁算法。这个概念是允许所有的客户端获取资源的副本,然后让他们在本地修改资源,并成功通过允许第一个客户端提交更新来控制并发,基于此服务端的后面版本的更新都将被拒绝。

image.png

这是使用 If-MatchIf-Unmodified-Since标头实现的。如果 Etag 与原始文件不匹配,或者自获取以来已对文件进行了修改,则更改为拒绝更新,并显示412 Precondition Failed错误。

HTTP Cookies

HTTP 协议中的 Cookie 包括 Web Cookie浏览器 Cookie,它是服务器发送到 Web 浏览器的一小块数据。服务器发送到浏览器的 Cookie,浏览器会进行存储,并与下一个请求一起发送到服务器。通常,它用于判断两个请求是否来自于同一个浏览器,例如用户保持登录状态。

HTTP Cookie 机制是 HTTP 协议无状态的一种补充和改良

Cookie 主要用于下面三个目的

  • 会话管理

登陆、购物车、游戏得分或者服务器应该记住的其他内容

  • 个性化

用户偏好、主题或者其他设置

  • 追踪

记录和分析用户行为

Cookie 曾经用于一般的客户端存储。虽然这是合法的,因为它们是在客户端上存储数据的唯一方法,但如今建议使用现代存储 API。Cookie 随每个请求一起发送,因此它们可能会降低性能(尤其是对于移动数据连接而言)。客户端存储的现代 API 是 Web 存储 API(localStorage 和 sessionStorage)和 IndexedDB。

创建 Cookie

当接收到客户端发出的 HTTP 请求时,服务器可以发送带有响应的 Set-Cookie 标头,Cookie 通常由浏览器存储,然后将 Cookie 与 HTTP 标头一同向服务器发出请求。可以指定到期日期或持续时间,之后将不再发送Cookie。此外,可以设置对特定域和路径的限制,从而限制 cookie 的发送位置。

Set-Cookie 和 Cookie 标头

Set-Cookie HTTP 响应标头将 cookie 从服务器发送到用户代理。下面是一个发送 Cookie 的例子

HTTP/2.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

[page content]

此标头告诉客户端存储 Cookie

现在,随着对服务器的每个新请求,浏览器将使用 Cookie 头将所有以前存储的 cookie 发送回服务器。

GET /sample_page.html HTTP/2.0
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

Cookie 主要分为三类,它们是 会话Cookie永久CookieCookie的 Secure 和 HttpOnly 标记,下面依次来介绍一下

会话 Cookies

上面的示例创建的是会话 Cookie ,会话 Cookie 有个特征,客户端关闭时 Cookie 会删除,因为它没有指定Expires 或 Max-Age 指令。 这两个指令你看到这里应该比较熟悉了。

但是,Web 浏览器可能会使用会话还原,这会使大多数会话 Cookie 保持永久状态,就像从未关闭过浏览器一样

永久性 Cookies

永久性 Cookie 不会在客户端关闭时过期,而是在特定日期(Expires)或特定时间长度(Max-Age)外过期。例如

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;

Cookie的 Secure 和 HttpOnly 标记

安全的 Cookie 需要经过 HTTPS 协议通过加密的方式发送到服务器。即使是安全的,也不应该将敏感信息存储在cookie 中,因为它们本质上是不安全的,并且此标志不能提供真正的保护。

HttpOnly 的作用

  • 会话 cookie 中缺少 HttpOnly 属性会导致攻击者可以通过程序(JS脚本、Applet等)获取到用户的 cookie 信息,造成用户cookie 信息泄露,增加攻击者的跨站脚本攻击威胁。
  • HttpOnly 是微软对 cookie 做的扩展,该值指定 cookie 是否可通过客户端脚本访问。
  • 如果在 Cookie 中没有设置 HttpOnly 属性为 true,可能导致 Cookie 被窃取。窃取的 Cookie 可以包含标识站点用户的敏感信息,如 ASP.NET 会话 ID 或 Forms 身份验证票证,攻击者可以重播窃取的 Cookie,以便伪装成用户或获取敏感信息,进行跨站脚本攻击等。

Cookie 的作用域

DomainPath 标识定义了 Cookie 的作用域:即 Cookie 应该发送给哪些 URL。

Domain 标识指定了哪些主机可以接受 Cookie。如果不指定,默认为当前主机(不包含子域名)。如果指定了Domain,则一般包含子域名。

例如,如果设置 Domain=mozilla.org,则 Cookie 也包含在子域名中(如developer.mozilla.org)。

例如,设置 Path=/docs,则以下地址都会匹配:

  • /docs
  • /docs/Web/
  • /docs/Web/HTTP

文章参考:

https://developer.mozilla.org...

https://www.jianshu.com/p/5c4...

https://www.w3schools.com/php...

https://www.jianshu.com/p/ea4...

https://blog.csdn.net/qq_3809...

image.png

查看原文

赞 63 收藏 51 评论 2

程序员cxuan 发布了文章 · 1月19日

一文带你看清HTTP所有概念

上一篇文章我们大致讲解了一下 HTTP 的基本特征和使用,大家反响很不错,那么本篇文章我们就来深究一下 HTTP 的特性。我们接着上篇文章没有说完的 HTTP 标头继续来介绍(此篇文章会介绍所有标头的概念,但没有深入底层)

HTTP 标头

先来回顾一下 HTTP1.1 标头都有哪几种

HTTP 1.1 的标头主要分为四种,通用标头实体标头请求标头响应标头,现在我们来对这几种标头进行介绍

通用标头

HTTP 通用标头之所以这样命名,是因为与其他三个类别不同,它们不是限定于特定种类的消息或者消息组件(请求,响应或消息实体)的。HTTP 通用标头主要用于传达有关消息本身的信息,而不是它所携带的内容。它们提供一般信息并控制如何处理和处理消息。

尽管通用标头不会限定于是请求还是响应报文,但是某些通用标头大部分或全部用于一种特定类型的请求中。也就是说,如果某个通用标头出现在请求报文中,那么大部分通用标头都会显示在该请求报文中。响应报文也是一样的。

先列出来一个清单,讲明我们都需要介绍哪些通用标头

  • Cache-Control
  • Connection
  • Date
  • Pragma
  • Trailer
  • Transfer-Encoding
  • Upgrade
  • Via
  • Warning

Cache-Control

缓存(Cache)是计算机领域里的一个重要概念,是优化系统性能的利器。不仅计算机中的 CPU 为了提高指令执行效率从而选择使用寄存器作为辅助,计算机网络同样存在缓存,下面我们就来介绍一下计算机网络中的缓存。

Cache-Control 是通用标头的指令,它能够管理如何对 HTTP 的请求或者响应使用缓存。

因为计算机网络中是可以有第三者出现的,也就是缓存服务器,这个指令通过影响请求/响应中的缓存服务器从而达到控制缓存的目的;不仅有缓存服务器,还有浏览器内部缓存也会影响链路的缓存。

这个标头中可以出现许多单独的指令,其详细信息可以在 RFC 2616 中找到,即使这是常规标头,某些指令也只能出现在请求或响应中。下表提供了一个 Cache-Control 选项的总结并告诉你如何去使用

请注意,在 Cache-Control 标头中只能出现一个指令,但是在消息中可以出现多个这样的标头。

image.png

上面这个表格其实会有四种分类

  • 可缓存性: 它们分别是 no-cacheno-storeprivatepublic
  • 缓存有效性时间: 它们分别是 max-ages-maxagemax-stalemin-fresh
  • 重新验证并重新加载: 它们分别是 must-revalidateproxy-revalidate
  • 其他: 它们分别是 only-if-cachedno-transform

分别对表格中的内容进行一下详细介绍

no-cache

no-cache 很容易和 no-store 混淆,一般都会把 no-cache 认为是不缓存,其实不是这样。

使用 no-cache 指令的目的是为了防止从缓存中返回过期的资源,例如下图所示

Cache-Control: no-cache

image.png

举个例子你就明白了,No-Cache 就相当于是吃着碗里的,占着锅里的,如果锅里还有新的肉片,就先吃锅里的,如果锅里没有新的,再吃自己的,这里锅里的就相当于是源服务器产生的,碗里的就相当于是缓存的。

no-store

no-store 才是真正意义上的不缓存,每次服务器接受到客户端的请求后,都会返回最新的资源给客户端。

Cache-Control: no-store

max-age

max-age 可以用在请求或者响应中,当客户端发送带有 max-age 的指令时,缓存服务器会判断自己缓存时间的数值和 max-age 的大小,如果比 max-age 小,那么缓存有效,可以继续给客户端返回缓存的数据,如果比 max-age 大,那么缓存服务器将不能返回给客户端缓存的数据。

Cache-Control: max-age=60

如果 max-age = 0,那么缓存服务器将会直接把请求转发到服务器

Cache-Control: max-age=0
注意:这个 max-age 的值是相对于请求时间的

must-revalidate

表示一旦资源过期,缓存就必须在原始服务器上没有成功验证的情况下才使用其过期的数据。

Cache-Control: must-revalidate

no-storeno_cachemust-revalidatemax-age 可以一起看,下面是一个这四个标头的流程图

image.png

public

public 属性只出现在客户端响应中,表示响应可以被任何缓存所缓存。在计算机网络中,分为两种缓存,共享缓存和私有缓存,如下所示

Cache-Control: public

image.png

private

当指定 private 指令后,响应只以特定的用户作为对象,这与 public 的用法相反,缓存服务器只对特定的客户端进行缓存,其他客户端发送过来的请求,缓存服务器则不会返回缓存。

Cache-Control: private

image.png

s-maxage

s-maxage 指令的功能和 max-age 指令的功能相同,不同点之处在于 s-maxage 不能用于私有缓存,只能用于多用户使用的公共服务器,对于同一用户的重复请求和响应来说,这个指令没有任何作用。

Cache-Control: s-maxage=60

min-fresh

min-fresh只能出现在请求中,min-fresh 要求缓存服务器返回 min-fresh 时间内的缓存数据。例如 Cache-Control:min-fresh=60,这就要求缓存服务器发送60秒内的数据。

Cache-Control: min-fresh=60

max-stable

max-stable 只能出现在请求中,表示客户端会接受缓存数据,即使过期也照常接收。

Cache-Control: max-stable=60

only-if-cached

这个标头只能出现在请求中,使用 only-if-cached 指令表示客户端仅在缓存服务器本地缓存目标资源的情况下才会要求其返回。

Cache-Control: only-if-cached

proxy-revalidate

proxy-revalidate 指令要求所有的缓存服务器在接收到客户端带有该指令的请求返回响应之前,必须再次验证缓存的有效性。

Cache-Control: proxy-revalidate

no-transform

使用 no-transform 指令规定无论是在请求还是响应中,缓存都不能改变实体主体的媒体类型。

Cache-Control: no-transform

Connection

HTTP 协议使用 TCP 来管理连接方式,主要有两种连接方式,持久性连接非持久性连接

持久性连接

持久性连接指的是一次会话完成后,TCP 连接并未关闭,第二次再次发送请求后,就不再需要建立 TCP 连接,而是可以直接进行请求和响应。它的一般表示形式如下

Connection: keep-alive

从 HTTP 1.1 开始,默认使用持久性连接

keep-alive 也是一个通用标头,一般 Connection 都会和 keep-alive 一起使用,keep-alive 有两个参数,一个是 timeout;另一个是 max,它们的主要表现形式如下

Connection: Keep-Alive
Keep-Alive: timeout=5, max=1000
  • timeout: 指的是空闲连接必须打开的最短时间,也就是说这次请求的连接时间不能少于5秒,
  • max: 指的是在连接关闭之前服务器所能够收到的最大请求数。

非持久性连接

非持久性连接表示一次会话请求/响应后关闭连接的方式。HTTP 1.1 之前使用的连接都是非持久连接,也就是

Connection: close

Date

Date 是一个通用标头,它可以出现在请求标头和响应标头中,它的基本表示如下

Date: Wed, 21 Oct 2015 07:28:00 GMT 

表示的是格林威治标准时间,这个时间要比北京时间慢八个小时

image.png

Pragma

Pragma是 http 1.1 之前版本的历史遗留字段,仅作为与 http 的向后兼容而定义。它的一般形式如下

Pragma: no-cache

只用于客户端发送的请求中。客户端会要求所有的中间服务器不返回缓存的资源。

如果所有的中间服务器都以实现 HTTP /1.1为标准,那么直接使用 Cache-Control: no-cache 即可,如果不是的话,就要包含两个字段,如下

Cache-Control: no-cache
Pragma: no-cache

Trailer

首部字段 Trailer 会事先说明在报文主体后记录了哪些首部字段。该首部字段可应用在 HTTP/1.1 版本分块传输编码时。一般用法如下

Transfer-Encoding: chunked
Trailer: Expires

以上用例中,指定首部字段 Trailer 的值为 Expires,在报文主体之后(分块长度 0 之后)出现了首部字段 Expires。

Transfer-Encoding

Transfer-Encoding 属于内容协商的范畴,下面会具体介绍一下内容协商,现在先做个预告:Transfer-Encoding 规定了传输报文所采用的编码方式

注意:HTTP 1.1 的传输编码方式仅对分块传输有效,但是 HTTP 2.0 就不再支持分块传输,而提供了自己更有效的数据传输机制。
Transfer-Encoding: chunked

Transfer-Encoding 也属于 Hop-by-hop(逐跳) 首部 ,下面来回顾一下,HTTP 报文标头除了可以根据属性所在的位置分为 通用标头请求标头响应标头实体标头;还可以按照是否被缓存分为 端到端首部(End-to-End)逐跳首部(Top-to-Top)

除了下面八种属于逐跳首部外,其余都属于端到端首部

Connection、Keep-Alive、Proxy-Authenticate、Proxy-Authorization、Trailer、TE、Transfer-Encoding、Upgrade

下面回到讨论中来,Transfer-Encoding 用于两个节点之间传输消息,而不是资源本身。在多个节点传输消息的过程中,每一段消息的传输都可以使用不同的 Transfer-Encoding。如图所示

image.png

Transfer-Encoding 支持文件压缩,如果你想要以文件压缩后的形式发送的话。Transfer-Encoding 所有可选类型如下

  • chunked: 数据按照一系列块发送,在这种情况下,将省略 Content-Length 标头,并在每个块的开头,需要以十六进制填充当前块的长度,后跟 '\r\n',然后是块本身,然后是另一个'\r\n'。当将大量数据发送到客户端并且在请求已被完全处理之前,可能无法知道响应的总大小时,分块编码很有用。 例如,在生成由数据库查询产生的大型 HTML 表时或在传输大型图像时。 分块的响应看起来像这样
HTTP/1.1 200 OK 
Content-Type: text/plain 
Transfer-Encoding: chunked

7\r\n
Mozilla\r\n 
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n 
\r\n

终止块通常是0。紧随Transfer-Encoding 后面的是 Trailer 标头, Trailer 可能为空。

  • compress: 使用 Lempel-Ziv-Welch(LZW) 算法的格式。值名称取自 UNIX 压缩程序,该程序实现了该算法。现在几乎没有浏览器使用这种内容编码了,因为这个专利在 2003 年就停掉了。
  • deflate:使用 zlib(在 RFC 1950 定义) 结构和 deflate 压缩算法
  • gzip: 使用Lempel-Ziv编码(LZ77)和32位CRC的格式。这最初是 UNIX gzip 程序的格式。HTTP / 1.1标准还建议出于兼容性目的,支持此内容编码的服务器应将 x-gzip 识别为别名。
  • identity: 使用身份功能(即无压缩或修改)。

也可以列出多个值,以逗号分隔,类似一个集合列表

Transfer-Encoding: gzip, chunked

Upgrade

首部字段 Upgrade 用于检测 HTTP 协议及其他协议是否可使用更高的版本进行通信,其参数值可以用来指定一个完全不同的通信协议。

image.png

上图用例中,首部字段 Upgrade 指定的值为 TLS/1.0。请注意此处两个字段首部字段的对应关系,Connection 的值被指定为 Upgrade。
Upgrade 首部字段产生作用的对象仅限于客户端和临近服务器之间。因此,使用首部字段 Upgrade 时,还需要额外指定 Connection: Upgrade
对于附有首部字段 Upgrade 的请求,服务器可用 101 Switching Protocols 状态码作为响应返回。

Via

使用 Via 是为了跟踪客户端和服务器之间的请求/响应路径,避免请求循环以及能够识别请求/响应链中发送者协议的功能。Via 字段由代理服务器添加,不论是正向代理还是反向代理,并且可以出现在请求标头和响应标头中。它用于跟踪消息转发。例如下图所示

image.png

Via 后面的的 1.1, 1.0 表示接收服务器上的 HTTP 版本,Via 首部是为了跟踪路径,经常和 TRACE 方法一起使用。

Warning

注意:Warning 字段即将被弃用

查阅 Warning (https://github.com/httpwg/http-core/issues/139) and Warning: header & stale-while-revalidate (https://github.com/whatwg/fetch/issues/913) 获取更多细节

Warning 通用 HTTP 标头通常会告知用户一些与缓存相关的问题的警告

HTTP/1.1 中定义了 7 种警告。它们分别如下

image.png

请求标头

请求标头用于客户端发送 HTTP 请求到服务器中所使用的字段,下面我们一起来看一下 HTTP 请求标头都包含哪些字段,分别是什么意思。下面会介绍

  • Accept
  • Accept-Charset
  • Accept-Encoding
  • Accept-Language
  • Authorization
  • Expect
  • From
  • Host
  • If-Match
  • If-Modified-Since
  • If-None-Match
  • If-Range
  • If-Unmodified-Since
  • Max-Forwards
  • Proxy-Authorization
  • RangeReferer
  • TE
  • User-Agent

下面分别来介绍一下

Accept

HTTP 请求标头会告知客户端能够接收的 MIME 类型是什么

那么什么是 MIME 类型呢?在回答这个问题前你应该先了解一下什么是 MIME

MIME: MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。MIME 消息能包含文本、图像、音频、视频以及其他应用程序专用的数据。

也就是说,MIME 类型其实就是一系列消息内容类型的集合。那么 MIME 类型都有哪些呢?

文本文件: text/html、text/plain、text/css、application/xhtml+xml、application/xml

图片文件: image/jpeg、image/gif、image/png

视频文件: video/mpeg、video/quicktime

应用程序二进制文件: application/octet-stream、application/zip

比如,如果浏览器不支持 PNG 图片的显示,那 Accept 就不指定image/png,而指定可处理的 image/gif 和 image/jpeg 等图片类型。

一般 MIME 类型也会和 q 这个属性一起使用,q 是什么?q 表示的是权重,来看一个例子

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

这是什么意思呢?若想要给显示的媒体类型增加优先级,则使用 q= 来额外表示权重值,没有显示权重的时候默认值是1.0 ,我给你列个表格你就明白了

qMIME
1.0text/html
1.0application/xhtml+xml
0.9application/xml
0.8 /

也就是说,这是一个放置顺序,权重高的在前,低的在后,application/xml;q=0.9 是不可分割的整体。

Accept-Charset

Accept-Charset 表示客户端能够接受的字符编码。Accept-Charset 也是属于内容协商的一部分,它和

Accept 一样,也可以用 q 来表示字符集,用逗号进行分割,例如

Accept-Charset: iso-8859-1
Accept-Charset: utf-8, iso-8859-1;q=0.5
Accept-Charset: utf-8, iso-8859-1;q=0.5, *;q=0.1
事实上,很多以 Accept-* 开头的标头,都是属于内容协商的范畴,关于内容协商我们下面会说。

Accept-Encoding

表示 HTTP 标头会标明客户端希望服务端返回的内容编码,这通常是一种压缩算法。Accept-Encoding 也是属于内容协商 的一部分,使用并通过客户端选择 Content-Encoding 内容进行返回。

即使客户端和服务器都能够支持相同的压缩算法,服务器也可能选择不压缩并返回,这种情况可能是由于这两种情况造成的:

  • 要发送的数据已经被压缩了一次,第二次压缩并不会导致发送的数据更小
  • 服务器过载,无法承受压缩带来的性能开销,通常,如果服务器使用 CPU 超过 80% ,Microsoft 则建议不要使用压缩

下面是 Accept-Encoding 的使用方式

Accept-Encoding: gzip
Accept-Encoding: compress
Accept-Encoding: deflate
Accept-Encoding: br
Accept-Encoding: identity
Accept-Encoding: *
Accept-Encoding: deflate, gzip;q=1.0, *;q=0.5

上面的几种表述方式就已经把 Accept-Encoding 的属性列全了

  • gzip: 由文件压缩程序 gzip 生成的编码格式,使用 Lempel-Ziv编码(LZ77)和32位CRC的压缩格式,感兴趣的同学可以读一下 (https://en.wikipedia.org/wiki...
  • compress: 使用Lempel-Ziv-Welch(LZW)算法的压缩格式,有兴趣的同学可以读 (https://en.wikipedia.org/wiki...
  • deflate: 使用 zlib 结构和 deflate 压缩算法的压缩格式,参考 (https://en.wikipedia.org/wiki...) 和 (https://en.wikipedia.org/wiki...
  • br: 使用 Brotli 算法的压缩格式,参考 (https://en.wikipedia.org/wiki...
  • 不执行压缩或不会变化的默认编码格式
  • * : 匹配标头中未列出的任何内容编码,如果没有列出 Accept-Encoding ,这就是默认值,并不意味着支

    持任何算法,只是表示没有偏好

  • ;q= 采用权重 q 值来表示相对优先级,这点与首部字段 Accept 相同。

Accept-Language

Accept-Language 请求表示客户端需要服务端返回的语言类型,Accept-Language 也属于内容协商的范畴。服务端通过 Content-Language 进行响应,和 Accept 首部字段一样,按权重值 q 来表示相对优先级。例如

Accept-Language: de
Accept-Language: de-CH
Accept-Language: en-US,en;q=0.5

Authorization

HTTP Authorization 请求头用于向服务器认证用户代理的凭据,通常用在服务器以401未经授权状态和WWW-Authenticate标头响应之后,啥意思呢?你不明白的话我画张图给你看

image.png

请求标头 Authorization 是用来告知服务器,用户的认证信息,服务器在只有收到认证后才会返回给客户端 200 OK 的响应,如果没有认证信息,则会返回 401 并告知客户端需要认证信息。详细关于 Authorization 的信息,后面也会详细解释

Expect

Expect HTTP 请求标头指示服务器需要满足的期望才能正确处理请求。如果服务器没有办法完成客服端所期望完成的事情并且服务端存在错误的话,会返回 417 Expectation Failed 。HTTP 1.1 只规定了100-continue

  • 如果服务器能正常完成客户端所期望的事情,会返回 100
  • 如果不能满足期望或返回任何其他4xx 的状态码,会返回 417

例如

PUT /somewhere/fun HTTP/1.1
Host: origin.example.com
Content-Type: video/h264
Content-Length: 1234567890987
Expect: 100-continue

From

From 请求头用来告知服务器使用用户代理的电子邮件地址。通常情况下,其使用目的就是为了显示搜索引擎等用户代理的负责人的电子邮件联系方式。我们在使用代理的情况下,应尽可能包含 From 首部字段。例如

From: webmaster@example.org
你不应该将 From 用在访问控制或者身份验证中

Host

Host 请求头指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号。如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个 HTTP 的 URL 会自动使用80作为端口)。

Host: developer.mozilla.org

Host 首部字段在 HTTP/1.1 规范内是唯一一个必须被包含在请求内的首部字段。

If-Match

If-Match 后面可以跟一大堆属性,形式像 If-Match 这种的请求头称为条件请求,服务器接收到条件请求后,需要判定条件请求是否满足,只有条件请求为真,才会执行条件请求

类似的还有 If-Match、If-Modified-Since、If-None-Match、If-Range、If-Unmodified-Since

对于 GETPOST 方法,服务器仅在与列出的 ETag(响应标头) 之一匹配时才返回请求的资源。这里又多了一个新词 ETag,我们稍后再说 ETag 的用法。对于像是 PUT 和其他非安全的方法,在这种情况下,它仅仅将上传资源。

下面是两种常见的案例

  • 对于 GETPOST 方法,会结合使用 Range 标头,它可以确保新发送请求的范围与上一个请求的资源相同,如果不匹配的话,会返回 416 响应。
  • 对于其他方法,特别是 PUT 方法,If-Match 可以防止丢失更新,服务器会比对 If-Match 的字段值和资源的 ETag 值,仅当两者一致时,才会执行请求。反之,则返回状态码 412 Precondition Failed 的响应。例如
If-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"
If-Match: *

If-Modified-Since

If-Modified-Since 是 HTTP 条件请求的一部分,只有在给定日期之后,服务端修改了请求所需要的资源,才会返回 200 OK 的响应。如果在给定日期之后,服务端没有修改内容,响应会返回 304 并且不带任何响应体。If-Modified-Since 只能使用 GETHEAD 请求。

If-Modified-Since 与 If-None-Match 结合使用时,它将被忽略,除非服务器不支持 If-None-Match。一般表示如下

If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT 
注意:这是格林威治标准时间。 HTTP 日期始终以格林尼治标准时间表示,而不是本地时间。

If-None-Match

条件请求,它与 If-Match 的作用相反,仅当 If-None-Match 的字段值与 ETag 值不一致时,可处理该请求。对于GETHEAD ,仅当服务器没有与给定资源匹配的 ETag 时,服务器将返回 200 作为响应。对于其他方法,仅当最终现有资源的 ETag 与列出的任何值都不匹配时,才会处理请求。

GETPOST 发送的 If-None-MatchETag 匹配时,服务器会返回 304

If-None-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"
If-None-Match: W/"67ab43", "54ed21", "7892dd"
If-None-Match: *

有同学可能会好奇 W/ 是什么意思,这其实是 ETag 的弱匹配,关于 ETag 我们会在响应标头中详细讲述。

If-Range

If-Range 也是条件请求,如果满足条件(If-Range 的值和 ETag 值或者更新的日期时间一致),则会发出范围请求,否则将会返回全部资源。它的一般表示如下

If-Range: Wed, 21 Oct 2015 07:28:00 GMT 

If-Unmodified-Since

If-Unmodified-Since HTTP 请求标头也是一个条件请求,服务器只有在给定日期之后没有对其进行修改时,服务器才返回请求资源。如果在指定日期时间后发生了更新,则以状态码 412 Precondition Failed 作为响应返回。

If-Unmodified-Since: Wed, 21 Oct 2015 07:28:00 GMT 

Max-Forwards

MDN 把这个标头置灰了,所以下面内容取自《图解 HTTP》

Max-Forwards 一般用于 TRACEOPTION 方法,发送包含 Max-Forwards 的首部字段时,每经过一个服务器,Max-Forwards 的值就会 -1,直到 Max-Forwards 为0时返回。Max-Forwards 是一个十进制的整数值。

Max-Forwards: 10

可以灵活使用首部字段 Max-Forwards,针对以上问题产生的原因展开调查。由于当 Max-Forwards 字段值为 0 时,服务器就会立即返回响应,由此我们至少可以对以那台服务器为终点的传输路径的通信状况有所把握。

Proxy-Authorization

Proxy-Authorization 是属于请求与认证的范畴,我们在上面提到一个认证的 HTTP 标头是 Authorization,不同于 Authorization 发生在客户端 - 服务器之间;Proxy-Authorization 发生在代理服务器和客户端之间。它表示接收到从代理服务器发来的认证时,客户端会发送包含首部字段 Proxy-Authorization 的请求,以告知服务器认证所需要的信息。

Proxy-Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l

Range

Range HTTP 请求标头指示服务器应返回文档指定部分的资源,可以一次请求一个 Range 来返回多个部分,服务器会将这些资源返回各个文档中。如果服务器成功返回,那么将返回 206 响应;如果 Range 范围无效,服务器返回416 Range Not Satisfiable错误;服务器还可以忽略 Range 标头,并且返回 200 作为响应。

Range: bytes=200-1000, 2000-6576, 19000-

Referer

HTTP Referer 属性是请求标头的一部分,当浏览器向 web 服务器发送请求的时候,一般会带上 Referer,告诉服务器该网页是从哪个页面链接过来的,服务器因此可以获得一些信息用于处理。

Referer: https://developer.mozilla.org/testpage.html

TE

首部字段 TE 会告知服务器客户端能够处理响应的传输编码方式及相对优先级。它和首部字段 Accept-Encoding 的功能很相像,但是用于传输编码。

TE: gzip, deflate;q=0.5

首部字段 TE 除指定传输编码之外,还可以指定伴随 trailer 字段的分块传输编码的方式。应用后者时,只需把 trailers 赋值给该字段值。

TE: trailers, deflate;q=0.5

User-Agent

首部字段 User-Agent 会将创建请求的浏览器和用户代理名称等信息传达给服务器。

Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0

响应标头

刚刚我们的着重点一直放在客户端请求,现在我们把关注点转换一下放在服务器上。响应首部字段是由服务器发送给客户端响应中所包含的字段,用于补充相应信息等,这部分标头也是非常多,我们先一起来看一下

  • Accept-Ranges
  • Age
  • ETag
  • Location
  • Proxy-Authenticate
  • Retry-After
  • Server
  • Vary
  • www-Authenticate

Accept-Ranges

Accept-Ranges HTTP 响应标头,这个标头有两个值

  • 当服务器能够处理客户端发送过来的请求时,使用bytes 来指定
  • 当服务器不能处理客户端发来的请求时,使用 none 来指定
Accept-Ranges: bytes
Accept-Ranges: none

Age

Age HTTP 响应标头告诉客户端源服务器在多久之前创建了响应,它的单位为,Age 标头通常接近于0,如果是0则可能是从源服务器获取的,如果不是表示可能是由代理服务器创建,那么 Age 的值表示的是缓存后的响应再次发起认证到认证完成的时间值。代理创建响应时必须加上首部字段 Age。一般表示如下

Age: 24

ETag

ETag 对于条件请求来说真是太重要了。因为条件请求就是根据 ETag 的值进行匹配的,下面我们就来详细了解一下。

ETag 响应头是特定版本的标识,它能够使缓存变得更高效并能够节省带宽,因为如果缓存内容未发生变更,Web 服务器则不需要重新发送完整的响应。除此之外,ETag 能够防止资源同时更新互相覆盖。

image.png

如果给定 URL 上的资源发生变更,必须生成一个新的 ETag 值,通过比较它们可以确定资源的两个表示形式是否相同。

ETag 值有两种,一种是强 ETag,一种是弱 ETag;

  • 强 ETag 值,无论实体发生多么细微的变化都会改变其值,一般的表示如下
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
  • 弱 ETag 值,弱 ETag 值只用于提示资源是否相同。只有资源发生了根本改变,产生差异时才会改变 ETag 值。这时,会在字段值最开始处附加 W/。
ETag: W/"0815"

Location

Location 响应标头表示 URL 需要重定向页面,它仅仅与 3xx(重定向)201(已创建) 状态响应一起使用。下面是一个页面重定向的过程

image.png

使用首部字段 Location 可以将响应接受方引导至某个与请求 URI 位置不同的资源。

Locationcontent-Location 是不一样的:Location 表示目标的重定向(或新创建资源的 URL)。然而 Content-Location 表示发生内容协商时用于访问资源的直接 URL,而无须进一步协商。Location 是与响应相关联的标头,而 Content-Location 与返回的实体相关联。

Location: /index.html

Proxy-Authenticate

HTTP 响应标头 Proxy-Authenticate 会定义认证方法,应该使用身份验证方法来访问代理服务器后面的资源即客户端。

它与 HTTP 客户端和服务端之间的访问认证行为相似,不同之处在于 Proxy-Authenticate 的认证双方是客户端与代理之间。它的一般表示形式如下

Proxy-Authenticate: Basic
Proxy-Authenticate: Basic realm="Access to the internal site"

Retry-After

HTTP 响应标头 Retry-After 告知客户端需要在多久之后重新发送请求,使用此标头主要有如下三种情况

  • 当发送 503(服务不可用) 响应时,这表示该服务预计无法使用多长时间。
  • 当发送 429(太多请求)响应时,这表示发出新请求之前要等待多长时间。
  • 当发送重定向的响应像是 301(永久移动),这表示在发出重定向请求之前要求用户客户端等待的最短时间。

字段值可以指定为具体的日期时间,也可以是创建响应后所持续的秒数,例如

Retry-After: Wed, 21 Oct 2015 07:28:00 GMT
Retry-After: 120

Server

服务器标头包含有关原始服务器用来处理请求的软件的信息。

应该避免使用过于冗长和详细的 Server 值,因为它们可能会泄露内部实施细节,这可能会使攻击者容易地发现并利用已知的安全漏洞。例如下面这种写法

Server: Apache/2.4.1 (Unix)

Vary

Vary HTTP 响应标头确定如何匹配请求标头,以决定是否可以使用缓存的响应,而不是从原始服务器请求一个新的响应。

Vary: User-Agent

www-Authenticate

HTTP WWW-Authenticate 响应标头定义了应用于获得对资源的访问权限的身份验证方法。WWW-Authenticate标头与401未经授权的响应一起发送。它的一般表示形式如下

WWW-Authenticate: Basic
WWW-Authenticate: Basic realm="Access to the staging site", charset="UTF-8"

Access-Control-Allow-Origin

一个返回的 HTTP 标头可能会具有 Access-Control-Allow-Origin ,Access-Control-Allow-Origin 指定一个来源,它告诉浏览器允许该来源进行资源访问。 否则-对于没有凭据的请求 *通配符,告诉浏览器允许任何源访问资源。例如,要允许源 https://mozilla.org 的代码访问资源,可以指定:

Access-Control-Allow-Origin: https://mozilla.org
Vary: Origin

如果服务器指定单个来源而不是 * 通配符的话 ,则服务器还应在 Vary 响应标头中包含 Origin ,以向客户端指示 服务器响应将根据原始请求标头的值而有所不同。

实体标头

实体标头用于HTTP请求和响应中,例如 Content-Length,Content-Language,Content-Encoding 的标头是实体标头。实体标头不局限于请求标头或者响应标头,下面例子中,Content-Length 是一个实体标头,但是却出现在了请求报文中

POST /myform.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Content-Length: 128

下面就来说一下实体标头都包含哪些

  • Allow
  • Content-Encoding
  • Content-Language
  • Content-Length
  • Content-Location
  • Content-MD5
  • Content-Range
  • Content-Type
  • Expires
  • Last-Modified

下面来分开说一下

Allow

HTTP 实体标头 Allow 列出了资源支持的方法集合。如果服务器响应405 Method Not Allowed状态码以指示可以使用哪些请求方法,则必须发送此标头。例如

Allow: GET, POST, HEAD

这段代码表示服务器允许支持 GETPOSTHEAD 方法。当服务器接收到不支持的 HTTP 方法时,会以状态码 405 Method Not Allowed 作为响应返回。

Content-Encoding

我们上面讲过 Accept-Encoding 是客户端希望服务端返回的内容编码,但是实际上服务端返回给客户端的内容编码实际上是通过 Content-Encoding 返回的。内容编码是指在不丢失实体信息的前提下所进行的压缩。主要也是四种,和 Accept-Encoding 相同,它们是 gzip、compress、deflate、identity。下面是一组请求/响应内容压缩编码

Accept-Encoding: gzip, deflate
Content-Encoding: gzip

Content-Language

首部字段 Content-Language 会告知客户端,服务器使用的自然语言是什么,它与 Accept-Language 相对,下面是一组请求/响应使用的语言类型

Content-Language: de-DE, en-CA

Content-Length

Content-Length 的实体标头指服务器发送给客户端的实际主体大小,以字节为单位。

Content-Length: 3000

如上,服务器返回给客户端的主体大小是 3000 字节。

Content-Location

Content-Location 可不是对应 Accept-Location,因为没有这个标头哈哈哈哈。实际上 Content-Location 对应的是 Location

Location 和 Content-Location 是不一样的,Location 表示重定向的 URL,而 Content-Location 表示用于访问资源的直接 URL,以后无需进行进一步的内容协商。Location 是与响应关联的标头,而 Content-Location 是与返回的数据相关联的标头,如果你不好理解,看一下下面的表格

Request headerResponse header
Accept: application/json, text/jsonContent-Location: /documents/foo.json
Accept: application/xml, text/xmlContent-Location: /documents/foo.xml
Accept: text/plain, text/*Content-Location: /documents/foo.txt

Content-MD5

客户端会对接收的报文主体执行相同的 MD5 算法,然后与首部字段 Content-MD5 的字段进行比较。

Content-MD5: e10adc3949ba59abbe56e057f20f883e

首部字段 Content-MD5 是一串由 MD5 算法生成的值,其目的在于检查报文主体在传输过程中是否保持完整,有无被修改的情况,以及确认传输到达。

image.png

Content-Range

HTTP 的 Content-Range 响应标头是针对范围请求而设定的,返回响应时使用首部字段 Content-Range,能够告知客户端响应实体的哪部分是符合客户端请求的,字段以字节为单位。它的一般表示如下

Content-Range: bytes 200-1000/67589 

上段代码表示从所有 67589 个字节中返回 200-1000 个字节的内容

Content-Type

HTTP 响应标头 Content-Type 说明了实体内对象的媒体类型,和首部字段 Accept 一样使用,表示服务器能够响应的媒体类型。

Expires

HTTP Expires 实体标头包含 日期/时间,在该日期/时间之后,响应被认为过期;在响应时间之内被认为有效。特殊的值比如0表示过去的日期,表示资源已过期。

Expires: Wed, 21 Oct 2015 07:28:00 GMT

源服务器会将资源失效的日期或时间发送给客户端,缓存服务器在接受到 Expires 的响应后,会判断是否把缓存返回给客户端。

源服务器不希望缓存服务器对资源缓存时,最好在 Expires 字段内写入与首部字段 Date 相同的时间值。但是,当首部字段 Cache-Control 有指定 max-age 指令时,比起首部字段 Expires,会优先处理 max-age 指令。

Last-Modified

实体字段 Last-Modified 指明资源的最后修改时间,它用作验证器来确定接收或存储的资源是否相同。它的作用不如 ETag 那么准确,它可以作为一种后备机制,包含 If-Modified-SinceIf-Unmodified-Since 标头的条件请求将使用此字段。它的一般表示如下

Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT

总结

本篇文章主要介绍了 HTTP 四种标头的基本概念,但是并没有涵盖全部,毕竟 HTTP 标头内容确实太多了,以上介绍的基本都是平常工作中常用的一些概念,下一篇文章预告 HTTP 的黑科技

文章参考:

https://developer.mozilla.org...

http://www.tcpipguide.com/fre...

http://www.tcpipguide.com/fre...

https://developer.mozilla.org...

《图解 HTTP》

https://www.w3.org/Protocols/...

https://blog.csdn.net/qq_2940...

https://en.wikipedia.org/wiki...

二维码.png

查看原文

赞 18 收藏 14 评论 0

程序员cxuan 发布了文章 · 1月15日

程序员硬核知识大全

我们每个程序员或许都有一个梦,那就是成为大牛,我们或许都沉浸在各种框架中,以为框架就是一切,以为应用层才是最重要的,你错了。在当今计算机行业中,会应用是基本素质,如果你懂其原理才能让你在行业中走的更远,而计算机基础知识又是重中之重。下面,跟随我的脚步,为你介绍一下计算机底层知识。

CPU

还不了解 CPU 吗?现在就带你了解一下 CPU 是什么

CPU 的全称是 Central Processing Unit,它是你的电脑中最硬核的组件,这种说法一点不为过。CPU 是能够让你的计算机叫计算机的核心组件,但是它却不能代表你的电脑,CPU 与计算机的关系就相当于大脑和人的关系。CPU 的核心是从程序或应用程序获取指令并执行计算。此过程可以分为三个关键阶段:提取,解码和执行。CPU从系统的主存中提取指令,然后解码该指令的实际内容,然后再由 CPU 的相关部分执行该指令。

CPU 内部处理过程

下图展示了一般程序的运行流程(以 C 语言为例),可以说了解程序的运行流程是掌握程序运行机制的基础和前提。

image.png

在这个流程中,CPU 负责的就是解释和运行最终转换成机器语言的内容。

CPU 主要由两部分构成:控制单元算术逻辑单元(ALU)

  • 控制单元:从内存中提取指令并解码执行
  • 算数逻辑单元(ALU):处理算数和逻辑运算

CPU 是计算机的心脏和大脑,它和内存都是由许多晶体管组成的电子部件。它接收数据输入,执行指令并处理信息。它与输入/输出(I / O)设备进行通信,这些设备向 CPU 发送数据和从 CPU 接收数据。

从功能来看,CPU 的内部由寄存器、控制器、运算器和时钟四部分组成,各部分之间通过电信号连通。

image.png

  • 寄存器是中央处理器内的组成部分。它们可以用来暂存指令、数据和地址。可以将其看作是内存的一种。根据种类的不同,一个 CPU 内部会有 20 - 100个寄存器。
  • 控制器负责把内存上的指令、数据读入寄存器,并根据指令的结果控制计算机
  • 运算器负责运算从内存中读入寄存器的数据
  • 时钟 负责发出 CPU 开始计时的时钟信号

CPU 是一系列寄存器的集合体

在 CPU 的四个结构中,我们程序员只需要了解寄存器就可以了,其余三个不用过多关注,为什么这么说?因为程序是把寄存器作为对象来描述的。

不同类型的 CPU ,其内部寄存器的种类,数量以及寄存器存储的数值范围都是不同的。不过,根据功能的不同,可以将寄存器划分为下面这几类

image.png

其中程序计数器、累加寄存器、标志寄存器、指令寄存器和栈寄存器都只有一个,其他寄存器一般有多个。

image.png

下面就对各个寄存器进行说明

程序计数器

程序计数器(Program Counter)是用来存储下一条指令所在单元的地址。

程序执行时,PC的初值为程序第一条指令的地址,在顺序执行程序时,控制器首先按程序计数器所指出的指令地址从内存中取出一条指令,然后分析和执行该指令,同时将PC的值加1指向下一条要执行的指令。

我们还是以一个事例为准来详细的看一下程序计数器的执行过程

image.png

这是一段进行相加的操作,程序启动,在经过编译解析后会由操作系统把硬盘中的程序复制到内存中,示例中的程序是将 123 和 456 执行相加操作,并将结果输出到显示器上。

地址 0100 是程序运行的起始位置。Windows 等操作系统把程序从硬盘复制到内存后,会将程序计数器作为设定为起始位置 0100,然后执行程序,每执行一条指令后,程序计数器的数值会增加1(或者直接指向下一条指令的地址),然后,CPU 就会根据程序计数器的数值,从内存中读取命令并执行,也就是说,程序计数器控制着程序的流程

条件分支和循环机制

高级语言中的条件控制流程主要分为三种:顺序执行、条件分支、循环判断三种,顺序执行是按照地址的内容顺序的执行指令。条件分支是根据条件执行任意地址的指令。循环是重复执行同一地址的指令。

  • 顺序执行的情况比较简单,每执行一条指令程序计数器的值就是 + 1。
  • 条件和循环分支会使程序计数器的值指向任意的地址,这样一来,程序便可以返回到上一个地址来重复执行同一个指令,或者跳转到任意指令。

下面以条件分支为例来说明程序的执行过程(循环也很相似)

image.png

程序的开始过程和顺序流程是一样的,CPU 从0100处开始执行命令,在0100和0101都是顺序执行,PC 的值顺序+1,执行到0102地址的指令时,判断0106寄存器的数值大于0,跳转(jump)到0104地址的指令,将数值输出到显示器中,然后结束程序,0103 的指令被跳过了,这就和我们程序中的 if() 判断是一样的,在不满足条件的情况下,指令会直接跳过。所以 PC 的执行过程也就没有直接+1,而是下一条指令的地址。

标志寄存器

条件和循环分支会使用到 jump(跳转指令),会根据当前的指令来判断是否跳转,上面我们提到了标志寄存器,无论当前累加寄存器的运算结果是正数、负数还是零,标志寄存器都会将其保存

CPU 在进行运算时,标志寄存器的数值会根据当前运算的结果自动设定,运算结果的正、负和零三种状态由标志寄存器的三个位表示。标志寄存器的第一个字节位、第二个字节位、第三个字节位各自的结果都为1时,分别代表着正数、零和负数。

image.png

CPU 的执行机制比较有意思,假设累加寄存器中存储的 XXX 和通用寄存器中存储的 YYY 做比较,执行比较的背后,CPU 的运算机制就会做减法运算。而无论减法运算的结果是正数、零还是负数,都会保存到标志寄存器中。结果为正表示 XXX 比 YYY 大,结果为零表示 XXX 和 YYY 相等,结果为负表示 XXX 比 YYY 小。程序比较的指令,实际上是在 CPU 内部做减法运算。

函数调用机制

接下来,我们继续介绍函数调用机制,哪怕是高级语言编写的程序,函数调用处理也是通过把程序计数器的值设定成函数的存储地址来实现的。函数执行跳转指令后,必须进行返回处理,单纯的指令跳转没有意义,下面是一个实现函数跳转的例子

image.png

图中将变量 a 和 b 分别赋值为 123 和 456 ,调用 MyFun(a,b) 方法,进行指令跳转。图中的地址是将 C 语言编译成机器语言后运行时的地址,由于1行 C 程序在编译后通常会变为多行机器语言,所以图中的地址是分散的。在执行完 MyFun(a,b)指令后,程序会返回到 MyFun(a,b) 的下一条指令,CPU 继续执行下面的指令。

函数的调用和返回很重要的两个指令是 callreturn 指令,再将函数的入口地址设定到程序计数器之前,call 指令会把调用函数后要执行的指令地址存储在名为栈的主存内。函数处理完毕后,再通过函数的出口来执行 return 指令。return 指令的功能是把保存在栈中的地址设定到程序计数器。MyFun 函数在被调用之前,0154 地址保存在栈中,MyFun 函数处理完成后,会把 0154 的地址保存在程序计数器中。这个调用过程如下

image.png

在一些高级语言的条件或者循环语句中,函数调用的处理会转换成 call 指令,函数结束后的处理则会转换成 return 指令。

通过地址和索引实现数组

接下来我们看一下基址寄存器和变址寄存器,通过这两个寄存器,我们可以对主存上的特定区域进行划分,来实现类似数组的操作,首先,我们用十六进制数将计算机内存上的 00000000 - FFFFFFFF 的地址划分出来。那么,凡是该范围的内存地址,只要有一个 32 位的寄存器,便可查看全部地址。但如果想要想数组那样分割特定的内存区域以达到连续查看的目的的话,使用两个寄存器会更加方便。

例如,我们用两个寄存器(基址寄存器和变址寄存器)来表示内存的值

image.png

这种表示方式很类似数组的构造,数组是指同样长度的数据在内存中进行连续排列的数据构造。用数组名表示数组全部的值,通过索引来区分数组的各个数据元素,例如: a[0] - a[4],[]内的 0 - 4 就是数组的下标。

CPU 指令执行过程

几乎所有的冯·诺伊曼型计算机的CPU,其工作都可以分为5个阶段:取指令、指令译码、执行指令、访存取数、结果写回

  • 取指令阶段是将内存中的指令读取到 CPU 中寄存器的过程,程序寄存器用于存储下一条指令所在的地址
  • 指令译码阶段,在取指令完成后,立马进入指令译码阶段,在指令译码阶段,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类别以及各种获取操作数的方法。
  • 执行指令阶段,译码完成后,就需要执行这一条指令了,此阶段的任务是完成指令所规定的各种操作,具体实现指令的功能。
  • 访问取数阶段,根据指令的需要,有可能需要从内存中提取数据,此阶段的任务是:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算。
  • 结果写回阶段,作为最后一个阶段,结果写回(Write Back,WB)阶段把执行指令阶段的运行结果数据“写回”到某种存储形式:结果数据经常被写到CPU的内部寄存器中,以便被后续的指令快速地存取;

内存

CPU 和 内存就像是一堆不可分割的恋人一样,是无法拆散的一对儿,没有内存,CPU 无法执行程序指令,那么计算机也就失去了意义;只有内存,无法执行指令,那么计算机照样无法运行。

那么什么是内存呢?内存和 CPU 如何进行交互?下面就来介绍一下

什么是内存

内存(Memory)是计算机中最重要的部件之一,它是程序与CPU进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存对计算机的影响非常大,内存又被称为主存,其作用是存放 CPU 中的运算数据,以及与硬盘等外部存储设备交换的数据。只要计算机在运行中,CPU 就会把需要运算的数据调到主存中进行运算,当运算完成后CPU再将结果传送出来,主存的运行也决定了计算机的稳定运行。

内存的物理结构

内存的内部是由各种 IC 电路组成的,它的种类很庞大,但是其主要分为三种存储器

  • 随机存储器(RAM): 内存中最重要的一种,表示既可以从中读取数据,也可以写入数据。当机器关闭时,内存中的信息会 丢失
  • 只读存储器(ROM):ROM 一般只能用于数据的读取,不能写入数据,但是当机器停电时,这些数据不会丢失。
  • 高速缓存(Cache):Cache 也是我们经常见到的,它分为一级缓存(L1 Cache)、二级缓存(L2 Cache)、三级缓存(L3 Cache)这些数据,它位于内存和 CPU 之间,是一个读写速度比内存更快的存储器。当 CPU 向内存写入数据时,这些数据也会被写入高速缓存中。当 CPU 需要读取数据时,会直接从高速缓存中直接读取,当然,如需要的数据在Cache中没有,CPU会再去读取内存中的数据。

内存 IC 是一个完整的结构,它内部也有电源、地址信号、数据信号、控制信号和用于寻址的 IC 引脚来进行数据的读写。下面是一个虚拟的 IC 引脚示意图

image.png

图中 VCC 和 GND 表示电源,A0 - A9 是地址信号的引脚,D0 - D7 表示的是控制信号、RD 和 WR 都是好控制信号,我用不同的颜色进行了区分,将电源连接到 VCC 和 GND 后,就可以对其他引脚传递 0 和 1 的信号,大多数情况下,+5V 表示1,0V 表示 0

我们都知道内存是用来存储数据,那么这个内存 IC 中能存储多少数据呢?D0 - D7 表示的是数据信号,也就是说,一次可以输入输出 8 bit = 1 byte 的数据。A0 - A9 是地址信号共十个,表示可以指定 00000 00000 - 11111 11111 共 2 的 10次方 = 1024个地址。每个地址都会存放 1 byte 的数据,因此我们可以得出内存 IC 的容量就是 1 KB。

内存的读写过程

让我们把关注点放在内存 IC 对数据的读写过程上来吧!我们来看一个对内存IC 进行数据写入和读取的模型

image.png

来详细描述一下这个过程,假设我们要向内存 IC 中写入 1byte 的数据的话,它的过程是这样的:

  • 首先给 VCC 接通 +5V 的电源,给 GND 接通 0V 的电源,使用 A0 - A9 来指定数据的存储场所,然后再把数据的值输入给 D0 - D7 的数据信号,并把 WR(write)的值置为 1,执行完这些操作后,即可以向内存 IC 写入数据
  • 读出数据时,只需要通过 A0 - A9 的地址信号指定数据的存储场所,然后再将 RD 的值置为 1 即可。
  • 图中的 RD 和 WR 又被称为控制信号。其中当WR 和 RD 都为 0 时,无法进行写入和读取操作。

内存的现实模型

为了便于记忆,我们把内存模型映射成为我们现实世界的模型,在现实世界中,内存的模型很想我们生活的楼房。在这个楼房中,1层可以存储一个字节的数据,楼层号就是地址,下面是内存和楼层整合的模型图

image.png

我们知道,程序中的数据不仅只有数值,还有数据类型的概念,从内存上来看,就是占用内存大小(占用楼层数)的意思。即使物理上强制以 1 个字节为单位来逐一读写数据的内存,在程序中,通过指定其数据类型,也能实现以特定字节数为单位来进行读写。

二进制

我们都知道,计算机的底层都是使用二进制数据进行数据流传输的,那么为什么会使用二进制表示计算机呢?或者说,什么是二进制数呢?在拓展一步,如何使用二进制进行加减乘除?下面就来看一下

什么是二进制数

那么什么是二进制数呢?为了说明这个问题,我们先把 00100111 这个数转换为十进制数看一下,二进制数转换为十进制数,直接将各位置上的值 * 位权即可,那么我们将上面的数值进行转换

image.png

也就是说,二进制数代表的 00100111 转换成十进制就是 39,这个 39 并不是 3 和 9 两个数字连着写,而是 3 * 10 + 9 * 1,这里面的 10 , 1 就是位权,以此类推,上述例子中的位权从高位到低位依次就是 7 6 5 4 3 2 1 0。这个位权也叫做次幂,那么最高位就是2的7次幂,2的6次幂 等等。二进制数的运算每次都会以2为底,这个2 指得就是基数,那么十进制数的基数也就是 10 。在任何情况下位权的值都是 数的位数 - 1,那么第一位的位权就是 1 - 1 = 0, 第二位的位权就睡 2 - 1 = 1,以此类推。

那么我们所说的二进制数其实就是 用0和1两个数字来表示的数,它的基数为2,它的数值就是每个数的位数 * 位权再求和得到的结果,我们一般来说数值指的就是十进制数,那么它的数值就是 3 * 10 + 9 * 1 = 39。

移位运算和乘除的关系

在了解过二进制之后,下面我们来看一下二进制的运算,和十进制数一样,加减乘除也适用于二进制数,只要注意逢 2 进位即可。二进制数的运算,也是计算机程序所特有的运算,因此了解二进制的运算是必须要掌握的。

首先我们来介绍移位 运算,移位运算是指将二进制的数值的各个位置上的元素坐左移和右移操作,见下图

image.png

补数

刚才我们没有介绍右移的情况,是因为右移之后空出来的高位数值,有 0 和 1 两种形式。要想区分什么时候补0什么时候补1,首先就需要掌握二进制数表示负数的方法。

二进制数中表示负数值时,一般会把最高位作为符号来使用,因此我们把这个最高位当作符号位。 符号位是 0 时表示正数,是 1 时表示 负数。那么 -1 用二进制数该如何表示呢?可能很多人会这么认为: 因为 1 的二进制数是 0000 0001,最高位是符号位,所以正确的表示 -1 应该是 1000 0001,但是这个答案真的对吗?

计算机世界中是没有减法的,计算机在做减法的时候其实就是在做加法,也就是用加法来实现的减法运算。比如 100 - 50 ,其实计算机来看的时候应该是 100 + (-50),为此,在表示负数的时候就要用到二进制补数,补数就是用正数来表示的负数。

为了获得补数,我们需要将二进制的各数位的数值全部取反,然后再将结果 + 1 即可,先记住这个结论,下面我们来演示一下。

image.png

具体来说,就是需要先获取某个数值的二进制数,然后对二进制数的每一位做取反操作(0 ---> 1 , 1 ---> 0),最后再对取反后的数 +1 ,这样就完成了补数的获取。

补数的获取,虽然直观上不易理解,但是逻辑上却非常严谨,比如我们来看一下 1 - 1 的这个过程,我们先用上面的这个 1000 0001(它是1的补数,不知道的请看上文,正确性先不管,只是用来做一下计算)来表示一下

image.png

奇怪,1 - 1 会变成 130 ,而不是0,所以可以得出结论 1000 0001 表示 -1 是完全错误的。

那么正确的该如何表示呢?其实我们上面已经给出结果了,那就是 1111 1111,来论证一下它的正确性

image.png

我们可以看到 1 - 1 其实实际上就是 1 + (-1),对 -1 进行上面的取反 + 1 后变为 1111 1111, 然后与 1 进行加法运算,得到的结果是九位的 1 0000 0000,结果发生了溢出,计算机会直接忽略掉溢出位,也就是直接抛掉 最高位 1 ,变为 0000 0000。也就是 0,结果正确,所以 1111 1111 表示的就是 -1 。

所以负数的二进制表示就是先求其补数,补数的求解过程就是对原始数值的二进制数各位取反,然后将结果 + 1

算数右移和逻辑右移的区别

在了解完补数后,我们重新考虑一下右移这个议题,右移在移位后空出来的最高位有两种情况 0 和 1

将二进制数作为带符号的数值进行右移运算时,移位后需要在最高位填充移位前符号位的值( 0 或 1)。这就被称为算数右移。如果数值使用补数表示的负数值,那么右移后在空出来的最高位补 1,就可以正确的表示 1/2,1/4,1/8等的数值运算。如果是正数,那么直接在空出来的位置补 0 即可。

下面来看一个右移的例子。将 -4 右移两位,来各自看一下移位示意图

image.png

如上图所示,在逻辑右移的情况下, -4 右移两位会变成 63, 显然不是它的 1/4,所以不能使用逻辑右移,那么算数右移的情况下,右移两位会变为 -1,显然是它的 1/4,故而采用算数右移。

那么我们可以得出来一个结论:左移时,无论是图形还是数值,移位后,只需要将低位补 0 即可;右移时,需要根据情况判断是逻辑右移还是算数右移。

下面介绍一下符号扩展:将数据进行符号扩展是为了产生一个位数加倍、但数值大小不变的结果,以满足有些指令对操作数位数的要求,例如倍长于除数的被除数,再如将数据位数加长以减少计算过程中的误差。

以8位二进制为例,符号扩展就是指在保持值不变的前提下将其转换成为16位和32位的二进制数。将0111 1111这个正的 8位二进制数转换成为 16位二进制数时,很容易就能够得出0000 0000 0111 1111这个正确的结果,但是像 1111 1111这样的补数来表示的数值,该如何处理?直接将其表示成为1111 1111 1111 1111就可以了。也就是说,不管正数还是补数表示的负数,只需要将 0 和 1 填充高位即可。

内存和磁盘的关系

我们大家知道,计算机的五大基础部件是 存储器控制器运算器输入和输出设备,其中从存储功能的角度来看,可以把存储器分为内存磁盘,我们上面介绍过内存,下面就来介绍一下磁盘以及磁盘和内存的关系

程序不读入内存就无法运行

计算机最主要的存储部件是内存和磁盘。磁盘中存储的程序必须加载到内存中才能运行,在磁盘中保存的程序是无法直接运行的,这是因为负责解析和运行程序内容的 CPU 是需要通过程序计数器来指定内存地址从而读出程序指令的。

image.png

磁盘构造

磁盘缓存

我们上面提到,磁盘往往和内存是互利共生的关系,相互协作,彼此持有良好的合作关系。每次内存都需要从磁盘中读取数据,必然会读到相同的内容,所以一定会有一个角色负责存储我们经常需要读到的内容。 我们大家做软件的时候经常会用到缓存技术,那么硬件层面也不例外,磁盘也有缓存,磁盘的缓存叫做磁盘缓存

磁盘缓存指的是把从磁盘中读出的数据存储到内存的方式,这样一来,当接下来需要读取相同的内容时,就不会再通过实际的磁盘,而是通过磁盘缓存来读取。某一种技术或者框架的出现势必要解决某种问题的,那么磁盘缓存就大大改善了磁盘访问的速度

image.png

虚拟内存

虚拟内存是内存和磁盘交互的第二个媒介。虚拟内存是指把磁盘的一部分作为假想内存来使用。这与磁盘缓存是假想的磁盘(实际上是内存)相对,虚拟内存是假想的内存(实际上是磁盘)。

虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续可用的内存(一个完整的地址空间),但是实际上,它通常被分割成多个物理碎片,还有部分存储在外部磁盘管理器上,必要时进行数据交换。

通过借助虚拟内存,在内存不足时仍然可以运行程序。例如,在只剩 5MB 内存空间的情况下仍然可以运行 10MB 的程序。由于 CPU 只能执行加载到内存中的程序,因此,虚拟内存的空间就需要和内存中的空间进行置换(swap),然后运行程序。

虚拟内存与内存的交换方式

虚拟内存的方法有分页式分段式 两种。Windows 采用的是分页式。该方式是指在不考虑程序构造的情况下,把运行的程序按照一定大小的页进行分割,并以为单位进行置换。在分页式中,我们把磁盘的内容读到内存中称为 Page In,把内存的内容写入磁盘称为 Page Out。Windows 计算机的页大小为 4KB ,也就是说,需要把应用程序按照 4KB 的页来进行切分,以页(page)为单位放到磁盘中,然后进行置换。

image.png

为了实现内存功能,Windows 在磁盘上提供了虚拟内存使用的文件(page file,页文件)。该文件由 Windows 生成和管理,文件的大小和虚拟内存大小相同,通常大小是内存的 1 - 2 倍。

磁盘的物理结构

之前我们介绍了CPU、内存的物理结构,现在我们来介绍一下磁盘的物理结构。磁盘的物理结构指的是磁盘存储数据的形式

磁盘是通过其物理表面划分成多个空间来使用的。划分的方式有两种:可变长方式扇区方式。前者是将物理结构划分成长度可变的空间,后者是将磁盘结构划分为固定长度的空间。一般 Windows 所使用的硬盘和软盘都是使用扇区这种方式。扇区中,把磁盘表面分成若干个同心圆的空间就是 磁道,把磁道按照固定大小的存储空间划分而成的就是 扇区

image.png

扇区是对磁盘进行物理读写的最小单位。Windows 中使用的磁盘,一般是一个扇区 512 个字节。不过,Windows 在逻辑方面对磁盘进行读写的单位是扇区整数倍簇。根据磁盘容量不同功能,1簇可以是 512 字节(1 簇 = 1扇区)、1KB(1簇 = 2扇区)、2KB、4KB、8KB、16KB、32KB( 1 簇 = 64 扇区)。簇和扇区的大小是相等的。

压缩算法

我们想必都有过压缩解压缩文件的经历,当文件太大时,我们会使用文件压缩来降低文件的占用空间。比如微信上传文件的限制是100 MB,我这里有个文件夹无法上传,但是我解压完成后的文件一定会小于 100 MB,那么我的文件就可以上传了。

此外,我们把相机拍完的照片保存到计算机上的时候,也会使用压缩算法进行文件压缩,文件压缩的格式一般是JPEG

那么什么是压缩算法呢?压缩算法又是怎么定义的呢?在认识算法之前我们需要先了解一下文件是如何存储的

文件存储

文件是将数据存储在磁盘等存储媒介的一种形式。程序文件中最基本的存储数据单位是字节。文件的大小不管是 xxxKB、xxxMB等来表示,就是因为文件是以字节 B = Byte 为单位来存储的。

文件就是字节数据的集合。用 1 字节(8 位)表示的字节数据有 256 种,用二进制表示的话就是 0000 0000 - 1111 1111 。如果文件中存储的数据是文字,那么该文件就是文本文件。如果是图形,那么该文件就是图像文件。在任何情况下,文件中的字节数都是连续存储的。

image.png

压缩算法的定义

上面介绍了文件的集合体其实就是一堆字节数据的集合,那么我们就可以来给压缩算法下一个定义。

压缩算法(compaction algorithm)指的就是数据压缩的算法,主要包括压缩和还原(解压缩)的两个步骤。

其实就是在不改变原有文件属性的前提下,降低文件字节空间和占用空间的一种算法。

根据压缩算法的定义,我们可将其分成不同的类型:

有损和无损

无损压缩:能够无失真地从压缩后的数据重构,准确地还原原始数据。可用于对数据的准确性要求严格的场合,如可执行文件和普通文件的压缩、磁盘的压缩,也可用于多媒体数据的压缩。该方法的压缩比较小。如差分编码、RLE、Huffman编码、LZW编码、算术编码。

有损压缩:有失真,不能完全准确地恢复原始数据,重构的数据只是原始数据的一个近似。可用于对数据的准确性要求不高的场合,如多媒体数据的压缩。该方法的压缩比较大。例如预测编码、音感编码、分形压缩、小波压缩、JPEG/MPEG。

对称性

如果编解码算法的复杂性和所需时间差不多,则为对称的编码方法,多数压缩算法都是对称的。但也有不对称的,一般是编码难而解码容易,如 Huffman 编码和分形编码。但用于密码学的编码方法则相反,是编码容易,而解码则非常难。

帧间与帧内

在视频编码中会同时用到帧内与帧间的编码方法,帧内编码是指在一帧图像内独立完成的编码方法,同静态图像的编码,如 JPEG;而帧间编码则需要参照前后帧才能进行编解码,并在编码过程中考虑对帧之间的时间冗余的压缩,如 MPEG。

实时性

在有些多媒体的应用场合,需要实时处理或传输数据(如现场的数字录音和录影、播放MP3/RM/VCD/DVD、视频/音频点播、网络现场直播、可视电话、视频会议),编解码一般要求延时 ≤50 ms。这就需要简单/快速/高效的算法和高速/复杂的CPU/DSP芯片。

分级处理

有些压缩算法可以同时处理不同分辨率、不同传输速率、不同质量水平的多媒体数据,如JPEG2000、MPEG-2/4。

这些概念有些抽象,主要是为了让大家了解一下压缩算法的分类,下面我们就对具体的几种常用的压缩算法来分析一下它的特点和优劣

几种常用压缩算法的理解

RLE 算法的机制

接下来就让我们正式看一下文件的压缩机制。首先让我们来尝试对 AAAAAABBCDDEEEEEF 这 17 个半角字符的文件(文本文件)进行压缩。虽然这些文字没有什么实际意义,但是很适合用来描述 RLE 的压缩机制。

由于半角字符(其实就是英文字符)是作为 1 个字节保存在文件中的,所以上述的文件的大小就是 17 字节。如图

image.png

那么,如何才能压缩该文件呢?大家不妨也考虑一下,只要是能够使文件小于 17 字节,我们可以使用任何压缩算法。

最显而易见的一种压缩方式我觉得你已经想到了,就是把相同的字符去重化,也就是 字符 * 重复次数 的方式进行压缩。所以上面文件压缩后就会变成下面这样

image.png

从图中我们可以看出,AAAAAABBCDDEEEEEF 的17个字符成功被压缩成了 A6B2C1D2E5F1 的12个字符,也就是 12 / 17 = 70%,压缩比为 70%,压缩成功了。

像这样,把文件内容用 数据 * 重复次数 的形式来表示的压缩方法成为 RLE(Run Length Encoding, 行程长度编码) 算法。RLE 算法是一种很好的压缩方法,经常用于压缩传真的图像等。因为图像文件的本质也是字节数据的集合体,所以可以用 RLE 算法进行压缩

哈夫曼算法和莫尔斯编码

下面我们来介绍另外一种压缩算法,即哈夫曼算法。在了解哈夫曼算法之前,你必须舍弃半角英文数字的1个字符是1个字节(8位)的数据。下面我们就来认识一下哈夫曼算法的基本思想。

文本文件是由不同类型的字符组合而成的,而且不同字符出现的次数也是不一样的。例如,在某个文本文件中,A 出现了 100次左右,Q仅仅用到了 3 次,类似这样的情况很常见。哈夫曼算法的关键就在于 多次出现的数据用小于 8 位的字节数表示,不常用的数据则可以使用超过 8 位的字节数表示。A 和 Q 都用 8 位来表示时,原文件的大小就是 100次 * 8 位 + 3次 * 8 位 = 824位,假设 A 用 2 位,Q 用 10 位来表示就是 2 * 100 + 3 * 10 = 230 位。

不过要注意一点,最终磁盘的存储都是以8位为一个字节来保存文件的。

哈夫曼算法比较复杂,在深入了解之前我们先吃点甜品,了解一下 莫尔斯编码,你一定看过美剧或者战争片的电影,在战争中的通信经常采用莫尔斯编码来传递信息,例如下面

image.png

接下来我们来讲解一下莫尔斯编码,下面是莫尔斯编码的示例,大家把 1 看作是短点(嘀),把 11 看作是长点(嗒)即可。

image.png

莫尔斯编码一般把文本中出现最高频率的字符用短编码 来表示。如表所示,假如表示短点的位是 1,表示长点的位是 11 的话,那么 E(嘀)这一数据的字符就可以用 1 来表示,C(滴答滴答)就可以用 9 位的 110101101来表示。在实际的莫尔斯编码中,如果短点的长度是 1 ,长点的长度就是 3,短点和长点的间隔就是1。这里的长度指的就是声音的长度。比如我们想用上面的 AAAAAABBCDDEEEEEF 例子来用莫尔斯编码重写,在莫尔斯曼编码中,各个字符之间需要加入表示时间间隔的符号。这里我们用 00 加以区分。

所以,AAAAAABBCDDEEEEEF 这个文本就变为了 A * 6 次 + B * 2次 + C * 1次 + D * 2次 + E * 5次 + F * 1次 + 字符间隔 * 16 = 4 位 * 6次 + 8 位 * 2次 + 9 位 * 1 次 + 6位 * 2次 + 1位 * 5次 + 8 位 * 1次 + 2位 * 16次 = 106位 = 14字节。

所以使用莫尔斯电码的压缩比为 14 / 17 = 82%。效率并不太突出。

用二叉树实现哈夫曼算法

刚才已经提到,莫尔斯编码是根据日常文本中各字符的出现频率来决定表示各字符的编码数据长度的。不过,在该编码体系中,对 AAAAAABBCDDEEEEEF 这种文本来说并不是效率最高的。

下面我们来看一下哈夫曼算法。哈夫曼算法是指,为各压缩对象文件分别构造最佳的编码体系,并以该编码体系为基础来进行压缩。因此,用什么样的编码(哈夫曼编码)对数据进行分割,就要由各个文件而定。用哈夫曼算法压缩过的文件中,存储着哈夫曼编码信息和压缩过的数据。

image.png

接下来,我们在对 AAAAAABBCDDEEEEEF 中的 A - F 这些字符,按照出现频率高的字符用尽量少的位数编码来表示这一原则进行整理。按照出现频率从高到低的顺序整理后,结果如下,同时也列出了编码方案。

image.png

在上表的编码方案中,随着出现频率的降低,字符编码信息的数据位数也在逐渐增加,从最开始的 1位、2位依次增加到3位。不过这个编码体系是存在问题的,你不知道100这个3位的编码,它的意思是用 1、0、0这三个编码来表示 E、A、A 呢?还是用10、0来表示 B、A 呢?还是用100来表示 C 呢。

而在哈夫曼算法中,通过借助哈夫曼树的构造编码体系,即使在不使用字符区分符号的情况下,也可以构建能够明确进行区分的编码体系。不过哈夫曼树的算法要比较复杂,下面是一个哈夫曼树的构造过程。

image.png

自然界树的从根开始生叶的,而哈夫曼树则是叶生枝

哈夫曼树能够提升压缩比率

使用哈夫曼树之后,出现频率越高的数据所占用的位数越少,这也是哈夫曼树的核心思想。通过上图的步骤二可以看出,枝条连接数据时,我们是从出现频率较低的数据开始的。这就意味着出现频率低的数据到达根部的枝条也越多。而枝条越多则意味着编码的位数随之增加。

接下来我们来看一下哈夫曼树的压缩比率,用上图得到的数据表示 AAAAAABBCDDEEEEEF 为 000000000000 100100 110 101101 0101010101 111,40位 = 5 字节。压缩前的数据是 17 字节,压缩后的数据竟然达到了惊人的5 字节,也就是压缩比率 = 5 / 17 = 29% 如此高的压缩率,简直是太惊艳了。

大家可以参考一下,无论哪种类型的数据,都可以用哈夫曼树作为压缩算法

image.png

可逆压缩和非可逆压缩

最后,我们来看一下图像文件的数据形式。图像文件的使用目的通常是把图像数据输出到显示器、打印机等设备上。常用的图像格式有 : BMPJPEGTIFFGIF 格式等。

  • BMP : 是使用 Windows 自带的画笔来做成的一种图像形式
  • JPEG:是数码相机等常用的一种图像数据形式
  • TIFF: 是一种通过在文件中包含"标签"就能够快速显示出数据性质的图像形式
  • GIF: 是由美国开发的一种数据形式,要求色数不超过 256个

图像文件可以使用前面介绍的 RLE 算法和哈夫曼算法,因为图像文件在多数情况下并不要求数据需要还原到和压缩之前一摸一样的状态,允许丢失一部分数据。我们把能还原到压缩前状态的压缩称为 可逆压缩,无法还原到压缩前状态的压缩称为非可逆压缩

image.png

一般来说,JPEG格式的文件是非可逆压缩,因此还原后有部分图像信息比较模糊。GIF 是可逆压缩

操作系统

操作系统环境

程序中包含着运行环境这一内容,可以说 运行环境 = 操作系统 + 硬件 ,操作系统又可以被称为软件,它是由一系列的指令组成的。我们不介绍操作系统,我们主要来介绍一下硬件的识别。

我们肯定都玩儿过游戏,你玩儿游戏前需要干什么?是不是需要先看一下自己的笔记本或者电脑是不是能肝的起游戏?下面是一个游戏的配置(怀念一下 wow)

image.png

图中的主要配置如下

  • 操作系统版本:说的就是应用程序运行在何种系统环境,现在市面上主要有三种操作系统环境,Windows 、Linux 和 Unix ,一般我们玩儿的大型游戏几乎都是在 Windows 上运行,可以说 Windows 是游戏的天堂。Windows 操作系统也会有区分,分为32位操作系统和64位操作系统,互不兼容。
  • 处理器:处理器指的就是 CPU,你的电脑的计算能力,通俗来讲就是每秒钟能处理的指令数,如果你的电脑觉得卡带不起来的话,很可能就是 CPU 的计算能力不足导致的。想要加深理解,请阅读博主的另一篇文章:程序员需要了解的硬核知识之CPU
  • 显卡:显卡承担图形的输出任务,因此又被称为图形处理器(Graphic Processing Unit,GPU),显卡也非常重要,比如我之前玩儿的剑灵开五档(其实就是图像变得更清晰)会卡,其实就是显卡显示不出来的原因。
  • 内存:内存即主存,就是你的应用程序在运行时能够动态分析指令的这部分存储空间,它的大小也能决定你电脑的运行速度,想要加深理解,请阅读博主的另一篇文章 程序员需要了解的硬核知识之内存
  • 存储空间:存储空间指的就是应用程序安装所占用的磁盘空间,由图中可知,此游戏的最低存储空间必须要大于 5GB,其实我们都会遗留很大一部分用来安装游戏。

从程序的运行环境这一角度来考量的话,CPU 的种类是特别重要的参数,为了使程序能够正常运行,必须满足 CPU 所需的最低配置。

CPU 只能解释其自身固有的语言。不同的 CPU 能解释的机器语言的种类也是不同的。机器语言的程序称为 本地代码(native code),程序员用 C 等高级语言编写的程序,仅仅是文本文件。文本文件(排除文字编码的问题)在任何环境下都能显示和编辑。我们称之为源代码。通过对源代码进行编译,就可以得到本地代码。下图反映了这个过程。

image.png

Windows 操作系统克服了CPU以外的硬件差异

计算机的硬件并不仅仅是由 CPU 组成的,还包括用于存储程序指令的数据和内存,以及通过 I/O 连接的键盘、显示器、硬盘、打印机等外围设备。

在 WIndows 软件中,键盘输入、显示器输出等并不是直接向硬件发送指令。而是通过向 Windows 发送指令实现的。因此,程序员就不用注意内存和 I/O 地址的不同构成了。Windows 操作的是硬件而不是软件,软件通过操作 Windows 系统可以达到控制硬件的目的。

image.png

不同操作系统的 API 差异性

接下来我们看一下操作系统的种类。同样机型的计算机,可安装的操作系统类型也会有多种选择。例如:AT 兼容机除了可以安装 Windows 之外,还可以采用 Unix 系列的 Linux 以及 FreeBSD (也是一种Unix操作系统)等多个操作系统。当然,应用软件则必须根据不同的操作系统类型来专门开发。CPU 的类型不同,所对应机器的语言也不同,同样的道理,操作系统的类型不同,应用程序向操作系统传递指令的途径也不同

应用程序向系统传递指令的途径称为 API(Application Programming Interface)。Windows 以及 Linux 操作系统的 API,提供了任何应用程序都可以利用的函数组合。因为不同操作系统的 API 是有差异的。所以,如何要将同样的应用程序移植到另外的操作系统,就必须要覆盖应用所用到的 API 部分。

键盘输入、鼠标输入、显示器输出、文件输入和输出等同外围设备进行交互的功能,都是通过 API 提供的。

这也就是为什么 Windows 应用程序不能直接移植到 Linux 操作系统上的原因,API 差异太大了。

在同类型的操作系统下,不论硬件如何,API 几乎相同。但是,由于不同种类 CPU 的机器语言不同,因此本地代码也不尽相同。

操作系统功能的历史

操作系统其实也是一种软件,任何新事物的出现肯定都有它的历史背景,那么操作系统也不是凭空出现的,肯定有它的历史背景。

在计算机尚不存在操作系统的年代,完全没有任何程序,人们通过各种按钮来控制计算机,这一过程非常麻烦。于是,有人开发出了仅具有加载和运行功能的监控程序,这就是操作系统的原型。通过事先启动监控程序,程序员可以根据需要将各种程序加载到内存中运行。虽然仍旧比较麻烦,但比起在没有任何程序的状态下进行开发,工作量得到了很大的缓解。

image.png

随着时代的发展,人们在利用监控程序编写程序的过程中发现很多程序都有公共的部分。例如,通过键盘进行文字输入,显示器进行数据展示等,如果每编写一个新的应用程序都需要相同的处理的话,那真是太浪费时间了。因此,基本的输入输出部分的程序就被追加到了监控程序中。初期的操作系统就是这样诞生了。

image.png

类似的想法可以共用,人们又发现有更多的应用程序可以追加到监控程序中,比如硬件控制程序编程语言处理器(汇编、编译、解析)以及各种应用程序等,结果就形成了和现在差异不大的操作系统,也就是说,其实操作系统是多个程序的集合体。

image.png

Windows 操作系统的特征

Windows 操作系统是世界上用户数量最庞大的群体,作为 Windows 操作系统的资深用户,你都知道 Windows 操作系统有哪些特征吗?下面列举了一些 Windows 操作系统的特性

  • Windows 操作系统有两个版本:32位和64位
  • 通过 API 函数集成来提供系统调用
  • 提供了采用图形用户界面的用户界面
  • 通过 WYSIWYG 实现打印输出,WYSIWYG 其实就是 What You See Is What You Get ,值得是显示器上显示的图形和文本都是可以原样输出到打印机打印的。
  • 提供多任务功能,即能够同时开启多个任务
  • 提供网络功能和数据库功能
  • 通过即插即用实现设备驱动的自设定

这些是对程序员来讲比较有意义的一些特征,下面针对这些特征来进行分别的介绍

32位操作系统

这里表示的32位操作系统表示的是处理效率最高的数据大小。Windows 处理数据的基本单位是 32 位。这与最一开始在 MS-DOS 等16位操作系统不同,因为在16位操作系统中处理32位数据需要两次,而32位操作系统只需要一次就能够处理32位的数据,所以一般在 windows 上的应用,它们的最高能够处理的数据都是 32 位的。

比如,用 C 语言来处理整数数据时,有8位的 char 类型,16位的short类型,以及32位的long类型三个选项,使用位数较大的 long 类型进行处理的话,增加的只是内存以及磁盘的开销,对性能影响不大。

现在市面上大部分都是64位操作系统了,64位操作系统也是如此。

通过 API 函数集来提供系统调用

Windows 是通过名为 API 的函数集来提供系统调用的。API是联系应用程序和操作系统之间的接口,全称叫做 Application Programming Interface,应用程序接口。

当前主流的32位版 Windows API 也称为 Win32 API,之所以这样命名,是需要和不同的操作系统进行区分,比如最一开始的 16 位版的 Win16 API,和后来流行的 Win64 API

API 通过多个 DLL 文件来提供,各个 API 的实体都是用 C 语言编写的函数。所以,在 C 语言环境下,使用 API 更加容易,比如 API 所用到的 MessageBox() 函数,就被保存在了 Windows 提供的 user32.dll 这个 DLL 文件中。

提供采用了 GUI 的用户界面

GUI(Graphical User Interface) 指得就是图形用户界面,通过点击显示器中的窗口以及图标等可视化的用户界面,举个例子:Linux 操作系统就有两个版本,一种是简洁版,直接通过命令行控制硬件,还有一种是可视化版,通过光标点击图形界面来控制硬件。

通过 WYSIWYG 实现打印输出

WYSIWYG 指的是显示器上输出的内容可以直接通过打印机打印输出。在 Windows 中,显示器和打印机被认作同等的图形输出设备处理的,该功能也为 WYSIWYG 提供了条件。

借助 WYSIWYG 功能,程序员可以轻松不少。最初,为了是现在显示器中显示和在打印机中打印,就必须分别编写各自的程序,而在 Windows 中,可以借助 WYSIWYG 基本上在一个程序中就可以做到显示和打印这两个功能了。

提供多任务功能

多任务指的就是同时能够运行多个应用程序的功能,Windows 是通过时钟分割技术来实现多任务功能的。时钟分割指的是短时间间隔内,多个程序切换运行的方式。在用户看来,就好像是多个程序在同时运行,其底层是 CPU 时间切片,这也是多线程多任务的核心。

image.png

提供网络功能和数据库功能

Windows 中,网络功能是作为标准功能提供的。数据库(数据库服务器)功能有时也会在后面追加。网络功能和数据库功能虽然并不是操作系统不可或缺的,但因为它们和操作系统很接近,所以被统称为中间件而不是应用。意思是处于操作系统和应用的中间层,操作系统和中间件组合在一起,称为系统软件。应用不仅可以利用操作系统,也可以利用中间件的功能。

image.png

相对于操作系统一旦安装就不能轻易更换,中间件可以根据需要进行更换,不过,对于大部分应用来说,更换中间件的话,会造成应用也随之更换,从这个角度来说,更å换中间件也不是那么容易。

通过即插即用实现设备驱动的自动设定

即插即用(Plug-and-Play)指的是新的设备连接(plug) 后就可以直接使用的机制,新设备连接计算机后,计算机就会自动安装和设定用来控制该设备的驱动程序

设备驱动是操作系统的一部分,提供了同硬件进行基本的输入输出的功能。键盘、鼠标、显示器、磁盘装置等,这些计算机中必备的硬件的设备驱动,一般都是随操作系统一起安装的。

有时 DLL 文件也会同设备驱动文件一起安装。这些 DLL 文件中存储着用来利用该新追加的硬件API,通过 API ,可以制作出运行该硬件的心应用。

汇编语言和本地代码

我们在之前的文章中探讨过,计算机 CPU 只能运行本地代码(机器语言)程序,用 C 语言等高级语言编写的代码,需要经过编译器编译后,转换为本地代码才能够被 CPU 解释执行。

但是本地代码的可读性非常差,所以需要使用一种能够直接读懂的语言来替换本地代码,那就是在各本地代码中,附带上表示其功能的英文缩写,比如在加法运算的本地代码加上add(addition) 的缩写、在比较运算符的本地代码中加上cmp(compare)的缩写等,这些通过缩写来表示具体本地代码指令的标志称为 助记符,使用助记符的语言称为汇编语言。这样,通过阅读汇编语言,也能够了解本地代码的含义了。

不过,即使是使用汇编语言编写的源代码,最终也必须要转换为本地代码才能够运行,负责做这项工作的程序称为编译器,转换的这个过程称为汇编。在将源代码转换为本地代码这个功能方面,汇编器和编译器是同样的。

用汇编语言编写的源代码和本地代码是一一对应的。因而,本地代码也可以反过来转换成汇编语言编写的代码。把本地代码转换为汇编代码的这一过程称为反汇编,执行反汇编的程序称为反汇编程序

image.png

哪怕是 C 语言编写的源代码,编译后也会转换成特定 CPU 用的本地代码。而将其反汇编的话,就可以得到汇编语言的源代码,并对其内容进行调查。不过,本地代码变成 C 语言源代码的反编译,要比本地代码转换成汇编代码的反汇编要困难,这是因为,C 语言代码和本地代码不是一一对应的关系。

通过编译器输出汇编语言的源代码

我们上面提到本地代码可以经过反汇编转换成为汇编代码,但是只有这一种转换方式吗?显然不是,C 语言编写的源代码也能够通过编译器编译称为汇编代码,下面就来尝试一下。

首先需要先做一些准备,需要先下载 Borland C++ 5.5 编译器,为了方便,我这边直接下载好了读者直接从我的百度网盘提取即可 (链接:https://pan.baidu.com/s/19LqVICpn5GcV88thD2AnlA 密码:hz1u)

下载完毕,需要进行配置,下面是配置说明 (https://wenku.baidu.com/view/22e2f418650e52ea551898ad.html),教程很完整跟着配置就可以,下面开始我们的编译过程

首先用 Windows 记事本等文本编辑器编写如下代码

 // 返回两个参数值之和的函数
 intAddNum(inta,intb){
  returna+b;
 }
 ​
 // 调用 AddNum 函数的函数
 voidMyFunc(){
  intc;
  c=AddNum(123,456);
 }

编写完成后将其文件名保存为 Sample4.c ,C 语言源文件的扩展名,通常用.c 来表示,上面程序是提供两个输入参数并返回它们之和。

在 Windows 操作系统下打开命令提示符,切换到保存 Sample4.c 的文件夹下,然后在命令提示符中输入

 bcc32-c-SSample4.c

作为编译的结果,当前目录下会生成一个名为Sample4.asm的汇编语言源代码。汇编语言源文件的扩展名,通常用.asm来表示,下面就让我们用编辑器打开看一下 Sample4.asm 中的内容

 .386p
 ifdef??version
 if  ??version GT 500H
 .mmx
 endif
 endif
 modelflat
 ifndef??version
 ?debugmacro
 endm
 endif
 ?debugS"Sample4.c"
 ?debugT"Sample4.c"
 _TEXTsegment dword public use32 'CODE'
 _TEXTends
 _DATAsegment dword public use32 'DATA'
 _DATAends
 _BSSsegment dword public use32 'BSS'
 _BSSends
 DGROUPgroup_BSS,_DATA
 _TEXTsegment dword public use32 'CODE'
 _AddNumprocnear
 ?live1@0:
  ;
  ;int AddNum(int a,int b){
  ;
 push     ebp
 mov     ebp,esp
  ;
  ;
  ;  return a + b;
  ;
 @1:
 mov     eax,dword ptr [ebp+8]
 add     eax,dword ptr [ebp+12]
  ;
  ;}
  ;
 @3:
 @2:
 pop     ebp
 ret
 _AddNumendp
 _MyFuncprocnear
 ?live1@48:
  ;
  ;void MyFunc(){
  ;
 push     ebp
 mov     ebp,esp
  ;
  ;  int c;
  ;  c = AddNum(123,456);
  ;
 @4:
 push     456
 push     123
 call     _AddNum
 add     esp,8
  ;
  ;}
  ;
 @5:
 pop     ebp
 ret
 _MyFuncendp
 _TEXTends
 public_AddNum
 public_MyFunc
 ?debugD"Sample4.c"20343 45835
 end

这样,编译器就成功的把 C 语言转换成为了汇编代码了。

不会转换成本地代码的伪指令

第一次看到汇编代码的读者可能感觉起来比较难,不过实际上其实比较简单,而且可能比 C 语言还要简单,为了便于阅读汇编代码的源代码,需要注意几个要点

汇编语言的源代码,是由转换成本地代码的指令(后面讲述的操作码)和针对汇编器的伪指令构成的。伪指令负责把程序的构造以及汇编的方法指示给汇编器(转换程序)。不过伪指令是无法汇编转换成为本地代码的。下面是上面程序截取的伪指令

 _TEXTsegment dword public use32 'CODE'
 _TEXTends
 _DATAsegment dword public use32 'DATA'
 _DATAends
 _BSSsegment dword public use32 'BSS'
 _BSSends
 DGROUPgroup_BSS,_DATA
 ​
 _AddNumprocnear
 _AddNumendp
 ​
 _MyFuncprocnear
 _MyFuncendp
 ​
 _TEXTends
 end

由伪指令 segmentends 围起来的部分,是给构成程序的命令和数据的集合体上加一个名字而得到的,称为段定义。段定义的英文表达具有区域的意思,在这个程序中,段定义指的是命令和数据等程序的集合体的意思,一个程序由多个段定义构成。

上面代码的开始位置,定义了3个名称分别为_TEXT、_DATA、_BSS的段定义,_TEXT是指定的段定义,_DATA是被初始化(有初始值)的数据的段定义,_BSS是尚未初始化的数据的段定义。这种定义的名称是由 Borland C++ 定义的,是由 Borland C++ 编译器自动分配的,所以程序段定义的顺序就成为了_TEXT、_DATA、_BSS,这样也确保了内存的连续性

 _TEXTsegment dword public use32 'CODE'
 _TEXTends
 _DATAsegment dword public use32 'DATA'
 _DATAends
 _BSSsegment dword public use32 'BSS'
 _BSSends

段定义( segment ) 是用来区分或者划分范围区域的意思。汇编语言的 segment 伪指令表示段定义的起始,ends 伪指令表示段定义的结束。段定义是一段连续的内存空间

group 这个伪指令表示的是将 _BSS和_DATA 这两个段定义汇总名为 DGROUP 的组

 DGROUPgroup_BSS,_DATA

围起_AddNum_MyFun_TEXTsegment 和_TEXTends ,表示_AddNum_MyFun是属于_TEXT这一段定义的。

 _TEXTsegment dword public use32 'CODE'
 _TEXTends

因此,即使在源代码中指令和数据是混杂编写的,经过编译和汇编后,也会转换成为规整的本地代码。

_AddNum proc_AddNum endp 围起来的部分,以及_MyFunc proc_MyFunc endp 围起来的部分,分别表示 AddNum 函数和 MyFunc 函数的范围。

1 _AddNum proc near

2 _AddNum endp

3

4 _MyFunc proc near

5 _MyFunc endp

编译后在函数名前附带上下划线_ ,是 Borland C++ 的规定。在 C 语言中编写的 AddNum 函数,在内部是以 _AddNum 这个名称处理的。伪指令 proc 和 endp 围起来的部分,表示的是 过程(procedure) 的范围。在汇编语言中,这种相当于 C 语言的函数的形式称为过程。

末尾的end伪指令,表示的是源代码的结束。

汇编语言的语法是 操作码 + 操作数

在汇编语言中,一行表示一对 CPU 的一个指令。汇编语言指令的语法结构是操作码 + 操作数,也存在只有操作码没有操作数的指令。

操作码表示的是指令动作,操作数表示的是指令对象。操作码和操作数一起使用就是一个英文指令。比如从英语语法来分析的话,操作码是动词,操作数是宾语。比如这个句子Give me money这个英文指令的话,Give 就是操作码,me 和 money 就是操作数。汇编语言中存在多个操作数的情况,要用逗号把它们分割,就像是 Give me,money 这样。

能够使用何种形式的操作码,是由 CPU 的种类决定的,下面对操作码的功能进行了整理。

image.png

本地代码需要加载到内存后才能运行,内存中存储着构成本地代码的指令和数据。程序运行时,CPU会从内存中把数据和指令读出来,然后放在 CPU 内部的寄存器中进行处理。

image.png

如果 CPU 和内存的关系你还不是很了解的话,请阅读作者的另一篇文章 程序员需要了解的硬核知识之CPU 详细了解。

寄存器是 CPU 中的存储区域,寄存器除了具有临时存储和计算的功能之外,还具有运算功能,x86 系列的主要种类和角色如下图所示

image.png

指令解析

下面就对 CPU 中的指令进行分析

最常用的 mov 指令

指令中最常使用的是对寄存器和内存进行数据存储的 mov 指令,mov 指令的两个操作数,分别用来指定数据的存储地和读出源。操作数中可以指定寄存器、常数、标签(附加在地址前),以及用方括号([]) 围起来的这些内容。如果指定了没有用([]) 方括号围起来的内容,就表示对该值进行处理;如果指定了用方括号围起来的内容,方括号的值则会被解释为内存地址,然后就会对该内存地址对应的值进行读写操作。让我们对上面的代码片段进行说明

 mov     ebp,esp
 mov     eax,dword ptr [ebp+8]

mov ebp,esp 中,esp 寄存器中的值被直接存储在了 ebp 中,也就是说,如果 esp 寄存器的值是100的话那么 ebp 寄存器的值也是 100。

而在 mov eax,dword ptr [ebp+8] 这条指令中,ebp 寄存器的值 + 8 后会被解析称为内存地址。如果 ebp

寄存器的值是100的话,那么 eax 寄存器的值就是 100 + 8 的地址的值。dword ptr 也叫做 double word pointer 简单解释一下就是从指定的内存地址中读出4字节的数据

对栈进行 push 和 pop

程序运行时,会在内存上申请分配一个称为栈的数据空间。栈(stack)的特性是后入先出,数据在存储时是从内存的下层(大的地址编号)逐渐往上层(小的地址编号)累积,读出时则是按照从上往下进行读取的。

image.png

栈是存储临时数据的区域,它的特点是通过 push 指令和 pop 指令进行数据的存储和读出。向栈中存储数据称为入栈,从栈中读出数据称为出栈,32位 x86 系列的 CPU 中,进行1次 push 或者 pop,即可处理 32 位(4字节)的数据。

函数的调用机制

下面我们一起来分析一下函数的调用机制,我们以上面的 C 语言编写的代码为例。首先,让我们从MyFunc函数调用AddNum函数的汇编语言部分开始,来对函数的调用机制进行说明。栈在函数的调用中发挥了巨大的作用,下面是经过处理后的 MyFunc 函数的汇编处理内容

 _MyFuncprocnear
 pushebp ; 将 ebp 寄存器的值存入栈中             (1)
 movebp,esp; 将 esp 寄存器的值存入 ebp 寄存器中  (2)
 push456; 将 456 入栈(3)
 push123; 将 123 入栈(4)
 call_AddNum; 调用 AddNum 函数(5)
 addesp,8; esp 寄存器的值 + 8(6)
 popebp; 读出栈中的数值存入 esp 寄存器中(7)
 ret; 结束 MyFunc 函数,返回到调用源(8)
 _MyFuncendp

代码解释中的(1)、(2)、(7)、(8)的处理适用于 C 语言中的所有函数,我们会在后面展示AddNum函数处理内容时进行说明。这里希望大家先关注(3) - (6) 这一部分,这对了解函数调用机制至关重要。

(3) 和 (4) 表示的是将传递给 AddNum 函数的参数通过 push 入栈。在 C 语言源代码中,虽然记述为函数 AddNum(123,456),但入栈时则会先按照 456,123 这样的顺序。也就是位于后面的数值先入栈。这是 C 语言的规定。(5) 表示的 call 指令,会把程序流程跳转到 AddNum 函数指令的地址处。在汇编语言中,函数名表示的就是函数所在的内存地址。AddNum 函数处理完毕后,程序流程必须要返回到编号(6) 这一行。call 指令运行后,call 指令的下一行(也就指的是 (6) 这一行)的内存地址(调用函数完毕后要返回的内存地址)会自动的 push 入栈。该值会在 AddNum 函数处理的最后通过ret指令 pop 出栈,然后程序会返回到 (6) 这一行。

(6) 部分会把栈中存储的两个参数 (456 和 123) 进行销毁处理。虽然通过两次的 pop 指令也可以实现,不过采用 esp 寄存器 + 8 的方式会更有效率(处理 1 次即可)。对栈进行数值的输入和输出时,数值的单位是4字节。因此,通过在负责栈地址管理的 esp 寄存器中加上4的2倍8,就可以达到和运行两次 pop 命令同样的效果。虽然内存中的数据实际上还残留着,但只要把 esp 寄存器的值更新为数据存储地址前面的数据位置,该数据也就相当于销毁了。

我在编译Sample4.c文件时,出现了下图的这条消息

image.png

图中的意思是指 c 的值在 MyFunc 定义了但是一直未被使用,这其实是一项编译器优化的功能,由于存储着 AddNum 函数返回值的变量 c 在后面没有被用到,因此编译器就认为该变量没有意义,进而也就没有生成与之对应的汇编语言代码

下图是调用 AddNum 这一函数前后栈内存的变化

image.png

函数的内部处理

上面我们用汇编代码分析了一下 Sample4.c 整个过程的代码,现在我们着重分析一下 AddNum 函数的源代码部分,分析一下参数的接收、返回值和返回等机制

 _AddNumprocnear
 pushebp              -----------(1)
 movebp,esp              -----------(2)
 moveax,dword ptr[ebp+8]   -----------(3)
 addeax,dword ptr[ebp+12] -----------(4)
 popebp-----------(5)
 ret----------------------------------(6)
 _AddNumendp

ebp 寄存器的值在(1)中入栈,在(5)中出栈,这主要是为了把函数中用到的 ebp 寄存器的内容,恢复到函数调用前的状态。

(2) 中把负责管理栈地址的 esp 寄存器的值赋值到了 ebp 寄存器中。这是因为,在 mov 指令中方括号内的参数,是不允许指定 esp 寄存器的。因此,这里就采用了不直接通过 esp,而是用 ebp 寄存器来读写栈内容的方法。

(3) 使用[ebp + 8] 指定栈中存储的第1个参数123,并将其读出到 eax 寄存器中。像这样,不使用 pop 指令,也可以参照栈的内容。而之所以从多个寄存器中选择了 eax 寄存器,是因为 eax 是负责运算的累加寄存器。

通过(4) 的 add 指令,把当前 eax 寄存器的值同第2个参数相加后的结果存储在 eax 寄存器中。[ebp + 12] 是用来指定第2个参数456的。在 C 语言中,函数的返回值必须通过 eax 寄存器返回,这也是规定。也就是函数的参数是通过栈来传递,返回值是通过寄存器返回的

(6) 中 ret 指令运行后,函数返回目的地内存地址会自动出栈,据此,程序流程就会跳转返回到(6) (Call _AddNum)的下一行。这时,AddNum 函数入口和出口处栈的状态变化,就如下图所示

image.png

全局变量和局部变量

在熟悉了汇编语言后,接下来我们来了解一下全局变量和局部变量,在函数外部定义的变量称为全局变量,在函数内部定义的变量称为局部变量,全局变量可以在任意函数中使用,局部变量只能在函数定义局部变量的内部使用。下面,我们就通过汇编语言来看一下全局变量和局部变量的不同之处。

下面定义的 C 语言代码分别定义了局部变量和全局变量,并且给各变量进行了赋值,我们先看一下源代码部分

 // 定义被初始化的全局变量
 inta1=1;
 inta2=2;
 inta3=3;
 inta4=4;
 inta5=5;
 ​
 // 定义没有初始化的全局变量
 intb1,b2,b3,b4,b5;
 ​
 // 定义函数
 voidMyFunc(){
  // 定义局部变量
  intc1,c2,c3,c4,c5,c6,c7,c8,c9,c10;
 
  // 给局部变量赋值
  c1=1;
  c2=2;
  c3=3;
  c4=4;
  c5=5;
  c6=6;
  c7=7;
  c8=8;
  c9=9;
  c10=10;
 
  // 把局部变量赋值给全局变量
  a1=c1;
  a2=c2;
  a3=c3;
  a4=c4;
  a5=c5;
  b1=c6;
  b2=c7;
  b3=c8;
  b4=c9;
  b5=c10;
 }

上面的代码挺暴力的,不过没关系,能够便于我们分析其汇编源码就好,我们用 Borland C++ 编译后的汇编代码如下,编译完成后的源码比较长,这里我们只拿出来一部分作为分析使用(我们改变了一下段定义顺序,删除了部分注释)

上面的代码挺暴力的,不过没关系,能够便于我们分析其汇编源码就好,我们用 Borland C++ 编译后的汇编代码如下,编译完成后的源码比较长,这里我们只拿出来一部分作为分析使用(我们改变了一下段定义顺序,删除了部分注释)

 _DATAsegment dword public use32 'DATA'
  align4
  _a1label dword
  dd1
  align4
  _a2label dword
  dd2
  align4
  _a3label dword
  dd3
  align4
  _a4label dword
  dd4
  align4
  _a5label dword
  dd5
 _DATAends
 ​
 _BSSsegment dword public use32 'BSS'
 align4
  _b1label dword
  db4dup(?)
  align4
  _b2label dword
  db4dup(?)
  align4
  _b3label dword
  db4dup(?)
  align4
  _b4label dword
  db4dup(?)
  align4
  _b5label dword
  db4dup(?)
 _BSSends
 ​
 _TEXTsegment dword public use32 'CODE'
 _MyFuncproc near
 ​
 push     ebp
 mov     ebp,esp
 add     esp,-20
 push     ebx
 push     esi
 mov     eax,1
 mov     edx,2
 mov     ecx,3
 mov     ebx,4
 mov     esi,5
 mov     dwordptr[ebp-4],6
 mov     dwordptr[ebp-8],7
 mov     dwordptr[ebp-12],8
 mov     dwordptr[ebp-16],9
 mov     dwordptr[ebp-20],10
 mov     dwordptr[_a1],eax
 mov     dwordptr[_a2],edx
 mov     dwordptr[_a3],ecx
 mov     dwordptr[_a4],ebx
 mov     dwordptr[_a5],esi
 mov     eax,dword ptr [ebp-4]
 mov     dwordptr[_b1],eax
 mov     edx,dword ptr [ebp-8]
 mov     dwordptr[_b2],edx
 mov     ecx,dword ptr [ebp-12]
 mov     dwordptr[_b3],ecx
 mov     eax,dword ptr [ebp-16]
 mov     dwordptr[_b4],eax
 mov     edx,dword ptr [ebp-20]
 mov     dwordptr[_b5],edx
 pop     esi
 pop     ebx
 mov     esp,ebp
 pop     ebp
 ret
 
 _MyFunc  endp
 _TEXTends

编译后的程序,会被归类到名为段定义的组。

  • 初始化的全局变量,会汇总到名为 _DATA 的段定义中

 _DATAsegment dword public use32 'DATA'
 ...
 _DATAends

  • 没有初始化的全局变量,会汇总到名为 _BSS 的段定义中

 _BSSsegment dword public use32 'BSS'
 ...
 _BSSends

  • 被段定义 _TEXT 围起来的汇编代码则是 Borland C++ 的定义

 _TEXTsegment dword public use32 'CODE'
 _MyFuncproc near
 ...
 _MyFunc  endp
 _TEXTends

我们在分析上面汇编代码之前,先来认识一下更多的汇编指令,此表是对上面部分操作码及其功能的接续

image.png

我们首先来看一下_DATA段定义的内容。_a1 label dword定义了_a1这个标签。标签表示的是相对于段定义起始位置的位置。由于_a1_DATA 段定义的开头位置,所以相对位置是0。_a1就相当于是全局变量a1。编译后的函数名和变量名前面会加一个(_),这也是 Borland C++ 的规定。dd 1指的是,申请分配了4字节的内存空间,存储着1这个初始值。 dd指的是define double word表示有两个长度为2的字节领域(word),也就是4字节的意思。

Borland C++ 中,由于int类型的长度是4字节,因此汇编器就把 int a1 = 1 变换成了_a1 label dword 和 dd 1。同样,这里也定义了相当于全局变量的 a2 - a5 的标签_a2 - _a5,它们各自的初始值 2 - 5 也被存储在各自的4字节中。

接下来,我们来说一说_BSS段定义的内容。这里定义了相当于全局变量 b1 - b5 的标签_b1 - _b5。其中的db 4dup(?)表示的是申请分配了4字节的领域,但值尚未确定(这里用 ? 来表示)的意思。db(define byte)表示有1个长度是1字节的内存空间。因而,db 4 dup(?) 的情况下,就是4字节的内存空间。

注意:db 4 dup(?) 不要和 dd 4 混淆了,前者表示的是4个长度是1字节的内存空间。而 db 4 表示的则是双字节( = 4 字节) 的内存空间中存储的值是 4

临时确保局部变量使用的内存空间

我们知道,局部变量是临时保存在寄存器和栈中的。函数内部利用栈进行局部变量的存储,函数调用完成后,局部变量值被销毁,但是寄存器可能用于其他目的。所以,局部变量只是函数在处理期间临时存储在寄存器和栈中的

回想一下上述代码是不是定义了10个局部变量?这是为了表示存储局部变量的不仅仅是栈,还有寄存器。为了确保 c1 - c10 所需的域,寄存器空闲的时候就会使用寄存器,寄存器空间不足的时候就会使用栈。

让我们继续来分析上面代码的内容。_TEXT段定义表示的是 MyFunc 函数的范围。在 MyFunc 函数中定义的局部变量所需要的内存领域。会被尽可能的分配在寄存器中。大家可能认为使用高性能的寄存器来替代普通的内存是一种资源浪费,但是编译器不这么认为,只要寄存器有空间,编译器就会使用它。由于寄存器的访问速度远高于内存,所以直接访问寄存器能够高效的处理。局部变量使用寄存器,是 Borland C++ 编译器最优化的运行结果。

代码清单中的如下内容表示的是向寄存器中分配局部变量的部分

1 mov       eax,1

2 mov       edx,2

3 mov       ecx,3

4 mov       ebx,4

5 mov       esi,5

仅仅对局部变量进行定义是不够的,只有在给局部变量赋值时,才会被分配到寄存器的内存区域。上述代码相当于就是给5个局部变量 c1 - c5 分别赋值为 1 - 5。eax、edx、ecx、ebx、esi 是 x86 系列32位 CPU 寄存器的名称。至于使用哪个寄存器,是由编译器来决定的 。

x86 系列 CPU 拥有的寄存器中,程序可以操作的是十几,其中空闲的最多会有几个。因而,局部变量超过寄存器数量的时候,可分配的寄存器就不够用了,这种情况下,编译器就会把栈派上用场,用来存储剩余的局部变量。

在上述代码这一部分,给局部变量c1 - c5 分配完寄存器后,可用的寄存器数量就不足了。于是,剩下的5个局部变量c6 - c10 就被分配给了栈的内存空间。如下面代码所示

 mov     dwordptr[ebp-4],6
 mov     dwordptr[ebp-8],7
 mov     dwordptr[ebp-12],8
 mov     dwordptr[ebp-16],9
 mov     dwordptr[ebp-20],10

函数入口add esp,-20指的是,对栈数据存储位置的 esp 寄存器(栈指针)的值做减20的处理。为了确保内存变量 c6 - c10 在栈中,就需要保留5个 int 类型的局部变量(4字节 * 5 = 20 字节)所需的空间。mov ebp,esp这行指令表示的意思是将 esp 寄存器的值赋值到 ebp 寄存器。之所以需要这么处理,是为了通过在函数出口处mov esp ebp这一处理,把 esp 寄存器的值还原到原始状态,从而对申请分配的栈空间进行释放,这时栈中用到的局部变量就消失了。这也是栈的清理处理。在使用寄存器的情况下,局部变量则会在寄存器被用于其他用途时自动消失,如下图所示。

image.png

1 mov       dword ptr [ebp-4],6

2 mov       dword ptr [ebp-8],7

3 mov       dword ptr [ebp-12],8

4 mov       dword ptr [ebp-16],9

5 mov       dword ptr [ebp-20],10

这五行代码是往栈空间代入数值的部分,由于在向栈申请内存空间前,借助了 mov ebp, esp 这个处理,esp 寄存器的值被保存到了 esp 寄存器中,因此,通过使用[ebp - 4]、[ebp - 8]、[ebp - 12]、[ebp - 16]、[ebp - 20] 这样的形式,就可以申请分配20字节的栈内存空间切分成5个长度为4字节的空间来使用。例如,mov dword ptr [ebp-4],6 表示的就是,从申请分配的内存空间的下端(ebp寄存器指示的位置)开始向前4字节的地址([ebp - 4]) 中,存储着6这一4字节数据。

image.png

循环控制语句的处理

上面说的都是顺序流程,那么现在就让我们分析一下循环流程的处理,看一下 for 循环以及 if 条件分支等 c 语言程序的 流程控制是如何实现的,我们还是以代码以及编译后的结果为例,看一下程序控制流程的处理过程。

 // 定义MySub 函数
 voidMySub(){
  // 不做任何处理
 
 }
 ​
 // 定义MyFunc 函数
 voidMyfunc(){
  inti;
  for(inti=0;i<10;i++){
    // 重复调用MySub十次
    MySub();
 }
 }

上述代码将局部变量 i 作为循环条件,循环调用十次MySub函数,下面是它主要的汇编代码

 xorebx,ebx; 将寄存器清0
 @4 call_MySub; 调用MySub函数
 incebx; ebx寄存器的值 + 1
 cmpebx,10;将ebx寄存器的值和10进行比较
 jlshort@4; 如果小于10就跳转到 @4

C 语言中的 for 语句是通过在括号中指定循环计数器的初始值(i = 0)、循环的继续条件(i < 10)、循环计数器的更新(i++) 这三种形式来进行循环处理的。与此相对的汇编代码就是通过比较指令(cmp)跳转指令(jl)来实现的。

下面我们来对上述代码进行说明

MyFunc函数中用到的局部变量只有 i ,变量 i 申请分配了 ebx 寄存器的内存空间。for 语句括号中的 i = 0 被转换为xor ebx,ebx这一处理,xor 指令会对左起第一个操作数和右起第二个操作数进行 XOR 运算,然后把结果存储在第一个操作数中。由于这里把第一个操作数和第二个操作数都指定为了 ebx,因此就变成了对相同数值的 XOR 运算。也就是说不管当前寄存器的值是什么,最终的结果都是0。类似的,我们使用mov ebx,0也能得到相同的结果,但是 xor 指令的处理速度更快,而且编译器也会启动最优化功能。

XOR 指的就是异或操作,它的运算规则是如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0

ebx 寄存器的值初始化后,会通过 call 指定调用 _MySub 函数,从 _MySub 函数返回后,会执行inc ebx 指令,对 ebx 的值进行 + 1 操作,这个操作就相当于 i++ 的意思,++ 表示的就是当前数值 + 1。

这里需要知道 i++ 和 ++i 的区别

i++ 是先赋值,复制完成后再对 i执行 + 1 操作

++i 是先进行 +1 操作,完成后再进行赋值

inc 下一行的 cmp 是用来对第一个操作数和第二个操作数的数值进行比较的指令。 cmp ebx,10 就相当于 C 语言中的 i < 10 这一处理,意思是把 ebx 寄存器的值与10进行比较。汇编语言中比较指令的结果,会存储在 CPU 的标志寄存器中。不过,标志寄存器的值,程序是无法直接参考的。那如何判断比较结果呢?

汇编语言中有多个跳转指令,这些跳转指令会根据标志寄存器的值来判断是否进行跳转操作,例如最后一行的 jl,它会根据 cmp ebx,10 指令所存储在标志寄存器中的值来判断是否跳转,jl 这条指令表示的就是 jump on less than(小于的话就跳转)。发现如果 i 比 10 小,就会跳转到 @4 所在的指令处继续执行。

那么汇编代码的意思也可以用 C 语言来改写一下,加深理解

 i^=i;
 L4:MySub();
 i++;
 if(i<10)gotoL4;

代码第一行 i ^= i 指的就是 i 和 i 进行异或运算,也就是 XOR 运算,MySub() 函数用 L4 标签来替代,然后进行 i 自增操作,如果i 的值小于 10 的话,就会一直循环 MySub() 函数。

条件分支的处理方法

条件分支的处理方式和循环的处理方式很相似,使用的也是 cmp 指令和跳转指令。下面是用 C 语言编写的条件分支的代码

 // 定义MySub1 函数
 voidMySub1(){
 ​
 // 不做任何处理
 }
 ​
 // 定义MySub2 函数
 voidMySub2(){
 
 // 不做任何处理
 }
 ​
 // 定义MySub3 函数
 voidMySub3(){
 ​
 // 不做任何处理
 }
 ​
 // 定义MyFunc 函数
 voidMyFunc(){
 ​
 inta=123;
 // 根据条件调用不同的函数
 if(a>100){
  MySub1();
 }
 elseif(a<50){
  MySub2();
 }
 else
 {
  MySub3();
 }
 ​
 }

很简单的一个实现了条件判断的 C 语言代码,那么我们把它用 Borland C++ 编译之后的结果如下

 _MyFuncproc near
 push     ebp
 mov     ebp,esp
 mov     eax,123; 把123存入 eax 寄存器中
 cmp     eax,100; 把 eax 寄存器的值同100进行比较
 jle     short@8; 比100小时,跳转到@8标签
 call     _MySub1; 调用MySub1函数
 jmpshort@11; 跳转到@11标签
 @8:
 cmp     eax,50; 把 eax 寄存器的值同50进行比较
 jge     short@10; 比50大时,跳转到@10标签
 call     _MySub2; 调用MySub2函数
 jmpshort@11; 跳转到@11标签
 @10:
 call     _MySub3; 调用MySub3函数
 @11:
 pop     ebp
 ret
 _MyFuncendp

上面代码用到了三种跳转指令,分别是jle(jump on less or equal) 比较结果小时跳转,jge(jump on greater or equal) 比较结果大时跳转,还有不管结果怎样都会进行跳转的jmp,在这些跳转指令之前还有用来比较的指令 cmp,构成了上述汇编代码的主要逻辑形式。

了解程序运行逻辑的必要性

通过对上述汇编代码和 C 语言源代码进行比较,想必大家对程序的运行方式有了新的理解,而且,从汇编源代码中获取的知识,也有助于了解 Java 等高级语言的特性,比如 Java 中就有 native 关键字修饰的变量,那么这个变量的底层就是使用 C 语言编写的,还有一些 Java 中的语法糖只有通过汇编代码才能知道其运行逻辑。在某些情况下,对于查找 bug 的原因也是有帮助的。

上面我们了解到的编程方式都是串行处理的,那么串行处理有什么特点呢?

image.png

串行处理最大的一个特点就是专心只做一件事情,一件事情做完之后才会去做另外一件事情。

image.png

我们还是举个实际的例子,让我们来看一段代码

 // 定义全局变量
 intcounter=100;
 ​
 // 定义MyFunc1()
 voidMyFunc(){
  counter*=2;
 }
 ​
 // 定义MyFunc2()
 voidMyFunc2(){
  counter*=2;
 }

上述代码是更新 counter 的值的 C 语言程序,MyFunc1() 和 MyFunc2() 的处理内容都是把 counter 的值扩大至原来的二倍,然后再把 counter 的值赋值给 counter 。这里,我们假设使用多线程处理,同时调用了一次MyFunc1 和 MyFunc2 函数,这时,全局变量 counter 的值,理应编程 100 * 2 * 2 = 400。如果你开启了多个线程的话,你会发现 counter 的数值有时也是 200,对于为什么出现这种情况,如果你不了解程序的运行方式,是很难找到原因的。

我们将上面的代码转换成汇编语言的代码如下

 moveax,dword ptr[_counter]; 将 counter 的值读入 eax 寄存器
 addeax,eax; 将 eax 寄存器的值扩大2倍。
 movdwordptr[_counter],eax; 将 eax 寄存器的值存入 counter 中。

在多线程程序中,用汇编语言表示的代码每运行一行,处理都有可能切换到其他线程中。因而,假设 MyFun1 函数在读出 counter 数值100后,还未来得及将它的二倍值200写入 counter 时,正巧 MyFun2 函数读出了 counter 的值100,那么结果就将变为 200 。

image.png

为了避免该bug,我们可以采用以函数或 C 语言代码的行为单位来禁止线程切换的锁定方法,或者使用某种线程安全的方式来避免该问题的出现。

现在基本上没有人用汇编语言来编写程序了,因为 C、Java等高级语言的效率要比汇编语言快很多。不过,汇编语言的经验还是很重要的,通过借助汇编语言,我们可以更好的了解计算机运行机制。

文章参考

https://www.computerhope.com/jargon/m/memory.htm

https://baike.baidu.com/item/队列/14580481?fr=aladdin

https://baike.baidu.com/item/栈/12808149?fr=aladdin

https://baike.baidu.com/item/环形缓冲器/22701730?fr=aladdin

《程序是怎样跑起来的》

https://baike.baidu.com/item/汇编语言/61826?fr=aladdin

https://baike.baidu.com/item/Windows操作系统/852149?fr=aladdin

磁盘

磁盘缓存

虚拟内存

https://baike.baidu.com/item/压缩算法/2762648

https://en.wikipedia.org/wiki/Central_processing_unit

https://www.digitaltrends.com/computing/what-is-a-cpu/

https://baike.baidu.com/item/寄存器/187682?fr=aladdin

https://baike.baidu.com/item/内存/103614?fr=aladdin

https://blog.csdn.net/mark_lq/article/details/44245423

https://baike.baidu.com/item/程序计数器/3219536?fr=aladdin

https://zhidao.baidu.com/question/124425422.html

二维码.png

查看原文

赞 104 收藏 71 评论 2

程序员cxuan 发布了文章 · 1月10日

看完这篇HTTP,跟面试官扯皮就没问题了

我是一名程序员,我的主要编程语言是 Java,我更是一名 Web 开发人员,所以我必须要了解 HTTP,所以本篇文章就来带你从 HTTP 入门到进阶,看完让你有一种恍然大悟、醍醐灌顶的感觉。

最初在有网络之前,我们的电脑都是单机的,单机系统是孤立的,我还记得 05 年前那会儿家里有个电脑,想打电脑游戏还得两个人在一个电脑上玩儿,及其不方便。我就想为什么家里人不让上网,我的同学 xxx 家里有网,每次一提这个就落一通批评:xxx上xxx什xxxx么xxxx网xxxx看xxxx你xxxx考xxxx的xxxx那xxxx点xxxx分。虽然我家里没有上网,但是此时互联网已经在高速发展了,HTTP 就是高速发展的一个产物。

认识 HTTP

首先你听的最多的应该就是 HTTP 是一种 超文本传输协议(Hypertext Transfer Protocol),这你一定能说出来,但是这样还不够,假如你是大厂面试官,这不可能是他想要的最终结果,我们在面试的时候往往把自己知道的尽可能多的说出来,才有和面试官谈价钱的资本。那么什么是超文本传输协议?

超文本传输协议可以进行文字分割:超文本(Hypertext)、传输(Transfer)、协议(Protocol),它们之间的关系如下

image.png

按照范围的大小 协议 > 传输 > 超文本。下面就分别对这三个名次做一个解释。

什么是超文本

在互联网早期的时候,我们输入的信息只能保存在本地,无法和其他电脑进行交互。我们保存的信息通常都以文本即简单字符的形式存在,文本是一种能够被计算机解析的有意义的二进制数据包。而随着互联网的高速发展,两台电脑之间能够进行数据的传输后,人们不满足只能在两台电脑之间传输文字,还想要传输图片、音频、视频,甚至点击文字或图片能够进行超链接的跳转,那么文本的语义就被扩大了,这种语义扩大后的文本就被称为超文本(Hypertext)

什么是传输

那么我们上面说到,两台计算机之间会形成互联关系进行通信,我们存储的超文本会被解析成为二进制数据包,由传输载体(例如同轴电缆,电话线,光缆)负责把二进制数据包由计算机终端传输到另一个终端的过程(对终端的详细解释可以参考 你说你懂互联网,那这些你知道么?这篇文章)称为传输(transfer)

通常我们把传输数据包的一方称为请求方,把接到二进制数据包的一方称为应答方。请求方和应答方可以进行互换,请求方也可以作为应答方接受数据,应答方也可以作为请求方请求数据,它们之间的关系如下

image.png

如图所示,A 和 B 是两个不同的端系统,它们之间可以作为信息交换的载体存在,刚开始的时候是 A 作为请求方请求与 B 交换信息,B 作为响应的一方提供信息;随着时间的推移,B 也可以作为请求方请求 A 交换信息,那么 A 也可以作为响应方响应 B 请求的信息。

什么是协议

协议这个名词不仅局限于互联网范畴,也体现在日常生活中,比如情侣双方约定好在哪个地点吃饭,这个约定也是一种协议,比如你应聘成功了,企业会和你签订劳动合同,这种双方的雇佣关系也是一种 协议。注意自己一个人对自己的约定不能成为协议,协议的前提条件必须是多人约定。

那么网络协议是什么呢?

网络协议就是网络中(包括互联网)传递、管理信息的一些规范。如同人与人之间相互交流是需要遵循一定的规矩一样,计算机之间的相互通信需要共同遵守一定的规则,这些规则就称为网络协议。

没有网络协议的互联网是混乱的,就和人类社会一样,人不能想怎么样就怎么样,你的行为约束是受到法律的约束的;那么互联网中的端系统也不能自己想发什么发什么,也是需要受到通信协议约束的。

那么我们就可以总结一下,什么是 HTTP?可以用下面这个经典的总结回答一下: HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范

与 HTTP 有关的组件

随着网络世界演进,HTTP 协议已经几乎成为不可替代的一种协议,在了解了 HTTP 的基本组成后,下面再来带你进一步认识一下 HTTP 协议。

网络模型

网络是一个复杂的系统,不仅包括大量的应用程序、端系统、通信链路、分组交换机等,还有各种各样的协议组成,那么现在我们就来聊一下网络中的协议层次。

为了给网络协议的设计提供一个结构,网络设计者以分层(layer)的方式组织协议,每个协议属于层次模型之一。每一层都是向它的上一层提供服务(service),即所谓的服务模型(service model)。每个分层中所有的协议称为 协议栈(protocol stack)。因特网的协议栈由五个部分组成:物理层、链路层、网络层、运输层和应用层。我们采用自上而下的方法研究其原理,也就是应用层 -> 物理层的方式。

应用层

应用层是网络应用程序和网络协议存放的分层,因特网的应用层包括许多协议,例如我们学 web 离不开的 HTTP,电子邮件传送协议 SMTP、端系统文件上传协议 FTP、还有为我们进行域名解析的 DNS 协议。应用层协议分布在多个端系统上,一个端系统应用程序与另外一个端系统应用程序交换信息分组,我们把位于应用层的信息分组称为 报文(message)

运输层

因特网的运输层在应用程序断点之间传送应用程序报文,在这一层主要有两种传输协议 TCP UDP,利用这两者中的任何一个都能够传输报文,不过这两种协议有巨大的不同。

TCP 向它的应用程序提供了面向连接的服务,它能够控制并确认报文是否到达,并提供了拥塞机制来控制网络传输,因此当网络拥塞时,会抑制其传输速率。

UDP 协议向它的应用程序提供了无连接服务。它不具备可靠性的特征,没有流量控制,也没有拥塞控制。我们把运输层的分组称为 报文段(segment)

网络层

因特网的网络层负责将称为 数据报(datagram) 的网络分层从一台主机移动到另一台主机。网络层一个非常重要的协议是 IP 协议,所有具有网络层的因特网组件都必须运行 IP 协议,IP 协议是一种网际协议,除了 IP 协议外,网络层还包括一些其他网际协议和路由选择协议,一般把网络层就称为 IP 层,由此可知 IP 协议的重要性。

链路层

现在我们有应用程序通信的协议,有了给应用程序提供运输的协议,还有了用于约定发送位置的 IP 协议,那么如何才能真正的发送数据呢?为了将分组从一个节点(主机或路由器)运输到另一个节点,网络层必须依靠链路层提供服务。链路层的例子包括以太网、WiFi 和电缆接入的 DOCSIS 协议,因为数据从源目的地传送通常需要经过几条链路,一个数据包可能被沿途不同的链路层协议处理,我们把链路层的分组称为 帧(frame)

物理层

虽然链路层的作用是将帧从一个端系统运输到另一个端系统,而物理层的作用是将帧中的一个个 比特 从一个节点运输到另一个节点,物理层的协议仍然使用链路层协议,这些协议与实际的物理传输介质有关,例如,以太网有很多物理层协议:关于双绞铜线、关于同轴电缆、关于光纤等等。

五层网络协议的示意图如下

image.png

OSI 模型

我们上面讨论的计算网络协议模型不是唯一的 协议栈,ISO(国际标准化组织)提出来计算机网络应该按照7层来组织,那么7层网络协议栈与5层的区别在哪里?

image.png

从图中可以一眼看出,OSI 要比上面的网络模型多了 表示层会话层,其他层基本一致。表示层主要包括数据压缩和数据加密以及数据描述,数据描述使得应用程序不必担心计算机内部存储格式的问题,而会话层提供了数据交换的定界和同步功能,包括建立检查点和恢复方案。

浏览器

就如同各大邮箱使用电子邮件传送协议 SMTP 一样,浏览器是使用 HTTP 协议的主要载体,说到浏览器,你能想起来几种?是的,随着网景大战结束后,浏览器迅速发展,至今已经出现过的浏览器主要有

image.png

浏览器正式的名字叫做 Web Broser,顾名思义,就是检索、查看互联网上网页资源的应用程序,名字里的 Web,实际上指的就是 World Wide Web,也就是万维网。

我们在地址栏输入URL(即网址),浏览器会向DNS(域名服务器,后面会说)提供网址,由它来完成 URL 到 IP 地址的映射。然后将请求你的请求提交给具体的服务器,在由服务器返回我们要的结果(以HTML编码格式返回给浏览器),浏览器执行HTML编码,将结果显示在浏览器的正文。这就是一个浏览器发起请求和接受响应的过程。

Web 服务器

Web 服务器的正式名称叫做 Web Server,Web 服务器一般指的是网站服务器,上面说到浏览器是 HTTP 请求的发起方,那么 Web 服务器就是 HTTP 请求的应答方,Web 服务器可以向浏览器等 Web 客户端提供文档,也可以放置网站文件,让全世界浏览;可以放置数据文件,让全世界下载。目前最主流的三个Web服务器是Apache、 Nginx 、IIS。

CDN

CDN的全称是Content Delivery Network,即内容分发网络,它应用了 HTTP 协议里的缓存和代理技术,代替源站响应客户端的请求。CDN 是构建在现有网络基础之上的网络,它依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储分发技术

打比方说你要去亚马逊上买书,之前你只能通过购物网站购买后从美国发货过海关等重重关卡送到你的家里,现在在中国建立一个亚马逊分基地,你就不用通过美国进行邮寄,从中国就能把书尽快给你送到。

WAF

WAF 是一种 Web 应用程序防护系统(Web Application Firewall,简称 WAF),它是一种通过执行一系列针对HTTP / HTTPS的安全策略来专门为Web应用提供保护的一款产品,它是应用层面的防火墙,专门检测 HTTP 流量,是防护 Web 应用的安全技术。

WAF 通常位于 Web 服务器之前,可以阻止如 SQL 注入、跨站脚本等攻击,目前应用较多的一个开源项目是 ModSecurity,它能够完全集成进 Apache 或 Nginx。

WebService

WebService 是一种 Web 应用程序,WebService是一种跨编程语言和跨操作系统平台的远程调用技术

Web Service 是一种由 W3C 定义的应用服务开发规范,使用 client-server 主从架构,通常使用 WSDL 定义服务接口,使用 HTTP 协议传输 XML 或 SOAP 消息,它是一个基于 Web(HTTP)的服务架构技术,既可以运行在内网,也可以在适当保护后运行在外网。

HTML

HTML 称为超文本标记语言,是一种标识性的语言。它包括一系列标签.通过这些标签可以将网络上的文档格式统一,使分散的 Internet 资源连接为一个逻辑整体。HTML 文本是由 HTML 命令组成的描述性文本,HTML 命令可以说明文字,图形、动画、声音、表格、链接等。

Web 页面构成

Web 页面(Web page)也叫做文档,是由一个个对象组成的。一个对象(Objecy) 只是一个文件,比如一个 HTML 文件、一个 JPEG 图形、一个 Java 小程序或一个视频片段,它们在网络中可以通过 URL 地址寻址。多数的 Web 页面含有一个 HTML 基本文件 以及几个引用对象。

举个例子,如果一个 Web 页面包含 HTML 文件和5个 JPEG 图形,那么这个 Web 页面就有6个对象:一个 HTML 文件和5个 JPEG 图形。HTML 基本文件通过 URL 地址引用页面中的其他对象。

与 HTTP 有关的协议

在互联网中,任何协议都不会单独的完成信息交换,HTTP 也一样。虽然 HTTP 属于应用层的协议,但是它仍然需要其他层次协议的配合完成信息的交换,那么在完成一次 HTTP 请求和响应的过程中,需要哪些协议的配合呢?一起来看一下

TCP/IP

TCP/IP 协议你一定听过,TCP/IP 我们一般称之为协议簇,什么意思呢?就是 TCP/IP 协议簇中不仅仅只有 TCP 协议和 IP 协议,它是一系列网络通信协议的统称。而其中最核心的两个协议就是 TCP / IP 协议,其他的还有 UDP、ICMP、ARP 等等,共同构成了一个复杂但有层次的协议栈。

TCP 协议的全称是 Transmission Control Protocol 的缩写,意思是传输控制协议,HTTP 使用 TCP 作为通信协议,这是因为 TCP 是一种可靠的协议,而可靠能保证数据不丢失。

IP 协议的全称是 Internet Protocol 的缩写,它主要解决的是通信双方寻址的问题。IP 协议使用 IP 地址 来标识互联网上的每一台计算机,可以把 IP 地址想象成为你手机的电话号码,你要与他人通话必须先要知道他人的手机号码,计算机网络中信息交换必须先要知道对方的 IP 地址。(关于 TCP 和 IP 更多的讨论我们会在后面详解)

DNS

你有没有想过为什么你可以通过键入 www.google.com 就能够获取你想要的网站?我们上面说到,计算机网络中的每个端系统都有一个 IP 地址存在,而把 IP 地址转换为便于人类记忆的协议就是 DNS 协议

DNS 的全称是域名系统(Domain Name System,缩写:DNS),它作为将域名和 IP 地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。

URI / URL

我们上面提到,你可以通过输入 www.google.com 地址来访问谷歌的官网,那么这个地址有什么规定吗?我怎么输都可以?AAA.BBB.CCC 是不是也行?当然不是的,你输入的地址格式必须要满足 URI 的规范。

URI的全称是(Uniform Resource Identifier),中文名称是统一资源标识符,使用它就能够唯一地标记互联网上资源。

URL的全称是(Uniform Resource Locator),中文名称是统一资源定位符,也就是我们俗称的网址,它实际上是 URI 的一个子集。

URI 不仅包括 URL,还包括 URN(统一资源名称),它们之间的关系如下

image.png

HTTPS

HTTP 一般是明文传输,很容易被攻击者窃取重要信息,鉴于此,HTTPS 应运而生。HTTPS 的全称为 (Hyper Text Transfer Protocol over SecureSocket Layer),全称有点长,HTTPS 和 HTTP 有很大的不同在于 HTTPS 是以安全为目标的 HTTP 通道,在 HTTP 的基础上通过传输加密和身份认证保证了传输过程的安全性。HTTPS 在 HTTP 的基础上增加了 SSL 层,也就是说 HTTPS = HTTP + SSL。(这块我们后面也会详谈 HTTPS)

HTTP 请求响应过程

你是不是很好奇,当你在浏览器中输入网址后,到底发生了什么事情?你想要的内容是如何展现出来的?让我们通过一个例子来探讨一下,我们假设访问的 URL 地址为 http://www.someSchool.edu/someDepartment/home.index,当我们输入网址并点击回车时,浏览器内部会进行如下操作

  • DNS服务器会首先进行域名的映射,找到访问www.someSchool.edu所在的地址,然后HTTP 客户端进程在 80 端口发起一个到服务器 www.someSchool.edu 的 TCP 连接(80 端口是 HTTP 的默认端口)。在客户和服务器进程中都会有一个套接字与其相连。
  • HTTP 客户端通过它的套接字向服务器发送一个 HTTP 请求报文。该报文中包含了路径 someDepartment/home.index 的资源,我们后面会详细讨论 HTTP 请求报文。
  • HTTP 服务器通过它的套接字接受该报文,进行请求的解析工作,并从其存储器(RAM 或磁盘)中检索出对象 www.someSchool.edu/someDepartment/home.index,然后把检索出来的对象进行封装,封装到 HTTP 响应报文中,并通过套接字向客户进行发送。
  • HTTP 服务器随即通知 TCP 断开 TCP 连接,实际上是需要等到客户接受完响应报文后才会断开 TCP 连接。
  • HTTP 客户端接受完响应报文后,TCP 连接会关闭。HTTP 客户端从响应中提取出报文中是一个 HTML 响应文件,并检查该 HTML 文件,然后循环检查报文中其他内部对象。
  • 检查完成后,HTTP 客户端会把对应的资源通过显示器呈现给用户。

至此,键入网址再按下回车的全过程就结束了。上述过程描述的是一种简单的请求-响应全过程,真实的请求-响应情况可能要比上面描述的过程复杂很多。

HTTP 请求特征

从上面整个过程中我们可以总结出 HTTP 进行分组传输是具有以下特征

  • 支持客户-服务器模式
  • 简单快速:客户向服务器请求服务时,只需传送请求方法和路径。请求方法常用的有 GET、HEAD、POST。每种方法规定了客户与服务器联系的类型不同。由于 HTTP 协议简单,使得 HTTP 服务器的程序规模小,因而通信速度很快。
  • 灵活:HTTP 允许传输任意类型的数据对象。正在传输的类型由 Content-Type 加以标记。
  • 无连接:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。
  • 无状态:HTTP 协议是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另一方面,在服务器不需要先前信息时它的应答就较快。

详解 HTTP 报文

我们上面描述了一下 HTTP 的请求响应过程,流程比较简单,但是凡事就怕认真,你这一认真,就能拓展出很多东西,比如 HTTP 报文是什么样的,它的组成格式是什么? 下面就来探讨一下

HTTP 协议主要由三大部分组成:

  • 起始行(start line):描述请求或响应的基本信息;
  • 头部字段(header):使用 key-value 形式更详细地说明报文;
  • 消息正文(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据。

其中起始行和头部字段并成为 请求头 或者 响应头,统称为 Header;消息正文也叫做实体,称为 body。HTTP 协议规定每次发送的报文必须要有 Header,但是可以没有 body,也就是说头信息是必须的,实体信息可以没有。而且在 header 和 body 之间必须要有一个空行(CRLF),如果用一幅图来表示一下的话,我觉得应该是下面这样

image.png

我们使用上面的那个例子来看一下 http 的请求报文

image.png

如图,这是 http://www.someSchool.edu/someDepartment/home.index 请求的请求头,通过观察这个 HTTP 报文我们就能够学到很多东西,首先,我们看到报文是用普通 ASCII 文本书写的,这样保证人能够可以看懂。然后,我们可以看到每一行和下一行之间都会有换行,而且最后一行(请求头部后)再加上一个回车换行符。

每个报文的起始行都是由三个字段组成:方法、URL 字段和 HTTP 版本字段

image.png

HTTP 请求方法

HTTP 请求方法一般分为 8 种,它们分别是

  • GET 获取资源,GET 方法用来请求访问已被 URI 识别的资源。指定的资源经服务器端解析后返回响应内容。也就是说,如果请求的资源是文本,那就保持原样返回;
  • POST 传输实体,虽然 GET 方法也可以传输主体信息,但是便于区分,我们一般不用 GET 传输实体信息,反而使用 POST 传输实体信息,
  • PUT 传输文件,PUT 方法用来传输文件。就像 FTP 协议的文件上传一样,要求在请求报文的主体中包含文件内容,然后保存到请求 URI 指定的位置。

    但是,鉴于 HTTP 的 PUT 方法自身不带验证机制,任何人都可以上传文件 , 存在安全性问题,因此一般的 W eb 网站不使用该方法。若配合 W eb 应用程序的验证机制,或架构设计采用 REST(REpresentational State Transfer,表征状态转移)标准的同类 Web 网站,就可能会开放使用 PUT 方法。

  • HEAD 获得响应首部,HEAD 方法和 GET 方法一样,只是不返回报文主体部分。用于确认 URI 的有效性及资源更新的日期时间等。
  • DELETE 删除文件,DELETE 方法用来删除文件,是与 PUT 相反的方法。DELETE 方法按请求 URI 删除指定的资源。
  • OPTIONS 询问支持的方法,OPTIONS 方法用来查询针对请求 URI 指定的资源支持的方法。
  • TRACE 追踪路径,TRACE 方法是让 Web 服务器端将之前的请求通信环回给客户端的方法。
  • CONNECT 要求用隧道协议连接代理,CONNECT 方法要求在与代理服务器通信时建立隧道,实现用隧道协议进行 TCP 通信。主要使用 SSL(Secure Sockets Layer,安全套接层)和 TLS(Transport Layer Security,传输层安全)协议把通信内容加 密后经网络隧道传输。

我们一般最常用的方法也就是 GET 方法和 POST 方法,其他方法暂时了解即可。下面是 HTTP1.0 和 HTTP1.1 支持的方法清单

image.png

HTTP 请求 URL

HTTP 协议使用 URI 定位互联网上的资源。正是因为 URI 的特定功能,在互联网上任意位置的资源都能访问到。URL 带有请求对象的标识符。在上面的例子中,浏览器正在请求对象 /somedir/page.html 的资源。

我们再通过一个完整的域名解析一下 URL

比如 http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument 这个 URL 比较繁琐了吧,你把这个 URL 搞懂了其他的 URL 也就不成问题了。

首先出场的是 http

image.png

http://告诉浏览器使用何种协议。对于大部分 Web 资源,通常使用 HTTP 协议或其安全版本,HTTPS 协议。另外,浏览器也知道如何处理其他协议。例如, mailto: 协议指示浏览器打开邮件客户端;ftp:协议指示浏览器处理文件传输。

第二个出场的是 主机

image.png

www.example.com 既是一个域名,也代表管理该域名的机构。它指示了需要向网络上的哪一台主机发起请求。当然,也可以直接向主机的 IP address 地址发起请求。但直接使用 IP 地址的场景并不常见。

第三个出场的是 端口

image.png

我们前面说到,两个主机之间要发起 TCP 连接需要两个条件,主机 + 端口。它表示用于访问 Web 服务器上资源的入口。如果访问的该 Web 服务器使用HTTP协议的标准端口(HTTP为80,HTTPS为443)授予对其资源的访问权限,则通常省略此部分。否则端口就是 URI 必须的部分。

上面是请求 URL 所必须包含的部分,下面就是 URL 具体请求资源路径

第四个出场的是 路径

image.png

/path/to/myfile.html 是 Web 服务器上资源的路径。以端口后面的第一个 / 开始,到 ? 号之前结束,中间的 每一个/ 都代表了层级(上下级)关系。这个 URL 的请求资源是一个 html 页面。

紧跟着路径后面的是 查询参数

image.png

?key1=value1&key2=value2 是提供给 Web 服务器的额外参数。如果是 GET 请求,一般带有请求 URL 参数,如果是 POST 请求,则不会在路径后面直接加参数。这些参数是用 & 符号分隔的键/值对列表。key1 = value1 是第一对,key2 = value2 是第二对参数

紧跟着参数的是锚点

image.png

#SomewhereInTheDocument 是资源本身的某一部分的一个锚点。锚点代表资源内的一种“书签”,它给予浏览器显示位于该“加书签”点的内容的指示。 例如,在HTML文档上,浏览器将滚动到定义锚点的那个点上;在视频或音频文档上,浏览器将转到锚点代表的那个时间。值得注意的是 # 号后面的部分,也称为片段标识符,永远不会与请求一起发送到服务器。

HTTP 版本

表示报文使用的 HTTP 协议版本。

请求头部

这部分内容只是大致介绍一下,内容较多,后面会再以一篇文章详述

在表述完了起始行之后我们再来看一下请求头部,现在我们向上找,找到http://www.someSchool.edu/someDepartment/home.index,来看一下它的请求头部

Host: www.someschool.edu
Connection: close
User-agent: Mozilla/5.0
Accept-language: fr

这个请求头信息比较少,首先 Host 表示的是对象所在的主机。你也许认为这个 Host 是不需要的,因为 URL 不是已经指明了请求对象的路径了吗?这个首部行提供的信息是 Web 代理高速缓存所需要的。Connection: close 表示的是浏览器需要告诉服务器使用的是非持久连接。它要求服务器在发送完响应的对象后就关闭连接。User-agent: 这是请求头用来告诉 Web 服务器,浏览器使用的类型是 Mozilla/5.0,即 Firefox 浏览器。Accept-language 告诉 Web 服务器,浏览器想要得到对象的法语版本,前提是服务器需要支持法语类型,否则将会发送服务器的默认版本。下面我们针对主要的实体字段进行介绍(具体的可以参考 https://developer.mozilla.org... MDN 官网学习)

HTTP 的请求标头分为四种: 通用标头请求标头响应标头实体标头,依次来进行详解。

通用标头

通用标头主要有三个,分别是 DateCache-ControlConnection

Date

Date 是一个通用标头,它可以出现在请求标头和响应标头中,它的基本表示如下

Date: Wed, 21 Oct 2015 07:28:00 GMT 

表示的是格林威治标准时间,这个时间要比北京时间慢八个小时

image.png

Cache-Control

Cache-Control 是一个通用标头,他可以出现在请求标头和响应标头中,Cache-Control 的种类比较多,虽然说这是一个通用标头,但是又一些特性是请求标头具有的,有一些是响应标头才有的。主要大类有 可缓存性阈值性重新验证并重新加载其他特性

可缓存性是唯一响应标头才具有的特性,我们会在响应标头中详述。

阈值性,这个我翻译可能不准确,它的原英文是 Expiration,我是根据它的值来翻译的,你看到这些值可能会觉得我翻译的有点道理

  • max-age: 资源被认为仍然有效的最长时间,与 Expires 不同,这个请求是相对于 request标头的时间,而 Expires 是相对于响应标头。(请求标头)
  • s-maxage: 重写了 max-age 和 Expires 请求头,仅仅适用于共享缓存,被私有缓存所忽略(这块不理解,看完响应头的 Cache-Control 再进行理解)(请求标头)
  • max-stale:表示客户端将接受的最大响应时间,以秒为单位。(响应标头)
  • min-fresh: 表示客户端希望响应在指定的最小时间内有效。(响应标头)

Connection

Connection 决定当前事务(一次三次握手和四次挥手)完成后,是否会关闭网络连接。Connection 有两种,一种是持久性连接,即一次事务完成后不关闭网络连接

Connection: keep-alive

另一种是非持久性连接,即一次事务完成后关闭网络连接

Connection: close

HTTP1.1 其他通用标头如下

image.png

实体标头

实体标头是描述消息正文内容的 HTTP 标头。实体标头用于 HTTP 请求和响应中。头部Content-LengthContent-LanguageContent-Encoding 是实体头。

  • Content-Length 实体报头指示实体主体的大小,以字节为单位,发送到接收方。
  • Content-Language 实体报头描述了客户端或者服务端能够接受的语言,例如
Content-Language: de-DE
Content-Language: en-US
Content-Language: de-DE, en-CA
  • Content-Encoding 这又是一个比较麻烦的属性,这个实体报头用来压缩媒体类型。Content-Encoding 指示对实体应用了何种编码。

    常见的内容编码有这几种: gzip、compress、deflate、identity ,这个属性可以应用在请求报文和响应报文中

Accept-Encoding: gzip, deflate //请求头
Content-Encoding: gzip  //响应头

下面是一些实体标头字段

image.png

请求标头

上面给出的例子请求报文的属性比较少,下面给出一个 MDN 官网的例子

GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html
Connection: keep-alive
Upgrade-Insecure-Requests: 1
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
Cache-Control: max-age=0 

Host

Host 请求头指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号。如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个 HTTP 的 URL 会自动使用80作为端口)。

Host: developer.mozilla.org

上面的 AccpetAccept-LanguageAccept-Encoding 都是属于内容协商的请求标头,我们会在下面说明

Referer

HTTP Referer 属性是请求标头的一部分,当浏览器向 web 服务器发送请求的时候,一般会带上 Referer,告诉服务器该网页是从哪个页面链接过来的,服务器因此可以获得一些信息用于处理。

Referer: https://developer.mozilla.org/testpage.html

Upgrade-Insecure-Requests

Upgrade-Insecure-Requests 是一个请求标头,用来向服务器端发送信号,表示客户端优先选择加密及带有身份验证的响应。

Upgrade-Insecure-Requests: 1

If-Modified-Since

HTTP 的 If-Modified-Since 使其成为条件请求

  • 返回200,只有在给定日期的最后一次修改资源后,服务器才会以200状态发送回请求的资源。
  • 如果请求从开始以来没有被修改过,响应会返回304并且没有任何响应体

If-Modified-Since 通常会与 If-None-Match 搭配使用,If-Modified-Since 用于确认代理或客户端拥有的本地资源的有效性。获取资源的更新日期时间,可通过确认首部字段 Last-Modified 来确定。

大白话说就是如果在 Last-Modified 之后更新了服务器资源,那么服务器会响应200,如果在 Last-Modified 之后没有更新过资源,则返回 304。

If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT

If-None-Match

If-None-Match HTTP请求标头使请求成为条件请求。 对于 GET 和 HEAD 方法,仅当服务器没有与给定资源匹配的 ETag 时,服务器才会以200状态发送回请求的资源。 对于其他方法,仅当最终现有资源的ETag与列出的任何值都不匹配时,才会处理请求。

If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"

ETag 属于响应标头,后面进行介绍。

内容协商

内容协商机制是指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言、字符集、编码方式等作为判断的标准。

image.png

内容协商主要有以下3种类型:

  • 服务器驱动协商(Server-driven Negotiation)

这种协商方式是由服务器端进行内容协商。服务器端会根据请求首部字段进行自动处理

  • 客户端驱动协商(Agent-driven Negotiation)

这种协商方式是由客户端来进行内容协商。

  • 透明协商(Transparent Negotiation)

是服务器驱动和客户端驱动的结合体,是由服务器端和客户端各自进行内容协商的一种方法。

内容协商的分类有很多种,主要的几种类型是 Accept、Accept-Charset、Accept-Encoding、Accept-Language、Content-Language

Accept

接受请求 HTTP 标头会通告客户端其能够理解的 MIME 类型

那么什么是 MIME 类型呢?在回答这个问题前你应该先了解一下什么是 MIME

MIME: MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。MIME 消息能包含文本、图像、音频、视频以及其他应用程序专用的数据。

也就是说,MIME 类型其实就是一系列消息内容类型的集合。那么 MIME 类型都有哪些呢?

文本文件: text/html、text/plain、text/css、application/xhtml+xml、application/xml

图片文件: image/jpeg、image/gif、image/png

视频文件: video/mpeg、video/quicktime

应用程序二进制文件: application/octet-stream、application/zip

比如,如果浏览器不支持 PNG 图片的显示,那 Accept 就不指定image/png,而指定可处理的 image/gif 和 image/jpeg 等图片类型。

一般 MIME 类型也会和 q 这个属性一起使用,q 是什么?q 表示的是权重,来看一个例子

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

这是什么意思呢?若想要给显示的媒体类型增加优先级,则使用 q= 来额外表示权重值,没有显示权重的时候默认值是1.0 ,我给你列个表格你就明白了

qMIME
1.0text/html
1.0application/xhtml+xml
0.9application/xml
0.8 /

也就是说,这是一个放置顺序,权重高的在前,低的在后,application/xml;q=0.9 是不可分割的整体。

Accept-Charset

accept-charset 属性规定服务器处理表单数据所接受的字符集。

accept-charset 属性允许您指定一系列字符集,服务器必须支持这些字符集,从而得以正确解释表单中的数据。

该属性的值是用引号包含字符集名称列表。如果可接受字符集与用户所使用的字符即不相匹配的话,浏览器可以选择忽略表单或是将该表单区别对待。

此属性的默认值是 unknown,表示表单的字符集与包含表单的文档的字符集相同。

常用的字符集有: UTF-8 - Unicode 字符编码 ; ISO-8859-1 - 拉丁字母表的字符编码

Accept-Language

首部字段 Accept-Language 用来告知服务器用户代理能够处理的自然语言集(指中文或英文等),以及自然语言集的相对优先级。可一次指定多种自然语言集。
和 Accept 首部字段一样,按权重值 q 来表示相对优先级。

Accept-Language: en-US,en;q=0.5

请求标头我们大概就介绍这几种,后面会有一篇文章详细深挖所有的响应头的,下面是一个响应头的汇总,基于 HTTP 1.1

image.png

响应标头

响应标头是可以在 HTTP 响应种使用的 HTTP 标头,这听起来是像一句废话,不过确实是这样解释。并不是所有出现在响应中的标头都是响应标头。还有一些特殊的我们上面说过,有通用标头和实体标头也会出现在响应标头中,比如 Content-Length 就是一个实体标头,但是,在这种情况下,这些实体请求通常称为响应头。下面以一个例子为例和你探讨一下响应头

200 OK
Access-Control-Allow-Origin: *
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Mon, 18 Jul 2016 16:06:00 GMT
Etag: "c561c68d0ba92bbeb8b0f612a9199f722e3a621a"
Keep-Alive: timeout=5, max=997
Last-Modified: Mon, 18 Jul 2016 02:36:04 GMT
Server: Apache
Set-Cookie: mykey=myvalue; expires=Mon, 17-Jul-2017 16:06:00 GMT; Max-Age=31449600; Path=/; secure
Transfer-Encoding: chunked
Vary: Cookie, Accept-Encoding
x-frame-options: DENY

响应状态码

首先出现的应该就是 200 OK,这是 HTTP 响应标头的状态码,它表示着响应成功完成。HTTP 响应标头的状态码有很多,并做了如下规定

2xx 为开头的都表示请求成功响应。

状态码含义
200成功响应
204请求处理成功,但是没有资源可以返回
206对资源某一部分进行响应,由Content-Range 指定范围的实体内容。

3xx 为开头的都表示需要进行附加操作以完成请求

状态码含义
301永久性重定向,该状态码表示请求的资源已经重新分配 URI,以后应该使用资源现有的 URI
302临时性重定向。该状态码表示请求的资源已被分配了新的 URI,希望用户(本次)能使用新的 URI 访问。
303该状态码表示由于请求对应的资源存在着另一个 URI,应使用 GET 方法定向获取请求的资源。
304该状态码表示客户端发送附带条件的请求时,服务器端允许请求访问资源,但未满足条件的情况。
307临时重定向。该状态码与 302 Found 有着相同的含义。

4xx 的响应结果表明客户端是发生错误的原因所在。

状态码含义
400该状态码表示请求报文中存在语法错误。当错误发生时,需修改请求的内容后再次发送请求。
401该状态码表示发送的请求需要有通过 HTTP 认证(BASIC 认证、DIGEST 认证)的认证信息。
403该状态码表明对请求资源的访问被服务器拒绝了。
404该状态码表明服务器上无法找到请求的资源。

5xx 为开头的响应标头都表示服务器本身发生错误

状态码含义
500该状态码表明服务器端在执行请求时发生了错误。
503该状态码表明服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。

Access-Control-Allow-Origin

一个返回的 HTTP 标头可能会具有 Access-Control-Allow-Origin ,Access-Control-Allow-Origin 指定一个来源,它告诉浏览器允许该来源进行资源访问。 否则-对于没有凭据的请求 *通配符,告诉浏览器允许任何源访问资源。例如,要允许源 https://mozilla.org 的代码访问资源,可以指定:

Access-Control-Allow-Origin: https://mozilla.org
Vary: Origin

如果服务器指定单个来源而不是 * 通配符的话 ,则服务器还应在 Vary 响应标头中包含 Origin ,以向客户端指示 服务器响应将根据原始请求标头的值而有所不同。

Keep-Alive

上面我们提到,HTTP 报文标头会分为四种,这其实是按着上下文来分类的

还有一种分类是根据代理进行分类,根据代理会分为端到端头逐跳标头

而 Keep-Alive 表示的是 Connection 非持续连接的存活时间,如下

Connection: Keep-Alive
Keep-Alive: timeout=5, max=997

Keep-Alive 有两个参数,它们是以逗号分隔的参数列表,每个参数由一个标识符和一个由等号 = 分隔的值组成。

timeout:指示空闲连接必须保持打开状态的最短时间(以秒为单位)。

max:指示在关闭连接之前可以在此连接上发送的最大请求数。

上述 HTTP 代码的意思就是限制最大的超时时间是 5s 和 最大的连接请求是 997 个。

Server

服务器标头包含有关原始服务器用来处理请求的软件的信息。

应该避免使用过于冗长和详细的 Server 值,因为它们可能会泄露内部实施细节,这可能会使攻击者容易地发现并利用已知的安全漏洞。例如下面这种写法

Server: Apache/2.4.1 (Unix)

Set-Cookie

Cookie 又是另外一个领域的内容了,我们后面文章会说道 Cookie,这里需要记住 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定义的首部字段,它们不是属于 HTTP 1.1 的首部字段,但是使用率仍然很高。

Transfer-Encoding

首部字段 Transfer-Encoding 规定了传输报文主体时采用的编码方式。

Transfer-Encoding: chunked

HTTP /1.1 的传输编码方式仅对分块传输编码有效。

X-Frame-Options

HTTP 首部字段是可以自行扩展的。所以在 Web 服务器和浏览器的应用上,会出现各种非标准的首部字段。

首部字段 X-Frame-Options 属于 HTTP 响应首部,用于控制网站内容在其他 Web 网站的 Frame 标签内的显示问题。其主要目的是为了防止点击劫持(clickjacking)攻击。

下面是一个响应头的汇总,基于 HTTP 1.1

image.png

非 HTTP/1.1 首部字段

在 HTTP 协议通信交互中使用到的首部字段,不限于 RFC2616 中定义的 47 种首部字段。还有 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定义的首部字段,它们的使用频率也很高。
这些非正式的首部字段统一归纳在 RFC4229 HTTP Header Field Registrations 中。

End-to-end 首部和 Hop-by-hop 首部

HTTP 首部字段将定义成缓存代理和非缓存代理的行为,分成 2 种类型。

一种是 End-to-end 首部 和 Hop-by-hop 首部

End-to-end(端到端) 首部

这些标头必须发送给消息的最终接收者 : 请求的服务器,或响应的客户端。中间代理必须重新传输未经修改的标头,并且缓存必须存储这些信息

Hop-by-hop(逐跳) 首部

分在此类别中的首部只对单次转发有效,会因通过缓存或代理而不再转发。

下面列举了 HTTP/1.1 中的逐跳首部字段。除这 8 个首部字段之外,其他所有字段都属于端到端首部。

Connection、Keep-Alive、Proxy-Authenticate、Proxy-Authorization、Trailer、TE、Transfer-Encoding、Upgrade

HTTP 的优点和缺点

HTTP 的优点

简单灵活易扩展

HTTP 最重要也是最突出的优点是 简单、灵活、易于扩展

HTTP 的协议比较简单,它的主要组成就是 header + body,头部信息也是简单的文本格式,而且 HTTP 的请求报文根据英文也能猜出来个大概的意思,降低学习门槛,能够让更多的人研究和开发 HTTP 应用。

所以,在简单的基础上,HTTP 协议又多了灵活易扩展 的优点。

HTTP 协议里的请求方法、URI、状态码、原因短语、头字段等每一个核心组成要素都没有被制定死,允许开发者任意定制、扩充或解释,给予了浏览器和服务器最大程度的信任和自由。

应用广泛、环境成熟

因为过于简单,普及,因此应用很广泛。因为 HTTP 协议本身不属于一种语言,它并不限定某种编程语言或者操作系统,所以天然具有跨语言、跨平台的优越性。而且,因为本身的简单特性很容易实现,所以几乎所有的编程语言都有 HTTP 调用库和外围的开发测试工具。

随着移动互联网的发展, HTTP 的触角已经延伸到了世界的每一个角落,从简单的 Web 页面到复杂的 JSON、XML 数据,从台式机上的浏览器到手机上的各种 APP、新闻、论坛、购物、手机游戏,你很难找到一个没有使用 HTTP 的地方。

无状态

无状态其实既是优点又是缺点。因为服务器没有记忆能力,所以就不需要额外的资源来记录状态信息,不仅实现上会简单一些,而且还能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。

HTTP 的缺点

无状态

既然服务器没有记忆能力,它就无法支持需要连续多个步骤的事务操作。每次都得问一遍身份信息,不仅麻烦,而且还增加了不必要的数据传输量。由此出现了 Cookie 技术。

明文

HTTP 协议里还有一把优缺点一体的双刃剑,就是明文传输。明文意思就是协议里的报文(准确地说是 header 部分)不使用二进制数据,而是用简单可阅读的文本形式。

对比 TCP、UDP 这样的二进制协议,它的优点显而易见,不需要借助任何外部工具,用浏览器、Wireshark 或者 tcpdump 抓包后,直接用肉眼就可以很容易地查看或者修改,为我们的开发调试工作带来极大的便利。

当然缺点也是显而易见的,就是不安全,可以被监听和被窥探。因为无法判断通信双方的身份,不能判断报文是否被更改过。

性能

HTTP 的性能不算差,但不完全适应现在的互联网,还有很大的提升空间。

二维码.png

参考资料:

https://en.wikipedia.org/wiki...

《极客时间》- 透视 HTTP 协议

https://developer.mozilla.org...

https://baike.baidu.com/item/...

https://baike.baidu.com/item/...

https://baike.baidu.com/item/...

https://www.jianshu.com/p/3dd...

《计算机网络-自顶向下方法》

《图解 HTTP》

HTTP协议的内容协商

https://www.w3school.com.cn/t...

查看原文

赞 150 收藏 115 评论 4

程序员cxuan 关注了用户 · 1月7日

徐九 @weepie

SegmentFault 思否社区老编辑

饮水机の守护神,艾泽拉斯的勇士,朝阳区埃米纳姆,我爸我妈的儿子,深夜撰稿者,统领一猫一狗的国王,Glory to the Sin'dorei!

关注 100

程序员cxuan 发布了文章 · 1月6日

ReentrantLock 源码分析从入门到入土

回答一个问题

在开始本篇文章的内容讲述前,先来回答我一个问题,为什么 JDK 提供一个 synchronized 关键字之后还要提供一个 Lock 锁,这不是多此一举吗?难道 JDK 设计人员都是沙雕吗?

我听过一句话非常的经典,也是我认为是每个人都应该了解的一句话:你以为的并不是你以为的。明白什么意思么?不明白的话,加我微信我告诉你。

初识 ReentrantLock

ReentrantLock 位于 java.util.concurrent.locks 包下,它实现了 Lock 接口和 Serializable 接口。

image.png

ReentrantLock 是一把可重入锁互斥锁,它具有与 synchronized 关键字相同的含有隐式监视器锁(monitor)的基本行为和语义,但是它比 synchronized 具有更多的方法和功能。

ReentrantLock 基本方法

构造方法

ReentrantLock 类中带有两个构造函数,一个是默认的构造函数,不带任何参数;一个是带有 fair 参数的构造函数

public ReentrantLock() {
  sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
  sync = fair ? new FairSync() : new NonfairSync();
}

第二个构造函数也是判断 ReentrantLock 是否是公平锁的条件,如果 fair 为 true,则会创建一个公平锁的实现,也就是 new FairSync(),如果 fair 为 false,则会创建一个 非公平锁的实现,也就是 new NonfairSync(),默认的情况下创建的是非公平锁

// 创建的是公平锁
private ReentrantLock lock = new ReentrantLock(true);

// 创建的是非公平锁
private ReentrantLock lock = new ReentrantLock(false);

// 默认创建非公平锁
private ReentrantLock lock = new ReentrantLock();

FairSync 和 NonfairSync 都是 ReentrantLock 的内部类,继承于 Sync 类,下面来看一下它们的继承结构,便于梳理。

image.png

abstract static class Sync extends AbstractQueuedSynchronizer {...}

static final class FairSync extends Sync {...}
  
static final class NonfairSync extends Sync {...}

在多线程尝试加锁时,如果是公平锁,那么锁获取的机会是相同的。否则,如果是非公平锁,那么 ReentrantLock 则不会保证每个锁的访问顺序

下面是一个公平锁的实现

public class MyFairLock extends Thread{

    private ReentrantLock lock = new ReentrantLock(true);
    public void fairLock(){
        try {
            lock.lock();
            System.out.println(Thread.currentThread().getName()  + "正在持有锁");
        }finally {
            System.out.println(Thread.currentThread().getName()  + "释放了锁");
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        MyFairLock myFairLock = new MyFairLock();
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            myFairLock.fairLock();
        };
        Thread[] thread = new Thread[10];
        for(int i = 0;i < 10;i++){
            thread[i] = new Thread(runnable);
        }
        for(int i = 0;i < 10;i++){
            thread[i].start();
        }
    }
}

不信?不信你输出试试啊!懒得输出?就知道你懒得输出,所以直接告诉你结论吧,结论就是自己试

试完了吗?试完了我是不会让你休息的,过来再试一下非公平锁的测试和结论,知道怎么试吗?上面不是讲过要给 ReentrantLock 传递一个参数的吗?你想,传 true 的时候是公平锁,那么反过来不就是非公平锁了?其他代码还用改吗?不需要了啊。

明白了吧,再来测试一下非公平锁的流程,看看是不是你想要的结果。

公平锁的加锁(lock)流程详解

通常情况下,使用多线程访问公平锁的效率会非常低(通常情况下会慢很多),但是 ReentrantLock 会保证每个线程都会公平的持有锁,线程饥饿的次数比较小。锁的公平性并不能保证线程调度的公平性。

此时如果你想了解更多的话,那么我就从源码的角度跟你聊聊如何 ReentrantLock 是如何实现这两种锁的。

image.png

如上图所示,公平锁的加锁流程要比非公平锁的加锁流程简单,下面要聊一下具体的流程了,请小伙伴们备好板凳。

下面先看一张流程图,这张图是 acquire 方法的三条主要流程

image.png

首先是第一条路线,tryAcquire 方法,顾名思义尝试获取,也就是说可以成功获取锁,也可以获取锁失败。

使用 ctrl+左键 点进去是调用 AQS 的方法,但是 ReentrantLock 实现了 AQS 接口,所以调用的是 ReentrantLock 的 tryAcquire 方法;

image.png

首先会取得当前线程,然后去读取当前锁的同步状态,还记得锁的四种状态吗?分别是 无锁、偏向锁、轻量级锁和重量级锁,如果你不是很明白的话,请参考博主这篇文章(不懂什么是锁?看看这篇你就明白了),如果判断同步状态是 0 的话,就证明是无锁的,参考下面这幅图( 1bit 表示的是是否偏向锁 )

image.png

如果是无锁(也就是没有加锁),说明是第一次上锁,首先会先判断一下队列中是否有比当前线程等待时间更长的线程(hasQueuedPredecessors);然后通过 CAS 方法原子性的更新锁的状态,CAS 方法更新的要求涉及三个变量,currentValue(当前线程的值),expectedValue(期望更新的值),updateValue(更新的值),它们的更新如下

if(currentValue == expectedValue){
  currentValue = updateValue
}

CAS 通过 C 底层机制保证原子性,这个你不需要考虑它。如果既没有排队的线程而且使用 CAS 方法成功的把 0 -> 1 (偏向锁),那么当前线程就会获得偏向锁,记录获取锁的线程为当前线程。

然后我们看 else if 逻辑,如果读取的同步状态是1,说明已经线程获取到了锁,那么就先判断当前线程是不是获取锁的线程,如果是的话,记录一下获取锁的次数 + 1,也就是说,只有同步状态为 0 的时候是无锁状态。如果当前线程不是获取锁的线程,直接返回 false。

acquire 方法会先查看同步状态是否获取成功,如果成功则方法结束返回,也就是 !tryAcquire == false ,若失败则先调用 addWaiter 方法再调用 acquireQueued 方法

然后看一下第二条路线 addWaiter

image.png

这里首先把当前线程和 Node 的节点类型进行封装,Node 节点的类型有两种,EXCLUSIVESHARED ,前者为独占模式,后者为共享模式,具体的区别我们会在 AQS 源码讨论,这里读者只需要知道即可。

首先会进行 tail 节点的判断,有没有尾节点,其实没有头节点也就相当于没有尾节点,如果有尾节点,就会原子性的将当前节点插入同步队列中,再执行 enq 入队操作,入队操作相当于原子性的把节点插入队列中。

如果当前同步队列尾节点为null,说明当前线程是第一个加入同步队列进行等待的线程。

在看第三条路线 acquireQueued

image.png

主要会有两个分支判断,首先会进行无限循环中,循环中每次都会判断给定当前节点的先驱节点,如果没有先驱节点会直接抛出空指针异常,直到返回 true。

然后判断给定节点的先驱节点是不是头节点,并且当前节点能否获取独占式锁,如果是头节点并且成功获取独占锁后,队列头指针用指向当前节点,然后释放前驱节点。如果没有获取到独占锁,就会进入 shouldParkAfterFailedAcquireparkAndCheckInterrupt 方法中,我们贴出这两个方法的源码

image.png

shouldParkAfterFailedAcquire 方法主要逻辑是使用compareAndSetWaitStatus(pred, ws, Node.SIGNAL)使用CAS将节点状态由 INITIAL 设置成 SIGNAL,表示当前线程阻塞。当 compareAndSetWaitStatus 设置失败则说明 shouldParkAfterFailedAcquire 方法返回 false,然后会在 acquireQueued 方法中死循环中会继续重试,直至compareAndSetWaitStatus 设置节点状态位为 SIGNAL 时 shouldParkAfterFailedAcquire 返回 true 时才会执行方法 parkAndCheckInterrupt 方法。(这块在后面研究 AQS 会细讲)

parkAndCheckInterrupt 该方法的关键是会调用 LookSupport.park 方法(关于LookSupport会在以后的文章进行讨论),该方法是用来阻塞当前线程。

所以 acquireQueued 主要做了两件事情:如果当前节点的前驱节点是头节点,并且能够获取独占锁,那么当前线程能够获得锁该方法执行结束退出

如果获取锁失败的话,先将节点状态设置成 SIGNAL,然后调用 LookSupport.park 方法使得当前线程阻塞。

如果 !tryAcquireacquireQueued 都为 true 的话,则打断当前线程。

那么它们的主要流程如下(注:只是加锁流程,并不是 lock 所有流程)

image.png

非公平锁的加锁(lock)流程详解

非公平锁的加锁步骤和公平锁的步骤只有两处不同,一处是非公平锁在加锁前会直接使用 CAS 操作设置同步状态,如果设置成功,就会把当前线程设置为偏向锁的线程;一处是 CAS 操作失败执行 tryAcquire 方法,读取线程同步状态,如果未加锁会使用 CAS 再次进行加锁,不会等待 hasQueuedPredecessors 方法的执行,达到只要线程释放锁就会加锁的目的。下面通过源码和流程图来详细理解

image.png

这是非公平锁和公平锁不同的两处地方,下面是非公平锁的加锁流程图

image.png

lockInterruptibly 以可中断的方式获取锁

以下是 JavaDoc 官方解释:

lockInterruptibly 的中文意思为如果没有被打断,则获取锁。如果没有其他线程持有该锁,则获取该锁并立即返回,将锁保持计数设置为1。如果当前线程已经持有锁,那么此方法会立刻返回并且持有锁的数量会 + 1。如果锁是由另一个线程持有的,则出于线程调度目的,当前线程将被禁用,并处于休眠状态,直到发生以下两种情况之一

  • 锁被当前线程持有
  • 一些其他线程打断了当前线程

如果当前线程获取了锁,则锁保持计数将设置为1。

如果当前线程发生了如下情况:

  • 在进入此方法时设置了其中断状态
  • 当获取锁的时候发生了中断(Thread.interrupt)

那么当前线程就会抛出InterruptedException 并且当前线程的中断状态会清除。

下面看一下它的源码是怎么写的

image.png

首先会调用 acquireInterruptibly 这个方法,判断当前线程是否被中断,如果中断抛出异常,没有中断则判断公平锁/非公平锁 是否已经获取锁,如果没有获取锁(tryAcquire 返回 false)则调用 doAcquireInterruptibly 方法,这个方法和 acquireQueued 方法没什么区别,就是线程在等待状态的过程中,如果线程被中断,线程会抛出异常。

下面是它的流程图

image.png

tryLock 尝试加锁

仅仅当其他线程没有获取这把锁的时候获取这把锁,tryLock 的源代码和非公平锁的加锁流程基本一致,它的源代码如下

image.png

tryLock 超时获取锁

ReentrantLock除了能以中断的方式去获取锁,还可以以超时等待的方式去获取锁,所谓超时等待就是线程如果在超时时间内没有获取到锁,那么就会返回false,而不是一直死循环获取。可以使用 tryLock 和 tryLock(timeout, unit)) 结合起来实现公平锁,像这样

if (lock.tryLock() || lock.tryLock(timeout, unit)) {...}

如果超过了指定时间,则返回值为 false。如果时间小于或者等于零,则该方法根本不会等待。

它的源码如下

image.png

首先需要了解一下 TimeUnit 工具类,TimeUnit 表示给定粒度单位的持续时间,并且提供了一些用于时分秒跨单位转换的方法,通过使用这些方法进行定时和延迟操作。

toNanos 用于把 long 型表示的时间转换成为纳秒,然后判断线程是否被打断,如果没有打断,则以公平锁/非公平锁 的方式获取锁,如果能够获取返回true,获取失败则调用doAcquireNanos方法使用超时等待的方式获取锁。在超时等待获取锁的过程中,如果等待时间大于应等待时间,或者应等待时间设置不合理的话,返回 false。

image.png

这里面以超时的方式获取锁也可以画一张流程图如下

image.png

unlock 解锁流程

unlocklock 是一对情侣,它们分不开彼此,在调用 lock 后必须通过 unlock 进行解锁。如果当前线程持有锁,在调用 unlock 后,count 计数将减少。如果保持计数为0就会进行解锁。如果当前线程没有持有锁,在调用 unlock 会抛出 IllegalMonitorStateException 异常。下面是它的源码

image.png

在有了上面阅读源码的经历后,相信你会很快明白这段代码的意思,锁的释放不会区分公平锁还是非公平锁,主要的判断逻辑就是 tryRelease 方法,getState 方法会取得同步锁的重入次数,如果是获取了偏向锁,那么可能会多次获取,state 的值会大于 1,这时候 c 的值 > 0 ,返回 false,解锁失败。如果 state = 1,那么 c = 0,再判断当前线程是否是独占锁的线程,释放独占锁,返回 true,当 head 指向的头结点不为 null,并且该节点的状态值不为0的话才会执行 unparkSuccessor 方法,再进行锁的获取。

image.png

ReentrantLock 其他方法

isHeldByCurrentThread & getHoldCount

在多线程同时访问时,ReentrantLock 由最后一次成功锁定的线程拥有,当这把锁没有被其他线程拥有时,线程调用 lock() 方法会立刻返回并成功获取锁。如果当前线程已经拥有锁,这个方法会立刻返回。可以通过 isHeldByCurrentThread getHoldCount 来进行检查。

首先来看 isHeldByCurrentThread 方法

public boolean isHeldByCurrentThread() {
  return sync.isHeldExclusively();
}

根据方法名可以略知一二,是否被当前线程持有,它用来询问锁是否被其他线程拥有,这个方法和 Thread.holdsLock(Object) 方法内置的监视器锁相同,而 Thread.holdsLock(Object) 是 Thread 类的静态方法,是一个 native 类,它表示的意思是如果当前线程在某个对象上持有 monitor lock(监视器锁) 就会返回 true。这个类没有实际作用,仅仅用来测试和调试所用。例如

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert lock.isHeldByCurrentThread();
}

这个方法也可以确保重入锁能够表现出不可重入的行为

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert !lock.isHeldByCurrentThread();
  lock.lock();
  try {
    // 执行业务代码
  }finally {
    lock.unlock();
  }
}

如果当前线程持有锁则 lock.isHeldByCurrentThread() 返回 true,否则返回 false。

我们在了解它的用法后,看一下它内部是怎样实现的,它内部只是调用了一下 sync.isHeldExclusively(),sync 是 ReentrantLock 的一个静态内部类,基于 AQS 实现,而 AQS 它是一种抽象队列同步器,是许多并发实现类的基础,例如 ReentrantLock/Semaphore/CountDownLatch。sync.isHeldExclusively() 方法如下

protected final boolean isHeldExclusively() {
  return getExclusiveOwnerThread() == Thread.currentThread();
}

此方法会在拥有锁之前先去读一下状态,如果当前线程是锁的拥有者,则不需要检查。

getHoldCount()方法和isHeldByCurrentThread 都是用来检查线程是否持有锁的方法,不同之处在于 getHoldCount() 用来查询当前线程持有锁的数量,对于每个未通过解锁操作匹配的锁定操作,线程都会保持锁定状态,这个方法也通常用于调试和测试,例如

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert lock.getHoldCount() == 0;
  lock.lock();
  try {
    // 执行业务代码
  }finally {
    lock.unlock();
  }
}

这个方法会返回当前线程持有锁的次数,如果当前线程没有持有锁,则返回0。

newCondition 创建 ConditionObject 对象

ReentrantLock 可以通过 newCondition 方法创建 ConditionObject 对象,而 ConditionObject 实现了 Condition 接口,关于 Condition 的用法我们后面再讲。

isLocked 判断是否锁定

查询是否有任意线程已经获取锁,这个方法用来监视系统状态,而不是用来同步控制,很简单,直接判断 state 是否等于0。

isFair 判断是否是公平锁的实例

这个方法也比较简单,直接使用 instanceof 判断是不是 FairSync 内部类的实例

public final boolean isFair() {
  return sync instanceof FairSync;
}

getOwner 判断锁拥有者

判断同步状态是否为0,如果是0,则没有线程拥有锁,如果不是0,直接返回获取锁的线程。

final Thread getOwner() {
  return getState() == 0 ? null : getExclusiveOwnerThread();
}

hasQueuedThreads 是否有等待线程

判断是否有线程正在等待获取锁,如果头节点与尾节点不相等,说明有等待获取锁的线程。

public final boolean hasQueuedThreads() {
  return head != tail;
}

isQueued 判断线程是否排队

判断给定的线程是否正在排队,如果正在排队,返回 true。这个方法会遍历队列,如果找到匹配的线程,返回true

public final boolean isQueued(Thread thread) {
  if (thread == null)
    throw new NullPointerException();
  for (Node p = tail; p != null; p = p.prev)
    if (p.thread == thread)
      return true;
  return false;
}

getQueueLength 获取队列长度

此方法会返回一个队列长度的估计值,该值只是一个估计值,因为在此方法遍历内部数据结构时,线程数可能会动态变化。 此方法设计用于监视系统状态,而不用于同步控制。

public final int getQueueLength() {
  int n = 0;
  for (Node p = tail; p != null; p = p.prev) {
    if (p.thread != null)
      ++n;
  }
  return n;
}

getQueuedThreads 获取排队线程

返回一个包含可能正在等待获取此锁的线程的集合。 因为实际的线程集在构造此结果时可能会动态更改,所以返回的集合只是一个大概的列表集合。 返回的集合的元素没有特定的顺序。

public final Collection<Thread> getQueuedThreads() {
  ArrayList<Thread> list = new ArrayList<Thread>();
  for (Node p = tail; p != null; p = p.prev) {
    Thread t = p.thread;
    if (t != null)
      list.add(t);
  }
  return list;
}

回答上面那个问题

那么你看完源码分析后,你能总结出 synchronizedlock 锁的实现 ReentrantLock 有什么异同吗?

Synchronzied 和 Lock 的主要区别如下:

  • 存在层面:Syncronized 是Java 中的一个关键字,存在于 JVM 层面,Lock 是 Java 中的一个接口
  • 锁的释放条件:1. 获取锁的线程执行完同步代码后,自动释放;2. 线程发生异常时,JVM会让线程释放锁;Lock 必须在 finally 关键字中释放锁,不然容易造成线程死锁
  • 锁的获取: 在 Syncronized 中,假设线程 A 获得锁,B 线程等待。如果 A 发生阻塞,那么 B 会一直等待。在 Lock 中,会分情况而定,Lock 中有尝试获取锁的方法,如果尝试获取到锁,则不用一直等待
  • 锁的状态:Synchronized 无法判断锁的状态,Lock 则可以判断
  • 锁的类型:Synchronized 是可重入,不可中断,非公平锁;Lock 锁则是 可重入,可判断,可公平锁
  • 锁的性能:Synchronized 适用于少量同步的情况下,性能开销比较大。Lock 锁适用于大量同步阶段:

    Lock 锁可以提高多个线程进行读的效率(使用 readWriteLock)

  • 在竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;
  • ReetrantLock 提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等

还有什么要说的吗

面试官可能还会问你 ReentrantLock 的加锁流程是怎样的,其实如果你能把源码给他讲出来的话,一定是高分。如果你记不住源码流程的话可以记住下面这个简化版的加锁流程

  • 如果 lock 加锁设置成功,设置当前线程为独占锁的线程;
  • 如果 lock 加锁设置失败,还会再尝试获取一次锁数量,

    如果锁数量为0,再基于 CAS 尝试将 state(锁数量)从0设置为1一次,如果设置成功,设置当前线程为独占锁的线程;

    如果锁数量不为0或者上边的尝试又失败了,查看当前线程是不是已经是独占锁的线程了,如果是,则将当前的锁数量+1;如果不是,则将该线程封装在一个Node内,并加入到等待队列中去。等待被其前一个线程节点唤醒。

文章参考:

【试验局】ReentrantLock中非公平锁与公平锁的性能测试

第五章 ReentrantLock源码解析1--获得非公平锁与公平锁lock()

https://juejin.im/post/5c95df...

【JUC】JDK1.8源码分析之ReentrantLock(三)

https://www.lagou.com/lgeduar...

查看原文

赞 39 收藏 27 评论 5