Java 基础面试题(爪哇程序员)
说下面向对象四大特性
封装、继承、多态、抽象。
什么是Java程序的主类?应用程序和小程序的主类有何不同?
一个程序中可以有多个类,但只能有一个类是主类。在Java应用程序中,这个主类是指包含 main()方法的类。而在Java小程序中,这个主类是一个继承自系统类JApplet或Applet的子 类。应用程序的主类不一定要求是public类,但小程序的主类要求必须是public类。主类是 Java程序执行的入口点。
Java语言有哪些特点
封装、继承、多态、抽象。
访问修饰符public,private,protected,以及不写(默认)时的区别?
修饰符 | 当前类 | 同包 | 子类 | 其他包 |
---|---|---|---|---|
public | √ | √ | √ | √ |
protected | √ | √ | √ | × |
default | √ | √ | × | × |
private | √ | × | × | × |
类的成员不写访问修饰时默认为default。默认对于同一个包中的其他类相当于公开 (public),对于不是同一个包中的其他类相当于私有(private)。受保护(protected)对 子类相当于公开,对不是同一包中的没有父子关系的类相当于私有。Java中,外部类的修饰符 只能是public或默认,类的成员(包括内部类)的修饰符可以是以上四种。
float f=3.4;是否正确?
不正确。3.4是双精度数,将双精度型(double)赋值给浮点型(float)属于下转型(downcasting,也称为窄化)会造成精度损失,因此需要强制类型转换float f =(float)3.4; 或者写 成float f =3.4F;。
Java有没有goto?
goto 是Java中的保留字,在目前版本的Java中没有使用。(根据James Gosling(Java之 父)编写的《The Java Programming Language》一书的附录中给出了一个Java关键字列 表,其中有goto和const,但是这两个是目前无法使用的关键字,因此有些地方将其称之为保 留字,其实保留字这个词应该有更广泛的意义,因为熟悉C语言的程序员都知道,在系统类库 中使用过的有特殊意义的单词或单词的组合都被视为保留字)
&和&&的区别?
&运算符有两种用法:(1)按位与;(2)逻辑与。
&&运算符是短路与运算。
逻辑与跟短路与的差别是非常巨大的,虽然二者都要求运算符左右两端的布尔值都是true整个 表达式的值才是true。&&之所以称为短路运算是因为,如果&&左边的表达式的值是false,右 边的表达式会被直接短路掉,不会进行运算。很多时候我们可能都需要用&&而不是&,例如在 验证用户登录时判定用户名不是null而且不是空字符串,应当写为:username != null &&!username.equals(""),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果 不成立,根本不能进行字符串的equals比较,否则会产生NullPointerException异常。注 意:逻辑或运算符(|)和短路或运算符(||)的差别也是如此。
Math.round(11.5) 等于多少?Math.round(-11.5)等于多少?
Math.round(11.5)的返回值是12,Math.round(-11.5)的返回值是-11。四舍五入的原理是在 参数上加0.5然后进行下取整。
用最有效率的方法计算2乘以8?
2 << 3
什么是Java注释
定义:用于解释说明程序的文字
Java注释的分类
单行注释 格式:
-
单行注释
- 格式: // 注释文字
-
多行注释
-
格式: / 注释文字 /
-
文档注释
-
格式:/ 注释文字* /
Java注释的作用
在程序中,尤其是复杂的程序中,适当地加入注释可以增加程序的可读性,有利于程序的修 改、调试和交流。注释的内容在程序编译的时候会被忽视,不会产生目标代码,注释的部分不 会对程序的执行结果产生任何影响。
注意事项:多行和文档注释都不能嵌套使用。
Java有哪些数据类型
定义:Java语言是强类型语言,对于每一种数据都定义了明确的具体的数据类型,在内存中分 配了不同大小的内存空间。
-
基本数据类型
- 数值型
- 整数类型(byte,short,int,long)
- 浮点类型(float,double)
- 字符型(char)
- 布尔型(boolean)
-
引用数据类型
-
类(class)
- 接口(interface)
- 数组([])
final 有什么用?
用于修饰类、属性和方法;
-
被final修饰的类不可以被继承
-
被final修饰的方法不可以被重写
-
被final修饰的变量不可以被改变,被final修饰不可变的是变量的引用,而不是引用指向 的内容,引用指向的内容是可以改变的
final finally finalize的区别
- final可以修饰类、变量、方法,修饰类表示该类不能被继承、修饰方法表示该方法不能 被重写、修饰变量表 示该变量是一个常量不能被重新赋值。
- finally一般作用在try-catch代码块中,在处理异常的时候,通常我们将一定要执行的代 码方法finally代码块 中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代码。
- finalize是一个方法,属于Object类的一个方法,而Object类是所有类的父类,该方法一 般由垃圾回收器来调 用,当我们调用System.gc() 方法的时候,由垃圾回收器调用finalize(),回收垃圾,一 个对象是否可回收的 最后判断。
String str = “i” 和String str = new String(“1”)一样吗?
不一样,因为内存的分配方式不一样。String str = "i"的方式JVM会将其分配到常量池中,而 String str = new String("i")JVM会将其分配到堆内存中。
Java 中操作字符串都有哪些类?它们之间有什么区别?
操作字符串的类有:String、StringBuffer、StringBuilder。
String 和 StringBuffer、StringBuilder 的区别在于 String 声明的是不可变的对象,每次操 作都会生成新的 String 对象,再将指针指向新的 String 对象,而 StringBuffer 、 StringBuilder 可以在原有对象的基础上进行操作,所以在经常改变字符串内容的情况下最好 不要使用 String。
StringBuffer 和 StringBuilder 最大的区别在于,StringBuffer 是线程安全的,而 StringBuilder 是非线程安全的,但 StringBuilder 的性能却高于 StringBuffer,所以在单线 程环境下推荐使用 StringBuilder,多线程环境下推荐使用 StringBuffer。
Java中为什么要用 clone?
在实际编程过程中,我们常常要遇到这种情况:有一个对象 A,在某一时刻 A 中已经包含了一 些有效值,此时可能会需要一个和 A 完全相同新对象 B,并且此后对 B 任何改动都不会影响到 A 中的值,也就是说,A 与 B 是两个独立的对象,但 B 的初始值是由 A 对象确定的。在 Java 语言中,用简单的赋值语句是不能满足这种需求的。要满足这种需求虽然有很多途径,但 clone()方法是其中最简单,也是最高效的手段。
深克隆和浅克隆?
浅克隆:创建一个新对象,新对象的属性和原来对象完全相同,对于非基本类型属性,仍指向 原有属性所指向的对象的内存地址。
深克隆:创建一个新对象,属性中引用的其他对象也会被克隆,不再指向原有对象地址。
new一个对象的过程和clone一个对象的区别?
new 操作符的本意是分配内存。程序执行到 new 操作符时,首先去看 new 操作符后面的类 型,因为知道了类型,才能知道要分配多大的内存空间。分配完内存之后,再调用构造函数, 填充对象的各个域,这一步叫做对象的初始化,构造方法返回后,一个对象创建完毕,可以把 他的引用(地址)发布到外部,在外部就可以使用这个引用操纵这个对象。
clone 在第一步是和 new 相似的,都是分配内存,调用 clone 方法时,分配的内存和原对象 (即调用 clone 方法的对象)相同,然后再使用原对象中对应的各个域,填充新对象的域,填 充完成之后,clone方法返回,一个新的相同的对象被创建,同样可以把这个新对象的引用发 布到外部。
Java中实现多态的机制是什么?
Java中的多态靠的是父类或接口定义的引用变量可以指向子类或具体实现类的实例对象,而程 序调用的方法在运行期才动态绑定,就是引用变量所指向的具体实例对象的方法,也就是内存 里正在运行的那个对象的方法,而不是引用变量的类型中定义的方法。
谈谈你对多态的理解?
多态就是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编程 时并不确定,而是在程序运行期间才确定,即一个引用变量到底会指向哪个类的实例对象,该 引用变量发出的方法调用到底是哪个类中实现的方法,必须在程序运行期间才能决定。因为在 程序运行时才确定具体的类,这样,不用修改源代码,就可以让引用变量绑定到各种不同的对 象上,从而导致该引用调用的具体方法随之改变,即不修改程序代码就可以改变程序运行时所 绑定的具体代码,让程序可以选择多个运行状态,这就是多态性。
构造器(constructor)是否可被重写(override)?
构造器不能被继承,因此不能被重写,但可以被重载。
两个对象值相同(x.equals(y) == true),但却可有不同的hash code,这句话对 不对?
不对,如果两个对象x和y满足x.equals(y) == true,它们的哈希码(hash code)应当相同。
Java对于eqauls方法和hashCode方法是这样规定的:
(1)如果两个对象相同(equals方法返回true),那么它们的hashCode值一定要相同;
(2)如果两个对象的hashCode相同,它们并不一定相同。
当然,你未必要按照要求去做,但是如果你违背了上述原则就会发现在使用容器时,相同的对 象可以出现在Set集合中,同时增加新元素的效率会大大下降(对于使用哈希存储的系统,如 果哈希码频繁的冲突将会造成存取性能急剧下降)。
是否可以继承String类?
String 类是final类,不可以被继承。
补充:继承String本身就是一个错误的行为,对String类型最好的重用方式是关联关系 (Has-A)和依赖关系(Use-A)而不是继承关系(Is-A)。
String类的常用方法有哪些?
-
indexof();返回指定字符的的索引。
-
charAt();返回指定索引处的字符。
-
replace();字符串替换。
-
trim();去除字符串两端空格。
-
splt();字符串分割,返回分割后的字符串数组。
-
getBytes();返回字符串byte类型数组。
-
length();返回字符串长度。
-
toLowerCase();将字符串转换为小写字母。 •toUpperCase();将字符串转换为大写字母。
-
substring();字符串截取。
-
equals();比较字符串是否相等。
char型变量中能否能不能存储一个中文汉字,为什么?
char可以存储一个中文汉字,因为Java中使用的编码是Unicode(不选择任何特定的编码,直 接使用字符在字符集中的编号,这是统一的唯一方法),一个char 类型占2个字节(16 比 特),所以放一个中文是没问题的。
String类的常用方法有哪些?
-
indexof();返回指定字符的的索引。
-
charAt();返回指定索引处的字符。
-
replace();字符串替换。
-
trim();去除字符串两端空格。
-
splt();字符串分割,返回分割后的字符串数组。
-
getBytes();返回字符串byte类型数组。
-
length();返回字符串长度。
-
toLowerCase();将字符串转换为小写字母。 •toUpperCase();将字符串转换为大写字母。
-
substring();字符串截取。
-
equals();比较字符串是否相等。
super关键字的用法
super可以理解为是指向自己超(父)类对象的一个指针,而这个超类指的是离自己最近的一 个父类。
super也有三种用法:
1.普通的直接引用
与this类似,super相当于是指向当前对象的父类的引用,这样就可以用super.xxx来引用父类的成员。
2.子类中的成员变量或方法与父类中的成员变量或方法同名时,用super进行区分
class Person {
protected String name;
public Person(String name) {
this.name = name;
}
}
class Student extends Person {
private String name;
public Student(String name, String name1) {
super(name);
this.name = name1;
}
public void getInfo() {
System.out.println(this.name);
System.out.println(super.name);
}
}
public class Test {
public static void main(String[] args) {
Student s1 = new Student("Father", "Child");
s1.getInfo();
}
}
3.引用父类构造函数
- super(参数):调用父类中的某一个构造函数(应该为构造函数中的第一条语句)。
- this(参数):调用本类中另一种形式的构造函数(应该为构造函数中的第一条语句)。
this与super的区别
- super:它引用当前对象的直接父类中的成员(用来访问直接父类中被隐藏的父类中成员
- 数据或函数,基类与派生类中有相同成员定义时如:super.变量名 super.成员函数据名 (实参)
- this:它代表当前对象名(在程序中易产生二义性之处,应使用this来指明当前对象;如果函数的形参与类中的成员数据同名,这时需用this来指明成员变量名)
- super()和this()类似,区别是,super()在子类中调用父类的构造方法,this()在本类内
- 调用本类的其它构造方法。
- super()和this()均需放在构造方法内第一行。
- 尽管可以用this调用一个构造器,但却不能调用两个。
- this和super不能同时出现在一个构造函数里面,因为this必然会调用其它的构造函数, 其它的构造函数必然也会有super语句的存在,所以在同一个构造函数里面有相同的语 句,就失去了语句的意义,编译器也不会通过。
- this()和super()都指的是对象,所以,均不可以在static环境中使用。包括:static变
- 量,static方法,static语句块。
- 从本质上讲,this是一个指向本对象的指针, 然而super是一个Java关键字。
static存在的主要意义
static的主要意义是在于创建独立于具体对象的域变量或者方法。 以致于即使没有创建对象, 也能使用属性和调用方法 !
static关键字还有一个比较关键的作用就是 用来形成静态代码块以优化程序性能 。static块可 以置于类中的任何地方,类中可以有多个static块。在类初次被加载的时候,会按照static块的 顺序来执行每个static块,并且只会执行一次。
为什么说static块可以用来优化程序性能,是因为它的特性:只会在类加载的时候执行一次。因 此,很多时候会将一些只需要进行一次的初始化操作都放在static代码块中进行。
static的独特之处
1、被static修饰的变量或者方法是独立于该类的任何对象,也就是说,这些变量和方法 不属于 任何一个实例对象,而是被类的实例对象所共享 。
怎么理解 “被类的实例对象所共享” 这句话呢?就是说,一个类的静态成员,它是属于大伙的 【大伙指的是这个类的多个对象实例,我们都知道一个类可以创建多个实例!】,所有的类对 象共享的,不像成员变量是自个的【自个指的是这个类的单个实例对象】…我觉得我已经讲的 很通俗了,你明白了咩?
2、在该类被第一次加载的时候,就会去加载被static修饰的部分,而且只在类第一次使用时加 载并进行初始化,注意这是第一次用就要初始化,后面根据需要是可以再次赋值的。
3、static变量值在类加载的时候分配空间,以后创建类对象的时候不会重新分配。赋值的话, 是可以任意赋值的!
4、被static修饰的变量或者方法是优先于对象存在的,也就是说当一个类加载完毕之后,即便 没有创建对象,也可以去访问。
static应用场景
因为static是被类的实例对象所共享,因此如果 某个成员变量是被所有对象所共享的,那么这 个成员变量就应该定义为静态变量 。
因此比较常见的static应用场景有:
1、修饰成员变量
2、修饰成员方法
3、静态代码块
4、修饰类【只能修饰内部类也就是静态内 部类】
5、静态导包
static注意事项
1、静态只能访问静态。
2、非静态既可以访问非静态的,也可以访问静态的。
break ,continue ,return 的区别及作用
break 跳出总上一层循环,不再执行循环(结束当前的循环体)
continue 跳出本次循环,继续执行下次循环(结束正在执行的循环 进入下一个循环条件)
return 程序返回,不再执行下面的代码(结束当前的方法 直接返回)
在Java中定义一个不做事且没有参数的构造方法的作用
Java程序在执行子类的构造方法之前,如果没有用super()来调用父类特定的构造方法,则会 调用父类中“没有参数的构造方法”。因此,如果父类中只定义了有参数的构造方法,而在子类 的构造方法中又没有用super()来调用父类中特定的构造方法,则编译时将发生错误,因为 Java程序在父类中找不到没有参数的构造方法可供执行。解决办法是在父类里加上一个不做事 且没有参数的构造方法。
构造方法有哪些特性?
名字与类名相同;
没有返回值,但不能用void声明构造函数;
生成类的对象时自动执行,无需调用。
静态变量和实例变量区别
静态变量: 静态变量由于不属于任何实例对象,属于类的,所以在内存中只会有一份,在类的 加载过程中,JVM只为静态变量分配一次内存空间。
实例变量: 每次创建对象,都会为每个对象分配成员变量内存空间,实例变量是属于实例对象 的,在内存中,创建几次对象,就有几份成员变量。
静态方法和实例方法有何不同?
静态方法和实例方法的区别主要体现在两个方面:
- 在外部调用静态方法时,可以使用"类名.方法名"的方式,也可以使用"对象名.方法名"的 方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。
- 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法), 而不允许访问实例成员变量和实例方法;实例方法则无此限制
什么是方法的返回值?返回值的作用是什么?
方法的返回值是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能 产生结果)。返回值的作用:接收出结果,使得它可以用于其他的操作!
什么是内部类?
在Java中,可以将一个类的定义放在另外一个类的定义内部,这就是 内部类 。内部类本身就 是类的一个属性,与其他属性定义方式一致。
内部类的分类有哪些
内部类可以分为四种: 成员内部类、局部内部类、匿名内部类和静态内部类 。
Java中异常分为哪些种类?
按照异常需要处理的时机分为编译时异常(也叫受控异常)也叫 CheckedException 和运行时异 常(也叫非受控异常)也叫 UnCheckedException。Java认为Checked异常都是可以被处理的 异常,所以Java程序必须显式处理Checked异常。如果程序没有处理Checked 异常,该程序 在编译时就会发生错误无法编译。这体现了Java 的设计哲学:没有完善错误处理的代码根本没 有机会被执行。对Checked异常处理方法有两种:
-
第一种:当前方法知道如何处理该异常,则用try…catch块来处理该异常。
-
第二种:当前方法不知道如何处理,则在定义该方法时声明抛出该异常。
运行时异常只有当代码在运行时才发行的异常,编译的时候不需要try…catch。Runtime如除 数是0和数组下标越界等,其产生频繁,处理麻烦,若显示申明或者捕获将会对程序的可读性 和运行效率影响很大。所以由系统自动检测并将它们交给缺省的异常处理程序。当然如果你有 处理要求也可以显示捕获它们。
hashCode 与 equals (重要)
HashSet如何检查重复 两个对象的 hashCode() 相同,则 equals() 也一定为 true,对吗?
hashCode和equals方法的关系 面试官可能会问你:“你重写过 hashcode 和 equals 么,为什么重写equals时必须重写 hashCode方法?”
hashCode()介绍
hashCode() 的作用是获取哈希码,也称为散列码;它实际上是返回一个int整数。这个哈希码 的作用是确定该对象在哈希表中的索引位置。hashCode() 定义在JDK的Object.java中,这就 意味着Java中的任何类都包含有hashCode()函数。
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这 其中就利用到了散列码!(可以快速找到所需要的对象)
为什么要有 hashCode
我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode:
当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位 置,同时也会与其他已经加入的对象的 hashcode 值作比较,如果没有相符的hashcode, HashSet会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用 equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让 其加入操作成功。如果不同的话,就会重新散列到其他位置。(摘自我的Java启蒙书《Head first java》第二版)。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。
hashCode()与equals()的相关规定
如果两个对象相等,则hashcode一定也是相同的
两个对象相等,对两个对象分别调用equals方法都返回true
两个对象有相同的hashcode值,它们也不一定是相等的
因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖
hashCode() 的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)
对象的相等与指向他们的引用相等,两者有什么不同?
对象的相等 比的是内存中存放的内容是否相等而 引用相等 比较的是他们指向的内存地址是否 相等。
抽象类和接口(Java7)的区别
-
抽象类可以提供成员方法的实现细节,而接口中只能存在public abstract 方法;
-
抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是public static final 类型的;
-
接口中不能含有静态代码块以及静态方法,而抽象类可以有静态代码块和静态方法;
-
一个类只能继承一个抽象类,而一个类却可以实现多个接口。
Java 8的接口新增了哪些特性?
增加了default方法和static方法,这2种方法可以有方法体。
重写和重载的区别
重写是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。即 外壳不变,核心重写!
重写的好处在于子类可以根据需要,定义特定于自己的行为。 也就是说子类能够根据需要实现 父类的方法。
重写方法不能抛出新的检查异常或者比被重写方法申明更加宽泛的异常。
重载(overloading) 是在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以 不同。
每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。
ArrayList和LinkedList有什么区别?
-
ArrayList和LinkedList的差别主要来自于Array和LinkedList数据结构的不同。 ArrayList是基于数组实现的,LinkedList是基于双链表实现的。另外LinkedList类不 仅是List接口的实现类,可以根据索引来随机访问集合中的元素,除此之外, LinkedList还实现了Deque接口,Deque接口是Queue接口的子接口,它代表一个双向 队列,因此LinkedList可以作为双向队列 ,栈(可以参见Deque提供的接口方法)和 List集合使用,功能强大。
-
因为Array是基于索引(index)的数据结构,它使用索引在数组中搜索和读取数据是很快 的,可以直接返回数组中index位置的元素,因此在随机访问集合元素上有较好的性能。 Array获取数据的时间复杂度是O(1),但是要插入、删除数据却是开销很大的,因为这需 要移动数组中插入位置之后的的所有元素。
-
相对于ArrayList,LinkedList的随机访问集合元素时性能较差,因为需要在双向列表中 找到要index的位置,再返回;但在插入,删除操作是更快的。因为LinkedList不像 ArrayList一样,不需要改变数组的大小,也不需要在数组装满的时候要将所有的数据重 新装入一个新的数组,这是ArrayList最坏的一种情况,时间复杂度是O(n),而 LinkedList中插入或删除的时间复杂度仅为O(1)。ArrayList在插入数据时还需要更新索 引(除了插入数组的尾部)。
-
LinkedList需要更多的内存,因为ArrayList的每个索引的位置是实际的数据,而 LinkedList中的每个节点中存储的是实际的数据和前后节点的位置。
静态代理和动态代理的区别
静态代理中代理类在编译期就已经确定,而动态代理则是JVM运行时动态生成,静态代理的效 率相对动态代理来说相对高一些,但是静态代理代码冗余大,一单需要修改接口,代理类和委 托类都需要修改。
JDK动态代理和CGLIB动态代理的区别
JDK动态代理只能对实现了接口的类生成代理,而不能针对类。
CGLIB是针对类实现代理,主要是对指定的类生成一个子类,覆盖其中的方法。因为是继承, 所以该类或方法最好不要声明成final。
Java 的接口和 C++的虚类的相同和不同处
答:由于 Java 不支持多继承,而有可能某个类或对象要使用分别在几个类或对象里面的方法或属性,现有的单继承机制就不能满足要求。 与继承相比,接口有更高的灵活性,因为接口中没有任何实现代码。 当一个类实现了接口以后,该类要实现接口里面所有的方法和属性,并且接口里面的属性在默认状态下面都是public static,所有方法默认情况下是 public.一个类可以实现多个接口。
一个“.java”源文件中是否可以包含多个类(不是内部类)有什么限制
答:可以;必须只有一个类名与文件名相同。
说出一些常用的类,包,接口,请各举 5 个
答:常用的类:
- BufferedReader
- BufferedWriter
- FileReader FileWirter
- String Integer;
常用的包:
- java.lang
- java.awt
- java.io
- java.util
- java.sql; 常用的接口:
- Remote
- List
- Map
- Document
- NodeList
Anonymous Inner Class (匿名内部类) 是否可以 extends(继承)其它类是否可以 implements(实现)interface(接口)
答:可以继承其他类或实现其他接口,在 swing 编程中常用此方式。
内部类可以引用他包含类的成员吗有没有什么限制
答:一个内部类对象可以访问创建它的外部类对象的内容。
java 中实现多态的机制是什么
答:方法的覆盖 Overriding 和重载 Overloading 是 java 多态性的不同表现;
- 覆盖 Overriding 是父类与子类之间多态性的一种表现,
- 重载 Overloading 是一个类中多态性的一种表现。
在 java 中一个类被声明为 final 类型,表示了什么意思
答:表示该类不能被继承,是顶级类。
下面哪些类可以被继承
- 1)java.lang.Thread (T)
- 2)java.lang.Number (T)
- 3)java.lang.Double (F)
- 4)java.lang.Math (F)
- 5)java.lang.Void (F)
- 6)java.lang.Class (F)
- 7)java.lang.ClassLoader (T) 答:1、2、7 可以被继承。
指出下面程序的运行结果
class A{
static{
System.out.print("1");
}
public A(){
System.out.print("2");
}
}
class B extends A{
static{
System.out.print("a");
}
public B(){
System.out.print("b");
}
}
public class Hello{
public static void main(String[] ars){
A ab = new B(); //执行到此处,结果: 1a2b
ab = new B(); //执行到此处,结果: 1a2b2b
}
}
答:输出结果为 1a2b2b; 类的 static 代码段,可以看作是类首次加载(虚拟机加 载)执行的代码,而对于类加载,首先要执行其基类的构造,再执行其本身的构造。
继承时候类的执行顺序问题,一般都是选择题,问你将会打印出什么
父类:
package test;
public class FatherClass {
public FatherClass() {
System.out.println("FatherClass Create");
}
}
子类:
package test;
import test.FatherClass;
public class ChildClass extends FatherClass {
public ChildClass() {
System.out.println("ChildClass Create");
}
public static void main(String[] args) {
FatherClass fc = new FatherClass();
ChildClass cc = new ChildClass();
}
}
答:输出结果为:
FatherClass Create
FatherClass Create
ChildClass Create
内部类的实现方式
答:示例代码如下:
package test;
public class OuterClass {
private class InterClass {
public InterClass() {
System.out.println("InterClass Create");
}
}
public OuterClass() {
InterClass ic = new InterClass();
System.out.println("OuterClass Create");
}
public static void main(String[] args) {
OuterClass oc = new OuterClass();
}
}
输出结果为:
InterClass Create
OuterClass Create
关于内部类
public class OuterClass {
private double d1 = 1.0;
//insert code here
}
You need to insert an inner class declaration at line 3,Which two inner
class declarations are valid?(Choose two.)
A. class InnerOne{
public static double methoda() {return d1;}
}
B. public class InnerOne{
static double methoda() {return d1;}
}
C. private class InnerOne{
double methoda() {return d1;}
}
D. static class InnerOne{
protected double methoda() {return d1;}
}
E. abstract class InnerOne{
public abstract double methoda();
}
答:答案为 C、E;说明如下:
- 1)静态内部类可以有静态成员,而非静态内部类则不能有静态成员;故 A、B 错;
- 2)静态内部类的非静态成员可以访问外部类的静态变量,而不可访问外部类的非静态变量;故 D 错;
- 3)非静态内部类的非静态成员可以访问外部类的非静态变量;故 C 正确 。
数据类型之间的转换
- 1)如何将数值型字符转换为数字?
- 2)如何将数字转换为字符?
- 3)如何取小数点前两位并四舍五入? 【基础】 答:
- 1)调用数值类型相应包装类中的方法 parse***(String)或 valueOf(String) 即可返回相应基本类型或包装类型数值;
- 2)将数字与空字符串相加即可获得其所对应的字符串;另外对于基本类型 数字还可调用 String 类中的 valueOf(…)方法返回相应字符串,而对于包装类型数字则可调用其 toString()方法获得相应字符串;
- 3)可用该数字构造一 java.math.BigDecimal 对象,再利用其 round()方法 进行四舍五入到保留小数点后两位,再将其转换为字符串截取最后两位。
字符串操作:如何实现字符串的反转及替换
答:可用字符串构造一 StringBuffer 对象,然后调用 StringBuffer 中的 reverse方法即可实现字符串的反转,调用 replace 方法即可实现字符串的替换。
编码转换:怎样将 GB2312 编码的字符串转换为 ISO-8859-1 编码的字符串
答:示例代码如下:
String s1 = "你好";
String s2 = new String(s1.getBytes("GB2312"), "ISO-8859-1");
写一个函数,要求输入一个字符串和一个字符长度,对该字符串进行分隔
答:函数代码如下:
public String[] split(String str, int chars){
int n = (str.length()+ chars - 1)/chars;
String ret[] = new String[n];
for(int i=0; i<n; i++){
if(i < n-1){
ret[i] = str.substring(i*chars , (i+1)*chars);
}else{
ret[i] = str.substring(i*chars);
}
}
return ret;
}
写一个函数,2 个参数,1 个字符串,1 个字节数,返回截取的字符串,要求字符串中的中文不能出现乱码:如(“我 ABC”,4)应该截为“我 AB”,输入(“我ABC 汉 DEF”,6)应该输出为“我 ABC”而不是“我 ABC+汉的半个”
答:代码如下:
public String subString(String str, int subBytes) {
int bytes = 0; // 用来存储字符串的总字节数
for (int i = 0; i < str.length(); i++) {
if (bytes == subBytes) {
return str.substring(0, i);
}
char c = str.charAt(i);
if (c < 256) {
bytes += 1; // 英文字符的字节数看作 1
} else {
bytes += 2; // 中文字符的字节数看作 2
if(bytes - subBytes == 1){
return str.substring(0, i);
}
}
}
return str;
}
日期和时间
- 1)如何取得年月日、小时分秒?
- 2)如何取得从 1970 年到现在的毫秒数?
- 3)如何取得某个日期是当月的最后一天?
- 4)如何格式化日期?【基础】 答:
-
1)创建 java.util.Calendar 实例(Calendar.getInstance()),调用其 get() 方法传入不同的参数即可获得参数所对应的值,如:
calendar.get(Calendar.YEAR);//获得年
-
2)以下方法均可获得该毫秒数:
Calendar.getInstance().getTimeInMillis(); System.currentTimeMillis();
-
3)示例代码如下:
Calendar time = Calendar.getInstance(); time.set(Calendar.DAY_OF_MONTH, time.getActualMaximum(Calendar.DAY_OF_MONTH));
-
4)利用 java.text.DataFormat 类中的 format()方法可将日期格式化。
Java 编程,打印昨天的当前时刻
答:
public class YesterdayCurrent{
public static void main(String[] args){
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DATE, -1);
System.out.println(cal.getTime());
}
}
java 和 javasciprt 的区别
答:JavaScript 与 Java 是两个公司开发的不同的两个产品。 Java 是 SUN 公司推出的新一代面向对象的程序设计语言,特别适合于 Internet 应用程序开发; 而JavaScript 是 Netscape 公司的产品,其目的是为了扩展 Netscape Navigator功能,而开发的一种可以嵌入 Web 页面中的基于对象和事件驱动的解释性语言, 它的前身是 Live Script; 而 Java 的前身是 Oak 语言。 下面对两种语言间的异同作如下比较:
1)基于对象和面向对象:
- Java 是一种真正的面向对象的语言,即使是开发简单的程序,必须设计对象;
-
JavaScript 是种脚本语言,它可以用来制作与网络无关的,与用户交互作用的复杂软件。它是一种基于对象(Object Based)和事件驱动(Event Driver)的编程语言。因而它本身提供了非常丰富的内部对象供设计人员使用;
2)解释和编译:
-
Java 的源代码在执行之前,必须经过编译;
-
JavaScript 是一种解释性编程语言,其源代码不需经过编译,由浏览器解释执行;
3)强类型变量和类型弱变量:
-
Java 采用强类型变量检查,即所有变量在编译之前必须作声明;
-
JavaScript 中变量声明,采用其弱类型。即变量在使用前不需作声明,而是解释器在运行时检查其数据类型;
4)代码格式不一样。
什么时候用 assert
答:assertion(断言)在软件开发中是一种常用的调试方式,很多开发语言中都支持这种机制。 一般来说,assertion 用于保证程序最基本、关键的正确性。 assertion 检查通常在开发和测试时开启。 为了提高性能,在软件发布后,assertion 检查通常是关闭的。 在实现中,断言是一个包含布尔表达式的语句,在执行这个语句时假定该表达式为 true;如果表达式计算为 false,那么系统会报告一个 Assertionerror。
断言用于调试目的:
assert(a > 0); // throws an Assertionerror if a <= 0
断言可以有两种形式:
- assert Expression1 ;
-
assert Expression1 : Expression2 ; Expression1 应该总是产生一个布尔值。 Expression2 可以是得出一个值的任意表达式;这个值用于生成显示更多调试信息的 String 消息。 断言在默认情况下是禁用的,要在编译时启用断言,需使用 source 1.4 标记:
javac -source 1.4 Test.java
要在运行时启用断言,可使用 -enableassertions 或者 -ea 标记。 要在运行时选择禁用断言,可使用 -da 或者 -disableassertions 标记。 要在系统类中启用断言,可使用 -esa 或者 -dsa 标记。 还可以在包的基础上启用或者禁用断言。可以在预计正常情况下不会到达的任何位置上放置断言。 断言可以用于验证传递给私有方法的参数。不过,断言不应该用于验证传递给公有方法的参数,因为不管是否启用了断言,公有方法都必须检查其参数。 不过,既可以在公有方法中,也可以在非公有方法中利用断言测试后置条件。另外,断言不应该以任何方式改变程序的状态。
Java 中的异常处理机制的简单原理和应用
答:当 JAVA 程序违反了 JAVA 的语义规则时,JAVA 虚拟机就会将发生的错误表示为一个异常。 违反语义规则包括 2 种情况。
- 一种是 JAVA 类库内置的语义检查。 例如数组下标越界,会引发 IndexOutOfBoundsException;访问 null 的对象时会引发 NullPointerException。
- 另一种情况就是 JAVA 允许程序员扩展这种语义检查,程序员可以创建自己的异常,并自由选择在何时用 throw 关键字引发异常。 所有的异常都是 java.lang.Thowable 的子类。
error 和 exception 有什么区别
答:error 表示系统级的错误和程序不必处理的异常,是恢复不是不可能但很困难的情况下的一种严重问题;比如内存溢出,不可能指望程序能处理这样的情况; exception 表示需要捕捉或者需要程序进行处理的异常,是一种设计或实现问题;也就是说,它表示如果程序运行正常,从不会发生的情况。
try {}里有一个 return 语句,那么紧跟在这个 try 后的 finally {}里的 code会不会被执行,什么时候被执行,在 return 前还是后
答:会执行,在 return 前执行。
JAVA 语言如何进行异常处理,关键字:throws,throw,try,catch,finally分别代表什么意义在 try 块中可以抛出异常吗
答:Java 通过面向对象的方法进行异常处理,把各种不同的异常进行分类,并提供了良好的接口。 在 Java 中,每个异常都是一个对象,它是 Throwable 类或其它子类的实例。 当一个方法出现异常后便抛出一个异常对象,该对象中包含有异常信息,调用这个对象的方法可以捕获到这个异常并进行处理。 Java 的异常处理是通过 5 个关键词来实现的:try、catch、throw、throws 和 finally。 一般情况下是用 try 来执行一段程序,如果出现异常,系统会抛出(throws)一个异常,这时候你可以通过它的类型来捕捉(catch)它,或最后(finally)由缺省处理器来处理;
- try 用来指定一块预防所有“异常”的程序;
- catch 子句紧跟在 try 块后面,用来指定你想要捕捉的“异常”的类型;
- throw 语句用来明确地抛出一个“异常”;
- throws 用来标明一个成员函数可能抛出的各种“异常”;
-
Finally 为确保一段代码不管发生什么“异常”都被执行一段代码;
可以在一个成员函数调用的外面写一个 try 语句,在这个成员函数内部写另一个 try 语句保护其他代码。 每当遇到一个 try 语句,“异常”的框架就放到堆栈上面,直到所有的 try 语句都完成。 如果下一级的 try 语句没有对某种“异常”进行处理,堆栈就会展开,直到遇到有处理这种“异常”的 try 语句。
运行时异常与一般异常有何异同
答:异常表示程序运行过程中可能出现的非正常状态,运行时异常表示虚拟机的通常操作中可能遇到的异常,是一种常见运行错误。 java 编译器要求方法必须声明抛出可能发生的非运行时异常,但是并不要求必须声明抛出未被捕获的运行时异常。
给我一个你最常见到的 runtime exception
答: ArithmeticException, ArrayStoreException, BufferOverflowException, BufferUnderflowException, CannotRedoException, CannotUndoException, ClassCastException, CMMException, ConcurrentModificationException, DOMException, EmptyStackException, IllegalArgumentException, IllegalMonitorStateException, IllegalPathStateException, IllegalStateException, ImagingOpException, IndexOutOfBoundsException, MissingResourceException, NegativeArraySizeException, NoSuchElementException, NullPointerException, ProfileDataException, ProviderException, RasterFormatException, SecurityException, SystemException, UndeclaredThrowableException, UnmodifiableSetException, UnsupportedOperationException
final, finally, finalize 的区别
答:final:修饰符(关键字);如果一个类被声明为 final,意味着它不能再派生出新的子类,不能作为父类被继承,因此一个类不能既被声明为 abstract的,又被声明为 final 的;将变量或方法声明为 final,可以保证它们在使用中不被改变;被声明为 final 的变量必须在声明时给定初值,而在以后的引用中只能读取,不可修改;被声明为 final 的方法也同样只能使用,不能重载。
finally:再异常处理时提供 finally 块来执行任何清除操作;如果抛出一个异常,那么相匹配的 catch 子句就会执行,然后控制就会进入 finally 块(如果有的话)。
finalize:方法名;Java 技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的。它是在 Object 类中定义的,因此所有的类都继承了它。子类覆盖 finalize() 方法以整理系统资源或者执行其他清理工作。finalize() 方法是在垃圾收集器删除对象之前对这个对象调用的。
类 Example A 继承 Exception,类 ExampleB 继承 Example A
有如下代码片断:
try{
throw new ExampleB(“b”);
}catch(ExampleA e){
System.out.printfln(“ExampleA”);
}catch(Exception e){
System.out.printfln(“Exception”);
}
输出的内容应该是: A:ExampleA B:Exception C:b D:无 答:输出为 A。
介绍 JAVA 中的 Collection FrameWork(及如何写自己的数据结构)
答:Collection FrameWork 如下:
Collection
├List
│├LinkedList
│├ArrayList
│└Vector
│ └Stack
└Set
Map
├Hashtable
├HashMap
└WeakHashMap
Collection 是最基本的集合接口,一个 Collection 代表一组 Object,即Collection 的元素(Elements);Map 提供 key 到 value 的映射。
List,Set,Map 是否继承自 Collection 接口
答:List,Set 是;Map 不是。
你所知道的集合类都有哪些主要方法
答:最常用的集合类是 List 和 Map。List 的具体实现包括 ArrayList 和 Vector,它们是可变大小的列表,比较适合构建、存储和操作任何类型对象的元素列表。 List 适用于按数值索引访问元素的情形。 Map 提供了一个更通用的元素存储方法。 Map 集合类用于存储元素对(称作“键”和“值”),其中每个键映射到一个值。
说出 ArrayList,Vector, LinkedList 的存储性能和特性
答:ArrayList 和 Vector 都是使用数组方式存储数据,此数组元素数大于实际存储的数据以便增加和插入元素,它们都允许直接按序号索引元素,但是插入元素要涉及数组元素移动等内存操作,所以索引数据快而插入数据慢,Vector 由于使用了 synchronized 方法(线程安全),通常性能上较 ArrayList 差,而LinkedList 使用双向链表实现存储,按序号索引数据需要进行前向或后向遍历,但是插入数据时只需要记录本项的前后项即可,所以插入速度较快。
Collection 和 Collections 的区别
答:Collection 是 java.util 下的接口,它是各种集合的父接口,继承于它的接口主要有 Set 和 List; Collections 是个 java.util 下的类,是针对集合的帮助类,提供一系列静态方法实现对各种集合的搜索、排序、线程安全化等操作。
HashMap 和 Hashtable 的区别
答:二者都实现了 Map 接口,是将惟一键映射到特定的值上;主要区别在于:
- 1)HashMap 没有排序,允许一个 null 键和多个 null 值,而 Hashtable 不允许;
- 2)HashMap 把 Hashtable 的 contains 方法去掉了,改成 containsvalue 和containsKey,因为 contains 方法容易让人引起误解;
- 3)Hashtable 继承自 Dictionary 类,HashMap 是 Java1.2 引进的 Map 接口的实现;
- 4)Hashtable 的方法是 Synchronize 的,而 HashMap 不是,在多个线程访问Hashtable 时,不需要自己为它的方法实现同步,而 HashMap 就必须为之提供外同步。 Hashtable 和 HashMap 采用的 hash/rehash 算法大致一样,所以性能不会有很大的差异。
Arraylist 与 Vector 区别
答:就 ArrayList 与 Vector 主要从二方面来说:
- 1)同步性:Vector 是线程安全的(同步),而 ArrayList 是线程序不安全的;
- 2)数据增长:当需要增长时,Vector 默认增长一倍,而 ArrayList 却是一半。
List、Map、Set 三个接口,存取元素时,各有什么特点
答:List 以特定次序来持有元素,可有重复元素。 Set 无法拥有重复元素,内部排序。 Map 保存 key-value 值,value 可多值。
Set 里的元素是不能重复的,那么用什么方法来区分重复与否呢 是用==还是 equals() 它们有何区别
答:Set 里的元素是不能重复的,用 equals ()方法来区分重复与否。 覆盖 equals()方法用来判断对象的内容是否相同,而”==”判断地址是否相等,用来决定引用值是否指向同一对象。
用程序给出随便大小的 10 个数,序号为 1-10,按从小到大顺序输出,并输出相应的序号
答:代码如下:
package test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
public class RandomSort {
public static void printRandomBySort() {
Random random = new Random(); // 创建随机数生成器
List list = new ArrayList();
// 生成 10 个随机数,并放在集合 list 中
for (int i = 0; i < 10; i++) {
list.add(random.nextInt(1000));
}
Collections.sort(list); // 对集合中的元素进行排序
Iterator it = list.iterator();
int count = 0;
while (it.hasNext()) { // 顺序输出排序后集合中的元素
System.out.println(++count + ": " + it.next());
}
}
public static void main(String[] args) {
printRandomBySort();
}
}
用 JAVA 实现一种排序,JAVA 类实现序列化的方法? 在 COLLECTION 框架中,实现比较要实现什么样的接口
答:用插入法进行排序代码如下:
package test;
import java.util.*;
class InsertSort {
ArrayList al;
public InsertSort(int num,int mod) {
al = new ArrayList(num);
Random rand = new Random();
System.out.println("The ArrayList Sort Before:");
for (int i=0;i<num al.add="" integer="" mod="" system.out.println="" public="" void="" sortit="" tempint="" int="" maxsize="1;" for="" i="1;i<al.size();i++){" if="">=
((Integer)al.get(MaxSize-1)).intValue()){
al.add(MaxSize,tempInt);
MaxSize++;
System.out.println(al.toString());
}else{
for (int j=0;j<maxsize if="">=tempInt.intValue()){
al.add(j,tempInt);
MaxSize++;
System.out.println(al.toString());
break;
}
}
}
}
System.out.println("The ArrayList Sort After:");
for(int i=0;i</maxsize></num>
JAVA 类实现序例化的方法是实现 java.io.Serializable 接口; Collection 框架中实现比较要实现 Comparable 接口和 Comparator 接口。
sleep() 和 wait() 有什么区别
答:sleep 是线程类(Thread)的方法,导致此线程暂停执行指定时间,给执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复。调用 sleep 不会释放对象锁。 wait 是 Object 类的方法,对此对象调用 wait 方法导致本线程放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象发出 notify 方法(或notifyAll)后本线程才进入对象锁定池准备获得对象锁进入运行状态。
当一个线程进入一个对象的一个 synchronized 方法后,其它线程是否可进入此对象的其它方法
答:其它线程只能访问该对象的其它非同步方法,同步方法则不能进入。
请说出你所知道的线程同步的方法
答:
- wait():使一个线程处于等待状态,并且释放所持有的对象的 lock;
- sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要捕捉 InterruptedException 异常;
- notify():唤醒一个处于等待状态的线程,注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且不是按优先级;
- notityAll():唤醒所有处入等待状态的线程,注意并不是给所有唤醒线程一个对象的锁,而是让它们竞争。
多线程有几种实现方法,都是什么同步有几种实现方法,都是什么
答:多线程有两种实现方法,分别是继承 Thread 类与实现 Runnable 接口 ,同步的实现方面有两种,分别是 synchronized,wait 与 notify。
同步和异步有何异同,在什么情况下分别使用他们举例说明
答:如果数据将在线程间共享。例如正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。
启动一个线程是用 run()还是 start()
答:启动一个线程是调用 start()方法,使线程所代表的虚拟处理机处于可运行状态,这意味着它可以由 JVM 调度并执行。这并不意味着线程就会立即运行。run()方法可以产生必须退出的标志来停止一个线程。
线程的基本概念、线程的基本状态以及状态之间的关系
答:线程指在程序执行过程中,能够执行程序代码的一个执行单位,每个程序至少都有一个线程,也就是程序本身; Java 中的线程有四种状态分别是:运行、就绪、挂起、结束。
简述 synchronized 和 java.util.concurrent.locks.Lock 的异同
答:
- 主要相同点:Lock 能完成 synchronized 所实现的所有功能;
- 主要不同点:Lock 有比 synchronized 更精确的线程语义和更好的性能。
- synchronized 会自动释放锁,而 Lock 一定要求程序员手工释放,并且必须在finally 从句中释放。
java 中有几种方法可以实现一个线程用什么关键字修饰同步方法 stop()和 suspend()方法为何不推荐使用
答:有两种实现方法,分别是继承 Thread 类与实现 Runnable 接口; 用 synchronized 关键字修饰同步方法; 反对使用 stop(),是因为它不安全。它会解除由线程获取的所有锁定,而且如果对象处于一种不连贯状态,那么其他线程能在那种状态下检查和修改它们。结果很难检查出真正的问题所在; suspend()方法容易发生死锁。 调用 suspend()的时候,目标线程会停下来,但却仍然持有在这之前获得的锁定。此时,其他任何线程都不能访问锁定的资源,除非被“挂起”的线程恢复运行。对任何线程来说,如果它们想恢复目标线程,同时又试图使用任何一个锁定的资源,就会造成死锁。 故不应该使用 suspend(),而应在自己的 Thread 类中置入一个标志,指出线程应该活动还是挂起。若标志指出线程应该挂起,便用 wait()命其进入等待状态。若标志指出线程应当恢复,则用一个 notify()重新启动线程。
设计 4 个线程,其中两个线程每次对 j 增加 1,另两个线程对 j 每次减少 1;写出程序
答:以下程序使用内部类实现线程,对 j 增减的时候没有考虑顺序问题:
public class TestThread {
private int j;
public TestThread(int j) {this.j = j;}
private synchronized void inc(){
j++;
System.out.println(j + "--Inc--" +
Thread.currentThread().getName());
}
private synchronized void dec(){
j--;
System.out.println(j + "--Dec--" +
Thread.currentThread().getName());
}
public void run() {
(new Dec()).start();
new Thread(new Inc()).start();
(new Dec()).start();
new Thread(new Inc()).start();
}
class Dec extends Thread {
public void run() {
for(int i=0; i<100; i++){
dec();
}
}
}
class Inc implements Runnable {
public void run() {
for(int i=0; i<100; i++){
inc();
}
}
}
public static void main(String[] args) {
(new TestThread(5)).run();
}
}
什么是 java 序列化,如何实现 java 序列化
答:序列化就是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。 可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。 序列化是为了解决在对对象流进行读写操作时所引发的问题; 序列化的实现:将需要被序列化的类实现 Serializable 接口,该接口没有需实现的方法,implements Serializable 只是为了标注该对象是可被序列化的,然后使用一个输出流(如 FileOutputStream)来构造一个 ObjectOutputStream(对象流)对象,接着,使用 ObjectOutputStream 对象的 writeObject(Object obj)方法就可以将参数为 obj 的对象写出(即保存其状态),要恢复的话则用输入流。
java 中有几种类型的流JDK 为每种类型的流提供了一些抽象类以供继承,请说出他们分别是哪些类
答:字节流,字符流。 字节流继承于 InputStream、OutputStream,字符流继承于 Reader、Writer。在 java.io 包中还有许多其他的流,主要是为了提高性能和使用方便。
文件和目录(IO)操作
- 1)如何列出某个目录下的所有文件?
- 2)如何列出某个目录下的所有子目录?
- 3)如何判断一个文件或目录是否存在?
-
4)如何读写文件?【基础】 答:1)示例代码如下:
File file = new File("e:\\总结"); File[] files = file.listFiles(); for(int i=0; i<files.length i="" if="" system.out.println=""></files.length>
2)示例代码如下:
File file = new File("e:\\总结"); File[] files = file.listFiles(); for(int i=0; i<files.length i="" if="" system.out.println=""></files.length>
3)创建 File 对象,调用其 exsit()方法即可返回是否存在,如:
System.out.println(new File("d:\\t.txt").exists());
4)示例代码如下:
//读文件: FileInputStream fin = new FileInputStream("e:\\tt.txt"); byte[] bs = new byte[100]; while(true){ int len = fin.read(bs); if(len <= 0) break; System.out.print(new String(bs,0,len)); } fin.close(); //写文件: FileWriter fw = new FileWriter("e:\\test.txt"); fw.write("hello world!" + System.getProperty("line.separator")); fw.write("你好!北京!"); fw.close();
写一个方法,输入一个文件名和一个字符串,统计这个字符串在这个文件中出现的次数
答:代码如下:
public int countWords(String file, String find) throws Exception {
int count = 0;
Reader in = new FileReader(file);
int c;
while ((c = in.read()) != -1) {
while (c == find.charAt(0)) {
for (int i = 1; i < find.length(); i++) {
c = in.read();
if (c != find.charAt(i)) break;
if (i == find.length() - 1) count++;
}
}
}
return count;
}
Java 的通信编程,编程题(或问答),用 JAVA SOCKET 编程,读服务器几个字符,再写入本地显示
答:Server 端程序:
package test;
import java.net.*;
import java.io.*;
public class Server{
private ServerSocket ss;
private Socket socket;
private BufferedReader in;
private PrintWriter out;
public Server(){
try {
ss=new ServerSocket(10000);
while(true){
socket = ss.accept();
String RemoteIP =
socket.getInetAddress().getHostAddress();
String RemotePort = ":"+socket.getLocalPort();
System.out.println("A client come in!IP:"
+ RemoteIP+RemotePort);
in = new BufferedReader(new
InputStreamReader(socket.getInputStream()));
String line = in.readLine();
System.out.println("Cleint send is :" + line);
out =
new PrintWriter(socket.getOutputStream(),true);
out.println("Your Message Received!");
out.close();
in.close();
socket.close();
}
}catch (IOException e){
out.println("wrong");
}
}
public static void main(String[] args){
new Server();
}
}
Client 端程序:
package test;
import java.io.*;
import java.net.*;
public class Client {
Socket socket;
BufferedReader in;
PrintWriter out;
public Client(){
try {
System.out.println("Try to Connect to 127.0.0.1:10000");
socket = new Socket("127.0.0.1",10000);
System.out.println("The Server Connected!");
System.out.println("Please enter some Character:");
BufferedReader line = new BufferedReader(new
InputStreamReader(System.in));
out = new PrintWriter(socket.getOutputStream(),true);
out.println(line.readLine());
in = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
System.out.println(in.readLine());
out.close();
in.close();
socket.close();
}catch(IOException e){
out.println("Wrong");
}
}
public static void main(String[] args) {
new Client();
}
}
UML 是什么?常用的几种图
答:UML 是标准建模语言;常用图包括:用例图,静态图(包括类图、对象图和包图),行为图,交互图(顺序图,合作图),实现图。
编程题写一个 Singleton出来
答:Singleton 模式主要作用是保证在 Java 应用程序中,一个类 Class 只有一个实例存在。 举例:定义一个类,它的构造函数为 private 的,它有一个 static的 private 的该类变量,在类初始化时实例话,通过一个 public 的 getInstance方法获取对它的引用,继而调用其中的方法。 第一种形式:
public class Singleton {
private Singleton(){}
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
}
第二种形式:
public class Singleton {
private static Singleton instance = null;
public static synchronized Singleton getInstance(){
if (instance==null)
instance=new Singleton();
return instance;
}
}
其他形式: 定义一个类,它的构造函数为 private 的,所有方法为 static 的。 一般认为第一种形式要更加安全些 。
说说你所熟悉或听说过的j2ee中的几种常用模式及对设计模式的一些看法
答:
- Session Facade Pattern:使用 SessionBean 访问 EntityBean;
- Message Facade Pattern:实现异步调用;
- EJB Command Pattern:使用 Command JavaBeans 取代 SessionBean,实现轻量级访问;
- Data Transfer Object Factory:通过 DTO Factory 简化 EntityBean 数据提供特性;
- Generic Attribute Access:通过 AttibuteAccess 接口简化 EntityBean 数据提供特性;
- Business Interface:通过远程(本地)接口和 Bean 类实现相同接口规范业务逻辑一致性; EJB 架构的设计好坏将直接影响系统的性能、可扩展性、可维护性、组件可重用性及开发效率。 项目越复杂,项目队伍越庞大则越能体现良好设计的重要性。
Java 中常用的设计模式 说明工厂模式
答:Java 中的 23 种设计模式:
- Factory(工厂模式),
- Builder(建造模式),
- Factory Method(工厂方法模式),
- Prototype(原始模型模式),
- Singleton(单例模式),
- Facade(门面模式),
- Adapter(适配器模式),
- Bridge(桥梁模式),
- Composite(合成模式),
- Decorator(装饰模式),
- Flyweight(享元模式),
- Proxy(代理模式),
- Command(命令模式),
- Interpreter(解释器模式),
- Visitor(访问者模式),
- Iterator(迭代子模式),
- Mediator(调停者模式),
- Memento(备忘录模式),
- Observer(观察者模式),
- State(状态模式),
- Strategy(策略模式),
- Template Method(模板方法模式),
- Chain Of Responsibleity(责任链模式)。 工厂模式:工厂模式是一种经常被使用到的模式,根据工厂模式实现的类可以根据提供的数据生成一组类中某一个类的实例,通常这一组类有一个公共的抽象父类并且实现了相同的方法,但是这些方法针对不同的数据进行了不同的操作。 首先需要定义一个基类,该类的子类通过不同的方法实现了基类中的方法。 然后需要定义一个工厂类,工厂类可以根据条件生成不同的子类实例。 当得到子类的实例后,开发人员可以调用基类中的方法而不必考虑到底返回的是哪一个子类的实例。
开发中都用到了那些设计模式 用在什么场合
答:每个模式都描述了一个在我们的环境中不断出现的问题,然后描述了该问题的解决方案的核心。 通过这种方式,你可以无数次地使用那些已有的解决方案,无需在重复相同的工作。 主要用到了 MVC 的设计模式,用来开发 JSP/Servlet或者 J2EE 的相关应用;及简单工厂模式等。
XML 文档定义有几种形式?它们之间有何本质区别?解析 XML 文档有哪几种方式
答:
- 1)两种形式:dtd 以及 schema;
- 2)本质区别:schema 本身是 xml 的,可以被 XML 解析器解析(这也是从 DTD上发展 schema 的根本目的);
- 3)解析方式:有 DOM,SAX,STAX 等:
- DOM:处理大型文件时其性能下降的非常厉害。这个问题是由 DOM 的树结构所造成的,这种结构占用的内存较多,而且 DOM 必须在解析文件之前把整个文档装入内存,适合对 XML 的随机访问;
- SAX:不同于 DOM,SAX 是事件驱动型的 XML 解析方式。它顺序读取 XML 文件,不需要一次全部装载整个文件。当遇到像文件开头,文档结束,或者标签开头与标签结束时,它会触发一个事件,用户通过在其回调事件中写入处理代码来处理 XML 文件,适合对 XML 的顺序访问;
- STAX:Streaming API for XML (StAX)。
你对软件开发中迭代的含义的理解
答:软件开发中,各个开发阶段不是顺序执行的,应该是并行执行,也就是迭代的意思。 这样对于开发中的需求变化,及人员变动都能得到更好的适应。
你在项目中用到了 xml 技术的哪些方面 如何实现的
答:用到了数据存贮,信息配置两方面。 在做数据交换平台时,将不能数据源的数据组装成 XML 文件,然后将 XML 文件压缩打包加密后通过网络传送给接收者,接收解密与解压缩后再同 XML 文件中还原相关信息进行处理。 在做软件配置时,利用 XML 可以很方便的进行,软件的各种配置参数都存贮在 XML 文件中。
用 jdom 解析 xml 文件时如何解决中文问题 如何解析
答:看如下代码,用编码方式加以解决
package test;
import java.io.*;
public class DOMTest{
private String inFile = "c:\people.xml";
private String outFile = "c:\people.xml";
public static void main(String args[]){
new DOMTest();
}
public DOMTest(){
try{
javax.xml.parsers.DocumentBuilder builder =
javax.xml.parsers.DocumentBuilderFactory.
newInstance().newDocumentBuilder();
org.w3c.dom.Document doc = builder.newDocument();
org.w3c.dom.Element root = doc.createElement("老师");
org.w3c.dom.Element wang = doc.createElement("王");
org.w3c.dom.Element liu = doc.createElement("刘");
wang.appendChild(doc.createTextNode("我是王老师"));
root.appendChild(wang);
doc.appendChild(root);
javax.xml.transform.Transformer transformer =
javax.xml.transform.TransformerFactory.
newInstance().newTransformer();
transformer.setOutputProperty(
javax.xml.transform.OutputKeys.ENCODING,"gb2312");
transformer.setOutputProperty(
javax.xml.transform.OutputKeys.INDENT, "yes");
transformer.transform(new
javax.xml.transform.dom.DOMSource(doc),
new javax.xml.transform.stream.StreamResult(outFile));
}catch (Exception e){
System.out.println (e.getMessage());
}
}
}
编程用 JAVA 解析 XML 的方式
答:用 SAX 方式解析 XML,XML 文件如下:
<person>
<name>王小明</name>
<college>信息学院</college>
<telephone>6258113</telephone>
<notes>男,1955 年生,博士,95 年调入海南大学</notes>
</person>
事件回调类 SAXHandler.java :
import java.io.*;
import java.util.Hashtable;
import org.xml.sax.*;
public class SAXHandler extends HandlerBase{
private Hashtable table = new Hashtable();
private String currentElement = null;
private String currentValue = null;
public void setTable(Hashtable table){
this.table = table;
}
public Hashtable getTable(){
return table;
}
public void startElement(String tag, AttributeList attrs)
throws SAXException{
currentElement = tag;
}
public void characters(char[] ch, int start, int length)
throws SAXException{
currentValue = new String(ch, start, length);
}
public void endElement(String name) throws SAXException{
if (currentElement.equals(name))
table.put(currentElement, currentValue);
}
}
JSP 内容显示源码,SaxXml.jsp:
<title>剖析 XML 文件 people.xml</title>
" +
"教师信息表");
out.println("姓名" + "" +
(String)hashTable.get(new String("name")) + "");
out.println("学院" + "" +
(String)hashTable.get(new String("college"))
+"");
out.println("电话" + "" +
(String)hashTable.get(new String("telephone"))
+ "");
out.println("备注" + "" +
(String)hashTable.get(new String("notes"))
+ "");
out.println("");
%>
有 3 个表(15 分钟)
- Student 学生表 (学号,姓名,性别,年龄,组织部门)
- Course 课程表 (编号,课程名称)
- Sc 选课表 (学号,课程编号,成绩) 表结构如下:
- 1)写一个 SQL 语句,查询选修了’计算机原理’的学生学号和姓名(3 分钟)
- 2)写一个 SQL 语句,查询’周星驰’同学选修了的课程名字(3 分钟)
-
3)写一个 SQL 语句,查询选修了 5 门课程的学生学号和姓名(9 分钟) 答:1)SQL 语句如下:
select stu.sno, stu.sname from Student stu where (select count(*) from sc where sno=stu.sno and cno = (select cno from Course where cname='计算机原理')) != 0;
2)SQL 语句如下:
select cname from Course where cno in ( select cno from sc where sno = (select sno from Student where sname='周星驰'));
3)SQL 语句如下:
select stu.sno, stu.sname from student stu where (select count(*) from sc where sno=stu.sno) = 5;
有三张表,学生表 S,课程 C,学生课程表 SC,学生可以选修多门课程,一门课程可以被多个学生选修,通过 SC 表关联
1)写出建表语句; 2)写出 SQL 语句,查询选修了所有选修课程的学生; 3)写出 SQL 语句,查询选修了至少 5 门以上的课程的学生。 答:1)建表语句如下(mysql 数据库):
create table s(id integer primary key, name varchar(20));
create table c(id integer primary key, name varchar(20));
create table sc(
sid integer references s(id),
cid integer references c(id),
primary key(sid,cid)
);
2)SQL 语句如下:
select stu.id, stu.name from s stu
where (select count(*) from sc where sid=stu.id)
= (select count(*) from c);
3)SQL 语句如下:
select stu.id, stu.name from s stu
where (select count(*) from sc where sid=stu.id)>=5;
列出所有年龄比所属主管年龄大的人的 ID 和名字
ID | NAME | AGE | MANAGER(所属主管人 ID) |
---|---|---|---|
106 | A | 30 | 104 |
109 | B | 19 | 104 |
104 | C | 20 | 111 |
107 | D | 35 | 109 |
112 | E | 25 | 120 |
119 | F | 45 | NULL |
要求:列出所有年龄比所属主管年龄大的人的 ID 和名字? 答:SQL 语句如下:
select employee.name from test employee where employee.age > (select manager.age from test manager where manager.id=employee.manager);
有如下两张表
表 city: 表 state:
CityNo | CityName | StateNo |
---|---|---|
BJ | 北京 | (Null) |
SH | 上海 | (Null) |
GZ | 广州 | GD |
DL | 大连 | LN |
State No | State Name |
---|---|
GD | 广东 |
LN | 辽宁 |
SD | 山东 |
NMG | 内蒙古 |
欲得到如下结果:
City No | City Name | State No | State Name |
---|---|---|---|
BJ | 北京 | (Null) | (Null) |
DL | 大连 | LN | 辽宁 |
GZ | 广州 | GD | 广东 |
SH | 上海 | (Null) | (Null) |
写相应的 SQL 语句。 答:SQL 语句为:
SELECT C.CITYNO, C.CITYNAME, C.STATENO, S.STATENAME
FROM CITY C, STATE S
WHERE C.STATENO=S.STATENO(+)
ORDER BY(C.CITYNO);
数据库,比如 100 用户同时来访,要采取什么技术解决
答:可采用连接池。
什么是 ORM
答:对象关系映射(Object—Relational Mapping,简称 ORM)是一种为了解决面向对象与面向关系数据库存在的互不匹配的现象的技术; 简单的说,ORM 是通过使用描述对象和数据库之间映射的元数据,将 java 程序中的对象自动持久化到关系数据库中;本质上就是将数据从一种形式转换到另外一种形式。
Hibernate 有哪 5 个核心接口
答:
- Configuration 接口:配置 Hibernate,根据其启动 hibernate,创建SessionFactory 对象;
- SessionFactory 接口:初始化 Hibernate,充当数据存储源的代理,创建session 对象,sessionFactory 是线程安全的,意味着它的同一个实例可以被应用的多个线程共享,是重量级、二级缓存;
- Session 接口:负责保存、更新、删除、加载和查询对象,是线程不安全的,避免多个线程共享同一个 session,是轻量级、一级缓存;
- Transaction 接口:管理事务;
- Query 和 Criteria 接口:执行数据库的查询。
关于 hibernate
1)在 hibernate 中,在配置文件呈标题一对多,多对多的标签是什么; 2)Hibernate 的二级缓存是什么; 3)Hibernate 是如何处理事务的; 答:
- 1)一对多的标签为\(<\)one-to-many\(>\) ;多对多的标签为\(<\)many-to-many\(>\);
- 2)sessionFactory 的缓存为 hibernate 的二级缓存;
- 3)Hibernate 的事务实际上是底层的 JDBC Transaction 的封装或者是 JTA Transaction 的封装;默认情况下使用 JDBCTransaction。
Hibernate 的应用(Hibernate 的结构)
答:
//首先获得 SessionFactory 的对象
SessionFactory sessionFactory = new Configuration().configure().
buildSessionFactory();
//然后获得 session 的对象
Session session = sessionFactory.openSession();
//其次获得 Transaction 的对象
Transaction tx = session.beginTransaction();
//执行相关的数据库操作:增,删,改,查
session.save(user); //增加, user 是 User 类的对象
session.delete(user); //删除
session.update(user); //更新
Query query = session.createQuery(“from User”); //查询
List list = query.list();
//提交事务
tx.commit();
//如果有异常,我们还要作事务的回滚,恢复到操作之前
tx.rollback();
//最后还要关闭 session,释放资源
session.close();
什么是重量级?什么是轻量级
答:轻量级是指它的创建和销毁不需要消耗太多的资源,意味着可以在程序中经常创建和销毁 session 的对象; 重量级意味不能随意的创建和销毁它的实例,会占用很多的资源。
数据库的连接字符串
答:MS SQL Server
//第二种连接方式
Class.forName(“com.microsoft.jdbc.sqlserver.SQLServerDriver”).
newInstance();
conn = DriverManager.getConnection(“jdbc:Microsoft:sqlserver
://localhost:1433;DatabaseName=pubs”,”sa”,””);
//Oracle
Class.forName(“oracle.jdbc.driver.OracleDriver”).newInstance();
conn = DriverManager.getConnection(“jdbc:oracle:thin:
@localhost:1521:sid”, uid, pwd);
//Mysql
Class.forName(“org.git.mm.mysql.Driver”).newInstance();
conn = DriverManager.getConnection(“jdbc:mysql
://localhost:3306/pubs”,”root”,””);
处理中文的问题:
jdbc:mysql://localhost:3306/pubs?useUnicode=true
&characterEncoding=GB2312
事务处理
答:Connection 类中提供了 3 个事务处理方法:
- setAutoCommit(Boolean autoCommit):设置是否自动提交事务,默认为自动提交事务,即为 true,通过设置 false 禁止自动提交事务;
- commit():提交事务;
- rollback():回滚事务。
Java中访问数据库的步骤?Statement和 PreparedStatement之间的区别
答:Java 中访问数据库的步骤如下:
- 1)注册驱动;
- 2)建立连接;
- 3)创建 Statement;
- 4)执行 sql 语句;
- 5)处理结果集(若 sql 语句为查询语句);
- 6)关闭连接。 PreparedStatement 被创建时即指定了 SQL 语句,通常用于执行多次结构相同的 SQL 语句。
用你熟悉的语言写一个连接 ORACLE 数据库的程序,能够完成修改和查询工作
答:JDBC 示例程序如下:
public void testJdbc(){
Connection con = null;
PreparedStatement ps = null;
ResultSet rs = null;
try{
//step1:注册驱动;
Class.forName("oracle.jdbc.driver.OracleDriver");
//step 2:获取数据库连接;
con=DriverManager.getConnection(
"jdbc:oracle:thin:@192.168.0.39:1521:TARENADB",
"sd0605","sd0605");
/************************查 询************************/
//step 3:创建 Statement;
String sql = "SELECT id, fname, lname, age, FROM Person_Tbl";
ps = con.prepareStatement(sql);
//step 4 :执行查询语句,获取结果集;
rs = ps.executeQuery();
//step 5:处理结果集—输出结果集中保存的查询结果;
while (rs.next()){
System.out.print("id = " + rs.getLong("id"));
System.out.print(" , fname = " + rs.getString("fname"));
System.out.print(" , lname = " + rs.getString("lname"));
System.out.print(" , age = " + rs.getInt("age"));
}
/************************JDBC 修 改*********************/
sql = "UPDATE Person_Tbl SET age=23 WHERE id = ?";
ps = con.prepareStatement(sql);
ps.setLong(1, 88);
int rows = ps.executeUpdate();
System.out.println(rows + " rows affected.");
} catch (Exception e){
e.printStackTrace();
} finally{
try{
con.close(); //关闭数据库连接,以释放资源。
} catch (Exception e1) {
}
}
}
JDBC,Hibernate 分页怎样实现
答:方法分别为:
-
1) Hibernate 的分页:
Query query = session.createQuery("from Student"); query.setFirstResult(firstResult);//设置每页开始的记录号 query.setMaxResults(resultNumber);//设置每页显示的记录数 Collection students = query.list();
-
2) JDBC 的分页:根据不同的数据库采用不同的 sql 分页语句 例如: Oracle 中的 sql 语句为:
"SELECT * FROM (SELECT a.*, rownum r FROM TB_STUDENT) WHERE r between 2 and 10"
查询从记录号 2 到记录号 10 之间的所有记录
javascript 的优缺点和内置对象
答:
- 1)优点:简单易用,与 Java 有类似的语法,可以使用任何文本编辑工具编写,只需要浏览器就可执行程序,并且事先不用编译,逐行执行,无需进行严格的变量声明,而且内置大量现成对象,编写少量程序可以完成目标;
- 2)缺点:不适合开发大型应用程序;
- 3)Javascript 有 11 种内置对象: Array、String、Date、Math、Boolean、Number、Function、Global、Error、RegExp、Object。
EJB 与 JAVA BEAN 的区别
答:Java Bean 是可复用的组件,对 Java Bean 并没有严格的规范,理论上讲,任何一个 Java 类都可以是一个 Bean。 但通常情况下,由于 Java Bean 是被容器所创建(如 Tomcat)的,所以 Java Bean 应具有一个无参的构造器,另外,通常 Java Bean 还要实现 Serializable 接口用于实现 Bean 的持久性。Java Bean实际上相当于微软 COM模型中的本地进程内 COM组件,它是不能被跨进程访问的。 Enterprise Java Bean 相当于 DCOM,即分布式组件。它是基于 Java 的远程方法调用(RMI)技术的,所以 EJB 可以被远程访问(跨进程、跨计算机)。但 EJB必须被布署在诸如 Webspere、WebLogic 这样的容器中,EJB 客户从不直接访问真正的 EJB 组件,而是通过其容器访问。EJB 容器是 EJB 组件的代理,EJB 组件由容器所创建和管理。客户通过容器来访问真正的 EJB 组件。
EJB 的几种类型
答:会话(Session)Bean、实体(Entity)Bean、消息驱动的(Message Driven)Bean; 会话 Bean 又可分为有状态(Stateful)和无状态(Stateless)两种; 实体 Bean 可分为 Bean 管理的持续性(BMP)和容器管理的持续性(CMP)两种。
remote 接口和 home 接口主要作用
答: remote 接口定义了业务方法,用于 EJB 客户端调用业务方法; home 接口是 EJB 工厂用于创建和移除查找 EJB 实例。
客服端口调用 EJB 对象的几个基本步骤
答:设置 JNDI 服务工厂以及 JNDI 服务地址系统属性,查找 Home 接口,从 Home接口调用 Create 方法创建 Remote 接口,通过 Remote 接口调用其业务方法。
EJB 的角色和三个对象
答:一个完整的基于 EJB 的分布式计算结构由六个角色组成,这六个角色可以由不同的开发商提供,每个角色所作的工作必须遵循 Sun 公司提供的 EJB 规范,以保证彼此之间的兼容性。 这六个角色分别是
- EJB 组件开发者(Enterprise Bean Provider) 、
- 应用组合者(Application Assembler)、
- 部署者(Deployer)、
- EJB 服务器提供者(EJB Server Provider)、
- EJB 容器提供者(EJB Container Provider)、
- 系统管理员(System Administrator), 这里面,EJB 容器是 EJB之所以能够运行的核心。EJB 容器管理着 EJB 的创建,撤消,激活,去活,与数据库的连接等等重要的核心工作;三个对象是 Remote(Local)接口、Home(LocalHome)接口,Bean 类。
EJB 是基于哪些技术实现的?并说出 SessionBean 和 EntityBean 的区别,StatefulBean 和 StatelessBean 的区别
答:EJB 包括 Session Bean、Entity Bean、Message Driven Bean,基于 JNDI、RMI、JTA 等技术实现。 SessionBean 在 J2EE 应用程序中被用来完成一些服务器端的业务操作,例如访问数据库、调用其他 EJB 组件。EntityBean 被用来代表应用系统中用到的数据。 对于客户机,SessionBean 是一种非持久性对象,它实现某些在服务器上运行的业务逻辑。 对于客户机,EntityBean 是一种持久性对象,它代表一个存储在持久性存储器中的实体的对象视图,或是一个由现有企业应用程序实现的实体。 Session Bean 还可以再细分为 Stateful Session Bean 与 Stateless Session Bean ,这两种的 Session Bean 都可以将系统逻辑放在 method 之中执行,不同的是 Stateful Session Bean 可以记录呼叫者的状态,因此通常来说,一个使用者会有一个相对应的 Stateful Session Bean 的实体。 Stateless Session Bean 虽然也是逻辑组件,但是他却不负责记录使用者状态,也就是说当使用者呼叫 Stateless Session Bean 的时候,EJB Container 并不会找寻特定的 Stateless Session Bean 的实体来执行这个 method。换言之,很可能数个使用者在执行某个 Stateless Session Bean 的 methods 时,会是同一个 Bean 的 Instance 在执行。从内存方面来看, Stateful Session Bean 与 Stateless Session Bean 比较, Stateful Session Bean 会消耗 J2EE Server 较多的内存,然而 Stateful Session Bean 的优势却在于他可以维持使用者的状态。
bean 实例的生命周期
答:对于 Stateless Session Bean、Entity Bean、Message Driven Bean 一般存在缓冲池管理,而对于 Entity Bean 和 Statefull Session Bean 存在 Cache管理,通常包含创建实例,设置上下文、创建 EJB Object(create)、业务方法调用、remove 等过程,对于存在缓冲池管理的 Bean,在 create 之后实例并不 从内存清除,而是采用缓冲池调度机制不断重用实例,而对于存在 Cache 管理的Bean 则通过激活和去激活机制保持 Bean 的状态并限制内存中实例数量。
EJB 的激活机制
答:以 Stateful Session Bean 为例:其 Cache 大小决定了内存中可以同时存在的 Bean 实例的数量,根据 MRU 或 NRU 算法,实例在激活和去激活状态之间迁移,激活机制是当客户端调用某个 EJB 实例业务方法时,如果对应 EJB Object发现自己没有绑定对应的 Bean 实例则从其去激活 Bean 存储中(通过序列化机制存储实例)回复(激活)此实例。状态变迁前会调用对应的 ejbActive 和ejbPassivate 方法。
EJB 包括(SessionBean,EntityBean)说出他们的生命周期,及如何管理事务的
答:SessionBean:Stateless Session Bean 的生命周期是由容器决定的,当客户机发出请求要建立一个 Bean 的实例时,EJB 容器不一定要创建一个新的 Bean的实例供客户机调用,而是随便找一个现有的实例提供给客户机。当客户机第一次调用一个 Stateful Session Bean 时,容器必须立即在服务器中创建一个新的 Bean 实例,并关联到客户机上,以后此客户机调用 Stateful Session Bean 的方法时容器会把调用分派到与此客户机相关联的 Bean 实例。 EntityBean:Entity Beans 能存活相对较长的时间,并且状态是持续的。只要数据库中的数据存在,Entity beans 就一直存活。而不是按照应用程序或者服务进程来说的。即使 EJB容器崩溃了,Entity beans 也是存活的。Entity Beans 生命周期能够被容器或者 Beans 自己管理。EJB 通过以下技术管理事务:对象管理组织(OMG)的对象实务服务(OTS),Sun Microsystems 的 Transaction Service(JTS)、Java Transaction API(JTA),开发组(X/Open)的 XA 接口。
EJB 的事务是如何实现的?何时进行回滚
答:是通过使用容器或 Bean 自身管理事务的; 当产生一个系统异常时容器就自动回滚事务。
EJB 容器提供的服务
答:主要提供生命周期管理、代码产生、持续性管理、安全、事务管理、锁和并发行管理等服务。
EJB 需直接实现它的业务接口或 Home 接口吗?请简述理由
答:远程接口和 Home 接口不需要直接实现,他们的实现代码是由服务器产生的,程序运行中对应实现类会作为对应接口类型的实例被使用。
请对以下在 J2EE 中常用的名词进行解释(或简单描述)
答:
web 容器:给处于其中的应用程序组件(JSP,SERVLET)提供一个环境,使JSP,SERVLET 直接跟容器中的环境变量接口交互,不必关注其它系统问题。
主要由 WEB 服务器来实现。 例如:TOMCAT,WEBLOGIC,WEBSPHERE 等。 该容器提供的接口严格遵守 J2EE 规范中的 WEB APPLICATION 标准。 我们把遵守以上标准的 WEB服务器就叫做 J2EE 中的 WEB 容器;
EJB 容器:Enterprise java bean 容器。
更具有行业领域特色。 他提供给运行在其中的组件 EJB 各种管理功能。 只要满足 J2EE 规范的 EJB 放入该容器,马上就会被容器进行高效率的管理。 并且可以通过现成的接口来获得系统级别的服务。 例如邮件服务、事务管理;
JNDI:(Java Naming & Directory Interface)JAVA 命名目录服务。
主要提供的功能是:提供一个目录系统,让其它各地的应用程序在其上面留下自己的索引,从而满足快速查找和定位分布式应用程序的功能;
JMS:(Java Message Service)JAVA 消息服务。
主要实现各个应用程序之间的通讯。包括点对点和广播;
JTA:(Java Transaction API)JAVA 事务服务。
提供各种分布式事务服务。 应用程序只需调用其提供的接口即可;
JAF:(Java Action FrameWork)JAVA 安全认证框架。
提供一些安全控制方面的框架。让开发者通过各种部署和自定义实现自己的个性安全控制策略;
RMI/IIOP:(Remote Method Invocation /internet 对象请求中介协议)他们主要用于通过远程调用服务。
例如,远程有一台计算机上运行一个程序,它提供股票分析服务,我们可以在本地计算机上实现对其直接调用。当然这是要通过一定的规范才能在异构的系统之间进行通信。RMI 是 JAVA 特有的。
J2EE 是什么
答:J2EE 是 Sun 公司提出的多层(multi-diered),分布式(distributed),基于组件(component-base)的企业级应用模型(enterpriese application model).在这样的一个应用系统中,可按照功能划分为不同的组件,这些组件又可在不同计算机上,并且处于相应的层次(tier)中。 所属层次包括
- 客户层(clietn tier)组件,
- web 层和组件,
- Business 层和组件,
- 企业信息系统(EIS)层。
J2EE 是技术还是平台还是框架
答:J2EE 本身是一个标准,一个为企业分布式应用的开发提供的标准平台; J2EE 也是一个框架,包括 JDBC、JNDI、RMI、JMS、EJB、JTA 等技术。
请写出 spring 中 I0C 的三种实现机制
答:三种机制为:通过 setter 方法注入、通过构造方法注入和接口注入。
写出你熟悉的开源框架以及各自的作用
答:框架:
- hibernate、spring、struts;
- Hibernate 主要用于数据持久化;
- Spring 的控制反转能起到解耦合的作用;
- Struts 主要用于流程控制。
EJB 规范规定 EJB 中禁止的操作有哪些
答:
- 1)不能操作线程和线程 API(线程 API 指非线程对象的方法,如 notify,wait等);
- 2)不能操作 awt;
- 3)不能实现服务器功能;
- 4)不能对静态属性存取;
- 5)不能使用 IO 操作直接存取文件系统;
- 6)不能加载本地库;
- 7)不能将 this 作为变量和返回;
- 8)不能循环调用。
一个 byte 几个单位
答:8bit。
常用 UNIX 命令(Linux 的常用命令)(至少 10 个)
答:ls pwd mkdir rm cp mv cd ps ftp telnet ping env more echo
后序遍历下列二叉树,访问结点的顺序是
A / \ B C / \ \ D E F / / \ G N I / \ J K 答:顺序为:DJGEBKNIFCA 。
排序都有哪几种方法?请列举。用 JAVA 实现一个快速排序
答:排序的方法有:插入排序(直接插入排序、希尔排序),交换排序(冒泡排序、快速排序),选择排序(直接选择排序、堆排序),归并排序,分配排序(箱排序、基数排序); 快速排序的伪代码:
//使用快速排序方法对 a[ 0 :n- 1 ]排序
从 a[ 0 :n- 1 ]中选择一个元素作为 middle,该元素为支点;
把余下的元素分割为两段 left 和 right,使得 left 中的元素都小于等于支点,
而 right 中的元素都大于等于支点;
递归地使用快速排序方法对 left 进行排序;
递归地使用快速排序方法对 right 进行排序;
所得结果为 left + middle + right。
写一种常见排序
答:C++中冒泡排序:
void swap( int& a, int& b ){
int c=a; a = b; b = c;
}
void bubble( int* p, int len ){
bool bSwapped;
do {
bSwapped = false;
for( int i=1; i<len; i++ ){
if( p[i-1]>p[i] ){
swap( p[i-1], p[i] );
bSwapped = true;
}
}
}while( bSwapped );
}
写一个一小段程序检查数字是否为质数;以上的程序你采用的哪种语言写的?采用该种语言的理由是什么
答:代码如下:
#include <math.h>bool prime( int n ){
if(n<=0) exit(0);
for( int i=2; i<=n; i++ )
for( int j=2; j<=sqrt(i); j++)
if((n%j==0) && (j!=n))
return false;
return true;
}</math.h>
采用 C++,因为其运行效率高。
编程题:设有n个人依围成一圈,从第1个人开始报数,数到第m个人出列,然后从出列的下一个人开始报数,数到第m个人又出列,⋯,如此反复到所有的人全部出列为止。设n个人的编号分别为 1,2,⋯,n,打印出出列的顺序;要求用 java 实现
答:代码如下:
package test;
public class CountGame {
private static boolean same(int[] p,int l,int n){
for(int i=0;i <l if="" return="" true="" false="" public="" static="" void="" play="" playernum="" int="" step="" p="new" counter="1;" while="">playerNum*step){
break;
}
for(int i=1;i <playernum while="" if="" break="" else="" i="i+1;">playerNum)break;
if(counter%step==0){
System.out.print(i + " ");
p[counter/step-1]=i;
}
counter+=1;
}
}
System.out.println();
}
public static void main(String[] args) {
play(10, 7);
}
}</playernum></l>
写一个方法 1000 的阶乘
答:C++的代码实现如下:
#include <iostream>
#include <iomanip>
#include <vector>
using namespace std;
class longint {
private:
vector <int>iv;
public:
longint(void) { iv.push_back(1); }
longint& multiply(const int &);
friend ostream& operator<::const_reverse_iterator iv_iter = v.iv.rbegin();
os << *iv_iter++;
for ( ; iv_iter < v.iv.rend(); ++iv_iter) {
os << setfill('0') << setw(4) << *iv_iter;
}
return os;
}
longint& longint::multiply(const int &rv) {
vector<int>::iterator iv_iter = iv.begin();
int overflow = 0, product = 0;
for ( ; iv_iter < iv.end(); ++iv_iter) {
product = (*iv_iter) * rv;
product += overflow;
overflow = 0;
if (product > 10000) {
overflow = product / 10000;
product -= overflow * 10000;
}
iv_iter = product;
}
if (0 != overflow) {
iv.push_back(overflow);
}
return *this;
}
int main(int argc, char **argv) {
longint result;
int l = 0;
if(argc==1){
cout << "like: multiply 1000" << endl;
exit(0);
}
sscanf(argv[1], "%d", &l);
for (int i = 2; i <= l; ++i) {
result.multiply(i);
}
cout << result << endl;
return 0;
}</int></int></vector></iomanip></iostream>
以下三条输出语句分别输出什么
char str1[] = "abc";
char str2[] = "abc";
const char str3[] = "abc";
const char str4[] = "abc";
const char* str5 = "abc";
const char* str6 = "abc";
cout << boolalpha << (str1==str2) << endl; //输出什么?
cout << boolalpha << (str3==str4) << endl; //输出什么?
cout << boolalpha << (str5==str6) << endl; //输出什么?
答:输出为:false、false、true。
以下反向遍历 array 数组的方法有什么错误
vector<int> array;
array.push_back(1);
array.push_back(2);
array.push_back(3);
//反向遍历 array 数组:
for(vector<int>::size_type i=array.size()-1; i>=0; --i){
cout << array[i] << endl;
}
答:for 循环中的变量 i 的类型不应定义为 vector\(<\)int\(>\)::size_type, 因为该类型为无符号数值类型,故循环条件将恒成立,为死循环,应将其类型定义为有符号的 int 类型。
以下代码有什么问题
cout << (true ? 1 : "1") << endl;
答:运算符中两个可选值的类型不同。
以下代码有什么问题
typedef vector<int> IntArray;
IntArray array;
array.push_back(1);
array.push_back(2);
array.push_back(2);
array.push_back(3);
//删除 array 数组中所有的 2
for(IntArray::iterator itor=array.begin(); itor!=array.end();
++itor){
if(2==*itor) {
array.erase(itor);
}
}
答:for 循环中的 if 语句后的 array.erase(itor)语句,它将迭代器 itor 所指向的元素删除后会自动下移一位,故应在其后加上语句:itor–;
以下代码中的两个 sizeof 用法有问题吗
void upperCase(char str[]){ //将 str 中的小写字母转换成大写字母
for(int i=0; i<sizeof(str)/sizeof(str[0]); ++i){
if('a'<=str[i] && str[i]<='z')
str[i] -= ('a'-'A');
}
}
int main(){
char str[] = "aBcDe";
cout << "str 字符串长度为:" << sizeof(str)/sizeof(str[0]);
cout << endl;
upperCase(str);
cout << str << endl;
return 0;
}
答:在 upperCase 方法中,for 循环的 sizeof(str)的值将总是 4,所以该方法只能将参数中的字符串的前四个字符转换成大写字母。
以下代码能够编译通过吗?为什么
unsigned int const size1 = 2;
char str1[size1];
unsigned int temp = 0;
cin >> temp;
unsigned int const size2 = temp;
char str2[size2];
答:能;
以下代码有什么问题
struct Test{
Test(int){}
Test(){}
void fun(){}
};
void main(void){
Test a(1);
a.fun();
Test b();
b.fun();
}
答:main 函数的返回类型应为 int;不能对 b 调用 fun()方法。
以下代码中的输出语句输出 0 吗?为什么
struct CLS{
int m_i;
CLS(int i):m_i(i){ }
CLS(){ CLS(0);}
};
int main(){
CLS obj;
cout <
答:输出不是 0;
C++中的空类,默认产生哪些类成员函数
答:空类中默认包含的成员函数如下:
class Empty{
public:
Empty(); //缺省构造函数
Empty( const Empty& ); //拷贝构造函数
~Empty(); //析构函数
Empty& operator=( const Empty& ); //赋值运算符
Empty* operator&(); //取址运算符
const Empty* operator&() const; //取址运算符 const
};
统计一篇文章中单词个数
答:代码如下:
include <iostream>#include <fstream>using namespace std;
int main(){
ifstream fin("t.txt");
if(!fin){
cout<>buf;
if(fin2.eof())
break;
count++;
}
cout<</fstream></iostream>
写一个函数,完成内存之间的拷贝
答:代码如下:
void* mymemcpy(void* dest, const void* src, size_t count){
char* pdest = static_cast<char>(dest);
const char* psrc = static_cast<const char="">(src);
if(pdest>psrc && pdest</const></char>
非 C++内建类型 A 和 B,在哪几种情况下 B 能隐式转化为 A
答:
- a)class B : public A{……}//B 公有继承自 A,可以是间接继承的
- b)class B{operator A();}//B 实现了隐式转化为 A 的转化
- c)class A{ A(const B&);}//A 实现了 non-explicit 的参数为 B 构造函数 (可以有其他带带默认值的参数)
- d)A& operator= (const A&);//赋值操作,虽不是正宗的隐式类型转换, 但也可以勉强算一个
以下两条输出语句分别输出什么
float a = 1.0f;
cout << (int)a << endl;
cout << (int&)a << endl;
cout << boolalpha << ((int)a==(int&)a) << endl; //输出什么
float b = 0.0f;
cout << (int)b << endl;
cout << (int&)b << endl;
cout << boolalpha << ((int)b==(int&)b) << endl;//输出什么
答:第一处输出 false,第二处输出 true。
程序的机器级表示
汇编代码是计算机的一种低级表示,它是一种低级语言,可以从字面角度去理解它,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信等。编译器生成机器码经过了一系列的转换,这些转换遵循编程语言
、目标机器的指令集
和操作系统
。
指令集
指令集就是指挥计算机工作的指令,因为程序就是按照一定执行顺序排列的指令。因为计算机的执行控制权由 CPU 操作,所以指令集就是 CPU 中用来计算和控制计算机的一系列指令的集合。每个 CPU 在产出时都规定了与硬件电路相互配合工作的指令集。
指令集有不少分类,但是一般分为两种,一种是精简指令集
,一种是复杂指令集
。具体描述如下
精简指令集
精简指令的英文是 reduced instruction set computer, RISC
,原意是精简指令集计算,简称为精简指令集,是 CPU 的一种 设计模式
,可以把 CPU 想象成一家流水线工厂,对指令数目
和寻址方式
都做了精简,使其实现更容易,指令并行执行程度更好,编译器的效率更高。
常见的精简指令集处理器包括 ARM、AVR、MIPS、PARISC、RISC-V 和 SPARC。
所以你就能理解
这本书是讲啥的了。
它主要是基于 MIPS 体系结构把冯诺依曼体系的五大组件进行了逐一的硬件实现 + 软件设计介绍,更为重要的是引入了诸多并行计算的内容,这是大部分教材中忽略或者内容较少的,会根据这个思路把并行相关的内容,结合 OpenMP, CUDA 和 Hadoop/Spark 整体融入到新书中,毕竟这是未来发展的趋势
还有这本书
这本书又是讲啥的。
这本书是讲 RISC-V 指令集的,因为指令集的不同也区分了三个版本,三个版本???嗯,还有下面这个
这本书是讲 ARM 指令集的。
所以一般在看 CASPP 的时候并发的看看这本书是非常不错的选择。
精简指令集一般具有如下特征
- 统一的指令编码
- 通用的寄存器,一般会区分整数和浮点数
- 简单的寻址模式,复杂寻址模式被简单指令序列来取代
- 支持很少偏门的类型,例如 RISC 支持字节字符串类型。
复杂指令集
复杂指令集的英文是 Complex Instruction Set Computing, CISC
,是一种微处理器指令集架构,也被译为复杂指令集。
复杂指令集包括 System/360、VAX、x86 等。
复杂指令集可以说是在精简指令集之上作出的改变。
复杂指令集的特点是指令数目多而复杂,每条指令字长并不相等,计算机必须加以判读,并为此付出了性能的代价。
一般来说,提升 CPU 性能的方法有如下这几种
- 增加寄存器的大小
- 增进内部的并行性
- 增加高速缓存的大小
- 增加核心时脉的速度
- 加入其他功能,例如 IO 和计时器
- 加入向量处理器
- 硬件多线程技术
比较抽象,我们后面会组织成文章具体介绍一下。
C 编译器会接收其他操作并把其转换为汇编语言
输出,汇编语言是机器级别的代码表示。我们之前介绍过,C 语言程序的执行过程分为下面这几步
下面我们更多的讨论都是基于汇编代码来讨论。
我们日常所接触的高级语言,都是经过了层层封装的结果,所以我们平常是接触不到汇编语言的,更不会用汇编语言来进行编程,这就和你不知道操作系统的存在一样,但其实你每个操作,甚至你双击一个图标都和操作系统有关系。
高级语言的抽象级别很高,但是经过了层层抽象之后,高级语言的执行效率肯定没有汇编语言高,也没有汇编语言可靠。
但是高级语言有更大的优点是其编译后能够在不同的机器上运行,汇编语言针对不同的指令集有不同的表示。并且高级语言学习来更加通俗易懂,降低计算机门槛,让内卷更加严重(当然这是开个玩笑,冒犯到请别当真)。
话不多说,了解底层必须了解汇编语言。否则一个 synchronized 底层实现就能够让你头疼不已。而且,天天飘着也不好,迟早要落地。
了解汇编代码也有助于我们优化程序代码,分析代码中隐含的低效率,并且这种优化方法一旦优化成功,将是量级的提高,而不是改改 if…else ,使用一个新特性所能比的。
机器级代码
计算机系统使用了多种不同形式的抽象,可以通过一个简单的抽象模型来隐藏实现细节。对于机器级别的程序来说,有两点非常重要。
首先第一点,定义机器级别程序的格式和行为被称为 指令集体系结构或指令集架构(instruction set architecture)
, ISA。ISA 定义了进程状态、指令的格式和每一个指令对状态的影响。大部分的指令集架构包括 ISA 用来描述进程的行为就好像是顺序执行的,一条指令执行结束后,另外一条指令再开始。处理器硬件的描述要更复杂,它可以同时并行执行许多指令,但是它采用了安全措施
来确保整体行为与 ISA 规定的顺序一致。
第二点,机器级别对内存地址的描述就是 虚拟地址(virtual address)
,它提供了一个内存模型来表示一个巨大的字节数组。
编译器在整个编译的过程中起到了至关重要的作用,把 C 语言转换为处理器执行的基本指令。汇编代码非常接近于机器代码,只不过与二进制机器代码相比,汇编代码的可读性更强,所以理解汇编是理解机器工作的第一步。
一些进程状态对机器可见,但是 C 语言程序员却看不到这些,包括
程序计数器(Program counter)
,它存储下一条指令的地址,在 x86-64 架构中用%rip
来表示。
程序执行时,PC 的初始值为程序第一条指令的地址,在顺序执行程序时, CPU 首先按程序计数器所指出的指令地址从内存中取出一条指令,然后分析和执行该指令,同时将 PC 的值加 1 并指向下一条要执行的指令。
比如下面一个例子。
这是一段数值进行相加的操作,程序启动,在经过编译解析后会由操作系统把硬盘中的程序复制到内存中,示例中的程序是将 123 和 456 执行相加操作,并将结果输出到显示器上。由于使用机器语言难以描述,所以这是经过翻译后的结果,实际上每个指令和数据都可能分布在不同的地址上,但为了方便说明,把组成一条指令的内存和数据放在了一个内存地址上。
- 整数
寄存器文件(register file)
包含 16 个命名的位置,用来存储 64 位的值。这些寄存器可以存储地址和整型数据。有些寄存器用于跟踪程序状态,而另一些寄存器用于保存临时数据,例如过程的参数和局部变量,以及函数要返回的值。这个文件
是和磁盘文件无关的,它只是 CPU 内部的一块高速存储单元。有专用的寄存器,也有通用的寄存器用来存储操作数。 条件码寄存器
用来保存有关最近执行的算术或逻辑指令的状态信息。这些用于实现控件或数据流中的条件更改,例如实现 if 和 while 语句所需的条件更改。我们都学过高级语言,高级语言中的条件控制流程主要分为三种:顺序执行、条件分支、循环判断
三种,顺序执行是按照地址的内容顺序的执行指令。条件分支是根据条件执行任意地址的指令。循环是重复执行同一地址的指令。- 顺序执行的情况比较简单,每执行一条指令程序计数器的值就是 + 1。
- 条件和循环分支会使程序计数器的值指向任意的地址,这样一来,程序便可以返回到上一个地址来重复执行同一个指令,或者跳转到任意指令。
下面以条件分支为例来说明程序的执行过程(循环也很相似)
程序的开始过程和顺序流程是一样的,CPU 从 0100 处开始执行命令,在 0100 和 0101 都是顺序执行,PC 的值顺序+1,执行到 0102 地址的指令时,判断 0106 寄存器的数值大于 0,跳转(jump)到 0104 地址的指令,将数值输出到显示器中,然后结束程序,0103 的指令被跳过了,这就和我们程序中的 if()
判断是一样的,在不满足条件的情况下,指令会直接跳过。所以 PC 的执行过程也就没有直接+1,而是下一条指令的地址。
- 一组
向量寄存器
用来存储一个或者多个整数或者浮点数值,向量寄存器是对一维数据上进行操作。
机器指令只会执行非常简单的操作,例如将存放在寄存器的两个数进行相加,把数据从内存转移到寄存器中或者是条件分支转移到新的指令地址。编译器必须生成此类指令的序列,以实现程序构造,例如算术表达式求值,循环或过程调用和返回
认识汇编
我相信各位应该都知道汇编语言的出现背景吧,那就是二进制表示数据,太复杂太庞大了,为了解决这个问题,出现了汇编语言,汇编语言和机器指令的区别就在于表示方法上,汇编使用操作数
来表示,机器指令使用二进制来表示,我之前多次提到机器码就是汇编,你也不能说我错,但是不准确。
但是汇编适合二进制代码存在转换关系的。
汇编代码需要经过 汇编器
编译后才产生二进制代码,这个二进制代码就是目标代码,然后由链接器将其连接起来运行。
汇编语言主要分为以下三类
- 汇编指令:它是一种机器码的
助记符
,它有对应的机器码 - 伪指令:没有对应的机器码,由编译器执行,计算机并不执行
- 其他符号,比如 +、-、*、/ 等,由编译器识别,没有对应的机器码
汇编语言的核心是汇编指令,而我们对汇编的探讨也是基于汇编指令展开的。
与汇编有关的硬件和概念
CPU
CPU 是计算机的大脑,它也是整个计算机的核心,它也是执行汇编语言的硬件,CPU 的内部包含有寄存器,而寄存器是用于存储指令和数据的,汇编语言的本质也就是 CPU 内部操作数所执行的一系列计算。
内存
没有内存,计算机就像是一个没有记忆的人类,只会永无休止的重复性劳动。CPU 所需的指令和数据都由内存来提供,CPU 指令经由内存提供,经过一系列计算后再输出到内存。
磁盘
磁盘也是一种存储设备,它和内存的最大区别在于永久存储,程序需要在内存装载后才能运行,而提供给内存的程序都是由磁盘存储的。
总线
一般来说,内存内部会划分多个存储单元,存储单元用来存储指令和数据,就像是房子一样,存储单元就是房子的门牌号。而 CPU 与内存之间的交互是通过地址总线
来进行的,总线从逻辑上分为三种
- 地址线
- 数据线
- 控制线
CPU 与存储器之间的读写主要经过以下几步
读操作步骤
- CPU 通过地址线发出需要读取指令的位置
- CPU 通过控制线发出读指令
- 内存把数据放在数据线上返回给 CPU
写操作步骤
- CPU 通过地址线发出需要写出指令的位置
- CPU 通过控制线发出写指令
- CPU 把数据通过数据线写入内存
下面我们就来具体了解一下这三类总线
地址总线
通过我们上面的探讨,我们知道 CPU 通过地址总线
来指定存储位置的,地址总线上能传送多少不同的信息,CPU 就可以对多少个存储单元进行寻址。
上图中 CPU 和内存中间信息交换通过了 10 条地址总线,每一条线能够传递的数据都是 0 或 1 ,所以上图一次 CPU 和内存传递的数据是 2 的十次方。
所以,如果 CPU 有 N 条地址总线,那么可以说这个地址总线的宽度是 N 。这样 CPU 可以寻找 2 的 N 次方个内存单元。
数据总线
CPU 与内存或其他部件之间的数据传送是由数据总线
来完成的。数据总线的宽度决定了 CPU 和外界的数据传输速度。8 根数据总线可以一次传送一个 8 位二进制数据(即一个字节)。16 根数据总线一次可以传输两个字节,32 根数据总线可以一次传输四个字节。。。。。。
控制总线
CPU 与其他部件之间的控制是通过 控制总线
来完成的。有多少根控制总线,就意味着 CPU 提供了对外部器件的多少种控制。所以,控制总线的宽度决定了 CPU 对外部部件的控制能力。
一次内存的读取过程
**内存结构
内存 IC 是一个完整的结构,它内部也有电源、地址信号、数据信号、控制信号和用于寻址的 IC 引脚来进行数据的读写。下面是一个虚拟的 IC 引脚示意图
图中 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。
如果我们使用的是 512 MB 的内存,这就相当于是 512000(512 * 1000) 个内存 IC。当然,一台计算机不太可能有这么多个内存 IC ,然而,通常情况下,一个内存 IC 会有更多的引脚,也就能存储更多数据。
**内存读取过程
下面是一次内存的读取过程。
来详细描述一下这个过程,假设我们要向内存 IC 中写入 1byte 的数据的话,它的过程是这样的:
- 首先给 VCC 接通 +5V 的电源,给 GND 接通 0V 的电源,使用
A0 - A9
来指定数据的存储场所,然后再把数据的值输入给D0 - D7
的数据信号,并把WR(write)
的值置为 1,执行完这些操作后,即可以向内存 IC 写入数据 - 读出数据时,只需要通过 A0 – A9 的地址信号指定数据的存储场所,然后再将 RD 的值置为 1 即可。
- 图中的 RD 和 WR 又被称为控制信号。其中当WR 和 RD 都为 0 时,无法进行写入和读取操作。
总结
此篇文章我们主要探讨了指令集、指令集的分类,与汇编有关的硬件,总线都有哪些,分别的作用都是什么,然后我们以一次内存读取过程来连接一下 CPU 和内存的交互过程。
寄存器
下面我们就来介绍一下关于寄存器的相关内容。我们知道,寄存器
是 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 有一组关于栈的寄存器 SS
和 SP
。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 只会告诉你栈顶在哪,并不会知道栈空间有多大,所以需要程序员自己手动去保证。。。
程序员需要了解的硬核知识之CPU
大家都是程序员,大家都是和计算机打交道的程序员,大家都是和计算机中软件硬件打交道的程序员,大家都是和CPU
打交道的程序员,所以,不管你是玩儿硬件的还是做软件的,你的世界都少不了计算机最核心的 – CPU
CPU是什么
CPU 的全称是 Central Processing Unit
,它是你的电脑中最硬核
的组件,这种说法一点不为过。CPU 是能够让你的计算机叫计算机
的核心组件,但是它却不能代表你的电脑,CPU 与计算机的关系就相当于大脑和人的关系。它是一种小型的计算机芯片,它嵌入在台式机、笔记本电脑或者平板电脑的主板上。通过在单个计算机芯片上放置数十亿个微型晶体管来构建 CPU。 这些晶体管使它能够执行运行存储在系统内存中的程序所需的计算,也就是说 CPU 决定了你电脑的计算能力。
CPU 实际做什么
CPU 的核心是从程序或应用程序获取指令并执行计算。此过程可以分为三个关键阶段:提取,解码和执行。CPU从系统的 RAM 中提取指令,然后解码该指令的实际内容,然后再由 CPU 的相关部分执行该指令。
RAM : 随机存取存储器(英语:Random Access Memory,缩写:RAM),也叫主存,是与 CPU 直接交换数据的内部存储器。它可以随时读写(刷新时除外),而且速度很快,通常作为操作系统或其他正在运行中的程序的**临时数据存储介质
CPU 的内部结构
说了这么多 CPU 的重要性,那么 CPU 的内部结构是什么呢?又是由什么组成的呢?下图展示了一般程序的运行流程(以 C 语言为例),可以说了解程序的运行流程是掌握程序运行机制的基础和前提。
在这个流程中,CPU 负责的就是解释和运行最终转换成机器语言的内容。
CPU 主要由两部分构成:控制单元
和 算术逻辑单元(ALU)
- 控制单元:从内存中提取指令并解码执行
- 算数逻辑单元(ALU):处理算数和逻辑运算
CPU 是计算机的心脏和大脑,它和内存都是由许多晶体管组成的电子部件。它接收数据输入,执行指令并处理信息。它与输入/输出(I / O)设备进行通信,这些设备向 CPU 发送数据和从 CPU 接收数据。
从功能来看,CPU 的内部由寄存器、控制器、运算器和时钟四部分组成,各部分之间通过电信号连通。
寄存器
是中央处理器内的组成部分。它们可以用来暂存指令、数据和地址。可以将其看作是内存的一种。根据种类的不同,一个 CPU 内部会有 20 – 100个寄存器。控制器
负责把内存上的指令、数据读入寄存器,并根据指令的结果控制计算机运算器
负责运算从内存中读入寄存器的数据时钟
负责发出 CPU 开始计时的时钟信号
接下来简单解释一下内存,为什么说 CPU 需要讲一下内存呢,因为内存是与 CPU 进行沟通的桥梁。计算机所有程序的运行都是在内存中运行的,内存又被称为主存
,其作用是存放 CPU 中的运算数据,以及与硬盘等外部存储设备交换的数据。只要计算机在运行中,CPU 就会把需要运算的数据调到主存中进行运算,当运算完成后CPU再将结果传送出来,主存的运行也决定了计算机的稳定运行。
主存通过控制芯片与 CPU 进行相连,由可读写的元素构成,每个字节(1 byte = 8 bits)都带有一个地址编号,注意是一个字节,而不是一个位。CPU 通过地址从主存中读取数据和指令,也可以根据地址写入数据。注意一点:当计算机关机时,内存中的指令和数据也会被清除。
CPU 是寄存器的集合体
在 CPU 的四个结构中,我们程序员只需要了解寄存器
就可以了,其余三个不用过多关注,为什么这么说?因为程序是把寄存器作为对象来描述的。
说到寄存器,就不得不说到汇编语言,我大学是学信息管理与信息系统的,我就没有学过汇编这门课(就算有这门课也不会好好学hhhh),出来混总是要还的,要想作为一个硬核程序员,不能不了解这些概念。说到汇编语言,就不得不说到高级语言,说到高级语言就不得不牵扯出语言
这个概念。
计算机语言
我们生而为人最明显的一个特征是我们能通过讲话来实现彼此的交流,但是计算机听不懂你说的话,你要想和他交流必须按照计算机指令来交换,这就涉及到语言的问题,计算机是由二进制构成的,它只能听的懂二进制也就是机器语言
,但是普通人是无法看懂机器语言的,这个时候就需要一种电脑既能识别,人又能理解的语言,最先出现的就是汇编语言
。但是汇编语言晦涩难懂,所以又出现了像是 C,C++,Java 的这种高级语言。
所以计算机语言一般分为两种:低级语言(机器语言,汇编语言)和高级语言。使用高级语言编写的程序,经过编译转换成机器语言后才能运行,而汇编语言经过汇编器才能转换为机器语言。
汇编语言
首先来看一段用汇编语言表示的代码清单
mov eax, dword ptr [ebp-8] /* 把数值从内存复制到 eax */
add eax, dword ptr [ebp-0Ch] /* 把 eax 的数值和内存的数值相加 */
mov dword ptr [ebp-4], eax /* 把 eax 的数值(上一步的结果)存储在内存中*/
这是采用汇编语言(assembly)编写程序的一部分。汇编语言采用 助记符(memonic)
来编写程序,每一个原本是电信号的机器语言指令会有一个与其对应的助记符,例如 mov,add
分别是数据的存储(move)和相加(addition)的简写。汇编语言和机器语言是一一对应的。这一点和高级语言有很大的不同,通常我们将汇编语言编写的程序转换为机器语言的过程称为 汇编
;反之,机器语言转化为汇编语言的过程称为 反汇编
。
汇编语言能够帮助你理解计算机做了什么工作,机器语言级别的程序是通过寄存器
来处理的,上面代码中的 eax,ebp
都是表示的寄存器,是 CPU 内部寄存器的名称,所以可以说 CPU 是一系列寄存器的集合体。在内存中的存储通过地址编号来表示,而寄存器的种类则通过名字来区分。
不同类型的 CPU ,其内部寄存器的种类,数量以及寄存器存储的数值范围都是不同的。不过,根据功能的不同,可以将寄存器划分为下面这几类
种类 | 功能 |
---|---|
累加寄存器 | 存储运行的数据和运算后的数据。 |
标志寄存器 | 用于反应处理器的状态和运算结果的某些特征以及控制指令的执行。 |
程序计数器 | 程序计数器是用于存放下一条指令所在单元的地址的地方。 |
基址寄存器 | 存储数据内存的起始位置 |
变址寄存器 | 存储基址寄存器的相对地址 |
通用寄存器 | 存储任意数据 |
指令寄存器 | 储存正在被运行的指令,CPU内部使用,程序员无法对该寄存器进行读写 |
栈寄存器 | 存储栈区域的起始位置 |
其中程序计数器、累加寄存器、标志寄存器、指令寄存器和栈寄存器都只有一个,其他寄存器一般有多个
程序计数器
程序计数器(Program Counter)
是用来存储下一条指令所在单元的地址。
程序执行时,PC的初值为程序第一条指令的地址,在顺序执行程序时,控制器
首先按程序计数器所指出的指令地址从内存中取出一条指令,然后分析和执行该指令,同时将PC的值加1指向下一条要执行的指令。
我们还是以一个事例为准来详细的看一下程序计数器的执行过程
这是一段进行相加的操作,程序启动,在经过编译解析后会由操作系统把硬盘中的程序复制到内存中,示例中的程序是将 123 和 456 执行相加操作,并将结果输出到显示器上。由于使用机器语言难以描述,所以这是经过翻译后的结果,实际上每个指令和数据都可能分布在不同的地址上,但为了方便说明,把组成一条指令的内存和数据放在了一个内存地址上。
地址 0100
是程序运行的起始位置。Windows 等操作系统把程序从硬盘复制到内存后,会将程序计数器作为设定为起始位置 0100,然后执行程序,每执行一条指令后,程序计数器的数值会增加1(或者直接指向下一条指令的地址),然后,CPU 就会根据程序计数器的数值,从内存中读取命令并执行,也就是说,程序计数器控制着程序的流程。
条件分支和循环机制
我们都学过高级语言,高级语言中的条件控制流程主要分为三种:顺序执行、条件分支、循环判断
三种,顺序执行是按照地址的内容顺序的执行指令。条件分支是根据条件执行任意地址的指令。循环是重复执行同一地址的指令。
- 顺序执行的情况比较简单,每执行一条指令程序计数器的值就是 + 1。
- 条件和循环分支会使程序计数器的值指向任意的地址,这样一来,程序便可以返回到上一个地址来重复执行同一个指令,或者跳转到任意指令。
下面以条件分支为例来说明程序的执行过程(循环也很相似)
程序的开始过程和顺序流程是一样的,CPU 从0100处开始执行命令,在0100和0101都是顺序执行,PC 的值顺序+1,执行到0102地址的指令时,判断0106寄存器的数值大于0,跳转(jump)到0104地址的指令,将数值输出到显示器中,然后结束程序,0103 的指令被跳过了,这就和我们程序中的 if()
判断是一样的,在不满足条件的情况下,指令会直接跳过。所以 PC 的执行过程也就没有直接+1,而是下一条指令的地址。
标志寄存器
条件和循环分支会使用到 jump(跳转指令)
,会根据当前的指令来判断是否跳转,上面我们提到了标志寄存器
,无论当前累加寄存器的运算结果是正数、负数还是零,标志寄存器都会将其保存(也负责溢出和奇偶校验)
溢出(overflow):是指运算的结果超过了寄存器的长度范围
奇偶校验(parity check):是指检查运算结果的值是偶数还是奇数
CPU 在进行运算时,标志寄存器的数值会根据当前运算的结果自动设定,运算结果的正、负和零三种状态由标志寄存器的三个位表示。标志寄存器的第一个字节位、第二个字节位、第三个字节位各自的结果都为1时,分别代表着正数、零和负数。
CPU 的执行机制比较有意思,假设累加寄存器中存储的 XXX 和通用寄存器中存储的 YYY 做比较,执行比较的背后,CPU 的运算机制就会做减法运算。而无论减法运算的结果是正数、零还是负数,都会保存到标志寄存器中。结果为正表示 XXX 比 YYY 大,结果为零表示 XXX 和 YYY 相等,结果为负表示 XXX 比 YYY 小。程序比较的指令,实际上是在 CPU 内部做减法
运算。
函数调用机制
接下来,我们继续介绍函数调用机制,哪怕是高级语言编写的程序,函数调用处理也是通过把程序计数器的值设定成函数的存储地址来实现的。函数执行跳转指令后,必须进行返回处理,单纯的指令跳转没有意义,下面是一个实现函数跳转的例子
图中将变量 a 和 b 分别赋值为 123 和 456 ,调用 MyFun(a,b) 方法,进行指令跳转。图中的地址是将 C 语言编译成机器语言后运行时的地址,由于1行 C 程序在编译后通常会变为多行机器语言,所以图中的地址是分散的。在执行完 MyFun(a,b)指令后,程序会返回到 MyFun(a,b) 的下一条指令,CPU 继续执行下面的指令。
函数的调用和返回很重要的两个指令是 call
和 return
指令,再将函数的入口地址设定到程序计数器之前,call 指令会把调用函数后要执行的指令地址存储在名为栈的主存内。函数处理完毕后,再通过函数的出口来执行 return 指令。return 指令的功能是把保存在栈中的地址设定到程序计数器。MyFun 函数在被调用之前,0154 地址保存在栈中,MyFun 函数处理完成后,会把0154的地址保存在程序计数器中。这个调用过程如下
在一些高级语言的条件或者循环语句中,函数调用的处理会转换成 call 指令,函数结束后的处理则会转换成 return 指令。
通过地址和索引实现数组
接下来我们看一下基址寄存器和变址寄存器,通过这两个寄存器,我们可以对主存上的特定区域进行划分,来实现类似数组的操作,首先,我们用十六进制数将计算机内存上的 00000000 – FFFFFFFF 的地址划分出来。那么,凡是该范围的内存地址,只要有一个 32 位的寄存器,便可查看全部地址。但如果想要想数组那样分割特定的内存区域以达到连续查看的目的的话,使用两个寄存器会更加方便。
例如,我们用两个寄存器(基址寄存器和变址寄存器)来表示内存的值
这种表示方式很类似数组的构造,数组
是指同样长度的数据在内存中进行连续排列的数据构造。用数组名表示数组全部的值,通过索引来区分数组的各个数据元素,例如: a[0] – a[4],[]
内的 0 – 4 就是数组的下标。
CPU 指令执行过程
那么 CPU 是如何执行一条条的指令的呢?
几乎所有的冯·诺伊曼型计算机的CPU,其工作都可以分为5个阶段:取指令、指令译码、执行指令、访存取数、结果写回。
取指令
阶段是将内存中的指令读取到 CPU 中寄存器的过程,程序寄存器用于存储下一条指令所在的地址指令译码
阶段,在取指令完成后,立马进入指令译码阶段,在指令译码阶段,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类别以及各种获取操作数的方法。执行指令
阶段,译码完成后,就需要执行这一条指令了,此阶段的任务是完成指令所规定的各种操作,具体实现指令的功能。访问取数
阶段,根据指令的需要,有可能需要从内存中提取数据,此阶段的任务是:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算。结果写回
阶段,作为最后一个阶段,结果写回(Write Back,WB)阶段把执行指令阶段的运行结果数据“写回”到某种存储形式:结果数据经常被写到CPU的内部寄存器中,以便被后续的指令快速地存取;
总结
本篇文章我们主要讲述了
- CPU 是什么,CPU 的重要性,CPU 执行程序的过程
- 还讲述了 CPU 的内部结构,它的组成部分
- 提到了汇编语言和高级语言
- 提到了CPU 与 寄存器的关系
- 提到了主要的寄存器的功能,程序计数器,标志寄存器,基址寄存器和变址寄存器
- 还提到了函数调用机制是怎样的。
- CPU 指令的执行过程
程序员需要了解的硬核知识之汇编语言
之前的系列文章从 CPU 和内存方面简单介绍了一下汇编语言,但是还没有系统的了解一下汇编语言,汇编语言作为第二代计算机语言,会用一些容易理解和记忆的字母,单词来代替一个特定的指令,作为高级编程语言的基础,有必要系统的了解一下汇编语言,那么本篇文章希望大家跟我一起来了解一下汇编语言。
汇编语言和本地代码
我们在之前的文章中探讨过,计算机 CPU 只能运行本地代码(机器语言)程序,用 C 语言等高级语言编写的代码,需要经过编译器编译后,转换为本地代码才能够被 CPU 解释执行。
但是本地代码的可读性非常差,所以需要使用一种能够直接读懂的语言来替换本地代码,那就是在各本地代码中,附带上表示其功能的英文缩写,比如在加法运算的本地代码加上add(addition)
的缩写、在比较运算符的本地代码中加上cmp(compare)
的缩写等,这些通过缩写来表示具体本地代码指令的标志称为 助记符
,使用助记符的语言称为汇编语言
。这样,通过阅读汇编语言,也能够了解本地代码的含义了。
不过,即使是使用汇编语言编写的源代码,最终也必须要转换为本地代码才能够运行,负责做这项工作的程序称为编译器
,转换的这个过程称为汇编
。在将源代码转换为本地代码这个功能方面,汇编器和编译器是同样的。
用汇编语言编写的源代码和本地代码是一一对应的。因而,本地代码也可以反过来转换成汇编语言编写的代码。把本地代码转换为汇编代码的这一过程称为反汇编
,执行反汇编的程序称为反汇编程序
。
哪怕是 C 语言编写的源代码,编译后也会转换成特定 CPU 用的本地代码。而将其反汇编的话,就可以得到汇编语言的源代码,并对其内容进行调查。不过,本地代码变成 C 语言源代码的反编译,要比本地代码转换成汇编代码的反汇编要困难,这是因为,C 语言代码和本地代码不是一一对应的关系。
通过编译器输出汇编语言的源代码
我们上面提到本地代码可以经过反汇编转换成为汇编代码,但是只有这一种转换方式吗?显然不是,C 语言编写的源代码也能够通过编译器编译称为汇编代码,下面就来尝试一下。
首先需要先做一些准备,需要先下载 Borland C++ 5.5
编译器,为了方便,我这边直接下载好了读者直接从我的百度网盘提取即可 (链接:https://pan.baidu.com/s/19LqVICpn5GcV88thD2AnlA 密码:hz1u)
下载完毕,需要进行配置,下面是配置说明 (https://wenku.baidu.com/view/22e2f418650e52ea551898ad.html),教程很完整跟着配置就可以,下面开始我们的编译过程
首先用 Windows 记事本等文本编辑器编写如下代码
// 返回两个参数值之和的函数
int AddNum(int a,int b){
return a + b;
}
// 调用 AddNum 函数的函数
void MyFunc(){
int c;
c = AddNum(123,456);
}
编写完成后将其文件名保存为 Sample4.c ,C 语言源文件的扩展名,通常用.c
来表示,上面程序是提供两个输入参数并返回它们之和。
在 Windows 操作系统下打开 命令提示符
,切换到保存 Sample4.c 的文件夹下,然后在命令提示符中输入
bcc32 -c -S Sample4.c
bcc32 是启动 Borland C++ 的命令,-c
的选项是指仅进行编译而不进行链接,-S
选项被用来指定生成汇编语言的源代码
作为编译的结果,当前目录下会生成一个名为Sample4.asm
的汇编语言源代码。汇编语言源文件的扩展名,通常用.asm
来表示,下面就让我们用编辑器打开看一下 Sample4.asm 中的内容
.386p
ifdef ??version
if ??version GT 500H
.mmx
endif
endif
model flat
ifndef ??version
?debug macro
endm
endif
?debug S "Sample4.c"
?debug T "Sample4.c"
_TEXT segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
DGROUP group _BSS,_DATA
_TEXT segment dword public use32 'CODE'
_AddNum proc near
?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
_AddNum endp
_MyFunc proc near
?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
_MyFunc endp
_TEXT ends
public _AddNum
public _MyFunc
?debug D "Sample4.c" 20343 45835
end
这样,编译器就成功的把 C 语言转换成为了汇编代码了。
不会转换成本地代码的伪指令
第一次看到汇编代码的读者可能感觉起来比较难,不过实际上其实比较简单,而且可能比 C 语言还要简单,为了便于阅读汇编代码的源代码,需要注意几个要点
汇编语言的源代码,是由转换成本地代码的指令(后面讲述的操作码)和针对汇编器的伪指令构成的。伪指令负责把程序的构造以及汇编的方法指示给汇编器(转换程序)。不过伪指令是无法汇编转换成为本地代码的。下面是上面程序截取的伪指令
_TEXT segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
DGROUP group _BSS,_DATA
_AddNum proc near
_AddNum endp
_MyFunc proc near
_MyFunc endp
_TEXT ends
end
由伪指令 segment
和 ends
围起来的部分,是给构成程序的命令和数据的集合体上加一个名字而得到的,称为段定义
。段定义的英文表达具有区域
的意思,在这个程序中,段定义指的是命令和数据等程序的集合体的意思,一个程序由多个段定义构成。
上面代码的开始位置,定义了3个名称分别为 _TEXT、_DATA、_BSS
的段定义,_TEXT
是指定的段定义,_DATA
是被初始化(有初始值)的数据的段定义,_BSS
是尚未初始化的数据的段定义。这种定义的名称是由 Borland C++ 定义的,是由 Borland C++ 编译器自动分配的,所以程序段定义的顺序就成为了 _TEXT、_DATA、_BSS
,这样也确保了内存的连续性
_TEXT segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
段定义( segment ) 是用来区分或者划分范围区域的意思。汇编语言的 segment 伪指令表示段定义的起始,ends 伪指令表示段定义的结束。段定义是一段连续的内存空间
而group
这个伪指令表示的是将 _BSS和_DATA
这两个段定义汇总名为 DGROUP 的组
DGROUP group _BSS,_DATA
围起 _AddNum
和 _MyFun
的 _TEXT
segment 和 _TEXT
ends ,表示_AddNum
和 _MyFun
是属于 _TEXT
这一段定义的。
_TEXT segment dword public use32 'CODE'
_TEXT ends
因此,即使在源代码中指令和数据是混杂编写的,经过编译和汇编后,也会转换成为规整的本地代码。
_AddNum proc
和 _AddNum endp
围起来的部分,以及_MyFunc proc
和 _MyFunc endp
围起来的部分,分别表示 AddNum 函数和 MyFunc 函数的范围。
_AddNum proc near
_AddNum endp
_MyFunc proc near
_MyFunc endp
编译后在函数名前附带上下划线_
,是 Borland C++ 的规定。在 C 语言中编写的 AddNum 函数,在内部是以 _AddNum 这个名称处理的。伪指令 proc 和 endp 围起来的部分,表示的是 过程(procedure)
的范围。在汇编语言中,这种相当于 C 语言的函数的形式称为过程。
末尾的 end
伪指令,表示的是源代码的结束。
汇编语言的语法是 操作码 + 操作数
在汇编语言中,一行表示一对 CPU 的一个指令。汇编语言指令的语法结构是 操作码 + 操作数,也存在只有操作码没有操作数的指令。
操作码表示的是指令动作,操作数表示的是指令对象。操作码和操作数一起使用就是一个英文指令。比如从英语语法来分析的话,操作码是动词,操作数是宾语。比如这个句子 Give me money
这个英文指令的话,Give 就是操作码,me 和 money 就是操作数。汇编语言中存在多个操作数的情况,要用逗号把它们分割,就像是 Give me,money 这样。
能够使用何种形式的操作码,是由 CPU 的种类决定的,下面对操作码的功能进行了整理。
本地代码需要加载到内存后才能运行,内存中存储着构成本地代码的指令和数据。程序运行时,CPU会从内存中把数据和指令读出来,然后放在 CPU 内部的寄存器中进行处理。
如果 CPU 和内存的关系你还不是很了解的话,请阅读作者的另一篇文章 程序员需要了解的硬核知识之CPU 详细了解。
寄存器是 CPU 中的存储区域,寄存器除了具有临时存储和计算的功能之外,还具有运算功能,x86 系列的主要种类和角色如下图所示
指令解析
下面就对 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)的特性是后入先出,数据在存储时是从内存的下层(大的地址编号)逐渐往上层(小的地址编号)累积,读出时则是按照从上往下进行读取的。
栈是存储临时数据的区域,它的特点是通过 push 指令和 pop 指令进行数据的存储和读出。向栈中存储数据称为 入栈
,从栈中读出数据称为 出栈
,32位 x86 系列的 CPU 中,进行1次 push 或者 pop,即可处理 32 位(4字节)的数据。
函数的调用机制
下面我们一起来分析一下函数的调用机制,我们以上面的 C 语言编写的代码为例。首先,让我们从MyFunc
函数调用AddNum
函数的汇编语言部分开始,来对函数的调用机制进行说明。栈在函数的调用中发挥了巨大的作用,下面是经过处理后的 MyFunc 函数的汇编处理内容
_MyFunc proc near
push ebp ; 将 ebp 寄存器的值存入栈中 (1)
mov ebp,esp ; 将 esp 寄存器的值存入 ebp 寄存器中 (2)
push 456 ; 将 456 入栈 (3)
push 123 ; 将 123 入栈 (4)
call _AddNum ; 调用 AddNum 函数 (5)
add esp,8 ; esp 寄存器的值 + 8 (6)
pop ebp ; 读出栈中的数值存入 ebp 寄存器中 (7)
ret ; 结束 MyFunc 函数,返回到调用源 (8)
_MyFunc endp
代码解释中的(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
文件时,出现了下图的这条消息
图中的意思是指 c 的值在 MyFunc 定义了但是一直未被使用,这其实是一项编译器优化的功能,由于存储着 AddNum 函数返回值的变量 c 在后面没有被用到,因此编译器就认为 该变量没有意义,进而也就没有生成与之对应的汇编语言代码。
下图是调用 AddNum 这一函数前后栈内存的变化
函数的内部处理
上面我们用汇编代码分析了一下 Sample4.c 整个过程的代码,现在我们着重分析一下 AddNum 函数的源代码部分,分析一下参数的接收、返回值和返回等机制
_AddNum proc near
push ebp (1)
mov ebp,esp (2)
mov eax,dword ptr[ebp+8] (3)
add eax,dword ptr[ebp+12] (4)
pop ebp (5)
ret (6)
_AddNum endp
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 函数入口和出口处栈的状态变化,就如下图所示
全局变量和局部变量
在熟悉了汇编语言后,接下来我们来了解一下全局变量和局部变量,在函数外部定义的变量称为全局变量
,在函数内部定义的变量称为局部变量
,全局变量可以在任意函数中使用,局部变量只能在函数定义局部变量的内部使用。下面,我们就通过汇编语言来看一下全局变量和局部变量的不同之处。
下面定义的 C 语言代码分别定义了局部变量和全局变量,并且给各变量进行了赋值,我们先看一下源代码部分
// 定义被初始化的全局变量
int a1 = 1;
int a2 = 2;
int a3 = 3;
int a4 = 4;
int a5 = 5;
// 定义没有初始化的全局变量
int b1,b2,b3,b4,b5;
// 定义函数
void MyFunc(){
// 定义局部变量
int c1,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++ 编译后的汇编代码如下,编译完成后的源码比较长,这里我们只拿出来一部分作为分析使用(我们改变了一下段定义顺序,删除了部分注释)
_DATA segment dword public use32 'DATA'
align 4
_a1 label dword
dd 1
align 4
_a2 label dword
dd 2
align 4
_a3 label dword
dd 3
align 4
_a4 label dword
dd 4
align 4
_a5 label dword
dd 5
_DATA ends
_BSS segment dword public use32 'BSS'
align 4
_b1 label dword
db 4 dup(?)
align 4
_b2 label dword
db 4 dup(?)
align 4
_b3 label dword
db 4 dup(?)
align 4
_b4 label dword
db 4 dup(?)
align 4
_b5 label dword
db 4 dup(?)
_BSS ends
_TEXT segment dword public use32 'CODE'
_MyFunc proc 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 dword ptr [ebp-4],6
mov dword ptr [ebp-8],7
mov dword ptr [ebp-12],8
mov dword ptr [ebp-16],9
mov dword ptr [ebp-20],10
mov dword ptr [_a1],eax
mov dword ptr [_a2],edx
mov dword ptr [_a3],ecx
mov dword ptr [_a4],ebx
mov dword ptr [_a5],esi
mov eax,dword ptr [ebp-4]
mov dword ptr [_b1],eax
mov edx,dword ptr [ebp-8]
mov dword ptr [_b2],edx
mov ecx,dword ptr [ebp-12]
mov dword ptr [_b3],ecx
mov eax,dword ptr [ebp-16]
mov dword ptr [_b4],eax
mov edx,dword ptr [ebp-20]
mov dword ptr [_b5],edx
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
_MyFunc endp
_TEXT ends
编译后的程序,会被归类到名为段定义的组。
- 初始化的全局变量,会汇总到名为 _DATA 的段定义中
_DATA segment dword public use32 'DATA'
...
_DATA ends
- 没有初始化的全局变量,会汇总到名为 _BSS 的段定义中
_BSS segment dword public use32 'BSS'
...
_BSS ends
- 被段定义 _TEXT 围起来的汇编代码则是 Borland C++ 的定义
_TEXT segment dword public use32 'CODE'
_MyFunc proc near
...
_MyFunc endp
_TEXT ends
我们在分析上面汇编代码之前,先来认识一下更多的汇编指令,此表是对上面部分操作码及其功能的接续
操作码 | 操作数 | 功能 |
---|---|---|
add | A,B | 把A和B的值相加,并把结果赋值给A |
call | A | 调用函数A |
cmp | A,B | 对A和B进行比较,比较结果会自动存入标志寄存器中 |
inc | A | 对A的值 + 1 |
ige | 标签名 | 和 cmp 命令组合使用。跳转到标签行 |
jl | 标签名 | 和 cmp 命令组合使用。跳转到标签行 |
jle | 标签名 | 和 cmp 命令组合使用。跳转到标签行 |
jmp | 标签名 | 和 cmp 命令组合使用。跳转到标签行 |
mov | A,B | 把 B 的值赋给 A |
pop | A | 从栈中读取数值并存入A |
push | A | 把A的值存入栈中 |
ret | 无 | 将处理返回到调用源 |
xor | A,B | A和B的位进行亦或比较,并将结果存入A中 |
我们首先来看一下 _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++ 编译器最优化的运行结果。
代码清单中的如下内容表示的是向寄存器中分配局部变量的部分
mov eax,1
mov edx,2
mov ecx,3
mov ebx,4
mov esi,5
仅仅对局部变量进行定义是不够的,只有在给局部变量赋值时,才会被分配到寄存器的内存区域。上述代码相当于就是给5个局部变量 c1 – c5 分别赋值为 1 – 5。eax、edx、ecx、ebx、esi 是 x86 系列32位 CPU 寄存器的名称。至于使用哪个寄存器,是由编译器
来决定的 。
x86 系列 CPU 拥有的寄存器中,程序可以操作的是十几,其中空闲的最多会有几个。因而,局部变量超过寄存器数量的时候,可分配的寄存器就不够用了,这种情况下,编译器就会把栈派上用场,用来存储剩余的局部变量。
在上述代码这一部分,给局部变量c1 – c5 分配完寄存器后,可用的寄存器数量就不足了。于是,剩下的5个局部变量c6 – c10 就被分配给了栈的内存空间。如下面代码所示
mov dword ptr [ebp-4],6
mov dword ptr [ebp-8],7
mov dword ptr [ebp-12],8
mov dword ptr [ebp-16],9
mov dword ptr [ebp-20],10
函数入口 add esp,-20
指的是,对栈数据存储位置的 esp 寄存器(栈指针)的值做减20的处理。为了确保内存变量 c6 – c10 在栈中,就需要保留5个 int 类型的局部变量(4字节 * 5 = 20 字节)所需的空间。 mov ebp,esp
这行指令表示的意思是将 esp 寄存器的值赋值到 ebp 寄存器。之所以需要这么处理,是为了通过在函数出口处 mov esp ebp
这一处理,把 esp 寄存器的值还原到原始状态,从而对申请分配的栈空间进行释放,这时栈中用到的局部变量就消失了。这也是栈的清理处理。在使用寄存器的情况下,局部变量则会在寄存器被用于其他用途时自动消失,如下图所示。
mov dword ptr [ebp-4],6
mov dword ptr [ebp-8],7
mov dword ptr [ebp-12],8
mov dword ptr [ebp-16],9
mov dword ptr [ebp-20],10
这五行代码是往栈空间代入数值的部分,由于在向栈申请内存空间前,借助了 mov ebp, esp
这个处理,esp 寄存器的值被保存到了 ebp 寄存器中,因此,通过使用[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字节数据。
循环控制语句的处理
上面说的都是顺序流程,那么现在就让我们分析一下循环流程的处理,看一下 for 循环
以及 if 条件分支
等 c 语言程序的 流程控制
是如何实现的,我们还是以代码以及编译后的结果为例,看一下程序控制流程的处理过程。
// 定义MySub 函数
void MySub(){
// 不做任何处理
}
// 定义MyFunc 函数
void Myfunc(){
int i;
for(int i = 0;i < 10;i++){
// 重复调用MySub十次
MySub();
}
}
上述代码将局部变量 i 作为循环条件,循环调用十次MySub
函数,下面是它主要的汇编代码
xor ebx, ebx ; 将寄存器清0
@4 call _MySub ; 调用MySub函数
inc ebx ; ebx寄存器的值 + 1
cmp ebx,10 ; 将ebx寄存器的值和10进行比较
jl short @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。
相同数值进行 XOR 运算,运算结果为0。XOR 的运算规则是,值不同时结果为1,值相同时结果为0。例如 01010101 和 01010101 进行运算,就会分别对各个数字位进行 XOR 运算。因为每个数字位都相同,所以运算结果为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) goto L4;
代码第一行 i ^= i 指的就是 i 和 i 进行异或运算,也就是 XOR 运算,MySub() 函数用 L4 标签来替代,然后进行 i 自增操作,如果i 的值小于 10 的话,就会一直循环 MySub() 函数。
条件分支的处理方法
条件分支的处理方式和循环的处理方式很相似,使用的也是 cmp 指令和跳转指令。下面是用 C 语言编写的条件分支的代码
// 定义MySub1 函数
void MySub1(){
// 不做任何处理
}
// 定义MySub2 函数
void MySub2(){
// 不做任何处理
}
// 定义MySub3 函数
void MySub3(){
// 不做任何处理
}
// 定义MyFunc 函数
void MyFunc(){
int a = 123;
// 根据条件调用不同的函数
if(a > 100){
MySub1();
}
else if(a < 50){
MySub2();
}
else
{
MySub3();
}
}
很简单的一个实现了条件判断的 C 语言代码,那么我们把它用 Borland C++ 编译之后的结果如下
_MyFunc proc near
push ebp
mov ebp,esp
mov eax,123 ; 把123存入 eax 寄存器中
cmp eax,100 ; 把 eax 寄存器的值同100进行比较
jle short @8 ; 比100小时,跳转到@8标签
call _MySub1 ; 调用MySub1函数
jmp short @11 ; 跳转到@11标签
@8:
cmp eax,50 ; 把 eax 寄存器的值同50进行比较
jge short @10 ; 比50大时,跳转到@10标签
call _MySub2 ; 调用MySub2函数
jmp short @11 ; 跳转到@11标签
@10:
call _MySub3 ; 调用MySub3函数
@11:
pop ebp
ret
_MyFunc endp
上面代码用到了三种跳转指令,分别是jle(jump on less or equal)
比较结果小时跳转,jge(jump on greater or equal)
比较结果大时跳转,还有不管结果怎样都会进行跳转的jmp
,在这些跳转指令之前还有用来比较的指令 cmp
,构成了上述汇编代码的主要逻辑形式。
了解程序运行逻辑的必要性
通过对上述汇编代码和 C 语言源代码进行比较,想必大家对程序的运行方式有了新的理解,而且,从汇编源代码中获取的知识,也有助于了解 Java 等高级语言的特性,比如 Java 中就有 native 关键字修饰的变量,那么这个变量的底层就是使用 C 语言编写的,还有一些 Java 中的语法糖只有通过汇编代码才能知道其运行逻辑。在某些情况下,对于查找 bug 的原因也是有帮助的。
上面我们了解到的编程方式都是串行处理的,那么串行处理有什么特点呢?
串行处理最大的一个特点就是专心只做一件事情
,一件事情做完之后才会去做另外一件事情。
计算机是支持多线程的,多线程的核心就是 CPU切换,如下图所示
我们还是举个实际的例子,让我们来看一段代码
// 定义全局变量
int counter = 100;
// 定义MyFunc1()
void MyFunc(){
counter *= 2;
}
// 定义MyFunc2()
void MyFunc2(){
counter *= 2;
}
上述代码是更新 counter 的值的 C 语言程序,MyFunc1() 和 MyFunc2() 的处理内容都是把 counter 的值扩大至原来的二倍,然后再把 counter 的值赋值给 counter 。这里,我们假设使用多线程处理
,同时调用了一次MyFunc1 和 MyFunc2 函数,这时,全局变量 counter 的值,理应编程 100 2 2 = 400。如果你开启了多个线程的话,你会发现 counter 的数值有时也是 200,对于为什么出现这种情况,如果你不了解程序的运行方式,是很难找到原因的。
我们将上面的代码转换成汇编语言的代码如下
mov eax,dword ptr[_counter] ; 将 counter 的值读入 eax 寄存器
add eax,eax ; 将 eax 寄存器的值扩大2倍。
mov dword ptr[_counter],eax ; 将 eax 寄存器的值存入 counter 中。
在多线程程序中,用汇编语言表示的代码每运行一行,处理都有可能切换到其他线程中。因而,假设 MyFun1 函数在读出 counter 数值100后,还未来得及将它的二倍值200写入 counter 时,正巧 MyFun2 函数读出了 counter 的值100,那么结果就将变为 200 。
为了避免该bug,我们可以采用以函数或 C 语言代码的行为单位来禁止线程切换的锁定
方法,或者使用某种线程安全的方式来避免该问题的出现。
现在基本上没有人用汇编语言来编写程序了,因为 C、Java等高级语言的效率要比汇编语言快很多。不过,汇编语言的经验还是很重要的,通过借助汇编语言,我们可以更好的了解计算机运行机制。
程序员需要了解的硬核知识之控制硬件
应用和硬件的关系
我们作为程序员一般很少直接操控硬件,我们一般通过 C、Java 等高级语言编写的程序起到间接控制硬件的作用。所以大家很少直接接触到硬件的指令,硬件的控制是由 Windows 操作系统
全权负责的。
你一定猜到我要说什么了,没错,我会说但是,任何事情没有绝对性,环境的不同会造成结果的偏差。虽然程序员没法直接控制硬件,并且 Windows 屏蔽了控制硬件的细节,但是 Windows 却为你开放了 系统调用
功能来实现对硬件的控制。在 Windows 中,系统调用称为 API
,API 就是应用调用的函数,这些函数的实体被存放在 DLL
文件中。
下面我们来看一个通过系统调用来间接控制硬件的实例
假如要在窗口中显示字符串,就可以使用 Windows API 中的 TextOut
函数。TextOut 函数的语法(C 语言)如下
BOOL TextOut{
HDC hdc, // 设备描述表的句柄
int nXStart, // 显示字符串的 x 坐标
int nYStart, // 显示字符串的 y 坐标
LPCTSTR lpString, // 指向字符串的指针
int cbString // 字符串的文字数
}
那么,在处理 TextOut 函数的内容时,Windows 做了些什么呢?从结果来看,Windows 直接控制了作为硬件的显示器。但 Windows 本身也是软件,由此可见,Windows 应该向 CPU 传递了某种指令,从而通过软件控制了硬件。
Windows 提供的 TextOut 函数 API 可以向窗口和打印机输出字符。C 语言提供的 printf 函数,是用来在命令提示符中显示字符串的函数。使用 printf 函数是无法向打印机输出字符的。
支持硬件输入输出的 IN 指令和 OUT 指令
Windows 控制硬件借助的是输入和输出指令。其中具有代表性的两个输入输出指令就是 IN
和 OUT
指令。这些指令也是汇编语言的助记符。
可以通过 IN 和 OUT 指令来实现对数据的读入和输出,如下图所示
也就是说,IN 指令通过指定的端口号输入数据,OUT 指令则是把 CPU 寄存器中存储的数据输出到指定端口号的端口。
那么这个端口号
和 端口
是什么呢?你感觉它像不像港口一样?通过标注哪个港口然后进行货物的运送和运出?
下面我们来看一下官方是如何定义端口号和端口的
还记得计算机组成原理中计算机的五大组成部分吗,再来回顾一下:运算器、控制器、存储器、输入设备和输出设备。我们今天不谈前三个,就说说后面两个输入设备和输出设备,这两个与我们本节主题息息相关。
那么问题来了,IO设备如何实现输入和输出的呢?计算机主机中,附带了用来连接显示器以及键盘等外围设备的连接器
。 而连接器的内部,都连接有用来交换计算机主机同外围设备之间电流特性的 IC。如果 IC 你不明白是什么的话,可以参考作者的文章 程序员需要了解的硬核知识之内存 进行了解。这些 IC 统称为 IO 控制器
。
IO 是 Input/Output 的缩写。显示器、键盘等外围设备都有各自专用的 I/O 控制器。I/O 控制器中有用于临时保存输入输出数据的内存。这个内存就是 端口(port)
。端口你就可以把它理解为我们上述说的 港口。IO 控制器内部的内存,也被称为寄存器
,不要慌,这个寄存器和内存中的寄存器不一样。CPU 内存的寄存器是用于进行数据运算处理的,而IO中的寄存器是用于临时存储数据的。
在 I/O 设备内部的 IC 中,有多个端口。由于计算机中连接着很多外围设备,因此也就有很多 I/O 控制器。当然也会有多个端口,一个 I/O 控制器可以控制多个设备,不仅仅只能控制一个。各端口之间通过 端口号
进行区分。
端口号也被称为 I/O地址
。IN 指令和 OUT 指令在端口号指定的端口和 CPU 之间进行数据的输入和输出。这跟通过内存的地址来对内存进行读写是一样的道理。
测试输入和输出程序
首先让我们利用 IN 指令和 OUT 指令,来进行一个直接控制硬件的实验。假如试验的目的是让一个计算机内置的喇叭(蜂鸣器)发出声音。蜂鸣器封装在计算机内部,但它也是外围设备的一种。
用汇编语言比较繁琐,这次我们用 C 语言来实现。在大部分 C 语言的处理(编译器的种类)中,只要使用 _asm{ 和 }
括起来,就可以在其中记述助记符。也就是说,采用这种方式就能够使用 C 语言和汇编语言混合的源代码。
在 AT 兼容机中,蜂鸣器的默认端口号是 61H ,末尾的 H 表示的是十六进制数的意思。用 IN 指令通过该端口号输入数据,并将数据的低2位设定为 ON,然后再通过该端口号用 OUT 指令输出数据,这时蜂鸣器就会发出声音。同样的方法,将数据的低2位设定为 OFF 并输出后,蜂鸣器就停止工作。
位设定为 ON 指的是将该位设定为1,位设定为 OFF 指的是将该位设定为0 。把位设定为 ON,只需要把想要设定为 ON 的位设定为1,其他位设定为0后进行 OR 运算即可。由于这里需要把低2位置为1,因此就是和 03H 进行 OR 运算。03H 用8为二进制来表示的话是 00000011。由于即便高6位存在着具体意义。和0进行OR运算后也不会发生变化,因而就和 03H 进行 OR 运算。把位设定为 OFF,只需要把想要置 OFF 的位设定为0,其他位设定为1后进行 AND 运算即可。由于这里需要把低2位设定为0,因此就要和 FCH 进行 AND 运算。在源代码中,FCH 是用 0FCH 来记述的。在前面加 0 是汇编语言的规定,表示的是以 A – F 这些字符开头的十六进制数是数值的意思。0FCH 用8位二进制数来表示的话是 11111100。由于即便高6位存在着具体意义,和1进行 AND 运算后也不会产生变化,因而就是同 0FCH 进行 OR 运算。
void main(){
// 计数器
int i;
// 蜂鸣器发声
_asm{
IN EAX, 61H
OR EAX, 03H
OUT 61H, EAX
}
// 等待一段时间
for(i = 0;i < 1000000;i++);
// 蜂鸣器停止发生
_asm{
IN EAX, 61H
AND EAX, 0FCH
OUT 61H, EAX
}
}
我们对上面的代码进行说明,main 是 C 语言程序起始位置的函数。在该函数中,有两个用 _asm{}
围起来的部分,它们中间有一个使用 for 循环的空循环
首先是蜂鸣器发声的部分,通过 IN EAX,61H(助记符不区分大小写)指令,把端口 61H 的数据存储到 CPU 的 EAX 寄存器中。接下来,通过 OR EAX,03H 指令,把 EAX 寄存器的低2位设定成 ON。最后,通过 OUT 61H,EAX 指令,把 EAX 寄存器的内容输出到61端口。使蜂鸣器开始发音。虽然 EAX 寄存器的长度是 32 位,不过由于蜂鸣器端口是8位,所以只需对下8位进行OR运算和AND运算就可以正常工作了。
其次是一个重复100次的空循环,主要是为了在蜂鸣器开始发音和停止发音之间稍微加上一些时间间隔。因为现在计算机器的运行速度非常快,哪怕是 100 万次循环,也几乎是瞬时间完成的。
然后是用来控制器蜂鸣器停止发声的部分。首先,通过 IN EAX,61H 指令,把端口 61H 的数据存储到 CPU 的 EAX 寄存器中。接下来,通过 AND EAX,0FCH 指令,把 EAX 寄存器的低2位设定为 OFF。最后,通过 OUT 61H,EAX 指令,把寄存器的 EAX 内容输出到61号端口,使蜂鸣器停止发音。
外围设备的中断请求
IRQ(Interrupt Request)
代表的就是中断请求。IRQ 用来暂停当前正在运行的程序,并跳转到其他程序运行的必要机制。该机制被称为 处理中断
。中断处理在硬件控制中担当着重要的角色。因为如果没有中断处理,就有可能无法顺畅进行处理的情况。
从中断处理开始到请求中断的程序(中断处理程序)运行结束之前,被中断的程序(主程序)的处理是停止的。这种情况就类似于在处理文档的过程中有电话打进来,电话就相当于是中断处理。假如没有中断处理的发生,就必须等到文档处理完成后才能够接听电话。由此可见,中断处理有着巨大的价值,就像是接听完电话后会返回原来的文档作业一样,中断程序处理完成后,也会返回到主程序中继续。
实施中断请求的是连接外围设备的 I/O 控制器,负责实施中断处理的是 CPU,外围设备的中断请求会使用不同于 I/O 端口的其他编号,该编号称为中断编号
。在控制面板中查看软盘驱动器的属性时,IRQ处现实的数值是 06,表示的就是用06号来识别软盘驱动器发出的请求。还有就是操作系统以及 BIOS
则会提供响应中断编号的中断处理程序。
BIOS(Basic Input Output System): 位于计算机主板或者扩张卡上内置的 ROM 中,里面记录了用来控制外围设备的程序和数据。
假如有多个外围设备进行中断请求的话, CPU 需要做出选择进行处理,为此,我们可以在 I/O 控制器和 CPU 中间加入名为中断控制器
的 IC 来进行缓冲。中断控制器会把从多个外围设备发出的中断请求有序的传递给 CPU。中断控制器的功能相当于就是缓冲。下面是中断控制器功能的示意图
CPU 在接受到中断请求后,会把当前正在运行的任务中断,并切换到中断处理程序。中断处理程序的第一步处理,就是把 CPU 所有寄存器的数值保存到内存的栈中。在中断处理程序中完成外围设备的输入和输出后,把栈中保存的数值还原到 CPU 寄存器中,然后再继续进行对主程序的处理。
假如 CPU 寄存器数值还没有还原的话,就会影响到主程序的运行,甚至还有可能会使程序意外停止或发生运行时异常。这是因为主程序在运行过程中,会用到 CPU 寄存器进行处理,这时候如果突然插入其他程序的运行结果,此时 CPU 必然会受到影响。所以,在处理完中断请求后,各个寄存器的值必须要还原。只要寄存器的值保持不变,主程序就可以像没有发生过任何事情一样继续处理。
用中断来实现实时处理
中断是指计算机运行过程中,出现某些意外情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行。
在程序的运行过程中,几乎无时无刻都会发生中断,其原因就是为了实时处理外部输入的数据,虽然程序也可以在不会中断的基础上处理外部数据,但是那种情况下,主程序就会频繁的检查外围设备是否会有数据输入。由于外围设备会有很多个,因此有必要按照顺序来调查。按照顺序检查多个外围设备的状态称为 轮询
。对于计算机来说,这种采用轮询的方式不是很合理,如果你正在检查是否有鼠标输入,这时候发生了键盘输入该如何处理呢?结果必定会导致文字的实时处理效率。所以即时的中断能够提高程序的运行效率。
上面只是中断的一种好处,下面汇总一下利用中断能够带来的正面影响
- 提高计算机系统效率。计算机系统中处理机的工作速度远高于外围设备的工作速度。通过中断可以协调它们之间的工作。当外围设备需要与处理机交换信息时,由外围设备向处理机发出中断请求,处理机及时响应并作相应处理。不交换信息时,处理机和外围设备处于各自独立的并行工作状态。
- 维持系统可靠正常工作。现代计算机中,程序员不能直接干预和操纵机器,必须通过中断系统向操作系统发出请求,由操作系统来实现人为干预。主存储器中往往有多道程序和各自的存储空间。在程序运行过程中,如出现越界访问,有可能引起程序混乱或相互破坏信息。为避免这类事件的发生,由存储管理部件进行监测,一旦发生越界访问,向处理机发出中断请求,处理机立即采取保护措施。
- 满足实时处理要求。在实时系统中,各种监测和控制装置随机地向处理机发出中断请求,处理机随时响应并进行处理。
- 提供故障现场处理手段。处理机中设有各种故障检测和错误诊断的部件,一旦发现故障或错误,立即发出中断请求,进行故障现场记录和隔离,为进一步处理提供必要的依据。
利用 DMA 实现短时间内大量数据传输
上面我们介绍了 I/O 处理和中断的关系,下面我们来介绍一下另外一个机制,这个机制就是 DMA(Direct Memory Access)
。DMA 是指在不通过 CPU 的情况下,外围设备直接和主存进行数据传输。磁盘等硬件设备都用到了 DMA 机制,通过 DMA,大量数据可以在短时间内实现传输,之所以这么快,是因为 CPU 作为中介的时间被节省了,下面是 DMA 的传输过程
I/O 端口号、IRQ、DMA 通道可以说是识别外围设备的3点组合。不过,IRQ、DMA 通道并不是所有外围设备都具备的。计算机主机通过软件控制硬件时所需要的信息的最低限,是外围设备的 I/O 端口号。IRQ 只对需要中断处理的外围设备来说是必须的,DMA 通道则只对需要 DMA 机制的外围设备来说是必须的。假如多个外围设备都设定成相同的端口号、IRQ 和 DMA 通道的话,计算机就无法正常工作,会提示 设备冲突
。
文字和图片的显示机制
你知道文字和图片是如何显示出来的吗?事实上,如果用一句话来简单的概括一下该机制,那就是显示器中显示的信息一直存储在某内存中。该内存称为VRAM(Video RAM)
。在程序中,只要往 VRAM 中写入数据,该数据就会在显示器中显示出来。实现该功能的程序,是由操作系统或者 BIOS 提供,并借助中断来进行处理。
在 MS-DOS
时代,对于大部分计算机来说,VRAM 都是主内存的一部分。在现代计算机中,显卡
等专用硬件中一般都配置有与主内存相独立的 VRAM 和 GPU(Graphics Processing Unit),也叫做图形处理器或者图形芯片。这是因为,对经常描绘图形的 windows 来说,数百兆的 VRAM 都是必需的。
用软件来控制硬件听起来好像很难,但实际上只是利用输入输出指令同外围设备进行输入输出而已。中断处理是根据需要来使用的功能选项。DMA 则直接交给对应的外围设备即可。
虽然计算机领域新技术在不断涌现,但是计算机所能处理的事情,始终只是对输入的数据进行运算,并把结果输出,这一点是永远不会发生变化的。
程序员需要了解的硬核知识之操作系统入门
操作系统环境
程序中包含着运行环境
这一内容,可以说 运行环境 = 操作系统 + 硬件 ,操作系统又可以被称为软件,它是由一系列的指令组成的。我们不介绍操作系统,我们主要来介绍一下硬件的识别。
我们肯定都玩儿过游戏,你玩儿游戏前需要干什么?是不是需要先看一下自己的笔记本或者电脑是不是能肝的起游戏?下面是一个游戏的配置(怀念一下 wow)
图中的主要配置如下
-
操作系统版本:说的就是应用程序运行在何种系统环境,现在市面上主要有三种操作系统环境,Windows 、Linux 和 Unix ,一般我们玩儿的大型游戏几乎都是在 Windows 上运行,可以说 Windows 是游戏的天堂。Windows 操作系统也会有区分,分为32位操作系统和64位操作系统,互不兼容。
-
处理器:处理器指的就是 CPU,你的电脑的计算能力,通俗来讲就是每秒钟能处理的指令数,如果你的电脑觉得卡带不起来的话,很可能就是 CPU 的计算能力不足导致的。想要加深理解,请阅读博主的另一篇文章:程序员需要了解的硬核知识之CPU
-
显卡:显卡承担图形的输出任务,因此又被称为图形处理器(Graphic Processing Unit,GPU),显卡也非常重要,比如我之前玩儿的
剑灵
开五档(其实就是图像变得更清晰)会卡,其实就是显卡显示不出来的原因。 -
内存:内存即主存,就是你的应用程序在运行时能够动态分析指令的这部分存储空间,它的大小也能决定你电脑的运行速度,想要加深理解,请阅读博主的另一篇文章 程序员需要了解的硬核知识之内存
-
存储空间:存储空间指的就是应用程序安装所占用的磁盘空间,由图中可知,此游戏的最低存储空间必须要大于 5GB,其实我们都会遗留很大一部分用来安装游戏。
从程序的运行环境这一角度来考量的话,CPU 的种类是特别重要的参数,为了使程序能够正常运行,必须满足 CPU 所需的最低配置。
CPU 只能解释其自身固有的语言。不同的 CPU 能解释的机器语言的种类也是不同的。机器语言的程序称为 本地代码(native code)
,程序员用 C 等高级语言编写的程序,仅仅是文本文件。文本文件(排除文字编码的问题)
在任何环境下都能显示和编辑。我们称之为源代码
。通过对源代码进行编译,就可以得到本地代码
。下图反映了这个过程。
Windows 操作系统克服了CPU以外的硬件差异
计算机的硬件并不仅仅是由 CPU 组成的,还包括用于存储程序指令的数据和内存,以及通过 I/O 连接的键盘、显示器、硬盘、打印机等外围设备。
在 WIndows 软件中,键盘输入、显示器输出等并不是直接向硬件发送指令。而是通过向 Windows 发送指令实现的。因此,程序员就不用注意内存和 I/O 地址的不同构成了。Windows 操作的是硬件而不是软件,软件通过操作 Windows 系统可以达到控制硬件的目的。
不同操作系统的 API 差异性
接下来我们看一下操作系统的种类。同样机型的计算机,可安装的操作系统类型也会有多种选择。例如:AT 兼容机除了可以安装 Windows 之外,还可以采用 Unix 系列的 Linux 以及 FreeBSD (也是一种Unix操作系统)等多个操作系统。当然,应用软件则必须根据不同的操作系统类型来专门开发。CPU 的类型不同,所对应机器的语言也不同,同样的道理,操作系统的类型不同,应用程序向操作系统传递指令的途径也不同。
应用程序向系统传递指令的途径称为 API(Application Programming Interface)
。Windows 以及 Linux 操作系统的 API,提供了任何应用程序都可以利用的函数组合。因为不同操作系统的 API 是有差异的。所以,如何要将同样的应用程序移植到另外的操作系统,就必须要覆盖应用所用到的 API 部分。
键盘输入、鼠标输入、显示器输出、文件输入和输出等同外围设备进行交互的功能,都是通过 API 提供的。
**这也就是为什么 Windows 应用程序不能直接移植到 Linux 操作系统上的原因,API 差异太大了。
在同类型的操作系统下,不论硬件如何,API 几乎相同。但是,由于不同种类 CPU 的机器语言不同,因此本地代码也不尽相同。
FreeBSD Port 帮你轻松使用源代码
不知道你有没有这个想法:“既然 CPU 不同会导致本地代码不同,那为何不将源代码直接发送给程序呢?”这确实是一种解决办法,Unix 系列的 FreeBSD
操作系统就使用了这种方式。
Unix 系列操作系统 FreeBSD 中,存在一种名为 Ports
的机制。该机制能够结合当前运行环境的硬件环境来编译应用的源代码,进而得到可以运行的本地代码。如果目标应用的源代码在硬件上找不到,Ports 就会自动使用 FTP 连接到相应站点下载代码。
全球有很多站点都提供适用于 FreeBSD 的应用源代码。通过使用 Ports 可以利用的程序源代码,大约有 16000 种。根据不同的领域进行分类,可以随时使用。
FreeBSD 上应用的源代码,大部分是用 C 语言来标注的,C 编译器
可以结合 FreeBSD 的运行环境来生成合适的本地代码。
FTP( File Transfer Protocol) 是连接到互联网上的计算机之间的传送文件的协议。
可以使用虚拟机获取其他环境
即使不通过应用程序的移植,在同一个操作系统上仍然可以使用其他的操作系统,那就是使用 虚拟机软件
。虚拟机(Virtual Machine)指通过软件的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。在实体计算机中能够完成的工作在虚拟机中都能够实现。
提供相同运行环境的 Java 虚拟机
总算是提到大 Java 了, Java 大法好,除了虚拟机的方法之外,还有一种方法能够提供不依赖于特定硬件和操作系统的程序运行环境,那就是 Java。
大家说的 Java 其实有两层意思,一种是作为编程语言的 Java;一种是作为程序运行环境的 Java。Java 与其他语言相同,都是通过源代码编译后运行的。不过,编译后生成的不是特定 CPU 使用的本地代码,而是名为字节代码
的程序。直接代码的运行环境就称为 Java 虚拟机(Java Virtual Machine)
。Java 虚拟机是一边把 Java 字节代码逐一转换为本地代码一边在运行着。
程序运行时,将编译后的字节代码转换为本地代码,这样的操作看上去有些迂回,但由此可以实现相同的字节码可以在不同的操作系统环境下运行。
**想象一下,你开发完成的应用部署到 Linux 环境下,是不是什么都不用管?
Windows 有专门的 Windows 虚拟机,Macintosh 有 Macintosh 专门的虚拟机。从操作系统来看,Java虚拟机就是一个应用,从运行环境上来看,Java 虚拟机就是运行环境。
BIOS 和引导
最后对一些比较基础的部分做一些补充说明。程序的运行环境,存在着名为 BIOS(Basic Input/Output System)
的系统。BIOS 存储在 ROM 中,是预先内置在计算机主机内部的程序。BIOS 除了键盘、磁盘和显卡等基本控制外,还有引导程序
的功能。引导程序是存储在启动驱动器启示区域的小程序。操作系统的启动驱动器一般硬盘。不过有时也可能是 CD-ROM
或软盘。
电脑开机后,BIOS 会确认硬件是否正常运行,没有异常的话会直接启动引导程序。引导程序的功能是把在硬盘等记录的 OS 加载到内存中运行。虽然启动应用是 OS 的功能,但 OS 不能启动自己,是通过引导程序来启动的。
程序员需要了解的硬核知识之内存
我们都知道,计算机是处理数据的设备,而数据的主要存储位置就是磁盘
和内存
,并且对于程序员来讲,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 引脚示意图
图中 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。
如果我们使用的是 512 MB 的内存,这就相当于是 512000(512 * 1000) 个内存 IC。当然,一台计算机不太可能有这么多个内存 IC ,然而,通常情况下,一个内存 IC 会有更多的引脚,也就能存储更多数据。
内存的读写过程
让我们把关注点放在内存 IC 对数据的读写过程上来吧!我们来看一个对内存IC 进行数据写入和读取的模型
来详细描述一下这个过程,假设我们要向内存 IC 中写入 1byte 的数据的话,它的过程是这样的:
- 首先给 VCC 接通 +5V 的电源,给 GND 接通 0V 的电源,使用
A0 - A9
来指定数据的存储场所,然后再把数据的值输入给D0 - D7
的数据信号,并把WR(write)
的值置为 1,执行完这些操作后,即可以向内存 IC 写入数据 - 读出数据时,只需要通过 A0 – A9 的地址信号指定数据的存储场所,然后再将 RD 的值置为 1 即可。
- 图中的 RD 和 WR 又被称为控制信号。其中当WR 和 RD 都为 0 时,无法进行写入和读取操作。
内存的现实模型
为了便于记忆,我们把内存模型映射成为我们现实世界的模型,在现实世界中,内存的模型很想我们生活的楼房。在这个楼房中,1层可以存储一个字节的数据,楼层号就是地址
,下面是内存和楼层整合的模型图
我们知道,程序中的数据不仅只有数值,还有数据类型
的概念,从内存上来看,就是占用内存大小(占用楼层数)的意思。即使物理上强制以 1 个字节为单位来逐一读写数据的内存,在程序中,通过指定其数据类型,也能实现以特定字节数为单位来进行读写。
下面是一个以特定字节数为例来读写指令字节的程序的示例
// 定义变量
char a;
short b;
long c;
// 变量赋值
a = 123;
b = 123;
c = 123;
我们分别声明了三个变量 a,b,c ,并给每个变量赋上了相同的 123,这三个变量表示内存的特定区域。通过变量,即使不指定物理地址,也可以直接完成读写操作,操作系统会自动为变量分配内存地址。
这三个变量分别表示 1 个字节长度的 char,2 个字节长度的 short,表示4 个字节的 long。因此,虽然数据都表示的是 123,但是其存储时所占的内存大小是不一样的。如下所示
这里的 123 都没有超过每个类型的最大长度,所以 short 和 long 类型为所占用的其他内存空间分配的数值是0,这里我们采用的是低字节序列
的方式存储
低字节序列:将数据低位存储在内存低位地址。
高字节序列:将数据的高位存储在内存地位的方式称为高字节序列。
内存的使用
指针
指针是 C 语言非常重要的特征,指针也是一种变量,只不过它所表示的不是数据的值,而是内存的地址。通过使用指针,可以对任意内存地址的数据进行读写。
在了解指针读写的过程前,我们先需要了解如何定义一个指针,和普通的变量不同,在定义指针时,我们通常会在变量名前加一个 *
号。例如我们可以用指针定义如下的变量
char *d; // char类型的指针 d 定义
short *e; // short类型的指针 e 定义
long *f; // long类型的指针 f 定义
我们以32
位计算机为例,32位计算机的内存地址是 4 字节,在这种情况下,指针的长度也是 32 位。然而,变量 d e f 却代表了不同的字节长度,这是为什么呢?
实际上,这些数据表示的是从内存中一次读取的字节数,比如 d e f 的值都为 100,那么使用 char 类型时就能够从内存中读写 1 byte 的数据,使用 short 类型就能够从内存读写 2 字节的数据, 使用 long 就能够读写 4 字节的数据,下面是一个完整的类型字节表
类型 | 32位 | 64位 |
---|---|---|
char | 1 | 1 |
short int | 2 | 2 |
int | 4 | 4 |
unsigned int | 4 | 4 |
float | 4 | 4 |
double | 8 | 8 |
long | 4 | 8 |
long long | 8 | 8 |
unsigned long | 4 | 8 |
我们可以用图来描述一下这个读写过程
数组是内存的实现
数组是指多个相同
的数据类型在内存中连续排列的一种形式。作为数组元素的各个数据会通过下标编号
来区分,这个编号也叫做索引
,如此一来,就可以对指定索引的元素进行读写操作。
首先先来认识一下数组,我们还是用 char、short、long 三种元素来定义数组,数组的元素用[value]
扩起来,里面的值代表的是数组的长度,就像下面的定义
char g[100];
short h[100];
long i[100];
数组定义的数据类型,也表示一次能够读写的内存大小,char 、short 、long 分别以 1 、2 、4 个字节为例进行内存的读写。
数组是内存的实现,数组和内存的物理结构完全一致,尤其是在读写1个字节的时候,当字节数超过 1 时,只能通过逐个字节来读取,下面是内存的读写过程
数组是我们学习的第一个数据结构,我们都知道数组的检索效率是比较快的,至于数组的检索效率为什么这么快并不是我们这篇文章讨论的重点。
栈和队列
我们上面提到数组是内存的一种实现,使用数组能够使编程更加高效,下面我们就来认识一下其他数据结构,通过这些数据结构也可以操作内存的读写。
栈
栈(stack)是一种很重要的数据结构,栈采用 LIFO(Last In First Out)即后入先出
的方式对内存进行操作。它就像一个大的收纳箱,你可以往里面放相同类型的东西,比如书,最先放进收纳箱的书在最下面,最后放进收纳箱的书在最上面,如果你想拿书的话, 必须从最上面开始取,否则是无法取出最下面的书籍的。
栈的数据结构就是这样,你把书籍压入收纳箱的操作叫做压入(push)
,你把书籍从收纳箱取出的操作叫做弹出(pop)
,它的模型图大概是这样
入栈相当于是增加操作,出栈相当于是删除操作,只不过叫法不一样。栈和内存不同,它不需要指定元素的地址。它的大概使用如下
// 压入数据
Push(123);
Push(456);
Push(789);
// 弹出数据
j = Pop();
k = Pop();
l = Pop();
在栈中,LIFO 方式表示栈的数组中所保存的最后面的数据(Last In)会被最先读取出来(First On)。
队列
队列
和栈很相似但又不同,相同之处在于队列也不需要指定元素的地址,不同之处在于队列是一种 先入先出(First In First Out)
的数据结构。队列在我们生活中的使用很像是我们去景区排队买票一样,第一个排队的人最先买到票,以此类推,俗话说: 先到先得。它的使用如下
// 往队列中写入数据
EnQueue(123);
EnQueue(456);
EnQueue(789);
// 从队列中读出数据
m = DeQueue();
n = DeQueue();
o = DeQueue();
向队列中写入数据称为 EnQueue()
入列,从队列中读出数据称为DeQueue()
。
与栈相对,FIFO 的方式表示队列中最先所保存的数据会优先被读取出来。
队列的实现一般有两种:顺序队列
和 循环队列
,我们上面的事例使用的是顺序队列,那么下面我们看一下循环队列的实现方式
**环形缓冲区
循环队列一般是以环状缓冲区(ring buffer)
的方式实现的,它是一种用于表示一个固定尺寸、头尾相连的缓冲区的数据结构,适合缓存数据流。假如我们要用 6 个元素的数组来实现一个环形缓冲区,这时可以从起始位置开始有序的存储数据,然后再按照存储时的顺序把数据读出。在数组的末尾写入数据后,后一个数据就会从缓冲区的头开始写。这样,数组的末尾和开头就连接了起来。
链表
下面我们来介绍一下链表
和 二叉树
,它们都是可以不用考虑索引的顺序就可以对元素进行读写的方式。通过使用链表,可以高效的对数据元素进行添加
和 删除
操作。而通过使用二叉树,则可以更高效的对数据进行检索
。
在实现数组的基础上,除了数据的值之外,通过为其附带上下一个元素的索引,即可实现链表
。数据的值和下一个元素的地址(索引)就构成了一个链表元素,如下所示
对链表的添加和删除都是非常高效的,我们来叙述一下这个添加和删除的过程,假如我们要删除地址为 p[2]
的元素,链表该如何变化呢?
我们可以看到,删除地址为 p[2] 的元素后,直接将链表剔除,并把 p[2] 前一个位置的元素 p[1] 的指针域
指向 p[2] 下一个链表元素的数据区即可。
那么对于新添加进来的链表,需要确定插入位置
,比如要在 p[2] 和 p[3] 之间插入地址为 p[6]
的元素,需要将 p[6] 的前一个位置 p[2] 的指针域改为 p[6] 的地址,然后将 p[6] 的指针域改为 p[3] 的地址即可。
链表的添加不涉及到数据的移动
,所以链表的添加和删除很快,而数组的添加设计到数据的移动,所以比较慢,通常情况下,使用数组来检索数据,使用链表来进行添加和删除操作。
二叉树
二叉树
也是一种检索效率非常高的数据结构,二叉树是指在链表的基础上往数组追加元素时,考虑到数组的大小关系,将其分成左右两个方向的表现形式。假如我们把 50 这个值保存到了数组中,那么,如果接下来要进行值写入的话,就需要和50比较,确定谁大谁小,比50数值大的放右边,小的放左边,下图是二叉树的比较示例
二叉树是由链表发展而来,因此二叉树在追加和删除元素方面也是同样有效的。
这一切的演变都是以内存为基础的。
程序员需要了解的硬核知识之磁盘
我们大家知道,计算机的五大基础部件是 存储器
、控制器
、运算器
、输入和输出设备
,其中从存储功能的角度来看,可以把存储器分为内存
和 磁盘
,内存我们上面的文章已经介绍过了,那么此篇文章我们来介绍一下磁盘以及内存和磁盘的关系。
认识磁盘
首先,磁盘和内存都具有存储功能,它们都是存储设备。区别在于,内存是通过电流
来实现存储;磁盘则是通过磁记录技术
来实现存储。内存是一种高速,造假昂贵的存储设备;而磁盘则是速度较慢、造假低廉的存储设备;电脑断电后,内存中的数据会丢失,而磁盘中的数据可以长久保留。内存是属于内部存储设备
,硬盘是属于 外部存储设备
。一般在我们的计算机中,磁盘和内存是相互配合共同作业的。
一般内存指的就是主存(负责存储CPU中运行的程序和数据);早起的磁盘指的是软磁盘(soft disk,简称软盘),就是下面这个
(2000年的时候我曾经我姑姑家最早的计算机中见到过这个,当时还不知道这是啥,现在知道了。)
如今常用的磁盘是硬磁盘(hard disk,简称硬盘),就是下面这个
程序不读入内存就无法运行
在了解磁盘前,还需要了解一下内存的运行机制是怎样的,我们的程序被保存在存储设备中,通过使用 CPU 读入来实现程序指令的执行。这种机制称为存储程序方式
,现在看来这种方式是理所当然的,但在以前程序的运行都是通过改变计算机的布线来读写指令的。
计算机最主要的存储部件是内存和磁盘。磁盘中存储的程序必须加载到内存中才能运行,在磁盘中保存的程序是无法直接运行的,这是因为负责解析和运行程序内容的 CPU 是需要通过程序计数器来指定内存地址从而读出程序指令的。
磁盘构件
磁盘缓存
我们上面提到,磁盘往往和内存是互利共生的关系,相互协作,彼此持有良好的合作关系。每次内存都需要从磁盘中读取数据,必然会读到相同的内容,所以一定会有一个角色负责存储我们经常需要读到的内容。 我们大家做软件的时候经常会用到缓存技术
,那么硬件层面也不例外,磁盘也有缓存,磁盘的缓存叫做磁盘缓存
。
磁盘缓存指的是把从磁盘中读出的数据存储到内存的方式,这样一来,当接下来需要读取相同的内容时,就不会再通过实际的磁盘,而是通过磁盘缓存来读取。某一种技术或者框架的出现势必要解决某种问题的,那么磁盘缓存就大大改善了磁盘访问的速度。
Windows 操作系统提供了磁盘缓存技术,不过,对于大部分用户来说是感受不到磁盘缓存的,并且随着计算机的演进,对硬盘的访问速度也在不断演进,实际上磁盘缓存到 Windows 95/98 就已经不怎么使用了。
把低速设备的数据保存在高速设备中,需要时可以直接将其从高速设备中读出,这种缓存方式在web中应用比较广泛,web 浏览器是通过网络来获取远程 web 服务器的数据并将其显示出来。因此,在读取较大的图片的时候,会耗费不少时间,这时 web 浏览器可以把获取的数据保存在磁盘中,然后根据需要显示数据,再次读取的时候就不用重新加载了。
虚拟内存
虚拟内存
是内存和磁盘交互的第二个媒介。虚拟内存是指把磁盘的一部分作为假想内存
来使用。这与磁盘缓存是假想的磁盘(实际上是内存)相对,虚拟内存是假想的内存(实际上是磁盘)。
虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续可用
的内存(一个完整的地址空间),但是实际上,它通常被分割成多个物理碎片,还有部分存储在外部磁盘管理器上,必要时进行数据交换。
计算机中的程序都要通过内存来运行,如果程序占用内存很大,就会将内存空间消耗殆尽。为了解决这个问题,WINDOWS 操作系统运用了虚拟内存技术,通过拿出一部分硬盘来当作内存使用,来保证程序耗尽内存仍然有可以存储的空间。虚拟内存在硬盘上的存在形式就是 PAGEFILE.SYS
这个页面文件。
通过借助虚拟内存,在内存不足时仍然可以运行程序。例如,在只剩 5MB 内存空间的情况下仍然可以运行 10MB 的程序。由于 CPU 只能执行加载到内存中的程序,因此,虚拟内存的空间就需要和内存中的空间进行置换(swap)
,然后运行程序。
虚拟内存与内存的交换方式
刚才我们提到虚拟内存需要和内存中的部分内容做置换才可让 CPU 继续执行程序,那么做置换的方式是怎样的呢?又分为哪几种方式呢?
虚拟内存的方法有分页式
和 分段式
两种。Windows 采用的是分页式。该方式是指在不考虑程序构造的情况下,把运行的程序按照一定大小的页进行分割,并以页
为单位进行置换。在分页式中,我们把磁盘的内容读到内存中称为 Page In
,把内存的内容写入磁盘称为 Page Out
。Windows 计算机的页大小为 4KB ,也就是说,需要把应用程序按照 4KB 的页来进行切分,以页(page)为单位放到磁盘中,然后进行置换。
为了实现内存功能,Windows 在磁盘上提供了虚拟内存使用的文件(page file,页文件)。该文件由 Windows 生成和管理,文件的大小和虚拟内存大小相同,通常大小是内存的 1 – 2 倍。
节约内存
Windows 是以图形界面为基础的操作系统。它的前身是 MS-DOC
,最初的版本可以在 128kb 的内存上运行程序,但是现在想要 Windows 运行流畅的花至少要需要 512MB 的内存,但通常往往是不够的。
也许许多人认为可以使用虚拟内存来解决内存不足的情况,而虚拟内存确实能够在内存不足的时候提供补充,但是使用虚拟内存的 Page In 和 Page Out 通常伴随着低速的磁盘访问,这是一种得不偿失的表现。所以虚拟内存无法从根本上解决内存不足的情况。
为了从根本上解决内存不足的情况,要么是增加内存的容量,加内存条;要么是优化应用程序,使其尽可能变小。第一种建议往往需要衡量口袋的银子,所以我们只关注第二种情况。
注意:以下的篇幅会涉及到 C 语言的介绍,是每个程序员(不限语言)都需要知道和了解的知识。
通过 DLL 文件实现函数共有
DLL(Dynamic Link Library)
文件,是一种动态链接库
文件,顾名思义,是在程序运行时可以动态加载 Library(函数和数据的集合)
的文件。此外,多个应用可以共有同一个 DLL 文件。而通过共有一个 DLL 文件则可以达到节约内存的效果。
例如,假设我们编写了一个具有某些处理功能的函数 MyFunc()
。应用 A 和 应用 B 都需要用到这个函数,然后在各自的应用程序中内置 MyFunc()(这个称为Static Link,静态链接)后同时运行两个应用,内存中就存在了同一个函数的两个程序,这会造成资源浪费。
为了改变这一点,使用 DLL 文件而不是应用程序的执行文件(EXE文件)。因为同一个 DLL 文件内容在运行时可以被多个应用共有,因此内存中存在函数 MyFunc()的程序就只有一个
Windows 操作系统其实就是无数个 DLL 文件的集合体。有些应用在安装时,DLL文件也会被追加。应用程序通过这些 DLL 文件来运行,既可以节约内存,也可以在不升级 EXE 文件的情况下,通过升级 DLL 文件就可以完成更新。
通过调用 _stdcall 来减少程序文件的大小
通过调用 _stdcall
来减小程序文件的方法,是用 C 语言编写应用时可以利用的高级技巧。我们来认识一下什么是 _stdcall。
_stdcall 是 standard call(标准调用)
的缩写。Windows 提供的 DLL 文件内的函数,基本上都是通过 _stdcall 调用方式来完成的,这主要是为了节约内存。另一方面,用 C 语言编写的程序默认都不是 _stdcall 。C 语言特有的调用方式称为 C 调用
。C 语言默认不使用 _stdcall 的原因是因为 C 语言所对应的函数传入参数是可变的,只有函数调用方才能知道到底有多少个参数,在这种情况下,栈的清理作业便无法进行。不过,在 C 语言中,如果函数的参数和数量固定的话,指定 _stdcall 是没有任何问题的。
C 语言和 Java 最主要的区别之一在于 C 语言需要人为控制释放内存空间
C 语言中,在调用函数后,需要人为执行栈清理指令。把不需要的数据从接收和传递函数的参数时使用的内存上的栈区域中清理出去的操作叫做 栈清理处理
。
例如如下代码
// 函数调用方
void main(){
int a;
a = MyFunc(123,456);
}
// 被调用方
int MyFunc(int a,int b){
...
}
代码中,从 main 主函数调用到 MyFunc() 方法,按照默认的设定,栈的清理处理会附加在 main 主函数这一方。在同一个程序中,有可能会多次调用,导致 MyFunc() 会进行多次清理,这就会造成内存的浪费。
汇编之后的代码如下
push 1C8h // 将参数 456( = 1C8h) 存入栈中
push 7Bh // 将参数 123( = 7Bh) 存入栈中
call @LTD+15 (MyFunc)(00401014) // 调用 MyFunc 函数
add esp,8 // 运行栈清理
C 语言通过栈来传递函数的参数,使用 push
是往栈中存入数据的指令,pop
是从栈中取出数据的指令。32 位 CPU 中,1次 push 指令可以存储 4 个字节(32 位)的数据。上述代码由于进行了两次 push 操作,所以存储了 8 字节的数据。通过 call
指令来调用函数,调用完成后,栈中存储的数据就不再需要了。于是就通过 add esp,8 这个指令,使存储着栈数据的 esp 寄存器前进 8 位(设定为指向高 8 位字节的地址),来进行数据清理。由于栈是在各种情况下都可以利用的内存领域,因此使用完毕后有必要将其恢复到原始状态。上述操作就是执行栈的清理工作。另外,在 C 语言中,函数的返回值,是通过寄存器而非栈来返回的。
栈执行清理工作,在调用方法处执行清理工作和在反复调用方法处执行清理工作不同,使用 _stdcall
标准调用的方式称为反复调用方法,在这种情况下执行栈清理开销比较小。
磁盘的物理结构
之前我们介绍了CPU、内存的物理结构,现在我们来介绍一下磁盘的物理结构。磁盘的物理结构指的是磁盘存储数据的形式。
磁盘是通过其物理表面划分成多个空间来使用的。划分的方式有两种:可变长方式
和 扇区方式
。前者是将物理结构划分成长度可变的空间,后者是将磁盘结构划分为固定长度的空间。一般 Windows 所使用的硬盘和软盘都是使用扇区这种方式。扇区中,把磁盘表面分成若干个同心圆的空间就是 磁道
,把磁道按照固定大小的存储空间划分而成的就是 扇区
扇区
是对磁盘进行物理读写的最小单位。Windows 中使用的磁盘,一般是一个扇区 512 个字节。不过,Windows 在逻辑方面对磁盘进行读写的单位是扇区整数倍簇。根据磁盘容量不同功能,1簇可以是 512 字节(1 簇 = 1扇区)、1KB(1簇 = 2扇区)、2KB、4KB、8KB、16KB、32KB( 1 簇 = 64 扇区)。簇和扇区的大小是相等的。
不管是硬盘还是软盘,不同的文件是不能存储在同一簇中的,否则就会导致只有一方的文件不能删除。所以,不管多小的文件,都会占用 1 簇的空间。这样一来,所有的文件都会占用 1 簇的整数倍的空间。
我们使用软盘做实验会比较简单一些,我们先对软盘进行格式化,格式化后的软盘空间如下
接下来,我们保存一个 txt
文件,并在文件输入一个字符,这时候文件其实只占用了一个字节,但是我们看一下磁盘的属性却占用了 512 字节
然后我们继续写入一些东西,当文件大小到达 512 个字节时,已用空间也是 512 字节,但是当我们继续写入一个字符时,我们点开属性会发现磁盘空间会变为 1024 个字节(= 2 簇),通过这个实验我们可以证明磁盘是以簇为单位来保存的。
程序员需要了解的硬核知识之二进制
我们都知道,计算机的底层都是使用二进制数据进行数据流传输的,那么为什么会使用二进制表示计算机呢?或者说,什么是二进制数呢?在拓展一步,如何使用二进制进行加减乘除?二进制数如何表示负数呢?本文将一一为你揭晓。
为什么用二进制表示
我们大家知道,计算机内部是由IC电子元件组成的,其中 CPU
和 内存
也是 IC 电子元件的一种,CPU和内存图如下
CPU 和 内存使用IC电子元件作为基本单元,IC电子元件有不同种形状,但是其内部的组成单元称为一个个的引脚。有人说CPU 和 内存内部都是超大规模集成电路,其实IC 就是集成电路(Integrated Circuit)。
IC元件两侧排列的四方形块就是引脚,IC的所有引脚,只有两种电压: 0V
和 5V
,IC的这种特性,也就决定了计算机的信息处理只能用 0 和 1 表示,也就是二进制来处理。一个引脚可以表示一个 0 或 1 ,所以二进制的表示方式就变成 0、1、10、11、100、101等,虽然二进制数并不是专门为 引脚 来设计的,但是和 IC引脚的特性非常吻合。
计算机的最小集成单位为 位
,也就是 比特(bit)
,二进制数的位数一般为 8位、16位、32位、64位,也就是 8 的倍数,为什么要跟 8 扯上关系呢? 因为在计算机中,把 8 位二进制数称为 一个字节, 一个字节有 8 位,也就是由 8个bit构成。
为什么1个字节等于8位呢?因为 8 位能够涵盖所有的字符编码,这个记住就可以了。
字节是最基本的计量单位,位是最小单位。
用字节处理数据时,如果数字小于存储数据的字节数 ( = 二进制的位数),那么高位就用 0 填补,高位和数学的数字表示是一样的,左侧表示高位,右侧表示低位。比如 这个六位数用二进制数来表示就是 100111
,只有6位,高位需要用 0 填充,填充完后是 00100111
,占一个字节,如果用 16 位表示 就是 0000 0000 0010 0111
占用两个字节。
我们一般口述的 32 位和 64位的计算机一般就指的是处理位数,32 位一次可以表示 4个字节,64位一次可以表示8个字节的二进制数。
我们一般在软件开发中用十进制数表示的逻辑运算等,也会被计算机转换为二进制数处理。对于二进制数,计算机不会区分他是 图片、音频文件还是数字,这些都是一些数据的结合体。
什么是二进制数
那么什么是二进制数呢?为了说明这个问题,我们先把 00100111
这个数转换为十进制数看一下,二进制数转换为十进制数,直接将各位置上的值 * 位权即可,那么我们将上面的数值进行转换
也就是说,二进制数代表的 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 进位即可。二进制数的运算,也是计算机程序所特有的运算,因此了解二进制的运算是必须要掌握的。
首先我们来介绍移位
运算,移位运算是指将二进制的数值的各个位置上的元素坐左移和右移操作,见下图
上述例子中还是以 39 为例,我们先把十进制的39 转换为二进制的 0010 0111
,然后向左移位 <<
一个字节,也就变成了 0100 1110
,那么再把此二进制数转换为十进制数就是上面的78, 十进制的78 竟然是 十进制39 的2倍关系。我们在让 0010 0111
左移两位,也就是 1001 1100
,得出来的值是 156,相当于扩大了四倍!
因此你可以得出来此结论,左移相当于是数值扩大的操作,那么右移 >>
呢?按理说右移应该是缩小 1/2,1/4 倍,但是39 缩小二倍和四倍不就变成小数了吗?这个怎么表示呢?请看下一节
便于计算机处理的补数
刚才我们没有介绍右移的情况,是因为右移之后空出来的高位数值,有 0 和 1 两种形式。要想区分什么时候补0什么时候补1,首先就需要掌握二进制数表示负数
的方法。
二进制数中表示负数值时,一般会把最高位作为符号来使用,因此我们把这个最高位当作符号位。 符号位是 0 时表示正数
,是 1 时表示 负数
。那么 -1 用二进制数该如何表示呢?可能很多人会这么认为: 因为 1 的二进制数是 0000 0001
,最高位是符号位,所以正确的表示 -1 应该是 1000 0001
,但是这个答案真的对吗?
计算机世界中是没有减法的,计算机在做减法的时候其实就是在做加法,也就是用加法来实现的减法运算。比如 100 – 50 ,其实计算机来看的时候应该是 100 + (-50),为此,在表示负数的时候就要用到二进制补数
,补数就是用正数来表示的负数。
为了获得补数
,我们需要将二进制的各数位的数值全部取反,然后再将结果 + 1 即可,先记住这个结论,下面我们来演示一下。
具体来说,就是需要先获取某个数值的二进制数,然后对二进制数的每一位做取反操作(0 —> 1 , 1 —> 0),最后再对取反后的数 +1 ,这样就完成了补数的获取。
补数的获取,虽然直观上不易理解,但是逻辑上却非常严谨,比如我们来看一下 1 – 1 的这个过程,我们先用上面的这个 1000 0001
(它是1的补数,不知道的请看上文,正确性先不管,只是用来做一下计算)来表示一下
奇怪,1 – 1 会变成 130 ,而不是0,所以可以得出结论 1000 0001
表示 -1 是完全错误的。
那么正确的该如何表示呢?其实我们上面已经给出结果了,那就是 1111 1111
,来论证一下它的正确性
我们可以看到 1 – 1 其实实际上就是 1 + (-1),对 -1 进行上面的取反 + 1 后变为 1111 1111
, 然后与 1 进行加法运算,得到的结果是九位的 1 0000 0000
,结果发生了溢出
,计算机会直接忽略掉溢出位,也就是直接抛掉 最高位 1 ,变为 0000 0000
。也就是 0,结果正确,所以 1111 1111
表示的就是 -1 。
所以负数的二进制表示就是先求其补数,补数的求解过程就是对原始数值的二进制数各位取反,然后将结果 + 1,
当然,结果不为 0 的运算同样也可以通过补数求得正确的结果。不过,有一点需要注意,当运算结果为负的时候,计算结果的值也是以补数的形式出现的,比如 3 – 5 这个运算,来看一下解析过程
3 – 5 的运算,我们按着上面的思路来过一遍,计算出来的结果是 1111 1110
,我们知道,这个数值肯定表示负数,但是负数无法直接用十进制表示,需要对其取反+ 1,算出来的结果是 2,因为 1111 1110
的高位是 1,所以最终的结果是 -2。
编程语言的数据类型中,有的可以处理负数,有的不可以。比如 C语言中不能处理负数的 unsigned short
类型,也有能处理负数的short
类型 ,都是两个字节的变量,它们都有 2 的十六次幂种值,但是取值范围不一样,short 类型的取值范围是 -32768 – 32767 , unsigned short 的取值范围是 0 – 65536。
仔细思考一下补数的机制,就能明白 -32768 比 32767 多一个数的原因了,最高位是 0 的正数有 0 ~ 32767 共 32768 个,其中包括0。最高位是 1 的负数,有 -1 ~ -32768 共 32768 个,其中不包含0。0 虽然既不是正数也不是负数,但是考虑到其符号位,就将其归为了正数。
算数右移和逻辑右移的区别
在了解完补数后,我们重新考虑一下右移这个议题,右移在移位后空出来的最高位有两种情况 0 和 1
。当二进制数的值表示图形模式而非数值时,移位后需要在最高位补0,类似于霓虹灯向右平移的效果,这就被称为逻辑右移
。
将二进制数作为带符号的数值进行右移运算时,移位后需要在最高位填充移位前符号位的值( 0 或 1)。这就被称为算数右移
。如果数值使用补数表示的负数值,那么右移后在空出来的最高位补 1,就可以正确的表示 1/2,1/4,1/8
等的数值运算。如果是正数,那么直接在空出来的位置补 0 即可。
下面来看一个右移的例子。将 -4 右移两位,来各自看一下移位示意图
如上图所示,在逻辑右移的情况下, -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 填充高位即可。
逻辑运算的窍门
掌握逻辑和运算的区别是:将二进制数表示的信息作为四则运算的数值来处理就是算数
,像图形那样,将数值处理为单纯的 0
和 1
的罗列就是逻辑
计算机能够处理的运算,大体可分为逻辑运算和算数运算,算数运算
指的是加减乘除四则运算;逻辑运算
指的是对二进制各个数位的 0 和 1分别进行处理的运算,包括逻辑非(NOT运算)、逻辑与(AND运算)、逻辑或(OR运算)和逻辑异或(XOR运算)四种。
逻辑非
指的是将 0 变成 1,1 变成 0 的取反操作逻辑与
指的是"两个都是 1 时,运算结果才是 1,其他情况下是 0"逻辑或
指的是"至少有一方是 1 时,运算结果为 1,其他情况下运算结果都是 0"逻辑异或
指的是 "其中一方是 1,另一方是 0时运算结果才是 1,其他情况下是 0"
掌握逻辑运算的窍门,就是要摒弃二进制数表示数值这一个想法。大家不要把二进制数表示的值当作数值,应该把它看成是 开关上的 ON/OFF
。
程序员需要了解的硬核知识之操作系统和应用
利用计算机运行程序大部分都是为了提高处理效率。例如,Microsoft Word 这样的文字处理软件,是用来提高文本文件处理效率的程序,Microsoft Excel 等表格计算软件,是用来提高账本处理效率的程序。这种为了提高特定处理效率的程序统称为 应用
程序员的工作就是编写各种各样的应用来提高工作效率,程序员一般不编写操作系统,但是程序员编写的应用离不开操作系统,此篇文章我们就针对 Windows 操作系统来说明一下操作系统和应用之间的关系。
操作系统功能的历史
操作系统
其实也是一种软件,任何新事物的出现肯定都有它的历史背景,那么操作系统也不是凭空出现的,肯定有它的历史背景。
在计算机尚不存在操作系统的年代,完全没有任何程序,人们通过各种按钮
来控制计算机,这一过程非常麻烦。于是,有人开发出了仅具有加载和运行功能的监控程序
,这就是操作系统的原型。通过事先启动监控程序,程序员可以根据需要将各种程序加载到内存中运行。虽然仍旧比较麻烦,但比起在没有任何程序的状态下进行开发,工作量得到了很大的缓解。
随着时代的发展,人们在利用监控程序编写程序的过程中发现很多程序都有公共的部分。例如,通过键盘进行文字输入,显示器进行数据展示等,如果每编写一个新的应用程序都需要相同的处理的话,那真是太浪费时间了。因此,基本的输入输出部分的程序就被追加到了监控程序中。初期的操作系统就是这样诞生了。
类似的想法可以共用,人们又发现有更多的应用程序可以追加到监控程序中,比如硬件控制程序
,编程语言处理器(汇编、编译、解析)
以及各种应用程序等,结果就形成了和现在差异不大的操作系统,也就是说,其实操作系统是多个程序的集合体。
我在 程序员需要了解的硬核知识之CPU这篇文章中提到了汇编语言,这里简单再提一下。
汇编语言是一种低级语言,也被称为
符号语言
。汇编语言是第二代计算机语言,在汇编语言中,用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址。用一些容易理解和记忆的字母,单词来代替一个特定的指令,比如:用ADD
代表数字逻辑上的加减,MOV
代表数据传递等等,通过这种方法,人们很容易去阅读已经完成的程序或者理解程序正在执行的功能,对现有程序的bug修复以及运营维护都变得更加简单方便
可以说共用思想真是人类前进的一大步,对于解放生产力而言简直是太重要了。
要把操作系统放在第一位
对于程序员来说,程序员创造的不是硬件,而是各种应用程序,但是如果程序员只做应用不懂硬件层面的知识的话,是无法成为硬核程序员的。现在培训机构培养出了一批怎么用的人才,却没有培训出为什么这么做的人才,毕竟为什么
不是培训机构教的,而是学校教的,我很相信耗子叔说的话:学习没有速成这回事。言归正题。
在操作系统诞生之后,程序员不需要在硬件层面考虑问题,所以程序员的数量就增加了。哪怕自称对硬件一窍不通
的人也可能制作出一个有模有样的程序。不过,要想成为一个全面的程序员,有一点需要清楚的就是,掌握硬件的基本知识,并借助操作系统进行抽象化,可以大大提高编程效率。
下面就看一下操作系统是如何给开发人员带来便利的,在 Windows 操作系统下,用 C 语言制作一个具有表示当前时间功能的应用。time()
是用来取得当前日期和时间的函数,printf()
是把结果打印到显示器上的函数,如下:
#include <stdio.h>
#include <time.h>
void main(){
// 保存当前日期和时间信息
time_t tm;
// 取得当前的日期和时间
time(&tm);
// 在显示器上显示日期和时间
printf("%s\n", ctime(&tm));
}
读者可以自行运行程序查看结果,我们主要关注硬件在这段代码中做了什么事情
- 通过 time_t tm,为 time_t 类型的变量申请分配内存空间;
- 通过 time(&tm) ,将当前的日期和时间数据保存到变量的内存空间中
- 通过 printf("%s\n",ctime(&tm)), 把变量内存空间的内容输出到显示器上。
应用的可执行文件指的是,计算机的 CPU 可以直接解释并运行的本地代码,不过这些代码是无法直接控制硬件的,事实上,这些代码是通过操作系统来间接控制硬件的。变量中涉及到的内存分配情况,以及 time() 和 printf() 这些函数的运行结果,都不是面向硬件而是面向操作系统
的。操作系统收到应用发出的指令后,首先会对该指令进行解释,然后会对 时钟IC
和显示器用的 I/O 进行控制。
计算机中都安装有保存日期和时间的实时时钟(Real-time clock),上面提到的时钟IC 就是值该实时时钟。
系统调用和编程语言的移植性
操作系统控制硬件的功能,都是通过一些小的函数集合体的形式来提供的。这些函数以及调用函数的行为称为系统调用
,也就是通过应用进而调用操作系统的意思。在前面的程序中用到了 time()
以及 printf()
函数,这些函数内部也封装了系统调用。
C 语言等高级编程语言并不依存于特定的操作系统,这是因为人们希望不管是Windows
操作系统还是 Linux
操作系统都能够使用相同的源代码。因此,高级编程语言的机制就是,使用独自的函数名,然后在编译的时候将其转换为系统调用的方式(也有可能是多个系统调用的组合)。也就是说,高级语言编写的应用在编译后,就转换成了利用系统调用的本地代码。
不过,在高级语言中也存在直接调用系统调用的编程语言,不过,利用这种方式做成应用,移植性并不友好。
移植性:移植性指的是同样的程序在不同操作系统下运行时所花费的时间,时间越少证明移植性越好。
操作系统和高级编程语言使硬件抽象化
通过使用操作系统提供的系统调用,程序员不必直接编写控制硬件的程序,而且,通过使用高级编程语言,有时也无需考虑系统调用的存在,系统调用往往是自动触发的,操作系统和高级编程语言能够使硬件抽象化,这很了不起。
下面让我们看一个硬件抽象化的具体实例
#include <stdio.h>
void main(){
// 打开文件
FILE *fp = fopen("MyFile.txt","w");
// 写入文件
fputs("你好", fp);
// 关闭文件
fclose(fp);
}
上述代码使用 C 编写的程序,fputs()
是用来往文件中写入字符串的函数,fclose()
是用来关闭文件的函数。
上述应用在编译运行后,会向文件中写入 "你好" 字符串。文件是操作系统对磁盘空间的抽象化,就如同我们在 程序员需要了解的硬核知识之磁盘 这篇文章提到的一样,磁盘就如同树的年轮,磁盘的读写是以扇区为单位的,通过磁道来寻址,如果直接对硬件读写的话,那么就会变为通过向磁盘用的 I/O 指定扇区位置来对数据进行读写了。
但是,在上面代码中,扇区压根就没有出现过传递给 fopen() 函数的参数,是文件名 MyFile.txt
和指定文件写入的 w
。传递给 fputs() 的参数,是往文件中写入的字符串"你好" 和 fp,传递给 fclose() 的参数,也仅仅是 fp,也就是说磁盘通过打开文件这个操作,把磁盘抽象化了,打开文件这个操作就可以说是操作硬件的指令。
下面让我们来看一下代码清单中 fp 的功能,变量 fp 中被赋予的是 fopen() 函数的返回值,该值被称为文件指针
。应用打开文件后,操作系统就会自动申请分配用来管理文件读写的内存空间。内存地址可以通过 fopen() 函数的返回值获得。用 fopen() 打开文件后,接下来就是通过制定的文件指针进行操作,正因为如此,fputs() 和 fclose() 以及 fclose() 参数中都制定了文件指针。
由此我们可以得出一个结论,应用程序是通过系统调用,磁盘抽象来实现对硬盘的控制的。
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 时间切片
,这也是多线程多任务的核心。
提供网络功能和数据库功能
Windows 中,网络功能是作为标准功能提供的。数据库(数据库服务器)功能有时也会在后面追加。网络功能和数据库功能虽然并不是操作系统不可或缺的,但因为它们和操作系统很接近,所以被统称为中间件
而不是应用。意思是处于操作系统和应用的中间层,操作系统和中间件组合在一起,称为系统软件
。应用不仅可以利用操作系统,也可以利用中间件的功能。
相对于操作系统一旦安装就不能轻易更换,中间件可以根据需要进行更换,不过,对于大部分应用来说,更换中间件的话,会造成应用也随之更换,从这个角度来说,更换中间件也不是那么容易。
通过即插即用实现设备驱动的自动设定
即插即用(Plug-and-Play)
指的是新的设备连接(plug) 后就可以直接使用的机制,新设备连接计算机后,计算机就会自动安装和设定用来控制该设备的驱动程序
设备驱动是操作系统的一部分,提供了同硬件进行基本的输入输出的功能。键盘、鼠标、显示器、磁盘装置等,这些计算机中必备的硬件的设备驱动,一般都是随操作系统一起安装的。
有时 DLL 文件也会同设备驱动文件一起安装。这些 DLL 文件中存储着用来利用该新追加的硬件API,通过 API ,可以制作出运行该硬件的心应用。
程序员需要了解的硬核知识之压缩算法
认识压缩算法
我们想必都有过压缩
和 解压缩
文件的经历,当文件太大时,我们会使用文件压缩来降低文件的占用空间。比如微信上传文件的限制是100 MB,我这里有个文件夹无法上传,但是我解压完成后的文件一定会小于 100 MB,那么我的文件就可以上传了。
此外,我们把相机拍完的照片保存到计算机上的时候,也会使用压缩算法进行文件压缩,文件压缩的格式一般是JPEG
。
那么什么是压缩算法呢?压缩算法又是怎么定义的呢?在认识算法之前我们需要先了解一下文件是如何存储的
文件存储
文件是将数据存储在磁盘等存储媒介的一种形式。程序文件中最基本的存储数据单位是字节
。文件的大小不管是 xxxKB、xxxMB等来表示,就是因为文件是以字节 B = Byte
为单位来存储的。
文件就是字节数据的集合。用 1 字节(8 位)表示的字节数据有 256 种,用二进制表示的话就是 0000 0000 – 1111 1111 。如果文件中存储的数据是文字,那么该文件就是文本文件。如果是图形,那么该文件就是图像文件。在任何情况下,文件中的字节数都是连续存储
的。
压缩算法的定义
上面介绍了文件的集合体其实就是一堆字节数据的集合,那么我们就可以来给压缩算法下一个定义。
压缩算法(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 字节。如图
(这里有个问题需要读者思考一下:为什么 17 个字符的大小是 17 字节,而占用空间却很大呢? 这个问题此篇文章暂不讨论)
那么,如何才能压缩该文件呢?大家不妨也考虑一下,只要是能够使文件小于 17 字节,我们可以使用任何压缩算法。
最显而易见的一种压缩方式我觉得你已经想到了,就是把相同的字符去重化
,也就是 字符 * 重复次数
的方式进行压缩。所以上面文件压缩后就会变成下面这样
从图中我们可以看出,AAAAAABBCDDEEEEEF 的17个字符成功被压缩成了 A6B2C1D2E5F1 的12个字符,也就是 12 / 17 = 70%,压缩比为 70%,压缩成功了。
像这样,把文件内容用 数据 * 重复次数
的形式来表示的压缩方法成为 RLE(Run Length Encoding, 行程长度编码)
算法。RLE 算法是一种很好的压缩方法,经常用于压缩传真的图像等。因为图像文件的本质也是字节数据的集合体,所以可以用 RLE 算法进行压缩
RLE 算法的缺点
RLE 的压缩机制比较简单,所以 RLE 算法的程序也比较容易编写,所以使用 RLE 的这种方式更能让你体会到压缩思想,但是 RLE 只针对特定序列的数据管用,下面是 RLE 算法压缩汇总
文件类型 | 压缩前文件大小 | 压缩后文件大小 | 压缩比率 |
---|---|---|---|
文本文件 | 14862字节 | 29065字节 | 199% |
图像文件 | 96062字节 | 38328字节 | 40% |
EXE文件 | 24576字节 | 15198字节 | 62% |
通过上表可以看出,使用 RLE 对文本文件进行压缩后的数据不但没有减小反而增大了!几乎是压缩前的两倍!因为文本字符种连续的字符并不多见。
就像上面我们探讨的这样,RLE 算法只针对连续
的字节序列压缩效果比较好,假如有一连串不相同的字符该怎么压缩呢?比如说ABCDEFGHIJKLMNOPQRSTUVWXYZ
,26个英文字母所占空间应该是 26 个字节,我们用 RLE 压缩算法压缩后的结果为 A1B1C1D1E1F1G1H1I1J1K1L1M1N1O1P1Q1R1S1T1U1V1W1X1Y1Z1
,所占用 52 个字节,压缩完成后的容量没有减少反而增大了!这显然不是我们想要的结果,所以这种情况下就不能再使用 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位为一个字节来保存文件的。
哈夫曼算法比较复杂,在深入了解之前我们先吃点甜品
,了解一下 莫尔斯编码
,你一定看过美剧或者战争片的电影,在战争中的通信经常采用莫尔斯编码来传递信息,例如下面
接下来我们来讲解一下莫尔斯编码,下面是莫尔斯编码的示例
,大家把 1 看作是短点(嘀),把 11 看作是长点(嗒)即可。
莫尔斯编码一般把文本中出现最高频率的字符用短编码
来表示。如表所示,假如表示短点的位是 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 这种文本来说并不是效率最高的。
下面我们来看一下哈夫曼算法。哈夫曼算法是指,为各压缩对象文件分别构造最佳的编码体系,并以该编码体系为基础来进行压缩。因此,用什么样的编码(哈夫曼编码)对数据进行分割,就要由各个文件而定。用哈夫曼算法压缩过的文件中,存储着哈夫曼编码信息和压缩过的数据。
接下来,我们在对 AAAAAABBCDDEEEEEF 中的 A – F 这些字符,按照出现频率高的字符用尽量少的位数编码来表示
这一原则进行整理。按照出现频率从高到低的顺序整理后,结果如下,同时也列出了编码方案。
字符 | 出现频率 | 编码(方案) | 位数 |
---|---|---|---|
A | 6 | 0 | 1 |
E | 5 | 1 | 1 |
B | 2 | 10 | 2 |
D | 2 | 11 | 2 |
C | 1 | 100 | 3 |
F | 1 | 101 | 3 |
在上表的编码方案中,随着出现频率的降低,字符编码信息的数据位数也在逐渐增加,从最开始的 1位、2位依次增加到3位。不过这个编码体系是存在问题的,你不知道100这个3位的编码,它的意思是用 1、0、0这三个编码来表示 E、A、A 呢?还是用10、0来表示 B、A 呢?还是用100来表示 C 呢。
而在哈夫曼算法中,通过借助哈夫曼树的构造编码体系,即使在不使用字符区分符号的情况下,也可以构建能够明确进行区分的编码体系。不过哈夫曼树的算法要比较复杂,下面是一个哈夫曼树的构造过程。
自然界树的从根开始生叶的,而哈夫曼树则是叶生枝
哈夫曼树能够提升压缩比率
使用哈夫曼树之后,出现频率越高的数据所占用的位数越少,这也是哈夫曼树的核心思想。通过上图的步骤二可以看出,枝条连接数据时,我们是从出现频率较低的数据开始的。这就意味着出现频率低的数据到达根部的枝条也越多。而枝条越多则意味着编码的位数随之增加。
接下来我们来看一下哈夫曼树的压缩比率,用上图得到的数据表示 AAAAAABBCDDEEEEEF 为 000000000000 100100 110 101101 0101010101 111,40位 = 5 字节。压缩前的数据是 17 字节,压缩后的数据竟然达到了惊人的5 字节,也就是压缩比率 = 5 / 17 = 29% 如此高的压缩率,简直是太惊艳了。
大家可以参考一下,无论哪种类型的数据,都可以用哈夫曼树作为压缩算法
文件类型 | 压缩前 | 压缩后 | 压缩比率 |
---|---|---|---|
文本文件 | 14862字节 | 4119字节 | 28% |
图像文件 | 96062字节 | 9456字节 | 10% |
EXE文件 | 24576字节 | 4652字节 | 19% |
可逆压缩和非可逆压缩
最后,我们来看一下图像文件的数据形式。图像文件的使用目的通常是把图像数据输出到显示器、打印机等设备上。常用的图像格式有 : BMP
、JPEG
、TIFF
、GIF
格式等。
- BMP : 是使用 Windows 自带的画笔来做成的一种图像形式
- JPEG:是数码相机等常用的一种图像数据形式
- TIFF: 是一种通过在文件中包含"标签"就能够快速显示出数据性质的图像形式
- GIF: 是由美国开发的一种数据形式,要求色数不超过 256个
图像文件可以使用前面介绍的 RLE 算法和哈夫曼算法,因为图像文件在多数情况下并不要求数据需要还原到和压缩之前一摸一样的状态,允许丢失一部分数据。我们把能还原到压缩前状态的压缩称为 可逆压缩
,无法还原到压缩前状态的压缩称为非可逆压缩
。
一般来说,JPEG格式的文件是非可逆压缩,因此还原后有部分图像信息比较模糊。GIF 是可逆压缩
计算机网络运输层
运输层
位于应用层和网络层之间,是 OSI 分层体系中的第四层,同时也是网络体系结构的重要部分。运输层主要负责网络上的端到端通信。
运输层为运行在不同主机上的应用程序之间的通信起着至关重要的作用。下面我们就来一起探讨一下关于运输层的协议部分
运输层概述
计算机网络的运输层非常类似于高速公路,高速公路负责把人或者物品从一端运送到另一端,而计算机网络的运输层则负责把报文从一端运输到另一端,这个端指的就是 端系统
。在计算机网络中,任意一个可以交换信息的介质都可以称为端系统,比如手机、网络媒体、电脑、运营商等。
在运输层运输报文的过程中,会遵守一定的协议规范,比如一次传输的数据限制、选择什么样的运输协议等。运输层实现了让两个互不相关的主机进行逻辑通信
的功能,看起来像是让两个主机相连一样。
运输层协议是在端系统中实现的,而不是在路由器中实现的。路由只是做识别地址并转发的功能。这就比如快递员送快递一样,当然是要由地址的接受人也就是 xxx 号楼 xxx 单元 xxx 室的这个人来判断了!
TCP 如何判断是哪个端口的呢?
还记得数据包的结构吗,这里来回顾一下
数据包经过每层后,该层协议都会在数据包附上包首部,一个完整的包首部图如上所示。
在数据传输到运输层后,会为其附上 TCP 首部,首部包含着源端口号和目的端口号。
在发送端,运输层将从发送应用程序进程接收到的报文转化成运输层分组
,分组在计算机网络中也称为 报文段(segment)
。运输层一般会将报文段进行分割,分割成为较小的块,为每一块加上运输层首部并将其向目的地发送。
在发送过程中,可选的运输层协议(也就是交通工具) 主要有 TCP
和 UDP
,关于这两种运输协议的选择及其特性也是我们着重探讨的重点。
TCP 和 UDP 前置知识
在 TCP/IP 协议中能够实现传输层功能的,最具代表性的就是 TCP 和 UDP。提起 TCP 和 UDP ,就得先从这两个协议的定义说起。
TCP 叫做传输控制协议(TCP,Transmission Control Protocol)
,通过名称可以大致知道 TCP 协议有控制传输的功能,主要体现在其可控,可控就表示着可靠,确实是这样的,TCP 为应用层提供了一种可靠的、面向连接的服务,它能够将分组可靠的传输到服务端。
UDP 叫做 用户数据报协议(UDP,User Datagram Protocol)
,通过名称可以知道 UDP 把重点放在了数据报上,它为应用层提供了一种无需建立连接就可以直接发送数据报的方法。
怎么计算机网络中的术语对一个数据的描述这么多啊?
在计算机网络中,在不同层之间会有不同的描述。我们上面提到会将运输层的分组称为报文段,除此之外,还会将 TCP 中的分组也称为报文段,然而将 UDP 的分组称为数据报,同时也将网络层的分组称为数据报
但是为了统一,一般在计算机网络中我们统一称 TCP 和 UDP 的报文为 报文段
,这个就相当于是约定,到底如何称呼不用过多纠结啦。
套接字
在 TCP 或者 UDP 发送具体的报文信息前,需要先经过一扇 门
,这个门就是套接字(socket)
,套接字向上连接着应用层,向下连接着网络层。在操作系统中,操作系统分别为应用和硬件提供了接口(Application Programming Interface)
。而在计算机网络中,套接字同样是一种接口,它也是有接口 API 的。
使用 TCP 或 UDP 通信时,会广泛用到套接字的 API,使用这套 API 设置 IP 地址、端口号,实现数据的发送和接收。
现在我们知道了, Socket 和 TCP/IP 没有必然联系,Socket 的出现只是方便了 TCP/IP 的使用,如何方便使用呢?你可以直接使用下面 Socket API 的这些方法。
方法 | 描述 |
---|---|
create() | 创建一个 socket |
bind() | 套接字标识,一般用于绑定端口号 |
listen() | 准备接收连接 |
connect() | 准备充当发送者 |
accept() | 准备作为接收者 |
write() | 发送数据 |
read() | 接收数据 |
close() | 关闭连接 |
套接字类型
套接字的主要类型有三种,下面我们分别介绍一下
数据报套接字(Datagram sockets)
:数据报套接字提供一种无连接
的服务,而且并不能保证数据传输的可靠性。数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP( User DatagramProtocol)协议
进行数据的传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。流套接字(Stream sockets)
:流套接字用于提供面向连接、可靠的数据传输服务。能够保证数据的可靠性、顺序性。流套接字之所以能够实现可靠的数据服务,原因在于其使用了传输控制协议,即TCP(The Transmission Control Protocol)协议
原始套接字(Raw sockets)
: 原始套接字允许直接发送和接收 IP 数据包,而无需任何特定于协议的传输层格式,原始套接字可以读写内核没有处理过的 IP 数据包。
套接字处理过程
在计算机网络中,要想实现通信,必须至少需要两个端系统,至少需要一对两个套接字才行。下面是套接字的通信过程。
- socket 中的 API 用于创建通信链路中的端点,创建完成后,会返回描述该套接字的
套接字描述符
。
就像使用文件描述符来访问文件一样,套接字描述符用来访问套接字。
- 当应用程序具有套接字描述符后,它可以将唯一的名称绑定在套接字上,服务器必须绑定一个名称才能在网络中访问
- 在为服务端分配了 socket 并且将名称使用 bind 绑定到套接字上后,将会调用 listen api。
listen
表示客户端愿意等待连接的意愿,listen 必须在 accept api 之前调用。 - 客户端应用程序在流套接字(基于 TCP)上调用
connect
发起与服务器的连接请求。 - 服务器应用程序使用
accept
API 接受客户端连接请求,服务器必须先成功调用 bind 和 listen 后,再调用 accept api。 - 在流套接字之间建立连接后,客户端和服务器就可以发起 read/write api 调用了。
- 当服务器或客户端要停止操作时,就会调用
close
API 释放套接字获取的所有系统资源。
虽然套接字 API 位于应用程序层和传输层之间的通信模型中,但是套接字 API 不属于通信模型。套接字 API 允许应用程序与传输层和网络层进行交互。
在往下继续聊之前,我们先播放一个小插曲,简单聊一聊 IP。
聊聊 IP
IP
是Internet Protocol(网际互连协议)
的缩写,是 TCP/IP 体系中的网络层
协议。设计 IP 的初衷主要想解决两类问题
- 提高网络扩展性:实现大规模网络互联
- 对应用层和链路层进行解藕,让二者独立发展。
IP 是整个 TCP/IP 协议族的核心,也是构成互联网的基础。为了实现大规模网络的互通互联,IP 更加注重适应性、简洁性和可操作性,并在可靠性做了一定的牺牲。IP 不保证分组的交付时限和可靠性,所传送分组有可能出现丢失、重复、延迟或乱序等问题。
我们知道,TCP 协议的下一层就是 IP 协议层,既然 IP 不可靠,那么如何保证数据能够准确无误地到达呢?
这就涉及到 TCP 传输机制的问题了,我们后面聊到 TCP 的时候再说。
端口号
在聊端口号前,先来聊一聊文件描述以及 socket 和端口号的关系
为了方便资源的使用,提高机器的性能、利用率和稳定性等等原因,我们的计算机都有一层软件叫做操作系统,它用于帮我们管理计算机可以使用的资源,当我们的程序要使用一个资源的时候,可以向操作系统申请,再由操作系统为我们的程序分配和管理资源。通常当我们要访问一个内核设备或文件时,程序可以调用系统函数,系统就会为我们打开设备或文件,然后返回一个文件描述符fd(或称为ID,是一个整数),我们要访问该设备或文件,只能通过该文件描述符。可以认为该编号对应着打开的文件或设备。
而当我们的程序要使用网络时,要使用到对应的操作系统内核的操作和网卡设备,所以我们可以向操作系统申请,然后系统会为我们创建一个套接字 Socket,并返回这个 Socket 的ID,以后我们的程序要使用网络资源,只要向这个 Socket 的编号 ID 操作即可。而我们的每一个网络通信的进程至少对应着一个 Socket。向 Socket 的 ID 中写数据,相当于向网络发送数据,向 Socket 中读数据,相当于接收数据。而且这些套接字都有唯一标识符——文件描述符 fd。
端口号是 16
位的非负整数,它的范围是 0 – 65535 之间,这个范围会分为三种不同的端口号段,由 Internet 号码分配机构 IANA 进行分配
- 周知/标准端口号,它的范围是 0 – 1023
- 注册端口号,范围是 1024 – 49151
- 私有端口号,范围是 49152 – 6553
一台计算机上可以运行多个应用程序,当一个报文段到达主机后,应该传输给哪个应用程序呢?你怎么知道这个报文段就是传递给 HTTP 服务器而不是 SSH 服务器的呢?
是凭借端口号吗?当报文到达服务器时,是端口号来区分不同应用程序的,所以应该借助端口号来区分。
举个例子反驳一下 cxuan,假如到达服务器的两条数据都是由 80 端口发出的你该如何区分呢?或者说到达服务器的两条数据端口一样,协议不同,该如何区分呢?
所以仅凭端口号来确定某一条报文显然是不够的。
互联网上一般使用 源 IP 地址、目标 IP 地址、源端口号、目标端口号 来进行区分。如果其中的某一项不同,就被认为是不同的报文段。这些也是多路分解和多路复用
的基础。
确定端口号
在实际通信之前,需要先确定一下端口号,确定端口号的方法分为两种:
- 标准既定的端口号
标准既定的端口号是静态分配的,每个程序都会有自己的端口号,每个端口号都有不同的用途。端口号是一个 16 比特的数,其大小在 0 – 65535 之间,0 – 1023 范围内的端口号都是动态分配的既定端口号,例如 HTTP 使用 80 端口来标识,FTP 使用 21 端口来标识,SSH 使用 22 来标识。这类端口号有一个特殊的名字,叫做 周知端口号(Well-Known Port Number)
。
- 时序分配的端口号
第二种分配端口号的方式是一种动态分配法,在这种方法下,客户端应用程序可以完全不用自己设置端口号,凭借操作系统进行分配,操作系统可以为每个应用程序分配互不冲突的端口号。这种动态分配端口号的机制即使是同一个客户端发起的 TCP 连接,也能识别不同的连接。
多路复用和多路分解
我们上面聊到了在主机上的每个套接字都会分配一个端口号,当报文段到达主机时,运输层会检查报文段中的目的端口号,并将其定向到相应的套接字,然后报文段中的数据通过套接字进入其所连接的进程。下面我们来聊一下什么是多路复用和多路分解的概念。
多路复用和多路分解分为两种,即无连接
的多路复用(多路分解)和面向连接
的多路复用(多路分解)
无连接的多路复用和多路分解
开发人员会编写代码确定端口号是周知端口号还是时序分配的端口号。假如主机 A 中的一个 10637 端口要向主机 B 中的 45438 端口发送数据,运输层采用的是 UDP
协议,数据在应用层产生后,会在运输层中加工处理,然后在网络层将数据封装得到 IP 数据报,IP 数据包通过链路层尽力而为的交付给主机 B,然后主机 B 会检查报文段中的端口号判断是哪个套接字的,这一系列的过程如下所示
UDP 套接字就是一个二元组,二元组包含目的 IP 地址和目的端口号。
所以,如果两个 UDP 报文段有不同的源 IP 地址和/或相同的源端口号,但是具有相同的目的 IP 地址和目的端口号,那么这两个报文会通过套接字定位到相同的目的进程。
这里思考一个问题,主机 A 给主机 B 发送一个消息,为什么还需要知道源端口号呢?比如我给妹子表达出我对你有点意思的信息,妹子还需要知道这个信息是从我的哪个器官发出的吗?知道是我这个人对你有点意思不就完了?实际上是需要的,因为妹子如果要表达出她对你也有点意思,她是不是可能会亲你一口,那她得知道往哪亲吧?
这就是,在 A 到 B 的报文段中,源端口号会作为 返回地址
的一部分,即当 B 需要回发一个报文段给 A 时,B 需要从 A 到 B 中的源端口号取值,如下图所示
面向连接的多路复用与多路分解
如果说无连接的多路复用和多路分解指的是 UDP 的话,那么面向连接的多路复用与多路分解指的是 TCP 了,TCP 和 UDP 在报文结构上的差别是,UDP 是一个二元组而 TCP 是一个四元组,即源 IP 地址、目标 IP 地址、源端口号、目标端口号 ,这个我们上面也提到了。当一个 TCP 报文段从网络到达一台主机时,这个主机会根据这四个值拆解到对应的套接字上。
上图显示了面向连接的多路复用和多路分解的过程,图中主机 C 向主机 B 发起了两个 HTTP 请求,主机 A 向主机 C 发起了一个 HTTP 请求,主机 A、B、C 都有自己唯一的 IP 地址,当主机 C 发出 HTTP 请求后,主机 B 能够分解这两个 HTTP 连接,因为主机 C 发出请求的两个源端口号不同,所以对于主机 B 来说,这是两条请求,主机 B 能够进行分解。对于主机 A 和主机 C 来说,这两个主机有不同的 IP 地址,所以对于主机 B 来说,也能够进行分解。
UDP
终于,我们开始了对 UDP 协议的探讨,淦起!
UDP 的全称是 用户数据报协议(UDP,User Datagram Protocol)
,UDP 为应用程序提供了一种无需建立连接
就可以发送封装的 IP 数据包的方法。如果应用程序开发人员选择的是 UDP 而不是 TCP 的话,那么该应用程序相当于就是和 IP 直接打交道的。
从应用程序传递过来的数据,会附加上多路复用/多路分解的源和目的端口号字段,以及其他字段,然后将形成的报文传递给网络层,网络层将运输层报文段封装到 IP 数据报中,然后尽力而为的交付给目标主机。最关键的一点就是,使用 UDP 协议在将数据报传递给目标主机时,发送方和接收方的运输层实体间是没有握手
的。正因为如此,UDP 被称为是无连接
的协议。
UDP 特点
UDP 协议一般作为流媒体应用、语音交流、视频会议所使用的传输层协议,我们大家都知道的 DNS 协议底层也使用了 UDP 协议,这些应用或协议之所以选择 UDP 主要是因为以下这几点
速度快
,采用 UDP 协议时,只要应用进程将数据传给 UDP,UDP 就会将此数据打包进 UDP 报文段并立刻传递给网络层,然后 TCP 有拥塞控制的功能,它会在发送前判断互联网的拥堵情况,如果互联网极度阻塞,那么就会抑制 TCP 的发送方。使用 UDP 的目的就是希望实时性。无须建立连接
,TCP 在数据传输之前需要经过三次握手的操作,而 UDP 则无须任何准备即可进行数据传输。因此 UDP 没有建立连接的时延。如果使用 TCP 和 UDP 来比喻开发人员:TCP 就是那种凡事都要设计好,没设计不会进行开发的工程师,需要把一切因素考虑在内后再开干!所以非常靠谱
;而 UDP 就是那种上来直接干干干,接到项目需求马上就开干,也不管设计,也不管技术选型,就是干,这种开发人员非常不靠谱
,但是适合快速迭代开发,因为可以马上上手!无连接状态
,TCP 需要在端系统中维护连接状态
,连接状态包括接收和发送缓存、拥塞控制参数以及序号和确认号的参数,在 UDP 中没有这些参数,也没有发送缓存和接受缓存。因此,某些专门用于某种特定应用的服务器当应用程序运行在 UDP 上,一般能支持更多的活跃用户分组首部开销小
,每个 TCP 报文段都有 20 字节的首部开销,而 UDP 仅仅只有 8 字节的开销。
这里需要注意一点,并不是所有使用 UDP 协议的应用层都是
不可靠
的,应用程序可以自己实现可靠的数据传输,通过增加确认和重传机制。所以使用 UDP 协议最大的特点就是速度快。
UDP 报文结构
下面来一起看一下 UDP 的报文结构,每个 UDP 报文分为 UDP 报头和 UDP 数据区两部分。报头由 4 个 16 位长(2 字节)字段组成,分别说明该报文的源端口、目的端口、报文长度和校验值。
源端口号(Source Port)
:这个字段占据 UDP 报文头的前 16 位,通常包含发送数据报的应用程序所使用的 UDP 端口。接收端的应用程序利用这个字段的值作为发送响应的目的地址。这个字段是可选项,有时不会设置源端口号。没有源端口号就默认为 0 ,通常用于不需要返回消息的通信中。目标端口号(Destination Port)
: 表示接收端端口,字段长为 16 位长度(Length)
: 该字段占据 16 位,表示 UDP 数据报长度,包含 UDP 报文头和 UDP 数据长度。因为 UDP 报文头长度是 8 个字节,所以这个值最小为 8,最大长度为 65535 字节。校验和(Checksum)
:UDP 使用校验和来保证数据安全性,UDP 的校验和也提供了差错检测功能,差错检测用于校验报文段从源到目标主机的过程中,数据的完整性是否发生了改变。发送方的 UDP 对报文段中的 16 比特字的和进行反码运算,求和时遇到的位溢出都会被忽略,比如下面这个例子,三个 16 比特的数字进行相加
这些 16 比特的前两个和是
然后再将上面的结果和第三个 16 比特的数进行相加
最后一次相加的位会进行溢出,溢出位 1 要被舍弃,然后进行反码运算,反码运算就是将所有的 1 变为 0 ,0 变为 1。因此 1000 0100 1001 0101 的反码就是 0111 1011 0110 1010,这就是校验和,如果在接收方,数据没有出现差错,那么全部的 4 个 16 比特的数值进行运算,同时也包括校验和,如果最后结果的值不是 1111 1111 1111 1111 的话,那么就表示传输过程中的数据出现了差错。
下面来想一个问题,为什么 UDP 会提供差错检测的功能?
这其实是一种 端到端
的设计原则,这个原则说的是要让传输中各种错误发生的概率降低到一个可以接受的水平。
文件从主机A传到主机B,也就是说AB主机要通信,需要经过三个环节:首先是主机A从磁盘上读取文件并将数据分组成一个个数据包packet,,然后数据包通过连接主机A和主机B的网络传输到主机B,最后是主机B收到数据包并将数据包写入磁盘。在这个看似简单其实很复杂的过程中可能会由于某些原因而影响正常通信。比如:磁盘上文件读写错误、缓冲溢出、内存出错、网络拥挤等等这些因素都有可能导致数据包的出错或者丢失,由此可见用于通信的网络是不可靠的。
由于实现通信只要经过上述三个环节,那么我们就想是否在其中某个环节上增加一个检错纠错机制来用于对信息进行把关呢?
网络层肯定不能做这件事,因为网络层的最主要目的是增大数据传输的速率,网络层不需要考虑数据的完整性,数据的完整性和正确性交给端系统去检测就行了,因此在数据传输中,对于网络层只能要求其提供尽可能好的数据传输服务,而不可能寄希望于网络层提供数据完整性的服务。
UDP 不可靠的原因是它虽然提供差错检测的功能,但是对于差错没有恢复能力更不会有重传机制。
TCP
UDP 是一种没有复杂的控制,提供无连接通信服务的一种协议,换句话说,它将部分控制部分交给应用程序去处理,自己只提供作为传输层协议最基本的功能。
而与 UDP 不同的是,同样作为传输层协议,TCP 协议要比 UDP 的功能多很多。
TCP
的全称是 Transmission Control Protocol
,它被称为是一种面向连接(connection-oriented)
的协议,这是因为一个应用程序开始向另一个应用程序发送数据之前,这两个进程必须先进行握手
,握手是一个逻辑连接,并不是两个主机之间进行真实的握手。
这个连接是指各种设备、线路或者网络中进行通信的两个应用程序为了相互传递消息而专有的、虚拟的通信链路,也叫做虚拟电路。
一旦主机 A 和主机 B 建立了连接,那么进行通信的应用程序只使用这个虚拟的通信线路发送和接收数据就可以保证数据的传输,TCP 协议负责控制连接的建立、断开、保持等工作。
TCP 连接是全双工服务(full-duplex service)
的,全双工是什么意思?全双工指的是主机 A 与另外一个主机 B 存在一条 TCP 连接,那么应用程数据就可以从主机 B 流向主机 A 的同时,也从主机 A 流向主机 B。
TCP 只能进行 点对点(point-to-point)
连接,那么所谓的多播
,即一个主机对多个接收方发送消息的情况是不存在的,TCP 连接只能连接两个一对主机。
TCP 的连接建立需要经过三次握手,这个我们下面再说。一旦 TCP 连接建立后,主机之间就可以相互发送数据了,客户进程通过套接字传送数据流。数据一旦通过套接字后,它就由客户中运行的 TCP 协议所控制。
TCP 会将数据临时存储到连接的发送缓存(send buffer)
中,这个 send buffer 是三次握手之间设置的缓存之一,然后 TCP 在合适的时间将发送缓存中的数据发送到目标主机的接收缓存中,实际上,每一端都会有发送缓存和接收缓存,如下所示
主机之间的发送是以 报文段(segment)
进行的,那么什么是 Segement 呢?
TCP 会将要传输的数据流分为多个块(chunk)
,然后向每个 chunk 中添加 TCP 标头,这样就形成了一个 TCP 段也就是报文段。每一个报文段可以传输的长度是有限的,不能超过最大数据长度(Maximum Segment Size)
,俗称 MSS
。在报文段向下传输的过程中,会经过链路层,链路层有一个 Maximum Transmission Unit
,最大传输单元 MTU, 即数据链路层上所能通过最大数据包的大小,最大传输单元通常与通信接口有关。
那么 MSS 和 MTU 有啥关系呢?
因为计算机网络是分层考虑的,这个很重要,不同层的称呼不一样,对于传输层来说,称为报文段而对网络层来说就叫做 IP 数据包,所以,MTU 可以认为是网络层能够传输的最大 IP 数据包,而 MSS(Maximum segment size)可以认为是传输层的概念,也就是 TCP 数据包每次能够传输的最大量。
TCP 报文段结构
在简单聊了聊 TCP 连接后,下面我们就来聊一下 TCP 的报文段结构,如下图所示
TCP 报文段结构相比 UDP 报文结构多了很多内容。但是前两个 32 比特的字段是一样的。它们是 源端口号
和 目标端口号
,我们知道,这两个字段是用于多路复用和多路分解的。另外,和 UDP 一样,TCP 也包含校验和(checksum field)
,除此之外,TCP 报文段首部还有下面这些
-
32 比特的
序号字段(sequence number field)
和 32 比特的确认号字段(acknowledgment number field)
。这些字段被 TCP 发送方和接收方用来实现可靠的数据传输。 -
4 比特的
首部字段长度字段(header length field)
,这个字段指示了以 32 比特的字为单位的 TCP 首部长度。TCP 首部的长度是可变的,但是通常情况下,选项字段为空,所以 TCP 首部字段的长度是 20 字节。 -
16 比特的
接受窗口字段(receive window field)
,这个字段用于流量控制。它用于指示接收方能够/愿意接受的字节数量 -
可变的
选项字段(options field)
,这个字段用于发送方和接收方协商最大报文长度,也就是 MSS 时使用 -
6 比特的
标志字段(flag field)
,ACK
标志用于指示确认字段中的值是有效的,这个报文段包括一个对已被成功接收报文段的确认;RST
、SYN
、FIN
标志用于连接的建立和关闭;CWR
和ECE
用于拥塞控制;PSH
标志用于表示立刻将数据交给上层处理;URG
标志用来表示数据中存在需要被上层处理的 紧急 数据。紧急数据最后一个字节由 16 比特的紧急数据指针字段(urgeent data pointer field)
指出。一般情况下,PSH 和 URG 并没有使用。
TCP 的各种功能和特点都是通过 TCP 报文结构来体现的,在聊完 TCP 报文结构之后,我们下面就来聊一下 TCP 有哪些功能及其特点了。
序号、确认号实现传输可靠性
TCP 报文段首部中两个最重要的字段就是 序号
和 确认号
,这两个字段是 TCP 实现可靠性的基础,那么你肯定好奇如何实现可靠性呢?要了解这一点,首先我们得先知道这两个字段里面存了哪些内容吧?
一个报文段的序号就是数据流的字节编号 。因为 TCP 会把数据流分割成为一段一段的字节流,因为字节流本身是有序的,所以每一段的字节编号就是标示是哪一段的字节流。比如,主机 A 要给主机 B 发送一条数据。数据经过应用层产生后会有一串数据流,数据流会经过 TCP 分割,分割的依据就是 MSS,假设数据是 10000 字节,MSS 是 2000 字节,那么 TCP 就会把数据拆分成 0 – 1999 , 2000 – 3999 的段,依次类推。
所以,第一个数据 0 – 1999 的首字节编号就是 0 ,2000 – 3999 的首字节编号就是 2000 。。。。。。
然后,每个序号都会被填入 TCP 报文段首部的序号字段中。
至于确认号的话,会比序号要稍微麻烦一些。这里我们先拓展下几种通信模型。
- 单工通信:单工数据传输只支持数据在一个方向上传输;在同一时间只有一方能接受或发送信息,不能实现双向通信,比如广播、电视等。
- 双工通信是一种点对点系统,由两个或者多个在两个方向上相互通信的连接方或者设备组成。双工通信模型有两种:*全双工(FDX)和半双工(HDX)
- 全双工:在全双工系统中,连接双方可以相互通信,一个最常见的例子就是电话通信。全双工通信是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力。
- 半双工:在半双工系统中,连接双方可以彼此通信,但不能同时通信,比如对讲机,只有把按钮按住的人才能够讲话,只有一个人讲完话后另外一个人才能讲话。
单工、半双工、全双工通信如下图所示
TCP 是一种全双工的通信协议,因此主机 A 在向主机 B 发送消息的过程中,也在接受来自主机 B 的数据。主机 A 填充进报文段的确认号是期望从主机 B 收到的下一字节的序号。稍微有点绕,我们来举个例子看一下。比如主机 A 收到了来自主机 B 发送的编号为 0 – 999 字节的报文段,这个报文段会写入序号中,随后主机 A 期望能够从主机 B 收到 1000 – 剩下的报文段,因此,主机 A 发送到主机 B 的报文段中,它的确认号就是 1000 。
累积确认
这里再举出一个例子,比如主机 A 在发送 0 – 999 报文段后,期望能够接受到 1000 之后的报文段,但是主机 B 却给主机 A 发送了一个 1500 之后的报文段,那么主机 A 是否还会继续进行等待呢?
答案显然是会的,因为 TCP 只会确认流中至第一个丢失字节为止的字节,因为 1500 虽然属于 1000 之后的字节,但是主机 B 没有给主机 A 发送 1000 – 1499 之间的字节,所以主机 A 会继续等待。
在了解完序号和确认号之后,我们下面来聊一下 TCP 的发送过程。下面是一个正常的发送过程
TCP 通过肯定的确认应答(ACK)
来实现可靠的数据传输,当主机 A将数据发出之后会等待主机 B 的响应。如果有确认应答(ACK),说明数据已经成功到达对端。反之,则数据很可能会丢失。
如下图所示,如果在一定时间内主机 A 没有等到确认应答,则认为主机 B 发送的报文段已经丢失,并进行重发。
主机 A 给主机 B 的响应可能由于网络抖动等原因无法到达,那么在经过特定的时间间隔后,主机 A 将重新发送报文段。
主机 A 没有收到主机 B 的响应还可能是因为主机 B 在发送给主机 A 的过程中丢失。
如上图所示,由主机 B 返回的确认应答,由于网络拥堵等原因在传送的过程中丢失,并没有到达主机 A。主机 A 会等待一段时间,如果在这段时间内主机 A 仍没有等到主机 B 的响应,那么主机 A 会重新发送报文段。
那么现在就存在一个问题,如果主机 A 给主机 B 发送了一个报文段后,主机 B 接受到报文段发送响应,此刻由于网络原因,这个报文段并未到达,等到一段时间后主机 A 重新发送报文段,然后此时主机 B 发送的响应在主机 A 第二次发送后失序到达主机 A,那么主机 A 应该如何处理呢?
TCP RFC 并未为此做任何规定,也就是说,我们可以自己决定如何处理失序到达的报文段。一般处理方式有两种
- 接收方立刻丢弃失序的报文段
- 接收方接受时许到达的报文段,并等待后续的报文段
一般来说通常采取的做法是第二种。
传输控制
利用窗口控制提高速度
前面我们介绍了 TCP 是以数据段的形式进行发送,如果经过一段时间内主机 A 等不到主机 B 的响应,主机 A 就会重新发送报文段,接受到主机 B 的响应,再会继续发送后面的报文段,我们现在看到,这一问一答的形式还存在许多条件,比如响应未收到、等待响应等,那么对崇尚性能的互联网来说,这种形式的性能应该不会很高。
那么如何提升性能呢?
为了解决这个问题,TCP 引入了 窗口
这个概念,即使在往返时间较长、频次很多的情况下,它也能控制网络性能的下降,听起来很牛批,那它是如何实现的呢?
如下图所示
我们之前每次请求发送都是以报文段的形式进行的,引入窗口后,每次请求都可以发送多个报文段,也就是说一个窗口可以发送多个报文段。窗口大小就是指无需等待确认应答就可以继续发送报文段的最大值。
在这个窗口机制中,大量使用了 缓冲区
,通过对多个段同时进行确认应答的功能。
如下图所示,发送报文段中高亮部分即是我们提到的窗口,在窗口内,即是没有收到确认应答也可以把请求发送出去。不过,在整个窗口的确认应答没有到达之前,如果部分报文段丢失,那么主机 A 将仍会重传。为此,主机 A 需要设置缓存来保留这些需要重传的报文段,直到收到他们的确认应答。
在滑动窗口以外的部分是尚未发送的报文段和已经接受到的报文段,如果报文段已经收到确认则不可进行重发,此时报文段就可以从缓冲区中清除。
在收到确认的情况下,会将窗口滑动到确认应答中确认号的位置,如上图所示,这样可以顺序的将多个段同时发送,用以提高通信性能,这种窗口也叫做 滑动窗口(Sliding window)
。
窗口控制和重发
报文段的发送和接收,必然伴随着报文段的丢失和重发,窗口也是同样如此,如果在窗口中报文段发送过程中出现丢失怎么办?
首先我们先考虑确认应答没有返回的情况。在这种情况下,主机 A 发送的报文段到达主机 B,是不需要再进行重发的。这和单个报文段的发送不一样,如果发送单个报文段,即使确认应答没有返回,也要进行重发。
窗口在一定程度上比较大时,即使有少部分确认应答的丢失,也不会重新发送报文段。
我们知道,如果在某个情况下由于发送的报文段丢失,导致接受主机未收到请求,或者主机返回的响应未到达客户端的话,会经过一段时间重传报文。那么在使用窗口的情况下,报文段丢失会怎么样呢?
如下图所示,报文段 0 – 999 丢失后,但是主机 A 并不会等待,主机 A 会继续发送余下的报文段,主机 B 发送的确认应答却一直是 1000,同一个确认号的应答报文会被持续不断的返回,如果发送端主机在连续 3 次收到同一个确认应答后,就会将其所对应的数据重发,这种机制要比之前提到的超时重发更加高效,这种机制也被称为 高速重发控制
。这种重发的确认应答也被称为 冗余 ACK(响应)
。
主机 B 在没有接收到自己期望序列号的报文段时,会对之前收到的数据进行确认应答。发送端则一旦收到某个确认应答后,又连续三次收到同样的确认应答,那么就会认为报文段已经丢失。需要进行重发。使用这种机制可以提供更为快速的重发服务。
流量控制
前面聊的是传输控制,下面 cxuan 再和你聊一下 流量控制
。我们知道,在每个 TCP 连接的一侧主机都会有一个 socket 缓冲区,缓冲区会为每个连接设置接收缓存和发送缓存,当 TCP 建立连接后,从应用程序产生的数据就会到达接收方的接收缓冲区中,接收方的应用程序并不一定会马上读区缓冲区的数据,它需要等待操作系统分配时间片。如果此时发送方的应用程序产生数据过快,而接收方读取接受缓冲区的数据相对较慢的话,那么接收方中缓冲区的数据将会溢出
。
但是还好,TCP 有 流量控制服务(flow-control service)
用于消除缓冲区溢出的情况。流量控制是一个速度匹配服务,即发送方的发送速率与接受方应用程序的读取速率相匹配。
TCP 通过使用一个 接收窗口(receive window)
的变量来提供流量控制。接受窗口会给发送方一个指示到底还有多少可用的缓存空间。发送端会根据接收端的实际接受能力来控制发送的数据量。
接收端主机向发送端主机通知自己可以接收数据的大小,发送端会发送不超过这个限度的数据,这个大小限度就是窗口大小,还记得 TCP 的首部么,有一个接收窗口,我们上面聊的时候说这个字段用于流量控制。它用于指示接收方能够/愿意接受的字节数量。
那么只知道这个字段用于流量控制,那么如何控制呢?
发送端主机会定期发送一个窗口探测包
,这个包用于探测接收端主机是否还能够接受数据,当接收端的缓冲区一旦面临数据溢出的风险时,窗口大小的值也随之被设置为一个更小的值通知发送端,从而控制数据发送量。
下面是一个流量控制示意图
发送端主机根据接收端主机的窗口大小进行流量控制。由此也可以防止发送端主机一次发送过大数据导致接收端主机无法处理。
如上图所示,当主机 B 收到报文段 2000 – 2999 之后缓冲区已满,不得不暂时停止接收数据。然后主机 A 发送窗口探测包,窗口探测包非常小仅仅一个字节。然后主机 B 更新缓冲区接收窗口大小并发送窗口更新通知给主机 A,然后主机 A 再继续发送报文段。
在上面的发送过程中,窗口更新通知可能会丢失,一旦丢失发送端就不会发送数据,所以窗口探测包会随机发送,以避免这种情况发生。
连接管理
在继续介绍下面有意思的特性之前,我们先来把关注点放在 TCP 的连接管理
上,因为没有 TCP 连接,也就没有后续的一系列 TCP 特性什么事儿了。假设运行在一台主机上的进程想要和另一台主机上的进程建立一条 TCP 连接,那么客户中的 TCP 会使用下面这些步骤与服务器中的 TCP 建立连接。
-
首先,客户端首先向服务器发送一个特殊的 TCP 报文段。这个报文段首部不包含应用层数据,但是在报文段的首部中有一个
SYN 标志位
被置为 1。因此,这个特殊的报文段也可以叫做 SYN 报文段。然后,客户端随机选择一个初始序列号(client_isn)
,并将此数字放入初始 TCP SYN 段的序列号字段中,SYN 段又被封装在 IP 数据段中发送给服务器。 -
一旦包含 IP 数据段到达服务器后,服务端会从 IP 数据段中提取 TCP SYN 段,将 TCP 缓冲区和变量分配给连接,然后给客户端发送一个连接所允许的报文段。这个连接所允许的报文段也不包括任何应用层数据。然而,它却包含了三个非常重要的信息。
这些缓冲区和变量的分配使 TCP 容易受到称为 SYN 泛洪的拒绝服务攻击。
- 首先,SYN 比特被置为 1 。
- 然后,TCP 报文段的首部确认号被设置为
client_isn + 1
。 - 最后,服务器选择自己的
初始序号(server_isn)
,并将其放置到 TCP 报文段首部的序号字段中。
如果用大白话解释下就是,我收到了你发起建立连接的 SYN 报文段,这个报文段具有首部字段 client_isn。我同意建立该连接,我自己的初始序号是 server_isn。这个允许连接的报文段被称为
SYNACK 报文段
-
第三步,在收到 SYNACK 报文段后,客户端也要为该连接分配缓冲区和变量。客户端主机向服务器发送另外一个报文段,最后一个报文段对服务器发送的响应报文做了确认,确认的标准是客户端发送的数据段中确认号为 server_isn + 1,因为连接已经建立,所以 SYN 比特被置为 0 。以上就是 TCP 建立连接的三次数据段发送过程,也被称为
三次握手
。
一旦完成这三个步骤,客户和服务器主机就可以相互发送报文段了,在以后的每一个报文段中,SYN 比特都被置为 0 ,整个过程描述如下图所示
在客户端主机和服务端主机建立连接后,参与一条 TCP 连接的两个进程中的任何一个都能终止 TCP 连接。连接结束后,主机中的缓存和变量将会被释放。假设客户端主机想要终止 TCP 连接,它会经历如下过程
客户应用进程发出一个关闭命令,客户 TCP 向服务器进程发送一个特殊的 TCP 报文段,这个特殊的报文段的首部标志 FIN 被设置为 1 。当服务器收到这个报文段后,就会向发送方发送一个确认报文段。然后,服务器发送它自己的终止报文段,FIN 位被设置为 1 。客户端对这个终止报文段进行确认。此时,在两台主机上用于该连接的所有资源都被释放了,如下图所示
在一个 TCP 连接的生命周期内,运行在每台主机中的 TCP 协议都会在各种 TCP 状态(TCP State)
之间进行变化,TCP 的状态主要有 LISTEN、SYN-SEND、SYN-RECEIVED、ESTABLISHED、FIN-WAIT-1、FIN-WAIT-2、CLOSE-WAIT、CLOSING、LAST-ACK、TIME-WAIT 和 CLOSED 。这些状态的解释如下
LISTEN
: 表示等待任何来自远程 TCP 和端口的连接请求。SYN-SEND
: 表示发送连接请求后等待匹配的连接请求。SYN-RECEIVED
: 表示已接收并发送连接请求后等待连接确认,也就是 TCP 三次握手中第二步后服务端的状态ESTABLISHED
: 表示已经连接已经建立,可以将应用数据发送给其他主机
上面这四种状态是 TCP 三次握手所涉及的。
-
FIN-WAIT-1
: 表示等待来自远程 TCP 的连接终止请求,或者等待先前发送的连接终止请求的确认。 -
FIN-WAIT-2
: 表示等待来自远程 TCP 的连接终止请求。 -
CLOSE-WAIT
: 表示等待本地用户的连接终止请求。 -
CLOSING
: 表示等待来自远程 TCP 的连接终止请求确认。 -
LAST-ACK
: 表示等待先前发送给远程 TCP 的连接终止请求的确认(包括对它的连接终止请求的确认)。 -
TIME-WAIT
: 表示等待足够的时间以确保远程 TCP 收到其连接终止请求的确认。 -
CLOSED
: 表示连接已经关闭,无连接状态。
上面 7 种状态是 TCP 四次挥手,也就是断开链接所设计的。
TCP 的连接状态会进行各种切换,这些 TCP 连接的切换是根据事件进行的,这些事件由用户调用:OPEN、SEND、RECEIVE、CLOSE、ABORT 和 STATUS。涉及到 TCP 报文段的标志有 SYN、ACK、RST 和 FIN ,当然,还有超时。
我们下面加上 TCP 连接状态后,再来看一下三次握手和四次挥手的过程。
三次握手建立连接
下图画出了 TCP 连接建立的过程。假设图中左端是客户端主机,右端是服务端主机,一开始,两端都处于CLOSED(关闭)
状态。
- 服务端进程准备好接收来自外部的 TCP 连接,一般情况下是调用 bind、listen、socket 三个函数完成。这种打开方式被认为是
被动打开(passive open)
。然后服务端进程处于LISTEN
状态,等待客户端连接请求。 - 客户端通过
connect
发起主动打开(active open)
,向服务器发出连接请求,请求中首部同步位 SYN = 1,同时选择一个初始序号 sequence ,简写 seq = x。SYN 报文段不允许携带数据,只消耗一个序号。此时,客户端进入SYN-SEND
状态。 - 服务器收到客户端连接后,,需要确认客户端的报文段。在确认报文段中,把 SYN 和 ACK 位都置为 1 。确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。请注意,这个报文段也不能携带数据,但同样要消耗掉一个序号。此时,TCP 服务器进入
SYN-RECEIVED(同步收到)
状态。 - 客户端在收到服务器发出的响应后,还需要给出确认连接。确认连接中的 ACK 置为 1 ,序号为 seq = x + 1,确认号为 ack = y + 1。TCP 规定,这个报文段可以携带数据也可以不携带数据,如果不携带数据,那么下一个数据报文段的序号仍是 seq = x + 1。这时,客户端进入
ESTABLISHED (已连接)
状态 - 服务器收到客户的确认后,也进入
ESTABLISHED
状态。
TCP 建立一个连接需要三个报文段,释放一个连接却需要四个报文段。
四次挥手
数据传输结束后,通信的双方可以释放连接。数据传输结束后的客户端主机和服务端主机都处于 ESTABLISHED 状态,然后进入释放连接的过程。
TCP 断开连接需要历经的过程如下
-
客户端应用程序发出释放连接的报文段,并停止发送数据,主动关闭 TCP 连接。客户端主机发送释放连接的报文段,报文段中首部 FIN 位置为 1 ,不包含数据,序列号位 seq = u,此时客户端主机进入
FIN-WAIT-1(终止等待 1)
阶段。 -
服务器主机接受到客户端发出的报文段后,即发出确认应答报文,确认应答报文中 ACK = 1,生成自己的序号位 seq = v,ack = u + 1,然后服务器主机就进入
CLOSE-WAIT(关闭等待)
状态,这个时候客户端主机 -> 服务器主机这条方向的连接就释放了,客户端主机没有数据需要发送,此时服务器主机是一种半连接的状态,但是服务器主机仍然可以发送数据。 -
客户端主机收到服务端主机的确认应答后,即进入
FIN-WAIT-2(终止等待2)
的状态。等待客户端发出连接释放的报文段。 -
当服务器主机没有数据发送后,应用进程就会通知 TCP 释放连接。这时服务端主机会发出断开连接的报文段,报文段中 ACK = 1,序列号 seq = w,因为在这之间可能已经发送了一些数据,所以 seq 不一定等于 v + 1。ack = u + 1,在发送完断开请求的报文后,服务端主机就进入了
LAST-ACK(最后确认)
的阶段。 -
客户端收到服务端的断开连接请求后,客户端需要作出响应,客户端发出断开连接的报文段,在报文段中,ACK = 1, 序列号 seq = u + 1,因为客户端从连接开始断开后就没有再发送数据,ack = w + 1,然后进入到
TIME-WAIT(时间等待)
状态,请注意,这个时候 TCP 连接还没有释放。必须经过时间等待的设置,也就是2MSL
后,客户端才会进入CLOSED
状态,时间 MSL 叫做最长报文段寿命(Maximum Segment Lifetime)
。 -
服务端主要收到了客户端的断开连接确认后,就会进入 CLOSED 状态。因为服务端结束 TCP 连接时间要比客户端早,而整个连接断开过程需要发送四个报文段,因此释放连接的过程也被称为四次挥手。
什么是 TIME-WAIT
我上面只是简单提到了一下 TIME-WAIT 状态和 2MSL 是啥,下面来聊一下这两个概念。
MSL
是 TCP 报文段可以存活或者驻留在网络中的最长时间。RFC 793 定义了 MSL 的时间是两分钟,但是具体的实现还要根据程序员来指定,一些实现采用了 30 秒的这个最大存活时间。
那么为什么要等待 2MSL
呢?
主要是因为两个理由
- 为了保证最后一个响应能够到达服务器,因为在计算机网络中,最后一个 ACK 报文段可能会丢失,从而致使客户端一直处于
LAST-ACK
状态等待客户端响应。这时候服务器会重传一次 FINACK 断开连接报文,客户端接收后再重新确认,重启定时器。如果客户端不是 2MSL ,在客户端发送 ACK 后直接关闭的话,如果报文丢失,那么双方主机会无法进入 CLOSED 状态。 - 还可以防止
已失效
的报文段。客户端在发送最后一个 ACK 之后,再经过经过 2MSL,就可以使本链接持续时间内所产生的所有报文段都从网络中消失。从保证在关闭连接后不会有还在网络中滞留的报文段去骚扰服务器。
这里注意一点:在服务器发送了 FIN-ACK 之后,会立即启动超时重传计时器。客户端在发送最后一个 ACK 之后会立即启动时间等待计时器。
说好的 RST 呢
说好的 RST
、SYN
、FIN
标志用于连接的建立和关闭,那么 SYN 和 FIN 都现身了,那 RST 呢?也是啊,我们上面探讨的都是一种理想的情况,就是客户端服务器双方都会接受传输报文段的情况,还有一种情况是当主机收到 TCP 报文段后,其 IP 和端口号不匹配的情况。假设客户端主机发送一个请求,而服务器主机经过 IP 和端口号的判断后发现不是给这个服务器的,那么服务器就会发出一个 RST
特殊报文段给客户端。
因此,当服务端发送一个 RST 特殊报文段给客户端的时候,它就会告诉客户端没有匹配的套接字连接,请不要再继续发送了。
上面探讨的是 TCP 的情况,那么 UDP 呢?
使用 UDP 作为传输协议后,如果套接字不匹配的话,UDP 主机就会发送一个特殊的 ICMP 数据报。
SYN 洪泛攻击
下面我们来讨论一下什么是 SYN 洪泛攻击。
我们在 TCP 的三次握手中已经看到,服务器为了响应一个收到的 SYN,分配并初始化变量连接和缓存,然后服务器发送一个 SYNACK 作为响应,然后等待来自于客户端的 ACK 报文。如果客户端不发送 ACK 来完成最后一步的话,那么这个连接就处在一个挂起的状态,也就是半连接状态。
攻击者通常在这种情况下发送大量的 TCP SYN 报文段,服务端继续响应,但是每个连接都完不成三次握手的步骤。随着 SYN 的不断增加,服务器会不断的为这些半开连接分配资源,导致服务器的连接最终被消耗殆尽。这种攻击也是属于 Dos
攻击的一种。
抵御这种攻击的方式是使用 SYN cookie
,下面是它的工作流程介绍
- 当服务器收到一个 SYN 报文段时,它并不知道这个报文段是来自哪里,是来自攻击者主机还是客户端主机(虽然攻击者也是客户端,不过这么说更便于区分) 。因此服务器不会为报文段生成一个半开连接。与此相反,服务器生成一个初始的 TCP 序列号,这个序列号是 SYN 报文段的源和目的 IP 地址与端口号这个四元组构造的一个复杂的散列函数,这个散列函数生成的 TCP 序列号就是
SYN Cookie
,用于缓存 SYN 请求。然后,服务器会发送带着 SYN Cookie 的 SYNACK 分组。有一点需要注意的是,服务器不会记忆这个 Cookie 或 SYN 的其他状态信息。 - 如果客户端不是攻击者的话,它就会返回一个 ACK 报文段。当服务器收到这个 ACK 后,需要验证这个 ACK 与 SYN 发送的是否相同,验证的标准就是确认字段中的确认号和序列号,源和目的 IP 地址与端口号以及和散列函数的是否一致,散列函数的结果 + 1 是否和 SYNACK 中的确认值相同。(大致是这样,说的不对还请读者纠正) 。如果有兴趣读者可以自行深入了解。如果是合法的,服务器就会生成一个具有套接字的全开连接。
- 如果客户端没有返回 ACK,即认为是攻击者,那么这样也没关系,服务器没有收到 ACK,不会分配变量和缓存资源,不会对服务器产生危害。
拥塞控制
有了 TCP 的窗口控制后,使计算机网络中两个主机之间不再是以单个数据段的形式发送了,而是能够连续发送大量的数据包。然而,大量数据包同时也伴随着其他问题,比如网络负载、网络拥堵等问题。TCP 为了防止这类问题的出现,使用了 拥塞控制
机制,拥塞控制机制会在面临网络拥塞时遏制发送方的数据发送。
拥塞控制主要有两种方法
端到端的拥塞控制
: 因为网络层没有为运输层拥塞控制提供显示支持。所以即使网络中存在拥塞情况,端系统也要通过对网络行为的观察来推断。TCP 就是使用了端到端的拥塞控制方式。IP 层不会向端系统提供有关网络拥塞的反馈信息。那么 TCP 如何推断网络拥塞呢?如果超时或者三次冗余确认就被认为是网络拥塞,TCP 会减小窗口的大小,或者增加往返时延来避免。网络辅助的拥塞控制
: 在网络辅助的拥塞控制中,路由器会向发送方提供关于网络中拥塞状态的反馈。这种反馈信息就是一个比特信息,它指示链路中的拥塞情况。
下图描述了这两种拥塞控制方式
TCP 拥塞控制
如果你看到这里,那我就暂定认为你了解了 TCP 实现可靠性的基础了,那就是使用序号和确认号。除此之外,另外一个实现 TCP 可靠性基础的就是 TCP 的拥塞控制。如果说
TCP 所采用的方法是让每一个发送方根据所感知到的网络的拥塞程度来限制发出报文段的速率,如果 TCP 发送方感知到没有什么拥塞,则 TCP 发送方会增加发送速率;如果发送方感知沿着路径有阻塞,那么发送方就会降低发送速率。
但是这种方法有三个问题
- TCP 发送方如何限制它向其他连接发送报文段的速率呢?
- 一个 TCP 发送方是如何感知到网络拥塞的呢?
- 当发送方感知到端到端的拥塞时,采用何种算法来改变其发送速率呢?
我们先来探讨一下第一个问题,TCP 发送方如何限制它向其他连接发送报文段的速率呢?
我们知道 TCP 是由接收缓存、发送缓存和变量(LastByteRead, rwnd,等)
组成。发送方的 TCP 拥塞控制机制会跟踪一个变量,即 拥塞窗口(congestion window)
的变量,拥塞窗口表示为 cwnd
,用于限制 TCP 在接收到 ACK 之前可以发送到网络的数据量。而接收窗口(rwnd)
是一个用于告诉接收方能够接受的数据量。
一般来说,发送方未确认的数据量不得超过 cwnd 和 rwnd 的最小值,也就是
*LastByteSent – LastByteAcked <= min(cwnd,rwnd)
由于每个数据包的往返时间是 RTT,我们假设接收端有足够的缓存空间用于接收数据,我们就不用考虑 rwnd 了,只专注于 cwnd,那么,该发送方的发送速率大概是 cwnd/RTT 字节/秒
。通过调节 cwnd,发送方因此能调整它向连接发送数据的速率。
一个 TCP 发送方是如何感知到网络拥塞的呢?
这个我们上面讨论过,是 TCP 根据超时或者 3 个冗余 ACK 来感知的。
当发送方感知到端到端的拥塞时,采用何种算法来改变其发送速率呢 ?
这个问题比较复杂,且容我娓娓道来,一般来说,TCP 会遵循下面这几种指导性原则
- 如果在报文段发送过程中丢失,那就意味着网络拥堵,此时需要适当降低 TCP 发送方的速率。
- 一个确认报文段指示发送方正在向接收方传递报文段,因此,当对先前未确认报文段的确认到达时,能够增加发送方的速率。为啥呢?因为未确认的报文段到达接收方也就表示着网络不拥堵,能够顺利到达,因此发送方拥塞窗口长度会变大,所以发送速率会变快
带宽探测
,带宽探测说的是 TCP 可以通过调节传输速率来增加/减小 ACK 到达的次数,如果出现丢包事件,就会减小传输速率。因此,为了探测拥塞开始出现的频率, TCP 发送方应该增加它的传输速率。然后慢慢使传输速率降低,进而再次开始探测,看看拥塞开始速率是否发生了变化。
在了解完 TCP 拥塞控制后,下面我们就该聊一下 TCP 的 拥塞控制算法(TCP congestion control algorithm)
了。TCP 拥塞控制算法主要包含三个部分:慢启动、拥塞避免、快速恢复,下面我们依次来看一下
慢启动
当一条 TCP 开始建立连接时,cwnd 的值就会初始化为一个 MSS 的较小值。这就使得初始发送速率大概是 MSS/RTT 字节/秒
,比如要传输 1000 字节的数据,RTT 为 200 ms ,那么得到的初始发送速率大概是 40 kb/s 。实际情况下可用带宽要比这个 MSS/RTT 大得多,因此 TCP 想要找到最佳的发送速率,可以通过 慢启动(slow-start)
的方式,在慢启动的方式中,cwnd 的值会初始化为 1 个 MSS,并且每次传输报文确认后就会增加一个 MSS,cwnd 的值会变为 2 个 MSS,这两个报文段都传输成功后每个报文段 + 1,会变为 4 个 MSS,依此类推,每成功一次 cwnd 的值就会翻倍。如下图所示
发送速率不可能会一直增长,增长总有结束的时候,那么何时结束呢?慢启动通常会使用下面这几种方式结束发送速率的增长。
-
如果在慢启动的发送过程出现丢包的情况,那么 TCP 会将发送方的 cwnd 设置为 1 并重新开始慢启动的过程,此时会引入一个
ssthresh(慢启动阈值)
的概念,它的初始值就是产生丢包的 cwnd 的值 / 2,即当检测到拥塞时,ssthresh 的值就是窗口值的一半。 -
第二种方式是直接和 ssthresh 的值相关联,因为当检测到拥塞时,ssthresh 的值就是窗口值的一半,那么当 cwnd > ssthresh 时,每次翻番都可能会出现丢包,所以最好的方式就是 cwnd 的值 = ssthresh ,这样 TCP 就会转为拥塞控制模式,结束慢启动。
-
慢启动结束的最后一种方式就是如果检测到 3 个冗余 ACK,TCP 就会执行一种快速重传并进入恢复状态。
拥塞避免
当 TCP 进入拥塞控制状态后,cwnd 的值就等于拥塞时值的一半,也就是 ssthresh 的值。所以,无法每次报文段到达后都将 cwnd 的值再翻倍。而是采用了一种相对保守
的方式,每次传输完成后只将 cwnd 的值增加一个 MSS
,比如收到了 10 个报文段的确认,但是 cwnd 的值只增加一个 MSS。这是一种线性增长模式,它也会有增长逾值,它的增长逾值和慢启动一样,如果出现丢包,那么 cwnd 的值就是一个 MSS,ssthresh 的值就等于 cwnd 的一半;或者是收到 3 个冗余的 ACK 响应也能停止 MSS 增长。如果 TCP 将 cwnd 的值减半后,仍然会收到 3 个冗余 ACK,那么就会将 ssthresh 的值记录为 cwnd 值的一半,进入 快速恢复
状态。
快速恢复
在快速恢复中,对于使 TCP 进入快速恢复状态缺失的报文段,对于每个收到的冗余 ACK,cwnd 的值都会增加一个 MSS 。当对丢失报文段的一个 ACK 到达时,TCP 在降低 cwnd 后进入拥塞避免状态。如果在拥塞控制状态后出现超时,那么就会迁移到慢启动状态,cwnd 的值被设置为 1 个 MSS,ssthresh 的值设置为 cwnd 的一半。
ICMP协议
我们之前的文章中了解过 TCP/IP 协议,我那时候码了一句
原文链接见如下:
下面我们就来真正认识一下 ICMP 协议
什么是 ICMP
ICMP 的全称是 Internet Control Message Protocol(互联网控制协议)
,它是一种互联网套件,它用于IP 协议中发送控制消息。也就是说,ICMP 是依靠 IP 协议来完成信息发送的,它是 IP 的主要部分,但是从体系结构上来讲,它位于 IP 之上,因为 ICMP 报文是承载在 IP 分组中的,就和 TCP 与 UDP 报文段作为 IP 有效载荷被承载那样。这也就是说,当主机收到一个指明上层协议为 ICMP 的 IP 数据报时,它会分解出该数据报的内容给 ICMP,就像分解数据报的内容给 TCP 和 UDP 一样。
ICMP 协议和 TCP、UDP 等协议不同,它不用于传输数据,只是用来发送消息。因为 IP 协议现在有两类版本:IPv4 和 IPv6 ,所以 ICMP 也有两个版本:ICMPv4 和 ICMPv6。
ICMP 的主要功能
对于 ICMP 的功能,主要分为两个
- ICMP 的第一个功能是确认 IP 包是否能够成功到达目标地址,当两个设备通过互联网相连时,任意一个设备发送给另一个设备的 IP 包如果没有到达,就会生成 ICMP 数据包发送给设备共享。
- ICMP 的第二个功能是进行
网络诊断
,经常使用 ICMP 数据包的两个终端程序是ping
和traceroute
,traceroute 程序用于显示两台互联网设备之间可能的路径并测量数据包在 IP 网络上的时延。ping 程序是 traceroute 的简化版本,我们经常使用 ping 命令来测试两台设备之间是否互联,ping 通常用来测试两台主机之间的连接速度,并准确报告数据包到达目的地并返回后所花费的时间。
现在我们知道了,如果在 IP 通信过程中由于某个 IP 包由于某种原因未能到达目标主机,那么这个具体的原因将由 ICMP 进行通知,下面是一个 ICMP 的通知示意图
上面我们只是画出了路由器 2 给主机 A 发送了一个 ICMP 数据包,而没有画出具体的通知类型,但实际情况是,上面发送的是目标不可达类型(Destination unreachable)
,ICMP 也是具有不同的通知类型的,下面我们汇总了 ICMP 数据包的具体通知类型。
上表显示的 ICMP 通知类型主要分为两类:有关 IP 数据报传递的 ICMP 报文,这类报文也叫做差错报文(error message)
,以及有关信息采集和配置的 ICMP 报文,这类报文也被称为查询 query 或者信息类报文。
信息类报文包括回送请求和回送应答(类型 8 和 类型 0 ),路由器公告和路由器请求(类型 9 和 类型 0 )。最常见的差错报文类型包括目标不可达(类型 3 )、重定向(类型 5)、超时(类型 11)。
ICMP 在 IPv4 和 IPv6 的封装
我们知道,ICMP 是承载在 IP 内部的,而且 IPv4 和 IPv6 的封装位置不同:
ICMP 在 IPv4 协议中的封装
ICMP 在 IPv6 协议中的封装
上面两张图显示了 ICMPV4 和 ICMPv6 的报文格式。开头的 4 个字节在所有的报文中都是一样的。但是其余部分在不同的报文中却不一样。
ICMP 头部包含了整个 ICMP 数据段的校验和
,具体格式如下
所有的 ICMP 报文都以 8 位的类型(Type)
和代码(Code)
字段开始,其后的 16 位
校验和涵盖了整个报文,ICMPv4 和 ICMPv6 种的类型和代码字段是不同的。
ICMP 的主要消息
ICMP 目标不可达(类型 3)
我们知道,路由器无法将 IP 数据报发送给目标地址时,会给发送端主机返回一个目标不可达(Destination Unreachable Message)
的 ICMP 消息,并且会在消息中显示不可达的具体原因。
实际通信过程中会显示各种各样的不可达信息,比如错误代码时 1 表示主机不可达,它指的是路由表中没有主机的信息,或者主机没有连接到网络的意思。一些 ICMP 不可达信息的具体原因如下
ICMP 重定向消息(类型 5)
如果路由器发现发送端主机使用了次优的路径发送数据,那么它会返回一个 ICMP 重定向(ICMP Redirect Message)
的消息给这个主机。这个 ICMP 重定向消息包含了最合适的路由信息和源数据。这种情况会发生在路由器持有更好的路由信息的情况下。路由器会通过这样的 ICMP 消息给发送端主机一个更合适的发送路由。
主机 Host 的 IP 地址为 10.0.0.100。主机的路由表中有一个默认路由条目,指向路由器 G1 的 IP 地址 10.0.0.1 作为默认网关。路由器 G1 在将数据包转发到目的网络 X 时,会使用路由器 G2 的 IP 地址 10.0.0.2 作为下一跳。
当主机向目的网络 X 发送数据包时,会发生以下情况
-
IP 地址为 10.0.0.1 的网关 G1 在其所连接的网络上接收来自 10.0.0.100 的数据包。
-
网关 G1 检查其路由表,并在通往数据包目的网络 X 的路由中获取下一个网关 G2 的 IP 地址 10.0.0.2。
-
如果 G2 和 IP 数据包的源地址标识的主机位于同一网络中(也就是 Host 主机),那么 G1 会向主机发送 ICMP 重定向消息。ICMP 重定向消息建议主机直接将发送到网络 X 的数据包发送至 G2,因为 Host – G2 这是通往目的地的较短路径。
-
网关 G1 将原始数据包转发到其目的地。
当然,根据主机的配置,Host 主机也可以选择忽略 G1 给它发送的 ICMP 重定向消息。但是,这样就享受不到 ICMP 重定向带来的两大好处,即
- 优化数据在网络中的转发路径;流量更快到达目的地
- 降低网络资源利用率,例如带宽和路由器 CPU 负载
如果 Host 主机采用了 ICMP 提供的重定向路径的话,那么 Host 就会直接把数据包发送至网络 X,如下图所示
在主机为 G2 作为下一跳的网络 X 创建路由缓存条目后,这些优势在网络中可见:
- 交换机和路由器 G1 之间链路的带宽利用率在两个方向上都会降低
- 由于从主机到网络 X 的流量不再流经此节点,因此路由器 G1 的 CPU 使用率降低
- 主机和网络 X 之间的端到端网络延迟得到改善。
ICMP 重定向示例如下
ICMP 超时消息(类型 11)
在 IP 数据包中有一个叫做 TTL(Time To Live, 生存周期)
,它的值在每经过路由器一跳之后都会减 1,IP 数据包减为 0 时会被丢弃。此时,IP 路由器会发送一个 ICMP 超时消息(ICMP TIme Exceeded Message, 错误号 0)发送给主机,通知该包已经被丢弃。
设置生存周期的主要目的就是为了防止路由器控制遇到问题发生循环状况时,避免 IP 包无休止的在网络上转发,如下图所示
这里给大家推荐一款比较好用的追踪超时消息的工具
traceroute
,它可以显示出由执行程序的主机到达特定主机之前需要经过多少路由器。 traceroute 的官网如下 http://www.traceroute.org
ICMP 回送消息(类型 0 和 类型 8)
ICMP 回送消息用于判断相互通信的主机之间是否连通,也就是判断所发送的数据包是否能够到达目标主机。可以向对端主机发送回送请求的消息(ICMP Echo Request Message,类型 8)
,也可以接收对端主机发送来的回送消息(ICMP Echo Reply Message, 类型 0 )。网络上最常用的 ping 命令就是利用这个实现的。
其他 ICMP 消息
ICMP 原点抑制消息(类型 4)
在使用低速率网络的情况下,网络通信可能会遇到网络拥堵的情况下,ICMP 的原点抑制就是为了应对这种情况的。当路由器向低速线路发送数据时,其发送队列的残存数据报变为 0 从而无法发送时,可以向 IP 数据报的源地址发送一个 ICMP 原点抑制(ICMP Source Quench Message)
消息,收到这个消息的主机了解到线路某处发生了拥堵,从而抑制 IP 数据报的发送。
不过这个 ICMP 消息可能会引起不公平的网络通信,一般不被使用。
ICMP 路由器探索消息(类型 9、10)
ICMP 路由器探索消息主要用于路由器发现(Router Discovery, RD)
,它主要分为两种,路由器请求(Router Solicitation, 类型 10)
和路由器响应(Router Advertisement, 类型 9)
。主机会在任意路由连接组播的网络上发送一个 RS 消息,想要选择一个路由器进行学习,以此来作为默认路由,而相对应的该路由会发送一个 RA 消息来作为默认路由的响应。
ICMP 地址掩码消息(类型 17、18)
主要用于主机或者路由器想要了解子网掩码的情况。可以向那些目标主机或路由器发送 ICMP 地址掩码请求消息(ICMP Address Mask Request, 类型 17)
和 ICMP 地址掩码应答消息(ICMP Address Mask Reply, 类型 18)
获取子网掩码信息。
ICMPv6
ICMPv6 的作用
IPv4 中 ICMP 仅仅作为一个辅助作用支持 IPv4。也就是说,在 IPv4 时期,即使没有 ICMP,也能进行正常的 IP 数据包的发送和接收,也就是 IP 通信。但是在 IPv6 中,ICMP 的作用被放大了,如果没有 ICMP,则不能进行正常的 IP 通信。
尤其在 IPv6 中,从 IP 定位 MAC 地址的协议从 ARP 转为 ICMP 的邻居探索消息(Neighbor Discovery)
。这种邻居探索消息融合了 IPv4 的 ARP、ICMP 重定向以及 ICMP 的路由选择等功能于一体。甚至还提供了自动设置 IP 的功能。
在 IPv6 中,ICMP 消息主要分为两类:一类是错误消息
,一类是信息消息
。0 – 127 属于错误消息;128 – 255 属于信息消息。
RFC 2463 中描述了以下消息类型:
ICMPv6 除了包含 ICMPv4 的所有功能外,还有两个额外的功能。
ICMPv6 邻居探索
邻居探索是 ICMPv6 非常重要的功能,主要表示的类型是 133 – 137 之间的消息叫做邻居探索消息
。这种邻居探索消息对于 IPv6 通信起到举足轻重的作用。邻居请求消息用于查询 IPv6 地址于 MAC 地址的对应关系。邻居请求消息利用 IPv6 的多播地址实现传输。
此外,由于 IPv6 实现了即插即用的功能,所以在没有 DHCP
服务器的环境下也能实现 IP 地址的自动获取。如果是一个没有路由器的网络,就使用 MAC 地址作为链路本地单播地址。如果在一个有路由器的网络环境中,可以从路由器获得 IPv6 地址的前面部分,后面部分使用 MAC 地址进行设置。此时可以利用路由器请求消息和路由器公告消息进行设置。
ICMPv6 的组播收听发现协议
组播收听发现协议(MLD,Multicast Listener Discovery)
由子网内的组播成员管理。MLD 协议定义了3条ICMPv6 消息:
- 组播收听查询消息:组播路由器向子网内的组播收听者发送此消息,以获取组播收听者的状态。
- 组播收听者报告消息:组播收听者向组播路由器汇报当前状态,包括离开某个组播组。
- 组播收听者。
与 ICMP 有关的攻击
涉及 ICMP 攻击主要分为 3 类:泛洪(flood)、炸弹(bomb) 和信息泄露(information disclsure)。
- 泛洪将会产生大量流量,导致针对一台或者多台计算机的有效 Dos 攻击。
- 炸弹指的是发送经过特殊构造的报文,这类报文能够导致 IP 或者 ICMP 的处理失效或者崩溃。
- 信息泄露本身不会造成危害,但是能够帮助辅助其他攻击。
针对 TCP 的 ICMP 攻击已经记录在了 RFC5927 中。
计算机网络的应用层
- 计算机网络的应用层
文章的整体脉络如下
在有了之前两篇文章的介绍后,相信读者对计算机网络有了初步的认识,那么下面我们就要对不同的协议层进行分类介绍了,我们还是采用自上而下的方式来介绍,这种介绍对读者来说更容易接纳,吸收程度更好(说白了就是更容易给我的文章点赞,逃)。
一般情况下,用户不太在意网络应用程序实际上是按照怎样的机制运行的,但我们是程序员吖,就套用朱伟的一句话说:你觉得计算机网络程序员不了解,你指着互联网用户去了解吗?有内个味儿没?
应用层指的是 OSI 标准模型的第 5、6、7层,也就是会话层、表现层、应用层。
我们介绍的时候都会使用 OSI 标准模型来介绍,因为这样涵盖的层次比较多,这样对于 TCP/IP 模型来说,你也能加深理解。
应用层概念
应用层协议的定义
现如今,越来越多的应用程序利用网络进行通信,这些应用有 Web 浏览器、远程登录、电子邮件、文件传输、文件下载等,应用层的协议正是进行这些行为活动的规则和标准。
应用层协议(application layer protocol)
定义了在不同端系统上的应用程序进程如何相互传递报文。一般来说,会定义如下内容
- 交换的报文类型:是请求报文还是相应报文
- 报文字段的解释:对报文中各个字段的详细描述
- 报文字段的语义:报文各个字段的含义是什么
- 进程何时、以什么方式发送报文以及响应
应用层体系结构
应用层体系结构
的英文是 Application Architecture,它指的是应用层的结构,一般来说,应用层有两种主流体系结构
- 客户 – 服务器体系结构 ( client-server architecture )
- 对等体系结构 ( P2P architecture )
下面我们先来聊一下客户 – 服务器体系结构的概念
在客户-服务器体系结构中,有一个总是打开的主机称为 服务器(Server)
,它提供来自于 客户(client)
的服务。我们最常见的服务器就是 Web 服务器
,Web 服务器服务于来自 浏览器
的请求。
当 Web 服务器通过浏览器接收到用户请求后,它会经过一系列的处理把信息或者页面等通过浏览器呈现给应用。这种模式就是客户 – 服务器模式。
有两点需要注意
- 在客户 – 服务器模式下,通常客户彼此之间是并不互相通信的。
- 服务器通常具有固定的、周知的 IP 地址可以提供访问。
客户 – 服务器模式通常会出现随着客户数量的急剧增加导致单台服务器无法完成大量客户请求的情况。为此,通常需要配备大量主机的 数据中心(data center)
,用来跟踪所有的用户请求。
于此相反,P2P 也就是对等体系结构对这种数据中心的依赖性很低,因为在 P2P 体系结构中,应用程序在两个主机之间直接通信,这些主机被称为对等方
,与有中心服务器的中央网络系统不同,对等网络的每个用户端既是一个节点,也有服务器的功能。常见的 P2P 体系结构的应用有 文件共享、视频会议、网络电话等。
P2P 一个最大的特点就是 扩展性(self-scalability)
,因为 P2P 网络的一个重要的目标就是让所有的客户端都能提供资源、获取资源,共享带宽,存储空间等。因此,当有更多节点加入且对系统请求增多,整个系统的容量也增大。这是具有一组固定服务器的客户 – 服务器结构不具备的,这也就是 P2P 的扩展性。
进程通信
我们上面说到了两种体系结构,一种是客户 – 服务器模式,一种是 P2P 对等模式。我们都知道一个计算机允许同时运行多个应用程序,在我们看起来这些应用程序好像是同时运行的,那么它们之间是如何通信的呢?
用操作系统的术语来说,进行通信实际上是 进程(process)
而不是程序。一个进程可以被认为是运行在端系统中的程序。当多个进程运行在相同的端系统上时,它们使用进程间的通信机制相互通信。进程间的通信规则由操作系统来确定。
进程与计算机网络之间的接口
计算机是庞大且繁杂的,计算机网络也是,应用程序不可能只有一个进程组成,它同样是多个进程共同作用协商运行,然而,分布在多个端系统之间的进程是如何进行通信的呢?实际上,每个进程之间会有一个 套接字(socket)
的软件接口存在,套接字是应用程序的内部接口,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。
通过一个实例来简单类比一下套接字和网络进程:进程可类比一座房子,而它的套接字相当于是房子的门,当一个进程想要与其他进程进行通信时,它会把报文推出门外,然后通过运输设备把报文运输到另外一座房子,通过门进入房子内部使用。
下图是一个通过套接字进行通信的流程图
从图可以看到,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)
能够根据当时可用的带宽或多或少地利用可供使用的吞吐量。
定时
定时是什么意思?定时能够确保网络中两个应用程序的收发是否能够在指定的时间内完成,这也是应用程序选择运输服务需要考虑的一个因素,这听起来很自然,你网络应用发送和接收数据包肯定要加以时间的概念,比如在游戏中,你一包数据迟迟发送不过去,对面都推塔了你还卡在半路上呢。
安全性
最后,选择运输协议一定要能够为应用程序提供一种或多种安全性服务。
因特网能够提供的运输服务
说完运输服务的选型,接下来该聊一聊因特网能够提供哪些服务了。实际上,因特网为应用程序提供了两种运输层的协议,即 UDP
和 TCP
,下面是一些网络应用的选择要求,可以根据需要来选择适合的运输层协议。
应用 | 数据丢失 | 带宽 | 时间敏感 |
---|---|---|---|
文件传输 | 不能丢失 | 弹性 | 不敏感 |
电子邮件 | 不能丢失 | 弹性 | 不敏感 |
Web 文档 | 不能丢失 | 弹性 | 不敏感 |
因特网电话/视频会议 | 容忍丢失 | 弹性 | 敏感,100ms |
流式存储音频/视频 | 容忍丢失 | 弹性 | 敏感,几秒 |
交互式游戏 | 容忍丢失 | 弹性 | 是,100ms |
智能手机消息 | 不能丢失 | 弹性 | 无所谓 |
下面我们就来聊一聊这两种运输协议的应用场景
TCP
TCP
服务模型的特性主要有下面几种
- 面向连接的服务
在应用层数据报发送后, TCP 让客户端和服务器互相交换运输层控制信息。这个握手过程就是提醒客户端和服务器需要准备好接受数据报。握手阶段后,一个 TCP 连接(TCP Connection)
就建立了。这是一条全双工的连接,即连接双方的进程都可以在此连接上同时进行收发报文。当应用程序结束报文发送后,必须拆除连接。
- 可靠的数据传输
通信进程能够依靠 TCP,无差错、按适当顺序交付所有发送的数据。应用程序能够依靠 TCP 将相同的字节流交付给接收方的套接字,没有字节的丢失和冗余。
- 拥塞控制
TCP 的拥塞控制并不一定为通信进程带来直接好处,但能为因特网带来整体好处。当接收方和发送方之间的网络出现拥塞时,TCP 的拥塞控制会抑制发送进程(客户端或服务器),我们会在后面具体探讨拥塞控制
UDP
UDP
是一种轻量级的运输协议,它仅提供最小服务。UDP 是无连接的,因此在两个进程通信前没有握手过程。UDP 也不会保证报文是否传输到服务端,它就像是一个撒手掌柜。不仅如此,到达接收进程的报文也可能是乱序到达的。
下面是上表列出来的一些应用所选择的协议
应用 | 应用层协议 | 支撑的运输协议 |
---|---|---|
电子邮件 | SMTP | TCP |
远程终端访问 | Telnet | TCP |
Web | HTTP | TCP |
文件传输 | FTP | TCP |
流式多媒体 | HTTP | TCP |
因特网电话 | SIP、RTP | TCP 或 UDP |
应用层协议
下面我们着重介绍一下应用层都有哪些比较重要的应用协议
WWW 和 HTTP
万维网(WWW, World Wide Web)
是将互联网中的信息以超文本的形式展现的系统,也就是 Web 。用来显示 WWW 结果的客户端被称为 Web 浏览器,通过浏览器,我们无需关注想要访问的内容在哪个服务器上,我们只需要知道我们想访问的内容就可以了。
WWW 定义了三个比较重要的概念,这些概念主要有
URI
,定义了访问信息的手段和位置HTML
, 定义了信息的表现形式HTTP
,定义了 WWW 的访问规范
URI / URL
URI
的全称是(Uniform Resource Identifier),中文名称是统一资源标识符,使用它就能够唯一地标记互联网上资源。
URL
的全称是(Uniform Resource Locator),中文名称是统一资源定位符,也就是我们俗称的网址
,它实际上是 URI 的一个子集。
URI 不仅包括 URL,还包括 URN(统一资源名称),它们之间的关系如下
URI 已经不局限于标识互联网资源,它可以作为所有资源的识别码。
HTML
HTML 称为超文本标记语言,是一种标识性的语言。它包括一系列标签.通过这些标签可以将网络上的文档格式统一,使分散的 Internet 资源连接为一个逻辑整体。HTML 文本是由 HTML 命令组成的描述性文本,HTML 命令可以说明文字,图形、动画、声音、表格、链接等。
HTTP
Web 的应用层协议就是 HTTP(HyperText Transfer Protocol, HTTP)
, 超文本传输协议,它是 Web 的核心协议。下面我们需要了解一下 HTTP 中的几个核心概念。
Web 页面
Web 页面也叫做 Web Page
,它是由对象组成,一个对象(object)
简单来说就是一个文件,这个文件可以是 HTML 文件、一个图片、一段 Java 应用程序等,它们都可以通过 URI 来找到。一个 Web 页面包含了很多对象,Web 页面可以说是对象的集合体。
浏览器
就如同各大邮箱使用电子邮件传送协议 SMTP
一样,浏览器是使用 HTTP 协议的主要载体,说到浏览器,你能想起来几种?是的,随着网景大战结束后,浏览器迅速发展,至今已经出现过的浏览器主要有
Web 服务器
Web 服务器的正式名称叫做 Web Server
,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 是一种跨编程语言和跨操作系统平台的远程调用技术。
WebService 是一种由 W3C 定义的应用服务开发规范,使用 client-server 主从架构,通常使用 WSDL 定义服务接口,使用 HTTP 协议传输 XML 或 SOAP 消息,它是一个基于 Web(HTTP)的服务架构技术,既可以运行在内网,也可以在适当保护后运行在外网。
HTTP
HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范。HTTP 是一种应用层协议,它使用 TCP 作为运输层协议,因为文档、数据这些信息在我们看来是一种重要的信息,不可丢失。
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 客户端会把对应的资源通过显示器呈现给用户。
至此,键入网址再按下回车的全过程就结束了。上述过程描述的是一种简单的请求-响应
全过程,真实的请求-响应情况可能要比上面描述的过程复杂很多。
HTTP 请求特征
从上面整个过程中我们可以总结出 HTTP 进行分组传输是具有以下特征
- 支持客户 – 服务器模式
简单快速
:客户向服务器请求服务时,只需传送请求方法和路径。请求方法常用的有 GET、HEAD、POST。每种方法规定了客户与服务器联系的类型不同。由于 HTTP 协议简单,使得 HTTP 服务器的程序规模小,因而通信速度很快。灵活
:HTTP 允许传输任意类型的数据对象。正在传输的类型由Content-Type
加以标记。无连接
:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。无状态
:HTTP 协议是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另一方面,在服务器不需要先前信息时它的应答就较快。
持久链接和非持久链接
我们上面描述的 HTTP 请求响应过程就是一种非持久链接
,因为每次 TCP 在传递完报文后,都会关闭 TCP 链接,每个 TCP 连接只传输一个请求报文和响应报文。
非持久性连接有一些缺点
。
- 第一,必须为每个请求的对象建立和维护一个全新的连接。
- 第二,对于每个这样的连接来说,在客户端和服务器中都要分配 TCP 的缓冲区和保持 TCP 变量,这给 Web 服务器带来了严重的负担。因为一台 Web 服务器可能要同时服务于数百甚至上千个客户请求。
在采用 HTTP 1.1 持续连接的情况下,服务器在发送响应后保持该 TCP 连接打开不关闭。在相同的客户与服务器之间,后续的请求和响应报文能够通过相同的连接进行传送。一般来说,如果一跳连接经过一定的时间间隔(可配置)后仍未使用,HTTP 服务器就应该关闭其连接。
HTTP 报文格式
我们上面描述了一下 HTTP 的请求响应过程,相信你对 HTTP 有了更深的认识,下面我们就来一起认识一下 HTTP 的报文格式是怎样的。
HTTP 协议主要由三大部分组成:
起始行(start line)
:描述请求或响应的基本信息;头部字段(header)
:使用 key-value 形式更详细地说明报文;消息正文(entity)
:实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据。
其中起始行和头部字段并成为 请求头
或者 响应头
,统称为 Header
;消息正文也叫做实体,称为 body
。HTTP 协议规定每次发送的报文必须要有 Header,但是可以没有 body,也就是说头信息是必须的,实体信息可以没有。而且在 header 和 body 之间必须要有一个空行(CRLF)。如果用一幅图来表示一下 HTTP 请求的话,我觉得应该是下面这样
如果细化一点的话,那就是下面这样
这幅图需要注意一下,如果使用 GET
方法,是没有实体体的,如果你使用的是 POST
方法,才会有实体体。当用户提交表单时,HTTP 客户端通常使用 POST 方法;与此相反,HTML 表单的获取通常使用 GET 方法。HEAD 方法类似于 GET 方法,只不过 HEAD 方法不会返回对象。
下面我们来看一下 HTTP 响应报文
可以看到,请求报文和响应报文只有请求头是不同的,其他信息均一致。
请求报文请求行:
GET /some/page.html HTTP/1.1
响应报文:
HTTP/1.1 200 OK
Cookie 和 Session
HTTP 协议是一种无状态协议
,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;Session 和 Cookie 的主要目的就是为了弥补 HTTP 的无状态特性。
Session 是什么
客户端请求服务端,服务端会为这次请求开辟一块内存空间
,这个对象便是 Session 对象,存储结构为 ConcurrentHashMap
。Session 弥补了 HTTP 无状态特性,服务器可以利用 Session 存储客户端在同一个会话期间的一些操作记录。
Session 如何判断是否是同一会话
服务器第一次接收到请求时,开辟了一块 Session 空间(创建了Session对象),同时生成一个 sessionId ,并通过响应头的 Set-Cookie:JSESSIONID=XXXXXXX 命令,向客户端发送要求设置 Cookie 的响应; 客户端收到响应后,在本机客户端设置了一个 JSESSIONID=XXXXXXX 的 Cookie 信息,该 Cookie 的过期时间为浏览器会话结束;
接下来客户端每次向同一个网站发送请求时,请求头都会带上该 Cookie信息(包含 sessionId ), 然后,服务器通过读取请求头中的 Cookie 信息,获取名称为 JSESSIONID 的值,得到此次请求的 sessionId。
Session 的缺点
Session 机制有个缺点,比如 A 服务器存储了 Session,就是做了负载均衡后,假如一段时间内 A 的访问量激增,会转发到 B 进行访问,但是 B 服务器并没有存储 A 的 Session,会导致 Session 的失效。
Cookies 是什么
HTTP 协议中的 Cookie 包括 Web Cookie
和浏览器 Cookie
,它是服务器发送到 Web 浏览器的一小块数据。服务器发送到浏览器的 Cookie,浏览器会进行存储,并与下一个请求一起发送到服务器。通常,它用于判断两个请求是否来自于同一个浏览器,例如用户保持登录状态。
HTTP Cookie 机制是 HTTP 协议无状态的一种补充和改良
Cookie 主要用于下面三个目的
会话管理
登陆、购物车、游戏得分或者服务器应该记住的其他内容
个性化
用户偏好、主题或者其他设置
追踪
记录和分析用户行为
Cookie 曾经用于一般的客户端存储。虽然这是合法的,因为它们是在客户端上存储数据的唯一方法,但如今建议使用现代存储 API。Cookie 随每个请求一起发送,因此它们可能会降低性能(尤其是对于移动数据连接而言)。
创建 Cookie
当接收到客户端发出的 HTTP 请求时,服务器可以发送带有响应的 Set-Cookie
标头,Cookie 通常由浏览器存储,然后将 Cookie 与 HTTP 标头一同向服务器发出请求。
Set-Cookie 和 Cookie 标头
Set-Cookie
HTTP 响应标头将 cookie 从服务器发送到用户代理。下面是一个发送 Cookie 的例子
此标头告诉客户端存储 Cookie
现在,随着对服务器的每个新请求,浏览器将使用 Cookie 头将所有以前存储的 Cookie 发送回服务器。
有两种类型的 Cookies,一种是 Session Cookies,一种是 Persistent Cookies,如果 Cookie 不包含到期日期,则将其视为会话 Cookie。会话 Cookie 存储在内存中,永远不会写入磁盘,当浏览器关闭时,此后 Cookie 将永久丢失。如果 Cookie 包含有效期
,则将其视为持久性 Cookie。在到期指定的日期,Cookie 将从磁盘中删除。
还有一种是 Cookie的 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 的争论
尽管 Cookie 能够简化用户的网络活动,但是 Cookie 的使用存在争议,因为不少人认为它对用户是一种侵权行为。因为结合 Cookie 和用户提供的账户信息,Web 站点可以知道更多关于用户的信息。
Web 缓存
Web 缓存(Web cache)
也叫做 代理服务器(proxy server)
,它是代表 HTTP 服务器来满足用户需求的网络实体。Web 缓存器有自己的磁盘存储空间
,并会在存储空间内保存最近请求过的对象,如下图所示
Web 缓存可以在用户的浏览器中进行配置,一旦配置后,用户首先访问的就不是初始服务器了,需要先访问代理服务器判断请求的对象是否存在,如果代理服务器没有,再由代理服务器来请求初始服务器把对象返回给客户,同时在自己的磁盘空间保存对象。
这里需要注意,客户和初始服务器的架构是
客户-服务器
模式,而代理服务器不仅能当服务器使用,也可以当作客户端使用。
代理服务器一般由 ISP(Internet Service Provider)
,提供。注意不是老色批。。。ISP 也就是我们常说的运营商,你懂的。
那么为什么需要代理服务器的存在呢?相信你看完上面的描述应该能大致猜到它的作用。
- 首先,代理服务器可以大大减少对客户请求的响应时间,能够更快给用户响应。
- 其次,代理服务器可以减少一个机构接入链路到网络的通信量,降低网络带宽,降低运营商成本。
- 然后,代理服务器可以分担初始服务器的压力,改善应用程序的性能。
DASH
通过上面的描述我们知道 HTTP 是可以传输普通文件、音频、视频的,这些传输的信息统称为 MIME
类型。HTTP 在传递视频中,也只是把视频当作对象来传输,而一个对象其实就是一个文件,一个文件都在 HTTP 中都可以用 URL 来表示。当用户在看视频时,客户与服务器建立一个 TCP 连接并发送对该 URL 的 GET 请求,然后服务器响应给客户端时,客户端会缓存一定量的字节数据,当数据超过预先设定的门限时,客户应用程序就开始播放视频。
这种方式有一种局限性就是对每个客户端来说,尽管每个客户端可用的带宽量不同,但所有客户端都收到相同的视频编码。这就造成带宽浪费。这就相当我是一个 2兆的网络和 50 兆的光纤都能收到相同的视频编码,以几乎相同的等待时间开始播放视频,那么我为什么还要花 50 兆光纤的钱呢?
为了改善这一现象,出现了 HTTP 的 DASH
,DASH 即 Dynamic Adaptive Streaming HTTP
,动态适应流。它的理念是针对不同流量的网络来说,所能够传输的比特数据也不相同。DASH 允许客户使用不同的因特网传输速率可以播放不同编码速率的视频。对于 3G 用户和光纤用户自然会选择以不同的速率传输比特数据,从而最大限度的使用带宽。
CDN
随着互联网的接入用户变得越来越多,视频逐渐成为了比特传输的瓶颈和用户的强烈需求。作为一个因特网视频公司,最一开始提供流式服务最直接的方式是建立单一的大规模数据中心
。在数据中心内缓存所有视频,并直接从数据中心向世界范围内传播视频。但是这种方式存在三种问题
- 如果客户远离数据中心,那么服务器到客户分组会跨越许多通信链路并且可能通过许多 ISP,这样你的视频播放能快到哪去?
- 每次视频数据都会重新传递给客户端,这样会严重浪费网络带宽,而且视频公司会支付重复的带宽费用
- 单点故障问题,只要视频数据中心宕机或者其他事故,直接导致全球范围内的视频无法播放。
为了应对能够向全世界的用户 24 小时不间断的分发视频,几乎所有的主流视频公司都会使用 内容分发网(Content Distribution Network, CDN)
。CDN 管理分布在多个地理位置上的服务器,在每个服务器上缓存各种视频、音频、文件等。
CDN 内容选择策略
CDN 管理分布在多个地理位置上的服务器,在它的服务器上存储视频副本,并且所有试图将每个用户请求定向到一个提供最好用户体验的 CDN 位置。那么服务器如何选址呢?事实上有两种服务器安置原则
深入
,它的主要目标是靠近用户,通过减少端用户和 CDN 集群之间链路和路由器的数量,从而改善了用户感受的时延和吞吐量。邀请做客
,这个原则是通过在少量(例如 10 个)关键位置建造大集群来邀请 ISP 来做客,与深入设计原则相比,邀请做客设计通常产生较低的维护和管理开销。
CDN 工作流程
CDN 可以是专用 CDN(private CDN)
, 即它由内容提供商自己所拥有;另一种 CDN 是 第三方 CDN(third-party CDN)
,它代表多个内容提供商分发内容。
下面我们来聊一下 CDN 工作流程,如下图所示
-
用户想要访问指定网站的内容
-
用户首先发起对本地 DNS,LDNS 的查询,LDNS 会将请求中继到网站 DNS 服务器,网站的 DNS 服务器会返回给 LDNS 一个网站 CDN 权威服务器的地址
-
LDNS 服务器会发送第二个请求给网站 CDN 权威服务器,希望获取网站内容分发服务器的地址,网站 CDN 会把 CDN 内容分发服务器的地址发送给本地 DNS 服务器
-
本地 DNS 服务器会把网站 CDN 内容分发服务器的地址发送给用户
-
用户知道网站 CDN 内容分发服务器的地址后,无需额外操作,直接和网站 CDN 内容分发服务器建立 TCP 连接,并且发出 HTTP GET 请求,如果使用了 DASH 流,会根据不同 URL 的版本选择不同速率的块发送给用户。
CDN 集群选择策略
任何 CDN 的部署,其核心是 集群选择策略(cluster selection strategy)
, 即动态的将客户定向到 CDN 中某个服务器集群或数据中心的机制。一种简单的策略是指派客户到 地理上最为临近(geographically closest)
的集群。这种选择策略忽略了时延和可用带宽随因特网路径时间而变化,总是为特定的客户指派相同的集群;还有一种选择策略是 实时测量(real-time measurement)
,该机制是基于集群和客户之间的时延和丢包性能执行周期性检查。
DNS 因特网目录服务协议
试想一个问题,我们人类可以有多少种识别自己的方式?可以通过身份证来识别,可以通过社保卡号来识别,也可以通过驾驶证来识别,尽管我们有多种识别方式,但在特定的环境下,某种识别方法可能比另一种方法更为适合。因特网上的主机和人类一样,可以使用多种识别方式进行标识。互联网上主机的一种标识方法是使用它的 主机名(hostname)
,如 www.facebook.com、 www.google.com 等。但是这是我们人类的记忆方式,路由器不会这么理解,路由器喜欢定长的、有层次结构的 IP地址
,so,还记得 IP 是什么吗?
IP 地址现在简单表述一下,就是一个由 4 字节组成,并有着严格的层次结构。例如 121.7.106.83
这样一个 IP 地址,其中的每个字节都可以用 .
进行分割,表示了 0 - 255
的十进制数字。(具体的 IP 我们会在后面讨论)
然而,路由器喜欢的是 IP 地址进行解析,我们人类却便于记忆的是网址,那么路由器如何把 IP 地址解析为我们熟悉的网址地址呢?这时候就需要 DNS
出现了。
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 服务器
。这些服务器的层次模型如下图所示
假设现在一个 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 的报文格式
下面对报文格式进行解释
-
前 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 体系中,所有的计算机和设备都被称为对等体,他们互相交换工作。对等网络中的每个对等方都等于其他对等方。网络中没有特权对等体,也没有主管理员设备。
从某种意义上说,对等网络是计算机世界中最平等的网络。每个对等方都相等,并且每个对等方具有与其他对等方相同的权利和义务。对等体同时是客户端和服务器。
实际上,对等网络中可用的每个资源都是在对等之间共享的,而无需任何中央服务器。P2P 网络中的共享资源可以是诸如处理器使用率,磁盘存储容量或网络带宽等。
P2P 用来做什么
P2P 的主要目标是共享资源并帮助计算机和设备协同工作,提供特定服务或执行特定任务。如前面说到的,P2P 用于共享各种计算资源,例如网络带宽或磁盘存储空间。 但是,对等网络最常见的例子是 Internet 上的文件共享。 对等网络非常适合文件共享,因为它们允许连接到它们计算机等同时接收文件和发送文件。
BitTorrent
是 P2P 使用的主要协议。
P2P 网络的作用
P2P 网络具有一些使它们有用的特征
- 很难完全掉线,即使其中的一个对等方掉线,其他对等方仍在运行并进行通信。 为了使 P2P(对等)网络停止工作,你必须关闭所有对等网络。对等网络具有很强的可扩展性。 添加新的对等节点很容易,因为你无需在中央服务器上进行任何中央配置。
- 当涉及到文件共享时,对等网络越大,速度越快。 在 P2P 网络中的许多对等点上存储相同的文件意味着当某人需要下载文件时,该文件会同时从多个位置下载。
TELNET
TELNET 又称为远程登录,是一种应用层协议,它为用户提供了在本地机器上就能够操控远程主机工作的能力。例如下面这幅图所示
主机 A 可以直接通过 TELNET 协议访问主机 B。
TELNET 利用 TCP 的一条连接,通过一条连接向主机发送文字命令并在主机上执行。
使用 TELNET 协议进行远程登录时需要满足一下几个条件
- 必须知道远程主机的 IP 地址或者域名
- 必须知道登录标识和口令
TELNET 远程登录一般使用 23 端口
TELNET 的工作过程如下
- 本地主机与远程主机建立连接,这个连接其实是 TCP 连接,用户需要知道指定主机的 IP 地址或者域名
- 与远程主机建立连接后,在本地主机终端上输入的字符都会以
NVT(Net Virtual Terminal)
的形式发送至远程主机,这个过程实际上是发送一个数据包到远程主机。 - 远程主机接受数据包后,产生的输出会以 NVT 的格式发送给本地主机一个数据包,包括输入命令回显和命令执行结果
- 最后,本地主机终端对远程主机撤销链接,这个过程实际上就是 TCP 断开连接的过程。
SSH
TELNET 有一个非常明显的缺点,那就是在主机和远程主机的发送数据包的过程中是明文传输,未经任何安全加密,这样的后果是容易被互联网上不法分子嗅探到数据包来搞一些坏事,为了数据的安全性,我们一般使用 SSH
进行远程登录。
SSH 是加密的远程登录系统。使用 SSH 可以加密通信内容,即时数据包被嗅探和抓取也无法破解所包含的信息,除此之外,SSH 还有一些其他功能
- SSH 可以使用更强的认证机制
- SSH 可以转发文件
- SSH 可以使用端口转发功能
端口转发(Port forwarding)
是 SSH 为网络安全通信使用的一种方法。SSH 可以利用端口转发技术来传输其他 TCP/IP 协议的报文,当使用这种方式时,SSH 就为其他服务在客户端和服务器端建立了一条安全的传输管道端口转发是指将特定端口号所收到的消息转发到指定 IP 地址和端口号的一种机制。
FTP
FTP(File Transfer Protocol,文件传输协议)
是应用层协议之一。FTP 协议包括两个组成部分,分为 FTP 服务器和 FTP 客户端。其中 FTP 服务器用来存储文件,用户可以使用 FTP 客户端通过 FTP 协议访问位于 FTP 服务器上的资源。
由于 FTP 传输效率非常高,一般用来在网络上传输大的文件。
默认情况下 FTP 协议使用 TCP 端口中的 20 和 21 这两个端口,其中 20 用于传输数据,21 用于传输控制信息。FTP TCP 21 号端口上进行文件传输时,每次都会建立一个用于数据传输的 TCP 连接,数据传输完毕后,传输数据的这条连接也会被断开,在控制用的连接上继续进行命令或应答的处理。
SMTP
提供电子邮件服务的协议叫做 SMTP(Simple Mail Transfer Protocol)
, SMTP 在传输层也是用了 TCP 协议。
早起电子邮件是在发送端主机和接收端主机之间直接建立 TCP 连接。发送方编写好邮件之后会将邮件保存在磁盘中,然后与接受主机建立 TCP 连接,将邮件发送到接受主机的磁盘中。当发送方把邮件发送后,再从本地磁盘中删除邮件。如果接受主机因为特殊情况无法接收,发送端将等待一段时间后重新发送。
这种方法虽然能够保证电子邮件的完整性和有效性,但却不适合当今的互联网,因为早期的电子邮件只能在线发送,这种方式显然不够成熟。
针对于此,提出了邮件服务器
的概念。邮件服务器构成了整个邮件系统的核心。每个接收方在其中的邮件服务器上会有一个邮箱(mailbox)
存在。用户的邮箱管理和维护发送给他的报文。
一个典型的邮件发送过程是:从发送方的用户代理开始,传输到发送方的邮件服务器,再传输到接收方的邮件服务器,然后在这里被分发到接收方的邮箱中。用接收方的用户想要从邮箱中读取邮件时,他的邮件服务器会对用户进行认证。如果发送方发送的邮件无法正确交付给接收方的服务器,那么发送方的用户代理会把邮件存储在一个报文队列(message queue)
中,并在以后尝试再次发送,通常每 30 分钟发送一次,如果一段时间后还发送不成功,服务器就会删除报文队列中的邮件并以电子邮件的方式通知发送方。
现在你知道了两台邮件服务器邮件发送的大体过程,那么,SMTP 是如何将邮件从 Alice 邮件服务器发送到 Bob 的邮件服务器的呢?主要分为下面三个阶段
建立连接
:在这一阶段,SMTP 客户请求与服务器的25端口建立一个 TCP 连接。一旦连接建立,SMTP 服务器和客户就开始相互通告自己的域名,同时确认对方的域名。邮件传送
:一旦连接建立后,就开始邮件传输。SMTP 依靠 TCP 能够将邮件准确无误地传输到接收方的邮件服务器中。SMTP 客户将邮件的源地址、目的地址和邮件的具体内容传递给 SMTP 服务器,SMTP 服务器进行相应的响应并接收邮件。连接释放
:SMTP 客户发出退出命令,服务器在处理命令后进行响应,随后关闭 TCP 连接。
MIME 类型
最一开始,互联网中的电子邮件只能处理文本格式,后来也逐渐扩展为 MIME 类型,我们上面也简单提到了一句 MIME 类型,MIME(Multipurpose Internet Mail Extensions)
是用途互联网邮件扩展类型。
它是一个互联网标准,扩展了电子邮件标准,使其能够支持很多格式,这些格式如下
- 超文本标记语言文本 .html text/html
- xml文档 .xml text/xml
- 普通文本 .txt text/plain
- PNG图像 .png image/png
- GIF图形 .gif image/gif
- JPEG图形 .jpeg,.jpg image/jpeg
- AVI 文件 .avi video/x-msvideo 等。
后记
文章涵盖了许多应用层协议,包括 HTTP、DNS、SMTP、FTP、TELNET 协议等
这些应用层协议我们在日常工作中都会用到,我们不仅仅是用户,还是程序员,势必要对其进行了解,我给你画了一些图帮助你理解清楚这些协议,简化的背后却是复杂而艰巨的规范标准和开发的复杂。
DHCP协议
哈喽小伙伴们大家好啊,这里是 cxuan 计算机网络连载系列的文章第 11 篇,本篇文章我们来聊一聊 DHCP 协议。在聊之前,先想象一个场景。
你现在站在地铁上或者坐在办公室中,你的手机也好,电脑也好都有一个 IP 地址
,假如这个 IP 地址是你动输入的,你需要写下面这些东西 ……
电脑配置这些还好,直接咔咔咔的配置完了,如果你用的是手机,那么你需要点到 IP 地址,输入 IP 地址,点到子网掩码,输入子网掩码,点到默认路由,输入路由,点到 DNS 服务器,输入 DNS 服务器 …… 这玩意这么麻烦啊,恰好你刚配置完,领导叫你开会,得嘞,刚配置好的地址白瞎了。换了一个环境,需要重新配置 IP 地址,于是你把上面的步骤再重复了一遍,这时候散会了,然后你炸了。。。。。。
我们还省去了你有可能配置错误的时候。
上面这段描述最让人恼火的就是你需要手动配置 IP 地址,woc,为啥不能设置成自动配置 IP 地址呢?谁说不能的,能!那就是用 DHCP
, 这也是我们下面要聊的内容。
认识 DHCP
DHCP
的全称是 Dynamic Host Configuration Protocol
动态主机配置协议。使用 DHCP 就能实现自动设置 IP 地址、统一管理 IP 地址分配。也就是不管你是在开会还是在工位干活,都省去了手动配置 IP 地址这一步繁琐的操作,同时 DHCP 也大大减少了可能由于你手动分配 IP 地址导致错误的几率。
DHCP 与 IP 密切相关,它是 IP 网络上所使用的协议。如果你想要使用 DHCP 提供服务的话,那么在整条通信链路上就需要 DHCP 服务器
的存在,连接到网络的设备使用 DHCP 协议从 DHCP 服务器请求 IP 地址。DHCP 服务器会为设备分配一个唯一的 IP 地址。
除了 IP 地址外,DHCP 服务器还会把子网掩码,默认路由,DNS 服务器告诉你。
DHCP 服务器
现在,你不需要手动配置 IP 地址,也不再需要管理 IP 地址了,管理权已经移交给了 DHCP 服务器,DHCP 服务器会维护 IP 地址池,在网络上启动时会将地址租借给启用 DHCP 的客户端。
由于 IP 地址是动态的(临时分配)而不是静态的(永久分配),因此不再使用的 IP 地址会自动返回 IP 地址池中进行重新分配。
那么 DHCP 服务器由谁维护呢?
网络管理员负责建立 DHCP 服务器,并以租约的形式向启用 DHCP 的客户端提供地址配置,啊,既然不需要我管理,那就很舒服了~
好了,现在你能舒舒服服的开发了,你用 postman 配了一条 192.168.1.4/x/x 的接口进行请求,请求能够顺利进行,但是过了一段时间后,你发现 192.168.1.4/x/x 这个接口请求不通了,这是为啥呢?然后你用 ipconfig
查询了一下自己的 IP 地址,发现 IP 地址变成了 192.168.1.7,怎么我用着用着 IP 地址还改了?DHCP 是个垃圾,破玩意!!@#¥%¥%……¥%
其实,这也是一个 DHCP 服务器的一个功能,DHCP 服务器通常为每个客户端分配一个唯一的动态 IP 地址,当该 IP 地址的客户端租约到期时,该地址就会更改。
唯一意思说的就是,如果你手动设置了一个静态 IP,同时 DHCP 服务器分配了一个动态 IP,这个动态 IP 和静态 IP 一样,那么必然会有一个客户端无法上网。
我就遇到过这种情况,我使用虚拟机配置的静态 IP 是192.168.1.8,手机使用 DHCP 也同样配置了 192.168.1.8 的 IP 地址,此时我的虚拟机还没有接入网络,当我接入网络时,我怎样也连不上虚拟机了,一查才发现 IP 地址冲突了 ……
虽然 DHCP 服务器能提供 IP 地址,但是他怎么知道哪些 IP 地址空闲,哪些 IP 地址正在使用呢?
实际上,这些信息都配置在了数据库
中,下面我们就来一起看一下 DHCP 服务器维护了哪些信息。
- 网络上所有有效的 TCP/IP 配置参数
这些参数主要包括主机名(Host name)、DHCP 客户端(DHCP client)、域名(Domain name)、IP 地址IP address)、网关(Netmask)、广播地址(Broadcast address)、默认路由(default rooter)。
- 有效的 IP 地址和排除的 IP 地址,保存在 IP 地址池中等待分配给客户端
- 为某些特定的 DHCP 客户端保留的地址,这些地址是静态 IP,这样可以将单个 IP 地址一致地分配给单个DHCP 客户端
好了,现在你知道 DHCP 服务器都需要保存哪些信息了,并且看过上面的内容,你应该知道一个 DHCP 的组件有哪些了,下面我们就来聊一聊 DHCP 中都有哪些组件,这些组件缺一不可。
DHCP 的组件
使用 DHCP 时,了解所有的组件很重要,下面我为你列出了一些 DHCP 的组件和它们的作用都是什么。
DHCP Server
,DHCP 服务器,这个大家肯定都知道,因为我们上面就一直在探讨 DHCP 服务器的内容,使用 DHCP ,是一定要有 DHCP 服务器的,要不然谁给你提供服务呢?DHCP Client
,DHCP 客户端,这个大家应该也知道,毕竟只有一个服务端不行啊,没有客户端你为谁服务啊?DHCP 的客户端可以是计算机、移动设备或者其他需要连接到网络的任何设备,默认情况下,大多数配置为接收 DHCP 信息。Ip address pool
: 你得有 IP 地址池啊,虽然说你 DHCP 提供服务,但是你也得有工具啊,没有工具玩儿啥?IP 地址池是 DHCP 客户端可用的地址范围,这个地址范围通常由最低 -> 最高顺序发送。Subnet
:这个组件是子网,IP 网络可以划分一段一段的子网,子网更有助于网络管理。Lease
:租期,这个表示的就是 IP 地址续约的期限,同时也代表了客户端保留 IP 地址信息的时间长度,一般租约到期时,客户端必须续约。DHCP relay
:DHCP 中继器,这个一般比较难想到,DHCP 中继器一般是路由器或者主机。DHCP 中继器通常应对 DHCP 服务器和 DHCP 客户端不再同一个网断的情况,如果 DHCP 服务器和 DHCP 客户端在同一个网段下,那么客户端可以正确的获得动态分配的 IP 地址;如果不在的话,就需要使用 DHCP 中继器进行中继代理。
现在 DHCP 的组件你了解后,下面我就要和你聊聊 DHCP 的工作机制了。
DHCP 工作机制
在聊 DHCP 工作机制前,先来看一下 DHCP 的报文消息
DHCP 报文
DHCP 报文共有一下几种:
- DHCP DISCOVER :客户端开始 DHCP 过程发送的包,是 DHCP 协议的开始
- DHCP OFFER :服务器接收到 DHCPDISCOVER 之后做出的响应,它包括了给予客户端的 IP 租约过期时间、服务器的识别符以及其他信息
- DHCP REQUEST :客户端对于服务器发出的 DHCPOFFER 所做出的响应。在续约租期的时候同样会使用。
- DHCP ACK :服务器在接收到客户端发来的 DHCPREQUEST 之后发出的成功确认的报文。在建立连接的时候,客户端在接收到这个报文之后才会确认分配给它的 IP 和其他信息可以被允许使用。
- DHCP NAK :DHCPACK 的相反的报文,表示服务器拒绝了客户端的请求。
- DHCP RELEASE :一般出现在客户端关机、下线等状况。这个报文将会使 DHCP 服务器释放发出此报文的客户端的 IP 地址
- DHCP INFORM :客户端发出的向服务器请求一些信息的报文
- DHCP DECLINE :当客户端发现服务器分配的 IP 地址无法使用(如 IP 地址冲突时),将发出此报文,通知服务器禁止使用该 IP 地址。
DHCP 的工作机制比较简单,无非就是客户端向服务器租借 IP ,服务器提供 IP 给客户端的这个过程呗。嗯,你很聪明,大致是这样的,不过有一些细节需要注意下,下面我通过两张图来和你聊一下。
关于从 DHCP 中获取 IP 地址的流程,主要分为两个阶段。
第一个阶段是 DHCP 查找包的阶段
查找包的阶段主要分为两步:第一步是 DHCP 发现包,第二步是 DHCP 提供包。
DHCP 客户端在通信链路上发起广播
,看看链路上有没有能提供 DHCP 包的服务器,然后通信链路上的各个节点会检查自己是否能够提供 DHCP 包,这时 DHCP 服务器说它能够提供 DHCP 包,然后 DHCP 就发出一个 DHCP 包沿着通信链路返回给 DHCP 客户端。
第二个阶段是 DHCP 的请求阶段。
DHCP 的请求包也分为两步:第一步是 DHCP 请求包,第二步是 DHCP 确认包。
DHCP 客户端在通信链路上发起 DHCP 请求包,请求包主要是告诉 DHCP 服务器,它想要用上一步提供的网络设置,然后 DHCP 服务器向 DHCP 客户端发送确认包,表示允许 DHCP 客户端使用第二步发送的网络设置。
至此,DHCP 的网络设置就结束了,然后通信链路上的主机之间就可以进行 TCP/IP 通信了。
当不需要 IP 地址时,可以发送 DHCP 解除包(DHCP RELEASE)
进行解除。另外,DHCP 的设置中通常会有一个租期时间的设定,DHCP 客户端在这个时限内可以发送 DHCP 请求包通知想要延长这个期限。
DHCP 状态机
我们上面知道 DHCP 会发送几种请求包,我们知道,动作肯定伴随着状态的更改,DHCP 也是一样的,在 DHCP 发送/接收各种包的时候,其状态也在发生相应的改变。DHCP 协议可以在客户端和服务器上运行状态机。状态决定了协议接下来要处理的消息类型。
状态之间的转换(箭头)是由于接收和发送消息或者计时器到期才发生的转换。下面是 DHCP 的状态轮转图。
客户端在开始时没有消息,此时处于 INIT
状态,然后客户端会在通信链路上发起一个广播 DHCP DISCOVER
。
在 Selecting
选择状态下,客户端会收集 DHCPOFFER 消息,直到确定要使用的地址和服务器为止。
一旦 DHCP 客户端做好选择后,它就会发送 DHCPREQUEST 消息并进入 Requesting 状态
,在这个状态下它很可能收到并不需要的 ACK 响应,如果这个状态下没有找到合适的地址的话,那么客户端就会发送DHCPDECLINE
并恢复为 INIT 状态,但是这种发生的概率比较小。
在处于 Requesting 状态下的客户端很可能接受发送过来的 DHCPACK 消息,获取超时时间 T1
和 T2
,然后进入 Bound
绑定状态,在这个状态下可以使用地址直到地址过期。
在第一个计时器 T1 到期时,客户端会进入 renewing
续订状态,并重新尝试建立租约时间,如果收到新的 ACK 消息就表示续订成功,然后就恢复为 Bound 状态。
如果没有收到 ACK 那么 T2 会最终过期进入 Rebinding
状态,进入这个状态的客户端会重新尝试获取地址,如果最终的租约到期,那么客户端必须放弃租约地址,并且如果没有其他地址或网络连接要使用,客户端将断开连接。
DHCP 冲突
现在我们讨论一下 DHCP 冲突的问题,DHCP 冲突其实就是 IP 重了
,当一个子网中两个或者更多主机配置了相同的 IP 地址时,就会发生 IP 冲突的现象。发生这种情况可能导致的后果是两个冲突的主机混在一起,一台主机可能接收了另一台主机的数据包。
那么造成这种情况的原因是啥呢?
造成这种情况的原因有很多,这里我列举两个可能出现的情况:
-
第一种情况是一台主机配置了静态 IP 地址,这台主机联网后,其 IP 地址不会在 DHCP 服务器中,然后另外一个主机入网,DHCP 服务器给这台主机自动分配了相同的 IP 地址,这两个地址就产生了 IP 冲突。
-
第二种情况是,客户端从 DHCP 服务器获得了 IP 地址,然后这台主机下线了,随着租约到期,DHCP 会将这个 IP 地址又分配给了其他主机,等到这个主机重新上线后,由于某种原因,计算机无法访问 DHCP 服务器,这种情况下会造成 IP 冲突。
当检测到 IP 冲突时,通常 Windows 系统和 Mac 系统会弹出 IP 冲突的弹窗。
DHCP 中继代理
常规家庭网络(土豪除外)中大多数都只有一个以太网
,也就是 LAN 网段,一个 DHCP 服务器完全可以满足 LAN 中的客户机使用。但是,在更复杂的网络中,比如企业或者学校,一台 DHCP 服务器显然就无法满足了。因此,这种情况下,往往需要 DHCP 的统一管理,具体实现方式可以通过 DHCP 中继代理
来转发 DHCP 流量,如下图所示。
如上图所示,存在两个网段 A 和网段 B,DHCP 客户机和 DHCP 服务器不在一个网段内,所以我们在通信链路上架设了一个中继代理,DHCP 客户机通过访问中继代理以达到访问 DHCP 服务器的目的。
使用这种方式,我们不再需要在每个网段都设置一个 DHCP 服务器,只需要在每个网段架设一个中继代理即可。它可以设置 DHCP 服务器的 IP 地址,从而可以在 DHCP 服务器上为每个网段注册 IP 地址的分配范围。
DHCP 客户端会向 DHCP 中继代理发送 DHCP 请求包,而 DHCP 中继代理在收到这个广播包之后再以单播的形式发送给 DHCP 服务器。服务器收到该包以后再向 DHCP 中继代理返回应答,并由 DHCP 中继代理将此包发送给 DHCP 客户端。
DHCP 认证
我们总是假想所有情况都能够顺利进行,害怕出问题,这也许意味着我永远只是个初级程序员吧。我们上面探讨的 DHCP 服务器都是合理的、合法的,但是互联网是一把双刃剑,不是所有人都是合法公民。如果假设了一个未经授权的 DHCP 服务器怎么办?它很可能会对网络造成影响。
为了避免这些问题,在 [RFC3118] 中指定了一种认证 DHCP 消息的方法。 它定义了一个 DHCP 选项,即Authentication 选项,如下所示
认证选项的主要目的就是确定 DHCP 消息是否来自一个授权的发送方。
身份验证的代码(code)属性值是 90,而长度(Length)给出了选项中的字节数(不包括代码和长度字段的字节)。如果协议(Protocol)和算法(Algorithm)属性被设置为 0 ,则认证信息
字段将保存一个简单的共享配置的 token,token 大家开发应该都接触过把,就是一条认证信息。只要配置令牌在客户端和服务器上匹配,这条消息就会被接受。
我们上面聊到的只是其中的一种,还有一种更安全的方法是涉及所谓的延迟身份认证
,如果协议和算法都被设置为 1,就表示使用了延迟身份认证。在这种情况下,客户端的 DHCPDISCOVER 消息或 DHCPINFORM 消息包括身份验证选项,并且服务器以其 DHCPOFFER 或 DHCPACK 消息中包含的身份验证信息进行响应。这个认证信息中包括一个消息认证码
,它提供对发送方的认证和消息的完整性校验。RDM 表示中继检测,中继检测包括一个单项递增的值,只要经过一个代理中继,那么这个中继检测的值就会 + 1。
虽然 DHCP 认证能够确保安全性,但是它没有被广泛使用,原因有两点:
- 首先,该方法要求在 DHCP 服务器和每个需要身份验证的客户端之间分配共享密钥。
- 其次,在 DHCP 已经被广泛使用之后,才指定了 Authentication 选项。
总结
这篇文章我和你探讨了计算机网络中一个比较容易忽视的概念,为什么说他容易忽视呢?因为我们平常开发过程中基本上不会管 IP 地址的配置的,也就是环境搭建的时候会用到一些,但是要系统学习计算机网络的话,DHCP 的重要性不可忽视,DHCP 包括工作机制、DHCP 报文消息,DHCP 状态机、DHCP 认证这些都是需要你了解并掌握的。
DNS协议
试想一个问题,我们人类可以有多少种识别自己的方式?可以通过身份证来识别,可以通过社保卡号来识别,也可以通过驾驶证来识别,尽管我们有多种识别方式,但在特定的环境下,某种识别方法可能比另一种方法更为适合。因特网上的主机和人类一样,可以使用多种识别方式进行标识。互联网上主机的一种标识方法是使用它的 主机名(hostname)
,如 www.facebook.com、 www.google.com 等。但是这是我们人类的记忆方式,路由器不会这么理解,路由器喜欢定长的、有层次结构的 IP地址
。
如果你还不理解 IP 的话,可以翻阅一下我的这篇文章计算机网络层
IP 地址现在简单表述一下,就是一个由 4 字节组成,并有着严格的层次结构。例如 121.7.106.83
这样一个 IP 地址,其中的每个字节都可以用 .
进行分割,表示了 0 - 255
的十进制数字。
然而,路由器喜欢的是 IP 地址进行解析,我们人类却便于记忆的是网址,那么路由器如何把 IP 地址解析为我们熟悉的网址地址呢?这时候就需要 DNS
出现了。
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 工作概述
假设运行在用户主机上的某些应用程序(如 Web 浏览器或邮件阅读器) 需要将主机名转换为 IP 地址。这些应用程序将调用 DNS 的客户端,并指明需要被转换的主机名。用户主机上的 DNS 收到后,会使用 UDP 通过 53 端口向网络上发送一个 DNS 查询报文,经过一段时间后,用户主机上的 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 服务器
。这些服务器的层次模型如下图所示
假设现在一个 DNS 客户端想要知道 www.amazon.com 的 IP 地址,那么上面的域名服务器是如何解析的呢?首先,客户端会先根服务器之一进行关联,它将返回顶级域名 com
的 TLD 服务器的 IP 地址。该客户则与这些 TLD 服务器之一联系,它将为 amazon.com 返回权威服务器的 IP 地址。最后,该客户与 amazom.com 权威服务器之一联系,它为 www.amazom.com 返回其 IP 地址。
DNS 层次结构
我们现在来讨论一下上面域名服务器的层次系统
根 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 的查询步骤,从 DNS 解析 IP 再到 DNS 返回的一系列流程。
注意:通常情况下 DNS 会将查找的信息缓存在浏览器或者计算机本地中,如果有相同的请求到来时,就不再会进行 DNS 查找,而会直接返回结果。
通常情况下,DNS 的查找会经历下面这些步骤
-
用户在浏览器中输入网址
www.example.com
并点击回车后,查询会进入网络,并且由 DNS 解析器进行接收。 -
DNS 解析器会向根域名发起查询请求,要求返回顶级域名的地址。
-
根 DNS 服务器会注意到请求地址的前缀并向 DNS 解析器返回 com 的
顶级域名服务器(TLD)
的 IP 地址列表。 -
然后,DNS 解析器会向 TLD 服务器发送查询报文
-
TLD 服务器接收请求后,会根据域名的地址把
权威 DNS 服务器
的 IP 地址返回给 DNS 解析器。 -
最后,DNS 解析器将查询直接发送到权威 DNS 服务器
-
权威 DNS 服务器将 IP 地址返回给 DNS 解析器
-
DNS 解析器将会使用 IP 地址响应 Web 浏览器
一旦 DNS 查找的步骤返回了 example.com 的 IP 地址,浏览器就可以请求网页了。
整个流程如下图所示
DNS 解析器
进行 DNS 查询的主机和软件叫做 DNS 解析器
,用户所使用的工作站和个人电脑都属于解析器。一个解析器要至少注册一个以上域名服务器的 IP 地址。DNS 解析器是 DNS 查找的第一站,其负责与发出初始请求的客户端打交道。解析器启动查询序列,最终使 URL 转换为必要的 IP 地址。
DNS 递归查询和 DNS 递归解析器不同,该查询是指向需要解析该查询的 DNS 解析器发出请求。DNS 递归解析器是一种计算机,其接受递归查询并通过发出必要的请求来处理响应。
DNS 查询类型
DNS 查找中会出现三种类型的查询。通过组合使用这些查询,优化的 DNS 解析过程可缩短传输距离。在理想情况下,可以使用缓存的记录数据,从而使 DNS 域名服务器能够直接使用非递归查询。
-
递归查询
:在递归查询中,DNS 客户端要求 DNS 服务器(一般为 DNS 递归解析器)将使用所请求的资源记录响应客户端,或者如果解析器无法找到该记录,则返回错误消息。 -
迭代查询
:在迭代查询中,如果所查询的 DNS 服务器与查询名称不匹配,则其将返回对较低级别域名空间具有权威性的 DNS 服务器的引用。然后,DNS 客户端将对引用地址进行查询。此过程继续使用查询链中的其他 DNS 服务器,直至发生错误或超时为止。 -
非递归查询
:当 DNS 解析器客户端查询 DNS 服务器以获取其有权访问的记录时通常会进行此查询,因为其对该记录具有权威性,或者该记录存在于其缓存内。DNS 服务器通常会缓存 DNS 记录,查询到来后能够直接返回缓存结果,以防止更多带宽消耗和上游服务器上的负载。
DNS 缓存
DNS 缓存(DNS caching)
有时也叫做 DNS 解析器缓存
,它是由操作系统维护的临时数据库,它包含有最近的网站和其他 Internet 域的访问记录。也就是说, DNS 缓存只是计算机为了满足快速的响应速度而把已加载过的资源缓存起来,再次访问时可以直接快速引用的一项技术和手段。那么 DNS 的缓存是如何工作的呢?
DNS 缓存的工作流程
在浏览器向外部发出请求之前,计算机会拦截每个请求并在 DNS 缓存数据库中查找域名,该数据库包含有最近的域名列表,以及 DNS 首次发出请求时 DNS 为它们计算的地址。
DNS 缓存方式
DNS 数据可缓存到各种不同的位置上,每个位置均将存储 DNS 记录,它的生存时间由 TTL(DNS 字段) 来决定。
浏览器缓存
现如今的 Web 浏览器设计默认将 DNS 记录缓存一段时间。因为越靠近 Web 浏览器进行 DNS 缓存,为检查缓存并向 IP 地址发出请求的次数就越少。发出对 DNS 记录的请求时,浏览器缓存是针对所请求的记录而检查的第一个位置。
在 chrome
浏览器中,你可以使用 chrome://net-internals/#dns 查看 DNS 缓存的状态。这是基于 Windows 下查询的,我的 Mac 电脑输入上面 url 后无法查看 DNS ,只能 clear host cache
,我也不知道为啥,可能是哪里设置的原因?
操作系统内核缓存
在浏览器缓存查询后,会进行操作系统级 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 的报文格式,其中事务 ID、标志、问题数量、回答资源记录数、权威名称服务器计数、附加资源记录数这六个字段是 DNS 的报文段首部,报文段首部一共有 12 个字节。
报文段首部
报文段首部是 DNS 报文的基础结构部分,下面我们对报文段首部中的每个字节进行描述
- 事务 ID: 事务 ID 占用 2 个字节。它是 DNS 的标识,又叫做
标识符
,对于请求报文和响应报文来说,这个字段的值是一样的,通过标识符可以区分 DNS 应答报文是对哪个请求进行响应的。 - 标志:标志字段占用 2 个字节。标志字段有很多,而且也比较重要,下面列出来了所有的标志字段。
每个字段的含义如下
-
QR(Response)
: 1 bit 的 QR 标识报文是查询报文还是响应报文,查询报文时 QR = 0,响应报文时 QR = 1。 -
OpCode
: 4 bit 的 OpCode 表示操作码,其中,0 表示标准查询,1 表示反向查询,2 表示服务器状态请求。 -
AA(Authoritative)
: 1 bit 的 AA 代表授权应答,这个 AA 只在响应报文中有效,值为 1 时,表示名称服务器是权威服务器;值为 0 时,表示不是权威服务器。 -
TC(Truncated)
: 截断标志位,值为 1 时,表示响应已超过 512 字节并且已经被截断,只返回前 512 个字节。 -
RD(Recursion Desired)
: 这个字段是期望递归字段,该字段在查询中设置,并在响应中返回。该标志告诉名称服务器必须处理这个查询,这种方式被称为一个递归查询。如果该位为 0,且被请求的名称服务器没有一个授权回答,它将返回一个能解答该查询的其他名称服务器列表。这种方式被称为迭代查询。 -
RA(Recursion Available)
: 可用递归字段,这个字段只出现在响应报文中。当值为 1 时,表示服务器支持递归查询。 -
zero
: 保留字段,在所有的请求和应答报文中,它的值必须为 0。 -
AD
: 这个字段表示信息是否是已授权。 -
CD
: 这个字段表示是否禁用安全检查。 -
rcode(Reply code)
:这个字段是返回码字段,表示响应的差错状态。当值为 0 时,表示没有错误;当值为 1 时,表示报文格式错误(Format error),服务器不能理解请求的报文;当值为 2 时,表示域名服务器失败(Server failure),因为服务器的原因导致没办法处理这个请求;当值为 3 时,表示名字错误(Name Error),只有对授权域名解析服务器有意义,指出解析的域名不存在;当值为 4 时,表示查询类型不支持(Not Implemented),即域名服务器不支持查询类型;当值为 5 时,表示拒绝(Refused),一般是服务器由于设置的策略拒绝给出应答,如服务器不希望对某些请求者给出应答。
相信读者跟我一样,只看这些字段没什么意思,下面我们就通过抓包的方式,看一下具体的 DNS 报文。
现在我们可以看一下具体的 DNS 报文,通过 query
可知这是一个请求报文,这个报文的标识符是 0xcd28
,它的标志如下
- QR = 0 实锤了这就是一个请求。
- 然后是四个字节的 OpCode,它的值是 0,表示这是一个标准查询。
- 因为这是一个查询请求,所以没有 AA 字段出现。
- 然后是截断标志位 Truncated,表示没有被截断。
- 紧随其后的 RD = 1,表示希望得到递归回答。
- 请求报文中没有 RA 字段出现。
- 然后是保留字段 zero。
- 紧随其后的 0 表示未经身份验证的数据是不可接受的。
- 没有 rcode 字段的值
然后我们看一下响应报文
可以看到,标志位也是 0xcd28
,可以说明这就是上面查询请求的响应。
查询请求已经解释过的报文我们这里就不再说明了,现在只解释一下请求报文中没有的内容
- 紧随在 OpCode 后面的 AA 字段已经出现了,它的值为 0 ,表示不是权威 DNS 服务器的响应
- 最后是 rcode 字段的响应,值为 0 时,表示没有错误。
问题区域
问题区域通常指报文格式中查询问题的区域部分。这部分用来显示 DNS 查询请求的问题,包括查询类型和查询类
这部分中每个字段的含义如下
- 查询名:指定要查询的域名,有时候也是 IP 地址,用于反向查询。
- 查询类型:DNS 查询请求的资源类型,通常查询类型为 A 类型,表示由域名获取对应的 IP 地址。
- 查询类:地址类型,通常为互联网地址,值为 1 。
同样的,我们再使用 wireshark 查看一下问题区域
可以看到,这是对 mobile-gtalk.l.google.com 发起的 DNS 查询请求,查询类型是 A,那么得到的响应类型应该也是 A
如上图所示,响应类型是 A ,查询类的值通常是 1、254 和 255,分别表示互联网类、没有此类和所有类,这些是我们感兴趣的值,其他值通常不用于 TCP/IP 网络。
资源记录部分
资源记录部分是 DNS 报文的最后三个字段,包括回答问题区域、权威名称服务器记录、附加信息区域,这三个字段均采用一种称为资源记录的格式,如下图所示
资源记录部分的字段含义如下
- 域名:DNS 请求的域名。
- 类型:资源记录的类型,与问题部分中的查询类型值是一样的。
- 类:地址类型、与问题中的查询类值一样的。
- 生存时间:以秒为单位,表示资源记录的生命周期。
- 资源数据长度:资源数据的长度。
- 资源数据:表示按查询段要求返回的相关资源记录的数据。
资源记录部分只有在 DNS 响应包中才会出现。下面我们就来通过响应报文看一下具体的字段示例
其中,域名的值是 mobile-gtalk.l.google.com ,类型是 A,类是 1,生存时间是 5 秒,数据长度是 4 字节,资源数据表示的地址是 63.233.189.188。
SOA 记录
如果是权威 DNS 服务器的响应的话,会显示记录存储有关区域的重要信息,这种信息就是 SOA
记录。所有 的DNS 区域都需要一个 SOA 记录才能符合 IETF 标准。 SOA 记录对于区域传输也很重要。
SOA 记录除具有 DNS 解析器响应的字段外,还具有一些额外的字段,如下
具体字段含义
PNAME
:即 Primary Name Server,这是区域的主要名称服务器的名称。RNAME
:即 Responsible authority’s mailbox,RNAME 代表管理员的电子邮件地址,@ 用 . 来表示,也就是说 admin.example.com 等同于 admin@example.com。序列号
: 即 Serial Number ,区域序列号是该区域的唯一标识符。刷新间隔
:即 Refresh Interval,在请求主服务器提供 SOA 记录以查看其是否已更新之前,辅助服务器应等待的时间(以秒为单位)。重试间隔
:服务器应等待无响应的主要名称服务器再次请求更新的时间。过期限制
:如果辅助服务器在这段时间内没有收到主服务器的响应,则应停止响应对该区域的查询。
上面提到了主要名称服务器和服务名称服务器,他们之间的关系如下
这块我们主要解释了 RR 类型为 A(IPv4) 和 SOA 的记录,除此之外还有很多类型,这篇文章就不再详细介绍了,读者朋友们可以阅读 《TCP/IP 卷一 协议》和 cloudflare 的官网 https://www.cloudflare.com/learning/dns/dns-records/ 查阅,值得一提的是,cloudflare 是一个学习网络协议非常好的网站。
DNS 安全
几乎所有的网络请求都会经过 DNS 查询,而且 DNS 和许多其他的 Internet 协议一样,系统设计时并未考虑到安全性,并且存在一些设计限制,这为 DNS 攻击创造了机会。
DNS 攻击主要有下面这几种方式
- 第一种是
Dos 攻击
,这种攻击的主要形式是使重要的 DNS 服务器比如 TLD 服务器或者根域名服务器过载,从而无法响应权威服务器的请求,使 DNS 查询不起作用。 - 第二种攻击形式是
DNS 欺骗
,通过改变 DNS 资源内容,比如伪装一个官方的 DNS 服务器,回复假的资源记录,从而导致主机在尝试与另一台机器连接时,连接至错误的 IP 地址。 - 第三种攻击形式是
DNS 隧道
,这种攻击使用其他网络协议通过 DNS 查询和响应建立隧道。攻击者可以使用 SSH、TCP 或者 HTTP 将恶意软件或者被盗信息传递到 DNS 查询中,这种方式使防火墙无法检测到,从而形成 DNS 攻击。 - 第四种攻击形式是
DNS 劫持
,在 DNS 劫持中,攻击者将查询重定向到其他域名服务器。这可以通过恶意软件或未经授权的 DNS 服务器修改来完成。尽管结果类似于 DNS 欺骗,但这是完全不同的攻击,因为它的目标是名称服务器上网站的 DNS 记录,而不是解析程序的缓存。 - 第五章攻击形式是
DDoS 攻击
,也叫做分布式拒绝服务带宽洪泛攻击,这种攻击形式相当于是 Dos 攻击的升级版
那么该如何防御 DNS 攻击呢?
防御 DNS 威胁的最广为人知的方法之一就是采用 DNSSEC 协议
。
DNSSEC
DNSSEC 又叫做 DNS 安全扩展
,DNSSEC 通过对数据进行数字签名
来保护其有效性,从而防止受到攻击。它是由 IETF 提供的一系列 DNS 安全认证的机制。DNSSEC 不会对数据进行加密,它只会验证你所访问的站点地址是否有效。
DNS 防火墙
有一些攻击是针对服务器进行的,这就需要 DNS 防火墙的登场了,DNS 防火墙
是一种可以为 DNS 服务器提供许多安全和性能服务的工具。DNS 防火墙位于用户的 DNS 解析器和他们尝试访问的网站或服务的权威名称服务器之间。防火墙提供 限速访问
,以关闭试图淹没服务器的攻击者。如果服务器确实由于攻击或任何其他原因而导致停机,则 DNS 防火墙可以通过提供来自缓存的 DNS 响应来使操作员的站点或服务正常运行。
除了上述两种防御手段外,本身 DNS 区域的运营商就会采取进步一措施保护 DNS 服务器,比如配置 DNS 基础架构,来防止 DDoS 攻击。
更多关于 DNS 的攻击和防御就是网络安全的主题,这篇文章就不再详细介绍了。
总结
这篇文章我用较多的字数为你介绍了 DNS 的基本概述,DNS 的工作机制,DNS 的查询方式,DNS 的缓存机制,我们还通过 WireShark 抓包带你认识了一下 DNS 的报文,最后我为你介绍了 DNS 的攻击手段和防御方式。
这是一篇入门 DNS 较全的文章,花了我一周多的时间来写这篇文章,这篇文章了解清楚后,基本上 DNS 的大部分问题你应该都能够回答,面试我估计也稳了。
计算机网络基础知识
如果说计算机把我们从工业时代带到了信息时代,那么计算机网络就可以说把我们带到了网络时代。随着使用计算机人数的不断增加,计算机也经历了一系列的发展,从大型通用计算机 -> 超级计算机 -> 小型机 -> 个人电脑 -> 工作站 -> 便携式电脑 -> 智能手机终端等都是这一过程的产物。计算机网络也逐渐从独立模式
演变为了 网络互联模式
。
可以看到,在独立模式下,每个人都需要排队等待其他人在一个机器上完成工作后,其他用户才能使用。这样的数据是单独管理的。
现在切换到了网络互联模式,在这种模式下,每个人都能独立的使用计算机,甚至还会有一个服务器,来为老大哥、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/IP | HTTP、SMTP、TELNET、IP、ICMP、TCP、UDP 等 | 主要用于互联网、局域网 |
IPX/SPX | IPX、NPC、SPX | 主要用于个人电脑局域网 |
AppleTalk | AEP、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。它们不受电磁干扰。光纤是一种引导型物理媒介,下面是光纤的实物图
一般长途电话网络全面使用光纤,光纤也广泛应用于因特网的主干。
陆地无线电信道
无线电信道承载电磁频谱中的信号。它不需要安装物理线路,并具有穿透墙壁、提供与移动用户的连接以及长距离承载信号的能力。
卫星无线电信道
一颗卫星电信道连接地球上的两个或多个微博发射器/接收器,它们称为地面站。通信中经常使用两类卫星:同步卫星和近地卫星。
计算机网络的数据链路层
下面我们把关注点放在数据链路层,如果没有数据链路层,计算机网络也就不复存在;这就好比大楼没有了地基,人没有了腿;所以,数据链路层的知识也固然重要,不少小伙伴只把关注点放在 TCP 和 IP 这两个协议上,这是一种狭隘的思想,需要及时纠正,计算机网络可不只有 TCP 和 IP。下面 cxuan 就和你聊聊计算机中的数据链路层。
数据链路层
数据链路层,按照 OSI 七层模型来划分的话,就属于物理层的上层
数据链路层是一种协议层,它有很多协议。数据链路层用于跨物理层在网段节点之间传输数据,通常指以太网、无线局域网等通信手段。数据链路层提供了在网络的两个实体之间传输数据的功能,并且提供了差错检测
用于纠正物理层中发生的错误。
关键概念
在数据链路层中,链路层地址有很多中不同的称谓:LAN 地址、物理地址或者 MAC 地址,因为 MAC 地址是最流行的术语,所以我们一般称呼链路层地址指的就是 MAC 地址。
下面我们就来认识一下数据链路层的几个关键概念
打包成帧
打包成帧(framing)
: 在每个网络层数据报在传输之前,几乎所有的链路层协议都会将数据报用链路层封装起来。数据链路层从网络层获取数据后将其封装成为 帧
,如果帧太大的话,数据链路层会将大帧拆分为一个个的小帧,小帧能够使传输控制和错误检测更加高效。
帧就是 0 1 序列的封装。
一个帧由 Header、Payload Field、Trailer 组成,网络层数据报就封装在 Payload Field
字段中。根据不同的物理介质,每个帧的结构也不同。帧的组成如下
帧中主要涉及的内容如下
帧头(Frame header)
:它包含帧的源地址和目的地址。有效载荷(Payload Field)
:它包含要传递的数据和信息。尾部标记(Trailer)
:它包含错误检测和错误纠正位。标记(Flag)
:它标记了帧的开始和结束。
Flag 位位于帧的开头和结尾,两个连续的标志指示帧的结束和开始
帧的类型主要有两种,固定大小的帧和可变大小的帧。
固定大小的帧(Fixed-sized Framing)
:表示帧的大小是固定的,帧的长度充当帧的边界,因此它不需要额外的边界位来标识帧的开始和结束。可变大小的帧(Sized Framing)
:表示每个真的大小是不固定的,因此保留了其他机制来标记一帧的结束和下一帧的开始。它通常用于局域网,在可变大小的帧中定义帧定界符的两种方法是长度字段(Length Field)
: 使用长度字段来确定帧的大小。它用于以太网(IEEE 802.3)结束定界符(End Delimiter)
: 经常用于令牌环
链路接入
链路接入主要指的是 MAC 协议,MAC(Medium Access Control)
协议规定了帧在链路上的传输规则。我们知道,数据链路层是 OSI 标准模型的第二层,数据链路层向下还能够细分,主要分为 The logical link control (LLC)
层和The medium access control (MAC)
层。
LLC 层又叫做逻辑控制链路
层,它主要用于数据传输,它充当网络层和数据链路层中的媒体访问控制(MAC)
子层之间的接口。LLC 层的主要功能如下
- LLC 的主要功能是发送时在 MAC 层上多路复用协议,并在接收时同样地多路分解协议。
- LLC 提供跳到跳的流和差错控制,像是路由器和路由器之间这种相邻节点的数据传输称为
一跳
。 - 它允许通过计算机网络进行多点通信。
MAC 层负责传输介质的流控制和多路复用,它的主要功能如下
- MAC 层为 LLC 和 OSI 网络的上层提供了物理层的抽象。
- MAC 层负责封装帧,以便通过物理介质进行传输。
- MAC 层负责解析源和目标地址。
- MAC 层还负责在冲突的情况下执行冲突解决并启动重传。
- MAC 层负责生成帧校验序列,从而有助于防止传输错误。
在 MAC 层中,有一个非常关键的概念就是 MAC 地址
。MAC 地址主要用于识别数据链路中互联的节点,如下图所示
MAC 地址长 48 bit,在使用网卡(NIC)
的情况下,MAC 地址一般都会烧入 ROM 中。因此,任何一个网卡的 MAC 地址都是唯一的。MAC 地址的结构如下
MAC 地址中的 3 – 24 位表示厂商识别码,每个 NIC 厂商都有特定唯一的识别数字。25 – 48 位是厂商内部为识别每个网卡而用。因此,可以保证全世界不会有相同 MAC 地址的网卡。
MAC 地址也有例外情况,即 MAC 地址也会有重复的时候,比如你可以手动更改 MAC 地址。但是问题不大,只要两个 MAC 地址是属于不同的数据链路层就不会出现问题。
可靠交付
网络层提供的可靠交付更多指的是端系统到端系统的交付,而数据链路层提供的可靠交付更多指的是单端链路节点到节点地传送。当链路层协议提供可靠交付时,它能保证无差错地经链路层移动每个网络层数据报。链路层提供可靠交付的方法和 TCP 类似,也是使用 确认
和 重传
取得的。
链路层的可靠交付通常用于出错率很高的链路,例如无线链路,它的目的是在本地纠正出错的帧,而不是通过运输层或应用层协议强制进行端到端的数据传输。对于出错率较低的链路,比如光纤、同轴电缆和双绞线来说,链路层的交付开销是没有必要的,由于这个原因,这些链路通常不提供可靠的交付
差错检测和纠正
链路层数据以帧的形式发送,在发送的过程中,接收方节点的链路层硬件可能会由于信号干扰或者电磁噪音等原因错误的把 1 识别为 0 ,0 识别为 1。这种情况下没有必要转发一个有差错的数据报,所以许多链路层协议提供一种机制来检测这样的比特差错。通过让方节点在帧中包括差错检测比特,让接收节点进行差错检查,以此来完成这项工作。
运输层和网络层通过因特网校验和来实现差错检测,链路层的差错检测通常更复杂,并且用硬件实现。差错纠正类似于差错检测,区别在于接收方不仅能检测帧中出现的比特差错,而且能够准确的确定帧中出现差错的位置。
差错检测和纠正的技术主要有
- 奇偶校验:它主要用来差错检测和纠正
- 校验和:这是一种用于运输层检验的方法
- 循环冗余校验:它更多应用于适配器中的链路层
地址映射
因为存在网络层地址(IP 地址)和 数据链路层地址(MAC 地址),所以需要在它们之间进行转换和映射,这就是地址解析协议所做的工作,更多关于地址解析协议的理解,请查阅
数据链路层的作用
数据链路层中的协议定义了互联网络的两个设备之间传输数据的规范。数据链路层需要以通信介质
作为传输载体,通信媒介包含双绞铜线、光纤、电波等红外装置。在数据分发装置上有 交换机、网桥、中继器 等中转数据。链路层中的任何设备又被称为节点(node)
,而沿着通信路径相邻节点之间的通信信道被称为 链路(link)
。实际上,在链路层上传输数据的过程中,链路层和物理层都在发挥作用。因为在计算机中,信息是以 0 1 这种二进制的形式进行传输,而实际的链路通信却是以电压的高低、光的闪灭以及电波的频谱来进行的,所以物理层的作用就是把二进制转换成为链路传输所需要的信息来进行传输。数据链路层传输也不只是单个的 0 1 序列,它们通常是以 帧
为单位进行的。
现在我们知道了数据链路层大概是干啥的,那么只有理论不行,你还得有硬通货,也就是硬件,一切的理论都离不开硬件的支撑。
硬件就可以简单理解为通信介质
,在通信介质上会有不同种类的信息传递方式,不过总的来说可以概括为两种:一种是共享介质型网络
,一种是非共享介质型网络
,下面我们就要聊一聊这两种通信类型。
通信类型分类
共享介质型网络
共享介质型网络故名思义就是多个设备共同使用同一个通信介质的网络。共享介质型网络的类型主要有以太网(Ethernet)
和 光纤分布式数据接口(Fiber Distributed Data Interface,FDDI)
。
共享说的是,多个设备会使用同一个载波信道进行发送和接收,这是一种半双工的设计。
什么是半双工?
半双工指的是数据可以在一个信道上的两个方向上相互传输,但是不能同时传输,举个简单的例子,就是你能给我发消息,我也能给你发消息,但是不能你给我发消息的同时我也在给你发消息。
既然多个设备会共同使用一个信道,那么就可能存在多个数据传输到同一个介质上导致的数据争用问题,为此,共享介质型网络有两种介质访问控制方式:争用和令牌传递。
争用
争用是发生在共享介质,载波监听多路访问(CSMA)
上的数据访问方式。在这种访问方式下,网络中各个介质会采用先到先得
的方式占用载波信道发送数据。如果多个介质同时发送帧,就势必会产生冲突,继而导致通信性能的下降和网络拥堵。下面是争用的处理方式
如上图所示,假如 A 想要给 C 发送数据,那么介质 A 会在确认周围没有其他介质要给 C 发送数据后,也就是经过一段时间后,A 会把数据马上发送给 C。
每个介质在接受到 A 发送的数据后,会从 A 报文中解析出来 MAC 地址判断是否是发送给自己的数据包,如果不是的话就是丢弃这条数据。
上面这种方式会使用在一部分以太网中,但是另外一部分以太网却使用了 CSMA 的改良方式 – CSMA/CD 。CSMA/CD 会要求每个介质提前检查一下链路上是否有可能产生冲突的现象,一旦发生冲突,那么尽可能早地释放信道。它的具体工作原理大致如下:
- 监听载波信道上是否会有数据流动,如果没有的话,那么任何介质都可以发送数据。
- 介质会检查是否发生冲突,一旦发生冲突就会丢弃数据,同时立即释放载波信道。
- 放弃数据后,会经过一段时间重新争用介质。
下面是 CSMA/CD 的改良版
上图这个过程是 CSMA(Carrier Sense Multiple Access)
,首先介质会监控载波信道上是否有数据存在,如果没有再发送,如果有,等一段时间再发送。
下面是 CD(Collision Detection)
的示意图
- 在发送数据 -> 发送完成后,如果电压一直处于规定范围内,就会认为数据已经正常发送。
- 发送途中,如果电压超过了一定范围,就会认为是数据冲突。
- 发生冲突时会先发送一个阻塞报文,继而放弃数据,在延迟一段时间后再次发送
令牌环
第二种共享介质型网络的传输方式就是令牌环
了,令牌环顾名思义就是有一个令牌一样的东西,以环为一圈进行令牌传输,那么令牌是啥呢?你想啊,我们最终的目的不就是为了传输数据吗?那么这个令牌,它可不可以作为数据呢?
其实,在这种传输方式中,令牌环是作为一种特殊报文
来传输的,它是控制传输的一种方式,在数据传输的过程中同时会将令牌进行传递,只有获得令牌的介质才能够传输数据。这种方式有两个优点,即
- 持有令牌的介质才能够传输数据,这样能够保证不会有报文冲突情况。
- 每个介质都有平等获取令牌的机会,这样保证了即使网络拥堵也不会导致性能下降。
但是这种令牌环的传递方式也是有缺点的,因为只有持有令牌的介质才能发送数据,所以即使在网络不太拥堵的情况下,其利用率也达不到 100%。
下面是令牌的传递示意图
最一开始,令牌位于介质 A 处,此时介质 A 拥有数据传输的能力,然后介质 A 把令牌传递给介质 B。
此时 B 持有令牌,所以介质 B 具有发送数据的能力。
这个数据最终会由 D 接收,然后 D 就会设置一个已接收数据的标志位,然后数据会继续向下发送。
令牌环是一项很成功的技术,尤其是在公司环境中使用,但后来被更高版本的以太网所取代。
在了解完共享网络之后,我们来探讨一波非共享网络
非共享介质型网络
如果说共享介质型网络是共享介质的话,那么非共享介质型网络就是不共享介质,那么如何通信呢?在这种方式下,网络中的每个介质会直接连上交换机
,由交换机来转发数据帧。发送端和接收端不会共享通信介质,共享通信介质的意思就是介质之间直接通信。这种网络传输方式一般采用的是全双工通信。
非共享介质型网络比较适合应用于搭建虚拟局域网(VLAN)
,但是这种通信方式有一个及其致命的弱点:一旦交换机发生故障,那么与交换机相连的所有计算机都无法通信。
下面是非共享介质型网络的通信示意图
如图所示,主机 A 发送了一个目标地址为 B,源地址为 A 的交换机,由交换机负责将数据转发给介质 B,如下图所示
非共享型网络是一种全双工通信的方式,每个介质在发送数据的同时也能够接受来自交换机传递过来的数据。
交换集线器
交换集线器是一种共享型网络通信介质,它是使用同轴电缆作为传输介质,通常用于以太网中,交换集线器也叫做以太网交换机
。
以太网交换机中的各个端口会根据介质的 MAC地址
来转发数据,那么转发数据肯定得有所依靠啊,这时可以参考的表就叫做转发表(Forwarding Table)
,转发表中记录着每个介质的 MAC 地址。转发表当然不需要我们手动维护,交换机会自动维护转发表。交换机会自学
每个数据包的经过介质的 MAC 地址,如下图所示
由于不知道主机 B 的 MAC 地址,所以主机 A 发送的数据会经过交换机广播给以太网内的其他主机,主机 B 接收到数据后,会给主机 A 回送消息。
在主机 B 给主机 A 回送消息后,交换机就知道主机 A 和主机 B 的 MAC 地址了,从此以后双方通信会在各自相连的端口之间进行。
由于 MAC 地址没有层次性,转发表中的记录个数与所有网络设备的数量
有关,当设备增加时,转发表的记录也会越来越多,检索时间会逐渐增加。所以如果需要连接多个终端时,需要将网络分成多个数据链路,采用类似 IP 地址一样对地址进行分层管理。
在网络通信的过程中,由于网络链路的冗余或者路由线路冗余可能会造成闭环
,也就是我们所称的环路
。环路会导致数据报文在网络中不断重复复制,最终导致网络设备负载过重,无法正常运行。影响的范围可能会扩散至整个局域网,导致整个局域网里的计算机无法正常使用网络。
那么如何检测网络中出现的环路呢?
环路检测方法
目前有两种检测环路的方式,一种是生成树
,一种是源路由法
。
生成树:生成树指的是每个网桥必须在 1 – 10 秒内相互交换生成树协议单元包,以此来判断哪些接口使用,从而消除环路,一旦发生故障后就会立刻切换线路,利用没有被使用的端口进行传输。
源路由法:源路由法通常是用来解决令牌环路。这种方式可以判断发送数据的源地址是通过哪个网桥实现传输的,并将帧写入 RIF,网桥会根据这个 RIF 信息发送给目标地址,即使网桥中出现了环路,数据帧也不存在被反复转发的可能。
虚拟局域网 VLAN
网络通信过程中经常会遇到网络负载过高,通信性能下降的情况,往往遇到这种情况,就需要分散网络负载,变换部署网络设备的位置等。在虚拟局域网出现之前,往往需要管理员手动变更网络的拓扑结构,比如变更主机网段,进行硬件线路改造等,但是使用了虚拟局域网,就可以不用再做如此复杂的操作了,只需要修改网络结构即可。
那么虚拟局域网究竟是什么呢?
如上图所示,交换机按照端口区分了多个网段,从而区分了广播数据的传播范围,提高网络安全性。然而异构的两个网段之间,需要利用具有路由功能的交换机才能实现通信。
由于交换机端口有两种 VLAN 属性,一个是 VLANID,一个是 VLANTAG,分别对应 VLAN 对数据包设置 VLAN 标签和允许通过的 VLANTAG(标签)数据包,不同 VLANID 端口,可以通过相互允许 VLANTAG,构建 VLAN。
以太网
以太网提了这么多次,那么以太网到底是什么?
数据链路层有很多分类,包括以太网、无线通信、PPP、ATM、POS、FDDI、Token Ring、HDMI 等,其中最著名的通信链路就是以太网了。
以太网最开始的时候,一般使用的是以同轴电缆为传输介质的共享介质型连接方式,这也是以太网的第一种方式,叫做经典以太网
。而现在,随着互联设备的处理能力和传输速度的提高,现在都采用终端和交换机之间连接方式,这也是第二种方式,叫做交换式以太网
。以太网使用的是 CSMA/CD
的总线技术,我们前面也介绍过了。
以太帧格式
在以太网链路上的数据包被称为以太帧
,以太帧开头有一个叫做前导码(Preamble)
的部分,它是由 0、1 数字交替组合而成。前导码的末尾最后是一个叫做 SFD(Start Frame Delimiter)
的域,值为 11。前导码与 SFD 共同占用 8 个字节。
以太网最后 2 bit 称为 SDF,而 IEEE802.3 中将最后 8 bit 称为 SDF。
IEEE802.3 是电气和电子工程师协会 (IEEE)标准的集合制定的标准。
这是以太帧的前导码部分,下面是以太帧的本体部分
以太帧体格式也有两种,一种是以太帧格式,一种是 IEEE802.3 标准以太帧格式。
在以太帧格式中,以太帧的本体的前端是以太网的首部,总共占用 14 字节,分别是 6 字节的目标 MAC 地址、6 字节的源 MAC 地址和 2 字节的上层协议类型,后面是数据部分,占用 46 – 1500 字节,最后是 FCS(Frame Check Sequence,帧检验序列)
4 个字节。FCS 用于检查帧是否有所损坏,因为在通信过程中由于噪声干扰,可能会导致数据出现乱码位。
IEEE802.3 以太帧的格式有区别,一般以太帧中的类型字段却在 IEEE802.3 表示帧长度,此外新增加了 LLC 和 SNAP 字段。
数据链路层在细化的话可以分为两层,介质访问控制层和逻辑链路控制层
介质访问会根据以太网等不同链路特有的首部信息进行控制,逻辑链路层则根据以太网等不同链路共有的帧头信息进行控制。
LLC 和 SNAP 就是逻辑链路控制的首部信息,那么现在你应该明白怎么回事儿了吧。
计算机网络自学指南
关于计算机网络如何学习,我就拿自己亲身实践的来举例吧,因为我也自学学起的。
我觉得最重要的就是看书(博客) + 实践。
当然视频是最快速的入门方式,你可以先看视频有所了解后再去看书系统学习
视频
今天在 b 站看视频的时候,看到了一句话众所周知,b 站是用来搞学习的,对于我们学习编程的童鞋来说,b 站有着非常多的学习资源,但是有一些质量并不是很好,看了之后不容易理解,这也是写这一篇文章的原因,为大家分
享一些质量超高的计算机基础的学习视频,往下看就完了。
1. 计算机网络微课堂
学习计算机网络,我首先推荐的 UP 主湖科大教书匠,他讲的计算机网络十分通俗易懂,重点的地方讲的十分细致,并且还有一些实验,更好的是有考研 408 的难题的讲解,也是非常适合考研党,除了课程内容外还有很多习题讲解视频,特别赞的一点是每天动态里都会更新一道考研题,播放量也非常的多。
2. 2019 王道考研 计算机网络
既然说到了考研,那我就不得不提一下王道考研了,恭喜你发现了宝藏。王道考研的计算机网络视频,播放量非常多,而且老师是一位小姐姐,声音十分动听,声音这么好听的老师给你讲课,妈妈再也不用担心我的学习了呢,总之,这个视频的质量也非常高,弹幕全是对小姐姐的高度评价。(王道考研其他的视频也不错哦,暗示一下:操作系统,数据结构等等)
3. 韩立刚计算机网络谢希仁
韩立刚老师所讲的计算机网络视频,内容比较多,但是讲解的通俗易懂,并且老师讲课的经验也十分的丰富。配套的教材是谢希仁老师的计算机网络教材,韩老师的最近的一个视频视频比较新,播放量还比较少,但是他讲的是真的不错,相比于王道考研所讲的计算机网络,韩老师更加细致一些。
4. 计算机网络(谢希仁第七版)-方老师
在计算机网络方面,我还想推荐的一位老师就是方老师,也是一位小姐姐老师。她的视频配套的教材也是谢老师的网络教材,在线看的小伙伴也超多,弹幕都是对方老师的评价。
博客
推荐几个不错的学习博客。
互联网协议入门-阮一峰:http://www.ruanyifeng.com/blog/2012/05/internet_protocol_suite_part_i….
网络协议-兰亭风雨:http://blog.csdn.net/ns_code/article/category/1805481
HTTP协议:http://www.cnblogs.com/TankXiao/category/415412.html
Unix 网络编程:http://blog.csdn.net/chenhanzhun/article/category/2767131/2
TCP/IP详解:http://blog.csdn.net/chenhanzhun/article/category/2734921/1
计算机网络面试题:http://blog.csdn.net/shadowkiss/article/details/6552144
国外优秀计算机网络站点:http://www.tcpipguide.com/free/t_TCPSlidingWindowAcknowledgmentSystemForDataTranspo-6.htm
当然最硬核的就是 RFC 文档了 RFC Index
学习 HTTP ,必须要看一下 MDN 官网 HTTP | MDN
学习计算机网络,Cloudflare 你必须要去看 https://www.cloudflare.com/zh-cn/learning/
GeeksforGeeks 学习计算机网络也非常不错 Basics of Computer Networking – GeeksforGeeks
Tutorialspoint 系统学习计算机,不仅仅局限于计算机网络 Computer – Networking
国外优秀的学习网站不能少了 javapoint Types of Computer Network – javatpoint
以上这些网站都是我精心汇总的一些内容。
书也分为不同的层次,最基础的入门书籍有
书籍
网络是怎样连接的
这本书是日本人写的,它和《程序是怎样运行的》、《计算机是怎样跑起来的》统称为图解入门系列,最大的特点就是风趣幽默,简单易懂。这本书通过多图来解释浏览器中输入网址开始,一路追踪了到显示出网页内容为止的整个过程,以图配文,讲解了网络的全貌,并重点介绍了实际的网络设备和软件是如何工作的。
本书图文并茂,通俗易懂,非常适合计算机、网络爱好者及相关从业人员阅读。
所以如果大家是新手的话,强烈推荐一下这本书。
日本人就爱图解,同样图解系列的入门书籍还有《图解 HTTP》、《图解 TCP/IP》。
图解 HTTP
《图解 HTTP》是 HTTP 协议的入门书籍,当然 HTTP 也是属于计算机网络的范畴,这本书适合于想要对 HTTP 有基本认知的程序员,同样也适合查漏补缺。
这类书看起来就毫无难度了,不得不说图解系列是给小白的圣经,它能增强你的自信,让你觉得计算机其实 "没那么难",这是非常重要的。初学者,最怕的就是劝退了。
图解 TCP/IP
上面的图解 HTTP 是针对 HTTP 协议的,那么《图解 TCP/IP》就是针对 TCP/IP 协议簇中的协议了,这本书我已经看了 80% 了,还是比较系统的,基本上涵盖了 TCP/IP 协议簇中的所有协议知识了,这本书看完了完全就可以直接深入理解 TCP/IP 协议簇了。
对于新手来说,最重要的一点就是帮助你理解,怎么简单怎么来,这样才能快速入门,对于快餐式的社会来说,快速理解当然是当仁不让的首选了。
如果上面这几本书你都搞定了的话,那你就可以读一下 《计算机网络:自顶向下方法》这本书了,这本书可以作为基础书籍也可以作为进阶书籍,这里我归为了进阶书籍,因为里面有一些章节不是那么好理解,比如介绍网络层的时候,会分为数据平面和控制平面,介绍 TCP 和 UDP 的时候,也会聊到一些原理性问题。
计算机网络 第七版
这本书是一本计算机网络的圣经书籍,圣经就在于人人都应该读一下这本书,原著非常经典,翻译也很不错,我自己也马上就看完了,这本书会从顶层,也就是网络层逐步下探到物理层,一层一层的带你入门,解释各层之间的协议,主要特征是什么,一个数据包的发送历程。这本书并不局限于某个具体的协议,而是从宏观的角度来看待计算机网络到底是什么,里面有一些专业名词,理解并掌握后会对深入学习计算机网络非常有用。
计算机网络 谢希仁
这本书是很多大学的教材,也是一本非常好的进阶书籍,这本书相对于自顶向下方法更多是对于通信网络的阐述。
这本书的特点是突出基本原理和基本概念的阐述,同时力图反映计算机网络的一些最新发展。本书可供电气信息类和计算机类专业的大学本科生和研究生使用,对从事计算机网络工作的工程技术人员也有参考价值
现在我们接着聊,如果上面这两本书随便一本看完了,那么恭喜你已经是一个"老手"了,你的网络基础能打败 90% 以上的人了,如果你还不满足的话,那你就需要继续深入,继续深入也是我推荐给你的提高书籍。
HTTP 权威指南
HTTP 权威指南是深入 HTTP 非常值得一看的书,这本书写的非常全了。
此书第一部分是HTTP的概略,如果你没有时间,通读第一部分就能让你应付普通的日常开发工作。
第二部分主要讲现实世界中HTTP的架构,也可以看作HTTP的全景图,包括Web Server/Cache/Proxy/Gateway,是全书中精华的部分。
第三部分主要是HTTP安全,其中Basic和Digest概略看下即可,现实世界中用的应该不多。看HTTPs最好有一些计算机安全基础,这样会顺畅很多。
第四部分主要是关于HTTP Message Body的部分,包括Content Negotiation,MIME Type,chunked encoding等,概略看下即可。
第五部分的内容,Web Hosting可以认真看下,了解下Virtual Host(话说我上学的时候一直搞不懂Virtual Host,一个IP怎么能同时Host两个不同域名的Web页面呢,sigh)。
剩下三章已经过时,基本可以忽略。 最后的附录,可以用作边用边学的字典,如果你自己来写Web Server,那么这一部分是极有价值的参考。
总而言之,无论你是前端还是后端,只要是Web相关的,那么此书就是必读的。
TCP/IP 详解
这是一本被翻译耽误的经典书,两个硬核作者 Kevin R. Fall 和 W. Richard Stevens 被南开大学的某计算机洗的译者给毁了。我第一开始读这本书以为是自己智商不够,原来是翻译 "瞎TM翻" 啊。语句不通且不说,您好歹走点心,改点措辞也行啊,纯碎是生搬硬套谷歌翻译啊,哎。
上面都是一些理论书籍,下面是稍微偏实战一些的书籍了。
计算机网络实战最有效的当然就属于抓包了,有很多抓包工具比如
wireshark、sniffer、httpwatch、iptool、fiddle 等,但是我用的和使用频率最高的应该就是 wireshark 了,关于 wireshark 还有两本实战方面的书你需要知道
wireshark 数据包分析实战
初学者必备,介绍了wireshark安装,嗅探网络流量,wireshark的基本使用,用wireshark分析了一圈常用的TCP,UDP协议,也简要分析了HTTP等应用层协议,概要介绍了一些TCP重传的机制,最后是无线分析
整个书定位应该是入门级别的,基本上每章都是简要介绍,并没有特别深入大张阔斧地进行描述。文章行文思路清晰,译者的翻译水平也不错。
总的来说,是初步认识和了解wireshark的好书
wireshark 网络分析就是这么简单
读的时候你会忍不住笑的,区别于《Wireshark数据包分析实战》,本书就像一本侦探小说集,以幽默风趣的语言风格,借助wireshark以理性的思考来不断探险,根据蛛丝马迹来“侦破案情”
总结,读完数据包分析实战来读这本。
Wireshark网络分析实战
其内容涵盖了Wireshark的基础知识,抓包过滤器的用法,显示过滤器的用法,基本/高级信息统计工具的用法,Expert Info工具的用法,Wiresahrk在Ethernet、LAN及无线LAN中的用法,ARP和IP故障分析,TCP/UDP故障分析,HTTP和DNS故障分析,企业网应用程序行为分析,SIP、多媒体和IP电话,排除由低带宽或高延迟所引发的故障,认识网络安全等知识。
实验
借鉴一些大佬的回答,给你推荐一个斯坦福课程的实验
推荐 Stanford 课程 cs144,配合《计算机网络:自顶向下方法》(Computer Networking: A Top-Down Approach)。具体来说就是跟着 cs144 的课程安排走一遍,完成课程的lab。
计算机网络核心概念
主机
:计算机网络上任何一种能够连接网络的设备都被称为主机或者说是端系统
,比如手机、平板电脑、电视、游戏机、汽车等,随着 5G 的到来,将会有越来越多的终端设备接入网络。通信链路
:通信链路是由物理链路(同轴电缆、双绞线、光纤灯)连接到一起组成的一种物理通路。传输速率
:单位是 bit/s 或者 bps ,用来度量不同链路从一个端系统到另一个端系统传输数据的速率。分组
:当一台端系统向另外一台端系统发送数据时,通常会将数据进行分片,然后为每段加上首部字节,从而形成计算机网络的专业术语:分组。这些分组通过网络发送到端系统,然后再进行数据处理。路由器
:它和链路层交换机一样,都是一种交换机,主要用于转发数据的目的。
-
路径
:一个分组所经历一系列通信链路和分组交换机称为通过这个网络的路径。 -
因特网服务商
:也叫 ISP,不是 lsp。这个好理解,就是网络运营商,移动、电信、联通。 -
网络协议
:网络协议是计算机网络中进行数据交换而建立的规则、标准或者约定。 -
IP
:网际协议,它规定了路由器和端系统之间发送和接收的分组格式。 -
TCP/IP 协议簇
:不仅仅只有 TCP 协议和 IP 协议,而是以 TCP、IP 协议为主的一系列协议,比如 ICMP 协议、ARP 协议、UDP 协议、DNS 洗衣、SMTP 协议等。 -
分布式应用程序
:多个端系统之间相互交换数据的端系统被称为分布式应用程序。 -
套接字接口
:指的就是 socket 接口,这个接口规定了端系统之间通过因特网进行数据交换的方式。 -
协议
:协议定义了两个以上通信实体之间交换报文格式和顺序所遵从的标准。 -
客户端
:在客户-服务器架构中扮演请求方的角色,通常是 PC,智能手机等端系统。 -
服务器
:在客户-服务器架构中扮演服务方的角色,通常是大型服务器集群扮演服务器的角色。 -
转发表
:路由内部记录报文路径的映射关系的一种记录。 -
时延
:时延指的是一个报文或者分组从网络的一端传递到另一端所需要的时间,时延分类有发送时延、传播时延、处理时延、排队时延,总时延 = 发送时延+传播时延+处理时延+排队时延。 -
丢包
:在计算机网络中指的是分组出现丢失的现象。 -
吞吐量
:吞吐量在计算机网络中指的是单位时间内成功传输数据的数量。 -
报文
:通常指的是应用层的分组。 -
报文段
:通常把运输层的分组称为报文段。 -
数据报
:通常将网络层的分组称为数据报。 -
帧
:一般把链路层的分组称为帧。 -
客户-服务体系
:它是一种面向网络应用的体系结构。把系统中的不同端系统区分为客户和服务器两类,客户向服务器发出服务请求,由服务器完成所请求的服务,并把处理结果回送给客户。在客户-服务器体系结构中,有一个总是打开的主机称为服务器(Server)
,它提供来自于客户(client)
的服务。我们最常见的服务器就是Web 服务器
,Web 服务器服务于来自浏览器
的请求。
P2P 体系
:对等体系结构,相当于没有服务器了,大家都是客户机,每个客户既能发送请求,也能对请求作出响应。
-
IP 地址
:IP 地址就是网际协议地址,在互联网中唯一标识主机的一种地址。每一台入网的设备都会有一个 IP 地址,这个 IP 又分为内网 IP 和公网 IP。 -
端口号
:在同一台主机内,端口号用于标识不同应用程序进程。 -
URI
:它的全称是(Uniform Resource Identifier),中文名称是统一资源标识符,使用它就能够唯一地标记互联网上资源。 -
URL
:它的全称是(Uniform Resource Locator),中文名称是统一资源定位符,它实际上是 URI 的一个子集。 -
HTML
:HTML 称为超文本标记语言,是一种标识性的语言。它包括一系列标签.通过这些标签可以将网络上的文档格式统一,使分散的 Internet 资源连接为一个逻辑整体。HTML 文本是由 HTML 命令组成的描述性文本,HTML 命令可以说明文字,图形、动画、声音、表格、链接等。 -
Web 页面
:Web 页面也叫做Web Page
,它是由对象组成,一个对象(object)
简单来说就是一个文件,这个文件可以是 HTML 文件、一个图片、一段 Java 应用程序等,它们都可以通过 URI 来找到。一个 Web 页面包含了很多对象,Web 页面可以说是对象的集合体。 -
Web 服务器
:Web 服务器的正式名称叫做Web Server
,Web 服务器可以向浏览器等 Web 客户端提供文档,也可以放置网站文件,让全世界浏览;可以放置数据文件,让全世界下载。目前最主流的三个 Web 服务器是 Apache、 Nginx 、IIS。 -
CDN
:CDN 的全称是Content Delivery Network
,即内容分发网络
,它应用了 HTTP 协议里的缓存和代理技术,代替源站响应客户端的请求。CDN 是构建在现有网络基础之上的网络,它依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近
获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。 -
WAF
:WAF 是一种 应用程序防护系统,它是一种通过执行一系列针对 HTTP / HTTPS的安全策略
来专门为 Web 应用提供保护的一款产品,它是应用层面的防火墙
,专门检测 HTTP 流量,是防护 Web 应用的安全技术。 -
WebService
:WebService 是一种 Web 应用程序,WebService 是一种跨编程语言和跨操作系统平台的远程调用技术。 -
HTTP
: TCP/IP 协议簇的一种,它是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范。 -
Session
:Session 其实就是客户端会话的缓存,主要是为了弥补 HTTP 无状态的特性而设计的。服务器可以利用 Session 存储客户端在同一个会话期间的一些操作记录。当客户端请求服务端时,服务端会为这次请求开辟一块内存空间
,这个对象便是 Session 对象,存储结构为ConcurrentHashMap
。 -
Cookie
:HTTP 协议中的 Cookie 包括Web Cookie
和浏览器 Cookie
,它是服务器发送到 Web 浏览器的一小块数据。服务器发送到浏览器的 Cookie,浏览器会进行存储,并与下一个请求一起发送到服务器。通常,它用于判断两个请求是否来自于同一个浏览器,例如用户保持登录状态。 -
SMTP 协议
:提供电子邮件服务的协议叫做 SMTP 协议, SMTP 在传输层也使用了 TCP 协议。SMTP 协议主要用于系统之间的邮件信息传递,并提供有关来信的通知。 -
DNS 协议
:由于 IP 地址是计算机能够识别的地址,而我们人类不方便记忆这种地址,所以为了方便人类的记忆,使用 DNS 协议,来把我们容易记忆的网络地址映射称为主机能够识别的 IP 地址。 -
TELNET 协议
:远程登陆协议,它允许用户(Telnet 客户端)通过一个协商过程来与一个远程设备进行通信,它为用户提供了在本地计算机上完成远程主机工作的能力。 -
SSH 协议
:SSH 是一种建立在应用层上的安全加密协议。因为 TELNET 有一个非常明显的缺点,那就是在主机和远程主机的发送数据包的过程中是明文传输,未经任何安全加密,这样的后果是容易被互联网上不法分子嗅探到数据包来搞一些坏事,为了数据的安全性,我们一般使用SSH
进行远程登录。 -
FTP 协议
:文件传输协议,是应用层协议之一。FTP 协议包括两个组成部分,分为 FTP 服务器和 FTP 客户端。其中 FTP 服务器用来存储文件,用户可以使用 FTP 客户端通过 FTP 协议访问位于 FTP 服务器上的资源。FTP 协议传输效率很高,一般用来传输大文件。
-
MIME 类型
,它表示的是互联网的资源类型,一般类型有 超文本标记语言文本 .html text/html、xml文档 .xml text/xml、普通文本 .txt text/plain、PNG图像 .png image/png、GIF图形 .gif image/gif、JPEG图形 .jpeg,.jpg image/jpeg、AVI 文件 .avi video/x-msvideo 等。 -
多路分解
:在接收端,运输层会检查源端口号和目的端口号等字段,然后标识出接收的套接字,从而将运输层报文段的数据交付到正确套接字的过程被称为多路分解。 -
多路复用
:在发送方,从不同的套接字中收集数据块,然后为数据块封装上首部信息从而生成报文段,然后将报文段传递给网络层的过程被称为多路复用。 -
周知端口号
:在主机的应用程序中,从 0 – 1023 的端口号是受限制的,被称为周知端口号,这些端口号一般不能占用。 -
单向数据传输
:数据的流向只能是单向的,也就是从发送端 -> 接收端。 -
双向数据传输
:数据的流向是双向的,又叫做全双工通信,发送端和接收端可以相互发送数据。 -
面向连接的
:面向连接指的是应用进程在向另一个应用进程发送数据前,需要先进行握手
,即它们必须先相互发送预备报文段,用来建立确保数据传输的参数。 -
三次握手
:TCP 连接的建立需要经过三个报文段的发送,这种连接的建立过程被称为三次握手。 -
最大报文段长度
:即 MSS,它指的是从缓存中取出并放入报文段中的最大值。 -
最大传输单元
:即 MTU,它指的是通信双方能够接收有效载荷的大小,MSS 通常会根据 MTU 来设。 -
冗余 ACK
:就是再次确认某个报文段的 ACK,报文段的丢失会导致冗余 ACK 的出现。 -
快速重传
:即在报文段定时器过期之前重传丢失的报文段。 -
选择确认
:在报文段出现丢失的情况下,TCP 能够选择确认失序的报文段,这个机制通常和重传一起使用。 -
拥塞控制
:拥塞控制说的是,当某一段时间网络中的分组过多,使得接收端来不及处理,从而引起部分甚至整个网络性能下降的现象时采取的一种抑制发送端发送数据,等过一段时间或者网络情况改善后再继续发送报文段的一种方法。 -
四次挥手
:TCP 断开链接需要经过四个报文段的发送,这种断开过程是四次挥手。 -
路由选择算法
:网络层中决定分组发送路径的一种算法。 -
转发
:它指的是将分组从一个输入链路转移到合适的输出链路的动作。 -
分组调度
:分组调度讨论的是分组如何经输出链路传输的问题,主要有三种调度方式:先进先出、优先级排队和"循环和加权公平排队"。 -
IPv4
:网际协议的第四个版本,也是被广泛使用的一个版本。IPv4 是一种无连接的协议,无连接不保证数据的可靠性交付。使用 32 位的地址。 -
IPv6
:网际协议的第六个版本,IPv6 的地址长度是 128 位,由于 IPv4 最大的问题在于网络地址资源不足,严重制约了互联网的应用和发展。IPv6 的使用,不仅能解决网络地址资源数量的问题,而且也解决了多种接入设备连入互联网的障碍。 -
接口
:主机和物理链路之间的边界。 -
ARP 协议
:ARP 是一种解决地址问题的协议,通过 IP 位线索,可以定位下一个用来接收数据的网络设备的 MAC 地址。如果目标主机与主机不在同一个链路上时,可以通过 ARP 查找下一跳路由的地址。不过 ARP 只适用于 IPv4 ,不适用于 IPv6。 -
RARP
:RARP 就是将 ARP 协议反过来,通过 MAC 地址定位 IP 地址的一种协议。 -
代理 ARP
:用于解决 ARP 包被路由器隔离的情况,通过代理 ARP 可以实现将 ARP 请求转发给临近的网段。 -
ICMP 协议
:Internet 报文控制协议,如果在 IP 通信过程中由于某个 IP 包由于某种原因未能到达目标主机,那么将会发送 ICMP 消息,ICMP 实际上是 IP 的一部分。 -
DHCP 协议
:DHCP 是一种动态主机配置协议。使用 DHCP 就能实现自动设置 IP 地址、统一管理 IP 地址分配,实现即插即用。 -
NAT 协议
:网络地址转换协议,它指的是所有本地地址的主机在接入网络时,都会要在 NAT 路由器上讲其转换成为全球 IP 地址,才能和其他主机进行通信。 -
IP 隧道
:IP 隧道技术说的是由路由器把网络层协议封装到另一个协议中从而跨过网络传输到另外一个路由器的过程。 -
单播
:单播最大的特点就是 1 对 1,早期的固定电话就是单播的一个例子 -
广播
:我们一般小时候经常会广播体操,这就是广播的一个事例,主机和与他连接的所有端系统相连,主机将信号发送给所有的端系统。 -
多播
:多播与广播很类似,也是将消息发送给多个接收主机,不同之处在于多播需要限定在某一组主机作为接收端。 -
任播
:任播是在特定的多台主机中选出一个接收端的通信方式。虽然和多播很相似,但是行为与多播不同,任播是从许多目标机群中选出一台最符合网络条件的主机作为目标主机发送消息。然后被选中的特定主机将返回一个单播信号,然后再与目标主机进行通信。 -
IGP
:内部网关协议,一般用于企业内部自己搭建的路由自治系统。 -
EGP
:外部网关协议,EGP 通常用于在网络主机之间相互交换路由信息。 -
RIP
:一种距离向量型路由协议,广泛应用于 LAN 网。 -
OSPF
:是根据 OSI 的 IS-IS 协议提出的一种链路状态型协议。这种协议还能够有效的解决网络环路问题。 -
MPLS
:它是一种标记交换技术,标记交换会对每个 IP 数据包都设定一个标记,然后根据这个标记进行转发。 -
节点
:一般指链路层协议中的设备。 -
链路
:一般把沿着通信路径连接相邻节点的通信信道称为链路。 -
MAC 协议
:媒体访问控制协议,它规定了帧在链路上传输的规则。 -
奇偶校验位
:一种差错检测方式,多用于计算机硬件的错误检测中,奇偶校验通常用在数据通信中来保证数据的有效性。 -
向前纠错
:接收方检测和纠正差错的能力被称为向前纠错。 -
以太网
:以太网是一种当今最普遍的局域网技术,它规定了物理层的连线、电子信号和 MAC 协议的内容。 -
VLAN
:虚拟局域网(VLAN)是一组逻辑上的设备和用户,这些设备和用户并不受物理位置的限制,可以根据功能、部门及应用等因素将它们组织起来,相互之间的通信就好像它们在同一个网段中一样,所以称为虚拟局域网。 -
基站
:无线网络的基础设施。
Web页面的请求历程
Hey guys 各位读者姥爷们大家好,这里是程序员 cxuan 计算机网络连载系列的第 13 篇文章。
到现在为止,我们算是把应用层、运输层、网络层和数据链路层都介绍完了,那么现在是时候把这些内容都串起来,做一个全面的回顾了。那么我这就以 Web 页面的请求历程为例,来和你聊聊计算机网络中这些协议是怎样工作的、数据包是怎么收发的,从输入 URL 、敲击会车到最终完成页面呈现在你面前的这个过程。
首先,我打开了 Web Browser ,然后在 Google 浏览器 URL 地址栏中输入了 maps.google.com
。
然后 ……
查找 DNS 缓存
浏览器在这个阶段会检查四个地方是否存在缓存,第一个地方是浏览器缓存,这个缓存就是 DNS 记录。
浏览器会为你访问过的网站在固定期限内维护 DNS 记录。因此,它是第一个运行 DNS 查询的地方。 浏览器首先会检查这个网址在浏览器中是否有一条对应的 DNS 记录,用来找到目标网址的 IP 地址。
我是 chrome 浏览器,所以在 mac 中,无法使用 chrome://net-internals/#dns 找到对应的 IP 地址,在 windows 中是可以找到的。
那么 mac 怎么查询 DNS 记录呢?你可以使用
nslookup
命令来查找,但这不是我们讨论的重点。
DNS(Domain Name System)
是一个分布式的数据库,它用于维护网址 URL 到其 IP 地址的映射关系。在互联网中,IP 地址是计算机所能够理解的一种地址,而 DNS 的这种别名地址是我们人类能够理解和记忆的地址,DNS 就负责把人类记忆的地址映射成计算机能够理解的地址,每个 URL 都有唯一的 IP 地址进行对应。
举个例子,google 的官网是 www.google.com ,而 google 的 ip 地址是 216.58.200.228 ,这两个地址你在 URL 上输入哪个都能访问,但是 IP 地址不好记忆,而 google.com 简单明了。DNS 就相当于是我们几年前使用的家庭电话薄,比如你想给 cxuan 打电话,你有可能记不住 cxuan 的电话号码,此时你需要查询电话薄来找到 cxuan 的电话号码。
浏览器第二个需要检查的地方就是操作系统缓存。如果 DNS 记录不在浏览器缓存中,那么浏览器将对操作系统发起系统调用,Windows 下就是 getHostName
。
在 Linux 和大部分 UNIX 系统上,除非安装了
nscd
,否则操作系统可能没有 DNS 缓存。nscd 是 Linux 系统上的一种名称服务缓存程序。
浏览低第三个需要检查的地方是路由器缓存,如果 DNS 记录不在自己电脑上的话,浏览器就会和与之相连的路由器共同维护 DNS 记录。
如果与之相连的路由器也没有 DNS 记录的话,浏览器就会检查 ISP
中是否有缓存。ISP 缓存就是你本地通信服务商的缓存,因为 ISP 维护着自己的 DNS 服务器,它缓存 DNS 记录的本质也是为了降低请求时间,达到快速响应的效果。一旦你访问过某些网站,你的 ISP 可能就会缓存这些页面,以便下次快速访问。对于经常看小电影的你是否感到震惊呢?如果家里还安装了一个可以联网的摄像头的话,那就有点嗨皮了。
你肯定比较困惑为什么第一步浏览器需要检查这么多缓存,你可能会感到不舒服,因为缓存可能会透露我们的隐私,但是这些缓存在调节网络流量和缩短数据传输时间等方面至关重要。
所以,上面涉及到 DNS 缓存的查询过程如下。
如果上面四个步骤中都不存在 DNS 记录,那么就表示不存在 DNS 缓存,这个时候就需要发起 DNS 查询,以查找目标网址(本示例中是 maps.google.com)的 IP 地址。
发起 DNS 查询
如上所述,如果想要使我的计算机和 maps.google.com 建立连接并进行通信的话,我需要知道 maps.google.com 的 IP 地址,由于 DNS 的设计原因,本地 DNS 可能无法给我提供正确的 IP 地址,那么它就需要在互联网上搜索多个 DNS 服务器,来找到网站的正确 IP 地址。
这里有个疑问,为什么我需要搜索多个 DNS 服务器的来找到网站的 IP 地址呢?一台服务器不行吗?
因为 DNS 是分布式域名服务器,每台服务器只维护一部分 IP 地址到网络地址的映射,没有任何一台服务器能够维持全部的映射关系。
在 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 服务器
。这些服务器的层次模型如下图所示
根 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 查找的步骤上来,DNS 的查询方式主要分为三种
DNS 查找中会出现三种类型的查询。通过组合使用这些查询,优化的 DNS 解析过程可缩短传输距离。在理想情况下,可以使用缓存的记录数据,从而使 DNS 域名服务器能够直接使用非递归查询。
递归查询
:在递归查询中,DNS 客户端要求 DNS 服务器(一般为 DNS 递归解析器)将使用所请求的资源记录响应客户端,或者如果解析器无法找到该记录,则返回错误消息。
迭代查询
:在迭代查询中,如果所查询的 DNS 服务器与查询名称不匹配,则其将返回对较低级别域名空间具有权威性的 DNS 服务器的引用。然后,DNS 客户端将对引用地址进行查询。此过程继续使用查询链中的其他 DNS 服务器,直至发生错误或超时为止。
非递归查询
:当 DNS 解析器客户端查询 DNS 服务器以获取其有权访问的记录时通常会进行此查询,因为其对该记录具有权威性,或者该记录存在于其缓存内。DNS 服务器通常会缓存 DNS 记录,查询到来后能够直接返回缓存结果,以防止更多带宽消耗和上游服务器上的负载。
上面负责开始 DNS 查找的介质就是 DNS 解析器,它一般是 ISP 维护的 DNS 服务器,它的主要职责就是通过向网络中其他 DNS 服务器询问正确的 IP 地址。
如果想要了解更多关于 DNS 的消息,请查阅 万字长文爆肝 DNS 协议!
所以对于 maps.google.com 这个域名来说,如果 ISP 维护的服务器没有 DNS 缓存记录,它就会向 DNS 根服务器地址发起查询,根名称服务器会将其重定向到 .com 顶级域名服务器。 .com 顶级域名服务器会将其重定向到google.com 权威服务器。google.com 名称服务器将在其 DNS 记录中找到 maps.google.com 匹配的 IP 地址,并将其返回给您的 DNS 解析器,然后将其发送回你的浏览器。
这里值得注意的是,DNS 查询报文会经过许多路由器和设备才会达到根域名等服务器,每经过一个设备或者路由器都会使用路由表
来确定哪种路径是数据包达到目的地最快的选择。这里面涉及到路由选择算法,如果小伙伴们想要了解路由选择算法,可以看看这篇文章 https://www.cisco.com/c/en/us/support/docs/ip/border-gateway-protocol-bgp/13753-25.html#anc3
ARP 请求
我看了很多篇文章都没有提到这一点,那就是 ARP 请求的这个过程。
什么时候需要发送 ARP 请求呢?
这里其实有个前提条件
- 如果 DNS 服务器和我们的主机在同一个子网内,系统会按照下面的 ARP 过程对 DNS 服务器进行 ARP 查询
- 如果 DNS 服务器和我们的主机在不同的子网,系统会按照下面的 ARP 过程对默认网关进行查询
ARP 协议的全称是 Address Resolution Protocol(地址解析协议)
,它是一个通过用于实现从 IP 地址到 MAC 地址的映射,即询问目标 IP 对应的 MAC 地址 的一种协议。
简而言之,ARP 就是一种解决地址问题的协议,它以 IP 地址为线索,定位下一个应该接收数据分包的主机 MAC 地址。如果目标主机不在同一个链路上,那么会查找下一跳路由器的 MAC 地址。
关于为什么有了 IP 地址,还要有 MAC 地址概述可以参看知乎这个回答 https://www.zhihu.com/question/21546408
ARP 的大致工作流程如下
假设 A 和 B 位于同一链路,不需要经过路由器的转换,主机 A 向主机 B 发送一个 IP 分组,主机 A 的地址是 192.168.1.2 ,主机 B 的地址是 192.168.1.3,它们都不知道对方的 MAC 地址是啥,主机 C 和 主机 D 是同一链路的其他主机。
主机 A 想要获取主机 B 的 MAC 地址,通过主机 A 会通过广播
的方式向以太网上的所有主机发送一个 ARP 请求包
,这个 ARP 请求包中包含了主机 A 想要知道的主机 B 的 IP 地址的 MAC 地址。
主机 A 发送的 ARP 请求包会被同一链路上的所有主机/路由器接收并进行解析。每个主机/路由器都会检查 ARP 请求包中的信息,如果 ARP 请求包中的目标 IP 地址
和自己的相同,就会将自己主机的 MAC 地址写入响应包返回主机 A
由此,可以通过 ARP 从 IP 地址获取 MAC 地址,实现同一链路内的通信。
所以,要想发送 ARP 广播,我们需要有一个目标 IP 地址,同时还需要知道用于发送 ARP 广播的接口的 MAC 地址。
这里会涉及到 ARP 缓存的概念。
现在你知道了发送一次 IP 分组前通过发送一次 ARP 请求就能够确定 MAC 地址。那么是不是每发送一次都得经过广播 -> 封装 ARP 响应 -> 返回给主机这一系列流程呢?
想想看,浏览器是如何做的?浏览器内置了缓存能够缓存你最近经常使用的地址,那么 ARP 也是一样的。ARP 高效运行的关键就是维护每个主机和路由器上的 ARP 缓存(或表)
。这个缓存维护着每个 IP 到 MAC 地址的映射关系。通过把第一次 ARP 获取到的 MAC 地址作为 IP 对 MAC 的映射关系到一个 ARP 缓存表中,下一次再向这个地址发送数据报时就不再需要重新发送 ARP 请求了,而是直接使用这个缓存表中的 MAC 地址进行数据报的发送。每发送一次 ARP 请求,缓存表中对应的映射关系都会被清除。
通过 ARP 缓存,降低了网络流量的使用,在一定程度上防止了 ARP 的大量广播。
一般来说,发送过一次 ARP 请求后,再次发送相同请求的几率比较大,因此使用 ARP 缓存能够减少 ARP 包的发送,除此之外,不仅仅 ARP 请求的发送方能够缓存 ARP 接收方的 MAC 地址,接收方也能够缓存 ARP 请求方的 IP 和 MAC 地址,如下所示
不过,MAC 地址的缓存有一定期限,超过这个期限后,缓存的内容会被清除。
深入理解 ARP 协议的话,可以参考 cxuan 的这篇文章。
所以,浏览器会首先查询 ARP 缓存,如果缓存命中,我们返回结果:目标 IP = MAC。
如果缓存没有命中:
- 查看路由表,看看目标 IP 地址是不是在本地路由表中的某个子网内。是的话,使用跟那个子网相连的接口,否则使用与默认网关相连的接口。
- 查询选择的网络接口的 MAC 地址
- 我们发送一个数据链路层的 ARP 请求:
根据连接主机和路由器的硬件类型不同,可以分为以下几种情况:
直连:
- 如果我们和路由器是直接连接的,路由器会返回一个
ARP Reply
(见下面)。
集线器:
- 如果我们连接到一个集线器,集线器会把 ARP 请求向所有其它端口广播,如果路由器也连接在其中,它会返回一个
ARP Reply
。
交换机:
- 如果我们连接到了一个交换机,交换机会检查本地 CAM/MAC 表,看看哪个端口有我们要找的那个 MAC 地址,如果没有找到,交换机会向所有其它端口广播这个 ARP 请求。
- 如果交换机的 MAC/CAM 表中有对应的条目,交换机会向有我们想要查询的 MAC 地址的那个端口发送 ARP 请求
- 如果路由器也
连接
在其中,它会返回一个ARP Reply
ARP Reply
:
现在我们有了 DNS 服务器或者默认网关的 IP 地址,我们可以继续 DNS 请求了:
- 使用 53 端口向 DNS 服务器发送 UDP 请求包,如果响应包太大,会使用 TCP 协议
- 如果本地/ISP DNS 服务器没有找到结果,它会发送一个递归查询请求,一层一层向高层 DNS 服务器做查询,直到查询到起始授权机构,如果找到会把结果返回。
(上述均来自:https://github.com/skyline75489/what-happens-when-zh_CN#dns)
封装 TCP 数据包
浏览器得到目标服务器的 IP 地址后,根据 URL 中的端口可以知道端口号 (http 协议默认端口号是 80, https 默认端口号是 443),会准备 TCP 数据包。数据包的封装会经过下面的层层处理,数据到达目标主机后,目标主机会解析数据包,完整的请求和解析过程如下。
这里就不再详细介绍了,读者朋友们可以阅读 cxuan 的这篇文章 TCP/IP 基础知识详解详细了解。
浏览器与目标服务器建立 TCP 连接
在经过上述 DNS 和 ARP 查找流程后,浏览器就会收到一个目标服务器的 IP 和 MAC地址,然后浏览器将会和目标服务器建立连接来传输信息。这里可以使用很多种 Internet 协议,但是 HTTP 协议建立连接所使用的运输层协议是 TCP 协议。所以这一步骤是浏览器与目标服务器建立 TCP 连接的过程。
TCP 的连接建立需要经过 TCP/IP 的三次握手,三次握手的过程其实就是浏览器和服务器交换 SYN 同步和 ACK 确认消息的过程。
假设图中左端是客户端主机,右端是服务端主机,一开始,两端都处于CLOSED(关闭)
状态。
- 服务端进程准备好接收来自外部的 TCP 连接。然后服务端进程处于
LISTEN
状态,等待客户端连接请求。 - 客户端向服务器发出连接请求,请求中首部同步位 SYN = 1,同时选择一个初始序号 sequence ,简写 seq = x。SYN 报文段不允许携带数据,只消耗一个序号。此时,客户端进入
SYN-SEND
状态。 - 服务器收到客户端连接后,,需要确认客户端的报文段。在确认报文段中,把 SYN 和 ACK 位都置为 1 。确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。请注意,这个报文段也不能携带数据,但同样要消耗掉一个序号。此时,TCP 服务器进入
SYN-RECEIVED(同步收到)
状态。 - 客户端在收到服务器发出的响应后,还需要给出确认连接。确认连接中的 ACK 置为 1 ,序号为 seq = x + 1,确认号为 ack = y + 1。TCP 规定,这个报文段可以携带数据也可以不携带数据,如果不携带数据,那么下一个数据报文段的序号仍是 seq = x + 1。这时,客户端进入
ESTABLISHED (已连接)
状态 - 服务器收到客户的确认后,也进入
ESTABLISHED
状态。
这样三次握手建立连接的阶段就完成了,双方可以直接通信了。
浏览器发送 HTTP 请求到 web 服务器
一旦 TCP 连接建立完成后,就开始直接传输数据办正事了!此时浏览器会发送 GET
请求,要求目标服务器提供 maps.google.com 的网页,如果你填写的是表单,则发起的是 POST
请求,在 HTTP 中,GET 请求和 POST 请求是最常见的两种请求,基本上占据了所有 HTTP 请求的九成以上。
除了请求类型外,HTTP 请求还包含很多很多信息,最常见的有 Host、Connection 、User-agent、Accept-language 等
首先 Host 表示的是对象所在的主机。Connection: close
表示的是浏览器需要告诉服务器使用的是非持久连接
。它要求服务器在发送完响应的对象后就关闭连接。User-agent
: 这是请求头用来告诉 Web 服务器,浏览器使用的类型是 Mozilla/5.0
,即 Firefox 浏览器。Accept-language
告诉 Web 服务器,浏览器想要得到对象的法语版本,前提是服务器需要支持法语类型,否则将会发送服务器的默认版本。下面我们针对主要的实体字段进行介绍(具体的可以参考 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers MDN 官网学习)
HTTP 的请求标头分为四种: 通用标头
、请求标头
、响应标头
和 实体标头
。
这四种标头又分别有很多内容,如果你想要深入理解一下关于 HTTP 请求头的相关内容,可以参考 cxuan 的这篇文章
服务器处理请求并发回一个响应
这个服务器包含一个 Web 服务器,也就是 Apache 服务器,服务器会从浏览器接收请求并将其传递给请求处理程序并生成响应。
请求处理程序也是一个程序,它一般是用 .net 、php、ruby 等语言编写,用于读取请求,检查请求内容,cookie,必要时更新服务器上的信息的这么一个程序。它会以特定的格式比如 JSON、XML、HTML 组合响应。
服务器发送回一个 HTTP 响应
服务器响应包含你请求的网页以及状态代码,压缩类型(Content-Encoding),如何缓存页面(Cache-Control),要设置的 cookie,隐私信息等。
比如下面就是一个响应体
关于深入理解 HTTP 请求和响应,可以参考这篇文章
浏览器显示 HTML 的相关内容
浏览器会分阶段显示 HTML 内容。 首先,它将渲染裸露的 HTML 骨架。 然后它将检查 HTML 标记并发送 GET 请求以获取网页上的其他元素,例如图像,CSS 样式表,JavaScript 文件等。这些静态文件由浏览器缓存,因此你再次访问该页面时,不用重新再请求一次。最后,您会看到 maps.google.com 显示的内容出现在你的浏览器中。
TCP传输控制协议
TCP 是一种面向连接的单播
协议,在 TCP 中,并不存在多播、广播的这种行为,因为 TCP 报文段中能明确发送方和接受方的 IP 地址。
在发送数据前,相互通信的双方(即发送方和接受方)需要建立一条连接
,在发送数据后,通信双方需要断开连接,这就是 TCP 连接的建立和终止。
TCP 连接的建立和终止
如果你看过我之前写的关于网络层的一篇文章,你应该知道 TCP 的基本元素有四个:即发送方的 IP 地址、发送方的端口号、接收方的 IP 地址、接收方的端口号。而每一方的 IP + 端口号都可以看作是一个套接字
,套接字能够被唯一标示。套接字就相当于是门,出了这个门,就要进行数据传输了。
TCP 的连接建 立 -> 终止总共分为三个阶段
下面我们所讨论的重点也是集中在这三个层面。
下图是一个非常典型的 TCP 连接的建立和关闭过程,其中不包括数据传输的部分。
TCP 建立连接 – 三次握手
- 服务端进程准备好接收来自外部的 TCP 连接,一般情况下是调用 bind、listen、socket 三个函数完成。这种打开方式被认为是
被动打开(passive open)
。然后服务端进程处于LISTEN
状态,等待客户端连接请求。 - 客户端通过
connect
发起主动打开(active open)
,向服务器发出连接请求,请求中首部同步位 SYN = 1,同时选择一个初始序号 sequence ,简写 seq = x。SYN 报文段不允许携带数据,只消耗一个序号。此时,客户端进入SYN-SEND
状态。 - 服务器收到客户端连接后,,需要确认客户端的报文段。在确认报文段中,把 SYN 和 ACK 位都置为 1 。确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。这个报文段也不能携带数据,但同样要消耗掉一个序号。此时,TCP 服务器进入
SYN-RECEIVED(同步收到)
状态。 - 客户端在收到服务器发出的响应后,还需要给出确认连接。确认连接中的 ACK 置为 1 ,序号为 seq = x + 1,确认号为 ack = y + 1。TCP 规定,这个报文段可以携带数据也可以不携带数据,如果不携带数据,那么下一个数据报文段的序号仍是 seq = x + 1。这时,客户端进入
ESTABLISHED (已连接)
状态 - 服务器收到客户的确认后,也进入
ESTABLISHED
状态。
这是一个典型的三次握手过程,通过上面 3 个报文段就能够完成一个 TCP 连接的建立。三次握手的的目的不仅仅在于让通信双方知晓正在建立一个连接,也在于利用数据包中的选项字段来交换一些特殊信息,交换初始序列号。
一般首个发送 SYN 报文的一方被认为是主动打开一个连接,而这一方通常也被称为
客户端
。而 SYN 的接收方通常被称为服务端
,它用于接收这个 SYN,并发送下面的 SYN,因此这种打开方式是被动打开。
TCP 建立一个连接需要三个报文段,释放一个连接却需要四个报文段。
TCP 断开连接 – 四次挥手
数据传输结束后,通信的双方可以释放连接。数据传输结束后的客户端主机和服务端主机都处于 ESTABLISHED 状态,然后进入释放连接的过程。
TCP 断开连接需要历经的过程如下
-
客户端应用程序发出释放连接的报文段,并停止发送数据,主动关闭 TCP 连接。客户端主机发送释放连接的报文段,报文段中首部 FIN 位置为 1 ,不包含数据,序列号位 seq = u,此时客户端主机进入
FIN-WAIT-1(终止等待 1)
阶段。 -
服务器主机接受到客户端发出的报文段后,即发出确认应答报文,确认应答报文中 ACK = 1,生成自己的序号位 seq = v,ack = u + 1,然后服务器主机就进入
CLOSE-WAIT(关闭等待)
状态。 -
客户端主机收到服务端主机的确认应答后,即进入
FIN-WAIT-2(终止等待2)
的状态。等待客户端发出连接释放的报文段。 -
这时服务端主机会发出断开连接的报文段,报文段中 ACK = 1,序列号 seq = v,ack = u + 1,在发送完断开请求的报文后,服务端主机就进入了
LAST-ACK(最后确认)
的阶段。 -
客户端收到服务端的断开连接请求后,客户端需要作出响应,客户端发出断开连接的报文段,在报文段中,ACK = 1, 序列号 seq = u + 1,因为客户端从连接开始断开后就没有再发送数据,ack = v + 1,然后进入到
TIME-WAIT(时间等待)
状态,请注意,这个时候 TCP 连接还没有释放。必须经过时间等待的设置,也就是2MSL
后,客户端才会进入CLOSED
状态,时间 MSL 叫做最长报文段寿命(Maximum Segment Lifetime)
。 -
服务端主要收到了客户端的断开连接确认后,就会进入 CLOSED 状态。因为服务端结束 TCP 连接时间要比客户端早,而整个连接断开过程需要发送四个报文段,因此释放连接的过程也被称为四次挥手。
TCP 连接的任意一方都可以发起关闭操作,只不过通常情况下发起关闭连接操作一般都是客户端。然而,一些服务器比如 Web 服务器在对请求作出相应后也会发起关闭连接的操作。TCP 协议规定通过发送一个 FIN 报文来发起关闭操作。
所以综上所述,建立一个 TCP 连接需要三个报文段,而关闭一个 TCP 连接需要四个报文段。TCP 协议还支持一种半开启(half-open)
状态,虽然这种情况并不多见。
TCP 半开启
TCP 连接处于半开启的这种状态是因为连接的一方关闭或者终止了这个 TCP 连接却没有通知另一方,也就是说两个人正在微信聊天,cxuan 你下线了你不告诉我,我还在跟你侃八卦呢。此时就认为这条连接处于半开启
状态。这种情况发生在通信中的一方处于主机崩溃的情况下,你 xxx 的,我电脑死机了我咋告诉你?只要处于半连接状态的一方不传输数据的话,那么是无法检测出来对方主机已经下线的。
另外一种处于半开启状态的原因是通信的一方关闭了主机电源 而不是正常关机。这种情况下会导致服务器上有很多半开启的 TCP 连接。
TCP 半关闭
既然 TCP 支持半开启操作,那么我们可以设想 TCP 也支持半关闭操作。同样的,TCP 半关闭也并不常见。TCP 的半关闭操作是指仅仅关闭数据流的一个传输方向。两个半关闭操作合在一起就能够关闭整个连接。在一般情况下,通信双方会通过应用程序互相发送 FIN 报文段来结束连接,但是在 TCP 半关闭的情况下,应用程序会表明自己的想法:"我已经完成了数据的发送发送,并发送了一个 FIN 报文段给对方,但是我依然希望接收来自对方的数据直到它发送一个 FIN 报文段给我"。 下面是一个 TCP 半关闭的示意图。
解释一下这个过程:
首先客户端主机和服务器主机一直在进行数据传输,一段时间后,客户端发起了 FIN 报文,要求主动断开连接,服务器收到 FIN 后,回应 ACK ,由于此时发起半关闭的一方也就是客户端仍然希望服务器发送数据,所以服务器会继续发送数据,一段时间后服务器发送另外一条 FIN 报文,在客户端收到 FIN 报文回应 ACK 给服务器后,断开连接。
TCP 的半关闭操作中,连接的一个方向被关闭,而另一个方向仍在传输数据直到它被关闭为止。只不过很少有应用程序使用这一特性。
同时打开和同时关闭
还有一种比较非常规的操作,这就是两个应用程序同时主动打开连接。虽然这种情况看起来不太可能,但是在特定的安排下却是有可能发生的。我们主要讲述这个过程。
通信双方在接收到来自对方的 SYN 之前会首先发送一个 SYN,这个场景还要求通信双方都知道对方的 IP 地址 + 端口号。
比如恋爱中的一对男女,他俩都同时说出了我爱你这个神圣的誓言,然后他俩对彼此的响应进行么么哒,这就是同时打开。
下面是同时打开的例子
如上图所示,通信双方都在收到对方报文前主动发送了 SYN 报文,都在收到彼此的报文后回复了一个 ACK 报文。
一个同时打开过程需要交换四个报文段,比普通的三次握手增加了一个,由于同时打开没有客户端和服务器一说,所以这里我用了通信双方来称呼。
像同时打开一样,同时关闭也是通信双方同时提出主动关闭请求,发送 FIN 报文,下图显示了一个同时关闭的过程。
同时关闭过程中需要交换和正常关闭相同数量的报文段,只不过同时关闭不像四次挥手那样顺序进行,而是交叉进行的。
聊一聊初始序列号
也许是我上面图示或者文字描述的不专业,初始序列号它是有专业术语表示的,初始序列号的英文名称是Initial sequence numbers (ISN),所以我们上面表示的 seq = v,其实就表示的 ISN。
在发送 SYN 之前,通信双方会选择一个初始序列号。初始序列号是随机生成的,每一个 TCP 连接都会有一个不同的初始序列号。RFC 文档指出初始序列号是一个 32 位的计数器,每 4 us(微秒) + 1。因为每个 TCP 连接都是一个不同的实例,这么安排的目的就是为了防止出现序列号重叠的情况。
当一个 TCP 连接建立的过程中,只有正确的 TCP 四元组和正确的序列号才会被对方接收。这也反应了 TCP 报文段容易被伪造
的脆弱性,因为只要我伪造了一个相同的四元组和初始序列号就能够伪造 TCP 连接,从而打断 TCP 的正常连接,所以抵御这种攻击的一种方式就是使用初始序列号,另外一种方法就是加密序列号。
TCP 状态转换
我们上面聊到了三次握手和四次挥手,提到了一些关于 TCP 连接之间的状态转换,那么下面我就从头开始和你好好梳理一下这些状态之间的转换。
首先第一步,刚开始时服务器和客户端都处于 CLOSED 状态,这时需要判断是主动打开还是被动打开,如果是主动打开,那么客户端向服务器发送 SYN
报文,此时客户端处于 SYN-SEND
状态,SYN-SEND 表示发送连接请求后等待匹配的连接请求,服务器被动打开会处于 LISTEN
状态,用于监听 SYN 报文。如果客户端调用了 close 方法或者经过一段时间没有操作,就会重新变为 CLOSED 状态,这一步转换图如下
这里有个疑问,为什么处于 LISTEN 状态下的客户端还会发送 SYN 变为 SYN_SENT 状态呢?
知乎看到了车小胖大佬的回答,这种情况可能出现在 FTP 中,LISTEN -> SYN_SENT 是因为这个连接可能是由于服务器端的应用有数据发送给客户端所触发的,客户端被动接受连接,连接建立后,开始传输文件。也就是说,处于 LISTEN 状态的服务器也是有可能发送 SYN 报文的,只不过这种情况非常少见。
处于 SYN_SEND 状态的服务器会接收 SYN 并发送 SYN 和 ACK 转换成为 SYN_RCVD
状态,同样的,处于 LISTEN 状态的客户端也会接收 SYN 并发送 SYN 和 ACK 转换为 SYN_RCVD 状态。如果处于 SYN_RCVD 状态的客户端收到 RST
就会变为 LISTEN 状态。
这两张图一起看会比较好一些。
这里需要解释下什么是 RST
这里有一种情况是当主机收到 TCP 报文段后,其 IP 和端口号不匹配的情况。假设客户端主机发送一个请求,而服务器主机经过 IP 和端口号的判断后发现不是给这个服务器的,那么服务器就会发出一个 RST
特殊报文段给客户端。
因此,当服务端发送一个 RST 特殊报文段给客户端的时候,它就会告诉客户端没有匹配的套接字连接,请不要再继续发送了。
RST:(Reset the connection)用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到 RST 位时候,通常发生了某些错误。
上面没有识别正确的 IP 端口是一种导致 RST 出现的情况,除此之外,RST 还可能由于请求超时、取消一个已存在的连接等出现。
位于 SYN_RCVD 的服务器会接收 ACK 报文,SYN_SEND 的客户端会接收 SYN 和 ACK 报文,并发送 ACK 报文,由此,客户端和服务器之间的连接就建立了。
这里还要注意一点,同时打开的状态我在上面没有刻意表示出来,实际上,在同时打开的情况下,它的状态变化是这样的。
为什么会是这样呢?因为你想,在同时打开的情况下,两端主机都发起 SYN 报文,而主动发起 SYN 的主机会处于 SYN-SEND 状态,发送完成后,会等待接收 SYN 和 ACK , 在双方主机都发送了 SYN + ACK 后,双方都处于 SYN-RECEIVED(SYN-RCVD) 状态,然后等待 SYN + ACK 的报文到达后,双方就会处于 ESTABLISHED 状态,开始传输数据。
好了,到现在为止,我给你叙述了一下 TCP 连接建立过程中的状态转换,现在你可以泡一壶茶喝点水,等着数据传输了。
好了,现在水喝够了,这时候数据也传输完成了,数据传输完成后,这条 TCP 连接就可以断开了。
现在我们把时钟往前拨一下,调整到服务端处于 SYN_RCVD 状态的时刻,因为刚收到了 SYN 包并发送了 SYN + ACK 包,此时服务端很开心,但是这时,服务端应用进程关闭了,然后应用进程发了一个 FIN
包,就会让服务器从 SYN_RCVD -> FIN_WAIT_1
状态。
然后把时钟调到现在,客户端和服务器现在已经传输完数据了 ,此时客户端发送了一条 FIN 报文希望断开连接,此时客户端也会变为 FIN_WAIT_1
状态,对于服务器来说,它接收到了 FIN 报文段并回复了 ACK 报文,就会从 ESTABLISHED -> CLOSE_WAIT
状态。
位于 CLOSE_WAIT 状态的服务端会发送 FIN 报文,然后把自己置于 LAST_ACK 状态。处于 FIN_WAIT_1 的客户端接收 ACK 消息就会变为 FIN_WAIT_2 状态。
这里需要先解释一下 CLOSING 这个状态,FIN_WAIT_1 -> CLOSING 的转换比较特殊
CLOSING 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送FIN 报文后,按理来说是应该先收到(或同时收到)对方的 ACK 报文,再收到对方的 FIN 报文。但是 CLOSING 状态表示你发送 FIN 报文后,并没有收到对方的 ACK 报文,反而却也收到了对方的 FIN 报文。
什么情况下会出现此种情况呢?其实细想一下,也不难得出结论:那就是如果双方在同时关闭一个链接的话,那么就出现了同时发送 FIN 报文的情况,也即会出现 CLOSING 状态,表示双方都正在关闭连接。
FIN_WAIT_2 状态的客户端接收服务端主机发送的 FIN + ACK 消息,并发送 ACK 响应后,会变为 TIME_WAIT
状态。处于 CLOSE_WAIT 的客户端发送 FIN 会处于 LAST_ACK 状态。
这里不少图和博客虽然在图上画的是 FIN + ACK 报文后才会处于 LAST_ACK 状态,但是描述的时候,一般通常只对于 FIN 进行描述。也就是说 CLOSE_WAIT 发送 FIN 才会处于 LAST_ACK 状态。
所以这里 FIN_WAIT_1 -> TIME_WAIT 的状态也就是接收 FIN 和 ACK 并发送 ACK 之后,客户端处于的状态。
然后位于 CLOSINIG 状态的客户端这时候还有 ACK 接收的话,会继续处于 TIME_WAIT 状态,可以看到,TIME_WAIT 状态相当于是客户端在关闭前的最后一个状态,它是一种主动关闭的状态;而 LAST_ACK 是服务端在关闭前的最后一个状态,它是一种被动打开的状态。
上面有几个状态比较特殊,这里我们向西解释下。
TIME_WAIT 状态
通信双方建立 TCP 连接后,主动关闭连接的一方就会进入 TIME_WAIT 状态。TIME_WAIT 状态也称为 2MSL
的等待状态。在这个状态下,TCP 将会等待最大段生存期(Maximum Segment Lifetime, MSL) 时间的两倍。
这里需要解释下 MSL
MSL 是 TCP 段期望的最大生存时间,也就是在网络中存在的最长时间。这个时间是有限制的,因为我们知道 TCP 是依靠 IP 数据段来进行传输的,IP 数据报中有 TTL 和跳数的字段,这两个字段决定了 IP 的生存时间,一般情况下,TCP 的最大生存时间是 2 分钟,不过这个数值是可以修改的,根据不同操作系统可以修改此值。
基于此,我们来探讨 TIME_WAIT 的状态。
当 TCP 执行一个主动关闭并发送最终的 ACK 时,TIME_WAIT 应该以 2 * 最大生存时间存在,这样就能够让 TCP 重新发送最终的 ACK 以避免出现丢失的情况。重新发送最终的 ACK 并不是因为 TCP 重传了 ACK,而是因为通信另一方重传了 FIN,客户端经常回发送 FIN,因为它需要 ACK 的响应才能够关闭连接,如果生存时间超过了 2MSL 的话,客户端就会发送 RST,使服务端出错。
Web页面的请求历程
Hey guys 各位读者姥爷们大家好,这里是程序员 cxuan 计算机网络连载系列的第 13 篇文章。
到现在为止,我们算是把应用层、运输层、网络层和数据链路层都介绍完了,那么现在是时候把这些内容都串起来,做一个全面的回顾了。那么我这就以 Web 页面的请求历程为例,来和你聊聊计算机网络中这些协议是怎样工作的、数据包是怎么收发的,从输入 URL 、敲击会车到最终完成页面呈现在你面前的这个过程。
首先,我打开了 Web Browser ,然后在 Google 浏览器 URL 地址栏中输入了 maps.google.com
。
然后 ……
查找 DNS 缓存
浏览器在这个阶段会检查四个地方是否存在缓存,第一个地方是浏览器缓存,这个缓存就是 DNS 记录。
浏览器会为你访问过的网站在固定期限内维护 DNS 记录。因此,它是第一个运行 DNS 查询的地方。 浏览器首先会检查这个网址在浏览器中是否有一条对应的 DNS 记录,用来找到目标网址的 IP 地址。
我是 chrome 浏览器,所以在 mac 中,无法使用 chrome://net-internals/#dns 找到对应的 IP 地址,在 windows 中是可以找到的。
那么 mac 怎么查询 DNS 记录呢?你可以使用
nslookup
命令来查找,但这不是我们讨论的重点。
DNS(Domain Name System)
是一个分布式的数据库,它用于维护网址 URL 到其 IP 地址的映射关系。在互联网中,IP 地址是计算机所能够理解的一种地址,而 DNS 的这种别名地址是我们人类能够理解和记忆的地址,DNS 就负责把人类记忆的地址映射成计算机能够理解的地址,每个 URL 都有唯一的 IP 地址进行对应。
举个例子,google 的官网是 www.google.com ,而 google 的 ip 地址是 216.58.200.228 ,这两个地址你在 URL 上输入哪个都能访问,但是 IP 地址不好记忆,而 google.com 简单明了。DNS 就相当于是我们几年前使用的家庭电话薄,比如你想给 cxuan 打电话,你有可能记不住 cxuan 的电话号码,此时你需要查询电话薄来找到 cxuan 的电话号码。
浏览器第二个需要检查的地方就是操作系统缓存。如果 DNS 记录不在浏览器缓存中,那么浏览器将对操作系统发起系统调用,Windows 下就是 getHostName
。
在 Linux 和大部分 UNIX 系统上,除非安装了
nscd
,否则操作系统可能没有 DNS 缓存。nscd 是 Linux 系统上的一种名称服务缓存程序。
浏览低第三个需要检查的地方是路由器缓存,如果 DNS 记录不在自己电脑上的话,浏览器就会和与之相连的路由器共同维护 DNS 记录。
如果与之相连的路由器也没有 DNS 记录的话,浏览器就会检查 ISP
中是否有缓存。ISP 缓存就是你本地通信服务商的缓存,因为 ISP 维护着自己的 DNS 服务器,它缓存 DNS 记录的本质也是为了降低请求时间,达到快速响应的效果。一旦你访问过某些网站,你的 ISP 可能就会缓存这些页面,以便下次快速访问。对于经常看小电影的你是否感到震惊呢?如果家里还安装了一个可以联网的摄像头的话,那就有点嗨皮了。
你肯定比较困惑为什么第一步浏览器需要检查这么多缓存,你可能会感到不舒服,因为缓存可能会透露我们的隐私,但是这些缓存在调节网络流量和缩短数据传输时间等方面至关重要。
所以,上面涉及到 DNS 缓存的查询过程如下。
如果上面四个步骤中都不存在 DNS 记录,那么就表示不存在 DNS 缓存,这个时候就需要发起 DNS 查询,以查找目标网址(本示例中是 maps.google.com)的 IP 地址。
发起 DNS 查询
如上所述,如果想要使我的计算机和 maps.google.com 建立连接并进行通信的话,我需要知道 maps.google.com 的 IP 地址,由于 DNS 的设计原因,本地 DNS 可能无法给我提供正确的 IP 地址,那么它就需要在互联网上搜索多个 DNS 服务器,来找到网站的正确 IP 地址。
这里有个疑问,为什么我需要搜索多个 DNS 服务器的来找到网站的 IP 地址呢?一台服务器不行吗?
因为 DNS 是分布式域名服务器,每台服务器只维护一部分 IP 地址到网络地址的映射,没有任何一台服务器能够维持全部的映射关系。
在 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 服务器
。这些服务器的层次模型如下图所示
根 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 查找的步骤上来,DNS 的查询方式主要分为三种
DNS 查找中会出现三种类型的查询。通过组合使用这些查询,优化的 DNS 解析过程可缩短传输距离。在理想情况下,可以使用缓存的记录数据,从而使 DNS 域名服务器能够直接使用非递归查询。
递归查询
:在递归查询中,DNS 客户端要求 DNS 服务器(一般为 DNS 递归解析器)将使用所请求的资源记录响应客户端,或者如果解析器无法找到该记录,则返回错误消息。
迭代查询
:在迭代查询中,如果所查询的 DNS 服务器与查询名称不匹配,则其将返回对较低级别域名空间具有权威性的 DNS 服务器的引用。然后,DNS 客户端将对引用地址进行查询。此过程继续使用查询链中的其他 DNS 服务器,直至发生错误或超时为止。
非递归查询
:当 DNS 解析器客户端查询 DNS 服务器以获取其有权访问的记录时通常会进行此查询,因为其对该记录具有权威性,或者该记录存在于其缓存内。DNS 服务器通常会缓存 DNS 记录,查询到来后能够直接返回缓存结果,以防止更多带宽消耗和上游服务器上的负载。
上面负责开始 DNS 查找的介质就是 DNS 解析器,它一般是 ISP 维护的 DNS 服务器,它的主要职责就是通过向网络中其他 DNS 服务器询问正确的 IP 地址。
如果想要了解更多关于 DNS 的消息,请查阅 万字长文爆肝 DNS 协议!
所以对于 maps.google.com 这个域名来说,如果 ISP 维护的服务器没有 DNS 缓存记录,它就会向 DNS 根服务器地址发起查询,根名称服务器会将其重定向到 .com 顶级域名服务器。 .com 顶级域名服务器会将其重定向到google.com 权威服务器。google.com 名称服务器将在其 DNS 记录中找到 maps.google.com 匹配的 IP 地址,并将其返回给您的 DNS 解析器,然后将其发送回你的浏览器。
这里值得注意的是,DNS 查询报文会经过许多路由器和设备才会达到根域名等服务器,每经过一个设备或者路由器都会使用路由表
来确定哪种路径是数据包达到目的地最快的选择。这里面涉及到路由选择算法,如果小伙伴们想要了解路由选择算法,可以看看这篇文章 https://www.cisco.com/c/en/us/support/docs/ip/border-gateway-protocol-bgp/13753-25.html#anc3
ARP 请求
我看了很多篇文章都没有提到这一点,那就是 ARP 请求的这个过程。
什么时候需要发送 ARP 请求呢?
这里其实有个前提条件
- 如果 DNS 服务器和我们的主机在同一个子网内,系统会按照下面的 ARP 过程对 DNS 服务器进行 ARP 查询
- 如果 DNS 服务器和我们的主机在不同的子网,系统会按照下面的 ARP 过程对默认网关进行查询
ARP 协议的全称是 Address Resolution Protocol(地址解析协议)
,它是一个通过用于实现从 IP 地址到 MAC 地址的映射,即询问目标 IP 对应的 MAC 地址 的一种协议。
简而言之,ARP 就是一种解决地址问题的协议,它以 IP 地址为线索,定位下一个应该接收数据分包的主机 MAC 地址。如果目标主机不在同一个链路上,那么会查找下一跳路由器的 MAC 地址。
关于为什么有了 IP 地址,还要有 MAC 地址概述可以参看知乎这个回答 https://www.zhihu.com/question/21546408
ARP 的大致工作流程如下
假设 A 和 B 位于同一链路,不需要经过路由器的转换,主机 A 向主机 B 发送一个 IP 分组,主机 A 的地址是 192.168.1.2 ,主机 B 的地址是 192.168.1.3,它们都不知道对方的 MAC 地址是啥,主机 C 和 主机 D 是同一链路的其他主机。
主机 A 想要获取主机 B 的 MAC 地址,通过主机 A 会通过广播
的方式向以太网上的所有主机发送一个 ARP 请求包
,这个 ARP 请求包中包含了主机 A 想要知道的主机 B 的 IP 地址的 MAC 地址。
主机 A 发送的 ARP 请求包会被同一链路上的所有主机/路由器接收并进行解析。每个主机/路由器都会检查 ARP 请求包中的信息,如果 ARP 请求包中的目标 IP 地址
和自己的相同,就会将自己主机的 MAC 地址写入响应包返回主机 A
由此,可以通过 ARP 从 IP 地址获取 MAC 地址,实现同一链路内的通信。
所以,要想发送 ARP 广播,我们需要有一个目标 IP 地址,同时还需要知道用于发送 ARP 广播的接口的 MAC 地址。
这里会涉及到 ARP 缓存的概念。
现在你知道了发送一次 IP 分组前通过发送一次 ARP 请求就能够确定 MAC 地址。那么是不是每发送一次都得经过广播 -> 封装 ARP 响应 -> 返回给主机这一系列流程呢?
想想看,浏览器是如何做的?浏览器内置了缓存能够缓存你最近经常使用的地址,那么 ARP 也是一样的。ARP 高效运行的关键就是维护每个主机和路由器上的 ARP 缓存(或表)
。这个缓存维护着每个 IP 到 MAC 地址的映射关系。通过把第一次 ARP 获取到的 MAC 地址作为 IP 对 MAC 的映射关系到一个 ARP 缓存表中,下一次再向这个地址发送数据报时就不再需要重新发送 ARP 请求了,而是直接使用这个缓存表中的 MAC 地址进行数据报的发送。每发送一次 ARP 请求,缓存表中对应的映射关系都会被清除。
通过 ARP 缓存,降低了网络流量的使用,在一定程度上防止了 ARP 的大量广播。
一般来说,发送过一次 ARP 请求后,再次发送相同请求的几率比较大,因此使用 ARP 缓存能够减少 ARP 包的发送,除此之外,不仅仅 ARP 请求的发送方能够缓存 ARP 接收方的 MAC 地址,接收方也能够缓存 ARP 请求方的 IP 和 MAC 地址,如下所示
不过,MAC 地址的缓存有一定期限,超过这个期限后,缓存的内容会被清除。
深入理解 ARP 协议的话,可以参考 cxuan 的这篇文章。
所以,浏览器会首先查询 ARP 缓存,如果缓存命中,我们返回结果:目标 IP = MAC。
如果缓存没有命中:
- 查看路由表,看看目标 IP 地址是不是在本地路由表中的某个子网内。是的话,使用跟那个子网相连的接口,否则使用与默认网关相连的接口。
- 查询选择的网络接口的 MAC 地址
- 我们发送一个数据链路层的 ARP 请求:
根据连接主机和路由器的硬件类型不同,可以分为以下几种情况:
直连:
- 如果我们和路由器是直接连接的,路由器会返回一个
ARP Reply
(见下面)。
集线器:
- 如果我们连接到一个集线器,集线器会把 ARP 请求向所有其它端口广播,如果路由器也连接在其中,它会返回一个
ARP Reply
。
交换机:
- 如果我们连接到了一个交换机,交换机会检查本地 CAM/MAC 表,看看哪个端口有我们要找的那个 MAC 地址,如果没有找到,交换机会向所有其它端口广播这个 ARP 请求。
- 如果交换机的 MAC/CAM 表中有对应的条目,交换机会向有我们想要查询的 MAC 地址的那个端口发送 ARP 请求
- 如果路由器也
连接
在其中,它会返回一个ARP Reply
ARP Reply
:
现在我们有了 DNS 服务器或者默认网关的 IP 地址,我们可以继续 DNS 请求了:
- 使用 53 端口向 DNS 服务器发送 UDP 请求包,如果响应包太大,会使用 TCP 协议
- 如果本地/ISP DNS 服务器没有找到结果,它会发送一个递归查询请求,一层一层向高层 DNS 服务器做查询,直到查询到起始授权机构,如果找到会把结果返回。
(上述均来自:https://github.com/skyline75489/what-happens-when-zh_CN#dns)
封装 TCP 数据包
浏览器得到目标服务器的 IP 地址后,根据 URL 中的端口可以知道端口号 (http 协议默认端口号是 80, https 默认端口号是 443),会准备 TCP 数据包。数据包的封装会经过下面的层层处理,数据到达目标主机后,目标主机会解析数据包,完整的请求和解析过程如下。
这里就不再详细介绍了,读者朋友们可以阅读 cxuan 的这篇文章 TCP/IP 基础知识详解详细了解。
浏览器与目标服务器建立 TCP 连接
在经过上述 DNS 和 ARP 查找流程后,浏览器就会收到一个目标服务器的 IP 和 MAC地址,然后浏览器将会和目标服务器建立连接来传输信息。这里可以使用很多种 Internet 协议,但是 HTTP 协议建立连接所使用的运输层协议是 TCP 协议。所以这一步骤是浏览器与目标服务器建立 TCP 连接的过程。
TCP 的连接建立需要经过 TCP/IP 的三次握手,三次握手的过程其实就是浏览器和服务器交换 SYN 同步和 ACK 确认消息的过程。
假设图中左端是客户端主机,右端是服务端主机,一开始,两端都处于CLOSED(关闭)
状态。
- 服务端进程准备好接收来自外部的 TCP 连接。然后服务端进程处于
LISTEN
状态,等待客户端连接请求。 - 客户端向服务器发出连接请求,请求中首部同步位 SYN = 1,同时选择一个初始序号 sequence ,简写 seq = x。SYN 报文段不允许携带数据,只消耗一个序号。此时,客户端进入
SYN-SEND
状态。 - 服务器收到客户端连接后,,需要确认客户端的报文段。在确认报文段中,把 SYN 和 ACK 位都置为 1 。确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。请注意,这个报文段也不能携带数据,但同样要消耗掉一个序号。此时,TCP 服务器进入
SYN-RECEIVED(同步收到)
状态。 - 客户端在收到服务器发出的响应后,还需要给出确认连接。确认连接中的 ACK 置为 1 ,序号为 seq = x + 1,确认号为 ack = y + 1。TCP 规定,这个报文段可以携带数据也可以不携带数据,如果不携带数据,那么下一个数据报文段的序号仍是 seq = x + 1。这时,客户端进入
ESTABLISHED (已连接)
状态 - 服务器收到客户的确认后,也进入
ESTABLISHED
状态。
这样三次握手建立连接的阶段就完成了,双方可以直接通信了。
浏览器发送 HTTP 请求到 web 服务器
一旦 TCP 连接建立完成后,就开始直接传输数据办正事了!此时浏览器会发送 GET
请求,要求目标服务器提供 maps.google.com 的网页,如果你填写的是表单,则发起的是 POST
请求,在 HTTP 中,GET 请求和 POST 请求是最常见的两种请求,基本上占据了所有 HTTP 请求的九成以上。
除了请求类型外,HTTP 请求还包含很多很多信息,最常见的有 Host、Connection 、User-agent、Accept-language 等
首先 Host 表示的是对象所在的主机。Connection: close
表示的是浏览器需要告诉服务器使用的是非持久连接
。它要求服务器在发送完响应的对象后就关闭连接。User-agent
: 这是请求头用来告诉 Web 服务器,浏览器使用的类型是 Mozilla/5.0
,即 Firefox 浏览器。Accept-language
告诉 Web 服务器,浏览器想要得到对象的法语版本,前提是服务器需要支持法语类型,否则将会发送服务器的默认版本。下面我们针对主要的实体字段进行介绍(具体的可以参考 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers MDN 官网学习)
HTTP 的请求标头分为四种: 通用标头
、请求标头
、响应标头
和 实体标头
。
这四种标头又分别有很多内容,如果你想要深入理解一下关于 HTTP 请求头的相关内容,可以参考 cxuan 的这篇文章
服务器处理请求并发回一个响应
这个服务器包含一个 Web 服务器,也就是 Apache 服务器,服务器会从浏览器接收请求并将其传递给请求处理程序并生成响应。
请求处理程序也是一个程序,它一般是用 .net 、php、ruby 等语言编写,用于读取请求,检查请求内容,cookie,必要时更新服务器上的信息的这么一个程序。它会以特定的格式比如 JSON、XML、HTML 组合响应。
服务器发送回一个 HTTP 响应
服务器响应包含你请求的网页以及状态代码,压缩类型(Content-Encoding),如何缓存页面(Cache-Control),要设置的 cookie,隐私信息等。
比如下面就是一个响应体
关于深入理解 HTTP 请求和响应,可以参考这篇文章
浏览器显示 HTML 的相关内容
浏览器会分阶段显示 HTML 内容。 首先,它将渲染裸露的 HTML 骨架。 然后它将检查 HTML 标记并发送 GET 请求以获取网页上的其他元素,例如图像,CSS 样式表,JavaScript 文件等。这些静态文件由浏览器缓存,因此你再次访问该页面时,不用重新再请求一次。最后,您会看到 maps.google.com 显示的内容出现在你的浏览器中。
TCP传输控制协议
TCP 是一种面向连接的单播
协议,在 TCP 中,并不存在多播、广播的这种行为,因为 TCP 报文段中能明确发送方和接受方的 IP 地址。
在发送数据前,相互通信的双方(即发送方和接受方)需要建立一条连接
,在发送数据后,通信双方需要断开连接,这就是 TCP 连接的建立和终止。
TCP 连接的建立和终止
如果你看过我之前写的关于网络层的一篇文章,你应该知道 TCP 的基本元素有四个:即发送方的 IP 地址、发送方的端口号、接收方的 IP 地址、接收方的端口号。而每一方的 IP + 端口号都可以看作是一个套接字
,套接字能够被唯一标示。套接字就相当于是门,出了这个门,就要进行数据传输了。
TCP 的连接建 立 -> 终止总共分为三个阶段
下面我们所讨论的重点也是集中在这三个层面。
下图是一个非常典型的 TCP 连接的建立和关闭过程,其中不包括数据传输的部分。
TCP 建立连接 – 三次握手
- 服务端进程准备好接收来自外部的 TCP 连接,一般情况下是调用 bind、listen、socket 三个函数完成。这种打开方式被认为是
被动打开(passive open)
。然后服务端进程处于LISTEN
状态,等待客户端连接请求。 - 客户端通过
connect
发起主动打开(active open)
,向服务器发出连接请求,请求中首部同步位 SYN = 1,同时选择一个初始序号 sequence ,简写 seq = x。SYN 报文段不允许携带数据,只消耗一个序号。此时,客户端进入SYN-SEND
状态。 - 服务器收到客户端连接后,,需要确认客户端的报文段。在确认报文段中,把 SYN 和 ACK 位都置为 1 。确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。这个报文段也不能携带数据,但同样要消耗掉一个序号。此时,TCP 服务器进入
SYN-RECEIVED(同步收到)
状态。 - 客户端在收到服务器发出的响应后,还需要给出确认连接。确认连接中的 ACK 置为 1 ,序号为 seq = x + 1,确认号为 ack = y + 1。TCP 规定,这个报文段可以携带数据也可以不携带数据,如果不携带数据,那么下一个数据报文段的序号仍是 seq = x + 1。这时,客户端进入
ESTABLISHED (已连接)
状态 - 服务器收到客户的确认后,也进入
ESTABLISHED
状态。
这是一个典型的三次握手过程,通过上面 3 个报文段就能够完成一个 TCP 连接的建立。三次握手的的目的不仅仅在于让通信双方知晓正在建立一个连接,也在于利用数据包中的选项字段来交换一些特殊信息,交换初始序列号。
一般首个发送 SYN 报文的一方被认为是主动打开一个连接,而这一方通常也被称为
客户端
。而 SYN 的接收方通常被称为服务端
,它用于接收这个 SYN,并发送下面的 SYN,因此这种打开方式是被动打开。
TCP 建立一个连接需要三个报文段,释放一个连接却需要四个报文段。
TCP 断开连接 – 四次挥手
数据传输结束后,通信的双方可以释放连接。数据传输结束后的客户端主机和服务端主机都处于 ESTABLISHED 状态,然后进入释放连接的过程。
TCP 断开连接需要历经的过程如下
-
客户端应用程序发出释放连接的报文段,并停止发送数据,主动关闭 TCP 连接。客户端主机发送释放连接的报文段,报文段中首部 FIN 位置为 1 ,不包含数据,序列号位 seq = u,此时客户端主机进入
FIN-WAIT-1(终止等待 1)
阶段。 -
服务器主机接受到客户端发出的报文段后,即发出确认应答报文,确认应答报文中 ACK = 1,生成自己的序号位 seq = v,ack = u + 1,然后服务器主机就进入
CLOSE-WAIT(关闭等待)
状态。 -
客户端主机收到服务端主机的确认应答后,即进入
FIN-WAIT-2(终止等待2)
的状态。等待客户端发出连接释放的报文段。 -
这时服务端主机会发出断开连接的报文段,报文段中 ACK = 1,序列号 seq = v,ack = u + 1,在发送完断开请求的报文后,服务端主机就进入了
LAST-ACK(最后确认)
的阶段。 -
客户端收到服务端的断开连接请求后,客户端需要作出响应,客户端发出断开连接的报文段,在报文段中,ACK = 1, 序列号 seq = u + 1,因为客户端从连接开始断开后就没有再发送数据,ack = v + 1,然后进入到
TIME-WAIT(时间等待)
状态,请注意,这个时候 TCP 连接还没有释放。必须经过时间等待的设置,也就是2MSL
后,客户端才会进入CLOSED
状态,时间 MSL 叫做最长报文段寿命(Maximum Segment Lifetime)
。 -
服务端主要收到了客户端的断开连接确认后,就会进入 CLOSED 状态。因为服务端结束 TCP 连接时间要比客户端早,而整个连接断开过程需要发送四个报文段,因此释放连接的过程也被称为四次挥手。
TCP 连接的任意一方都可以发起关闭操作,只不过通常情况下发起关闭连接操作一般都是客户端。然而,一些服务器比如 Web 服务器在对请求作出相应后也会发起关闭连接的操作。TCP 协议规定通过发送一个 FIN 报文来发起关闭操作。
所以综上所述,建立一个 TCP 连接需要三个报文段,而关闭一个 TCP 连接需要四个报文段。TCP 协议还支持一种半开启(half-open)
状态,虽然这种情况并不多见。
TCP 半开启
TCP 连接处于半开启的这种状态是因为连接的一方关闭或者终止了这个 TCP 连接却没有通知另一方,也就是说两个人正在微信聊天,cxuan 你下线了你不告诉我,我还在跟你侃八卦呢。此时就认为这条连接处于半开启
状态。这种情况发生在通信中的一方处于主机崩溃的情况下,你 xxx 的,我电脑死机了我咋告诉你?只要处于半连接状态的一方不传输数据的话,那么是无法检测出来对方主机已经下线的。
另外一种处于半开启状态的原因是通信的一方关闭了主机电源 而不是正常关机。这种情况下会导致服务器上有很多半开启的 TCP 连接。
TCP 半关闭
既然 TCP 支持半开启操作,那么我们可以设想 TCP 也支持半关闭操作。同样的,TCP 半关闭也并不常见。TCP 的半关闭操作是指仅仅关闭数据流的一个传输方向。两个半关闭操作合在一起就能够关闭整个连接。在一般情况下,通信双方会通过应用程序互相发送 FIN 报文段来结束连接,但是在 TCP 半关闭的情况下,应用程序会表明自己的想法:"我已经完成了数据的发送发送,并发送了一个 FIN 报文段给对方,但是我依然希望接收来自对方的数据直到它发送一个 FIN 报文段给我"。 下面是一个 TCP 半关闭的示意图。
解释一下这个过程:
首先客户端主机和服务器主机一直在进行数据传输,一段时间后,客户端发起了 FIN 报文,要求主动断开连接,服务器收到 FIN 后,回应 ACK ,由于此时发起半关闭的一方也就是客户端仍然希望服务器发送数据,所以服务器会继续发送数据,一段时间后服务器发送另外一条 FIN 报文,在客户端收到 FIN 报文回应 ACK 给服务器后,断开连接。
TCP 的半关闭操作中,连接的一个方向被关闭,而另一个方向仍在传输数据直到它被关闭为止。只不过很少有应用程序使用这一特性。
同时打开和同时关闭
还有一种比较非常规的操作,这就是两个应用程序同时主动打开连接。虽然这种情况看起来不太可能,但是在特定的安排下却是有可能发生的。我们主要讲述这个过程。
通信双方在接收到来自对方的 SYN 之前会首先发送一个 SYN,这个场景还要求通信双方都知道对方的 IP 地址 + 端口号。
比如恋爱中的一对男女,他俩都同时说出了我爱你这个神圣的誓言,然后他俩对彼此的响应进行么么哒,这就是同时打开。
下面是同时打开的例子
如上图所示,通信双方都在收到对方报文前主动发送了 SYN 报文,都在收到彼此的报文后回复了一个 ACK 报文。
一个同时打开过程需要交换四个报文段,比普通的三次握手增加了一个,由于同时打开没有客户端和服务器一说,所以这里我用了通信双方来称呼。
像同时打开一样,同时关闭也是通信双方同时提出主动关闭请求,发送 FIN 报文,下图显示了一个同时关闭的过程。
同时关闭过程中需要交换和正常关闭相同数量的报文段,只不过同时关闭不像四次挥手那样顺序进行,而是交叉进行的。
聊一聊初始序列号
也许是我上面图示或者文字描述的不专业,初始序列号它是有专业术语表示的,初始序列号的英文名称是Initial sequence numbers (ISN),所以我们上面表示的 seq = v,其实就表示的 ISN。
在发送 SYN 之前,通信双方会选择一个初始序列号。初始序列号是随机生成的,每一个 TCP 连接都会有一个不同的初始序列号。RFC 文档指出初始序列号是一个 32 位的计数器,每 4 us(微秒) + 1。因为每个 TCP 连接都是一个不同的实例,这么安排的目的就是为了防止出现序列号重叠的情况。
当一个 TCP 连接建立的过程中,只有正确的 TCP 四元组和正确的序列号才会被对方接收。这也反应了 TCP 报文段容易被伪造
的脆弱性,因为只要我伪造了一个相同的四元组和初始序列号就能够伪造 TCP 连接,从而打断 TCP 的正常连接,所以抵御这种攻击的一种方式就是使用初始序列号,另外一种方法就是加密序列号。
TCP 状态转换
我们上面聊到了三次握手和四次挥手,提到了一些关于 TCP 连接之间的状态转换,那么下面我就从头开始和你好好梳理一下这些状态之间的转换。
首先第一步,刚开始时服务器和客户端都处于 CLOSED 状态,这时需要判断是主动打开还是被动打开,如果是主动打开,那么客户端向服务器发送 SYN
报文,此时客户端处于 SYN-SEND
状态,SYN-SEND 表示发送连接请求后等待匹配的连接请求,服务器被动打开会处于 LISTEN
状态,用于监听 SYN 报文。如果客户端调用了 close 方法或者经过一段时间没有操作,就会重新变为 CLOSED 状态,这一步转换图如下
这里有个疑问,为什么处于 LISTEN 状态下的客户端还会发送 SYN 变为 SYN_SENT 状态呢?
知乎看到了车小胖大佬的回答,这种情况可能出现在 FTP 中,LISTEN -> SYN_SENT 是因为这个连接可能是由于服务器端的应用有数据发送给客户端所触发的,客户端被动接受连接,连接建立后,开始传输文件。也就是说,处于 LISTEN 状态的服务器也是有可能发送 SYN 报文的,只不过这种情况非常少见。
处于 SYN_SEND 状态的服务器会接收 SYN 并发送 SYN 和 ACK 转换成为 SYN_RCVD
状态,同样的,处于 LISTEN 状态的客户端也会接收 SYN 并发送 SYN 和 ACK 转换为 SYN_RCVD 状态。如果处于 SYN_RCVD 状态的客户端收到 RST
就会变为 LISTEN 状态。
这两张图一起看会比较好一些。
这里需要解释下什么是 RST
这里有一种情况是当主机收到 TCP 报文段后,其 IP 和端口号不匹配的情况。假设客户端主机发送一个请求,而服务器主机经过 IP 和端口号的判断后发现不是给这个服务器的,那么服务器就会发出一个 RST
特殊报文段给客户端。
因此,当服务端发送一个 RST 特殊报文段给客户端的时候,它就会告诉客户端没有匹配的套接字连接,请不要再继续发送了。
RST:(Reset the connection)用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到 RST 位时候,通常发生了某些错误。
上面没有识别正确的 IP 端口是一种导致 RST 出现的情况,除此之外,RST 还可能由于请求超时、取消一个已存在的连接等出现。
位于 SYN_RCVD 的服务器会接收 ACK 报文,SYN_SEND 的客户端会接收 SYN 和 ACK 报文,并发送 ACK 报文,由此,客户端和服务器之间的连接就建立了。
这里还要注意一点,同时打开的状态我在上面没有刻意表示出来,实际上,在同时打开的情况下,它的状态变化是这样的。
为什么会是这样呢?因为你想,在同时打开的情况下,两端主机都发起 SYN 报文,而主动发起 SYN 的主机会处于 SYN-SEND 状态,发送完成后,会等待接收 SYN 和 ACK , 在双方主机都发送了 SYN + ACK 后,双方都处于 SYN-RECEIVED(SYN-RCVD) 状态,然后等待 SYN + ACK 的报文到达后,双方就会处于 ESTABLISHED 状态,开始传输数据。
好了,到现在为止,我给你叙述了一下 TCP 连接建立过程中的状态转换,现在你可以泡一壶茶喝点水,等着数据传输了。
好了,现在水喝够了,这时候数据也传输完成了,数据传输完成后,这条 TCP 连接就可以断开了。
现在我们把时钟往前拨一下,调整到服务端处于 SYN_RCVD 状态的时刻,因为刚收到了 SYN 包并发送了 SYN + ACK 包,此时服务端很开心,但是这时,服务端应用进程关闭了,然后应用进程发了一个 FIN
包,就会让服务器从 SYN_RCVD -> FIN_WAIT_1
状态。
然后把时钟调到现在,客户端和服务器现在已经传输完数据了 ,此时客户端发送了一条 FIN 报文希望断开连接,此时客户端也会变为 FIN_WAIT_1
状态,对于服务器来说,它接收到了 FIN 报文段并回复了 ACK 报文,就会从 ESTABLISHED -> CLOSE_WAIT
状态。
位于 CLOSE_WAIT 状态的服务端会发送 FIN 报文,然后把自己置于 LAST_ACK 状态。处于 FIN_WAIT_1 的客户端接收 ACK 消息就会变为 FIN_WAIT_2 状态。
这里需要先解释一下 CLOSING 这个状态,FIN_WAIT_1 -> CLOSING 的转换比较特殊
CLOSING 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送FIN 报文后,按理来说是应该先收到(或同时收到)对方的 ACK 报文,再收到对方的 FIN 报文。但是 CLOSING 状态表示你发送 FIN 报文后,并没有收到对方的 ACK 报文,反而却也收到了对方的 FIN 报文。
什么情况下会出现此种情况呢?其实细想一下,也不难得出结论:那就是如果双方在同时关闭一个链接的话,那么就出现了同时发送 FIN 报文的情况,也即会出现 CLOSING 状态,表示双方都正在关闭连接。
FIN_WAIT_2 状态的客户端接收服务端主机发送的 FIN + ACK 消息,并发送 ACK 响应后,会变为 TIME_WAIT
状态。处于 CLOSE_WAIT 的客户端发送 FIN 会处于 LAST_ACK 状态。
这里不少图和博客虽然在图上画的是 FIN + ACK 报文后才会处于 LAST_ACK 状态,但是描述的时候,一般通常只对于 FIN 进行描述。也就是说 CLOSE_WAIT 发送 FIN 才会处于 LAST_ACK 状态。
所以这里 FIN_WAIT_1 -> TIME_WAIT 的状态也就是接收 FIN 和 ACK 并发送 ACK 之后,客户端处于的状态。
然后位于 CLOSINIG 状态的客户端这时候还有 ACK 接收的话,会继续处于 TIME_WAIT 状态,可以看到,TIME_WAIT 状态相当于是客户端在关闭前的最后一个状态,它是一种主动关闭的状态;而 LAST_ACK 是服务端在关闭前的最后一个状态,它是一种被动打开的状态。
上面有几个状态比较特殊,这里我们向西解释下。
TIME_WAIT 状态
通信双方建立 TCP 连接后,主动关闭连接的一方就会进入 TIME_WAIT 状态。TIME_WAIT 状态也称为 2MSL
的等待状态。在这个状态下,TCP 将会等待最大段生存期(Maximum Segment Lifetime, MSL) 时间的两倍。
这里需要解释下 MSL
MSL 是 TCP 段期望的最大生存时间,也就是在网络中存在的最长时间。这个时间是有限制的,因为我们知道 TCP 是依靠 IP 数据段来进行传输的,IP 数据报中有 TTL 和跳数的字段,这两个字段决定了 IP 的生存时间,一般情况下,TCP 的最大生存时间是 2 分钟,不过这个数值是可以修改的,根据不同操作系统可以修改此值。
基于此,我们来探讨 TIME_WAIT 的状态。
当 TCP 执行一个主动关闭并发送最终的 ACK 时,TIME_WAIT 应该以 2 * 最大生存时间存在,这样就能够让 TCP 重新发送最终的 ACK 以避免出现丢失的情况。重新发送最终的 ACK 并不是因为 TCP 重传了 ACK,而是因为通信另一方重传了 FIN,客户端经常回发送 FIN,因为它需要 ACK 的响应才能够关闭连接,如果生存时间超过了 2MSL 的话,客户端就会发送 RST,使服务端出错。
计算机网络的网络层
前面我们学习了运输层如何为客户端和服务器输送数据的,提供进程端到端的通信。那么下面我们将学习网络层实际上是怎样实现主机到主机的通信服务的。几乎每个端系统都有网络层这一部分。所以,网络层必然是很复杂的。下面我将花费大量篇幅来介绍一下计算机网络层的知识。
网络层概述
网络层是 OSI 参考模型的第三层,它位于传输层和链路层之间,网络层的主要目的是实现两个端系统之间透明的数据传输。
网络层的作用从表面看上去非常简单,即将分组
从一台主机移动到另外一台主机。为了实现这个功能,网络层需要两种功能
转发
:因为在互联网中有很多路由器
的存在,而路由器是构成互联网的根本,路由器最重要的一个功能就是分组转发
,当一个分组到达某路由器的一条输入链路时,该路由器会将分组移动到适当的输出链路。转发是在数据平面中实现的唯一功能。
在网络中存在两种平面的选择
- 数据平面(data plane):负责转发网络流量,如路由器交换机中的转发表(我们后面会说)。
- 控制平面(control plane):控制网络的行为,比如网络路径的选择。
路由选择
:当分组由发送方流向接收方时,网络层必须选择这些分组的路径。计算这些路径选择的算法被称为路由选择算法(routing algorithm)
。
也就是说,转发是指将分组从一个输入链路转移到适当输出链路接口的路由器本地动作。而路由选择是指确定分组从源到目的地所定位的路径的选择。我们后面会经常提到转发和路由选择这两个名词。
那么此处就有一个问题,路由器怎么知道有哪些路径可以选择呢?
每台路由器都有一个关键的概念就是 转发表(forwarding table)
。路由器通过检查数据包标头中字段的值,来定位转发表中的项来实现转发。标头中的值即对应着转发表中的值,这个值指出了分组将被转发的路由器输出链路。如下图所示
上图中有一个 1001 分组到达路由器后,首先会在转发表中进行索引,然后由路由选择算法决定分组要走的路径。每台路由器都有两种功能:转发和路由选择。下 面我们就来聊一聊路由器的工作原理。
路由器工作原理
下面是一个路由器体系结构图,路由器主要是由 4 个组件构成的
- 输入端口:
输入端口(input port)
有很多功能。线路终端功能
和数据链路处理
功能,这两个功能实现了路由器的单个输入链路相关联的物理层和数据链路层。输入端口查找/转发功能
对路由器的交换功能来说至关重要,由路由器的交换结构来决定输出端口,具体来讲应该是查询转发表来确定的。 - 交换结构:
交换结构(Switching fabric)
就是将路由器的输入端口连接到它的输出端口。这种交换结构相当于是路由器内部的网络。 - 输出端口:
输出端口(Output ports)
通过交换结构转发分组,并通过物理层和数据链路层的功能传输分组,因此,输出端口作为输入端口执行反向数据链接和物理层功能。 - 路由选择处理器:
路由选择处理器(Routing processor)
在路由器内执行路由协议,维护路由表并执行网络管理功能。
上面只是这几个组件的简单介绍,其实这几个组件的组成并不像描述的那样简单,下面我们就来深入聊一聊这几个组件。
输入端口
上面介绍了输入端口有很多功能,包括线路终端、数据处理、查找转发,其实这些功能在输入端口的内部有相应的模块,输入端口的内部实现如下图所示
每个输入端口中都有一个路由处理器维护的路由表的副本,根据路由处理器进行更新。这个路由表的副本能 够使每个输入端口进行切换,而无需经过路由处理器统一处理。这是一种分散式
的切换,这种方式避免了路 由选择器统一处理造成转发瓶颈。
在输入端口处理能力有限的路由器中,输入端口不会进行交换功能,而是由路由处理器统一处理,然后根据 路由表查找并将数据包转发到相应的输出端口。
一般这种路由器不是单独的路由器,而是工作站或者服务器充当的路由,这种路由器内部中,路由处理器其实就是
CPU
,而输入端口其实只是网卡
。
输入端口会根据转发表定位输出端口,然后再会进行分组转发,那么现在就有一个问题,是不是每一个分组都有自己的一条链路呢?如果分组数量非常大,到达亿级的话,也会有亿个输出端口路径吗?
我们的潜意识中显然不是的,来看下面一个例子。
下面是三个输入端口对应了转发表中的三个输出链路的示例
可以看到,对于这个例子来说,路由器转发表中不需要那么多条链路,只需要四条就够,即对应输出链路 0 1 2 3 。也就是说,能够使用 4 个转发表就可以实现亿级链路。
如何实现呢?
使用这种风格的转发表,路由器分组的地址 前缀(prefix)
会与该表中的表项进行匹配。
如果存在一个匹配项,那么就会转发到对应的链路上,可能不好理解,我举个例子来说吧。
比如这时有一个分组是 11000011 10010101 00010000 0001100 到达,因为这个分组与 11000011 10010101 00010000 相匹配,所以路由器会转发到 0 链路接口上。如果一个前缀不匹配上面三个输出链路中的一种,那么路由器将向链路接口 3 进行转发。
路由匹配遵循 最长前缀原则(longest prefix matching rule)
,最长匹配原则故名思义就是如果有两个匹配项一个长一个短的话,就匹配最长的。
一旦通过查找功能确定了分组的输出端口后,那么该分组就会进入交换结构。在进入交换结构时,如果交换结构正在被使用,就会阻塞新到的分组,等到交换结构调度新的分组。
交换结构
交换结构是路由器的核心功能,通过交换功能把分组从输入端口转发至输出端口,这就是交换结构的主要功能。交换结构有多种形式,主要分为 通过内存交换、通过总线交换、通过互联网络进行交换,下面我们分开来探讨一下。
- 经过内存交换:最开始的传统计算机就是使用
内存交换
的,在输入端口和输出端口之间是通过 CPU 进行的。输入端口和输出端口的功能就好像传统操作系统中的 I/O 设备一样。当一个分组到达输入端口时,这个端口会首先以中断
的方式向路由选择器发出信号,将分组从输入端口拷贝到内存中。然后,路由选择处理器从分组首部中提取目标地址,在转发表中找出适当的输出端口进行转发,同时将分组复制到输出端口的缓存中。
这里需要注意一点,如果内存带宽以每秒读取或者写入 B 个数据包,那么总的交换机吞吐量(数据包从输入端口到输出端口的总速率) 必须小于 B/2。
- 经过总线交换:在这种处理方式中,总线经由输入端口直接将分组传送到输出端口,中间不需要路由选择器的干预。总线的工作流程如下:输入端口给分组分配一个
标签
,然后分组经由总线发送给所有的输出端口,每个输出端口都会判断标签中的端口和自己的是否匹配,如果匹配的话,那么这个输出端口就会把标签拆掉,这个标签只用于交换机内部跨越总线。如果同时有多个
分组到达路由器的话,那么只有一个分组能够被处理,其他分组需要再进入交换结构前等待。
- 经过互联网络交换:克服单一、共享式总线带宽限制的一种方法是使用一个更复杂的互联网络。如下图所示
每条垂直的的总线在交叉点与每条水平的总线交叉,交叉点通过交换结构控制器能够在任何时候开启和闭合。当分组到达输入端口 A 时,如果需要转发到端口 X,交换机控制器会闭合 A 到 X 交叉部分的交叉点,然后端口 A 在总线上进行分组转发。这种网络互联式的交换结构是 非阻塞的(non-blocking)
的,也就是说 A -> X 的交叉点闭合不会影响 B -> Y 的链路。如果来自两个不同输入端口的两个分组其目的地为相同的输出端口的话,这种情况下只能有一个分组被交换,另外一个分组必须进行等待。
输出端口处理
如下图所示,输出端口处理取出已经存放在输出端口内存中的分组并将其发送到输出链路上。包括选择和去除排队的分组进行传输,执行所需的链路层和物理层的功能。
在输入端口中有等待进入交换的排队队列,而在输出端口中有等待转发的排队队列,排队的位置和程度取决于流量负载、交换结构的相对频率和线路速率。
随着队列的不断增加,会导致路由器的缓存空间被耗尽,进而使没有内存可以存储溢出的队列,致使分组出现丢包(packet loss)
,这就是我们说的在网络中丢包或者被路由器丢弃。
何时出现排队
下面我们通过输入端口的排队队列和输出端口的排队队列来介绍一下可能出现的排队情况。
输入队列
如果交换结构的处理速度没有输入队列到达的速度快,在这种情况下,输入端口将会出现排队情况,到达交换结构前的分组会加入输入端口队列中,以等待通过交换结构传送到输出端口。
为了描述清楚输入队列,我们假设以下情况:
- 使用网络互联的交换方式;
- 假定所有链路的速度相同;
- 在链路中一个分组由输入端口交换到输出端口所花的时间相同,从任意一个输入端口传送到给定的输出端口;
- 分组按照 FCFS 的方式,只要输出端口不同,就可以进行并行传送。但是如果位于任意两个输入端口中的分组是发往同一个目的地的,那么其中的一个分组将被阻塞,而且必须在输入队列中等待,因为交换结构一次只能传输一个到指定端口。
如下图所示
在 A 队列中,输入队列中的两个分组会发送至同一个目的地 X,假设在交换结构正要发送 A 中的分组,在这个时候,C 队列中也有一个分组发送至 X,在这种情况下,C 中发送至 X 的分组将会等待,不仅如此,C 队列中发送至 Y 输出端口的分组也会等待,即使 Y 中没有出现竞争的情况。这种现象叫做 线路前部阻塞(Head-Of-The-Line, HOL)
。
输出队列
我们下面讨论输出队列中出现等待的情况。假设交换速率要比输入/输出的传输速率快很多,而且有 N 个输入分组的目的地是转发至相同的输出端口。在这种情况下,在向输出链路发送分组的过程中,将会有 N 个新分组到达传输端口。因为输出端口在一个单位时间内只能传输一个分组,那么这 N 个分组将会等待。然而在等待 N 个分组被处理的过程中,同时又有 N 个分组到达,所以 ,分组队列能够在输出端口形成。这种情况下最终会因为分组数量变的足够大,从而耗尽
输出端口的可用内存。
如果没有足够的内存来缓存分组的话,就必须考虑其他的方式,主要有两种:一种是丢失分组,采用 弃尾(drop-tail)
的方法;一种是删除一个或多个已经排队的分组,从而来为新的分组腾出空间。
网络层的策略对 TCP 拥塞控制影响很大的就是路由器的分组丢弃策略。在最简单的情况下,路由器的队列通常都是按照 FCFS 的规则处理到来的分组。由于队列长度总是有限的,因此当队列已经满了的时候,以后再到达的所有分组(如果能够继续排队,这些分组都将排在队列的尾部)将都被丢弃。这就叫做尾部丢弃策略。
通常情况下,在缓冲填满之前将其丢弃是更好的策略。
如上图所示,A B C 每个输入端口都到达了一个分组,而且这个分组都是发往 X 的,同一时间只能处理一个分组,然后这时,又有两个分组分别由 A B 发往 X,所以此时有 4 个分组在 X 中进行等待。
等上一个分组被转发完成后,输出端口就会选择在剩下的分组中根据 分组调度(packet scheduleer)
选择一个分组来进行传输,我们下面就会聊到分组传输。
分组调度
现在我们来讨论一下分组调度次序的问题,即排队的分组如何经输出链路传输的问题。我们生活中有无数排队的例子,但是我们生活中一般的排队算法都是 先来先服务(FCFS)
,也是先进先出(FIFO)
。
先进先出
先进先出就映射为数据结构中的队列
,只不过它现在是链路调度规则的排队模型。
FIFO 调度规则按照分组到达输出链路队列的相同次序来选择分组,先到达队列的分组将先会被转发。在这种抽象模型中,如果队列已满,那么弃尾的分组将是队列末尾的后面一个。
优先级排队
优先级排队是先进先出排队的改良版本,到达输出链路的分组被分类放入输出队列中的优先权类,如下图所示
通常情况下,每个优先级不同的分组有自己的优先级类,每个优先级类有自己的队列,分组传输会首先从优先级高的队列中进行,在同一类优先级的分组之间的选择通常是以 FIFO 的方式完成。
循环加权公平排队
在循环加权公平规则(round robin queuing discipline)
下,分组像使用优先级那样被分类。然而,在类之间却不存在严格的服务优先权。循环调度器在这些类之间循环轮流提供服务。如下图所示
在循环加权公平排队中,类 1 的分组被传输,接着是类 2 的分组,最后是类 3 的分组,这算是一个循环,然后接下来又重新开始,又从 1 -> 2 -> 3 这个顺序进行轮询。每个队列也是一个先入先出的队列。
这是一种所谓的保持工作排队(work-conserving queuing)
的规则,就是说如果轮询的过程中发现有空队列,输出端口不会等待分组,而是继续轮询下面的队列。
IP 协议
路由器对分组进行转发后,就会把数据包传到网络上,数据包最终是要传递到客户端或者服务器上的,那么数据包怎么知道要发往哪里呢?起到关键作用的就是 IP 协议。
IP 主要分为三个部分,分别是 IP 寻址、路由和分包组包。下面我们主要围绕这三点进行阐述。
IP 地址
既然一个数据包要在网络上传输,那么肯定需要知道这个数据包到底发往哪里,也就是说需要一个目标地址信息,IP 地址就是连接网络中的所有主机进行通信的目标地址,因此,在网络上的每个主机都需要有自己的 IP 地址。
在 IP 数据报发送的链路中,有可能链路非常长,比如说由中国发往美国的一个数据报,由于网络抖动等一些意外因素可能会导致数据报丢失,这时我们在这条链路中会放入一些 中转站
,一方面能够确保数据报是否丢失,另一方面能够控制数据报的转发,这个中转站就是我们前面聊过的路由器,这个转发过程就是 路由控制
。
路由控制(Routing)
是指将分组数据发送到最终目标地址的功能,即使网络复杂多变,也能够通过路由控制到达目标地址。因此,一个数据报能否到达目标主机,关键就在于路由器的控制。
这里有一个名词,就是 跳
,因为在一条链路中可能会布满很多路由器,路由器和路由器之间的数据报传送就是跳,比如你和隔壁老王通信,中间就可能会经过路由器 A-> 路由器 B -> 路由器 C 。
那么一跳的范围有多大呢?
一跳是指从源 MAC 地址到目标 MAC 地址之间传输帧的区间,这里引出一个新的名词,MAC 地址是啥?
MAC 地址指的就是计算机的物理地址(Physical Address)
,它是用来确认网络设备位置的地址。在 OSI 网络模型中,网络层负责 IP 地址的定位,而数据链路层负责 MAC 地址的定位。MAC 地址用于在网络中唯一标示一个网卡,一台设备若有一或多个网卡,则每个网卡都需要并会有一个唯一的 MAC 地址,也就是说 MAC 地址和网卡是紧密联系在一起的。
路由器的每一跳都需要询问当前中转的路由器,下一跳应该跳到哪里,从而跳转到目标地址。而不是数据报刚开始发送后,网络中所有的通路都会显示出来,这种多次跳转也叫做多跳路由
。
IP 地址定义
现如今有两个版本的 IP 地址,IPv4 和 IPv6,我们首先探讨一下现如今还在广泛使用的 IPv4 地址,后面再考虑 IPv6 。
IPv4 由 32 位正整数来表示,在计算机内部会转化为二进制来处理,但是二进制不符合人类阅读的习惯,所以我们根据易读性
的原则把 32 位的 IP 地址以 8 位为一组,分成四组,每组之间以 .
进行分割,再将每组转换为十进制数。如下图所示
那么上面这个 32 位的 IP 地址就会被转换为十进制的 156.197.1.1。
除此之外,从图中我们还可以得到如下信息
每个这样 8 位位一组的数字,自然是非负数,其取值范围是 [0,255]。
IP 地址的总个数有 2^32 次幂个,这个数值算下来是 4294967296
,大概能允许 43 亿台设备连接到网络。实际上真的如此吗?
实际上 IP 不会以主机的个数来配置的,而是根据设备上的 网卡(NIC)
进行配置,每一块网卡都会设置一个或者多个 IP 地址,而且通常一台路由器会有至少两块网卡,所以可以设置两个以上的 IP 地址,所以主机的数量远远达不到 43 亿。
IP 地址构造和分类
IP 地址由 网络标识
和 主机标识
两部分组成,网络标识代表着网络地址,主机标识代表着主机地址。网络标识在数据链路的每个段配置不同的值。网络标识必须保证相互连接的每个段的地址都不重复。而相同段内相连的主机必须有相同的网络地址。IP 地址的 主机标识
则不允许在同一网段内重复出现。
举个例子来说:比如说我在石家庄(好像不用比如昂),我所在的小区的某一栋楼就相当于是网络标识,某一栋楼的第几户就相当于是我的主机标识,当然如果你有整栋楼的话,那就当我没说。你可以通过xx省xx市xx区xx路xx小区xx栋来定位我的网络标识,这一栋的第几户就相当于是我的网络标识。
IP 地址分为四类,分别是 A类、B类、C类、D类、E类,它会根据 IP 地址中的第 1 位到第 4 位的比特对网络标识和主机标识进行分类。
-
A 类
:(1.0.0.0 – 126.0.0.0)(默认子网掩码:255.0.0.0 或 0xFF000000)第一个字节为网络号,后三个字节为主机号。该类 IP 地址的最前面为 0 ,所以地址的网络号取值于 1~126 之间。一般用于大型网络。 -
B 类
:(128.0.0.0 – 191.255.0.0)(默认子网掩码:255.255.0.0 或 0xFFFF0000)前两个字节为网络号,后两个字节为主机号。该类 IP 地址的最前面为 10 ,所以地址的网络号取值于 128~191 之间。一般用于中等规模网络。 -
C 类
:(192.0.0.0 – 223.255.255.0)(子网掩码:255.255.255.0 或 0xFFFFFF00)前三个字节为网络号,最后一个字节为主机号。该类 IP 地址的最前面为 110 ,所以地址的网络号取值于 192~223 之间。一般用于小型网络。 -
D 类
:是多播地址。该类 IP 地址的最前面为 1110 ,所以地址的网络号取值于 224~239 之间。一般用于多路广播用户。 -
E 类
:是保留地址。该类 IP 地址的最前面为 1111 ,所以地址的网络号取值于 240~255 之间。
为了方便理解,我画了一张 IP 地址分类图,如下所示
根据不同的 IP 范围,有下面不同的地总空间分类
子网掩码
子网掩码(subnet mask)
又叫做网络掩码,它是一种用来指明一个 IP 地址的哪些位标识的是主机所在的网络。子网掩码是一个 32位 地址,用于屏蔽 IP 地址的一部分以区别网络标识和主机标识。
一个 IP 地址只要确定了其分类,也就确定了它的网络标识和主机标识,由此,各个分类所表示的网络标识范围如下
用 1
表示 IP 网络地址的比特范围,0
表示 IP 主机地址的范围。将他们用十进制表示,那么这三类的表示如下
保留地址
在IPv4 的几类地址中,有几个保留的地址空间不能在互联网上使用。这些地址用于特殊目的,不能在局域网外部路由。
IP 协议版本
目前,全球 Internet 中共存有两个IP版本:IP 版本 4(IPv4)
和 IP 版本6(IPv6)
。 IP 地址由二进制值组成,可驱动 Internet 上所有数据的路由。 IPv4 地址的长度为 32 位,而 IPv6 地址的长度为 128 位。
Internet IP 资源由 Internet 分配号码机构(IANA)
分配给区域 Internet 注册表(RIR),例如 APNIC,该机构负责根 DNS ,IP 寻址和其他 Internet 协议资源。
下面我们就一起认识一下 IP 协议中非常重要的两个版本 IPv4 和 IPv6。
IPv4
IPv4 的全称是 Internet Protocol version 4
,是 Internet 协议的第四版。IPv4 是一种无连接的协议,这个协议会尽最大努力交付数据包,也就是说它不能保证任何数据包能到达目的地,也不能保证所有的数据包都会按照正确的顺序到达目标主机,这些都是由上层比如传输控制协议控制的。也就是说,单从 IP 看来,这是一个不可靠的协议。
前面我们讲过网络层分组被称为
数据报
,所以我们接下来的叙述也会围绕着数据报展开。
IPv4 的数据报格式如下
IPv4 数据报中的关键字及其解释
版本字段(Version)
占用 4 bit,通信双方使用的版本必须一致,对于 IPv4 版本来说,字段值是 4。首部长度(Internet Header Length)
占用 4 bit,首部长度说明首部有多少 32 位(4 字节)。由于 IPv4 首部可能包含不确定的选项,因此这个字段被用来确定数据的偏移量。大多数 IP 不包含这个选项,所以一般首部长度设置为 5, 数据报为 20 字节 。服务类型(Differential Services Codepoint,DSCP)
占用 6 bit,以便使用不同的 IP 数据报,比如一些低时延、高吞吐量和可靠性的数据报。服务类型如下表所示
-
拥塞通告(Explicit Congestion Notification,ECN)
占用 2 bit,它允许在不丢弃报文的同时通知对方网络拥塞的发生。ECN 是一种可选的功能,仅当两端都支持并希望使用,且底层网络支持时才被使用。 最开始 DSCP 和 ECN 统称为 TOS,也就是区分服务,但是后来被细化为了 DSCP 和 ECN。 -
数据报长度(Total Length)
占用 16 bit,这 16 位是包括在数据在内的总长度,理论上数据报的总长度为 2 的 16 次幂 – 1,最大长度是 65535 字节,但是实际上数据报很少有超过 1500 字节的。IP 规定所有主机都必须支持最小 576 字节的报文,但大多数现代主机支持更大的报文。当下层的数据链路协议的最大传输单元(MTU)
字段的值小于 IP 报文长度时,报文就必须被分片。 -
标识符(Identification)
占用 16 bit,这个字段用来标识所有的分片,因为分片不一定会按序到达,所以到达目标主机的所有分片会进行重组,每产生一个数据报,计数器加1,并赋值给此字段。 -
标志(Flags)
占用 3 bit,标志用于控制和识别分片,这 3 位分别是- 0 位:保留,必须为0;
- 1 位:
禁止分片(Don’t Fragment,DF)
,当 DF = 0 时才允许分片; - 2 位:
更多分片(More Fragment,MF)
,MF = 1 代表后面还有分片,MF = 0 代表已经是最后一个分片。
如果 DF 标志被设置为 1 ,但是路由要求必须进行分片,那么这条数据报回丢弃
-
分片偏移(Fragment Offset)
占用 13 位,它指明了每个分片相对于原始报文开头的偏移量,以 8 字节作单位。 -
存活时间(Time To Live,TTL)
占用 8 位,存活时间避免报文在互联网中迷失
,比如陷入路由环路。存活时间以秒为单位,但小于一秒的时间均向上取整到一秒。在现实中,这实际上成了一个跳数计数器:报文经过的每个路由器都将此字段减 1,当此字段等于 0 时,报文不再向下一跳传送并被丢弃,这个字段最大值是 255。 -
协议(Protocol)
占用 8 位,这个字段定义了报文数据区使用的协议。协议内容可以在 https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml 官网上获取。 -
首部校验和(Header Checksum)
占用 16 位,首部校验和会对字段进行纠错检查,在每一跳中,路由器都要重新计算出的首部检验和并与此字段进行比对,如果不一致,此报文将会被丢弃。 -
源地址(Source address)
占用 32 位,它是 IPv4 地址的构成条件,源地址指的是数据报的发送方 -
目的地址(Destination address)
占用 32 位,它是 IPv4 地址的构成条件,目标地址指的是数据报的接收方 -
选项(Options)
是附加字段,选项字段占用 1 – 40 个字节不等,一般会跟在目的地址之后。如果首部长度 > 5,就应该考虑选项字段。 -
数据
不是首部的一部分,因此并不被包含在首部检验和中。
在 IP 发送的过程中,每个数据报的大小是不同的,每个链路层协议能承载的网络层分组也不一样,有的协议能够承载大数据报,有的却只能承载很小的数据报,不同的链路层能够承载的数据报大小如下。
IPv4 分片
一个链路层帧能承载的最大数据量叫做最大传输单元(Maximum Transmission Unit, MTU)
,每个 IP 数据报封装在链路层帧中从一台路由器传到下一台路由器。因为每个链路层所支持的最大 MTU 不一样,当数据报的大小超过 MTU 后,会在链路层进行分片,每个数据报会在链路层单独封装,每个较小的片都被称为 片(fragement)
。
每个片在到达目的地后会进行重组,准确的来说是在运输层之前会进行重组,TCP 和 UDP 都会希望发送完整的、未分片的报文,出于性能的原因,分片重组不会在路由器中进行,而是会在目标主机中进行重组。
当目标主机收到从发送端发送过来的数据报后,它需要确定这些数据报中的分片是否是由源数据报分片传递过来的,如果是的话,还需要确定何时收到了分片中的最后一片
,并且这些片会如何拼接一起成为数据报。
针对这些潜在的问题,IPv4 设计者将 标识、标志和片偏移放在 IP 数据报首部中。当生成一个数据报时,发送主机会为该数据报设置源和目的地址的同时贴上标识号
。发送主机通常将它发送的每个数据报的标识 + 1。当某路由器需要对一个数据报分片时,形成的每个数据报具有初始数据报的源地址、目标地址和标识号。当目的地从同一发送主机收到一系列数据报时,它能够检查数据报的标识号以确定哪些数据是由源数据报发送过来的。由于 IP 是一种不可靠的服务,分片可能会在网路中丢失,鉴于这种情况,通常会把分片的最后一个比特设置为 0 ,其他分片设置为 1,同时使用偏移字段指定分片应该在数据报的哪个位置。
IPv4 寻址
IPv4 支持三种不同类型的寻址模式,分别是
- 单播寻址模式:在这种模式下,数据只发送到一个目的地的主机。
- 广播寻址模式:在此模式下,数据包将被寻址到网段中的所有主机。这里客户端发送一个数据包,由所有服务器接收:
- 组播寻址模式:此模式是前两种模式的混合,即发送的数据包既不指向单个主机也不指定段上的所有主机
IPv6
随着端系统接入的越来越多,IPv4 已经无法满足分配了,所以,IPv6 应运而生,IPv6 就是为了解决 IPv4 的地址耗尽问题而被标准化的网际协议。IPv4 的地址长度为 4 个 8 字节,即 32 比特, 而 IPv6 的地址长度是原来的四倍,也就是 128 比特,一般写成 8 个 16 位字节。
从 IPv4 切换到 IPv6 及其耗时,需要将网络中所有的主机和路由器的 IP 地址进行设置,在互联网不断普及的今天,替换所有的 IP 是一个工作量及其庞大的任务。我们后面会说。
我们先来看一下 IPv6 的地址是怎样的
版本
与 IPv4 一样,版本号由 4 bit 构成,IPv6 版本号的值为 6。流量类型(Traffic Class)
占用 8 bit,它就相当于 IPv4 中的服务类型(Type Of Service)。流标签(Flow Label)
占用 20 bit,这 20 比特用于标识一条数据报的流,能够对一条流中的某些数据报给出优先权,或者它能够用来对来自某些应用的数据报给出更高的优先权,只有流标签、源地址和目标地址一致时,才会被认为是一个流。有效载荷长度(Payload Length)
占用 16 bit,这 16 比特值作为一个无符号整数,它给出了在 IPv6 数据报中跟在鼎昌 40 字节数据报首部后面的字节数量。下一个首部(Next Header)
占用 8 bit,它用于标识数据报中的内容需要交付给哪个协议,是 TCP 协议还是 UDP 协议。跳限制(Hop Limit)
占用 8 bit,这个字段与 IPv4 的 TTL 意思相同。数据每经过一次路由就会减 1,减到 0 则会丢弃数据。源地址(Source Address)
占用 128 bit (8 个 16 位 ),表示发送端的 IP 地址。目标地址(Destination Address)
占用 128 bit (8 个 16 位 ),表示接收端 IP 地址。
可以看到,相较于 IPv4 ,IPv6 取消了下面几个字段
- 标识符、标志和比特偏移:IPv6 不允许在中间路由器上进行分片和重新组装。这种操作只能在端系统上进行,IPv6 将这个功能放在端系统中,加快了网络中的转发速度。
- 首部校验和:因为在运输层和数据链路执行了报文段完整性校验工作,IP 设计者大概觉得在网络层中有首部校验和比较多余,所以去掉了。IP 更多专注的是快速处理分组数据。
- 选项字段:选项字段不再是标准 IP 首部的一部分了,但是它并没有消失,而是可能出现在 IPv6 的扩展首部,也就是下一个首部中。
IPv6 扩展首部
IPv6 首部长度固定,无法将选项字段加入其中,取而代之的是 IPv6 使用了扩展首部
扩展首部通常介于 IPv6 首部与 TCP/UDP 首部之间,在 IPv4 中可选长度固定为 40 字节,在 IPv6 中没有这样的限制。IPv6 的扩展首部可以是任意长度。扩展首部中还可以包含扩展首部协议和下一个扩展字段。
IPv6 首部中没有标识和标志字段,对 IP 进行分片时,需要使用到扩展首部。
具体的扩展首部表如下所示
下面我们来看一下 IPv6 都有哪些特点
IPv6 特点
IPv6 的特点在 IPv4 中得以实现,但是即便实现了 IPv4 的操作系统,也未必实现了 IPv4 的所有功能。而 IPv6 却将这些功能大众化了,也就表明这些功能在 IPv6 已经进行了实现,这些功能主要有
-
地址空间变得更大:这是 IPv6 最主要的一个特点,即支持更大的地址空间。
-
精简报文结构: IPv6 要比 IPv4 精简很多,IPv4 的报文长度不固定,而且有一个不断变化的选项字段;IPv6 报文段固定,并且将选项字段,分片的字段移到了 IPv6 扩展头中,这就极大的精简了 IPv6 的报文结构。
-
实现了自动配置:IPv6 支持其主机设备的状态和无状态自动配置模式。这样,没有
DHCP 服务器
不会停止跨段通信。 -
层次化的网络结构: IPv6 不再像 IPv4 一样按照 A、B、C等分类来划分地址,而是通过 IANA -> RIR -> ISP 这样的顺序来分配的。IANA 是国际互联网号码分配机构,RIR 是区域互联网注册管理机构,ISP 是一些运营商(例如电信、移动、联通)。
-
IPSec:IPv6 的扩展报头中有一个认证报头、封装安全净载报头,这两个报头是 IPsec 定义的。通过这两个报头网络层自己就可以实现端到端的安全,而无需像 IPv4 协议一样需要其他协议的帮助。
-
支持任播:IPv6 引入了一种新的寻址方式,称为任播寻址。
IPv6 地址
我们知道,IPv6 地址长度为 128 位,他所能表示的范围是 2 ^ 128 次幂,这个数字非常庞大,几乎涵盖了你能想到的所有主机和路由器,那么 IPv6 该如何表示呢?
一般我们将 128 比特的 IP 地址以每 16 比特为一组,并用 :
号进行分隔,如果出现连续的 0 时还可以将 0 省略,并用 ::
两个冒号隔开,记住,一个 IP 地址只允许出现一次两个连续的冒号。
下面是一些 IPv6 地址的示例
- 二进制数表示
- 用十六进制数表示
- 出现两个冒号的情况
如上图所示,A120 和 4CD 中间的 0 被 :: 所取代了。
如何从 IPv4 迁移到 IPv6
我们上面聊了聊 IPv4 和 IPv6 的报文格式、报文含义是什么、以及 IPv4 和 IPv6 的特征分别是什么,看完上面的内容,你已经知道了 IPv4 现在马上就变的不够用了,而且随着 IPv6 的不断发展和引用,虽然新型的 IPv6 可以做到向后兼容
,即 IPv6 可以收发 IPv4 的数据报,但是已经部署的具有 IPv4 能力的系统却不能够处理 IPv6 数据报。所以 IPv4 噬需迁移到 IPv6,迁移并不意味着将 IPv4 替换为 IPv6。这仅意味着同时启用 IPv6 和 IPv4。
那么现在就有一个问题了,IPv4 如何迁移到 IPv6 呢?这就是我们接下来讨论的重点。
标志
最简单的方式就是设置一个标志日,指定某个时间点和日期,此时全球的因特网机器都会在这时关机从 IPv4 迁移到 IPv6 。上一次重大的技术迁移是在 35 年前,但是很显然,不用我过多解释,这种情况肯定是 不行的
。影响不可估量不说,如何保证全球人类都能知道如何设置自己的 IPv6 地址?一个设计数十亿台机器的标志日现在是想都不敢想的。
隧道技术
现在已经在实践中使用的从 IPv4 迁移到 IPv6 的方法是 隧道技术(tunneling)
。
什么是隧道技术呢?
隧道技术是一种使用互联网络的基础设施在网络之间的传输数据的方式,使用隧道传递的数据可以是不同协议的数据帧或包。使用隧道技术所遵从的协议叫做隧道协议(tunneling protocol)
。隧道协议会将这些协议的数据帧或包封装在新的包头中发送。新的包头提供了路由信息,从而使封装的负载数据能够通过互联网络进行传递。
使用隧道技术一般都会建一个隧道
,建隧道的依据如下:
比如两个 IPv6 节点(下方 B、E)要使用 IPv6 数据报进行交互,但是它们是经由两个 IPv4 的路由器进行互联的。那么我们就需要将 IPv6 节点和 IPv4 路由器组成一个隧道,如下图所示
借助于隧道,在隧道发送端的 IPv6 节点可将整个 IPv6 数据报放到一个 IPv4 数据报的数据(有效载荷)
字段中,于是,IPv4 数据报的地址被设置为指向隧道接收端的 IPv6 的节点,比如上面的 E 节点。然后再发送给隧道中的第一个节点 C,如下所示
隧道中间的 IPv4 提供路由,路由器不知道这个 IPv4 内部包含一个指向 IPv6 的地址。隧道接收端的 IPv6 节点收到 IPv4 数据报,会确定这个 IPv4 数据报含有一个 IPv6 数据报,通过观察数据报长度和数据得知。然后取出 IPv6 数据报,再为 IPv6 提供路由,就好像两个节点直接相连传输数据报一样。
总结
这篇文章是计算机网络系列的连载文章,这篇我们主要探讨了网络层的相关知识、路由器的内部构造、路由器如何实现转发的,IP 协议相关内容:包括 IP 地址、IPv4 和 IPv6 的相关内容,最后我们探讨了如何使 IPv4 迁移到 IPv6 。
计算机网络TCP|IP基础
要说我们接触计算机网络最多的协议,那势必离不开 TCP/IP 协议了,TCP/IP 协议同时也是互联网中最为著名的协议,下面我们就来一起聊一下 TCP/IP 协议。
TCP/IP 的历史背景
最初还没有 TCP/IP 协议的时候,也就是在 20 世纪 60 年代,许多国家和地区认识到通信技术的重要性。美国国防部希望能够研究一种即使通信线路被破坏也能够通过其他路线进行通信的技术。为了实现这种技术,出现了分组
网络。
即使在两个节点通信的过程中,几个节点遭到破坏,却依然能够通过改变线路等方式达使两个节点之间进行通信。
这种分组网络促进了 ARPANET(Advanced Research Projects Agency Network)
的诞生。ARPANET 是第一个具有分布式控制的广域包分t组交换网络,也是最早实现 TCP/IP 协议的前身。
ARPANET 其实是由 美国国防部高级研究计划局 计划建立。
所以,计算机网络的出现在最一开始是因为军事研究目的。
20 世纪 90 年代,IOS 开展了 OSI 这一国际标准化的进程,然而却没有取得实质性的进展,但是却使 TCP/IP 协议得到了广泛使用。
这种致使 TCP/IP 协议快速发展的原因可能是由于 TCP/IP 的标准化。也就是说 TCP/IP 协议中会涉及到 OSI 所没有的标准,而这种标准将是我们接下来主要探讨的内容。
这里我们先来认识一下 TCP/IP 协议,TCP/IP 协议说的不仅仅只是 TCP 和 IP 这两种协议,实际上,TCP/IP 指的是协议簇,协议簇是啥呢?简单来说就是一系列协议的综合,如果下次再问你 TCP/IP 协议有哪些的话,可以把下面这张图甩给他
以上的协议汇总起来,就是 TCP/IP 协议簇。
TCP/IP 标准
TCP/IP 相较于其他协议的标准,更注重两点:开放性
和 实用性
,即标准化能否被实际使用。
开放性说的是 TCP/IP 是由 IETF
讨论制定的,而 IETF 本身就是一个允许任何人加入进行讨论的组织。
实用性说的是就拿框架来说,如果只浮于理论,而没有落地的实践,那么永远成为不了主流。
TCP/IP 的标准协议就是我们所熟知的 RFC 文档
,当然你可以在网络上看到。RFC 不仅规范了协议标准,还包含了协议的实现和使用信息。
关于更多 RFC 协议,你可以看一下官方文档 https://www.rfc-editor.org/rfc-index.html
这里我们不再详细展开介绍了,我们这篇文章的重点要放在对 TCP/IP 的研究上。
TCP/IP 协议簇
下面我们就开始聊一聊 TCP/IP 协议簇。
TCP/IP 协议是我们程序员接触最多的协议,OSI 模型共有七层,从下到上分别是物理层、数据链路层、网络层、运输层、会话层、表示层和应用层。但是这显然是有些复杂的,所以在 TCP/IP 协议中,它们被简化为了四个层次
下面我们从通信链路层开始介绍一下这些层以及与层之间的协议。
通信链路层
如果非要细分的话,通信链路层也可以分为 物理层
和 数据链路层
。
物理层
物理层是 TCP/IP 的最底层是负责传输的硬件,这种硬件就相当于是以太网或电话线路等物理层的设备。
数据链路层
另外一层是数据链路层,数据链路层位于物理层和网络层中间,数据链路层定义了在单个链路上如何传输数据。
网络层
网络层主要使用 IP
协议,IP 协议基于 IP 地址转发分包数据。
IP 协议的主要作用就是将分组数据包发送到目标主机
TCP/IP 分层中的互联网层与传输层的功能通常由操作系统提供。
IP 还隐含着数据链路层的功能,通过 IP 协议,相互通信的主机之间不论经过怎样的底层数据链路,都能够实现相互通信。
虽然 IP 也是一种分组交换协议,但是 IP 却不具备重发机制。即使数据没有到达另一端也不会进行重发,所以 IP 属于非可靠性协议。
网络层还有一种协议就是 ICMP
,因为 IP 在数据包的发送过程中可能会出现异常,当 IP 数据包因为异常而无法到达目标地址时,需要给发送端发送一个异常通知,ICMP 的主要功能就在于此了。鉴于此情况,ICMP 也可以被用来诊断网络情况。
传输层
我们上面刚介绍完 TCP/IP 协议最重要的 IP 协议后,下面我们来介绍一下传输层协议,TCP 协议时传输层协议的一种。
传输层就好像高速公路一样,连接两个城市的道路。下面是互联网的逻辑通道,你可以把它想象成为高速公路。
传输层最主要的功能就是让应用层的应用程序之间完成通信和数据交换。在计算机内部运行着很多应用程序,每个应用程序都对应一个端口号,我们一般使用端口号来区分这些应用程序。
传输层的协议主要分为面向有连接的协议 TCP 和面向无连接的协议 UDP
**TCP
TCP 是一种可靠的协议,它能够保证数据包的可靠性交付,TCP 能够正确处理传输过程中的丢包、传输顺序错乱等异常情况。此外,TCP 还提供拥塞控制用于缓解网络拥堵。
**UDP
UDP 是一种不可靠的协议,它无法保证数据的可靠交付,相比 TCP ,UDP 不会检查数据包是否到达、网络是否阻塞等情况,但是 UDP 的效率比较高。
UDP 常用于分组数据较少或者广播、多播等视频通信和多媒体领域。
应用层
在 TCP/IP 协议簇中,将 OSI 标准模型中的会话层、表示层都归为了应用层。应用层的架构大多属于客户端/服务端模型,提供服务的程序叫做服务端、接受服务的程序叫做客户端。在这种架构中,服务端通常会提前部署到服务器上,等待客户端的连接,从而提供服务。
数据包的发送历程
下面我们来介绍一下一个数据包是如何经过应用层、运输层、网络层和通信链路层进行传输的。
数据包结构
我们首先先来认识一下数据包的结构,这里 cxuan 只是给你简单介绍一下,后面的文章会更加详细的介绍。
在上面的每个分层中,都会对所发送的数据增加一个 首部
,这个首部中包含了该层必要的信息。每一层都会对数据进行处理并在数据包中附上这一层的必要信息。下面我们就来聊一聊数据包的发送过程。
数据包发送历程
假设主机 A 和主机 B 进行通信,主机 A 想要向主机 B 发送一个数据包,都会经历哪些奇特的操作?
**应用层的处理
主机 A 也就是用户点击了某个应用或者打开了一个聊天窗口输入了cxuan
,然后点击了发送,那么这个 cxuan 就作为一个数据包遨游在了网络中,等下还没完呢,应用层还需要对这个数据包进行处理,包括字符编码、格式化等等,这一层其实是 OSI 中表现层做的工作,只不过在 TCP/IP 协议中都归为了应用层。
数据包在发送的那一刻建立 TCP 连接,这个连接相当于通道,在这之后其他数据包也会使用通道传输数据。
**传输层的处理
为了描述信息能准确的到达另一方,我们使用 TCP 协议来进行描述。TCP 会根据应用的指示,负责建立连接、发送数据和断开连接。
TCP 会在应用数据层的前端附加一个 TCP 首部字段,TCP 首部包含了源端口号
和 目的端口号
,这两个端口号用于表明数据包是从哪里发出的,需要发送到哪个应用程序上;TCP 首部还包含序号
,用以表示该包中数据是发送端整个数据中第几个字节的序列号;TCP 首部还包含 校验和
,用于判断数据是否损坏,随后将 TCP 头部附加在数据包的首部发送给 IP。
**网络层的处理
网络层主要负责处理数据包的是 IP 协议,IP 协议将 TCP 传过来的 TCP 首部和数据结合当作自己的数据,并在 TCP 首部的前端加上自己的 IP 首部。因此,IP 数据包后面会紧跟着 TCP 数据包,后面才是数据本身。IP 首部包含目的和源地址,紧随在 IP 首部的还有用来判断后面是 TCP 还是 UDP 的信息。
IP 包生成后,会由路由控制表判断应该发送至哪个主机,IP 修饰后的数据包继续向下发送给路由器或者网络接口的驱动程序,从而实现真正的数据传输。
如果不知道目标主机的 IP 地址,可以利用
ARP(Address Resolution Protocol)
地址解析协议进行查找。
**通信链路层的处理
经由 IP 传过来的数据包,以太网会给数据附上以太网首部并进行发送处理。以太网首部包含接收端的 MAC 地址、发送端的 MAC 地址以及标志以太网类型的以太网数据协议等。
下面是完整的处理过程和解析过程。
如上图所示,左侧是数据的发送处理过程,应用层的数据经过层层处理后会变为可以发送的数据包,经过物理介质发送至指定主机中。
数据包的接收流程是发送流程的逆序过程,数据包的解析同样也会经过下面这几步。
**通信链路的解析
目标主机收到数据包后,首先会从以太网的首部找到 MAC 地址判断是否是发给自己的数据包,如果不是发给自己的数据包则会丢弃该数据包。
如果收到的数据包是发送给自己的,就会查找以太网类型判断是哪种协议,如果是 IP 协议就会扔给 IP 协议进行处理,如果是 ARP
协议就会扔给 ARP 协议进行处理。如果协议类型是一种无法识别的协议,就会将该数据包直接丢弃。
**网络层的解析
经过以太网处理后的数据包扔给网络层进行处理,我们假设协议类型是 IP 协议,那么,在 IP 收到数据包后就会解析 IP 首部,判断 IP 首部中的 IP 地址是否和自己的 IP 地址匹配,如果匹配则接收数据并判断上一层协议是 TCP 还是 UDP;如果不匹配则直接丢弃。
注意:在路由转发的过程中,有的时候 IP 地址并不是自己的,这个时候需要借助路由表协助处理。
**传输层的处理
在传输层中,我们默认使用 TCP 协议,在 TCP 处理过程中,首先会计算一下 校验和
,判断数据是否被损坏。然后检查是否按照序号接收数据,最后检查端口号,确定具体是哪个应用程序。
数据被完整的识别后,会传递给由端口号识别的应用程序进行处理。
**应用程序的处理
接收端指定的应用程序会处理发送方传递过来的数据,通过解码等操作识别出数据的内容,然后把对应的数据存储在磁盘上,返回一个保存成功的消息给发送方,如果保存失败,则返回错误消息。
上面是一个完整的数据包收发过程,在上面的数据收发过程中,涉及到不同层之间的地址、端口号、协议类型等,那么我们现在就来剖析一下。
数据包经过每层后,该层协议都会在数据包附上包首部,一个完整的包首部图如下所示
在数据包的发送过程中,各层以此对数据包添加了首部信息,每个首部都包含发送端和接收端地址以及上一层的协议类型。以太网会使用 MAC 地址、IP 会使用 IP 地址、TCP/UDP 则会用端口号作为识别两端主机的地址。
此外,每个分层中的包首部还包含一个识别位,它是用来标识上一层协议的种类信息。
总结
这一篇文章 cxuan 还是在和你聊一些基础知识,这些基础知识是为下面文章提前预热准备的,下一篇文章我们会聊到数据链路层的相关知识,敬请期待。
如果这篇文章还不错的话,希望各位小伙伴们点在、留言、在看、分享,cxuan 谢谢大家。
深入理解计算机系统
什么是计算机系统
计算机系统(A computer system)
是由硬件和软件组成的,它们协同工作运行程序。不同的系统可能会有不同实现,但是核心概念是一样的,通用的。
不同的系统有 Microsoft Windows、Apple Mac OS X、Linux 等。
所有的计算机系统都有相似的软件和硬件组成,它们执行相似的功能。
你想要什么
首先,问你一个问题,你想成为哪种程序员?
这是我最近搜索到的一个很好的开源项目,它的路径是 https://github.com/keithnull/TeachYourselfCS-CN/blob/master/TeachYourselfCS-CN.md
也就是
我也把它里面涉及的中文/英文书籍都下载下来了,公众号回复 计算机基础
,即可领取。(图中是冯·诺伊曼)
我一直想成为第一种工程师,即使我永远成为不了,我也要越来越靠近它。
回到正题
没错,我就想成为一种电源程序员
一段简单的程序
这次真的言归正传了,下面是一道很简单的 C 程序(不要管我的名字是 Java建设者还是什么,Java建设者就不能学习 C 了吗?虽然饭碗是 Java,但是 C 才是爸爸啊。)
#include <stdio.h>
int main(){
pritnf("hello, world\n");
return 0;
}
这是用 C 语言输出的一个 Hello,world 程序,尽管它是一个非常简单的程序,但系统的每个部分都必须协同工作才能运行。
这段程序的生命周期就是程序员创建程序、在系统中运行这段程序、打印出一个简单的消息然后终止。
程序员首先在文本中创建这段代码,这个文本又被称为源文件
或者源程序
,然后保存为 hello.c
文件,源程序实际上就是一个由 0 和 1 组成的位(又称为 比特
,即 bit)。8 个 bit 成为一组,称做 字节
。每个字节又表示着一个文本字符,这些文本字符通常是由 ASCII
码组成的,下面是 hello.c
程序的 ASCII 码
hello.c 程序以字节顺序存储在文件中,每个字节都对应一个整数值,也就是 8 位表示一个整数。比如第一个字符是 35,那这个 35 是从哪来的呢?这其实是有个 ASCII 码的对照表(因为 ASCII 非常多,可以去 http://ascii.911cha.com/?year=%23 官网查询,这里只选取几个作为参考哦)
每行都以不可见的 \n
来结尾,它的 ASCII 码值是 10。
注意;只由 ASCII 字符组成的诸如 hello.c 之类的文件称为文本文件。 所有其他文件称为二进制文件。
hello.c 的表示方法说明了一个基本思想:系统中所有的信息 — 包括磁盘文件、内存中的程序、内存中存放的数据以及网络上传输的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是我们读取对象时的上下文,比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。
为什么是 C
这里插播一则新闻,为什么我们要学 C 语言?学 Java 用不用懂 C 语言?这里需要聊聊 C 语言的发家史了
C 语言起源于贝尔实验室。美国国家标准学会 ANSI 在 1981 年颁布了 ANSI C 的标准,后来 C 就被标准化了,这些标准定义了 C 语言和一系列函数库,即所谓的
C 语言标准库
,那么 C 语言有什么特点呢?
- C 语言与 Unix 操作系统密切关联。C 从一开始就被开发为 UNIX 系统的编程语言,大部分 UNIX 内核(操作系统和核心部分)和工具,动态库都是使用 C 编写的。UNIX 成为 1970 – 1980 年代最火的操作系统,而 C 成为最火的编程语言
- C 是一种非常小巧,简单的语言。并且 C 语言的简单使他移植性比较强。
- C 语言是为实践目的设计的。
我们上面提到了 C 语言的各种优势,但是 C 语言也并非所有程序员都能熟练掌握并运用的,C 语言的指针经常让很多程序员头疼,C 语言还缺乏对抽象的良好支持,例如类、对象,但是 C++ 和 Java 都解决了这些问题。
程序被其他程序翻译成不同的形式
C 语言程序成为高级语言的原因是它能够读取并理解人们的思想。然而,为了能够在系统中运行 hello.c
程序,则各个 C 语句必须由其他程序转换为一系列低级机器语言指令。这些指令被打包作为可执行对象程序
,存储在二进制磁盘文件中。目标程序也称为可执行目标文件。
在 UNIX 系统中,从源文件到对象文件的转换是由编译器
执行完成的。
gcc -o hello hello.c
gcc 编译器驱动从源文件读取 hello.c
,并把它翻译成一个可执行文件 hello
。这个翻译过程可用如下图来表示
这就是一个完整的 hello world 程序执行过程,会涉及几个核心组件:预处理器、编译器、汇编器、连接器,下面我们逐个击破。
预处理阶段(Preprocessing phase)
,预处理器会根据开始的#
字符,修改源 C 程序。#include命令就会告诉预处理器去读系统头文件 stdio.h
中的内容,并把它插入到程序作为文本。然后就得到了另外一个 C 程序hello.i
,这个程序通常是以.i
为结尾。- 然后是
编译阶段(Compilation phase)
,编译器会把文本文件hello.i
翻译成文本hello.s
,它包括一段汇编语言程序(assembly-language program)
。这个函数包含 main 函数的定义,如下
main:
subq $8, %rsp
movl $.LCO, %edi
call puts
movl &0, %eax
addq $8, %rsp
ret
上面定义中的 2 – 7 描述了一种低级语言指令。汇编语言是非常有用的,因为它能够针对不同高级语言来提供自己的一套标准输出语言。
- 编译完成之后是
汇编阶段(Assembly phase)
,这一步,汇编器 as
会把 hello.s 翻译成机器指令,把这些指令打包成可重定位的二进制程序(relocatable object program)
放在 hello.c 文件中。它包含的 17 个字节是函数 main 的指令编码,如果我们在文本编辑器中打开 hello.c 将会看到一堆乱码。 - 最后一个是
链接阶段(Linking phase)
,我们的 hello 程序会调用printf
函数,它是 C 编译器提供的 C 标准库中的一部分。printf 函数位于一个叫做printf.o
文件中,它是一个单独的预编译好的目标文件,而这个文件必须要和我们的 hello.o 进行链接,连接器(ld)
会处理这个合并操作。结果是,hello 文件,它是一个可执行的目标文件(或称为可执行文件),已准备好加载到内存中并由系统执行。
你需要理解编译系统做了什么
对于上面这种简单的 hello 程序来说,我们可以依赖编译系统(compilation system)
来提供一个正确和有效的机器代码。然而,对于我们上面讲的程序员来说,编译器有几大特征你需要知道
优化程序性能(Optimizing program performance)
,现代编译器是一种高效的用来生成良好代码的工具。对于程序员来说,你无需为了编写高质量的代码而去理解编译器内部做了什么工作。然而,为了编写出高效的 C 语言程序,我们需要了解一些基本的机器码以及编译器将不同的 C 语句转化为机器代码的过程。理解链接时出现的错误(Understanding link-time errors)
,在我们的经验中,一些非常复杂的错误大多是由链接阶段引起的,特别是当你想要构建大型软件项目时。避免安全漏洞(Avoiding security holes)
,近些年来,缓冲区溢出(buffer overflow vulnerabilities)
是造成网络和 Internet 服务的罪魁祸首,所以我们有必要去规避这种问题
处理器读取、解释内存中的指令
现在,我们的 hello.c 源程序已经被解释成为了可执行的 hello 目标程序,它存储在磁盘上。如果想要在 UNIX 操作系统中运行这个程序,我们需要在 shell 应用程序中输入
cxuan $ ./hello
hello, world
cxuan $
这里解释下什么是 shell,shell 其实就是一个命令解释器,它输出一个字符,等待用户输入一条命令,然后执行这个命令。如果命令行的第一个词不是 shell 内置的命令,那么 shell 就会假设这是一个可执行文件,它会加载并运行这个可执行文件。
系统硬件组成
为了理解 hello 程序在运行时发生了什么,我们需要首先对系统的硬件有一个认识。下面这是一张 Intel 系统产品的模型,我们来对其进行解释
总线(Buses)
:在整个系统中运行的是称为总线的电气管道的集合,这些总线在组件之间来回传输字节信息。通常总线被设计成传送定长的字节块,也就是字(word)
。字中的字节数(字长)是一个基本的系统参数,各个系统中都不尽相同。现在大部分的字都是 4 个字节(32 位)或者 8 个字节(64 位)。
-
I/O 设备(I/O Devices)
:Input/Output 设备是系统和外部世界的连接。上图中有四类 I/O 设备:用于用户输入的键盘和鼠标,用于用户输出的显示器,一个磁盘驱动用来长时间的保存数据和程序。刚开始的时候,可执行程序就保存在磁盘上。每个I/O 设备连接 I/O 总线都被称为
控制器(controller)
或者是适配器(Adapter)
。控制器和适配器之间的主要区别在于封装方式。控制器是 I/O 设备本身或者系统的主印制板电路(通常称作主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论组织形式如何,它们的最终目的都是彼此交换信息。 -
主存(Main Memory)
,主存是一个临时存储设备
,而不是永久性存储,磁盘是永久性存储
的设备。主存既保存程序,又保存处理器执行流程所处理的数据。从物理组成上说,主存是由一系列DRAM(dynamic random access memory)
动态随机存储构成的集合。逻辑上说,内存就是一个线性的字节数组,有它唯一的地址编号,从 0 开始。一般来说,组成程序的每条机器指令都由不同数量的字节构成,C 程序变量相对应的数据项的大小根据类型进行变化。比如,在 Linux 的 x86-64 机器上,short 类型的数据需要 2 个字节,int 和 float 需要 4 个字节,而 long 和 double 需要 8 个字节。 -
处理器(Processor)
,CPU(central processing unit)
或者简单的处理器,是解释(并执行)存储在主存储器中的指令的引擎。处理器的核心大小为一个字的存储设备(或寄存器),称为程序计数器(PC)
。在任何时刻,PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)。从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。处理器根据其指令集体系结构定义的指令模型进行操作。在这个模型中,指令按照严格的顺序执行,执行一条指令涉及执行一系列的步骤。处理器从程序计数器指向的内存中读取指令,解释指令中的位,执行该指令指示的一些简单操作,然后更新程序计数器以指向下一条指令。指令与指令之间可能连续,可能不连续(比如 jmp 指令就不会顺序读取)
下面是 CPU 可能执行简单操作的几个步骤
-
加载(Load)
:从主存中拷贝一个字节或者一个字到内存中,覆盖寄存器先前的内容 -
存储(Store)
:将寄存器中的字节或字复制到主存储器中的某个位置,从而覆盖该位置的先前内容 -
操作(Operate)
:把两个寄存器的内容复制到ALU(Arithmetic logic unit)
。把两个字进行算术运算,并把结果存储在寄存器中,重写寄存器先前的内容。
算术逻辑单元(ALU)是对数字二进制数执行算术和按位运算的组合数字电子电路。
跳转(jump)
:从指令中抽取一个字,把这个字复制到程序计数器(PC)
中,覆盖原来的值
剖析 hello 程序的执行过程
前面我们简单的介绍了一下计算机的硬件的组成和操作,现在我们正式介绍运行示例程序时发生了什么,我们会从宏观的角度进行描述,不会涉及到所有的技术细节
刚开始时,shell 程序执行它的指令,等待用户键入一个命令。当我们在键盘上输入了 ./hello
这几个字符时,shell 程序将字符逐一读入寄存器,再把它放到内存中,如下图所示
当我们在键盘上敲击回车键
的时候,shell 程序就知道我们已经结束了命令的输入。然后 shell 执行一系列指令来加载可执行的 hello 文件,这些指令将目标文件中的代码和数据从磁盘复制到主存。
利用 DMA(Direct Memory Access)
技术可以直接将磁盘中的数据复制到内存中,如下
一旦目标文件中 hello 中的代码和数据被加载到主存,处理器就开始执行 hello 程序的 main 程序中的机器语言指令。这些指令将 hello,world\n
字符串中的字节从主存复制到寄存器文件,再从寄存器中复制到显示设备,最终显示在屏幕上。如下所示
高速缓存是关键
上面我们介绍完了一个 hello 程序的执行过程,系统花费了大量时间把信息从一个地方搬运到另外一个地方。hello 程序的机器指令最初存储在磁盘
上。当程序加载后,它们会拷贝
到主存中。当 CPU 开始运行时,指令又从内存复制到 CPU 中。同样的,字符串数据 hello,world \n
最初也是在磁盘上,它被复制到内存中,然后再到显示器设备输出。从程序员的角度来看,这种复制大部分是开销,这减慢了程序的工作效率。因此,对于系统设计来说,最主要的一个工作是让程序运行的越来越快。
由于物理定律,较大的存储设备要比较小的存储设备慢。而由于寄存器和内存的处理效率在越来越大,所以针对这种差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器(cache memory, 简称为 cache 高速缓存)
,作为暂时的集结区域,存放近期可能会需要的信息。如下图所示
图中我们标出了高速缓存的位置,位于高速缓存中的 L1
高速缓存容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。容量更大的 L2
高速缓存通过一条特殊的总线链接 CPU,虽然 L2 缓存比 L1 缓存慢 5 倍,但是仍比内存要哦快 5 – 10 倍。L1 和 L2 是使用一种静态随机访问存储器(SRAM)
的硬件技术实现的。最新的、处理器更强大的系统甚至有三级缓存:L1、L2 和 L3。系统可以获得一个很大的存储器,同时访问速度也更快,原因是利用了高速缓存的 局部性
原理。
局部性原理:在 cs 中,引用局部性,也称为局部性原理,是 CPU 倾向于在短时间内重复访问同一组内存的机制。
通过把经常访问的数据存放在高速缓存中,大部分对内存的操作直接在高速缓存中就能完成。
存储设备层次结构
上面我们提到了L1、L2、L3 高速缓存还有内存,它们都是用于存储的目的,下面为你绘制了它们之间的层次结构
存储器的主要思想就是上一层的存储器作为低一层存储器的高速缓存。因此,寄存器文件就是 L1 的高速缓存,L1 就是 L2 的高速缓存,L2 是 L3 的高速缓存,L3 是主存的高速缓存,而主存又是磁盘的高速缓存。这里简单介绍一下存储器设备层次结构,具体的会在后面介绍。
操作系统如何管理硬件
再回到我们这个 hello 程序中,当 shell 加载并运行 hello 程序,以及 hello 程序输出自己的消息时,shell 和 hello 程序都没有直接访问键盘、显示器、磁盘或者主存,相反,它们会依赖操作系统(operating System)
做这项工作。操作系统是一种软件,我们可以将操作系统视为介于应用程序和硬件之间的软件层,所有想要直接对硬件的操作都会通过操作系统。
操作系统有两项基本的功能:
- **操作系统能够防止硬件被失控程序滥用
- 向应用程序提供简单一致的机制来控制低级硬件设备。
那么操作系统是通过什么实现对硬件的操作的呢?无非是通过 进程、虚拟内存、文件 来实现这两个功能。
文件是对 I/O 设备的抽象表示,虚拟内存是对主存和磁盘 I/O 设备的抽象表示,进程则是对处理器、主存和 I/O 设备的抽象表示。下面我们依次来探讨一下
进程
进程
是操作系统中的核心概念,进程是对正在运行中的程序的一个抽象。操作系统的其他所有内容都是围绕着进程展开的。即使只有一个 CPU,它们也支持(伪)并发
操作。它们会将一个单独的 CPU 抽象为多个虚拟机的 CPU。我们可以把进程抽象为一种进程模型。
在进程模型中,一个进程就是一个正在执行的程序的实例,进程也包括程序计数器、寄存器和变量的当前值。从概念上来说,每个进程都有各自的虚拟 CPU,但是实际情况是 CPU 会在各个进程之间进行来回切换。
如上图所示,这是一个具有 4 个程序的多道处理程序,在进程不断切换的过程中,程序计数器也在不同的变化。
在上图中,这 4 道程序被抽象为 4 个拥有各自控制流程(即每个自己的程序计数器)的进程,并且每个程序都独立的运行。当然,实际上只有一个物理程序计数器,每个程序要运行时,其逻辑程序计数器会装载到物理程序计数器中。当程序运行结束后,其物理程序计数器就会是真正的程序计数器,然后再把它放回进程的逻辑计数器中。
从下图我们可以看到,在观察足够长的一段时间后,所有的进程都运行了,但在任何一个给定的瞬间仅有一个进程真正运行。
因此,当我们说一个 CPU 只能真正一次运行一个进程的时候,即使有 2 个核(或 CPU),每一个核也只能一次运行一个线程。
由于 CPU 会在各个进程之间来回快速切换,所以每个进程在 CPU 中的运行时间是无法确定的。并且当同一个进程再次在 CPU 中运行时,其在 CPU 内部的运行时间往往也是不固定的。
如下图所示,从一个进程到另一个进程的转换是由操作系统内核(kernel)
管理的。内核是操作系统代码常驻
的部分。当应用程序需要操作系统某些操作时,比如读写文件,它就会执行一条特殊的 系统调用
指令。
注意:内核不是一个独立的进程。相反,它是系统管理全部进程所用代码和数据结构的集合。
我们会在后面具体介绍这些过程
线程
在传统的操作系统中,每个进程都有一个地址空间和一个控制线程。事实上,这是大部分进程的定义。不过,在许多情况下,经常存在同一地址空间中运行多个控制线程的情形,这些线程就像是分离的进程。准确的说,这其实是进程模型和线程模型的讨论,回答这个问题,可能需要分三步来回答
- 多线程之间会共享同一块地址空间和所有可用数据的能力,这是进程所不具备的
- 线程要比进程
更轻量级
,由于线程更轻,所以它比进程更容易创建,也更容易撤销。在许多系统中,创建一个线程要比创建一个进程快 10 – 100 倍。 - 第三个原因可能是性能方面的探讨,如果多个线程都是 CPU 密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的 I/O 处理,拥有多个线程能在这些活动中彼此重叠进行,从而会加快应用程序的执行速度
进程中拥有一个执行的线程,通常简写为 线程(thread)
。线程会有程序计数器,用来记录接着要执行哪一条指令;线程还拥有寄存器
,用来保存线程当前正在使用的变量;线程还会有堆栈,用来记录程序的执行路径。尽管线程必须在某个进程中执行,但是进程和线程完完全全是两个不同的概念,并且他们可以分开处理。进程用于把资源集中在一起,而线程则是 CPU 上调度执行的实体。
线程给进程模型增加了一项内容,即在同一个进程中,允许彼此之间有较大的独立性且互不干扰。在一个进程中并行运行多个线程类似于在一台计算机上运行多个进程。在多个线程中,各个线程共享同一地址空间和其他资源。在多个进程中,进程共享物理内存、磁盘、打印机和其他资源。因为线程会包含有一些进程的属性,所以线程被称为轻量的进程(lightweight processes)
。多线程(multithreading)
一词还用于描述在同一进程中多个线程的情况。
下图我们可以看到三个传统的进程,每个进程有自己的地址空间和单个控制线程。每个线程都在不同的地址空间中运行
下图中,我们可以看到有一个进程三个线程的情况。每个线程都在相同的地址空间中运行。
虚拟内存
虚拟内存的基本思想是,每个程序都有自己的地址空间,这个地址空间被划分为多个称为页面(page)
的块。每一页都是连续
的地址范围。这些页被映射到物理内存,但并不是所有的页都必须在内存中才能运行程序。当程序引用到一部分在物理内存中的地址空间时,硬件会立刻执行必要的映射。当程序引用到一部分不在物理内存中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失败的指令。
在某种意义上来说,虚拟地址是对基址寄存器和变址寄存器的一种概述。8088 有分离的基址寄存器(但不是变址寄存器)用于放入 text 和 data 。
使用虚拟内存,可以将整个地址空间以很小的单位映射到物理内存中,而不是仅仅针对 text 和 data 区进行重定位。下面我们会探讨虚拟内存是如何实现的。
虚拟内存很适合在多道程序设计系统中使用,许多程序的片段同时保存在内存中,当一个程序等待它的一部分读入内存时,可以把 CPU 交给另一个进程使用。
文件
文件(Files)
是由进程创建的逻辑信息单元。一个磁盘会包含几千甚至几百万个文件,每个文件是独立于其他文件的。它是一种抽象机制,它提供了一种方式用来存储信息以及在后面进行读取。
网络通信
现代系统是不会独立存在的,因此经常通过网络和其他系统连接到一起。从一个单独的系统来看,网络可以视为 I/O
设备,如下图所示
当系统从主存复制一串字节到网络适配器时,数据流经过网络到达另一台机器,而不是说到达本地磁盘驱动器。类似的,系统可以读取其他系统发送过来的数据,把数据复制到自己的主存中。
随着 internet 的出现,数据从一台主机复制到另一台主机的情况已经成为最重要的用途之一。比如,像电子邮件、即时通讯、FTP 和 telnet 这样的应用都是基于网络复制信息的功能。
C语言教程
前言
C 语言是一门抽象的
、面向过程
的语言,C 语言广泛应用于底层开发
,C 语言在计算机体系中占据着不可替代的作用,可以说 C 语言是编程的基础,也就是说,不管你学习任何语言,都应该把 C 语言放在首先要学
的位置上。下面这张图更好的说明 C 语言的重要性
可以看到,C 语言是一种底层语言,是一种系统层级的语言,操作系统就是使用 C 语言来编写的,比如 Windows、Linux、UNIX 。如果说其他语言是光鲜亮丽的外表,那么 C 语言就是灵魂,永远那么朴实无华。
C 语言特性
那么,既然 C 语言这么重要,它有什么值得我们去学的地方呢?我们不应该只因为它重要而去学,我们更在意的是学完我们能学会什么,能让我们获得什么。
C 语言的设计
C 语言是 1972 年,由贝尔实验室的丹尼斯·里奇(Dennis Ritch)
和肯·汤普逊(Ken Thompson)
在开发 UNIX 操作系统时设计了C语言。C 语言是一门流行的语言,它把计算机科学理论和工程实践理论完美的融合在一起,使用户能够完成模块化的编程和设计。
计算机科学理论:简称 CS、是系统性研究信息与计算的理论基础以及它们在计算机系统中如何实现与应用的实用技术的学科。
C 语言具有高效性
C 语言是一门高效性语言,它被设计用来充分发挥计算机的优势,因此 C 语言程序运行速度很快,C 语言能够合理了使用内存来获得最大的运行速度
C 语言具有可移植性
C 语言是一门具有可移植性的语言,这就意味着,对于在一台计算机上编写的 C 语言程序可以在另一台计算机上轻松地运行,从而极大的减少了程序移植的工作量。
C 语言特点
- C 语言是一门简洁的语言,因为 C 语言设计更加靠近底层,因此不需要众多 Java 、C# 等高级语言才有的特性,程序的编写要求不是很严格。
- C 语言具有结构化控制语句,C 语言是一门结构化的语言,它提供的控制语句具有结构化特征,如 for 循环、if⋯ else 判断语句和 switch 语句等。
- C 语言具有丰富的数据类型,不仅包含有传统的字符型、整型、浮点型、数组类型等数据类型,还具有其他编程语言所不具备的数据类型,比如指针。
- C 语言能够直接对内存地址进行读写,因此可以实现汇编语言的主要功能,并可直接操作硬件。
- C 语言速度快,生成的目标代码执行效率高。
下面让我们通过一个简单的示例来说明一下 C 语言
入门级 C 语言程序
下面我们来看一个很简单的 C 语言程序,我是 mac 电脑,所以我使用的是 xcode
进行开发,我觉得工具无所谓大家用着顺手就行。
第一个 C 语言程序
#include <stdio.h>
int main(int argc, const char * argv[]) {
printf("Hello, World!\n");
printf("my Name is cxuan \n")
printf("number = %d \n", number);
return 0;
}
你可能不知道这段代码是什么意思,不过别着急,我们先运行一下看看结果。
这段程序输出了 Hello,World!
和 My Name is cxuan
,最后一行是程序的执行结果,表示这段程序是否有错误。下面我们解释一下各行代码的含义。
首先,第一行的 #include <stdio.h>
, 这行代码包含另一个文件,这一行告诉编译器把 stdio.h
的内容包含在当前程序中。 stdio.h
是 C 编译器软件包的标准部分,它能够提供键盘输入和显示器输出。
什么是 C 标准软件包?C 是由 Dennis M 在1972年开发的通用,过程性,命令式计算机编程语言。C标准库是一组 C 语言内置函数,常量和头文件,例如
, , 等。此库将用作 C 程序员的参考手册。
我们后面会介绍 stdio.h ,现在你知道它是什么就好。
在 stdio.h 下面一行代码就是 main
函数。
C 程序能够包含一个或多个函数,函数是 C 语言的根本,就和方法是 Java 的基本构成一样。main()
表示一个函数名,int
表示的是 main 函数返回一个整数。void 表明 main() 不带任何参数。这些我们后面也会详细说明,只需要记住 int 和 void 是标准 ANSI C
定义 main() 的一部分(如果使用 ANSI C 之前的编译器,请忽略 void)。
然后是 /*一个简单的 C 语言程序*/
表示的是注释,注释使用 /**/
来表示,注释的内容在两个符号之间。这些符号能够提高程序的可读性。
注意:注释只是为了帮助程序员理解代码的含义,编译器会忽略注释
下面就是 {
,这是左花括号,它表示的是函数体的开始,而最后的右花括号 }
表示函数体的结束。 { }
中间是书写代码的地方,也叫做代码块。
int number
表示的是将会使用一个名为 number 的变量,而且 number 是 int
整数类型。
number = 11
表示的是把值 11 赋值给 number 的变量。
printf(Hello,world!\n);
表示调用一个函数,这个语句使用 printf()
函数,在屏幕上显示 Hello,world
, printf() 函数是 C 标准库函数中的一种,它能够把程序运行的结果输出到显示器上。而代码 \n
表示的是 换行
,也就是另起一行,把光标移到下一行。
然后接下来的一行 printf() 和上面一行是一样的,我们就不多说了。最后一行 printf() 有点意思,你会发现有一个 %d
的语法,它的意思表示的是使用整形输出字符串。
代码块的最后一行是 return 0
,它可以看成是 main 函数的结束,最后一行是代码块 }
,它表示的是程序的结束。
好了,我们现在写完了第一个 C 语言程序,有没有对 C 有了更深的认识呢?肯定没有。。。这才哪到哪,继续学习吧。
现在,我们可以归纳为 C 语言程序的几个组成要素,如下图所示
C 语言执行流程
C 语言程序成为高级语言的原因是它能够读取并理解人们的思想。然而,为了能够在系统中运行 hello.c
程序,则各个 C 语句必须由其他程序转换为一系列低级机器语言指令。这些指令被打包作为可执行对象程序
,存储在二进制磁盘文件中。目标程序也称为可执行目标文件。
在 UNIX 系统中,从源文件到对象文件的转换是由编译器
执行完成的。
gcc -o hello hello.c
gcc 编译器驱动从源文件读取 hello.c
,并把它翻译成一个可执行文件 hello
。这个翻译过程可用如下图来表示
这就是一个完整的 hello world 程序执行过程,会涉及几个核心组件:预处理器、编译器、汇编器、连接器,下面我们逐个击破。
-
预处理阶段(Preprocessing phase)
,预处理器会根据开始的#
字符,修改源 C 程序。#include命令就会告诉预处理器去读系统头文件 stdio.h
中的内容,并把它插入到程序作为文本。然后就得到了另外一个 C 程序hello.i
,这个程序通常是以.i
为结尾。 -
然后是
编译阶段(Compilation phase)
,编译器会把文本文件hello.i
翻译成文本hello.s
,它包括一段汇编语言程序(assembly-language program)
。 -
编译完成之后是
汇编阶段(Assembly phase)
,这一步,汇编器 as
会把 hello.s 翻译成机器指令,把这些指令打包成可重定位的二进制程序(relocatable object program)
放在 hello.c 文件中。它包含的 17 个字节是函数 main 的指令编码,如果我们在文本编辑器中打开 hello.o 将会看到一堆乱码。 -
最后一个是
链接阶段(Linking phase)
,我们的 hello 程序会调用printf
函数,它是 C 编译器提供的 C 标准库中的一部分。printf 函数位于一个叫做printf.o
文件中,它是一个单独的预编译好的目标文件,而这个文件必须要和我们的 hello.o 进行链接,连接器(ld)
会处理这个合并操作。结果是,hello 文件,它是一个可执行的目标文件(或称为可执行文件),已准备好加载到内存中并由系统执行。
你需要理解编译系统做了什么
对于上面这种简单的 hello 程序来说,我们可以依赖编译系统(compilation system)
来提供一个正确和有效的机器代码。然而,对于我们上面讲的程序员来说,编译器有几大特征你需要知道
优化程序性能(Optimizing program performance)
,现代编译器是一种高效的用来生成良好代码的工具。对于程序员来说,你无需为了编写高质量的代码而去理解编译器内部做了什么工作。然而,为了编写出高效的 C 语言程序,我们需要了解一些基本的机器码以及编译器将不同的 C 语句转化为机器代码的过程。理解链接时出现的错误(Understanding link-time errors)
,在我们的经验中,一些非常复杂的错误大多是由链接阶段引起的,特别是当你想要构建大型软件项目时。避免安全漏洞(Avoiding security holes)
,近些年来,缓冲区溢出(buffer overflow vulnerabilities)
是造成网络和 Internet 服务的罪魁祸首,所以我们有必要去规避这种问题。
系统硬件组成
为了理解 hello 程序在运行时发生了什么,我们需要首先对系统的硬件有一个认识。下面这是一张 Intel 系统产品的模型,我们来对其进行解释
总线(Buses)
:在整个系统中运行的是称为总线的电气管道的集合,这些总线在组件之间来回传输字节信息。通常总线被设计成传送定长的字节块,也就是字(word)
。字中的字节数(字长)是一个基本的系统参数,各个系统中都不尽相同。现在大部分的字都是 4 个字节(32 位)或者 8 个字节(64 位)。
-
I/O 设备(I/O Devices)
:Input/Output 设备是系统和外部世界的连接。上图中有四类 I/O 设备:用于用户输入的键盘和鼠标,用于用户输出的显示器,一个磁盘驱动用来长时间的保存数据和程序。刚开始的时候,可执行程序就保存在磁盘上。每个I/O 设备连接 I/O 总线都被称为
控制器(controller)
或者是适配器(Adapter)
。控制器和适配器之间的主要区别在于封装方式。控制器是 I/O 设备本身或者系统的主印制板电路(通常称作主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论组织形式如何,它们的最终目的都是彼此交换信息。 -
主存(Main Memory)
,主存是一个临时存储设备
,而不是永久性存储,磁盘是永久性存储
的设备。主存既保存程序,又保存处理器执行流程所处理的数据。从物理组成上说,主存是由一系列DRAM(dynamic random access memory)
动态随机存储构成的集合。逻辑上说,内存就是一个线性的字节数组,有它唯一的地址编号,从 0 开始。一般来说,组成程序的每条机器指令都由不同数量的字节构成,C 程序变量相对应的数据项的大小根据类型进行变化。比如,在 Linux 的 x86-64 机器上,short 类型的数据需要 2 个字节,int 和 float 需要 4 个字节,而 long 和 double 需要 8 个字节。 -
处理器(Processor)
,CPU(central processing unit)
或者简单的处理器,是解释(并执行)存储在主存储器中的指令的引擎。处理器的核心大小为一个字的存储设备(或寄存器),称为程序计数器(PC)
。在任何时刻,PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)。从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。处理器根据其指令集体系结构定义的指令模型进行操作。在这个模型中,指令按照严格的顺序执行,执行一条指令涉及执行一系列的步骤。处理器从程序计数器指向的内存中读取指令,解释指令中的位,执行该指令指示的一些简单操作,然后更新程序计数器以指向下一条指令。指令与指令之间可能连续,可能不连续(比如 jmp 指令就不会顺序读取)
下面是 CPU 可能执行简单操作的几个步骤
-
加载(Load)
:从主存中拷贝一个字节或者一个字到内存中,覆盖寄存器先前的内容 -
存储(Store)
:将寄存器中的字节或字复制到主存储器中的某个位置,从而覆盖该位置的先前内容 -
操作(Operate)
:把两个寄存器的内容复制到ALU(Arithmetic logic unit)
。把两个字进行算术运算,并把结果存储在寄存器中,重写寄存器先前的内容。
算术逻辑单元(ALU)是对数字二进制数执行算术和按位运算的组合数字电子电路。
跳转(jump)
:从指令中抽取一个字,把这个字复制到程序计数器(PC)
中,覆盖原来的值
剖析 hello 程序的执行过程
前面我们简单的介绍了一下计算机的硬件的组成和操作,现在我们正式介绍运行示例程序时发生了什么,我们会从宏观的角度进行描述,不会涉及到所有的技术细节
刚开始时,shell 程序执行它的指令,等待用户键入一个命令。当我们在键盘上输入了 ./hello
这几个字符时,shell 程序将字符逐一读入寄存器,再把它放到内存中,如下图所示
当我们在键盘上敲击回车键
的时候,shell 程序就知道我们已经结束了命令的输入。然后 shell 执行一系列指令来加载可执行的 hello 文件,这些指令将目标文件中的代码和数据从磁盘复制到主存。
利用 DMA(Direct Memory Access)
技术可以直接将磁盘中的数据复制到内存中,如下
一旦目标文件中 hello 中的代码和数据被加载到主存,处理器就开始执行 hello 程序的 main 程序中的机器语言指令。这些指令将 hello,world\n
字符串中的字节从主存复制到寄存器文件,再从寄存器中复制到显示设备,最终显示在屏幕上。如下所示
高速缓存是关键
上面我们介绍完了一个 hello 程序的执行过程,系统花费了大量时间把信息从一个地方搬运到另外一个地方。hello 程序的机器指令最初存储在磁盘
上。当程序加载后,它们会拷贝
到主存中。当 CPU 开始运行时,指令又从内存复制到 CPU 中。同样的,字符串数据 hello,world \n
最初也是在磁盘上,它被复制到内存中,然后再到显示器设备输出。从程序员的角度来看,这种复制大部分是开销,这减慢了程序的工作效率。因此,对于系统设计来说,最主要的一个工作是让程序运行的越来越快。
由于物理定律,较大的存储设备要比较小的存储设备慢。而由于寄存器和内存的处理效率在越来越大,所以针对这种差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器(cache memory, 简称为 cache 高速缓存)
,作为暂时的集结区域,存放近期可能会需要的信息。如下图所示
图中我们标出了高速缓存的位置,位于高速缓存中的 L1
高速缓存容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。容量更大的 L2
高速缓存通过一条特殊的总线链接 CPU,虽然 L2 缓存比 L1 缓存慢 5 倍,但是仍比内存要哦快 5 – 10 倍。L1 和 L2 是使用一种静态随机访问存储器(SRAM)
的硬件技术实现的。最新的、处理器更强大的系统甚至有三级缓存:L1、L2 和 L3。系统可以获得一个很大的存储器,同时访问速度也更快,原因是利用了高速缓存的 局部性
原理。
Again:入门程序细节
现在,我们来探讨一下入门级
程序的细节,由浅入深的来了解一下 C 语言的特性。
include
我们上面说到,#include<stdio.h>
是程序编译之前要处理的内容,称为编译预处理
命令。
预处理命令是在编译之前进行处理。预处理程序一般以 #
号开头。
所有的 C 编译器软件包都提供 stdio.h
文件。该文件包含了给编译器使用的输入和输出函数,比如 println() 信息。该文件名的含义是标准输入/输出 头文件。通常,在 C 程序顶部的信息集合被称为 头文件(header)
。
C 的第一个标准是由 ANSI 发布的。虽然这份文档后来被国际标准化组织(ISO)采纳并且 ISO 发布的修订版也被 ANSI 采纳了,但名称 ANSI C(而不是 ISO C) 仍被广泛使用。一些软件开发者使用ISO C,还有一些使用 Standard C。
C 标准库
除了
提供了一个名为 assert
的关键字,它用于验证程序作出的假设,并在假设为假输出诊断消息。
C 标准库的 ctype.h 头文件提供了一些函数,可以用于测试和映射字符。
这些字符接受 int 作为参数,它的值必须是 EOF
或者是一个无符号字符
EOF是一个计算机术语,为 End Of File 的缩写,在操作系统中表示资料源无更多的资料可读取。资料源通常称为档案或串流。通常在文本的最后存在此字符表示资料结束。
C 标准库的 errno.h 头文件定义了整数变量 errno,它是通过系统调用设置的,这些库函数表明了什么发生了错误。
**
C 标准库的 float.h 头文件包含了一组与浮点值相关的依赖于平台的常量。
limits.h 头文件决定了各种变量类型的各种属性。定义在该头文件中的宏限制了各种变量类型(比如 char、int 和 long)的值。
**
locale.h 头文件定义了特定地域的设置,比如日期格式和货币符号
**
math.h 头文件定义了各种数学函数和一个宏。在这个库中所有可用的功能都带有一个 double 类型的参数,且都返回 double 类型的结果。
setjmp.h 头文件定义了宏 setjmp()、函数 longjmp() 和变量类型 jmp_buf,该变量类型会绕过正常的函数调用和返回规则。
**
signal.h 头文件定义了一个变量类型 sig_atomic_t、两个函数调用和一些宏来处理程序执行期间报告的不同信号。
**
stdarg.h 头文件定义了一个变量类型 va_list 和三个宏,这三个宏可用于在参数个数未知(即参数个数可变)时获取函数中的参数。
**
stddef .h 头文件定义了各种变量类型和宏。这些定义中的大部分也出现在其它头文件中。
**
stdlib .h 头文件定义了四个变量类型、一些宏和各种通用工具函数。
string .h 头文件定义了一个变量类型、一个宏和各种操作字符数组的函数。
time.h 头文件定义了四个变量类型、两个宏和各种操作日期和时间的函数。
main() 函数
main 函数听起来像是调皮捣蛋的孩子故意给方法名起一个 主要的
方法,来告诉他人他才是这个世界的中心。但事实却不是这样,而 main()
方法确实是世界的中心。
C 语言程序一定从 main() 函数开始执行,除了 main() 函数外,你可以随意命名其他函数。通常,main 后面的 ()
中表示一些传入信息,我们上面的那个例子中没有传递信息,因为圆括号中的输入是 void 。
除了上面那种写法外,还有两种 main 方法的表示方式,一种是 void main(){}
,一种是 int main(int argc, char* argv[]) {}
- void main() 声明了一个带有不确定参数的构造方法
- int main(int argc, char* argv[]) {} 其中的 argc 是一个非负值,表示从运行程序的环境传递到程序的参数数量。它是指向 argc + 1 指针数组的第一个元素的指针,其中最后一个为null,而前一个(如果有的话)指向表示从主机环境传递给程序的参数的字符串。 如果argv [0]不是空指针(或者等效地,如果argc> 0),则指向表示程序名称的字符串,如果在主机环境中无法使用程序名称,则该字符串为空。
注释
在程序中,使用 /**/ 的表示注释,注释对于程序来说没有什么实际用处,但是对程序员来说却非常有用,它能够帮助我们理解程序,也能够让他人看懂你写的程序,我们在开发工作中,都非常反感不写注释的人,由此可见注释非常重要。
C 语言注释的好处是,它可以放在任意地方,甚至代码在同一行也没关系。较长的注释可以多行表示,我们使用 /**/ 表示多行注释,而 // 只表示的是单行注释。下面是几种注释的表示形式
// 这是一个单行注释
/* 多行注释用一行表示 */
/
多行注释用多行表示
多行注释用多行表示
多行注释用多行表示
多行注释用多行表示
*/
函数体
在头文件、main 方法后面的就是函数体(注释一般不算),函数体就是函数的执行体,是你编写大量代码的地方。
变量声明
在我们入门级的代码中,我们声明了一个名为 number
的变量,它的类型是 int,这行代码叫做 声明
,声明是 C 语言最重要的特性之一。这个声明完成了两件事情:定义了一个名为 number 的变量,定义 number 的具体类型。
int 是 C 语言的一个 关键字(keyword)
,表示一种基本的 C 语言数据类型。关键字是用于语言定义的。不能使用关键字作为变量进行定义。
示例中的 number
是一个 标识符(identifier)
,也就是一个变量、函数或者其他实体的名称。
变量赋值
在入门例子程序中,我们声明了一个 number 变量,并为其赋值为 11,赋值是 C 语言的基本操作之一。这行代码的意思就是把值 1 赋给变量 number。在执行 int number 时,编译器会在计算机内存中为变量 number 预留空间,然后在执行这行赋值表达式语句时,把值存储在之前预留的位置。可以给 number 赋不同的值,这就是 number 之所以被称为 变量(variable)
的原因。
printf 函数
在入门例子程序中,有三行 printf(),这是 C 语言的标准函数。圆括号中的内容是从 main 函数传递给 printf 函数的。参数分为两种:实际参数(actual argument)
和 形式参数(formal parameters)
。我们上面提到的 printf 函数括号中的内容,都是实参。
return 语句
在入门例子程序中,return 语句是最后一条语句。int main(void)
中的 int 表明 main() 函数应返回一个整数。有返回值的 C 函数要有 return 语句,没有返回值的程序也建议大家保留 return 关键字,这是一种好的习惯或者说统一的编码风格。
分号
在 C 语言中,每一行的结尾都要用 ;
进行结束,它表示一个语句的结束,如果忘记或者会略分号会被编译器提示错误。
关键字
下面是 C 语言中的关键字,C 语言的关键字一共有 32
个,根据其作用不同进行划分
数据类型关键字
数据类型的关键字主要有 12 个,分别是
char
: 声明字符型变量或函数double
: 声明双精度变量或函数float
: 声明浮点型变量或函数int
: 声明整型变量或函数long
: 声明长整型变量或函数short
: 声明短整型变量或函数signed
: 声明有符号类型变量或函数_Bool
: 声明布尔类型_Complex
:声明复数_Imaginary
: 声明虚数unsigned
: 声明无符号类型变量或函数void
: 声明函数无返回值或无参数,声明无类型指针
控制语句关键字
控制语句循环的关键字也有 12 个,分别是
**循环语句
for
: for 循环,使用的最多do
:循环语句的前提条件循环体while
:循环语句的循环条件break
: 跳出当前循环continue
:结束当前循环,开始下一轮循环
**条件语句
if
:条件语句的判断条件else
: 条件语句的否定分支,与 if 连用goto
: 无条件跳转语句
**开关语句
switch
: 用于开关语句case
:开关语句的另外一种分支default
: 开关语句中的其他分支
**返回语句
retur
:子程序返回语句(可以带参数,也看不带参数)
存储类型关键字
auto
: 声明自动变量 一般不使用extern
: 声明变量是在其他文件正声明(也可以看做是引用变量)register
: 声明寄存器变量static
: 声明静态变量
其他关键字
const
: 声明只读变量sizeof
: 计算数据类型长度typedef
: 用以给数据类型取别名volatile
: 说明变量在程序执行中可被隐含地改变
后记
这篇文章我们先介绍了 C 语言的特性,C 语言为什么这么火,C 语言的重要性,之后我们以一道 C 语言的入门程序讲起,我们讲了 C 语言的基本构成要素,C 语言在硬件上是如何运行的,C 语言的编译过程和执行过程等,在这之后我们又加深讲解了一下入门例子程序的组成特征。
我向面试官讲解了单例模式,他对我竖起了大拇指
单例模式相信大家都有所听闻,甚至也写过不少了,在面试中也是考得最多的其中一个设计模式,面试官常常会要求写出两种类型的单例模式并且解释其原理,废话不多说,我们开始学习如何很好地回答这一道面试题吧。
什么是单例模式
面试官问什么是单例模式时,千万不要答非所问,给出单例模式有两种类型之类的回答,要围绕单例模式的定义去展开。
单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象。
单例模式的类型
单例模式有两种类型:
懒汉式
:在真正需要使用对象时才去创建该单例类对象饿汉式
:在类加载时已经创建好该单例对象,等待被程序使用
懒汉式创建单例对象
懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象。否则则先执行实例化操作。
根据上面的流程图,就可以写出下面的这段代码
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
没错,这里我们已经写出了一个很不错的单例模式,不过它不是完美的,但是这并不影响我们使用这个“单例对象”。
以上就是懒汉式创建单例对象的方法,我会在后面解释这段代码在哪里可以优化,存在什么问题。
饿汉式创建单例对象
饿汉式在类加载
时已经创建好该对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。
关于类加载,涉及到JVM的内容,我们目前可以简单认为在程序启动时,这个单例对象就已经创建好了。
public class Singleton{
private static final Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance() {
return singleton;
}
}
注意上面的代码在第3行已经实例化好了一个Singleton对象在内存中,不会有多个Singleton对象实例存在
类在加载时会在堆内存中创建一个Singleton对象,当类被卸载时,Singleton对象也随之消亡了。
懒汉式如何保证只创建一个对象
我们再来回顾懒汉式的核心方法
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
这个方法其实是存在问题的,试想一下,如果两个线程同时判断 singleton 为空,那么它们都会去实例化一个Singleton 对象,这就变成多例了。所以,我们要解决的是线程安全
问题。
最容易想到的解决方法就是在方法上加锁,或者是对类对象加锁,程序就会变成下面这个样子
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
// 或者
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}
这样就规避了两个线程同时创建Singleton对象的风险,但是引来另外一个问题:**每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。
接下来要做的就是优化性能
:目标是如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例
所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁
public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
上面的代码已经完美地解决了并发安全 + 性能低效问题:
- 第 2 行代码,如果 singleton 不为空,则直接返回对象,不需要获取锁;而如果多个线程发现 singleton 为空,则进入分支;
- 第 3 行代码,多个线程尝试争抢同一个锁,只有一个线程争抢成功,第一个获取到锁的线程会再次判断singleton 是否为空,因为 singleton 有可能已经被之前的线程实例化
- 其它之后获取到锁的线程在执行到第 4 行校验代码,发现 singleton 已经不为空了,则不会再 new 一个对象,直接返回对象即可
- 之后所有进入该方法的线程都不会去获取锁,在第一次判断 singleton 对象时已经不为空了
因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为:**Double Check(双重校验) + Lock(加锁)
完整的代码如下所示:
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
}
上面这段代码已经近似完美了,但是还存在最后一个问题:指令重排
使用 volatile 防止指令重排
创建一个对象,在 JVM 中会经过三步:
(1)为 singleton 分配内存空间
(2)初始化 singleton 对象
(3)将 singleton 指向分配好的内存空间
指令重排序是指:**JVM 在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能
在这三步中,第 2、3 步有可能会发生指令重排现象,创建对象的顺序变为 1-3-2,会导致多个线程获取对象时,有可能线程 A 创建对象的过程中,执行了 1、3 步骤,线程 B 判断 singleton 已经不为空,获取到未初始化的singleton 对象,就会报 NPE 异常。文字较为晦涩,可以看流程图:
使用 volatile 关键字可以防止指令重排序,其原理较为复杂,这篇文章不打算展开,可以这样理解:使用 volatile 关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生 NPE 异常了。
volatile 还有第二个作用:使用 volatile 关键字修饰的变量,可以保证其内存可见性,即每一时刻线程读取到该变量的值都是内存中最新的那个值,线程每次操作该变量都需要先读取该变量。
最终的代码如下所示:
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
}
破坏懒汉式单例与饿汉式单例
无论是完美的懒汉式还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。
利用反射破坏单例模式
下面是一段使用反射破坏单例模式的例子
public static void main(String[] args) {
// 获取类的显式构造器
Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
// 可访问私有构造器
construct.setAccessible(true);
// 利用反射构造新对象
Singleton obj1 = construct.newInstance();
// 通过正常方式获取单例对象
Singleton obj2 = Singleton.getInstance();
System.out.println(obj1 == obj2); // false
}
上述的代码一针见血了:利用反射,强制访问类的私有构造器,去创建另一个对象
利用序列化与反序列化破坏单例模式
下面是一种使用序列化和反序列化破坏单例模式的例子
public static void main(String[] args) {
// 创建输出流
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
// 将单例对象写到文件中
oos.writeObject(Singleton.getInstance());
// 从文件中读取单例对象
File file = new File("Singleton.file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Singleton newInstance = (Singleton) ois.readObject();
// 判断是否是同一个对象
System.out.println(newInstance == Singleton.getInstance()); // false
}
两个对象地址不相等的原因是:readObject() 方法读入对象时它必定会返回一个新的对象实例,必然指向新的内存地址。
让面试官鼓掌的枚举实现
我们已经掌握了懒汉式与饿汉式的常见写法了,通常情况下到这里已经足够了。但是,追求极致的我们,怎么能够止步于此,在《Effective Java》书中,给出了终极解决方法,话不多说,学完下面,真的不虚面试官考你了。
在 JDK 1.5 后,使用 Java 语言实现单例模式的方式又多了一种:枚举
枚举实现单例模式完整代码如下:
public enum Singleton {
INSTANCE;
public void doSomething() {
System.out.println("这是枚举类型的单例模式!");
}
}
使用枚举实现单例模式较其它两种实现方式的优势有 3 点,让我们来细品。
优势 1 :一目了然的代码
代码对比饿汉式与懒汉式来说,更加地简洁。最少只需要3行代码,就可以完成一个单例模式:
public enum Test {
INSTANCE;
}
我们从最直观的地方入手,第一眼看到这3行代码,就会感觉到少
,没错,就是少,虽然这优势有些牵强,但写的代码越少,越不容易出错。
优势 2:天然的线程安全与单一实例
它不需要做任何额外的操作,就可以保证对象单一性与线程安全性。
我写了一段测试代码放在下面,这一段代码可以证明程序启动时仅会创建一个 Singleton 对象,且是线程安全的。
我们可以简单地理解枚举创建实例的过程:在程序启动时,会调用 Singleton 的空参构造器,实例化好一个Singleton 对象赋给 INSTANCE,之后再也不会实例化
public enum Singleton {
INSTANCE;
Singleton() { System.out.println("枚举创建对象了"); }
public static void main(String[] args) { /* test(); */ }
public void test() {
Singleton t1 = Singleton.INSTANCE;
Singleton t2 = Singleton.INSTANCE;
System.out.print("t1和t2的地址是否相同:" + t1 == t2);
}
}
// 枚举创建对象了
// t1和t2的地址是否相同:true
除了优势1和优势2,还有最后一个优势是 保护单例模式
,它使得枚举在当前的单例模式领域已经是 无懈可击
了
优势 3:枚举保护单例模式不被破坏
使用枚举可以防止调用者使用反射、序列化与反序列化机制强制生成多个单例对象,破坏单例模式。
**防反射
枚举类默认继承了 Enum 类,在利用反射调用 newInstance() 时,会判断该类是否是一个枚举类,如果是,则抛出异常。
**防止反序列化创建多个枚举对象
在读入 Singleton 对象时,每个枚举类型和枚举名字都是唯一的,所以在序列化时,仅仅只是对枚举的类型和变量名输出到文件中,在读入文件反序列化成对象时,使用 Enum 类的 valueOf(String name) 方法根据变量的名字查找对应的枚举对象。
所以,在序列化和反序列化的过程中,只是写出和读入了枚举类型和名字,没有任何关于对象的操作。
小结:
(1)Enum 类内部使用Enum 类型判定防止通过反射创建多个对象
(2)Enum 类通过写出(读入)对象类型和枚举名字将对象序列化(反序列化),通过 valueOf() 方法匹配枚举名找到内存中的唯一的对象实例,防止通过反序列化构造多个对象
(3)枚举类不需要关注线程安全、破坏单例和性能问题,因为其创建对象的时机与饿汉式单例有异曲同工之妙。
总结
(1)单例模式常见的写法有两种:懒汉式、饿汉式
(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题
(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。
(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;
(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题
(6)为了防止多线程环境下,因为指令重排序导致变量报NPE,需要在单例对象上添加 volatile 关键字防止指令重排序
(7)最优雅的实现方式是使用枚举,其代码精简,没有线程安全问题,且 Enum 类内部防止反射和反序列化时破坏单例。
设计模式超强总结
设计模式(Design pattern)
代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。
设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。
设计模式在项目开发的重要性已经不言而喻,下面就让我们一起来走进设计模式的主题中
首先先来认识一下为更好的理解设计模式打基础的 UML 建模
UML中类图以及类图之间的关系
统一建模语言(Unified Modeling Language,UML)是用来设计软件蓝图的可视化建模语言,1997 年被国际对象管理组织(OMG)采纳为面向对象的建模语言的国际标准。它的特点是简单、统一、图形化、能表达软件设计中的动态与静态信息。
UML 从目标系统的不同角度出发,定义了用例图、类图、对象图、状态图、活动图、时序图、协作图、构件图、部署图等 9 种图。
类、接口和类图
类
类(Class)是指具有相同属性、方法和关系的对象的抽象,它封装了数据和行为,是面向对象程序设计(OOP)的基础,具有封装性、继承性和多态性等三大特性。
类有类名、属性和方法,下面是一个表示类图的例子
上面的 no、name、school、age、sex 都表示的是属性,下面 display() 表示的是方法。
接口
接口(Interface)是一种特殊的类,它具有类的结构但不可被实例化,只可以被子类实现。接口一般用来设计类之间的关系
上面的手机接口有两个方法,可以表示call() 打电话,也可以表示receive() 接电话。
类图
类图(ClassDiagram)是用来显示系统中的类、接口、协作以及它们之间的静态结构和关系的一种静态模型。
**类与类之间的关系
在软件系统中,类不是孤立存在的,类与类之间存在各种关系。根据类与类之间的耦合度从弱到强排列,UML 中的类图有以下几种关系:依赖关系、关联关系、聚合关系、组合关系、泛化关系和实现关系。其中泛化和实现的耦合度相等,它们是最强的。
- 依赖关系
依赖(Dependency)
关系是一种使用关系,它是对象之间耦合度最弱的一种关联方式,是临时性的关联。在代码中,某个类的方法通过局部变量、方法的参数或者对静态方法的调用来访问另一个类(被依赖类)中的某些方法来完成一些事情。
在 UML 类图中,依赖关系使用带箭头的虚线来表示,箭头从使用类指向被依赖的类,例如下面这个例子
现代人需要依赖手机进行通讯和交流。在 UML 类图中,依赖关系使用带箭头的虚线来表示,箭头从使用类指向被依赖的类
- 关联关系
关联(Association)
关系是对象之间的一种引用关系,用于表示一类对象与另一类对象之间的联系,如老师和学生、师傅和徒弟、丈夫和妻子等。关联关系是类与类之间最常用的一种关系,分为一般关联关系、聚合关系和组合关系。
关联可以是双向的,也可以是单向的。在 UML 类图中,双向的关联可以用带两个箭头或者没有箭头的实线来表示,单向的关联用带一个箭头的实线来表示,箭头从使用类指向被关联的类。也可以在关联线的两端标注角色名,代表两种不同的角色。
例如一个老师和学生的关系是多对多的,一个老师可以教多个学生,一个学生有多个上课老师,那么一个学生同时也可以选择多门课程,学生和课程之间的关系也是多对多。
- 聚合关系
聚合(Aggregation)
关系是关联关系的一种,是强关联关系,是整体和部分之间的关系,是 has-a
的关系。
聚合关系也是通过成员对象来实现的,其中成员对象是整体对象的一部分,但是成员对象可以脱离整体对象而独立存在。例如,学校与老师的关系,学校包含老师,但如果学校停办了,老师依然存在。
在 UML 类图中,聚合关系可以用带空心菱形的实线来表示,菱形指向整体
一个学校会有多个老师,但是学校没有了,老师还依然会很存在。
- 组合关系
组合(Composition)
关系也是关联关系的一种,也表示类之间的整体与部分的关系,但它是一种更强烈的聚合关系,是 contains-a
关系。
在组合关系中,整体对象可以控制部分对象的生命周期,一旦整体对象不存在,部分对象也将不存在,部分对象不能脱离整体对象而存在。
在 UML 类图中,组合关系用带实心菱形的实线来表示,菱形指向整体
钱包和钱是一个组合的关系,钱包是放钱的口袋,钱包丢了钱也就没了,所以是一种组合关系。
- 泛化关系
泛化(Generalization)关系是对象之间耦合度最大的一种关系,表示一般与特殊的关系,是父类与子类之间的关系,是一种继承关系,是 is-a
的关系。
在 UML 类图中,泛化关系用带空心三角箭头的实线来表示,箭头从子类指向父类。在代码实现时,使用面向对象的继承机制来实现泛化关系。例如,Student 类和 Teacher 类都是 Person 类的子类,
- 实现关系
实现(Realization)关系是接口与实现类之间的关系。在这种关系中,类实现了接口,类中的操作实现了接口中所声明的所有的抽象操作。
在 UML 类图中,实现关系使用带空心三角箭头的虚线来表示,箭头从实现类指向接口。
比如表示一个交通工具的接口,其中有一个 move 方法,有两个类 Bike 和 Car 分别实现了 Vehicle,那么也就实现了 move() 方法
设计模式的原则
在了解完上面一个简单的UML建模后,下面来聊一下设计原则,在设计模式中有六种设计原则,它们分别是
开闭原则(Open Closed Principle)
核心思想是对扩展开放,对修改关闭。也就是说,对已经使用的类的改动是通过增加代码进行的,而不是修改现有代码,实现一个热插拔的效果。
例如:你手机中的桌面主题,你无法修改已有的桌面主题,只能从网上下载新的桌面主题
单一职责原则(Single Responsiblity Principle)
单一职责原则的核心就是控制类的粒度大小、将对象解耦、提高其内聚性。其实就是开发人员经常说的”高内聚,低耦合”,每个类应该只有一个职责,对外只能提供一种功能,而引起类变化的原因应该只有一个。
比如一个班级会有很多课代表,语文课代表、数学课代表、英语课代表等等,那么课代表只应该负责班级特定学科的工作,语文课代表不能插手数学课代表和英语课代表的工作。
里式替换原则(Liskov Substitution Principle)
里式替换的原则认为子类可以扩展父类的功能,但不能改变父类原有的功能,也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
例如鸟有一个fly() 方法,燕子是一种鸟,燕子继承鸟类并重写了 fly() 方法,企鹅是一种鸟,企鹅继承了鸟类但是企鹅不能飞,这就相当于是改变了鸟类的功能,不能说企鹅不能飞,所以鸟不能飞
依赖倒转原则(Dependency Inversion Principle)
依赖倒转原则的核心思想是:要依赖于抽象和接口,不要依赖于具体的实现。
其实就是说:在应用程序中,所有的类如果使用或依赖于其他的类,则应该依赖这些其他类的抽象类或者接口,而不是直接依赖这些其他类的具体类。为了实现这一原则,就要求我们在编程的时候针对抽象类或者接口编程,而不是针对具体实现编程。
比如女人去商场购物,她可能买很多东西,不局限于只买一个包,她还可能买鞋,化妆品等。那么我们可以针对商品建模
接口分离原则(Interface Segregation Principle)
要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法。
接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:
- 单一职责原则注重的是职责,而接口隔离原则注重的是对接口依赖的隔离。
- 单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。
迪米特原则(Principle of Least Knowledge)
迪米特法则又叫最少认知原则,它的核心思想是一个对象应当对其他对象尽可能少的了解
其实就是说:降低各个对象之间的耦合,提高系统的可维护性。在模块之间应该只通过接口编程,而不理会模块的内部工作原理,它可以使各个模块耦合度降到最低,促进软件的复用。
拿我们身边的朋友圈举例子,朋友圈的确定,如果两个人有一个共同的好友,那么他们的朋友圈点赞和留言也是可见的,如果没有共同好友,那么彼此不可见。
二十三种设计模式
设计模式概述
- 设计模式(Design pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。
- 设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
- 设计模式是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性。
- 设计模式不是一种方法和技术,而是一种思想。
- 设计模式和具体的语言无关,学习设计模式就是要建立面向对象的思想,尽可能的面向接口编程,低耦合,高内聚,使设计的程序可复用。
- 学习设计模式能够促进对面向对象思想的理解,反之亦然。它们相辅相成。
设计模式的类型
总体来说,设计模式分为三类23种:
- 创建型(5种) :工厂模式、抽象工厂模式、单例模式、原型模式、构建者模式
- 结构型(7种):适配器模式、装饰模式、代理模式、外观模式、桥接模式、组合模式、享元模式
- 行为型(11种):模板方法模式、策略模式、观察者模式、中介者模式、状态模式、责任链模式、命令模式、迭代器模式、访问者模式、解释器模式、备忘录模式
带你一步步解析HTTPS
下面我们来一起学习一下 HTTPS ,首先问你一个问题,为什么有了 HTTP 之后,还需要有 HTTPS ?我突然有个想法,为什么我们面试的时候需要回答标准答案
呢?为什么我们不说出我们自己的想法和见解,却要记住一些所谓的标准回答呢?技术还有正确与否吗?
HTTPS 为什么会出现
一个新技术的出现必定是为了解决某种问题的,那么 HTTPS 解决了 HTTP 的什么问题呢?
HTTPS 解决了什么问题
一个简单的回答可能会是 HTTP
它不安全。由于 HTTP 天生明文传输的特性,在 HTTP 的传输过程中,任何人都有可能从中截获、修改或者伪造请求发送,所以可以认为 HTTP 是不安全的;在 HTTP 的传输过程中不会验证通信方的身份,因此 HTTP 信息交换的双方可能会遭到伪装,也就是没有用户验证
;在 HTTP 的传输过程中,接收方和发送方并不会验证报文的完整性
,综上,为了结局上述问题,HTTPS 应用而生。
什么是 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。
HTTPS 做了什么
HTTPS 协议提供了三个关键的指标
-
加密(Encryption)
, HTTPS 通过对数据加密来使其免受窃听者对数据的监听,这就意味着当用户在浏览网站时,没有人能够监听他和网站之间的信息交换,或者跟踪用户的活动,访问记录等,从而窃取用户信息。 -
数据一致性(Data integrity)
,数据在传输的过程中不会被窃听者所修改,用户发送的数据会完整
的传输到服务端,保证用户发的是什么,服务器接收的就是什么。 -
身份认证(Authentication)
,是指确认对方的真实身份,也就是证明你是你
(可以比作人脸识别),它可以防止中间人攻击并建立用户信任。
有了上面三个关键指标的保证,用户就可以和服务器进行安全的交换信息了。那么,既然你说了 HTTPS 的种种好处,那么我怎么知道网站是用 HTTPS 的还是 HTTP 的呢?给你两幅图应该就可以解释了。
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 协议签名和加密的电子邮件
- 代码签名:它指的是使用数字证书对软件应用程序进行签名以安全分发和安装的过程。
通过使用由知名公共证书颁发机构(例如SSL.com)颁发的证书对软件进行数字签名,开发人员可以向最终用户保证他们希望安装的软件是由已知且受信任的开发人员发布;并且签名后未被篡改或损害。
还可用于文档签名
还可用于客户端认证
政府签发的电子身份证(详见 https://www.ssl.com/article/pki-and-digital-certificates-for-government/)
我们后面还会讨论。
HTTPS 的内核是 HTTP
HTTPS 并不是一项新的应用层协议,只是 HTTP 通信接口部分由 SSL 和 TLS 替代而已。通常情况下,HTTP 会先直接和 TCP 进行通信。在使用 SSL 的 HTTPS 后,则会先演变为和 SSL 进行通信,然后再由 SSL 和 TCP 进行通信。也就是说,HTTPS 就是身披了一层 SSL 的 HTTP。(我都喜欢把骚粉留在最后。。。)
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/assignments/tls-parameters/tls-parameters.xhtml)
ECDHE-ECDSA-AES256-GCM-SHA384
这是啥意思呢?我刚开始看也有点懵啊,但其实是有套路的,因为 TLS 的密码套件比较规范,基本格式就是 密钥交换算法 – 签名算法 – 对称加密算法 – 摘要算法 组成的一个密码串,有时候还有分组模式
,我们先来看一下刚刚是什么意思
使用 ECDHE 进行密钥交换,使用 ECDSA 进行签名和认证,然后使用 AES 作为对称加密算法,密钥的长度是 256 位,使用 GCM 作为分组模式,最后使用 SHA384 作为摘要算法。
TLS 在根本上使用对称加密
和 非对称加密
两种形式。
对称加密
在了解对称加密前,我们先来了解一下密码学
的东西,在密码学中,有几个概念:**明文、密文、加密、解密
明文(Plaintext)
,一般认为明文是有意义的字符或者比特集,或者是通过某种公开编码就能获得的消息。明文通常用 m 或 p 表示密文(Ciphertext)
,对明文进行某种加密后就变成了密文加密(Encrypt)
,把原始的信息(明文)转换为密文的信息变换过程解密(Decrypt)
,把已经加密的信息恢复成明文的过程。
对称加密(Symmetrical Encryption)
顾名思义就是指加密和解密时使用的密钥都是同样的密钥。只要保证了密钥的安全性,那么整个通信过程也就是具有了机密性。
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)
也被称为公钥加密
,相对于对称加密来说,非对称加密是一种新的改良加密方式。密钥通过网络传输交换,它能够确保及时密钥被拦截,也不会暴露数据信息。非对称加密中有两个密钥,一个是公钥,一个是私钥,公钥进行加密,私钥进行解密。公开密钥可供任何人使用,私钥只有你自己能够知道。
使用公钥加密的文本只能使用私钥解密,同时,使用私钥加密的文本也可以使用公钥解密。公钥不需要具有安全性,因为公钥需要在网络间进行传输,非对称加密可以解决密钥交换
的问题。网站保管私钥,在网上任意分发公钥,你想要登录网站只要用公钥加密就行了,密文只能由私钥持有者才能解密。而黑客因为没有私钥,所以就无法破解密文。
非对称加密算法的设计要比对称算法难得多(我们不会探讨具体的加密方式),常见的比如 DH、DSA、RSA、ECC 等。
其中 RSA
加密算法是最重要的、最出名的一个了。例如 DHE_RSA_CAMELLIA128_GCM_SHA256
。它的安全性基于 整数分解
,使用两个超大素数的乘积作为生成密钥的材料,想要从公钥推算出私钥是非常困难的。
ECC(Elliptic Curve Cryptography)
也是非对称加密算法的一种,它基于椭圆曲线离散对数
的数学难题,使用特定的曲线方程和基点生成公钥和私钥, ECDHE 用于密钥交换,ECDSA 用于数字签名。
TLS 是使用对称加密
和非对称加密
的混合加密方式来实现机密性。
混合加密
RSA 的运算速度非常慢,而 AES 的加密速度比较快,而 TLS 正是使用了这种混合加密
方式。在通信刚开始的时候使用非对称算法,比如 RSA、ECDHE ,首先解决密钥交换
的问题。然后用随机数产生对称算法使用的会话密钥(session key)
,再用公钥加密
。对方拿到密文后用私钥解密
,取出会话密钥。这样,双方就实现了对称密钥的安全交换。
现在我们使用混合加密的方式实现了机密性,是不是就能够安全的传输数据了呢?还不够,在机密性的基础上还要加上完整性
、身份认证
的特性,才能实现真正的安全。而实现完整性的主要手段是 摘要算法(Digest Algorithm)
摘要算法
如何实现完整性呢?在 TLS 中,实现完整性的手段主要是 摘要算法(Digest Algorithm)
。摘要算法你不清楚的话,MD5 你应该清楚,MD5 的全称是 Message Digest Algorithm 5
,它是属于密码哈希算法(cryptographic hash algorithm)
的一种,MD5 可用于从任意长度的字符串创建 128 位字符串值。尽管 MD5 存在不安全因素,但是仍然沿用至今。MD5 最常用于验证文件
的完整性。但是,它还用于其他安全协议和应用程序中,例如 SSH、SSL 和 IPSec。一些应用程序通过向明文加盐值或多次应用哈希函数来增强 MD5 算法。
什么是加盐?在密码学中,
盐
就是一项随机数据,用作哈希数据,密码或密码的单向
函数的附加输入。盐用于保护存储中的密码。例如什么是单向?就是在说这种算法没有密钥可以进行解密,只能进行单向加密,加密后的数据无法解密,不能逆推出原文。
我们再回到摘要算法的讨论上来,其实你可以把摘要算法理解成一种特殊的压缩算法,它能够把任意长度的数据压缩
成一种固定长度的字符串,这就好像是给数据加了一把锁。
除了常用的 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 等。
现在我们又解决了完整性的问题,那么就只剩下一个问题了,那就是认证
,认证怎么做的呢?我们再向服务器发送数据的过程中,黑客(攻击者)有可能伪装成任何一方来窃取信息。它可以伪装成你,来向服务器发送信息,也可以伪装称为服务器,接受你发送的信息。那么怎么解决这个问题呢?
认证
如何确定你自己的唯一性呢?我们在上面的叙述过程中出现过公钥加密,私钥解密的这个概念。提到的私钥只有你一个人所有,能够辨别唯一性,所以我们可以把顺序调换一下,变成私钥加密,公钥解密。使用私钥再加上摘要算法,就能够实现数字签名
,从而实现认证。
到现在,综合使用对称加密、非对称加密和摘要算法,我们已经实现了加密、数据认证、认证,那么是不是就安全了呢?非也,这里还存在一个数字签名的认证问题。因为私钥是是自己的,公钥是谁都可以发布,所以必须发布经过认证的公钥,才能解决公钥的信任问题。
所以引入了 CA
,CA 的全称是 Certificate Authority
,证书认证机构,你必须让 CA 颁布具有认证过的公钥,才能解决公钥的信任问题。
全世界具有认证的 CA 就几家,分别颁布了 DV、OV、EV 三种,区别在于可信程度。DV 是最低的,只是域名级别的可信,EV 是最高的,经过了法律和审计的严格核查,可以证明网站拥有者的身份(在浏览器地址栏会显示出公司的名字,例如 Apple、GitHub 的网站)。不同的信任等级的机构一起形成了层级关系。
通常情况下,数字证书的申请人将生成由私钥和公钥以及证书签名请求(CSR)
组成的密钥对。CSR是一个编码的文本文件,其中包含公钥和其他将包含在证书中的信息(例如域名,组织,电子邮件地址等)。密钥对和 CSR生成通常在将要安装证书的服务器上完成,并且 CSR 中包含的信息类型取决于证书的验证级别。与公钥不同,申请人的私钥是安全的,永远不要向 CA(或其他任何人)展示。
生成 CSR 后,申请人将其发送给 CA,CA 会验证其包含的信息是否正确,如果正确,则使用颁发的私钥对证书进行数字签名,然后将其发送给申请人。
总结
本篇文章我们主要讲述了 HTTPS 为什么会出现 ,HTTPS 解决了 HTTP 的什么问题,HTTPS 和 HTTP 的关系是什么,TLS 和 SSL 是什么,TLS 和 SSL 解决了什么问题?如何实现一个真正安全的数据传输?
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 标头中只能出现一个指令,但是在消息中可以出现多个这样的标头。
上面这个表格其实会有四种分类
可缓存性
: 它们分别是no-cache
、no-store
、private
和public
缓存有效性时间
: 它们分别是max-age
、s-maxage
、max-stale
、min-fresh
重新验证并重新加载
: 它们分别是must-revalidate
和proxy-revalidate
其他
: 它们分别是only-if-cached
和no-transform
分别对表格中的内容进行一下详细介绍
**no-cache
no-cache
很容易和 no-store
混淆,一般都会把 no-cache
认为是不缓存,其实不是这样。
使用 no-cache 指令的目的是为了防止从缓存中返回过期的资源,例如下图所示
Cache-Control: no-cache
举个例子你就明白了,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-store
、no_cache
、 must-revalidate
和 max-age
可以一起看,下面是一个这四个标头的流程图
**public
public
属性只出现在客户端响应中,表示响应可以被任何缓存所缓存。在计算机网络中,分为两种缓存,共享缓存和私有缓存,如下所示
Cache-Control: public
**private
当指定 private
指令后,响应只以特定的用户作为对象,这与 public
的用法相反,缓存服务器只对特定的客户端进行缓存,其他客户端发送过来的请求,缓存服务器则不会返回缓存。
Cache-Control: private
**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
表示的是格林威治标准时间,这个时间要比北京时间慢八个小时
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
。如图所示
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 协议及其他协议是否可使用更高的版本进行通信,其参数值可以用来指定一个完全不同的通信协议。
上图用例中,首部字段 Upgrade
指定的值为 TLS/1.0
。请注意此处两个字段首部字段的对应关系,Connection 的值被指定为 Upgrade。
Upgrade 首部字段产生作用的对象仅限于客户端和临近服务器之间。因此,使用首部字段 Upgrade 时,还需要额外指定 Connection: Upgrade
。
对于附有首部字段 Upgrade 的请求,服务器可用 101 Switching Protocols
状态码作为响应返回。
Via
使用 Via 是为了跟踪客户端和服务器之间的请求/响应路径,避免请求循环以及能够识别请求/响应
链中发送者协议的功能。Via 字段由代理服务器添加,不论是正向代理还是反向代理,并且可以出现在请求标头和响应标头中。它用于跟踪消息转发。例如下图所示
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 种警告。它们分别如下
请求标头
请求标头用于客户端发送 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 ,我给你列个表格你就明白了
q | MIME |
---|---|
1.0 | text/html |
1.0 | application/xhtml+xml |
0.9 | application/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/LZ77_and_LZ78#LZ77) -
compress
: 使用Lempel-Ziv-Welch(LZW)
算法的压缩格式,有兴趣的同学可以读 (https://en.wikipedia.org/wiki/LZW) -
deflate
: 使用 zlib 结构和 deflate 压缩算法的压缩格式,参考 (https://en.wikipedia.org/wiki/Zlib) 和 (https://en.wikipedia.org/wiki/DEFLATE) -
br
: 使用 Brotli 算法的压缩格式,参考 (https://en.wikipedia.org/wiki/Brotli) -
不执行压缩或不会变化的默认编码格式
-
*
: 匹配标头中未列出的任何内容编码,如果没有列出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标头响应之后,啥意思呢?你不明白的话我画张图给你看
请求标头 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
对于 GET
和 POST
方法,服务器仅在与列出的 ETag(响应标头)
之一匹配时才返回请求的资源。这里又多了一个新词 ETag
,我们稍后再说 ETag 的用法。对于像是 PUT
和其他非安全的方法,在这种情况下,它仅仅将上传资源。
下面是两种常见的案例
- 对于
GET
和POST
方法,会结合使用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 只能使用 GET
和 HEAD
请求。
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
值不一致时,可处理该请求。对于GET
和 HEAD
,仅当服务器没有与给定资源匹配的 ETag
时,服务器将返回 200 作为响应。对于其他方法,仅当最终现有资源的 ETag 与列出的任何值都不匹配时,才会处理请求。
当 GET
和 POST
发送的 If-None-Match
与 ETag
匹配时,服务器会返回 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
一般用于 TRACE
和 OPTION
方法,发送包含 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 能够防止资源同时更新互相覆盖。
如果给定 URL 上的资源发生变更,必须生成一个新的 ETag
值,通过比较它们可以确定资源的两个表示形式是否相同。
ETag 值有两种,一种是强 ETag,一种是弱 ETag;
- 强 ETag 值,无论实体发生多么细微的变化都会改变其值,一般的表示如下
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
- 弱 ETag 值,弱 ETag 值只用于提示资源是否相同。只有资源发生了根本改变,产生差异时才会改变 ETag 值。这时,会在字段值最开始处附加 W/。
ETag: W/"0815"
Location
Location 响应标头表示 URL 需要重定向页面,它仅仅与 3xx(重定向)
或 201(已创建)
状态响应一起使用。下面是一个页面重定向的过程
使用首部字段 Location 可以将响应接受方引导至某个与请求 URI 位置不同的资源。
Location
和 content-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
这段代码表示服务器允许支持 GET
、POST
和 HEAD
方法。当服务器接收到不支持的 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 header | Response header |
---|---|
Accept: application/json, text/json |
Content-Location: /documents/foo.json |
Accept: application/xml, text/xml |
Content-Location: /documents/foo.xml |
Accept: text/plain, text/* |
Content-Location: /documents/foo.txt |
Content-MD5
客户端会对接收的报文主体执行相同的 MD5 算法,然后与首部字段 Content-MD5 的字段进行比较。
Content-MD5: e10adc3949ba59abbe56e057f20f883e
首部字段 Content-MD5 是一串由 MD5 算法生成的值,其目的在于检查报文主体在传输过程中是否保持完整,有无被修改的情况,以及确认传输到达。
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-Since
或 If-Unmodified-Since
标头的条件请求将使用此字段。它的一般表示如下
Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
总结
本篇文章主要介绍了 HTTP 四种标头的基本概念,但是并没有涵盖全部,毕竟 HTTP 标头内容确实太多了,以上介绍的基本都是平常工作中常用的一些概念,下一篇文章预告 **HTTP 的黑科技
HTTP进阶
这是 HTTP 系列的第三篇文章,此篇文章为 HTTP 的进阶文章。
在前面两篇文章中我们讲述了 HTTP 的入门,HTTP 所有常用标头的概述,这篇文章我们来聊一下 HTTP 的一些 黑科技
。
HTTP 内容协商
什么是内容协商
在 HTTP 中,内容协商
是一种用于在同一 URL 上提供资源的不同表示形式的机制。内容协商机制是指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言、字符集、编码方式等作为判断的标准。
内容协商的种类
内容协商主要有以下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 ,我给你列个表格你就明白了
q | MIME |
---|---|
1.0 | text/html |
1.0 | application/xhtml+xml |
0.9 | application/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/LZ77_and_LZ78#LZ77) -
compress
: 使用Lempel-Ziv-Welch(LZW)
算法的压缩格式,有兴趣的同学可以读 (https://en.wikipedia.org/wiki/LZW) -
deflate
: 使用 zlib 结构和 deflate 压缩算法的压缩格式,参考 (https://en.wikipedia.org/wiki/Zlib) 和 (https://en.wikipedia.org/wiki/DEFLATE) -
br
: 使用 Brotli 算法的压缩格式,参考 (https://en.wikipedia.org/wiki/Brotli) -
不执行压缩或不会变化的默认编码格式
-
*
: 匹配标头中未列出的任何内容编码,如果没有列出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 来表示。
HTTP 认证
HTTP 提供了用于访问控制和身份认证的功能,下面就对 HTTP 的权限和认证功能进行介绍
通用 HTTP 认证框架
RFC 7235 定义了 HTTP 身份认证框架,服务器可以根据其文档的定义来检查客户端请求。客户端也可以根据其文档定义来提供身份验证信息。
请求/响应的工作流程如下:服务器以401(未授权)
的状态响应客户端告诉客户端服务器需要认证信息,客户端提供至少一个 www-Authenticate
的响应标头进行授权信息的认证。想要通过服务器进行身份认证的客户端可以在请求标头字段中添加认证标头进行身份认证,一般的认证过程如下
首先客户端发起一个 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
下面是代理服务器的请求/响应认证过程
这个过程和通用的过程类似,我们就不再详细展开描述了。
禁止访问
如果代理服务器
收到的有效凭据不足以获取对给定资源的访问权限,则服务器应使用403 Forbidden
状态代码进行响应。与 401 Unauthorized
和 407 Proxy Authorization Required
不同,该用户无法进行身份验证。
WWW-Authenticate 和 Proxy-Authenticate 头
WWW-Authenticate
和 Proxy-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/rfc7617)
其他的认证协议主要有
认证协议 | 参考来源 |
---|---|
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
标头代表不缓存过期资源。
no-cache 属于 Cache-Control 通用标头,其一般的表示方法如下
Cache-Control: no-cache
也可以使用 max-age = 0
来实现不缓存的效果。
Cache-Control: max-age=0
私有缓存
私有缓存只用来缓存单个用户,你可能在浏览器设置中看到了 缓存
,浏览器缓存包含服务器通过 HTTP 下载下来的所有文档。这个高速缓存用于使访问的文档可以进行前进/后退,保存操作而无需重新发送请求到源服务器。
可以使用 private
来实现私有缓存,这与 public
的用法相反,缓存服务器只对特定的客户端进行缓存,其他客户端发送过来的请求,缓存服务器则不会返回缓存。它的一般表示方法如下
Cache-Control: private
共享缓存
共享缓存是一种用于存储要由多个用户重用的响应缓存。共享缓存一般使用 public
来表示,public
属性只出现在客户端响应中,表示响应可以被任何缓存所缓存。一般表示方法如下
Cache-Control: public
缓存控制
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
下面是一个缓存验证图
什么是新鲜的数据
一旦资源存储在缓存中,理论上就可以永远被缓存使用。但是不管是浏览器缓存还是代理缓存,其存储空间是有限的,所以缓存会定期进行清除,这个过程叫做 缓存回收(cache eviction)
(自译)。另一方面,服务器上的缓存也会定期进行更新,HTTP 作为应用层的协议,它是一种客户-服务器
模式,HTTP 是无状态的协议,因此当资源发生更改时,服务器无法通知缓存和客户端。因此服务器必须通过某种方式告知客户端缓存已经被更新。服务器会提供过期时间
这个概念,告知客户端在此到期时间之前,资源是新鲜的
,也就是未更改过的。在此到期时间的范围之外,资源已过时。过期算法(Eviction algorithms)
通常会将新资源优先于陈旧资源使用。
这里需要注意一下,过期的资源并不会被回收或忽略,当高速缓存接收到过期资源时,它会使用 If-None-Match
转发此请求,以检查它是否仍然有效。如果有效,服务器会返回 304 Not Modified
响应头并且没有任何响应体,从而节省了一些带宽。
下面是使用共享缓存代理的过程
这个图应该比较好理解,只说一下 Age 的作用,Age 是 HTTP 响应标头告诉客户端源服务器在多久之前创建了响应,它的单位为秒
,Age 标头通常接近于0,如果是0则可能是从源服务器获取的,如果不是表示可能是由代理服务器创建,那么 Age 的值表示的是缓存后的响应再次发起认证到认证完成的时间值。
缓存的有效性是由多个标头来共同决定的,而并非某一个标头来决定。如果指定了 Cache-control:max-age=N
,那么缓存会保存 N 秒。如果这个通用标头不存在的话,则会检查是否存在 Expires
标头。如果 Exprires 标头存在,那么它的值减去 Date 标头的值就可以确定其有效性。最后,如果max-age
和 expires
都不存在,就去寻找 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.com
和 example.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
URL | Outcome | Reason |
---|---|---|
http://store.company.com/dir2/other.html | 相同来源 | 只有path不同 |
http://store.company.com/dir/inner/another.html | 相同来源 | 只有path不同 |
https://store.company.com/page.html | 不同来源 | 协议不通 |
http://store.company.com:81/dir/page.html | 不同来源 | 默认端口不同 |
http://news.company.com/dir/page.html | 不同来源 | 主机不同 |
现在我带你认识了两遍不同的源,现在你应该知道如何区分两个 URL 是否属于同一来源了吧!
好,你现在知道了什么是跨域问题,现在我要问你,哪些请求会产生跨域请求呢?这是我们下面要讨论的问题
跨域请求
跨域请求可能会从下面这几种请求中发出:
- 调用
XMLHttpRequest
或者Fetch
api。
XMLHttpRequest 是什么?(我是后端程序员,前端不太懂,简单解释下,如果解释的不好,还请前端大佬们不要胖揍我)
所有的现代浏览器都有一个内置的 XMLHttpReqeust
对象,这个对象可以用于从服务器请求数据。
XMLHttpReqeust 对于开发人员来说很重要,XMLHttpReqeust 对象可以用来做下面这些事情
- 更新网页无需重新刷新页面
- 页面加载后从服务器请求数据
- 页面加载后从服务端获取数据
- 在后台将数据发送到服务器
使用 XMLHttpRequest(XHR) 对象与服务器进行交互,你可以从 URL 检索数据从而不必刷新整个页面,这使网页可以更新页面的一部分,而不会中断用户的操作。XMLHttpRequest 在 AJAX
异步编程中使用很广泛。
再来说一下 Fetch API 是什么,Fetch 提供了请求和响应对象(以及其他网络请求)的通用定义。它还提供了相关概念的定义,例如 CORS 和 HTTP Origin 头语义,并在其他地方取代了它们各自的定义。
- Web 字体(用于 CSS 中@ font-face中的跨域字体使用),以便服务器可以部署 TrueType 字体,这些字体只能由允许跨站点加载和使用的网站使用。
- WebGL 纹理
- 使用
drawImage()
绘制到画布上的图像/视频帧 - 图片的 CSS 形状
跨域功能概述
跨域资源共享标准通过添加新的 HTTP 标头来工作,这些标头允许服务器描述允许哪些来源从 Web 浏览器读取信息。另外,对于可能导致服务器数据产生副作用的 HTTP 请求方法(尤其是 GET 或者具有某些 MIME 类型 POST 方法以外 HTTP 方法),该规范要求浏览器预检
请求,使用 HTTP OPTIONS 请求方法从服务器请求受支持的方法,然后在服务器批准
后发送实际请求。服务器还可以通知客户端是否应与请求一起发送凭据
(例如 Cookies 和 HTTP 身份验证)。
注意:CORS 故障会导致错误,但是出于安全原因,该错误的详细信息不适用于 JavaScript。 所有代码都知道发生了错误。 确定具体出问题的唯一方法是查看浏览器的控制台以获取详细信息。
访问控制
下面我会和大家探讨三种方案,这些方案都演示了跨域资源共享的工作方式。所有这些示例都使用XMLHttpRequest,它可以在任何支持的浏览器中发出跨站点请求。
简单请求
一些请求不会触发 CORS预检
(关于预检我们后面再介绍)。简单请求
是满足一下所有条件的请求
-
允许以下的方法:
GET
、HEAD
和POST
-
除了由用户代理自动设置的标头(例如 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 标头来处理特权,从而在客户端和服务器之间执行某种转换。
让我们看看在这种情况下浏览器将发送到服务器的内容,并让我们看看服务器如何响应:
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
,并且设置了自定义标头,因此该请求被预检
。如下图所示
如下所述,实际的 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 页面的内容。
上面的请求用下图可以表示
这是客户端和服务器之间的示例交换:
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/en-US/docs/Web/API/Request)
当发送的是凭证模式的请求包含 (Request.credentials)时,如果 Access-Control-Allow-Credentials 值为 true,浏览器将仅向前端 JavaScript 代码公开响应。
Access-Control-Allow-Credentials: true
凭证一般包括 **cookie、认证头和 TLS 客户端证书
当用作对预检请求响应的一部分时,这表明是否可以使用凭据发出实际请求。注意简单的
GET
请求不会进行预检。
可以参考一个实际的例子 https://www.jianshu.com/p/ea485e5665b3
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 能够防止资源同时更新互相覆盖。
如果给定 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
对于 GET
和 POST
方法,服务器仅在与列出的 Etag(响应标头)
之一匹配时才返回请求的资源。这里又多了一个新词 Etag
,我们稍后再说 Etag 的用法。对于像是 PUT
和其他非安全的方法,在这种情况下,它仅仅将上传资源。
下面是两种常见的案例
- 对于
GET
和POST
方法,会结合使用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
值不一致时,可处理该请求。对于GET
和 HEAD
,仅当服务器没有与给定资源匹配的 Etag
时,服务器将返回 200 OK
作为响应。对于其他方法,仅当最终现有资源的 Etag 与列出的任何值都不匹配时,才会处理请求。
当 GET
和 POST
发送的 If-None-Match
与 Etag
匹配时,服务器会返回 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 只能使用 GET
和 HEAD
请求。
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
的状态发送回请求的资源。如下图所示
客户端第一次发送请求没有,缓存为空并且没有条件请求,服务器在收到客户端请求后,设置验证器 Last-Modified
和 Etag
标签,并把这两个标签随着响应一起发送回客户端。
下一次客户端再发送相同的请求后,会直接从缓存中提取,只要缓存没有过期,就不会有任何新的请求到达服务器重新下载资源。但是,一旦缓存过期,客户端不会直接使用缓存的值,而是发出条件请求。 验证器的值用作 If-Modified-Since
和 If-Match
标头的参数。
缓存过期后客户端重新发起请求,服务器收到请求后发现如果资源没有更改,服务器会发回 304 Not Modified
响应,这使缓存再次刷新,并让客户端使用缓存的资源。 尽管有一个响应/请求往返消耗一些资源,但是这比再次通过有线传输整个资源更有效。
如果资源已经发生更改,则服务器仅使用新版本的资源返回 200 OK 响应,就像没有条件请求,并且客户端会重新使用新的资源,从这个角度来讲,缓存是条件请求的前置条件。
断点续传
HTTP 可以支持文件的部分下载,通过保留已获得的信息,此功能允许恢复先前的操作,从而节省带宽和时间。
支持断点续传的服务器通过发送 Accept-Ranges
标头广播此消息,一旦发生这种情况,客户端可以通过发送缺少范围的 Ranges
标头来恢复下载
这里你可能有疑问 Ranges
和 Content-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-Since
和If-Match
,如果前提条件失败,服务器将返回错误;然后客户端从头开始重新下载。
即使此方法有效,当文档资源发生改变时,它也会添加额外的 响应/请求
交换。这会降低性能,并且 HTTP 具有特定的标头来避免这种情况 If-Range
。
该解决方案效率更高,但灵活性稍差一些,因为在这种情况下只能使用一个 Etag。
通过乐观锁避免丢失更新
Web 应用程序中最普遍的操作是资源更新。这在任何文件系统或应用程序中都很常见,但是任何允许存储远程资源的应用程序都需要这种机制。
使用 put
方法,你可以实现这一点,客户端首先读取原始文件对其进行修改,然后把它们发送到服务器。
上面这种请求响应存在问题,一旦考虑到并发性,事情就会变得不准确。当客户端在本地修改资源打算重新发送之前,第二个客户端可以获取相同的资源并对资源进行修改操作,这样就会造成问题。当它们重新发送请求到服务器时,第一个客户端所做的修改将被第二次客户端的修改所覆盖,因为第二次客户端修改并不知道第一次客户端正在修改。资源提交并更新的一方不会传达给另外一方,所以要保留哪个客户的更改,将随着他们提交的速度而变化; 这取决于客户端,服务器的性能,甚至取决于人工在客户端编辑文档的性能。 例如下面这个流程
如果没有两个用户同时操作服务器,也就不存在这个问题。但是,现实情况是不可能只有单个用户出现的,所以为了规避或者避免这个问题,我们希望客户端资源在更新时进行提示或者修改被拒绝时收到通知。
条件请求允许实现乐观锁算法。这个概念是允许所有的客户端获取资源的副本,然后让他们在本地修改资源,并成功通过允许第一个客户端提交更新来控制并发,基于此服务端的后面版本的更新都将被拒绝。
这是使用 If-Match
或 If-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
、永久Cookie
和 Cookie的 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 的作用域
Domain
和 Path
标识定义了 Cookie 的作用域:即 Cookie 应该发送给哪些 URL。
Domain
标识指定了哪些主机可以接受 Cookie。如果不指定,默认为当前主机(不包含子域名)。如果指定了Domain
,则一般包含子域名。
例如,如果设置 Domain=mozilla.org
,则 Cookie 也包含在子域名中(如developer.mozilla.org
)。
例如,设置 Path=/docs
,则以下地址都会匹配:
/docs
/docs/Web/
/docs/Web/HTTP
Cooke、Session和Token
Cookie 和 Session
HTTP 协议是一种无状态协议
,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;Session 和 Cookie 的主要目的就是为了弥补 HTTP 的无状态特性。
Session 是什么
客户端请求服务端,服务端会为这次请求开辟一块内存空间
,这个对象便是 Session 对象,存储结构为 ConcurrentHashMap
。Session 弥补了 HTTP 无状态特性,服务器可以利用 Session 存储客户端在同一个会话期间的一些操作记录。
Session 如何判断是否是同一会话
服务器第一次接收到请求时,开辟了一块 Session 空间(创建了Session对象),同时生成一个 sessionId ,并通过响应头的 Set-Cookie:JSESSIONID=XXXXXXX 命令,向客户端发送要求设置 Cookie 的响应; 客户端收到响应后,在本机客户端设置了一个 JSESSIONID=XXXXXXX 的 Cookie 信息,该 Cookie 的过期时间为浏览器会话结束;
接下来客户端每次向同一个网站发送请求时,请求头都会带上该 Cookie信息(包含 sessionId ), 然后,服务器通过读取请求头中的 Cookie 信息,获取名称为 JSESSIONID 的值,得到此次请求的 sessionId。
Session 的缺点
Session 机制有个缺点,比如 A 服务器存储了 Session,就是做了负载均衡后,假如一段时间内 A 的访问量激增,会转发到 B 进行访问,但是 B 服务器并没有存储 A 的 Session,会导致 Session 的失效。
Cookies 是什么
HTTP 协议中的 Cookie 包括 Web Cookie
和浏览器 Cookie
,它是服务器发送到 Web 浏览器的一小块数据。服务器发送到浏览器的 Cookie,浏览器会进行存储,并与下一个请求一起发送到服务器。通常,它用于判断两个请求是否来自于同一个浏览器,例如用户保持登录状态。
HTTP Cookie 机制是 HTTP 协议无状态的一种补充和改良
Cookie 主要用于下面三个目的
会话管理
登陆、购物车、游戏得分或者服务器应该记住的其他内容
个性化
用户偏好、主题或者其他设置
追踪
记录和分析用户行为
Cookie 曾经用于一般的客户端存储。虽然这是合法的,因为它们是在客户端上存储数据的唯一方法,但如今建议使用现代存储 API。Cookie 随每个请求一起发送,因此它们可能会降低性能(尤其是对于移动数据连接而言)。
创建 Cookie
当接收到客户端发出的 HTTP 请求时,服务器可以发送带有响应的 Set-Cookie
标头,Cookie 通常由浏览器存储,然后将 Cookie 与 HTTP 标头一同向服务器发出请求。
Set-Cookie 和 Cookie 标头
Set-Cookie
HTTP 响应标头将 cookie 从服务器发送到用户代理。下面是一个发送 Cookie 的例子
此标头告诉客户端存储 Cookie
现在,随着对服务器的每个新请求,浏览器将使用 Cookie 头将所有以前存储的 Cookie 发送回服务器。
有两种类型的 Cookies,一种是 Session Cookies,一种是 Persistent Cookies,如果 Cookie 不包含到期日期,则将其视为会话 Cookie。会话 Cookie 存储在内存中,永远不会写入磁盘,当浏览器关闭时,此后 Cookie 将永久丢失。如果 Cookie 包含有效期
,则将其视为持久性 Cookie。在到期指定的日期,Cookie 将从磁盘中删除。
还有一种是 Cookie的 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 的作用域
Domain
和 Path
标识定义了 Cookie 的作用域:即 Cookie 应该发送给哪些 URL。
Domain
标识指定了哪些主机可以接受 Cookie。如果不指定,默认为当前主机(不包含子域名)。如果指定了Domain
,则一般包含子域名。
例如,如果设置 Domain=mozilla.org
,则 Cookie 也包含在子域名中(如developer.mozilla.org
)。
例如,设置 Path=/docs
,则以下地址都会匹配:
/docs
/docs/Web/
/docs/Web/HTTP
JSON Web Token 和 Session Cookies 的对比
JSON Web Token ,简称 JWT
,它和 Session
都可以为网站提供用户的身份认证,但是它们不是一回事。
下面是 JWT 和 Session 不同之处的研究
JWT 和 Session Cookies 的相同之处
在探讨 JWT 和 Session Cookies 之前,有必要需要先去理解一下它们的相同之处。
它们既可以对用户进行身份验证,也可以用来在用户单击进入不同页面时以及登陆网站或应用程序后进行身份验证。
如果没有这两者,那你可能需要在每个页面切换时都需要进行登录了。因为 HTTP 是一个无状态的协议。这也就意味着当你访问某个网页,然后单击同一站点上的另一个页面时,服务器的内存中
将不会记住你之前的操作。
因此,如果你登录并访问了你有权访问的另一个页面,由于 HTTP 不会记录你刚刚登录的信息,因此你将再次登录。
JWT 和 Session Cookies 就是用来处理在不同页面之间切换,保存用户登录信息的机制。
也就是说,这两种技术都是用来保存你的登录状态,能够让你在浏览任意受密码保护的网站。通过在每次产生新的请求时对用户数据进行身份验证来解决此问题。
所以 JWT 和 Session Cookies 的相同之处是什么?那就是它们能够支持你在发送不同请求之间,记录并验证你的登录状态的一种机制。
什么是 Session Cookies
Session Cookies 也称为会话 Cookies
,在 Session Cookies 中,用户的登录状态会保存在服务器
的内存
中。当用户登录时,Session 就被服务端安全的创建。
在每次请求时,服务器都会从会话 Cookie 中读取 SessionId,如果服务端的数据和读取的 SessionId 相同,那么服务器就会发送响应给浏览器,允许用户登录。
什么是 Json Web Tokens
Json Web Token 的简称就是 JWT,通常可以称为 Json 令牌
。它是RFC 7519
中定义的用于安全的
将信息作为 Json 对象
进行传输的一种形式。JWT 中存储的信息是经过数字签名
的,因此可以被信任和理解。可以使用 HMAC 算法或使用 RSA/ECDSA 的公用/专用密钥对 JWT 进行签名。
使用 JWT 主要用来下面两点
认证(Authorization)
:这是使用 JWT 最常见的一种情况,一旦用户登录,后面每个请求都会包含 JWT,从而允许用户访问该令牌所允许的路由、服务和资源。单点登录
是当今广泛使用 JWT 的一项功能,因为它的开销很小。信息交换(Information Exchange)
:JWT 是能够安全传输信息的一种方式。通过使用公钥/私钥对 JWT 进行签名认证。此外,由于签名是使用head
和payload
计算的,因此你还可以验证内容是否遭到篡改。
JWT 的格式
下面,我们会探讨一下 JWT 的组成和格式是什么
JWT 主要由三部分组成,每个部分用 .
进行分割,各个部分分别是
Header
Payload
Signature
因此,一个非常简单的 JWT 组成会是下面这样
然后我们分别对不同的部分进行探讨。
**Header
Header 是 JWT 的标头,它通常由两部分组成:令牌的类型(即 JWT)
和使用的 签名算法
,例如 HMAC SHA256 或 RSA。
例如
{
"alg": "HS256",
"typ": "JWT"
}
指定类型和签名算法后,Json 块被 Base64Url
编码形成 JWT 的第一部分。
**Payload
Token 的第二部分是 Payload
,Payload 中包含一个声明。声明是有关实体(通常是用户)和其他数据的声明。共有三种类型的声明:registered, public 和 private 声明。
registered 声明
: 包含一组建议使用的预定义声明,主要包括
ISS | 签发人 |
---|---|
iss (issuer) | 签发人 |
exp (expiration time) | 过期时间 |
sub (subject) | 主题 |
aud (audience) | 受众 |
nbf (Not Before) | 生效时间 |
iat (Issued At) | 签发时间 |
jti (JWT ID) | 编号 |
public 声明
:公共的声明,可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。private 声明
:自定义声明,旨在在同意使用它们的各方之间共享信息,既不是注册声明也不是公共声明。
例如
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后 payload Json 块会被Base64Url
编码形成 JWT 的第二部分。
**signature
JWT 的第三部分是一个签证信息,这个签证信息由三部分组成
- header (base64后的)
- payload (base64后的)
- secret
比如我们需要 HMAC SHA256 算法进行签名
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
签名用于验证消息在此过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证 JWT 的发送者的真实身份
拼凑在一起
现在我们把上面的三个由点分隔的 Base64-URL 字符串部分组成在一起,这个字符串可以在 HTML 和 HTTP 环境中轻松传递这些字符串。
下面是一个完整的 JWT 示例,它对 header 和 payload 进行编码,然后使用 signature 进行签名
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
如果想自己测试编写的话,可以访问 JWT 官网 https://jwt.io/#debugger-io
JWT 和 Session Cookies 的不同
JWT 和 Session Cookies 都提供安全的用户身份验证,但是它们有以下几点不同
密码签名
JWT 具有加密签名,而 Session Cookies 则没有。
JSON 是无状态的
JWT 是无状态
的,因为声明被存储在客户端
,而不是服务端内存中。
身份验证可以在本地
进行,而不是在请求必须通过服务器数据库或类似位置中进行。 这意味着可以对用户进行多次身份验证,而无需与站点或应用程序的数据库进行通信,也无需在此过程中消耗大量资源。
可扩展性
Session Cookies 是存储在服务器内存中,这就意味着如果网站或者应用很大的情况下会耗费大量的资源。由于 JWT 是无状态的,在许多情况下,它们可以节省服务器资源。因此 JWT 要比 Session Cookies 具有更强的可扩展性
。
JWT 支持跨域认证
Session Cookies 只能用在单个节点的域
或者它的子域
中有效。如果它们尝试通过第三个节点访问,就会被禁止。如果你希望自己的网站和其他站点建立安全连接时,这是一个问题。
使用 JWT 可以解决这个问题,使用 JWT 能够通过多个节点
进行用户认证,也就是我们常说的跨域认证
。
JWT 和 Session Cookies 的选型
我们上面探讨了 JWT 和 Cookies 的不同点,相信你也会对选型有了更深的认识,大致来说
对于只需要登录用户并访问存储在站点数据库中的一些信息的中小型网站来说,Session Cookies 通常就能满足。
如果你有企业级站点,应用程序或附近的站点,并且需要处理大量的请求,尤其是第三方或很多第三方(包括位于不同域的API),则 JWT 显然更适合。
后记
前两天面试的时候问到了这个题,所以写篇文章总结一下,还问到了一个面试题,禁用 Cookies,如何使用 Session ?网上百度了一下,发现这是 PHP 的面试题……
但还是选择了解了一下,如何禁用 Cookies 后,使用 Session
- 如果禁用了 Cookies,服务器仍会将 sessionId 以 cookie 的方式发送给浏览器,但是,浏览器不再保存这个cookie (即sessionId) 了。
- 如果想要继续使用 session,需要采用
URL 重写
的方式来实现,可以参考 https://www.cnblogs.com/Renyi-Fan/p/11012086.html
带你一步步解析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),它们之间的关系如下
按照范围的大小 协议 > 传输 > 超文本。下面就分别对这三个名次做一个解释。
什么是超文本
在互联网早期的时候,我们输入的信息只能保存在本地,无法和其他电脑进行交互。我们保存的信息通常都以文本
即简单字符的形式存在,文本是一种能够被计算机解析的有意义的二进制数据包。而随着互联网的高速发展,两台电脑之间能够进行数据的传输后,人们不满足只能在两台电脑之间传输文字,还想要传输图片、音频、视频,甚至点击文字或图片能够进行超链接
的跳转,那么文本的语义就被扩大了,这种语义扩大后的文本就被称为超文本(Hypertext)
。
什么是传输
那么我们上面说到,两台计算机之间会形成互联关系进行通信,我们存储的超文本会被解析成为二进制数据包,由传输载体(例如同轴电缆,电话线,光缆)负责把二进制数据包由计算机终端传输到另一个终端的过程(对终端的详细解释可以参考 你说你懂互联网,那这些你知道么?这篇文章)称为传输(transfer)
。
通常我们把传输数据包的一方称为请求方
,把接到二进制数据包的一方称为应答方
。请求方和应答方可以进行互换,请求方也可以作为应答方接受数据,应答方也可以作为请求方请求数据,它们之间的关系如下
如图所示,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)
物理层
虽然链路层的作用是将帧从一个端系统运输到另一个端系统,而物理层的作用是将帧中的一个个 比特
从一个节点运输到另一个节点,物理层的协议仍然使用链路层协议,这些协议与实际的物理传输介质有关,例如,以太网有很多物理层协议:关于双绞铜线、关于同轴电缆、关于光纤等等。
五层网络协议的示意图如下
OSI 模型
我们上面讨论的计算网络协议模型不是唯一的 协议栈
,ISO(国际标准化组织)提出来计算机网络应该按照7层来组织,那么7层网络协议栈与5层的区别在哪里?
从图中可以一眼看出,OSI 要比上面的网络模型多了 表示层
和 会话层
,其他层基本一致。表示层主要包括数据压缩和数据加密以及数据描述,数据描述使得应用程序不必担心计算机内部存储格式的问题,而会话层提供了数据交换的定界和同步功能,包括建立检查点和恢复方案。
浏览器
就如同各大邮箱使用电子邮件传送协议 SMTP
一样,浏览器是使用 HTTP 协议的主要载体,说到浏览器,你能想起来几种?是的,随着网景大战结束后,浏览器迅速发展,至今已经出现过的浏览器主要有
浏览器正式的名字叫做 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(统一资源名称),它们之间的关系如下
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),如果用一幅图来表示一下的话,我觉得应该是下面这样
我们使用上面的那个例子来看一下 http 的请求报文
如图,这是 http://www.someSchool.edu/someDepartment/home.index
请求的请求头,通过观察这个 HTTP 报文我们就能够学到很多东西,首先,我们看到报文是用普通 ASCII
文本书写的,这样保证人能够可以看懂。然后,我们可以看到每一行和下一行之间都会有换行,而且最后一行(请求头部后)再加上一个回车换行符。
每个报文的起始行都是由三个字段组成:方法、URL 字段和 HTTP 版本字段。
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 支持的方法清单
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
http://
告诉浏览器使用何种协议。对于大部分 Web 资源,通常使用 HTTP 协议或其安全版本,HTTPS 协议。另外,浏览器也知道如何处理其他协议。例如, mailto:
协议指示浏览器打开邮件客户端;ftp:
协议指示浏览器处理文件传输。
第二个出场的是 主机
www.example.com
既是一个域名,也代表管理该域名的机构。它指示了需要向网络上的哪一台主机发起请求。当然,也可以直接向主机的 IP address 地址发起请求。但直接使用 IP 地址的场景并不常见。
第三个出场的是 端口
我们前面说到,两个主机之间要发起 TCP 连接需要两个条件,主机 + 端口。它表示用于访问 Web 服务器上资源的入口。如果访问的该 Web 服务器使用HTTP协议的标准端口(HTTP为80,HTTPS为443)授予对其资源的访问权限,则通常省略此部分。否则端口就是 URI 必须的部分。
上面是请求 URL 所必须包含的部分,下面就是 URL 具体请求资源路径
第四个出场的是 路径
/path/to/myfile.html
是 Web 服务器上资源的路径。以端口后面的第一个 /
开始,到 ?
号之前结束,中间的 每一个/
都代表了层级(上下级)关系。这个 URL 的请求资源是一个 html 页面。
紧跟着路径后面的是 查询参数
?key1=value1&key2=value2
是提供给 Web 服务器的额外参数。如果是 GET 请求,一般带有请求 URL 参数,如果是 POST 请求,则不会在路径后面直接加参数。这些参数是用 & 符号分隔的键/值对
列表。key1 = value1 是第一对,key2 = value2 是第二对参数
紧跟着参数的是锚点
#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/zh-CN/docs/Web/HTTP/Headers MDN 官网学习)
HTTP 的请求标头分为四种: 通用标头
、请求标头
、响应标头
和 实体标头
,依次来进行详解。
通用标头
通用标头主要有三个,分别是 Date
、Cache-Control
和 Connection
**Date
Date 是一个通用标头,它可以出现在请求标头和响应标头中,它的基本表示如下
Date: Wed, 21 Oct 2015 07:28:00 GMT
表示的是格林威治标准时间,这个时间要比北京时间慢八个小时
**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 其他通用标头如下
实体标头
实体标头是描述消息正文内容的 HTTP 标头。实体标头用于 HTTP 请求和响应中。头部Content-Length
、 Content-Language
、 Content-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 //响应头
下面是一些实体标头字段
请求标头
上面给出的例子请求报文的属性比较少,下面给出一个 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
上面的 Accpet
、 Accept-Language
、Accept-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 属于响应标头,后面进行介绍。
内容协商
内容协商机制是指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言、字符集、编码方式等作为判断的标准。
内容协商主要有以下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 ,我给你列个表格你就明白了
q | MIME |
---|---|
1.0 | text/html |
1.0 | application/xhtml+xml |
0.9 | application/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
响应标头
响应标头是可以在 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
非 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 的性能不算差,但不完全适应现在的互联网,还有很大的提升空间。
面经
最开始是打算面试外包公司刷经验,等经验差不多了,再去甲方尝试,可惜不太顺利。
一、迈思(面试了30分钟)
-
自我介绍
-
左连接(a 表左连接 b 表,a 表全部数据出来,b 表没有的数据为空)
-
a 表左连接 b 表,b 表左连接 c 表,c 表左连接 a 表,这样的数据是什么数据(博主当时有点懵,回答全连接,全部数据会出来。。。)
-
sql:一个班的学生有两个字段,一个字段叫分数,另外一个字段叫组名,有 4 个组,查出每个组的第一名,sql 怎么写
select group_name,max(score) from table group by group_name order by group_name
5.数据库引擎有哪些(myIsam,InnoDB 等等)
-
myIsam 和 Inno DB的区别(InnoDB 支持事务,外键,崩溃后恢复,InnoDB 行级锁,myIsam 表级锁)
-
myIsam 的优点(博主当时只记住了 InnoDB 的优点,没想到面试官问到了 myIsam 的优点,速度快,磁盘空间占用少)
-
Spring 的两个特性(IOC 和 aop,这两个特性用到了哪些设计模式)
-
java 的容器,集合(老生常谈了,list, set, map 等等,另外说一下有哪些实现类)
-
hashmap 的实现(数组+链表+红黑树)
-
put 一个 key 和 value,怎么确定数组的下标,如果有两个key put到同个位置,怎么做?(根据key计算hash值,根据hash确定下标等等)
-
HashMap 是线程安全的吗?有哪些实现?(不是,线程安全的可以用hashtable,concurrentHashMap等等)
-
concurrentHashMap 是怎么实现线程安全的?具体的实现?两个线程同时 put 两个 key 是怎么做的?
(1.7数组+链表,分段锁,1.8数组+链表+红黑树,cas+synchronized)
- java 集合的排序(stream 中的 sort),内部是怎么实现的?原理是什么?了解过哪些排序?
(Comparable和Comparator 参考链接)
-
java 集合的分组(groupingby (对象::属性))
-
函数式方法(接口),什么条件下才能用?这种适用于所有的吗?还是说有一定的限制?
-
后台的请求比较慢,一般是什么原因造成的?后台请求直接卡了,怎么排查,日志没报错呢?(查一下慢sql,需要大量运算)
-
怎么查锁日志,线程日志?(这个不知道)
-
常见线程池,这些都是什么样的线程池?(newCachedThreadPool,newFixedThreadPool,newScheduledThreadPool,newSingleThreadExecutor,现在有6种线程池了,没记那么多)
-
java 锁,并简单说一下锁,类名,关键字,锁的实现等等(ReentrantLock,迷迷糊糊说了一些,毕竟记得不多)
-
谷歌 guava 缓存用过吗?(没用过)
-
项目有哪些难的实现,你是怎么做的?(根据自己的情况去回答)
二、亲邻科技(甲方)
过去面试的时候,是星期五晚上8点多了
一面:hr(不到10分钟)
简单自我介绍,对技术,薪资有要求什么的,跳槽原因,评价自己,公司加班挺多的,问能不能接受
二面:技术(不到20分钟,答的有点差)
-
对称加密和非对称加密的区别
-
跨域脚本攻击(还有好几个没听过)
-
数据库隔离级别(读未提交,读已提交,可重复读,串行化)
-
不可重复读和幻读是什么?(a重复读同个数据,b修改数据,a再次读,就是不可重复读;a修改某些数据,b从中插入一条数据,a会发现还有一条数据没修改,那么就是幻读)
-
死锁是什么?产生原因?怎么解决(竞争同一资源,四个条件,破坏四个条件,这一块没答好)
-
服务器 cpu 百分百,怎么排查(ps 查看进程,答的不是很好,毕竟这方面没研究。虽然公司也遇到了 cpu 百分百的情况,但是解决办法是把需要大量运算的 mrp 功能给禁掉,后面加内存,加服务器。这一块我可不敢实话实说)
-
nginx 可以用来做什么(负载均衡,反向代理,面试官还继续问还有呢,我不知道什么了,我说可以拦截 ip 等等)
-
cas 是什么?(乐观锁的一种实现,会造成aba问题,加版本号或者时间戳)
-
redis 持久化机制,rdb 和 aof 的优缺点(全量数据备份,安全性低,备份间隔时间长;增量数据备份,数据安全,文件大)
-
spring 异步注解(没用过)
-
讲一讲类加载机制
-
内存泄露和内存溢出的区别
-
还问了其他一些题目,不太记得了
-
讲一讲项目,你是怎么做的
三、奥萨医药(甲方,面试了35分钟左右)
**看到一个小姐姐,问路,好漂亮
进去之后,填资料,填完资料,就问问题
-
mysql 的常用引擎,区别(InnoDB:支持事务,外键,行锁,支持崩溃后恢复,面试官还问为什么能恢复。Myisam: 表锁,全文索引)
-
sql 有哪些优化,常用索引(查询哪些字段,不要用*, null, like后面的"% 等等)
-
sql 语句执行的过程是怎么样的?怎么分析 explain
-
假如有 100w 数据,我想要第 60 万行之后的数据,怎么优化(用limit,加索引,面试官太变态了,问加了这些还很慢,怎么办)
-
redis 是什么,为什么比 mongodb 热门?(非关系型数据库,基于内存等等,为什么热门,这个我就说不知道了,因为不知道 mongodb)
-
什么是非关系型数据库,与关系型数据库的区别?(没回答好)
-
除了基于内存,读取速度快,还有哪些原因让 redis 快?(没回答好)
-
常用的数据类型,应用场景(String,List,Set,Zset,Hash,面试官还问为什么,额,简直是抽丝剥茧呢)
-
持久化机制,rdb 和 aof 的区别,你会怎么选择哪种机制(全量和增量,备份时间长短,数据安全等等,现在有混合机制)
-
淘汰策略有哪些(答到过期策略那里去了,面试官提醒了)
-
redis 为什么可以对 String 进行自增自减运算(这个不知道)
-
redis 怎么进行优化?(不知道)
-
java 常用的集合有哪些?(list,set,map,实现类)
-
arrayList 和 linkedList 的区别(数据结构,读取和增删速度,线程安全copyonwriteArrayList)
-
我想要插入几十万数据到 arrayList,有什么优化方法?(这个还真的不知道了)
-
hashmap 底层结构,put的过程?为什么要加红黑数?结构全部用红黑树可以吗?(数组+链表+红黑树,根据 key 计算 hash,根据 hash 计算下标,下标为 null 就赋值,不为 null,就遍历判断 hashcode 相等等等,1.7用头插,1.8用尾插,加红黑树是稳定,效率。数组查询时间复杂度o(1),链表增删时间复制度o(1),红黑树o(log n),效率没有前面两者高,所以,不能全部用红黑树)
-
concurrenthashmap 的结构(1.7分段锁,1.8cas+synchronize)
-
synchronized和 lock 的区别
-
hashtable 和 concurrenthashmap 的区别(结构不同,hashtable是锁整个对象和方法)
-
有哪些 java 的锁?实现类?cas 是什么?aqs 是什么?(乐观悲观,公平非公平,只说了 ReentrantLock,面试官接着问还有呢?cas,乐观锁的实现,会造成 aba 问题,加版本号或者时间戳。aqs 是锁框架)
-
线程池怎么使用,常用的参数?(使用 ThreadPoolExcutor,核心线程数,最大线程数,时间,队列,线程工厂,拒绝策略)
-
假如有 50 个任务去执行,5 个核心线程,10 个最大线程数,10 个任务队列,流程是怎么样的,状态是怎么样的?多余的任务是怎么用拒绝策略的(创建 5 个核心线程,10 个放队列,队列满了,再创建 10 个非核心线程,剩余 25 个根据拒绝策略来决定,默认报异常,其余三种:要么忽略,要么放弃最早的线程,要么用该线程去执行)(后来才知道回答错了,10 个最大线程数,5个核心 + 5个非核心,剩下的 30 个走拒绝策略)
面试官给我的评价:面试的都能答出来,这一点很不错,但了解的不深,虽然工作上用不到,但是面试就是这样造火箭的。面试不会差,但也不优秀,处于待定状态。
四、金蝶软件(甲方,面试了40分钟)
-
问的项目问题比较多,怎么设计功能的,数据库是怎么设计的
-
左连接,右连接,内连接的区别
-
mysql 执行计划,有哪些看的
-
springmvc 执行过程,从前端到后台,再返回前端的过程
-
权限验证,验权怎么做
-
数据库的锁(乐观锁,悲观锁,独占锁,共享锁)
-
select, update, delete 对应哪些锁
-
java 集合体系(list,set,map)
-
arraylist 删除元素有哪些注意的地方
-
arraylist 是线程安全的吗(不是,线程安全:vector,copyonwritearraylist)
-
什么时候用 arraylist,linkedList(频繁增删用 linkedList)
-
深拷贝和浅拷贝
-
值传递和引用传递
-
多线程,线程池
-
资源同步是怎么做的?
-
synchronized 可以修饰静态类吗
-
lock 和 synchronized 的区别?用的话,你会怎么选
-
平时怎么学习的(看教程,看博客)
-
自己的规划是什么?(规划学微服务,分布式等等)
-
技术方面的优势是什么
-
代码怎么优化,重构(单一职责,共用等等)
-
自己的网站是怎么进行性能优化的?(加 cdn,加 redis)
-
你有什么想问我的?你们那边用到的技术是什么?面试官:技术桟是封装框架,没有前端,用拖拉组件什么的,微服务是 dubbo,数据库是 oracle,侧重点是业务,技术次要。
最后面试官说:有四轮面试,这边面试完跟总监商量一下(剩下3轮都不是技术面)
五、平安银行(外包,面试了40分钟,因为有事,最后中断了)
-
spirngboot 启动原理(内嵌 tomcat….)
-
启动的注解(springbootapplication)
-
springboot 核心配置
-
配置文件的方式(yml, properties)
-
springmvc 的工作流程
-
springmvc 的组件
-
@RequestMapping 的作用(拦截 url)
-
spring 常用的模块,核心
-
说一说 ioc 和 aop
-
spring 常用的注入方式
-
spring 的 bean 有没有了解
-
spring 事务的实现
-
spring 的隔离
-
数据库的隔离级别(读未提交,读已提交,可重复读,串行化)
-
隔离级别的影响(脏读,不可重复读,幻读)
-
原子性,持久性(不可分割,保存到数据库)
-
char 和 varchar 的区别(字节大小,”和"")面试官继续问还有没有。。。
-
left join 和 right join 的区别
-
sql 你是怎么调优的
-
sql 执行计划(explain)
-
sql 的行锁和表锁,优势(锁一行和锁整个表)
-
乐观锁,悲观锁(版本号,时间戳)
-
mysql 的引擎,区别(innodb 支持外键,行锁,支持奔溃恢复,myisam 支持全文索引)
-
select count(*) from table,数据是怎么执行的,会造成全表扫描吗(innodb,不支持全文索引,所以在innodb会造成全表扫描)
-
float 和 double 内存占多少字节
-
在自增表,有6条数据,删了两条数据,再增加一条数据,这条数据的id是多少(innodb是7,myisam是5,结果说反了)
-
redis使用场景,和 memcache 的区别
-
redis 的持久化(rdb 和 aof,全量,增量)
-
了解 redis 分布式,有多少个节点,以及一些命令
-
nginx应用场景(前后端分离,负载均衡)
-
负载均衡的策略(轮询,权重等等)
-
前后端是怎么交互的
-
正向代理和反向代理
-
swagger 有了解吗(接口文档)
-
== 和 equals 的区别
-
堆栈有了解吗
-
stringbuilder 和 stringbuffer 的区别(线程安全,效率)
-
io 流(reader和writer,inputstream和outputstream)
-
fileinputstream 和 bufferinputstream 的区别
-
集合(list,set,map)
-
线程安全的集合有哪些(vector,copyonwritearraylist,hashtable, concurrenthashmap)
-
hashmap的实现过程(1.7头插,1.8尾插)
-
hashset 和 linkedhashset(底层hashmap,有序,底层 linkedhashmap,无序)
-
深拷贝,浅拷贝
**问的时间差不多40分钟了,因为还要工作,就打断面试了,结果晚上就来了第二轮面试
平安银行第二轮面试(面试了26分钟)
-
mysql 查询,有很多关联的表,怎么优化(表加字段,适当冗余,少关联表,不要用*,in,null,or, %等等)
-
索引失效的情况(in,null,or, %等等)
-
常用的集合类(list,set,map及实现类)
-
线程安全的容器有哪些(vector,copyonwritearraylist,hashtable,concurrenthashmap)
-
concurrenthashmap 是怎么保证线程安全的(1.7用分段锁,16个都上锁,1.8用cas+syn)
-
hashmap 的数据结构(1.7数组+链表,多线程会形成一个环,cpu会飙升100%,1.8数组+链表+红黑树)
-
什么情况下会转成红黑树(key,hash,数组大于64,链表大于8,转成红黑树)
-
重写了 equals,还需要重写 hashcode 方法吗(因为根据 key,hash 计算出来有可能会冲突,所以要重写hashcode)
-
链表是双向链表吗
-
redis的数据结构(string,set,sort set,list,hash,bitmap等等)
-
redis 分布式锁
-
b+ 树了解吗(mysql索引)
-
缓存击穿(缓存失效,在缓存找不到,在数据库有数据)
-
解决办法(设置缓存失效时间随机,错开时间,或者给个标记)(其实是设置热点数据永远不过期或者加互斥锁等等)
-
threadlocal有了解吗(发音没听清,听成什么logo了,听了三遍没听出来,后面才反应他要说什么)
-
项目中遇到的困难(mrp功能)
-
服务器 cpu 上升到90以上的时候,还可以用多线程吗?
-
最近有研究什么新的技术吗?(在学习微服务)
-
微服务相关组件,一些没听过(说了一下这些是干嘛的)
-
zookeeper有了解吗(分布式)
-
内存泄露有哪些情况?
-
从专业角度+性格,评价自己
-
你希望的项目是什么样的?(技术桟,技术氛围)
-
linux常用命令(ls,cd,pwd,chmod,vi,whereis,find二进制文件)
面试官:到岗时间比较急,技术栈是 springboot+dubbo,微服务集群,zookeeper+redis+mysql 等等
**面试评价:没什么大问题,希望快点入职
结尾
jvm,微服务和分布式等等的没有记录了,自己没接触过,听到了也忘记,没有及时写上去。还有一些面试,没有去记录了,整理和记录面试挺费时间的,上面的主要记录技术面试。有些终面,面了 40 分钟,问生活细节,有什么爱好,之前写的博客,还记得吗?xx篇,你还记得写了什么内容吗?你是哪里人,将来是不是还待在深圳?晕死,一直问,问了 40 多分钟,如果我不打断,可能会一个小时以上。像这样的流水账面试,就没有写到上面去了。
最后比较幸运,拿下几个 offer,面试题还是得多刷题,避免答不出来,另外收到offer的机会才会多。
总结
待的公司,比较安逸稳定,也算是个温室,业务比较复杂(ERP 系统),项目架构比较简单,单体项目,去年说加缓存的一直没加,单表数据最多是上千,现在是 260 多张表。目前有 20 个客户,cpu 飘升到百分之 90,之后,服务器奔溃,好几次都这样。后来我们 CTO 的解决办法是把功能给禁掉(MRP 运算),后来发现是报表问题,oom 错误,换了报表工具。过了一段时间,加了内存,加服务器。(微服务,分布式,缓存,集群,消息队列都没用到,也学不到),一直做一些复杂的业务功能,写业务代码。还是早一点离开技术得不到提升的地方,虽然他一直给我洗脑,说会那么多技术有什么用,你业务不会,代码不会写,会再多技术也是等于零。但我心里一直否认,毕竟我是搞技术的,懂基本业务可以,讨论业务,需求可以交给项目经理什么的,最后安排我做什么就行了。除非走业务路线,走管理,懂技术懂业务。像我这种学五渣,不适合走业务。
读者考研经历
你考得上么,就算你考上了,你能毕业么?你读三年研究生,和人家三年工作经验的能比么?大学时单片机老师当着全专业同学的面问我。我这个人记性比较好,时隔四年,研究生即将毕业,可还是可以清楚的把这位老师的话复现。以前我不知道这个问题的答案,但是现在我可以自信的回答这个问题:能毕业。
在力所能及的情况下尽可能的提高学历
这里分享一下本菜鸡的考研之旅。篇幅较长。
大学之前,并未想过大学结束之后是怎么样,只是整天开心的像个小傻子,尽情的享受美好的大学时光,大三临近结束的时候,大家都在讨论以后的安排,考研,实习,考公,创业还是什么?我才幡然醒悟
,那么我呢,高中时读了个二流的高中,拼尽全力,也不过上了个三流的本科。进了大学,好像大家都不知道怎么学习,各自就像散养放牧的小羊,大学任由你自由的发展,于是在思考毕业以后去向的同时,我也开始问自己,你究竟想要的是什么?不止我一个人,好像每个人到了这个年龄,都开始变的非常迷茫。天天跑教室上专业课,忙社团,宿舍看电影的大学时光,就要结束了,一霎那间,使我感受到,这几年飞逝的青春,自己居然一无所获。我忍不住问自己,我这个大学究竟在读什么?好在,我清楚的知道,我想要的是什么:继续学习,当下的我还没有具备找到一个理想工作所需要的素质,无论是专业的深度,还是知识的广度,我想给自己更多的时间,提升自己,使自己变的更加优秀。同时,去更广阔的天地,结识一群更优秀的人。于是我决定踏上考研这条不归路,即使这条路,走的是那么的孤独
。
时间定格在 2016 年 5 月的某一天。我整理好自己的装备,一个双肩背包,一个水杯,一套考研书籍,和一颗明朗的心,大步的走向自习室,真正的开始了我的考研之路。我是一个新人,就像现在的你们一样,除了十足的勇气,还像个慌乱的小孩,不知道从何下手。渴望前辈指路,但是没有人告诉我到底该怎么走,只有自己一点一点的摸索。逛了考研论坛,看了无数的经验帖,关注了很多考研相关的微博,向已经考上的学长学姐咨询各种问题,但是别人始终不同于自己。我开始真正的思考,如何去走自己的路。考研成了我大三大四所有事情的优先选择,我放弃了副部最后竞选,也不再做班干,三年的时光恍然如梦,有一位老师说,几十年后,你们会发觉,人生根本没有什么选择。人都是被各种因素推着往前走,不论如何,我依然认为,就算命运左右着我们的选择,但前方谜一样的未知,都会让我心动不已。
十月,同学们纷纷离校开始实习,曾经热闹熟悉的寝室,也变的寂静无比。行走在校园,即使有意放慢脚步,也再也见不到那些熟悉的面孔,夏天逐渐消失无影,秋天悄无声息的染黄了道路两旁的银杏叶。夜晚背着书包,在路灯下,拉长身影的男生,习惯了看着星星微笑,静享这一刻的放松,日子充实而美好。有目标的生活,让自己心甘情愿的去付出。
十一月,是最难熬的一个月,觉得时间太过于紧迫,又觉得自己什么都没复习,信心也大不如之前,最可恨的是身体也开始各种罢工,每到下午就开始头昏恶心,不想待在自习室,只想在操场上走一走,呼吸两口新鲜的空气。
北方的清晨,六点钟的自习室冷的要命,必须随身携带着暖手宝,热水袋。凳子上放着用来盖住腿部的旧羽绒服。气温的骤降,丝毫没有影响到自习室那群可爱的人们,全力以赴的去追求自己的梦想。各种需要背诵的政治,英语作文,专业课简答题,似乎没完没了的跟在屁股后面,迫使楼道里,后花园的树林里,自习室的顶楼,没有人的教室,甚至厕所里,都有人在背书。我只是众多考研人中的一个,比我付出更多的,大有人在。
终于,所有受过的苦,都将要画上句号了。所有考研人,迎来了 2016 年中,最重要的两天,12 月 24,25 号。在激烈的订房斗争中失蹄,所有考研考场附近的宾馆都是爆满。那个晚上必然是难眠的。忐忑不安到很晚才睡,没事翻翻政治书,困了就躺在床上,脑海里各种各样的琐事,一股脑的浮现出来。翻来覆去,睡不着就又起来看书。
第二天 5、6 点钟,天微亮,就诈尸一般的醒来了,考场上的每一个人,除了心是热的,四肢早已冷到不行。
初试因为数学计算能力不行而排名靠后,于是认真准备复试,七本书,半个月时间,恨不得一天当成两天用,终于,复试结束后的第二天,学长给了我一个名单,全日制学硕录取,笔试第一,解放啦,我也是 985 的研究生了,我要把这个好消息,告诉我的爸爸妈妈,告诉我的老师同学们,告诉我的亲人朋友们,可是就在成绩贴出来的时候,我却被换成了非全日制,看着前面的四个本校,和有点变动的面试成绩,我默默接受了这个结果 ……
可我该怎么跟我的亲戚朋友爸爸妈妈解释呢?无奈,我只能失意痛苦的不断的回望这令人向往的校园,而现在,匆匆一面后,我就要转身离开了,我默默的一个人,离开校园,眼泪朦胧的看着往来的学生,曾经我也幻想是他们的一员的,曾经我以为我成了他们中的一员了,可这只是曾经,是我的一厢情愿。
踏上了回去的火车,思绪万千。
我真的心灰意冷了,整日躺在床上,没日没夜的睡觉,睡醒了,就默默的趴在床上掉眼泪,觉得自己曾经的努力没了意义,过着每天看不到日出的生活,找不到方向,不甘心,不甘心别人考上了,自己没考上。不甘心,曾经那么的努力,却换来这样一个结果。
感谢我的老师,亲人朋友们,在我最难熬的那段时间,给了我无微不至的关怀,让我挺了过来,认清当前最重要的任务,是好好毕业。
一起在球场挥洒汗水,一起啃着面包奔去教室,一起坐在寝室打游戏,一起默契的吐槽挖苦,一起在食堂,争论着吃啥,一起在桌上碰杯畅饮,一起八卦,一起追剧,追电影,一起考前熬夜,临时抱佛脚,一起为一个题目,争的面红耳赤,一起穿着学士服,一起毕业,在最开心最得瑟的时候,在最沮丧最灰暗的时候,辗转难眠的时候,都有你们在身旁,一起度过了四年最快乐的时光的那些人们,终于迎来了最后的别离,醉成狗了,第一次,也是最后一次了,以后再也不会这么疯,这么醉了,再也不要那么的难过了。
白天送你们上了火车,晚上一个人在校园里绕圈,经过一个个熟悉的地方,这里的每一处,都留有数不清的你们的过往和足迹。
某年某月某日毕业,某年某月在某日某处,取得了某学位,四年,两本证书,从一个新的起点开始,跌滚着前进,悄然溜走的岁月,我学会了珍惜,懂得了追求,失去是因为时间的无情与付出的匮乏,时光飞逝,良辰难在,青春本来就是不完满的,你喜欢过的人,或许这辈子都不属于你,你追逐过的梦,或许这辈子都只是一个梦,但是这又怎么样,最起码,我们曾经喜欢过,曾经梦过,最重要是我们曾经拥有过整个曾经,只有这样,才是每个人独一无二的青春啊。所有的旧时光,都是青春里,缝隙里的微光。
在送走了最后一个同学,租住了一个十平米的小隔间。每天坐在这里,摇摇晃晃的小桌子前,备考我的初试,憧憬着我的研究生生活。考研是一件辛苦的事情,比考研更辛苦的是二战考研,选择二战,一部分是对于目标的执着,另一部分真的是因为不甘心
。
2017 年对我来说是漫长而疲惫的一年,多少次食不甘味夜不能寐,多少次独坐窗台遥望东方渐白,多少次迷惘不知所措,多少悲思愁绪吞噎窒息,那一点小小的憧憬和幻想一直支撑着这 365 个日日夜夜。
很多人无法想象,一个人,或者一群人,在一年时间里,只在一个地方,重复着做一件事情,这个人,或者这群人的身体,心理会有什么变化,结果是少数人有不变的有实力,有信心的走进考场,并赢得胜利,另一部分人则会信心溃散,落荒而逃,经历人生的重大失败,在这个过程中,身心会有一些变化,对周围的事情异常敏感,最常见的是犯困,很多很多的现象,脱离了考研,也许就不会在经历了。又是一段重复的日子。
在数学与英语间,在疲惫与亢奋间,在宿舍与教室间,在追逐与领先间,在朋友的关心与父母的关怀间,在倒数100 天和 50 天,在白天与黑夜间,在一条马路上,看到了一年四季的变迁,春夏秋冬,小草的不同的颜色。
用过的 A4 草稿纸,已经堆积到腰间,某本书,已经看了第 N 遍,考试结束的钟声响起,一步一步在马路上行走,我凝望着来来往往的下班的人群,拨通了爸爸的电话,爸,我考完了,然后,终于,忍了两天,不 是一年的泪水,不停的滚落下来,漂泊的大雨中,篮球砸在地面四溅的水花,湿透的头发,睁不开的双眼,都浸透着我对未来的各种各样的幻想。
刚刚收到了某 985 的拟录取通知,因为每一科的成绩都是很均匀的不是很高,我深知自己和论坛上写经验贴的400+ 的大佬们相比,差的简直不要太远,所以打算尘埃落定后,隐居山林,不再抛头露面,细细想来,从准备初试开始到初试,等待初试成绩公布,等待复试名单公布,准备复试,复试,等待拟录取结果公布,每一天都在紧张和不安中度过,时间的跨度长达数半年。各种滋味,考研人才懂。
也许考研又是一个改变自己命运的又一个新的起点,这是一场会使人怀念,但不想重复也不能重复的旅行,多年以后,也许我们会感谢自己当年那么努力,感谢那些美好的遇见。
这一年来我很幸运,尽管去年年初因为各种打击陷入低谷,然而一年时间内,我就得到了我当初奢望的一切。
低谷未必是绝境,更有可能是重生,没有白费的努力,一切都是最好的安排,时至今日,常怀感恩。
提前做好规划
**要早准备、尽量实习、打好基础
尽管经历了考研,研究生的学习强度还是让我适应了一段时间,半学期的时间修完了三年的课。然后全身心的投入实验室,三点一线,看论文,建模型,求解优化,写论文。日子平静的如家乡的小城,永远不会有什么大事发生。
19 年寒假按照往常,就几天时间假期,就带了个电脑就回家了,什么论文、ppt 全都留在学校,回家就是要好好放松。没想到的是,这个假放了 10 个月。
导致在家懒散了很久,忙着和外甥、外甥女玩,忙着装修房子。一转眼就四月份了,课题课题没看,找工作的东西没学。唯一的成就是体重从回家前的 130 涨到了 154。
仿佛与世隔绝了般,天天窝在家里,抖音视频刷了一个又一个。
时间 4 月份,偶然的一次和室友聊天,得知他在刷题。细问才知道是算法题,需要注册牛客或者 leetcode 。聊完后的一个星期,注册了账号,开始刷题。因为平时做的模型求解优化也用到一些算法,做的第一道简单的题竟然通过了,瞬间膨胀了,这有啥好刷的,都会。
事情并没有这么简单,和本科的一位学长聊天时候,才知道同样是做出来,人家是 1 ms,你可能 10 s。我又去牛客看了看,才发现好多大佬的思路很清晰,解法巧妙,这才沉下心来,以一天两题的频率开始刷题。中间穿插着课题的汇报,还是要读论文,写 ppt,刷的断断续续。之前学过 c#,所以刷题之前,用了一周把 Java 基础语法过了一遍。Hard 直接跳过,避免信心受到打击。
时间持续到了 5 月份,终于除了困难的题,都刷了。这时得知实验室的同学刚刚开始刷题,本着哥们精神,我决定等等他们,这一等,体重又增了 5 斤,直逼 160。
6月,室友说他找到银行的实习了,让我也准备一下面试实习。啊 面…面试,大学直接准备考研了,并未有过几次面试。赶紧去看看 Java 开发都要问什么。下载了某聘,某直聘,看看需求,好多没听过的名词。
-
熟悉 Java 集合,IO 、多线程;
-
熟悉网络、操作系统;较好的算法基础;
-
熟悉 MySQL、NoSQL 等常用数据库;熟悉数据库性能优化;
-
熟练运用 SpringMVC、Spring、MyBatis、SpringBoot 等开源框架;
-
熟练运用 Redis、ActiveMQ、Shiro、Tomcat 等中间件技术;
-
熟悉 maven、eclipse/idea、git/svn 等常用工具,熟悉 linux 系统常用操作命令;
-
熟悉 SpringCloud… 怎么看到这些时,忽然也慌了神。像我这样迷茫的人,像我这样寻找的人,像我这样碌碌无为的人,你还见过多少人。。。
于是各种向大佬们请教,疯狂找资料复习。料找了一堆,却不知道从哪下手。就像一个从未下过水的人掉进了海里,在挣扎,但是淹死也是早晚的事。就在 xuan 哥开着游艇过来了,没错,偶然间看到了 xuan 哥的操作系统,图文并茂,思路清晰,排版工整,给了我很大启发。我可不可以也像这样,做一份工整的笔记,复习方便,学起来也更有条理。
按着面试要求开始准备,先从 Java 基础开始准备,集合,IO。做了个简历,投了字节的提前批,简历挂。难道是我没项目?就又去学习 SSM 框架。学完读了一个项目源码,画了个脑图,我又觉得我行了。
开始面试小公司面试小试牛刀,面试感觉还可以,问集合的框架,源码,答得还算完整。而后膨胀到去面试阿里,深受打击,MySQL 的执行流程说一下,线程和进程的区别,并发和并行区别,线程中止方式,这些在现在我看来在基础不过的东西,在当时却难的我想找个地缝钻进去。
我知识欠缺的太多了。于是 6 – 7 月都在静心补基础。并发不行,就去补并发的基础,先看了 37 个小时的并发视频,觉得老师讲的很透彻,全程两倍速,边看边做笔记,5 天看完。
上午复习之前学过的,下午学习新知识,晚上刷题,每天学到凌晨一点,累并充实着,不敢有一点懈怠,我深知我差的太多,后来的后来,连晚饭后,出门打会篮球都是那么奢侈。对于一些全新的知识,我一般是看视频快速入门,看书深入学习。熟悉的就会看一些权威的博主整理好的知识点,比如 cxuan 哥,Guide 哥,hollis 哥。然后整理成自己的笔记。
就这样脑图画了一个又一个,知识点增加的越来越多。知识体系越来越完善,
当我觉得自己又可以了的时候,我就试着去牛客上看万字面经,试着自己作答,答的好的就跳过,答的不好的就继续补充笔记。
终于在某银行提前批的面试中,和面试官聊的有来有回,一度被夸,问是不是报了什么培训班,是不是科班。那天晚上,我三分投的特别准,大汗淋漓后,洗澡沉睡去了。
8 月,开始认真改写简历,读开源项目,学历让我大多数公司都有了笔试机会,然而,我写代码的能力太弱,笔试能过的企业寥寥无几。又要恶补手撕代码了,先从最基本的排序开始,从头再学算法,动态规划。看了 labuladong 大佬的算法笔记,收获很大,让我有机会过了几家公司的笔试。然而,秋招准备的太晚了,投的也太晚了,八月底才开始大批量投简历,今年远程面试,大佬们面试无成本,占了很多 hc,加上实习,提前批,导致很多笔试过后,都是很久才约我面试。
直到九月中,测评笔试做了一堆(每次测评和笔试时间都还挺长),手里也没有一个 offer(很多面试都是流程中)同学也会时不时的跟我说美团三面过了,快手发意向了,字节百度都给 offer 了。表面上看上去风平浪静,内心实则慌的不行。在我 0 offer 的时候,大家好多都已经结束秋招了,此时我的心态可想而知。我开始跑学校的招聘会,很幸运跟着室友拿了一个外企银行的 offer,自此心态稳了很多,再结束秋招的边缘挣扎,后来越来越多的企业开始约面试了,菊厂一口气过了三面,以为拿到了offer,谁知道这才是开始。慢慢的,互联网也拿到了几个offer,心态就更稳了,面试时可以和面试官侃侃而谈,还被体面厂总监小夸一下。不少面试官都说我在答题的时候思路比较清晰,答的比较全面,我觉得这真的得益于自己的总结。最终拿到了一个自己满意的不大不小的offer,薪资待遇都还可以。秋招正式结束。
曾整日端坐电脑前,眼涩腰酸,也曾为刷了几个小视频懊悔不已,曾怀疑过自己,也曾三过球场而不入,爱而不得。这一年我太幸运,幸运的得到师兄师姐和各位业内大佬的指导。
借用一位传奇大佬的一句话:得到博士学位不是终点,不代表你以后能比别人成功,但博士学位代表的是,只要你愿意,你可以做好这个世界上几乎所有的、有技术含量的事情。
还没读到博士,我就已经深刻体会到了这段话的含义,这在以前,我是不可能在短时间内快速入门并上手一门技术的。研究生教我最多的是如何学习,终身学习。
总结下:很多面试官面完后都说我和其他人相比,除了缺少实习经验外,基础非常扎实。
基础:要重视内功修炼,早做准备,代码题要渗透到日常。打好基础,都说面经无用,但是很多面试官问的实际问题都是可以从里面找到影子的。尝试读源码,比如 JUC ,去感受李大哥的优雅和独到。
项目&实习
公司一般需要能为他们减少培训成本的人。
项目亮点、难点是什么,考察知识深度、项目的优劣。
大厂一般都会看实习经历,实习做了什么,实习很重要。
数学建模竞赛、高水平论文。
好的简历是一块敲门。
保持谦卑,不忘学习,长征永远在路上。
春招经历
先说下楼主经历:
其实我之前已经写过一篇关于自己秋招
经历的文章了,里面有很多面试题,可以作为参考:
下面重新介绍一下自己:
我个人是双非本,去年五月份至今一直在 JD 实习,拿到了 JD 的 offer 后,偶然的机会(boss 上)就开始面试
部门是百度云 cdn 具体好像是做音视频相关的,主要语言是 Java 和我本人相符
一、二、三面两天面完,具体的问题记不清楚了,以下内容仅凭回忆(上班摸鱼写的):
正文
一面面经:2021/1/21 下午五点
是个小姐姐,但是很严肃
- 自我介绍
- 介绍实习项目、难点、参与的工作
- 项目用到的设计模式以及其他设计模式
- syn 锁(升级过程、降级?(读写锁)一些底层原理)
- 操作系统线程间同步机制
- syn 和 lock 区别
- 线程池、参数详解?你怎么设置参数(I/O 密集型、计算密集型)
- JVM 内存区域
- 如何排查一下线上 OOM 问题?
- 类加载机制?
- 双亲委派?tomcat 如何打破的?
- 写代码 1 翻转链表(秒)2 手写快排序 介绍各个排序算法时间复杂度
- 在看的书籍?知识?《深入理解 Java 虚拟机》说下最深刻的部分?我说了个 R 大写的 JVM 是如何区分出是引用类型还是基本类型
- 反问 大约 47 min
二面:2020/1/22 上午十一点
HR 说的是一个高 T 二面面试我,果不其然,一个秃头大叔。
- 自我介绍
- 介绍京东实习项目
- 项目分布式锁怎么用的?主从的缓存 master 节点 down 了怎么办?(Redlock)
- 脑裂问题(配置文件)、数据倾斜(一致性哈希,虚拟节点)、数据分片
- 缓存穿透、击穿、雪崩
- Redis 哨兵?cluster?
- zset 底层,为什么用压缩列表(避免内存碎片),跳表查询复杂度?log(n)(逮住 Redis 真就往死里问啊)
- 看你博客里有微服务,讲讲微服务、分布式?
- 项目中各个服务之间怎么调用的?我说用 JSF(JSF 是京东内部的RPC通信工具,类似于 dubbo)
- 分布式 CAP 定理
- 讲讲分布式事务解决方案,各自优缺点(内心。。。还好前几天看过)
- 写题 忘记具体是啥了 反正都是属于 easy 的题目,大约 50+min
还有一些回忆不起来了,好像是 Spring 源码的东西,电脑面试没有录音,总之抠得很细,面试官果然是个大佬。。。
三面 2020/1/22 晚上八点半
- 介绍自己
- 两个栈实现一个队列
- 基本上就是一些非技术问题,唠家常、谈理想、问 offer
- 可能是我比较能 bb 三面约 1 h 15min
十分钟 HR 联系微信联系说过了,让我等接下来的补笔试、测评邮件
1/26下午收到邮件做完后 HR 叫我安心等待,最迟周五给发 offer。
lz 从去年二月份开始春招找实习,(双非本科学历)到现在差不多过去了一年。大大小小的面试也经历过了不少,也相应的拿了一些公司的 offer,下面是我的一些个人见解加上别的大佬的一些参考,希望能抛砖引玉,如有瑕疵,还请多多指教!
我认为的面试 = 基础 + 能力 + 规划以及一些面试技巧
其实也就是对应着一个人的:过去(学过的知识)、现在(拥有的能力)、未来(规划)
基础
这个就很简单了比如
- 最基本的数据结构、算法;
- 以Java 为例的一些基础知识:JVM、集合、框架;
- 计算机操作系统、计算机网络;
- 通用的一些中间件:netty、nginx、redis、MySQL 等。
这些是基本上都要知道的,尤其是一些面试常问的必须要数量掌握,尽管有人认为这是背书,但是连背书都不背的说明态度有问题,肯定是不是被公司接受的。
当然以上说的太笼统了,具体的复习路线可以参考其他大佬的作品,如不嫌弃可以看一下我写过的一些博客 https://blog.csdn.net/weixin_44104367 本文只做一些面试经验相关的总结、概述。
能力
这方面主要是围绕实习、项目来展开的基本上 = 技术硬实力 + 能力软实力。
比如:有实习的
- 在实习期间做了哪些事能够证明自己的能力
- 如何在开发过程中优雅的书写代码?(其实一些知识譬如设计模式大家都会背,但是能够真正将这个知识落到实地的又有几个呢?
- 项目开发过程中如何一步步提升自己的技术能力、业务水平?
- 如何快速的理解业务?适应环境?
- 对于实习OR项目的思考总结?
要明白一点:工作了的人很喜欢问一些 case,尤其是一些领导特别喜欢问,哪怕这个项目技术再牛,那么它是如何落地的呢?他的场景是什么?为了解决什么问题?使用了什么方法 OR 工具?达到的效果如何?最终能满足预期吗?
比如大家都会背一些 Spring 源码,设计模式,但是能自己将这二者结合起来吗?比如结合 Spring 源码+设计模式开发?(这篇文章就是个例子 淘系技术部的 https://mp.weixin.qq.com/s/94oe5c_7ouE1GbyiPfNg5g)
对面试官而言,他们已经听吐了这些背的东西,如果自己能讲的让面试官眼前一亮,那么即时面试问题回答的不太好 最起码也能证明自己对于技术是有追求、有思考的,而不是一个背题机器。
对于一个技术而言,它在这个公司存在的意义就是为了一些变现业务服务 技术服务于业务,用业务创造价值。
个人认为学生状态过渡到工作状态就是理论转化为实践动力的过程 有些人总觉得校招生身上有一股书生气
大概就是:理论的东西多而幼稚,有些不切实际的想法,能不能落地呢?
规划
这点其实发现面试总结里面很少有人去谈,但是看到脉脉上,十个 HR,九个都会喜欢听到候选人这方面的一些思考、总结。
路线规划
举个例子比如:会不会提前去规划自己的人生路线?
这是从网上找的一张图,可以看到有很多路线,究竟哪一条适合自己?可能很多人压根没思考过这个问题?另外自己适合哪一行呢?教育?广告?地图?还是纯技术?
隔行如隔山,因为我从去年五月份就来实习,也换过俩部门,所以对于这些事情比其他人要深刻一些。
只有清楚知道自己喜欢的才有动力去做好他 不喜欢的只是为了生活被动的产出。
不是所有人都适合做纯技术、也有的人压根就不适合搞业务,究竟那条大路通向自己心中的罗马,只有自己清楚
而这些事很多人都这么回复的:先有了工作再说。
看过很多人 尤其是 90 后工作半年跳槽、一年的也有。反正给我的感觉就是:不踏实
。对于公司而言就是不忠诚:谁能保证你从上一家公司跳又能干多长时间又跳走了呢?公司培养一个人的代价又有谁去承担呢?
在一个公司呆够超过五年 最起码可以说明这个人在这个行业、公司站住脚了。
有的人入职以后才发现自己不太适合这个行业、这个方向,于是就跳槽,简历就花了。
简历一花,找工作就更不好找。别的公司不知道,JD 这边有的部门五二原则卡的很严格(五年内只在两家公司工作过,也就是说平均一家公司至少工作两年时间 https://www.jianshu.com/p/ac8f28f58e11)
这还是次要,更重要的是自己能确保这个新的方向就自己适合或者喜欢吗?于是又开始跳、跳、跳。
人生往往是一步错、步步错。
别人已经在自己合适的方向上工作很久了 而你还在思考自己要做什么。
男怕入错行,女怕嫁错郎,说的大概就是这个意思。
时间规划
大家都知道程序猿 35 是一道坎,到时候要么转管理,要么成为 CTO,自己对于自己的成长路线是一个什么规划呢?
管理路线:三年能够处理日常开发当中的任何问题;五年能够在技术上达到自己一个比较理想的状态;七年能够成为小组 leader;十年能够成为部门 leader
技术路线:三年够在技术上能够有较大提升;五年成为架构师;七年成为资深架构;十年成为总监等。
能够表达出这些,最起码能够说明自己比较踏实,是一个有规划、有思想的人。
面试经验
对于面试经验这块真的就只能实战找感觉了,每个人都有自己的一个表达方式,不过套路都是差不太多:
扬长避短
像楼主本人学校不好但是一直在 JD 实习,那么自我介绍的时候可以说自己实习时间比较长等。
学校比较好但是导师不放实习的可以着重说下自己的学校经历,paper、竞赛情况,都没有的可以说能够凸显自己能力的地方
适当的往自己会的方向引导面试官
有的面试官会自己电脑前放一个题库,但是大部分不会,会根据简历上,自己脑海中搜索问题。
比如问你 MySQL 调优,自己知道那些就说哪些,比如你知道索引这块哪些自己知道原理就说那些:(is null 判断可能会导致放弃索引、尽量避免使用判断等)再往下往往会问原理,因为你知道最左前缀原则、MySQL 优化器的索引代价分析、选择过程,你就可以轻松回答上来。
但是你不太懂索引相关的原理就不要胡言乱语,瞎往自己不擅长的领域引导面试官,可以从设计规范方面谈起(使用 varchar 而不是 char 等等)因为面试官一般会问:为什么?自己在学习知识的过程中也要经常问自己一句:为什么?比如都知道函数表达式操作会导致索引失效,那么原理呢?
面试充满了不确定性
你又不是RMB 不会所有人都喜欢你。
这个恐怕很多人是深有体会:我面试面的挺好的啊,问题都回答上来了,怎么还是挂了?放宽心态,该佛系的时候佛系一点啦。
面试充满了不确定性,能和面试官聊得来,即便问题回答的不怎么样,面试官也会放你一马。
有的人跟面试官聊不来,甚至面试过程中发生了争执,那肯定就是不给过了呗。
面试是一个双向选择的过程
你被面试官面试的时候其实你也在考量这个部门、这个面试官技术水平、人品以及是否愿意引导新人等。
一般面试自己的都会和自己入职后的工作关系是在一起的,一般是自己的同事、直属领导。
如果面试官为人和善、愿意引导你解答出问题来,那么入职之后你的成长速度也会更快的。
如果面试官技术问题问的很模糊其辞,不够专业,那么面试多了你也会感受出来。
你不是非我不要,我也不是非你们部门、公司不选,面试就是一个双向选择的过程。
烂大街的项目尽量不要写
秒杀系统 、商城系统。
原因自己体会 懂得都懂
学历差的自信一点
牛客上认识了很多盆友,大家的学历大多数都比我这个双非渣本菜鸡学历高得多,这也不是意味着学历差就一定不行,我这不是也上岸了百度、京东了么。
衷心劝诫 22 届及以后毕业学历较差的盆友,学历差不代表一切。
但正是因为学历的问题,就需要我们比别人付出更多,别人不会的可以通过学校、paper 补过来,但是我们一无所有只能冲。
确实像 bat 这大厂对于学历的要求不是特别高(某东除外,很多部门明确要求学历 211 及以上)
PS
都在问啥项目比较好,我从个人角度谈一下:有实习的话肯定是公司的项目比较好 无论是体量还是专业程度
没实习的话可以做个:仿制 dubbo、netty、tomcat、简单的 ioc 容器啦 或者一些实实在在能落地的项目,因为一个项目落没落地面试官一问就知道。
前者会更好的走完一个底层的流程,从原理搞懂这些中间件,会比直接背书强。
真正走完一个流程的项目远远优于那些网上的项目 没有实际的背景 那些开发中的情况面试官一问就破(以上为个人观点)
以上内容纯手打,囿于楼主本人的认知阶层,目前暂时总结了这么些,算是回馈一下牛客吧 希望22届及以后的同学们看到这篇帖子能够有所启发,写的不好多多包涵!
京东面试之路
本人 92 年 5 年传统行业 Java 开发。出于对技术(金钱)的热爱,面试了几家 互联网大厂,最终入职京东
。下面是我的面经分享
美团
美团面试难度较大,基本上问到的所有内容都会深挖原理。所以有些东西自己对于原理不熟 尽量不要自己主动提出来,不然就是给自己挖坑。当时不懂这个道理,所以美团一面 2 个小时还是被 pass 了(谁让自己扯那么多了),面试体验也不是很好,面试官全程口罩无表情给人压力较大。
一面
-
自我介绍。自我介绍要简洁抓住要点,最好不要超过 3 分钟。
-
项目有没有进行 mysql 调优,如何做的?为什么这么做?
-
mysql 事务隔离级别知道吗?mvcc了解吗?mvcc 解决了什么问题?
-
redis 项目中使用了吗?怎么用的?
-
redis 的数据结构你知道的说一下
-
redis 常见问题以及解决方案(雪崩、穿透、击穿)
-
跳表、布隆过滤器数据结构说一下。有什么使用场景、好处。
-
redis 集群,项目中如何使用的
-
redis 内存淘汰策略
-
juc 包下用过哪些类,有没有看过源码
-
线程池执行过程
-
线程池淘汰策略
-
AQS 原理
-
手写 LRU 算法
个人感受
我面试的美团部门,对于技术的要求还是很高的。
基本上你简历上写到的技能点以及项目上用到的技能点都会深挖原理的,所以千万不要给自 己挖坑。本来面试只是问 redis 基本数据结构,你非要提底层是什么实现的,那么对应的 sds、跳表、布隆过滤你都要理解不然就会被自己挖的坑埋了。
另外美团面试官全程口罩根本感觉不出他的表情和情绪,给人的压迫感很大。所以要提前做好心里准备,当然只要技术过硬这些都不是问题。
滴滴
滴滴面试整体体验不错,但是二面和三面难度较大。基本上是一周一面。
一面
-
自我介绍
-
Object 类了解吗?有哪些方法,具体说下每个方法的作用。
-
== 和 equals 有什么区别
-
java 的几个特性。继承、封装、多态并说说你对他们的理解
-
说下你了解的设计模式,项目中如何使用的。
-
设计模式的 8 大原则知道吗?开闭原则你如何理解的。
-
hashMap 了解吗?底层数据结构是什么,有没有看过源码,扩容机制了解吗?
-
mysql 了解吗?索引底层数据结构,为什么是 B+ 树。
-
mysql 事务的原子性实现原理
-
说下 juc 下的 lock
-
线程池的参数以及各自的作用
-
AQS 了解吗?说一下它的原理。
-
手写一个字符串压缩算法。(就是 aaaabbbbcccc 变成 a4b4c4 )
二面
- 自我介绍
- 介绍一个自己熟悉的项目,主要是业务逻辑。
- 项目中的难点,哪些地方做的不好有什么改进的方法。
- 设计一个可以支撑峰值20万QPS,5千写库操作的系统。
- 系统设计(上一个问题)的缺陷在哪里,如何解决。
三面
- 自我介绍
- 介绍一下自己做的最好的一个项目,有哪些亮点。具体说一下细节。
- 数据库与缓存一致性解决方案,不同方案适用场景以及存在的问题。
- 设计一个支撑 10 万 QPS 的系统查看当前滴滴司机查看历史订单的系统。
- 手写一个时间复杂度为O(n),空间复杂度为O(1)的将一个单链表转为为一个新的按照奇偶顺序的新的单链表。
个人感受
滴滴的面试是他们客服流量分发部门,面试曲线感觉很陡峭。
一面都是基础问题略简单(当然一定要基础过关),算法也不难。吸取美团的教训面试过程并没有主动拓展太多很顺利就二面了。
但是二面和三面的难度陡增,可能和面试职位是 D7 有关系吧。直接要求设计一个支撑具体 QPS、TPS 值的系统,三面甚至直接要设计滴滴(WTF?黑人问号脸),这种问题对于我这 种没有互联网经验的老 crud 来说真的很棘手。只能按照自己平时积累的关于系统设计的所有点和层面进行分析,从服务端 nginx、网关层、应用层、redis 集群、分库分表(运行之后的扩容)各个点去逐个分析了,虽然通过了面试但是这种面试让我感觉挺痛苦的(当然这也正是我想提高自己的地方,不然还搞啥互联网啊?)。所以滴滴这边更重视你的大局观,系统设计分析能力。
京东
京东面试体验不错,比较注重对于知识的理解。面试官很善于引导你,所以压力没有那么大。
一面
- 自我介绍
- hashMap 底层数据结构,扩容过程。树化的条件,平时项目怎么使用的。
- synchronized 和 lock 的区别。各自实现原理
- 说说你对并发中的原子性、可见性、有序性的理解
- 线程有几个状态?线程池各个参数说一下
- jvm 垃圾回收算法有哪些
- jvm 垃圾回收器你知道哪些?jdk8 默认垃圾回收器是什么?
- 项目中有没有 jvm 调优的经验
- 如果让你设计一个 jvm 内存管理系统,你会怎么做。
二面
- 自我介绍
- 说下你认为做的最好的一个项目的业务逻辑。
- 项目中遇到最大的问题,你是如何解决的。
- java8 新特性了解吗?项目中怎么使用的
- 有没有进行过代码重构,怎么做的以及原因
- 项目中如何进行 mysql 调优的,说下过程
- 为什么使用索引会加快查询速度
- 项目中怎么使用 redis 的
- 分布式锁了解吗?什么场景下会用,怎么使用。
- redis 集群有几种,热 key 问题如何解决。
- redis 持久化方式有几种,原理是什么?
- 手写一个 demo 可以控制多个线程在同一时间同时执行相同任务。
三面
- 自我介绍
- spring 中 bean 的生命周期 spring 事务实现原理,事务不生效的场景有哪些
- 分布式事务了解吗?如何实现,各自优缺点
- java 自带的 jvm 监控工具使用过吗?可以说一下具体使用案例吗?
- 线上 oom 如何排查
- 如何在不影响线上系统运行的情况下进行数据库水平扩容,给出一个可行的方案
个人感受
京东这边面试相对美团和滴滴来说更加注重你对知识的理解(确定入职之后,面试官亲口和我说的)。所以回答问题要带上自己的理解,除了问题本身都会问你项目中怎么用的问什么。
一面就问我如何设计一个 jvm 内存管理系统,那么就需要你对 jvm 内存管理(GC)这一块很熟。也不是说每个人都会这样问,肯定是跟着你的简历内容走。当然你要是自己给自己挖坑那就没办法了。
京东二面三面都很注重你的业务理解能力,不单纯是技术层面。也会让你去表述自己所做系统的业务逻辑,面试官人还是很好的,非常乐意引导你。相对来说给我感觉是最好的。
双非渣本菜鸡的面试之路
从春节过后,我,一位双非渣本的大三学生,便踏上了实习之旅,面试了不下三十场,虽然很菜,但是也相应地拿了一些 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 动态代理和 CGLib 动态代理、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和微服务的区别。
-
为什么要用微服务。
-
使用微服务存在的问题以及解决办法。
-
微服务之间如何通信。
-
微服务如何发现。
-
微服务挂了、如何解决。
-
重试机制、幂等性。
限流
熔断、降级
-
Linux
-
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之类的
字节跳动对于算法十分钟爱,几乎每一面都会至少两到算法题,所以,要想进字节,至少俩月算法题刷起来。
不要扯什么算法不重要,程序猿搞不定算法就像厨子不会颠勺,司机不会挂挡。
MySQL常见面试题
- MySQL 常见面试题
MySQL 一直是本人很薄弱的部分,后面会多输出 MySQL 的文章贡献给大家,毕竟 MySQL 涉及到数据存储、锁、磁盘寻道、分页等操作系统概念,而且互联网对 MySQL 的注重程度是不言而喻的,后面要加紧对 MySQL 的研究。写的如果不好,还请大家见谅。
非关系型数据库和关系型数据库区别,优势比较
非关系型数据库(感觉翻译不是很准确)称为 NoSQL
,也就是 Not Only SQL,不仅仅是 SQL。非关系型数据库不需要写一些复杂的 SQL 语句,其内部存储方式是以 key-value
的形式存在可以把它想象成电话本的形式,每个人名(key)对应电话(value)。常见的非关系型数据库主要有 Hbase、Redis、MongoDB 等。非关系型数据库不需要经过 SQL 的重重解析,所以性能很高;非关系型数据库的可扩展性比较强,数据之间没有耦合性,遇见需要新加字段的需求,就直接增加一个 key-value 键值对即可。
关系型数据库以表格
的形式存在,以行和列
的形式存取数据,关系型数据库这一系列的行和列被称为表,无数张表组成了数据库
,常见的关系型数据库有 Oracle、DB2、Microsoft SQL Server、MySQL等。关系型数据库能够支持复杂的 SQL 查询,能够体现出数据之间、表之间的关联关系;关系型数据库也支持事务,便于提交或者回滚。
它们之间的劣势都是基于对方的优势来满足的。
MySQL 事务四大特性
一说到 MySQL 事务,你肯定能想起来四大特性:原子性
、一致性
、隔离性
、持久性
,下面再对这事务的四大特性做一个描述
原子性(Atomicity)
: 原子性指的就是 MySQL 中的包含事务的操作要么全部成功
、要么全部失败回滚
,因此事务的操作如果成功就必须要全部应用到数据库,如果操作失败则不能对数据库有任何影响。
这里涉及到一个概念,什么是 MySQL 中的事务?
事务是一组操作,组成这组操作的各个单元,要不全都成功要不全都失败,这个特性就是事务。
在 MySQL 中,事务是在引擎层实现的,只有使用
innodb
引擎的数据库或表才支持事务。
-
一致性(Consistency)
:一致性指的是一个事务在执行前后其状态一致。比如 A 和 B 加起来的钱一共是 1000 元,那么不管 A 和 B 之间如何转账,转多少次,事务结束后两个用户的钱加起来还得是 1000,这就是事务的一致性。 -
持久性(Durability)
: 持久性指的是一旦事务提交,那么发生的改变就是永久性的,即使数据库遇到特殊情况比如故障的时候也不会产生干扰。 -
隔离性(Isolation)
:隔离性需要重点说一下,当多个事务同时进行时,就有可能出现脏读(dirty read)
、不可重复读(non-repeatable read)
、幻读(phantom read)
的情况,为了解决这些并发问题,提出了隔离性的概念。
脏读:事务 A 读取了事务 B 更新后的数据,但是事务 B 没有提交,然后事务 B 执行回滚操作,那么事务 A 读到的数据就是脏数据
不可重复读:事务 A 进行多次读取操作,事务 B 在事务 A 多次读取的过程中执行更新操作并提交,提交后事务 A 读到的数据不一致。
幻读:事务 A 将数据库中所有学生的成绩由 A -> B,此时事务 B 手动插入了一条成绩为 A 的记录,在事务 A 更改完毕后,发现还有一条记录没有修改,那么这种情况就叫做出现了幻读。
SQL的隔离级别有四种,它们分别是读未提交(read uncommitted)
、读已提交(read committed)
、可重复读(repetable read)
和 串行化(serializable)
。下面分别来解释一下。
读未提交:读未提交指的是一个事务在提交之前,它所做的修改就能够被其他事务所看到。
读已提交:读已提交指的是一个事务在提交之后,它所做的变更才能够让其他事务看到。
可重复读:可重复读指的是一个事务在执行的过程中,看到的数据是和启动时看到的数据是一致的。未提交的变更对其他事务不可见。
串行化:顾名思义是对于同一行记录,写
会加写锁
,读
会加读锁
。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
这四个隔离级别可以解决脏读、不可重复读、幻象读这三类问题。总结如下
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | 允许 | 允许 | 允许 |
读已提交 | 不允许 | 允许 | 允许 |
可重复读 | 不允许 | 不允许 | 允许 |
串行化 | 不允许 | 不允许 | 不允许 |
其中隔离级别由低到高是:读未提交 < 读已提交 < 可重复读 < 串行化
隔离级别越高,越能够保证数据的完整性和一致性,但是对并发的性能影响越大。大多数数据库的默认级别是读已提交(Read committed)
,比如 Sql Server、Oracle ,但是 MySQL 的默认隔离级别是 可重复读(repeatable-read)
。
MySQL 常见存储引擎的区别
MySQL 常见的存储引擎,可以使用
SHOW ENGINES
命令,来列出所有的存储引擎
可以看到,InnoDB 是 MySQL 默认支持的存储引擎,支持事务、行级锁定和外键。
MyISAM 存储引擎的特点
在 5.1 版本之前,MyISAM 是 MySQL 的默认存储引擎,MyISAM 并发性比较差,使用的场景比较少,主要特点是
-
不支持
事务
操作,ACID 的特性也就不存在了,这一设计是为了性能和效率考虑的。 -
不支持
外键
操作,如果强行增加外键,MySQL 不会报错,只不过外键不起作用。 -
MyISAM 默认的锁粒度是
表级锁
,所以并发性能比较差,加锁比较快,锁冲突比较少,不太容易发生死锁的情况。 -
MyISAM 会在磁盘上存储三个文件,文件名和表名相同,扩展名分别是
.frm(存储表定义)
、.MYD(MYData,存储数据)
、MYI(MyIndex,存储索引)
。这里需要特别注意的是 MyISAM 只缓存索引文件
,并不缓存数据文件。 -
MyISAM 支持的索引类型有
全局索引(Full-Text)
、B-Tree 索引
、R-Tree 索引
Full-Text 索引:它的出现是为了解决针对文本的模糊查询效率较低的问题。
B-Tree 索引:所有的索引节点都按照平衡树的数据结构来存储,所有的索引数据节点都在叶节点
R-Tree索引:它的存储方式和 B-Tree 索引有一些区别,主要设计用于存储空间和多维数据的字段做索引,目前的 MySQL 版本仅支持 geometry 类型的字段作索引,相对于 BTREE,RTREE 的优势在于范围查找。
-
数据库所在主机如果宕机,MyISAM 的数据文件容易损坏,而且难以恢复。
-
增删改查性能方面:SELECT 性能较高,适用于查询较多的情况
InnoDB 存储引擎的特点
自从 MySQL 5.1 之后,默认的存储引擎变成了 InnoDB 存储引擎,相对于 MyISAM,InnoDB 存储引擎有了较大的改变,它的主要特点是
- 支持事务操作,具有事务 ACID 隔离特性,默认的隔离级别是
可重复读(repetable-read)
、通过MVCC(并发版本控制)来实现的。能够解决脏读
和不可重复读
的问题。 - InnoDB 支持外键操作。
- InnoDB 默认的锁粒度
行级锁
,并发性能比较好,会发生死锁的情况。 - 和 MyISAM 一样的是,InnoDB 存储引擎也有
.frm文件存储表结构
定义,但是不同的是,InnoDB 的表数据与索引数据是存储在一起的,都位于 B+ 数的叶子节点上,而 MyISAM 的表数据和索引数据是分开的。 - InnoDB 有安全的日志文件,这个日志文件用于恢复因数据库崩溃或其他情况导致的数据丢失问题,保证数据的一致性。
- InnoDB 和 MyISAM 支持的索引类型相同,但具体实现因为文件结构的不同有很大差异。
- 增删改查性能方面,果执行大量的增删改操作,推荐使用 InnoDB 存储引擎,它在删除操作时是对行删除,不会重建表。
MyISAM 和 InnoDB 存储引擎的对比
锁粒度方面
:由于锁粒度不同,InnoDB 比 MyISAM 支持更高的并发;InnoDB 的锁粒度为行锁、MyISAM 的锁粒度为表锁、行锁需要对每一行进行加锁,所以锁的开销更大,但是能解决脏读和不可重复读的问题,相对来说也更容易发生死锁可恢复性上
:由于 InnoDB 是有事务日志的,所以在产生由于数据库崩溃等条件后,可以根据日志文件进行恢复。而 MyISAM 则没有事务日志。查询性能上
:MyISAM 要优于 InnoDB,因为 InnoDB 在查询过程中,是需要维护数据缓存,而且查询过程是先定位到行所在的数据块,然后在从数据块中定位到要查找的行;而 MyISAM 可以直接定位到数据所在的内存地址,可以直接找到数据。表结构文件上
: MyISAM 的表结构文件包括:.frm(表结构定义),.MYI(索引),.MYD(数据);而 InnoDB 的表数据文件为:.ibd和.frm(表结构定义);
MySQL 基础架构
这道题应该从 MySQL 架构来理解,我们可以把 MySQL 拆解成几个零件,如下图所示
大致上来说,MySQL 可以分为 Server
层和 存储引擎
层。
Server 层包括连接器、查询缓存、分析器、优化器、执行器,包括大多数 MySQL 中的核心功能,所有跨存储引擎的功能也在这一层实现,包括 存储过程、触发器、视图等。
存储引擎层包括 MySQL 常见的存储引擎,包括 MyISAM、InnoDB 和 Memory 等,最常用的是 InnoDB,也是现在 MySQL 的默认存储引擎。存储引擎也可以在创建表的时候手动指定,比如下面
CREATE TABLE t (i INT) ENGINE = <Storage Engine>;
然后我们就可以探讨 MySQL 的执行过程了
连接器
首先需要在 MySQL 客户端登陆才能使用,所以需要一个连接器
来连接用户和 MySQL 数据库,我们一般是使用
mysql -u 用户名 -p 密码
来进行 MySQL 登陆,和服务端建立连接。在完成 TCP 握手
后,连接器会根据你输入的用户名和密码验证你的登录身份。如果用户名或者密码错误,MySQL 就会提示 Access denied for user,来结束执行。如果登录成功后,MySQL 会根据权限表中的记录来判定你的权限。
查询缓存
连接完成后,你就可以执行 SQL 语句了,这行逻辑就会来到第二步:查询缓存。
MySQL 在得到一个执行请求后,会首先去 查询缓存
中查找,是否执行过这条 SQL 语句,之前执行过的语句以及结果会以 key-value
对的形式,被直接放在内存中。key 是查询语句,value 是查询的结果。如果通过 key 能够查找到这条 SQL 语句,就直接返回 SQL 的执行结果。
如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果就会被放入查询缓存中。可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直接返回结果,效率会很高。
**但是查询缓存不建议使用
为什么呢?因为只要在 MySQL 中对某一张表执行了更新操作,那么所有的查询缓存就会失效,对于更新频繁的数据库来说,查询缓存的命中率很低。
分析器
如果没有命中查询,就开始执行真正的 SQL 语句。
- 首先,MySQL 会根据你写的 SQL 语句进行解析,分析器会先做
词法分析
,你写的 SQL 就是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串是什么,代表什么。 - 然后进行
语法分析
,根据词法分析的结果, 语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。如果 SQL 语句不正确,就会提示 **You have an error in your SQL syntax
优化器
经过分析器的词法分析和语法分析后,你这条 SQL 就合法
了,MySQL 就知道你要做什么了。但是在执行前,还需要进行优化器的处理,优化器会判断你使用了哪种索引,使用了何种连接,优化器的作用就是确定效率最高的执行方案。
执行器
MySQL 通过分析器知道了你的 SQL 语句是否合法,你想要做什么操作,通过优化器知道了该怎么做效率最高,然后就进入了执行阶段,开始执行这条 SQL 语句
在执行阶段,MySQL 首先会判断你有没有执行这条语句的权限,没有权限的话,就会返回没有权限的错误。如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。对于有索引的表,执行的逻辑也差不多。
至此,MySQL 对于一条语句的执行过程也就完成了。
SQL 的执行顺序
我们在编写一个查询语句的时候
SELECT DISTINCT
< select_list >
FROM
< left_table > < join_type >
JOIN < right_table > ON < join_condition >
WHERE
< where_condition >
GROUP BY
< group_by_list >
HAVING
< having_condition >
ORDER BY
< order_by_condition >
LIMIT < limit_number >
它的执行顺序你知道吗?这道题就给你一个回答。
FROM 连接
首先,对 SELECT 语句执行查询时,对FROM
关键字两边的表执行连接,会形成笛卡尔积
,这时候会产生一个虚表VT1(virtual table)
首先先来解释一下什么是
笛卡尔积
现在我们有两个集合 A = {0,1} , B = {2,3,4}
那么,集合 A * B 得到的结果就是
A * B = {(0,2)、(1,2)、(0,3)、(1,3)、(0,4)、(1,4)};
B * A = {(2,0)、{2,1}、{3,0}、{3,1}、{4,0}、(4,1)};
上面 A B 和 B A 的结果就可以称为两个集合相乘的
笛卡尔积
我们可以得出结论,A 集合和 B 集合相乘,包含了集合 A 中的元素和集合 B 中元素之和,也就是 A 元素的个数 * B 元素的个数
再来解释一下什么是虚表
在 MySQL 中,有三种类型的表
一种是
永久表
,永久表就是创建以后用来长期保存数据的表一种是
临时表
,临时表也有两类,一种是和永久表一样,只保存临时数据,但是能够长久存在的;还有一种是临时创建的,SQL 语句执行完成就会删除。一种是
虚表
,虚表其实就是视图
,数据可能会来自多张表的执行结果。
ON 过滤
然后对 FROM 连接的结果进行 ON 筛选,创建 VT2,把符合记录的条件存在 VT2 中。
JOIN 连接
第三步,如果是 OUTER JOIN(left join、right join)
,那么这一步就将添加外部行,如果是 left join 就把 ON 过滤条件的左表添加进来,如果是 right join ,就把右表添加进来,从而生成新的虚拟表 VT3。
WHERE 过滤
第四步,是执行 WHERE 过滤器,对上一步生产的虚拟表引用 WHERE 筛选,生成虚拟表 VT4。
WHERE 和 ON 的区别
- 如果有外部列,ON 针对过滤的是关联表,主表(保留表)会返回所有的列;
- 如果没有添加外部列,两者的效果是一样的;
应用
- 对主表的过滤应该使用 WHERE;
- 对于关联表,先条件查询后连接则用 ON,先连接后条件查询则用 WHERE;
GROUP BY
根据 group by 字句中的列,会对 VT4 中的记录进行分组操作,产生虚拟机表 VT5。果应用了group by,那么后面的所有步骤都只能得到的 VT5 的列或者是聚合函数(count、sum、avg等)。
HAVING
紧跟着 GROUP BY 字句后面的是 HAVING,使用 HAVING 过滤,会把符合条件的放在 VT6
SELECT
第七步才会执行 SELECT 语句,将 VT6 中的结果按照 SELECT 进行刷选,生成 VT7
DISTINCT
在第八步中,会对 TV7 生成的记录进行去重操作,生成 VT8。事实上如果应用了 group by 子句那么 distinct 是多余的,原因同样在于,分组的时候是将列中唯一的值分成一组,同时只为每一组返回一行记录,那么所以的记录都将是不相同的。
ORDER BY
应用 order by 子句。按照 order_by_condition 排序 VT8,此时返回的一个游标,而不是虚拟表。sql 是基于集合的理论的,集合不会预先对他的行排序,它只是成员的逻辑集合,成员的顺序是无关紧要的。
SQL 语句执行的过程如下
什么是临时表,何时删除临时表
什么是临时表?MySQL 在执行 SQL 语句的过程中,通常会临时创建一些存储中间结果集
的表,临时表只对当前连接可见,在连接关闭时,临时表会被删除并释放所有表空间。
临时表分为两种:一种是内存临时表
,一种是磁盘临时表
,什么区别呢?内存临时表使用的是 MEMORY 存储引擎,而临时表采用的是 MyISAM 存储引擎。
MEMORY 存储引擎:
memory
是 MySQL 中一类特殊的存储引擎,它使用存储在内容中的内容来创建表,而且数据全部放在内存中。每个基于 MEMORY 存储引擎的表实际对应一个磁盘文件。该文件的文件名与表名相同,类型为frm
类型。而其数据文件,都是存储在内存中,这样有利于数据的快速处理,提高整个表的效率。MEMORY 用到的很少,因为它是把数据存到内存中,如果内存出现异常就会影响数据。如果重启或者关机,所有数据都会消失。因此,基于 MEMORY 的表的生命周期很短,一般是一次性的。
MySQL 会在下面这几种情况产生临时表
-
使用 UNION 查询:UNION 有两种,一种是
UNION
,一种是UNION ALL
,它们都用于联合查询;区别是 使用 UNION 会去掉两个表中的重复数据,相当于对结果集做了一下去重(distinct)
。使用 UNION ALL,则不会排重,返回所有的行。使用 UNION 查询会产生临时表。 -
使用
TEMPTABLE 算法
或者是 UNION 查询中的视图。TEMPTABLE 算法是一种创建临时表的算法,它是将结果放置到临时表中,意味这要 MySQL 要先创建好一个临时表,然后将结果放到临时表中去,然后再使用这个临时表进行相应的查询。 -
ORDER BY 和 GROUP BY 的子句不一样时也会产生临时表。
-
DISTINCT 查询并且加上 ORDER BY 时;
-
SQL中用到 SQL_SMALL_RESULT 选项时;如果查询结果比较小的时候,可以加上 SQL_SMALL_RESULT 来优化,产生临时表
-
FROM 中的子查询;
-
EXPLAIN 查看执行计划结果的 Extra 列中,如果使用
Using Temporary
就表示会用到临时表。
MySQL 常见索引类型
索引是存储在一张表中特定列上的数据结构
,索引是在列上创建的。并且,索引是一种数据结构。
在 MySQL 中,主要有下面这几种索引
全局索引(FULLTEXT)
:全局索引,目前只有 MyISAM 引擎支持全局索引,它的出现是为了解决针对文本的模糊查询效率较低的问题。哈希索引(HASH)
:哈希索引是 MySQL 中用到的唯一 key-value 键值对的数据结构,很适合作为索引。HASH 索引具有一次定位的好处,不需要像树那样逐个节点查找,但是这种查找适合应用于查找单个键的情况,对于范围查找,HASH 索引的性能就会很低。B-Tree 索引
:B 就是 Balance 的意思,BTree 是一种平衡树,它有很多变种,最常见的就是 B+ Tree,它被 MySQL 广泛使用。R-Tree 索引
:R-Tree 在 MySQL 很少使用,仅支持 geometry 数据类型,支持该类型的存储引擎只有MyISAM、BDb、InnoDb、NDb、Archive几种,相对于 B-Tree 来说,R-Tree 的优势在于范围查找。
varchar 和 char 的区别和使用场景
MySQL 中没有 nvarchar 数据类型,所以直接比较的是 varchar 和 char 的区别
char
:表示的是定长
的字符串,当你输入小于指定的数目,比如你指定的数目是 char(6)
,当你输入小于 6 个字符的时候,char 会在你最后一个字符后面补空值。当你输入超过指定允许最大长度后,MySQL 会报错
varchar
: varchar 指的是长度为 n 个字节的可变长度,并且是 非Unicode
的字符数据。n 的值是介于 1 – 8000 之间的数值。存储大小为实际大小。
Unicode 是一种字符编码方案,它为每种语言中的每个字符都设定了统一唯一的二进制编码,以实现跨语言、跨平台进行文本转换、处理的要求
使用 char 存储定长的数据非常方便、char 检索效率高,无论你存储的数据是否到了 10 个字节,都要去占用 10 字节的空间
使用 varchar 可以存储变长的数据,但存储效率没有 char 高。
什么是 内连接、外连接、交叉连接、笛卡尔积
连接的方式主要有三种:**外连接、内链接、交叉连接
-
外连接(OUTER JOIN)
:外连接分为三种,分别是左外连接(LEFT OUTER JOIN 或 LEFT JOIN)
、右外连接(RIGHT OUTER JOIN 或 RIGHT JOIN)
、全外连接(FULL OUTER JOIN 或 FULL JOIN)
左外连接:又称为左连接,这种连接方式会显示左表不符合条件的数据行,右边不符合条件的数据行直接显示 NULL
右外连接:也被称为右连接,他与左连接相对,这种连接方式会显示右表不符合条件的数据行,左表不符合条件的数据行直接显示 NULL
**MySQL 暂不支持全外连接
内连接(INNER JOIN)
:结合两个表中相同的字段,返回关联字段相符的记录。
笛卡尔积(Cartesian product)
: 我在上面提到了笛卡尔积,为了方便,下面再列出来一下。
现在我们有两个集合 A = {0,1} , B = {2,3,4}
那么,集合 A * B 得到的结果就是
A * B = {(0,2)、(1,2)、(0,3)、(1,3)、(0,4)、(1,4)};
B * A = {(2,0)、{2,1}、{3,0}、{3,1}、{4,0}、(4,1)};
上面 A B 和 B A 的结果就可以称为两个集合相乘的
笛卡尔积
我们可以得出结论,A 集合和 B 集合相乘,包含了集合 A 中的元素和集合 B 中元素之和,也就是 A 元素的个数 * B 元素的个数
-
交叉连接的原文是
Cross join
,就是笛卡尔积在 SQL 中的实现,SQL中使用关键字CROSS JOIN
来表示交叉连接,在交叉连接中,随便增加一个表的字段,都会对结果造成很大的影响。SELECT * FROM t_Class a CROSS JOIN t_Student b WHERE a.classid=b.classid
或者不用 CROSS JOIN,直接用 FROM 也能表示交叉连接的效果
SELECT * FROM t_Class a ,t_Student b WHERE a.classid=b.classid
如果表中字段比较多,不适宜用交叉连接,交叉连接的效率比较差。
-
全连接:全连接也就是
full join
,MySQL 中不支持全连接,但是可以使用其他连接查询来模拟全连接,可以使用UNION
和UNION ALL
进行模拟。例如``` (select colum1,colum2...columN from tableA ) union (select colum1,colum2...columN from tableB )
或 (select colum1,colum2...columN from tableA ) union all (select colum1,colum2...columN from tableB ); ```
使用 UNION 和 UNION ALL 的注意事项
通过 union 连接的 SQL 分别单独取出的列数必须相同
使用 union 时,多个相等的行将会被合并,由于合并比较耗时,一般不直接使用 union 进行合并,而是通常采用 union all 进行合并
谈谈 SQL 优化的经验
- 查询语句无论是使用哪种判断条件 等于、小于、大于,
WHERE
左侧的条件查询字段不要使用函数或者表达式 - 使用
EXPLAIN
命令优化你的 SELECT 查询,对于复杂、效率低的 sql 语句,我们通常是使用 explain sql 来分析这条 sql 语句,这样方便我们分析,进行优化。 - 当你的 SELECT 查询语句只需要使用一条记录时,要使用
LIMIT 1
- 不要直接使用
SELECT *
,而应该使用具体需要查询的表字段,因为使用 EXPLAIN 进行分析时,SELECT * 使用的是全表扫描,也就是type = all
。 - 为每一张表设置一个 ID 属性
- 避免在
WHERE
字句中对字段进行NULL
判断 - 避免在
WHERE
中使用!=
或<>
操作符 - 使用
BETWEEN AND
替代IN
- 为搜索字段创建索引
- 选择正确的存储引擎,InnoDB 、MyISAM 、MEMORY 等
- 使用
LIKE %abc%
不会走索引,而使用LIKE abc%
会走索引 - 对于枚举类型的字段(即有固定罗列值的字段),建议使用
ENUM
而不是VARCHAR
,如性别、星期、类型、类别等 - 拆分大的 DELETE 或 INSERT 语句
- 选择合适的字段类型,选择标准是 尽可能小、尽可能定长、尽可能使用整数。
- 字段设计尽可能使用
NOT NULL
- 进行水平切割或者垂直分割
水平分割:通过建立结构相同的几张表分别存储数据
垂直分割:将经常一起使用的字段放在一个单独的表中,分割后的表记录之间是一一对应关系。
Java基础面试题
- Java 基础面试题
- Java 基础篇
- Java 有哪些特点
- 描述一下值传递和引用传递的区别
- == 和 equals 区别是什么
- String 中的 equals 是如何重写的
- 为什么重写 equals 方法必须重写 hashcode 方法
- String s1 = new String("abc") 在内存中创建了几个对象
- String 为什么是不可变的、jdk 源码中的 String 如何定义的、为什么这么设计。
- static 关键字是干什么用的?谈谈你的理解
- final 关键字是干什么用的?谈谈你的理解
- 抽象类和接口的区别是什么
- 重写和重载的区别
- byte的取值范围是多少,怎么计算出来的
- HashMap 和 HashTable 的区别
- HashMap 和 HashSet 的区别
- HashMap 的底层结构
- HashMap 的长度为什么是 2 的幂次方
- HashMap 多线程操作导致死循环问题
- HashMap 线程安全的实现有哪些
- 讲一下 HashMap put 的过程
- ConcurrentHashMap 底层实现
- Integer 缓存池
- UTF-8 和 Unicode 的关系
- 项目为 UTF-8 环境,char c = ‘中’,是否合法
- Arrays.asList 获得的 List 应该注意什么
- Collection 和 Collections 的区别
- 你知道 fail-fast 和 fail-safe 吗
- ArrayList、LinkedList 和 Vector 的区别
- Exception 和 Error 有什么区别
- String、StringBuilder 和 StringBuffer 有什么区别
- 动态代理是基于什么原理
- int 和 Integer 的区别
- Java 提供了哪些 I/O 方式
- 谈谈你知道的设计模式
- Comparator 和 Comparable 有什么不同
- Object 类中一般都有哪些方法
- Java 泛型和类型擦除
- 反射的基本原理,反射创建类实例的三种方式是什么
- 强引用、若引用、虚引用和幻象引用的区别
- final、finally 和 finalize() 的区别
- 内部类有哪些分类,分别解释一下
- 说出几种常用的异常
- 静态绑定和动态绑定的区别
- Java 基础篇
Java 基础篇
Java 有哪些特点
并发性的
: 你可以在其中执行许多语句,而不必一次执行它面向对象的
:基于类和面向对象的编程语言。独立性的
: 支持一次编写,到处运行的独立编程语言,即编译后的代码可以在支持 Java 的所有平台上运行。
Java 的特性
Java 的特性有如下这几点
-
简单
,Java 会让你的工作变得更加轻松,使你把关注点放在主要业务逻辑上,而不必关心指针、运算符重载、内存回收等与主要业务无关的功能。 -
便携性
,Java 是平台无关性的,这意味着在一个平台上编写的任何应用程序都可以轻松移植到另一个平台上。 -
安全性
, 编译后会将所有的代码转换为字节码,人类无法读取。它使开发无病毒,无篡改的系统/应用成为可能。 -
动态性
,它具有适应不断变化的环境的能力,它能够支持动态内存分配,从而减少了内存浪费,提高了应用程序的性能。 -
分布式
,Java 提供的功能有助于创建分布式应用。使用远程方法调用(RMI)
,程序可以通过网络调用另一个程序的方法并获取输出。您可以通过从互联网上的任何计算机上调用方法来访问文件。这是革命性的一个特点,对于当今的互联网来说太重要了。 -
健壮性
,Java 有强大的内存管理功能,在编译和运行时检查代码,它有助于消除错误。 -
高性能
,Java 最黑的科技就是字节码编程,Java 代码编译成的字节码可以轻松转换为本地机器代码。通过 JIT 即时编译器来实现高性能。 -
解释性
,Java 被编译成字节码,由 Java 运行时环境解释。 -
多线程性
,Java支持多个执行线程(也称为轻量级进程),包括一组同步原语。这使得使用线程编程更加容易,Java 通过管程模型来实现线程安全性。
描述一下值传递和引用传递的区别
要想真正理解的话,可以参考这篇文章 : https://www.zhihu.com/question/31203609
简单理解的话就是
值传递
是指在调用函数时将实际参数复制一份到函数中,这样的话如果函数对其传递过来的形式参数进行修改,将不会影响到实际参数
引用传递
是指在调用函数时将对象的地址直接传递到函数中,如果在对形式参数进行修改,将影响到实际参数的值。
== 和 equals 区别是什么
==
是 Java 中一种操作符,它有两种比较方式
- 对于
基本数据类型
来说, == 判断的是两边的值
是否相等
public class DoubleCompareAndEquals {
Person person1 = new Person(24,"boy");
Person person2 = new Person(24,"girl");
int c = 10;
private void doubleCompare(){
int a = 10;
int b = 10;
System.out.println(a == b);
System.out.println(a == c);
System.out.println(person1.getId() == person2.getId());
}
}
- 对于
引用类型
来说, == 判断的是两边的引用
是否相等,也就是判断两个对象是否指向了同一块内存区域。
private void equals(){
System.out.println(person1.getName().equals(person2.getName()));
}
equals
是 Java 中所有对象的父类,即 Object
类定义的一个方法。它只能比较对象,它表示的是引用双方的值是否相等。所以记住,并不是说 == 比较的就是引用是否相等,equals 比较的就是值,这需要区分来说的。
equals 用作对象之间的比较具有如下特性
自反性
:对于任何非空引用 x 来说,x.equals(x) 应该返回 true。对称性
:对于任何非空引用 x 和 y 来说,若x.equals(y)为 true,则y.equals(x)也为 true。传递性
:对于任何非空引用的值来说,有三个值,x、y 和 z,如果x.equals(y) 返回true,y.equals(z) 返回true,那么x.equals(z) 也应该返回true。一致性
:对于任何非空引用 x 和 y 来说,如果 x.equals(y) 相等的话,那么它们必须始终相等。非空性
:对于任何非空引用的值 x 来说,x.equals(null) 必须返回 false。
String 中的 equals 是如何重写的
String 代表的是 Java 中的字符串
,String 类比较特殊,它整个类都是被 final
修饰的,也就是说,String 不能被任何类继承,任何 修改
String 字符串的方法都是创建了一个新的字符串。
equals 方法是 Object 类定义的方法,Object 是所有类的父类,当然也包括 String,String 重写了 equals
方法,下面我们来看看是怎么重写的
- 首先会判断要比较的两个字符串它们的
引用
是否相等。如果引用相等的话,直接返回 true ,不相等的话继续下面的判断 - 然后再判断被比较的对象是否是 String 的实例,如果不是的话直接返回 false,如果是的话,再比较两个字符串的长度是否相等,如果长度不想等的话也就没有比较的必要了;长度如果相同,会比较字符串中的每个
字符
是否相等,一旦有一个字符不相等,就会直接返回 false。
下面是它的流程图
这里再提示一下,你可能有疑惑什么时候是
if (this == anObject) {
return true;
}
这个判断语句如何才能返回 true?因为都是字符串啊,字符串比较的不都是堆空间吗,猛然一看发现好像永远也不会走,但是你忘记了 String.intern()
方法,它表示的概念在不同的 JDK 版本有不同的区分
在 JDK1.7 及以后调用 intern 方法是判断运行时常量池中是否有指定的字符串,如果没有的话,就把字符串添加到常量池中,并返回常量池中的对象。
验证过程如下
private void StringOverrideEquals(){
String s1 = "aaa";
String s2 = "aa" + new String("a");
String s3 = new String("aaa");
System.out.println(s1.intern().equals(s1));
System.out.println(s1.intern().equals(s2));
System.out.println(s3.intern().equals(s1));
}
-
首先 s1.intern.equals(s1) 这个无论如何都返回 true,因为 s1 字符串创建出来就已经在常量池中存在了。
-
然后第二条语句返回 false,因为 s1 返回的是常量池中的对象,而 s2 返回的是堆中的对象
-
第三条语句 s3.intern.equals(s1),返回 true ,因为 s3 对象虽然在堆中创建了一个对象,但是 s3 中的 "aaa" 返回的是常量池中的对象。
为什么重写 equals 方法必须重写 hashcode 方法
equals 方法和 hashCode 都是 Object 中定义的方法,它们经常被一起重写。
equals 方法是用来比较对象大小是否相等的方法,hashcode 方法是用来判断每个对象 hash 值的一种方法。如果只重写 equals 方法而不重写 hashcode 方法,很可能会造成两个不同的对象,它们的 hashcode 也相等,造成冲突。比如
String str1 = "通话";
String str2 = "重地";
它们两个的 hashcode 相等,但是 equals 可不相等。
我们来看一下 hashCode 官方的定义
总结起来就是
- 如果在 Java 运行期间对同一个对象调用 hashCode 方法后,无论调用多少次,都应该返回相同的 hashCode,但是在不同的 Java 程序中,执行 hashCode 方法返回的值可能不一致。
- 如果两个对象的 equals 相等,那么 hashCode 必须相同
- 如果两个对象 equals 不相等,那么 hashCode 也有可能相同,所以需要重写 hashCode 方法,因为你不知道 hashCode 的底层构造(反正我是不知道,有大牛可以传授传授),所以你需要重写 hashCode 方法,来为不同的对象生成不同的 hashCode 值,这样能够提高不同对象的访问速度。
- hashCode 通常是将地址转换为整数来实现的。
String s1 = new String("abc") 在内存中创建了几个对象
一个或者两个,String s1 是声明了一个 String 类型的 s1 变量,它不是对象。使用 new
关键字会在堆中创建一个对象,另外一个对象是 abc
,它会在常量池中创建,所以一共创建了两个对象;如果 abc 在常量池中已经存在的话,那么就会创建一个对象。
详细请翻阅笔者的另外一篇文章 一篇与众不同的 String、StringBuffer、StringBuilder 详解
String 为什么是不可变的、jdk 源码中的 String 如何定义的、为什么这么设计。
首先了解一下什么是不可变对象
,不可变对象就是一经创建后,其对象的内部状态不能被修改,啥意思呢?也就是说不可变对象需要遵守下面几条原则
- 不可变对象的内部属性都是 final 的
- 不可变对象的内部属性都是 private 的
- 不可变对象不能提供任何可以修改内部状态的方法、setter 方法也不行
- 不可变对象不能被继承和扩展
与其说问 String 为什么是不可变的,不如说如何把 String 设计成不可变的。
String 类是一种对象,它是独立于 Java 基本数据类型而存在的,String 你可以把它理解为字符串的集合,String 被设计为 final 的,表示 String 对象一经创建后,它的值就不能再被修改,任何对 String 值进行修改的方法就是重新创建一个字符串。String 对象创建后会存在于运行时常量池中,运行时常量池是属于方法区的一部分,JDK1.7 后把它移到了堆中。
不可变对象不是真的不可变,可以通过反射
来对其内部的属性和值进行修改,不过一般我们不这样做。
static 关键字是干什么用的?谈谈你的理解
static 是 Java 中非常重要的关键字,static 表示的概念是 静态的
,在 Java 中,static 主要用来
- 修饰变量,static 修饰的变量称为
静态变量
、也称为类变量
,类变量属于类所有,对于不同的类来说,static 变量只有一份,static 修饰的变量位于方法区中;static 修饰的变量能够直接通过 类名.变量名 来进行访问,不用通过实例化类再进行使用。 - 修饰方法,static 修饰的方法被称为
静态方法
,静态方法能够直接通过 类名.方法名 来使用,在静态方法内部不能使用非静态属性和方法 - static 可以修饰代码块,主要分为两种,一种直接定义在类中,使用
static{}
,这种被称为静态代码块
,一种是在类中定义静态内部类
,使用static class xxx
来进行定义。 - static 可以用于静态导包,通过使用
import static xxx
来实现,这种方式一般不推荐使用 - static 可以和单例模式一起使用,通过双重检查锁来实现线程安全的单例模式。
详情请参考这篇文章 一篇 static 还能难得住我?
final 关键字是干什么用的?谈谈你的理解
final 是 Java 中的关键字,它表示的意思是 不可变的
,在 Java 中,final 主要用来
- 修饰类,final 修饰的类不能被继承,不能被继承的意思就是不能使用
extends
来继承被 final 修饰的类。 - 修饰变量,final 修饰的变量不能被改写,不能被改写的意思有两种,对于基本数据类型来说,final 修饰的变量,其值不能被改变,final 修饰的对象,对象的引用不能被改变,但是对象内部的属性可以被修改。final 修饰的变量在某种程度上起到了
不可变
的效果,所以,可以用来保护只读数据,尤其是在并发编程中,因为明确的不能再为 final 变量进行赋值,有利于减少额外的同步开销。 - 修饰方法,final 修饰的方法不能被重写。
- final 修饰符和 Java 程序性能优化没有必然联系
抽象类和接口的区别是什么
抽象类和接口都是 Java 中的关键字,抽象类和接口中都允许进行方法的定义,而不用具体的方法实现。抽象类和接口都允许被继承,它们广泛的应用于 JDK 和框架的源码中,来实现多态和不同的设计模式。
不同点在于
抽象级别不同
:类、抽象类、接口其实是三种不同的抽象级别,抽象程度依次是 接口 > 抽象类 > 类。在接口中,只允许进行方法的定义,不允许有方法的实现,抽象类中可以进行方法的定义和实现;而类中只允许进行方法的实现,我说的方法的定义是不允许在方法后面出现{}
使用的关键字不同
:类使用class
来表示;抽象类使用abstract class
来表示;接口使用interface
来表示变量
:接口中定义的变量只能是公共的静态常量,抽象类中的变量是普通变量。
重写和重载的区别
在 Java 中,重写和重载都是对同一方法的不同表现形式,下面我们针对重写和重载做一下简单的区分
子父级关系不同
,重写是针对子级和父级的不同表现形式,而重载是在同一类中的不同表现形式;概念不同
,子类重写父类的方法一般使用@override
来表示;重写后的方法其方法的声明和参数类型、顺序必须要与父类完全一致;重载是针对同一类中概念,它要求重载的方法必须满足下面任何一个要求:方法参数的顺序,参数的个数,参数的类型任意一个保持不同即可。
byte的取值范围是多少,怎么计算出来的
byte 的取值范围是 -128 -> 127 之间,一共是 256 位。一个 byte 类型在计算机中占据一个字节,那么就是 8 bit,所以最大就是 2^7 = 1111 1111。
Java 中用补码
来表示二进制数,补码的最高位是符号位,最高位用 0 表示正数,最高位 1 表示负数,正数的补码就是其本身
,由于最高位是符号位,所以正数表示的就是 0111 1111 ,也就是 127。最大负数就是 1111 1111,这其中会涉及到两个 0 ,一个 +0 ,一个 -0 ,+0 归为正数,也就是 0 ,-0 归为负数,也就是 -128,所以 byte 的范围就是 -128 – 127。
HashMap 和 HashTable 的区别
**相同点
HashMap 和 HashTable 都是基于哈希表实现的,其内部每个元素都是 key-value
键值对,HashMap 和 HashTable 都实现了 Map、Cloneable、Serializable 接口。
**不同点
-
父类不同:HashMap 继承了
AbstractMap
类,而 HashTable 继承了Dictionary
类 -
空值不同:HashMap 允许空的 key 和 value 值,HashTable 不允许空的 key 和 value 值。HashMap 会把 Null key 当做普通的 key 对待。不允许 null key 重复。
- 线程安全性:HashMap 不是线程安全的,如果多个外部操作同时修改 HashMap 的数据结构比如 add 或者是 delete,必须进行同步操作,仅仅对 key 或者 value 的修改不是改变数据结构的操作。可以选择构造线程安全的 Map 比如
Collections.synchronizedMap
或者是ConcurrentHashMap
。而 HashTable 本身就是线程安全的容器。 - 性能方面:虽然 HashMap 和 HashTable 都是基于
单链表
的,但是 HashMap 进行 put 或者 get 操作,可以达到常数时间的性能;而 HashTable 的 put 和 get 操作都是加了synchronized
锁的,所以效率很差。
- 初始容量不同:HashTable 的初始长度是11,之后每次扩充容量变为之前的 2n+1(n为上一次的长度)而 HashMap 的初始长度为16,之后每次扩充变为原来的两倍。创建时,如果给定了容量初始值,那么HashTable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。
HashMap 和 HashSet 的区别
HashSet 继承于 AbstractSet 接口,实现了 Set、Cloneable,、java.io.Serializable 接口。HashSet 不允许集合中出现重复的值。HashSet 底层其实就是 HashMap,所有对 HashSet 的操作其实就是对 HashMap 的操作。所以 HashSet 也不保证集合的顺序,也不是线程安全的容器。
HashMap 的底层结构
JDK1.7 中,HashMap 采用位桶 + 链表
的实现,即使用链表
来处理冲突,同一 hash 值的链表都存储在一个数组中。但是当位于一个桶中的元素较多,即 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。
所以,与 JDK 1.7 相比,JDK 1.8 在底层结构方面做了一些改变,当每个桶中元素大于 8 的时候,会转变为红黑树,目的就是优化查询效率。
HashMap 的长度为什么是 2 的幂次方
这道题我想了几天,之前和群里小伙伴们探讨每日一题的时候,问他们为什么 length%hash == (n – 1) & hash,它们说相等的前提是 length 的长度 2 的幂次方,然后我回了一句难道 length 还能不是 2 的幂次方吗?其实是我没有搞懂因果关系,因为 HashMap 的长度是 2 的幂次方,所以使用余数来判断在桶中的下标。如果 length 的长度不是 2 的幂次方,小伙伴们可以举个例子来试试
例如长度为 9 时候,3 & (9-1) = 0,2 & (9-1) = 0 ,都在 0 上,碰撞了;
这样会增大 HashMap 碰撞的几率。
HashMap 多线程操作导致死循环问题
HashMap 不是一个线程安全的容器,在高并发场景下,应该使用 ConcurrentHashMap
,在多线程场景下使用 HashMap 会造成死循环问题(基于 JDK1.7),出现问题的位置在 rehash
处,也就是
do {
Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
这是 JDK1.7 的 rehash 代码片段,在并发的场景下会形成环。
JDK1.8 也会造成死循环问题。
HashMap 线程安全的实现有哪些
因为 HashMap 不是一个线程安全的容器,所以并发场景下推荐使用 ConcurrentHashMap
,或者使用线程安全的 HashMap,使用 Collections
包下的线程安全的容器,比如说
Collections.synchronizedMap(new HashMap());
还可以使用 HashTable ,它也是线程安全的容器,基于 key-value 存储,经常用 HashMap 和 HashTable 做比较就是因为 HashTable 的数据结构和 HashMap 相同。
上面效率最高的就是 ConcurrentHashMap。
讲一下 HashMap put 的过程
首先会使用 hash 函数来计算 key,然后执行真正的插入方法
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 为null 或者没有为table分配内存,就resize一次
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 指定hash值节点为空则直接插入,这个(n - 1) & hash才是表中真正的哈希
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果不为空
else {
Node<K,V> e; K k;
// 计算表中的这个真正的哈希值与要插入的key.hash相比
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);
// key.hash 不同并且也不再 TreeNode 上,在链表上找到 p.next==null
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 在表尾插入
p.next = newNode(hash, key, value, null);
// 新增节点后如果节点个数到达阈值,则进入 treeifyBin() 进行再次判断
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到了同hash、key的节点,那么直接退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 更新 p 指向下一节点
p = e;
}
}
// map中含有旧值,返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// map调整次数 + 1
++modCount;
// 键值对的数量达到阈值,需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap put 方法的核心就是在 putval
方法,它的插入过程如下
- 首先会判断 HashMap 中是否是新构建的,如果是的话会首先进行 resize
- 然后判断需要插入的元素在 HashMap 中是否已经存在(说明出现了碰撞情况),如果不存在,直接生成新的k-v 节点存放,再判断是否需要扩容。
- 如果要插入的元素已经存在的话,说明发生了冲突,这就会转换成链表或者红黑树来解决冲突,首先判断链表中的 hash,key 是否相等,如果相等的话,就用新值替换旧值,如果节点是属于 TreeNode 类型,会直接在红黑树中进行处理,如果 hash ,key 不相等也不属于 TreeNode 类型,会直接转换为链表处理,进行链表遍历,如果链表的 next 节点是 null,判断是否转换为红黑树,如果不转换的话,在遍历过程中找到 key 完全相等的节点,则用新节点替换老节点
ConcurrentHashMap 底层实现
ConcurrentHashMap 是线程安全的 Map,它也是高并发场景下的首选数据结构,ConcurrentHashMap 底层是使用分段锁
来实现的。
Integer 缓存池
Integer 缓存池也就是 IntegerCache
,它是 Integer 的静态内部类。
它的默认值用于缓存 -128 – 127 之间的数字,如果有 -128 – 127 之间的数字的话,使用 new Integer 不用创建对象,会直接从缓存池中取,此操作会减少堆中对象的分配,有利于提高程序的运行效率。
例如创建一个 Integer a = 24,其实是调用 Integer 的 valueOf
,可以通过反编译得出这个结论
然后我们看一下 valueOf 方法
如果在指定缓存池范围内的话,会直接返回缓存的值而不用创建新的 Integer 对象。
缓存的大小可以使用 XX:AutoBoxCacheMax
来指定,在 VM 初始化时,java.lang.Integer.IntegerCache.high
属性会设置和保存在 sun.misc.VM
的私有系统属性中。
UTF-8 和 Unicode 的关系
由于每个国家都有自己独有的字符编码,所以Unicode 的发展旨在创建一个新的标准,用来映射当今使用的大多数语言中的字符,这些字符有一些不是必要的,但是对于创建文本来说却是不可或缺的。Unicode 统一了所有字符的编码,是一个 Character Set,也就是字符集,字符集只是给所有的字符一个唯一编号,但是却没有规定如何存储,不同的字符其存储空间不一样,有的需要一个字节就能存储,有的则需要2、3、4个字节。
UTF-8 只是众多能够对文本字符进行解码
的一种方式,它是一种变长的方式。UTF-8 代表 8 位一组表示 Unicode 字符的格式,使用 1 – 4 个字节来表示字符。
U+ 0000 ~ U+ 007F: 0XXXXXXX
U+ 0080 ~ U+ 07FF: 110XXXXX 10XXXXXX
U+ 0800 ~ U+ FFFF: 1110XXXX 10XXXXXX 10XXXXXX
U+10000 ~ U+1FFFF: 11110XXX 10XXXXXX 10XXXXXX 10XXXXXX
可以看到,UTF-8 通过开头的标志位位数实现了变长。对于单字节字符,只占用一个字节,实现了向下兼容 ASCII,并且能和 UTF-32 一样,包含 Unicode 中的所有字符,又能有效减少存储传输过程中占用的空间。
项目为 UTF-8 环境,char c = ‘中’,是否合法
可以,因为 Unicode 编码采用 2 个字节的编码,UTF-8 是 Unicode 的一种实现,它使用可变长度的字符集进行编码,char c = ‘中’ 是两个字节,所以能够存储。合法。
Arrays.asList 获得的 List 应该注意什么
Arrays.asList
是 Array 中的一个静态方法,它能够实现把数组转换成为 List 序列,需要注意下面几点
- Arrays.asList 转换完成后的 List 不能再进行结构化的修改,什么是结构化的修改?就是不能再进行任何 List 元素的增加或者减少的操作。
public static void main(String[] args) {
Integer[] integer = new Integer[] { 1, 2, 3, 4 };
List integetList = Arrays.asList(integer);
integetList.add(5);
}
结果会直接抛出
Exception in thread "main" java.lang.UnsupportedOperationException
我们看一下源码就能发现问题
// 这是 java.util.Arrays 的内部类,而不是 java.util.ArrayList
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
继承 AbstractList 中对 add、remove、set 方法是直接抛异常的,也就是说如果继承的子类没有去重写这些方法,那么子类的实例去调用这些方法是会直接抛异常的。
下面是AbstractList中方法的定义,我们可以看到具体抛出的异常:
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
public E remove(int index) {
throw new UnsupportedOperationException();
}
public E set(int index, E element) {
throw new UnsupportedOperationException();
}
虽然 set 方法也抛出了一场,但是由于 内部类 ArrayList 重写了 set 方法,所以支持其可以对元素进行修改。
- Arrays.asList 不支持基础类型的转换
Java 中的基础数据类型(byte,short,int,long,float,double,boolean)是不支持使用 Arrays.asList 方法去转换的
Collection 和 Collections 的区别
Collection 和 Collections 都是位于 java.util
包下的类
Collection 是集合类的父类,它是一个顶级接口,大部分抽象类比如说 AbstractList
、AbstractSet
都继承了 Collection 类,Collection 类只定义一节标准方法比如说 add、remove、set、equals 等,具体的方法由抽象类或者实现类去实现。
Collections 是集合类的工具类,Collections 提供了一些工具类的基本使用
- sort 方法,对当前集合进行排序, 实现 Comparable 接口的类,只能使用一种排序方案,这种方案叫做自然比较
- 比如实现线程安全的容器
Collections.synchronizedList
、Collections.synchronizedMap
等 - reverse 反转,使用 reverse 方法可以根据元素的自然顺序 对指定列表按降序进行排序。
- fill,使用指定元素替换指定列表中的所有元素。
有很多用法,读者可以翻阅 Collections 的源码查看,Collections 不能进行实例化,所以 Collections 中的方法都是由 Collections.方法
直接调用。
你知道 fail-fast 和 fail-safe 吗
fail-fast
是 Java 中的一种快速失败
机制,java.util 包下所有的集合都是快速失败的,快速失败会抛出 ConcurrentModificationException
异常,fail-fast 你可以把它理解为一种快速检测机制,它只能用来检测错误,不会对错误进行恢复,fail-fast 不一定只在多线程
环境下存在,ArrayList 也会抛出这个异常,主要原因是由于 modCount 不等于 expectedModCount。
fail-safe
是 Java 中的一种 安全失败
机制,它表示的是在遍历时不是直接在原集合上进行访问,而是先复制原有集合内容,在拷贝的集合上进行遍历。 由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 ConcurrentModificationException。java.util.concurrent
包下的容器都是安全失败的,可以在多线程条件下使用,并发修改。
ArrayList、LinkedList 和 Vector 的区别
这也是一道老生常谈的问题了
ArrayList、LinkedList、Vector 都是位于 java.util
包下的工具类,它们都实现了 List 接口。
- ArrayList 的底层是动态数组,它是基于数组的特性而演变出来的,所以ArrayList 遍历访问非常快,但是增删比较慢,因为会涉及到数组的拷贝。ArrayList 是一个非线程安全的容器,在并发场景下会造成问题,如果想使用线程安全的容器的话,推荐使用
Collections.synchronizedList
;ArrayList 在扩容时会增加 50% 的容量。 - LinkedList 的底层是双向链表,所以 LinkedList 的增加和删除非常快,只需把元素删除,把各自的指针指向新的元素即可。但是 LinkedList 遍历比较慢,因为只有每次访问一个元素才能知道下一个元素的值。LinkedList 也是一个非线程安全的容器,推荐使用
Collections.synchronizedList
- Vector 向量是最早出现的集合容器,Vector 是一个线程安全的容器,它的每个方法都粗暴的加上了
synchronized
锁,所以它的增删、遍历效率都很低。Vector 在扩容时,它的容量会增加一倍。
Exception 和 Error 有什么区别
Exception 泛指的是 异常
,Exception 主要分为两种异常,一种是编译期出现的异常,称为 checkedException
,一种是程序运行期间出现的异常,称为 uncheckedException
,常见的 checkedException 有 IOException
,uncheckedException 统称为 RuntimeException
,常见的 RuntimeException 主要有NullPointerException
、 IllegalArgumentException
、ArrayIndexOutofBoundException
等,Exception 可以被捕获。
Error 是指程序运行过程中出现的错误,通常情况下会造成程序的崩溃,Error 通常是不可恢复的,Error 不能被捕获。
详细可以参考这篇文章 看完这篇 Exception 和 Error ,和面试官扯皮就没问题了
String、StringBuilder 和 StringBuffer 有什么区别
String 特指的是 Java 中的字符串,String 类位于 java.lang
包下,String 类是由 final 修饰的,String 字符串一旦创建就不能被修改,任何对 String 进行修改的操作都相当于重新创建了一个字符串。String 字符串的底层使用 StringBuilder 来实现的
StringBuilder 位于 java.util
包下,StringBuilder 是一非线程安全的容器,StringBuilder 的 append 方法常用于字符串拼接,它的拼接效率要比 String 中 +
号的拼接效率高。StringBuilder 一般不用于并发环境
StringBuffer 位于 java.util
包下,StringBuffer 是一个线程安全的容器,多线程场景下一般使用 StringBuffer 用作字符串的拼接
StringBuilder 和 StringBuffer 都是继承于AbstractStringBuilder 类,AbstractStringBuilder 类实现了 StringBuffer 和 StringBuilder 的常规操作。
动态代理是基于什么原理
代理一般分为静态代理
和 动态代理
,它们都是代理模式的一种应用,静态代理指的是在程序运行前已经编译好,程序知道由谁来执行代理方法。
而动态代理只有在程序运行期间才能确定,相比于静态代理, 动态代理的优势在于可以很方便的对代理类的函数进行统一的处理,而不用修改每个代理类中的方法。可以说动态代理是基于 反射
实现的。通过反射我们可以直接操作类或者对象,比如获取类的定义,获取声明的属性和方法,调用方法,在运行时可以修改类的定义。
动态代理是一种在运行时构建代理、动态处理方法调用的机制。动态代理的实现方式有很多,Java 提供的代理被称为 JDK 动态代理
,JDK 动态代理是基于类的继承。
int 和 Integer 的区别
int 和 Integer 区别可就太多了
- int 是 Java 中的基本数据类型,int 代表的是
整型
,一个 int 占 4 字节,也就是 32 位,int 的初始值是默认值是 0 ,int 在 Java 内存模型中被分配在栈中,int 没有方法。 - Integer 是 Java 中的基本数据类型的包装类,Integer 是一个对象,Integer 可以进行方法调用,Integer 的默认值是 null,Integer 在 Java 内存模型中被分配在堆中。int 和 Integer 在计算时可以进行相互转换,int -> Integer 的过程称为
装箱
,Integer -> int 的过程称为拆箱
,Integer 还有 IntegerCache ,会自动缓存 -128 – 127 中的值
Java 提供了哪些 I/O 方式
Java I/O 方式有很多种,传统的 I/O 也称为 BIO
,主要流有如下几种
Java I/O 包的实现比较简单,但是容易出现性能瓶颈,传统的 I/O 是基于同步阻塞的。
JDK 1.4 之后提供了 NIO
,也就是位于 java.nio
包下,提供了基于 channel、Selector、Buffer的抽象,可以构建多路复用、同步非阻塞 I/O 程序。
JDK 1.7 之后对 NIO 进行了进一步改进,引入了 异步非阻塞
的方式,也被称为 AIO(Asynchronous IO)
。可以用生活中的例子来说明:项目经理交给手下员工去改一个 bug,那么项目经理不会一直等待员工解决 bug,他肯定在员工解决 bug 的期间给其他手下分配 bug 或者做其他事情,员工解决完 bug 之后再告诉项目经理 bug 解决完了。
谈谈你知道的设计模式
一张思维导图镇场
比如全局唯一性可以用 单例模式
。
可以使用 策略模式
优化过多的 if…else…
制定标准用 模版模式
接手其他人的锅,但不想改原来的类用 适配器模式
使用 组合
而不是继承
使用 装饰器
可以制作加糖、加奶酪的咖啡
代理
可以用于任何中间商……
Comparator 和 Comparable 有什么不同
-
Comparable 更像是自然排序
-
Comparator 更像是定制排序
**同时存在时采用 Comparator(定制排序)的规则进行比较。
对于一些普通的数据类型(比如 String, Integer, Double…),它们默认实现了Comparable 接口,实现了 compareTo 方法,我们可以直接使用。
而对于一些自定义类,它们可能在不同情况下需要实现不同的比较策略,我们可以新创建 Comparator 接口,然后使用特定的 Comparator 实现进行比较。
Object 类中一般都有哪些方法
Object 类是所有对象的父类,它里面包含一些所有对象都能够使用的方法
- hashCode():用于计算对象的哈希码
- equals():用于对象之间比较值是否相等
- toString(): 用于把对象转换成为字符串
- clone(): 用于对象之间的拷贝
- wait(): 用于实现对象之间的等待
- notify(): 用于通知对象释放资源
- notifyAll(): 用于通知所有对象释放资源
- finalize(): 用于告知垃圾回收器进行垃圾回收
- getClass(): 用于获得对象类
Java 泛型和类型擦除
反射的基本原理,反射创建类实例的三种方式是什么
反射机制就是使 Java 程序在运行时具有自省(introspect)
的能力,通过反射我们可以直接操作类和对象,比如获取某个类的定义,获取类的属性和方法,构造方法等。
创建类实例的三种方式是
- 对象实例.getClass();
- 通过 Class.forName() 创建
- 对象实例.newInstance() 方法创建
强引用、若引用、虚引用和幻象引用的区别
我们说的不同的引用类型其实都是逻辑上的,而对于虚拟机来说,主要体现的是对象的不同的可达性(reachable)
状态和对垃圾收集(garbage collector)
的影响。
可以通过下面的流程来对对象的生命周期做一个总结
对象被创建并初始化,对象在运行时被使用,然后离开对象的作用域,对象会变成不可达并会被垃圾收集器回收。图中用红色标明的区域表示对象处于强可达阶段。
JDK1.2 介绍了 java.lang.ref
包,对象的生命周期有四个阶段:强可达(Strongly Reachable)
、软可达(Soft Reachable)
、弱可达(Weak Reachable)
、 幻象可达(Phantom Reachable)
。
如果只讨论符合垃圾回收条件的对象,那么只有三种:软可达、弱可达和幻象可达。
-
软可达:软可达就是我们只能通过软引用才能访问的状态,软可达的对象是由
SoftReference
引用的对象,并且没有强引用的对象。软引用是用来描述一些还有用但是非必须的对象。垃圾收集器会尽可能长时间的保留软引用的对象,但是会在发生OutOfMemoryError
之前,回收软引用的对象。如果回收完软引用的对象,内存还是不够分配的话,就会直接抛出 OutOfMemoryError。 -
弱可达:弱可达的对象是
WeakReference
引用的对象。垃圾收集器可以随时收集弱引用的对象,不会尝试保留软引用的对象。 -
幻象可达:幻象可达是由
PhantomReference
引用的对象,幻象可达就是没有强、软、弱引用进行关联,并且已经被 finalize 过了,只有幻象引用指向这个对象的时候。
除此之外,还有强可达和不可达的两种可达性判断条件
- 强可达:就是一个对象刚被创建、初始化、使用中的对象都是处于强可达的状态
不可达(unreachable)
:处于不可达的对象就意味着对象可以被清除了。
下面是一个不同可达性状态的转换图
判断可达性条件,也是 JVM 垃圾收集器决定如何处理对象的一部分考虑因素。
所有的对象可达性引用都是 java.lang.ref.Reference
的子类,它里面有一个get()
方法,返回引用对象。 如果已通过程序或垃圾收集器清除了此引用对象,则此方法返回 null 。也就是说,除了幻象引用外,软引用和弱引用都是可以得到对象的。而且这些对象可以人为拯救
,变为强引用,例如把 this 关键字赋值给对象,只要重新和引用链上的任意一个对象建立关联即可。
final、finally 和 finalize() 的区别
这三者可以说是没有任何关联之处,我们上面谈到了,final 可以用来修饰类、变量和方法,可以参考上面 final 的那道面试题。
finally 是一个关键字,它经常和 try 块一起使用,用于异常处理。使用 try…finally 的代码块种,finally 部分的代码一定会被执行,所以我们经常在 finally 方法中用于资源的关闭操作。
JDK1.7 中,推荐使用 try-with-resources
优雅的关闭资源,它直接使用 try(){} 进行资源的关闭即可,就不用写 finally 关键字了。
finalize 是 Object 对象中的一个方法,用于对象的回收方法,这个方法我们一般不推荐使用,finalize 是和垃圾回收关联在一起的,在 Java 9 中,将 finalize 标记为了 deprecated
, 如果没有特别原因,不要实现 finalize 方法,也不要指望他来进行垃圾回收。
内部类有哪些分类,分别解释一下
在 Java 中,可以将一个类的定义放在另外一个类的定义内部,这就是内部类。内部类本身就是类的一个属性,与其他属性定义方式一致。
内部类的分类一般主要有四种
- 成员内部类
- 局部内部类
- 匿名内部类
- 静态内部类
静态内部类
就是定义在类内部的静态类,静态内部类可以访问外部类所有的静态变量,而不可访问外部类的非静态变量;
成员内部类
就是定义在类内部,成员位置上的非静态类,就是成员内部类。成员内部类可以访问外部类所有的变量和方法,包括静态和非静态,私有和公有。
定义在方法中的内部类,就是局部内部类
。定义在实例方法中的局部类可以访问外部类的所有变量和方法,定义在静态方法中的局部类只能访问外部类的静态变量和方法。
匿名内部类
就是没有名字的内部类,除了没有名字,匿名内部类还有以下特点:
- 匿名内部类必须继承一个抽象类或者实现一个接口
- 匿名内部类不能定义任何静态成员和静态方法。
- 当所在的方法的形参需要被匿名内部类使用时,必须声明为 final。
- 匿名内部类不能是抽象的,它必须要实现继承的类或者实现的接口的所有抽象方法。
说出几种常用的异常
- NullPointerException: 空指针异常
- NoSuchMethodException:找不到方法
- IllegalArgumentException:不合法的参数异常
- IndexOutOfBoundException: 数组下标越界异常
- IOException:由于文件未找到、未打开或者I/O操作不能进行而引起异常
- ClassNotFoundException :找不到文件所抛出的异常
- NumberFormatException: 字符的UTF代码数据格式有错引起异常;
- InterruptedException: 线程中断抛出的异常
静态绑定和动态绑定的区别
一个Java 程序要经过编写、编译、运行三个步骤,其中编写代码不在我们讨论的范围之内,那么我们的重点自然就放在了编译
和 运行
这两个阶段,由于编译和运行阶段过程相当繁琐,下面就我的理解来进行解释:
Java 程序从源文件创建到程序运行要经过两大步骤:
1、编译时期是由编译器将源文件编译成字节码的过程
2、字节码文件由Java虚拟机解释执行
绑定
**绑定就是一个方法的调用与调用这个方法的类连接在一起的过程被称为绑定。
绑定主要分为两种:
静态绑定 和 动态绑定
绑定的其他叫法
静态绑定 == 前期绑定 == 编译时绑定
动态绑定 == 后期绑定 == 运行时绑定
为了方便区分: 下面统一称呼为静态绑定和动态绑定
静态绑定
**在程序运行前,也就是编译时期 JVM 就能够确定方法由谁调用,这种机制称为静态绑定
**识别静态绑定的三个关键字以及各自的理解
如果一个方法由 private、static、final 任意一个关键字所修饰,那么这个方法是前期绑定的
构造方法也是前期绑定
private:private 关键字是私有的意思,如果被 private 修饰的方法是无法由本类之外的其他类所调用的,也就是本类所特有的方法,所以也就由编译器识别此方法是属于哪个类的
public class Person {
private String talk;
private String canTalk(){
return talk;
}
}
class Animal{
public static void main(String[] args) {
Person p = new Person();
// private 修饰的方法是Person类独有的,所以Animal类无法访问(动物本来就不能说话)
// p.canTalk();
}
}
final:final 修饰的方法不能被重写,但是可以由子类进行调用,如果将方法声明为 final 可以有效的关闭动态绑定
public class Fruit {
private String fruitName;
final String eatingFruit(String name){
System.out.println("eating " + name);
return fruitName;
}
}
class Apple extends Fruit{
// 不能重写final方法,eatingFruit方法只属于Fruit类,Apple类无法调用
// String eatingFruit(String name){
// super.eatingFruit(name);
// }
String eatingApple(String name){
return super.eatingFruit(name);
}
}
static: static 修饰的方法比较特殊,不用通过 new 出某个类来调用,由类名.变量名
直接调用该方法,这个就很关键了,new 很关键,也可以认为是开启多态的导火索,而由类名.变量名直接调用的话,此时的类名是确定的,并不会产生多态,如下代码:
public class SuperClass {
public static void sayHello(){
System.out.println("由 superClass 说你好");
}
}
public class SubClass extends SuperClass{
public static void sayHello(){
System.out.println("由 SubClass 说你好");
}
public static void main(String[] args) {
SuperClass.sayHello();
SubClass.sayHello();
}
}
SubClass 继承 SuperClass 后,在 是无法重写 sayHello 方法的,也就是说 sayHello() 方法是对子类隐藏的,但是你可以编写自己的 sayHello() 方法,也就是子类 SubClass 的sayHello() 方法,由此可见,方法由 static 关键词所修饰,也是编译时绑定
动态绑定
**在运行时根据具体对象的类型进行绑定
**除了由 private、final、static 所修饰的方法和构造方法外,JVM 在运行期间决定方法由哪个对象调用的过程称为动态绑定
如果把编译、运行看成一条时间线的话,在运行前必须要进行程序的编译过程,那么在编译期进行的绑定是前期绑定,在程序运行了,发生的绑定就是后期绑定
public class Father {
void drinkMilk(){
System.out.println("父亲喜欢喝牛奶");
}
}
public class Son extends Father{
@Override
void drinkMilk() {
System.out.println("儿子喜欢喝牛奶");
}
public static void main(String[] args) {
Father son = new Son();
son.drinkMilk();
}
}
Son 类继承 Father 类,并重写了父类的 dringMilk() 方法,在输出结果得出的是儿子喜欢喝牛奶。那么上面的绑定方式是什么呢?
上面的绑定方式称之为动态绑定
,因为在你编写 Father son = new Son() 的时候,编译器并不知道 son 对象真正引用的是谁,在程序运行时期才知道,这个 son 是一个 Father 类的对象,但是却指向了 Son 的引用,这种概念称之为多态,那么我们就能够整理出来多态的三个原则:
-
继承
-
重写
-
父类引用指向子类对象
也就是说,在 Father son = new Son() ,触发了动态绑定机制。
动态绑定的过程
- 虚拟机提取对象的实际类型的方法表;
- 虚拟机搜索方法签名;
- 调用方法。
动态绑定和静态绑定的特点
静态绑定
静态绑定在编译时期触发,那么它的主要特点是
1、编译期触发,能够提早知道代码错误
2、提高程序运行效率
动态绑定
1、使用动态绑定的前提条件能够提高代码的可用性,使代码更加灵活。
2、多态是设计模式的基础,能够降低耦合性。
HTTP常见面试题
HTTP 和 HTTPS 的区别
HTTP 是一种 超文本传输协议(Hypertext Transfer Protocol)
,**HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范
HTTP 主要内容分为三部分,超文本(Hypertext)、传输(Transfer)、协议(Protocol)。
- 超文本就是不单单只是本文,它还可以传输图片、音频、视频,甚至点击文字或图片能够进行
超链接
的跳转。 - 上面这些概念可以统称为数据,传输就是数据需要经过一系列的物理介质从一个端系统传送到另外一个端系统的过程。通常我们把传输数据包的一方称为
请求方
,把接到二进制数据包的一方称为应答方
。 - 而协议指的就是是网络中(包括互联网)传递、管理信息的一些规范。如同人与人之间相互交流是需要遵循一定的规矩一样,计算机之间的相互通信需要共同遵守一定的规则,这些规则就称为协议,只不过是网络协议。
说到 HTTP,不得不提的就是 TCP/IP 网络模型,一般是五层模型。如下图所示
但是也可以分为四层,就是**把链路层和物理层都表示为网络接口层
还有一种就是 OSI 七层网络模型,它就是在五层协议之上加了**表示层和会话层
而 HTTPS 的全称是 Hypertext Transfer Protocol Secure
,从名称我们可以看出 HTTPS 要比 HTTPS 多了 secure 安全性这个概念,实际上, HTTPS 并不是一个新的应用层协议,它其实就是 HTTP + TLS/SSL 协议组合而成,而安全性的保证正是 TLS/SSL 所做的工作。
也就是说,HTTPS 就是身披了一层 SSL 的 HTTP。
那么,HTTP 和 HTTPS 的主要区别是什么呢?
- 最简单的,HTTP 在地址栏上的协议是以
http://
开头,而 HTTPS 在地址栏上的协议是以https://
开头
http://www.cxuanblog.com/
https://www.cxuanblog.com/
- HTTP 是未经安全加密的协议,它的传输过程容易被攻击者监听、数据容易被窃取、发送方和接收方容易被伪造;而 HTTPS 是安全的协议,它通过 密钥交换算法 – 签名算法 – 对称加密算法 – 摘要算法 能够解决上面这些问题。
- HTTP 的默认端口是 80,而 HTTPS 的默认端口是 443。
HTTP Get 和 Post 区别
HTTP 中包括许多方法,Get 和 Post 是 HTTP 中最常用的两个方法,基本上使用 HTTP 方法中有 99% 都是在使用 Get 方法和 Post 方法,所以有必要我们对这两个方法有更加深刻的认识。
- get 方法一般用于请求,比如你在浏览器地址栏输入
www.cxuanblog.com
其实就是发送了一个 get 请求,它的主要特征是请求服务器返回资源,而 post 方法一般用于<form> 表单
的提交,相当于是把信息提交给服务器,等待服务器作出响应,get 相当于一个是 pull/拉的操作,而 post 相当于是一个 push/推的操作。 - get 方法是不安全的,因为你在发送请求的过程中,你的请求参数会拼在 URL 后面,从而导致容易被攻击者窃取,对你的信息造成破坏和伪造;
/test/demo_form.asp?name1=value1&name2=value2
而 post 方法是把参数放在请求体 body 中的,这对用户来说不可见。
POST /test/demo_form.asp HTTP/1.1
Host: w3schools.com
name1=value1&name2=value2
-
get 请求的 URL 有长度限制,而 post 请求会把参数和值放在消息体中,对数据长度没有要求。
-
get 请求会被浏览器主动 cache,而 post 不会,除非手动设置。
-
get 请求在浏览器反复的
回退/前进
操作是无害的,而 post 操作会再次提交表单请求。 -
get 请求在发送过程中会产生一个 TCP 数据包;post 在发送过程中会产生两个 TCP 数据包。对于 get 方式的请求,浏览器会把 http header 和 data 一并发送出去,服务器响应 200(返回数据);而对于 post,浏览器先发送 header,服务器响应 100 continue,浏览器再发送 data,服务器响应 200 ok(返回数据)。
什么是无状态协议,HTTP 是无状态协议吗,怎么解决
无状态协议(Stateless Protocol)
就是指浏览器对于事务的处理没有记忆能力。举个例子来说就是比如客户请求获得网页之后关闭浏览器,然后再次启动浏览器,登录该网站,但是服务器并不知道客户关闭了一次浏览器。
HTTP 就是一种无状态的协议,他对用户的操作没有记忆能力。可能大多数用户不相信,他可能觉得每次输入用户名和密码登陆一个网站后,下次登陆就不再重新输入用户名和密码了。这其实不是 HTTP 做的事情,起作用的是一个叫做 小甜饼(Cookie)
的机制。它能够让浏览器具有记忆
能力。
如果你的浏览器允许 cookie 的话,查看方式 **chrome://settings/content/cookies
也就说明你的记忆芯片通电了…… 当你想服务端发送请求时,服务端会给你发送一个认证信息,服务器第一次接收到请求时,开辟了一块 Session 空间(创建了Session对象),同时生成一个 sessionId ,并通过响应头的 Set-Cookie:JSESSIONID=XXXXXXX 命令,向客户端发送要求设置 Cookie 的响应; 客户端收到响应后,在本机客户端设置了一个 JSESSIONID=XXXXXXX 的 Cookie 信息,该 Cookie 的过期时间为浏览器会话结束;
接下来客户端每次向同一个网站发送请求时,请求头都会带上该 Cookie信息(包含 sessionId ), 然后,服务器通过读取请求头中的 Cookie 信息,获取名称为 JSESSIONID 的值,得到此次请求的 sessionId。这样,你的浏览器才具有了记忆能力。
还有一种方式是使用 JWT 机制,它也是能够让你的浏览器具有记忆能力的一种机制。与 Cookie 不同,JWT 是保存在客户端的信息,它广泛的应用于单点登录的情况。JWT 具有两个特点
- JWT 的 Cookie 信息存储在
客户端
,而不是服务端内存中。也就是说,JWT 直接本地进行验证就可以,验证完毕后,这个 Token 就会在 Session 中随请求一起发送到服务器,通过这种方式,可以节省服务器资源,并且 token 可以进行多次验证。 - JWT 支持跨域认证,Cookies 只能用在
单个节点的域
或者它的子域
中有效。如果它们尝试通过第三个节点访问,就会被禁止。使用 JWT 可以解决这个问题,使用 JWT 能够通过多个节点
进行用户认证,也就是我们常说的跨域认证
。
UDP 和 TCP 的区别
TCP 和 UDP 都位于计算机网络模型中的运输层,它们负责传输应用层产生的数据。下面我们就来聊一聊 TCP 和 UDP 分别的特征和他们的区别
UDP 是什么
UDP 的全称是 User Datagram Protocol
,用户数据报协议。它不需要所谓的握手
操作,从而加快了通信速度,允许网络上的其他主机在接收方同意通信之前进行数据传输。
数据报是与分组交换网络关联的传输单元。
UDP 的特点主要有
- UDP 能够支持容忍数据包丢失的带宽密集型应用程序
- UDP 具有低延迟的特点
- UDP 能够发送大量的数据包
- UDP 能够允许 DNS 查找,DNS 是建立在 UDP 之上的应用层协议。
TCP 是什么
TCP 的全称是Transmission Control Protocol
,传输控制协议。它能够帮助你确定计算机连接到 Internet 以及它们之间的数据传输。通过三次握手来建立 TCP 连接,三次握手就是用来启动和确认 TCP 连接的过程。一旦连接建立后,就可以发送数据了,当数据传输完成后,会通过关闭虚拟电路来断开连接。
TCP 的主要特点有
- TCP 能够确保连接的建立和数据包的发送
- TCP 支持错误重传机制
- TCP 支持拥塞控制,能够在网络拥堵的情况下延迟发送
- TCP 能够提供错误校验和,甄别有害的数据包。
TCP 和 UDP 的不同
下面为你罗列了一些 TCP 和 UDP 的不同点,方便理解,方便记忆。
TCP | UDP |
---|---|
TCP 是面向连接的协议 | UDP 是无连接的协议 |
TCP 在发送数据前先需要建立连接,然后再发送数据 | UDP 无需建立连接就可以直接发送大量数据 |
TCP 会按照特定顺序重新排列数据包 | UDP 数据包没有固定顺序,所有数据包都相互独立 |
TCP 传输的速度比较慢 | UDP 的传输会更快 |
TCP 的头部字节有 20 字节 | UDP 的头部字节只需要 8 个字节 |
TCP 是重量级的,在发送任何用户数据之前,TCP需要三次握手建立连接。 | UDP 是轻量级的。没有跟踪连接,消息排序等。 |
TCP 会进行错误校验,并能够进行错误恢复 | UDP 也会错误检查,但会丢弃错误的数据包。 |
TCP 有发送确认 | UDP 没有发送确认 |
TCP 会使用握手协议,例如 SYN,SYN-ACK,ACK | 无握手协议 |
TCP 是可靠的,因为它可以确保将数据传送到路由器。 | 在 UDP 中不能保证将数据传送到目标。 |
TCP 三次握手和四次挥手
TCP 三次握手和四次挥手也是面试题的热门考点,它们分别对应 TCP 的连接和释放过程。下面就来简单认识一下这两个过程
TCP 三次握手
在了解具体的流程前,我们需要先认识几个概念
消息类型 | 描述 |
---|---|
SYN | 这个消息是用来初始化和建立连接的。 |
ACK | 帮助对方确认收到的 SYN 消息 |
SYN-ACK | 本地的 SYN 消息和较早的 ACK 数据包 |
FIN | 用来断开连接 |
-
SYN:它的全称是
Synchronize Sequence Numbers
,同步序列编号。是 TCP/IP 建立连接时使用的握手信号。在客户机和服务器之间建立 TCP 连接时,首先会发送的一个信号。客户端在接受到 SYN 消息时,就会在自己的段内生成一个随机值 X。 -
SYN-ACK:服务器收到 SYN 后,打开客户端连接,发送一个 SYN-ACK 作为答复。确认号设置为比接收到的序列号多一个,即 X + 1,服务器为数据包选择的序列号是另一个随机数 Y。
-
ACK:
Acknowledge character
, 确认字符,表示发来的数据已确认接收无误。最后,客户端将 ACK 发送给服务器。序列号被设置为所接收的确认值即 Y + 1。
如果用现实生活来举例的话就是
小明 – 客户端 小红 – 服务端
- 小明给小红打电话,接通了后,小明说喂,能听到吗,这就相当于是连接建立。
- 小红给小明回应,能听到,你能听到我说的话吗,这就相当于是请求响应。
- 小明听到小红的回应后,好的,这相当于是连接确认。在这之后小明和小红就可以通话/交换信息了。
TCP 四次挥手
在连接终止阶段使用四次挥手,连接的每一端都会独立的终止。下面我们来描述一下这个过程。
- 首先,客户端应用程序决定要终止连接(这里服务端也可以选择断开连接)。这会使客户端将 FIN 发送到服务器,并进入
FIN_WAIT_1
状态。当客户端处于 FIN_WAIT_1 状态时,它会等待来自服务器的 ACK 响应。 - 然后第二步,当服务器收到 FIN 消息时,服务器会立刻向客户端发送 ACK 确认消息。
- 当客户端收到服务器发送的 ACK 响应后,客户端就进入
FIN_WAIT_2
状态,然后等待来自服务器的FIN
消息 - 服务器发送 ACK 确认消息后,一段时间(可以进行关闭后)会发送 FIN 消息给客户端,告知客户端可以进行关闭。
- 当客户端收到从服务端发送的 FIN 消息时,客户端就会由 FIN_WAIT_2 状态变为
TIME_WAIT
状态。处于 TIME_WAIT 状态的客户端允许重新发送 ACK 到服务器为了防止信息丢失。客户端在 TIME_WAIT 状态下花费的时间取决于它的实现,在等待一段时间后,连接关闭,客户端上所有的资源(包括端口号和缓冲区数据)都被释放。
还是可以用上面那个通话的例子来进行描述
- 小明对小红说,我所有的东西都说完了,我要挂电话了。
- 小红说,收到,我这边还有一些东西没说。
- 经过若干秒后,小红也说完了,小红说,我说完了,现在可以挂断了
- 小明收到消息后,又等了若干时间后,挂断了电话。
简述 HTTP1.0/1.1/2.0 的区别
HTTP 1.0
HTTP 1.0 是在 1996 年引入的,从那时开始,它的普及率就达到了惊人的效果。
- HTTP 1.0 仅仅提供了最基本的认证,这时候用户名和密码还未经加密,因此很容易收到窥探。
- HTTP 1.0 被设计用来使用短链接,即每次发送数据都会经过 TCP 的三次握手和四次挥手,效率比较低。
- HTTP 1.0 只使用 header 中的 If-Modified-Since 和 Expires 作为缓存失效的标准。
- HTTP 1.0 不支持断点续传,也就是说,每次都会传送全部的页面和数据。
- HTTP 1.0 认为每台计算机只能绑定一个 IP,所以请求消息中的 URL 并没有传递主机名(hostname)。
HTTP 1.1
HTTP 1.1 是 HTTP 1.0 开发三年后出现的,也就是 1999 年,它做出了以下方面的变化
- HTTP 1.1 使用了摘要算法来进行身份验证
- HTTP 1.1 默认使用长连接,长连接就是只需一次建立就可以传输多次数据,传输完成后,只需要一次切断连接即可。长连接的连接时长可以通过请求头中的
keep-alive
来设置 - HTTP 1.1 中新增加了 E-tag,If-Unmodified-Since, If-Match, If-None-Match 等缓存控制标头来控制缓存失效。
- HTTP 1.1 支持断点续传,通过使用请求头中的
Range
来实现。 - HTTP 1.1 使用了虚拟网络,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个IP地址。
HTTP 2.0
HTTP 2.0 是 2015 年开发出来的标准,它主要做的改变如下
头部压缩
,由于 HTTP 1.1 经常会出现 User-Agent、Cookie、Accept、Server、Range 等字段可能会占用几百甚至几千字节,而 Body 却经常只有几十字节,所以导致头部偏重。HTTP 2.0 使用HPACK
算法进行压缩。二进制格式
,HTTP 2.0 使用了更加靠近 TCP/IP 的二进制格式,而抛弃了 ASCII 码,提升了解析效率强化安全
,由于安全已经成为重中之重,所以 HTTP2.0 一般都跑在 HTTPS 上。多路复用
,即每一个请求都是是用作连接共享。一个请求对应一个id,这样一个连接上可以有多个请求。
请你说一下 HTTP 常见的请求头
这个问题比较开放,因为 HTTP 请求头有很多,这里只简单举出几个例子,具体的可以参考我的另一篇文章
https://mp.weixin.qq.com/s/XZZR0945IcI6X4S0g5fZXg
HTTP 标头会分为四种,分别是 通用标头
、实体标头
、请求标头
、响应标头
。分别介绍一下
通用标头
通用标头主要有三个,分别是 Date
、Cache-Control
和 Connection
**Date
Date 是一个通用标头,它可以出现在请求标头和响应标头中,它的基本表示如下
Date: Wed, 21 Oct 2015 07:28:00 GMT
表示的是格林威治标准时间,这个时间要比北京时间慢八个小时
**Cache-Control
Cache-Control 是一个通用标头,他可以出现在请求标头
和响应标头
中,Cache-Control 的种类比较多,虽然说这是一个通用标头,但是又一些特性是请求标头具有的,有一些是响应标头才有的。主要大类有 可缓存性
、阈值性
、 重新验证并重新加载
和其他特性
**Connection
Connection 决定当前事务(一次三次握手和四次挥手)完成后,是否会关闭网络连接。Connection 有两种,一种是持久性连接
,即一次事务完成后不关闭网络连接
Connection: keep-alive
另一种是非持久性连接
,即一次事务完成后关闭网络连接
Connection: close
HTTP1.1 其他通用标头如下
实体标头
实体标头是描述消息正文内容的 HTTP 标头。实体标头用于 HTTP 请求和响应中。头部Content-Length
、 Content-Language
、 Content-Encoding
是实体头。
-
Content-Length 实体报头指示实体主体的大小,以字节为单位,发送到接收方。
-
Content-Language 实体报头描述了客户端或者服务端能够接受的语言。
-
Content-Encoding 这又是一个比较麻烦的属性,这个实体报头用来压缩媒体类型。Content-Encoding 指示对实体应用了何种编码。
常见的内容编码有这几种: gzip、compress、deflate、identity ,这个属性可以应用在请求报文和响应报文中
Accept-Encoding: gzip, deflate //请求头
Content-Encoding: gzip //响应头
下面是一些实体标头字段
请求标头
**Host
Host 请求头指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的 TCP 端口号。如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个 HTTP 的 URL 会自动使用 80 作为端口)。
Host: developer.mozilla.org
上面的 Accpet
、 Accept-Language
、Accept-Encoding
都是属于内容协商的请求标头。
**Referer
HTTP Referer 属性是请求标头的一部分,当浏览器向 web 服务器发送请求的时候,一般会带上 Referer,告诉服务器该网页是从哪个页面链接过来的,服务器因此可以获得一些信息用于处理。
Referer: https://developer.mozilla.org/testpage.html
**If-Modified-Since
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"
**Accept
接受请求 HTTP 标头会通告客户端其能够理解的 MIME 类型
**Accept-Charset
accept-charset 属性规定服务器处理表单数据所接受的字符集。
常用的字符集有: UTF-8 – Unicode 字符编码 ; ISO-8859-1 – 拉丁字母表的字符编码
**Accept-Language
首部字段 Accept-Language 用来告知服务器用户代理能够处理的自然语言集(指中文或英文等),以及自然语言集的相对优先级。可一次指定多种自然语言集。
请求标头我们大概就介绍这几种,后面会有一篇文章详细深挖所有的响应头的,下面是一个响应头的汇总,基于 HTTP 1.1
响应标头
**Access-Control-Allow-Origin
一个返回的 HTTP 标头可能会具有 Access-Control-Allow-Origin ,Access-Control-Allow-Origin
指定一个来源,它告诉浏览器允许该来源进行资源访问。
**Keep-Alive
Keep-Alive 表示的是 Connection 非持续连接的存活时间,可以进行指定。
**Server
服务器标头包含有关原始服务器用来处理请求的软件的信息。
应该避免使用过于冗长和详细的 Server 值,因为它们可能会泄露内部实施细节,这可能会使攻击者容易地发现并利用已知的安全漏洞。例如下面这种写法
Server: Apache/2.4.1 (Unix)
**Set-Cookie
Set-Cookie 用于服务器向客户端发送 sessionID。
**Transfer-Encoding
首部字段 Transfer-Encoding 规定了传输报文主体时采用的编码方式。
HTTP /1.1 的传输编码方式仅对分块传输编码有效。
**X-Frame-Options
HTTP 首部字段是可以自行扩展的。所以在 Web 服务器和浏览器的应用上,会出现各种非标准的首部字段。
首部字段 X-Frame-Options
属于 HTTP 响应首部,用于控制网站内容在其他 Web 网站的 Frame 标签内的显示问题。其主要目的是为了防止点击劫持(clickjacking)攻击。
下面是一个响应头的汇总,基于 HTTP 1.1
地址栏输入 URL 发生了什么
这道题也是一道经常会考的面试题。那么下面我们就来探讨一下从你输入 URL 后到响应,都经历了哪些过程。
- 首先,你需要在浏览器中的 URL 地址上,输入你想访问的地址,如下
你应该访问不到的,对不对~
- 然后,浏览器会根据你输入的 URL 地址,去查找域名是否被本地 DNS 缓存,不同浏览器对 DNS 的设置不同,如果浏览器缓存了你想访问的 URL 地址,那就直接返回 ip。如果没有缓存你的 URL 地址,浏览器就会发起系统调用来查询本机
hosts
文件是否有配置 ip 地址,如果找到,直接返回。如果找不到,就向网络中发起一个 DNS 查询。
首先来看一下 DNS 是啥,互联网中识别主机的方式有两种,通过
主机名
和IP 地址
。我们人喜欢用名字的方式进行记忆,但是通信链路中的路由却喜欢定长、有层次结构的 IP 地址。所以就需要一种能够把主机名到 IP 地址的转换服务,这种服务就是由 DNS 提供的。DNS 的全称是Domain Name System
域名系统。DNS 是一种由分层的 DNS 服务器实现的分布式数据库。DNS 运行在 UDP 上,使用 53 端口。
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 服务器来查询是否包含 IP 地址,如果本地 DNS 无法查询到目标 IP 地址,就会向根域名服务器发起一个 DNS 查询。
注意:DNS 涉及两种查询方式:一种是
递归查询(Recursive query)
,一种是迭代查询(Iteration query)
。《计算机网络:自顶向下方法》竟然没有给出递归查询和迭代查询的区别,找了一下网上的资料大概明白了下。如果根域名服务器无法告知本地 DNS 服务器下一步需要访问哪个顶级域名服务器,就会使用递归查询;
如果根域名服务器能够告知 DNS 服务器下一步需要访问的顶级域名服务器,就会使用迭代查询。
在由根域名服务器 -> 顶级域名服务器 -> 权威 DNS 服务器后,由权威服务器告诉本地服务器目标 IP 地址,再有本地 DNS 服务器告诉用户需要访问的 IP 地址。
- 第三步,浏览器需要和目标服务器建立 TCP 连接,需要经过三次握手的过程,具体的握手过程请参考上面的回答。
- 在建立连接后,浏览器会向目标服务器发起
HTTP-GET
请求,包括其中的 URL,HTTP 1.1 后默认使用长连接,只需要一次握手即可多次传输数据。 - 如果目标服务器只是一个简单的页面,就会直接返回。但是对于某些大型网站的站点,往往不会直接返回主机名所在的页面,而会直接重定向。返回的状态码就不是 200 ,而是 301,302 以 3 开头的重定向码,浏览器在获取了重定向响应后,在响应报文中 Location 项找到重定向地址,浏览器重新第一步访问即可。
- 然后浏览器重新发送请求,携带新的 URL,返回状态码 200 OK,表示服务器可以响应请求,返回报文。
HTTPS 的工作原理
我们上面描述了一下 HTTP 的工作原理,下面来讲述一下 HTTPS 的工作原理。因为我们知道 HTTPS 不是一种新出现的协议,而是
所以,我们探讨 HTTPS 的握手过程,其实就是 SSL/TLS 的握手过程。
TLS 旨在为 Internet 提供通信安全的加密协议。TLS 握手是启动和使用 TLS 加密的通信会话的过程。在 TLS 握手期间,Internet 中的通信双方会彼此交换信息,验证密码套件,交换会话密钥。
每当用户通过 HTTPS 导航到具体的网站并发送请求时,就会进行 TLS 握手。除此之外,每当其他任何通信使用HTTPS(包括 API 调用和在 HTTPS 上查询 DNS)时,也会发生 TLS 握手。
TLS 具体的握手过程会根据所使用的密钥交换算法的类型
和双方支持的密码套件
而不同。 我们以RSA 非对称加密
来讨论这个过程。整个 TLS 通信流程图如下
- 在进行通信前,首先会进行 HTTP 的三次握手,握手完成后,再进行 TLS 的握手过程
- ClientHello:客户端通过向服务器发送
hello
消息来发起握手过程。这个消息中会夹带着客户端支持的TLS 版本号(TLS1.0 、TLS1.2、TLS1.3)
、客户端支持的密码套件、以及一串客户端随机数
。 - ServerHello:在客户端发送 hello 消息后,服务器会发送一条消息,这条消息包含了服务器的 SSL 证书、服务器选择的密码套件和服务器生成的随机数。
- 认证(Authentication):客户端的证书颁发机构会认证 SSL 证书,然后发送
Certificate
报文,报文中包含公开密钥证书。最后服务器发送ServerHelloDone
作为hello
请求的响应。第一部分握手阶段结束。 加密阶段
:在第一个阶段握手完成后,客户端会发送ClientKeyExchange
作为响应,这个响应中包含了一种称为The premaster secret
的密钥字符串,这个字符串就是使用上面公开密钥证书进行加密的字符串。随后客户端会发送ChangeCipherSpec
,告诉服务端使用私钥解密这个premaster secret
的字符串,然后客户端发送Finished
告诉服务端自己发送完成了。
Session key 其实就是用公钥证书加密的公钥。
实现了安全的非对称加密
:然后,服务器再发送ChangeCipherSpec
和Finished
告诉客户端解密完成,至此实现了 RSA 的非对称加密。
Java代理
说在前面:今天我们来聊一聊 Java 中的代理,先来聊聊故事背景:
小明想购买法国某个牌子的香水送给女朋友,但是在国内没有货源售卖,亲自去法国又大费周章了,而小红现在正在法国玩耍,她和小明是好朋友,可以帮小明买到这个牌子的香水,于是小明就找到小红,答应给她多加 5% 的辛苦费,小红答应了,小明成功在中国买到了法国的香水。之后小红开启了疯狂的代购模式,赚到了很多手续费。
在故事中,小明是一个客户,它让小红帮忙购买香水,小红就成了一个代理对象,而香水提供商是一个真实的对象,可以售卖香水,小明通过代理商小红,购买到法国的香水,这就是一个代购的例子。我画了一幅图帮助理解这个故事的整个结构。
这个故事是最典型的代理模式,代购从供应商购买货物后返回给调用者,也就是需要代理的小明。
代理可以分为静态代理
和动态代理
两大类:
**静态代理
- 优点:代码结构简单,较容易实现
- 缺点:无法适配所有代理场景,如果有新的需求,需要修改代理类,**不符合软件工程的开闭原则
小红现在只是代理香水,如果小明需要找小红买法国红酒,那小红就需要代理法国红酒了,但是静态代理去扩展代理功能必须修改小红内部的逻辑,这会让小红内部代码越来越臃肿,后面会详细分析。
**动态代理
- 优点:能够动态适配特定的代理场景,扩展性较好,**符合软件工程的开闭原则
- 缺点:动态代理需要利用到反射机制和动态生成字节码,导致其性能会比静态代理稍差一些,**但是相比于优点,这些劣势几乎可以忽略不计
如果小明需要找小红代理红酒,我们无需修改代理类小红的内部逻辑,只需要关注扩展的功能点:代理红酒,实例化新的类,通过一些转换即可让小红既能够代理香水也能够代理红酒了。
本文将会通过以下几点,尽可能让你理解 Java 代理中所有重要的知识点:
- 学习代理模式(实现故事的代码,解释代理模式的类结构特点)
- 比较静态代理与动态代理二者的异同
- Java 中常见的两种动态代理实现(JDK Proxy 和 Cglib)
- 动态代理的应用(Spring AOP)
代理模式
(1)我们定义好一个售卖香水的接口,定义好售卖香水的方法并传入该香水的价格。
public interface SellPerfume {
void sellPerfume(double price);
}
(2)定义香奈儿(Chanel)香水提供商,实现接口。
public class ChanelFactory implements SellPerfume {
@Override
public void sellPerfume(double price) {
System.out.println("成功购买香奈儿品牌的香水,价格是:" + price + "元");
}
}
(3)定义小红代理类,她需要代购去售卖香奈儿香水,所以她是香奈儿香水提供商的代理对象,同样实现接口,并在内部保存对目标对象(香奈儿提供商)的引用,控制其它对象对目标对象的访问。
public class XiaoHongSellProxy implements SellPerfume {
private SellPerfume sellPerfumeFactory;
public XiaoHongSellProxy(SellPerfume sellPerfumeFactory) {
this.sellPerfumeFactory = sellPerfumeFactory;
}
@Override
public void sellPerfume(double price) {
doSomethingBeforeSell(); // 前置增强
sellPerfumeFactory.sellPerfume(price);
doSomethingAfterSell(); // 后置增强
}
private void doSomethingBeforeSell() {
System.out.println("小红代理购买香水前的额外操作...");
}
private void doSomethingAfterSell() {
System.out.println("小红代理购买香水后的额外操作...");
}
}
(4)小明是一个需求者,他需要去购买香水,只能通过小红去购买,所以他去找小红购买1999.99
的香水。
public class XiaoMing {
public static void main(String[] args) {
ChanelFactory factory = new ChanelFactory();
XiaoHongSellProxy proxy = new XiaoHongSellProxy(factory);
proxy.sellPerfume(1999.99);
}
}
我们来看看运行结果,小红在向小明售卖香水前可以执行额外的其它操作,如果良心点的代购就会打折、包邮···,如果黑心点的代购就会加手续费、售出不退还···,是不是很刺激。
我们来看看上面 4 个类组成的类图关系结构,可以发现小红和香奈儿提供商都实现了售卖香水这一接口,而小红内部增加了对提供商的引用,用于调用提供商的售卖香水功能。
实现代理模式,需要走以下几个步骤:
- 定义真实对象和代理对象的公共接口(售卖香水接口)
- 代理对象内部保存对真实目标对象的引用(小红引用提供商)
- 访问者仅能通过代理对象访问真实目标对象,不可直接访问目标对象(小明只能通过小红去购买香水,不能直接到香奈儿提供商购买)
代理模式很容易产生错误思维的一个地方:代理对象并不是真正提供服务的一个对象,它只是替访问者访问目标对象的一个中间人,真正提供服务的还是目标对象,而代理对象的作用就是在目标对象提供服务之前和之后能够执行额外的逻辑。
从故事来说,小红并不是真正卖香水的,卖香水的还是香奈儿提供商,而小红只不过是在让香奈儿卖香水之前和之后执行了一些自己额外加上去的操作。
讲完这个代理模式的代码实现,我们来系统地学习它究竟是如何定义的,以及实现它需要注意什么规范。
代理模式的定义:**给目标对象提供一个代理对象,代理对象包含该目标对象,并控制对该目标对象的访问。
代理模式的目的:
- 通过代理对象的隔离,可以在对目标对象访问前后**增加额外的业务逻辑,实现功能增强。
- 通过代理对象访问目标对象,可以防止系统大量地直接对目标对象进行不正确地访问,出现不可预测的后果
静态代理与动态代理
你是否会有我一样的疑惑:代理为什么还要分静态和动态的?它们两个有啥不同吗?
很明显,所有人都会有这样的疑惑,我们先来看看它们的相同点:
- 都能够实现代理模式(这不废话吗…)
- 无论是静态代理还是动态代理,代理对象和目标对象都需要实现一个**公共接口
重点当然是它们的不同之处,动态代理在静态代理的基础上做了改进,极大地提高了程序的可维护性和可扩展性。我先列出它们俩的不同之处,再详细解释为何静态代理不具备这两个特性:
- 动态代理产生代理对象的时机是运行时动态生成,它没有 Java 源文件,直接生成字节码文件实例化代理对象;而静态代理的代理对象,在程序编译时已经写好 Java 文件了,直接 new 一个代理对象即可。
- 动态代理比静态代理更加稳健,对程序的可维护性和可扩展性更加友好
目前来看,代理对象小红已经能够代理购买香水了,但有一天,小红的另外一个朋友小何来了,他想购买最纯正的法国红酒,国内没有这样的购买渠道,小红刚巧也在法国,于是小何就想找小红帮他买红酒啦,这和小明找小红是一个道理的,都是想让小红做代理。
但问题是:在程序中,小红只能代理购买香水,如果要代理购买红酒,要怎么做呢?
-
创建售卖红酒的接口
-
售卖红酒提供商和代理对象小红都需要实现该接口
-
小何访问小红,让小红卖给他红酒
OK,事已至此,代码就不重复写了,我们来探讨一下,面对这种新增的场景,上面的这种实现方法有没有什么缺陷呢?
我们不得不提的是软件工程中的**开闭原则
开闭原则:在编写程序的过程中,软件的所有对象应该是对扩展是开放的,而对修改是关闭的
静态代理违反了开闭原则,原因是:面对新的需求时,需要修改代理类,增加实现新的接口和方法,导致代理类越来越庞大,变得难以维护。
虽然说目前代理类只是实现了2个接口,如果日后小红不只是代理售卖红酒,还需要代理售卖电影票、代购日本寿司······实现的接口会变得越来越多,内部的结构变得越来越复杂,整个类显得愈发臃肿,变得不可维护,之后的扩展也会成问题,只要任意一个接口有改动,就会牵扯到这个代理类,维护的代价很高。
**所以,为了提高类的可扩展性和可维护性,满足开闭原则,Java 提供了动态代理机制。
常见的动态代理实现
动态代理最重要的当然是动态两个字,学习动态代理的过程,最重要的就是理解何为动态,话不多说,马上开整。
我们来明确一点:**动态代理解决的问题是面对新的需求时,不需要修改代理对象的代码,只需要新增接口和真实对象,在客户端调用即可完成新的代理。
这样做的目的:满足软件工程的开闭原则,提高类的可维护性和可扩展性。
JDK Proxy
JDK Proxy 是 JDK 提供的一个动态代理机制,它涉及到两个核心类,分别是Proxy
和InvocationHandler
,我们先来了解如何使用它们。
以小红代理卖香水的故事为例,香奈儿香水提供商依旧是真实对象,实现了SellPerfume
接口,这里不再重新写了,重点是小红代理,这里的代理对象不再是小红一个人,而是一个代理工厂,里面会有许多的代理对象。我画了一幅图,你看了之后会很好理解:
小明来到代理工厂,需要购买一款法国在售的香奈儿香水,那么工厂就会找一个可以实际的代理对象(动态实例化)分配给小明,例如小红或者小花,让该代理对象完成小明的需求。**该代理工厂含有无穷无尽的代理对象可以分配,且每个对象可以代理的事情可以根据程序的变化而动态变化,无需修改代理工厂。
如果有一天小明需要招待一个可以代购红酒的代理对象,该代理工厂依旧可以满足他的需求,无论日后需要什么代理,都可以满足,是不是觉得很神奇?我们来学习如何使用它。
我们看一下动态代理的 UML 类图结构长什么样子。
可以看到和静态代理区别不大,唯一的变动是代理对象,我做了标注:由代理工厂生产。
这句话的意思是:代理对象是在程序运行过程中,由代理工厂动态生成,代理对象本身不存在 Java 源文件。
那么,我们的关注点有2
个:
- 如何实现一个代理工厂
- 如何通过代理工厂动态生成代理对象
首先,代理工厂需要实现InvocationHanlder
接口并实现其invoke()
方法。
public class SellProxyFactory implements InvocationHandler {
/** 代理的真实对象 */
private Object realObject;
public SellProxyFactory(Object realObject) {
this.realObject = realObject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
doSomethingBefore();
Object obj = method.invoke(realObject, args);
doSomethingAfter();
return obj;
}
private void doSomethingAfter() {
System.out.println("执行代理后的额外操作...");
}
private void doSomethingBefore() {
System.out.println("执行代理前的额外操作...");
}
}
invoke() 方法有3
个参数:
Object proxy
:代理对象Method method
:真正执行的方法Object[] agrs
:调用第二个参数 method 时传入的参数列表值
invoke() 方法是一个代理方法,也就是说最后客户端请求代理时,执行的就是该方法。代理工厂类到这里为止已经结束了,我们接下来看第二点:如何通过代理工厂动态生成代理对象。
生成代理对象需要用到Proxy
类,它可以帮助我们生成任意一个代理对象,里面提供一个静态方法newProxyInstance
。
Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h);
实例化代理对象时,需要传入3
个参数:
- ClassLoader loader:加载动态代理类的类加载器
- Class<?>[] interfaces:代理类实现的接口,可以传入多个接口
- InvocationHandler h:指定代理类的调用处理程序,即调用接口中的方法时,会找到该代理工厂
h
,执行invoke()
方法
我们在客户端请求代理时,就需要用到上面这个方法。
public class XiaoMing {
public static void main(String[] args) {
ChanelFactory chanelFactory = new ChanelFactory();
SellProxyFactory sellProxyFactory = new SellProxyFactory(chanelFactory);
SellPerfume sellPerfume = (SellPerfume) Proxy.newProxyInstance(chanelFactory.getClass().getClassLoader(),
chanelFactory.getClass().getInterfaces(),
sellProxyFactory);
sellPerfume.sellPerfume(1999.99);
}
}
执行结果和静态代理的结果相同,但二者的思想是不一样的,一个是静态,一个是动态。那又如何体现出动态代理的优势呢?别急,往下看就知道了。
注意看下图,相比
静态代理
的前置增强和后置增强,少了小红二字,实际上代理工厂分配的代理对象是随机的,不会针对某一个具体的代理对象,所以每次生成的代理对象都不一样,也就不确定是不是小红了,但是能够唯一确定的是,**这个代理对象能和小红一样帮小明买到香水!
按照之前的故事线发展,小红去代理红酒,而小明又想买法国的名牌红酒,所以去找代理工厂,让它再分配一个人帮小明买红酒,代理工厂说:“当然没问题!我们是专业的!等着!”
我们需要实现两个类:红酒提供商类 和 售卖红酒接口。
/** 售卖红酒接口 */
public interface SellWine {
void sellWine(double price);
}
/** 红酒供应商 */
public class RedWineFactory implements SellWine {
@Override
public void sellWine(double price) {
System.out.println("成功售卖一瓶红酒,价格:" + price + "元");
}
}
然后我们的小明在请求代理工厂时,就可以实例化一个可以售卖红酒的代理了。
public class XiaoMing {
public static void main(String[] args) {
// 实例化一个红酒销售商
RedWineFactory redWineFactory = new RedWineFactory();
// 实例化代理工厂,传入红酒销售商引用控制对其的访问
SellProxyFactory sellProxyFactory = new SellProxyFactory(redWineFactory);
// 实例化代理对象,该对象可以代理售卖红酒
SellWine sellWineProxy = (SellWine) Proxy.newProxyInstance(redWineFactory.getClass().getClassLoader(),
redWineFactory.getClass().getInterfaces(),
sellProxyFactory);
// 代理售卖红酒
sellWineProxy.sellWine(1999.99);
}
}
期待一下执行结果,你会很惊喜地发现,居然也能够代理售卖红酒了,但是我们没有修改代理工厂。
回顾一下我们新增红酒代理功能时,需要2
个步骤:
- 创建新的红酒提供商
SellWineFactory
和售卖红酒接口SellWine
- 在客户端实例化一个代理对象,然后向该代理对象购买红酒
再回想开闭原则:面向扩展开放,面向修改关闭。动态代理正是满足了这一重要原则,在面对功能需求扩展时,只需要关注扩展的部分,不需要修改系统中原有的代码。
如果感兴趣想深究的朋友,把注意力放在Proxy.newProxyInstance()
这个方法上,这是整个 JDK 动态代理起飞的一个方法。
讲到这里,JDK 提供的动态代理已经到尾声了,我们来总结一下 JDK 的动态代理:
(1)JDK 动态代理的使用方法
- 代理工厂需要实现
InvocationHandler
接口,调用代理方法时会转向执行invoke()
方法 - 生成代理对象需要使用
Proxy
对象中的newProxyInstance()
方法,返回对象可强转成传入的其中一个接口,然后调用接口方法即可实现代理
(2)JDK 动态代理的特点
- 目标对象强制需要实现一个接口,否则无法使用 JDK 动态代理
**(以下为扩展内容,如果不想看可跳过)
Proxy.newProxyInstance() 是生成动态代理对象的关键,我们可来看看它里面到底干了些什么,我把重要的代码提取出来,一些对分析无用的代码就省略掉了。
private static final Class<?>[] constructorParams ={ InvocationHandler.class };
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h) {
// 获取代理类的 Class 对象
Class<?> cl = getProxyClass0(loader, intfs);
// 获取代理对象的显示构造器,参数类型是 InvocationHandler
final Constructor<?> cons = cl.getConstructor(constructorParams);
// 反射,通过构造器实例化动态代理对象
return cons.newInstance(new Object[]{h});
}
我们看到第 6
行获取了一个动态代理对象,那么是如何生成的呢?接着往下看。
private static Class<?> getProxyClass0(ClassLoader loader,
Class<?>... interfaces) {
// 去代理类对象缓存中获取代理类的 Class 对象
return proxyClassCache.get(loader, interfaces);
}
发现里面用到一个缓存 proxyClassCache,从结构来看类似于是一个 map
结构,根据类加载器loader
和真实对象实现的接口interfaces
查找是否有对应的 Class 对象,我们接着往下看 get()
方法。
public V get(K key, P parameter) {
// 先从缓存中查询是否能根据 key 和 parameter 查询到 Class 对象
// ...
// 生成一个代理类
Object subKey = Objects.requireNonNull(subKeyFactory.apply(key, parameter));
}
在 get() 方法中,如果没有从缓存中获取到 Class 对象,则需要利用 subKeyFactory 去实例化一个动态代理对象,而在 Proxy 类中包含一个 ProxyClassFactory 内部类,由它来创建一个动态代理类,所以我们接着去看 ProxyClassFactory 中的 apply()
方法。
private static final class ProxyClassFactory
implements BiFunction<ClassLoader, Class<?>[], Class<?>> {
// 非常重要,这就是我们看到的动态代理的对象名前缀!
private static final String proxyClassNamePrefix = "$Proxy";
@Override
public Class<?> apply(ClassLoader loader, Class<?>[] interfaces) {
Map<Class<?>, Boolean> interfaceSet = new IdentityHashMap<>(interfaces.length);
// 一些状态校验
// 计数器,该计数器记录了当前已经实例化多少个代理对象
long num = nextUniqueNumber.getAndIncrement();
// 动态代理对象名拼接!包名 + "$Proxy" + 数字
String proxyName = proxyPkg + proxyClassNamePrefix + num;
// 生成字节码文件,返回一个字节数组
byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
proxyName, interfaces, accessFlags);
try {
// 利用字节码文件创建该字节码的 Class 类对象
return defineClass0(loader, proxyName,
proxyClassFile, 0, proxyClassFile.length);
} catch (ClassFormatError e) {
throw new IllegalArgumentException(e.toString());
}
}
}
apply() 方法中注意有两个非常重要的方法:
- ProxyGenerator.generateProxyClass():它是生成字节码文件的方法,它返回了一个字节数组,字节码文件本质上就是一个字节数组,所以
proxyClassFile
数组就是一个字节码文件 - defineClass0():生成字节码文件的 Class 对象,它是一个
native
本地方法,调用操作系统底层的方法创建类对象
而 proxyName
是代理对象的名字,我们可以看到它利用了 proxyClassNamePrefix + 计数器 拼接成一个新的名字。所以在 DEBUG 时,停留在代理对象变量上,你会发现变量名是$Proxy0
。
到了这里,源码分析完了,是不是感觉被掏空了?哈哈哈哈,其实我当时也有这种感觉,不过现在你也感觉到,JDK 的动态代理其实并不是特别复杂吧(只要你有毅力)
CGLIB
CGLIB(Code generation Library) 不是 JDK 自带的动态代理,它需要导入第三方依赖,它是一个字节码生成类库,能够在运行时动态生成代理类对 Java类 和 Java接口 扩展。
CGLIB不仅能够为 Java接口 做代理,而且能够为普通的 Java类 做代理,而 JDK Proxy 只能为实现了接口的 Java类 做代理,所以 CGLIB 为 Java 的代理做了很好的扩展。**如果需要代理的类没有实现接口,可以选择 Cglib 作为实现动态代理的工具。
废话太多,一句话概括:**CGLIB 可以代理没有实现接口的 Java 类
下面我们来学习它的使用方法,以小明找代理工厂买法国香水这个故事背景为例子。
(1)导入依赖
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>3.3.0</version>
<scope>test</scope>
</dependency>
还有另外一个 CGLIB 包,二者的区别是带有
-nodep
的依赖内部已经包括了ASM
字节码框架的相关代码,无需额外依赖ASM
(2)CGLIB 代理中有两个核心的类:MethodInterceptor
接口 和 Enhancer
类,前者是实现一个代理工厂的根接口,后者是创建动态代理对象的类,在这里我再贴一次故事的结构图,帮助你们理解。
首先我们来定义代理工厂SellProxyFactory
。
public class SellProxyFactory implements MethodInterceptor {
// 关联真实对象,控制对真实对象的访问
private Object realObject;
/** 从代理工厂中获取一个代理对象实例,等价于创建小红代理 */
public Object getProxyInstance(Object realObject) {
this.realObject = realObject;
Enhancer enhancer = new Enhancer();
// 设置需要增强类的类加载器
enhancer.setClassLoader(realObject.getClass().getClassLoader());
// 设置被代理类,真实对象
enhancer.setSuperclass(realObject.getClass());
// 设置方法拦截器,代理工厂
enhancer.setCallback(this);
// 创建代理类
return enhancer.create();
}
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
doSomethingBefore(); // 前置增强
Object object = methodProxy.invokeSuper(o, objects);
doSomethingAfter(); // 后置增强
return object;
}
private void doSomethingBefore() {
System.out.println("执行方法前额外的操作...");
}
private void doSomethingAfter() {
System.out.println("执行方法后额外的操作...");
}
}
intercept() 方法涉及到 4 个参数:
- Object o:被代理对象
- Method method:被拦截的方法
- Object[] objects:被拦截方法的所有入参值
- MethodProxy methodProxy:方法代理,用于调用原始的方法
对于
methodProxy
参数调用的方法,在其内部有两种选择:invoke()
和invokeSuper()
,二者的区别不在本文展开说明,感兴趣的读者可以参考本篇文章:Cglib源码分析 invoke和invokeSuper的差别
在 getInstance()
方法中,利用 Enhancer
类实例化代理对象(可以看作是小红)返回给调用者小明,即可完成代理操作。
public class XiaoMing {
public static void main(String[] args) {
SellProxyFactory sellProxyFactory = new SellProxyFactory();
// 获取一个代理实例
SellPerfumeFactory proxyInstance =
(SellPerfumeFactory) sellProxyFactory.getProxyInstance(new SellPerfumeFactory());
// 创建代理类
proxyInstance.sellPerfume(1999.99);
}
}
我们关注点依旧放在可扩展性和可维护性上,Cglib 依旧符合开闭原则,如果小明需要小红代理购买红酒,该如何做呢?这里碍于篇幅原因,我不再将完整的代码贴出来了,可以自己试着手动实现一下,或者在心里有一个大概的实现思路即可。
我们来总结一下 CGLIB 动态代理:
(1)CGLIB 的使用方法:
- 代理工厂需要实现 MethodInterceptor 接口,并重写方法,内部关联真实对象,控制第三者对真实对象的访问;代理工厂内部暴露
getInstance(Object realObject)
方法,用于从代理工厂中获取一个代理对象实例。 Enhancer
类用于从代理工厂中实例化一个代理对象,给调用者提供代理服务。
JDK Proxy 和 CGLIB 的对比
(2)仔细对比一下,JDK Proxy 和 CGLIB 具有相似之处:
JDK Proxy | CGLIB | |
---|---|---|
代理工厂实现接口 | InvocationHandler | MethodInterceptor |
构造代理对象给 Client 服务 | Proxy | Enhancer |
二者都是用到了两个核心的类,它们也有不同:
-
最明显的不同:CGLIB 可以代理大部分类(第二点说到);而 JDK Proxy **仅能够代理实现了接口的类
-
CGLIB 采用动态创建被代理类的子类实现方法拦截,子类内部重写被拦截的方法,所以 CGLIB 不能代理被
final
关键字修饰的类和方法
细心的读者会发现,讲的东西都是浅尝辄止~~(你都没有给我讲源码,水文实锤)~~,动态代理的精髓在于程序在运行时动态生成代理类对象,拦截调用方法,在调用方法前后扩展额外的功能,而生成动态代理对象的原理就是反射机制,在上一篇文章中,我详细讲到了如何利用反射实例化对象,调用方法······在代理中运用得淋漓尽致,所以反射和代理也是天生的一对,谈到其中一个,必然会涉及另外一个。
动态代理的实际应用
传统的 OOP 编程符合从上往下的编码关系,却不符合从左往右的编码关系,如果你看不懂,可以参考下面的动图,OOP 满足我们一个方法一个方法从上往下地执行,但是却不能从左往右嵌入代码,而 AOP 的出现很好地弥补了这一点,它允许我们将重复的代码逻辑抽取出来形成一个单独的覆盖层,在执行代码时可以将该覆盖层毫无知觉的嵌入到原代码逻辑里面去。
Spring AOP
如下图所示,method1 和 method2 都需要在方法执行前后记录日志,实际上会有更多的方法需要记录日志,传统的 OOP 只能够让我们在每个方法前后手动记录日志,大量的Log.info
存在于方法内部,导致代码阅读性下降,方法内部无法专注于自己的逻辑。
**AOP 可以将这些重复性的代码包装到额外的一层,监听方法的执行,当方法被调用时,通用的日志记录层会拦截掉该方法,在该方法调用前后记录日志,这样可以让方法专注于自己的业务逻辑而无需关注其它不必要的信息。
Spring AOP 有许多功能:提供缓存、提供日志环绕、事务处理······在这里,我会以事务作为例子向你讲解 Spring 底层是如何使用动态代理的。
Spring 的事务涉及到一个核心注解@Transactional
,相信很多人在项目中都用到过,加上这个注解之后,在执行方法时如果发生异常,该方法内所有的事务都回滚,否则全部提交生效,这是最宏观的表现,它内部是如何实现的呢?今天就来简单分析一下。
每个有关数据库的操作都要保证一个事务内的所有操作,要么全部执行成功,要么全部执行失败,传统的事务失败回滚和成功提交是使用try...catch
代码块完成的
SqlSession session = null;
try{
session = getSqlSessionFactory().openSession(false);
session.update("...", new Object());
// 事务提交
session.commit();
}catch(Exception e){
// 事务回滚
session.rollback();
throw e;
}finally{
// 关闭事务
session.close();
}
如果多个方法都需要写这一段逻辑非常冗余,所以 Spring 给我们封装了一个注解 @Transactional,使用它后,调用方法时会监视方法,如果方法上含有该注解,就会自动帮我们把数据库相关操作的代码包裹起来,最终形成类似于上面的一段代码原理,当然这里并不准确,只是给你们一个大概的总览,了解Spring AOP 的本质在干什么,这篇文章讲解到这里,知识量应该也非常多了,好好消化上面的知识点,为后面的 Spring AOP 专题学习打下坚实的基础。
Java创建对象的五种方式
我们日常生活中会创建很多对象,但是这个对象和你理解的那么对象不一样,因为作者不是女娲,不能造人。作者只是程序员,他只能在 Java 中创建对象。
那么我问你一个问题,你知道 Java 中如何创建对象吗?
这个问题仿佛是给 Java 新手来写的,好像有点瞧不起在座各位的样子,嗯。。。那么我换种问法好了,毕竟看我公众号的人都是将来月入 10w 的大佬。
你知道 Java 中有哪几种创建对象的方式吗?
诶?这个问题有点意思,平常我们用的最多的就是使用 new
来创建对象了,这是第一种方式;如果我们使用框架的话就直接交给 Spring
去管理就好了,Spring 底层是使用反射来创建对象的,这是第二种方式;然后。。。。。。。有点想不起来了,不要急,这篇文章就帮你回顾下。
使用 new 来创建对象
使用 new 来创建对象是最简单的一种方式了,new
是 Java 中的关键字,new 通过为新对象分配内存并返回对该内存的引用来实例化一个类,这个实例化一个类其实就相当于创建了一个对象,因为类也是一种对象;new 也负责调用对象的构造函数,下面是使用 new 来创建对象的代码
Object obj = new Object();
这段代码中,我们在堆区域中分配了一块内存,然后把 obj 对象指向了这块内存区域。
不知道你有没有看过 new 的字节码呢?下面是这段代码的字节码
在 Java 中,我们认为创建一个对象就是调用其构造方法,所以我们使用 new Object() 构造的对象,其实是调用了 Object 类的无参数
的构造方法。但是通过字节码我们发现,对象的创建和调用其构造方法是分开的。
字节码的 new
表示在堆中创建一个对象,并把对象的引用推入栈中。invokespecial
表示调用对象无参数的构造方法。其实,JVM 提供了五种方法调用指令,分别是
invokestatic
:该指令用于调用静态方法,即使用 static 关键字修饰的方法;invokespecial
:该指令用于三种场景:调用实例构造方法,调用私有方法(即 private 关键字修饰的方法)和父类方法(即 super 关键字调用的方法);invokeinterface
:该指令用于调用接口方法,在运行时再确定一个实现此接口的对象;invokevirtual
:该指令用于调用虚方法(就是除了上述三种情况之外的方法);invokedynamic
:在运行时动态解析出调用点限定符所引用的方法之后,调用该方法;在 JDK 1.7 中提出,主要用于支持 JVM 上的动态脚本语言(如 Groovy,Jython 等)
好了,现在你知道了 new 和 invokespecial 是干啥用的,那么 dup
指令呢?
dup 会复制栈上的最后一个元素,然后再次将其推入栈;因此,如果在栈上有一个对象引用,并且调用了 dup,则现在在栈上有对该对象的两个引用。看起来有点不知其所以然,所以在求助网上的时候,又发现了 R 大的解释
来源:https://www.zhihu.com/question/52749416
后面的 astore 就会把操作数栈顶的那个引用消耗掉,保存到指定的局部变量去。
如果直接使用 new Object()
没有创建局部变量的话,请注意一下它的字节码。
看出来细微的差别了吗?上图中的 astore_1
竟然变成了 pop
,这也就是说,new Object() 没有保存对象的局部变量,而是直接把它给消耗掉了。嗯,符合预期。
所以这是第一种创建的方式,也就是使用 new 来创建。
使用 newInstance 方法来创建
这个newInstance
方法指的是 class
类中的方法,newInstance 方法会调用无参的构造方法创建对象。
我们可以使用 newInstance 方法创建对象,下面是使用示例代码
User user = (User)Class.forName("com.cxuan.test.User").newInstance();
// 或者使用
User user = User.class.newInstance();
下面我们分析一下这个字节码,其实使用第一种方式和第二种方式就差了一个 Class.forName 的字节码,这是一个静态方法,应该用的是 invokestatic
,下面我们验证一下。
第一种方式的字节码
第二种方式的字节码
可以看到,我们验证的是正确的。
那么这段字节码是什么意思呢?
ldc
的意思是把常量池中的引用推入到当前堆栈中,invokestatic 和 invokevirtual 我们上面解释过了,然后就是 checkcast
, 这个字节码的含义就是进行类型转换,因为 newInstance 生成的是一个 Object 的对象,所以我们需要把它转换为我们需要的 User 类型,这个字节码就是干这个活的。
使用反射来创建对象
使用反射来创建对象其实也是使用了 newInstance 方法,只不过这个方法是 Constructor
,Java 反射中构造器的方法,我们可以通过这种方式来创建一个新的对象。如下代码所示
Constructor<User> constructor = User.class.getConstructor();
User user = constructor.newInstance();
下面是它的字节码
这里解释下 iconst_0
,它的意思就是将 int 值 0 加载到堆栈上,这个相当于是为 getConstructor
方法准备参数分配的字节码。
为了验证这个结论,我们从简优化,看一下其他方法的字节码
User.class.getDeclaredField("id");
它的字节码如下:
可以看到,第二个 ldc 其实就是 getDeclaredField
中的参数,为 String 类型,所以是用的 ldc,它是将引用推入堆栈。
使用对象克隆来创建对象
这是第四种创建方式,使用 Cloneable 类中的 clone()
方法来创建,它的前提是你需要实现 Cloneable 接口并实现其定义的 clone 方法。用 clone 方法创建对象并不会调用任何构造函数。
如下代码所示
Constructor<User> constructor = User.class.getConstructor();
User user = constructor.newInstance();
user.setName("cxuan");
User user2 = (User)user.clone();
System.out.println(user2.getName());
输出 cxuan
它的字节码如下
这个字节码有些长,但是字节码的概念和含义我们上面已经介绍过了,最主要的就是推入堆栈,调用对应的实例方法。
对象克隆这块是面试官非常喜欢考的一个点,我后面会解析一下浅拷贝和深拷贝的区别。
使用反序列化创建对象
当我们使用序列化和反序列化时,JVM 也会帮我们创建一个单独的对象。在反序列化时,JVM 创建对象不会调用任何构造函数,如下代码所示
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("xxx"));
out.writeObject(user2);
out.close();
//Deserialization
ObjectInputStream in = new ObjectInputStream(new FileInputStream("xxx"));
User user3 = (User) in.readObject();
in.close();
user3.setName("cxuan003");
System.out.println(user3 + ", hashcode : " + user3.hashCode());
这段反编译过后的字节码文件比较长,我这里就先不贴出来了,读者们可以自己编译看一下,其实并没有特别的字节码指令,大部分我们上面已经提到过了。
深入理解static关键字
static 是我们日常生活中经常用到的关键字,也是 Java 中非常重要的一个关键字,static 可以修饰变量、方法、做静态代码块、静态导包等,下面我们就来具体聊一聊这个关键字,我们先从基础开始,从基本用法入手,然后分析其原理、优化等。
初识 static 关键字
static 修饰变量
static
关键字表示的概念是 全局的、静态的
,用它修饰的变量被称为静态变量
。
public class TestStatic {
static int i = 10; // 定义了一个静态变量 i
}
静态变量也被称为类变量,静态变量是属于这个类所有的。什么意思呢?这其实就是说,static 关键字只能定义在类的 {}
中,而不能定义在任何方法中。
就算把方法中的 static 关键字去掉也是一样的。
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 方法,如何做到呢?看下面一段代码
在上面的例子中,由于 staticMethod
是静态方法,所以能够使用 类名.变量名进行调用。
因此,如果说想在不创建对象的情况下调用某个方法,就可以将这个方法设置为 static。平常我们见的最多的 static 方法就是 main方 法,至于为什么 main 方法必须是 static 的,现在应该很清楚了。因为程序在执行 main 方法的时候没有创建任何对象,因此只有通过类名来访问。
**static 修饰方法的注意事项
- 首先第一点就是最常用的,不用创建对象,直接
类名.变量名
即可访问; - static 修饰的方法内部不能调用非静态方法;
- 非静态方法内部可以调用 static 静态方法。
static 修饰代码块
static 关键字可以用来修饰代码块,代码块分为两种,一种是使用 {}
代码块;一种是 static {}
静态代码块。static 修饰的代码块被称为静态代码块。静态代码块可以置于类中的任何地方,类中可以有多个 static 块,在类初次被加载的时候,会按照 static 代码块的顺序来执行,每个 static 修饰的代码块只能执行一次。我们会面会说一下代码块的加载顺序。下面是静态代码块的例子
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 包,才能使用其内部的工具类,如下
但是还有一种导包方式是使用静态导包
,静态导入就是使用 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 的不同存储区域。
-
虚拟机栈
: 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/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.invokestatic 说到
从这个角度来讲,invokestatic
指令是专门用来执行 static 方法的指令;invokespecial
是专门用来执行实例方法的指令;从这个角度来讲,构造器也不是静态的。
Java动态代理
这篇文章我们来聊一下 Java 中的动态代理。
动态代理在 Java 中有着广泛的应用,比如 AOP 的实现原理、RPC远程调用、Java 注解对象获取、日志框架、全局性异常处理、事务处理等。
在了解动态代理前,我们需要先了解一下什么是代理模式。
代理模式
代理模式(Proxy Pattern)
是 23 种设计模式的一种,属于结构型模式
。他指的是一个对象本身不做实际的操作,而是通过其他对象来得到自己想要的结果。这样做的好处是可以在目标对象实现的基础上,增强额外的功能操作,即扩展目标对象的功能。
这里能体现出一个非常重要的编程思想:不要随意去改源码,如果需要修改,可以通过代理的方式来扩展该方法。
如上图所示,用户不能直接使用目标对象,而是构造出一个代理对象,由代理对象作为中转,代理对象负责调用目标对象真正的行为,从而把结果返回给用户。
也就是说,代理的关键点就是代理对象和目标对象的关系。
代理其实就和经纪人一样,比如你是一个明星,有很多粉丝。你的流量很多,经常会有很多金主来找你洽谈合作等,你自己肯定忙不过来,因为你要处理的不只是谈合作这件事情,你还要懂才艺、拍戏、维护和粉丝的关系、营销等。为此,你找了一个经纪人,你让他负责和金主谈合作这件事,经纪人做事很认真负责,它圆满的完成了任务,于是,金主找你谈合作就变成了金主和你的经纪人谈合作,你就有更多的时间来忙其他事情了。如下图所示
这是一种静态代理,因为这个代理(经纪人)
是你自己亲自挑选的。
但是后来随着你的业务逐渐拓展,你无法选择每个经纪人,所以你索性交给了代理公司来帮你做。如果你想在 B 站火一把,那就直接让代理公司帮你找到负责营销方面的代理人,如果你想维护和粉丝的关系,那你直接让代理公司给你找一些托儿就可以了,那么此时的关系图会变为如下
此时你几乎所有的工作都是由代理公司来进行打理,而他们派出谁来帮你做这些事情你就不得而知了,这得根据实际情况来定,因为代理公司也不只是负责你一个明星,而且每个人所擅长的领域也不同,所以你只有等到有实际需求后,才会给你指定对应的代理人,这种情况就叫做动态代理
。
静态代理
从编译期是否能确定最终的执行方法可以把代理模式分为静态代理和动态代理,我们先演示一下动态代理,这里有一个需求,领导想在系统中添加一个用户,但是他不自己添加,他让下面的程序员来添加,我们看一下这个过程。
首先构建一个用户接口,定义一个保存用户的模版方法。
public interface UserDao {
void saveUser();
}
构建一个用户实现类,这个用户实现类是真正进行用户操作的方法
public class UserDaoImpl implements UserDao{
@Override
public void saveUser() {
System.out.println(" ---- 保存用户 ---- ");
}
}
构建一个用户代理类,用户代理类也有一个保存用户的方法,不过这个方法属于代理方法,它不会执行真正的保存用户,而是内部持有一个真正的用户对象,进行用户保存。
public class UserProxy {
private UserDao userDao;
public UserProxy(UserDao userDao){
this.userDao = userDao;
}
public void saveUser() {
System.out.println(" ---- 代理开始 ---- ");
userDao.saveUser();
System.out.println(" ---- 代理结束 ----");
}
}
下面是测试方法。
public class UserTest {
public static void main(String[] args) {
UserDao userDao = new UserDaoImpl();
UserProxy userProxy = new UserProxy(userDao);
userProxy.saveUser();
}
}
新创建一个用户实现类 (UserDaoImpl),它不执行用户操作。然后再创建一个用户代理(UserProxy),执行用户代理的用户保存(saveUser),其内部会调用用户实现类的保存用户(saveUser)方法,因为我们 JVM 可以在编译期确定最终的执行方法,所以上面的这种代理模式又叫做静态代理
。
代理模式具有无侵入性的优点,以后我们增加什么新功能的话,我们可以直接增加一个代理类,让代理类来调用用户操作,这样我们就实现了不通过改源码的方式增加了新的功能。然后生活很美好了,我们能够直接添加我们想要的功能,在这美丽
的日子里,cxuan 添加了用户代理、日志代理等等无数个代理类。但是好景不长,cxuan 发现每次改代码的时候都要改每个代理类,这就很烦啊!我宝贵的时光都浪费在改每个代理类上面了吗?
动态代理
JDK 动态代理
于是乎 cxuan 上网求助,发现了一个叫做动态代理的概念,通读了一下,发现有点意思,于是乎 cxuan 修改了一下静态代理的代码,新增了一个 UserHandler
的用户代理,并做了一下 test
,代码如下
public class UserHandler implements InvocationHandler {
private UserDao userDao;
public UserHandler(UserDao userDao){
this.userDao = userDao;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
saveUserStart();
Object obj = method.invoke(userDao, args);
saveUserDone();
return obj;
}
public void saveUserStart(){
System.out.println("---- 开始插入 ----");
}
public void saveUserDone(){
System.out.println("---- 插入完成 ----");
}
}
测试类如下
public static void dynamicProxy(){
UserDao userDao = new UserDaoImpl();
InvocationHandler handler = new UserHandler(userDao);
ClassLoader loader = userDao.getClass().getClassLoader();
Class<?>[] interfaces = userDao.getClass().getInterfaces();
UserDao proxy = (UserDao)Proxy.newProxyInstance(loader, interfaces, handler);
proxy.saveUser();
}
UserHandler
是用户代理类,构造函数中的 UserDao 是真实对象,通过把 UserDao 隐藏进 UserHandler ,通过 UserHandler 中的 UserDao 执行真正的方法。
类加载器、接口数组你可以把它理解为一个方法树,每棵叶子结点都是一个方法,通过后面的 proxy.saveUser() 来告诉 JVM 执行的是方法树上的哪个方法。
用户代理是通过类加载器、接口数组、代理类来得到的。saveUser 方法就相当于是告诉 proxy 你最终要执行的是哪个方法,这个 proxy.saveUser 方法并不是最终直接执行的 saveUser 方法,最终的 saveUser 方法是由 UserHandler 中的 invoke 方法触发的。
上面这种在编译期无法确定最终的执行方法,而只能通过运行时动态获取方法的代理模式被称为 动态代理
。
动态代理的优势是实现无侵入式
的代码扩展,也可以对方法进行增强。此外,也可以大大减少代码量,避免代理类泛滥成灾的情况。
所以我们现在总结一下静态代理和动态代理各自的特点。
**静态代理
- 静态代理类:由程序员创建或者由第三方工具生成,再进行编译;在程序运行之前,代理类的 .class 文件已经存在了。
- 静态代理事先知道要代理的是什么。
- 静态代理类通常只代理一个类。
**动态代理
- 动态代理通常是在程序运行时,通过
反射机制
动态生成的。 - 动态代理类通常代理
接口
下的所有类。 - 动态代理事先不知道要代理的是什么,只有在运行的时候才能确定。
- 动态代理的调用处理程序必须事先继承 InvocationHandler 接口,使用 Proxy 类中的 newProxyInstance 方法动态的创建代理类。
在上面的代码示例中,我们是定义了一个 UserDao 接口,然后有 UserDaoImpl 接口的实现类,我们通过 Proxy.newProxyInstance 方法得到的也是 UserDao 的实现类对象,那么其实这是一种基于接口的动态代理。也叫做 JDK 动态代理
。
是不是只有这一种动态代理技术呢?既然都这么问了,那当然不是。
除此之外,还有一些其他代理技术,不过是需要加载额外的 jar 包的,那么我们汇总一下所有的代理技术和它的特征
-
JDK 的动态代理使用简单,它内置在 JDK 中,因此不需要引入第三方 Jar 包。
-
CGLIB 和 Javassist 都是高级的字节码生成库,总体性能比 JDK 自带的动态代理好,而且功能十分强大。
-
ASM 是低级的字节码生成工具,使用 ASM 已经近乎于在使用字节码编程,对开发人员要求最高。当然,也是
性能最好
的一种动态代理生成工具。但 ASM 的使用很繁琐,而且性能也没有数量级的提升,与 CGLIB 等高级字节码生成工具相比,ASM 程序的维护性较差,如果不是在对性能有苛刻要求的场合,还是推荐 CGLIB 或者 Javassist。
下面我们就来依次介绍一下这些动态代理工具的使用
CGLIB 动态代理
上面我们提到 JDK 动态代理是基于接口的代理,而 CGLIB 动态代理是针对类实现代理,主要是对指定的类生成一个子类,覆盖其中的方法 ,也就是说 CGLIB 动态代理采用类继承 -> 方法重写的方式进行的,下面我们先来看一下 CGLIB 动态代理的结构。
如上图所示,代理类继承于目标类,每次调用代理类的方法都会在拦截器中进行拦截,拦截器中再会调用目标类的方法。
下面我们通过一个示例来演示一下 CGLIB 动态代理的使用
首先导入 CGLIB 相关 jar 包,我们使用的是 MAVEN 的方式
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.2.5</version>
</dependency>
然后我们新创建一个 UserService 类,为了和上面的 UserDao 和 UserDaoImpl 进行区分。
public class UserService {
public void saveUser(){
System.out.println("---- 保存用户 ----");
}
}
之后我们创建一个自定义方法拦截器,这个自定义方法拦截器实现了拦截器类
public class AutoMethodInterceptor implements MethodInterceptor {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
System.out.println("---- 方法拦截 ----");
Object object = methodProxy.invokeSuper(obj, args);
return object;
}
}
这里解释一下这几个参数都是什么含义
- Object obj: obj 是 CGLIB 动态生成代理类实例
- Method method: Method 为实体类所调用的被代理的方法引用
- Objectp[] args: 这个就是方法的参数列表
- MethodProxy methodProxy : 这个就是生成的代理类对方法的引用。
对于 methodProxy
参数调用的方法,在其内部有两种选择:invoke()
和 invokeSuper()
,二者的区别不在本文展开说明,感兴趣的读者可以参考本篇文章:Cglib源码分析 invoke和invokeSuper的差别
然后我们创建一个测试类进行测试
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserService.class);
enhancer.setCallback(new AutoMethodInterceptor());
UserService userService = (UserService)enhancer.create();
userService.saveUser();
}
测试类主要涉及 Enhancer
的使用,Enhancer 是一个非常重要的类,它允许为非接口类型
创建一个 Java 代理,Enhancer 动态的创建给定类的子类并且拦截代理类的所有的方法,和 JDK 动态代理不一样的是不管是接口还是类它都能正常工作。
JDK 动态代理与 CGLIB 动态代理都是将真实对象隐藏
在代理对象的后面,以达到 代理
的效果。与 JDK 动态代理所不同的是 CGLIB 动态代理使用 Enhancer 来创建代理对象,而 JDK 动态代理使用的是 Proxy.newProxyInstance 来创建代理对象;还有一点是 CGLIB 可以代理大部分类,而 JDK 动态代理只能代理实现了接口的类。
Javassist 代理
Javassist
是在 Java 中编辑字节码的类库;它使 Java 程序能够在运行时定义一个新类, 并在 JVM 加载时修改类文件。我们使用最频繁的动态特性就是 反射
,而且反射也是动态代理的基础,我们之所以没有提反射对动态代理的作用是因为我想在后面详聊,反射可以在运行时查找对象属性、方法,修改作用域,通过方法名称调用方法等。实时应用不会频繁使用反射来创建,因为反射开销比较大,另外,还有一种具有和反射一样功能强大的特性那就是 Javaassist
。
我们先通过一个简单的示例来演示一下 Javaassist ,以及 Javaassist 如何创建动态代理。
我们仍旧使用上面提到的 UserDao 和 UserDaoImpl 作为基类。
我们新创建一个 AssistByteCode 类,它里面有一个 createByteCode 方法,这个方法主要做的事情就是通过字节码生成 UserDaoImpl 实现类。我们下面来看一下它的代码
public class AssistByteCode {
public static void createByteCode() throws Exception{
ClassPool classPool = ClassPool.getDefault();
CtClass cc = classPool.makeClass("com.cxuan.proxypattern.UserDaoImpl");
// 设置接口
CtClass ctClass = classPool.get("com.cxuan.proxypattern.UserDao");
cc.setInterfaces(new CtClass[] {ctClass});
// 创建方法
CtMethod saveUser = CtMethod.make("public void saveUser(){}", cc);
saveUser.setBody("System.out.println(\"---- 插入用户 ----\");");
cc.addMethod(saveUser);
Class c = cc.toClass();
cc.writeFile("/Users/mr.l/cxuan-justdoit");
}
}
由于本文并不是一个具体研究 Javaassist 的文章,所以我们不会过多研究细节问题,只专注于这个框架一些比较重要的类
ClassPool
:ClassPool 就是一个 CtClass 的容器,而一个 CtClass
对象就是一个 class 对象的实例,这个实例和 class 对象一样,包含属性、方法等。
那么上面代码主要做了哪些事儿呢?通过 ClassPool 来获取 CtClass 所需要的接口、抽象类的 CtClass 实例,然后通过 CtClass 实例添加自己的属性和方法,并通过它的 writeFile 把二进制流输出到当前项目的根目录路径下。writeFile 其内部是使用了 DataOutputStream
进行输出的。
流写完后,我们打开这个 .class
文件如下所示
public class UserDaoImpl implements UserDao {
public void saveUser() {
System.out.println("---- 插入用户 ----");
}
public UserDaoImpl() {
}
}
可以对比一下上面发现 UserDaoImpl 发现编译器除了为我们添加了一个公有的构造器,其他基本一致。
经过这个简单的示例后,cxuan 给你演示一下如何使用 Javaassist 动态代理。
首先我们先创建一个 Javaassist 的代理工厂,代码如下
public class JavaassistProxyFactory {
public Object getProxy(Class clazz) throws Exception{
// 代理工厂
ProxyFactory proxyFactory = new ProxyFactory();
// 设置需要创建的子类
proxyFactory.setSuperclass(clazz);
proxyFactory.setHandler((self, thisMethod, proceed, args) -> {
System.out.println("---- 开始拦截 ----");
Object result = proceed.invoke(self, args);
System.out.println("---- 结束拦截 ----");
return result;
});
return proxyFactory.createClass().newInstance();
}
}
上面我们定义了一个代理工厂,代理工厂里面创建了一个 handler,在调用目标方法时,Javassist 会回调 MethodHandler 接口方法拦截,来调用真正执行的方法,你可以在拦截方法的前后实现自己的业务逻辑。最后的 proxyFactory.createClass().newInstance() 就是使用字节码技术来创建了最终的子类实例,这种代理方式类似于 JDK 中的 InvocationHandler 接口。
测试方法如下
public static void main(String[] args) throws Exception {
JavaassistProxyFactory proxyFactory = new JavaassistProxyFactory();
UserService userProxy = (UserService) proxyFactory.getProxy(UserService.class);
userProxy.saveUser();
}
ASM 代理
ASM 是一套 Java 字节码生成架构,它可以动态生成二进制格式的子类或其它代理类,或者在类被 Java 虚拟机装入内存之前,动态修改类。
下面我们使用 ASM 框架实现一个动态代理,ASM 生成的动态代理
以下代码摘自 https://blog.csdn.net/lightj1996/article/details/107305662
public class AsmProxy extends ClassLoader implements Opcodes {
public static void createAsmProxy() throws Exception {
// 目标类类名 字节码中类修饰符以 “/” 分割
String targetServiceName = TargetService.class.getName().replace(".", "/");
// 切面类类名
String aspectServiceName = AspectService.class.getName().replace(".", "/");
// 代理类类名
String proxyServiceName = targetServiceName+"Proxy";
// 创建一个 classWriter 它是继承了ClassVisitor
ClassWriter classWriter = new ClassWriter(0);
// 访问类 指定jdk版本号为1.8, 修饰符为 public,父类是TargetService
classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, proxyServiceName, null, targetServiceName, null);
// 访问目标类成员变量 为类添加切面属性 “private TargetService targetService”
classWriter.visitField(ACC_PRIVATE, "targetService", "L" + targetServiceName+";", null, null);
// 访问切面类成员变量 为类添加目标属性 “private AspectService aspectService”
classWriter.visitField(ACC_PRIVATE, "aspectService", "L" + aspectServiceName+";", null, null);
// 访问默认构造方法 TargetServiceProxy()
// 定义函数 修饰符为public 方法名为 <init>, 方法表述符为()V 表示无参数,无返回参数
MethodVisitor initVisitor = classWriter.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
// 从局部变量表取第0个元素 “this”
initVisitor.visitVarInsn(ALOAD, 0);
// 调用super 的构造方法 invokeSpecial在这里的意思是调用父类方法
initVisitor.visitMethodInsn(INVOKESPECIAL, targetServiceName, "<init>", "()V", false);
// 方法返回
initVisitor.visitInsn(RETURN);
// 设置最大栈数量,最大局部变量表数量
initVisitor.visitMaxs(1, 1);
// 访问结束
initVisitor.visitEnd();
// 创建有参构造方法 TargetServiceProxy(TargetService var1, AspectService var2)
// 定义函数 修饰符为public 方法名为 <init>, 方法表述符为(TargetService, AspectService)V 表示无参数,无返回参数
MethodVisitor methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "<init>", "(L" + targetServiceName + ";L"+aspectServiceName+";)V", null, null);
// 从局部变量表取第0个元素 “this”压入栈顶
methodVisitor.visitVarInsn(ALOAD, 0);
// this出栈 , 调用super 的构造方法 invokeSpecial在这里的意思是调用父类方法。 <init>的owner是AspectService, 无参无返回类型
methodVisitor.visitMethodInsn(INVOKESPECIAL, targetServiceName, "<init>", "()V", false);
// 从局部变量表取第0个元素 “this”压入栈顶
methodVisitor.visitVarInsn(ALOAD, 0);
// 从局部变量表取第1个元素 “targetService”压入栈顶
methodVisitor.visitVarInsn(ALOAD, 1);
// this 和 targetService 出栈, 调用targetService put 赋值给this.targetService
methodVisitor.visitFieldInsn(PUTFIELD, proxyServiceName, "targetService", "L" + targetServiceName + ";");
// 从局部变量表取第0个元素 “this”压入栈顶
methodVisitor.visitVarInsn(ALOAD, 0);
// 从局部变量表取第2个元素 “aspectService”压入栈顶
methodVisitor.visitVarInsn(ALOAD, 2);
// this 和 aspectService 出栈 将 targetService put 赋值给this.aspectService
methodVisitor.visitFieldInsn(PUTFIELD, proxyServiceName, "aspectService", "L" + aspectServiceName + ";");
// 方法返回
methodVisitor.visitInsn(RETURN);
// 设置最大栈数量,最大局部变量表数量
methodVisitor.visitMaxs(2, 3);
// 方法返回
methodVisitor.visitEnd();
// 创建代理方法 修饰符为public,方法名为 demoQuest
MethodVisitor visitMethod = classWriter.visitMethod(ACC_PUBLIC, "demoQuest", "()I", null, null);
// 从局部变量表取第0个元素 “this”压入栈顶
visitMethod.visitVarInsn(ALOAD, 0);
// this 出栈 将this.aspectService压入栈顶
visitMethod.visitFieldInsn(GETFIELD, proxyServiceName, "aspectService", "L"+aspectServiceName+";");
// 取栈顶元素出栈 也就是targetService 调用其preOperation方法, demoQuest的owner是AspectService, 无参无返回类型
visitMethod.visitMethodInsn(INVOKEVIRTUAL, aspectServiceName,"preOperation", "()V", false);
// 从局部变量表取第0个元素 “this”压入栈顶
visitMethod.visitVarInsn(ALOAD, 0);
// this 出栈, 取this.targetService压入栈顶
visitMethod.visitFieldInsn(GETFIELD, proxyServiceName, "targetService", "L"+targetServiceName+";");
// 取栈顶元素出栈 也就是targetService调用其demoQuest方法, demoQuest的owner是TargetService, 无参无返回类型
visitMethod.visitMethodInsn(INVOKEVIRTUAL, targetServiceName, "demoQuest", "()I", false);
// 方法返回
visitMethod.visitInsn(IRETURN);
// 设置最大栈数量,最大局部变量表数量
visitMethod.visitMaxs(1, 1);
// 方法返回
visitMethod.visitEnd();
// 生成字节码二进制流
byte[] code = classWriter.toByteArray();
// 自定义classloader加载类
Class<?> clazz = (new AsmProxy()).defineClass(TargetService.class.getName() + "Proxy", code, 0, code.length);
// 取其带参数的构造方法
Constructor constructor = clazz.getConstructor(TargetService.class, AspectService.class);
// 使用构造方法实例化对象
Object object = constructor.newInstance(new TargetService(), new AspectService());
// 使用TargetService类型的引用接收这个对象
TargetService targetService;
if (!(object instanceof TargetService)) {
return;
}
targetService = (TargetService)object;
System.out.println("生成代理类的名称: " + targetService.getClass().getName());
// 调用被代理方法
targetService.demoQuest();
// 这里可以不用写, 但是如果想看最后生成的字节码长什么样子,可以写 "ascp-purchase-app/target/classes/"是我的根目录, 阅读者需要将其替换成自己的
String classPath = "/Users/mr.l/cxuan-justdoit/";
String path = classPath + proxyServiceName + ".class";
FileOutputStream fos =
new FileOutputStream(path);
fos.write(code);
fos.close();
}
}
使用 ASM 生成动态代理的代码比较长,上面这段代码的含义就是生成类 TargetServiceProxy,用于代理TargetService ,在调用 targetService.demoQuest() 方法之前调用切面的方法 aspectService.preOperation();
测试类就直接调用 AsmProxy.createAsmProxy() 方法即可,比较简单。
下面是我们生成 TargetServiceProxy 的目标类
至此,我们已经介绍了四种动态代理的方式,分别是JDK 动态代理、CGLIB 动态代理、Javaassist 动态代理、ASM 动态代理,那么现在思考一个问题,为什么会有动态代理的出现呢?或者说动态代理是基于什么原理呢?
其实我们上面已经提到过了,没错,动态代理使用的就是反射
机制,反射机制是 Java 语言提供的一种基础功能,赋予程序在运行时动态修改属性、方法的能力。通过反射我们能够直接操作类或者对象,比如获取某个类的定义,获取某个类的属性和 方法等。
关于 Java 反射的相关内容可以参考 Java建设者的这一篇文章
另外还有需要注意的一点,从性能角度来讲,有些人得出结论说是 Java 动态代理要比 CGLIB 和 Javaassist 慢几十倍,其实,在主流 JDK 版本中,Java 动态代理可以提供相等的性能水平,数量级的差距不是广泛存在的。而且,在现代 JDK 中,反射已经得到了改进和优化。
我们在选型中,性能考量并不是主要关注点,可靠性、可维护性、编码工作量同等重要。
深入理解final、finally和finalize关键字
final
是 Java 中的关键字,它也是 Java 中很重要的一个关键字,final 修饰的类、方法、变量有不同的含义;finally
也是一个关键字,不过我们可以使用 finally 和其他关键字结合做一些组合操作; finalize
是一个不让人待见的方法,它是对象祖宗 Object
中的一个方法,finalize 机制现在已经不推荐使用了。本篇文章,cxuan 就带你从这三个关键字入手,带你从用法、应用、原理的角度带你深入浅出理解这三个关键字。
final、finally 和 finalize
我相信在座的各位都是资深程序员,final 这种基础关键字就不用多说了。不过,还是要照顾一下小白读者,毕竟我们都是从小白走过来的嘛。
final 修饰类、属性和方法
final
可以用来修饰类,final 修饰的类不允许其他类继承,也就是说,final 修饰的类是独一无二的。如下所示
我们首先定义了一个 FinalUsage 类,它使用 final 修饰,同时我们又定义了一个 FinalUsageExtend 类,它想要继承(extend)
FinalUsage,我们如上继承后,编译器不让我们这么玩儿,它提示我们 不能从 FinalUsage 类继承,为什么呢?不用管,这是 Java 的约定,有一些为什么没有必要,遵守就行。
final
可以用来修饰方法,final 修饰的方法不允许被重写,我们先演示一下不用 final 关键字修饰的情况
如上图所示,我们使用 FinalUsageExtend 类继承了 FinalUsage 类,并提供了 writeArticle
方法的重写。这样编译是没有问题的,重写的关键点是 @Override
注解和方法修饰符、名称、返回值的一致性。
注意:很多程序员在重写方法的时候都会忽略 @Override,这样其实无疑增加了代码阅读的难度,不建议这样。
当我们使用 final 修饰方法后,这个方法则不能被重写,如下所示
当我们把 writeArticle 方法声明为 void 后,重写的方法会报错,无法重写 writeArticle 方法。
final
可以修饰变量,final 修饰的变量一经定义后就不能被修改,如下所示
编译器提示的错误正是不能继承一个被 final 修饰的类。
我们上面使用的是字符串 String ,String 默认就是 final 的,其实用不用 final 修饰意义不大,因为字符串本来就不能被改写,这并不能说明问题。
我们改写一下,使用基本数据类型来演示
同样的可以看到,编译器仍然给出了 age 不能被改写的提示,由此可以证明,final 修饰的变量不能被重写。
在 Java 中不仅仅只有基本数据类型,还有引用数据类型,那么引用类型被 final 修饰后会如何呢?我们看一下下面的代码
首先构造一个 Person
类
public class Person {
int id;
String name;
get() and set() ...
toString()...
}
然后我们定义一个 final 的 Person 变量。
static final Person person = new Person(25,"cxuan");
public static void main(String[] args) {
System.out.println(person);
person.setId(26);
person.setName("cxuan001");
System.out.println(person);
}
输出一下,你会发现一个奇怪的现象,为什么我们明明改了 person 中的 id 和 name ,编译器却没有报错呢?
这是因为,final 修饰的引用类型,只是保证对象的引用不会改变。对象内部的数据可以改变。这就涉及到对象在内存中的分配问题,我们后面再说。
finally 保证程序一定被执行
finally
是保证程序一定执行的机制,同样的它也是 Java 中的一个关键字,一般来讲,finally 一般不会单独使用,它一般和 try 块一起使用,例如下面是一段 try…finally 代码块
try{
lock.lock();
}finally {
lock.unlock();
}
这是一段加锁/解锁的代码示例,在 lock 加锁后,在 finally 中执行解锁操作,因为 finally 能够保证代码一定被执行,所以一般都会把一些比较重要的代码放在 finally 中,例如解锁操作、流关闭操作、连接释放操作等。
当 lock.lock() 产生异常时还可以和 try...catch...finally
一起使用
try{
lock.lock();
}catch(Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
try…finally 这种写法适用于 JDK1.7 之前,在 JDK1.7 中引入了一种新的关闭流的操作,那就是 try...with...resources
,Java 引入了 try-with-resources 声明,将 try-catch-finally 简化为 try-catch,这其实是一种语法糖,并不是多了一种语法。try…with…resources 在编译时还是会进行转化为 try-catch-finally 语句。
语法糖(Syntactic sugar)
,也译为糖衣语法,是指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。
在 Java 中,有一些为了简化程序员使用的语法糖,后面有机会我们再谈。
finalize 的作用
finalize 是祖宗类 Object
类的一个方法,它的设计目的是保证对象在垃圾收集前完成特定资源的回收。finalize 现在已经不再推荐使用,在 JDK 1.9 中已经明确的被标记为 deprecated
。
深入理解 final 、finally 和 finalize
final 设计
许多编程语言都会有某种方法来告知编译器,某一块数据是恒定不变的。有时候恒定不变的数据很有用,比如
- 一个永不改变的编译期常量 。例如 **static final int num = 1024
- 一个运行时被初始化的值,而且你不希望改变它
final 的设计会和 abstract
的设计产生冲突,因为 abstract 关键字主要修饰抽象类
,而抽象类需要被具体的类所实现。final 表示禁止继承,也就不会存在被实现的问题。因为只有继承后,子类才可以实现父类的方法。
类中的所有 private
都隐式的指定为 final
的,在 private 修饰的代码中使用 final 并没有额外的意义。
空白 final
Java 是允许空白 final
的,空白 final 指的是声明为 final ,但是却没有对其赋值使其初始化。但是无论如何,编译器都需要初始化 final,所以这个初始化的任务就交给了构造器
来完成,空白 final 给 final 提供了更大的灵活性。如下代码
public class FinalTest {
final Integer finalNum;
public FinalTest(){
finalNum = 11;
}
public FinalTest(int num){
finalNum = num;
}
public static void main(String[] args) {
new FinalTest();
new FinalTest(25);
}
}
在不同的构造器中对不同的 final 进行初始化,使 finalNum 的使用更加灵活。
使用 final 的方法主要有两个:不可变
和 效率
- 不可变:不可变说的是把方法锁定(注意不是加锁),重在防止其他方法重写。
- 效率:这个主要是针对 Java 早期版本来说的,在 Java 早期实现中,如果将方法声明为 final 的,就是同意编译器将对此方法的调用改为
内嵌调用
,但是却没有带来显著的性能优化。这种调用就比较鸡肋,在 Java5/6 中,hotspot 虚拟机会自动探测到内嵌调用,并把它们优化掉,所以使用 final 修饰的方法就主要有一个:不可变。
注意:final 不是 Immutable 的,Immutable 才是真正的不可变。
final 不是真正的 Immutable
,因为 final 关键字引用的对象是可以改变的。如果我们真的希望对象不可变,通常需要相应的类支持不可变行为,比如下面这段代码
final List<String> fList = new ArrayList();
fList.add("Hello");
fList.add("World");
List unmodfiableList = List.of("hello","world");
unmodfiableList.add("again");
List.of
方法创建的就是不可变的 List。不可变 Immutable 在很多情况下是很好的选择,一般来说,实现 Immutable 需要注意如下几点
- 将类声明为 final,防止其他类进行扩展。
- 将类内部的成员变量(包括实例变量和类变量)声明为
private
或final
的,不要提供可以修改成员变量的方法,也就是 setter 方法。 - 在构造对象时,通常使用
deep-clone
,这样有助于防止在直接对对象赋值时,其他人对输入对象的修改。 - 坚持
copy-on-write
原则,创建私有的拷贝。
final 能提高性能吗?
final 能否提高性能一直是业界争论的点,很多书籍中都介绍了可以在特定场景提高性能,例如 final 可能用于帮助 JVM 将方法进行内联,可以改造编译器进行编译的能力等等,但这些结论很多都是基于假设作出的。
或许 R 大这篇回答会给我们一些结论 https://www.zhihu.com/question/21762917
大致说的就是无论局部变量声明时带不带 final 关键字修饰,对其访问的效率都一样。
比如下面这段代码(不带 final 的版本)
static int foo() {
int a = someValueA();
int b = someValueB();
return a + b; // 这里访问局部变量
}
带 final 的版本
static int foo() {
final int a = someValueA();
final int b = someValueB();
return a + b; // 这里访问局部变量
}
使用 javac
编译后得出来的结果一摸一样。
invokestatic someValueA:()I
istore_0 // 设置a的值
invokestatic someValueB:()I
istore_1 // 设置b的值
iload_0 // 读取a的值
iload_1 // 读取b的值
iadd
ireturn
因为上面是使用引用类型,所以字节码相同。
如果是常量类型,我们看一下
// 带 final
static int foo(){
final int a = 11;
final int b = 12;
return a + b;
}
// 不带 final
static int foo(){
int a = 11;
int b = 12;
return a + b;
}
我们分别编译一下两个 foo
方法,会发现如下字节码
左边是非 final 关键字修饰的代码,右边是有 final 关键字修饰的代码,对比这两个字节码,可以得出如下结论。
- 不管有没有 final 修饰 ,int a = 11 或者 int a = 12 都当作常量看待。
- 在 return 返回处,不加 final 的 a + b 会当作变量来处理;加 final 修饰的 a + b 会直接当作常量处理。
其实这种层面上的差异只对比较简易的 JVM 影响较大,因为这样的 VM 对解释器的依赖较大,原本 Class 文件里的字节码是怎样的它就怎么执行;对高性能的 JVM(例如 HotSpot、J9 等)则没啥影响。
所以,大部分 final 对性能优化的影响,可以直接忽略,我们使用 final 更多的考量在于其不可变性。
深入理解 finally
我们上面大致聊到了 finally 的使用,其作用就是保证在 try 块中的代码执行完成之后,必然会执行 finally 中的语句。不管 try 块中是否抛出异常。
那么下面我们就来深入认识一下 finally ,以及 finally 的字节码是什么,以及 finally 究竟何时执行的本质。
- 首先我们知道 finally 块只会在 try 块执行的情况下才执行,finally 不会单独存在。
这个不用再过多解释,这是大家都知道的一条规则。finally 必须和 try 块或者 try catch 块一起使用。
- **其次,finally 块在离开 try 块执行完成后或者 try 块未执行完成但是接下来是控制转移语句时(return/continue/break)在控制转移语句之前执行
这一条其实是说明 finally 的执行时机的,我们以 return 为例来看一下是不是这么回事。
如下这段代码
static int mayThrowException(){
try{
return 1;
}finally {
System.out.println("finally");
}
}
public static void main(String[] args) {
System.out.println(FinallyTest.mayThrowException());
}
从执行结果可以证明是 finally 要先于 return 执行的。
当 finally 有返回值时,会直接返回。不会再去返回 try 或者 catch 中的返回值。
static int mayThrowException(){
try{
return 1;
}finally {
return 2;
}
}
public static void main(String[] args) {
System.out.println(FinallyTest.mayThrowException());
}
- **在执行 finally 语句之前,控制转移语句会将返回值存在本地变量中
看下面这段代码
static int mayThrowException(){
int i = 100;
try {
return i;
}finally {
++i;
}
}
public static void main(String[] args) {
System.out.println(FinallyTest.mayThrowException());
}
上面这段代码能够说明 return i 是先于 ++i 执行的,而且 return i 会把 i 的值暂存,和 finally 一起返回。
finally 的本质
下面我们来看一段代码
public static void main(String[] args) {
int a1 = 0;
try {
a1 = 1;
}catch (Exception e){
a1 = 2;
}finally {
a1 = 3;
}
System.out.println(a1);
}
这段代码输出的结果是什么呢?答案是 3,为啥呢?
抱着疑问,我们先来看一下这段代码的字节码
字节码的中文注释我已经给你标出来了,这里需要注意一下下面的 Exception table
,Exception table 是异常表,异常表中每一个条目代表一个异常发生器,异常发生器由 From 指针,To 指针,Target 指针和应该捕获的异常类型构成。
所以上面这段代码的执行路径有三种
- 如果 try 语句块中出现了属于 exception 及其子类的异常,则跳转到 catch 处理
- 如果 try 语句块中出现了不属于 exception 及其子类的异常,则跳转到 finally 处理
- 如果 catch 语句块中新出现了异常,则跳转到 finally 处理
聊到这里,我们还没说 finally 的本质到底是什么,仔细观察一下上面的字节码,你会发现其实 finally 会把 a1 = 3
的字节码 iconst_3 和 istore_1 放在 try 块和 catch 块的后面,所以上面这段代码就形同于
public static void main(String[] args) {
int a1 = 0;
try {
a1 = 1;
// finally a1 = 3
}catch (Exception e){
a1 = 2;
// finally a1 = 3
}finally {
a1 = 3;
}
System.out.println(a1);
}
上面中的 Exception table 是只有 Throwable
的子类 exception 和 error 才会执行异常走查的异常表,正常情况下没有 try 块是没有异常表的,下面来验证一下
public static void main(String[] args) {
int a1 = 1;
System.out.println(a1);
}
比如上面我们使用了一段非常简单的程序来验证,编译后我们来看一下它的字节码
可以看到,果然没有异常表的存在。
finally 一定会执行吗
上面我们讨论的都是 finally 一定会执行的情况,那么 finally 一定会被执行吗?恐怕不是。
除了机房断电、机房爆炸、机房进水、机房被雷劈、强制关机、拔电源之外,还有几种情况能够使 finally 不会执行。
-
调用
System.exit
方法 -
调用
Runtime.getRuntime().halt(exitStatus)
方法 -
JVM 宕机(搞笑脸)
-
如果 JVM 在 try 或 catch 块中达到了无限循环(或其他不间断,不终止的语句)
-
操作系统是否强行终止了 JVM 进程;例如,在 UNIX 上执行 kill -9 pid
-
如果主机系统死机;例如电源故障,硬件错误,操作系统死机等不会执行
-
如果 finally 块由守护程序线程执行,那么所有非守护线程在 finally 调用之前退出。
finalize 真的没用吗
我们上面简单介绍了一下 finalize 方法,并说明了它是一种不好的实践。那么 finalize 调用的时机是什么?为什么说 finalize 没用呢?
我们知道,Java 与 C++ 一个显著的区别在于 Java 能够自动管理内存
,在 Java 中,由于 GC 的自动回收机制,因而并不能保证 finalize
方法会被及时地执行(垃圾对象的回收时机具有不确定性),也不能保证它们会被执行。
也就是说,finalize 的执行时期不确定,我们并不能依赖于 finalize 方法帮我们进行垃圾回收,可能出现的情况是在我们耗尽资源之前,gc 却仍未触发,所以推荐使用资源用完即显示释放的方式,比如 close 方法。除此之外,finalize 方法也会生吞异常。
finalize 的工作方式是这样的:一旦垃圾回收器准备好释放对象占用的存储空间,将会首先调用 finalize
方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。垃圾回收只与内存有关。
我们在日常开发中并不提倡使用 finalize 方法,能用 finalize 方法的地方,使用 try…finally 会处理的更好。
学习Java网站推荐给你
推荐几个非常不错的 Java 学习网站
LearnJava 在线
这是一个非常不错的学习 Java 的在线网站,纯免费。这是一个个人项目,旨在通过简单有效的在浏览器中进行练习让你快速掌握 Java 编程语言。通过直接从网络浏览器运行实际代码,你无需在本地安装和执行代码就可以尝试编码,这无疑提高了编程效率和上手效率。
你可以根据目录进行练习,有基础部分和高级部分,首先有例子代码,告诉你需要注意的事项以及详细的讲解,旨在把你教会后再让你进行练习。
下面就是练习部分,练习部分没有固定在底部边框,可以自由滑动,能够让你编写代码的同时能够查看示例代码详解
这也是一个教程网站,不仅仅只有 Java 的学习,也包括 Python
、HTML
、GO
、C
和 C++
等编程语言的学习,非常不错,你值得拥有
官网:https://www.learnjavaonline.org/
StackOverflow
说到学习 Java ,怎么能不提 StackOverflow 呢?
StackOverflow 是一个与程序相关的 IT 技术问答网站。用户可以在网站免费提交问题,浏览问题,索引相关内容,在创建主页的时候使用简单的 HTML。在问题页面,不会弹出任何广告,销售信息,JavaScript 窗口等。
干净清爽,基本上所有的问题你在 StackOverflow 都能找到答案,你可以提出公共问题,也可以提出私人问题,甚至可以在 StackOverflow 上找工作
官网: https://stackoverflow.com/
DZone
DZone 上会有 IT 前沿的新闻和文章,会有 **AI、大数据、云、数据库、DevOps、IoT、Java 还有开源项目
关于 Java 新特性的介绍,新特性的使用都会在上面,是你掌握前沿动态不可或缺的网站
LeetCode
LeetCode 是一个很牛逼的刷题网站,它的重要性不用我多说了吧
看到这个界面就爱了,里面包括大量的算法题,这些算法题是大厂面试必出的题型,据说掌握了这些算法题后,你可以吊打中国任何一家大厂,是不是真的咱也不知道,毕竟咱们没做过几道题。
问题可以区分难易程度,有解决措施,接受度,困难程度等,下面还有大厂公司的面试题
因为 LeetCode 太牛逼了,LeetCode 推出了中文版,中文为力扣
几乎和英文版的一模一样,如果小伙伴英文不是很好可以看看中文版 刷题
官网:https://leetcode.com/ ; https://leetcode-cn.com
Java 官方文档
学习 Java,还有什么比官网更权威的呢?我之前一直不知道官网能做的这么干净,你来看一下
包含各种内容,总揽,编程语言,工具,特性,JVM 等,只不过语言有一些生硬,需要强大的英文功底,不过现在有谷歌翻译后我觉得这些问题也都不是问题了。
官网:http://docs.oracle.com/javase/
Coursera
Coursera 是一个在线课程网站,是为大厂量身定做的,就和国内很多在线教育机构类似,不过真实性如何不能确保,毕竟身边没有小伙伴抱过课程。
Coursera是大型公开在线课程项目,由美国斯坦福大学两名计算机科学教授创办。旨在同世界顶尖大学合作,在线提供网络公开课程,它与全世界最顶尖的大学和机构合作,提供任何人可学习的在线课程。
Java World
Java World 是一个纯 Java 学习网站,它里面包括很多 Java 文章,它不同于 DZone 的领域那么多,Java World 只专注于 Java,哦对了,还有安卓,是你提高视野,学习 Java 新特性不可或缺的网站
官网: https://www.javaworld.com/
IBM 开发手册
IBM Developer 的文章都非常权威,页面很干净清爽,技术都非常权威。学习 Java 如果你不知道 IBM 开发首页,那你一定是有遗憾的。
官网: http://www.ibm.com/developerworks/java/
Comparable和Comparator的理解
对Comparable 的解释
Comparable是一个排序接口
此接口给实现类提供了一个排序的方法,此接口有且只有一个方法
public int compareTo(T o);
compareTo方法接受任意类型的参数,来进行比较
list或者数组实现了这个接口能够自动的进行排序,相关类的方法有Collections.sort(),Arrays.sort();
SortedMap 接口的key内置了compareTo方法来进行键排序,SortedSet 也是内置了compareTo方法作为其内部元素的比较手段
compareTo()方法与equals()方法的比较
compareTo()方法不同于equals()方法,它的返回值是一个int类型
int a = 10,b = 20,c = 30,d = 30;
a.compareTo(b) // 返回 -1 说明 a 要比 b 小
c.compareTo(b) // 返回 1 说明 c 要比 b 大
d.compareTo(c) // 返回 0 说明 d 和c 相等
而equals 方法返回的是boolean 类型
x.equals(y) // true 说明x 与 y 的值 相等 , false 说明x 与 y 的值 不相等
代码
Comparable 更像是一个内部排序接口,一个类实现了Comparable比较器,就意味着它本身支持排序;可以用Collections.sort() 或者 Arrays.sort() 进行排序
public class Student implements Comparable<Student>{
String name;
int record;
public Student(){}
public Student(String name,int record){
this.name = name;
this.record = record;
}
public boolean equals(Student student) {
// 拿名字和成绩进行对比
return name.equals(student.name)
&& record == student.record;
}
@Override
public int compareTo(Student stu) {
// 调用String 类的compareTo方法,返回值 -1,0,1
return this.name.compareTo(stu.name);
}
get and set...
}
public class ComparableTest {
public static void main(String[] args) {
List<Student> studentList = Arrays.asList(new Student("liming", 90),
new Student("xiaohong", 95),
new Student("zhoubin", 88),
new Student("xiaoli", 94)
);
// 排序前
System.out.println(studentList);
Collections.sort(studentList);
// 排序后
System.out.println(studentList);
for(Student student : studentList){
System.out.println(student.equals(new Student("xiaohong", 95)));
}
}
}
输出:
[liming = 90, xiaohong = 95, zhoubin = 88, xiaoli = 94] [liming = 90, xiaohong = 95, xiaoli = 94, zhoubin = 88]
false true false false
对 Arrays.asList() 的解释说明 http://xxxx.com
compareTo()方法抛出异常
public int compareTo(T o);
NullPointerException : 如果 对象o为null,抛出空指针异常
ClassCastException: 如果需要类型转换之后进行比较,可能会抛出ClassCastException
对Comparator 的解释
Comparator 相当于一个比较器,作用和Comparable类似,也是使用Collections.sort() 和 Arrays.sort()来进行排序,也可以对SortedMap 和 SortedSet 的数据结构进行精准的控制,你可以不用实现此接口或者Comparable接口就可以实现次序比较。 TreeSet 和 TreeMap的数据结构底层也是使用Comparator 来实现。不同于Comparable ,比较器可以任选地允许比较null参数,同时保持要求等价关系。
Comparator比较器的方法
int compare(T o1, T o2);
compare() 方法的用法和Comparable 的 compareTo() 用法基本一样,这个方法不允许进行null值比较,会抛出空指针异常
boolean equals(Object obj);
jdk1.8 之后又增加了很多新的方法
很多都是关于函数式编程的,在这里先不做讨论了
代码实现
public class AscComparator implements Comparator<Student> {
@Override
public int compare(Student stu1, Student stu2) {
// 根据成绩降序排列
return stu1.getRecord() - stu2.getRecord();
}
}
public class ComparatorTest {
public static void main(String[] args) {
List<Student> studentList = Arrays.asList(new Student("liming", 90),
new Student("xiaohong", 95),
new Student("zhoubin", 88),
new Student("xiaoli", 94)
);
// 1\. 可以实现自己的外部接口进行排序
Collections.sort(studentList,new AscComparator());
System.out.println(studentList);
// 2、 可以匿名内部类实现自定义排序
Collections.sort(studentList, new Comparator<Student>() {
@Override
public int compare(Student stu1, Student stu2) {
return stu2.getRecord() - stu1.getRecord();
}
});
System.out.println(studentList);
}
}
也可以使用Arrays.sort()进行排序,不过针对的数据结构是数组。
Comparable 和 Comparator 的对比
1、Comparable 更像是自然排序
2、Comparator 更像是定制排序
**同时存在时采用 Comparator(定制排序)的规则进行比较。
对于一些普通的数据类型(比如 String, Integer, Double…),它们默认实现了Comparable 接口,实现了 compareTo 方法,我们可以直接使用。
而对于一些自定义类,它们可能在不同情况下需要实现不同的比较策略,我们可以新创建 Comparator 接口,然后使用特定的 Comparator 实现进行比较。
JavaIO体系
Java IO 是一个庞大的知识体系,很多人学着学着就会学懵了,包括我在内也是如此,所以本文将会从 Java 的 BIO 开始,一步一步深入学习,引出 JDK1.4 之后出现的 NIO 技术,对比 NIO 与 BIO 的区别,然后对 NIO 中重要的三个组成部分进行讲解(缓冲区、通道、选择器),最后实现一个简易的客户端与服务器通信功能。
传统的 BIO
Java IO流是一个庞大的生态环境,其内部提供了很多不同的输入流和输出流,细分下去还有字节流和字符流,甚至还有缓冲流提高 IO 性能,转换流将字节流转换为字符流······看到这些就已经对 IO 产生恐惧了,在日常开发中少不了对文件的 IO 操作,虽然 apache 已经提供了 Commons IO
这种封装好的组件,但面对特殊场景时,我们仍需要自己去封装一个高性能的文件 IO 工具类,本文将会解析 Java IO 中涉及到的各个类,以及讲解如何正确、高效地使用它们。
BIO NIO 和 AIO 的区别
我们会以一个经典的烧开水的例子通俗地讲解它们之间的区别
类型 | 烧开水 |
---|---|
BIO | 一直监测着某个水壶,该水壶烧开水后再监测下一个水壶 |
NIO | 每隔一段时间就看看所有水壶的状态,哪个水壶烧开水就去处理哪个水壶 |
AIO | 不用监测水壶,每个水壶烧开水后都会主动通知线程说:“我的水烧开了,来处理我吧” |
**BIO (同步阻塞 I/O)
这里假设一个烧开水的场景,有一排水壶在烧开水,BIO的工作模式就是, 小菠萝一直看着着这个水壶,直到这个水壶烧开,才去处理下一个水壶。线程在等待水壶烧开的时间段什么都没有做。
**NIO(同步非阻塞 I/O)
还拿烧开水来说,NIO的做法是小菠萝一边玩着手机,每隔一段时间就看一看每个水壶的状态,看看是否有水壶的状态发生了改变,如果某个水壶烧开了,可以先处理那个水壶,然后继续玩手机,继续隔一段时间又看看每个水壶的状态。
**AIO (异步非阻塞 I/O)
小菠萝觉得每隔一段时间就去看一看水壶太费劲了,于是购买了一批烧开水时可以哔哔响的水壶,于是开始烧水后,小菠萝就直接去客厅玩手机了,水烧开时,就发出“哔哔”的响声,通知小菠萝来关掉水壶。
什么是流
知识科普:我们知道任何一个文件都是以二进制形式存在于设备中,计算机就只有 0
和 1
,你能看见的东西全部都是由这两个数字组成,你看这篇文章时,这篇文章也是由01组成,只不过这些二进制串经过各种转换演变成一个个文字、一张张图片跃然屏幕上。
而流就是将这些二进制串在各种设备之间进行传输,如果你觉得有些抽象,我举个例子就会好理解一些:
下图是一张图片,它由01串组成,我们可以通过程序把一张图片拷贝到一个文件夹中,
把图片转化成二进制数据集,把数据一点一点地传递到文件夹中 , 类似于水的流动 , 这样整体的数据就是一个数据流
IO 流读写数据的特点:
- 顺序读写。读写数据时,大部分情况下都是按照顺序读写,读取时从文件开头的第一个字节到最后一个字节,写出时也是也如此(RandomAccessFile 可以实现随机读写)
- 字节数组。读写数据时本质上都是对字节数组做读取和写出操作,即使是字符流,也是在字节流基础上转化为一个个字符,所以字节数组是 IO 流读写数据的本质。
流的分类
根据数据流向不同分类:输入流 和 输出流
- 输入流:从磁盘或者其它设备中将数据输入到进程中
- 输出流:将进程中的数据输出到磁盘或其它设备上保存
图示中的硬盘只是其中一种设备,还有非常多的设备都可以应用在IO流中,例如:打印机、硬盘、显示器、手机······
根据处理数据的基本单位不同分类:字节流 和 字符流
- 字节流:以字节(8 bit)为单位做数据的传输
- 字符流:以字符为单位(1字符 = 2字节)做数据的传输
字符流的本质也是通过字节流读取,Java 中的字符采用 Unicode 标准,在读取和输出的过程中,通过以字符为单位,查找对应的码表将字节转换为对应的字符。
面对字节流和字符流,很多读者都有疑惑:**什么时候需要用字节流,什么时候又要用字符流?
我这里做一个简单的概括,你可以按照这个标准去使用:
字符流只针对字符数据进行传输,所以如果是文本数据,优先采用字符流传输;除此之外,其它类型的数据(图片、音频等),最好还是以字节流传输。
根据这两种不同的分类,我们就可以做出下面这个表格,里面包含了 IO 中最核心的 4 个顶层抽象类:
数据流向 / 数据类型 | 字节流 | 字符流 |
---|---|---|
输入流 | InputStream | Reader |
输出流 | OutputStream | Writer |
现在看 IO 是不是有一些思路了,不会觉得很混乱了,我们来看这四个类下的所有成员。
看到这么多的类是不是又开始觉得混乱了,不要慌,字节流和字符流下的输入流和输出流大部分都是一一对应的,有了上面的表格支撑,我们不需要再担心看见某个类会懵逼的情况了。
看到 Stream
就知道是字节流,看到 Reader / Writer
就知道是字符流。
这里还要额外补充一点:Java IO 提供了字节流转换为字符流的转换类,称为转换流。
转换流 / 数据类型 | 字节流与字符流之间的转换 |
---|---|
(输入)字节流 => 字符流 | InputStreamReader |
(输出)字符流 => 字节流 | OutputStreamWriter |
注意字节流与字符流之间的转换是有严格定义的:
- 输入流:可以将字节流 => 字符流
- 输出流:可以将字符流 => 字节流
为什么在输入流不能字符流 => 字节流,输出流不能字节流 => 字符流?
在存储设备上,所有数据都是以字节为单位存储的,所以输入到内存时必定是以字节为单位输入,输出到存储设备时必须是以字节为单位输出,字节流才是计算机最根本的存储方式,而字符流是在字节流的基础上对数据进行转换,输出字符,但每个字符依旧是以字节为单位存储的。
节点流和处理流
在这里需要额外插入一个小节讲解节点流和处理流。
- 节点流:节点流是真正传输数据的流对象,用于向特定的一个地方(节点)读写数据,称为节点流。例如 FileInputStream
- 处理流:处理流是对节点流的封装,使用外层的处理流读写数据,本质上是利用节点流的功能,外层的处理流可以提供额外的功能。处理流的基类都是以
Filter
开头。
上图将 ByteArrayInputStream
封装成 DataInputStream
,可以将输入的字节数组转换为对应数据类型的数据。例如希望读入int
类型数据,就会以2
个字节为单位转换为一个数字。
Java IO 的核心类 File
Java 提供了 File类,它指向计算机操作系统中的文件和目录,通过该类只能访问文件和目录,无法访问内容。 它内部主要提供了 3
种操作:
- 访问文件的属性:绝对路径、相对路径、文件名······
- 文件检测:是否文件、是否目录、文件是否存在、文件的读/写/执行权限······
- 操作文件:创建目录、创建文件、删除文件······
上面举例的操作都是在开发中非常常用的,File 类远不止这些操作,更多的操作可以直接去 API 文档中根据需求查找。
访问文件的属性:
API | 功能 |
---|---|
String getAbsolutePath() | 返回该文件处于系统中的绝对路径名 |
String getPath() | 返回该文件的相对路径,通常与 new File() 传入的路径相同 |
String getName() | 返回该文件的文件名 |
文件检测:
API | 功能 |
---|---|
boolean isFIle() | 校验该路径指向是否一个文件 |
boolean isDirectory() | 校验该路径指向是否一个目录 |
boolean isExist() | 校验该路径指向的文件/目录是否存在 |
boolean canWrite() | 校验该文件是否可写 |
boolean canRead() | 校验该文件是否可读 |
boolean canExecute() | 校验该文件/目录是否可以被执行 |
操作文件:
API | 功能 |
---|---|
mkdirs() | 递归创建多个文件夹,路径中间有可能某些文件夹不存在 |
createNewFile() | 创建新文件,它是一个原子操作,有两步:检查文件是否存在、创建新文件 |
delete() | 删除文件或目录,删除目录时必须保证该目录为空 |
**多了解一些
文件的读/写/执行权限,在 Windows
中通常表现不出来,而在 Linux
中可以很好地体现这一点,原因是 Linux
有严格的用户权限分组,不同分组下的用户对文件有不同的操作权限,所以这些方法在 Linux
下会比在 Windows
下更好理解。下图是 redis 文件夹中的一些文件的详细信息,被红框标注的是不同用户的执行权限:
- r(Read):代表该文件可以被当前用户读,操作权限的序号是
4
- w(Write):代表该文件可以被当前用户写,操作权限的序号是
2
- x(Execute):该文件可以被当前用户执行,操作权限的序号是
1
root root
分别代表:当前文件的所有者,当前文件所属的用户分组。Linux 下文件的操作权限分为三种用户:
- 文件所有者:拥有的权限是红框中的前三个字母,
-
代表没有某个权限 - 文件所在组的所有用户:拥有的权限是红框中的**中间三个字母
- 其它组的所有用户:拥有的权限是红框中的**最后三个字母
Java IO 流对象
回顾流的分类有2种:
- 根据数据流向分为输入流和输出流
- 根据数据类型分为字节流和字符流
所以,本小节将以字节流和字符流作为主要分割点,在其内部再细分为输入流和输出流进行讲解。
字节流对象
字节流对象大部分输入流和输出流都是成双成对地出现,所以学习的时候可以将输入流和输出流一一对应的流对象关联起来,输入流和输出流只是数据流向不同,而处理数据的方式可以是相同的。
注意不要认为用什么流读入数据,就需要用对应的流写出数据,在 Java 中没有这么规定,下图只是各个对象之间的一个对应关系,不是两个类使用时必须强制关联使用。
下面有非常多的类,我会介绍基类的方法,了解这些方法是非常有必要的,子类的功能基于父类去扩展,只有真正了解父类在做什么,学习子类的成本就会下降。
InputStream
InputStream 是字节输入流的抽象基类,提供了通用的读方法,让子类使用或重写它们。下面是 InputStream 常用的重要的方法。
重要方法 | 功能 |
---|---|
public abstract int read() | 从输入流中读取下一个字节,读到尾部时返回 -1 |
public int read(byte b[]) | 从输入流中读取长度为 b.length 个字节放入字节数组 b 中 |
public int read(byte b[], int off, int len) | 从输入流中读取指定范围的字节数据放入字节数组 b 中 |
public void close() | 关闭此输入流并释放与该输入流相关的所有资源 |
还有其它一些不太常用的方法,我也列出来了。
其它方法 | 功能 |
---|---|
public long skip(long n) | 跳过接下来的 n 个字节,返回实际上跳过的字节数 |
public long available() | 返回下一次可读取(跳过)且不会被方法阻塞的字节数的估计值 |
public synchronized void mark(int readlimit) | 标记此输入流的当前位置,对 reset() 方法的后续调用将会重新定位在 mark() 标记的位置,可以重新读取相同的字节 |
public boolean markSupported() | 判断该输入流是否支持 mark() 和 reset() 方法,即能否重复读取字节 |
public synchronized void reset() | 将流的位置重新定位在最后一次调用 mark() 方法时的位置 |
**(1)ByteArrayInputStream
ByteArrayInputStream 内部包含一个 buf
字节数组缓冲区,该缓冲区可以从流中读取的字节数,使用 pos
指针指向读取下一个字节的下标位置,内部还维护了一个count
属性,代表能够读取 count
个字节。
必须保证 pos 严格小于 count,而 count 严格小于 buf.length 时,才能够从缓冲区中读取数据
**(2)FileInputStream
文件输入流,从文件中读入字节,通常对文件的拷贝、移动等操作,可以使用该输入流把文件的字节读入内存中,然后再利用输出流输出到指定的位置上。
**(3)PipedInputStream
管道输入流,它与 PipedOutputStream 成对出现,可以实现多线程中的管道通信。PipedOutputStream 中指定与特定的 PipedInputStream 连接,PipedInputStream 也需要指定特定的 PipedOutputStream 连接,之后输出流不断地往输入流的 buffer
缓冲区写数据,而输入流可以从缓冲区中读取数据。
**(4)ObjectInputStream
对象输入流,用于对象的反序列化,将读入的字节数据反序列化为一个对象,实现对象的持久化存储。
**(5)PushBackInputStream
它是 FilterInputStream 的子类,是一个处理流,它内部维护了一个缓冲数组buf
。
- 在读入字节的过程中可以将读取到的字节数据回退给缓冲区中保存,下次可以再次从缓冲区中读出该字节数据。所以PushBackInputStream 允许多次读取输入流的字节数据,只要将读到的字节放回缓冲区即可。
需要注意的是如果回推字节时,如果缓冲区已满,会抛出 IOException
异常。
它的应用场景:对数据进行分类规整。
假如一个文件中存储了数字和字母两种类型的数据,我们需要将它们交给两种线程各自去收集自己负责的数据,如果采用传统的做法,把所有的数据全部读入内存中,再将数据进行分离,面对大文件的情况下,例如1G、2G,传统的输入流在读入数组后,由于没有缓冲区,只能对数据进行抛弃,这样每个线程都要读一遍文件。
使用 PushBackInputStream 可以让一个专门的线程读取文件,唤醒不同的线程读取字符:
- 第一次读取缓冲区的数据,判断该数据由哪些线程读取
- 回退数据,唤醒对应的线程读取数据
- 重复前两步
- 关闭输入流
到这里,你是否会想到 AQS
的 Condition
等待队列,多个线程可以在不同的条件上等待被唤醒。
**(6)BufferedInputStream
缓冲流,它是一种处理流,对节点流进行封装并增强,其内部拥有一个 buffer
缓冲区,用于缓存所有读入的字节,当缓冲区满时,才会将所有字节发送给客户端读取,而不是每次都只发送一部分数据,提高了效率。
**(7)DataInputStream
数据输入流,它同样是一种处理流,对节点流进行封装后,能够在内部对读入的字节转换为对应的 Java 基本数据类型。
**(8)SequenceInputStream
将两个或多个输入流看作是一个输入流依次读取,该类的存在与否并不影响整个 IO 生态,在程序中也能够做到这种效果
**~~(9)StringBufferInputStream~~
将字符串中每个字符的低 8 位转换为字节读入到字节数组中,目前已过期
**InputStream 总结:
- InputStream 是所有输入字节流的**抽象基类
- ByteArrayInputStream 和 FileInputStream 是两种基本的节点流,他们分别从字节数组 和 本地文件中读取数据
- DataInputStream、BufferedInputStream 和 PushBackInputStream 都是处理流,对基本的节点流进行封装并增强
- PipiedInputStream 用于多线程通信,可以与其它线程公用一个管道,读取管道中的数据。
- ObjectInputStream 用于对象的反序列化,将对象的字节数据读入内存中,通过该流对象可以将字节数据转换成对应的对象
OutputStream
OutputStream 是字节输出流的抽象基类,提供了通用的写方法,让继承的子类重写和复用。
方法 | 功能 |
---|---|
public abstract void write(int b) | 将指定的字节写出到输出流,写入的字节是参数 b 的低 8 位 |
public void write(byte b[]) | 将指定字节数组中的所有字节写入到输出流当中 |
public void write(byte b[], int off, int len) | 指定写入的起始位置 offer,字节数为 len 的字节数组写入到输出流当中 |
public void flush() | 刷新此输出流,并强制写出所有缓冲的输出字节到指定位置,每次写完都要调用 |
public void close() | 关闭此输出流并释放与此流关联的所有系统资源 |
OutputStream 中大多数的类和 InputStream 是对应的,只不过数据的流向不同而已。从上面的图可以看出:
-
OutputStream 是所有输出字节流的**抽象基类
-
ByteArrayOutputStream 和 FileOutputStream 是两种基本的节点流,它们分别向字节数组和本地文件写出数据
-
DataOutputStream、BufferedOutputStream 是处理流,前者可以将字节数据转换成基本数据类型写出到文件中;后者是缓冲字节数组,只有在缓冲区满时,才会将所有的字节写出到目的地,减少了 IO 次数。
-
PipedOutputStream 用于多线程通信,可以和其它线程共用一个管道,向管道中写入数据
-
ObjectOutputStream 用于对象的序列化,将对象转换成字节数组后,将所有的字节都写入到指定位置中
-
PrintStream 在 OutputStream 基础之上提供了增强的功能,即可以方便地输出各种类型的数据(而不仅限于byte型)的格式化表示形式,且 PrintStream 的方法从不抛出 IOEception,其原理是写出时将各个数据类型的数据统一转换为 String 类型,我会在讲解完
字符流对象
字符流对象也会有对应关系,大多数的类可以认为是操作的数据从字节数组变为字符,类的功能和字节流对象是相似的。
字符输入流和字节输入流的组成非常相似,字符输入流是对字节输入流的一层转换,所有文件的存储都是字节的存储,在磁盘上保留的不是文件的字符,而是先把字符编码成字节,再保存到文件中。在读取文件时,读入的也是一个一个字节组成的字节序列,而 Java 虚拟机通过将字节序列,按照2个字节为单位转换为 Unicode 字符,实现字节到字符的映射。
Reader
Reader 是字符输入流的抽象基类,它内部的重要方法如下所示。
重要方法 | 方法功能 |
---|---|
public int read(java.nio.CharBuffer target) | 将读入的字符存入指定的字符缓冲区中 |
public int read() | 读取一个字符 |
public int read(char cbuf[]) | 读入字符放入整个字符数组中 |
abstract public int read(char cbuf[], int off, int len) | 将字符读入字符数组中的指定范围中 |
还有其它一些额外的方法,与字节输入流基类提供的方法是相同的,只是作用的对象不再是字节,而是字符。
- Reader 是所有字符输入流的**抽象基类
- CharArrayReader 和 StringReader 是两种基本的节点流,它们分别从读取 字符数组 和 字符串 数据,StringReader 内部是一个
String
变量值,通过遍历该变量的字符,实现读取字符串,**本质上也是在读取字符数组 - PipedReader 用于多线程中的通信,从共用地管道中读取字符数据
- BufferedReader 是字符输入缓冲流,将读入的数据放入字符缓冲区中,**实现高效地读取字符
- InputStreamReader 是一种转换流,可以实现从字节流转换为字符流,将字节数据转换为字符
Writer
Reader 是字符输出流的抽象基类,它内部的重要方法如下所示。
重要方法 | 方法功能 |
---|---|
public void write(char cbuf[]) | 将 cbuf 字符数组写出到输出流 |
abstract public void write(char cbuf[], int off, int len) | 将指定范围的 cbuf 字符数组写出到输出流 |
public void write(String str) | 将字符串 str 写出到输出流,str 内部也是字符数组 |
public void write(String str, int off, int len) | 将字符串 str 的某一部分写出到输出流 |
abstract public void flush() | 刷新,如果数据保存在缓冲区,调用该方法才会真正写出到指定位置 |
abstract public void close() | 关闭流对象,每次 IO 执行完毕后都需要关闭流对象,释放系统资源 |
-
Writer 是所有的输出字符流的抽象基类
-
CharArrayWriter、StringWriter 是两种基本的节点流,它们分别向Char 数组、字符串中写入数据。StringWriter 内部保存了 StringBuffer 对象,可以实现字符串的动态增长
-
PipedWriter 可以向共用的管道中写入字符数据,给其它线程读取。
-
BufferedWriter 是缓冲输出流,可以将写出的数据缓存起来,缓冲区满时再调用 flush() 写出数据,减少 IO 次数。
-
PrintWriter 和 PrintStream 类似,功能和使用也非常相似,只是写出的数据是字符而不是字节。
-
OutputStreamWriter 将字符流转换为字节流,将字符写出到指定位置
字节流与字符流的转换
从任何地方把数据读入到内存都是先以字节流形式读取,即使是使用字符流去读取数据,依然成立,因为数据永远是以字节的形式存在于互联网和硬件设备中,字符流是通过字符集的映射,才能够将字节转换为字符。
所以 Java 提供了两种转换流:
- InputStreamReader:从字节流转换为字符流,将字节数据转换为字符数据读入到内存
- OutputStreamWriter:从字符流转换为字节流,将字符数据转换为字节数据写出到指定位置
了解了 Java 传统的 BIO 中字符流和字节流的主要成员之后,至少要掌握以下两个关键点:
(1)传统的 BIO 是以
流
为基本单位处理数据的,想象成水流,一点点地传输字节数据,IO 流传输的过程永远是以字节
形式传输。(2)字节流和字符流的区别在于操作的数据单位不相同,字符流是通过将字节数据通过字符集映射成对应的字符,字符流本质上也是字节流。
接下来我们再继续学习 NIO 知识,NIO 是当下非常火热的一种 IO 工作方式,它能够解决传统 BIO 的痛点:阻塞。
-
BIO 如果遇到 IO 阻塞时,线程将会被挂起,直到 IO 完成后才唤醒线程,线程切换带来了额外的开销。
-
BIO 中每个 IO 都需要有对应的一个线程去专门处理该次 IO 请求,会让服务器的压力迅速提高。
我们希望做到的是当线程等待 IO 完成时能够去完成其它事情,当 IO 完成时线程可以回来继续处理 IO 相关操作,不必干干的坐等 IO 完成。在 IO 处理的过程中,能够有一个专门的线程负责监听这些 IO 操作,通知服务器该如何操作。所以,我们聊到 IO,不得不去接触 NIO 这一块硬骨头。
新潮的 NIO
我们来看看 BIO 和 NIO 的区别,BIO 是面向流的 IO,它建立的通道都是单向的,所以输入和输出流的通道不相同,必须建立2个通道,通道内的都是传输==0101001···==的字节数据。
而在 NIO 中,不再是面向流的 IO 了,而是面向缓冲区,它会建立一个通道(Channel),该通道我们可以理解为铁路,该铁路上可以运输各种货物,而通道上会有一个缓冲区(Buffer)用于存储真正的数据,缓冲区我们可以理解为一辆火车。
通道(铁路)只是作为运输数据的一个连接资源,而真正存储数据的是缓冲区(火车)。即**通道负责传输,缓冲区负责存储。
理解了上面的图之后,BIO 和 NIO 的主要区别就可以用下面这个表格简单概括。
BIO | NIO |
---|---|
面向流(Stream) | 面向缓冲区(Buffer) |
单向通道 | 双向通道 |
阻塞 IO | 非阻塞 IO |
选择器(Selectors) |
缓冲区(Buffer)
缓冲区是存储数据的区域,在 Java 中,缓冲区就是数组,为了可以操作不同数据类型的数据,Java 提供了许多不同类型的缓冲区,除了布尔类型以外,其它基本数据类型都有对应的缓冲区数组对象。
为什么没有布尔类型的缓冲区呢?
在 Java 中,boolean 类型数据只占用
1 bit
,而在 IO 传输过程中,都是以字节为单位进行传输的,所以 boolean 的 1 bit 完全可以使用 byte 类型的某一位,或者 int 类型的某一位来表示,没有必要为了这 1 bit 而专门提供多一个缓冲区。
缓冲区 | 解释 |
---|---|
ByteBuffer | 存储字节数据的缓冲区 |
CharBuffer | 存储字符数据的缓冲区 |
ShortBuffer | 存储短整型数据的缓冲区 |
IntBuffer | 存储整型数据的缓冲区 |
LongBuffer | 存储长整型数据的缓冲区 |
FloatBuffer | 存储单精度浮点型数据的缓冲区 |
DoubleBuffer | 存储双精度浮点型数据的缓冲区 |
分配一个缓冲区的方式都高度一致:使用allocate(int capacity)
方法。
例如需要分配一个 1024 大小的字节数组,代码就是下面这样子。
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
缓冲区读写数据的两个核心方法:
- put():将数据写入到缓冲区中
- get():从缓冲区中读取数据
缓冲区的重要属性:
-
capacity:缓冲区中最大存储数据的容量,一旦声明则无法改变
-
limit:表示缓冲区中可以操作数据的大小,limit 之后的数据无法进行读写。必须满足 limit <= capacity
-
position:当前缓冲区中正在操作数据的下标位置,必须满足 position <= limit
-
mark:标记位置,调用 reset() 将 position 位置调整到 mark 属性指向的下标位置,**实现多次读取数据
缓冲区为高效读写数据而提供的其它辅助方法:
- flip():可以实现读写模式的切换,我们可以看看里面的源码
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
调用 flip() 会将可操作的大小 limit 设置为当前写的位置,操作数据的起始位置 position 设置为 0,即从头开始读取数据。
- rewind():可以将 position 位置设置为 0,再次读取缓冲区中的数据
- clear():清空整个缓冲区,它会将 position 设置为 0,limit 设置为 capacity,可以**写整个缓冲区
更多的方法可以去查阅 API 文档,本文碍于篇幅原因就不贴出其它方法了,主要是要**理解缓冲区的作用
我们来看一个简单的例子
public Class Main {
public static void main(String[] args) {
// 分配内存大小为11的整型缓存区
IntBuffer buffer = IntBuffer.allocate(11);
// 往buffer里写入2个整型数据
for (int i = 0; i < 2; ++i) {
int randomNum = new SecureRandom().nextInt();
buffer.put(randomNum);
}
// 将Buffer从写模式切换到读模式
buffer.flip();
System.out.println("position >> " + buffer.position()
+ "limit >> " + buffer.limit()
+ "capacity >> " + buffer.capacity());
// 读取buffer里的数据
while (buffer.hasRemaining()) {
System.out.println(buffer.get());
}
System.out.println("position >> " + buffer.position()
+ "limit >> " + buffer.limit()
+ "capacity >> " + buffer.capacity());
}
}
执行结果如下图所示,首先我们往缓冲区中写入 2 个数据,position 在写模式下指向下标 2,然后调用 flip() 方法切换为读模式,limit 指向下标 2,position 从 0 开始读数据,读到下标为 2 时发现到达 limit 位置,不可继续读。
整个过程可以用下图来理解,调用 flip() 方法以后,读出数据的同时 position 指针不断往后挪动,到达 limit 指针的位置时,该次读取操作结束。
介绍完缓冲区后,我们知道它是存储数据的空间,进程可以将缓冲区中的数据读取出来,也可以写入新的数据到缓冲区,那缓冲区的数据从哪里来,又怎么写出去呢?接下来我们需要学习传输数据的介质:通道(Channel)
通道(Channel)
上面我们介绍过,通道是作为一种连接资源,作用是传输数据,而真正存储数据的是缓冲区,所以介绍完缓冲区后,我们来学习通道这一块。
通道是可以双向读写的,传统的 BIO 需要使用输入/输出流表示数据的流向,在 NIO 中可以减少通道资源的消耗。
通道类都保存在 java.nio.channels
包下,我们日常用到的几个重要的类有 4 个:
IO 通道类型 | 具体类 |
---|---|
文件 IO | FileChannel(用于文件读写、操作文件的通道) |
TCP 网络 IO | SocketChannel(用于读写数据的 TCP 通道)、ServerSocketChannel(监听客户端的连接) |
UDP 网络 IO | DatagramChannel(收发 UDP 数据报的通道) |
可以通过 getChannel()
方法获取一个通道,支持获取通道的类如下:
- 文件 IO:FileInputStream、FileOutputStream、RandomAccessFile
- TCP 网络 IO:Socket、ServerSocket
- UDP 网络 IO:DatagramSocket
示例:文件拷贝案例
我们来看一个利用通道拷贝文件的例子,需要下面几个步骤:
- 打开原文件的输入流通道,将字节数据读入到缓冲区中
- 打开目的文件的输出流通道,将缓冲区中的数据写到目的地
- 关闭所有流和通道(重要!)
这是一张小菠萝的照片,它存在于d:\小菠萝\
文件夹下,我们将它拷贝到 d:\小菠萝分身\
文件夹下。
public class Test {
/** 缓冲区的大小 */
public static final int SIZE = 1024;
public static void main(String[] args) throws IOException {
// 打开文件输入流
FileChannel inChannel = new FileInputStream("d:\小菠萝\小菠萝.jpg").getChannel();
// 打开文件输出流
FileChannel outChannel = new FileOutputStream("d:\小菠萝分身\小菠萝-拷贝.jpg").getChannel();
// 分配 1024 个字节大小的缓冲区
ByteBuffer dsts = ByteBuffer.allocate(SIZE);
// 将数据从通道读入缓冲区
while (inChannel.read(dsts) != -1) {
// 切换缓冲区的读写模式
dsts.flip();
// 将缓冲区的数据通过通道写到目的地
outChannel.write(dsts);
// 清空缓冲区,准备下一次读
dsts.clear();
}
inChannel.close();
outChannel.close();
}
}
我画了一张图帮助你理解上面的这一个过程。
有人会问,NIO 的文件拷贝和传统 IO 流的文件拷贝有何不同呢?我们在编程时感觉它们没有什么区别呀,貌似只是 API 不同罢了,我们接下来就去看看这两者之间的区别吧。
BIO 和 NIO 拷贝文件的区别
这个时候就要来了解了解操作系统底层是怎么对 IO 和 NIO 进行区别的,我会用尽量通俗的文字带你理解,可能并不是那么严谨。
操作系统最重要的就是内核,它既可以访问受保护的内存,也可以访问底层硬件设备,所以为了保护内核的安全,操作系统将底层的虚拟空间分为了用户空间和内核空间,其中用户空间就是给用户进程使用的,内核空间就是专门给操作系统底层去使用的。
接下来,有一个 Java 进程希望把小菠萝这张图片从磁盘上拷贝,那么内核空间和用户空间都会有一个**缓冲区
- 这张照片就会从磁盘中读出到内核缓冲区中保存,然后操作系统将内核缓冲区中的这张图片字节数据拷贝到用户进程的缓冲区中保存下来,对应着下面这幅图
- 然后用户进程会希望把缓冲区中的字节数据写到磁盘上的另外一个地方,会将数据拷贝到 Socket 缓冲区中,最终操作系统再将 Socket 缓冲区的数据写到磁盘的指定位置上。
这一轮操作下来,我们数数经过了几次数据的拷贝?4
次。有 2 次是内核空间和用户空间之间的数据拷贝,这两次拷贝涉及到用户态和内核态的切换,需要CPU参与进来,进行上下文切换。而另外 2 次是硬盘和内核空间之间的数据拷贝,这个过程利用到 DMA与系统内存交换数据,不需要 CPU 的参与。
导致 IO 性能瓶颈的原因:**内核空间与用户空间之间数据过多无意义的拷贝,以及多次上下文切换
操作 | 状态 |
---|---|
用户进程请求读取数据 | 用户态 -> 内核态 |
操作系统内核返回数据给用户进程 | 内核态 -> 用户态 |
用户进程请求写数据到硬盘 | 用户态 -> 内核态 |
操作系统返回操作结果给用户进程 | 内核态 -> 用户态 |
在用户空间与内核空间之间的操作,会涉及到上下文的切换,这里需要 CPU 的干预,而数据在两个空间之间来回拷贝,也需要 CPU 的干预,这无疑会增大 CPU 的压力,NIO 是如何减轻 CPU 的压力?运用操作系统的零拷贝技术。
操作系统的零拷贝
所以,操作系统出现了一个全新的概念,解决了 IO 瓶颈:零拷贝。零拷贝指的是内核空间与用户空间之间的零次拷贝。
零拷贝可以说是 IO 的一大救星,操作系统底层有许多种零拷贝机制,我这里仅针对 Java NIO 中使用到的其中一种零拷贝机制展开讲解。
在 Java NIO 中,零拷贝是通过用户空间和内核空间的缓冲区共享一块物理内存实现的,也就是说上面的图可以演变成这个样子。
这时,无论是用户空间还是内核空间操作自己的缓冲区,本质上都是操作这一块共享内存中的缓冲区数据,省去了用户空间和内核空间之间的数据拷贝操作。
现在我们重新来拷贝文件,就会变成下面这个步骤:
- 用户进程通过系统调用
read()
请求读取文件到用户空间缓冲区(第一次上下文切换),用户态 -> 核心态,数据从硬盘读取到内核空间缓冲区中(第一次数据拷贝) - 系统调用返回到用户进程(第二次上下文切换),此时用户空间与内核空间共享这一块内存(缓冲区),所以**不需要从内核缓冲区拷贝到用户缓冲区
- 用户进程发出
write()
系统调用请求写数据到硬盘上(第三次上下文切换),此时需要将内核空间缓冲区中的数据拷贝到内核的 Socket 缓冲区中(第二次数据拷贝) - 由 DMA 将 Socket 缓冲区的内容写到硬盘上(第三次数据拷贝),
write()
系统调用返回(第四次上下文切换)
整个过程就如下面这幅图所示。
图中,需要 CPU 参与工作的步骤只有第③个步骤,对比于传统的 IO,CPU 需要在用户空间与内核空间之间参与拷贝工作,需要无意义地占用 2 次 CPU 资源,导致 CPU 资源的浪费。
下面总结一下操作系统中零拷贝的优点:
- 降低 CPU 的压力:避免 CPU 需要参与内核空间与用户空间之间的数据拷贝工作
- 减少不必要的拷贝:避免用户空间与内核空间之间需要进行数据拷贝
上面的图示可能并不严谨,对于你理解零拷贝会有一定的帮助,关于零拷贝的知识点可以去查阅更多资料哦,这是一门大学问。
介绍完通道后,我们知道它是用于传输数据的一种介质,而且是可以双向读写的,那么如果放在网络 IO 中,这些通道如果有数据就绪时,服务器是如何发现并处理的呢?接下来我们去学习 NIO 中的最后一个重要知识点:选择器(Selector)
选择器(Selectors)
选择器是提升 IO 性能的灵魂之一,它底层利用了多路复用 IO机制,让选择器可以监听多个 IO 连接,根据 IO 的状态响应到服务器端进行处理。通俗地说:**选择器可以监听多个 IO 连接,而传统的 BIO 每个 IO 连接都需要有一个线程去监听和处理。
图中很明显的显示了在 BIO 中,每个 Socket 都需要有一个专门的线程去处理每个请求,而在 NIO 中,只需要一个 Selector 即可监听各个 Socket 请求,而且 Selector 并不是阻塞的,所以不会因为多个线程之间切换导致上下文切换带来的开销。
在 Java NIO 中,选择器是使用 Selector
类表示,Selector 可以接收各种 IO 连接,在 IO 状态准备就绪时,会通知该通道注册的 Selector,Selector 在下一次轮询时会发现该 IO 连接就绪,进而处理该连接。
Selector 选择器主要用于网络 IO当中,在这里我会将传统的 BIO Socket 编程和使用 NIO 后的 Socket 编程作对比,分析 NIO 为何更受欢迎。首先先来了解 Selector 的基本结构。
重要方法 | 方法解析 |
---|---|
open() | 打开一个 Selector 选择器 |
int select() | 阻塞地等待就绪的通道 |
int select(long timeout) | 最多阻塞 timeout 毫秒,如果是 0 则一直阻塞等待,如果是 1 则代表最多阻塞 1 毫秒 |
int selectNow() | 非阻塞地轮询就绪的通道 |
在这里,你会看到 select() 和它的重载方法是会阻塞的,如果用户进程轮询时发现没有就绪的通道,操作系统有两种做法:
- 一直等待直到一个就绪的通道,再返回给用户进程
- 立即返回一个错误状态码给用户进程,让用户进程继续运行,不会阻塞
这两种方法对应了同步阻塞 IO 和 同步非阻塞 IO ,这里读者的一点小的观点,请各位大神**批判阅读
Java 中的 NIO 不能真正意义上称为 Non-Blocking IO,我们通过 API 的调用可以发现,select() 方法还是会存在阻塞的现象,根据传入的参数不同,操作系统的行为也会有所不同,不同之处就是阻塞还是非阻塞,所以我更倾向于把 NIO 称为 New IO,因为它不仅提供了 Non-Blocking IO,而且保留原有的 Blocking IO 的功能。
了解了选择器之后,它的作用就是:监听多个 IO 通道,当有通道就绪时选择器会轮询发现该通道,并做相应的处理。那么 IO 状态分为很多种,我们如何去识别就绪的通道是处于哪种状态呢?在 Java 中提供了选择键(SelectionKey)。
选择键(SelectionKey)
在 Java 中提供了 4 种选择键:
- SelectionKey.OP_READ:套接字通道准备好进行**读操作
- SelectionKey.OP_WRITE:套接字通道准备好进行**写操作
- SelectionKey.OP_ACCEPT:服务器套接字通道**接受其它通道
- SelectionKey.OP_CONNECT:套接字通道准备**完成连接
在 SelectionKey 中包含了许多属性
- channel:该选择键**绑定的通道
- selector:轮询到该选择键的**选择器
- readyOps:当前**就绪选择键的值
- interesOps:该选择器对该通道**感兴趣的所有选择键
选择键的作用是:**在选择器轮询到有就绪通道时,会返回这些通道的就绪选择键(SelectionKey),通过选择键可以获取到通道进行操作。
简单了解了选择器后,我们可以结合缓冲区、通道和选择器来完成一个简易的聊天室应用。
示例:简易的客户端服务器通信
先说明,这里的代码非常的臭和长,不推荐细看,直接看注释附近的代码即可。
我们在服务器端会开辟两个线程
- Thread1:专门监听客户端的连接,并把通道注册到客户端选择器上
- Thread2:专门监听客户端的其它 IO 状态(读状态),当客户端的 IO 状态就绪时,该选择器会轮询发现,并作相应处理
public class NIOServer {
Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();
public static void main(String[] args) throws IOException {
NIOServer server = nwe NIOServer();
new Thread(() -> {
try {
// 对应IO编程中服务端启动
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
listenerChannel.socket().bind(new InetSocketAddress(3333));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
server.acceptListener();
} catch (IOException ignored) {
}
}).start();
new Thread(() -> {
try {
server.clientListener();
} catch (IOException ignored) {
}
}).start();
}
}
// 监听客户端连接
public void acceptListener() {
while (true) {
if (serverSelector.select(1) > 0) {
Set<SelectionKey> set = serverSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// (1) 每来一个新连接,注册到clientSelector
SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
// 从就绪的列表中移除这个key
keyIterator.remove();
}
}
}
}
}
}
// 监听客户端的 IO 状态就绪
public void clientListener() {
while (true) {
// 批量轮询是否有哪些连接有数据可读
if (clientSelector.select(1) > 0) {
Set<SelectionKey> set = clientSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 判断该通道是否读就绪状态
if (key.isReadable()) {
try {
// 获取客户端通道读入数据
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(
LocalDateTime.now().toString() + " Server 端接收到来自 Client 端的消息: " +
Charset.defaultCharset().decode(byteBuffer).toString());
} finally {
// 从就绪的列表中移除这个key
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
}
在客户端,我们可以简单的输入一些文字,发送给服务器
public class NIOClient {
public static final int CAPACITY = 1024;
public static void main(String[] args) throws Exception {
ByteBuffer dsts = ByteBuffer.allocate(CAPACITY);
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 3333));
socketChannel.configureBlocking(false);
Scanner sc = new Scanner(System.in);
while (true) {
String msg = sc.next();
dsts.put(msg.getBytes());
dsts.flip();
socketChannel.write(dsts);
dsts.clear();
}
}
}
下图可以看见,在客户端给服务器端发送信息,服务器接收到消息后,可以将该条消息分发给其它客户端,就可以实现一个简单的群聊系统,我们还可以给这些客户端贴上标签例如用户姓名,聊天等级······,就可以标识每个客户端啦。在这里由于篇幅原因,我没有写出所有功能,因为使用原生的 NIO 实在是不太便捷。
我相信你们都是直接滑下来看这里的,我在写这段代码的时候也非常痛苦,甚至有点厌烦 Java 原生的 NIO 编程。实际上我们在日常开发中很少直接用 NIO 进行编程,通常都会用 Netty,Mina 这种服务器框架,它们都是很好地 NIO 技术,对 Java 原生的 NIO 进行了上层的封装、优化,简化开发难度,但是**在学习框架之前,我们需要了解它底层原生的技术,就像 Spring AOP 的动态代理,Spring IOC 容器的 Map 容器存储对象,Netty 底层的 NIO 基础······
总结
NIO 的三大板块基本上都介绍完了,我没有做过多详细的 API 介绍,我希望能够通过这篇文章让你们对以下内容有所认知
- Java IO 体系的组成部分:BIO 和 NIO
- BIO 的基本组成部分:字节流,字符流,转换流和处理流
- NIO 的三大重要模块:缓冲区(Buffer),通道(Channel),选择器(Selector)以及它们的作用
- NIO 与 BIO 两者的对比:同步/非同步、阻塞/非阻塞,在文件 IO 和 网络 IO 中,使用 NIO 相对于使用 BIO 有什么优势
for、foreach、iterator三种遍历方式的比较
习惯用法
for、foreach循环、iterator迭代器都是我们常用的一种遍历方式,你可以用它来遍历任何东西:包括数组、集合等
for 惯用法:
List<String> list = new ArrayList<String>();
String[] arr = new String[]{"1,2,3,4"};
for(int i = 0;i < arr.length;i++){
System.out.println(arr[i]);
}
for(int i = 0;i < list.size();i++){
System.out.println(list.get(i));
}
foreach 惯用法:
String[] arr = new String[]{"1,2,3,4"};
List<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
for(String str : arr){
System.out.println(str);
}
for (String item : list) {
System.out.println(item);
}
Iterator 惯用法:
Iterator<String> it = list.iterator();
while (it.hasNext()){
System.out.println(it.next());
}
速度对比
性能是我们选取某一种技术手段的一种考虑方式,且看这三种遍历方式的速度对比
List<Long> list = new ArrayList<Long>();
long maxLoop = 2000000;
for(long i = 0;i < maxLoop;i++){
list.add(i);
}
// for循环
long startTime = System.currentTimeMillis();
for(int i = 0;i < list.size();i++){
;
}
long endTime = System.currentTimeMillis();
System.out.println(endTime - startTime + "ms");
// foreach 循环
startTime = System.currentTimeMillis();
for(Long lon : list){
;
}
endTime = System.currentTimeMillis();
System.out.println(endTime - startTime + "ms");
// iterator 循环
startTime = System.currentTimeMillis();
Iterator<Long> iterator = list.iterator();
while (iterator.hasNext()) {
iterator.next();
}
endTime = System.currentTimeMillis();
System.out.println(endTime - startTime + "ms");
4ms 16ms 9ms
由以上得知,for()循环是最快的遍历方式,随后是iterator()迭代器,最后是foreach循环
remove操作三种遍历方式的影响
for循环的remove
List<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
for(int i = 0;i < list.size();i++){
if("2".equals(list.get(i))){
System.out.println(list.get(i));
list.remove(list.get(i));
}
}
for循环可以直接进行remove,不会受到任何影响。
foreach 中的remove
List<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
for (String item : list) {
if ("2".equals(item)) {
System.out.println(item);
list.remove(item);
}
}
你觉得这段代码的正确输出是什么?我们一起来探究一下
当我执行一下这段代码的时候,出现了以下的情况
由以上异常情况的堆栈信息得知,程序出现了并发修改的异常,为什么会这样?我们从错误开始入手,
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
也就是这行代码,找到这行代码的所在地
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
你会好奇, modCount 和 expectedModCount 是什么变量?在我对 ArrayList 相关用法那篇文章中有比较详细的解释。我大致说明一下: modCount 相当于是程序所能够进行修改 ArrayList 结构化的一个变量,怎么理解?看几个代码片段
你能够从中获取什么共性的特征呢?没错,也就是涉及到其中关于ArrayList的 容量大小 和 元素个数的时候,就会触发modCount 的值的变化
expectedModCount这个变量又是怎么回事?从ArrayList 源码可知,这个变量是一个局部变量,也就是说每个方法内部都有expectedModCount 和 modCount 的判断机制,进一步来讲,这个变量就是 预期的修改次数,
先抛开这个不谈,我们先来谈论一下foreach(增强for循环)本身。
增强for循环是Java给我们提供的一个语法糖,如果将以上代码编译后的class文件进行反编译(使用jad工具)的话,可以得到以下代码:
terator iterator = item.iterator();
**也就是说,其实foreach 每次循环都调用了一次iterator的next()方法
因此才会有这个堆栈信息:
at java.util.ArrayList$Itr.next(ArrayList.java:859)
下面我们来尝试分析一下这段代码报错的原因:
1、第一次 以 “1”的值进入循环,"1" != "2", 执行下一次循环
2、第二次循环以"2"的值进入,判断相等,执行remove()方法(注意这个remove方法并不是 iterator的remove(),而是ArrayList的remove()方法),导致modCount++
3、再次调用next()的时候,modCount != expectedModCount ,所以抛出异常
Iterator迭代器的remove
使用迭代器进行遍历还有很多需要注意的地方:
**正确的遍历
List<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
list.add("3");
Iterator<String> it = list.iterator();
while (it.hasNext()){
System.out.println(it.next());
it.remove();
}
这是一种正确的写法,如果输出语句和 remove()方法互换顺序怎么样呢?
**错误的遍历 —— next() 和 remove() 执行顺序的问题
List<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
list.add("3");
Iterator<String> it = list.iterator();
while (it.hasNext()){
it.remove();
System.out.println(it.next());
}
执行程序输出就会报错:
Exception in thread "main" java.lang.IllegalStateException
at java.util.ArrayList$Itr.remove(ArrayList.java:872)
at test.SimpleTest.main(SimpleTest.java:46)
这又是为什么? 还是直接从错误入手:
定位到错误的位置
at java.util.ArrayList$Itr.remove(ArrayList.java:872)
发现如果 lastRet 的值小于 0就会抛出非法状态的异常,这个lastRet是什么?
**且看定义:
**lastRet的赋值场景
由上面代码可以看出,当你执行next()方法的时候, lastRet 赋值为i,所以这个elementData[]中的下标最小是0,所以这个时候lastRet 最小的值是0, 那么只有当执行remove()方法的时候,lastRet的值赋值为-1,也就是说,你必须先执行一次next方法,再执行一次remove方法,才能够保证程序的正确运行。
**错误的遍历 —— 使用Arrays.asList()
List<String> list = Arrays.asList("1","2","3");
Iterator<String> it = list.iterator();
while (it.hasNext()){
System.out.println(it.next());
it.remove();
}
这段代码执行之后的输出是怎样的呢?
1
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.remove(AbstractList.java:161)
at java.util.AbstractList$Itr.remove(AbstractList.java:374)
at test.SimpleTest.main(SimpleTest.java:50)
很不幸,这段代码也抛出了异常,直接从错误处入手发现,这个remove()方法调用的是AbstractList中的remove方法,跟进入发现有一段代码
remove()方法:
也就是说,只要这段代码执行了,都会报错,抛出异常
后记:
上述文章主要介绍了 for循环、foreach 循环、iterator 迭代器遍历元素的速度大小的比较
还介绍了各自遍历过程中 对remove操作的影响。
**如果你有什么问题或者好的建议,欢迎你与我一起讨论
深入理解Java变量
网上罗列了很多关于变量的理解,良莠不齐,不知道哪些是对的,哪些是错的,所以笔者就这些博客和自己的理解写出这篇文章,如果有不对的地方,希望读者能够指正,感谢。
变量是我们经常用到的一种,我在刚学 Java 的时候,也经常被各种变量的概念折磨,当时并没有细抠,但是我在写一篇类似的文章中,想把变量作为一种小标题来简述一下,但是发现,变量这个概念还是比较繁琐的,本篇文章就来深入认识一下 Java 中这些变量的概念
变量汇总
所以,到底有哪些变量的概念呢?距今为止,目前已知的变量主要有
- 实例变量
- 全局变量
- 静态变量
- 类变量
- 局部变量
- 成员变量
- 常量
下面我们就采用各个击破的方式来认识每个概念
实例变量
实例变量又被称为Instance variables
。不使用 static
关键字定义,并且在任何方法、构造方法、块之外的变量都是实例变量
。实例变量都是基于特定实例的,实例变量不会在实例之间共享,也就是说,每一个对象的实例都有自己的一个实例变量。下面是实例变量的一个例子
class Fruits {
public String fruitName; // 具有公共访问权限的 fruitName;
private int fruitNum; // 具有私有访问权限的 fruitNum;
}
你可以使用下面这种方式进行实例变量的调用
public class Fruits {
public String fruitName;
private int fruitNum;
public static void main(String[] args) {
Fruits fruits = new Fruits();
fruits.fruitName = "strawberry";
fruits.fruitNum = 100;
}
}
如何识别实例变量
那么我如何知道一个变量它是实例变量呢?下面是一些关于实例变量的定义规则
- 实例变量可以使用四种访问修饰符进行修饰:**public、protected、default、private
- 实例变量可以使用
transient、final
关键字进行修饰 - 实例变量不可以使用
abstract、synchronized、strictfp、native、static
关键字进行修饰
实例变量带有默认值,也就是说,实例变量不用初始化就能使用。下面是常用实例变量的初始值
实例变量的特点
上面我们了解了实例变量的基本特征和如何区分实例变量,下面我们来讲一下实例变量的特点。
-
实例变量的只能在类中声明,但是在方法、构造函数或任何块之外。
-
当在为堆中对象分配空间时,将为每个实例变量分配一块区域。
-
实例变量只能通过创建对象来使用,当使用
new
关键字进行创建对象时,实例变量同时也被创建,当垃圾回收器回收对象时,实例变量也会被销毁。 -
实例变量可以使用访问
修饰符
来修饰 -
实例变量不用强制初始化,它有自己的默认值。
-
每个对象都有自己的一个实例变量的副本,因此在一个对象中修改变量不会对其他对象中的实例变量造成影响
-
实例变量只能通过创建对象引用来使用。
全局变量
全局变量又被称为Global variables
。如果你有其他语言的编程经验,比如 C、C++ 的话,你会接触到全局变量这个概念,你可以使用下面代码来创建全局变量
#include<stdio.h>
// 全局变量
int A;
int B;
int Add()
{
return A + B;
}
但是在 Java 中,是不存在全局变量的。因为 Java 是一门面向对象的编程语言,所有的内容都是属于类的一部分。Java 这么做的原因是为了防止数据和类成员被其他程序的其他部分有意或者无意的修改。所以在 Java 中,使用 静态变量
来起到全局访问的目的。
静态变量
静态变量又被称为Static variables
。静态变量的定义比较简单,静态变量是属于该类
的变量,它是由 static
关键字来修饰的。static 修饰的变量属于静态变量,它只能定义在类的内部、方法的外部。
静态变量的特点
- 静态变量只能使用 static 关键字进行修饰,它不能在方法中进行声明,不论是静态方法还是非静态方法。
- 静态变量会在程序运行前进行初始化,并且只初始化一次。静态变量会有一个初始化顺序,我们后面说。
- 静态变量的所有实例共享同一个副本。也就是说,静态变量只有一个,它不会随着对象实例的创建而进行副本拷贝
- 静态变量可以通过
类名.变量名
进行访问,并且不需要创建任何对象就能访问。
public class Fruits {
public String fruitName;
private int fruitNum;
static String fruitType;
public static void main(String[] args) {
Fruits.fruitType = "apple"; // 类名.变量名
System.out.println(fruitType);
}
}
- 可以在
非静态方法
中使用静态变量
类变量
类变量又被称为 Class variables
在 Java 中,类变量就是静态变量,它们都用 static
关键字进行修饰,所以,如果你再听到说静态变量的时候,它也就是类变量。
局部变量
还有一种说法,说 Java 中只有类变量、实例变量和局部变量。这么分也没有问题,可能有人会问到,你把成员变量和常量放在哪了?别着急我们后面会说
先来说一下什么是局部变量
局部变量又称为 Local variables
。它指的是在方法中、构造器中或者块代码中定义的变量。局部变量的生命周期随方法、构造器、代码块的执行完毕而销毁。
不管上面的一些变量概念如何变换、局部变量都站如松,坐如钟,行如风,卧如弓,从容应对各种不同文章的比较。真是一个省事的变量。
那么这么好的东西我们可要仔细研究一下其特点是啥
- 根据定义可知,局部变量定义在方法、构造器或者代码块中;
- 然后局部变量的生命周期随方法、构造器、代码块的执行完毕而销毁;
- 局部变量不能使用访问修饰符,例如如下代码
-
局部变量仅在方法的声明、构造函数或者块内可见,局部变量只能在调用这些方法、构造函数或者块的内部使用
-
局部变量没有默认值,所以局部变量应该在第一次使用或者声明的时候就应该初始化完成
成员变量
什么?成员变量在 Java 中就是实例变量?这个结论对吗?
常量
验证过程
为了验证这个结论,我们求助于了 stackoverflow
网站
我们搜索 java member variable and instance variable 就帮我们定为到了这个标题
大致意思是:什么是成员变量?成员变量和实例变量是否相同呢?我们下面有个回答
第一句就给出,实例变量和类变量都称为成员变量,然后给出了 JDK 官网手册对变量的定义。意思是在 Java 中,只有三中类型的变量
- 定义在类中的成员变量 — 被称为属性
- 定义在方法(包含构造方法)或者块代码中的变量 — 被称为局部变量
- 定义在方法定义中的变量 — 被称为参数
嗯。。。或许还不是很好解决我们的问题,我带着问题再次求助 JDK 官网手册,又看到了关于 Variables
的定义
意思是在 Java 中,只有下面几种类型的变量(别扯别的了,莫非你比官网还靠谱?)
实例变量(非静态属性)
:大致意思就是说
非静态属性也就被称为实例变量
,因为它们的值是相对于每个实例来说的。换句话说,对于每个对象来讲,实例变量的值都是唯一的;
-
类变量(静态属性)
:类变量就是使用 static 修饰符声明的字段,这就会告诉编译器:无论该类被实例化了多少次,该变量只存在一个副本。另外,可以添加关键字 final 来表示常量
。 -
局部变量
:没有特殊的关键字将制定的变量声明为局部变量、确定其声明的完全取决于声明变量的位置。 -
参数
:想一下我们平常用到最多的方法是什么方法?当然是main
方法啊,main 方法是怎么定义的?
public static void main(String[] args) {}
其中的 args 是不是就是 String 的数组的变量,我们也称其为参数
,所以参数也没有关键字进行声明,标识其为参数也只是取决于其声明位置。
bilibili
所以 我写了一篇文章为了给你讲清楚,现在你应该知道 Java 中到底有哪些变量了吧。
如果你用 static 来定义变量,只能是类变量、或者说静态变量、而且其定义位置只能在类中,方法或代码块外,变量的副本只有一个。
如果你不用 static 来声明变量,那么就会有三种变量的叫法
- 定义在构造方法、代码块、方法
外
的变量被称为实例变量,实例变量的副本数量和实例的数量一样。 - 定义在方法、构造方法、代码块
内
的变量被称为局部变量; - 定义在方法参数
中
的变量被称为参数。
也就是下面代码所描述的这样
public class VariablesInJava {
int instanceVariable; // 实例变量
static String staticVariable; // 类变量
public void method() {
String localVariable = "localVariable"; // 局部变量
System.out.println(localVariable);
}
public static void main(String args[]) {} //参数
}
Java基础核心总结
- Java 基础核心总结
先来看一下本篇文章的思维导图吧,我会围绕下面这些内容进行讲解。内容很干,小伙伴们看完还希望不吝转发。(高清思维导图版本关注作者公众号 Java建设者
回复 Java666
获取,其他思维导图获取方式在文末)。
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
。我们知道,计算机应用程序和硬件之间会屏蔽很多细节,它们之间依靠操作系统完成调度和协调,大致的体系结构如下
那么加上 Java 应用、JVM 的体系结构会变为如下
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)
。
我们可以认真研究一下这张图,它几乎包括了 Java 中所有的概念,我使用的是 jdk1.8
,可以点进去 Description of Java Conceptual Diagram
, 可以发现这里面包括了所有关于 Java 的描述
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 位
都指的是在内存中的占用。
基础语法
- 大小写敏感:Java 是对大小写敏感的语言,例如 Hello 与 hello 是不同的,这其实就是 Java 的字符串表示方式
- 类名:对于所有的类来说,首字母应该大写,例如
MyFirstClass
- 包名:包名应该尽量保证小写,例如
my.first.package
- 方法名:方法名首字母需要小写,后面每个单词字母都需要大写,例如
myFirstMethod()
运算符
运算符不只 Java 中有,其他语言也有运算符,运算符是一些特殊的符号,主要用于数学函数、一些类型的赋值语句和逻辑比较方面,我们就以 Java 为例,来看一下运算符。
- 赋值运算符
赋值运算符使用操作符 =
来表示,它的意思是把 = 号右边的值复制给左边,右边的值可以是任何常数、变量或者表达式,但左边的值必须是一个明确的,已经定义的变量。比如 int a = 4
。
但是对于对象来说,复制的不是对象的值,而是对象的引用,所以如果说将一个对象复制给另一个对象,实际上是将一个对象的引用赋值给另一个对象。
- 算数运算符
算数运算符就和数学中的数值计算差不多,主要有
算数运算符需要注意的就是优先级问题
,当一个表达式中存在多个操作符时,操作符的优先级顺序就决定了计算顺序,最简单的规则就是先乘除后加减,()
的优先级最高,没必要记住所有的优先级顺序,不确定的直接用 () 就可以了。
- 自增、自减运算符
这个就不文字解释了,解释不如直接看例子明白
int a = 5;
b = ++a;
c = a++;
- 比较运算符
比较运算符用于程序中的变量之间,变量和自变量之间以及其他类型的信息之间的比较。
比较运算符的运算结果是 boolean 型。当运算符对应的关系成立时,运算的结果为 true,否则为 false。比较运算符共有 6 个,通常作为判断的依据用于条件语句中。
- 逻辑运算符
逻辑运算符主要有三种,与、或、非
下面是逻辑运算符对应的 true/false 符号表
- 按位运算符
按位运算符用来操作整数基本类型中的每个比特
位,也就是二进制位。按位操作符会对两个参数中对应的位执行布尔代数运算,并最终生成一个结果。
运算符 | 作用 |
---|---|
& | 与 eg: 4 & 5 = 4 |
| | 或 eg: 4 | 5 = 5 |
~ | 非 eg: ~4 = ~5 |
^ | 异或 eg : 4 ^ 5 = 1 |
如果进行比较的双方是数字的话,那么进行比较就会变为按位运算。
按位与:按位进行与运算(AND),两个操作数中位都为1,结果才为1,否则结果为0。需要首先把比较双方转换成二进制再按每个位进行比较
按位或:按位进行或运算(OR),两个位只要有一个为1,那么结果就是1,否则就为0。
按位异或:按位进行异或运算(XOR),如果位为0,结果是1,如果位为1,结果是0。
按位非:按位进行取反运算(NOT),两个操作数的位中,相同则结果为0,不同则结果为1。
- 移位运算符
移位运算符用来将操作数向某个方向(向左或者右)移动指定的二进制位数。
- 三元运算符
三元运算符是类似 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 就不再为你提供默认的构造器,你必须手动指定,否则会出现编译错误。
显示的错误是,必须提供 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 会尽量保证每个变量在使用前都会获得初始化,初始化涉及两种初始化。
-
一种是编译器默认指定的字段初始化,基本数据类型的初始化
一种是其他对象类型的初始化,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 虚拟机》中提到一个观点
在 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() 必须放在构造方法的第一行,否则编译不通过
如果你把 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 关键字的比较。
访问控制权限
访问控制权限又称为封装
,它是面向对象三大特性中的一种,我之前在学习过程中经常会忽略封装,心想这不就是一个访问修饰符么,怎么就是三大特性的必要条件了?后来我才知道,如果你信任的下属对你隐瞒 bug,你是根本不知道的。
访问控制权限其实最核心就是一点:只对需要的类可见。
Java中成员的访问权限共有四种,分别是 public、protected、default、private,它们的可见性如下
继承
继承是所有 OOP(Object Oriented Programming)
语言和 Java 语言都不可或缺的一部分。只要我们创建了一个类,就隐式的继承自 Object
父类,只不过没有指定。如果你显示指定了父类,那么你继承于父类,而你的父类继承于 Object 类。
继承的关键字是 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 中的属性和方法。
组合和继承是有区别的,它们的主要区别如下。
关于继承和组合孰优孰劣的争论没有结果,只要发挥各自的长处和优点即可,一般情况下,组合和继承也是一对可以连用的好兄弟。
代理
除了继承和组合外,另外一种值得探讨的关系模型称为 代理
。代理的大致描述是,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
修饰的方法一定是抽象方法,具有抽象方法的类一定是抽象类。实现类方法中只有方法具体的实现。 -
抽象类中不一定只有抽象方法,抽象类中也可以有具体的方法,你可以自己去选择是否实现这些方法。
-
抽象类中的约束不像接口那么严格,你可以在抽象类中定义 **构造方法、抽象方法、普通属性、方法、静态属性和静态方法
-
抽象类和接口一样不能被实例化,实例化只能实例化
具体的类
异常
异常是程序经常会出现的,发现错误的最佳时机是在编译阶段,也就是你试图在运行程序之前。但是,在编译期间并不能找到所有的错误,有一些 NullPointerException
和 ClassNotFoundException
异常在编译期找不到,这些异常是 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 thanRuntimeException
and its subclasses andError
and its subclasses.
也就是说,除了 RuntimeException
和其子类,以及error
和其子类,其它的所有异常都是 checkedException
。
那么,按照这种逻辑关系,我们可以对 Throwable 及其子类进行归类分析
可以看到,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
CheckedException
与 Exception 有关的 Java 关键字
那么 Java 中是如何处理这些异常的呢?在 Java 中有这几个关键字 throws、throw、try、finally、catch 下面我们分别来探讨一下
throws 和 throw
在 Java 中,异常也就是一个对象,它能够被程序员自定义抛出或者应用程序抛出,必须借助于 throws
和 throw
语句来定义抛出异常。
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 虚拟机)出现的问题。这些错误是不可检查的,因为它们在应用程序的控制和处理能力之 外,而且绝大多数是程序运行时不允许出现的状况,比如 OutOfMemoryError
和 StackOverflowError
异常的出现会有几种情况,这里需要先介绍一下 Java 内存模型 JDK1.7。
其中包括两部分,由所有线程共享的数据区和线程隔离的数据区组成,在上面的 Java 内存模型中,只有程序计数器是不会发生 OutOfMemoryError
情况的区域,程序计数器控制着计算机指令的分支、循环、跳转、异常处理和线程恢复,并且程序计数器是每个线程私有的。
什么是线程私有:表示的就是各条线程之间互不影响,独立存储的内存区域。
如果应用程序执行的是 Java 方法,那么这个计数器记录的就是虚拟机字节码
指令的地址;如果正在执行的是 Native
方法,这个计数器值则为空(Undefined)
。
除了程序计数器外,其他区域:方法区(Method Area)
、虚拟机栈(VM Stack)
、本地方法栈(Native Method Stack)
和 堆(Heap)
都是可能发生 OutOfMemoryError 的区域。
-
虚拟机栈:如果线程请求的栈深度大于虚拟机栈所允许的深度,将会出现
StackOverflowError
异常;如果虚拟机动态扩展无法申请到足够的内存,将出现OutOfMemoryError
。 -
本地方法栈和虚拟机栈一样
-
堆:Java 堆可以处于物理上不连续,逻辑上连续,就像我们的磁盘空间一样,如果堆中没有内存完成实例分配,并且堆无法扩展时,将会抛出 OutOfMemoryError。
-
方法区:方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
在 Java 中,你可以把异常理解为是一种能够提高你程序健壮性的机制,它能够让你在编写代码中注意这些问题,也可以说,如果你写代码不会注意这些异常情况,你是无法成为一位硬核程序员的。
内部类
距今为止,我们了解的都是普通类的定义,那就是直接在 IDEA 中直接新建一个 class 。
新建完成后,你就会拥有一个 class 文件的定义,这种操作太简单了,时间长了就会枯燥,我们年轻人多需要更新潮和骚气的写法,好吧,既然你提到了那就使用 内部类
吧,这是一种有用而且骚气的定义类的方式,内部类的定义非常简单:可以将一个类的定义放在另一个类的内部,这就是内部类。
内部类是一种非常有用的特性,定义在类内部的类,持有外部类的引用,但却对其他外部类不可见,看起来就像是一种隐藏代码的机制,就和 弗兰奇将军
似的,弗兰奇可以和弗兰奇将军进行通讯,但是外面的敌人却无法直接攻击到弗兰奇本体。
下面我们就来聊一聊创建内部类的方式。
创建内部类
定义内部类非常简单,就是直接将一个类定义在外围类的里面,如下代码所示
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 这个庞大集合框架的家族体系和成员,让你了解它的设计与实现。
**是时候祭出这张神图了
首先来介绍的就是列表爷爷辈儿的接口- **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 的继承体系:
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。一些实现类是CopyOnWriteArrayList
,ConcurrentHashMap
,CopyOnWriteArraySet
**Collections 算法
此类包含用于集合框架算法的方法,例如二进制搜索,排序,重排,反向等。
集合实现类特征图
下图汇总了部分集合框架的主要实现类的特征图,让你能有清晰明了看出每个实现类之间的差异性
还有一种类型是关于强引用、弱引用、虚引用的文章,请参考
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 所设计的类如下
下面是一个简单的反射类
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 的类继承图如下
枚举
枚举可能是我们使用次数比较少的特性,在 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
关键字可以做到静态导入包
上面代码导入的是 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
包下,可以自动引用。
此类的属性和方法都比较少。你会发现这个类中没有我们的 values 方法。前面刚说到,values()
方法是你使用枚举时被编译器添加进来的 static 方法。可以使用反射来验证一下。
除此之外,enum 还和 Class 类有交集,在 Class 类中有三个关于 Enum 的方法
前面两个方法用于获取 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 进行一个分类。
公号回复
IO
获取思维导图
I/O 还可以根据操作对象来进行区分:主要分为
除此之外,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:\\java\\file1.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 类进行总结
基础 IO 类和相关方法
虽然. IO 类有很多,但是最基本的是四个抽象类,InputStream、OutputStream、Reader、Writer。最基本的方法也就是 read()
和 write()
方法,其他流都是上面这四类流的子类,方法也是通过这两类方法衍生而成的。而且大部分的 IO 源码都是 native
标志的,也就是说源码都是 C/C++ 写的。这里我们先来认识一下这些流类及其方法
InputStream
InputStream 是一个定义了 Java 流式字节输入模式的抽象类。该类的所有方法在出错条件下引发一个IOException 异常。它的主要方法定义如下
OutputStream
OutputStream 是定义了流式字节输出模式的抽象类。该类的所有方法返回一个void 值并且在出错情况下引发一个IOException异常。它的主要方法定义如下
Reader 类
Reader 是 Java 定义的流式字符输入模式的抽象类。类中的方法在出错时引发 IOException
异常。
Writer 类
Writer 是定义流式字符输出的抽象类。 所有该类的方法都返回一个 void 值并在出错条件下引发 IOException 异常
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 引起的错误。
大小写敏感
首先,null 是 Java 中的关键字
,像是 public、static、final。它是大小写敏感的,你不能将 null 写成 Null 或 NULL,编辑器将不能识别它们然后报错。
这个问题已经几乎不会出现,因为 eclipse 和 Idea 编译器已经给出了编译器提示,所以你不用考虑这个问题。
null 是任何引用类型的初始值
null 是所有引用类型的默认值,Java 中的任何引用变量都将null作为默认值,也就是说所有 Object 类下的引用类型默认值都是 null。这对所有的引用变量都适用。就像是基本类型的默认值一样,例如 int 的默认值是 0,boolean 的默认值是 false。
下面是基本数据类型的初始值
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
Java.lang
Java.math
Java.net
思维导图持续更新中~~~ 欢迎关注公众号 程序员cxuan
领取超全思维导图。
String、StringBuffer和StringBuilder的区别
碎碎念
这是一道老生常谈的问题了,字符串是不仅是 Java 中非常重要的一个对象,它在其他语言中也存在。比如 C++、Visual Basic、C# 等。字符串使用 String 来表示,字符串一旦被创建出来就不会被修改,当你想修改 StringBuffer 或者是 StringBuilder,出于效率的考量,虽然 String 可以通过 + 来创建多个对象达到字符串拼接的效果,但是这种拼接的效率相比 StringBuffer 和 StringBuilder,那就是心有余而力不足了。本篇文章我们一起来深入了解一下这三个对象。
简单认识这三个对象
String
String 表示的就是 Java 中的字符串,我们日常开发用到的使用 ""
双引号包围的数都是字符串的实例。String 类其实是通过 char 数组来保存字符串的。下面是一个典型的字符串的声明
String s = "abc";
上面你创建了一个名为 abc
的字符串。
字符串是恒定的,一旦创建出来就不会被修改,怎么理解这句话?我们可以看下 String 源码的声明
告诉我你看到了什么?String 对象是由final
修饰的,一旦使用 final 修饰的类不能被继承、方法不能被重写、属性不能被修改。而且 String 不只只有类是 final 的,它其中的方法也是由 final 修饰的,换句话说,Sring 类就是一个典型的 Immutable
类。也由于 String 的不可变性,类似字符串拼接、字符串截取等操作都会产生新的 String 对象。
所以请你告诉我下面
String s1 = "aaa";
String s2 = "bbb" + "ccc";
String s3 = s1 + "bbb";
String s4 = new String("aaa");
分别创建了几个对象?
- 首先第一个问题,s1 创建了几个对象。字符串在创建对象时,会在常量池中看有没有 aaa 这个字符串;如果没有此时还会在常量池中创建一个;如果有则不创建。我们默认是没有的情况,所以会创建一个对象。下同。
- 那么 s2 创建了几个对象呢?是两个对象还是一个对象?我们可以使用
javap -c
看一下反汇编代码
public class com.sendmessage.api.StringDemo {
public com.sendmessage.api.StringDemo();
Code:
0: aload_0
1: invokespecial #1 // 执行对象的初始化方法
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // 将 String aaa 执行入栈操作
2: astore_1 # pop出栈引用值,将其(引用)赋值给局部变量表中的变量 s1
3: ldc #3 // String bbbccc
5: astore_2
6: return
}
编译器做了优化 String s2 = "bbb" + "ccc"
会直接被优化为 bbbccc
。也就是直接创建了一个 bbbccc 对象。
javap 是 jdk 自带的
反汇编
工具。它的作用就是根据 class 字节码文件,反汇编出当前类对应的 code 区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。javap -c 就是对代码进行反汇编操作。
- 下面来看 s3,s3 创建了几个对象呢?是一个还是两个?还是有其他选项?我们使用 javap -c 来看一下
我们可以看到,s3 执行 + 操作会创建一个 StringBuilder
对象然后执行初始化。执行 + 号相当于是执行 new StringBuilder.append()
操作。所以
String s3 = s1 + "bbb";
==
String s3 = new StringBuilder().append(s1).append("bbb").toString();
// Stringbuilder.toString() 方法也会创建一个 String
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
所以 s3 执行完成后,相当于创建了 3 个对象。
- 下面来看 s4 创建了几个对象,在创建这个对象时因为使用了 new 关键字,所以肯定会在堆中创建一个对象。然后会在常量池中看有没有 aaa 这个字符串;如果没有此时还会在常量池中创建一个;如果有则不创建。所以可能是创建一个或者两个对象,但是一定存在两个对象。
说完了 String 对象,我们再来说一下 StringBuilder 和 StringBuffer 对象。
上面的 String 对象竟然和 StringBuilder 产生了千丝万缕的联系。不得不说 StringBuilder 是一个牛逼的对象。String 对象底层是使用了 StringBuilder 对象的 append 方法进行字符串拼接的,不由得对 StringBuilder 心生敬意。
不由得我们想要真正认识一下这个 StringBuilder 大佬,但是在认识大佬前,还有一个大 boss 就是 StringBuffer 对象,这也是你不得不跨越的鸿沟。
StringBuffer
StringBuffer 对象
代表一个可变的字符串序列,当一个 StringBuffer 被创建以后,通过 StringBuffer 的一系列方法可以实现字符串的拼接、截取等操作。一旦通过 StringBuffer 生成了最终想要的字符串后,就可以调用其 toString
方法来生成一个新的字符串。例如
StringBuffer b = new StringBuffer("111");
b.append("222");
System.out.println(b);
我们上面提到 +
操作符连接两个字符串,会自动执行 toString()
方法。那你猜 StringBuffer.append 方法会自动调用吗?直接看一下反汇编代码不就完了么?
上图左边是手动调用 toString 方法的代码,右图是没有调用 toString 方法的代码,可以看到,toString() 方法不像 +
一样自动被调用。
StringBuffer 是线程安全的,我们可以通过它的源码可以看出
StringBuffer 在字符串拼接上面直接使用 synchronized
关键字加锁,从而保证了线程安全性。
StringBuilder
最后来认识大佬了,StringBuilder 其实是和 StringBuffer 几乎一样,只不过 StringBuilder 是非线程安全
的。并且,为什么 + 号操作符使用 StringBuilder 作为拼接条件而不是使用 StringBuffer 呢?我猜测原因是加锁是一个比较耗时的操作,而加锁会影响性能,所以 String 底层使用 StringBuilder 作为字符串拼接。
深入理解 String、StringBuilder、StringBuffer
我们上面说到,使用 +
连接符时,JVM 会隐式创建 StringBuilder 对象,这种方式在大部分情况下并不会造成效率的损失,不过在进行大量循环拼接字符串时则需要注意。如下这段代码
String s = "aaaa";
for (int i = 0; i < 100000; i++) {
s += "bbb";
}
这是一段很普通的代码,只不过对字符串 s 进行了 + 操作,我们通过反编译代码来看一下。
// 经过反编译后
String s = "aaa";
for(int i = 0; i < 10000; i++) {
s = (new StringBuilder()).append(s).append("bbb").toString();
}
你能看出来需要注意的地方了吗?在每次进行循环时,都会创建一个 StringBuilder
对象,每次都会把一个新的字符串元素 bbb
拼接到 aaa
的后面,所以,执行几次后的结果如下
每次都会创建一个 StringBuilder ,并把引用赋给 StringBuilder 对象,因此每个 StringBuilder 对象都是强引用
, 这样在创建完毕后,内存中就会多了很多 StringBuilder 的无用对象。了解更多关于引用的知识,请看
https://mp.weixin.qq.com/s/ZflBpn2TBzTNv_-G-zZxNg
这样由于大量 StringBuilder 创建在堆内存中,肯定会造成效率的损失,所以在这种情况下建议在循环体外创建一个 StringBuilder 对象调用 append()
方法手动拼接。
例如
StringBuilder builder = new StringBuilder("aaa");
for (int i = 0; i < 10000; i++) {
builder.append("bbb");
}
builder.toString();
这段代码中,只会创建一个 builder 对象,每次循环都会使用这个 builder 对象进行拼接,因此提高了拼接效率。
从设计角度理解
我们前面说过,String 类是典型的 Immutable
不可变类实现,保证了线程安全性,所有对 String 字符串的修改都会构造出一个新的 String 对象,由于 String 的不可变性,不可变对象在拷贝时不需要额外的复制数据。
String 在 JDK1.6 之后提供了 intern()
方法,intern 方法是一个 native
方法,它底层由 C/C++ 实现,intern 方法的目的就是为了把字符串缓存起来,在 JDK1.6 中却不推荐使用 intern 方法,因为 JDK1.6 把方法区放到了永久代(Java 堆的一部分),永久代的空间是有限的,除了 Fullgc
外,其他收集并不会释放永久代的存储空间。JDK1.7 将字符串常量池移到了堆内存
中,
下面我们来看一段代码,来认识一下 intern
方法
public static void main(String[] args) {
String a = new String("ab");
String b = new String("ab");
String c = "ab";
String d = "a";
String e = new String("b");
String f = d + e;
System.out.println(a.intern() == b);
System.out.println(a.intern() == b.intern());
System.out.println(a.intern() == c);
System.out.println(a.intern() == f);
}
上述的执行结果是什么呢?我们先把答案贴出来,以防心急的同学想急于看到结果,他们的答案是
false true true false
和你预想的一样吗?为什么会这样呢?我们先来看一下 intern 方法的官方解释
这里你需要知道 JVM 的内存模型
虚拟机栈
: Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈种创建一个栈帧(stack frame)
。本地方法栈
: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用native
关键字修饰的方法所存储的区域程序计数器
:程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。方法区
:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。堆
: 堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上运行时常量池
:运行时常量池又被称为Runtime Constant Pool
,这块区域是方法区的一部分,它的名字非常有意思,它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。
在 JDK 1.6 及之前的版本中,常量池是分配在方法区中永久代(Parmanent Generation)
内的,而永久代和 Java 堆是两个完全分开的区域。如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回常量池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。
一些人把方法区称为永久代,这种说法不准确,仅仅是 Hotspot 虚拟机设计团队选择使用永久代来实现方法区而已。
从JDK 1.7开始去永久代
,字符串常量池已经被转移至 Java 堆中,开发人员也对 intern 方法做了一些修改。因为字符串常量池和 new 的对象都存于 Java 堆中,为了优化性能和减少内存开销,当调用 intern 方法时,如果常量池中已经存在该字符串,则返回池中字符串;否则直接存储堆中的引用,也就是字符串常量池中存储的是指向堆里的对象。
所以我们对上面的结论进行分析
String a = new String("ab");
String b = new String("ab");
System.out.println(a.intern() == b);
输出什么? false,为什么呢?画一张图你就明白了(图画的有些问题,栈应该是后入先出,所以 b 应该在 a 上面,不过不影响效果)
a.intern 返回的是常量池中的 ab,而 b 是直接返回的是堆中的 ab。地址不一样,肯定输出 false
所以第二个
System.out.println(a.intern() == b.intern());
也就没问题了吧,它们都返回的是字符串常量池中的 ab,地址相同,所以输出 true
然后来看第三个
System.out.println(a.intern() == c);
图示如下
a 不会变,因为常量池中已经有了 ab ,所以 c 不会再创建一个 ab 字符串,这是编译器做的优化,为了提高效率。
下面来看最后一个
System.out.println(a.intern() == f);
String
首先来看一下 String 类在继承树的什么位置、实现了什么接口、父类是谁,这是源码分析的几大重要因素。
String 没有继承任何接口,不过实现了三个接口,分别是 Serializable、Comparable、CharSequence 接口
- Serializable :这个序列化接口没有任何方法和域,仅用于标识序列化的语意。
- Comparable:实现了 Comparable 的接口可用于内部比较两个对象的大小
- CharSequence:字符串序列接口,CharSequence 是一个可读的 char 值序列,提供了 length(), charAt(int index), subSequence(int start, int end) 等接口,StringBuilder 和 StringBuffer 也继承了这个接口
**重要属性
字符串是什么,字!符!串! 你品,你细品。你会发现它就是一连串字符组成的串。
也就是说
String str = "abc";
// ===
char data[] = {'a', 'b', 'c'};
String str = new String(data);
原来这么回事啊!
所以,String 中有一个用于存储字符的 char 数组 value[]
,这个数组存储了每个字符。另外一个就是 hash 属性,它用于缓存字符串的哈希码。因为 String 经常被用于比较,比如在 HashMap 中。如果每次进行比较都重新计算其 hashcode 的值的话,那无疑是比较麻烦的,而保存一个 hashcode 的缓存无疑能优化这样的操作。
String 可以通过许多途径创建,也可以根据 Stringbuffer 和 StringBuilder 进行创建。
毕竟我们本篇文章探讨的不是源码分析的文章,所以涉及到的源码不会很多。
除此之外,String 还提供了一些其他方法
-
charAt
:返回指定位置上字符的值 -
getChars
: 复制 String 中的字符到指定的数组 -
equals
: 用于判断 String 对象的值是否相等 -
indexOf
: 用于检索字符串 -
substring
: 对字符串进行截取 -
concat
: 用于字符串拼接,效率高于 + -
replace
:用于字符串替换 -
match
:正则表达式的字符串匹配 -
contains
: 是否包含指定字符序列 -
split
: 字符串分割 -
join
: 字符串拼接 -
trim
: 去掉多余空格 -
toCharArray
: 把 String 对象转换为字符数组 -
valueOf
: 把对象转换为字符串
StringBuilder
StringBuilder 类表示一个可变的字符序列,我们知道,StringBuilder 是非线程安全的容器,一般适用于单线程
场景中的字符串拼接操作,下面我们就来从源码角度看一下 StringBuilder
首先我们来看一下 StringBuilder 的定义
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {...}
StringBuilder 被 final 修饰,表示 StringBuilder 是不可被继承的,StringBuilder 类继承于 AbstractStringBuilder类。实际上,AbstractStringBuilder 类具体实现了可变字符序列的一系列操作,比如:append()、insert()、delete()、replace()、charAt() 方法等。
StringBuilder 实现了 2 个接口
- Serializable 序列化接口,表示对象可以被序列化。
- CharSequence 字符序列接口,提供了几个对字符序列进行只读访问的方法,例如 length()、charAt()、subSequence()、toString() 方法等。
StringBuilder 使用 AbstractStringBuilder 类中的两个变量作为元素
char[] value; // 存储字符数组
int count; // 字符串使用的计数
StringBuffer
StringBuffer 也是继承于 AbstractStringBuilder ,使用 value 和 count 分别表示存储的字符数组和字符串使用的计数,StringBuffer 与 StringBuilder 最大的区别就是 StringBuffer 可以在多线程场景下使用,StringBuffer 内部有大部分方法都加了 synchronized
锁。在单线程场景下效率比较低,因为有锁的开销。
StringBuilder 和 StringBuffer 的扩容问题
我相信这个问题很多同学都没有注意到吧,其实 StringBuilder 和 StringBuffer 存在扩容问题,先从 StringBuilder 开始看起
首先先注意一下 StringBuilder 的初始容量
public StringBuilder() {
super(16);
}
StringBuilder 的初始容量是 16,当然也可以指定 StringBuilder 的初始容量。
在调用 append 拼接字符串,会调用 AbstractStringBuilder 中的 append 方法
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
上面代码中有一个 ensureCapacityInternal
方法,这个就是扩容方法,我们跟进去看一下
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
这个方法会进行判断,minimumCapacity 就是字符长度 + 要拼接的字符串长度,如果拼接后的字符串要比当前字符长度大的话,会进行数据的复制,真正扩容的方法是在 newCapacity
中
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
扩容后的字符串长度会是原字符串长度增加一倍 + 2,如果扩容后的长度还比拼接后的字符串长度小的话,那就直接扩容到它需要的长度 newCapacity = minCapacity,然后再进行数组的拷贝。
总结
本篇文章主要描述了 String 、StringBuilder 和 StringBuffer 的主要特性,String、StringBuilder 和 StringBuffer 的底层构造是怎样的,以及 String 常量池的优化、StringBuilder 和 StringBuffer 的扩容特性等。
如果有错误的地方,还请大佬们提出宝贵意见。
理解静态绑定与动态绑定
一个Java 程序要经过编写、编译、运行三个步骤,其中编写代码不在我们讨论的范围之内,那么我们的重点自然就放在了编译 和 运行这两个阶段,由于编译和运行阶段过程相当繁琐,下面就我的理解来进行解释:
Java程序从源文件创建到程序运行要经过两大步骤:
1、编译时期是由编译器将源文件编译成字节码的过程
2、字节码文件由Java虚拟机解释执行
绑定
**绑定就是一个方法的调用与调用这个方法的类连接在一起的过程被称为绑定。
绑定分类
绑定主要分为两种:
静态绑定 和 动态绑定
绑定的其他叫法
静态绑定 == 前期绑定 == 编译时绑定
动态绑定 == 后期绑定 == 运行时绑定
为了方便区分: 下面统一称呼为静态绑定和动态绑定
静态绑定
**在程序运行前,也就是编译时期JVM就能够确定方法由谁调用,这种机制称为静态绑定
识别静态绑定的三个关键字以及各自的理解
如果一个方法由private、Static、final任意一个关键字所修饰,那么这个方法是前期绑定的
构造方法也是前期绑定
private:private关键字是私有的意思,如果被private修饰的方法是无法由本类之外的其他类所调用的,也就是本类所特有的方法,所以也就由编译器识别此方法是属于哪个类的
public class Person {
private String talk;
private String canTalk(){
return talk;
}
}
class Animal{
public static void main(String[] args) {
Person p = new Person();
// private 修饰的方法是Person类独有的,所以Animal类无法访问(动物本来就不能说话)
// p.canTalk();
}
}
final:final修饰的方法不能被重写,但是可以由子类进行调用,如果将方法声明为final可以有效的关闭动态绑定
public class Fruit {
private String fruitName;
final String eatingFruit(String name){
System.out.println("eating " + name);
return fruitName;
}
}
class Apple extends Fruit{
// 不能重写final方法,eatingFruit方法只属于Fruit类,Apple类无法调用
// String eatingFruit(String name){
// super.eatingFruit(name);
// }
String eatingApple(String name){
return super.eatingFruit(name);
}
}
static: static修饰的方法比较特殊,不用通过new出某个类来调用,由类名.变量名直接调用该方法,这个就很关键了,new 很关键,也可以认为是开启多态的导火索,而由类名.变量名直接调用的话,此时的类名是确定的,并不会产生多态,如下代码:
public class SuperClass {
public static void sayHello(){
System.out.println("由 superClass 说你好");
}
}
public class SubClass extends SuperClass{
public static void sayHello(){
System.out.println("由 SubClass 说你好");
}
public static void main(String[] args) {
SuperClass.sayHello();
SubClass.sayHello();
}
}
SubClass 继承SuperClass 后,在 是无法重写sayHello方法的,也就是说sayHello()方法是对子类隐藏的,但是你可以编写"自己的"sayHello()方法,也就是子类SubClass 的sayHello()方法,由此可见,方法由static 关键词所修饰,也是编译时绑定
动态绑定
概念
**在运行时根据具体对象的类型进行绑定
**除了由private、final、static 所修饰的方法和构造方法外,JVM在运行期间决定方法由哪个对象调用的过程称为动态绑定
如果把编译、运行看成一条时间线的话,在运行前必须要进行程序的编译过程,那么在编译期进行的绑定是前期绑定,在程序运行了,发生的绑定就是后期绑定
代码理解
public class Father {
void drinkMilk(){
System.out.println("父亲喜欢喝牛奶");
}
}
public class Son extends Father{
@Override
void drinkMilk() {
System.out.println("儿子喜欢喝牛奶");
}
public static void main(String[] args) {
Father son = new Son();
son.drinkMilk();
}
}
Son类继承Father类,并重写了父类的dringMilk()方法,在输出结果得出的是儿子喜欢喝牛奶。那么上面的绑定方式是什么呢?
上面的绑定方式称之为动态绑定,因为在你编写 Father son = new Son()的时候,编译器并不知道son对象真正引用的是谁,在程序运行时期才知道,这个son是一个Father类的对象,但是却指向了Son的引用,这种概念称之为多态,那么我们就能够整理出来多态的三个原则:
**1. 继承
**2.重写
**3.父类对象指向子类引用
也就是说,在Father son = new Son() ,触发了动态绑定机制。
动态绑定的过程
- 虚拟机提取对象的实际类型的方法表;
- 虚拟机搜索方法签名;
- 调用方法。
动态绑定和静态绑定的特点
静态绑定
静态绑定在编译时期触发,那么它的主要特点是
1、编译期触发,能够提早知道代码错误
2、提高程序运行效率
动态绑定
1、使用动态绑定的前提条件能够提高代码的可用性,使代码更加灵活。
2、多态是设计模式的基础,能够降低耦合性。
@SuppressWarnings用法
从Java 5.0起,您可以使用java.lang.SuppressWarning
注释,来停用与编译单元子集相关的编译警告
作用:用于抑制编译器产生警告信息。
Idea 设置泛型检查,变量、方法未使用检查
从 eclipse 转换到idea 发现有很多不习惯的地方,比如说
String s;
List list = new ArrayList();
没有未使用的变量,未检查泛型,未使用的方法提示,特意查找了一下相关资料
设置泛型检查
我使用的是mac电脑,windows电脑应该类似
打开如图
会出现如下页面
选择 editor —> inspections —> 搜索 Raw use of —> 勾上 Raw use of parameterized class
效果如图:
设置变量未使用提示
打开如图:
出现如下页面
在 Editor —> General —> Errors and Warnings —> 选择 Unused symbol
**右侧勾选上 Error stripe mark 和 Effects 下面选择 Underwaved波浪线
参考:
https://blog.csdn.net/Lovincc/article/details/80464782
https://blog.csdn.net/codejas/article/details/78657560
变量未使用产生的警告
如上设置完成之后,变量未使用的提示应该是这样的:
如图 , list、set、map 都未被使用
**1.为未使用的变量设置SuppressWarning
在方法前添加
@SuppressWarning("unused")
能够越过变量未使用检查,@SuppressWarning 中的属性我们稍后再讨论。
**2.对未使用的方法添加SuppressWarning跳过方法未调用检查
可以在方法上添加SuppressWarning 跳过对方法未使用的检查
**3.为单行泛型添加SuppressWarning 跳过泛型检查
在单个泛型代码上添加@SuppressWarning("rawtypes")可以跳过泛型检查,但是需要注意: 还需要在方法上添加
@SuppressWarning("unchecked")注释
如上图所示,可以对单个泛型设置@SuppressWarning 跳过类型检查
**4. 为方法体上添加SuppressWarning 跳过泛型检查
如果一个方法体上含有多个未被检查的泛型,需要在方法体上添加@SuppressWarning(value={"unchecked","rawtypes"}) 跳过泛型检查
如图所示:
也可以使用 @SuppressWarning("all") ,来跳过所有的检查。
**5.@SuppressWarning 中的属性介绍以及属性说明
- all,抑制所有警告
- boxing,抑制与封装/拆装作业相关的警告
- cast,抑制与强制转型作业相关的警告
- dep-ann,抑制与淘汰注释相关的警告
- deprecation,抑制与淘汰的相关警告
- fallthrough,抑制与switch陈述式中遗漏break相关的警告
- finally,抑制与未传回finally区块相关的警告
- hiding,抑制与隐藏变数的区域变数相关的警告
- incomplete-switch,抑制与switch陈述式(enum case)中遗漏项目相关的警告
- javadoc,抑制与javadoc相关的警告
- nls,抑制与非nls字串文字相关的警告
- null,抑制与空值分析相关的警告
- rawtypes,抑制与使用raw类型相关的警告
- resource,抑制与使用Closeable类型的资源相关的警告
- restriction,抑制与使用不建议或禁止参照相关的警告
- serial,抑制与可序列化的类别遗漏serialVersionUID栏位相关的警告
- static-access,抑制与静态存取不正确相关的警告
- static-method,抑制与可能宣告为static的方法相关的警告
- super,抑制与置换方法相关但不含super呼叫的警告
- synthetic-access,抑制与内部类别的存取未最佳化相关的警告
- sync-override,抑制因为置换同步方法而遗漏同步化的警告
- unchecked,抑制与未检查的作业相关的警告
- unqualified-field-access,抑制与栏位存取不合格相关的警告
- unused,抑制与未用的程式码及停用的程式码相关的警告
**后记: @SuppressWarning 注解的主要作用就是抑制编译时期所产生的警告,从而提高程序的可读性,对于上面 @SuppressWarning 的所有属性,读者不用全部记忆,读两遍加深印象即可。用到的时候从google或者本文章都可查询
Java基础反射篇
反射是一个非常重要的知识点,在学习Spring 框架
时,Bean的初始化用到了反射,在破坏单例模式
时也用到了反射,在获取标注的注解
时也会用到反射······
当然了,反射在日常开发中,我们没碰到过多少,至少我没怎么用过。但面试是造火箭现场,可爱的面试官们又怎会轻易地放过我们呢?反射是开源框架中的一个重要设计理念,在源码分析中少不了它的身影,所以,今天我会尽量用浅显易懂的语言,让你去理解下面这几点:
(1)反射的思想以及它的作用 :point_right: **概念篇
(2)反射的基本使用及应用场景 :point_right: **应用篇
(3)使用反射能给我们编码时带来的优势以及存在的缺陷 :point_right: **分析篇
反射的思想及作用
有反必有正,就像世间的阴和阳,计算机的0和1一样。天道有轮回,苍天…~~(净会在这瞎bibi)~~
在学习反射之前,先来了解正射是什么。我们平常用的最多的 new
方式实例化对象的方式就是一种正射的体现。假如我需要实例化一个HashMap
,代码就会是这样子。
Map<Integer, Integer> map = new HashMap<>();
map.put(1, 1);
某一天发现,该段程序不适合用 HashMap 存储键值对,更倾向于用LinkedHashMap
存储。重新编写代码后变成下面这个样子。
Map<Integer, Integer> map = new LinkedHashMap<>();
map.put(1, 1);
假如又有一天,发现数据还是适合用 HashMap来存储,难道又要重新修改源码吗?
发现问题了吗?我们每次改变一种需求,都要去重新修改源码,然后对代码进行编译,打包,再到 JVM 上重启项目。这么些步骤下来,效率非常低。
对于这种需求频繁变更但变更不大的场景,频繁地更改源码肯定是一种不允许的操作,我们可以使用一个开关
,判断什么时候使用哪一种数据结构。
public Map<Integer, Integer> getMap(String param) {
Map<Integer, Integer> map = null;
if (param.equals("HashMap")) {
map = new HashMap<>();
} else if (param.equals("LinkedHashMap")) {
map = new LinkedHashMap<>();
} else if (param.equals("WeakHashMap")) {
map = new WeakHashMap<>();
}
return map;
}
通过传入参数param
决定使用哪一种数据结构,可以在项目运行时,通过动态传入参数决定使用哪一个数据结构。
如果某一天还想用TreeMap
,还是避免不了修改源码,重新编译执行的弊端。这个时候,反射就派上用场了。
在代码运行之前,我们不确定将来会使用哪一种数据结构,只有在程序运行时才决定使用哪一个数据类,而反射
可以在程序运行过程中动态获取类信息和调用类方法。通过反射构造类实例,代码会演变成下面这样。
public Map<Integer, Integer> getMap(String className) {
Class clazz = Class.forName(className);
Consructor con = clazz.getConstructor();
return (Map<Integer, Integer>) con.newInstance();
}
无论使用什么 Map,只要实现了Map接口
,就可以使用全类名路径
传入到方法中,获得对应的 Map 实例。例如java.util.HashMap / java.util.LinkedHashMap····如果要创建其它类例如WeakHashMap
,我也不需要修改上面这段源码。
我们来回顾一下如何从 new
一个对象引出使用反射
的。
- 在不使用反射时,构造对象使用 new 方式实现,这种方式在编译期就可以把对象的类型确定下来。
- 如果需求发生变更,需要构造另一个对象,则需要修改源码,非常不优雅,所以我们通过使用
开关
,在程序运行时判断需要构造哪一个对象,在运行时可以变更开关来实例化不同的数据结构。 - 如果还有其它扩展的类有可能被使用,就会创建出非常多的分支,且在编码时不知道有什么其他的类被使用到,假如日后
Map
接口下多了一个集合类是xxxHashMap
,还得创建分支,此时引出了反射:可以在运行时
才确定使用哪一个数据类,在切换类时,无需重新修改源码、编译程序。
第一章总结:
- 反射的思想:**在程序运行过程中确定和解析数据类的类型。
- 反射的作用:对于在
编译期
无法确定使用哪个数据类的场景,通过反射
可以在程序运行时构造出不同的数据类实例。
反射的基本使用
Java 反射的主要组成部分有4个:
Class
:任何运行在内存中的所有类都是该 Class 类的实例对象,每个 Class 类对象内部都包含了本来的所有信息。记着一句话,通过反射干任何事,先找 Class 准没错!Field
:描述一个类的属性,内部包含了该属性的所有信息,例如数据类型,属性名,访问修饰符······Constructor
:描述一个类的构造方法,内部包含了构造方法的所有信息,例如参数类型,参数名字,访问修饰符······Method
:描述一个类的所有方法(包括抽象方法),内部包含了该方法的所有信息,与Constructor
类似,不同之处是 Method 拥有返回值类型信息,因为构造方法是没有返回值的。
我总结了一张脑图,放在了下面,如果用到了反射,离不开这核心的4
个类,只有去了解它们内部提供了哪些信息,有什么作用,运用它们的时候才能易如反掌。
我们在学习反射的基本使用时,我会用一个SmallPineapple
类作为模板进行说明,首先我们先来熟悉这个类的基本组成:**属性,构造函数和方法
public class SmallPineapple {
public String name;
public int age;
private double weight; // 体重只有自己知道
public SmallPineapple() {}
public SmallPineapple(String name, int age) {
this.name = name;
this.age = age;
}
public void getInfo() {
System.out.print("["+ name + " 的年龄是:" + age + "]");
}
}
反射中的用法有非常非常多,常见的功能有以下这几个:
- 在运行时获取一个类的 **Class 对象
- 在运行时构造一个类的**实例化对象
- 在运行时获取一个类的所有信息:**变量、方法、构造器、注解
获取类的 Class 对象
在 Java 中,每一个类都会有专属于自己的 Class 对象,当我们编写完.java
文件后,使用javac
编译后,就会产生一个字节码文件.class
,在字节码文件中包含类的所有信息,如属性
,构造方法
,方法
······当字节码文件被装载进虚拟机执行时,会在内存中生成 Class 对象,它包含了该类内部的所有信息,在程序运行时可以获取这些信息。
获取 Class 对象的方法有3
种:
类名.class
:这种获取方式只有在编译
前已经声明了该类的类型才能获取到 Class 对象
Class clazz = SmallPineapple.class;
实例.getClass()
:通过实例化对象获取该实例的 Class 对象
SmallPineapple sp = new SmallPineapple();
Class clazz = sp.getClass();
Class.forName(className)
:通过类的全限定名获取该类的 Class 对象
Class clazz = Class.forName("com.bean.smallpineapple");
拿到 Class
对象就可以对它为所欲为了:剥开它的皮(获取类信息)、指挥它做事(调用它的方法),看透它的一切(获取属性),总之它就没有隐私了。
不过在程序中,每个类的 Class 对象只有一个,也就是说你只有这一个奴隶
。我们用上面三种方式测试,通过三种方式打印各个 Class
对象都是相同的。
Class clazz1 = Class.forName("com.bean.SmallPineapple");
Class clazz2 = SmallPineapple.class;
SmallPineapple instance = new SmallPineapple();
Class clazz3 = instance.getClass();
System.out.println("Class.forName() == SmallPineapple.class:" + (clazz1 == clazz2));
System.out.println("Class.forName() == instance.getClass():" + (clazz1 == clazz3));
System.out.println("instance.getClass() == SmallPineapple.class:" + (clazz2 == clazz3));
内存中只有一个 Class 对象的原因要牵扯到
JVM 类加载机制
的双亲委派模型
,它保证了程序运行时,加载类
时每个类在内存中仅会产生一个Class对象
。在这里我不打算详细展开说明,可以简单地理解为 JVM 帮我们保证了一个类在内存中至多存在一个 Class 对象。
构造类的实例化对象
通过反射构造一个类的实例方式有2
种:
- Class 对象调用
newInstance()
方法
Class clazz = Class.forName("com.bean.SmallPineapple");
SmallPineapple smallPineapple = (SmallPineapple) clazz.newInstance();
smallPineapple.getInfo();
// [null 的年龄是:0]
即使 SmallPineapple 已经显式定义了构造方法,通过 newInstance() 创建的实例中,所有属性值都是对应类型的初始值
,因为 newInstance() 构造实例会调用默认无参构造器。
- Constructor 构造器调用
newInstance()
方法
Class clazz = Class.forName("com.bean.SmallPineapple");
Constructor constructor = clazz.getConstructor(String.class, int.class);
constructor.setAccessible(true);
SmallPineapple smallPineapple2 = (SmallPineapple) constructor.newInstance("小菠萝", 21);
smallPineapple2.getInfo();
// [小菠萝 的年龄是:21]
通过 getConstructor(Object… paramTypes) 方法指定获取指定参数类型的 Constructor, Constructor 调用 newInstance(Object… paramValues) 时传入构造方法参数的值,同样可以构造一个实例,且内部属性已经被赋值。
通过Class
对象调用 newInstance() 会走默认无参构造方法,如果想通过显式构造方法构造实例,需要提前从Class中调用getConstructor()
方法获取对应的构造器,通过构造器去实例化对象。
这些 API 是在开发当中最常遇到的,当然还有非常多重载的方法,本文由于篇幅原因,且如果每个方法都一一讲解,我们也记不住,所以用到的时候去类里面查找就已经足够了。
获取一个类的所有信息
**Class 对象中包含了该类的所有信息,在编译期我们能看到的信息就是该类的变量、方法、构造器,在运行时最常被获取的也是这些信息。
获取类中的变量(Field)
- Field[] getFields():获取类中所有被
public
修饰的所有变量 - Field getField(String name):根据变量名获取类中的一个变量,该**变量必须被public修饰
- Field[] getDeclaredFields():获取类中所有的变量,但**无法获取继承下来的变量
- Field getDeclaredField(String name):根据姓名获取类中的某个变量,**无法获取继承下来的变量
获取类中的方法(Method)
-
Method[] getMethods():获取类中被
public
修饰的所有方法 -
Method getMethod(String name, Class…<?> paramTypes):根据名字和参数类型获取对应方法,该方法必须被
public
修饰 -
Method[] getDeclaredMethods():获取
所有
方法,但**无法获取继承下来的方法 -
Method getDeclaredMethod(String name, Class…<?> paramTypes):根据名字和参数类型获取对应方法,**无法获取继承下来的方法
获取类的构造器(Constructor)
- Constuctor[] getConstructors():获取类中所有被
public
修饰的构造器 - Constructor getConstructor(Class…<?> paramTypes):根据
参数类型
获取类中某个构造器,该构造器必须被public
修饰 - Constructor[] getDeclaredConstructors():获取类中所有构造器
- Constructor getDeclaredConstructor(class…<?> paramTypes):根据
参数类型
获取对应的构造器
每种功能内部以 Declared 细分为2
类:
有
Declared
修饰的方法:可以获取该类内部包含的所有变量、方法和构造器,但是**无法获取继承下来的信息无
Declared
修饰的方法:可以获取该类中public
修饰的变量、方法和构造器,可**获取继承下来的信息
如果想获取类中所有的(包括继承)变量、方法和构造器,则需要同时调用getXXXs()
和getDeclaredXXXs()
两个方法,用Set
集合存储它们获得的变量、构造器和方法,以防两个方法获取到相同的东西。
例如:要获取SmallPineapple获取类中所有的变量,代码应该是下面这样写。
Class clazz = Class.forName("com.bean.SmallPineapple");
// 获取 public 属性,包括继承
Field[] fields1 = clazz.getFields();
// 获取所有属性,不包括继承
Field[] fields2 = clazz.getDeclaredFields();
// 将所有属性汇总到 set
Set<Field> allFields = new HashSet<>();
allFields.addAll(Arrays.asList(fields1));
allFields.addAll(Arrays.asList(fields2));
不知道你有没有发现一件有趣的事情,如果父类的属性用
protected
修饰,利用反射是无法获取到的。protected 修饰符的作用范围:只允许
同一个包下
或者子类
访问,可以继承到子类。getFields() 只能获取到本类的
public
属性的变量值;getDeclaredFields() 只能获取到本类的所有属性,不包括继承的;无论如何都获取不到父类的 protected 属性修饰的变量,但是它的的确确存在于子类中。
获取注解
**获取注解单独拧了出来,因为它并不是专属于 Class 对象的一种信息,每个变量,方法和构造器都可以被注解修饰,所以在反射中,Field,Constructor 和 Method 类对象都可以调用下面这些方法获取标注在它们之上的注解。
- Annotation[] getAnnotations():获取该对象上的**所有注解
- Annotation getAnnotation(Class annotaionClass):传入
注解类型
,获取该对象上的特定一个注解 - Annotation[] getDeclaredAnnotations():获取该对象上的显式标注的所有注解,无法获取
继承
下来的注解 - Annotation getDeclaredAnnotation(Class annotationClass):根据
注解类型
,获取该对象上的特定一个注解,无法获取继承
下来的注解
只有注解的@Retension
标注为RUNTIME
时,才能够通过反射获取到该注解,@Retension 有3
种保存策略:
SOURCE
:只在源文件(.java)中保存,即该注解只会保留在源文件中,编译时编译器会忽略该注解,例如 @Override 注解CLASS
:保存在字节码文件(.class)中,注解会随着编译跟随字节码文件中,但是运行时不会对该注解进行解析RUNTIME
:一直保存到运行时,用得最多的一种保存策略,在运行时可以获取到该注解的所有信息
像下面这个例子,SmallPineapple 类继承了抽象类Pineapple
,getInfo()
方法上标识有 @Override 注解,且在子类中标注了@Transient
注解,在运行时获取子类重写方法上的所有注解,只能获取到@Transient
的信息。
public abstract class Pineapple {
public abstract void getInfo();
}
public class SmallPineapple extends Pineapple {
@Transient
@Override
public void getInfo() {
System.out.print("小菠萝的身高和年龄是:" + height + "cm ; " + age + "岁");
}
}
启动类Bootstrap
获取 SmallPineapple 类中的 getInfo() 方法上的注解信息:
public class Bootstrap {
/
* 根据运行时传入的全类名路径判断具体的类对象
* @param path 类的全类名路径
*/
public static void execute(String path) throws Exception {
Class obj = Class.forName(path);
Method method = obj.getMethod("getInfo");
Annotation[] annotations = method.getAnnotations();
for (Annotation annotation : annotations) {
System.out.println(annotation.toString());
}
}
public static void main(String[] args) throws Exception {
execute("com.pineapple.SmallPineapple");
}
}
// @java.beans.Transient(value=true)
通过反射调用方法
通过反射获取到某个 Method 类对象后,可以通过调用invoke
方法执行。
invoke(Oject obj, Object... args)
:参数`1
指定调用该方法的对象,参数2
是方法的参数列表值。
如果调用的方法是静态方法,参数1只需要传入null
,因为静态方法不与某个对象有关,只与某个类有关。
可以像下面这种做法,通过反射实例化一个对象,然后获取Method
方法对象,调用invoke()
指定SmallPineapple
的getInfo()
方法。
Class clazz = Class.forName("com.bean.SmallPineapple");
Constructor constructor = clazz.getConstructor(String.class, int.class);
constructor.setAccessible(true);
SmallPineapple sp = (SmallPineapple) constructor.newInstance("小菠萝", 21);
Method method = clazz.getMethod("getInfo");
if (method != null) {
method.invoke(sp, null);
}
// [小菠萝的年龄是:21]
反射的应用场景
反射常见的应用场景这里介绍3
个:
- Spring 实例化对象:当程序启动时,Spring 会读取配置文件
applicationContext.xml
并解析出里面所有的标签实例化到 IOC
容器中。 - 反射 + 工厂模式:通过
反射
消除工厂中的多个分支,如果需要生产新的类,无需关注工厂类,工厂类可以应对各种新增的类,反射
可以使得程序更加健壮。 - JDBC连接数据库:使用JDBC连接数据库时,指定连接数据库的
驱动类
时用到反射加载驱动类
Spring 的 IOC 容器
在 Spring 中,经常会编写一个上下文配置文件applicationContext.xml
,里面就是关于bean
的配置,程序启动时会读取该 xml 文件,解析出所有的 <bean>
标签,并实例化对象放入IOC
容器中。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="smallpineapple" class="com.bean.SmallPineapple">
<constructor-arg type="java.lang.String" value="小菠萝"/>
<constructor-arg type="int" value="21"/>
</bean>
</beans>
在定义好上面的文件后,通过ClassPathXmlApplicationContext
加载该配置文件,程序启动时,Spring 会将该配置文件中的所有bean
都实例化,放入 IOC 容器中,IOC 容器本质上就是一个工厂,通过该工厂传入 \ id
属性获取到对应的实例。
public class Main {
public static void main(String[] args) {
ApplicationContext ac =
new ClassPathXmlApplicationContext("applicationContext.xml");
SmallPineapple smallPineapple = (SmallPineapple) ac.getBean("smallpineapple");
smallPineapple.getInfo(); // [小菠萝的年龄是:21]
}
}
Spring 在实例化对象的过程经过简化之后,可以理解为反射实例化对象的步骤:
- **获取Class对象的构造器
- 通过构造器调用 newInstance() 实例化对象
当然 Spring 在实例化对象时,做了非常多额外的操作,才能够让现在的开发足够的便捷且稳定。
在之后的文章中会专门写一篇文章讲解如何利用反射实现一个
简易版
的IOC
容器,IOC容器原理很简单,只要掌握了反射的思想,了解反射的常用 API 就可以实现,我可以提供一个简单的思路:利用 HashMap 存储所有实例,key 代表 \标签的 id
,value 存储对应的实例,这对应了 Spring IOC容器管理的对象默认是单例的。
反射 + 抽象工厂模式
传统的工厂模式,如果需要生产新的子类,需要修改工厂类,在工厂类中增加新的分支;
public class MapFactory {
public Map<Object, object> produceMap(String name) {
if ("HashMap".equals(name)) {
return new HashMap<>();
} else if ("TreeMap".equals(name)) {
return new TreeMap<>();
} // ···
}
}
利用反射和工厂模式相结合,在产生新的子类时,工厂类不用修改任何东西,可以专注于子类的实现,**当子类确定下来时,工厂也就可以生产该子类了。
反射 + 抽象工厂的核心思想是:
- 在运行时通过参数传入不同子类的全限定名获取到不同的 Class 对象,调用 newInstance() 方法返回不同的子类。细心的读者会发现提到了子类这个概念,所以反射 + 抽象工厂模式,一般会用于有继承或者接口实现关系。
例如,在运行时才确定使用哪一种 Map
结构,我们可以利用反射传入某个具体 Map 的全限定名,实例化一个特定的子类。
public class MapFactory {
/
* @param className 类的全限定名
*/
public Map<Object, Object> produceMap(String className) {
Class clazz = Class.forName(className);
Map<Object, Object> map = clazz.newInstance();
return map;
}
}
className
可以指定为 java.util.HashMap,或者 java.util.TreeMap 等等,根据业务场景来定。
JDBC 加载数据库驱动类
在导入第三方库时,JVM不会主动去加载外部导入的类,而是等到真正使用时,才去加载需要的类,正是如此,我们可以在获取数据库连接时传入驱动类的全限定名,交给 JVM 加载该类。
public class DBConnectionUtil {
/** 指定数据库的驱动类 */
private static final String DRIVER_CLASS_NAME = "com.mysql.jdbc.Driver";
public static Connection getConnection() {
Connection conn = null;
// 加载驱动类
Class.forName(DRIVER_CLASS_NAME);
// 获取数据库连接对象
conn = DriverManager.getConnection("jdbc:mysql://···", "root", "root");
return conn;
}
}
在我们开发 SpringBoot 项目时,会经常遇到这个类,但是可能习惯成自然了,就没多大在乎,我在这里给你们看看常见的application.yml
中的数据库配置,我想你应该会恍然大悟吧。
这里的 driver-class-name,和我们一开始加载的类是不是觉得很相似,这是因为MySQL版本不同引起的驱动类不同,这体现使用反射的好处:不需要修改源码,仅加载配置文件就可以完成驱动类的替换。
在之后的文章中会专门写一篇文章详细地介绍反射的应用场景,实现简单的
IOC
容器以及通过反射实现工厂模式的好处。在这里,你只需要掌握反射的基本用法和它的思想,了解它的主要使用场景。
反射的优势及缺陷
反射的优点:
- 增加程序的灵活性:面对需求变更时,可以灵活地实例化不同对象
但是,有得必有失,一项技术不可能只有优点没有缺点,反射也有两个比较隐晦的缺点:
- 破坏类的封装性:可以强制访问 private 修饰的信息
- 性能损耗:反射相比直接实例化对象、调用方法、访问变量,中间需要非常多的检查步骤和解析步骤,JVM无法对它们优化。
增加程序的灵活性
这里不再用 SmallPineapple 举例了,我们来看一个更加贴近开发
的例子:
- 利用反射连接数据库,涉及到数据库的数据源。在 SpringBoot 中一切约定大于配置,想要定制配置时,使用
application.properties
配置文件指定数据源
角色1 – Java的设计者:我们设计好DataSource
接口,你们其它数据库厂商想要开发者用你们的数据源
监控数据库,就得实现我的这个接口
!
角色2 – 数据库厂商:
- MySQL 数据库厂商:我们提供了 com.mysql.cj.jdbc.MysqlDataSource 数据源,开发者可以使用它连接 MySQL。
- 阿里巴巴厂商:我们提供了 com.alibaba.druid.pool.DruidDataSource 数据源,我这个数据源更牛逼,具有页面监控,慢SQL日志记录等功能,开发者快来用它监控 MySQL吧!
- SQLServer 厂商:我们提供了 com.microsoft.sqlserver.jdbc.SQLServerDataSource 数据源,如果你想实用SQL Server 作为数据库,那就使用我们的这个数据源连接吧
角色3 – 开发者:我们可以用配置文件
指定使用DruidDataSource
数据源
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
需求变更:某一天,老板来跟我们说,Druid 数据源不太符合我们现在的项目了,我们使用 MysqlDataSource 吧,然后程序猿就会修改配置文件,重新加载配置文件,并重启项目,完成数据源的切换。
spring.datasource.type=com.mysql.cj.jdbc.MysqlDataSource
在改变连接数据库的数据源时,只需要改变配置文件即可,无需改变任何代码,原因是:
- **Spring Boot 底层封装好了连接数据库的数据源配置,利用反射,适配各个数据源。
下面来简略的进行源码分析。我们用ctrl+左键
点击spring.datasource.type
进入 DataSourceProperties 类中,发现使用setType() 将全类名转化为 Class 对象注入到type
成员变量当中。在连接并监控数据库时,就会使用指定的数据源操作。
private Class<? extends DataSource> type;
public void setType(Class<? extends DataSource> type) {
this.type = type;
}
Class
对象指定了泛型上界DataSource
,我们去看一下各大数据源的类图结构
。
**上图展示了一部分数据源,当然不止这些,但是我们可以看到,无论指定使用哪一种数据源,我们都只需要与配置文件打交道,而无需更改源码,这就是反射的灵活性!
破坏类的封装性
很明显的一个特点,反射可以获取类中被private
修饰的变量、方法和构造器,这违反了面向对象的封装特性,因为被 private 修饰意味着不想对外暴露,只允许本类访问,而setAccessable(true)
可以无视访问修饰符的限制,外界可以强制访问。
还记得单例模式
一文吗?里面讲到反射破坏饿汉式和懒汉式单例模式,所以之后用了枚举
避免被反射KO。
回到最初的起点,SmallPineapple 里有一个 weight 属性被 private 修饰符修饰,目的在于自己的体重并不想给外界知道。
public class SmallPineapple {
public String name;
public int age;
private double weight; // 体重只有自己知道
public SmallPineapple(String name, int age, double weight) {
this.name = name;
this.age = age;
this.weight = weight;
}
}
虽然 weight 属性理论上只有自己知道,但是如果经过反射,这个类就像在裸奔一样,在反射面前变得一览无遗
。
SmallPineapple sp = new SmallPineapple("小菠萝", 21, "54.5");
Clazz clazz = Class.forName(sp.getClass());
Field weight = clazz.getDeclaredField("weight");
weight.setAccessable(true);
System.out.println("窥觑到小菠萝的体重是:" + weight.get(sp));
// 窥觑到小菠萝的体重是:54.5 kg
性能损耗
**在直接 new 对象并调用对象方法和访问属性时,编译器会在编译期提前检查可访问性,如果尝试进行不正确的访问,IDE会提前提示错误,例如参数传递类型不匹配,非法访问 private 属性和方法。
而在利用反射操作对象时,编译器无法提前得知对象的类型,访问是否合法,参数传递类型是否匹配。只有在程序运行时调用反射的代码时才会从头开始检查、调用、返回结果,JVM也无法对反射的代码进行优化。
虽然反射具有性能损耗的特点,但是我们不能一概而论,产生了使用反射就会性能下降的思想,反射的慢,需要同时调用上100W
次才可能体现出来,在几次、几十次的调用,并不能体现反射的性能低下。所以不要一味地戴有色眼镜看反射,**在单次调用反射的过程中,性能损耗可以忽略不计。如果程序的性能要求很高,那么尽量不要使用反射。
反射基础篇文末总结
- 反射的思想:反射就像是一面镜子一样,在运行时才看到自己是谁,可获取到自己的信息,甚至实例化对象。
- 反射的作用:在运行时才确定实例化对象,使程序更加健壮,面对需求变更时,可以最大程度地做到不修改程序源码应对不同的场景,实例化不同类型的对象。
- 反射的应用场景常见的有
3
个:Spring的 IOC 容器,反射+工厂模式 使工厂类更稳定,JDBC连接数据库时加载驱动类 - 反射的
3
个特点:增加程序的灵活性、破坏类的封装性以及性能损耗
谈谈强引用、软引用、弱引用、幻象引用
我们说的不同的引用类型其实都是逻辑上的,而对于虚拟机来说,主要体现的是对象的不同的可达性(reachable)
状态和对垃圾收集(garbage collector)
的影响。
初识引用
对于刚接触 Java 的 C++ 程序员而言,理解栈和堆的关系可能很不习惯。在 C++ 中,可以使用 new 操作符在堆上创建对象,或者使用自动分配在栈上创建对象。下面的 C++ 语句是合法的,但是 Java 编译器却拒绝这么写代码,会出现 syntax error
编译错误。
Integer foo = Integer(1);
Java 和 C 不一样,Java 中会把对象都放在堆上,需要 new 操作符来创建对象。本地变量存储在栈
中,它们持有一个指向堆中对象的引用(指针)
。下面是一个 Java 方法,该方法具有一个 Integer 变量,该变量从 String 解析值
public static void foo(String bar){
Integer baz = new Integer(bar);
}
这段代码我们使用堆栈分配图可以看一下它们的关系
首先先来看一下 foo()
方法,这一行代码分配了一个新的 Integer 对象,JVM 尝试在堆空间中开辟一块内存空间。如果允许分配的话,就会调用 Integer 的构造方法把 String 字符串转换为 Integer 对象。JVM 将指向该对象的指针存储在变量 baz 中。
上面这种情况是我们乐意看到的情况,毕竟我们不想在编写代码的时候遇到阻碍,但是这种情况是不可能出现的,当堆空间无法为 bar 和 baz 开辟内存空间时,就会出现 OutOfMemoryError
,然后就会调用垃圾收集器(garbage collector)
来尝试腾出内存空间。这中间涉及到一个问题,垃圾收集器会回收哪些对象?
垃圾收集器
Java 给你提供了一个 new 操作符来为堆中的对象开辟内存空间,但它没有提供 delete
操作符来释放对象空间。当 foo() 方法返回时,如果变量 baz 超过最大内存,但它所指向的对象仍然还在堆中。如果没有垃圾回收器的话,那么程序就会抛出 OutOfMemoryError
错误。然而 Java 不会,它会提供垃圾收集器来释放不再引用的对象。
当程序尝试创建新对象并且堆中没有足够的空间时,垃圾收集器就开始工作。当收集器访问堆时,请求线程被挂起,试图查找程序不再主动使用的对象,并回收它们的空间。如果垃圾收集器无法释放足够的内存空间,并且JVM 无法扩展堆,则会出现 OutOfMemoryError
,你的应用程序通常在这之后崩溃。还有一种情况是 StackOverflowError
,它出现的原因是因为线程请求的栈深度要大于虚拟机所允许的深度时出现的错误。
标记 – 清除算法
Java 能永久不衰的一个原因就是因为垃圾收集器。许多人认为 JVM 会为每个对象保留一个引用计数,当每次引用对象的时候,引用计数器的值就 + 1,当引用失效的时候,引用计数器的值就 – 1。而垃圾收集器只会回收引用计数器的值为 0 的情况。这其实是 引用计数法(Reference Counting)
的收集方式。但是这种方式无法解决对象之间相会引用的问题,如下
class A{
public B b;
}
class B{
public A a;
}
public class Main{
public static void main(String[] args){
A a = new A();
B b = new B();
a.b=b;
b.a=a;
}
}
然而实际上,JVM 使用一种叫做 标记-清除(Mark-Sweep)
的算法,标记清除垃圾回收背后的想法很简单:程序无法到达的每个对象都是垃圾,可以进行回收。
标记-清除收集具有如下几个阶段
- 阶段一:标记
垃圾收集器会从 根(root)
引用开始,标记它到达的所有对象。如果用老师给学生判断卷子来比喻,这就相当于是给试卷上的全部答案判断正确还是错误的过程。
- 阶段二:清理
在第一阶段中所有可回收的的内容都能够被垃圾收集器进行回收。如果一个对象被判定为是可以回收的对象,那么这个对象就被放在一个 finalization queue(回收队列)
中,并在稍后会由一个虚拟机自动建立的、低优先级的 finalizer
线程去执行它。
- 阶段三:整理(可选)
一些收集器有第三个步骤,整理。在这个步骤中,GC 将对象移动到垃圾收集器回收完对象后所留下的自由空间中。这么做可以防止堆碎片化,防止大对象在堆中由于堆空间的不连续性而无法分配的情况。
所以上面的过程中就涉及到一个根节点(GC Roots)
来判断是否存在需要回收的对象。这个算法的基本思想就是通过一系列的 GC Roots
作为起始点,从这些节点向下搜索,搜索所走过的路径称为 引用链(Reference Chain)
,当一个对象到 GC Roots 之间没有任何引用链相连的话,则证明此对象不可用。引用链上的任何一个能够被访问的对象都是强引用
对象,垃圾收集器不会回收强引用对象。
因此,返回到 foo() 方法中,仅在执行方法时,参数 bar 和局部变量 baz 才是强引用。一旦方法执行完成,它们都超过了作用域的时候,它们引用的对象都会进行垃圾回收。
下面来考虑一个例子
LinkedList foo = new LinkedList();
foo.add(new Integer(111));
变量 foo 是一个强引用,它指向一个 LinkedList 对象。LinkedList(JDK.18) 是一个链表的数据结构,每一个元素都会指向前驱元素,每个元素都有其后继元素。
当我们调用add()
方法时,我们会增加一个新的链表元素,并且该链表元素指向值为 111 的 Integer 实例。这是一连串的强引用,也就是说,这个 Integer 的实例不符合垃圾收集条件。一旦 foo 对象超出了程序运行的作用域,LinkedList 和其中的引用内容都可以进行收集,收集的前提是没有强引用关系。
Finalizers
C++ 允许对象定义析构函数方法:当对象超出作用范围或被明确删除时,会调用析构函数来清理使用的资源。对于大多数对象来说,析构函数能够释放使用 new 或者 malloc 函数分配的内存。 在Java中,垃圾收集器会为你自动清除对象,分配内存,因此不需要显式析构函数即可执行此操作。这也是 Java 和 C++ 的一大区别。
然而,内存并不是唯一需要被释放的资源。考虑 FileOutputStream
:当你创建此对象的实例时,它从操作系统分配文件句柄。如果你让流的引用在关闭前超过了其作用范围,该文件句柄会怎么样?实际上,每个流都会有一个 finalizer
方法,这个方法是垃圾回收器在回收之前由 JVM 调用的方法。对于 FileOutputStream 来说,finalizer 方法会关闭流,释放文件句柄给操作系统,然后清除缓冲区,确保数据能够写入磁盘。
任何对象都具有 finalizer 方法,你要做的就是声明 finalize()
方法。如下
protected void finalize() throws Throwable
{
// 清除对象
}
虽然 finalizers 的 finalize() 方法是一种好的清除方式,但是这种方法产生的负面影响非常大,你不应该依靠这个方法来做任何垃圾回收工作。因为 finalize
方法的运行开销比较大,不确定性强,无法保证各个对象的调用顺序。finalize 能做的任何事情,可以使用 try-finally
或者其他方式来做,甚至做的更好。
对象的生命周期
综上所述,可以通过下面的流程来对对象的生命周期做一个总结
对象被创建并初始化,对象在运行时被使用,然后离开对象的作用域,对象会变成不可达并会被垃圾收集器回收。图中用红色标明的区域表示对象处于强可达阶段。
JDK1.2 介绍了 java.lang.ref
包,对象的生命周期有四个阶段:强可达(Strongly Reachable)
、软可达(Soft Reachable)
、弱可达(Weak Reachable)
、 幻象可达(Phantom Reachable)
。
如果只讨论符合垃圾回收条件的对象,那么只有三种:软可达、弱可达和幻象可达。
-
软可达:软可达就是我们只能通过软引用才能访问的状态,软可达的对象是由
SoftReference
引用的对象,并且没有强引用的对象。软引用是用来描述一些还有用但是非必须的对象。垃圾收集器会尽可能长时间的保留软引用的对象,但是会在发生OutOfMemoryError
之前,回收软引用的对象。如果回收完软引用的对象,内存还是不够分配的话,就会直接抛出 OutOfMemoryError。 -
弱可达:弱可达的对象是
WeakReference
引用的对象。垃圾收集器可以随时收集弱引用的对象,不会尝试保留软引用的对象。 -
幻象可达:幻象可达是由
PhantomReference
引用的对象,幻象可达就是没有强、软、弱引用进行关联,并且已经被 finalize 过了,只有幻象引用指向这个对象的时候。
除此之外,还有强可达和不可达的两种可达性判断条件
- 强可达:就是一个对象刚被创建、初始化、使用中的对象都是处于强可达的状态
不可达(unreachable)
:处于不可达的对象就意味着对象可以被清除了。
下面是一个不同可达性状态的转换图
判断可达性条件,也是 JVM 垃圾收集器决定如何处理对象的一部分考虑因素。
所有的对象可达性引用都是 java.lang.ref.Reference
的子类,它里面有一个get()
方法,返回引用对象。 如果已通过程序或垃圾收集器清除了此引用对象,则此方法返回 null 。也就是说,除了幻象引用外,软引用和弱引用都是可以得到对象的。而且这些对象可以人为拯救
,变为强引用,例如把 this 关键字赋值给对象,只要重新和引用链上的任意一个对象建立关联即可。
ReferenceQueue
引用队列
又称为 ReferenceQueue
,它位于 java.lang.ref 包下。我们在建各种引用(软引用,弱引用,幻象引用)并关联到响应对象时,可以选择是否需要关联引用队列。JVM 会在特定的时机将引用入队到队列中,程序可以通过判断引用队列中是否已经加入引用,来了解被引用的对象是否被GC回收。
Reference
java.lang.ref.Reference 为软(soft)引用、弱(weak)引用、虚(phantom)引用的父类。因为 Reference 对象和垃圾回收密切配合实现,该类可能不能被直接子类化。
看完这篇HashMap,和面试官扯皮就没问题了
- 看完这篇 HashMap,和面试官扯皮就没问题了
HashMap 概述
如果你没有时间细抠本文,可以直接看 HashMap 概述,能让你对 HashMap 有个大致的了解。
HashMap 是 Map 接口的实现,HashMap 允许空的 key-value 键值对,HashMap 被认为是 Hashtable 的增强版,HashMap 是一个非线程安全的容器,如果想构造线程安全的 Map 考虑使用 ConcurrentHashMap。HashMap 是无序的,因为 HashMap 无法保证内部存储的键值对的有序性。
HashMap 的底层数据结构是数组 + 链表的集合体,数组在 HashMap 中又被称为桶(bucket)
。遍历 HashMap 需要的时间损耗为 HashMap 实例桶的数量 + (key – value 映射) 的数量。因此,如果遍历元素很重要的话,不要把初始容量设置的太高或者负载因子设置的太低。
HashMap 实例有两个很重要的因素,初始容量和负载因子,初始容量指的就是 hash 表桶的数量,负载因子是一种衡量哈希表填充程度的标准,当哈希表中存在足够数量的 entry,以至于超过了负载因子和当前容量,这个哈希表会进行 rehash 操作,内部的数据结构重新 rebuilt。
注意 HashMap 不是线程安全的,如果多个线程同时影响了 HashMap ,并且至少一个线程修改了 HashMap 的结构,那么必须对 HashMap 进行同步操作。可以使用 Collections.synchronizedMap(new HashMap)
来创建一个线程安全的 Map。
HashMap 会导致除了迭代器本身的 remove 外,外部 remove 方法都可能会导致 fail-fast 机制,因此尽量要用迭代器自己的 remove 方法。如果在迭代器创建的过程中修改了 map 的结构,就会抛出 ConcurrentModificationException
异常。
下面就来聊一聊 HashMap 的细节问题。我们还是从面试题入手来分析 HashMap 。
HashMap 和 HashTable 的区别
我们上面介绍了一下 HashMap ,现在来介绍一下 HashTable
相同点
HashMap 和 HashTable 都是基于哈希表实现的,其内部每个元素都是 key-value
键值对,HashMap 和 HashTable 都实现了 Map、Cloneable、Serializable 接口。
不同点
-
父类不同:HashMap 继承了
AbstractMap
类,而 HashTable 继承了Dictionary
类 -
空值不同:HashMap 允许空的 key 和 value 值,HashTable 不允许空的 key 和 value 值。HashMap 会把 Null key 当做普通的 key 对待。不允许 null key 重复。
-
线程安全性:HashMap 不是线程安全的,如果多个外部操作同时修改 HashMap 的数据结构比如 add 或者是 delete,必须进行同步操作,仅仅对 key 或者 value 的修改不是改变数据结构的操作。可以选择构造线程安全的 Map 比如
Collections.synchronizedMap
或者是ConcurrentHashMap
。而 HashTable 本身就是线程安全的容器。 -
性能方面:虽然 HashMap 和 HashTable 都是基于单链表的,但是 HashMap 进行 put 或者 get 操作,可以达到常数时间的性能;而 HashTable 的 put 和 get 操作都是加了
synchronized
锁的,所以效率很差。
-
初始容量不同:HashTable 的初始长度是11,之后每次扩充容量变为之前的 2n+1(n为上一次的长度)
而 HashMap 的初始长度为16,之后每次扩充变为原来的两倍。创建时,如果给定了容量初始值,那么HashTable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。
HashMap 和 HashSet 的区别
也经常会问到 HashMap 和 HashSet 的区别
HashSet 继承于 AbstractSet 接口,实现了 Set、Cloneable,、java.io.Serializable 接口。HashSet 不允许集合中出现重复的值。HashSet 底层其实就是 HashMap,所有对 HashSet 的操作其实就是对 HashMap 的操作。所以 HashSet 也不保证集合的顺序。
HashMap 底层结构
要了解一个类,先要了解这个类的结构,先来看一下 HashMap 的结构:
最主要的三个类(接口)就是 HashMap
,AbstractMap
和 Map
了,HashMap 我们上面已经在概述中简单介绍了一下,下面来介绍一下 AbstractMap。
AbstractMap 类
这个抽象类是 Map 接口的骨干实现,以求最大化的减少实现类的工作量。为了实现不可修改的 map,程序员仅需要继承这个类并且提供 entrySet 方法的实现即可。它将会返回一组 map 映射的某一段。通常,返回的集合将在AbstractSet 之上实现。这个set不应该支持 add 或者 remove 方法,并且它的迭代器也不支持 remove 方法。
为了实现可修改的 map,程序员必须额外重写这个类的 put 方法(否则就会抛出UnsupportedOperationException),并且 entrySet.iterator() 返回的 iterator 必须实现 remove() 方法。
Map 接口
Map 接口定义了 key-value 键值对的标准。一个对象支持 key-value 存储。Map不能包含重复的 key,每个键最多映射一个值。这个接口代替了Dictionary 类,Dictionary是一个抽象类而不是接口。
Map 接口提供了三个集合的构造器,它允许将 map 的内容视为一组键,值集合或一组键值映射。map的顺序定义为map映射集合上的迭代器返回其元素的顺序。一些map实现,像是TreeMap类,保证了map的有序性;其他的实现,像是HashMap,则没有保证。
重要内部类和接口
Node 接口
Node节点是用来存储HashMap的一个个实例,它实现了 Map.Entry
接口,我们先来看一下 Map中的内部接口 Entry 接口的定义
Map.Entry
// 一个map 的entry 链,这个Map.entrySet()方法返回一个集合的视图,包含类中的元素,
// 这个唯一的方式是从集合的视图进行迭代,获取一个map的entry链。这些Map.Entry链只在
// 迭代期间有效。
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
}
Node 节点会存储四个属性,hash值,key,value,指向下一个Node节点的引用
// hash值
final int hash;
// 键
final K key;
// 值
V value;
// 指向下一个Node节点的Node类型
Node<K,V> next;
因为Map.Entry 是一条条entry 链连接在一起的,所以Node节点也是一条条entry链。构造一个新的HashMap实例的时候,会把这四个属性值分为传入
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
实现了 Map.Entry 接口所以必须实现其中的方法,所以 Node 节点中也包括上面的五个方法
KeySet 内部类
keySet 类继承于 AbstractSet 抽象类,它是由 HashMap 中的 keyset()
方法来创建 KeySet 实例的,旨在对HashMap 中的key键进行操作,看一个代码示例
图中把1, 2, 3这三个key 放在了HashMap中,然后使用 lambda 表达式循环遍历 key 值,可以看到,map.keySet() 其实是返回了一个 Set 接口,KeySet() 是在 Map 接口中进行定义的,不过是被HashMap 进行了实现操作,来看一下源码就明白了
// 返回一个set视图,这个视图中包含了map中的key。
public Set<K> keySet() {
// // keySet 指向的是 AbstractMap 中的 keyset
Set<K> ks = keySet;
if (ks == null) {
// 如果 ks 为空,就创建一个 KeySet 对象
// 并对 ks 赋值。
ks = new KeySet();
keySet = ks;
}
return ks;
}
所以 KeySet 类中都是对 Map中的 Key 进行操作的:
Values 内部类
Values 类的创建其实是和 KeySet 类很相似,不过 KeySet 旨在对 Map中的键进行操作,Values 旨在对key-value
键值对中的 value 值进行使用,看一下代码示例:
循环遍历 Map中的 values值,看一下 values() 方法最终创建的是什么:
public Collection<V> values() {
// values 其实是 AbstractMap 中的 values
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
所有的 values 其实都存储在 AbstractMap 中,而 Values 类其实也是实现了 Map 中的 Values 接口,看一下对 values 的操作都有哪些方法
其实是和 key 的操作差不多
EntrySet 内部类
上面提到了HashMap中分别有对 key、value 进行操作的,其实还有对 key-value
键值对进行操作的内部类,它就是 EntrySet,来看一下EntrySet 的创建过程:
点进去 entrySet() 会发现这个方法也是在 Map 接口中定义的,HashMap对它进行了重写
// 返回一个 set 视图,此视图包含了 map 中的key-value 键值对
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
如果 es 为空创建一个新的 EntrySet 实例,EntrySet 主要包括了对key-value 键值对映射的方法,如下
HashMap 1.7 的底层结构
JDK1.7 中,HashMap 采用位桶 + 链表的实现,即使用链表来处理冲突,同一 hash 值的链表都存储在一个数组中。但是当位于一个桶中的元素较多,即 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。它的数据结构如下
HashMap 底层数据结构就是一个 Entry 数组,Entry 是 HashMap 的基本组成单元,每个 Entry 中包含一个 key-value 键值对。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
而每个 Entry 中包含 hash, key ,value 属性,它是 HashMap 的一个内部类
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
...
}
所以,HashMap 的整体结构就像下面这样
HashMap 1.8 的底层结构
与 JDK 1.7 相比,1.8 在底层结构方面做了一些改变,当每个桶中元素大于 8 的时候,会转变为红黑树,目的就是优化查询效率,JDK 1.8 重写了 resize()
方法。
HashMap 重要属性
**初始容量
HashMap 的默认初始容量是由 DEFAULT_INITIAL_CAPACITY
属性管理的。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
HashMaap 的默认初始容量是 1 << 4 = 16, << 是一个左移
操作,它相当于是
**最大容量
HashMap 的最大容量是
static final int MAXIMUM_CAPACITY = 1 << 30;
这里是不是有个疑问?int 占用四个字节,按说最大容量应该是左移 31 位,为什么 HashMap 最大容量是左移 30 位呢?因为在数值计算中,最高位也就是最左位的位
是代表着符号为,0 -> 正数,1 -> 负数,容量不可能是负数,所以 HashMap 最高位只能移位到 2 ^ 30 次幂。
**默认负载因子
HashMap 的默认负载因子是
static final float DEFAULT_LOAD_FACTOR = 0.75f;
float 类型所以用 .f
为单位,负载因子是和扩容机制有关,这里大致提一下,后面会细说。扩容机制的原则是当 HashMap 中存储的数量 > HashMap 容量 * 负载因子时,就会把 HashMap 的容量扩大为原来的二倍。
HashMap 的第一次扩容就在 DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12 时进行。
**树化阈值
HashMap 的树化阈值是
static final int TREEIFY_THRESHOLD = 8;
在进行添加元素时,当一个桶中存储元素的数量 > 8 时,会自动转换为红黑树(JDK1.8 特性)。
**链表阈值
HashMap 的链表阈值是
static final int UNTREEIFY_THRESHOLD = 6;
在进行删除元素时,如果一个桶中存储元素数量 < 6 后,会自动转换为链表
**扩容临界值
static final int MIN_TREEIFY_CAPACITY = 64;
这个值表示的是当桶数组容量小于该值时,优先进行扩容,而不是树化
**节点数组
HashMap 中的节点数组就是 Entry 数组,它代表的就是 HashMap 中 数组 + 链表 数据结构中的数组。
transient Node<K,V>[] table;
Node 数组在第一次使用的时候进行初始化操作,在必要的时候进行 resize
,resize 后数组的长度扩容为原来的二倍。
**键值对数量
在 HashMap 中,使用 size
来表示 HashMap 中键值对的数量。
**修改次数
在 HashMap 中,使用 modCount
来表示修改次数,主要用于做并发修改 HashMap 时的快速失败 – fail-fast 机制。
**扩容阈值
在 HashMap 中,使用 threshold
表示扩容的阈值,也就是 初始容量 * 负载因子的值。
threshold 涉及到一个扩容的阈值问题,这个问题是由 tableSizeFor
源码解决的。我们先看一下它的源码再来解释
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;
}
代码中涉及一个运算符 |=
,它表示的是按位或,啥意思呢?你一定知道 a+=b 的意思是 a=a+b,那么 同理:a |= b 就是 a = a | b ,也就是双方都转换为二进制,来进行与操作。如下图所示
我们上面采用了一个比较大的数字进行扩容,由上图可知 2^29 次方的数组经过一系列的或操作后,会算出来结果是 2^30 次方。
所以扩容后的数组长度是原来的 2 倍。
**负载因子
loadFactor
表示负载因子,它表示的是 HashMap 中的密集程度。
HashMap 构造函数
在 HashMap 源码中,有四种构造函数,分别来介绍一下
- 带有
初始容量 initialCapacity
和负载因子 loadFactor
的构造函数
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);
}
初始容量不能为负,所以当传递初始容量 < 0 的时候,会直接抛出 IllegalArgumentException
异常。如果传递进来的初始容量 > 最大容量时,初始容量 = 最大容量。负载因子也不能小于 0 。然后进行数组的扩容,这个扩容机制也非常重要,我们后面进行探讨
- 只带有 initialCapacity 的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
最终也会调用到上面的构造函数,不过这个默认的负载因子就是 HashMap 的默认负载因子也就是 0.75f
- 无参数的构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
默认的负载因子也就是 0.75f
- 带有 map 的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
带有 Map 的构造函数,会直接把外部元素批量放入 HashMap 中。
讲一讲 HashMap put 的全过程
我记得刚毕业一年去北京面试,一家公司问我 HashMap put 过程的时候,我支支吾吾答不上来,后面痛下决心好好整。以 JDK 1.8 为基准进行分析,后面也是。先贴出整段代码,后面会逐行进行分析。
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 为null 或者没有为 table 分配内存,就resize一次
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 指定hash值节点为空则直接插入,这个(n - 1) & hash才是表中真正的哈希
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果不为空
else {
Node<K,V> e; K k;
// 计算表中的这个真正的哈希值与要插入的key.hash相比
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);
// key.hash 不同并且也不再 TreeNode 上,在链表上找到 p.next==null
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 在表尾插入
p.next = newNode(hash, key, value, null);
// 新增节点后如果节点个数到达阈值,则进入 treeifyBin() 进行再次判断
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到了同 hash、key 的节点,那么直接退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 更新 p 指向下一节点
p = e;
}
}
// map中含有旧值,返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// map调整次数 + 1
++modCount;
// 键值对的数量达到阈值,需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
首先看一下 putVal
方法,这个方法是 final 的,如果你自已定义 HashMap 继承的话,是不允许你自己重写 put 方法的,然后这个方法涉及五个参数
- hash -> put 放在桶中的位置,在 put 之前,会进行 hash 函数的计算。
- key -> 参数的 key 值
- value -> 参数的 value 值
- onlyIfAbsent -> 是否改变已经存在的值,也就是是否进行 value 值的替换标志
- evict -> 是否是刚创建 HashMap 的标志
在调用到 putVal 方法时,首先会进行 hash 函数计算应该插入的位置
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
哈希函数的源码如下
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先先来理解一下 hash 函数的计算规则
Hash 函数
hash 函数会根据你传递的 key 值进行计算,首先计算 key 的 hashCode
值,然后再对 hashcode 进行无符号右移操作,最后再和 hashCode 进行异或 ^
操作。
>>>
: 无符号右移操作,它指的是 无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0 ,也就是不管是正数还是负数,右移都会在空缺位补 0 。
在得到 hash 值后,就会进行 put 过程。
首先会判断 HashMap 中的 Node 数组是否为 null,如果第一次创建 HashMap 并进行第一次插入元素,首先会进行数组的 resize,也就是重新分配
,这里还涉及到一个 resize()
扩容机制源码分析,我们后面会介绍。扩容完毕后,会计算出 HashMap 的存放位置,通过使用 ( n – 1 ) & hash 进行计算得出。
然后会把这个位置作为数组的下标作为存放元素的位置。如果不为空,那么计算表中的这个真正的哈希值与要插入的 key.hash 相比。如果哈希值相同,key-value 不一样,再判断是否是树的实例,如果是的话,那么就把它插入到树上。如果不是,就执行尾插法在 entry 链尾进行插入。
会根据桶中元素的数量判断是链表还是红黑树。然后判断键值对数量是否大于阈值,大于的话则进行扩容。
扩容机制
在 Java 中,数组的长度是固定的,这意味着数组只能存储固定量的数据。但在开发的过程中,很多时候我们无法知道该建多大的数组合适。好在 HashMap 是一种自动扩容的数据结构,在这种基于变长的数据结构中,扩容机制是非常重要的。
在 HashMap 中,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。HashMap 中的扩容机制是由 resize()
方法来实现的,下面我们就来一次认识下。(贴出中文注释,便于复制)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 存储old table 的大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 存储扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 如果old table数据已达最大,那么threshold也被设置成最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 左移扩大二倍,
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容成原来二倍
newThr = oldThr << 1; // double threshold
}
// 如果oldThr !> 0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 如果old table <= 0 并且 存储的阈值 <= 0
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果扩充阈值为0
if (newThr == 0) {
// 扩容阈值为 初始容量*负载因子
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 重新给负载因子赋值
threshold = newThr;
// 获取扩容后的数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果第一次进行table 初始化不会走下面的代码
// 扩容之后需要重新把节点放在新扩容的数组中
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;
}
扩容机制源码比较长,我们耐心点进行拆分
我们以 if…else if…else 逻辑进行拆分,上面代码主要做了这几个事情
- 判断 HashMap 中的数组的长度,也就是
(Node<K,V>[])oldTab.length()
,再判断数组的长度是否比最大的的长度也就是 2^30 次幂要大,大的话直接取最大长度,否则利用位运算<<
扩容为原来的两倍
- 如果数组长度不大于0 ,再判断扩容阈值
threshold
是否大于 0 ,也就是看有无外部指定的扩容阈值,若有则使用,这里需要说明一下 threshold 何时是oldThr > 0
,因为 oldThr = threshold ,这里其实比较的就是 threshold,因为 HashMap 中的每个构造方法都会调用HashMap(initCapacity,loadFactor)
这个构造方法,所以如果没有外部指定 initialCapacity,初始容量使用的就是 16,然后根据this.threshold = tableSizeFor(initialCapacity);
求得 threshold 的值。
- 否则,直接使用默认的初始容量和扩容阈值,走 else 的逻辑是在 table 刚刚初始化的时候。
然后会判断 newThr 是否为 0 ,笔者在刚开始研究时发现 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
一直以为这是常量做乘法,怎么会为 0 ,其实不是这部分的问题,在于上面逻辑判断中的扩容操作,可能会导致位溢出
。
导致位溢出的示例:oldCap = 2^28 次幂,threshold > 2 的三次方整数次幂。在进入到 float ft = (float)newCap * loadFactor;
这个方法是 2^28 * 2^(3+n) 会直接 > 2^31 次幂,导致全部归零。
**在扩容后需要把节点放在新扩容的数组中,这里也涉及到三个步骤
-
循环桶中的每个 Node 节点,判断 Node[i] 是否为空,为空直接返回,不为空则遍历桶数组,并将键值对映射到新的桶数组中。
-
如果不为空,再判断是否是树形结构,如果是树形结构则按照树形结构进行拆分,拆分方法在
split
方法中。 -
如果不是树形结构,则遍历链表,并将链表节点按原顺序进行分组。
讲一讲 get 方法全过程
我们上面讲了 HashMap 中的 put 方法全过程,下面我们来看一下 get
方法的过程,
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;
// 找到真实的元素位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 总是会check 一下第一个元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果不是第一个元素,并且下一个元素不是空的
if ((e = first.next) != null) {
// 判断是否属于 TreeNode,如果是 TreeNode 实例,直接从 TreeNode.getTreeNode 取
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 如果还不是 TreeNode 实例,就直接循环数组元素,直到找到指定元素位置
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
来简单介绍下吧,首先会检查 table 中的元素是否为空,然后根据 hash 算出指定 key 的位置。然后检查链表的第一个元素是否为空,如果不为空,是否匹配,如果匹配,直接返回这条记录;如果匹配,再判断下一个元素的值是否为 null,为空直接返回,如果不为空,再判断是否是 TreeNode
实例,如果是 TreeNode 实例,则直接使用 TreeNode.getTreeNode
取出元素,否则执行循环,直到下一个元素为 null 位置。
getNode
方法有一个比较重要的过程就是 (n – 1) & hash,这段代码是确定需要查找的桶的位置的,那么,为什么要 (n – 1) & hash 呢?
n 就是 HashMap 中桶的数量,这句话的意思也就是说 (n – 1) & hash 就是 (桶的容量 – 1) & hash
// 为什么 HashMap 的检索位置是 (table.size - 1) & hash
public static void main(String[] args) {
Map<String,Object> map = new HashMap<>();
// debug 得知 1 的 hash 值算出来是 49
map.put("1","cxuan");
// debug 得知 1 的 hash 值算出来是 50
map.put("2","cxuan");
// debug 得知 1 的 hash 值算出来是 51
map.put("3","cxuan");
}
那么每次算完之后的 (n – 1) & hash ,依次为
也就是 tab[(n – 1) & hash] 算出的具体位置。
HashMap 的遍历方式
HashMap 的遍历,也是一个使用频次特别高的操作
HashMap 遍历的基类是 HashIterator
,它是一个 Hash 迭代器,它是一个 HashMap 内部的抽象类,它的构造比较简单,只有三种方法,hasNext 、 remove 和 nextNode 方法,其中 nextNode 方法是由三种迭代器实现的
这三种迭代器就就是
KeyIterator
,对 key 进行遍历ValueIterator
,对 value 进行遍历EntryIterator
, 对 Entry 链进行遍历
虽然说看着迭代器比较多,但其实他们的遍历顺序都是一样的,构造也非常简单,都是使用 HashIterator
中的 nextNode
方法进行遍历
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
HashIterator 中的遍历方式
abstract class HashIterator {
Node<K,V> next; // 下一个 entry 节点
Node<K,V> current; // 当前 entry 节点
int expectedModCount; // fail-fast 的判断标识
int index; // 当前槽
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;
}
public final void remove() {...}
}
next 和 current 分别表示下一个 Node 节点和当前的 Node 节点,HashIterator 在初始化时会遍历所有的节点。下面我们用图来表示一下他们的遍历顺序
你会发现 nextNode()
方法的遍历方式和 HashIterator 的遍历方式一样,只不过判断条件不一样,构造 HashIterator 的时候判断条件是有没有链表,桶是否为 null,而遍历 nextNode 的判断条件变为下一个 node 节点是不是 null ,并且桶是不是为 null。
HashMap 中的移除方法
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 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
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;
}
remove 方法有很多,最终都会调用到 removeNode 方法,只不过传递的参数值不同,我们拿 remove(object) 来演示一下。
首先会通过 hash 来找到对应的 bucket,然后通过遍历链表,找到键值相等的节点,然后把对应的节点进行删除。
关于 HashMap 的面试题
HashMap 的数据结构
JDK1.7 中,HashMap 采用位桶 + 链表
的实现,即使用链表
来处理冲突,同一 hash 值的链表都存储在一个数组中。但是当位于一个桶中的元素较多,即 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。
所以,与 JDK 1.7 相比,JDK 1.8 在底层结构方面做了一些改变,当每个桶中元素大于 8 的时候,会转变为红黑树,目的就是优化查询效率。
HashMap 的 put 过程
大致过程如下,首先会使用 hash 方法计算对象的哈希码,根据哈希码来确定在 bucket 中存放的位置,如果 bucket 中没有 Node 节点则直接进行 put,如果对应 bucket 已经有 Node 节点,会对链表长度进行分析,判断长度是否大于 8,如果链表长度小于 8 ,在 JDK1.7 前会使用头插法,在 JDK1.8 之后更改为尾插法。如果链表长度大于 8 会进行树化操作,把链表转换为红黑树,在红黑树上进行存储。
HashMap 为啥线程不安全
HashMap 不是一个线程安全的容器,不安全性体现在多线程并发对 HashMap 进行 put 操作上。如果有两个线程 A 和 B ,首先 A 希望插入一个键值对到 HashMap 中,在决定好桶的位置进行 put 时,此时 A 的时间片正好用完了,轮到 B 运行,B 运行后执行和 A 一样的操作,只不过 B 成功把键值对插入进去了。如果 A 和 B 插入的位置(桶)是一样的,那么线程 A 继续执行后就会覆盖 B 的记录,造成了数据不一致问题。
还有一点在于 HashMap 在扩容时,因 resize 方法会形成环,造成死循环,导致 CPU 飙高。
HashMap 是如何处理哈希碰撞的
HashMap 底层是使用位桶 + 链表实现的,位桶决定元素的插入位置,位桶是由 hash 方法决定的,当多个元素的 hash 计算得到相同的哈希值后,HashMap 会把多个 Node 元素都放在对应的位桶中,形成链表,这种处理哈希碰撞的方式被称为链地址法。
其他处理 hash 碰撞的方式还有 开放地址法、rehash 方法、建立一个公共溢出区这几种方法。
HashMap 是如何 get 元素的
首先会检查 table 中的元素是否为空,然后根据 hash 算出指定 key 的位置。然后检查链表的第一个元素是否为空,如果不为空,是否匹配,如果匹配,直接返回这条记录;如果匹配,再判断下一个元素的值是否为 null,为空直接返回,如果不为空,再判断是否是 TreeNode
实例,如果是 TreeNode 实例,则直接使用 TreeNode.getTreeNode
取出元素,否则执行循环,直到下一个元素为 null 位置。
HashMap 和 HashTable 有什么区别
见上
HashMap 和 HashSet 的区别
见上
HashMap 是如何扩容的
HashMap 中有两个非常重要的变量,一个是 loadFactor
,一个是 threshold
,loadFactor 表示的就是负载因子,threshold 表示的是下一次要扩容的阈值,当 threshold = loadFactor * 数组长度时,数组长度扩大位原来的两倍,来重新调整 map 的大小,并将原来的对象放入新的 bucket 数组中。
HashMap 的长度为什么是 2 的幂次方
这道题我想了几天,之前和群里小伙伴们探讨每日一题的时候,问他们为什么 length%hash == (n – 1) & hash,它们说相等的前提是 length 的长度 2 的幂次方,然后我回了一句难道 length 还能不是 2 的幂次方吗?其实是我没有搞懂因果关系,因为 HashMap 的长度是 2 的幂次方,所以使用余数来判断在桶中的下标。如果 length 的长度不是 2 的幂次方,小伙伴们可以举个例子来试试
例如长度为 9 时候,3 & (9-1) = 0,2 & (9-1) = 0 ,都在 0 上,碰撞了;
这样会增大 HashMap 碰撞的几率。
HashMap 线程安全的实现有哪些
因为 HashMap 不是一个线程安全的容器,所以并发场景下推荐使用 ConcurrentHashMap
,或者使用线程安全的 HashMap,使用 Collections
包下的线程安全的容器,比如说
Collections.synchronizedMap(new HashMap());
还可以使用 HashTable ,它也是线程安全的容器,基于 key-value 存储,经常用 HashMap 和 HashTable 做比较就是因为 HashTable 的数据结构和 HashMap 相同。
上面效率最高的就是 ConcurrentHashMap。
后记
文章并没有叙述太多关于红黑树的构造、包含添加、删除、树化等过程,一方面是自己能力还达不到,一方面是关于红黑树的描述太过于占据篇幅,红黑树又是很大的一部分内容,所以会考虑放在后面的红黑树进行讲解。
Java中的语法糖,真甜
我们在日常开发中经常会使用到诸如泛型、自动拆箱和装箱、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等,我们只觉得用的很爽,因为这些特性能够帮助我们减轻开发工作量;但我们未曾认真研究过这些特性的本质是什么,那么这篇文章,cxuan 就来为你揭开这些特性背后的真相。
语法糖
在聊之前我们需要先了解一下 语法糖
的概念:语法糖(Syntactic sugar)
,也叫做糖衣语法,是英国科学家发明的一个术语,通常来说使用语法糖能够增加程序的可读性
,从而减少程序代码出错的机会,真是又香又甜。
语法糖指的是计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。因为 Java 代码需要运行在 JVM 中,JVM 是并不支持语法糖的,语法糖在程序编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖
。所以在 Java 中,真正支持语法糖的是 Java 编译器,真是换汤不换药,万变不离其宗,关了灯都一样。。。。。。
下面我们就来认识一下 Java 中的这些语法糖
泛型
泛型是一种语法糖。在 JDK1.5 中,引入了泛型机制,但是泛型机制的本身是通过类型擦除
来实现的,在 JVM 中没有泛型,只有普通类型和普通方法,泛型类的类型参数,在编译时都会被擦除。泛型并没有自己独特的 Class类型。如下代码所示
List<Integer> aList = new ArrayList();
List<String> bList = new ArrayList();
System.out.println(aList.getClass() == bList.getClass());
List<Ineger>
和 List<String>
被认为是不同的类型,但是输出却得到了相同的结果,这是因为,泛型信息只存在于代码编译阶段,在进入 JVM 之前,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除。但是,如果将一个 Integer 类型的数据放入到 List<String>
中或者将一个 String 类型的数据放在 List<Ineger>
中是不允许的。
如下图所示
无法将一个 Integer 类型的数据放在 List<String>
和无法将一个 String 类型的数据放在 List<Integer>
中是一样会编译失败。
自动拆箱和自动装箱
自动拆箱和自动装箱是一种语法糖,它说的是八种基本数据类型的包装类和其基本数据类型之间的自动转换。简单的说,拆箱就是自动将基本数据类型转换为包装器
类型;装箱就是自动将包装器类型转换为基本数据类型。
我们先来了解一下基本数据类型的包装类都有哪些
也就是说,上面这些基本数据类型和包装类在进行转换的过程中会发生自动装箱/拆箱,例如下面代码
Integer integer = 66; // 自动装箱
int i1 = integer; // 自动拆箱
上面代码中的 integer 对象会使用基本数据类型来进行赋值,而基本数据类型 i1 却把它赋值给了一个对象类型,一般情况下是不能这样操作的,但是编译器却允许我们这么做,这其实就是一种语法糖。这种语法糖使我们方便我们进行数值运算,如果没有语法糖,在进行数值运算时,你需要先将对象转换成基本数据类型,基本数据类型同时也需要转换成包装类型才能使用其内置的方法,无疑增加了代码冗余。
那么自动拆箱和自动装箱是如何实现的呢?
其实这背后的原理是编译器做了优化。将基本类型赋值给包装类其实是调用了包装类的 valueOf()
方法创建了一个包装类再赋值给了基本类型。
int i1 = Integer.valueOf(1);
而包装类赋值给基本类型就是调用了包装类的 xxxValue() 方法拿到基本数据类型后再进行赋值。
Integer i1 = new Integer(1).intValue();
我们使用 javap -c 反编译一下上面的自动装箱和自动拆箱来验证一下
可以看到,在 Code 2 处调用 invokestatic
的时候,相当于是编译器自动为我们添加了一下 Integer.valueOf 方法从而把基本数据类型转换为了包装类型。
在 Code 7 处调用了 invokevirtual
的时候,相当于是编译器为我们添加了 Integer.intValue() 方法把 Integer 的值转换为了基本数据类型。
枚举
我们在日常开发中经常会使用到 enum
和 public static final ...
这类语法。那么什么时候用 enum 或者是 public static final 这类常量呢?好像都可以。
但是在 Java 字节码结构中,并没有枚举类型。枚举只是一个语法糖,在编译完成后就会被编译成一个普通的类,也是用 Class 修饰。这个类继承于 java.lang.Enum,并被 final 关键字修饰。
我们举个例子来看一下
public enum School {
STUDENT,
TEACHER;
}
这是一个 School 的枚举,里面包括两个字段,一个是 STUDENT ,一个是 TEACHER,除此之外并无其他。
下面我们使用 javap
反编译一下这个 School.class 。反编译完成之后的结果如下
从图中我们可以看到,枚举其实就是一个继承于 java.lang.Enum
类的 class 。而里面的属性 STUDENT 和 TEACHER 本质也就是 public static final
修饰的字段。这其实也是一种编译器的优化,毕竟 STUDENT 要比 public static final School STUDENT 的美观性、简洁性都要好很多。
除此之外,编译器还会为我们生成两个方法,values()
方法和 valueOf
方法,这两个方法都是编译器为我们添加的方法,通过使用 values() 方法可以获取所有的 Enum 属性值,而通过 valueOf 方法用于获取单个的属性值。
注意,Enum 的 values() 方法不属于 JDK API 的一部分,在 Java 源码中,没有 values() 方法的相关注释。
用法如下
public enum School {
STUDENT("Student"),
TEACHER("Teacher");
private String name;
School(String name){
this.name = name;
}
public String getName() {
return name;
}
public static void main(String[] args) {
System.out.println(School.STUDENT.getName());
School[] values = School.values();
for(School school : values){
System.out.println("name = "+ school.getName());
}
}
}
内部类
内部类是 Java 一个小众
的特性,我之所以说小众,并不是说内部类没有用,而是我们日常开发中其实很少用到,但是翻看 JDK 源码,发现很多源码中都有内部类的构造。比如常见的 ArrayList
源码中就有一个 Itr
内部类继承于 Iterator
类;再比如 HashMap
中就构造了一个 Node
继承于 Map.Entry
Java 语言中之所以引入内部类,是因为有些时候一个类只想在一个类中有用,不想让其在其他地方被使用,也就是对外隐藏内部细节。
内部类其实也是一个语法糖,因为其只是一个编译时的概念,一旦编译完成,编译器就会为内部类生成一个单独的class 文件,名为 outer$innter.class。
下面我们就根据一个示例来验证一下。
public class OuterClass {
private String label;
class InnerClass {
public String linkOuter(){
return label = "inner";
}
}
public static void main(String[] args) {
OuterClass outerClass = new OuterClass();
InnerClass innerClass = outerClass.new InnerClass();
System.out.println(innerClass.linkOuter());
}
}
上面这段编译后就会生成两个 class 文件,一个是 OuterClass.class
,一个是 OuterClass$InnerClass.class
,这就表明,外部类可以链接到内部类,内部类可以修改外部类的属性等。
我们来看一下内部类编译后的结果
如上图所示,内部类经过编译后的 linkOuter() 方法会生成一个指向外部类的 this 引用,这个引用就是连接外部类和内部类的引用。
变长参数
变长参数也是一个比较小众的用法,所谓变长参数,就是方法可以接受长度不定确定的参数。一般我们开发不会使用到变长参数,而且变长参数也不推荐使用,它会使我们的程序变的难以处理。但是我们有必要了解一下变长参数的特性。
其基本用法如下
public class VariableArgs {
public static void printMessage(String... args){
for(String str : args){
System.out.println("str = " + str);
}
}
public static void main(String[] args) {
VariableArgs.printMessage("l","am","cxuan");
}
}
变长参数也是一种语法糖,那么它是如何实现的呢?我们可以猜测一下其内部应该是由数组构成,否则无法接受多个值,那么我们反编译看一下是不是由数组实现的。
可以看到,printMessage() 的参数就是使用了一个数组来接收,所以千万别被变长参数忽悠
了!
变长参数特性是在 JDK 1.5 中引入的,使用变长参数有两个条件,一是变长的那一部分参数具有相同的类型,二是变长参数必须位于方法参数列表的最后面。
增强 for 循环
为什么有了普通的 for 循环后,还要有增强 for 循环呢?想一下,普通 for 循环你不是需要知道遍历次数?每次还需要知道数组的索引是多少,这种写法明显有些繁琐。增强 for 循环与普通 for 循环相比,功能更强并且代码更加简洁,你无需知道遍历的次数和数组的索引即可进行遍历。
增强 for 循环的对象要么是一个数组,要么实现了 Iterable 接口。这个语法糖主要用来对数组或者集合进行遍历,其在循环过程中不能改变集合的大小。
public static void main(String[] args) {
String[] params = new String[]{"hello","world"};
//增强for循环对象为数组
for(String str : params){
System.out.println(str);
}
List<String> lists = Arrays.asList("hello","world");
//增强for循环对象实现Iterable接口
for(String str : lists){
System.out.println(str);
}
}
经过编译后的 class 文件如下
public static void main(String[] args) {
String[] params = new String[]{"hello", "world"};
String[] lists = params;
int var3 = params.length;
//数组形式的增强for退化为普通for
for(int str = 0; str < var3; ++str) {
String str1 = lists[str];
System.out.println(str1);
}
List var6 = Arrays.asList(new String[]{"hello", "world"});
Iterator var7 = var6.iterator();
//实现Iterable接口的增强for使用iterator接口进行遍历
while(var7.hasNext()) {
String var8 = (String)var7.next();
System.out.println(var8);
}
}
如上代码所示,如果对数组进行增强 for 循环的话,其内部还是对数组进行遍历,只不过语法糖把你忽悠了,让你以一种更简洁的方式编写代码。
而对继承于 Iterator 迭代器进行增强 for 循环遍历的话,相当于是调用了 Iterator 的 hasNext()
和 next()
方法。
Switch 支持字符串和枚举
switch
关键字原生只能支持整数
类型。如果 switch 后面是 String 类型的话,编译器会将其转换成 String 的hashCode
的值,所以其实 switch 语法比较的是 String 的 hashCode 。
如下代码所示
public class SwitchCaseTest {
public static void main(String[] args) {
String str = "cxuan";
switch (str){
case "cuan":
System.out.println("cuan");
break;
case "xuan":
System.out.println("xuan");
break;
case "cxuan":
System.out.println("cxuan");
break;
default:
break;
}
}
}
我们反编译一下,看看我们的猜想是否正确
根据字节码可以看到,进行 switch 的实际是 hashcode 进行判断,然后通过使用 equals 方法进行比较,因为字符串有可能会产生哈希冲突的现象。
条件编译
这个又是让小伙伴们摸不着头脑了,什么是条件编译呢?其实,如果你用过 C 或者 C++ 你就知道可以通过预处理语句来实现条件编译。
那么什么是条件编译呢?
一般情况下,源程序中所有的行都参加编译。但有时希望对其中一部分内容只在满足一定条件下才进行编译,即对一部分内容指定编译条件,这就是 条件编译(conditional compile)
。
#define DEBUG
#IFDEF DEBUUG
/
code block 1
*/
#ELSE
/
code block 2
*/
#ENDIF
但是在 Java 中没有预处理和宏定义这些内容,那么我们想实现条件编译,应该怎样做呢?
使用 final + if 的组合就可以实现条件编译了。如下代码所示
public static void main(String[] args) {
final boolean DEBUG = true;
if (DEBUG) {
System.out.println("Hello, world!");
} else {
System.out.println("nothing");
}
}
这段代码会发生什么?我们反编译看一下
我们可以看到,我们明明是使用了 if …else 语句,但是编译器却只为我们编译了 DEBUG = true 的条件,
所以,Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的,编译器不会为我们编译分支为 false 的代码。
断言
你在 Java 中使用过断言作为日常的判断条件吗?
断言:也就是所谓的 assert
关键字,是 jdk 1.4 后加入的新功能。它主要使用在代码开发和测试时期,用于对某些关键数据的判断,如果这个关键数据不是你程序所预期的数据,程序就提出警告或退出。当软件正式发布后,可以取消断言部分的代码。它也是一个语法糖吗?现在我不告诉你,我们先来看一下 assert 如何使用。
//这个成员变量的值可以变,但最终必须还是回到原值5
static int i = 5;
public static void main(String[] args) {
assert i == 5;
System.out.println("如果断言正常,我就被打印");
}
如果要开启断言检查,则需要用开关 -enableassertions 或 -ea 来开启。其实断言的底层实现就是 if 判断,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行。
assert 断言就是通过对布尔标志位进行了一个 if 判断。
try-with-resources
JDK 1.7 开始,java引入了 try-with-resources 声明,将 try-catch-finally 简化为 try-catch,这其实是一种语法糖
,在编译时会进行转化为 try-catch-finally 语句。新的声明包含三部分:try-with-resources 声明、try 块、catch 块。它要求在 try-with-resources 声明中定义的变量实现了 AutoCloseable 接口,这样在系统可以自动调用它们的 close 方法,从而替代了 finally 中关闭资源的功能。
如下代码所示
public class TryWithResourcesTest {
public static void main(String[] args) {
try(InputStream inputStream = new FileInputStream(new File("xxx"))) {
inputStream.read();
}catch (Exception e){
e.printStackTrace();
}
}
}
我们可以看一下 try-with-resources 反编译之后的代码
可以看到,生成的 try-with-resources 经过编译后还是使用的 try…catch…finally 语句,只不过这部分工作由编译器替我们做了,这样能让我们的代码更加简洁,从而消除样板代码。
字符串相加
这个想必大家应该都知道,字符串的拼接有两种,如果能够在编译时期确定拼接的结果,那么使用 +
号连接的字符串会被编译器直接优化为相加的结果,如果编译期不能确定拼接的结果,底层会直接使用 StringBuilder
的 append
进行拼接,如下图所示。
public class StringAppendTest {
public static void main(String[] args) {
String s1 = "I am " + "cxuan";
String s2 = "I am " + new String("cxuan");
String s3 = "I am ";
String s4 = "cxuan";
String s5 = s3 + s4;
}
}
上面这段代码就包含了两种字符串拼接的结果,我们反编译看一下
首先来看一下 s1 ,s1 因为 = 号右边是两个常量,所以两个字符串拼接会被直接优化成为 I am cxuan
。而 s2 由于在堆空间中分配了一个 cxuan 对象,所以 + 号两边进行字符串拼接会直接转换为 StringBuilder ,调用其 append 方法进行拼接,最后再调用 toString() 方法转换成字符串。
而由于 s5 进行拼接的两个对象在编译期不能判定其拼接结果,所以会直接使用 StringBuilder 进行拼接。
学习语法糖的意义
互联网时代,有很多标新立异的想法和框架层出不穷,但是,我们对于学习来说应该抓住技术的核心。然而,软件工程是一门协作的艺术,对于工程来说如何提高工程质量,如何提高工程效率也是我们要关注的,既然这些语法糖能辅助我们以更好的方式编写备受欢迎的代码,我们程序员为什么要 抵制
呢?
语法糖也是一种进步,这就和你写作文似的,大白话能把故事讲明白的它就没有语言优美、酣畅淋漓的把故事讲生动的更令人喜欢。
我们要在敞开怀抱拥抱变化的同时也要掌握其 屠龙之技
。
超全Java集合框架讲解
集合在我们日常开发使用的次数数不胜数,ArrayList
/LinkedList
/HashMap
/HashSet
······信手拈来,抬手就拿来用,在 IDE 上龙飞凤舞,但是作为一名合格的优雅的程序猿,仅仅了解怎么使用API
是远远不够的,如果在调用API
时,知道它内部发生了什么事情,就像开了透视
外挂一样,洞穿一切,这种感觉才真的爽,而且这样就不是集合提供什么功能给我们使用,而是我们选择使用它的什么功能了。
集合框架总览
下图堪称集合框架的上帝视角,讲到集合框架不得不看的就是这幅图,当然,你会觉得眼花缭乱,不知如何看起,这篇文章带你一步一步地秒杀上面的每一个接口、抽象类和具体类。我们将会从最顶层的接口开始讲起,一步一步往下深入,帮助你把对集合的认知构建起一个知识网络。
工欲善其事必先利其器,让我们先来过一遍整个集合框架的组成部分:
- 集合框架提供了两个遍历接口:
Iterator
和ListIterator
,其中后者是前者的优化版
,支持在任意一个位置进行前后双向遍历。注意图中的Collection
应当继承的是Iterable
而不是Iterator
,后面会解释Iterable
和Iterator
的区别 - 整个集合框架分为两个门派(类型):
Collection
和Map
,前者是一个容器,存储一系列的对象;后者是键值对<key, value>
,存储一系列的**键值对 - 在集合框架体系下,衍生出四种具体的集合类型:
Map
、Set
、List
、Queue
Map
存储<key,value>
键值对,查找元素时通过key
查找value
Set
内部存储一系列不可重复的对象,且是一个无序集合,对象排列顺序不一List
内部存储一系列可重复的对象,是一个有序集合,对象按插入顺序排列Queue
是一个队列容器,其特性与List
相同,但只能从队头
和队尾
操作元素- JDK 为集合的各种操作提供了两个工具类
Collections
和Arrays
,之后会讲解工具类的常用方法 - 四种抽象集合类型内部也会衍生出许多具有不同特性的集合类,**不同场景下择优使用,没有最佳的集合
上面了解了整个集合框架体系的组成部分,接下来的章节会严格按照上面罗列的顺序进行讲解,每一步都会有承上启下
的作用
学习
Set
前,最好最好要先学习Map
,因为Set
的操作本质上是对Map
的操作,往下看准没错
Iterator Iterable ListIterator
在第一次看这两个接口,真以为是一模一样的,没发现里面有啥不同,存在即合理,它们两个还是有本质上的区别的。
首先来看Iterator
接口:
public interface Iterator<E> {
boolean hasNext();
E next();
void remove();
}
提供的API接口含义如下:
hasNext()
:判断集合中是否存在下一个对象next()
:返回集合中的下一个对象,并将访问指针移动一位remove()
:删除集合中调用next()
方法返回的对象
在早期,遍历集合的方式只有一种,通过Iterator
迭代器操作
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator iter = list.iterator();
while (iter.hasNext()) {
Integer next = iter.next();
System.out.println(next);
if (next == 2) { iter.remove(); }
}
再来看Iterable
接口:
public interface Iterable<T> {
Iterator<T> iterator();
// JDK 1.8
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
}
可以看到Iterable
接口里面提供了Iterator
接口,所以实现了Iterable
接口的集合依旧可以使用迭代器
遍历和操作集合中的对象;
而在 JDK 1.8
中,Iterable
提供了一个新的方法forEach()
,它允许使用增强 for 循环遍历对象。
List<Integer> list = new ArrayList<>();
for (Integer num : list) {
System.out.println(num);
}
我们通过命令:javap -c
反编译上面的这段代码后,发现它只是 Java 中的一个语法糖
,本质上还是调用Iterator
去遍历。
翻译成代码,就和一开始的Iterator
迭代器遍历方式基本相同了。
Iterator iter = list.iterator();
while (iter.hasNext()) {
Integer num = iter.next();
System.out.println(num);
}
还有更深层次的探讨:为什么要设计两个接口
Iterable
和Iterator
,而不是保留其中一个就可以了。简单讲解:
Iterator
的保留可以让子类去实现自己的迭代器,而Iterable
接口更加关注于for-each
的增强语法。具体可参考:Java中的Iterable与Iterator详解
关于Iterator
和Iterable
的讲解告一段落,下面来总结一下它们的重点:
Iterator
是提供集合操作内部对象的一个迭代器,它可以遍历、移除对象,且只能够**单向移动Iterable
是对Iterator
的封装,在JDK 1.8
时,实现了Iterable
接口的集合可以使用增强 for 循环遍历集合对象,我们通过反编译后发现底层还是使用Iterator
迭代器进行遍历
等等,这一章还没完,还有一个ListIterator
。它继承 Iterator 接口,在遍历List
集合时可以从任意索引下标开始遍历,而且支持双向遍历。
ListIterator 存在于 List 集合之中,通过调用方法可以返回起始下标为 index
的迭代器
List<Integer> list = new ArrayList<>();
// 返回下标为0的迭代器
ListIterator<Integer> listIter1 = list.listIterator();
// 返回下标为5的迭代器
ListIterator<Integer> listIter2 = list.listIterator(5);
ListIterator 中有几个重要方法,大多数方法与 Iterator 中定义的含义相同,但是比 Iterator 强大的地方是可以在任意一个下标位置返回该迭代器,且可以实现双向遍历。
public interface ListIterator<E> extends Iterator<E> {
boolean hasNext();
E next();
boolean hasPrevious();
E previous();
int nextIndex();
int previousIndex();
void remove();
// 替换当前下标的元素,即访问过的最后一个元素
void set(E e);
void add(E e);
}
Map 和 Collection 接口
Map 接口和 Collection 接口是集合框架体系的两大门派,Collection 是存储元素本身,而 Map 是存储<key, value>
键值对,在 Collection 门派下有一小部分弟子去偷师
,利用 Map 门派下的弟子来修炼自己。
是不是听的一头雾水哈哈哈,举个例子你就懂了:HashSet
底层利用了HashMap
,TreeSet
底层用了TreeMap
,LinkedHashSet
底层用了LinkedHashMap
。
下面我会详细讲到各个具体集合类哦,所以在这里,我们先从整体上了解这两个门派
的特点和区别。
Map
接口定义了存储的数据结构是<key, value>
形式,根据 key 映射到 value,一个 key 对应一个 value ,所以key
不可重复,而value
可重复。
在Map
接口下会将存储的方式细分为不同的种类:
SortedMap
接口:该类映射可以对<key, value>
按照自己的规则进行排序,具体实现有 TreeMapAbsractMap
:它为子类提供好一些通用的API实现,所有的具体Map如HashMap
都会继承它
而Collection
接口提供了所有集合的通用方法(注意这里不包括Map
):
- 添加方法:
add(E e)
/addAll(Collection<? extends E> var1)
- 删除方法:
remove(Object var1)
/removeAll(Collection<?> var1)
- 查找方法:
contains(Object var1)
/containsAll(Collection<?> var1);
- 查询集合自身信息:
size()
/isEmpty()
- ···
在Collection
接口下,同样会将集合细分为不同的种类:
Set
接口:一个不允许存储重复元素的无序集合,具体实现有HashSet
/TreeSet
···List
接口:一个可存储重复元素的有序集合,具体实现有ArrayList
/LinkedList
···Queue
接口:一个可存储重复元素的队列,具体实现有PriorityQueue
/ArrayDeque
···
Map 集合体系详解
Map
接口是由<key, value>
组成的集合,由key
映射到唯一的value
,所以Map
不能包含重复的key
,每个键至多映射一个值。下图是整个 Map 集合体系的主要组成部分,我将会按照日常使用频率从高到低一一讲解。
不得不提的是 Map 的设计理念:定位元素的时间复杂度优化到 O(1)
Map 体系下主要分为 AbstractMap 和 SortedMap两类集合
AbstractMap
是对 Map 接口的扩展,它定义了普通的 Map 集合具有的通用行为,可以避免子类重复编写大量相同的代码,子类继承 AbstractMap 后可以重写它的方法,实现额外的逻辑,对外提供更多的功能。
SortedMap
定义了该类 Map 具有 排序
行为,同时它在内部定义好有关排序的抽象方法,当子类实现它时,必须重写所有方法,对外提供排序功能。
HashMap
HashMap 是一个最通用的利用哈希表存储元素的集合,将元素放入 HashMap 时,将key
的哈希值转换为数组的索引
下标确定存放位置,查找时,根据key
的哈希地址转换成数组的索引
下标确定查找位置。
HashMap 底层是用数组 + 链表 + 红黑树这三种数据结构实现,它是非线程安全的集合。
发送哈希冲突时,HashMap 的解决方法是将相同映射地址的元素连成一条链表
,如果链表的长度大于8
时,且数组的长度大于64
则会转换成红黑树
数据结构。
关于 HashMap 的简要总结:
- 它是集合中最常用的
Map
集合类型,底层由数组 + 链表 + 红黑树
组成 - HashMap不是线程安全的
- 插入元素时,通过计算元素的
哈希值
,通过哈希映射函数转换为数组下标
;查找元素时,同样通过哈希映射函数得到数组下标定位元素的位置
LinkedHashMap
LinkedHashMap 可以看作是 HashMap
和 LinkedList
的结合:它在 HashMap 的基础上添加了一条双向链表,默认
存储各个元素的插入顺序,但由于这条双向链表,使得 LinkedHashMap 可以实现 LRU
缓存淘汰策略,因为我们可以设置这条双向链表按照元素的访问次序
进行排序
LinkedHashMap 是 HashMap 的子类,所以它具备 HashMap 的所有特点,其次,它在 HashMap 的基础上维护了一条双向链表
,该链表存储了所有元素,默认
元素的顺序与插入顺序一致。若accessOrder
属性为true
,则遍历顺序按元素的访问次序进行排序。
// 头节点
transient LinkedHashMap.Entry<K, V> head;
// 尾结点
transient LinkedHashMap.Entry<K, V> tail;
利用 LinkedHashMap 可以实现 LRU
缓存淘汰策略,因为它提供了一个方法:
protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
return false;
}
该方法可以移除最靠近链表头部
的一个节点,而在get()
方法中可以看到下面这段代码,其作用是挪动结点的位置:
if (this.accessOrder) {
this.afterNodeAccess(e);
}
只要调用了get()
且accessOrder = true
,则会将该节点更新到链表尾部
,具体的逻辑在afterNodeAccess()
中,感兴趣的可翻看源码,篇幅原因这里不再展开。
现在如果要实现一个LRU
缓存策略,则需要做两件事情:
- 指定
accessOrder = true
可以设定链表按照访问顺序排列,通过提供的构造器可以设定accessOrder
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
- 重写
removeEldestEntry()
方法,内部定义逻辑,通常是判断容量
是否达到上限,若是则执行淘汰。
这里就要贴出一道大厂面试必考题目:146. LRU缓存机制,只要跟着我的步骤,就能顺利完成这道大厂题了。
关于 LinkedHashMap 主要介绍两点:
- 它底层维护了一条
双向链表
,因为继承了 HashMap,所以它也不是线程安全的 - LinkedHashMap 可实现
LRU
缓存淘汰策略,其原理是通过设置accessOrder
为true
并重写removeEldestEntry
方法定义淘汰元素时需满足的条件
TreeMap
TreeMap 是 SortedMap
的子类,所以它具有排序功能。它是基于红黑树
数据结构实现的,每一个键值对<key, value>
都是一个结点,默认情况下按照key
自然排序,另一种是可以通过传入定制的Comparator
进行自定义规则排序。
// 按照 key 自然排序,Integer 的自然排序是升序
TreeMap<Integer, Object> naturalSort = new TreeMap<>();
// 定制排序,按照 key 降序排序
TreeMap<Integer, Object> customSort = new TreeMap<>((o1, o2) -> Integer.compare(o2, o1));
TreeMap 底层使用了数组+红黑树实现,所以里面的存储结构可以理解成下面这幅图哦。
图中红黑树的每一个节点都是一个Entry
,在这里为了图片的简洁性,就不标明 key 和 value 了,注意这些元素都是已经按照key
排好序了,整个数据结构都是保持着有序
的状态!
关于自然
排序与定制
排序:
- 自然排序:要求
key
必须实现Comparable
接口。
由于Integer
类实现了 Comparable 接口,按照自然排序规则是按照key
从小到大排序。
TreeMap<Integer, String> treeMap = new TreeMap<>();
treeMap.put(2, "TWO");
treeMap.put(1, "ONE");
System.out.print(treeMap);
// {1=ONE, 2=TWO}
- 定制排序:在初始化 TreeMap 时传入新的
Comparator
,不要求key
实现 Comparable 接口
TreeMap<Integer, String> treeMap = new TreeMap<>((o1, o2) -> Integer.compare(o2, o1));
treeMap.put(1, "ONE");
treeMap.put(2, "TWO");
treeMap.put(4, "FOUR");
treeMap.put(3, "THREE");
System.out.println(treeMap);
// {4=FOUR, 3=THREE, 2=TWO, 1=ONE}
通过传入新的Comparator
比较器,可以覆盖默认的排序规则,上面的代码按照key
降序排序,在实际应用中还可以按照其它规则自定义排序。
compare()
方法的返回值有三种,分别是:0
,-1
,+1
(1)如果返回0
,代表两个元素相等,不需要调换顺序
(2)如果返回+1
,代表前面的元素需要与后面的元素调换位置
(3)如果返回-1
,代表前面的元素不需要与后面的元素调换位置
而何时返回+1
和-1
,则由我们自己去定义,JDK默认是按照自然排序,而我们可以根据key
的不同去定义降序还是升序排序。
关于 TreeMap 主要介绍了两点:
- 它底层是由
红黑树
这种数据结构实现的,所以操作的时间复杂度恒为O(logN)
- TreeMap 可以对
key
进行自然排序或者自定义排序,自定义排序时需要传入Comparator
,而自然排序要求key
实现了Comparable
接口 - TreeMap 不是线程安全的。
WeakHashMap
WeakHashMap 日常开发中比较少见,它是基于普通的Map
实现的,而里面Entry
中的键在每一次的垃圾回收
都会被清除掉,所以非常适合用于短暂访问、仅访问一次的元素,缓存在WeakHashMap
中,并尽早地把它回收掉。
当Entry
被GC
时,WeakHashMap 是如何感知到某个元素被回收的呢?
在 WeakHashMap 内部维护了一个引用队列queue
private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
这个 queue 里包含了所有被GC
掉的键,当JVM开启GC
后,如果回收掉 WeakHashMap 中的 key,会将 key 放入queue 中,在expungeStaleEntries()
中遍历 queue,把 queue 中的所有key
拿出来,并在 WeakHashMap 中删除掉,以达到同步。
private void expungeStaleEntries() {
for (Object x; (x = queue.poll()) != null; ) {
synchronized (queue) {
// 去 WeakHashMap 中删除该键值对
}
}
}
再者,需要注意 WeakHashMap 底层存储的元素的数据结构是数组 + 链表
,没有红黑树哦,可以换一个角度想,如果还有红黑树,那干脆直接继承 HashMap ,然后再扩展就完事了嘛,然而它并没有这样做:
public class WeakHashMap<K, V> extends AbstractMap<K, V> implements Map<K, V> {
}
所以,WeakHashMap 的数据结构图我也为你准备好啦。
图中被虚线标识的元素将会在下一次访问 WeakHashMap 时被删除掉,WeakHashMap 内部会做好一系列的调整工作,所以记住队列的作用就是标志那些已经被GC
回收掉的元素。
关于 WeakHashMap 需要注意两点:
- 它的键是一种弱键,放入 WeakHashMap 时,随时会被回收掉,所以不能确保某次访问元素一定存在
- 它依赖普通的
Map
进行实现,是一个非线程安全的集合 - WeakHashMap 通常作为缓存使用,适合存储那些只需访问一次、或只需保存短暂时间的键值对
Hashtable
Hashtable 底层的存储结构是数组 + 链表
,而它是一个线程安全的集合,但是因为这个线程安全,它就被淘汰掉了。
下面是Hashtable存储元素时的数据结构图,它只会存在数组+链表,当链表过长时,查询的效率过低,而且会长时间锁住 Hashtable。
这幅图是否有点眼熟哈哈哈哈,本质上就是 WeakHashMap 的底层存储结构了。你千万别问为什么 WeakHashMap 不继承 Hashtable 哦,Hashtable 的
性能
在并发环境下非常差,在非并发环境下可以用HashMap
更优。
HashTable 本质上是 HashMap 的前辈,它被淘汰的原因也主要因为两个字:**性能
HashTable 是一个 线程安全 的 Map,它所有的方法都被加上了 synchronized 关键字,也是因为这个关键字,它注定成为了时代的弃儿。
HashTable 底层采用 数组+链表 存储键值对,由于被弃用,后人也没有对它进行任何改进
HashTable 默认长度为 11
,负载因子为 0.75F
,即元素个数达到数组长度的 75% 时,会进行一次扩容,每次扩容为原来数组长度的 2
倍
HashTable 所有的操作都是线程安全的。
Collection 集合体系详解
Collection 集合体系的顶层接口就是Collection
,它规定了该集合下的一系列行为约定。
该集合下可以分为三大类集合:List,Set和Queue
Set
接口定义了该类集合不允许存储重复的元素,且任何操作时均需要通过哈希函数映射到集合内部定位元素,集合内部的元素默认是无序的。
List
接口定义了该类集合允许存储重复的元素,且集合内部的元素按照元素插入的顺序有序排列,可以通过索引访问元素。
Queue
接口定义了该类集合是以队列
作为存储结构,所以集合内部的元素有序排列,仅可以操作头结点元素,无法访问队列中间的元素。
上面三个接口是最普通,最抽象的实现,而在各个集合接口内部,还会有更加具体的表现,衍生出各种不同的额外功能,使开发者能够对比各个集合的优势,择优使用。
Set 接口
Set
接口继承了Collection
接口,是一个不包括重复元素的集合,更确切地说,Set 中任意两个元素不会出现 o1.equals(o2)
,而且 Set 至多只能存储一个 NULL
值元素,Set 集合的组成部分可以用下面这张图概括:
在 Set 集合体系中,我们需要着重关注两点:
-
存入可变元素时,必须非常小心,因为任意时候元素状态的改变都有可能使得 Set 内部出现两个相等的元素,即
o1.equals(o2) = true
,所以一般不要更改存入 Set 中的元素,否则将会破坏了equals()
的作用! -
Set 的最大作用就是判重,在项目中最大的作用也是判重!
接下来我们去看它的实现类和子类: AbstractSet
和 SortedSet
AbstractSet 抽象类
AbstractSet
是一个实现 Set 的一个抽象类,定义在这里可以将所有具体 Set 集合的相同行为在这里实现,**避免子类包含大量的重复代码
所有的 Set 也应该要有相同的 hashCode()
和 equals()
方法,所以使用抽象类把该方法重写后,子类无需关心这两个方法。
public abstract class AbstractSet<E> implements Set<E> {
// 判断两个 set 是否相等
public boolean equals(Object o) {
if (o == this) { // 集合本身
return true;
} else if (!(o instanceof Set)) { // 集合不是 set
return false;
} else {
// 比较两个集合的元素是否全部相同
}
}
// 计算所有元素的 hashcode 总和
public int hashCode() {
int h = 0;
Iterator i = this.iterator();
while(i.hasNext()) {
E obj = i.next();
if (obj != null) {
h += obj.hashCode();
}
}
return h;
}
}
SortedSet 接口
SortedSet
是一个接口,它在 Set 的基础上扩展了排序的行为,所以所有实现它的子类都会拥有排序功能。
public interface SortedSet<E> extends Set<E> {
// 元素的比较器,决定元素的排列顺序
Comparator<? super E> comparator();
// 获取 [var1, var2] 之间的 set
SortedSet<E> subSet(E var1, E var2);
// 获取以 var1 开头的 Set
SortedSet<E> headSet(E var1);
// 获取以 var1 结尾的 Set
SortedSet<E> tailSet(E var1);
// 获取首个元素
E first();
// 获取最后一个元素
E last();
}
HashSet
HashSet 底层借助 HashMap
实现,我们可以观察它的多个构造方法,本质上都是 new 一个 HashMap
这也是这篇文章为什么先讲解 Map 再讲解 Set 的原因!先学习 Map,有助于理解 Set
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, Serializable {
public HashSet() {
this.map = new HashMap();
}
public HashSet(int initialCapacity, float loadFactor) {
this.map = new HashMap(initialCapacity, loadFactor);
}
public HashSet(int initialCapacity) {
this.map = new HashMap(initialCapacity);
}
}
我们可以观察 add()
方法和remove()
方法是如何将 HashSet 的操作嫁接到 HashMap 的。
private static final Object PRESENT = new Object();
public boolean add(E e) {
return this.map.put(e, PRESENT) == null;
}
public boolean remove(Object o) {
return this.map.remove(o) == PRESENT;
}
我们看到 PRESENT
就是一个静态常量:使用 PRESENT 作为 HashMap 的 value 值,使用HashSet的开发者只需关注于需要插入的 key
,屏蔽了 HashMap 的 value
上图可以观察到每个Entry
的value
都是 PRESENT 空对象,我们就不用再理会它了。
HashSet 在 HashMap 基础上实现,所以很多地方可以联系到 HashMap:
- 底层数据结构:HashSet 也是采用
数组 + 链表 + 红黑树
实现 - 线程安全性:由于采用 HashMap 实现,而 HashMap 本身线程不安全,在HashSet 中没有添加额外的同步策略,所以 HashSet 也**线程不安全
- 存入 HashSet 的对象的状态最好不要发生变化,因为有可能改变状态后,在集合内部出现两个元素
o1.equals(o2)
,破坏了equals()
的语义。
LinkedHashSet
LinkedHashSet 的代码少的可怜,不信我给你我粘出来
少归少,还是不能闹,LinkedHashSet
继承了HashSet
,我们跟随到父类 HashSet 的构造方法看看
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
this.map = new LinkedHashMap(initialCapacity, loadFactor);
}
发现父类中 map 的实现采用LinkedHashMap
,这里注意不是HashMap
,而 LinkedHashMap 底层又采用 HashMap + 双向链表 实现的,所以本质上 LinkedHashSet 还是使用 HashMap 实现的。
LinkedHashSet -> LinkedHashMap -> HashMap + 双向链表
而 LinkedHashMap 是采用 HashMap
和双向链表
实现的,这条双向链表中保存了元素的插入顺序。所以 LinkedHashSet 可以按照元素的插入顺序遍历元素,如果你熟悉LinkedHashMap
,那 LinkedHashSet 也就更不在话下了。
关于 LinkedHashSet 需要注意几个地方:
- 它继承了
HashSet
,而 HashSet 默认是采用 HashMap 存储数据的,但是 LinkedHashSet 调用父类构造方法初始化 map 时是 LinkedHashMap 而不是 HashMap,这个要额外注意一下 - 由于 LinkedHashMap 不是线程安全的,且在 LinkedHashSet 中没有添加额外的同步策略,所以 LinkedHashSet 集合也不是线程安全的
TreeSet
TreeSet 是基于 TreeMap 的实现,所以存储的元素是有序的,底层的数据结构是数组 + 红黑树
。
而元素的排列顺序有2
种,和 TreeMap 相同:自然排序和定制排序,常用的构造方法已经在下面展示出来了,TreeSet 默认按照自然排序,如果需要定制排序,需要传入Comparator
。
public TreeSet() {
this(new TreeMap<E,Object>());
}
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
TreeSet 应用场景有很多,像在游戏里的玩家战斗力排行榜
public class Player implements Comparable<Integer> {
public String name;
public int score;
@Override
public int compareTo(Student o) {
return Integer.compareTo(this.score, o.score);
}
}
public static void main(String[] args) {
Player s1 = new Player("张三", 100);
Player s2 = new Player("李四", 90);
Player s3 = new Player("王五", 80);
TreeSet<Player> set = new TreeSet();
set.add(s2); set.add(s1); set.add(s3);
System.out.println(set);
}
// [Student{name='王五', score=80}, Student{name='李四', score=90}, Student{name='张三', score=100}]
对 TreeSet 介绍了它的主要实现方式和应用场景,有几个值得注意的点。
- TreeSet 的所有操作都会转换为对 TreeMap 的操作,TreeMap 采用红黑树实现,任意操作的平均时间复杂度为
O(logN)
- TreeSet 是一个线程不安全的集合
- TreeSet 常应用于对不重复的元素定制排序,例如玩家战力排行榜
注意:TreeSet判断元素是否重复的方法是判断compareTo()方法是否返回0,而不是调用 hashcode() 和 equals() 方法,如果返回 0 则认为集合内已经存在相同的元素,不会再加入到集合当中。
List 接口
List 接口和 Set 接口齐头并进,是我们日常开发中接触的很多的一种集合类型了。整个 List 集合的组成部分如下图
List
接口直接继承 Collection 接口,它定义为可以存储重复元素的集合,并且元素按照插入顺序有序排列,且可以通过索引访问指定位置的元素。常见的实现有:ArrayList、LinkedList、Vector和Stack
AbstractList 和 AbstractSequentialList
AbstractList 抽象类实现了 List 接口,其内部实现了所有的 List 都需具备的功能,子类可以专注于实现自己具体的操作逻辑。
// 查找元素 o 第一次出现的索引位置
public int indexOf(Object o)
// 查找元素 o 最后一次出现的索引位置
public int lastIndexOf(Object o)
//···
AbstractSequentialList 抽象类继承了 AbstractList,在原基础上限制了访问元素的顺序只能够按照顺序访问,而不支持随机访问,如果需要满足随机访问的特性,则继承 AbstractList。子类 LinkedList 使用链表实现,所以仅能支持顺序访问,顾继承了 AbstractSequentialList
而不是 AbstractList。
Vector
Vector
在现在已经是一种过时的集合了,包括继承它的 Stack
集合也如此,它们被淘汰的原因都是因为性能低下。
JDK 1.0 时代,ArrayList 还没诞生,大家都是使用 Vector 集合,但由于 Vector 的每个操作都被 synchronized 关键字修饰,即使在线程安全的情况下,仍然进行无意义的加锁与释放锁,造成额外的性能开销,做了无用功。
public synchronized boolean add(E e);
public synchronized E get(int index);
在 JDK 1.2 时,Collection 家族出现了,它提供了大量高性能、适用於不同场合的集合,而 Vector 也是其中一员,但由于 Vector 在每个方法上都加了锁,由于需要兼容许多老的项目,很难在此基础上优化Vector
了,所以渐渐地也就被历史淘汰了。
现在,在线程安全的情况下,不需要选用 Vector 集合,取而代之的是 ArrayList 集合;在并发环境下,出现了 CopyOnWriteArrayList
,Vector 完全被弃用了。
Stack
Stack
是一种后入先出(LIFO)
型的集合容器,如图中所示,大雄
是最后一个进入容器的,top指针指向大雄,那么弹出元素时,大雄也是第一个被弹出去的。
Stack 继承了 Vector 类,提供了栈顶的压入元素操作(push)和弹出元素操作(pop),以及查看栈顶元素的方法(peek)等等,但由于继承了 Vector,正所谓跟错老大没福报,Stack 也渐渐被淘汰了。
取而代之的是后起之秀 Deque
接口,其实现有 ArrayDeque
,该数据结构更加完善、可靠性更好,依靠队列也可以实现LIFO
的栈操作,所以优先选择 ArrayDeque 实现栈。
Deque<Integer> stack = new ArrayDeque<Integer>();
ArrayDeque 的数据结构是:数组
,并提供头尾指针下标对数组元素进行操作。本文也会讲到哦,客官请继续往下看,莫着急!:smile:
ArrayList
ArrayList 以数组作为存储结构,它是线程不安全的集合;具有查询快、在数组中间或头部增删慢的特点,所以它除了线程不安全这一点,其余可以替代Vector
,而且线程安全的 ArrayList 可以使用 CopyOnWriteArrayList
代替 Vector。
关于 ArrayList 有几个重要的点需要注意的:
-
具备随机访问特点,访问元素的效率较高,ArrayList 在频繁插入、删除集合元素的场景下效率较
低
。 -
底层数据结构:ArrayList 底层使用数组作为存储结构,具备查找快、增删慢的特点
-
线程安全性:ArrayList 是线程不安全的集合
-
ArrayList 首次扩容后的长度为
10
,调用add()
时需要计算容器的最小容量。可以看到如果数组elementData
为空数组,会将最小容量设置为10
,之后会将数组长度完成首次扩容到 10。
// new ArrayList 时的默认空数组
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 默认容量
private static final int DEFAULT_CAPACITY = 10;
// 计算该容器应该满足的最小容量
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
- 集合从第二次扩容开始,数组长度将扩容为原来的
1.5
倍,即:newLength = oldLength * 1.5
LinkedList
LinkedList 底层采用双向链表
数据结构存储元素,由于链表的内存地址非连续
,所以它不具备随机访问的特点,但由于它利用指针连接各个元素,所以插入、删除元素只需要操作指针
,不需要移动元素
,故具有增删快、查询慢的特点。它也是一个非线程安全的集合。
由于以双向链表作为数据结构,它是线程不安全的集合;存储的每个节点称为一个Node
,下图可以看到 Node 中保存了next
和prev
指针,item
是该节点的值。在插入和删除时,时间复杂度都保持为 O(1)
关于 LinkedList,除了它是以链表实现的集合外,还有一些特殊的特性需要注意的。
- 优势:LinkedList 底层没有
扩容机制
,使用双向链表
存储元素,所以插入和删除元素效率较高,适用于频繁操作元素的场景 - 劣势:LinkedList 不具备
随机访问
的特点,查找某个元素只能从head
或tail
指针一个一个比较,所以**查找中间的元素时效率很低 - 查找优化:LinkedList 查找某个下标
index
的元素时做了优化,若index > (size / 2)
,则从head
往后查找,否则从tail
开始往前查找,代码如下所示:
LinkedList.Node<E> node(int index) {
LinkedList.Node x;
int i;
if (index < this.size >> 1) { // 查找的下标处于链表前半部分则从头找
x = this.first;
for(i = 0; i < index; ++i) { x = x.next; }
return x;
} else { // 查找的下标处于数组的后半部分则从尾开始找
x = this.last;
for(i = this.size - 1; i > index; --i) { x = x.prev; }
return x;
}
}
- 双端队列:使用双端链表实现,并且实现了
Deque
接口,使得 LinkedList 可以用作双端队列。下图可以看到 Node 是集合中的元素,提供了前驱指针和后继指针,还提供了一系列操作头结点
和尾结点
的方法,具有双端队列的特性。
LinkedList 集合最让人树枝的是它的链表结构,但是我们同时也要注意它是一个双端队列型的集合。
Deque<Object> deque = new LinkedList<>();
Queue接口
Queue
队列,在 JDK 中有两种不同类型的集合实现:单向队列(AbstractQueue) 和 双端队列(Deque)
Queue 中提供了两套增加、删除元素的 API,当插入或删除元素失败时,会有两种不同的失败处理策略。
方法及失败策略 | 插入方法 | 删除方法 | 查找方法 |
---|---|---|---|
抛出异常 | add() | remove() | get() |
返回失败默认值 | offer() | poll() | peek() |
选取哪种方法的决定因素:插入和删除元素失败时,希望抛出异常
还是返回布尔值
add()
和 offer()
对比:
在队列长度大小确定的场景下,队列放满元素后,添加下一个元素时,add() 会抛出 IllegalStateException
异常,而 offer()
会返回 false
。
但是它们两个方法在插入某些不合法的元素时都会抛出三个相同的异常。
remove()
和 poll()
对比:
在队列为空的场景下, remove()
会抛出 NoSuchElementException
异常,而 poll()
则返回 null
。
get()
和peek()
对比:
在队列为空的情况下,get()
会抛出NoSuchElementException
异常,而peek()
则返回null
。
Deque 接口
Deque
接口的实现非常好理解:从单向队列演变为双向队列,内部额外提供双向队列的操作方法即可:
Deque 接口额外提供了针对队列的头结点和尾结点操作的方法,而插入、删除方法同样也提供了两套不同的失败策略。除了add()
和offer()
,remove()
和poll()
以外,还有get()
和peek()
出现了不同的策略
AbstractQueue 抽象类
AbstractQueue 类中提供了各个 API 的基本实现,主要针对各个不同的处理策略给出基本的方法实现,定义在这里的作用是让子类
根据其方法规范
(操作失败时抛出异常还是返回默认值)实现具体的业务逻辑。
LinkedList
LinkedList 在上面已经详细解释了,它实现了 Deque
接口,提供了针对头结点和尾结点的操作,并且每个结点都有前驱和后继指针,具备了双向队列的所有特性。
ArrayDeque
使用数组实现的双端队列,它是无界的双端队列,最小的容量是8
(JDK 1.8)。在 JDK 11 看到它默认容量已经是 16
了。
ArrayDeque
在日常使用得不多,值得注意的是它与 LinkedList
的对比:LinkedList
采用链表实现双端队列,而 ArrayDeque
使用数组实现双端队列。
在文档中作者写到:**ArrayDeque 作为栈时比 Stack 性能好,作为队列时比 LinkedList 性能好
由于双端队列只能在头部和尾部操作元素,所以删除元素和插入元素的时间复杂度大部分都稳定在 O(1)
,除非在扩容时会涉及到元素的批量复制操作。但是在大多数情况下,使用它时应该指定一个大概的数组长度,避免频繁的扩容。
个人观点:链表的插入、删除操作涉及到指针的操作,我个人认为作者是觉得数组下标的移动要比指针的操作要廉价,而且数组采用连续的内存地址空间,而链表元素的内存地址是不连续的,所以数组操作元素的效率在寻址上会比链表要快。请批判看待观点。
PriorityQueue
PriorityQueue 基于优先级堆实现的优先级队列,而堆是采用数组实现:
文档中的描述告诉我们:该数组中的元素通过传入 Comparator
进行定制排序,如果不传入Comparator
时,则按照元素本身自然排序
,但要求元素实现了Comparable
接口,所以 PriorityQueue 不允许存储 NULL 元素。
PriorityQueue 应用场景:元素本身具有优先级,需要按照**优先级处理元素
- 例如游戏中的VIP玩家与普通玩家,VIP 等级越高的玩家越先安排进入服务器玩耍,减少玩家流失。
public static void main(String[] args) {
Student vip1 = new Student("张三", 1);
Student vip3 = new Student("洪七", 2);
Student vip4 = new Student("老八", 4);
Student vip2 = new Student("李四", 1);
Student normal1 = new Student("王五", 0);
Student normal2 = new Student("赵六", 0);
// 根据玩家的 VIP 等级进行降序排序
PriorityQueue<Student> queue = new PriorityQueue<>((o1, o2) -> o2.getScore().compareTo(o1.getScore()));
queue.add(vip1);queue.add(vip4);queue.add(vip3);
queue.add(normal1);queue.add(normal2);queue.add(vip2);
while (!queue.isEmpty()) {
Student s1 = queue.poll();
System.out.println(s1.getName() + "进入游戏; " + "VIP等级: " + s1.getScore());
}
}
public static class Student implements Comparable<Student> {
private String name;
private Integer score;
public Student(String name, Integer score) {
this.name = name;
this.score = score;
}
@Override
public int compareTo(Student o) {
return this.score.compareTo(o.getScore());
}
}
执行上面的代码可以得到下面这种有趣的结果,可以看到氪金
使人带来快乐。
VIP 等级越高(优先级越高)就越优先安排进入游戏(优先处理),类似这种有优先级的场景还有非常多,各位可以发挥自己的想象力。
PriorityQueue 总结:
-
PriorityQueue 是基于优先级堆实现的优先级队列,而堆是用数组维护的
-
PriorityQueue 适用于元素按优先级处理的业务场景,例如用户在请求人工客服需要排队时,根据用户的VIP等级进行
插队
处理,等级越高,越先安排客服。
章节结束各集合总结:(以 JDK1.8 为例)
数据类型 | 插入、删除时间复杂度 | 查询时间复杂度 | 底层数据结构 | 是否线程安全 |
---|---|---|---|---|
Vector | O(N) | O(1) | 数组 | 是(已淘汰) |
ArrayList | O(N) | O(1) | 数组 | 否 |
LinkedList | O(1) | O(N) | 双向链表 | 否 |
HashSet | O(1) | O(1) | 数组+链表+红黑树 | 否 |
TreeSet | O(logN) | O(logN) | 红黑树 | 否 |
LinkedHashSet | O(1) | O(1)~O(N) | 数组 + 链表 + 红黑树 | 否 |
ArrayDeque | O(N) | O(1) | 数组 | 否 |
PriorityQueue | O(logN) | O(logN) | 堆(数组实现) | 否 |
HashMap | O(1) ~ O(N) | O(1) ~ O(N) | 数组+链表+红黑树 | 否 |
TreeMap | O(logN) | O(logN) | 数组+红黑树 | 否 |
HashTable | O(1) / O(N) | O(1) / O(N) | 数组+链表 | 是(已淘汰) |
文末总结
这一篇文章对各个集合都有些点到即止
的味道,此文的目的是对整个集合框架有一个较为整体的了解,分析了最常用的集合的相关特性,以及某些特殊集合的应用场景例如TreeSet
、TreeMap
这种可定制排序的集合。
-
Collection
接口提供了整个集合框架最通用的增删改查以及集合自身操作的抽象方法,让子类去实现 -
Set
接口决定了它的子类都是无序、无重复元素的集合,其主要实现有HashSet、TreeSet、LinkedHashSet。HashSet
底层采用HashMap
实现,而TreeSet
底层使用TreeMap
实现,大部分 Set 集合的操作都会转换为 Map 的操作,TreeSet 可以将元素按照规则进行排序。-
List
接口决定了它的子类都是有序、可存储重复元素的集合,常见的实现有 ArrayList,LinkedList,Vector -
ArrayList
使用数组实现,而 LinkedList 使用链表实现,所以它们两个的使用场景几乎是相反的,频繁查询的场景使用 ArrayList,而频繁插入删除的场景最好使用 LinkedList LinkedList
和ArrayDeque
都可用于双端队列,而 Josh Bloch and Doug Lea 认为ArrayDeque
具有比LinkedList
更好的性能,ArrayDeque
使用数组实现双端队列,LinkedList
使用链表实现双端队列。-
Queue
接口定义了队列的基本操作,子类集合都会拥有队列的特性:先进先出,主要实现有:LinkedList,ArrayDeque -
PriorityQueue
底层使用二叉堆维护的优先级队列,而二叉堆是由数组实现的,它可以按照元素的优先级进行排序,优先级越高的元素,排在队列前面,优先被弹出处理。 -
Map
接口定义了该种集合类型是以<key,value>
键值对形式保存,其主要实现有:HashMap,TreeMap,LinkedHashMap,Hashtable -
LinkedHashMap 底层多加了一条双向链表,设置
accessOrder
为true
并重写方法则可以实现LRU
缓存 - TreeMap 底层采用数组+红黑树实现,集合内的元素默认按照自然排序,也可以传入
Comparator
定制排序
看到这里非常不容易,感谢你愿意阅读我的文章,希望能对你有所帮助,你可以参考着文末总结的顺序,每当我提到一个集合时,回想它的重要知识点是什么,主要就是底层数据结构
,线程安全性
,该集合的一两个特有性质
,只要能够回答出来个大概,我相信之后运用这些数据结构,你能够熟能生巧。
本文对整个集合体系的所有常用的集合类都分析了,这里并没有对集合内部的实现深入剖析,我想先从最宏观的角度让大家了解每个集合的的作用,应用场景,以及简单的对比,之后会抽时间对常见的集合进行源码剖析,尽情期待,感谢阅读!
最后有些话想说:这篇文章花了我半个月去写,也是意义重大,多谢
cxuan
哥一直指导我写文章,一步一步地去打磨出一篇好的文章真的非常不容易,写下的每一个字都能够让别人看得懂是一件非常难的事情,总结出最精华的知识分享给你们也是非常难的一件事情,希望能够一直进步下去!不忘初心,热爱分享,喜爱写作。
Exception和Error
在 Java 中的基本理念是 结构不佳的代码不能运行
,发现错误的理想时期是在编译期间,因为你不用运行程序,只是凭借着对 Java 基本理念的理解就能发现问题。但是编译期并不能找出所有的问题,有一些 NullPointerException 和 ClassNotFoundException 在编译期找不到,这些异常是 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 thanRuntimeException
and its subclasses andError
and its subclasses.
也就是说,除了 RuntimeException
和其子类,以及error
和其子类,其它的所有异常都是 checkedException
。
那么,按照这种逻辑关系,我们可以对 Throwable 及其子类进行归类分析
可以看到,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
序号 | 异常名称 | 异常描述 |
---|---|---|
1 | ArrayIndexOutOfBoundsException | 数组越界异常 |
2 | NullPointerException | 空指针异常 |
3 | IllegalArgumentException | 非法参数异常 |
4 | NegativeArraySizeException | 数组长度为负异常 |
5 | IllegalStateException | 非法状态异常 |
6 | ClassCastException | 类型转换异常 |
UncheckedException
序号 | 异常名称 | 异常描述 |
---|---|---|
1 | NoSuchFieldException | 表示该类没有指定名称抛出来的异常 |
2 | NoSuchMethodException | 表示该类没有指定方法抛出来的异常 |
3 | IllegalAccessException | 不允许访问某个类的异常 |
4 | ClassNotFoundException | 类没有找到抛出异常 |
与 Exception 有关的 Java 关键字
那么 Java 中是如何处理这些异常的呢?在 Java 中有这几个关键字 throws、throw、try、finally、catch 下面我们分别来探讨一下
throws 和 throw
在 Java 中,异常也就是一个对象,它能够被程序员自定义抛出或者应用程序抛出,必须借助于 throws
和 throw
语句来定义抛出异常。
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 中的代码逻辑。
JDK1.7 使用 try…with…resources 优雅关闭资源
Java 类库中有许多资源需要通过 close 方法进行关闭。比如 InputStream、OutputStream,数据库连接对象 Connection,MyBatis 中的 SqlSession 会话等。作为开发人员经常会忽略掉资源的关闭方法,导致内存泄漏。
根据经验,try-finally
语句是确保资源会被关闭的最佳方法,就算异常或者返回也一样。try-catch-finally 一般是这样来用的
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
}finally {
br.close();
}
}
这样看起来代码还是比较整洁,但是当我们添加第二个需要关闭的资源的时候,就像下面这样
static void copy(String src,String dst) throws Exception{
InputStream is = new FileInputStream(src);
try {
OutputStream os = new FileOutputStream(dst);
try {
byte[] buf = new byte[100];
int n;
while ((n = is.read()) >= 0){
os.write(buf,n,0);
}
}finally {
os.close();
}
}finally {
is.close();
}
}
这样感觉这个方法已经变得臃肿起来了。
而且这种写法也存在诸多问题,即使 try – finally 能够正确关闭资源,但是它不能阻止异常的抛出,因为 try 和 finally 块中都可能有异常的发生。
比如说你正在读取的时候硬盘损坏,这个时候你就无法读取文件和关闭资源了,此时会抛出两个异常。但是在这种情况下,第二个异常会抹掉第一个异常。在异常堆栈中也无法找到第一个异常的记录,怎么办,难道像这样来捕捉异常么?
static void tryThrowException(String path) throws Exception {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
String s = br.readLine();
System.out.println("s = " + s);
}catch (Exception e){
e.printStackTrace();
}finally {
try {
br.close();
}catch (Exception e){
e.printStackTrace();
}finally {
br.close();
}
}
}
这种写法,虽然能解决异常抛出的问题,但是各种 try-cath-finally 的嵌套会让代码变得非常臃肿。
Java7 中引入了try-with-resources
语句时,所有这些问题都能得到解决。要使用 try-with-resources 语句,首先要实现 AutoCloseable
接口,此接口包含了单个返回的 close 方法。Java 类库与三方类库中的许多类和接口,现在都实现或者扩展了 AutoCloseable 接口。如果编写了一个类,它代表的是必须关闭的资源,那么这个类应该实现 AutoCloseable 接口。
java 引入了 try-with-resources 声明,将 try-catch-finally 简化为 try-catch,这其实是一种语法糖
,在编译时会进行转化为 try-catch-finally 语句。
下面是使用 try-with-resources 的第一个范例
/
* 使用try-with-resources 改写示例一
* @param path
* @return
* @throws IOException
*/
static String firstLineOfFileAutoClose(String path) throws IOException {
try(BufferedReader br = new BufferedReader(new FileReader(path))){
return br.readLine();
}
}
使用 try-with-resources 改写程序的第二个示例
static void copyAutoClose(String src,String dst) throws IOException{
try(InputStream in = new FileInputStream(src);
OutputStream os = new FileOutputStream(dst)){
byte[] buf = new byte[1000];
int n;
while ((n = in.read(buf)) >= 0){
os.write(buf,0,n);
}
}
}
使用 try-with-resources 不仅使代码变得通俗易懂,也更容易诊断。以firstLineOfFileAutoClose
方法为例,如果调用 readLine()
和 close()
方法都抛出异常,后一个异常就会被禁止,以保留第一个异常。
异常处理的原则
我们在日常处理异常的代码中,应该遵循三个原则
- 不要捕获类似
Exception
之类的异常,而应该捕获类似特定的异常,比如InterruptedException
,方便排查问题,而且也能够让其他人接手你的代码时,会减少骂你的次数。 - 不要生吞异常。这是异常处理中要特别注重的事情,因为很可能会非常难以正常结束情况。如果我们不把异常抛出来,或者也没有输出到 Logger 日志中,程序可能会在后面以不可控的方式结束。
- 不要在函数式编程中使用
checkedException
。
什么是 Error
Error 是程序无法处理的错误,表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。这些错误是不可检查的,因为它们在应用程序的控制和处理能力之 外,而且绝大多数是程序运行时不允许出现的状况,比如 OutOfMemoryError
和 StackOverflowError
异常的出现会有几种情况,这里需要先介绍一下 Java 内存模型 JDK1.7。
其中包括两部分,由所有线程共享的数据区和线程隔离的数据区组成,在上面的 Java 内存模型中,只有程序计数器是不会发生 OutOfMemoryError
情况的区域,程序计数器控制着计算机指令的分支、循环、跳转、异常处理和线程恢复,并且程序计数器是每个线程私有的。
什么是线程私有:表示的就是各条线程之间互不影响,独立存储的内存区域。
如果应用程序执行的是 Java 方法,那么这个计数器记录的就是虚拟机字节码
指令的地址;如果正在执行的是 Native
方法,这个计数器值则为空(Undefined)
。
除了程序计数器外,其他区域:方法区(Method Area)
、虚拟机栈(VM Stack)
、本地方法栈(Native Method Stack)
和 堆(Heap)
都是可能发生 OutOfMemoryError 的区域。
-
虚拟机栈:如果线程请求的栈深度大于虚拟机栈所允许的深度,将会出现
StackOverflowError
异常;如果虚拟机动态扩展无法申请到足够的内存,将出现OutOfMemoryError
。 -
本地方法栈和虚拟机栈一样
-
堆:Java 堆可以处于物理上不连续,逻辑上连续,就像我们的磁盘空间一样,如果堆中没有内存完成实例分配,并且堆无法扩展时,将会抛出 OutOfMemoryError。
-
方法区:方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
一道经典的面试题
一道非常经典的面试题,NoClassDefFoundError 和 ClassNotFoundException 有什么区别?
在类的加载过程中, JVM 或者 ClassLoader 无法找到对应的类时,都可能会引起这两种异常/错误,由于不同的 ClassLoader 会从不同的地方加载类,有时是错误的 CLASSPATH 类路径导致的这类错误,有时是某个库的 jar 包缺失引发这类错误。NoClassDefFoundError 表示这个类在编译时期存在,但是在运行时却找不到此类,有时静态初始化块也会导致 NoClassDefFoundError 错误。
ClassLoader 是类路径装载器,在Java 中,类路径装载器一共有三种两类
一种是虚拟机自带的 ClassLoader,分为三种
启动类加载器(Bootstrap)
,负责加载 $JAVAHOME/jre/lib/rt.jar扩展类加载器(Extension)
,负责加载 $JAVAHOME/jre/lib/ext/*.jar应用程序类加载器(AppClassLoader)
,加载当前应用的 classpath 的所有类第二种是用户自定义类加载器
- Java.lang.ClassLoader 的子类,用户可以定制类的加载方式。
另一方面,ClassNotFoundException 与编译时期无关,当你尝试在运行时使用反射加载类时,ClassNotFoundException 就会出现。
简而言之,ClassNotFoundException 和 NoClassDefFoundError 都是由 CLASSPATH 中缺少类引起的,通常是由于缺少 JAR 文件而引起的,但是如果 JVM 认为应用运行时找不到相应的引用,就会抛出 NoClassDefFoundError 错误;当你在代码中显示的加载类比如 Class.forName()
调用时却没有找到相应的类,就会抛出 java.lang.ClassNotFoundException
。
- NoClassDefFoundError 是 JVM 引起的错误,是 unchecked,未经检查的。因此不会使用 try-catch 或者 finally 语句块;另外,ClassNotFoundException 是受检异常,因此需要 try-catch 语句块或者 try-finally 语句块包围,否则会导致编译错误。
- 调用 Class.forName()、ClassLoader.findClass() 和 ClassLoader.loadClass() 等方法时可能会引起
java.lang.ClassNotFoundException
,如图所示
- NoClassDefFoundError 是链接错误,发生在链接阶段,当解析引用找不到对应的类,就会触发;而 ClassNotFoundException 是发生在运行时的异常。
简单认识并发
到目前为止,你学到的都是顺序编程,顺序编程的概念就是某一时刻只有一个任务在执行,顺序编程固然能够解决很多问题,但是对于某种任务,如果能够并发的执行程序中重要的部分就显得尤为重要,同时也可以极大提高程序运行效率,享受并发为你带来的便利。但是,熟练掌握并发编程理论和技术,对于只会CRUD的你来说是一种和你刚学面向对象一样的一种飞跃。
正如你所看到的,当并行的任务彼此干涉时,实际的并发问题就会接踵而至。而且并发问题不是很难复现,在你实际的测试过程中往往会忽略它们,因为故障是偶尔发生的,这也是我们研究它们的必要条件:如果你对并发问题置之不理,那么你最终会承受它给你带来的损害。
并发的多面性
更快的执行
速度问题听起来很简单,如果你想让一个程序运行的更快一些,那么可以将其切成多个分片,在单独的处理器上运行各自的分片:前提是这些任务彼此之间没有联系。
注意:速度的提高是以多核处理器而不是芯片的形式出现的。
如果你有一台多处理器的机器,那么你就可以在这些处理器之间分布多个任务,从而极大的提高吞吐量。但是,并发通常是提高在单处理器上的程序的性能。在单处理上的性能开销要比多处理器上的性能开销大很多,因为这其中增加了线程切换
(从一个线程切换到另外一个线程)的重要依据。表面上看,将程序的所有部分当作单个的任务运行好像是开销更小一点,节省了线程切换的时间。
改进代码的设计
在单CPU机器上使用多任务的程序在任意时刻仍旧只在执行一项工作,你肉眼观察到控制台的输出好像是这些线程在同时工作,这不过是CPU的障眼法罢了,CPU为每个任务都提供了不固定的时间切片。Java 的线程机制是抢占式的,也就是说,你必须编写某种让步语句才会让线程进行切换,切换给其他线程。
基本的线程机制
并发编程使我们可以将程序划分成多个分离的,独立运行的任务。通过使用多线程机制,这些独立任务中的每一项任务都由执行线程
来驱动。一个线程就是进程中的一个单一的顺序控制流。因此,单个进程可以拥有多个并发执行的任务,但是你的程序看起来每个任务都有自己的CPU一样。其底层是切分CPU时间,通常你不需要考虑它。
定义任务
线程可以驱动任务,因此你需要一种描述任务的方式,这可以由 Runnable
接口来提供,要想定义任务,只需要实现 Runnable 接口,并在run
方法中实现你的逻辑即可。
public class TestThread implements Runnable{
public static int i = 0;
@Override
public void run() {
System.out.println("start thread..." + i);
i++;
System.out.println("end thread ..." + i);
}
public static void main(String[] args) {
for(int i = 0;i < 5;i++){
TestThread testThread = new TestThread();
testThread.run();
}
}
}
任务 run 方法会有某种形式的循环,使得任务一直运行下去直到不再需要,所以要设定 run 方法的跳出条件(有一种选择是从 run 中直接返回,下面会说到。)
在 run 中使用静态方法 Thread.yield()
可以使用线程调度,它的意思是建议线程机制进行切换:你已经执行完重要的部分了,剩下的交给其他线程跑一跑吧。注意是建议执行,而不是强制执行。在下面添加 Thread.yield() 你会看到有意思的输出
public void run() {
System.out.println("start thread..." + i);
i++;
Thread.yield();
System.out.println("end thread ..." + i);
}
Thread 类
将 Runnable 转变工作方式的传统方式是使用 Thread 类托管他,下面展示了使用 Thread 类来实现一个线程。
public static void main(String[] args) {
for(int i = 0;i < 5;i++){
Thread t = new Thread(new TestThread());
t.start();
}
System.out.println("Waiting thread ...");
}
Thread 构造器只需要一个 Runnable 对象,调用 Thread 对象的 start() 方法为该线程执行必须的初始化操作,然后调用 Runnable 的 run 方法,以便在这个线程中启动任务。可以看到,在 run 方法还没有结束前,run 就被返回了。也就是说,程序不会等到 run 方法执行完毕就会执行下面的指令。
在 run 方法中打印出每个线程的名字,就更能看到不同的线程的切换和调度
@Override
public void run() {
System.out.println(Thread.currentThread() + "start thread..." + i);
i++;
System.out.println(Thread.currentThread() + "end thread ..." + i);
}
这种线程切换和调度是交由 线程调度器
来自动控制的,如果你的机器上有多个处理器,线程调度器会在这些处理器之间默默的分发线程。每一次的运行结果都不尽相同,因为线程调度机制是未确定的。
使用 Executor
CachedThreadPool
JDK1.5 的java.util.concurrent 包中的执行器 Executor 将为你管理 Thread 对象,从而简化了并发编程。Executor 在客户端和任务之间提供了一个间接层;与客户端直接执行任务不同,这个中介对象将执行任务。Executor 允许你管理异步任务的执行,而无须显示地管理线程的生命周期。
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
for(int i = 0;i < 5;i++){
service.execute(new TestThread());
}
service.shutdown();
}
我们使用 Executor 来替代上述显示创建 Thread 对象。CachedThreadPool
为每个任务都创建一个线程。注意:ExecutorService 对象是使用静态的 Executors
创建的,这个方法可以确定 Executor 类型。对 shutDown
的调用可以防止新任务提交给 ExecutorService ,这个线程在 Executor 中所有任务完成后退出。
FixedThreadPool
FixedThreadPool 使你可以使用有限的线程集来启动多线程
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(5);
for(int i = 0;i < 5;i++){
service.execute(new TestThread());
}
service.shutdown();
}
有了 FixedThreadPool 使你可以一次性的预先执行高昂的线程分配,因此也就可以限制线程的数量。这可以节省时间,因为你不必为每个任务都固定的付出创建线程的开销。
SingleThreadExecutor
SingleThreadExecutor 就是线程数量为 1 的 FixedThreadPool,如果向 SingleThreadPool 一次性提交了多个任务,那么这些任务将会排队,每个任务都会在下一个任务开始前结束,所有的任务都将使用相同的线程。SingleThreadPool 会序列化所有提交给他的任务,并会维护它自己(隐藏)的悬挂队列。
public static void main(String[] args) {
ExecutorService service = Executors.newSingleThreadExecutor();
for(int i = 0;i < 5;i++){
service.execute(new TestThread());
}
service.shutdown();
}
从输出的结果就可以看到,任务都是挨着执行的。我为任务分配了五个线程,但是这五个线程不像是我们之前看到的有换进换出的效果,它每次都会先执行完自己的那个线程,然后余下的线程继续“走完”这条线程的执行路径。你可以用 SingleThreadExecutor 来确保任意时刻都只有唯一一个任务在运行。
从任务中产生返回值
Runnable 是执行工作的独立任务,但它不返回任何值。如果你希望任务在完成时能够返回一个值 ,这个时候你就需要考虑使用 Callable
接口,它是 JDK1.5 之后引入的,通过调用它的 submit
方法,可以把它的返回值放在一个 Future 对象中,然后根据相应的 get() 方法取得提交之后的返回值。
public class TaskWithResult implements Callable<String> {
private int id;
public TaskWithResult(int id){
this.id = id;
}
@Override
public String call() throws Exception {
return "result of TaskWithResult " + id;
}
}
public class CallableDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executors = Executors.newCachedThreadPool();
ArrayList<Future<String>> future = new ArrayList<>();
for(int i = 0;i < 10;i++){
// 返回的是调用 call 方法的结果
future.add(executors.submit(new TaskWithResult(i)));
}
for(Future<String> fs : future){
System.out.println(fs.get());
}
}
}
submit() 方法会返回 Future 对象,Future 对象存储的也就是你返回的结果。你也可以使用 isDone
来查询 Future 是否已经完成。
休眠
影响任务行为的一种简单方式就是使线程 休眠,选定给定的休眠时间,调用它的 sleep()
方法, 一般使用的TimeUnit
这个时间类替换 Thread.sleep()
方法,示例如下:
public class SuperclassThread extends TestThread{
@Override
public void run() {
System.out.println(Thread.currentThread() + "starting ..." );
try {
for(int i = 0;i < 5;i++){
if(i == 3){
System.out.println(Thread.currentThread() + "sleeping ...");
TimeUnit.MILLISECONDS.sleep(1000);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "wakeup and end ...");
}
public static void main(String[] args) {
ExecutorService executors = Executors.newCachedThreadPool();
for(int i = 0;i < 5;i++){
executors.execute(new SuperclassThread());
}
executors.shutdown();
}
}
关于 TimeUnit 中的 sleep() 方法和 Thread.sleep() 方法的比较,请参考下面这篇博客
优先级
上面提到线程调度器对每个线程的执行都是不可预知的,随机执行的,那么有没有办法告诉线程调度器哪个任务想要优先被执行呢?你可以通过设置线程的优先级状态,告诉线程调度器哪个线程的执行优先级比较高,"请给这个骑手马上派单",线程调度器倾向于让优先级较高的线程优先执行,然而,这并不意味着优先级低的线程得不到执行,也就是说,优先级不会导致死锁的问题。优先级较低的线程只是执行频率较低。
public class SimplePriorities implements Runnable{
private int priority;
public SimplePriorities(int priority) {
this.priority = priority;
}
@Override
public void run() {
Thread.currentThread().setPriority(priority);
for(int i = 0;i < 100;i++){
System.out.println(this);
if(i % 10 == 0){
Thread.yield();
}
}
}
@Override
public String toString() {
return Thread.currentThread() + " " + priority;
}
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
for(int i = 0;i < 5;i++){
service.execute(new SimplePriorities(Thread.MAX_PRIORITY));
}
service.execute(new SimplePriorities(Thread.MIN_PRIORITY));
}
}
toString() 方法被覆盖,以便通过使用 Thread.toString()
方法来打印线程的名称。你可以改写线程的默认输出,这里采用了 Thread[pool-1-thread-1,10,main] 这种形式的输出。
通过输出,你可以看到,最后一个线程的优先级最低,其余的线程优先级最高。注意,优先级是在 run 开头设置的,在构造器中设置它们不会有任何好处,因为这个时候线程还没有执行任务。
尽管JDK有10个优先级,但是一般只有MAX_PRIORITY,NORM_PRIORITY,MIN_PRIORITY 三种级别。
作出让步
我们上面提过,如果知道一个线程已经在 run() 方法中运行的差不多了,那么它就可以给线程调度器一个提示:我已经完成了任务中最重要的部分,可以让给别的线程使用CPU了。这个暗示将通过 yield() 方法作出。
有一个很重要的点就是,Thread.yield() 是建议执行切换CPU,而不是强制执行CPU切换。
对于任何重要的控制或者在调用应用时,都不能依赖于 yield()
方法,实际上, yield() 方法经常被滥用。
后台线程
后台(daemon) 线程,是指运行时在后台提供的一种服务线程,这种线程不是属于必须的。当所有非后台线程结束时,程序也就停止了,同时会终止所有的后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。
public class SimpleDaemons implements Runnable{
@Override
public void run() {
while (true){
try {
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(Thread.currentThread() + " " + this);
} catch (InterruptedException e) {
System.out.println("sleep() interrupted");
}
}
}
public static void main(String[] args) throws InterruptedException {
for(int i = 0;i < 10;i++){
Thread daemon = new Thread(new SimpleDaemons());
daemon.setDaemon(true);
daemon.start();
}
System.out.println("All Daemons started");
TimeUnit.MILLISECONDS.sleep(175);
}
}
在每次的循环中会创建10个线程,并把每个线程设置为后台线程,然后开始运行,for循环会进行十次,然后输出信息,随后主线程睡眠一段时间后停止运行。在每次run 循环中,都会打印当前线程的信息,主线程运行完毕,程序就执行完毕了。因为 daemon
是后台线程,无法影响主线程的执行。
但是当你把 daemon.setDaemon(true)
去掉时,while(true) 会进行无限循环,那么主线程一直在执行最重要的任务,所以会一直循环下去无法停止。
ThreadFactory
按需要创建线程的对象。使用线程工厂替换了 Thread 或者 Runnable 接口的硬连接,使程序能够使用特殊的线程子类,优先级等。一般的创建方式为
class SimpleThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
return new Thread(r);
}
}
Executors.defaultThreadFactory 方法提供了一个更有用的简单实现,它在返回之前将创建的线程上下文设置为已知值
ThreadFactory 是一个接口,它只有一个方法就是创建线程的方法
public interface ThreadFactory {
// 构建一个新的线程。实现类可能初始化优先级,名称,后台线程状态和 线程组等
Thread newThread(Runnable r);
}
下面来看一个 ThreadFactory 的例子
public class DaemonThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setDaemon(true);
return t;
}
}
public class DaemonFromFactory implements Runnable{
@Override
public void run() {
while (true){
try {
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(Thread.currentThread() + " " + this);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newCachedThreadPool(new DaemonThreadFactory());
for(int i = 0;i < 10;i++){
service.execute(new DaemonFromFactory());
}
System.out.println("All daemons started");
TimeUnit.MILLISECONDS.sleep(500);
}
}
Executors.newCachedThreadPool
可以接受一个线程池对象,创建一个根据需要创建新线程的线程池,但会在它们可用时重用先前构造的线程,并在需要时使用提供的ThreadFactory创建新线程。
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
加入一个线程
一个线程可以在其他线程上调用 join()
方法,其效果是等待一段时间直到第二个线程结束才正常执行。如果某个线程在另一个线程 t 上调用 t.join() 方法,此线程将被挂起,直到目标线程 t 结束才回复(可以用 t.isAlive() 返回为真假判断)。
也可以在调用 join 时带上一个超时参数,来设置到期时间,时间到期,join方法自动返回。
对 join 的调用也可以被中断,做法是在线程上调用 interrupted
方法,这时需要用到 try…catch 子句
public class TestJoinMethod extends Thread{
@Override
public void run() {
for(int i = 0;i < 5;i++){
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Interrupted sleep");
}
System.out.println(Thread.currentThread() + " " + i);
}
}
public static void main(String[] args) throws InterruptedException {
TestJoinMethod join1 = new TestJoinMethod();
TestJoinMethod join2 = new TestJoinMethod();
TestJoinMethod join3 = new TestJoinMethod();
join1.start();
// join1.join();
join2.start();
join3.start();
}
}
join() 方法等待线程死亡。 换句话说,它会导致当前运行的线程停止执行,直到它加入的线程完成其任务。
线程异常捕获
由于线程的本质,使你不能捕获从线程中逃逸的异常,一旦异常逃出任务的run 方法,它就会向外传播到控制台,除非你采取特殊的步骤捕获这种错误的异常,在 Java5 之前,你可以通过线程组来捕获,但是在 Java5 之后,就需要用 Executor 来解决问题,因为线程组不是一次好的尝试。
下面的任务会在 run 方法的执行期间抛出一个异常,并且这个异常会抛到 run 方法的外面,而且 main 方法无法对它进行捕获
public class ExceptionThread implements Runnable{
@Override
public void run() {
throw new RuntimeException();
}
public static void main(String[] args) {
try {
ExecutorService service = Executors.newCachedThreadPool();
service.execute(new ExceptionThread());
}catch (Exception e){
System.out.println("eeeee");
}
}
}
为了解决这个问题,我们需要修改 Executor 产生线程的方式,Java5 提供了一个新的接口 Thread.UncaughtExceptionHandler
,它允许你在每个 Thread 上都附着一个异常处理器。Thread.UncaughtExceptionHandler.uncaughtException()
会在线程因未捕获临近死亡时被调用。
public class ExceptionThread2 implements Runnable{
@Override
public void run() {
Thread t = Thread.currentThread();
System.out.println("run() by " + t);
System.out.println("eh = " + t.getUncaughtExceptionHandler());
// 手动抛出异常
throw new RuntimeException();
}
}
// 实现Thread.UncaughtExceptionHandler 接口,创建异常处理器
public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler{
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println("caught " + e);
}
}
public class HandlerThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
System.out.println(this + " creating new Thread");
Thread t = new Thread(r);
System.out.println("created " + t);
t.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
System.out.println("ex = " + t.getUncaughtExceptionHandler());
return t;
}
}
public class CaptureUncaughtException {
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool(new HandlerThreadFactory());
service.execute(new ExceptionThread2());
}
}
在程序中添加了额外的追踪机制,用来验证工厂创建的线程会传递给UncaughtExceptionHandler
,你可以看到,未捕获的异常是通过 uncaughtException
来捕获的。
Java锁事
Java 锁分类
Java 中的锁有很多,可以按照不同的功能、种类进行分类,下面是我对 Java 中一些常用锁的分类,包括一些基本的概述
- 从线程是否需要对资源加锁可以分为
悲观锁
和乐观锁
- 从资源已被锁定,线程是否阻塞可以分为
自旋锁
- 从多个线程并发访问资源,也就是 Synchronized 可以分为
无锁
、偏向锁
、轻量级锁
和重量级锁
- 从锁的公平性进行区分,可以分为
公平锁
和非公平锁
- 从根据锁是否重复获取可以分为
可重入锁
和不可重入锁
- 从那个多个线程能否获取同一把锁分为
共享锁
和排他锁
下面我们依次对各个锁的分类进行详细阐述。
线程是否需要对资源加锁
Java 按照是否对资源加锁分为乐观锁
和悲观锁
,乐观锁和悲观锁并不是一种真实存在的锁,而是一种设计思想,乐观锁和悲观锁对于理解 Java 多线程和数据库来说至关重要,下面就来探讨一下这两种实现方式的区别和优缺点
悲观锁
悲观锁
是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把资源
或者 数据
锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。悲观锁的实现往往依靠数据库本身的锁功能实现。
Java 中的 Synchronized
和 ReentrantLock
等独占锁(排他锁)也是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁,生怕自己心爱的宝贝被别人拿走。
乐观锁
乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过(具体如何判断我们下面再说)。乐观锁的实现方案一般来说有两种: 版本号机制
和 CAS实现
。乐观锁多适用于多度的应用类型,这样可以提高吞吐量。
在Java中java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
两种锁的使用场景
上面介绍了两种锁的基本概念,并提到了两种锁的适用场景,一般来说,悲观锁不仅会对写操作加锁还会对读操作加锁,一个典型的悲观锁调用:
select * from student where name="cxuan" for update
这条 sql 语句从 Student 表中选取 name = "cxuan" 的记录并对其加锁,那么其他写操作再这个事务提交之前都不会对这条数据进行操作,起到了独占和排他的作用。
悲观锁因为对读写都加锁,所以它的性能比较低,对于现在互联网提倡的三高
(高性能、高可用、高并发)来说,悲观锁的实现用的越来越少了,但是一般多读的情况下还是需要使用悲观锁的,因为虽然加锁的性能比较低,但是也阻止了像乐观锁一样,遇到写不一致的情况下一直重试的时间。
相对而言,乐观锁用于读多写少的情况,即很少发生冲突的场景,这样可以省去锁的开销,增加系统的吞吐量。
乐观锁的适用场景有很多,典型的比如说成本系统,柜员要对一笔金额做修改,为了保证数据的准确性和实效性,使用悲观锁锁住某个数据后,再遇到其他需要修改数据的操作,那么此操作就无法完成金额的修改,对产品来说是灾难性的一刻,使用乐观锁的版本号机制能够解决这个问题,我们下面说。
乐观锁的实现方式
乐观锁一般有两种实现方式:采用版本号机制
和 CAS(Compare-and-Swap,即比较并替换)
算法实现。
版本号机制
版本号机制是在数据表中加上一个 version
字段来实现的,表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1,当线程A要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
我们以上面的金融系统为例,来简述一下这个过程。
- 成本系统中有一个数据表,表中有两个字段分别是
金额
和version
,金额的属性是能够实时变化,而 version 表示的是金额每次发生变化的版本,一般的策略是,当金额发生改变时,version 采用递增的策略每次都在上一个版本号的基础上 + 1。 - 在了解了基本情况和基本信息之后,我们来看一下这个过程:公司收到回款后,需要把这笔钱放在金库中,假如金库中存有100 元钱
- 下面开启事务一:当男柜员执行回款写入操作前,他会先查看(读)一下金库中还有多少钱,此时读到金库中有 100 元,可以执行写操作,并把数据库中的钱更新为 120 元,提交事务,金库中的钱由 100 -> 120,version的版本号由 0 -> 1。
- 开启事务二:女柜员收到给员工发工资的请求后,需要先执行读请求,查看金库中的钱还有多少,此时的版本号是多少,然后从金库中取出员工的工资进行发放,提交事务,成功后版本 + 1,此时版本由 1 -> 2。
**上面两种情况是最乐观的情况,上面的两个事务都是顺序执行的,也就是事务一和事务二互不干扰,那么事务要并行执行会如何呢?
-
事务一开启,男柜员先执行读操作,取出金额和版本号,执行写操作
begin update 表 set 金额 = 120,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 120,版本号为1,事务还没有提交
事务二开启,女柜员先执行读操作,取出金额和版本号,执行写操作
begin update 表 set 金额 = 50,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 50,版本号变为 1,事务未提交
现在提交事务一,金额改为 120,版本变为1,提交事务。理想情况下应该变为 金额 = 50,版本号 = 2,但是实际上事务二 的更新是建立在金额为 100 和 版本号为 0 的基础上的,所以事务二不会提交成功,应该重新读取金额和版本号,再次进行写操作。
这样,就避免了女柜员 用基于 version = 0 的旧数据修改的结果覆盖男操作员操作结果的可能。
CAS 算法
**省略代码,完整代码请参照 看完你就应该能明白的悲观锁和乐观锁
CAS 即 compare and swap(比较与交换)
,是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization
Java 从 JDK1.5 开始支持,java.util.concurrent
包里提供了很多面向并发编程的类,也提供了 CAS 算法的支持,一些以 Atomic
为开头的一些原子类都使用 CAS 作为其实现方式。使用这些类在多核 CPU 的机器上会有比较好的性能。
如果要把证它们的原子性,必须进行加锁,使用 Synchronzied
或者 ReentrantLock
,我们前面介绍它们是悲观锁的实现,我们现在讨论的是乐观锁,那么用哪种方式保证它们的原子性呢?请继续往下看
CAS 中涉及三个要素:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
我们以 java.util.concurrent 中的 AtomicInteger
为例,看一下在不用锁的情况下是如何保证线程安全的
public class AtomicCounter {
private AtomicInteger integer = new AtomicInteger();
public AtomicInteger getInteger() {
return integer;
}
public void setInteger(AtomicInteger integer) {
this.integer = integer;
}
public void increment(){
integer.incrementAndGet();
}
public void decrement(){
integer.decrementAndGet();
}
}
public class AtomicProducer extends Thread{
private AtomicCounter atomicCounter;
public AtomicProducer(AtomicCounter atomicCounter){
this.atomicCounter = atomicCounter;
}
@Override
public void run() {
for(int j = 0; j < AtomicTest.LOOP; j++) {
System.out.println("producer : " + atomicCounter.getInteger());
atomicCounter.increment();
}
}
}
public class AtomicConsumer extends Thread{
private AtomicCounter atomicCounter;
public AtomicConsumer(AtomicCounter atomicCounter){
this.atomicCounter = atomicCounter;
}
@Override
public void run() {
for(int j = 0; j < AtomicTest.LOOP; j++) {
System.out.println("consumer : " + atomicCounter.getInteger());
atomicCounter.decrement();
}
}
}
public class AtomicTest {
final static int LOOP = 10000;
public static void main(String[] args) throws InterruptedException {
AtomicCounter counter = new AtomicCounter();
AtomicProducer producer = new AtomicProducer(counter);
AtomicConsumer consumer = new AtomicConsumer(counter);
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println(counter.getInteger());
}
}
经测试可得,不管循环多少次最后的结果都是0,也就是多线程并行的情况下,使用 AtomicInteger 可以保证线程安全性。 incrementAndGet 和 decrementAndGet 都是原子性操作。
乐观锁的缺点
任何事情都是有利也有弊,软件行业没有完美的解决方案只有最优的解决方案,所以乐观锁也有它的弱点和缺陷:
ABA 问题
ABA 问题说的是,如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?可以是由 A -> B -> A 的这种情况,但是 AtomicInteger 却不会这么认为,它只相信它看到的,它看到的是什么就是什么。
JDK 1.5 以后的 AtomicStampedReference
类就提供了此种能力,其中的 compareAndSet 方法
就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
也可以采用CAS的一个变种DCAS来解决这个问题。 DCAS,是对于每一个V增加一个引用的表示修改次数的标记符。对于每个V,如果引用修改了一次,这个计数器就加1。然后再这个变量需要update的时候,就同时检查变量的值和计数器的值。
循环开销大
我们知道乐观锁在进行写操作的时候会判断是否能够写入成功,如果写入不成功将触发等待 -> 重试机制,这种情况是一个自旋锁,简单来说就是适用于短期内获取不到,进行等待重试的锁,它不适用于长期获取不到锁的情况,另外,自旋循环对于性能开销比较大。
CAS与synchronized的使用情景
简单的来说 CAS 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况,使用 Synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
资源已被锁定,线程是否阻塞
自旋锁的提出背景
由于在多处理器环境中某些资源的有限性,有时需要互斥访问(mutual exclusion)
,这时候就需要引入锁的概念,只有获取了锁的线程才能够对资源进行访问,由于多线程的核心是CPU的时间分片,所以同一时刻只能有一个线程获取到锁。那么就面临一个问题,那么没有获取到锁的线程应该怎么办?
通常有两种处理方式:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING)
;还有一种处理方式就是把自己阻塞起来,等待重新调度请求,这种叫做互斥锁
。
什么是自旋锁
自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)
。
自旋锁的原理
自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。
因为自旋锁避免了操作系统进程调度和线程切换,所以自旋锁通常适用在时间比较短的情况下。由于这个原因,操作系统的内核经常使用自旋锁。但是,如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被 OS(Operating System)
调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。
解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。但是如何去选择自旋时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!JDK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cpu 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁。
自旋锁的实现
下面我们用Java 代码来实现一个简单的自旋锁
public class SpinLockTest {
private AtomicBoolean available = new AtomicBoolean(false);
public void lock(){
// 循环检测尝试获取锁
while (!tryLock()){
// doSomething...
}
}
public boolean tryLock(){
// 尝试获取锁,成功返回true,失败返回false
return available.compareAndSet(false,true);
}
public void unLock(){
if(!available.compareAndSet(true,false)){
throw new RuntimeException("释放锁失败");
}
}
}
这种简单的自旋锁有一个问题:无法保证多线程竞争的公平性。对于上面的 SpinlockTest,当多个线程想要获取锁时,谁最先将available
设为false
谁就能最先获得锁,这可能会造成某些线程一直都未获取到锁造成线程饥饿
。就像我们下课后蜂拥的跑向食堂,下班后蜂拥地挤向地铁,通常我们会采取排队的方式解决这样的问题,类似地,我们把这种锁叫排队自旋锁(QueuedSpinlock)。计算机科学家们使用了各种方式来实现排队自旋锁,如TicketLock,MCSLock,CLHLock。接下来我们分别对这几种锁做个大致的介绍。
TicketLock
在计算机科学领域中,TicketLock 是一种同步机制或锁定算法,它是一种自旋锁,它使用ticket
来控制线程执行顺序。
就像票据队列管理系统一样。面包店或者服务机构(例如银行)都会使用这种方式来为每个先到达的顾客记录其到达的顺序,而不用每次都进行排队。通常,这种地点都会有一个分配器(叫号器,挂号器等等都行),先到的人需要在这个机器上取出自己现在排队的号码,这个号码是按照自增的顺序进行的,旁边还会有一个标牌显示的是正在服务的标志,这通常是代表目前正在服务的队列号,当前的号码完成服务后,标志牌会显示下一个号码可以去服务了。
像上面系统一样,TicketLock 是基于先进先出(FIFO) 队列的机制。它增加了锁的公平性,其设计原则如下:TicketLock 中有两个 int 类型的数值,开始都是0,第一个值是队列ticket(队列票据)
, 第二个值是 出队(票据)
。队列票据是线程在队列中的位置,而出队票据是现在持有锁的票证的队列位置。可能有点模糊不清,简单来说,就是队列票据是你取票号的位置,出队票据是你距离叫号的位置。现在应该明白一些了吧。
当叫号叫到你的时候,不能有相同的号码同时办业务,必须只有一个人可以去办,办完后,叫号机叫到下一个人,这就叫做原子性
。你在办业务的时候不能被其他人所干扰,而且不可能会有两个持有相同号码的人去同时办业务。然后,下一个人看自己的号是否和叫到的号码保持一致,如果一致的话,那么就轮到你去办业务,否则只能继续等待。上面这个流程的关键点在于,每个办业务的人在办完业务之后,他必须丢弃自己的号码,叫号机才能继续叫到下面的人,如果这个人没有丢弃这个号码,那么其他人只能继续等待。下面来实现一下这个票据排队方案
public class TicketLock {
// 队列票据(当前排队号码)
private AtomicInteger queueNum = new AtomicInteger();
// 出队票据(当前需等待号码)
private AtomicInteger dueueNum = new AtomicInteger();
// 获取锁:如果获取成功,返回当前线程的排队号
public int lock(){
int currentTicketNum = dueueNum.incrementAndGet();
while (currentTicketNum != queueNum.get()){
// doSomething...
}
return currentTicketNum;
}
// 释放锁:传入当前排队的号码
public void unLock(int ticketNum){
queueNum.compareAndSet(ticketNum,ticketNum + 1);
}
}
每次叫号机在叫号的时候,都会判断自己是不是被叫的号,并且每个人在办完业务的时候,叫号机根据在当前号码的基础上 + 1,让队列继续往前走。
但是上面这个设计是有问题的,因为获得自己的号码之后,是可以对号码进行更改的,这就造成系统紊乱,锁不能及时释放。这时候就需要有一个能确保每个人按会着自己号码排队办业务的角色,在得知这一点之后,我们重新设计一下这个逻辑
public class TicketLock2 {
// 队列票据(当前排队号码)
private AtomicInteger queueNum = new AtomicInteger();
// 出队票据(当前需等待号码)
private AtomicInteger dueueNum = new AtomicInteger();
private ThreadLocal<Integer> ticketLocal = new ThreadLocal<>();
public void lock(){
int currentTicketNum = dueueNum.incrementAndGet();
// 获取锁的时候,将当前线程的排队号保存起来
ticketLocal.set(currentTicketNum);
while (currentTicketNum != queueNum.get()){
// doSomething...
}
}
// 释放锁:从排队缓冲池中取
public void unLock(){
Integer currentTicket = ticketLocal.get();
queueNum.compareAndSet(currentTicket,currentTicket + 1);
}
}
这次就不再需要返回值,办业务的时候,要将当前的这一个号码缓存起来,在办完业务后,需要释放缓存的这条票据。
**缺点
TicketLock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量queueNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
为了解决这个问题,MCSLock 和 CLHLock 应运而生。
CLHLock
上面说到TicketLock 是基于队列的,那么 CLHLock 就是基于链表设计的,CLH的发明人是:Craig,Landin and Hagersten,用它们各自的字母开头命名。CLH 是一种基于链表的可扩展,高性能,公平的自旋锁,申请线程只能在本地变量上自旋,它会不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
public class CLHLock {
public static class CLHNode{
private volatile boolean isLocked = true;
}
// 尾部节点
private volatile CLHNode tail;
private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<>();
private static final AtomicReferenceFieldUpdater<CLHLock,CLHNode> UPDATER =
AtomicReferenceFieldUpdater.newUpdater(CLHLock.class,CLHNode.class,"tail");
public void lock(){
// 新建节点并将节点与当前线程保存起来
CLHNode node = new CLHNode();
LOCAL.set(node);
// 将新建的节点设置为尾部节点,并返回旧的节点(原子操作),这里旧的节点实际上就是当前节点的前驱节点
CLHNode preNode = UPDATER.getAndSet(this,node);
if(preNode != null){
// 前驱节点不为null表示当锁被其他线程占用,通过不断轮询判断前驱节点的锁标志位等待前驱节点释放锁
while (preNode.isLocked){
}
preNode = null;
LOCAL.set(node);
}
// 如果不存在前驱节点,表示该锁没有被其他线程占用,则当前线程获得锁
}
public void unlock() {
// 获取当前线程对应的节点
CLHNode node = LOCAL.get();
// 如果tail节点等于node,则将tail节点更新为null,同时将node的lock状态职位false,表示当前线程释放了锁
if (!UPDATER.compareAndSet(this, node, null)) {
node.isLocked = false;
}
node = null;
}
}
MCSLock
MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。MCS 来自于其发明人名字的首字母: John Mellor-Crummey 和 Michael Scott。
public class MCSLock {
public static class MCSNode {
volatile MCSNode next;
volatile boolean isLocked = true;
}
private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<>();
// 队列
@SuppressWarnings("unused")
private volatile MCSNode queue;
private static final AtomicReferenceFieldUpdater<MCSLock,MCSNode> UPDATE =
AtomicReferenceFieldUpdater.newUpdater(MCSLock.class,MCSNode.class,"queue");
public void lock(){
// 创建节点并保存到ThreadLocal中
MCSNode currentNode = new MCSNode();
NODE.set(currentNode);
// 将queue设置为当前节点,并且返回之前的节点
MCSNode preNode = UPDATE.getAndSet(this, currentNode);
if (preNode != null) {
// 如果之前节点不为null,表示锁已经被其他线程持有
preNode.next = currentNode;
// 循环判断,直到当前节点的锁标志位为false
while (currentNode.isLocked) {
}
}
}
public void unlock() {
MCSNode currentNode = NODE.get();
// next为null表示没有正在等待获取锁的线程
if (currentNode.next == null) {
// 更新状态并设置queue为null
if (UPDATE.compareAndSet(this, currentNode, null)) {
// 如果成功了,表示queue==currentNode,即当前节点后面没有节点了
return;
} else {
// 如果不成功,表示queue!=currentNode,即当前节点后面多了一个节点,表示有线程在等待
// 如果当前节点的后续节点为null,则需要等待其不为null(参考加锁方法)
while (currentNode.next == null) {
}
}
} else {
// 如果不为null,表示有线程在等待获取锁,此时将等待线程对应的节点锁状态更新为false,同时将当前线程的后继节点设为null
currentNode.next.isLocked = false;
currentNode.next = null;
}
}
}
CLHLock 和 MCSLock
- 都是基于链表,不同的是CLHLock是基于隐式链表,没有真正的后续节点属性,MCSLock是显示链表,有一个指向后续节点的属性。
- 将获取锁的线程状态借助节点(node)保存,每个线程都有一份独立的节点,这样就解决了TicketLock多处理器缓存同步的问题。
多个线程并发访问资源
锁状态的分类
Java 语言专门针对 synchronized
关键字设置了四种状态,它们分别是:无锁、偏向锁、轻量级锁和重量级锁,但是在了解这些锁之前还需要先了解一下 Java 对象头和 Monitor。
Java 对象头
我们知道 synchronized 是悲观锁,在操作同步之前需要给资源加锁,这把锁就是对象头里面的,而Java 对象头又是什么呢?我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段)
和 class Pointer(类型指针)
。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
class Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
在32位虚拟机和64位虚拟机的 Mark Word 所占用的字节大小不一样,32位虚拟机的 Mark Word 和 class Pointer 分别占用 32bits 的字节,而 64位虚拟机的 Mark Word 和 class Pointer 占用了64bits 的字节,下面我们以 32位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的
用中文翻译过来就是
- 无状态也就是
无锁
的时候,对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01 偏向锁
中划分更细,还是开辟25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01轻量级锁
中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00重量级锁
中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11GC标记
开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK
中的markOop.hpp类中的枚举窥出端倪
来解释一下
- age_bits 就是我们说的分代回收的标识,占用4字节
- lock_bits 是锁的标志位,占用2个字节
- biased_lock_bits 是是否偏向锁的标识,占用1个字节
- max_hash_bits 是针对无锁计算的hashcode 占用字节数量,如果是32位虚拟机,就是 32 – 4 – 2 -1 = 25 byte,如果是64 位虚拟机,64 – 4 – 2 – 1 = 57 byte,但是会有 25 字节未使用,所以64位的 hashcode 占用 31 byte
- hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取31,否则取真实的字节数
- cms_bits 我觉得应该是不是64位虚拟机就占用 0 byte,是64位就占用 1byte
- epoch_bits 就是 epoch 所占用的字节大小,2字节。
Synchronized锁
synchronized
用的锁记录是存在Java对象头里的。
JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应地,在执行 monitorexit 指令时会将锁计数器减1,当计数器被减到0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
Monitor
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁
。
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁
和轻量级锁
:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
所以锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。
锁的分类及其解释
先来个大体的流程图来感受一下这个过程,然后下面我们再分开来说
无锁
无锁状态
,无锁即没有对资源进行锁定,所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源。
无锁的特点就是在循环内进行修改操作,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出,在此过程中没有出现冲突的发生,这很像我们在之前文章中介绍的 CAS 实现,CAS 的原理和应用就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
可以从对象头的分配中看到,偏向锁要比无锁多了线程ID
和 epoch
,下面我们就来描述一下偏向锁的获取过程
**偏向锁获取过程
- 首先线程访问同步代码块,会通过检查对象头 Mark Word 的
锁标志位
判断目前锁的状态,如果是 01,说明就是无锁或者偏向锁,然后再根据是否偏向锁
的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步 - 线程使用 CAS 操作来尝试对对象加锁,如果使用 CAS 替换 ThreadID 成功,就说明是第一次上锁,那么当前线程就会获得对象的偏向锁,此时会在对象头的 Mark Word 中记录当前线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。
全局安全点(Safe Point):全局安全点的理解会涉及到 C 语言底层的一些知识,这里简单理解 SafePoint 是 Java 代码中的一个线程可能暂停执行的位置。
等到下一次线程在进入和退出同步代码块时就不需要进行 CAS
操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的。如果用流程图来表示的话就是下面这样
**关闭偏向锁
偏向锁在Java 6 和Java 7 里是默认启用
的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态。
**关于 epoch
偏向锁的对象头中有一个被称为 epoch
的值,它作为偏差有效性的时间戳。
轻量级锁
轻量级锁
是指当前锁是偏向锁的时候,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁
,其他线程会通过自旋
的形式尝试获取锁,不会阻塞,从而提高性能,下面是详细的获取过程。
**轻量级锁加锁过程
- 紧接着上一步,如果 CAS 操作替换 ThreadID 没有获取成功,执行下一步
- 如果使用 CAS 操作替换 ThreadID 失败(这时候就切换到另外一个线程的角度)说明该资源已被同步访问过,这时候就会执行锁的撤销操作,撤销偏向锁,然后等原持有偏向锁的线程到达
全局安全点(SafePoint)
时,会暂停原持有偏向锁的线程,然后会检查原持有偏向锁的状态,如果已经退出同步,就会唤醒持有偏向锁的线程,执行下一步 - 检查对象头中的 Mark Word 记录的是否是当前线程 ID,如果是,执行同步代码,如果不是,执行偏向锁获取流程 的第2步。
如果用流程表示的话就是下面这样(已经包含偏向锁的获取)
重量级锁
重量级锁的获取流程比较复杂,小伙伴们做好准备,其实多看几遍也没那么麻烦,呵呵。
**重量级锁的获取流程
-
接着上面偏向锁的获取过程,由偏向锁升级为轻量级锁,执行下一步
-
会在原持有偏向锁的线程的栈中分配锁记录,将对象头中的 Mark Word 拷贝到原持有偏向锁线程的记录中,然后原持有偏向锁的线程获得轻量级锁,然后唤醒原持有偏向锁的线程,从安全点处继续执行,执行完毕后,执行下一步,当前线程执行第4步
-
执行完毕后,开始轻量级解锁操作,解锁需要判断两个条件
- 判断对象头中的 Mark Word 中锁记录指针是否指向当前栈中记录的指针
-
拷贝在当前线程锁记录的 Mark Word 信息是否与对象头中的 Mark Word 一致。
如果上面两个判断条件都符合的话,就进行锁释放,如果其中一个条件不符合,就会释放锁,并唤起等待的线程,进行新一轮的锁竞争。
-
在当前线程的栈中分配锁记录,拷贝对象头中的 MarkWord 到当前线程的锁记录中,执行 CAS 加锁操作,会把对象头 Mark Word 中锁记录指针指向当前线程锁记录,如果成功,获取轻量级锁,执行同步代码,然后执行第3步,如果不成功,执行下一步
-
当前线程没有使用 CAS 成功获取锁,就会自旋一会儿,再次尝试获取,如果在多次自旋到达上限后还没有获取到锁,那么轻量级锁就会升级为
重量级锁
如果用流程图表示是这样的
锁的公平性与非公平性
我们知道,在并发环境中,多个线程需要对同一资源进行访问,同一时刻只能有一个线程能够获取到锁并进行资源访问,那么剩下的这些线程怎么办呢?这就好比食堂排队打饭的模型,最先到达食堂的人拥有最先买饭的权利,那么剩下的人就需要在第一个人后面排队,这是理想的情况,即每个人都能够买上饭。那么现实情况是,在你排队的过程中,就有个别不老实的人想走捷径,插队打饭,如果插队的这个人后面没有人制止他这种行为,他就能够顺利买上饭,如果有人制止,他就也得去队伍后面排队。
对于正常排队的人来说,没有人插队,每个人都在等待排队打饭的机会,那么这种方式对每个人来说都是公平的,先来后到嘛。这种锁也叫做公平锁。
那么假如插队的这个人成功买上饭并且在买饭的过程不管有没有人制止他,他的这种行为对正常排队的人来说都是不公平的,这在锁的世界中也叫做非公平锁。
那么我们根据上面的描述可以得出下面的结论
公平锁表示线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的FIFO先进先出顺序。而非公平锁就是一种获取锁的抢占机制,是随机获得锁的,和公平锁不一样的就是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。
锁公平性的实现
在 Java 中,我们一般通过 ReetrantLock
来实现锁的公平性
我们分别通过两个例子来讲解一下锁的公平性和非公平性
**锁的公平性
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();
}
}
}
我们创建了一个 ReetrantLock,并给构造函数传了一个 true,我们可以查看 ReetrantLock 的构造函数
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
根据 JavaDoc 的注释可知,如果是 true 的话,那么就会创建一个 ReentrantLock 的公平锁,然后并创建一个 FairSync
,FairSync 其实是一个 Sync
的内部类,它的主要作用是同步对象以获取公平锁。
而 Sync 是 ReentrantLock 中的内部类,Sync 继承 AbstractQueuedSynchronizer
类,AbstractQueuedSynchronizer 就是我们常说的 AQS ,它是 JUC(java.util.concurrent) 中最重要的一个类,通过它来实现独占锁和共享锁。
abstract static class Sync extends AbstractQueuedSynchronizer {...}
也就是说,我们把 fair 参数设置为 true 之后,就可以实现一个公平锁了,是这样吗?我们回到示例代码,我们可以执行一下这段代码,它的输出是顺序获取的(碍于篇幅的原因,这里就暂不贴出了),也就是说我们创建了一个公平锁
**锁的非公平性
与公平性相对的就是非公平性,我们通过设置 fair
参数为 true,便实现了一个公平锁,与之相对的,我们把 fair 参数设置为 false,是不是就是非公平锁了?用事实证明一下
private ReentrantLock lock = new ReentrantLock(false);
其他代码不变,我们执行一下看看输出(部分输出)
Thread-1启动
Thread-4启动
Thread-1正在持有锁
Thread-1释放了锁
Thread-5启动
Thread-6启动
Thread-3启动
Thread-7启动
Thread-2启动
可以看到,线程的启动并没有按顺序获取,可以看出非公平锁对锁的获取是乱序的,即有一个抢占锁的过程。也就是说,我们把 fair 参数设置为 false 便实现了一个非公平锁。
ReentrantLock 基本概述
ReentrantLock 是一把可重入锁
,也是一把互斥锁
,它具有与 synchronized
相同的方法和监视器锁的语义,但是它比 synchronized 有更多可扩展的功能。
ReentrantLock 的可重入性是指它可以由上次成功锁定但还未解锁的线程拥有。当只有一个线程尝试加锁时,该线程调用 lock()
方法会立刻返回成功并直接获取锁。如果当前线程已经拥有这把锁,这个方法会立刻返回。可以使用 isHeldByCurrentThread
和 getHoldCount
进行检查。
这个类的构造函数接受可选择的 fairness 参数,当 fairness 设置为 true 时,在多线程争夺尝试加锁时,锁倾向于对等待时间最长的线程访问,这也是公平性的一种体现。否则,锁不能保证每个线程的访问顺序,也就是非公平锁。与使用默认设置的程序相比,使用许多线程访问的公平锁的程序可能会显示较低
的总体吞吐量(即较慢;通常要慢得多)。但是获取锁并保证线程不会饥饿的次数比较小。无论如何请注意:锁的公平性不能保证线程调度的公平性。因此,使用公平锁的多线程之一可能会连续多次获得它,而其他活动线程没有进行且当前未持有该锁。这也是互斥性
的一种体现。
也要注意的 tryLock()
方法不支持公平性。如果锁是可以获取的,那么即使其他线程等待,它仍然能够返回成功。
推荐使用下面的代码来进行加锁和解锁
class MyFairLock {
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
// ...
} finally {
lock.unlock()
}
}
}
ReentrantLock 锁通过同一线程最多支持2147483647个递归锁。 尝试超过此限制会导致锁定方法引发错误。
ReentrantLock 如何实现锁公平性
我们在上面的简述中提到,ReentrantLock 是可以实现锁的公平性的,那么原理是什么呢?下面我们通过其源码来了解一下 ReentrantLock 是如何实现锁的公平性的
跟踪其源码发现,调用 Lock.lock()
方法其实是调用了 sync
的内部的方法
abstract void lock();
而 sync 是最基础的同步控制 Lock 的类,它有公平锁和非公平锁的实现。它继承 AbstractQueuedSynchronizer
即 使用 AQS 状态代表锁持有的数量。
lock 是抽象方法是需要被子类实现的,而继承了 AQS 的类主要有
我们可以看到,所有实现了 AQS 的类都位于 JUC 包下,主要有五类:ReentrantLock
、ReentrantReadWriteLock
、Semaphore
、CountDownLatch
和 ThreadPoolExecutor
,其中 ReentrantLock、ReentrantReadWriteLock、Semaphore 都可以实现公平锁和非公平锁。
下面是公平锁 FairSync
的继承关系
非公平锁的NonFairSync
的继承关系
由继承图可以看到,两个类的继承关系都是相同的,我们从源码发现,公平锁和非公平锁的实现就是下面这段代码的区别(下一篇文章我们会从原理角度分析一下公平锁和非公平锁的实现)
通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()
。
hasQueuedPredecessors() 也是 AQS 中的方法,它主要是用来 查询是否有任何线程在等待获取锁的时间比当前线程长,也就是说每个等待线程都是在一个队列中,此方法就是判断队列中在当前线程获取锁时,是否有等待锁时间比自己还长的队列,如果当前线程之前有排队的线程,返回 true,如果当前线程位于队列的开头或队列为空,返回 false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
根据锁是否可重入进行区分
可重入锁
可重入锁又称为递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java 中 ReentrantLock
和synchronized
都是可重入锁,可重入锁的一个优点是在一定程度上可以避免死锁。
我们先来看一段代码来说明一下 synchronized 的可重入性
private synchronized void doSomething(){
System.out.println("doSomething...");
doSomethingElse();
}
private synchronized void doSomethingElse(){
System.out.println("doSomethingElse...");
}
在上面这段代码中,我们对 doSomething()
和 doSomethingElse()
分别使用了 synchronized
进行锁定,doSomething() 方法中调用了 doSomethingElse() 方法,因为 synchronized 是可重入锁,所以同一个线程在调用 doSomething() 方法时,也能够进入 doSomethingElse() 方法中。
不可重入锁
如果 synchronized 是不可重入锁的话,那么在调用 doSomethingElse() 方法的时候,必须把 doSomething() 的锁丢掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
也就是说,不可重入锁会造成死锁
多个线程能够共享同一把锁
独占锁和共享锁
独占多和共享锁一般对应 JDK 源码的 ReentrantLock 和 ReentrantReadWriteLock 源码来介绍独占锁和共享锁。
独占锁又叫做排他锁,是指锁在同一时刻只能被一个线程拥有,其他线程想要访问资源,就会被阻塞。JDK 中 synchronized和 JUC 中 Lock 的实现类就是互斥锁。
共享锁指的是锁能够被多个线程所拥有,如果某个线程对资源加上共享锁后,则其他线程只能对资源再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
我们看到 ReentrantReadWriteLock 有两把锁:ReadLock
和 WriteLock
,也就是一个读锁一个写锁,合在一起叫做读写锁。再进一步观察可以发现 ReadLock 和 WriteLock 是靠内部类 Sync 实现的锁。Sync 是继承于 AQS 子类的,AQS 是并发的根本,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在 ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
Java线程池
我们知道,线程需要的时候要进行创建,不需要的时候需要进行销毁,但是线程的创建和销毁都是一个开销比较大的操作。
为什么开销大呢?
虽然我们程序员创建一个线程很容易,直接使用 new Thread() 创建就可以了,但是操作系统做的工作会多很多,它需要发出 系统调用
,陷入内核,调用内核 API 创建线程,为线程分配资源等,这一些操作有很大的开销。
所以,在高并发大流量的情况下,频繁的创建和销毁线程会大大拖慢响应速度,那么有什么能够提高响应速度的方式吗?方式有很多,尽量避免线程的创建和销毁是一种提升性能的方式,也就是把线程 复用
起来,因为性能是我们日常最关注的因素。
本篇文章我们先来通过认识一下 Executor 框架、然后通过描述线程池的基本概念入手、逐步认识线程池的核心类,然后慢慢进入线程池的原理中,带你一步一步理解线程池。
在 Java 中可以通过线程池来达到这样的效果。今天我们就来详细讲解一下 Java 的线程池
。
Executor 框架
为什么要先说一下 Executor 呢?因为我认为 Executor 是线程池的一个驱动,我们平常创建并执行线程用的一般都是 new Thread().start() 这个方法,这个方法更多强调 创建一个线程并开始运行。而我们后面讲到创建线程池更多体现在驱动执行上。
Executor 的总体框架如下,我们下面会对 Executor 框架中的每个类进行介绍。
我们首先来认识一下 Executor
Executor 接口
Executor 是 java.util.concurrent
的顶级接口,这个接口只有一个方法,那就是 execute
方法。我们平常创建并启动线程会使用 new Thread().start()
,而 Executor 中的 execute 方法替代了显示创建线程的方式。Executor 的设计初衷就是将任务提交和任务执行细节进行解藕。使用 Executor 框架,你可以使用如下的方式创建线程
Executor executor = Executors.xxx // xxx 其实就是 Executor 的实现类,我们后面会说
executor.execute(new RunnableTask1());
executor.execute(new RunnableTask2());
execute方法接收一个 Runnable
实例,它用来执行一个任务,而任务就是一个实现了 Runnable 接口的类,但是 execute 方法不能接收实现了 Callable
接口的类,也就是说,execute 方法不能接收具有返回值的任务。
execute 方法创建的线程是异步执行的,也就是说,你不用等待每个任务执行完毕后再执行下一个任务。
比如下面就是一个简单的使用 Executor 创建并执行线程的示例
public class RunnableTask implements Runnable{
@Override
public void run() {
System.out.println("running");
}
public static void main(String[] args) {
Executor executor = Executors.newSingleThreadExecutor(); // 你可能不太理解这是什么意思,我们后面会说。
executor.execute(new RunnableTask());
}
}
Executor 就相当于是族长,大佬只发号令,族长让你异步执行你就得异步执行,族长说不用汇报
任务你就不用回报,但是这个族长管的事情有点少,所以除了 Executor 之外,我们还需要认识其他管家,比如说管你这个线程啥时候终止,啥时候暂停,判断你这个线程当前的状态等,ExecutorService
就是一位大管家。
ExecutorService 接口
ExecutorService 也是一个接口,它是 Executor 的拓展,提供了一些 Executor 中没有的方法,下面我们来介绍一下这些方法
void shutdown();
shutdown
方法调用后,ExecutorService 会有序关闭正在执行的任务,但是不接受新任务。如果任务已经关闭,那么这个方法不会产生任何影响。
ExecutorService 还有一个和 shutdown 方法类似的方法是
List<Runnable> shutdownNow();
shutdownNow
会尝试停止关闭所有正在执行的任务,停止正在等待的任务,并返回正在等待执行的任务列表。
既然 shutdown 和 shutdownNow 这么相似,那么二者有啥区别呢?
- shutdown 方法只是会将
线程池
的状态设置为SHUTWDOWN
,正在执行的任务会继续执行下去,线程池会等待任务的执行完毕,而没有执行的线程则会中断。- shutdownNow 方法会将线程池的状态设置为
STOP
,正在执行和等待的任务则被停止,返回等待执行的任务列表
ExecutorService 还有三个判断线程状态的方法,分别是
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException;
isShutdown
方法表示执行器是否已经关闭,如果已经关闭,返回 true,否则返回 false。isTerminated
方法表示判断所有任务再关闭后是否已完成,如果完成返回 false,这个需要注意一点,除非首先调用 shutdown 或者 shutdownNow 方法,否则 isTerminated 方法永远不会为 true。awaitTermination
方法会阻塞,直到发出调用 shutdown 请求后所有的任务已经完成执行后才会解除。这个方法不是非常容易理解,下面通过一个小例子来看一下。
public static ExecutorService executorService = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
System.out.println("Waiting...");
boolean isTermination = executorService.awaitTermination(3, TimeUnit.SECONDS);
System.out.println("Waiting...Done");
if(isTermination){
System.out.println("All Thread Done");
}
System.out.println(Thread.currentThread().getName());
}
如果在调用 executorService.shutdown() 之后,所有线程完成任务,isTermination 返回 true,程序才会打印出 All Thread Done ,如果注释掉 executorService.shutdown() 或者在任务没有完成后 awaitTermination 就超时了,那么 isTermination 就会返回 false。
ExecutorService 当大管家还有一个原因是因为它不仅能够包容 Runnable 对象,还能够接纳 Callable
对象。在 ExecutorService 中,submit
方法扮演了这个角色。
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);
submit 方法会返回一个 Future
对象,<T>
表示范型,它是对 Callable 产生的返回值来说的,submit 方法提交的任务中的 call 方法如果返回 Integer,那么 submit 方法就返回 Future<Integer>
,依此类推。
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
throws InterruptedException;
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit)
throws InterruptedException;
invokeAll
方法用于执行给定的任务结合,执行完成后会返回一个任务列表,任务列表每一项是一个任务,每个任务会包括任务状态和执行结果,同样 invokeAll 方法也会返回 Future 对象。
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
throws InterruptedException, ExecutionException;
<T> T invokeAny(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
invokeAny 会获得最先完成任务的结果,即Callable<T>
接口中的 call 的返回值,在获得结果时,会中断其他正在执行的任务,具有阻塞性
。
大管家的职责相对于组长来说标准更多,管的事情也比较宽,但是大管家毕竟也是家族的中流砥柱,他不会做具体的活,他的下面有各个干将,干将是一个家族的核心,他负责完成大管家的工作。
AbstractExecutorService 抽象类
AbstractExecutorService 是一个抽象类,它实现了 ExecutorService 中的部分方法,它相当一个干将,会分析大管家有哪些要做的工作,然后针对大管家的要求做一些具体的规划,然后找他的得力助手 ThreadPoolExecutor
来完成目标。
AbstractExecutorService 这个抽象类主要实现了 invokeAll
和 invokeAny
方法,关于这两个方法的源码分析我们会在后面进行解释。
ScheduledExecutorService 接口
ScheduledExecutorService 也是一个接口,它扩展了 ExecutorService 接口,提供了 ExecutorService 接口所没有的功能,ScheduledExecutorService 顾名思义就是一个定时执行器
,定时执行器可以安排命令在一定延迟时间后运行或者定期执行。
它主要有三个接口方法,一个重载方法。下面我们先来看一下这两个重载方法。
public ScheduledFuture<?> schedule(Runnable command,
long delay, TimeUnit unit);
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
long delay, TimeUnit unit);
schedule
方法能够延迟一定时间后执行任务,并且只能执行一次。可以看到,schedule 方法也返回了一个 ScheduledFuture
对象,ScheduledFuture 对象扩展了 Future 和 Delayed 接口,它表示异步延迟计算的结果。schedule 方法支持零延迟和负延迟,这两类值都被视为立即执行任务。
还有一点需要说明的是,schedule 方法能够接收相对的时间和周期作为参数,而不是固定的日期,你可以使用 date.getTime – System.currentTimeMillis() 来得到相对的时间间隔。
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
long initialDelay,
long period,
TimeUnit unit);
scheduleAtFixedRate 表示任务会根据固定的速率在时间 initialDelay
后不断地执行。
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
long initialDelay,
long delay,
TimeUnit unit);
这个方法和上面的方法很类似,它表示的是以固定延迟时间的方式来执行任务。
scheduleAtFixedRate 和 scheduleWithFixedDelay 这两个方法容易混淆,下面我们通过一个示例来说明一下这两个方法的区别。
public class ScheduleTest {
public static void main(String[] args) {
Runnable command = () -> {
long startTime = System.currentTimeMillis();
System.out.println("current timestamp = " + startTime);
try {
TimeUnit.MILLISECONDS.sleep(new Random().nextInt(100));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("time spend = " + (System.currentTimeMillis() - startTime));
};
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10);
scheduledExecutorService.scheduleAtFixedRate(command,100,1000,TimeUnit.MILLISECONDS);
}
}
输出结果大致如下
可以看到,没次打印出来 current timestamp 的时间间隔大约等于 1000 毫秒,所以可以断定 scheduleAtFixedRate
是以恒定的速率来执行任务的。
然后我们再看一下 scheduleWithFixedDelay
方法,和上面测试类一样,只不过我们把 scheduleAtFixedRate 换为了 scheduleWithFixedDelay 。
scheduledExecutorService.scheduleWithFixedDelay(command,10,1000,TimeUnit.MILLISECONDS);
然后观察一下输出结果
可以看到,两个 current timestamp 之间的间隔大约等于 1000(固定时间) + delay(time spend) 的总和,由此可以确定 scheduleWithFixedDelay
是以固定时延来执行的。
线程池的描述
下面我们先来认识一下什么是线程池,线程池从概念上来看就是一个池子
,什么池子呢?是指管理同一组工作线程的池子,也就是说,线程池会统一管理内部的工作线程。
wiki 上说,线程池其实就是一种软件设计模式,这种设计模式用于实现计算机程序中的并发。
比如下面就是一个简单的线程池概念图。
注意:这个图只是一个概念模型,不是真正的线程池实现,希望读者不要混淆。
可以看到,这种其实也相当于是生产者-消费者模型,任务队列中的线程会进入到线程池中,由线程池进行管理,线程池中的一个个线程就是工作线程,工作线程执行完毕后会放入完成队列中,代表已经完成的任务。
上图有个缺点,那就是队列中的线程执行完毕后就会销毁,销毁就会产生性能损耗,降低响应速度,而我们使用线程池的目的往往是需要把线程重用起来,提高程序性能。
所以我们应该把执行完成后的工作线程重新利用起来,等待下一次使用。
线程池创建
我们上面大概聊了一下什么线程池的基本执行机制,你知道了线程是如何复用的,那么任何事物不可能是凭空出现的,线程也一样,那么它是如何创建出来的呢?下面就不得不提一个工具类,那就是 Executors
。
Executors 也是java.util.concurrent
包下的成员,它是一个创建线程池的工厂,可以使用静态工厂方法来创建线程池,下面就是 Executors 所能够创建线程池的具体类型。
-
newFixedThreadPool
:newFixedThreadPool 将会创建固定数量的线程池,这个数量可以由程序员通过创建Executors.newFixedThreadPool(int nThreads)
时手动指定,每次提交一个任务就会创建一个线程,在任何时候,nThreads 的值是最多允许活动的线程。如果在所有线程都处于活跃状态时有额外的任务被创建,这些新创建的线程会进入等待队列等待线程调度。如果有任何线程由于执行期间出现意外导致线程终止
,那么在执行后续任务时会使用等待队列中的线程进行替代。 -
newWorkStealingPool
:newWorkStealingPool 是 JDK1.8 新增加的线程池,它是基于fork-join
机制的一种线程池实现,使用了Work-Stealing
算法。newWorkStealingPool 会创建足够的线程来支持并行度,会使用多个队列来减少竞争。work-stealing pool 线程池不会保证提交任务的执行顺序。 -
newSingleThreadExecutor
:newSingleThreadExecutor 是一个单线程的执行器,它只会创建单个
线程来执行任务,如果这个线程异常结束,则会创建另外一个线程来替代。newSingleThreadExecutor 会确保任务在任务队列中的执行次序,也就是说,任务的执行是有序的
。 -
newCachedThreadPool
:newCachedThreadPool 会根据实际需要创建一个可缓存的线程池。如果线程池的线程数量超过实际需要处理的任务,那么 newCachedThreadPool 将会回收多余的线程。如果实际需要处理的线程不能满足任务的数量,则回你添加新的线程到线程池中,线程池中线程的数量不存在任何限制。 -
newSingleThreadScheduledExecutor
:newSingleThreadScheduledExecutor 和 newSingleThreadExecutor 很类似,只不过带有 scheduled 的这个执行器哥们能够在一定延迟后执行或者定期执行任务。 -
newScheduledThreadPool
:这个线程池和上面的 scheduled 执行器类似,只不过 newSingleThreadScheduledExecutor 比 newScheduledThreadPool 多加了一个DelegatedScheduledExecutorService
代理,这其实包装器设计模式的体现。
上面这些线程池的底层实现都是由 ThreadPoolExecutor 来提供支持的,所以要理解这些线程池的工作原理,你就需要先把 ThreadPoolExecutor 搞明白,下面我们就来聊一聊 ThreadPoolExecutor。
ThreadPoolExecutor 类
ThreadPoolExecutor
位于 java.util.concurrent
工具类下,可以说它是线程池中最核心的一个类了。如果你要想把线程池理解透彻的话,就要首先了解一下这个类。
如果我们再拿上面家族举例子的话,ThreadPoolExecutor 就是一个家族的骨干人才,家族顶梁柱。ThreadPoolExecutor 做的工作真是太多太多了。
首先,ThreadPoolExecutor 提供了四个构造方法,然而前三个构造方法最终都会调用最后一个构造方法进行初始化
public class ThreadPoolExecutor extends AbstractExecutorService {
.....
// 1
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue);
// 2
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory);
// 3
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,RejectedExecutionHandler handler);
// 4
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler);
...
}
所以我们直接就来看一波最后这个线程池,看看参数都有啥,如果我没数错的话,应该是有 7 个参数(小学数学水平。。。。。。)
- 首先,一个非常重要的参数就是
corePoolSize
,核心线程池的容量/大小,你叫啥我觉得都没毛病。只不过你得理解这个参数的意义,它和线程池的实现原理有非常密切的关系。你刚开始创建了一个线程池,此时是没有任何线程的,这个很好理解,因为我现在没有任务可以执行啊,创建线程干啥啊?而且创建线程还有开销啊,所以等到任务过来时再创建线程也不晚。但是!我要说但是了,如果调用了 prestartAllCoreThreads 或者 prestartCoreThread 方法,就会在没有任务到来时创建线程,前者是创建 corePoolSize 个线程,后者是只创建一个线程。Lea 爷爷本来想让我们程序员当个懒汉
,等任务来了再干;可是你非要当个饿汉
,提前完成任务。如果我们想当个懒汉的话,在创建了线程池后,线程池中的线程数为 0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到 corePoolSize 后,就会把到达的任务放到缓存队列
当中。
maximumPoolSize
:又来一个线程池的容量,只不过这个是线程池的最大容量,也就是线程池所能容纳最大的线程,而上面的 corePoolSize 只是核心线程容量。
我知道你此时会有疑问,那就是不知道如何核心线程的容量和线程最大容量的区别是吧?我们后面会解释这点。
-
keepAliveTime
:这个参数是线程池的保活机制
,表示线程在没有任务执行的情况下保持多久会终止。在默认情况下,这个参数只在线程数量大于 corePoolSize 时才会生效。当线程数量大于 corePoolSize 时,如果任意一个空闲的线程的等待时间 > keepAliveTime 后,那么这个线程会被剔除,直到线程数量等于 corePoolSize 为止。如果调用了 allowCoreThreadTimeOut 方法,线程数量在 corePoolSize 范围内也会生效,直到线程减为 0。 -
unit
:这个参数好说,它就是一个TimeUnit
的变量,unit 表示的是 keepAliveTime 的时间单位。unit 的类型有下面这几种TimeUnit.DAYS; //天 TimeUnit.HOURS; //小时 TimeUnit.MINUTES; //分钟 TimeUnit.SECONDS; //秒 TimeUnit.MILLISECONDS; //毫秒 TimeUnit.MICROSECONDS; //微妙 TimeUnit.NANOSECONDS; //纳秒
-
workQueue
:这个参数表示的概念就是等待队列,我们上面说过,如果核心线程 > corePoolSize 的话,就会把任务放入等待队列,这个等待队列的选择也是一门学问。Lea 爷爷给我们展示了三种等待队列的选择SynchronousQueue
: 基于阻塞队列(BlockingQueue)
的实现,它会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。使用 SynchronousQueue 阻塞队列一般要求maximumPoolSizes 为无界,也就是 Integer.MAX_VALUE,避免线程拒绝执行操作。LinkedBlockingQueue
:LinkedBlockingQueue 是一个无界缓存等待队列。当前执行的线程数量达到 corePoolSize 的数量时,剩余的元素会在阻塞队列里等待。ArrayBlockingQueue
:ArrayBlockingQueue 是一个有界缓存等待队列,可以指定缓存队列的大小,当正在执行的线程数等于 corePoolSize 时,多余的元素缓存在 ArrayBlockingQueue 队列中等待有空闲的线程时继续执行,当 ArrayBlockingQueue 已满时,加入 ArrayBlockingQueue 失败,会开启新的线程去执行,当线程数已经达到最大的 maximumPoolSizes 时,再有新的元素尝试加入 ArrayBlockingQueue时会报错threadFactory
:线程工厂,这个参数主要用来创建线程;
-
handler
:拒绝策略,拒绝策略主要有以下取值AbortPolicy
:丢弃任务并抛出 RejectedExecutionException 异常。DiscardPolicy
: 直接丢弃任务,但是不抛出异常。DiscardOldestPolicy
:直接丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)。CallerRunsPolicy
:由调用线程处理该任务。
深入理解线程池
上面我和你简单聊了一下线程池的基本构造,线程池有几个非常重要的参数可以细细品味,但是哥们醒醒,接下来才是刺激的地方。
线程池状态
首先我们先来聊聊线程池状态,线程池状态是一个非常有趣的设计点,ThreadPoolExecutor 使用 ctl
来存储线程池状态,这些状态也叫做线程池的生命周期
。想想也是,线程池作为一个存储管理线程的资源池,它自己也要有这些状态,以及状态之间的变更才能更好的满足我们的需求。ctl 其实就是一个 AtomicInteger
类型的变量,保证原子性
。
ctl 除了存储线程池状态之外,它还存储 workerCount
这个概念,workerCount 指示的是有效线程数,workerCount 表示的是已经被允许启动但不允许停止的工作线程数量。workerCount 的值与实际活动线程的数量不同。
ctl 高低位来判断是线程池状态还是工作线程数量,线程池状态位于高位。
这里有个设计点,为什么使用 AtomicInteger 而不是存储上线更大的 AtomicLong 之类的呢?
Lea 并非没有考虑过这个问题,为了表示 int 值,目前 workerCount 的大小是(2 ^ 29)-1(约 5 亿个线程),而不是(2 ^ 31)-1(20亿个)可表示的线程。如果将来有问题,可以将该变量更改为 AtomicLong。但是在需要之前,使用 int 可以使此代码更快,更简单,int 存储占用存储空间更小。
runState 具有如下几种状态
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
我们先上状态轮转图,然后根据状态轮转图做详细的解释。
这几种状态的解释如下
RUNNING
: 如果线程池处于 RUNNING 状态下的话,能够接收新任务,也能处理正在运行的任务。可以从 ctl 的初始化得知,线程池一旦创建出来就会处于 RUNNING 状态,并且线程池中的有效线程数为 0。
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
SHUTDOWN
: 在调用 shutdown 方法后,线程池的状态会由 RUNNING -> SHUTDOWN 状态,位于 SHUTDOWN 状态的线程池能够处理正在运行的任务,但是不能接受新的任务,这和我们上面说的对与 shutdown 的描述一致。STOP
: 和 shutdown 方法类似,在调用 shutdownNow 方法时,程序会从 RUNNING/SHUTDOWN -> STOP 状态,处于 STOP 状态的线程池,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。TIDYING
:TIDYING 状态有个前置条件,分为两种:一种是是当线程池位于 SHUTDOWN 状态下,阻塞队列和线程池中的线程数量为空时,会由 SHUTDOWN -> TIDYING;另一种是当线程池位于 STOP 状态下时,线程池中的数量为空时,会由 STOP -> TIDYING 状态。转换为 TIDYING 的线程池会调用terminated
这个钩子方法,terminated 在 ThreadPoolExecutor 类中是空实现,若用户想在线程池变为 TIDYING 时,进行相应的处理,可以通过重载 terminated 函数来实现。TERMINATED
:TERMINATED 状态是线程池的最后一个状态,线程池处在 TIDYING 状态时,执行完terminated 方法之后,就会由 TIDYING -> TERMINATED 状态。此时表示线程池的彻底终止。
重要变量
下面我们一起来了解一下线程池中的重要变量。
private final BlockingQueue<Runnable> workQueue;
阻塞队列,这个和我们上面说的阻塞队列的参数是一个意思,因为在构造 ThreadPoolExecutor 时,会把参数的值赋给 this.workQueue。
private final ReentrantLock mainLock = new ReentrantLock();
线程池的主要状态锁
,对线程池的状态(比如线程池大小、运行状态)的改变都需要使用到这个锁
private final HashSet<Worker> workers = new HashSet<Worker>();
workers 持有线程池中所有线程的集合,只有持有上面 mainLock
的锁才能够访问。
private final Condition termination = mainLock.newCondition();
等待条件,用来支持 awaitTermination 方法。Condition 和 Lock 一起使用可以实现通知/等待机制。
private int largestPoolSize;
largestPoolSize 表示线程池中最大池的大小,只有持有 mainLock 才能访问
private long completedTaskCount;
completedTaskCount 表示任务完成的计数,它仅仅在任务终止时更新,需要持有 mainLock 才能访问。
private volatile ThreadFactory threadFactory;
threadFactory 是创建线程的工厂,所有的线程都会使用这个工厂,调用 addWorker
方法创建。
private volatile RejectedExecutionHandler handler;
handler 表示拒绝策略,handler 会在线程饱和或者将要关闭的时候调用。
private volatile long keepAliveTime;
保活时间,它指的是空闲线程等待工作的超时时间,当存在多个 corePoolSize 或 allowCoreThreadTimeOut 时,线程将使用这个超时时间。
下面是一些其他变量,这些变量比较简单,我就直接给出注释了。
private volatile boolean allowCoreThreadTimeOut; //是否允许为核心线程设置存活时间
private volatile int corePoolSize; //核心池的大小(即线程池中的线程数目大于这个参数时,提交的任务会被放进任务缓存队列)
private volatile int maximumPoolSize; //线程池最大能容忍的线程数
private static final RejectedExecutionHandler defaultHandler =
new AbortPolicy(); // 默认的拒绝策略
任务提交
现在我们知道了 ThreadPoolExecutor 创建出来就会处于运行状态,此时线程数量为 0 ,等任务到来时,线程池就会创建线程来执行任务,而下面我们的关注点就会放在任务提交这个过程上。
通常情况下,我们会使用
executor.execute()
来执行任务,我在很多书和博客教程上都看到过这个执行过程,下面是一些书和博客教程所画的 ThreadPoolExecutor 的执行示意图和执行流程图
执行示意图
处理流程图
ThreadPoolExecutor 的执行 execute 的方法分为下面四种情况
- 如果当前运行的工作线程少于 corePoolSize 的话,那么会创建新线程来执行任务 ,这一步需要获取 mainLock
全局锁
。 - 如果运行线程不小于 corePoolSize,则将任务加入 BlockingQueue 阻塞队列。
- 如果无法将任务加入 BlockingQueue 中,此时的现象就是队列已满,此时需要创建新的线程来处理任务,这一步同样需呀获取 mainLock 全局锁。
- 如果创建新线程会使当前运行的线程超过
maximumPoolSize
的话,任务将被拒绝,并且使用RejectedExecutionHandler.rejectEExecution()
方法拒绝新的任务。
ThreadPoolExecutor 采取上面的整体设计思路,是为了在执行 execute 方法时,避免获取全局锁,因为频繁获取全局锁会是一个严重的可伸缩瓶颈
,所以,几乎所有的 execute 方法调用都是通过执行步骤2。
上面指出了 execute 的运行过程,整体上来说这个执行过程把非常重要的点讲解出来了,但是不够细致,我查阅 ThreadPoolExecute 和部分源码分析文章后,发现这事其实没这么简单,先来看一下 execute 的源码,我已经给出了中文注释
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
// 获取 ctl 的值
int c = ctl.get();
// 判断 ctl 的值是否小于核心线程池的数量
if (workerCountOf(c) < corePoolSize) {
// 如果小于,增加工作队列,command 就是一个个的任务
if (addWorker(command, true))
// 线程创建成功,直接返回
return;
// 线程添加不成功,需要再次判断,每需要一次判断都会获取 ctl 的值
c = ctl.get();
}
// 如果线程池处于运行状态并且能够成功的放入阻塞队列
if (isRunning(c) && workQueue.offer(command)) {
// 再次进行检查
int recheck = ctl.get();
// 如果不是运行态并且成功的从阻塞队列中删除
if (! isRunning(recheck) && remove(command))
// 执行拒绝策略
reject(command);
// worker 线程数量是否为 0
else if (workerCountOf(recheck) == 0)
// 增加工作线程
addWorker(null, false);
}
// 如果不能增加工作线程的数量,就会直接执行拒绝策略
else if (!addWorker(command, false))
reject(command);
}
下面是我根据源码画出的执行流程图
下面我们针对 execute 流程进行分析,可能有点啰嗦,因为几个核心流程上面已经提过了,不过为了流程的完整性,我们再在这里重新提一下。
- 如果线程池的核心数量少于
corePoolSize
,那么就会使用 addWorker 创建新线程,addworker 的流程我们会在下面进行分析。如果创建成功,那么 execute 方法会直接返回。如果没创建成功,可能是由于线程池已经 shutdown,可能是由于并发情况下 workerCountOf(c) < corePoolSize ,别的线程先创建了 worker 线程,导致 workerCoun t>= corePoolSize。 - 如果线程池还在 Running 状态,会将 task 加入阻塞队列,加入成功后会进行
double-check
双重校验,继续下面的步骤,如果加入失败,可能是由于队列线程已满,此时会判断是否能够加入线程池中,如果线程池也满了的话,就会直接执行拒绝策略,如果线程池能加入,execute 方法结束。 - 步骤 2 中的 double-check 主要是为了判断进入 workQueue 中的 task 是否能被执行:如果线程池已经不是 Running 状态,则应该拒绝添加任务,从 workQueue 队列中删除任务。如果线程池是 Running,但是从 workQueue 中删除失败了,此时的原因可能是由于其他线程执行了这个任务,此时会直接执行拒绝策略。
- 如果线程是 Running 状态,并且不能把任务从队列中移除,进而判断工作线程是否为 0 ,如果不为 0 ,execute 执行完毕,如果工作线程是 0 ,则会使用 addWorker 增加工作线程,execute 执行完毕。
添加 worker 线程
从上面的执行流程可以看出,添加一个 worker 涉及的工作也非常多,这也是一个比价难啃的点,我们一起来分析下,这是 worker 的源码
private boolean addWorker(Runnable firstTask, boolean core) {
// retry 的用法相当于 goto
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
// 仅在必要时检查队列是否为空。
// 线程池状态有五种,state 越小越是运行状态
// rs >= SHUTDOWN,表示此时线程池状态可能是 SHUTDOWN、STOP、TIDYING、TERMINATED
// 默认 rs >= SHUTDOWN,如果 rs = SHUTDOWN,直接返回 false
// 默认 rs < SHUTDOWN,是 RUNNING,如果任务不是空,返回 false
// 默认 RUNNING,任务是空,如果工作队列为空,返回 false
//
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
// 执行循环
for (;;) {
// 统计工作线程数量
int wc = workerCountOf(c);
// 如果 worker 数量>线程池最大上限 CAPACITY(即使用int低29位可以容纳的最大值)
// 或者 worker数量 > corePoolSize 或 worker数量>maximumPoolSize ),即已经超过了给定的边界
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
// 使用 CAS 增加 worker 数量,增加成功,跳出循环。
if (compareAndIncrementWorkerCount(c))
break retry;
// 检查 ctl
c = ctl.get(); // Re-read ctl
// 如果状态不等于之前获取的 state,跳出内层循环,继续去外层循环判断
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
/
worker数量+1成功的后续操作
* 添加到 workers Set 集合,并启动 worker 线程
*/
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
// 包装 Runnable 对象
// 设置 firstTask 的值为 -1
// 赋值给当前任务
// 使用 worker 自身这个 runnable,调用 ThreadFactory 创建一个线程,并设置给worker的成员变量thread
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// 在持有锁的时候重新检查
// 如果 ThreadFactory 失败或在获得锁之前关闭,请回退。
int rs = runStateOf(ctl.get());
//如果线程池在运行 running<shutdown 或者 线程池已经 shutdown,且firstTask==null
// (可能是 workQueue 中仍有未执行完成的任务,创建没有初始任务的 worker 线程执行)
//worker 数量 -1 的操作在 addWorkerFailed()
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
// workers 就是一个 HashSet 集合
workers.add(w);
// 设置最大的池大小 largestPoolSize,workerAdded 设置为true
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start();
workerStarted = true;
}
}
//如果启动线程失败
// worker 数量 -1
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
真长的一个方法,有点想吐血,其实我肝到现在已经肝不动了,但我一想到看这篇文章的读者们能给我一个关注,就算咳出一口老血也值了。
这个方法的执行流程图如下
这里我们就不再文字描述了,但是上面流程图中有一个对象引起了我的注意,那就是 worker
对象,这个对象就代表了线程池中的工作线程,那么这个 worker 对象到底是啥呢?
worker 对象
Worker 位于 ThreadPoolExecutor
内部,它继承了 AQS 类并且实现了 Runnable 接口。Worker 类主要维护了线程运行过程中的中断控制状态。它提供了锁的获取和释放操作。在 worker 的实现中,我们使用了非重入的互斥锁而不是使用重复锁,因为 Lea 觉得我们不应该在调用诸如 setCorePoolSize 之类的控制方法时能够重新获取锁。
worker 对象的源码比较简单和标准,这里我们只说一下 worker 对象的构造方法,也就是
Worker(Runnable firstTask) {
setState(-1);
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}
构造一个 worker 对象需要做三步操作:
- 初始 AQS 状态为 -1,此时不允许中断 interrupt(),只有在 worker 线程启动了,执行了 runWorker() 方法后,将 state 置为0,才能进行中断。
- 将 firstTask 赋值给为当前类的全局变量
- 通过
ThreadFactory
创建一个新的线程。
任务运行
我们前面的流程主要分析了线程池的 execute 方法的执行过程,这个执行过程相当于是任务提交过程,而我们下面要说的是从队列中获取任务并运行的这个工作流程。
一般情况下,我们会从初始任务开始运行,所以我们不需要获取第一个任务。否则,只要线程池还处于 Running 状态,我们会调用 getTask
方法获取任务。getTask 方法可能会返回 null,此时可能是由于线程池状态改变或者是配置参数更改而导致的退出。还有一种情况可能是由于 异常
而引发的,这个我们后面会细说。
下面来看一下 runWorker
方法的源码:
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
// 允许打断
// new Worker() 是 state==-1,此处是调用 Worker 类的 tryRelease() 方法,
// 将 state 置为0
w.unlock();
boolean completedAbruptly = true;
try {
// 调用 getTask() 获取任务
while (task != null || (task = getTask()) != null) {
// 获取全局锁
w.lock();
// 确保只有在线程 STOPING 时,才会被设置中断标志,否则清除中断标志。
// 如果一开始判断线程池状态 < STOPING,但 Thread.interrupted() 为 true,
// 即线程已经被中断,又清除了中断标示,再次判断线程池状态是否 >= stop
// 是,再次设置中断标示,wt.interrupt()
// 否,不做操作,清除中断标示后进行后续步骤
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
// 执行前需要调用的方法,交给程序员自己来实现
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
// 执行后需要调用的方法,交给程序员自己来实现
afterExecute(task, thrown);
}
} finally {
// 把 task 置为 null,完成任务数 + 1,并进行解锁
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
// 最后处理 worker 的退出
} finally {
processWorkerExit(w, completedAbruptly);
}
}
下面是 runWorker 的执行流程图
这里需要注意一下最后的 processWorkerExit
方法,这里面其实也做了很多事情,包括判断 completedAbruptly
的布尔值来表示是否完成任务,获取锁,尝试从队列中移除 worker,然后尝试中断,接下来会判断一下中断状态,在线程池当前状态小于 STOP 的情况下会创建一个新的 worker 来替换被销毁的 worker。
任务获取
任务获取就是 getTask 方法的执行过程,这个环节主要用来获取任务和剔除任务。下面进入源码分析环节
private Runnable getTask() {
// 判断最后一个 poll 是否超时。
boolean timedOut = false; // Did the last poll() time out?
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
// 必要时检查队列是否为空
// 对线程池状态的判断,两种情况会 workerCount-1,并且返回 null
// 线程池状态为 shutdown,且 workQueue 为空(反映了 shutdown 状态的线程池还是要执行 workQueue 中剩余的任务的)
// 线程池状态为 stop(shutdownNow() 会导致变成 STOP)(此时不用考虑 workQueue 的情况)
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
// Are workers subject to culling?
// 是否需要定时从 workQueue 中获取
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
// 如果工作线程的数量大于 maximumPoolSize 会进行线程剔除
// 如果使用了 allowCoreThreadTimeOut ,并且工作线程不为0或者队列有任务的话,会直接进行线程剔除
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
getTask 方法的执行流程图如下
工作线程退出
工作线程退出是 runWorker 的最后一步,这一步会判断工作线程是否突然终止,并且会尝试终止线程,以及是否需要增加线程来替换原工作线程。
private void processWorkerExit(Worker w, boolean completedAbruptly) {
// worker数量 -1
// completedAbruptly 是 true,突然终止,说明是 task 执行时异常情况导致,即run()方法执行时发生了异常,那么正在工作的 worker 线程数量需要-1
// completedAbruptly 是 false 是突然终止,说明是 worker 线程没有 task 可执行了,不用-1,因为已经在 getTask() 方法中-1了
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();
// 从 Workers Set 中移除 worker
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
completedTaskCount += w.completedTasks;
workers.remove(w);
} finally {
mainLock.unlock();
}
// 尝试终止线程,
tryTerminate();
// 是否需要增加 worker 线程
// 线程池状态是 running 或 shutdown
// 如果当前线程是突然终止的,addWorker()
// 如果当前线程不是突然终止的,但当前线程数量 < 要维护的线程数量,addWorker()
// 故如果调用线程池 shutdown(),直到workQueue为空前,线程池都会维持 corePoolSize 个线程,
// 然后再逐渐销毁这 corePoolSize 个线程
int c = ctl.get();
if (runStateLessThan(c, STOP)) {
if (!completedAbruptly) {
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
if (min == 0 && ! workQueue.isEmpty())
min = 1;
if (workerCountOf(c) >= min)
return; // replacement not needed
}
addWorker(null, false);
}
}
源码搞的有点头大了,可能一时半会无法理解上面这些源码,不过你可以先把注释粘过去,等有时间了需要反复刺激,加深印象!
其他线程池
下面我们来了解一下其他线程池的构造原理,主要涉及 FixedThreadPool、SingleThreadExecutor、CachedThreadPool。
newFixedThreadPool
newFixedThreadPool 被称为可重用固定线程数
的线程池,下面是 newFixedThreadPool 的源码
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
可以看到,newFixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为创建 FixedThreadPool 时指定的参数 nThreads
,也就是说,在 newFiexedThreadPool 中,核心线程数就是最大线程数。
下面是 newFixedThreadPool 的执行示意图
newFixedThreadPool 的工作流程如下
- 如果当前运行的线程数少于 corePoolSize,则会创建新线程 addworker 来执行任务
- 如果当前线程的线程数等于 corePoolSize,会将任务直接加入到
LinkedBlockingQueue
无界阻塞队列中,LinkedBlockingQueue 的上限如果没有制定,默认为 Integer.MAX_VALUE 大小。 - 等到线程池中的任务执行完毕后,newFixedThreadPool 会反复从 LinkedBlockingQueue 中获取任务来执行。
相较于 ThreadPoolExecutor,newFixedThreadPool 主要做了以下改变
-
核心线程数等于最大线程数,因此 newFixedThreadPool 只有两个最大容量,一个是线程池的线程容量,还有一个是 LinkedBlockingQueue 无界阻塞队列的线程容量。
-
这里可以看到还有一个变化是 0L,也就是 keepAliveTime = 0L,keepAliveTime 就是到达工作线程最大容量后的线程等待时间,0L 就意味着当线程池中的线程数大于 corePoolsize 时,空余的线程会被立即终止。
-
由于使用无界队列,运行中的 newFixedThreadPool 不会拒绝任务,也就是不会调用 RejectedExecutionHandler.rejectedExecution 方法。
newSingleThreadExecutor
newSingleThreadExecutor 中只有单个工作线程,也就是说它是一个只有单个 worker 的 Executor。
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}
可以看到,在 newSingleThreadExecutor 中,corePoolSize 和 maximumPoolSize 都被设置为 1,也不存在超时情况,同样使用了 LinkedBlockingQueue 无界阻塞队列,除了 corePoolSize 和 maximumPoolSize 外,其他几乎和 newFixedThreadPool 一模一样。
下面是 newSingleThreadExecutor 的执行示意图
newSingleThreadExecutor 的执行过程和 newFixedThreadPool 相同,只是 newSingleThreadExecutor 的工作线程数为 1。
newCachedThreadPool
newCachedThreadPool 是一个根据需要创建工作线程的线程池,newCachedThreadPool 线程池最大数量是 Integer.MAX_VALUE,保活时间是 60
秒,使用的是SynchronousQueue
无缓冲阻塞队列。
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
它的执行示意图如下
- 首先会先执行 SynchronousQueue.offer 方法,如果当前 maximumPool 中有空闲线程正在执行
SynchronousQueue.poll
,就会把任务交给空闲线程来执行,execute 方法执行完毕,否则的话,继续向下执行。 - 如果 maximumPool 中没有线程执行 SynchronousQueue.poll 方法,这种情况下 newCachedThreadPool 会创建一个新线程执行任务,execute 方法执行完成。
- 执行完成的线程将执行 poll 操作,这个 poll 操作会让空闲线程最多在 SynchronousQueue 中等待 60 秒钟。如果 60 秒钟内提交了一个新任务,那么空闲线程会执行这个新提交的任务,否则空闲线程将会终止。
这里的关键点在于 SynchronousQueue 队列,它是一个没有容量的阻塞队列。每个插入操作必须等待另一个线程对应的移除操作。这其实就是一种任务传递,如下图所示
其实还有一个线程池 ScheduledThreadPoolExecutor
,就先不在此篇文章做详细赘述了。
线程池实践考量因素
下面介绍几种在实践过程中使用线程池需要考虑的几个点
- 避免任务堆积,比如我们上面提到的 newFixedThreadPool,它是创建指定数目的线程,但是工作队列是无界的,这就导致如果工作队列线程太少,导致处理速度跟不上入队速度,这种情况下很可能会导致 OOM,诊断时可以使用
jmap
检查是否有大量任务入队。 - 生产实践中很可能由于逻辑不严谨或者工作线程不能及时释放导致 线程泄漏,这个时候最好检查一下线程栈
- 避免死锁等同步问题
- 尽量避免在使用线程池时操作
ThreadLocal
,因为工作线程的生命周期可能会超过任务的生命周期。
线程池大小的设置
线程池大小的设置也是面试官经常会考到的一个点,一般需要根据任务类型
来配置线程池大小
- 如果是 CPU 密集型任务,那么就意味着 CPU 是稀缺资源,这个时候我们通常不能通过增加线程数来提高计算能力,因为线程数量太多,会导致频繁的上下文切换,一般这种情况下,建议合理的线程数值是
N(CPU)数 + 1
。 - 如果是 I/O 密集型任务,就说明需要较多的等待,这个时候可以参考 Brain Goetz 的推荐方法 线程数 = CPU核数 × (1 + 平均等待时间/平均工作时间)。参考值可以是 N(CPU) 核数 * 2。
当然,这只是一个参考值,具体的设置还需要根据实际情况进行调整,比如可以先将线程池大小设置为参考值,再观察任务运行情况和系统负载、资源利用率来进行适当调整。
后记
这篇文章真的写了很久,因为之前对线程池认识不是很深,所以花了大力气来研究,希望这篇文章对你有所帮助。
AtomicReference用法和源码分析
我们之前了解过了 AtomicInteger、AtomicLong、AtomicBoolean 等原子性工具类,下面我们继续了解一下位于 java.util.concurrent.atomic
包下的工具类。
关于 AtomicInteger、AtomicLong、AtomicBoolean 相关的内容请查阅
关于 AtomicReference 这种 JDK 工具类的了解的文章比较枯燥,并不是代表着文章质量的下降,因为我想搞出一整套 bestJavaer 的全方位解析,那就势必离不开对 JDK 工具类的了解。
记住:技术要做长线。
AtomicReference 基本使用
我们这里再聊起老生常谈的账户问题,通过个人银行账户问题,来逐渐引入 AtomicReference 的使用,我们首先来看一下基本的个人账户类
public class BankCard {
private final String accountName;
private final int money;
// 构造函数初始化 accountName 和 money
public BankCard(String accountName,int money){
this.accountName = accountName;
this.money = money;
}
// 不提供任何修改个人账户的 set 方法,只提供 get 方法
public String getAccountName() {
return accountName;
}
public int getMoney() {
return money;
}
// 重写 toString() 方法, 方便打印 BankCard
@Override
public String toString() {
return "BankCard{" +
"accountName='" + accountName + '\'' +
", money='" + money + '\'' +
'}';
}
}
个人账户类只包含两个字段:accountName 和 money,这两个字段代表账户名和账户金额,账户名和账户金额一旦设置后就不能再被修改。
现在假设有多个人分别向这个账户打款,每次存入一定数量的金额,那么理想状态下每个人在每次打款后,该账户的金额都是在不断增加的,下面我们就来验证一下这个过程。
public class BankCardTest {
private static volatile BankCard bankCard = new BankCard("cxuan",100);
public static void main(String[] args) {
for(int i = 0;i < 10;i++){
new Thread(() -> {
// 先读取全局的引用
final BankCard card = bankCard;
// 构造一个新的账户,存入一定数量的钱
BankCard newCard = new BankCard(card.getAccountName(),card.getMoney() + 100);
System.out.println(newCard);
// 最后把新的账户的引用赋给原账户
bankCard = newCard;
try {
TimeUnit.MICROSECONDS.sleep(1000);
}catch (Exception e){
e.printStackTrace();
}
}).start();
}
}
}
在上面的代码中,我们首先声明了一个全局变量 BankCard,这个 BankCard 由 volatile
进行修饰,目的就是在对其引用进行变化后对其他线程可见,在每个打款人都存入一定数量的款项后,输出账户的金额变化,我们可以观察一下这个输出结果。
可以看到,我们预想最后的结果应该是 1100 元,但是最后却只存入了 900 元,那 200 元去哪了呢?我们可以断定上面的代码不是一个线程安全的操作。
问题出现在哪里?
虽然每次 volatile 都能保证每个账户的金额都是最新的,但是由于上面的步骤中出现了组合操作,即获取账户引用
和更改账户引用
,每个单独的操作虽然都是原子性的,但是组合在一起就不是原子性的了。所以最后的结果会出现偏差。
我们可以用如下线程切换图来表示一下这个过程的变化。
可以看到,最后的结果可能是因为在线程 t1 获取最新账户变化后,线程切换到 t2,t2 也获取了最新账户情况,然后再切换到 t1,t1 修改引用,线程切换到 t2,t2 修改引用,所以账户引用的值被修改了两次
。
那么该如何确保获取引用和修改引用之间的线程安全性呢?
最简单粗暴的方式就是直接使用 synchronized
关键字进行加锁了。
使用 synchronized 保证线程安全性
使用 synchronized 可以保证共享数据的安全性,代码如下
public class BankCardSyncTest {
private static volatile BankCard bankCard = new BankCard("cxuan",100);
public static void main(String[] args) {
for(int i = 0;i < 10;i++){
new Thread(() -> {
synchronized (BankCardSyncTest.class) {
// 先读取全局的引用
final BankCard card = bankCard;
// 构造一个新的账户,存入一定数量的钱
BankCard newCard = new BankCard(card.getAccountName(), card.getMoney() + 100);
System.out.println(newCard);
// 最后把新的账户的引用赋给原账户
bankCard = newCard;
try {
TimeUnit.MICROSECONDS.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
}
相较于 BankCardTest ,BankCardSyncTest 增加了 synchronized 锁,运行 BankCardSyncTest 后我们发现能够得到正确的结果。
修改 BankCardSyncTest.class 为 bankCard 对象,我们发现同样能够确保线程安全性,这是因为在这段程序中,只有 bankCard 会进行变化,不会再有其他共享数据。
如果有其他共享数据的话,我们需要使用 BankCardSyncTest.clas 确保线程安全性。
除此之外,java.util.concurrent.atomic
包下的 AtomicReference 也可以保证线程安全性。
我们先来认识一下 AtomicReference ,然后再使用 AtomicReference 改写上面的代码。
了解 AtomicReference
使用 AtomicReference 保证线程安全性
下面我们改写一下上面的那个示例
public class BankCardARTest {
private static AtomicReference<BankCard> bankCardRef = new AtomicReference<>(new BankCard("cxuan",100));
public static void main(String[] args) {
for(int i = 0;i < 10;i++){
new Thread(() -> {
while (true){
// 使用 AtomicReference.get 获取
final BankCard card = bankCardRef.get();
BankCard newCard = new BankCard(card.getAccountName(), card.getMoney() + 100);
// 使用 CAS 乐观锁进行非阻塞更新
if(bankCardRef.compareAndSet(card,newCard)){
System.out.println(newCard);
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
}
在上面的示例代码中,我们使用了 AtomicReference 封装了 BankCard 的引用,然后使用 get()
方法获得原子性的引用,接着使用 CAS 乐观锁进行非阻塞更新,更新的标准是如果使用 bankCardRef.get() 获取的值等于内存值的话,就会把银行卡账户的资金 + 100,我们观察一下输出结果。
可以看到,有一些输出是乱序执行的,出现这个原因很简单,有可能在输出结果之前,进行线程切换,然后打印了后面线程的值,然后线程切换回来再进行输出,但是可以看到,没有出现银行卡金额相同的情况。
AtomicReference 源码解析
在了解上面这个例子之后,我们来看一下 AtomicReference 的使用方法
AtomicReference 和 AtomicInteger 非常相似,它们内部都是用了下面三个属性
Unsafe
是 sun.misc
包下面的类,AtomicReference 主要是依赖于 sun.misc.Unsafe 提供的一些 native 方法保证操作的原子性
。
Unsafe 的 objectFieldOffset
方法可以获取成员属性在内存中的地址相对于对象内存地址的偏移量。这个偏移量也就是 valueOffset
,说得简单点就是找到这个变量在内存中的地址,便于后续通过内存地址直接进行操作。
value
就是 AtomicReference 中的实际值,因为有 volatile ,这个值实际上就是内存值。
不同之处就在于 AtomicInteger 是对整数的封装,而 AtomicReference 则对应普通的对象引用
。也就是它可以保证你在修改对象引用时的线程安全性。
get and set
我们首先来看一下最简单的 get 、set 方法:
get()
: 获取当前 AtomicReference 的值
set()
: 设置当前 AtomicReference 的值
get() 可以原子性的读取 AtomicReference 中的数据,set() 可以原子性的设置当前的值,因为 get() 和 set() 最终都是作用于 value 变量,而 value 是由 volatile
修饰的,所以 get 、set 相当于都是对内存进行读取和设置。如下图所示
lazySet 方法
volatile 有内存屏障你知道吗?
内存屏障是啥啊?
内存屏障,也称
内存栅栏
,内存栅障,屏障指令等, 是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。也是一个让CPU 处理单元中的内存状态对其它处理单元可见的一项技术。
CPU 使用了很多优化,使用缓存、指令重排等,其最终的目的都是为了性能,也就是说,当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。所以指令的执行时序并不是顺序执行的,而是乱序执行的,这就会带来很多问题,这也促使着内存屏障的出现。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
内存屏障的开销非常轻量级,但是再小也是有开销的,LazySet 的作用正是如此,它会以普通变量的形式来读写变量。
也可以说是:**懒得设置屏障了
getAndSet 方法
以原子方式设置为给定值并返回旧值。它的源码如下
它会调用 unsafe
中的 getAndSetObject 方法,源码如下
可以看到这个 getAndSet 方法涉及两个 cpp 实现的方法,一个是 getObjectVolatile
,一个是 compareAndSwapObject
方法,他们用在 do…while 循环中,也就是说,每次都会先获取最新对象引用的值,如果使用 CAS 成功交换两个对象的话,就会直接返回 var5
的值,var5 此时应该就是更新前的内存值,也就是旧值。
compareAndSet 方法
这就是 AtomicReference 非常关键的 CAS 方法了,与 AtomicInteger 不同的是,AtomicReference 是调用的 compareAndSwapObject
,而 AtomicInteger 调用的是 compareAndSwapInt
方法。这两个方法的实现如下
路径在 hotspot/src/share/vm/prims/unsafe.cpp
中。
我们之前解析过 AtomicInteger 的源码,所以我们接下来解析一下 AtomicReference 源码。
因为对象存在于堆中,所以方法 index_oop_from_field_offset_long
应该是获取对象的内存地址,然后使用 atomic_compare_exchange_oop
方法进行对象的 CAS 交换。
这段代码会首先判断是否使用了 UseCompressedOops
,也就是指针压缩
。
这里简单解释一下指针压缩的概念:JVM 最初的时候是 32 位的,但是随着 64 位 JVM 的兴起,也带来一个问题,内存占用空间更大了 ,但是 JVM 内存最好不要超过 32 G,为了节省空间,在 JDK 1.6 的版本后,我们在 64位中的 JVM 中可以开启指针压缩(UseCompressedOops)
来压缩我们对象指针的大小,来帮助我们节省内存空间,在 JDK 8来说,这个指令是默认开启的。
如果不开启指针压缩的话,64 位 JVM 会采用 8 字节(64位)存储真实内存地址,比之前采用4字节(32位)压缩存储地址带来的问题:
- 增加了 GC 开销:64 位对象引用需要占用更多的堆空间,留给其他数据的空间将会减少, 从而加快了 GC 的发生,更频繁的进行 GC。
- 降低 CPU 缓存命中率:64 位对象引用增大了,CPU 能缓存的 oop 将会更少,从而降低了 CPU 缓存的效率。
由于 64 位存储内存地址会带来这么多问题,程序员发明了指针压缩技术,可以让我们既能够使用之前 4 字节存储指针地址,又能够扩大内存存储。
可以看到,atomic_compare_exchange_oop 方法底层也是使用了 Atomic:cmpxchg
方法进行 CAS 交换,然后把旧值进行 decode 返回 (我这局限的 C++ 知识,只能解析到这里了,如果大家懂这段代码一定告诉我,让我请教一波)
weakCompareAndSet 方法
weakCompareAndSet
: 非常认真看了好几遍,发现 JDK1.8 的这个方法和 compareAndSet 方法完全一摸一样啊,坑我。。。
但是真的是这样么?并不是,JDK 源码很博大精深,才不会设计一个重复的方法,你想想 JDK 团队也不是会犯这种低级团队,但是原因是什么呢?
《Java 高并发详解》这本书给出了我们一个答案
总结
此篇文章主要介绍了 AtomicReference 的出现背景,AtomicReference 的使用场景,以及介绍了 AtomicReference 的源码,重点方法的源码分析。此篇 AtomicReference 的文章基本上涵盖了网络上所有关于 AtomicReference 的内容了,遗憾的是就是 cpp 源码可能分析的不是很到位,这需要充足的 C/C++ 编程知识,如果有读者朋友们有最新的研究成果,请及时告诉我。
CountDownLatch用法和源码解析
CountDownLatch
是多线程控制的一种工具,它被称为 门阀
、 计数器
或者 闭锁
。这个工具经常用来用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。下面我们就来一起认识一下 CountDownLatch
认识 CountDownLatch
CountDownLatch 能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。它相当于是一个计数器,这个计数器的初始值就是线程的数量,每当一个任务完成后,计数器的值就会减一,当计数器的值为 0 时,表示所有的线程都已经任务了,然后在 CountDownLatch 上等待的线程就可以恢复执行接下来的任务。
CountDownLatch 的使用
CountDownLatch 提供了一个构造方法,你必须指定其初始值,还指定了 countDown
方法,这个方法的作用主要用来减小计数器的值,当计数器变为 0 时,在 CountDownLatch 上 await
的线程就会被唤醒,继续执行其他任务。当然也可以延迟唤醒,给 CountDownLatch 加一个延迟时间就可以实现。
其主要方法如下
CountDownLatch 主要有下面这几个应用场景
CountDownLatch 应用场景
典型的应用场景就是当一个服务启动时,同时会加载很多组件和服务,这时候主线程会等待组件和服务的加载。当所有的组件和服务都加载完毕后,主线程和其他线程在一起完成某个任务。
CountDownLatch 还可以实现学生一起比赛跑步的程序,CountDownLatch 初始化为学生数量的线程,鸣枪后,每个学生就是一条线程,来完成各自的任务,当第一个学生跑完全程后,CountDownLatch 就会减一,直到所有的学生完成后,CountDownLatch 会变为 0 ,接下来再一起宣布跑步成绩。
顺着这个场景,你自己就可以延伸、拓展出来很多其他任务场景。
CountDownLatch 用法
下面我们通过一个简单的计数器来演示一下 CountDownLatch 的用法
public class TCountDownLatch {
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
Increment increment = new Increment(latch);
Decrement decrement = new Decrement(latch);
new Thread(increment).start();
new Thread(decrement).start();
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Decrement implements Runnable {
CountDownLatch countDownLatch;
public Decrement(CountDownLatch countDownLatch){
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
try {
for(long i = countDownLatch.getCount();i > 0;i--){
Thread.sleep(1000);
System.out.println("countdown");
this.countDownLatch.countDown();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Increment implements Runnable {
CountDownLatch countDownLatch;
public Increment(CountDownLatch countDownLatch){
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
try {
System.out.println("await");
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Waiter Released");
}
}
在 main 方法中我们初始化了一个计数器为 5 的 CountDownLatch,在 Decrement 方法中我们使用 countDown
执行减一操作,然后睡眠一段时间,同时在 Increment 类中进行等待,直到 Decrement 中的线程完成计数减一的操作后,唤醒 Increment 类中的 run 方法,使其继续执行。
下面我们再来通过学生赛跑这个例子来演示一下 CountDownLatch 的具体用法
public class StudentRunRace {
CountDownLatch stopLatch = new CountDownLatch(1);
CountDownLatch runLatch = new CountDownLatch(10);
public void waitSignal() throws Exception{
System.out.println("选手" + Thread.currentThread().getName() + "正在等待裁判发布口令");
stopLatch.await();
System.out.println("选手" + Thread.currentThread().getName() + "已接受裁判口令");
Thread.sleep((long) (Math.random() * 10000));
System.out.println("选手" + Thread.currentThread().getName() + "到达终点");
runLatch.countDown();
}
public void waitStop() throws Exception{
Thread.sleep((long) (Math.random() * 10000));
System.out.println("裁判"+Thread.currentThread().getName()+"即将发布口令");
stopLatch.countDown();
System.out.println("裁判"+Thread.currentThread().getName()+"已发送口令,正在等待所有选手到达终点");
runLatch.await();
System.out.println("所有选手都到达终点");
System.out.println("裁判"+Thread.currentThread().getName()+"汇总成绩排名");
}
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
StudentRunRace studentRunRace = new StudentRunRace();
for (int i = 0; i < 10; i++) {
Runnable runnable = () -> {
try {
studentRunRace.waitSignal();
} catch (Exception e) {
e.printStackTrace();
}
};
service.execute(runnable);
}
try {
studentRunRace.waitStop();
} catch (Exception e) {
e.printStackTrace();
}
service.shutdown();
}
}
下面我们就来一起分析一下 CountDownLatch
的源码
CountDownLatch 源码分析
CountDownLatch 使用起来比较简单,但是却非常有用,现在你可以在你的工具箱中加上 CountDownLatch 这个工具类了。下面我们就来深入认识一下 CountDownLatch。
CountDownLatch 的底层是由 AbstractQueuedSynchronizer
支持,而 AQS 的数据结构的核心就是两个队列,一个是 同步队列(sync queue)
,一个是条件队列(condition queue)
。
Sync 内部类
CountDownLatch 在其内部是一个 Sync ,它继承了 AQS 抽象类。
private static final class Sync extends AbstractQueuedSynchronizer {...}
CountDownLatch 其实其内部只有一个 sync
属性,并且是 final 的
private final Sync sync;
CountDownLatch 只有一个带参数的构造方法
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
也就是说,初始化的时候必须指定计数器的数量,如果数量为负会直接抛出异常。
然后把 count 初始化为 Sync 内部的 count,也就是
Sync(int count) {
setState(count);
}
注意这里有一个 setState(count),这是什么意思呢?见闻知意这只是一个设置状态的操作,但是实际上不单单是,还有一层意思是 state 的值代表着待达到条件的线程数。这个我们在聊 countDown 方法的时候再讨论。
getCount()
方法的返回值是 getState()
方法,它是 AbstractQueuedSynchronizer 中的方法,这个方法会返回当前线程计数,具有 volatile 读取的内存语义。
// ---- CountDownLatch ----
int getCount() {
return getState();
}
// ---- AbstractQueuedSynchronizer ----
protected final int getState() {
return state;
}
tryAcquireShared()
方法用于获取·共享状态下对象的状态,判断对象是否为 0 ,如果为 0 返回 1 ,表示能够尝试获取,如果不为 0,那么返回 -1,表示无法获取。
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
// ---- getState() 方法和上面的方法相同 ----
这个 共享状态
属于 AQS 中的概念,在 AQS 中分为两种模式,一种是 独占模式
,一种是 共享模式
。
- tryAcquire 独占模式,尝试获取资源,成功则返回 true,失败则返回 false。
- tryAcquireShared 共享方式,尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared()
方法用于共享模式下的释放
protected boolean tryReleaseShared(int releases) {
// 减小数量,变为 0 的时候进行通知。
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
这个方法是一个无限循环,获取线程状态,如果线程状态是 0 则表示没有被线程占有,没有占有的话那么直接返回 false ,表示已经释放;然后下一个状态进行 – 1 ,使用 compareAndSetState CAS 方法进行和内存值的比较,如果内存值也是 1 的话,就会更新内存值为 0 ,判断 nextc 是否为 0 ,如果 CAS 比较不成功的话,会再次进行循环判断。
如果 CAS 用法不清楚的话,读者朋友们可以参考这篇文章 告诉你一个 AtomicInteger 的惊天大秘密!
await 方法
await()
方法是 CountDownLatch 一个非常重要的方法,基本上可以说只有 countDown 和 await 方法才是 CountDownLatch 的精髓所在,这个方法将会使当前线程在 CountDownLatch 计数减至零之前一直等待,除非线程被中断。
CountDownLatch 中的 await 方法有两种,一种是不带任何参数的 await()
,一种是可以等待一段时间的await(long timeout, TimeUnit unit)
。下面我们先来看一下 await() 方法。
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
await 方法内部会调用 acquireSharedInterruptibly 方法,这个 acquireSharedInterruptibly 是 AQS 中的方法,以共享模式进行中断。
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
可以看到,acquireSharedInterruptibly 方法的内部会首先判断线程是否中断
,如果线程中断,则直接抛出线程中断异常。如果没有中断,那么会以共享的方式获取。如果能够在共享的方式下不能获取锁,那么就会以共享的方式断开链接。
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这个方法有些长,我们分开来看
- 首先,会先构造一个共享模式的 Node 入队
- 然后使用无限循环判断新构造 node 的前驱节点,如果 node 节点的前驱节点是头节点,那么就会判断线程的状态,这里调用了一个 setHeadAndPropagate ,其源码如下
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
首先会设置头节点,然后进行一系列的判断,获取节点的获取节点的后继,以共享模式进行释放,就会调用 doReleaseShared 方法,我们再来看一下 doReleaseShared 方法
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
这个方法会以无限循环的方式首先判断头节点是否等于尾节点,如果头节点等于尾节点的话,就会直接退出。如果头节点不等于尾节点,会判断状态是否为 SIGNAL,不是的话就继续循环 compareAndSetWaitStatus,然后断开后继节点。如果状态不是 SIGNAL,也会调用 compareAndSetWaitStatus 设置状态为 PROPAGATE,状态为 0 并且不成功,就会继续循环。
也就是说 setHeadAndPropagate 就是设置头节点并且释放后继节点的一系列过程。
- 我们来看下面的 if 判断,也就是
shouldParkAfterFailedAcquire(p, node)
这里
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
如果上面 Node p = node.predecessor() 获取前驱节点不是头节点,就会进行 park 断开操作,判断此时是否能够断开,判断的标准如下
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
这个方法会判断 Node p 的前驱节点的结点状态(waitStatus)
,节点状态一共有五种,分别是
-
CANCELLED(1)
:表示当前结点已取消调度。当超时或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。 -
SIGNAL(-1)
:表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为 SIGNAL。 -
CONDITION(-2)
:表示结点等待在 Condition 上,当其他线程调用了 Condition 的 signal() 方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。 -
PROPAGATE(-3)
:共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。 -
0
:新结点入队时的默认状态。
如果前驱节点是 SIGNAL 就会返回 true 表示可以断开,如果前驱节点的状态大于 0 (此时为什么不用 ws == Node.CANCELLED ) 呢?因为 ws 大于 0 的条件只有 CANCELLED 状态了。然后就是一系列的查找遍历操作直到前驱节点的 waitStatus > 0。如果 ws <= 0 ,而且还不是 SIGNAL 状态的话,就会使用 CAS 替换前驱节点的 ws 为 SIGNAL 状态。
如果检查判断是中断状态的话,就会返回 false。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
这个方法使用 LockSupport.park
断开连接,然后返回线程是否中断的标志。
cancelAcquire()
用于取消等待队列,如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了),那么取消结点在队列中的等待。
private void cancelAcquire(Node node) {
if (node == null)
return;
node.thread = null;
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
Node predNext = pred.next;
node.waitStatus = Node.CANCELLED;
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
所以,对 CountDownLatch 的 await 调用大致会有如下的调用过程。
一个和 await 重载的方法是 await(long timeout, TimeUnit unit)
,这个方法和 await 最主要的区别就是这个方法能够可以等待计数器一段时间再执行后续操作。
countDown 方法
countDown 是和 await 同等重要的方法,countDown 用于减少计数器的数量,如果计数减为 0 的话,就会释放所有的线程。
public void countDown() {
sync.releaseShared(1);
}
这个方法会调用 releaseShared 方法,此方法用于共享模式下的释放操作,首先会判断是否能够进行释放,判断的方法就是 CountDownLatch 内部类 Sync 的 tryReleaseShared 方法
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
// ---- CountDownLatch ----
protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
tryReleaseShared 会进行 for 循环判断线程状态值,使用 CAS 不断尝试进行替换。
如果能够释放,就会调用 doReleaseShared 方法
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
可以看到,doReleaseShared 其实也是一个无限循环不断使用 CAS 尝试替换的操作。
总结
本文是 CountDownLatch 的基本使用和源码分析,CountDownLatch 就是一个基于 AQS 的计数器,它内部的方法都是围绕 AQS 框架来谈的,除此之外还有其他比如 ReentrantLock、Semaphore 等都是 AQS 的实现,所以要研究并发的话,离不开对 AQS 的探讨。CountDownLatch 的源码看起来很少,比较简单,但是其内部比如 await 方法的调用链路却很长,也值得花费时间深入研究。
Java锁之乐观锁和悲观锁
Java 按照锁的实现分为乐观锁和悲观锁,乐观锁和悲观锁并不是一种真实存在的锁,而是一种设计思想,乐观锁和悲观锁对于理解 Java 多线程和数据库来说至关重要,那么本篇文章就来详细探讨一下这两种锁的概念以及实现方式。
悲观锁
悲观锁
是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把资源
或者 数据
锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。悲观锁的实现往往依靠数据库本身的锁功能实现。
Java 中的 Synchronized
和 ReentrantLock
等独占锁(排他锁)也是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁,生怕自己心爱的宝贝被别人拿走。
乐观锁
乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过(具体如何判断我们下面再说)。乐观锁的实现方案一般来说有两种: 版本号机制
和 CAS实现
。乐观锁多适用于多度的应用类型,这样可以提高吞吐量。
在Java中java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
两种锁的使用场景
上面介绍了两种锁的基本概念,并提到了两种锁的适用场景,一般来说,悲观锁不仅会对写操作加锁还会对读操作加锁,一个典型的悲观锁调用:
select * from student where name="cxuan" for update
这条 sql 语句从 Student 表中选取 name = "cxuan" 的记录并对其加锁,那么其他写操作再这个事务提交之前都不会对这条数据进行操作,起到了独占和排他的作用。
悲观锁因为对读写都加锁,所以它的性能比较低,对于现在互联网提倡的三高
(高性能、高可用、高并发)来说,悲观锁的实现用的越来越少了,但是一般多读的情况下还是需要使用悲观锁的,因为虽然加锁的性能比较低,但是也阻止了像乐观锁一样,遇到写不一致的情况下一直重试的时间。
相对而言,乐观锁用于读多写少的情况,即很少发生冲突的场景,这样可以省去锁的开销,增加系统的吞吐量。
乐观锁的适用场景有很多,典型的比如说成本系统,柜员要对一笔金额做修改,为了保证数据的准确性和实效性,使用悲观锁锁住某个数据后,再遇到其他需要修改数据的操作,那么此操作就无法完成金额的修改,对产品来说是灾难性的一刻,使用乐观锁的版本号机制能够解决这个问题,我们下面说。
乐观锁的实现方式
乐观锁一般有两种实现方式:采用版本号机制
和 CAS(Compare-and-Swap,即比较并替换)算法
实现。
版本号机制
版本号机制是在数据表中加上一个 version
字段来实现的,表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1,当线程A要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
我们以上面的金融系统为例,来简述一下这个过程。
- 成本系统中有一个数据表,表中有两个字段分别是
金额
和version
,金额的属性是能够实时变化,而 version 表示的是金额每次发生变化的版本,一般的策略是,当金额发生改变时,version 采用递增的策略每次都在上一个版本号的基础上 + 1。 - 在了解了基本情况和基本信息之后,我们来看一下这个过程:公司收到回款后,需要把这笔钱放在金库中,假如金库中存有100 元钱
- 下面开启事务一:当男柜员执行回款写入操作前,他会先查看(读)一下金库中还有多少钱,此时读到金库中有 100 元,可以执行写操作,并把数据库中的钱更新为 120 元,提交事务,金库中的钱由 100 -> 120,version的版本号由 0 -> 1。
- 开启事务二:女柜员收到给员工发工资的请求后,需要先执行读请求,查看金库中的钱还有多少,此时的版本号是多少,然后从金库中取出员工的工资进行发放,提交事务,成功后版本 + 1,此时版本由 1 -> 2。
**上面两种情况是最乐观的情况,上面的两个事务都是顺序执行的,也就是事务一和事务二互不干扰,那么事务要并行执行会如何呢?
-
事务一开启,男柜员先执行读操作,取出金额和版本号,执行写操作
begin update 表 set 金额 = 120,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 120,版本号为1,事务还没有提交
事务二开启,女柜员先执行读操作,取出金额和版本号,执行写操作
begin update 表 set 金额 = 50,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 50,版本号变为 1,事务未提交
现在提交事务一,金额改为 120,版本变为1,提交事务。理想情况下应该变为 金额 = 50,版本号 = 2,但是实际上事务二 的更新是建立在金额为 100 和 版本号为 0 的基础上的,所以事务二不会提交成功,应该重新读取金额和版本号,再次进行写操作。
这样,就避免了女柜员 用基于 version=0 的旧数据修改的结果覆盖男操作员操作结果的可能。
CAS 算法
先来看一道经典的并发执行 1000次递增和递减后的问题:
public class Counter {
int count = 0;
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
public void add(){
count += 1;
}
public void dec(){
count -= 1;
}
}
public class Consumer extends Thread{
Counter counter;
public Consumer(Counter counter){
this.counter = counter;
}
@Override
public void run() {
for(int j = 0;j < Test.LOOP;j++){
counter.dec();
}
}
}
public class Producer extends Thread{
Counter counter;
public Producer(Counter counter){
this.counter = counter;
}
@Override
public void run() {
for(int i = 0;i < Test.LOOP;++i){
counter.add();
}
}
}
public class Test {
final static int LOOP = 1000;
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Producer producer = new Producer(counter);
Consumer consumer = new Consumer(counter);
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println(counter.getCount());
}
}
多次测试的结果都不为 0,也就是说出现了并发后数据不一致的问题,原因是 count -= 1 和 count += 1 都是非原子性操作,它们的执行步骤分为三步:
- 从内存中读取 count 的值,把它放入寄存器中
- 执行 + 1 或者 – 1 操作
- 执行完成的结果再复制到内存中
如果要把证它们的原子性,必须进行加锁,使用 Synchronzied
或者 ReentrantLock
,我们前面介绍它们是悲观锁的实现,我们现在讨论的是乐观锁,那么用哪种方式保证它们的原子性呢?请继续往下看
CAS 即 compare and swap(比较与交换)
,是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization
CAS 中涉及三个要素:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
JAVA对CAS的支持:在JDK1.5 中新添加 java.util.concurrent (J.U.C) 就是建立在 CAS 之上的。对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种实现。所以J.U.C在性能上有了很大的提升。
我们以 java.util.concurrent 中的 AtomicInteger
为例,看一下在不用锁的情况下是如何保证线程安全的
public class AtomicCounter {
private AtomicInteger integer = new AtomicInteger();
public AtomicInteger getInteger() {
return integer;
}
public void setInteger(AtomicInteger integer) {
this.integer = integer;
}
public void increment(){
integer.incrementAndGet();
}
public void decrement(){
integer.decrementAndGet();
}
}
public class AtomicProducer extends Thread{
private AtomicCounter atomicCounter;
public AtomicProducer(AtomicCounter atomicCounter){
this.atomicCounter = atomicCounter;
}
@Override
public void run() {
for(int j = 0; j < AtomicTest.LOOP; j++) {
System.out.println("producer : " + atomicCounter.getInteger());
atomicCounter.increment();
}
}
}
public class AtomicConsumer extends Thread{
private AtomicCounter atomicCounter;
public AtomicConsumer(AtomicCounter atomicCounter){
this.atomicCounter = atomicCounter;
}
@Override
public void run() {
for(int j = 0; j < AtomicTest.LOOP; j++) {
System.out.println("consumer : " + atomicCounter.getInteger());
atomicCounter.decrement();
}
}
}
public class AtomicTest {
final static int LOOP = 10000;
public static void main(String[] args) throws InterruptedException {
AtomicCounter counter = new AtomicCounter();
AtomicProducer producer = new AtomicProducer(counter);
AtomicConsumer consumer = new AtomicConsumer(counter);
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println(counter.getInteger());
}
}
经测试可得,不管循环多少次最后的结果都是0,也就是多线程并行的情况下,使用 AtomicInteger 可以保证线程安全性。 incrementAndGet 和 decrementAndGet 都是原子性操作。本篇文章暂不探讨它们的实现方式。
乐观锁的缺点
任何事情都是有利也有弊,软件行业没有完美的解决方案只有最优的解决方案,所以乐观锁也有它的弱点和缺陷:
ABA 问题
ABA 问题说的是,如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?可以是由 A -> B -> A 的这种情况,但是 AtomicInteger 却不会这么认为,它只相信它看到的,它看到的是什么就是什么。
JDK 1.5 以后的 AtomicStampedReference
类就提供了此种能力,其中的 compareAndSet 方法
就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
也可以采用CAS的一个变种DCAS来解决这个问题。 DCAS,是对于每一个V增加一个引用的表示修改次数的标记符。对于每个V,如果引用修改了一次,这个计数器就加1。然后再这个变量需要update的时候,就同时检查变量的值和计数器的值。
循环开销大
我们知道乐观锁在进行写操作的时候会判断是否能够写入成功,如果写入不成功将触发等待 -> 重试机制,这种情况是一个自旋锁,简单来说就是适用于短期内获取不到,进行等待重试的锁,它不适用于长期获取不到锁的情况,另外,自旋循环对于性能开销比较大。
CAS与synchronized的使用情景
简单的来说 CAS 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
补充: Java并发编程这个领域中 synchronized 关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized 的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和 CAS 类似的性能;而线程冲突严重的情况下,性能远高于CAS。
深入理解AQS
前言
谈到并发,我们不得不说AQS(AbstractQueuedSynchronizer)
,所谓的AQS
即是抽象的队列式的同步器,内部定义了很多锁相关的方法,我们熟知的ReentrantLock
、ReentrantReadWriteLock
、CountDownLatch
、Semaphore
等都是基于AQS
来实现的。
我们先看下AQS
相关的UML
图:
思维导图:
AQS实现原理
AQS
中 维护了一个volatile int state
(代表共享资源)和一个FIFO
线程等待队列(多线程争用资源被阻塞时会进入此队列)。
这里volatile
能够保证多线程下的可见性,当state=1
则代表当前对象锁已经被占有,其他线程来加锁时则会失败,加锁失败的线程会被放入一个FIFO
的等待队列中,比列会被UNSAFE.park()
操作挂起,等待其他获取锁的线程释放锁才能够被唤醒。
另外state
的操作都是通过CAS
来保证其并发修改的安全性。
具体原理我们可以用一张图来简单概括:
AQS
中提供了很多关于锁的实现方法,
- getState():获取锁的标志state值
- setState():设置锁的标志state值
- tryAcquire(int):独占方式获取锁。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式释放锁。尝试释放资源,成功则返回true,失败则返回false。
这里还有一些方法并没有列出来,接下来我们以ReentrantLock
作为突破点通过源码和画图的形式一步步了解AQS
内部实现原理。
目录结构
文章准备模拟多线程竞争锁、释放锁的场景来进行分析AQS
源码:
**三个线程(线程一、线程二、线程三)同时来加锁/释放锁
**目录如下:
- 线程一加锁成功时
AQS
内部实现 - 线程二/三加锁失败时
AQS
中等待队列的数据模型 - 线程一释放锁及线程二获取锁实现原理
- 通过线程场景来讲解公平锁具体实现原理
- 通过线程场景来讲解Condition中a
wait()
和signal()
实现原理
这里会通过画图来分析每个线程加锁、释放锁后AQS
内部的数据结构和实现原理
场景分析
线程一加锁成功
如果同时有三个线程并发抢占锁,此时线程一抢占锁成功,线程二和线程三抢占锁失败,具体执行流程如下:
此时AQS
内部数据为:
线程二、线程三加锁失败:
有图可以看出,等待队列中的节点Node
是一个双向链表,这里SIGNAL
是Node
中waitStatus
属性,Node
中还有一个nextWaiter
属性,这个并未在图中画出来,这个到后面Condition
会具体讲解的。
具体看下抢占锁代码实现:
java.util.concurrent.locks.ReentrantLock .NonfairSync:
static final class NonfairSync extends Sync {
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
这里使用的ReentrantLock非公平锁,线程进来直接利用CAS
尝试抢占锁,如果抢占成功state
值回被改为1,且设置对象独占锁线程为当前线程。如下所示:
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
线程二抢占锁失败
我们按照真实场景来分析,线程一抢占锁成功后,state
变为1,线程二通过CAS
修改state
变量必然会失败。此时AQS
中FIFO
(First In First Out 先进先出)队列中数据如图所示:
我们将线程二执行的逻辑一步步拆解来看:
java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire()
:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
先看看tryAcquire()
的具体实现:
java.util.concurrent.locks.ReentrantLock .nonfairTryAcquire()
:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
nonfairTryAcquire()
方法中首先会获取state
的值,如果不为0则说明当前对象的锁已经被其他线程所占有,接着判断占有锁的线程是否为当前线程,如果是则累加state
值,这就是可重入锁的具体实现,累加state
值,释放锁的时候也要依次递减state
值。
如果state
为0,则执行CAS
操作,尝试更新state
值为1,如果更新成功则代表当前线程加锁成功。
以线程二为例,因为线程一已经将state
修改为1,所以线程二通过CAS
修改state
的值不会成功。加锁失败。
线程二执行tryAcquire()
后会返回false,接着执行addWaiter(Node.EXCLUSIVE)
逻辑,将自己加入到一个FIFO
等待队列中,代码实现如下:
java.util.concurrent.locks.AbstractQueuedSynchronizer.addWaiter()
:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
这段代码首先会创建一个和当前线程绑定的Node
节点,Node
为双向链表。此时等待对内中的tail
指针为空,直接调用enq(node)
方法将当前线程加入等待队列尾部:
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
第一遍循环时tail
指针为空,进入if逻辑,使用CAS
操作设置head
指针,将head
指向一个新创建的Node
节点。此时AQS
中数据:
执行完成之后,head
、tail
、t
都指向第一个Node
元素。
接着执行第二遍循环,进入else
逻辑,此时已经有了head
节点,这里要操作的就是将线程二对应的Node
节点挂到head
节点后面。此时队列中就有了两个Node
节点:
addWaiter()
方法执行完后,会返回当前线程创建的节点信息。继续往后执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
逻辑,此时传入的参数为线程二对应的Node
节点信息:
java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued()
:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndChecknIterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
acquireQueued()
这个方法会先判断当前传入的Node
对应的前置节点是否为head
,如果是则尝试加锁。加锁成功过则将当前节点设置为head
节点,然后空置之前的head
节点,方便后续被垃圾回收掉。
如果加锁失败或者Node
的前置节点不是head
节点,就会通过shouldParkAfterFailedAcquire
方法
将head
节点的waitStatus
变为了SIGNAL=-1
,最后执行parkAndChecknIterrupt
方法,调用LockSupport.park()
挂起当前线程。
此时AQS
中的数据如下图:
此时线程二就静静的待在AQS
的等待队列里面了,等着其他线程释放锁来唤醒它。
线程三抢占锁失败
看完了线程二抢占锁失败的分析,那么再来分析线程三抢占锁失败就很简单了,先看看addWaiter(Node mode)
方法:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
此时等待队列的tail
节点指向线程二,进入if
逻辑后,通过CAS
指令将tail
节点重新指向线程三。接着线程三调用enq()
方法执行入队操作,和上面线程二执行方式是一致的,入队后会修改线程二对应的Node
中的waitStatus=SIGNAL
。最后线程三也会被挂起。此时等待队列的数据如图:
线程一释放锁
现在来分析下释放锁的过程,首先是线程一释放锁,释放锁后会唤醒head
节点的后置节点,也就是我们现在的线程二,具体操作流程如下:
执行完后等待队列数据如下:
此时线程二已经被唤醒,继续尝试获取锁,如果获取锁失败,则会继续被挂起。如果获取锁成功,则AQS
中数据如图:
接着还是一步步拆解来看,先看看线程一释放锁的代码:
java.util.concurrent.locks.AbstractQueuedSynchronizer.release()
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
这里首先会执行tryRelease()
方法,这个方法具体实现在ReentrantLock
中,如果tryRelease
执行成功,则继续判断head
节点的waitStatus
是否为0,前面我们已经看到过,head
的waitStatue
为SIGNAL(-1)
,这里就会执行unparkSuccessor()
方法来唤醒head
的后置节点,也就是我们上面图中线程二对应的Node
节点。
此时看ReentrantLock.tryRelease()
中的具体实现:
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
执行完ReentrantLock.tryRelease()
后,state
被设置成0,Lock对象的独占锁被设置为null。此时看下AQS
中的数据:
接着执行java.util.concurrent.locks.AbstractQueuedSynchronizer.unparkSuccessor()
方法,唤醒head
的后置节点:
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
这里主要是将head
节点的waitStatus
设置为0,然后解除head
节点next
的指向,使head
节点空置,等待着被垃圾回收。
此时重新将head
指针指向线程二对应的Node
节点,且使用LockSupport.unpark
方法来唤醒线程二。
被唤醒的线程二会接着尝试获取锁,用CAS
指令修改state
数据。
执行完成后可以查看AQS
中数据:
此时线程二被唤醒,线程二接着之前被park
的地方继续执行,继续执行acquireQueued()
方法。
线程二唤醒继续加锁
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
此时线程二被唤醒,继续执行for
循环,判断线程二的前置节点是否为head
,如果是则继续使用tryAcquire()
方法来尝试获取锁,其实就是使用CAS
操作来修改state
值,如果修改成功则代表获取锁成功。接着将线程二设置为head
节点,然后空置之前的head
节点数据,被空置的节点数据等着被垃圾回收。
此时线程三获取锁成功,AQS
中队列数据如下:
等待队列中的数据都等待着被垃圾回收。
线程二释放锁/线程三加锁
当线程二释放锁时,会唤醒被挂起的线程三,流程和上面大致相同,被唤醒的线程三会再次尝试加锁,具体代码可以参考上面内容。具体流程图如下:
此时AQS
中队列数据如图:
公平锁实现原理
上面所有的加锁场景都是基于非公平锁来实现的,非公平锁是ReentrantLock
的默认实现,那我们接着来看一下公平锁的实现原理,这里先用一张图来解释公平锁和非公平锁的区别:
非公平锁执行流程:
这里我们还是用之前的线程模型来举例子,当线程二释放锁的时候,唤醒被挂起的线程三,线程三执行tryAcquire()
方法使用CAS
操作来尝试修改state
值,如果此时又来了一个线程四也来执行加锁操作,同样会执行tryAcquire()
方法。
这种情况就会出现竞争,线程四如果获取锁成功,线程三仍然需要待在等待队列中被挂起。这就是所谓的非公平锁,线程三辛辛苦苦排队等到自己获取锁,却眼巴巴的看到线程四插队获取到了锁。
公平锁执行流程:
公平锁在加锁的时候,会先判断AQS
等待队列中是存在节点,如果存在节点则会直接入队等待,具体代码如下.
公平锁在获取锁是也是首先会执行acquire()
方法,只不过公平锁单独实现了tryAcquire()
方法:
#java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire()
:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这里会执行ReentrantLock
中公平锁的tryAcquire()
方法
#java.util.concurrent.locks.ReentrantLock.FairSync.tryAcquire()
:
static final class FairSync extends Sync {
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
这里会先判断state
值,如果不为0且获取锁的线程不是当前线程,直接返回false代表获取锁失败,被加入等待队列。如果是当前线程则可重入获取锁。
如果state=0
则代表此时没有线程持有锁,执行hasQueuedPredecessors()
判断AQS
等待队列中是否有元素存在,如果存在其他等待线程,那么自己也会加入到等待队列尾部,做到真正的先来后到,有序加锁。具体代码如下:
#java.util.concurrent.locks.AbstractQueuedSynchronizer.hasQueuedPredecessors()
:
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
这段代码很有意思,返回false
代表队列中没有节点或者仅有一个节点是当前线程创建的节点。返回true
则代表队列中存在等待节点,当前线程需要入队等待。
先判断head
是否等于tail
,如果队列中只有一个Node
节点,那么head
会等于tail
,接着判断head
的后置节点,这里肯定会是null
,如果此Node
节点对应的线程和当前的线程是同一个线程,那么则会返回false
,代表没有等待节点或者等待节点就是当前线程创建的Node
节点。此时当前线程会尝试获取锁。
如果head
和tail
不相等,说明队列中有等待线程创建的节点,此时直接返回true
,如果只有一个节点,而此节点的线程和当前线程不一致,也会返回true
非公平锁和公平锁的区别:
非公平锁性能高于公平锁性能。非公平锁可以减少CPU
唤醒线程的开销,整体的吞吐效率会高点,CPU
也不必取唤醒所有线程,会减少唤起线程的数量
非公平锁性能虽然优于公平锁,但是会存在导致线程饥饿的情况。在最坏的情况下,可能存在某个线程一直获取不到锁。不过相比性能而言,饥饿问题可以暂时忽略,这可能就是ReentrantLock
默认创建非公平锁的原因之一了。
Condition实现原理
Condition简介
上面已经介绍了AQS
所提供的核心功能,当然它还有很多其他的特性,这里我们来继续说下Condition
这个组件。
Condition
是在java 1.5
中才出现的,它用来替代传统的Object
的wait()
、notify()
实现线程间的协作,相比使用Object
的wait()
、notify()
,使用Condition
中的await()
、signal()
这种方式实现线程间协作更加安全和高效。因此通常来说比较推荐使用Condition
其中AbstractQueueSynchronizer
中实现了Condition
中的方法,主要对外提供awaite(Object.wait())
和signal(Object.notify())
调用。
Condition Demo示例
使用示例代码:
/
* ReentrantLock 实现源码学习
* @author 一枝花算不算浪漫
* @date 2020/4/28 7:20
*/
public class ReentrantLockDemo {
static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Condition condition = lock.newCondition();
new Thread(() -> {
lock.lock();
try {
System.out.println("线程一加锁成功");
System.out.println("线程一执行await被挂起");
condition.await();
System.out.println("线程一被唤醒成功");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
System.out.println("线程一释放锁成功");
}
}).start();
new Thread(() -> {
lock.lock();
try {
System.out.println("线程二加锁成功");
condition.signal();
System.out.println("线程二唤醒线程一");
} finally {
lock.unlock();
System.out.println("线程二释放锁成功");
}
}).start();
}
}
执行结果如下图:
这里线程一先获取锁,然后使用await()
方法挂起当前线程并释放锁,线程二获取锁后使用signal
唤醒线程一。
Condition实现原理图解
我们还是用上面的demo
作为实例,执行的流程如下:
线程一执行await()
方法:
先看下具体的代码实现,#java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject.await()
:
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
await()
方法中首先调用addConditionWaiter()
将当前线程加入到Condition
队列中。
执行完后我们可以看下Condition
队列中的数据:
具体实现代码为:
private Node addConditionWaiter() {
Node t = lastWaiter;
if (t != null && t.waitStatus != Node.CONDITION) {
unlinkCancelledWaiters();
t = lastWaiter;
}
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null)
firstWaiter = node;
else
t.nextWaiter = node;
lastWaiter = node;
return node;
}
这里会用当前线程创建一个Node
节点,waitStatus
为CONDITION
。接着会释放该节点的锁,调用之前解析过的release()
方法,释放锁后此时会唤醒被挂起的线程二,线程二会继续尝试获取锁。
接着调用isOnSyncQueue()
方法判断当前节点是否为Condition
队列中的头部节点,如果是则调用LockSupport.park(this)
挂起Condition
中当前线程。此时线程一被挂起,线程二获取锁成功。
具体流程如下图:
线程二执行signal()
方法:
首先我们考虑下线程二已经获取到锁,此时AQS
等待队列中已经没有了数据。
接着就来看看线程二唤醒线程一的具体执行流程:
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignal(first);
}
先判断当前线程是否为获取锁的线程,如果不是则直接抛出异常。
接着调用doSignal()
方法来唤醒线程。
private void doSignal(Node first) {
do {
if ( (firstWaiter = first.nextWaiter) == null)
lastWaiter = null;
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
final boolean transferForSignal(Node node) {
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
Node p = enq(node);
int ws = p.waitStatus;
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}
/
* Inserts node into queue, initializing if necessary. See picture above.
* @param node the node to insert
* @return node's predecessor
*/
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
这里先从transferForSignal()
方法来看,通过上面的分析我们知道Condition
队列中只有线程一创建的一个Node
节点,且waitStatue
为CONDITION
,先通过CAS
修改当前节点waitStatus
为0,然后执行enq()
方法将当前线程加入到等待队列中,并返回当前线程的前置节点。
加入等待队列的代码在上面也已经分析过,此时等待队列中数据如下图:
接着开始通过CAS
修改当前节点的前置节点waitStatus
为SIGNAL
,并且唤醒当前线程。此时AQS
中等待队列数据为:
线程一被唤醒后,继续执行await()
方法中的while循环。
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
因为此时线程一的waitStatus
已经被修改为0,所以执行isOnSyncQueue()
方法会返回false
。跳出while
循环。
接着执行acquireQueued()
方法,这里之前也有讲过,尝试重新获取锁,如果获取锁失败继续会被挂起。直到另外线程释放锁才被唤醒。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
此时线程一的流程都已经分析完了,等线程二释放锁后,线程一会继续重试获取锁,流程到此终结。
Condition总结
我们总结下Condition和wait/notify的比较:
-
Condition可以精准的对多个不同条件进行控制,wait/notify只能和synchronized关键字一起使用,并且只能唤醒一个或者全部的等待队列;
-
Condition需要使用Lock进行控制,使用的时候要注意lock()后及时的unlock(),Condition有类似于await的机制,因此不会产生加锁方式而产生的死锁出现,同时底层实现的是park/unpark的机制,因此也不会产生先唤醒再挂起的死锁,一句话就是不会产生死锁,但是wait/notify会产生先唤醒再挂起的死锁。
总结
这里用了一步一图的方式结合三个线程依次加锁/释放锁来展示了ReentrantLock
的实现方式和实现原理,而ReentrantLock
底层就是基于AQS
实现的,所以我们也对AQS
有了深刻的理解。
另外还介绍了公平锁与非公平锁的实现原理,Condition
的实现原理,基本上都是使用源码+绘图的讲解方式,尽量让大家更容易去理解。
参考资料:
- 打通 Java 任督二脉 —— 并发数据结构的基石 https://juejin.im/post/5c11d6376fb9a049e82b6253
- Java并发之AQS详解https://www.cnblogs.com/waterystone/p/4920797.html
深入理解synchronized关键字
前言
synchronized 这个关键字的重要性不言而喻,几乎可以说是并发、多线程必须会问到的关键字了。synchronized 会涉及到锁、升级降级操作、锁的撤销、对象头等。所以理解 synchronized 非常重要,本篇文章就带你从 synchronized 的基本用法、再到 synchronized 的深入理解,对象头等,为你揭开 synchronized 的面纱。
浅析 synchronized
synchronized
是 Java 并发模块非常重要的关键字,它是 Java 内建的一种同步机制,代表了某种内在锁定的概念,当一个线程对某个共享资源
加锁后,其他想要获取共享资源的线程必须进行等待,synchronized 也具有互斥和排他的语义。
什么是互斥?我们想必小时候都玩儿过磁铁,磁铁会有正负极的概念,同性相斥异性相吸,相斥相当于就是一种互斥的概念,也就是两者互不相容。
synchronized 也是一种独占的关键字,但是它这种独占的语义更多的是为了增加线程安全性,通过独占某个资源以达到互斥、排他的目的。
在了解了排他和互斥的语义后,我们先来看一下 synchronized 的用法,先来了解用法,再来了解底层实现。
synchronized 的使用
关于 synchronized 想必你应该都大致了解过
- synchronized 修饰实例方法,相当于是对类的实例进行加锁,进入同步代码前需要获得当前实例的锁
- synchronized 修饰静态方法,相当于是对类对象进行加锁
- synchronized 修饰代码块,相当于是给对象进行加锁,在进入代码块前需要先获得对象的锁
下面我们针对每个用法进行解释
synchronized 修饰实例方法
synchronized 修饰实例方法,实例方法是属于类的实例。synchronized 修饰的实例方法相当于是对象锁。下面是一个 synchronized 修饰实例方法的例子。
public synchronized void method()
{
// ...
}
像如上述 synchronized 修饰的方法就是实例方法,下面我们通过一个完整的例子来认识一下 synchronized 修饰实例方法
public class TSynchronized implements Runnable{
static int i = 0;
public synchronized void increase(){
i++;
System.out.println(Thread.currentThread().getName());
}
@Override
public void run() {
for(int i = 0;i < 1000;i++) {
increase();
}
}
public static void main(String[] args) throws InterruptedException {
TSynchronized tSynchronized = new TSynchronized();
Thread aThread = new Thread(tSynchronized);
Thread bThread = new Thread(tSynchronized);
aThread.start();
bThread.start();
aThread.join();
bThread.join();
System.out.println("i = " + i);
}
}
上面输出的结果 i = 2000 ,并且每次都会打印当前现成的名字
来解释一下上面代码,代码中的 i 是一个静态变量,静态变量也是全局变量,静态变量存储在方法区中。increase 方法由 synchronized 关键字修饰,但是没有使用 static 关键字修饰,表示 increase 方法是一个实例方法,每次创建一个 TSynchronized 类的同时都会创建一个 increase 方法,increase 方法中只是打印出来了当前访问的线程名称。Synchronized 类实现了 Runnable 接口,重写了 run 方法,run 方法里面就是一个 0 – 1000 的计数器,这个没什么好说的。在 main 方法中,new 出了两个线程,分别是 aThread 和 bThread,Thread.join 表示等待这个线程处理结束。这段代码主要的作用就是判断 synchronized 修饰的方法能够具有独占性。
synchronized 修饰静态方法
synchronized 修饰静态方法就是 synchronized 和 static 关键字一起使用
public static synchronized void increase(){}
当 synchronized 作用于静态方法时,表示的就是当前类的锁,因为静态方法是属于类的,它不属于任何一个实例成员,因此可以通过 class 对象控制并发访问。
这里需要注意一点,因为 synchronized 修饰的实例方法是属于实例对象,而 synchronized 修饰的静态方法是属于类对象,所以调用 synchronized 的实例方法并不会阻止访问 synchronized 的静态方法。
synchronized 修饰代码块
synchronized 除了修饰实例方法和静态方法外,synchronized 还可用于修饰代码块,代码块可以嵌套在方法体的内部使用。
public void run() {
synchronized(obj){
for(int j = 0;j < 1000;j++){
i++;
}
}
}
上面代码中将 obj 作为锁对象对其加锁,每次当线程进入 synchronized 修饰的代码块时就会要求当前线程持有obj 实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待。
synchronized 修饰的代码块,除了可以锁定对象之外,也可以对当前实例对象锁、class 对象锁进行锁定
// 实例对象锁
synchronized(this){
for(int j = 0;j < 1000;j++){
i++;
}
}
//class对象锁
synchronized(TSynchronized.class){
for(int j = 0;j < 1000;j++){
i++;
}
}
synchronized 底层原理
在简单介绍完 synchronized 之后,我们就来聊一下 synchronized 的底层原理了。
我们或许都有所了解(下文会细致分析),synchronized 的代码块是由一组 monitorenter/monitorexit 指令实现的。而Monitor
对象是实现同步的基本单元。
啥是
Monitor
对象呢?
Monitor 对象
任何对象都关联了一个管程,管程就是控制对象并发访问的一种机制。管程
是一种同步原语,在 Java 中指的就是 synchronized,可以理解为 synchronized 就是 Java 中对管程的实现。
管程提供了一种排他访问机制,这种机制也就是 互斥
。互斥保证了在每个时间点上,最多只有一个线程会执行同步方法。
所以你理解了 Monitor 对象其实就是使用管程控制同步访问的一种对象。
对象内存布局
在 hotspot
虚拟机中,对象在内存中的布局分为三块区域:
对象头(Header)
实例数据(Instance Data)
对齐填充(Padding)
这三块区域的内存分布如下图所示
我们来详细介绍一下上面对象中的内容。
对象头 Header
对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。
在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。
如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。
在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。
用中文翻译过来就是
- 无状态也就是
无锁
的时候,对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为 01。 偏向锁
中划分更细,还是开辟 25 bit 的空间,其中 23 bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01。轻量级锁
中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00。重量级锁
中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11GC标记
开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。
其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK
中的markOop.hpp类中的枚举窥出端倪
来解释一下
- age_bits 就是我们说的分代回收的标识,占用4字节
- lock_bits 是锁的标志位,占用2个字节
- biased_lock_bits 是是否偏向锁的标识,占用1个字节。
- max_hash_bits 是针对无锁计算的 hashcode 占用字节数量,如果是 32 位虚拟机,就是 32 – 4 – 2 -1 = 25 byte,如果是 64 位虚拟机,64 – 4 – 2 – 1 = 57 byte,但是会有 25 字节未使用,所以 64 位的 hashcode 占用 31 byte。
- hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取 31,否则取真实的字节数
- cms_bits 我觉得应该是不是 64 位虚拟机就占用 0 byte,是 64 位就占用 1byte
- epoch_bits 就是 epoch 所占用的字节大小,2 字节。
在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态),偏向锁,轻量级锁,重量级锁,其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。
所以我们的重点放在对 synchronized 重量级锁的研究上,当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor
实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)
这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter
对象。
_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。
那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。
**锁的两个列表
当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count – 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。
Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
你可能不是很理解指针是个什么概念,你可以简单理解为指针就是指向某个数据的地址。
实例数据 Instance Data
实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。
对齐 Padding
对齐不是必须存在的,它只起到了占位符(%d, %c 等)的作用。这就是 JVM 的要求了,因为 HotSpot JVM 要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的字节大小是 8 的整数倍,不够的需要使用 Padding 补全。
锁的升级流程
先来个大体的流程图来感受一下这个过程,然后下面我们再分开来说
无锁
无锁状态
,无锁即没有对资源进行锁定,所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源。
无锁的特点就是在循环内进行修改操作,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出,在此过程中没有出现冲突的发生,这很像我们在之前文章中介绍的 CAS 实现,CAS 的原理和应用就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
可以从对象头的分配中看到,偏向锁要比无锁多了线程ID
和 epoch
,下面我们就来描述一下偏向锁的获取过程
**偏向锁获取过程
- 首先线程访问同步代码块,会通过检查对象头 Mark Word 的
锁标志位
判断目前锁的状态,如果是 01,说明就是无锁或者偏向锁,然后再根据是否偏向锁
的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步 - 线程使用 CAS 操作来尝试对对象加锁,如果使用 CAS 替换 ThreadID 成功,就说明是第一次上锁,那么当前线程就会获得对象的偏向锁,此时会在对象头的 Mark Word 中记录当前线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。
全局安全点(Safe Point):全局安全点的理解会涉及到 C 语言底层的一些知识,这里简单理解 SafePoint 是 Java 代码中的一个线程可能暂停执行的位置。
等到下一次线程在进入和退出同步代码块时就不需要进行 CAS
操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的。如果用流程图来表示的话就是下面这样
**关闭偏向锁
偏向锁在Java 6 和Java 7 里是默认启用
的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态。
**关于 epoch
偏向锁的对象头中有一个被称为 epoch
的值,它作为偏差有效性的时间戳。
轻量级锁
轻量级锁
是指当前锁是偏向锁的时候,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁
,其他线程会通过自旋
的形式尝试获取锁,不会阻塞,从而提高性能,下面是详细的获取过程。
**轻量级锁加锁过程
- 紧接着上一步,如果 CAS 操作替换 ThreadID 没有获取成功,执行下一步
- 如果使用 CAS 操作替换 ThreadID 失败(这时候就切换到另外一个线程的角度)说明该资源已被同步访问过,这时候就会执行锁的撤销操作,撤销偏向锁,然后等原持有偏向锁的线程到达
全局安全点(SafePoint)
时,会暂停原持有偏向锁的线程,然后会检查原持有偏向锁的状态,如果已经退出同步,就会唤醒持有偏向锁的线程,执行下一步 - 检查对象头中的 Mark Word 记录的是否是当前线程 ID,如果是,执行同步代码,如果不是,执行偏向锁获取流程 的第2步。
如果用流程表示的话就是下面这样(已经包含偏向锁的获取)
重量级锁
重量级锁其实就是 synchronized 最终加锁的过程,在 JDK 1.6 之前,就是由无锁 -> 加锁的这个过程。
**重量级锁的获取流程
- 接着上面偏向锁的获取过程,由偏向锁升级为轻量级锁,执行下一步
- 会在原持有偏向锁的线程的栈中分配锁记录,将对象头中的 Mark Word 拷贝到原持有偏向锁线程的记录中,然后原持有偏向锁的线程获得轻量级锁,然后唤醒原持有偏向锁的线程,从安全点处继续执行,执行完毕后,执行下一步,当前线程执行第 4 步
- 执行完毕后,开始轻量级解锁操作,解锁需要判断两个条件
- 判断对象头中的 Mark Word 中锁记录指针是否指向当前栈中记录的指针
- 拷贝在当前线程锁记录的 Mark Word 信息是否与对象头中的 Mark Word 一致。
如果上面两个判断条件都符合的话,就进行锁释放,如果其中一个条件不符合,就会释放锁,并唤起等待的线程,进行新一轮的锁竞争。
- 在当前线程的栈中分配锁记录,拷贝对象头中的 MarkWord 到当前线程的锁记录中,执行 CAS 加锁操作,会把对象头 Mark Word 中锁记录指针指向当前线程锁记录,如果成功,获取轻量级锁,执行同步代码,然后执行第3步,如果不成功,执行下一步
- 当前线程没有使用 CAS 成功获取锁,就会自旋一会儿,再次尝试获取,如果在多次自旋到达上限后还没有获取到锁,那么轻量级锁就会升级为
重量级锁
如果用流程图表示是这样的
根据上面对于锁升级细致的描述,我们可以总结一下不同锁的适用范围和场景。
锁类型 | 适用场景 | 缺点 | 优点 |
---|---|---|---|
偏向锁 | 适用于只有一个线程访问的同步场景 | 如果存在多个线程竞争使用锁,会带来额外的锁撤销消耗 | 加锁和解消耗小 |
轻量级锁 | 适用于追求响应时间的应用场景 | 如果始终得不到资源,会自旋消耗 CPU | 提高程序响应速度 |
重量级锁 | 适用于追求吞吐量的应用场景 | 得不到锁的线程会阻塞,性能比较差 | 阻塞,不需要消耗 CPU |
synchronized 代码块的底层实现
为了便于方便研究,我们把 synchronized 修饰代码块的示例简单化,如下代码所示
public class SynchronizedTest {
private int i;
public void syncTask(){
synchronized (this){
i++;
}
}
}
我们主要关注一下 synchronized 的字节码,如下所示
从这段字节码中我们可以知道,同步语句块使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令指向同步代码块的结束位置。
那么为什么会有两个 monitorexit 呢?
不知道你注意到下面的异常表了吗?如果你不知道什么是异常表,那么我建议你读一下这篇文章
看完这篇Exception 和 Error,和面试官扯皮就没问题了
synchronized 修饰方法的底层原理
方法的同步是隐式的,也就是说 synchronized 修饰方法的底层无需使用字节码来控制,真的是这样吗?我们来反编译一波看看结果
public class SynchronizedTest {
private int i;
public synchronized void syncTask(){
i++;
}
}
这次我们使用 javap -verbose 来输出详细的结果
从字节码上可以看出,synchronized 修饰的方法并没有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 标识,该标识指明了此方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这就是 synchronized 锁在同步代码块上和同步方法上的实现差别。
Atomicxxx的用法和实现原理
i++ 不是线程安全的操作,因为它不是一个原子性操作。
那么,如果我想要达到类似 i++ 的这种效果,我应该使用哪些集合或者说工具类呢?
在 JDK1.5 之前,为了确保在多线程下对某基本
数据类型或者引用
数据类型运算的原子性,必须依赖于外部关键字 synchronized
,但是这种情况在 JDK1.5 之后发生了改观,当然你依然可以使用 synchronized 来保证原子性,我们这里所说的一种线程安全的方式是原子性的工具类,比如 AtomicInteger、AtomicBoolean 等。这些原子类都是线程安全的工具类,他们同时也是 Lock-Free
的。下面我们就来一起认识一下这些工具类以及 Lock – Free 是个什么概念。
了解 AtomicInteger
AtomicInteger
是 JDK 1.5 新添加的工具类,我们首先来看一下它的继承关系
与 int 的包装类 Integer 一样,都是继承于 Number
类的。
这个 Number 类是基本数据类型的包装类,一般和数据类型有关的对象都会继承于 Number 类。
它的继承体系很简单,下面我们来看一下它的基本属性和方法
AtomicInteger 的基本属性
AtomicInteger 的基本属性有三个
Unsafe
是 sun.misc
包下面的类,AtomicInteger 主要是依赖于 sun.misc.Unsafe 提供的一些 native 方法保证操作的原子性。
Unsafe 的 objectFieldOffset
方法可以获取成员属性在内存中的地址相对于对象内存地址的偏移量。说得简单点就是找到这个变量在内存中的地址,便于后续通过内存地址直接进行操作,这个值就是 value
这个我们后面会再细说
value
就是 AtomicIneger 的值。
AtomicInteger 的构造方法
继续往下看,AtomicInteger 的构造方法只有两个,一个是无参数的构造方法,无参数的构造方法默认的 value 初始值是 0 ,带参数的构造方法可以指定初始值。
AtomicInteger 中的方法
下面我们就来聊一下 AtomicInteger 中的方法。
Get 和 Set
我们首先来看一下最简单的 get 、set 方法:
get()
: 获取当前 AtomicInteger 的值
set()
: 设置当前 AtomicInteger 的值
get() 可以原子性的读取 AtomicInteger 中的数据,set() 可以原子性的设置当前的值,因为 get() 和 set() 最终都是作用于 value 变量,而 value 是由 volatile
修饰的,所以 get 、set 相当于都是对内存进行读取和设置。如下图所示
我们上面提到了 i++ 和 i++ 的非原子性操作,我们说可以使用 AtomicInteger 中的方法进行替换。
Incremental 操作
AtomicInteger 中的 Incremental
相关方法可以满足我们的需求
getAndIncrement()
: 原子性的增加当前的值,并把结果返回。相当于i++
的操作。
为了验证是不是线程安全的,我们用下面的例子进行测试
public class TAtomicTest implements Runnable{
AtomicInteger atomicInteger = new AtomicInteger();
@Override
public void run() {
for(int i = 0;i < 10000;i++){
System.out.println(atomicInteger.getAndIncrement());
}
}
public static void main(String[] args) {
TAtomicTest tAtomicTest = new TAtomicTest();
Thread t1 = new Thread(tAtomicTest);
Thread t2 = new Thread(tAtomicTest);
t1.start();
t2.start();
}
}
通过输出结果你会发现它是一个线程安全的操作,你可以修改 i 的值,但是最后的结果仍然是 i – 1,因为先取值,然后再 + 1,它的示意图如下。
incrementAndGet
与此相反,首先执行 + 1 操作,然后返回自增后的结果,该操作方法能够确保对 value 的原子性操作。如下图所示
Decremental 操作
与此相对,x– 或者 x = x – 1 这样的自减操作也是原子性的。我们仍然可以使用 AtomicInteger 中的方法来替换
getAndDecrement
: 返回当前类型的 int 值,然后对 value 的值进行自减运算。下面是测试代码
class TAtomicTestDecrement implements Runnable{
AtomicInteger atomicInteger = new AtomicInteger(20000);
@Override
public void run() {
for(int i = 0;i < 10000 ;i++){
System.out.println(atomicInteger.getAndDecrement());
}
}
public static void main(String[] args) {
TAtomicTestDecrement tAtomicTest = new TAtomicTestDecrement();
Thread t1 = new Thread(tAtomicTest);
Thread t2 = new Thread(tAtomicTest);
t1.start();
t2.start();
}
}
下面是 getAndDecrement 的示意图
decrementAndGet
:同样的,decrementAndGet 方法就是先执行递减操作,然后再获取 value 的值,示意图如下
LazySet 方法
volatile 有内存屏障你知道吗?
内存屏障是啥啊?
内存屏障,也称
内存栅栏
,内存栅障,屏障指令等, 是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。也是一个让CPU 处理单元中的内存状态对其它处理单元可见的一项技术。
CPU 使用了很多优化,使用缓存、指令重排等,其最终的目的都是为了性能,也就是说,当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。所以指令的执行时序并不是顺序执行的,而是乱序执行的,这就会带来很多问题,这也促使着内存屏障的出现。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
内存屏障的开销非常轻量级,但是再小也是有开销的,LazySet 的作用正是如此,它会以普通变量的形式来读写变量。
也可以说是:**懒得设置屏障了
GetAndSet 方法
以原子方式设置为给定值并返回旧值。
它的源码就是调用了一下 unsafe 中的 getAndSetInt 方法,如下所示
就是先进行循环,然后调用 getIntVolatile
方法,这个方法我在 cpp 中没有找到,找到的小伙伴们记得及时告诉让我学习一下。
循环直到 compareAndSwapInt 返回 false,这就说明使用 CAS 并没有更新为新的值,所以 var5 返回的就是最新的内存值。
CAS 方法
我们一直常说的 CAS 其实就是 CompareAndSet
方法,这个方法顾名思义,就是 比较并更新 的意思,当然这是字面理解,字面理解有点偏差,其实人家的意思是先比较,如果满足那么再进行更新。
上面给出了 CAS Java 层面的源码,JDK 官方给它的解释就是 如果当前值等于 expect 的值,那么就以原子性的方式将当前值设置为 update 给定值,这个方法会返回一个 boolean 类型,如果是 true 就表示比较并更新成功,否则表示失败。
CAS 同时也是一种无锁并发机制,也称为 Lock Free
,所以你觉得 Lock Free 很高大上吗?并没有。
下面我们构建一个加锁解锁的 CASLock
class CASLock {
AtomicInteger atomicInteger = new AtomicInteger();
Thread currentThread = null;
public void tryLock() throws Exception{
boolean isLock = atomicInteger.compareAndSet(0, 1);
if(!isLock){
throw new Exception("加锁失败");
}
currentThread = Thread.currentThread();
System.out.println(currentThread + " tryLock");
}
public void unlock() {
int lockValue = atomicInteger.get();
if(lockValue == 0){
return;
}
if(currentThread == Thread.currentThread()){
atomicInteger.compareAndSet(1,0);
System.out.println(currentThread + " unlock");
}
}
public static void main(String[] args) {
CASLock casLock = new CASLock();
for(int i = 0;i < 5;i++){
new Thread(() -> {
try {
casLock.tryLock();
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
}finally {
casLock.unlock();
}
}).start();
}
}
}
在上面的代码中,我们构建了一个 CASLock,在 tryLock
方法中,我们先使用 CAS 方法进行更新,如果更新不成功则抛出异常,并把当前线程设置为加锁线程。在 unLock
方法中,我们先判断当前值是否为 0 ,如果是 0 就是我们愿意看到的结果,直接返回。否则是 1,则表示当前线程还在加锁,我们再来判断一下当前线程是否是加锁线程,如果是则执行解锁操作。
那么我们上面提到的 compareAndSet,它其实可以解析为如下操作
// 伪代码
// 当前值
int v = 0;
int a = 0;
int b = 1;
if(compare(0,0) == true){
set(0,1);
}
else{
// 继续向下执行
}
也可以拿生活场景中的买票举例子,你去景区旅游肯定要持票才能进,如果你拿着是假票或者不符合景区的票肯定是能够被识别出来的,如果你没有拿票拿你也肯定进不去景区。
废话少说,这就祭出来 compareAndSet 的示意图
weakCompareAndSet
: 妈的非常认真看了好几遍,发现 JDK1.8 的这个方法和 compareAndSet 方法完全一摸一样啊,坑我。。。
但是真的是这样么?并不是,JDK 源码很博大精深,才不会设计一个重复的方法,你想想 JDK 团队也不是会犯这种低级团队,但是原因是什么呢?
《Java 高并发详解》这本书给出了我们一个答案
AddAndGet
AddAndGet 和 getAndIncrement、getAndAdd、incrementAndGet 等等方法都是使用了 do … while + CAS 操作,其实也就相当于是一个自旋锁,如果 CAS 修改成功就会一直循环,修改失败才会返回。示意图如下
深入 AtomicInteger
我们上面探讨了 AtomicInteger 的具体使用,同时我们知道 AtomicInteger 是依靠 volatile 和 CAS 来保证原子性的,那么我们下面就来分析一下为什么 CAS 能够保证原子性,它的底层是什么?AtomicInteger 与乐观锁又有什么关系呢?
AtomicInteger 的底层实现原理
我们再来瞧瞧这个可爱的 compareAndSetL(CAS)
方法,为什么就这两行代码就保证原子性了?
我们可以看到,这个 CAS 方法相当于是调用了 unsafe 中的 compareAndSwapInt
方法,我们进到 unsafe 方能发中看一下具体实现。
compareAndSwapInt 是 sun.misc
中的方法,这个方法是一个 native
方法,它的底层是 C/C++ 实现的,所以我们需要看 C/C++ 的源码。
知道 C/C++ 的牛逼之处了么。使用 Java 就是玩应用和架构的,C/C++ 是玩服务器、底层的。
compareAndSwapInt 的源码在 jdk8u-dev/hotspot/src/share/vm/prims/unsafe.app
路径下,它的源码实现是
也就是 Unsafe_CompareAndSwapInt
方法,我们找到这个方法
C/C++ 源码我也看不懂,但是这不妨碍我们找到关键代码 Atomic::cmpxchg
,cmpxchg 是 x86 CPU 架构的汇编指令,它的主要作用就是比较并交换操作数。我们继续往下跟找一下这个指令的定义。
我们会发现对应不同的 os,其底层实现方式不一样
我们找到 Windows 的实现方式如下
我们继续向下找,它其实定义的是第 216 行的代码,我们找进去
此时就需要汇编指令和寄存器相关的知识了。
上面的 os::is-MP()
是多处理操作系统的接口,下面是 __asm ,它是 C/C++ 的关键字,用于调用内联汇编程序。
__asm 中的代码是汇编程序,大致来说就是把 dest、exchange_value 、compare_value 的值都放在寄存器中,下面的 LOCK_IF_MP
中代码的大致意思就是
如果是多处理器的话就会执行 lock,然后进行比较操作。其中的 cmp 表示比较,mp 表示的就是 MultiProcess
,je
表示相等跳转,L0 表示的是标识位。
我们回到上面的汇编指令,我们可以看到,CAS 的底层就是 cmpxchg
指令。
乐观锁
你有没有这个疑问,为什么 AtomicInteger 可以获取当前值,那为什么还会出现 expectValue
和 value
不一致的情况呢?
因为 AtomicInteger 只是一个原子性的工具类,它不具有排他性,它不像是 synchronized
或者是 lock
一样具有互斥和排他性,还记得 AtomicInteger 中有两个方法 get 和 set 吗?它们只是用 volatile
修饰了一下,而 volatile 不具有原子性,所以可能会存在 expectValue 和 value 的当前值不一致的情况,因此可能会出现重复修改。
针对上面这种情况的解决办法有两种,一种是使用 synchronized
和 lock
等类似的加锁机制,这种锁具有独占性,也就是说同一时刻只能有一个线程来进行修改,这种方式能够保证原子性,但是相对开销比较大,这种锁也叫做悲观锁。另外一种解决办法是使用版本号
或者是 CAS 方法
。
**版本号
版本号机制是在数据表中加上一个 version
字段来实现的,表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1,当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
**CAS 方法
还有一种方式就是 CAS 了,我们上面用了大量的篇幅来介绍 CAS 方法,那么我们认为你现在已经对其运行机制有一定的了解了,我们就不再阐述它的运行机制了。
任何事情都是有利也有弊,软件行业没有完美的解决方案只有最优的解决方案,所以乐观锁也有它的弱点和缺陷,那就是 ABA 问题。
ABA 问题
ABA 问题说的是,如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?可以是由 A -> B -> A 的这种情况,但是 AtomicInteger 却不会这么认为,它只相信它看到的,它看到的是什么就是什么。举个例子来说
假如现在有一个单链表,如下图所示
A.next = B ,B.next = null,此时有两个线程 T1 和 T2 分别从单链表中取出 A ,由于一些特殊原因,T2 把 A 改为 B ,然后又改为 A ,此时 T1 执行 CAS 方法,发现单链表仍然是 A ,就会执行 CAS 方法,虽然结果没错,但是这种操作会造成一些潜在的问题。
此时还是一个单链表,两个线程 T1 和 T2 分别从单链表中取出 A ,然后 T1 把链表改为 ACD 如下图所示
此时 T2,发现内存值还是 A ,就会把 A 的值尝试替换为 B ,因为 B 的引用是 null,此时就会造成 C、D 处于游离态
JDK 1.5 以后的 AtomicStampedReference
类就提供了此种能力,其中的 compareAndSet
方法就是首先检查当前值是否等于预期值,判断的标准就是当前引用和邮戳分别和预期引用和邮戳相等,如果全部相等,则以原子方式设置为给定的更新值。
好了,上面就是 Java 代码流程了,看到 native 我们知道又要撸 cpp 了。开撸
简单解释一下就是 UnsafeWrapper
就是包装器,换个名字而已。然后经过一些 JNI 的处理,因为 compareAndSwapOject 比较的是引用,所以需要经过 C++ 面向对象的转换。最主要的方法是 atomic_compare_exchange_oop
可以看到,又出现了熟悉的词汇 cmpxchg
,也就是说 compareAndSwapOject 使用的还是 cmpxchg 原子性指令,只是它经过了一系列转换。
我们上面介绍到了 AtomicInteger 是一种原子性的工具类,它的底层是依靠 CAS + Volatile 关键字来实现的原子性和可见性。那么作为八种基本数据类型的原子工具类,我们此篇文章就来介绍一下另外一个原子工具类那就是 AtomicBoolean
,通常情况下,AtomicBoolean 用于原子性的更新状态标示位。
认识 AtomicBoolean
AtomicBoolean 的用法和 AtomicInteger 的非常相似,我们具体来看一下。
为什么 AtomicInteger 可以继承 Number 类而 AtomicBoolean 却没有继承于 Number 类呢?
我们先来看下 Number
类是什么吧
JDK 源码给出了我们解释
Number 类是 JDK 平台的超类,同时也是一个抽象类,它可以转换为具体数值的类,比如原始类型 byte、double、float、int、long 和 short。
注意:可以看到只有六中基本数据类型,并没有 char 和 boolean。
也就是说,Number 类是基本数据类型 byte、double、float、int、long 和 short 包装类的超类,Number 类只进行方法的定义,不提供方法的具体实现,具体的实现交给子类去做。
不同的数值之间的转换会存在数值丢失的问题,JDK 源码也给出了我们说明
简单证明一下。
Double d1 = 3.1214;
System.out.println(d1.intValue());
那么,还有两种基本数据类型 char 和 boolean ,char 和 boolean 的包装类又分别是啥呢?它们为什么没有被划分为 Number 超类中呢?
char 类型的包装类其实是 character
而 boolean 类型的包装类是 Boolean。
Char 是字符,它可以代表任何值,而不单单是数字,Boolean 只有两个数值:TRUE
和 FALSE
,而 Boolean 在传输的过程中会被当作整数来看待,没有必要再继承于 Number 类。
在了解完 Number 之后,我们继续回到 AtomicBoolean 类的讨论上来。
AtomicBoolean 创建
AtomicBoolean 用于原子性的更新标志值,它不能作为 boolean 的包装类 Boolean 的替代。
AtomicBoolean 的创建有两种,一种是无参的构造方法;一种是带参数的构造方法,如果 boolean 是 true 的话,那么 value 的值就是 1, 否则就是 0。
和 AtomicInteger 的构造方法一摸一样,只不过 AtomicInteger 的值可以是任意的,而 AtomicBoolean 的值只能是 true 和 false。
AtomicBoolean 基本方法
AtomicBoolean 中的方法很少,下面一起来认识一下。
Get
AtomicBoolean 你看起来像是一个 Boolean 类型的值,但是其内部仍然使用 value 这个 int 值来进行存储,int 的值只能是 1 或 0 ,分别对应 true 或 false。
所以如果当前值是 1 就返回 true,如果是 0 就返回 false。
而 AtomicInteger 中的 get 方法只是返回当前值。
CompareAndSet
AtomicBoolean 也有 CAS 方法,而且和 AtomicInteger 中的 compareAndSet
底层都是使用的 unfase.compareAndSwapInt
方法,也就是说是,如果你的值只是使用的 0 和 1 ,那么不管是使用 AtomicBoolean 还是 AtomicInteger 达到的效果一样。
Set
JDK 给出的解释是无条件的设置为当前值。AtomicInteger 也是一样的。
LazySet
就连 lazySet 都和 AtomicInteger 底层使用的方法也一样。只是 AtomicBoolean 比 AtomicInteger 多了一层判断
GetAndSet
AtomicBoolean 中的 getAndSet 方法还是和 AtomicInteger 中的有点区别的。
AtomicBoolean 的底层和方法和 AtomicInteger 一样,如果了解了 AtomicInteger 之后,那么 AtomicBoolean 也就没问题了。AtomicBoolean 本身没多少东西。但是 AtomicBoolean 很适合做开关,因为 AtomicBoolean 中的值只有 1 和 0 。
AtomicLong
与 AtomicInteger 一样,AtomicLong 也是继承于 Number 类的,AtomicLong 里面的方法几乎和 AtomicInteger 一样,不过 AtomicLong 底层源码可不一样。
对比如下
方法 | AtomicInteger 底层 | AtomicLong 底层 |
---|---|---|
lazySet | putOrderedLong | putOrderedInt |
getAndSet | getAndSetLong | getAndSetInt |
compareAndSet | compareAndSwapLong | compareAndSwapInt |
getAndIncrement 等等 | getAndAddLong | getAndAddInt |
可以看到,虽然只有一词之差,但是其中的 C/C++ 源码可是相差很多,这次我们简单介绍一下 AtomicLong 的底层实现。
CAS 方法
下面我们一起来看一下 CAS 的区别
在 /jdk8u-dev/hotspot/src/share/vm/prims/unsafe.cpp 中定义了 CAS 中的方法 UNSAFE_ENTRY
。如下所示
相比于 compareAndSwapInt
方法,在 unsafe.cpp 中,compareAndSwapLong 方法包含了条件编译
SUPPORTS_NATIVE_CX8,这是啥?
我们在 AtomicLong 的 .java 文件中也能看到定义
JDK 源码给出了我们解释
VM_SUPPORTS_LONG_CAS
是 JVM 的一项用来记录,用来记录是否长期支持无锁的 compareAndSwap
方法。尽管 Unsafe.compareAndSwapLong 在有锁和无锁情况下都支持,但是应该在 Java 级别处理某些构造,以避免锁定用户可见的锁。
从代码可以看到,UNSAFE_ENTRY
方法首先会判断是否支持 SUPPORTS_NATIVE_CX8
,啥意思呢?它的意思就是判断机器是否支持 8 字节的 cmpxchg
这个 CPU 指令。如果硬件不支持,就会判断 JVM 是否支持,如果 JVM 也不支持,就表明这个操作不是 Lock Free
的。此时JVM 会使用显示锁例如 synchronized
来接管。这也是上面这段 cpp 源码的解释。
比如 32 位 CPU 肯定不支持 8 字节 64 位数字的 cpmxchg 指令
那么如何判断系统是否支持 8 字节的 cmpxchg 指令呢?或许用下面这段代码可以证明
public static void main(String[] args) throws Exception {
Class klass = Class.forName("java.util.concurrent.atomic.AtomicLong");
Field field = klass.getDeclaredField("VM_SUPPORTS_LONG_CAS");
field.setAccessible(true);
boolean VMSupportsCS8 = field.getBoolean(null);
System.out.println(VMSupportsCS8);
if (! VMSupportsCS8)
throw new Exception("Unexpected value for VMSupportsCS8");
}
这段代码摘自/jdk8u-dev/jdk/test/java/util/concurrent/atomic/VMSupportsCS8.java
JDK 源码真是个好东西。
LazySet
lazySet 的底层调用的是 unsafe.putOrderedLong
方法,它的底层源码是
可以看到,也出现了 SUPPORTS_NATIVE_CX8 这个判断,如果硬件支持的话,那么就会长期使用 CAS + Volatile 这种 Lock Free 的方式来保证原子性,下面的 SET_FIELD_VOLATILE
同时也证明了这一点,就是使用 volatile 内存语义来保证可见性。
否则就会判断 JVM 是否支持 ,如果 JVM 不值得话,也会使用 synchronized
进行锁定。
其他的方法其实大同小异了。
JSR-133都解决了哪些问题?
究竟什么是内存模型?
在多处理系统中,每个 CPU 通常都包含一层或者多层内存缓存,这样设计的原因是为了加快数据访问速度(因为数据会更靠近处理器) 并且能够减少共享内存总线上的流量(因为可以满足许多内存操作)来提高性能。内存缓存能够极大的提高性能。
但是同时,这种设计方式也带来了许多挑战。
比如,当两个 CPU 同时对同一内存位置进行操作时会发生什么?在什么情况下这两个 CPU 会看到同一个内存值?
现在,内存模型登场了!!!在处理器层面,内存模型明确定义了其他处理器的写入是如何对当前处理器保持可见的,以及当前处理器写入内存的值是如何使其他处理器可见的,这种特性被称为可见性
,这是官方定义的一种说法。
然而,可见性也分为强可见性和弱可见性,强可见性说的是任何 CPU 都能够看到指定内存位置具有相同的值;弱可见性说的是需要一种被称为内存屏障
的特殊指令来刷新缓存或者使本地处理器缓存无效,才能看到其他 CPU 对指定内存位置写入的值,写入后的值就是内存值。这些特殊的内存屏障是被封装之后的,我们不研究源码的话是不知道内存屏障这个概念的。
内存模型还规定了另外一种特性,这种特性能够使编译器对代码进行重新排序(其实重新排序不只是编译器所具有的特性),这种特性被称为有序性
。如果两行代码彼此没有相关性,那么编译器是能够改变这两行代码的编译顺序的,只要代码不会改变程序的语义,那么编译器就会这样做。
我们上面刚提到了,重新排序不只是编译器所特有的功能,编译器的这种重排序只是一种静态重排序,其实在运行时或者硬件执行指令的过程中也会发生重排序,重排序是一种提高程序运行效率的一种方式。
比如下面这段代码
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
当两个线程并行执行上面这段代码时,可能会发生重排序现象,因为 x 、 y 是两个互不相关的变量,所以当线程一执行到 writer 中时,发生重排序,y = 2 先被编译,然后线程切换,执行 r1 的写入,紧接着执行 r2 的写入,注意此时 x 的值是 0 ,因为 x = 1 没有编译。这时候线程切换到 writer ,编译 x = 1,所以最后的值为 r1 = 2,r2 = 0,这就是重排序可能导致的后果。
所以 Java 内存模型为我们带来了什么?
Java 内存模型描述了多线程中哪些行为是合法的,以及线程之间是如何通过内存进行交互的。Java 内存模型提供了两种特性,即变量之间的可见性和有序性,这些特性是需要我们在日常开发中所注意到的点。Java 中也提供了一些关键字比如 volatile、final 和 synchronized 来帮助我们应对 Java 内存模型带来的问题,同时 Java 内存模型也定义了 volatile 和 synchronized 的行为。
其他语言,比如 C++ 会有内存模型吗?
其他语言比如 C 和 C++ 在设计时并未直接支持多线程,这些语言针对编译器和硬件发生的重排序是依靠线程库(比如 pthread )、所使用的编译器以及运行代码的平台提供的保证。
JSR – 133 是关于啥的?
在 1997 年,在此时 Java 版本中的内存模型中发现了几个严重的缺陷,这个缺陷经常会出现诡异的问题,比如字段的值经常会发生改变,并且非常容易削弱编译器的优化能力。
所以,Java 提出了一项雄心勃勃的畅想:合并内存模型,这是编程语言规范第一次尝试合并一个内存模型,这个模型能够为跨各种架构的并发性提供一致的语义,但是实际操作起来要比畅想困难很多。
最终,JSR-133 为 Java 语言定义了一个新的内存模型,它修复了早期内存模型的缺陷。
所以,我们说的 JSR – 133 是关于内存模型的一种规范和定义。
JSR – 133 的设计目标主要包括:
- 保留 Java 现有的安全性保证,比如类型安全,并加强其他安全性保证,比如线程观察到的每个变量的值都必须是某个线程对变量进行修改之后的。
- 程序的同步语义应该尽可能简单和直观。
- 将多线程如何交互的细节交给程序员进行处理。
- 在广泛、流行的硬件架构上设计正确、高性能的 JVM 实现。
- 应提供初始化安全的保证,如果一个对象被正确构造后,那么所有看到对象构造的线程都能够看到构造函数中设置其最终字段的值,而不用进行任何的同步操作。
- 对现有的代码影响要尽可能的小。
重排序是什么?
在很多情况下,访问程序变量,比如对象实例字段、类静态字段和数组元素的执行顺序与程序员编写的程序指定的执行顺序不同。编译器可以以优化的名义任意调整指令的执行顺序。在这种情况下,数据可以按照不同于程序指定的顺序在寄存器、处理器缓存和内存之间移动。
有许多潜在的重新排序来源,例如编译器、JIT(即时编译)和缓存。
重排序是硬件、编译器一起制造出来的一种错觉,在单线程程序中不会发生重排序的现象,重排序往往发生在未正确同步的多线程程序中。
旧的内存模型有什么错误?
新内存模型的提出是为了弥补旧内存模型的不足,所以旧内存模型有哪些不足,我相信读者也能大致猜到了。
首先,旧的内存模型不允许发生重排序。再一点,旧的内存模型没有保证 final 的真正 不可变性
,这是一个非常令人大跌眼睛的结论,旧的内存模型没有把 final 和其他不用 final 修饰的字段区别对待,这也就意味着,String 并非是真正不可变,这确实是一个非常严重的问题。
其次,旧的内存模型允许 volatile 写入与非 volatile 读取和写入重新排序,这与大多数开发人员对 volatile 的直觉不一致,因此引起了混乱。
什么是不正确同步?
当我们讨论不正确同步的时候,我们指的是任何代码
- 一个线程对一个变量执行写操作,
- 另一个线程读取了相同的变量,
- 并且读写之间并没有正确的同步
当违反这些规则时,我们说在这个变量上发生了数据竞争现象。 具有数据竞争现象的程序是不正确同步的程序。
同步(synchronization)都做了哪些事情?
同步有几个方面,最容易理解的是互斥,也就是说一次只有一个线程可以持有一个监视器(monitor)
,所以在 monitor 上的同步意味着一旦一个线程进入一个受 monitor 保护的同步代码块,其他线程就不能进入受该 monitor 保护的块直到第一个线程退出同步代码块。
但是同步不仅仅只有互斥,它还有可见
,同步能够确保线程在进入同步代码块之前和同步代码块执行期间,线程写入内存的值对在同一 monitor 上同步的其他线程可见。
在进入同步块之前,会获取 monitor ,它具有使本地处理器缓存失效的效果,以便变量将从主内存中重新读取。 在退出一个同步代码块后,会释放 monitor ,它具有将缓存刷新到主存的功能,以便其他线程可以看到该线程所写入的值。
新的内存模型语义在内存操作上面制定了一些特定的顺序,这些内存操作包含(read、write、lock、unlock)和一些线程操作(start 、join),这些特定的顺序保证了第一个动作在执行之前对第二个动作可见,这就是 happens-before 原则,这些特定的顺序有
- 线程中的每个操作都 happens – before 按照程序定义的线程操作之前。
- Monitor 中的每个 unlock 操作都 happens-before 相同 monitor 的后续 lock 操作之前。
- 对 volatile 字段的写入都 happens-before 在每次后续读取同一 volatile 变量之前。
- 对线程的 start() 调用都 happens-before 在已启动线程的任何操作之前。
- 线程中的所有操作都 happens-before 在任何其他线程从该线程上的 join() 成功返回之前。
需要注意非常重要的一点:两个线程在同一个 monitor 之间的同步非常重要。并不是线程 A 在对象 X 上同步时可见的所有内容在对象 Y 上同步后对线程 B 可见。释放和获取必须进行
匹配
(即,在同一个 monitor 上执行)才能有正确的内存语义,否则就会发生数据竞争现象。
另外,关于 synchronized 在 Java 中的用法,你可以参考这篇文章 synchronized 的超多干货!
final 在新的 JMM 下是如何工作的?
通过上面的讲述,你现在已经知道,final 在旧的 JMM 下是无法正常工作的,在旧的 JMM 下,final 的语义就和普通的字段一样,没什么其他区别,但是在新的 JMM 下,final 的这种内存语义发生了质的改变,下面我们就来探讨一下 final 在新的 JMM 下是如何工作的。
对象的 final 字段在构造函数中设置,一旦对象被正确的构造出来,那么在构造函数中的 final 的值将对其他所有线程可见,无需进行同步操作。
什么是正确的构造呢?
正确的构造意味着在构造的过程中不允许对正在构造的对象的引用发生 逃逸
,也就是说,不要将正在构造的对象的引用放在另外一个线程能够看到它的地方。下面是一个正确构造的示例:
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
执行读取器的线程一定会看到 f.x 的值 3,因为它是 final 的。 不能保证看到 y 的值 4,因为它不是 final 的。 如果 FinalFieldExample 的构造函数如下所示:
public FinalFieldExample() {
x = 3;
y = 4;
// 错误的构造,可能会发生逃逸
global.obj = this;
}
这样就不会保证读取 x 的值一定是 3 了。
这也就说是,如果在一个线程构造了一个不可变对象(即一个只包含 final 字段的对象)之后,你想要确保它被所有其他线程正确地看到,通常仍然需要正确的使用同步。
volatile 做了哪些事情?
我写过一篇 volatile 的详细用法和其原理的文章,你可以阅读这篇文章 volatile 的用法和实现原理
新的内存模型修复了双重检查锁的问题吗?
也许我们大家都见过多线程单例模式双重检查锁的写法,这是一种支持延迟初始化同时避免同步开销的技巧。
class DoubleCheckSync{
private static DoubleCheckSync instance = null;
public DoubleCheckSync getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new DoubleCheckSync();
}
}
return instance;
}
}
这样的代码看起来在程序定义的顺序上看起来很聪明,但是这段代码却有一个致命的问题:它不起作用。
??????
双重检查锁不起作用?
是的!
为毛?
原因就是初始化实例的写入和对实例字段的写入可以由编译器或缓存重新排序,看起来我们可能读取了初始化了 instance 对象,但其实你可能只是读取了一个未初始化的 instance 对象。
有很多小伙伴认为使用 volatile 能够解决这个问题,但是在 1.5 之前的 JVM 中,volatile 不能保证。在新的内存模型下,使用 volatile 会修复双重检查锁定的问题,因为这样在构造线程初始化 DoubleCheckSync 和返回其值之间将存在 happens-before 关系读取它的线程。
AtomicInteger的用法和实现原理
i++ 不是线程安全的操作,因为它不是一个原子性操作。
那么,如果我想要达到类似 i++ 的这种效果,我应该使用哪些集合或者说工具类呢?
在 JDK1.5 之前,为了确保在多线程下对某基本
数据类型或者引用
数据类型运算的原子性,必须依赖于外部关键字 synchronized
,但是这种情况在 JDK1.5 之后发生了改观,当然你依然可以使用 synchronized 来保证原子性,我们这里所说的一种线程安全的方式是原子性的工具类,比如 AtomicInteger、AtomicBoolean 等。这些原子类都是线程安全的工具类,他们同时也是 Lock-Free
的。下面我们就来一起认识一下这些工具类以及 Lock – Free 是个什么概念。
了解 AtomicInteger
AtomicInteger
是 JDK 1.5 新添加的工具类,我们首先来看一下它的继承关系
与 int 的包装类 Integer 一样,都是继承于 Number
类的。
这个 Number 类是基本数据类型的包装类,一般和数据类型有关的对象都会继承于 Number 类。
它的继承体系很简单,下面我们来看一下它的基本属性和方法
AtomicInteger 的基本属性
AtomicInteger 的基本属性有三个
Unsafe
是 sun.misc
包下面的类,AtomicInteger 主要是依赖于 sun.misc.Unsafe 提供的一些 native 方法保证操作的原子性。
Unsafe 的 objectFieldOffset
方法可以获取成员属性在内存中的地址相对于对象内存地址的偏移量。说得简单点就是找到这个变量在内存中的地址,便于后续通过内存地址直接进行操作,这个值就是 value
这个我们后面会再细说
value
就是 AtomicIneger 的值。
AtomicInteger 的构造方法
继续往下看,AtomicInteger 的构造方法只有两个,一个是无参数的构造方法,无参数的构造方法默认的 value 初始值是 0 ,带参数的构造方法可以指定初始值。
AtomicInteger 中的方法
下面我们就来聊一下 AtomicInteger 中的方法。
Get 和 Set
我们首先来看一下最简单的 get 、set 方法:
get()
: 获取当前 AtomicInteger 的值
set()
: 设置当前 AtomicInteger 的值
get() 可以原子性的读取 AtomicInteger 中的数据,set() 可以原子性的设置当前的值,因为 get() 和 set() 最终都是作用于 value 变量,而 value 是由 volatile
修饰的,所以 get 、set 相当于都是对内存进行读取和设置。如下图所示
我们上面提到了 i++ 和 i++ 的非原子性操作,我们说可以使用 AtomicInteger 中的方法进行替换。
Incremental 操作
AtomicInteger 中的 Incremental
相关方法可以满足我们的需求
getAndIncrement()
: 原子性的增加当前的值,并把结果返回。相当于i++
的操作。
为了验证是不是线程安全的,我们用下面的例子进行测试
public class TAtomicTest implements Runnable{
AtomicInteger atomicInteger = new AtomicInteger();
@Override
public void run() {
for(int i = 0;i < 10000;i++){
System.out.println(atomicInteger.getAndIncrement());
}
}
public static void main(String[] args) {
TAtomicTest tAtomicTest = new TAtomicTest();
Thread t1 = new Thread(tAtomicTest);
Thread t2 = new Thread(tAtomicTest);
t1.start();
t2.start();
}
}
通过输出结果你会发现它是一个线程安全的操作,你可以修改 i 的值,但是最后的结果仍然是 i – 1,因为先取值,然后再 + 1,它的示意图如下。
incrementAndGet
与此相反,首先执行 + 1 操作,然后返回自增后的结果,该操作方法能够确保对 value 的原子性操作。如下图所示
Decremental 操作
与此相对,x– 或者 x = x – 1 这样的自减操作也是原子性的。我们仍然可以使用 AtomicInteger 中的方法来替换
getAndDecrement
: 返回当前类型的 int 值,然后对 value 的值进行自减运算。下面是测试代码
class TAtomicTestDecrement implements Runnable{
AtomicInteger atomicInteger = new AtomicInteger(20000);
@Override
public void run() {
for(int i = 0;i < 10000 ;i++){
System.out.println(atomicInteger.getAndDecrement());
}
}
public static void main(String[] args) {
TAtomicTestDecrement tAtomicTest = new TAtomicTestDecrement();
Thread t1 = new Thread(tAtomicTest);
Thread t2 = new Thread(tAtomicTest);
t1.start();
t2.start();
}
}
下面是 getAndDecrement 的示意图
decrementAndGet
:同样的,decrementAndGet 方法就是先执行递减操作,然后再获取 value 的值,示意图如下
LazySet 方法
volatile 有内存屏障你知道吗?
内存屏障是啥啊?
内存屏障,也称
内存栅栏
,内存栅障,屏障指令等, 是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。也是一个让CPU 处理单元中的内存状态对其它处理单元可见的一项技术。
CPU 使用了很多优化,使用缓存、指令重排等,其最终的目的都是为了性能,也就是说,当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。所以指令的执行时序并不是顺序执行的,而是乱序执行的,这就会带来很多问题,这也促使着内存屏障的出现。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
内存屏障的开销非常轻量级,但是再小也是有开销的,LazySet 的作用正是如此,它会以普通变量的形式来读写变量。
也可以说是:**懒得设置屏障了
GetAndSet 方法
以原子方式设置为给定值并返回旧值。
它的源码就是调用了一下 unsafe 中的 getAndSetInt 方法,如下所示
就是先进行循环,然后调用 getIntVolatile
方法,这个方法我在 cpp 中没有找到,找到的小伙伴们记得及时告诉让我学习一下。
循环直到 compareAndSwapInt 返回 false,这就说明使用 CAS 并没有更新为新的值,所以 var5 返回的就是最新的内存值。
CAS 方法
我们一直常说的 CAS 其实就是 CompareAndSet
方法,这个方法顾名思义,就是 比较并更新 的意思,当然这是字面理解,字面理解有点偏差,其实人家的意思是先比较,如果满足那么再进行更新。
上面给出了 CAS Java 层面的源码,JDK 官方给它的解释就是 如果当前值等于 expect 的值,那么就以原子性的方式将当前值设置为 update 给定值,这个方法会返回一个 boolean 类型,如果是 true 就表示比较并更新成功,否则表示失败。
CAS 同时也是一种无锁并发机制,也称为 Lock Free
,所以你觉得 Lock Free 很高大上吗?并没有。
下面我们构建一个加锁解锁的 CASLock
class CASLock {
AtomicInteger atomicInteger = new AtomicInteger();
Thread currentThread = null;
public void tryLock() throws Exception{
boolean isLock = atomicInteger.compareAndSet(0, 1);
if(!isLock){
throw new Exception("加锁失败");
}
currentThread = Thread.currentThread();
System.out.println(currentThread + " tryLock");
}
public void unlock() {
int lockValue = atomicInteger.get();
if(lockValue == 0){
return;
}
if(currentThread == Thread.currentThread()){
atomicInteger.compareAndSet(1,0);
System.out.println(currentThread + " unlock");
}
}
public static void main(String[] args) {
CASLock casLock = new CASLock();
for(int i = 0;i < 5;i++){
new Thread(() -> {
try {
casLock.tryLock();
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
}finally {
casLock.unlock();
}
}).start();
}
}
}
在上面的代码中,我们构建了一个 CASLock,在 tryLock
方法中,我们先使用 CAS 方法进行更新,如果更新不成功则抛出异常,并把当前线程设置为加锁线程。在 unLock
方法中,我们先判断当前值是否为 0 ,如果是 0 就是我们愿意看到的结果,直接返回。否则是 1,则表示当前线程还在加锁,我们再来判断一下当前线程是否是加锁线程,如果是则执行解锁操作。
那么我们上面提到的 compareAndSet,它其实可以解析为如下操作
// 伪代码
// 当前值
int v = 0;
int a = 0;
int b = 1;
if(compare(0,0) == true){
set(0,1);
}
else{
// 继续向下执行
}
也可以拿生活场景中的买票举例子,你去景区旅游肯定要持票才能进,如果你拿着是假票或者不符合景区的票肯定是能够被识别出来的,如果你没有拿票拿你也肯定进不去景区。
废话少说,这就祭出来 compareAndSet 的示意图
weakCompareAndSet
: 妈的非常认真看了好几遍,发现 JDK1.8 的这个方法和 compareAndSet 方法完全一摸一样啊,坑我。。。
但是真的是这样么?并不是,JDK 源码很博大精深,才不会设计一个重复的方法,你想想 JDK 团队也不是会犯这种低级团队,但是原因是什么呢?
《Java 高并发详解》这本书给出了我们一个答案
AddAndGet
AddAndGet 和 getAndIncrement、getAndAdd、incrementAndGet 等等方法都是使用了 do … while + CAS 操作,其实也就相当于是一个自旋锁,如果 CAS 修改成功就会一直循环,修改失败才会返回。示意图如下
深入 AtomicInteger
我们上面探讨了 AtomicInteger 的具体使用,同时我们知道 AtomicInteger 是依靠 volatile 和 CAS 来保证原子性的,那么我们下面就来分析一下为什么 CAS 能够保证原子性,它的底层是什么?AtomicInteger 与乐观锁又有什么关系呢?
AtomicInteger 的底层实现原理
我们再来瞧瞧这个可爱的 compareAndSetL(CAS)
方法,为什么就这两行代码就保证原子性了?
我们可以看到,这个 CAS 方法相当于是调用了 unsafe 中的 compareAndSwapInt
方法,我们进到 unsafe 方能发中看一下具体实现。
compareAndSwapInt 是 sun.misc
中的方法,这个方法是一个 native
方法,它的底层是 C/C++ 实现的,所以我们需要看 C/C++ 的源码。
知道 C/C++ 的牛逼之处了么。使用 Java 就是玩应用和架构的,C/C++ 是玩服务器、底层的。
compareAndSwapInt 的源码在 jdk8u-dev/hotspot/src/share/vm/prims/unsafe.app
路径下,它的源码实现是
也就是 Unsafe_CompareAndSwapInt
方法,我们找到这个方法
C/C++ 源码我也看不懂,但是这不妨碍我们找到关键代码 Atomic::cmpxchg
,cmpxchg 是 x86 CPU 架构的汇编指令,它的主要作用就是比较并交换操作数。我们继续往下跟找一下这个指令的定义。
我们会发现对应不同的 os,其底层实现方式不一样
我们找到 Windows 的实现方式如下
我们继续向下找,它其实定义的是第 216 行的代码,我们找进去
此时就需要汇编指令和寄存器相关的知识了。
上面的 os::is-MP()
是多处理操作系统的接口,下面是 __asm ,它是 C/C++ 的关键字,用于调用内联汇编程序。
__asm 中的代码是汇编程序,大致来说就是把 dest、exchange_value 、compare_value 的值都放在寄存器中,下面的 LOCK_IF_MP
中代码的大致意思就是
如果是多处理器的话就会执行 lock,然后进行比较操作。其中的 cmp 表示比较,mp 表示的就是 MultiProcess
,je
表示相等跳转,L0 表示的是标识位。
我们回到上面的汇编指令,我们可以看到,CAS 的底层就是 cmpxchg
指令。
乐观锁
你有没有这个疑问,为什么 AtomicInteger 可以获取当前值,那为什么还会出现 expectValue
和 value
不一致的情况呢?
因为 AtomicInteger 只是一个原子性的工具类,它不具有排他性,它不像是 synchronized
或者是 lock
一样具有互斥和排他性,还记得 AtomicInteger 中有两个方法 get 和 set 吗?它们只是用 volatile
修饰了一下,而 volatile 不具有原子性,所以可能会存在 expectValue 和 value 的当前值不一致的情况,因此可能会出现重复修改。
针对上面这种情况的解决办法有两种,一种是使用 synchronized
和 lock
等类似的加锁机制,这种锁具有独占性,也就是说同一时刻只能有一个线程来进行修改,这种方式能够保证原子性,但是相对开销比较大,这种锁也叫做悲观锁。另外一种解决办法是使用版本号
或者是 CAS 方法
。
**版本号
版本号机制是在数据表中加上一个 version
字段来实现的,表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1,当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
**CAS 方法
还有一种方式就是 CAS 了,我们上面用了大量的篇幅来介绍 CAS 方法,那么我们认为你现在已经对其运行机制有一定的了解了,我们就不再阐述它的运行机制了。
任何事情都是有利也有弊,软件行业没有完美的解决方案只有最优的解决方案,所以乐观锁也有它的弱点和缺陷,那就是 ABA 问题。
ABA 问题
ABA 问题说的是,如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?可以是由 A -> B -> A 的这种情况,但是 AtomicInteger 却不会这么认为,它只相信它看到的,它看到的是什么就是什么。举个例子来说
假如现在有一个单链表,如下图所示
A.next = B ,B.next = null,此时有两个线程 T1 和 T2 分别从单链表中取出 A ,由于一些特殊原因,T2 把 A 改为 B ,然后又改为 A ,此时 T1 执行 CAS 方法,发现单链表仍然是 A ,就会执行 CAS 方法,虽然结果没错,但是这种操作会造成一些潜在的问题。
此时还是一个单链表,两个线程 T1 和 T2 分别从单链表中取出 A ,然后 T1 把链表改为 ACD 如下图所示
此时 T2,发现内存值还是 A ,就会把 A 的值尝试替换为 B ,因为 B 的引用是 null,此时就会造成 C、D 处于游离态
JDK 1.5 以后的 AtomicStampedReference
类就提供了此种能力,其中的 compareAndSet
方法就是首先检查当前值是否等于预期值,判断的标准就是当前引用和邮戳分别和预期引用和邮戳相等,如果全部相等,则以原子方式设置为给定的更新值。
好了,上面就是 Java 代码流程了,看到 native 我们知道又要撸 cpp 了。开撸
简单解释一下就是 UnsafeWrapper
就是包装器,换个名字而已。然后经过一些 JNI 的处理,因为 compareAndSwapOject 比较的是引用,所以需要经过 C++ 面向对象的转换。最主要的方法是 atomic_compare_exchange_oop
可以看到,又出现了熟悉的词汇 cmpxchg
,也就是说 compareAndSwapOject 使用的还是 cmpxchg 原子性指令,只是它经过了一系列转换。
后记
抛出来一个问题,CAS 能保证变量之间的可见性么?为什么?
还有一个问题,getIntVolatile
方法的 cpp 源码在哪里?怎么找?
如果上面大佬们对这两个问题有兴趣,欢迎交流。
深入理解volatile关键字
volatile 这个关键字大家都不陌生,这个关键字一般通常用于并发编程中,是 Java 虚拟机提供的轻量化同步机制,你可能知道 volatile 是干啥的,但是你未必能够清晰明了的知道 volatile 的实现机制,以及 volatile 解决了什么问题,这篇文章我就来带大家解析一波。
volatile 能够保证共享变量之间的 可见性
,共享变量是存在堆区的,而堆区又与内存模型有关,所以我们要聊 volatile ,就需要首先了解一下 JVM 内存模型,而 JVM 又是和内存进行交互的,所以在聊 JVM 内存模型前,我们还需要了解一下操作系统层面中内存模型的相关概念。
先从内存模型谈起
计算机在执行程序时,会从内存中读取数据,然后加载到 CPU 中运行。由于 CPU 执行指令的速度要比从内存中读取和写入的速度快的多,所以如果每条指令都要和内存交互的话,会大大降低 CPU 的运行速度,造成昂贵的 CPU 性能损耗,为了解决这种问题,设计了 CPU 高速缓存。有了 CPU 高速缓存后,CPU 就不再需要频繁的和内存交互了,有高速缓存就行了,而 CPU 高速缓存,就是我们经常说的 L1 、L2、L3 cache。
当程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存中,在 CPU 进行计算时就可以直接从它的高速缓存读写数据,当运算结束之后,再将高速缓存中的数据刷新到主存中。
就拿我们常说的
i = i + 1
来举例子
当 CPU 执行这条语句时,会先从内存中读取 i 的值,复制一份到高速缓存当中,然后 CPU 执行指令对 i 进行加 1 操作,再将数据写入高速缓存,最后将高速缓存中 i 最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了,因为每个 CPU 都可以运行一条线程,线程就是程序的顺序执行流,因此每个线程运行时有自己的高速缓存(对单核 CPU 来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核 CPU 为例来讲解说明。
比如同时有 2 个线程执行这段代码,假如初始时 i 的值为 0,那么我们希望两个线程执行完之后 i 的值变为 2,但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取 i 的值存入各自所在的 CPU 高速缓存中,然后线程 1 执行加 1 操作,把 i 的最新值 1 写入到内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为 1,然后线程 2 把 i 的值写入内存。
最终结果 i 的值是 1,而不是 2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
也就是说,如果一个变量在多个 CPU 中都存在缓存(一般在多线程编程时才会出现),就很可能存在缓存不一致的问题。
JVM 内存模型
我们上面说到,共享变量会存在缓存不一致的问题,缓存不一致问题换种说法就是线程安全问题,那么共享变量在 Java 中是如何存在的呢?JVM 中有没有提供线程安全的变量或者数据呢?
这就要聊聊 JVM 内存模型的问题了,图示如下
虚拟机栈
: Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈种创建一个栈帧(stack frame)
。本地方法栈
: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用native
关键字修饰的方法所存储的区域。程序计数器
:程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。方法区
:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。堆
: 堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上运行时常量池
:运行时常量池又被称为Runtime Constant Pool
,这块区域是方法区的一部分,它的名字非常有意思,它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。
根据上面的描述可以看到,会产生缓存不一致问题(线程安全问题)的有堆区和方法区。而虚拟机栈、本地方法栈、程序计数器是线程私有,由线程封闭的原因,它们不存在线程安全问题。
针对线程安全问题,有没有解决办法呢?
一般情况下,Java 中解决缓存不一致的方法有两种,第一种就是 synchronized
使用的总线锁方式,也就是在总线上声言 LOCK#
信号;第二种就是著名的 MESI
协议。这两种都是硬件层面提供的解决方式。
我们先来说一下第一种总线锁的方式。通过在总线上声言 LOCK# 信号,能够有效的阻塞其他 CPU 对于总线的访问,从而使得只能有一个 CPU 访问变量所在的内存。在上面的 i = i + 1 代码示例中,在代码执行的过程中,声言了 LOCK# 信号后,那么只有等待 i = i + 1 的结果执行完毕并应用到内存后,总线锁才会解开,其他 CPU 才能够继续访问内存中的变量,再继续执行后面的代码,这样就解决了缓存不一致问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。
在 JDK 1.6 之后,优化了 synchronized 声言 LOCK# 的方式,不再对总线进行锁定,转而采取了对 CPU 缓存行进行锁定,因为本篇文章不是介绍 synchronized 实现细节的文章,所以不再对这种方式进行详细介绍,读者只需要知道在优化之后,synchronized 的性能不再成为并发问题的瓶颈了。
MESI 协议就是缓存一致性协议,即 Modified(被修改)Exclusive(独占的) Shared(共享的) Or Invalid(无效的)。MESI 的基本思想就是如果发现 CPU 操作的是共享变量,其他 CPU 中也会出现这个共享变量的副本,在 CPU 执行代码期间,会发出信号通知其他 CPU 自己正在修改共享变量,其他 CPU 收到通知后就会把自己的共享变量置为无效状态。
并发编程中的三个主要问题
可见性问题
在单核 CPU 时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决,我们还拿上面的 i = 1 + 1 来举例,CPU 和 内存之间如果用图来表示的话我想会是下面这样。
在多核时代,每个核都能够独立的运行一个线程,每个 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程使用的是不同的 CPU 缓存。
因为 i 没有经过任何线程安全措施的保护,多个线程会并发修改 i 的值,所以我们认为 i 不是线程安全的,导致这种结果的出现是由于 aThread 和 bThread 中读取的 i 值彼此不可见,所以这是由于 可见性
导致的线程安全问题。
原子性问题
当两个线程开始运行后,每个线程都会把 i 的值读入到 CPU 缓存中,再执行 + 1 操作,然后把 + 1 之后的值写入内存。因为线程间都有各自的虚拟机栈和程序计数器,他们彼此之间没有数据交换,所以当 aThread 执行 + 1 操作后,会把数据写入到内存,同时 bThread 执行 + 1 操作后,也会把数据写入到内存,因为 CPU 时间片的执行周期是不确定的,所以会出现当 aThread 还没有把数据写入内存时,bThread 就会读取内存中的数据,然后执行 + 1操作,再写回内存,从而覆盖 i 的值。
有序性问题
在并发编程中还有带来让人非常头疼的 有序性
问题,有序性顾名思义就是顺序性,在计算机中指的就是指令的先后执行顺序。一个非常显而易见的例子就是 JVM 中的类加载
。
这是一个 JVM 加载类的过程图,也称为类的生命周期,类从加载到 JVM 到卸载一共会经历五个阶段 加载、连接、初始化、使用、卸载。这五个过程的执行顺序是一定的,但是在连接阶段,也会分为三个过程,即 验证、准备、解析 阶段,这三个阶段的执行顺序不是确定的,通常交叉进行,在一个阶段的执行过程中会激活另一个阶段。
在执行程序的过程中,为了提高性能,编译器和处理器通常会对指令进行重排序。重排序主要分为三类
- 编译器优化的重排序:编译器在不改变单线程语义的情况下,会对执行语句进行重新排序。
- 指令集重排序:现代操作系统中的处理器都是并行的,如果执行语句之间不存在数据依赖性,处理器可以改变语句的执行顺序
- 内存重排序:由于处理器会使用读/写缓冲区,出于性能的原因,内存会对读/写进行重排序
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
volatile 的实现原理
上面聊了这么多,你可能都要忘了这篇文章的故事主角了吧?主角永远存在于我们心中 ……
其实上面聊的这些,都是在为 volatile 做铺垫。
在并发编程中,最需要处理的就是线程之间的通信
和线程间的同步
问题,上面的可见性、原子性、有序性也是这两个问题带来的。
可见性
而 volatile 就是为了解决这些问题而存在的。Java 语言规范对 volatile 下了定义:Java 语言为了确保能够安全的访问共享变量,提供了 volatile 这个关键字,volatile 是一种轻量级同步机制,它并不会对共享变量进行加锁,但在某些情况下要比加锁更加方便,如果一个字段被声明为 volatile,Java 线程内存模型能够确保所有线程访问这个变量的值都是一致的。
一旦共享变量被 volatile 修饰后,就具有了下面两种含义
- 保证了这个字段的可见性,也就是说所有线程都能够"看到"这个变量的值,如果某个 CPU 修改了这个变量的值之后,其他 CPU 也能够获得通知。
- 能够禁止指令的重排序
下面我们来看一段代码,这也是我们编写并发代码中经常会使用到的
boolean isStop = false;
while(!isStop){
...
}
isStop = true;
在这段代码中,如果线程一正在执行 while 循环,而线程二把 isStop 改为 true 之后,转而去做其他事情,因为线程一并不知道线程二把 isStop 改为 true ,所以线程一就会一直运行下去。
如果 isStop 用 volatile 修饰之后,那么事情就会变的不一样了。
使用 volatile 修饰了 isStop 之后,在线程二把 isStop 改为 true 之后,会强制将其写入内存,并且会把线程一中 isStop 的值置为无效(这个值实际上是在缓存在 CPU 中的缓存行里),当线程一继续执行代码的时候,会从内存中重新读取 isStop 的值,此时 isStop 的值就是正确的内存地址的值。
volatile 有下面两条实现原则,其实这两条原则我们在上面介绍的时候已经提过了,一种是总线锁的方式,我们后面说总线锁的方式开销比较大,所以后面设计人员做了优化,采用了锁缓存的方式。另外一种是 MESI 协议的方式。
- 在 IA-32 架构软件开发者的手册中,有一种 Lock 前缀指令,这种指令能够声言 LOCK# 信号,在最近的处理器中,LOCK# 信号用于锁缓存,等到指令执行完毕后,会把缓存的内容写回内存,这种操作一般又被称为缓存锁定。
- 当缓存写回内存后,IA-32 和 IA-64 处理器会使用 MESI 协议控制内部缓存和其他处理器一致。IA-32 和 IA-64 处理器能够嗅探其他处理器访问系统内部缓存,当内存值修改后,处理器会从内存中重新读取内存值进行新的缓存行填充。
由此可见,volatile 能够保证线程的可见性。
那么 volatile 能够保证原子性吗?
原子性
我们还是以 i = i + 1 这个例子来说明一下,i = i + 1 分为三个操作
- 读取 i 的值
- 自增 i 的值
- 把 i 的值写会内存
我们知道,volatile 能够保证修改 i 的值对其他线程可见,所以我们此时假设线程一执行 i 的读取操作,此时发生了线程切换,线程二读取到最新 i 的值是 0 ,然后线程再次发生切换,线程一把 i 的值改为 1,线程再次切换,因为此时 i 的值还没有应用到内存,所以线程 i 同样把 i 的值改为 1 后,线程再次发生切换,线程一把 i 的值写入内存后,再次发生切换,线程二再次把 i 的值写会内存,所以此时,虽然内存值改了两次,但是最后的结果却不是 2。
那么 volatile 不能保证原子性,那么该如何保证原子性呢?
在 JDK 5 的 java.util.concurrent.atomic 包下提供了一些原子操作类,例如 AtomicInteger、AtomicLong、AtomicBoolean,这些操作是原子性操作。它们是利用 CAS 来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的 CMPXCHG
指令实现的,而处理器执行 CMPXCHG 指令是一个原子性操作。
详情可以参考笔者的这篇文章 一场 Atomic XXX 的魔幻之旅。
那么 volatile 能不能保证有序性呢?
这里就需要和你聊一聊 volatile 对有序性的影响了
有序性
上面提到过,重排序分为编译器重排序、处理器重排序和内存重排序。我们说的 volatile 会禁用指令重排序,实际上 volatile 禁用的是编译器重排序和处理器重排序。
下面是 volatile 禁用重排序的规则
从这个表中可以看出来,读写操作有四种,即不加任何修饰的普通读写和使用 volatile 修饰的读写。
从这个表中,我们可以得出下面这些结论
- 只要第二个操作(这个操作就指的是代码执行指令)是 volatile 修饰的写操作,那么无论第一个操作是什么,都不能被重排序。
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能进行重排序。
- 当第一个操作是 volatile 写之后,第二个操作是 volatile 读/写都不能重排序。
为了实现这种有序性,编译器会在生成字节码中,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
这里我们先来了解一下内存屏障的概念。
内存屏障也叫做栅栏
,它是一种底层原语。它使得 CPU 或编译器在对内存进行操作的时候, 要严格按照一定的顺序来执行, 也就是说在 memory barrier 之前的指令和 memory barrier 之后的指令不会由于系统优化等原因而导致乱序。
内存屏障提供了两个功能。首先,它们通过确保从另一个 CPU 来看屏障的两边的所有指令都是正确的程序顺序;其次它们可以实现内存数据可见性,确保内存数据会同步到 CPU 缓存子系统。
不同计算机体系结构下面的内存屏障也不一样,通常需要认真研读硬件手册来确定,所以我们的主要研究对象是基于 x86 的内存屏障,通常情况下,硬件为我们提供了四种类型的内存屏障。
- LoadLoad 屏障
它的执行顺序是 Load1 ; LoadLoad ;Load2 ,其中的 Load1 和 Load2 都是加载指令。LoadLoad 指令能够确保执行顺序是在 Load1 之后,Load2 之前,LoadLoad 指令是一个比较有效的防止看到旧数据的指令。
- StoreStore 屏障
它的执行顺序是 Store1 ;StoreStore ;Store2 ,和上面的 LoadLoad 屏障的执行顺序相似,它也能够确保执行顺序是在 Store1 之后,Store2 之前。
- LoadStore 屏障
它的执行顺序是 Load1 ; StoreLoad ; Store2 ,保证 Load1 的数据被加载在与这数据相关的 Store2 和之后的 store 指令之前。
- StoreLoad 屏障
它的执行顺序是 Store1 ; StoreLoad ; Load2 ,保证 Store1 的数据被其他 CPU 看到,在数据被 Load2 和之后的 load 指令加载之前。也就是说,它有效的防止所有 barrier 之前的 stores 与所有 barrier 之后的 load 乱序。
JMM 采取了保守策略来实现内存屏障,JMM 使用的内存屏障如下
下面是一个使用内存屏障的示例
class MemoryBarrierTest {
int a, b;
volatile int v, u;
void f() {
int i, j;
i = a;
j = b;
i = v;
j = u;
a = i;
b = j;
v = i;
u = j;
i = u;
j = b;
a = i;
}
}
这段代码虽然比较简单,但是使用了不少变量,看起来有些乱,我们反编译一下来分析一下内存屏障对这段代码的影响。
从反编译的代码我们是看不到内存屏障的,因为内存屏障是一种硬件层面的指令,单凭字节码是肯定无法看到的。虽然无法看到内存屏障的硬件指令,但是 JSR-133 为我们说明了哪些字节码会出现内存屏障。
- 普通的读类似 getfield 、getstatic 、 不加 volatile 修饰的数组 load 。
- 普通的写类似 putfield 、 putstatic 、 不加 volatile 修饰的数组 store 。
- volatile 读是可以被多个线程访问修饰的 getfield、 getstatic 字段。
- volatile 写是可以被当个线程访问修饰的 putfield、 putstatic 字段。
这也就是说,只要是普通的读写加上了 volatile 关键字之后,就是 volatile 读写(呃呃呃,我好像说了一句废话),并没有其他特殊的 volatile 独有的指令。
根据这段描述,我们来继续分析一下上面的字节码。
a、b 是全局变量,也就是实例变量,不加 volatile 修饰,u、v 是 volatile 修饰的全局变量;i、j 是局部变量。
首先 i = a、j = b 只是把全局变量的值赋给了局部变量,由于是获取对象引用的操作,所以是字节码指令是 getfield 。
从官方手册就可以知晓原因了。
地址在 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
由内存屏障的表格可知,第一个操作是普通读写的情况下,只有第二个操作是 volatile 写才会设置内存屏障。
继续向下分析,遇到了 i = v,这个是把 volatile 变量赋值给局部变量,是一种 volatile 读,同样的 j = u 也是一种 volatile 读,所以这两个操作之间会设置 LoadLoad 屏障。
下面遇到了 a = i ,这是为全局变量赋值操作,所以其对应的字节码是 putfield
地址在 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
所以在 j = u 和 a = i 之间会增加 LoadStore 屏障。然后 a = i 和 b = j 是两个普通写,所以这两个操作之间不需要有内存屏障。
继续往下面分析,第一个操作是 b = j ,第二个操作是 v = i 也就是 volatile 写,所以需要有 StoreStore 屏障;同样的,v = i 和 u = j 之间也需要有 StoreStore 屏障。
第一个操作是 u = j 和 第二个操作 i = u volatile 读之间需要 StoreLoad 屏障。
最后一点需要注意下,因为最后两个操作是普通读和普通写,所以最后需要插入两个内存屏障,防止 volatile 读和普通读/写重排序。
《Java 并发编程艺术》里面也提到了这个关键点。
从上面的分析可知,volatile 实现有序性是通过内存屏障来实现的。
关键概念
在 volatile 实现可见性和有序性的过程中,有一些关键概念,cxuan 这里重新给读者朋友们唠叨下。
-
缓冲行:英文概念是 cache line,它是缓存中可以分配的最小存储单位。因为数据在内存中不是以独立的项进行存储的,而是以临近 64 字节的方式进行存储。
-
缓存行填充:cache line fill,当 CPU 把内存的数据载入缓存时,会把临近的共 64 字节的数据一同放入同一个 Cache line,因为局部性原理:临近的数据在将来被访问的可能性大。
-
缓存命中:cache hit,当 CPU 从内存地址中提取数据进行缓存行填充时,发现提取的位置仍然是上次访问的位置,此时 CPU 会选择从缓存中读取操作数,而不是从内存中取。
-
写命中:write hit ,当处理器打算将操作数写回到内存时,首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器会将这个操作数写回到缓存,而不是写回到内存,这种方式被称为写命中。
-
内存屏障:memory barriers,是一组硬件指令,是 volatile 实现有序性的基础。
-
原子操作:atomic operations,是一组不可中断的一个或者一组操作。
如何正确的使用 volatile 变量
上面我们聊了这么多 volatile 的原理,下面我们就来谈一谈 volatile 的使用问题。
volatile 通常用来和 synchronized 锁进行比较,虽然它和锁都具有可见性,但是 volatile 不具有原子性,它不是真正意义上具有线程安全性的一种工具。
从程序代码简易性和可伸缩性角度来看,你可能更倾向于使用 volatile 而不是锁,因为 volatile 写起来更方便,并且 volatile 不会像锁那样造成线程阻塞,而且如果程序中的读操作的使用远远大于写操作的话,volatile 相对于锁还更加具有性能优势。
很多并发专家都推荐远离 volatile 变量,因为它们相对于锁更加容易出错,但是如果你谨慎的遵从一些模式,就能够安全的使用 volatile 变量,这里有一个 volatile 使用原则
只有在状态真正独立于程序内其他内容时才能使用 volatile。
下面我们通过几段代码来感受一下这条规则的力量。
状态标志
一种最简单使用 volatile 的方式就是将 volatile 作为状态标志来使用。
volatile boolean shutdownRequested;
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
为了能够正确的调用 shutdown() 方法,你需要确保 shutdownRequested 的可见性。这种状态标志的一种特性就是通常只有一种状态转换:shutdownRequested 的标志从 false 转为 true,然后程序停止。这种模式可以相互来回转换。
双重检查锁
使用 volatile 和 synchronized 可以满足双重检查锁的单例模式。
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if(instance == null) {
synchronized (Singleton.class) {
if(instance == null)
instance = new Singleton();
}
}
return instance;
}
}
这里说下为什么要用两次检查,假如有两个线程,线程一在进入到 synchronized 同步代码块之后,在还没有生成 Singleton 对象前发生线程切换,此时线程二判断 instance == null 为 true,会发生线程切换,切换到线程一,然后退出同步代码块,线程切换,线程二进入同步代码块后,会再判断一下 instance 的值,这就是双重检查锁的必要所在。
读-写锁
这也是 volatile 和 synchronized 一起使用的示例,用于实现开销比较低的读-写锁。
public class ReadWriteLockTest {
private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
如果只使用 volatile 是不能安全实现计数器的,但是你能够在读操作中使用 volatile 保证可见性。如果你想要实现一种读写锁的话,必须进行外部加锁。
Semaphore用法和源码解析
这是并发线程工具类的第二篇文章,在第一篇中,我们分析过 CountDownLatch
的相关内容,你可以参考
那么本篇文章我们继续来和你聊聊并发工具类的第二篇文章 — Semaphore 。
认识 Semaphore
Semaphore 是什么
Semaphore 一般译作 信号量
,它也是一种线程同步工具,主要用于多个线程对共享资源进行并行操作的一种工具类。它代表了一种许可
的概念,是否允许多线程对同一资源进行操作的许可,使用 Semaphore 可以控制并发访问资源的线程个数。
Semaphore 的使用场景
Semaphore 的使用场景主要用于流量控制
,比如数据库连接,同时使用的数据库连接会有数量限制,数据库连接不能超过一定的数量,当连接到达了限制数量后,后面的线程只能排队等前面的线程释放数据库连接后才能获得数据库连接。
再比如交通公路上的红绿灯,绿灯亮起时只能让 100 辆车通过,红灯亮起不允许车辆通过。
再比如停车场的场景中,一个停车场有有限数量的车位,同时能够容纳多少台车,车位满了之后只有等里面的车离开停车场外面的车才可以进入。
Semaphore 使用
下面我们就来模拟一下停车场的业务场景:在进入停车场之前会有一个提示牌,上面显示着停车位还有多少,当车位为 0 时,不能进入停车场,当车位不为 0 时,才会允许车辆进入停车场。所以停车场有几个关键因素:停车场车位的总容量,当一辆车进入时,停车场车位的总容量 – 1,当一辆车离开时,总容量 + 1,停车场车位不足时,车辆只能在停车场外等待。
public class CarParking {
private static Semaphore semaphore = new Semaphore(10);
public static void main(String[] args){
for(int i = 0;i< 100;i++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("欢迎 " + Thread.currentThread().getName() + " 来到停车场");
// 判断是否允许停车
if(semaphore.availablePermits() == 0) {
System.out.println("车位不足,请耐心等待");
}
try {
// 尝试获取
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " 进入停车场");
Thread.sleep(new Random().nextInt(10000));// 模拟车辆在停车场停留的时间
System.out.println(Thread.currentThread().getName() + " 驶出停车场");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, i + "号车");
thread.start();
}
}
}
在上面这段代码中,我们给出了 Semaphore 的初始容量,也就是只有 10 个车位,我们用这 10 个车位来控制 100 辆车的流量,所以结果和我们预想的很相似,即大部分车都在等待状态。但是同时仍允许一些车驶入停车场,驶入停车场的车辆,就会 semaphore.acquire 占用一个车位,驶出停车场时,就会 semaphore.release 让出一个车位,让后面的车再次驶入。
Semaphore 信号量的模型
上面代码虽然比较简单,但是却能让我们了解到一个信号量模型的五脏六腑
。下面是一个信号量的模型:
来解释一下 Semaphore ,Semaphore 有一个初始容量,这个初始容量就是 Semaphore 所能够允许的信号量。在调用 Semaphore 中的 acquire 方法后,Semaphore 的容量 -1,相对的在调用 release 方法后,Semaphore 的容量 + 1,在这个过程中,计数器一直在监控 Semaphore 数量的变化,等到流量超过 Semaphore 的容量后,多余的流量就会放入等待队列中进行排队等待。等到 Semaphore 的容量允许后,方可重新进入。
Semaphore 所控制的流量其实就是一个个的线程,因为并发工具最主要的研究对象就是线程。
它的工作流程如下
这幅图应该很好理解吧,这里就不再过多解释啦。
Semaphore 深入理解
在了解 Semaphore 的基本使用和 Semaphore 的模型后,下面我们还是得从源码来和你聊一聊 Semaphore 的种种细节问题,因为我写文章最核心的东西就是想让我的读者 了解 xxx,看这一篇就够了,这是我写文章的追求,好了话不多说,源码走起来!
Semaphore 基本属性
Semaphore 中只有一个属性
private final Sync sync;
Sync 是 Semaphore 的同步实现,Semaphore 保证线程安全性的方式和 ReentrantLock 、CountDownLatch 类似,都是继承于 AQS 的实现。同样的,这个 Sync 也是继承于 AbstractQueuedSynchronizer
的一个变量,也就是说,聊 Semaphore 也绕不开 AQS,所以说 AQS 真的太重要了。
Semaphore 的公平性和非公平性
那么我们进入 Sync 内部看看它实现了哪些方法
abstract static class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 1192457210091910933L;
Sync(int permits) {
setState(permits);
}
final int getPermits() {
return getState();
}
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))
return true;
}
}
final void reducePermits(int reductions) {
for (;;) {
int current = getState();
int next = current - reductions;
if (next > current) // underflow
throw new Error("Permit count underflow");
if (compareAndSetState(current, next))
return;
}
}
final int drainPermits() {
for (;;) {
int current = getState();
if (current == 0 || compareAndSetState(current, 0))
return current;
}
}
}
首先是 Sync 的初始化,内部调用了 setState
并传递了 permits ,我们知道,AQS 中的 State 其实就是同步状态的值,而 Semaphore 的这个 permits 就是代表了许可的数量。
getPermits 其实就是调用了 getState 方法获取了一下线程同步状态值。后面的 nonfairTryAcquireShared 方法其实是在 Semaphore 中构造了 NonfairSync 中的 tryAcquireShared 调用的
这里需要提及一下什么是 NonfairSync
,除了 NonfairSync 是不是还有 FairSync 呢?查阅 JDK 源码发现确实有。
那么这里的 FairSync 和 NonfairSync 都代表了什么?为什么会有这两个类呢?
事实上,Semaphore 就像 ReentrantLock 一样,也存在“公平”和"不公平"两种,默认情况下 Semaphore 是一种不公平的信号量
Semaphore 的不公平意味着它不会保证线程获得许可的顺序,Semaphore 会在线程等待之前为调用 acquire 的线程分配一个许可,拥有这个许可的线程会自动将自己置于线程等待队列的头部。
当这个参数为 true 时,Semaphore 确保任何调用 acquire 的方法,都会按照先入先出的顺序来获取许可。
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
// 获取同步状态值
int available = getState();
// state 的值 - 当前线程需要获取的信号量(通常默认是 -1),只有
// remaining > 0 才表示可以获取。
int remaining = available - acquires;
// 先判断是否小于 0 ,如果小于 0 则表示无法获取,如果是正数
// 就需要使用 CAS 判断内存值和同步状态值是否一致,然后更新为同步状态值 - 1
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
从上面这幅源码对比图可以看到,NonfairSync 和 FairSync 最大的区别就在于 tryAcquireShared
方法的区别。
NonfairSync 版本中,是不会管当前等待队列中是否有排队许可的,它会直接判断信号许可量和 CAS 方法的可行性。
FairSync 版本中,它首先会判断是否有许可进行排队,如果有的话就直接获取失败。
这时候可能就会有读者问了,你上面说公平性和非公平性的区别一直针对的是 acquire 方法来说的,怎么现在他们两个主要的区别在于
tryAcquireShared
方法呢?
别急,让我们进入到 acquire
方法一探究竟
可以看到,在 acquire 方法中,会调用 tryAcquireShared 方法,根据其返回值判断是否调用 doAcquireSharedInterruptibly
方法,更多关于 doAcquireSharedInterruptibly 的使用分析,请参考读者的这篇文章
这里需要注意下,acquire 方法具有阻塞性,而 tryAcquire 方法不具有阻塞性。
这也就是说,调用 acquire 方法如果获取不到许可,那么 Semaphore 会阻塞,直到有可用的许可。而 tryAcquire 方法如果获取不到许可会直接返回 false。
这里还需要注意下 acquireUninterruptibly
方法,其他 acquire 的相关方法要么是非阻塞,要么是阻塞可中断,而 acquireUninterruptibly 方法不仅在没有许可的情况下执着的等待,而且也不会中断,使用这个方法时需要注意,这个方法很容易在出现大规模线程阻塞而导致 Java 进程出现假死的情况。
有获取许可相对应的就有释放许可,但是释放许可不会区分到底是公平释放还是非公平释放。不管方式如何都是释放一个许可给 Semaphore ,同样的 Semaphore 中的许可数量会增加。
在上图中调用 tryReleaseShared 判断是否能进行释放后,再会调用 AQS 中的 releasedShared
方法进行释放。
上面这个释放流程只是释放一个许可,除此之外,还可以释放多个许可
public void release(int permits) {
if (permits < 0) throw new IllegalArgumentException();
sync.releaseShared(permits);
}
后面这个 releaseShared 的释放流程和上面的释放流程一致。
其他 Semaphore 方法
除了上面基本的 acquire 和 release 相关方法外,我们也要了解一下 Semaphore 的其他方法。Semaphore 的其他方法比较少,只有下面这几个
drainPermits : 获取并退还所有立即可用的许可,其实相当于使用 CAS 方法把内存值置为 0
reducePermits:和 nonfairTryAcquireShared
方法类似,只不过 nonfairTryAcquireShared 是使用 CAS 使内存值 + 1,而 reducePermits 是使内存值 – 1 。
isFair:对 Semaphore 许可的争夺是采用公平还是非公平的方式,对应到内部的实现就是 FairSync 和 NonfairSync。
hasQueuedThreads:当前是否有线程由于要获取 Semaphore 许可而进入阻塞。
getQueuedThreads:返回一个包含了等待获取许可的线程集合。
getQueueLength:获取正在排队而进入阻塞状态的线程个数
看完你就明白的锁系列之锁的状态
看完你就会知道,线程如果锁住了某个资源,致使其他线程无法访问的这种锁被称为悲观锁,相反,线程不锁住资源的锁被称为乐观锁,而自旋锁是基于 CAS 机制实现的,CAS又是乐观锁的一种实现,那么对于锁来说,多个线程同步访问某个资源的流程细节是否一样呢?换句话说,在多线程同步访问某个资源时,锁的状态会如何变化呢?本篇文章来探讨一下。
**锁状态的分类
Java 语言专门针对 synchronized
关键字设置了四种状态,它们分别是:无锁、偏向锁、轻量级锁和重量级锁,但是在了解这些锁之前还需要先了解一下 Java 对象头和 Monitor。
Java 对象头
我们知道 synchronized 是悲观锁,在操作同步之前需要给资源加锁,这把锁就是对象头里面的,而Java 对象头又是什么呢?我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段)
和 Klass Pointer(类型指针)
。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
在32位虚拟机和64位虚拟机的 Mark Word 所占用的字节大小不一样,32位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32bits 的字节,而 64位虚拟机的 Mark Word 和 Klass Pointer 占用了64bits 的字节,下面我们以 32位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的
用中文翻译过来就是
- 无状态也就是
无锁
的时候,对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01 偏向锁
中划分更细,还是开辟25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01轻量级锁
中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00重量级锁
中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11GC标记
开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK
中的markOop.hpp类中的枚举窥出端倪
来解释一下
- age_bits 就是我们说的分代回收的标识,占用4字节
- lock_bits 是锁的标志位,占用2个字节
- biased_lock_bits 是是否偏向锁的标识,占用1个字节
- max_hash_bits 是针对无锁计算的hashcode 占用字节数量,如果是32位虚拟机,就是 32 – 4 – 2 -1 = 25 byte,如果是64 位虚拟机,64 – 4 – 2 – 1 = 57 byte,但是会有 25 字节未使用,所以64位的 hashcode 占用 31 byte
- hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取31,否则取真实的字节数
- cms_bits 我觉得应该是不是64位虚拟机就占用 0 byte,是64位就占用 1byte
- epoch_bits 就是 epoch 所占用的字节大小,2字节。
Synchronized锁
synchronized
用的锁是存在Java对象头里的。
JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应地,在执行 monitorexit 指令时会将锁计数器减1,当计数器被减到0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
Monitor
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁
。
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁
和轻量级锁
:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
所以锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。
锁的分类及其解释
无锁
无锁状态
,无锁即没有对资源进行锁定,所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源。
无锁的特点就是在循环内进行修改操作,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出,在此过程中没有出现冲突的发生,这很像我们在之前文章中介绍的 CAS 实现,CAS 的原理和应用就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
Hotspot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
可以从对象头的分配中看到,偏向锁要比无锁多了线程ID
和 epoch
,当一个线程访问同步代码块并获取锁时,会在对象头和栈帧的记录中存储线程的ID,等到下一次线程在进入和退出同步代码块时就不需要进行 CAS
操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的。
偏向锁的获取过程
-
访问 Mark Word 中偏向锁的标志是否设置成 1,锁的标志位是否是 01 — 确认为可偏向状态。
-
如果确认为可偏向状态,判断当前线程id 和 对象头中存储的线程 ID 是否一致,如果一致的话,则执行步骤5,如果不一致,进入步骤3
-
如果当前线程ID 与对象头中存储的线程ID 不一致的话,则通过 CAS 操作来竞争获取锁。如果竞争成功,则将 Mark Word 中的线程ID 修改为当前线程ID,然后执行步骤5,如果不一致,则执行步骤4
-
如果 CAS 获取偏向锁失败,则表示有竞争(CAS 获取偏向锁失败则表明至少有其他线程曾经获取过偏向锁,因为线程不会主动释放偏向锁)。当到达全局安全点(SafePoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头置为
无锁状态(标志位为01)
,然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁
的状态(标志位为00
),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。 -
执行同步代码
偏向锁的释放过程
偏向锁的释放过程可以参考上述的步骤4 ,偏向锁在遇到其他线程竞争锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为01
)或轻量级锁(标志位为00
)的状态。
关闭偏向锁
偏向锁在Java 6 和Java 7 里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
关于 epoch
真正理解 epoch 的概念比较复杂,这里简单理解,就是 epoch 的值可以作为一种检测偏向锁有效性的时间戳
轻量级锁
轻量级锁
是指当前锁是偏向锁的时候,被另外的线程所访问,那么偏向锁就会升级为轻量级锁
,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
加锁过程
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为 01 状态,是否为偏向锁为 0 ),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)
的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。
拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record里的 owner 指针指向对象的 Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为 00 ,表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为 10 ,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
重量级锁
重量级锁也就是通常说 synchronized 的对象锁,锁标识位为10,其中指针指向的是 monitor 对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
上图简单描述多线程获取锁的过程,当多个线程同时访问一段同步代码时,首先会进入 Entry Set当线程获取到对象的 monitor 后进入 The Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器count 加1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner变量恢复为 null,count自减1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor (锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
由此看来,monitor 对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象Object中的原因。(部分来源于网络)
ReentrantLock源码分析
回答一个问题
在开始本篇文章的内容讲述前,先来回答我一个问题,为什么 JDK 提供一个 synchronized
关键字之后还要提供一个 Lock 锁,这不是多此一举吗?难道 JDK 设计人员都是沙雕吗?
我听过一句话非常的经典,也是我认为是每个人都应该了解的一句话:你以为的并不是你以为的
。明白什么意思么?不明白的话,加我微信我告诉你。
初识 ReentrantLock
ReentrantLock 位于 java.util.concurrent.locks
包下,它实现了 Lock
接口和 Serializable
接口。
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
类,下面来看一下它们的继承结构,便于梳理。
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 是如何实现这两种锁的。
如上图所示,公平锁的加锁流程要比非公平锁的加锁流程简单,下面要聊一下具体的流程了,请小伙伴们备好板凳。
下面先看一张流程图,这张图是 acquire 方法的三条主要流程
首先是第一条路线,tryAcquire 方法,顾名思义尝试获取,也就是说可以成功获取锁,也可以获取锁失败。
使用 ctrl+左键
点进去是调用 AQS 的方法,但是 ReentrantLock 实现了 AQS 接口,所以调用的是 ReentrantLock 的 tryAcquire 方法;
首先会取得当前线程,然后去读取当前锁的同步状态,还记得锁的四种状态吗?分别是 无锁、偏向锁、轻量级锁和重量级锁
,如果你不是很明白的话,请参考博主这篇文章(不懂什么是锁?看看这篇你就明白了),如果判断同步状态是 0 的话,就证明是无锁的,参考下面这幅图( 1bit 表示的是是否偏向锁 )
如果是无锁(也就是没有加锁),说明是第一次上锁,首先会先判断一下队列中是否有比当前线程等待时间更长的线程(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
这里首先把当前线程和 Node 的节点类型进行封装,Node 节点的类型有两种,EXCLUSIVE
和 SHARED
,前者为独占模式,后者为共享模式,具体的区别我们会在 AQS 源码讨论,这里读者只需要知道即可。
首先会进行 tail 节点的判断,有没有尾节点,其实没有头节点也就相当于没有尾节点,如果有尾节点,就会原子性的将当前节点插入同步队列中,再执行 enq 入队操作,入队操作相当于原子性的把节点插入队列中。
如果当前同步队列尾节点为null,说明当前线程是第一个加入同步队列进行等待的线程。
**在看第三条路线 acquireQueued
主要会有两个分支判断,首先会进行无限循环中,循环中每次都会判断给定当前节点的先驱节点,如果没有先驱节点会直接抛出空指针异常,直到返回 true。
然后判断给定节点的先驱节点是不是头节点,并且当前节点能否获取独占式锁,如果是头节点并且成功获取独占锁后,队列头指针用指向当前节点,然后释放前驱节点。如果没有获取到独占锁,就会进入 shouldParkAfterFailedAcquire
和 parkAndCheckInterrupt
方法中,我们贴出这两个方法的源码
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
方法使得当前线程阻塞。
如果 !tryAcquire
和 acquireQueued
都为 true 的话,则打断当前线程。
那么它们的主要流程如下(注:只是加锁流程,并不是 lock 所有流程)
非公平锁的加锁(lock)流程详解
非公平锁的加锁步骤和公平锁的步骤只有两处不同,一处是非公平锁在加锁前会直接使用 CAS 操作设置同步状态,如果设置成功,就会把当前线程设置为偏向锁的线程;一处是 CAS 操作失败执行 tryAcquire
方法,读取线程同步状态,如果未加锁会使用 CAS 再次进行加锁,不会等待 hasQueuedPredecessors
方法的执行,达到只要线程释放锁就会加锁的目的。下面通过源码和流程图来详细理解
这是非公平锁和公平锁不同的两处地方,下面是非公平锁的加锁流程图
lockInterruptibly 以可中断的方式获取锁
以下是 JavaDoc 官方解释:
lockInterruptibly 的中文意思为如果没有被打断,则获取锁。如果没有其他线程持有该锁,则获取该锁并立即返回,将锁保持计数设置为1。如果当前线程已经持有锁,那么此方法会立刻返回并且持有锁的数量会 + 1。如果锁是由另一个线程持有的,则出于线程调度目的,当前线程将被禁用,并处于休眠状态,直到发生以下两种情况之一
- 锁被当前线程持有
- 一些其他线程打断了当前线程
如果当前线程获取了锁,则锁保持计数将设置为1。
如果当前线程发生了如下情况:
- 在进入此方法时设置了其中断状态
- 当获取锁的时候发生了中断(Thread.interrupt)
那么当前线程就会抛出InterruptedException
并且当前线程的中断状态会清除。
**下面看一下它的源码是怎么写的
首先会调用 acquireInterruptibly
这个方法,判断当前线程是否被中断,如果中断抛出异常,没有中断则判断公平锁/非公平锁
是否已经获取锁,如果没有获取锁(tryAcquire 返回 false)则调用 doAcquireInterruptibly
方法,这个方法和 acquireQueued 方法没什么区别,就是线程在等待状态的过程中,如果线程被中断,线程会抛出异常。
下面是它的流程图
tryLock 尝试加锁
仅仅当其他线程没有获取这把锁的时候获取这把锁,tryLock 的源代码和非公平锁的加锁流程基本一致,它的源代码如下
tryLock 超时获取锁
ReentrantLock
除了能以中断的方式去获取锁,还可以以超时等待的方式去获取锁,所谓超时等待就是线程如果在超时时间内没有获取到锁,那么就会返回false
,而不是一直死循环获取。可以使用 tryLock 和 tryLock(timeout, unit)) 结合起来实现公平锁,像这样
if (lock.tryLock() || lock.tryLock(timeout, unit)) {...}
如果超过了指定时间,则返回值为 false。如果时间小于或者等于零,则该方法根本不会等待。
它的源码如下
首先需要了解一下 TimeUnit
工具类,TimeUnit 表示给定粒度单位的持续时间,并且提供了一些用于时分秒跨单位转换的方法,通过使用这些方法进行定时和延迟操作。
toNanos
用于把 long 型表示的时间转换成为纳秒,然后判断线程是否被打断,如果没有打断,则以公平锁/非公平锁
的方式获取锁,如果能够获取返回true,获取失败则调用doAcquireNanos
方法使用超时等待的方式获取锁。在超时等待获取锁的过程中,如果等待时间大于应等待时间,或者应等待时间设置不合理的话,返回 false。
这里面以超时的方式获取锁也可以画一张流程图如下
unlock 解锁流程
unlock
和 lock
是一对情侣,它们分不开彼此,在调用 lock 后必须通过 unlock 进行解锁。如果当前线程持有锁,在调用 unlock 后,count 计数将减少。如果保持计数为0就会进行解锁。如果当前线程没有持有锁,在调用 unlock 会抛出 IllegalMonitorStateException
异常。下面是它的源码
在有了上面阅读源码的经历后,相信你会很快明白这段代码的意思,锁的释放不会区分公平锁还是非公平锁,主要的判断逻辑就是 tryRelease
方法,getState
方法会取得同步锁的重入次数,如果是获取了偏向锁,那么可能会多次获取,state 的值会大于 1,这时候 c 的值 > 0 ,返回 false,解锁失败。如果 state = 1,那么 c = 0,再判断当前线程是否是独占锁的线程,释放独占锁,返回 true,当 head 指向的头结点不为 null,并且该节点的状态值不为0的话才会执行 unparkSuccessor 方法,再进行锁的获取。
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;
}
回答上面那个问题
那么你看完源码分析后,你能总结出 synchronized
和 lock
锁的实现 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内,并加入到等待队列中去。等待被其前一个线程节点唤醒。
174.JAVA 异常分类及处理的概念
如果某个方法不能按照正常的途径完成任务,就可以通过另一种路径退出方法。在这种情况下会抛出一个封装了错误信息的对象。此时,这个方法会立刻退出同时不返回任何值。另外,调用这个方法的其他代码也无法继续执行,异常处理机制会将代码执行交给异常处理器。
175.异常分类
Throwable 是 Java 语言中所有错误或异常的超类。下一层分为 Error 和 Exception
**Error
- Error 类是指 java 运行时系统的内部错误和资源耗尽错误。应用程序不会抛出该类对象。如果出现了这样的错误,除了告知用户,剩下的就是尽力使程序安全的终止。
Exception(RuntimeException、CheckedException)
- Exception 又 有 两 个 分 支 , 一 个 是 运 行 时 异 常 RuntimeException , 一 个 是CheckedException。
**RuntimeException
如 : NullPointerException 、 ClassCastException ; 一 个 是 检 查 异 常CheckedException,如 I/O 错误导致的 IOException、SQLException。 RuntimeException 是那些可能在 Java 虚拟机正常运行期间抛出的异常的超类。 如果出现 RuntimeException,那么一定是程序员的错误.
检查异常 CheckedException: 一般是外部错误,这种异常都发生在编译阶段,Java 编译器会强制程序去捕获此类异常,即会出现要求你把这段可能出现异常的程序进行 try catch,该类异常一般包括几个方面:
- 试图在文件尾部读取数据
- 试图打开一个错误格式的 URL
- 试图根据给定的字符串查找 class 对象,而这个字符串表示的类并不存在
176.异常的处理方式
遇到问题不进行具体处理,而是继续抛给调用者(throw,throws)
抛出异常有三种形式,一是 throw,一个 throws,还有一种系统自动抛异常。
public static void main(String[] args) {
String s = "abc";
if(s.equals("abc")) {
throw new NumberFormatException();
} else {
System.out.println(s);
}
}
int div(int a,int b) throws Exception{
return a/b;}
**try catch 捕获异常针对性处理方式
177.Throw 和 throws 的区别
**位置不同
-
throws 用在函数上,后面跟的是异常类,可以跟多个;而 throw 用在函数内,后面跟的是异常对象。 功能不同:
-
throws 用来声明异常,让调用者只知道该功能可能出现的问题,可以给出预先的处理方式;throw 抛出具体的问题对象,执行到 throw,功能就已经结束了,跳转到调用者,并将具体的问题对象抛给调用者。也就是说 throw 语句独立存在时,下面不要定义其他语句,因为执行不到。
-
throws 表示出现异常的一种可能性,并不一定会发生这些异常;throw 则是抛出了异常,执行 throw 则一定抛出了某种异常对象。
-
两者都是消极处理异常的方式,只是抛出或者可能抛出异常,但是不会由函数去处理异常,真正的处理异常由函数的上层调用处理。
179.反射机制概念 (运行状态中知道类所有的属性和方法)
在 Java 中的反射机制是指在运行状态中,对于任意一个类都能够知道这个类所有的属性和方法;并且对于任意一个对象,都能够调用它的任意一个方法;这种动态获取信息以及动态调用对象方法的功能成为 Java 语言的反射机制。
180.反射的应用场合
**编译时类型和运行时类型 在 Java 程序中许多对象在运行是都会出现两种类型:编译时类型和运行时类型。 编译时的类型由声明对象时实用的类型来决定,运行时的类型由实际赋值给对象的类型决定 。如:
Person p=new Student();
其中编译时类型为 Person,运行时类型为 Student
。
的编译时类型无法获取具体方法 程序在运行时还可能接收到外部传入的对象,该对象的编译时类型为 Object,但是程序有需要调用该对象的运行时类型的方法。为了解决这些问题,程序需要在运行时发现对象和类的真实信息**。然而,如果编译时根本无法预知该对象和类属于哪些类,程序只能依靠运行时信息来发现该对象和类的真实信息,此时就必须使用到反射了。
181.Java 反射 API
反射 API 用来生成 JVM 中的类、接口或则对象的信息。
- Class 类:反射的核心类,可以获取类的属性,方法等信息。
- Field 类:Java.lang.reflec 包中的类,表示类的成员变量,可以用来获取和设置类之中的属性值。
- Method 类: Java.lang.reflec 包中的类,表示类的方法,它可以用来获取类中的方法信息或者执行方法。
- Constructor 类: Java.lang.reflec 包中的类,表示类的构造方法。
182.反射使用步骤(获取 Class 对象、调用对象方法)
- 获取想要操作的类的 Class 对象,他是反射的核心,通过 Class 对象我们可以任意调用类的方法。
- 调用 Class 类中的方法,既就是反射的使用阶段。
- 使用反射 API 来操作这些信息。
183.获取 Class 对象的 3 种方法
**调用某个对象的 getClass()方法
Person p=new Person();
Class clazz=p.getClass();
**调用某个类的 class 属性来获取该类对应的 Class 对象
Class clazz=Person.class;
**使用 Class 类中的 forName()静态方法(最安全/性能最好)
Class clazz=Class.forName("类的全路径"); (最常用)
当我们获得了想要操作的类的 Class 对象后,可以通过 Class 类中的方法获取并查看该类中的方法和属性。
//获取 Person 类的 Class 对象
Class clazz=Class.forName("reflection.Person");
//获取 Person 类的所有方法信息
Method[] method=clazz.getDeclaredMethods();
for(Method m:method){
System.out.println(m.toString());
}
//获取 Person 类的所有成员属性信息
Field[] field=clazz.getDeclaredFields();
for(Field f:field){
System.out.println(f.toString());
}
//获取 Person 类的所有构造方法信息
Constructor[] constructor=clazz.getDeclaredConstructors();
for(Constructor c:constructor){
System.out.println(c.toString());
}
184.创建对象的两种方法
**Class 对象的 newInstance()
-
使用 Class 对象的 newInstance()方法来创建该 Class 对象对应类的实例,但是这种方法要求该 Class 对象对应的类有默认的空构造器。 **调用 Constructor 对象的 newInstance()
-
先使用 Class 对象获取指定的 Constructor 对象,再调用 Constructor 对象的 newInstance()方法来创建 Class 对象对应类的实例,通过这种方法可以选定构造方法创建实例。
//获取 Person 类的 Class 对象
Class clazz=Class.forName("reflection.Person");
//使用.newInstane 方法创建对象
Person p=(Person) clazz.newInstance();
//获取构造方法并创建对象
Constructor c=clazz.getDeclaredConstructor(String.class,String.class,int.class);
//创建对象并设置属性
Person p1=(Person) c.newInstance("李四","男",20);
185.JAVA 注解概念
Annotation(注解)是 Java 提供的一种对元程序中元素关联信息和元数据(metadata)的途径和方法。Annatation(注解)是一个接口,程序可以通过反射来获取指定程序中元素的 Annotation对象,然后通过该 Annotation 对象来获取注解中的元数据信息。
186.4 种标准元注解
元注解的作用是负责注解其他注解。 Java5.0 定义了 4 个标准的 meta-annotation 类型,它们被用来提供对其它 annotation 类型作说明。 @Target 修饰的对象范围 @Target说明了Annotation所修饰的对象范围: Annotation可被用于 packages、types(类、接口、枚举、Annotation 类型)、类型成员(方法、构造方法、成员变量、枚举值)、方法参数和本地变量(如循环变量、catch 参数)。在 Annotation 类型的声明中使用了 target 可更加明晰其修饰的目标 @Retention 定义 被保留的时间长短 Retention 定义了该 Annotation 被保留的时间长短:表示需要在什么级别保存注解信息,用于描述注解的生命周期(即:被描述的注解在什么范围内有效),取值(RetentionPoicy)由:
- SOURCE:在源文件中有效(即源文件保留)
- CLASS:在 class 文件中有效(即 class 保留)
- RUNTIME:在运行时有效(即运行时保留) @Documented 描述-javadoc @ Documented 用于描述其它类型的 annotation 应该被作为被标注的程序成员的公共 API,因此可以被例如 javadoc 此类的工具文档化。 @Inherited 阐述了某个被标注的类型是被继承的 @Inherited 元注解是一个标记注解,@Inherited 阐述了某个被标注的类型是被继承的。如果一个使用了@Inherited 修饰的 annotation 类型被用于一个 class,则这个 annotation 将被用于该class 的子类。
187.注解处理器
如果没有用来读取注解的方法和工作,那么注解也就不会比注释更有用处了。使用注解的过程中,很重要的一部分就是创建于使用注解处理器。Java SE5 扩展了反射机制的 API,以帮助程序员快速的构造自定义注解处理器。下面实现一个注解处理器。
/1:*** 定义注解*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FruitProvider {
/**供应商编号*/
public int id() default -1;
/*** 供应商名称*/
public String name() default "";
/** * 供应商地址*/
public String address() default "";
}
//2:注解使用
public class Apple {
@FruitProvider(id = 1, name = "陕西红富士集团", address = "陕西省西安市延安路")
private String appleProvider;
public void setAppleProvider(String appleProvider) {
this.appleProvider = appleProvider;
}
public String getAppleProvider() {
return appleProvider;
}
}
/3:*********** 注解处理器 ***************/
public class FruitInfoUtil {
public static void getFruitInfo(Class<?> clazz) {
String strFruitProvicer = "供应商信息:";
Field[] fields = clazz.getDeclaredFields();//通过反射获取处理注解
for (Field field : fields) {
if (field.isAnnotationPresent(FruitProvider.class)) {
FruitProvider fruitProvider = (FruitProvider) field.getAnnotation(FruitProvider.class);
//注解信息的处理地方
strFruitProvicer = " 供应商编号:" + fruitProvider.id() + " 供应商名称:"
+ fruitProvider.name() + " 供应商地址:"+ fruitProvider.address();
System.out.println(strFruitProvicer);
}
}
}
}
public class FruitRun {
public static void main(String[] args) {
FruitInfoUtil.getFruitInfo(Apple.class);
/***********输出结果***************/
// 供应商编号:1 供应商名称:陕西红富士集团 供应商地址:陕西省西安市延
}
}
188.JAVA 内部类
Java 类中不仅可以定义变量和方法,还可以定义类,这样定义在类内部的类就被称为内部类。根据定义的方式不同,内部类分为静态内部类,成员内部类,局部内部类,匿名内部类四种。
189.静态内部类
定义在类内部的静态类,就是静态内部类。
public class Out {
private static int a;
private int b;
public static class Inner {
public void print() {
System.out.println(a);
}
}
}
- 静态内部类可以访问外部类所有的静态变量和方法,即使是 private 的也一样。
- 静态内部类和一般类一致,可以定义静态变量、方法,构造方法等。
- 其它类使用静态内部类需要使用“外部类.静态内部类”方式,如下所示:Out.Inner inner = new Out.Inner();inner.print();
- Java集合类HashMap内部就有一个静态内部类Entry。Entry是HashMap存放元素的抽象,HashMap 内部维护 Entry 数组用了存放元素,但是 Entry 对使用者是透明的。像这种和外部类关系密切的,且不依赖外部类实例的,都可以使用静态内部类。
190.成员内部类
定义在类内部的非静态类,就是成员内部类。成员内部类不能定义静态方法和变量(final 修饰的除外)。这是因为成员内部类是非静态的,**类初始化的时候先初始化静态成员,如果允许成员内部类定义静态变量,那么成员内部类的静态变量初始化顺序是有歧义的。
public class Out {
private static int a;
private int b;
public class Inner {
public void print() {
System.out.println(a);
System.out.println(b);
}
}
}
191.局部内部类(定义在方法中的类)
定义在方法中的类,就是局部类。如果一个类只在某个方法中使用,则可以考虑使用局部类。
public class Out {
private static int a;
private int b;
public void test(final int c) {
final int d = 1;
class Inner {
public void print() {
System.out.println(c);
}
}
}
}
192.匿名内部类(要继承一个父类或者实现一个接口、直接使用 new 来生成一个对象的引用)
匿名内部类我们必须要继承一个父类或者实现一个接口,当然也仅能只继承一个父类或者实现一个接口。同时它也是没有 class 关键字,这是因为匿名内部类是直接使用 new 来生成一个对象的引用。
public abstract class Bird {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public abstract int fly();
}
public class Test {
public void test(Bird bird){
System.out.println(bird.getName() + "能够飞 " + bird.fly() + "米");
}
public static void main(String[] args) {
Test test = new Test();
test.test(new Bird() {
public int fly() {
return 10000;
}
public String getName() {
return "大雁";
}
});
}
}
193.JAVA 泛型
泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。比如我们要写一个排序方法,能够对整型数组、字符串数组甚至其他任何类型的数组进行排序,我们就可以使用 Java 泛型。
194.泛型方法( )
你可以写一个泛型方法,该方法在调用时可以接收不同类型的参数。根据传递给泛型方法的参数类型,编译器适当地处理每一个方法调用。
// 泛型方法 printArray
public static < E > void printArray( E[] inputArray )
{
for ( E element : inputArray ){
System.out.printf( "%s ", element );
}
}
- <? extends T>表示该通配符所代表的类型是 T 类型的子类。
- <? super T>表示该通配符所代表的类型是 T 类型的父类。
195.泛型类
泛型类的声明和非泛型类的声明类似,除了在类名后面添加了类型参数声明部分。和泛型方法一样,泛型类的类型参数声明部分也包含一个或多个类型参数,参数间用逗号隔开。一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。因为他们接受一个或多个参数,这些类被称为参数化的类或参数化的类型。
public class Box<T> {
private T t;
public void add(T t) {
this.t = t;
}
public T get() {
return t;
}
196.类型通配符?
类型通配符一般是使用 ? 代 替 具 体 的 类 型 参 数 。 例 如 List<?> 在逻辑上是List
197.类型擦除
Java 中的泛型基本上都是在编译器这个层次来实现的。在生成的 Java 字节代码中是不包含泛型中的类型信息的。使用泛型的时候加上的类型参数,会被编译器在编译的时候去掉。这个过程就称为类型擦除。如在代码中定义的 List
198.保存(持久化)对象及其状态到内存或者磁盘
Java 平台允许我们在内存中创建可复用的 Java 对象,但一般情况下,只有当 JVM 处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比 JVM 的生命周期更长。但在现实应用中,就可能要求在JVM停止运行之后能够保存(持久化)指定的对象,并在将来重新读取被保存的对象。Java 对象序列化就能够帮助我们实现该功能。
199.序列化对象以字节数组保持-静态成员不保存
使用 Java 对象序列化,在保存对象时,会把其状态保存为一组字节,在未来,再将这些字节组装成对象。必须注意地是,对象序列化保存的是对象的”状态”,即它的成员变量。由此可知,对象序列化不会关注类中的静态变量。
200.序列化用户远程对象传输
除了在持久化对象时会用到对象序列化之外,当使用 RMI(远程方法调用),或在网络中传递对象时,都会用到对象序列化。Java序列化API为处理对象序列化提供了一个标准机制,该API简单易用。
201.Serializable 实现序列化
在 Java 中,只要一个类实现了 java.io.Serializable 接口,那么它就可以被序列化。
202.ObjectOutputStream 和 ObjectInputStream 对对象进行序列化及反序列化
通过 ObjectOutputStream 和 ObjectInputStream 对对象进行序列化及反序列化。
203.writeObject 和 readObject 自定义序列化策略
在类中增加 writeObject 和 readObject 方法可以实现自定义序列化策略。
204.序列化 ID
虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化 ID 是否一致(就是 private static final long serialVersionUID)
205.序列化静态变量
序列化并不保存静态变量
206.序列化子父类说明
要想将父类对象也序列化,就需要让父类也实现 Serializable 接口。
207.Transient 关键字阻止该变量被序列化到文件中
- 在变量声明前加上 Transient 关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。
- 服务器端给客户端发送序列化对象数据,对象中有一些数据是敏感的,比如密码字符串等,希望对该密码字段在序列化时,进行加密,而客户端如果拥有解密的密钥,只有在客户端进行反序列化时,才可以对密码进行读取,这样可以一定程度保证序列化对象的数据安全。
208.JAVA 复制
将一个对象的引用复制给另外一个对象,一共有三种方式。第一种方式是直接赋值,第二种方式是浅拷贝,第三种是深拷贝。所以大家知道了哈,这三种概念实际上都是为了拷贝对象。
209.直接赋值复制
直接赋值。在 Java 中,A a1 = a2,我们需要理解的是这实际上复制的是引用,也就是说 a1 和 a2 指向的是同一个对象。因此,当 a1 变化的时候,a2 里面的成员变量也会跟着变化。
210.浅复制(复制引用但不复制引用的对象)
创建一个新对象,然后将当前对象的非静态字段复制到该新对象,如果字段是值类型的,那么对该字段执行复制;如果该字段是引用类型的话,则复制引用但不复制引用的对象。因此,原始对象及其副本引用同一个对象。
class Resume implements Cloneable{
public Object clone() {
try {
return (Resume)super.clone();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
211.深复制(复制对象和其应用对象)
深拷贝不仅复制对象本身,而且复制对象包含的引用指向的所有对象。
class Student implements Cloneable {
String name;
int age;
Professor p;
Student(String name, int age, Professor p) {
this.name = name;
this.age = age;
this.p = p;
}
public Object clone() {
Student o = null;
try {
o = (Student) super.clone();
} catch (CloneNotSupportedException e) {
System.out.println(e.toString());
}
o.p = (Professor) p.clone();
return o;
}
}
212.序列化(深 clone 一中实现)
在 Java 语言里深复制一个对象,常常可以先使对象实现Serializable 接口,然后把对象(实际上只是对象的一个拷贝)写到一个流里,再从流里读出来,便可以重建对象。
345.Slf4j
slf4j 的全称是 Simple Loging Facade For Java,即它仅仅是一个为 Java 程序提供日志输出的统一接口,并不是一个具体的日志实现方案,就比如 JDBC 一样,只是一种规则而已。所以单独的 slf4j 是不能工作的,必须搭配其他具体的日志实现方案,比如 apache 的 org.apache.log4j.Logger,jdk 自带的 java.util.logging.Logger 等。
346.Log4j
Log4j 是 Apache 的一个开源项目,通过使用 Log4j,我们可以控制日志信息输送的目的地是控制台、文件、GUI 组件,甚至是套接口服务器、NT 的事件记录器、UNIX Syslog 守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。
Log4j 由三个重要的组成构成:日志记录器(Loggers),输出端(Appenders)和日志格式化器(Layout)。
- Logger:控制要启用或禁用哪些日志记录语句,并对日志信息进行级别限制
- Appenders : 指定了日志将打印到控制台还是文件中
- Layout : 控制日志信息的显示格式
Log4j 中将要输出的 Log 信息定义了 5 种级别,依次为 DEBUG、INFO、WARN、ERROR 和 FATAL,当输出时,只有级别高过配置中规定的 级别的信息才能真正的输出,这样就很方便的来配置不同情况下要输出的内容,而不需要更改代码。
347.LogBack
简单地说,Logback 是一个 Java 领域的日志框架。它被认为是 Log4J 的继承人。
Logback 主要由三个模块组成:logback-core,logback-classic。logback-access
logback-core 是其它模块的基础设施,其它模块基于它构建,显然,logback-core 提供了一些关键的通用机制。
logback-classic 的地位和作用等同于 Log4J,它也被认为是 Log4J 的一个改进版,并且它实现了简单日志门面 SLF4J;
logback-access 主要作为一个与 Servlet 容器交互的模块,比如说 tomcat 或者 jetty,提供一些与HTTP 访问相关的功能。
348.Logback 优点
- 同样的代码路径,Logback 执行更快
- 更充分的测试
- 原生实现了 SLF4J API(Log4J 还需要有一个中间转换层)
- 内容更丰富的文档
- 支持 XML 或者 Groovy 方式配置
- 配置文件自动热加载
- 从 IO 错误中优雅恢复
- 自动删除日志归档
- 自动压缩日志成为归档文件
- 支持 Prudent 模式,使多个 JVM 进程能记录同一个日志文件
- 支持配置文件中加入条件判断来适应不同的环境
- 更强大的过滤器
- 支持 SiftingAppender(可筛选 Appender)
- 异常栈信息带有包信息
349.ELK
ELK 是软件集合 Elasticsearch、Logstash、Kibana 的简称,由这三个软件及其相关的组件可以打造大规模日志实时处理系统。
- Elasticsearch 是一个基于 Lucene 的、支持全文索引的分布式存储和索引引擎,主要负责将日志索引并存储起来,方便业务方检索查询。
- Logstash 是一个日志收集、过滤、转发的中间件,主要负责将各条业务线的各类日志统一收集、过滤后,转发给 Elasticsearch 进行下一步处理。
- Kibana 是一个可视化工具,主要负责查询 Elasticsearch 的数据并以可视化的方式展现给业务方,比如各类饼图、直方图、区域图等。
623.面向对象的特征有哪些方面?
面向对象的特征主要有以下几个方面:
- 抽象:抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面。抽象只关注对象有哪些属性和行为,并不关注这些行为的细节是什么。
- 继承:继承是从已有类得到继承信息创建新类的过程。提供继承信息的类被称为父类(超类、基类);得到继承信息的类被称为子类(派生类)。继承让变化中的软件系统有了一定的延续性,同时继承也是封装程序中可变因素的重要手段(如果不能理解请阅读阎宏博士的《Java 与模式》或《设计模式精解》中关于桥梁模式的部分)。
- 封装:通常认为封装是把数据和操作数据的方法绑定起来,对数据的访问只能通过已定义的接口。面向对象的本质就是将现实世界描绘成一系列完全自治、封闭的对象。我们在类中编写的方法就是对实现细节的一种封装;我们编写一个类就是对数据和数据操作的封装。可以说,封装就是隐藏一切可隐藏的东西,只向外界提供最简单的编程接口(可以想想普通洗衣机和全自动洗衣机的差别,明显全自动洗衣机封装更好因此操作起来更简单;我们现在使用的智能手机也是封装得足够好的,因为几个按键就搞定了所有的事情)。
- 多态性:多态性是指允许不同子类型的对象对同一消息作出不同的响应。简单的说就是用同样的对象引用调用同样的方法但是做了不同的事情。多态性分为编译时的多态性和运行时的多态性。如果将对象的方法视为对象向外界提供的服务,那么运行时的多态性可以解释为:当 A 系统访问 B 系统提供的服务时,B系统有多种提供服务的方式,但一切对 A 系统来说都是透明的(就像电动剃须刀是 A 系统,它的供电系统是 B 系统,B 系统可以使用电池供电或者用交流电,甚至还有可能是太阳能,A 系统只会通过 B 类对象调用供电的方法,但并不知道供电系统的底层实现是什么,究竟通过何种方式获得了动力)。方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重写(override)实现的是运行时的多态性(也称为后绑定)。运行时的多态是面向对象最精髓的东西,要实现多态需要做两件事:1. 方法重写(子类继承父类并重写父类中已有的或抽象的方法);2. 对象造型(用父类型引用引用子类型对象,这样同样的引用调用同样的方法就会根据子类对象的不同而表现出不同的行为)。
624.访问修饰符 public,private,protected,以及不写(默认)时的区别?
区别如下:
作用域 | 当前类 | 同包 | 子类 | 其他 |
---|---|---|---|---|
public | √ | √ | √ | √ |
protected | √ | √ | √ | × |
default | √ | √ | × | × |
private | √ | × | × | × |
类的成员不写访问修饰时默认为 default。默认对于同一个包中的其他类相当于公开(public),对于不是同一个包中的其他类相当于私有(private)。受保护(protected)对子类相当于公开,对不是同一包中的没有父子关系的类相当于私有。
625.String 是最基本的数据类型吗?
不是。Java 中的基本数据类型只有 8 个: **byte、short、int、long、float、double、char、boolean; 除了基本类型(primitive type)和枚举类型(enumeration type),剩下的都是引用类型(reference type)
626.、float f=3.4;是否正确
不正确。3.4 是双精度数,将双精度型(double)赋值给浮点型(float)属于下转型(down-casting,也称为窄化)会造成精度损失,因此需要强制类型转换 float f =(float)3.4; 或者写成 float f =3.4F;。
627.short s1 = 1; s1 = s1 + 1;有错吗?short s1 = 1; s1 += 1;有错吗?
对于 short s1 = 1; s1 = s1 + 1;由于 1 是 int 类型,因此 s1+1 运算结果也是 int 型,需要强制转换类型才能赋值给 short 型。而 short s1 = 1; s1+= 1;可以正确编译,因为 s1+= 1;相当于 s1 = (short)(s1 + 1);其中有隐含的强制类型转换。
628.Java 有没有 goto?
goto 是 Java 中的保留字,在目前版本的 Java 中没有使用。(根据 James Gosling(Java 之父)编写的《The Java Programming Language》一书的附录中给出了一个 Java 关键字列表,其中有 goto 和 const,但是这两个是目前无法使用的关键字,因此有些地方将其称之为保留字,其实保留字这个词应该有更广泛的意义,因为熟悉 C 语言的程序员都知道,在系统类库中使用过的有特殊意义的单词或单词的组合都被视为保留字)
629.int 和 Integer 有什么区别?
Java 是一个近乎纯洁的面向对象编程语言,但是为了编程的方便还是引入不是对象的基本数据类型,但是为了能够将这些基本数据类型当成对象操作,Java 为每一个基本数据类型都引入了对应的包装类型(wrapper class),int的包装类就是 Integer,从 JDK 1.5 开始引入了自动装箱/拆箱机制,使得二者可以相互转换。
Java 为每个原始类型提供了包装类型:
原始类型: boolean,char,byte,short,int,long,float,double 包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double
630.&和&&的区别?
&运算符有两种用法:(1)按位与;(2)逻辑与。 &&运算符是短路与运算。逻辑与跟短路与的差别是非常巨大的,虽然二者都要求运算符左右两端的布尔值都是 true 整个表达式的值才是 true。&&之所以称为短路运算是因为,如果&&左边的表达式的值是 false,右边的表达式会被直接短路掉,不会进行运算。
很多时候我们可能都需要用&&而不是&,例如在验证用户登录时判定用户名不是 null 而且不是空字符串,应当写为:username != null &&!username.equals(“”),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果不成立,根本不能进行字符串的 equals 比较,否则会产生 NullPointerException异常。
注意:逻辑或运算符(|)和短路或运算符(||)的差别也是如此。
补充:如果你熟悉 JavaScript,那你可能更能感受到短路运算的强大,想成为 JavaScript 的高手就先从玩转短路运算开始吧。
631.解释内存中的栈(stack)、堆(heap)和静态存储区的用法。
通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用内存中的栈空间;而通过 new 关键字和构造器创建的对象放在堆空间;程序中的字面量(literal)如直接书写的 100、“hello”和常量都是放在静态存储区中。栈空间操作最快但是也很小,通常大量的对象都是放在堆空间,整个内存包括硬盘上的虚拟内存都可以被当成堆空间来使用。
String str = new String(“hello”);
上面的语句中 str 放在栈上,用 new 创建出来的字符串对象放在堆上,而“hello”这个字面量放在静态存储区。
补充:较新版本的 Java 中使用了一项叫“逃逸分析“的技术,可以将一些局部对象放在栈上以提升对象的操作性能。
632.Math.round(11.5) 等于多少? Math.round(-11.5)等于多少?
Math.round(11.5)的返回值是 12,Math.round(-11.5)的返回值是-11。四舍五入的原理是在参数上加 0.5 然后进行下取整。
633.swtich 是否能作用在 byte 上,是否能作用在 long 上,是否能作用在 S tring 上?
早期的 JDK 中,switch(expr)中,expr 可以是 byte、short、char、int。从 1.5 版开始,Java 中引入了枚举类型(enum),expr 也可以是枚举,从 JDK 1.7 版开始,还可以是字符串(String)。长整型(long)是不可以的。
634.用最有效率的方法计算 2 乘以 8?
2 << 3(左移 3 位相当于乘以 2 的 3 次方,右移 3 位相当于除以 2 的 3次方)。
635.数组有没有 length()方法?String 有没有 length()方法?
数组没有 length()方法,有 length 的属性。String 有 length()方法。JavaScript 中,获得字符串的长度是通过 length 属性得到的,这一点容易和 Java混淆
636.在 Java 中,如何跳出当前的多重嵌套循环
在最外层循环前加一个标记如 A,然后用 break A;可以跳出多重循环。(Java 中支持带标签的 break 和 continue 语句,作用有点类似于 C 和 C++中的 goto 语句,但是就像要避免使用 goto 一样,应该避免使用带标签的 break 和 continue,因为它不会让你的程序变得更优雅,很多时候甚至有相反的作用,所以这种语法其实不知道更好)
637.构造器(constructor)是否可被重写(override)?
构造器不能被继承,因此不能被重写,但可以被重载。
638.两个对象值相同(x.equals(y) == true),但却可有不同的 hash code, 这句话对不对?
不对,如果两个对象 x 和 y 满足 x.equals(y) == true,它们的哈希码(hash code)应当相同。Java 对于 eqauls 方法和 hashCode 方法是这样规定的:
- 如果两个对象相同(equals 方法返回 true),那么它们的 hashCode 值一定要相同;
- 如果两个对象的 hashCode 相同,它们并不一定相同。当然,你未必要按照要求去做,但是如果你违背了上述原则就会发现在使用容器时,相同的对象可以出现在 Set 集合中,同时增加新元素的效率会大大下降(对于使用哈希存储的系统,如果哈希码频繁的冲突将会造成存取性能急剧下降)。
639.是否可以继承 String 类
String 类是 final 类,不可以被继承。
补充:继承 String 本身就是一个错误的行为,对 String 类型最好的重用方式是关联(HAS-A)而不是继承(IS-A)
640.当一个对象被当作参数传递到一个方法后,此方法可改变这个对象的属性, 并可返回变化后的结果,那么这里到底是值传递还是引用传递?
是值传递。Java 编程语言只有值传递参数。当一个对象实例作为一个参数被传递到方法中时,参数的值就是对该对象的引用。对象的属性可以在被调用过程中被改变,但对象的引用是永远不会改变的。C++和 C#中可以通过传引用或传输出参数来改变传入的参数的值。
补充:Java 中没有传引用实在是非常的不方便,这一点在 Java 8 中仍然没有得到改进,正是如此在 Java 编写的代码中才会出现大量的 Wrapper 类(将需要通过方法调用修改的引用置于一个 Wrapper 类中,再将 Wrapper 对象传入方法),这样的做法只会让代码变得臃肿,尤其是让从 C 和 C++转型为 Java 程序员的开发者无法容忍。
641.String 和 StringBuilder、StringBuffer 的区别?
Java 平台提供了两种类型的字符串:String 和 StringBuffer / StringBuilder,它们可以储存和操作字符串。其中 String 是只读字符串,也就意味着 String 引用的字符串内容是不能被改变的。而 StringBuffer 和 StringBuilder 类表示的字符串对象可以直接进行修改。StringBuilder 是 JDK 1.5 中引入的,它和StringBuffer 的方法完全相同,区别在于它是在单线程环境下使用的,因为它的所有方面都没有被 synchronized 修饰,因此它的效率也比 StringBuffer 略高。
补充 1:有一个面试题问:有没有哪种情况用+做字符串连接比调用 StringBuffer / StringBuilder 对象的 append 方法性能更好?如果连接后得到的字符串在静态存储区中是早已存在的,那么用+做字符串连接是优于 StringBuffer / StringBuilder 的 append 方法的。
642.重载(Overload)和重写(Override)的区别。重载的方法能否根据返 回类型进行区分?
方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。重载发生在一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同或者二者都不同)则视为重载;
重写发生在子类与父类之间,重写要求子类被重写方法与父类被重写方法有相同的返回类型,比父类被重写方法更好访问,不能比父类被重写方法声明更多的异常(里氏代换原则)。重载对返回类型没有特殊的要求
643.描述一下 JVM 加载 class 文件的原理机制?
JVM 中类的装载是由类加载器(ClassLoader) 和它的子类来实现的,Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。
补充:
- 由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件,然后产生与所加载类对应的 Class 对象。加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后JVM 对类进行初始化,包括:1 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;2 如果类中存在初始化语句,就依次执行这些初始化语句。
- 类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader 的子类)。从 JDK 1.2 开始,类加载过程采取了父亲委托机制(PDM)。PDM 更好的保证了 Java 平台的安全性,在该机制中,JVM 自带的 Bootstrap 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 Bootstrap 的引用。下面是关于几个类加载器的说明:
- Bootstrap:一般用本地代码实现,负责加载 JVM 基础核心类库(rt.jar);
- Extension:从 java.ext.dirs 系统属性所指定的目录中加载类库,它的父加载器是 Bootstrap;
- System:又叫应用类加载器,其父类是 Extension。它是应用最广泛的类加载器。它从环境变量 classpath 或者系统属性 java.class.path 所指定的目录中记载类,是用户自定义加载器的默认父加载器。
644.char 型变量中能不能存贮一个中文汉字?为什么?
char 类型可以存储一个中文汉字,因为 Java 中使用的编码是 Unicode(不选择任何特定的编码,直接使用字符在字符集中的编号,这是统一的唯一方法),一个 char 类型占 2 个字节(16bit),所以放一个中文是没问题的。
补充: 使用 Unicode 意味着字符在 JVM 内部和外部有不同的表现形式,在 JVM 内部都是 Unicode,当这个字符被从 JVM 内部转移到外部时(例如存入文件系统中),需要进行编码转换。所以 Java 中有字节流和字符流,以及在字符流和字节流之间进行转换的转换流,如 InputStreamReader 和 OutputStreamReader,这两个类是字节流和字符流之间的适配器类,承担了编码转换的任务;对于 C 程序员来说,要完成这样的编码转换恐怕要依赖于 union(联合体/共用体)共享内存的特征来实现了。
645.抽象类(abstract class)和接口(interface)有什么异同
抽象类和接口都不能够实例化,但可以定义抽象类和接口类型的引用。一个类如果继承了某个抽象类或者实现了某个接口都需要对其中的抽象方法全部进行实现,否则该类仍然需要被声明为抽象类。接口比抽象类更加抽象,因为抽象类中可以定义构造器,可以有抽象方法和具体方法,而接口中不能定义构造器而且其中的方法全部都是抽象方法。抽象类中的成员可以是 private、默认、protected、public 的,而接口中的成员全都是 public 的。抽象类中可以定义成员变量,而接口中定义的成员变量实际上都是常量。有抽象方法的类必须被声明为抽象类,而抽象类未必要有抽象方法。
646.静态嵌套类(Static Nested Class)和内部类(Inner Class)的不同
Static Nested Class 是被声明为静态(static)的内部类,它可以不依赖于外部类实例被实例化。而通常的内部类需要在外部类实例化后才能实例化,其语法看起来挺诡异的,如下所示
647.Java 中会存在内存泄漏吗,请简单描述。
理论上 Java 因为有垃圾回收机制(GC)不会存在内存泄露问题(这也是 Java 被广泛使用于服务器端编程的一个重要原因);然而在实际开发中,可能会存在无用但可达的对象,这些对象不能被 GC 回收也会发生内存泄露。一个例子就是 Hibernate 的 Session(一级缓存)中的对象属于持久态,垃圾回收器是不会回收这些对象的,然而这些对象中可能存在无用的垃圾对象。
648.抽象的(abstract)方法是否可同时是静态的(static),是否可同时是本 地方法(native),是否可同时被 synchronized 修饰
都不能。抽象方法需要子类重写,而静态的方法是无法被重写的,因此二者是矛盾的。本地方法是由本地代码(如 C 代码)实现的方法,而抽象方法是没有实现的,也是矛盾的。synchronized 和方法的实现细节有关,抽象方法不涉及实现细节,因此也是相互矛盾的。
649.静态变量和实例变量的区别?
静态变量是被 static 修饰符修饰的变量,也称为类变量,它属于类,不属于类的任何一个对象,一个类不管创建多少个对象,静态变量在内存中有且仅有一个拷贝;实例变量必须依存于某一实例,需要先创建对象然后通过对象才能访问到它。静态变量可以实现让多个对象共享内存。在 Java 开发中,上下文类和工具类中通常会有大量的静态成员。
650.是否可以从一个静态(static)方法内部发出对非静态(non-static)方法 的调用?
不可以,静态方法只能访问静态成员,因为非静态方法的调用要先创建对象,因此在调用静态方法时可能对象并没有被初始化。
651.如何实现对象克隆?
有两种方式:
- 实现 Cloneable 接口并重写 Object 类中的 clone()方法;
- 实现 Serializable 接口,通过对象的序列化和反序列化实现克隆,可以实现真正的深度克隆,代码如下 注意:基于序列化和反序列化实现的克隆不仅仅是深度克隆,更重要的是通过泛型限定,可以检查出要克隆的对象是否支持序列化,这项检查是编译器完成的,不是在运行时抛出异常,这种是方案明显优于使用 Object 类的 clone 方法克隆对象。
652.GC 是什么?为什么要有 GC?
GC 是垃圾收集的意思,内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。Java 程序员不用担心内存管理,因为垃圾收集器会自动进行管理。要请求垃圾收集,可以调用下面的方法之一:System.gc() 或 Runtime.getRuntime().gc() ,但 JVM 可以屏蔽掉显示的垃圾回收调用。
垃圾回收可以有效的防止内存泄露,有效的使用可以使用的内存。垃圾回收器通常是作为一个单独的低优先级的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收。在 Java 诞生初期,垃圾回收是 Java最大的亮点之一,因为服务器端的编程需要有效的防止内存泄露问题,然而时过境迁,如今 Java 的垃圾回收机制已经成为被诟病的东西。移动智能终端用户通常觉得 iOS 的系统比 Android 系统有更好的用户体验,其中一个深层次的原因就在于 Android 系统中垃圾回收的不可预知性。
补充:垃圾回收机制有很多种,包括:分代复制垃圾回收、标记垃圾回收、增量垃圾回收等方式。标准的 Java 进程既有栈又有堆。栈保存了原始型局部变量,堆保存了要创建的对象。Java 平台对堆内存回收和再利用的基本算法被称为标记和清除,但是 Java 对其进行了改进,采用“分代式垃圾收集”。这种方法会跟 Java 对象的生命周期将堆内存划分为不同的区域,在垃圾收集过程中,可能会将对象移动到不同区域:
- 伊甸园(Eden):这是对象最初诞生的区域,并且对大多数对象来说,这里是它们唯一存在过的区域。
- 幸存者乐园(Survivor):从伊甸园幸存下来的对象会被挪到这里。
- 终身颐养园(Tenured):这是足够老的幸存对象的归宿。年轻代收集(Minor-GC)过程是不会触及这个地方的。当年轻代收集不能把对象放进终身颐养园时,就会触发一次完全收集(Major-GC),这里可能还会牵扯到压缩,以便为大对象腾出足够的空间。
与垃圾回收相关的 JVM 参数:
- -Xms / -Xmx — 堆的初始大小 / 堆的最大大小
- -Xmn — 堆中年轻代的大小
- -XX:-DisableExplicitGC — 让 System.gc()不产生任何作用
- -XX:+PrintGCDetail — 打印 GC 的细节
- -XX:+PrintGCDateStamps — 打印 GC 操作的时间戳
653.String s=new String(“xyz”);创建了几个字符串对象
两个对象,一个是静态存储区的"xyz",一个是用 new 创建在堆上的对象。
654.接口是否可继承(extends)接口? 抽象类是否可实现(implements) 接口? 抽象类是否可继承具体类(concrete class)
接口可以继承接口。抽象类可以实现(implements)接口,抽象类可继承具体类,但前提是具体类必须有明确的构造函数。
655.一个“.java”源文件中是否可以包含多个类(不是内部类) 有什么限制
可以,但一个源文件中最多只能有一个公开类(public class)而且文件名必须和公开类的类名完全保持一致
656.Anonymous Inner Class(匿名内部类)是否可以继承其它类?是否可以 实现接口
可以继承其他类或实现其他接口,在 Swing 编程中常用此方式来实现事件监听和回调
657.内部类可以引用它的包含类(外部类)的成员吗?有没有什么限制
一个内部类对象可以访问创建它的外部类对象的成员,包括私有成员。
658.Java 中的 final 关键字有哪些用法
- 修饰类:表示该类不能被继承;
- 修饰方法:表示方法不能被重写;
- 修饰变量:表示变量只能一次赋值以后值不能被修改(常量)。
659.指出下面程序的运行结果
执行结果:1a2b2b。创建对象时构造器的调用顺序是:先初始化静态成员,然后调用父类构造器,再初始化非静态成员,最后调用自身构造器。
660.数据类型之间的转换
- 如何将字符串转换为基本数据类型?
- 如何将基本数据类型转换为字符串?
答:
- 调用基本数据类型对应的包装类中的方法 parseXXX(String)或 valueOf(String)即可返回相应基本类型;
- 一种方法是将基本数据类型与空字符串(””)连接(+)即可获得其所对应的字符串;另一种方法是调用 String 类中的 valueOf(…)方法返回相应字符
661.如何实现字符串的反转及替换
方法很多,可以自己写实现也可以使用 String 或 StringBuffer / StringBuilder 中的方法。
662.怎样将 GB2312 编码的字符串转换为 ISO-8859-1 编码的字符串?
代码如下所示:
String s1 = "你好";
String s2 = newString(s1.getBytes("GB2312"), "ISO-8859-1");
并发编程入门
并发历史
在计算机最早期的时候,没有操作系统,执行程序只需要一种方式,那就是从头到尾依次执行。任何资源都会为这个程序服务,在计算机使用某些资源时,其他资源就会空闲,就会存在 浪费资源
的情况。
这里说的浪费资源指的是资源空闲,没有充分使用的情况。
操作系统的出现为我们的程序带来了 并发性
,操作系统使我们的程序能够同时运行多个程序,一个程序就是一个进程,也就相当于同时运行多个进程。
操作系统是一个并发系统
,并发性是操作系统非常重要的特征,操作系统具有同时处理和调度多个程序的能力,比如多个 I/O 设备同时在输入输出;设备 I/O 和 CPU 计算同时进行;内存中同时有多个系统和用户程序被启动交替、穿插地执行。操作系统在协调和分配进程的同时,操作系统也会为不同进程分配不同的资源。
操作系统实现多个程序同时运行解决了单个程序无法做到的问题,主要有下面三点
资源利用率
,我们上面说到,单个进程存在资源浪费的情况,举个例子,当你在为某个文件夹赋予权限的时候,输入程序无法接受外部的输入字符,只有等到权限赋予完毕后才能接受外部输入。总的来讲,就是在等待程序时无法执行其他工作。如果在等待程序时可以运行另一个程序,那么将会大大提高资源的利用率。(资源并不会觉得累)因为它不会划水~公平性
,不同的用户和程序都能够使用计算机上的资源。一种高效的运行方式是为不同的程序划分时间片来使用资源,但是有一点需要注意,操作系统可以决定不同进程的优先级。虽然每个进程都有能够公平享有资源的权利,但是当有一个进程释放资源后的同时有一个优先级更高的进程抢夺资源,就会造成优先级低的进程无法获得资源,进而导致进程饥饿。便利性
,单个进程是是不用通信的,通信的本质就是信息交换
,及时进行信息交换能够避免信息孤岛
,做重复性的工作;任何并发能做的事情,单进程也能够实现,只不过这种方式效率很低,它是一种顺序性
的。
但是,顺序编程(也称为串行编程
)也不是一无是处
的,串行编程的优势在于其直观性和简单性,客观来讲,串行编程更适合我们人脑的思考方式,但是我们并不会满足于顺序编程,we want it more!!! 。资源利用率、公平性和便利性促使着进程出现的同时,也促使着线程
的出现。
如果你还不是很理解进程和线程的区别的话,那么我就以我多年操作系统的经验(吹牛逼,实则半年)来为你解释一下:进程是一个应用程序,而线程是应用程序中的一条顺序流。
进程中会有多个线程来完成一些任务,这些任务有可能相同有可能不同。每个线程都有自己的执行顺序。
每个线程都有自己的栈空间,这是线程私有的,还有一些其他线程内部的和线程共享的资源,如下所示。
在计算机中,一般堆栈指的就是栈,而堆指的才是堆
线程会共享进程范围内的资源,例如内存和文件句柄,但是每个线程也有自己私有的内容,比如程序计数器、栈以及局部变量。下面汇总了进程和线程共享资源的区别
线程是一种轻量级
的进程,轻量级体现在线程的创建和销毁要比进程的开销小很多。
注意:任何比较都是相对的。
在大多数现代操作系统中,都以线程为基本的调度单位,所以我们的视角着重放在对线程
的探究。
线程
什么是多线程
多线程意味着你能够在同一个应用程序中运行多个线程,我们知道,指令是在 CPU 中执行的,多线程应用程序就像是具有多个 CPU 在同时执行应用程序的代码。
其实这是一种假象,线程数量并不等于 CPU 数量,单个 CPU 将在多个线程之间共享 CPU 的时间片,在给定的时间片内执行每个线程之间的切换,每个线程也可以由不同的 CPU 执行,如下图所示
并发和并行的关系
并发
意味着应用程序会执行多个的任务,但是如果计算机只有一个 CPU 的话,那么应用程序无法同时执行多个的任务,但是应用程序又需要执行多个任务,所以计算机在开始执行下一个任务之前,它并没有完成当前的任务,只是把状态暂存,进行任务切换,CPU 在多个任务之间进行切换,直到任务完成。如下图所示
并行
是指应用程序将其任务分解为较小的子任务,这些子任务可以并行处理,例如在多个CPU上同时进行。
优势和劣势
合理使用线程是一门艺术,合理编写一道准确无误的多线程程序更是一门艺术,如果线程使用得当,能够有效的降低程序的开发和维护成本。
Java 很好的在用户空间实现了开发工具包,并在内核空间提供系统调用来支持多线程编程,Java 支持了丰富的类库 java.util.concurrent
和跨平台的内存模型
,同时也提高了开发人员的门槛,并发一直以来是一个高阶的主题,但是现在,并发也成为了主流开发人员的必备素质。
虽然线程带来的好处很多,但是编写正确的多线程(并发)程序是一件极困难的事情,并发程序的 Bug 往往会诡异地出现又诡异的消失,在当你认为没有问题的时候它就出现了,难以定位
是并发程序的一个特征,所以在此基础上你需要有扎实的并发基本功。那么,并发为什么会出现呢?
并发为什么会出现
计算机世界的快速发展离不开 CPU、内存和 I/O 设备的高速发展,但是这三者一直存在速度差异性问题,我们可以从存储器的层次结构可以看出
CPU 内部是寄存器的构造,寄存器的访问速度要高于高速缓存
,高速缓存的访问速度要高于内存,最慢的是磁盘访问。
程序是在内存中执行的,程序里大部分语句都要访问内存,有些还需要访问 I/O 设备,根据漏桶理论来说,程序整体的性能取决于最慢的操作也就是磁盘访问速度。
因为 CPU 速度太快了,所以为了发挥 CPU 的速度优势,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 使用缓存来中和和内存的访问速度差异
- 操作系统提供进程和线程调度,让 CPU 在执行指令的同时分时复用线程,让内存和磁盘不断交互,不同的
CPU 时间片
能够执行不同的任务,从而均衡这三者的差异 - 编译程序提供优化指令的执行顺序,让缓存能够合理的使用
我们在享受这些便利的同时,多线程也为我们带来了挑战,下面我们就来探讨一下并发问题为什么会出现以及多线程的源头是什么
线程带来的安全性问题
线程安全性是非常复杂的,在没有采用同步机制
的情况下,多个线程中的执行操作往往是不可预测的,这也是多线程带来的挑战之一,下面我们给出一段代码,来看看安全性问题体现在哪
public class TSynchronized implements Runnable{
static int i = 0;
public void increase(){
i++;
}
@Override
public void run() {
for(int i = 0;i < 1000;i++) {
increase();
}
}
public static void main(String[] args) throws InterruptedException {
TSynchronized tSynchronized = new TSynchronized();
Thread aThread = new Thread(tSynchronized);
Thread bThread = new Thread(tSynchronized);
aThread.start();
bThread.start();
System.out.println("i = " + i);
}
}
这段程序输出后会发现,i 的值每次都不一样,这不符合我们的预测,那么为什么会出现这种情况呢?我们先来分析一下程序的运行过程。
TSynchronized
实现了 Runnable 接口,并定义了一个静态变量 i
,然后在 increase
方法中每次都增加 i 的值,在其实现的 run 方法中进行循环调用,共执行 1000 次。
可见性问题
在单核 CPU 时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决,CPU 和 内存之间
如果用图来表示的话我想会是下面这样
在多核时代,因为有多核的存在,每个核都能够独立的运行一个线程,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存
因为 i 是静态变量,没有经过任何线程安全措施的保护,多个线程会并发修改 i 的值,所以我们认为 i 不是线程安全的,导致这种结果的出现是由于 aThread 和 bThread 中读取的 i 值彼此不可见,所以这是由于 可见性
导致的线程安全问题。
原子性问题
看起来很普通的一段程序却因为两个线程 aThread
和 bThread
交替执行产生了不同的结果。但是根源不是因为创建了两个线程导致的,多线程只是产生线程安全性的必要条件,最终的根源出现在 i++
这个操作上。
这个操作怎么了?这不就是一个给 i 递增的操作吗?也就是 i++ => i = i + 1,这怎么就会产生问题了?
因为 i++
不是一个 原子性
操作,仔细想一下,i++ 其实有三个步骤,读取 i 的值,执行 i + 1 操作,然后把 i + 1 得出的值重新赋给 i(将结果写入内存)。
当两个线程开始运行后,每个线程都会把 i 的值读入到 CPU 缓存中,然后执行 + 1 操作,再把 + 1 之后的值写入内存。因为线程间都有各自的虚拟机栈和程序计数器,他们彼此之间没有数据交换,所以当 aThread 执行 + 1 操作后,会把数据写入到内存,同时 bThread 执行 + 1 操作后,也会把数据写入到内存,因为 CPU 时间片的执行周期是不确定的,所以会出现当 aThread 还没有把数据写入内存时,bThread 就会读取内存中的数据,然后执行 + 1操作,再写回内存,从而覆盖 i 的值,导致 aThread 所做的努力白费。
为什么上面的线程切换会出现问题呢?
我们先来考虑一下正常情况下(即不会出现线程安全性问题的情况下)两条线程的执行顺序
可以看到,当 aThread 在执行完整个 i++ 的操作后,操作系统对线程进行切换,由 aThread -> bThread,这是最理想的操作,一旦操作系统在任意 读取/增加/写入
阶段产生线程切换,都会产生线程安全问题。例如如下图所示
最开始的时候,内存中 i = 0,aThread 读取内存中的值并把它读取到自己的寄存器中,执行 +1 操作,此时发生线程切换,bThread 开始执行,读取内存中的值并把它读取到自己的寄存器中,此时发生线程切换,线程切换至 aThread 开始运行,aThread 把自己寄存器的值写回到内存中,此时又发生线程切换,由 aThread -> bThread,线程 bThread 把自己寄存器的值 +1 然后写回内存,写完后内存中的值不是 2 ,而是 1, 内存中的 i 值被覆盖了。
我们上面提到 原子性
这个概念,那么什么是原子性呢?
并发编程的原子性操作是完全独立于任何其他进程运行的操作,原子操作多用于现代操作系统和并行处理系统中。
原子操作通常在内核中使用,因为内核是操作系统的主要组件。但是,大多数计算机硬件,编译器和库也提供原子性操作。
在加载和存储中,计算机硬件对存储器字进行读取和写入。为了对值进行匹配、增加或者减小操作,一般通过原子操作进行。在原子操作期间,处理器可以在同一数据传输期间完成读取和写入。 这样,其他输入/输出机制或处理器无法执行存储器读取或写入任务,直到原子操作完成为止。
简单来讲,就是原子操作要么全部执行,要么全部不执行。数据库事务的原子性也是基于这个概念演进的。
有序性问题
在并发编程中还有带来让人非常头疼的 有序性
问题,有序性顾名思义就是顺序性,在计算机中指的就是指令的先后执行顺序。一个非常显而易见的例子就是 JVM 中的类加载
这是一个 JVM 加载类的过程图,也称为类的生命周期,类从加载到 JVM 到卸载一共会经历五个阶段 加载、连接、初始化、使用、卸载。这五个过程的执行顺序是一定的,但是在连接阶段,也会分为三个过程,即 验证、准备、解析 阶段,这三个阶段的执行顺序不是确定的,通常交叉进行,在一个阶段的执行过程中会激活另一个阶段。
有序性问题一般是编译器带来的,编译器有的时候确实是 好心办坏事,它为了优化系统性能,往往更换指令的执行顺序。
活跃性问题
多线程还会带来活跃性
问题,如何定义活跃性问题呢?活跃性问题关注的是 某件事情是否会发生。
如果一组线程中的每个线程都在等待一个事件的发生,而这个事件只能由该组中正在等待的线程触发,这种情况会导致死锁。
简单一点来表述一下,就是每个线程都在等待其他线程释放资源,而其他资源也在等待每个线程释放资源,这样没有线程抢先释放自己的资源,这种情况会产生死锁,所有线程都会无限的等待下去。
**死锁的必要条件
造成死锁的原因有四个,破坏其中一个即可破坏死锁
- 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程释放。
- 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持占有。
- 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 循环等待:指在发生死锁时,必然存在一个进程对应的环形链。
换句话说,死锁线程集合中的每个线程都在等待另一个死锁线程占有的资源。但是由于所有线程都不能运行,它们之中任何一个资源都无法释放资源,所以没有一个线程可以被唤醒。
如果说死锁很痴情
的话,那么活锁
用一则成语来表示就是 弄巧成拙
。
某些情况下,当线程意识到它不能获取所需要的下一个锁时,就会尝试礼貌的释放已经获得的锁,然后等待非常短的时间再次尝试获取。可以想像一下这个场景:当两个人在狭路相逢的时候,都想给对方让路,相同的步调会导致双方都无法前进。
现在假想有一对并行的线程用到了两个资源。它们分别尝试获取另一个锁失败后,两个线程都会释放自己持有的锁,再次进行尝试,这个过程会一直进行重复。很明显,这个过程中没有线程阻塞,但是线程仍然不会向下执行,这种状况我们称之为 活锁(livelock)
。
如果我们期望的事情一直不会发生,就会产生活跃性问题,比如单线程中的无限循环
while(true){...}
for(;;){}
在多线程中,比如 aThread 和 bThread 都需要某种资源,aThread 一直占用资源不释放,bThread 一直得不到执行,就会造成活跃性问题,bThread 线程会产生饥饿
,我们后面会说。
性能问题
与活跃性问题密切相关的是 性能
问题,如果说活跃性问题关注的是最终的结果,那么性能问题关注的就是造成结果的过程,性能问题有很多方面:比如服务时间过长,吞吐率过低,资源消耗过高,在多线程中这样的问题同样存在。
在多线程中,有一个非常重要的性能因素那就是我们上面提到的 线程切换
,也称为 上下文切换(Context Switch)
,这种操作开销很大。
在计算机世界中,老外都喜欢用 context 上下文这个词,这个词涵盖的内容很多,包括上下文切换的资源,寄存器的状态、程序计数器等。context switch 一般指的就是这些上下文切换的资源、寄存器状态、程序计数器的变化等。
在上下文切换中,会保存和恢复上下文,丢失局部性,把大量的时间消耗在线程切换上而不是线程运行上。
为什么线程切换会开销如此之大呢?线程间的切换会涉及到以下几个步骤
将 CPU 从一个线程切换到另一线程涉及挂起当前线程,保存其状态,例如寄存器,然后恢复到要切换的线程的状态,加载新的程序计数器,此时线程切换实际上就已经完成了;此时,CPU 不在执行线程切换代码,进而执行新的和线程关联的代码。
引起线程切换的几种方式
线程间的切换一般是操作系统层面需要考虑的问题,那么引起线程上下文切换有哪几种方式呢?或者说线程切换有哪几种诱因呢?主要有下面几种引起上下文切换的方式
- 当前正在执行的任务完成,系统的 CPU 正常调度下一个需要运行的线程
- 当前正在执行的任务遇到 I/O 等阻塞操作,线程调度器挂起此任务,继续调度下一个任务。
- 多个任务并发抢占锁资源,当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务。
- 用户的代码挂起当前任务,比如线程执行 sleep 方法,让出CPU。
- 使用硬件中断的方式引起上下文切换
线程安全性
在 Java 中,要实现线程安全性,必须要正确的使用线程和锁,但是这些只是满足线程安全的一种方式,要编写正确无误的线程安全的代码,其核心就是对状态访问操作进行管理。最重要的就是最 共享(Shared)
的 和 可变(Mutable)
的状态。只有共享和可变的变量才会出现问题,私有变量不会出现问题,参考程序计数器
。
对象的状态可以理解为存储在实例变量或者静态变量中的数据,共享意味着某个变量可以被多个线程同时访问、可变意味着变量在生命周期内会发生变化。一个变量是否是线程安全的,取决于它是否被多个线程访问。要使变量能够被安全访问,必须通过同步机制来对变量进行修饰。
如果不采用同步机制的话,那么就要避免多线程对共享变量的访问,主要有下面两种方式
- 不要在多线程之间共享变量
- 将共享变量置为不可变的
我们说了这么多次线程安全性,那么什么是线程安全性呢?
什么是线程安全性
多个线程可以同时安全调用的代码称为线程安全的,如果一段代码是安全的,那么这段代码就不存在 竞态条件
。仅仅当多个线程共享资源时,才会出现竞态条件。
根据上面的探讨,我们可以得出一个简单的结论:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
单线程就是一个线程数量为 1 的多线程,单线程一定是线程安全的。读取某个变量的值不会产生安全性问题,因为不管读取多少次,这个变量的值都不会被修改。
原子性
我们上面提到了原子性的概念,你可以把原子性
操作想象成为一个不可分割
的整体,它的结果只有两种,要么全部执行,要么全部回滚。你可以把原子性认为是 婚姻关系
的一种,男人和女人只会产生两种结果,好好的
和 说散就散
,一般男人的一生都可以把他看成是原子性的一种,当然我们不排除时间管理(线程切换)
的个例,我们知道线程切换必然会伴随着安全性问题,男人要出去浪也会造成两种结果,这两种结果分别对应安全性的两个结果:线程安全(好好的)和线程不安全(说散就散)。
竞态条件
有了上面的线程切换的功底,那么竞态条件也就好定义了,它指的就是两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件(race condition) ,线程切换是导致竞态条件出现的诱导因素,我们通过一个示例来说明,来看一段代码
public class RaceCondition {
private Signleton single = null;
public Signleton newSingleton(){
if(single == null){
single = new Signleton();
}
return single;
}
}
在上面的代码中,涉及到一个竞态条件,那就是判断 single
的时候,如果 single 判断为空,此时发生了线程切换,另外一个线程执行,判断 single 的时候,也是空,执行 new 操作,然后线程切换回之前的线程,再执行 new 操作,那么内存中就会有两个 Singleton 对象。
加锁机制
在 Java 中,有很多种方式来对共享和可变的资源进行加锁和保护。Java 提供一种内置的机制对资源进行保护:synchronized
关键字,它有三种保护机制
- 对方法进行加锁,确保多个线程中只有一个线程执行方法;
- 对某个对象实例(在我们上面的探讨中,变量可以使用对象来替换)进行加锁,确保多个线程中只有一个线程对对象实例进行访问;
- 对类对象进行加锁,确保多个线程只有一个线程能够访问类中的资源。
synchronized 关键字对资源进行保护的代码块俗称 同步代码块(Synchronized Block)
,例如
synchronized(lock){
// 线程安全的代码
}
每个 Java 对象都可以用做一个实现同步的锁,这些锁被称为 内置锁(Instrinsic Lock)
或者 监视器锁(Monitor Lock)
。线程在进入同步代码之前会自动获得锁,并且在退出同步代码时自动释放锁,而无论是通过正常执行路径退出还是通过异常路径退出,获得内置锁的唯一途径就是进入这个由锁保护的同步代码块或方法。
synchronized 的另一种隐含的语义就是 互斥
,互斥意味着独占
,最多只有一个线程持有锁,当线程 A 尝试获得一个由线程 B 持有的锁时,线程 A 必须等待或者阻塞,直到线程 B 释放这个锁,如果线程 B 不释放锁的话,那么线程 A 将会一直等待下去。
线程 A 获得线程 B 持有的锁时,线程 A 必须等待或者阻塞,但是获取锁的线程 B 可以重入,重入的意思可以用一段代码表示
public class Retreent {
public synchronized void doSomething(){
doSomethingElse();
System.out.println("doSomething......");
}
public synchronized void doSomethingElse(){
System.out.println("doSomethingElse......");
}
获取 doSomething() 方法锁的线程可以执行 doSomethingElse() 方法,执行完毕后可以重新执行 doSomething() 方法中的内容。锁重入也支持子类和父类之间的重入,具体的我们后面会进行介绍。
volatile
是一种轻量级的 synchronized
,也就是一种轻量级的加锁方式,volatile 通过保证共享变量的可见性来从侧面对对象进行加锁。可见性的意思就是当一个线程修改一个共享变量时,另外一个线程能够 看见
这个修改的值。volatile 的执行成本要比 synchronized
低很多,因为 volatile 不会引起线程的上下文切换。
我们还可以使用原子类
来保证线程安全,原子类其实就是 rt.jar
下面以 atomic
开头的类
除此之外,我们还可以使用 java.util.concurrent
工具包下的线程安全的集合类来确保线程安全,具体的实现类和其原理我们后面会说。
可以使用不同的并发模型来实现并发系统,并发模型说的是系统中的线程如何协作完成并发任务。不同的并发模型以不同的方式拆分任务,线程可以以不同的方式进行通信和协作。
竞态条件和关键区域
竞态条件是在关键代码区域发生的一种特殊条件。关键区域是由多个线程同时执行的代码部分,关键区域中的代码执行顺序会对造成不一样的结果。如果多个线程执行一段关键代码,而这段关键代码会因为执行顺序不同而造成不同的结果时,那么这段代码就会包含竞争条件。
并发模型和分布式系统很相似
并发模型其实和分布式系统模型非常相似,在并发模型中是线程
彼此进行通信,而在分布式系统模型中是 进程
彼此进行通信。然而本质上,进程和线程也非常相似。这也就是为什么并发模型和分布式模型非常相似的原因。
分布式系统通常要比并发系统面临更多的挑战和问题比如进程通信、网络可能出现异常,或者远程机器挂掉等等。但是一个并发模型同样面临着比如 CPU 故障、网卡出现问题、硬盘出现问题等。
因为并发模型和分布式模型很相似,因此他们可以相互借鉴,例如用于线程分配的模型就类似于分布式系统环境中的负载均衡模型。
其实说白了,分布式模型的思想就是借鉴并发模型的基础上推演发展来的。
认识两个状态
并发模型的一个重要的方面是,线程是否应该共享状态
,是具有共享状态
还是独立状态
。共享状态也就意味着在不同线程之间共享某些状态
状态其实就是数据
,比如一个或者多个对象。当线程要共享数据时,就会造成 竞态条件
或者 死锁
等问题。当然,这些问题只是可能会出现,具体实现方式取决于你是否安全的使用和访问共享对象。
独立的状态表明状态不会在多个线程之间共享,如果线程之间需要通信的话,他们可以访问不可变的对象来实现,这是最有效的避免并发问题的一种方式,如下图所示
使用独立状态让我们的设计更加简单,因为只有一个线程能够访问对象,即使交换对象,也是不可变的对象。
并发模型
并行 Worker
第一个并发模型是并行 worker 模型,客户端会把任务交给 代理人(Delegator)
,然后由代理人把工作分配给不同的 工人(worker)
。如下图所示
并行 worker 的核心思想是,它主要有两个进程即代理人和工人,Delegator 负责接收来自客户端的任务并把任务下发,交给具体的 Worker 进行处理,Worker 处理完成后把结果返回给 Delegator,在 Delegator 接收到 Worker 处理的结果后对其进行汇总,然后交给客户端。
并行 Worker 模型是 Java 并发模型中非常常见的一种模型。许多 java.util.concurrent
包下的并发工具都使用了这种模型。
并行 Worker 的优点
并行 Worker 模型的一个非常明显的特点就是很容易理解,为了提高系统的并行度你可以增加多个 Worker 完成任务。
并行 Worker 模型的另外一个好处就是,它会将一个任务拆分成多个小任务,并发执行,Delegator 在接受到 Worker 的处理结果后就会返回给 Client,整个 Worker -> Delegator -> Client 的过程是异步
的。
并行 Worker 的缺点
同样的,并行 Worker 模式同样会有一些隐藏的缺点
**共享状态会变得很复杂
实际的并行 Worker 要比我们图中画出的更复杂,主要是并行 Worker 通常会访问内存或共享数据库中的某些共享数据。
这些共享状态可能会使用一些工作队列来保存业务数据、数据缓存、数据库的连接池等。在线程通信中,线程需要确保共享状态是否能够让其他线程共享,而不是仅仅停留在 CPU 缓存中让自己可用,当然这些都是程序员在设计时就需要考虑的问题。线程需要避免 竞态条件
,死锁
和许多其他共享状态造成的并发问题。
多线程在访问共享数据时,会丢失并发性,因为操作系统要保证只有一个线程能够访问数据,这会导致共享数据的争用和抢占。未抢占到资源的线程会 阻塞
。
现代的非阻塞并发算法可以减少争用提高性能,但是非阻塞算法比较难以实现。
可持久化的数据结构(Persistent data structures)
是另外一个选择。可持久化的数据结构在修改后始终会保留先前版本。因此,如果多个线程同时修改一个可持久化的数据结构,并且一个线程对其进行了修改,则修改的线程会获得对新数据结构的引用。
虽然可持久化的数据结构是一个新的解决方法,但是这种方法实行起来却有一些问题,比如,一个持久列表会将新元素添加到列表的开头,并返回所添加的新元素的引用,但是其他线程仍然只持有列表中先前的第一个元素的引用,他们看不到新添加的元素。
持久化的数据结构比如 链表(LinkedList)
在硬件性能上表现不佳。列表中的每个元素都是一个对象,这些对象散布在计算机内存中。现代 CPU 的顺序访问往往要快的多,因此使用数组等顺序访问的数据结构则能够获得更高的性能。CPU 高速缓存可以将一个大的矩阵块加载到高速缓存中,并让 CPU 在加载后直接访问 CPU 高速缓存中的数据。对于链表,将元素分散在整个 RAM 上,这实际上是不可能的。
**无状态的 worker
共享状态可以由其他线程所修改,因此,worker 必须在每次操作共享状态时重新读取,以确保在副本上能够正确工作。不在线程内部保持状态的 worker 成为无状态的 worker。
**作业顺序是不确定的
并行工作模型的另一个缺点是作业的顺序不确定,无法保证首先执行或最后执行哪些作业。任务 A 在任务 B 之前分配给 worker,但是任务 B 可能在任务 A 之前执行。
流水线
第二种并发模型就是我们经常在生产车间遇到的 流水线并发模型
,下面是流水线设计模型的流程图
这种组织架构就像是工厂中装配线中的 worker,每个 worker 只完成全部工作的一部分,完成一部分后,worker 会将工作转发给下一个 worker。
每道程序都在自己的线程中运行,彼此之间不会共享状态,这种模型也被称为无共享并发模型。
使用流水线并发模型通常被设计为非阻塞I/O
,也就是说,当没有给 worker 分配任务时,worker 会做其他工作。非阻塞I/O 意味着当 worker 开始 I/O 操作,例如从网络中读取文件,worker 不会等待 I/O 调用完成。因为 I/O 操作很慢,所以等待 I/O 非常耗费时间。在等待 I/O 的同时,CPU 可以做其他事情,I/O 操作完成后的结果将传递给下一个 worker。下面是非阻塞 I/O 的流程图
在实际情况中,任务通常不会按着一条装配线流动,由于大多数程序需要做很多事情,因此需要根据完成的不同工作在不同的 worker 之间流动,如下图所示
任务还可能需要多个 worker 共同参与完成
响应式 – 事件驱动系统
使用流水线模型的系统有时也被称为 响应式
或者 事件驱动系统
,这种模型会根据外部的事件作出响应,事件可能是某个 HTTP 请求或者某个文件完成加载到内存中。
Actor 模型
在 Actor 模型中,每一个 Actor 其实就是一个 Worker, 每一个 Actor 都能够处理任务。
简单来说,Actor 模型是一个并发模型,它定义了一系列系统组件应该如何动作和交互的通用规则,最著名的使用这套规则的编程语言是 Erlang。一个参与者Actor
对接收到的消息做出响应,然后可以创建出更多的 Actor 或发送更多的消息,同时准备接收下一条消息。
Channels 模型
在 Channel 模型中,worker 通常不会直接通信,与此相对的,他们通常将事件发送到不同的 通道(Channel)
上,然后其他 worker 可以在这些通道上获取消息,下面是 Channel 的模型图
有的时候 worker 不需要明确知道接下来的 worker 是谁,他们只需要将作者写入通道中,监听 Channel 的 worker 可以订阅或者取消订阅,这种方式降低了 worker 和 worker 之间的耦合性。
流水线设计的优点
与并行设计模型相比,流水线模型具有一些优势,具体优势如下
**不会存在共享状态
因为流水线设计能够保证 worker 在处理完成后再传递给下一个 worker,所以 worker 与 worker 之间不需要共享任何状态,也就无需考虑并发问题。你甚至可以在实现上把每个 worker 看成是单线程的一种。
**有状态 worker
因为 worker 知道没有其他线程修改自身的数据,所以流水线设计中的 worker 是有状态的,有状态的意思是他们可以将需要操作的数据保留在内存中,有状态通常比无状态更快。
**更好的硬件整合
因为你可以把流水线看成是单线程的,而单线程的工作优势在于它能够和硬件的工作方式相同。因为有状态的 worker 通常在 CPU 中缓存数据,这样可以更快地访问缓存的数据。
**使任务更加有效的进行
可以对流水线并发模型中的任务进行排序,一般用来日志的写入和恢复。
流水线设计的缺点
流水线并发模型的缺点是任务会涉及多个 worker,因此可能会分散在项目代码的多个类中。因此很难确定每个 worker 都在执行哪个任务。流水线的代码编写也比较困难,设计许多嵌套回调处理程序的代码通常被称为 回调地狱
。回调地狱很难追踪 debug。
函数性并行
函数性并行模型是最近才提出的一种并发模型,它的基本思路是使用函数调用来实现。消息的传递就相当于是函数的调用。传递给函数的参数都会被拷贝,因此在函数之外的任何实体都无法操纵函数内的数据。这使得函数执行类似于原子
操作。每个函数调用都可以独立于任何其他函数调用执行。
当每个函数调用独立执行时,每个函数都可以在单独的 CPU 上执行。这也就是说,函数式并行并行相当于是各个 CPU 单独执行各自的任务。
JDK 1.7 中的 ForkAndJoinPool
类就实现了函数性并行的功能。Java 8 提出了 stream 的概念,使用并行流也能够实现大量集合的迭代。
函数性并行的难点是要知道函数的调用流程以及哪些 CPU 执行了哪些函数,跨 CPU 函数调用会带来额外的开销。
我们之前说过,线程就是进程中的一条顺序流
,在 Java 中,每一条 Java 线程就像是 JVM 的一条顺序流,就像是虚拟 CPU 一样来执行代码。Java 中的 main()
方法是一条特殊的线程,JVM 创建的 main 线程是一条主执行线程
,在 Java 中,方法都是由 main 方法发起的。在 main 方法中,你照样可以创建其他的线程
(执行顺序流),这些线程可以和 main 方法共同执行应用代码。
Java 线程也是一种对象,它和其他对象一样。Java 中的 Thread 表示线程,Thread 是 java.lang.Thread
类或其子类的实例。那么下面我们就来一起探讨一下在 Java 中如何创建和启动线程。
创建并启动线程
在 Java 中,创建线程的方式主要有三种
- 通过继承
Thread
类来创建线程 - 通过实现
Runnable
接口来创建线程 - 通过
Callable
和Future
来创建线程
下面我们分别探讨一下这几种创建方式
继承 Thread 类来创建线程
第一种方式是继承 Thread 类来创建线程,如下示例
public class TJavaThread extends Thread{
static int count;
@Override
public synchronized void run() {
for(int i = 0;i < 10000;i++){
count++;
}
}
public static void main(String[] args) throws InterruptedException {
TJavaThread tJavaThread = new TJavaThread();
tJavaThread.start();
tJavaThread.join();
System.out.println("count = " + count);
}
}
线程的主要创建步骤如下
- 定义一个线程类使其继承 Thread 类,并重写其中的 run 方法,run 方法内部就是线程要完成的任务,因此 run 方法也被称为
执行体
- 创建了 Thread 的子类,上面代码中的子类是
TJavaThread
- 启动方法需要注意,并不是直接调用
run
方法来启动线程,而是使用start
方法来启动线程。当然 run 方法可以调用,这样的话就会变成普通方法调用,而不是新创建一个线程来调用了。
public static void main(String[] args) throws InterruptedException {
TJavaThread tJavaThread = new TJavaThread();
tJavaThread.run();
System.out.println("count = " + count);
}
这样的话,整个 main 方法只有一条执行线程也就是 main 线程,由两条执行线程变为一条执行线程
Thread 构造器只需要一个 Runnable 对象,调用 Thread 对象的 start() 方法为该线程执行必须的初始化操作,然后调用 Runnable 的 run 方法,以便在这个线程中启动任务。我们上面使用了线程的 join
方法,它用来等待线程的执行结束,如果我们不加 join 方法,它就不会等待 tJavaThread 的执行完毕,输出的结果可能就不是 10000
可以看到,在 run 方法还没有结束前,run 就被返回了。也就是说,程序不会等到 run 方法执行完毕就会执行下面的指令。
使用继承方式创建线程的优势:编写比较简单;可以使用 this
关键字直接指向当前线程,而无需使用 Thread.currentThread()
来获取当前线程。
使用继承方式创建线程的劣势:在 Java 中,只允许单继承(拒绝肛精说使用内部类可以实现多继承)的原则,所以使用继承的方式,子类就不能再继承其他类。
使用 Runnable 接口来创建线程
相对的,还可以使用 Runnable
接口来创建线程,如下示例
public class TJavaThreadUseImplements implements Runnable{
static int count;
@Override
public synchronized void run() {
for(int i = 0;i < 10000;i++){
count++;
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(new TJavaThreadUseImplements()).start();
System.out.println("count = " + count);
}
}
线程的主要创建步骤如下
- 首先定义 Runnable 接口,并重写 Runnable 接口的 run 方法,run 方法的方法体同样是该线程的线程执行体。
- 创建线程实例,可以使用上面代码这种简单的方式创建,也可以通过 new 出线程的实例来创建,如下所示
TJavaThreadUseImplements tJavaThreadUseImplements = new TJavaThreadUseImplements();
new Thread(tJavaThreadUseImplements).start();
- 再调用线程对象的 start 方法来启动该线程。
线程在使用实现 Runnable
的同时也能实现其他接口,非常适合多个相同线程来处理同一份资源的情况,体现了面向对象的思想。
使用 Runnable 实现的劣势是编程稍微繁琐,如果要访问当前线程,则必须使用 Thread.currentThread()
方法。
使用 Callable 接口来创建线程
Runnable 接口执行的是独立的任务,Runnable 接口不会产生任何返回值,如果你希望在任务完成后能够返回一个值的话,那么你可以实现 Callable
接口而不是 Runnable 接口。Java SE5 引入了 Callable 接口,它的示例如下
public class CallableTask implements Callable {
static int count;
public CallableTask(int count){
this.count = count;
}
@Override
public Object call() {
return count;
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> task = new FutureTask((Callable<Integer>) () -> {
for(int i = 0;i < 1000;i++){
count++;
}
return count;
});
Thread thread = new Thread(task);
thread.start();
Integer total = task.get();
System.out.println("total = " + total);
}
}
我想,使用 Callable 接口的好处你已经知道了吧,既能够实现多个接口,也能够得到执行结果的返回值。Callable 和 Runnable 接口还是有一些区别的,主要区别如下
- Callable 执行的任务有返回值,而 Runnable 执行的任务没有返回值
- Callable(重写)的方法是 call 方法,而 Runnable(重写)的方法是 run 方法。
- call 方法可以抛出异常,而 Runnable 方法不能抛出异常
使用线程池来创建线程
首先先来认识一下顶级接口 Executor
,Executor 虽然不是传统线程创建的方式之一,但是它却成为了创建线程的替代者,使用线程池的好处如下
- 利用线程池能够复用线程、控制最大并发数。
- 实现任务线程队列
缓存策略
和拒绝机制
。 - 实现某些与时间相关的功能,如定时执行、周期执行等。
- 隔离线程环境。比如,交易服务和搜索服务在同一台服务器上,分别开启两个线程池,交易线程的资源消耗明显要大;因此,通过配置独立的线程池,将较慢的交易服务与搜索服务隔开,避免个服务线程互相影响。
你可以使用如下操作来替换线程创建
new Thread(new(RunnableTask())).start()
// 替换为
Executor executor = new ExecutorSubClass() // 线程池实现类;
executor.execute(new RunnableTask1());
executor.execute(new RunnableTask2());
ExecutorService
是 Executor 的默认实现,也是 Executor 的扩展接口,ThreadPoolExecutor 类提供了线程池的扩展实现。Executors
类为这些 Executor 提供了方便的工厂方法。下面是使用 ExecutorService 创建线程的几种方式
CachedThreadPool
从而简化了并发编程。Executor 在客户端和任务之间提供了一个间接层;与客户端直接执行任务不同,这个中介对象将执行任务。Executor 允许你管理异步
任务的执行,而无须显示地管理线程的生命周期。
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
for(int i = 0;i < 5;i++){
service.execute(new TestThread());
}
service.shutdown();
}
CachedThreadPool
会为每个任务都创建一个线程。
注意:ExecutorService 对象是使用静态的
Executors
创建的,这个方法可以确定 Executor 类型。对shutDown
的调用可以防止新任务提交给 ExecutorService ,这个线程在 Executor 中所有任务完成后退出。
FixedThreadPool
FixedThreadPool 使你可以使用有限
的线程集来启动多线程
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(5);
for(int i = 0;i < 5;i++){
service.execute(new TestThread());
}
service.shutdown();
}
有了 FixedThreadPool 使你可以一次性的预先执行高昂的线程分配,因此也就可以限制线程的数量。这可以节省时间,因为你不必为每个任务都固定的付出创建线程的开销。
SingleThreadExecutor
SingleThreadExecutor 就是线程数量为 1
的 FixedThreadPool,如果向 SingleThreadPool 一次性提交了多个任务,那么这些任务将会排队,每个任务都会在下一个任务开始前结束,所有的任务都将使用相同的线程。SingleThreadPool 会序列化所有提交给他的任务,并会维护它自己(隐藏)的悬挂队列。
public static void main(String[] args) {
ExecutorService service = Executors.newSingleThreadExecutor();
for(int i = 0;i < 5;i++){
service.execute(new TestThread());
}
service.shutdown();
}
从输出的结果就可以看到,任务都是挨着执行的。我为任务分配了五个线程,但是这五个线程不像是我们之前看到的有换进换出的效果,它每次都会先执行完自己的那个线程,然后余下的线程继续走完
这条线程的执行路径。你可以用 SingleThreadExecutor 来确保任意时刻都只有唯一一个任务在运行。
休眠
影响任务行为的一种简单方式就是使线程 休眠,选定给定的休眠时间,调用它的 sleep()
方法, 一般使用的TimeUnit
这个时间类替换 Thread.sleep()
方法,示例如下:
public class SuperclassThread extends TestThread{
@Override
public void run() {
System.out.println(Thread.currentThread() + "starting ..." );
try {
for(int i = 0;i < 5;i++){
if(i == 3){
System.out.println(Thread.currentThread() + "sleeping ...");
TimeUnit.MILLISECONDS.sleep(1000);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "wakeup and end ...");
}
public static void main(String[] args) {
ExecutorService executors = Executors.newCachedThreadPool();
for(int i = 0;i < 5;i++){
executors.execute(new SuperclassThread());
}
executors.shutdown();
}
}
关于 TimeUnit 中的 sleep() 方法和 Thread.sleep() 方法的比较,请参考下面这篇博客
优先级
上面提到线程调度器对每个线程的执行都是不可预知的,随机执行的,那么有没有办法告诉线程调度器哪个任务想要优先被执行呢?你可以通过设置线程的优先级状态,告诉线程调度器哪个线程的执行优先级比较高,请给这个骑手马上派单,线程调度器倾向于让优先级较高的线程优先执行,然而,这并不意味着优先级低的线程得不到执行,也就是说,优先级不会导致死锁的问题。优先级较低的线程只是执行频率较低。
public class SimplePriorities implements Runnable{
private int priority;
public SimplePriorities(int priority) {
this.priority = priority;
}
@Override
public void run() {
Thread.currentThread().setPriority(priority);
for(int i = 0;i < 100;i++){
System.out.println(this);
if(i % 10 == 0){
Thread.yield();
}
}
}
@Override
public String toString() {
return Thread.currentThread() + " " + priority;
}
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
for(int i = 0;i < 5;i++){
service.execute(new SimplePriorities(Thread.MAX_PRIORITY));
}
service.execute(new SimplePriorities(Thread.MIN_PRIORITY));
}
}
toString() 方法被覆盖,以便通过使用 Thread.toString()
方法来打印线程的名称。你可以改写线程的默认输出,这里采用了 Thread[pool-1-thread-1,10,main] 这种形式的输出。
通过输出,你可以看到,最后一个线程的优先级最低,其余的线程优先级最高。注意,优先级是在 run 开头设置的,在构造器中设置它们不会有任何好处,因为这个时候线程还没有执行任务。
尽管 JDK 有 10 个优先级,但是一般只有MAX_PRIORITY,NORM_PRIORITY,MIN_PRIORITY 三种级别。
作出让步
我们上面提过,如果知道一个线程已经在 run() 方法中运行的差不多了,那么它就可以给线程调度器一个提示:我已经完成了任务中最重要的部分,可以让给别的线程使用 CPU 了。这个暗示将通过 yield() 方法作出。
有一个很重要的点就是,Thread.yield() 是建议执行切换CPU,而不是强制执行CPU切换。
对于任何重要的控制或者在调用应用时,都不能依赖于 yield()
方法,实际上, yield() 方法经常被滥用。
后台线程
后台(daemon)
线程,是指运行时在后台提供的一种服务线程,这种线程不是属于必须的。当所有非后台线程结束时,程序也就停止了,同时会终止所有的后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。
public class SimpleDaemons implements Runnable{
@Override
public void run() {
while (true){
try {
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(Thread.currentThread() + " " + this);
} catch (InterruptedException e) {
System.out.println("sleep() interrupted");
}
}
}
public static void main(String[] args) throws InterruptedException {
for(int i = 0;i < 10;i++){
Thread daemon = new Thread(new SimpleDaemons());
daemon.setDaemon(true);
daemon.start();
}
System.out.println("All Daemons started");
TimeUnit.MILLISECONDS.sleep(175);
}
}
在每次的循环中会创建 10 个线程,并把每个线程设置为后台线程,然后开始运行,for 循环会进行十次,然后输出信息,随后主线程睡眠一段时间后停止运行。在每次 run 循环中,都会打印当前线程的信息,主线程运行完毕,程序就执行完毕了。因为 daemon
是后台线程,无法影响主线程的执行。
但是当你把 daemon.setDaemon(true)
去掉时,while(true) 会进行无限循环,那么主线程一直在执行最重要的任务,所以会一直循环下去无法停止。
ThreadFactory
按需要创建线程的对象。使用线程工厂替换了 Thread 或者 Runnable 接口的硬连接,使程序能够使用特殊的线程子类,优先级等。一般的创建方式为
class SimpleThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
return new Thread(r);
}
}
Executors.defaultThreadFactory 方法提供了一个更有用的简单实现,它在返回之前将创建的线程上下文设置为已知值
ThreadFactory
是一个接口,它只有一个方法就是创建线程的方法
public interface ThreadFactory {
// 构建一个新的线程。实现类可能初始化优先级,名称,后台线程状态和 线程组等
Thread newThread(Runnable r);
}
下面来看一个 ThreadFactory 的例子
public class DaemonThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setDaemon(true);
return t;
}
}
public class DaemonFromFactory implements Runnable{
@Override
public void run() {
while (true){
try {
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(Thread.currentThread() + " " + this);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newCachedThreadPool(new DaemonThreadFactory());
for(int i = 0;i < 10;i++){
service.execute(new DaemonFromFactory());
}
System.out.println("All daemons started");
TimeUnit.MILLISECONDS.sleep(500);
}
}
Executors.newCachedThreadPool
可以接受一个线程池对象,创建一个根据需要创建新线程的线程池,但会在它们可用时重用先前构造的线程,并在需要时使用提供的 ThreadFactory 创建新线程。
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
加入一个线程
一个线程可以在其他线程上调用 join()
方法,其效果是等待一段时间直到第二个线程结束才正常执行。如果某个线程在另一个线程 t 上调用 t.join() 方法,此线程将被挂起,直到目标线程 t 结束才回复(可以用 t.isAlive() 返回为真假判断)。
也可以在调用 join 时带上一个超时参数,来设置到期时间,时间到期,join方法自动返回。
对 join 的调用也可以被中断,做法是在线程上调用 interrupted
方法,这时需要用到 try…catch 子句
public class TestJoinMethod extends Thread{
@Override
public void run() {
for(int i = 0;i < 5;i++){
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Interrupted sleep");
}
System.out.println(Thread.currentThread() + " " + i);
}
}
public static void main(String[] args) throws InterruptedException {
TestJoinMethod join1 = new TestJoinMethod();
TestJoinMethod join2 = new TestJoinMethod();
TestJoinMethod join3 = new TestJoinMethod();
join1.start();
// join1.join();
join2.start();
join3.start();
}
}
join() 方法等待线程死亡。 换句话说,它会导致当前运行的线程停止执行,直到它加入的线程完成其任务。
线程异常捕获
由于线程的本质,使你不能捕获从线程中逃逸的异常,一旦异常逃出任务的 run 方法,它就会向外传播到控制台,除非你采取特殊的步骤捕获这种错误的异常,在 Java5 之前,你可以通过线程组来捕获,但是在 Java 5 之后,就需要用 Executor 来解决问题,因为线程组不是一次好的尝试。
下面的任务会在 run 方法的执行期间抛出一个异常,并且这个异常会抛到 run 方法的外面,而且 main 方法无法对它进行捕获
public class ExceptionThread implements Runnable{
@Override
public void run() {
throw new RuntimeException();
}
public static void main(String[] args) {
try {
ExecutorService service = Executors.newCachedThreadPool();
service.execute(new ExceptionThread());
}catch (Exception e){
System.out.println("eeeee");
}
}
}
为了解决这个问题,我们需要修改 Executor 产生线程的方式,Java5 提供了一个新的接口 Thread.UncaughtExceptionHandler
,它允许你在每个 Thread 上都附着一个异常处理器。Thread.UncaughtExceptionHandler.uncaughtException()
会在线程因未捕获临近死亡时被调用。
public class ExceptionThread2 implements Runnable{
@Override
public void run() {
Thread t = Thread.currentThread();
System.out.println("run() by " + t);
System.out.println("eh = " + t.getUncaughtExceptionHandler());
// 手动抛出异常
throw new RuntimeException();
}
}
// 实现Thread.UncaughtExceptionHandler 接口,创建异常处理器
public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler{
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println("caught " + e);
}
}
public class HandlerThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
System.out.println(this + " creating new Thread");
Thread t = new Thread(r);
System.out.println("created " + t);
t.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
System.out.println("ex = " + t.getUncaughtExceptionHandler());
return t;
}
}
public class CaptureUncaughtException {
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool(new HandlerThreadFactory());
service.execute(new ExceptionThread2());
}
}
在程序中添加了额外的追踪机制,用来验证工厂创建的线程会传递给UncaughtExceptionHandler
,你可以看到,未捕获的异常是通过 uncaughtException
来捕获的。
看完你就明白的锁系列之自旋锁
自旋锁的提出背景
由于在多处理器环境中某些资源的有限性,有时需要互斥访问(mutual exclusion),这时候就需要引入锁的概念,只有获取了锁的线程才能够对资源进行访问,由于多线程的核心是CPU的时间分片,所以同一时刻只能有一个线程获取到锁。那么就面临一个问题,那么没有获取到锁的线程应该怎么办?
通常有两种处理方式:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING);还有一种处理方式就是把自己阻塞起来,等待重新调度请求,这种叫做互斥锁
。
什么是自旋锁
自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)
。
自旋锁的原理
自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。
因为自旋锁避免了操作系统进程调度和线程切换,所以自旋锁通常适用在时间比较短的情况下。由于这个原因,操作系统的内核经常使用自旋锁。但是,如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被 OS(Operating System)
调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。
解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。但是如何去选择自旋时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!JDK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cpu 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁。
自旋锁的实现
下面我们用Java 代码来实现一个简单的自旋锁
public class SpinLockTest {
private AtomicBoolean available = new AtomicBoolean(false);
public void lock(){
// 循环检测尝试获取锁
while (!tryLock()){
// doSomething...
}
}
public boolean tryLock(){
// 尝试获取锁,成功返回true,失败返回false
return available.compareAndSet(false,true);
}
public void unLock(){
if(!available.compareAndSet(true,false)){
throw new RuntimeException("释放锁失败");
}
}
}
这种简单的自旋锁有一个问题:无法保证多线程竞争的公平性。对于上面的SpinlockTest,当多个线程想要获取锁时,谁最先将available
设为false
谁就能最先获得锁,这可能会造成某些线程一直都未获取到锁造成线程饥饿
。就像我们下课后蜂拥的跑向食堂,下班后蜂拥地挤向地铁,通常我们会采取排队的方式解决这样的问题,类似地,我们把这种锁叫排队自旋锁(QueuedSpinlock)。计算机科学家们使用了各种方式来实现排队自旋锁,如TicketLock,MCSLock,CLHLock。接下来我们分别对这几种锁做个大致的介绍。
TicketLock
在计算机科学领域中,TicketLock 是一种同步机制或锁定算法,它是一种自旋锁,它使用ticket
来控制线程执行顺序。
就像票据队列管理系统一样。面包店或者服务机构(例如银行)都会使用这种方式来为每个先到达的顾客记录其到达的顺序,而不用每次都进行排队。通常,这种地点都会有一个分配器(叫号器,挂号器等等都行),先到的人需要在这个机器上取出自己现在排队的号码,这个号码是按照自增的顺序进行的,旁边还会有一个标牌显示的是正在服务的标志,这通常是代表目前正在服务的队列号,当前的号码完成服务后,标志牌会显示下一个号码可以去服务了。
像上面系统一样,TicketLock 是基于先进先出(FIFO) 队列的机制。它增加了锁的公平性,其设计原则如下:TicketLock 中有两个 int 类型的数值,开始都是0,第一个值是队列ticket(队列票据)
, 第二个值是 出队(票据)
。队列票据是线程在队列中的位置,而出队票据是现在持有锁的票证的队列位置。可能有点模糊不清,简单来说,就是队列票据是你取票号的位置,出队票据是你距离叫号的位置。现在应该明白一些了吧。
当叫号叫到你的时候,不能有相同的号码同时办业务,必须只有一个人可以去办,办完后,叫号机叫到下一个人,这就叫做原子性
。你在办业务的时候不能被其他人所干扰,而且不可能会有两个持有相同号码的人去同时办业务。然后,下一个人看自己的号是否和叫到的号码保持一致,如果一致的话,那么就轮到你去办业务,否则只能继续等待。上面这个流程的关键点在于,每个办业务的人在办完业务之后,他必须丢弃自己的号码,叫号机才能继续叫到下面的人,如果这个人没有丢弃这个号码,那么其他人只能继续等待。下面来实现一下这个票据排队方案
public class TicketLock {
// 队列票据(当前排队号码)
private AtomicInteger queueNum = new AtomicInteger();
// 出队票据(当前需等待号码)
private AtomicInteger dueueNum = new AtomicInteger();
// 获取锁:如果获取成功,返回当前线程的排队号
public int lock(){
int currentTicketNum = dueueNum.incrementAndGet();
while (currentTicketNum != queueNum.get()){
// doSomething...
}
return currentTicketNum;
}
// 释放锁:传入当前排队的号码
public void unLock(int ticketNum){
queueNum.compareAndSet(ticketNum,ticketNum + 1);
}
}
每次叫号机在叫号的时候,都会判断自己是不是被叫的号,并且每个人在办完业务的时候,叫号机根据在当前号码的基础上 + 1,让队列继续往前走。
但是上面这个设计是有问题的,因为获得自己的号码之后,是可以对号码进行更改的,这就造成系统紊乱,锁不能及时释放。这时候就需要有一个能确保每个人按会着自己号码排队办业务的角色,在得知这一点之后,我们重新设计一下这个逻辑
public class TicketLock2 {
// 队列票据(当前排队号码)
private AtomicInteger queueNum = new AtomicInteger();
// 出队票据(当前需等待号码)
private AtomicInteger dueueNum = new AtomicInteger();
private ThreadLocal<Integer> ticketLocal = new ThreadLocal<>();
public void lock(){
int currentTicketNum = dueueNum.incrementAndGet();
// 获取锁的时候,将当前线程的排队号保存起来
ticketLocal.set(currentTicketNum);
while (currentTicketNum != queueNum.get()){
// doSomething...
}
}
// 释放锁:从排队缓冲池中取
public void unLock(){
Integer currentTicket = ticketLocal.get();
queueNum.compareAndSet(currentTicket,currentTicket + 1);
}
}
这次就不再需要返回值,办业务的时候,要将当前的这一个号码缓存起来,在办完业务后,需要释放缓存的这条票据。
**缺点
TicketLock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量queueNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
为了解决这个问题,MCSLock 和 CLHLock 应运而生。
CLHLock
上面说到TicketLock 是基于队列的,那么 CLHLock 就是基于链表设计的,CLH的发明人是:Craig,Landin and Hagersten,用它们各自的字母开头命名。CLH 是一种基于链表的可扩展,高性能,公平的自旋锁,申请线程只能在本地变量上自旋,它会不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
public class CLHLock {
public static class CLHNode{
private volatile boolean isLocked = true;
}
// 尾部节点
private volatile CLHNode tail;
private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<>();
private static final AtomicReferenceFieldUpdater<CLHLock,CLHNode> UPDATER =
AtomicReferenceFieldUpdater.newUpdater(CLHLock.class,CLHNode.class,"tail");
public void lock(){
// 新建节点并将节点与当前线程保存起来
CLHNode node = new CLHNode();
LOCAL.set(node);
// 将新建的节点设置为尾部节点,并返回旧的节点(原子操作),这里旧的节点实际上就是当前节点的前驱节点
CLHNode preNode = UPDATER.getAndSet(this,node);
if(preNode != null){
// 前驱节点不为null表示当锁被其他线程占用,通过不断轮询判断前驱节点的锁标志位等待前驱节点释放锁
while (preNode.isLocked){
}
preNode = null;
LOCAL.set(node);
}
// 如果不存在前驱节点,表示该锁没有被其他线程占用,则当前线程获得锁
}
public void unlock() {
// 获取当前线程对应的节点
CLHNode node = LOCAL.get();
// 如果tail节点等于node,则将tail节点更新为null,同时将node的lock状态职位false,表示当前线程释放了锁
if (!UPDATER.compareAndSet(this, node, null)) {
node.isLocked = false;
}
node = null;
}
}
MCSLock
MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。MCS 来自于其发明人名字的首字母: John Mellor-Crummey和Michael Scott。
public class MCSLock {
public static class MCSNode {
volatile MCSNode next;
volatile boolean isLocked = true;
}
private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<>();
// 队列
@SuppressWarnings("unused")
private volatile MCSNode queue;
private static final AtomicReferenceFieldUpdater<MCSLock,MCSNode> UPDATE =
AtomicReferenceFieldUpdater.newUpdater(MCSLock.class,MCSNode.class,"queue");
public void lock(){
// 创建节点并保存到ThreadLocal中
MCSNode currentNode = new MCSNode();
NODE.set(currentNode);
// 将queue设置为当前节点,并且返回之前的节点
MCSNode preNode = UPDATE.getAndSet(this, currentNode);
if (preNode != null) {
// 如果之前节点不为null,表示锁已经被其他线程持有
preNode.next = currentNode;
// 循环判断,直到当前节点的锁标志位为false
while (currentNode.isLocked) {
}
}
}
public void unlock() {
MCSNode currentNode = NODE.get();
// next为null表示没有正在等待获取锁的线程
if (currentNode.next == null) {
// 更新状态并设置queue为null
if (UPDATE.compareAndSet(this, currentNode, null)) {
// 如果成功了,表示queue==currentNode,即当前节点后面没有节点了
return;
} else {
// 如果不成功,表示queue!=currentNode,即当前节点后面多了一个节点,表示有线程在等待
// 如果当前节点的后续节点为null,则需要等待其不为null(参考加锁方法)
while (currentNode.next == null) {
}
}
} else {
// 如果不为null,表示有线程在等待获取锁,此时将等待线程对应的节点锁状态更新为false,同时将当前线程的后继节点设为null
currentNode.next.isLocked = false;
currentNode.next = null;
}
}
}
CLHLock 和 MCSLock
- 都是基于链表,不同的是CLHLock是基于隐式链表,没有真正的后续节点属性,MCSLock是显示链表,有一个指向后续节点的属性。
- 将获取锁的线程状态借助节点(node)保存,每个线程都有一份独立的节点,这样就解决了TicketLock多处理器缓存同步的问题。
总结
此篇文章我们主要讲述了自旋锁的提出背景,自旋锁是为了提高资源的使用频率而出现的一种锁,自旋锁说的是线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
自旋锁在等待期间不会睡眠或者释放自己的线程。自旋锁不适用于长时间持有CPU的情况,这会加剧系统的负担,为了解决这种情况,需要设定自旋周期,那么自旋周期的设定也是一门学问。
还提到了自旋锁本身无法保证公平性,那么为了保证公平性又引出了TicketLock ,TicketLock 是采用排队叫号的机制来实现的一种公平锁,但是它每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
所以我们又引出了CLHLock和MCSLock,CLHLock和MCSLock通过链表的方式避免了减少了处理器缓存同步,极大的提高了性能,区别在于CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态。
Kafka入门一篇文章就够了
初识 Kafka
什么是 Kafka
Kafka 是由 Linkedin
公司开发的,它是一个分布式的,支持多分区、多副本,基于 Zookeeper 的分布式消息流平台,它同时也是一款开源的基于发布订阅模式的消息引擎系统。
Kafka 的基本术语
消息:Kafka 中的数据单元被称为消息
,也被称为记录,可以把它看作数据库表中某一行的记录。
批次:为了提高效率, 消息会分批次
写入 Kafka,批次就代指的是一组消息。
主题:消息的种类称为 主题
(Topic),可以说一个主题代表了一类消息。相当于是对消息进行分类。主题就像是数据库中的表。
分区:主题可以被分为若干个分区(partition),同一个主题中的分区可以不在一个机器上,有可能会部署在多个机器上,由此来实现 kafka 的伸缩性
,单一主题中的分区有序,但是无法保证主题中所有的分区有序
生产者: 向主题发布消息的客户端应用程序称为生产者
(Producer),生产者用于持续不断的向某个主题发送消息。
消费者:订阅主题消息的客户端程序称为消费者
(Consumer),消费者用于处理生产者产生的消息。
消费者群组:生产者与消费者的关系就如同餐厅中的厨师和顾客之间的关系一样,一个厨师对应多个顾客,也就是一个生产者对应多个消费者,消费者群组
(Consumer Group)指的就是由一个或多个消费者组成的群体。
偏移量:偏移量
(Consumer Offset)是一种元数据,它是一个不断递增的整数值,用来记录消费者发生重平衡时的位置,以便用来恢复数据。
broker: 一个独立的 Kafka 服务器就被称为 broker
,broker 接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存。
broker 集群:broker 是集群
的组成部分,broker 集群由一个或多个 broker 组成,每个集群都有一个 broker 同时充当了集群控制器
的角色(自动从集群的活跃成员中选举出来)。
副本:Kafka 中消息的备份又叫做 副本
(Replica),副本的数量是可以配置的,Kafka 定义了两类副本:领导者副本(Leader Replica) 和 追随者副本(Follower Replica),前者对外提供服务,后者只是被动跟随。
重平衡:Rebalance。消费者组内某个消费者实例挂掉后,其他消费者实例自动重新分配订阅主题分区的过程。Rebalance 是 Kafka 消费者端实现高可用的重要手段。
Kafka 的特性(设计原则)
高吞吐、低延迟
:kakfa 最大的特点就是收发消息非常快,kafka 每秒可以处理几十万条消息,它的最低延迟只有几毫秒。高伸缩性
: 每个主题(topic) 包含多个分区(partition),主题中的分区可以分布在不同的主机(broker)中。持久性、可靠性
: Kafka 能够允许数据的持久化存储,消息被持久化到磁盘,并支持数据备份防止数据丢失,Kafka 底层的数据存储是基于 Zookeeper 存储的,Zookeeper 我们知道它的数据能够持久存储。容错性
: 允许集群中的节点失败,某个节点宕机,Kafka 集群能够正常工作高并发
: 支持数千个客户端同时读写
Kafka 的使用场景
- 活动跟踪:Kafka 可以用来跟踪用户行为,比如我们经常回去淘宝购物,你打开淘宝的那一刻,你的登陆信息,登陆次数都会作为消息传输到 Kafka ,当你浏览购物的时候,你的浏览信息,你的搜索指数,你的购物爱好都会作为一个个消息传递给 Kafka ,这样就可以生成报告,可以做智能推荐,购买喜好等。
- 传递消息:Kafka 另外一个基本用途是传递消息,应用程序向用户发送通知就是通过传递消息来实现的,这些应用组件可以生成消息,而不需要关心消息的格式,也不需要关心消息是如何发送的。
- 度量指标:Kafka也经常用来记录运营监控数据。包括收集各种分布式应用的数据,生产各种操作的集中反馈,比如报警和报告。
- 日志记录:Kafka 的基本概念来源于提交日志,比如我们可以把数据库的更新发送到 Kafka 上,用来记录数据库的更新时间,通过kafka以统一接口服务的方式开放给各种consumer,例如hadoop、Hbase、Solr等。
- 流式处理:流式处理是有一个能够提供多种应用程序的领域。
- 限流削峰:Kafka 多用于互联网领域某一时刻请求特别多的情况下,可以把请求写入Kafka 中,避免直接请求后端程序导致服务崩溃。
Kafka 的消息队列
Kafka 的消息队列一般分为两种模式:点对点模式和发布订阅模式
Kafka 是支持消费者群组的,也就是说 Kafka 中会有一个或者多个消费者,如果一个生产者生产的消息由一个消费者进行消费的话,那么这种模式就是点对点模式
如果一个生产者或者多个生产者产生的消息能够被多个消费者同时消费的情况,这样的消息队列成为发布订阅模式的消息队列
Kafka 系统架构
如上图所示,一个典型的 Kafka 集群中包含若干Producer(可以是web前端产生的Page View,或者是服务器日志,系统CPU、Memory等),若干broker(Kafka支持水平扩展,一般broker数量越多,集群吞吐率越高),若干Consumer Group,以及一个Zookeeper集群。Kafka通过Zookeeper管理集群配置,选举leader,以及在Consumer Group发生变化时进行rebalance。Producer使用push模式将消息发布到broker,Consumer使用pull模式从broker订阅并消费消息。
核心 API
Kafka 有四个核心API,它们分别是
- Producer API,它允许应用程序向一个或多个 topics 上发送消息记录
- Consumer API,允许应用程序订阅一个或多个 topics 并处理为其生成的记录流
- Streams API,它允许应用程序作为流处理器,从一个或多个主题中消费输入流并为其生成输出流,有效的将输入流转换为输出流。
- Connector API,它允许构建和运行将 Kafka 主题连接到现有应用程序或数据系统的可用生产者和消费者。例如,关系数据库的连接器可能会捕获对表的所有更改
Kafka 为何如此之快
Kafka 实现了零拷贝
原理来快速移动数据,避免了内核之间的切换。Kafka 可以将数据记录分批发送,从生产者到文件系统(Kafka 主题日志)到消费者,可以端到端的查看这些批次的数据。
批处理能够进行更有效的数据压缩并减少 I/O 延迟,Kafka 采取顺序写入磁盘的方式,避免了随机磁盘寻址的浪费,更多关于磁盘寻址的了解,请参阅 程序员需要了解的硬核知识之磁盘 。
总结一下其实就是四个要点
- 顺序读写
- 零拷贝
- 消息压缩
- 分批发送
Kafka 安装和重要配置
Kafka 安装我在 Kafka 系列第一篇应该比较详细了,详情见带你涨姿势的认识一下kafka 这篇文章。
那我们还是主要来说一下 Kafka 中的重要参数配置吧,这些参数对 Kafka 来说是非常重要的。
broker 端配置
- broker.id
每个 kafka broker 都有一个唯一的标识来表示,这个唯一的标识符即是 broker.id,它的默认值是 0。这个值在 kafka 集群中必须是唯一的,这个值可以任意设定,
- port
如果使用配置样本来启动 kafka,它会监听 9092 端口。修改 port 配置参数可以把它设置成任意的端口。要注意,如果使用 1024 以下的端口,需要使用 root 权限启动 kakfa。
- zookeeper.connect
用于保存 broker 元数据的 Zookeeper 地址是通过 zookeeper.connect 来指定的。比如我可以这么指定 localhost:2181
表示这个 Zookeeper 是运行在本地 2181 端口上的。我们也可以通过 比如我们可以通过 zk1:2181,zk2:2181,zk3:2181
来指定 zookeeper.connect 的多个参数值。该配置参数是用冒号分割的一组 hostname:port/path
列表,其含义如下
hostname 是 Zookeeper 服务器的机器名或者 ip 地址。
port 是 Zookeeper 客户端的端口号
/path 是可选择的 Zookeeper 路径,Kafka 路径是使用了 chroot
环境,如果不指定默认使用跟路径。
如果你有两套 Kafka 集群,假设分别叫它们 kafka1 和 kafka2,那么两套集群的
zookeeper.connect
参数可以这样指定:zk1:2181,zk2:2181,zk3:2181/kafka1
和zk1:2181,zk2:2181,zk3:2181/kafka2
- log.dirs
Kafka 把所有的消息都保存到磁盘上,存放这些日志片段的目录是通过 log.dirs
来制定的,它是用一组逗号来分割的本地系统路径,log.dirs 是没有默认值的,你必须手动指定他的默认值。其实还有一个参数是 log.dir
,如你所知,这个配置是没有 s
的,默认情况下只用配置 log.dirs 就好了,比如你可以通过 /home/kafka1,/home/kafka2,/home/kafka3
这样来配置这个参数的值。
- num.recovery.threads.per.data.dir
对于如下3种情况,Kafka 会使用可配置的线程池
来处理日志片段。
服务器正常启动,用于打开每个分区的日志片段;
服务器崩溃后重启,用于检查和截断每个分区的日志片段;
服务器正常关闭,用于关闭日志片段。
默认情况下,每个日志目录只使用一个线程。因为这些线程只是在服务器启动和关闭时会用到,所以完全可以设置大量的线程来达到井行操作的目的。特别是对于包含大量分区的服务器来说,一旦发生崩愤,在进行恢复时使用井行操作可能会省下数小时的时间。设置此参数时需要注意,所配置的数字对应的是 log.dirs 指定的单个日志目录。也就是说,如果 num.recovery.threads.per.data.dir 被设为 8,并且 log.dir 指定了 3 个路径,那么总共需要 24 个线程。
- auto.create.topics.enable
默认情况下,kafka 会使用三种方式来自动创建主题,下面是三种情况:
当一个生产者开始往主题写入消息时
当一个消费者开始从主题读取消息时
当任意一个客户端向主题发送元数据请求时
auto.create.topics.enable
参数我建议最好设置成 false,即不允许自动创建 Topic。在我们的线上环境里面有很多名字稀奇古怪的 Topic,我想大概都是因为该参数被设置成了 true 的缘故。
主题默认配置
Kafka 为新创建的主题提供了很多默认配置参数,下面就来一起认识一下这些参数
- num.partitions
num.partitions 参数指定了新创建的主题需要包含多少个分区。如果启用了主题自动创建功能(该功能是默认启用的),主题分区的个数就是该参数指定的值。该参数的默认值是 1。要注意,我们可以增加主题分区的个数,但不能减少分区的个数。
- default.replication.factor
这个参数比较简单,它表示 kafka保存消息的副本数,如果一个副本失效了,另一个还可以继续提供服务default.replication.factor 的默认值为1,这个参数在你启用了主题自动创建功能后有效。
- log.retention.ms
Kafka 通常根据时间来决定数据可以保留多久。默认使用 log.retention.hours 参数来配置时间,默认是 168 个小时,也就是一周。除此之外,还有两个参数 log.retention.minutes 和 log.retentiion.ms 。这三个参数作用是一样的,都是决定消息多久以后被删除,推荐使用 log.retention.ms。
- log.retention.bytes
另一种保留消息的方式是判断消息是否过期。它的值通过参数 log.retention.bytes
来指定,作用在每一个分区上。也就是说,如果有一个包含 8 个分区的主题,并且 log.retention.bytes 被设置为 1GB,那么这个主题最多可以保留 8GB 数据。所以,当主题的分区个数增加时,整个主题可以保留的数据也随之增加。
- log.segment.bytes
上述的日志都是作用在日志片段上,而不是作用在单个消息上。当消息到达 broker 时,它们被追加到分区的当前日志片段上,当日志片段大小到达 log.segment.bytes 指定上限(默认为 1GB)时,当前日志片段就会被关闭,一个新的日志片段被打开。如果一个日志片段被关闭,就开始等待过期。这个参数的值越小,就越会频繁的关闭和分配新文件,从而降低磁盘写入的整体效率。
- log.segment.ms
上面提到日志片段经关闭后需等待过期,那么 log.segment.ms
这个参数就是指定日志多长时间被关闭的参数和,log.segment.ms 和 log.retention.bytes 也不存在互斥问题。日志片段会在大小或时间到达上限时被关闭,就看哪个条件先得到满足。
- message.max.bytes
broker 通过设置 message.max.bytes
参数来限制单个消息的大小,默认是 1000 000, 也就是 1MB,如果生产者尝试发送的消息超过这个大小,不仅消息不会被接收,还会收到 broker 返回的错误消息。跟其他与字节相关的配置参数一样,该参数指的是压缩后的消息大小,也就是说,只要压缩后的消息小于 mesage.max.bytes,那么消息的实际大小可以大于这个值
这个值对性能有显著的影响。值越大,那么负责处理网络连接和请求的线程就需要花越多的时间来处理这些请求。它还会增加磁盘写入块的大小,从而影响 IO 吞吐量。
- retention.ms
规定了该主题消息被保存的时常,默认是7天,即该主题只能保存7天的消息,一旦设置了这个值,它会覆盖掉 Broker 端的全局参数值。
- retention.bytes
retention.bytes
:规定了要为该 Topic 预留多大的磁盘空间。和全局参数作用相似,这个值通常在多租户的 Kafka 集群中会有用武之地。当前默认值是 -1,表示可以无限使用磁盘空间。
JVM 参数配置
JDK 版本一般推荐直接使用 JDK1.8,这个版本也是现在中国大部分程序员的首选版本。
说到 JVM 端设置,就绕不开堆
这个话题,业界最推崇的一种设置方式就是直接将 JVM 堆大小设置为 6GB,这样会避免很多 Bug 出现。
JVM 端配置的另一个重要参数就是垃圾回收器的设置,也就是平时常说的 GC
设置。如果你依然在使用 Java 7,那么可以根据以下法则选择合适的垃圾回收器:
- 如果 Broker 所在机器的 CPU 资源非常充裕,建议使用 CMS 收集器。启用方法是指定
-XX:+UseCurrentMarkSweepGC
。 - 否则,使用吞吐量收集器。开启方法是指定
-XX:+UseParallelGC
。
当然了,如果你已经在使用 Java 8 了,那么就用默认的 G1 收集器就好了。在没有任何调优的情况下,G1 表现得要比 CMS 出色,主要体现在更少的 Full GC,需要调整的参数更少等,所以使用 G1 就好了。
一般 G1 的调整只需要这两个参数即可
- MaxGCPauseMillis
该参数指定每次垃圾回收默认的停顿时间。该值不是固定的,G1可以根据需要使用更长的时间。它的默认值是 200ms,也就是说,每一轮垃圾回收大概需要200 ms 的时间。
- InitiatingHeapOccupancyPercent
该参数指定了 G1 启动新一轮垃圾回收之前可以使用的堆内存百分比,默认值是45,这就表明G1在堆使用率到达45之前不会启用垃圾回收。这个百分比包括新生代和老年代。
Kafka Producer
在 Kafka 中,我们把产生消息的那一方称为生产者
,比如我们经常回去淘宝购物,你打开淘宝的那一刻,你的登陆信息,登陆次数都会作为消息传输到 Kafka 后台,当你浏览购物的时候,你的浏览信息,你的搜索指数,你的购物爱好都会作为一个个消息传递给 Kafka 后台,然后淘宝会根据你的爱好做智能推荐,致使你的钱包从来都禁不住诱惑,那么这些生产者产生的消息
是怎么传到 Kafka 应用程序的呢?发送过程是怎么样的呢?
尽管消息的产生非常简单,但是消息的发送过程还是比较复杂的,如图
我们从创建一个ProducerRecord
对象开始,ProducerRecord 是 Kafka 中的一个核心类,它代表了一组 Kafka 需要发送的 key/value
键值对,它由记录要发送到的主题名称(Topic Name),可选的分区号(Partition Number)以及可选的键值对构成。
在发送 ProducerRecord 时,我们需要将键值对对象由序列化器转换为字节数组,这样它们才能够在网络上传输。然后消息到达了分区器。
如果发送过程中指定了有效的分区号,那么在发送记录时将使用该分区。如果发送过程中未指定分区,则将使用key 的 hash 函数映射指定一个分区。如果发送的过程中既没有分区号也没有,则将以循环的方式分配一个分区。选好分区后,生产者就知道向哪个主题和分区发送数据了。
ProducerRecord 还有关联的时间戳,如果用户没有提供时间戳,那么生产者将会在记录中使用当前的时间作为时间戳。Kafka 最终使用的时间戳取决于 topic 主题配置的时间戳类型。
- 如果将主题配置为使用
CreateTime
,则生产者记录中的时间戳将由 broker 使用。 - 如果将主题配置为使用
LogAppendTime
,则生产者记录中的时间戳在将消息添加到其日志中时,将由 broker 重写。
然后,这条消息被存放在一个记录批次里,这个批次里的所有消息会被发送到相同的主题和分区上。由一个独立的线程负责把它们发到 Kafka Broker 上。
Kafka Broker 在收到消息时会返回一个响应,如果写入成功,会返回一个 RecordMetaData 对象,它包含了主题和分区信息,以及记录在分区里的偏移量,上面两种的时间戳类型也会返回给用户。如果写入失败,会返回一个错误。生产者在收到错误之后会尝试重新发送消息,几次之后如果还是失败的话,就返回错误消息。
创建 Kafka 生产者
要向 Kafka 写入消息,首先需要创建一个生产者对象,并设置一些属性。Kafka 生产者有3个必选的属性
- bootstrap.servers
该属性指定 broker 的地址清单,地址的格式为 host:port
。清单里不需要包含所有的 broker 地址,生产者会从给定的 broker 里查找到其他的 broker 信息。不过建议至少要提供两个
broker 信息,一旦其中一个宕机,生产者仍然能够连接到集群上。
- key.serializer
broker 需要接收到序列化之后的 key/value
值,所以生产者发送的消息需要经过序列化之后才传递给 Kafka Broker。生产者需要知道采用何种方式把 Java 对象转换为字节数组。key.serializer 必须被设置为一个实现了org.apache.kafka.common.serialization.Serializer
接口的类,生产者会使用这个类把键对象序列化为字节数组。这里拓展一下 Serializer 类
Serializer 是一个接口,它表示类将会采用何种方式序列化,它的作用是把对象转换为字节,实现了 Serializer 接口的类主要有 ByteArraySerializer
、StringSerializer
、IntegerSerializer
,其中 ByteArraySerialize 是 Kafka 默认使用的序列化器,其他的序列化器还有很多,你可以通过 这里 查看其他序列化器。要注意的一点:key.serializer 是必须要设置的,即使你打算只发送值的内容。
- value.serializer
与 key.serializer 一样,value.serializer 指定的类会将值序列化。
下面代码演示了如何创建一个 Kafka 生产者,这里只指定了必要的属性,其他使用默认的配置
private Properties properties = new Properties();
properties.put("bootstrap.servers","broker1:9092,broker2:9092");
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties = new KafkaProducer<String,String>(properties);
来解释一下这段代码
- 首先创建了一个 Properties 对象
- 使用
StringSerializer
序列化器序列化 key / value 键值对 - 在这里我们创建了一个新的生产者对象,并为键值设置了恰当的类型,然后把 Properties 对象传递给他。
Kafka 消息发送
实例化生产者对象后,接下来就可以开始发送消息了,发送消息主要由下面几种方式
简单消息发送
Kafka 最简单的消息发送如下:
ProducerRecord<String,String> record =
new ProducerRecord<String, String>("CustomerCountry","West","France");
producer.send(record);
代码中生产者(producer)的 send()
方法需要把 ProducerRecord
的对象作为参数进行发送,ProducerRecord 有很多构造函数,这个我们下面讨论,这里调用的是
public ProducerRecord(String topic, K key, V value) {}
这个构造函数,需要传递的是 topic主题,key 和 value。
把对应的参数传递完成后,生产者调用 send() 方法发送消息(ProducerRecord对象)。我们可以从生产者的架构图中看出,消息是先被写入分区中的缓冲区中,然后分批次发送给 Kafka Broker。
发送成功后,send() 方法会返回一个 Future(java.util.concurrent)
对象,Future 对象的类型是 RecordMetadata
类型,我们上面这段代码没有考虑返回值,所以没有生成对应的 Future 对象,所以没有办法知道消息是否发送成功。如果不是很重要的信息或者对结果不会产生影响的信息,可以使用这种方式进行发送。
我们可以忽略发送消息时可能发生的错误或者在服务器端可能发生的错误,但在消息发送之前,生产者还可能发生其他的异常。这些异常有可能是 SerializationException(序列化失败)
,BufferedExhaustedException 或 TimeoutException(说明缓冲区已满)
,又或是 InterruptedException(说明发送线程被中断)
同步发送消息
第二种消息发送机制如下所示
ProducerRecord<String,String> record =
new ProducerRecord<String, String>("CustomerCountry","West","France");
try{
RecordMetadata recordMetadata = producer.send(record).get();
}catch(Exception e){
e.printStackTrace();
}
这种发送消息的方式较上面的发送方式有了改进,首先调用 send() 方法,然后再调用 get() 方法等待 Kafka 响应。如果服务器返回错误,get() 方法会抛出异常,如果没有发生错误,我们会得到 RecordMetadata
对象,可以用它来查看消息记录。
生产者(KafkaProducer)在发送的过程中会出现两类错误:其中一类是重试错误,这类错误可以通过重发消息来解决。比如连接的错误,可以通过再次建立连接来解决;无主
错误则可以通过重新为分区选举首领来解决。KafkaProducer 被配置为自动重试,如果多次重试后仍无法解决问题,则会抛出重试异常。另一类错误是无法通过重试来解决的,比如消息过大
对于这类错误,KafkaProducer 不会进行重试,直接抛出异常。
异步发送消息
同步发送消息都有个问题,那就是同一时间只能有一个消息在发送,这会造成许多消息无法直接发送,造成消息滞后,无法发挥效益最大化。
比如消息在应用程序和 Kafka 集群之间一个来回需要 10ms。如果发送完每个消息后都等待响应的话,那么发送100个消息需要 1 秒,但是如果是异步
方式的话,发送 100 条消息所需要的时间就会少很多很多。大多数时候,虽然Kafka 会返回 RecordMetadata
消息,但是我们并不需要等待响应。
为了在异步发送消息的同时能够对异常情况进行处理,生产者提供了回掉支持。下面是回调的一个例子
ProducerRecord<String, String> producerRecord = new ProducerRecord<String, String>("CustomerCountry", "Huston", "America");
producer.send(producerRecord,new DemoProducerCallBack());
class DemoProducerCallBack implements Callback {
public void onCompletion(RecordMetadata metadata, Exception exception) {
if(exception != null){
exception.printStackTrace();;
}
}
}
首先实现回调需要定义一个实现了org.apache.kafka.clients.producer.Callback
的类,这个接口只有一个 onCompletion
方法。如果 kafka 返回一个错误,onCompletion 方法会抛出一个非空(non null)异常,这里我们只是简单的把它打印出来,如果是生产环境需要更详细的处理,然后在 send() 方法发送的时候传递一个 Callback 回调的对象。
生产者分区机制
Kafka 对于数据的读写是以分区
为粒度的,分区可以分布在多个主机(Broker)中,这样每个节点能够实现独立的数据写入和读取,并且能够通过增加新的节点来增加 Kafka 集群的吞吐量,通过分区部署在多个 Broker 来实现负载均衡
的效果。
上面我们介绍了生产者的发送方式有三种:不管结果如何直接发送
、发送并返回结果
、发送并回调
。由于消息是存在主题(topic)的分区(partition)中的,所以当 Producer 生产者发送产生一条消息发给 topic 的时候,你如何判断这条消息会存在哪个分区中呢?
这其实就设计到 Kafka 的分区机制了。
分区策略
Kafka 的分区策略指的就是将生产者发送到哪个分区的算法。Kafka 为我们提供了默认的分区策略,同时它也支持你自定义分区策略。
如果要自定义分区策略的话,你需要显示配置生产者端的参数 Partitioner.class
,我们可以看一下这个类它位于 org.apache.kafka.clients.producer
包下
public interface Partitioner extends Configurable, Closeable {
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);
public void close();
default public void onNewBatch(String topic, Cluster cluster, int prevPartition) {}
}
Partitioner 类有三个方法,分别来解释一下
- partition(): 这个类有几个参数:
topic
,表示需要传递的主题;key
表示消息中的键值;keyBytes
表示分区中序列化过后的key,byte数组的形式传递;value
表示消息的 value 值;valueBytes
表示分区中序列化后的值数组;cluster
表示当前集群的原数据。Kafka 给你这么多信息,就是希望让你能够充分地利用这些信息对消息进行分区,计算出它要被发送到哪个分区中。 - close() : 继承了
Closeable
接口能够实现 close() 方法,在分区关闭时调用。 - onNewBatch(): 表示通知分区程序用来创建新的批次
其中与分区策略息息相关的就是 partition() 方法了,分区策略有下面这几种
**顺序轮询
顺序分配,消息是均匀的分配给每个 partition,即每个分区存储一次消息。就像下面这样
上图表示的就是轮询策略,轮训策略是 Kafka Producer 提供的默认策略,如果你不使用指定的轮训策略的话,Kafka 默认会使用顺序轮训策略的方式。
**随机轮询
随机轮询简而言之就是随机的向 partition 中保存消息,如下图所示
实现随机分配的代码只需要两行,如下
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return ThreadLocalRandom.current().nextInt(partitions.size());
先计算出该主题总的分区数,然后随机地返回一个小于它的正整数。
本质上看随机策略也是力求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以如果追求数据的均匀分布,还是使用轮询策略比较好。事实上,随机策略是老版本生产者使用的分区策略,在新版本中已经改为轮询了。
**按照 key 进行消息保存
这个策略也叫做 key-ordering 策略,Kafka 中每条消息都会有自己的key,一旦消息被定义了 Key,那么你就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略,如下图所示
实现这个策略的 partition 方法同样简单,只需要下面两行代码即可:
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return Math.abs(key.hashCode()) % partitions.size();
上面这几种分区策略都是比较基础的策略,除此之外,你还可以自定义分区策略。
生产者压缩机制
压缩一词简单来讲就是一种互换思想,它是一种经典的用 CPU 时间去换磁盘空间或者 I/O 传输量的思想,希望以较小的 CPU 开销带来更少的磁盘占用或更少的网络 I/O 传输。如果你还不了解的话我希望你先读完这篇文章 程序员需要了解的硬核知识之压缩算法,然后你就明白压缩是怎么回事了。
Kafka 压缩是什么
Kafka 的消息分为两层:消息集合 和 消息。一个消息集合中包含若干条日志项,而日志项才是真正封装消息的地方。Kafka 底层的消息日志由一系列消息集合日志项组成。Kafka 通常不会直接操作具体的一条条消息,它总是在消息集合这个层面上进行写入
操作。
在 Kafka 中,压缩会发生在两个地方:Kafka Producer 和 Kafka Consumer,为什么启用压缩?说白了就是消息太大,需要变小一点
来使消息发的更快一些。
Kafka Producer 中使用 compression.type
来开启压缩
private Properties properties = new Properties();
properties.put("bootstrap.servers","192.168.1.9:9092");
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("compression.type", "gzip");
Producer<String,String> producer = new KafkaProducer<String, String>(properties);
ProducerRecord<String,String> record =
new ProducerRecord<String, String>("CustomerCountry","Precision Products","France");
上面代码表明该 Producer 的压缩算法使用的是 GZIP
有压缩必有解压缩,Producer 使用压缩算法压缩消息后并发送给服务器后,由 Consumer 消费者进行解压缩,因为采用的何种压缩算法是随着 key、value 一起发送过去的,所以消费者知道采用何种压缩算法。
Kafka 重要参数配置
在上一篇文章 带你涨姿势的认识一下kafka中,我们主要介绍了一下 kafka 集群搭建的参数,本篇文章我们来介绍一下 Kafka 生产者重要的配置,生产者有很多可配置的参数,在文档里(http://kafka.apache.org/documentation/#producerconfigs)都有说明,我们介绍几个在内存使用、性能和可靠性方面对生产者影响比较大的参数进行说明
**key.serializer
用于 key 键的序列化,它实现了 org.apache.kafka.common.serialization.Serializer
接口
**value.serializer
用于 value 值的序列化,实现了 org.apache.kafka.common.serialization.Serializer
接口
**acks
acks 参数指定了要有多少个分区副本接收消息,生产者才认为消息是写入成功的。此参数对消息丢失的影响较大
- 如果 acks = 0,就表示生产者也不知道自己产生的消息是否被服务器接收了,它才知道它写成功了。如果发送的途中产生了错误,生产者也不知道,它也比较懵逼,因为没有返回任何消息。这就类似于 UDP 的运输层协议,只管发,服务器接受不接受它也不关心。
- 如果 acks = 1,只要集群的 Leader 接收到消息,就会给生产者返回一条消息,告诉它写入成功。如果发送途中造成了网络异常或者 Leader 还没选举出来等其他情况导致消息写入失败,生产者会受到错误消息,这时候生产者往往会再次重发数据。因为消息的发送也分为
同步
和异步
,Kafka 为了保证消息的高效传输会决定是同步发送还是异步发送。如果让客户端等待服务器的响应(通过调用Future
中的get()
方法),显然会增加延迟,如果客户端使用回调,就会解决这个问题。 - 如果 acks = all,这种情况下是只有当所有参与复制的节点都收到消息时,生产者才会接收到一个来自服务器的消息。不过,它的延迟比 acks =1 时更高,因为我们要等待不只一个服务器节点接收消息。
**buffer.memory
此参数用来设置生产者内存缓冲区的大小,生产者用它缓冲要发送到服务器的消息。如果应用程序发送消息的速度超过发送到服务器的速度,会导致生产者空间不足。这个时候,send() 方法调用要么被阻塞,要么抛出异常,具体取决于 block.on.buffer.null
参数的设置。
**compression.type
此参数来表示生产者启用何种压缩算法,默认情况下,消息发送时不会被压缩。该参数可以设置为 snappy、gzip 和 lz4,它指定了消息发送给 broker 之前使用哪一种压缩算法进行压缩。下面是各压缩算法的对比
**retries
生产者从服务器收到的错误有可能是临时性的错误(比如分区找不到首领),在这种情况下,reteis
参数的值决定了生产者可以重发的消息次数,如果达到这个次数,生产者会放弃重试并返回错误。默认情况下,生产者在每次重试之间等待 100ms,这个等待参数可以通过 retry.backoff.ms
进行修改。
**batch.size
当有多个消息需要被发送到同一个分区时,生产者会把它们放在同一个批次里。该参数指定了一个批次可以使用的内存大小,按照字节数计算。当批次被填满,批次里的所有消息会被发送出去。不过生产者井不一定都会等到批次被填满才发送,任意条数的消息都可能被发送。
**client.id
此参数可以是任意的字符串,服务器会用它来识别消息的来源,一般配置在日志里
**max.in.flight.requests.per.connection
此参数指定了生产者在收到服务器响应之前可以发送多少消息,它的值越高,就会占用越多的内存,不过也会提高吞吐量。把它设为1 可以保证消息是按照发送的顺序写入服务器。
**timeout.ms、request.timeout.ms 和 metadata.fetch.timeout.ms
request.timeout.ms 指定了生产者在发送数据时等待服务器返回的响应时间,metadata.fetch.timeout.ms 指定了生产者在获取元数据(比如目标分区的首领是谁)时等待服务器返回响应的时间。如果等待时间超时,生产者要么重试发送数据,要么返回一个错误。timeout.ms 指定了 broker 等待同步副本返回消息确认的时间,与 asks 的配置相匹配—-如果在指定时间内没有收到同步副本的确认,那么 broker 就会返回一个错误。
**max.block.ms
此参数指定了在调用 send() 方法或使用 partitionFor() 方法获取元数据时生产者的阻塞时间当生产者的发送缓冲区已捕,或者没有可用的元数据时,这些方法就会阻塞。在阻塞时间达到 max.block.ms 时,生产者会抛出超时异常。
**max.request.size
该参数用于控制生产者发送的请求大小。它可以指能发送的单个消息的最大值,也可以指单个请求里所有消息的总大小。
**receive.buffer.bytes 和 send.buffer.bytes
Kafka 是基于 TCP 实现的,为了保证可靠的消息传输,这两个参数分别指定了 TCP Socket 接收和发送数据包的缓冲区的大小。如果它们被设置为 -1,就使用操作系统的默认值。如果生产者或消费者与 broker 处于不同的数据中心,那么可以适当增大这些值。
Kafka Consumer
应用程序使用 KafkaConsumer
从 Kafka 中订阅主题并接收来自这些主题的消息,然后再把他们保存起来。应用程序首先需要创建一个 KafkaConsumer 对象,订阅主题并开始接受消息,验证消息并保存结果。一段时间后,生产者往主题写入的速度超过了应用程序验证数据的速度,这时候该如何处理?如果只使用单个消费者的话,应用程序会跟不上消息生成的速度,就像多个生产者像相同的主题写入消息一样,这时候就需要多个消费者共同参与消费主题中的消息,对消息进行分流处理。
Kafka 消费者从属于消费者群组
。一个群组中的消费者订阅的都是相同
的主题,每个消费者接收主题一部分分区的消息。下面是一个 Kafka 分区消费示意图
上图中的主题 T1 有四个分区,分别是分区0、分区1、分区2、分区3,我们创建一个消费者群组1,消费者群组中只有一个消费者,它订阅主题T1,接收到 T1 中的全部消息。由于一个消费者处理四个生产者发送到分区的消息,压力有些大,需要帮手来帮忙分担任务,于是就演变为下图
这样一来,消费者的消费能力就大大提高了,但是在某些环境下比如用户产生消息特别多的时候,生产者产生的消息仍旧让消费者吃不消,那就继续增加消费者。
如上图所示,每个分区所产生的消息能够被每个消费者群组中的消费者消费,如果向消费者群组中增加更多的消费者,那么多余的消费者将会闲置,如下图所示
向群组中增加消费者是横向伸缩消费能力的主要方式。总而言之,我们可以通过增加消费组的消费者来进行水平扩展提升消费能力
。这也是为什么建议创建主题时使用比较多的分区数,这样可以在消费负载高的情况下增加消费者来提升性能。另外,消费者的数量不应该比分区数多,因为多出来的消费者是空闲的,没有任何帮助。
Kafka 一个很重要的特性就是,只需写入一次消息,可以支持任意多的应用读取这个消息。换句话说,每个应用都可以读到全量的消息。为了使得每个应用都能读到全量消息,应用需要有不同的消费组。对于上面的例子,假如我们新增了一个新的消费组 G2,而这个消费组有两个消费者,那么就演变为下图这样
在这个场景中,消费组 G1 和消费组 G2 都能收到 T1 主题的全量消息,在逻辑意义上来说它们属于不同的应用。
总结起来就是如果应用需要读取全量消息,那么请为该应用设置一个消费组;如果该应用消费能力不足,那么可以考虑在这个消费组里增加消费者。
消费者组和分区重平衡
消费者组是什么
消费者组(Consumer Group)
是由一个或多个消费者实例(Consumer Instance)组成的群组,具有可扩展性和可容错性的一种机制。消费者组内的消费者共享
一个消费者组ID,这个ID 也叫做 Group ID
,组内的消费者共同对一个主题进行订阅和消费,同一个组中的消费者只能消费一个分区的消息,多余的消费者会闲置,派不上用场。
我们在上面提到了两种消费方式
- 一个消费者群组消费一个主题中的消息,这种消费模式又称为
点对点
的消费方式,点对点的消费方式又被称为消息队列 - 一个主题中的消息被多个消费者群组共同消费,这种消费模式又称为
发布-订阅
模式
消费者重平衡
我们从上面的消费者演变图
中可以知道这么一个过程:最初是一个消费者订阅一个主题并消费其全部分区的消息,后来有一个消费者加入群组,随后又有更多的消费者加入群组,而新加入的消费者实例分摊
了最初消费者的部分消息,这种把分区的所有权通过一个消费者转到其他消费者的行为称为重平衡
,英文名也叫做 Rebalance
。如下图所示
重平衡非常重要,它为消费者群组带来了高可用性
和 伸缩性
,我们可以放心的添加消费者或移除消费者,不过在正常情况下我们并不希望发生这样的行为。在重平衡期间,消费者无法读取消息,造成整个消费者组在重平衡的期间都不可用。另外,当分区被重新分配给另一个消费者时,消息当前的读取状态会丢失,它有可能还需要去刷新缓存,在它重新恢复状态之前会拖慢应用程序。
消费者通过向组织协调者
(Kafka Broker)发送心跳来维护自己是消费者组的一员并确认其拥有的分区。对于不同不的消费群体来说,其组织协调者可以是不同的。只要消费者定期发送心跳,就会认为消费者是存活的并处理其分区中的消息。当消费者检索记录或者提交它所消费的记录时就会发送心跳。
如果过了一段时间 Kafka 停止发送心跳了,会话(Session)就会过期,组织协调者就会认为这个 Consumer 已经死亡,就会触发一次重平衡。如果消费者宕机并且停止发送消息,组织协调者会等待几秒钟,确认它死亡了才会触发重平衡。在这段时间里,死亡的消费者将不处理任何消息。在清理消费者时,消费者将通知协调者它要离开群组,组织协调者会触发一次重平衡,尽量降低处理停顿。
重平衡是一把双刃剑,它为消费者群组带来高可用性和伸缩性的同时,还有有一些明显的缺点(bug),而这些 bug 到现在社区还无法修改。
重平衡的过程对消费者组有极大的影响。因为每次重平衡过程中都会导致万物静止,参考 JVM 中的垃圾回收机制,也就是 Stop The World ,STW,(引用自《深入理解 Java 虚拟机》中 p76 关于 Serial 收集器的描述):
更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程。直到它收集结束。
Stop The World
这个名字听起来很帅,但这项工作实际上是由虚拟机在后台自动发起并完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。
也就是说,在重平衡期间,消费者组中的消费者实例都会停止消费,等待重平衡的完成。而且重平衡这个过程很慢……
创建消费者
上面的理论说的有点多,下面就通过代码来讲解一下消费者是如何消费的
在读取消息之前,需要先创建一个 KafkaConsumer
对象。创建 KafkaConsumer 对象与创建 KafkaProducer 对象十分相似 — 把需要传递给消费者的属性放在 properties
对象中,后面我们会着重讨论 Kafka 的一些配置,这里我们先简单的创建一下,使用3个属性就足矣,分别是 bootstrap.server
,key.deserializer
,value.deserializer
。
这三个属性我们已经用过很多次了,如果你还不是很清楚的话,可以参考 带你涨姿势是认识一下Kafka Producer
还有一个属性是 group.id
这个属性不是必须的,它指定了 KafkaConsumer 是属于哪个消费者群组。创建不属于任何一个群组的消费者也是可以的
Properties properties = new Properties();
properties.put("bootstrap.server","192.168.1.9:9092"); properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer"); properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
KafkaConsumer<String,String> consumer = new KafkaConsumer<>(properties);
主题订阅
创建好消费者之后,下一步就开始订阅主题了。subscribe()
方法接受一个主题列表作为参数,使用起来比较简单
consumer.subscribe(Collections.singletonList("customerTopic"));
为了简单我们只订阅了一个主题 customerTopic
,参数传入的是一个正则表达式,正则表达式可以匹配多个主题,如果有人创建了新的主题,并且主题的名字与正则表达式相匹配,那么会立即触发一次重平衡,消费者就可以读取新的主题。
要订阅所有与 test 相关的主题,可以这样做
consumer.subscribe("test.*");
轮询
我们知道,Kafka 是支持订阅/发布模式的,生产者发送数据给 Kafka Broker,那么消费者是如何知道生产者发送了数据呢?其实生产者产生的数据消费者是不知道的,KafkaConsumer 采用轮询的方式定期去 Kafka Broker 中进行数据的检索,如果有数据就用来消费,如果没有就再继续轮询等待,下面是轮询等待的具体实现
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(100));
for (ConsumerRecord<String, String> record : records) {
int updateCount = 1;
if (map.containsKey(record.value())) {
updateCount = (int) map.get(record.value() + 1);
}
map.put(record.value(), updateCount);
}
}
}finally {
consumer.close();
}
- 这是一个无限循环。消费者实际上是一个长期运行的应用程序,它通过轮询的方式向 Kafka 请求数据。
- 第三行代码非常重要,Kafka 必须定期循环请求数据,否则就会认为该 Consumer 已经挂了,会触发重平衡,它的分区会移交给群组中的其它消费者。传给
poll()
方法的是一个超市时间,用java.time.Duration
类来表示,如果该参数被设置为 0 ,poll() 方法会立刻返回,否则就会在指定的毫秒数内一直等待 broker 返回数据。 - poll() 方法会返回一个记录列表。每条记录都包含了记录所属主题的信息,记录所在分区的信息、记录在分区中的偏移量,以及记录的键值对。我们一般会遍历这个列表,逐条处理每条记录。
- 在退出应用程序之前使用
close()
方法关闭消费者。网络连接和 socket 也会随之关闭,并立即触发一次重平衡,而不是等待群组协调器发现它不再发送心跳并认定它已经死亡。
**线程安全性
在同一个群组中,我们无法让一个线程运行多个消费者,也无法让多个线程安全的共享一个消费者。按照规则,一个消费者使用一个线程,如果一个消费者群组中多个消费者都想要运行的话,那么必须让每个消费者在自己的线程中运行,可以使用 Java 中的
ExecutorService
启动多个消费者进行进行处理。
消费者配置
到目前为止,我们学习了如何使用消费者 API,不过只介绍了几个最基本的属性,Kafka 文档列出了所有与消费者相关的配置说明。大部分参数都有合理的默认值,一般不需要修改它们,下面我们就来介绍一下这些参数。
- fetch.min.bytes
该属性指定了消费者从服务器获取记录的最小字节数。broker 在收到消费者的数据请求时,如果可用的数据量小于 fetch.min.bytes
指定的大小,那么它会等到有足够的可用数据时才把它返回给消费者。这样可以降低消费者和 broker 的工作负载,因为它们在主题使用频率不是很高的时候就不用来回处理消息。如果没有很多可用数据,但消费者的 CPU 使用率很高,那么就需要把该属性的值设得比默认值大。如果消费者的数量比较多,把该属性的值调大可以降低 broker 的工作负载。
- fetch.max.wait.ms
我们通过上面的 fetch.min.bytes 告诉 Kafka,等到有足够的数据时才会把它返回给消费者。而 fetch.max.wait.ms 则用于指定 broker 的等待时间,默认是 500 毫秒。如果没有足够的数据流入 kafka 的话,消费者获取的最小数据量要求就得不到满足,最终导致 500 毫秒的延迟。如果要降低潜在的延迟,就可以把参数值设置的小一些。如果 fetch.max.wait.ms 被设置为 100 毫秒的延迟,而 fetch.min.bytes 的值设置为 1MB,那么 Kafka 在收到消费者请求后,要么返回 1MB 的数据,要么在 100 ms 后返回所有可用的数据。就看哪个条件首先被满足。
- max.partition.fetch.bytes
该属性指定了服务器从每个分区里返回给消费者的最大字节数
。它的默认值时 1MB,也就是说,KafkaConsumer.poll()
方法从每个分区里返回的记录最多不超过 max.partition.fetch.bytes 指定的字节。如果一个主题有20个分区和5个消费者,那么每个消费者需要至少
4 MB的可用内存来接收记录。在为消费者分配内存时,可以给它们多分配一些,因为如果群组里有消费者发生崩溃,剩下的消费者需要处理更多的分区。max.partition.fetch.bytes 的值必须比 broker 能够接收的最大消息的字节数(通过 max.message.size 属性配置大),否则消费者可能无法读取这些消息,导致消费者一直挂起重试。 在设置该属性时,另外一个考量的因素是消费者处理数据的时间。消费者需要频繁的调用 poll() 方法来避免会话过期和发生分区再平衡,如果单次调用poll() 返回的数据太多,消费者需要更多的时间进行处理,可能无法及时进行下一个轮询来避免会话过期。如果出现这种情况,可以把 max.partition.fetch.bytes 值改小,或者延长会话过期时间。
- session.timeout.ms
这个属性指定了消费者在被认为死亡之前可以与服务器断开连接的时间,默认是 3s。如果消费者没有在 session.timeout.ms 指定的时间内发送心跳给群组协调器,就会被认定为死亡,协调器就会触发重平衡。把它的分区分配给消费者群组中的其它消费者,此属性与 heartbeat.interval.ms
紧密相关。heartbeat.interval.ms 指定了 poll() 方法向群组协调器发送心跳的频率,session.timeout.ms 则指定了消费者可以多久不发送心跳。所以,这两个属性一般需要同时修改,heartbeat.interval.ms 必须比 session.timeout.ms 小,一般是 session.timeout.ms 的三分之一。如果 session.timeout.ms 是 3s,那么 heartbeat.interval.ms 应该是 1s。把 session.timeout.ms 值设置的比默认值小,可以更快地检测和恢复崩愤的节点,不过长时间的轮询或垃圾收集可能导致非预期的重平衡。把该属性的值设置得大一些,可以减少意外的重平衡,不过检测节点崩溃需要更长的时间。
- auto.offset.reset
该属性指定了消费者在读取一个没有偏移量的分区或者偏移量无效的情况下的该如何处理。它的默认值是 latest
,意思指的是,在偏移量无效的情况下,消费者将从最新的记录开始读取数据。另一个值是 earliest
,意思指的是在偏移量无效的情况下,消费者将从起始位置处开始读取分区的记录。
- enable.auto.commit
我们稍后将介绍几种不同的提交偏移量的方式。该属性指定了消费者是否自动提交偏移量,默认值是 true,为了尽量避免出现重复数据和数据丢失,可以把它设置为 false,由自己控制何时提交偏移量。如果把它设置为 true,还可以通过 auto.commit.interval.ms 属性来控制提交的频率
- partition.assignment.strategy
我们知道,分区会分配给群组中的消费者。PartitionAssignor
会根据给定的消费者和主题,决定哪些分区应该被分配给哪个消费者,Kafka 有两个默认的分配策略Range
和 RoundRobin
- client.id
该属性可以是任意字符串,broker 用他来标识从客户端发送过来的消息,通常被用在日志、度量指标和配额中
- max.poll.records
该属性用于控制单次调用 call() 方法能够返回的记录数量,可以帮你控制在轮询中需要处理的数据量。
- receive.buffer.bytes 和 send.buffer.bytes
socket 在读写数据时用到的 TCP 缓冲区也可以设置大小。如果它们被设置为 -1,就使用操作系统默认值。如果生产者或消费者与 broker 处于不同的数据中心内,可以适当增大这些值,因为跨数据中心的网络一般都有比较高的延迟和比较低的带宽。
提交和偏移量的概念
特殊偏移
我们上面提到,消费者在每次调用poll()
方法进行定时轮询的时候,会返回由生产者写入 Kafka 但是还没有被消费者消费的记录,因此我们可以追踪到哪些记录是被群组里的哪个消费者读取的。消费者可以使用 Kafka 来追踪消息在分区中的位置(偏移量)
消费者会向一个叫做 _consumer_offset
的特殊主题中发送消息,这个主题会保存每次所发送消息中的分区偏移量,这个主题的主要作用就是消费者触发重平衡后记录偏移使用的,消费者每次向这个主题发送消息,正常情况下不触发重平衡,这个主题是不起作用的,当触发重平衡后,消费者停止工作,每个消费者可能会分到对应的分区,这个主题就是让消费者能够继续处理消息所设置的。
如果提交的偏移量小于客户端最后一次处理的偏移量,那么位于两个偏移量之间的消息就会被重复处理
如果提交的偏移量大于最后一次消费时的偏移量,那么处于两个偏移量中间的消息将会丢失
既然_consumer_offset
如此重要,那么它的提交方式是怎样的呢?下面我们就来说一下####提交方式
KafkaConsumer API 提供了多种方式来提交偏移量
自动提交
最简单的方式就是让消费者自动提交偏移量。如果 enable.auto.commit
被设置为true,那么每过 5s,消费者会自动把从 poll() 方法轮询到的最大偏移量提交上去。提交时间间隔由 auto.commit.interval.ms
控制,默认是 5s。与消费者里的其他东西一样,自动提交也是在轮询中进行的。消费者在每次轮询中会检查是否提交该偏移量了,如果是,那么就会提交从上一次轮询中返回的偏移量。
提交当前偏移量
把 auto.commit.offset
设置为 false,可以让应用程序决定何时提交偏移量。使用 commitSync()
提交偏移量。这个 API 会提交由 poll() 方法返回的最新偏移量,提交成功后马上返回,如果提交失败就抛出异常。
commitSync() 将会提交由 poll() 返回的最新偏移量,如果处理完所有记录后要确保调用了 commitSync(),否则还是会有丢失消息的风险,如果发生了在均衡,从最近一批消息到发生在均衡之间的所有消息都将被重复处理。
异步提交
异步提交 commitAsync()
与同步提交 commitSync()
最大的区别在于异步提交不会进行重试,同步提交会一致进行重试。
同步和异步组合提交
一般情况下,针对偶尔出现的提交失败,不进行重试不会有太大的问题,因为如果提交失败是因为临时问题导致的,那么后续的提交总会有成功的。但是如果在关闭消费者或再均衡前的最后一次提交,就要确保提交成功。
因此,在消费者关闭之前一般会组合使用commitAsync和commitSync提交偏移量。
提交特定的偏移量
消费者API允许调用 commitSync() 和 commitAsync() 方法时传入希望提交的 partition 和 offset 的 map,即提交特定的偏移量。
深入Kafka
如果只是为了开发 Kafka 应用程序,或者只是在生产环境使用 Kafka,那么了解 Kafka 的内部工作原理不是必须的。不过,了解 Kafka 的内部工作原理有助于理解 Kafka 的行为,也利用快速诊断问题。下面我们来探讨一下这三个问题
- Kafka 是如何进行复制的
- Kafka 是如何处理来自生产者和消费者的请求的
- Kafka 的存储细节是怎样的
如果感兴趣的话,就请花费你一些时间,耐心看完这篇文章。
集群成员间的关系
我们知道,Kafka 是运行在 ZooKeeper 之上的,因为 ZooKeeper 是以集群形式出现的,所以 Kafka 也可以以集群形式出现。这也就涉及到多个生产者和多个消费者如何协调的问题,这个维护集群间的关系也是由 ZooKeeper 来完成的。如果你看过我之前的文章(真的,关于 Kafka 入门看这一篇就够了),你应该会知道,Kafka 集群间会有多个 主机(broker)
,每个 broker 都会有一个 broker.id
,每个 broker.id 都有一个唯一的标识符用来区分,这个标识符可以在配置文件里手动指定,也可以自动生成。
Kafka 可以通过 broker.id.generation.enable 和 reserved.broker.max.id 来配合生成新的 broker.id。
broker.id.generation.enable参数是用来配置是否开启自动生成 broker.id 的功能,默认情况下为true,即开启此功能。自动生成的broker.id有一个默认值,默认值为1000,也就是说默认情况下自动生成的 broker.id 从1001开始。
Kafka 在启动时会在 ZooKeeper 中 /brokers/ids
路径下注册一个与当前 broker 的 id 相同的临时节点。Kafka 的健康状态检查就依赖于此节点。当有 broker 加入集群或者退出集群时,这些组件就会获得通知。
- 如果你要启动另外一个具有相同 ID 的 broker,那么就会得到一个错误 —— 新的 broker 会试着进行注册,但不会成功,因为 ZooKeeper 里面已经有一个相同 ID 的 broker。
- 在 broker 停机、出现分区或者长时间垃圾回收停顿时,broker 会从 ZooKeeper 上断开连接,此时 broker 在启动时创建的临时节点会从 ZooKeeper 中移除。监听 broker 列表的 Kafka 组件会被告知该 broker 已移除。
- 在关闭 broker 时,它对应的节点也会消失,不过它的 ID 会继续存在其他数据结构中,例如主题的副本列表中,副本列表复制我们下面再说。在完全关闭一个 broker 之后,如果使用相同的 ID 启动另一个全新的 broker,它会立刻加入集群,并拥有一个与旧 broker 相同的分区和主题。
Broker Controller 的作用
我们之前在讲 Kafka Rebalance 重平衡的时候,提过一个群组协调器,负责协调群组间的关系,那么 broker 之间也有一个控制器组件(Controller),它是 Kafka 的核心组件。它的主要作用是在 ZooKeeper 的帮助下管理和协调整个 Kafka 集群,集群中的每个 broker 都可以称为 controller,但是在 Kafka 集群启动后,只有一个 broker 会成为 Controller 。既然 Kafka 集群是依赖于 ZooKeeper 集群的,所以有必要先介绍一下 ZooKeeper 是什么,可以参考作者的这一篇文章(ZooKeeper不仅仅是注册中心,你还知道有哪些?)详细了解,在这里就简单提一下 znode
节点的问题。
ZooKeeper 的数据是保存在节点上的,每个节点也被称为znode
,znode 节点是一种树形的文件结构,它很像 Linux 操作系统的文件路径,ZooKeeper 的根节点是 /
。
znode 根据数据的持久化方式可分为临时节点和持久性节点。持久性节点不会因为 ZooKeeper 状态的变化而消失,但是临时节点会随着 ZooKeeper 的重启而自动消失。
znode 节点有一个 Watcher
机制:当数据发生变化的时候, ZooKeeper 会产生一个 Watcher 事件,并且会发送到客户端。Watcher 监听机制是 Zookeeper 中非常重要的特性,我们基于 Zookeeper 上创建的节点,可以对这些节点绑定监听事件,比如可以监听节点数据变更、节点删除、子节点状态变更等事件,通过这个事件机制,可以基于 ZooKeeper 实现分布式锁、集群管理等功能。
控制器的选举
Kafka 当前选举控制器的规则是:Kafka 集群中第一个启动的 broker 通过在 ZooKeeper 里创建一个临时节点 /controller
让自己成为 controller 控制器。其他 broker 在启动时也会尝试创建这个节点,但是由于这个节点已存在,所以后面想要创建 /controller 节点时就会收到一个 节点已存在 的异常。然后其他 broker 会在这个控制器上注册一个 ZooKeeper 的 watch 对象,/controller
节点发生变化时,其他 broker 就会收到节点变更通知。这种方式可以确保只有一个控制器存在。那么只有单独的节点一定是有个问题的,那就是单点问题
。
如果控制器关闭或者与 ZooKeeper 断开链接,ZooKeeper 上的临时节点就会消失。集群中的其他节点收到 watch 对象发送控制器下线的消息后,其他 broker 节点都会尝试让自己去成为新的控制器。其他节点的创建规则和第一个节点的创建原则一致,都是第一个在 ZooKeeper 里成功创建控制器节点的 broker 会成为新的控制器,那么其他节点就会收到节点已存在的异常,然后在新的控制器节点上再次创建 watch 对象进行监听。
控制器的作用
那么说了这么多,控制是什么呢?控制器的作用是什么呢?或者说控制器的这么一个组件
被设计用来干什么?别着急,接下来我们就要说一说。
Kafka 被设计为一种模拟状态机的多线程控制器,它可以作用有下面这几点
-
控制器相当于部门(集群)中的部门经理(broker controller),用于管理部门中的部门成员(broker)
-
控制器是所有 broker 的一个监视器,用于监控 broker 的上线和下线
-
在 broker 宕机后,控制器能够选举新的分区 Leader
-
控制器能够和 broker 新选取的 Leader 发送消息
再细分一下可以具体分为如下 5 点
主题管理
: Kafka Controller 可以帮助我们完成对 Kafka 主题创建、删除和增加分区的操作,简而言之就是对分区拥有最高行使权。
换句话说,当我们执行kafka-topics 脚本时,大部分的后台工作都是控制器来完成的。
-
分区重分配
: 分区重分配主要是指,kafka-reassign-partitions 脚本提供的对已有主题分区进行细粒度的分配功能。这部分功能也是控制器实现的。 -
Prefered 领导者选举
: Preferred 领导者选举主要是 Kafka 为了避免部分 Broker 负载过重而提供的一种换 Leader 的方案。 -
集群成员管理
: 主要管理 新增 broker、broker 关闭、broker 宕机 -
数据服务
: 控制器的最后一大类工作,就是向其他 broker 提供数据服务。控制器上保存了最全的集群元数据信息,其他所有 broker 会定期接收控制器发来的元数据更新请求,从而更新其内存中的缓存数据。这些数据我们会在下面讨论
当控制器发现一个 broker 离开集群(通过观察相关 ZooKeeper 路径),控制器会收到消息:这个 broker 所管理的那些分区需要一个新的 Leader。控制器会依次遍历每个分区,确定谁能够作为新的 Leader,然后向所有包含新 Leader 或现有 Follower 的分区发送消息,该请求消息包含谁是新的 Leader 以及谁是 Follower 的信息。随后,新的 Leader 开始处理来自生产者和消费者的请求,Follower 用于从新的 Leader 那里进行复制。
这就很像外包公司的一个部门,这个部门就是专门出差的,每个人在不同的地方办公,但是中央总部有一个部门经理,现在部门经理突然离职了。公司不打算外聘人员,决定从部门内部选一个能力强的人当领导,然后当上领导的人需要向自己的组员发送消息,这条消息就是任命消息和明确他管理了哪些人,大家都知道了,然后再各自给部门干活。
当控制器发现一个 broker 加入集群时,它会使用 broker ID 来检查新加入的 broker 是否包含现有分区的副本。如果有控制器就会把消息发送给新加入的 broker 和 现有的 broker。
上面这块关于分区复制的内容我们接下来会说到。
broker controller 数据存储
上面我们介绍到 broker controller 会提供数据服务,用于保存大量的 Kafka 集群数据。如下图
可以对上面保存信息归类,主要分为三类
- broker 上的所有信息,包括 broker 中的所有分区,broker 所有分区副本,当前都有哪些运行中的 broker,哪些正在关闭中的 broker 。
- 所有主题信息,包括具体的分区信息,比如领导者副本是谁,ISR 集合中有哪些副本等。
- 所有涉及运维任务的分区。包括当前正在进行 Preferred 领导者选举以及分区重分配的分区列表。
Kafka 是离不开 ZooKeeper的,所以这些数据信息在 ZooKeeper 中也保存了一份。每当控制器初始化时,它都会从 ZooKeeper 上读取对应的元数据并填充到自己的缓存中。
broker controller 故障转移
我们在前面说过,第一个在 ZooKeeper 中的 /brokers/ids
下创建节点的 broker 作为 broker controller,也就是说 broker controller 只有一个,那么必然会存在单点失效问题。kafka 为考虑到这种情况提供了故障转移
功能,也就是 Fail Over
。如下图
最一开始,broker1 会抢先注册成功成为 controller,然后由于网络抖动或者其他原因致使 broker1 掉线,ZooKeeper 通过 Watch 机制觉察到 broker1 的掉线,之后所有存活的 brokers 开始竞争成为 controller,这时 broker3 抢先注册成功,此时 ZooKeeper 存储的 controller 信息由 broker1 -> broker3,之后,broker3 会从 ZooKeeper 中读取元数据信息,并初始化到自己的缓存中。
注意:ZooKeeper 中存储的不是缓存信息,broker 中存储的才是缓存信息。
broker controller 存在的问题
在 Kafka 0.11 版本之前,控制器的设计是相当繁琐的。我们上面提到过一句话:Kafka controller 被设计为一种模拟状态机的多线程控制器,这种设计其实是存在一些问题的
- controller 状态的更改由不同的监听器并罚执行,因此需要进行很复杂的同步,并且容易出错而且难以调试。
- 状态传播不同步,broker 可能在时间不确定的情况下出现多种状态,这会导致不必要的额外的数据丢失
- controller 控制器还会为主题删除创建额外的 I/O 线程,导致性能损耗
- controller 的多线程设计还会访问共享数据,我们知道,多线程访问共享数据是线程同步最麻烦的地方,为了保护数据安全性,控制器不得不在代码中大量使用ReentrantLock 同步机制,这就进一步拖慢了整个控制器的处理速度。
broker controller 内部设计原理
在 Kafka 0.11 之后,Kafka controller 采用了新的设计,把多线程的方案改成了单线程加事件队列的方案。如下图所示
主要所做的改变有下面这几点
第一个改进是增加了一个 Event Executor Thread
,事件执行线程,从图中可以看出,不管是 Event Queue 事件队列还是 Controller context 控制器上下文都会交给事件执行线程进行处理。将原来执行的操作全部建模成一个个独立的事件,发送到专属的事件队列中,供此线程消费。
第二个改进是将之前同步的 ZooKeeper 全部改为异步操作
。ZooKeeper API 提供了两种读写的方式:同步和异步。之前控制器操作 ZooKeeper 都是采用的同步方式,这次把同步方式改为异步,据测试,效率提升了10倍。
第三个改进是根据优先级处理请求,之前的设计是 broker 会公平性的处理所有 controller 发送的请求。什么意思呢?公平性难道还不好吗?在某些情况下是的,比如 broker 在排队处理 produce 请求,这时候 controller 发出了一个 StopReplica 的请求,你会怎么办?还在继续处理 produce 请求吗?这个 produce 请求还有用吗?此时最合理的处理顺序应该是,**赋予 StopReplica 请求更高的优先级,使它能够得到抢占式的处理。
副本机制
复制功能是 Kafka 架构的核心功能,在 Kafka 文档里面 Kafka 把自己描述为 一个分布式的、可分区的、可复制的提交日志服务。复制之所以这么关键,是因为消息的持久存储非常重要,这能够保证在主节点宕机后依旧能够保证 Kafka 高可用。副本机制也可以称为备份机制(Replication)
,通常指分布式系统在多台网络交互的机器上保存有相同的数据备份/拷贝。
Kafka 使用主题来组织数据,每个主题又被分为若干个分区,分区会部署在一到多个 broker 上,每个分区都会有多个副本,所以副本也会被保存在 broker 上,每个 broker 可能会保存成千上万个副本。下图是一个副本复制示意图
如上图所示,为了简单我只画出了两个 broker ,每个 broker 指保存了一个 Topic 的消息,在 broker1 中分区0 是Leader,它负责进行分区的复制工作,把 broker1 中的分区0复制一个副本到 broker2 的主题 A 的分区0。同理,主题 A 的分区1也是一样的道理。
副本类型分为两种:一种是 Leader(领导者)
副本,一种是Follower(跟随者)
副本。
Leader 副本
Kafka 在创建分区的时候都要选举一个副本,这个选举出来的副本就是 Leader 领导者副本。
Follower 副本
除了 Leader 副本以外的副本统称为 Follower 副本
,Follower 不对外提供服务。下面是 Leader 副本的工作方式
这幅图需要注意以下几点
- Kafka 中,Follower 副本也就是追随者副本是不对外提供服务的。这就是说,任何一个追随者副本都不能响应消费者和生产者的请求。所有的请求都是由领导者副本来处理。或者说,所有的请求都必须发送到 Leader 副本所在的 broker 中,Follower 副本只是用做数据拉取,采用
异步拉取
的方式,并写入到自己的提交日志中,从而实现与 Leader 的同步 - 当 Leader 副本所在的 broker 宕机后,Kafka 依托于 ZooKeeper 提供的监控功能能够实时感知到,并开启新一轮的选举,从追随者副本中选一个作为 Leader。如果宕机的 broker 重启完成后,该分区的副本会作为 Follower 重新加入。
首领的另一个任务是搞清楚哪个跟随者的状态与自己是一致的。跟随者为了保证与领导者的状态一致,在有新消息到达之前先尝试从领导者那里复制消息。为了与领导者保持一致,跟随者向领导者发起获取数据的请求,这种请求与消费者为了读取消息而发送的信息是一样的。
跟随者向领导者发送消息的过程是这样的,先请求消息1,然后再接收到消息1,在时候到请求1之后,发送请求2,在收到领导者给发送给跟随者之前,跟随者是不会继续发送消息的。这个过程如下
跟随者副本在收到响应消息前,是不会继续发送消息,这一点很重要。通过查看每个跟随者请求的最新偏移量,首领就会知道每个跟随者复制的进度。如果跟随者在10s 内没有请求任何消息,或者虽然跟随者已经发送请求,但是在10s 内没有收到消息,就会被认为是不同步
的。如果一个副本没有与领导者同步,那么在领导者掉线后,这个副本将不会称为领导者,因为这个副本的消息不是全部的。
与之相反的,如果跟随者同步的消息和领导者副本的消息一致,那么这个跟随者副本又被称为同步的副本
。也就是说,如果领导者掉线,那么只有同步的副本能够称为领导者。
关于副本机制我们说了这么多,那么副本机制的好处是什么呢?
- 能够立刻看到写入的消息,就是你使用生产者 API 成功向分区写入消息后,马上使用消费者就能读取刚才写入的消息
- 能够实现消息的幂等性,啥意思呢?就是对于生产者产生的消息,在消费者进行消费的时候,它每次都会看到消息存在,并不会存在消息不存在的情况
同步复制和异步复制
我在学习副本机制的时候,有个疑问,既然领导者副本和跟随者副本是发送 - 等待
机制的,这是一种同步的复制方式,那么为什么说跟随者副本同步领导者副本的时候是一种异步操作呢?
我认为是这样的,跟随者副本在同步领导者副本后会把消息保存在本地 log 中,这个时候跟随者会给领导者副本一个响应消息,告诉领导者自己已经保存成功了,同步复制的领导者会等待所有的跟随者副本都写入成功后,再返回给 producer 写入成功的消息。而异步复制是领导者副本不需要关心跟随者副本是否写入成功,只要领导者副本自己把消息保存到本地 log ,就会返回给 producer 写入成功的消息。下面是同步复制和异步复制的过程
**同步复制
- producer 通知 ZooKeeper 识别领导者
- producer 向领导者写入消息
- 领导者收到消息后会把消息写入到本地 log
- 跟随者会从领导者那里拉取消息
- 跟随者向本地写入 log
- 跟随者向领导者发送写入成功的消息
- 领导者会收到所有的跟随者发送的消息
- 领导者向 producer 发送写入成功的消息
**异步复制
和同步复制的区别在于,领导者在写入本地log之后,直接向客户端发送写入成功消息,不需要等待所有跟随者复制完成。
ISR
Kafka动态维护了一个同步状态的副本的集合(a set of In-Sync Replicas),简称ISR
,ISR 也是一个很重要的概念,我们之前说过,追随者副本不提供服务,只是定期的异步拉取领导者副本的数据而已,拉取这个操作就相当于是复制,ctrl-c + ctrl-v
大家肯定用的熟。那么是不是说 ISR 集合中的副本消息的数量都会与领导者副本消息数量一样呢?那也不一定,判断的依据是 broker 中参数 replica.lag.time.max.ms
的值,这个参数的含义就是跟随者副本能够落后领导者副本最长的时间间隔。
replica.lag.time.max.ms 参数默认的时间是 10秒,如果跟随者副本落后领导者副本的时间不超过 10秒,那么 Kafka 就认为领导者和跟随者是同步的。即使此时跟随者副本中存储的消息要小于领导者副本。如果跟随者副本要落后于领导者副本 10秒以上的话,跟随者副本就会从 ISR 被剔除。倘若该副本后面慢慢地追上了领导者的进度,那么它是能够重新被加回 ISR 的。这也表明,ISR 是一个动态调整的集合,而非静态不变的。
Unclean 领导者选举
既然 ISR 是可以动态调整的,那么必然会出现 ISR 集合中为空的情况,由于领导者副本是一定出现在 ISR 集合中的,那么 ISR 集合为空必然说明领导者副本也挂了,所以此时 Kafka 需要重新选举一个新的领导者,那么该如何选举呢?现在你需要转变一下思路,我们上面说 ISR 集合中一定是与领导者同步的副本,那么不再 ISR 集合中的副本一定是不与领导者同步的副本了,也就是不再 ISR 列表中的跟随者副本会丢失一些消息。如果你开启 broker 端参数 unclean.leader.election.enable
的话,下一个领导者就会在这些非同步的副本中选举。这种选举也叫做Unclean 领导者选举
。
如果你接触过分布式项目的话你一定知道 CAP 理论,那么这种 Unclean 领导者选举其实是牺牲了数据一致性,保证了 Kafka 的高可用性。
你可以根据你的实际业务场景决定是否开启 Unclean 领导者选举,一般不建议开启这个参数,因为数据的一致性要比可用性重要的多。
Kafka 请求处理流程
broker 的大部分工作是处理客户端、分区副本和控制器发送给分区领导者的请求。这种请求一般都是请求/响应
式的,我猜测你接触最早的请求/响应的方式应该就是 HTTP 请求了。事实上,HTTP 请求可以是同步可以是异步的。一般正常的 HTTP 请求都是同步的,同步方式最大的一个特点是提交请求->等待服务器处理->处理完毕返回 这个期间客户端浏览器不能做任何事。而异步方式最大的特点是 请求通过事件触发->服务器处理(这是浏览器仍然可以作其他事情)-> 处理完毕。
那么我也可以说同步请求就是顺序处理的,而异步请求的执行方式则不确定,因为异步需要创建多个执行线程,而每个线程的执行顺序不同。
这里需要注意一点,我们只是使用 HTTP 请求来举例子,而 Kafka 采用的是 TCP 基于 Socket 的方式进行通讯
那么这两种方式有什么缺点呢?
我相信聪明的你应该能马上想到,同步的方式最大的缺点就是吞吐量太差
,资源利用率极低,由于只能顺序处理请求,因此,每个请求都必须等待前一个请求处理完毕才能得到处理。这种方式只适用于请求发送非常不频繁的系统
。
异步的方式的缺点就是为每个请求都创建线程的做法开销极大,在某些场景下甚至会压垮整个服务。
响应式模型
说了这么半天,Kafka 采用同步还是异步的呢?都不是,Kafka 采用的是一种 响应式(Reactor)模型
,那么什么是响应式模型呢?简单的说,Reactor 模式是事件驱动架构的一种实现方式,特别适合应用于处理多个客户端并发向服务器端发送请求的场景,如下图所示
Kafka 的 broker 端有个 SocketServer组件,类似于处理器,SocketServer 是基于 TCP 的 Socket 连接的,它用于接受客户端请求,所有的请求消息都包含一个消息头,消息头中都包含如下信息
- Request type (也就是 API Key)
- Request version(broker 可以处理不同版本的客户端请求,并根据客户版本做出不同的响应)
- Correlation ID — 一个具有唯一性的数字,用于标示请求消息,同时也会出现在响应消息和错误日志中(用于诊断问题)
- Client ID — 用于标示发送请求的客户端
broker 会在它所监听的每一个端口上运行一个 Acceptor
线程,这个线程会创建一个连接,并把它交给 Processor(网络线程池)
, Processor 的数量可以使用 num.network.threads
进行配置,其默认值是3,表示每台 broker 启动时会创建3个线程,专门处理客户端发送的请求。
Acceptor 线程会采用轮询
的方式将入栈请求公平的发送至网络线程池中,因此,在实际使用过程中,这些线程通常具有相同的机率被分配到待处理请求队列
中,然后从响应队列
获取响应消息,把它们发送给客户端。Processor 网络线程池中的请求 – 响应的处理还是比较复杂的,下面是网络线程池中的处理流程图
Processor 网络线程池接收到客户和其他 broker 发送来的消息后,网络线程池会把消息放到请求队列中,注意这个是共享请求队列
,因为网络线程池是多线程机制的,所以请求队列的消息是多线程共享的区域,然后由 IO 线程池进行处理,根据消息的种类判断做何处理,比如 PRODUCE
请求,就会将消息写入到 log 日志中,如果是FETCH
请求,则从磁盘或者页缓存中读取消息。也就是说,IO线程池是真正做判断,处理请求的一个组件。在IO 线程池处理完毕后,就会判断是放入响应队列
中还是 Purgatory
中,Purgatory 是什么我们下面再说,现在先说一下响应队列,响应队列是每个线程所独有的,因为响应式模型中不会关心请求发往何处,因此把响应回传的事情就交给每个线程了,所以也就不必共享了。
注意:IO 线程池可以通过 broker 端参数
num.io.threads
来配置,默认的线程数是8,表示每台 broker 启动后自动创建 8 个IO 处理线程。
请求类型
下面是几种常见的请求类型
**生产请求
我在 真的,关于 Kafka 入门看这一篇就够了 文章中提到过 acks
这个配置项的含义
简单来讲就是不同的配置对写入成功的界定是不同的,如果 acks = 1,那么只要领导者收到消息就表示写入成功,如果acks = 0,表示只要领导者发送消息就表示写入成功,根本不用考虑返回值的影响。如果 acks = all,就表示领导者需要收到所有副本的消息后才表示写入成功。
在消息被写入分区的首领后,如果 acks 配置的值是 all
,那么这些请求会被保存在 炼狱(Purgatory)
的缓冲区中,直到领导者副本发现跟随者副本都复制了消息,响应才会发送给客户端。
**获取请求
broker 获取请求的方式与处理生产请求的方式类似,客户端发送请求,向 broker 请求主题分区中特定偏移量的消息,如果偏移量存在,Kafka 会采用 零复制
技术向客户端发送消息,Kafka 会直接把消息从文件中发送到网络通道中,而不需要经过任何的缓冲区,从而获得更好的性能。
客户端可以设置获取请求数据的上限和下限,上限
指的是客户端为接受足够消息分配的内存空间,这个限制比较重要,如果上限太大的话,很有可能直接耗尽客户端内存。下限
可以理解为攒足了数据包再发送的意思,这就相当于项目经理给程序员分配了 10 个bug,程序员每次改一个 bug 就会向项目经理汇报一下,有的时候改好了有的时候可能还没改好,这样就增加了沟通成本和时间成本,所以下限值得就是程序员你改完10个 bug 再向我汇报!!!如下图所示
如图你可以看到,在拉取消息
—> 消息
之间是有一个等待消息积累这么一个过程的,这个消息积累你可以把它想象成超时时间,不过超时会跑出异常,消息积累超时后会响应回执。延迟时间可以通过 replica.lag.time.max.ms
来配置,它指定了副本在复制消息时可被允许的最大延迟时间。
**元数据请求
生产请求和响应请求都必须发送给领导者副本,如果 broker 收到一个针对某个特定分区的请求,而该请求的首领在另外一个 broker 中,那么发送请求的客户端会收到非分区首领
的错误响应;如果针对某个分区的请求被发送到不含有领导者的 broker 上,也会出现同样的错误。Kafka 客户端需要把请求和响应发送到正确的 broker 上。这不是废话么?我怎么知道要往哪发送?
事实上,客户端会使用一种 元数据请求
,这种请求会包含客户端感兴趣的主题列表,服务端的响应消息指明了主题的分区,领导者副本和跟随者副本。元数据请求可以发送给任意一个 broker,因为所有的 broker 都会缓存这些信息。
一般情况下,客户端会把这些信息缓存,并直接向目标 broker 发送生产请求和相应请求,这些缓存需要隔一段时间就进行刷新,使用metadata.max.age.ms
参数来配置,从而知道元数据是否发生了变更。比如,新的 broker 加入后,会触发重平衡,部分副本会移动到新的 broker 上。这时候,如果客户端收到 不是首领
的错误,客户端在发送请求之前刷新元数据缓存。
Kafka 重平衡流程
我在 真的,关于 Kafka 入门看这一篇就够了 中关于消费者描述的时候大致说了一下消费者组和重平衡之间的关系,实际上,归纳为一点就是让组内所有的消费者实例就消费哪些主题分区达成一致。
我们知道,一个消费者组中是要有一个群组协调者(Coordinator)
的,而重平衡的流程就是由 Coordinator 的帮助下来完成的。
这里需要先声明一下重平衡发生的条件
- 消费者订阅的任何主题发生变化
- 消费者数量发生变化
- 分区数量发生变化
- 如果你订阅了一个还尚未创建的主题,那么重平衡在该主题创建时发生。如果你订阅的主题发生删除那么也会发生重平衡
- 消费者被群组协调器认为是
DEAD
状态,这可能是由于消费者崩溃或者长时间处于运行状态下发生的,这意味着在配置合理时间的范围内,消费者没有向群组协调器发送任何心跳,这也会导致重平衡的发生。
在了解重平衡之前,你需要知道这两个角色
群组协调器(Coordinator)
:群组协调器是一个能够从消费者群组中收到所有消费者发送心跳消息的 broker。在最早期的版本中,元数据信息是保存在 ZooKeeper 中的,但是目前元数据信息存储到了 broker 中。每个消费者组都应该和群组中的群组协调器同步。当所有的决策要在应用程序节点中进行时,群组协调器可以满足 JoinGroup
请求并提供有关消费者组的元数据信息,例如分配和偏移量。群组协调器还有权知道所有消费者的心跳,消费者群组中还有一个角色就是领导者,注意把它和领导者副本和 kafka controller 进行区分。领导者是群组中负责决策的角色,所以如果领导者掉线了,群组协调器有权把所有消费者踢出组。因此,消费者群组的一个很重要的行为是选举领导者,并与协调器读取和写入有关分配和分区的元数据信息。
消费者领导者
: 每个消费者群组中都有一个领导者。如果消费者停止发送心跳了,协调者会触发重平衡。
在了解重平衡之前,你需要知道状态机是什么
Kafka 设计了一套消费者组状态机(State Machine)
,来帮助协调者完成整个重平衡流程。消费者状态机主要有五种状态它们分别是 Empty、Dead、PreparingRebalance、CompletingRebalance 和 Stable。
了解了这些状态的含义之后,下面我们用几条路径来表示一下消费者状态的轮转
消费者组一开始处于 Empty
状态,当重平衡开启后,它会被置于 PreparingRebalance
状态等待新消费者的加入,一旦有新的消费者加入后,消费者群组就会处于 CompletingRebalance
状态等待分配,只要有新的消费者加入群组或者离开,就会触发重平衡,消费者的状态处于 PreparingRebalance 状态。等待分配机制指定好后完成分配,那么它的流程图是这样的
在上图的基础上,当消费者群组都到达 Stable
状态后,一旦有新的消费者加入/离开/心跳过期,那么触发重平衡,消费者群组的状态重新处于 PreparingRebalance 状态。那么它的流程图是这样的。
在上图的基础上,消费者群组处于 PreparingRebalance 状态后,很不幸,没人玩儿了,所有消费者都离开了,这时候还可能会保留有消费者消费的位移数据,一旦位移数据过期或者被刷新,那么消费者群组就处于 Dead
状态了。它的流程图是这样的
在上图的基础上,我们分析了消费者的重平衡,在 PreparingRebalance
或者 CompletingRebalance
或者 Stable
任意一种状态下发生位移主题分区 Leader 发生变更,群组会直接处于 Dead 状态,它的所有路径如下
这里面需要注意两点:
一般出现 Required xx expired offsets in xxx milliseconds 就表明Kafka 很可能就把该组的位移数据删除了
只有 Empty 状态下的组,才会执行过期位移删除的操作。
重平衡流程
上面我们了解到了消费者群组状态的转化过程,下面我们真正开始介绍 Rebalance
的过程。重平衡过程可以从两个方面去看:消费者端和协调者端,首先我们先看一下消费者端
从消费者看重平衡
从消费者看重平衡有两个步骤:分别是 消费者加入组
和 等待领导者分配方案
。这两个步骤后分别对应的请求是 JoinGroup
和 SyncGroup
。
新的消费者加入群组时,这个消费者会向协调器发送 JoinGroup
请求。在该请求中,每个消费者成员都需要将自己消费的 topic 进行提交,我们上面描述群组协调器中说过,这么做的目的就是为了让协调器收集足够的元数据信息,来选取消费者组的领导者。通常情况下,第一个发送 JoinGroup 请求的消费者会自动称为领导者。领导者的任务是收集所有成员的订阅信息,然后根据这些信息,制定具体的分区消费分配方案。如图
在所有的消费者都加入进来并把元数据信息提交给领导者后,领导者做出分配方案并发送 SyncGroup
请求给协调者,协调者负责下发群组中的消费策略。下图描述了 SyncGroup 请求的过程
当所有成员都成功接收到分配方案后,消费者组进入到 Stable 状态,即开始正常的消费工作。
从协调者来看重平衡
从协调者角度来看重平衡主要有下面这几种触发条件,
- 新成员加入组
- 组成员主动离开
- 组成员崩溃离开
- 组成员提交位移
我们分别来描述一下,先从新成员加入组开始
新成员加入组
我们讨论的场景消费者集群状态处于Stable
等待分配的过程,这时候如果有新的成员加入组的话,重平衡的过程
从这个角度来看,协调者的过程和消费者类似,只是刚刚从消费者的角度去看,现在从领导者的角度去看
组成员离开
组成员离开消费者群组指的是消费者实例调用 close()
方法主动通知协调者它要退出。这里又会有一个新的请求出现 LeaveGroup()请求
。如下图所示
组成员崩溃
组成员崩溃是指消费者实例出现严重故障,宕机或者一段时间未响应,协调者接收不到消费者的心跳,就会被认为是组成员崩溃
,崩溃离组是被动的,协调者通常需要等待一段时间才能感知到,这段时间一般是由消费者端参数 session.timeout.ms 控制的。如下图所示
重平衡时提交位移
这个过程我们就不再用图形来表示了,大致描述一下就是 消费者发送 JoinGroup 请求后,群组中的消费者必须在指定的时间范围内提交各自的位移,然后再开启正常的 JoinGroup/SyncGroup 请求发送。
LinuxIO管理
我们之前了解过了 Linux 的进程和线程、Linux 内存管理,那么下面我们就来认识一下 Linux 中的 I/O 管理。
Linux 系统和其他 UNIX 系统一样,IO 管理比较直接和简洁。所有 IO 设备都被当作文件
,通过在系统内部使用相同的 read 和 write 一样进行读写。
Linux IO 基本概念
Linux 中也有磁盘、打印机、网络等 I/O 设备,Linux 把这些设备当作一种 特殊文件
整合到文件系统中,一般通常位于 /dev
目录下。可以使用与普通文件相同的方式来对待这些特殊文件。
特殊文件一般分为两种:
块特殊文件是一个能存储固定大小块
信息的设备,它支持以固定大小的块,扇区或群集读取和(可选)写入数据。每个块都有自己的物理地址
。通常块的大小在 512 – 65536 之间。所有传输的信息都会以连续
的块为单位。块设备的基本特征是每个块都较为对立,能够独立的进行读写。常见的块设备有 硬盘、蓝光光盘、USB 盘与字符设备相比,块设备通常需要较少的引脚。
块特殊文件的缺点基于给定固态存储器的块设备比基于相同类型的存储器的字节寻址要慢一些,因为必须在块的开头开始读取或写入。所以,要读取该块的任何部分,必须寻找到该块的开始,读取整个块,如果不使用该块,则将其丢弃。要写入块的一部分,必须寻找到块的开始,将整个块读入内存,修改数据,再次寻找到块的开头处,然后将整个块写回设备。
另一类 I/O 设备是字符特殊文件
。字符设备以字符
为单位发送或接收一个字符流,而不考虑任何块结构。字符设备是不可寻址的,也没有任何寻道操作。常见的字符设备有 打印机、网络设备、鼠标、以及大多数与磁盘不同的设备。
每个设备特殊文件都会和 设备驱动
相关联。每个驱动程序都通过一个 主设备号
来标识。如果一个驱动支持多个设备的话,此时会在主设备的后面新加一个 次设备号
来标识。主设备号和次设备号共同确定了唯一的驱动设备。
我们知道,在计算机系统中,CPU 并不直接和设备打交道,它们中间有一个叫作 设备控制器(Device Control Unit)
的组件,例如硬盘有磁盘控制器、USB 有 USB 控制器、显示器有视频控制器等。这些控制器就像代理商一样,它们知道如何应对硬盘、鼠标、键盘、显示器的行为。
绝大多数字符特殊文件都不能随机访问,因为他们需要使用和块特殊文件不同的方式来控制。比如,你在键盘上输入了一些字符,但是你发现输错了一个,这时有一些人喜欢使用 backspace
来删除,有人喜欢用 del
来删除。为了中断正在运行的设备,一些系统使用 ctrl-u
来结束,但是现在一般使用 ctrl-c
来结束。
网络
I/O 的另外一个概念是网络
, 也是由 UNIX 引入,网络中一个很关键的概念就是 套接字(socket)
。套接字允许用户连接到网络,正如邮筒允许用户连接到邮政系统,套接字的示意图如下
套接字的位置如上图所示,套接字可以动态创建和销毁。成功创建一个套接字后,系统会返回一个文件描述符(file descriptor)
,在后面的创建链接、读数据、写数据、解除连接时都需要使用到这个文件描述符。每个套接字都支持一种特定类型的网络类型,在创建时指定。一般最常用的几种
- 可靠的面向连接的字节流
- 可靠的面向连接的数据包
- 不可靠的数据包传输
可靠的面向连接的字节流会使用管道
在两台机器之间建立连接。能够保证字节从一台机器按照顺序到达另一台机器,系统能够保证所有字节都能到达。
除了数据包之间的分界之外,第二种类型和第一种类型是类似的。如果发送了 3 次写操作,那么使用第一种方式的接受者会直接接收到所有字节;第二种方式的接受者会分 3 次接受所有字节。除此之外,用户还可以使用第三种即不可靠的数据包来传输,使用这种传输方式的优点在于高性能,有的时候它比可靠性更加重要,比如在流媒体中,性能就尤其重要。
以上涉及两种形式的传输协议,即 TCP
和 UDP
,TCP 是 传输控制协议
,它能够传输可靠的字节流。UDP
是 用户数据报协议
,它只能够传输不可靠的字节流。它们都属于 TCP/IP 协议簇中的协议,下面是网络协议分层
可以看到,TCP 、UDP 都位于网络层上,可见它们都把 IP 协议 即 互联网协议
作为基础。
一旦套接字在源计算机和目的计算机建立成功,那么两个计算机之间就可以建立一个链接。通信一方在本地套接字上使用 listen
系统调用,它就会创建一个缓冲区,然后阻塞直到数据到来。另一方使用 connect
系统调用,如果另一方接受 connect 系统调用后,则系统会在两个套接字之间建立连接。
socket 连接建立成功后就像是一个管道,一个进程可以使用本地套接字的文件描述符从中读写数据,当连接不再需要的时候使用 close
系统调用来关闭。
Linux I/O 系统调用
Linux 系统中的每个 I/O 设备都有一个特殊文件(special file)
与之关联,什么是特殊文件呢?
在操作系统中,特殊文件是一种在文件系统中与硬件设备相关联的文件。特殊文件也被称为
设备文件(device file)
。特殊文件的目的是将设备作为文件系统中的文件进行公开。特殊文件为硬件设备提供了借口,用于文件 I/O 的工具可以进行访问。因为设备有两种类型,同样特殊文件也有两种,即字符特殊文件和块特殊文件
对于大部分 I/O 操作来说,只用合适的文件就可以完成,并不需要特殊的系统调用。然后,有时需要一些设备专用的处理。在 POSIX 之前,大多数 UNIX 系统会有一个叫做 ioctl
的系统调用,它用于执行大量的系统调用。随着时间的发展,POSIX 对其进行了整理,把 ioctl 的功能划分为面向终端设备的独立功能调用,现在已经变成独立的系统调用了。
下面是几个管理终端的系统调用
系统调用 | 描述 |
---|---|
tcgetattr | 获取属性 |
tcsetattr | 设置属性 |
cfgetispeed | 获取输入速率 |
cfgetospeed | 获取输出速率 |
cfsetispeed | 设置输入速率 |
cfsetospeed | 设置输出速率 |
Linux IO 实现
Linux 中的 IO 是通过一系列设备驱动实现的,每个设备类型对应一个设备驱动。设备驱动为操作系统和硬件分别预留接口,通过设备驱动来屏蔽操作系统和硬件的差异。
当用户访问一个特殊的文件时,由文件系统提供此特殊文件的主设备号和次设备号,并判断它是一个块特殊文件还是字符特殊文件。主设备号用于标识字符设备还是块设备,次设备号用于参数传递。
每个驱动程序
都有两部分:这两部分都是属于 Linux 内核,也都运行在内核态下。上半部分运行在调用者上下文并且与 Linux 其他部分交互。下半部分运行在内核上下文并且与设备进行交互。驱动程序可以调用内存分配、定时器管理、DMA 控制等内核过程。可被调用的内核功能都位于 驱动程序 - 内核接口
的文档中。
I/O 实现指的就是对字符设备和块设备的实现
块设备实现
系统中处理块特殊文件 I/O 部分的目标是为了使传输次数尽可能的小。为了实现这个目标,Linux 系统在磁盘驱动程序和文件系统之间设置了一个 高速缓存(cache)
,如下图所示
在 Linux 内核 2.2 之前,Linux 系统维护着两个缓存:页面缓存(page cache)
和 缓冲区缓存(buffer cache)
,因此,存储在一个磁盘块中的文件可能会在两个缓存中。2.2 版本以后 Linux 内核只有一个统一的缓存一个 通用数据块层(generic block layer)
把这些融合在一起,实现了磁盘、数据块、缓冲区和数据页之间必要的转换。那么什么是通用数据块层?
通用数据块层是一个内核的组成部分,用于处理对系统中所有块设备的请求。通用数据块主要有以下几个功能
将数据缓冲区放在内存高位处,当 CPU 访问数据时,页面才会映射到内核线性地址中,并且此后取消映射
实现
零拷贝
机制,磁盘数据可以直接放入用户模式的地址空间,而无需先复制到内核内存中管理磁盘卷,会把不同块设备上的多个磁盘分区视为一个分区。
利用最新的磁盘控制器的高级功能,例如 DMA 等。
cache 是提升性能的利器,不管以什么样的目的需要一个数据块,都会先从 cache 中查找,如果找到直接返回,避免一次磁盘访问,能够极大的提升系统性能。
如果页面 cache 中没有这个块,操作系统就会把页面从磁盘中调入内存,然后读入 cache 进行缓存。
cache 除了支持读操作外,也支持写操作,一个程序要写回一个块,首先把它写到 cache 中,而不是直接写入到磁盘中,等到磁盘中缓存达到一定数量值时再被写入到 cache 中。
Linux 系统中使用 IO 调度器
来保证减少磁头的反复移动从而减少损失。I/O 调度器的作用是对块设备的读写操作进行排序,对读写请求进行合并。Linux 有许多调度器的变体,从而满足不同的工作需要。最基本的 Linux 调度器是基于传统的 Linux 电梯调度器(Linux elevator scheduler)
。Linux 电梯调度器的主要工作流程就是按照磁盘扇区的地址排序并存储在一个双向链表
中。新的请求将会以链表的形式插入。这种方法可以有效的防止磁头重复移动。因为电梯调度器会容易产生饥饿现象。因此,Linux 在原基础上进行了修改,维护了两个链表,在 最后日期(deadline)
内维护了排序后的读写操作。默认的读操作耗时 0.5s,默认写操作耗时 5s。如果在最后期限内等待时间最长的链表没有获得服务,那么它将优先获得服务。
字符设备实现
和字符设备的交互是比较简单的。由于字符设备会产生并使用字符流、字节数据,因此对随机访问的支持意义不大。一个例外是使用 行规则(line disciplines)
。一个行规可以和终端设备相关联,使用 tty_struct
结构来表示,它表示与终端设备交换数据的解释器,当然这也属于内核的一部分。例如:行规可以对行进行编辑,映射回车为换行等一系列其他操作。
什么是行规则?
行规是某些类 UNIX 系统中的一层,终端子系统通常由三层组成:上层提供字符设备接口,下层硬件驱动程序与硬件或伪终端进行交互,中层规则用于实现终端设备共有的行为。
网络设备实现
网络设备的交互是不一样的,虽然 网络设备(network devices)
也会产生字符流,因为它们的异步(asynchronous)
特性是他们不易与其他字符设备在同一接口下集成。网络设备驱动程序会产生很多数据包,经由网络协议到达用户应用程序中。
Linux 中的模块
UNIX 设备驱动程序是被静态加载
到内核中的。因此,只要系统启动后,设备驱动程序都会被加载到内存中。随着个人电脑 Linux 的出现,这种静态链接完成后会使用一段时间的模式被打破。相对于小型机上的 I/O 设备,PC 上可用的 I/O 设备有了数量级的增长。绝大多数用户没有能力去添加一个新的应用程序、更新设备驱动、重新连接内核,然后进行安装。
Linux 为了解决这个问题,引入了 可加载(loadable module)
机制。可加载是在系统运行时添加到内核中的代码块。
当一个模块被加载到内核时,会发生下面几件事情:第一,在加载的过程中,模块会被动态的重新部署。第二,系统会检查程序程序所需的资源是否可用。如果可用,则把这些资源标记为正在使用。第三步,设置所需的中断向量。第四,更新驱动转换表使其能够处理新的主设备类型。最后再来运行设备驱动程序。
在完成上述工作后,驱动程序就会安装完成,其他现代 UNIX 系统也支持可加载机制。
Linux文件系统
在 Linux 中,最直观、最可见的部分就是 文件系统(file system)
。下面我们就来一起探讨一下关于 Linux 中国的文件系统,系统调用以及文件系统实现背后的原理和思想。这些思想中有一些来源于 MULTICS,现在已经被 Windows 等其他操作系统使用。Linux 的设计理念就是 小的就是好的(Small is Beautiful)
。虽然 Linux 只是使用了最简单的机制和少量的系统调用,但是 Linux 却提供了强大而优雅的文件系统。
Linux 文件系统基本概念
Linux 在最初的设计是 MINIX1 文件系统,它只支持 14 字节的文件名,它的最大文件只支持到 64 MB。在 MINIX 1 之后的文件系统是 ext 文件系统。ext 系统相较于 MINIX 1 来说,在支持字节大小和文件大小上均有很大提升,但是 ext 的速度仍没有 MINIX 1 快,于是,ext 2 被开发出来,它能够支持长文件名和大文件,而且具有比 MINIX 1 更好的性能。这使他成为 Linux 的主要文件系统。只不过 Linux 会使用 VFS
曾支持多种文件系统。在 Linux 链接时,用户可以动态的将不同的文件系统挂载倒 VFS 上。
Linux 中的文件是一个任意长度的字节序列,Linux 中的文件可以包含任意信息,比如 ASCII 码、二进制文件和其他类型的文件是不加区分的。
为了方便起见,文件可以被组织在一个目录中,目录存储成文件的形式在很大程度上可以作为文件处理。目录可以有子目录,这样形成有层次的文件系统,Linux 系统下面的根目录是 /
,它通常包含了多个子目录。字符 /
还用于对目录名进行区分,例如 /usr/cxuan 表示的就是根目录下面的 usr 目录,其中有一个叫做 cxuan 的子目录。
下面我们介绍一下 Linux 系统根目录下面的目录名
/bin
,它是重要的二进制应用程序,包含二进制文件,系统的所有用户使用的命令都在这里/boot
,启动包含引导加载程序的相关文件/dev
,包含设备文件,终端文件,USB 或者连接到系统的任何设备/etc
,配置文件,启动脚本等,包含所有程序所需要的配置文件,也包含了启动/停止单个应用程序的启动和关闭 shell 脚本/home
,本地主要路径,所有用户用 home 目录存储个人信息/lib
,系统库文件,包含支持位于 /bin 和 /sbin 下的二进制库文件/lost+found
,在根目录下提供一个遗失+查找系统,必须在 root 用户下才能查看当前目录下的内容/media
,挂载可移动介质/mnt
,挂载文件系统/opt
,提供一个可选的应用程序安装目录/proc
,特殊的动态目录,用于维护系统信息和状态,包括当前运行中进程信息/root
,root 用户的主要目录文件夹/sbin
,重要的二进制系统文件/tmp
, 系统和用户创建的临时文件,系统重启时,这个目录下的文件都会被删除/usr
,包含绝大多数用户都能访问的应用程序和文件/var
,经常变化的文件,诸如日志文件或数据库等
在 Linux 中,有两种路径,一种是 绝对路径(absolute path)
,绝对路径告诉你从根目录下查找文件,绝对路径的缺点是太长而且不太方便。还有一种是 相对路径(relative path)
,相对路径所在的目录也叫做工作目录(working directory)
。
如果 /usr/local/books
是工作目录,那么 shell 命令
cp books books-replica
就表示的是相对路径,而
cp /usr/local/books/books /usr/local/books/books-replica
则表示的是绝对路径。
在 Linux 中经常出现一个用户使用另一个用户的文件或者使用文件树结构中的文件。两个用户共享同一个文件,这个文件位于某个用户的目录结构中,另一个用户需要使用这个文件时,必须通过绝对路径才能引用到他。如果绝对路径很长,那么每次输入起来会变的非常麻烦,所以 Linux 提供了一种 链接(link)
机制。
举个例子,下面是一个使用链接之前的图
以上所示,比如有两个工作账户 jianshe 和 cxuan,jianshe 想要使用 cxuan 账户下的 A 目录,那么它可能会输入 /usr/cxuan/A
,这是一种未使用链接之后的图。
使用链接后的示意如下
现在,jianshe 可以创建一个链接来使用 cxuan 下面的目录了。‘
当一个目录被创建出来后,有两个目录项也同时被创建出来,它们就是 .
和 ..
,前者代表工作目录自身,后者代表该目录的父目录,也就是该目录所在的目录。这样一来,在 /usr/jianshe 中访问 cxuan 中的目录就是 ../cxuan/xxx
Linux 文件系统不区分磁盘的,这是什么意思呢?一般来说,一个磁盘中的文件系统相互之间保持独立,如果一个文件系统目录想要访问另一个磁盘中的文件系统,在 Windows 中你可以像下面这样。
两个文件系统分别在不同的磁盘中,彼此保持独立。
而在 Linux 中,是支持挂载
的,它允许一个磁盘挂在到另外一个磁盘上,那么上面的关系会变成下面这样
挂在之后,两个文件系统就不再需要关心文件系统在哪个磁盘上了,两个文件系统彼此可见。
Linux 文件系统的另外一个特性是支持 加锁(locking)
。在一些应用中会出现两个或者更多的进程同时使用同一个文件的情况,这样很可能会导致竞争条件(race condition)
。一种解决方法是对其进行加不同粒度的锁,就是为了防止某一个进程只修改某一行记录从而导致整个文件都不能使用的情况。
POSIX 提供了一种灵活的、不同粒度级别的锁机制,允许一个进程使用一个不可分割的操作对一个字节或者整个文件进行加锁。加锁机制要求尝试加锁的进程指定其 **要加锁的文件,开始位置以及要加锁的字节
Linux 系统提供了两种锁:共享锁和互斥锁。如果文件的一部分已经加上了共享锁,那么再加排他锁是不会成功的;如果文件系统的一部分已经被加了互斥锁,那么在互斥锁解除之前的任何加锁都不会成功。为了成功加锁、请求加锁的部分的所有字节都必须是可用的。
在加锁阶段,进程需要设计好加锁失败后的情况,也就是判断加锁失败后是否选择阻塞,如果选择阻塞式,那么当已经加锁的进程中的锁被删除时,这个进程会解除阻塞并替换锁。如果进程选择非阻塞式的,那么就不会替换这个锁,会立刻从系统调用中返回,标记状态码表示是否加锁成功,然后进程会选择下一个时间再次尝试。
加锁区域是可以重叠的。下面我们演示了三种不同条件的加锁区域。
如上图所示,A 的共享锁在第四字节到第八字节进行加锁
如上图所示,进程在 A 和 B 上同时加了共享锁,其中 6 – 8 字节是重叠锁
如上图所示,进程 A 和 B 和 C 同时加了共享锁,那么第六字节和第七字节是共享锁。
如果此时一个进程尝试在第 6 个字节处加锁,此时会设置失败并阻塞,由于该区域被 A B C 同时加锁,那么只有等到 A B C 都释放锁后,进程才能加锁成功。
Linux 文件系统调用
许多系统调用都会和文件与文件系统有关。我们首先先看一下对单个文件的系统调用,然后再来看一下对整个目录和文件的系统调用。
为了创建一个新的文件,会使用到 creat
方法,注意没有 e
。
这里说一个小插曲,曾经有人问 UNIX 创始人 Ken Thompson,如果有机会重新写 UNIX ,你会怎么办,他回答自己要把 creat 改成 create ,哈哈哈哈。
这个系统调用的两个参数是文件名和保护模式
fd = creat("aaa",mode);
这段命令会创建一个名为 aaa 的文件,并根据 mode 设置文件的保护位。这些位决定了哪个用户可能访问文件、如何访问。
creat 系统调用不仅仅创建了一个名为 aaa 的文件,还会打开这个文件。为了允许后续的系统调用访问这个文件,这个 creat 系统调用会返回一个 非负整数
, 这个就叫做 文件描述符(file descriptor)
,也就是上面的 fd。
如果在已经存在的文件上调用了 creat 系统调用,那么该文件中的内容会被清除,从 0 开始。通过设置合适的参数,open
系统调用也能够创建文件。
下面让我们看一看主要的系统调用,如下表所示
系统调用 | 描述 |
---|---|
fd = creat(name,mode) | 一种创建一个新文件的方式 |
fd = open(file, …) | 打开文件读、写或者读写 |
s = close(fd) | 关闭一个打开的文件 |
n = read(fd, buffer, nbytes) | 从文件中向缓存中读入数据 |
n = write(fd, buffer, nbytes) | 从缓存中向文件中写入数据 |
position = lseek(fd, offset, whence) | 移动文件指针 |
s = stat(name, &buf) | 获取文件信息 |
s = fstat(fd, &buf) | 获取文件信息 |
s = pipe(&fd[0]) | 创建一个管道 |
s = fcntl(fd,…) | 文件加锁等其他操作 |
为了对一个文件进行读写的前提是先需要打开文件,必须使用 creat 或者 open 打开,参数是打开文件的方式,是只读、可读写还是只写。open 系统调用也会返回文件描述符。打开文件后,需要使用 close
系统调用进行关闭。close 和 open 返回的 fd 总是未被使用的最小数量。
什么是文件描述符?文件描述符就是一个数字,这个数字标示了计算机操作系统中打开的文件。它描述了数据资源,以及访问资源的方式。
当程序要求打开一个文件时,内核会进行如下操作
- 授予访问权限
- 在
全局文件表(global file table)
中创建一个条目(entry)
- 向软件提供条目的位置
文件描述符由唯一的非负整数组成,系统上每个打开的文件至少存在一个文件描述符。文件描述符最初在 Unix 中使用,并且被包括 Linux,macOS 和 BSD 在内的现代操作系统所使用。
当一个进程成功访问一个打开的文件时,内核会返回一个文件描述符,这个文件描述符指向全局文件表的 entry 项。这个文件表项包含文件的 inode 信息,字节位移,访问限制等。例如下图所示
默认情况下,前三个文件描述符为 STDIN(标准输入)
、STDOUT(标准输出)
、STDERR(标准错误)
。
标准输入的文件描述符是 0 ,在终端中,默认为用户的键盘输入
标准输出的文件描述符是 1 ,在终端中,默认为用户的屏幕
与错误有关的默认数据流是 2,在终端中,默认为用户的屏幕。
在简单聊了一下文件描述符后,我们继续回到文件系统调用的探讨。
在文件系统调用中,开销最大的就是 read 和 write 了。read 和 write 都有三个参数
文件描述符
:告诉需要对哪一个打开文件进行读取和写入缓冲区地址
:告诉数据需要从哪里读取和写入哪里统计
:告诉需要传输多少字节
这就是所有的参数了,这个设计非常简单轻巧。
虽然几乎所有程序都按顺序读取和写入文件,但是某些程序需要能够随机访问文件的任何部分。与每个文件相关联的是一个指针,该指针指示文件中的当前位置。顺序读取(或写入)时,它通常指向要读取(写入)的下一个字节。如果指针在读取 1024 个字节之前位于 4096 的位置,则它将在成功读取系统调用后自动移至 5120 的位置。
Lseek
系统调用会更改指针位置的值,以便后续对 read 或 write 的调用可以在文件中的任何位置开始,甚至可以超出文件末尾。
lseek = Lseek ,段首大写。
lseek 避免叫做 seek 的原因就是 seek 已经在之前 16 位的计算机上用于搜素功能了。
Lseek
有三个参数:第一个是文件的文件描述符,第二个是文件的位置;第三个告诉文件位置是相对于文件的开头,当前位置还是文件的结尾
lseek(int fildes, off_t offset, int whence);
lseek 的返回值是更改文件指针后文件中的绝对位置。lseek 是唯一从来不会造成真正磁盘查找的系统调用,它只是更新当前的文件位置,这个文件位置就是内存中的数字。
对于每个文件,Linux 都会跟踪文件模式(常规,目录,特殊文件),大小,最后修改时间以及其他信息。程序能够通过 stat
系统调用看到这些信息。第一个参数就是文件名,第二个是指向要放置请求信息结构的指针。这些结构的属性如下图所示。
存储文件的设备 |
---|
存储文件的设备 |
i-node 编号 |
文件模式(包括保护位信息) |
文件链接的数量 |
文件所有者标识 |
文件所属的组 |
文件大小(字节) |
创建时间 |
最后一个修改/访问时间 |
fstat
调用和 stat
相同,只有一点区别,fstat 可以对打开文件进行操作,而 stat 只能对路径进行操作。
pipe
文件系统调用被用来创建 shell 管道。它会创建一系列的伪文件
,来缓冲和管道组件之间的数据,并且返回读取或者写入缓冲区的文件描述符。在管道中,像是如下操作
sort <in | head –40
sort 进程将会输出到文件描述符1,也就是标准输出,写入管道中,而 head 进程将从管道中读入。在这种方式中,sort 只是从文件描述符 0 中读取并写入到文件描述符 1 (管道)中,甚至不知道它们已经被重定向了。如果没有重定向的话,sort 会自动的从键盘读入并输出到屏幕中。
最后一个系统调用是 fcntl
,它用来锁定和解锁文件,应用共享锁和互斥锁,或者是执行一些文件相关的其他操作。
现在我们来关心一下和整体目录和文件系统相关的系统调用,而不是把精力放在单个的文件上,下面列出了这些系统调用,我们一起来看一下。
系统调用 | 描述 |
---|---|
s = mkdir(path,mode) | 创建一个新的目录 |
s = rmdir(path) | 移除一个目录 |
s = link(oldpath,newpath) | 创建指向已有文件的链接 |
s = unlink(path) | 取消文件的链接 |
s = chdir(path) | 改变工作目录 |
dir = opendir(path) | 打开一个目录读取 |
s = closedir(dir) | 关闭一个目录 |
dirent = readdir(dir) | 读取一个目录项 |
rewinddir(dir) | 回转目录使其在此使用 |
可以使用 mkdir 和 rmdir 创建和删除目录。但是需要注意,只有目录为空时才可以删除。
创建一个指向已有文件的链接时会创建一个目录项(directory entry)
。系统调用 link 来创建链接,oldpath 代表已有的路径,newpath 代表需要链接的路径,使用 unlink
可以删除目录项。当文件的最后一个链接被删除时,这个文件会被自动删除。
使用 chdir
系统调用可以改变工作目录。
最后四个系统调用是用于读取目录的。和普通文件类似,他们可以被打开、关闭和读取。每次调用 readdir
都会以固定的格式返回一个目录项。用户不能对目录执行写操作,但是可以使用 creat 或者 link 在文件夹中创建一个目录,或使用 unlink 删除一个目录。用户不能在目录中查找某个特定文件,但是可以使用 rewindir
作用于一个打开的目录,使他能在此从头开始读取。
Linux 文件系统的实现
下面我们主要讨论一下 虚拟文件系统(Virtual File System)
。 VFS 对高层进程和应用程序隐藏了 Linux 支持的所有文件系统的区别,以及文件系统是存储在本地设备,还是需要通过网络访问远程设备。设备和其他特殊文件和 VFS 层相关联。接下来,我们就会探讨一下第一个 Linux 广泛传播的文件系统: ext2
。随后,我们就会探讨 ext4
文件系统所做的改进。各种各样的其他文件系统也正在使用中。 所有 Linux 系统都可以处理多个磁盘分区,每个磁盘分区上都有不同的文件系统。
Linux 虚拟文件系统
为了能够使应用程序能够在不同类型的本地或者远程设备上的文件系统进行交互,因为在 Linux 当中文件系统千奇百种,比较常见的有 EXT3、EXT4,还有基于内存的 ramfs、tmpfs 和基于网络的 nfs,和基于用户态的 fuse,当然 fuse 应该不能完全的文件系统,只能算是一个能把文件系统实现放到用户态的模块,满足了内核文件系统的接口,他们都是文件系统的一种实现。对于这些文件系统,Linux 做了一层抽象就是 VFS
虚拟文件系统,
下表总结了 VFS 支持的四个主要的文件系统结构。
对象 | 描述 |
---|---|
超级块 | 特定的文件系统 |
Dentry | 目录项,路径的一个组成部分 |
I-node | 特定的文件 |
File | 跟一个进程相关联的打开文件 |
超级块(superblock)
包含了有关文件系统布局的重要信息,超级块如果遭到破坏那么就会导致整个文件系统不可读。
i-node
索引节点,包含了每一个文件的描述符。
在 Linux 中,目录和设备也表示为文件,因为它们具有对应的 i-node
超级块和索引块所在的文件系统都在磁盘上有对应的结构。
为了便于某些目录操作和路径遍历,比如 /usr/local/cxuan,VFS 支持一个 dentry
数据结构,该数据结构代表着目录项。这个 dentry 数据结构有很多东西(http://books.gigatux.nl/mirror/kerneldevelopment/0672327201/ch12lev1sec7.html)这个数据结构由文件系统动态创建。
目录项被缓存在 dentry_cache
缓存中。例如,缓存条目会缓存 /usr 、 /usr/local 等条目。如果多个进程通过硬连接访问相同的文件,他们的文件对象将指向此缓存中的相同条目。
最后,文件数据结构是代表着打开的文件,也代表着内存表示,它根据 open 系统调用创建。它支持 read、write、sendfile、lock 和其他在我们之前描述的系统调用中。
在 VFS 下实现的实际文件系统不需要在内部使用完全相同的抽象和操作。 但是,它们必须在语义上实现与 VFS 对象指定的文件系统操作相同的文件系统操作。 四个 VFS 对象中每个对象的操作数据结构的元素都是指向基础文件系统中功能的指针。
Linux Ext2 文件系统
现在我们一起看一下 Linux 中最流行的一个磁盘文件系统,那就是 ext2
。Linux 的第一个版本用于 MINIX1
文件系统,它的文件名大小被限制为最大 64 MB。MINIX 1 文件系统被永远的被它的扩展系统 ext 取代,因为 ext 允许更长的文件名和文件大小。由于 ext 的性能低下,ext 被其替代者 ext2 取代,ext2 目前仍在广泛使用。
一个 ext2 Linux 磁盘分区包含了一个文件系统,这个文件系统的布局如下所示
Boot 块也就是第 0 块不是让 Linux 使用的,而是用来加载和引导计算机启动代码的。在块 0 之后,磁盘分区被分成多个组,这些组与磁盘柱面边界所处的位置无关。
第一个块是 超级块(superblock)
。它包含有关文件系统布局的信息,包括 i-node、磁盘块数量和以及空闲磁盘块列表的开始。下一个是 组描述符(group descriptor)
,其中包含有关位图的位置,组中空闲块和 i-node 的数量以及组中的目录数量的信息。这些信息很重要,因为 ext2 会在磁盘上均匀分布目录。
图中的两个位图用来记录空闲块和空闲 i-node,这是从 MINIX 1文件系统继承的选择,大多数 UNIX 文件系统使用位图而不是空闲列表。每个位图的大小是一个块。如果一个块的大小是 1 KB,那么就限制了块组的数量是 8192 个块和 8192 个 i-node。块的大小是一个严格的限制,块组的数量不固定,在 4KB 的块中,块组的数量增大四倍。
在超级块之后分布的是 i-node
它们自己,i-node 取值范围是 1 – 某些最大值。每个 i-node 是 128 字节的 long
,这些字节恰好能够描述一个文件。i-node 包含了统计信息(包含了 stat
系统调用能获得的所有者信息,实际上 stat 就是从 i-node 中读取信息的),以及足够的信息来查找保存文件数据的所有磁盘块。
在 i-node 之后的是 数据块(data blocks)
。所有的文件和目录都保存在这。如果一个文件或者目录包含多个块,那么这些块在磁盘中的分布不一定是连续的,也有可能不连续。事实上,大文件块可能会被拆分成很多小块散布在整个磁盘上。
对应于目录的 i-node 分散在整个磁盘组上。如果有足够的空间,ext2 会把普通文件组织到与父目录相同的块组中,而把同一块上的数据文件组织成初始 i-node
节点。位图用来快速确定新文件系统数据的分配位置。在分配新的文件块时,ext2 也会给该文件预分配许多额外的数据块,这样可以减少将来向文件写入数据时产生的文件碎片。这种策略在整个磁盘上实现了文件系统的 负载
,后续还有对文件碎片的排列和整理,而且性能也比较好。
为了达到访问的目的,需要首先使用 Linux 系统调用,例如 open
,这个系统调用会确定打开文件的路径。路径分为两种,相对路径
和 绝对路径
。如果使用相对路径,那么就会从当前目录开始查找,否则就会从根目录进行查找。
目录文件的文件名最高不能超过 255 个字符,它的分配如下图所示
每一个目录都由整数个磁盘块组成,这样目录就可以整体的写入磁盘。在一个目录中,文件和子目录的目录项都是未经排序的,并且一个挨着一个。目录项不能跨越磁盘块,所以通常在每个磁盘块的尾部会有部分未使用的字节。
上图中每个目录项都由四个固定长度的属性和一个长度可变的属性组成。第一个属性是 i-node
节点数量,文件 first 的 i-node 编号是 19 ,文件 second 的编号是 42,目录 third 的 i-node 编号是 88。紧随其后的是 rec_len
域,表明目录项大小是多少字节,名称后面会有一些扩展,当名字以未知长度填充时,这个域被用来寻找下一个目录项,直至最后的未使用。这也是图中箭头的含义。紧随其后的是 类型域
:F 表示的是文件,D 表示的是目录,最后是固定长度的文件名,上面的文件名的长度依次是 5、6、5,最后以文件名结束。
rec_len 域是如何扩展的呢?如下图所示
我们可以看到,中间的 second
被移除了,所以将其所在的域变为第一个目录项的填充。当然,这个填充可以作为后续的目录项。
由于目录是按照线性的顺序进行查找的,因此可能需要很长时间才能在大文件末尾找到目录项。因此,系统会为近期的访问目录维护一个缓存。这个缓存用文件名来查找,如果缓存命中,那么就会避免线程搜索这样昂贵的开销。组成路径的每个部分都在目录缓存中保存一个 dentry
对象,并且通过 i-node 找到后续的路径元素的目录项,直到找到真正的文件 i – node。
比如说要使用绝对路径来寻找一个文件,我们暂定这个路径是 /usr/local/file
,那么需要经过如下几个步骤:
- 首先,系统会确定根目录,它通常使用 2 号 i -node ,也就是索引 2 节点,因为索引节点 1 是 ext2 /3/4 文件系统上的
坏块
索引节点。系统会将一项放在 dentry 缓存中,以应对将来对根目录的查找。 - 然后,在根目录中查找字符串
usr
,得到 /usr 目录的 i – node 节点号。/usr 的 i – node 同样也进入 dentry 缓存。然后节点被取出,并从中解析出磁盘块,这样就可以读取 /usr 目录并查找字符串local
了。一旦找到这个目录项,目录/usr/local
的 i – node 节点就可以从中获得。有了 /usr/local 的 i – node 节点号,就可以读取 i – node 并确定目录所在的磁盘块。最后,从 /usr/local 目录查找 file 并确定其 i – node 节点呢号。
如果文件存在,那么系统会提取 i – node 节点号并把它作为索引在 i – node 节点表中定位相应的 i – node 节点并装入内存。i – node 被存放在 i – node 节点表(i-node table)
中,节点表是一个内核数据结构,它会持有当前打开文件和目录的 i – node 节点号。下面是一些 Linux 文件系统支持的 i – node 数据结构。
属性 | 字节 | 描述 |
---|---|---|
Mode | 2 | 文件属性、保护位、setuid 和 setgid 位 |
Nlinks | 2 | 指向 i – node 节点目录项的数目 |
Uid | 2 | 文件所有者的 UID |
Gid | 2 | 文件所有者的 GID |
Size | 4 | 文件字节大小 |
Addr | 60 | 12 个磁盘块以及后面 3 个间接块的地址 |
Gen | 1 | 每次重复使用 i – node 时增加的代号 |
Atime | 4 | 最近访问文件的时间 |
Mtime | 4 | 最近修改文件的时间 |
Ctime | 4 | 最近更改 i – node 的时间 |
现在我们来一起探讨一下文件读取过程,还记得 read
函数是如何调用的吗?
n = read(fd,buffer,nbytes);
当内核接管后,它会从这三个参数以及内部表与用户有关的信息开始。内部表的其中一项是文件描述符数组。文件描述符数组用文件描述符
作为索引并为每一个打开文件保存一个表项。
文件是和 i – node 节点号相关的。那么如何通过一个文件描述符找到文件对应的 i – node 节点呢?
这里使用的一种设计思想是在文件描述符表和 i – node 节点表之间插入一个新的表,叫做 打开文件描述符(open-file-description table)
。文件的读写位置会在打开文件描述符表中存在,如下图所示
我们使用 shell 、P1 和 P2 来描述一下父进程、子进程、子进程的关系。Shell 首先生成 P1,P1 的数据结构就是 Shell 的一个副本,因此两者都指向相同的打开文件描述符的表项。当 P1 运行完成后,Shell 的文件描述符仍会指向 P1 文件位置的打开文件描述。然后 Shell 生成了 P2,新的子进程自动继承文件的读写位置,甚至 P2 和 Shell 都不知道文件具体的读写位置。
上面描述的是父进程和子进程这两个 相关
进程,如果是一个不相关进程打开文件时,它将得到自己的打开文件描述符表项,以及自己的文件读写位置,这是我们需要的。
因此,打开文件描述符相当于是给相关进程提供同一个读写位置,而给不相关进程提供各自私有的位置。
i – node 包含三个间接块的磁盘地址,它们每个指向磁盘块的地址所能够存储的大小不一样。
Linux Ext4 文件系统
为了防止由于系统崩溃和电源故障造成的数据丢失,ext2 系统必须在每个数据块创建之后立即将其写入到磁盘上,磁盘磁头寻道操作导致的延迟是无法让人忍受的。为了增强文件系统的健壮性,Linux 依靠日志文件系统
,ext3 是一个日志文件系统,它在 ext2 文件系统的基础之上做了改进,ext4 也是 ext3 的改进,ext4 也是一个日志文件系统。ext4 改变了 ext3 的块寻址方案,从而支持更大的文件和更大的文件系统大小。下面我们就来描述一下 ext4 文件系统的特性。
具有记录的文件系统最基本的功能就是记录日志
,这个日志记录了按照顺序描述所有文件系统的操作。通过顺序写出文件系统数据或元数据的更改,操作不受磁盘访问期间磁盘头移动的开销。最终,这个变更会写入并提交到合适的磁盘位置上。如果这个变更在提交到磁盘前文件系统宕机了,那么在重启期间,系统会检测到文件系统未正确卸载,那么就会遍历日志并应用日志的记录来对文件系统进行更改。
Ext4 文件系统被设计用来高度匹配 ext2 和 ext3 文件系统的,尽管 ext4 文件系统在内核数据结构和磁盘布局上都做了变更。尽管如此,一个文件系统能够从 ext2 文件系统上卸载后成功的挂载到 ext4 文件系统上,并提供合适的日志记录。
日志是作为循环缓冲区管理的文件。日志可以存储在与主文件系统相同或者不同的设备上。日志记录的读写操作会由单独的 JBD(Journaling Block Device)
来扮演。
JBD 中有三个主要的数据结构,分别是 log record(日志记录)、原子操作和事务。一个日志记录描述了一个低级别的文件系统操作,这个操作通常导致块内的变化。因为像是 write
这种系统调用会包含多个地方的改动 — i – node 节点,现有的文件块,新的文件块和空闲列表等。相关的日志记录会以原子性的方式分组。ext4 会通知系统调用进程的开始和结束,以此使 JBD 能够确保原子操作的记录都能被应用,或者一个也不被应用。最后,主要从效率方面考虑,JBD 会视原子操作的集合为事务。一个事务中的日志记录是连续存储的。只有在所有的变更一起应用到磁盘后,日志记录才能够被丢弃。
由于为每个磁盘写出日志的开销会很大,所以 ext4 可以配置为保留所有磁盘更改的日志,或者仅仅保留与文件系统元数据相关的日志更改。仅仅记录元数据可以减少系统开销,提升性能,但不能保证不会损坏文件数据。其他的几个日志系统维护着一系列元数据操作的日志,例如 SGI 的 XFS。
/proc 文件系统
另外一个 Linux 文件系统是 /proc
(process) 文件系统
它的主要思想来源于贝尔实验室开发的第 8 版的 UNIX,后来被 BSD 和 System V 采用。
然而,Linux 在一些方面上对这个想法进行了扩充。它的基本概念是为系统中的每个进程在 /proc
中创建一个目录。目录的名字就是进程 PID,以十进制数进行表示。例如,/proc/1024
就是一个进程号为 1024 的目录。在该目录下是进程信息相关的文件,比如进程的命令行、环境变量和信号掩码等。事实上,这些文件在磁盘上并不存在磁盘中。当需要这些信息的时候,系统会按需从进程中读取,并以标准格式返回给用户。
许多 Linux 扩展与 /proc
中的其他文件和目录有关。它们包含各种各样的关于 CPU、磁盘分区、设备、中断向量、内核计数器、文件系统、已加载模块等信息。非特权用户可以读取很多这样的信息,于是就可以通过一种安全的方式了解系统情况。
NFS 网络文件系统
从一开始,网络就在 Linux 中扮演了很重要的作用。下面我们会探讨一下 NFS(Network File System)
网络文件系统,它在现代 Linux 操作系统的作用是将不同计算机上的不同文件系统链接成一个逻辑整体。
NFS 架构
NFS 最基本的思想是允许任意选定的一些客户端
和服务器
共享一个公共文件系统。在许多情况下,所有的客户端和服务器都会在同一个 LAN(Local Area Network)
局域网内共享,但是这并不是必须的。也可能是下面这样的情况:如果客户端和服务器距离较远,那么它们也可以在广域网上运行。客户端可以是服务器,服务器可以是客户端,但是为了简单起见,我们说的客户端就是消费服务,而服务器就是提供服务的角度来聊。
每一个 NFS 服务都会导出一个或者多个目录供远程客户端访问。当一个目录可用时,它的所有子目录也可用。因此,通常整个目录树都会作为一个整体导出。服务器导出的目录列表会用一个文件来维护,这个文件是 /etc/exports
,当服务器启动后,这些目录可以自动的被导出。客户端通过挂载这些导出的目录来访问它们。当一个客户端挂载了一个远程目录,这个目录就成为客户端目录层次的一部分,如下图所示。
在这个示例中,一号客户机挂载到服务器的 bin 目录下,因此它现在可以使用 shell 访问 /bin/cat 或者其他任何一个目录。同样,客户机 1 也可以挂载到 二号服务器上从而访问 /usr/local/projects/proj1 或者其他目录。二号客户机同样可以挂载到二号服务器上,访问路径是 /mnt/projects/proj2。
从上面可以看到,由于不同的客户端将文件挂载到各自目录树的不同位置,同一个文件在不同的客户端有不同的访问路径和不同的名字。挂载点一般通常在客户端本地,服务器不知道任何一个挂载点的存在。
NFS 协议
由于 NFS 的协议之一是支持 异构
系统,客户端和服务器可能在不同的硬件上运行不同的操作系统,因此有必要在服务器和客户端之间进行接口定义。这样才能让任何写一个新客户端能够和现有的服务器一起正常工作,反之亦然。
NFS 就通过定义两个客户端 – 服务器协议从而实现了这个目标。协议就是客户端发送给服务器的一连串的请求,以及服务器发送回客户端的相应答复。
第一个 NFS 协议是处理挂载。客户端可以向服务器发送路径名并且请求服务器是否能够将服务器的目录挂载到自己目录层次上。因为服务器不关心挂载到哪里,因此请求不会包含挂载地址。如果路径名是合法的并且指定的目录已经被导出,那么服务器会将文件 句柄
返回给客户端。
文件句柄包含唯一标识文件系统类型,磁盘,目录的i节点号和安全性信息的字段。
随后调用读取和写入已安装目录或其任何子目录中的文件,都将使用文件句柄。
当 Linux 启动时会在多用户之前运行 shell 脚本 /etc/rc 。可以将挂载远程文件系统的命令写入该脚本中,这样就可以在允许用户登陆之前自动挂载必要的远程文件系统。大部分 Linux 版本是支持自动挂载
的。这个特性会支持将远程目录和本地目录进行关联。
相对于手动挂载到 /etc/rc 目录下,自动挂载具有以下优势
- 如果列出的 /etc/rc 目录下出现了某种故障,那么客户端将无法启动,或者启动会很困难、延迟或者伴随一些出错信息,如果客户根本不需要这个服务器,那么手动做了这些工作就白费了。
- 允许客户端并行的尝试一组服务器,可以实现一定程度的容错率,并且性能也可以得到提高。
另一方面,我们默认在自动挂载时所有可选的文件系统都是相同的。由于 NFS 不提供对文件或目录复制的支持,用户需要自己确保这些所有的文件系统都是相同的。因此,大部分的自动挂载都只应用于二进制文件和很少改动的只读的文件系统。
第二个 NFS 协议是为文件和目录的访问而设计的。客户端能够通过向服务器发送消息来操作目录和读写文件。客户端也可以访问文件属性,比如文件模式、大小、上次修改时间。NFS 支持大多数的 Linux 系统调用,但是 open 和 close 系统调用却不支持。
不支持 open 和 close 并不是一种疏忽,而是一种刻意的设计,完全没有必要在读一个文件之前对其进行打开,也没有必要在读完时对其进行关闭。
NFS 使用了标准的 UNIX 保护机制,使用 rwx
位来标示所有者(owner)
、组(groups)
、其他用户
。最初,每个请求消息都会携带调用者的 groupId 和 userId,NFS 会对其进行验证。事实上,它会信任客户端不会发生欺骗行为。可以使用公钥密码来创建一个安全密钥,在每次请求和应答中使用它验证客户端和服务器。
NFS 实现
即使客户端和服务器的代码实现是独立于 NFS 协议的,大部分的 Linux 系统会使用一个下图的三层实现,顶层是系统调用层,系统调用层能够处理 open 、 read 、 close 这类的系统调用。在解析和参数检查结束后调用第二层,虚拟文件系统 (VFS)
层。
VFS 层的任务是维护一个表,每个已经打开的文件都在表中有一个表项。VFS 层为每一个打开的文件维护着一个虚拟i节点
,简称为 v – node。v 节点用来说明文件是本地文件还是远程文件。如果是远程文件的话,那么 v – node 会提供足够的信息使客户端能够访问它们。对于本地文件,会记录其所在的文件系统和文件的 i-node ,因为现代操作系统能够支持多文件系统。虽然 VFS 是为了支持 NFS 而设计的,但是现代操作系统都会使用 VFS,而不管有没有 NFS。
Linux进程和线程
上一篇文章
https://github.com/crisxuan/bestJavaer/blob/master/linux/linux-first.md
只是简单的描述了一下 Linux 基本概念,通过几个例子来说明 Linux 基本应用程序,然后以 Linux 基本内核构造来结尾。那么本篇文章我们就深入理解一下 Linux 内核来理解 Linux 的基本概念之进程和线程。系统调用是操作系统本身的接口,它对于创建进程和线程,内存分配,共享文件和 I/O 来说都很重要。
我们将从各个版本的共性出发来进行探讨。
基本概念
Linux 一个非常重要的概念就是进程,Linux 进程和我们在现代操作系统中探讨的进程模型非常相似。每个进程都会运行一段独立的程序,并且在初始化的时候拥有一个独立的控制线程。换句话说,每个进程都会有一个自己的程序计数器,这个程序计数器用来记录下一个需要被执行的指令。Linux 允许进程在运行时创建额外的线程。
Linux 是一个多道程序设计系统,因此系统中存在彼此相互独立的进程同时运行。此外,每个用户都会同时有几个活动的进程。因为如果是一个大型系统,可能有数百上千的进程在同时运行。
在某些用户空间中,即使用户退出登录,仍然会有一些后台进程在运行,这些进程被称为 守护进程(daemon)
。
Linux 中有一种特殊的守护进程被称为 计划守护进程(Cron daemon)
,计划守护进程可以每分钟醒来一次检查是否有工作要做,做完会继续回到睡眠状态等待下一次唤醒。
Cron 是一个守护程序,可以做任何你想做的事情,比如说你可以定期进行系统维护、定期进行系统备份等。在其他操作系统上也有类似的程序,比如 Mac OS X 上 Cron 守护程序被称为
launchd
的守护进程。在 Windows 上可以被称为计划任务(Task Scheduler)
。
在 Linux 系统中,进程通过非常简单的方式来创建,fork
系统调用会创建一个源进程的拷贝(副本)
。调用 fork 函数的进程被称为 父进程(parent process)
,使用 fork 函数创建出来的进程被称为 子进程(child process)
。父进程和子进程都有自己的内存映像。如果在子进程创建出来后,父进程修改了一些变量等,那么子进程是看不到这些变化的,也就是 fork 后,父进程和子进程相互独立。
虽然父进程和子进程保持相互独立,但是它们却能够共享相同的文件,如果在 fork 之前,父进程已经打开了某个文件,那么 fork 后,父进程和子进程仍然共享这个打开的文件。对共享文件的修改会对父进程和子进程同时可见。
那么该如何区分父进程和子进程呢?子进程只是父进程的拷贝,所以它们几乎所有的情况都一样,包括内存映像、变量、寄存器等。区分的关键在于 fork
函数调用后的返回值,如果 fork 后返回一个非零值,这个非零值即是子进程的 进程标识符(Process Identiier, PID)
,而会给子进程返回一个零值,可以用下面代码来进行表示
pid = fork(); // 调用 fork 函数创建进程
if(pid < 0){
error() // pid < 0,创建失败
}
else if(pid > 0){
parent_handle() // 父进程代码
}
else {
child_handle() // 子进程代码
}
父进程在 fork 后会得到子进程的 PID,这个 PID 即能代表这个子进程的唯一标识符也就是 PID。如果子进程想要知道自己的 PID,可以调用 getpid
方法。当子进程结束运行时,父进程会得到子进程的 PID,因为一个进程会 fork 很多子进程,子进程也会 fork 子进程,所以 PID 是非常重要的。我们把第一次调用 fork 后的进程称为 原始进程
,一个原始进程可以生成一颗继承树
Linux 进程间通信
Linux 进程间的通信机制通常被称为 Internel-Process communication,IPC
下面我们来说一说 Linux 进程间通信的机制,大致来说,Linux 进程间的通信机制可以分为 6 种
下面我们分别对其进行概述
信号 signal
信号是 UNIX 系统最先开始使用的进程间通信机制,因为 Linux 是继承于 UNIX 的,所以 Linux 也支持信号机制,通过向一个或多个进程发送异步事件信号
来实现,信号可以从键盘或者访问不存在的位置等地方产生;信号通过 shell 将任务发送给子进程。
你可以在 Linux 系统上输入 kill -l
来列出系统使用的信号,下面是我提供的一些信号
进程可以选择忽略发送过来的信号,但是有两个是不能忽略的:SIGSTOP
和 SIGKILL
信号。SIGSTOP 信号会通知当前正在运行的进程执行关闭操作,SIGKILL 信号会通知当前进程应该被杀死。除此之外,进程可以选择它想要处理的信号,进程也可以选择阻止信号,如果不阻止,可以选择自行处理,也可以选择进行内核处理。如果选择交给内核进行处理,那么就执行默认处理。
操作系统会中断目标程序的进程来向其发送信号、在任何非原子指令中,执行都可以中断,如果进程已经注册了新号处理程序,那么就执行进程,如果没有注册,将采用默认处理的方式。
例如:当进程收到 SIGFPE
浮点异常的信号后,默认操作是对其进行 dump(转储)
和退出。信号没有优先级的说法。如果同时为某个进程产生了两个信号,则可以将它们呈现给进程或者以任意的顺序进行处理。
下面我们就来看一下这些信号是干什么用的
- SIGABRT 和 SIGIOT
SIGABRT 和 SIGIOT 信号发送给进程,告诉其进行终止,这个 信号通常在调用 C标准库的abort()
函数时由进程本身启动
- SIGALRM 、 SIGVTALRM、SIGPROF
当设置的时钟功能超时时会将 SIGALRM 、 SIGVTALRM、SIGPROF 发送给进程。当实际时间或时钟时间超时时,发送 SIGALRM。 当进程使用的 CPU 时间超时时,将发送 SIGVTALRM。 当进程和系统代表进程使用的CPU 时间超时时,将发送 SIGPROF。
- SIGBUS
SIGBUS 将造成总线中断
错误时发送给进程
- SIGCHLD
当子进程终止、被中断或者被中断恢复,将 SIGCHLD 发送给进程。此信号的一种常见用法是指示操作系统在子进程终止后清除其使用的资源。
- SIGCONT
SIGCONT 信号指示操作系统继续执行先前由 SIGSTOP 或 SIGTSTP 信号暂停的进程。该信号的一个重要用途是在 Unix shell 中的作业控制中。
- SIGFPE
SIGFPE 信号在执行错误的算术运算(例如除以零)时将被发送到进程。
- SIGUP
当 SIGUP 信号控制的终端关闭时,会发送给进程。许多守护程序将重新加载其配置文件并重新打开其日志文件,而不是在收到此信号时退出。
- SIGILL
SIGILL 信号在尝试执行非法、格式错误、未知或者特权指令时发出
- SIGINT
当用户希望中断进程时,操作系统会向进程发送 SIGINT 信号。用户输入 ctrl – c 就是希望中断进程。
- SIGKILL
SIGKILL 信号发送到进程以使其马上进行终止。 与 SIGTERM 和 SIGINT 相比,这个信号无法捕获和忽略执行,并且进程在接收到此信号后无法执行任何清理操作,下面是一些例外情况
僵尸进程无法杀死,因为僵尸进程已经死了,它在等待父进程对其进行捕获
处于阻塞状态的进程只有再次唤醒后才会被 kill 掉
init
进程是 Linux 的初始化进程,这个进程会忽略任何信号。
SIGKILL 通常是作为最后杀死进程的信号、它通常作用于 SIGTERM 没有响应时发送给进程。
- SIGPIPE
SIGPIPE 尝试写入进程管道时发现管道未连接无法写入时发送到进程
- SIGPOLL
当在明确监视的文件描述符上发生事件时,将发送 SIGPOLL 信号。
- SIGRTMIN 至 SIGRTMAX
SIGRTMIN 至 SIGRTMAX 是实时信号
- SIGQUIT
当用户请求退出进程并执行核心转储时,SIGQUIT 信号将由其控制终端发送给进程。
- SIGSEGV
当 SIGSEGV 信号做出无效的虚拟内存引用或分段错误时,即在执行分段违规时,将其发送到进程。
- SIGSTOP
SIGSTOP 指示操作系统终止以便以后进行恢复时
- SIGSYS
当 SIGSYS 信号将错误参数传递给系统调用时,该信号将发送到进程。
- SYSTERM
我们上面简单提到过了 SYSTERM 这个名词,这个信号发送给进程以请求终止。与 SIGKILL 信号不同,该信号可以被过程捕获或忽略。这允许进程执行良好的终止,从而释放资源并在适当时保存状态。 SIGINT 与SIGTERM 几乎相同。
- SIGTSIP
SIGTSTP 信号由其控制终端发送到进程,以请求终端停止。
- SIGTTIN 和 SIGTTOU
当 SIGTTIN 和SIGTTOU 信号分别在后台尝试从 tty 读取或写入时,信号将发送到该进程。
- SIGTRAP
在发生异常或者 trap 时,将 SIGTRAP 信号发送到进程
- SIGURG
当套接字具有可读取的紧急或带外数据时,将 SIGURG 信号发送到进程。
- SIGUSR1 和 SIGUSR2
SIGUSR1 和 SIGUSR2 信号被发送到进程以指示用户定义的条件。
- SIGXCPU
当 SIGXCPU 信号耗尽 CPU 的时间超过某个用户可设置的预定值时,将其发送到进程
- SIGXFSZ
当 SIGXFSZ 信号增长超过最大允许大小的文件时,该信号将发送到该进程。
- SIGWINCH
SIGWINCH 信号在其控制终端更改其大小(窗口更改)时发送给进程。
管道 pipe
Linux 系统中的进程可以通过建立管道 pipe 进行通信
在两个进程之间,可以建立一个通道,一个进程向这个通道里写入字节流,另一个进程从这个管道中读取字节流。管道是同步的,当进程尝试从空管道读取数据时,该进程会被阻塞,直到有可用数据为止。shell 中的管线 pipelines
就是用管道实现的,当 shell 发现输出
sort <f | head
它会创建两个进程,一个是 sort,一个是 head,sort,会在这两个应用程序之间建立一个管道使得 sort 进程的标准输出作为 head 程序的标准输入。sort 进程产生的输出就不用写到文件中了,如果管道满了系统会停止 sort 以等待 head 读出数据
管道实际上就是 |
,两个应用程序不知道有管道的存在,一切都是由 shell 管理和控制的。
共享内存 shared memory
两个进程之间还可以通过共享内存进行进程间通信,其中两个或者多个进程可以访问公共内存空间。两个进程的共享工作是通过共享内存完成的,一个进程所作的修改可以对另一个进程可见(很像线程间的通信)。
在使用共享内存前,需要经过一系列的调用流程,流程如下
- 创建共享内存段或者使用已创建的共享内存段
(shmget())
- 将进程附加到已经创建的内存段中
(shmat())
- 从已连接的共享内存段分离进程
(shmdt())
- 对共享内存段执行控制操作
(shmctl())
先入先出队列 FIFO
先入先出队列 FIFO 通常被称为 命名管道(Named Pipes)
,命名管道的工作方式与常规管道非常相似,但是确实有一些明显的区别。未命名的管道没有备份文件:操作系统负责维护内存中的缓冲区,用来将字节从写入器传输到读取器。一旦写入或者输出终止的话,缓冲区将被回收,传输的数据会丢失。相比之下,命名管道具有支持文件和独特 API ,命名管道在文件系统中作为设备的专用文件存在。当所有的进程通信完成后,命名管道将保留在文件系统中以备后用。命名管道具有严格的 FIFO 行为
写入的第一个字节是读取的第一个字节,写入的第二个字节是读取的第二个字节,依此类推。
消息队列 Message Queue
一听到消息队列这个名词你可能不知道是什么意思,消息队列是用来描述内核寻址空间内的内部链接列表。可以按几种不同的方式将消息按顺序发送到队列并从队列中检索消息。每个消息队列由 IPC 标识符唯一标识。消息队列有两种模式,一种是严格模式
, 严格模式就像是 FIFO 先入先出队列似的,消息顺序发送,顺序读取。还有一种模式是 非严格模式
,消息的顺序性不是非常重要。
套接字 Socket
还有一种管理两个进程间通信的是使用 socket
,socket 提供端到端的双相通信。一个套接字可以与一个或多个进程关联。就像管道有命令管道和未命名管道一样,套接字也有两种模式,套接字一般用于两个进程之间的网络通信,网络套接字需要来自诸如TCP(传输控制协议)
或较低级别UDP(用户数据报协议)
等基础协议的支持。
套接字有以下几种分类
顺序包套接字(Sequential Packet Socket)
: 此类套接字为最大长度固定的数据报提供可靠的连接。此连接是双向的并且是顺序的。数据报套接字(Datagram Socket)
:数据包套接字支持双向数据流。数据包套接字接受消息的顺序与发送者可能不同。流式套接字(Stream Socket)
:流套接字的工作方式类似于电话对话,提供双向可靠的数据流。原始套接字(Raw Socket)
: 可以使用原始套接字访问基础通信协议。
Linux 中进程管理系统调用
现在关注一下 Linux 系统中与进程管理相关的系统调用。在了解之前你需要先知道一下什么是系统调用。
操作系统为我们屏蔽了硬件和软件的差异,它的最主要功能就是为用户提供一种抽象,隐藏内部实现,让用户只关心在 GUI 图形界面下如何使用即可。操作系统可以分为两种模式
- 内核态:操作系统内核使用的模式
- 用户态:用户应用程序所使用的模式
我们常说的上下文切换
指的就是内核态模式和用户态模式的频繁切换。而系统调用
指的就是引起内核态和用户态切换的一种方式,系统调用通常在后台静默运行,表示计算机程序向其操作系统内核请求服务。
系统调用指令有很多,下面是一些与进程管理相关的最主要的系统调用
fork
fork 调用用于创建一个与父进程相同的子进程,创建完进程后的子进程拥有和父进程一样的程序计数器、相同的 CPU 寄存器、相同的打开文件。
exec
exec 系统调用用于执行驻留在活动进程中的文件,调用 exec 后,新的可执行文件会替换先前的可执行文件并获得执行。也就是说,调用 exec 后,会将旧文件或程序替换为新文件或执行,然后执行文件或程序。新的执行程序被加载到相同的执行空间中,因此进程的 PID
不会修改,因为我们没有创建新进程,只是替换旧进程。但是进程的数据、代码、堆栈都已经被修改。如果当前要被替换的进程包含多个线程,那么所有的线程将被终止,新的进程映像被加载执行。
这里需要解释一下进程映像(Process image)
的概念
什么是进程映像呢?进程映像是执行程序时所需要的可执行文件,通常会包括下面这些东西
- **代码段(codesegment/textsegment)
又称文本段,用来存放指令,运行代码的一块内存空间
此空间大小在代码运行前就已经确定
内存空间一般属于只读,某些架构的代码也允许可写
在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
- **数据段(datasegment)
可读可写
存储初始化的全局变量和初始化的 static 变量
数据段中数据的生存期是随程序持续性(随进程持续性) 随进程持续性:进程创建就存在,进程死亡就消失
- **bss 段(bsssegment):
可读可写
存储未初始化的全局变量和未初始化的 static 变量
bss 段中的数据一般默认为 0
- **Data 段
是可读写的,因为变量的值可以在运行时更改。此段的大小也固定。
- **栈(stack):
可读可写
存储的是函数或代码中的局部变量(非 static 变量)
栈的生存期随代码块持续性,代码块运行就给你分配空间,代码块结束,就自动回收空间
- **堆(heap):
可读可写
存储的是程序运行期间动态分配的 malloc/realloc 的空间
堆的生存期随进程持续性,从 malloc/realloc 到 free 一直存在
下面是这些区域的构成图
exec 系统调用是一些函数的集合,这些函数是
- execl
- execle
- execlp
- execv
- execve
- execvp
下面来看一下 exec 的工作原理
- 当前进程映像被替换为新的进程映像
- 新的进程映像是你做为 exec 传递的灿睡
- 结束当前正在运行的进程
- 新的进程映像有 PID,相同的环境和一些文件描述符(因为未替换进程,只是替换了进程映像)
- CPU 状态和虚拟内存受到影响,当前进程映像的虚拟内存映射被新进程映像的虚拟内存代替。
waitpid
等待子进程结束或终止
exit
在许多计算机操作系统上,计算机进程的终止是通过执行 exit
系统调用命令执行的。0 表示进程能够正常结束,其他值表示进程以非正常的行为结束。
其他一些常见的系统调用如下
系统调用指令 | 描述 |
---|---|
pause | 挂起信号 |
nice | 改变分时进程的优先级 |
ptrace | 进程跟踪 |
kill | 向进程发送信号 |
pipe | 创建管道 |
mkfifo | 创建 fifo 的特殊文件(命名管道) |
sigaction | 设置对指定信号的处理方法 |
msgctl | 消息控制操作 |
semctl | 信号量控制 |
Linux 进程和线程的实现
Linux 进程
Linux 进程就像一座冰山,你看到的只是冰山一角。
在 Linux 内核结构中,进程会被表示为 任务
,通过结构体 structure
来创建。不像其他的操作系统会区分进程、轻量级进程和线程,Linux 统一使用任务结构来代表执行上下文。因此,对于每个单线程进程来说,单线程进程将用一个任务结构表示,对于多线程进程来说,将为每一个用户级线程分配一个任务结构。Linux 内核是多线程的,并且内核级线程不与任何用户级线程相关联。
对于每个进程来说,在内存中都会有一个 task_struct
进程描述符与之对应。进程描述符包含了内核管理进程所有有用的信息,包括 调度参数、打开文件描述符等等。进程描述符从进程创建开始就一直存在于内核堆栈中。
Linux 和 Unix 一样,都是通过 PID
来区分不同的进程,内核会将所有进程的任务结构组成为一个双向链表。PID 能够直接被映射称为进程的任务结构所在的地址,从而不需要遍历双向链表直接访问。
我们上面提到了进程描述符,这是一个非常重要的概念,我们上面还提到了进程描述符是位于内存中的,这里我们省略了一句话,那就是进程描述符是存在用户的任务结构中,当进程位于内存并开始运行时,进程描述符才会被调入内存。
进程位于内存
被称为PIM(Process In Memory)
,这是冯诺伊曼体系架构的一种体现,加载到内存中并执行的程序称为进程。简单来说,一个进程就是正在执行的程序。
进程描述符可以归为下面这几类
调度参数(scheduling parameters)
:进程优先级、最近消耗 CPU 的时间、最近睡眠时间一起决定了下一个需要运行的进程内存映像(memory image)
:我们上面说到,进程映像是执行程序时所需要的可执行文件,它由数据和代码组成。信号(signals)
:显示哪些信号被捕获、哪些信号被执行寄存器
:当发生内核陷入 (trap) 时,寄存器的内容会被保存下来。系统调用状态(system call state)
:当前系统调用的信息,包括参数和结果文件描述符表(file descriptor table)
:有关文件描述符的系统被调用时,文件描述符作为索引在文件描述符表中定位相关文件的 i-node 数据结构统计数据(accounting)
:记录用户、进程占用系统 CPU 时间表的指针,一些操作系统还保存进程最多占用的 CPU 时间、进程拥有的最大堆栈空间、进程可以消耗的页面数等。内核堆栈(kernel stack)
:进程的内核部分可以使用的固定堆栈其他
: 当前进程状态、事件等待时间、距离警报的超时时间、PID、父进程的 PID 以及用户标识符等
有了上面这些信息,现在就很容易描述在 Linux 中是如何创建这些进程的了,创建新流程实际上非常简单。为子进程开辟一块新的用户空间的进程描述符,然后从父进程复制大量的内容。为这个子进程分配一个 PID,设置其内存映射,赋予它访问父进程文件的权限,注册并启动。
当执行 fork 系统调用时,调用进程会陷入内核并创建一些和任务相关的数据结构,比如内核堆栈(kernel stack)
和 thread_info
结构。
关于 thread_info 结构可以参考
这个结构中包含进程描述符,进程描述符位于固定的位置,使得 Linux 系统只需要很小的开销就可以定位到一个运行中进程的数据结构。
进程描述符的主要内容是根据父进程
的描述符来填充。Linux 操作系统会寻找一个可用的 PID,并且此 PID 没有被任何进程使用,更新进程标示符使其指向一个新的数据结构即可。为了减少 hash table 的碰撞,进程描述符会形成链表
。它还将 task_struct 的字段设置为指向任务数组上相应的上一个/下一个进程。
task_struct : Linux 进程描述符,内部涉及到众多 C++ 源码,我们会在后面进行讲解。
从原则上来说,为子进程开辟内存区域并为子进程分配数据段、堆栈段,并且对父进程的内容进行复制,但是实际上 fork 完成后,子进程和父进程没有共享内存,所以需要复制技术来实现同步,但是复制开销比较大,因此 Linux 操作系统使用了一种 欺骗
方式。即为子进程分配页表,然后新分配的页表指向父进程的页面,同时这些页面是只读的。当进程向这些页面进行写入的时候,会开启保护错误。内核发现写入操作后,会为进程分配一个副本,使得写入时把数据复制到这个副本上,这个副本是共享的,这种方式称为 写入时复制(copy on write)
,这种方式避免了在同一块内存区域维护两个副本的必要,节省内存空间。
在子进程开始运行后,操作系统会调用 exec 系统调用,内核会进行查找验证可执行文件,把参数和环境变量复制到内核,释放旧的地址空间。
现在新的地址空间需要被创建和填充。如果系统支持映射文件,就像 Unix 系统一样,那么新的页表就会创建,表明内存中没有任何页,除非所使用的页面是堆栈页,其地址空间由磁盘上的可执行文件支持。新进程开始运行时,立刻会收到一个缺页异常(page fault)
,这会使具有代码的页面加载进入内存。最后,参数和环境变量被复制到新的堆栈中,重置信号,寄存器全部清零。新的命令开始运行。
下面是一个示例,用户输出 ls,shell 会调用 fork 函数复制一个新进程,shell 进程会调用 exec 函数用可执行文件 ls 的内容覆盖它的内存。
Linux 线程
现在我们来讨论一下 Linux 中的线程,线程是轻量级的进程,想必这句话你已经听过很多次了,轻量级
体现在所有的进程切换都需要清除所有的表、进程间的共享信息也比较麻烦,一般来说通过管道或者共享内存,如果是 fork 函数后的父子进程则使用共享文件,然而线程切换不需要像进程一样具有昂贵的开销,而且线程通信起来也更方便。线程分为两种:用户级线程和内核级线程
用户级线程
用户级线程避免使用内核,通常,每个线程会显示调用开关,发送信号或者执行某种切换操作来放弃 CPU,同样,计时器可以强制进行开关,用户线程的切换速度通常比内核线程快很多。在用户级别实现线程会有一个问题,即单个线程可能会垄断 CPU 时间片,导致其他线程无法执行从而 饿死
。如果执行一个 I/O 操作,那么 I/O 会阻塞,其他线程也无法运行。
一种解决方案是,一些用户级的线程包解决了这个问题。可以使用时钟周期的监视器来控制第一时间时间片独占。然后,一些库通过特殊的包装来解决系统调用的 I/O 阻塞问题,或者可以为非阻塞 I/O 编写任务。
内核级线程
内核级线程通常使用几个进程表在内核中实现,每个任务都会对应一个进程表。在这种情况下,内核会在每个进程的时间片内调度每个线程。
所有能够阻塞的调用都会通过系统调用的方式来实现,当一个线程阻塞时,内核可以进行选择,是运行在同一个进程中的另一个线程(如果有就绪线程的话)还是运行一个另一个进程中的线程。
从用户空间 -> 内核空间 -> 用户空间的开销比较大,但是线程初始化的时间损耗可以忽略不计。这种实现的好处是由时钟决定线程切换时间,因此不太可能将时间片与任务中的其他线程占用时间绑定到一起。同样,I/O 阻塞也不是问题。
混合实现
结合用户空间和内核空间的优点,设计人员采用了一种内核级线程
的方式,然后将用户级线程与某些或者全部内核线程多路复用起来
在这种模型中,编程人员可以自由控制用户线程和内核线程的数量,具有很大的灵活度。采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。
Linux 调度
下面我们来关注一下 Linux 系统的调度算法,首先需要认识到,Linux 系统的线程是内核线程,所以 Linux 系统是基于线程的,而不是基于进程的。
为了进行调度,Linux 系统将线程分为三类
- 实时先入先出
- 实时轮询
- 分时
实时先入先出线程具有最高优先级,它不会被其他线程所抢占,除非那是一个刚刚准备好的,拥有更高优先级的线程进入。实时轮转线程与实时先入先出线程基本相同,只是每个实时轮转线程都有一个时间量,时间到了之后就可以被抢占。如果多个实时线程准备完毕,那么每个线程运行它时间量所规定的时间,然后插入到实时轮转线程末尾。
注意这个实时只是相对的,无法做到绝对的实时,因为线程的运行时间无法确定。它们相对分时系统来说,更加具有实时性
Linux 系统会给每个线程分配一个 nice
值,这个值代表了优先级的概念。nice 值默认值是 0 ,但是可以通过系统调用 nice 值来修改。修改值的范围从 -20 – +19。nice 值决定了线程的静态优先级。一般系统管理员的 nice 值会比一般线程的优先级高,它的范围是 -20 – -1。
下面我们更详细的讨论一下 Linux 系统的两个调度算法,它们的内部与调度队列(runqueue)
的设计很相似。运行队列有一个数据结构用来监视系统中所有可运行的任务并选择下一个可以运行的任务。每个运行队列和系统中的每个 CPU 有关。
Linux O(1)
调度器是历史上很流行的一个调度器。这个名字的由来是因为它能够在常数时间内执行任务调度。在 O(1) 调度器里,调度队列被组织成两个数组,一个是任务正在活动的数组,一个是任务过期失效的数组。如下图所示,每个数组都包含了 140 个链表头,每个链表头具有不同的优先级。
大致流程如下:
调度器从正在活动数组中选择一个优先级最高的任务。如果这个任务的时间片过期失效了,就把它移动到过期失效数组中。如果这个任务阻塞了,比如说正在等待 I/O 事件,那么在它的时间片过期失效之前,一旦 I/O 操作完成,那么这个任务将会继续运行,它将被放回到之前正在活动的数组中,因为这个任务之前已经消耗一部分 CPU 时间片,所以它将运行剩下的时间片。当这个任务运行完它的时间片后,它就会被放到过期失效数组中。一旦正在活动的任务数组中没有其他任务后,调度器将会交换指针,使得正在活动的数组变为过期失效数组,过期失效数组变为正在活动的数组。使用这种方式可以保证每个优先级的任务都能够得到执行,不会导致线程饥饿。
在这种调度方式中,不同优先级的任务所得到 CPU 分配的时间片也是不同的,高优先级进程往往能得到较长的时间片,低优先级的任务得到较少的时间片。
这种方式为了保证能够更好的提供服务,通常会为 交互式进程
赋予较高的优先级,交互式进程就是用户进程
。
Linux 系统不知道一个任务究竟是 I/O 密集型的还是 CPU 密集型的,它只是依赖于交互式的方式,Linux 系统会区分是静态优先级
还是 动态优先级
。动态优先级是采用一种奖励机制来实现的。奖励机制有两种方式:奖励交互式线程、惩罚占用 CPU 的线程。在 Linux O(1) 调度器中,最高的优先级奖励是 -5,注意这个优先级越低越容易被线程调度器接受,所以最高惩罚的优先级是 +5。具体体现就是操作系统维护一个名为 sleep_avg
的变量,任务唤醒会增加 sleep_avg 变量的值,当任务被抢占或者时间量过期会减少这个变量的值,反映在奖励机制上。
O(1) 调度算法是 2.6 内核版本的调度器,最初引入这个调度算法的是不稳定的 2.5 版本。早期的调度算法在多处理器环境中说明了通过访问正在活动数组就可以做出调度的决定。使调度可以在固定的时间 O(1) 完成。
O(1) 调度器使用了一种 启发式
的方式,这是什么意思?
在计算机科学中,启发式是一种当传统方式解决问题很慢时用来快速解决问题的方式,或者找到一个在传统方法无法找到任何精确解的情况下找到近似解。
O(1) 使用启发式的这种方式,会使任务的优先级变得复杂并且不完善,从而导致在处理交互任务时性能很糟糕。
为了改进这个缺点,O(1) 调度器的开发者又提出了一个新的方案,即 公平调度器(Completely Fair Scheduler, CFS)
。 CFS 的主要思想是使用一颗红黑树
作为调度队列。
数据结构太重要了。
CFS 会根据任务在 CPU 上的运行时间长短而将其有序地排列在树中,时间精确到纳秒级。下面是 CFS 的构造模型
CFS 的调度过程如下:
CFS 算法总是优先调度哪些使用 CPU 时间最少的任务。最小的任务一般都是在最左边的位置。当有一个新的任务需要运行时,CFS 会把这个任务和最左边的数值进行对比,如果此任务具有最小时间值,那么它将进行运行,否则它会进行比较,找到合适的位置进行插入。然后 CPU 运行红黑树上当前比较的最左边的任务。
在红黑树中选择一个节点来运行的时间可以是常数时间,但是插入一个任务的时间是 O(loog(N))
,其中 N 是系统中的任务数。考虑到当前系统的负载水平,这是可以接受的。
调度器只需要考虑可运行的任务即可。这些任务被放在适当的调度队列中。不可运行的任务和正在等待的各种 I/O 操作或内核事件的任务被放入一个等待队列
中。等待队列头包含一个指向任务链表的指针和一个自旋锁。自旋锁对于并发处理场景下用处很大。
Linux 系统中的同步
下面来聊一下 Linux 中的同步机制。早期的 Linux 内核只有一个 大内核锁(Big Kernel Lock,BKL)
。它阻止了不同处理器并发处理的能力。因此,需要引入一些粒度更细的锁机制。
Linux 提供了若干不同类型的同步变量,这些变量既能够在内核中使用,也能够在用户应用程序中使用。在地层中,Linux 通过使用 atomic_set
和 atomic_read
这样的操作为硬件支持的原子指令提供封装。硬件提供内存重排序,这是 Linux 屏障的机制。
具有高级别的同步像是自旋锁的描述是这样的,当两个进程同时对资源进行访问,在一个进程获得资源后,另一个进程不想被阻塞,所以它就会自旋,等待一会儿再对资源进行访问。Linux 也提供互斥量或信号量这样的机制,也支持像是 mutex_tryLock
和 mutex_tryWait
这样的非阻塞调用。也支持中断处理事务,也可以通过动态禁用和启用相应的中断来实现。
Linux 启动
下面来聊一聊 Linux 是如何启动的。
当计算机电源通电后,BIOS
会进行开机自检(Power-On-Self-Test, POST)
,对硬件进行检测和初始化。因为操作系统的启动会使用到磁盘、屏幕、键盘、鼠标等设备。下一步,磁盘中的第一个分区,也被称为 MBR(Master Boot Record)
主引导记录,被读入到一个固定的内存区域并执行。这个分区中有一个非常小的,只有 512 字节的程序。程序从磁盘中调入 boot 独立程序,boot 程序将自身复制到高位地址的内存从而为操作系统释放低位地址的内存。
复制完成后,boot 程序读取启动设备的根目录。boot 程序要理解文件系统和目录格式。然后 boot 程序被调入内核,把控制权移交给内核。直到这里,boot 完成了它的工作。系统内核开始运行。
内核启动代码是使用汇编语言
完成的,主要包括创建内核堆栈、识别 CPU 类型、计算内存、禁用中断、启动内存管理单元等,然后调用 C 语言的 main 函数执行操作系统部分。
这部分也会做很多事情,首先会分配一个消息缓冲区来存放调试出现的问题,调试信息会写入缓冲区。如果调试出现错误,这些信息可以通过诊断程序调出来。
然后操作系统会进行自动配置,检测设备,加载配置文件,被检测设备如果做出响应,就会被添加到已链接的设备表中,如果没有相应,就归为未连接直接忽略。
配置完所有硬件后,接下来要做的就是仔细手工处理进程0,设置其堆栈,然后运行它,执行初始化、配置时钟、挂载文件系统。创建 init 进程(进程 1 )
和 守护进程(进程 2)
。
init 进程会检测它的标志以确定它是否为单用户还是多用户服务。在前一种情况中,它会调用 fork 函数创建一个 shell 进程,并且等待这个进程结束。后一种情况调用 fork 函数创建一个运行系统初始化的 shell 脚本(即 /etc/rc)的进程,这个进程可以进行文件系统一致性检测、挂载文件系统、开启守护进程等。
然后 /etc/rc 这个进程会从 /etc/ttys 中读取数据,/etc/ttys 列出了所有的终端和属性。对于每一个启用的终端,这个进程调用 fork 函数创建一个自身的副本,进行内部处理并运行一个名为 getty
的程序。
getty 程序会在终端上输入
login:
等待用户输入用户名,在输入用户名后,getty 程序结束,登陆程序 /bin/login
开始运行。login 程序需要输入密码,并与保存在 /etc/passwd
中的密码进行对比,如果输入正确,login 程序以用户 shell 程序替换自身,等待第一个命令。如果不正确,login 程序要求输入另一个用户名。
整个系统启动过程如下
Linux内存管理
Linux 内存管理模型非常直接明了,因为 Linux 的这种机制使其具有可移植性并且能够在内存管理单元相差不大的机器下实现 Linux,下面我们就来认识一下 Linux 内存管理是如何实现的。
基本概念
每个 Linux 进程都会有地址空间,这些地址空间由三个段区域组成:text 段、data 段、stack 段。下面是进程地址空间的示例。
数据段(data segment)
包含了程序的变量、字符串、数组和其他数据的存储。数据段分为两部分,已经初始化的数据和尚未初始化的数据。其中尚未初始化的数据
就是我们说的 BSS。数据段部分的初始化需要编译就期确定的常量以及程序启动就需要一个初始值的变量。所有 BSS 部分中的变量在加载后被初始化为 0 。
和 代码段(Text segment)
不一样,data segment 数据段可以改变。程序总是修改它的变量。而且,许多程序需要在执行时动态分配空间。Linux 允许数据段随着内存的分配和回收从而增大或者减小。为了分配内存,程序可以增加数据段的大小。在 C 语言中有一套标准库 malloc
经常用于分配内存。进程地址空间描述符包含动态分配的内存区域称为 堆(heap)
。
第三部分段是 栈段(stack segment)
。在大部分机器上,栈段会在虚拟内存地址顶部地址位置处,并向低位置处(向地址空间为 0 处)拓展。举个例子来说,在 32 位 x86 架构的机器上,栈开始于 0xC0000000
,这是用户模式下进程允许可见的 3GB 虚拟地址限制。如果栈一直增大到超过栈段后,就会发生硬件故障并把页面下降一个页面。
当程序启动时,栈区域并不是空的,相反,它会包含所有的 shell 环境变量以及为了调用它而向 shell 输入的命令行。举个例子,当你输入
cp cxuan lx
时,cp 程序会运行并在栈中带着字符串 cp cxuan lx
,这样就能够找出源文件和目标文件的名称。
当两个用户运行在相同程序中,例如编辑器(editor)
,那么就会在内存中保持编辑器程序代码的两个副本,但是这种方式并不高效。Linux 系统支持共享文本段作
为替代。下面图中我们会看到 A 和 B 两个进程,它们有着相同的文本区域。
数据段和栈段只有在 fork 之后才会共享,共享也是共享未修改过的页面。如果任何一个都需要变大但是没有相邻空间容纳的话,也不会有问题,因为相邻的虚拟页面不必映射到相邻的物理页面上。
除了动态分配更多的内存,Linux 中的进程可以通过内存映射文件
来访问文件数据。这个特性可以使我们把一个文件映射到进程空间的一部分而该文件就可以像位于内存中的字节数组一样被读写。把一个文件映射进来使得随机读写比使用 read 和 write 之类的 I/O 系统调用要容易得多。共享库的访问就是使用了这种机制。如下所示
我们可以看到两个相同文件会被映射到相同的物理地址上,但是它们属于不同的地址空间。
映射文件的优点是,两个或多个进程可以同时映射到同一文件中,任意一个进程对文件的写操作对其他文件可见。通过使用映射临时文件的方式,可以为多线程共享内存提供高带宽
,临时文件在进程退出后消失。但是实际上,并没有两个相同的地址空间,因为每个进程维护的打开文件和信号不同。
Linux 内存管理系统调用
下面我们探讨一下关于内存管理的系统调用方式。事实上,POSIX 并没有给内存管理指定任何的系统调用。然而,Linux 却有自己的内存系统调用,主要系统调用如下
系统调用 | 描述 |
---|---|
s = brk(addr) | 改变数据段大小 |
a = mmap(addr,len,prot,flags,fd,offset) | 进行映射 |
s = unmap(addr,len) | 取消映射 |
如果遇到错误,那么 s 的返回值是 -1,a 和 addr 是内存地址,len 表示的是长度,prot 表示的是控制保护位,flags 是其他标志位,fd 是文件描述符,offset 是文件偏移量。
brk
通过给出超过数据段之外的第一个字节地址来指定数据段的大小。如果新的值要比原来的大,那么数据区会变得越来越大,反之会越来越小。
mmap
和 unmap
系统调用会控制映射文件。mmp 的第一个参数 addr 决定了文件映射的地址。它必须是页面大小的倍数。如果参数是 0,系统会分配地址并返回 a。第二个参数是长度,它告诉了需要映射多少字节。它也是页面大小的倍数。prot 决定了映射文件的保护位,保护位可以标记为 可读、可写、可执行或者这些的结合。第四个参数 flags 能够控制文件是私有的还是可读的以及 addr 是必须的还是只是进行提示。第五个参数 fd 是要映射的文件描述符。只有打开的文件是可以被映射的,因此如果想要进行文件映射,必须打开文件;最后一个参数 offset 会指示文件从什么时候开始,并不一定每次都要从零开始。
Linux 内存管理实现
内存管理系统是操作系统最重要的部分之一。从计算机早期开始,我们实际使用的内存都要比系统中实际存在的内存多。内存分配策略
克服了这一限制,并且其中最有名的就是 虚拟内存(virtual memory)
。通过在多个竞争的进程之间共享虚拟内存,虚拟内存得以让系统有更多的内存。虚拟内存子系统主要包括下面这些概念。
**大地址空间
操作系统使系统使用起来好像比实际的物理内存要大很多,那是因为虚拟内存要比物理内存大很多倍。
**保护
系统中的每个进程都会有自己的虚拟地址空间。这些虚拟地址空间彼此完全分开,因此运行一个应用程序的进程不会影响另一个。并且,硬件虚拟内存机制允许内存保护关键内存区域。
**内存映射
内存映射用来向进程地址空间映射图像和数据文件。在内存映射中,文件的内容直接映射到进程的虚拟空间中。
**公平的物理内存分配
内存管理子系统允许系统中的每个正在运行的进程公平分配系统的物理内存。
**共享虚拟内存
尽管虚拟内存让进程有自己的内存空间,但是有的时候你是需要共享内存的。例如几个进程同时在 shell 中运行,这会涉及到 IPC 的进程间通信问题,这个时候你需要的是共享内存来进行信息传递而不是通过拷贝每个进程的副本独立运行。
下面我们就正式探讨一下什么是 虚拟内存
虚拟内存的抽象模型
在考虑 Linux 用于支持虚拟内存的方法之前,考虑一个不会被太多细节困扰的抽象模型是很有用的。
处理器在执行指令时,会从内存中读取指令并将其解码(decode)
,在指令解码时会获取某个位置的内容并将他存到内存中。然后处理器继续执行下一条指令。这样,处理器总是在访问存储器以获取指令和存储数据。
在虚拟内存系统中,所有的地址空间都是虚拟的而不是物理的。但是实际存储和提取指令的是物理地址,所以需要让处理器根据操作系统维护的一张表将虚拟地址转换为物理地址。
为了简单的完成转换,虚拟地址和物理地址会被分为固定大小的块,称为 页(page)
。这些页有相同大小,如果页面大小不一样的话,那么操作系统将很难管理。Alpha AXP系统上的 Linux 使用 8 KB 页面,而 Intel x86 系统上的 Linux 使用 4 KB 页面。每个页面都有一个唯一的编号,即页面框架号(PFN)
。
上面就是 Linux 内存映射模型了,在这个页模型中,虚拟地址由两部分组成:偏移量和虚拟页框号。每次处理器遇到虚拟地址时都会提取偏移量和虚拟页框号。处理器必须将虚拟页框号转换为物理页号,然后以正确的偏移量的位置访问物理页。
上图中展示了两个进程 A 和 B 的虚拟地址空间,每个进程都有自己的页表。这些页表将进程中的虚拟页映射到内存中的物理页中。页表中每一项均包含
有效标志(valid flag)
: 表明此页表条目是否有效- 该条目描述的物理页框号
- 访问控制信息,页面使用方式,是否可写以及是否可以执行代码
要将处理器的虚拟地址映射为内存的物理地址,首先需要计算虚拟地址的页框号和偏移量。页面大小为 2 的次幂,可以通过移位完成操作。
如果当前进程尝试访问虚拟地址,但是访问不到的话,这种情况称为 缺页异常
,此时虚拟操作系统的错误地址和页面错误的原因将通知操作系统。
通过以这种方式将虚拟地址映射到物理地址,虚拟内存可以以任何顺序映射到系统的物理页面。
按需分页
由于物理内存要比虚拟内存少很多,因此操作系统需要注意尽量避免直接使用低效
的物理内存。节省物理内存的一种方式是仅加载执行程序当前使用的页面(这何尝不是一种懒加载的思想呢?)。例如,可以运行数据库来查询数据库,在这种情况下,不是所有的数据都装入内存,只装载需要检查的数据。这种仅仅在需要时才将虚拟页面加载进内中的技术称为按需分页。
交换
如果某个进程需要将虚拟页面传入内存,但是此时没有可用的物理页面,那么操作系统必须丢弃物理内存中的另一个页面来为该页面腾出空间。
如果页面已经修改过,那么操作系统必须保留该页面的内容,以便以后可以访问它。这种类型的页面被称为脏页,当将其从内存中移除时,它会保存在称为交换文件
的特殊文件中。相对于处理器和物理内存的速度,对交换文件的访问非常慢,并且操作系统需要兼顾将页面写到磁盘的以及将它们保留在内存中以便再次使用。
Linux 使用最近最少使用(LRU)
页面老化技术来公平的选择可能会从系统中删除的页面,这个方案涉及系统中的每个页面,页面的年龄随着访问次数的变化而变化,如果某个页面访问次数多,那么该页就表示越 年轻
,如果某个呃页面访问次数太少,那么该页越容易被换出
。
物理和虚拟寻址模式
大多数多功能处理器都支持 物理地址
模式和虚拟地址
模式的概念。物理寻址模式不需要页表,并且处理器不会在此模式下尝试执行任何地址转换。 Linux 内核被链接在物理地址空间中运行。
Alpha AXP 处理器没有物理寻址模式。相反,它将内存空间划分为几个区域,并将其中两个指定为物理映射的地址。此内核地址空间称为 KSEG 地址空间,它包含从 0xfffffc0000000000 向上的所有地址。为了从 KSEG 中链接的代码(按照定义,内核代码)执行或访问其中的数据,该代码必须在内核模式下执行。链接到 Alpha 上的 Linux内核以从地址 0xfffffc0000310000 执行。
访问控制
页面表的每一项还包含访问控制信息,访问控制信息主要检查进程是否应该访问内存。
必要时需要对内存进行访问限制
。 例如包含可执行代码的内存,自然是只读内存; 操作系统不应允许进程通过其可执行代码写入数据。 相比之下,包含数据的页面可以被写入,但是尝试执行该内存的指令将失败。 大多数处理器至少具有两种执行模式:内核态和用户态。 你不希望访问用户执行内核代码或内核数据结构,除非处理器以内核模式运行。
访问控制信息被保存在上面的 Page Table Entry ,页表项中,上面这幅图是 Alpha AXP的 PTE。位字段具有以下含义
- V
表示 valid ,是否有效位
- FOR
读取时故障,在尝试读取此页面时出现故障
- FOW
写入时错误,在尝试写入时发生错误
- FOE
执行时发生错误,在尝试执行此页面中的指令时,处理器都会报告页面错误并将控制权传递给操作系统,
- ASM
地址空间匹配,当操作系统希望清除转换缓冲区中的某些条目时,将使用此选项。
- GH
当在使用单个转换缓冲区
条目而不是多个转换缓冲区
条目映射整个块时使用的提示。
- KRE
内核模式运行下的代码可以读取页面
- URE
用户模式下的代码可以读取页面
- KWE
以内核模式运行的代码可以写入页面
- UWE
以用户模式运行的代码可以写入页面
- 页框号
对于设置了 V 位的 PTE,此字段包含此 PTE 的物理页面帧号(页面帧号)。对于无效的 PTE,如果此字段不为零,则包含有关页面在交换文件中的位置的信息。
除此之外,Linux 还使用了两个位
- _PAGE_DIRTY
如果已设置,则需要将页面写出到交换文件中
- _PAGE_ACCESSED
Linux 用来将页面标记为已访问。
缓存
上面的虚拟内存抽象模型可以用来实施,但是效率不会太高。操作系统和处理器设计人员都尝试提高性能。 但是除了提高处理器,内存等的速度之外,最好的方法就是维护有用信息和数据的高速缓存,从而使某些操作更快。在 Linux 中,使用很多和内存管理有关的缓冲区,使用缓冲区来提高效率。
缓冲区缓存
缓冲区高速缓存包含块设备
驱动程序使用的数据缓冲区。
还记得什么是块设备么?这里回顾下
块设备是一个能存储固定大小块
信息的设备,它支持以固定大小的块,扇区或群集读取和(可选)写入数据。每个块都有自己的物理地址
。通常块的大小在 512 – 65536 之间。所有传输的信息都会以连续
的块为单位。块设备的基本特征是每个块都较为对立,能够独立的进行读写。常见的块设备有 **硬盘、蓝光光盘、USB 盘
与字符设备相比,块设备通常需要较少的引脚。
缓冲区高速缓存通过设备标识符
和块编号用于快速查找数据块。 如果可以在缓冲区高速缓存中找到数据,则无需从物理块设备中读取数据,这种访问方式要快得多。
页缓存
页缓存用于加快对磁盘上图像和数据的访问
它用于一次一页地缓存文件中的内容,并且可以通过文件和文件中的偏移量进行访问。当页面从磁盘读入内存时,它们被缓存在页面缓存中。
交换区缓存
仅仅已修改(脏页)被保存在交换文件中
只要这些页面在写入交换文件后没有修改,则下次交换该页面时,无需将其写入交换文件,因为该页面已在交换文件中。 可以直接丢弃。 在大量交换的系统中,这节省了许多不必要的和昂贵的磁盘操作。
硬件缓存
处理器中通常使用一种硬件缓存。页表条目的缓存。在这种情况下,处理器并不总是直接读取页表,而是根据需要缓存页的翻译。 这些是转换后备缓冲区
也被称为 TLB
,包含来自系统中一个或多个进程的页表项的缓存副本。
引用虚拟地址后,处理器将尝试查找匹配的 TLB 条目。 如果找到,则可以将虚拟地址直接转换为物理地址,并对数据执行正确的操作。 如果处理器找不到匹配的 TLB 条目, 它通过向操作系统发信号通知已发生 TLB 丢失获得操作系统的支持和帮助。系统特定的机制用于将该异常传递给可以修复问题的操作系统代码。 操作系统为地址映射生成一个新的 TLB 条目。 清除异常后,处理器将再次尝试转换虚拟地址。这次能够执行成功。
使用缓存也存在缺点,为了节省精力,Linux 必须使用更多的时间和空间来维护这些缓存,并且如果缓存损坏,系统将会崩溃。
Linux 页表
Linux 假定页表分为三个级别。访问的每个页表都包含下一级页表
图中的 PDG 表示全局页表,当创建一个新的进程时,都要为新进程创建一个新的页面目录,即 PGD。
要将虚拟地址转换为物理地址,处理器必须获取每个级别字段的内容,将其转换为包含页表的物理页的偏移量,并读取下一级页表的页框号。这样重复三次,直到找到包含虚拟地址的物理页面的页框号为止。
Linux 运行的每个平台都必须提供翻译宏,这些宏允许内核遍历特定进程的页表。这样,内核无需知道页表条目的格式或它们的排列方式。
页分配和取消分配
对系统中物理页面有很多需求。例如,当图像加载到内存中时,操作系统需要分配页面。
系统中所有物理页面均由 mem_map
数据结构描述,这个数据结构是 mem_map_t
的列表。它包括一些重要的属性
- count :这是页面的用户数计数,当页面在多个进程之间共享时,计数大于 1
- age:这是描述页面的年龄,用于确定页面是否适合丢弃或交换
- map_nr :这是此mem_map_t描述的物理页框号。
页面分配代码使用 free_area
向量查找和释放页面,free_area 的每个元素都包含有关页面块的信息。
页面分配
Linux 的页面分配使用一种著名的伙伴算法来进行页面的分配和取消分配。页面以 2 的幂为单位进行块分配。这就意味着它可以分配 1页、2 页、4页等等,只要系统中有足够可用的页面来满足需求就可以。判断的标准是nr_free_pages> min_free_pages,如果满足,就会在 free_area 中搜索所需大小的页面块完成分配。free_area 的每个元素都有该大小的块的已分配页面和空闲页面块的映射。
分配算法会搜索请求大小的页面块。如果没有任何请求大小的页面块可用的话,会搜寻一个是请求大小二倍的页面块,然后重复,直到一直搜寻完 free_area 找到一个页面块为止。如果找到的页面块要比请求的页面块大,就会对找到的页面块进行细分,直到找到合适的大小块为止。
因为每个块都是 2 的次幂,所以拆分过程很容易,因为你只需将块分成两半即可。空闲块在适当的队列中排队,分配的页面块返回给调用者。
如果请求一个 2 个页的块,则 4 页的第一个块(从第 4 页的框架开始)将被分成两个 2 页的块。第一个页面(从第 4 页的帧开始)将作为分配的页面返回给调用方,第二个块(从第 6 页的页面开始)将作为 2 页的空闲块排队到 free_area 数组的元素 1 上。
页面取消分配
上面的这种内存方式最造成一种后果,那就是内存的碎片化,会将较大的空闲页面分成较小的页面。页面解除分配代码会尽可能将页面重新组合成为更大的空闲块。每释放一个页面,都会检查相同大小的相邻的块,以查看是否空闲。如果是,则将其与新释放的页面块组合以形成下一个页面大小块的新的自由页面块。 每次将两个页面块重新组合为更大的空闲页面块时,页面释放代码就会尝试将该页面块重新组合为更大的空闲页面。 通过这种方式,可用页面的块将尽可能多地使用内存。
例如上图,如果要释放第 1 页的页面,则将其与已经空闲的第 0 页页面框架组合在一起,并作为大小为 2页的空闲块排队到 free_area 的元素 1 中
内存映射
内核有两种类型的内存映射:共享型(shared)
和私有型(private)
。私有型是当进程为了只读文件,而不写文件时使用,这时,私有映射更加高效。 但是,任何对私有映射页的写操作都会导致内核停止映射该文件中的页。所以,写操作既不会改变磁盘上的文件,对访问该文件的其它进程也是不可见的。
按需分页
一旦可执行映像被内存映射到虚拟内存后,它就可以被执行了。因为只将映像的开头部分物理的拉入到内存中,因此它将很快访问物理内存尚未存在的虚拟内存区域。当进程访问没有有效页表的虚拟地址时,操作系统会报告这项错误。
页面错误描述页面出错的虚拟地址和引起的内存访问(RAM)类型。
Linux 必须找到代表发生页面错误的内存区域的 vm_area_struct 结构。由于搜索 vm_area_struct 数据结构对于有效处理页面错误至关重要,因此它们以 AVL(Adelson-Velskii和Landis)
树结构链接在一起。如果引起故障的虚拟地址没有 vm_area_struct
结构,则此进程已经访问了非法地址,Linux 会向进程发出 SIGSEGV
信号,如果进程没有用于该信号的处理程序,那么进程将会终止。
然后,Linux 会针对此虚拟内存区域所允许的访问类型,检查发生的页面错误类型。 如果该进程以非法方式访问内存,例如写入仅允许读的区域,则还会发出内存访问错误信号。
现在,Linux 已确定页面错误是合法的,因此必须对其进行处理。
Linux开篇!!!
此篇文章主要会带你介绍 Linux 操作系统,包括 Linux 本身、Linux 如何使用、以及系统调用和 Linux 是如何工作的。
Linux 简介
UNIX 是一个交互式系统,用于同时处理多进程和多用户同时在线。为什么要说 UNIX,那是因为 Linux 是由 UNIX 发展而来的,UNIX 是由程序员设计,它的主要服务对象也是程序员。Linux 继承了 UNIX 的设计目标。从智能手机到汽车,超级计算机和家用电器,从家用台式机到企业服务器,Linux 操作系统无处不在。
大多数程序员都喜欢让系统尽量简单,优雅并具有一致性。举个例子,从最底层的角度来讲,一个文件应该只是一个字节集合。为了实现顺序存取、随机存取、按键存取、远程存取只能是妨碍你的工作。相同的,如果命令
ls A
意味着只列出以 A 为开头的所有文件,那么命令
rm A
应该会移除所有以 A 为开头的文件而不是只删除文件名是 A*
的文件。这个特性也是最小吃惊原则(principle of least surprise)
最小吃惊原则一半常用于用户界面和软件设计。它的原型是:该功能或者特征应该符合用户的预期,不应该使用户感到惊讶和震惊。
一些有经验的程序员通常希望系统具有较强的功能性和灵活性。设计 Linux 的一个基本目标是每个应用程序只做一件事情并把他做好。所以编译器只负责编译的工作,编译器不会产生列表,因为有其他应用比编译器做的更好。
很多人都不喜欢冗余,为什么在 cp 就能描述清楚你想干什么时候还使用 copy?这完全是在浪费宝贵的 hacking time
。为了从文件中提取所有包含字符串 ard
的行,Linux 程序员应该输入
grep ard f
Linux 接口
Linux 系统是一种金字塔模型的系统,如下所示
应用程序发起系统调用把参数放在寄存器中(有时候放在栈中),并发出 trap
系统陷入指令切换用户态至内核态。因为不能直接在 C 中编写 trap 指令,因此 C 提供了一个库,库中的函数对应着系统调用。有些函数是使用汇编编写的,但是能够从 C 中调用。每个函数首先把参数放在合适的位置然后执行系统调用指令。因此如果你想要执行 read 系统调用的话,C 程序会调用 read 函数库来执行。这里顺便提一下,是由 POSIX 指定的库接口而不是系统调用接口。也就是说,POSIX 会告诉一个标准系统应该提供哪些库过程,它们的参数是什么,它们必须做什么以及它们必须返回什么结果。
除了操作系统和系统调用库外,Linux 操作系统还要提供一些标准程序,比如文本编辑器、编译器、文件操作工具等。直接和用户打交道的是上面这些应用程序。因此我们可以说 Linux 具有三种不同的接口:**系统调用接口、库函数接口和应用程序接口
Linux 中的 GUI(Graphical User Interface)
和 UNIX 中的非常相似,这种 GUI 创建一个桌面环境,包括窗口、目标和文件夹、工具栏和文件拖拽功能。一个完整的 GUI 还包括窗口管理器以及各种应用程序。
Linux 上的 GUI 由 X 窗口支持,主要组成部分是 X 服务器、控制键盘、鼠标、显示器等。当在 Linux 上使用图形界面时,用户可以通过鼠标点击运行程序或者打开文件,通过拖拽将文件进行复制等。
Linux 组成部分
事实上,Linux 操作系统可以由下面这几部分构成
引导程序(Bootloader)
:引导程序是管理计算机启动过程的软件,对于大多数用户而言,只是弹出一个屏幕,但其实内部操作系统做了很多事情内核(Kernel)
:内核是操作系统的核心,负责管理 CPU、内存和外围设备等。初始化系统(Init System)
:这是一个引导用户空间并负责控制守护程序的子系统。一旦从引导加载程序移交了初始引导,它就是用于管理引导过程的初始化系统。后台进程(Daemon)
:后台进程顾名思义就是在后台运行的程序,比如打印、声音、调度等,它们可以在引导过程中启动,也可以在登录桌面后启动图形服务器(Graphical server)
:这是在监视器上显示图形的子系统。通常将其称为 X 服务器或 X。桌面环境(Desktop environment)
:这是用户与之实际交互的部分,有很多桌面环境可供选择,每个桌面环境都包含内置应用程序,比如文件管理器、Web 浏览器、游戏等应用程序(Applications)
:桌面环境不提供完整的应用程序,就像 Windows 和 macOS 一样,Linux 提供了成千上万个可以轻松找到并安装的高质量软件。
Shell
尽管 Linux 应用程序提供了 GUI ,但是大部分程序员仍偏好于使用命令行(command-line interface)
,称为shell
。用户通常在 GUI 中启动一个 shell 窗口然后就在 shell 窗口下进行工作。
shell 命令行使用速度快、功能更强大、而且易于扩展、并且不会带来肢体重复性劳损(RSI)
。
下面会介绍一些最简单的 bash shell。当 shell 启动时,它首先进行初始化,在屏幕上输出一个 提示符(prompt)
,通常是一个百分号或者美元符号,等待用户输入
等用户输入一个命令后,shell 提取其中的第一个词,这里的词指的是被空格或制表符分隔开的一连串字符。假定这个词是将要运行程序的程序名,那么就会搜索这个程序,如果找到了这个程序就会运行它。然后 shell 会将自己挂起直到程序运行完毕,之后再尝试读入下一条指令。shell 也是一个普通的用户程序。它的主要功能就是读取用户的输入和显示计算的输出。shell 命令中可以包含参数,它们作为字符串传递给所调用的程序。比如
cp src dest
会调用 cp 应用程序并包含两个参数 src
和 dest
。这个程序会解释第一个参数是一个已经存在的文件名,然后创建一个该文件的副本,名称为 dest。
并不是所有的参数都是文件名,比如下面
head -20 file
第一个参数 -20,会告诉 head 应用程序打印文件的前 20 行,而不是默认的 10 行。控制命令操作或者指定可选值的参数称为标志(flag)
,按照惯例标志应该使用 -
来表示。这个符号是必要的,比如
head 20 file
是一个完全合法的命令,它会告诉 head 程序输出文件名为 20 的文件的前 10 行,然后输出文件名为 file 文件的前 10 行。Linux 操作系统可以接受一个或多个参数。
为了更容易的指定多个文件名,shell 支持 魔法字符(magic character)
,也被称为通配符(wild cards)
。比如,*
可以匹配一个或者多个可能的字符串
ls *.c
告诉 ls 列举出所有文件名以 .c
结束的文件。如果同时存在多个文件,则会在后面进行并列。
另一个通配符是问号,负责匹配任意一个字符。一组在中括号中的字符可以表示其中任意一个,因此
ls [abc]
会列举出所有以 a
、b
或者 c
开头的文件。
shell 应用程序不一定通过终端进行输入和输出。shell 启动时,就会获取 标准输入、标准输出、标准错误文件进行访问的能力。
标准输出是从键盘输入的,标准输出或者标准错误是输出到显示器的。许多 Linux 程序默认是从标准输入进行输入并从标准输出进行输出。比如
sort
会调用 sort 程序,会从终端读取数据(直到用户输入 ctrl-d 结束),根据字母顺序进行排序,然后将结果输出到屏幕上。
通常还可以重定向标准输入和标准输出,重定向标准输入使用 <
后面跟文件名。标准输出可以通过一个大于号 >
进行重定向。允许一个命令中重定向标准输入和输出。例如命令
sort <in >out
会使 sort 从文件 in 中得到输入,并把结果输出到 out 文件中。由于标准错误没有重定向,所以错误信息会直接打印到屏幕上。从标准输入读入,对其进行处理并将其写入到标准输出的程序称为 过滤器
。
考虑下面由三个分开的命令组成的指令
sort <in >temp;head -30 <temp;rm temp
首先会调用 sort 应用程序,从标准输入 in 中进行读取,并通过标准输出到 temp。当程序运行完毕后,shell 会运行 head ,告诉它打印前 30 行,并在标准输出(默认为终端)上打印。最后,temp 临时文件被删除。轻轻的,你走了,你挥一挥衣袖,不带走一片云彩。
命令行中的第一个程序通常会产生输出,在上面的例子中,产生的输出都不 temp 文件接收。然而,Linux 还提供了一个简单的命令来做这件事,例如下面
sort <in | head -30
上面 |
称为竖线符号,它的意思是从 sort 应用程序产生的排序输出会直接作为输入显示,无需创建、使用和移除临时文件。由管道符号连接的命令集合称为管道(pipeline)
。例如如下
grep cxuan *.c | sort | head -30 | tail -5 >f00
对任意以 .t
结尾的文件中包含 cxuan
的行被写到标准输出中,然后进行排序。这些内容中的前 30 行被 head 出来并传给 tail ,它又将最后 5 行传递给 foo。这个例子提供了一个管道将多个命令连接起来。
可以把一系列 shell 命令放在一个文件中,然后将此文件作为输入来运行。shell 会按照顺序对他们进行处理,就像在键盘上键入命令一样。包含 shell 命令的文件被称为 shell 脚本(shell scripts)
。
推荐一个 shell 命令的学习网站:https://www.shellscript.sh/
shell 脚本其实也是一段程序,shell 脚本中可以对变量进行赋值,也包含循环控制语句比如 if、for、while 等,shell 的设计目标是让其看起来和 C 相似(There is no doubt that C is father)。由于 shell 也是一个用户程序,所以用户可以选择不同的 shell。
Linux 应用程序
Linux 的命令行也就是 shell,它由大量标准应用程序组成。这些应用程序主要有下面六种
- 文件和目录操作命令
- 过滤器
- 文本程序
- 系统管理
- 程序开发工具,例如编辑器和编译器
- 其他
除了这些标准应用程序外,还有其他应用程序比如 Web 浏览器、多媒体播放器、图片浏览器、办公软件和游戏程序等。
我们在上面的例子中已经见过了几个 Linux 的应用程序,比如 sort、cp、ls、head,下面我们再来认识一下其他 Linux 的应用程序。
我们先从几个例子开始讲起,比如
cp a b
是将 a 复制一个副本为 b ,而
mv a b
是将 a 移动到 b ,但是删除原文件。
上面这两个命令有一些区别,cp
是将文件进行复制,复制完成后会有两个文件 a 和 b;而 mv
相当于是文件的移动,移动完成后就不再有 a 文件。cat
命令可以把多个文件内容进行连接。使用 rm
可以删除文件;使用 chmod
可以允许所有者改变访问权限;文件目录的的创建和删除可以使用 mkdir
和 rmdir
命令;使用 ls
可以查看目录文件,ls 可以显示很多属性,比如大小、用户、创建日期等;sort 决定文件的显示顺序
Linux 应用程序还包括过滤器 grep,grep
从标准输入或者一个或多个输入文件中提取特定模式的行;sort
将输入进行排序并输出到标准输出;head
提取输入的前几行;tail 提取输入的后面几行;除此之外的过滤器还有 cut
和 paste
,允许对文本行的剪切和复制;od
将输入转换为 ASCII ;tr
实现字符大小写转换;pr
为格式化打印输出等。
程序编译工具使用 gcc
;
make
命令用于自动编译,这是一个很强大的命令,它用于维护一个大的程序,往往这类程序的源码由许多文件构成。典型的,有一些是 header files 头文件
,源文件通常使用 include
指令包含这些文件,make 的作用就是跟踪哪些文件属于头文件,然后安排自动编译的过程。
下面列出了 POSIX 的标准应用程序
程序 | 应用 |
---|---|
ls | 列出目录 |
cp | 复制文件 |
head | 显示文件的前几行 |
make | 编译文件生成二进制文件 |
cd | 切换目录 |
mkdir | 创建目录 |
chmod | 修改文件访问权限 |
ps | 列出文件进程 |
pr | 格式化打印 |
rm | 删除一个文件 |
rmdir | 删除文件目录 |
tail | 提取文件最后几行 |
tr | 字符集转换 |
grep | 分组 |
cat | 将多个文件连续标准输出 |
od | 以八进制显示文件 |
cut | 从文件中剪切 |
paste | 从文件中粘贴 |
Linux 内核结构
在上面我们看到了 Linux 的整体结构,下面我们从整体的角度来看一下 Linux 的内核结构
内核直接坐落在硬件上,内核的主要作用就是 I/O 交互、内存管理和控制 CPU 访问。上图中还包括了 中断
和 调度器
,中断是与设备交互的主要方式。中断出现时调度器就会发挥作用。这里的低级代码停止正在运行的进程,将其状态保存在内核进程结构中,并启动驱动程序。进程调度也会发生在内核完成一些操作并且启动用户进程的时候。图中的调度器是 dispatcher。
注意这里的调度器是
dispatcher
而不是scheduler
,这两者是有区别的scheduler 和 dispatcher 都是和进程调度相关的概念,不同的是 scheduler 会从几个进程中随意选取一个进程;而 dispatcher 会给 scheduler 选择的进程分配 CPU。
然后,我们把内核系统分为三部分。
- I/O 部分负责与设备进行交互以及执行网络和存储 I/O 操作的所有内核部分。
从图中可以看出 I/O 层次的关系,最高层是一个虚拟文件系统
,也就是说不管文件是来自内存还是磁盘中,都是经过虚拟文件系统中的。从底层看,所有的驱动都是字符驱动或者块设备驱动。二者的主要区别就是是否允许随机访问。网络驱动设备并不是一种独立的驱动设备,它实际上是一种字符设备,不过网络设备的处理方式和字符设备不同。
上面的设备驱动程序中,每个设备类型的内核代码都不同。字符设备有两种使用方式,有一键式
的比如 vi 或者 emacs ,需要每一个键盘输入。其他的比如 shell ,是需要输入一行按回车键将字符串发送给程序进行编辑。
网络软件通常是模块化的,由不同的设备和协议来支持。大多数 Linux 系统在内核中包含一个完整的硬件路由器的功能,但是这个不能和外部路由器相比,路由器上面是协议栈
,包括 TCP/IP 协议,协议栈上面是 socket 接口,socket 负责与外部进行通信,充当了门的作用。
磁盘驱动上面是 I/O 调度器,它负责排序和分配磁盘读写操作,以尽可能减少磁头的无用移动。
-
I/O 右边的是内存部件,程序被装载进内存,由 CPU 执行,这里会涉及到虚拟内存的部件,页面的换入和换出是如何进行的,坏页面的替换和经常使用的页面会进行缓存。
-
进程模块负责进程的创建和终止、进程的调度、Linux 把进程和线程看作是可运行的实体,并使用统一的调度策略来进行调度。
在内核最顶层的是系统调用接口,所有的系统调用都是经过这里,系统调用会触发一个 trap,将系统从用户态转换为内核态,然后将控制权移交给上面的内核部件。
MyBatis基础搭建及架构概述
MyBatis 是什么?
MyBatis是第一个支持自定义SQL、存储过程和高级映射的类持久框架。MyBatis消除了大部分JDBC的样板代码、手动设置参数以及检索结果。MyBatis能够支持简单的XML和注解配置规则。使Map接口和POJO类映射到数据库字段和记录。
下面我们通过一个简单的项目搭建来带你认识一下MyBatis的使用和一些核心组件的讲解。
MyBatis 项目构建
为了快速构建一个MyBatis项目,我们采用SpringBoot快速搭建的方式。搭建好后在对应的pom.xml下添加如下的maven依赖,主要作用在于引入mybatis一些jar包和类库
主要分为四个步骤:
- 快速构建项目,引入核心maven dependency依赖
- 构建POJO类和接口式编程的 Mapper类,编写SQL语句
- 编写
config.properties
数据库驱动等配置 - 构建Mybatis核心配置文件即
mybatis-config.xml
,引入数据库驱动,映射Mapper类 - 编写Junit单元测试类
<!-- mybatis 核心依赖-->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.4.6</version>
</dependency>
<!-- 数据库驱动包 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.25</version>
</dependency>
<!-- 单元测试包-->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
**为了便于更好的说明文章的主旨,这里就不贴出全部代码了,会贴出核心代码部分
编写对应的POJO类和接口式编程Mapper类,这里我们以部门业务逻辑为例,构建一个部门类,有三个属性即部门编号、部门名称、位置,下面是部分代码:
Dept.java
package com.mybatis.beans;
public class Dept {
private Integer deptNo;
private String dname;
private String loc;
public Dept() {}
public Dept(Integer deptNo, String dname, String loc) {
this.deptNo = deptNo;
this.dname = dname;
this.loc = loc;
}
get and set...
}
MyBatis最核心的功能之一就是接口式编程,它可以让我们编写Mapper接口和XML文件,从而把参数和返回结果映射到对应的字段中。
DeptDao.java
package com.mybatis.dao;
public interface DeptDao {
// 通过部门名称查询
public Dept findByDname(String Dname);
// 通过部门编号查询
public Dept findByDeptNo(Integer deptno);
}
在/resources 下新建com.mybatis.dao 包,在其内编写对应的XML配置文件,此XML配置文件和Mapper互为映射关系。
<mapper namespace="com.mybatis.dao.DeptDao" >
<sql id="DeptFindSql">
select * from dept
</sql>
<select id="findByDeptNo" resultType="com.mybatis.beans.Dept">
<include refid="DeptFindSql"></include>
where deptno = #{deptNo}
</select>
<select id="findByDname" resultType="com.mybatis.beans.Dept">
<include refid="DeptFindSql"></include>
where dname = #{dname}
</select>
</mapper>
上述的
就是映射到Mapper接口类的命名空间
<select>
标签用于编写查询语句,查询完成之后需要把结果映射到对象或者map集合等,需要用到resultType
属性指定对应的结果集。上述采用了
和 的标签写法,为了方便的映射到实体类,需要修改的话统一修改即可,降低耦合性。
构建完成基础的SQL语句和映射之后,下面来构建MySQL数据库驱动,在/resources 下创建config.properties
类
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/test
jdbc.username=root
jdbc.password=123456
在/resources 下编写MyBatis核心配置文件myBatis-config.xml
,引入数据库驱动,映射Mapper类
<configuration>
<!-- 设置导入外部properties文件位置 -->
<properties resource="config.properties"></properties>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${jdbc.driver}"/>
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</dataSource>
</environment>
</environments>
<mappers>
<package name="com.mybatis.dao"/>
</mappers>
</configuration>
configuration 标签很像是Spring 中的 beans 标签或者是基于注解的配置@Configuration,也就是MyBatis的核心配置环境,使用 properties 标签引入外部属性环境,也就是数据库驱动配置,使用 mappers 映射到Mapper所在的包,这里指的就是DeptDao.java所在的包。
在test包下面新建一个Junit单元测试类,主要流程如下:
MyBatisTest.java 代码如下:
public class MyBatisTest {
private SqlSession sqlSession;
/
* 读取配置文件,创建SQL工厂,打开会话
* @throws Exception
*/
@Before
public void start() throws Exception{
InputStream is = Resources.getResourceAsStream("myBatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(is);
sqlSession = factory.openSession();
}
/
* 销毁会话
*/
@After
public void destroy() {
if(sqlSession != null){
sqlSession.close();
}
}
@Test
public void test(){
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
Dept dept = deptDao.findByDeptNo(1);
System.out.println(dept.getDname());
}
}
@Before 和 @After 是junit工具包中的类,@Before在执行@Test 测试其主要业务之前加载,@After 在执行@Test 测试完成之后加载。
整体结构如下:
MyBatis 整体架构
MyBatis的架构大概是这样的,最上面是接口层,接口层就是开发人员在Mapper或者是Dao接口中的接口定义,是查询、新增、更新还是删除操作;中间层是数据处理层,主要是配置Mapper -> xml层级之间的参数映射,SQL解析,SQL执行,结果映射的过程。上述两种流程都由基础支持层来提供功能支撑,基础支持层包括连接管理,事务管理,配置加载,缓存处理。
接口层
在不与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的四大神器,它们是: Executor
、StatementHandler
、ParameterHandler
、ResultSetHandler
。SQL的执行过程可以
用下面这幅图来表示
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的基础模块,它们为核心处理层提供了良好的支撑。
**(1)反射模块
Mybatis中的反射模块,对Java原生的反射进行了很好的封装,提供了简易的API,方便上层调用,并且对反射操作进行了一系列的优化,比如,缓存了类的元数据(MetaClass)和对象的元数据(MetaObject),提高了反射操作的性能。
**(2)类型转换模块
Mybatis的别名机制,是为了简化配置文件的,该机制是类型转换模块的主要功能之一。类型转换模块的另一个功能是实现JDBC类型与Java类型间的转换。该功能在SQL语句绑定实参和映射查询结果集时都会涉及。在SQL语句绑定实参时,会将数据有Java类型转换成JDBC类型;在映射结果集时,会将数据有JDBC类型转换成Java类型。
**(3)日志模块
Java世界里,有很多优秀的日志框架,如Log4j、Log4j2、slf4j等。Mybatis除了提供了详细的日志输出信息,还能够集成多种日志框架,其日志模块的主要功能就是集成第三方日志框架。
**(4)资源加载模块
该模块主要封装了类加载器,确定了类加载器的使用顺序,并提供了加载类文件和其它资源文件的功能。
**(5) 解析器模块
该模块有两个主要功能:一个是封装了XPath,为Mybatis初始化时解析mybatis-config.xml配置文件以及映射配置文件提供支持;另一个为处理动态SQL语句中的占位符提供支持。
**(6)数据源模块
在数据源模块中,Mybatis自身提供了相应的数据源实现,也提供了与第三方数据源集成的接口。数据源是开发中的常用组件之一,很多开源的数据源都提供了丰富的功能,如,连接池、检测连接状态等,选择性能优秀的数据源组件,对于提供ORM框架以及整个应用的性能都是非常重要的。
**(7)事务管理模块
一般地,Mybatis与Spring框架集成,由Spring框架管理事务。但Mybatis自身对数据库事务进行了抽象,提供了相应的事务接口和简单实现。
**(8)缓存模块
Mybatis中有一级缓存和二级缓存,这两级缓存都依赖于缓存模块中的实现。但是,需要注意,这两级缓存与Mybatis以及整个应用是运行在同一个JVM中的,共享同一块内存,如果这两级缓存中的数据量较大,则可能影响系统中其它功能,所以需要缓存大量数据时,优先考虑使用Redis、Memcache等缓存产品。
**(9)Binding模块
在调用SqlSession相应方法执行数据库操作时,需要制定映射文件中定义的SQL节点,如果sql中出现了拼写错误,那就只能在运行时才能发现。为了能尽早发现这种错误,Mybatis通过Binding模块将用户自定义的Mapper接口与映射文件关联起来,系统可以通过调用自定义Mapper接口中的方法执行相应的SQL语句完成数据库操作,从而避免上述问题。注意,在开发中,我们只是创建了Mapper接口,而并没有编写实现类,这是因为Mybatis自动为Mapper接口创建了动态代理对象。有时,自定义的Mapper接口可以完全代替映射配置文件,但比如动态SQL语句啊等,还是写在映射配置文件中更好。
MyBatis核心配置综述之ParameterHandler
MyBatis 四大核心组件我们已经了解到了两种,一个是 Executor ,它是MyBatis 解析SQL请求首先会经过的第一道关卡,它的主要作用在于创建缓存,管理 StatementHandler 的调用,为 StatementHandler 提供 Configuration 环境等。StatementHandler 组件最主要的作用在于创建 Statement 对象与数据库进行交流,还会使用 ParameterHandler 进行参数配置,使用 ResultSetHandler 把查询结果与实体类进行绑定。那么本篇就来了解一下第三个组件 ParameterHandler。
ParameterHandler 简介
ParameterHandler
相比于其他的组件就简单很多了,ParameterHandler 译为参数处理器,负责为 PreparedStatement 的 sql 语句参数动态赋值,这个接口很简单只有两个方法
/
* A parameter handler sets the parameters of the {@code PreparedStatement}
* 参数处理器为 PreparedStatement 设置参数
*/
public interface ParameterHandler {
Object getParameterObject();
void setParameters(PreparedStatement ps)
throws SQLException;
}
ParameterHandler 只有一个实现类 DefaultParameterHandler
, 它实现了这两个方法。
- getParameterObject: 用于读取参数
- setParameters: 用于对 PreparedStatement 的参数赋值
ParameterHandler 创建
参数处理器对象是在创建 StatementHandler 对象的同时被创建的,由 Configuration 对象负责创建
BaseStatementHandler.java
protected BaseStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
this.configuration = mappedStatement.getConfiguration();
this.executor = executor;
this.mappedStatement = mappedStatement;
this.rowBounds = rowBounds;
this.typeHandlerRegistry = configuration.getTypeHandlerRegistry();
this.objectFactory = configuration.getObjectFactory();
if (boundSql == null) { // issue #435, get the key before calculating the statement
generateKeys(parameterObject);
boundSql = mappedStatement.getBoundSql(parameterObject);
}
this.boundSql = boundSql;
// 创建参数处理器
this.parameterHandler = configuration.newParameterHandler(mappedStatement, parameterObject, boundSql);
// 创建结果映射器
this.resultSetHandler = configuration.newResultSetHandler(executor, mappedStatement, rowBounds, parameterHandler, resultHandler, boundSql);
}
在创建 ParameterHandler 时,需要传入SQL的mappedStatement 对象,读取的参数和SQL语句
注意:一个 BoundSql 对象,就代表了一次sql语句的实际执行,而 SqlSource 对象的责任,就是根据传入的参数对象,动态计算这个 BoundSql, 也就是 Mapper 文件中节点的计算,是由 SqlSource 完成的,SqlSource 最常用的实现类是 DynamicSqlSource
Configuration.java
public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
// 创建ParameterHandler
ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
return parameterHandler;
}
上面是 Configuration 创建 ParameterHandler 的过程,它实际上是交由 LanguageDriver
来创建具体的参数处理器,LanguageDriver 默认的实现类是 XMLLanguageDriver
,由它调用 DefaultParameterHandler
中的构造方法完成 ParameterHandler 的创建工作
public ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
return new DefaultParameterHandler(mappedStatement, parameterObject, boundSql);
}
public DefaultParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
this.mappedStatement = mappedStatement;
this.configuration = mappedStatement.getConfiguration();
// 获取 TypeHandlerRegistry 注册
this.typeHandlerRegistry = mappedStatement.getConfiguration().getTypeHandlerRegistry();
this.parameterObject = parameterObject;
this.boundSql = boundSql;
}
上面的流程是创建 ParameterHandler 的过程,创建完成之后,该进行具体的解析工作,那么 ParameterHandler 如何解析SQL中的参数呢?SQL中的参数从哪里来的?
ParameterHandler 中的参数从何而来
你可能知道 Parameter 中的参数是怎么来的,无非就是从 Mapper 配置文件中映射过去的啊,就比如如下例子
参数肯定就是图中标红的 1 ,然后再传到XML对应的 SQL 语句中,用 #{}
或者 ${}
来进行赋值啊,
嗯,你讲的没错,可是你知道这个参数是如何映射过来的吗?或者说你知道 Parameter 的解析过程吗?或许你不是很清晰了,我们下面就来探讨一下 ParameterHandler 对参数的解析,这其中涉及到 MyBatis 中的动态代理模式
在MyBatis 中,当 deptDao.findByDeptNo(1) 将要执行的时候,会被 JVM 进行拦截,交给 MyBatis 中的代理实现类 MapperProxy 的 invoke 方法中,这也是执行 SQL 语句的主流程。
然后交给 Executor 、StatementHandler进行对应的参数解析和执行,因为是带参数的 SQL 语句,最终会创建 PreparedStatement 对象并创建参数解析器进行参数解析
SimpleExecutor.java
handler.parameterize(stmt) 最终会调用到 DefaultParameterHandler
中的 setParameters
方法,我在源码上做了注释,为了方便拷贝,我没有采用截图的形式
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 解析
我们在 MyBatis 核心配置综述之 StatementHandler
一文中了解到 Executor 管理的是 StatementHandler 对象的创建以及参数赋值,那么我们的主要入口还是 Executor 执行器
下面用一个流程图表示一下 ParameterHandler 的解析过程,以简单执行器为例
像是 doQuery
,doUpdate
,doQueryCursor
等方法都会先调用到
// 生成 preparedStatement 并调用 prepare 方法,并为参数赋值
private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
Statement stmt;
Connection connection = getConnection(statementLog);
stmt = handler.prepare(connection, transaction.getTimeout());
handler.parameterize(stmt);
return stmt;
}
然后在生成 preparedStatement
调用DefaultParameterHandler
进行参数赋值。
MyBatis想启动?得先问问它同不同意
话说,我最近一直在研究 MyBatis ,研究 MyBatis ,必然逃不了研究 Configuration
对象,这个对象简直是太重要了,它是 MyBatis 起步的核心环境配置,下面我们来一起看一下 Configuration 类
Configuration 的创建
如果你喜欢一个妹子,你是不是闲得问清楚妹子住在哪?只加微信那就只能望梅止渴,主动出击才是硬道理。否则,就算你租了一辆玛莎拉蒂,你都不知道在哪装B。
想要了解 Configuration,得先问清楚它是如何创建的。
在这之前,我先告诉你一个 MyBatis 的入口类,那就是 SqlSessionFactoryBuilder
, 为什么要介绍这个类哦?因为这个类可以创建 SqlSession
,想要孩子?没有Builder 的功能怎么行?它的创建在这里
SqlSessionFactoryBuilder 在创建完成 XMLConfigBuilder
之后,会完成 Configuration 的创建工作,也就是说Configuration 对象的创建是在 XMLConfigBuilder 中完成的 ,如下图
看到这里,你是不是有点跃跃欲试想要按住 control 键点进去?如你所愿,看一下 new Configuration
到底生出个什么东西
这就是初始化 Configuration 完成的工作了,图中还有一个很关键的类就是 TypeAliasRegistry
, 想要注册?你得先知道 "我" 是谁 。
TypeAliasRegistry 在Configuration 创建的时候就被初始化了
protected final TypeAliasRegistry typeAliasRegistry = new TypeAliasRegistry();
so? 看一下 new 都做了一些什么事情
public TypeAliasRegistry() {
registerAlias("string", String.class);
registerAlias("byte", Byte.class);
registerAlias("long", Long.class);
registerAlias("short", Short.class);
registerAlias("int", Integer.class);
registerAlias("integer", Integer.class);
registerAlias("double", Double.class);
registerAlias("float", Float.class);
registerAlias("boolean", Boolean.class);
registerAlias("byte[]", Byte[].class);
registerAlias("long[]", Long[].class);
registerAlias("short[]", Short[].class);
registerAlias("int[]", Integer[].class);
registerAlias("integer[]", Integer[].class);
registerAlias("double[]", Double[].class);
registerAlias("float[]", Float[].class);
registerAlias("boolean[]", Boolean[].class);
registerAlias("_byte", byte.class);
registerAlias("_long", long.class);
registerAlias("_short", short.class);
registerAlias("_int", int.class);
registerAlias("_integer", int.class);
registerAlias("_double", double.class);
registerAlias("_float", float.class);
registerAlias("_boolean", boolean.class);
registerAlias("_byte[]", byte[].class);
registerAlias("_long[]", long[].class);
registerAlias("_short[]", short[].class);
registerAlias("_int[]", int[].class);
registerAlias("_integer[]", int[].class);
registerAlias("_double[]", double[].class);
registerAlias("_float[]", float[].class);
registerAlias("_boolean[]", boolean[].class);
registerAlias("date", Date.class);
registerAlias("decimal", BigDecimal.class);
registerAlias("bigdecimal", BigDecimal.class);
registerAlias("biginteger", BigInteger.class);
registerAlias("object", Object.class);
registerAlias("date[]", Date[].class);
registerAlias("decimal[]", BigDecimal[].class);
registerAlias("bigdecimal[]", BigDecimal[].class);
registerAlias("biginteger[]", BigInteger[].class);
registerAlias("object[]", Object[].class);
registerAlias("map", Map.class);
registerAlias("hashmap", HashMap.class);
registerAlias("list", List.class);
registerAlias("arraylist", ArrayList.class);
registerAlias("collection", Collection.class);
registerAlias("iterator", Iterator.class);
registerAlias("ResultSet", ResultSet.class);
}
好刺激啊,这么一大段代码,不过看起来还是比较清晰明了的,这不就是 MyBatis 常用类型么,并给它们都起了一个各自的别名存起来,用来解析的时候使用。
Configuration 的标签以及使用
说完了 Configuration 的创建,我们不直接切入初始化的主题,先来吃点甜点
还记得你是如何搭建一个 MyBatis 项目么?其中很关键的是不是有一个叫做 mybatis-config.xml
的这么一个配置?
这个配置就是 <configuration>
标签存在的意义了。
我在最外侧写了一个 configuration 标签,然后 dtd 语言约束就给我提示这么多属性可以设置,它们都是属于 Configuration 内的标签,那么这些标签都是啥呢?别急,慢慢来,掌握好频率和节奏还有力度,别太猛,年轻人要沉稳。
我不想按着标签的顺序来了,请跟好我的节奏。
首先很重要的两个属性就是 properties
和 environments
,properties 就是外部属性配置,你可以这么配置它
<properties resource="config.properties" />
导入外部配置文件,config.properties
文件中是一系列关于数据库的配置,给你举个例子吧,看你着急的
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/kkb
jdbc.username=root
jdbc.password=123456
载入外部属性配置后,需要配置 environments
标签,它可以配置事务管理、数据源、读取配置文件等
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${jdbc.driver}"/>
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</dataSource>
</environment>
</environments>
明白否?
还有一个很关键的配置就是 mapper
标签,你可以把它理解为 ComponentScan
,ComponentScan 完成的是 Bean 定义的查找,而 mapper 完成的是 接口的查找,该接口要与对应的 XML 命名空间相匹配才可以。例如
<mappers>
<package name="com.mybatis.dao"/>
</mappers>
再继续深入,来看一下 <setting>
都需要哪些内容,你可以设置下面这些,下面这些配置有些多,你可以查看(http://www.mybatis.org/mybatis-3/zh/configuration.html#settings) 来具体查看这些配置。
<settings>
// 全局地开启或关闭配置文件中的所有映射器已经配置的任何缓存。
<setting name="cacheEnabled" value="true"/>
// 延迟加载的全局开关。当开启时,所有关联对象都会延迟加载。 特定关联关系中可通过设置 fetchType 属性来覆盖该项的开关状态。
<setting name="lazyLoadingEnabled" value="true"/>
// 是否允许单一语句返回多结果集(需要驱动支持)。
<setting name="multipleResultSetsEnabled" value="true"/>
// 使用列标签代替列名。不同的驱动在这方面会有不同的表现,具体可参考相关驱动文档或通过测试这两种不同的模式来观察所用驱动的结果。
<setting name="useColumnLabel" value="true"/>
// 允许 JDBC 支持自动生成主键,需要驱动支持。 如果设置为 true 则这个设置强制使用自动生成主键,尽管一些驱动不能支持但仍可正常工作(比如 Derby)。
<setting name="useGeneratedKeys" value="false"/>
// 指定 MyBatis 应如何自动映射列到字段或属性。 NONE 表示取消自动映射;PARTIAL 只会自动映射没有定义嵌套结果集映射的结果集。 FULL 会自动映射任意复杂的结果集(无论是否嵌套)。
<setting name="autoMappingBehavior" value="PARTIAL"/>
// 指定发现自动映射目标未知列(或者未知属性类型)的行为。
// NONE: 不做任何反应
// WARNING: 输出提醒日志 ('org.apache.ibatis.session.AutoMappingUnknownColumnBehavior' 的日志等级必须设置为 WARN)
// FAILING: 映射失败 (抛出 SqlSessionException)
<setting name="autoMappingUnknownColumnBehavior" value="WARNING"/>
// 配置默认的执行器。SIMPLE 就是普通的执行器;REUSE 执行器会重用预处理语句(prepared statements); BATCH 执行器将重用语句并执行批量更新。
<setting name="defaultExecutorType" value="SIMPLE"/>
// 设置超时时间,它决定驱动等待数据库响应的秒数。
<setting name="defaultStatementTimeout" value="25"/>
// 为驱动的结果集获取数量(fetchSize)设置一个提示值。此参数只可以在查询设置中被覆盖。
<setting name="defaultFetchSize" value="100"/>
// 允许在嵌套语句中使用分页(RowBounds)。如果允许使用则设置为 false
<setting name="safeRowBoundsEnabled" value="false"/>
// 是否开启自动驼峰命名规则(camel case)映射,即从经典数据库列名 A_COLUMN 到经典 Java 属性名 aColumn 的类似映射。
<setting name="mapUnderscoreToCamelCase" value="false"/>
// MyBatis 利用本地缓存机制(Local Cache)防止循环引用(circular references)和加速重复嵌套查询。 默认值为 SESSION,这种情况下会缓存一个会话中执行的所有查询。 若设置值为 STATEMENT,本地会话仅用在语句执行上,对相同 SqlSession 的不同调用将不会共享数据
<setting name="localCacheScope" value="SESSION"/>
// 当没有为参数提供特定的 JDBC 类型时,为空值指定 JDBC 类型。 某些驱动需要指定列的 JDBC 类型,多数情况直接用一般类型即可,比如 NULL、VARCHAR 或 OTHER。
<setting name="jdbcTypeForNull" value="OTHER"/>
// 指定哪个对象的方法触发一次延迟加载。
<setting name="lazyLoadTriggerMethods" value="equals,clone,hashCode,toString"/>
</settings>
你知道 Oracle 和 MySQL 都可以对表,字段设置别名吗?MyBatis 也可以设置别名,采用的是 typeAliases
属性,比如
<!-- 为每一个实体类设置一个具体别名 -->
<typeAliases>
<typeAlias type="com.kaikeba.beans.Dept" alias="Dept"/>
</typeAliases>
<!-- 为当前包下的每一个类设置一个默认别名 -->
<typeAliases>
<package name="com.mybatis.beans"/>
</typeAliases>
MyBatis 可以根据不同的数据库厂商执行不同的语句,这种多厂商的支持是基于映射语句中的 databaseId
属性。 MyBatis 会加载不带 databaseId
属性和带有匹配当前数据库 databaseId
属性的所有语句。 如果同时找到带有 databaseId
和不带 databaseId
的相同语句,则后者会被舍弃。 为支持多厂商特性只要像下面这样在 mybatis-config.xml 文件中加入 databaseIdProvider
即可:
<databaseIdProvider type="DB_VENDOR" />
DB_VENDOR 对应的 databaseIdProvider 实现会将 databaseId 设置为 DatabaseMetaData#getDatabaseProductName()
返回的字符串。 由于通常情况下这些字符串都非常长而且相同产品的不同版本会返回不同的值,所以你可能想通过设置属性别名来使其变短,如下:
<databaseIdProvider type="DB_VENDOR">
<property name="SQL Server" value="sqlserver"/>
<property name="DB2" value="db2"/>
<property name="Oracle" value="oracle" />
</databaseIdProvider>
MyBatis 每次创建结果对象的新实例时,它都会使用一个对象工厂(ObjectFactory)实例来完成。 默认的对象工厂需要做的仅仅是实例化目标类,要么通过默认构造方法,要么在参数映射存在的时候通过参数构造方法来实例化。 如果想覆盖对象工厂的默认行为,则可以通过创建自己的对象工厂来实现。比如:
// ExampleObjectFactory.java
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);
}
}
<!-- mybatis-config.xml -->
<objectFactory type="org.mybatis.example.ExampleObjectFactory">
<property name="someProperty" value="100"/>
</objectFactory>
ObjectFactory 的作用就很像是 Spring 中的 FactoryBean ,如果不是很了解关于 FactoryBean 的讲解,请移步至
(https://mp.weixin.qq.com/s/aCFzCopCX1mK6Zg-dT_KgA) 进行了解
MyBatis 留给开发人员的后门是可以进行插件开发的,插件开发在何处体现呢?其实 MyBatis 四大组件都会有体现, MyBatis 的插件开发其实也是代理的一种应用,如图
Configuration.java
这是 Executor 插件开发的调用位置,那么 StatementHandler, ParameterHandler, ResultSetHandler 的调用和 Executor 基本一致,如图
过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。例如官网的这个例子
// ExamplePlugin.java
@Intercepts({@Signature(
type= Executor.class,
method = "update",
args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {
private Properties properties = new Properties();
public Object intercept(Invocation invocation) throws Throwable {
// implement pre processing if need
Object returnObject = invocation.proceed();
// implement post processing if need
return returnObject;
}
public void setProperties(Properties properties) {
this.properties = properties;
}
}
只需要再把这个插件告诉 MyBatis, 这里有个插件拦截器,记得用奥
<!-- mybatis-config.xml -->
<plugins>
<plugin interceptor="org.mybatis.example.ExamplePlugin">
<property name="someProperty" value="100"/>
</plugin>
</plugins>
typeHandlers
也叫做类型转换器,主要用在参数转换的地方,哪里进行参数转换呢?其实有两点:
- PreparedStatementHandler 在解析 SQL 参数,进行参数设置的时候,需要把 Java Type 转换为 JDBC 类型
- ResultSetHandler 返回的结果集,需要把 JDBC 类型转换为 Java Type
可以编写自己的类型转换器,如下:
// ExampleTypeHandler.java
@MappedJdbcTypes(JdbcType.VARCHAR)
public class ExampleTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return rs.getString(columnName);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return rs.getString(columnIndex);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return cs.getString(columnIndex);
}
}
也需要告诉 MyBatis ,这里面有个参数转换器,别忘了转换!
<!-- mybatis-config.xml -->
<typeHandlers>
<typeHandler handler="org.mybatis.example.ExampleTypeHandler"/>
</typeHandlers>
Configuration 标签的解析
现在有了上面的这些标签的定义,应该在哪解析呢?就好比合适的人在合适的岗位才能创造出最大的价值一样。
现在就需要续上 SqlSessionFactoryBuilder 的第三步了, Configuration 的解析工作
在 XMLConfigBuilder 中
这是不是就和上面的标签对应起来了?解析工作是在这里进行的,这也是一种好的编码习惯,一个方法只做一件事情,应该多多借鉴这种写法。
Configuration 子标签的源码分析
假如你能从上向下看到这里,就说明你对这篇文章产生了浓厚的兴趣,恭喜你,你的段位又升级了。我不打王者荣耀,我之前一直打魔兽solo,solo是很需要手速的,同时也需要考虑到各种因素:比如你是 ORC(兽族),你的 BM(剑圣) 开 W(疾风步) 抢怪的时间要掌握好,你骚扰 NE (暗夜精灵) 采木材的时间要掌握好,抢宝的时间要掌握好,比如你玩的是 Turtle Rock(龟岛),你单刷蓝胖的时间也要算好,等等等等。
你既要sky的中规中矩,你也要MOON的不羁,你还要fly100%的沉稳,你也需要TED的坚持。也就印证了一句话,小孩子才做选择,成年人都要!
所以你不仅仅要知其果,还要懂其因。
第一步:Properties 解析
第一个方法: propertiesElement(root.evalNode("properties"))
,点进去可以看到其源码,我这里已经做了注释,方便你去理解
// 其实一个个 <> 的标签就是 一个个的XNode节点
private void propertiesElement(XNode context) throws Exception {
if (context != null) {
// 首先判断要解析的属性是否有无子节点
Properties defaults = context.getChildrenAsProperties();
// 解析<properties resource=""/> 解析完成就变为配置文件的 SimpleName
String resource = context.getStringAttribute("resource");
// 解析<properties url=""/>
String url = context.getStringAttribute("url");
// 如果都为空,抛出异常
if (resource != null && url != null) {
throw new BuilderException("The properties element cannot specify both a URL and a resource based property file reference. Please specify one or the other.");
}
// 如果不为空的话,就把配置文件的内容都放到 Resources 类中
if (resource != null) {
defaults.putAll(Resources.getResourceAsProperties(resource));
} else if (url != null) {
defaults.putAll(Resources.getUrlAsProperties(url));
}
// 这块应该是判断有无之前的配置
Properties vars = configuration.getVariables();
if (vars != null) {
defaults.putAll(vars);
}
parser.setVariables(defaults);
// 最后放入 configuration 属性中
configuration.setVariables(defaults);
}
}
第二步:Settings 解析
在这里我们以二级缓存的开启为例来做解析
<!-- 通知 MyBatis 框架开启二级缓存 -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
那么它在settingsAsProperties(root.evalNode("settings"))
中是如何解析的呢?
// XNode 就是一个个的 标签
private Properties settingsAsProperties(XNode context) {
if (context == null) {
return new Properties();
}
// 获取字标签,字标签也就是 <settings> 中的 <setting>
Properties props = context.getChildrenAsProperties();
// Check that all settings are known to the configuration class
// 用反射确保所有的设置都在 Configuration 类中。
MetaClass metaConfig = MetaClass.forClass(Configuration.class, localReflectorFactory);
for (Object key : props.keySet()) {
// 如果反射没有确保这个key 在类中,就抛出异常
if (!metaConfig.hasSetter(String.valueOf(key))) {
throw new BuilderException("The setting " + key + " is not known. Make sure you spelled it correctly (case sensitive).");
}
}
return props;
}
解析完成后的 settings
对象,底层是用 Hashtable 存储了一个个的 entry 对象。
第三步:TypeAliases 解析
TypeAliases 用于别名注册,你可以为实体类指定它的别名,源码如下
private void typeAliasesElement(XNode parent) {
if (parent != null) {
// 也是首先判断有无子标签
for (XNode child : parent.getChildren()) {
// 如果有字标签,那么取出字标签的属性名,如果是 package
if ("package".equals(child.getName())) {
// 那么取出 字标签 的name属性
String typeAliasPackage = child.getStringAttribute("name");
configuration.getTypeAliasRegistry().registerAliases(typeAliasPackage);
} else {
// typeAliases 下面有两个标签,一个是 package 一个是 TypeAlias
String alias = child.getStringAttribute("alias");
String type = child.getStringAttribute("type");
try {
Class<?> clazz = Resources.classForName(type);
if (alias == null) {
typeAliasRegistry.registerAlias(clazz);
} else {
typeAliasRegistry.registerAlias(alias, clazz);
}
} catch (ClassNotFoundException e) {
throw new BuilderException("Error registering typeAlias for '" + alias + "'. Cause: " + e, e);
}
}
}
}
}
第四步:Plugins 解析
MyBatis 中的插件都在这一步进行解析注册
private void pluginElement(XNode parent) throws Exception {
if (parent != null) {
for (XNode child : parent.getChildren()) {
// 取出 interceptor 的名称
String interceptor = child.getStringAttribute("interceptor");
Properties properties = child.getChildrenAsProperties();
// 生成新实例,设置属性名
Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).newInstance();
interceptorInstance.setProperties(properties);
// 添加到 configuration 中
configuration.addInterceptor(interceptorInstance);
}
}
}
其他步骤
其实后面的源码分析步骤都差不多,大体上都是判断有无此 XNode 节点,然后判断它的子节点标签,得到标签的属性,放入 Configuration 对象中,这样就完成了 Configuration 对象的初始化,其实你可以看出,MyBatis 中的 Configuration 也是一个大的容器,来为后面的SQL语句解析和初始化提供保障。
总结
本文主要概括了
- Configuration 的创建过程
SqlSessionFactoryBuilder 创建 XMLConfigBuilder ,XMLConfigBuilder 再创建 Configuration , Configuration 的创建会装载一些基本属性,如事务,数据源,缓存,日志,代理等,它们由 TypeAliasRegistry 进行注册,而TypeAliasRegistry 初始化也注册了一些基本数据类型,map,list,collection等,Configuration 还初始化了其他很多属性,由此完成 Configuration 的创建。
- Configuration 的标签以及使用
此步骤分析了 Configuration 中的标签以及使用,此部分不用去记忆,只知道有哪几个比较重要的标签就可以了,比如: properties, environment,mappers,settings,typeHandler,如果有开发需求直接查找官网就好
(http://www.mybatis.org/mybatis-3/zh/configuration.html)
- Configuration 对标签的解析
此步骤分析了 XMLConfigBuilder 对 Configuration 类下所有标签的解析工作,解析工作大部分模式都差不多
大体上都是判断有无此 XNode 节点,然后判断它的子节点标签,得到标签的属性,放入 Configuration 对象中。
MyBatis核心配置综述之ResultSetHandler
我们之前介绍过了MyBatis 四大核心配置之 Executor、StatementHandler、 ParameterHandler,今天本文的主题是介绍一下 MyBatis 最后一个神器也就是 ResultSetHandler。那么开始我们的讨论
ResultSetHandler 简介
回想一下,一条 SQL 的请求过程会经过哪几个步骤? 首先会经过 Executor 执行器,它主要负责管理创建 StatementHandler 对象,然后由 StatementHandler 对象做数据库的连接以及生成 Statement 对象,并解析 SQL 参数,由 ParameterHandler 对象负责把 Mapper 方法中的参数映射到 XML 中的 SQL 语句中,那么是不是还少了一个步骤,就能完成一个完整的 SQL 请求了?没错,这最后一步就是 SQL 结果集的处理工作,也就是 ResultSetHandler
的主要工作
要了解 ResultSetHandler 之前,首先需要了解 ResultSetHandler的继承关系以及基本方法
public interface ResultSetHandler {
// 处理结果集
<E> List<E> handleResultSets(Statement stmt) throws SQLException;
// 批量处理结果集
<E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;
// 处理存储过程的结果集
void handleOutputParameters(CallableStatement cs) throws SQLException;
}
ResultSetHandler是一个接口,它只有一个默认的实现类,像是 ParameterHandler 一样,它的默认实现类是DefaultResultSetHandler
ResultSetHandler 创建
ResultSetHandler 是在处理查询请求的时候由 Configuration 对象负责创建,示例如下
protected BaseStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
this.configuration = mappedStatement.getConfiguration();
this.executor = executor;
this.mappedStatement = mappedStatement;
this.rowBounds = rowBounds;
this.typeHandlerRegistry = configuration.getTypeHandlerRegistry();
this.objectFactory = configuration.getObjectFactory();
if (boundSql == null) { // issue #435, get the key before calculating the statement
generateKeys(parameterObject);
boundSql = mappedStatement.getBoundSql(parameterObject);
}
this.boundSql = boundSql;
// 创建参数处理器
this.parameterHandler = configuration.newParameterHandler(mappedStatement, parameterObject, boundSql);
// 创建结果映射器
this.resultSetHandler = configuration.newResultSetHandler(executor, mappedStatement, rowBounds, parameterHandler, resultHandler, boundSql);
}
public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler,
ResultHandler resultHandler, BoundSql boundSql) {
// 由 DefaultResultSetHandler 进行初始化
ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
return resultSetHandler;
}
上述的创建过程是对 ResultSetHandler 创建过程以及初始化的简单解释,下面是对具体的查询请求进行分析
ResultSetHandler 处理结果映射
回想一下,我们在进行传统crud操作的时候,哪些方法是需要返回值的?当然我们说的返回值指的是从数据库中查询出来的值,而不是标识符,应该只有查询方法吧?所以 MyBatis 只针对 query 方法做了返回值的映射,代码如下:
PreparedStatementHandler.java
@Override
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
// 处理结果集
return resultSetHandler.<E> handleResultSets(ps);
}
@Override
public <E> Cursor<E> queryCursor(Statement statement) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
// 批量处理结果集
return resultSetHandler.<E> handleCursorResultSets(ps);
}
CallableStatementHandler.java 处理存储过程的SQL
@Override
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
CallableStatement cs = (CallableStatement) statement;
cs.execute();
List<E> resultList = resultSetHandler.<E>handleResultSets(cs);
resultSetHandler.handleOutputParameters(cs);
return resultList;
}
@Override
public <E> Cursor<E> queryCursor(Statement statement) throws SQLException {
CallableStatement cs = (CallableStatement) statement;
cs.execute();
Cursor<E> resultList = resultSetHandler.<E>handleCursorResultSets(cs);
resultSetHandler.handleOutputParameters(cs);
return resultList;
}
DefaultResultSetHandler 源码解析
MyBatis 只有一个默认的实现类就是 DefaultResultSetHandler
,ResultSetHandler 主要负责处理两件事
- 处理 Statement 执行后产生的结果集,生成结果列表
- 处理存储过程执行后的输出参数
按照 Mapper 文件中配置的 ResultType 或 ResultMap 来封装成对应的对象,最后将封装的对象返回即可。
来看一下主要的源码:
@Override
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
: 负责处理更复杂的映射关系
multipleResults
:
其中的主要方法是 handleResultSet
private void handleResultSet(ResultSetWrapper rsw, ResultMap resultMap, List<Object> multipleResults, ResultMapping parentMapping) throws SQLException {
try {
if (parentMapping != null) {
// 处理多行结果的值
handleRowValues(rsw, resultMap, null, RowBounds.DEFAULT, parentMapping);
} else {
if (resultHandler == null) {
DefaultResultHandler defaultResultHandler = new DefaultResultHandler(objectFactory);
handleRowValues(rsw, resultMap, defaultResultHandler, rowBounds, null);
multipleResults.add(defaultResultHandler.getResultList());
} else {
handleRowValues(rsw, resultMap, resultHandler, rowBounds, null);
}
}
} finally {
// issue #228 (close resultsets)
closeResultSet(rsw.getResultSet());
}
}
// 如果有嵌套的ResultMap 的话
// 确保没有行绑定
// 检查结果处理器
// 如果没有的话,直接处理简单的ResultMap
public void handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
if (resultMap.hasNestedResultMaps()) {
ensureNoRowBounds();
checkResultHandler();
handleRowValuesForNestedResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
} else {
handleRowValuesForSimpleResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
}
}
handleResultSets 方法返回的是 collapseSingleResultList(multipleResults) ,它是什么呢?
private List<Object> collapseSingleResultList(List<Object> multipleResults) {
return multipleResults.size() == 1 ? (List<Object>) multipleResults.get(0) : multipleResults;
}
它是判断的 multipleResults 的数量,如果数量是 1 ,就直接取位置为0的元素,如果不是1,那就返回 multipleResults 的真实数量
那么 multipleResults 的数量是哪来的呢?
它的值其实是处理结果集中传递进去的
handleResultSet(rsw, resultMap, multipleResults, null);
然后在处理结果集的方法中对 multipleResults 进行添加
multipleResults.add(defaultResultHandler.getResultList());
下面我们来看一下返回的真实实现类 DefaultResultSetHandler 中的结构组成
在 DefaultResultSetHandler 中处理完结果映射,并把上述结构返回给调用的客户端,从而执行完成一条完整的SQL语句。
MyBatis核心配置综述之StatementHandler
MyBatis 四大组件之StatementHandler
StatementHandler
是四大组件中最重要的一个对象,负责操作 Statement 对象与数据库进行交流,在工作时还会使用 ParameterHandler 和 ResultSetHandler 对参数进行映射,对结果进行实体类的绑定
我们在搭建原生JDBC的时候,会有这样一行代码
Statement stmt = conn.createStatement(); //也可以使用PreparedStatement来做
这行代码创建的 Statement 对象或者是 PreparedStatement 对象就是由StatementHandler进行管理的。
StatementHandler 的基本构成
来看一下StatementHandler中的主要方法:
- prepare: 用于创建一个具体的 Statement 对象的实现类或者是 Statement 对象
- parametersize: 用于初始化 Statement 对象以及对sql的占位符进行赋值
- update: 用于通知 Statement 对象将 insert、update、delete 操作推送到数据库
- query: 用于通知 Statement 对象将 select 操作推送数据库并返回对应的查询结果
StatementHandler的继承结构
有没有感觉和 Executor
的继承体系很相似呢?最顶级接口是四大组件对象,分别有两个实现类 BaseStatementHandler
和 RoutingStatementHandler
,BaseStatementHandler 有三个实现类, 他们分别是 SimpleStatementHandler、PreparedStatementHandler 和 CallableStatementHandler。
RoutingStatementHandler: RoutingStatementHandler 并没有对 Statement 对象进行使用,只是根据StatementType 来创建一个代理,代理的就是对应Handler的三种实现类。在MyBatis工作时,使用的StatementHandler 接口对象实际上就是 RoutingStatementHandler 对象.我们可以理解为
StatementHandler statmentHandler = new RountingStatementHandler();
public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
// 根据 statementType 创建对应的 Statement 对象
switch (ms.getStatementType()) {
case STATEMENT:
delegate = new SimpleStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
case PREPARED:
delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
case CALLABLE:
delegate = new CallableStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
default:
throw new ExecutorException("Unknown statement type: " + ms.getStatementType());
}
}
BaseStatementHandler: 是 StatementHandler 接口的另一个实现类.本身是一个抽象类.用于简化StatementHandler 接口实现的难度,属于适配器设计模式体现,它主要有三个实现类
- SimpleStatementHandler: 管理 Statement 对象并向数据库中推送不需要预编译的SQL语句
- PreparedStatementHandler: 管理 Statement 对象并向数据中推送需要预编译的SQL语句,
- CallableStatementHandler:管理 Statement 对象并调用数据库中的存储过程
StatementHandler 对象创建以及源码分析
StatementHandler 对象是在 SqlSession 对象接收到命令操作时,由 Configuration 对象中的newStatementHandler 负责调用的,也就是说 Configuration 中的 newStatementHandler 是由执行器中的查询、更新(插入、更新、删除)方法来提供的,StatementHandler 其实就是由 Executor 负责管理和创建的。
SimpleExecutor.java
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
Statement stmt = null;
try {
// 获取环境配置
Configuration configuration = ms.getConfiguration();
// 创建StatementHandler,解析SQL语句
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
stmt = prepareStatement(handler, ms.getStatementLog());
// 由handler来对SQL语句执行解析工作
return handler.<E>query(stmt, resultHandler);
} finally {
closeStatement(stmt);
}
}
由图中可以看出,StatementHandler 默认创建一个 RoutingStatementHandler ,这也就是 StatementHandler 的默认实现,由 RoutingStatementHandler 负责根据 StatementType 创建对应的StatementHandler 来处理调用。
prepare方法调用流程分析
prepare 方法的调用过程是这样的,在上面的源码分析过程中,我们分析到了执行器 Executor 在执行SQL语句的时候会创建 StatementHandler 对象,进而经过一系列的 StatementHandler 类型的判断并初始化。再拿到StatementHandler 返回的 statementhandler 对象的时候,会调用其prepareStatement()
方法,下面就来一起看一下 preparedStatement()
方法(我们以简单执行器为例,因为创建其 StatementHandler 对象的流程和执行 preparedStatement() 方法的流程是差不多的):
SimpleExecutor.java
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
Statement stmt = null;
try {
// 获取环境配置
Configuration configuration = ms.getConfiguration();
// 创建StatementHandler,解析SQL语句
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
stmt = prepareStatement(handler, ms.getStatementLog());
// 由handler来对SQL语句执行解析工作
return handler.<E>query(stmt, resultHandler);
} finally {
closeStatement(stmt);
}
}
private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
Statement stmt;
Connection connection = getConnection(statementLog);
stmt = handler.prepare(connection, transaction.getTimeout());
handler.parameterize(stmt);
return stmt;
}
// prepare方法调用到 StatementHandler 的实现类RoutingStatementHandler,再由RoutingStatementHandler调用BaseStatementHandler中的prepare 方法
// RoutingStatementHandler.java
@Override
public Statement prepare(Connection connection, Integer transactionTimeout) throws SQLException {
return delegate.prepare(connection, transactionTimeout);
}
// BaseStatementHandler.java
@Override
public Statement prepare(Connection connection, Integer transactionTimeout) throws SQLException {
ErrorContext.instance().sql(boundSql.getSql());
Statement statement = null;
try {
statement = instantiateStatement(connection);
setStatementTimeout(statement, transactionTimeout);
setFetchSize(statement);
return statement;
} ...
其中最重要的方法就是 instantiateStatement()
方法了,在得到数据库连接 connection 的对象的时候,会去调用 instantiateStatement()
方法,instantiateStatement 方法位于 StatementHandler 中,是一个抽象方法由子类去实现,实际执行的是三种 StatementHandler 中的一种,我们还以 SimpleStatementHandler
为例
protected Statement instantiateStatement(Connection connection) throws SQLException {
if (mappedStatement.getResultSetType() != null) {
return connection.createStatement(mappedStatement.getResultSetType().getValue(), ResultSet.CONCUR_READ_ONLY);
} else {
return connection.createStatement();
}
}
从上面代码我们可以看到,instantiateStatement() 最终返回的也是Statement对象,经过一系列的调用会把statement 对象返回到 SimpleExecutor 简单执行器中,为 parametersize 方法所用。也就是说,prepare 方法负责生成 Statement 实例对象,而 parameterize 方法用于处理 Statement 实例多对应的参数。
parametersize 方法调用流程分析
parametersize 方法看的就比较畅快了,也是经由执行器来管理 parametersize 的方法调用,这次我们还想以SimpleStatementHandler 为例但是却不行了?为什么呢?因为 SimpleStatementHandler 是个空实现了,为什么是null呢?因为 SimpleStatementHandler 只负责处理简单SQL,能够直接查询得到结果的SQL,例如:
select studenname from Student
而 SimpleStatementHandler 又不涉及到参数的赋值问题,那么参数赋值该在哪里进行呢?实际上为参数赋值这步操作是在 PreparedStatementHandler
中进行的,因此我们的主要关注点在 PreparedStatementHandler 中的parameterize 方法
public void parameterize(Statement statement) throws SQLException {
parameterHandler.setParameters((PreparedStatement) statement);
}
我们可以看到,为参数赋值的工作是由一个叫做 parameterHandler 对象完成的,都是这样的吗?来看一下CallableStatementHandler
public void parameterize(Statement statement) throws SQLException {
registerOutputParameters((CallableStatement) statement);
parameterHandler.setParameters((CallableStatement) statement);
}
上面代码可以看到,CallableStatementHandler 也是由 parameterHandler 进行参数赋值的。
那么这个 parameterHandler 到底是什么呢?这个问题能想到说明老兄你已经上道了,这也就是我们执行器的第三个组件。这个组件我们在下一节进行分析
update 方法调用流程分析
用一幅流程图来表示一下这个调用过程:
简单描述一下update 方法的执行过程:
- MyBatis 接收到 update 请求后会先找到 CachingExecutor 缓存执行器查询是否需要刷新缓存,然后找到BaseExecutor 执行 update 方法;
- BaseExecutor 基础执行器会清空一级缓存,然后交给再根据执行器的类型找到对应的执行器,继续执行 update 方法;
- 具体的执行器会先创建 Configuration 对象,根据 Configuration 对象调用 newStatementHandler 方法,返回 statementHandler 的句柄;
- 具体的执行器会调用 prepareStatement 方法,找到本类的 prepareStatement 方法后,再有prepareStatement 方法调用 StatementHandler 的子类 BaseStatementHandler 中的 prepare 方法
- BaseStatementHandler 中的 prepare 方法会调用 instantiateStatement 实例化具体的 Statement 对象并返回给具体的执行器对象
- 由具体的执行器对象调用 parameterize 方法给参数进行赋值。
续上上面的 parameter
方法,具体交给 ParameterHandler
进行进一步的赋值处理
Query 查询方法几乎和 update 方法相同,这里就不再详细的举例说明了
MyBatis启动流程
初识 MyBatis
MyBatis 是第一个支持自定义 SQL、存储过程和高级映射的类持久框架。MyBatis 消除了大部分 JDBC 的样板代码、手动设置参数以及检索结果。MyBatis 能够支持简单的 XML 和注解配置规则。使 Map 接口和 POJO 类映射到数据库字段和记录。
MyBatis 的特点
那么 MyBatis 具有什么特点呢?或许我们可以从如下几个方面来描述
- MyBatis 中的 SQL 语句和主要业务代码分离,我们一般会把 MyBatis 中的 SQL 语句统一放在 XML 配置文件中,便于统一维护。
- 解除 SQL 与程序代码的耦合,通过提供 DAO 层,将业务逻辑和数据访问逻辑分离,使系统的设计更清晰,更易维护,更易单元测试。SQL 和代码的分离,提高了可维护性。
- MyBatis 比较简单和轻量
本身就很小且简单。没有任何第三方依赖,只要通过配置 jar 包,或者如果你使用 Maven 项目的话只需要配置 Maven 以来就可以。易于使用,通过文档和源代码,可以比较完全的掌握它的设计思路和实现。
- 屏蔽样板代码
MyBatis 回屏蔽原始的 JDBC 样板代码,让你把更多的精力专注于 SQL 的书写和属性-字段映射上。
- 编写原生 SQL,支持多表关联
MyBatis 最主要的特点就是你可以手动编写 SQL 语句,能够支持多表关联查询。
- 提供映射标签,支持对象与数据库的 ORM 字段关系映射
ORM 是什么?
对象关系映射(Object Relational Mapping,简称ORM)
,是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。本质上就是将数据从一种形式转换到另外一种形式。
- 提供 XML 标签,支持编写动态 SQL。
你可以使用 MyBatis XML 标签,起到 SQL 模版的效果,减少繁杂的 SQL 语句,便于维护。
MyBatis 整体架构
MyBatis 最上面是接口层,接口层就是开发人员在 Mapper 或者是 Dao 接口中的接口定义,是查询、新增、更新还是删除操作;中间层是数据处理层,主要是配置 Mapper -> XML 层级之间的参数映射,SQL 解析,SQL 执行,结果映射的过程。上述两种流程都由基础支持层来提供功能支撑,基础支持层包括连接管理,事务管理,配置加载,缓存处理等。
接口层
在不与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 的四大核心,它们是: Executor
、StatementHandler
、ParameterHandler
、ResultSetHandler
。SQL 的执行过程可以用下面这幅图来表示
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
对象
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 输入流和environment
、properties
属性创建一个XMLConfigBuilder
对象。SqlSessionFactoryBuilder 对象调用XMLConfigBuilder 的parse()
方法,流程如下。
XMLConfigBuilder 会解析/configuration
标签,configuration 是 MyBatis 中最重要的一个标签,下面流程会介绍 Configuration 标签。
MyBatis 默认使用 XPath 来解析标签,关于 XPath 的使用,参见 https://www.w3school.com.cn/xpath/index.asp
在 parseConfiguration
方法中,会对各个在 /configuration
中的标签进行解析
重要配置
说一下这些标签都是什么意思吧
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/zh/configuration.html#settings 详细了解。这里介绍几个平常使用过程中比较重要的配置
属性 | 描述 |
---|---|
cacheEnabled | 全局地开启或关闭配置文件中的所有映射器已经配置的任何缓存。 |
useGeneratedKeys | 允许 JDBC 支持自动生成主键,需要驱动支持。 如果设置为 true 则这个设置强制使用自动生成主键。 |
lazyLoadingEnabled | 延迟加载的全局开关。当开启时,所有关联对象都会延迟加载。 |
jdbcTypeForNull | 当没有为参数提供特定的 JDBC 类型时,为空值指定 JDBC 类型。 某些驱动需要指定列的 JDBC 类型,多数情况直接用一般类型即可,比如 NULL、VARCHAR 或 OTHER。 |
defaultExecutorType | 配置默认的执行器。SIMPLE 就是普通的执行器;REUSE 执行器会重用预处理语句(prepared statements); BATCH 执行器将重用语句并执行批量更新。 |
localCacheScope | MyBatis 利用本地缓存机制(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,可以参考如下
你可以重写类型处理器或创建你自己的类型处理器来处理不支持的或非标准的类型。
具体做法为:实现 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"></transactionmanager> <datasource type="POOLED"></datasource></environment></environments>
-
databaseIdProvider
,数据库厂商标示,MyBatis 可以根据不同的数据库厂商执行不同的语句,这种多厂商的支持是基于映射语句中的databaseId
属性。<databaseidprovider type="DB_VENDOR"></databaseidprovider>
-
mappers
,映射器,这是告诉 MyBatis 去哪里找到这些 SQL 语句,mappers 映射配置有四种方式```
```
上面的一个个属性都对应着一个解析方法,都是使用 XPath 把标签进行解析,解析完成后返回一个 DefaultSqlSessionFactory
对象,它是 SqlSessionFactory 的默认实现类。这就是 SqlSessionFactoryBuilder 的初始化流程,通过流程我们可以看到,初始化流程就是对一个个 /configuration
标签下子标签的解析过程。
SqlSession
在 MyBatis 初始化流程结束,也就是 SqlSessionFactoryBuilder -> SqlSessionFactory 的获取流程后,我们就可以通过 SqlSessionFactory 对象得到 SqlSession
然后执行 SQL 语句了。具体来看一下这个过程
在 SqlSessionFactory.openSession 过程中我们可以看到,会调用到 DefaultSqlSessionFactory 中的 openSessionFromDataSource
方法,这个方法主要创建了两个与我们分析执行流程重要的对象,一个是 Executor
执行器对象,一个是 SqlSession
对象。执行器我们下面会说,现在来说一下 SqlSession 对象
SqlSession 对象是 MyBatis 中最重要的一个对象,这个接口能够让你执行命令,获取映射,管理事务。SqlSession 中定义了一系列模版方法,让你能够执行简单的 CRUD
操作,也可以通过 getMapper
获取 Mapper 层,执行自定义 SQL 语句,因为 SqlSession 在执行 SQL 语句之前是需要先开启一个会话,涉及到事务操作,所以还会有 commit
、 rollback
、close
等方法。这也是模版设计模式的一种应用。
MapperProxy
MapperProxy 是 Mapper 映射 SQL 语句的关键对象,我们写的 Dao 层或者 Mapper 层都是通过 MapperProxy
来和对应的 SQL 语句进行绑定的。下面我们就来解释一下绑定过程
这就是 MyBatis 的核心绑定流程,我们可以看到 SqlSession 首先调用 getMapper
方法,我们刚才说到 SqlSession 是大哥级别的人物,只定义标准(有一句话是怎么说的来着,一流的企业做标准,二流的企业做品牌,三流的企业做产品)。
SqlSession 不愿意做的事情交给 Configuration
这个手下去做,但是 Configuration 也是有小弟的,它不愿意做的事情直接甩给小弟去做,这个小弟是谁呢?它就是 MapperRegistry
,马上就到核心部分了。MapperRegistry 相当于项目经理,项目经理只从大面上把握项目进度,不需要知道手下的小弟是如何工作的,把任务完成了就好。最终真正干活的还是 MapperProxyFactory
。看到这段代码 Proxy.newProxyInstance ,你是不是有一种恍然大悟的感觉,如果你没有的话,建议查阅一下动态代理的文章,这里推荐一篇 (https://www.jianshu.com/p/95970b089360)
也就是说,MyBatis 中 Mapper 和 SQL 语句的绑定正是通过动态代理来完成的。
通过动态代理,我们就可以方便的在 Dao 层或者 Mapper 层定义接口,实现自定义的增删改查操作了。那么具体的执行过程是怎么样呢?上面只是绑定过程,别着急,下面就来探讨一下 SQL 语句的执行过程。
有一部分代码被遮挡,代码有些多,不过不影响我们看主要流程
MapperProxyFactory 会生成代理对象,这个对象就是 MapperProxy,最终会调用到 mapperMethod.execute 方法,execute
方法比较长,其实逻辑比较简单,就是判断是 插入
、更新
、删除
还是 查询
语句,其中如果是查询的话,还会判断返回值的类型,我们可以点进去看一下都是怎么设计的。
很多代码其实可以忽略,只看我标出来的重点就好了,我们可以看到,不管你前面经过多少道关卡处理,最终都逃不过 SqlSession
这个老大制定的标准。
我们以 selectList
为例,来看一下下面的执行过程。
这是 DefaultSqlSession
中 selectList 的代码,我们可以看到出现了 executor
,这是什么呢?我们下面来解释。
Executor
还记得我们之前的流程中提到了 Executor(执行器)
这个概念吗?我们来回顾一下它第一次出现的位置。
由 Configuration 对象创建了一个 Executor
对象,这个 Executor 是干嘛的呢?下面我们就来认识一下
Executor 的继承结构
每一个 SqlSession 都会拥有一个 Executor 对象,这个对象负责增删改查的具体操作,我们可以简单的将它理解为 JDBC 中 Statement 的封装版。 也可以理解为 SQL 的执行引擎,要干活总得有一个发起人吧,可以把 Executor 理解为发起人的角色。
首先先从 Executor 的继承体系来认识一下
如上图所示,位于继承体系最顶层的是 Executor 执行器,它有两个实现类,分别是BaseExecutor
和 CachingExecutor
。
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 会根据执行器的类型创建,如下
这一步就是执行器的创建过程,根据传入的 ExecutorType
类型来判断是哪种执行器,如果不指定 ExecutorType ,默认创建的是简单执行器。它的赋值可以通过两个地方进行赋值:
- 可以通过
<settings>
标签来设置当前工程中所有的 SqlSession 对象使用默认的 Executor
<settings>
<!--取值范围 SIMPLE, REUSE, BATCH -->
<setting name="defaultExecutorType" value="SIMPLE"/>
</settings>
- 另外一种直接通过Java对方法赋值的方式
session = factory.openSession(ExecutorType.BATCH);
Executor 的具体执行过程
Executor 中的大部分方法的调用链其实是差不多的,下面是深入源码分析执行过程,如果你没有时间或者暂时不想深入研究的话,给你下面的执行流程图作为参考。
我们紧跟着上面的 selectList
继续分析,它会调用到 executor.query
方法。
当有一个查询请求访问的时候,首先会经过 Executor 的实现类 CachingExecutor
,先从缓存中查询 SQL 是否是第一次执行,如果是第一次执行的话,那么就直接执行 SQL 语句,并创建缓存,如果第二次访问相同的 SQL 语句的话,那么就会直接从缓存中提取。
上面这段代码是从 selectList -> 从缓存中 query 的具体过程。可能你看到这里有些觉得类都是什么东西,我想鼓励你一下,把握重点,不用每段代码都看,从找到 SQL 的调用链路,其他代码想看的时候在看,看源码就是很容易发蒙,容易烦躁,但是切记一点,把握重点。
上面代码会判断缓存中是否有这条 SQL 语句的执行结果,如果没有的话,就再重新创建 Executor
执行器执行 SQL 语句,注意, list = doQuery
是真正执行 SQL 语句的过程,这个过程中会创建我们上面提到的三种执行器,这里我们使用的是简单执行器。
到这里,执行器所做的工作就完事了,Executor 会把后续的工作交给 StatementHandler
继续执行。下面我们来认识一下 StatementHandler
StatementHandler
StatementHandler
是四大组件中最重要的一个对象,负责操作 Statement 对象与数据库进行交互,在工作时还会使用 ParameterHandler
和 ResultSetHandler
对参数进行映射,对结果进行实体类的绑定,这两个组件我们后面说。
我们在搭建原生 JDBC 的时候,会有这样一行代码
Statement stmt = conn.createStatement(); //也可以使用PreparedStatement来做
这行代码创建的 Statement 对象或者是 PreparedStatement 对象就是由 StatementHandler 进行管理的。
StatementHandler 的继承结构
有没有感觉和 Executor
的继承体系很相似呢?最顶级接口是四大组件对象,分别有两个实现类 BaseStatementHandler
和 RoutingStatementHandler
,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 的创建过程如下
MyBatis 会根据 SQL 语句的类型进行对应 StatementHandler 的创建。我们以预处理 StatementHandler 为例来讲解一下
执行器不仅掌管着 StatementHandler 的创建,还掌管着创建 Statement 对象,设置参数等,在创建完 PreparedStatement 之后,我们需要对参数进行处理了。
如果用一副图来表示一下这个执行流程的话我想是这样
这里我们先暂停一下,来认识一下第三个核心组件 ParameterHandler
ParameterHandler
ParameterHandler 介绍
ParameterHandler
相比于其他的组件就简单很多了,ParameterHandler 译为参数处理器,负责为 PreparedStatement 的 sql 语句参数动态赋值,这个接口很简单只有两个方法
ParameterHandler 只有一个实现类 DefaultParameterHandler
, 它实现了这两个方法。
- getParameterObject: 用于读取参数
- setParameters: 用于对 PreparedStatement 的参数赋值
ParameterHandler 的解析过程
上面我们讨论过了 ParameterHandler
的创建过程,下面我们继续上面 parameterSize
流程
这就是具体参数的解析过程了,下面我们来描述一下
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 的解析过程,以简单执行器为例
我们在完成 ParameterHandler 对 SQL 参数的预处理后,回到 SimpleExecutor 中的 doQuery
方法
上面又引出来了一个重要的组件那就是 ResultSetHandler,下面我们来认识一下这个组件
ResultSetHandler
ResultSetHandler 简介
ResultSetHandler 也是一个非常简单的接口
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语句。
663.日期和时间
- 如何取得年月日、小时分钟秒?
- 如何取得从 1970 年 1 月 1 日 0 时 0 分 0 秒到现在的毫秒数?
- 如何取得某月的最后一天?
- 如何格式化日期?
答:操作方法如下所示:
- 创建 java.util.Calendar 实例,调用其 get()方法传入不同的参数即可获得参数所对应的值
- 以下方法均可获得该毫秒数:
- 示例代码如下:
- 利用 java.text.DataFormat 的子类(如 SimpleDateFormat 类)中的 format(Date)方法可将日期格式化。
664.打印昨天的当前时刻。
665.比较一下 Java 和 JavaSciprt
JavaScript 与 Java 是两个公司开发的不同的两个产品。Java 是原 Sun公司推出的面向对象的程序设计语言,特别适合于互联网应用程序开发;而 JavaScript 是 Netscape 公司的产品,为了扩展 Netscape 浏览器的功能而开发的一种可以嵌入 Web 页面中运行的基于对象和事件驱动的解释性语言,它的前身是 LiveScript;而 Java 的前身是 Oak 语言。下面对两种语言间的异同作如下比较:
- 基于对象和面向对象:Java 是一种真正的面向对象的语言,即使是开发简单的程序,必须设计对象;JavaScript 是种脚本语言,它可以用来制作与网络无关的,与用户交互作用的复杂软件。它是一种基于对象(Object-Based)和事件驱动(Event-Driven)的编程语言。因而它本身提供了非常丰富的内部对象供设计人员使用;
- 解释和编译:Java 的源代码在执行之前,必须经过编译;JavaScript 是一种解释性编程语言,其源代码不需经过编译,由浏览器解释执行;
- 强类型变量和类型弱变量:Java 采用强类型变量检查,即所有变量在编译之前必须作声明;JavaScript 中变量声明,采用其弱类型。即变量在使用前不需作声明,而是解释器在运行时检查其数据类型;
- 代码格式不一样。
补充:上面列出的四点是原来所谓的标准答案中给出的。其实 Java 和 JavaScript 最重要的区别是一个是静态语言,一个是动态语言。目前的编程语言的发展趋势是函数式语言和动态语言。在 Java 中类(class)是一等公民,而 JavaScript 中函数(function)是一等公民。对于这种问题,在面试时还是用自己的语言回答会更加靠谱。
666.什么时候用 assert?
assertion(断言)在软件开发中是一种常用的调试方式,很多开发语言中都支持这种机制。一般来说,assertion 用于保证程序最基本、关键的正确性。assertion 检查通常在开发和测试时开启。为了提高性能,在软件发布后, assertion 检查通常是关闭的。在实现中,断言是一个包含布尔表达式的语句,在执行这个语句时假定该表达式为 true;如果表达式计算为 false,那么系统会报告一个AssertionError。
断言用于调试目的:
assert(a > 0); // throws an AssertionError if a <= 0
断言可以有两种形式:
assert Expression1;
assert Expression1 : Expression2 ;
Expression1 应该总是产生一个布尔值。 Expression2 可以是得出一个值的任意表达式;这个值用于生成显示更多调试信息的字符串消息。
断言在默认情况下是禁用的,要在编译时启用断言,需使用 source 1.4 标记:
javac -source 1.4 Test.java
要在运行时启用断言,可使用-enableassertions 或者-ea 标记。 要在运行时选择禁用断言,可使用-da 或者-disableassertions 标记。 要在系统类中启用断言,可使用-esa 或者-dsa 标记。还可以在包的基础上启用或者禁用断言。可以在预计正常情况下不会到达的任何位置上放置断言。断言可以用于验证传递给私有方法的参数。不过,断言不应该用于验证传递给公有方法的参数,因为不管是否启用了断言,公有方法都必须检查其参数。不过,既可以在公有方法中,也可以在非公有方法中利用断言测试后置条件。另外,断言不应该以任何方式改变程序的状态。
667.Error 和 Exception 有什么区别
Error 表示系统级的错误和程序不必处理的异常,是恢复不是不可能但很困难的情况下的一种严重问题;比如内存溢出,不可能指望程序能处理这样的情况;Exception 表示需要捕捉或者需要程序进行处理的异常,是一种设计或实现问题;也就是说,它表示如果程序运行正常,从不会发生的情况。
668.try{}里有一个 return 语句,那么紧跟在这个 try 后的 finally{}里的 code 会不会被执行,什么时候被执行,在 return 前还是后?
会执行,在方法返回调用者前执行。Java 允许在 finally 中改变返回值的做法是不好的,因为如果存在 finally 代码块,try 中的 return 语句不会立马返回调用者,而是记录下返回值待 finally 代码块执行完毕之后再向调用者返回其值,然后如果在 finally 中修改了返回值,这会对程序造成很大的困扰,C#中就从语法上规定不能做这样的事。
669.Java 语言如何进行异常处理,关键字:throws、throw、try、catch、f inally 分别如何使用
Java 通过面向对象的方法进行异常处理,把各种不同的异常进行分类,并提供了良好的接口。在 Java 中,每个异常都是一个对象,它是 Throwable 类或其子类的实例。当一个方法出现异常后便抛出一个异常对象,该对象中包含有异常信息,调用这个对象的方法可以捕获到这个异常并进行处理。Java 的异常处理是通过 5 个关键词来实现的:try、catch、throw、throws 和 finally。 一般情况下是用 try 来执行一段程序,如果出现异常,系统会抛出(throw)一个异常,这时候你可以通过它的类型来捕捉(catch)它,或最后(finally)由缺省处理器来处理;try 用来指定一块预防所有“异常”的程序;catch 子句紧跟在 try 块后面,用来指定你想要捕捉的“异常”的类型;throw 语句用来明确地抛出一个“异常”;throws 用来标明一个成员函数可能抛出的各种“异常”;finally 为确保一段代码不管发生什么“异常”都被执行一段代码;可以在一个成员函数调用的外面写一个 try 语句,在这个成员函数内部写另一个 try 语句保护其他代码。每当遇到一个 try 语句,“异常”的框架就放到栈上面,直到所有的try 语句都完成。如果下一级的 try 语句没有对某种“异常”进行处理,栈就会展开,直到遇到有处理这种“异常”的 try 语句
670.运行时异常与受检异常有何异同?
异常表示程序运行过程中可能出现的非正常状态,运行时异常表示虚拟机的通常操作中可能遇到的异常,是一种常见运行错误,只要程序设计得没有问题通常就不会发生。受检异常跟程序运行的上下文环境有关,即使程序设计无误,仍然可能因使用的问题而引发。Java 编译器要求方法必须声明抛出可能发生的受检异常,但是并不要求必须声明抛出未被捕获的运行时异常。异常和继承一样,是面向对象程序设计中经常被滥用的东西,神作《Effective Java》中对异常的使用给出了以下指导原则:
- 不要将异常处理用于正常的控制流(设计良好的 API 不应该强迫它的调用者为了正常的控制流而使用异常)
- 对可以恢复的情况使用受检异常,对编程错误使用运行时异常
- 避免不必要的使用受检异常(可以通过一些状态检测手段来避免异常的发生)
- 优先使用标准的异常
- 每个方法抛出的异常都要有文档
- 保持异常的原子性
- 不要在 catch 中忽略掉捕获到的异常
671.列出一些你常见的运行时异常?
ArithmeticException(算术异常) ClassCastException (类转换异常) IllegalArgumentException (非法参数异常) IndexOutOfBoundsException (下表越界异常) NullPointerException (空指针异常) SecurityException (安全异常)
672.final, finally, finalize 的区别?
final:修饰符(关键字)有三种用法:如果一个类被声明为 final,意味着它不能再派生出新的子类,即不能被继承,因此它和 abstract 是反义词。将变量声明为 final,可以保证它们在使用中不被改变,被声明为 final 的变量必须在声明时给定初值,而在以后的引用中只能读取不可修改。被声明为 final 的方法也同样只能使用,不能在子类中被重写。finally:通常放在 try…catch 的后面构造总是执行代码块,这就意味着程序无论正常执行还是发生异常,这里的代码只要 JVM 不关闭都能执行,可以将释放外部资源的代码写在 finally 块中。finalize:Object 类中定义的方法,Java 中允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在销毁对象时调用的,通过重写 finalize() 方法可以整理系统资源或者执行其他清理工作。
VM 内存可简单分为三个区:
- 堆区(heap):用于存放所有对象,是线程共享的(注:数组也属于对象)
- 栈区(stack):用于存放基本数据类型的数据和对象的引用,是线程私有的(分为:虚拟机栈和本地方法栈)
- 方法区(method):用于存放类信息、常量、静态变量、编译后的字节码等,是线程共享的(也被称为非堆,即 None-Heap)
Java 的垃圾回收器(GC)主要针对堆区
MyBatis一级缓存
什么是缓存
缓存就是内存中的一个对象,用于对数据库查询结果的保存,用于减少与数据库的交互次数从而降低数据库的压力,进而提高响应速度。
什么是MyBatis中的缓存
**MyBatis 中的缓存就是说 MyBatis 在执行一次SQL查询或者SQL更新之后,这条SQL语句并不会消失,而是被MyBatis 缓存起来,当再次执行相同SQL语句的时候,就会直接从缓存中进行提取,而不是再次执行SQL命令。
MyBatis中的缓存分为一级缓存和二级缓存,一级缓存又被称为 SqlSession 级别的缓存,二级缓存又被称为表级缓存。
SqlSession是什么?SqlSession 是SqlSessionFactory会话工厂创建出来的一个会话的对象,这个SqlSession对象用于执行具体的SQL语句并返回给用户请求的结果。
SqlSession级别的缓存是什么意思?SqlSession级别的缓存表示的就是每当执行一条SQL语句后,默认就会把该SQL语句缓存起来,也被称为会话缓存
MyBatis 中的一级缓存
一级缓存是 SqlSession级别 的缓存。在操作数据库时需要构造 sqlSession 对象,在对象中有一个(内存区域)数据结构(HashMap)用于存储缓存数据。不同的 sqlSession 之间的缓存数据区域(HashMap)是互相不影响的。用一张图来表示一下一级缓存,其中每一个 SqlSession 的内部都会有一个一级缓存对象。
在应用运行过程中,我们有可能在一次数据库会话中,执行多次查询条件完全相同的SQL,**MyBatis 提供了一级缓存的方案优化这部分场景,如果是相同的SQL语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。
初探一级缓存
我们继续使用 MyBatis基础搭建以及配置详解中的例子(https://mp.weixin.qq.com/s/Ys03zaTSaOakdGU4RlLJ1A)进行进行) 一级缓存的探究。
在对应的 resources 根目录下加上日志的输出信息 log4j.properties
##define an appender named console
log4j.appender.console=org.apache.log4j.ConsoleAppender
#The Target value is System.out or System.err
log4j.appender.console.Target=System.out
#set the layout type of the apperder
log4j.appender.console.layout=org.apache.log4j.PatternLayout
#set the layout format pattern
log4j.appender.console.layout.ConversionPattern=[%-5p] %m%n
##define a logger
log4j.rootLogger=debug,console
模拟思路: 既然每个 SqlSession 都会有自己的一个缓存,那么我们用同一个 SqlSession 是不是就能感受到一级缓存的存在呢?调用多次 getMapper
方法,生成对应的SQL语句,判断每次SQL语句是从缓存中取还是对数据库进行操作,下面的例子来证明一下
@Test
public void test(){
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
Dept dept = deptDao.findByDeptNo(1);
System.out.println(dept);
DeptDao deptDao2 = sqlSession.getMapper(DeptDao.class);
Dept dept2 = deptDao2.findByDeptNo(1);
System.out.println(dept2);
System.out.println(deptDao2.findByDeptNo(1));
}
输出:
可以看到,上面代码执行了三条相同的SQL语句,但是只有一条SQL语句进行了输出,其他两条SQL语句都是从缓存中查询的,所以它们生成了相同的 Dept 对象。
探究一级缓存是如何失效的
上面的一级缓存初探让我们感受到了 MyBatis 中一级缓存的存在,那么现在你或许就会有疑问了,那么什么时候缓存失效呢? 这个问题也就是我们接下来需要详细讨论的议题之一。
**探究更新对一级缓存失效的影响
上面的代码执行了三次相同的查询操作,返回了相同的结果,那么,如果我在第一条和第二条SQL语句之前插入更新的SQL语句,是否会对一级缓存产生影响呢?代码如下:
@Test
public void testCacheLose(){
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
Dept dept = deptDao.findByDeptNo(1);
System.out.println(dept);
// 在两次查询之间使用 更新 操作,是否会对一级缓存产生影响
deptDao.insertDept(new Dept(7,"tengxun","shenzhen"));
// deptDao.updateDept(new Dept(1,"zhongke","sjz"));
// deptDao.deleteByDeptNo(7);
DeptDao deptDao2 = sqlSession.getMapper(DeptDao.class);
Dept dept2 = deptDao2.findByDeptNo(1);
System.out.println(dept2);
}
为了演示效果,就不贴出 insertDept 的代码了,就是一条简单的插入语句。
分别放开不同的更新语句,发现执行效果如下
输出结果:
如图所示,在两次查询语句中使用插入,会对一级缓存进行刷新,会导致一级缓存失效。
**探究不同的 SqlSession 对一级缓存的影响
如果你看到这里了,那么你应该知道一级缓存就是 SqlSession 级别的缓存,而同一个 SqlSession 会有相同的一级缓存,那么使用不同的 SqlSession 是不是会对一级缓存产生影响呢? 显而易见是的,那么下面就来演示并且证明一下
private SqlSessionFactory factory; // 把factory设置为全局变量
@Test
public void testCacheLoseWithSqlSession(){
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
Dept dept = deptDao.findByDeptNo(1);
System.out.println(dept);
SqlSession sqlSession2 = factory.openSession();
DeptDao deptDao2 = sqlSession2.getMapper(DeptDao.class);
Dept dept2 = deptDao2.findByDeptNo(1);
System.out.println(dept2);
}
输出:
上面代码使用了不同的 SqlSession 对同一个SQL语句执行了相同的查询操作,却对数据库执行了两次相同的查询操作,生成了不同的 dept 对象,由此可见,不同的 SqlSession 是肯定会对一级缓存产生影响的。
**同一个 SqlSession 使用不同的查询操作
使用不同的查询条件是否会对一级缓存产生影响呢?可能在你心里已经有这个答案了,再来看一下代码吧
@Test
public void testWithDifferentParam(){
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
Dept dept = deptDao.findByDeptNo(1);
System.out.println(dept);
DeptDao deptDao2 = sqlSession.getMapper(DeptDao.class);
Dept dept2 = deptDao2.findByDeptNo(5);
System.out.println(dept2);
}
输出结果
我们在两次查询SQL分别使用了不同的查询条件,查询出来的数据不一致,那就肯定会对一级缓存产生影响了。
**手动清理缓存对一级缓存的影响
我们在两次查询的SQL语句之间使用 clearCache
是否会对一级缓存产生影响呢?下面例子证实了这一点
@Test
public void testClearCache(){
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
Dept dept = deptDao.findByDeptNo(1);
System.out.println(dept);
//在两次相同的SQL语句之间使用查询操作,对一级缓存的影响。
sqlSession.clearCache();
DeptDao deptDao2 = sqlSession.getMapper(DeptDao.class);
Dept dept2 = deptDao2.findByDeptNo(1);
System.out.println(dept2);
}
输出:
我们在两次查询操作之间,使用了 sqlSession 的 clearCache() 方法清除了一级缓存,所以使用 clearCache 也会对一级缓存产生影响。
一级缓存原理探究
一级缓存到底是什么?一级缓存的工作流程是怎样的?一级缓存何时消失?相信你现在应该会有这几个疑问,那么我们本节就来研究一下一级缓存的本质
嗯。。。。。。该从何处入手呢?
你可以这样想,上面我们一直提到一级缓存,那么提到一级缓存就绕不开 SqlSession,所以索性我们就直接从 SqlSession ,看看有没有创建缓存或者与缓存有关的属性或者方法
调研了一圈,发现上述所有方法中,好像只有 clearCache()
和缓存沾点关系,那么就直接从这个方法入手吧,分析源码时,我们要看它(此类)是谁,它的父类和子类分别又是谁,对如上关系了解了,你才会对这个类有更深的认识,分析了一圈,你可能会得到如下这个流程图
再深入分析,流程走到Perpetualcache
中的 clear() 方法之后,会调用其 cache.clear()
方法,那么这个cache 是什么东西呢? 点进去发现,cache 其实就是 private Map<Object, Object> cache = new HashMap<Object, Object>();
也就是一个Map,所以说 cache.clear() 其实就是 map.clear() ,也就是说,缓存其实就是本地存放的一个 map 对象,每一个SqlSession 都会存放一个 map 对象的引用,那么这个 cache 是何时创建的呢?
你觉得最有可能创建缓存的地方是哪里呢? 我觉得是 Executor
,为什么这么认为? 因为 Executor 是执行器,用来执行SQL请求,而且清除缓存的方法也在 Executor 中执行,所以很可能缓存的创建也很有可能在 Executor 中,看了一圈发现 Executor 中有一个 createCacheKey
方法,这个方法很像是创建缓存的方法啊,跟进去看看,你发现 createCacheKey 方法是由 BaseExecutor
执行的,代码如下
CacheKey cacheKey = new CacheKey();
//MappedStatement的id
// id 就是Sql语句的所在位置 包名 + 类名 + SQL名称
cacheKey.update(ms.getId());
// offset 就是 0
cacheKey.update(rowBounds.getOffset());
// limit 就是 Integer.MAXVALUE
cacheKey.update(rowBounds.getLimit());
// 具体的SQL语句
cacheKey.update(boundSql.getSql());
//后面是update了sql中带的参数
cacheKey.update(value);
...
if (configuration.getEnvironment() != null) {
// issue #176
cacheKey.update(configuration.getEnvironment().getId());
}
创建缓存key会经过一系列的 update 方法,update 方法由一个 CacheKey
这个对象来执行的,这个 update 方法最终由 updateList
的 list 来把五个值存进去,对照上面的代码和下面的图示,你应该能理解这五个值都是什么了
这里需要注意一下最后一个值, configuration.getEnvironment().getId() 这是什么,这其实就是定义在
mybatis-config.xml
中的标签,见如下。
<environments default="development"><environment id="development"><transactionmanager type="JDBC"><datasource type="POOLED"></datasource></transactionmanager></environment></environments>
那么我们回归正题,那么创建完缓存之后该用在何处呢?总不会凭空创建一个缓存不使用吧?绝对不会的,经过我们对一级缓存的探究之后,我们发现一级缓存更多是用于查询操作,毕竟一级缓存也叫做查询缓存吧,为什么叫查询缓存我们一会儿说。我们先来看一下这个缓存到底用在哪了,我们跟踪到 query 方法如下:
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
// 创建缓存
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
...
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
// 这个主要是处理存储过程用的。
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
...
}
// queryFromDatabase 方法
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
如果查不到的话,就从数据库查,在queryFromDatabase
中,会对localcache
进行写入。localcache 对象的put 方法最终交给 Map 进行存放
private Map<Object, Object> cache = new HashMap<Object, Object>();
@Override
public void putObject(Object key, Object value) {
cache.put(key, value);
}
那么再说一下为什么一级缓存也叫做查询缓存呢?
我们先来看一下 update 更新方法,先来看一下 update 的源码
@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
clearLocalCache();
return doUpdate(ms, parameter);
}
由 BaseExecutor
在每次执行 update 方法的时候,都会先 clearLocalCache() ,所以更新方法并不会有缓存,这也就是说为什么一级缓存也叫做查询缓存了,这也就是为什么我们没有探究多次执行更新方法对一级缓存的影响了。
还有其他要补充的吗?
我们上面分析了一级缓存的执行流程,为什么一级缓存要叫查询缓存以及一级缓存组成条件
那么,你可能看到这感觉这些知识还是不够连贯,那么我就帮你把 一级缓存的探究
小结中的原理说一下吧,为什么一级缓存会失效
- 探究更新对一级缓存失效的影响: 由上面的分析结论可知,我们每次执行 update 方法时,都会先刷新一级缓存,因为是同一个 SqlSession, 所以是由同一个 Map 进行存储的,所以此时一级缓存会失效
- 探究不同的 SqlSession 对一级缓存的影响: 这个也就比较好理解了,因为不同的 SqlSession 会有不同的Map 存储一级缓存,然而 SqlSession 之间也不会共享,所以此时也就不存在相同的一级缓存
- 同一个 SqlSession 使用不同的查询操作: 这个论点就需要从缓存的构成角度来讲了,我们通过 cacheKey 可知,一级缓存命中的必要条件是两个 cacheKey 相同,要使得 cacheKey 相同,就需要使 cacheKey 里面的值相同,也就是
看出差别了吗?第一个SQL 我们查询的是部门编号为1的值,而第二个SQL我们查询的是编号为5的值,两个缓存对象不相同,所以也就不存在缓存。
- 手动清理缓存对一级缓存的影响: 由程序员自己去调用
clearCache
方法,这个方法就是清除缓存的方法,所以也就不存在缓存了。
总结
所以此文章到底写了点什么呢?抛给你几个问题了解一下
- 什么是缓存?什么是 MyBatis 缓存?
- 认识MyBatis缓存,MyBatis 一级缓存的失效方式
- MyBatis 一级缓存的执行流程,MyBatis 一级缓存究竟是什么?
MyBatis核心配置综述之Executor
上一篇我们对SqlSession和SqlSessionFactory的创建过程有了一个详细的了解,但上述的创建过程只是为SQL执行和SQL映射做了基础的铺垫而已,就和我们Spring源码为Bean容器的加载进行许多初始化的工作相同,那么做好前期的准备工作接下来该做什么了?该做数据库连接驱动管理和SQL解析工作了!那么本篇本章就来讨论一下数据库驱动连接管理和SQL解析的管理组件之 Executor执行器。
MyBatis四大组件之 Executor执行器
每一个SqlSession都会拥有一个Executor对象,这个对象负责增删改查的具体操作,我们可以简单的将它理解为JDBC中Statement的封装版。
Executor的继承结构
如图所示,位于继承体系最顶层的是Executor执行器,它有两个实现类,分别是BaseExecutor
和 CachingExecutor
。
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创建过程以及源码分析
上面我们分析完SqlSessionFactory的创建过程的准备工作后,我们下面就开始分析会话的创建以及Executor的执行过程。
在创建完SqlSessionFactory之后,调用其openSession
方法:
SqlSession sqlSession = factory.openSession();
SqlSessionFactory的默认实现是DefaultSqlSessionFactory,所以我们需要关心的就是DefaultSqlSessionFactory中的openSession()方法
openSession调用的是openSessionFromDataSource
方法,传递执行器的类型,方法传播级别,是否自动提交,然后在openSessionFromDataSource方法中会创建一个执行器
public SqlSession openSession() {
return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
}
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
Transaction tx = null;
try {
// 得到configuration 中的environment
final Environment environment = configuration.getEnvironment();
// 得到configuration 中的事务工厂
final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
// 获取执行器
final Executor executor = configuration.newExecutor(tx, execType);
// 返回默认的SqlSession
return new DefaultSqlSession(configuration, executor, autoCommit);
} catch (Exception e) {
closeTransaction(tx); // may have fetched a connection so lets call close()
throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
调用newExecutor方法,根据传入的ExecutorType类型来判断是哪种执行器,然后执行相应的逻辑
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
// defaultExecutorType默认是简单执行器, 如果不传executorType的话,默认使用简单执行器
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
Executor executor;
// 根据执行器类型生成对应的执行器逻辑
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
// 如果允许缓存,则使用缓存执行器
// 默认是true,如果不允许缓存的话,需要手动设置
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
// 插件开发。
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
ExecutorType的选择:
ExecutorType来决定Configuration对象创建何种类型的执行器,它的赋值可以通过两个地方进行赋值:
可以通过
标签来设置当前工程中所有的SqlSession对象使用默认的Executor 另外一种直接通过Java对方法赋值的方式
session = factory.openSession(ExecutorType.BATCH);
ExecutorType是一个枚举,它只有三个值SIMPLE, REUSE, BATCH
创建完成Executor之后,会把Executor执行器放入一个DefaultSqlSession对象中来对四个属性进行赋值,他们分别是 configuration
、executor
、 dirty
、autoCommit
。
Executor接口的主要方法
Executor接口的方法还是比较多的,这里我们就几个主要的方法和调用流程来做一个简单的描述
大致流程
Executor中的大部分方法的调用链其实是差不多的,下面都是深入源码分析执行过程,如果你没有时间或者暂时不想深入研究的话,给你下面的执行流程图作为参考。
query()方法
query方法有两种形式,一种是直接查询;一种是从缓存中查询,下面来看一下源码
<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;
<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;
当有一个查询请求访问的时候,首先会经过Executor的实现类CachingExecutor,先从缓存中查询SQL是否是第一次执行,如果是第一次执行的话,那么就直接执行SQL语句,并创建缓存,如果第二次访问相同的SQL语句的话,那么就会直接从缓存中提取
CachingExecutor.j
// 第一次查询,并创建缓存
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameterObject);
CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
MapperStatement
维护了一条,包括资源(resource),配置(configuration),SqlSource(sql源文件)等。使用Configuration的getMappedStatement方法来获取MappedStatement对象
BoundSql
这个类包括SQL的基本信息,基本的SQL语句,参数映射,参数类型等
上述的query方法会调用到CachingExecutor类中的query查询缓存的方法
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
// 得到缓存
Cache cache = ms.getCache();
if (cache != null) {
// 如果需要的话刷新缓存
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list);
}
return list;
}
}
// 委托模式,交给SimpleExecutor等实现类去实现方法。
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
由delegate执行query方法,delegate即是BaseExecutor,然后由具体的执行器去真正执行query方法
注意:源码中一般以do** 开头的方法都是真正加载执行的方法
// 经过一系列的调用,会调用到下面的方法(与主流程无关,故省略)
// 以SimpleExecutor简单执行器为例
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
Statement stmt = null;
try {
// 获取环境配置
Configuration configuration = ms.getConfiguration();
// 创建StatementHandler,解析SQL语句
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
stmt = prepareStatement(handler, ms.getStatementLog());
// 由handler来对SQL语句执行解析工作
return handler.<E>query(stmt, resultHandler);
} finally {
closeStatement(stmt);
}
}
由上面的源码可以看出,Executor执行器所起的作用相当于是管理StatementHandler 的整个生命周期的工作,包括创建、初始化、解析、关闭。
ReuseExecutor完成的doQuery 工作:几乎和SimpleExecutor完成的工作一样,其内部不过是使用一个Map来存储每次执行的查询语句,为后面的SQL重用作准备。
BatchExecutor完成的doQuery 工作:和SimpleExecutor完成的工作一样。
update() 方法
在分析完上面的查询方法后,我们再来聊一下update()方法,update()方法不仅仅指的是update()方法,它是一条update链,什么意思呢?就是*insert、update、delete在语义上其实都是更新的意思,而查询在语义上仅仅只是表示的查询,那么我们来偷窥一下update方法的执行流程,与select的主要执行流程很相似,所以一次性贴出。
// 首先在顶级接口中定义update 方法,交由子类或者抽象子类去实现
// 也是首先去缓存中查询是否具有已经执行过的相同的update语句
public int update(MappedStatement ms, Object parameterObject) throws SQLException {
flushCacheIfRequired(ms);
return delegate.update(ms, parameterObject);
}
// 然后再交由BaseExecutor 执行update 方法
public int update(MappedStatement ms, Object parameter) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
clearLocalCache();
return doUpdate(ms, parameter);
}
// 往往do* 开头的都是真正执行解析的方法,所以doUpdate 应该就是真正要执行update链的解析方法了
// 交给具体的执行器去执行
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog());
return handler.update(stmt);
} finally {
closeStatement(stmt);
}
}
ReuseExecutor完成的doUpdate 工作:几乎和SimpleExecutor完成的工作一样,其内部不过是使用一个Map来存储每次执行的更新语句,为后面的SQL重用作准备。
BatchExecutor完成的doUpdate 工作:和SimpleExecutor完成的工作相似,只是其内部有一个List列表来一次行的存储多个Statement,**用于将多个sql语句一次性输送到数据库执行.
queryCursor()方法
我们查阅其源码的时候,在执行器的执行过程中并没有发现其与query方法有任何不同之处,但是在doQueryCursor 方法中我们可以看到它返回了一个cursor对象,网上搜索cursor的相关资料并查阅其基本结构,得出来的结论是:用于逐条读取SQL语句,应对数据量
// 查询可以返回Cursor<T>类型的数据,类似于JDBC里的ResultSet类,
// 当查询百万级的数据的时候,使用游标可以节省内存的消耗,不需要一次性取出所有数据,可以进行逐条处理或逐条取出部分批量处理。
public interface Cursor<T> extends Closeable, Iterable<T> {
boolean isOpen();
boolean isConsumed();
int getCurrentIndex();
}
flushStatements() 方法
flushStatement()的主要执行流程和query,update 的执行流程差不多,我们这里就不再详细贴代码了,简单说一下flushStatement()的主要作用,flushStatement()主要用来释放statement,或者用于ReuseExecutor和BatchExecutor来刷新缓存
createCacheKey() 方法
createCacheKey()方法主要由BaseExecutor来执行并创建缓存,MyBatis中的缓存分为一级缓存和二级缓存,关于缓存的讨论我们将在Mybatis系列的缓存章节
Executor 中的其他方法
Executor 中还有其他方法,提交commit,回滚rollback,判断是否时候缓存isCached,关闭close,获取事务getTransaction一级清除本地缓存clearLocalCache等
Executor 的现实抽象
在上面的分析过程中我们了解到,Executor执行器是MyBatis中很重要的一个组件,Executor相当于是外包的boss,它定义了甲方(SQL)需要干的活(Executor的主要方法),这个外包公司是个小公司,没多少人,每个人都需要干很多工作,boss接到开发任务的话,一般都找项目经理(CachingExecutor),项目经理几乎不懂技术,它主要和技术leader(BaseExecutor)打交道,技术leader主要负责框架的搭建,具体的工作都会交给下面的程序员来做,程序员的技术也有优劣,高级程序员(BatchExecutor)、中级程序员(ReuseExecutor)、初级程序员(SimpleExecutor),它们干的活也不一样。一般有新的项目需求传达到项目经理这里,项目经理先判断自己手里有没有现成的类库或者项目直接套用(Cache),有的话就直接让技术leader拿来直接套用就好,没有的话就需要搭建框架,再把框架存入本地类库中,再进行解析。
MyBatis二级缓存全详解
MyBatis 二级缓存介绍
上一篇文章中我们介绍到了 MyBatis 一级缓存其实就是 SqlSession 级别的缓存,什么是 SqlSession 级别的缓存呢?一级缓存的本质是什么呢? 以及一级缓存失效的原因?我希望你在看下文之前能够回想起来这些内容。
MyBatis 一级缓存最大的共享范围就是一个SqlSession内部,那么如果多个 SqlSession 需要共享缓存,则需要开启二级缓存,开启二级缓存后,会使用 CachingExecutor 装饰 Executor,进入一级缓存的查询流程前,先在CachingExecutor 进行二级缓存的查询,具体的工作流程如下所示
当二级缓存开启后,同一个命名空间(namespace) 所有的操作语句,都影响着一个共同的 cache,也就是二级缓存被多个 SqlSession 共享,是一个全局的变量。当开启缓存后,数据的查询执行的流程就是 二级缓存 -> 一级缓存 -> 数据库。
二级缓存开启条件
二级缓存默认是不开启的,需要手动开启二级缓存,实现二级缓存的时候,MyBatis要求返回的POJO必须是可序列化的。开启二级缓存的条件也是比较简单,通过直接在 MyBatis 配置文件中通过
<settings>
<setting name = "cacheEnabled" value = "true" />
</settings>
来开启二级缓存,还需要在 Mapper 的xml 配置文件中加入 <cache>
标签
**设置 cache 标签的属性
cache 标签有多个属性,一起来看一些这些属性分别代表什么意义
eviction
: 缓存回收策略,有这几种回收策略- LRU – 最近最少回收,移除最长时间不被使用的对象
- FIFO – 先进先出,按照缓存进入的顺序来移除它们
- SOFT – 软引用,移除基于垃圾回收器状态和软引用规则的对象
- WEAK – 弱引用,更积极的移除基于垃圾收集器和弱引用规则的对象
默认是 LRU 最近最少回收策略
flushinterval
缓存刷新间隔,缓存多长时间刷新一次,默认不清空,设置一个毫秒值readOnly
: 是否只读;true 只读,MyBatis 认为所有从缓存中获取数据的操作都是只读操作,不会修改数据。MyBatis 为了加快获取数据,直接就会将数据在缓存中的引用交给用户。不安全,速度快。读写(默认):MyBatis 觉得数据可能会被修改size
: 缓存存放多少个元素type
: 指定自定义缓存的全类名(实现Cache 接口即可)blocking
: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存。
探究二级缓存
我们继续以 MyBatis 一级缓存文章中的例子为基础,搭建一个满足二级缓存的例子,来对二级缓存进行探究,例子如下(对 一级缓存的例子部分源码进行修改):
Dept.java
//存放在共享缓存中数据进行序列化操作和反序列化操作
//因此数据对应实体类必须实现【序列化接口】
public class Dept implements Serializable {
private Integer deptNo;
private String dname;
private String loc;
public Dept() {}
public Dept(Integer deptNo, String dname, String loc) {
this.deptNo = deptNo;
this.dname = dname;
this.loc = loc;
}
get and set...
@Override
public String toString() {
return "Dept{" +
"deptNo=" + deptNo +
", dname='" + dname + '\'' +
", loc='" + loc + '\'' +
'}';
}
}
myBatis-config.xml
在myBatis-config 中添加开启二级缓存的条件
<!-- 通知 MyBatis 框架开启二级缓存 -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
DeptDao.xml
还需要在 Mapper 对应的xml中添加 cache 标签,表示对哪个mapper 开启缓存
<!-- 表示DEPT表查询结果保存到二级缓存(共享缓存) -->
<cache/>
对应的二级缓存测试类如下:
public class MyBatisSecondCacheTest {
private SqlSession sqlSession;
SqlSessionFactory factory;
@Before
public void start() throws IOException {
InputStream is = Resources.getResourceAsStream("myBatis-config.xml");
SqlSessionFactoryBuilder builderObj = new SqlSessionFactoryBuilder();
factory = builderObj.build(is);
sqlSession = factory.openSession();
}
@After
public void destory(){
if(sqlSession!=null){
sqlSession.close();
}
}
@Test
public void testSecondCache(){
//会话过程中第一次发送请求,从数据库中得到结果
//得到结果之后,mybatis自动将这个查询结果放入到当前用户的一级缓存
DeptDao dao = sqlSession.getMapper(DeptDao.class);
Dept dept = dao.findByDeptNo(1);
System.out.println("第一次查询得到部门对象 = "+dept);
//触发MyBatis框架从当前一级缓存中将Dept对象保存到二级缓存
sqlSession.commit();
// 改成 sqlSession.close(); 效果相同
SqlSession session2 = factory.openSession();
DeptDao dao2 = session2.getMapper(DeptDao.class);
Dept dept2 = dao2.findByDeptNo(1);
System.out.println("第二次查询得到部门对象 = "+dept2);
}
}
测试二级缓存效果,提交事务,
sqlSession
查询完数据后,sqlSession2
相同的查询是否会从缓存中获取数据。
测试结果如下:
通过结果可以得知,首次执行的SQL语句是从数据库中查询得到的结果,然后第一个 SqlSession 执行提交,第二个 SqlSession 执行相同的查询后是从缓存中查取的。
用一下这幅图能够比较直观的反映两次 SqlSession 的缓存命中
二级缓存失效的条件
与一级缓存一样,二级缓存也会存在失效的条件的,下面我们就来探究一下哪些情况会造成二级缓存失效
第一次SqlSession 未提交
SqlSession 在未提交的时候,SQL 语句产生的查询结果还没有放入二级缓存中,这个时候 SqlSession2 在查询的时候是感受不到二级缓存的存在的,修改对应的测试类,结果如下:
@Test
public void testSqlSessionUnCommit(){
//会话过程中第一次发送请求,从数据库中得到结果
//得到结果之后,mybatis自动将这个查询结果放入到当前用户的一级缓存
DeptDao dao = sqlSession.getMapper(DeptDao.class);
Dept dept = dao.findByDeptNo(1);
System.out.println("第一次查询得到部门对象 = "+dept);
//触发MyBatis框架从当前一级缓存中将Dept对象保存到二级缓存
SqlSession session2 = factory.openSession();
DeptDao dao2 = session2.getMapper(DeptDao.class);
Dept dept2 = dao2.findByDeptNo(1);
System.out.println("第二次查询得到部门对象 = "+dept2);
}
产生的输出结果:
更新对二级缓存影响
与一级缓存一样,更新操作很可能对二级缓存造成影响,下面用三个 SqlSession来进行模拟,第一个 SqlSession 只是单纯的提交,第二个 SqlSession 用于检验二级缓存所产生的影响,第三个 SqlSession 用于执行更新操作,测试如下:
@Test
public void testSqlSessionUpdate(){
SqlSession sqlSession = factory.openSession();
SqlSession sqlSession2 = factory.openSession();
SqlSession sqlSession3 = factory.openSession();
// 第一个 SqlSession 执行更新操作
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
Dept dept = deptDao.findByDeptNo(1);
System.out.println("dept = " + dept);
sqlSession.commit();
// 判断第二个 SqlSession 是否从缓存中读取
DeptDao deptDao2 = sqlSession2.getMapper(DeptDao.class);
Dept dept2 = deptDao2.findByDeptNo(1);
System.out.println("dept2 = " + dept2);
// 第三个 SqlSession 执行更新操作
DeptDao deptDao3 = sqlSession3.getMapper(DeptDao.class);
deptDao3.updateDept(new Dept(1,"ali","hz"));
sqlSession3.commit();
// 判断第二个 SqlSession 是否从缓存中读取
dept2 = deptDao2.findByDeptNo(1);
System.out.println("dept2 = " + dept2);
}
对应的输出结果如下
探究多表操作对二级缓存的影响
现有这样一个场景,有两个表,部门表dept(deptNo,dname,loc)和 部门数量表deptNum(id,name,num),其中部门表的名称和部门数量表的名称相同,通过名称能够联查两个表可以知道其坐标(loc)和数量(num),现在我要对部门数量表的 num 进行更新,然后我再次关联dept 和 deptNum 进行查询,你认为这个 SQL 语句能够查询到的 num 的数量是多少?来看一下代码探究一下
DeptNum.java
public class DeptNum {
private int id;
private String name;
private int num;
get and set...
}
DeptVo.java
public class DeptVo {
private Integer deptNo;
private String dname;
private String loc;
private Integer num;
public DeptVo(Integer deptNo, String dname, String loc, Integer num) {
this.deptNo = deptNo;
this.dname = dname;
this.loc = loc;
this.num = num;
}
public DeptVo(String dname, Integer num) {
this.dname = dname;
this.num = num;
}
get and set
@Override
public String toString() {
return "DeptVo{" +
"deptNo=" + deptNo +
", dname='" + dname + '\'' +
", loc='" + loc + '\'' +
", num=" + num +
'}';
}
}
DeptDao.java
public interface DeptDao {
...
DeptVo selectByDeptVo(String name);
DeptVo selectByDeptVoName(String name);
int updateDeptVoNum(DeptVo deptVo);
}
DeptDao.xml
<select id="selectByDeptVo" resultType="com.mybatis.beans.DeptVo">
select d.deptno,d.dname,d.loc,dn.num from dept d,deptNum dn where dn.name = d.dname
and d.dname = #{name}
</select>
<select id="selectByDeptVoName" resultType="com.mybatis.beans.DeptVo">
select * from deptNum where name = #{name}
</select>
<update id="updateDeptVoNum" parameterType="com.mybatis.beans.DeptVo">
update deptNum set num = #{num} where name = #{dname}
</update>
DeptNum 数据库初始值:
测试类对应如下:
/
* 探究多表操作对二级缓存的影响
*/
@Test
public void testOtherMapper(){
// 第一个mapper 先执行联查操作
SqlSession sqlSession = factory.openSession();
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
DeptVo deptVo = deptDao.selectByDeptVo("ali");
System.out.println("deptVo = " + deptVo);
// 第二个mapper 执行更新操作 并提交
SqlSession sqlSession2 = factory.openSession();
DeptDao deptDao2 = sqlSession2.getMapper(DeptDao.class);
deptDao2.updateDeptVoNum(new DeptVo("ali",1000));
sqlSession2.commit();
sqlSession2.close();
// 第一个mapper 再次进行查询,观察查询结果
deptVo = deptDao.selectByDeptVo("ali");
System.out.println("deptVo = " + deptVo);
}
测试结果如下:
在对DeptNum 表执行了一次更新后,再次进行联查,发现数据库中查询出的还是 num 为 1050 的值,也就是说,实际上 1050 -> 1000 ,最后一次联查实际上查询的是第一次查询结果的缓存,而不是从数据库中查询得到的值,这样就读到了脏数据。
**解决办法
如果是两个mapper命名空间的话,可以使用 <cache-ref>
来把一个命名空间指向另外一个命名空间,从而消除上述的影响,再次执行,就可以查询到正确的数据
二级缓存源码解析
源码模块主要分为两个部分:二级缓存的创建和二级缓存的使用,首先先对二级缓存的创建进行分析:
二级缓存的创建
二级缓存的创建是使用 Resource 读取 XML 配置文件开始的
InputStream is = Resources.getResourceAsStream("myBatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
factory = builder.build(is);
读取配置文件后,需要对XML创建 Configuration并初始化
XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
return build(parser.parse());
调用 parser.parse()
解析根目录 /configuration 下面的标签,依次进行解析
public Configuration parse() {
if (parsed) {
throw new BuilderException("Each XMLConfigBuilder can only be used once.");
}
parsed = true;
parseConfiguration(parser.evalNode("/configuration"));
return configuration;
}
private void parseConfiguration(XNode root) {
try {
//issue #117 read properties first
propertiesElement(root.evalNode("properties"));
Properties settings = settingsAsProperties(root.evalNode("settings"));
loadCustomVfs(settings);
typeAliasesElement(root.evalNode("typeAliases"));
pluginElement(root.evalNode("plugins"));
objectFactoryElement(root.evalNode("objectFactory"));
objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
reflectorFactoryElement(root.evalNode("reflectorFactory"));
settingsElement(settings);
// read it after objectFactory and objectWrapperFactory issue #631
environmentsElement(root.evalNode("environments"));
databaseIdProviderElement(root.evalNode("databaseIdProvider"));
typeHandlerElement(root.evalNode("typeHandlers"));
mapperElement(root.evalNode("mappers"));
} catch (Exception e) {
throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
}
}
其中有一个二级缓存的解析就是
mapperElement(root.evalNode("mappers"));
然后进去 mapperElement 方法中
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
mapperParser.parse();
继续跟 mapperParser.parse() 方法
public void parse() {
if (!configuration.isResourceLoaded(resource)) {
configurationElement(parser.evalNode("/mapper"));
configuration.addLoadedResource(resource);
bindMapperForNamespace();
}
parsePendingResultMaps();
parsePendingCacheRefs();
parsePendingStatements();
}
这其中有一个 configurationElement 方法,它是对二级缓存进行创建,如下
private void configurationElement(XNode context) {
try {
String namespace = context.getStringAttribute("namespace");
if (namespace == null || namespace.equals("")) {
throw new BuilderException("Mapper's namespace cannot be empty");
}
builderAssistant.setCurrentNamespace(namespace);
cacheRefElement(context.evalNode("cache-ref"));
cacheElement(context.evalNode("cache"));
parameterMapElement(context.evalNodes("/mapper/parameterMap"));
resultMapElements(context.evalNodes("/mapper/resultMap"));
sqlElement(context.evalNodes("/mapper/sql"));
buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
} catch (Exception e) {
throw new BuilderException("Error parsing Mapper XML. Cause: " + e, e);
}
}
有两个二级缓存的关键点
cacheRefElement(context.evalNode("cache-ref"));
cacheElement(context.evalNode("cache"));
也就是说,mybatis 首先进行解析的是 cache-ref
标签,其次进行解析的是 cache
标签。
根据上面我们的 — 多表操作对二级缓存的影响 一节中提到的解决办法,采用 cache-ref 来进行命名空间的依赖能够避免二级缓存,但是总不能每次写一个 XML 配置都会采用这种方式吧,最有效的方式还是避免多表操作使用二级缓存
然后我们再来看一下cacheElement(context.evalNode("cache")) 这个方法
private void cacheElement(XNode context) throws Exception {
if (context != null) {
String type = context.getStringAttribute("type", "PERPETUAL");
Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
String eviction = context.getStringAttribute("eviction", "LRU");
Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
Long flushInterval = context.getLongAttribute("flushInterval");
Integer size = context.getIntAttribute("size");
boolean readWrite = !context.getBooleanAttribute("readOnly", false);
boolean blocking = context.getBooleanAttribute("blocking", false);
Properties props = context.getChildrenAsProperties();
builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
}
}
认真看一下其中的属性的解析,是不是感觉很熟悉?这不就是对 cache 标签属性的解析吗?!!!
上述最后一句代码
builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
public Cache useNewCache(Class<? extends Cache> typeClass,
Class<? extends Cache> evictionClass,
Long flushInterval,
Integer size,
boolean readWrite,
boolean blocking,
Properties props) {
Cache cache = new CacheBuilder(currentNamespace)
.implementation(valueOrDefault(typeClass, PerpetualCache.class))
.addDecorator(valueOrDefault(evictionClass, LruCache.class))
.clearInterval(flushInterval)
.size(size)
.readWrite(readWrite)
.blocking(blocking)
.properties(props)
.build();
configuration.addCache(cache);
currentCache = cache;
return cache;
}
这段代码使用了构建器模式,一步一步构建Cache 标签的所有属性,最终把 cache 返回。
二级缓存的使用
在 mybatis 中,使用 Cache 的地方在 CachingExecutor
中,来看一下 CachingExecutor 中缓存做了什么工作,我们以查询为例
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
// 得到缓存
Cache cache = ms.getCache();
if (cache != null) {
// 如果需要的话刷新缓存
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, parameterObject, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
// 委托模式,交给SimpleExecutor等实现类去实现方法。
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
其中,先从 MapperStatement 取出缓存。只有通过<cache/>,<cache-ref/>
或@CacheNamespace,@CacheNamespaceRef
标记使用缓存的Mapper.xml或Mapper接口(同一个namespace,不能同时使用)才会有二级缓存。
如果缓存不为空,说明是存在缓存。如果cache存在,那么会根据sql配置(<insert>,<select>,<update>,<delete>
的flushCache
属性来确定是否清空缓存。
flushCacheIfRequired(ms);
然后根据xml配置的属性useCache
来判断是否使用缓存(resultHandler一般使用的默认值,很少会null)。
if (ms.isUseCache() && resultHandler == null)
确保方法没有Out类型的参数,mybatis不支持存储过程的缓存,所以如果是存储过程,这里就会报错。
private void ensureNoOutParams(MappedStatement ms, Object parameter, BoundSql boundSql) {
if (ms.getStatementType() == StatementType.CALLABLE) {
for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) {
if (parameterMapping.getMode() != ParameterMode.IN) {
throw new ExecutorException("Caching stored procedures with OUT params is not supported. Please configure useCache=false in " + ms.getId() + " statement.");
}
}
}
}
然后根据在 TransactionalCacheManager
中根据 key 取出缓存,如果没有缓存,就会执行查询,并且将查询结果放到缓存中并返回取出结果,否则就执行真正的查询方法。
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
是否应该使用二级缓存?
那么究竟应该不应该使用二级缓存呢?先来看一下二级缓存的注意事项:
- 缓存是以
namespace
为单位的,不同namespace
下的操作互不影响。 - insert,update,delete操作会清空所在
namespace
下的全部缓存。 - 通常使用MyBatis Generator生成的代码中,都是各个表独立的,每个表都有自己的
namespace
。 - 多表操作一定不要使用二级缓存,因为多表操作进行更新操作,一定会产生脏数据。
如果你遵守二级缓存的注意事项,那么你就可以使用二级缓存。
但是,如果不能使用多表操作,二级缓存不就可以用一级缓存来替换掉吗?而且二级缓存是表级缓存,开销大,没有一级缓存直接使用 HashMap 来存储的效率更高,所以二级缓存并不推荐使用。
MySQL入门大全
SQL 基础使用
MySQL 是一种关系型数据库,说到关系,那么就离不开表与表之间的关系,而最能体现这种关系的其实就是我们接下来需要介绍的主角 SQL
,SQL 的全称是 Structure Query Language
,结构化的查询语言,它是一种针对表关联关系所设计的一门语言,也就是说,学好 MySQL,SQL 是基础和重中之重。SQL 不只是 MySQL 中特有的一门语言,大多数关系型数据库都支持这门语言。
下面我们就来一起学习一下这门非常重要的语言。
查询语言分类
在了解 SQL 之前我们需要知道下面这几个概念
-
数据定义语言: 简称
DDL
(Data Definition Language),用来定义数据库对象:数据库、表、列等; -
数据操作语言: 简称
DML
(Data Manipulation Language),用来对数据库中表的记录进行更新。关键字: insert、update、delete等 -
数据控制语言: 简称
DCL
(Data Control Language),用来定义数据库访问权限和安全级别,创建用户等。关键字: grant等 -
数据查询语言: 简称
DQL
(Data Query Language),用来查询数据库中表的记录,关键字: select from where等
DDL 语句
创建数据库
下面就开始我们的 SQL 语句学习之旅,首先你需要启动 MySQL 服务,我这里是 mac 电脑,所以我直接可以启动
然后我们使用命令行的方式连接数据库,打开 iterm
,输入下面
MacBook:~ mr.l$ mysql -uroot -p
就可以连接到数据库了
在上面命令中,mysql
代表客户端命令,- u
表示后面需要连接的用户,-p
表示需要输入此用户的密码。在你输入用户名和密码后,如果成功登陆,会显示一个欢迎界面(如上图 )和 mysql>
提示符。
欢迎界面主要描述了这些东西
- 每一行的结束符,这里用
;
或者\g
来表示每一行的结束 - Your MySQL connection id is 4,这个记录了 MySQL 服务到目前为止的连接数,每个新链接都会自动增加 1 ,上面显示的连接次数是 4 ,说明我们只连接了四次
- 然后下面是 MySQL 的版本,我们使用的是 5.7
- 通过
help
或者\h
命令来显示帮助内容,通过\c
命令来清除命令行 buffer。
然后需要做的事情是什么?我们最终想要学习 SQL 语句,SQL 语句肯定是要查询数据,通过数据来体现出来表的关联关系,所以我们需要数据,那么数据存在哪里呢?数据存储的位置被称为 表(table)
,表存储的位置被称为 数据库(database)
,所以我们需要先建数据库后面再建表然后插入数据,再进行查询。
所以我们首先要做的就是创建数据库,创建数据库可以直接使用指令
CREATE DATABASE dbname;
进行创建,比如我们创建数据库 cxuandb
create database cxuandb;
注意最后的 ;
结束语法一定不要丢掉,否则 MySQL 会认为你的命令没有输出完,敲 enter 后会直接换行输出
创建完成后,会提示 Query OK, 1 row affected,这段语句什么意思呢? Query OK 表示的就是查询完成,为什么会显示这个?因为所有的 DDL 和 DML 操作执行完成后都会提示这个, 也可以理解为操作成功。后面跟着的 1 row affected 表示的是影响的行数,()
内显示的是你执行这条命令所耗费的时间,也就是 0.03 秒。
上图我们成功创建了一个 cxuandb 的数据库,此时我们还想创建一个数据库,我们再执行相同的指令,结果提示
提示我们不能再创建数据库了,数据库已经存在。这时候我就有疑问了,我怎么知道都有哪些数据库呢?别我再想创建一个数据库又告诉我已经存在,这时候可以使用 show databases
命令来查看你的 MySQL 已有的数据库
show databases;
执行完成后的结果如下
因为数据库我之前已经使用过,这里就需要解释一下,除了刚刚新创建成功的 cxuandb 外,informationn_schema
、performannce_schema
和 sys
都是系统自带的数据库,是安装 MySQL 默认创建的数据库。它们各自表示
- informationn_schema: 主要存储一些数据库对象信息,比如用户表信息、权限信息、分区信息等
- performannce_schema: MySQL 5.5 之后新增加的数据库,主要用于收集数据库服务器性能参数。
- sys: MySQL 5.7 提供的数据库,sys 数据库里面包含了一系列的存储过程、自定义函数以及视图来帮助我们快速的了解系统的元数据信息。
其他所有的数据库都是作者自己创建的,可以忽略他们。
在创建完数据库之后,可以用如下命令选择要操作的数据库
use cxuandb
这样就成功切换为了 cxuandb 数据库,我们可以在此数据库下进行建表、查看基本信息等操作。
比如想要看康康我们新建的数据库里面有没有其他表
show tables;
果然,我们新建的数据库下面没有任何表,但是现在,我们还不进行建表操作,我们还是先来认识一下数据库层面的命令,也就是其他 DDL 指令
删除数据库
如果一个数据库我们不想要了,那么该怎么办呢?直接删掉数据库不就好了吗?删表语句是
drop database dbname;
比如 cxuandb 我们不想要他了,可以通过使用
drop database cxuandb;
进行删除,这里我们就不进行演示了,因为 cxuandb 我们后面还会使用。
但是这里注意一点,你删除数据库成功后会出现 0 rows affected,这个可以不用理会,因为在 MySQL 中,drop 语句操作的结果都是 0 rows affected。
创建表
下面我们就可以对表进行操作了,我们刚刚 show tables 发现还没有任何表,所以我们现在进行建表语句
CREATE TABLE 表名称
(
列名称1 数据类型 约束,
列名称2 数据类型 约束,
列名称3 数据类型 约束,
....
)
这样就很清楚了吧,列名称就是列的名字,紧跟着列名后面就是数据类型,然后是约束,为什么要这么设计?举个例子你就清楚了,比如 cxuan 刚被生出来就被打印上了标签
比如我们创建一个表,里面有 5 个字段,姓名(name)、性别(sex)、年龄(age)、何时雇佣(hiredate)、薪资待遇(wage),建表语句如下
create table job(name varchar(20), sex varchar(2), age int(2), hiredate date, wage decimal(10,2));
事实证明这条建表语句还是没问题的,建表完成后可以使用 DESC tablename
查看表的基本信息
DESC
命令会查看表的定义,但是输出的信息还不够全面,所以,如果想要查看更全的信息,还要通过查看表的创建语句的 SQL 来得到
show create table job \G;
可以看到,除了看到表定义之外,还看到了表的 engine(存储引擎)
为 InnoDB 存储引擎,\G
使得记录能够竖着排列,如果不用 \G
的话,效果如下
删除表
表的删除语句有两种,一种是 drop
语句,SQL 语句如下
drop table job
一种是 truncate
语句,SQL 语句如下
truncate table job
这两者的区别简单理解就是 drop 语句删除表之后,可以通过日志进行回复,而 truncate 删除表之后永远恢复不了,所以,一般不使用 truncate 进行表的删除。‘
修改表
对于已经创建好的表,尤其是有大量数据的表,如果需要对表做结构上的改变,可以将表删除然后重新创建表,但是这种效率会产生一些额外的工作,数据会重新加载近来,如果此时有服务正在访问的话,也会影响服务读取表中数据,所以此时,我们需要表的修改语句来对已经创建好的表的定义进行修改。
修改表结构一般使用 alter table
语句,下面是常用的命令
ALTER TABLE tb MODIFY [COLUMN] column_definition [FIRST | AFTER col_name];
比如我们想要将 job 表中的 name 由 varchar(20)
改为 varchar(25)
,可以使用如下语句
alter table job modify name varchar(25);
也可以对表结构进行修改,比如增加一个字段
alter table job add home varchar(30);
将新添加的表的字段进行删除
alter table job drop column home;
可以对表中字段的名称进行修改,比如吧 wage 改为 salary
alter table job change wage salary decimal(10,2);
修改字段的排列顺序,我们前面介绍过修改语法涉及到一个顺序问题,都有一个可选项 first | after column_name,这个选项可以用来修改表中字段的位置,默认 ADD 是在添加为表中最后一个字段,而 CHANGE/MODIFY 不会改变字段位置。比如
alter table job add birthday after hiredate;
可以对表名进行修改,例如将 job 表改为 worker
alter table job rename worker;
DML 语句
有的地方把 DML 语句(增删改)和 DQL 语句(查询)统称为 DML 语句,有的地方分开,我们目前使用分开称呼的方式
插入
表创建好之后,我们就可以向表里插入数据了,插入记录的基本语法如下
INSERT INTO tablename (field1,field2) VALUES(value1,value2);
例如,向中插入以下记录
insert into job(name,sex,age,hiredate,birthday,salary) values("cxuan","男",24,"2020-04-27","1995-08-22",8000);
也可以不用指定要插入的字段,直接插入数据即可
insert into job values("cxuan02","男",25,"2020-06-01","1995-04-23",12000);
这里就有一个问题,如果插入的顺序不一致的话会怎么样呢?
对于含可空字段、非空但是含有默认值的字段、自增字段可以不用在 insert 后的字段列表出现,values 后面只需要写对应字段名称的 value 即可,没有写的字段可以自动的设置为 NULL、默认值或者自增的下一个值,这样可以缩短要插入 SQL 语句的长度和复杂性。
比如我们设置一下 hiredate、age 可以为 null,来试一下
insert into job(name,sex,birthday,salary) values("cxuan03","男","1992-08-23",15000);
我们看一下实际插入的数据
我们可以看到有一行两个字段显示 NULL。在 MySQL 中,insert 语句还有一个很好的特性,就是一次可以插入多条记录
INSERT INTO tablename (field1,field2) VALUES
(value1,value2),
(value1,value2),
(value1,value2),
...;
可以看出,每条记录之间都用逗号进行分割,这个特性可以使得 MySQL 在插入大量记录时,节省很多的网络开销,大大提高插入效率。
更新记录
对于表中已经存在的数据,可以通过 update 命令对其进行修改,语法如下
UPDATE tablename SET field1 = value1, field2 = value2 ;
例如,将 job 表中的 cxuan03 中 age 的 NULL 改为 26,SQL 语句如下
update job set age = 26 where name = 'cxuan03';
SQL 语句中出现了一个 where 条件,我们会在后面说到 where 条件,这里简单理解一下它的概念就是根据哪条记录进行更新,如果不写 where 的话,会对整个表进行更新
删除记录
如果记录不再需要,可以使用 delete 命令进行删除
DELETE FROM tablename [WHERE CONDITION]
例如,在 job 中删除名字是 cxuan03 的记录
delete from job where name = 'cxuan03';
在 MySQL 中,删除语句也可以不指定 where 条件,直接使用
delete from job
这种删除方式相当于是清楚表的操作,表中所有的记录都会被清除。
DQL 语句
下面我们一起来认识一下 DQL 语句,数据被插入到 MySQL 中,就可以使用 SELECT
命令进行查询,来得到我们想要的结果。
SELECT 查询语句可以说是最复杂的语句了,这里我们只介绍一下基本语法
一种最简单的方式就是从某个表中查询出所有的字段和数据,简单粗暴,直接使用 SELECT *
SELECT * FROM tablename;
例如我们将 job 表中的所有数据查出来
select * from job;
其中 * 是查询出所有的数据,当然,你也可以查询出指定的数据项
select name,sex,age,hiredate,birthday,salary from job;
上面这条 SQL 语句和 select * from job
表是等价的,但是这种直接查询指定字段的 SQL 语句效率要高。
上面我们介绍了基本的 SQL 查询语句,但是实际的使用场景会会比简单查询复杂太多,一般都会使用各种 SQL 的函数和查询条件等,下面我们就来一起认识一下。
去重
使用非常广泛的场景之一就是 去重
,去重可以使用 distinct
关键字来实现
为了演示效果,我们先向数据库中插入批量数据,插入完成后的表结构如下
下面我们使用 distinct 来对 age 去重来看一下效果
你会发现只有两个不同的值,其他和 25 重复的值被过滤掉了,所以我们使用 distinct 来进行去重
条件查询
我们之前的所有例子都是查询全部的记录,如果我们只想查询指定的记录呢?这里就会用到 where
条件查询语句,条件查询可以对指定的字段进行查询,比如我们想查询所有年龄为 24 的记录,如下
select * from job where age = 24;
where 条件语句后面会跟一个判断的运算符 =
,除了 =
号比较外,还可以使用 >、<、>=、<=、!= 等比较运算符;例如
select * from job where age >= 24;
就会从 job 表中查询出 age 年龄大于或等于 24 的记录
除此之外,在 where 条件查询中还可以有多个并列的查询条件,比如我们可以查询年龄大于等于 24,并且薪资大雨 8000 的记录
select * from job where age >= 24 and salary > 8000;
多个条件之间还可以使用 or、and 等逻辑运算符进行多条件联合查询,运算符会在以后章节中详细讲解。
排序
我们会经常有这样的需求,按照某个字段进行排序,这就用到了数据库的排序功能,使用关键字 order by
来实现,语法如下
SELECT * FROM tablename [WHERE CONDITION] [ORDER BY field1 [DESC|ASC] , field2 [DESC|ASC],……fieldn [DESC|ASC]]
其中 DESC 和 ASC 就是顺序排序的关键字,DESC 会按照字段进行降序排列,ASC 会按照字段进行升序排列,默认会使用升序排列,也就是说,你不写 order by
具体的排序的话,默认会使用升序排列。order by 后面可以跟多个排序字段,并且每个排序字段可以有不同的排序顺序。
为了演示功能,我们先把表中的 salary
工资列进行修改,修改完成后的表记录如下
下面我们按照工资进行排序,SQL 语句如下
select * from job order by salary desc;
语句执行完成后的结果如下
这是对一个字段进行排序的结果,也可以对多个字段进行排序,但是需要注意一点
根据 order by 后面声名的顺序进行排序,如果有三个排序字段 A、B、C 的话,如果 A 字段排序字段的值一样,则会根据第二个字段进行排序,以此类推。
如果只有一个排序字段,那么这些字段相同的记录将会无序排列。
限制
对于排序后的字段,或者不排序的字段,如果只希望显示一部分的话,就会使用 LIMIT
关键字来实现,比如我们只想取前三条记录
select * from job limit 3;
或者我们对排序后的字段取前三条记录
select * from job order by salary limit 3;
上面这种 limit 是从表记录的第 0 条开始取,如果从指定记录开始取,比如从第二条开始取,取三条记录,SQL 如下
select * from job order by salary desc limit 2,3;
limit 一般经常和 order by 语法一起实现分页查询。
注意:limit 是 MySQL 扩展 SQL92 之后的语法,在其他数据库比如 Oracle 上就不通用,我犯过一个白痴的行为就是在 Oracle 中使用 limit 查询语句。。。
聚合
下面我们来看一下对记录进行汇总的操作,这类操作主要有
汇总函数
,比如 sum 求和、count 统计数量、max 最大值、min 最小值等group by
,关键字表示对分类聚合的字段进行分组,比如按照部门统计员工的数量,那么 group by 后面就应该跟上部门with
是可选的语法,它表示对汇总之后的记录进行再次汇总having
关键字表示对分类后的结果再进行条件的过滤。
看起来 where 和 having 意思差不多,不过它们用法不一样,where 是使用在统计之前,对统计前的记录进行过滤,having 是用在统计之后,是对聚合之后的结果进行过滤。也就是说 where 永远用在 having 之前,我们应该先对筛选的记录进行过滤,然后再对分组的记录进行过滤。
可以对 job 表中员工薪水进行统计,选出总共的薪水、最大薪水、最小薪水
select sum(salary) from job;
select max(salary),min(salary) from job;
比如我们要统计 job 表中人员的数量
select count(1) from job;
统计完成后的结果如下
我们可以按照 job 表中的年龄来进行对应的统计
select age,count(1) from job group by age;
既要统计各年龄段的人数,又要统计总人数
select age,count(1) from job group by age with rollup;
在此基础上进行分组,统计数量大于 1 的记录
select age,count(1) from job group by age with rollup having count(1) > 1;
表连接
表连接一直是笔者比较痛苦的地方,曾经因为一个表连接挂了面试,现在来认真撸一遍。
表连接一般体现在表之间的关系上。当需要同时显示多个表中的字段时,就可以用表连接来实现。
为了演示表连接的功能,我们为 job 表加一个 type
字段表示工作类型,增加一个 job_type 表表示具体的工作种类,如下所示
下面开始我们的演示
查询出 job 表中的 type 和 job_type 表中的 type 匹配的姓名和工作类型
select job.name,job_type.name from job,job_type where job.type = job_type.type;
上面这种连接使用的是内连接,除此之外,还有外连接。那么它们之间的区别是啥呢?
内连接:选出两张表中互相匹配的记录;
外连接:不仅选出匹配的记录,也会选出不匹配的记录;
外连接分为两种
- 左外连接:筛选出包含左表的记录并且右表没有和它匹配的记录
- 右外连接:筛选出包含右表的记录甚至左表没有和它匹配的记录
为了演示效果我们在 job 表和 job_type 表中分别添加记录,添加完成后的两表如下
下面我们进行左外连接查询:查询出 job 表中的 type 和 job_type 表中的 type 匹配的姓名和工作类型
select job.name,job_type.name from job left join job_type on job.type = job_type.type;
查询出来的结果如下
可以看出 cxuan06 也被查询出来了,而 cxuan06 他没有具体的工作类型。
使用右外连接查询
select job.name,job_type.name from job right join job_type on job.type = job_type.type;
可以看出,job 表中并没有 waiter 和 manager 的角色,但是也被查询出来了。
子查询
有一些情况,我们需要的查询条件是另一个 SQL 语句的查询结果,这种查询方式就是子查询,子查询有一些关键字比如 in、not in、=、!=、exists、not exists 等,例如我们可以通过子查询查询出每个人的工作类型
select job.* from job where type in (select type from job_type);
如果自查询数量唯一的话,还可以用 =
来替换 in
select * from job where type = (select type from job_type);
意思是自查询不唯一,我们使用 limit 限制一下返回的记录数
select * from job where type = (select type from job_type limit 1,1);
在某些情况下,子查询可以转换为表连接
联合查询
我们还经常会遇到这样的场景,将两个表的数据单独查询出来之后,将结果合并到一起进行显示,这个时候就需要 UNION 和 UNION ALL 这两个关键字来实现这样的功能,UNION 和 UNION ALL 的主要区别是 UNION ALL 是把结果集直接合并在一起,而 UNION 是将 UNION ALL 后的结果进行一次 DISTINCT
去除掉重复数据。
比如
select type from job union all select type from job_type;
它的结果如下
上述结果是查询 job 表中的 type 字段和 job_type 表中的 type 字段,并把它们进行汇总,可以看出 UNION ALL 只是把所有的结果都列出来了
使用 UNION 的 SQL 语句如下
select type from job union select type from job_type;
可以看出 UNION 是对 UNION ALL 使用了 distinct
去重处理。
DCL 语句
DCL 语句主要是管理数据库权限的时候使用,这类操作一般是 DBA 使用的,开发人员不会使用 DCL 语句。
关于帮助文档的使用
我们一般使用 MySQL 遇到不会的或者有疑问的东西经常要去查阅网上资料,甚至可能需要去查 MySQL 官发文档,这样会耗费大量的时间和精力。
下面教你一下在 MySQL 命令行就能直接查询资料的语句
按照层次查询
可以使用 ? contents
来查询所有可供查询的分类,如下所示
? contents;
我们输入
? Account Management
可以查询具体关于权限管理的命令
比如我们想了解一下数据类型
? Data Types
然后我们想了解一下 VARCHAR
的基本定义,可以直接使用
? VARCHAR
可以看到有关于 VARCHAR 数据类型的详细信息,然后在最下面还有 MySQL 的官方文档,方便我们快速查阅。
快速查阅
在实际应用过程中,如果要快速查询某个语法时,可以使用关键字进行快速查询,比如我们使用
? show
能够快速列出一些命令
比如我们想要查阅 database 的信息,使用
SHOW CREATE DATABASE cxuandb;
MySQL 数据类型
MySQL 提供很多种数据类型来对不同的常量、变量进行区分,MySQL 中的数据类型主要是 数值类型、日期和时间类型、字符串类型 选择合适的数据类型进行数据的存储非常重要,在实际开发过程中,选择合适的数据类型也能够提高 SQL 性能,所以有必要认识一下这些数据类型。
数值类型
MySQL 支持所有标准的 SQL 数据类型,这些数据类型包括严格数据类型的严格数值类型
,这些数据类型有
- INTEGER
- SMALLINT
- DECIMAL
- NUMERIC。
近似数值数据类型
并不用严格按照指定的数据类型进行存储,这些有
- FLOAT
- REAL
- DOUBLE PRECISION
还有经过扩展之后的数据类型,它们是
- TINYINT
- MEDIUMINT
- BIGINT
- BIT
其中 INT 是 INTEGER 的缩写,DEC 是 DECIMAL 的缩写。
下面是所有数据类型的汇总
整数
在整数类型中,按照取值范围和存储方式的不同,分为
- TINYINT ,占用 1 字节
- SMALLINT,占用 2 字节
- MEDIUMINT,占用 3 字节
- INT、INTEGER,占用 4 字节
- BIGINT,占用 8 字节
五个数据类型,如果超出类型范围的操作,会发生错误提示,所以选择合适的数据类型非常重要。
还记得我们上面的建表语句么
我们一般会在 SQL 语句的数据类型后面加上指定长度来表示数据类型许可的范围,例如
int(7)
表示 int 类型的数据最大长度为 7,如果填充不满的话会自动填满,如果不指定 int 数据类型的长度的话,默认是 int(11)
。
我们创建一张表来演示一下
create table test1(aId int, bId int(5));
/* 然后我们查看一下表结构 */
desc test1;
整数类型一般配合 zerofill
来使用,顾名思义,就是用 0 进行填充,也就是数字位数不够的空间使用 0 进行填充。
分别修改 test1 表中的两个字段
alter table test1 modify aId int zerofill;
alter table test1 modify bId int(5) zerofill;
然后插入两条数据,执行查询操作
如上图所示,使用zerofill
可以在数字前面使用 0
来进行填充,那么如果宽度超过指定长度后会如何显示?我们来试验一下,向 aId 和 bId 分别插入超过字符限制的数字
会发现 aId 已经超出了指定范围,那么我们对 aId 插入一个在其允许范围之内的数据
会发现,aId 已经插进去了,bId 也插进去了,为什么 bId 显示的是 int(5) 却能够插入 7 位长度的数值呢?
所有的整数都有一个可选属性 UNSIGNED(无符号)
,如果需要在字段里面保存非负数或者是需要较大上限值时,可以使用此选项,它的取值范围是正常值的下限取 0 ,上限取原值的 2 倍。如果一个列为 zerofill ,会自动为该列添加 UNSIGNED 属性。
除此之外,整数还有一个类型就是 AUTO_INCREMENT
,在需要产生唯一标识符或者顺序值时,可利用此属性,这个属性只用于整数字符。一个表中最多只有一个 AUTO_INCREMENT 属性,一般用于自增主键
,而且 NOT NULL
,并且是 PRIMARY KEY
和 UNIQUE
的,主键必须保证唯一性而且不为空。
小数
小数说的是啥?它其实有两种类型;一种是浮点数
类型,一种是定点数
类型;
浮点数有两种
- 单精度浮点型 – float 型
- 双精度浮点型 – double 型
定点数只有一种 decimal
。定点数在 MySQL 内部中以字符串的形式存在,比浮点数更为准确,适合用来表示精度特别高的数据。
浮点数和定点数都可以使用 (M,D)
的方式来表示,M 表示的就是 整数位 + 小数位 的数字,D 表示位于 .
后面的小数。M 也被称为精度 ,D 被称为标度。
下面通过示例来演示一下
首先建立一个 test2
表
CREATE TABLE test2 (aId float(6,2) default NULL, bId double(6,2) default NULL,cId decimal(6,2) default NULL)
然后向表中插入几条数据
insert into test2 values(1234.12,1234.12,1234.12);
这个时候显示的数据就是
然后再向表中插入一些约束之外的数据
insert into test2 values(1234.123,1234.123,1234.123);
发现插入完成后还显示的是 1234.12
,小数位第三位的值被舍去了。
现在我们把 test2 表中的精度全部去掉,再次插入
alter table test2 modify aId float;
alter table test2 modify bId double;
alter table test2 modify cId decimal;
先查询一下,发现 cId 舍去了小数位。
然后再次插入 1.23,SQL 语句如下
insert into test2 values(1.23,1.23,1.23);
结果如下
这个时候可以验证
- 浮点数如果不写精度和标度,会按照实际的精度值进行显示
- 定点数如果不写精度和标度,会按照
decimal(10,0)
来进行操作,如果数据超过了精度和标题,MySQL 会报错
位类型
对于位类型,用于存放字段值,BIT(M)
可以用来存放多位二进制数,M 的范围是 1 – 64,如果不写的话默认为 1 位。
下面我们来掩饰一下位类型
新建一个 test3 表,表中只有一个位类型的字段
create table test3(id bit(1));
然后随意插入一条数据
insert into test3 values(1);
发现无法查询出对应结果。
然后我们使用 hex()
和 bin()
函数进行查询
发现能够查询出对应结果。
也就是说当数据插入 test3 时,会首先把数据转换成为二进制数,如果位数允许,则将成功插入;如果位数小于实际定义的位数,则插入失败。如果我们像表中插入数据 2
insert into test3 values(2);
那么会报错
因为 2 的二进制数表示是 10
,而表中定义的是 bit(1)
,所以无法插入。
那么我们将表字段修改一下
然后再进行插入,发现已经能够插入了
日期时间类型
MySQL 中的日期与时间类型,主要包括:YEAR、TIME、DATE、DATETIME、TIMESTAMP,每个版本可能不同。下表中列出了这几种类型的属性。
下面分别来介绍一下
YEAR
YEAR 可以使用三种方式来表示
- 用 4 位的数字或者字符串表示,两者效果相同,表示范围 1901 – 2155,插入超出范围的数据会报错。
- 以 2 位字符串格式表示,范围为 ‘00’~‘99’。‘00’~‘69’ 表示 2000~2069,‘70’~‘99’ 表示1970~1999。‘0’ 和 ‘00’ 都会被识别为 2000,超出范围的数据也会被识别为 2000。
- 以 2 位数字格式表示,范围为 1~99。1~69 表示 2001~2069, 70~99 表示 1970~1999。但 0 值会被识别为0000,这和 2 位字符串被识别为 2000 有所不同
下面我们来演示一下 YEAR 的用法,创建一个 test4 表
create table test4(id year);
然后我们看一下 test4 的表结构
默认创建的 year 就是 4 位,下面我们向 test4 中插入数据
insert into test4 values(2020),('2020');
然后进行查询,发现表示形式是一样的
使用两位字符串来表示
delete from test4;
insert into test4 values ('0'),('00'),('11'),('88'),('20'),('21');
使用两位数字来表示
delete from test4;
insert into test4 values (0),(00),(11),(88),(20),(21);
发现只有前两项不一样。
TIME
TIME 所表示的范围和我们预想的不一样
我们把 test4 改为 TIME 类型,下面是 TIME 的示例
alter table test4 modify id TIME;
insert into test4 values ('15:11:23'),('20:13'),('2 11:11'),('3 05'),('33');
结果如下
DATE
DATE 表示的类型有很多种,下面是 DATE 的几个示例
create table test5 (id date);
查看一下 test5 表
然后插入部分数据
insert into test5 values ('2020-06-13'),('20200613'),(20200613);
DATE 的表示一般很多种,如下所示 DATE 的所有形式
- ‘YYYY-MM-DD’
- ‘YYYYMMDD’
- YYYYMMDD
- ‘YY-MM-DD’
- ‘YYMMDD’
- YYMMDD
DATETIME
DATETIME 类型,包含日期和时间部分,可以使用引用字符串或者数字,年份可以是 4 位也可以是 2 位。
下面是 DATETIME 的示例
create table test6 (id datetime);
insert into test4 values ('2020-06-13 11:11:11'),(20200613111111),('20200613111111'),(20200613080808);
TIMESTAMP
TIMESTAMP 类型和 DATETIME 类型的格式相同,存储 4 个字节(比DATETIME少),取值范围比 DATETIME 小。
下面来说一下各个时间类型的使用场景
-
一般表示
年月日
,通常用DATE
类型; -
用来表示
时分秒
,通常用TIME
表示; -
年月日时分秒
,通常用DATETIME
来表示; -
如果需要插入的是当前时间,通常使用
TIMESTAMP
来表示,TIMESTAMP 值返回后显示为YYYY-MM-DD HH:MM:SS
格式的字符串, -
如果只表示年份、则应该使用 YEAR,它比 DATE 类型需要更小的空间。
每种日期类型都有一个范围,如果超出这个范围,在默认的 SQLMode
下,系统会提示错误,并进行零值存储。
下面来解释一下 SQLMode
是什么
MySQL 中有一个环境变量是 sql_mode ,sql_mode 支持了 MySQL 的语法、数据校验,我们可以通过下面这种方式来查看当前数据库使用的 sql_mode
select @@sql_mode;
一共有下面这几种模式
来源于 https://www.cnblogs.com/Zender/p/8270833.html
字符串类型
MySQL 提供了很多种字符串类型,下面是字符串类型的汇总
下面我们对这些数据类型做一个详细的介绍
CHAR 和 VARCHAR 类型
CHAR 和 VARCHAR 类型很相似,导致很多同学都会忽略他们之间的差别,首先他俩都是用来保存字符串的数据类型,他俩的主要区别在于存储方式不同。CHAR 类型的长度就是你定义多少显示多少。占用 M 字节,比如你声明一个 CHAR(20)
的字符串类型,那么每个字符串占用 20 字节,M 的取值范围时 0 – 255。VARCHAR
是可变长的字符串,范围是 0 – 65535,在字符串检索的时候,CHAR 会去掉尾部的空格,而 VARCHAR 会保留这些空格。下面是演示例子
create table vctest1 (vc varchar(6),ch char(6));
insert into vctest1 values("abc ","abc ");
select length(vc),length(ch) from vctest1;
结果如下
可以看到 vc 的字符串类型是 varchar ,长度是 5,ch 的字符串类型是 char,长度是 3。可以得出结论,varchar 会保留最后的空格,char 会去掉最后的空格。
BINARY 和 VARBINARY 类型
BINARY 和 VARBINARY 与 CHAR 和 VARCHAR 非常类似,不同的是它们包含二进制字符串而不包含非二进制字符串。BINARY 与 VARBINARY 的最大长度和 CHAR 与 VARCHAR 是一样的,只不过他们是定义字节长度,而 CHAR 和 VARCHAR 对应的是字符长度。
BLOB 类型
BLOB 是一个二进制大对象,可以容纳可变数量的数据。有 4 种 BLOB 类型:TINYBLOB、BLOB、MEDIUMBLOB 和 LONGBLOB。它们区别在于可容纳存储范围不同。
TEXT 类型
有 4 种 TEXT 类型:TINYTEXT、TEXT、MEDIUMTEXT 和 LONGTEXT。对应的这 4 种 BLOB 类型,可存储的最大长度不同,可根据实际情况选择。
ENUM 类型
ENUM 我们在 Java 中经常会用到,它表示的是枚举类型。它的范围需要在创建表时显示指定,对 1 – 255 的枚举需要 1 个字节存储;对于 255 – 65535 的枚举需要 2 个字节存储。ENUM 会忽略大小写,在存储时都会转换为大写。
SET 类型
SET 类型和 ENUM 类型有两处不同
- 存储方式
SET 对于每 0 – 8 个成员,分别占用 1 个字节,最大到 64 ,占用 8 个字节
- Set 和 ENUM 除了存储之外,最主要的区别在于 Set 类型一次可以选取多个成员,而 ENUM 则只能选一个。
MySQL 运算符
MySQL 中有多种运算符,下面对 MySQL 运算符进行分类
- 算术运算符
- 比较运算符
- 逻辑运算符
- 位运算符
下面那我们对各个运算符进行介绍
算术运算符
MySQL 支持的算术运算符包括加、减、乘、除和取余,这类运算符的使用频率比较高
下面是运算符的分类
运算符 | 作用 |
---|---|
+ | 加法 |
– | 减法 |
* | 乘法 |
/, DIV | 除法,返回商 |
%, MOD | 除法,返回余数 |
下面简单描述了这些运算符的使用方法
+
用于获得一个或多个值的和-
用于从一个值减去另一个值*
用于两数相乘,得到两个或多个值的乘积/
用一个值除以另一个值得到商%
用于一个值除以另一个值得到余数
在除法和取余需要注意一点,如果除数是 0 ,将是非法除数,返回结果为 NULL。
比较运算符
熟悉了运算符,下面来聊一聊比较运算符,使用 SELECT 语句进行查询时,MySQL 允许用户对表达式的两侧的操作数进行比较,比较结果为真,返回 1, 比较结果为假,返回 0 ,比较结果不确定返回 NULL。下面是所有的比较运算符
运算符 | 描述 |
---|---|
= | 等于 |
<> 或者是 != | 不等于 |
<=> | NULL 安全的等于,也就是 NULL-safe |
< | 小于 |
<= | 小于等于 |
> | 大于 |
>= | 大于等于 |
BETWEEN | 在指定范围内 |
IS NULL | 是否为 NULL |
IS NOT NULL | 是否为 NULL |
IN | 存在于指定集合 |
LIKE | 通配符匹配 |
REGEXP 或 RLIKE | 正则表达式匹配 |
比较运算符可以用来比较数字、字符串或者表达式。数字作为浮点数进行比较,字符串以不区分大小写的方式进行比较。
- = 号运算符,用于比较运算符两侧的操作数是否相等,如果相等则返回 1, 如果不相等则返回 0 ,下面是具体的示例,NULL 不能用于比较,会直接返回 NULL
<>
号用于表示不等于,和=
号相反,示例如下
<=>
NULL-safe 的等于运算符,与 = 号最大的区别在于可以比较 NULL 值
<
号运算符,当左侧操作数小于右侧操作数时,返回值为 1, 否则其返回值为 0。
- 和上面同理,只不过是满足 <= 的时候返回 1 ,否则 > 返回 0。这里我有个疑问,为什么
select 'a' <= 'b'; /* 返回 1 */
/*而*/
select 'a' >= 'b'; /* 返回 0 呢*/
-
关于
>
和>=
是同理 -
BETWEEN
运算符的使用格式是 a BETWEEN min AND max ,当 a 大于等于 min 并且小于等于 max 时,返回 1,否则返回 0 。操作数类型不同的时候,会转换成相同的数据类型再进行处理。比如
IS NULL
和IS NOT NULL
表示的是是否为 NULL,ISNULL 为 true 返回 1,否则返回 0 ;IS NOT NULL 同理
IN
这个比较操作符判断某个值是否在一个集合中,使用方式是 xxx in (value1,value2,value3)
LIKE
运算符的格式是xxx LIKE %123%
,比如如下
当 like 后面跟的是 123%
的时候, xxx 如果是 123 则返回 1,如果是 123xxx 也返回 1,如果是 12 或者 1 就返回 0 。123 是一个整体。
REGEX
运算符的格式是s REGEXP str
,匹配时返回值为 1,否则返回 0 。
后面会详细介绍 regexp 的用法。
逻辑运算符
逻辑运算符指的就是布尔运算符
,布尔运算符指返回真和假。MySQL 支持四种逻辑运算符
运算符 | 作用 |
---|---|
NOT 或 ! | 逻辑非 |
AND 或者是 && | 逻辑与 |
OR 或者是 || | 逻辑或 |
XOR | 逻辑异或 |
下面分别来介绍一下
NOT
或者是!
表示的是逻辑非,当操作数为 0(假) ,则返回值为 1,否则值为 0。但是有一点除外,那就是 NOT NULL 的返回值为 NULL
AND
和&&
表示的是逻辑与的逻辑,当所有操作数为非零值并且不为 NULL 时,结果为 1,但凡是有一个 0 则返回 0,操作数中有一个 null 则返回 null
OR
和||
表示的是逻辑或,当两个操作数均为非 NULL 值时,如有任意一个操作数为非零值,则结果为 1,否则结果为 0。
XOR
表示逻辑异或,当任意一个操作数为 NULL 时,返回值为 NULL。对于非 NULL 的操作数,如果两个的逻辑真假值相异,则返回结果 1;否则返回 0。
位运算符
一听说位运算,就知道是和二进制有关的运算符了,位运算就是将给定的操作数转换为二进制后,对各个操作数的每一位都进行指定的逻辑运算,得到的二进制结果转换为十进制后就说是位运算的结果,下面是所有的位运算。
运算符 | 作用 |
---|---|
& | 位与 |
| | 位或 |
^ | 位异或 |
~ | 位取反 |
>> | 位右移 |
<< | 位左移 |
下面分别来演示一下这些例子
位与
指的就是按位与,把 & 双方转换为二进制再进行 & 操作
按位与是一个数值减小的操作
位或
指的就是按位或,把 | 双方转换为二进制再进行 | 操作
位或是一个数值增大的操作
位异或
指的就是对操作数的二进制位做异或操作
位取反
指的就是对操作数的二进制位做NOT
操作,这里的操作数只能是一位,下面看一个经典的取反例子:对 1 做位取反,具体如下所示:
为什么会有这种现象,因为在 MySQL 中,常量数字默认会以 8 个字节来显示,8 个字节就是 64 位,常量 1 的二进制表示 63 个 0
,加 1 个 1
, 位取反后就是 63 个 1
加一个 0
, 转换为二进制后就是 18446744073709551614,我们可以使用 select bin() 查看一下
位右移
是对左操作数向右移动指定位数,例如 50 >> 3,就是对 50 取其二进制然后向右移三位,左边补上 0 ,转换结果如下
位左移
与位右移相反,是对左操作数向左移动指定位数,例如 20 << 2
MySQL 常用函数
下面我们来了解一下 MySQL 函数,MySQL 函数也是我们日常开发过程中经常使用的,选用合适的函数能够提高我们的开发效率,下面我们就来一起认识一下这些函数
字符串函数
字符串函数是最常用的一种函数了,MySQL 也是支持很多种字符串函数,下面是 MySQL 支持的字符串函数表
函数 | 功能 |
---|---|
LOWER | 将字符串所有字符变为小写 |
UPPER | 将字符串所有字符变为大写 |
CONCAT | 进行字符串拼接 |
LEFT | 返回字符串最左边的字符 |
RIGHT | 返回字符串最右边的字符 |
INSERT | 字符串替换 |
LTRIM | 去掉字符串左边的空格 |
RTRIM | 去掉字符串右边的空格 |
REPEAT | 返回重复的结果 |
TRIM | 去掉字符串行尾和行头的空格 |
SUBSTRING | 返回指定的字符串 |
LPAD | 用字符串对最左边进行填充 |
RPAD | 用字符串对最右边进行填充 |
STRCMP | 比较字符串 s1 和 s2 |
REPLACE | 进行字符串替换 |
下面通过具体的示例演示一下每个函数的用法
- LOWER(str) 和 UPPER(str) 函数:用于转换大小写
- CONCAT(s1,s2 … sn) :把传入的参数拼接成一个字符串
上面把 c xu an
拼接成为了一个字符串,另外需要注意一点,任何和 NULL 进行字符串拼接的结果都是 NULL。
- LEFT(str,x) 和 RIGHT(str,x) 函数:分别返回字符串最左边的 x 个字符和最右边的 x 个字符。如果第二个参数是 NULL,那么将不会返回任何字符串
- INSERT(str,x,y,instr) : 将字符串 str 从指定 x 的位置开始, 取 y 个长度的字串替换为 instr。
- LTRIM(str) 和 RTRIM(str) 分别表示去掉字符串 str 左侧和右侧的空格
- REPEAT(str,x) 函数:返回 str 重复 x 次的结果
- TRIM(str) 函数:用于去掉目标字符串的空格
- SUBSTRING(str,x,y) 函数:返回从字符串 str 中第 x 位置起 y 个字符长度的字符串
- LPAD(str,n,pad) 和 RPAD(str,n,pad) 函数:用字符串 pad 对 str 左边和右边进行填充,直到长度为 n 个字符长度
- STRCMP(s1,s2) 用于比较字符串 s1 和 s2 的 ASCII 值大小。如果 s1 < s2,则返回 -1;如果 s1 = s2 ,返回 0 ;如果 s1 > s2 ,返回 1。
- REPLACE(str,a,b) : 用字符串 b 替换字符串 str 种所有出现的字符串 a
数值函数
MySQL 支持数值函数,这些函数能够处理很多数值运算。下面我们一起来学习一下 MySQL 中的数值函数,下面是所有的数值函数
函数 | 功能 |
---|---|
ABS | 返回绝对值 |
CEIL | 返回大于某个值的最大整数值 |
MOD | 返回模 |
ROUND | 四舍五入 |
FLOOR | 返回小于某个值的最大整数值 |
TRUNCATE | 返回数字截断小数的结果 |
RAND | 返回 0 – 1 的随机值 |
下面我们还是以实践为主来聊一聊这些用法
- ABS(x) 函数:返回 x 的绝对值
- CEIL(x) 函数: 返回大于 x 的整数
- MOD(x,y),对 x 和 y 进行取模操作
- ROUND(x,y) 返回 x 四舍五入后保留 y 位小数的值;如果是整数,那么 y 位就是 0 ;如果不指定 y ,那么 y 默认也是 0 。
- FLOOR(x) : 返回小于 x 的最大整数,用法与 CEIL 相反
- TRUNCATE(x,y): 返回数字 x 截断为 y 位小数的结果, TRUNCATE 知识截断,并不是四舍五入。
- RAND() :返回 0 到 1 的随机值
日期和时间函数
日期和时间函数也是 MySQL 中非常重要的一部分,下面我们就来一起认识一下这些函数
函数 | 功能 |
---|---|
NOW | 返回当前的日期和时间 |
WEEK | 返回一年中的第几周 |
YEAR | 返回日期的年份 |
HOUR | 返回小时值 |
MINUTE | 返回分钟值 |
MONTHNAME | 返回月份名 |
CURDATE | 返回当前日期 |
CURTIME | 返回当前时间 |
UNIX_TIMESTAMP | 返回日期 UNIX 时间戳 |
DATE_FORMAT | 返回按照字符串格式化的日期 |
FROM_UNIXTIME | 返回 UNIX 时间戳的日期值 |
DATE_ADD | 返回日期时间 + 上一个时间间隔 |
DATEDIFF | 返回起始时间和结束时间之间的天数 |
下面结合示例来讲解一下每个函数的使用
- NOW(): 返回当前的日期和时间
- WEEK(DATE) 和 YEAR(DATE) :前者返回的是一年中的第几周,后者返回的是给定日期的哪一年
- HOUR(time) 和 MINUTE(time) : 返回给定时间的小时,后者返回给定时间的分钟
- MONTHNAME(date) 函数:返回 date 的英文月份
- CURDATE() 函数:返回当前日期,只包含年月日
- CURTIME() 函数:返回当前时间,只包含时分秒
- UNIX_TIMESTAMP(date) : 返回 UNIX 的时间戳
- FROM_UNIXTIME(date) : 返回 UNIXTIME 时间戳的日期值,和 UNIX_TIMESTAMP 相反
- DATE_FORMAT(date,fmt) 函数:按照字符串 fmt 对 date 进行格式化,格式化后按照指定日期格式显示
具体的日期格式可以参考这篇文章 https://blog.csdn.net/weixin_38703170/article/details/82177837
我们演示一下将当前日期显示为年月日的这种形式,使用的日期格式是 %M %D %Y。
- DATE_ADD(date, interval, expr type) 函数:返回与所给日期 date 相差 interval 时间段的日期
interval 表示间隔类型的关键字,expr 是表达式,这个表达式对应后面的类型,type 是间隔类型,MySQL 提供了 13 种时间间隔类型
表达式类型 | 描述 | 格式 |
---|---|---|
YEAR | 年 | YY |
MONTH | 月 | MM |
DAY | 日 | DD |
HOUR | 小时 | hh |
MINUTE | 分 | mm |
SECOND | 秒 | ss |
YEAR_MONTH | 年和月 | YY-MM |
DAY_HOUR | 日和小时 | DD hh |
DAY_MINUTE | 日和分钟 | DD hh : mm |
DAY_SECOND | 日和秒 | DD hh :mm :ss |
HOUR_MINUTE | 小时和分 | hh:mm |
HOUR_SECOND | 小时和秒 | hh:ss |
MINUTE_SECOND | 分钟和秒 | mm:ss |
- DATE_DIFF(date1, date2) 用来计算两个日期之间相差的天数
查看离 2021 – 01 – 01 还有多少天
流程函数
流程函数也是很常用的一类函数,用户可以使用这类函数在 SQL 中实现条件选择。这样做能够提高查询效率。下表列出了这些流程函数
函数 | 功能 |
---|---|
IF(value,t f) | 如果 value 是真,返回 t;否则返回 f |
IFNULL(value1,value2) | 如果 value1 不为 NULL,返回 value1,否则返回 value2。 |
CASE WHEN[value1] THEN[result1] …ELSE[default] END | 如果 value1 是真,返回 result1,否则返回 default |
CASE[expr] WHEN[value1] THEN [result1]… ELSE[default] END | 如果 expr 等于 value1, 返回 result1, 否则返回 default |
其他函数
除了我们介绍过的字符串函数、日期和时间函数、流程函数,还有一些函数并不属于上面三类函数,它们是
函数 | 功能 |
---|---|
VERSION | 返回当前数据库的版本 |
DATABASE | 返回当前数据库名 |
USER | 返回当前登陆用户名 |
PASSWORD | 返回字符串的加密版本 |
MD5 | 返回 MD5 值 |
INET_ATON(IP) | 返回 IP 地址的数字表示 |
INET_NTOA(num) | 返回数字代表的 IP 地址 |
下面来看一下具体的使用
- VERSION: 返回当前数据库版本
- DATABASE: 返回当前的数据库名
- USER : 返回当前登录用户名
- PASSWORD(str) : 返回字符串的加密版本,例如
- MD5(str) 函数:返回字符串 str 的 MD5 值
- INET_ATON(IP): 返回 IP 的网络字节序列
- INET_NTOA(num)函数:返回网络字节序列代表的 IP 地址,与 INET_ATON 相对
MySQL优化
一般传统互联网公司很少接触到 SQL 优化问题,其原因是数据量小,大部分厂商的数据库性能能够满足日常的业务需求,所以不需要进行 SQL 优化,但是随着应用程序的不断变大,数据量的激增,数据库自身的性能跟不上了,此时就需要从 SQL 自身角度来进行优化,这也是我们这篇文章所讨论的。
SQL 优化步骤
当面对一个需要优化的 SQL 时,我们有哪几种排查思路呢?
通过 show status 命令了解 SQL 执行次数
首先,我们可以使用 show status 命令查看服务器状态信息。show status 命令会显示每个服务器变量 variable_name 和 value,状态变量是只读的。如果使用 SQL 命令,可以使用 like 或者 where 条件来限制结果。like 可以对变量名做标准模式匹配。
图我没有截全,下面还有很多变量,读者可以自己尝试一下。也可以在操作系统上使用 mysqladmin extended-status 命令来获取这些消息。
但是我执行 mysqladmin extended-status 后,出现这个错误。
应该是我没有输入密码的原因,使用 mysqladmin -P3306 -uroot -p -h127.0.0.1 -r -i 1 extended-status 后,问题解决。
这里需要注意一下 show status 命令中可以添加统计结果的级别,这个级别有两个
- session 级: 默认当前链接的统计结果
- global 级:自数据库上次启动到现在的统计结果
如果不指定统计结果级别的话,默认使用 session 级别。
对于 show status 查询出来的统计结果,有两类参数需要注意下,一类是以 Com_
为开头的参数,一类是以 Innodb_
为开头的参数。
下面是 Com_ 为开头的参数,参数很多,我同样没有截全。
Com_xxx 表示的是每个 xxx 语句执行的次数,我们通常关心的是 select 、insert 、update、delete 语句的执行次数,即
- Com_select:执行 select 操作的次数,一次查询会使结果 + 1。
- Com_insert:执行 INSERT 操作的次数,对于批量插入的 INSERT 操作,只累加一次。
- Com_update:执行 UPDATE 操作的次数。
- Com_delete:执行 DELETE 操作的次数。
以 Innodb_ 为开头的参数主要有
- Innodb_rows_read:执行 select 查询返回的行数。
- Innodb_rows_inserted:执行 INSERT 操作插入的行数。
- Innodb_rows_updated:执行 UPDATE 操作更新的行数。
- Innodb_rows_deleted:执行 DELETE 操作删除的行数。
通过上面这些参数执行结果的统计,我们能够大致了解到当前数据库是以更新(包括插入、删除)为主还是查询为主。
除此之外,还有一些其他参数用于了解数据库的基本情况。
- Connections:查询 MySQL 数据库的连接次数,这个次数是不管连接是否成功都算上。
- Uptime:服务器的工作时间。
- Slow_queries:满查询次数。
- Threads_connected:查看当前打开的连接的数量。
下面这个博客汇总了几乎所有 show status 的参数,可以当作参考手册。
https://blog.csdn.net/ayay_870621/article/details/88633092
定位执行效率较低的 SQL
定位执行效率比较慢的 SQL 语句,一般有两种方式
- 可以通过慢查询日志来定位哪些执行效率较低的 SQL 语句。
MySQL 中提供了一个慢查询的日志记录功能,可以把查询 SQL 语句时间大于多少秒的语句写入慢查询日志,日常维护中可以通过慢查询日志的记录信息快速准确地判断问题所在。用 –log-slow-queries 选项启动时,mysqld 会写一个包含所有执行时间超过 long_query_time 秒的 SQL 语句的日志文件,通过查看这个日志文件定位效率较低的 SQL 。
比如我们可以在 my.cnf 中添加如下代码,然后退出重启 MySQL。
log-slow-queries = /tmp/mysql-slow.log
long_query_time = 2
通常我们设置最长的查询时间是 2 秒,表示查询时间超过 2 秒就记录了,通常情况下 2 秒就够了,然而对于很多 WEB 应用来说,2 秒时间还是比较长的。
也可以通过命令来开启:
我们先查询 MySQL 慢查询日志是否开启
show variables like "%slow%";
启用慢查询日志
set global slow_query_log='ON';
然后再次查询慢查询是否开启
如图所示,我们已经开启了慢查询日志。
慢查询日志会在查询结束以后才记录,所以在应用反应执行效率出现问题的时候慢查询日志并不能定位问题,此时应该使用 show processlist 命令查看当前 MySQL 正在进行的线程。包括线程的状态、是否锁表等,可以实时的查看 SQL 执行情况。同样,使用mysqladmin processlist语句也能得到此信息。
下面就来解释一下各个字段对应的概念
- Id :Id 就是一个标示,在我们使用 kill 命令杀死进程的时候很有用,比如 kill 进程号。
- User:显示当前的用户,如果不是 root,这个命令就只显示你权限范围内的 SQL 语句。
- Host:显示 IP ,用于追踪问题
- Db:显示这个进程目前连接的是哪个数据库,为 null 是还没有 select 数据库。
- Command:显示当前连接锁执行的命令,一般有三种:查询 query,休眠 sleep,连接 connect。
- Time:这个状态持续的时间,单位是秒
- State:显示当前 SQL 语句的状态,非常重要,下面会具体解释。
- Info:显示这个 SQL 语句。
State 列非常重要,关于这个列的内容比较多,读者可以参考一下这篇文章
https://blog.csdn.net/weixin_34357436/article/details/91768402
这里面涉及线程的状态、是否锁表等选项,可以实时的查看 SQL 的执行情况,同时对一些锁表进行优化。
通过 EXPLAIN 命令分析 SQL 的执行计划
通过以上步骤查询到效率低的 SQL 语句后,可以通过 EXPLAIN 或者 DESC 命令获取 MySQL 如何执行 SELECT 语句的信息,包括在 SELECT 语句执行过程中表如何连接和连接的顺序。
比如我们使用下面这条 SQL 语句来分析一下执行计划
explain select * from test1;
上表中涉及内容如下
- select_type:表示常见的 SELECT 类型,常见的有 SIMPLE,SIMPLE 表示的是简单的 SQL 语句,不包括 UNION 或者子查询操作,比如下面这段就是 SIMPLE 类型。
PRIMARY ,查询中最外层的 SELECT(如两表做 UNION 或者存在子查询的外层的表操作为 PRIMARY,内层的操作为 UNION),比如下面这段子查询。
UNION,在 UNION 操作中,查询中处于内层的 SELECT(内层的 SELECT 语句与外层的 SELECT 语句没有依赖关系时)。
SUBQUERY:子查询中首个SELECT(如果有多个子查询存在),如我们上面的查询语句,子查询第一个是 sr(sys_role)表,所以它的 select_type 是 SUBQUERY。
-
table ,这个选项表示输出结果集的表。
-
type,这个选项表示表的连接类型,这个选项很有深入研究的价值,因为很多 SQL 的调优都是围绕 type 来讲的,但是这篇文章我们主要围绕优化方式来展开的,type 这个字段我们暂时作为了解,这篇文章不过多深入。
type 这个字段会牵扯到连接的性能,它的不同类型的性能由好到差分别是
system :表中仅有一条数据时,该表的查询就像查询常量表一样。
const :当表中只有一条记录匹配时,比如使用了表主键(primary key)或者表唯一索引(unique index)进行查询。
eq-ref :表示多表连接时使用表主键或者表唯一索引,比如
select A.text, B.text where A.ID = B.ID
这个查询语句,对于 A 表中的每一个 ID 行,B 表中都只能有唯一的 B.Id 来进行匹配时。
ref :这个类型不如上面的 eq-ref 快,因为它表示的是因为对于表 A 中扫描的每一行,表 C 中有几个可能的行,C.ID 不是唯一的。
ref_or_null :与 ref 类似,只不过这个选项包含对 NULL 的查询。
index_merge :查询语句使用了两个以上的索引,比如经常在有 and 和 or 关键字出现的场景,但是在由于读取索引过多导致其性能有可能还不如 range(后面说)。
unique_subquery :这个选项经常用在 in 关键字后面,子查询带有 where 关键字的子查询中,用 sql 来表示就是这样
value IN (SELECT primary_key FROM single_table WHERE some_expr)
range :索引范围查询,常见于使用 =,<>,>,>=,<,<=,IS NULL,<=>,BETWEEN,IN() 或者 like 等运算符的查询中。
index :索引全表扫描,把索引从头到尾扫一遍。
all : 这个我们接触的最多了,就是全表查询,select * from xxx ,性能最差。
上面就是 type 内容的大致解释,关于 type 我们经常会在 SQL 调优的环节使用 explain 分析其类型,然后改进查询方式,越靠近 system 其查询效率越高,越靠近 all 其查询效率越低。
- possible_keys :表示查询时,可能使用的索引。
- key :表示实际使用的索引。
- key_len :索引字段的长度。
- rows :扫描行的数量。
- filtered :通过查询条件查询出来的 SQL 数量占用总行数的比例。
- extra :执行情况的描述。
通过上面的分析,我们可以大致确定 SQL 效率低的原因,一种非常有效的提升 SQL 查询效率的方式就是使用索引,接下来我会讲解一下如何使用索引提高查询效率。
索引
索引是数据库优化中最常用也是最重要的手段,通过使用不同的索引可以解决大多数 SQL 性能问题,也是面试经常会问到的优化方式,围绕着索引,面试官能让你造出火箭来,所以总结一点就是索引非常非常重!要!不只是使用,你还要懂其原!理!
索引介绍
索引的目的就是用于快速查找某一列的数据,对相关数据列使用索引能够大大提高查询操作的性能。不使用索引,MySQL 必须从第一条记录开始读完整个表,直到找出相关的行,表越大查询数据所花费的时间就越多。如果表中查询的列有索引,MySQL 能够快速到达一个位置去搜索数据文件,而不必查看所有数据,那么将会节省很大一部分时间。
索引分类
先来了解一下索引都有哪些分类。
全局索引(FULLTEXT)
:全局索引,目前只有 MyISAM 引擎支持全局索引,它的出现是为了解决针对文本的模糊查询效率较低的问题,并且只限于 CHAR、VARCHAR 和 TEXT 列。哈希索引(HASH)
:哈希索引是 MySQL 中用到的唯一 key-value 键值对的数据结构,很适合作为索引。HASH 索引具有一次定位的好处,不需要像树那样逐个节点查找,但是这种查找适合应用于查找单个键的情况,对于范围查找,HASH 索引的性能就会很低。默认情况下,MEMORY 存储引擎使用 HASH 索引,但也支持 BTREE 索引。B-Tree 索引
:B 就是 Balance 的意思,BTree 是一种平衡树,它有很多变种,最常见的就是 B+ Tree,它被 MySQL 广泛使用。R-Tree 索引
:R-Tree 在 MySQL 很少使用,仅支持 geometry 数据类型,支持该类型的存储引擎只有MyISAM、BDb、InnoDb、NDb、Archive几种,相对于 B-Tree 来说,R-Tree 的优势在于范围查找。
从逻辑上来对 MySQL 进行分类,主要分为下面这几种
-
普通索引:普通索引是最基础的索引类型,它没有任何限制 。创建方式如下
create index normal_index on cxuan003(id);
删除方式
drop index normal_index on cxuan003;
-
唯一索引:唯一索引列的值必须唯一,允许有空值,如果是组合索引,则列值的组合必须唯一,创建方式如下
create unique index normal_index on cxuan003(id);
-
主键索引:是一种特殊的索引,一个表只能有一个主键,不允许有空值。一般是在建表的时候同时创建主键索引。
CREATE TABLE `table` ( `id` int(11) NOT NULL AUTO_INCREMENT , `title` char(255) NOT NULL , PRIMARY KEY (`id`) )
-
组合索引:指多个字段上创建的索引,只有在查询条件中使用了创建索引时的第一个字段,索引才会被使用。使用组合索引时遵循最左前缀原则,下面我们就会创建组合索引。
-
全文索引:主要用来查找文本中的关键字,而不是直接与索引中的值相比较,目前只有 char、varchar,text 列上可以创建全文索引,创建表的适合添加全文索引
CREATE TABLE `table` ( `id` int(11) NOT NULL AUTO_INCREMENT , `title` char(255) CHARACTER NOT NULL , `content` text CHARACTER NULL , `time` int(10) NULL DEFAULT NULL , PRIMARY KEY (`id`), FULLTEXT (content) );
当然也可以直接创建全局索引
CREATE FULLTEXT INDEX index_content ON article(content)
索引使用
索引可以在创建表的时候进行创建,也可以单独创建,下面我们采用单独创建的方式,我们在 cxuan004 上创建前缀索引
我们使用 explain
进行分析,可以看到 cxuan004 使用索引的情况
如果不想使用索引,可以删除索引,索引的删除语法是
索引使用细则
我们在 cxuan005 上根据 id 和 hash 创建一个复合索引,如下所示
create index id_hash_index on cxuan005(id,hash);
然后根据 id 进行执行计划的分析
explain select * from cxuan005 where id = '333';
可以发现,即使 where 条件中使用的不是复合索引(Id 、hash),索引仍然能够使用,这就是索引的前缀特性。但是如果只按照 hash 进行查询的话,索引就不会用到。
explain select * from cxuan005 where hash='8fd1f12575f6b39ee7c6d704eb54b353';
如果 where 条件使用了 like 查询,并且 %
不在第一个字符,索引才可能被使用。
对于复合索引来说,只能使用 id 进行 like 查询,因为 hash 列不管怎么查询都不会走索引。
explain select * from cxuan005 where id like '%1';
可以看到,如果第一个字符是 % ,则没有使用索引。
explain select * from cxuan005 where id like '1%';
如果使用了 % 号,就会触发索引。
如果列名是索引的话,那么对列名进行 NULL 查询,将会触发索引。
explain select * from cxuan005 where id is null;
还有一些情况是存在索引但是 MySQL 并不会使用的情况。
-
最简单的,如果使用索引后比不使用索引的效率还差,那么 MySQL 就不会使用索引。
-
如果 SQL 中使用了 OR 条件,OR 前的条件列有索引,而后面的列没有索引的话,那么涉及到的索引都不会使用,比如 cxuan005 表中,只有 id 和 hash 字段有索引,而 info 字段没有索引,那么我们使用 or 进行查询。
explain select * from cxuan005 where id = 111 and info = 'cxuan';
我们从 explain 的执行结果可以看到,虽然 possible_keys 选项上仍然有 id_hash_index 索引,但是从 key、key_len 可以得知,这条 SQL 语句并未使用索引。
-
在带有复合索引的列上查询不是第一列的数据,也不会使用索引。
explain select * from cxuan005 where hash = '8fd1f12575f6b39ee7c6d704eb54b353';
-
如果 where 条件的列参与了计算,那么也不会使用索引
explain select * from cxuan005 where id + '111' = '666';
-
索引列使用函数,一样也不会使用索引
explain select * from cxuan005 where concat(id,'111') = '666';
-
索引列使用了 like ,并且
%
位于第一个字符,则不会使用索引。 -
在 order by 操作中,排序的列同时也在 where 语句中,将不会使用索引。
-
当数据类型出现隐式转换时,比如 varchar 不加单引号可能转换为 int 类型时,会使索引无效,触发全表扫描。比如下面这两个例子能够显而易见的说明这一点
-
在索引列上使用 IS NOT NULL 操作
-
在索引字段上使用 <>,!=。不等于操作符是永远不会用到索引的,因此对它的处理只会产生全表扫描。
关于设置索引但是索引没有生效的场景还有很多,这个需要小伙伴们工作中不断总结和完善,不过我上面总结的这些索引失效的情景,能够覆盖大多数索引失效的场景了。
查看索引的使用情况
在 MySQL 索引的使用过程中,有一个 Handler_read_key
值,这个值表示了某一行被索引值读的次数。 Handler_read_key 的值比较低的话,则表明增加索引得到的性能改善不是很理想,可能索引使用的频率不高。
还有一个值是 Handler_read_rnd_next
,这个值高则意味着查询运行效率不高,应该建立索引来进行抢救。这个值的含义是在数据文件中读下一行的请求数。如果正在进行大量的表扫描,Handler_read_rnd_next 的值比较高,就说明表索引不正确或写入的查询没有利用索引。
MySQL 分析表、检查表和优化表
对于大多数开发者来说,他们更倾向于解决简单 SQL的优化,而复杂 SQL 的优化交给了公司的 DBA 来做。
下面就从普通程序员的角度和你聊几个简单的优化方式。
MySQL 分析表
分析表用于分析和存储表的关键字分布,分析的结果可以使得系统得到准确的统计信息,使得 SQL 生成正确的执行计划。如果用于感觉实际执行计划与预期不符,可以执行分析表来解决问题,分析表语法如下
analyze table cxuan005;
分析结果涉及到的字段属性如下
Table:表示表的名称;
Op:表示执行的操作,analyze 表示进行分析操作,check 表示进行检查查找,optimize 表示进行优化操作;
Msg_type:表示信息类型,其显示的值通常是状态、警告、错误和信息这四者之一;
Msg_text:显示信息。
对表的定期分析可以改善性能,应该成为日常工作的一部分。因为通过更新表的索引信息对表进行分析,可改善数据库性能。
MySQL 检查表
数据库经常可能遇到错误,比如数据写入磁盘时发生错误,或是索引没有同步更新,或是数据库未关闭 MySQL 就停止了。遇到这些情况,数据就可能发生错误: Incorrect key file for table: ‘ ‘. Try to repair it. 此时,我们可以使用 Check Table 语句来检查表及其对应的索引。
check table cxuan005;
检查表的主要目的就是检查一个或者多个表是否有错误。Check Table 对 MyISAM 和 InnoDB 表有作用。Check Table 也可以检查视图的错误。
MySQL 优化表
MySQL 优化表适用于删除了大量的表数据,或者对包含 VARCHAR、BLOB 或则 TEXT 命令进行大量修改的情况。MySQL 优化表可以将大量的空间碎片进行合并,消除由于删除或者更新造成的空间浪费情况。它的命令如下
optimize table cxuan005;
我的存储引擎是 InnoDB 引擎,但是从图可以知道,InnoDB 不支持使用 optimize 优化,建议使用 recreate + analyze 进行优化。optimize 命令只对 MyISAM 、BDB 表起作用。
常用 SQL 优化
前面我们介绍了使用索引来优化 MySQL ,那么对于 SQL 的各种语法,句法来说,应该怎样优化呢?下面,我会从 SQL 命令的角度来聊一波 SQL 优化。
导入的优化
对于 MyISAM 类型的表,可以通过下面这种方式导入大量的数据
ALTER TABLE tblname DISABLE KEYS;
loading the data
ALTER TABLE tblname ENABLE KEYS;
这两个命令用来打开或者关闭 MyISAM 表非唯一索引的更新。在导入大量的数据到一个非空的 MyISAM 表时,通过设置这两个命令,可以提高导入的效率。对于导入大量数据到一个空的 MyISAM 表,默认就是先导入数据然后才创建索引,所以不用进行设置。
但是对于 InnoDB 搜索引擎的表来说,这样做不能提高导入效率,我们有以下几种方式可以提高导入的效率:
- 因为 InnoDB 类型的表是按照主键的顺序保存的,所以将导入的数据按照主键的顺序排列,可以有效的提高导入数据的效率。如果 InnoDB 表没有主键,那么系统会默认创建一个内部列作为主键,所以如果可以给表创建一个主键,将可以利用这个优势提高导入数据的效率。
- 在导入数据前执行 SET UNIQUE_CHECKS = 0,关闭唯一性校验,在导入结束后执行SETUNIQUE_CHECKS = 1,恢复唯一性校验,可以提高导入的效率。
- 如果应用使用自动提交的方式,建议在导入前执行 SET AUTOCOMMIT = 0,关闭自动提交,导入结束后再执行 SET AUTOCOMMIT = 1,打开自动提交,也可以提高导入的效率。
insert 的优化
当进行插入语句的时候,可以考虑采用下面这几种方式进行优化
- 如果向同一张表插入多条数据的话,最好一次性插入,这样可以减少数据库建立连接 -> 断开连接的时间,如下所示
insert into test values(1,2),(1,3),(1,4)
- 如果向不同的表插入多条数据,可以使用 insert delayed 语句提高执行效率。delayed 的含义是让 insert 语句马上执行,要么数据都会放在内存的队列中,并没有真正写入磁盘。
- 对于 MyISAM 表来说,可以增加 bulk_insert_buffer_size 的值提高插入效率。
- 最好将索引和数据文件在不同的磁盘上存放。
group by 的优化
在使用分组和排序的场景下,如果先进行 Group By 再进行 Order By 的话,可以指定 order by null 禁止排序,因为 order by null 可以避免 filesort
,filesort 往往很耗费时间。如下所示
explain select id,sum(moneys) from sales2 group by id order by null;
order by 的优化
在执行计划中,经常可以看到 Extra
列出现了 filesort,filesort 是一种文件排序,这种排序方式比较慢,我们认为是不好的排序,需要进行优化。
优化的方式是要使用索引。
我们在 cxuan005 上创建一个索引。
create index idx on cxuan005(id);
然后我们使用查询字段和排序相同的顺序进行查询。
explain select id from cxuan005 where id > '111' order by id;
可以看到,在这次查询中,使用的是 Using index
。这表明我们使用的是索引。
如果创建索引和 order by 的顺序不一致,将会使用 Using filesort。
explain select id from cxuan005 where id > '111' order by info;
MySQL 支持两种方式的排序,filesort 和 index,Using index 是指 MySQL 扫描索引本身完成排序。index 效率高,filesort 效率低。
order by 在满足下面这些情况下才会使用 index
- order by 语句使用索引最左前列。
- 使用 where 子句与 order by 子句条件列组合满足索引最左前列。
优化嵌套查询
嵌套查询是我们经常使用的一种查询方式,这种查询方式可以使用 SELECT 语句来创建一个单独的查询结果,然后把这个结果当作嵌套语句的查询范围用在另一个查询语句中。使用时子查询可以将一个复杂的查询拆分成一个个独立的部分,逻辑上更易于理解以及代码的维护和重复使用。
但是某些情况下,子查询的效率不高,一般使用 join
来替代子查询。
使用嵌套查询的 SQL 语句进行 explain 分析如下
explain select c05.id from cxuan005 c05 where id not in (select id from cxuan003);
从 explain 的结果可以看出,主表的查询是 index ,子查询是 index_subquery ,这两个执行效率都不高。我们使用 join 来优化后的分析计划如下。
explain select c05.id from cxuan005 c05 left join cxuan003 c03 on c05.id = c03.id;
从 explain 分析结果可以看到,主表查询和子查询分别是 index 和 ref,而 ref 的执行效率相对较高,一般 type 的效率由高到低是 System–>const–>eq_ref–>ref–> fulltext–>ref_or_null–>index_merge–>unique_subquery–>index_subquery–>range–>index–>all 。
count 的优化
count 我们大家用的太多了,一般都用来统计某一列结果集的行数,当 MySQL 确认括号内的表达式不可能为空时,实际上就是在统计行数。
其实 count 还有另一层统计方式:统计某个列值的数量,在统计列值数量的时候,它默认不会统计 NULL
值。
我们经常犯的一个错误就是,在括号内指定一个列但是却希望统计结果集的行数。如果想要知道结果集行数的话,最好使用 count(*)。
limit 分页的优化
通常我们的系统会进行分页,一般情况下我们会使用 limit 加上偏移量来实现。同时还会加上 order by 语句进行排序。如果使用索引的情况下,效率一般不会有什么问题,如果没有使用索引的话,MySQL 就可能会做大量的文件排序操作。
通常我们可能会遇到比如 limit 1000 , 50 这种情况,抛弃 1000 条,只取 50 条,这样的代价非常高,如果所有页面被访问的频率相同,那么这样的查询平均需要访问半个表的数据。
要优化这种查询,要么限制分页的数量,要么优化大偏移量的性能。
SQL 中 IN 包含的值不应该太多
MySQL 中对 IN 做了相应的优化,MySQL 会将全部的常量存储在一个数组里面,如果数值较多,产生的消耗也会变大,比如
select name from dual where num in(4,5,6)
像这种 SQL 语句的话,能用 between 使用就不要再使用 in 了。
只需要一条数据的情况
如果只需要一条数据的情况下,推荐使用 limit 1
,这样会使执行计划中的 type 变为 const
。
如果没有使用索引,就尽量减少排序
尽量用 union all 来代替 union
union 和 union all 的差异主要是前者需要将结果集合并后再进行唯一性过滤操作,这就会涉及到排序,增加大量的 CPU 运算,加大资源消耗及延迟。当然,union all 的前提条件是两个结果集没有重复数据。
where 条件优化
-
避免在 WHERE 字句中对字段进行 NULL 判断
-
避免在 WHERE 中使用 != 或 <> 操作符
-
不建议使用 % 前缀模糊查询,例如 LIKE “%name”或者LIKE “%name%”,这种查询会导致索引失效而进行全表扫描。但是可以使用LIKE “name%”。
-
避免在 where 中对字段进行表达式操作,比如 select user_id,user_project from table_name where age*2=36 就是一种表达式操作,建议改为** select user_id,user_project from table_name where age=36/2
-
建议在 where 子句中确定 column 的类型,避免 column 字段的类型和传入的参数类型不一致的时候发生的类型转换。
查询时,尽量指定查询的字段名
我们在日常使用 select 查询时,尽量使用 select 字段名 这种方式,避免直接 select* ,这样增加很多不必要的消耗(cpu、io、内存、网络带宽);而且查询效率比较低。
MySQL开发
我们在 MySQL 入门篇主要介绍了基本的 SQL 命令、数据类型和函数,在局部以上知识后,你就可以进行 MySQL 的开发工作了,但是如果要成为一个合格的开发人员,你还要具备一些更高级的技能,下面我们就来探讨一下 MySQL 都需要哪些高级的技能
MySQL 存储引擎
存储引擎概述
数据库最核心的一点就是用来存储数据,数据存储就避免不了和磁盘打交道。那么数据以哪种方式进行存储,如何存储是存储的关键所在。所以存储引擎就相当于是数据存储的发动机,来驱动数据在磁盘层面进行存储。
MySQL 的架构可以按照三层模式来理解
存储引擎也是 MySQL 的组建,它是一种软件,它所能做的和支持的功能主要有
- 并发
- 支持事务
- 完整性约束
- 物理存储
- 支持索引
- 性能帮助
MySQL 默认支持多种存储引擎,来适用不同数据库应用,用户可以根据需要选择合适的存储引擎,下面是 MySQL 支持的存储引擎
- MyISAM
- InnoDB
- BDB
- MEMORY
- MERGE
- EXAMPLE
- NDB Cluster
- ARCHIVE
- CSV
- BLACKHOLE
- FEDERATED
默认情况下,如果创建表不指定存储引擎,会使用默认的存储引擎,如果要修改默认的存储引擎,那么就可以在参数文件中设置 default-table-type
,能够查看当前的存储引擎
show variables like 'table_type';
奇怪,为什么没有了呢?网上求证一下,在 5.5.3 取消了这个参数
可以通过下面两种方法查询当前数据库支持的存储引擎
show engines \g
在创建新表的时候,可以通过增加 ENGINE
关键字设置新建表的存储引擎。
create table cxuan002(id int(10),name varchar(20)) engine = MyISAM;
上图我们指定了 MyISAM
的存储引擎。
如果你不知道表的存储引擎怎么办?你可以通过 show create table
来查看
如果不指定存储引擎的话,从MySQL 5.1 版本之后,MySQL 的默认内置存储引擎已经是 InnoDB了。建一张表看一下
如上图所示,我们没有指定默认的存储引擎,下面查看一下表
可以看到,默认的存储引擎是 InnoDB
。
如果你的存储引擎想要更换,可以使用
alter table cxuan003 engine = myisam;
来更换,更换完成后回显示 0 rows affected ,但其实已经操作成功
我们使用 show create table
查看一下表的 sql 就知道
存储引擎特性
下面会介绍几个常用的存储引擎以及它的基本特性,这些存储引擎是 **MyISAM、InnoDB、MEMORY 和 MERGE
MyISAM
在 5.1 版本之前,MyISAM 是 MySQL 的默认存储引擎,MyISAM 并发性比较差,使用的场景比较少,主要特点是
-
不支持
事务
操作,ACID 的特性也就不存在了,这一设计是为了性能和效率考虑的。 -
不支持
外键
操作,如果强行增加外键,MySQL 不会报错,只不过外键不起作用。 -
MyISAM 默认的锁粒度是
表级锁
,所以并发性能比较差,加锁比较快,锁冲突比较少,不太容易发生死锁的情况。 -
MyISAM 会在磁盘上存储三个文件,文件名和表名相同,扩展名分别是
.frm(存储表定义)
、.MYD(MYData,存储数据)
、MYI(MyIndex,存储索引)
。这里需要特别注意的是 MyISAM 只缓存索引文件
,并不缓存数据文件。 -
MyISAM 支持的索引类型有
全局索引(Full-Text)
、B-Tree 索引
、R-Tree 索引
Full-Text 索引:它的出现是为了解决针对文本的模糊查询效率较低的问题。
B-Tree 索引:所有的索引节点都按照平衡树的数据结构来存储,所有的索引数据节点都在叶节点
R-Tree索引:它的存储方式和 B-Tree 索引有一些区别,主要设计用于存储空间和多维数据的字段做索引,目前的 MySQL 版本仅支持 geometry 类型的字段作索引,相对于 BTREE,RTREE 的优势在于范围查找。
-
数据库所在主机如果宕机,MyISAM 的数据文件容易损坏,而且难以恢复。
-
增删改查性能方面:SELECT 性能较高,适用于查询较多的情况
InnoDB
自从 MySQL 5.1 之后,默认的存储引擎变成了 InnoDB 存储引擎,相对于 MyISAM,InnoDB 存储引擎有了较大的改变,它的主要特点是
- 支持事务操作,具有事务 ACID 隔离特性,默认的隔离级别是
可重复读(repetable-read)
、通过MVCC(并发版本控制)
来实现的。能够解决脏读
和不可重复读
的问题。 - InnoDB 支持外键操作。
- InnoDB 默认的锁粒度
行级锁
,并发性能比较好,会发生死锁的情况。 - 和 MyISAM 一样的是,InnoDB 存储引擎也有
.frm文件存储表结构
定义,但是不同的是,InnoDB 的表数据与索引数据是存储在一起的,都位于 B+ 数的叶子节点上,而 MyISAM 的表数据和索引数据是分开的。 - InnoDB 有安全的日志文件,这个日志文件用于恢复因数据库崩溃或其他情况导致的数据丢失问题,保证数据的一致性。
- InnoDB 和 MyISAM 支持的索引类型相同,但具体实现因为文件结构的不同有很大差异。
- 增删改查性能方面,果执行大量的增删改操作,推荐使用 InnoDB 存储引擎,它在删除操作时是对行删除,不会重建表。
MEMORY
MEMORY 存储引擎使用存在内存中的内容来创建表。每个 MEMORY 表实际只对应一个磁盘文件,格式是 .frm
。 MEMORY 类型的表访问速度很快,因为其数据是存放在内存中。默认使用 HASH 索引
。
MERGE
MERGE 存储引擎是一组 MyISAM 表的组合,MERGE 表本身没有数据,对 MERGE 类型的表进行查询、更新、删除的操作,实际上是对内部的 MyISAM 表进行的。MERGE 表在磁盘上保留两个文件,一个是 .frm
文件存储表定义、一个是 .MRG
文件存储 MERGE 表的组成等。
选择合适的存储引擎
在实际开发过程中,我们往往会根据应用特点选择合适的存储引擎。
- MyISAM:如果应用程序通常以检索为主,只有少量的插入、更新和删除操作,并且对事物的完整性、并发程度不是很高的话,通常建议选择 MyISAM 存储引擎。
- InnoDB:如果使用到外键、需要并发程度较高,数据一致性要求较高,那么通常选择 InnoDB 引擎,一般互联网大厂对并发和数据完整性要求较高,所以一般都使用 InnoDB 存储引擎。
- MEMORY:MEMORY 存储引擎将所有数据保存在内存中,在需要快速定位下能够提供及其迅速的访问。MEMORY 通常用于更新不太频繁的小表,用于快速访问取得结果。
- MERGE:MERGE 的内部是使用 MyISAM 表,MERGE 表的优点在于可以突破对单个 MyISAM 表大小的限制,并且通过将不同的表分布在多个磁盘上, 可以有效地改善 MERGE 表的访问效率。
选择合适的数据类型
我们会经常遇见的一个问题就是,在建表时如何选择合适的数据类型,通常选择合适的数据类型能够提高性能、减少不必要的麻烦,下面我们就来一起探讨一下,如何选择合适的数据类型。
CHAR 和 VARCHAR 的选择
char 和 varchar 是我们经常要用到的两个存储字符串的数据类型,char 一般存储定长的字符串,它属于固定长度的字符类型,比如下面
值 | char(5) | 存储字节 |
---|---|---|
” | ‘ ‘ | 5个字节 |
‘cx’ | ‘cx ‘ | 5个字节 |
‘cxuan’ | ‘cxuan’ | 5个字节 |
‘cxuan007’ | ‘cxuan’ | 5个字节 |
可以看到,不管你的值写的是什么,一旦指定了 char 字符的长度,如果你的字符串长度不够指定字符的长度的话,那么就用空格来填补,如果超过字符串长度的话,只存储指定字符长度的字符。
这里注意一点:如果 MySQL 使用了非
严格模式
的话,上面表格最后一行是可以存储的。如果 MySQL 使用了严格模式
的话,那么表格上面最后一行存储会报错。
如果使用了 varchar 字符类型,我们来看一下例子
值 | varchar(5) | 存储字节 |
---|---|---|
” | ” | 1个字节 |
‘cx’ | ‘cx ‘ | 3个字节 |
‘cxuan’ | ‘cxuan’ | 6个字节 |
‘cxuan007’ | ‘cxuan’ | 6个字节 |
可以看到,如果使用 varchar 的话,那么存储的字节将根据实际的值进行存储。你可能会疑惑为什么 varchar 的长度是 5 ,但是却需要存储 3 个字节或者 6 个字节,这是因为使用 varchar 数据类型进行存储时,默认会在最后增加一个字符串长度,占用1个字节(如果列声明的长度超过255,则使用两个字节)。varchar 不会填充空余的字符串。
一般使用 char 来存储定长的字符串,比如身份证号、手机号、邮箱等;使用 varchar 来存储不定长的字符串。由于 char 长度是固定的,所以它的处理速度要比 VARCHAR 快很多,但是缺点是浪费存储空间,但是随着 MySQL 版本的不断演进,varchar 数据类型的性能也在不断改进和提高,所以在许多应用中,VARCHAR 类型更多的被使用。
在 MySQL 中,不同的存储引擎对 CHAR 和 VARCHAR 的使用原则也有不同
- MyISAM:建议使用固定长度的数据列替代可变长度的数据列,也就是 CHAR
- MEMORY:使用固定长度进行处理、CHAR 和 VARCHAR 都会被当作 CHAR 处理
- InnoDB:建议使用 VARCHAR 类型
TEXT 与 BLOB
一般在保存较少的文本的时候,我们会选择 CHAR 和 VARCHAR,在保存大数据量的文本时,我们往往选择 TEXT 和 BLOB;TEXT 和 BLOB 的主要差别是 BLOB 能够保存二进制数据
;而 TEXT 只能保存字符数据
,TEXT 往下细分有
- TEXT
- MEDIUMTEXT
- LONGTEXT
BLOB 往下细分有
- BLOB
- MEDIUMBLOB
- LONGBLOB
三种,它们最主要的区别就是存储文本长度不同和存储字节不同,用户应该根据实际情况选择满足需求的最小存储类型,下面主要对 BLOB 和 TEXT 存在一些问题进行介绍
TEXT 和 BLOB 在删除数据后会存在一些性能上的问题,为了提高性能,建议使用 OPTIMIZE TABLE
功能对表进行碎片整理。
也可以使用合成索引来提高文本字段(BLOB 和 TEXT)的查询性能。合成索引就是根据大文本(BLOB 和 TEXT)字段的内容建立一个散列值,把这个值存在对应列中,这样就能够根据散列值查找到对应的数据行。一般使用散列算法比如 md5() 和 SHA1() ,如果散列算法生成的字符串带有尾部空格,就不要把它们存在 CHAR 和 VARCHAR 中,下面我们就来看一下这种使用方式
首先创建一张表,表中记录 blob 字段和 hash 值
向 cxuan005 中插入数据,其中 hash 值作为 info 的散列值。
然后再插入两条数据
插入一条 info 为 cxuan005 的数据
如果想要查询 info 为 cxuan005 的数据,可以通过查询 hash 列来进行查询
这是合成索引的例子,如果要对 BLOB 进行模糊查询的话,就要使用前缀索引。
其他优化 BLOB 和 TEXT 的方式:
- 非必要的时候不要检索 BLOB 和 TEXT 索引
- 把 BLOB 或 TEXT 列分离到单独的表中。
浮点数和定点数的选择
浮点数指的就是含有小数的值,浮点数插入到指定列中超过指定精度后,浮点数会四舍五入,MySQL 中的浮点数指的就是 float
和 double
,定点数指的是 decimal
,定点数能够更加精确的保存和显示数据。下面通过一个示例讲解一下浮点数精确性问题
首先创建一个表 cxuan006 ,只为了测试浮点数问题,所以这里我们选择的数据类型是 float
然后分别插入两条数据
然后执行查询,可以看到查询出来的两条数据执行的舍入不同
为了清晰的看清楚浮点数与定点数的精度问题,再来看一个例子
先修改 cxuan006 的两个字段为相同的长度和小数位数
然后插入两条数据
执行查询操作,可以发现,浮点数相较于定点数来说,会产生误差
日期类型选择
在 MySQL 中,用来表示日期类型的有 DATE、TIME、DATETIME、TIMESTAMP,在
这篇文中介绍过了日期类型的区别,我们这里就不再阐述了。下面主要介绍一下选择
- TIMESTAMP 和时区相关,更能反映当前时间,如果记录的日期需要让不同时区的人使用,最好使用 TIMESTAMP。
- DATE 用于表示年月日,如果实际应用值需要保存年月日的话就可以使用 DATE。
- TIME 用于表示时分秒,如果实际应用值需要保存时分秒的话就可以使用 TIME。
- YEAR 用于表示年份,YEAR 有 2 位(最好使用4位)和 4 位格式的年。 默认是4位。如果实际应用只保存年份,那么用 1 bytes 保存 YEAR 类型完全可以。不但能够节约存储空间,还能提高表的操作效率。
MySQL 字符集
下面来认识一下 MySQL 字符集,简单来说字符集就是一套文字符号和编码、比较规则的集合。1960 年美国标准化组织 ANSI 发布了第一个计算机字符集,就是著名的 ASCII(American Standard Code for Information Interchange)
。自从 ASCII 编码后,每个国家、国际组织都研究了一套自己的字符集,比如 ISO-8859-1
、GBK
等。
但是每个国家都使用自己的字符集为移植性带来了很大的困难。所以,为了统一字符编码,国际标准化组织(ISO)
指定了统一的字符标准 – Unicode 编码,它容纳了几乎所有的字符编码。下面是一些常见的字符编码
字符集 | 是否定长 | 编码方式 |
---|---|---|
ASCII | 是 | 单字节 7 位编码 |
ISO-8859-1 | 是 | 单字节 8 位编码 |
GBK | 是 | 双字节编码 |
UTF-8 | 否 | 1 – 4 字节编码 |
UTF-16 | 否 | 2 字节或 4 字节编码 |
UTF-32 | 是 | 4 字节编码 |
对数据库来说,字符集是很重要的,因为数据库存储的数据大多数都是各种文字,字符集对数据库的存储、性能、系统的移植来说都非常重要。
MySQL 支持多种字符集,可以使用 show character set;
来查看所有可用的字符集
或者使用
select character_set_name, default_collate_name, description, maxlen from information_schema.character_sets;
来查看。
使用 information_schema.character_set
来查看字符集和校对规则。
MySQL高级主题
之前两篇文章带你了解了 MySQL 的基础语法和 MySQL 的进阶内容,那么这篇文章我们来了解一下 MySQL 中的高级内容。
事务控制和锁定语句
我们知道,MyISAM 和 MEMORY 存储引擎支持表级锁定(table-level locking)
,InnoDB 存储引擎支持行级锁定(row-level locking)
,BDB 存储引擎支持页级锁定(page-level locking)
。各个锁定级别的特点如下
页级锁:销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般
表级锁:表级锁是对整张表进行加锁,MyISAM 和 MEMORY 主要支持表级锁,表级锁加锁快,不会出现死锁,锁的粒度比较粗,并发度最低
行级锁:行级锁可以说是 MySQL 中粒度最细的一种锁了,InnoDB 支持行级锁,行级锁容易发生死锁,并发度比较好,同时锁的开销也比较大。
MySQL 默认情况下支持表级锁定和行级锁定。但是在某些情况下需要手动控制事务以确保整个事务的完整性,下面我们就来探讨一下事务控制。但是在探讨事务控制之前我们先来认识一下两个锁定语句
锁定语句
MySQL 的锁定语句主要有两个 Lock
和 unLock
,Lock Tables 可用于锁定当前线程的表,就跟 Java 语法中的 Lock 锁的用法是一样的,如果表锁定,意味着其他线程不能再操作表,直到锁定被释放为止。如下图所示
lock table cxuan005 read;
我们锁定了 cxuan005 的 read 锁,然后这时我们再进行一次查询,看看是否能够执行这条语句
select * from cxuan005 where id = 111;
可以看到,在进行 read 锁定了,我们仍旧能够执行查询语句。
现在我们另外起一个窗口,相当于另起了一个线程来进行查询操作。
select * from cxuan005;
这是第二个窗口执行查询的结果,可以看到,在一个线程执行 read 锁定后,其他线程仍然可以进行表的查询操作。
那么第二个线程能否执行更新操作呢?我们来看一下
update cxuan005 set info='cxuan' where id = 111;
发生了什么?怎么没有提示结果呢?其实这个情况下表示 cxuan005 已经被加上了 read 锁,由于当前线程不是持有锁的线程,所以当前线程无法执行更新。
解锁语句
现在我们把窗口切换成持有 read 锁的线程,来进行 read 锁的解锁
unlock tables;
在解锁完成前,进行更新的线程会一直等待,直到解锁完成后,才会进行更新。我们可以看一下更新线程的结果。
可以看到,线程已经更新完毕,我们看一下更新的结果
select * from cxuan005 where id = 111;
如上图所示,id = 111 的值已经被更新成了 cxuan。
事务控制
事务(Transaction)
是访问和更新数据库的基本执行单元,一个事务中可能会包含多个 SQL 语句,事务中的这些 SQL 语句要么都执行,要么都不执行,而 MySQL 它是一个关系型数据库,它自然也是支持事务的。事务同时也是区分关系型数据库和非关系型数据库的一个重要的方面。
在 MySQL 事务中,主要涉及的语法包含 SET AUTOCOMMIT、START TRANSACTION、COMMIT 和 ROLLBACK 等。
自动提交
在 MySQL 中,事务默认是自动提交(Autocommit)
的,如下所示
show variables like 'autocommit';
在自动提交的模式下,每个 SQL 语句都会当作一个事务执行提交操作,例如我们上面使用的更新语句
update cxuan005 set info='cxuan' where id = 111;
如果想要关闭数据库的自动提交应该怎么做呢?
其实,MySQL 是可以关闭自动提交的,你可以执行
set autocommit = 0;
然后我们再看一下自动提交是否关闭了,再次执行一下 show variables like ‘autocommit’ 语句
可以看到,自动提交已经关闭了,再次执行
set autocommit = 1;
会再次开启自动提交。
这里注意一下特殊操作。
在 MySQL 中,存在一些特殊的命令,如果在事务中执行了这些命令,会马上强制执行 commit 提交事务;比如 DDL 语句(create table/drop table/alter/table)、lock tables 语句等等。
不过,常用的 select、insert、update 和 delete命令,都不会强制提交事务。
手动提交
如果需要手动 commit 和 rollback 的话,就需要明确的事务控制语句了。
典型的 MySQL 事务操作如下
start transaction;
... # 一条或者多条语句
commit;
上面代码中的 start transaction 就是事务的开始语句,编写 SQL 后会调用 commit 提交事务,然后将事务统一执行,如果 SQL 语句出现错误会自动调用 Rollback 进行回滚。
下面我们就通过示例来演示一下 MySQL 的事务,同样的,我们需要启动两个窗口来演示,为了便于区分,我们使用 mysql01 和 mysql02 来命名。
我们用 start transaction
命令启动一个事务,然后再 cxuan005 表中插入一条数据,此时 mysql02 不做任何操作。涉及的 SQL 语句如下。
start transaction;
然后执行
select * from cxuan005;
查询一下 cxuan005 中的数据
嗯。。。很多长度太长了,现在我们把所有的 info 数据都更新为 cxuan 。
update cxuan005 set info='cxuan';
更新完毕后,我们先不提交事务,分别在 mysql01 和 mysql02 中进行查询,发现只有 mysql01 窗口中的查询已经生效,而 mysql02 中还是更新前的数据
现在我们在 mysql01 中 commit 当前事务,然后在 mysql02 中查询,发现数据已经被修改了。
除了 commit 之外,MySQL 中还有 commit and chain
命令,这个命令会提交当前事务并且重新开启一个新的事务。如下代码所示
start transaction; # 开启一个新的事务
insert into cxuan005(id,info) values (555,'cxuan005'); # 插入一条数据
commit and chain; # 提交当前事务并重新开启一个事务
上面是一个事务操作,在 commit and chain 键入后,我们可以再次执行 SQL 语句
update cxuan005 set info = 'cxuan' where id = 555;
commit;
然后再次查询
select * from cxuan005;
执行后,可以发现,我们仅仅使用了一个 start transaction 命令就执行了两次事务操作。
如果在手动提交的事务中,你发现有一条 SQL 语句写的不正确或者有其他原因需要回滚,那么此时你就会用到 rollback
语句,它会回滚当前事务,相当于什么也没发生。如下代码所示。
start transaction;
delete from cxuan005 where id = 555;
rollback;
这里
切忌
一点:delete 删除语句一定要加 where ,不加 where 语句的删除就是耍流氓。
在同一个事务操作中,最好使用相同存储引擎的表,如果使用不同存储引擎的表后,rollback 语句会对非事务类型的表进行特别处理,因此 commit 、rollback 只能对事务类型的表进行提交和回滚。
我们提交的事务一般都会被记录到二进制的日志中,但是如果一个事务中包含非事务类型的表,那么回滚操作也会被记录到二进制日志中,以确保非事务类型的表可以被复制到从数据库中。
这里解释一下什么是事务表和非事务表
事务表和非事务表
事务表故名思义就是支持事务的表,支不支持事务和 MySQL 的存储类型有关,一般情况下,InnoDB
存储引擎的表是支持事务的,关于 InnoDB 的知识,我们会在后面详细介绍。
非事务表相应的就是不支持事务的表,在 MySQL 中,存储引擎 MyISAM
是不支持事务的,非事务表的特点是不支持回滚。
对于回滚的话,还要讲一点就是 SAVEPOINT
,它能指定事务回滚的一部分,但是不能指定事务提交的一部分。 SAVEPOINT 可以指定多个,在满足不同条件的同时,回滚不同的 SAVEPOINT。需要注意的是,如果定义了两个相同名称的 SAVEPOINT,则后面定义的 SAVEPOINT 会覆盖之前的定义。如果 SAVEPOINT 不再需要的话,可以通过 RELEASE SAVEPOINT
来进行删除。删除后的 SAVEPOINT 不能再执行 ROLLBACK TO SAVEPOINT 命令。
我们通过一个示例来进行模拟不同的 SAVEPOINT
首先先启动一个事务 ,向 cxuan005 中插入一条数据,然后进行查询,那么是可以查询到这条记录的
start transaction;
insert into cxuan005(id,info) values(666,'cxuan666');
select * from cxuan005 where id = 666;
查询之后的记录如下
然后我们定义一个 SAVEPOINT,如下所示
savepoint test;
然后继续插入一条记录
insert into cxuan005(id,info) values(777,'cxuan777');
此时就可以查询到两条新增记录了,id 是 666 和 777 的记录。
select * from cxuan005 where id = 777;
那么我们可以回滚到刚刚定义的 SAVEPOINT
rollback to savepoint test;
再次查询 cxuan005 这个表,可以看到,只有 id=666 的这条记录插入进来了,说明 id=777 这条记录已经被回滚了。
此时我们看到的都是 mysql01 中事务还没有提交前的状态,所以这时候 mysql02 中执行查询操作是看不到 666 这条记录的。
然后我们在 mysql01 中执行 commit 操作,那么此时在 mysql02 中就可以查询到这条记录了。
SQL 安全问题
SQL 安全问题应该是我们程序员比较忽视的一个地方了。日常开发中,我们一般只会关心 SQL 能不能解决我们的业务问题,能不能把数据查出来,而对于 SQL 问题,我们一般都认为这是 DBA 的活,其实我们 CRUD 程序员也应该了解一下 SQL 的安全问题。
SQL 注入简介
SQL 注入就是利用某些数据库的外部接口将用户数据插入到实际的 SQL 中,从而达到入侵数据库
的目的。SQL 注入是一种常见的网络攻击的方式,它不是利用操作系统的 BUG 来实现攻击的。SQL 主要是针对程序员编写时的疏忽来入侵的。
SQL 注入攻击有很大的危害,攻击者可以利用它读取、修改或者删除数据库内的数据,获取数据库中的用户名和密码,甚至获得数据库管理员的权限。并且 SQL 注入一般比较难以防范。
SQL Mode
MySQL 可以运行在不同的 SQL Mode 模式下,不同的 SQL Mode 定义了不同的 SQL 语法,数据校验规则,这样就能够在不同的环境中使用 MySQL ,下面我们就来介绍一下 SQL Mode。
SQL Mode 解决问题
SQL Mode 可以解决下面这几种问题
- 通过设置 SQL Mode,可以完成不同严格程度的数据校验,有效保障数据的准确性。
- 设置 SQL Mode 为
ANSI
模式,来保证大多数 SQL 符合标准的 SQL 语法,这样应用在不同数据库的迁移中,不需要对 SQL 进行较大的改变 - 数据在不同数据库的迁移中,通过改变 SQL Mode 能够更方便的进行迁移。
下面我们就通过示例来演示一下 SQL Mode 用法
我们可以通过
select @@sql_mode;
来查看默认的 SQL Mode,如下是我的数据库所支持的 SQL Mode
涉及到很多 SQL Mode,下面是这些 SQL Mode 的解释
ONLY_FULL_GROUP_BY
:这个模式会对 GROUP BY 进行合法性检查,对于 GROUP BY 操作,如果在SELECT 中的列,没有在 GROUP BY 中出现,那么将认为这个 SQL 是不合法的,因为列不在 GROUP BY 从句中
同样举个例子,我们现在查询一下 cxuan005 的 id 和 info 字段。
select id,info from cxuan005;
这样是可以运行的
然后我们使用 GROUP BY 字句进行分组,这里只对 info 进行分组,我们看一下会出现什么情况
select id,info from cxuan005 group by info;
我们可以从错误原因中看到,这条 SQL 语句是不符合 ONLY_FULL_GROUP_BY 的这条 SQL Mode 的。因为我们只对 info 进行分组了,没有对 id 进行分组,我们把 SQL 语句改成如下形式
select id,info from cxuan005 group by id,info;
这样 SQL 就能正确执行了。
当然,我们也可以删除 sql_mode = ONLY_FULL_GROUP_BY 的这条 Mode,可以使用
SET sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));
来进行删除,删除后我们使用分组语句就可以放飞自我了。
select id,info from cxuan005 group by info;
但是这种做法只是暂时的修改,我们可以修改配置文件 my.ini 中的 sql_mode= STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
STRICT_TRANS_TABLES
:这就是严格模式,在这个模式下会对数据进行严格的校验,错误数据不能插入,报error 错误。如果不能将给定的值插入到事务表中,则放弃该语句。对于非事务表,如果值出现在单行语句或多行语句的第1行,则放弃该语句。
当使用 innodb 存储引擎表时,考虑使用 innodb_strict_mode 模式的 sql_mode,它能增量额外的错误检测功能。
NO_ZERO_IN_DATE
:这个模式影响着日期中的月份和天数是否可以为 0(注意年份是非 0 的),这个模式也取决于严格模式是否被启用。如果这个模式未启用,那么日期中的零部分被允许并且插入没有警告。如果这个模式启用,那么日期中的零部分插入被作为 0000-00-00
并且产生一个警告。
这个模式需要注意下,如果启用的话,需要 STRICT_TRANS_TABLES
和 NO_ZERO_IN_DATE
同时启用,否则不起作用,也就是
set session sql_mode='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE';
然后我们换表了,使用 cxuan003 这张表,表结构如下
我们主要测试日期的使用,在 cxuan003 中插入一条日期为 0000-00-00
的数据
insert into cxuan003 values(111,'study','0000-00-00');
发现能够执行成功,但是把年月日各自变为 0 之后再进行插入,则会插入失败。
insert into cxuan003 values(111,'study','2021-00-00');
insert into cxuan003 values(111,'study','2021-01-00');
这些组合有很多,我这里就不再细致演示了,读者可以自行测试。
如果要插入 0000-00-00
这样的数据,必须设置 NO_ZERO_IN_DATE
和 NO_ZERO_DATE
。
ERROR_FOR_DIVISION_BY_ZERO
:如果这个模式未启用,那么零除操作将会插入空值并且不会产生警告;如果这个模式启用,零除操作插入空值并产生警告;如果这个模式和严格模式都启用,零除从操作将会产生一个错误。
NO_AUTO_CREATE_USER
:禁止使用 grant 语句自动创建用户,除非认证信息被指定。
NO_ENGINE_SUBSTITUTION
:此模式指定当执行 create 语句或者 alter 语句指定的存储引擎没有启用或者没有编译时,控制默认存储引擎的自动切换。默认是启用状态的。
SQL Mode 三种作用域
SQL Mode 按作用区域和时间可分为 3。个级别,分别是会话级别,全局级别,配置(永久生效)级别。
我们上面使用的 SQL Mode 都是 会话级别
,会话级别就是当前窗口域有效。它的设置方式是
set @@session.sql_mode='xx_mode'
set session sql_mode='xx_mode'
全局域就是当前会话关闭不失效,但是在 MySQL 重启后失效。它的设置方式是
set global sql_mode='xx_mode';
set @@global.sql_mode='xx_mode';
配置域就是在 vi /etc/my.cnf
里面添加
[mysqld]
sql-mode = "xx_mode"
配置域在保存退出后,重启服务器,即可永久生效。
SQL 正则表达式
正则表达式相信大家应该都用过,不过你在 MySQL 中用过正则表达式吗?下面我们就来聊一聊 SQL 中的正则表达式。
正则表达式(Regular Expression)
是指一个用来描述或者匹配字符串的句法规则。正则表达式通常用来检索和替换某个文本中的文本内容。很多语言都支持正则表达式,MySQL 同样也不例外,MySQL 利用 REGEXP
命令提供给用户扩展的正则表达式功能。下面是 MySQL 中正则表达式的一些规则。
下面来演示一下正则表达式的用法
-
^
在字符串的开始进行匹配,根据返回的结果来判断是否匹配,1 = 匹配,0 = 不匹配。下面尝试匹配字符串aaaabbbccc
是否以字符串a
为开始select 'aaaabbbccc' regexp '^a';
-
同样的,
$
会在末尾处进行匹配,如下所示select 'aaaabbbccc' regexp 'c$';
-
.
匹配单个任意字符select 'berska' regexp '.s', 'zara' regexp '.a';
-
[...]
表示匹配括号内的任意字符,示例如下select 'whosyourdaddy' regexp '[abc]';
-
[^...]
匹配括号内不包含的任意字符,和[...]
是相反的,如果有任何匹配不上,返回 0 ,全部匹配上返回 1。select 'x' regexp '[^xyz]';
-
n*
表示匹配零个或者多个 n 字符串,如下select 'aabbcc' regexp 'd*';
没有 d 出现也可以返回 1 ,因为 * 表示 0 或者多个。
-
n+
表示匹配 1 个或者 n 个字符串select 'aabbcc' regexp 'd+';
-
n?
的用法和 n+ 类似,只不过 n? 可以匹配空串
常见 SQL 技巧
RAND() 函数
大多数数据库都会提供产生随机数的函数,通过这些函数可以产生随机数,也可以使用从数据库表中抽取随机产生的记录,这对统计分析来说很有用。
在 MySQL 中,通常使用 RAND()
函数来产生随机数。RAND() 和 ORDER BY 组合完成数据抽取功能,如下所示。
我们新建一张表用于数据检索。
CREATE TABLE `clerk_Info` (
`id` int(11) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`salary` decimal(10,2) DEFAULT NULL,
`companyId` int(10) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
然后插入一些数据,插入完成后的数据如下。
然后我们可以使用 RAND() 函数进行随机检索数据行
select * from clerk_info order by rand();
检索完成后的数据如下
多次查询后发现每次检索的数据顺序都是随机的。
这个函数多用于随机抽样,比如选取一定数量的样本在进行随机排序,需要用到 limit
关键字。
GROUP BY + WITH ROLLUP
我们经常使用 GROUP BY 语句,但是你用过 GROUP BY
和 WITH ROLLUP
一起使用的吗?使用 GROUP BY 和 WITH ROLLUP 字句可以检索出更多的分组集合信息。
我们仍旧对 clerk_info 表进行操作,我们对 name 和 salary 进行分组统计工资总数。
select name,sum(salary) from clerk_info group by name with rollup;
可以看到上面的表按照 name 进行分组,然后再对 money 进行统计。
也就是说 GROUP BY 语句执行完成后可以满足用户想要的任何一个分组以及分组组合的聚合信息值。
这里需要注意一点,不能同时使用 ORDER BY 字句对结果进行排序,ROLLUP 和 ORDER BY 是互斥的。
数据库名、表名大小写问题
在 MySQL 中,数据库中的每个表至少对应数据库目录中的一个文件,当然这取决于存储引擎的实现了。不同的操作系统对大小写的敏感性决定了数据库和表名的大小写的敏感性。在 UNIX 操作系统中是对大小写敏感的,因此数据库名和表名也是具有敏感性的,而到了 Windows 则不存在敏感性问题,因为 Windows 操作系统本身对大小写不敏感。列、索引、触发器在任何平台上都对大小写不敏感。
在 MySQL 中,数据库名和表名是由 lower_case_tables_name
系统变量决定的。可以在启动 mysqld
时设置这个系统变量。下面是 lower_case_tables_name
的值。
如果只在一个平台上使用 MySQL 的话,通常不需要修改 lower_case_tables_name
变量。如果想要在不同系统系统之间迁移表就会涉及到大小写问题,因为 UNIX 中 clerk_info 和 CLERK_INFO 被认为是两个不同的表,而 Windows 中则认为是一个。在 UNIX 中使用 lower_case_tables_name=0, 而在 Windows 中使用lower_case_tables_name=2,这样可以保留数据库名和表名的大小写,但是不能保证所有的 SQL 查询中使用的表名和数据库名的大小写相同。如果 SQL 语句中没有正确引用数据库名和表名的大小写,那么虽然在 Windows 中能正确执行,但是如果将查询转移到 UNIX 中,大小写不正确,将会导致查询失败。
外键问题
这里需要注意一个问题,InnoDB
存储引擎是支持外键的,而 MyISAM
存储引擎是不支持外键的,因此在 MyISAM 中设置外键会不起作用。
MySQL 常用函数
下面我们来了解一下 MySQL 函数,MySQL 函数也是我们日常开发过程中经常使用的,选用合适的函数能够提高我们的开发效率,下面我们就来一起认识一下这些函数
字符串函数
字符串函数是最常用的一种函数了,MySQL 也是支持很多种字符串函数,下面是 MySQL 支持的字符串函数表
函数 | 功能 |
---|---|
LOWER | 将字符串所有字符变为小写 |
UPPER | 将字符串所有字符变为大写 |
CONCAT | 进行字符串拼接 |
LEFT | 返回字符串最左边的字符 |
RIGHT | 返回字符串最右边的字符 |
INSERT | 字符串替换 |
LTRIM | 去掉字符串左边的空格 |
RTRIM | 去掉字符串右边的空格 |
REPEAT | 返回重复的结果 |
TRIM | 去掉字符串行尾和行头的空格 |
SUBSTRING | 返回指定的字符串 |
LPAD | 用字符串对最左边进行填充 |
RPAD | 用字符串对最右边进行填充 |
STRCMP | 比较字符串 s1 和 s2 |
REPLACE | 进行字符串替换 |
下面通过具体的示例演示一下每个函数的用法
- LOWER(str) 和 UPPER(str) 函数:用于转换大小写
- CONCAT(s1,s2 … sn) :把传入的参数拼接成一个字符串
上面把 c xu an
拼接成为了一个字符串,另外需要注意一点,任何和 NULL 进行字符串拼接的结果都是 NULL。
- LEFT(str,x) 和 RIGHT(str,x) 函数:分别返回字符串最左边的 x 个字符和最右边的 x 个字符。如果第二个参数是 NULL,那么将不会返回任何字符串
- INSERT(str,x,y,instr) : 将字符串 str 从指定 x 的位置开始, 取 y 个长度的字串替换为 instr。
- LTRIM(str) 和 RTRIM(str) 分别表示去掉字符串 str 左侧和右侧的空格
- REPEAT(str,x) 函数:返回 str 重复 x 次的结果
- TRIM(str) 函数:用于去掉目标字符串的空格
- SUBSTRING(str,x,y) 函数:返回从字符串 str 中第 x 位置起 y 个字符长度的字符串
- LPAD(str,n,pad) 和 RPAD(str,n,pad) 函数:用字符串 pad 对 str 左边和右边进行填充,直到长度为 n 个字符长度
- STRCMP(s1,s2) 用于比较字符串 s1 和 s2 的 ASCII 值大小。如果 s1 < s2,则返回 -1;如果 s1 = s2 ,返回 0 ;如果 s1 > s2 ,返回 1。
- REPLACE(str,a,b) : 用字符串 b 替换字符串 str 种所有出现的字符串 a
数值函数
MySQL 支持数值函数,这些函数能够处理很多数值运算。下面我们一起来学习一下 MySQL 中的数值函数,下面是所有的数值函数
函数 | 功能 |
---|---|
ABS | 返回绝对值 |
CEIL | 返回大于某个值的最大整数值 |
MOD | 返回模 |
ROUND | 四舍五入 |
FLOOR | 返回小于某个值的最大整数值 |
TRUNCATE | 返回数字截断小数的结果 |
RAND | 返回 0 – 1 的随机值 |
下面我们还是以实践为主来聊一聊这些用法
- ABS(x) 函数:返回 x 的绝对值
- CEIL(x) 函数: 返回大于 x 的整数
- MOD(x,y),对 x 和 y 进行取模操作
- ROUND(x,y) 返回 x 四舍五入后保留 y 位小数的值;如果是整数,那么 y 位就是 0 ;如果不指定 y ,那么 y 默认也是 0 。
- FLOOR(x) : 返回小于 x 的最大整数,用法与 CEIL 相反
- TRUNCATE(x,y): 返回数字 x 截断为 y 位小数的结果, TRUNCATE 知识截断,并不是四舍五入。
- RAND() :返回 0 到 1 的随机值
日期和时间函数
日期和时间函数也是 MySQL 中非常重要的一部分,下面我们就来一起认识一下这些函数
函数 | 功能 |
---|---|
NOW | 返回当前的日期和时间 |
WEEK | 返回一年中的第几周 |
YEAR | 返回日期的年份 |
HOUR | 返回小时值 |
MINUTE | 返回分钟值 |
MONTHNAME | 返回月份名 |
CURDATE | 返回当前日期 |
CURTIME | 返回当前时间 |
UNIX_TIMESTAMP | 返回日期 UNIX 时间戳 |
DATE_FORMAT | 返回按照字符串格式化的日期 |
FROM_UNIXTIME | 返回 UNIX 时间戳的日期值 |
DATE_ADD | 返回日期时间 + 上一个时间间隔 |
DATEDIFF | 返回起始时间和结束时间之间的天数 |
下面结合示例来讲解一下每个函数的使用
- NOW(): 返回当前的日期和时间
- WEEK(DATE) 和 YEAR(DATE) :前者返回的是一年中的第几周,后者返回的是给定日期的哪一年
- HOUR(time) 和 MINUTE(time) : 返回给定时间的小时,后者返回给定时间的分钟
- MONTHNAME(date) 函数:返回 date 的英文月份
- CURDATE() 函数:返回当前日期,只包含年月日
- CURTIME() 函数:返回当前时间,只包含时分秒
- UNIX_TIMESTAMP(date) : 返回 UNIX 的时间戳
- FROM_UNIXTIME(date) : 返回 UNIXTIME 时间戳的日期值,和 UNIX_TIMESTAMP 相反
- DATE_FORMAT(date,fmt) 函数:按照字符串 fmt 对 date 进行格式化,格式化后按照指定日期格式显示
具体的日期格式可以参考这篇文章 https://blog.csdn.net/weixin_38703170/article/details/82177837
我们演示一下将当前日期显示为年月日的这种形式,使用的日期格式是 %M %D %Y。
- DATE_ADD(date, interval, expr type) 函数:返回与所给日期 date 相差 interval 时间段的日期
interval 表示间隔类型的关键字,expr 是表达式,这个表达式对应后面的类型,type 是间隔类型,MySQL 提供了 13 种时间间隔类型
表达式类型 | 描述 | 格式 |
---|---|---|
YEAR | 年 | YY |
MONTH | 月 | MM |
DAY | 日 | DD |
HOUR | 小时 | hh |
MINUTE | 分 | mm |
SECOND | 秒 | ss |
YEAR_MONTH | 年和月 | YY-MM |
DAY_HOUR | 日和小时 | DD hh |
DAY_MINUTE | 日和分钟 | DD hh : mm |
DAY_SECOND | 日和秒 | DD hh :mm :ss |
HOUR_MINUTE | 小时和分 | hh:mm |
HOUR_SECOND | 小时和秒 | hh:ss |
MINUTE_SECOND | 分钟和秒 | mm:ss |
- DATE_DIFF(date1, date2) 用来计算两个日期之间相差的天数
查看离 2021 – 01 – 01 还有多少天
流程函数
流程函数也是很常用的一类函数,用户可以使用这类函数在 SQL 中实现条件选择。这样做能够提高查询效率。下表列出了这些流程函数
函数 | 功能 |
---|---|
IF(value,t f) | 如果 value 是真,返回 t;否则返回 f |
IFNULL(value1,value2) | 如果 value1 不为 NULL,返回 value1,否则返回 value2。 |
CASE WHEN[value1] THEN[result1] …ELSE[default] END | 如果 value1 是真,返回 result1,否则返回 default |
CASE[expr] WHEN[value1] THEN [result1]… ELSE[default] END | 如果 expr 等于 value1, 返回 result1, 否则返回 default |
其他函数
除了我们介绍过的字符串函数、日期和时间函数、流程函数,还有一些函数并不属于上面三类函数,它们是
函数 | 功能 |
---|---|
VERSION | 返回当前数据库的版本 |
DATABASE | 返回当前数据库名 |
USER | 返回当前登陆用户名 |
PASSWORD | 返回字符串的加密版本 |
MD5 | 返回 MD5 值 |
INET_ATON(IP) | 返回 IP 地址的数字表示 |
INET_NTOA(num) | 返回数字代表的 IP 地址 |
下面来看一下具体的使用
- VERSION: 返回当前数据库版本
- DATABASE: 返回当前的数据库名
- USER : 返回当前登录用户名
- PASSWORD(str) : 返回字符串的加密版本,例如
- MD5(str) 函数:返回字符串 str 的 MD5 值
- INET_ATON(IP): 返回 IP 的网络字节序列
- INET_NTOA(num)函数:返回网络字节序列代表的 IP 地址,与 INET_ATON 相对
总结
这篇文章我带你手把手撸了一波 MySQL 的高级内容,其实说高级也不一定真的高级或者说难,其实就是区分不同梯度的东西。
操作系统之内存管理
主存(RAM)
是一件非常重要的资源,必须要认真对待内存。虽然目前大多数内存的增长速度要比 IBM 7094 要快的多,但是,程序大小的增长要比内存的增长还快很多。不管存储器有多大,程序大小的增长速度比内存容量的增长速度要快的多
。下面我们就来探讨一下操作系统是如何创建内存并管理他们的。
经过多年的研究发现,科学家提出了一种 分层存储器体系(memory hierarchy)
,下面是分层体系的分类
位于顶层的存储器速度最快,但是相对容量最小,成本非常高。层级结构向下,其访问速度会变慢,但是容量会变大,相对造价也就越便宜。(所以个人感觉相对存储容量来说,访问速度是更重要的)
操作系统中管理内存层次结构的部分称为内存管理器(memory manager)
,它的主要工作是有效的管理内存,记录哪些内存是正在使用的,在进程需要时分配内存以及在进程完成时回收内存。所有现代操作系统都提供内存管理。
下面我们会对不同的内存管理模型进行探讨,从简单到复杂,由于最低级别的缓存是由硬件进行管理的,所以我们主要探讨主存模型和如何对主存进行管理。
无存储器抽象
最简单的存储器抽象是无存储器
。早期大型计算机(20 世纪 60 年代之前),小型计算机(20 世纪 70 年代之前)和个人计算机(20 世纪 80 年代之前)都没有存储器抽象。每一个程序都直接访问物理内存。当一个程序执行如下命令:
MOV REGISTER1, 1000
计算机会把位置为 1000 的物理内存中的内容移到 REGISTER1
中。因此呈现给程序员的内存模型就是物理内存,内存地址从 0 开始到内存地址的最大值中,每个地址中都会包含一个 8 位位数的内存单元。
所以这种情况下的计算机不可能会有两个应用程序同时
在内存中。如果第一个程序向内存地址 2000 的这个位置写入了一个值,那么此值将会替换第二个程序 2000 位置上的值,所以,同时运行两个应用程序是行不通的,两个程序会立刻崩溃。
不过即使存储器模型就是物理内存,还是存在一些可变体的。下面展示了三种变体
在上图 a 中,操作系统位于 RAM(Random Access Memory)
的底部,或像是图 b 一样位于 ROM(Read-Only Memory)
顶部;而在图 c 中,设备驱动程序位于顶端的 ROM 中,而操作系统位于底部的 RAM 中。图 a 的模型以前用在大型机和小型机上,但现在已经很少使用了;图 b 中的模型一般用于掌上电脑或者是嵌入式系统中。第三种模型就应用在早期个人计算机中了。ROM 系统中的一部分成为 BIOS (Basic Input Output System)
。模型 a 和 c 的缺点是用户程序中的错误可能会破坏操作系统,可能会导致灾难性的后果。
按照这种方式组织系统时,通常同一个时刻只能有一个进程正在运行。一旦用户键入了一个命令,操作系统就把需要的程序从磁盘复制到内存中并执行;当进程运行结束后,操作系统在用户终端显示提示符并等待新的命令。收到新的命令后,它把新的程序装入内存,覆盖前一个程序。
在没有存储器抽象的系统中实现并行性
一种方式是使用多线程来编程。由于同一进程中的多线程内部共享同一内存映像,那么实现并行也就不是问题了。但是这种方式却并没有被广泛采纳,因为人们通常希望能够在同一时间内运行没有关联的程序,而这正是线程抽象所不能提供的。
运行多个程序
但是,即便没有存储器抽象,同时运行多个程序也是有可能的。操作系统只需要把当前内存中所有内容保存到磁盘文件中,然后再把程序读入内存即可。只要某一时刻内存只有一个程序在运行,就不会有冲突的情况发生。
在额外特殊硬件的帮助下,即使没有交换功能,也可以并行的运行多个程序。IBM 360 的早期模型就是这样解决的
System/360是 IBM 在1964年4月7日,推出的划时代的大型电脑,这一系列是世界上首个指令集可兼容计算机。
在 IBM 360 中,内存被划分为 2KB 的区域块,每块区域被分配一个 4 位的保护键,保护键存储在 CPU 的特殊寄存器(SFR)
中。一个内存为 1 MB 的机器只需要 512 个这样的 4 位寄存器,容量总共为 256 字节 (这个会算吧) ,PSW(Program Status Word, 程序状态字)
中有一个 4 位码。一个运行中的进程如果访问键与其 PSW 中保存的码不同,360 硬件会捕获这种情况。因为只有操作系统可以修改保护键,这样就可以防止进程之间、用户进程和操作系统之间的干扰。
这种解决方式是有一个缺陷。如下所示,假设有两个程序,每个大小各为 16 KB
从图上可以看出,这是两个不同的 16KB 程序的装载过程,a 程序首先会跳转到地址 24,那里是一条 MOV
指令,然而 b 程序会首先跳转到地址 28,地址 28 是一条 CMP
指令。这是两个程序被先后
加载到内存中的情况,假如这两个程序被同时加载到内存中并且从 0 地址处开始执行,内存的状态就如上面 c 图所示,程序装载完成开始运行,第一个程序首先从 0 地址处开始运行,执行 JMP 24 指令,然后依次执行后面的指令(许多指令没有画出),一段时间后第一个程序执行完毕,然后开始执行第二个程序。第二个程序的第一条指令是 28,这条指令会使程序跳转到第一个程序的 ADD
处,而不是事先设定好的跳转指令 CMP,由于这种不正确访问,可能会造成程序崩溃。
上面两个程序的执行过程中有一个核心问题,那就是都引用了绝对物理地址,这不是我们想要看到的。我们想要的是每一个程序都会引用一个私有的本地地址。IBM 360 在第二个程序装载到内存中的时候会使用一种称为 静态重定位(static relocation)
的技术来修改它。它的工作流程如下:当一个程序被加载到 16384 地址时,常数 16384 被加到每一个程序地址上(所以 JMP 28
会变为JMP 16412
)。虽然这个机制在不出错误
的情况下是可行的,但这不是一种通用的解决办法,同时会减慢装载速度。更近一步来讲,它需要所有可执行程序中的额外信息,以指示哪些包含(可重定位)地址,哪些不包含(可重定位)地址。毕竟,上图 b 中的 JMP 28 可以被重定向(被修改),而类似 MOV REGISTER1,28
会把数字 28 移到 REGISTER 中则不会重定向。所以,装载器(loader)
需要一定的能力来辨别地址和常数。
一种存储器抽象:地址空间
把物理内存暴露给进程会有几个主要的缺点:第一个问题是,如果用户程序可以寻址内存的每个字节,它们就可以很容易的破坏操作系统,从而使系统停止运行
(除非使用 IBM 360 那种 lock-and-key 模式或者特殊的硬件进行保护)。即使在只有一个用户进程运行的情况下,这个问题也存在。
第二点是,这种模型想要运行多个程序是很困难的(如果只有一个 CPU 那就是顺序执行)。在个人计算机上,一般会打开很多应用程序,比如输入法、电子邮件、浏览器,这些进程在不同时刻会有一个进程正在运行,其他应用程序可以通过鼠标来唤醒。在系统中没有物理内存的情况下很难实现。
地址空间的概念
如果要使多个应用程序同时运行在内存中,必须要解决两个问题:保护
和 重定位
。我们来看 IBM 360 是如何解决的:第一种解决方式是用保护密钥标记内存块
,并将执行过程的密钥与提取的每个存储字的密钥进行比较。这种方式只能解决第一种问题(破坏操作系统),但是不能解决多进程在内存中同时运行的问题。
还有一种更好的方式是创造一个存储器抽象:地址空间(the address space)
。就像进程的概念创建了一种抽象的 CPU 来运行程序,地址空间也创建了一种抽象内存供程序使用。地址空间是进程可以用来寻址内存的地址集。每个进程都有它自己的地址空间,独立于其他进程的地址空间,但是某些进程会希望可以共享地址空间。
基址寄存器和变址寄存器
最简单的办法是使用动态重定位(dynamic relocation)
技术,它就是通过一种简单的方式将每个进程的地址空间映射到物理内存的不同区域。从 CDC 6600(世界上最早的超级计算机)
到 Intel 8088(原始 IBM PC 的核心)
所使用的经典办法是给每个 CPU 配置两个特殊硬件寄存器,通常叫做基址寄存器(basic register)
和变址寄存器(limit register)
。当使用基址寄存器和变址寄存器时,程序会装载到内存中的连续位置并且在装载期间无需重定位。当一个进程运行时,程序的起始物理地址装载到基址寄存器中,程序的长度则装载到变址寄存器中。在上图 c 中,当一个程序运行时,装载到这些硬件寄存器中的基址和变址寄存器的值分别是 0 和 16384。当第二个程序运行时,这些值分别是 16384 和 32768。如果第三个 16 KB 的程序直接装载到第二个程序的地址之上并且运行,这时基址寄存器和变址寄存器的值会是 32768 和 16384。那么我们可以总结下
- 基址寄存器:存储数据内存的起始位置
- 变址寄存器:存储应用程序的长度。
每当进程引用内存以获取指令或读取、写入数据时,CPU 都会自动将基址值
添加到进程生成的地址中,然后再将其发送到内存总线上。同时,它检查程序提供的地址是否大于或等于变址寄存器
中的值。如果程序提供的地址要超过变址寄存器的范围,那么会产生错误并中止访问。这样,对上图 c 中执行 JMP 28
这条指令后,硬件会把它解释为 JMP 16412
,所以程序能够跳到 CMP 指令,过程如下
使用基址寄存器和变址寄存器是给每个进程提供私有地址空间的一种非常好的方法,因为每个内存地址在送到内存之前,都会先加上基址寄存器的内容。在很多实际系统中,对基址寄存器和变址寄存器都会以一定的方式加以保护,使得只有操作系统可以修改它们。在 CDC 6600
中就提供了对这些寄存器的保护,但在 Intel 8088
中则没有,甚至没有变址寄存器。但是,Intel 8088 提供了许多基址寄存器,使程序的代码和数据可以被独立的重定位,但是对于超出范围的内存引用没有提供保护。
所以你可以知道使用基址寄存器和变址寄存器的缺点,在每次访问内存时,都会进行 ADD
和 CMP
运算。CMP 指令可以执行的很快,但是加法就会相对慢一些,除非使用特殊的加法电路,否则加法因进位传播时间而变慢。
交换技术
如果计算机的物理内存足够大来容纳所有的进程,那么之前提及的方案或多或少是可行的。但是实际上,所有进程需要的 RAM 总容量要远远高于内存的容量。在 Windows、OS X、或者 Linux 系统中,在计算机完成启动(Boot)后,大约有 50 – 100 个进程随之启动。例如,当一个 Windows 应用程序被安装后,它通常会发出命令,以便在后续系统启动时,将启动一个进程,这个进程除了检查应用程序的更新外不做任何操作。一个简单的应用程序可能会占用 5 - 10MB
的内存。其他后台进程会检查电子邮件、网络连接以及许多其他诸如此类的任务。这一切都会发生在第一个用户
启动之前。如今,像是 Photoshop
这样的重要用户应用程序仅仅需要 500 MB 来启动,但是一旦它们开始处理数据就需要许多 GB 来处理。从结果上来看,将所有进程始终保持在内存中需要大量内存,如果内存不足,则无法完成。
所以针对上面内存不足的问题,提出了两种处理方式:最简单的一种方式就是交换(swapping)
技术,即把一个进程完整的调入内存,然后再内存中运行一段时间,再把它放回磁盘。空闲进程会存储在磁盘中,所以这些进程在没有运行时不会占用太多内存。另外一种策略叫做虚拟内存(virtual memory)
,虚拟内存技术能够允许应用程序部分的运行在内存中。下面我们首先先探讨一下交换
交换过程
下面是一个交换过程
刚开始的时候,只有进程 A 在内存中,然后从创建进程 B 和进程 C 或者从磁盘中把它们换入内存,然后在图 d 中,A 被换出内存到磁盘中,最后 A 重新进来。因为图 g 中的进程 A 现在到了不同的位置,所以在装载过程中需要被重新定位,或者在交换程序时通过软件来执行;或者在程序执行期间通过硬件来重定位。基址寄存器和变址寄存器就适用于这种情况。
交换在内存创建了多个 空闲区(hole)
,内存会把所有的空闲区尽可能向下移动合并成为一个大的空闲区。这项技术称为内存紧缩(memory compaction)
。但是这项技术通常不会使用,因为这项技术回消耗很多 CPU 时间。例如,在一个 16GB 内存的机器上每 8ns 复制 8 字节,它紧缩全部的内存大约要花费 16s。
有一个值得注意的问题是,当进程被创建或者换入内存时应该为它分配多大的内存。如果进程被创建后它的大小是固定的并且不再改变,那么分配策略就比较简单:操作系统会准确的按其需要的大小进行分配。
但是如果进程的 data segment
能够自动增长,例如,通过动态分配堆中的内存,肯定会出现问题。这里还是再提一下什么是 data segment
吧。从逻辑层面操作系统把数据分成不同的段(不同的区域)
来存储:
- **代码段(codesegment/textsegment):
又称文本段,用来存放指令,运行代码的一块内存空间
此空间大小在代码运行前就已经确定
内存空间一般属于只读,某些架构的代码也允许可写
在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
- **数据段(datasegment):
可读可写
存储初始化的全局变量和初始化的 static 变量
数据段中数据的生存期是随程序持续性(随进程持续性) 随进程持续性:进程创建就存在,进程死亡就消失
- **bss段(bsssegment):
可读可写
存储未初始化的全局变量和未初始化的 static 变量
bss 段中数据的生存期随进程持续性
bss 段中的数据一般默认为0
- **rodata段:
只读数据 比如 printf 语句中的格式字符串和开关语句的跳转表。也就是常量区。例如,全局作用域中的 const int ival = 10,ival 存放在 .rodata 段;再如,函数局部作用域中的 printf("Hello world %d\n", c); 语句中的格式字符串 "Hello world %d\n",也存放在 .rodata 段。
- **栈(stack):
可读可写
存储的是函数或代码中的局部变量(非 static 变量)
栈的生存期随代码块持续性,代码块运行就给你分配空间,代码块结束,就自动回收空间
- **堆(heap):
可读可写
存储的是程序运行期间动态分配的 malloc/realloc 的空间
堆的生存期随进程持续性,从 malloc/realloc 到 free 一直存在
下面是我们用 Borland C++ 编译过后的结果
_TEXT segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
段定义( segment ) 是用来区分或者划分范围区域的意思。汇编语言的 segment 伪指令表示段定义的起始,ends 伪指令表示段定义的结束。段定义是一段连续的内存空间
**所以内存针对自动增长的区域,会有三种处理方式
-
如果一个进程与空闲区相邻,那么可把该空闲区分配给进程以供其增大。
-
如果进程相邻的是另一个进程,就会有两种处理方式:要么把需要增长的进程移动到一个内存中空闲区足够大的区域,要么把一个或多个进程交换出去,已变成生成一个大的空闲区。
-
如果一个进程在内存中不能增长,而且磁盘上的交换区也满了,那么这个进程只有挂起一些空闲空间(或者可以结束该进程)
上面只针对单个或者一小部分需要增长的进程采用的方式,如果大部分进程都要在运行时增长,为了减少因内存区域不够而引起的进程交换和移动所产生的开销,一种可用的方法是,在换入或移动进程时为它分配一些额外的内存。然而,当进程被换出到磁盘上时,应该只交换实际上使用的内存,将额外的内存交换也是一种浪费,下面是一种为两个进程分配了增长空间的内存配置。
如果进程有两个可增长的段,例如,供变量动态分配和释放的作为堆(全局变量)
使用的一个数据段(data segment)
,以及存放局部变量与返回地址的一个堆栈段(stack segment)
,就如图 b 所示。在图中可以看到所示进程的堆栈段在进程所占内存的顶端向下增长,紧接着在程序段后的数据段向上增长。当增长预留的内存区域不够了,处理方式就如上面的流程图(data segment 自动增长的三种处理方式)
一样了。
空闲内存管理
在进行内存动态分配时,操作系统必须对其进行管理。大致上说,有两种监控内存使用的方式
位图(bitmap)
空闲列表(free lists)
下面我们就来探讨一下这两种使用方式
使用位图的存储管理
使用位图方法时,内存可能被划分为小到几个字或大到几千字节的分配单元。每个分配单元对应于位图中的一位,0 表示空闲, 1 表示占用(或者相反)。一块内存区域和其对应的位图如下
图 a 表示一段有 5 个进程和 3 个空闲区的内存,刻度为内存分配单元,阴影区表示空闲(在位图中用 0 表示);图 b 表示对应的位图;图 c 表示用链表表示同样的信息
分配单元的大小是一个重要的设计因素,分配单位越小,位图越大。然而,即使只有 4 字节的分配单元,32 位的内存也仅仅只需要位图中的 1 位。32n
位的内存需要 n 位的位图,所以1 个位图只占用了 1/32 的内存。如果选择更大的内存单元,位图应该要更小。如果进程的大小不是分配单元的整数倍,那么在最后一个分配单元中会有大量的内存被浪费。
位图
提供了一种简单的方法在固定大小的内存中跟踪内存的使用情况,因为位图的大小取决于内存和分配单元的大小。这种方法有一个问题是,当决定为把具有 k 个分配单元的进程放入内存时,内容管理器(memory manager)
必须搜索位图,在位图中找出能够运行 k 个连续 0 位的串。在位图中找出制定长度的连续 0 串是一个很耗时的操作,这是位图的缺点。(可以简单理解为在杂乱无章的数组中,找出具有一大长串空闲的数组单元)
使用链表进行管理
另一种记录内存使用情况的方法是,维护一个记录已分配内存段和空闲内存段的链表,段会包含进程或者是两个进程的空闲区域。可用上面的图 c 来表示内存的使用情况。链表中的每一项都可以代表一个 空闲区(H)
或者是进程(P)
的起始标志,长度和下一个链表项的位置。
在这个例子中,段链表(segment list)
是按照地址排序的。这种方式的优点是,当进程终止或被交换时,更新列表很简单。一个终止进程通常有两个邻居(除了内存的顶部和底部外)。相邻的可能是进程也可能是空闲区,它们有四种组合方式。
当按照地址顺序在链表中存放进程和空闲区时,有几种算法可以为创建的进程(或者从磁盘中换入的进程)分配内存。我们先假设内存管理器知道应该分配多少内存,最简单的算法是使用 首次适配(first fit)
。内存管理器会沿着段列表进行扫描,直到找个一个足够大的空闲区为止。除非空闲区大小和要分配的空间大小一样,否则将空闲区分为两部分,一部分供进程使用;一部分生成新的空闲区。首次适配算法是一种速度很快的算法,因为它会尽可能的搜索链表。
首次适配的一个小的变体是 下次适配(next fit)
。它和首次匹配的工作方式相同,只有一个不同之处那就是下次适配在每次找到合适的空闲区时就会记录当时的位置,以便下次寻找空闲区时从上次结束的地方开始搜索,而不是像首次匹配算法那样每次都会从头开始搜索。Bays(1997)
证明了下次算法的性能略低于首次匹配算法。
另外一个著名的并且广泛使用的算法是 最佳适配(best fit)
。最佳适配会从头到尾寻找整个链表,找出能够容纳进程的最小空闲区。最佳适配算法会试图找出最接近实际需要的空闲区,以最好的匹配请求和可用空闲区,而不是先一次拆分一个以后可能会用到的大的空闲区。比如现在我们需要一个大小为 2 的块,那么首次匹配算法会把这个块分配在位置 5 的空闲区,而最佳适配算法会把该块分配在位置为 18 的空闲区,如下
那么最佳适配算法的性能如何呢?最佳适配会遍历整个链表,所以最佳适配算法的性能要比首次匹配算法差。但是令人想不到的是,最佳适配算法要比首次匹配和下次匹配算法浪费更多的内存,因为它会产生大量无用的小缓冲区,首次匹配算法生成的空闲区会更大一些。
最佳适配的空闲区会分裂出很多非常小的缓冲区,为了避免这一问题,可以考虑使用 最差适配(worst fit)
算法。即总是分配最大的内存区域(所以你现在明白为什么最佳适配算法会分裂出很多小缓冲区了吧),使新分配的空闲区比较大从而可以继续使用。仿真程序表明最差适配算法也不是一个好主意。
如果为进程和空闲区维护各自独立的链表,那么这四个算法的速度都能得到提高。这样,这四种算法的目标都是为了检查空闲区而不是进程。但这种分配速度的提高的一个不可避免的代价是增加复杂度和减慢内存释放速度,因为必须将一个回收的段从进程链表中删除并插入空闲链表区。
如果进程和空闲区使用不同的链表,那么可以按照大小对空闲区链表排序,以便提高最佳适配算法的速度。在使用最佳适配算法搜索由小到大排列的空闲区链表时,只要找到一个合适的空闲区,则这个空闲区就是能容纳这个作业的最小空闲区,因此是最佳匹配。因为空闲区链表以单链表形式组织,所以不需要进一步搜索。空闲区链表按大小排序时,首次适配算法与最佳适配算法一样快,而下次适配算法在这里毫无意义。
另一种分配算法是 快速适配(quick fit)
算法,它为那些常用大小的空闲区维护单独的链表。例如,有一个 n 项的表,该表的第一项是指向大小为 4 KB 的空闲区链表表头指针,第二项是指向大小为 8 KB 的空闲区链表表头指针,第三项是指向大小为 12 KB 的空闲区链表表头指针,以此类推。比如 21 KB 这样的空闲区既可以放在 20 KB 的链表中,也可以放在一个专门存放大小比较特别的空闲区链表中。
快速匹配算法寻找一个指定代销的空闲区也是十分快速的,但它和所有将空闲区按大小排序的方案一样,都有一个共同的缺点,即在一个进程终止或被换出时,寻找它的相邻块并查看是否可以合并的过程都是非常耗时的。如果不进行合并,内存将会很快分裂出大量进程无法利用的小空闲区。
虚拟内存
尽管基址寄存器和变址寄存器用来创建地址空间的抽象,但是这有一个其他的问题需要解决:管理软件的不断增大(managing bloatware)
。虽然内存的大小增长迅速,但是软件的大小增长的要比内存还要快。在 1980 年的时候,许多大学用一台 4 MB 的 VAX 计算机运行分时操作系统,供十几个用户同时运行。现在微软公司推荐的 64 位 Windows 8 系统至少需要 2 GB 内存,而许多多媒体的潮流则进一步推动了对内存的需求。
这一发展的结果是,需要运行的程序往往大到内存无法容纳,而且必然需要系统能够支持多个程序同时运行,即使内存可以满足其中单独一个程序的需求,但是从总体上来看内存仍然满足不了日益增长的软件的需求(感觉和xxx和xxx 的矛盾很相似)。而交换技术并不是一个很有效的方案,在一些中小应用程序尚可使用交换,如果应用程序过大,难道还要每次交换几 GB 的内存?这显然是不合适的,一个典型的 SATA
磁盘的峰值传输速度高达几百兆/秒,这意味着需要好几秒才能换出或者换入一个 1 GB 的程序。
SATA(Serial ATA)硬盘,又称串口硬盘,是未来 PC 机硬盘的趋势,已基本取代了传统的 PATA 硬盘。
那么还有没有一种有效的方式来应对呢?有,那就是使用 虚拟内存(virtual memory)
,虚拟内存的基本思想是,每个程序都有自己的地址空间,这个地址空间被划分为多个称为页面(page)
的块。每一页都是连续的地址范围。这些页被映射到物理内存,但并不是所有的页都必须在内存中才能运行程序。当程序引用到一部分在物理内存中的地址空间时,硬件会立刻执行必要的映射。当程序引用到一部分不在物理内存中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失败的指令。
在某种意义上来说,虚拟地址是对基址寄存器和变址寄存器的一种概述。8088 有分离的基址寄存器(但不是变址寄存器)用于放入 text 和 data 。
使用虚拟内存,可以将整个地址空间以很小的单位映射到物理内存中,而不是仅仅针对 text 和 data 区进行重定位。下面我们会探讨虚拟内存是如何实现的。
虚拟内存很适合在多道程序设计系统中使用,许多程序的片段同时保存在内存中,当一个程序等待它的一部分读入内存时,可以把 CPU 交给另一个进程使用。
分页
大部分使用虚拟内存的系统中都会使用一种 分页(paging)
技术。在任何一台计算机上,程序会引用使用一组内存地址。当程序执行
MOV REG,1000
这条指令时,它会把内存地址为 1000 的内存单元的内容复制到 REG 中(或者相反,这取决于计算机)。地址可以通过索引、基址寄存器、段寄存器或其他方式产生。
这些程序生成的地址被称为 虚拟地址(virtual addresses)
并形成虚拟地址空间(virtual address space)
,在没有虚拟内存的计算机上,系统直接将虚拟地址送到内存中线上,读写操作都使用同样地址的物理内存。在使用虚拟内存时,虚拟地址不会直接发送到内存总线上。相反,会使用 MMU(Memory Management Unit)
内存管理单元把虚拟地址映射为物理内存地址,像下图这样
下面这幅图展示了这种映射是如何工作的
页表给出虚拟地址与物理内存地址之间的映射关系。每一页起始于 4096 的倍数位置,结束于 4095 的位置,所以 4K 到 8K 实际为 4096 – 8191 ,8K – 12K 就是 8192 – 12287
在这个例子中,我们可能有一个 16 位地址的计算机,地址从 0 – 64 K – 1,这些是虚拟地址
。然而只有 32 KB 的物理地址。所以虽然可以编写 64 KB 的程序,但是程序无法全部调入内存运行,在磁盘上必须有一个最多 64 KB 的程序核心映像的完整副本,以保证程序片段在需要时被调入内存。
存在映射的页如何映射
虚拟地址空间由固定大小的单元组成,这种固定大小的单元称为 页(pages)
。而相对的,物理内存中也有固定大小的物理单元,称为 页框(page frames)
。页和页框的大小一样。在上面这个例子中,页的大小为 4KB ,但是实际的使用过程中页的大小范围可能是 512 字节 – 1G 字节的大小。对应于 64 KB 的虚拟地址空间和 32 KB 的物理内存,可得到 16 个虚拟页面和 8 个页框。RAM 和磁盘之间的交换总是以整个页为单元进行交换的。
程序试图访问地址时,例如执行下面这条指令
MOV REG, 0
会将虚拟地址 0 送到 MMU。MMU 看到虚拟地址落在页面 0 (0 – 4095),根据其映射结果,这一页面对应的页框 2 (8192 – 12287),因此 MMU 把地址变换为 8192 ,并把地址 8192 送到总线上。内存对 MMU 一无所知,它只看到一个对 8192 地址的读写请求并执行它。MMU 从而有效的把所有虚拟地址 0 – 4095 映射到了 8192 – 12287 的物理地址。同样的,指令
MOV REG, 8192
也被有效的转换为
MOV REG, 24576
虚拟地址 8192(在虚拟页 2 中)被映射到物理地址 24576(在物理页框 6 中)上。
通过恰当的设置 MMU,可以把 16 个虚拟页面映射到 8 个页框中的任何一个。但是这并没有解决虚拟地址空间比物理内存大的问题。
上图中有 8 个物理页框,于是只有 8 个虚拟页被映射到了物理内存中,在上图中用 X
号表示的其他页面没有被映射。在实际的硬件中,会使用一个 在/不在(Present/absent bit)
位记录页面在内存中的实际存在情况。
未映射的页如何映射
当程序访问一个未映射的页面,如执行指令
MOV REG, 32780
将会发生什么情况呢?虚拟页面 8 (从 32768 开始)的第 12 个字节所对应的物理地址是什么?MMU 注意到该页面没有被映射(在图中用 X 号表示),于是 CPU 会陷入(trap)
到操作系统中。这个陷入称为 缺页中断(page fault)
或者是 缺页错误
。操作系统会选择一个很少使用的页并把它的内容写入磁盘(如果它不在磁盘上)。随后把需要访问的页面读到刚才回收的页框中,修改映射关系,然后重新启动引起陷入的指令。有点不太好理解,举个例子来看一下。
例如,如果操作系统决定放弃页框 1,那么它将把虚拟机页面 8 装入物理地址 4096,并对 MMU 映射做两处修改。首先,它要将虚拟页中的 1 表项标记为未映射,使以后任何对虚拟地址 4096 – 8191 的访问都将导致陷入。随后把虚拟页面 8 的表项的叉号改为 1,因此在引起陷阱的指令重新启动时,它将把虚拟地址 32780 映射为物理地址(4096 + 12)。
下面查看一下 MMU 的内部构造以便了解它们是如何工作的,以及了解为什么我们选用的页大小都是 2 的整数次幂。下图我们可以看到一个虚拟地址的例子
虚拟地址 8196 (二进制 0010000000000100)用上面的页表映射图所示的 MMU 映射机制进行映射,输入的 16 位虚拟地址被分为 4 位的页号和 12 位的偏移量。4 位的页号可以表示 16 个页面,12 位的偏移可以为一页内的全部 4096 个字节。
可用页号作为页表(page table)
的索引,以得出对应于该虚拟页面的页框号。如果在/不在
位则是 0 ,则引起一个操作系统陷入。如果该位是 1,则将在页表中查到的页框号复制到输出寄存器的高 3 位中,再加上输入虚拟地址中的低 12 位偏移量。如此就构成了 15 位的物理地址。输出寄存器的内容随即被作为物理地址送到总线。
页表
在上面这个简单的例子中,虚拟地址到物理地址的映射可以总结如下:虚拟地址被分为虚拟页号(高位部分)
和偏移量(低位部分)
。例如,对于 16 位地址和 4 KB 的页面大小,高 4 位可以指定 16 个虚拟页面中的一页,而低 12 位接着确定了所选页面中的偏移量(0-4095)。
虚拟页号可作为页表的索引用来找到虚拟页中的内容。由页表项可以找到页框号(如果有的话)。然后把页框号拼接到偏移量的高位端,以替换掉虚拟页号,形成物理地址。
因此,页表的目的是把虚拟页映射到页框中。从数学上说,页表是一个函数,它的参数是虚拟页号,结果是物理页框号。
通过这个函数可以把虚拟地址中的虚拟页转换为页框,从而形成物理地址。
页表项的结构
下面我们探讨一下页表项的具体结构,上面你知道了页表项的大致构成,是由页框号和在/不在位构成的,现在我们来具体探讨一下页表项的构成
页表项的结构是与机器相关的,但是不同机器上的页表项大致相同。上面是一个页表项的构成,不同计算机的页表项可能不同,但是一般来说都是 32 位的。页表项中最重要的字段就是页框号(Page frame number)
。毕竟,页表到页框最重要的一步操作就是要把此值映射过去。下一个比较重要的就是在/不在
位,如果此位上的值是 1,那么页表项是有效的并且能够被使用
。如果此值是 0 的话,则表示该页表项对应的虚拟页面不在
内存中,访问该页面会引起一个缺页异常(page fault)
。
保护位(Protection)
告诉我们哪一种访问是允许的,啥意思呢?最简单的表示形式是这个域只有一位,0 表示可读可写,1 表示的是只读。
修改位(Modified)
和 访问位(Referenced)
会跟踪页面的使用情况。当一个页面被写入时,硬件会自动的设置修改位。修改位在页面重新分配页框时很有用。如果一个页面已经被修改过(即它是 脏
的),则必须把它写回磁盘。如果一个页面没有被修改过(即它是 干净
的),那么重新分配时这个页框会被直接丢弃,因为磁盘上的副本仍然是有效的。这个位有时也叫做 脏位(dirty bit)
,因为它反映了页面的状态。
访问位(Referenced)
在页面被访问时被设置,不管是读还是写。这个值能够帮助操作系统在发生缺页中断时选择要淘汰的页。不再使用的页要比正在使用的页更适合被淘汰。这个位在后面要讨论的页面置换
算法中作用很大。
最后一位用于禁止该页面被高速缓存,这个功能对于映射到设备寄存器还是内存中起到了关键作用。通过这一位可以禁用高速缓存。具有独立的 I/O 空间而不是用内存映射 I/O 的机器来说,并不需要这一位。
在深入讨论下面问题之前,需要强调一下:虚拟内存本质上是用来创造一个地址空间的抽象,可以把它理解成为进程是对 CPU 的抽象,虚拟内存的实现,本质是将虚拟地址空间分解成页,并将每一项映射到物理内存的某个页框。因为我们的重点是如何管理这个虚拟内存的抽象。
加速分页过程
到现在我们已经虚拟内存(virtual memory)
和 分页(paging)
的基础,现在我们可以把目光放在具体的实现上面了。在任何带有分页的系统中,都会需要面临下面这两个主要问题:
- 虚拟地址到物理地址的映射速度必须要快
- 如果虚拟地址空间足够大,那么页表也会足够大
第一个问题是由于每次访问内存都需要进行虚拟地址到物理地址的映射,所有的指令最终都来自于内存,并且很多指令也会访问内存中的操作数。
操作数:操作数是计算机指令中的一个组成部分,它规定了指令中进行数字运算的量 。操作数指出指令执行的操作所需要数据的来源。操作数是汇编指令的一个字段。比如,MOV、ADD 等。
因此,每条指令可能会多次访问页表,如果执行一条指令需要 1 ns,那么页表查询需要在 0.2 ns 之内完成,以避免映射成为一个主要性能瓶颈。
第二个问题是所有的现代操作系统都会使用至少 32 位的虚拟地址,并且 64 位正在变得越来越普遍。假设页大小为 4 KB,32 位的地址空间将近有 100 万页,而 64 位地址空间简直多到无法想象。
对大而且快速的页映射的需要成为构建计算机的一个非常重要的约束。就像上面页表中的图一样,每一个表项对应一个虚拟页面,虚拟页号作为索引。在启动一个进程时,操作系统会把保存在内存中进程页表读副本放入寄存器中。
最后一句话是不是不好理解?还记得页表是什么吗?它是虚拟地址到内存地址的映射页表。页表是虚拟地址转换的关键组成部分,它是访问内存中数据所必需的。在进程启动时,执行很多次虚拟地址到物理地址的转换,会把物理地址的副本从内存中读入到寄存器中,再执行这一转换过程。
所以,在进程的运行过程中,不必再为页表而访问内存。使用这种方法的优势是简单而且映射过程中不需要访问内存
。缺点是 页表太大时,代价高昂
,而且每次上下文切换的时候都必须装载整个页表
,这样会造成性能的降低。鉴于此,我们讨论一下加速分页机制和处理大的虚拟地址空间的实现方案
转换检测缓冲区
我们首先先来一起探讨一下加速分页的问题。大部分优化方案都是从内存中的页表开始的。这种设计对效率有着巨大的影响。考虑一下,例如,假设一条 1 字节的指令要把一个寄存器中的数据复制到另一个寄存器。在不分页的情况下,这条指令只访问一次内存,即从内存取出指令。有了分页机制后,会因为要访问页表而需要更多的内存访问。由于执行速度通常被 CPU 从内存中取指令和数据的速度所限制,这样的话,两次访问才能实现一次的访问效果,所以内存访问的性能会下降一半。在这种情况下,根本不会采用分页机制。
什么是 1 字节的指令?我们以 8085 微处理器为例来说明一下,在 8085 微处理中,一共有 3 种字节指令,它们分别是
1-byte(1 字节)
、2-byte(2 字节)
、3-byte(3 字节)
,我们分别来说一下1-byte:1 字节的操作数和操作码共同以 1 字节表示;操作数是内部寄存器,并被编码到指令中;指令需要一个存储位置来将单个寄存器存储在存储位置中。没有操作数的指令也是 1-byte 指令。
例如:MOV B,C 、LDAX B、NOP、HLT(这块不明白的读者可以自行查阅)
2-byte: 2 字节包括:第一个字节指定的操作码;第二个字节指定操作数;指令需要两个存储器位置才能存储在存储器中。
例如 MVI B, 26 H、IN 56 H
3-byte: 在 3 字节指令中,第一个字节指定操作码;后面两个字节指定 16 位的地址;第二个字节保存
低位
地址;第三个字节保存高位
地址。指令需要三个存储器位置才能将单个字节存储在存储器中。例如 LDA 2050 H、JMP 2085 H
大多数程序总是对少量页面进行多次访问,而不是对大量页面进行少量访问。因此,只有很少的页面能够被再次访问,而其他的页表项很少被访问。
页表项一般也被称为
Page Table Entry(PTE)
。
基于这种设想,提出了一种方案,即从硬件方面来解决这个问题,为计算机设置一个小型的硬件设备,能够将虚拟地址直接映射到物理地址,而不必再访问页表。这种设备被称为转换检测缓冲区(Translation Lookaside Buffer, TLB)
,有时又被称为 相联存储器(associate memory)
。
有效位 | 虚拟页面号 | 修改位 | 保护位 | 页框号 |
---|---|---|---|---|
1 | 140 | 1 | RW | 31 |
1 | 20 | 0 | R X | 38 |
1 | 130 | 1 | RW | 29 |
1 | 129 | 1 | RW | 62 |
1 | 19 | 0 | R X | 50 |
1 | 21 | 0 | R X | 45 |
1 | 860 | 1 | RW | 14 |
1 | 861 | 1 | RW | 75 |
TLB 加速分页
TLB 通常位于 MMU 中,包含少量的表项,每个表项都记录了页面的相关信息,除了虚拟页号外,其他表项都和页表是一一对应的
是不是你到现在还是有点不理解什么是 TLB,TLB 其实就是一种内存缓存,用于减少访问内存所需要的时间,它就是 MMU 的一部分,TLB 会将虚拟地址到物理地址的转换存储起来,通常可以称为地址翻译缓存(address-translation cache)
。TLB 通常位于 CPU 和 CPU 缓存之间,它与 CPU 缓存是不同的缓存级别。下面我们来看一下 TLB 是如何工作的。
当一个 MMU 中的虚拟地址需要进行转换时,硬件首先检查虚拟页号与 TLB 中所有表项进行并行匹配,判断虚拟页是否在 TLB 中。如果找到了有效匹配项,并且要进行的访问操作没有违反保护位的话,则将页框号直接从 TLB 中取出而不用再直接访问页表。如果虚拟页在 TLB 中但是违反了保护位的权限的话(比如只允许读但是是一个写指令),则会生成一个保护错误(protection fault)
返回。
上面探讨的是虚拟地址在 TLB 中的情况,那么如果虚拟地址不再 TLB 中该怎么办?如果 MMU 检测到没有有效的匹配项,就会进行正常的页表查找,然后从 TLB 中逐出一个表项然后把从页表中找到的项放在 TLB 中。当一个表项被从 TLB 中清除出,将修改位复制到内存中页表项,除了访问位之外,其他位保持不变。当页表项从页表装入 TLB 中时,所有的值都来自于内存。
软件 TLB 管理
直到现在,我们假设每台电脑都有可以被硬件识别的页表,外加一个 TLB。在这个设计中,TLB 管理和处理 TLB 错误完全由硬件来完成。仅仅当页面不在内存中时,才会发生操作系统的陷入(trap)
。
在以前,我们上面的假设通常是正确的。但是,许多现代的 RISC
机器,包括 SPARC、MIPS 和 HP PA,几乎所有的页面管理都是在软件中完成的。
精简指令集计算机或 RISC 是一种计算机指令集,它使计算机的微处理器的每条指令(CPI)周期比复杂指令集计算机(CISC)少
在这些计算机上,TLB 条目由操作系统显示加载。当发生 TLB 访问丢失时,不再是由 MMU 到页表中查找并取出需要的页表项,而是生成一个 TLB 失效并将问题交给操作系统解决。操作系统必须找到该页,把它从 TLB 中移除(移除页表中的一项),然后把新找到的页放在 TLB 中,最后再执行先前出错的指令。然而,所有这些操作都必须通过少量指令完成,因为 TLB 丢失的发生率要比出错率高很多。
无论是用硬件还是用软件来处理 TLB 失效,常见的方式都是找到页表并执行索引操作以定位到将要访问的页面,在软件中进行搜索的问题是保存页表的页可能不在 TLB 中,这将在处理过程中导致其他 TLB 错误。改善方法是可以在内存中的固定位置维护一个大的 TLB 表项的高速缓存来减少 TLB 失效。通过首先检查软件的高速缓存,操作系统
能够有效的减少 TLB 失效问题。
TLB 软件管理会有两种 TLB 失效问题,当一个页访问在内存中而不在 TLB 中时,将产生 软失效(soft miss)
,那么此时要做的就是把页表更新到 TLB 中(我们上面探讨的过程),而不会产生磁盘 I/O,处理仅仅需要一些机器指令在几纳秒的时间内完成。然而,当页本身不在内存中时,将会产生硬失效(hard miss)
,那么此时就需要从磁盘中进行页表提取,硬失效的处理时间通常是软失效的百万倍。在页表结构中查找映射的过程称为 页表遍历(page table walk)
。
上面的这两种情况都是理想情况下出现的现象,但是在实际应用过程中情况会更加复杂,未命中的情况可能既不是硬失效又不是软失效。一些未命中可能更软
或更硬
(偷笑)。比如,如果页表遍历的过程中没有找到所需要的页,那么此时会出现三种情况:
- 所需的页面就在内存中,但是却没有记录在进程的页表中,这种情况可能是由其他进程从磁盘掉入内存,这种情况只需要把页正确映射就可以了,而不需要在从硬盘调入,这是一种软失效,称为
次要缺页错误(minor page fault)
。 - 基于上述情况,如果需要从硬盘直接调入页面,这就是
严重缺页错误(major page falut)
。 - 还有一种情况是,程序可能访问了一个非法地址,根本无需向 TLB 中增加映射。此时,操作系统会报告一个
段错误(segmentation fault)
来终止程序。只有第三种缺页属于程序错误,其他缺页情况都会被硬件或操作系统以降低程序性能为代价来修复
针对大内存的页表
还记得我们讨论的是什么问题吗?(捂脸),可能讨论的太多你有所不知道了,我再提醒你一下,上面加速分页
过程讨论的是虚拟地址到物理地址的映射速度必须要快的问题,还有一个问题是 如果虚拟地址空间足够大,那么页表也会足够大的问题,如何处理巨大的虚拟地址空间,下面展开我们的讨论。
多级页表
第一种方案是使用多级页表(multi)
,下面是一个例子
32 位的虚拟地址被划分为 10 位的 PT1 域,10 位的 PT2 域,还有 12 位的 Offset 域。因为偏移量是 12 位,所以页面大小是 4KB,公有 2^20 次方个页面。
引入多级页表的原因是避免把全部页表一直保存在内存中。不需要的页表就不应该保留。
多级页表是一种分页方案,它由两个或多个层次的分页表组成,也称为分层分页。级别1(level 1)页面表的条目是指向级别 2(level 2) 页面表的指针,级别2页面表的条目是指向级别 3(level 3) 页面表的指针,依此类推。最后一级页表存储的是实际的信息。
下面是一个二级页表的工作过程
在最左边是顶级页表,它有 1024 个表项,对应于 10 位的 PT1 域。当一个虚拟地址被送到 MMU 时,MMU 首先提取 PT1 域并把该值作为访问顶级页表的索引。因为整个 4 GB (即 32 位)虚拟地址已经按 4 KB 大小分块,所以顶级页表中的 1024 个表项的每一个都表示 4M 的块地址范围。
由索引顶级页表得到的表项中含有二级页表的地址或页框号。顶级页表的表项 0 指向程序正文的页表,表项 1 指向含有数据的页表,表项 1023 指向堆栈的页表,其他的项(用阴影表示)
表示没有使用。现在把 PT2 域作为访问选定的二级页表的索引,以便找到虚拟页面的对应页框号。
倒排页表
针对分页层级结构中不断增加的替代方法是使用 倒排页表(inverted page tables)
。采用这种解决方案的有 PowerPC、UltraSPARC 和 Itanium。在这种设计中,实际内存中的每个页框对应一个表项,而不是每个虚拟页面对应一个表项。
虽然倒排页表节省了大量的空间,但是它也有自己的缺陷:那就是从虚拟地址到物理地址的转换会变得很困难。当进程 n 访问虚拟页面 p 时,硬件不能再通过把 p 当作指向页表的一个索引来查找物理页。而是必须搜索整个倒排表来查找某个表项。另外,搜索必须对每一个内存访问操作都执行一次,而不是在发生缺页中断时执行。
解决这一问题的方式时使用 TLB。当发生 TLB 失效时,需要用软件搜索整个倒排页表。一个可行的方式是建立一个散列表,用虚拟地址来散列。当前所有内存中的具有相同散列值的虚拟页面被链接在一起。如下图所示
如果散列表中的槽数与机器中物理页面数一样多,那么散列表的冲突链的长度将会是 1 个表项的长度,这将会大大提高映射速度。一旦页框被找到,新的(虚拟页号,物理页框号)就会被装在到 TLB 中。
页面置换算法
当发生缺页异常时,操作系统会选择一个页面进行换出从而为新进来的页面腾出空间。如果要换出的页面在内存中已经被修改
,那么必须将其写到磁盘中以使磁盘副本保持最新状态
。如果页面没有被修改过,并且磁盘中的副本也已经是最新的,那么就不需要
进行重写。那么就直接使用调入的页面覆盖需要移除的页面就可以了。
当发生缺页中断时,虽然可以随机的选择一个页面进行置换,但是如果每次都选择一个不常用的页面会提升系统的性能。如果一个经常使用的页面被换出,那么这个页面在短时间内又可能被重复使用,那么就可能会造成额外的性能开销。在关于页面的主题上有很多页面置换算法(page replacement algorithms)
,这些已经从理论上和实践上得到了证明。
需要指出的是,页面置换
问题在计算机的其他领域中也会出现。例如,多数计算机把最近使用过的 32 字节或者 64 字节的存储块保存在一个或多个高速缓存中。当缓存满的时候,一些块就被选择和移除。这些块的移除除了花费时间较短外,这个问题同页面置换问题完全一样。之所以花费时间较短,是因为丢掉的高速缓存可以从内存
中获取,而内存没有寻找磁道的时间也不存在旋转延迟。
第二个例子是 Web 服务器。服务器会在内存中缓存一些经常使用到的 Web 页面。然而,当缓存满了并且已经引用了新的页面,那么必须决定退出哪个 Web 页面。在高速缓存中的 Web 页面不会被修改。因此磁盘中的 Web 页面经常是最新的,同样的考虑也适用在虚拟内存中。在虚拟系统中,内存中的页面可能会修改也可能不会修改。
下面我们就来探讨一下有哪些页面置换算法。
最优页面置换算法
最优的页面置换算法很容易描述但在实际情况下很难实现。它的工作流程如下:在缺页中断发生时,这些页面之一将在下一条指令(包含该指令的页面)上被引用。其他页面则可能要到 10、100 或者 1000 条指令后才会被访问。每个页面都可以用在该页首次被访问前所要执行的指令数作为标记。
最优化的页面算法表明应该标记最大的页面。如果一个页面在 800 万条指令内不会被使用,另外一个页面在 600 万条指令内不会被使用,则置换前一个页面,从而把需要调入这个页面而发生的缺页中断推迟。计算机也像人类一样,会把不愿意做的事情尽可能的往后拖。
这个算法最大的问题时无法实现。当缺页中断发生时,操作系统无法知道各个页面的下一次将在什么时候被访问。这种算法在实际过程中根本不会使用。
最近未使用页面置换算法
为了能够让操作系统收集页面使用信息,大部分使用虚拟地址的计算机都有两个状态位,R 和 M,来和每个页面进行关联。每当引用页面(读入或写入)时都设置 R,写入(即修改)页面时设置 M,这些位包含在每个页表项中,就像下面所示
因为每次访问时都会更新这些位,因此由硬件
来设置它们非常重要。一旦某个位被设置为 1,就会一直保持 1 直到操作系统下次来修改此位。
如果硬件没有这些位,那么可以使用操作系统的缺页中断
和时钟中断
机制来进行模拟。当启动一个进程时,将其所有的页面都标记为不在内存
;一旦访问任何一个页面就会引发一次缺页中断,此时操作系统就可以设置 R 位(在它的内部表中)
,修改页表项使其指向正确的页面,并设置为 READ ONLY
模式,然后重新启动引起缺页中断的指令。如果页面随后被修改,就会发生另一个缺页异常。从而允许操作系统设置 M 位并把页面的模式设置为 READ/WRITE
。
可以用 R 位和 M 位来构造一个简单的页面置换算法:当启动一个进程时,操作系统将其所有页面的两个位都设置为 0。R 位定期的被清零(在每个时钟中断)。用来将最近未引用的页面和已引用的页面分开。
当出现缺页中断后,操作系统会检查所有的页面,并根据它们的 R 位和 M 位将当前值分为四类:
- 第 0 类:没有引用 R,没有修改 M
- 第 1 类:没有引用 R,已修改 M
- 第 2 类:引用 R ,没有修改 M
- 第 3 类:已被访问 R,已被修改 M
尽管看起来好像无法实现第一类页面,但是当第三类页面的 R 位被时钟中断清除时,它们就会发生。时钟中断不会清除 M 位,因为需要这个信息才能知道是否写回磁盘中。清除 R 但不清除 M 会导致出现一类页面。
NRU(Not Recently Used)
算法从编号最小的非空类中随机删除一个页面。此算法隐含的思想是,在一个时钟内(约 20 ms)淘汰一个已修改但是没有被访问的页面要比一个大量引用的未修改页面好,NRU 的主要优点是易于理解并且能够有效的实现。
先进先出页面置换算法
另一种开销较小的方式是使用 FIFO(First-In,First-Out)
算法,这种类型的数据结构也适用在页面置换算法中。由操作系统维护一个所有在当前内存中的页面的链表,最早进入的放在表头,最新进入的页面放在表尾。在发生缺页异常时,会把头部的页移除并且把新的页添加到表尾。
还记得缺页异常什么时候发生吗?我们知道应用程序访问内存会进行虚拟地址到物理地址的映射,缺页异常就发生在虚拟地址无法映射到物理地址的时候。因为实际的物理地址要比虚拟地址小很多(参考上面的虚拟地址和物理地址映射图),所以缺页经常会发生。
先进先出页面可能是最简单的页面替换算法了。在这种算法中,操作系统会跟踪链表中内存中的所有页。下面我们举个例子看一下(这个算法我刚开始看的时候有点懵逼,后来才看懂,我还是很菜)
- 初始化的时候,没有任何页面,所以第一次的时候会检查页面 1 是否位于链表中,没有在链表中,那么就是
MISS
,页面1 进入链表,链表的先进先出的方向如图所示。 - 类似的,第二次会先检查页面 2 是否位于链表中,没有在链表中,那么页面 2 进入链表,状态为
MISS
,依次类推。 - 我们来看第四次,此时的链表为
1 2 3
,第四次会检查页面2
是否位于链表中,经过检索后,发现 2 在链表中,那么状态就是HIT
,并不会再进行入队和出队操作,第五次也是一样的。 - 下面来看第六次,此时的链表还是
1 2 3
,因为之前没有执行进入链表操作,页面5
会首先进行检查,发现链表中没有页面 5 ,则执行页面 5 的进入链表操作,页面 2 执行出链表的操作,执行完成后的链表顺序为2 3 5
。
第二次机会页面置换算法
我们上面学到的 FIFO 链表页面有个缺陷
,那就是出链和入链并不会进行 check 检查
,这样就会容易把经常使用的页面置换出去,为了避免这一问题,我们对该算法做一个简单的修改:我们检查最老页面的 R 位
,如果是 0 ,那么这个页面就是最老的而且没有被使用,那么这个页面就会被立刻换出。如果 R 位是 1,那么就清除此位,此页面会被放在链表的尾部,修改它的装入时间就像刚放进来的一样。然后继续搜索。
这种算法叫做 第二次机会(second chance)
算法,就像下面这样,我们看到页面 A 到 H 保留在链表中,并按到达内存的时间排序。
a)按照先进先出的方法排列的页面;b)在时刻 20 处发生缺页异常中断并且 A 的 R 位已经设置时的页面链表。
假设缺页异常发生在时刻 20 处,这时最老的页面是 A ,它是在 0 时刻到达的。如果 A 的 R 位是 0,那么它将被淘汰出内存,或者把它写回磁盘(如果它已经被修改过),或者只是简单的放弃(如果它是未被修改过)。另一方面,如果它的 R 位已经设置了,则将 A 放到链表的尾部并且重新设置装入时间
为当前时刻(20 处),然后清除 R 位。然后从 B 页面开始继续搜索合适的页面。
寻找第二次机会的是在最近的时钟间隔中未被访问过的页面。如果所有的页面都被访问过,该算法就会被简化为单纯的 FIFO 算法
。具体来说,假设图 a 中所有页面都设置了 R 位。操作系统将页面依次移到链表末尾,每次都在添加到末尾时清除 R 位。最后,算法又会回到页面 A,此时的 R 位已经被清除,那么页面 A 就会被执行出链处理,因此算法能够正常结束。
时钟页面置换算法
即使上面提到的第二次页面置换算法也是一种比较合理的算法,但它经常要在链表中移动页面,既降低了效率,而且这种算法也不是必须的。一种比较好的方式是把所有的页面都保存在一个类似钟面的环形链表中,一个表针指向最老的页面。如下图所示
当缺页错误出现时,算法首先检查表针指向的页面,如果它的 R 位是 0 就淘汰该页面,并把新的页面插入到这个位置,然后把表针向前移动一位;如果 R 位是 1 就清除 R 位并把表针前移一个位置。重复这个过程直到找到了一个 R 位为 0 的页面位置。了解这个算法的工作方式,就明白为什么它被称为 时钟(clokc)
算法了。
最近最少使用页面置换算法
最近最少使用页面置换算法的一个解释会是下面这样:在前面几条指令中频繁使用的页面和可能在后面的几条指令中被使用。反过来说,已经很久没有使用的页面有可能在未来一段时间内仍不会被使用。这个思想揭示了一个可以实现的算法:在缺页中断时,置换未使用时间最长的页面。这个策略称为 LRU(Least Recently Used)
,最近最少使用页面置换算法。
虽然 LRU 在理论上是可以实现的,但是从长远看来代价比较高。为了完全实现 LRU,会在内存中维护一个所有页面的链表,最频繁使用的页位于表头,最近最少使用的页位于表尾。困难的是在每次内存引用时更新整个链表。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常耗时的操作,即使使用硬件
来实现也是一样的费时。
然而,还有其他方法可以通过硬件实现 LRU。让我们首先考虑最简单的方式。这个方法要求硬件有一个 64 位的计数器,它在每条指令执行完成后自动加 1,每个页表必须有一个足够容纳这个计数器值的域。在每次访问内存后,将当前的值保存到被访问页面的页表项中。一旦发生缺页异常,操作系统就检查所有页表项中计数器的值,找到值最小的一个页面,这个页面就是最少使用的页面。
用软件模拟 LRU
尽管上面的 LRU 算法在原则上是可以实现的,但是很少有机器能够拥有那些特殊的硬件。上面是硬件的实现方式,那么现在考虑要用软件
来实现 LRU 。一种可以实现的方案是 NFU(Not Frequently Used,最不常用)
算法。它需要一个软件计数器来和每个页面关联,初始化的时候是 0 。在每个时钟中断时,操作系统会浏览内存中的所有页,会将每个页面的 R 位(0 或 1)加到它的计数器上。这个计数器大体上跟踪了各个页面访问的频繁程度。当缺页异常出现时,则置换计数器值最小的页面。
NFU 最主要的问题是它不会忘记任何东西,想一下是不是这样?例如,在一个多次(扫描)的编译器中,在第一遍扫描中频繁使用的页面会在后续的扫描中也有较高的计数。事实上,如果第一次扫描的执行时间恰好是各次扫描中最长的,那么后续遍历的页面的统计次数总会比第一次页面的统计次数小
。结果是操作系统将置换有用的页面而不是不再使用的页面。
幸运的是只需要对 NFU 做一个简单的修改就可以让它模拟 LRU,这个修改有两个步骤
- 首先,在 R 位被添加进来之前先把计数器右移一位;
- 第二步,R 位被添加到最左边的位而不是最右边的位。
修改以后的算法称为 老化(aging)
算法,下图解释了老化算法是如何工作的。
我们假设在第一个时钟周期内页面 0 – 5 的 R 位依次是 1,0,1,0,1,1,(也就是页面 0 是 1,页面 1 是 0,页面 2 是 1 这样类推)。也就是说,在 0 个时钟周期到 1 个时钟周期之间,0,2,4,5 都被引用了,从而把它们的 R 位设置为 1,剩下的设置为 0 。在相关的六个计数器被右移之后 R 位被添加到 左侧
,就像上图中的 a。剩下的四列显示了接下来的四个时钟周期内的六个计数器变化。
CPU正在以某个频率前进,该频率的周期称为
时钟滴答
或时钟周期
。一个 100Mhz 的处理器每秒将接收100,000,000个时钟滴答。
当缺页异常出现时,将置换(就是移除)
计数器值最小的页面。如果一个页面在前面 4 个时钟周期内都没有被访问过,那么它的计数器应该会有四个连续的 0 ,因此它的值肯定要比前面 3 个时钟周期内都没有被访问过的页面的计数器小。
这个算法与 LRU 算法有两个重要的区别:看一下上图中的 e
,第三列和第五列
它们在两个时钟周期内都没有被访问过,在此之前的时钟周期内都引用了两个页面。根据 LRU 算法,如果需要置换的话,那么应该在这两个页面中选择一个。那么问题来了,我萌应该选择哪个?现在的问题是我们不知道时钟周期 1 到时钟周期 2 内它们中哪个页面是后被访问到的。因为在每个时钟周期内只记录了一位,所以无法区分在一个时钟周期内哪个页面最早被引用,哪个页面是最后被引用的。因此,我们能做的就是置换页面3
,因为页面 3 在周期 0 – 1 内都没有被访问过,而页面 5 却被引用过。
LRU 与老化之前的第 2 个区别是,在老化期间,计数器具有有限数量的位(这个例子中是 8 位),这就限制了以往的访问记录。如果两个页面的计数器都是 0 ,那么我们可以随便选择一个进行置换。实际上,有可能其中一个页面的访问次数实在 9 个时钟周期以前,而另外一个页面是在 1000 个时钟周期之前,但是我们却无法看到这些。在实际过程中,如果时钟周期是 20 ms,8 位一般是够用的。所以我们经常拿 20 ms 来举例。
工作集页面置换算法
在最单纯的分页系统中,刚启动进程时,在内存中并没有页面。此时如果 CPU 尝试匹配第一条指令,就会得到一个缺页异常,使操作系统装入含有第一条指令的页面。其他的错误比如 全局变量
和 堆栈
引起的缺页异常通常会紧接着发生。一段时间以后,进程需要的大部分页面都在内存中了,此时进程开始在较少的缺页异常环境中运行。这个策略称为 请求调页(demand paging)
,因为页面是根据需要被调入的,而不是预先调入的。
在一个大的地址空间中系统的读所有的页面,将会造成很多缺页异常,因此会导致没有足够的内存来容纳这些页面。不过幸运的是,大部分进程不是这样工作的,它们都会以局部性方式(locality of reference)
来访问,这意味着在执行的任何阶段,程序只引用其中的一小部分。
一个进程当前正在使用的页面的集合称为它的 工作集(working set)
,如果整个工作集都在内存中,那么进程在运行到下一运行阶段(例如,编译器的下一遍扫面)之前,不会产生很多缺页中断。如果内存太小从而无法容纳整个工作集,那么进程的运行过程中会产生大量的缺页中断,会导致运行速度也会变得缓慢。因为通常只需要几纳秒就能执行一条指令,而通常需要十毫秒才能从磁盘上读入一个页面。如果一个程序每 10 ms 只能执行一到两条指令,那么它将需要很长时间才能运行完。如果只是执行几条指令就会产生中断,那么就称作这个程序产生了 颠簸(thrashing)
。
在多道程序的系统中,通常会把进程移到磁盘上(即从内存中移走所有的页面),这样可以让其他进程有机会占用 CPU 。有一个问题是,当进程想要再次把之前调回磁盘的页面调回内存怎么办?从技术的角度上来讲,并不需要做什么,此进程会一直产生缺页中断直到它的工作集
被调回内存。然后,每次装入一个进程需要 20、100 甚至 1000 次缺页中断,速度显然太慢了,并且由于 CPU 需要几毫秒时间处理一个缺页中断,因此由相当多的 CPU 时间也被浪费了。
因此,不少分页系统中都会设法跟踪进程的工作集,确保这些工作集在进程运行时被调入内存。这个方法叫做 工作集模式(working set model)
。它被设计用来减少缺页中断的次数的。在进程运行前首先装入工作集页面的这一个过程被称为 预先调页(prepaging)
,工作集是随着时间来变化的。
根据研究表明,大多数程序并不是均匀的访问地址空间的,而访问往往是集中于一小部分页面。一次内存访问可能会取出一条指令,也可能会取出数据,或者是存储数据。在任一时刻 t,都存在一个集合,它包含所哟欧最近 k 次内存访问所访问过的页面。这个集合 w(k,t)
就是工作集。因为最近 k = 1次访问肯定会访问最近 k > 1 次访问所访问过的页面,所以 w(k,t)
是 k 的单调递减函数。随着 k 的增大,w(k,t)
是不会无限变大的,因为程序不可能访问比所能容纳页面数量上限还多的页面。
事实上大多数应用程序只会任意访问一小部分页面集合,但是这个集合会随着时间而缓慢变化,所以为什么一开始曲线会快速上升而 k 较大时上升缓慢。为了实现工作集模型,操作系统必须跟踪哪些页面在工作集中。一个进程从它开始执行到当前所实际使用的 CPU 时间总数通常称作 当前实际运行时间
。进程的工作集可以被称为在过去的 t 秒实际运行时间中它所访问过的页面集合。
下面来简单描述一下工作集的页面置换算法,基本思路就是找出一个不在工作集中的页面并淘汰它。下面是一部分机器页表
因为只有那些在内存中的页面才可以作为候选者被淘汰,所以该算法忽略了那些不在内存中的页面。每个表项至少包含两条信息:上次使用该页面的近似时间和 R(访问)位。空白的矩形表示该算法不需要其他字段,例如页框数量、保护位、修改位。
算法的工作流程如下,假设硬件要设置 R 和 M 位。同样的,在每个时钟周期内,一个周期性的时钟中断会使软件清除 Referenced(引用)
位。在每个缺页异常,页表会被扫描以找出一个合适的页面把它置换。
随着每个页表项的处理,都需要检查 R 位。如果 R 位是 1,那么就会将当前时间写入页表项的 上次使用时间
域,表示的意思就是缺页异常发生时页面正在被使用。因为页面在当前时钟周期内被访问过,那么它应该出现在工作集中而不是被删除(假设 t 是横跨了多个时钟周期)。
如果 R 位是 0 ,那么在当前的时钟周期内这个页面没有被访问过,应该作为被删除的对象。为了查看是否应该将其删除,会计算其使用期限(当前虚拟时间 – 上次使用时间),来用这个时间和 t 进行对比。如果使用期限大于 t,那么这个页面就不再工作集中,而使用新的页面来替换它。然后继续扫描更新剩下的表项。
然而,如果 R 位是 0 但是使用期限小于等于 t,那么此页应该在工作集中。此时就会把页面临时保存起来,但是会记生存时间最长(即上次使用时间的最小值)
的页面。如果扫描完整个页表却没有找到适合被置换的页面,也就意味着所有的页面都在工作集中。在这种情况下,如果找到了一个或者多个 R = 0 的页面,就淘汰生存时间最长的页面。最坏的情况下是,在当前时钟周期内,所有的页面都被访问过了(也就是都有 R = 1),因此就随机选择一个页面淘汰,如果有的话最好选一个未被访问的页面,也就是干净的页面。
工作集时钟页面置换算法
当缺页异常发生后,需要扫描整个页表才能确定被淘汰的页面,因此基本工作集算法还是比较浪费时间的。一个对基本工作集算法的提升是基于时钟算法但是却使用工作集的信息,这种算法称为WSClock(工作集时钟)
。由于它的实现简单并且具有高性能,因此在实践中被广泛应用。
与时钟算法一样,所需的数据结构是一个以页框为元素的循环列表,就像下面这样
最初的时候,该表是空的。当装入第一个页面后,把它加载到该表中。随着更多的页面的加入,它们形成一个环形结构。每个表项包含来自基本工作集算法的上次使用时间,以及 R 位(已标明)和 M 位(未标明)。
与时钟算法一样,在每个缺页异常时,首先检查指针指向的页面。如果 R 位被是设置为 1,该页面在当前时钟周期内就被使用过,那么该页面就不适合被淘汰。然后把该页面的 R 位置为 0,指针指向下一个页面,并重复该算法。该事件序列化后的状态参见图 b。
现在考虑指针指向的页面 R = 0 时会发生什么,参见图 c,如果页面的使用期限大于 t 并且页面为被访问过,那么这个页面就不会在工作集中,并且在磁盘上会有一个此页面的副本。申请重新调入一个新的页面,并把新的页面放在其中,如图 d 所示。另一方面,如果页面被修改过,就不能重新申请页面,因为这个页面在磁盘上没有有效的副本。为了避免由于调度写磁盘操作引起的进程切换,指针继续向前走,算法继续对下一个页面进行操作。毕竟,有可能存在一个老的,没有被修改过的页面可以立即使用。
原则上来说,所有的页面都有可能因为磁盘I/O
在某个时钟周期内被调度。为了降低磁盘阻塞,需要设置一个限制,即最大只允许写回 n 个页面。一旦达到该限制,就不允许调度新的写操作。
那么就有个问题,指针会绕一圈回到原点的,如果回到原点,它的起始点会发生什么?这里有两种情况:
- 至少调度了一次写操作
- 没有调度过写操作
在第一种情况中,指针仅仅是不停的移动,寻找一个未被修改过的页面。由于已经调度了一个或者多个写操作,最终会有某个写操作完成,它的页面会被标记为未修改。置换遇到的第一个未被修改过的页面,这个页面不一定是第一个被调度写操作的页面,因为硬盘驱动程序为了优化性能可能会把写操作重排序。
对于第二种情况,所有的页面都在工作集中,否则将至少调度了一个写操作。由于缺乏额外的信息,最简单的方法就是置换一个未被修改的页面来使用,扫描中需要记录未被修改的页面的位置,如果不存在未被修改的页面,就选定当前页面并把它写回磁盘。
页面置换算法小结
我们到现在已经研究了各种页面置换算法,现在我们来一个简单的总结,算法的总结归纳如下
算法 | 注释 |
---|---|
最优算法 | 不可实现,但可以用作基准 |
NRU(最近未使用) 算法 | 和 LRU 算法很相似 |
FIFO(先进先出) 算法 | 有可能会抛弃重要的页面 |
第二次机会算法 | 比 FIFO 有较大的改善 |
时钟算法 | 实际使用 |
LRU(最近最少)算法 | 比较优秀,但是很难实现 |
NFU(最不经常食用)算法 | 和 LRU 很类似 |
老化算法 | 近似 LRU 的高效算法 |
工作集算法 | 实施起来开销很大 |
工作集时钟算法 | 比较有效的算法 |
-
最优算法
在当前页面中置换最后要访问的页面。不幸的是,没有办法来判定哪个页面是最后一个要访问的,因此实际上该算法不能使用
。然而,它可以作为衡量其他算法的标准。 -
NRU
算法根据 R 位和 M 位的状态将页面氛围四类。从编号最小的类别中随机选择一个页面。NRU 算法易于实现,但是性能不是很好。存在更好的算法。 -
FIFO
会跟踪页面加载进入内存中的顺序,并把页面放入一个链表中。有可能删除存在时间最长但是还在使用的页面,因此这个算法也不是一个很好的选择。 -
第二次机会
算法是对 FIFO 的一个修改,它会在删除页面之前检查这个页面是否仍在使用。如果页面正在使用,就会进行保留。这个改进大大提高了性能。 -
时钟
算法是第二次机会算法的另外一种实现形式,时钟算法和第二次算法的性能差不多,但是会花费更少的时间来执行算法。 -
LRU
算法是一个非常优秀的算法,但是没有特殊的硬件(TLB)
很难实现。如果没有硬件,就不能使用 LRU 算法。 -
NFU
算法是一种近似于 LRU 的算法,它的性能不是非常好。 -
老化
算法是一种更接近 LRU 算法的实现,并且可以更好的实现,因此是一个很好的选择 -
最后两种算法都使用了工作集算法。工作集算法提供了合理的性能开销,但是它的实现比较复杂。
WSClock
是另外一种变体,它不仅能够提供良好的性能,而且可以高效地实现。
总之,最好的算法是老化算法和WSClock算法。他们分别是基于 LRU 和工作集算法。他们都具有良好的性能并且能够被有效的实现。还存在其他一些好的算法,但实际上这两个可能是最重要的。
文章参考:
https://www.informit.com/articles/article.aspx?p=25260&seqNum=9
https://gerardnico.com/computer/clock/tick
https://en.wikipedia.org/wiki/Page_replacement_algorithm
http://faculty.salina.k-state.edu/tim/ossg/Memory/virt_mem/page_replace.html
https://www.geeksforgeeks.org/page-replacement-algorithms-in-operating-systems/
https://www.geeksforgeeks.org/multilevel-paging-in-operating-system/
https://en.wikipedia.org/wiki/Translation_lookaside_buffer
https://electricalvoice.com/instruction-word-size-8085-microprocessor/
https://en.wikipedia.org/wiki/Page_table
https://www.javatpoint.com/os-page-table
https://baike.baidu.com/item/内存/103614?fr=aladdin
https://baike.baidu.com/item/数据段/5136260?fromtitle=data%20segment&fromid=18082638&fr=aladdin
https://blog.csdn.net/One_L_Star/article/details/81901186
《现代操作系统》第四版
《Modern Operation System》fourth
https://baike.baidu.com/item/SATA硬盘/3947233?fr=aladdin
https://baike.baidu.com/item/虚拟地址/1329947?fr=aladdin
关于操作系统,你必备的名词
-
操作系统(Operating System,OS)
:是管理计算机硬件与软件资源的系统软件
,同时也是计算机系统的内核与基石
。操作系统需要处理管理与配置内存、决定系统资源供需的优先次序、控制输入与输出设备、操作网络与管理文件系统等基本事务。操作系统也提供一个让用户与系统交互的操作界面。 -
shell
:它是一个程序,可从键盘获取命令并将其提供给操作系统以执行。 在过去,它是类似 Unix 的系统上唯一可用的用户界面。 如今,除了命令行界面(CLI)外,我们还具有图形用户界面(··)。 -
GUI (Graphical User Interface)
:是一种用户界面
,允许用户通过图形图标和音频指示符与电子设备进行交互。 -
内核模式(kernel mode)
: 通常也被称为超级模式(supervisor mode)
,在内核模式下,正在执行的代码具有对底层硬件的完整且不受限制的访问。 它可以执行任何 CPU 指令并引用任何内存地址。 内核模式通常保留给操作系统的最低级别,最受信任的功能。 内核模式下的崩溃是灾难性的; 他们将停止整个计算机。 超级用户模式是计算机开机时选择的自动模式。 -
用户模式(user node)
:当操作系统运行用户应用程序(例如处理文本编辑器)时,系统处于用户模式。 当应用程序请求操作系统的帮助或发生中断或系统调用时,就会发生从用户模式到内核模式的转换。在用户模式下,模式位设置为1。 从用户模式切换到内核模式时,它从1更改为0。 -
计算机架构(computer architecture)
: 在计算机工程中,计算机体系结构是描述计算机系统功能,组织和实现的一组规则和方法。它主要包括指令集、内存管理、I/O 和总线结构
-
SATA(Serial ATA)
:串行 ATA (Serial Advanced Technology Attachment),它是一种电脑总线,负责主板和大容量存储设备(如硬盘及光盘驱动器)之间的数据传输,主要用于个人电脑。 -
复用(multiplexing)
:也称为共享,在操作系统中主要指示了时间和空间的管理。对资源进行复用时,不同的程序或用户轮流使用它。 他们中的第一个开始使用资源,然后再使用另一个,依此类推。 -
大型机(mainframes)
:大型机是一类计算机,通常以其大尺寸,存储量,处理能力和高度的可靠性而著称。它们主要由大型组织用于需要大量数据处理的关键任务应用程序。 -
批处理(batch system)
: 批处理操作系统的用户不直接与计算机进行交互。 每个用户都在打孔卡等脱机设备上准备工作,并将其提交给计算机操作员。 为了加快处理速度,将具有类似需求的作业一起批处理并成组运行。 程序员将程序留给操作员,然后操作员将具有类似要求的程序分批处理。 -
OS/360
: OS/360,正式称为IBM System / 360操作系统,是由 IBM 为 1964 年发布的其当时新的System/360 大型机开发的已停产的批处理操作系统。 -
多处理系统(Computer multitasking)
:是指计算机同时运行多个程序的能力。多任务的一般方法是运行第一个程序的一段代码,保存工作环境;再运行第二个程序的一段代码,保存环境;……恢复第一个程序的工作环境,执行第一个程序的下一段代码。 -
分时系统(Time-sharing)
:在计算中,分时是通过多程序和多任务同时在许多用户之间共享计算资源的一种系统 -
相容分时系统(Compatible Time-Sharing System)
:最早的分时操作系统,由美国麻省理工学院计算机中心设计与实作。 -
云计算(cloud computing)
:云计算是计算机系统资源(尤其是数据存储和计算能力)的按需可用性,而无需用户直接进行主动管理。这个术语通常用于描述 Internet 上可供许多用户使用的数据中心。 如今占主导地位的大型云通常具有从中央服务器分布在多个位置的功能。 如果与用户的连接相对较近,则可以将其指定为边缘服务器。 -
UNIX 操作系统
:UNIX 操作系统,是一个强大的多用户、多任务操作系统,支持多种处理器架构,按照操作系统的分类,属于分时操作系统。 -
UNIX System V
:是 UNIX 操作系统的一个分支。 -
BSD(Berkeley Software Distribution)
:UNIX 的衍生系统。 -
POSIX
:可移植操作系统接口,是 IEEE 为要在各种 UNIX 操作系统上运行软件,而定义API的一系列互相关联的标准的总称。 -
MINIX
:Minix,是一个迷你版本的类 UNIX 操作系统。 -
Linux
:终于到了大名鼎鼎的 Linux 操作系统了,太强大了,不予以解释了,大家都懂。 -
DOS (Disk Operating System)
:磁盘操作系统(缩写为DOS)是可以使用磁盘存储设备(例如软盘,硬盘驱动器或光盘)的计算机操作系统。 -
MS-DOS(MicroSoft Disk Operating System)
:一个由美国微软公司发展的操作系统,运行在Intel x86个人电脑上。它是DOS操作系统家族中最著名的一个,在Windows 95以前,DOS是IBM PC及兼容机中的最基本配备,而MS-DOS则是个人电脑中最普遍使用的DOS操作系统。
MacOS X
,怎能少的了苹果操作系统?macOS 是苹果公司推出的基于图形用户界面操作系统,为 Macintosh 的主操作系统
-
Windows NT(Windows New Technology)
:是美国微软公司 1993 年推出的纯 32 位操作系统核心。 -
Service Pack(SP)
:是程序的更新、修复和(或)增强的集合,以一个独立的安装包的形式发布。许多公司,如微软或Autodesk,通常在为某一程序而做的修补程序达到一定数量时,就发布一个Service Pack。 -
数字版权管理(DRM)
:他是工具或技术保护措施(TPM)是一组访问控制技术,用于限制对专有硬件和受版权保护的作品的使用。 -
x86
:x86是一整套指令集体系结构,由 Intel 最初基于 Intel 8086 微处理器及其 8088 变体开发。采用内存分段作为解决方案,用于处理比普通 16 位地址可以覆盖的更多内存。32 位是 x86 默认的位数,除此之外,还有一个 x86-64 位,是x86架构的 64 位拓展,向后兼容于 16 位及 32 位的 x86架构。 -
FreeBSD
:FreeBSD 是一个类 UNIX 的操作系统,也是 FreeBSD 项目的发展成果。 -
X Window System
:X 窗口系统(X11,或简称X)是用于位图显示的窗口系统,在类 UNIX 操作系统上很常见。
Gnome
:GNOME 是一个完全由自由软件组成的桌面环境。它的目标操作系统是Linux,但是大部分的 BSD 系统亦支持 GNOME。
网络操作系统(network operating systems)
:网络操作系统是用于网络设备(如路由器,交换机或防火墙)的专用操作系统。
分布式网络系统(distributed operating systems)
:分布式操作系统是在独立,网络,通信和物理上独立计算节点的集合上的软件。 它们处理由多个CPU服务的作业。每个单独的节点都拥有全局集合操作系统的特定软件的一部分。
-
程序计数器(Program counter)
:程序计数器 是一个 CPU 中的寄存器
,用于指示计算机在其程序序列中的位置
。 -
堆栈寄存器(stack pointer)
: 堆栈寄存器是计算机 CPU 中的寄存器,其目的是跟踪调用堆栈
。 -
程序状态字(Program Status Word)
: 它是由操作系统维护的8个字节(或64位)长的数据的集合。它跟踪系统的当前状态。 -
流水线(Pipeline)
: 在计算世界中,管道是一组串联连接的数据处理元素,其中一个元素的输出是下一个元素的输入。 流水线的元素通常以并行或按时间分割的方式执行。 通常在元素之间插入一定数量的缓冲区存储。
超标量(superscalar)
: 超标量 CPU 架构是指在一颗处理器内核中实行了指令级并发的一类并发运算。这种技术能够在相同的CPU主频下实现更高的 CPU 流量。系统调用(system call)
: 指运行在用户空间的程序向操作系统内核请求需要更高权限运行的服务。系统调用提供用户程序与操作系统之间的接口。大多数系统交互式操作需求在内核态运行。如设备 IO 操作或者进程间通信。多线程(multithreading)
:是指从软件或者硬件上实现多个线程并发执行的技术。具有多线程能力的计算机因为有硬件支持而能够在同一时间执行多个线程,进而提升整体处理性能。CPU 核心(core)
:它是 CPU 的大脑,它接收指令,并执行计算或运算以满足这些指令。一个 CPU 可以有多个内核。图形处理器(Graphics Processing Unit)
:又称显示核心、视觉处理器、显示芯片或绘图芯片;它是一种专门在个人电脑、工作站、游戏机和一些移动设备(如平板电脑、智能手机等)上运行绘图运算工作的微处理器。
- 存储体系结构:顶层的存储器速度最高,但是容量最小,成本非常高,层级结构越向下,其访问效率越慢,容量越大,但是造价也就越便宜。
高速缓存行(cache lines)
:其实就是把高速缓存分割成了固定大小的块,其大小是以突发读或者突发写周期的大小为基础的。缓存命中(cache hit)
:当应用程序或软件请求数据时,会首先发生缓存命中。 首先,中央处理单元(CPU)在其最近的内存位置(通常是主缓存)中查找数据。 如果在缓存中找到请求的数据,则将其视为缓存命中。
-
L1 cache
:一级缓存是 CPU 芯片中内置的存储库。 L1缓存也称为主缓存
,是计算机中最快
的内存,并且最接近处理器。 -
L2 cache
: 二级缓存存储库,内置在 CPU 芯片中,包装在同一模块中,或者建在主板上。 L2 高速缓存提供给 L1 高速缓存,后者提供给处理器。 L2 内存比 L1 内存慢。 -
L2 cache
: 三级缓存内置在主板上或CPU模块内的存储库。 L3 高速缓存为 L2 高速缓存提供数据,其内存通常比 L2 内存慢,但比主内存快。 L3 高速缓存提供给 L2 高速缓存,后者又提供给 L1 高速缓存,后者又提供给处理器。 -
RAM((Random Access Memory)
:随机存取存储器,也叫主存,是与 CPU直接交换数据
的内部存储器。它可以随时读写,而且速度很快,通常作为操作系统或其他正在运行中的程序的临时
数据存储介质。RAM工作时可以随时从任何一个指定的地址写入(存入)或读出(取出)信息。它与 ROM 的最大区别是数据的易失性
,即一旦断电所存储的数据将随之丢失。RAM 在计算机和数字系统中用来暂时存储程序、数据和中间结果。 -
ROM (Read Only Memory)
:只读存储器是一种半导体存储器,其特性是一旦存储数据就无法改变或删除,且内容不会因为电源关闭而消失
。在电子或电脑系统中,通常用以存储不需经常变更的程序或数据。 -
EEPROM (Electrically Erasable PROM)
:电可擦除可编程只读存储器,是一种可以通过电子方式多次复写的半导体存储设备。 -
闪存(flash memory)
: 是一种电子式可清除程序化只读存储器的形式,允许在操作中被多次擦或写的存储器。这种科技主要用于一般性数据存储,以及在电脑与其他数字产品间交换传输数据,如储存卡与U盘。 -
SSD(Solid State Disks)
:固态硬盘,是一种主要以闪存作为永久性存储器的电脑存储设备。
-
虚拟地址(virtual memory)
: 虚拟内存是计算机系统内存管理
的一种机制。它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如RAM)的使用也更有效率。 -
MMU (Memory Management Unit)
:内存管理单元,有时称作分页内存管理单元。它是一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件。它的功能包括虚拟地址到物理地址的转换(即虚拟内存管理)、内存保护、中央处理器高速缓存的控制等。 -
context switch
:上下文切换,又称环境切换。是一个存储和重建 CPU 状态的机制。要交换 CPU 上的进程时,必需先行存储当前进程的状态,然后再将进程状态读回 CPU 中。 -
驱动程序(device driver)
:设备驱动程序,简称驱动程序(driver),是一个允许高级别电脑软件与硬件交互的程序,这种程序创建了一个硬件与硬件,或硬件与软件沟通的接口,经由主板上的总线或其它沟通子系统与硬件形成连接的机制,这样使得硬件设备上的数据交换成为可能。 -
忙等(busy waiting)
:在软件工程中,忙碌等待也称自旋
,是一种以进程反复检查一个条件是否为真的条件,这种机制可能为检查键盘输入或某个锁是否可用。 -
中断(Interrupt)
:通常,在接收到来自外围硬件(相对于中央处理器和内存)的异步信号,或来自软件的同步信号之后,处理器将会进行相应的硬件/软件处理。发出这样的信号称为进行中断请求(interrupt request,IRQ)
。硬件中断导致处理器通过一个运行信息切换(context switch)
来保存执行状态(以程序计数器和程序状态字等寄存器信息为主);软件中断则
通常作为 CPU 指令集中的一个指令,以可编程的方式直接指示这种运行信息切换,并将处理导向一段中断处理代码。中断在计算机多任务处理,尤其是即时系统中尤为有用。 -
中断向量(interrupt vector)
:中断向量位于中断向量表中。中断向量表(IVT)
是将中断处理程序列表与中断向量表中的中断请求列表相关联的数据结构。 中断向量表的每个条目(称为中断向量)都是中断处理程序的地址。 -
DMA (Direct Memory Access)
:直接内存访问,直接内存访问是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。 -
总线(Bus)
:总线(Bus)是指计算机组件间规范化的交换数据的方式,即以一种通用的方式为各组件提供数据传送和控制逻辑。 -
PCIe (Peripheral Component Interconnect Express)
:官方简称PCIe,是计算机总线的一个重要分支,它沿用现有的PCI编程概念及信号标准,并且构建了更加高速的串行通信系统标准。 -
DMI (Direct Media Interface)
:直接媒体接口,是英特尔专用的总线,用于电脑主板上南桥芯片和北桥芯片之间的连接。 -
USB(Universal Serial Bus)
:是连接计算机系统与外部设备的一种串口总线
标准,也是一种输入输出接口的技术规范,被广泛地应用于个人电脑和移动设备等信息通讯产品,并扩展至摄影器材、数字电视(机顶盒)、游戏机等其它相关领域。
-
BIOS(Basic Input Output System)
:是在通电引导阶段运行硬件初始化,以及为操作系统提供运行时服务的固件。它是开机时运行的第一个软件。 -
硬实时系统(hard real-time system)
:硬实时性意味着你必须绝对在每个截止日期前完成任务。 很少有系统有此要求。 例如核系统,一些医疗应用(例如起搏器),大量国防应用,航空电子设备等。 -
软实时系统(soft real-time system)
:软实时系统可能会错过某些截止日期,但是如果错过太多,最终性能将下降。 一个很好的例子是计算机中的声音系统。 -
进程(Process)
:程序本身只是指令、数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实例。若进程有可能与同一个程序相关系,且每个进程皆可以同步(循序)或异步的方式独立运行。 -
地址空间(address space)
:地址空间是内存中可供程序或进程使用的有效地址范围。 也就是说,它是程序或进程可以访问的内存。 存储器可以是物理的也可以是虚拟的,用于执行指令和存储数据。 -
进程表(process table)
:进程表是操作系统维护的数据结构
,该表中的每个条目(通常称为上下文块)均包含有关进程
的信息,例如进程名称和状态,优先级,寄存器以及它可能正在等待的信号灯。 -
命令行界面(command-line interpreter)
:是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。
进程间通信(interprocess communication)
: 指至少两个进程或线程间传送数据或信号的一些技术或方法。超级用户(superuser)
: 也被称为管理员帐户,在计算机操作系统领域中指一种用于进行系统管理的特殊用户,其在系统中的实际名称也因系统而异,如 root、administrator 与supervisor。目录(directory)
: 在计算机或相关设备中,一个目录或文件夹就是一个装有数字文件系统的虚拟容器
。在它里面保存着一组文件和其它一些目录。路径(path name)
: 路径是一种电脑文件或目录的名称的通用表现形式,它指向文件系统上的一个唯一位置。根目录(root directory)
:根目录指的就是计算机系统中的顶层目录,比如 Windows 中的 C 盘和 D 盘,Linux 中的/
。工作目录(Working directory)
:它是一个计算机用语。用户在操作系统内所在的目录,用户可在此目录之下,用相对文件名访问文件。文件描述符(file descriptor)
: 文件描述符是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。inode
:索引节点的缩写,索引节点是 UNIX 系统中包含的信息,其中包含有关每个文件的详细信息,例如节点,所有者,文件,文件位置等。共享库(shared library)
:共享库是一个包含目标代码的文件,执行过程中多个 a.out 文件可能会同时使用该目标代码。DLLs (Dynamic-Link Libraries)
:动态链接库,它是微软公司在操作系统中实现共享函数库
概念的一种实现方式。这些库函数的扩展名是 .DLL、.OCX(包含ActiveX控制的库)或者.DRV(旧式的系统驱动程序)。客户端(clients)
:客户端是访问服务器提供的服务的计算机硬件或软件。服务端(servers)
: 在计算中,服务器是为其他程序或设备提供功能的计算机程序或设备,称为服务端
主从架构(client-server)
: 主从式架构也称客户端/服务器
架构、C/S
架构,是一种网络架构,它把客户端与服务器区分开来。每一个客户端软件的实例都可以向一个服务器或应用程序服务器发出请求。有很多不同类型的服务器,例如文件服务器、游戏服务器等。
-
虚拟机(Virtual Machines)
:在计算机科学中的体系结构里,是指一种特殊的软件,可以在计算机平台和终端用户之间创建一种环境,而终端用户则是基于虚拟机这个软件所创建的环境来操作其它软件。 -
Java 虚拟机(Jaav virtual Machines)
:Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。 -
目标文件(object file)
:目标文件是包含目标代码
的文件,这意味着通常无法直接执行的可重定位格式的机器代码。 目标文件有多种格式,相同的目标代码可以打包在不同的目标文件中。 目标文件也可以像共享库一样工作。 -
C preprocessor
: C 预处理å器是 C 语言、C++ 语言的预处理器。用于在编译器处理程序之前预扫描源代码,完成头文件的包含, 宏扩展, 条件编译, 行控制等操作。 -
设备控制器(device controller)
: 设备控制器是处理 CPU 传入信号和传出信号的系统。设备通过插头和插座连接到计算机,并且插座连接到设备控制器。 -
ECC(Error-Correcting Code)
: 指能够实现错误检查和纠正错误技术的内存。 -
I/O port
: 也被称为输入/输出端口,它是由软件用来与计算机上的硬件进行通信的内存地址。 -
内存映射I/O(memory mapped I/O,MMIO)
: 内存映射的 I/O 使用相同的地址空间来寻址内存和 I/O 设备,也就是说,内存映射I/O 设备共享同一内存地址。 -
端口映射I/O(Port-mapped I/O ,PMIO)
:在 PMIO中,内存和I/O设备有各自的地址空间。 端口映射I/O通常使用一种特殊的CPU指令,专门执行I/O操作。 -
DMA (Direct Memory Access)
: 直接内存访问,它是计算机系统的一项功能,它允许某些硬件系统能够独立于 CPU 访问内存。如果没有 DMA,当 CPU 执行输入/输出指令时,它通常在读取或写入操作的整个过程中都被完全占用,因此无法执行其他工作。使用 DMA 后,CPU 首先启动传输信号,然后在进行传输时执行其他操作,最后在完成操作后从 DMA 控制器(DMAC)接收中断。完成执行。
-
周期窃取(cycle stealing)
:许多总线能够以两种模式操作:每次一字模式和块模式。一些 DMA 控制器也能够使用这两种方式进行操作。在前一个模式中,DMA 控制器请求传送一个字并得到这个字。如果 CPU 想要使用总线,它必须进行等待。设备可能会偷偷进入并且从 CPU 偷走一个总线周期,从而轻微的延迟 CPU。它类似于直接内存访问(DMA),允许I / O控制器在无需 CPU 干预的情况下读取或写入RAM。 -
突发模式(burst mode)
: 指的是设备在不进行单独事务中重复传输每个数据所需的所有步骤的情况下,重复传输数据的情况。 -
中断向量表(interrupt vector table)
: 用来形成相应的中断服务程序的入口地址或存放中断服务程序的首地址称为中断向量。 中断向量表是中断向量的集合,中断向量是中断处理程序的地址。 -
精确中断(precise interrupt)
:精确中断是一种能够使机器处于良好状态下的中断,它具有如下特征 -
PC (程序计数器)保存在一个已知的地方
- PC 所指向的指令之前所有的指令已经完全执行
- PC 所指向的指令之后所有的指令都没有执行
-
PC 所指向的指令的执行状态是已知的
-
非精确中断(imprecise interrupt)
:不满足以上要求的中断,指令的执行时序和完成度具有不确定性,而且恢复起来也非常麻烦。 设备独立性(device independence)
:我们编写访问任何设备的应用程序,不用事先指定特定的设备。比如你编写了一个能够从设备读入文件的应用程序,那么这个应用程序可以从硬盘、DVD 或者 USB 进行读入,不必再为每个设备定制应用程序。这其实就体现了设备独立性的概念。
UNC(Uniform Naming Convention)
:UNC 是统一命名约定或统一命名约定的缩写,是用于命名和访问网络资源(例如网络驱动器,打印机或服务器)的标准。 例如,在 MS-DOS 和 Microsoft Windows 中,用户可以通过键入或映射到类似于以下示例的共享名来访问共享资源。
\\computer\path
然而,在 UNIX 和 Linux 中,你会像如下这么写
//computer/path
挂载(mounting)
:挂载是指操作系统会让存储在硬盘、CD-ROM 等资源设备上的目录和文件,通过文件系统能够让用户访问的过程。错误处理(Error handling)
: 错误处理是指对软件应用程序中存在的错误情况的响应和恢复过程。同步阻塞(synchronous)
: 同步是阻塞式的,CPU 必须等待同步的处理结果。异步响应(asynchronous)
: 异步是由中断驱动的,CPU 不用等待每个操作的处理结果继而执行其他操作缓冲区(buffering)
: 缓冲区是内存的临时存储区域,它的出现是为了加快内存的访问速度而设计的。对于经常访问的数据和指令来说,CPU 应该访问的是缓冲区而非内存Programmed input–output,PIO
:它指的是在 CPU 和外围设备(例如网络适配器或 ATA 存储设备)之间传输数据的一种方法。轮询(polling)
: 轮询是指通过客户端程序主动通过对每个设备进行访问来获得同步状态的过程。
忙等(busy waiting)
:当一个进程正处在某临界区内,任何试图进入其临界区的进程都必须等待,陷入忙等状态。连续测试一个变量直到某个值出现为止,称为忙等。可重入(reentrant)
: 如果一段程序或者代码在任意时刻被中断后由操作系统调用其他程序或者代码,这段代码调用子程序并能够正确运行,这种现象就称为可重入。也就是说当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。主设备编号(major device number)、副设备编号(minor device number)
: 所有设备都有一个主,副号码。 主号码是更大,更通用的类别(例如硬盘,输入/输出设备等),而次号码则更具体(即告诉设备连接到哪条总线)。多重缓冲区(double buffering)
: 它指的是使用多个缓冲区来保存数据块,每个缓冲区都保留数据块的一部分,读取的时候通过读取多个缓冲区的数据进而拼凑成一个完整的数据。环形缓冲区(circular buffer)
: 它指的是首尾相连的缓冲区,常用来实现数据缓冲流。
假脱机(Spooling)
:假脱机是多程序的一种特殊形式,目的是在不同设备之间复制数据。 在现代系统中,通常用于计算机应用程序和慢速外围设备(例如打印机)之间的中介。守护进程(Daemon)
: 在计算机中,守护程序是作为后台进程运行的计算机程序,而不是在交互式用户的直接控制下运行的程序。逻辑块寻址(logical block addressing, LBA)
:逻辑块寻址是一种通用方案,用于指定存储在计算机存储设备上的数据块的位置。RAID
:全称是 Redundant Array of Inexpensive Disks ,廉价磁盘或驱动器的冗余阵列,它是一种数据存储虚拟化的技术,将多个物理磁盘驱动器组件组合成一个或多个逻辑单元,以实现数据冗余,改善性能。
MBR(Master Boot Record)
:主引导记录(MBR)是任何硬盘或软盘的第一扇区中的信息,用于标识操作系统的放置方式和位置,以便可以将其加载到计算机的主存储器或随机存取存储器中。
FCFS (First-Come, First-Served)
: 先进先出的调度算法,也就是说,首先到达 CPU 的进程首先进行服务。SSF (Shortest Seek First)
最短路径优先算法,这是对先进先出算法的改进,这种算法因为减少了总的磁臂运动,从而缩短了平均响应时间。稳定存储(stable storage)
: 它是计算机存储技术的一种分类,该技术可确保任何给定的写操作都具有原子性。时钟(Clocks)
:也被称为 timers。通常,时钟是指调节所有计算机功能的时序和速度的微芯片。芯片中是一个晶体,当通电时,晶体会以特定的频率振动。 任何一台计算机能够执行的最短时间是一个时钟或时钟芯片的一次振动。QR Code
: 二维码的一种,它的全称是快速响应矩阵图码,能够快速响应。一般应用于手机读码操作,国内火车票上的二维码就是 QR 码
显卡(Video card)
,是个人电脑最基本组成部分之一,用途是将计算机系统所需要的显示信息进行转换驱动显示器,并向显示器提供逐行或隔行扫描信号,控制显示器的正确显示,是连接显示器和个人电脑主板的重要组件,是人机对话
的重要设备之一。
GDI (Graphics Device Interface)
:图形接口,是微软视窗系统提供的应用程序接口,也是其用来表征图形对象、将图形对象传送给诸如显示器、打印机之类输出设备的核心组件。设备上下文(device context)
:设备上下文是 Windows 数据结构,其中包含有关设备(例如显示器或打印机)的图形属性的信息。 所有绘图调用都是通过设备上下文对象进行的,该对象封装了用于绘制线条,形状和文本的 Windows API。 设备上下文可用于绘制到屏幕,打印机或图元文件。位图(bitmap)
:在计算机中,位图是从某个域(例如,整数范围)到位的映射。也称为位数组或位图索引。电阻式触摸屏(Resistive touchscreens)
:电阻式触摸屏基于施加到屏幕上的压力来工作。 电阻屏由许多层组成。 当按下屏幕时,外部的后面板将被推到下一层,下一层会感觉到施加了压力并记录了输入。 电阻式触摸屏用途广泛,可以用手指,指甲,手写笔或任何其他物体进行操作。
电容式触摸屏(capacitive touchscreen)
:电容式触摸屏通过感应物体(通常是指尖上的皮肤)的导电特性来工作。 手机或智能手机上的电容屏通常具有玻璃表面,并且不依赖压力。 当涉及到手势(如滑动和捏合)时,它比电阻式屏幕更具响应性。 电容式触摸屏只能用手指触摸,而不能用普通的手写笔,手套或大多数其他物体来响应。
死锁(deadlock)
:死锁常用于并发情况下,死锁
是一种状态,死锁中的每个成员都在等待另一个成员(包括其自身)采取行动。
相信你一定看过这个图
可抢占资源(preemptable resource)
:可以从拥有它的进程中抢占而并不会产生任何副作用。不可抢占资源(nonpreemptable resource)
:与可抢占资源相反,如果资源被抢占后,会导致进程或任务出错。系统检查点(system checkpointed)
:系统检查点是操作系统(OS)的可启动实例。检查点是计算机在特定时间点的快照。两阶段加锁(two-phase locking, 2PL)
:经常用于数据库的并发控制,以保证可串行化
这种方法使用数据库锁在两个阶段:
-
扩张阶段:不断上锁,没有锁被释放
-
收缩阶段:锁被陆续释放,没有新的加锁
-
活锁(Livelock)
:活锁类似于死锁,不同之处在于,活锁中仅涉及进程的状态彼此之间不断变化,没有进展。举一个现实世界的例子,当两个人在狭窄的走廊里相遇时,就会发生活锁,每个人都试图通过移动到一边让对方通过而礼貌,但最终却没有任何进展就左右摇摆,因为他们总是同时移动相同的方式。 -
饥饿(starvation)
:在死锁或者活锁的状态中,在任何时刻都可能请求资源,虽然一些调度策略能够决定一些进程在某一时刻获得资源,但是有一些进程永远无法获得资源。永远无法获得资源的进程很容易产生饥饿
。 -
沙盒(sandboxing)
:沙盒是一种软件管理策略,可将应用程序与关键系统资源和其他程序隔离。它提供了一层额外的安全保护,可防止恶意软件或有害应用程序对你的系统造成负面影响。 -
VMM (Virtual Machine Monitor)
:也被称为 hypervisor,在同一个物理机器上创建出来多态虚拟机器的假象。
-
虚拟化技术(virtualization)
: 是一种资源管理技术,将计算机的各种实体资源(CPU、内存、磁盘空间、网络适配器等),进行抽象、转换后呈现出来并可供分割、组合为一个或多个电脑配置环境。 -
云(cloud)
:云是目前虚拟机最重要、最时髦的玩法。 -
解释器(interpreter)
: 解释器是一种程序,能够把编程语言一行一行解释运行。每次运行程序时都要先转成另一种语言再运行,因此解释器的程序运行速度比较缓慢。它不会一次把整个程序翻译出来,而是每翻译一行程序叙述就立刻运行,然后再翻译下一行,再运行,如此不停地进行下去。 -
半虚拟化(paravirtualization)
: 半虚拟化的目的不是呈现出一个和底层硬件一摸一样的虚拟机,而是提供一个软件接口,软件接口与硬件接口相似但又不完全一样。 -
全虚拟化(full virtualization)
:全虚拟化是硬件虚拟化的一种,允许未经修改的客操作系统隔离运行。对于全虚拟化,硬件特征会被映射到虚拟机上,这些特征包括完整的指令集、I/O操作、中断和内存管理等。 -
客户操作系统(guest operating system)
: 客户操作系统是安装在计算机上操作系统之后的操作系统,客户操作系统既可以是分区系统的一部分,也可以是虚拟机设置的一部分。客户操作系统为设备提供了备用操作系统。 -
主机操作系统(host operating system)
: 主机操作系统是计算机系统的硬盘驱动器上安装的主要操作系统。 在大多数情况下,只有一个主机操作系统。 -
应用编程接口(Application Programming Interface,API)
:应用程序编程接口(API)是软件组件或系统的编程接口,它定义其他组件或系统如何使用它。 -
虚拟机接口(Virtual Machine Interface, VMI)
:它是一个高速接口,同一主机上的虚拟机(VM)可用于相互之间以及主机内核模块之间进行通信。 -
输入输出内存管理单元(Input–output memory management unit, I/O MMU)
:在计算机中,输入输出内存管理单元(IOMMU)是将直接内存访问(DMA)I / O 总线连接到主存的内存管理单元(MMU)。 -
设备穿透(device pass through)
:它允许将物理设备直接分配给特定虚拟机。 -
设备隔离(device isolation)
: 保证设备可以直接访问其分配到的虚拟机的内存而不影响其他虚拟机的完整性。 -
基础设施即服务(IAAS (Infrastructure As A Service))
:基础架构即服务(IaaS)是一种即时计算基础架构,可通过 Internet 进行配置和管理。 它是四种云服务类型之一,另外还有软件即服务(SaaS),平台即服务(PaaS)和无服务器。
平台即服务(PAAS (Platform As A Service))
:平台即服务(PaaS)或应用程序平台即服务(aPaaS)或基于平台的服务是云计算服务的一种,它提供了一个平台,使客户可以开发,运行和管理应用程序,而无需构建和维护该应用程序。
软件即服务(SAAS(Software As A Service))
: 它是一个提供特定软件服务访问的平台,是一种软件许可和交付模型,在该模型中,软件是基于订阅许可的,并且是集中托管的。
实时迁移(live migration)
: 实时迁移是指在不断开客户端或应用程序连接的情况下,在不同的物理机之间移动正在运行的虚拟机或应用程序的过程,一般经常采用的方式是内存预复制迁移写入时复制(copy on write)
:写入时复制是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)
同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)
给该调用者,而其他调用者所见到的最初的资源仍然保持不变主从模型(master-slave)
:主/从是一种不对称通信或控制的模型,其中一个设备进程控制一个或多个其他设备或进程并充当其通信中心。 在某些系统中,从一组合格的设备中选择一个主设备,而其他设备则充当从设备的角色。
-
分布式系统(distributed system)
:分布式系统,也称为分布式计算,是一种具有位于不同机器上的多个组件的系统,这些组件可以通信和协调动作,以便对最终用户显示为单个一致的系统。 -
局域网(LANs, Local Area Networks)
:局域网(LAN)是一种计算机网络,可将住宅,学校,实验室,大学校园或办公大楼等有限区域内的计算机互连。 -
广域网(WAN,Wide Area Network)
:又称广域网、外网、公网。是连接不同地区局域网或城域网计算机通信的远程网。通常跨接很大的物理范围,所覆盖的范围从几十公里到几千公里,它能连接多个地区、城市和国家,或横跨几个洲并能提供远距离通信,形成国际性的远程网络。 -
以太网(Ethernet)
:以太网是一种计算机局域网的技术,它规定了包括物理层的连线、电子信号和介质访问层协议的内容。 -
桥接器(bridge)
:当指代计算机时,网桥是连接两个 LAN(局域网)或同一 LAN 的两个网段的设备。与路由器不同,网桥是独立于协议的。他们转发数据包时无需分析和重新路由消息。
主机(host)
:在网络硬件中,主机又被称为网络主机,网络主机是连接到计算机网络的计算机或其他设备。主机可以充当服务器,向网络上的用户或其他主机提供信息资源,服务和应用程序。主机被分配至少一个网络地址。路由器(router)
:路由器是在计算机网络之间转发数据包的联网设备。通过互联网发送的数据(例如网页或电子邮件)以数据包的形式出现。面向连接的服务(Connection-oriented service)
:面向连接的服务是一种在数据通信开始之前在通信实体之间建立专用连接的服务。要使用面向连接的服务,用户首先建立一个连接,使用它,然后释放它。TCP 就是一种面向连接的服务,在发送数据包之前需要经过握手操作。无连接的服务(Connectionless service)
:无连接服务是两个节点之间的数据通信,其中发送方在不确保接收方是否可以接收数据的情况下发送数据。此处,每个数据包都具有目标地址,并且与其他数据包无关地独立路由。UDP 就是一种无连接的服务,发送数据包不需要经过握手连接。服务质量(quality of service, QoS)
:服务质量是对服务整体性能的描述或度量,尤其是网络用户看到的性能。确认包(acknowledgement packet)
:在数据网络,电信和计算机总线中,确认(ACK)是作为通信协议一部分在通信过程,计算机或设备之间传递以表示确认或消息接收的信号。请求-响应服务(request-reply service)
:请求-响应是计算机彼此通信的基本方法之一,其中第一台计算机发送对某些数据的请求,第二台计算机对请求进行响应。
协议栈(protocol stack)
:所有现代网络都使用所谓的协议栈把不同的协议一层一层叠加起来。每一层解决不同的问题。
IP地址
:标示互联网上每一台主机有两种方式,一种是 IPv4 ,一种是 IPv6。超链接(hyperlink)
:超链接是可以单击以跳到新文档或当前文档中新部分的单词,短语或图像。 几乎在所有网页中都可以找到超链接,从而允许用户单击页面之间的方式。 文本超链接通常为蓝色并带有下划线。Web 页面(Web page)
:网页是一个适用于万维网和网页浏览器的文件。Web浏览器
:Web浏览器(通常称为浏览器)是一种用于访问 Internet 上的信息的软件应用程序。 当用户请求特定网站时,Web 浏览器从 Web 服务器检索必要的内容,然后在用户的设备上显示结果网页。漏洞(vulnerability)
:漏洞是一种系统不安全级别的错误。漏洞利用(exploit)
:漏洞利用是计算机安全术语,指的是利用程序中的某些漏洞,来得到计算机的控制权。病毒(virus)
:计算机病毒是一种计算机程序,在执行时会通过修改其他计算机程序并插入自己的代码来自我复制。复制成功后,可以说受影响的区域已被计算机病毒感染
。
CIA(Confidentiality,Integrity,Availability)
:安全系统的三个指标,即机密性、完整性和可用性。黑客(cracker)
:黑客是指经常通过网络闯入他人计算机系统的人。 绕过计算机程序中的密码或许可证; 或以其他方式故意破坏计算机安全性。 黑客可能会出于恶意,出于某些利他目的或原因,或者是因为存在挑战而牟取暴利。 表面上已经进行了一些破解和输入,以指出站点安全系统中的弱点。端口扫描(portscan)
:端口扫描程序是一种旨在探测服务器或主机是否存在开放端口的应用程序。 管理员可以使用这种应用程序来验证其网络的安全策略,攻击者可以使用这种应用程序来识别主机上运行的网络服务并利用漏洞。僵尸网络(botnets)
:僵尸网络是指骇客利用自己编写的分布式拒绝服务攻击程序将数万个沦陷的机器,即骇客常说的傀儡机或肉鸡
。域(domain)
:网域名称,简称域名、网域,是由一串用点分隔的字符组成的互联网上某一台计算机或计算机组的名称,用于在数据传输时标识计算机的电子方位。
盐(solt)
:在密码学中,盐是随机数据,用作哈希数据,密码或密码的单向函数的附加输入。逻辑炸弹(logic bomb)
: 是一些嵌入在正常软件中并在特定情况下执行的恶意程式码。这些特定情况包括更改档案、特别的程式输入序列、特定的时间或日期等。恶意程式码可能会将档案删除、使电脑主机当机或是造成其他的损害。定时炸弹(time bomb)
:在计算机软件中,定时炸弹是已编写的计算机程序的一部分,因此它会在达到预定的日期或时间后开始或停止运行。登陆欺骗(login spoofing)
:登录欺骗是用于窃取用户密码的技术。它会向用户显示一个普通的登录提示,提示用户名和密码,这实际上是一个恶意程序,通常在攻击者的控制下称为特洛伊木马。后门程序(backdoor)
:软件后门指绕过软件的安全性控制,从比较隐秘的通道获取对程序或系统访问权的黑客方法。防火墙(firewall)
:防火墙在计算机科学领域中是一个架设在互联网与企业内网之间的信息安全系统,根据企业预定的策略来监控往来的传输。
文章参考:
https://en.wikipedia.org/wiki/Copy-on-write
https://en.wikipedia.org/wiki/Live_migration
https://www.techopedia.com/definition/15763/host-operating-system
https://en.wikibooks.org/wiki/Operating_System_Design/Concurrency/Livelock
https://www.studytonight.com/operating-system/first-come-first-serve
https://blog.csdn.net/liuchuo/article/details/51986201
https://docs.openstack.org/ceilometer/6.1.5/architecture.html
https://www.techopedia.com/definition/16626/error-handling
https://simple.wikipedia.org/wiki/Device_controller
https://blog.csdn.net/zhangjg_blog/article/details/20380971
https://www.techopedia.com/definition/4763/address-space
https://en.wikipedia.org/wiki/Direct_Media_Interface
https://en.wikipedia.org/wiki/Bus_(computing
https://en.wikipedia.org/wiki/Interrupt_vector_table
https://en.wikipedia.org/wiki/Busy_waiting
https://en.wikipedia.org/wiki/Context_switch
https://en.wikipedia.org/wiki/Read-only_memory
https://www.techopedia.com/definition/6306/cache-hit
https://zhuanlan.zhihu.com/p/37749443
https://en.wikipedia.org/wiki/Pipeline_(computing
https://en.wikipedia.org/wiki/Stack_register
https://en.wikipedia.org/wiki/Distributed_operating_system
https://en.wikipedia.org/wiki/Time-sharing
https://zh.wikipedia.org/wiki/UNIX
https://zh.wikipedia.org/wiki/UNIX_System_V
https://en.wikipedia.org/wiki/Network_operating_system
https://zh.wikipedia.org/zh/X86-64
https://zh.wikipedia.org/zh/X86
https://en.wikipedia.org/wiki/Cloud_computing
https://www.techopedia.com/definition/24356/mainframe
https://zh.wikipedia.org/wiki/SATA
https://blog.codinghorror.com/understanding-user-and-kernel-mode/
https://en.wikipedia.org/wiki/Protection_ring
操作系统必知的面试题
- 操作系统必知的面试题
解释一下什么是操作系统
操作系统是运行在计算机上最重要的一种软件
,它管理计算机的资源和进程以及所有的硬件和软件。它为计算机硬件和软件提供了一种中间层
通常情况下,计算机上会运行着许多应用程序,它们都需要对内存和 CPU 进行交互,操作系统的目的就是为了保证这些访问和交互能够准确无误的进行。
解释一下操作系统的主要目的是什么
操作系统是一种软件,它的主要目的有三种
- 管理计算机资源,这些资源包括 CPU、内存、磁盘驱动器、打印机等。
- 提供一种图形界面,就像我们前面描述的那样,它提供了用户和计算机之间的桥梁。
- 为其他软件提供服务,操作系统与软件进行交互,以便为其分配运行所需的任何必要资源。
操作系统的种类有哪些
操作系统通常预装在你购买计算机之前。大部分用户都会使用默认的操作系统,但是你也可以升级甚至更改操作系统。但是一般常见的操作系统只有三种:Windows、macOS 和 Linux。
操作系统结构
单体系统
在大多数系统中,整个系统在内核态以单一程序的方式运行。整个操作系统是以程序集合来编写的,链接在一块形成一个大的二进制可执行程序,这种系统称为单体系统。
在单体系统中构造实际目标程序时,会首先编译所有单个过程(或包含这些过程的文件),然后使用系统链接器将它们全部绑定到一个可执行文件中
在单体系统中,对于每个系统调用都会有一个服务程序来保障和运行。需要一组实用程序来弥补服务程序需要的功能,例如从用户程序中获取数据。可将各种过程划分为一个三层模型
除了在计算机初启动时所装载的核心操作系统外,许多操作系统还支持额外的扩展。比如 I/O 设备驱动和文件系统。这些部件可以按需装载。在 UNIX 中把它们叫做 共享库(shared library)
,在 Windows 中则被称为 动态链接库(Dynamic Link Library,DLL)
。他们的扩展名为 .dll
,在 C:\Windows\system32
目录下存在 1000 多个 DLL 文件,所以不要轻易删除 C 盘文件,否则可能就炸了哦。
分层系统
分层系统使用层来分隔不同的功能单元。每一层只与该层的上层和下层通信。每一层都使用下面的层来执行其功能。层之间的通信通过预定义的固定接口通信。
微内核
为了实现高可靠性,将操作系统划分成小的、层级之间能够更好定义的模块是很有必要的,只有一个模块 — 微内核 — 运行在内核态,其余模块可以作为普通用户进程运行。由于把每个设备驱动和文件系统分别作为普通用户进程,这些模块中的错误虽然会使这些模块崩溃,但是不会使整个系统死机。
MINIX 3
是微内核的代表作,它的具体结构如下
在内核的外部,系统的构造有三层,它们都在用户态下运行,最底层是设备驱动器。由于它们都在用户态下运行,所以不能物理的访问 I/O 端口空间,也不能直接发出 I/O 命令。相反,为了能够对 I/O 设备编程,驱动器构建一个结构,指明哪个参数值写到哪个 I/O 端口,并声称一个内核调用,这样就完成了一次调用过程。
客户-服务器模式
微内核思想的策略是把进程划分为两类:服务器
,每个服务器用来提供服务;客户端
,使用这些服务。这个模式就是所谓的 客户-服务器
模式。
客户-服务器模式会有两种载体,一种情况是一台计算机既是客户又是服务器,在这种方式下,操作系统会有某种优化;但是普遍情况下是客户端和服务器在不同的机器上,它们通过局域网或广域网连接。
客户通过发送消息与服务器通信,客户端并不需要知道这些消息是在本地机器上处理,还是通过网络被送到远程机器上处理。对于客户端而言,这两种情形是一样的:都是发送请求并得到回应。
什么是按需分页
在操作系统中,进程是以页为单位加载到内存中的,按需分页是一种虚拟内存
的管理方式。在使用请求分页的系统中,只有在尝试访问页面所在的磁盘并且该页面尚未在内存中时,也就发生了缺页异常
,操作系统才会将磁盘页面复制到内存中。
多处理系统的优势
随着处理器的不断增加,我们的计算机系统由单机系统变为了多处理系统,多处理系统的吞吐量比较高,多处理系统拥有多个并行的处理器,这些处理器共享时钟、内存、总线、外围设备等。
多处理系统由于可以共享资源,因此可以开源节流,省钱。整个系统的可靠性也随之提高。
什么是内核
在计算机中,内核是一个计算机程序,它是操作系统的核心,可以控制操作系统中所有的内容。内核通常是在 boot loader 装载程序之前加载的第一个程序。
这里还需要了解一下什么是 boot loader
。
boot loader 又被称为引导加载程序,它是一个程序,能够将计算机的操作系统放入内存中。在电源通电或者计算机重启时,BIOS 会执行一些初始测试,然后将控制权转移到引导加载程序所在的
主引导记录(MBR)
。
什么是实时系统
实时操作系统对时间做出了严格的要求,实时操作系统分为两种:**硬实时和软实时
硬实时操作系统
规定某个动作必须在规定的时刻内完成或发生,比如汽车生产车间,焊接机器必须在某一时刻内完成焊接,焊接的太早或者太晚都会对汽车造成永久性伤害。
软实时操作系统
虽然不希望偶尔违反最终的时限要求,但是仍然可以接受。并且不会引起任何永久性伤害。比如数字音频、多媒体、手机都是属于软实时操作系统。
你可以简单理解硬实时和软实时的两个指标:是否在时刻内必须完成以及是否造成严重损害。
什么是虚拟内存
虚拟内存
是一种内存分配方案,是一项可以用来辅助内存分配的机制。我们知道,应用程序是按页装载进内存中的。但并不是所有的页都会装载到内存中,计算机中的硬件和软件会将数据从 RAM 临时传输到磁盘中来弥补内存的不足。如果没有虚拟内存的话,一旦你将计算机内存填满后,计算机会对你说
呃,不,对不起,您无法再加载任何应用程序,请关闭另一个应用程序以加载新的应用程序。对于虚拟内存,计算机可以执行操作是查看内存中最近未使用过的区域,然后将其复制到硬盘上。虚拟内存通过复制技术实现了 妹子,你快来看哥哥能装这么多程序 的资本。复制是自动进行的,你无法感知到它的存在。
什么是进程和进程表
进程
就是正在执行程序的实例,比如说 Web 程序就是一个进程,shell 也是一个进程,文章编辑器 typora 也是一个进程。
操作系统负责管理所有正在运行的进程,操作系统会为每个进程分配特定的时间来占用 CPU,操作系统还会为每个进程分配特定的资源。
操作系统为了跟踪每个进程的活动状态,维护了一个进程表
。在进程表的内部,列出了每个进程的状态以及每个进程使用的资源等。
http://courses.cs.vt.edu/csonline/OS/Lessons/Processes/index.html 这个网站上面有一个关于进程状态轮转的动画,做的真是太好了。
什么是线程,线程和进程的区别
这又是一道老生常谈的问题了,从操作系统的角度来回答一下吧。
我们上面说到进程是正在运行的程序的实例,而线程其实就是进程中的单条流向,因为线程具有进程中的某些属性,所以线程又被称为轻量级的进程。浏览器如果是一个进程的话,那么浏览器下面的每个 tab 页可以看作是一个个的线程。
下面是线程和进程持有资源的区别
线程不像进程那样具有很强的独立性,线程之间会共享数据
创建线程的开销要比进程小很多,因为创建线程仅仅需要堆栈指针
和程序计数器
就可以了,而创建进程需要操作系统分配新的地址空间,数据资源等,这个开销比较大。
使用多线程的好处是什么
多线程是程序员不得不知的基本素养之一,所以,下面我们给出一些多线程编程的好处
- 能够提高对用户的响应顺序
- 在流程中的资源共享
- 比较经济适用
- 能够对多线程架构有深入的理解
什么是 RR 调度算法
RR(round-robin)
调度算法主要针对分时系统,RR 的调度算法会把时间片以相同的部分并循环的分配给每个进程,RR 调度算法没有优先级的概念。这种算法的实现比较简单,而且每个线程都会占有时间片,并不存在线程饥饿的问题。
导致系统出现死锁的情况
死锁的出现需要同时满足下面四个条件
互斥(Mutual Exclusion)
:一次只能有一个进程使用资源。如果另一个进程请求该资源,则必须延迟请求进程,直到释放该资源为止。保持并等待(Hold and Wait)
:必须存在一个进程,该进程至少持有一个资源,并且正在等待获取其他进程当前所持有的资源。无抢占(No Preemption)
:资源不能被抢占,也就是说,在进程完成其任务之后,只能由拥有它的进程自动释放资源。循环等待(Circular Wait)
:必须存在一组 {p0,p1,….. pn} 的等待进程,使 p0 等待 p1 持有的资源,p1 等待由 p2 持有的资源, pn-1 正在等待由 pn 持有的资源,而 pn 正在等待由 p0 持有的资源。
RAID 的不同级别
RAID 称为 磁盘冗余阵列
,简称 磁盘阵列
。利用虚拟化技术把多个硬盘结合在一起,成为一个或多个磁盘阵列组,目的是提升性能或数据冗余。
RAID 有不同的级别
- RAID 0 – 无容错的条带化磁盘阵列
- RAID 1 – 镜像和双工
- RAID 2 – 内存式纠错码
- RAID 3 – 比特交错奇偶校验
- RAID 4 – 块交错奇偶校验
- RAID 5 – 块交错分布式奇偶校验
- RAID 6 – P + Q冗余
什么是 DMA
DMA 的中文名称是直接内存访问
,它意味着 CPU 授予 I/O 模块权限在不涉及 CPU 的情况下读取或写入内存。也就是 DMA 可以不需要 CPU 的参与。这个过程由称为 DMA 控制器(DMAC)的芯片管理。由于 DMA 设备可以直接在内存之间传输数据,而不是使用 CPU 作为中介,因此可以缓解总线上的拥塞。DMA 通过允许 CPU 执行任务,同时 DMA 系统通过系统和内存总线传输数据来提高系统并发性。
多线程编程的好处是什么
对不起,我忍不住想偷笑
说直白点,为什么单线程能够处理的却要用多线程来处理?当然是为了提高程序的~~装逼~~并行能力了。多线程在某些情况下
能够使你程序运行的更快,这也是为什么多核 CPU 会出现,但是多核 CPU 的出现会导致数据的一致性问题,不过这些问题程序员就能解决。另一个角度来说,多线程编程能够提高程序员的编程能力和编程思维。同时也能提高程序员的管理能力,你如果把每条线程流当作罗老师时间管理的女主一样,能够及时协调好所有P友的关系,那你也是超神程序员了,所以,是谁说程序员不会做管理的?Doug Lea 大佬牛逼!!!
ps:Doug Lea 大佬开发的 JUC 工具包,此处不加狗头。
什么是设备驱动程序
在计算机中,设备驱动程序是一种计算机程序,它能够控制或者操作连接到计算机的特定设备。驱动程序提供了与硬件进行交互的软件接口,使操作系统和其他计算机程序能够访问特定设备,不用需要了解其硬件的具体构造。
进程间的通信方式
通信概念
进程间的通信方式比较多,首先你需要理解下面这几个概念
-
竞态条件:即两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为
竞态条件(race condition)
。 -
临界区:不仅
共享资源
会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢?或许一句话可以概括说明:禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。换句话说,我们需要一种互斥(mutual exclusion)
条件,这也就是说,如果一个进程在某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)。一个好的解决方案,应该包含下面四种条件
- 任何时候两个进程不能同时处于临界区
- 不应对 CPU 的速度和数量做任何假设
- 位于临界区外的进程不得阻塞其他进程
- 不能使任何进程无限等待进入临界区
- 忙等互斥:当一个进程在对资源进行修改时,其他进程必须进行等待,进程之间要具有互斥性,我们讨论的解决方案其实都是基于忙等互斥提出的。
解决方案
进程间的通信用专业一点的术语来表示就是 Inter Process Communication,IPC
,它主要有下面几种通信方式
消息传递
:消息传递是进程间实现通信和同步等待的机制,使用消息传递,进程间的交流不需要共享变量,直接就可以进行通信;消息传递分为发送方和接收方先进先出队列
:先进先出队列指的是两个不相关联进程间的通信,两个进程之间可以彼此相互进程通信,这是一种全双工通信方式管道
:管道用于两个相关进程之间的通信,这是一种半双工的通信方式,如果需要全双工,需要另外一个管道。直接通信
:在这种进程通信的方式中,进程与进程之间只存在一条链接,进程间要明确通信双方的命名。间接通信
:间接通信是通信双方不会直接建立连接,而是找到一个中介者,这个中介者可能是个对象等等,进程可以在其中放置消息,并且可以从中删除消息,以此达到进程间通信的目的。消息队列
:消息队列是内核中存储消息的链表,它由消息队列标识符进行标识,这种方式能够在不同的进程之间提供全双工的通信连接。共享内存
:共享内存是使用所有进程之间的内存来建立连接,这种类型需要同步进程访问来相互保护。
进程间状态模型
cat chapter1 chapter2 chapter3 | grep tree
第一个进程是 cat
,将三个文件级联并输出。第二个进程是 grep
,它从输入中选择具有包含关键字 tree
的内容,根据这两个进程的相对速度(这取决于两个程序的相对复杂度和各自所分配到的 CPU 时间片),可能会发生下面这种情况,grep
准备就绪开始运行,但是输入进程还没有完成,于是必须阻塞 grep 进程,直到输入完毕。
当一个进程开始运行时,它可能会经历下面这几种状态
图中会涉及三种状态
运行态
,运行态指的就是进程实际占用 CPU 时间片运行时就绪态
,就绪态指的是可运行,但因为其他进程正在运行而处于就绪状态阻塞态
,除非某种外部事件发生,否则进程不能运行
逻辑上来说,运行态和就绪态是很相似的。这两种情况下都表示进程可运行
,但是第二种情况没有获得 CPU 时间分片。第三种状态与前两种状态不同的原因是这个进程不能运行,CPU 空闲时也不能运行。
三种状态会涉及四种状态间的切换,在操作系统发现进程不能继续执行时会发生状态1
的轮转,在某些系统中进程执行系统调用,例如 pause
,来获取一个阻塞的状态。在其他系统中包括 UNIX,当进程从管道或特殊文件(例如终端)中读取没有可用的输入时,该进程会被自动终止。
转换 2 和转换 3 都是由进程调度程序(操作系统的一部分)引起的,进程本身不知道调度程序的存在。转换 2 的出现说明进程调度器认定当前进程已经运行了足够长的时间,是时候让其他进程运行 CPU 时间片了。当所有其他进程都运行过后,这时候该是让第一个进程重新获得 CPU 时间片的时候了,就会发生转换 3。
程序调度指的是,决定哪个进程优先被运行和运行多久,这是很重要的一点。已经设计出许多算法来尝试平衡系统整体效率与各个流程之间的竞争需求。
当进程等待的一个外部事件发生时(如从外部输入一些数据后),则发生转换 4。如果此时没有其他进程在运行,则立刻触发转换 3,该进程便开始运行,否则该进程会处于就绪阶段,等待 CPU 空闲后再轮到它运行。
调度算法都有哪些
调度算法分为三大类:批处理中的调度、交互系统中的调度、实时系统中的调度
批处理中的调度
先来先服务
很像是先到先得。。。可能最简单的非抢占式调度算法的设计就是 先来先服务(first-come,first-serverd)
。使用此算法,将按照请求顺序为进程分配 CPU。最基本的,会有一个就绪进程的等待队列。当第一个任务从外部进入系统时,将会立即启动并允许运行任意长的时间。它不会因为运行时间太长而中断。当其他作业进入时,它们排到就绪队列尾部。当正在运行的进程阻塞,处于等待队列的第一个进程就开始运行。当一个阻塞的进程重新处于就绪态时,它会像一个新到达的任务,会排在队列的末尾,即排在所有进程最后。
这个算法的强大之处在于易于理解和编程,在这个算法中,一个单链表记录了所有就绪进程。要选取一个进程运行,只要从该队列的头部移走一个进程即可;要添加一个新的作业或者阻塞一个进程,只要把这个作业或进程附加在队列的末尾即可。这是很简单的一种实现。
不过,先来先服务也是有缺点的,那就是没有优先级的关系,试想一下,如果有 100 个 I/O 进程正在排队,第 101 个是一个 CPU 密集型进程,那岂不是需要等 100 个 I/O 进程运行完毕才会等到一个 CPU 密集型进程运行,这在实际情况下根本不可能,所以需要优先级或者抢占式进程的出现来优先选择重要的进程运行。
最短作业优先
批处理中,第二种调度算法是 最短作业优先(Shortest Job First)
,我们假设运行时间已知。例如,一家保险公司,因为每天要做类似的工作,所以人们可以相当精确地预测处理 1000 个索赔的一批作业需要多长时间。当输入队列中有若干个同等重要的作业被启动时,调度程序应使用最短优先作业算法
如上图 a 所示,这里有 4 个作业 A、B、C、D ,运行时间分别为 8、4、4、4 分钟。若按图中的次序运行,则 A 的周转时间为 8 分钟,B 为 12 分钟,C 为 16 分钟,D 为 20 分钟,平均时间内为 14 分钟。
现在考虑使用最短作业优先算法运行 4 个作业,如上图 b 所示,目前的周转时间分别为 4、8、12、20,平均为 11 分钟,可以证明最短作业优先是最优的。考虑有 4 个作业的情况,其运行时间分别为 a、b、c、d。第一个作业在时间 a 结束,第二个在时间 a + b 结束,以此类推。平均周转时间为 (4a + 3b + 2c + d) / 4 。显然 a 对平均值的影响最大,所以 a 应该是最短优先作业,其次是 b,然后是 c ,最后是 d 它就只能影响自己的周转时间了。
需要注意的是,在所有的进程都可以运行的情况下,最短作业优先的算法才是最优的。
最短剩余时间优先
最短作业优先的抢占式版本被称作为 最短剩余时间优先(Shortest Remaining Time Next)
算法。使用这个算法,调度程序总是选择剩余运行时间最短的那个进程运行。当一个新作业到达时,其整个时间同当前进程的剩余时间做比较。如果新的进程比当前运行进程需要更少的时间,当前进程就被挂起,而运行新的进程。这种方式能够使短期作业获得良好的服务。
交互式系统中的调度
交互式系统中在个人计算机、服务器和其他系统中都是很常用的,所以有必要来探讨一下交互式调度
轮询调度
一种最古老、最简单、最公平并且最广泛使用的算法就是 轮询算法(round-robin)
。每个进程都会被分配一个时间段,称为时间片(quantum)
,在这个时间片内允许进程运行。如果时间片结束时进程还在运行的话,则抢占一个 CPU 并将其分配给另一个进程。如果进程在时间片结束前阻塞或结束,则 CPU 立即进行切换。轮询算法比较容易实现。调度程序所做的就是维护一个可运行进程的列表,就像下图中的 a,当一个进程用完时间片后就被移到队列的末尾,就像下图的 b。
优先级调度
事实情况是不是所有的进程都是优先级相等的。例如,在一所大学中的等级制度,首先是院长,然后是教授、秘书、后勤人员,最后是学生。这种将外部情况考虑在内就实现了优先级调度(priority scheduling)
它的基本思想很明确,每个进程都被赋予一个优先级,优先级高的进程优先运行。
但是也不意味着高优先级的进程能够永远一直运行下去,调度程序会在每个时钟中断期间降低当前运行进程的优先级。如果此操作导致其优先级降低到下一个最高进程的优先级以下,则会发生进程切换。或者,可以为每个进程分配允许运行的最大时间间隔。当时间间隔用完后,下一个高优先级的进程会得到运行的机会。
最短进程优先
对于批处理系统而言,由于最短作业优先常常伴随着最短响应时间,一种方式是根据进程过去的行为进行推测,并执行估计运行时间最短的那一个。假设每个终端上每条命令的预估运行时间为 T0
,现在假设测量到其下一次运行时间为 T1
,可以用两个值的加权来改进估计时间,即aT0+ (1- 1)T1
。通过选择 a 的值,可以决定是尽快忘掉老的运行时间,还是在一段长时间内始终记住它们。当 a = 1/2 时,可以得到下面这个序列
可以看到,在三轮过后,T0 在新的估计值中所占比重下降至 1/8。
有时把这种通过当前测量值和先前估计值进行加权平均从而得到下一个估计值的技术称作 老化(aging)
。这种方法会使用很多预测值基于当前值的情况。
彩票调度
有一种既可以给出预测结果而又有一种比较简单的实现方式的算法,就是 彩票调度(lottery scheduling)
算法。他的基本思想为进程提供各种系统资源的彩票
。当做出一个调度决策的时候,就随机抽出一张彩票,拥有彩票的进程将获得资源。比如在 CPU 进行调度时,系统可以每秒持有 50 次抽奖,每个中奖进程会获得额外运行时间的奖励。
可以把彩票理解为 buff,这个 buff 有 15% 的几率能让你产生
速度之靴
的效果。
公平分享调度
如果用户 1 启动了 9 个进程,而用户 2 启动了一个进程,使用轮转或相同优先级调度算法,那么用户 1 将得到 90 % 的 CPU 时间,而用户 2 将之得到 10 % 的 CPU 时间。
为了阻止这种情况的出现,一些系统在调度前会把进程的拥有者考虑在内。在这种模型下,每个用户都会分配一些CPU 时间,而调度程序会选择进程并强制执行。因此如果两个用户每个都会有 50% 的 CPU 时间片保证,那么无论一个用户有多少个进程,都将获得相同的 CPU 份额。
页面置换算法都有哪些
算法 | 注释 |
---|---|
最优算法 | 不可实现,但可以用作基准 |
NRU(最近未使用) 算法 | 和 LRU 算法很相似 |
FIFO(先进先出) 算法 | 有可能会抛弃重要的页面 |
第二次机会算法 | 比 FIFO 有较大的改善 |
时钟算法 | 实际使用 |
LRU(最近最少)算法 | 比较优秀,但是很难实现 |
NFU(最不经常食用)算法 | 和 LRU 很类似 |
老化算法 | 近似 LRU 的高效算法 |
工作集算法 | 实施起来开销很大 |
工作集时钟算法 | 比较有效的算法 |
最优算法
在当前页面中置换最后要访问的页面。不幸的是,没有办法来判定哪个页面是最后一个要访问的,因此实际上该算法不能使用
。然而,它可以作为衡量其他算法的标准。NRU
算法根据 R 位和 M 位的状态将页面氛围四类。从编号最小的类别中随机选择一个页面。NRU 算法易于实现,但是性能不是很好。存在更好的算法。FIFO
会跟踪页面加载进入内存中的顺序,并把页面放入一个链表中。有可能删除存在时间最长但是还在使用的页面,因此这个算法也不是一个很好的选择。第二次机会
算法是对 FIFO 的一个修改,它会在删除页面之前检查这个页面是否仍在使用。如果页面正在使用,就会进行保留。这个改进大大提高了性能。时钟
算法是第二次机会算法的另外一种实现形式,时钟算法和第二次算法的性能差不多,但是会花费更少的时间来执行算法。LRU
算法是一个非常优秀的算法,但是没有特殊的硬件(TLB)
很难实现。如果没有硬件,就不能使用 LRU 算法。NFU
算法是一种近似于 LRU 的算法,它的性能不是非常好。老化
算法是一种更接近 LRU 算法的实现,并且可以更好的实现,因此是一个很好的选择- 最后两种算法都使用了工作集算法。工作集算法提供了合理的性能开销,但是它的实现比较复杂。
WSClock
是另外一种变体,它不仅能够提供良好的性能,而且可以高效地实现。
最好的算法是老化算法和WSClock算法。他们分别是基于 LRU 和工作集算法。他们都具有良好的性能并且能够被有效的实现。还存在其他一些好的算法,但实际上这两个可能是最重要的。
影响调度程序的指标是什么
会有下面几个因素决定调度程序的好坏
- CPU 使用率:
CPU 正在执行任务(即不处于空闲状态)的时间百分比。
- 等待时间
这是进程轮流执行的时间,也就是进程切换的时间
- 吞吐量
单位时间内完成进程的数量
- 响应时间
这是从提交流程到获得有用输出所经过的时间。
- 周转时间
从提交流程到完成流程所经过的时间。
什么是僵尸进程
僵尸进程是已完成且处于终止状态,但在进程表中却仍然存在的进程。僵尸进程通常发生在父子关系的进程中,由于父进程仍需要读取其子进程的退出状态所造成的。
操作系统之输入输出
我们之前的文章提到了操作系统的三个抽象,它们分别是进程、地址空间和文件,除此之外,操作系统还要控制所有的 I/O 设备。操作系统必须向设备发送命令
,捕捉中断
并处理错误
。它还应该在设备和操作系统的其余部分之间提供一个简单易用的接口。操作系统如何管理 I/O 是我们接下来的重点。
不同的人对 I/O 硬件的理解也不同。对于电子工程师而言,I/O 硬件就是芯片、导线、电源和其他组成硬件的物理设备。而我们程序员眼中的 I/O 其实就是硬件提供给软件的接口
,比如硬件接受到的命令、执行的操作以及反馈的错误。我们着重探讨的是如何对硬件进行编程,而不是其工作原理。
I/O 设备
什么是 I/O 设备?I/O 设备又叫做输入/输出设备,它是人类用来和计算机进行通信的外部硬件。输入/输出设备能够向计算机发送数据(输出)
并从计算机接收数据(输入)
。
I/O 设备(I/O devices)
可以分成两种:块设备(block devices)
和 字符设备(character devices)
。
块设备
块设备是一个能存储固定大小块
信息的设备,它支持以固定大小的块,扇区或群集读取和(可选)写入数据。每个块都有自己的物理地址
。通常块的大小在 512 – 65536 之间。所有传输的信息都会以连续
的块为单位。块设备的基本特征是每个块都较为对立,能够独立的进行读写。常见的块设备有 **硬盘、蓝光光盘、USB 盘
与字符设备相比,块设备通常需要较少的引脚。
块设备的缺点
基于给定固态存储器的块设备比基于相同类型的存储器的字节寻址要慢一些,因为必须在块的开头开始读取或写入。所以,要读取该块的任何部分,必须寻找到该块的开始,读取整个块,如果不使用该块,则将其丢弃。要写入块的一部分,必须寻找到块的开始,将整个块读入内存,修改数据,再次寻找到块的开头处,然后将整个块写回设备。
字符设备
另一类 I/O 设备是字符设备
。字符设备以字符
为单位发送或接收一个字符流,而不考虑任何块结构。字符设备是不可寻址的,也没有任何寻道操作。常见的字符设备有 打印机、网络设备、鼠标、以及大多数与磁盘不同的设备。
下面显示了一些常见设备的数据速率。
设备控制器
首先需要先了解一下设备控制器的概念。
设备控制器是处理 CPU 传入和传出信号的系统。设备通过插头和插座连接到计算机,并且插座连接到设备控制器。设备控制器从连接的设备处接收数据,并将其存储在控制器内部的一些特殊目的寄存器(special purpose registers)
也就是本地缓冲区中。
特殊用途寄存器,顾名思义是仅为一项任务而设计的寄存器。例如,cs,ds,gs 和其他段寄存器属于特殊目的寄存器,因为它们的存在是为了保存段号。 eax,ecx 等是一般用途的寄存器,因为你可以无限制地使用它们。 例如,你不能移动 ds,但是可以移动 eax,ebx。
通用目的寄存器比如有:eax、ecx、edx、ebx、esi、edi、ebp、esp
特殊目的寄存器比如有:cs、ds、ss、es、fs、gs、eip、flag
每个设备控制器都会有一个应用程序与之对应,设备控制器通过应用程序的接口通过中断与操作系统进行通信。设备控制器是硬件,而设备驱动程序是软件。
I/O 设备通常由机械组件(mechanical component)
和电子组件(electronic component)
构成。电子组件被称为 设备控制器(device controller)
或者 适配器(adapter)
。在个人计算机上,它通常采用可插入(PCIe)扩展插槽
的主板上的芯片或印刷电路卡的形式。
机械设备就是它自己,它的组成如下
控制器卡上通常会有一个连接器,通向设备本身的电缆可以插入到这个连接器中,很多控制器可以操作 2 个、4 个设置 8 个相同的设备。
控制器与设备之间的接口通常是一个低层次的接口。例如,磁盘可能被格式化为 2,000,000 个扇区,每个磁道 512 字节。然而,实际从驱动出来的却是一个串行的比特流,从一个前导符(preamble)
开始,然后是一个扇区中的 4096 位,最后是一个校验和
或 ECC(错误码,Error-Correcting Code)
。前导符是在对磁盘进行格式化的时候写上去的,它包括柱面数和扇区号,扇区大小以及类似的数据,此外还包含同步信息。
控制器的任务是把串行的位流转换为字节块,并进行必要的错误校正工作。字节块通常会在控制器内部的一个缓冲区按位进行组装,然后再对校验和进行校验并证明字节块没有错误后,再将它复制到内存中。
内存映射 I/O
每个控制器都会有几个寄存器用来和 CPU 进行通信。通过写入这些寄存器,操作系统可以命令设备发送数据,接收数据、开启或者关闭设备等。通过从这些寄存器中读取信息,操作系统能够知道设备的状态,是否准备接受一个新命令等。
为了控制寄存器
,许多设备都会有数据缓冲区(data buffer)
,来供系统进行读写。例如,在屏幕上显示一个像素的常规方法是使用一个视频 RAM,这一 RAM 基本上只是一个数据缓冲区,用来供程序和操作系统写入数据。
那么问题来了,CPU 如何与设备寄存器和设备数据缓冲区进行通信呢?存在两个可选的方式。第一种方法是,每个控制寄存器都被分配一个 I/O 端口(I/O port)
号,这是一个 8 位或 16 位的整数。所有 I/O 端口的集合形成了受保护的 I/O 端口空间,以便普通用户程序无法访问它(只有操作系统可以访问)。使用特殊的 I/O 指令像是
IN REG,PORT
CPU 可以读取控制寄存器 PORT 的内容并将结果放在 CPU 寄存器 REG 中。类似的,使用
OUT PORT,REG
CPU 可以将 REG 的内容写到控制寄存器中。大多数早期计算机,包括几乎所有大型主机,如 IBM 360 及其所有后续机型,都是以这种方式工作的。
控制寄存器是一个处理器寄存器而改变或控制的一般行为 CPU 或其他数字设备。控制寄存器执行的常见任务包括中断控制,切换寻址模式,分页控制和协处理器控制。
在这一方案中,内存地址空间和 I/O 地址空间是不相同的,如下图所示
指令
IN R0,4
和
MOV R0,4
这一设计中完全不同。前者读取 I/O端口 4 的内容并将其放入 R0,而后者读取存储器字 4 的内容并将其放入 R0。这些示例中的 4 代表不同且不相关的地址空间。
第二个方法是 PDP-11 引入的,
什么是 PDP-11?
它将所有控制寄存器映射到内存空间中,如下图所示
内存映射的 I/O
是在 CPU 与其连接的外围设备之间交换数据和指令的一种方式,这种方式是处理器和 IO 设备共享同一内存位置
的内存,即处理器和 IO 设备使用内存地址进行映射。
在大多数系统中,分配给控制寄存器的地址位于或者靠近地址的顶部附近。
下面是采用的一种混合方式
这种方式具有与内存映射 I/O 的数据缓冲区,而控制寄存器则具有单独的 I/O 端口。x86 采用这一体系结构。在 IBM PC 兼容机中,除了 0 到 64K – 1 的 I/O 端口之外,640 K 到 1M – 1 的内存地址保留给设备的数据缓冲区。
这些方案是如何工作的呢?当 CPU 想要读入一个字的时候,无论是从内存中读入还是从 I/O 端口读入,它都要将需要的地址放到总线地址线上,然后在总线的一条控制线上调用一个 READ
信号。还有第二条信号线来表明需要的是 I/O 空间还是内存空间。如果是内存空间,内存将响应请求。如果是 I/O 空间,那么 I/O 设备将响应请求。如果只有内存空间,那么每个内存模块和每个 I/O 设备都会将地址线和它所服务的地址范围进行比较。如果地址落在这一范围之内,它就会响应请求。绝对不会出现地址既分配给内存又分配给 I/O 设备,所以不会存在歧义和冲突。
内存映射 I/O 的优点和缺点
这两种寻址控制器的方案具有不同的优缺点。先来看一下内存映射 I/O 的优点。
- 第一,如果需要特殊的 I/O 指令读写设备控制寄存器,那么访问这些寄存器需要使用汇编代码,因为在 C 或 C++ 中不存在执行
IN
和OUT
指令的方法。调用这样的过程增加了 I/O 的开销。在内存映射中,控制寄存器只是内存中的变量,在 C 语言中可以和其他变量一样进行寻址。 - 第二,对于内存映射 I/O ,不需要特殊的保护机制就能够阻止用户进程执行 I/O 操作。操作系统需要保证的是禁止把控制寄存器的地址空间放在用户的虚拟地址中就可以了。
- 第三,对于内存映射 I/O,可以引用内存的每一条指令也可以引用控制寄存器,便于引用。
在计算机设计中,几乎所有的事情都要权衡。内存映射 I/O 也是一样,它也有自己的缺点。首先,大部分计算机现在都会有一些对于内存字的缓存。缓存一个设备控制寄存器的代价是很大的。为了避免这种内存映射 I/O 的情况,硬件必须有选择性的禁用缓存,例如,在每个页面上禁用缓存,这个功能为硬件和操作系统增加了额外的复杂性,因此必须选择性的进行管理。
第二点,如果仅仅只有一个地址空间,那么所有的内存模块(memory modules)
和所有的 I/O 设备都必须检查所有的内存引用来推断出谁来进行响应。
什么是内存模块?在计算中,存储器模块是其上安装有存储器集成电路的印刷电路板。
如果计算机是一种单总线体系结构的话,如下图所示
让每个内存模块和 I/O 设备查看每个地址是简单易行的。
然而,现代个人计算机的趋势是专用的高速内存总线,如下图所示
装备这一总线是为了优化内存访问速度,x86 系统还可以有多种总线(内存、PCIe、SCSI 和 USB)。如下图所示
在内存映射机器上使用单独的内存总线的麻烦之处在于,I/O 设备无法通过内存总线查看内存地址,因此它们无法对其进行响应。此外,必须采取特殊的措施使内存映射 I/O 工作在具有多总线的系统上。一种可能的方法是首先将全部内存引用发送到内存,如果内存响应失败,CPU 再尝试其他总线。
第二种设计是在内存总线上放一个探查设备
,放过所有潜在指向所关注的 I/O 设备的地址。此处的问题是,I/O 设备可能无法以内存所能达到的速度处理请求。
第三种可能的设计是在内存控制器中对地址进行过滤,这种设计与上图所描述的设计相匹配。这种情况下,内存控制器芯片中包含在引导时预装载的范围寄存器。这一设计的缺点是需要在引导时判定哪些内存地址而不是真正的内存地址。因而,每一设计都有支持它和反对它的论据,所以折中和权衡是不可避免的。
直接内存访问
无论一个 CPU 是否具有内存映射 I/O,它都需要寻址设备控制器以便与它们交换数据。CPU 可以从 I/O 控制器每次请求一个字节的数据,但是这么做会浪费 CPU 时间,所以经常会用到一种称为直接内存访问(Direct Memory Access)
的方案。为了简化,我们假设 CPU 通过单一的系统总线访问所有的设备和内存,该总线连接 CPU 、内存和 I/O 设备,如下图所示
现代操作系统实际更为复杂,但是原理是相同的。如果硬件有 DMA 控制器
,那么操作系统只能使用 DMA。有时这个控制器会集成到磁盘控制器和其他控制器中,但这种设计需要在每个设备上都装有一个分离的 DMA 控制器。单个的 DMA 控制器可用于向多个设备传输,这种传输往往同时进行。
不管 DMA 控制器的物理地址在哪,它都能够独立于 CPU 从而访问系统总线,如上图所示。它包含几个可由 CPU 读写的寄存器,其中包括一个内存地址寄存器,字节计数寄存器和一个或多个控制寄存器。控制寄存器指定要使用的 I/O 端口、传送方向(从 I/O 设备读或写到 I/O 设备)、传送单位(每次一个字节或者每次一个字)以及在一次突发传送中要传送的字节数。
为了解释 DMA 的工作原理,我们首先看一下不使用 DMA 该如何进行磁盘读取。
- 首先,控制器从
磁盘驱动器
串行地、一位一位的读一个块(一个或多个扇区),直到将整块信息放入控制器的内部缓冲区。 - 读取
校验和
以保证没有发生读错误。然后控制器会产生一个中断,当操作系统开始运行时,它会重复的从控制器的缓冲区中一次一个字节或者一个字地读取该块的信息,并将其存入内存中。
DMA 工作原理
当使用 DMA 后,这个过程就会变得不一样了。首先 CPU 通过设置 DMA 控制器的寄存器对它进行编程,所以 DMA 控制器知道将什么数据传送到什么地方。DMA 控制器还要向磁盘控制器发出一个命令,通知它从磁盘读数据到其内部的缓冲区并检验校验和。当有效数据位于磁盘控制器的缓冲区中时,DMA 就可以开始了。
DMA 控制器通过在总线上发出一个读请求
到磁盘控制器而发起 DMA 传送,这是第二步。这个读请求就像其他读请求一样,磁盘控制器并不知道或者并不关心它是来自 CPU 还是来自 DMA 控制器。通常情况下,要写的内存地址在总线的地址线上,所以当磁盘控制器去匹配下一个字时,它知道将该字写到什么地方。写到内存就是另外一个总线循环了,这是第三步。当写操作完成时,磁盘控制器在总线上发出一个应答信号到 DMA 控制器,这是第四步。
然后,DMA 控制器会增加内存地址并减少字节数量。如果字节数量仍然大于 0 ,就会循环步骤 2 – 步骤 4 ,直到字节计数变为 0 。此时,DMA 控制器会打断 CPU 并告诉它传输已经完成了。操作系统开始运行时,它不会把磁盘块拷贝到内存中,因为它已经在内存中了。
不同 DMA 控制器的复杂程度差别很大。最简单的 DMA 控制器每次处理一次传输,就像上面描述的那样。更为复杂的情况是一次同时处理很多次传输,这样的控制器内部具有多组寄存器,每个通道一组寄存器。在传输每一个字之后,DMA 控制器就决定下一次要为哪个设备提供服务。DMA 控制器可能被设置为使用 轮询算法
,或者它也有可能具有一个优先级规划设计,以便让某些设备受到比其他设备更多的照顾。假如存在一个明确的方法分辨应答信号,那么在同一时间就可以挂起对不同设备控制器的多个请求。
许多总线能够以两种模式操作:每次一字模式和块模式。一些 DMA 控制器也能够使用这两种方式进行操作。在前一个模式中,DMA 控制器请求传送一个字并得到这个字。如果 CPU 想要使用总线,它必须进行等待。设备可能会偷偷进入并且从 CPU 偷走一个总线周期,从而轻微的延迟 CPU。这种机制称为 周期窃取(cycle stealing)
。
在块模式中,DMA 控制器告诉设备获取总线,然后进行一系列的传输操作,然后释放总线。这一操作的形式称为 突发模式(burst mode)
。这种模式要比周期窃取更有效因为获取总线占用了时间,并且一次总线获得的代价是可以同时传输多个字。缺点是如果此时进行的是长时间的突发传送,有可能将 CPU 和其他设备阻塞很长的时间。
在我们讨论的这种模型中,有时被称为 飞越模式(fly-by mode)
,DMA 控制器会告诉设备控制器把数据直接传递到内存。一些 DMA 控制器使用的另一种模式是让设备控制器将字发送给 DMA 控制器,然后 DMA 控制器发出第二条总线请求,将字写到任何可以写入的地方。采用这种方案,每个传输的字都需要一个额外的总线周期,但是更加灵活,因为它还可以执行设备到设备的复制,甚至是内存到内存的复制(通过事先对内存进行读取,然后对内存进行写入)。
大部分的 DMA 控制器使用物理地址进行传输。使用物理地址需要操作系统将目标内存缓冲区的虚拟地址转换为物理地址,并将该物理地址写入 DMA 控制器的地址寄存器中。另一种方案是一些 DMA 控制器将虚拟地址写入 DMA 控制器中。然后,DMA 控制器必须使用 MMU 才能完成虚拟到物理的转换。仅当 MMU 是内存的一部分而不是 CPU 的一部分时,才可以将虚拟地址放在总线上。
重温中断
在一台个人计算机体系结构中,中断结构会如下所示
当一个 I/O 设备完成它的工作后,它就会产生一个中断(默认操作系统已经开启中断),它通过在总线上声明已分配的信号来实现此目的。主板上的中断控制器芯片会检测到这个信号,然后执行中断操作。
如果在中断前没有其他中断操作阻塞的话,中断控制器将立刻对中断进行处理,如果在中断前还有其他中断操作正在执行
,或者有其他设备发出级别更高
的中断信号的话,那么这个设备将暂时不会处理。在这种情况下,该设备会继续在总线上置起中断信号,直到得到 CPU 服务。
为了处理中断,中断控制器在地址线上放置一个数字,指定要关注的设备是哪个,并声明一个信号以中断 CPU。中断信号导致 CPU 停止当前正在做的工作并且开始做其他事情。地址线上会有一个指向中断向量表
的索引,用来获取下一个程序计数器。这个新获取的程序计数器也就表示着程序将要开始,它会指向程序的开始处。一般情况下,陷阱和中断从这一点上看使用相同的机制,并且常常共享相同的中断向量。中断向量的位置可以硬连线到机器中,也可以位于内存中的任何位置,由 CPU 寄存器指向其起点。
中断服务程序开始运行后,中断服务程序通过将某个值写入中断控制器的 I/O 端口来确认中断。告诉它中断控制器可以自由地发出另一个中断。通过让 CPU 延迟响应来达到多个中断同时到达 CPU 涉及到竞争的情况发生。一些老的计算机没有集中的中断控制器,通常每个设备请求自己的中断。
硬件通常在服务程序开始前保存当前信息。对于不同的 CPU 来说,哪些信息需要保存以及保存在哪里差别很大。不管其他的信息是否保存,程序计数器必须要被保存,这对所有的 CPU 来说都是相同的,以此来恢复中断的进程。所有可见寄存器和大量内部寄存器也应该被保存。
上面说到硬件应该保存当前信息,那么保存在哪里是个问题,一种选择是将其放入到内部寄存器中,在需要时操作系统可以读出这些内部寄存器。这种方法会造成的问题是:一段时间内设备无法响应,直到所有的内部寄存器中存储的信息被读出后,才能恢复运行,以免第二个内部寄存器重写内部寄存器的状态。
第二种方式是在堆栈中保存信息,这也是大部分 CPU 所使用的方式。但是,这种方法也存在问题,因为使用的堆栈不确定,如果使用的是当前堆栈
,则它很可能是用户进程的堆栈。堆栈指针甚至不合法,这样当硬件试图在它所指的地址处写入时,将会导致致命错误。如果使用的是内核堆栈,堆栈指针是合法的并且指向一个固定的页面,这样的机会可能会更大。然而,切换到内核态需要切换 MMU 上下文,并且可能使高速缓存或者 TLB 失效。静态或动态重新装载这些东西将增加中断处理的时间,浪费 CPU 时间。
精确中断和不精确中断
另一个问题是:现代 CPU 大量的采用流水线
并且有时还采用超标量(内部并行)
。在一些老的系统中,每条指令执行完毕后,微程序或硬件将检查是否存在未完成的中断。如果存在,那么程序计数器和 PSW 将被压入堆栈中开始中断序列。在中断程序运行之后,旧的 PSW 和程序计数器将从堆栈中弹出恢复先前的进程。
下面是一个流水线模型
在流水线满的时候出现一个中断会发生什么情况?许多指令正处于不同的执行阶段,中断出现时,程序计数器的值可能无法正确地反应已经执行过的指令和尚未执行的指令的边界。事实上,许多指令可能部分执行力,不同的指令完成的程度或多或少。在这种情况下,a程序计数器更有可能反应的是将要被取出并压入流水线的下一条指令的地址,而不是刚刚被执行单元处理过的指令的地址。
在超标量的设计中,可能更加糟糕
每个指令都可以分解成为微操作,微操作有可能乱序执行,这取决于内部资源(如功能单元和寄存器)的可用性。当中断发生时,某些很久以前启动的指令可能还没开始执行,而最近执行的指令可能将要马上完成。在中断信号出现时,可能存在许多指令处于不同的完成状态,它们与程序计数器之间没有什么关系。
使机器处于良好状态的中断称为精确中断(precise interrupt)
。这样的中断具有四个属性:
- PC (程序计数器)保存在一个已知的地方
- PC 所指向的指令之前所有的指令已经完全执行
- PC 所指向的指令之后所有的指令都没有执行
- PC 所指向的指令的执行状态是已知的
不满足以上要求的中断称为 不精确中断(imprecise interrupt)
,不精确中断让人很头疼。上图描述了不精确中断的现象。指令的执行时序和完成度具有不确定性,而且恢复起来也非常麻烦。
IO 软件原理
I/O 软件目标
设备独立性
现在让我们转向对 I/O 软件的研究,I/O 软件设计一个很重要的目标就是设备独立性(device independence)
。啥意思呢?这意味着我们能够编写访问任何设备的应用程序,而不用事先指定特定的设备。比如你编写了一个能够从设备读入文件的应用程序,那么这个应用程序可以从硬盘、DVD 或者 USB 进行读入,不必再为每个设备定制应用程序。这其实就体现了设备独立性的概念。
再比如说你可以输入一条下面的指令
sort 输入 输出
那么上面这个 输入
就可以接收来自任意类型的磁盘或者键盘,并且 输出
可以写入到任意类型的磁盘或者屏幕。
计算机操作系统是这些硬件的媒介,因为不同硬件它们的指令序列不同,所以需要操作系统来做指令间的转换。
与设备独立性密切相关的一个指标就是统一命名(uniform naming)
。设备的代号应该是一个整数或者是字符串,它们不应该依赖于具体的设备。在 UNIX 中,所有的磁盘都能够被集成到文件系统中,所以用户不用记住每个设备的具体名称,直接记住对应的路径即可,如果路径记不住,也可以通过 ls
等指令找到具体的集成位置。举个例子来说,比如一个 USB 磁盘被挂载到了 /usr/cxuan/backup
下,那么你把文件复制到 /usr/cxuan/backup/device
下,就相当于是把文件复制到了磁盘中,通过这种方式,实现了向任何磁盘写入文件都相当于是向指定的路径输出文件。
错误处理
除了设备独立性
外,I/O 软件实现的第二个重要的目标就是错误处理(error handling)
。通常情况下来说,错误应该交给硬件
层面去处理。如果设备控制器发现了读错误的话,它会尽可能的去修复这个错误。如果设备控制器处理不了这个问题,那么设备驱动程序应该进行处理,设备驱动程序会再次尝试读取操作,很多错误都是偶然性的,如果设备驱动程序无法处理这个错误,才会把错误向上抛到硬件层面(上层)进行处理,很多时候,上层并不需要知道下层是如何解决错误的。这就很像项目经理不用把每个决定都告诉老板;程序员不用把每行代码如何写告诉项目经理。这种处理方式不够透明。
同步和异步传输
I/O 软件实现的第三个目标就是 同步(synchronous)
和 异步(asynchronous,即中断驱动)
传输。这里先说一下同步和异步是怎么回事吧。
同步传输中数据通常以块或帧的形式发送。发送方和接收方在数据传输之前应该具有同步时钟
。而在异步传输中,数据通常以字节或者字符的形式发送,异步传输则不需要同步时钟,但是会在传输之前向数据添加奇偶校验位
。下面是同步和异步的主要区别
回到正题。大部分物理IO(physical I/O)
是异步的。物理 I/O 中的 CPU 是很聪明的,CPU 传输完成后会转而做其他事情,它和中断心灵相通,等到中断发生后,CPU 才会回到传输这件事情上来。
I/O 分为两种:物理I/O 和
逻辑I/O(Logical I/O)
。物理 I/O 通常是从磁盘等存储设备实际获取数据。逻辑 I/O 是对存储器(块,缓冲区)获取数据。
缓冲
I/O 软件的最后一个问题是缓冲(buffering)
。通常情况下,从一个设备发出的数据不会直接到达最后的设备。其间会经过一系列的校验、检查、缓冲等操作才能到达。举个例子来说,从网络上发送一个数据包,会经过一系列检查之后首先到达缓冲区,从而消除缓冲区填满速率和缓冲区过载。
共享和独占
I/O 软件引起的最后一个问题就是共享设备和独占设备的问题。有些 I/O 设备能够被许多用户共同使用。一些设备比如磁盘,让多个用户使用一般不会产生什么问题,但是某些设备必须具有独占性,即只允许单个用户使用完成后才能让其他用户使用。
下面,我们来探讨一下如何使用程序来控制 I/O 设备。一共有三种控制 I/O 设备的方法
- 使用程序控制 I/O
- 使用中断驱动 I/O
- 使用 DMA 驱动 I/O
使用程序控制 I/O
使用程序控制 I/O 又被称为 可编程I/O
,它是指由 CPU 在驱动程序软件控制下启动的数据传输,来访问设备上的寄存器或者其他存储器。CPU 会发出命令,然后等待 I/O 操作的完成。由于 CPU 的速度比 I/O 模块的速度快很多,因此可编程 I/O 的问题在于,CPU 必须等待很长时间才能等到处理结果。CPU 在等待时会采用轮询(polling)
或者 忙等(busy waiting)
的方式,结果,整个系统的性能被严重拉低。可编程 I/O 十分简单,如果需要等待的时间非常短的话,可编程 I/O 倒是一个很好的方式。一个可编程的 I/O 会经历如下操作
- CPU 请求 I/O 操作
- I/O 模块执行响应
- I/O 模块设置状态位
- CPU 会定期检查状态位
- I/O 不会直接通知 CPU 操作完成
- I/O 也不会中断 CPU
- CPU 可能会等待或在随后的过程中返回
使用中断驱动 I/O
鉴于上面可编程 I/O 的缺陷,我们提出一种改良方案,我们想要在 CPU 等待 I/O 设备的同时,能够做其他事情,等到 I/O 设备完成后,它就会产生一个中断,这个中断会停止当前进程并保存当前的状态。一个可能的示意图如下
尽管中断减轻了 CPU 和 I/O 设备的等待时间的负担,但是由于还需要在 CPU 和 I/O 模块之前进行大量的逐字传输,因此在大量数据传输中效率仍然很低。下面是中断的基本操作
- CPU 进行读取操作
- I/O 设备从外围设备获取数据,同时 CPU 执行其他操作
- I/O 设备中断通知 CPU
- CPU 请求数据
- I/O 模块传输数据
所以我们现在着手需要解决的就是 CPU 和 I/O 模块间数据传输的效率问题。
使用 DMA 的 I/O
DMA 的中文名称是直接内存访问,它意味着 CPU 授予 I/O 模块权限在不涉及 CPU 的情况下读取或写入内存。也就是 DMA 可以不需要 CPU 的参与。这个过程由称为 DMA 控制器(DMAC)的芯片管理。由于 DMA 设备可以直接在内存之间传输数据,而不是使用 CPU 作为中介,因此可以缓解总线上的拥塞。DMA 通过允许 CPU 执行任务,同时 DMA 系统通过系统和内存总线传输数据来提高系统并发性。
I/O 层次结构
I/O 软件通常组织成四个层次,它们的大致结构如下图所示
每一层和其上下层都有明确的功能和接口。下面我们采用和计算机网络相反的套路,即自下而上的了解一下这些程序。
下面是另一幅图,这幅图显示了输入/输出软件系统所有层及其主要功能。
下面我们具体的来探讨一下上面的层次结构
中断处理程序
在计算机系统中,中断就像女人的脾气一样无时无刻都在产生,中断的出现往往是让人很不爽的。中断处理程序又被称为中断服务程序
或者是 ISR(Interrupt Service Routines)
,它是最靠近硬件的一层。中断处理程序由硬件中断、软件中断或者是软件异常启动产生的中断,用于实现设备驱动程序或受保护的操作模式(例如系统调用)之间的转换。
中断处理程序负责处理中断发生时的所有操作,操作完成后阻塞,然后启动中断驱动程序来解决阻塞。通常会有三种通知方式,依赖于不同的具体实现
- 信号量实现中:在信号量上使用
up
进行通知; - 管程实现:对管程中的条件变量执行
signal
操作 - 还有一些情况是发送一些消息
不管哪种方式都是为了让阻塞的中断处理程序恢复运行。
中断处理方案有很多种,下面是 《**ARM System Developer’s Guide
Designing and Optimizing System Software》列出来的一些方案
非嵌套
的中断处理程序按照顺序处理各个中断,非嵌套的中断处理程序也是最简单的中断处理嵌套
的中断处理程序会处理多个中断而无需分配优先级可重入
的中断处理程序可使用优先级处理多个中断简单优先级
中断处理程序可处理简单的中断标准优先级
中断处理程序比低优先级的中断处理程序在更短的时间能够处理优先级更高的中断高优先级
中断处理程序在短时间能够处理优先级更高的任务,并直接进入特定的服务例程。优先级分组
中断处理程序能够处理不同优先级的中断任务
下面是一些通用的中断处理程序的步骤,不同的操作系统实现细节不一样
- 保存所有没有被中断硬件保存的寄存器
- 为中断服务程序设置上下文环境,可能包括设置
TLB
、MMU
和页表,如果不太了解这三个概念,请参考另外一篇文章 - 为中断服务程序设置栈
- 对中断控制器作出响应,如果不存在集中的中断控制器,则继续响应中断
- 把寄存器从保存它的地方拷贝到进程表中
- 运行中断服务程序,它会从发出中断的设备控制器的寄存器中提取信息
- 操作系统会选择一个合适的进程来运行。如果中断造成了一些优先级更高的进程变为就绪态,则选择运行这些优先级高的进程
- 为进程设置 MMU 上下文,可能也会需要 TLB,根据实际情况决定
- 加载进程的寄存器,包括 PSW 寄存器
- 开始运行新的进程
上面我们罗列了一些大致的中断步骤,不同性质的操作系统和中断处理程序能够处理的中断步骤和细节也不尽相同,下面是一个嵌套中断的具体运行步骤
设备驱动程序
在上面的文章中我们知道了设备控制器所做的工作。我们知道每个控制器其内部都会有寄存器用来和设备进行沟通,发送指令,读取设备的状态等。
因此,每个连接到计算机的 I/O 设备都需要有某些特定设备的代码对其进行控制,例如鼠标控制器需要从鼠标接受指令,告诉下一步应该移动到哪里,键盘控制器需要知道哪个按键被按下等。这些提供 I/O 设备到设备控制器转换的过程的代码称为 设备驱动程序(Device driver)
。
为了能够访问设备的硬件,实际上也就意味着,设备驱动程序通常是操作系统内核的一部分,至少现在的体系结构是这样的。但是也可以构造用户空间
的设备驱动程序,通过系统调用来完成读写操作。这样就避免了一个问题,有问题的驱动程序会干扰内核,从而造成崩溃。所以,在用户控件实现设备驱动程序是构造系统稳定性一个非常有用的措施。MINIX 3
就是这么做的。下面是 MINI 3 的调用过程
然而,大多数桌面操作系统要求驱动程序必须运行在内核中。
操作系统通常会将驱动程序归为 字符设备
和 块设备
,我们上面也介绍过了
在 UNIX 系统中,操作系统是一个二进制程序
,包含需要编译到其内部的所有驱动程序,如果你要对 UNIX 添加一个新设备,需要重新编译内核,将新的驱动程序装到二进制程序中。
然而随着大多数个人计算机的出现,由于 I/O 设备的广泛应用,上面这种静态编译的方式不再有效,因此,从 MS-DOS
开始,操作系统转向驱动程序在执行期间动态的装载到系统中。
设备驱动程序具有很多功能,比如接受读写请求,对设备进行初始化、管理电源和日志、对输入参数进行有效性检查等。
设备驱动程序接受到读写请求后,会检查当前设备是否在使用,如果设备在使用,请求被排入队列中,等待后续的处理。如果此时设备是空闲的,驱动程序会检查硬件以了解请求是否能够被处理。在传输开始前,会启动设备或者马达。等待设备就绪完成,再进行实际的控制。控制设备就是对设备发出指令。
发出命令后,设备控制器便开始将它们写入控制器的设备寄存器
。在将每个命令写入控制器后,会检查控制器是否接受了这条命令并准备接受下一个命令。一般控制设备会发出一系列的指令,这称为指令序列
,设备控制器会依次检查每个命令是否被接受,下一条指令是否能够被接收,直到所有的序列发出为止。
发出指令后,一般会有两种可能出现的情况。在大多数情况下,设备驱动程序会进行等待直到控制器完成它的事情。这里需要了解一下设备控制器的概念
设备控制器的主要主责是控制一个或多个 I/O 设备,以实现 I/O 设备和计算机之间的数据交换。
设备控制器接收从 CPU 发送过来的指令,继而达到控制硬件的目的
设备控制器是一个可编址
的设备,当它仅控制一个设备时,它只有一个唯一的设备地址;如果设备控制器控制多个可连接设备时,则应含有多个设备地址,并使每一个设备地址对应一个设备。
设备控制器主要分为两种:字符设备和块设备
设备控制器的主要功能有下面这些
-
接收和识别命令:设备控制器可以接受来自 CPU 的指令,并进行识别。设备控制器内部也会有寄存器,用来存放指令和参数
-
进行数据交换:CPU、控制器和设备之间会进行数据的交换,CPU 通过总线把指令发送给控制器,或从控制器中并行地读出数据;控制器将数据写入指定设备。
-
地址识别:每个硬件设备都有自己的地址,设备控制器能够识别这些不同的地址,来达到控制硬件的目的,此外,为使 CPU 能向寄存器中写入或者读取数据,这些寄存器都应具有唯一的地址。
-
差错检测:设备控制器还具有对设备传递过来的数据进行检测的功能。
在这种情况下,设备控制器会阻塞,直到中断来解除阻塞状态。还有一种情况是操作是可以无延迟的完成,所以驱动程序不需要阻塞。在第一种情况下,操作系统可能被中断唤醒;第二种情况下操作系统不会被休眠。
设备驱动程序必须是可重入
的,因为设备驱动程序会阻塞和唤醒然后再次阻塞。驱动程序不允许进行系统调用,但是它们通常需要与内核的其余部分进行交互。
与设备无关的 I/O 软件
I/O 软件有两种,一种是我们上面介绍过的基于特定设备的,还有一种是设备无关性
的,设备无关性也就是不需要特定的设备。设备驱动程序与设备无关的软件之间的界限取决于具体的系统。下面显示的功能由设备无关的软件实现
与设备无关的软件的基本功能是对所有设备执行公共的 I/O 功能,并且向用户层软件提供一个统一的接口。
缓冲
无论是对于块设备还是字符设备来说,缓冲都是一个非常重要的考量标准。下面是从 ADSL(调制解调器)
读取数据的过程,调制解调器是我们用来联网的设备。
用户程序调用 read 系统调用阻塞用户进程,等待字符的到来,这是对到来的字符进行处理的一种方式。每一个到来的字符都会造成中断。中断服务程序
会给用户进程提供字符,并解除阻塞。将字符提供给用户程序后,进程会去读取其他字符并继续阻塞,这种模型如下
这一种方案是没有缓冲区的存在,因为用户进程如果读不到数据会阻塞,直到读到数据为止,这种情况效率比较低,而且阻塞式的方式,会直接阻止用户进程做其他事情,这对用户来说是不能接受的。还有一种情况就是每次用户进程都会重启,对于每个字符的到来都会重启用户进程,这种效率会严重降低,所以无缓冲区的软件不是一个很好的设计。
作为一个改良点,我们可以尝试在用户空间中使用一个能读取 n 个字节缓冲区来读取 n 个字符。这样的话,中断服务程序会把字符放到缓冲区中直到缓冲区变满为止,然后再去唤醒用户进程。这种方案要比上面的方案改良很多。
但是这种方案也存在问题,当字符到来时,如果缓冲区被调出内存会出现什么问题?解决方案是把缓冲区锁定在内存中,但是这种方案也会出现问题,如果少量的缓冲区被锁定还好,如果大量的缓冲区被锁定在内存中,那么可以换进换出的页面就会收缩,造成系统性能的下降。
一种解决方案是在内核
中内部创建一块缓冲区,让中断服务程序将字符放在内核内部的缓冲区中。
当内核中的缓冲区要满的时候,会将用户空间中的页面调入内存,然后将内核空间的缓冲区复制到用户空间的缓冲区中,这种方案也面临一个问题就是假如用户空间的页面被换入内存,此时内核空间的缓冲区已满,这时候仍有新的字符到来,这个时候会怎么办?因为缓冲区满了,没有空间来存储新的字符了。
一种非常简单的方式就是再设置一个缓冲区就行了,在第一个缓冲区填满后,在缓冲区清空前,使用第二个缓冲区,这种解决方式如下
当第二个缓冲区也满了的时候,它也会把数据复制到用户空间中,然后第一个缓冲区用于接受新的字符。这种具有两个缓冲区的设计被称为 双缓冲(double buffering)
。
还有一种缓冲形式是 循环缓冲(circular buffer)
。它由一个内存区域和两个指针组成。一个指针指向下一个空闲字,新的数据可以放在此处。另外一个指针指向缓冲区中尚未删除数据的第一个字。在许多情况下,硬件会在添加新的数据时,移动第一个指针;而操作系统会在删除和处理无用数据时会移动第二个指针。两个指针到达顶部时就回到底部重新开始。
缓冲区对输出来说也很重要。对输出的描述和输入相似
缓冲技术应用广泛,但它也有缺点。如果数据被缓冲次数太多,会影响性能。考虑例如如下这种情况,
数据经过用户进程 -> 内核空间 -> 网络控制器,这里的网络控制器应该就相当于是 socket 缓冲区,然后发送到网络上,再到接收方的网络控制器 -> 接收方的内核缓冲 -> 接收方的用户缓冲,一条数据包被缓存了太多次,很容易降低性能。
错误处理
在 I/O 中,出错是一种再正常不过的情况了。当出错发生时,操作系统必须尽可能处理这些错误。有一些错误是只有特定的设备才能处理,有一些是由框架进行处理,这些错误和特定的设备无关。
I/O 错误的一类是程序员编程
错误,比如还没有打开文件前就读流,或者不关闭流导致内存溢出等等。这类问题由程序员处理;另外一类是实际的 I/O 错误,例如向一个磁盘坏块写入数据,无论怎么写都写入不了。这类问题由驱动程序处理,驱动程序处理不了交给硬件处理,这个我们上面也说过。
设备驱动程序统一接口
我们在操作系统概述中说到,操作系统一个非常重要的功能就是屏蔽了硬件和软件的差异性,为硬件和软件提供了统一的标准,这个标准还体现在为设备驱动程序提供统一的接口,因为不同的硬件和厂商编写的设备驱动程序不同,所以如果为每个驱动程序都单独提供接口的话,这样没法搞,所以必须统一。
分配和释放
一些设备例如打印机,它只能由一个进程来使用,这就需要操作系统根据实际情况判断是否能够对设备的请求进行检查,判断是否能够接受其他请求,一种比较简单直接的方式是在特殊文件上执行 open
操作。如果设备不可用,那么直接 open 会导致失败。还有一种方式是不直接导致失败,而是让其阻塞,等到另外一个进程释放资源后,在进行 open 打开操作。这种方式就把选择权交给了用户,由用户判断是否应该等待。
注意:阻塞的实现有多种方式,有阻塞队列等
设备无关的块
不同的磁盘会具有不同的扇区大小,但是软件不会关心扇区大小,只管存储就是了。一些字符设备可以一次一个字节的交付数据,而其他的设备则以较大的单位交付数据,这些差异也可以隐藏起来。
用户空间的 I/O 软件
虽然大部分 I/O 软件都在内核结构中,但是还有一些在用户空间实现的 I/O 软件,凡事没有绝对。一些 I/O 软件和库过程在用户空间存在,然后以提供系统调用的方式实现。
盘
盘可以说是硬件里面比较简单的构造了,同时也是最重要的。下面我们从盘谈起,聊聊它的物理构造
盘硬件
盘会有很多种类型。其中最简单的构造就是磁盘(magnetic hard disks)
, 也被称为 hard disk,HDD
等。磁盘通常与安装在磁臂上的磁头配对,磁头可将数据读取或者将数据写入磁盘,因此磁盘的读写速度都同样快。在磁盘中,数据是随机访问的,这也就说明可以通过任意的顺序来存储
和检索
单个数据块,所以你可以在任意位置放置磁盘来让磁头读取,磁盘是一种非易失性
的设备,即使断电也能永久保留。
在计算机发展早期一般是用光盘来存储数据的,然而随着固态硬盘的流行,固态硬盘不包含运动部件的特点,成为现在计算机的首选存储方式。
磁盘
为了组织和检索数据,会将磁盘组织成特定的结构,这些特定的结构就是**磁道、扇区和柱面
每一个磁盘都是由无数个同心圆组成,这些同心圆就好像树的年轮一样
部分树的年轮照片都要付费下载了,不敢直接白嫖,阔怕阔怕。
磁盘被组织成柱面形式,每个盘用轴相连,每一个柱面包含若干磁道,每个磁道由若干扇区组成。软盘上大约每个磁道有 8 – 32 个扇区,硬盘上每条磁道上扇区的数量可达几百个,磁头大约是 1 – 16 个。
对于磁盘驱动程序来说,一个非常重要的特性就是控制器是否能够同时控制两个或者多个驱动器进行磁道寻址,这就是重叠寻道(overlapped seek)
。对于控制器来说,它能够控制一个磁盘驱动程序完成寻道操作,同时让其他驱动程序等待寻道结束。控制器也可以在一个驱动程序上进行读写草哦做,与此同时让另外的驱动器进行寻道操作,但是软盘控制器不能在两个驱动器上进行读写操作。
RAID
RAID 称为 磁盘冗余阵列
,简称 磁盘阵列
。利用虚拟化技术把多个硬盘结合在一起,成为一个或多个磁盘阵列组,目的是提升性能或数据冗余。
RAID 有不同的级别
- RAID 0 – 无容错的条带化磁盘阵列
- RAID 1 – 镜像和双工
- RAID 2 – 内存式纠错码
- RAID 3 – 比特交错奇偶校验
- RAID 4 – 块交错奇偶校验
- RAID 5 – 块交错分布式奇偶校验
- RAID 6 – P + Q冗余
磁盘格式化
磁盘由一堆铝的、合金或玻璃的盘片组成,磁盘刚被创建出来后,没有任何信息。磁盘在使用前必须经过低级格式化(low-levvel format)
,下面是一个扇区的格式
前导码相当于是标示扇区的开始位置,通常以位模式开始,前导码还包括柱面号
、扇区号
等一些其他信息。紧随前导码后面的是数据区,数据部分的大小由低级格式化程序来确定。大部分磁盘使用 512 字节的扇区。数据区后面是 ECC,ECC 的全称是 error correction code ,数据纠错码
,它与普通的错误检测不同,ECC 还可以用于恢复读错误。ECC 阶段的大小由不同的磁盘制造商实现。ECC 大小的设计标准取决于设计者愿意牺牲多少磁盘空间来提高可靠性,以及程序可以处理的 ECC 的复杂程度。通常情况下 ECC 是 16 位,除此之外,硬盘一般具有一定数量的备用扇区,用于替换制造缺陷的扇区。
低级格式化后的每个 0 扇区的位置都和前一个磁道存在偏移
,如下图所示
这种方式又被称为 柱面斜进(cylinder skew)
,之所以采用这种方式是为了提高程序的运行性能。可以这样想,磁盘在转动的过程中会经由磁头来读取扇区信息,在读取内侧一圈扇区数据后,磁头会进行向外侧磁道的寻址操作,寻址操作的同时磁盘在继续转动,如果不采用这种方式,可能刚好磁头寻址到外侧,0 号扇区已经转过了磁头,所以需要旋转一圈才能等到它继续读取,通过柱面斜进的方式可以消除这一问题。
柱面斜进量取决于驱动器的几何规格。柱面斜进量就是两个相邻同心圆 0 号扇区的差异量。如下图所示
这里需要注意一点,不只有柱面存在斜进,磁头也会存在斜进(head skew)
,但是磁头斜进比较小。
磁盘格式化会减少磁盘容量,减少的磁盘容量都会由前导码、扇区间间隙和 ECC 的大小以及保留的备用扇区数量。
在磁盘使用前,还需要经过最后一道工序,那就是对每个分区分别执行一次高级格式化(high-level format)
,这一操作要设置一个引导块、空闲存储管理(采用位图或者是空闲列表)、根目录和空文件系统。这一步操作会把码放在分区表项中,告诉分区使用的是哪种文件系统,因为许多操作系统支持多个兼容的文件系统。在这一步之后,系统就可以进行引导过程。
当电源通电后,BIOS 首先运行,它会读取主引导记录并跳转到主引导记录中。然后引导程序会检查以了解哪个分区是处于活动的。然后,它从该分区读取启动扇区(boot sector)
并运行它。启动扇区包含一个小程序来加载一个更大一点的引导器来搜索文件系统以找到系统内核(system kernel)
,然后程序被转载进入内存并执行。
这里说下什么是引导扇区:引导扇区是磁盘或者存储设备的保留扇区,其中包含用于完成计算机或磁盘引导过程所必要的数据或者代码。
引导扇区存储引导记录数据,这些数据用于在计算机启动时提供指令。有两种不同类型的引导扇区
- Master boot record 称为主引导扇区
- Volume boot record 卷启动记录
对于分区磁盘,引导扇区由主引导记录组成;
非分区磁盘由卷启动记录组成。
磁盘臂调度算法
下面我们来探讨一下关于影响磁盘读写的算法,一般情况下,影响磁盘快读写的时间由下面几个因素决定
- 寻道时间 – 寻道时间指的就是将磁盘臂移动到需要读取磁盘块上的时间
- 旋转延迟 – 等待合适的扇区旋转到磁头下所需的时间
- 实际数据的读取或者写入时间
这三种时间参数也是磁盘寻道的过程。一般情况下,寻道时间对总时间的影响最大,所以,有效的降低寻道时间能够提高磁盘的读取速度。
如果磁盘驱动程序每次接收一个请求并按照接收顺序完成请求,这种处理方式也就是 先来先服务(First-Come, First-served, FCFS)
,这种方式很难优化寻道时间。因为每次都会按照顺序处理,不管顺序如何,有可能这次读完后需要等待一个磁盘旋转一周才能继续读取,而其他柱面能够马上进行读取,这种情况下每次请求也会排队。
通常情况下,磁盘在进行寻道时,其他进程会产生其他的磁盘请求。磁盘驱动程序会维护一张表,表中会记录着柱面号当作索引,每个柱面未完成的请求会形成链表,链表头存放在表的相应表项中。
一种对先来先服务的算法改良的方案是使用 最短路径优先(SSF)
算法,下面描述了这个算法。
假如我们在对磁道 6 号进行寻址时,同时发生了对 11 , 2 , 4, 14, 8, 15, 3 的请求,如果采用先来先服务的原则,如下图所示
我们可以计算一下磁盘臂所跨越的磁盘数量为 5 + 9 + 2 + 10 + 6 + 7 + 12 = 51,相当于是跨越了 51 次盘面,如果使用最短路径优先,我们来计算一下跨越的盘面
跨越的磁盘数量为 4 + 1 + 1 + 4 + 3 + 3 + 1 = 17 ,相比 51 足足省了两倍的时间。
但是,最短路径优先的算法也不是完美无缺的,这种算法照样存在问题,那就是优先级
问题,
这里有一个原型可以参考就是我们日常生活中的电梯,电梯使用一种电梯算法(elevator algorithm)
来进行调度,从而满足协调效率和公平性这两个相互冲突的目标。电梯一般会保持向一个方向移动,直到在那个方向上没有请求为止,然后改变方向。
电梯算法需要维护一个二进制位
,也就是当前的方向位:UP(向上)
或者是 DOWN(向下)
。当一个请求处理完成后,磁盘或电梯的驱动程序会检查该位,如果此位是 UP 位,磁盘臂或者电梯仓移到下一个更高跌未完成的请求。如果高位没有未完成的请求,则取相反方向。当方向位是 DOWN
时,同时存在一个低位的请求,磁盘臂会转向该点。如果不存在的话,那么它只是停止并等待。
我们举个例子来描述一下电梯算法,比如各个柱面得到服务的顺序是 4,7,10,14,9,6,3,1 ,那么它的流程图如下
所以电梯算法需要跨越的盘面数量是 3 + 3 + 4 + 5 + 3 + 3 + 1 = 22
电梯算法通常情况下不如 SSF 算法。
一些磁盘控制器为软件提供了一种检查磁头下方当前扇区号的方法,使用这样的控制器,能够进行另一种优化。如果对一个相同的柱面有两个或者多个请求正等待处理,驱动程序可以发出请求读写下一次要通过磁头的扇区。
这里需要注意一点,当一个柱面有多条磁道时,相继的请求可能针对不同的磁道,这种选择没有代价,因为选择磁头不需要移动磁盘臂也没有旋转延迟。
对于磁盘来说,最影响性能的就是寻道时间和旋转延迟,所以一次只读取一个或两个扇区的效率是非常低的。出于这个原因,许多磁盘控制器总是读出多个扇区并进行高速缓存,即使只请求一个扇区时也是这样。一般情况下读取一个扇区的同时会读取该扇区所在的磁道或者是所有剩余的扇区被读出,读出扇区的数量取决于控制器的高速缓存中有多少可用的空间。
磁盘控制器的高速缓存和操作系统的高速缓存有一些不同,磁盘控制器的高速缓存用于缓存没有实际被请求的块,而操作系统维护的高速缓存由显示地读出的块组成,并且操作系统会认为这些块在近期仍然会频繁使用。
当同一个控制器上有多个驱动器时,操作系统应该为每个驱动器都单独的维护一个未完成的请求表。一旦有某个驱动器闲置时,就应该发出一个寻道请求来将磁盘臂移到下一个被请求的柱面。如果下一个寻道请求到来时恰好没有磁盘臂处于正确的位置,那么驱动程序会在刚刚完成传输的驱动器上发出一个新的寻道命令并等待,等待下一次中断到来时检查哪个驱动器处于闲置状态。
错误处理
磁盘在制造的过程中可能会有瑕疵,如果瑕疵比较小,比如只有几位,那么使用坏扇区并且每次只是让 ECC 纠正错误是可行的,如果瑕疵较大,那么错误就不可能被掩盖。
一般坏块有两种处理办法,一种是在控制器中进行处理;一种是在操作系统层面进行处理。
这两种方法经常替换使用,比如一个具有 30 个数据扇区和两个备用扇区的磁盘,其中扇区 4 是有瑕疵的。
控制器能做的事情就是将备用扇区之一重新映射。
还有一种处理方式是将所有的扇区都向上移动一个扇区
上面这这两种情况下控制器都必须知道哪个扇区,可以通过内部的表来跟踪这一信息,或者通过重写前导码来给出重新映射的扇区号。如果是重写前导码,那么涉及移动的方式必须重写后面所有的前导码,但是最终会提供良好的性能。
稳定存储器
磁盘经常会出现错误,导致好的扇区会变成坏扇区,驱动程序也有可能挂掉。RAID 可以对扇区出错或者是驱动器崩溃提出保护,然而 RAID 却不能对坏数据中的写错误提供保护,也不能对写操作期间的崩溃提供保护,这样就会破坏原始数据。
我们期望磁盘能够准确无误的工作,但是事实情况是不可能的,但是我们能够知道的是,一个磁盘子系统具有如下特性:当一个写命令发给它时,磁盘要么正确地写数据,要么什么也不做,让现有的数据完整无误的保留。这样的系统称为 稳定存储器(stable storage)
。 稳定存储器的目标就是不惜一切代价保证磁盘的一致性。
稳定存储器使用两个一对相同的磁盘,对应的块一同工作形成一个无差别的块。稳定存储器为了实现这个目的,定义了下面三种操作:
稳定写(stable write)
稳定读(stable read)
崩溃恢复(crash recovery)
稳定写指的就是首先将块写到比如驱动器 1 上,然后将其读回来验证写入的是否正确,如果不正确,那么就会再次尝试写入和读取,一直到能够验证写入正确为止。如果块都写完了也没有验证正确,就会换块继续写入和读取,直到正确为止。无论尝试使用多少个备用块,都是在对你驱动器 1 写入成功之后,才会对驱动器 2 进行写入和读取。这样我们相当于是对两个驱动器进行写入。
稳定读指的就是首先从驱动器 1 上进行读取,如果读取操作会产生错误的 ECC,则再次尝试读取,如果所有的读取操作都会给出错误的 ECC,那么会从驱动器 2 上进行读取。这样我们相当于是对两个驱动器进行读取。
崩溃恢复指的是崩溃之后,恢复程序扫描两个磁盘,比较对应的块。如果一对块都是好的并且是相同的,就不会触发任何机制;如果其中一个块触发了 ECC 错误,这时候就需要使用好块来覆盖坏块。
如果 CPU 没有崩溃的话,那么这种方式是可行的,因为稳定写总是对每个块写下两个有效的副本,并且假设自发的错误不会再相同的时刻发生在两个对应的块上。如果在稳定写期间出现 CPU 崩溃会怎么样?这就取决于崩溃发生的精确时间,有五种情况,下面来说一下
- 第一种情况是崩溃发生在写入之前,在恢复的时候就什么都不需要修改,旧的值也会继续存在。
- 第二种情况是 CPU 崩溃发生在写入驱动器 1 的时候,崩溃导致块内容被破坏,然而恢复程序能够检测出这一种错误,并且从驱动器 2 恢复驱动器 1 上的块。
- 第三种情况是崩溃发生在磁盘驱动器 1 之后但是还没有写驱动器 2 之前,这种情况下由于磁盘 1 已经写入成功
- 第四种情况是崩溃发生在磁盘驱动 1 写入后在磁盘驱动 2 写入时,恢复期间会用好的块替换坏的块,两个块的最终值都是最新的
- 最后一种情况就是崩溃发生在两个磁盘驱动写入后,这种情况下不会发生任何问题
这种模式下进行任何优化和改进都是可行的,但是代价高昂,一种改进是在稳定写期间监控被写入的块,这样在崩溃后进行检验的块只有一个。有一种 非易失性 RAM
能够在崩溃之后保留数据,但是这种方式并不推荐使用。
时钟
时钟(Clocks)
也被称为定时器(timers)
,时钟/定时器对任何程序系统来说都是必不可少的。时钟负责维护时间、防止一个进程长期占用 CPU 时间等其他功能。时钟软件(clock software)
也是一种设备驱动的方式。下面我们就来对时钟进行介绍,一般都是先讨论硬件再介绍软件,采用由下到上的方式,也是告诉你,底层是最重要的。
时钟硬件
在计算机中有两种类型的时钟,这些时钟与现实生活中使用的时钟完全不一样。
- 比较简单的一种时钟被连接到 110 V 或 220 V 的电源线上,这样每个
电压周期
会产生一个中断,大概是 50 – 60 HZ。这些时钟过去一直占据支配地位。 - 另外的一种时钟由晶体振荡器、计数器和寄存器组成,示意图如下所示
这种时钟称为可编程时钟
,可编程时钟有两种模式,一种是 一键式(one-shot mode)
,当时钟启动时,会把存储器中的值复制到计数器中,然后,每次晶体的振荡器的脉冲都会使计数器 -1。当计数器变为 0 时,会产生一个中断,并停止工作,直到软件再一次显示启动。还有一种模式时 方波(square-wave mode)
模式,在这种模式下,当计数器变为 0 并产生中断后,存储寄存器的值会自动复制到计数器中,这种周期性的中断称为一个时钟周期。
时钟软件
时钟硬件所做的工作只是根据已知的时间间隔产生中断,而其他的工作都是由时钟软件
来完成,一般操作系统的不同,时钟软件的具体实现也不同,但是一般都会包括以下这几点
- 维护一天的时间
- 阻止进程运行的时间超过其指定时间
- 统计 CPU 的使用情况
- 处理用户进程的警告系统调用
- 为系统各个部分提供看门狗定时器
- 完成概要剖析,监视和信息收集
软定时器
时钟软件也被称为可编程时钟,可以设置它以程序需要的任何速率引发中断。时钟软件触发的中断是一种硬中断,但是某些应用程序对于硬中断来说是不可接受的。
这时候就需要一种软定时器(soft timer)
避免了中断,无论何时当内核因为某种原因呢在运行时,它返回用户态之前都会检查时钟来了解软定时器是否到期。如果软定时器到期,则执行被调度的事件也无需切换到内核态,因为本身已经处于内核态中。这种方式避免了频繁的内核态和用户态之前的切换,提高了程序运行效率。
软定时器因为不同的原因切换进入内核态的速率不同,原因主要有
- 系统调用
- TLB 未命中
- 缺页异常
- I/O 中断
- CPU 变得空闲
我发起了一个成为最好的 bestJavaer 开源项目,地址是 https://github.com/crisxuan/bestJavaer,欢迎各位 star。
操作系统必知面试题
大家好,我是 cxuan,我之前汇总了一下关于操作系统的面试题,最近又重新翻阅了一下发现不是很全,现在也到了面试季了,所以我又花了一周的时间修订整理了一下这份面试题,这份面试题可以吊打市面上所有的操作系统面试题了,不是我说,是因为我系统查过,如果有不相信的大佬,欢迎狠狠的打我脸。
这份面试题有 43 道题,囊括了校招面试和社招面试,看完这一篇文章,保准你能和面试官侃侃而谈,增加进入大厂的几率!
话不多说,下面我们直接进入面试题。
操作系统简介篇
解释一下什么是操作系统
操作系统是管理硬件和软件的一种应用程序。操作系统是运行在计算机上最重要的一种软件
,它管理计算机的资源和进程以及所有的硬件和软件。它为计算机硬件和软件提供了一种中间层,使应用软件和硬件进行分离,让我们无需关注硬件的实现,把关注点更多放在软件应用上。
通常情况下,计算机上会运行着许多应用程序,它们都需要对内存和 CPU 进行交互,操作系统的目的就是为了保证这些访问和交互能够准确无误的进行。
操作系统的主要功能
一般来说,现代操作系统主要提供下面几种功能
进程管理
: 进程管理的主要作用就是任务调度,在单核处理器下,操作系统会为每个进程分配一个任务,进程管理的工作十分简单;而在多核处理器下,操作系统除了要为进程分配任务外,还要解决处理器的调度、分配和回收等问题内存管理
:内存管理主要是操作系统负责管理内存的分配、回收,在进程需要时分配内存以及在进程完成时回收内存,协调内存资源,通过合理的页面置换算法进行页面的换入换出设备管理
:根据确定的设备分配原则对设备进行分配,使设备与主机能够并行工作,为用户提供良好的设备使用界面。文件管理
:有效地管理文件的存储空间,合理地组织和管理文件系统,为文件访问和文件保护提供更有效的方法及手段。提供用户接口
:操作系统提供了访问应用程序和硬件的接口,使用户能够通过应用程序发起系统调用从而操纵硬件,实现想要的功能。
软件访问硬件的几种方式
软件访问硬件其实就是一种 IO 操作,软件访问硬件的方式,也就是 I/O 操作的方式有哪些。
硬件在 I/O 上大致分为并行和串行,同时也对应串行接口和并行接口。
随着计算机技术的发展,I/O 控制方式也在不断发展。选择和衡量 I/O 控制方式有如下三条原则
(1) 数据传送速度足够快,能满足用户的需求但又不丢失数据;
(2) 系统开销小,所需的处理控制程序少;
(3) 能充分发挥硬件资源的能力,使 I/O 设备尽可能忙,而 CPU 等待时间尽可能少。
根据以上控制原则,I/O 操作可以分为四类
直接访问
:直接访问由用户进程直接控制主存或 CPU 和外围设备之间的信息传送。直接程序控制方式又称为忙/等待方式。中断驱动
:为了减少程序直接控制方式下 CPU 的等待时间以及提高系统的并行程度,系统引入了中断机制。中断机制引入后,外围设备仅当操作正常结束或异常结束时才向 CPU 发出中断请求。在 I/O 设备输入每个数据的过程中,由于无需 CPU 的干预,一定程度上实现了 CPU 与 I/O 设备的并行工作。
上述两种方法的特点都是以 CPU
为中心,数据传送通过一段程序来实现,软件的传送手段限制了数据传送的速度。接下来介绍的这两种 I/O 控制方式采用硬件的方法来显示 I/O 的控制
DMA 直接内存访问
:为了进一步减少 CPU 对 I/O 操作的干预,防止因并行操作设备过多使 CPU 来不及处理或因速度不匹配而造成的数据丢失现象,引入了 DMA 控制方式。通道控制方式
:通道,独立于 CPU 的专门负责输入输出控制的处理机,它控制设备与内存直接进行数据交换。有自己的通道指令,这些指令由 CPU 启动,并在操作结束时向 CPU 发出中断信号。
解释一下操作系统的主要目的是什么
操作系统是一种软件,它的主要目的有三种
- 管理计算机资源,这些资源包括 CPU、内存、磁盘驱动器、打印机等。
- 提供一种图形界面,就像我们前面描述的那样,它提供了用户和计算机之间的桥梁。
- 为其他软件提供服务,操作系统与软件进行交互,以便为其分配运行所需的任何必要资源。
操作系统的种类有哪些
操作系统通常预装在你购买计算机之前。大部分用户都会使用默认的操作系统,但是你也可以升级甚至更改操作系统。但是一般常见的操作系统只有三种:Windows、macOS 和 Linux。
为什么 Linux 系统下的应用程序不能直接在 Windows 下运行
这是一个老生常谈的问题了,在这里给出具体的回答。
其中一点是因为 Linux 系统和 Windows 系统的格式不同,格式就是协议,就是在固定位置有意义的数据。Linux 下的可执行程序文件格式是 elf
,可以使用 readelf
命令查看 elf 文件头。
而 Windows 下的可执行程序是 PE
格式,它是一种可移植的可执行文件。
还有一点是因为 Linux 系统和 Windows 系统的 API
不同,这个 API 指的就是操作系统的 API,Linux 中的 API 被称为系统调用
,是通过 int 0x80
这个软中断实现的。而 Windows 中的 API 是放在动态链接库文件中的,也就是 Windows 开发人员所说的 DLL
,这是一个库,里面包含代码和数据。Linux 中的可执行程序获得系统资源的方法和 Windows 不一样,所以显然是不能在 Windows 中运行的。
操作系统结构
单体系统
在大多数系统中,整个系统在内核态以单一程序的方式运行。整个操作系统是以程序集合来编写的,链接在一块形成一个大的二进制可执行程序,这种系统称为单体系统。
在单体系统中构造实际目标程序时,会首先编译所有单个过程(或包含这些过程的文件),然后使用系统链接器将它们全部绑定到一个可执行文件中
在单体系统中,对于每个系统调用都会有一个服务程序来保障和运行。需要一组实用程序来弥补服务程序需要的功能,例如从用户程序中获取数据。可将各种过程划分为一个三层模型
除了在计算机初启动时所装载的核心操作系统外,许多操作系统还支持额外的扩展。比如 I/O 设备驱动和文件系统。这些部件可以按需装载。在 UNIX 中把它们叫做 共享库(shared library)
,在 Windows 中则被称为 动态链接库(Dynamic Link Library,DLL)
。他们的扩展名为 .dll
,在 C:\Windows\system32
目录下存在 1000 多个 DLL 文件,所以不要轻易删除 C 盘文件,否则可能就炸了哦。
分层系统
分层系统使用层来分隔不同的功能单元。每一层只与该层的上层和下层通信。每一层都使用下面的层来执行其功能。层之间的通信通过预定义的固定接口通信。
微内核
为了实现高可靠性,将操作系统划分成小的、层级之间能够更好定义的模块是很有必要的,只有一个模块 — 微内核 — 运行在内核态,其余模块可以作为普通用户进程运行。由于把每个设备驱动和文件系统分别作为普通用户进程,这些模块中的错误虽然会使这些模块崩溃,但是不会使整个系统死机。
MINIX 3
是微内核的代表作,它的具体结构如下
在内核的外部,系统的构造有三层,它们都在用户态下运行,最底层是设备驱动器。由于它们都在用户态下运行,所以不能物理的访问 I/O 端口空间,也不能直接发出 I/O 命令。相反,为了能够对 I/O 设备编程,驱动器构建一个结构,指明哪个参数值写到哪个 I/O 端口,并声称一个内核调用,这样就完成了一次调用过程。
客户-服务器模式
微内核思想的策略是把进程划分为两类:服务器
,每个服务器用来提供服务;客户端
,使用这些服务。这个模式就是所谓的 客户-服务器
模式。
客户-服务器模式会有两种载体,一种情况是一台计算机既是客户又是服务器,在这种方式下,操作系统会有某种优化;但是普遍情况下是客户端和服务器在不同的机器上,它们通过局域网或广域网连接。
客户通过发送消息与服务器通信,客户端并不需要知道这些消息是在本地机器上处理,还是通过网络被送到远程机器上处理。对于客户端而言,这两种情形是一样的:都是发送请求并得到回应。
为什么称为陷入内核
如果把软件结构进行分层说明的话,应该是这个样子的,最外层是应用程序,里面是操作系统内核。
应用程序处于特权级 3,操作系统内核处于特权级 0 。如果用户程序想要访问操作系统资源时,会发起系统调用,陷入内核,这样 CPU 就进入了内核态,执行内核代码。至于为什么是陷入,我们看图,内核是一个凹陷的构造,有陷下去的感觉,所以称为陷入。
什么是用户态和内核态
用户态和内核态是操作系统的两种运行状态。
-
内核态
:处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态。 -
用户态
:处于用户态的 CPU 只能受限的访问内存,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。
那么为什么要有用户态和内核态呢?
这个主要是访问能力的限制的考量,计算机中有一些比较危险的操作,比如设置时钟、内存清理,这些都需要在内核态下完成,如果随意进行这些操作,那你的系统得崩溃多少次。
用户态和内核态是如何切换的?
所有的用户进程都是运行在用户态的,但是我们上面也说了,用户程序的访问能力有限,一些比较重要的比如从硬盘读取数据,从键盘获取数据的操作则是内核态才能做的事情,而这些数据却又对用户程序来说非常重要。所以就涉及到两种模式下的转换,即用户态 -> 内核态 -> 用户态,而唯一能够做这些操作的只有 系统调用
,而能够执行系统调用的就只有 操作系统
。
一般用户态 -> 内核态的转换我们都称之为 trap 进内核,也被称之为 陷阱指令(trap instruction)
。
他们的工作流程如下:
- 首先用户程序会调用
glibc
库,glibc 是一个标准库,同时也是一套核心库,库中定义了很多关键 API。 - glibc 库知道针对不同体系结构调用
系统调用
的正确方法,它会根据体系结构应用程序的二进制接口设置用户进程传递的参数,来准备系统调用。 - 然后,glibc 库调用
软件中断指令(SWI)
,这个指令通过更新CPSR
寄存器将模式改为超级用户模式,然后跳转到地址0x08
处。 - 到目前为止,整个过程仍处于用户态下,在执行 SWI 指令后,允许进程执行内核代码,MMU 现在允许内核虚拟内存访问
- 从地址 0x08 开始,进程执行加载并跳转到中断处理程序,这个程序就是 ARM 中的
vector_swi()
。 - 在 vector_swi() 处,从 SWI 指令中提取系统调用号 SCNO,然后使用 SCNO 作为系统调用表
sys_call_table
的索引,调转到系统调用函数。 - 执行系统调用完成后,将还原用户模式寄存器,然后再以用户模式执行。
什么是内核
在计算机中,内核是一个计算机程序,它是操作系统的核心,可以控制操作系统中所有的内容。内核通常是在 boot loader 装载程序之前加载的第一个程序。
这里还需要了解一下什么是 boot loader
。
boot loader 又被称为引导加载程序,能够将计算机的操作系统放入内存中。在电源通电或者计算机重启时,BIOS 会执行一些初始测试,然后将控制权转移到引导加载程序所在的
主引导记录(MBR)
。
什么是实时系统
实时操作系统对时间做出了严格的要求,实时操作系统分为两种:**硬实时和软实时
硬实时操作系统
规定某个动作必须在规定的时刻内完成或发生,比如汽车生产车间,焊接机器必须在某一时刻内完成焊接,焊接的太早或者太晚都会对汽车造成永久性伤害。
软实时操作系统
虽然不希望偶尔违反最终的时限要求,但是仍然可以接受。并且不会引起任何永久性伤害。比如数字音频、多媒体、手机都是属于软实时操作系统。
你可以简单理解硬实时和软实时的两个指标:是否在时刻内必须完成以及是否造成严重损害。
Linux 操作系统的启动过程
当计算机电源通电后,BIOS
会进行开机自检(Power-On-Self-Test, POST)
,对硬件进行检测和初始化。因为操作系统的启动会使用到磁盘、屏幕、键盘、鼠标等设备。下一步,磁盘中的第一个分区,也被称为 MBR(Master Boot Record)
主引导记录,被读入到一个固定的内存区域并执行。这个分区中有一个非常小的,只有 512 字节的程序。程序从磁盘中调入 boot 独立程序,boot 程序将自身复制到高位地址的内存从而为操作系统释放低位地址的内存。
复制完成后,boot 程序读取启动设备的根目录。boot 程序要理解文件系统和目录格式。然后 boot 程序被调入内核,把控制权移交给内核。直到这里,boot 完成了它的工作。系统内核开始运行。
内核启动代码是使用汇编语言
完成的,主要包括创建内核堆栈、识别 CPU 类型、计算内存、禁用中断、启动内存管理单元等,然后调用 C 语言的 main 函数执行操作系统部分。
这部分也会做很多事情,首先会分配一个消息缓冲区来存放调试出现的问题,调试信息会写入缓冲区。如果调试出现错误,这些信息可以通过诊断程序调出来。
然后操作系统会进行自动配置,检测设备,加载配置文件,被检测设备如果做出响应,就会被添加到已链接的设备表中,如果没有相应,就归为未连接直接忽略。
配置完所有硬件后,接下来要做的就是仔细手工处理进程0,设置其堆栈,然后运行它,执行初始化、配置时钟、挂载文件系统。创建 init 进程(进程 1 )
和 守护进程(进程 2)
。
init 进程会检测它的标志以确定它是否为单用户还是多用户服务。在前一种情况中,它会调用 fork 函数创建一个 shell 进程,并且等待这个进程结束。后一种情况调用 fork 函数创建一个运行系统初始化的 shell 脚本(即 /etc/rc)的进程,这个进程可以进行文件系统一致性检测、挂载文件系统、开启守护进程等。
然后 /etc/rc 这个进程会从 /etc/ttys 中读取数据,/etc/ttys 列出了所有的终端和属性。对于每一个启用的终端,这个进程调用 fork 函数创建一个自身的副本,进行内部处理并运行一个名为 getty
的程序。
getty 程序会在终端上输入
login:
等待用户输入用户名,在输入用户名后,getty 程序结束,登陆程序 /bin/login
开始运行。login 程序需要输入密码,并与保存在 /etc/passwd
中的密码进行对比,如果输入正确,login 程序以用户 shell 程序替换自身,等待第一个命令。如果不正确,login 程序要求输入另一个用户名。
整个系统启动过程如下
进程和线程篇
多处理系统的优势
随着处理器的不断增加,我们的计算机系统由单机系统变为了多处理系统,多处理系统的吞吐量比较高,多处理系统拥有多个并行的处理器,这些处理器共享时钟、内存、总线、外围设备等。
多处理系统由于可以共享资源,因此可以开源节流,省钱。整个系统的可靠性也随之提高。
什么是进程和进程表
进程
就是正在执行程序的实例,比如说 Web 程序就是一个进程,shell 也是一个进程,文章编辑器 typora 也是一个进程。
操作系统负责管理所有正在运行的进程,操作系统会为每个进程分配特定的时间来占用 CPU,操作系统还会为每个进程分配特定的资源。
操作系统为了跟踪每个进程的活动状态,维护了一个进程表
。在进程表的内部,列出了每个进程的状态以及每个进程使用的资源等。
什么是线程,线程和进程的区别
这又是一道老生常谈的问题了,从操作系统的角度来回答一下吧。
我们上面说到进程是正在运行的程序的实例,而线程其实就是进程中的单条流向,因为线程具有进程中的某些属性,所以线程又被称为轻量级的进程。浏览器如果是一个进程的话,那么浏览器下面的每个 tab 页可以看作是一个个的线程。
下面是线程和进程持有资源的区别
线程不像进程那样具有很强的独立性,线程之间会共享数据
创建线程的开销要比进程小很多,因为创建线程仅仅需要堆栈指针
和程序计数器
就可以了,而创建进程需要操作系统分配新的地址空间,数据资源等,这个开销比较大。
什么是上下文切换
对于单核单线程 CPU 而言,在某一时刻只能执行一条 CPU 指令。上下文切换 (Context Switch) 是一种 将 CPU 资源从一个进程分配给另一个进程的机制。从用户角度看,计算机能够并行运行多个进程,这恰恰是操作系统通过快速上下文切换造成的结果。在切换的过程中,操作系统需要先存储当前进程的状态 (包括内存空间的指针,当前执行完的指令等等),再读入下一个进程的状态,然后执行此进程。
使用多线程的好处是什么
多线程是程序员不得不知的基本素养之一,所以,下面我们给出一些多线程编程的好处
- 能够提高对用户的响应顺序
- 在流程中的资源共享
- 比较经济适用
- 能够对多线程架构有深入的理解
进程终止的方式
进程的终止
进程在创建之后,它就开始运行并做完成任务。然而,没有什么事儿是永不停歇的,包括进程也一样。进程早晚会发生终止,但是通常是由于以下情况触发的
正常退出(自愿的)
错误退出(自愿的)
严重错误(非自愿的)
被其他进程杀死(非自愿的)
正常退出
多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉操作系统它完成了工作。这个调用在 UNIX 中是 exit
,在 Windows 中是 ExitProcess
。面向屏幕中的软件也支持自愿终止操作。字处理软件、Internet 浏览器和类似的程序中总有一个供用户点击的图标或菜单项,用来通知进程删除它锁打开的任何临时文件,然后终止。
错误退出
进程发生终止的第二个原因是发现严重错误,例如,如果用户执行如下命令
cc foo.c
为了能够编译 foo.c 但是该文件不存在,于是编译器就会发出声明并退出。在给出了错误参数时,面向屏幕的交互式进程通常并不会直接退出,因为这从用户的角度来说并不合理,用户需要知道发生了什么并想要进行重试,所以这时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要重试还是退出。
严重错误
进程终止的第三个原因是由进程引起的错误,通常是由于程序中的错误所导致的。例如,执行了一条非法指令,引用不存在的内存,或者除数是 0 等。在有些系统比如 UNIX 中,进程可以通知操作系统,它希望自行处理某种类型的错误,在这类错误中,进程会收到信号(中断),而不是在这类错误出现时直接终止进程。
被其他进程杀死
第四个终止进程的原因是,某个进程执行系统调用告诉操作系统杀死某个进程。在 UNIX 中,这个系统调用是 kill。在 Win32 中对应的函数是 TerminateProcess
(注意不是系统调用)。
进程间的通信方式
进程间的通信方式比较多,首先你需要理解下面这几个概念
-
竞态条件:即两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为
竞态条件(race condition)
。 -
临界区:不仅
共享资源
会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢?或许一句话可以概括说明:禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。换句话说,我们需要一种互斥(mutual exclusion)
条件,这也就是说,如果一个进程在某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)。一个好的解决方案,应该包含下面四种条件
- 任何时候两个进程不能同时处于临界区
- 不应对 CPU 的速度和数量做任何假设
- 位于临界区外的进程不得阻塞其他进程
- 不能使任何进程无限等待进入临界区
-
忙等互斥:当一个进程在对资源进行修改时,其他进程必须进行等待,进程之间要具有互斥性,我们讨论的解决方案其实都是基于忙等互斥提出的。
进程间的通信用专业一点的术语来表示就是 Inter Process Communication,IPC
,它主要有下面 7。种通信方式
消息传递
:消息传递是进程间实现通信和同步等待的机制,使用消息传递,进程间的交流不需要共享变量,直接就可以进行通信;消息传递分为发送方和接收方先进先出队列
:先进先出队列指的是两个不相关联进程间的通信,两个进程之间可以彼此相互进程通信,这是一种全双工通信方式管道
:管道用于两个相关进程之间的通信,这是一种半双工的通信方式,如果需要全双工,需要另外一个管道。直接通信
:在这种进程通信的方式中,进程与进程之间只存在一条链接,进程间要明确通信双方的命名。间接通信
:间接通信是通信双方不会直接建立连接,而是找到一个中介者,这个中介者可能是个对象等等,进程可以在其中放置消息,并且可以从中删除消息,以此达到进程间通信的目的。消息队列
:消息队列是内核中存储消息的链表,它由消息队列标识符进行标识,这种方式能够在不同的进程之间提供全双工的通信连接。共享内存
:共享内存是使用所有进程之间的内存来建立连接,这种类型需要同步进程访问来相互保护。
进程间状态模型
进程的三态模型
当一个进程开始运行时,它可能会经历下面这几种状态
图中会涉及三种状态
运行态
:运行态指的就是进程实际占用 CPU 时间片运行时就绪态
:就绪态指的是可运行,但因为其他进程正在运行而处于就绪状态阻塞态
:阻塞态又被称为睡眠态,它指的是进程不具备运行条件,正在等待被 CPU 调度。
逻辑上来说,运行态和就绪态是很相似的。这两种情况下都表示进程可运行
,但是第二种情况没有获得 CPU 时间分片。第三种状态与前两种状态不同的原因是这个进程不能运行,CPU 空闲时也不能运行。
三种状态会涉及四种状态间的切换,在操作系统发现进程不能继续执行时会发生状态1
的轮转,在某些系统中进程执行系统调用,例如 pause
,来获取一个阻塞的状态。在其他系统中包括 UNIX,当进程从管道或特殊文件(例如终端)中读取没有可用的输入时,该进程会被自动终止。
转换 2 和转换 3 都是由进程调度程序(操作系统的一部分)引起的,进程本身不知道调度程序的存在。转换 2 的出现说明进程调度器认定当前进程已经运行了足够长的时间,是时候让其他进程运行 CPU 时间片了。当所有其他进程都运行过后,这时候该是让第一个进程重新获得 CPU 时间片的时候了,就会发生转换 3。
程序调度指的是,决定哪个进程优先被运行和运行多久,这是很重要的一点。已经设计出许多算法来尝试平衡系统整体效率与各个流程之间的竞争需求。
当进程等待的一个外部事件发生时(如从外部输入一些数据后),则发生转换 4。如果此时没有其他进程在运行,则立刻触发转换 3,该进程便开始运行,否则该进程会处于就绪阶段,等待 CPU 空闲后再轮到它运行。
进程的五态模型
在三态模型的基础上,增加了两个状态,即 新建
和 终止
状态。
- 新建态:进程的新建态就是进程刚创建出来的时候
创建进程需要两个步骤:即为新进程分配所需要的资源和空间,设置进程为就绪态,并等待调度执行。
- 终止态:进程的终止态就是指进程执行完毕,到达结束点,或者因为错误而不得不中止进程。
终止一个进程需要两个步骤:
先等待操作系统或相关的进程进行善后处理。
然后回收占用的资源并被系统删除。
调度算法都有哪些
调度算法分为三大类:批处理中的调度、交互系统中的调度、实时系统中的调度
批处理中的调度
先来先服务
很像是先到先得。。。可能最简单的非抢占式调度算法的设计就是 先来先服务(first-come,first-serverd)
。使用此算法,将按照请求顺序为进程分配 CPU。最基本的,会有一个就绪进程的等待队列。当第一个任务从外部进入系统时,将会立即启动并允许运行任意长的时间。它不会因为运行时间太长而中断。当其他作业进入时,它们排到就绪队列尾部。当正在运行的进程阻塞,处于等待队列的第一个进程就开始运行。当一个阻塞的进程重新处于就绪态时,它会像一个新到达的任务,会排在队列的末尾,即排在所有进程最后。
这个算法的强大之处在于易于理解和编程,在这个算法中,一个单链表记录了所有就绪进程。要选取一个进程运行,只要从该队列的头部移走一个进程即可;要添加一个新的作业或者阻塞一个进程,只要把这个作业或进程附加在队列的末尾即可。这是很简单的一种实现。
不过,先来先服务也是有缺点的,那就是没有优先级的关系,试想一下,如果有 100 个 I/O 进程正在排队,第 101 个是一个 CPU 密集型进程,那岂不是需要等 100 个 I/O 进程运行完毕才会等到一个 CPU 密集型进程运行,这在实际情况下根本不可能,所以需要优先级或者抢占式进程的出现来优先选择重要的进程运行。
最短作业优先
批处理中,第二种调度算法是 最短作业优先(Shortest Job First)
,我们假设运行时间已知。例如,一家保险公司,因为每天要做类似的工作,所以人们可以相当精确地预测处理 1000 个索赔的一批作业需要多长时间。当输入队列中有若干个同等重要的作业被启动时,调度程序应使用最短优先作业算法
如上图 a 所示,这里有 4 个作业 A、B、C、D ,运行时间分别为 8、4、4、4 分钟。若按图中的次序运行,则 A 的周转时间为 8 分钟,B 为 12 分钟,C 为 16 分钟,D 为 20 分钟,平均时间内为 14 分钟。
现在考虑使用最短作业优先算法运行 4 个作业,如上图 b 所示,目前的周转时间分别为 4、8、12、20,平均为 11 分钟,可以证明最短作业优先是最优的。考虑有 4 个作业的情况,其运行时间分别为 a、b、c、d。第一个作业在时间 a 结束,第二个在时间 a + b 结束,以此类推。平均周转时间为 (4a + 3b + 2c + d) / 4 。显然 a 对平均值的影响最大,所以 a 应该是最短优先作业,其次是 b,然后是 c ,最后是 d 它就只能影响自己的周转时间了。
需要注意的是,在所有的进程都可以运行的情况下,最短作业优先的算法才是最优的。
最短剩余时间优先
最短作业优先的抢占式版本被称作为 最短剩余时间优先(Shortest Remaining Time Next)
算法。使用这个算法,调度程序总是选择剩余运行时间最短的那个进程运行。当一个新作业到达时,其整个时间同当前进程的剩余时间做比较。如果新的进程比当前运行进程需要更少的时间,当前进程就被挂起,而运行新的进程。这种方式能够使短期作业获得良好的服务。
交互式系统中的调度
交互式系统中在个人计算机、服务器和其他系统中都是很常用的,所以有必要来探讨一下交互式调度
轮询调度
一种最古老、最简单、最公平并且最广泛使用的算法就是 轮询算法(round-robin)
。每个进程都会被分配一个时间段,称为时间片(quantum)
,在这个时间片内允许进程运行。如果时间片结束时进程还在运行的话,则抢占一个 CPU 并将其分配给另一个进程。如果进程在时间片结束前阻塞或结束,则 CPU 立即进行切换。轮询算法比较容易实现。调度程序所做的就是维护一个可运行进程的列表,就像下图中的 a,当一个进程用完时间片后就被移到队列的末尾,就像下图的 b。
优先级调度
事实情况是不是所有的进程都是优先级相等的。例如,在一所大学中的等级制度,首先是院长,然后是教授、秘书、后勤人员,最后是学生。这种将外部情况考虑在内就实现了优先级调度(priority scheduling)
它的基本思想很明确,每个进程都被赋予一个优先级,优先级高的进程优先运行。
但是也不意味着高优先级的进程能够永远一直运行下去,调度程序会在每个时钟中断期间降低当前运行进程的优先级。如果此操作导致其优先级降低到下一个最高进程的优先级以下,则会发生进程切换。或者,可以为每个进程分配允许运行的最大时间间隔。当时间间隔用完后,下一个高优先级的进程会得到运行的机会。
最短进程优先
对于批处理系统而言,由于最短作业优先常常伴随着最短响应时间,一种方式是根据进程过去的行为进行推测,并执行估计运行时间最短的那一个。假设每个终端上每条命令的预估运行时间为 T0
,现在假设测量到其下一次运行时间为 T1
,可以用两个值的加权来改进估计时间,即aT0+ (1- 1)T1
。通过选择 a 的值,可以决定是尽快忘掉老的运行时间,还是在一段长时间内始终记住它们。当 a = 1/2 时,可以得到下面这个序列
可以看到,在三轮过后,T0 在新的估计值中所占比重下降至 1/8。
有时把这种通过当前测量值和先前估计值进行加权平均从而得到下一个估计值的技术称作 老化(aging)
。这种方法会使用很多预测值基于当前值的情况。
彩票调度
有一种既可以给出预测结果而又有一种比较简单的实现方式的算法,就是 彩票调度(lottery scheduling)
算法。他的基本思想为进程提供各种系统资源的彩票
。当做出一个调度决策的时候,就随机抽出一张彩票,拥有彩票的进程将获得资源。比如在 CPU 进行调度时,系统可以每秒持有 50 次抽奖,每个中奖进程会获得额外运行时间的奖励。
可以把彩票理解为 buff,这个 buff 有 15% 的几率能让你产生
速度之靴
的效果。
公平分享调度
如果用户 1 启动了 9 个进程,而用户 2 启动了一个进程,使用轮转或相同优先级调度算法,那么用户 1 将得到 90 % 的 CPU 时间,而用户 2 将之得到 10 % 的 CPU 时间。
为了阻止这种情况的出现,一些系统在调度前会把进程的拥有者考虑在内。在这种模型下,每个用户都会分配一些CPU 时间,而调度程序会选择进程并强制执行。因此如果两个用户每个都会有 50% 的 CPU 时间片保证,那么无论一个用户有多少个进程,都将获得相同的 CPU 份额。
影响调度程序的指标是什么
会有下面几个因素决定调度程序的好坏
- CPU 使用率:
CPU 正在执行任务(即不处于空闲状态)的时间百分比。
- 等待时间
这是进程轮流执行的时间,也就是进程切换的时间
- 吞吐量
单位时间内完成进程的数量
- 响应时间
这是从提交流程到获得有用输出所经过的时间。
- 周转时间
从提交流程到完成流程所经过的时间。
什么是 RR 调度算法
RR(round-robin)
调度算法主要针对分时系统,RR 的调度算法会把时间片以相同的部分并循环的分配给每个进程,RR 调度算法没有优先级的概念。这种算法的实现比较简单,而且每个线程都会占有时间片,并不存在线程饥饿的问题。
内存管理篇
什么是按需分页
在操作系统中,进程是以页为单位加载到内存中的,按需分页是一种虚拟内存
的管理方式。在使用请求分页的系统中,只有在尝试访问页面所在的磁盘并且该页面尚未在内存中时,也就发生了缺页异常
,操作系统才会将磁盘页面复制到内存中。
什么是虚拟内存
虚拟内存
是一种内存分配方案,是一项可以用来辅助内存分配的机制。我们知道,应用程序是按页装载进内存中的。但并不是所有的页都会装载到内存中,计算机中的硬件和软件会将数据从 RAM 临时传输到磁盘中来弥补内存的不足。如果没有虚拟内存的话,一旦你将计算机内存填满后,计算机会对你说
呃,不,对不起,您无法再加载任何应用程序,请关闭另一个应用程序以加载新的应用程序。对于虚拟内存,计算机可以执行操作是查看内存中最近未使用过的区域,然后将其复制到硬盘上。虚拟内存通过复制技术实现了 妹子,你快来看哥哥能装这么多程序 的资本。复制是自动进行的,你无法感知到它的存在。
虚拟内存的实现方式
虚拟内存中,允许将一个作业分多次调入内存。釆用连续分配方式时,会使相当一部分内存空间都处于暂时或永久
的空闲状态,造成内存资源的严重浪费,而且也无法从逻辑上扩大内存容量。因此,虚拟内存的实需要建立在离散分配的内存管理方式的基础上。虚拟内存的实现有以下三种方式:
- 请求分页存储管理。
- 请求分段存储管理。
- 请求段页式存储管理。
不管哪种方式,都需要有一定的硬件支持。一般需要的支持有以下几个方面:
- 一定容量的内存和外存。
- 页表机制(或段表机制),作为主要的数据结构。
- 中断机构,当用户程序要访问的部分尚未调入内存,则产生中断。
- 地址变换机构,逻辑地址到物理地址的变换。
内存为什么要分段
内存是随机访问设备,对于内存来说,不需要从头开始查找,只需要直接给出地址即可。内存的分段是从 8086 CPU
开始的,8086 的 CPU 还是 16 位的寄存器宽,16 位的寄存器可以存储的数字范围是 2 的 16 次方,即 64 KB,8086 的 CPU 还没有 虚拟地址
,只有物理地址,也就是说,如果两个相同的程序编译出来的地址相同,那么这两个程序是无法同时运行的。为了解决这个问题,操作系统设计人员提出了让 CPU 使用 段基址 + 段内偏移
的方式来访问任意内存。这样的好处是让程序可以 重定位
,这也是内存为什么要分段的第一个原因。
那么什么是重定位呢?
简单来说就是将程序中的指令地址改为另一个地址,地址处存储的内容还是原来的。
CPU 采用段基址 + 段内偏移地址的形式访问内存,就需要提供专门的寄存器,这些专门的寄存器就是 CS、DS、ES 等,如果你对寄存器不熟悉,可以看我的这一篇文章。
也就是说,程序中需要用到哪块内存,就需要先加载合适的段到段基址寄存器中,再给出相对于该段基址的段偏移地址即可。CPU 中的地址加法器会将这两个地址进行合并,从地址总线送入内存。
8086 的 CPU 有 20 根地址总线,最大的寻址能力是 1MB,而段基址所在的寄存器宽度只有 16 位,最大为你 64 KB 的寻址能力,64 KB 显然不能满足 1MB 的最大寻址范围,所以就要把内存分段,每个段的最大寻址能力是 64KB,但是仍旧不能达到最大 1 MB 的寻址能力,所以这时候就需要 偏移地址
的辅助,偏移地址也存入寄存器,同样为 64 KB 的寻址能力,这么一看还是不能满足 1MB 的寻址,所以 CPU 的设计者对地址单元动了手脚,将段基址左移 4 位,然后再和 16 位的段内偏移地址相加,就达到了 1MB 的寻址能力。所以内存分段的第二个目的就是能够访问到所有内存。
物理地址、逻辑地址、有效地址、线性地址、虚拟地址的区别
物理地址就是内存中真正的地址,它就相当于是你家的门牌号,你家就肯定有这个门牌号,具有唯一性。不管哪种地址,最终都会映射为物理地址。
在实模式
下,段基址 + 段内偏移经过地址加法器的处理,经过地址总线传输,最终也会转换为物理地址
。
但是在保护模式
下,段基址 + 段内偏移被称为线性地址
,不过此时的段基址不能称为真正的地址,而是会被称作为一个选择子
的东西,选择子就是个索引,相当于数组的下标,通过这个索引能够在 GDT 中找到相应的段描述符,段描述符记录了段的起始、段的大小等信息,这样便得到了基地址。如果此时没有开启内存分页功能,那么这个线性地址可以直接当做物理地址来使用,直接访问内存。如果开启了分页功能,那么这个线性地址又多了一个名字,这个名字就是虚拟地址
。
不论在实模式还是保护模式下,段内偏移地址都叫做有效地址
。有效抵制也是逻辑地址。
线性地址可以看作是虚拟地址
,虚拟地址不是真正的物理地址,但是虚拟地址会最终被映射为物理地址。下面是虚拟地址 -> 物理地址的映射。
空闲内存管理的方式
操作系统在动态分配内存时(malloc,new),需要对空间内存进行管理。一般采用了两种方式:位图和空闲链表。
使用位图进行管理
使用位图方法时,内存可能被划分为小到几个字或大到几千字节的分配单元。每个分配单元对应于位图中的一位,0 表示空闲, 1 表示占用(或者相反)。一块内存区域和其对应的位图如下
图 a 表示一段有 5 个进程和 3 个空闲区的内存,刻度为内存分配单元,阴影区表示空闲(在位图中用 0 表示);图 b 表示对应的位图;图 c 表示用链表表示同样的信息
分配单元的大小是一个重要的设计因素,分配单位越小,位图越大。然而,即使只有 4 字节的分配单元,32 位的内存也仅仅只需要位图中的 1 位。32n
位的内存需要 n 位的位图,所以1 个位图只占用了 1/32 的内存。如果选择更大的内存单元,位图应该要更小。如果进程的大小不是分配单元的整数倍,那么在最后一个分配单元中会有大量的内存被浪费。
位图
提供了一种简单的方法在固定大小的内存中跟踪内存的使用情况,因为位图的大小取决于内存和分配单元的大小。这种方法有一个问题,当决定为把具有 k 个分配单元的进程放入内存时,内容管理器(memory manager)
必须搜索位图,在位图中找出能够运行 k 个连续 0 位的串。在位图中找出制定长度的连续 0 串是一个很耗时的操作,这是位图的缺点。(可以简单理解为在杂乱无章的数组中,找出具有一大长串空闲的数组单元)
使用空闲链表
另一种记录内存使用情况的方法是,维护一个记录已分配内存段和空闲内存段的链表,段会包含进程或者是两个进程的空闲区域。可用上面的图 c 来表示内存的使用情况。链表中的每一项都可以代表一个 空闲区(H)
或者是进程(P)
的起始标志,长度和下一个链表项的位置。
在这个例子中,段链表(segment list)
是按照地址排序的。这种方式的优点是,当进程终止或被交换时,更新列表很简单。一个终止进程通常有两个邻居(除了内存的顶部和底部外)。相邻的可能是进程也可能是空闲区,它们有四种组合方式。
当按照地址顺序在链表中存放进程和空闲区时,有几种算法可以为创建的进程(或者从磁盘中换入的进程)分配内存。
- 首次适配算法:在链表中进行搜索,直到找到最初的一个足够大的空闲区,将其分配。除非进程大小和空间区大小恰好相同,否则会将空闲区分为两部分,一部分为进程使用,一部分成为新的空闲区。该方法是速度很快的算法,因为索引链表结点的个数较少。
- 下次适配算法:工作方式与首次适配算法相同,但每次找到新的空闲区位置后都记录当前位置,下次寻找空闲区从上次结束的地方开始搜索,而不是与首次适配放一样从头开始;
- 最佳适配算法:搜索整个链表,找出能够容纳进程分配的最小的空闲区。这样存在的问题是,尽管可以保证为进程找到一个最为合适的空闲区进行分配,但大多数情况下,这样的空闲区被分为两部分,一部分用于进程分配,一部分会生成很小的空闲区,而这样的空闲区很难再被进行利用。
- 最差适配算法:与最佳适配算法相反,每次分配搜索最大的空闲区进行分配,从而可以使得空闲区拆分得到的新的空闲区可以更好的被进行利用。
页面置换算法都有哪些
在地址映射过程中,如果在页面中发现所要访问的页面不在内存中,那么就会产生一条缺页中断。当发生缺页中断时,如果操作系统内存中没有空闲页面,那么操作系统必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。而用来选择淘汰哪一页的规则叫做页面置换算法。
下面我汇总的这些页面置换算法比较齐全,只给出简单介绍,算法具体的实现和原理读者可以自行了解。
最优算法
在当前页面中置换最后要访问的页面。不幸的是,没有办法来判定哪个页面是最后一个要访问的,因此实际上该算法不能使用
。然而,它可以作为衡量其他算法的标准。NRU
算法根据 R 位和 M 位的状态将页面氛围四类。从编号最小的类别中随机选择一个页面。NRU 算法易于实现,但是性能不是很好。存在更好的算法。FIFO
会跟踪页面加载进入内存中的顺序,并把页面放入一个链表中。有可能删除存在时间最长但是还在使用的页面,因此这个算法也不是一个很好的选择。第二次机会
算法是对 FIFO 的一个修改,它会在删除页面之前检查这个页面是否仍在使用。如果页面正在使用,就会进行保留。这个改进大大提高了性能。时钟
算法是第二次机会算法的另外一种实现形式,时钟算法和第二次算法的性能差不多,但是会花费更少的时间来执行算法。LRU
算法是一个非常优秀的算法,但是没有特殊的硬件(TLB)
很难实现。如果没有硬件,就不能使用 LRU 算法。NFU
算法是一种近似于 LRU 的算法,它的性能不是非常好。老化
算法是一种更接近 LRU 算法的实现,并且可以更好的实现,因此是一个很好的选择- 最后两种算法都使用了工作集算法。工作集算法提供了合理的性能开销,但是它的实现比较复杂。
WSClock
是另外一种变体,它不仅能够提供良好的性能,而且可以高效地实现。
最好的算法是老化算法和WSClock算法。他们分别是基于 LRU 和工作集算法。他们都具有良好的性能并且能够被有效的实现。还存在其他一些好的算法,但实际上这两个可能是最重要的。
文件系统篇
提高文件系统性能的方式
访问磁盘的效率要比内存慢很多,是时候又祭出这张图了
所以磁盘优化是很有必要的,下面我们会讨论几种优化方式
高速缓存
最常用的减少磁盘访问次数的技术是使用 块高速缓存(block cache)
或者 缓冲区高速缓存(buffer cache)
。高速缓存指的是一系列的块,它们在逻辑上属于磁盘,但实际上基于性能的考虑被保存在内存中。
管理高速缓存有不同的算法,常用的算法是:检查全部的读请求,查看在高速缓存中是否有所需要的块。如果存在,可执行读操作而无须访问磁盘。如果检查块不再高速缓存中,那么首先把它读入高速缓存,再复制到所需的地方。之后,对同一个块的请求都通过高速缓存
来完成。
高速缓存的操作如下图所示
由于在高速缓存中有许多块,所以需要某种方法快速确定所需的块是否存在。常用方法是将设备和磁盘地址进行散列操作。然后在散列表中查找结果。具有相同散列值的块在一个链表中连接在一起(这个数据结构是不是很像 HashMap?),这样就可以沿着冲突链查找其他块。
如果高速缓存已满
,此时需要调入新的块,则要把原来的某一块调出高速缓存,如果要调出的块在上次调入后已经被修改过,则需要把它写回磁盘。这种情况与分页非常相似。
块提前读
第二个明显提高文件系统的性能是在需要用到块之前试图提前将其写入高速缓存从而提高命中率。许多文件都是顺序读取
。如果请求文件系统在某个文件中生成块 k,文件系统执行相关操作并且在完成之后,会检查高速缓存,以便确定块 k + 1 是否已经在高速缓存。如果不在,文件系统会为 k + 1 安排一个预读取,因为文件希望在用到该块的时候能够直接从高速缓存中读取。
当然,块提前读取策略只适用于实际顺序读取的文件。对随机访问的文件,提前读丝毫不起作用。甚至还会造成阻碍。
减少磁盘臂运动
高速缓存和块提前读并不是提高文件系统性能的唯一方法。另一种重要的技术是把有可能顺序访问的块放在一起,当然最好是在同一个柱面上,从而减少磁盘臂的移动次数。当写一个输出文件时,文件系统就必须按照要求一次一次地分配磁盘块。如果用位图来记录空闲块,并且整个位图在内存中,那么选择与前一块最近的空闲块是很容易的。如果用空闲表,并且链表的一部分存在磁盘上,要分配紧邻的空闲块就会困难很多。
不过,即使采用空闲表,也可以使用 块簇
技术。即不用块而用连续块簇来跟踪磁盘存储区。如果一个扇区有 512 个字节,有可能系统采用 1 KB 的块(2 个扇区),但却按每 2 块(4 个扇区)一个单位来分配磁盘存储区。这和 2 KB 的磁盘块并不相同,因为在高速缓存中它仍然使用 1 KB 的块,磁盘与内存数据之间传送也是以 1 KB 进行,但在一个空闲的系统上顺序读取这些文件,寻道的次数可以减少一半,从而使文件系统的性能大大改善。若考虑旋转定位则可以得到这类方法的变体。在分配块时,系统尽量把一个文件中的连续块存放在同一个柱面上。
在使用 inode 或任何类似 inode 的系统中,另一个性能瓶颈是,读取一个很短的文件也需要两次磁盘访问:一次是访问 inode,一次是访问块。通常情况下,inode 的放置如下图所示
其中,全部 inode 放在靠近磁盘开始位置,所以 inode 和它所指向的块之间的平均距离是柱面组的一半,这将会需要较长时间的寻道时间。
一个简单的改进方法是,在磁盘中部而不是开始处存放 inode ,此时,在 inode 和第一个块之间的寻道时间减为原来的一半。另一种做法是:将磁盘分成多个柱面组,每个柱面组有自己的 inode,数据块和空闲表,如上图 b 所示。
当然,只有在磁盘中装有磁盘臂的情况下,讨论寻道时间和旋转时间才是有意义的。现在越来越多的电脑使用 固态硬盘(SSD)
,对于这些硬盘,由于采用了和闪存同样的制造技术,使得随机访问和顺序访问在传输速度上已经较为相近,传统硬盘的许多问题就消失了。但是也引发了新的问题。
磁盘碎片整理
在初始安装操作系统后,文件就会被不断的创建和清除,于是磁盘会产生很多的碎片,在创建一个文件时,它使用的块会散布在整个磁盘上,降低性能。删除文件后,回收磁盘块,可能会造成空穴。
磁盘性能可以通过如下方式恢复:移动文件使它们相互挨着,并把所有的至少是大部分的空闲空间放在一个或多个大的连续区域内。Windows 有一个程序 defrag
就是做这个事儿的。Windows 用户会经常使用它,SSD 除外。
磁盘碎片整理程序会在让文件系统上很好地运行。Linux 文件系统(特别是 ext2 和 ext3)由于其选择磁盘块的方式,在磁盘碎片整理上一般不会像 Windows 一样困难,因此很少需要手动的磁盘碎片整理。而且,固态硬盘并不受磁盘碎片的影响,事实上,在固态硬盘上做磁盘碎片整理反倒是多此一举,不仅没有提高性能,反而磨损了固态硬盘。所以碎片整理只会缩短固态硬盘的寿命。
磁盘臂调度算法
一般情况下,影响磁盘快读写的时间由下面几个因素决定
- 寻道时间 – 寻道时间指的就是将磁盘臂移动到需要读取磁盘块上的时间
- 旋转延迟 – 等待合适的扇区旋转到磁头下所需的时间
- 实际数据的读取或者写入时间
这三种时间参数也是磁盘寻道的过程。一般情况下,寻道时间对总时间的影响最大,所以,有效的降低寻道时间能够提高磁盘的读取速度。
如果磁盘驱动程序每次接收一个请求并按照接收顺序完成请求,这种处理方式也就是 先来先服务(First-Come, First-served, FCFS)
,这种方式很难优化寻道时间。因为每次都会按照顺序处理,不管顺序如何,有可能这次读完后需要等待一个磁盘旋转一周才能继续读取,而其他柱面能够马上进行读取,这种情况下每次请求也会排队。
通常情况下,磁盘在进行寻道时,其他进程会产生其他的磁盘请求。磁盘驱动程序会维护一张表,表中会记录着柱面号当作索引,每个柱面未完成的请求会形成链表,链表头存放在表的相应表项中。
一种对先来先服务的算法改良的方案是使用 最短路径优先(SSF)
算法,下面描述了这个算法。
假如我们在对磁道 6 号进行寻址时,同时发生了对 11 , 2 , 4, 14, 8, 15, 3 的请求,如果采用先来先服务的原则,如下图所示
我们可以计算一下磁盘臂所跨越的磁盘数量为 5 + 9 + 2 + 10 + 6 + 7 + 12 = 51,相当于是跨越了 51 次盘面,如果使用最短路径优先,我们来计算一下跨越的盘面
跨越的磁盘数量为 4 + 1 + 1 + 4 + 3 + 3 + 1 = 17 ,相比 51 足足省了两倍的时间。
但是,最短路径优先的算法也不是完美无缺的,这种算法照样存在问题,那就是优先级
问题,
这里有一个原型可以参考就是我们日常生活中的电梯,电梯使用一种电梯算法(elevator algorithm)
来进行调度,从而满足协调效率和公平性这两个相互冲突的目标。电梯一般会保持向一个方向移动,直到在那个方向上没有请求为止,然后改变方向。
电梯算法需要维护一个二进制位
,也就是当前的方向位:UP(向上)
或者是 DOWN(向下)
。当一个请求处理完成后,磁盘或电梯的驱动程序会检查该位,如果此位是 UP 位,磁盘臂或者电梯仓移到下一个更高跌未完成的请求。如果高位没有未完成的请求,则取相反方向。当方向位是 DOWN
时,同时存在一个低位的请求,磁盘臂会转向该点。如果不存在的话,那么它只是停止并等待。
我们举个例子来描述一下电梯算法,比如各个柱面得到服务的顺序是 4,7,10,14,9,6,3,1 ,那么它的流程图如下
所以电梯算法需要跨越的盘面数量是 3 + 3 + 4 + 5 + 3 + 3 + 1 = 22
电梯算法通常情况下不如 SSF 算法。
一些磁盘控制器为软件提供了一种检查磁头下方当前扇区号的方法,使用这样的控制器,能够进行另一种优化。如果对一个相同的柱面有两个或者多个请求正等待处理,驱动程序可以发出请求读写下一次要通过磁头的扇区。
这里需要注意一点,当一个柱面有多条磁道时,相继的请求可能针对不同的磁道,这种选择没有代价,因为选择磁头不需要移动磁盘臂也没有旋转延迟。
对于磁盘来说,最影响性能的就是寻道时间和旋转延迟,所以一次只读取一个或两个扇区的效率是非常低的。出于这个原因,许多磁盘控制器总是读出多个扇区并进行高速缓存,即使只请求一个扇区时也是这样。一般情况下读取一个扇区的同时会读取该扇区所在的磁道或者是所有剩余的扇区被读出,读出扇区的数量取决于控制器的高速缓存中有多少可用的空间。
磁盘控制器的高速缓存和操作系统的高速缓存有一些不同,磁盘控制器的高速缓存用于缓存没有实际被请求的块,而操作系统维护的高速缓存由显示地读出的块组成,并且操作系统会认为这些块在近期仍然会频繁使用。
当同一个控制器上有多个驱动器时,操作系统应该为每个驱动器都单独的维护一个未完成的请求表。一旦有某个驱动器闲置时,就应该发出一个寻道请求来将磁盘臂移到下一个被请求的柱面。如果下一个寻道请求到来时恰好没有磁盘臂处于正确的位置,那么驱动程序会在刚刚完成传输的驱动器上发出一个新的寻道命令并等待,等待下一次中断到来时检查哪个驱动器处于闲置状态。
RAID 的不同级别
RAID 称为 磁盘冗余阵列
,简称 磁盘阵列
。利用虚拟化技术把多个硬盘结合在一起,成为一个或多个磁盘阵列组,目的是提升性能或数据冗余。
RAID 有不同的级别
- RAID 0 – 无容错的条带化磁盘阵列
- RAID 1 – 镜像和双工
- RAID 2 – 内存式纠错码
- RAID 3 – 比特交错奇偶校验
- RAID 4 – 块交错奇偶校验
- RAID 5 – 块交错分布式奇偶校验
- RAID 6 – P + Q冗余
IO 篇
操作系统中的时钟是什么
时钟(Clocks)
也被称为定时器(timers)
,时钟/定时器对任何程序系统来说都是必不可少的。时钟负责维护时间、防止一个进程长期占用 CPU 时间等其他功能。时钟软件(clock software)
也是一种设备驱动的方式。下面我们就来对时钟进行介绍,一般都是先讨论硬件再介绍软件,采用由下到上的方式,也是告诉你,底层是最重要的。
时钟硬件
在计算机中有两种类型的时钟,这些时钟与现实生活中使用的时钟完全不一样。
- 比较简单的一种时钟被连接到 110 V 或 220 V 的电源线上,这样每个
电压周期
会产生一个中断,大概是 50 – 60 HZ。这些时钟过去一直占据支配地位。 - 另外的一种时钟由晶体振荡器、计数器和寄存器组成,示意图如下所示
这种时钟称为可编程时钟
,可编程时钟有两种模式,一种是 一键式(one-shot mode)
,当时钟启动时,会把存储器中的值复制到计数器中,然后,每次晶体的振荡器的脉冲都会使计数器 -1。当计数器变为 0 时,会产生一个中断,并停止工作,直到软件再一次显示启动。还有一种模式时 方波(square-wave mode)
模式,在这种模式下,当计数器变为 0 并产生中断后,存储寄存器的值会自动复制到计数器中,这种周期性的中断称为一个时钟周期。
设备控制器的主要功能
设备控制器是一个可编址
的设备,当它仅控制一个设备时,它只有一个唯一的设备地址;如果设备控制器控制多个可连接设备时,则应含有多个设备地址,并使每一个设备地址对应一个设备。
设备控制器主要分为两种:字符设备和块设备
设备控制器的主要功能有下面这些
- 接收和识别命令:设备控制器可以接受来自 CPU 的指令,并进行识别。设备控制器内部也会有寄存器,用来存放指令和参数
- 进行数据交换:CPU、控制器和设备之间会进行数据的交换,CPU 通过总线把指令发送给控制器,或从控制器中并行地读出数据;控制器将数据写入指定设备。
- 地址识别:每个硬件设备都有自己的地址,设备控制器能够识别这些不同的地址,来达到控制硬件的目的,此外,为使 CPU 能向寄存器中写入或者读取数据,这些寄存器都应具有唯一的地址。
- 差错检测:设备控制器还具有对设备传递过来的数据进行检测的功能。
中断处理过程
中断处理方案有很多种,下面是 《**ARM System Developer’s Guide
Designing and Optimizing System Software》列出来的一些方案
非嵌套
的中断处理程序按照顺序处理各个中断,非嵌套的中断处理程序也是最简单的中断处理嵌套
的中断处理程序会处理多个中断而无需分配优先级可重入
的中断处理程序可使用优先级处理多个中断简单优先级
中断处理程序可处理简单的中断标准优先级
中断处理程序比低优先级的中断处理程序在更短的时间能够处理优先级更高的中断高优先级
中断处理程序在短时间能够处理优先级更高的任务,并直接进入特定的服务例程。优先级分组
中断处理程序能够处理不同优先级的中断任务
下面是一些通用的中断处理程序的步骤,不同的操作系统实现细节不一样
- 保存所有没有被中断硬件保存的寄存器
- 为中断服务程序设置上下文环境,可能包括设置
TLB
、MMU
和页表,如果不太了解这三个概念,请参考另外一篇文章 - 为中断服务程序设置栈
- 对中断控制器作出响应,如果不存在集中的中断控制器,则继续响应中断
- 把寄存器从保存它的地方拷贝到进程表中
- 运行中断服务程序,它会从发出中断的设备控制器的寄存器中提取信息
- 操作系统会选择一个合适的进程来运行。如果中断造成了一些优先级更高的进程变为就绪态,则选择运行这些优先级高的进程
- 为进程设置 MMU 上下文,可能也会需要 TLB,根据实际情况决定
- 加载进程的寄存器,包括 PSW 寄存器
- 开始运行新的进程
上面我们罗列了一些大致的中断步骤,不同性质的操作系统和中断处理程序能够处理的中断步骤和细节也不尽相同,下面是一个嵌套中断的具体运行步骤
什么是设备驱动程序
在计算机中,设备驱动程序是一种计算机程序,它能够控制或者操作连接到计算机的特定设备。驱动程序提供了与硬件进行交互的软件接口,使操作系统和其他计算机程序能够访问特定设备,不用需要了解其硬件的具体构造。
什么是 DMA
DMA 的中文名称是直接内存访问
,它意味着 CPU 授予 I/O 模块权限在不涉及 CPU 的情况下读取或写入内存。也就是 DMA 可以不需要 CPU 的参与。这个过程由称为 DMA 控制器(DMAC)的芯片管理。由于 DMA 设备可以直接在内存之间传输数据,而不是使用 CPU 作为中介,因此可以缓解总线上的拥塞。DMA 通过允许 CPU 执行任务,同时 DMA 系统通过系统和内存总线传输数据来提高系统并发性。
直接内存访问的特点
DMA 方式有如下特点:
-
数据传送以数据块为基本单位
-
所传送的数据从设备直接送入主存,或者从主存直接输出到设备上
-
仅在传送一个或多个数据块的开始和结束时才需 CPU 的干预,而整块数据的传送则是在控制器的控制下完成。
DMA 方式和中断驱动控制方式相比,减少了 CPU 对 I/O 操作的干预,进一步提高了 CPU 与 I/O 设备的并行操作程度。
DMA 方式的线路简单、价格低廉,适合高速设备与主存之间的成批数据传送,小型、微型机中的快速设备均采用这种方式,但其功能较差,不能满足复杂的 I/O 要求。
死锁篇
什么是僵尸进程
僵尸进程是已完成且处于终止状态,但在进程表中却仍然存在的进程。僵尸进程通常发生在父子关系的进程中,由于父进程仍需要读取其子进程的退出状态所造成的。
死锁产生的原因
死锁产生的原因大致有两个:资源竞争和程序执行顺序不当
死锁产生的必要条件
资源死锁可能出现的情况主要有
- 互斥条件:每个资源都被分配给了一个进程或者资源是可用的
- 保持和等待条件:已经获取资源的进程被认为能够获取新的资源
- 不可抢占条件:分配给一个进程的资源不能强制的从其他进程抢占资源,它只能由占有它的进程显示释放
- 循环等待:死锁发生时,系统中一定有两个或者两个以上的进程组成一个循环,循环中的每个进程都在等待下一个进程释放的资源。
死锁的恢复方式
所以针对检测出来的死锁,我们要对其进行恢复,下面我们会探讨几种死锁的恢复方式
通过抢占进行恢复
在某些情况下,可能会临时将某个资源从它的持有者转移到另一个进程。比如在不通知原进程的情况下,将某个资源从进程中强制取走给其他进程使用,使用完后又送回。这种恢复方式一般比较困难而且有些简单粗暴,并不可取。
通过回滚进行恢复
如果系统设计者和机器操作员知道有可能发生死锁,那么就可以定期检查流程。进程的检测点意味着进程的状态可以被写入到文件以便后面进行恢复。检测点不仅包含存储映像(memory image)
,还包含资源状态(resource state)
。一种更有效的解决方式是不要覆盖原有的检测点,而是每出现一个检测点都要把它写入到文件中,这样当进程执行时,就会有一系列的检查点文件被累积起来。
为了进行恢复,要从上一个较早的检查点上开始,这样所需要资源的进程会回滚到上一个时间点,在这个时间点上,死锁进程还没有获取所需要的资源,可以在此时对其进行资源分配。
杀死进程恢复
最简单有效的解决方案是直接杀死一个死锁进程。但是杀死一个进程可能照样行不通,这时候就需要杀死别的资源进行恢复。
另外一种方式是选择一个环外的进程作为牺牲品来释放进程资源。
如何破坏死锁
和死锁产生的必要条件一样,如果要破坏死锁,也是从下面四种方式进行破坏。
破坏互斥条件
我们首先考虑的就是破坏互斥使用条件。如果资源不被一个进程独占,那么死锁肯定不会产生。如果两个打印机同时使用一个资源会造成混乱,打印机的解决方式是使用 假脱机打印机(spooling printer)
,这项技术可以允许多个进程同时产生输出,在这种模型中,实际请求打印机的唯一进程是打印机守护进程,也称为后台进程。后台进程不会请求其他资源。我们可以消除打印机的死锁。
后台进程通常被编写为能够输出完整的文件后才能打印,假如两个进程都占用了假脱机空间的一半,而这两个进程都没有完成全部的输出,就会导致死锁。
因此,尽量做到尽可能少的进程可以请求资源。
破坏保持等待的条件
第二种方式是如果我们能阻止持有资源的进程请求其他资源,我们就能够消除死锁。一种实现方式是让所有的进程开始执行前请求全部的资源。如果所需的资源可用,进程会完成资源的分配并运行到结束。如果有任何一个资源处于频繁分配的情况,那么没有分配到资源的进程就会等待。
很多进程无法在执行完成前就知道到底需要多少资源,如果知道的话,就可以使用银行家算法;还有一个问题是这样无法合理有效利用资源。
还有一种方式是进程在请求其他资源时,先释放所占用的资源,然后再尝试一次获取全部的资源。
破坏不可抢占条件
破坏不可抢占条件也是可以的。可以通过虚拟化
的方式来避免这种情况。
破坏循环等待条件
现在就剩最后一个条件了,循环等待条件可以通过多种方法来破坏。一种方式是制定一个标准,一个进程在任何时候只能使用一种资源。如果需要另外一种资源,必须释放当前资源。
另一种方式是将所有的资源统一编号,如下图所示
进程可以在任何时间提出请求,但是所有的请求都必须按照资源的顺序提出。如果按照此分配规则的话,那么资源分配之间不会出现环。
死锁类型
两阶段加锁
虽然很多情况下死锁的避免和预防都能处理,但是效果并不好。随着时间的推移,提出了很多优秀的算法用来处理死锁。例如在数据库系统中,一个经常发生的操作是请求锁住一些记录,然后更新所有锁定的记录。当同时有多个进程运行时,就会有死锁的风险。
一种解决方式是使用 两阶段提交(two-phase locking)
。顾名思义分为两个阶段,一阶段是进程尝试一次锁定它需要的所有记录。如果成功后,才会开始第二阶段,第二阶段是执行更新并释放锁。第一阶段并不做真正有意义的工作。
如果在第一阶段某个进程所需要的记录已经被加锁,那么该进程会释放所有锁定的记录并重新开始第一阶段。从某种意义上来说,这种方法类似于预先请求所有必需的资源或者是在进行一些不可逆的操作之前请求所有的资源。
不过在一般的应用场景中,两阶段加锁的策略并不通用。如果一个进程缺少资源就会半途中断并重新开始的方式是不可接受的。
通信死锁
我们上面一直讨论的是资源死锁,资源死锁是一种死锁类型,但并不是唯一类型,还有通信死锁,也就是两个或多个进程在发送消息时出现的死锁。进程 A 给进程 B 发了一条消息,然后进程 A 阻塞直到进程 B 返回响应。假设请求消息丢失了,那么进程 A 在一直等着回复,进程 B 也会阻塞等待请求消息到来,这时候就产生死锁
。
尽管会产生死锁,但是这并不是一个资源死锁,因为 A 并没有占据 B 的资源。事实上,通信死锁并没有完全可见的资源。根据死锁的定义来说:每个进程因为等待其他进程引起的事件而产生阻塞,这就是一种死锁。相较于最常见的通信死锁,我们把上面这种情况称为通信死锁(communication deadlock)
。
通信死锁不能通过调度的方式来避免,但是可以使用通信中一个非常重要的概念来避免:超时(timeout)
。在通信过程中,只要一个信息被发出后,发送者就会启动一个定时器,定时器会记录消息的超时时间,如果超时时间到了但是消息还没有返回,就会认为消息已经丢失并重新发送,通过这种方式,可以避免通信死锁。
但是并非所有网络通信发生的死锁都是通信死锁,也存在资源死锁,下面就是一个典型的资源死锁。
当一个数据包从主机进入路由器时,会被放入一个缓冲区,然后再传输到另外一个路由器,再到另一个,以此类推直到目的地。缓冲区都是资源并且数量有限。如下图所示,每个路由器都有 10 个缓冲区(实际上有很多)。
假如路由器 A 的所有数据需要发送到 B ,B 的所有数据包需要发送到 D,然后 D 的所有数据包需要发送到 A 。没有数据包可以移动,因为在另一端没有缓冲区可用,这就是一个典型的资源死锁。
活锁
某些情况下,当进程意识到它不能获取所需要的下一个锁时,就会尝试礼貌的释放已经获得的锁,然后等待非常短的时间再次尝试获取。可以想像一下这个场景:当两个人在狭路相逢的时候,都想给对方让路,相同的步调会导致双方都无法前进。
现在假想有一对并行的进程用到了两个资源。它们分别尝试获取另一个锁失败后,两个进程都会释放自己持有的锁,再次进行尝试,这个过程会一直进行重复。很明显,这个过程中没有进程阻塞,但是进程仍然不会向下执行,这种状况我们称之为 活锁(livelock)
。
饥饿
与死锁和活锁的一个非常相似的问题是 饥饿(starvvation)
。想象一下你什么时候会饿?一段时间不吃东西是不是会饿?对于进程来讲,最重要的就是资源,如果一段时间没有获得资源,那么进程会产生饥饿,这些进程会永远得不到服务。
我们假设打印机的分配方案是每次都会分配给最小文件的进程,那么要打印大文件的进程会永远得不到服务,导致进程饥饿,进程会无限制的推后,虽然它没有阻塞。
后记
这篇文章到这里就结束了,后面我会继续写关于计算机网络、计算机基础、Java 相关、Java 架构相关的面试题。
如果这篇文章你觉得还不错的话,还希望可以点赞、在看、转发、留言,欢迎关注一下我的公众号【程序员cxuan】,这个号的干货简直太多了。
最后,你的支持是我继续肝文的动力。希望你能顺利进入大厂,加油!
操作系统之总览
文章主要结构图如下
操作系统
现代计算机系统由一个或多个处理器、主存、打印机、键盘、鼠标、显示器、网络接口以及各种输入/输出设备构成。
然而,程序员不会直接和这些硬件打交道,而且每位程序员不可能会掌握所有计算机系统的细节,这样我们就不用再编写代码了,所以在硬件的基础之上,计算机安装了一层软件,这层软件能够通过响应用户输入的指令达到控制硬件的效果,从而满足用户需求,这种软件称之为 操作系统
,它的任务就是为用户程序提供一个更好、更简单、更清晰的计算机模型。
我们一般常见的操作系统主要有 Windows、Linux、FreeBSD 或 OS X ,这种带有图形界面的操作系统被称为 图形用户界面(Graphical User Interface, GUI)
,而基于文本、命令行的通常称为 Shell
。下面是我们所要探讨的操作系统的部件
这是一个操作系统的简化图,最下面的是硬件,硬件包括芯片、电路板、磁盘、键盘、显示器等我们上面提到的设备,在硬件之上是软件。大部分计算机有两种运行模式:内核态
和 用户态
,软件中最基础的部分是操作系统
,它运行在 内核态
中,内核态也称为 管态
和 核心态
,它们都是操作系统的运行状态,只不过是不同的叫法而已。操作系统具有硬件的访问权,可以执行机器能够运行的任何指令。软件的其余部分运行在 用户态
下。
用户接口程序(shell 或者 GUI)
处于用户态中,并且它们位于用户态的最低层,允许用户运行其他程序,例如 Web 浏览器、电子邮件阅读器、音乐播放器等。而且,越靠近用户态的应用程序越容易编写,如果你不喜欢某个电子邮件阅读器你可以重新写一个或者换一个,但你不能自行写一个操作系统或者是中断处理程序。这个程序由硬件保护,防止外部对其进行修改。
计算机硬件简介
操作系统与运行操作系统的内核硬件关系密切。操作系统扩展了计算机指令集并管理计算机的资源。因此,操作系统因此必须足够了解硬件的运行,这里我们先简要介绍一下现代计算机中的计算机硬件。
从概念上来看,一台简单的个人电脑可以被抽象为上面这种相似的模型,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)
,
比流水线更先进的设计是 超标量(superscalar)
CPU,下面是超标量 CPU 的设计
在上面这个设计中,存在多个执行单元,例如,一个用来进行整数运算、一个用来浮点数运算、一个用来布尔运算。两个或者更多的指令被一次性取出、解码并放入缓冲区中,直至它们执行完毕。只要一个执行单元空闲,就会去检查缓冲区是否有可以执行的指令。如果有,就把指令从缓冲区中取出并执行。这种设计的含义是应用程序通常是无序执行的。在大多数情况下,硬件负责保证这种运算的结果与顺序执行指令时的结果相同。
除了用在嵌入式系统中非常简单的 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。
如果要说在绝对核心数量方面,没有什么能赢过现代 GPU(Graphics Processing Unit)
,GPU 是指由成千上万个微核组成的处理器。它们擅长处理大量并行的简单计算。
内存
计算机中第二个主要的组件就是内存。理想情况下,内存应该非常快速(比执行一条指令要快,从而不会拖慢 CPU 执行效率),而且足够大且便宜,但是目前的技术手段无法满足三者的需求。于是采用了不同的处理方式,存储器系统采用一种分层次的结构
顶层的存储器速度最高,但是容量最小,成本非常高,层级结构越向下,其访问效率越慢,容量越大,但是造价也就越便宜。
寄存器
存储器的顶层是 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 相比,每个二进制位的成本低了两个数量级,而且经常也有两个数量级大的容量。磁盘唯一的问题是随机访问数据时间大约慢了三个数量级。磁盘访问慢的原因是因为磁盘的构造不同
磁盘是一种机械装置,在一个磁盘中有一个或多个金属盘片,它们以 5400rpm、7200rpm、10800rpm 或更高的速度旋转。从边缘开始有一个机械臂悬横在盘面上,这类似于老式播放塑料唱片 33 转唱机上的拾音臂。信息会写在磁盘一系列的同心圆上。在任意一个给定臂的位置,每个磁头可以读取一段环形区域,称为磁道(track)
。把一个给定臂的位置上的所有磁道合并起来,组成了一个柱面(cylinder)
。
每个磁道划分若干扇区,扇区的值是 512 字节。在现代磁盘中,较外部的柱面比较内部的柱面有更多的扇区。机械臂从一个柱面移动到相邻的柱面大约需要 1ms。而随机移到一个柱面的典型时间为 5ms 至 10ms,具体情况以驱动器为准。一旦磁臂到达正确的磁道上,驱动器必须等待所需的扇区旋转到磁头之下,就开始读写,低端硬盘的速率是50MB/s
,而高速磁盘的速率是 160MB/s
。
需要注意,
固态硬盘(Solid State Disk, SSD)
不是磁盘,固态硬盘并没有可以移动的部分,外形也不像唱片,并且数据是存储在存储器(闪存)
中,与磁盘唯一的相似之处就是它也存储了大量即使在电源关闭也不会丢失的数据。
许多计算机支持一种著名的虚拟内存
机制,这种机制使得期望运行的存储空间大于实际的物理存储空间。其方法是将程序放在磁盘上,而将主存作为一部分缓存,用来保存最频繁使用的部分程序,这种机制需要快速映像内存地址,用来把程序生成的地址转换为有关字节在 RAM 中的物理地址。这种映像由 CPU 中的一个称为 存储器管理单元(Memory Management Unit, MMU)
的部件来完成。
缓存和 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 端口空间,每个寄存器都有一个端口地址。在这些计算机中,特殊的 IN
和 OUT
指令会在内核态下启用,它能够允许设备驱动程序和寄存器进行读写。前面第一种方式会限制特殊的 I/O 指令但是允许一些地址空间;后者不需要地址空间但是需要特殊的指令,这两种应用都很广泛。
实现输入和输出的方式有三种。
- 在最简单的方式中,用户程序会发起系统调用,内核会将其转换为相应驱动程序的程序调用,然后设备驱动程序启动 I/O 并循环检查该设备,看该设备是否完成了工作(一般会有一些二进制位用来指示设备仍在忙碌中)。当 I/O 调用完成后,设备驱动程序把数据送到指定的地方并返回。然后操作系统会将控制权交给调用者。这种方式称为
忙等待(busy waiting)
,这种方式的缺点是要一直占据 CPU,CPU 会一直轮询 I/O 设备直到 I/O 操作完成。 - 第二种方式是设备驱动程序启动设备并且让该设备在操作完成时发生中断。设备驱动程序在这个时刻返回。操作系统接着在需要时阻塞调用者并安排其他工作进行。当设备驱动程序检测到该设备操作完成时,它发出一个
中断
通知操作完成。
在操作系统中,中断是非常重要的,所以这需要更加细致的讨论一下。
如上图所示,这是一个三步的 I/O 过程,第一步,设备驱动程序会通过写入设备寄存器告诉控制器应该做什么。然后,控制器启动设备。当控制器完成读取或写入被告知需要传输的字节后,它会在步骤 2 中使用某些总线向中断控制器发送信号。如果中断控制器准备好了接收中断信号(如果正忙于一个优先级较高的中断,则可能不会接收),那么它就会在 CPU 的一个引脚上面声明。这就是步骤3
在第四步中,中断控制器把该设备的编号放在总线上,这样 CPU 可以读取总线,并且知道哪个设备完成了操作(可能同时有多个设备同时运行)。
一旦 CPU 决定去实施中断后,程序计数器和 PSW 就会被压入到当前堆栈中并且 CPU 会切换到内核态。设备编号可以作为内存的一个引用,用来寻找该设备中断处理程序的地址。这部分内存称作中断向量(interrupt vector)
。一旦中断处理程序(中断设备的设备驱动程序的一部分)开始后,它会移除栈中的程序计数器和 PSW 寄存器,并把它们进行保存,然后查询设备的状态。在中断处理程序全部完成后,它会返回到先前用户程序尚未执行的第一条指令,这个过程如下
- 实现 I/O 的第三种方式是使用特殊的硬件:
直接存储器访问(Direct Memory Access, DMA)
芯片。它可以控制内存和某些控制器之间的位流,而无需 CPU 的干预。CPU 会对 DMA 芯片进行设置,说明需要传送的字节数,有关的设备和内存地址以及操作方向。当 DMA 芯片完成后,会造成中断,中断过程就像上面描述的那样。我们会在后面具体讨论中断过程
当另一个中断处理程序正在运行时,中断可能(并且经常)发生在不合宜的时间。 因此,CPU 可以禁用中断,并且可以在之后重启中断。在 CPU 关闭中断后,任何已经发出中断的设备,可以继续保持其中断信号处理,但是 CPU 不会中断,直至中断再次启用为止。如果在关闭中断时,已经有多个设备发出了中断信号,中断控制器将决定优先处理哪个中断,通常这取决于事先赋予每个设备的优先级,最高优先级的设备优先赢得中断权,其他设备则必须等待。
总线
上面的结构(简单个人计算机的组件图)在小型计算机已经使用了多年,并用在早期的 IBM PC 中。然而,随着处理器核内存变得越来越快,单个总线处理所有请求的能力也达到了上限,其中也包括 IBM PC 总线。必须放弃使用这种模式。其结果导致了其他总线的出现,它们处理 I/O 设备以及 CPU 到存储器的速度都更快。这种演变的结果导致了下面这种结构的出现。
上图中的 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
中提出示例,相同的示例也会存在于其他系统中,我们后面会进行介绍。
进程
操作系统一个很关键的概念就是 进程(Process)
。进程的本质就是操作系统执行的一个程序。与每个进程相关的是地址空间(address space)
,这是从某个最小值的存储位置(通常是零)到某个最大值的存储位置的列表。在这个地址空间中,进程可以进行读写操作。地址空间中存放有可执行程序,程序所需要的数据和它的栈。与每个进程相关的还有资源集,通常包括寄存器(registers)
(寄存器一般包括程序计数器(program counter)
和堆栈指针(stack pointer)
)、打开文件的清单、突发的报警、有关的进程清单和其他需要执行程序的信息。你可以把进程看作是容纳运行一个程序所有信息的一个容器。
对进程建立一种直观感觉的方式是考虑建立一种多程序的系统。考虑下面这种情况:用户启动一个视频编辑程序,指示它按照某种格式转换视频,然后再去浏览网页。同时,一个检查电子邮件的后台进程被唤醒并开始运行,这样,我们目前就会有三个活动进程:视频编辑器、Web 浏览器和电子邮件接收程序。操作系统周期性的挂起一个进程然后启动运行另一个进程,这可能是由于过去一两秒钟程序用完了 CPU 分配的时间片,而 CPU 转而运行另外的程序。
像这样暂时中断进程后,下次应用程序在此启动时,必须要恢复到与中断时刻相同的状态,这在我们用户看起来是习以为常的事情,但是操作系统内部却做了巨大的事情。这就像和足球比赛一样,一场完美精彩的比赛是可以忽略裁判的存在的。这也意味着在挂起时该进程的所有信息都要被保存下来。例如,进程可能打开了多个文件进行读取。与每个文件相关联的是提供当前位置的指针(即下一个需要读取的字节或记录的编号)。当进程被挂起时,必须要保存这些指针,以便在重新启动进程后执行的 read
调用将能够正确的读取数据。在许多操作系统中,与一个进程有关的所有信息,除了该进程自身地址空间的内容以外,均存放在操作系统的一张表中,称为 进程表(process table)
,进程表是数组或者链表结构,当前存在每个进程都要占据其中的一项。
所以,一个挂起的进程包括:进程的地址空间(往往称作磁芯映像
, core image,纪念过去的磁芯存储器),以及对应的进程表项(其中包括寄存器以及稍后启动该进程所需要的许多其他信息)。
与进程管理有关的最关键的系统调用往往是决定着进程的创建和终止的系统调用。考虑一个典型的例子,有一个称为 命令解释器(command interpreter)
或 shell
的进程从终端上读取命令。此时,用户刚键入一条命令要求编译一个程序。shell 必须先创建一个新进程来执行编译程序,当编译程序结束时,它执行一个系统调用来终止自己的进程。
如果一个进程能够创建一个或多个进程(称为子进程
),而且这些进程又可以创建子进程,则很容易找到进程数,如下所示
上图表示一个进程树的示意图,进程 A 创建了两个子进程 B 和进程 C,子进程 B 又创建了三个子进程 D、E、F。
合作完成某些作业的相关进程经常需要彼此通信来完成作业,这种通信称为进程间通信(interprocess communication)
。我们在后面会探讨进程间通信。
其他可用的进程系统调用包括:申请更多的内存(或释放不再需要的内存),等待一个子进程结束,用另一个程序覆盖该程序。
有时,需要向一个正在运行的进程传递信息,而该进程并没有等待接收信息。例如,一个进程通过网络向另一台机器上的进程发送消息进行通信。为了保证一条消息或消息的应答不丢失。发送者要求它所在的操作系统在指定的若干秒后发送一个通知,这样如果对方尚未收到确认消息就可以进行重新发送。在设定该定时器后,程序可以继续做其他工作。
在限定的时间到达后,操作系统会向进程发送一个 警告信号(alarm signal)
。这个信号引起该进程暂时挂起,无论该进程正在做什么,系统将其寄存器的值保存到堆栈中,并开始重新启动一个特殊的信号处理程,比如重新发送可能丢失的消息。这些信号是软件模拟的硬件中断,除了定时器到期之外,该信号可以通过各种原因产生。许多由硬件检测出来的陷阱,如执行了非法指令或使用了无效地址等,也被转换成该信号并交给这个进程。
系统管理器授权每个进程使用一个给定的 UID(User IDentification)
。每个启动的进程都会有一个操作系统赋予的 UID,子进程拥有与父进程一样的 UID。用户可以是某个组的成员,每个组也有一个 GID(Group IDentification)
。
在 UNIX 操作系统中,有一个 UID 是 超级用户(superuser)
,或者 Windows 中的管理员(administrator)
,它具有特殊的权利,可以违背一些保护规则。在大型系统中,只有系统管理员掌握着那些用户可以称为超级用户。
地址空间
每台计算机都有一些主存用来保存正在执行的程序。在一个非常简单的操作系统中,仅仅有一个应用程序运行在内存中。为了运行第二个应用程序,需要把第一个应用程序移除才能把第二个程序装入内存。
复杂一些的操作系统会允许多个应用程序同时装入内存中运行。为了防止应用程序之间相互干扰(包括操作系统),需要有某种保护机制。虽然此机制是在硬件中实现,但却是由操作系统控制的。
上述观点涉及对计算机主存的管理和保护。另一种同等重要并与存储器有关的内容是管理进程的地址空间。通常,每个进程有一些可以使用的地址集合,典型值从 0 开始直到某个最大值。一个进程可拥有的最大地址空间小于主存。在这种情况下,即使进程用完其地址空间,内存也会有足够的内存运行该进程。
但是,在许多 32 位或 64 位地址的计算机中,分别有 2^32 或 2^64 字节的地址空间。如果一个进程有比计算机拥有的主存还大的地址空间,而且该进程希望使用全部的内存,那该怎么处理?在早期的计算机中是无法处理的。但是现在有了一种虚拟内存
的技术,正如前面讲到过的,操作系统可以把部分地址空间装入主存,部分留在磁盘上,并且在需要时来回交换它们。
文件
几乎所有操作系统都支持的另一个关键概念就是文件系统。如前所述,操作系统的一项主要功能是屏蔽磁盘和其他 I/O 设备的细节特性,给程序员提供一个良好、清晰的独立于设备的抽象文件模型。创建文件、删除文件、读文件和写文件 都需要系统调用。在文件可以读取之前,必须先在磁盘上定位和打开文件,在文件读过之后应该关闭该文件,有关的系统调用则用于完成这类操作。
为了提供保存文件的地方,大多数个人计算机操作系统都有目录(directory)
的概念,从而可以把文件分组。比如,学生可以给每个课程都创建一个目录,用于保存该学科的资源,另一个目录可以存放电子邮件,再有一个目录可以存放万维网主页。这就需要系统调用创建和删除目录、将已有文件放入目录中,从目录中删除文件等。目录项可以是文件或者目录,目录和目录之间也可以嵌套,这样就产生了文件系统
进程和文件层次都是以树状的结构组织,但这两种树状结构有不少不同之处。一般进程的树状结构层次不深(很少超过三层),而文件系统的树状结构要深一些,通常会到四层甚至五层。进程树层次结构是暂时的,通常最多存在几分钟,而目录层次则可能存在很长时间。进程和文件在权限保护方面也是有区别的。一般来说,父进程能控制和访问子进程,而在文件和目录中通常存在一种机制,使文件所有者之外的其他用户也能访问该文件。
目录层结构中的每一个文件都可以通过从目录的顶部即 根目录(Root directory)
开始的路径名(path name)
来确定。绝对路径名包含了从根目录到该文件的所有目录清单,它们之间用斜杠分隔符分开,在上面的大学院系文件系统中,文件 CS101 的路径名是 /Faculty/Prof.Brown/Courses/CS101
。最开始的斜杠分隔符代表的是根目录 /
,也就是文件系统的绝对路径。
出于历史原因,Windows 下面的文件系统以
\
来作为分隔符,但是 Linux 会以/
作为分隔符。
在上面的系统中,每个进程会有一个 工作目录(working directory)
,对于没有以斜线开头给出绝对地址的路径,将在这个工作目录下寻找。如果 /Faculty/Prof.Brown
是工作目录,那么 /Courses/CS101
与上面给定的绝对路径名表示的是同一个文件。进程可以通过使用系统调用指定新的工作目录,从而变更其工作目录。
在读写文件之前,首先需要打开文件,检查其访问权限。若权限许可,系统将返回一个小整数,称作文件描述符(file descriptor)
,供后续操作使用。若禁止访问,系统则返回一个错误码。
在 UNIX 中,另一个重要的概念是 特殊文件(special file)
。提供特殊文件是为了使 I/O 设备看起来像文件一般。这样,就像使用系统调用读写文件一样,I/O 设备也可以通过同样的系统调用进行读写。特殊文件有两种,一种是块儿特殊文件(block special file)
和 字符特殊文件(character special file)
。块特殊文件指那些由可随机存取的块组成的设备,如磁盘等。比如打开一个块特殊文件,然后读取第4块,程序可以直接访问设备的第4块而不必考虑存放在该文件的文件系统结构。类似的,字符特殊文件用于打印机、调制解调起和其他接受或输出字符流的设备。按照惯例,特殊文件保存在 /dev
目录中。例如,/devv/lp 是打印机。
还有一种与进程和文件相关的特性是管道,管道(pipe)
是一种虚文件,他可以连接两个进程
如果 A 和 B 希望通过管道对话,他们必须提前设置管道。当进程 A 相对进程 B 发送数据时,它把数据写到管道上,相当于管道就是输出文件。这样,在 UNIX 中两个进程之间的通信就非常类似于普通文件的读写了。
保护
计算机中含有大量的信息,用户希望能够对这些信息中有用而且重要的信息加以保护,这些信息包括电子邮件、商业计划等,管理这些信息的安全性完全依靠操作系统来保证。例如,文件提供授权用户访问。
比如 UNIX 操作系统,UNIX 操作系统通过对每个文件赋予一个 9 位二进制保护代码,对 UNIX 中的文件实现保护。该保护代码有三个位子段,一个用于所有者,一个用于与所有者同组(用户被系统管理员划分成组)的其他成员,一个用于其他人。每个字段中有一位用于读访问,一位用于写访问,一位用于执行访问。这些位就是著名的 rwx位
。例如,保护代码 rwxr-x--x
的含义是所有者可以读、写或执行该文件,其他的组成员可以读或执行(但不能写)此文件、而其他人可以执行(但不能读和写)该文件。
shell
操作系统是执行系统调用的代码。编辑器、编译器、汇编程序、链接程序、使用程序以及命令解释符等,尽管非常重要,非常有用,但是它们确实不是操作系统的组成部分。下面我们着重介绍一下 UNIX 下的命令提示符,也就是 shell
,shell 虽然有用,但它也不是操作系统的一部分,然而它却能很好的说明操作系统很多特性,下面我们就来探讨一下。
shell 有许多种,例如 sh、csh、ksh 以及 bash等,它们都支持下面这些功能,最早起的 shell 可以追溯到 sh
用户登录时,会同时启动一个 shell,它以终端作为标准输入和标准输出。首先显示提示符(prompt)
,它可能是一个美元符号($)
,提示用户 shell 正在等待接收命令,假如用户输入
date
shell 会创建一个子进程,并运行 date 做为子进程。在该子进程运行期间,shell 将等待它结束。在子进程完成时,shell 会显示提示符并等待下一行输入。
用户可以将标准输出重定向到一个文件中,例如
date > file
同样的,也可以将标准输入作为重定向
sort <file1> file2
这会调用 sort 程序来接收 file1 的内容并把结果输出到 file2。
可以将一个应用程序的输出通过管道作为另一个程序的输入,因此有
cat file1 file2 file3 | sort > /dev/lp
这会调用 cat 应用程序来合并三个文件,将其结果输送到 sort 程序中并按照字典进行排序。sort 应用程序又被重定向到 /dev/lp ,显然这是一个打印操作。
系统调用
我们已经可以看到操作系统提供了两种功能:为用户提供应用程序抽象和管理计算机资源。对于大部分在应用程序和操作系统之间的交互主要是应用程序的抽象,例如创建、写入、读取和删除文件。计算机的资源管理对用户来说基本上是透明的。因此,用户程序和操作系统之间的接口主要是处理抽象。为了真正理解操作系统的行为,我们必须仔细的分析这个接口。
多数现代操作系统都有功能相同但是细节不同的系统调用,引发操作系统的调用依赖于计算机自身的机制,而且必须用汇编代码表达。任何单 CPU 计算机一次执行执行一条指令。如果一个进程在用户态下运行用户程序,例如从文件中读取数据。那么如果想要把控制权交给操作系统控制,那么必须执行一个异常指令或者系统调用指令。操作系统紧接着需要参数检查找出所需要的调用进程。操作系统紧接着进行参数检查找出所需要的调用进程。然后执行系统调用,把控制权移交给系统调用下面的指令。大致来说,系统调用就像是执行了一个特殊的过程调用,但是只有系统调用能够进入内核态而过程调用则不能进入内核态。
为了能够了解具体的调用过程,下面我们以 read
方法为例来看一下调用过程。像上面提到的那样,会有三个参数,第一个参数是指定文件、第二个是指向缓冲区、第三个参数是给定需要读取的字节数。就像几乎所有系统调用一样,它通过使用与系统调用相同的名称来调用一个函数库,从而从C程序中调用:read。
count = read(fd,buffer,nbytes);
系统调用在 count 中返回实际读出的字节数。这个值通常与 nbytes 相同,但也可能更小。比如在读过程中遇到了文件尾的情况。
如果系统调用不能执行,不管是因为无效的参数还是磁盘错误,count 的值都会被置成 -1,然后在全局变量 errno
中放入错误信号。程序应该进场检查系统调用的结果以了解是否出错。
系统调用是通过一系列的步骤实现的,为了更清楚的说明这个概念,我们还以 read 调用为例,在准备系统调用前,首先会把参数压入堆栈,如下所示
C 和 C++ 编译器使用逆序(必须把第一个参数赋值给 printf(格式字符串),放在堆栈的顶部)。第一个参数和第三个参数都是值调用,但是第二个参数通过引用传递,即传递的是缓冲区的地址(由 & 指示),而不是缓冲的内容。然后是 C 调用系统库的 read 函数,这也是第四步。
在由汇编语言写成的库过程中,一般把系统调用的编号放在操作系统所期望的地方,如寄存器(第五步)。然后执行一个 TRAP
指令,将用户态切换到内核态,并在内核中的一个固定地址开始执行第六步。TRAP 指令实际上与过程调用指令非常相似,它们后面都跟随一个来自远处位置的指令,以及供以后使用的一个保存在栈中的返回地址。
TRAP 指令与过程调用指令存在两个方面的不同
- TRAP 指令会改变操作系统的状态,由用户态切换到内核态,而过程调用不改变模式
- 其次,TRAP 指令不能跳转到任意地址上。根据机器的体系结构,要么跳转到一个单固定地址上,或者指令中有一 8 位长的字段,它给定了内存中一张表格的索引,这张表格中含有跳转地址,然后跳转到指定地址上。
跟随在 TRAP 指令后的内核代码开始检查系统调用编号,然后dispatch
给正确的系统调用处理器,这通常是通过一张由系统调用编号所引用的、指向系统调用处理器的指针表来完成第七步。此时,系统调用处理器运行第八步,一旦系统调用处理器完成工作,控制权会根据 TRAP 指令后面的指令中返回给函数调用库第九步。这个过程接着以通常的过程调用返回的方式,返回到客户应用程序,这是第十步。然后调用完成后,操作系统还必须清除用户堆栈,然后增加堆栈指针(increment stackpointer)
,用来清除调用 read 之前压入的参数。从而完成整个 read 调用过程。
在上面的第九步中我们说道,控制可能返回 TRAP 指令后面的指令,把控制权再移交给调用者这个过程中,系统调用会发生阻塞,从而避免应用程序继续执行。这么做是有原因的。例如,如果试图读键盘,此时并没有任何输入,那么调用者就必须被阻塞。在这种情形下,操作系统会检查是否有其他可以运行的进程。这样,当有用户输入 时候,进程会提醒操作系统,然后返回第 9 步继续运行。
下面,我们会列出一些常用的 POSIX
系统调用,POSIX 系统调用大概有 100 多个,它们之中最重要的一些调用见下表
**进程管理
调用 | 说明 |
---|---|
pid = fork() | 创建与父进程相同的子进程 |
pid = waitpid(pid, &statloc,options) | 等待一个子进程终止 |
s = execve(name,argv,environp) | 替换一个进程的核心映像 |
exit(status) | 终止进程执行并返回状态 |
**文件管理
调用 | 说明 |
---|---|
fd = open(file, how,…) | 打开一个文件使用读、写 |
s = close(fd) | 关闭一个打开的文件 |
n = read(fd,buffer,nbytes) | 把数据从一个文件读到缓冲区中 |
n = write(fd,buffer,nbytes) | 把数据从缓冲区写到一个文件中 |
position = iseek(fd,offset,whence) | 移动文件指针 |
s = stat(name,&buf) | 取得文件状态信息 |
**目录和文件系统管理
调用 | 说明 |
---|---|
s = mkdir(nname,mode) | 创建一个新目录 |
s = rmdir(name) | 删去一个空目录 |
s = link(name1,name2) | 创建一个新目录项 name2,并指向 name1 |
s = unlink(name) | 删去一个目录项 |
s = mount(special,name,flag) | 安装一个文件系统 |
s = umount(special) | 卸载一个文件系统 |
**其他
调用 | 说明 |
---|---|
s = chdir(dirname) | 改变工作目录 |
s = chmod(name,mode) | 修改一个文件的保护位 |
s = kill(pid, signal) | 发送信号给进程 |
seconds = time(&seconds) | 获取从 1970 年1月1日至今的时间 |
上面的系统调用参数中有一些公共部分,例如 pid 系统进程 id,fd 是文件描述符,n 是字节数,position 是在文件中的偏移量、seconds 是流逝时间。
从宏观角度上看,这些系统调所提供的服务确定了多数操作系统应该具有的功能,下面分别来对不同的系统调用进行解释
用于进程管理的系统调用
在 UNIX 中,fork
是唯一可以在 POSIX 中创建进程的途径,它创建一个原有进程的副本,包括所有的文件描述符、寄存器等内容。在 fork 之后,原有进程以及副本(父与子)就分开了。在 fork 过程中,所有的变量都有相同的值,虽然父进程的数据通过复制给子进程,但是后续对其中任何一个进程的修改不会影响到另外一个。fork 调用会返回一个值,在子进程中该值为 0 ,并且在父进程中等于子进程的 进程标识符(Process IDentified,PID)
。使用返回的 PID,就可以看出来哪个是父进程和子进程。
在多数情况下, 在 fork 之后,子进程需要执行和父进程不一样的代码。从终端读取命令,创建一个子进程,等待子进程执行命令,当子进程结束后再读取下一个输入的指令。为了等待子进程完成,父进程需要执行 waitpid
系统调用,父进程会等待直至子进程终止(若有多个子进程的话,则直至任何一个子进程终止)。waitpid 可以等待一个特定的子进程,或者通过将第一个参数设为 -1 的方式,等待任何一个比较老的子进程。当 waitpid 完成后,会将第二个参数 statloc
所指向的地址设置为子进程的退出状态(正常或异常终止以及退出值)。有各种可使用的选项,它们由第三个参数确定。例如,如果没有已经退出的子进程则立刻返回。
那么 shell 该如何使用 fork 呢?在键入一条命令后,shell 会调用 fork 命令创建一个新的进程。这个子进程会执行用户的指令。通过使用 execve
系统调用可以实现系统执行,这个系统调用会引起整个核心映像被一个文件所替代,该文件由第一个参数给定。下面是一个简化版的例子说明 fork、waitpid 和 execve 的使用
#define TRUE 1
/* 一直循环下去 */
while(TRUE){
/* 在屏幕上显示提示符 */
type_prompt();
/* 从终端读取输入 */
read_command(command,parameters)
/* fork 子进程 */
if(fork() != 0){
/* 父代码 */
/* 等待子进程执行完毕 */
waitpid(-1, &status, 0);
}else{
/* 执行命令 */
/* 子代码 */
execve(command,parameters,0)
}
}
一般情况下,execve 有三个参数:将要执行的文件名称,一个指向变量数组的指针,以及一个指向环境数组的指针。这里对这些参数做一个简要的说明。
先看一个 shell 指令
cp file1 file2
此命令把 file1 复制到 file2 文件中,在 shell 执行 fork 之后,子进程定位并执行文件拷贝,并将源文件和目标文件的名称传递给它。
cp 的主程序(以及包含其他大多数 C 程序的主程序)包含声明
main(argc,argv,envp)
其中 argc 是命令行中参数数目的计数,包括程序名称。对于上面的例子,argc
是3。第二个参数argv
是数组的指针。该数组的元素 i 是指向该命令行第 i 个字符串的指针。在上面的例子中,argv[0] 指向字符串 cp,argv[1] 指向字符串 file1,argv[2] 指向字符串 file2。main 的第三个参数是指向环境的指针,该环境是一个数组,含有 name = value
的赋值形式,用以将诸如终端类型以及根目录等信息传送给程序。这些变量通常用来确定用户希望如何完成特定的任务(例如,使用默认打印机)。在上面的例子中,没有环境参数传递给 execve ,所以环境变量是 0 ,所以 execve 的第三个参数为 0 。
可能你觉得 execve 过于复杂,这时候我要鼓励一下你,execve 可能是 POSIX 的全部系统调用中最复杂的一个了,其他都比较简单。作为一个简单的例子,我们再来看一下 exit
,这是进程在执行完成后应执行的系统调用。这个系统调用有一个参数,它的退出状态是 0 – 255 之间,它通过 waitpid 系统调用中的 statloc 返回给父级。
UNIX 中的进程将内存划分成三个部分:text segment,文本区
,例如程序代码,data segment,数据区
,例如变量,stack segment
,栈区域。数据向上增长而堆栈向下增长,如下图所示
上图能说明三个部分的内存分配情况,夹在中间的是空闲区,也就是未分配的区域,堆栈在需要时自动的挤压空闲区域,不过数据段的扩展是显示地通过系统调用 brk
进行的,在数据段扩充后,该系统调用指向一个新地址。但是,这个调用不是 POSIX 标准中定义的,对于存储器的动态分配,鼓励程序员使用 malloc
函数,而 malloc 的内部实现则不是一个适合标准化的主题,因为几乎没有程序员直接使用它。
用于文件管理的系统调用
许多系统调用都与文件系统有关,要读写一个文件,必须先将其打开。这个系统调用通过绝对路径名或指向工作目录的相对路径名指定要打开文件的名称,而代码 O_RDONLY
、 O_WRONLY
或 O_RDWR
的含义分别是只读、只写或者两者都可以,为了创建一个新文件,使用 O_CREATE
参数。然后可使用返回的文件描述符进行读写操作。接着,可以使用 close 关闭文件,这个调用使得文件描述符在后续的 open 中被再次使用。
最常用的调用还是 read
和 write
,我们再前面探讨过 read 调用,write 具有与 read 相同的参数。
尽管多数程序频繁的读写文件,但是仍有一些应用程序需要能够随机访问一个文件的任意部分。与每个文件相关的是一个指向文件当前位置的指针。在顺序读写时,该指针通常指向要读出(写入)的下一个字节。Iseek
调用可以改变该位置指针的值,这样后续的 read 或 write 调用就可以在文件的任何地方开始。
Iseek 有三个参数,position = iseek(fd,offset,whence)
,第一个是文件描述符,第二个是文件位置,第三个是说明该文件位置是相对于文件起始位置,当前位置还是文件的结尾。在修改了指针之后,Iseek 所返回的值是文件中的绝对位置。
UNIX 为每个文件保存了该文件的类型(普通文件、特殊文件、目录等)、大小,最后修改时间以及其他信息,程序可以通过 stat
系统调用查看这些信息。s = stat(name,&buf)
,第一个参数指定了被检查的文件;第二个参数是一个指针,该指针指向存放这些信息的结构。对于一个打开的文件而言,fstat 调用完成同样的工作。
用于目录管理的系统调用
下面我们探讨目录和整个文件系统的系统调用,上面探讨的是和某个文件有关的系统调用。 mkdir
和 rmdir
分别用于创建s = mkdir(nname,mode)
和删除 s = rmdir(name)
空目录,下一个调用是 s = link(name1,name2)
它的作用是允许同一个文件以两个或者多个名称出现,多数情况下是在不同的目录中使用 link ,下面我们探讨一下 link 是如何工作的
图中有两个用户 ast
和 jim
,每个用户都有他自己的一个目录和一些文件,如果 ast 要执行一个包含下面系统调用的应用程序
link("/usr/jim/memo", "/usr/ast/note");
jim 中的 memo 文件现在会进入到 ast 的目录中,在 note 名称下。此后,/usr/jim/memo
和 /usr/ast/note
会有相同的名称。
用户目录是保存在 /usr,/user,/home 还是其他位置,都是由本地系统管理员决定的。
要理解 link 是如何工作的需要清楚 link 做了什么操作。UNIX 中的每个文件都有一个独一无二的版本,也称作 i - number,i-编号
,它标示着不同文件的版本。这个 i – 编号是 i-nodes,i-节点
表的索引。每个文件都会表明谁拥有这个文件,这个磁盘块的位置在哪,等等。目录只是一个包含一组(i编号,ASCII名称)对应的文件。UNIX 中的第一个版本中,每个目录项都会有 16 个字节,2 个字节对应 i – 编号和 14 个字节对应其名称。现在需要一个更复杂的结构需要支持长文件名,但是从概念上讲一个目录仍是一系列(i-编号,ASCII 名称)的集合。在上图中,mail
的 i-编号为 16,依此类推。link 只是利用某个已有文件的 i-编号,创建一个新目录项(也许用一个新名称)。在上图 b 中,你会发现有两个相同的 70 i-编号的文件,因此它们需要有相同的文件。如果其中一个使用了 unlink
系统调用的话,其中一个会被移除,另一个将保留。如果两个文件都移除了,则 UNIX 会发现该文件不存在任何没有目录项(i-节点中的一个域记录着指向该文件的目录项),就会把该文件从磁盘中移除。
就像我们上面提到过的那样,mount
系统 s = mount(special,name,flag)
调用会将两个文件系统合并为一个。通常的情况是将根文件系统分布在硬盘(子)分区上,并将用户文件分布在另一个(子)分区上,该根文件系统包含常用命令的二进制(可执行)版本和其他使用频繁的文件。然后,用户就会插入可读取的 USB 硬盘。
通过执行 mount 系统调用,USB 文件系统可以被添加到根文件系统中,
如果用 C 语言来执行那就是
mount("/dev/sdb0","/mnt",0)
这里,第一个参数是 USB 驱动器 0 的块特殊文件名称,第二个参数是被安装在树中的位置,第三个参数说明将要安装的文件系统是可读写的还是只读的。
当不再需要一个文件系统时,可以使用 umount 移除之。
其他系统调用
除了进程、文件、目录系统调用,也存在其他系统调用的情况,下面我们来探讨一下。我们可以看到上面其他系统调用只有四种,首先来看第一个 chdir,chdir 调用更改当前工作目录,在调用
chdir("/usr/ast/test");
后,打开 xyz 文件,会打开 /usr/ast/test/xyz
文件,工作目录的概念消除了总是需要输入长文件名的需要。
在 UNIX 系统中,每个文件都会有保护模式,这个模式会有一个读-写-执行
位,它用来区分所有者、组和其他成员。chmod
系统调用提供改变文件模式的操作。例如,要使一个文件除了对所有者之外的用户可读,你可以执行
chmod("file",0644);
kill
系统调用是用户和用户进程发送信号的方式,如果一个进程准备好捕捉一个特定的信号,那么在信号捕捉之前,会运行一个信号处理程序。如果进程没有准备好捕捉特定的信号,那么信号的到来会杀掉该进程(此名字的由来)。
POSIX 定义了若干时间处理的进程。例如,time
以秒为单位返回当前时间,0 对应着 1970 年 1月 1日。在一台 32 位字的计算机中,time 的最大值是 (2^32) – 1秒,这个数字对应 136 年多一点。所以在 2106 年,32 位的 UNIX 系统会发飙。如果读者现在有 32 位 UNIX 系统,建议在 2106 年更换位 64 位操作系统(偷笑~)。
Win 32 API
上面我们提到的都是 UNIX 系统调用,现在我们来聊聊 Win 32 中的系统调用。Windows 和 UNIX 在各自的编程方式上有着根本的不同。UNIX 程序由执行某些操作或执行其他操作的代码组成,进行系统调用以执行某些服务。Windows 系统则不同,Windows 应用程序通常是由事件驱动的。主程序会等待一些事件发生,然后调用程序去处理。最简单的事件处理是键盘敲击和鼠标滑过,或者是鼠标点击,或者是插入 USB 驱动,然后操作系统调用处理器去处理事件,更新屏幕和更新程序内部状态。这是与 UNIX 不同的设计风格。
当然,Windows 也有系统调用。在 UNIX 中,系统调用(比如 read)和系统调用所使用的调用库(例如 read)几乎是一对一的关系。而在 Windows 中,情况则大不相同。首先,函数库的调用和实际的系统调用几乎是不对应的。微软定义了一系列过程,称为 Win32应用编程接口(Application Programming Interface)
,程序员通过这套标准的接口来实现系统调用。这个接口支持从 Windows 95 版本以来所有的 Windows 版本。
Win32 API 调用的数量是非常巨大的,有数千个多。但这些调用并不都是在内核态的模式下运行时,有一些是在用户态的模型下运行。Win32 API 有大量的调用,用来管理视窗、几何图形、文本、字体、滚动条、对话框、菜单以及 GUI 的其他功能。为了使图形子系统在内核态下运行,需要系统调用,否则就只有函数库调用。
我们把关注点放在和 Win32 系统调用中来,我们可以简单看一下 Win32 API 中的系统调用和 UNIX 中有什么不同(并不是所有的系统调用)
UNIX | Win32 | 说明 |
---|---|---|
fork | CreateProcess | 创建一个新进程 |
waitpid | WaitForSingleObject | 等待一个进程退出 |
execve | none | CraeteProcess = fork + servvice |
exit | ExitProcess | 终止执行 |
open | CreateFile | 创建一个文件或打开一个已有的文件 |
close | CloseHandle | 关闭文件 |
read | ReadFile | 从单个文件中读取数据 |
write | WriteFile | 向单个文件写数据 |
lseek | SetFilePointer | 移动文件指针 |
stat | GetFileAttributesEx | 获得不同的文件属性 |
mkdir | CreateDirectory | 创建一个新的目录 |
rmdir | RemoveDirectory | 移除一个空的目录 |
link | none | Win32 不支持 link |
unlink | DeleteFile | 销毁一个已有的文件 |
mount | none | Win32 不支持 mount |
umount | none | Win32 不支持 mount,所以也不支持mount |
chdir | SetCurrentDirectory | 切换当前工作目录 |
chmod | none | Win32 不支持安全 |
kill | none | Win32 不支持信号 |
time | GetLocalTime | 获取当前时间 |
上表中是 UNIX 调用大致对应的 Win32 API 系统调用,简述一下上表。CreateProcess
用于创建一个新进程,它把 UNIX 中的 fork 和 execve 两个指令合成一个,一起执行。它有许多参数用来指定新创建进程的性质。Windows 中没有类似 UNIX 中的进程层次,所以不存在父进程和子进程的概念。在进程创建之后,创建者和被创建者是平等的。WaitForSingleObject
用于等待一个事件,等待的事件可以是多种可能的事件。如果有参数指定了某个进程,那么调用者将等待指定的进程退出,这通过 ExitProcess
来完成。
然后是6个文件操作,在功能上和 UNIX 的调用类似,然而在参数和细节上是不同的。和 UNIX 中一样,文件可以打开,读取,写入,关闭。SetFilePointer
和 GetFileAttributesEx
设置文件的位置并取得文件的属性。
Windows 中有目录,目录分别用 CreateDirectory
以及 RemoveDirectory
API 调用创建和删除。也有对当前的目录的标记,这可以通过 SetCurrentDirectory
来设置。使用GetLocalTime
可获得当前时间。
Win32 接口中没有文件的链接、文件系统的 mount、umount 和 stat ,当然, Win32 中也有大量 UNIX 中没有的系统调用,特别是对 GUI 的管理和调用。
操作系统结构
下面我们会探讨操作系统的几种结构,主要包括单体结构、分层系统、微内核、客户-服务端系统、虚拟机和外核等。下面以此来探讨一下
单体系统
到目前为止,在大多数系统中,整个系统在内核态以单一程序的方式运行。整个操作系统是以程序集合来编写的,链接在一块形成一个大的二进制可执行程序。使用此技术时,如果系统中的每个过程都提供了前者所需的一些有用的计算,则它可以自由调用任何其他过程。在单体系统中,调用任何一个所需要的程序都非常高效,但是上千个不受限制的彼此调用往往非常臃肿和笨拙,而且单体系统必然存在单体问题,那就是只要系统发生故障,那么任何系统和应用程序将不可用,这往往是灾难性的。
在单体系统中构造实际目标程序时,会首先编译所有单个过程(或包含这些过程的文件),然后使用系统链接器将它们全部绑定到一个可执行文件中
对于单体系统,往往有下面几种建议
- 需要有一个主程序,用来调用请求服务程序
- 需要一套服务过程,用来执行系统调用
- 需要一套服务程序,用来辅助服务过程调用
在单体系统中,对于每个系统调用都会有一个服务程序来保障和运行。需要一组实用程序来弥补服务程序需要的功能,例如从用户程序中获取数据。可将各种过程划分为一个三层模型
除了在计算机初启动时所装载的核心操作系统外,许多操作系统还支持额外的扩展。比如 I/O 设备驱动和文件系统。这些部件可以按需装载。在 UNIX 中把它们叫做 共享库(shared library)
,在 Windows 中则被称为 动态链接库(Dynamic Link Library,DLL)
。他们的扩展名为 .dll
,在 C:\Windows\system32
目录下存在 1000 多个 DLL 文件,所以不要轻易删除 C 盘文件,否则可能就炸了哦。
分层系统
分层系统使用层来分隔不同的功能单元。每一层只与该层的上层和下层通信。每一层都使用下面的层来执行其功能。层之间的通信通过预定义的固定接口通信。
分层系统是由 E.W.Dijkstar
和他的学生在荷兰技术学院所开发的 THE 系统。
把上面单体系统进一步通用化,就变为了一个层次式结构的操作系统,它的上层软件都是在下层软件的基础之上构建的。该系统分为六层,如下所示
层号 | 功能 |
---|---|
5 | 操作员 |
4 | 用户程序 |
3 | 输入/输出管理 |
2 | 操作员-进程通信 |
1 | 存储器和磁鼓管理 |
0 | 处理器分配和多道程序编程 |
处理器在 0 层运行,当中断发生或定时器到期时,由该层完成进程切换;在第 0 层之上,系统由一些连续的进程组成,编写这些进程时不用再考虑在单处理器上多进程运行的细节。内存管理在第 1 层,它分配进程的主存空间。第 1 层软件保证一旦需要访问某一页面,该页面必定已经在内存中,并且在页面不需要的时候将其移出。
第 2 层处理进程与操作员控制台(即用户)之间的通信。第 3 层管理 I/O 设备和相关的信息流缓冲区。第 4 层是用户程序层,用户程序不用考虑进程、内存、控制台或 I/O 设备管理等细节。系统操作员在第 5 层。
微内核
在分层方式中,设计者要确定在哪里划分 内核-用户
的边界。传统上,所有的层都在内核中,但是这样做没有必要。事实上,尽可能减少内核态中功能可能是更好的做法。因为内核中的错误很难处理,一旦内核态中出错误会拖累整个系统。
所以,为了实现高可靠性,将操作系统划分成小的、层级之间能够更好定义的模块是很有必要的,只有一个模块 — 微内核 — 运行在内核态,其余模块可以作为普通用户进程运行。由于把每个设备驱动和文件系统分别作为普通用户进程,这些模块中的错误虽然会使这些模块崩溃,但是不会使整个系统死机。
MINIX 3
是微内核的代表作,它的具体结构如下
在内核的外部,系统的构造有三层,它们都在用户态下运行,最底层是设备驱动器。由于它们都在用户态下运行,所以不能物理的访问 I/O 端口空间,也不能直接发出 I/O 命令。相反,为了能够对 I/O 设备编程,驱动器构建一个结构,指明哪个参数值写到哪个 I/O 端口,并声称一个内核调用,这样就完成了一次调用过程。
位于用户态的驱动程序上面是服务器
层,包含有服务器,它们完成操作系统的多数工作。由一个或多个文件服务器管理着文件系统,进程管理器创建、销毁和管理进程。服务器中有一个特殊的服务器称为 再生服务器(reincarnation server)
,它的任务就是检查服务器和驱动程序的功能是否正确,一旦检查出来错误,它就会补上去,无需用户干预。这种方式使得系统具有可恢复性,并具有较高的可靠性。
微内核中的内核还具有一种 机制
与 策略
分离的思想。比如系统调度,一个比较简单的调度算法是,对每个进程赋予一个优先级,并让内核执行具有最高优先级的进程。这里,内核机制就是寻找最高的优先级进程并运行。而策略(赋予进程优先级)可以在用户态中的进程完成。在这种模式中,策略和机制是分离的,从而使内核变得更小。
客户-服务器模式
微内核思想的策略是把进程划分为两类:服务器
,每个服务器用来提供服务;客户端
,使用这些服务。这个模式就是所谓的 客户-服务器
模式。
客户-服务器模式会有两种载体,一种情况是一台计算机既是客户又是服务器,在这种方式下,操作系统会有某种优化;但是普遍情况下是客户端和服务器在不同的机器上,它们通过局域网或广域网连接。
客户通过发送消息与服务器通信,客户端并不需要知道这些消息是在本地机器上处理,还是通过网络被送到远程机器上处理。对于客户端而言,这两种情形是一样的:都是发送请求并得到回应。
越来越多的系统,包括家里的 PC,都成为客户端,而在某地运行的大型机器则成为服务器。许多 web 就是以这种方式运行的。一台 PC 向某个服务器请求一个 Web 页面,服务器把 Web 页面返回给客户端,这就是典型的客服-服务器模式
文章参考:
《现代操作系统》第四版
https://baike.baidu.com/item/操作系统/192?fr=aladdin
《Modern Operating System》forth edition
http://faculty.cs.niu.edu/~hutchins/csci360/hchnotes/psw.htm
https://www.computerhope.com/jargon/c/clockcyc.htm
《B站-操作系统》
https://www.bilibili.com/video/av9555596?from=search&seid=8107077283516919308
https://en.wikipedia.org/wiki/System_call
http://c.biancheng.net/cpp/html/238.html
http://www.dossier-andreas.net/software_architecture/layers.html
操作系统之进程和线程
我们平常说的进程和线程更多的是基于编程语言的角度来说的,那么你真的了解什么是线程和进程吗?那么我们就从操作系统的角度来了解一下什么是进程和线程。
进程
操作系统中最核心的概念就是 进程
,进程是对正在运行中的程序的一个抽象。操作系统的其他所有内容都是围绕着进程展开的。进程是操作系统提供的最古老也是最重要的概念之一。即使可以使用的 CPU 只有一个,它们也支持(伪)并发
操作。它们会将一个单独的 CPU 抽象为多个虚拟机的 CPU。可以说:没有进程的抽象,现代操作系统将不复存在。
所有现代的计算机会在同一时刻做很多事情,过去使用计算机的人(单 CPU)可能完全无法理解现在这种变化,举个例子更能说明这一点:首先考虑一个 Web 服务器,请求都来自于 Web 网页。当一个请求到达时,服务器会检查当前页是否在缓存中,如果是在缓存中,就直接把缓存中的内容返回。如果缓存中没有的话,那么请求就会交给磁盘来处理。但是,从 CPU 的角度来看,磁盘请求需要更长的时间,因为磁盘请求会很慢。当硬盘请求完成时,更多其他请求才会进入。如果有多个磁盘的话,可以在第一个请求完成前就可以连续的对其他磁盘发出部分或全部请求。很显然,这是一种并发现象,需要有并发控制条件来控制并发现象。
现在考虑只有一个用户的 PC。当系统启动时,许多进程也在后台启动,用户通常不知道这些进程的启动,试想一下,当你自己的计算机启动的时候,你能知道哪些进程是需要启动的么?这些后台进程可能是一个需要输入电子邮件的电子邮件进程,或者是一个计算机病毒查杀进程来周期性的更新病毒库。某个用户进程可能会在所有用户上网的时候打印文件以及刻录 CD-ROM,这些活动都需要管理。于是一个支持多进程的多道程序系统就会显得很有必要了。
在许多多道程序系统中,CPU 会在进程
间快速切换,使每个程序运行几十或者几百毫秒。然而,严格意义来说,在某一个瞬间,CPU 只能运行一个进程,然而我们如果把时间定位为 1 秒内的话,它可能运行多个进程。这样就会让我们产生并行
的错觉。有时候人们说的 伪并行(pseudoparallelism)
就是这种情况,以此来区分多处理器系统(该系统由两个或多个 CPU 来共享同一个物理内存)
再来详细解释一下伪并行:
伪并行
是指单核或多核处理器同时执行多个进程,从而使程序更快。 通过以非常有限的时间间隔在程序之间快速切换CPU,因此会产生并行感。 缺点是 CPU 时间可能分配给下一个进程,也可能不分配给下一个进程。
因为 CPU 执行速度很快,进程间的换进换出也非常迅速,因此我们很难对多个并行进程进行跟踪,所以,在经过多年的努力后,操作系统的设计者开发了用于描述并行的一种概念模型(顺序进程),使得并行更加容易理解和分析,对该模型的探讨,也是本篇文章的主题。下面我们就来探讨一下进程模型
进程模型
在进程模型中,所有计算机上运行的软件,通常也包括操作系统,被组织为若干顺序进程(sequential processes)
,简称为 进程(process)
。一个进程就是一个正在执行的程序的实例,进程也包括程序计数器、寄存器和变量的当前值。从概念上来说,每个进程都有各自的虚拟 CPU,但是实际情况是 CPU 会在各个进程之间进行来回切换。
如上图所示,这是一个具有 4 个程序的多道处理程序,在进程不断切换的过程中,程序计数器也在不同的变化。
在上图中,这 4 道程序被抽象为 4 个拥有各自控制流程(即每个自己的程序计数器)的进程,并且每个程序都独立的运行。当然,实际上只有一个物理程序计数器,每个程序要运行时,其逻辑程序计数器会装载到物理程序计数器中。当程序运行结束后,其物理程序计数器就会是真正的程序计数器,然后再把它放回进程的逻辑计数器中。
从下图我们可以看到,在观察足够长的一段时间后,所有的进程都运行了,但在任何一个给定的瞬间仅有一个进程真正运行。
因此,当我们说一个 CPU 只能真正一次运行一个进程的时候,即使有 2 个核(或 CPU),每一个核也只能一次运行一个线程。
由于 CPU 会在各个进程之间来回快速切换,所以每个进程在 CPU 中的运行时间是无法确定的。并且当同一个进程再次在 CPU 中运行时,其在 CPU 内部的运行时间往往也是不固定的。进程和程序之间的区别是非常微妙的,但是通过一个例子可以让你加以区分:想想一位会做饭的计算机科学家正在为他的女儿制作生日蛋糕。他有做生日蛋糕的食谱,厨房里有所需的原谅:面粉、鸡蛋、糖、香草汁等。在这个比喻中,做蛋糕的食谱就是程序、计算机科学家就是 CPU、而做蛋糕的各种原谅都是输入数据。进程就是科学家阅读食谱、取来各种原料以及烘焙蛋糕等一系例了动作的总和。
现在假设科学家的儿子跑过来告诉他,说他的头被蜜蜂蜇了一下,那么此时科学家会记录出来他做蛋糕这个过程到了哪一步,然后拿出急救手册,按照上面的步骤给他儿子实施救助。这里,会涉及到进程之间的切换,科学家(CPU)会从做蛋糕(进程)切换到实施医疗救助(另一个进程)。等待伤口处理完毕后,科学家会回到刚刚记录做蛋糕的那一步,继续制作。
这里的关键思想是认识到一个进程所需的条件
,进程是某一类特定活动的总和,它有程序、输入输出以及状态。单个处理器可以被若干进程共享,它使用某种调度算法决定何时停止一个进程的工作,并转而为另外一个进程提供服务。另外需要注意的是,如果一个进程运行了两遍,则被认为是两个进程。那么我们了解到进程模型后,那么进程是如何创建的呢?
进程的创建
操作系统需要一些方式来创建进程。下面是一些创建进程的方式
- 系统初始化(init)
- 正在运行的程序执行了创建进程的系统调用(比如 fork)
- 用户请求创建一个新进程
- 初始化一个批处理工作
系统初始化
启动操作系统时,通常会创建若干个进程。其中有些是前台进程(numerous processes)
,也就是同用户进行交互并替他们完成工作的进程。一些运行在后台,并不与特定的用户进行交互,例如,设计一个进程来接收发来的电子邮件,这个进程大部分的时间都在休眠,但是只要邮件到来后这个进程就会被唤醒。还可以设计一个进程来接收对该计算机上网页的传入请求,在请求到达的进程唤醒来处理网页的传入请求。进程运行在后台用来处理一些活动像是 e-mail,web 网页,新闻,打印等等被称为 守护进程(daemons)
。大型系统会有很多守护进程。在 UNIX 中,ps
程序可以列出正在运行的进程, 在 Windows 中,可以使用任务管理器。
系统调用创建
除了在启动阶段创建进程之外,一些新的进程也可以在后面创建。通常,一个正在运行的进程会发出系统调用
用来创建一个或多个新进程来帮助其完成工作。例如,如果有大量的数据需要经过网络调取并进行顺序处理,那么创建一个进程读数据,并把数据放到共享缓冲区中,而让第二个进程取走并正确处理会比较容易些。在多处理器中,让每个进程运行在不同的 CPU 上也可以使工作做的更快。
用户请求创建
在许多交互式系统中,输入一个命令或者双击图标就可以启动程序,以上任意一种操作都可以选择开启一个新的进程,在基本的 UNIX 系统中运行 X,新进程将接管启动它的窗口。在 Windows 中启动进程时,它一般没有窗口,但是它可以创建一个或多个窗口。每个窗口都可以运行进程。通过鼠标或者命令就可以切换窗口并与进程进行交互。
交互式系统是以人与计算机之间大量交互为特征的计算机系统,比如游戏、web浏览器,IDE 等集成开发环境。
批处理创建
最后一种创建进程的情形会在大型机的批处理系统
中应用。用户在这种系统中提交批处理作业。当操作系统决定它有资源来运行另一个任务时,它将创建一个新进程并从其中的输入队列中运行下一个作业。
从技术上讲,在所有这些情况下,让现有流程执行流程是通过创建系统调用来创建新流程的。该进程可能是正在运行的用户进程,是从键盘或鼠标调用的系统进程或批处理程序。这些就是系统调用创建新进程的过程。该系统调用告诉操作系统创建一个新进程,并直接或间接指示在其中运行哪个程序。
在 UNIX 中,仅有一个系统调用来创建一个新的进程,这个系统调用就是 fork
。这个调用会创建一个与调用进程相关的副本。在 fork 后,一个父进程和子进程会有相同的内存映像,相同的环境字符串和相同的打开文件。通常,子进程会执行 execve
或者一个简单的系统调用来改变内存映像并运行一个新的程序。例如,当一个用户在 shell 中输出 sort 命令时,shell 会 fork 一个子进程然后子进程去执行 sort 命令。这两步过程的原因是允许子进程在 fork 之后但在 execve 之前操作其文件描述符,以完成标准输入,标准输出和标准错误的重定向。
在 Windows 中,情况正相反,一个简单的 Win32 功能调用 CreateProcess
,会处理流程创建并将正确的程序加载到新的进程中。这个调用会有 10 个参数,包括了需要执行的程序、输入给程序的命令行参数、各种安全属性、有关打开的文件是否继承控制位、优先级信息、进程所需要创建的窗口规格以及指向一个结构的指针,在该结构中新创建进程的信息被返回给调用者。除了 CreateProcess
Win 32 中大概有 100 个其他的函数用于处理进程的管理,同步以及相关的事务。下面是 UNIX 操作系统和 Windows 操作系统系统调用的对比
UNIX | Win32 | 说明 |
---|---|---|
fork | CreateProcess | 创建一个新进程 |
waitpid | WaitForSingleObject | 等待一个进程退出 |
execve | none | CraeteProcess = fork + servvice |
exit | ExitProcess | 终止执行 |
open | CreateFile | 创建一个文件或打开一个已有的文件 |
close | CloseHandle | 关闭文件 |
read | ReadFile | 从单个文件中读取数据 |
write | WriteFile | 向单个文件写数据 |
lseek | SetFilePointer | 移动文件指针 |
stat | GetFileAttributesEx | 获得不同的文件属性 |
mkdir | CreateDirectory | 创建一个新的目录 |
rmdir | RemoveDirectory | 移除一个空的目录 |
link | none | Win32 不支持 link |
unlink | DeleteFile | 销毁一个已有的文件 |
mount | none | Win32 不支持 mount |
umount | none | Win32 不支持 mount,所以也不支持mount |
chdir | SetCurrentDirectory | 切换当前工作目录 |
chmod | none | Win32 不支持安全 |
kill | none | Win32 不支持信号 |
time | GetLocalTime | 获取当前时间 |
在 UNIX 和 Windows 中,进程创建之后,父进程和子进程有各自不同的地址空间。如果其中某个进程在其地址空间中修改了一个词,这个修改将对另一个进程不可见。在 UNIX 中,子进程的地址空间是父进程的一个拷贝,但是确是两个不同的地址空间;不可写的内存区域是共享的。某些 UNIX 实现是正是在两者之间共享,因为它不能被修改。或者,子进程共享父进程的所有内存,但是这种情况下内存通过 写时复制(copy-on-write)
共享,这意味着一旦两者之一想要修改部分内存,则这块内存首先被明确的复制,以确保修改发生在私有内存区域。再次强调,可写的内存是不能被共享的。但是,对于一个新创建的进程来说,确实有可能共享创建者的资源,比如可以共享打开的文件。在 Windows 中,从一开始父进程的地址空间和子进程的地址空间就是不同的。
进程的终止
进程在创建之后,它就开始运行并做完成任务。然而,没有什么事儿是永不停歇的,包括进程也一样。进程早晚会发生终止,但是通常是由于以下情况触发的
正常退出(自愿的)
错误退出(自愿的)
严重错误(非自愿的)
被其他进程杀死(非自愿的)
正常退出
多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉操作系统它完成了工作。这个调用在 UNIX 中是 exit
,在 Windows 中是 ExitProcess
。面向屏幕中的软件也支持自愿终止操作。字处理软件、Internet 浏览器和类似的程序中总有一个供用户点击的图标或菜单项,用来通知进程删除它锁打开的任何临时文件,然后终止。
错误退出
进程发生终止的第二个原因是发现严重错误,例如,如果用户执行如下命令
cc foo.c
为了能够编译 foo.c 但是该文件不存在,于是编译器就会发出声明并退出。在给出了错误参数时,面向屏幕的交互式进程通常并不会直接退出,因为这从用户的角度来说并不合理,用户需要知道发生了什么并想要进行重试,所以这时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要重试还是退出。
严重错误
进程终止的第三个原因是由进程引起的错误,通常是由于程序中的错误所导致的。例如,执行了一条非法指令,引用不存在的内存,或者除数是 0 等。在有些系统比如 UNIX 中,进程可以通知操作系统,它希望自行处理某种类型的错误,在这类错误中,进程会收到信号(中断),而不是在这类错误出现时直接终止进程。
被其他进程杀死
第四个终止进程的原因是,某个进程执行系统调用告诉操作系统杀死某个进程。在 UNIX 中,这个系统调用是 kill。在 Win32 中对应的函数是 TerminateProcess
(注意不是系统调用)。
进程的层次结构
在一些系统中,当一个进程创建了其他进程后,父进程和子进程就会以某种方式进行关联。子进程它自己就会创建更多进程,从而形成一个进程层次结构。
UNIX 进程体系
在 UNIX 中,进程和它的所有子进程以及子进程的子进程共同组成一个进程组。当用户从键盘中发出一个信号后,该信号被发送给当前与键盘相关的进程组中的所有成员(它们通常是在当前窗口创建的所有活动进程)。每个进程可以分别捕获该信号、忽略该信号或采取默认的动作,即被信号 kill 掉。
这里有另一个例子,可以用来说明层次的作用,考虑 UNIX
在启动时如何初始化自己。一个称为 init
的特殊进程出现在启动映像中 。当 init 进程开始运行时,它会读取一个文件,文件会告诉它有多少个终端。然后为每个终端创建一个新进程。这些进程等待用户登录。如果登录成功,该登录进程就执行一个 shell 来等待接收用户输入指令,这些命令可能会启动更多的进程,以此类推。因此,整个操作系统中所有的进程都隶属于一个单个以 init 为根的进程树。
Windows 进程体系
相反,Windows 中没有进程层次的概念,Windows 中所有进程都是平等的,唯一类似于层次结构的是在创建进程的时候,父进程得到一个特别的令牌(称为句柄),该句柄可以用来控制子进程。然而,这个令牌可能也会移交给别的操作系统,这样就不存在层次结构了。而在 UNIX 中,进程不能剥夺其子进程的 进程权
。(这样看来,还是 Windows 比较渣
)。
进程状态
尽管每个进程是一个独立的实体,有其自己的程序计数器和内部状态,但是,进程之间仍然需要相互帮助。例如,一个进程的结果可以作为另一个进程的输入,在 shell 命令中
cat chapter1 chapter2 chapter3 | grep tree
第一个进程是 cat
,将三个文件级联并输出。第二个进程是 grep
,它从输入中选择具有包含关键字 tree
的内容,根据这两个进程的相对速度(这取决于两个程序的相对复杂度和各自所分配到的 CPU 时间片),可能会发生下面这种情况,grep
准备就绪开始运行,但是输入进程还没有完成,于是必须阻塞 grep 进程,直到输入完毕。
当一个进程开始运行时,它可能会经历下面这几种状态
图中会涉及三种状态
运行态
,运行态指的就是进程实际占用 CPU 时间片运行时就绪态
,就绪态指的是可运行,但因为其他进程正在运行而处于就绪状态阻塞态
,除非某种外部事件发生,否则进程不能运行
逻辑上来说,运行态和就绪态是很相似的。这两种情况下都表示进程可运行
,但是第二种情况没有获得 CPU 时间分片。第三种状态与前两种状态不同的原因是这个进程不能运行,CPU 空闲时也不能运行。
三种状态会涉及四种状态间的切换,在操作系统发现进程不能继续执行时会发生状态1
的轮转,在某些系统中进程执行系统调用,例如 pause
,来获取一个阻塞的状态。在其他系统中包括 UNIX,当进程从管道或特殊文件(例如终端)中读取没有可用的输入时,该进程会被自动终止。
转换 2 和转换 3 都是由进程调度程序(操作系统的一部分)引起的,进程本身不知道调度程序的存在。转换 2 的出现说明进程调度器认定当前进程已经运行了足够长的时间,是时候让其他进程运行 CPU 时间片了。当所有其他进程都运行过后,这时候该是让第一个进程重新获得 CPU 时间片的时候了,就会发生转换 3。
程序调度指的是,决定哪个进程优先被运行和运行多久,这是很重要的一点。已经设计出许多算法来尝试平衡系统整体效率与各个流程之间的竞争需求。
当进程等待的一个外部事件发生时(如从外部输入一些数据后),则发生转换 4。如果此时没有其他进程在运行,则立刻触发转换 3,该进程便开始运行,否则该进程会处于就绪阶段,等待 CPU 空闲后再轮到它运行。
从上面的观点引入了下面的模型
操作系统最底层的就是调度程序,在它上面有许多进程。所有关于中断处理、启动进程和停止进程的具体细节都隐藏在调度程序中。事实上,调度程序只是一段非常小的程序。
进程的实现
操作系统为了执行进程间的切换,会维护着一张表格,这张表就是 进程表(process table)
。每个进程占用一个进程表项。该表项包含了进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其他在进程由运行态转换到就绪态或阻塞态时所必须保存的信息,从而保证该进程随后能再次启动,就像从未被中断过一样。
下面展示了一个典型系统中的关键字段
第一列内容与进程管理
有关,第二列内容与 存储管理
有关,第三列内容与文件管理
有关。
存储管理的 text segment 、 data segment、stack segment 更多了解见下面这篇文章
现在我们应该对进程表有个大致的了解了,就可以在对单个 CPU 上如何运行多个顺序进程的错觉做更多的解释。与每一 I/O 类相关联的是一个称作 中断向量(interrupt vector)
的位置(靠近内存底部的固定区域)。它包含中断服务程序的入口地址。假设当一个磁盘中断发生时,用户进程 3 正在运行,则中断硬件将程序计数器、程序状态字、有时还有一个或多个寄存器压入堆栈,计算机随即跳转到中断向量所指示的地址。这就是硬件所做的事情。然后软件就随即接管一切剩余的工作。
当中断结束后,操作系统会调用一个 C 程序来处理中断剩下的工作。在完成剩下的工作后,会使某些进程就绪,接着调用调度程序,决定随后运行哪个进程。然后将控制权转移给一段汇编语言代码,为当前的进程装入寄存器值以及内存映射并启动该进程运行,下面显示了中断处理和调度的过程。
-
硬件压入堆栈程序计数器等
-
硬件从中断向量装入新的程序计数器
-
汇编语言过程保存寄存器的值
-
汇编语言过程设置新的堆栈
-
C 中断服务器运行(典型的读和缓存写入)
-
调度器决定下面哪个程序先运行
-
C 过程返回至汇编代码
-
汇编语言过程开始运行新的当前进程
一个进程在执行过程中可能被中断数千次,但关键每次中断后,被中断的进程都返回到与中断发生前完全相同的状态。
线程
在传统的操作系统中,每个进程都有一个地址空间和一个控制线程。事实上,这是大部分进程的定义。不过,在许多情况下,经常存在同一地址空间中运行多个控制线程的情形,这些线程就像是分离的进程。下面我们就着重探讨一下什么是线程
线程的使用
或许这个疑问也是你的疑问,为什么要在进程的基础上再创建一个线程的概念,准确的说,这其实是进程模型和线程模型的讨论,回答这个问题,可能需要分三步来回答
- 多线程之间会共享同一块地址空间和所有可用数据的能力,这是进程所不具备的
- 线程要比进程
更轻量级
,由于线程更轻,所以它比进程更容易创建,也更容易撤销。在许多系统中,创建一个线程要比创建一个进程快 10 – 100 倍。 - 第三个原因可能是性能方面的探讨,如果多个线程都是 CPU 密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的 I/O 处理,拥有多个线程能在这些活动中彼此重叠进行,从而会加快应用程序的执行速度
多线程解决方案
现在考虑一个线程使用的例子:一个万维网服务器,对页面的请求发送给服务器,而所请求的页面发送回客户端。在多数 web 站点上,某些页面较其他页面相比有更多的访问。例如,索尼的主页比任何一个照相机详情介绍页面具有更多的访问,Web 服务器可以把获得大量访问的页面集合保存在内存中,避免到磁盘去调入这些页面,从而改善性能。这种页面的集合称为 高速缓存(cache)
,高速缓存也应用在许多场合中,比如说 CPU 缓存。
上面是一个 web 服务器的组织方式,一个叫做 调度线程(dispatcher thread)
的线程从网络中读入工作请求,在调度线程检查完请求后,它会选择一个空闲的(阻塞的)工作线程来处理请求,通常是将消息的指针写入到每个线程关联的特殊字中。然后调度线程会唤醒正在睡眠中的工作线程,把工作线程的状态从阻塞态变为就绪态。
当工作线程启动后,它会检查请求是否在 web 页面的高速缓存中存在,这个高速缓存是所有线程都可以访问的。如果高速缓存不存在这个 web 页面的话,它会调用一个 read
操作从磁盘中获取页面并且阻塞线程直到磁盘操作完成。当线程阻塞在硬盘操作的期间,为了完成更多的工作,调度线程可能挑选另一个线程运行,也可能把另一个当前就绪的工作线程投入运行。
这种模型允许将服务器编写为顺序线程的集合,在分派线程的程序中包含一个死循环,该循环用来获得工作请求并且把请求派给工作线程。每个工作线程的代码包含一个从调度线程接收的请求,并且检查 web 高速缓存中是否存在所需页面,如果有,直接把该页面返回给客户,接着工作线程阻塞,等待一个新请求的到达。如果没有,工作线程就从磁盘调入该页面,将该页面返回给客户机,然后工作线程阻塞,等待一个新请求。
下面是调度线程和工作线程的代码,这里假设 TRUE 为常数 1 ,buf 和 page 分别是保存工作请求和 Web 页面的相应结构。
**调度线程的大致逻辑
while(TRUE){
get_next_request(&buf);
handoff_work(&buf);
}
**工作线程的大致逻辑
while(TRUE){
wait_for_work(&buf);
look_for_page_in_cache(&buf,&page);
if(page_not_in_cache(&page)){
read_page_from_disk(&buf,&page);
}
return _page(&page);
}
单线程解决方案
现在考虑没有多线程的情况下,如何编写 Web 服务器。我们很容易的就想象为单个线程了,Web 服务器的主循环获取请求并检查请求,并争取在下一个请求之前完成工作。在等待磁盘操作时,服务器空转,并且不处理任何到来的其他请求。结果会导致每秒中只有很少的请求被处理,所以这个例子能够说明多线程提高了程序的并行性并提高了程序的性能。
状态机解决方案
到现在为止,我们已经有了两种解决方案,单线程解决方案和多线程解决方案,其实还有一种解决方案就是 状态机解决方案
,它的流程如下
如果目前只有一个非阻塞版本的 read 系统调用可以使用,那么当请求到达服务器时,这个唯一的 read 调用的线程会进行检查,如果能够从高速缓存中得到响应,那么直接返回,如果不能,则启动一个非阻塞的磁盘操作
服务器在表中记录当前请求的状态,然后进入并获取下一个事件,紧接着下一个事件可能就是一个新工作的请求或是磁盘对先前操作的回答。如果是新工作的请求,那么就开始处理请求。如果是磁盘的响应,就从表中取出对应的状态信息进行处理。对于非阻塞式磁盘 I/O 而言,这种响应一般都是信号中断响应。
每次服务器从某个请求工作的状态切换到另一个状态时,都必须显示的保存或者重新装入相应的计算状态。这里,每个计算都有一个被保存的状态,存在一个会发生且使得相关状态发生改变的事件集合,我们把这类设计称为有限状态机(finite-state machine)
,有限状态机杯广泛的应用在计算机科学中。
这三种解决方案各有各的特性,多线程使得顺序进程的思想得以保留下来,并且实现了并行性,但是顺序进程会阻塞系统调用;单线程服务器保留了阻塞系统的简易性,但是却放弃了性能。有限状态机的处理方法运用了非阻塞调用和中断,通过并行实现了高性能,但是给编程增加了困难。
模型 | 特性 |
---|---|
单线程 | 无并行性,性能较差,阻塞系统调用 |
多线程 | 有并行性,阻塞系统调用 |
有限状态机 | 并行性,非阻塞系统调用、中断 |
经典的线程模型
理解进程的另一个角度是,用某种方法把相关的资源集中在一起。进程有存放程序正文和数据以及其他资源的地址空间。这些资源包括打开的文件、子进程、即将发生的定时器、信号处理程序、账号信息等。把这些信息放在进程中会比较容易管理。
另一个概念是,进程中拥有一个执行的线程,通常简写为 线程(thread)
。线程会有程序计数器,用来记录接着要执行哪一条指令;线程还拥有寄存器,用来保存线程当前正在使用的变量;线程还会有堆栈,用来记录程序的执行路径。尽管线程必须在某个进程中执行,但是进程和线程完完全全是两个不同的概念,并且他们可以分开处理。进程用于把资源集中在一起,而线程则是 CPU 上调度执行的实体。
线程给进程模型增加了一项内容,即在同一个进程中,允许彼此之间有较大的独立性且互不干扰。在一个进程中并行运行多个线程类似于在一台计算机上运行多个进程。在多个线程中,各个线程共享同一地址空间和其他资源。在多个进程中,进程共享物理内存、磁盘、打印机和其他资源。因为线程会包含有一些进程的属性,所以线程被称为轻量的进程(lightweight processes)
。多线程(multithreading)
一词还用于描述在同一进程中多个线程的情况。
下图我们可以看到三个传统的进程,每个进程有自己的地址空间和单个控制线程。每个线程都在不同的地址空间中运行
下图中,我们可以看到有一个进程三个线程的情况。每个线程都在相同的地址空间中运行。
线程不像是进程那样具备较强的独立性。同一个进程中的所有线程都会有完全一样的地址空间,这意味着它们也共享同样的全局变量。由于每个线程都可以访问进程地址空间内每个内存地址,因此一个线程可以读取、写入甚至擦除另一个线程的堆栈。线程之间除了共享同一内存空间外,还具有如下不同的内容
上图左边的是同一个进程中每个线程共享
的内容,上图右边是每个线程
中的内容。也就是说左边的列表是进程的属性,右边的列表是线程的属性。
和进程一样,线程可以处于下面这几种状态:运行中、阻塞、就绪和终止(进程图中没有画)。正在运行的线程拥有 CPU 时间片并且状态是运行中。一个被阻塞的线程会等待某个释放它的事件。例如,当一个线程执行从键盘读入数据的系统调用时,该线程就被阻塞直到有输入为止。线程通常会被阻塞,直到它等待某个外部事件的发生或者有其他线程来释放它。线程之间的状态转换和进程之间的状态转换是一样的。
每个线程都会有自己的堆栈,如下图所示
线程系统调用
进程通常会从当前的某个单线程开始,然后这个线程通过调用一个库函数(比如 thread_create
)创建新的线程。线程创建的函数会要求指定新创建线程的名称。创建的线程通常都返回一个线程标识符,该标识符就是新线程的名字。
当一个线程完成工作后,可以通过调用一个函数(比如 thread_exit
)来退出。紧接着线程消失,状态变为终止,不能再进行调度。在某些线程的运行过程中,可以通过调用函数例如 thread_join
,表示一个线程可以等待另一个线程退出。这个过程阻塞调用线程直到等待特定的线程退出。在这种情况下,线程的创建和终止非常类似于进程的创建和终止。
另一个常见的线程是调用 thread_yield
,它允许线程自动放弃 CPU 从而让另一个线程运行。这样一个调用还是很重要的,因为不同于进程,线程是无法利用时钟中断强制让线程让出 CPU 的。
POSIX 线程
为了使编写可移植线程程序成为可能,IEEE 在 IEEE 标准 1003.1c 中定义了线程标准。线程包被定义为 Pthreads
。大部分的 UNIX 系统支持它。这个标准定义了 60 多种功能调用,一一列举不太现实,下面为你列举了一些常用的系统调用。
POSIX线程(通常称为pthreads)是一种独立于语言而存在的执行模型,以及并行执行模型。它允许程序控制时间上重叠的多个不同的工作流程。每个工作流程都称为一个线程,可以通过调用POSIX Threads API来实现对这些流程的创建和控制。可以把它理解为线程的标准。
POSIX Threads 的实现在许多类似且符合POSIX的操作系统上可用,例如 FreeBSD、NetBSD、OpenBSD、Linux、macOS、Android、Solaris,它在现有 Windows API 之上实现了pthread。
IEEE 是世界上最大的技术专业组织,致力于为人类的利益而发展技术。
线程调用 | 描述 |
---|---|
pthread_create | 创建一个新线程 |
pthread_exit | 结束调用的线程 |
pthread_join | 等待一个特定的线程退出 |
pthread_yield | 释放 CPU 来运行另外一个线程 |
pthread_attr_init | 创建并初始化一个线程的属性结构 |
pthread_attr_destory | 删除一个线程的属性结构 |
所有的 Pthreads 都有特定的属性,每一个都含有标识符、一组寄存器(包括程序计数器)和一组存储在结构中的属性。这个属性包括堆栈大小、调度参数以及其他线程需要的项目。
新的线程会通过 pthread_create
创建,新创建的线程的标识符会作为函数值返回。这个调用非常像是 UNIX 中的 fork
系统调用(除了参数之外),其中线程标识符起着 PID
的作用,这么做的目的是为了和其他线程进行区分。
当线程完成指派给他的工作后,会通过 pthread_exit
来终止。这个调用会停止线程并释放堆栈。
一般一个线程在继续运行前需要等待另一个线程完成它的工作并退出。可以通过 pthread_join
线程调用来等待别的特定线程的终止。而要等待线程的线程标识符作为一个参数给出。
有时会出现这种情况:一个线程逻辑上没有阻塞,但感觉上它已经运行了足够长的时间并且希望给另外一个线程机会去运行。这时候可以通过 pthread_yield
来完成。
下面两个线程调用是处理属性的。pthread_attr_init
建立关联一个线程的属性结构并初始化成默认值,这些值(例如优先级)可以通过修改属性结构的值来改变。
最后,pthread_attr_destroy
删除一个线程的结构,释放它占用的内存。它不会影响调用它的线程,这些线程会一直存在。
为了更好的理解 pthread 是如何工作的,考虑下面这个例子
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NUMBER_OF_THREADS 10
void *print_hello_world(vvoid *tid){
/* 输出线程的标识符,然后退出 */
printf("Hello World. Greetings from thread %d\n",tid);
pthread_exit(NULL);
}
int main(int argc,char *argv[]){
/* 主程序创建 10 个线程,然后退出 */
pthread_t threads[NUMBER_OF_THREADS];
int status,i;
for(int i = 0;i < NUMBER_OF_THREADS;i++){
printf("Main here. Creating thread %d\n",i);
status = pthread_create(&threads[i], NULL, print_hello_world, (void *)i);
if(status != 0){
printf("Oops. pthread_create returned error code %d\n",status);
exit(-1);
}
}
exit(NULL);
}
主线程在宣布它的指责之后,循环 NUMBER_OF_THREADS
次,每次创建一个新的线程。如果线程创建失败,会打印出一条信息后退出。在创建完成所有的工作后,主程序退出。
线程实现
主要有三种实现方式
- 在用户空间中实现线程;
- 在内核空间中实现线程;
- 在用户和内核空间中混合实现线程。
下面我们分开讨论一下
在用户空间中实现线程
第一种方法是把整个线程包放在用户空间中,内核对线程一无所知,它不知道线程的存在。所有的这类实现都有同样的通用结构
线程在运行时系统之上运行,运行时系统是管理线程过程的集合,包括前面提到的四个过程: pthread_create, pthread_exit, pthread_join 和 pthread_yield。
运行时系统(Runtime System)
也叫做运行时环境,该运行时系统提供了程序在其中运行的环境。此环境可能会解决许多问题,包括应用程序内存的布局,程序如何访问变量,在过程之间传递参数的机制,与操作系统的接口等等。编译器根据特定的运行时系统进行假设以生成正确的代码。通常,运行时系统将负责设置和管理堆栈,并且会包含诸如垃圾收集,线程或语言内置的其他动态的功能。
在用户空间管理线程时,每个进程需要有其专用的线程表(thread table)
,用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态。该线程标由运行时系统统一管理。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程的所有信息,与内核在进程表中存放的信息完全一样。
在用户空间实现线程的优势
在用户空间中实现线程要比在内核空间中实现线程具有这些方面的优势:考虑如果在线程完成时或者是在调用 pthread_yield
时,必要时会进程线程切换,然后线程的信息会被保存在运行时环境所提供的线程表中,然后,线程调度程序来选择另外一个需要运行的线程。保存线程的状态和调度程序都是本地过程
,所以启动他们比进行内核调用效率更高。因而不需要切换到内核,也就不需要上下文切换,也不需要对内存高速缓存进行刷新,因为线程调度非常便捷,因此效率比较高。
在用户空间实现线程还有一个优势就是它允许每个进程有自己定制的调度算法。例如在某些应用程序中,那些具有垃圾收集线程的应用程序(知道是谁了吧)就不用担心自己线程会不会在不合适的时候停止,这是一个优势。用户线程还具有较好的可扩展性,因为内核空间中的内核线程需要一些表空间和堆栈空间,如果内核线程数量比较大,容易造成问题。
在用户空间实现线程的劣势
尽管在用户空间实现线程会具有一定的性能优势,但是劣势还是很明显的,你如何实现阻塞系统调用
呢?假设在还没有任何键盘输入之前,一个线程读取键盘,让线程进行系统调用是不可能的,因为这会停止所有的线程。所以,使用线程的一个目标是能够让线程进行阻塞调用,并且要避免被阻塞的线程影响其他线程。
与阻塞调用类似的问题是缺页中断
问题,实际上,计算机并不会把所有的程序都一次性的放入内存中,如果某个程序发生函数调用或者跳转指令到了一条不在内存的指令上,就会发生页面故障,而操作系统将到磁盘上取回这个丢失的指令,这就称为缺页故障
。而在对所需的指令进行读入和执行时,相关的进程就会被阻塞。如果只有一个线程引起页面故障,内核由于甚至不知道有线程存在,通常会吧整个进程阻塞直到磁盘 I/O 完成为止,尽管其他的线程是可以运行的。
另外一个问题是,如果一个线程开始运行,该线程所在进程中的其他线程都不能运行,除非第一个线程自愿的放弃 CPU,在一个单进程内部,没有时钟中断,所以不可能使用轮转调度的方式调度线程。除非其他线程能够以自己的意愿进入运行时环境,否则调度程序没有可以调度线程的机会。
在内核中实现线程
现在我们考虑使用内核来实现线程的情况,此时不再需要运行时环境了。另外,每个进程中也没有线程表。相反,在内核中会有用来记录系统中所有线程的线程表。当某个线程希望创建一个新线程或撤销一个已有线程时,它会进行一个系统调用,这个系统调用通过对线程表的更新来完成线程创建或销毁工作。
内核中的线程表持有每个线程的寄存器、状态和其他信息。这些信息和用户空间中的线程信息相同,但是位置却被放在了内核中而不是用户空间中。另外,内核还维护了一张进程表用来跟踪系统状态。
所有能够阻塞的调用都会通过系统调用的方式来实现,当一个线程阻塞时,内核可以进行选择,是运行在同一个进程中的另一个线程(如果有就绪线程的话)还是运行一个另一个进程中的线程。但是在用户实现中,运行时系统始终运行自己的线程,直到内核剥夺它的 CPU 时间片(或者没有可运行的线程存在了)为止。
由于在内核中创建或者销毁线程的开销比较大,所以某些系统会采用可循环利用的方式来回收线程。当某个线程被销毁时,就把它标志为不可运行的状态,但是其内部结构没有受到影响。稍后,在必须创建一个新线程时,就会重新启用旧线程,把它标志为可用状态。
如果某个进程中的线程造成缺页故障后,内核很容易的就能检查出来是否有其他可运行的线程,如果有的话,在等待所需要的页面从磁盘读入时,就选择一个可运行的线程运行。这样做的缺点是系统调用的代价比较大,所以如果线程的操作(创建、终止)比较多,就会带来很大的开销。
混合实现
结合用户空间和内核空间的优点,设计人员采用了一种内核级线程
的方式,然后将用户级线程与某些或者全部内核线程多路复用起来
在这种模型中,编程人员可以自由控制用户线程和内核线程的数量,具有很大的灵活度。采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。
进程间通信
进程是需要频繁的和其他进程进行交流的。例如,在一个 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 都决定将一个文件排队打印,情况如下
墨菲法则(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)
。如果我们能够正确的操作,使两个不同进程不可能同时处于临界区,就能避免竞争条件,这也是从操作系统设计角度来进行的。
尽管上面这种设计避免了竞争条件,但是不能确保并发线程同时访问共享数据的正确性和高效性。一个好的解决方案,应该包含下面四种条件
- 任何时候两个进程不能同时处于临界区
- 不应对 CPU 的速度和数量做任何假设
- 位于临界区外的进程不得阻塞其他进程
- 不能使任何进程无限等待进入临界区
从抽象的角度来看,我们通常希望进程的行为如上图所示,在 t1 时刻,进程 A 进入临界区,在 t2 的时刻,进程 B 尝试进入临界区,因为此时进程 A 正在处于临界区中,所以进程 B 会阻塞直到 t3 时刻进程 A 离开临界区,此时进程 B 能够允许进入临界区。最后,在 t4 时刻,进程 B 离开临界区,系统恢复到没有进程的原始状态。
忙等互斥
下面我们会继续探讨实现互斥的各种设计,在这些方案中,当一个进程正忙于更新其关键区域的共享内存时,没有其他进程会进入其关键区域,也不会造成影响。
屏蔽中断
在单处理器系统上,最简单的解决方案是让每个进程在进入临界区后立即屏蔽所有中断
,并在离开临界区之前重新启用它们。屏蔽中断后,时钟中断也会被屏蔽。CPU 只有发生时钟中断或其他中断时才会进行进程切换。这样,在屏蔽中断后 CPU 不会切换到其他进程。所以,一旦某个进程屏蔽中断之后,它就可以检查和修改共享内存,而不用担心其他进程介入访问共享数据。
这个方案可行吗?进程进入临界区域是由谁决定的呢?不是用户进程吗?当进程进入临界区域后,用户进程关闭中断,如果经过一段较长时间后进程没有离开,那么中断不就一直启用不了,结果会如何?可能会造成整个系统的终止。而且如果是多处理器的话,屏蔽中断仅仅对执行 disable
指令的 CPU 有效。其他 CPU 仍将继续运行,并可以访问共享内存。
另一方面,对内核来说,当它在执行更新变量或列表的几条指令期间将中断屏蔽是很方便的。例如,如果多个进程处理就绪列表中的时候发生中断,则可能会发生竞态条件的出现。所以,屏蔽中断对于操作系统本身来说是一项很有用的技术,但是对于用户线程来说,屏蔽中断却不是一项通用的互斥机制。
锁变量
作为第二种尝试,可以寻找一种软件层面解决方案。考虑有单个共享的(锁)变量,初始为值为 0 。当一个线程想要进入关键区域时,它首先会查看锁的值是否为 0 ,如果锁的值是 0 ,进程会把它设置为 1 并让进程进入关键区域。如果锁的状态是 1,进程会等待直到锁变量的值变为 0 。因此,锁变量的值是 0 则意味着没有线程进入关键区域。如果是 1 则意味着有进程在关键区域内。我们对上图修改后,如下所示
这种设计方式是否正确呢?是否存在纰漏呢?假设一个进程读出锁变量的值并发现它为 0 ,而恰好在它将其设置为 1 之前,另一个进程调度运行,读出锁的变量为0 ,并将锁的变量设置为 1 。然后第一个线程运行,把锁变量的值再次设置为 1,此时,临界区域就会有两个进程在同时运行。
也许有的读者可以这么认为,在进入前检查一次,在要离开的关键区域再检查一次不就解决了吗?实际上这种情况也是于事无补,因为在第二次检查期间其他线程仍有可能修改锁变量的值,换句话说,这种 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;
/* 所有值初始化为 0 (FALSE) */
int interested[N];
/* 进程是 0 或 1 */
void enter_region(int process){
/* 另一个进程号 */
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:
| 复制锁到寄存器并将锁设为1
TSL REGISTER,LOCK
| 锁是 0 吗?
CMP REGISTER,#0
| 若不是零,说明锁已被设置,所以循环
JNE enter_region
| 返回调用者,进入临界区
RET
leave_region:
| 在锁中存入 0
MOVE LOCK,#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:
| 把 1 放在内存器中
MOVE REGISTER,#1
| 交换寄存器和锁变量的内容
XCHG REGISTER,LOCK
| 锁是 0 吗?
CMP REGISTER,#0
| 若不是 0 ,锁已被设置,进行循环
JNE enter_region
| 返回调用者,进入临界区
RET
leave_region:
| 在锁中存入 0
MOVE LOCK,#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 时间,最简单的是 sleep
和 wakeup
。Sleep 是一个能够造成调用者阻塞的系统调用,也就是说,这个系统调用会暂停直到其他进程唤醒它。wakeup 调用有一个参数,即要唤醒的进程。还有一种方式是 wakeup 和 sleep 都有一个参数,即 sleep 和 wakeup 需要匹配的内存地址。
生产者-消费者问题
作为这些私有原语的例子,让我们考虑生产者-消费者(producer-consumer)
问题,也称作 有界缓冲区(bounded-buffer)
问题。两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者(producer)
,将信息放入缓冲区, 另一个是消费者(consumer)
,会从缓冲区中取出。也可以把这个问题一般化为 m 个生产者和 n 个消费者的问题,但是我们这里只讨论一个生产者和一个消费者的情况,这样可以简化实现方案。
如果缓冲队列已满,那么当生产者仍想要将数据写入缓冲区的时候,会出现问题。它的解决办法是让生产者睡眠,也就是阻塞生产者。等到消费者从缓冲区中取出一个或多个数据项时再唤醒它。同样的,当消费者试图从缓冲区中取数据,但是发现缓冲区为空时,消费者也会睡眠,阻塞。直到生产者向其中放入一个新的数据。
这个逻辑听起来比较简单,而且这种方式也需要一种称作 监听
的变量,这个变量用于监视缓冲区的数据,我们暂定为 count,如果缓冲区最多存放 N 个数据项,生产者会每次判断 count 是否达到 N,否则生产者向缓冲区放入一个数据项并增量 count 的值。消费者的逻辑也很相似:首先测试 count 的值是否为 0 ,如果为 0 则消费者睡眠、阻塞,否则会从缓冲区取出数据并使 count 数量递减。每个进程也会检查检查是否其他线程是否应该被唤醒,如果应该被唤醒,那么就唤醒该线程。下面是生产者消费者的代码
/* 缓冲区 slot 槽的数量 */
#define N 100
/* 缓冲区数据的数量 */
int count = 0
// 生产者
void producer(void){
int item;
/* 无限循环 */
while(TRUE){
/* 生成下一项数据 */
item = produce_item()
/* 如果缓存区是满的,就会阻塞 */
if(count == N){
sleep();
}
/* 把当前数据放在缓冲区中 */
insert_item(item);
/* 增加缓冲区 count 的数量 */
count = count + 1;
if(count == 1){
/* 缓冲区是否为空? */
wakeup(consumer);
}
}
}
// 消费者
void consumer(void){
int item;
/* 无限循环 */
while(TRUE){
/* 如果缓冲区是空的,就会进行阻塞 */
if(count == 0){
sleep();
}
/* 从缓冲区中取出一个数据 */
item = remove_item();
/* 将缓冲区的 count 数量减一 */
count = count - 1
/* 缓冲区满嘛? */
if(count == N - 1){
wakeup(producer);
}
/* 打印数据项 */
consumer_item(item);
}
}
为了在 C 语言中描述像是 sleep
和 wakeup
的系统调用,我们将以库函数调用的形式来表示。它们不是 C 标准库的一部分,但可以在实际具有这些系统调用的任何系统上使用。代码中未实现的 insert_item
和 remove_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 提出了信号量有两个操作,现在通常使用 down
和 up
(分别可以用 sleep 和 wakeup 来表示)。down 这个指令的操作会检查值是否大于 0 。如果大于 0 ,则将其值减 1 ;若该值为 0 ,则进程将睡眠,而且此时 down 操作将会继续执行。检查数值、修改变量值以及可能发生的睡眠操作均为一个单一的、不可分割的 原子操作(atomic action)
完成。这会保证一旦信号量操作开始,没有其他的进程能够访问信号量,直到操作完成或者阻塞。这种原子性对于解决同步问题和避免竞争绝对必不可少。
原子性操作指的是在计算机科学的许多其他领域中,一组相关操作全部执行而没有中断或根本不执行。
up 操作会使信号量的值 + 1。如果一个或者多个进程在信号量上睡眠,无法完成一个先前的 down 操作,则由系统选择其中一个并允许该程完成 down 操作。因此,对一个进程在其上睡眠的信号量执行一次 up 操作之后,该信号量的值仍然是 0 ,但在其上睡眠的进程却少了一个。信号量的值增 1 和唤醒一个进程同样也是不可分割的。不会有某个进程因执行 up 而阻塞,正如在前面的模型中不会有进程因执行 wakeup 而阻塞是一样的道理。
用信号量解决生产者 – 消费者问题
用信号量解决丢失的 wakeup 问题,代码如下
/* 定义缓冲区槽的数量 */
#define N 100
/* 信号量是一种特殊的 int */
typedef int semaphore;
/* 控制关键区域的访问 */
semaphore mutex = 1;
/* 统计 buffer 空槽的数量 */
semaphore empty = N;
/* 统计 buffer 满槽的数量 */
semaphore full = 0;
void producer(void){
int item;
/* TRUE 的常量是 1 */
while(TRUE){
/* 产生放在缓冲区的一些数据 */
item = producer_item();
/* 将空槽数量减 1 */
down(&empty);
/* 进入关键区域 */
down(&mutex);
/* 把数据放入缓冲区中 */
insert_item(item);
/* 离开临界区 */
up(&mutex);
/* 将 buffer 满槽数量 + 1 */
up(&full);
}
}
void consumer(void){
int item;
/* 无限循环 */
while(TRUE){
/* 缓存区满槽数量 - 1 */
down(&full);
/* 进入缓冲区 */
down(&mutex);
/* 从缓冲区取出数据 */
item = remove_item();
/* 离开临界区 */
up(&mutex);
/* 将空槽数目 + 1 */
up(&empty);
/* 处理数据 */
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 操作,则可以确保相互互斥。
现在我们有了一个好的进程间原语的保证。然后我们再来看一下中断的顺序保证
-
硬件压入堆栈程序计数器等
-
硬件从中断向量装入新的程序计数器
-
汇编语言过程保存寄存器的值
-
汇编语言过程设置新的堆栈
-
C 中断服务器运行(典型的读和缓存写入)
-
调度器决定下面哪个程序先运行
-
C 过程返回至汇编代码
-
汇编语言过程开始运行新的当前进程
在使用信号量
的系统中,隐藏中断的自然方法是让每个 I/O 设备都配备一个信号量,该信号量最初设置为0。在 I/O 设备启动后,中断处理程序立刻对相关联的信号执行一个 down
操作,于是进程立即被阻塞。当中断进入时,中断处理程序随后对相关的信号量执行一个 up
操作,能够使已经阻止的进程恢复运行。在上面的中断处理步骤中,其中的第 5 步 C 中断服务器运行
就是中断处理程序在信号量上执行的一个 up 操作,所以在第 6 步中,操作系统能够执行设备驱动程序。当然,如果有几个进程已经处于就绪状态,调度程序可能会选择接下来运行一个更重要的进程,我们会在后面讨论调度的算法。
上面的代码实际上是通过两种不同的方式来使用信号量的,而这两种信号量之间的区别也是很重要的。mutex
信号量用于互斥。它用于确保任意时刻只有一个进程能够对缓冲区和相关变量进行读写。互斥是用于避免进程混乱所必须的一种操作。
另外一个信号量是关于同步(synchronization)
的。full
和 empty
信号量用于确保事件的发生或者不发生。在这个事例中,它们确保了缓冲区满时生产者停止运行;缓冲区为空时消费者停止运行。这两个信号量的使用与 mutex 不同。
互斥量
如果不需要信号量的计数能力时,可以使用信号量的一个简单版本,称为 mutex(互斥量)
。互斥量的优势就在于在一些共享资源和一段代码中保持互斥。由于互斥的实现既简单又有效,这使得互斥量在实现用户空间线程包时非常有用。
互斥量是一个处于两种状态之一的共享变量:解锁(unlocked)
和 加锁(locked)
。这样,只需要一个二进制位来表示它,不过一般情况下,通常会用一个 整形(integer)
来表示。0 表示解锁,其他所有的值表示加锁,比 1 大的值表示加锁的次数。
mutex 使用两个过程,当一个线程(或者进程)需要访问关键区域时,会调用 mutex_lock
进行加锁。如果互斥锁当前处于解锁状态(表示关键区域可用),则调用成功,并且调用线程可以自由进入关键区域。
另一方面,如果 mutex 互斥量已经锁定的话,调用线程会阻塞直到关键区域内的线程执行完毕并且调用了 mutex_unlock
。如果多个线程在 mutex 互斥量上阻塞,将随机选择一个线程并允许它获得锁。
由于 mutex 互斥量非常简单,所以只要有 TSL 或者是 XCHG 指令,就可以很容易地在用户空间实现它们。用于用户级线程包的 mutex_lock
和 mutex_unlock
代码如下,XCHG 的本质也一样。
mutex_lock:
| 将互斥信号量复制到寄存器,并将互斥信号量置为1
TSL REGISTER,MUTEX
| 互斥信号量是 0 吗?
CMP REGISTER,#0
| 如果互斥信号量为0,它被解锁,所以返回
JZE ok
| 互斥信号正在使用;调度其他线程
CALL thread_yield
| 再试一次
JMP mutex_lock
| 返回调用者,进入临界区
ok: RET
mutex_unlcok:
| 将 mutex 置为 0
MOVE MUTEX,#0
| 返回调用者
RET
mutex_lock 的代码和上面 enter_region 的代码很相似,我们可以对比着看一下
上面代码最大的区别你看出来了吗?
-
根据上面我们对 TSL 的分析,我们知道,如果 TSL 判断没有进入临界区的进程会进行无限循环获取锁,而在 TSL 的处理中,如果 mutex 正在使用,那么就调度其他线程进行处理。所以上面最大的区别其实就是在判断 mutex/TSL 之后的处理。
-
在(用户)线程中,情况有所不同,因为没有时钟来停止运行时间过长的线程。结果是通过忙等待的方式来试图获得锁的线程将永远循环下去,决不会得到锁,因为这个运行的线程不会让其他线程运行从而释放锁,其他线程根本没有获得锁的机会。在后者获取锁失败时,它会调用
thread_yield
将 CPU 放弃给另外一个线程。结果就不会进行忙等待。在该线程下次运行时,它再一次对锁进行测试。
上面就是 enter_region 和 mutex_lock 的差别所在。由于 thread_yield 仅仅是一个用户空间的线程调度,所以它的运行非常快捷。这样,mutex_lock
和 mutex_unlock
都不需要任何内核调用。通过使用这些过程,用户线程完全可以实现在用户空间中的同步,这个过程仅仅需要少量的同步。
我们上面描述的互斥量其实是一套调用框架中的指令。从软件角度来说,总是需要更多的特性和同步原语。例如,有时线程包提供一个调用 mutex_trylock
,这个调用尝试获取锁或者返回错误码,但是不会进行加锁操作。这就给了调用线程一个灵活性,以决定下一步做什么,是使用替代方法还是等候下去。
Futexes
随着并行的增加,有效的同步(synchronization)
和锁定(locking)
对于性能来说是非常重要的。如果进程等待时间很短,那么自旋锁(Spin lock)
是非常有效;但是如果等待时间比较长,那么这会浪费 CPU 周期。如果进程很多,那么阻塞此进程,并仅当锁被释放的时候让内核解除阻塞是更有效的方式。不幸的是,这种方式也会导致另外的问题:它可以在进程竞争频繁的时候运行良好,但是在竞争不是很激烈的情况下内核切换的消耗会非常大,而且更困难的是,预测锁的竞争数量更不容易。
有一种有趣的解决方案是把两者的优点结合起来,提出一种新的思想,称为 futex
,或者是 快速用户空间互斥(fast user space mutex)
,是不是听起来很有意思?
futex 是 Linux
中的特性实现了基本的锁定(很像是互斥锁)而且避免了陷入内核中,因为内核的切换的开销非常大,这样做可以大大提高性能。futex 由两部分组成:内核服务和用户库。内核服务提供了了一个 等待队列(wait queue)
允许多个进程在锁上排队等待。除非内核明确的对他们解除阻塞,否则它们不会运行。
对于一个进程来说,把它放到等待队列需要昂贵的系统调用,这种方式应该被避免。在没有竞争的情况下,futex 可以直接在用户空间中工作。这些进程共享一个 32 位整数(integer)
作为公共锁变量。假设锁的初始化为 1,我们认为这时锁已经被释放了。线程通过执行原子性的操作减少并测试(decrement and test)
来抢占锁。decrement and set 是 Linux 中的原子功能,由包裹在 C 函数中的内联汇编组成,并在头文件中进行定义。下一步,线程会检查结果来查看锁是否已经被释放。如果锁现在不是锁定状态,那么刚好我们的线程可以成功抢占该锁。然而,如果锁被其他线程持有,抢占锁的线程不得不等待。在这种情况下,futex 库不会自旋
,但是会使用一个系统调用来把线程放在内核中的等待队列中。这样一来,切换到内核的开销已经是合情合理的了,因为线程可以在任何时候阻塞。当线程完成了锁的工作时,它会使用原子性的 增加并测试(increment and test)
释放锁,并检查结果以查看内核等待队列上是否仍阻止任何进程。如果有的话,它会通知内核可以对等待队列中的一个或多个进程解除阻塞。如果没有锁竞争,内核则不需要参与竞争。
Pthreads 中的互斥量
Pthreads 提供了一些功能用来同步线程。最基本的机制是使用互斥量变量,可以锁定和解锁,用来保护每个关键区域。希望进入关键区域的线程首先要尝试获取 mutex。如果 mutex 没有加锁,线程能够马上进入并且互斥量能够自动锁定,从而阻止其他线程进入。如果 mutex 已经加锁,调用线程会阻塞,直到 mutex 解锁。如果多个线程在相同的互斥量上等待,当互斥量解锁时,只有一个线程能够进入并且重新加锁。这些锁并不是必须的,程序员需要正确使用它们。
下面是与互斥量有关的函数调用
向我们想象中的一样,mutex 能够被创建和销毁,扮演这两个角色的分别是 Phread_mutex_init
和 Pthread_mutex_destroy
。mutex 也可以通过 Pthread_mutex_lock
来进行加锁,如果互斥量已经加锁,则会阻塞调用者。还有一个调用Pthread_mutex_trylock
用来尝试对线程加锁,当 mutex 已经被加锁时,会返回一个错误代码而不是阻塞调用者。这个调用允许线程有效的进行忙等。最后,Pthread_mutex_unlock
会对 mutex 解锁并且释放一个正在等待的线程。
除了互斥量以外,Pthreads
还提供了第二种同步机制: 条件变量(condition variables)
。mutex 可以很好的允许或阻止对关键区域的访问。条件变量允许线程由于未满足某些条件而阻塞。绝大多数情况下这两种方法是一起使用的。下面我们进一步来研究线程、互斥量、条件变量之间的关联。
下面再来重新认识一下生产者和消费者问题:一个线程将东西放在一个缓冲区内,由另一个线程将它们取出。如果生产者发现缓冲区没有空槽可以使用了,生产者线程会阻塞起来直到有一个线程可以使用。生产者使用 mutex 来进行原子性检查从而不受其他线程干扰。但是当发现缓冲区已经满了以后,生产者需要一种方法来阻塞自己并在以后被唤醒。这便是条件变量做的工作。
下面是一些与条件变量有关的最重要的 pthread 调用
上表中给出了一些调用用来创建和销毁条件变量。条件变量上的主要属性是 Pthread_cond_wait
和 Pthread_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++){
/* 缓冲区独占访问,也就是使用 mutex 获取锁 */
pthread_mutex_lock(&the_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++){
/* 缓冲区独占访问,也就是使用 mutex 获取锁 */
pthread_mutex_lock(&the_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)
以及相关的两个操作 wait
和 signal
。当一个管程程序发现它不能运行时(例如,生产者发现缓冲区已满),它会在某个条件变量(如 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();
// run 包含了线程代码
static class Producer extends Thread{
public void run(){
int item;
// 生产者循环
while(true){
item = produce_item();
mon.insert(item);
}
}
// 生产代码
private int produce_item(){...}
}
// run 包含了线程代码
static class consumer extends Thread {
public void 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;
// 缓冲区中的数目自增 1
count = count + 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;
// 缓冲区中的数据项数目减 1
count = count - 1;
if(count = N - 1){
// 如果生产者睡眠,唤醒它
notify();
}
return val;
}
private void go_to_sleep() {
try{
wait( );
}catch(Interr uptedExceptionexc) {};
}
}
}
上面的代码中主要设计四个类,外部类(outer class)
ProducerConsumer 创建并启动两个线程,p 和 c。第二个类和第三个类 Producer
和 Consumer
分别包含生产者和消费者代码。最后,Our_monitor
是管程,它有两个同步线程,用于在共享缓冲区中插入和取出数据。
在前面的所有例子中,生产者和消费者线程在功能上与它们是相同的。生产者有一个无限循环,该无限循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环,该无限循环用于从缓冲区取出数据并完成一系列工作。
程序中比较耐人寻味的就是 Our_monitor
了,它包含缓冲区、管理变量以及两个同步方法。当生产者在 insert 内活动时,它保证消费者不能在 remove 方法中运行,从而保证更新变量以及缓冲区的安全性,并且不用担心竞争条件。变量 count 记录在缓冲区中数据的数量。变量 lo
是缓冲区槽的序号,指出将要取出的下一个数据项。类似地,hi
是缓冲区中下一个要放入的数据项序号。允许 lo = hi,含义是在缓冲区中有 0 个或 N 个数据。
Java 中的同步方法与其他经典管程有本质差别:Java 没有内嵌的条件变量。然而,Java 提供了 wait 和 notify 分别与 sleep 和 wakeup 等价。
通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性。但是管程也有缺点,我们前面说到过管程是一个编程语言的概念,编译器必须要识别管程并用某种方式对其互斥作出保证。C、Pascal 以及大多数其他编程语言都没有管程,所以不能依靠编译器来遵守互斥规则。
与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问共享内存的一个或多个 CPU 上的互斥问题的。通过将信号量放在共享内存中并用 TSL
或 XCHG
指令来保护它们,可以避免竞争。但是如果是在分布式系统中,可能同时具有多个 CPU 的情况,并且每个 CPU 都有自己的私有内存呢,它们通过网络相连,那么这些原语将会失效。因为信号量太低级了,而管程在少数几种编程语言之外无法使用,所以还需要其他方法。
消息传递
上面提到的其他方法就是 消息传递(messaage passing)
。这种进程间通信的方法使用两个原语 send
和 receive
,它们像信号量而不像管程,是系统调用而不是语言级别。示例如下
send(destination, &message);
receive(source, &message);
send 方法用于向一个给定的目标发送一条消息,receive 从一个给定的源接受一条消息。如果没有消息,接受者可能被阻塞,直到接受一条消息或者带着错误码返回。
消息传递系统的设计要点
消息传递系统现在面临着许多信号量和管程所未涉及的问题和设计难点,尤其对那些在网络中不同机器上的通信状况。例如,消息有可能被网络丢失。为了防止消息丢失,发送方和接收方可以达成一致:一旦接受到消息后,接收方马上回送一条特殊的 确认(acknowledgement)
消息。如果发送方在一段时间间隔内未收到确认,则重发消息。
现在考虑消息本身被正确接收,而返回给发送着的确认消息丢失的情况。发送者将重发消息,这样接受者将收到两次相同的消息。
对于接收者来说,如何区分新的消息和一条重发的老消息是非常重要的。通常采用在每条原始消息中嵌入一个连续的序号来解决此问题。如果接受者收到一条消息,它具有与前面某一条消息一样的序号,就知道这条消息是重复的,可以忽略。
消息系统还必须处理如何命名进程的问题,以便在发送或接收调用中清晰的指明进程。身份验证(authentication)
也是一个问题,比如客户端怎么知道它是在与一个真正的文件服务器通信,从发送方到接收方的信息有可能被中间人所篡改。
用消息传递解决生产者-消费者问题
现在我们考虑如何使用消息传递来解决生产者-消费者问题,而不是共享缓存。下面是一种解决方式
/* buffer 中槽的数量 */
#define N 100
void producer(void){
int item;
/* buffer 中槽的数量 */
message m;
while(TRUE){
/* 生成放入缓冲区的数据 */
item = produce_item();
/* 等待消费者发送空缓冲区 */
receive(consumer,&m);
/* 建立一个待发送的消息 */
build_message(&m,item);
/* 发送给消费者 */
send(consumer,&m);
}
}
void consumer(void){
int item,i;
message m;
/* 循环N次 */
for(int i = 0;i < N;i++){
/* 发送N个缓冲区 */
send(producer,&m);
}
while(TRUE){
/* 接受包含数据的消息 */
receive(producer,&m);
/* 将数据从消息中提取出来 */
item = extract_item(&m);
/* 将空缓冲区发送回生产者 */
send(producer,&m);
/* 处理数据 */
consume_item(item);
}
}
假设所有的消息都有相同的大小,并且在尚未接受到发出的消息时,由操作系统自动进行缓冲。在该解决方案中共使用 N 条消息,这就类似于一块共享内存缓冲区的 N 个槽。消费者首先将 N 条空消息发送给生产者。当生产者向消费者传递一个数据项时,它取走一条空消息并返回一条填充了内容的消息。通过这种方式,系统中总的消息数量保持不变,所以消息都可以存放在事先确定数量的内存中。
如果生产者的速度要比消费者快,则所有的消息最终都将被填满,等待消费者,生产者将被阻塞,等待返回一条空消息。如果消费者速度快,那么情况将正相反:所有的消息均为空,等待生产者来填充,消费者将被阻塞,以等待一条填充过的消息。
消息传递的方式有许多变体,下面先介绍如何对消息进行 编址
。
- 一种方法是为每个进程分配一个唯一的地址,让消息按进程的地址编址。
- 另一种方式是引入一个新的数据结构,称为
信箱(mailbox)
,信箱是一个用来对一定的数据进行缓冲的数据结构,信箱中消息的设置方法也有多种,典型的方法是在信箱创建时确定消息的数量。在使用信箱时,在 send 和 receive 调用的地址参数就是信箱的地址,而不是进程的地址。当一个进程试图向一个满的信箱发送消息时,它将被挂起,直到信箱中有消息被取走,从而为新的消息腾出地址空间。
屏障
最后一个同步机制是准备用于进程组而不是进程间的生产者-消费者情况的。在某些应用中划分了若干阶段,并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段,可以通过在每个阶段的结尾安装一个 屏障(barrier)
来实现这种行为。当一个进程到达屏障时,它会被屏障所拦截,直到所有的屏障都到达为止。屏障可用于一组进程同步,如下图所示
在上图中我们可以看到,有四个进程接近屏障,这意味着每个进程都在进行运算,但是还没有到达每个阶段的结尾。过了一段时间后,A、B、D 三个进程都到达了屏障,各自的进程被挂起,但此时还不能进入下一个阶段呢,因为进程 B 还没有执行完毕。结果,当最后一个 C 到达屏障后,这个进程组才能够进入下一个阶段。
避免锁:读-复制-更新
最快的锁是根本没有锁。问题在于没有锁的情况下,我们是否允许对共享数据结构的并发读写进行访问。答案当然是不可以。假设进程 A 正在对一个数字数组进行排序,而进程 B 正在计算其平均值,而此时你进行 A 的移动,会导致 B 会多次读到重复值,而某些值根本没有遇到过。
然而,在某些情况下,我们可以允许写操作来更新数据结构,即便还有其他的进程正在使用。窍门在于确保每个读操作要么读取旧的版本,要么读取新的版本,例如下面的树
上面的树中,读操作从根部到叶子遍历整个树。加入一个新节点 X 后,为了实现这一操作,我们要让这个节点在树中可见之前使它"恰好正确":我们对节点 X 中的所有值进行初始化,包括它的子节点指针。然后通过原子写操作,使 X 称为 A 的子节点。所有的读操作都不会读到前后不一致的版本
在上面的图中,我们接着移除 B 和 D。首先,将 A 的左子节点指针指向 C 。所有原本在 A 中的读操作将会后续读到节点 C ,而永远不会读到 B 和 D。也就是说,它们将只会读取到新版数据。同样,所有当前在 B 和 D 中的读操作将继续按照原始的数据结构指针并且读取旧版数据。所有操作均能正确运行,我们不需要锁住任何东西。而不需要锁住数据就能够移除 B 和 D 的主要原因就是 读-复制-更新(Ready-Copy-Update,RCU)
,将更新过程中的移除和再分配过程分离开。
调度
当一个计算机是多道程序设计系统时,会频繁的有很多进程或者线程来同时竞争 CPU 时间片。当两个或两个以上的进程/线程处于就绪状态时,就会发生这种情况。如果只有一个 CPU 可用,那么必须选择接下来哪个进程/线程可以运行。操作系统中有一个叫做 调度程序(scheduler)
的角色存在,它就是做这件事儿的,该程序使用的算法叫做 调度算法(scheduling algorithm)
。
尽管有一些不同,但许多适用于进程调度的处理方法同样也适用于线程调度。当内核管理线程的时候,调度通常会以线程级别发生,很少或者根本不会考虑线程属于哪个进程。下面我们会首先专注于进程和线程的调度问题,然后会明确的介绍线程调度以及它产生的问题。
调度介绍
让我们回到早期以磁带上的卡片作为输入的批处理系统的时代,那时候的调度算法非常简单:依次运行磁带上的每一个作业。对于多道程序设计系统,会复杂一些,因为通常会有多个用户在等待服务。一些大型机仍然将 批处理
和 分时服务
结合使用,需要调度程序决定下一个运行的是一个批处理作业还是终端上的用户。由于在这些机器中 CPU 是稀缺资源,所以好的调度程序可以在提高性能和用户的满意度方面取得很大的成果。
进程行为
几乎所有的进程(磁盘或网络)I/O 请求和计算都是交替运行的
如上图所示,CPU 不停顿的运行一段时间,然后发出一个系统调用等待 I/O 读写文件。完成系统调用后,CPU 又开始计算,直到它需要读更多的数据或者写入更多的数据为止。当一个进程等待外部设备完成工作而被阻塞时,才是 I/O 活动。
上面 a 是 CPU 密集型进程;b 是 I/O 密集型进程进程,a 因为在计算的时间上花费时间更长,因此称为计算密集型(compute-bound)
或者 CPU 密集型(CPU-bound)
,b 因为I/O 发生频率比较快因此称为 I/O 密集型(I/O-bound)
。计算密集型进程有较长的 CPU 集中使用和较小频度的 I/O 等待。I/O 密集型进程有较短的 CPU 使用时间和较频繁的 I/O 等待。注意到上面两种进程的区分关键在于 CPU 的时间占用而不是 I/O 的时间占用。I/O 密集型的原因是因为它们没有在 I/O 之间花费更多的计算、而不是 I/O 请求时间特别长。无论数据到达后需要花费多少时间,它们都需要花费相同的时间来发出读取磁盘块的硬件请求。
值得注意的是,随着 CPU 的速度越来越快,更多的进程倾向于 I/O 密集型。这种情况出现的原因是 CPU 速度的提升要远远高于硬盘。这种情况导致的结果是,未来对 I/O 密集型进程的调度处理似乎更为重要。这里的基本思想是,如果需要运行 I/O 密集型进程,那么就应该让它尽快得到机会,以便发出磁盘请求并保持磁盘始终忙碌。
何时调度
第一个和调度有关的问题是何时进行调度决策
。存在着需要调度处理的各种情形。首先,在创建一个新进程后,需要决定是运行父进程还是子进程。因为二者的进程都处于就绪态下,这是正常的调度决策,可以任意选择,也就是说,调度程序可以任意的选择子进程或父进程开始运行。
第二,在进程退出时需要作出调度决定。因为此进程不再运行(因为它将不再存在),因此必须从就绪进程中选择其他进程运行。如果没有进程处于就绪态,系统提供的空闲进程
通常会运行
**什么是空闲进程
空闲进程(system-supplied idle process)
是 Microsoft 公司 windows 操作系统带有的系统进程,该进程是在各个处理器上运行的单个线程,它唯一的任务是在系统没有处理其他线程时占用处理器时间。System Idle Process 并不是一个真正的进程,它是核心虚拟
出来的,多任务操作系统都存在。在没有可用的进程时,系统处于空运行状态,此时就是System Idle Process 在正在运行。你可以简单的理解成,它代表的是 CPU 的空闲状态,数值越大代表处理器越空闲,可以通过 Windows 任务管理器查看 Windows 中的 CPU 利用率
第三种情况是,当进程阻塞在 I/O 、信号量或其他原因时,必须选择另外一个进程来运行。有时,阻塞的原因会成为选择进程运行的关键因素。例如,如果 A 是一个重要进程,并且它正在等待 B 退出关键区域,让 B 退出关键区域从而使 A 得以运行。但是调度程序一般不会对这种情况进行考量。
第四点,当 I/O 中断发生时,可以做出调度决策。如果中断来自 I/O 设备,而 I/O 设备已经完成了其工作,那么那些等待 I/O 的进程现在可以继续运行。由调度程序来决定是否准备运行新的进程还是重新运行已经中断的进程。
如果硬件时钟以 50 或 60 Hz 或其他频率提供周期性中断,可以在每个时钟中断或第 k 个时钟中断处做出调度决策。根据如何处理时钟中断可以把调度算法可以分为两类。非抢占式(nonpreemptive)
调度算法挑选一个进程,让该进程运行直到被阻塞(阻塞在 I/O 上或等待另一个进程),或者直到该进程自动释放 CPU。即使该进程运行了若干个小时后,它也不会被强制挂起。这样会在时钟中断发生时不会进行调度。在处理完时钟中断后,如果没有更高优先级的进程等待,则被中断的进程会继续执行。
另外一种情况是 抢占式
调度算法,它会选择一个进程,并使其在最大固定时间内运行。如果在时间间隔结束后仍在运行,这个进程会被挂起,调度程序会选择其他进程来运行(前提是存在就绪进程)。进行抢占式调度需要在时间间隔结束时发生时钟中断,以将 CPU 的控制权交还给调度程序。如果没有可用的时钟,那么非抢占式就是唯一的选择。
调度算法的分类
毫无疑问,不同的环境下需要不同的调度算法。之所以出现这种情况,是因为不同的应用程序和不同的操作系统有不同的目标。也就是说,在不同的系统中,调度程序的优化也是不同的。这里有必要划分出三种环境
批处理(Batch)
交互式(Interactive)
实时(Real time)
批处理系统广泛应用于商业领域,比如用来处理工资单、存货清单、账目收入、账目支出、利息计算、索赔处理和其他周期性作业。在批处理系统中,一般会选择使用非抢占式算法或者周期性比较长的抢占式算法。这种方法可以减少线程切换因此能够提升性能。
在交互式用户环境中,为了避免一个进程霸占 CPU 拒绝为其他进程服务,所以需要抢占式算法。即使没有进程有意要一直运行下去,但是,由于某个进程出现错误也有可能无限期的排斥其他所有进程。为了避免这种情况,抢占式也是必须的。服务器也属于此类别,因为它们通常为多个(远程)用户提供服务,而这些用户都非常着急。计算机用户总是很忙。
在实时系统中,抢占有时是不需要的,因为进程知道自己可能运行不了很长时间,通常很快的做完自己的工作并阻塞。实时系统与交互式系统的差别是,实时系统只运行那些用来推进现有应用的程序,而交互式系统是通用的,它可以运行任意的非协作甚至是有恶意的程序。
调度算法的目标
为了设计调度算法,有必要考虑一下什么是好的调度算法。有一些目标取决于环境(批处理、交互式或者实时)蛋大部分是适用于所有情况的,下面是一些需要考量的因素,我们会在下面一起讨论。
**所有系统
在所有的情况中,公平
是很重要的。对一个进程给予相较于其他等价的进程更多的 CPU 时间片对其他进程来说是不公平的。当然,不同类型的进程可以采用不同的处理方式。
与公平有关的是系统的强制执行
,什么意思呢?如果某公司的薪资发放系统计划在本月的15号,那么碰上了疫情大家生活都很拮据,此时老板说要在14号晚上发放薪资,那么调度程序必须强制使进程执行 14 号晚上发放薪资的策略。
另一个共同的目标是保持系统的所有部分尽可能的忙碌
。如果 CPU 和所有的 I/O 设备能够一直运行,那么相对于让某些部件空转而言,每秒钟就可以完成更多的工作。例如,在批处理系统中,调度程序控制哪个作业调入内存运行。在内存中既有一些 CPU 密集型进程又有一些 I/O 密集型进程是一个比较好的想法,好于先调入和运行所有的 CPU 密集型作业,然后在它们完成之后再调入和运行所有 I/O 密集型作业的做法。使用后者这种方式会在 CPU 密集型进程启动后,争夺 CPU ,而磁盘却在空转,而当 I/O 密集型进程启动后,它们又要为磁盘而竞争,CPU 却又在空转。。。。。。显然,通过结合 I/O 密集型和 CPU 密集型,能够使整个系统运行更流畅,效率更高。
**批处理系统
通常有三个指标来衡量系统工作状态:吞吐量、周转时间和 CPU 利用率,吞吐量(throughout)
是系统每小时完成的作业数量。综合考虑,每小时完成 50 个工作要比每小时完成 40 个工作好。周转时间(Turnaround time)
是一种平均时间,它指的是从一个批处理提交开始直到作业完成时刻为止平均时间。该数据度量了用户要得到输出所需的平均等待时间。周转时间越小越好。
CPU 利用率(CPU utilization)
通常作为批处理系统上的指标。即使如此, CPU 利用率也不是一个好的度量指标,真正有价值的衡量指标是系统每小时可以完成多少作业(吞吐量),以及完成作业需要多长时间(周转时间)。把 CPU 利用率作为度量指标,就像是引擎每小时转动了多少次来比较汽车的性能一样。而且知道 CPU 的利用率什么时候接近 100% 要比什么时候要求得到更多的计算能力要有用。
**交互式系统
对于交互式系统,则有不同的指标。最重要的是尽量减少响应时间
。这个时间说的是从执行指令开始到得到结果的时间。再有后台进程运行(例如,从网络上读取和保存 E-mail 文件)的个人计算机上,用户请求启动一个程序或打开一个文件应该优先于后台的工作。能够让所有的交互式请求首先运行的就是一个好的服务。
一个相关的问题是 均衡性(proportionality)
,用户对做一件事情需要多长时间总是有一种固定(不过通常不正确)的看法。当认为一个请求很复杂需要较多时间时,用户会认为很正常并且可以接受,但是一个很简单的程序却花费了很长的运行时间,用户就会很恼怒。可以拿彩印和复印来举出一个简单的例子,彩印可能需要1分钟的时间,但是用户觉得复杂并且愿意等待一分钟,相反,复印很简单只需要 5 秒钟,但是复印机花费 1 分钟却没有完成复印操作,用户就会很焦躁。
**实时系统
实时系统则有着和交互式系统不同的考量因素,因此也就有不同的调度目标。实时系统的特点是必须满足最后的截止时间
。例如,如果计算机控制着以固定速率产生数据的设备,未能按时运行的话可能会导致数据丢失。因此,实时系统中最重要的需求是满足所有(或大多数)时间期限。
在一些实事系统中,特别是涉及到多媒体的,可预测性很重要
。偶尔不能满足最后的截止时间不重要,但是如果音频多媒体运行不稳定,声音质量会持续恶化。视频也会造成问题,但是耳朵要比眼睛敏感很多。为了避免这些问题,进程调度必须能够高度可预测的而且是有规律的。
批处理中的调度
现在让我们把目光从一般性的调度转换为特定的调度算法。下面我们会探讨在批处理中的调度。
先来先服务
很像是先到先得。。。可能最简单的非抢占式调度算法的设计就是 先来先服务(first-come,first-serverd)
。使用此算法,将按照请求顺序为进程分配 CPU。最基本的,会有一个就绪进程的等待队列。当第一个任务从外部进入系统时,将会立即启动并允许运行任意长的时间。它不会因为运行时间太长而中断。当其他作业进入时,它们排到就绪队列尾部。当正在运行的进程阻塞,处于等待队列的第一个进程就开始运行。当一个阻塞的进程重新处于就绪态时,它会像一个新到达的任务,会排在队列的末尾,即排在所有进程最后。
这个算法的强大之处在于易于理解和编程,在这个算法中,一个单链表记录了所有就绪进程。要选取一个进程运行,只要从该队列的头部移走一个进程即可;要添加一个新的作业或者阻塞一个进程,只要把这个作业或进程附加在队列的末尾即可。这是很简单的一种实现。
不过,先来先服务也是有缺点的,那就是没有优先级的关系,试想一下,如果有 100 个 I/O 进程正在排队,第 101 个是一个 CPU 密集型进程,那岂不是需要等 100 个 I/O 进程运行完毕才会等到一个 CPU 密集型进程运行,这在实际情况下根本不可能,所以需要优先级或者抢占式进程的出现来优先选择重要的进程运行。
最短作业优先
批处理中,第二种调度算法是 最短作业优先(Shortest Job First)
,我们假设运行时间已知。例如,一家保险公司,因为每天要做类似的工作,所以人们可以相当精确地预测处理 1000 个索赔的一批作业需要多长时间。当输入队列中有若干个同等重要的作业被启动时,调度程序应使用最短优先作业算法
如上图 a 所示,这里有 4 个作业 A、B、C、D ,运行时间分别为 8、4、4、4 分钟。若按图中的次序运行,则 A 的周转时间为 8 分钟,B 为 12 分钟,C 为 16 分钟,D 为 20 分钟,平均时间内为 14 分钟。
现在考虑使用最短作业优先算法运行 4 个作业,如上图 b 所示,目前的周转时间分别为 4、8、12、20,平均为 11 分钟,可以证明最短作业优先是最优的。考虑有 4 个作业的情况,其运行时间分别为 a、b、c、d。第一个作业在时间 a 结束,第二个在时间 a + b 结束,以此类推。平均周转时间为 (4a + 3b + 2c + d) / 4 。显然 a 对平均值的影响最大,所以 a 应该是最短优先作业,其次是 b,然后是 c ,最后是 d 它就只能影响自己的周转时间了。
需要注意的是,在所有的进程都可以运行的情况下,最短作业优先的算法才是最优的。
最短剩余时间优先
最短作业优先的抢占式版本被称作为 最短剩余时间优先(Shortest Remaining Time Next)
算法。使用这个算法,调度程序总是选择剩余运行时间最短的那个进程运行。当一个新作业到达时,其整个时间同当前进程的剩余时间做比较。如果新的进程比当前运行进程需要更少的时间,当前进程就被挂起,而运行新的进程。这种方式能够使短期作业获得良好的服务。
交互式系统中的调度
交互式系统中在个人计算机、服务器和其他系统中都是很常用的,所以有必要来探讨一下交互式调度
轮询调度
一种最古老、最简单、最公平并且最广泛使用的算法就是 轮询算法(round-robin)
。每个进程都会被分配一个时间段,称为时间片(quantum)
,在这个时间片内允许进程运行。如果时间片结束时进程还在运行的话,则抢占一个 CPU 并将其分配给另一个进程。如果进程在时间片结束前阻塞或结束,则 CPU 立即进行切换。轮询算法比较容易实现。调度程序所做的就是维护一个可运行进程的列表,就像下图中的 a,当一个进程用完时间片后就被移到队列的末尾,就像下图的 b。
时间片轮询调度中唯一有意思的一点就是时间片的长度。从一个进程切换到另一个进程需要一定的时间进行管理处理,包括保存寄存器的值和内存映射、更新不同的表格和列表、清除和重新调入内存高速缓存等。这种切换称作 进程间切换(process switch)
和 上下文切换(context switch)
。如果进程间的切换时间需要 1ms,其中包括内存映射、清除和重新调入高速缓存等,再假设时间片设为 4 ms,那么 CPU 在做完 4 ms 有用的工作之后,CPU 将花费 1 ms 来进行进程间的切换。因此,CPU 的时间片会浪费 20% 的时间在管理开销上。耗费巨大。
为了提高 CPU 的效率,我们把时间片设置为 100 ms。现在时间的浪费只有 1%。但是考虑会发现下面的情况,如果在一个非常短的时间内到达 50 个请求,并且对 CPU 有不同的需求,此时会发生什么?50 个进程都被放在可运行进程列表中。如果 CPU 是空闲的,第一个进程会立即开始执行,第二个直到 100 ms 以后才会启动,以此类推。不幸的是最后一个进程需要等待 5 秒才能获得执行机会。大部分用户都会觉得对于一个简短的指令运行 5 秒中是很慢的。如果队列末尾的某些请求只需要几号秒钟的运行时间的话,这种设计就非常糟糕了。
另外一个因素是如果时间片设置长度要大于 CPU 使用长度,那么抢占就不会经常发生。相反,在时间片用完之前,大多数进程都已经阻塞了,那么就会引起进程间的切换。消除抢占可提高性能,因为进程切换仅在逻辑上必要时才发生,即流程阻塞且无法继续时才发生。
结论可以表述如下:将时间片时间设置得太短会导致过多的进程切换并降低 CPU 效率,但设置时间太长会导致一个短请求很长时间得不到响应。最好的切换时间是在 20 – 50 毫秒之间设置。
优先级调度
轮询调度假设了所有的进程是同等重要的。但事实情况可能不是这样。例如,在一所大学中的等级制度,首先是院长,然后是教授、秘书、后勤人员,最后是学生。这种将外部情况考虑在内就实现了优先级调度(priority scheduling)
它的基本思想很明确,每个进程都被赋予一个优先级,优先级高的进程优先运行。
但是也不意味着高优先级的进程能够永远一直运行下去,调度程序会在每个时钟中断期间降低当前运行进程的优先级。如果此操作导致其优先级降低到下一个最高进程的优先级以下,则会发生进程切换。或者,可以为每个进程分配允许运行的最大时间间隔。当时间间隔用完后,下一个高优先级的进程会得到运行的机会。
可以静态或者动态的为进程分配优先级。在一台军用计算机上,可以把将军所启动的进程设为优先级 100,上校为 90 ,少校为 80,上尉为 70,中尉为 60,以此类推。UNIX 中有一条命令为 nice
,它允许用户为了照顾他人而自愿降低自己进程的优先级,但是一般没人用。
优先级也可以由系统动态分配,用于实现某种目的。例如,有些进程为 I/O 密集型,其多数时间用来等待 I/O 结束。当这样的进程需要 CPU 时,应立即分配 CPU,用来启动下一个 I/O 请求,这样就可以在另一个进程进行计算的同时执行 I/O 操作。这类 I/O 密集型进程长时间的等待 CPU 只会造成它长时间占用内存。使 I/O 密集型进程获得较好的服务的一种简单算法是,将其优先级设为 1/f
,f 为该进程在上一时间片中所占的部分。一个在 50 ms 的时间片中只使用 1 ms 的进程将获得优先级 50 ,而在阻塞之前用掉 25 ms 的进程将具有优先级 2,而使用掉全部时间片的进程将得到优先级 1。
可以很方便的将一组进程按优先级分成若干类,并且在各个类之间采用优先级调度,而在各类进程的内部采用轮转调度。下面展示了一个四个优先级类的系统
它的调度算法主要描述如下:上面存在优先级为 4 类的可运行进程,首先会按照轮转法为每个进程运行一个时间片,此时不理会较低优先级的进程。若第 4 类进程为空,则按照轮询的方式运行第三类进程。若第 4 类和第 3 类进程都为空,则按照轮转法运行第 2 类进程。如果不对优先级进行调整,则低优先级的进程很容易产生饥饿现象。
多级队列
最早使用优先级调度的系统是 CTSS(Compatible TimeSharing System)
。CTSS 是一种兼容分时系统,它有一个问题就是进程切换太慢,其原因是 IBM 7094 内存只能放进一个进程。
IBM 是哥伦比亚大学计算机中心在 1964 – 1968 年的计算机
CTSS 在每次切换前都需要将当前进程换出到磁盘,并从磁盘上读入一个新进程。CTSS 的设计者很快就认识到,为 CPU 密集型进程设置较长的时间片比频繁地分给他们很短的时间要更有效(减少交换次数)。另一方面,如前所述,长时间片的进程又会影响到响应时间,解决办法是设置优先级类。属于最高优先级的进程运行一个时间片,次高优先级进程运行 2 个时间片,再下面一级运行 4 个时间片,以此类推。当一个进程用完分配的时间片后,它被移到下一类。
最短进程优先
对于批处理系统而言,由于最短作业优先常常伴随着最短响应时间,所以如果能够把它用于交互式进程,那将是非常好的。在某种程度上,的确可以做到这一点。交互式进程通常遵循下列模式:等待命令、执行命令、等待命令、执行命令。。。如果我们把每个命令的执行都看作一个分离的作业,那么我们可以通过首先运行最短的作业来使响应时间最短。这里唯一的问题是如何从当前可运行进程中找出最短的那一个进程。
一种方式是根据进程过去的行为进行推测,并执行估计运行时间最短的那一个。假设每个终端上每条命令的预估运行时间为 T0
,现在假设测量到其下一次运行时间为 T1
,可以用两个值的加权来改进估计时间,即aT0+ (1- a)T1
。通过选择 a 的值,可以决定是尽快忘掉老的运行时间,还是在一段长时间内始终记住它们。当 a = 1/2 时,可以得到下面这个序列
可以看到,在三轮过后,T0 在新的估计值中所占比重下降至 1/8。
有时把这种通过当前测量值和先前估计值进行加权平均从而得到下一个估计值的技术称作 老化(aging)
。这种方法会使用很多预测值基于当前值的情况。
保证调度
一种完全不同的调度方法是对用户做出明确的性能保证。一种实际而且容易实现的保证是:若用户工作时有 n 个用户登录,则每个用户将获得 CPU 处理能力的 1/n。类似地,在一个有 n 个进程运行的单用户系统中,若所有的进程都等价,则每个进程将获得 1/n 的 CPU 时间。
彩票调度
对用户进行承诺并在随后兑现承诺是一件好事,不过很难实现。但是存在着一种简单的方式,有一种既可以给出预测结果而又有一种比较简单的实现方式的算法,就是 彩票调度(lottery scheduling)
算法。
其基本思想是为进程提供各种系统资源(例如 CPU 时间)的彩票。当做出一个调度决策的时候,就随机抽出一张彩票,拥有彩票的进程将获得该资源。在应用到 CPU 调度时,系统可以每秒持有 50 次抽奖,每个中奖者将获得比如 20 毫秒的 CPU 时间作为奖励。
George Orwell
关于 所有的进程是平等的,但是某些进程能够更平等一些。一些重要的进程可以给它们额外的彩票,以便增加他们赢得的机会。如果出售了 100 张彩票,而且有一个进程持有了它们中的 20 张,它就会有 20% 的机会去赢得彩票中奖。在长时间的运行中,它就会获得 20% 的CPU。相反,对于优先级调度程序,很难说明拥有优先级 40 究竟是什么意思,这里的规则很清楚,拥有彩票 f 份额的进程大约得到系统资源的 f 份额。
如果希望进程之间协作的话可以交换它们之间的票据。例如,客户端进程给服务器进程发送了一条消息后阻塞,客户端进程可能会把自己所有的票据都交给服务器,来增加下一次服务器运行的机会。当服务完成后,它会把彩票还给客户端让其有机会再次运行。事实上,如果没有客户机,服务器也根本不需要彩票。
可以把彩票理解为 buff,这个 buff 有 15% 的几率能让你产生
速度之靴
的效果。
公平分享调度
到目前为止,我们假设被调度的都是各个进程自身,而不用考虑该进程的拥有者是谁。结果是,如果用户 1 启动了 9 个进程,而用户 2 启动了一个进程,使用轮转或相同优先级调度算法,那么用户 1 将得到 90 % 的 CPU 时间,而用户 2 将之得到 10 % 的 CPU 时间。
为了阻止这种情况的出现,一些系统在调度前会把进程的拥有者考虑在内。在这种模型下,每个用户都会分配一些CPU 时间,而调度程序会选择进程并强制执行。因此如果两个用户每个都会有 50% 的 CPU 时间片保证,那么无论一个用户有多少个进程,都将获得相同的 CPU 份额。
实时系统中的调度
实时系统(real-time)
是一个时间扮演了重要作用的系统。典型的,一种或多种外部物理设备发给计算机一个服务请求,而计算机必须在一个确定的时间范围内恰当的做出反应。例如,在 CD 播放器中的计算机会获得从驱动器过来的位流,然后必须在非常短的时间内将位流转换为音乐播放出来。如果计算时间过长,那么音乐就会听起来有异常。再比如说医院特别护理部门的病人监护装置、飞机中的自动驾驶系统、列车中的烟雾警告装置等,在这些例子中,正确但是却缓慢的响应要比没有响应甚至还糟糕。
实时系统可以分为两类,硬实时(hard real time)
和 软实时(soft real time)
系统,前者意味着必须要满足绝对的截止时间;后者的含义是虽然不希望偶尔错失截止时间,但是可以容忍。在这两种情形中,实时都是通过把程序划分为一组进程而实现的,其中每个进程的行为是可预测和提前可知的。这些进程一般寿命较短,并且极快的运行完成。在检测到一个外部信号时,调度程序的任务就是按照满足所有截止时间的要求调度进程。
实时系统中的事件可以按照响应方式进一步分类为周期性(以规则的时间间隔发生)
事件或 非周期性(发生时间不可预知)
事件。一个系统可能要响应多个周期性事件流,根据每个事件处理所需的时间,可能甚至无法处理所有事件。例如,如果有 m 个周期事件,事件 i 以周期 Pi 发生,并需要 Ci 秒 CPU 时间处理一个事件,那么可以处理负载的条件是
只有满足这个条件的实时系统称为可调度的
,这意味着它实际上能够被实现。一个不满足此检验标准的进程不能被调度,因为这些进程共同需要的 CPU 时间总和大于 CPU 能提供的时间。
举一个例子,考虑一个有三个周期性事件的软实时系统,其周期分别是 100 ms、200 m 和 500 ms。如果这些事件分别需要 50 ms、30 ms 和 100 ms 的 CPU 时间,那么该系统时可调度的,因为 0.5 + 0.15 + 0.2 < 1。如果此时有第四个事件加入,其周期为 1 秒,那么此时这个事件如果不超过 150 ms,那么仍然是可以调度的。忽略上下文切换的时间。
实时系统的调度算法可以是静态的或动态的。前者在系统开始运行之前做出调度决策;后者在运行过程中进行调度决策。只有在可以提前掌握所完成的工作以及必须满足的截止时间等信息时,静态调度才能工作,而动态调度不需要这些限制。
调度策略和机制
到目前为止,我们隐含的假设系统中所有进程属于不同的分组用户并且进程间存在相互竞争 CPU 的情况。通常情况下确实如此,但有时也会发生一个进程会有很多子进程并在其控制下运行的情况。例如,一个数据库管理系统进程会有很多子进程。每一个子进程可能处理不同的请求,或者每个子进程实现不同的功能(如请求分析、磁盘访问等)。主进程完全可能掌握哪一个子进程最重要(或最紧迫),而哪一个最不重要。但是,以上讨论的调度算法中没有一个算法从用户进程接收有关的调度决策信息,这就导致了调度程序很少能够做出最优的选择。
解决问题的办法是将 调度机制(scheduling mechanism)
和 调度策略(scheduling policy)
分开,这是长期一贯的原则。这也就意味着调度算法在某种方式下被参数化了,但是参数可以被用户进程填写。让我们首先考虑数据库的例子。假设内核使用优先级调度算法,并提供了一条可供进程设置优先级的系统调用。这样,尽管父进程本身并不参与调度,但它可以控制如何调度子进程的细节。调度机制位于内核,而调度策略由用户进程决定,调度策略和机制分离是一种关键性思路。
线程调度
当若干进程都有多个线程时,就存在两个层次的并行:进程和线程。在这样的系统中调度处理有本质的差别,这取决于所支持的是用户级线程还是内核级线程(或两者都支持)。
首先考虑用户级线程,由于内核并不知道有线程存在,所以内核还是和以前一样地操作,选取一个进程,假设为 A,并给予 A 以时间片控制。A 中的线程调度程序决定哪个线程运行。假设为 A1。由于多道线程并不存在时钟中断,所以这个线程可以按其意愿任意运行多长时间。如果该线程用完了进程的全部时间片,内核就会选择另一个进程继续运行。
在进程 A 终于又一次运行时,线程 A1 会接着运行。该线程会继续耗费 A 进程的所有时间,直到它完成工作。不过,线程运行不会影响到其他进程。其他进程会得到调度程序所分配的合适份额,不会考虑进程 A 内部发生的事情。
现在考虑 A 线程每次 CPU 计算的工作比较少的情况,例如:在 50 ms 的时间片中有 5 ms 的计算工作。于是,每个线程运行一会儿,然后把 CPU 交回给线程调度程序。这样在内核切换到进程 B 之前,就会有序列 A1,A2,A3,A1,A2,A3,A1,A2,A3,A1 。 如下所示
运行时系统使用的调度算法可以是上面介绍算法的任意一种。从实用方面考虑,轮转调度和优先级调度更为常用。唯一的局限是,缺乏一个时钟中断运行过长的线程。但由于线程之间的合作关系,这通常也不是问题。
现在考虑使用内核线程的情况,内核选择一个特定的线程运行。它不用考虑线程属于哪个进程,不过如果有必要的话,也可以这么做。对被选择的线程赋予一个时间片,而且如果超过了时间片,就会强制挂起该线程。一个线程在 50 ms 的时间片内,5 ms 之后被阻塞,在 30 ms 的时间片中,线程的顺序会是 A1,B1,A2,B2,A3,B3。如下图所示
用户级线程和内核级线程之间的主要差别在于性能
。用户级线程的切换需要少量的机器指令(想象一下Java程序的线程切换),而内核线程需要完整的上下文切换,修改内存映像,使高速缓存失效,这会导致了若干数量级的延迟。另一方面,在使用内核级线程时,一旦线程阻塞在 I/O 上就不需要在用户级线程中那样将整个进程挂起。
从进程 A 的一个线程切换到进程 B 的一个线程,其消耗要远高于运行进程 A 的两个线程(涉及修改内存映像,修改高速缓存),内核对这种切换的消耗是了解到,可以通过这些信息作出决定。
操作系统之文件系统
所有的应用程序都需要存储
和检索
信息。进程运行时,它能够在自己的存储空间内存储一定量的信息。然而,存储容量受虚拟地址空间
大小的限制。对于一些应用程序来说,存储空间的大小是充足的,但是对于其他一些应用程序,比如航空订票系统、银行系统、企业记账系统来说,这些容量又显得太小了。
第二个问题是,当进程终止时信息会丢失。对于一些应用程序(例如数据库),信息会长久保留。在这些进程终止时,相关的信息应该保留下来,是不能丢失的。甚至这些应用程序崩溃后,信息也应该保留下来。
第三个问题是,通常需要很多进程在同一时刻访问这些信息。解决这种问题的方式是把这些信息单独保留在各自的进程中。
因此,对于长久存储的信息我们有三个基本需求:
-
**必须要有可能存储的大量的信息
- **信息必须能够在进程终止时保留
- **必须能够使多个进程同时访问有关信息
磁盘(Magnetic disk)
一直是用来长久保存信息的设备。近些年来,固态硬盘
逐渐流行起来。
固态硬盘不仅没有易损坏的移动部件,而且能够提供快速的随机访问。相比而言,虽然磁带和光盘也被广泛使用,但是它们的性能相对较差,通常应用于备份。我们会在后面探讨磁盘,现在姑且把磁盘当作一种大小固定块的线性序列好了,并且支持如下操作
- 读块 k
- 写块 k
事实上磁盘支持更多的操作,但是只要有了读写操作,原则上就能够解决长期存储的问题。
然而,磁盘还有一些不便于实现的操作,特别是在有很多程序或者多用户使用的大型系统上(如服务器)。在这种情况下,很容易产生一些问题,例如
-
你如何找到这些信息?
-
你如何保证一个用户不会读取另外一个用户的数据?
-
你怎么知道哪些块是空闲的?等等问题
我们可以针对这些问题提出一个新的抽象 – 文件
。进程和线程的抽象、地址空间和文件都是操作系统的重要概念。如果你能真正深入了解这三个概念,那么你就走上了成为操作系统专家的道路。
文件(Files)
是由进程创建的逻辑信息单元。一个磁盘会包含几千甚至几百万个文件,每个文件是独立于其他文件的。事实上,如果你能把每个文件都看作一个独立的地址空间,那么你就可以真正理解文件的概念了。
进程能够读取已经存在的文件,并在需要时重新创建他们。存储在文件中的信息必须是持久的
,这也就是说,不会因为进程的创建和终止而受影响。一个文件只能在当用户明确删除的时候才能消失。尽管读取和写入都是最基本的操作,但还有许多其他操作,我们将在下面介绍其中的一些。
文件由操作系统进行管理,有关文件的构造、命名、访问、使用、保护、实现和管理方式都是操作系统设计的主要内容。从总体上看,操作系统中处理文件的部分称为 文件系统(file system)
,这就是我们所讨论的。
从用户角度来说,用户通常会关心文件是由什么组成的,如何给文件进行命名,如何保护文件,以及可以对文件进行哪些操作等等。尽管是用链表还是用位图记录内存空闲区并不是用户所关心的主题,而这些对系统设计人员来说至关重要。下面我们就来探讨一下这些主题
文件
文件命名
文件是一种抽象机制,它提供了一种方式用来存储信息以及在后面进行读取。可能任何一种机制最重要的特性就是管理对象的命名方式。在创建一个文件后,它会给文件一个命名。当进程终止时,文件会继续存在,并且其他进程可以使用名称访问该文件
。
文件命名规则对于不同的操作系统来说是不一样的,但是所有现代操作系统都允许使用 1 – 8 个字母的字符串作为合法文件名。
某些文件区分大小写字母,而大多数则不区分。UNIX
属于第一类;历史悠久的 MS-DOS
属于第二类(顺便说一句,尽管 MS-DOS 历史悠久,但 MS-DOS 仍在嵌入式系统中非常广泛地使用,因此它绝不是过时的);因此,UNIX 系统会有三种不同的命名文件:maria
、Maria
、MARIA
。在 MS-DOS ,所有这些命名都属于相同的文件。
这里可能需要在文件系统上预留一个位置。Windows 95 和 Windows 98 都使用了 MS-DOS 文件系统,叫做 FAT-16
,因此继承了它的一些特征,例如有关文件名的构造方法。Windows 98 引入了对 FAT-16 的一些扩展,从而导致了 FAT-32
的生成,但是这两者很相似。另外,Windows NT,Windows 2000,Windows XP,Windows Vista,Windows 7 和 Windows 8 都支持 FAT
文件系统,这种文件系统有些过时。然而,这些较新的操作系统还具有更高级的本机文件系统(NTFS)
,有不同的特性,那就是基于 Unicode
编码的文件名。事实上,Windows 8 还配备了另一种文件系统,简称 ReFS(Resilient File System)
,但这个文件系统一般应用于 Windows 8 的服务器版本。下面除非我们特殊声明,否则我们在提到 MS-DOS 和 FAT 文件系统的时候,所指的就是 Windows 的 FAT-16 和 FAT-32。这里要说一下,有一种类似 FAT 的新型文件系统,叫做 exFAT
。它是微软公司对闪存和大文件系统开发的一种优化的 FAT 32 扩展版本。ExFAT 是现在微软唯一能够满足 OS X
读写操作的文件系统。
许多操作系统支持两部分的文件名,它们之间用 .
分隔开,比如文件名 prog.c
。原点后面的文件称为 文件扩展名(file extension)
,文件扩展名通常表示文件的一些信息。例如在 MS-DOS 中,文件名是 1 – 8 个字符,加上 1 – 3 个字符的可选扩展名组成。在 UNIX 中,如果有扩展名,那么扩展名的长度将由用户来决定,一个文件甚至可以包括两个或更多的扩展名,例如 homepage.html.zip
,html 表示一个 web 网页而 .zip 表示文件homepage.html
已经采用 zip 程序压缩完成。一些常用的文件扩展名以及含义如下图所示
扩展名 | 含义 |
---|---|
bak | 备份文件 |
c | c 源程序文件 |
gif | 符合图形交换格式的图像文件 |
hlp | 帮助文件 |
html | WWW 超文本标记语言文档 |
jpg | 符合 JPEG 编码标准的静态图片 |
mp3 | 符合 MP3 音频编码格式的音乐文件 |
mpg | 符合 MPEG 编码标准的电影 |
o | 目标文件(编译器输出格式,尚未链接) |
pdf 格式的文件 | |
ps | PostScript 文件 |
tex | 为 TEX 格式化程序准备的输入文件 |
txt | 文本文件 |
zip | 压缩文件 |
在 UNIX 系统中,文件扩展名只是一种约定,操作系统并不强制采用。
名为 file.txt
的文件是文本文件,这个文件名更多的是提醒所有者,而不是给计算机传递信息。但是另一方面,C 编译器可能要求它编译的文件以.c
结尾,否则它会拒绝编译。然而,操作系统并不关心这一点。
对于可以处理多种类型的程序,约定就显得及其有用。例如 C 编译器可以编译、链接多种文件,包括 C 文件和汇编语言文件。这时扩展名就很有必要,编译器利用它们区分哪些是 C 文件,哪些是汇编文件,哪些是其他文件。因此,扩展名对于编译器判断哪些是 C 文件,哪些是汇编文件以及哪些是其他文件变得至关重要。
与 UNIX 相反,Windows 就会关注扩展名并对扩展名赋予了新的含义。用户(或进程)
可以在操作系统中注册扩展名
,并且规定哪个程序能够拥有扩展名。当用户双击某个文件名时,拥有该文件名的程序就启动并运行文件。例如,双击 file.docx 启动了 Word 程序,并以 file.docx 作为初始文件。
文件结构
文件的构造有多种方式。下图列出了常用的三种构造方式
上图中的 a 是一种无结构的字节序列,操作系统不关心序列的内容是什么,操作系统能看到的就是字节(bytes)
。其文件内容的任何含义只在用户程序中进行解释。UNIX 和 Windows 都采用这种办法。
把文件看成字节序列提供了最大的灵活性。用户程序可以向文件中写任何内容,并且可以通过任何方便的形式命名。操作系统不会为为用户写入内容提供帮助,当然也不会干扰阻塞你。对于想做特殊操作的用户来说,后者是十分重要的。所有的 UNIX 版本(包括 Linux 和 OS X)和 Windows 都使用这种文件模型。
图 b 表示在文件结构上的第一部改进。在这个模型中,文件是具有固定长度记录的序列,每个记录都有其内部结构。 把文件作为记录序列的核心思想是:读操作返回一个记录,而写操作重写或者追加一个记录。第三种文件结构如上图 c 所示。在这种组织结构中,文件由一颗记录树
构成,记录树的长度不一定相同,每个记录树都在记录中的固定位置包含一个key
字段。这棵树按 key 进行排序,从而可以对特定的 key 进行快速查找。
在记录树的结构中,可以取出下一个记录,但是最关键的还是根据 key 搜索指定的记录。如上图 c 所示,用户可以读出指定的 pony
记录,而不必关心记录在文件中的确切位置。用户也可以在文件中添加新的记录。但是用户不能决定添加到何处位置,添加到何处位置是由操作系统
决定的。
文件类型
很多操作系统支持多种文件类型。例如,UNIX(同样包括 OS X)和 Windows 都具有常规的文件和目录。除此之外,UNIX 还具有字符特殊文件(character special file)
和 块特殊文件(block special file)
。常规文件(Regular files)
是包含有用户信息的文件。用户一般使用的文件大都是常规文件,常规文件一般包括 可执行文件、文本文件、图像文件,从常规文件读取数据或将数据写入时,内核会根据文件系统的规则执行操作,是写入可能被延迟,记录日志或者接受其他操作。
字符特殊文件和输入/输出有关,用于串行 I/O 类设备,如终端、打印机、网络等。块特殊文件用于磁盘类设备。我们主要讨论的是常规文件。
常规文件一般分为 ASCII
码文件或者二进制文件。ASCII 码文件由文本组成。在一些系统中,每行都会用回车符结束(ASCII码是13,控制字符 CR,转义字符\r
。),另外一些则会使用换行符(ASCII码是10,控制字符LF,转义字符\n
)。一些系统(比如 Windows)两者都会使用。
ASCII 文件的优点在于显示
和 打印
,还可以用任何文本编辑器进行编辑。进一步来说,如果许多应用程序使用 ASCII 码作为输入和输出,那么很容易就能够把多个程序连接起来,一个程序的输出可能是另一个程序的输入,就像管道一样。
其他与 ASCII 不同的是二进制文件。打印出来的二进制文件是无法理解的。下面是一个二进制文件的格式,它取自早期的 UNIX 。尽管从技术上来看这个文件只是字节序列,但是操作系统只有在文件格式正确的情况下才会执行。
这个文件有五个段:文件头、征文、数据、重定位位和符号表。文件头以 魔数(magic number)
为开始,表明这个文件是一个可执行文件(以防止意外执行非此格式的文件)。然后是文件各个部分的大小,开始执行的标志以及一些标志位。程序本身的正文和数据在文件头
后面,他们被加载到内存中或者重定位会根据重定位位
进行判断。符号表则用于调试
。
二进制文件的另外一种形式是存档文件
,它由已编译但没有链接的库过程(模块)组合而成。每个文件都以模块头开始,其中记录了名称、创建日期、所有者、保护码和文件大小。和可执行文件一样,模块头也都是二进制数,将它们复制到打印机将会产生乱码。
所有的操作系统必须至少能够识别一种文件类型:它自己的可执行文件。以前的 TOPS-20 系统(用于DECsystem 20)甚至要检查要执行的任何文件的创建时间,为了定位资源文件来检查自动文件创建后是否被修改过。如果被修改过了,那么就会自动编译文件。在 UNIX 中,就是在 shell 中嵌入 make
程序。此时操作系统要求用户必须采用固定的文件扩展名,从而确定哪个源程序生成哪个二进制文件。
什么是 make 程序?在软件发展过程中,make 程序是一个自动编译的工具,它通过读取称为
Makefiles
的文件来自动从源代码构建可执行程序和库,该文件指定了如何导出目标程序。尽管集成开发环境和特定于语言的编译器功能也可以用于管理构建过程,但 Make 仍被广泛使用,尤其是在 Unix 和类似 Unix 的操作系统中使用。
当程序从文件中读写数据时,请求会转到内核处理程序(kernel driver)
。如果文件是常规文件,则数据由文件系统驱动程序处理,并且通常存储在磁盘或其他存储介质上的某块区域中,从文件中读取的数据就是之前在该位置写入的数据。
当数据读取或写入到设备文件时,请求会被设备驱动程序处理。每个设备文件都有一个关联的编号,该编号标示要使用的设备驱动程序。设备处理数据的工作是它自己的事儿。
块设备
也叫做块特殊文件,它的行为通常与普通文件相似:它们是字节数组,并且在给定位置读取的值是最后写入该位置的值。来自块设备的数据可以缓存在内存中,并从缓存中读取;写入可以被缓冲。块设备通常是可搜索的,块设备的概念是,相应的硬件可以一次读取或者写入整个块,例如磁盘上的一个扇区字符设备
也称为字符特殊文件,它的行为类似于管道、串行端口。将字节写入字符设备可能会导致它在屏幕上显示,在串行端口上输出,转换为声音。
目录(Directories)
是管理文件系统结构的系统文件。它是用于在计算机上存储文件的位置。目录位于分层文件系统
中,例如 Linux,MS-DOS 和 UNIX。
它显示所有本地和子目录(例如,cdn 目录中的 big 目录)。当前目录是 C 盘驱动器的根目录
。之所以称为根目录,是因为该目录下没有任何内容,而其他目录都在该目录下分支
。
文件访问
早期的操作系统只有一种访问方式:序列访问(sequential access)
。在这些系统中,进程可以按照顺序读取所有的字节或文件中的记录,但是不能跳过并乱序执行它们。顺序访问文件是可以返回到起点的,需要时可以多次读取该文件。当存储介质是磁带而不是磁盘时,顺序访问文件很方便。
在使用磁盘来存储文件时,可以不按照顺序读取文件中的字节或者记录,或者按照关键字而不是位置来访问记录。这种能够以任意次序进行读取的称为随机访问文件(random access file)
。许多应用程序都需要这种方式。
随机访问文件对许多应用程序来说都必不可少,例如,数据库系统。如果乘客打电话预定某航班机票,订票程序必须能够直接访问航班记录,而不必先读取其他航班的成千上万条记录。
有两种方法可以指示从何处开始读取文件。第一种方法是直接使用 read
从头开始读取。另一种是用一个特殊的 seek
操作设置当前位置,在 seek 操作后,从这个当前位置顺序地开始读文件。UNIX 和 Windows 使用的是后面一种方式。
文件属性
文件包括文件名和数据。除此之外,所有的操作系统还会保存其他与文件相关的信息,如文件创建的日期和时间、文件大小。我们可以称这些为文件的属性(attributes)
。有些人也喜欢把它们称作 元数据(metadata)
。文件的属性在不同的系统中差别很大。文件的属性只有两种状态:设置(set)
和 清除(clear)
。下面是一些常用的属性
属性 | 含义 |
---|---|
保护 | 谁可以访问文件、以什么方式存取文件 |
密码(口令) | 访问文件所需要的密码(口令) |
创建者 | 创建文件者的 ID |
所有者 | 当前所有者 |
只读标志 | 0 表示读/写,1 表示只读 |
隐藏标志 | 0 表示正常,1 表示不再列表中显示 |
系统标志 | 0 表示普通文件,1 表示系统文件 |
存档标志 | 0 表示已经备份,1 表示需要备份 |
ASCII / 二进制标志 | 0 表示 ASCII 文件,1 表示二进制文件 |
随机访问标志 | 0 表示只允许顺序访问,1 表示随机访问 |
临时标志 | 0 表示正常,1 表示进程退出时删除该文件 |
加锁标志 | 0 表示未加锁,1 表示加锁 |
记录长度 | 一个记录中的字节数 |
键的位置 | 每个记录中的键的偏移量 |
键的长度 | 键字段的字节数 |
创建时间 | 创建文件的日期和时间 |
最后一次存取时间 | 上一次访问文件的日期和时间 |
最后一次修改时间 | 上一次修改文件的日期和时间 |
当前大小 | 文件的字节数 |
最大长度 | 文件可能增长到的字节数 |
没有一个系统能够同时具有上面所有的属性,但每个属性都在某个系统中采用。
前面四个属性(保护,口令,创建者,所有者)与文件保护有关,它们指出了谁可以访问这个文件,谁不能访问这个文件。
保护(File Protection)
: 用于保护计算机上有价值数据的方法。文件保护是通过密码保护文件或者仅仅向特定用户或组提供权限来实现。
在一些系统中,用户必须给出口令才能访问文件。标志(flags)
是一些位或者短属性能够控制或者允许特定属性。
隐藏文件位(hidden flag)
表示该文件不在文件列表中出现。存档标志位(archive flag)
用于记录文件是否备份过,由备份程序清除该标志位;若文件被修改,操作系统则设置该标志位。用这种方法,备份程序可以知道哪些文件需要备份。临时标志位(temporary flag)
允许文件被标记为是否允许自动删除当进程终止时。
记录长度(record-length)
、键的位置(key-position)
和键的长度(key-length)
等字段只能出现在用关键字查找记录的文件中。它们提供了查找关键字所需要的信息。
不同的时间字段记录了文件的创建时间、最近一次访问时间以及最后一次修改时间,它们的作用不同。例如,目标文件生成后被修改的源文件需要重新编译生成目标文件。这些字段提供了必要的信息。
当前大小字段指出了当前的文件大小,一些旧的大型机操作系统要求在创建文件时指定文件呢最大值,以便让操作系统提前保留最大存储值。但是一些服务器和个人计算机却不用设置此功能。
文件操作
使用文件的目的是用来存储信息并方便以后的检索。对于存储和检索,不同的系统提供了不同的操作。以下是与文件有关的最常用的一些系统调用:
Create
,创建不包含任何数据的文件。调用的目的是表示文件即将建立,并对文件设置一些属性。Delete
,当文件不再需要,必须删除它以释放内存空间。为此总会有一个系统调用来删除文件。Open
,在使用文件之前,必须先打开文件。这个调用的目的是允许系统将属性和磁盘地址列表保存到主存中,用来以后的快速访问。Close
,当所有进程完成时,属性和磁盘地址不再需要,因此应关闭文件以释放表空间。很多系统限制进程打开文件的个数,以此达到鼓励用户关闭不再使用的文件。磁盘以块为单位写入,关闭文件时会强制写入最后一块
,即使这个块空间内部还不满。Read
,数据从文件中读取。通常情况下,读取的数据来自文件的当前位置。调用者必须指定需要读取多少数据,并且提供存放这些数据的缓冲区。Write
,向文件写数据,写操作一般也是从文件的当前位置开始进行。如果当前位置是文件的末尾,则会直接追加进行写入。如果当前位置在文件中,则现有数据被覆盖,并且永远消失。append
,使用 append 只能向文件末尾添加数据。seek
,对于随机访问的文件,要指定从何处开始获取数据。通常的方法是用 seek 系统调用把当前位置指针指向文件中的特定位置。seek 调用结束后,就可以从指定位置开始读写数据了。get attributes
,进程运行时通常需要读取文件属性。set attributes
,用户可以自己设置一些文件属性,甚至是在文件创建之后,实现该功能的是 set attributes 系统调用。rename
,用户可以自己更改已有文件的名字,rename 系统调用用于这一目的。
目录
文件系统通常提供目录(directories)
或者 文件夹(folders)
用于记录文件的位置,在很多系统中目录本身也是文件,下面我们会讨论关于文件,他们的组织形式、属性和可以对文件进行的操作。
一级目录系统
目录系统最简单的形式是有一个能够包含所有文件的目录。这种目录被称为根目录(root directory)
,由于根目录的唯一性,所以其名称并不重要。在最早期的个人计算机中,这种系统很常见,部分原因是因为只有一个用户。下面是一个单层目录系统的例子
该目录中有四个文件。这种设计的优点在于简单,并且能够快速定位文件,毕竟只有一个地方可以检索。这种目录组织形式现在一般用于简单的嵌入式设备(如数码相机和某些便携式音乐播放器)上使用。
层次目录系统
对于简单的应用而言,一般都用单层目录方式,但是这种组织形式并不适合于现代计算机,因为现代计算机含有成千上万个文件和文件夹。如果都放在根目录下,查找起来会非常困难。为了解决这一问题,出现了层次目录系统(Hierarchical Directory Systems)
,也称为目录树
。通过这种方式,可以用很多目录把文件进行分组。进而,如果多个用户共享同一个文件服务器,比如公司的网络系统,每个用户可以为自己的目录树拥有自己的私人根目录。这种方式的组织结构如下
根目录含有目录 A、B 和 C ,分别属于不同的用户,其中两个用户个字创建了子目录
。用户可以创建任意数量的子目录,现代文件系统都是按照这种方式组织的。
路径名
当目录树组织文件系统时,需要有某种方法指明文件名。常用的方法有两种,第一种方式是每个文件都会用一个绝对路径名(absolute path name)
,它由根目录到文件的路径组成。举个例子,/usr/ast/mailbox
意味着根目录包含一个子目录usr
,usr 下面包含了一个 mailbox
。绝对路径名总是以 /
开头,并且是唯一的。在UNIX中,路径的组件由/
分隔。在Windows中,分隔符为\
。 在 MULTICS 中,它是>
。 因此,在这三个系统中,相同的路径名将被编写如下
Windows \usr\ast\mailbox
UNIX /usr/ast/mailbox
MULTICS >usr>ast>mailbox
不论使用哪种方式,如果路径名的第一个字符是分隔符,那就是绝对路径。
另外一种指定文件名的方法是 相对路径名(relative path name)
。它常常和 工作目录(working directory)
(也称作 当前目录(current directory)
)一起使用。用户可以指定一个目录作为当前工作目录。例如,如果当前目录是 /usr/ast
,那么绝对路径 /usr/ast/mailbox
可以直接使用 mailbox
来引用。也就是说,如果工作目录是 /usr/ast
,则 UNIX 命令
cp /usr/ast/mailbox /usr/ast/mailbox.bak
和命令
cp mailbox mailbox.bak
具有相同的含义。相对路径通常情况下更加方便和简洁。而它实现的功能和绝对路径安全相同。
一些程序需要访问某个特定的文件而不必关心当前的工作目录是什么。在这种情况下,应该使用绝对路径名。
支持层次目录结构的大多数操作系统在每个目录中有两个特殊的目录项.
和 ..
,长读作 dot
和 dotdot
。dot 指的是当前目录,dotdot 指的是其父目录(在根目录中例外,在根目录中指向自己)。可以参考下面的进程树来查看如何使用。
一个进程的工作目录是 /usr/ast
,它可采用 ..
沿树向上,例如,可用命令
cp ../lib/dictionary .
把文件 usr/lib/dictionary
复制到自己的目录下,第一个路径告诉系统向上找(到 usr 目录),然后向下到 lib
目录,找到 dictionary 文件
第二个参数 .
指定当前的工作目录,当 cp 命令用目录名作为最后一个参数时,则把全部的文件复制到该目录中。当然,对于上述复制,键入
cp /usr/lib/dictionary .
是更常用的方法。用户这里采用 .
可以避免键入两次 dictionary 。无论如何,键入
cp /usr/lib/dictionary dictionary
也可正常工作,就像键入
cp /usr/lib/dictionary /usr/lib/dictionary
一样。所有这些命令都能够完成同样的工作。
目录操作
不同文件中管理目录的系统调用的差别比管理文件的系统调用差别大。为了了解这些系统调用有哪些以及它们怎样工作,下面给出一个例子(取自 UNIX)。
Create
,创建目录,除了目录项.
和..
外,目录内容为空。Delete
,删除目录,只有空目录可以删除。只包含.
和..
的目录被认为是空目录,这两个目录项通常不能删除opendir
,目录内容可被读取。例如,未列出目录中的全部文件,程序必须先打开该目录,然后读其中全部文件的文件名。与打开和读文件相同,在读目录前,必须先打开文件。closedir
,读目录结束后,应该关闭目录用于释放内部表空间。readdir
,系统调用 readdir 返回打开目录的下一个目录项。以前也采用 read 系统调用来读取目录,但是这种方法有一个缺点:程序员必须了解和处理目录的内部结构。相反,不论采用哪一种目录结构,readdir 总是以标准格式返回一个目录项。rename
,在很多方面目录和文件都相似。文件可以更换名称,目录也可以。link
,链接技术允许在多个目录中出现同一个文件。这个系统调用指定一个存在的文件和一个路径名,并建立从该文件到路径所指名字的链接。这样,可以在多个目录中出现同一个文件。有时也被称为硬链接(hard link)
。unlink
,删除目录项。如果被解除链接的文件只出现在一个目录中,则将它从文件中删除。如果它出现在多个目录中,则只删除指定路径名的链接,依然保留其他路径名的链接。在 UNIX 中,用于删除文件的系统调用就是 unlink。
文件系统的实现
在对文件有了基本认识之后,现在是时候把目光转移到文件系统的实现
上了。之前用户关心的一直都是文件是怎样命名的、可以进行哪些操作、目录树是什么,如何找到正确的文件路径等问题。而设计人员关心的是文件和目录是怎样存储的、磁盘空间是如何管理的、如何使文件系统得以流畅运行的问题,下面我们就来一起讨论一下这些问题。
文件系统布局
文件系统存储在磁盘
中。大部分的磁盘能够划分出一到多个分区,叫做磁盘分区(disk partitioning)
或者是磁盘分片(disk slicing)
。每个分区都有独立的文件系统,每块分区的文件系统可以不同。磁盘的 0 号分区称为 主引导记录(Master Boot Record, MBR)
,用来引导(boot)
计算机。在 MBR 的结尾是分区表(partition table)
。每个分区表给出每个分区由开始到结束的地址。系统管理员使用一个称为分区编辑器的程序来创建,调整大小,删除和操作分区。这种方式的一个缺点是很难适当调整分区的大小,导致一个分区具有很多可用空间,而另一个分区几乎完全被分配。
MBR 可以用在 DOS 、Microsoft Windows 和 Linux 操作系统中。从 2010 年代中期开始,大多数新计算机都改用 GUID 分区表(GPT)分区方案。
下面是一个用 GParted
进行分区的磁盘,表中的分区都被认为是 活动的(active)
。
当计算机开始引 boot 时,BIOS 读入并执行 MBR。
引导块
MBR 做的第一件事就是确定活动分区
,读入它的第一个块,称为引导块(boot block)
并执行。引导块中的程序将加载分区中的操作系统。为了一致性,每个分区都会从引导块开始,即使引导块不包含操作系统。引导块占据文件系统的前 4096 个字节,从磁盘上的字节偏移量 0 开始。引导块可用于启动操作系统。
在计算机中,引导就是启动计算机的过程,它可以通过硬件(例如按下电源按钮)或者软件命令的方式来启动。开机后,电脑的 CPU 还不能执行指令,因为此时没有软件在主存中,所以一些软件必须先被加载到内存中,然后才能让 CPU 开始执行。也就是计算机开机后,首先会进行软件的装载过程。
重启电脑的过程称为
重新引导(rebooting)
,从休眠或睡眠状态返回计算机的过程不涉及启动。
除了从引导块开始之外,磁盘分区的布局是随着文件系统的不同而变化的。通常文件系统会包含一些属性,如下
超级块
紧跟在引导块后面的是 超级块(Superblock)
,超级块 的大小为 4096 字节,从磁盘上的字节偏移 4096 开始。超级块包含文件系统的所有关键参数
- 文件系统的大小
- 文件系统中的数据块数
- 指示文件系统状态的标志
- 分配组大小
在计算机启动或者文件系统首次使用时,超级块会被读入内存。
空闲空间块
接着是文件系统中空闲块
的信息,例如,可以用位图或者指针列表的形式给出。
**BitMap 位图或者 Bit vector 位向量
位图或位向量是一系列位或位的集合,其中每个位对应一个磁盘块,该位可以采用两个值:0和1,0表示已分配该块,而1表示一个空闲块。下图中的磁盘上给定的磁盘块实例(分配了绿色块)可以用16位的位图表示为:0000111000000110。
**使用链表进行管理
在这种方法中,空闲磁盘块链接在一起,即一个空闲块包含指向下一个空闲块的指针。第一个磁盘块的块号存储在磁盘上的单独位置,也缓存在内存中。
碎片
这里不得不提一个叫做碎片(fragment)
的概念,也称为片段。一般零散的单个数据通常称为片段。 磁盘块可以进一步分为固定大小的分配单元,片段只是在驱动器上彼此不相邻的文件片段。如果你不理解这个概念就给你举个例子。比如你用 Windows 电脑创建了一个文件,你会发现这个文件可以存储在任何地方,比如存在桌面上,存在磁盘中的文件夹中或者其他地方。你可以打开文件,编辑文件,删除文件等等。你可能以为这些都在一个地方发生,但是实际上并不是,你的硬盘驱动器可能会将文件中的一部分存储在一个区域内,另一部分存储在另外一个区域,在你打开文件时,硬盘驱动器会迅速的将文件的所有部分汇总在一起,以便其他计算机系统可以使用它。
inode
然后在后面是一个 inode(index node)
,也称作索引节点。它是一个数组的结构,每个文件有一个 inode,inode 非常重要,它说明了文件的方方面面。每个索引节点都存储对象数据的属性和磁盘块位置
有一种简单的方法可以找到它们 ls -lai
命令。让我们看一下根文件系统:
inode 节点主要包括了以下信息
- 模式/权限(保护)
- 所有者 ID
- 组 ID
- 文件大小
- 文件的硬链接数
- 上次访问时间
- 最后修改时间
- inode 上次修改时间
文件分为两部分,索引节点和块。一旦创建后,每种类型的块数是固定的。你不能增加分区上 inode 的数量,也不能增加磁盘块的数量。
紧跟在 inode 后面的是根目录,它存放的是文件系统目录树的根部。最后,磁盘的其他部分存放了其他所有的目录和文件。
文件的实现
最重要的问题是记录各个文件分别用到了哪些磁盘块。不同的系统采用了不同的方法。下面我们会探讨一下这些方式。分配背后的主要思想是有效利用文件空间
和快速访问文件
,主要有三种分配方案
- 连续分配
- 链表分配
- 索引分配
连续分配
最简单的分配方案是把每个文件作为一连串连续数据块存储在磁盘上。因此,在具有 1KB 块的磁盘上,将为 50 KB 文件分配 50 个连续块。
上面展示了 40 个连续的内存块。从最左侧的 0 块开始。初始状态下,还没有装载文件,因此磁盘是空的。接着,从磁盘开始处(块 0 )处开始写入占用 4 块长度的内存 A 。然后是一个占用 6 块长度的内存 B,会直接在 A 的末尾开始写。
注意每个文件都会在新的文件块开始写,所以如果文件 A 只占用了 3 又 1/2
个块,那么最后一个块的部分内存会被浪费。在上面这幅图中,总共展示了 7 个文件,每个文件都会从上个文件的末尾块开始写新的文件块。
连续的磁盘空间分配有两个优点。
-
第一,连续文件存储实现起来比较简单,只需要记住两个数字就可以:一个是第一个块的文件地址和文件的块数量。给定第一个块的编号,可以通过简单的加法找到任何其他块的编号。
-
第二点是读取性能比较强,可以通过一次操作从文件中读取整个文件。只需要一次寻找第一个块。后面就不再需要寻道时间和旋转延迟,所以数据会以全带宽进入磁盘。
因此,连续的空间分配具有实现简单
、高性能
的特点。
不幸的是,连续空间分配也有很明显的不足。随着时间的推移,磁盘会变得很零碎。下图解释了这种现象
这里有两个文件 D 和 F 被删除了。当删除一个文件时,此文件所占用的块也随之释放,就会在磁盘空间中留下一些空闲块。磁盘并不会在这个位置挤压掉空闲块,因为这会复制空闲块之后的所有文件,可能会有上百万的块,这个量级就太大了。
刚开始的时候,这个碎片不是问题,因为每个新文件都会在之前文件的结尾处进行写入。然而,磁盘最终会被填满,因此要么压缩磁盘、要么重新使用空闲块的空间。压缩磁盘的开销太大,因此不可行;后者会维护一个空闲列表,这个是可行的。但是这种情况又存在一个问题,为空闲块匹配合适大小的文件,需要知道该文件的最终大小
。
想象一下这种设计的结果会是怎样的。用户启动 word 进程创建文档。应用程序首先会询问最终创建的文档会有多大。这个问题必须回答,否则应用程序就不会继续执行。如果空闲块的大小要比文件的大小小,程序就会终止。因为所使用的磁盘空间已经满了。那么现实生活中,有没有使用连续分配内存的介质出现呢?
CD-ROM
就广泛的使用了连续分配方式。
CD-ROM(Compact Disc Read-Only Memory)
即只读光盘,也称作只读存储器。是一种在电脑上使用的光碟。这种光碟只能写入数据一次,信息将永久保存在光碟上,使用时通过光碟驱动器读出信息。
然而 DVD 的情况会更加复杂一些。原则上,一个 90分钟
的电影能够被编码成一个独立的、大约 4.5 GB 的文件。但是文件系统所使用的 UDF(Universal Disk Format)
格式,使用一个 30 位的数来代表文件长度,从而把文件大小限制在 1 GB。所以,DVD 电影一般存储在 3、4个连续的 1 GB 空间内。这些构成单个电影中的文件块称为扩展区(extends)
。
就像我们反复提到的,历史总是惊人的相似,许多年前,连续分配由于其简单
和高性能
被实际使用在磁盘文件系统中。后来由于用户不希望在创建文件时指定文件的大小,于是放弃了这种想法。但是随着 CD-ROM 、DVD、蓝光光盘等光学介质的出现,连续分配又流行起来。从而得出结论,技术永远没有过时性
,现在看似很老的技术,在未来某个阶段可能又会流行起来。
链表分配
第二种存储文件的方式是为每个文件构造磁盘块链表,每个文件都是磁盘块的链接列表,就像下面所示
每个块的第一个字作为指向下一块的指针,块的其他部分存放数据。如果上面这张图你看的不是很清楚的话,可以看看整个的链表分配方案
与连续分配方案不同,这一方法可以充分利用每个磁盘块。除了最后一个磁盘块外,不会因为磁盘碎片而浪费存储空间。同样,在目录项中,只要存储了第一个文件块,那么其他文件块也能够被找到。
另一方面,在链表的分配方案中,尽管顺序读取非常方便,但是随机访问却很困难(这也是数组和链表数据结构的一大区别)。
还有一个问题是,由于指针会占用一些字节,每个磁盘块实际存储数据的字节数并不再是 2 的整数次幂。虽然这个问题并不会很严重,但是这种方式降低了程序运行效率。许多程序都是以长度为 2 的整数次幂来读写磁盘,由于每个块的前几个字节被指针所使用,所以要读出一个完成的块大小信息,就需要当前块的信息和下一块的信息拼凑而成,因此就引发了查找和拼接的开销。
使用内存表进行链表分配
由于连续分配和链表分配都有其不可忽视的缺点。所以提出了使用内存中的表来解决分配问题。取出每个磁盘块的指针字,把它们放在内存的一个表中,就可以解决上述链表的两个不足之处。下面是一个例子
上图表示了链表形成的磁盘块的内容。这两个图中都有两个文件,文件 A 依次使用了磁盘块地址 4、7、 2、 10、 12,文件 B 使用了6、3、11 和 14。也就是说,文件 A 从地址 4 处开始,顺着链表走就能找到文件 A 的全部磁盘块。同样,从第 6 块开始,顺着链走到最后,也能够找到文件 B 的全部磁盘块。你会发现,这两个链表都以不属于有效磁盘编号的特殊标记(-1)结束。内存中的这种表格称为 文件分配表(File Application Table,FAT)
。
使用这种组织方式,整个块都可以存放数据。进而,随机访问也容易很多。虽然仍要顺着链在内存中查找给定的偏移量,但是整个链都存放在内存中,所以不需要任何磁盘引用。与前面的方法相同,不管文件有多大,在目录项中只需记录一个整数(起始块号),按照它就可以找到文件的全部块。
这种方式存在缺点,那就是必须要把整个链表放在内存中。对于 1TB 的磁盘和 1KB 的大小的块,那么这张表需要有 10 亿项。。。每一项对应于这 10 亿个磁盘块中的一块。每项至少 3 个字节,为了提高查找速度,有时需要 4 个字节。根据系统对空间或时间的优化方案,这张表要占用 3GB 或 2.4GB 的内存。FAT 的管理方式不能较好地扩展并应用于大型磁盘中。而这正是最初 MS-DOS 文件比较实用,并仍被各个 Windows 版本所安全支持。
inode
最后一个记录各个文件分别包含哪些磁盘块的方法是给每个文件赋予一个称为 inode(索引节点)
的数据结构,每个文件都与一个 inode
进行关联,inode 由整数进行标识。
下面是一个简单例子的描述。
给出 inode 的长度,就能够找到文件中的所有块。
相对于在内存中使用表的方式而言,这种机制具有很大的优势。即只有在文件打开时,其 inode 才会在内存中。如果每个 inode 需要 n 个字节,最多 k 个文件同时打开,那么 inode 占有总共打开的文件是 kn 字节。仅需预留这么多空间。
这个数组要比我们上面描述的 FAT(文件分配表)
占用的空间小的多。原因是用于保存所有磁盘块的链接列表的表的大小与磁盘本身成正比。如果磁盘有 n 个块,那么这个表也需要 n 项。随着磁盘空间的变大,那么该表也随之线性增长
。相反,inode 需要节点中的数组,其大小和可能需要打开的最大文件个数成正比。它与磁盘是 100GB、4000GB 还是 10000GB 无关。
inode 的一个问题是如果每个节点都会有固定大小的磁盘地址,那么文件增长到所能允许的最大容量外会发生什么?一个解决方案是最后一个磁盘地址不指向数据块,而是指向一个包含额外磁盘块地址的地址,如上图所示。一个更高级的解决方案是:有两个或者更多包含磁盘地址的块,或者指向其他存放地址的磁盘块的磁盘块。Windows 的 NTFS 文件系统采用了相似的方法,所不同的仅仅是大的 inode 也可以表示小的文件。
NTFS 的全称是
New Technology File System
,是微软公司开发的专用系统文件,NTFS 取代 FAT(文件分配表) 和HPFS(高性能文件系统)
,并在此基础上进一步改进。例如增强对元数据的支持,使用更高级的数据结构以提升性能、可靠性和磁盘空间利用率等。
目录的实现
文件只有打开后才能够被读取。在文件打开后,操作系统会使用用户提供的路径名来定位磁盘中的目录。目录项提供了查找文件磁盘块所需要的信息。根据系统的不同,提供的信息也不同,可能提供的信息是整个文件的磁盘地址,或者是第一个块的数量(两个链表方案)或 inode的数量。不过不管用那种情况,目录系统的主要功能就是 将文件的 ASCII 码的名称映射到定位数据所需的信息上。
与此关系密切的问题是属性应该存放在哪里。每个文件系统包含不同的文件属性,例如文件的所有者和创建时间,需要存储的位置。一种显而易见的方法是直接把文件属性存放在目录中。有一些系统恰好是这么做的,如下。
在这种简单的设计中,目录有一个固定大小的目录项列表,每个文件对应一项,其中包含一个固定长度的文件名,文件属性的结构体以及用以说明磁盘块位置的一个或多个磁盘地址。
对于采用 inode 的系统,会把 inode 存储在属性中而不是目录项中。在这种情况下,目录项会更短:仅仅只有文件名称和 inode 数量。这种方式如下所示
到目前为止,我们已经假设文件具有较短的、固定长度的名字。在 MS-DOS 中,具有 1 – 8 个字符的基本名称和 1 – 3 个字符的可拓展名称。在 UNIX 版本 7 中,文件有 1 – 14 个字符,包括任何拓展。然而,几乎所有的现代操作系统都支持可变长度的扩展名。这是如何实现的呢?
最简单的方式是给予文件名一个长度限制,比如 255 个字符,然后使用上图中的设计,并为每个文件名保留 255 个字符空间。这种处理很简单,但是浪费了大量的目录空间,因为只有很少的文件会有那么长的文件名称。所以,需要一种其他的结构来处理。
一种可选择的方式是放弃所有目录项大小相同的想法。在这种方法中,每个目录项都包含一个固定部分,这个固定部分通常以目录项的长度开始,后面是固定格式的数据,通常包括所有者、创建时间、保护信息和其他属性。这个固定长度的头的后面是一个任意长度的实际文件名,如下图所示
上图是 SPARC 机器使用正序放置。
处理机中的一串字符存放的顺序有
正序(big-endian)
和逆序(little-endian)
之分。正序存放的就是高字节在前低字节在后,而逆序存放的就是低字节在前高字节在后。
这个例子中,有三个文件,分别是 project-budget
、personnel
和 foo
。每个文件名以一个特殊字符(通常是 0 )结束,用矩形中的叉进行表示。为了使每个目录项从字的边界开始,每个文件名被填充成整数个字,如下图所示
这个方法的缺点是当文件被移除后,就会留下一块固定长度的空间,而新添加进来的文件大小不一定和空闲空间大小一致。
这个问题与我们上面探讨的连续磁盘文件的问题是一样的,由于整个目录在内存中,所以只有对目录进行紧凑拼接
操作才可节省空间。另一个问题是,一个目录项可能会分布在多个页上,在读取文件名时可能发生缺页中断。
处理可变长度文件名字的另外一种方法是,使目录项自身具有固定长度,而将文件名放在目录末尾的堆栈中。如上图所示的这种方式。这种方法的优点是当目录项被移除后,下一个文件将能够正常匹配移除文件的空间。当然,必须要对堆
进行管理,因为在处理文件名的时候也会发生缺页异常。
到目前为止的所有设计中,在需要查找文件名时,所有的方案都是线性的从头到尾对目录进行搜索。对于特别长的目录,线性搜索的效率很低。提高文件检索效率的一种方式是在每个目录上使用哈希表(hash table)
,也叫做散列表。我们假设表的大小为 n,在输入文件名时,文件名被散列在 0 和 n – 1 之间,例如,它被 n 除,并取余数。或者对构成文件名字的字求和或类似某种方法。
无论采用哪种方式,在添加一个文件时都要对与散列值相对 应的散列表进行检查。如果没有使用过,就会将一个指向目录项的指针指向这里。文件目录项紧跟着哈希表后面。如果已经使用过,就会构造一个链表(这种构造方式是不是和 HashMap 使用的数据结构一样?),链表的表头指针存放在表项中,并通过哈希值将所有的表项相连。
查找文件的过程和添加类似,首先对文件名进行哈希处理,在哈希表中查找是否有这个哈希值,如果有的话,就检查这条链上所有的哈希项,查看文件名是否存在。如果哈希不在链上,那么文件就不在目录中。
使用哈希表的优势是查找非常迅速
,缺点是管理起来非常复杂
。只有在系统中会有成千上万个目录项存在时,才会考虑使用散列表作为解决方案。
另外一种在大量目录中加快查找指令目录的方法是使用缓存
,缓存查找的结果。在开始查找之前,会首先检查文件名是否在缓存中。如果在缓存中,那么文件就能立刻定位。当然,只有在较少的文件下进行多次查找,缓存才会发挥最大功效。
共享文件
当多个用户在同一个项目中工作时,他们通常需要共享文件。如果这个共享文件同时出现在多个用户目录下,那么他们协同工作起来就很方便。下面的这张图我们在上面提到过,但是有一个更改的地方,就是 C 的一个文件也出现在了 B 的目录下。
如果按照如上图的这种组织方式而言,那么 B 的目录与该共享文件的联系称为 链接(link)
。那么文件系统现在就是一个 有向无环图(Directed Acyclic Graph, 简称 DAG)
,而不是一棵树了。
在图论中,如果一个有向图从任意顶点出发无法经过若干条边回到该点,则这个图是一个
有向无环图
,我们不会在此着重探讨关于图论的东西,大家可以自行 google。
将文件系统组织成为有向无环图会使得维护复杂化,但也是必须要付出的代价。
共享文件
很方便,但这也会带来一些问题。如果目录中包含磁盘地址,则当链接文件时,必须把 C 目录中的磁盘地址复制到 B 目录中。如果 B 或者 C 随后又向文件中添加内容,则仅在执行追加的用户的目录中显示新写入的数据块。这种变更将会对其他用户不可见,从而破坏了共享的目的。
有两种方案可以解决这种问题。
-
第一种解决方案,磁盘块不列入目录中,而是会把磁盘块放在与文件本身相关联的小型数据结构中。目录将指向这个小型数据结构。这是
UNIX
中使用的方式(小型数据结构就是 inode)。 -
在第二种解决方案中,通过让系统建立一个类型为
LINK
的新文件,并把该文件放在 B 的目录下,使得 B 与 C 建立链接。新的文件中只包含了它所链接的文件的路径名。当 B 想要读取文件时,操作系统会检查 B 的目录下存在一个类型为 LINK 的文件,进而找到该链接的文件和路径名,然后再去读文件,这种方式称为符号链接(symbolic linking)
。
上面的每一种方法都有各自的缺点,在第一种方式中,B 链接到共享文件时,inode 记录文件的所有者为 C。建立一个链接并不改变所有关系,如下图所示。
第一开始的情况如图 a 所示,此时 C 的目录的所有者是 C ,当目录 B 链接到共享文件时,并不会改变 C 的所有者关系,只是把计数 + 1,所以此时 系统知道目前有多少个目录指向这个文件。然后 C 尝试删除这个文件,这个时候有个问题,如果 C 把文件移除并清除了 inode 的话,那么 B 会有一个目录项指向无效的节点。如果 inode 以后分配给另一个文件,则 B 的链接指向一个错误的文件。系统通过 inode 可知文件仍在被引用,但是没有办法找到该文件的全部目录项以删除它们。指向目录的指针不能存储在 inode 中,原因是有可能有无数个这样的目录。
所以我们能做的就是删除 C 的目录项,但是将 inode 保留下来,并将计数设置为 1 ,如上图 c 所示。c 表示的是只有 B 有指向该文件的目录项,而该文件的前者是 C 。如果系统进行记账操作的话,那么 C 将继续为该文件付账直到 B 决定删除它,如果是这样的话,只有到计数变为 0 的时刻,才会删除该文件。
对于符号链接
,以上问题不会发生,只有真正的文件所有者才有一个指向 inode 的指针。链接到该文件上的用户只有路径名,没有指向 inode 的指针。当文件所有者删除文件时,该文件被销毁。以后若试图通过符号链接访问该文件将会失败,因为系统不能找到该文件。删除符号链接不会影响该文件。
符号链接的问题是需要额外的开销。必须读取包含路径的文件,然后要一个部分接一个部分地扫描路径,直到找到 inode 。这些操作也许需要很多次额外的磁盘访问。此外,每个符号链接都需要额外的 inode ,以及额外的一个磁盘块用于存储路径,虽然如果路径名很短,作为一种优化,系统可以将它存储在 inode 中。符号链接有一个优势,即只要简单地提供一个机器的网络地址以及文件在该机器上驻留的路径,就可以连接全球任何地方机器上的文件。
还有另一个由链接带来的问题,在符号链接和其他方式中都存在。如果允许链接,文件有两个或多个路径。查找一指定目录及其子目录下的全部文件的程序将多次定位到被链接的文件。例如,一个将某一目录及其子目录下的文件转存到磁带上的程序有可能多次复制一个被链接的文件。进而,如果接着把磁带读入另一台机器,除非转出程序具有智能,否则被链接的文件将被两次复制到磁盘上,而不是只是被链接起来。
日志结构文件系统
技术的改变会给当前的文件系统带来压力。这种情况下,CPU 会变得越来越快,磁盘会变得越来越大并且越来越便宜(但不会越来越快)。内存容量也是以指数级增长。但是磁盘的寻道时间(除了固态盘,因为固态盘没有寻道时间)并没有获得提高。
这些因素结合起来意味着许多系统文件中出现性能瓶颈。为此,Berkeley
设计了一种全新的文件系统,试图缓解这个问题,这个文件系统就是 日志结构文件系统(Log-structured File System, LFS)
。
日志结构文件系统由 Rosenblum
和 Ousterhout
于90年代初引入,旨在解决以下问题。
-
不断增长的系统内存
-
顺序 I/O 性能胜过随机 I/O 性能
-
现有低效率的文件系统
-
文件系统不支持 RAID(虚拟化)
另一方面,当时的文件系统不论是 UNIX 还是 FFS,都有大量的随机读写(在 FFS 中创建一个新文件至少需要5次随机写),因此成为整个系统的性能瓶颈。同时因为 Page cache
的存在,作者认为随机读不是主要问题:随着越来越大的内存,大部分的读操作都能被 cache,因此 LFS 主要要解决的是减少对硬盘的随机写操作。
在这种设计中,inode 甚至具有与 UNIX 中相同的结构,但是现在它们分散在整个日志中,而不是位于磁盘上的固定位置。所以,inode 很定位。为了能够找到 inode ,维护了一个由 inode 索引的 inode map(inode 映射)
。表项 i 指向磁盘中的第 i 个 inode 。这个映射保存在磁盘中,但是也保存在缓存中,因此,使用最频繁的部分大部分时间都在内存中。
日志结构文件系统主要使用四种数据结构:Inode、Inode Map、Segment、Segment Usage Table。
到目前为止,所有写入最初都缓存在内存
中,并且追加在日志末尾
,所有缓存的写入都定期在单个段中写入磁盘。所以,现在打开文件也就意味着用映射定位文件的索引节点。一旦 inode 被定位后,磁盘块的地址就能够被找到。所有这些块本身都将位于日志中某处的分段中。
真实情况下的磁盘容量是有限的,所以最终日志会占满整个磁盘空间,这种情况下就会出现没有新的磁盘块被写入到日志中。幸运的是,许多现有段可能具有不再需要的块。例如,如果一个文件被覆盖了,那么它的 inode 将被指向新的块,但是旧的磁盘块仍在先前写入的段中占据着空间。
为了处理这个问题,LFS 有一个清理(clean)
线程,它会循环扫描日志并对日志进行压缩。首先,通过查看日志中第一部分的信息来查看其中存在哪些索引节点和文件。它会检查当前 inode 的映射来查看 inode 否在在当前块中,是否仍在被使用。如果不是,该信息将被丢弃。如果仍然在使用,那么 inode 和块就会进入内存等待写回到下一个段中。然后原来的段被标记为空闲,以便日志可以用来存放新的数据。用这种方法,清理线程遍历日志,从后面移走旧的段,然后将有效的数据放入内存等待写到下一个段中。由此一来整个磁盘会形成一个大的环形缓冲区
,写线程将新的段写在前面,而清理线程则清理后面的段。
日志文件系统
虽然日志结构系统的设计很优雅,但是由于它们和现有的文件系统不相匹配,因此还没有广泛使用。不过,从日志文件结构系统衍生出来一种新的日志系统,叫做日志文件系统
,它会记录系统下一步将要做什么的日志。微软的 NTFS
文件系统、Linux 的 ext3
就使用了此日志。 OS X
将日志系统作为可供选项。为了看清它是如何工作的,我们下面讨论一个例子,比如 移除文件
,这个操作在 UNIX 中需要三个步骤完成:
- 在目录中删除文件
- 释放 inode 到空闲 inode 池
- 将所有磁盘块归还给空闲磁盘池。
在 Windows 中,也存在类似的步骤。不存在系统崩溃时,这些步骤的执行顺序不会带来问题。但是一旦系统崩溃,就会带来问题。假如在第一步完成后系统崩溃。inode 和文件块将不会被任何文件获得,也不会再分配;它们只存在于废物池中的某个地方,并因此减少了可利用的资源。如果崩溃发生在第二步后,那么只有磁盘块会丢失。日志文件系统
保留磁盘写入期间对文件系统所做的更改的日志或日志,该日志可用于快速重建可能由于系统崩溃或断电等事件而发生的损坏。
一般文件系统崩溃后必须运行
fsck(文件系统一致性检查)
实用程序。
为了让日志能够正确工作,被写入的日志操作必须是 幂等的(idempotent)
,它意味着只要有必要,它们就可以重复执行很多次,并不会带来破坏。像操作 更新位表并标记 inode k 或者块 n 是空闲的 可以重复执行任意次。同样地,查找一个目录并且删除所有叫 foobar
的项也是幂等的。相反,把从 inode k 新释放的块加入空闲表的末端不是幂等的,因为它们可能已经被释放并存放在那里了。
为了增加可靠性,一个文件系统可以引入数据库中 原子事务(atomic transaction)
的概念。使用这个概念,一组动作可以被界定在开始事务和结束事务操作之间。这样,文件系统就会知道它必须完成所有的动作,要么就一个不做。
虚拟文件系统
即使在同一台计算机上或者在同一个操作系统下,都会使用很多不同的文件系统。Windows 中的主要文件系统是 NTFS 文件系统
,但不是说 Windows 只有 NTFS 操作系统,它还有一些其他的例如旧的 FAT -32
或 FAT -16
驱动器或分区,其中包含仍需要的数据,闪存驱动器,旧的 CD-ROM 或 DVD(每个都有自己的独特文件系统)。Windows 通过指定不同的盘符来处理这些不同的文件系统,比如 C:
,D:
等。盘符可以显示存在也可以隐式存在,如果你想找指定位置的文件,那么盘符是显示存在;如果当一个进程打开一个文件时,此时盘符是隐式存在,所以 Windows 知道向哪个文件系统传递请求。
相比之下,UNIX 采用了一种不同的方式,即 UNIX 把多种文件系统整合到一个统一的结构中。一个 Linux 系统可以使用 ext2
作为根文件系统,ext3
分区装载在 /usr
下,另一块采用 Reiser FS
文件系统的硬盘装载到 /home
下,以及一个 ISO 9660 的 CD – ROM 临时装载到 /mnt
下。从用户的观点来看,只有一个文件系统层级,但是事实上它们是由多个文件系统组合而成,对于用户和进程是不可见的。
UNIX 操作系统使用一种 虚拟文件系统(Virtual File System, VFS)
来尝试将多种文件系统构成一个有序的结构。关键的思想是抽象出所有文件系统都共有的部分,并将这部分代码放在一层,这一层再调用具体文件系统来管理数据。下面是一个 VFS 的系统结构
还是那句经典的话,在计算机世界中,任何解决不了的问题都可以加个代理
来解决。所有和文件相关的系统调用在最初的处理上都指向虚拟文件系统。这些来自用户进程的调用,都是标准的 POSIX 系统调用
,比如 open、read、write 和 seek 等。VFS 对用户进程有一个 上层
接口,这个接口就是著名的 POSIX 接口。
VFS 也有一个对于实际文件的 下层
接口,就是上图中标记为 VFS 的接口。这个接口包含许多功能调用,这样 VFS 可以使每一个文件系统完成任务。因此,要创建一个可以与 VFS 一起使用的新文件系统,新文件系统的设计者必须确保它提供了 VFS 要求的功能。一个明显的例子是从磁盘读取特定的块,然后将其放入文件系统的缓冲区高速缓存中,然后返回指向该块的指针的函数。 因此,VFS具有两个不同的接口:上一个到用户进程,下一个到具体文件系统。
当系统启动时,根文件系统在 VFS 中注册。另外,当装载其他文件时,不管在启动时还是在操作过程中,它们也必须在 VFS 中注册。当一个文件系统注册时,根文件系统注册到 VFS。另外,在引导时或操作期间挂载其他文件系统时,它们也必须向 VFS 注册。当文件系统注册时,其基本作用是提供 VFS 所需功能的地址列表、调用向量表、或者 VFS 对象。因此一旦文件系统注册到 VFS,它就知道从哪里开始读取数据块。
装载文件系统后就可以使用它了。比如,如果一个文件系统装载到 /usr
并且一个进程调用它:
open("/usr/include/unistd.h",O_RDONLY)
当解析路径时, VFS 看到新的文件系统被挂载到 /usr
,并且通过搜索已经装载文件系统的超级块来确定它的超块。然后它找到它所转载的文件的根目录,在那里查找路径 include/unistd.h
。然后 VFS 创建一个 vnode 并调用实际文件系统,以返回所有的在文件 inode 中的信息。这个信息和其他信息一起复制到 vnode (内存中)。而这些其他信息中最重要的是指向包含调用 vnode 操作的函数表的指针,比如 read、write 和 close 等。
当 vnode 被创建后,为了进程调用,VFS 在文件描述符表中创建一个表项,并将它指向新的 vnode,最后,VFS 向调用者返回文件描述符,所以调用者可以用它去 read、write 或者 close 文件。
当进程用文件描述符进行一个读操作时,VFS 通过进程表和文件描述符确定 vnode 的位置,并跟随指针指向函数表,这样就调用了处理 read 函数,运行在实际系统中的代码并得到所请求的块。VFS 不知道请求时来源于本地硬盘、还是来源于网络中的远程文件系统、CD-ROM 、USB 或者其他介质,所有相关的数据结构欧如下图所示
从调用者进程号和文件描述符开始,进而是 vnode,读函数指针,然后是对实际文件系统的访问函数定位。
文件系统的管理和优化
能够使文件系统工作是一回事,能够使文件系统高效、稳定的工作是另一回事,下面我们就来探讨一下文件系统的管理和优化。
磁盘空间管理
文件通常存在磁盘中,所以如何管理磁盘空间是一个操作系统的设计者需要考虑的问题。在文件上进行存有两种策略:分配 n 个字节的连续磁盘空间;或者把文件拆分成多个并不一定连续的块。在存储管理系统中,主要有分段管理
和 分页管理
两种方式。
正如我们所看到的,按连续字节序列
存储文件有一个明显的问题,当文件扩大时,有可能需要在磁盘上移动文件。内存中分段也有同样的问题。不同的是,相对于把文件从磁盘的一个位置移动到另一个位置,内存中段的移动操作要快很多。因此,几乎所有的文件系统都把文件分割成固定大小的块来存储。
块大小
一旦把文件分为固定大小的块来存储,就会出现问题,块的大小是多少?按照磁盘组织方式,扇区、磁道和柱面显然都可以作为分配单位。在分页系统中,分页大小也是主要因素。
拥有大的块尺寸意味着每个文件,甚至 1 字节文件,都要占用一个柱面空间,也就是说小文件浪费了大量的磁盘空间。另一方面,小块意味着大部分文件将会跨越多个块,因此需要多次搜索和旋转延迟才能读取它们,从而降低了性能。因此,如果分配的块太大
会浪费空间
;分配的块太小
会浪费时间
。
记录空闲块
一旦指定了块大小,下一个问题就是怎样跟踪空闲块。有两种方法被广泛采用,如下图所示
第一种方法是采用磁盘块链表
,链表的每个块中包含极可能多的空闲磁盘块号。对于 1 KB 的块和 32 位的磁盘块号,空闲表中每个块包含有 255 个空闲的块号。考虑 1 TB 的硬盘,拥有大概十亿个磁盘块。为了存储全部地址块号,如果每块可以保存 255 个块号,则需要将近 400 万个块。通常,空闲块用于保存空闲列表,因此存储基本上是空闲的。
另一种空闲空间管理的技术是位图(bitmap)
,n 个块的磁盘需要 n 位位图。在位图中,空闲块用 1 表示,已分配的块用 0 表示。对于 1 TB 硬盘的例子,需要 10 亿位表示,即需要大约 130 000 个 1 KB 块存储。很明显,和 32 位链表模型相比,位图需要的空间更少,因为每个块使用 1 位。只有当磁盘快满的时候,链表需要的块才会比位图少。
如果空闲块是长期连续的话,那么空闲列表可以改成记录连续分块而不是单个的块。每个块都会使用 8位、16位、32 位的计数来与每个块相联,来记录连续空闲块的数量。最好的情况是一个空闲块可以用两个数字来表示:第一个空闲块的地址和空闲块的计数。另一方面,如果磁盘严重碎片化,那么跟踪连续分块要比跟踪单个分块运行效率低,因为不仅要存储地址,还要存储数量。
这种情况说明了一个操作系统设计者经常遇到的一个问题。有许多数据结构和算法可以用来解决问题,但是选择一个
最好
的方案需要数据的支持,而这些数据是设计者无法预先拥有的。只有在系统部署完毕真正使用使用后才会获得。
现在,回到空闲链表的方法,只有一个指针块保存在内存中。创建文件时,所需要的块从指针块中取出。当它用完时,将从磁盘中读取一个新的指针块。类似地,删除文件时,文件的块将被释放并添加到主存中的指针块中。当块被填满时,写回磁盘。
在某些特定的情况下,这个方法导致了不必要的磁盘 IO,如下图所示
上面内存中的指针块仅有两个空闲块,如果释放了一个含有三个磁盘块的文件,那么该指针块就会溢出,必须将其写入磁盘,那么就会产生如下图的这种情况。
如果现在写入含有三个块的文件,已满的指针不得不再次读入,这将会回到上图 a 中的情况。如果有三个块的文件只是作为临时文件被写入,在释放它时,需要进行另一次磁盘写操作以将完整的指针块写回到磁盘。简而言之,当指针块几乎为空时,一系列短暂的临时文件可能会导致大量磁盘 I/O。
避免大部分磁盘 I/O 的另一种方法是拆分完整的指针块
。这样,当释放三个块时,变化不再是从 a – b,而是从 a – c,如下图所示
现在,系统可以处理一系列临时文件,而不需要进行任何磁盘 I/O。如果内存中指针块满了,就写入磁盘,半满的指针块从磁盘中读入。这里的思想是:要保持磁盘上的大多数指针块为满的状态(减少磁盘的使用),但是在内存中保留了一个半满的指针块。这样,就可以既处理文件的创建又同时可以处理文件的删除操作,而不会为空闲表进行磁盘 I/O。
对于位图,会在内存中只保留一个块,只有在该块满了或空了的情形下,才到磁盘上取另一个块。通过在位图的单一块上进行所有的分配操作,磁盘块会紧密的聚集在一起,从而减少了磁盘臂的移动
。由于位图是一种固定大小的数据结构,所以如果内核是分页
的,就可以把位图放在虚拟内存中,在需要时将位图的页面调入。
磁盘配额
为了防止一些用户占用太多的磁盘空间,多用户操作通常提供一种磁盘配额(enforcing disk quotas)
的机制。系统管理员为每个用户分配最大的文件和块分配,并且操作系统确保用户不会超过其配额。我们下面会谈到这一机制。
在用户打开一个文件时,操作系统会找到文件属性
和磁盘地址
,并把它们送入内存中的打开文件表。其中一个属性告诉文件所有者
是谁。任何有关文件的增加都会记到所有者的配额中。
第二张表包含了每个用户当前打开文件的配额记录,即使是其他人打开该文件也一样。如上图所示,该表的内容是从被打开文件的所有者的磁盘配额文件中提取出来的。当所有文件关闭时,该记录被写回配额文件。
当在打开文件表中建立一新表项时,会产生一个指向所有者配额记录的指针。每次向文件中添加一个块时,文件所有者所用数据块的总数也随之增加,并会同时增加硬限制
和软限制
的检查。可以超出软限制,但硬限制不可以超出。当已达到硬限制时,再往文件中添加内容将引发错误。同样,对文件数目也存在类似的检查。
什么是硬限制和软限制?硬限制是软限制的上限。软限制是为会话或进程实际执行的限制。这允许管理员(或用户)将硬限制设置为允许它们希望允许的最大使用上限。然后,其他用户和进程可以根据需要使用软限制将其资源使用量自限制到更低的上限。
当一个用户尝试登陆,系统将检查配额文件以查看用户是否超出了文件数量或磁盘块数量的软限制
。如果违反了任一限制,则会显示警告,保存的警告计数减 1,如果警告计数为 0 ,表示用户多次忽略该警告,因而将不允许该用户登录。要想再得到登录的许可,就必须与系统管理员协商。
如果用户在退出系统时消除所超过的部分,他们就可以再一次终端会话期间超过其软限制,但无论什么情况下都不会超过硬限制。
文件系统备份
文件系统的毁坏要比计算机的损坏严重很多。无论是硬件还是软件的故障,只要计算机文件系统被破坏,要恢复起来都是及其困难的,甚至是不可能的。因为文件系统无法抵御破坏,因而我们要在文件系统在被破坏之前做好数据备份
,但是备份也不是那么容易,下面我们就来探讨备份的过程。
许多人认为为文件系统做备份是不值得的,并且很浪费时间,直到有一天他们的磁盘坏了,他们才意识到事情的严重性。相对来说,公司在这方面做的就很到位。磁带备份主要要处理好以下两个潜在问题中的一个
- 从意外的灾难中恢复
这个问题主要是由于外部条件的原因造成的,比如磁盘破裂,水灾火灾等。
- 从错误的操作中恢复
第二个问题通常是由于用户意外的删除了原本需要还原的文件。这种情况发生的很频繁,使得 Windows 的设计者们针对 删除
命令专门设计了特殊目录,这就是 回收站(recycle bin)
,也就是说,在删除文件的时候,文件本身并不真正从磁盘上消失,而是被放置到这个特殊目录下,等以后需要的时候可以还原回去。文件备份更主要是指这种情况,能够允许几天之前,几周之前的文件从原来备份的磁盘进行还原。
做文件备份很耗费时间而且也很浪费空间,这会引起下面几个问题。首先,是要备份整个文件还是仅备份一部分呢?一般来说,只是备份特定目录及其下的全部文件,而不是备份整个文件系统。
其次,对上次未修改过的文件再进行备份是一种浪费,因而产生了一种增量转储(incremental dumps)
的思想。最简单的增量转储的形式就是周期性
的做全面的备份,而每天只对增量转储完成后发生变化的文件做单个备份。
周期性:比如一周或者一个月
稍微好一点的方式是只备份最近一次转储以来更改过的文件。当然,这种做法极大的缩减了转储时间,但恢复起来却更复杂,因为最近的全面转储先要全部恢复,随后按逆序进行增量转储。为了方便恢复,人们往往使用更复杂的转储模式。
第三,既然待转储的往往是海量数据,那么在将其写入磁带之前对文件进行压缩就很有必要。但是,如果在备份过程中出现了文件损坏的情况,就会导致破坏压缩算法,从而使整个磁带无法读取。所以在备份前是否进行文件压缩需慎重考虑。
第四,对正在使用的文件系统做备份是很难的。如果在转储过程中要添加,删除和修改文件和目录,则转储结果可能不一致。因此,因为转储过程中需要花费数个小时的时间,所以有必要在晚上将系统脱机进行备份,然而这种方式的接受程度并不高。所以,人们修改了转储算法,记下文件系统的瞬时快照
,即复制关键的数据结构,然后需要把将来对文件和目录所做的修改复制到块中,而不是到处更新他们。
磁盘转储到备份磁盘上有两种方案:物理转储和逻辑转储。物理转储(physical dump)
是从磁盘的 0 块开始,依次将所有磁盘块按照顺序写入到输出磁盘,并在复制最后一个磁盘时停止。这种程序的万无一失性是其他程序所不具备的。
第二个需要考虑的是坏块的转储。制造大型磁盘而没有瑕疵是不可能的,所以也会存在一些坏块(bad blocks)
。有时进行低级格式化后,坏块会被检测出来并进行标记,这种情况的解决办法是用磁盘末尾的一些空闲块所替换。
然而,一些块在格式化后会变坏,在这种情况下操作系统可以检测到它们。通常情况下,它可以通过创建一个由所有坏块组成的文件
来解决问题,确保它们不会出现在空闲池中并且永远不会被分配。那么此文件是完全不可读的。如果磁盘控制器将所有的坏块重新映射,物理转储还是能够正常工作的。
Windows 系统有分页文件(paging files)
和 休眠文件(hibernation files)
。它们在文件还原时不发挥作用,同时也不应该在第一时间进行备份。
物理转储和逻辑转储
物理转储的主要优点是简单、极为快速(基本上是以磁盘的速度运行),缺点是全量备份
,不能跳过指定目录,也不能增量转储,也不能恢复个人文件的请求。因此句大多数情况下不会使用物理转储,而使用逻辑转储。
逻辑转储(logical dump)
从一个或几个指定的目录开始,递归转储自指定日期开始后更改的文件和目录。因此,在逻辑转储中,转储磁盘上有一系列经过仔细识别的目录和文件,这使得根据请求轻松还原特定文件或目录。
既然逻辑转储是最常用的方式,那么下面就让我们研究一下逻辑转储的通用算法。此算法在 UNIX 系统上广为使用,如下图所示
待转储的文件系统,其中方框代表目录
,圆圈代表文件
。黄色的项目表是自上次转储以来修改过。每个目录和文件都被标上其 inode 号。
此算法会转储位于修改文件或目录路径上的所有目录(也包括未修改的目录),原因有两个。第一是能够在不同电脑的文件系统中恢复转储的文件。通过这种方式,转储和重新存储的程序能够用来在两个电脑之间传输整个文件系统
。第二个原因是能够对单个文件进行增量恢复
。
逻辑转储算法需要维持一个 inode 为索引的位图(bitmap)
,每个 inode 包含了几位。随着算法的进行,位图中的这些位会被设置或清除。算法的执行分成四个阶段。第一阶段从起始目录(本例为根目录)
开始检查其中所有的目录项。对每一个修改过的文件,该算法将在位图中标记其 inode。算法还会标记并递归检查每一个目录(不管是否修改过)。
在第一阶段结束时,所有修改过的文件和全部目录都在位图中标记了,如下图所示
理论上来说,第二阶段再次递归遍历目录树,并去掉目录树中任何不包含被修改过的文件或目录的标记。本阶段执行的结果如下
注意,inode 编号为 10、11、14、27、29 和 30 的目录已经被去掉了标记,因为它们所包含的内容没有修改
。它们也不会转储。相反,inode 编号为 5 和 6 的目录本身尽管没有被修改过也要被转储,因为在新的机器上恢复当日的修改时需要这些信息。为了提高算法效率,可以将这两阶段的目录树遍历合二为一。
现在已经知道了哪些目录和文件必须被转储了,这就是上图 b 中标记的内容,第三阶段算法将以节点号为序,扫描这些 inode 并转储所有标记为需转储的目录,如下图所示
为了进行恢复,每个被转储的目录都用目录的属性(所有者、时间)作为前缀。
最后,在第四阶段,上图中被标记的文件也被转储,同样,由其文件属性作为前缀。至此,转储结束。
从转储磁盘上还原文件系统非常简单。一开始,需要在磁盘上创建空文件系统。然后恢复最近一次的完整转储。由于磁带上最先出现目录,所以首先恢复目录,给出文件系统的框架(skeleton)
,然后恢复文件系统本身。在完整存储之后是第一次增量存储,然后是第二次重复这一过程,以此类推。
尽管逻辑存储十分简单,但是也会有一些棘手的问题。首先,既然空闲块列表并不是一个文件,那么在所有被转储的文件恢复完毕之后,就需要从零开始重新构造。
另外一个问题是关于链接
。如果文件链接了两个或者多个目录,而文件只能还原一次,那么并且所有指向该文件的目录都必须还原。
还有一个问题是,UNIX 文件实际上包含了许多 空洞(holes)
。打开文件,写几个字节,然后找到文件中偏移了一定距离的地址,又写入更多的字节,这么做是合法的。但两者之间的这些块并不属于文件本身,从而也不应该在其上进行文件转储和恢复。
最后,无论属于哪一个目录,特殊文件,命名管道以及类似的文件都不应该被转储。
文件系统的一致性
影响可靠性的一个因素是文件系统的一致性。许多文件系统读取磁盘块、修改磁盘块、再把它们写回磁盘。如果系统在所有块写入之前崩溃,文件系统就会处于一种不一致(inconsistent)
的状态。如果某些尚未写回的块是索引节点块,目录块或包含空闲列表的块,则此问题是很严重的。
为了处理文件系统一致性问题,大部分计算机都会有应用程序来检查文件系统的一致性。例如,UNIX 有 fsck
;Windows 有 sfc
,每当引导系统时(尤其是在崩溃后),都可以运行该程序。
可以进行两种一致性检查:块的一致性检查和文件的一致性检查。为了检查块的一致性,应用程序会建立两张表,每个包含一个计数器的块,最初设置为 0 。第一个表中的计数器跟踪该块在文件中出现的次数,第二张表中的计数器记录每个块在空闲列表、空闲位图中出现的频率。
然后检验程序使用原始设备读取所有的 inode,忽略文件的结构,只返回从零开始的所有磁盘块。从 inode 开始,很容易找到文件中的块数量。每当读取一个块时,该块在第一个表中的计数器 + 1,应用程序会检查空闲块或者位图来找到没有使用的块。空闲列表中块的每次出现都会导致其在第二表中的计数器增加。
如果文件系统一致,则每一个块或者在第一个表计数器为 1,或者在第二个表计数器中为 1,如下图所示
但是当系统崩溃后,这两张表可能如下所示
其中,磁盘块 2 没有出现在任何一张表中,这称为 块丢失(missing block)
。尽管块丢失不会造成实际的损害,但它的确浪费了磁盘空间,减少了磁盘容量。块丢失的问题很容易解决,文件系统检验程序把他们加到空闲表中即可。
有可能出现的另外一种情况如下所示
其中,块 4 在空闲表中出现了 2 次。这种解决方法也很简单,只要重新建立空闲表即可。
最糟糕的情况是在两个或者多个文件中出现同一个数据块,如下所示
比如上图的磁盘块 5,如果其中一个文件被删除,块 5 会被添加到空闲表中,导致一个块同时处于使用和空闲的两种状态。如果删除这两个文件,那么在空闲表中这个磁盘块会出现两次。
文件系统检验程序采取的处理方法是,先分配一磁盘块,把块 5 中的内容复制到空闲块中,然后把它插入到其中一个文件中。这样文件的内容未改变,虽然这些内容可以肯定是不对的,但至少保证了文件的一致性。这一错误应该报告给用户,由用户检查受检情况。
除了检查每个磁盘块计数的正确性之外,文件系统还会检查目录系统。这时候会用到一张计数器表
,但这时是一个文件(而不是一个块)对应于一个计数器。程序从根目录开始检验,沿着目录树向下查找,检查文件系统的每个目录。对每个目录中的文件,使其计数 + 1。
注意,由于存在硬连接,一个文件可能出现在两个或多个目录中。而遇到符号链接是不计数的,不会对目标文件的计数器 + 1。
在检验程序完成后,会得到一张由 inode 索引的表,说明每个文件和目录的包含关系。检验程序会将这些数字与存储在文件 inode 中的链接数目做对比。如果 inode 节点的链接计数大户目录项个数,这时即使所有文件从目录中删除,这个计数仍然不是 0 ,inode 不会被删除。这种错误不严重,却因为存在不属于任何目录的文件而浪费了磁盘空间。
另一种错误则是潜在的风险。如果同一个文件链接两个目录项,但是 inode 链接计数只为 1,如果删除了任何一个目录项,对应 inode 链接计数变为 0。当 inode 计数为 0 时,文件系统标志 inode 为 未使用
,并释放全部的块。这会导致其中一个目录指向一未使用的 inode,而很有可能其块马上就被分配给其他文件。
文件系统性能
访问磁盘的效率要比内存满的多,是时候又祭出这张图了
从内存读一个 32 位字大概是 10ns,从硬盘上读的速率大概是 100MB/S,对每个 32 位字来说,效率会慢了四倍,另外,还要加上 5 – 10 ms 的寻道时间等其他损耗,如果只访问一个字,内存要比磁盘快百万数量级。所以磁盘优化是很有必要的,下面我们会讨论几种优化方式
高速缓存
最常用的减少磁盘访问次数的技术是使用 块高速缓存(block cache)
或者 缓冲区高速缓存(buffer cache)
。高速缓存指的是一系列的块,它们在逻辑上属于磁盘,但实际上基于性能的考虑被保存在内存中。
管理高速缓存有不同的算法,常用的算法是:检查全部的读请求,查看在高速缓存中是否有所需要的块。如果存在,可执行读操作而无须访问磁盘。如果检查块不再高速缓存中,那么首先把它读入高速缓存,再复制到所需的地方。之后,对同一个块的请求都通过高速缓存
来完成。
高速缓存的操作如下图所示
由于在高速缓存中有许多块,所以需要某种方法快速确定所需的块是否存在。常用方法是将设备和磁盘地址进行散列操作,然后,在散列表中查找结果。具有相同散列值的块在一个链表中连接在一起(这个数据结构是不是很像 HashMap?),这样就可以沿着冲突链查找其他块。
如果高速缓存已满
,此时需要调入新的块,则要把原来的某一块调出高速缓存,如果要调出的块在上次调入后已经被修改过,则需要把它写回磁盘。这种情况与分页非常相似,所有常用的页面置换算法我们之前已经介绍过,如果有不熟悉的小伙伴可以参考 https://mp.weixin.qq.com/s/5-k2BJDgEp9symxcSwoprw。比如 FIFO 算法、第二次机会算法、LRU 算法、时钟算法、老化算法等。它们都适用于高速缓存。
块提前读
第二个明显提高文件系统的性能是,在需要用到块之前,试图提前
将其写入高速缓存,从而提高命中率
。许多文件都是顺序读取。如果请求文件系统在某个文件中生成块 k,文件系统执行相关操作并且在完成之后,会检查高速缓存,以便确定块 k + 1 是否已经在高速缓存。如果不在,文件系统会为 k + 1 安排一个预读取,因为文件希望在用到该块的时候能够直接从高速缓存中读取。
当然,块提前读取策略只适用于实际顺序读取的文件。对随机访问的文件,提前读丝毫不起作用。甚至还会造成阻碍。
减少磁盘臂运动
高速缓存和块提前读并不是提高文件系统性能的唯一方法。另一种重要的技术是把有可能顺序访问的块放在一起,当然最好是在同一个柱面上,从而减少磁盘臂的移动次数。当写一个输出文件时,文件系统就必须按照要求一次一次地分配磁盘块。如果用位图来记录空闲块,并且整个位图在内存中,那么选择与前一块最近的空闲块是很容易的。如果用空闲表,并且链表的一部分存在磁盘上,要分配紧邻的空闲块就会困难很多。
不过,即使采用空闲表,也可以使用 块簇
技术。即不用块而用连续块簇来跟踪磁盘存储区。如果一个扇区有 512 个字节,有可能系统采用 1 KB 的块(2 个扇区),但却按每 2 块(4 个扇区)一个单位来分配磁盘存储区。这和 2 KB 的磁盘块并不相同,因为在高速缓存中它仍然使用 1 KB 的块,磁盘与内存数据之间传送也是以 1 KB 进行,但在一个空闲的系统上顺序读取这些文件,寻道的次数可以减少一半,从而使文件系统的性能大大改善。若考虑旋转定位则可以得到这类方法的变体。在分配块时,系统尽量把一个文件中的连续块存放在同一个柱面上。
在使用 inode 或任何类似 inode 的系统中,另一个性能瓶颈是,读取一个很短的文件也需要两次磁盘访问:一次是访问 inode,一次是访问块。通常情况下,inode 的放置如下图所示
其中,全部 inode 放在靠近磁盘开始位置,所以 inode 和它所指向的块之间的平均距离是柱面组的一半,这将会需要较长时间的寻道时间。
一个简单的改进方法是,在磁盘中部而不是开始处存放 inode ,此时,在 inode 和第一个块之间的寻道时间减为原来的一半。另一种做法是:将磁盘分成多个柱面组,每个柱面组有自己的 inode,数据块和空闲表,如上图 b 所示。
当然,只有在磁盘中装有磁盘臂的情况下,讨论寻道时间和旋转时间才是有意义的。现在越来越多的电脑使用 固态硬盘(SSD)
,对于这些硬盘,由于采用了和闪存同样的制造技术,使得随机访问和顺序访问在传输速度上已经较为相近,传统硬盘的许多问题就消失了。但是也引发了新的问题。
磁盘碎片整理
在初始安装操作系统后,文件就会被不断的创建和清除,于是磁盘会产生很多的碎片,在创建一个文件时,它使用的块会散布在整个磁盘上,降低性能。删除文件后,回收磁盘块,可能会造成空穴。
磁盘性能可以通过如下方式恢复:移动文件使它们相互挨着,并把所有的至少是大部分的空闲空间放在一个或多个大的连续区域内。Windows 有一个程序 defrag
就是做这个事儿的。Windows 用户会经常使用它,SSD 除外。
磁盘碎片整理程序会在让文件系统上很好地运行。Linux 文件系统(特别是 ext2 和 ext3)由于其选择磁盘块的方式,在磁盘碎片整理上一般不会像 Windows 一样困难,因此很少需要手动的磁盘碎片整理。而且,固态硬盘并不受磁盘碎片的影响,事实上,在固态硬盘上做磁盘碎片整理反倒是多此一举,不仅没有提高性能,反而磨损了固态硬盘。所以碎片整理只会缩短固态硬盘的寿命。
相关参考:
https://zhuanlan.zhihu.com/p/41358013
https://www.linuxtoday.com/blog/what-is-an-inode.html
https://www.lifewire.com/what-is-fragmentation-defragmentation-2625884
https://www.geeksforgeeks.org/free-space-management-in-operating-system/
https://en.wikipedia.org/wiki/Disk_partitioning
https://en.wikipedia.org/wiki/Master_boot_record
https://en.wikipedia.org/wiki/Booting
https://www.computerhope.com/jargon/f/fileprot.htm
https://en.wikipedia.org/wiki/File_attribute
https://en.wikipedia.org/wiki/Make_(software
https://www.computerhope.com/jargon/d/director.htm
https://www.computerhope.com/jargon/r/regular-file.htm
https://baike.baidu.com/item/固态硬盘/453510?fr=aladdin
《现代操作系统》第四版
《Modern Operation System》fourth
操作系统网站推荐
一般很少有人推荐操作系统的网站吧。。。。。。这几个网站来源于我平常的学习总结,也有一些是来源于网上优秀的回答,希望这几个网站能够助力你对操作系统有更深的认识。
studytonight
studytonight 简直太棒了!!! studytonight 会包括 operationg system
,但是并不是说 studytonight 就是一个单纯的 OS 学习网站,它是一个基础教程网,它的首页是这样的,不仅限于 os ,还会包括 Java、C、CSS、OS、Computer network 等。
这个页面一看就爱上了,和国内很多页面做的硬风格不同,这样的教程才不那么生硬和让人讨厌。在所有的素材库里面,有各种各样的教程
我们推荐的是操作系统,所以索性就点进去操作系统主页好了,点进去的页面就是这样的
studytonight 是一个对初学者来说学习操作系统的一个很友好的网站。
课程艺术主要分为三部分,由易到难分别是基础篇、中等篇、提高篇,我们点进去基础篇的第一篇
这一篇主要是对操作系统的介绍,操作系统的功能、操作系统任务等
通过简单的描述和图片来说明,让人感觉清晰易懂,很有目的性的学习,而且这些主题也可以作为面试题来考
你还真说对了,studytonight 还真的就有面试题
我们在 Test 页签下面会发现有很多面试题
我们选择 Operating System 进去之后会发现有很多的测试
是一个一体化的学习平台,适用于新手,我大致翻看了一下,里面的东西比较基础,受众广,是大家学习必备的一个网站。
udacity
udacity 是一个视频学习网站,界面看起来很清爽。
下面还有关于课程的介绍,同时你可以开始免费的课程,这个界面就是中文版了,让人眼前一亮
我的课程
就是你学过的所有课程,你可以在所有课程中搜索指定的课程,种类非常多
我们还是以操作系统为例,来看一下具体的课程
左面是具体的课程列表,右面是讲师授课部分,视频非常清晰,而且讲师吐字很清楚,可以锻炼英语。有一些小伙伴们说英文看不懂,对英文网而退却,这不是一个好习惯。英文在编程世界中的重要性不言而喻。所以英文是你一定要学好的一门语言。
讲师讲完课程之后还有具体的练习环节,作为学习巩固,非常不错。
udacity 的课程要比 studytonight 更深一些,操作系统这门课就分成了 22 堂课,每一堂课还有很多小节,真是一个非常好的网站。我现在在写文章,我巴不得不写字了,马上学习!。 还是忍住了,那就写完了马上去看!!!
tutorialspoint
tutorialspoint 这个网站也是一个资源教程网,这个网站的搜索指数非常高,基本上搜任何问题都有 tutorialspoint 的解释。
我们点进去 Library 之后发现,这就是教程宝库啊,罗列的非常清楚
tutorialspoint 也有视频教程,不过大多都收费。
教程非常多,不仅限于编程,也包含 Excel 、SAP 等教程。
tutorialspoint 还有电子书教程,不过大部分也是收费的,应该是 tutorialspoint 自己制作的 PDF,不知道写的怎么样,如果有兴趣的小伙伴可以付费下载,到时候记得给我发一份哦,嘿嘿。
说了这么多,我们还没有看 tutorialspoint 操作系统的主页呢
左边是导航栏,右边是具体的教程,这个样式感觉有些老,不过分类倒是很明确的,里面的内容要比 studytonight 差一些,也是一个入门非常好的教程网
classcentral
classcentral 这个网站有点强啊,里面汇总了各大名校的教程
我们发现了一个中文教程,一看是 南京大学
的教程,南京大学也是非常牛逼的一所学校,小编也有南大的基友在搞影视,其实我是一个被编程耽误的导演,狗头保命。
不扯皮了,来看看南大的课程
这么多牛逼的课程发现了中文大学,也侧面说明了我国大学在国际上的地位。Overview
是 OS 的总体介绍。
我们注册后点击开始课程,就进入讲课页面
课程分为 6 周,以视频的形式展开,我们开始 计算机系统概览
。
终于能看到国人讲的操作系统了!!!
右边还可以随时做笔记进行查看,课程还支持 下载
功能,非常不错!
好了我们该退出来了,一会儿又忍不住听课了!
nptel
仿佛见到了一个不得了的网站,这个网站的名字就隐隐的感觉有点牛逼。我们先看牛逼在哪,然后再看为什么牛逼
你能感觉出来牛逼了吗?
这每一个目录都 TM 是一本书啊,虽然页数不多,但这确确实实是宝贝啊。
从操作系统概述到文件系统、进程管理、文件管理、I/O 管理等等等等。这还不是最牛逼的,最牛逼的是右边能免费 下载
,你说气人不气人
每一章节都支持下载 PDF 版本。
我们带着好奇心,点开了 About Us
。
然后就看到了下面这段话
也就是说,这是一个 印度的国家增强型学习计划,也就是说,印度的各大名校联合推出的免费
学习计划,这也能理解为什么印度人在电气、编程、通信方面能够越来越牛逼了。
这个网站你一定要看,我认为可以和黑皮书系列平起平坐了。
codescracker
codescracker 又是一个牛逼的网站,看完这个网站,我才觉得低调才是最牛逼的炫耀。。。
分类都非常全,解释的也很到位,但是 codescracker
并不只是一个 os 网站,它是一门编程语言的学习一站式平台,只不过网站比较简洁
可以看到有各种各样的教程,下面还是有测试类的,基本上企业用的语言都涵盖到了。
你必须要珍藏的一个网站。
sciencedirect
sciencedirect 这个网站真是太牛逼了。百度对 sciencedirect 的解释
不过 sciencedirect 大部分的 PDF 下载是需要收费的,感兴趣的小伙伴们可以购买
homepage
Homepage 是做什么的,一看主页就明白了
这是一个计算机科学和统计学的网站,然后我们点击 search 搜索 os 直接跳转到 os 的网站
然后就呵呵呵呵了,这个网站比较奇葩,它没有后退和前进的按钮,那怎么访问?
我是直接通过 url 访问的,主页就是 https://homepage.cs.uri.edu/faculty/wolfe/book/Readings/Reading01.htm
但是网站太硬核了,很好的一个学习资料哦。
computer.howstuffworks.com
computer.howstuffworks.com 就是一个计算机网站,它涵盖软件和硬件的方方面面。
Computer 知识包括 **计算机硬件、网络、计算机软件、计算机秘密等等
都是一些讲的非常好的文章,可以说是计算机的百科全书了。
老样子我们点开 COMPUTER OPERATING SYSTEMS 专题,可以看到都是一些非常好的文章
加入收藏夹!
tldp.org
这个网站又是一个学习操作系统非常牛逼的网站,我都不知道今晚上说了多少次了,哈哈哈哈。
来看看主页是啥样的
这一看就是一个牛逼的网站,不要问我为什么,OS 的页面是这样的
没有任何点缀和装饰,成年人的生活就这么朴实无华。
操作系统之死锁
前言
计算机系统中有很多独占性
的资源,在同一时刻只能每个资源只能由一个进程使用,我们之前经常提到过打印机,这就是一个独占性的资源,同一时刻不能有两个打印机同时输出结果,否则会引起文件系统的瘫痪。所以,操作系统具有授权一个进程单独访问资源的能力。
两个进程独占性的访问某个资源,从而等待另外一个资源的执行结果,会导致两个进程都被阻塞,并且两个进程都不会释放各自的资源,这种情况就是 死锁(deadlock)
。
死锁可以发生在任何层面,在不同的机器之间可能会发生死锁,在数据库系统中也会导致死锁,比如进程 A 对记录 R1 加锁,进程 B 对记录 R2 加锁,然后进程 A 和 B 都试图把对象的记录加锁,这种情况下就会产生死锁。
下面我们就来讨论一下什么是死锁、死锁的条件是什么、死锁如何预防、活锁是什么等。
首先你需要先了解一个概念,那就是资源是什么
资源
大部分的死锁都和资源有关,在进程对设备、文件具有独占性(排他性)时会产生死锁。我们把这类需要排他性使用的对象称为资源(resource)
。资源主要分为 **可抢占资源和不可抢占资源
可抢占资源和不可抢占资源
资源主要有可抢占资源和不可抢占资源。可抢占资源(preemptable resource)
可以从拥有它的进程中抢占而不会造成其他影响,内存就是一种可抢占性资源,任何进程都能够抢先获得内存的使用权。
不可抢占资源(nonpreemtable resource)
指的是除非引起错误或者异常,否则进程无法抢占指定资源,这种不可抢占的资源比如有光盘,在进程执行调度的过程中,其他进程是不能得到该资源的。
死锁与不可抢占资源有关,虽然抢占式资源也会造成死锁,不过这种情况的解决办法通常是在进程之间重新分配资源来化解。所以,我们的重点自然就会放在了不可抢占资源上。
下面给出了使用资源所需事件的抽象顺序
如果在请求时资源不存在,请求进程就会强制等待。在某些操作系统中,当请求资源失败时进程会自动阻塞,当自资源可以获取时进程会自动唤醒。在另外一些操作系统中,请求资源失败并显示错误代码,然后等待进程等待一会儿再继续重试。
请求资源失败的进程会陷入一种请求资源、休眠、再请求资源的循环中。此类进程虽然没有阻塞,但是处于从目的和结果考虑,这类进程和阻塞差不多,因为这类进程并没有做任何有用的工作。
请求资源的这个过程是很依赖操作系统的。在一些系统中,一个 request
系统调用用来允许进程访问资源。在一些系统中,操作系统对资源的认知是它是一种特殊文件,在任何同一时刻只能被一个进程打开和占用。资源通过 open
命令进行打开。如果文件已经正在使用,那么这个调用者会阻塞直到当前的占用文件的进程关闭文件为止。
资源获取
对于一些数据库系统中的记录这类资源来说,应该由用户进程来对其进行管理。有一种管理方式是使用信号量(semaphore)
。这些信号量会初始化为 1 。互斥锁也能够起到相同的作用。
这里说一下什么是
互斥锁(Mutexes)
:在计算机程序中,
互斥对象(mutex)
是一个程序对象,它允许多个程序共享同一资源,例如文件访问权限,但并不是同时访问。需要锁定资源的线程都必须在使用资源时将互斥锁与其他线程绑定(进行加锁)。当不再需要数据或线程结束时,互斥锁设置为解锁。
下面是一个伪代码,这部分代码说明了信号量的资源获取、资源释放等操作,如下所示
typedef int semaphore;
semaphore aResource;
void processA(void){
down(&aResource);
useResource();
up(&aResource);
}
上面显示了一个进程资源获取和释放的过程,但是一般情况下会存在多个资源同时获取锁的情景,这样该如何处理?如下所示
typedef int semaphore;
semaphore aResource;
semaphore bResource;
void processA(void){
down(&aResource);
down(&bResource);
useAResource();
useBResource();
up(&aResource);
up(&bResource);
}
对于单个进程来说,并不需要加锁,因为不存在和这个进程的竞争条件。所以单进条件下程序能够完好运行。
现在让我们考虑两个进程的情况,A 和 B ,还存在两个资源。如下所示
typedef int semaphore;
semaphore aResource;
semaphore bResource;
void processA(void){
down(&aResource);
down(&bResource);
useBothResource();
up(&bResource);
up(&aResource);
}
void processB(void){
down(&aResource);
down(&bResource);
useBothResource();
up(&bResource);
up(&aResource);
}
在上述代码中,两个进程以相同的顺序访问资源。在这段代码中,一个进程在另一个进程之前获取资源,如果另外一个进程想在第一个进程释放之前获取资源,那么它会由于资源的加锁而阻塞,直到该资源可用为止。
在下面这段代码中,有一些变化
typedef int semaphore;
semaphore aResource;
semaphore bResource;
void processA(void){
down(&aResource);
down(&bResource);
useBothResource();
up(&bResource);
up(&aResource);
}
void processB(void){
down(&bResource); // 变化的代码
down(&aResource); // 变化的代码
useBothResource();
up(&aResource); // 变化的代码
up(&bResource); // 变化的代码
}
这种情况就不同了,可能会发生同时获取两个资源并有效地阻塞另一个过程,直到完成为止。也就是说,可能会发生进程 A 获取资源 A 的同时进程 B 获取资源 B 的情况。然后每个进程在尝试获取另一个资源时被阻塞。
在这里我们会发现一个简单的获取资源顺序的问题就会造成死锁
,所以死锁是很容易发生的,所以下面我们就对死锁做一个详细的认识和介绍。
死锁
如果要对死锁进行一个定义的话,下面的定义比较贴切
如果一组进程中的每个进程都在等待一个事件,而这个事件只能由该组中的另一个进程触发,这种情况会导致死锁。
简单一点来表述一下,就是每个进程都在等待其他进程释放资源,而其他资源也在等待每个进程释放资源,这样没有进程抢先释放自己的资源,这种情况会产生死锁,所有进程都会无限的等待下去。
换句话说,死锁进程结合中的每个进程都在等待另一个死锁进程已经占有的资源。但是由于所有进程都不能运行,它们之中任何一个资源都无法释放资源,所以没有一个进程可以被唤醒。这种死锁也被称为资源死锁(resource deadlock)
。资源死锁是最常见的类型,但不是所有的类型,我们后面会介绍其他类型,我们先来介绍资源死锁
资源死锁的条件
针对我们上面的描述,资源死锁可能出现的情况主要有
- 互斥条件:每个资源都被分配给了一个进程或者资源是可用的
- 保持和等待条件:已经获取资源的进程被认为能够获取新的资源
- 不可抢占条件:分配给一个进程的资源不能强制的从其他进程抢占资源,它只能由占有它的进程显示释放
- 循环等待:死锁发生时,系统中一定有两个或者两个以上的进程组成一个循环,循环中的每个进程都在等待下一个进程释放的资源。
发生死锁时,上面的情况必须同时会发生。如果其中任意一个条件不会成立,死锁就不会发生。可以通过破坏其中任意一个条件来破坏死锁,下面这些破坏条件就是我们探讨的重点
死锁模型
Holt 在 1972 年提出对死锁进行建模,建模的标准如下:
- 圆形表示进程
- 方形表示资源
从资源节点到进程节点表示资源已经被进程占用,如下图所示
在上图中表示当前资源 R 正在被 A 进程所占用
由进程节点到资源节点的有向图表示当前进程正在请求资源,并且该进程已经被阻塞,处于等待这个资源的状态
在上图中,表示的含义是进程 B 正在请求资源 S 。Holt 认为,死锁的描述应该如下
这是一个死锁的过程,进程 C 等待资源 T 的释放,资源 T 却已经被进程 D 占用,进程 D 等待请求占用资源 U ,资源 U 却已经被线程 C 占用,从而形成环。
总结一点:**吃着碗里的看着锅里的容易死锁
那么如何避免死锁呢?我们还是通过死锁模型来聊一聊
假设有三个进程 (A、B、C) 和三个资源(R、S、T) 。三个进程对资源的请求和释放序列如下图所示
操作系统可以任意选择一个非阻塞的程序运行,所以它可以决定运行 A 直到 A 完成工作;它可以运行 B 直到 B 完成工作;最后运行 C。
这样的顺序不会导致死锁(因为不存在对资源的竞争),但是这种情况也完全没有并行性
。进程除了在请求和释放资源外,还要做计算和输入/输出的工作。当进程按照顺序运行时,在等待一个 I/O 时,另一个进程不能使用 CPU。所以,严格按照串行的顺序执行并不是最优越的。另一方面,如果没有进程在执行任何 I/O 操作,那么最短路径优先作业会优于轮转调度,所以在这种情况下串行可能是最优越的
现在我们假设进程会执行计算和 I/O 操作,所以轮询调度是一种合理的调度算法
。资源请求可能会按照下面这个顺序进行
下图是针对上面这六个步骤的资源分配图。
这里需要注意一个问题,为什么从资源出来的有向图指向了进程却表示进程请求资源呢?笔者刚开始看也有这个疑问,但是想了一下这个意思解释为进程占用资源比较合适,而进程的有向图指向资源表示进程被阻塞的意思。
在上面的第四个步骤,进程 A 正在等待资源 S;第五个步骤中,进程 B 在等待资源 T;第六个步骤中,进程 C 在等待资源 R,因此产生了环路并导致了死锁。
然而,操作系统并没有规定一定按照某种特定的顺序来执行这些进程。遇到一个可能会引起死锁的线程后,操作系统可以干脆不批准请求,并把进程挂起一直到安全状态为止。比如上图中,如果操作系统认为有死锁的可能,它可以选择不把资源 S 分配给 B ,这样 B 被挂起。这样的话操作系统会只运行 A 和 C,那么资源的请求和释放就会是下面的步骤
下图是针对上面这六个步骤的资源分配图。
在第六步执行完成后,可以发现并没有产生死锁,此时就可以把资源 S 分配给 B,因为 A 进程已经执行完毕,C 进程已经拿到了它想要的资源。进程 B 可以直接获得资源 S,也可以等待进程 C 释放资源 T 。
有四种处理死锁的策略:
- 忽略死锁带来的影响(惊呆了)
- 检测死锁并回复死锁,死锁发生时对其进行检测,一旦发生死锁后,采取行动解决问题
- 通过仔细分配资源来避免死锁
- 通过破坏死锁产生的四个条件之一来避免死锁
下面我们分别介绍一下这四种方法
鸵鸟算法
最简单的解决办法就是使用鸵鸟算法(ostrich algorithm)
,把头埋在沙子里,假装问题根本没有发生。每个人看待这个问题的反应都不同。数学家认为死锁是不可接受的,必须通过有效的策略来防止死锁的产生。工程师想要知道问题发生的频次,系统因为其他原因崩溃的次数和死锁带来的严重后果。如果死锁发生的频次很低,而经常会由于硬件故障、编译器错误等其他操作系统问题导致系统崩溃,那么大多数工程师不会修复死锁。
死锁检测和恢复
第二种技术是死锁的检测和恢复。这种解决方式不会尝试去阻止死锁的出现。相反,这种解决方案会希望死锁尽可能的出现,在监测到死锁出现后,对其进行恢复。下面我们就来探讨一下死锁的检测和恢复的几种方式
每种类型一个资源的死锁检测方式
每种资源类型都有一个资源是什么意思?我们经常提到的打印机就是这样的,资源只有打印机,但是设备都不会超过一个。
可以通过构造一张资源分配表来检测这种错误,比如我们上面提到的
的算法来检测从 P1 到 Pn 这 n 个进程中的死锁。假设资源类型为 m,E1 代表资源类型1,E2 表示资源类型 2 ,Ei 代表资源类型 i (1 <= i <= m)。E 表示的是 现有资源向量(existing resource vector)
,代表每种已存在的资源总数。
现在我们就需要构造两个数组:C 表示的是当前分配矩阵(current allocation matrix)
,R 表示的是 请求矩阵(request matrix)
。Ci 表示的是 Pi 持有每一种类型资源的资源数。所以,Cij 表示 Pi 持有资源 j 的数量。Rij 表示 Pi 所需要获得的资源 j 的数量
一般来说,已分配资源 j 的数量加起来再和所有可供使用的资源数相加 = 该类资源的总数。
死锁的检测就是基于向量的比较。每个进程起初都是没有被标记过的,算法会开始对进程做标记,进程被标记后说明进程被执行了,不会进入死锁,当算法结束时,任何没有被标记过的进程都会被判定为死锁进程。
上面我们探讨了两种检测死锁的方式,那么现在你知道怎么检测后,你何时去做死锁检测呢?一般来说,有两个考量标准:
- 每当有资源请求时就去检测,这种方式会占用昂贵的 CPU 时间。
- 每隔 k 分钟检测一次,或者当 CPU 使用率降低到某个标准下去检测。考虑到 CPU 效率的原因,如果死锁进程达到一定数量,就没有多少进程可以运行,所以 CPU 会经常空闲。
从死锁中恢复
上面我们探讨了如何检测进程死锁,我们最终的目的肯定是想让程序能够正常的运行下去,所以针对检测出来的死锁,我们要对其进行恢复,下面我们会探讨几种死锁的恢复方式
通过抢占进行恢复
在某些情况下,可能会临时将某个资源从它的持有者转移到另一个进程。比如在不通知原进程的情况下,将某个资源从进程中强制取走给其他进程使用,使用完后又送回。这种恢复方式一般比较困难而且有些简单粗暴,并不可取。
通过回滚进行恢复
如果系统设计者和机器操作员知道有可能发生死锁,那么就可以定期检查流程。进程的检测点意味着进程的状态可以被写入到文件以便后面进行恢复。检测点不仅包含存储映像(memory image)
,还包含资源状态(resource state)
。一种更有效的解决方式是不要覆盖原有的检测点,而是每出现一个检测点都要把它写入到文件中,这样当进程执行时,就会有一系列的检查点文件被累积起来。
为了进行恢复,要从上一个较早的检查点上开始,这样所需要资源的进程会回滚到上一个时间点,在这个时间点上,死锁进程还没有获取所需要的资源,可以在此时对其进行资源分配。
杀死进程恢复
最简单有效的解决方案是直接杀死一个死锁进程。但是杀死一个进程可能照样行不通,这时候就需要杀死别的资源进行恢复。
另外一种方式是选择一个环外的进程作为牺牲品来释放进程资源。
死锁避免
我们上面讨论的是如何检测出现死锁和如何恢复死锁,下面我们探讨几种规避死锁的方式
单个资源的银行家算法
银行家算法是 Dijkstra 在 1965 年提出的一种调度算法,它本身是一种死锁的调度算法。它的模型是基于一个城镇中的银行家,银行家向城镇中的客户承诺了一定数量的贷款额度。算法要做的就是判断请求是否会进入一种不安全的状态。如果是,就拒绝请求,如果请求后系统是安全的,就接受该请求。
比如下面的例子,银行家一共为所有城镇居民提供了 15 单位个贷款额度,一个单位表示 1k 美元,如下所示
城镇居民都喜欢做生意,所以就会涉及到贷款,每个人能贷款的最大额度不一样,在某一时刻,A/B/C/D 的贷款金额如下
上面每个人的贷款总额加起来是 13,马上接近 15,银行家只能给 A 和 C 进行放贷,可以拖着 B 和 D、所以,可以让 A 和 C 首先完成,释放贷款额度,以此来满足其他居民的贷款。这是一种安全
的状态。
如果每个人的请求导致总额会超过甚至接近 15 ,就会处于一种不安全的状态,如下所示
这样,每个人还能贷款至少 2 个单位的额度,如果其中有一个人发起最大额度的贷款请求,就会使系统处于一种死锁状态。
这里注意一点:不安全状态并不一定引起死锁,由于客户不一定需要其最大的贷款额度,但是银行家不敢抱着这种侥幸心理。
银行家算法就是对每个请求进行检查,检查是否请求会引起不安全状态,如果不会引起,那么就接受该请求;如果会引起,那么就推迟该请求。
类似的,还有多个资源的银行家算法,读者可以自行了解。
破坏死锁
死锁本质上是无法避免的,因为它需要获得未知的资源和请求,但是死锁是满足四个条件后才出现的,它们分别是
- 互斥
- 保持和等待
- 不可抢占
- 循环等待
我们分别对这四个条件进行讨论,按理说破坏其中的任意一个条件就能够破坏死锁
破坏互斥条件
我们首先考虑的就是破坏互斥使用条件。如果资源不被一个进程独占,那么死锁肯定不会产生。如果两个打印机同时使用一个资源会造成混乱,打印机的解决方式是使用 假脱机打印机(spooling printer)
,这项技术可以允许多个进程同时产生输出,在这种模型中,实际请求打印机的唯一进程是打印机守护进程,也称为后台进程。后台进程不会请求其他资源。我们可以消除打印机的死锁。
后台进程通常被编写为能够输出完整的文件后才能打印,假如两个进程都占用了假脱机空间的一半,而这两个进程都没有完成全部的输出,就会导致死锁。
因此,尽量做到尽可能少的进程可以请求资源。
破坏保持等待的条件
第二种方式是如果我们能阻止持有资源的进程请求其他资源,我们就能够消除死锁。一种实现方式是让所有的进程开始执行前请求全部的资源。如果所需的资源可用,进程会完成资源的分配并运行到结束。如果有任何一个资源处于频繁分配的情况,那么没有分配到资源的进程就会等待。
很多进程无法在执行完成前就知道到底需要多少资源,如果知道的话,就可以使用银行家算法;还有一个问题是这样无法合理有效利用资源。
还有一种方式是进程在请求其他资源时,先释放所占用的资源,然后再尝试一次获取全部的资源。
破坏不可抢占条件
破坏不可抢占条件也是可以的。可以通过虚拟化的方式来避免这种情况。
破坏循环等待条件
现在就剩最后一个条件了,循环等待条件可以通过多种方法来破坏。一种方式是制定一个标准,一个进程在任何时候只能使用一种资源。如果需要另外一种资源,必须释放当前资源。对于需要将大文件从磁带复制到打印机的过程,此限制是不可接受的。
另一种方式是将所有的资源统一编号,如下图所示
进程可以在任何时间提出请求,但是所有的请求都必须按照资源的顺序提出。如果按照此分配规则的话,那么资源分配之间不会出现环。
尽管通过这种方式来消除死锁,但是编号的顺序不可能让每个进程都会接受。
其他问题
下面我们来探讨一下其他问题,包括 **通信死锁、活锁是什么、饥饿问题和两阶段加锁
两阶段加锁
虽然很多情况下死锁的避免和预防都能处理,但是效果并不好。随着时间的推移,提出了很多优秀的算法用来处理死锁。例如在数据库系统中,一个经常发生的操作是请求锁住一些记录,然后更新所有锁定的记录。当同时有多个进程运行时,就会有死锁的风险。
一种解决方式是使用 两阶段提交(two-phase locking)
。顾名思义分为两个阶段,一阶段是进程尝试一次锁定它需要的所有记录。如果成功后,才会开始第二阶段,第二阶段是执行更新并释放锁。第一阶段并不做真正有意义的工作。
如果在第一阶段某个进程所需要的记录已经被加锁,那么该进程会释放所有锁定的记录并重新开始第一阶段。从某种意义上来说,这种方法类似于预先请求所有必需的资源或者是在进行一些不可逆的操作之前请求所有的资源。
不过在一般的应用场景中,两阶段加锁的策略并不通用。如果一个进程缺少资源就会半途中断并重新开始的方式是不可接受的。
通信死锁
我们上面一直讨论的是资源死锁,资源死锁是一种死锁类型,但并不是唯一类型,还有通信死锁,也就是两个或多个进程在发送消息时出现的死锁。进程 A 给进程 B 发了一条消息,然后进程 A 阻塞直到进程 B 返回响应。假设请求消息丢失了,那么进程 A 在一直等着回复,进程 B 也会阻塞等待请求消息到来,这时候就产生死锁
。
尽管会产生死锁,但是这并不是一个资源死锁,因为 A 并没有占据 B 的资源。事实上,通信死锁并没有完全可见的资源。根据死锁的定义来说:每个进程因为等待其他进程引起的事件而产生阻塞,这就是一种死锁。相较于最常见的通信死锁,我们把上面这种情况称为通信死锁(communication deadlock)
。
通信死锁不能通过调度的方式来避免,但是可以使用通信中一个非常重要的概念来避免:超时(timeout)
。在通信过程中,只要一个信息被发出后,发送者就会启动一个定时器,定时器会记录消息的超时时间,如果超时时间到了但是消息还没有返回,就会认为消息已经丢失并重新发送,通过这种方式,可以避免通信死锁。
但是并非所有网络通信发生的死锁都是通信死锁,也存在资源死锁,下面就是一个典型的资源死锁。
当一个数据包从主机进入路由器时,会被放入一个缓冲区,然后再传输到另外一个路由器,再到另一个,以此类推直到目的地。缓冲区都是资源并且数量有限。如下图所示,每个路由器都有 10 个缓冲区(实际上有很多)。
假如路由器 A 的所有数据需要发送到 B ,B 的所有数据包需要发送到 D,然后 D 的所有数据包需要发送到 A 。没有数据包可以移动,因为在另一端没有缓冲区可用,这就是一个典型的资源死锁。
活锁
你会发现一个很有意思的事情,死锁就跟榆木脑袋一样,不会转弯。我看过古代的一则故事:
如果说死锁很痴情
的话,那么活锁
用一则成语来表示就是 弄巧成拙
。
某些情况下,当进程意识到它不能获取所需要的下一个锁时,就会尝试礼貌的释放已经获得的锁,然后等待非常短的时间再次尝试获取。可以想像一下这个场景:当两个人在狭路相逢的时候,都想给对方让路,相同的步调会导致双方都无法前进。
现在假想有一对并行的进程用到了两个资源。它们分别尝试获取另一个锁失败后,两个进程都会释放自己持有的锁,再次进行尝试,这个过程会一直进行重复。很明显,这个过程中没有进程阻塞,但是进程仍然不会向下执行,这种状况我们称之为 活锁(livelock)
。
饥饿
与死锁和活锁的一个非常相似的问题是 饥饿(starvvation)
。想象一下你什么时候会饿?一段时间不吃东西是不是会饿?对于进程来讲,最重要的就是资源,如果一段时间没有获得资源,那么进程会产生饥饿,这些进程会永远得不到服务。
我们假设打印机的分配方案是每次都会分配给最小文件的进程,那么要打印大文件的进程会永远得不到服务,导致进程饥饿,进程会无限制的推后,虽然它没有阻塞。
总结
死锁是一类通用问题,任何操作系统都会产生死锁。当每一组进程中的每个进程都因等待由该组的其他进程所占有的资源而导致阻塞,死锁就发生了。这种情况会使所有的进程都处于无限等待的状态。
死锁的检测和避免可以通过安全和不安全状态来判断,其中一个检测方式就是银行家算法;当然你也可以使用鸵鸟算法对死锁置之不理,但是你肯定会遭其反噬。
也可以在设计时通过系统结构的角度来避免死锁,这样能够预防死锁;也可以破坏死锁的四个条件来破坏死锁。资源死锁并不是唯一性的死锁,还有通信间死锁,可以设置适当的超时时间来完成。
活锁和死锁的问题有些相似,它们都是一种进程无法继续向下执行的状态。由于进程调度策略导致尝试获取进程的一方永远无法获得资源后,进程会导致饥饿的出现。
程序员们平时都喜欢逛什么论坛呢?
程序员的工作和日常生活非常的枯燥,这里给大家推荐一些程序员经常使用的网站,也是我经常上的一些网站,我将会从多个角度、多个层面为你整理归纳这些网站和论坛
项目类
项目是一个面试官非常看重的点,也是拓展视野、挖掘轮子一个的地方,下面就为你推荐几个程序员都应该 mark 的项目类网站。
Github
代码托管 https://github.com
作为开源代码库以及版本控制系统,Github 拥有140多万开发者用户。随着越来越多的应用程序转移到了云上,Github 已经成为了管理软件开发以及发现已有代码的首选方法。代码托管必备。
Github 也有非常多好的项目可以推荐,比如我自己的 github (逃了)
https://github.com/crisxuan/bestJavaer
还有其他非常多优秀的 Github ,比如 **CS-notes、JavaGuide、mall 商城项目
码云托管平台是中国的,之所以国内自己开发了一个码云平台,是因为 github 涉及到 fq,你懂的,而且码云是开源中国的托管平台,会定期评选一些优秀的项目,你不可错过!
Gitlab
https://about.gitlab.com/
对于有些人,提到GitHub就会自然的想到Gitlab,Gitlab支持无限的公有项目和私有项目。其首页截图如下
coding.net
https://coding.net
谈到 coding.net,首先必须提的是速度快,功能与开源中国相似,同样一个账号最多可以创建 1000 个项目,也支持任务的创建等
博客类
中国专业IT社区CSDN (Chinese Software Developer Network) 创立于1999年,致力于为中国软件开发者提供知识传播、在线学习、职业发展等服务。中国最大、最具专业的博客平台,同时也是质量最高的博客平台
这样一个不起眼的地方,却吸引了很多IT技术精英,把这里当作自己的网上家园,每天在这里分享着精彩的原创内容,也许他们看重的不是华丽的外表、诱人的虚名,而是纯净、专注、对技术人员的理解。
掘金现在被字节跳动收购了,内容审核变得越来越严格,也涌现了很多大佬,他们写的文章非常帮,强烈推荐大家每天逛一逛,博客内容都是经过层层筛选的,非常值得一看
思否上面有很多大佬,不得不说思否的界面做的非常清新,舒服,这就让人很喜欢这个博客平台,目前看来还是比较小众,但是上面的开发者的互动很多,是一个很温馨的地方。
infoq 是最近兴起的一个技术社区,界面非常小清新,让人感觉很舒服,目前入驻的开发者倒不是很多,但是 infoq 依托极客邦的大流量和资源,做起来应该很快的,我入驻了 infoq ,感觉里面还是有很多大佬的,推荐大家经常浏览一波
GitChat
https://gitbook.cn
GitChat 是一个付费学习网站,当然也支持免费模式,因为付费所以相对文章质量比较高,但是免费的文章同样也很不错,而且我就在 GitChat 上面写了很多免费文章,不应该是很多,应该是全部免费,我的 gitchat 主页如下 程序员cxuan 的 GitChat
V2EX
https://www.v2ex.com
无论你是在大学进行人生最重要阶段的学习,或者是在中国的某座城市工作,或者是在外太空的某个天体如 Sputnik 1 上享受人生,在注册进入 V2EX 之后,你都可以为自己设置一个所在地,从而找到更多和你在同一区域的朋友。
OSCHINA
https://www.oschina.net
这个网站和 CSDN 一样是国内最大程序员社区,各种教程、资源、工具、书籍都是可以找到的。在社区里,你可以尽情的写博客,发动态,激烈的讨论问题,只有在这种环境下才能激起你学习的热情。
简书是一个不仅仅为程序员提供的创作分享社区,上面充满了各种各样精彩的博文,也有很多优秀的博主,但是相比较与 CSDN 个人认为在技术创作方面就略低一筹了。
dev
https://dev.to/
dev 社区和国内的掘金社区很相似,技术分类也比较多,各种技术应有尽有,文章质量都很不错。
dreamincode
https://www.dreamincode.net
dreamincode 是一个相对小众的技术博客,风格简约,但是内容却不简单。
bytes
https://bytes.com
bytes 和 dreamcode 类似,简约但不简单。
hongkiat是与技术、设计领域相关的站点之一,大家可以在这里分享技术文章。
IBM Developer
https://developer.ibm.com
这里面都是一线工程师,技术性很强,每一篇文章都值得细细观看,认真学习。
问答类
有问题,上知乎。知乎,可信赖的问答社区,以让每个人高效获得可信赖的解答为使命。
本是一个分享各种人生杂谈,和一些鲜为人知以及各种大牛的免费和付费的知识型网站。由于流量逐步扩大,吸引了大批优秀的程序员在上面分享自己的技术创作,也是一个程序员常去的网站之一,不仅仅是为了学习技术。
SegmentFault 思否是中国领先的新一代开发者社区和专业的技术媒体。我们为中文开发者提供纯粹、高质的技术交流平台以及最前沿的技术行业动态,帮助更多的开发者获得认知
StackOverFlow
https://stackoverflow.com
这是一个由外国人创办的专为程序员提供的国际性问题解答交流社区,正如网站签名:Stack Overflow – Where Developers Learn, Share, & Build Careers。这个网站非常的纯粹,一般人还真不太习惯用这个,没有一手好英语还看不太懂全英文的技术交流与问答。
Reddit
https://www.reddit.com
reddit是一个非常个性的社区,你可以在这里讨论编程问题,还可以学习学英语,reddit 还很幽默,有古怪的莫名笑点,评论接楼很有意思。
daniweb
https://www.daniweb.com
daniweb也是一个质量比较高的问答平台,有一种像社交平台的感觉。
教程类
菜鸟教程提供了编程的基础技术教程, 介绍了 HTML、CSS、Javascript、Python、Java、Ruby、C、PHP、MySQL 等各种编程语言的基础知识,是个小白入门,学习语言的好地方。
w3schoool
在W3School,你可以找到你所需要的所有的网站建设教程。 从基础的 HTML 到 CSS,乃至进阶的 XML、SQL、JS、PHP 和 ASP.NET。 从左侧的菜单选择你需要的教程! 和菜鸟教程十分相似的网站。
易百网是一个内容全面的教程网站,专注于 VBScript, MATLAB, EJB, IPv6, IPv4, 电子商务, PostgreSQL, SQLite, SDLC, Assembly, 操作系统, JSON, iOS, 设计模式, VB.Net, 计算机基础知识。
Bilibili1
https://www.bilibili.com
期初这个网站是由游戏玩家视频火起来的,尤其LOL骨灰级玩家遍布其中。bilibili 是国内知名的视频弹幕网站,通过动漫打出了名声,最近两年发展势头迅猛,里面有不少有创意的 Up 主,不乏一些有趣的程序员。
中国大学MOOC网
icourse163.org
中国大学 MOOC(慕课) 是国内优质的中文 MOOC 学习平台,由爱课程网携手网易云课堂打造。平台拥有包括 985 高校在内提供的千余门课程。在这里,每一个有意愿提升自己的人都可以免费获得更优质的高等教育。
慕课网-程序员的梦工厂
https://www.imooc.com
慕课网(IMOOC)是IT技能学习平台。慕课网(IMOOC)提供了丰富的移动端开发、php开发、web前端、android开发以及 html5 等视频教程资源公开课。
网易云课堂
https://study.163.com
网易云课堂,是网易公司打造的在线实用技能学习平台,主要为学习者提供海量、优质的课程,用户可以根据自身的学习程度,自主安排学习进度。涵盖实用软件、IT与互联网、外语学习、生活家居、兴趣爱好、职场技能、金融管理、考试认证、中小学、亲子教育等十余大门类。
实验楼这个网站我直接吹爆,无需配置繁琐的本地环境,随时在线使用。
tutorialspoint
https://www.tutorialspoint.com/index.htm
这是一个在线学习的网站,并且免费,里面有各种技术、各个知识点的讲解和demo,灰常全面,这比查找API方便多了,遇到不明白的知识点直接根据索引找就是了,还有各种电子书。
codecademy
https://www.codecademy.com
学习新语言,敲代码玩就在这里了。这个网站将简化编程学习的过程。比如说网站左边会讲解知识点,右边直接练习。如果出现错误,就会有错误提示,直接给你反馈。所以,使用它不用想太多,直接拼命硬干敲代码入门。
Livecoding.tv
https://www.livecoding.tv/accounts/login/
Livecoding.tv 由一群欧美程序爱好者共同发起成立,旨在为全球程序员提供一个实时高效的互动平台。特色是使用了录屏直播技术,用户可以在线观看高手实时编程并且可以向对方提问互动,网站现在已经汇集了一大批程序精英。现在 Livecoding.TV 来到中国,希望更多的中国程序员加入进来一起切磋技术。
Dzone
https://dzone.com
Dzone 是一个技术涵盖比较全面的网站,像云平台、数据库、物联网、开发运维、Java 语言等都有。
simpleprogrammer
https://simpleprogrammer.com/
simpleprogrammer与其他技术类的社区不太一样,在这里并没有很多技术类的文章,更多的是指导建议性的文章,讲述了一些人生道理,职场规则,编程生涯的一些文章。
SitePoint
https://www.sitepoint.com/web
通过 SitePoint 教程,课程和书籍学习 Web 设计和开发-HTML5,CSS3,JavaScript,PHP,移动应用,这是一个偏向前端方向的网站,在这里包含了各种高质量的前端方面的文章,电子书。
YouTobe
https://www.youtube.com/
YouTobe 这个网站可算是经典,和国内的哔哩哔哩一样,各类视频汇聚于此,当然各国编程大神也在这啦。
算法类
LeetCode
https://leetcode-cn.com
几乎每个算法大牛都知道的神奇网站,这个网站上面有:算法、数据库、Shell、多线程等多种类型供你学习。多数人在上面练习编程算法,尤其是给想进入一线互联网公司的技术人员,提供了一个免费又方便的题库。面试前都会在上面进行长期和充分的刷题,是你的不二选择。
LintCode
https://www.lintcode.com
LintCode 领扣上有数量超过 1000 道的算法题目和人工智能题目,通过刷题熟练掌握数据结构和算法。完成各大名企的阶梯训练,为你斩获心仪的 offer 打下坚实的基础
洛谷创办于 2013 年,致力于为参加 noip、noi、acm 的选手提供清爽、快捷的编程体验。它拥有在线测题系统、强大的社区、在线学习功能,也是一个很好练习刷题的网站啦
Codeforces
http://codeforces.com/
Codeforces 是一家为计算机编程爱好者提供的在线评测系统该网站由萨拉托夫国立大学的一个团体创立并负责运营。在编程挑战赛中,选手有 2 个小时的时间去解决 5 道题,通过得分排名,选手可以看到实时的排名,也可以选择查看好友的排名,还可以看到某题有多少人通过等信息。
Topcoder
https://www.topcoder.com
Topcoder 据说是世界上规模最大的编程网站,这里面的题型,比赛形式跟 ACM/ICPC 极不相同。该网站把中国纳入其赛区,大家可以上去那里跟来自全世界的程序员(事实上大多数也是大学生)进行更直接的交流,可能也是ACM/ICPC 练兵的好地方吧。
接单类
程序员客栈
https://www.proginn.com
程序员客栈是领先的程序员自由远程工作平台,未来互联网企业用人方式。提供优秀程序员为您进行网站建设制作、测试运维服务、人工智能 AI、大数据区块链、软件开发等优质服务。
码市是互联网软件外包服务平台,意在连接需求方与广大开发者。让项目的需求方快速的找到合适的开发者,完成项目开发工作。
猿急送为您提供兼职程序员,兼职工程师信息,猿急送是一个高级技术共享平台,是优质的程序员兼职网站,这里汇聚 BAT 等知名互联网公司的技术开发、产品、设计大牛。
开源众包–专业的软件众包平台,350万+ 优质开发者为您提供网站、APP、微信/小程序、企业应用等软件开发服务,有效降低企业 IT 软件开发成本、解决技术资源不足等问题。
这个比较高级,是一些知名公司技术人员兼职的平台。我们可以在线约好去其他公司兼职坐班。实现网为企业提供BAT 等名企背景的、靠谱的开发设计兼职人才和自由职业者,满足企业项目外包、驻场开发、远程兼职、技术咨询等短期人力需求。
猪八戒网企业外包服务,中国领先的灵活用工平台,其中服务品类涵盖LOGO设计、UI设计、营销推广、网站建设、装修设计、工业设计、文案策划、知识产权的服务。
码易众包平台
https://www.mayigeek.com
码易是智网易联旗下 IT 软件服务平台,集软件商城、企业应用、电商软件、crm 软件、商务服务平台于一体的一站式软件外包开发服务平台。
求职类
求职之前,先上牛客,就业找工作一站解决。这个网站不像 csdn 和 OSChina 以技术博客论坛为主了。但是在你需要的时候,却是相当有价值,里面有面试技巧、各种知名的不知名的互联网公司的对应往年校招社招面试题库,刷到你手软,一般应届生用这个比较多。
拉勾招聘是专业的互联网求职招聘网站。致力于提供真实可靠的互联网岗位求职招聘找工作信息,拥有海量的互联网人才储备,互联网行业找工作就上拉勾招聘,值得信赖的求职。
Boss直聘
https://www.zhipin.com
BOSS直聘是权威领先的招聘网,开启人才网招聘求职新时代,让求职者与 Boss 直接开聊、加快面试、即时反馈,是一个致力于为招聘者和求职者搭建高效沟通、信息对等的平台
猎聘,2018年香港上市。作为中国知名中高端人才求职招聘平台,汇聚56万+知名企业、16万+认证猎头,为5700万用户提供高薪工作岗位。总之,猎聘还是非常不错的。
Spring注解配置的基本要素
随着Spring的流行,我们经历过基于XML-Based 的配置,随着SpringBoot的流行,我们逐渐使用基于注解的配置替换掉了基于XML-Based的配置,那么你知道基于注解的配置的基础组件都是什么吗?都包括哪些要素?那么本节就来探讨一下。**注:本篇文章更多的是讨论Spring基于注解的配置一览,具体的技术可能没有那么深,请各位大佬见谅。
探讨主题:
- 基础概念:@Bean 和 @Configuration
- 使用AnnotationConfigApplicationContext 实例化Spring容器
- 使用@Bean 注解
- 使用@Configuration 注解
- 编写基于Java的配置
- Bean定义配置文件
- PropertySource 抽象类
- 使用@PropertySource
- 占位符的声明
基础概念:@Bean 和 @Configuration
Spring中新的概念是支持@Bean注解 和 @Configuration 注解的类。@Bean 注解用来表明一个方法实例化,配置并且通过IOC容器初始化并管理一个新的对象。@Bean注解就等同于XML-Based中的<beans/>
标签,并且扮演了相同的作用。你可以使用基于注解的配置@Bean 和 @Component,然而他们都用在@Configuration配置类中。
使用@Configuration 注解的主要作用是作为bean定义的类,进一步来说,@Configuration注解的类允许通过调用同类中的其他@Bean标注的方法来定义bean之间依赖关系。 如下所示:
新建一个maven项目(我一般都直接创建SpringBoot项目,比较省事),创建AppConfig
,MyService
,MyServiceImpl
类,代码如下:
@Configuration
public class AppConfig {
@Bean
public MyService myService(){
return new MyServiceImpl();
}
}
public interface MyService {}
public class MyServiceImpl implements MyService {}
上述的依赖关系等同于XML-Based:
<beans>
<bean id="myService",class="com.spring.annotation.service.impl.MyServiceImpl"/>
</beans>
使用AnnotationConfigApplicationContext 实例化Spring容器
AnnotationConfigApplicationContext 基于注解的上下文是Spring3.0 新添加的注解,它是ApplicationContext
的一个具体实现,它可以接收@Configuration
注解的类作为输入参数,还能接收使用JSR-330元注解的普通@Component类。
当提供了@Configuration 类作为输入参数时,@Configuration类就会注册作为bean的定义信息并且所有声明@Bean的方法也都会作为bean的定义信息。
当提供@Component和JSR-330 声明的类时,他们都会注册作为bean的定义信息,并且假设在必要时在这些类中使用诸如@Autowired或@Inject之类的注解
简单的构造
在某些基于XML-Based的配置,我们想获取上下文容器使用ClassPathXmlApplicationContext
,现在你能够使用@Configuration 类来实例化AnnotationConfigApplicationContext。
在MyService
中添加一个printMessage()
方法,实现类实现对应的方法。新建测试类进行测试
public class ApplicationTests {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MyService service = context.getBean(MyService.class);
// printMessage() 输出something...
service.printMessage();
}
}
如前所述,AnnotationConfigApplicationContext不仅限于使用@Configuration类。 任何@Component或JSR-330带注释的类都可以作为输入提供给构造函数,如下例所示
public class ApplicationTests {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(MyServiceImpl.class,Dependency1.class,Dependency2.class);
MyService myService = context.getBean(MyService.class);
myService.printMessage();
}
}
使用register注册IOC容器
你可以实例化AnnotationConfigApplicationContext
通过使用无参数的构造器并且使用register
方法进行注册,它和AnnotationConfigApplicationContext
带参数的构造器起到的效果相同。
public class ApplicationTests {
public static void main(String[] args) {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.register(AppConfig.class, OtherConfig.class);
ctx.register(AdditionalConfig.class);
ctx.refresh();
MyService myService = ctx.getBean(MyService.class);
System.out.println(ctx.getBean(OtherConfig.class));
System.out.println(ctx.getBean(AdditionalConfig.class));
myService.printMessage();
}
}
OtherConfig.class 和 AdditionalConfig.class 是使用@Component 标注的类。
允许scan()方法进行组件扫描
为了允许组件进行扫描,需要在@Configuration配置类添加@ComponentScan()
注解,改造之前的AdditionalConfig
类,如下:
@Configuration
@ComponentScan(basePackages = "com.spring.annotation.config")
public class AdditionalConfig {}
@ComponentScan指定了基础扫描包位于com.spring.annotation.config下,所有位于该包范围内的bean都会被注册进来,交由Spring管理。它就等同于基于XML-Based的注解:
<beans>
<context:component-scan base-package="com.spring.annotation.config/>
</beans>
AnnotationConfigApplicationContext中的scan()方法以允许相同的组件扫描功能,如以下示例所示:
public static void main(String[] args) {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.scan("com.spring.annotation");
ctx.refresh();
MyService myService = ctx.getBean(MyService.class);
}
为什么说@Configuration用法和@Component都能够标注配置类?因为@Configuration的元注解就是@Component。
@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Component public @interface Configuration { String value() default ""; }
使用AnnotationConfigWebApplicationContext支持web容器
AnnotationConfigApplicationContext的一个WebApplicationContext的变化是使用AnnotationConfigWebApplicationContext
。配置Spring ContextLoaderListener的servlet监听器,Spring MVC的DispatcherServlet等时,可以使用此实现。以下web.xml代码段配置典型的Spring MVC Web应用程序(请注意context-param和init-param的使用)
<web-app>
<!-- 配置web上下文监听器使用 AnnotationConfigWebApplicationContext 而不是默认的
XmlWebApplicationContext -->
<context-param>
<param-name>contextClass</param-name>
<param-value>
org.springframework.web.context.support.AnnotationConfigWebApplicationContext
</param-value>
</context-param>
<!-- 配置位置必须包含一个或多个以逗号或空格分隔的完全限定的@Configuration类。 也可以为组件扫描指定完全 限定的包-->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>com.spring.annotation.config.AdditionalConfig</param-value>
</context-param>
<!--使用ContextLoaderListener像往常一样引导根应用程序上下文-->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<!-- 定义一个SpringMVC 核心控制器 DispatcherServlet-->
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<!-- 配置web上下文监听器使用 AnnotationConfigWebApplicationContext 而不是默认的
XmlWebApplicationContext -->
<init-param>
<param-name>contextClass</param-name>
<param-value>
org.springframework.web.context.support.AnnotationConfigWebApplicationContext
</param-value>
</init-param>
<!-- 配置位置必须包含一个或多个以逗号或空格分隔的完全限定的@Configuration类。 也可以为组件扫描指定 完全限定的包-->
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>com.spring.annotation.config.MvcConfig</param-value>
</init-param>
</servlet>
<!-- 将/app/* 的所有请求映射到调度程序servlet -->
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/app/*</url-pattern>
</servlet-mapping>
</web-app>
使用@Bean注解
@Bean 注解是一个方法级别的注解,能够替换XML-Based中的bean/>标签,@Bean注解同样支持init-method
, destroy-method
, autowiring
。
定义一个Bean
与基础概念中Bean的定义相同,读者可以参考基础概念部分进行了解,我们不在此再进行探讨。
Bean的依赖
@Bean 注解可以有任意数量的参数来构建其依赖项,例如
public class MyService {
private final MyRepository myRepository;
public MyService(MyRepository myRepository) {
this.myRepository = myRepository;
}
public String generateSomeString() {
return myRepository.findString() + "-from-MyService";
}
}
@Configuration
class MyConfiguration {
@Bean
public MyService myService() {
return new MyService(myRepository());
}
@Bean
public MyRepository myRepository() {
return new MyRepository();
}
}
public class MyRepository {
public String findString() {
return "some-string";
}
}
接受生命周期回调
任何使用@Bean的注解都支持生命周期的回调,使用JSR-220提供的@PostConstruct
和@PreDestory
注解来实现。如果bean实现了InitializingBean
,DisposableBean
或者Lifecycle
接口,他们的方法会由IOC容器回调。一些以Aware的实现接口(像是BeanFactoryAware,BeanNameAware, MessageSourceAware, ApplicationContextAware等)也支持回调。
@Bean注解支持特定的初始化和销毁方法,就像XML-Based中的init-method
和 destory-method
中的bean属性,下面这个例子证实了这一点
AppConfig.java
@Configuration
public class AppConfig {
@Bean(initMethod = "init")
public BeanOne beanOne(){
return new BeanOne();
}
@Bean(destroyMethod = "cleanup")
public BeanTwo beanTwo(){
return new BeanTwo();
}
}
class BeanOne {
public void init(){}
}
class BeanTwo {
public void cleanup(){}
}
对于上面的例子,也可以手动调用init()方法,与上面的initMethod 方法等效
@Bean
public BeanOne beanOne(){
BeanOne beanOne = new BeanOne();
beanOne.init();
return beanOne;
}
当你直接使用Java开发时,你可以使用对象执行任何操作,并且不必总是依赖于容器生命周期。
Bean的作用范围
Spring包括@Scope注解能够让你指定Bean的作用范围,Bean的Scope默认是单例的,也就是说@Bean标注的对象在IOC的容器中只有一个。你可以重写@Scope的作用范围,下面的例子说明了这一点,修改OtherConfig如下
OtherConfig.java
@Configuration
public class OtherConfig {
@Bean
@Scope("prototype")
public Dependency1 dependency1(){
return new Dependency1();
}
}
每次尝试获取dependency1这个对象的时候都会重新生成一个新的对象实例。下面是Scope的作用范围和解释:
Scope | Descriptionn |
---|---|
singleton | 默认单例的bean定义信息,对于每个IOC容器来说都是单例对象 |
prototype | bean对象的定义为任意数量的对象实例 |
request | bean对象的定义为一次HTTP请求的生命周期,也就是说,每个HTTP请求都有自己的bean实例,它是在单个bean定义的后面创建的。仅仅在web-aware的上下文中有效 |
session | bean对象的定义为一次HTTP会话的生命周期。仅仅在web-aware的上下文中有效 |
application | bean对象的定义范围在ServletContext生命周期内。仅仅在web-aware的上下文中有效 |
websocket | bean对象的定义为WebSocket的生命周期内。仅仅在web-aware的上下文中有效 |
@Scope和Scoped-proxy
Spring提供了一种通过scoped proxies与scoped依赖一起作用的方式。最简单的在XML环境中创建代理的方式是通过<aop:scoped-proxy/>
标签。使用@Scope
注解为在Java中配置bean提供了与proxyMode属性相同的功能。默认是不需要代理的(ScopedProxyMode.NO),但是你需要指定ScopedProxyMode.TARGET_CLASS
或者ScopedProxyMode.INTERFACES
。
自定义Bean名称
默认的情况下,配置类通过@Bean配置的默认名称(方法名第一个字母小写)进行注册和使用,但是你可以更换@Bean的name为你想指定的名称。修改AdditionalConfig 类
AdditionalConfig.java
@Configuration
//@ComponentScan(basePackages = "com.spring.annotation.config")
public class AdditionalConfig {
@Bean(name = "default")
public Dependency2 dependency2(){
return new Dependency2();
}
}
Bean的别名
有时候需要为单例的bean提供多个名称,也叫做Bean的别名。Bean注解的name属性接收一个Array数组。下面这个例子证实了这一点:
OtherConfig.java
@Configuration
public class OtherConfig {
// @Bean
// @Scope("prototype")
// public Dependency1 dependency1(){
// return new Dependency1();
// }
@Bean({"dataSource", "dataSourceA", "dataSourceB"})
public DataSource dataSource(){
return null;
}
}
Bean的描述
有时,提供更详细的bean描述信息会很有帮助(但是开发很少使用到)。为了增加一个对@Bean的描述,你需要使用到@Description注解
OtherConfig.java
@Configuration
public class OtherConfig {
// @Bean
// @Scope("prototype")
// public Dependency1 dependency1(){
// return new Dependency1();
// }
// @Bean({"dataSource", "dataSourceA", "dataSourceB"})
// public DataSource dataSource(){
// return null;
// }
@Bean
@Description("此方法的bean名称为dependency1")
public Dependency1 dependency1(){
return new Dependency1();
}
}
使用@Configuration注解
更多关于@Configuration 的详细说明,请你参考https://mp.weixin.qq.com/s/FLJTsT2bAru-w7cF4CG8kQ
已经把@Configuration的注解说明的比较详细了。
组成Java-Based环境配置的条件
Spring基于注解的配置能够允许你自定义注解,同时能够降低配置的复杂性。
使用@Import注解
就像在Spring XML文件中使用
@Configuration
public class ConfigA {
@Bean
public A a(){
return new A();
}
}
@Configuration
@Import(ConfigA.class)
public class ConfigB {
@Bean
public B b(){
return new B();
}
}
现在,在实例化上下文时,不需要同时指定ConfigA.class 和 ConfigB.class ,只需要显示提供ConfigB
public static void main(String[] args) {
ApplicationContext ctx = new AnnotationConfigApplicationContext(ConfigB.class);
A a = ctx.getBean(A.class);
B b = ctx.getBean(B.class);
}
这种方法简化了容器实例化,因为只需要处理一个类,而不是要求你在构造期间记住可能大量的@Configuration类
有选择性的包含@Configuration 类和@Bean 方法
选择性的允许或者禁止@Configuration注解的类和@Bean注解的方法是很有用的,基于一些任意系统状态。一个常见的例子是只有在Spring环境中启用了特定的配置文件时才使用@Profile注释激活bean。
@Profile注解也实现了更灵活的注解@Conditional,@Conditional 注解表明在注册@Bean 之前应参考特定的Condition实现。
实现Condition接口就会提供一个matched方法返回true或者false
更多关于@Conditional 的示例,请参考
https://www.cnblogs.com/cxuanBlog/p/10960575.html
结合Java与XML配置
Spring @Configuration类能够100%替换XML配置,但一些工具(如XML命名空间)仍旧是配置容器的首选方法,在这种背景下,使用XML使很方便的而且使刚需了。你有两个选择:使用以XML配置实例化容器为中心,例如:ClassPathXmlApplicationContext
导入XML或者实例化以Java配置为中心的AnnotationConfigApplicationContext
并提供ImportResource
注解导入需要的XML配置。
将@Configuration声明为普通的bean元素
请记住,@Configuration类存放的是容器中的bean定义信息,下面的例子中,我们将会创建一个@Configuration类并且加载了外部xml配置。下面展示了一个普通的Java配置类
@Configuration
public class AppConfig {
@Autowired
private DataSource dataSource;
@Bean
public AccountRepository accountRepository() {
return new JdbcAccountRepository(dataSource);
}
@Bean
public TransferService transferService() {
return new TransferService(accountRepository());
}
}
下面是system-test-config.xml
配置类的一部分
<beans>
<!--允许开启 @Autowired 或者 @Configuration-->
<context:annotation-config/>
<!-- 读取外部属性文件 -->
<!-- 更多关于属性读取的资料,参考 https://www.cnblogs.com/cxuanBlog/p/10927819.html -->
<context:property-placeholder location="classpath:/com/spring/annotation/jdbc.properties"/>
<bean class="com.spring.annotation.config.AppConfig"/>
<bean class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="${jdbc.driverClassName}" />
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</bean>
</beans>
引入jdbc.properties建立数据库连接
jdbc.driverClassName=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/sys
jdbc.username=root
jdbc.password=123456
public static void main(String[] args) {
ApplicationContext ctx = new ClassPathXmlApplicationContext("classpath:/com/spring/annotation/system-test-config.xml");
TransferService transferService = ctx.getBean(TransferService.class);
// ...
}
在
system-test-config.xml
中,AppConfig 对应的标签没有声明id属性,虽然这样做是可以接受的,但是没有必要,因为没有其他bean引用它,并且不太可能通过名称从容器中获取它。同样的,DataSource bean只是按类型自动装配,因此不严格要求显式的bean id。
使用<> 挑选指定的@Configuration类
因为@Configuration的原注解是@Component,所以@Configuration注解的类也能用于组件扫描,使用与前一个示例中描述的相同的方案,我们可以重新定义system-test-config.xml以利用组件扫描。 请注意,在这种情况下,我们不需要显式声明<context:annotation-config />
,因为<context:component-scan />
启用相同的功能。
<beans>
<context:component-scan base-package="com.spring.annotation"/>
<context:property-placeholder location="classpath:/com/spring/annotation/jdbc.properties"/>
<bean class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="${jdbc.driverClassName}" />
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</bean>
</beans>
@Configuration 类使用@ImportResource
在基于Java注解的配置类中,仍然可以使用少量的@ImportResource导入外部配置,最好的方式就是两者结合,下面展示了一下Java注解结合XML配置的示例
@Configuration
@ImportResource("classpath:/com/spring/annotation/properties-config.xml")
public class AppConfig {
@Value("${jdbc.driverClassName}")
private String driver;
@Value("${jdbc.url}")
private String url;
@Value("${jdbc.username}")
private String username;
@Value("${jdbc.password}")
private String password;
@Bean
public DataSource dataSource() {
return new DriverManagerDataSource(url, username, password);
}
}
Properties-config.xml
<beans>
<context:property-placeholder location="classpath:/com/spring/annotation/jdbc.properties"/>
</beans>
jdbc.properties
jdbc.driverClassName=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/sys
jdbc.username=root
jdbc.password=123456
public static void main(String[] args) {
ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class);
TransferService transferService = ctx.getBean(TransferService.class);
// ...
}
BeanFactory和FactoryBean的理解
我们或多或少都会经历过若干个面试,而每每问到 Spring 的时候都少不了IOC容器的影子,那么这个 IOC 容器是什么呢? 本篇文章就来了解一下 BeanFactory 和 FactoryBean
BeanFactory 是什么
BeanFactory 是用于Spring Bean 容器的根接口,它是 IOC 的基本容器,负责管理和加载 Bean,它为其他具体的IOC容器提供了最基本的规范,比如 DefaultListableBeanFactory
和 ConfigurableBeanFactory
,BeanFactory 也提供了用于读取 XML 配置文件的实现,比如 XMLBeanFactory
。
ApplicationContext 接口是 BeanFactory 的扩展,它除了具备 BeanFactory 接口所拥有的全部功能外,还有应用程序上下文的一层含义,主要包括
- 继承自 ListableBeanFactory 接口,可以访问 Bean 工厂上下文的组件
- 继承自 ResourceLoader 接口,以通用的方式加载文件资源
- 继承自 ApplicationContextPublisher 接口,拥有发布事件注册监听的能力
- 继承自 MessageSource 接口,解析消息支持国际化
它最主要的实现就是 ClassPathXmlApplicationContext
,用来读取XML 配置文件,现在我们用的更多的是 ClassPathXmlApplicationContext 而不是 XMLBeanFactory 了。
BeanFactory 的基本使用
上面了解了一下 BeanFactory 的基本概念之后,下面来介绍一下 BeanFactory 的基本使用
新建一个Maven 项目,配置基本的 Spring 依赖,新建一个简单的测试Bean —> HelloBean
HelloBean.java
public class HelloBean {
private String message;
get and set...
public void printMsg(){
System.out.println("message = " + message);
}
}
在 /resources 目录下新建一个 spring-beans.xml 配置文件,用来配置一下上面这个简单的bean
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd" >
<beans>
<bean id="helloBean" class="com.factory.bean.HelloBean">
<property name="message">
<value>Hello Beans</value>
</property>
</bean>
</beans>
新建一个测试类测试一下这个demo
public class FactoryBeanApplicationTests {
public static void main(String[] args) {
// BeanFactory beanFactory = new XmlBeanFactory(new FileSystemResource("/Users/mr.l/test/spring-beans.xml"));
ApplicationContext beanFactory = new ClassPathXmlApplicationContext("spring-beans.xml");
HelloBean helloBean = (HelloBean) beanFactory.getBean("helloBean");
helloBean.printMsg();
}
}
一些其他的表现形式:
Resource resource = new FileSystemResource("spring-beans.xml");
BeanFactory factory = new XmlBeanFactory(resource);
ApplicationContext context = new ClassPathXmlApplicationContext(new String[] {"applicationContext.xml", "applicationContext-part2.xml"});
BeanFactory factory = (BeanFactory) context;
现在更多的采用 ClassPathXmlApplicationContext 路径上下文读取XML配置文件,XmlBeanFactory 已经被废弃。
FactoryBean 是什么
FactoryBean 是一个接口,它本身就是一个对象工厂,如果bean 实现了这个接口,它被用作公开的对象工厂,而不是作为直接将bean暴露的实例。该接口在框架内部大量使用,例如 AOP ProxyFactoryBean 或者 JndiObjectFactoryBean。 也能自定义组件;然而,这仅适用于基础框架代码。FactoryBeans 支持单例或多例,并且可以根据需要懒加载创建对象,也可以在启动时 急切创建对象
我们先来看一下FactoryBean
的基本接口表示
public interface FactoryBean<T> {
@Nullable
T getObject() throws Exception;
@Nullable
Class<?> getObjectType();
default boolean isSingleton() {
return true;
}
}
接口很简单,只有三个方法,我们来讨论一下上面几个方法分别代表了什么意思:
getObject
: 返回一个工厂生产出来的对象,这个对象将要使用在Spring IOC 容器中getObjectType
: 顾名思义就是返回工厂生产出来对象的类型isSingleton
: 表示生产出来的对象是否是单例的
FactoryBean 的基本使用
下面我们来用一个简单的示例演示一下 FactoryBean 的用法
- 先构建一个普通的pojo类,只有一个简单的属性
public class Tool {
private int id;
public Tool(int id){
this.id = id;
}
get and set...
}
- 构建一个 ToolFactory 类,实现了 FactoryBean 接口,用于生产 Tool 的对象
public class ToolFactory implements FactoryBean<Tool> {
private int factoryId;
private int toolId;
@Override
public Tool getObject() throws Exception {
return new Tool(toolId);
}
@Override
public Class<?> getObjectType() {
return Tool.class;
}
@Override
public boolean isSingleton() {
return false;
}
get and set...
}
- 在 /resources 目录下新建一个
factorybean-spring.xml
,用于给 ToolFactory 赋值
<beans>
<bean id="tool" class="com.factory.bean.util.ToolFactory">
<property name="factoryId" value="9090" />
<property name="toolId" value="1"/>
</bean>
</beans>
- 新建一个测试类
FactoryXmlTest
测试 FactoryBean 生产的实例
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:factorybean-spring.xml")
public class FactoryXmlTest {
// 使用 & 读取xml 中的配置值
@Resource(name = "&tool")
private ToolFactory toolFactory;
@Test
public void testFactory(){
System.out.println(toolFactory.getFactoryId());
System.out.println(toolFactory.getObjectType());
System.out.println(toolFactory.getToolId());
}
}
输出:
9090 class com.factory.bean.pojo.Tool 1
BeanFactory和ApplicationContext的异同
相同:
- Spring提供了两种不同的IOC 容器,一个是BeanFactory,另外一个是ApplicationContext,它们都是Java interface,ApplicationContext继承于BeanFactory(ApplicationContext继承ListableBeanFactory。
- 它们都可以用来配置XML属性,也支持属性的自动注入。
- 而ListableBeanFactory继承BeanFactory),BeanFactory 和 ApplicationContext 都提供了一种方式,使用getBean("bean name")获取bean。
BeanFactory 获取bean注册信息
public class HelloWorldApp{
public static void main(String[] args) {
XmlBeanFactory factory = new XmlBeanFactory (new ClassPathResource("beans.xml"));
HelloWorld obj = (HelloWorld) factory.getBean("helloWorld");
obj.getMessage();
}
}
ApplicationContext 获取bean注册信息
public class HelloWorldApp{
public static void main(String[] args) {
ApplicationContext context=new ClassPathXmlApplicationContext("beans.xml");
HelloWorld obj = (HelloWorld) context.getBean("helloWorld");
obj.getMessage();
}
}
但是他们在工作和特性上有一些不同:
- 当你调用getBean()方法时,BeanFactory仅实例化bean,而ApplicationContext 在启动容器的时候实例化单例bean,不会等待调用getBean()方法时再实例化。
- BeanFactory不支持国际化,即i18n,但ApplicationContext提供了对它的支持。
- BeanFactory与ApplicationContext之间的另一个区别是能够将事件发布到注册为监听器的bean。
- BeanFactory 的一个核心实现是XMLBeanFactory 而ApplicationContext 的一个核心实现是ClassPathXmlApplicationContext,Web容器的环境我们使用WebApplicationContext并且增加了getServletContext 方法。
- 如果使用自动注入并使用BeanFactory,则需要使用API注册AutoWiredBeanPostProcessor,如果使用ApplicationContext,则可以使用XML进行配置。
- 简而言之,BeanFactory提供基本的IOC和DI功能,而ApplicationContext提供高级功能,BeanFactory可用于测试和非生产使用,但ApplicationContext是功能更丰富的容器实现,应该优于BeanFactory
@Bean注解全解析
随着SpringBoot的流行,基于注解式开发的热潮逐渐覆盖了基于XML纯配置的开发,而作为Spring中最核心的bean当然也能够使用注解的方式进行表示。**所以本篇就来详细的讨论一下作为Spring中的Bean到底都有哪些用法。
@Bean 基础声明
Spring的@Bean注解用于告诉方法,产生一个Bean对象,然后这个Bean对象交给Spring管理。产生这个Bean对象的方法Spring只会调用一次,随后这个Spring将会将这个Bean对象放在自己的IOC容器中。
SpringIOC 容器管理一个或者多个bean,这些bean都需要在@Configuration注解下进行创建,在一个方法上使用@Bean注解就表明这个方法需要交给Spring进行管理。
快速搭建一个maven项目并配置好所需要的Spring 依赖
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.3.13.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>4.3.13.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>4.3.13.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>4.3.13.RELEASE</version>
</dependency>
在src根目录下创建一个AppConfig
的配置类,这个配置类也就是管理一个或多个bean 的配置类,并在其内部声明一个myBean的bean,并创建其对应的实体类
@Configuration
public class AppConfig {
// 使用@Bean 注解表明myBean需要交给Spring进行管理
// 未指定bean 的名称,默认采用的是 "方法名" + "首字母小写"的配置方式
@Bean
public MyBean myBean(){
return new MyBean();
}
}
public class MyBean {
public MyBean(){
System.out.println("MyBean Initializing");
}
}
在对应的test文件夹下创建一个测试类SpringBeanApplicationTests
,测试上述代码的正确性
public class SpringBeanApplicationTests {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
context.getBean("myBean");
}
}
输出 : MyBean Initializing
随着SpringBoot的流行,我们现在更多采用基于注解式的配置从而替换掉了基于XML的配置,所以本篇文章我们主要探讨基于注解的@Bean以及和其他注解的使用。
@Bean 基本构成及其使用
在简单介绍了一下如何声明一个Bean组件,并将其交给Spring进行管理之后,下面我们来介绍一下Spring 的基本构成
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Bean {
@AliasFor("name")
String[] value() default {};
@AliasFor("value")
String[] name() default {};
Autowire autowire() default Autowire.NO;
String initMethod() default "";
String destroyMethod() default AbstractBeanDefinition.INFER_METHOD;
}
@Bean不仅可以作用在方法上,也可以作用在注解类型上,在运行时提供注册。
value: name属性的别名,在不需要其他属性时使用,也就是说value 就是默认值
name: 此bean 的名称,或多个名称,主要的bean的名称加别名。如果未指定,则bean的名称是带注解方法的名称。如果指定了,方法的名称就会忽略,如果没有其他属性声明的话,bean的名称和别名可能通过value属性配置
autowire : 此注解的方法表示自动装配的类型,返回一个Autowire
类型的枚举,我们来看一下Autowire
枚举类型的概念
// 枚举确定自动装配状态:即,bean是否应该使用setter注入由Spring容器自动注入其依赖项。
// 这是Spring DI的核心概念
public enum Autowire {
// 常量,表示根本没有自动装配。
NO(AutowireCapableBeanFactory.AUTOWIRE_NO),
// 常量,通过名称进行自动装配
BY_NAME(AutowireCapableBeanFactory.AUTOWIRE_BY_NAME),
// 常量,通过类型进行自动装配
BY_TYPE(AutowireCapableBeanFactory.AUTOWIRE_BY_TYPE);
private final int value;
Autowire(int value) {
this.value = value;
}
public int value() {
return this.value;
}
public boolean isAutowire() {
return (this == BY_NAME || this == BY_TYPE);
}
}
autowire的默认值为No
,默认表示不通过自动装配。
initMethod: 这个可选择的方法在bean实例化的时候调用,InitializationBean
接口允许bean在合适的时机通过设置注解的初始化属性从而调用初始化方法,InitializationBean 接口有一个定义好的初始化方法
void afterPropertiesSet() throws Exception;
Spring不推荐使用InitializationBean 来调用其初始化方法,因为它不必要地将代码耦合到Spring。Spring推荐使用
@PostConstruct
注解或者为POJO类指定其初始化方法这两种方式来完成初始化。
**不推荐使用:
public class InitBean implements InitializingBean {
public void afterPropertiesSet() {}
}
destroyMethod: 方法的可选择名称在调用bean示例在关闭上下文的时候,例如JDBC的close()方法,或者SqlSession的close()方法。DisposableBean
接口的实现允许在bean销毁的时候进行回调调用,DisposableBean 接口之后一个单个的方法
void destroy() throws Exception;
Spring不推荐使用DisposableBean 的方式来初始化其方法,因为它会将不必要的代码耦合到Spring。作为替代性的建议,Spring 推荐使用
@PreDestory
注解或者为@Bean
注解提供 destroyMethod 属性,
**不推荐使用:
public class DestroyBean {
public void cleanup() {}
}
推荐使用:
public class MyBean {
public MyBean(){
System.out.println("MyBean Initializing");
}
public void init(){
System.out.println("Bean 初始化方法被调用");
}
public void destroy(){
System.out.println("Bean 销毁方法被调用");
}
}
@Configuration
public class AppConfig {
// @Bean
@Bean(initMethod = "init", destroyMethod = "destroy")
public MyBean myBean(){
return new MyBean();
}
}
修改一下测试类,测试其初始化方法和销毁方法在何时会被调用
public class SpringBeanApplicationTests {
public static void main(String[] args) {
// ------------------------------ 测试一 ------------------------------
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
// context.getBean("myBean");
// 变体
context.getBean("myBean");
((AnnotationConfigApplicationContext) context).destroy();
// ((AnnotationConfigApplicationContext) context).close();
}
}
初始化方法在得到Bean的实例的时候就会被调用,销毁方法在容器销毁或者容器关闭的时候会被调用。
@Bean 注解与其他注解产生的火花
在上面的一个小节中我们了解到了@Bean注解的几个属性,但是对于@Bean注解的功能来讲这有点太看不起bean了,@Bean另外一个重要的功能是能够和其他注解产生化学反应,如果你还不了解这些注解的话,那么请继续往下读,你会有收获的
这一节我们主要探讨@profile,@scope,@lazy,@depends-on @primary等注解
@Profile 注解
@Profile的作用是把一些meta-data进行分类,分成Active和InActive这两种状态,然后你可以选择在active 和在Inactive这两种状态下配置bean,在Inactive状态通常的注解有一个!操作符,通常写为:@Profile("!p"),这里的p是Profile的名字。
**三种设置方式:
-
可以通过ConfigurableEnvironment.setActiveProfiles()以编程的方式激活
-
可以通过AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME (spring.profiles.active )属性设置为
JVM属性
-
作为环境变量,或作为web.xml 应用程序的Servlet 上下文参数。也可以通过@ActiveProfiles 注解在集成测试中以声明方式激活配置文件。
**作用域
- 作为类级别的注释在任意类或者直接与@Component 进行关联,包括@Configuration 类
- 作为原注解,可以自定义注解
- 作为方法的注解作用在任何方法
注意:
如果一个配置类使用了Profile 标签或者@Profile 作用在任何类中都必须进行启用才会生效,如果@Profile({"p1","!p2"}) 标识两个属性,那么p1 是启用状态 而p2 是非启用状态的。
现有一个POJO类为Subject学科类,里面有两个属性,一个是like(理科)属性,一个是wenke(文科)属性,分别有两个配置类,一个是AppConfigWithActiveProfile
,一个是AppConfigWithInactiveProfile
,当系统环境是 "like"的时候就注册 AppConfigWithActiveProfile ,如果是 "wenke",就注册 AppConfigWithInactiveProfile,来看一下这个需求如何实现
Subject.java
// 学科
public class Subject {
// 理科
private String like;
// 文科
private String wenke;
get and set ...
@Override
public String toString() {
return "Subject{" +
"like='" + like + '\'' +
", wenke='" + wenke + '\'' +
'}';
}
}
AppConfigWithActiveProfile.java 注册Profile 为like 的时候
@Profile("like")
@Configuration
public class AppConfigWithActiveProfile {
@Bean
public Subject subject(){
Subject subject = new Subject();
subject.setLike("物理");
return subject;
}
}
AppConfigWithInactiveProfile.java 注册Profile 为wenke 的时候
@Profile("wenke")
@Configuration
public class AppConfigWithInactiveProfile {
@Bean
public Subject subject(){
Subject subject = new Subject();
subject.setWenke("历史");
return subject;
}
}
修改一下对应的测试类,设置系统环境,当Profile 为like 和 wenke 的时候分别注册各自对应的属性
// ------------------------------ 测试 profile ------------------------------
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
// 激活 like 的profile
context.getEnvironment().setActiveProfiles("like");
context.register(AppConfigWithActiveProfile.class,AppConfigWithInactiveProfile.class);
context.refresh();
Subject subject = (Subject) context.getBean("subject");
System.out.println("subject = " + subject);
把context.getEnvironment().setActiveProfiles("wenke") 设置为wenke,观察其对应的输出内容发生了变化,这就是@Profile的作用,有一层可选择性注册的意味。
@Scope 注解
在Spring中对于bean的默认处理都是单例的,我们通过上下文容器.getBean方法拿到bean容器,并对其进行实例化,这个实例化的过程其实只进行一次,即多次getBean 获取的对象都是同一个对象,也就相当于这个bean的实例在IOC容器中是public的,对于所有的bean请求来讲都可以共享此bean。
那么假如我不想把这个bean被所有的请求共享或者说每次调用我都想让它生成一个bean实例该怎么处理呢?
**多例Bean
bean的非单例原型范围会使每次发出对该特定bean的请求时都创建新的bean实例,也就是说,bean被注入另一个bean,或者通过对容器的getBean()方法调用来请求它,可以用如下图来表示:
通过一个示例来说明bean的多个实例
新建一个AppConfigWithAliasAndScope
配置类,用来定义多例的bean,
@Configuration
public class AppConfigWithAliasAndScope {
/
* 为myBean起两个名字,b1 和 b2
* @Scope 默认为 singleton,但是可以指定其作用域
* prototype 是多例的,即每一次调用都会生成一个新的实例。
*/
@Bean({"b1","b2"})
@Scope("prototype")
public MyBean myBean(){
return new MyBean();
}
}
测试一下多例的情况:
// ------------------------------ 测试scope ------------------------------
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfigWithAliasAndScope.class);
MyBean myBean = (MyBean) context.getBean("b1");
MyBean myBean2 = (MyBean) context.getBean("b2");
System.out.println(myBean);
System.out.println(myBean2);
**其他情况
除了多例的情况下,Spring还为我们定义了其他情况:
Scope | Descriptionn |
---|---|
singleton | 默认单例的bean定义信息,对于每个IOC容器来说都是单例对象 |
prototype | bean对象的定义为任意数量的对象实例 |
request | bean对象的定义为一次HTTP请求的生命周期,也就是说,每个HTTP请求都有自己的bean实例,它是在单个bean定义的后面创建的。仅仅在web-aware的上下文中有效 |
session | bean对象的定义为一次HTTP会话的生命周期。仅仅在web-aware的上下文中有效 |
application | bean对象的定义范围在ServletContext生命周期内。仅仅在web-aware的上下文中有效 |
websocket | bean对象的定义为WebSocket的生命周期内。仅仅在web-aware的上下文中有效 |
singleton和prototype 一般都用在普通的Java项目中,而request、session、application、websocket都用于web应用中。
request、session、application、websocket的作用范围
你可以体会到 request、session、application、websocket 的作用范围在当你使用web-aware的ApplicationContext应用程序上下文的时候,比如XmlWebApplicationContext
的实现类。如果你使用了像是ClassPathXmlApplicationContext
的上下文环境时,就会抛出IllegalStateException
因为Spring不认识这个作用范围。
@Lazy 注解
@Lazy
: 表明一个bean 是否延迟加载,可以作用在方法上,表示这个方法被延迟加载;可以作用在@Component (或者由@Component 作为原注解) 注释的类上,表明这个类中所有的bean 都被延迟加载。如果没有@Lazy注释,或者@Lazy 被设置为false,那么该bean 就会急切渴望被加载;除了上面两种作用域,@Lazy 还可以作用在@Autowired和@Inject注释的属性上,在这种情况下,它将为该字段创建一个惰性代理,作为使用ObjectFactory或Provider的默认方法。下面来演示一下:
@Lazy
@Configuration
@ComponentScan(basePackages = "com.spring.configuration.pojo")
public class AppConfigWithLazy {
@Bean
public MyBean myBean(){
System.out.println("myBean Initialized");
return new MyBean();
}
@Bean
public MyBean IfLazyInit(){
System.out.println("initialized");
return new MyBean();
}
}
- 修改测试类
public class SpringConfigurationApplication {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfigWithLazy.class);
// 获取启动过程中的bean 定义的名称
for(String str : context.getBeanDefinitionNames()){
System.out.println("str = " + str);
}
}
}
输出你会发现没有关于bean的定义信息,但是当把@Lazy 注释拿掉,你会发现输出了关于bean的初始化信息
@DependsOn 注解
指当前bean所依赖的bean。任何指定的bean都能保证在此bean创建之前由IOC容器创建。在bean没有通过属性或构造函数参数显式依赖于另一个bean的情况下很少使用,可能直接使用在任何直接或者间接使用 Component 或者Bean 注解表明的类上。来看一下具体的用法
新建三个Bean,分别是FirstBean、SecondBean、ThirdBean三个普通的bean,新建AppConfigWithDependsOn
并配置它们之间的依赖关系
public class FirstBean {
@Autowired
private SecondBean secondBean;
@Autowired
private ThirdBean thirdBean;
public FirstBean() {
System.out.println("FirstBean Initialized via Constuctor");
}
}
public class SecondBean {
public SecondBean() {
System.out.println("SecondBean Initialized via Constuctor");
}
}
public class ThirdBean {
public ThirdBean() {
System.out.println("ThirdBean Initialized via Constuctor");
}
}
@Configuration
public class AppConfigWithDependsOn {
@Bean("firstBean")
@DependsOn(value = {
"secondBean",
"thirdBean"
})
public FirstBean firstBean() {
return new FirstBean();
}
@Bean("secondBean")
public SecondBean secondBean() {
return new SecondBean();
}
@Bean("thirdBean")
public ThirdBean thirdBean() {
return new ThirdBean();
}
}
使用测试类进行测试,如下
// ------------------------------ 测试 DependsOn ------------------------------
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfigWithDependsOn.class);
context.getBean(FirstBean.class);
context.close();
输出 :
SecondBean Initialized via Constuctor
ThirdBean Initialized via Constuctor
FirstBean Initialized via Constuctor
由于firstBean 的创建过程首先需要依赖secondBean
和 thirdBean
的创建,所以secondBean 首先被加载其次是thirdBean 最后是firstBean。
如果把@DependsOn 注解加在AppConfigWithDependsOn
类上则它们的初始化顺序就会变为 firstBean、secondBean、thirdBean
@Primary 注解
指示当多个候选者有资格自动装配依赖项时,应优先考虑bean。此注解在语义上就等同于在Spring XML中定义的bean 元素的primary属性。注意: 除非使用component-scanning进行组件扫描,否则在类级别上使用@Primary不会有作用。如果@Primary 注解定义在XML中,那么@Primary 的注解元注解就会忽略,相反使用
**@Primary 的两种使用方式
- 与@Bean 一起使用,定义在方法上,方法级别的注解
- 与@Component 一起使用,定义在类上,类级别的注解
通过一则示例来演示一下:
新建一个AppConfigWithPrimary
类,在方法级别上定义@Primary注解
@Configuration
public class AppConfigWithPrimary {
@Bean
public MyBean myBeanOne(){
return new MyBean();
}
@Bean
@Primary
public MyBean myBeanTwo(){
return new MyBean();
}
}
上面代码定义了两个bean ,其中myBeanTwo 由@Primary 进行标注,表示它首先会进行注册,使用测试类进行测试
// ------------------------------ 测试 Primary ------------------------------
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfigWithPrimary.class);
MyBean bean = context.getBean(MyBean.class);
System.out.println(bean);
你可以尝试放开@Primary ,使用测试类测试的话会发现出现报错信息,因为你尝试获取的是MyBean.class,而我们代码中定义了两个MyBean 的类型,所以需要@Primary 注解表明哪一个bean需要优先被获取。
文章参考:
https://www.javaguides.net/2018/10/spring-dependson-annotation-example.html
PropertyPlaceholderConfigurer用法
一、PropertyPlaceholderConfigurer 的继承体系
`PropertyPlaceholderConfigurer`位于**org.springframework.beans.factory.config** 包下,它的继承体系如下
PropertyPlaceholderConfigurer 直接继承于PlaceholderConfigurerSupport
,它的已知实现类只有一个
PreferencesPlaceholderConfigurer
二、PropertyPlaceholderConfigurer 的基本概念
源自JavaDoc: PropertyPlaceholderConfigurer 是 **PlaceholderConfigurerSupport** 的一个子类,用来解析`${…}` 占位符的,可以使用`setLocation`和`setProperties`设置系统属性和环境变量。从Spring3.1 开始,**PropertySourcesPlaceholderConfigurer**应优先与此实现,通过使用Spring3.1 中的 **Environment**和 **PropertySource**机制, 使它的灵活性更强。
但是PropertyPlaceholderConfigurer却适用如下情况:当 `spring-context` 模块不可用的时候,使用**BeanFactory**的API 而不是**ApplicationContext**的API。现有配置使用**setSystemPropertiesMode** 和 **setSystemPropertiesModeName**属性,建议用户不要使用这些设置, 而是使用容器的**Environment**属性;
在Spring3.1 之前,<context:property-placeholder/>
命名空间保存了PropertyPlaceholderConfigurer的实例,如果使用spring-context-3.0 xsd的定义的话,仍然会这样做。也就是说,即使使用Spring 3.1,您也可以通过命名空间保留PropertyPlaceholderConfigurer; 只是不更新schemaLocation 并继续使用3.0 XSD。
三、PropertyPlaceholderConfigurer 的基本使用
- PropertyPlaceholderConfigurer是个bean工厂后置处理器的实现,也就是
BeanFactoryPostProcessor
接口的一个实现。PropertyPlaceholderConfigurer可以将上下文(配置文 件)中的属性值放在另一个单独的标准java Properties文件中去。在XML文件中用${…}替换指定的properties文件中的值。这样的话,只需要对properties文件进 行修改,而不用对xml配置文件进行修改。 - 在Spring中,使用PropertyPlaceholderConfigurer可以在XML配置文件中加入外部属性文件
PropertyPlaceholderConfigurer 引入外部属性文件
- 定义一个properties 属性文件
jdbc.driverClassName=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/sys
jdbc.username=root
jdbc.password=123456
这是一个最基本的配置数据库连接的设置,前缀统一使用jdbc来命名
- 定义xml用来获取上面properties中的内容
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.5.xsd">
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="location">
<value>database.properties</value>
</property>
</bean>
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="${jdbc.driverClassName}" />
<property name="url" value="${jdbc.url}" />
<property name="username" value="${jdbc.username}" />
<property name="password" value="${jdbc.password}" />
</bean>
</beans>
通过给PropertyPlaceholderConfigurer 设置一个bean,指定
的名称为location,指定value值就能够引入外部配置文件,然后就能够通过${jdbc.key} 来获取properties 中的值
PropertyPlaceholderConfigurer 引入多个属性文件
- 再来定义一个encoding.properties
file.encoding=utf-8
file.name=encoding
- PropertyPlaceholderConfigurer 引入多个属性文件比较简单,需要把location -> locations ,然后直接指定一个list 就能够引入
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="locations">
<list>
<value>database.properties</value>
<value>encoding.properties</value>
</list>
</property>
</bean>
- 这样,一个简单的数据源就设置完毕了。可以看出:PropertyPlaceholderConfigurer起的作用就是将占位符指向的数据库配置信息放在bean中定义的工具。
- 查看源代码,可以发现,locations属性定义在PropertyPlaceholderConfigurer的祖父类 PropertiesLoaderSupport中,而location只有 setter方法。类似于这样的配置,在spring的源程序中很常见的。PropertyPlaceholderConfigurer如果在指定的Properties文件中找不到你想使用的属性,它还会在Java的System类属性中查找。我们可以通过System.setProperty(key, value)或者java中通过-Dnamevalue来给Spring配置文件传递参数。
PropertyPlaceholderConfigurer 的替代方案
正如PropertyPlaceholderConfigurer基本概念中提到的,Spring可以使用`<context:property-placeholder/>` 作为PropertyPlaceholderConfigurer 的替代方案,代码如下
<!-- 指定单个properties -->
<!--<context:property-placeholder location="database.properties" />-->
<!-- 指定多个properties-->
<!--<context:property-placeholder location="classpath:*.properties"/>-->
<!--<context:property-placeholder location="classpath:database.properties, classpath:encoding.properties"/>-->
<!-- 指定配置文件加载顺序-->
<context:property-placeholder order="0" location="database.properties" />
<context:property-placeholder order="1" location="encoding.properties" />
四、自定义PropertyPlaceholderConfigurer
- 自定义一个SubPropertyPlaceholderConfigurer 继承自PropertyPlaceholderConfigurer
public class SubPropertyPlaceholderConfigurer extends PropertyPlaceholderConfigurer {
private static Map<String, String> ctxPropertiesMap;
@Override
protected void processProperties(ConfigurableListableBeanFactory beanFactoryToProcess, Properties props) throws BeansException {
// 调用父类PropertyPlaceholderConfigurer 的构造器
super.processProperties(beanFactoryToProcess, props);
// 遍历配置文件的key,Properties 对象就是导入的配置文件
Enumeration<?> enumeration = props.propertyNames();
while (enumeration.hasMoreElements()) {
System.out.println(enumeration.nextElement());
}
ctxPropertiesMap = new HashMap<String, String>();
for (Object key : props.keySet()) {
String keyStr = key.toString();
String value = props.getProperty(keyStr);
ctxPropertiesMap.put(keyStr, value);
}
}
public static String getProperty(String name){
return ctxPropertiesMap.get(name);
}
}
- 需要引入这个自定义的SubPropertyPlaceholderConfigurer
<bean id="propertyPlaceholderConfigurer" class="com.cxuan.spring.common.SubPropertyPlaceholderConfigurer">
<property name="location">
<value>database.properties</value>
</property>
</bean>
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="${jdbc.driverClassName}" />
<property name="url" value="${jdbc.url}" />
<property name="username" value="${jdbc.username}" />
<property name="password" value="${jdbc.password}" />
</bean>
如何启动呢?其实引入的SubPropertyPlaceholderConfigurer 就能够随着Spring加载配置文件而被加载。
直接定义main方法,用ClassPathXmlApplicayionContext引入任意的配置文件即可。
SpringAOP扫盲
关于AOP
面向切面编程(Aspect-oriented Programming,俗称AOP)提供了一种面向对象编程(Object-oriented Programming,俗称OOP)的补充,面向对象编程最核心的单元是类(class),然而面向切面编程最核心的单元是切面(Aspects)。与面向对象的顺序流程不同,AOP采用的是横向切面的方式,注入与主业务流程无关的功能,例如事务管理和日志管理。
Spring的一个关键组件是AOP框架。 虽然Spring IoC容器不依赖于AOP(意味着你不需要在IOC中依赖AOP),但AOP为Spring IoC提供了非常强大的中间件解决方案。
AOP 是一种编程范式,最早由 AOP 联盟的组织提出的,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。它是 OOP的延续。利用 AOP 可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率
我们之间的开发流程都是使用顺序流程,那么使用 AOP 之后,你就可以横向抽取重复代码,什么叫横向抽取呢?或许下面这幅图你能理解,先来看一下传统的软件开发存在什么样风险。
纵向继承体系:
在改进方案之前,我们或许都遇到过 IDEA 对你输出 Duplicate Code 的时候,这个时候的类的设计是很糟糕的,代码写的也很冗余,基本上 if…else… 完成所有事情,这个时候就需要把相同的代码抽取出来成为公共的方法,降低耦合性。这种提取代码的方式是纵向抽取,纵向抽取的代码之间的关联关系非常密切。 横向抽取也是代码提取的一种方式,不过这种方式不会修改主要业务逻辑代码,只是在此基础上添加一些与主要的业务逻辑无关的功能,AOP 采取横向抽取机制,补充了传统纵向继承体系(OOP)无法解决的重复性 代码优化(性能监视、事务管理、安全检查、缓存),将业务逻辑和系统处理的代码(关闭连接、事务管理、操作日志记录)解耦。
AOP 的概念
在深入学习SpringAOP 之前,让我们先对AOP的几个基本术语有个大致的概念,这些概念不是很容易理解,比较抽象,可以知道有这么几个概念,下面一起来看一下:
切面(Aspect)
: Aspect 声明类似于 Java 中的类声明,事务管理是AOP一个最典型的应用。在AOP中,切面一般使用@Aspect
注解来使用,在XML 中,可以使用<aop:aspect>
来定义一个切面。连接点(Join Point)
: 一个在程序执行期间的某一个操作,就像是执行一个方法或者处理一个异常。在Spring AOP中,一个连接点就代表了一个方法的执行。通知(Advice):
在切面中(类)的某个连接点(方法出)采取的动作,会有四种不同的通知方式: around(环绕通知),before(前置通知),after(后置通知), exception(异常通知),return(返回通知)。许多AOP框架(包括Spring)将建议把通知作为为拦截器,并在连接点周围维护一系列拦截器。切入点(Pointcut):
表示一组连接点,通知与切入点表达式有关,并在切入点匹配的任何连接点处运行(例如执行具有特定名称的方法)。**由切入点表达式匹配的连接点的概念是AOP的核心,Spring默认使用AspectJ切入点表达式语言。介绍(Introduction):
introduction可以为原有的对象增加新的属性和方法。例如,你可以使用introduction使bean实现IsModified接口,以简化缓存。目标对象(Target Object):
由一个或者多个切面代理的对象。也被称为"切面对象"。由于Spring AOP是使用运行时代理实现的,因此该对象始终是代理对象。AOP代理(AOP proxy):
由AOP框架创建的对象,在Spring框架中,AOP代理对象有两种:**JDK动态代理和CGLIB代理织入(Weaving):
是指把增强应用到目标对象来创建新的代理对象的过程,它(例如 AspectJ 编译器)可以在编译时期,加载时期或者运行时期完成。与其他纯Java AOP框架一样,Spring AOP在运行时进行织入。
Spring AOP 中通知的分类
- 前置通知(Before Advice): 在目标方法被调用前调用通知功能;相关的类
org.springframework.aop.MethodBeforeAdvice
- 后置通知(After Advice): 在目标方法被调用之后调用通知功能;相关的类
org.springframework.aop.AfterReturningAdvice
- 返回通知(After-returning): 在目标方法成功执行之后调用通知功能;
- 异常通知(After-throwing): 在目标方法抛出异常之后调用通知功能;相关的类
org.springframework.aop.ThrowsAdvice
- 环绕通知(Around): 把整个目标方法包裹起来,在被调用前和调用之后分别调用通知功能相关的类
org.aopalliance.intercept.MethodInterceptor
Spring AOP 中织入的三种时期
编译期:
切面在目标类编译时被织入,这种方式需要特殊的编译器。**AspectJ 的织入编译器就是以这种方式织入切面的。类加载期:
切面在目标类加载到 JVM 时被织入,这种方式需要特殊的类加载器( ClassLoader ),它可以在目标类引入应用之前增强目标类的字节码。运行期:
切面在应用运行的某个时期被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态创建一个代理对象,**Spring AOP 采用的就是这种织入方式。
AOP 的两种实现方式
AOP 采用了两种实现方式:静态织入(AspectJ 实现)和动态代理(Spring AOP实现)
AspectJ
AspectJ 是一个采用Java 实现的AOP框架,它能够对代码进行编译(一般在编译期进行),让代码具有AspectJ 的 AOP 功能,AspectJ 是目前实现 AOP 框架中最成熟,功能最丰富的语言。ApectJ 主要采用的是编译期静态织入的方式。在这个期间使用 AspectJ 的 acj 编译器(类似 javac)把 aspect 类编译成 class 字节码后,在 java 目标类编译时织入,即先编译 aspect 类再编译目标类。
Spring AOP 实现
Spring AOP 是通过动态代理技术实现的,而动态代理是基于反射设计的。Spring AOP 采用了两种混合的实现方式:JDK 动态代理和 CGLib 动态代理,分别来理解一下
- JDK动态代理:Spring AOP的首选方法。 每当目标对象实现一个接口时,就会使用JDK动态代理。**目标对象必须实现接口
- CGLIB代理:如果目标对象没有实现接口,则可以使用CGLIB代理。
Spring 对 AOP的支持
Spring 提供了两种AOP 的实现:基于注解式配置和基于XML配置
@AspectJ 支持
为了在Spring 配置中使用@AspectJ
,你需要启用Spring支持,以根据@AspectJ切面配置Spring AOP,并配置自动代理。自动代理意味着,Spring 会根据自动代理为 Bean 生成代理来拦截方法的调用,并确保根据需要执行拦截。
可以使用XML或Java样式配置启用@AspectJ支持。 在任何一种情况下,都还需要确保AspectJ的aspectjweaver.jar
第三方库位于应用程序的类路径中(版本1.8或更高版本)。
开启@AspectJ 支持
使用@Configuration
支持@AspectJ 的时候,需要添加 @EnableAspectJAutoProxy
注解,就像下面例子展示的这样来开启 AOP代理
@Configuration
@EnableAspectJAutoProxy
public class AppConfig {}
也可以使用XML配置来开启@AspectJ 支持
<aop:aspectj-autoproxy/>
默认你已经添加了 aop 的schema 空间,如果没有的话,你需要手动添加
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="
http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd">
<!-- bean definitions here -->
</beans>
声明一个切面
在启用了@AspectJ支持的情况下,在应用程序上下文中定义的任何bean都具有@AspectJ方面的类(具有@Aspect注释),Spring会自动检测并用于配置Spring AOP。
使用XML 配置的方式定义一个切面
<aop:aspect />
使用注解的方式定义一个切面
@Aspect
public class MyAspect {}
切面(也就是用@Aspect注解的类)就像其他类一样有属性和方法。它们能够包含切入点,通知和介绍声明。
**通过自动扫描检测切面
你可以在Spring XML 配置中将切面类注册为常规的bean,或者通过类路径扫描自动检测它们 – 与任何其他Spring管理的bean相同。然而,只是注解了@Aspect 的类不会被当作bean 进行管理,你还需要在类上面添加 @Component 注解,把它当作一个组件交给 Spring 管理。
定义一个切点
一个切点由两部分组成:包含名称和任何参数以及切入点表达式的签名,该表达式能够确定我们想要执行的方法。在@AspectJ注释风格的AOP中,切入点表达式需要用@Pointcut
注解标注(这个表达式作为方法的签名,它的返回值必须是 void)。
@Pointcut("execution(* transfer(..))") // 切入点表达式
private void definePointcut() {}// 方法签名
切入点表达式的编写规则如下:
现在假设我们需要配置的切点仅仅匹配指定的包,就可以使用 within()
限定符来表示,如下表达式所述:
请注意我们使用了 &&
操作符把 execution() 和 within() 指示器连接在一起,表示的是 和 的关系,类似的,你还可以使用 ||
操作来表示 或 的关系, 使用 !
表示 非 的关系。
除了within() 表示的限定符外,还有其它的限定符,下面是一个限定符表
AspectJ 描述符 | 描述 |
---|---|
arg() | 限制连接点匹配参数为指定类型的执行方法 |
@args() | 限制连接点匹配参数由指定注解标注的执行方法 |
execution() | 用于匹配是连接点的执行方法 |
this() | 限制连接点匹配的AOP代理的bean引用为指定类型的类 |
target | 限制连接点匹配目标对象为指定类型的类 |
@target() | 限制连接点匹配特定的执行对象,这些对象对应的类要具有指定类型的注解 |
within() | 限制连接点匹配指定的类型 |
@within() | 限制连接点匹配指定注解所标注的类型 |
@annotationn | 限定匹配带有指定注解的连接点 |
使用XML配置来配置切点
<aop:config>
<aop:aspect ref = "">
<aop:poincut id = "" expression="execution(** com.cxuan.aop.definePointcut(......))"/>
</aop:aspect>
</aop:config>
声明一个通知
通知是和切入点表达式相互关联,用于在方法执行之前,之后或者方法前后,方法返回,方法抛出异常时调用通知的方法,切入点表达式可以是对命名切入点的简单引用,也可以是在适当位置声明的切入点表达式。下面以一个例子来演示一下这些通知都是如何定义的:
上面的例子就很清晰了,定义了一个 Audience
切面,并在切面中定义了一个performance()
的切点,下面各自定义了表演之前、表演之后返回、表演失败的时候进行通知,除此之外,你还需要在main 方法中开启 @EnableAspectJAutoProxy
来开启自动代理。
除了使用Java Config 的方式外,你还可以使用基于XML的配置方式
当然,这种切点定义的比较冗余,为了解决这种类似 if...else...
灾难性的业务逻辑,你需要单独定义一个<aop:pointcut>
,然后使用 pointcut-ref
属性指向上面那个标签,就像下面这样
**环绕通知
在目标方法执行之前和之后都可以执行额外代码的通知。在环绕通知中必须显式的调用目标方法,目标方法才会执行,这个显式调用时通过ProceedingJoinPoint
来实现的,可以在环绕通知中接收一个此类型的形参,spring容器会自动将该对象传入,注意这个参数必须处在环绕通知的第一个形参位置。
环绕通知需要返回返回值,否则真正调用者将拿不到返回值,只能得到一个null。下面是环绕通知的一个示例
<aop:around method="around" pointcut-ref="pc1"/>
public Object around(ProceedingJoinPoint jp) throws Throwable{
System.out.println("1 -- around before...");
Object obj = jp.proceed(); //--显式的调用目标方法
System.out.println("1 -- around after...");
return obj;
}
文章参考:
https://juejin.im/post/5a695b3cf265da3e47449471
《Spring In Action》
https://docs.spring.io/spring/docs/5.1.9.RELEASE/spring-framework-reference/core.html
Spring AOP 五大通知类型https://www.cnblogs.com/chuijingjing/p/9806651.html)
一文了解ConfigurationConditon接口
ConfigurationCondition 接口说明
@Conditional 和 Condition
在了解ConfigurationCondition 接口之前,先通过一个示例来了解一下@Conditional 和 Condition。(你也可以通过 https://www.cnblogs.com/cxuanBlog/p/10960575.html 详细了解)
- 首先新建一个Maven项目(可以使用SpringBoot快速搭建),添加Spring4.0 的pom.xml 依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cxuan.configuration</groupId>
<artifactId>configuration-condition</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>configuration-condition</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring.version>4.3.13.RELEASE</spring.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 新建一个
IfBeanAExistsCondition
类,该类继承了Condition接口,提供某些注册条件的逻辑
public class IfBeanAExistsCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
boolean IfContainsbeanA = context.getBeanFactory().containsBeanDefinition("beanA");
return IfContainsbeanA;
}
}
Condition是一个接口,里面只有一个方法就是matches,上述表明如果ConditionContext的beanFactory包括名称为beanA的bean就返回true,否则返回false不进行注册。
- 为了测试Condition是否可用,我们新建一个
ConfigurationConditionApplication
类,注册两个Bean分别为BeanA和BeanB,BeanB的注册条件是BeanA首先进行注册,采用手动注册和刷新的方式。详见https://www.cnblogs.com/cxuanBlog/p/10958307.html,具体代码如下:
public class ConfigurationConditionApplication {
private static void loadContextAndVerifyBeans(Class...classToRegistry){
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(classToRegistry);
context.refresh();
System.out.println("Has BeanA? " + context.containsBean("beanA"));
System.out.println("Has BeanB? " + context.containsBean("beanB"));
}
public static void main(String[] args) {
loadContextAndVerifyBeans(BeanA.class);
loadContextAndVerifyBeans(BeanA.class,BeanB.class);
loadContextAndVerifyBeans(BeanB.class);
loadContextAndVerifyBeans(BeanB.class,BeanA.class);
}
}
@Configuration()
class BeanA{}
@Conditional(IfBeanAExistsCondition.class)
@Configuration()
class BeanB{}
输出结果:
...
Has BeanA? true
Has BeanB? false
...
Has BeanA? true
Has BeanB? true
...
Has BeanA? false
Has BeanB? false
...
Has BeanA? true
Has BeanB? false
来解释一下上面的输出结果,第一次只注册了一个BeanA的bean,@Configuration标注的BeanA默认注册的definitionName为beanA,首字母小写。
第二次同时传入了BeanA.class 和 BeanB.class, 由于BeanB的注解上标明@Conditional(IfBeanAExistsCondition.class)表示的是注册BeanA之后才会注册BeanB,所以注册了beanA,因为beanA被注册了,所以同时也就注册了beanB。
第三次只传入了BeanB.class,因为没有注册BeanA和BeanB,所以两次输出都是false。
第四次先传入了BeanB.class,后又传入了BeanA.class,根据加载顺序来看,BeanB.class 首先被加载,然后是BeanA.class 被加载,BeanB被加载的时候BeanA.class 还没有被注入,之后BeanA才会注入,所以输出的结果是true和false。
上述例子可以把BeanA和BeanB类放入ConfigurationConditionApplication中,类似
``` public class ConfigurationConditionApplication {
@Configuration() static class BeanA{}
@Conditional(IfBeanAExistsCondition.class) @Configuration() static class BeanB{}
} ```
但是需要把BeanA和BeanB定义为静态类,因为静态类与外部类无关能够独立存在,如果定义为非静态的,启动会报错。
关于ConfigurationConditon
ConfigurationCondition接口是Spring4.0提供的注解。位于org.springframework.context.annotation包内,继承于Condition接口。Condition接口和@Configuration以及@Conditional接口为bean的注册提供更细粒度的控制,允许某些Condition在匹配时根据配置阶段进行调整。
public interface ConfigurationCondition extends Condition {
// 评估condition返回的ConfigurationPhase
ConfigurationPhase getConfigurationPhase();
// 可以评估condition的各种配置阶段。
enum ConfigurationPhase {
// @Condition 应该被评估为正在解析@Configuration类
// 如果此时条件不匹配,则不会添加@Configuration 类。
PARSE_CONFIGURATION,
// 添加常规(非@Configuration)bean时,应评估@Condition。Condition 将不会阻止@Configuration 类
// 添加。在评估条件时,将解析所有@Configuration
REGISTER_BEAN
}
}
getConfigurationPhase()方法返回ConfigurationPhase 的枚举。枚举类内定义了两个enum,PARSE_CONFIGURATION 和 REGISTER_BEAN,表示不同的注册阶段。
我们现在对condition实现更细粒度的控制,实现了ConfigurationCondition接口,我们现在需要实现getConfigurationPhase()方法获得condition需要评估的阶段。
- 新建
IfBeanAExistsConfigurationCondition
类,实现了ConfigurationCondition接口,分别返回ConfigurationPhase.REGISTER_BEAN 和 ConfigurationPhase.PARSE_CONFIGURATION 阶段。
public class IfBeanAExistsConfigurationCondition implements ConfigurationCondition {
@Override
public ConfigurationPhase getConfigurationPhase() {
return ConfigurationPhase.REGISTER_BEAN;
}
// @Override
// public ConfigurationPhase getConfigurationPhase() {
// return ConfigurationPhase.PARSE_CONFIGURATION;
// }
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return context.getBeanFactory().containsBeanDefinition("beanA");
}
}
- 新建
SpringConfigurationConditionExample
类,与上述测试类基本相同,就是把@Conditional 换为了**@Conditional(IfBeanAExistsConfigurationCondition.class)
测试类启动,输出结果
...
Has BeanA? true
Has BeanB? false
...
Has BeanA? true
Has BeanB? true
...
Has BeanA? false
Has BeanB? false
...
Has BeanA? true
Has BeanB? true
也就是说,如果返回的是PARSE_CONFIGURATION阶段的话,不会阻止@Configuration的标记类的注册顺序,啥意思呢?
第一个结果,只注册了BeanA,因为只有BeanA加载。
第二个结果,注册了BeanA和BeanB,因为BeanA和BeanB都被加载
第三个结果,因为BeanB注册的条件是BeanA注册,因为BeanA没有注册,所以BeanB不会注册
第四个结果,不论BeanA和BeanB的加载顺序如何,都会直接进行注册。
- 如果把REGISTER_BEAN改为PARSE_CONFIGURATION ,会发现加载顺序第一次一致。
浅析PropertySource基本使用
一、PropertySource 简介
org.springframework.context.annotation.PropertySource 是一个注解,可以标记在类上、接口上、枚举上,在运行时起作用。而@Repeatable(value = PropertySources.class) 表示在PropertySources 中此注解时可以重复使用的。
二、@PropertySource与Environment读取配置文件
此注解@PropertySource 为Spring 中的 Environment提供方便和声明机制,通常与Configuration一起搭配使用。
- 新建一个maven 项目,添加pom.xml 依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.spring.propertysource</groupId>
<artifactId>spring-propertysource</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-propertysource</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring.version>4.3.13.RELEASE</spring.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.2</version>
<configuration>
<source>1.6</source>
<target>1.6</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
一般把版本名称统一定义在
标签中,便于统一管理,如上可以通过 ${…}
来获取指定版本。
- 定义一个application.properties 来写入如下配置
com.spring.name=liuXuan
com.spring.age=18
- 新建一个TestBean,定义几个属性
public class TestBean {
private String name;
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "TestBean{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
- 新建一个main class ,用来演示@PropertySource 的使用
@Configuration
@PropertySource(value = "classpath:application.properties",ignoreResourceNotFound = false)
public class SpringPropertysourceApplication {
@Resource
Environment environment;
@Bean
public TestBean testBean(){
TestBean testBean = new TestBean();
// 读取application.properties中的name
testBean.setName(environment.getProperty("com.spring.name"));
// 读取application.properties中的age
testBean.setAge(Integer.valueOf(environment.getProperty("com.spring.age")));
System.out.println("testBean = " + testBean);
return testBean;
}
public static void main(String[] args) {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(SpringPropertysourceApplication.class);
TestBean testBean = (TestBean)applicationContext.getBean("testBean");
}
}
输出:
testBean = TestBean{name=’liuXuan’, age=18} Refreshing the spring context
**@Configuration : 相当于
标签,注意不是 ,一个配置类可以有多个bean,但是只能有一个 **@PropertySource: 用于引入外部属性配置,和Environment 配合一起使用。其中ignoreResourceNotFound 表示没有找到文件是否会报错,默认为false,就是会报错,一般开发情况应该使用默认值,设置为true相当于生吞异常,增加排查问题的复杂性。
引入PropertySource,注入Environment,然后就能用environment 获取配置文件中的value值。
三、@PropertySource与@Value读取配置文件
@Value 基本使用
我们以DB的配置文件为例,来看一下如何使用@Value
读取配置文件
- 首先新建一个DBConnection,具体代码如下:
// 组件bean
@Component
@PropertySource("classpath:db.properties")
public class DBConnection {
@Value("${DB_DRIVER_CLASS}")
private String driverClass;
@Value("${DB_URL}")
private String dbUrl;
@Value("${DB_USERNAME}")
private String userName;
@Value("${DB_PASSWORD}")
private String password;
public DBConnection(){}
public void printDBConfigs(){
System.out.println("Db Driver Class = " + driverClass);
System.out.println("Db url = " + dbUrl);
System.out.println("Db username = " + userName);
System.out.println("Db password = " + password);
}
}
类上加入@Component 表示这是一个组件bean,需要被spring进行管理,@PropertySource 用于获取类路径下的db.properties 配置文件,@Value用于获取properties中的key 对应的value值,printDBConfigs方法打印出来对应的值。
- 新建一个db.properties,具体文件如下
#MYSQL Database Configurations
DB_DRIVER_CLASS=com.mysql.jdbc.Driver
DB_URL=jdbc:mysql://localhost:3306/test
DB_USERNAME=cxuan
DB_PASSWORD=111111
APP_NAME=PropertySourceExample
这是一个MYSQL连接数据库驱动的配置文件。
- 新建一个SpringMainClass,用于测试DBConection中是否能够获取到@Value的值
public class SpringMainClass {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
// 注解扫描,和@ComponentScan 和 基于XML的配置<context:component-scan base-package>相同
context.scan("com.spring.propertysource.app");
// 刷新上下文环境
context.refresh();
System.out.println("Refreshing the spring context");
// 获取DBConnection这个Bean,调用其中的方法
DBConnection dbConnection = context.getBean(DBConnection.class);
dbConnection.printDBConfigs();
// 关闭容器(可以省略,容器可以自动关闭)
context.close();
}
}
输出:
Refreshing the spring context Db Driver Class = com.mysql.jdbc.Driver Db url = jdbc:mysql://localhost:3306/test Db username = cxuan Db password = 111111
@Value 高级用法
在实现了上述的例子之后,我们再来看一下@Value 的高级用法:
- @Value 可以直接为字段赋值,例如:
@Value("cxuan")
String name;
@Value(10)
Integer age;
@Value("${APP_NAME_NOT_FOUND:Default}")
private String defaultAppName;
- @Value 可以直接获取系统属性,例如:
@Value("${java.home}")
// @Value("#{systemProperties['java.home']}") SPEL 表达式
String javaHome;
@Value("${HOME}")
String dir;
- @Value 可以注解在方法和参数上
@Value("Test") // 可以直接使用Test 进行单元测试
public void printValues(String s, @Value("another variable") String v) {
...
}
修改DBConnection后的代码如下:
public class DBConnection {
@Value("${DB_DRIVER_CLASS}")
private String driverClass;
@Value("${DB_URL}")
private String dbUrl;
@Value("${DB_USERNAME}")
private String userName;
@Value("${DB_PASSWORD}")
private String password;
public DBConnection(){}
public void printDBConfigs(){
System.out.println("Db Driver Class = " + driverClass);
System.out.println("Db url = " + dbUrl);
System.out.println("Db username = " + userName);
System.out.println("Db password = " + password);
}
}
在com.spring.propertysource.app 下 新增DBConfiguration,作用是配置管理类,管理DBConnection,并读取配置文件,代码如下:
@Configuration
@PropertySources({
@PropertySource("classpath:db.properties"),
@PropertySource(value = "classpath:root.properties", ignoreResourceNotFound = true)
})
public class DBConfiguration {
@Value("Default DBConfiguration")
private String defaultName;
@Value("true")
private boolean defaultBoolean;
@Value("10")
private int defaultInt;
@Value("${APP_NAME_NOT_FOUND:Default}")
private String defaultAppName;
@Value("#{systemProperties['java.home']}")
// @Value("${java.home}")
private String javaHome;
@Value("${HOME}")
private String homeDir;
@Bean
public DBConnection getDBConnection() {
DBConnection dbConnection = new DBConnection();
return dbConnection;
}
@Value("Test") // 开启测试
public void printValues(String s, @Value("another variable") String v) {
System.out.println("Input Argument 1 = " + s);
System.out.println("Input Argument 2 = " + v);
System.out.println("Home Directory = " + homeDir);
System.out.println("Default Configuration Name = " + defaultName);
System.out.println("Default App Name = " + defaultAppName);
System.out.println("Java Home = " + javaHome);
System.out.println("Home dir = " + homeDir);
System.out.println("Boolean = " + defaultBoolean);
System.out.println("Int = " + defaultInt);
}
}
使用SpringMainClass 进行测试,测试结果如下:
Input Argument 1 = Test Input Argument 2 = another variable Home Directory = /Users/mr.l Default Configuration Name = Default DBConfiguration Default App Name = Default Java Home = /Library/Java/JavaVirtualMachines/jdk1.8.0_191.jdk/Contents/Home/jre Home dir = /Users/mr.l Boolean = true Int = 10 Refreshing the spring context
Db Driver Class = com.mysql.jdbc.Driver Db url = jdbc:mysql://localhost:3306/test Db username = cxuan Db password = 111111
可以看到上述代码并没有显示调用printValues 方法,默认是以单元测试的方式进行的。
四、@PropertySource 与 @Import
@Import 可以用来导入 @PropertySource 标注的类,具体代码如下:
- 新建一个PropertySourceReadApplication 类,用于读取配置文件并测试,具体代码如下:
// 导入BasicPropertyWithJavaConfig类
@Import(BasicPropertyWithJavaConfig.class)
public class PropertySourceReadApplication {
@Resource
private Environment env;
@Value("${com.spring.name}")
private String name;
@Bean("context")
public PropertySourceReadApplication contextLoadInitialized(){
// 用environment 读取配置文件
System.out.println(env.getProperty("com.spring.age"));
// 用@Value 读取配置文件
System.out.println("name = " + name);
return null;
}
public static void main(String[] args) {
// AnnotationConnfigApplicationContext 内部会注册Bean
new AnnotationConfigApplicationContext(PropertySourceReadApplication.class);
}
}
- 新建一个BasicPropertyWithJavaConfig 类,用于配置类并加载配置文件
@Configuration
@PropertySource(value = "classpath:application.properties")
public class BasicPropertyWithJavaConfig {
public BasicPropertyWithJavaConfig(){
super();
}
}
启动PropertySourceReadApplication ,console能够发现读取到配置文件中的value值
18 name = cxuan
Resource体系介绍
Resource介绍
在使用spring作为容器进行项目开发中会有很多的配置文件,这些配置文件都是通过Spring的Resource接口来实现加载,但是,Resource对于所有低级资源的访问都不够充分。例如,没有标准化的URL实现可用于访问需要从类路径或相对于ServletContext
获取的资源。(更多关于ServletContext
的理解,请访问https://www.cnblogs.com/cxuanBlog/p/10927813.html)虽然可以为专用的URL前缀注册新的处理程序(类似于http :)这样的前缀的现有处理程序,但这通常非常复杂,并且URL接口仍然缺少一些理想的功能,例如检查存在的方法被指向的资源。
JavaDoc解释
从实际类型的底层资源(例如文件或类路径资源)中抽象出来的资源描述符的接口。
Resource接口方法
Spring的Resource接口旨在成为一个更有能力的接口,用于抽象对低级资源的访问。以下清单显示了Resource接口定义
public interface Resource extends InputStreamSource {
boolean exists();
default boolean isReadable() {
return true;
}
default boolean isOpen() {
return false;
}
default boolean isFile() {
return false;
}
URL getURL() throws IOException;
URI getURI() throws IOException;
File getFile() throws IOException;
default ReadableByteChannel readableChannel() throws IOException {
return Channels.newChannel(getInputStream());
}
long contentLength() throws IOException;
long lastModified() throws IOException;
Resource createRelative(String relativePath) throws IOException;
String getFilename();
String getDescription();
}
Resource接口继承了InputStreamSource
接口,提供了很多InputStreamSource
所没有的方法
下面来看一下InputStreamSource
接口,只有一个方法
public interface InputStreamSource {
InputStream getInputStream() throws IOException;
}
**其中一些大部分重要的接口是:
getInputStream()
: 找到并打开资源,返回一个InputStream以从资源中读取。预计每次调用都会返回一个新的InputStream(),调用者有责任关闭每个流exists()
: 返回一个布尔值,表明某个资源是否以物理形式存在isOpen
: 返回一个布尔值,指示此资源是否具有开放流的句柄。如果为true,InputStream就不能够多次读取,只能够读取一次并且及时关闭以避免内存泄漏。对于所有常规资源实现,返回false,但是InputStreamResource除外。getDescription()
: 返回资源的描述,用来输出错误的日志。这通常是完全限定的文件名或资源的实际URL。
其他方法:
isReadable()
: 表明资源的目录读取是否通过getInputStream()进行读取。isFile()
: 表明这个资源是否代表了一个文件系统的文件。getURL()
: 返回一个URL句柄,如果资源不能够被解析为URL,将抛出IOExceptiongetURI()
: 返回一个资源的URI句柄getFile()
: 返回某个文件,如果资源不能够被解析称为绝对路径,将会抛出FileNotFoundExceptionlastModified()
: 资源最后一次修改的时间戳createRelative()
: 创建此资源的相关资源getFilename()
: 资源的文件名是什么 例如:最后一部分的文件名 myfile.txt
Resource的实现类
Resource 接口是 Spring 资源访问策略的抽象,它本身并不提供任何资源访问实现,具体的资源访问由该接口的实现类完成——每个实现类代表一种资源访问策略。
基础类介绍
Resource一般包括这些实现类:**UrlResource、ClassPathResource、FileSystemResource、ServletContextResource、InputStreamResource、ByteArrayResource
使用UrlResource访问网络资源
访问网络资源的实现类。Resource的一个实现类用来定位URL中的资源。它支持URL的绝对路径,用来作为file: 端口的一个资源,创建一个maven项目,配置Spring依赖(不再赘述)和dom4j 的依赖,并在根目录下创建一个books.xml。
代码表示:
public class UrlResourceTest {
public static void loadAndReadUrlResource(String path) throws Exception{
// 创建一个 Resource 对象,指定从文件系统里读取资源,相对路径
UrlResource resource = new UrlResource(path);
// 绝对路径
// UrlResource resource = new UrlResource("file:///Users/mr.l/test/CXuan-Spring/CXuan-Spring-Resource/books.xml");
// 获取文件名
System.out.println("resource.getFileName = " + resource.getFilename());
// 获取文件描述
System.out.println("resource.getDescription = "+ resource.getDescription());
SAXReader reader = new SAXReader();
System.out.println(resource.getFile());
Document document = reader.read("file:///Users/mr.l/test/CXuan-Spring/CXuan-Spring-Resource/books.xml");
Element parent = document.getRootElement();
List<Element> elements = parent.elements();
for(Element element : elements){
// 获取name,description,price
System.out.println(element.getName() + " = " +element.getText());
}
}
public static void main(String[] args) throws Exception {
loadAndReadUrlResource("file:books.xml");
}
}
上面程序使用UrlResource来访问网络资源,也可以通过file 前缀访问本地资源,上述代码就是这样做的。如果要访问网络资源,可以有两种形式
- http:-该前缀用于访问基于 HTTP 协议的网络资源。
- ftp:-该前缀用于访问基于 FTP 协议的网络资源。
使用ClassPathResource 访问类加载路径下的资源
ClassPathResource 用来访问类加载路径下的资源,相对于其他的 Resource 实现类,其主要优势是方便访问类加载路径里的资源,尤其对于 Web 应用,ClassPathResource 可自动搜索位于 WEB-INF/classes 下的资源文件,无须使用绝对路径访问。
public class ClassPathResourceTest {
public static void loadAndReadUrlResource(String path) throws Exception{
// 创建一个 Resource 对象,指定从文件系统里读取资源,相对路径
ClassPathResource resource = new ClassPathResource(path);
// 绝对路径
// UrlResource resource = new UrlResource("file:///Users/mr.l/test/CXuan-Spring/CXuan-Spring-Resource/books.xml");
// 获取文件名
System.out.println("resource.getFileName = " + resource.getFilename());
// 获取文件描述
System.out.println("resource.getDescription = "+ resource.getDescription());
SAXReader reader = new SAXReader();
System.out.println(resource.getPath());
Document document = reader.read("file:///Users/mr.l/test/CXuan-Spring/CXuan-Spring-Resource/books.xml");
Element parent = document.getRootElement();
List<Element> elements = parent.elements();
for(Element element : elements){
// 获取name,description,price
System.out.println(element.getName() + " = " +element.getText());
}
}
public static void main(String[] args) throws Exception {
loadAndReadUrlResource("books.xml");
}
}
除了以上新建方式的不同,其他代码和上述代码一致,这就是 Spring 资源访问的优势:Spring 的资源访问消除了底层资源访问的差异,允许程序以一致的方式来访问不同的底层资源。
使用FileSystemResource 访问文件资源系统
Spring 提供的 FileSystemResource 类用于访问文件系统资源,使用 FileSystemResource 来访问文件系统资源并没有太大的优势,因为 Java 提供的 File 类也可用于访问文件系统资源。
当然使用 FileSystemResource 也可消除底层资源访问的差异,程序通过统一的 Resource API 来进行资源访问。下面程序是使用 FileSystemResource 来访问文件系统资源的示例程序。
public class FileSystemResourceTest {
public static void loadAndReadUrlResource(String path) throws Exception{
// 创建一个 Resource 对象,指定从文件系统里读取资源,相对路径
FileSystemResource resource = new FileSystemResource(path);
// 绝对路径
// UrlResource resource = new UrlResource("file:///Users/mr.l/test/CXuan-Spring/CXuan-Spring-Resource/books.xml");
// 获取文件名
System.out.println("resource.getFileName = " + resource.getFilename());
// 获取文件描述
System.out.println("resource.getDescription = "+ resource.getDescription());
SAXReader reader = new SAXReader();
System.out.println(resource.getFile());
Document document = reader.read("file:///Users/mr.l/test/CXuan-Spring/CXuan-Spring-Resource/books.xml");
Element parent = document.getRootElement();
List<Element> elements = parent.elements();
for(Element element : elements){
// 获取name,description,price
System.out.println(element.getName() + " = " +element.getText());
}
}
public static void main(String[] args) throws Exception {
loadAndReadUrlResource("books.xml");
}
}
FileSystemResource 实例可使用 FileSystemResource 构造器显式地创建。但更多的时候它都是隐式创建的,执行 Spring 的某个方法时,该方法接受一个代表资源路径的字符串参数,当 Spring 识别该字符串参数中包含 file: 前缀后,系统将会自动创建 FileSystemResource 对象。
ServletContextResource
这是ServletContext资源的Resource实现,它解释相关Web应用程序根目录中的相对路径。
它始终支持流(stream)访问和URL访问,但只有在扩展Web应用程序存档且资源实际位于文件系统上时才允许java.io.File访问。无论它是在文件系统上扩展还是直接从JAR或其他地方(如数据库)访问,实际上都依赖于Servlet容器。
InputStreamResource
InputStreamResource 是给定的输入流(InputStream)的Resource实现。它的使用场景在没有特定的资源实现的时候使用(感觉和@Component 的适用场景很相似)。
与其他Resource实现相比,这是已打开资源的描述符。 因此,它的isOpen()方法返回true。如果需要将资源描述符保留在某处或者需要多次读取流,请不要使用它。
ByteArrayResource
字节数组的Resource实现类。通过给定的数组创建了一个ByteArrayInputStream。
它对于从任何给定的字节数组加载内容非常有用,而无需求助于单次使用的InputStreamResource。
Resource类图与策略模式
上述Resource实现类与Resource顶级接口之间的关系可以用下面的UML关系模型来表示
策略模式
上述流程图是不是对同一行为的不同实现方式,这种实现方式像极了策略模式?具体关于策略模式的文章,请参考
https://www.runoob.com/design-pattern/strategy-pattern.html
ResourceLoader 接口
ResourceLoader接口旨在由可以返回(即加载)Resource实例的对象实现,该接口实现类的实例将获得一个 ResourceLoader 的引用。下面是ResourceLoader的定义
public interface ResourceLoader {
//该接口仅包含这个方法,该方法用于返回一个 Resource 实例。ApplicationContext 的实现类都实现 ResourceLoader 接口,因此 ApplicationContext 可用于直接获取 Resource 实例
Resource getResource(String location);
}
所有的应用程序上下文都实现了ResourceLoader接口。因此,所有的应用程序上下文都可能会获取Resource实例。
在特定应用程序上下文上调用getResource()并且指定的位置路径没有特定前缀时,将返回适合该特定应用程序上下文的Resource类型。 例如,假设针对ClassPathXmlApplicationContext实例执行了以下代码:
Resource template = ctx.getResource("some/resource/path/myTemplate.txt");
你暂时不知道具体的上下文资源类型是什么,假设指定的是ClassPathXmlApplicationContext
,上述代码就会返回ClassPathResource
,如果执行上面相同的方法的是FileSystemXmlApplicationContext
,上述代码就会返回的是FileSystemResource
,对于web系统来说,如果上下文容器时候WebApplicationContext
,那么返回的将是ServletContextResource
,它同样会为每个上下文返回适当的对象。因此,您可以以适合特定应用程序上下文的方式加载资源。
另一方面,你可能强制使用ClassPathResource
,忽略应用程序的上下文类型,通过添加特定的前缀classpath:,以下示例说明了这一点。
Resource template = ctx.getResource("classpath:some/resource/path/myTemplate.txt");
同样的,你能够强制使用UrlResource
通过使用特定的前缀:java.net.URL。下述两个例子分别表示使用http
和file
前缀。
Resource template = ctx.getResource("file:///some/resource/path/myTemplate.txt");
Resource template = ctx.getResource("https://myhost.com/resource/path/myTemplate.txt");
下列表格对资源类型和前缀进行更好的汇总:
Prefix | Example | Explanation |
---|---|---|
classpath: | classpath:com/myapp/config.xml | 从类路径加载 |
file: | file:///data/config.xml | 从文件系统加载作为URL,查阅FileSystemResource |
http: | https://myserver/logo.png | 加载作为URL |
(none) | /data/config.xml | 依赖于ApplicationContext |
ResourceLoaderAware 接口
这个ResourceLoaderAware
接口是一个特殊的回调接口,用于标识希望随ResourceLoader引用提供的组件,下面是ResourceLoaderAware 接口的定义
public interface ResourceLoaderAware extends Aware {
void setResourceLoader(ResourceLoader resourceLoader);
}
ResourceLoaderAware 接口用于指定该接口的实现类必须持有一个 ResourceLoader 实例。
类似于BeanNameAware
,BeanFactoryAware
接口,ResourceLoaderAware
接口也提供了一个setResourceLoader()方法,该方法由Spring容器负责,Spring 容器会将一个 ResourceLoader 对象作为该方法的参数传入。
当然了,一个 bean 若想加载指定路径下的资源,除了刚才提到的实现 ResourcesLoaderAware 接口之外(将 ApplicationContext 作为一个 ResourceLoader 对象注入),bean 也可以实现 ApplicationContextAware 接口,这样可以直接使用应用上下文来加载资源。但总的来说,在需求满足都满足的情况下,最好是使用的专用 ResourceLoader 接口,因为这样代码只会与接口耦合,而不会与整个 spring ApplicationContext 耦合。与 ResourceLoader 接口耦合,抛开 spring 来看,就是提供了一个加载资源的工具类接口。由于ApplicationContext也是一个ResourceLoader,因此bean还可以实现ApplicationContextAware接口并直接使用提供的应用程序上下文来加载资源。但是,通常情况下,如果有需要的话最好还是使用特定的ResourceLoader接口。
在应用程序的组件中,除了实现 ResourceLoaderAware 接口,也可采取另外一种替代方案——依赖于 ResourceLoader 的自动装配。传统的构造函数注入和byType自动装配模式(如自动装配协作者中所述)能够分别为构造函数参数或setter方法参数提供ResourceLoader。若为了获得更大的灵活性(包括属性注入的能力和多参方法),可以考虑使用基于注解的新注入方式。使用注解 @Autowiring 标记 ResourceLoader 变量,便可将其注入到成员属性、构造参数或方法参数中。
使用Resource作为属性
前面介绍了 Spring 提供的资源访问策略,但这些依赖访问策略要么需要使用 Resource 实现类,要么需要使用 ApplicationContext 来获取资源。实际上,当应用程序中的 Bean 实例需要访问资源时,Spring 有更好的解决方法:直接利用依赖注入。
从这个意义上来看,Spring 框架不仅充分利用了策略模式来简化资源访问,而且还将策略模式和 IoC 进行充分地结合,最大程度地简化了 Spring 资源访问。
归纳起来,如果 Bean 实例需要访问资源,有如下两种解决方案:
- 代码中获取 Resource 实例。
- 使用依赖注入。
对于第一种方式的资源访问,当程序获取 Resource 实例时,总需要提供 Resource 所在的位置,不管通过 FileSystemResource 创建实例,还是通过 ClassPathResource 创建实例,或者通过 ApplicationContext 的 getResource() 方法获取实例,都需要提供资源位置。这意味着:资源所在的物理位置将被耦合到代码中,如果资源位置发生改变,则必须改写程序。因此,通常建议采用第二种方法,让 Spring 为 Bean 实例依赖注入资源。
以下示例说明了这一点(可以使用set方法注入):
public class TestBean {
private Resource resource;
public Resource getResource() {
return resource;
}
public void setResource(Resource resource) {
this.resource = resource;
}
public void parse() throws Exception {
// 获取文件名
System.out.println("resource.getFileName = " + resource.getFilename());
// 获取文件描述
System.out.println("resource.getDescription = "+ resource.getDescription());
SAXReader reader = new SAXReader();
Document document = reader.read("file:///Users/mr.l/test/CXuan-Spring/CXuan-Spring-Resource/books.xml");
Element parent = document.getRootElement();
List<Element> elements = parent.elements();
for(Element element : elements){
// 获取name,description,price
System.out.println(element.getName() + " = " +element.getText());
}
}
public static void main(String[] args) throws Exception {
TestBean testBean = new TestBean();
testBean.setResource(new ClassPathResource("beans.xml"));
testBean.parse();
}
}
上面配置文件配置了资源的位置,并使用了 classpath: 前缀,这指明让 Spring 从类加载路径里加载 book.xml 文件。与前面类似的是,此处的前缀也可采用 http:、ftp: 等,这些前缀将强制 Spring 采用怎样的资源访问策略(也就是指定具体使用哪个 Resource 实现类);如果不采用任何前缀,则 Spring 将采用与该 ApplicationContext 相同的资源访问策略来访问资源。
<property name="template" value="classpath:some/resource/path/myTemplate.txt">
<property name="template" value="file:///some/resource/path/myTemplate.txt"/>
应用程序上下文和资源路径
本节介绍如何使用资源创建应用程序上下文,包括使用XML的快捷方式,如何使用通配符以及其他详细信息。
构造应用程序上下文
应用程序上下文构造函数(对于特定的应用程序上下文类型)通常将字符串或字符串数组作为资源的位置路径,例如构成上下文定义的XML文件。
当这样的位置路径没有前缀时,从该路径构建并用于加载bean定义的特定资源类型取决于并且适合于特定的应用程序上下文。 例如,请考虑以下示例,该示例创建ClassPathXmlApplicationContext:
ApplicationContext ctx = new ClassPathXmlApplicationContext("conf/appContext.xml");
bean 定义从类路径中加载,因为ClassPathResource被使用了,然而,考虑以下例子,创建了一个FileSystemXmlApplicationContext
:
ApplicationContext ctx = new FileSystemXmlApplicationContext("conf/appContext.xml");
现在bean的定义信息会从文件系统中加载,请注意,在位置路径上使用特殊类路径前缀或标准URL前缀会覆盖为加载定义而创建的默认资源类型。 请考虑以下示例:
ApplicationContext ctx = new FileSystemXmlApplicationContext("classpath:conf/appContext.xml");
创建 Spring 容器时,系统将从类加载路径来搜索 appContext.xml;但使用 ApplicationContext 来访问资源时,依然采用的是 FileSystemResource 实现类,这与 FileSystemXmlApplicationContext 的访问策略是一致的。这表明:通过 classpath: 前缀指定资源访问策略仅仅对当次访问有效,程序后面进行资源访问时,还是会根据 AppliactionContext 的实现类来选择对应的资源访问策略。
应用程序上下文路径中的通配符
上下文构造资源的路径可能是一些简单路径,但是对于每一个映射来说,不可能只有简单路径,也会有特殊复杂的路径出现,这就需要使用到路径通配符(ant-style)。
**ant-style示例
/WEB-INF/*-context.xml com/mycompany/**/applicationContext.xml file:C:/some/path/*-context.xml classpath:com/mycompany/**/applicationContext.xml
classpath* 和 classpath的区别:
classpath: 当使用 classpath :时前缀来指定 XML 配置文件时,系统将搜索类加载路径,找出所有与文件名的文件,分别装载文件中的配置定义,最后合并成一个 ApplicationContext。
public static void main(String[] args) throws Exception {
// 使用 classpath* 装载多份配置文件输出 ApplicationContext 实例。
ApplicationContext ctx = new FileSystemXmlApplicationContext("classpath*:bean.xml");
System.out.println(ctx);
}
如果不是采用 classpath*: 前缀,而是改为使用 classpath: 前缀,Spring 只加载第一份符合条件的 XML 文件,例如如下代码
ApplicationContext ctx = new FileSystemXmlApplicationContext("classpath:bean.xml");
当使用 classpath: 前缀时,系统通过类加载路径搜索 bean.xml 文件,如果找到文件名匹配的文件,系统立即停止搜索,装载该文件,即使有多份文件名匹配的文件,系统只装载第一份文件。
**路径匹配
另外,还有一种可以一次性装载多份配置文件的方式:指定配置文件时指定使用通配符,例如如下代码:
ApplicationContext ctx = new ClassPathXmlApplicationContext("bean*.xml");
除此之外,Spring 甚至允许将 classpath*: 前缀和通配符结合使用,如下语句也是合法的:
ApplicationContext ctx = new FileSystemXmlApplicationContext("classpath*:bean*.xml");
**file 前缀的用法
相对路径的写法:
ApplicationContext ctx = new FileSystemXmlApplicationContext("bean.xml");
绝对路径的写法:
ApplicationContext ctx = new FileSystemXmlApplicationContext("/bean.xml");
如果程序中需要访问绝对路径,则不要直接使用 FileSystemResource 或 FileSystemXmlApplicationContext 来指定绝对路径。建议强制使用 file: 前缀来区分相对路径和绝对路径,例如如下两行代码
ApplicationContext ctx = new FileSystemXmlApplicationContext("file:bean.xml");
ApplicationContext ctx = new FileSystemXmlApplicationContext("file:/bean.xml");
Spring中的Null-Safety
之前一直在某些代码中看到过使用@Nullable 标注过的注释,当时也没有在意到底是什么意思,所以这篇文章来谈谈Spring中关于Null的那些事。
在Java中不允许让你使用类型表示其null的安全性,但Spring Framework 现在在org.sprinngframework.lang包提供以下注释,以便声明API和字段的可空性:
@Nullable
: 用于指定参数、返回值或者字段可以作为null的注释。@NonNull
: 与上述注释相反,表明指定参数、返回值或者字段不允许为null。(不需要@NonNullApi和@NonNullFields适用的参数/返回值和字段)@NonNullApi
: 包级别的注释声明非null作为参数和返回值。@NonNullFields
:包级别的注释声明字段默认非空
Spring Framework 本身利用了上面这几个注释,但它们也可以运用在任何基于Spring的Java 项目中,以声明空安全api 和 空安全字段。尚未支持泛型和数组元素的可空性,但应也即将发布在后来的版本。Spring Null-Safety出现在Spring5中,让我们更方便的编写空安全的代码,这叫做null-safety,null-safety不是让我们逃脱不安全的代码,而是在编译时产生警告。 此类警告可以在运行时防止灾难性空指针异常(NPE)。
@NonNull
@NonNull注释是null-safety的所有注释中最重要的一个,我们可以使用此注释在期望对象引用的任何地方声明非空约束:字段、方法参数或者方法返回值。
先来看一个例子
public class Student {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
if(name != null && name.isEmpty()){
name = null;
}
this.name = name;
}
}
上述代码对name的校验是有效的,但是存在一个缺陷,如果name被设置为null的话,那么当我们使用name的时候,就会以NullPointerException来结尾。
**使用@NonNull
Spring 的null-safety特性能够允许idea
或者eclipse
报告这个潜在的威胁,例如,如果我们用IDEA对属性加上@NonNull会出现如下的效果。
奇怪,并没有什么变化啊,没看见有潜在的安全提示啊,**那是因为你没有在idea进行设置
设置安全检查
如果你也没有提示的话,可以通过如下的方式设置安全检查
如果还不好使的话,那就在右侧 configuration annotations 添加一下 @NonNull和 @Nullable 所在的jar包,如下:
添加上,打上 ✅ 即可看到如下效果。
现在fullName 已经被@NonNull 注释添加编译器检查null值的功能了!
如果你不相信的话,可以把@NonNull 注释去掉,你的鼠标再放在fullName 上,已经没有这句提示了。
@NonNullFields
@NonNull 注解能够帮助你确保null-safety。然而,如果此注释直接装饰所有的字段的话,就会污染整个代码库。
Spring提供了另外一个不允许为null的注解 — @NonNullFields
。这个注解适合用在包级别上,通知我们的开发工具注释包中所有的字段,默认的,不允许为null
新建一个Parent类,并在该类所属包下创建一个名为package-info.java
的类,创建的不是Java类,而是创建的file
,名为package-info.java,如下
package-info.java
@NonNullFields
package com.nullsafety.demo.pojo;
import org.springframework.lang.NonNullFields;
新建一个Parent.java
类
public class Parent {
private String son;
private String age;
private String name;
public void setSon(String son) {
if(son != null && son.isEmpty()){
son = null;
}
this.son = son;
}
public void setAge(String age) {
if(age != null && age.isEmpty()){
age = null;
}
this.age = age;
}
public void setName(String name) {
if(name != null && name.isEmpty()){
name = null;
}
this.name = name;
}
}
package-info.java 中的
@NonNullFields
能够对Parent类中所有的属性起作用,把鼠标放在任意一个属性上,会出现编译期检查的提示
@Nullable
@NonNullFields注释通常比@NonNull更好,因为它有助于减少样板。 但是,有时我们想要从包级别指定的非null约束中免除某些字段,这时候就会使用到@Nullable
注解
改造一下Person.java,Person.java 与pack-info.java 处于同一包下
public class Person {
@NonNull
private String fullName;
@Nullable
private String nickName;
public String getNickName() {
return nickName;
}
public void setNickName(String nickName) {
if(nickName != null && nickName.isEmpty()){
nickName = null;
}
this.nickName = nickName;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
if(fullName != null && fullName.isEmpty()){
fullName = null;
}
this.fullName = fullName;
}
}
在这种情况下,我们使用@Nullable注释来覆盖字段上@NonNullFields的语义。
@NonNullApi
@NonNullFields
注释仅适用于其名称所示的字段。 如果我们想对方法的参数和返回值产生相同的影响,我们需要@NonNullApi。
添加 @NonNullApi和 @NonNullFields 在 configure annotations 中,并选用NonNullApi
与@NonNullFields一样,我们需要在package-info.java 中定义@NonNullApi
package-info.java
@NonNullApi
@NonNullFields
package com.nullsafety.demo.pojo;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;
加上如下注释后的效果如下: 可以在返回值的时候接受到编译期的提示。
后记:
看完文章,你至少应该了解
- 四个注解 @NonNull, @Nullable, @NonNullFields, @NonNullApi 四个注解各自的作用范围
- 如何设置编译期的Null-safety检查
@Configuration全部用法
现在大部分的项目都采用了基于注解的配置,采用了@Configuration 替换
@Configuration 基本说明
定义:指示一个类声明一个或者多个@Bean 声明的方法并且由Spring容器统一管理,以便在运行时为这些bean生成bean的定义和服务请求的类。例如:
@Configuration
public class AppConfig {
@Bean
public MyBean myBean(){
return new MyBean();
}
}
上述AppConfig 加入@Configuration 注解,表明这就是一个配置类。有一个myBean()的方法,返回一个MyBean()的实例,并用@Bean 进行注释,表明这个方法是需要被Spring进行管理的bean。@Bean 如果不指定名称的话,默认使用myBean
名称,也就是小写的名称。
通过注解启动:
通过启动一个AnnotationConfigApplicationContext 来引导这个@Configuration 注解的类,比如:
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.register(AppConfig.class);
ctx.refresh();
在web项目中,也可以使用AnnotationContextWebApplicationContext
或者其他变体来启动。
新建一个SpringBoot项目(别问我为什么,因为这样创建项目比较快)。
- pom.xml 文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.spring.configuration</groupId>
<artifactId>spring-configuration</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-configuration</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.6.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 在config 包下新建一个MyConfiguration环境配置,和上面的示例代码相似,完整的代码如下:
@Configuration
public class MyConfiguration {
@Bean
public MyBean myBean(){
System.out.println("myBean Initialized");
return new MyBean();
}
}
说明MyConfiguration 是一个配置类,能够在此类下面声明管理多个Bean,我们声明了一个
MyBean
的bean,希望它被容器加载和管理。
- 在pojo包下新建一个MyBean的类,具体代码如下
public class MyBean {
public MyBean(){
System.out.println("generate MyBean Instance");
}
public void init(){
System.out.println("MyBean Resources Initialized");
}
}
- 新建一个SpringConfigurationApplication类,用来测试MyConfiguration类,具体代码如下:
public class SpringConfigurationApplication {
public static void main(String[] args) {
// AnnotationConfigApplicationContext context = = new AnnotationConfigApplicationContext(MyConfiguration.class)
// 因为我们加载的@Configuration 是基于注解形式的,所以需要创建AnnotationConfigApplicationContext
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
// 注册MyConfiguration 类并刷新bean 容器。
context.register(MyConfiguration.class);
context.refresh();
}
}
输出:
myBean Initialized generate MyBean Instance
从输出的结果可以看到,默认名称为myBean 的bean随着容器的加载而加载,因为myBean方法返回一个myBean的构造方法,所以myBean被初始化了。
通过XML 的方式来启动
- 可以通过使用XML方式定义的
<context:annotation-config />
开启基于注解的启动,然后再定义一个MyConfiguration的bean,在/resources 目录下新建 application-context.xml 代码如下:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context"
xmlns:util="http://www.springframework.org/schema/util"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.2.xsd
http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-4.2.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.2.xsd"
>
<!-- 相当于基于注解的启动类 AnnotationConfigApplicationContext-->
<context:annotation-config />
<bean class="com.spring.configuration.config.MyConfiguration"/>
</beans>
- 需要引入applicationContext.xml ,在SpringConfigurationApplication 需要进行引入,修改后的SpringConfigurationApplication如下:
public class SpringConfigurationApplication {
public static void main(String[] args) {
ApplicationContext context =
new ClassPathXmlApplicationContext("applicationContext.xml");
}
}
输出:
myBean Initialized generate MyBean Instance
基于ComponentScan() 来获取Bean的定义
@Configuration 使用@Component 进行原注解,因此@Configuration 类也可以被组件扫描到(特别是使用XML
在这里认识几个注解: **@Controller, @Service, @Repository, @Component
-
@Controller
: 表明一个注解的类是一个"Controller",也就是控制器,可以把它理解为MVC 模式的Controller 这个角色。这个注解是一个特殊的@Component,允许实现类通过类路径的扫描扫描到。它通常与@RequestMapping 注解一起使用。 -
@Service
: 表明这个带注解的类是一个"Service",也就是服务层,可以把它理解为MVC 模式中的Service层这个角色,这个注解也是一个特殊的@Component,允许实现类通过类路径的扫描扫描到 -
@Repository
: 表明这个注解的类是一个"Repository",团队实现了JavaEE 模式中像是作为"Data Access Object" 可能作为DAO来使用,当与 PersistenceExceptionTranslationPostProcessor 结合使用时,这样注释的类有资格获得Spring转换的目的。这个注解也是@Component 的一个特殊实现,允许实现类能够被自动扫描到 -
@Component
: 表明这个注释的类是一个组件,当使用基于注释的配置和类路径扫描时,这些类被视为自动检测的候选者。
也就是说,上面四个注解标记的类都能够通过@ComponentScan 扫描到,上面四个注解最大的区别就是使用的场景和语义不一样,比如你定义一个Service类想要被Spring进行管理,你应该把它定义为@Service 而不是@Controller因为我们从语义上讲,@Service更像是一个服务的类,而不是一个控制器的类,@Component通常被称作组件,它可以标注任何你没有严格予以说明的类,比如说是一个配置类,它不属于MVC模式的任何一层,这个时候你更习惯于把它定义为 @Component。@Controller,@Service,@Repository 的注解上都有@Component,所以这三个注解都可以用@Component进行替换。
来看一下代码进行理解:
- 定义五个类,类上分别用@Controller, @Service, @Repository, @Component, @Configuration 进行标注,分别如下
@Component
public class UserBean {}
@Configuration
public class UserConfiguration {}
@Controller
public class UserController {}
@Repository
public class UserDao {}
@Service
public class UserService {}
- 在
MyConfiguration
上加上@ComponentScan 注解,扫描上面5个类所在的包位置。代码如下:
@Configuration
@ComponentScan(basePackages = "com.spring.configuration.pojo")
public class MyConfiguration {
@Bean
public MyBean myBean(){
System.out.println("myBean Initialized");
return new MyBean();
}
}
- 修改 SpringConfigurationApplication 中的代码,如下:
public class SpringConfigurationApplication {
public static void main(String[] args) {
// AnnotationConfigApplicationContext context = = new AnnotationConfigApplicationContext(MyConfiguration.class)
// ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(MyConfiguration.class);
context.refresh();
// 获取启动过程中的bean 定义的名称
for(String str : context.getBeanDefinitionNames()){
System.out.println("str = " + str);
}
context.close();
}
}
输出:
myBean Initialized generate MyBean Instance str = org.springframework.context.annotation.internalConfigurationAnnotationProcessor str = org.springframework.context.annotation.internalAutowiredAnnotationProcessor str = org.springframework.context.annotation.internalRequiredAnnotationProcessor str = org.springframework.context.annotation.internalCommonAnnotationProcessor str = org.springframework.context.event.internalEventListenerProcessor str = org.springframework.context.event.internalEventListenerFactory str = myConfiguration str = userBean str = userConfiguration str = userController str = userDao str = userService str = myBean
由输出可以清楚的看到,上述定义的五个类成功被@ComponentScan 扫描到,并在程序启动的时候进行加载。
@Configuration 和 Environment
@Configuration 通常和Environment 一起使用,通过@Environment 解析的属性驻留在一个或多个"属性源"对象中,@Configuration类可以使用@PropertySource,像Environment 对象提供属性源
- 为了便于测试,我们引入junit4和spring-test 的依赖,完整的配置文件如下
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.spring.configuration</groupId>
<artifactId>spring-configuration</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-configuration</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring.version>5.0.6.RELEASE</spring.version>
<spring.test.version>4.3.13.RELEASE</spring.test.version>
<junit.version>4.12</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.test.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 在config 包下定义一个 EnvironmentConfig 类,注入Environment 属性,完整代码如下:
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = EnvironmentConfig.class)
@Configuration
@PropertySource("classpath:beanName.properties")
public class EnvironmentConfig {
@Autowired
Environment env;
@Test
public void testReadProperty(){
// 获取bean.name.controller 的属性
System.out.println(env.getProperty("bean.name.controller"));
// 判断是否包含bean.name.component
System.out.println(env.containsProperty("bean.name.component"));
// 返回与给定键关联的属性值
System.out.println(env.getRequiredProperty("bean.name.service"));
}
}
- 在/resources 目录下新建beanName.properties 文件,如下:
bean.name.configuration=beanNameConfiguration
bean.name.controller=beanNameController
bean.name.service=beanNameService
bean.name.component=beanNameComponent
bean.name.repository=beanNameRepository
启动并进行Junit测试,输出如下:
…..……
beanNameController
true
beanNameService
…..……
@Autowired 、 @Inject、@Resource 的区别
@Inject
: 这是jsr330 的规范,通过AutowiredAnnotationBeanPostProcessor 类实现的依赖注入。位于javax.inject包内,是Java自带的注解。
@Inject
@Named("environment")
Environment env;
不加@Named注解,需要配置与变量名一致即可。
@Autowired
: @Autowired 是Spring提供的注解,通过AutowiredAnnotationBeanPostProessor 类实现注入。位于org.springframework.beans.factory.annotation 包内,是Spring 中的注解
@Autowired
Environment env;
默认是通过byType 实现注入
@Resource
: @Resource 是jsr250规范的实现,@Resource通过CommonAnnotationBeanPostProcessor 类实现注入。@Resource 一般会指定一个name属性,如下:
@Resource(name = "environment")
Environment env;
默认是通过byName 实现注入
区别:
@Autowired和@Inject基本是一样的,因为两者都是使用AutowiredAnnotationBeanPostProcessor来处理依赖注入。但是@Resource是个例外,它使用的是CommonAnnotationBeanPostProcessor来处理依赖注入。当然,两者都是BeanPostProcessor。
在介绍完上述三者的区别之后,可以对Environment
的属性以上述注入方式进行改造
@Value、@PropertySource 和 @Configuration
@Configuration 可以和@Value 和@PropertySource 一起使用读取外部配置文件,具体用法如下:
- 在config 包下新建一个
ReadValueFromPropertySource
类,代码如下
@PropertySource("classpath:beanName.properties")
@Configuration
public class ReadValueFromPropertySource {
@Value("bean.name.component")
String beanName;
@Bean("myTestBean")
public MyBean myBean(){
return new MyBean(beanName);
}
}
通过@PropertySource引入的配置文件,使@Value 能够获取到属性值,在给myBean()方法指定了一个名称叫做myTestBean。
- 修改MyBean类,增加一个name属性和一个构造器,再生成其toString() 方法
public class MyBean {
String name;
public MyBean(String name) {
this.name = name;
}
public MyBean(){
System.out.println("generate MyBean Instance");
}
public void init(){
System.out.println("MyBean Resources Initialized");
}
@Override
public String toString() {
return "MyBean{" +
"name='" + name + '\'' +
'}';
}
}
- 在SpringConfigurationApplication中进行测试,如下
public class SpringConfigurationApplication {
public static void main(String[] args) {
// 为了展示配置文件的完整性,之前的代码没有删除。
// AnnotationConfigApplicationContext context = = new AnnotationConfigApplicationContext(MyConfiguration.class)
// ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
// AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
// context.register(MyConfiguration.class);
// context.refresh();
//
// // 获取启动过程中的bean 定义的名称
// for(String str : context.getBeanDefinitionNames()){
// System.out.println("str = " + str);
// }
// context.close();
ApplicationContext context =
new AnnotationConfigApplicationContext(ReadValueFromPropertySource.class);
MyBean myBean = (MyBean) context.getBean("myTestBean");
System.out.println("myBean = " + myBean);
}
}
使用Applicatio@InConntext 就能够获取myTestBean 这个bean,再生成myBean的实例。
输出:myBean = MyBean{name=’bean.name.component’}
@Import 和 @Configuration
@Import
的定义(来自于JavaDoc):表明一个或者多个配置类需要导入,提供与Spring XML中
- 在pojo 包下新建两个配置类,分别是CustomerBo, SchedualBo
@Configuration
public class CustomerBo {
public void printMsg(String msg){
System.out.println("CustomerBo : " + msg);
}
@Bean
public CustomerBo testCustomerBo(){
return new CustomerBo();
}
}
@Configuration
public class SchedulerBo {
public void printMsg(String msg){
System.out.println("SchedulerBo : " + msg);
}
@Bean
public SchedulerBo testSchedulerBo(){
return new SchedulerBo();
}
}
- 在config 包下新建一个AppConfig,导入CustomerBo 和 SchedulerBo 。
@Configuration
@Import(value = {CustomerBo.class,SchedulerBo.class})
public class AppConfig {}
- 在config 包下新建一个ImportWithConfiguration ,用于测试@Import 和 @Configuration 的使用
public class ImportWithConfiguration {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
CustomerBo customerBo = (CustomerBo) context.getBean("testCustomerBo");
customerBo.printMsg("System out println('get from customerBo')");
SchedulerBo schedulerBo = (SchedulerBo) context.getBean("testSchedulerBo");
schedulerBo.printMsg("System out println('get from schedulerBo')");
}
}
输出:
CustomerBo : System out println(‘get from customerBo’) SchedulerBo : System out println(‘get from schedulerBo’)
@Profile
@Profile
: 表示当一个或多个@Value 指定的配置文件处于可用状态时,组件符合注册条件,可以进行注册。
**三种设置方式:
-
可以通过ConfigurableEnvironment.setActiveProfiles()以编程的方式激活
-
可以通过AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME (spring.profiles.active )属性设置为
JVM属性
-
作为环境变量,或作为web.xml 应用程序的Servlet 上下文参数。也可以通过@ActiveProfiles 注解在集成测试中以声明方式激活配置文件。
**作用域
-
作为类级别的注释在任意类或者直接与@Component 进行关联,包括@Configuration 类
-
作为原注解,可以自定义注解
-
作为方法的注解作用在任何方法
注意:
如果一个配置类使用了Profile 标签或者@Profile 作用在任何类中都必须进行启用才会生效,如果@Profile({"p1","!p2"}) 标识两个属性,那么p1 是启用状态 而p2 是非启用状态的。
@ImportResource 和 @Configuration
@ImportResource
: 这个注解提供了与@Import 功能相似作用,通常与@Configuration 一起使用,通过AnnotationConfigApplicationContext 进行启动,下面以一个示例来看一下具体用法:
- 在config下新建TestService 类,声明一个构造函数,类初始化时调用
public class TestService {
public TestService(){
System.out.println("test @importResource success");
}
}
- 在/resources 目录下新建 importResources.xml ,为了导入TestService
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context"
xmlns:util="http://www.springframework.org/schema/util"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.2.xsd
http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-4.2.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.2.xsd"
>
<bean id = "testService" class="com.spring.configuration.config.TestService" />
</beans>
- 然后在config 下新建一个ImportResourceWithConfiguration, 用于读取配置文件
@Configuration
@ImportResource("classpath:importResources.xml")
public class ImportResourceWithConfiguration {
@Autowired
private TestService service;
public void getImportResource(){
new TestService();
}
public static void main(String[] args) {
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(ImportResourceWithConfiguration.class);
context.getBean("testService");
}
}
输出:test @importResource success
@Configuration 嵌套
@Configuration注解作用在类上,就和普通类一样能够进行相互嵌套,定义内部类。
// 来自JavaDoc
@Configuration
public class AppConfig{
@Inject
DataSource dataSource;
@Bean
public MyBean myBean(){
return new MyBean(dataSource);
}
@Configuration
static class DataConfig(){
@Bean
DataSource dataSource(){
return new EmbeddedDatabaseBuilder().build()
}
}
}
在上述代码中,只需要在应用程序的上下文中注册 AppConfig 。由于是嵌套的@Configuration 类,DatabaseConfig 将自动注册。当AppConfig 、DatabaseConfig 之间的关系已经隐含清楚时,这就避免了使用@Import 注解的需要。
@Lazy 延迟初始化
@Lazy
: 表明一个bean 是否延迟加载,可以作用在方法上,表示这个方法被延迟加载;可以作用在@Component (或者由@Component 作为原注解) 注释的类上,表明这个类中所有的bean 都被延迟加载。如果没有@Lazy注释,或者@Lazy 被设置为false,那么该bean 就会急切渴望被加载;除了上面两种作用域,@Lazy 还可以作用在@Autowired和@Inject注释的属性上,在这种情况下,它将为该字段创建一个惰性代理,作为使用ObjectFactory或Provider的默认方法。下面来演示一下:
- 修改
MyConfiguration
类,在该类上添加@Lazy 注解,新增一个IfLazyInit()方法,检验是否被初始化。
@Lazy
@Configuration
@ComponentScan(basePackages = "com.spring.configuration.pojo")
public class MyConfiguration {
@Bean
public MyBean myBean(){
System.out.println("myBean Initialized");
return new MyBean();
}
@Bean
public MyBean IfLazyInit(){
System.out.println("initialized");
return new MyBean();
}
}
- 修改SpringConfigurationApplication 启动类,放开之前MyConfiguration 的启动类
public class SpringConfigurationApplication {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(MyConfiguration.class);
// ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
// AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
// context.register(MyConfiguration.class);
// context.refresh();
//
// // 获取启动过程中的bean 定义的名称
for(String str : context.getBeanDefinitionNames()){
System.out.println("str = " + str);
}
// context.close();
// ApplicationContext context =
// new AnnotationConfigApplicationContext(ReadValueFromPropertySource.class);
// MyBean myBean = (MyBean) context.getBean("myTestBean");
// System.out.println("myBean = " + myBean);
}
}
输出你会发现没有关于bean的定义信息,但是当吧@Lazy 注释拿掉,你会发现输出了关于bean的初始化信息:
myBean Initialized generate MyBean Instance initialized generate MyBean Instance
@RunWith 和 @ContextConfiguration
Junit4 测试类,用于注解在类上表示通过Junit4 进行测试,可以省略编写启动类代码,是ApplicationContext 等启动类的替换。一般用@RunWith 和 @Configuration 进行单元测试,这是软件开发过程中非常必要而且具有专业性的一部分,上面EnvironmentConfig
类证实了这一点:
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = EnvironmentConfig.class)
@Configuration
@PropertySource("classpath:beanName.properties")
public class EnvironmentConfig {
// @Autowired
// Environment env;
@Inject
Environment env;
@Test
public void testReadProperty(){
// 获取bean.name.controller 的属性
System.out.println(env.getProperty("bean.name.controller"));
// 判断是否包含bean.name.component
System.out.println(env.containsProperty("bean.name.component"));
// 返回与给定键关联的属性值
System.out.println(env.getRequiredProperty("bean.name.service"));
}
}
@Enable 启动Spring内置功能
详情查阅@EnableAsync
,@EnableScheduling
,@EnableTransactionManagement
,@EnableAspectJAutoProxy
,@EnableWebMvc
官方文档
@Configuration 使用约束
- 必须以类的方式提供(即不是从工厂方法返回的实例)
- @Configuration 注解的类必须是非final的
- 配置类必须是非本地的(即可能不在方法中声明),native 标注的方法
- 任何嵌套的@Configuration 都必须是static 的。
- @Bean 方法可能不会反过来创建更多配置类
文章来源:
https://blog.csdn.net/u012734441/article/details/51706504
使用SpringAPI进行验证
验证在任何时候都非常关键。考虑将数据验证作为业务逻辑开发有利也有弊,Spring 认为,验证不应该只在Web 端进行处理,在服务端也要进行相应的处理,可以防止脏数据存入数据库中,从而避免为运维同学和测试同学造成更大的困扰,因为数据造成的bug会更加难以发现,而且开发人员关注点也不会放在数据本身的问题上,所以做服务端的验证也是非常有必要的。 考虑到上面这些问题,Spring 提供了两种主要类型的验证:
- 一个是实现
Validator
接口来创建自定义验证器,用于服务端数据校验。 - 一种是通过Spring 对
Bean Validation
支持实现的。
通过使用 Spring Validator 接口进行验证
Spring 提供 Validator
接口用于验证对象。Validator 接口通过使用 Errors
对象来工作,以便在验证时,验证器可以向 Errors 对象报告验证失败。下面是一个简单的 对象示例
public class Person {
private String name;
private int age;
// get and set...
}
下面一个例子为 Person 对象提供了一种验证方式,通过实现了 org.springframework.validation.Validator
接口 的两个方法:
supports(Class)
: 表示此 Validator 是否能够验证提供的类的实例validate(Object, org.springframework.validation.Errors)
: 验证给定的对象,如果验证错误,则注册具有给定 Errors 对象。
实现一个 Validator
非常简单,而且Spring 也提供了 ValidationUtils
工具类帮助进行验证。下面是一个验证 Person 对象的例子:
@Component
public class PersonValidator implements Validator {
// 此 Validator 只验证 Person 实例
public boolean supports(Class clazz) {
return Person.class.equals(clazz);
}
public void validate(Object obj, Errors e) {
ValidationUtils.rejectIfEmpty(e, "name", "name.empty");
}
}
上面代码示例中的静态方法 rejectIfEmpty()
方法用于拒绝name属性,当name 属性是 null 或者是 空串的时候。查看 ValidationUtils 文档关于它能够提供的功能。
然后再来编写配置类 AppConfig
:
@Configuration
@ComponentScan("com.spring.validation")
public class AppConfig {}
使用 @Configuration 注解声明此类为配置类(更多 @Configuration 的用法,请参照 原创 | 我被面试官给虐懵了,竟然是因为我不懂Spring中的@Configuration )
配置@ComponentScan 注解用于自动装配,默认是使用 basePackages
扫描指定包,字符串表示。
然后对上面的程序进行验证
public class SpringValidationApplicationTests {
public static void main(String[] args) {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
Person person = new Person();
person.setAge(18);
person.setName(null);
PersonValidator personValidator = applicationContext.getBean("personValidator", PersonValidator.class);
BeanPropertyBindingResult result = new BeanPropertyBindingResult(person,"cxuan");
ValidationUtils.invokeValidator(personValidator,person,result);
List<ObjectError> allErrors = result.getAllErrors();
allErrors.forEach(e-> System.out.println(e.getCode()));
}
}
因为是基于注解的配置,所以使用 AnnotationConfigApplicationContext
上下文启动类,把配置类 AppConfig 当作参数,然后构建一个Person 类,为了测试验证有效性,把 name 设置为 null,然后通过上下问的 getBean
方法获得 personValidator 的实例,通过使用 BeanPropertyBindingResult
把 person 绑定为 cxuan
的名字,然后使用 ValidationUtils
工具类进行验证,最后把验证的结果进行检查。
上面程序经验证后的结果如下:
org.springframework.validation.ValidationUtils – Invoking validator [com.spring.validation.PersonValidator@37918c79] DEBUG org.springframework.validation.ValidationUtils – Validator found 1 errors name.empty
使用 Bean Validation 进行验证
从 Spring4 开始,就已经实现对 JSR-349 Bean Validation 的全面支持。Bean Validation API 在 javax.validation.constraints
包中以 Java 注解(例如 @NonNull) 形式定义了一组可用域对象的约束。
通过使用 Bean Validation API ,可以避免耦合到特定的验证服务提供程序。Spring 对 Bean Validation API 提供了无缝支持,主要使用一些注解进行验证,下面一起来看一下
定义对象属性上的验证约束
首先,将验证约束应用于域对象属性。使用maven 配置需要引入对应的依赖
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>1.1.0.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>5.2.4.Final</version>
</dependency>
之后定义了一些实体类,使用 javax.validation.constraints
包中的注释进行标注
public class Singer {
@NotNull
@Size(min = 2,max = 60)
private String firstName;
private String lastName;
@NotNull
private Genre genre;
private Gender gender;
get and set...
}
对于 firstName ,定义了两个约束,第一个约束由 @NotNull
进行控制,它表示该值不能为空。此外,@Size
注解控制着 firstName 的长度在 2 – 60 之间。@NotNull 还用于 genre 属性。下面是Genre
和 Gender
的枚举类
public enum Genre {
POP("P"),
JAZZ("J"),
BLUES("B"),
COUNTRY("C");
private String code;
private Genre(String code){
this.code = code;
}
public String toString(){
return this.code;
}
}
public enum Gender {
MALE("M"),
FEMALE("F");
private String code;
Gender(String code){
this.code = code;
}
@Override
public String toString() {
return this.code;
}
}
Genre 表示歌手所属的音乐类型,而 Gender 与音乐事业不相关,所以可以为空
在 Spring 中配置 Bean Validation 支持
为了在 Spring 的 ApplicationContext 中配置对 Bean Validation API 的支持,可以在Spring 的配置中定义一个 LocalValidatorFactoryBean
的 bean如下
@Configuration
@ComponentScan("com.spring.validation")
public class ValidationConfig {
@Bean
LocalValidatorFactoryBean validatorFactoryBean(){
return new LocalValidatorFactoryBean();
}
}
声明一个 LocalValidatorFactoryBean
的 bean 是必须的。默认情况下,Spring 会在类路径下搜索 Hibernate Validator
库,验证它是否存在。
下面我们编写一个为 Singer 类提供验证服务的服务类
@Service
public class SingerValidationService {
@Autowired
private Validator validator;
public Set<ConstraintViolation<Singer>> validateSinger(Singer singer){
return validator.validate(singer);
}
}
注入一个 javax.validation.Validator
实例(请注意与 Spring 提供的 Validator 接口不同)。一旦定义了 LocalValidatorFactoryBean ,就可以在应用程序中的任意位置创建 Validator 的句柄。要在 POJO 上进行验证,需要调用 validator.validate
方法,验证结果以 ConstraintViolation<T>
接口的集合形式返回。下面是上面例子程序的验证
public class SpringBeanValidationTest {
public static void main(String[] args) {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(ValidationConfig.class);
SingerValidationService singerBean = applicationContext.getBean(SingerValidationService.class);
Singer singer = new Singer();
singer.setFirstName("c");
singer.setLastName("xuan");
singer.setGenre(null);
singer.setGender(null);
validateSinger(singer,singerBean);
applicationContext.close();
}
private static void validateSinger(Singer singer,SingerValidationService singerValidationService){
Set<ConstraintViolation<Singer>> violationSet = singerValidationService.validateSinger(singer);
listViolations(violationSet);
}
private static void listViolations(Set<ConstraintViolation<Singer>> violations){
System.out.println("violations.size() = " + violations.size());
for(ConstraintViolation<Singer> violation : violations){
System.out.println("Validation error for property : " + violation.getPropertyPath());
System.out.println("with value : " + violation.getInvalidValue());
System.out.println("with error message : " + violation.getMessage());
}
}
}
上述代码构建了一个 Singer 类进行验证,因为 firstname 属性的要求是长度介于 2 – 60 之间并且不能为null,所以这里只用了一个字符验证,genre 属性不能为null,最核心的验证方法就是 singerValidationService.validateSinger(singer).
方法,它会调用
public Set<ConstraintViolation<Singer>> validateSinger(Singer singer){
return validator.validate(singer);
}
进行验证,验证的结果返回的是 ConstraintViolation<Singler>
类型,然后把对应的错误信息输出,上面的错误信息是
violations.size() = 2 Validation error for property : firstName with value : c with error message : 个数必须在2和60之间 Validation error for property : genre with value : null with error message : 不能为null
可以打印出两个错误,并输出错误的属性、值以及错误信息。
如何高效的学习技术?
学什么
基础与应用
近些年诞生了许多新技术,比如最时髦的 AI(目前还在智障阶段),数学基础是初中就接触过的概率统计。万丈高楼从地起,不要被新工具或者中间件迷住双眼,一味地追新求快。基础知识是所有技术的基石,在未来很长的时间都不会变化,应该花费足够的时间巩固基础。
以数据结构和算法为例,大家阅读一下 Java 的 BitSet
的源码,里面有大量的移位操作,移位运算掌握的好,看这份源码就没问题。Java 同步工具类 AQS
用到了双向链表,链表知识不过关,肯定搞不懂它的原理。互联网大厂都喜欢考算法,为了通过面试也要精通算法。
以Java工程师应该掌握的知识为例,按重要程度排出六个梯度:
- 第一梯度:计算机组成原理、数据结构和算法、网络通信原理、操作系统原理;
- 第二梯度:Java 基础、JVM 内存模型和 GC 算法、JVM 性能调优、JDK 工具、设计模式;
- 第三梯度:Spring 系列、Mybatis、Dubbo 等主流框架的运用和原理;
- 第四梯度:MySQL (含SQL编程)、Redis、RabbitMQ/RocketMQ/Kafka、ZooKeeper 等数据库或者中间件的运用和原理;
- 第五梯度:CAP 理论、BASE 理论、Paxos 和 Raft 算法等其他分布式理论;
- 第六梯度:容器化、大数据、AI、区块链等等前沿技术理论;
有同学认为第五梯度应该在移到第一梯度。其实很多小公司的日活犹如古天乐一样平平无奇,离大型分布式架构还远得很。学习框架和中间件的时候,顺手掌握分布式理论,效果更好。
广度与深度
许多公司的招聘 JD 没有设定技术人员年龄门槛,但是会加上一句具备与年龄相当的知识的广度与深度。多广才算广,多深才算深?这是很主观的话题,这里不展开讨论。
如何变得更广更深呢?突破收入上升的瓶颈,发掘自己真正的兴趣。
大多数人只是公司的普通职员,收入上升的瓶颈就是升职加薪。许多 IT 公司会对技术人员有个评级,如果你的评级不高,那就依照晋级章程努力升级。如果你在一个小公司,收入一般,发展前景不明,准备大厂的面试就是最好的学习过程。在这些过程中,你必然学习更多知识,变得更广更深。
个人兴趣是前进的动力之一,许多知名开源项目都源于作者的兴趣。个人兴趣并不局限技术领域,可以是其他学科。我有个朋友喜欢玩山地自行车,还给一些做自行车话题的自媒体投稿。久而久之,居然能够写一手好文章了,我相信他也能写好技术文档。
哲学
哲学不是故作高深的学科,它的现实意义就是解决问题
。年轻小伙是怎么泡妞的?三天两头花不断,大庭广众跪求爱。这类套路为什么总是能成功呢?礼物满足女人的物欲,当众求爱满足女人的虚荣心,投其所好。食堂大妈打菜的手越来越抖,辣子鸡丁变成辣子辣丁,为什么呢?食堂要控制成本,直接提价会惹众怒。
科学上的哲学,一般指研究事物发展的规律,归纳终极的解决方案。软件行业充满哲学味道的作品非常多,比如《人月神话》。举个例子,当软件系统遇到性能问题,尝试下面两种哲学思想提升性能
- 空间换时间:比如引入缓存,消耗额外的存储提高响应速度。
- 时间换空间:比如大文件的分片处理,分段处理后再汇总结果。
设计稳健高可用的系统,尝试从三个方面考虑问题:
- 存储:数据会丢失吗,数据一致性怎么解决。
- 计算:计算怎么扩容,应用允许任意增加节点吗。
- 传输:网络中断或拥塞怎么办。
从无数的失败或者成功的经验中,总结出高度概括性的方案,让我们下一步做的更好。
英语
英语是极为重要的基础,学好英语与掌握编程语言一样重要。且不说外企对英语的要求,许多知名博客就是把英文翻译成中文,充当知识的搬运工。如果英语足够好,直接阅读一手英语资料,避免他人翻译存在的谬误。
怎么学
知识体系
体系化的知识比零散的更容易记忆和理解,这正如一部好的电视剧,剧情环环相扣才能吸引观众。建议大家使用思维导图罗列知识点,构建体系结构,如下图所示:
克服遗忘
高中是我们知识的巅峰时刻,每周小考每月大考,教辅资料堆成山,地狱式的反复操练强化记忆。复习是对抗遗忘的唯一办法。大脑的遗忘是有规律的,先快后慢。一天后,学到的知识只剩下原来的 25%
,甚至更低。随着时间的推移,遗忘的速度减慢,遗忘的数量也就减少。
时间间隔 | 记忆量 |
---|---|
刚看完 | 100% |
20分钟后 | 60% |
1小时后 | 40% |
1天后 | 30% |
2天后 | 27% |
每个人的遗忘程度都不一样,建议第二天复习前一天的内容,七天后复习这段时间的所有内容。
碎片时间
不少朋友利用碎片时间学习,比如在公交上看公众号的推送。其实我们都高估了自己的抗干扰能力,如果处在嘈杂的环境,注意力容易被打断,记忆留存度也很低。碎片时间适合学习简单孤立的知识点,比如链表的定义与实现。
学习复杂的知识,需要大段的连续时间。图书馆是个好地方,安静氛围好。手机放一边,不要理会 QQ 微信,最好阅读纸质书,泡上一整天。有些城市出现了付费自习室,提供格子间、茶水等等,也是非常好的选择。
用起来
技术分享
从下面这张图我们可以看到,教授他人是知识留存率最高的方式。
准备 PPT 和演讲内容,给同事来一场技术分享。不光复习知识,还锻炼口才。曾经有个同事说话又快又急,口头禅也多,比如对吧、是不是,别人经常听不清,但是他本人不以为然。领导让他做了几次技术分享,听众的反应可想而知,他才彻底认清缺点。
坚持写技术博客,别在意你写的东西在网上已经重复千百遍。当自己动手的时候,才会意识到眼高手低。让文章读起来流畅清晰,需要呕心沥血的删改。写作是对大脑的长期考验,想不到肯定写不出,想不清楚肯定写不清楚。
造个轮子
我们经常说不要重复造轮子。为了开发效率,可以不造轮子,但是必须具备造轮子的能力。建议造一个简单的MQ,你能用到通信协议、设计模式、队列等许多知识。在造轮子的过程中,你会频繁的翻阅各种手册或者博客,这就是用输出倒逼输入。
原文链接:https://www.cnblogs.com/xiaoyangjia/p/11535486.html
ZooKeeper基本概述
什么是ZooKeeper
Apache ZooKeeper 是一个开源的实现高可用
的分布式协调服务器。ZooKeeper是一种集中式服务
,用于维护配置信息,域名服务,提供分布式同步和集群管理。所有这些服务的种类都被应用在分布式环境中,每一次实施这些都会做很多工作来避免出现bug和竞争条件。
ZooKeeper 设计原则
ZooKeeper 很简单
ZooKeeper 允许分布式进程通过共享的分层命名空间相互协调,ZooKeeper命名空间与文件系统很相似,每个命名空间填充了数据节点的注册信息 – 叫做Znode
,这是在 ZooKeeper 中的叫法,Znode 很像我们文件系统中的文件和目录。ZooKeeper 与典型的文件系统不同,ZooKeeper 数据保存在内存中,这意味着 ZooKeeper 可以实现高吞吐量和低时延。
ZooKeeper 可复制
与它协调的分布式进程一样,ZooKeeper本身也可以在称为集群的一组主机上进行复制。
组成 ZooKeeper 服务的单个服务端必须了解彼此。它们维护内存中的状态、持久性的事务日志和快照。只要大多数服务可用,ZooKeeper 服务就可用。
客户端可以连接到单个的服务器。客户端通过连接单个服务器进而维护 TCP 连接,通过连接发送请求,获取响应,获取监听事件以及发送心跳,很像Eureka Server 的功能。如果与单个服务器的连接中断,客户端会自动的连接到ZooKeeper Service 中的其他服务器。
ZooKeeper 有序
ZooKeeper使用时间戳来记录导致状态变更的事务性操作,也就是说,一组事务通过时间戳来保证有序性。基于这一特性。ZooKeeper可以实现更加高级的抽象操作,如同步等。
ZooKeeper 非常快
ZooKeeper包括读写两种操作,基于ZooKeeper的分布式应用,如果是读多写少的应用场景(读写比例大约是10:1),那么读性能更能够体现出高效。
ZooKeeper基本概念
数据模型和分层命名空间
ZooKeeper提供的命名空间非常类似于标准文件系统。名称是由斜杠(/)分隔的路径元素序列。 ZooKeeper名称空间中的每个节点都由路径标识。
ZooKeeper的分层命名空间图
节点和临时节点
与标准的文件系统所不同的是,ZooKeeper命名空间中的每个节点都可以包含与之关联的数据以及子项,这就像一个文件也是目录的文件系统。ZooKeeper被设计用来存储分布式数据:状态信息,配置,定位信息等等。所以每个ZooKeeper 节点能存储的容量非常小,最大容量为 1MB。我们使用术语 Znode 来表明我们正在谈论ZooKeeper数据节点。
Znodes 维护了一个 stat 结构,包括数据变更,ACL更改和时间戳的版本号,用来验证缓存和同步更新。每一次Znode 的数据发生了变化,版本号的数量就会进行增加。每当客户端检索某个znode 数据时,它也会接收该数据的版本。
命名空间下数据存储的Znode 节点都会以原子性的方式读写,也就是保证了原子性。读取所有Znode 相关联的节点数据并通过写的方式替换节点数据。每一个节点都会有一个 访问控制列表(ACL)
的限制来判断谁可以进行操作。
ZooKeeper 也有临时节点的概念。只要创建的 Znode 的会话(session)处于活动状态,就会存在这些临时节点。当会话结束,临时节点也就被删除。
选择性更新和watches
ZooKeeper支持watches的概念。客户端可以在 Znode 上设置监听
。当 Znode 发生变化时,监听会被触发并移除。触发监听时,客户端会收到一个数据包告知 Znode 发生变更。如果客户端与其中一个 ZooKeeper 服务器之间的连接中断,则客户端将收到本地通知。
集群角色
通常在分布式系统中,构成一个集群中的每一台机器都有一个自己的角色,最典型的集群模式就是 master/slave
(主备模式)。在这种模式中,我们把能够处理写操作请求的机器成为 Master ,把所有通过异步复制方式获取最新数据,并提供读请求服务的机器成为 Slave 机器。
而 ZooKeeper 没有采用这种方式,ZooKeeper 引用了 Leader、Follower和 Observer 三个角色。ZooKeeper 集群中的所有机器通过选举的方式选出一个 Leader,Leader 可以为客户端提供读服务和写服务。除了 Leader 外,集群中还包括了 Follower 和 Observer 。Follower 和 Observer 都能够提供读服务,唯一区别在于,Observer 不参与 Leader 的选举过程,也不写操作的"过半写成功"策略,因此 Observer 可以在不影响写性能的情况下提升集群的读性能。
Session
Session 指的是客户端会话,在讲解会话之前先来了解一下客户端连接。客户端连接指的就是客户端和服务器之间的一个 TCP长连接
,ZooKeeper 对外的端口是 2181
,客户端启动的时候会与服务器建立一个 TCP 连接,从第一次连接建立开始,客户端会话的生命周期也就开始了,通过这个连接,客户端能够通过心跳检测与服务器保证有效的会话,也能够向 ZooKeeper 服务器发送请求并接受响应,同时还能够通过该连接来接收来自服务器的Watch 事件通知。
ZooKeeper 特性
一致性要求
ZooKeeper 非常快并且很简单。但是,由于其开发的目的在于构建更复杂的服务(如同步)的基础,因此它提供了一系列的保证,这些保证是:
- **顺序一致性:客户端的更新将按顺序应用。
- **原子性:更新要么成功要么失败,没有其他结果
- **单一视图:无论客户端连接到哪个服务,所看到的环境都是一样的
- **可靠性:开始更新后,它将从该时间开始,一直到客户端覆盖更新
- **及时性: 系统的客户视图保证在特定时间范围内是最新的。
简单的API使用
ZooKeeper 设计之初提供了非常简单的编程接口。作为结果,它支持以下操作:
- create:在文档目录树中的某一个位置创建节点
- delete: 删除节点
- exists: 测试某个位置是否存在节点
- get data: 从节点中读取数据
- set data: 向节点中写数据
- get children: 从节点中检索子节点列表
- sync: 等待数据传播
实现
ZooKeeper Components 展现了ZooKeeper 服务的高级组件。除请求处理器外,构成 ZooKeeper 服务的每个服务器都复制其自己的每个组件的副本。
图中的 Replicated Database
(可复制的数据库)是一个包含了整个数据树的内存数据库。更新将记录到磁盘以获得可恢复性,并且写入在应用到内存数据库之前会得到序列化。
每一个 ZooKeeper 服务器都为客户端服务。客户端只连接到一台服务器用以提交请求。读请求由每个服务器数据库的本地副本提供服务,请求能够改变服务的状态,写请求由"同意协议"进行通过。
作为"同意协议" 的一部分,所有的请求都遵从一个单个的服务,由这个服务来询问除自己之外其他服务是否可以同意写请求,而这个单个的服务被称为Leader
。除自己之外其他的服务被称为follower
,它们接收来自Leader 的消息并对消息达成一致。消息传底层负责替换失败的 leader 并使 follower 与 leader 进行同步。
ZooKeeper 使用自定义的原子性消息传递协议。因为消息传底层是原子性的,ZooKeeper 能够保证本地副本永远不会产生分析或者冲突。当 leader 接收到写请求时,它会计算系统的状态以确保写请求何时应用,并且开启一个捕获新状态的事务。
使用者
ZooKeeper 的编程接口非常简单,但是通过它,你可以实现更高阶的操作。
性能
ZooKeeper 旨在提供高性能,但是真的是这样吗?ZooKeeper是由雅虎团队开发。当读请求远远高于写请求的时候,它的效率很高,因为写操作涉及同步所有服务器的状态。(读取数量超过写入通常是协调服务的情况。)
ZooKeeper吞吐量作为读写比率变化是在具有双2Ghz Xeon和两个SATA 15K RPM驱动器的服务器上运行的ZooKeeper版本3.2的吞吐量图。一个驱动器用作专用的ZooKeeper日志设备。快照已写入OS驱动器。写请求是1K写入,读取是1K读取。 “服务器”表示ZooKeeper集合的大小,即构成服务的服务器数量。 大约30个其他服务器用于模拟客户端。 ZooKeeper集合的配置使得Leader不允许来自客户端的连接。(此部分来源于翻译结果)
基准也表明它也是可靠的。 存在错误时的可靠性显示了部署如何响应各种故障。 图中标记的事件如下:
- follower 的失败和恢复
- 失败和恢复不同的 follower
- leader 的失败
- 两个follower 的失败和恢复
- 其他leader 的失败
可靠性
为了在注入故障时显示系统随时间的行为,我们运行了由7台机器组成的ZooKeeper服务。我们运行与以前相同的饱和度基准,但这次我们将写入百分比保持在恒定的30%,这是我们预期工作量的保守比率。(此部分来源于翻译结果)
该图中有一些重要的观察结果。 首先,如果follower 失败并迅速恢复,那么即使失败,ZooKeeper也能够维持高吞吐量。 但也许更重要的是,leader 选举算法允许系统足够快地恢复以防止吞吐量大幅下降。 在我们的观察中,ZooKeeper 选择新 leader 的时间不到200毫秒。 第三,随着follower 的恢复,ZooKeeper能够在开始处理请求后再次提高吞吐量。
文章来源:
https://zookeeper.apache.org/doc/current/zookeeperOver.html
《从Paxos到zookeeper分布式一致性原理与实践》
951.Java 类加载过程?
Java 类加载需要经历一下 7 个过程:
- 加载
加载是类加载的第一个过程,在这个阶段,将完成一下三件事情:
- 通过一个类的全限定名获取该类的二进制流。
- 将该二进制流中的静态存储结构转化为方法去运行时数据结构。
- 在内存中生成该类的 Class 对象,作为该类的数据访问入口。
- 验证
验证的目的是为了确保 Class 文件的字节流中的信息不回危害到虚拟机.在该阶段主要完成以下四钟验证:
- 文件格式验证:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型.
- 元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。
- 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。
- 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。
- 准备 准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。 public static int value=123;//在准备阶段 value 初始值为 0 。在初始化阶段才会变为 123 。
- 解析 该阶段主要完成符号引用到直接引用的转换动作。解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。
- 初始化 初始化时类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java 程序代码。
- 使用
- 卸载
952.描述一下 JVM 加载 Class 文件的原理机制?
Java 语言是一种具有动态性的解释型语言,类(Class)只有被加载到 JVM 后才能运行。当运行指定程序时,JVM 会将编译生成的 .class 文件按照需求和一定的规则加载到内存中,并组织成为一个完整的 Java 应用程序。这个加载过程是由类加载器完成,具体来说,就是由 ClassLoader 和它的子类来实现的。类加载器本身也是一个类,其实质是把类文件从硬盘读取到内存中。 类的加载方式分为隐式加载和显示加载。隐式加载指的是程序在使用 new 等方式创建对象时,会隐式地调用类的加载器把对应的类加载到 JVM 中。显示加载指的是通过直接调用 class.forName() 方法来把所需的类加载到 JVM 中。 任何一个工程项目都是由许多类组成的,当程序启动时,只把需要的类加载到 JVM 中,其他类只有被使用到的时候才会被加载,采用这种方法一方面可以加快加载速度,另一方面可以节约程序运行时对内存的开销。此外,在 Java 语言中,每个类或接口都对应一个 .class 文件,这些文件可以被看成是一个个可以被动态加载的单元,因此当只有部分类被修改时,只需要重新编译变化的类即可,而不需要重新编译所有文件,因此加快了编译速度。 在 Java 语言中,类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(例如基类)完全加载到 JVM 中,至于其他类,则在需要的时候才加载。 类加载的主要步骤:
- 装载。根据查找路径找到相应的 class 文件,然后导入。
- 链接。链接又可分为 3 个小步:
- 检查,检查待加载的 class 文件的正确性。
- 准备,给类中的静态变量分配存储空间。
- 解析,将符号引用转换为直接引用(这一步可选)
- 初始化。对静态变量和静态代码块执行初始化工作。
953.Java 内存分配。
- 寄存器:我们无法控制。
- 静态域:static 定义的静态成员。
- 常量池:编译时被确定并保存在 .class 文件中的(final)常量值和一些文本修饰的符号引用(类和接口的全限定名,字段的名称和描述符,方法和名称和描述符)。
- 非 RAM 存储:硬盘等永久存储空间。
- 堆内存:new 创建的对象和数组,由 Java 虚拟机自动垃圾回收器管理,存取速度慢。
- 栈内存:基本类型的变量和对象的引用变量(堆内存空间的访问地址),速度快,可以共享,但是大小与生存期必须确定,缺乏灵活性。
954.Java 堆的结构是什么样子的?什么是堆中的永久代(Perm Gen space)?
JVM 的堆是运行时数据区,所有类的实例和数组都是在堆上分配内存。它在 JVM 启动的时候被创建。对象所占的堆内存是由自动内存管理系统也就是垃圾收集器回收。 堆内存是由存活和死亡的对象组成的。存活的对象是应用可以访问的,不会被垃圾回收。死亡的对象是应用不可访问尚且还没有被垃圾收集器回收掉的对象。一直到垃圾收集器把这些 对象回收掉之前,他们会一直占据堆内存空间。
955.GC 是什么? 为什么要有 GC?
GC 是垃圾收集的意思(GabageCollection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。
956.简述 Java 垃圾回收机制。
在 Java 中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在 JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。
957.如何判断一个对象是否存活?(或者 GC 对象的判定方法)
判断一个对象是否存活有两种方法:
- 引用计数法 所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收. 引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象 A 引用对象 B,对象 B 又引用者对象 A,那么此时 A、B 对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。
- 可达性算法(引用链法)
该算法的思想是:从一个被称为 GC Roots 的对象开始向下搜索,如果一个对象到 GC Roots 没有任何引用链相连时,则说明此对象不可用。
在 Java 中可以作为 GC Roots 的对象有以下几种:
- 虚拟机栈中引用的对象
- 方法区类静态属性引用的对象
- 方法区常量池引用的对象
- 本地方法栈 JNI 引用的对象 虽然这些算法可以判定一个对象是否能被回收,但是当满足上述条件时,一个对象比不一定会被回收。当一个对象不可达 GC Root 时,这个对象并不会立马被回收,而是出于一个死缓的阶段,若要被真正的回收需要经历两次标记. 如果对象在可达性分析中没有与 GC Root 的引用链,那么此时就会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行finalize() 方法。当对象没有覆盖 finalize() 方法或者已被虚拟机调用过,那么就认为是没必要的。 如果该对象有必要执行finalize() 方法,那么这个对象将会放在一个称为 F-Queue 的对队列中,虚拟机会触发一个 Finalize() 线程去执行,此线程是低优先级的,并且虚拟机不会承诺一直等待它运行完,这是因为如果finalize() 执行缓慢或者发生了死锁,那么就会造成 F-Queue 队列一直等待,造成了内存回收系统的崩溃。GC 对处于 F-Queue 中的对象进行第二次被标记,这时,该对象将被移除” 即将回收”集合,等待回收。
958.垃圾回收的优点和原理。并考虑 2 种回收机制。
Java 语言中一个显著的特点就是引入了垃圾回收机制,使 C++ 程序员最头疼的内存管理的问题迎刃而解,它使得 Java 程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java 中的对象不再有“作用域”的概念,只有对象的引用才有"作用域"。垃圾回收可以有效的防止内存泄露,有效的使用可以使用的内存。垃圾回收器通常是作为一个单独的低级别的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清楚和回收,程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收。 回收机制有分代复制垃圾回收和标记垃圾回收,增量垃圾回收。
959. 垃圾回收器的基本原理是什么?垃圾回收器可以马上回收内存吗? 有什么办法主动通知虚拟机进行垃圾回收?
对于 GC 来说,当程序员创建对象时,GC 就开始监控这个对象的地址、大小以及使用情况。通常,GC 采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是”可达的”,哪些对象是”不可达的”。当 GC 确定一些对象为“不可达”时,GC 就有责任回收这些内存空间。可以。程序员可以手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。
960.Java 中会存在内存泄漏吗,请简单描述。
所谓内存泄露就是指一个不再被程序使用的对象或变量一直被占据在内存中。Java 中有垃圾回收机制,它可以保证一对象不再被引用的时候,即对象变成了孤儿的时候,对象将自动被垃圾回收器从内存中清除掉。由于 Java 使用有向图的方式进行垃圾回收管理,可以消除引用循环的问题,例如有两个对象,相互引用,只要它们和根进程不可达的,那么 GC 也是可以回收它们的,例如下面的代码可以看到这种情况的内存回收:
import java.io.IOException;
public class GarbageTest {
/
* @param args
* @throws IOException
*/
public static void main(String[] args) throws IOException
{
// TODO Auto-generated method stub
try {
gcTest();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("has exited gcTest!");
System.in.read();
System.in.read();
System.out.println("out begin gc!");
for(int i=0;i<100;i++)
{
System.gc();
System.in.read();
System.in.read();
}
}
private static void gcTest() throws IOException {
System.in.read();
System.in.read();
Person p1 = new Person();
System.in.read();
System.in.read();
Person p2 = new Person();
p1.setMate(p2);
p2.setMate(p1);
System.out.println("before exit gctest!");
System.in.read();
System.in.read();
System.gc();
System.out.println("exit gctest!");
}
private static class Person
{
byte[] data = new byte[20000000];
Person mate = null;
public void setMate(Person other)
{
mate = other;
}
}
}
Java 中的内存泄露的情况:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是 Java 中内存泄露的发生场景,通俗地说,就是程序员可能创建了一个对象,以后一直不再使用这个对象,这个对象却一直被引用,即这个对象无用但是却无法被垃圾回收器回收的,这就是 java 中可能出现内存泄露的情况,例如,缓存系统,我们加载了一个对象放在缓存中 (例如放在一个全局 map 对象中),然后一直不再使用它,这个对象一直被缓存引用,但却不再被使用。 检查 Java 中的内存泄露,一定要让程序将各种分支情况都完整执行到程序结束,然后看某个对象是否被使用过,如果没有,则才能判定这个对象属于内存泄露。 如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持久外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。 下面内容来自于网上(主要特点就是清空堆栈中的某个元素,并不是彻底把它从数组中拿掉,而是把存储的总数减少,本人写得可以比这个好,在拿掉某个元素时,顺便也让它从数组中消失,将那个元素所在的位置的值设置为 null 即可):我实在想不到比那个堆栈更经典的例子了,以致于我还要引用别人的例子,下面的例子不是我想到的,是书上看到的,当然如果没有在书上看到,可能过一段时间我自己也想的到,可是那时我说是我自己想到的也没有人相信的。
public class Stack {
private Object[] elements=new Object[10];
private int size = 0;
public void push(Object e){
ensureCapacity();
elements[size++] = e;
}
public Object pop(){
if( size == 0) throw new EmptyStackException();
return elements[--size];
}
private void ensureCapacity(){
if(elements.length == size){
Object[] oldElements = elements;
elements = new Object[2 * elements.length+1];
System.arraycopy(oldElements,0, elements, 0,
size);
}
}
}
上面的原理应该很简单,假如堆栈加了 10 个元素,然后全部弹出来,虽然堆栈是空的,没有我们要的东西,但是这是个对象是无法回收的,这个才符合了内存泄露的两个条件:无用,无法回收。 但是就是存在这样的东西也不一定会导致什么样的后果,如果这个堆栈用的比较少,也就浪费了几个 K 内存而已,反正我们的内存都上 G 了,哪里会有什么影响,再说这个东西很快就会被回收的,有什么关系。下面看两个例子。
public class Bad{
public static Stack s=Stack();
static{
s.push(new Object());
s.pop(); //这里有一个对象发生内存泄露
s.push(new Object()); //上面的对象可以被回收了,等于是自
愈了
}
}
因为是 static,就一直存在到程序退出,但是我们也可以看到它有自愈功能,就是说如果你的 Stack 最多有 100 个对象,那么最多也就只有 100 个对象无法被回收其实这个应该很容易理解,Stack 内部持有 100 个引用,最坏的情况就是他们都是无用的,因为我们一旦放新的进取,以前的引用自然消失! 内存泄露的另外一种情况:当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在 contains 方法使用该对象的当前引用作为的参数去 HashSet 集合中检索对象,也将返回找不到对象的结果,这也会导致无法从 HashSet 集合中单独删除当前对象,造成内存泄露。
961.深拷贝和浅拷贝。
简单来讲就是复制、克隆。
Person p=new Person(“张三”);
浅拷贝就是对对象中的数据成员进行简单赋值,如果存在动态成员或者指针就会报错。 深拷贝就是对对象中存在的动态成员或指针重新开辟内存空间。
962.System.gc() 和 Runtime.gc() 会做什么事情?
这两个方法用来提示 JVM 要进行垃圾回收。但是,立即开始还是延迟进行垃圾回收是取决于 JVM 的。
963.finalize() 方法什么时候被调用?析构函数 (finalization) 的 目的是什么?
垃圾回收器(garbage colector)决定回收某对象时,就会运行该对象的 finalize() 方法 但是在 Java 中很不幸,如果内存总是充足的,那么垃圾回收可能永远不会进行,也就是说 filalize() 可能永远不被执行,显然指望它做收尾工作是靠不住的。 那么finalize() 究竟是做什么的呢? 它最主要的用途是回收特殊渠道申请的内存。Java 程序有垃圾回收器,所以一般情况下内存问题不用程序员操心。但有一种 JNI(Java Native Interface)调用non-Java 程序(C 或 C++), finalize() 的工作就是回收这部分的内存。
964. 如果对象的引用被置为 null,垃圾收集器是否会立即释放对象占 用的内存?
不会,在下一个垃圾回收周期中,这个对象将是可被回收的。
965.什么是分布式垃圾回收(DGC)?它是如何工作的?
DGC 叫做分布式垃圾回收。RMI 使用 DGC 来做自动垃圾回收。因为 RMI 包含了跨虚拟机的远程对象的引用,垃圾回收是很困难的。DGC 使用引用计数算法来给远程对象提供自动内存管理。
966.串行(serial)收集器和吞吐量(throughput)收集器的区别 是什么?
吞吐量收集器使用并行版本的新生代垃圾收集器,它用于中等规模和大规模数据的应用程序。 而串行收集器对大多数的小应用(在现代处理器上需要大概 100M 左右的内存)就足够了。
967.在 Java 中,对象什么时候可以被垃圾回收?
当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被回收了。
968.简述 Java 内存分配与回收策率以及 Minor GC 和 Major GC。
- 对象优先在堆的 Eden 区分配
- 大对象直接进入老年代
- 长期存活的对象将直接进入老年代
当 Eden 区没有足够的空间进行分配时,虚拟机会执行一次Minor GC。Minor GC 通常发生在新生代的 Eden 区,在这个区的对象生存期短,往往发生 Gc 的频率较高,回收速度比较快;Full GC/Major GC 发生在老年代,一般情况下,触发老年代 GC 的时候不会触发 Minor GC,但是通过配置,可以在 Full GC 之前进行一次 Minor GC 这样可以加快老年代的回收速度。
969.JVM 的永久代中会发生垃圾回收么?
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。 注:Java 8 中已经移除了永久代,新加了一个叫做元数据区的native 内存区。
970.Java 中垃圾收集的方法有哪些?
标记 – 清除:这是垃圾收集算法中最基础的,根据名字就可以知道,它的思想就是标记哪些要被回收的对象,然后统一回收。这种方法很简单,但是会有两个主要问题:
- 效率不高,标记和清除的效率都很低;
- 会产生大量不连续的内存碎片,导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次 GC 动作。 复制算法:为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清楚完第一块内存,再将第二块上的对象复制到第一块。但是这种方式,内存的代价太高,每次基本上都要浪费一般的内存。于是将该算法进行了改进,内存区域不再是按照 1:1 去划分,而是将内存划分为 8:1:1 三部分,较大那份内存交 Eden 区,其余是两块较小的内存区叫 Survior 区。每次都会优先使用 Eden 区,若 Eden 区满,就将对象复制到第二块内存区上,然后清除 Eden 区,如果此时存活的对象太多,以至于 Survivor 不够时,会将这些对象通过分配担保机制复制到老年代中。(java 堆又分为新生代和老年代) 标记 – 整理:该算法主要是为了解决标记 – 清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。 分代收集:现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代和老年代。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保。
971.什么是类加载器,类加载器有哪些?
实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。 主要有一下四种类加载器:
- 启动类加载器(Bootstrap ClassLoader)用来加载 Java 核心类库,无法被 Java 程序直接引用。
- 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
- 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader() 来获取它。
- 用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。
972. 类加载器双亲委派模型机制?
当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。
973.内存模型以及分区,需要详细到每个区放什么。
JVM 分为堆区和栈区,还有方法区,初始化的对象放在堆里面,引用放在栈里面,class 类信息常量池(static 常量和 static 变量)等放在方法区new:
- 方法区:主要是存储类信息,常量池(static 常量和 static 变量),编译后的代码(字节码)等数据
- 堆:初始化的对象,成员变量 (那种非 static 的变量),所有的对象实例和数组都要在堆上分配
- 栈:栈的结构是栈帧组成的,调用一个方法就压入一帧,帧上面存储局部变量表,操作数栈,方法出口等信息,局部变量表存放的是 8 大基础类型加上一个应用类型,所以还是一个指向地址的指针
- 本地方法栈:主要为 Native 方法服务
- 程序计数器:记录当前线程执行的行号
974.堆里面的分区:Eden,survival (from+ to),老年代,各自的特点。
堆里面分为新生代和老生代(java8 取消了永久代,采用了 Metaspace),新生代包含 Eden+Survivor 区,survivor 区里面分为 from 和 to 区,内存回收时,如果用的是复制算法,从 from 复制到 to,当经过一次或者多次 GC 之后,存活下来的对象会被移动到老年区,当 JVM 内存不够用的时候,会触发 Full GC,清理 JVM 老年区当新生区满了之后会触发 YGC,先把存活的对象放到其中一个 Survice区,然后进行垃圾清理。因为如果仅仅清理需要删除的对象,这样会导致内存碎片,因此一般会把 Eden 进行完全的清理,然后整理内存。那么下次 GC 的时候,就会使用下一个 Survive,这样循环使用。如果有特别大的对象,新生代放不下,就会使用老年代的担保,直接放到老年代里面。因为 JVM 认为,一般大对象的存活时间一般比较久远。
975.对象创建方法,对象的内存分配,对象的访问定位。
new 一个对象
976.GC 的两种判定方法:
引用计数法:指的是如果某个地方引用了这个对象就+1,如果失效了就-1,当为 0 就会回收但是 JVM 没有用这种方式,因为无法判定相互循环引用(A 引用 B,B 引用 A)的情况 引用链法: 通过一种 GC ROOT 的对象(方法区中静态变量引用的对象等-static 变量)来判断,如果有一条链能够到达 GC ROOT 就说明,不能到达 GC ROOT 就说明可以回收
977.SafePoint 是什么
比如 GC 的时候必须要等到 Java 线程都进入到 safepoint 的时候 VMThread 才能开始执行 GC,
- 循环的末尾 (防止大循环的时候一直不进入 safepoint,而其他线程在等待它进入safepoint)
- 方法返回前
- 调用方法的 call 之后
- 抛出异常的位置
978.GC 的三种收集方法:标记清除、标记整理、复制算法的原理与特点,分别用 在什么地方,如果让你优化收集方法,有什么思路?
先标记,标记完毕之后再清除,效率不高,会产生碎片复制算法:分为 8:1 的 Eden 区和 survivor 区,就是上面谈到的 YGC标记整理:标记完毕之后,让所有存活的对象向一端移动
979.GC 收集器有哪些?CMS 收集器与 G1 收集器的特点。
并行收集器:串行收集器使用一个单独的线程进行收集,GC 时服务有停顿时间串行收集器:次要回收中使用多线程来执行 CMS 收集器是基于“标记—清除”算法实现的,经过多次标记才会被清除G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的
980.Minor GC 与 Full GC 分别在什么时候发生?
新生代内存不够用时候发生 MGC 也叫 YGC,JVM 内存不够的时候发生 FGC
981.几种常用的内存调试工具:jmap、jstack、jconsole、jhat
jstack 可以看当前栈的情况,jmap 查看内存,jhat 进行 dump 堆的信息mat(eclipse 的也要了解一下)
982.类加载的几个过程:
加载、验证、准备、解析、初始化。然后是使用和卸载了通过全限定名来加载生成 class 对象到内存中,然后进行验证这个 class 文件,包括文件格式校验、元数据验证,字节码校验等。准备是对这个对象分配内存。解析是将符号引用转化为直接引用(指针引用),初始化就是开始执行构造器的代码
983.JVM 内存分哪几个区,每个区的作用是什么?
java 虚拟机主要分为以下一个区: **方法区:
- 有时候也成为永久代,在该区内很少发生垃圾回收,但是并不代表不发生 GC,在这里进行的 GC 主要是对方法区里的常量池和对类型的卸载
- 方法区主要用来存储已被虚拟机加载的类的信息、常量、静态变量和即时编译器编译后的代码等数据。
- 该区域是被线程共享的。
- 方法区里有一个运行时常量池,用于存放静态编译产生的字面量和符号引用。该常量池具有动态性,也就是说常量并不一定是编译时确定,运行时生成的常量也会存在这个常量池中。 **虚拟机栈:
- 虚拟机栈也就是我们平常所称的栈内存,它为 java 方法服务,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口等信息。
- 虚拟机栈是线程私有的,它的生命周期与线程相同。
- 局部变量表里存储的是基本数据类型、returnAddress 类型(指向一条字节码指令的地址)和对象引用,这个对象引用有可能是指向对象起始地址的一个指针,也有可能是代表对象的句柄或者与对象相关联的位置。局部变量所需的内存空间在编译器间确定 4.操作数栈的作用主要用来存储运算结果以及运算的操作数,它不同于局部变量表通过索引来访问,而是压栈和出栈的方式 5.每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接.动态链接就是将常量池中的符号引用在运行期转化为直接引用。 本地方法栈 本地方法栈和虚拟机栈类似,只不过本地方法栈为 Native 方法服务。 堆 java 堆是所有线程所共享的一块内存,在虚拟机启动时创建,几乎所有的对象实例都在这里创建,因此该区域经常发生垃圾回收操作。 **程序计数器 内存空间小,字节码解释器工作时通过改变这个计数值可以选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内存区域是唯一一个 java 虚拟机规范没有规定任何 OOM 情况的区域。
984.如和判断一个对象是否存活?(或者 GC 对象的判定方 法)
判断一个对象是否存活有两种方法:
- 引用计数法
所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收.
引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象 A 引用对象 B,对象B 又引用者对象 A,那么此时 A,B 对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。
2.可达性算法(引用链法)
该算法的思想是:从一个被称为 GC Roots 的对象开始向下搜索,如果一个对象到 GCRoots 没有任何引用链相连时,则说明此对象不可用。在 java 中可以作为 GC Roots 的对象有以下几种:
- 虚拟机栈中引用的对象
- 方法区类静态属性引用的对象
- 方法区常量池引用的对象
- 本地方法栈 JNI 引用的对象
虽然这些算法可以判定一个对象是否能被回收,但是当满足上述条件时,一个对象比不一定会被回收。当一个对象不可达 GC Root 时,这个对象并不会立马被回收,而是出于一个死缓的阶段,若要被真正的回收需要经历两次标记如果对象在可达性分析中没有与 GC Root 的引用链,那么此时就会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法或者已被虚拟机调用过,那么就认为是没必要的。 如果该对象有必要执行 finalize()方法,那么这个对象将会放在一个称为 F-Queue 的对队列中,虚拟机会触发一个 Finalize()线程去执行,此线程是低优先级的,并且虚拟机不会承诺一直等待它运行完,这是因为如果 finalize()执行缓慢或者发生了死锁,那么就会造成 FQueue 队列一直等待,造成了内存回收系统的崩溃。GC 对处于 F-Queue 中的对象进行第二次被标记,这时,该对象将被移除”即将回收”集合,等待回收。
985.简述 java 垃圾回收机制?
在 java 中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。
986.java 中垃圾收集的方法有哪些?
- 标记-清除:
这是垃圾收集算法中最基础的,根据名字就可以知道,它的思想就是标记哪些要被回收的对象,然后统一回收。这种方法很简单,但是会有两个主要问题:
- 效率不高,标记和清除的效率都很低; 2.会产生大量不连续的内存碎片,导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次 GC 动作。
- 复制算法: 为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清楚完第一块内存,再将第二块上的对象复制到第一块。但是这种方式,内存的代价太高,每次基本上都要浪费一般的内存。 于是将该算法进行了改进,内存区域不再是按照 1:1 去划分,而是将内存划分为8:1:1 三部分,较大那份内存交 Eden 区,其余是两块较小的内存区叫 Survior 区。 每次都会优先使用 Eden 区,若 Eden 区满,就将对象复制到第二块内存区上,然后清除 Eden 区,如果此时存活的对象太多,以至于 Survivor 不够时,会将这些对象通过分配担保机制复制到老年代中。(java 堆又分为新生代和老年代)
- 标记-整理 该算法主要是为了解决标记-清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。
- 分代收集 现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代和老年代。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理 或者 标记-清除。
987.java 内存模型
java 内存模型(JMM)是线程间通信的控制机制.JMM 定义了主内存和线程之间抽象关系。 线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下: 从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
988.java 类加载过程?
java 类加载需要经历一下 7 个过程: **加载 加载时类加载的第一个过程,在这个阶段,将完成一下三件事情:
- 通过一个类的全限定名获取该类的二进制流。
- 将该二进制流中的静态存储结构转化为方法去运行时数据结构。
- 在内存中生成该类的 Class 对象,作为该类的数据访问入口。 **验证 验证的目的是为了确保 Class 文件的字节流中的信息不回危害到虚拟机.在该阶段主要完成以下四钟验证:
- 文件格式验证:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型.
- 元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。
- 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。
- 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。 准备 准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。 public static int value=123;//在准备阶段 value 初始值为 0 。在初始化阶段才会变为 123 。 解析 该阶段主要完成符号引用到直接引用的转换动作。解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。 **初始化 初始化时类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
989.简述 java 类加载机制?
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的 java 类型。
990.类加载器双亲委派模型机制?
当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。
991.什么是类加载器,类加载器有哪些?
实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。 主要有一下四种类加载器:
- 启动类加载器(Bootstrap ClassLoader)用来加载 java 核心类库,无法被 java 程序直接引用。
- 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
- 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。
- 用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。
992.简述 java 内存分配与回收策率以及 Minor GC 和 Major GC
- 对象优先在堆的 Eden 区分配。
- 大对象直接进入老年代.
- 长期存活的对象将直接进入老年代. 当 Eden 区没有足够的空间进行分配时,虚拟机会执行一次 Minor GC.Minor Gc 通 常发生在新生代的 Eden 区,在这个区的对象生存期短,往往发生 Gc 的频率较高, 回收速度比较快;Full Gc/Major GC 发生在老年代,一般情况下,触发老年代 GC 的时候不会触发 Minor GC,但是通过配置,可以在 Full GC 之前进行一次 Minor GC 这样可以加快老年代的回收速度