Coding
本文最后更新于:2024年12月8日 下午
Coding
Java SE
基本数据类型和大小
- boolean (1) = byte (1) < short (2) = char (2) < int (4) = float (4) < long (8) = double (8)
int 和 Integer 的区别
- int 是基本数据类型,Integer 是他的包装类
- Integer 保存的是对象的引用,int 保存的变量值
- Integer 默认是 null,int 默认是 0
- Integer 变量必须实例化后才能使用, 而 int 变量不需要
Integer 缓存
1 |
|
自动拆箱装箱
自动装箱与自动拆箱的实现原理
1 |
|
- 对以上代码进行反编译后可以得到以下代码:
1 |
|
哪些地方会自动拆装箱
- 将基本数据类型放入集合类
- 包装类型和基本类型的大小比较: 包装类与基本数据类型进行比较运算, 是先将包装类进行拆箱成基本数据类型, 然后进行比较的
- 包装类型的运算: 两个包装类型之间的运算, 会被自动拆箱成基本类型进行
- 三目运算符的使用: 当第二, 第三位操作数分别为基本类型和对象时, 其中的对象就会拆箱为基本类型进行操作
接口和抽象类的区别
- 类可以实现很多个接口, 但是只能继承一个抽象类
- 接口中所有的方法隐含的都是抽象的除了 default 方法, 而抽象类则可以同时包含抽象和非抽象的方法
- 接口中声明的变量默认都是 public final 的, 抽象类可以包含非 public final 的变量
- 抽象类是对实体类的抽象, 接口是对行为的抽象
访问控制
控制等级 | 同一类中 | 同一包中 | 不同包的子类中 | 其他 |
---|---|---|---|---|
private | 可直接访问 | |||
默认 | 可直接访问 | 可直接访问 | ||
protected | 可直接访问 | 可直接访问 | 可直接访问 | |
public | 可直接访问 | 可直接访问 | 可直接访问 | 可直接访问 |
super 和 this 的异同
- super (参数): 调用父类中的某一个构造函数 (应该为构造函数中的第一条语句), 每个子类构造方法的第一条语句, 都是隐含地调用
super()
, 如果父类没有这种形式的构造函数, 那么在编译的时候就会报错 - this (参数): 调用本类中另一种形成的构造函数 (应该为构造函数中的第一条语句)
- this 和 super 不能同时出现在一个构造函数中:因为 this 必然会调用其它的构造函数, 其它的构造函数必然也会有 super 语句的存在, 所以在同一个构造函数里面有相同的语句, 就失去了语句的意义, 编译器也不会通过
this
和super
都指的是对象, 所以均不可以在 static 环境中使用, 包括: static 变量, static 方法, static 语句块
继承中父类和子类的初始化顺序
- 父类中静态成员变量和静态代码块
- 子类中静态成员变量和静态代码块
- 父类中普通成员变量和代码块, 父类的构造函数
- 子类中普通成员变量和代码块, 子类的构造函数
多态
- 指允许不同类的对象对同一函数做出响应。即同一函数可以根据发送对象的不同而采用多种不同的行为方式
- 实现多态的技术称为:动态绑定(dynamic binding),是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法
- 方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重写(override)实现的是运行时的多态性(也称为后绑定)
- 多态存在的三个必要条件
- 继承
- 重写
- 父类引用指向子类对象
- 多态的实现方式
- 重写
- 接口
- 抽象类和抽象方法
方法的覆盖与隐藏
- 父类的实例方法被子类的同名实例方法覆盖, 父类的静态方法被子类的同名静态方法隐藏, 父类的实例变量和类变量可以被子类的实例变量和类变量隐藏
- 通过父类引用可以暴露隐藏的变量和方法
- 方法覆盖不能改变方法的静态与非静态属性, 子类中不能将父类的实例方法定义为静态方法, 也不能将父类的静态方法定义为实例方法
- 不允许子类中方法的访问修饰符比父类有更多的限制, 例如, 不能将父类定义中用 public 修饰的方法在子类中重定义为 private 方法, 但可以将父类的 private 方法重定义为 public 方法, 通常应将子类中方法访问修饰与父类中的保持一致
方法重写后的动态绑定
- 多态允许具体访问时实现方法的动态绑定, Java 对于动态绑定的实现主要依赖于方法表, 通过继承和接口的多态实现有所不同
- 继承: 在执行某个方法时, 在方法区中找到该类的方法表, 再确认该方法在方法表中的偏移量, 找到该方法后如果被重写则直接调用, 否则认为没有重写父类该方法, 这时会按照继承关系搜索父类的方法表中该偏移量对应的方法
- 接口: Java 允许一个类实现多个接口, 从某种意义上来说相当于多继承, 这样同一个接口的的方法在不同类方法表中的位置就可能不一样了, 所以不能通过偏移量的方法, 而是通过搜索完整的方法表
异常
- Error: 程序中无法处理的错误, 此类错误一般表示代码运行时 JVM 出现问题, 通常有 VirtualMachineError (虚拟机运行错误), OutOfMemoryError 等, JVM 将终止线程
- Exception: 程序本身可以捕获并且可以处理的异常
- 运行时异常 (非受检异常): RuntimeException 类及其子类, 表示 JVM 在运行期间可能出现的错误, 编译器不会检查此类异常, 并且不要求处理异常, 比如用空值对象的引用 (NullPointerException), 数组下标越界 (ArrayIndexOutBoundException), 此类异常属于不可查异常, 一般是由程序逻辑错误引起的, 在程序中可以选择捕获处理, 也可以不处理
- 非运行时异常 (受检异常): Exception 中除 RuntimeException 极其子类之外的异常, 编译器会检查此类异常, 如果程序中出现此类异常, 比如说 IOException, 必须对该异常进行处理, 要么使用 try-catch 捕获, 要么使用 throws 语句抛出, 否则编译不通过
try-catch-finally 中的 return
- 当 try 和 catch 中有 return 时,finally 仍然会执行, finally 是在 return 后面的表达式运算之后执行的
- try 语句在返回前,将其他所有的操作执行完,保留好要返回的值,而后转入执行 finally 中的语句,而后分为以下三种情况:
- 如果 finally 中有 return 语句,则会将 try 中的 return 语句覆盖掉,直接执行 finally 中的 return 语句,得到返回值,这样便无法得到 try 之前保留好的返回值
- 如果 finally 中没有 return 语句,也没有改变要返回值,则执行完 finally 中的语句后,会接着执行 try 中的 return 语句,返回之前保留的值。
- 如果 finally 中没有 return 语句,但是改变了要返回的值,这里有点类似与引用传递和值传递的区别,分以下两种情况:
- 如果 return 的数据是基本数据类型或文本字符串,则在 finally 中对该基本数据的改变不起作用,try 中的 return 语句依然会返回进入 finally 块之前保留的值。
- 如果 return 的数据是引用数据类型,而在 finally 中对该引用数据类型的属性值的改变起作用,try 中的 return 语句返回的就是在 finally 中改变后的该属性的值。
String & StringBuilder & StringBuffer
- String 是只读字符串, 也就意味着 String 引用的字符串内容是不能被改变的
- StringBuffer/StringBuilder 类表示的字符串对象可以直接进行修改
- StringBuilder 和 StringBuffer 的方法完全相同, 区别在于 StringBuilder 不是线程安全, 因为它的所有方面都没有被 synchronized 修饰, 因此它的效率也比 StringBuffer 要高
- 当使用 + 号将字符串拼接时, 其实底层是调用了 StringBuilder 的 append 方法
- 创建一个字符串时, 首先会检查池中是否有值相同的字符串对象, 如果有就直接返回引用, 不会创建字符串对象, 如果没有则新建字符串对象, 返回对象引用, 并且将新创建的对象放入池中, 但是, 通过 new 方法创建的 String 对象是不检查字符串常量池的, 而是直接在堆中创建新对象, 也不会把对象放入池中, 上述原则只适用于直接给 String 对象引用赋值的情况
1 |
|
浅复制和深复制
- 如果在拷贝这个对象的时候, 只对基本数据类型进行了拷贝, 而对引用数据类型只是进行了引用的传递, 而没有真实的创建一个新的对象, 则认为是浅拷贝
- 反之, 在对引用数据类型进行拷贝的时候, 创建了一个新的对象, 并且复制其内的成员变量, 则认为是深拷贝
Object 类的方法
- clone (): 创建并返回此对象的一个副本
- equals (Object obj): 指示某个其他对象是否与此对象"相等”
- finalize (): 当垃圾回收器确定不存在对该对象的更多引用时, 由对象的垃圾回收器调用此方法
- getClass (): 返回一个对象的运行时类
- hashCode (): 返回该对象的 Hash 值
- notify (): 唤醒在此对象监视器上等待的单个线程
- notifyAll (): 唤醒在此对象监视器上等待的所有线程
- toString (): 返回该对象的字符串表示
- wait (): 导致当前的线程等待, 直到其他线程调用此对象的 notify () 方法或 notifyAll () 方法
- wait (long timeout): 导致当前的线程等待, 直到其他线程调用此对象的 notify () 方法或 notifyAll () 方法, 或者超过指定的时间量
- wait (long timeout, int nanos): 导致当前的线程等待, 直到其他线程调用此对象的 notify () 方法或 notifyAll () 方法, 或者其他某个线程中断当前线程, 或者已超过某个实际时间量
String 为什么不可变
- 不可变对象: 指一个对象的状态在对象被创建之后就不再变化, 不能改变对象内的成员变量, 包括基本数据类型的值不能改变, 引用类型的变量不能指向其他的对象, 引用类型指向的对象的状态也不能改变
- String 不可变是因为在 JDK 中 String 类被声明为一个 final 类, 且类内部的 value 字节数组也是 final 的
- 只有当字符串是不可变时字符串池才有可能实现, 字符串池的实现可以在运行时节约很多 heap 空间, 因为不同的字符串变量都指向池中的同一个字符串
序列化与反序列化
- Java 序列化是将一个对象编码成一个字节流, 反序列化将字节流编码转换成一个对象
- 为了实现用户自定义的序列化, 相应的类必须实现
Serializable
接口,Serializable
接口中没有定义任何方法在, 实现了 Serializable 接口后, JVM 会在底层帮我们实现序列化和反序列
serialVersionUID 的作用
- 在进行反序列化时, JVM 会把传来的字节流中的 serialVersionUID 与本地相应实体 (类) 的 serialVersionUID 进行比较, 如果相同就认为是一致的, 可以进行反序列化, 否则就会出现序列化版本不一致的异常
- 如果不显示指定 serialVersionUID, JVM 在序列化时会根据属性自动生成一个 serialVersionUID, 然后与属性一起序列化, 再进行持久化或网络传输. 在反序列化时, JVM 会再根据属性自动生成一个新版 serialVersionUID, 然后将这个新版 serialVersionUID 与序列化时生成的旧版 serialVersionUID 进行比较, 如果相同则反序列化成功, 否则报错
- 当序列化了一个类实例后, 希望更改一个字段或添加一个字段, 不设置 serialVersionUID, 所做的任何更改都将导致无法反序化旧有实例, 并在反序列化时抛出异常
泛型
- 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。
- 在没有泛型的情况的下,通过对类型 Object 的引用来实现参数的“任意化”,“任意化”带来的缺点是要做显式的强制类型转换,而这种转换是要求开发者对实际参数类型可以预知的情况下进行的。对于强制类型转换错误的情况,编译器可能不提示错误,在运行的时候才出现异常,这是本身就是一个安全隐患。
通配符与类型参数
- 本质下面几种符号都是通配符,只不过是编码时的一种约定俗成的东西。通常情况下,T,E,K,V,? 是这样约定的:
T
(type) 表示具体的一个 Java 类型K V
(key value) 分别代表 Java 键值对中的 Key ValueE
(element) 代表 Element?
表示不确定的 Java 类型
- ?与 T 的区别
T
是一个确定的类型, 通常用于泛型类和泛型方法的定义?
是一个不确定的类型, 通常用于泛型方法的调用代码和形参, 不能用于定义类和泛型方法, 所以主要用于声明时的限制情况- T 可以多重限定而 ? 不行
- 通过 T 来确保泛型参数的一致性
- ? 可以使用超类限定而 T 不行
- 通配符的上界与下界
< ? extends E>
: 在类型参数中使用 extends 表示这个泛型中的参数必须是 E 或者 E 的子类< ? super E>
: 用 super 进行声明, 表示参数化的类型可能是所指定的类型, 或者是此类型的父类型, 直至 Object
Java 值传递与引用传递
- Java 参数传递分为值传递和引用传递, 基本类型是值传递, 封装的对象时引用传递
Statement 和 PreparedStatement 的区别
- PreparedStatement 接口代表预编译的语句, 它主要的优势在于可以减少 SQL 的编译错误并增加 SQL 的安全性 (减少 SQL 注射攻击的可能性)
- PreparedStatement 中的 SQL 语句是可以带参数的, 避免了用字符串连接拼接 SQL 语句的麻烦和不安全
- 当批量处理 SQL 或频繁执行相同的查询时, PreparedStatement 有明显的性能上的优势, 由于数据库可以将编译优化后的 SQL 语句缓存起来, 下次执行相同结构的语句时就会很快 (不用再次编译和生成执行计划)
JDBC 中 Class. forName 的作用
Class. forName
方法的作用是初始化给定的类, 而我们给定的 MySQL 的 Driver 类中, 它在静态代码块中通过 JDBC 的 DriverManager 注册了一下驱动
反射
获得一个类的类对象
- 类名. class, 例如: String. class
- 对象.getClass (), 例如: “hello”.getClass ()
- Class.forName (), 例如: Class.forName (“java. lang. String”)
通过反射创建对象
- 通过类对象调用 newInstance () 方法, 例如:
String.class.newInstance ()
- 通过类对象的 getConstructor () 或 getDeclaredConstructor () 方法获得构造器 (Constructor) 对象并调用其 newInstance () 方法创建对象, 例如:
String.class.getConstructor (String. class). newInstance ("Hello");
通过反射获取和设置对象私有字段的值
- 通过类对象的
getDeclaredField ()
方法字段 (Field) 对象 - 再通过字段对象的
setAccessible (true)
将其设置为可以访问 - 通过 get/set 方法来获取/设置字段的值了
通过反射调用对象的方法
- 通过类对象的
getMethod ()
方法获得方法对象 - 调用方法对象的
invoke ()
方法
Java EE
forward 与 redirect
- forward: 服务器请求资源, 服务器直接访问目标地址的 URL, 把对应 URL 的响应内容读取过来, 再发送给浏览器, 所以 URL 不变, 可以共享 request 的数据
- redirect: 服务器发送一个状态码 302, 告诉浏览器重新去请求指定的地址, 不能共享数据, 地址栏显示的是新的 URL
Cookie 与 Session
- cookie 数据存放在客户的浏览器上, session 数据放在服务器上
- cookie 不是很安全, 别人可以分析存放在本地的 cookie 并进行 cookie 欺骗, 考虑到安全应当使用 session
- 单个 cookie 保存的数据不能超过 4 K, 很多浏览器都限制一个站点最多保存 20 个 cookie
- 可以考虑将登陆信息等重要信息存放为 session, 其他信息如果需要保留, 可以放在 cookie 中
集合
List/Set/Map 的区别
- List 有序存取元素, 可以有重复元素
- Set 不能存放重复元素, 存入的元素是无序的
- Map 保存键值对映射, 映射关系可以是一对一或多对一
HashMap/Hashtable/HashSet/LinkedHashMap/TreeMap 比较
- Hashmap 是一个最常用的 Map, 它根据键的 HashCode 值存储数据, HashMap 最多只允许一条记录的键为 Null, 允许多条记录的值为 Null
- Hashtable 与 HashMap 类似, 不同的是: 它不允许记录的键或者值为空, 是线程安全的, 因此也导致了 Hashtale 的效率偏低
- LinkedHashMap 是 HashMap 的一个子类, 如果需要输出的顺序和输入的相同, 那么用 LinkedHashMap 可以实现
- TreeMap 实现 SortMap 接口, 内部实现是红黑树, 能够把它保存的记录根据键排序, 默认是按键值的升序排序, 也可以指定排序的比较器, 当用 Iterator 遍历 TreeMap 时, 得到的记录是排过序的, TreeMap 不允许 key 的值为 null
HashSet/LinkedHashSet/TreeSet 比较
- HashSet
- HashSet 是由 HashMap 实现的, 不保证元素的顺序
- LinkedHashSet
- LinkedHashSet 集合同样是根据元素的 hashCode 值来决定元素的存储位置, 但是它同时使用链表维护元素的次序, 这样使得元素看起来像是以插入顺序保存的, 也就是说, 当遍历该集合时候, LinkedHashSet 将会以元素的添加顺序访问集合的元素
- LinkedHashSet 在迭代访问 Set 中的全部元素时, 性能比 HashSet 好, 但是插入时性能稍微逊色于 HashSet
- TreeSet
- TreeSet 是 SortedSet 接口的唯一实现类, TreeSet 可以确保集合元素处于排序状态
ArrayList/LinkedList 比较
- 数据结构
- ArrayList 内部使用数组存放元素, 实现了可变大小的数组, 访问元素效率高, 当插入元素效率低
- LinkedList 内部使用双向链表存储元素, 插入元素效率高, 但访问元素效率低
- 时间复杂度:相对于 ArrayList, LinkedList 的插入, 添加, 删除操作速度更快, 因为当元素被添加到集合任意位置的时候, 不需要像数组那样重新计算大小或者是更新索引
- ArrayList: 查找时间复杂度 O (1), 增删时间复杂度 O (n/2)
- LinkedList: 查找时间复杂度 O (n/4), 增删时间复杂度 O (n/4)
- 空间复杂度:LinkedList 比 ArrayList 更占内存, 因为 LinkedList 为每一个节点存储了两个引用, 一个指向前一个元素, 一个指向下一个元素
- ArrayList 扩容机制:相当于在没指定 initialCapacity 时就是会使用延迟分配对象数组空间,当第一次插入元素时才分配 10(默认)个对象空间。假如有 20 个数据需要添加,那么会分别在第一次的时候,将 ArrayList 的容量变为 10,之后扩容会按照 1.5 倍增长。也就是当添加第 11 个数据的时候,Arraylist 继续扩容变为 10*1.5=15,当添加第 16 个数据时,继续扩容变为 15 * 1.5 =22 个
HashMap
- HashMap 基于 Hash 表的 Map 接口实现, 主要用来存放键值对, HashMap 通过 Hash 函数定位元素的存放位置, 所以 HashMap 并不是有序的, 使用拉链法解决 Hash 冲突, 在 JDK 1.8 之后, 当 HashEntry 的长度大于 8 并且当前 table 数组的长度大于 64 时, 将链表改为使用红黑树存储
- 补充: 将链表转换成红黑树前会判断, 即便阈值大于 8, 但是数组长度小于 64, 此时并不会将链表变为红黑树, 而是选择逬行数组扩容, 这样做的目的是因为数组比较小, 尽量避开红黑树结构, 这种情况下变为红黑树结构, 反而会降低效率, 因为红黑树需要逬行左旋, 右旋, 变色这些操作来保持平衡, 同时数组长度小于 64 时, 搜索时间相对要快
- Hash 函数:包括取 key 的 hashCode 值, 高位运算, 取模运算
- 如果 key 为 null 则 Hash 函数的值为 0
- 高位运算的算法, 通过 hashCode () 的高 16 位异或低 16 位实现的可以在数组 table 的 length 比较小的时候, 也能保证考虑到高低 Bit 都参与到 Hash 的计算中, 同时不会有太大的开销
- 取模运算通过 h & (table. length -1) 来得到该对象的保存位置, 而 HashMap 底层数组的长度总是 2 的 n 次方, 当 length 总是 2 的 n 次方时, h& (length-1) 运算等价于对 length 取模, 也就是 h%length, 但是&比%具有更高的效率
- 重写方法:由于 HashMap 的插入查询等操作需要用到
hashcode ()
和equals ()
, 所以如果 key 是自定义的类, 就必须重写这两个方法 - 扩容:
- HashMap 的扩容一个耗时的操作, 在第一次
put ()
的时候会初始化, 发生第一次resize ()
到 16, 默认负载因子是 0.75, 当容量达到size*Load Factor
时就会扩大容量为原来的 2 倍, 所以如果我们知道大概的数据量, 应该使用对应的构造方法直接初始化指定容量的 HashMap - 扩容长度扩为原来 2 倍, 所以元素的位置要么是在原位置, 要么是在原位置再移动 2 次幂的位置,因此, 我们在扩充 HashMap 的时候, 不需要像 JDK 1.7 的实现那样重新计算 hash, 只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了
- HashMap 的扩容一个耗时的操作, 在第一次
- 非线程安全:HashMap 的不是线程安全的, 如果需要使用线程安全的 Map 集合, 可以通过 Collections 类的 synchronizedMap 方法包装一下, 或者直接使用 JUC 包下的 ConcurrentHashMap
Hash 冲突
- 由于 Hash 算法被计算的数据是无限的, 而计算后的结果范围有限, 因此总会存在不同的数据经过计算后得到的值相同, 这就是 Hash 冲突
- 冲突处理分为以下几种方式:
- 开放地址法: 出现冲突后按照一定算法查找一个空位置存放
- 线性探测再散列: 线性探测方法就是线性探测空白单元, 当数据通过 Hash 函数计算应该放在 700 这个位置, 但是 700 这个位置已经有数据了, 那么接下来就应该查看 701 位置是否空闲, 再查看 702 位置, 依次类推
- 二次探测再散列: 二次探测是过程是 x+1, x+4, x+9, 以此类推,二次探测的步数是原始位置相隔的步数的平方
- 再哈希法: 出现冲突后采用其他的 Hash 函数计算, 直到不再冲突为止
- 链地址法 (拉链法): 不同与前两种方法, 他是在出现冲突的地方存储一个链表, 所有的同义词记录都存在其中
- 建立公共溢出区: 建立公共溢出区的基本思想是: 假设 Hash 函数的值域是[1, m-1], 则设向量 HashTable[0… m-1] 为基本表, 每个分量存放一个记录, 另外设向量 OverTable[0… v] 为溢出表, 所有关键字和基本表中关键字为同义词的记录, 不管它们由 Hash 函数得到的 Hash 值是什么, 一旦发生冲突, 都填入溢出表
- 开放地址法: 出现冲突后按照一定算法查找一个空位置存放
ConcurrentHashMap
-
ConcurrentHashmap 是线程安全的
-
JDK 1.7
- ConcurrentHashMap 和 HashMap 实现上类似, 最主要的差别是 ConcurrentHashMap 采用了分段锁 (Segment), 它继承自重入锁 ReentrantLock, 每个分段锁维护着几个桶 (HashEntry), 多个线程可以同时访问不同分段锁上的桶, 从而使其并发度更高 (并发度就是 Segment 的个数)
- 在 HashEntry 类中: key, hash 和 next 域都被声明为 final 型, value 域被声明为 volatile 型
- 在 ConcurrentHashMap 中, 如果产生 Hash 冲突, 将采用拉链法来处理, 即把碰撞的 HashEntry 对象链接成一个链表, 由于 HashEntry 的 next 域为 final 型, 所以新节点只能在链表的表头处插入, 由于只能在表头插入, 所以链表中节点的顺序和插入的顺序相反
- size () 的计算是先采用不加锁的方式, 连续计算元素的个数, 最多计算 3 次:
- 如果前后两次计算结果相同, 则说明计算出来的元素个数是准确的
- 如果前后两次计算结果都不同, 则给每个 Segment 进行加锁, 再计算一次元素的个数
-
JDK 1.8
- 放弃了 Segment 臃肿的设计, 使用了 CAS 操作来支持更高的并发度, 在 CAS 操作失败时使用内置锁 synchronized, 在链表过长时会转换为红黑树
poll & offer
throw Exception | 返回 false 或 null | |
---|---|---|
添加元素到队尾 | add (E e) | boolean offer (E e) |
取队首元素并删除 | E remove () | E poll () |
取队首元素但不删除 | E element () | E peek () |
快速失败 (fail-fast) 和安全失败 (fail-safe)
- Iterator 的安全失败是基于对底层集合做拷贝, 因此, 它不受源集合上修改的影响, java. util 包下面的所有的集合类都是快速失败的
- JUC 包下面的所有的类都是安全失败的, 快速失败的迭代器会抛出 ConcurrentModificationException 异常, 而安全失败的迭代器永远不会抛出这样的异常
重写 equals 和 hashCode
- 作为
key
的对象必须正确覆写equals ()
方法, 相等的两个key
实例调用equals ()
必须返回true
- 作为
key
的对象还必须正确覆写hashCode ()
方法, 因为通过key
计算索引的方式就是调用key
对象的hashCode ()
方法, 它返回一个int
整数,HashMap
正是通过这个方法直接定位key
对应的value
的索引, 继而直接返回value
, 且hashCode ()
方法要严格遵循以下规范:- 如果两个对象相等, 则两个对象的
hashCode ()
必须相等 - 如果两个对象不相等, 则两个对象的
hashCode ()
尽量不要相等
- 如果两个对象相等, 则两个对象的
- 即对应两个实例
a
和b
:- 如果
a
和b
相等, 那么a.equals (b)
一定为true
, 则a.hashCode ()
必须等于b.hashCode ()
- 如果
a
和b
不相等, 那么a.equals (b)
一定为false
, 则a.hashCode ()
和b.hashCode ()
尽量不要相等
- 如果
CopyOnWriteArrayList
- CopyOnWriteArrayList 是 ArrayList 的线程安全变体,其中通过创建底层数组的新副本来实现所有可变操作(添加,设置等)。
- CopyOnWriteArrayList 支持无锁并发读,在写操作时,将原容器拷贝一份,写操作则作用在新副本上,需要加锁。此过程中若有读操作则会作用在原容器上,将原容器引用指向新副本,切换过程使用 volatile 保证切换过程对读线程立即可见
Iterator 和 ListIterator
- Iterator 提供了统一遍历操作集合元素的统一接口, Collection 接口实现 Iterable 接口, 每个集合都通过实现 Iterable 接口中
iterator ()
方法返回 Iterator 接口的实例, 然后对集合的元素进行迭代操作 - 优点
- 对任何集合都采用同一种访问模型
- 调用者不用了解集合的内部结构
- Iterator 和 ListIterator 的区别
- Iterator 可用来遍历 Set 和 List 集合, 但是 ListIterator 只能用来遍历 List
- Iterator 对集合只能是前向遍历, ListIterator 既可以前向也可以后向
- ListIterator 实现了 Iterator 接口, 并包含其他的功能, 比如: 增加元素, 替换元素, 获取前一个和后一个元素的索引
多线程
线程的状态
线程的状态
-
Java 中线程的状态分为 6 种
- 初始 (NEW): 新创建了一个线程对象, 但还没有调用 start () 方法
- 运行 (RUNNABLE): Java 线程中将就绪 (ready) 和运行中 (running) 两种状态笼统的称为"运行”
线程对象创建后, 其他线程 (比如 main 线程) 调用了该对象的 start () 方法, 该状态的线程位于可运行线程池中, 等待被线程调度选中, 获取 CPU 的使用权, 此时处于就绪状态 (ready), 就绪状态的线程在获得 CPU 时间片后变为运行中状态 (running) - 阻塞 (BLOCKED): 表示线程阻塞于锁
- 等待 (WAITING): 进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)
- 超时等待 (TIMED_WAITING): 该状态不同于 WAITING, 它可以在指定的时间后自行返回
- 终止 (TERMINATED): 表示该线程已经执行完毕
-
这 6 种状态定义在 Thread 类的 State 枚举中, 可查看源码进行一一对应
线程的状态图

多线程的创建方式
- 继承 Thread 类
- 实现 Runnable 接口
- 实现 Callable 接口
多线程有关的方法
Thread.sleep (long millis)
: 一定是当前线程调用此方法, 当前线程进入TIMED_WAITING
状态, 但不释放对象锁, millis 后线程自动苏醒进入就绪状态Thread.yield ()
: 一定是当前线程调用此方法, 当前线程放弃获取的 CPU 时间片, 但不释放锁资源, 由运行状态变为就绪状态, 让 OS 再次选择线程thread.join ()/thread.join (long millis)
: 当前线程里调用其它线程 t 的 join 方法, 当前线程进入WAITING/TIMED_WAITING
状态, 当前线程不会释放已经持有的对象锁, 线程 t 执行完毕或者 millis 时间到, 当前线程一般情况下进入 RUNNABLE 状态, 也有可能进入 BLOCKED 状态 (因为 join 是基于 wait 实现的)thread.interrupt ()
: 标记线程为中断状态, 不过不会中断正在运行的线程thread.isInterrupted ()
: 测试线程是否已经中断, 该方法由对象调用obj.wait ()
: 当前线程调用对象的wait ()
方法, 当前线程释放对象锁, 进入等待队列, 依靠notify ()/notifyAll ()
唤醒或者wait (long timeout)
timeout 时间到自动唤醒ObjectSynchronizer::wait
方法通过 object 的对象中找到 ObjectMonitor 对象调用方法void ObjectMonitor:: wait (jlong millis, bool interruptible, TRAPS)
- 通过
ObjectMonitor::AddWaiter
调用把新建立的ObjectWaiter
对象放入到_WaitSet
的队列的末尾中然后在ObjectMonitor::exit
释放锁, 接着thread_ParkEvent->park
也就是 wait
obj.notify ()
: 唤醒在此对象监视器上等待的单个线程, 选择是任意性的obj.notifyAll ()
: 唤醒在此对象监视器上等待的所有线程
停止线程
- stop () 停止: 线程调用 stop () 方法会被暴力停止, 方法已弃用, 该方法会有不好的后果:
- 强制让线程停止有可能使一些清理性的工作得不到完成
- 对锁定的对象进行了解锁, 导致数据得不到同步的处理, 出现数据不一致的问题 (比如一个方法加上了 synchronized, 并在其中进行了一个长时间的处理, 而在处理结束之前该线程进行了
stop ()
, 则未完成的数据将没有进行到同步的处理)
- 异常法停止: 线程调用
interrupt ()
方法后, 在线程的 run 方法中判断当前对象的 interrupted 状态, 如果是中断状态则抛出异常, 达到中断线程的效果 - 在沉睡中停止: 先将线程 sleep, 然后调用 interrupt 标记中断状态, interrupt 会将阻塞状态的线程中断, 会抛出中断异常, 达到停止线程的效果
CAS
- CAS, 是 Compare and Swap 的简称, 在这个机制中有三个核心的参数
- 主内存中存放的共享变量的值: V (一般情况下这个 V 是内存的地址值, 通过这个地址可以获得内存中的值)
- 工作内存中共享变量的副本值, 也叫预期值:A
- 需要将共享变量更新到的最新值:B
- 如果内存中的值与预期值一样, 则更新为最新值
- ABA 问题
- CAS 需要在操作值的时候检查内存值是否发生变化, 没有发生变化才会更新内存值, 但是如果内存值原来是 A, 后来变成了 B, 然后又变成了 A, 那么 CAS 进行检查时会发现值没有发生变化, 但是实际上是有变化的, ABA 问题的解决思路就是在变量前面添加版本号, 每次变量更新的时候都把版本号加一, 这样变化过程就从
A-B-A
变成了1 A-2 B-3 A
- JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题, 具体操作封装在 compareAndSet () 中,compareAndSet () 首先检查当前引用和当前标志与预期引用和预期标志是否相等, 如果都相等, 则以原子方式将引用值和标志的值设置为给定的更新值
- CAS 需要在操作值的时候检查内存值是否发生变化, 没有发生变化才会更新内存值, 但是如果内存值原来是 A, 后来变成了 B, 然后又变成了 A, 那么 CAS 进行检查时会发现值没有发生变化, 但是实际上是有变化的, ABA 问题的解决思路就是在变量前面添加版本号, 每次变量更新的时候都把版本号加一, 这样变化过程就从
- CAS 操作在底层有一条对应的汇编指令, 硬件直接支持, LOCK_IF_MP, 如果是多个 CPU, 前面还要加一条 LOCK 指令, 本身 cmpxchg 指令是没有原子性的, 在多个 cpu 执行时, 加上 LOCK 指令就是保证一个 CPU 执行后面的 empxchg 指令时, 其他 CPU 不能执行, 在硬件层, LOCK 指令在执行后面指令的时候锁定一个北桥信号 (不采用锁总线方式), 所以 CAS 最终实现就是 lock cmpxchg 指令
cyclicbarrier 与 countdownlatch 区别
- CountDownLatch 一般用于某个线程 A 等待若干个其他线程执行完任务之后, 它才执行
- CyclicBarrier 一般用于一组线程互相等待至某个状态, 然后这一组线程再同时执行
- CountDownLatch 是不能够重用的, 而 CyclicBarrier 是可以重用的
多线程回调
- 所谓回调, 就是客户程序 C 调用服务程序 S 中的某个方法 A, 然后 S 又在某个时候反过来调用 C 中的某个方法 B, 对于 C 来说, 这个 B 方法便叫做回调方法框架
原子性, 可见性, 有序性
- 原子性: 能够保证同一时刻有且只有一个线程在操作共享数据, 其他线程必须等该线程处理完数据后才能进行
- 可见性: 当一个线程在修改共享数据时, 其他线程能够看到
- 有序性: 在 Java 中, JVM 能够根据处理器特性 (CPU 多级缓存系统, 多核处理器等) 适当对机器指令进行重排序, 最大限度发挥机器性能, Java 中的指令重排序有两次, 第一次发生在将字节码编译成机器码的阶段, 第二次发生在 CPU 执行的时候, 也会适当对指令进行重排
JMM
- Java 虚拟机规范中定义了一种 Java 内存模型 (Java Memory Model, 即 JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异, 以实现让 Java 程序在各种平台下都能达到一致的并发效果, Java 内存模型的主要目标就是定义程序中各个变量的访问规则, 即在虚拟机中将变量存储到内存和从内存中取出变量这样的细节
- JMM 中规定所有的变量都存储在主内存 (Main Memory) 中, 每条线程都有自己的工作内存 (Work Memory), 线程的工作内存中保存了该线程所使用的变量的从主内存中拷贝的副本, 线程对于变量的读, 写都必须在工作内存中进行, 而不能直接读, 写主内存中的变量, 同时, 本线程的工作内存的变量也无法被其他线程直接访问, 必须通过主内存完成
- 关于主内存与工作内存之间的具体交互协议, 即一个变量如何从主内存拷贝到工作内存, 如何从工作内存同步到主内存之间的实现细节, Java 内存模型定义了以下八种操作来完成:
- lock (锁定): 作用于主内存的变量, 把一个变量标识为一条线程独占状态
- unlock (解锁): 作用于主内存变量, 把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定
- read (读取): 作用于主内存变量, 把一个变量值从主内存传输到线程的工作内存中, 以便随后的 load 动作使用
- load (载入): 作用于工作内存的变量, 它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中
- use (使用): 作用于工作内存的变量, 把工作内存中的一个变量值传递给执行引擎, 每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign (赋值): 作用于工作内存的变量, 它把一个从执行引擎接收到的值赋值给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- store (存储): 作用于工作内存的变量, 把工作内存中的一个变量的值传送到主内存中, 以便随后的 write 的操作
- write (写入): 作用于主内存的变量, 它把 store 操作从工作内存中一个变量的值传送到主内存的变量中
volatile
- volatile 关键字是用来保证有序性和可见性的
- 有序性:
volatile
是通过编译器在生成字节码时, 在指令序列中添加内存屏障来禁止指令重排序的 - 当对 volatile 变量执行写操作后, JMM 会把工作内存中的最新变量值强制刷新到主内存写操作会导致其他线程中的缓存无效这样, 其他线程使用缓存时, 发现本地工作内存中此变量无效, 便从主内存中获取, 这样获取到的变量便是最新的值, 实现了线程的可见性
锁
-
乐观锁与悲观锁
-
对于同一个数据的并发操作, 悲观锁认为自己在使用数据的时候一定有别的线程来修改数据, 因此在获取数据的时候会先加锁, 确保数据不会被别的线程修改, Java 中, synchronized 关键字和 Lock 的实现类都是悲观锁
-
而乐观锁认为自己在使用数据时不会有别的线程修改数据, 所以不会添加锁, 只是在更新数据的时候去判断之前有没有别的线程更新了这个数据, 如果这个数据没有被更新, 当前线程将自己修改的数据成功写入, 如果数据已经被其他线程更新, 则根据不同的实现方式执行不同的操作 (例如报错或者自动重试)
-
乐观锁在 Java 中是通过使用无锁编程来实现, 最常采用的是 CAS 算法, Java 原子类中的递增操作就通过 CAS 自旋实现的
-
-
公平锁与非公平锁
- 公平锁是指多个线程按照申请锁的顺序来获取锁, 线程直接进入队列中排队, 队列中的第一个线程才能获得锁, 公平锁的优点是等待锁的线程不会饿死, 缺点是整体吞吐效率相对非公平锁要低, 等待队列中除第一个线程以外的所有线程都会阻塞, CPU 唤醒阻塞线程的开销比非公平锁大
- 非公平锁是多个线程加锁时直接尝试获取锁, 获取不到才会到等待队列的队尾等待, 但如果此时锁刚好可用, 那么这个线程可以无需阻塞直接获取到锁, 所以非公平锁有可能出现后申请锁的线程先获取锁的场景, 非公平锁的优点是可以减少唤起线程的开销, 整体的吞吐效率高, 因为线程有几率不阻塞直接获得锁, CPU 不必唤醒所有线程, 缺点是处于等待队列中的线程可能会饿死, 或者等很久才会获得锁
-
独享锁与共享锁
- 独享锁也叫排他锁, 是指该锁一次只能被一个线程所持有, 如果线程 T 对数据 A 加上排它锁后, 则其他线程不能再对 A 加任何类型的锁, 获得排它锁的线程即能读数据又能修改数据, JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁
- 共享锁是指该锁可被多个线程所持有, 如果线程 T 对数据 A 加上共享锁后, 则其他线程只能对 A 再加共享锁, 不能加排它锁, 获得共享锁的线程只能读数据, 不能修改数据
- 独享锁与共享锁也是通过 AQS 来实现的, 通过实现不同的方法, 来实现独享或者共享
-
可重入锁与非可重入锁
- 可重入锁又名递归锁, 是指在同一个线程在外层方法获取锁的时候, 再进入该线程的内层方法会自动获取锁 (前提锁对象得是同一个对象或者 class), 不会因为之前已经获取过还没释放而阻塞, Java 中 ReentrantLock 和 synchronized 都是可重入锁, 可重入锁的优点是可以一定程度避免死锁
死锁
- 预防死锁
- 使用共享锁代替独占锁,使用乐观锁代替悲观锁,破坏互斥条件
- 剥夺控制法:尝试使用定时锁, 使用
lock. tryLock ( timeOut )
,当超时等待时当前线程不会堵塞, 破坏不可剥夺条件 - 资源顺序分配法: 线程获取锁的顺序要一致, 临界资源按顺序分配, 破坏了循环等待条件
- 一次封锁法:就是在方法的开始阶段。已经预先知道会用到哪些数据,然后所有锁住,在方法执行之后,再所有解锁,破坏了请求与保持条件
- 解决死锁
- 使用
jps -l
定位进程号 - 使用
jstack
进程号, 找到死锁问题并解决
- 使用

synchronize
- synchronize 可以用来修饰实例方法, 静态方法, 还有代码块, 主要有三种作用: 可以确保原子性, 可见性, 有序性
- synchronized 的底层原理是跟 monitor 有关, 也就是视图器锁, 每个对象都有一个关联的 monitor, 当 Synchronize 获得 monitor 对象的所有权后会进行两个指令: 加锁指令跟减锁指令
- monitor 里面有个计数器, 初始值是从 0 开始的, 如果一个线程想要获取 monitor 的所有权, 就查看它计数器是不是 0
- 如果是 0 的话, 那么就说明没人获取锁, 那么它就可以获取锁了, 然后将计数器+1, 也就是执行 monitorenter 加锁指令, monitorexit 减锁指令是跟在程序执行结束和异常里的
- 如果不是 0 的话, 就会陷入一个堵塞等待的过程, 直到为 0 等待结束
- synchronized 是独占锁, 同步块内的代码相当于同一时刻单线程执行, 故不存在原子性和指令重排序的问题
AQS
- AQS 核心思想是, 如果被请求的共享资源空闲, 那么就将当前请求资源的线程设置为有效的工作线程, 将共享资源设置为锁定状态, 如果共享资源被占用, 就需要一定的阻塞等待唤醒机制来保证锁分配, 这个机制主要用的是 CLH 队列的变体实现的, 将暂时获取不到锁的线程加入到队列中
- AQS 使用一个 Volatile 的 int 类型的成员变量来表示同步状态, 通过内置的 FIFO 队列来完成资源获取的排队工作, 通过 CAS 完成对 State 值的修改
- State 初始化的时候为 0, 表示没有任何线程持有锁
- 当有线程持有该锁时, 值就会在原来的基础上+1, 同一个线程多次获得锁是, 就会多次+1, 这里就是可重入的概念
- 解锁也是对这个字段-1, 一直到 0, 此线程对锁释放
- 加锁
- 通过 ReentrantLock 的加锁方法 Lock 进行加锁操作
- 会调用到内部类 Sync 的 Lock 方法, 由于 Sync #lock 是抽象方法, 根据 ReentrantLock 初始化选择的公平锁和非公平锁, 执行相关内部类的 Lock 方法, 本质上都会执行 AQS 的 Acquire 方法
- AQS 的 Acquire 方法会执行 tryAcquire 方法, 但是由于 tryAcquire 需要自定义同步器实现, 因此执行了 ReentrantLock 中的 tryAcquire 方法, 由于 ReentrantLock 是通过公平锁和非公平锁内部类实现的 tryAcquire 方法, 因此会根据锁类型不同, 执行不同的 tryAcquire
- tryAcquire 是获取锁逻辑, 获取失败后, 会执行框架 AQS 的后续逻辑, 跟 ReentrantLock 自定义同步器无关
- 解锁
- 通过 ReentrantLock 的解锁方法 Unlock 进行解锁
- Unlock 会调用内部类 Sync 的 Release 方法, 该方法继承于 AQS
- Release 中会调用 tryRelease 方法, tryRelease 需要自定义同步器实现, tryRelease 只在 ReentrantLock 中的 Sync 实现, 因此可以看出, 释放锁的过程, 并不区分是否为公平锁
- 释放成功后, 所有处理由 AQS 框架完成, 与自定义同步器无关
Q: 某个线程获取锁失败的后续流程是什么呢?
A: 存在某种排队等候机制, 线程继续等待, 仍然保留获取锁的可能, 获取锁流程仍在继续
Q: 既然说到了排队等候机制, 那么就一定会有某种队列形成, 这样的队列是什么数据结构呢?
A: 是 CLH 变体的 FIFO 双端队列
Q: 如果处于排队等候机制中的线程一直无法获取锁, 需要一直等待么?还是有别的策略来解决这一问题?
A: 线程所在节点的状态会变成取消状态, 取消状态的节点会从队列中释放
Q: Lock 函数通过 Acquire 方法进行加锁, 但是具体是如何加锁的呢?
A: AQS 的 Acquire 会调用 tryAcquire 方法, tryAcquire 由各个自定义同步器实现, 通过 tryAcquire 完成加锁过程
Lock
- Lock 是一个接口, 而 synchronized 是 Java 中的关键字, Lock 能完成 synchronized 所实现的所有功能
- synchronized 在发生异常时, 会自动释放线程占有的锁, 因此不会导致死锁现象发生, 而 Lock 在发生异常时, 如果没有主动通过
unLock ()
去释放锁, 则很可能造成死锁现象, 因此使用 Lock 时需要在 finally 块中释放锁 - Lock 是显式锁 (需要手动开启和关闭锁), synchronized 是隐式锁, 出了作用域自动释放
- Lock 只有代码块锁, synchronized 有代码块和方法锁
- Lock 可以知道是不是已经获取到锁, 而 synchronized 无法知道
- Lock 可以让等待锁的线程响应中断, 而 synchronized 却不行, 使用 synchronized 时, 等待的线程会一直等待下去, 不能够响应中断
ReentrantLock
ReentrantLock
可以替代synchronized
进行线程同步- 必须先获取到锁, 再进入
try {...}
代码块, 最后使用finally
保证释放锁 - 可以使用
tryLock ()
尝试获取锁
ReadWriteLock
- 使用
ReadWriteLock
可以提高读取效率, 读多写少的场景 ReadWriteLock
只允许一个线程写入, 允许多个线程在没有写入时同时读取
线程池
- 线程池顾名思义就是事先创建若干个可执行的线程放入一个池 (容器) 中, 需要的时候从池中获取线程不用自行创建, 使用完毕不需要销毁线程而是放回池中, 从而减少创建和销毁线程对象的开销
- 使用线程池可以降低资源消耗, 提高响应速度, 提高线程的可管理性, 提供更多更强大的功能
主要参数
-
线程池核心线程数大小
- CPU 密集型: 属于需要处理大量计算任务,此时不宜设置过多线程,因为会造成线程上下文切换,反而损失性能。设置为
CPU 核心数 + 1
为宜(在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下 CPU 周期不会中断工作) - IO 密集型: 属于业务中涉及频繁的磁盘 IO 与网络 IO,为了避免 CPU 空闲可以将连接数调大一些,设置为
2 * CPU 核心数
为宜
- CPU 密集型: 属于需要处理大量计算任务,此时不宜设置过多线程,因为会造成线程上下文切换,反而损失性能。设置为
-
最大线程数
-
空闲线程存活时长
-
时间单位
-
阻塞队列
-
线程工厂
-
拒绝策略
运行流程
- 当需要任务大于核心线程数时候, 就开始把任务往存储任务的队列里, 当存储队列满了的话, 就开始增加线程池创建的线程数量, 如果当线程数量也达到了最大, 就开始执行拒绝策略, 比如说记录日志, 直接丢弃, 或者丢弃最老的任务, 或者交给提交任务的线程执行
- 当一个线程完成时, 它会从队列中取下一个任务来执行, 当一个线程无事可做, 且超过一定的时间 (keepAliveTime) 时, 如果当前运行的线程数大于核心线程数, 那么这个线程会停掉了
线程池种类
Executors
: 工具类, 线程池的工厂类, 用于创建并返回不同类型的线程池, 本质上是调用 ThreadPoolExecutor 的构造方法- newFixedThreadPool 创建一个指定大小的线程池, 每当提交一个任务就创建一个线程, 如果工作线程数量达到线程池初始的最大数, 则将提交的任务存入到等待队列中
- newCachedThreadPool 创建一个可缓存的线程池, 这种类型的线程池特点是:
- 工作线程的创建数量几乎没有限制 (其实也有限制的, 数目为 Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程
- 如果长时间没有往线程池中提交任务, 即如果工作线程空闲了指定的时间 (默认为 1 分钟), 则该工作线程将自动终止, 终止后, 如果你又提交了新的任务, 则线程池重新创建一个工作线程
- newSingleThreadExecutor 创建一个单线程的 Executor, 即只创建唯一的工作者线程来执行任务, 如果这个线程异常结束, 会有另一个取代它, 保证顺序执行
- newScheduleThreadPool 创建一个定长的线程池, 而且支持定时的以及周期性的任务执行, 类似于 Timer
线程池的 submit 和 execute 的区别
execute 提交的方式
execute 提交的方式只能提交一个 Runnable 的对象, 且该方法的返回值是 void, 也即是提交后如果线程运行后, 和主线程就脱离了关系了, 当然可以设置一些变量来获取到线程的运行结果, 并且当线程的执行过程中抛出了异常通常来说主线程也无法获取到异常的信息的, 只有通过 ThreadFactory 主动设置线程的异常处理类才能感知到提交的线程中的异常信息
submit 提交的方式
- submit 提交的方式有如下三种情况
1 |
|
- 这种提交的方式是提交一个实现了 Callable 接口的对象, 这种提交的方式会返回一个 Future 对象, 这个 Future 对象代表这线程的执行结果
- 当主线程调用 Future 的 get 方法的时候会获取到从线程中返回的结果数据
- 如果在线程的执行过程中发生了异常, get 会获取到异常的信息
1 |
|
- 也可以提交一个 Runable 接口的对象, 这样当调用 get 方法的时候, 如果线程执行成功会直接返回 null, 如果线程执行异常会返回异常的信息
1 |
|
- 这种方式除了 task 之外还有一个 result 对象, 当线程正常结束的时候调用 Future 的 get 方法会返回 result 对象, 当线程抛出异常的时候会获取到对应的异常的信息
ThreadLocal
- ThreadLocal 的作用是提供线程内的局部变量, 这种变量在线程的生命周期内起作用
- 因为一个线程内可以存在多个 ThreadLocal 对象, 所以其实是 ThreadLocal 内部维护了一个 Map, 这个 Map 的 key 是 ThreadLocal 类的实例对象, value 为用户的值, ThreadLocalMap 是 Thread 的成员变量
- 内存泄漏问题
- 实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用, 当 ThreadLocalMap 移除该 ThreadLocal 实例时, 在下一次垃圾回收的时候会被自动清理掉, 但是 value 是强引用, 不会被清理, 这样一来就会出现 key 为 null 的 value
- ThreadLocalMap 实现中已经考虑了这种情况, 在调用 set (), get (), remove () 方法的时候, 会清理掉 key 为 null 的记录, 如果说会出现内存泄漏, 那只有在出现了 key 为 null 的记录后, 没有手动调用 remove () 方法, 并且之后也不再调用 get (), set (), remove ()
- 尽量在代码中使用 finally 块进行回收
多线程循环打印 ABC
- 3 个线程 A, B, C 分别打印三个字母, 每个线程循环 10 次, 首先同步, 如果不满足打印条件, 则调用 wait () 函数一直等待, 之后打印字母, 更新 state, 调用
notifyAll ()
, 进入下一次循环
1 |
|
如何实现主线程等待子线程执行完后再继续执行?
- 可以使用
join ()
方法, 在主线程内部调用子线程join ()
方法 - CountDownLatch 实现
await ()
方法阻塞当前线程, 直到计数器等于 0countDown ()
方法将计数器减一
JVM
JVM 内存结构
- Java 虚拟机的内存空间分为 5 个部分:
- 程序计数器
- Java 虚拟机栈: Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做"栈帧”的区域, 用于存放该方法运行过程中的一些信息, 如:
- 局部变量表
- 存放基本变量类型 (会包含这个基本类型的基本数值)
- 引用对象的变量 (会存放这个引用在堆里面的具体地址)
- 操作数栈
- 动态链接
- 方法出口信息
- 局部变量表
- 本地方法栈
- 堆
- 存放 new 的对象和数组
- 可以被所有的线程共享, 不会存放别的对象引用
- 方法区: 方法区逻辑上属于堆的一部分, 但是为了与堆进行区分, 通常又叫"非堆”
- 已经被虚拟机加载的类信息
- 常量
- 静态变量
- 即时编译器编译后的代码

注意: JDK 1.8 同 JDK 1.7 比, 最大的差别就是: 元空间 (元数据区) 取代了永久代, 元空间的本质和永久代类似, 都是对 JVM 规范中方法区的实现, 不过元空间与永久代之间最大的区别在于: 元空间并不在虚拟机中, 而是使用本地内存
类的加载与实例化
- 加载: 查找和导入 Class 文件
- 校验: 检查载入 Class 文件数据的正确性
- 准备: 给类的静态变量分配存储空间
- 解析: 将符号引用转成直接引用
- 初始化: 执行类构造器
<clinit>()
方法, 对类的静态变量, 静态代码块执行初始化操作
类初始化的时机
- 类的主动引用(一定会发生类的初始化)
- 当虚拟机启动, 先初始化 main 方法所在的类
- new 一个类的对象
- 调用类的静态成员 (除 final 常量) 和静态方法
- 使用
java. lang. reflect
包的方法对类进行反射调用- 当初始化一个类, 如果其父类没有被初始化, 则会先初始化它的父类
- 类的被动引用(不会发生类的初始化)
- 当访问一个静态域时, 只有真正声明这个域的类才会被初始化, 如: 当通过子类引用父类的静态变量, 不会导致子类初始化
- 通过数组定义类引用, 不会触发此类的初始化
- 引用常量不会触发此类的初始化 (常量在链接阶段就存入调用类的常量池中)
类加载器
- 类的加载是由类加载器完成的, 类加载器包括: 根加载器 (BootStrap), 扩展加载器 (Extension), 系统加载器 (System) 和用户自定义类加载器 (java. lang. ClassLoader 的子类)
- 类加载过程采取了双亲委托机制, 更好的保证了 Java 平台的安全性, 在该机制中, JVM 自带的 Bootstrap 是根加载器, 其他的加载器都有且仅有一个父类加载器, 类的加载首先请求父类加载器加载, 父类加载器无能为力时才由其子类加载器自行加载, JVM 不会向 Java 程序提供对 Bootstrap 的引用
说明:
Bootstrap: 一般用本地代码实现, 负责加载 JVM 基础核心类库 (rt. jar)
Extension: 从 java. ext. dirs 系统属性所指定的目录中加载类库, 它的父加载器是 Bootstrap
System: 又叫应用类加载器, 其父类是 Extension, 它是应用最广泛的类加载器, 它从环境变量 classpath 或者系统属性 java. class. path 所指定的目录中记载类, 是用户自定义加载器的默认父加载器
类的实例化过程
-
类加载检查:虚拟机在解析
. class
文件时, 若遇到一条 new 指令, 首先它会去检查常量池中是否有这个类的符号引用, 并且检查这个符号引用所代表的类是否已被加载, 解析和初始化过, 如果没有, 那么必须先执行相应的类加载过程 -
为新生对象分配内存:对象所需内存的大小在类加载完成后便可完全确定, 接下来从堆中划分一块对应大小的内存空间给新的对象, 分配堆中内存有两种方式:
- 指针碰撞
如果 Java 堆中内存绝对规整(说明采用的是标记-复制算法或标记整理法), 空闲内存和已使用内存中间放着一个指针作为分界点指示器, 那么分配内存时只需要把指针向空闲内存挪动一段与对象大小一样的距离, 这种分配方式称为"指针碰撞” - 空闲列表
如果 Java 堆中内存并不规整, 已使用的内存和空闲内存交错 (说明采用的是标记-清除法, 有碎片), 此时没法简单进行指针碰撞, VM 必须维护一个列表, 记录其中哪些内存块空闲可用, 分配之时从空闲列表中找到一块足够大的内存空间划分给对象实例, 这种方式称为"空闲列表”
- 指针碰撞
-
初始化:分配完内存后, 为对象中的成员变量赋上初始值, 设置对象头信息, 调用对象的构造函数方法进行初始化
GC
- 垃圾回收可以有效的防止内存泄露, 有效的使用可以使用的内存, 垃圾回收器通常是作为一个单独的低优先级的线程运行, 不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收, 程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收
GC 算法
- 标记-清除算法: 遍历
GC Roots
, 然后将所有GC Roots
可达的对象标记, 将没有标记的对象全部清除掉 - 标记-复制算法: 它将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块, 当这一块内存用完, 需要进行垃圾收集时, 就将存活者的对象复制到另一块上面, 然后将第一块内存全部清除
- 标记-整理算法: 遍历
GC Roots
, 然后将存活的对象标记, 移动所有存活的对象, 且按照内存地址次序依次排列, 然后将末端内存地址以后的内存全部回收
堆的内存分配与 GC
- 堆分为两部分:年轻代与老年代,其中年轻代有三个区域: 一个 Eden 区和两个 Survivor 区,其大小比例为8:1:1
- 具体过程是这样的:
- 一个对象实例化时, 先去看 Eden 区有没有足够的空间
- 如果有, 不进行垃圾回收, 对象直接在 Eden 区存储
- 如果 Eden 区内存已满, 会进行一次 minor gc
- 然后再进行判断 Eden 区中的内存是否足够
- 如果不足, 则去看 Survivor 区的内存是否足够
- 如果内存足够, 把 Eden 区部分活跃对象保存在 Survivor 区, 然后把对象保存在 Eden 区
- 如果内存不足, 查询老年代的内存是否足够
- 如果老年代内存足够, 将部分 Survivor 区的活跃对象存入老年代, 然后把 Eden 区的活跃对象放入 Survivor 区, 对象依旧保存在 Eden 区
- 如果老年代内存不足, 会进行一次 Full GC, 之后老年代会再进行判断内存是否足够, 如果足够还是那些步骤
- 如果不足, 会抛出 OutOfMemoryError (内存溢出异常)
- 年轻代 2 个 Survivor 区的好处:解决了内存碎片化问题, 整个过程中, 永远有一个 Survivor 区是空的, 另一个非空的 Survivor 区是无碎片的
- 什么时候对象从年轻代转移到老年代
- Eden 区满时, 进行 Minor GC 时
- 如果新创建的对象占用内存很大, 则直接分配到老年代
- 虚拟机对每个对象定义了一个对象年龄 (Age) 计数器, 当年龄增加到一定的临界值时, 就会晋升到老年代中
- 如果在 Survivor 区中相同年龄的对象的所有大小之和超过 Survivor 空间的一半, 包括比这个年龄大的对象就都可以直接进入老年代
Full GC
- System.gc () 方法的调用: 此方法的调用是建议 JVM 进行 Full GC, 虽然只是建议而非一定, 但很多情况下它会触发 Full GC, 从而增加 Full GC 的频率, 也即增加了间歇性停顿的次数, 强烈影响系建议能不使用此方法就别使用, 让虚拟机自己去管理它的内存, 可通过通过
-XX:+ DisableExplicitGC
来禁止 RMI 调用 System. gc - 老年代空间不足: 老年代空间只有在年轻代对象转入以及创建为大对象, 大数组时才会出现不足的现象, 抛出
java. lang. OutOfMemoryError: Java heap space
, 为避免以上两种状况引起的 Full GC, 调优时应尽量做到让对象在 Minor GC 阶段被回收, 让对象在年轻代多存活一段时间及不要创建过大的对象及数组 - 方法区空间不足: Permanet Generation 中存放的为一些 class 的信息, 常量, 静态变量等数据, 当系统中要加载的类, 反射的类和调用的方法较多时, Permanet Generation 可能会被占满, 在未配置为采用 CMS GC 的情况下也会执行 Full GC, 如果经过 Full GC 仍然回收不了, 那么 JVM 会抛出如下错误信息:
java. lang. OutOfMemoryError: PermGen space
, 为避免 Perm Gen 占满造成 Full GC 现象, 可采用的方法为增大 Perm Gen 空间或转为使用 CMS GC
标记算法
- 若一个对象不被任何对象或变量引用, 那么它就是无效对象, 需要被回收
引用计数法
- 在对象头维护着一个 counter 计数器, 对象被引用一次则计数器 +1, 若引用失效则计数器 -1, 当计数器为 0 时, 就认为该对象无效了
- 引用计数算法的实现简单, 判定效率也很高, 在大部分情况下它都是一个不错的算法, 但是主流的 Java 虚拟机里没有选用引用计数算法来管理内存, 主要是因为它很难解决对象之间循环引用的问题
可达性分析法
- 所有和 GC Roots 直接或间接关联的对象都是有效对象, 和 GC Roots 没有关联的对象就是无效对象
- GC Roots 是指:
- Java 虚拟机栈 (栈帧中的本地变量表) 中引用的对象
- 本地方法栈中引用的对象
- 方法区中常量引用的对象
- 方法区中类静态属性引用的对象
- Synchronized 锁引用的对象
- GC Roots 并不包括堆中对象所引用的对象, 这样就不会有循环引用的问题
三色标记算法
- 要找出存活对象,根据可达性分析,从 GC Roots 开始进行遍历访问,可达的则为存活对象,我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色
- 白色:本对象没有被访问过 (没有被 GCRoot 扫描过,有可能是为垃圾对象)
- 灰色:本对象已经被访问过(被 GCRoot 扫描过),且本对象中的属性没有被 GCRoot 扫描,该对象就是为灰色对象,如果该对象的属性被扫描的情况下,从灰色变为黑色
- 黑色:本对象已经被访问过(被 GCRoot 扫描过),且本对象中的属性已经被 GCRoot 扫描过,该对象就是为黑色对象。
- 由于标记是在并发环境下进行的,无法一次遍历所有节点,所以引入灰色节点代表下一次遍历的开始节点,而黑色节点不再遍历
- 标记过程
- 在初始阶段的时候,所有的对象都是存放在白色容器中。
- 初始标记阶段,GCRoot 标记直接关联对象置为灰色
- 并发标记阶段,从灰色容器中获取对象,扫描整个引用链,有子节点的话,则当前节点变为黑色,所有子节点变为灰色
- 在白色盒子剩下的对象都是为没有被 GCRoot 关联的对象,可能会被垃圾回收机制清理。
- 下次 GCRoot 起点从灰色节点开始计算
- 三色标记算法缺陷:在并发标记阶段的时候,因为用户线程与 GC 线程同时运行,有可能会产生多标记(浮动垃圾)或漏标记
- 浮动垃圾
- 并发标记阶段用户与 GC 线程同时运行,假设已经遍历到 A(变为灰色了),此时应用执行了
A.B = null
,此刻之后,对象 C 是应该被回收的。然而因为已经变为灰色了,其仍会被当作存活对象继续遍历下去。 - 针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分
- 并发标记阶段用户与 GC 线程同时运行,假设已经遍历到 A(变为灰色了),此时应用执行了
- 漏标问题
- 用户线程先执行
B.C=null
,GC 线程的 GcRoot 就扫描不到 C。GC 就认为 C 对象就是为垃圾对象,不可达对象 - 用户线程然后执行
A.C=C
, 此时 C 对象就是应该为可达对象。因为 GCRoot 是从灰色节点 B 开始,不会从黑色的 A 开始,就会导致漏标的情况发生。 - 漏标只有同时满足以下两个条件时才会发生
- 灰色对象断开了白色对象的引用(直接或间接的引用),即灰色对象原来成员变量的引用发生了变化
- 黑色对象重新引用了该白色对象,即黑色对象成员变量增加了新的引用
- CMS 解决漏标问题—写屏障+增量更新方式
- 当满足条件(灰色对象与白色对象断开连接),在并发标记阶段当我们黑色对象(A)引用关联白色对象(C),记录下黑色对象,即写屏障
- 在重新标记阶段(所有用户线程暂停),重新扫描被记录的黑色对象,增量更新
- 缺点:遍历黑色节点整个链的效率非常低,有可能会导致用户线程等待的时间非常长
- 写屏障:其实就是指在赋值操作前后,加入一些处理(可以参考 AOP 的概念)
- G 1 解决漏标问题—原始快照方式
- 在灰色对象断开白色对象的时候,会记录原始快照,在重新标记阶段的时候以白色对象变为灰色为起始点扫描整个链,本次 GC 是不会被清理。
- 优点:如果假设黑色对象引入该白色对象的时候,无需做任何遍历效率是非常高。
- 缺点:如果假设黑色对象没有引入该白色对象的时候,该白色对象在本次 GC 继续存活,只能放在下一次 GC 在做并发标记的时候清理。
- 用户线程先执行
- 浮动垃圾
引用类型
- 强引用: 类似
Object obj = new Object ()
这类的引用, 就是强引用 - 软引用: 软引用是一种相对强引用弱化一些的引用, 可以让对象豁免一些垃圾收集, 只有当 JVM 认为内存不足时, 才会去试图回收软引用指向的对象, JVM 会确保在抛出
OutOfMemoryError
之前, 清理软引用指向的对象, 软引用通常用来实现内存敏感的缓存, 如果还有空闲内存, 就可以暂时保留缓存, 当内存不足时清理掉, 这样就保证了使用缓存的同时, 不会耗尽内存 - 弱引用: 弱引用的强度比软引用更弱一些, 当 JVM 进行垃圾回收时,无论内存是否充足, 都会回收只被弱引用关联的对象
- 虚引用: 虚引用也称幽灵引用或者幻影引用, 它是最弱的一种引用关系, 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 它仅仅是提供了一种确保对象被 finalize 以后, 做某些事情的机制, 比如, 通常用来做所谓的 Post-Mortem 清理机制
垃圾收集器
- CMS 垃圾收集器:以获取最短回收停顿时间为目标的收集器 (追求低停顿), 它在垃圾收集时使得用户线程和 GC 线程并发执行, 因此在垃圾收集过程中用户也不会感到明显的卡顿
- Stop The World 之前需要找到安全点,即所有对象的引用不会改变的时间点
- 初始标记: Stop The World, 仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记
- 并发标记: 使用多条标记线程, 与用户线程并发执行, 此过程进行可达性分析, 标记出所有废弃对象, 速度很慢
- 重新标记: Stop The World, 使用多条标记线程并发执行, 将刚才并发标记过程中新出现的废弃对象标记出来
- 并发清除: 只使用一条 GC 线程, 与用户线程并发执行, 清除刚才标记的对象, 这个过程非常耗时
- G 1 通用垃圾收集器:一款面向服务端应用的垃圾收集器, 它没有年轻代和老年代的概念, 而是将堆划分为一块块独立的 Region, 当要进行垃圾收集时, 首先估计每个 Region 中垃圾的数量, 每次都从垃圾回收价值最大的 Region 开始回收, 因此可以获得最大的回收效率
- 从整体上看, G 1 是基于"标记-整理”算法实现的收集器, 从局部 (两个 Region 之间) 上看是基于"标记-复制”算法实现的, 这意味着运行期间不会产生内存空间碎片
- 可以非常精确控制停顿时间, 在不牺牲吞吐量前提下, 实现低停顿垃圾回收
- 如果不计算维护 Remembered Set 的操作, G 1 收集器的工作过程分为以下几个步骤:
- 初始标记: Stop The World, 仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记
- 并发标记: 使用一条标记线程与用户线程并发执行, 此过程进行可达性分析, 速度很慢
- 最终标记: Stop The World, 使用多条标记线程并发执行
- 筛选回收: 回收废弃对象, 此时也要 Stop The World, 并使用多条筛选回收线程并发执行
- STW:由于程序一直在跑的话,可能会一直增加新的对象,导致永远都标记不完。当 Stop The World (以下简称 STW)时,对象间的引用是不会发生变化的,可以轻松完成标记。
OOM 问题
- 最常见的 OOM 情况有以下三种:
java. lang. OutOfMemoryError: Java heap space
: Java 堆内存溢出, 此种情况最常见, 一般由于内存泄露或者堆的大小设置不当引起, 对于内存泄露, 需要通过内存监控软件查找程序中的泄露代码, 而堆大小可以通过虚拟机参数-Xms
,-Xmx
等修改java. lang. OutOfMemoryError: PermGen space
: Java 永久代溢出, 即方法区溢出了, 一般出现于大量 Class 或者 jsp 页面, 或者采用 cglib 等反射机制的情况, 因为上述情况会产生大量的 Class 信息存储于方法区, 此种情况可以通过更改方法区的大小来解决, 使用类似-XX:PermSize=64 m -XX:MaxPermSize=256 m
的形式修改, 另外, 过多的常量尤其是字符串也会导致方法区溢出
- OOM 问题解决
- 查看报错日志
- 获取 dump 堆的内存镜像
- 设置 JVM 参数
-XX:+HeapDumpOnOutOfMemoryError
, 设定当发生 OOM 时自动 dump 出堆信息, 不过该方法需要 JDK 5 以上版本 - 使用 JDK 自带的 jmap 命令,
jmap -dump:format=b, file=heap. bin <pid>
其中 pid 可以通过 jps 获取
- 设置 JVM 参数
- 得到 dump 堆内存信息后, 需要对 dump 出的文件进行分析, 从而找到 OOM 的原因
- 将 heapdump 文件导入 VisualVM 中
- 可以查看每个类在堆中数量,寻找对象数和占用空间异常的类
- 通过 GCRoot 找到异常类的调用源头,通过定位线程找到对应的业务代码
- 修改业务代码
内存泄漏
- JVM 是使用引用计数法和可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用, 那么对于这种情况下, 由于代码的实现不同就会出现很多种内存泄漏问题 (让 JVM 误以为此对象还在引用中, 无法回收, 造成内存泄漏)
- 静态集合类, 如 HashMap, LinkedList 等等, 如果这些容器为静态的, 那么它们的生命周期与程序一致, 则容器中的对象在程序结束之前将不能被释放, 从而造成内存泄漏, 简单而言, 长生命周期的对象持有短生命周期对象的引用, 尽管短生命周期的对象不再使用, 但是因为长生命周期对象持有它的引用而导致不能被回收
- 各种连接, 如数据库连接, 网络连接和 IO 连接等, 在对数据库进行操作的过程中, 首先需要建立与数据库的连接, 当不再使用时, 需要调用 close 方法来释放与数据库的连接, 只有连接被关闭后, 垃圾回收器才会回收对应的对象, 否则, 如果在访问数据库的过程中, 对 Connection, Statement 或 ResultSet 不显性地关闭, 将会造成大量的对象无法被回收, 从而引起内存泄漏
- 变量不合理的作用域, 一般而言, 一个变量的定义的作用范围大于其使用范围, 很有可能会造成内存泄漏, 另一方面, 如果没有及时地把对象设置为 null, 很有可能导致内存泄漏的发生
1 |
|
- 如上面这个伪代码, 通过 readFromNet 方法把接受的消息保存在变量 msg 中, 然后调用 saveDB 方法把 msg 的内容保存到数据库中, 此时 msg 已经就没用了, 由于 msg 的生命周期与对象的生命周期相同, 此时 msg 还不能回收, 因此造成了内存泄漏, 实际上这个 msg 变量可以放在 receiveMsg 方法内部, 当方法使用完, 那么 msg 的生命周期也就结束, 此时就可以回收了, 还有一种方法, 在使用完 msg 后, 把 msg 设置为 null, 这样垃圾回收器也会回收 msg 的内存空间
- 内部类持有外部类, 如果一个外部类的实例对象的方法返回了一个内部类的实例对象, 这个内部类对象被长期引用了, 即使那个外部类实例对象不再被使用, 但由于内部类持有外部类的实例对象, 这个外部类对象将不会被垃圾回收, 这也会造成内存泄露
- 改变哈希值, 当一个对象被存储进 HashSet 集合中以后, 就不能修改这个对象中的那些参与计算哈希值的字段了, 否则, 对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了, 在这种情况下, 即使在 contains 方法使用该对象的当前引用作为的参数去 HashSet 集合中检索对象, 也将返回找不到对象的结果, 这也会导致无法从 HashSet 集合中单独删除当前对象, 造成内存泄漏
静态变量什么时候会被回收
- 静态变量是在类被 load 的时候分配内存的, 并且存在于方法区, 当类被卸载的时候, 静态变量被销毁
- 类在什么时候被卸载, 在进程结束的时候, 一般情况下, 所有的类都是默认的 ClassLoader 加载的, 只要 ClassLoader 存在, 类就不会被卸载, 而默认的 ClassLoader 生命周期是与进程一致的
JVM 参数
-Xms/-Xmx
: 堆的初始大小 / 堆的最大大小- Xms 与 Xmx 设置相同大小可以减少内存交换
- 评估 Xmx 方法:第一次起始设置大一些,跟踪监控日志,调整为堆峰值的 2~3 倍即可
-XX:+UseG 1 GC
: 使用 G 1 收集器-XX:MaxGCPauseMillis
: 设置最大 STW 时间,200~500 区间,增大可以减少 GC 次数,提高吞吐-Xss
: 设置虚拟机栈空间,一般 128 K 就够用了,超过 256 K 考虑优化-Xmn
: 堆中年轻代的大小-XX: NewSize/-XX:MaxNewSize
: 设置年轻代大小/年轻代最大大小-XX:NewRatio
: 可以设置老生代和年轻代的比例-XX:+PrintGCDetails
: 打印 GC 的细节-XX: InitialTenuringThreshold/-XX:MaxTenuringThreshold
: 设置老年代阀值的初始值和最大值
MySQL
数据库建表三大范式
- 原子性:要求属性具有原子性, 不可再分解
- 唯一性:要求记录有唯一标识, 即实体的唯一性, 即不存在部分依赖
- 消除冗余性:要求任何字段不能由其他字段派生出来, 它要求字段没有冗余, 即不存在传递依赖
存储引擎
- InnoDB 支持事务, MyISAM 不支持
- InnoDB 支持行级锁而 MyISAM 仅仅支持表锁, 但是 InnoDB 可能出现死锁
- InnoDB 的关注点在于: 并发写, 事务, 更大资源, 而 MyISAM 的关注点在于: 节省资源, 消耗少, 简单业务
- 在 MySQL 5.7 的时候, 默认就是 InnoDB 作为默认的存储引擎了
事务
- 事务就是将一组 SQL 语句放在同一批次内去执行, 如果一个 SQL 语句出错, 则该批次内的所有 SQL 都将被取消执行
ACID
- 原子性 (Atomicity): 整个事务中的所有操作, 要么全部完成, 要么全部不完成, 不可能停滞在中间某个环节, 事务在执行过程中发生错误, 会被回滚 (ROLLBACK) 到事务开始前的状态, 就像这个事务从来没有执行过一样
- 一致性 (Consistency): 一个事务可以封装状态改变 (除非它是一个只读的), 事务必须始终保持系统处于一致的状态, 不管在任何给定的时间并发事务有多少, 也就是说: 如果事务是并发多个, 系统也必须如同串行事务一样操作
- 隔离性 (Isolation): 隔离状态执行事务, 使它们好像是系统在给定时间内执行的唯一操作, 如果有两个事务, 运行在相同的时间内, 执行相同的功能, 事务的隔离性将确保每一事务在系统中认为只有该事务在使用系统, 这种属性有时称为串行化, 为了防止事务操作间的混淆, 必须串行化或序列化请求, 使得在同一时间仅有一个请求用于同一数据
- 持久性 (Durability): 在事务完成以后, 该事务对数据库所作的更改便持久的保存在数据库之中, 并不会被回滚
隔离级别
-
并发下遇到的问题
- 脏读 (Dirty Read): A 事务读取 B 事务尚未提交的数据并在此基础上操作, 而 B 事务执行回滚, 那么 A 读取到的数据就是脏数据
- 不可重复读 (Unrepeatable Read): 事务 A 重新读取前面读取过的数据, 发现该数据已经被另一个已提交的事务 B 修改过了
- 幻读 (Phantom Read): 事务 A 重新执行一个查询, 返回一系列符合查询条件的行, 发现其中插入了被事务 B 提交的行
- 第 1 类丢失更新: 事务 A 撤销时, 把已经提交的事务 B 的更新数据覆盖了
- 第 2 类丢失更新: 事务 A 覆盖事务 B 已经提交的数据, 造成事务 B 所做的操作丢失
- 不可重复读的和幻读很容易混淆, 不可重复读重点在于 update 和 delete, 而幻读的重点在于 insert, 解决不可重复读的问题只需锁住满足条件的行, 解决幻读需要锁表
-
隔离级别: 数据库为了维护事务的隔离性, 数据库通常会通过锁机制来解决数据并发访问问题, 直接使用锁是非常麻烦的, 为此数据库为用户提供了自动锁机制, 只要用户指定会话的事务隔离级别, 数据库就会通过分析 SQL 语句然后为事务访问的资源加上合适的锁
隔离级别 脏读 不可重复读 幻读 第一类丢失更新 第二类丢失更新 READ UNCOMMITED 允许 允许 允许 不允许 允许 READ COMMITTED 不允许 允许 允许 不允许 允许 REPEATABLE READ 不允许 不允许 允许 不允许 不允许 SERIALIZABLE 不允许 不允许 不允许 不允许 不允许 - 需要说明的是, 事务隔离级别和数据访问的并发性是对立的, 事务隔离级别越高并发性就越差, 所以要根据具体的应用来确定合适的事务隔离级别
-
隔离级别与锁的关系
- 未提交读 (Read Uncommitted): 事务对当前被读取的数据不加锁;事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级共享锁,直到事务结束才释放。
- 提交读 (Read Committed): 事务对当前被读取的数据加行级共享锁(当读到时才加锁),一旦读完该行,立即释放该行级共享锁;事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。
- 可重复读 (Repeatable Read): 事务在读取某数据的瞬间(就是开始读取的瞬间),必须先对其加行级共享锁,直到事务结束才释放;事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。
- MYSQL:事务对当前被读取的数据不加锁,且是快照读;事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁(Record,GAP,Next-Key),直到事务结束才释放。
- 通过间隙锁,在这个级别 MySQL 解决了幻读的问题
- 通过快照读,在这个级别 MySQL 解决了不可重复读的问题
- 可串行化 (Serializable): 事务在读取数据时,必须先对其加表级共享锁,直到事务结束才释放;事务在更新数据时,必须先对其加表级排他锁,直到事务结束才释放。
编程式事务
- Connection 提供了事务处理的方法, 通过调用
setAutoCommit (false)
可以设置手动提交事务, 当事务完成后用commit ()
显式提交事务如果在事务处理过程中发生异常则通过rollback ()
进行事务回滚 - 除此之外, 从 JDBC 3.0 中还引入了 Savepoint (保存点) 的概念, 允许通过代码设置保存点并让事务回滚到指定的保存点
锁
-
按照锁的使用方式可分为:共享锁、排它锁、意向共享锁、意向排他锁
- 共享锁/读锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。(其他事务可以读但不能写该数据集)
- 排他锁/写锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。 (其他事务不能读和写该数据集)
- 意向共享锁(IS):通知数据库接下来需要施加什么锁并对表加锁。如果需要对记录 A 加共享锁,那么此时 innodb 会先找到这张表,对该表加意向共享锁之后,再对记录 A 添加共享锁。
- 意向排他锁(IX):通知数据库接下来需要施加什么锁并对表加锁。如果需要对记录 A 加排他锁,那么此时 innodb 会先找到这张表,对该表加意向排他锁之后,再对记录 A 添加排他锁。
-
按照锁的粒度可分为:行锁、页锁(间隙锁)、表锁
- 表级锁: 开锁小, 加锁快, 不会出现死锁, 锁的粒度大, 发生锁冲突的概率最高, 并发量最低
- 行级锁: 开销大, 加锁慢, 会出现死锁, 锁的粒度小, 容易发生冲突的概率小, 并发度最高
- 行锁直接加在索引记录上面, 无索引项时演变成表锁 (因为如果一个条件无法通过索引快速过滤, 存储引擎层面就会将所有记录加锁后返回, 再由 MySQL Server 层进行过滤)
- 间隙锁(Gap Lock): 锁定索引记录间隙, 确保索引记录的间隙不变, 在无索引的情况下是锁全表,间隙锁是针对事务隔离级别为可重复读或以上级别的
- Next-Key Lock: 行锁和间隙锁组合起来就是 Next-Key Lock
-
意向共享锁和意向排它锁是数据库主动加的,不需要我们手动处理
-
对于 UPDATE、DELETE 和 INSERT 语句,InnoDB 会自动给涉及数据集加排他锁(X);对于普通 SELECT 语句,InnoDB 不会加任何锁,事务可以通过以下语句显示给记录集加共享锁或排他锁。
- 共享锁(S):
SELECT * FROM table_name WHERE … LOCK IN SHARE MODE
- 排他锁(X):
SELECT * FROM table_name WHERE … FOR UPDATE
- 共享锁(S):
MVCC
-
通过加锁控制,可以保证数据的一致性,但是同样一条数据,不论用什么样的锁,只可以并发读,并不可以读写并发(因为写的时候加的是排他锁所以不可以读),这时就要引入数据多版本控制来实现读写并发。
-
MVCC 实现可重复读下的快照读的幻读问题,而当前读的幻读问题是由间隙锁实现的
-
在 InnoDB 中, 会在每行数据后添加两个额外的隐藏的值来实现 MVCC
DB_ROW_ID
: 包含一个随着新行插入而单调递增的行 ID, 当由 innodb 自动产生聚集索引时, 聚集索引会包括这个行 ID 的值, 否则这个行 ID 不会出现在任何索引中DB_TRX_ID
: 用来标识最近一次对本行记录做修改的事务 IDDB_ROLL_PTR
: 指向写入回滚段 (rollback segment) 的 undo log record,如果一行记录被更新, 则 undo log record 包含该行记录被更新之前内容
-
undoLog:undoLog 分为两种。
- 一种是 insertUndoLog,此日志表示事务对 insert 新记录产成时需要的日志记录,主要是发生回滚时需要,事务添加成功提交后即可丢弃。
- 另一种是 updateUndoLog,事务对数据 delete 或者 update 时所产生的 undolog,不仅在回滚时需要,快照读时也需要,所以不能随便删除。只有当数据库中的快照读不涉及该日志记录,该回滚记录才会被线程删除。
-
MVCC 通过 undoLog 实现了数据的多个版本,每个版本之间都通过 DB_ROLL_PRT 进行连接,在进行快照读的时候会生成 ReadView 对象(RR 下会复用同一个 ReadView),根据规则构建出满足当前隔离级别的一致性视图
-
如果在两次快照读中间穿插当前读,会导致 ReadView 重新生成,从而无法避免幻读问题
-
在 RR 事务隔离级别下:
- SELECT:读取创建版本号 <=当前事务版本号,删除版本号为空,或者是删除版本号大于当前事务版本号的的数据
- InnoDB 只查找创建版本小于当前事务版本号的数据行,这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
- 行的删除版本要么未定义,要么大于当前事务版本号,这可以确保事务读取到的行在事务开始之前未被删除。
- INSERT:保存当前事务的版本号为创建版本号。
- UPDATE:插入一条新的记录,保存当前的版本号为创建版本号,同时当前版本号保存为原来数据的删除版本号。
- DELETE:保存当前版本号为删除版本号。
- SELECT:读取创建版本号 <=当前事务版本号,删除版本号为空,或者是删除版本号大于当前事务版本号的的数据
当前读与快照读
- 通过 MVCC 机制, 虽然让数据变得可重复读, 但我们读到的数据可能是历史数据, 是不及时的数据, 不是数据库当前的数据! 这在一些对于数据的时效特别敏感的业务中, 就很可能出问题
- 对于这种读取历史数据的方式, 我们叫它快照读 (snapshot read), 而读取数据库当前版本数据的方式, 叫当前读 (current read), 很显然, 在 MVCC 中:
- 快照读: 就是 select
- select * from table….;
- 当前读: 特殊的读操作, 插入/更新/删除操作, 属于当前读, 处理的都是当前的数据, 需要加锁
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update ;
delete;
数据库优化
- 选取最适用的字段属性
- 使用连接查询代替子查询
- 选择表合适存储引擎
- 对查询进行优化, 应尽量避免全表扫描, 首先应考虑在 WHERE 及 ORDER BY 涉及的列上建立索引
- 避免索引失效
- 使用反范式表避免联表查询效率低的问题
- 使用分表策略
索引
- 在 MySQL 中主要有四类索引: 主键索引, 唯一索引, 常规索引, 和全文索引
- 主键索引: 唯一标识数据库表中的每条记录, 每个表都应该有一个主键, 并且每个表只能有一个主键
- 唯一索引: 不允许出现相同的值, 避免同一个表中某数据列中的值重复
- 普通索引: 快速定位特定数据, 不会去约束索引的字段的行为
- 全文索引: 快速定位特定数据, 全文搜索通过
MATCH ()
函数完成 - 联合索引: 两个或更多个列上的索引被称作联合索引, 利用索引中的附加列, 可以缩小搜索的范围, 但使用一个具有两列的索引不同于使用两个单独的索引
- 聚簇索引和非聚簇索引: 索引的存储顺序和数据的存储顺序是否是关系的, 有关就是聚簇索引, 无关就是非聚簇索引
- 聚簇索引: Innodb 的主键索引, 非叶子节点存储的是索引指针, 叶子节点存储的是既有索引也有数据
- 非聚簇索引: MyISAM 的默认索引, B+Tree 的叶子节点存储的是数据存放的地址, 而不是具体的数据, 因此, 索引存储顺序和数据存储关系毫无关联, 另外 Inndob 里的辅助索引也是非聚簇索引
- 辅助索引与覆盖索引
- 辅助索引: 如果不是主键索引, 就称为辅助索引或者二级索引, 主键索引的叶子节点存储了完整的数据行, 而非主键索引的叶子节点存储的则是主键索引值, 通过非主键索引查询数据时, 会先查找到主键索引, 然后再到主键索引上去查找对应的数据
- 覆盖索引: 如果需要查询的字段被包含在辅助索引节点中, 那么可以直接获得我们所需要的信息, 按照这种思想 Innodb 针对使用辅助索引的查询场景做了优化, 称为覆盖索引
索引失效
- 最左前缀匹配原则
- 在 MySQL 建立联合索引时会遵守最左前缀匹配原则, 即查询从索引的最左列开始并且不能跳过索引中的列, 如果遇到索引失效的情况, 则右边的索引列全部转为全表查询
- 这是因为索引的底层数据结构 B+树的数据结构决定的, B+树是按照从左到右的顺序来建立叶子节点的, B+树会优先比较第一个字段来确定下一步的所搜方向, 如果第一个字段相同再依次比较第二和第三个字段, 最后得到检索的数据
- 不要在索引列上做任何操作 (计算, 函数, 自动或手动类型转换), 会导致索引失效而转向全表扫描
- 联合索引范围条件右边的索引列会失效, 范围查询的列在定义索引的时候, 应该放在最后面
- MySQL 在使用不等于 (!= 或者 <>) 的时候无法使用索引会导致全表扫描
- IS NOT NULL 也无法使用索引, 但是 IS NULL 是可以使用索引的
- LIKE 以通配符开头
'%abc...'
的索引失效会变成全表扫描的操作 - 字符串不加单引号索引失效 (类型转换导致索引失效)
EXPLAIN
- 使用 EXPLAIN 关键字可以模拟优化器执行 SQL 查询语句, 从而知道 MySQL 是如何处理你的 SQL 语句的, 分析查询语句或是表结构的性能瓶颈
1 |
|
- id: SELECT 查询的标识符. 每个 SELECT 都会自动分配一个唯一的标识符.
- select_type: SELECT 查询的类型.
- table: 查询的是哪个表
- partitions: 匹配的分区
- type: join 类型
- possible_keys: 此次查询中可能选用的索引
- key: 此次查询中确切使用到的索引.
- ref: 哪个字段或常数与 key 一起被使用
- rows: 显示此查询一共扫描了多少行, 这个是一个估计值.
- filtered: 表示此查询条件所过滤的数据的百分比
- extra: 额外的信息
B+ 树
- B+树是一种特殊的搜索树, InnoDB 存储引擎默认的底层的数据结构
- 性质
- 非叶子节点相当于是叶子节点的索引层, 叶子节点是存储关键字数据的数据层, 搜索只在叶子节点命中, 树的查询效率稳定
- 所有的叶子结点中包含了全部关键字的信息, 及指向含这些关键字记录的指针, 且叶子结点本身依关键字的大小自小而大顺序链接, B+树只需要去遍历叶子节点就可以实现整棵树的遍历
- B+树的出度 (树的分叉数)
- 不管是内存中的数据还是磁盘中的数据, 操作系统都是按页 (一页的大小通常是 4 kb, 这个值可以通过
getconfig (PAGE_SIZE)
命令查看) 来读取的, 一次只会读取一页的数据 - 如果要读取的数据量超过了一页的大小, 就会触发多次 IO 操作, 所以在选择 m 大小的时候, 要尽量让每个节点的大小等于一个页的大小
- 一般实际应用中, 出度是非常大的数字, 通常超过 100, 树的高度 (h) 非常小, 通常不超过 3
- 不管是内存中的数据还是磁盘中的数据, 操作系统都是按页 (一页的大小通常是 4 kb, 这个值可以通过
分表策略
- 垂直拆分: 表数据拆分到不同表中, 单表大数据量依然存在性能瓶颈, 垂直拆分就是要把表按模块划分到不同数据库表中, 相对于垂直切分更进一步的是服务化改造, 说得简单就是要把原来强耦合的系统拆分成多个弱耦合的服务, 通过服务间的调用来满足业务需求看, 因此表拆出来后要通过服务的形式暴露出去, 而不是直接调用不同模块的表
- 水平拆分: 行数据拆分到不同表中, 上面谈到垂直切分只是把表按模块划分到不同数据库, 但没有解决单表大数据量的问题, 而水平切分就是要把一个表按照某种规则把数据划分到不同表或数据库里, 例如像计费系统, 通过按时间来划分表就比较合适, 因为系统都是处理某一时间段的数据, 而像 SaaS 应用, 通过按用户维度来划分数据比较合适, 因为用户与用户之间的隔离的, 一般不存在处理多个用户数据的情况, 简单的按 user_id 范围来水平切分
数据库主键的选择
- UUID
- 占用空间大:UUID 有 128 位相较于自增主键长度较大,UUID 存储时可能会出现节点分裂, 导致节点多了, 但是每个节点的数据量少了, 存储到文件系统中时, 无论节点中数据是不是满的都会占用一页的空间
- UUID 是无序的, 作为主键会涉及大量索引重排,降低效率
- 自增主键
- 在水平分表时,由于自增主键必须连续,只能采用范围分片的方式,会产生尾部热点效应
- 雪花算法
- 以时间作为依据,结合机器 ID,以及 12 位序列,生成 64 位数据,生成的数据保证是唯一有序的
慢查询优化基本步骤
- 先运行看看是否真的很慢, 注意设置 SQL_NO_CACHE
- WHERE 条件单表查, 锁定最小返回记录表, 这句话的意思是把查询语句的 WHERE 都应用到表中返回的记录数最小的表开始查起, 单表每个字段分别查询, 看哪个字段的区分度最高
- EXPLAIN 查看执行计划, 是否与 2 预期一致 (从锁定记录较少的表开始查询)
- ORDER BY LIMIT 形式的 sql 语句让排序的表优先查
- 了解业务方使用场景
- 加索引时参照建索引的几大原则
- 观察结果, 不符合预期继续从头分析
SQL 语句执行过程
- 应用程序发现 SQL 到服务端
- 当执行 SQL 语句时, 应用程序会连接到相应的数据库服务器, 然后服务器对 SQL 进行处理
- 查询缓存
- 接着数据库服务器会先去查询是否有该 SQL 语句的缓存, key 是查询的语句, value 是查询的结果, 如果你的查询能够直接命中, 就会直接从缓存中拿出 value 来返回客户端
- 注意: 查询不会被解析, 不会生成执行计划, 不会被执行
- 查询优化处理, 生成执行计划
- 如果没有命中缓存, 则开始第三步
- 解析 SQL: 生成解析树, 验证关键字如 select, where, left join 等是否正确
- 预处理: 进一步检查解析树是否合法, 如检查数据表和列是否存在, 验证用户权限等
- 优化 SQL: 决定使用哪个索引, 或者在多个表相关联的时候决定表的连接顺序, 紧接着, 将 SQL 语句转成执行计划
- 将查询结果返回客户端
- 最后, 数据库服务器将查询结果返回给客户端, 如果查询可以缓存, MySQL 也会将结果放到查询缓存中
日志
- Mysql 有 4 种类型的日志: Error Log, Genaral Query Log, Binary Log 和 Slow Query Log
- Error Log: 记录 Mysql 运行过程中的 Error, Warning, Note 等信息, 系统出错或者某条记录出问题可以查看 Error 日志
- General Query Log: 记录 mysql 的日常日志, 包括查询, 修改, 更新等的每条 sql
- Binary Log: 二进制日志, 包含一些事件, 这些事件描述了数据库的改动, 如建表, 数据改动等, 主要用于备份恢复, 回滚操作等
- STATMENT: 每一条会修改数据的 sql 都会记录到 master 的 binlog 中, slave 在复制的时候 sql 进程会解析成和原来 master 端执行多相同的 sql 再执行
- ROW: 日志中会记录成每一行数据被修改的形式, 然后在 slave 端再对相同的数据进行修改, 只记录要修改的数据, 只有 value, 不会有 sql 多表关联的情况
- MIXED: MySQL 会根据执行的每一条具体的 SQL 语句来区分对待记录的日志形式, 也就是在 statement 和 row 之间选择一种
- Slow Query Log: 记录 Mysql 慢查询的日志
COUNT
COUNT (*)
包括了所有的列, 相当于行数, 在统计结果的时候, 不会忽略列值为 NULLCOUNT (1)
包括了所有的列, 用 1 代表代码行, 在统计结果的时候, 不会忽略列值为 NULLCOUNT (列名)
只包括列名那一列, 在统计结果的时候, 会忽略列值为空 (这里的空不是只空字符串或者 0, 而是表示 null) 的计数, 即某个字段值为 NULL 时, 不统计- 对于
COUNT (1)
和COUNT (*)
执行优化器的优化是完全一样的, 并没有COUNT (1)
会比COUNT (*)
快这个说法
Group By 和 Having, Where ,Order by 执行顺序
- Group By 和 Having, Where ,Order by 这些关键字是按照如下顺序进行执行的: Where, Group By, Having, Order by
- 首先 where 将最原始记录中不满足条件的记录删除 (所以应该在 where 语句中尽量的将不符合条件的记录筛选掉, 这样可以减少分组的次数), 然后通过 Group By 关键字后面指定的分组条件将筛选得到的视图进行分组, 接着系统根据 Having 关键字后面指定的筛选条件, 将分组视图后不满足条件的记录筛选掉, 最后按照 Order By 语句对视图进行排序, 这样最终的结果就产生了
- 在这四个关键字中, 只有在 Order By 语句中才可以使用最终视图的列名
数据库连接池
- 由于创建连接和释放连接都有很大的开销,尤其是数据库服务器不在本地时, 为了提升系统访问数据库的性能, 可以事先创建若干连接置于连接池中, 需要时直接从连接池获取, 使用结束时归还连接池而不必关闭连接, 从而避免频繁创建和释放连接所造成的开销
- 池化技术在 Java 开发中是很常见的, 在使用线程时创建线程池的道理与此相同, 基于 Java 的开源数据库连接池主要有: C 3 P 0, Hikari ,DBCP, BoneCP, Druid 等
- 连接数配置:最理想的情况每个连接都分配给单独的物理核心,但由于 IO 操作会阻塞,为了避免 CPU 空闲可以将连接数调大一些,经过实验验证
连接数 = (CPU 核心数 * 2) + 有效磁盘数
得到的连接数是最优的
Druid 连接池
- 强大的监控特性, 通过 Druid 提供的监控功能, 可以清楚知道连接池和 SQL 的工作情况
- 监控 SQL 的执行时间, ResultSet 持有时间, 返回行数, 更新行数, 错误次数, 错误堆栈信息;
- SQL 执行的耗时区间分布, 什么是耗时区间分布呢?比如说, 某个 SQL 执行了 1000 次, 其中
0~1
毫秒区间 50 次,1~10
毫秒 800 次,10~100
毫秒 100 次,100~1000
毫秒 30 次,1~10
秒 15 次, 10 秒以上 5 次, 通过耗时区间分布, 能够非常清楚知道 SQL 的执行耗时情况 - 监控连接池的物理连接创建和销毁次数, 逻辑连接的申请和关闭次数, 非空等待次数, PSCache 命中率等
- 其次, 方便扩展, Druid 提供了 Filter-Chain 模式的扩展 API, 可以自己编写 Filter 拦截 JDBC 中的任何方法, 可以在上面做任何事情, 比如说性能监控, SQL 审计, 用户名密码加密, 日志等等
- Druid 集合了开源和商业数据库连接池的优秀特性, 并结合阿里巴巴大规模苛刻生产环境的使用经验进行优化
主从同步
- Mysql 服务器之间的主从同步是基于二进制日志机制, 主服务器使用二进制日志来记录数据库的变动情况, 从服务器通过读取和执行该日志文件来保持和主服务器的数据一致
- Slave 会执行以下两个线程读取和执行该日志文件
Slave_IO
: 复制 master 主机 binlog 日志文件里的 SQL 命令到本机的 relay-log 文件里Slave_SQL
: 执行本机 relay-log 文件里的 SQL 语句, 实现与 Master 数据一致
- 在使用二进制日志时, 主服务器的所有操作都会被记录下来, 然后从服务器会接收到该日志的一个副本 RelayLog, 从服务器可以指定执行该日志中的哪一类事件 (例如只插入数据或者只更新数据), 默认会执行日志中的所有语句
分布式锁
- 要实现分布式锁, 最简单的方式可能就是直接创建一张锁表, 然后通过操作该表中的数据来实现了, 当我们想要获得锁的时候, 就可以在该表中增加一条记录, 想要释放锁的时候就删除这条记录
- 使用唯一性约束, 这样如果有多个请求同时提交到数据库的话, 数据库可以保证只有一个操作可以成功, 那么那么我们就可以认为操作成功的那个请求获得了锁
- 注意
- 这种锁没有失效时间, 一旦释放锁的操作失败就会导致锁记录一直在数据库中, 其它线程无法获得锁, 这个缺陷也很好解决, 比如可以做一个定时任务去定时清理
- 这种锁是非阻塞的, 因为插入数据失败之后会直接报错, 想要获得锁就需要再次操作, 如果需要阻塞式的, 可以弄个 for 循环, while 循环之类的, 直至 INSERT 成功再返回
- 这种锁也是非可重入的, 因为同一个线程在没有释放锁之前无法再次获得锁, 因为数据库中已经存在同一份记录了, 想要实现可重入锁, 可以在数据库中添加一些字段, 比如获得锁的主机信息, 线程信息等, 那么在再次获得锁的时候可以先查询数据, 如果当前的主机信息和线程信息等能被查到的话, 可以直接把锁分配给它
MHA 架构

- MHA 是最成熟的 MySQL 高可用方案,用于解决主从同步下主节点宕机导致整个集群不可用的情况
- MHA 有两个角色,Manager 与 Node,Manger 不存储数据,负责监控主节点的状态,并且负责协调故障转移,而 Node 则接受 Manager 的命令,执行具体故障转移的工作,binlog Server 用于备份主节点的 binlog 日志
- MHA 故障发现与转移过程:
- 如果 Manger 连续 Ping 主节点三次都无法成功,且从节点也无法使用 SSH 连接到主节点,则认定主节点客观下线
- 断开虚拟 IP 的映射,停止主从同步,Manger 抽取 binlog Server 中的 binlog
- Manger 检索记录最新的 Node 节点,该节点先与其他 Node 进行差异比对,将差异数据发送给其他从节点完成所有从节点的数据同步,然后 Manger 将 binlog 发送给所有从节点,这样所有服务器数据就一致了
- Manger 根据日志最新的从节点或者按照注册顺序选举新的主节点,将 binlog Server 连接到新的主节点,其他从节点 change master 为新的主节点,最后重新映射 VIP
- 旧主节点如果恢复连接,则降级为从节点
Limit 优化
- 对于小的偏移量,直接使用 limit 来查询没有什么问题,但随着数据量的增大,越往后分页,limit 语句的偏移量就会越大,速度也会明显变慢。
- 因此,对 limit 的优化,不是直接使用 limit,而是首先获取到 offset 的 id,然后直接使用 limit size 来获取数据。
1 |
|
- 另外,可以从前端获取上一页的最后一条数据的 id,将其带入 SQL 的 WHERE 条件中,可以节省一次子查询,如果类似于 create_time 可能重复的字段,可以采用添加偏移量的方式使该字段唯一
Redis
分布式锁
- Redis 锁主要利用 Redis 的 setnx 命令
- 加锁命令: SETNX key value, 当键不存在时, 对键进行设置操作并返回成功, 否则返回失败, KEY 是锁的唯一标识, 一般按业务来决定命名
- 解锁命令: DEL key, 通过删除键值对释放锁, 以便其他线程可以通过 SETNX 命令来获取锁
- 锁超时: EXPIRE key timeout, 设置 key 的超时时间, 以保证即使锁没有被显式释放, 锁也可以在一定时间后自动释放, 避免资源被永远锁住
- SETNX 和 EXPIRE 非原子性
- 如果 SETNX 成功, 在设置锁超时时间后, 服务器挂掉, 重启或网络问题等, 导致 EXPIRE 命令没有执行, 锁没有设置超时时间变成死锁
- 有很多开源代码来解决这个问题, 比如使用 lua 脚本
- 锁误解除
- 如果线程 A 成功获取到了锁, 并且设置了过期时间 30 秒, 但线程 A 执行时间超过了 30 秒, 锁过期自动释放, 此时线程 B 获取到了锁, 随后 A 执行完成, 线程 A 使用 DEL 命令来释放锁, 但此时线程 B 加的锁还没有执行完成, 线程 A 实际释放的线程 B 加的锁
- 通过在 value 中设置当前线程加锁的标识, 在删除之前验证 key 对应的 value 判断锁是否是当前线程持有, 可生成一个 UUID 标识当前线程, 使用 lua 脚本做验证标识和解锁操作
- 超时解锁导致并发
- 如果线程 A 成功获取锁并设置过期时间 30 秒, 但线程 A 执行时间超过了 30 秒, 锁过期自动释放, 此时线程 B 获取到了锁, 线程 A 和线程 B 并发执行
- A, B 两个线程发生并发显然是不被允许的, 一般有两种方式解决该问题:
- 将过期时间设置足够长, 确保代码逻辑在锁释放之前能够执行完成
- 为获取锁的线程增加守护线程, 为将要过期但未释放的锁增加有效时间
- 不可重入
- 当线程在持有锁的情况下再次请求加锁, 如果一个锁支持一个线程多次加锁, 那么这个锁就是可重入的, 如果一个不可重入锁被再次加锁, 由于该锁已经被持有, 再次加锁会失败, Redis 可通过对锁进行重入计数, 加锁时加 1, 解锁时减 1, 当计数归 0 时释放锁
- ThreadLocal 或 RedisMap 实现计数
- 无法等待锁释放
- 上述命令执行都是立即返回的, 如果客户端可以等待锁释放就无法使用
- 可以通过客户端轮询的方式解决该问题, 当未获取到锁时, 等待一段时间重新获取锁, 直到成功获取锁或等待超时, 这种方式比较消耗服务器资源, 当并发量比较大时, 会影响服务器的效率
- 另一种方式是使用 Redis 的发布订阅功能, 当获取锁失败时, 订阅锁释放消息, 获取锁成功后释放时, 发送锁释放消息
- 上述命令执行都是立即返回的, 如果客户端可以等待锁释放就无法使用
Redis 的应用场景
- 缓存
- 共享 Session
- 消息队列系统
- 分布式锁
高并发下的问题
缓存穿透
- 缓存穿透是指用户请求的数据在缓存中不存在即没有命中, 同时在数据库中也不存在, 导致用户每次请求该数据都要去数据库中查询一遍, 如果有恶意攻击者不断请求系统中不存在的数据, 会导致短时间大量请求落在数据库上, 造成数据库压力过大, 甚至导致数据库承受不住而宕机崩溃
解决方法
- 将无效的 key 存放进 Redis 中: 当出现 Redis 查不到数据, 数据库也查不到数据的情况, 我们就把这个 key 保存到 Redis 中, 设置 value=“null”, 并设置其过期时间极短, 后面再出现查询这个 key 的请求的时候, 直接返回 null, 就不需要再查询数据库了, 但这种处理方式是有问题的, 假如传进来的这个不存在的 Key 值每次都是随机的, 那存进 Redis 也没有意义
- 使用布隆过滤器: 如果布隆过滤器判定某个 key 不存在布隆过滤器中, 那么就一定不存在, 如果判定某个 key 存在, 那么很大可能是存在 (存在一定的误判率), 于是我们可以在缓存之前再加一个布隆过滤器, 将数据库中的所有 key 都存储在布隆过滤器中, 在查询 Redis 前先去布隆过滤器查询 key 是否存在, 如果不存在就直接返回, 不让其访问数据库, 从而避免了对底层存储系统的查询压力
布隆过滤器
- 初始化过程
- 初始化 n 位的二进制数组
- 对每个 Key 做多次 Hash, 将 Hash 值数组上的位置设置为 1
- 判断过程
- 对需要判断的 Key 同样做多次 Hash, 判断 Hash 值在数组上的位置是否全为 1, 是则存在 (可能误判), 否则一定不存在
- 减少误判的措施
- 增加二进制数组位数
- 增加 Hash 的次数
- 如果 Key 被删除怎么更新过滤器: 布隆过滤器因为某一位二进制可能被多个编号 Hash 引用, 因此布隆过滤器无法直接处理删除数据的情况
- 定时异步重建布隆过滤器
- 计数 Bloom Fliter
缓存击穿
- 缓存击穿是某个热点的 key 失效, 大并发集中对其进行请求, 就会造成大量请求读缓存没读到数据, 从而导致高并发访问数据库, 引起数据库压力剧增, 这种现象就叫做缓存击穿
解决方案
- 加互斥锁: 在缓存失效后, 通过互斥锁或者队列来控制读数据写缓存的线程数量, 比如某个 key 只允许一个线程查询数据和写缓存, 其他线程等待, 这种方式会阻塞其他的线程, 此时系统的吞吐量会下降
- 热点数据缓存永远不过期: 永不过期实际包含两层意思
- 物理不过期, 针对热点 key 不设置过期时间
- 逻辑过期, 把过期时间存在 key 对应的 value 里, 如果发现要过期了, 通过一个后台的异步线程进行缓存的构建
缓存雪崩
- 如果缓存某一个时刻出现大规模的 key 失效, 那么就会导致大量的请求打在了数据库上面, 导致数据库压力巨大, 如果在高并发的情况下, 可能瞬间就会导致数据库宕机, 这时候如果运维马上又重启数据库, 马上又会有新的流量把数据库打死, 这就是缓存雪崩
解决方案
- 事前
- 均匀过期: 设置不同的过期时间, 让缓存失效的时间尽量均匀, 避免相同的过期时间导致缓存雪崩, 造成大量数据库的访问
- 分级缓存: 第一级缓存失效的基础上, 访问二级缓存, 每一级缓存的失效时间都不同
- 热点数据缓存永远不过期
- 保证 Redis 缓存的高可用: 防止 Redis 宕机导致缓存雪崩的问题, 可以使用主从+ 哨兵, Redis 集群来避免 Redis 全盘崩溃的情况
- 事中
- 互斥锁: 在缓存失效后, 通过互斥锁或者队列来控制读数据写缓存的线程数量, 比如某个 key 只允许一个线程查询数据和写缓存, 其他线程等待, 这种方式会阻塞其他的线程, 此时系统的吞吐量会下降
- 使用熔断机制: 限流降级, 当流量达到一定的阈值, 直接返回"系统拥挤”之类的提示, 防止过多的请求打在数据库上将数据库击垮, 至少能保证一部分用户是可以正常使用, 其他用户多刷新几次也能得到结果
- 事后
- 开启 Redis 持久化机制, 尽快恢复缓存数据, 一旦重启, 就能从磁盘上自动加载数据恢复内存中的数据
并发竞争 key 问题
- Redis 并发竞争问题就是高并发写同一个 key 时导致的值错误。
- 常用的解决方法:
- 乐观锁:
watch
命令会监视给定的每一个 key,当提交事务时如果监视的任一个 key 自从调用 watch 后发生过变化,则整个事务会回滚,不执行任何动作。 - 分布式锁: 在业务层进行控制,操作 Redis 之前,先去申请一个分布式锁,拿到锁的才能操作
- 时间戳:适合有序场景,在写入时保存一个时间戳,写入前先比较自己的时间戳是不是早于现有记录的时间戳,如果早于,就不写入了。
- 消息队列: 在并发量很大的情况下,可以通过消息队列进行串行化处理
- 乐观锁:
数据类型
-
String: 字符串类型是 Redis 最基础的数据结构, 首先键都是字符串类型, 而且其他几种数据结构都是在字符串类型基础上构建的, 我们常使用的 set key value 命令就是字符串, 常用在缓存, 计数, 共享 Session, 限速等
- int:数字的时候
- raw:长字符串(长度大于 39 个字节)
- embstr:短字符串(长度小于 39 个字节)
- 注意:embstr 和 raw 都是由 SDS 动态字符串构成的。唯一区别是:raw 是分配内存的时候,RedisObject 和 SDS 各分配一块内存,而 embstr 是 RedisObject 和 SDS 在同一块内存中
-
Hash: 在 Redis 中, Hash 类型是指键值本身又是一个键值对结构, Hash 可以用来存放用户信息, 比如实现购物车
- ziplist:元素数量小于 512 且所有元素长度小于 64 字节
- 哈希表:不满足 ziplist 条件的其他情况
-
List: 列表 (list) 类型是用来存储多个有序的字符串, 可以做简单的消息队列的功能
- ziplist:列表对象所有字符串元素长度都小于 64 个字节且元素数量小于 512
- 双向链表:不满足 ziplist 条件的其他情况
-
Set: 集合 (set) 类型也是用来保存多个的字符串元素, 但和列表类型不一样的是, 集合中不允许有重复元素, 并且集合中的元素是无序的, 不能通过索引下标获取元素, 利用 Set 的交集, 并集, 差集等操作, 可以计算共同喜好, 全部的喜好, 自己独有的喜好等功能
- inset:所有元素都是整数且元素数量小于 512
- 哈希表:不满足 inset 条件的的其他情况
-
Sorted Set: Sorted Set 多了一个权重参数 Score, 集合中的元素能够按 Score 进行排列, 可以做排行榜应用, 取 TOP N 操作
- ziplist:元素数量小于 128 且所有元素长度小于 64
- 跳表:不满足 ziplist 条件的其他情况
-
SDS(动态字符串)
- 数据结构
- free: 还剩多少空间
- len: 字符串长度
- buf: 存放的字符数组
- 空间预分配:为减少修改字符串带来的内存重分配次数,sds 采用了一次管够的策略
- 若修改之后 sds 长度小于 1 MB, 则多分配现有 len 长度的空间
- 若修改之后 sds 长度大于等于 1 MB,则扩充除了满足修改之后的长度外,额外多 1 MB 空间
- 惰性空间释放:为避免缩短字符串时候的内存重分配操作,sds 在数据减少时,并不立刻释放空间
- 数据结构
-
跳表
-
跳跃表其实可以把它理解为多层的链表,它有如下的性质:
- 多层的结构组成,每层是一个有序的链表
- 最底层(level 1)的链表包含所有的元素
- 跳跃表的查找次数近似于层数,时间复杂度为 O (logn),插入、删除也为 O (logn)
- 跳跃表是一种随机化的数据结构,跳跃表维持结构平衡的成本是比较低的,完全是依靠随机,相比二叉查找树,在多次插入删除后,需要 Rebalance 来重新调整结构平衡
-
上浮元素:每隔随机元素,把它放到上一层的链表当中,组成多层链表结构
-
查找元素:从上层开始查找,大数向右找到头,小数向左找到头,例如我要查找
17
,查询的顺序是:13 -> 46 -> 22 -> 17;如果是查找35
,则是 13 -> 46 -> 22 -> 46 -> 35;如果是54
,则是 13 -> 46 -> 54 -
添加元素:是通过抛硬币的方式来决定该元素会出现到多少层,也就是说它会有 1/2 的概率出现第二层、1/4 的概率出现在第三层,以此类推
-
删除元素:跳跃表的删除很简单,只要先找到要删除的节点,然后顺藤摸瓜删除每一层相同的节点就好了
-
-
Ziplist (压缩列表)
-
Redis 的列表键和哈希键的底层实现之一。此数据结构是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。
zlbytes
:记录整个压缩列表占用的内存字节数,在压缩列表内存重分配,或者计算zlend
的位置时使用zltail
:记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过该偏移量,可以不用遍历整个压缩列表就可以确定表尾节点的地址zllen
:记录压缩列表包含的节点数量,但该属性值小于 UINT 16_MAX(65535)时,该值就是压缩列表的节点数量,否则需要遍历整个压缩列表才能计算出真实的节点数量entry...
:压缩列表的所有节点zlend
:特殊值 0 xFF(十进制 255),用于标记压缩列表的末端
-
每个压缩列表节点可以保存一个字节数字或者一个整数值,结构如下
previous_entry_length
:记录压缩列表前一个节点的长度encoding
:节点的 encoding 保存的是节点的 content 的内容类型content
:content 区域用于保存节点的内容,节点内容类型和长度由 encoding 决定
- 元素的遍历
- 先找到列表尾部元素
- 然后再根据 ziplist 节点元素中的
previous_entry_length
属性,来逐个遍历
- 连锁更新
entry
元素的结构,有一个previous_entry_length
字段,它的长度要么都是 1 个字节,要么都是 5 个字节- 前一节点的长度小于 254 字节,则
previous_entry_length
长度为 1 字节 - 前一节点的长度大于 254 字节,则
previous_entry_length
长度为 5 字节
- 前一节点的长度小于 254 字节,则
- 假设现在存在一组压缩列表,长度都在 250 字节至 253 字节之间,突然新增一新节点
new
长度大于等于 254 字节,会从后往前将原有的所有 entry 的长度都变长,程序需要不断的对压缩列表进行空间重分配工作,直到结束。 - 除了增加操作,删除操作也有可能带来“连锁更新”。
-
持久化技术
-
Redis 为了保证效率, 数据缓存在了内存中, 但是会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件中, 以保证数据的持久化
-
Redis 的持久化策略有两种:
-
RDB: 快照形式是直接把内存中的数据定时保存到一个 dump 的文件中
- 当 Redis 需要做持久化时, Redis 会 fork 一个子进程, 子进程将数据写到磁盘上一个临时 RDB 文件中, 当子进程完成写临时文件后, 将原来的 RDB 替换掉
-
AOF: 把所有的对 Redis 的服务器进行修改的命令都存到一个文件里
-
使用 AOF 做持久化, 每一个写命令都通过 write 函数追加到
appendonly. aof
中 -
AOF 的默认策略是每秒钟 fsync 一次, 在这种配置下, 就算发生故障停机, 也最多丢失一秒钟的数据
-
缺点: 对于相同的数据集来说, AOF 的文件体积通常要大于 RDB 文件的体积, AOF 的速度可能会慢于 RDB
-
-
-
Redis 默认是快照 RDB 的持久化方式
Redis 效率高的原因
- 纯内存操作, 相对于读写磁盘, 读写速度提升明显
- 单线程操作, 避免了频繁的上下文切换
- 采用了 I/O 多路复用机制
I/O 多路复用机制
- 多路指的是多个网络连接, 复用指的是复用同一个线程
- IO 多路复用只需要一个进程就能够处理多个 Socket, 从而解决了上下文切换的问题
- 在 I/O 多路复用模型中, 最重要的函数调用就是
select
, 该方法的能够同时监控多个文件描述符的可读可写情况, 当其中的某些文件描述符可读或者可写时,select
方法就会返回可读以及可写的文件描述符个数
Bigkey
- Redis Bigkey: 即数据量大的 Key, 比如字符串 Value 值非常大, 哈希, 列表, 集合, 有序集合元素多等, 由于其数据大小远大于其他 Key, 容易造成内存不均, 超时阻塞, 网络流量拥塞等一系列问题
- 如何发现 Bigkey: 使用官方的
redis-cli --bigkeys
时, 它会对 Redis 中的 key 进行SCAN
采样, 寻找较大的 keys, 不用担心会阻塞 Redis - 删除 Bigkey: 如果直接
DEL
bigkey 操作可能会引发 Redis 阻塞甚至是发生 Sentinel 主从切换, 可以使用 SCAN 命令来分多批枚举 bigkey, 然后实现渐进式删除 bigkey - 避免 Bigkey: 主要是对 Bigkey 进行拆分, 拆成多个 key, 然后用
MGET
取回来, 再在业务层做合并
过期策略与内存淘汰策略
过期策略:Redis 中同时使用了惰性过期和定期过期两种过期策略
- 定时删除:每个设置过期时间的 key 都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的 CPU 资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
- 定期删除
- Redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定期遍历这个字典来删除到期的 key
- Redis 默认会每秒进行十次过期扫描(100 ms 一次)
- 从过期字典中随机 20 个 key
- 删除这 20 个 key 中已经过期的 key
- 如果过期的 key 比率超过 1/4,那就重复步骤 1
- 惰性删除, key 过期的时候不删除, 每次从数据库获取 key 的时候去检查是否过期, 若过期, 则删除, 返回 null
内存淘汰策略
- noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键
- allkeys-lru:加入键的时候,如果过限,首先通过 LRU 算法驱逐最久没有使用的键
- volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键
- allkeys-random:加入键的时候如果过限,从所有 key 随机删除
- volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐
- volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
- volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
- allkeys-lfu:从所有键中驱逐使用频率最少的键
双写一致性
-
先写数据库再删缓存
- 先更新数据库
- 再删除缓存
-
先删缓存可能导致读操作更新旧的缓存数据, 导致数据库与 Redis 数据不一致
-
更新缓存可能会因为执行顺序与访问顺序不一致导致数据库与 Redis 数据不一致, 而删除缓存不会
-
缓存延时双删
- 先操作数据库再操作缓存, 也会导致数据不一致, 因为不是原子性操作
- 可能会遇到缓存失效导致读操作更新旧的缓存数据, 所以需要延时等待读操作结束再删除缓存
- 先删除缓存
- 再更新数据库
- 休眠一会 (读业务逻辑数据的耗时 + 几百毫秒)
- 再次删除缓存
- 第一次删除缓存的目的在于当最后一次延时删除缓存失败的情况发生, 至少一致性策略只会退化成先删缓存再更新数据的策略
- 为了确保读请求结束, 写请求第二次删除读请求可能带来的缓存脏数据, 只有休眠那一会 (比如就那 1 秒), 可能有脏数据, 一般业务也会接受的
-
删除缓存重试机制
- 不管是延时双删还是 Cache-Aside 的先操作数据库再删除缓存, 都可能会存在第二步的删除缓存失败, 导致的数据不一致问题
- 可以引入删除缓存重试机制, 如果删除失败就多删除几次, 保证删除缓存成功
- 写请求更新数据库
- 缓存因为某些原因, 删除失败
- 把删除失败的 key 放到消息队列
- 消费消息队列的消息, 获取要删除的 key
- 重试删除缓存操作
-
读取 binlog 异步删除缓存
- 一旦 MySQL 中产生了新的写入, 更新, 删除等操作, 就可以把 binlog 相关的消息通过消息队列推送至 Redis, Redis 再根据 binlog 中的记录, 对 Redis 进行更新
- 这种同步机制类似于 MySQL 的主从备份机制, 可以结合使用阿里的 canal 对 MySQL 的 binlog 进行订
高可用方案
主从复制
- 主从复制, 是指将一台 Redis 服务器的数据, 复制到其他的 Redis 服务器, 前者称为主节点 (master/leader) ,后者称为从节点 (slave/follower)
- 默认情况下, 每台 Redis 服务器都是主节点, 且一个主节点可以有 0 或多个从节点, 但一个从节点只能有一个主节点, 数据的复制是单向的, 只能由主节点到从节点
- 作用
- 数据冗余: 主从复制实现了数据的热备份, 是持久化之外的一种数据冗余方式
- 读写分离: 在主从复制的基础上, 配合读写分离, 可以由主节点提供写服务, 由从节点提供读服务 ,分担服务器负载; 尤其是在写少读多的场景下, 通过多个从节点分担读负载, 可以大大提高 Redis 服务器的并发量
- 高可用基础: 除了上述作用以外, 主从复制还是哨兵和集群能够实施的基础, 因此说主从复制是 Redis 高可用的基础
- 原理: 对于主从复制来说, 主从刚刚连接的时候, 进行全量同步 (RDB), 全同步结束后, 进行增量同步 (AOF)
- Slave 与 Master 建立连接
- Slave 向 Master 发起同步请求 (SYNC)
- Master 执行 bgsave 命令生成 rdb 数据快照, 发给 Slave
- Slave 加载 RDB 数据快照, 还原数据, 主从保持一致
- 之后 Master 执行的写操作都会发往 Slave 执行, 保持数据同步
哨兵
-
哨兵模式能够后台监控主机是否故障, 如果故障了则自动将从节点转换为主节点
-
流程
- 哨兵通过发送命令, 等待 Redis 服务器响应, 从而监控运行的多个 Redis 实例, 让 Redis 服务器返回其运行状态
- 当哨兵监测到主节点宕机, 会自动将从节点切换成主节点, 然后通过发布订阅模式通知其他的从节点, 修改配置文件并切换主节点
- 当主节点恢复连接后, 原主节点自动转换为从节点, 现主节点不变
-
优点
- 哨兵模式是主从模式的升级, 手动转换为自动
- 主从可以切换, 故障可以转移, 系统的可用性更好
-
多哨兵模式
-
每个 Sentinel 以每秒钟一次的频率向它所知的 Master, Slaver 以及其他 Sentinel 实例发送一个 PING 命令
-
如果一个实例距离最后一次有效回复 PING 命令的时间超过
own-after-millisecounds
选项所指定的值, 则这个实例会被 Sentinel 标记为主观下线 -
当有足够足够数量的 Sentinel (大于等于配置文件指定的值) 在指定的时间范围内确定 Master 的确进入了主观下线状态, 则 Master 会被标记为客观下线
-
Sentinel 从 Slave 中选出新的 Master
- 剔除主观下线, 已断线, 或者最后一次回复 PING 命令的时间大于 5 s 的 Slave
- 剔除与失效主服务器连接断开的时长超过
down-after
选项指定的时长 10 倍的 Slave - 按同步数据的偏移量选出数据最完成的 Slave
- 如果偏移量相同, 选中 ID 最小的 Slave
-
将 Slave 切换成 Master
- 向被选中的从服务器发送
SLAVEOF NO ONE
命令, 让它转成主服务器 - 通过发布于订阅功能, 将更新后的配置传播给所有其他 Sentinel, 其他 Sentinel 对它们自己的配置进行更新
- 向所有 Slave 下发 SLAVEOF 命令, 指向新的主服务器
- redis-slave 向 master 重新建立连接, 重放 rdb 保持数据同步
- 向被选中的从服务器发送
- 在上述转移过程中, 伴随着 Redis 本地配置文件的自动重写, 这样即使是实例重启配置也不会丢失
- 原有的 master 在恢复后降级为 slave 与新 master 全量同步
-
-
哨兵的高可用
- Sentinel 自动故障迁移使用 Raft 算法来选举领头 (leader) Sentinel
- 超过半数投票选出 Leader, Sentinel Leader 用于下发故障转移的指令
- 如果某个 Leader 挂了, 则使用 Raft 从剩余的 Sentinel 中选出 Leader
-
哨兵的感知发现
- 每一个 Sentinel 节点接入 Master 之后, 所有 Sentinel 的信息也在 Master 节点上进行了注册
- Sentinel 可以通过 Master 获取其他 Sentinel 节点的信息
Cluster
-
Cluster 是分割数据到多个 Redis 实例的处理过程, 因此每个实例只保存 key 的一个子集
-
优点
- 通过利用多台计算机内存, 可以构造更大的数据库
- 通过利用多台计算机的多核, 允许我们扩展计算能力
- 通过利用多台计算机的网络适配器, 可以扩展网络带宽
-
分片的不足
- 涉及多个 key 的操作通常是不被支持的, 例如, 当两个 set 映射到不同的 redis 实例上时, 你就不能对这两个 set 执行交集操作
- 涉及多个 key 的 Redis 事务不能使用
- 当使用分片时, 数据处理较为复杂, 比如需要处理多个 rdb/aof 文件, 并且从多个实例和主机备份持久化文件
-
数据分散存储
- Redis Cluster 集群采用 HashSlot(哈希槽)分配数据,Redis 集群预先分好 16384(16 K)个槽,初始化集群时平均规划给每一台 Redis Master
- 然后对存入的 Key 取哈希并对 16384(16 K)取模得到指定的槽,存放到包含该槽的节点服务器上
事务
- Redis 事务可以理解为一个打包的批量执行脚本, 但批量指令并非原子化的操作, 中间某条指令的失败不会导致前面已做指令的回滚, 也不会造成后续的指令不做
- 以
MULTI
开始一个事务, 然后将多个命令入队到事务中, 最后由EXEC
命令触发事务, 一并执行事务中的所有命令
Kafka
Kafka 的使用场景
- 通过使用消息队列, 我们可以异步处理请求, 从而缓解系统的压力, 同样可以达到削峰解耦的目的
- 日志订阅: 通过 kafka 对各个服务的日志进行收集, 再开放给各个 consumer, 例如实现其他下发操作给其他系统, 或者实现数据库与 Redis 的一致性
- 异步执行方法: 将方法名和入参存入 MQ 中, 之后再统一消费并执行方法, 起到缓解系统的压力
消息有序性
- 每个分区内, 每条消息都有 offset, 所以只能在同一分区内有序, 但不同的分区无法做到消息顺序性
- 生产者在写的时候可以指定一个 key, 这个 key 对应的消息都会发送到同一个 partition 中, 所以消费者消费到的消息也一定是有序的
- 消费者端可能需要使用多线程并发处理消息来提高吞吐量, 每个线程线程处理消息的快慢是不一致的, 导致最终消息有可能不一致, 所以我们需要保证同一个 Key 的消息只被同一个线程处理, 由此我们可以在线程处理前增加个内存队列, 每个线程只负责处理其中一个内存队列的消息, 同一个订单号的消息发送到同一个内存队列中即可
重复消费问题
- Kafka 实现幂等性: 将原来下游需要做的去重放在了数据上游, 开启幂等性的 Producer 在初始化的时候会被分配一个 PID, 发往同一 Partition 的消息会附带 Sequence Number, 而 Broker 端会对
<PID, Partition, SeqNumber>
做缓存, 当具有相同主键的消息提交时, Broker 只会持久化一条 - Consumer 可以通过业务的幂等性,防止重复消费的问题
Kafka 效率高的原因
- 磁盘顺序读写:Kafka 的 producer 生产数据, 要写入到 log 文件中, 写的过程是一直追加到文件末端, 顺序写省去了大量磁头寻址的时间,对 log 文件进行了 segment, 并对 segment 建立了索引
- 页缓存:kafka 避免使用 JVM 而是直接使用操作系统的页缓存特性提高处理速度,进而避免了 JVM GC 带来的性能损耗,Kafka 采用字节紧密存储,避免产生对象,这样可以进一步提高空间利用率
- 零复制技术:IO 操作不用经过用户态, 数据在内核态直接发送至 Socket 缓冲区,避免了数据在用户态与内核态之间复制的过程
- 批量操作:Kafka 提供了大量关于批处理的 API(ConcurrentKafkaListener),对数据压缩合并,通过更小的数据包和更短的时间进行数据的发送与处理。
Kafka 的高可靠性
- Kafka 通过分区的多副本机制来保证消息的可靠性
- ISR: 意为和 Leader 保持同步的 Follower 集合, 当 ISR 中的 Follower 完成数据的同步之后, 就会给 Leader 发送 ACK, 如果 Follower 长时间未向 Leader 同步数据, 则该 Follower 将被踢出 ISR, 该时间阈值由
replica. lag. time. max. ms
参数设定, Leader 发生故障之后, 就会从 ISR 中选举新的 Leader - 副本数据同步策略
- 当
ACK=0
时, Producer 不等待 Broker 的 ACK, 不管数据有没有写入成功, 都不再重复发该数据 - 当
ACK=1
时, Broker 会等到 Leader 写完数据后, 就会向 Producer 发送 ACK, 但不会等 Follower 同步数据, 如果这时 Leader 挂掉, Producer 会对新的 Leader 发送新的数据, 在 old 的 Leader 中不同步的数据就会丢失 - 当
ACK=-1
或者 ALL 时, broker 会等到 Leader 和 ISR 中的所有 Follower 都同步完数据, 再向 Producer 发送 ACK, 有可能造成数据重复
- 当
- 副本数据一致性问题
- follower 故障: Follower 发生故障后会被临时踢出 ISR, 待该 Follower 恢复后, Follower 会读取本地磁盘记录的上次的 HW, 并将 log 文件高于 HW 的部分截取掉, 从 HW 开始向 Leader 进行同步, 等该 Follower 的 LEO 大于等于该 Partition 的 HW, 即 Follower 追上 Leader 之后, 就可以重新加入 ISR 了
- leader 故障: Leader 发生故障之后, 会从 ISR 中选出一个新的 Leader, 之后, 为保证多个副本之间的数据一致性, 其余的 Follower 会先将各自的 log 文件高于 HW 的部分截掉, 然后从新的 leader 同步数据
- 注意: 这只能保证副本之间的数据一致性, 并不能保证数据不丢失或者不重复
- ISR: 意为和 Leader 保持同步的 Follower 集合, 当 ISR 中的 Follower 完成数据的同步之后, 就会给 Leader 发送 ACK, 如果 Follower 长时间未向 Leader 同步数据, 则该 Follower 将被踢出 ISR, 该时间阈值由
- Kafka 实现投递的可靠性
- 发送阶段:遇到高延迟,Producer 会多次重发消息,直到 Broker ACK 确认,过程中 Broker 会自动去重,超时 Producer 抛出异常
- 存储阶段:Broker 先保存再 ACK 确认,即使 ACK 失败消息也不会丢失,多次重试直到 Producer 接收,但是会导致消息积压
- 消费阶段:Broker 向 Consumer 发送数据,一段时间未接收,自动重发,直到 Consumer ACK 确认,Consumer 注意幂等处理
- Consume 可靠性
- 由于 Consumer 在消费过程中可能会出现断电宕机等故障, Consumer 恢复后, 需要从故障前的位置的继续消费, 所以 Consumer 需要实时记录自己消费到了哪个 offset, 以便故障恢复后继续消费
- Kafka 0.9 版本之前, Consumer 默认将 offset 保存在 Zookeeper 中, 从 0.9 版本开始, Consumer 默认将 offset 保存在 Kafka 一个内置的 topic 中, 该 topic 为
__consumer_offsets
- 先处理后提交 offset, 会造成重复消费, 先提交 offset 后处理, 会造成数据丢失
ISR, OSR, AR
- ISR (InSyncRepli): 速率和 leader 相差低于 10 秒的 follower 的集合
- OSR (OutSyncRepli) : 速率和 leader 相差大于 10 秒的 follower
- 将失效的 follower 踢出 ISR
- 等速率接近 leader 10 秒内, 再加进 ISR
- AR (AllRepli) : 所有分区的 follower
HW, LEO
- HW : 又名高水位, 根据同一分区中, 最低的 LEO 所决定
- LEO : 每个分区的最高 offset
分区
- 对于 kafka 集群来说, 分区可以做到负载均衡, 对于消费者来说, 可以提高并发度, 提高读取效率
- 在同一消费者组中, 超过分区数的消费者就不会再接收数据
- 创建 Topic 分配分区
- 首先副本数不能超过 broker 数
- 第一分区是随机从 Broker 中选择一个, 然后其他分区相对于 0 号分区依次向后移
- Topic 修改分区数
- 可以增加, 不可以减少, 先有的分区数据难以处理
- 生产者分区策略
- 指明 partition 的情况下, 直接将指明的值直接作为 partiton 值
- 没有指明 partition 值但有 key 的情况下, 将 key 的 hash 值与 topic 的 partition 数进行取余得到 partition 值
- 既没有 partition 值又没有 key 值的情况下, 第一次调用时随机生成一个整数 (后面每次调用在这个整数上自增), 将这个值与 topic 可用的 partition 总数取余得到 partition 值, 也就是常说的 round-robin 算法
- 消费者分区策略
- Roudn Robin: 先将每个 topic 的每个 partition 排序, 然后以轮询的方式分配所有的分区给每个 consumer
- Range 重分配策略: 先计算各个 consumer 将会承载的分区数量, 然后将指定数量的分区分配给该 consumer
消费方式
- 在 producer 阶段, 是向 broker 用 Push 模式
- 在 consumer 阶段, 是向 broker 用 Pull 模式
- 在 Pull 模式下, consumer 可以根据自身速率选择如何拉取数据, 避免了低速率的 consumer 发生崩溃的问题, 但缺点是, consumer 要时不时的去询问 broker 是否有新数据, 容易发生死循环, 内存溢出
文件存储
- partition 位于一个文件夹下, 该文件夹的命名规则为: topic 名称+分区序号, 例如, first 这个 topic 有三个分区, 则其对应的文件夹为 first-0, first-1, first-2
- 由于生产者生产的消息会不断追加到 log 文件末尾, 为防止 log 文件过大导致数据定位效率低下, Kafka 采取了分片和索引机制, 将每个 partition 分为多个 segment
- 每个 segment 对应两个文件, 即
. index
文件和. log
文件
Controller 的作用
- 负责 kafka 集群的上下线工作, 所有 topic 的副本分区分配和选举 leader 工作
- 选举: 在 ISR 中需要选择, 选择策略为先到先得
事务
- kafka 事务有两种: producer 事务和 consumer 事务
- producer 事务是为了解决 kafka 跨分区跨会话问题
- kafka 不能跨分区跨会话的主要问题是每次启动的 producer 的 PID 都是系统随机给的, 所以为了解决这个问题, 我们就要手动给 producer 一个全局唯一的 id, 也就是 transaction id 简称 TID
- 我们将 TID 和 PID 进行绑定, 在 producer 带着 TID 和 PID 第一次向 broker 注册时, broker 就会记录 TID, 并生成一个新的组件
__transaction_state
用来保存 TID 的事务状态信息, 当 producer 重启后, 就会带着 TID 和新的 PID 向 broker 发起请求, 当发现 TID 一致时, producer 就会获取之前的 PID, 将覆盖掉新的 PID, 并获取上一次的事务状态信息, 从而继续上次工作
- consumer 事务相对于 producer 事务就弱一点, 需要先确保 consumer 的消费和提交位置为一致且具有事务功能, 才能保证数据的完整, 不然会造成数据的丢失或重复
生产者客户端的整体结构
- Kafka 的 Producer 发送消息采用的是异步发送的方式, 在消息发送的过程中, 涉及到了两个线程: main 线程和 sender 线程, 以及一个线程共享变量: RecordAccumulator
- main 线程将消息发送给 RecordAccumulator
- sender 线程不断从 RecordAccumulator 中拉取消息发送到 Kafka broker
- 相关参数
- batch. size: 只有数据积累到 batch. size 之后, sender 才会发送数据
- linger. ms: 如果数据迟迟未达到 batch. size, sender 等待 linger. time 之后就会发送数据
Spring
AOP
-
AOP 指面向切面编程, 用于处理系统中分布于各个模块的横切关注点, 把那些与业务无关, 但是却为业务模块所共同调用的逻辑部分封装起来, 从而使得业务逻辑各部分之间的耦合度降低, 提高程序的可重用性, 同时提高了开发的效率
-
AOP 实现的关键在于 AOP 框架自动创建的 AOP 代理, AOP 代理主要分为静态代理和动态代理, 静态代理的代表为 AspectJ, 而动态代理则以 Spring AOP 为代表
-
AspectJ 是静态代理的增强, 所谓的静态代理就是 AOP 框架会在编译阶段生成 AOP 代理类, 因此也称为编译时增强
-
Spring AOP 中的动态代理主要有两种方式, JDK 动态代理和 CGLIB 动态代理, JDK 动态代理通过反射来接收被代理的类, 并且要求被代理的类必须实现一个接口, JDK 动态代理的核心是 InvocationHandler 接口和 Proxy 类
-
Spring AOP 中的代理使用的默认策略
- 如果目标对象实现类接口, 则默认采用 JDK 动态代理
- 如果目标对象没有实现接口, 则采用 CGLIB 进行动态代理
-
-
在 AOP 编程中, 我们经常会遇到下面的概念:
- Joinpoint: 连接点, 即定义在应用程序流程的何处插入切面的执行
- Pointcut: 切入点, 即一组连接点的集合
- Advice: 增强, 指特定连接点上执行的动作
- Introduction: 引介, 特殊的增强, 指为一个已有的 Java 对象动态地增加新的接口
- Weaving: 织入, 将增强添加到目标类具体连接点上的过程
- Aspect: 切面: 由切点和增强 (引介) 组成, 包括了对横切关注功能的定义, 已包括了对连接点的定义
IoC
-
控制反转, 是把传统上由程序代码直接操控的对象的调用权交给容器, 由容器来创建对象并管理对象之间的依赖关系, DI 是对 IoC 更准确的描述, 即由容器动态的将某种依赖关系注入到组件之中
-
IoC 的原理
- 实例化后的对象被封装在 BeanWrapper 对象中, 并且此时对象仍然是一个原生的状态, 并没有进行依赖注入
- 紧接着, Spring 根据 BeanDefinition 中的信息进行依赖注入
- 并且通过 BeanWrapper 提供的设置属性的接口完成依赖注入
DI
- 依赖注入的方式有以下几种:
- @Autowired,@Resource
- Setter 方法注入
- p 命名空间和 c 命名空间注入
- 构造器注入
- 自动装配
- @Autowired 和@Resource 区别
- @Autowired 默认按类型装配 (属于 Spring 规范), 如果我们想使用名称装配可以结合@Qualifier 注解进行使用
- @Resource 默认按照名称进行装配 (属于 J 2 EE 规范), 名称可以通过 name 属性进行指定, 而使用 type 属性时则使用 byType 自动注入策略
Bean 作用域
- 在 Spring 的早期版本中仅有两个作用域: singleton 和 prototype, 前者表示 Bean 以单例的方式存在, 后者表示每次从容器中调用 Bean 时, 都会返回一个新的实例
- Spring 2. x 中针对 WebApplicationContext 新增了 3 个作用域,分别是: request (每次 HTTP 请求都会创建一个新的 Bean), session (同一个 HttpSession 共享同一个 Beaan, 不同的 HttpSession 使用不同的 Bean) 和 globalSession (同一个全局 Session 共享一个 Bean)
Bean 的生命周期
- 实例化 Bean
- 对于 ApplicationContext 容器: 容器通过获取 BeanDefinition 对象中的信息进行简单的实例化, 并且这一步仅仅是简单的实例化
- 实例化后的对象被封装在 BeanWrapper 对象中, 并且此时对象仍然是一个原生的状态, 并没有进行依赖注入
- 设置对象属性 (依赖注入):Spring 根据 BeanDefinition 中的信息,并且通过 BeanWrapper 提供的设置属性的接口完成依赖注入
- 注入 Aware 接口:紧接着, Spring 会检测该对象是否实现了 xxxAware 接口, 并将相关的 xxxAware 实例注入给 bean
- 实现 BeanFactoryAware 主要目的是为了获取 Spring 容器, 如 Bean 通过 Spring 容器发布事件等
- 实现 BeanNameAware 清主要是为了通过 Bean 的引用来获得 Bean 的 ID, 一般业务中是很少有用到 Bean 的 ID 的
- 实现 ApplicationContextAware 接口, 作用与 BeanFactory 类似都是为了获取 Spring 容器
- BeanPostProcessor
- 当经过上述几个步骤后, bean 对象已经被正确构造, 但如果你想要对象被使用前再进行一些自定义的处理, 就可以通过 BeanPostProcessor 接口实现
postProcessBeforeInitialzation ( Object bean, String beanName )
: 当前正在初始化的 bean 对象会被传递进来, 我们就可以对这个 bean 作任何处理, 这个函数会先于 InitialzationBean 执行, 因此称为前置处理, 所有 Aware 接口的注入就是在这一步完成的
- InitializingBean 与 init-method
- 当 BeanPostProcessor 的前置处理完成后就会进入本阶段, InitializingBean 接口只有一个函数:
afterPropertiesSet ()
,这一阶段也可以在 bean 正式构造完成前增加我们自定义的逻辑, 但它与前置处理不同, 由于该函数并不会把当前 bean 对象传进来, 因此在这一步没办法处理对象本身, 只能增加一些额外的逻辑 - 若要使用它, 我们需要让 bean 实现该接口, 并把要增加的逻辑写在该函数中, 然后 Spring 会在前置处理完成后检测当前 bean 是否实现了该接口, 并执行 afterPropertiesSet 函数,当然, Spring 为了降低对代码的侵入性, 给 bean 的配置提供了 init-method 属性, 该属性指定了在这一阶段需要执行的函数名, Spring 便会在初始化阶段执行我们设置的函数, init-method 本质上仍然使用了 InitializingBean 接口
- 当 BeanPostProcessor 的前置处理完成后就会进入本阶段, InitializingBean 接口只有一个函数:
- BeanPostProcessor
- 与前置处理类似, 如果需要在初始化之后执行一些自定义的处理, 就可以通过 BeanPostProcessor 接口实现
postProcessAfterInitialzation ( Object bean, String beanName )
当前正在初始化的 bean 对象会被传递进来, 我们就可以对这个 bean 作任何处理, 这个函数会在 InitialzationBean 完成后执行, 因此称为后置处理
- DisposableBean 和 destroy-method
- 和 init-method 一样, 通过给 destroy-method 指定函数, 就可以在 bean 销毁前执行指定的逻辑
Spring ApplicationContext 容器
- Application Context : 是 BeanFactory 的子类, 因为古老的 BeanFactory 无法满足不断更新的 spring 的需求, 于是 ApplicationContext 就基本上代替了 BeanFactory 的工作, 它可以加载配置文件中定义的 bean, 将所有的 bean 集中在一起, 当有请求的时候分配 bean
- 最常被使用的 ApplicationContext 接口实现:
- FileSystemXmlApplicationContext: 该容器从 XML 文件中加载已被定义的 bean, 在这里, 你需要提供给构造器 XML 文件的完整路径
- ClassPathXmlApplicationContext: 该容器从 XML 文件中加载已被定义的 bean, 在这里, 你不需要提供 XML 文件的完整路径, 只需正确配置 CLASSPATH 环境变量即可, 因为, 容器会从 CLASSPATH 中搜索 bean 配置文件
- WebXmlApplicationContext: 该容器会在一个 web 应用程序的范围内加载在 XML 文件中已被定义的 bean
事务
- 事务属性: 事务的一些基本配置, 描述了事务策略如何应用到方法上, 事务属性包含了 5 个方面: 传播行为, 隔离规则, 回滚规则, 事务超时, 是否只读
- 隔离级别: 数据库默认, 读未提交, 读已提交, 可重复读, 序列化
- 事务传播行为: 当事务方法被另一个事务方法调用时, 必须指定事务应该如何传播, Spring 定义了七种传播行为
- 默认的事务传播行为是
PROPAGATION_REQUIRED
, 它适合于绝大多数的情况: 如果当前没有事务, 就新建一个事务, 如果已经存在一个事务, 则加入到这个事务中
- 默认的事务传播行为是
Spring MVC 的执行流程
DispatcherServlet
表示前置控制器, 是整个 Spring MVC 的控制中心, 用户发出请求,DispatcherServlet
接收请求并拦截请求HandlerMapping
为处理器映射,DispatcherServlet
调用HandlerMapping
,HandlerMapping
根据请求 url 查找Handler
HandlerExecution
表示具体的 Handler, 其主要作用是根据 url 查找 ControllerHandlerExecution
将解析后的信息传递给DispatcherServlet
, 如解析 Controller 映射等HandlerAdapter
表示处理器适配器, 其按照特定的规则去执行Handler
Handler
让具体的 Controller 执行- Controller 将具体的执行信息返回给
HandlerAdapter
, 如 ModelAndView HandlerAdapter
将逻辑视图或模型传递给DispatcherServlet
DispatcherServlet
调用ViewResolver
将逻辑视图解析为真实视图对象ViewResolver
将解析的真实视图对象返回DispatcherServlet
DispatcherServlet
利用是土地向对模型数据进行渲染- 最终视图呈现给客户端
过滤器和拦截器
- 拦截器是基于 java 的反射机制的, 而过滤器是基于函数回调
- 拦截器不依赖 servlet 容器, 过滤器依赖 servlet 容器
- 拦截器只能对 action 请求起作用, 而过滤器则可以对几乎所有的请求起作用
- 拦截器可以访问 action 上下文, 值栈里的对象, 而过滤器不能访问
- 在 action 的生命周期中, 拦截器可以多次被调用, 而过滤器只能在容器初始化时被调用一次
- 拦截器可以获取 IOC 容器中的各个 bean, 而过滤器就不行, 这点很重要, 在拦截器里注入一个 service, 可以调用业务逻辑
Spring MVC 拦截器的执行顺序
- preHandler
- 调用时间: Controller 方法处理之前
- 执行顺序: 链式 Intercepter 情况下, Intercepter 按照声明的顺序一个接一个执行
- 注意事项: 若返回 false, 则中断执行, 不会进入 afterCompletion
- postHandler
- 调用前提: preHandle 返回 true
- 调用时间: Controller 方法处理完之后, DispatcherServlet 进行视图的渲染之前, 也就是说在这个方法中你可以对 ModelAndView 进行操作
- 执行顺序: 链式 Intercepter 情况下, Intercepter 按照声明的顺序倒着执行
- afterCompletion
- 调用前提: preHandle 返回 true
- 调用时间: DispatcherServlet 进行视图的渲染之后
- 注意事项: 多用于清理资源
Spring Security
过滤器
- Spring Security 基本都是通过过滤器来完成配置的身份认证, 权限认证以及登出
- Spring Security 在 Servlet 的过滤链 (filter chain) 中注册了一个过滤器
FilterChainProxy
, 它会把请求代理到 Spring Security 自己维护的多个过滤链, 每个过滤链会匹配一些 URL, 如果匹配则执行对应的过滤器, 过滤链是有顺序的, 一个请求只会执行第一条匹配的过滤链, Spring Security 的配置本质上就是新增, 删除, 修改过滤器 - 默认情况下系统帮我们注入的这 15 个过滤器, 分别对应配置不同的需求, 例如
UsernamePasswordAuthenticationFilter
是用来使用用户名和密码登录认证的过滤器, 但是很多情况下登录不止是简单的用户名和密码, 又可能是用到第三方授权登录, 这个时候我们就需要使用自定义过滤器
核心类
- SecurityContextHolder:
SecurityContextHolder
存储SecurityContext
对象,SecurityContextHolder
是一个存储代理, 有三种存储模式分别是:MODE_THREADLOCAL:SecurityContext
: 存储在线程中MODE_INHERITABLETHREADLOCAL
:SecurityContext
存储在线程中, 但子线程可以获取到父线程中的SecurityContext
MODE_GLOBAL
:SecurityContext
在所有线程中都相同SecurityContextHolder
默认使用 MODE_THREADLOCAL 模式,SecurityContext
存储在当前线程中, 调用SecurityContextHolder
时不需要显示的参数传递, 在当前线程中可以直接获取到SecurityContextHolder
对象
- Authentication:
Authentication
即验证, 表明当前用户是谁, 什么是验证, 比如一组用户名和密码就是验证, 当然错误的用户名和密码也是验证, 只不过 Spring Security 会校验失败 - AuthenticationManager/ProviderManager/AuthenticationProvider
- 其实这三者很好区分,
AuthenticationManager
主要就是为了完成身份认证流程,ProviderManager
是AuthenticationManager
接口的具体实现类,ProviderManager
里面有个记录AuthenticationProvider
对象的集合属性providers
- 接下来就是遍历
ProviderManager
里面的providers
集合, 找到和合适的AuthenticationProvider
完成身份认证
- 其实这三者很好区分,
- UserDetailsService/UserDetails: 在
UserDetailsService
接口中只有一个简单的方法
身份认证流程
- 在运行到
UsernamePasswordAuthenticationFilter
过滤器的时候首先是进入其父类AbstractAuthenticationProcessingFilter
的doFilter ()
方法中- 判断请求的 url 是否与配置的一致
- 调用子类的
attemptAuthentication ()
方法 - 根据返回值走认证成功或认证失败的 handler (可以配置自定义的 handler)
AbstractAuthenticationProcessingFilter
调用UsernamePasswordAuthenticationFilter
的attemptAuthentication ()
方法完成身份认证- 从 request 中拿到 username 和 password
- 将 username 与 password 封装成一个
UsernamePasswordAuthenticationToken
对象并传入对应 provider 的authenticate ()
方法中
- 通过
AuthenticationManager
接口实现类ProviderManager
来遍历得到 Provider, 调用对应的authenticate ()
方法- Provider 可自定义配置注入
- 调用 Provider 的
authenticate ()
方法, 并返回Authentication
对象- 其中可调用
UserDetailsService
的loadUserByUsername ()
方法返回UserDetails
对象 - 根据 UserDetails 判断用户的状态, 或者注入其他的属性, 例如权限
- 通过 UserDetails 中的属性组装新的 AuthenticationToken, 并返回
- 其中可调用
Mybatis
MyBatis 核心类
Mybatis 的执行过程
- 读取 MyBatis 的核心配置文件
- 构造 SqlSessionFactoryBuilder 获取 SqlSessionFactory
- SqlSessionFactory 创建会话对象 SqlSession
- 使用 SqlSession 获得 Mapper
- 调用 Mapper 接口中的方法
Mybatis 的 Mapper 只有接口没有实现类却能工作的原因
- 获取已知的加载过的 Mapper 中获取出 MapperProxyFactory, Mapper 代理工厂是通过 Class. forName 反射生成 namespace 的对应接口的反射对象并将生成的对象传入 MapperProxyFactory 的构造函数, 最后存入 knownMappers 集合
- 代理工厂生成动态代理返回, 调用 MapperProxyFactory 的 newInstance 方法封装 InvocationHandler 的实现类 MapperProxy, 最后并返回代理类
命名空间
- 在大型项目中, 可能存在大量 SQL 语句, 这时候为每个 SQL 语句起一个唯一的标识就变得并不容易了, 为了解决这个问题, 在 Mybatis 中, 可以为每个映射文件起一个唯一的命名空间, 这样定义在这个映射文件中的每个 SQL 语句就成了定义在这个命名空间中的一个 ID, 只要我们能保证每个命名空间中的这个 ID 是唯一的, 即使在不同的映射文件中的语句 ID 相同, 也不会再产生冲突了
#{ } 和${ } 的区别
#{}
: 这种方式是使用的预编译的方式, 一个#{} 就是一个占位符, 相当于 jdbc 的占位符 PrepareStatement, 设置值的时候会加上引号${}
: 这种方式是直接拼接的方式, 不对数值做预编译, 存在 sql 注入的现象, 设置值的时候不会加上引号
缓存
- Mybatis 中一级缓存是默认开启的, 二级缓存默认是不开启的, 一级缓存是对于一个 sqlSeesion 而言, 而二级缓存是对于一个 nameSpace 而言, 可以多个 SqlSession 共享
- 查出的数据都会被默认先放在一级缓存中, 只有会话提交或者关闭以后, 一级缓存中的数据才会转到二级缓存中
数据结构
红黑树
- 平衡二叉树 (AVL) 为了追求高度平衡, 需要通过平衡处理使得左右子树的高度差必须小于等于 1, 高度平衡带来的好处是能够提供更高的搜索效率, 其最坏的查找时间复杂度都是 O (logN), 但是由于需要维持这份高度平衡, 所付出的代价就是当对树种结点进行插入和删除时, 需要经过多次旋转实现复衡, 这导致 AVL 的插入和删除效率并不高, 而红黑树能够兼顾搜索和插入删除的效率
- 性质
- 每个结点要么是红的要么是黑的
- 根结点是黑的
- 父子节点之间不能出现两个连续的红节点, 即如果一个结点是红的, 那么它的两个子节点都是黑的
- 对于任意结点而言, 其到叶结点树尾端 NIL 指针的每条路径都包含相同数目的黑结点
- 每个叶结点 (叶结点即指树尾端 NIL 指针或 NULL 结点) 都是黑的
- 红黑树通过将结点进行红黑着色, 使得原本高度平衡的树结构被稍微打乱, 平衡程度降低, 红黑树不追求完全平衡, 只要求达到部分平衡, 这是一种折中的方案, 大大提高了结点删除和插入的效率
BitMap
- BitMap 算法的核心思想是用 bit 数组来记录 0-1 两种状态,然后再将具体数据映射到这个比特数组的具体位置,这个比特位设置成 0 表示数据不存在,设置成 1 表示数据存在。
- BitMap 算在在大量数据查询、去重等应用场景中使用的比较多,这个算法具有比较高的空间利用率
算法
查找算法
二分查找
- 二分查找是一种在有序数组中查找某一特定元素的搜索算法
- 搜素过程从数组的中间元素开始,如果中间元素正好是要查找的元素, 则搜素过程结束,如果某一特定元素大于或者小于中间元素, 则在数组大于或小于中间元素的那一半中查找, 而且跟开始一样从中间元素开始比较
1 |
|
排序算法
冒泡排序
- 冒泡排序是一种简单的排序算法, 它重复地走访过要排序的数列, 一次比较两个元素, 如果它们的顺序错误就把它们交换过来, 走访数列的工作是重复地进行直到没有再需要交换, 也就是说该数列已经排序完成, 这个算法的名字由来是因为越小的元素会经由交换慢慢浮到数列的顶端
- 算法描述
- 比较相邻的元素, 如果第一个比第二个大, 就交换它们两个
- 对每一对相邻元素作同样的工作, 从开始第一对到结尾的最后一对, 这样在最后的元素应该会是最大的数
- 针对所有的元素重复以上的步骤, 除了最后一个
- 重复步骤 1~3, 直到排序完成
1 |
|
- 稳定性: 在相邻元素相等时, 它们并不会交换位置, 所以, 冒泡排序是稳定排序
- 适用场景: 冒泡排序思路简单, 代码也简单, 特别适合小数据的排序, 但是, 由于算法复杂度较高, 在数据量大的时候不适合使用
- 代码优化: 在数据完全有序的时候展现出最优时间复杂度, 为 O (n), 其他情况下, 几乎总是 O ( n2 ), 因此, 算法在数据基本有序的情况下, 性能最好要使算法在最佳情况下有 O (n) 复杂度, 需要做一些改进, 增加一个
swap
的标志, 当前一轮没有进行交换时, 说明数组已经有序, 没有必要再进行下一轮的循环了, 直接退出
1 |
|
选择排序
- 选择排序是一种简单直观的排序算法, 它也是一种交换排序算法, 和冒泡排序有一定的相似度, 可以认为选择排序是冒泡排序的一种改进
- 算法描述
- 在未排序序列中找到最小 (大) 元素, 存放到排序序列的起始位置
- 从剩余未排序元素中继续寻找最小 (大) 元素, 然后放到已排序序列的末尾
- 重复第二步, 直到所有元素均排序完毕
1 |
|
- 稳定性: 用数组实现的选择排序是不稳定的, 用链表实现的选择排序是稳定的, 不过, 一般提到排序算法时, 大家往往会默认是数组实现, 所以选择排序是不稳定的
- 适用场景: 选择排序实现也比较简单, 并且由于在各种情况下复杂度波动小, 因此一般是优于冒泡排序的, 在所有的完全交换排序中, 选择排序也是比较不错的一种算法, 但是, 由于固有的 O (n 2) 复杂度, 选择排序在海量数据面前显得力不从心, 因此, 它适用于简单数据排序
插入排序
- 插入排序是一种简单直观的排序算法, 它的工作原理是通过构建有序序列, 对于未排序数据, 在已排序序列中从后向前扫描, 找到相应位置并插入
- 算法描述
- 把待排序的数组分成已排序和未排序两部分, 初始的时候把第一个元素认为是已排好序的
- 从第二个元素开始, 在已排好序的子数组中寻找到该元素合适的位置并插入该位置
- 重复上述过程直到最后一个元素被插入有序子数组中

1 |
|
- 稳定性: 由于只需要找到不大于当前数的位置而并不需要交换, 因此, 直接插入排序是稳定的排序方法
- 适用场景: 插入排序由于 O ( n2 ) 的复杂度, 在数组较大的时候不适用, 但是, 在数据比较少的时候, 是一个不错的选择, 一般做为快速排序的扩充, 例如, 在 STL 的 sort 算法和 stdlib 的 qsort 算法中, 都将插入排序作为快速排序的补充, 用于少量元素的排序, 又如, 在 JDK 7 的
java. util. Arrays
所用的sort ()
方法的实现中, 当待排数组长度小于 47 时, 会使用插入排序
快速排序
- 快速排序是一个知名度极高的排序算法, 其对于大数据的优秀排序性能和相同复杂度算法中相对简单的实现使它注定得到比其他算法更多的宠爱
- 算法描述
- 从数列中挑出一个元素, 称为基准(pivot)
- 重新排序数列, 所有比基准值小的元素摆放在基准前面, 所有比基准值大的元素摆在基准后面 (相同的数可以到任何一边), 在这个分区结束之后, 该基准就处于数列的中间位置, 这个称为分区 (partition) 操作
- 递归地 (recursively) 把小于基准值元素的子数列和大于基准值元素的子数列排序
1 |
|
- 稳定性: 快速排序并不是稳定的, 这是因为我们无法保证相等的数据按顺序被扫描到和按顺序存放
- 适用场景: 快速排序在大多数情况下都是适用的, 尤其在数据量大的时候性能优越性更加明显, 但是在必要的时候, 需要考虑下优化以提高其在最坏情况下的性能
归并排序
- 归并排序是建立在归并操作上的一种有效的排序算法, 该算法是采用分治法的一个非常典型的应用, 将已有序的子序列合并, 得到完全有序的序列, 即先使每个子序列有序, 再使子序列段间有序, 若将两个有序表合并成一个有序表, 称为 2 路归并
- 算法描述
- 递归法 (Top-down)
- 申请空间, 使其大小为两个已经排序序列之和, 该空间用来存放合并后的序列
- 设定两个指针, 最初位置分别为两个已经排序序列的起始位置
- 比较两个指针所指向的元素, 选择相对小的元素放入到合并空间, 并移动指针到下一位置
- 重复步骤 3 直到某一指针到达序列尾
- 将另一序列剩下的所有元素直接复制到合并序列尾
- 迭代法 (Bottom-up)
- 将序列每相邻两个数字进行归并操作, 形成 ceil (n/2) 个序列, 排序后每个序列包含两/一个元素
- 若此时序列数不是 1 个则将上述序列再次归并, 形成 ceil (n/4) 个序列, 每个序列包含四/三个元素
- 重复步骤 2, 直到所有元素排序完毕, 即序列数为 1
- 递归法 (Top-down)
1 |
|
- 稳定性: 因为我们在遇到相等的数据的时候必然是按顺序抄写到辅助数组上的, 所以, 归并排序同样是稳定算法
- 适用场景: 归并排序在数据量比较大的时候也有较为出色的表现 (效率上), 但是, 其空间复杂度 O (n) 使得在数据量特别大的时候 (例如, 1 千万数据) 几乎不可接受, 而且, 考虑到有的机器内存本身就比较小, 因此, 采用归并排序一定要注意
堆排序
- 基本思想:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余 n-1 个元素重新构造成一个堆,这样会得到 n 个元素的次小值。如此反复执行,便能得到一个有序序列了
- 算法描述
- 将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆
- 将堆顶元素与末尾元素交换,将最大元素沉到数组末端
- 重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序

1 |
|
- 稳定性: 堆排序存在大量的筛选和移动过程, 属于不稳定的排序算法
- 适用场景: 堆排序在建立堆和调整堆的过程中会产生比较大的开销, 在元素少的时候并不适用, 但是, 在元素比较多的情况下, 还是不错的一个选择, 尤其是在解决诸如"前 n 大的数”一类问题时, 几乎是首选算法
希尔排序
- 在希尔排序出现之前, 计算机界普遍存在排序算法不可能突破 O (n2) 的观点, 希尔排序是第一个突破 O (n2) 的排序算法, 它是简单插入排序的改进版, 希尔排序的提出, 主要基于以下两点:
- 插入排序算法在数组基本有序的情况下, 可以近似达到 O (n) 复杂度, 效率极高
- 但插入排序每次只能将数据移动一位, 在数组较大且基本无序的情况下性能会迅速恶化
- 算法描述: 先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序, 具体算法描述:
- 选择一个增量序列
t 1, t 2,..., tk
, 其中ti>tj
,tk=1
- 按增量序列个数 k, 对序列进行 k 趟排序
- 每趟排序, 根据对应的增量 ti, 将待排序列分割成若干长度为 m 的子序列, 分别对各子表进行直接插入排序, 仅增量因子为 1 时, 整个序列作为一个表来处理, 表长度即为整个序列的长度
- 选择一个增量序列
- Donald Shell 增量
1 |
|
- O (n3/2) by Knuth
1 |
|
- 希尔排序的增量: 希尔排序的增量数列可以任取, 需要的唯一条件是最后一个一定为 1 (因为要保证按 1 有序), 但是, 不同的数列选取会对算法的性能造成极大的影响, 上面的代码演示了两种增量
- 注意: 增量序列中每两个元素最好不要出现 1 以外的公因子 (很显然, 按 4 有序的数列再去按 2 排序意义并不大), 下面是一些常见的增量序列
- 第一种增量是最初 Donald Shell 提出的增量, 即折半降低直到 1, 据研究, 使用希尔增量, 其时间复杂度还是 O (n2)
- 第二种增量 Hibbard:{1, 3, …, 2 k-1}, 该增量序列的时间复杂度大约是 O (n3/2)
- 第三种增量 Sedgewick 增量: (1, 5, 19, 41, 109,…), 其生成序列或者是
9*4 i* *- 9*2 i + 1
或者是4 i - 3*2 i + 1
- 稳定性: 我们都知道插入排序是稳定算法, 但是, Shell 排序是一个多次插入的过程, 在一次插入中我们能确保不移动相同元素的顺序, 但在多次的插入中, 相同元素完全有可能在不同的插入轮次被移动, 最后稳定性被破坏, 因此, Shell 排序不是一个稳定的算法
- 适用场景: Shell 排序虽然快, 但是毕竟是插入排序, 其数量级并没有快速排序 O (nLogN) 快, 在大量数据面前, Shell 排序不是一个好的算法, 但是, 中小型规模的数据完全可以使用它
计数排序
- 计数排序不是基于比较的排序算法, 其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中, 作为一种线性时间复杂度的排序, 计数排序要求输入的数据必须是有确定范围的整数
- 算法描述
- 找出待排序的数组中最大和最小的元素
- 统计数组中每个值为 i 的元素出现的次数, 存入数组 C 的第 i 项
- 对所有的计数累加 (从 C 中的第一个元素开始, 每一项和前一项相加)
- 反向填充目标数组: 将每个元素 i 放在新数组的第 C (i) 项, 每放一个元素就将 C (i) 减去 1
1 |
|
- 稳定性: 最后给 b 数组赋值是倒着遍历的, 而且放进去一个就将 C 数组对应的值 (表示前面有多少元素小于或等于 A[i]) 减去一, 如果有相同的数 x 1, x 2, 那么相对位置后面那个元素 x 2 放在 (比如下标为 4 的位置), 相对位置前面那个元素 x 1 下次进循环就会被放在 x 2 前面的位置 3, 从而保证了稳定性
- 适用场景: 排序目标要能够映射到整数域, 其最大值最小值应当容易辨别, 例如高中生考试的总分数, 显然用 0-750 就 OK 啦, 又比如一群人的年龄, 用个 0-150 应该就可以了, 再不济就用 0-200 喽, 另外, 计数排序需要占用大量空间, 它比较适用于数据比较集中的情况
桶排序
- 桶排序又叫箱排序, 是计数排序的升级版, 它的工作原理是将数组分到有限数量的桶子里, 然后对每个桶子再分别排序 (有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序), 最后将各个桶中的数据有序的合并起来
- 计数排序是桶排序的一种特殊情况, 可以把计数排序当成每个桶里只有一个元素的情况
- 算法描述
- 找出待排序数组中的最大值 max, 最小值 min
- 我们使用动态数组 ArrayList 作为桶, 桶里放的元素也用 ArrayList 存储, 桶的数量为 (max-min)/arr. length+1
- 遍历数组 arr, 计算每个元素 arr[i] 放的桶
- 每个桶各自排序
- 遍历桶数组, 把排序好的元素放进输出数组

1 |
|
- 稳定性: 可以看出, 在分桶和从桶依次输出的过程是稳定的, 但是, 由于我们在对每个桶进行排序时使用了其他算法, 所以, 桶排序的稳定性依赖于这一步, 如果我们使用了快排, 显然, 算法是不稳定的
- 适用场景: 桶排序可用于最大最小值相差较大的数据情况, 但桶排序要求数据的分布必须均匀, 否则可能导致数据都集中到一个桶中, 比如[104,150,123,132,20000], 这种数据会导致前 4 个数都集中到同一个桶中, 导致桶排序失效
基数排序
- 基数排序 (Radix Sort) 是桶排序的扩展, 它的基本思想是: 将整数按位数切割成不同的数字, 然后按每个位数分别比较排序过程: 将所有待比较数值 (正整数) 统一为同样的数位长度, 数位较短的数前面补零, 然后, 从最低位开始, 依次进行一次排序, 这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列
- 算法描述
- 取得数组中的最大数, 并取得位数
- arr 为原始数组, 从最低位开始取每个位组成 radix 数组
- 对 radix 进行计数排序 (利用计数排序适用于小范围数的特点)

算法实现
1 |
|
- 稳定性: 通过上面的排序过程, 我们可以看到, 每一轮映射和收集操作, 都保持从左到右的顺序进行, 如果出现相同的元素, 则保持他们在原始数组中的顺序, 可见, 基数排序是一种稳定的排序
- 适用场景: 基数排序要求较高, 元素必须是整数, 整数时长度 10 W 以上, 最大值 100 W 以下效率较好, 但是基数排序比其他排序好在可以适用字符串, 或者其他需要根据多个条件进行排序的场景, 例如日期, 先排序日, 再排序月, 最后排序年, 其它排序算法可是做不了的
总结
排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O (n²) | O (n²) | O (n) | O (1) | 稳定 |
直接选择排序 | O (n²) | O (n²) | O (n) | O (1) | 不稳定 |
直接插入排序 | O (n²) | O (n²) | O (n) | O (1) | 稳定 |
快速排序 | O (nlogn) | O (n²) | O (nlogn) | O (nlogn) | 不稳定 |
归并排序 | O (nlogn) | O (nlogn) | O (nlogn) | O (n) | 稳定 |
堆排序 | O (nlogn) | O (nlogn) | O (nlogn) | O (1) | 不稳定 |
希尔排序 | O (nlogn) | O (n) | O (n) | O (1) | 不稳定 |
计数排序 | O (n+k) | O (n+k) | O (n+k) | O (n+k) | 稳定 |
基数排序 | O (N*M) | O (N*M) | O (N*M) | O (M) | 稳定 |
二叉树遍历
- 前序遍历: 根结点 —> 左子树 —> 右子树
- 中序遍历: 左子树—> 根结点 —> 右子树
- 后序遍历: 左子树 —> 右子树 —> 根结点
- 层次遍历: 只需按层次遍历即可
前序遍历
- 递归实现
1 |
|
- 非递归的实现
1 |
|
中序遍历
- 递归实现
1 |
|
- 非递归实现
1 |
|
后序遍历
- 递归实现
1 |
|
- 非递归实现
1 |
|
层次遍历
- 层次遍历的代码比较简单, 只需要一个队列即可, 先在队列中加入根结点, 之后对于任意一个结点来说, 在其出队列的时候, 访问之, 同时如果左孩子和右孩子有不为空的, 入队列
1 |
|
为什么先序中序可以决定一颗树
- 前序和后序在本质上都是将父节点与子结点进行分离, 但并没有指明左子树和右子树的能力, 因此得到这两个序列只能明确父子关系, 而不能确定一个二叉树
操作系统
原码/反码/补码
- 为了简化计算机集成电路的设计, 根据运算法则减去一个正数等于加上一个负数, 即: 1-1 = 1 + (-1) = 0 , 所以机器可以只有加法而没有减法, 这样计算机运算的设计就更简单了
- 如果用原码表示, 让符号位也参与计算, 显然对于减法来说, 结果是不正确的, 这也就是为何计算机内部不使用原码表示一个数
- 为了解决原码做减法的问题, 出现了反码, 发现用反码计算减法, 结果的真值部分是正确的, 而唯一的问题其实就出现在"0"这个特殊的数值上, 虽然人们理解上+0 和-0 是一样的, 但是 0 带符号是没有任何意义的, 而且会有[0000 0000] 原和[1000 0000] 原两个编码表示 0
- 于是补码的出现, 解决了 0 的符号以及两个编码的问题, 使用补码, 不仅仅修复了 0 的符号以及存在两个编码的问题, 而且还能够多表示一个最低数, 这就是为什么 8 位二进制, 使用原码或反码表示的范围为[-127, +127], 而使用补码表示的范围为[-128, 127]
进程管理
进程与线程
- 进程是操作系统资源分配的基本单位, 是程序关于某个数据集合上的一次运行活动
- 线程是进程的一个实体, 是 CPU 调度的基本单位, 一个进程可以包含多个线程, 它可与同属一个进程的其他的线程共享进程所拥有的全部资源, 线程自己基本上不拥有系统资源, 只拥有一点在运行中必不可少的资源 (如程序计数器, 一组寄存器和栈)
- 进程之间的切换会有较大的开销而线程之间切换的开销小
- 每当切换进程时, 必须要考虑保存当前进程的状态, 状态包括存放在内存中的程序的代码和数据, 它的栈, 通用目的寄存器的内容, 程序计数器, 环境变量以及打开的文件描述符的集合, 这个状态叫做上下文 (Context)
- 同样线程有自己的上下文, 包括唯一的整数线程 ID, 栈, 栈指针, 程序计数器, 通用目的寄存器和条件码, 可以理解为线程上下文是进程上下文的子集
上下文切换
- 对于单核单线程 CPU 而言, 在某一时刻只能执行一条 CPU 指令, 上下文切换 (Context Switch) 是一种将 CPU 资源从一个进程分配给另一个进程的机制
- 在切换的过程中, 操作系统需要先存储当前进程的状态 (包括内存空间的指针, 当前执行完的指令等等), 再读入下一个进程的状态, 然后执行此进程
- 从用户角度看, 计算机能够并行运行多个进程, 这恰恰是操作系统通过快速上下文切换造成的结果
线程同步
- 互斥量: 采用互斥对象机制, 只有拥有互斥对象的线程才有访问公共资源的权限, 因为互斥对象只有一个, 所以可以保证公共资源不会被多个线程同时访问
- 信号量: 它允许同一时刻多个线程访问同一资源, 但是需要控制同一时刻访问此资源的最大线程数量
- 事件 (信号): 通过通知操作的方式来保持多线程同步, 还可以方便的实现多线程优先级的比较操作
进程同步
- 进程间同步的主要方法有原子操作, 信号量机制, 自旋锁, 管程, 会合, 分布式系统等
线程间的通信方式
- 锁机制: 包括互斥锁, 条件变量, 读写锁
- 互斥锁提供了以排他方式防止数据结构被并发修改的方法
- 读写锁允许多个线程同时读共享数据, 而对写操作是互斥的
- 条件变量可以以原子的方式阻塞进程, 直到某个特定条件为真为止, 对条件的试是在互斥锁的保护下进行的, 条件变量始终与互斥锁一起使用
- 信号量机制 (Semaphore): 包括无名线程信号量和命名线程信号量
- 信号机制 (Signal): 类似进程间的信号处理
进程间的通信方式
- 管道 ( pipe ): 管道是一种半双工的通信方式, 数据只能单向流动, 而且只能在具有亲缘关系的进程间使用, 进程的亲缘关系通常是指父子进程关系
- 有名管道 (namedpipe) : 有名管道也是半双工的通信方式, 但是它允许无亲缘关系进程间的通信
- 信号量 (semophore ): 信号量是一个计数器, 可以用来控制多个进程对共享资源的访问, 它常作为一种锁机制, 防止某进程正在访问共享资源时, 其他进程也访问该资源, 因此, 主要作为进程间以及同一进程内不同线程之间的同步手段
- 消息队列 ( messagequeue ) : 消息队列是由消息的链表, 存放在内核中并由消息队列标识符标识, 消息队列克服了信号传递信息少, 管道只能承载无格式字节流以及缓冲区大小受限等缺点
- 信号 (signal ) : 信号是一种比较复杂的通信方式, 用于通知接收进程某个事件已经发生
- 共享内存 (shared memory ) : 共享内存就是映射一段能被其他进程所访问的内存, 这段共享内存由一个进程创建, 但多个进程都可以访问, 共享内存是最快的 IPC 方式, 它是针对其他进程间通信方式运行效率低而专门设计的, 它往往与其他通信机制, 如信号量, 配合使用, 来实现进程间的同步和通信
- 套接字 (socket ) : 套接口也是一种进程间通信机制, 与其他通信机制不同的是, 它可用于不同及其间的进程通信
进程的状态
- 创建状态:为一个新进程创建 PCB, 并填写必要的管理信息,把该进程转入就绪状态并插入就绪队列之中
- 就绪状态:进程已获得除处理机以外的所需资源, 等待分配处理机资源
- 运行状态:占用处理机资源运行, 处于此状态的进程数小于等于 CPU 数
- 阻塞状态:进程等待某种条件 (例如 IO 操作), 在条件满足之前无法执行
- 终止状态:等待操作系统进行善后处理, 然后将其 PCB 清零, 并将 PCB 空间返还系统
处理机调度
调度算法
- 调度本质上就是一种资源分配, 饥饿指某个进程一直在等待, 得不到处理
- 调度算法的分类
- 抢占式(当前进程可以被抢): 可以暂停某个正在执行的进程, 将处理及重新分配给其他进程
- 非抢占式(当前进程不能被抢走): 一旦处理及分配给了某个进程, 他就一直运行下去, 直到结束
- 高级调度(作业调度/长程调度)(频率低): 将外存作业调入内存
- 低级调度(进程调度/短程调度)(频率高): 决定就绪队列中哪个进程获得处理机并执行
进程调度
- 先来先服务 (FCFS): 按照到达顺序, 非抢占式, 不会饥饿
- 短作业/进程优先 (SJF): 抢占/非抢占, 会饥饿
- 高响应比优先 (HRRN): 综合考虑等待时间和要求服务时间计算一个优先权, 非抢占, 不会饥饿
- 时间片轮转 (RR): 轮流为每个进程服务, 抢占式, 不会饥饿
- 优先级: 根据优先级, 抢占/非抢占, 会饥饿
- 多级反馈队列:
- 设置多个就绪队列, 每个队列的进程按照先来先服务排队, 然后按照时间片轮转分配时间片
- 若时间片用完还没有完成, 则进入下一级队尾, 只有当前队列为空时, 才会为下一级队列分配时间片
- 抢占式, 可能会饥饿
作业调度
- 先来先服务调度算法
- 短作业优先调度算法
- 优先级调度算法
中断和轮询
- 轮询:
- 轮询 (Polling) I/O 方式或程序控制 I/O 方式, 是让 CPU 以一定的周期按次序查询每一个外设, 看它是否有数据输入或输出的要求, 若有, 则进行相应的输入/输出服务, 若无, 或 I/O 处理完完毕, CPU 就接着查询下一个外设
- 效率低, 等待时间很长, CPU 利用率不高
- 中断:
- 程序中断通常简称中断, 是指 CPU 在正常运行程序的过程中, 由于预选安排或发生了各种随机的内部或外部事件, 使 CPU 中断正在运行的程序, 而转到为相应的服务程序去处理, 这个过程称为程序中断
- 提高 CPU 的效率, 只有当服务对象向 CPU 发出中断申请时才去为它服务, 这样, 就可以利用中断功能同时为多个对象服务, 从而大大提高了 CPU 的工作效率
并发与并行
- 并行是指两个或者多个事件在同一时刻发生, 而并发是指两个或多个事件在同一时间间隔发生
- 并发: 一个处理器同时处理多个任务
- 并行: 多个处理器或者是多核的处理器同时处理多个不同的任务
同步和异步
- 同步和异步关注的是消息通信机制, 所谓同步, 就是在发出一个调用时, 在没有得到结果之前, 该调用就不返回, 但是一旦调用返回, 就得到返回值了, 换句话说, 就是由调用者主动等待这个调用的结果
- 而异步则是相反, 调用在发出之后, 这个调用就直接返回了, 所以没有返回结果, 换句话说, 当一个异步过程调用发出后, 调用者不会立刻得到结果, 而是在调用发出后, 被调用者通过状态, 通知机制来通知调用者, 或通过回调函数处理这个调用
阻塞与非阻塞
- 阻塞与非阻塞关注的是程序在等待调用结果 (消息, 返回值) 时的状态
- 阻塞调用时指调用结果返回之前, 当前线程被挂起, 调用线程只有在得到结果之后才会返回
- 非阻塞调用时指在不能立刻得到结果之前, 该调用不会阻塞当前线程
死锁
- 两个或多个进程被无限期地阻塞, 相互等待的一种状态
- 互斥: 一个资源每次只能被一个进程使用
- 请求与保持: 一个进程因请求资源而阻塞时, 对已获得的资源保持不放
- 不可剥夺: 进程已获得的资源, 在末使用完之前, 不能强行剥夺
- 循环等待: 若干进程之间形成一种头尾相接的循环等待资源关系
- 这四个条件是死锁的必要条件, 只要系统发生死锁, 这些条件必然成立, 而只要上述条件之一不成立, 则死锁解除
临界区
- 每个进程中访问临界资源的那段程序称为临界区, 每次只准许一个进程进入临界区, 进入后不允许其他进程进入
- 任何时候,处于临界区内的进程不可多于一个, 如已有进程进入自己的临界区, 则其它所有试图进入临界区的进程必须等待
- 进入临界区的进程要在有限时间内退出, 以便其它进程能及时进入自己的临界区
- 如果进程不能进入自己的临界区, 则应让出 CPU, 避免进程出现"忙等”现象
用户态和内核态
- 由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, CPU 划分出两个权限等级 –用户态 和 内核态
- 内核态: CPU 可以访问内存所有数据, 包括外围设备, 例如硬盘, 网卡. CPU 也可以将自己从一个程序切换到另一个程序
- 用户态: 只能受限的访问内存, 且不允许访问外围设备, 占用 CPU 的能力被剥夺, CPU 资源可以被其他程序获取
- 用户态与内核态的切换: 所有用户程序都是运行在用户态的, 但是有时候程序确实需要做一些内核态的事情, 例如从硬盘读取数据, 或者从键盘获取输入等, 这是需要切换至内核态
- 系统调用: 这是处于用户态的进程主动请求切换到内核态的一种方式, 用户态的进程通过系统调用申请使用操作系统提供的系统调用服务例程来处理任务, 在 CPU 中的实现称之为陷阱指令(Trap Instruction)
- 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈 (stack frame), 以此表明需要操作系统提供的服务
- 用户态程序执行陷阱指令
- CPU 切换到内核态, 并跳到位于内存指定位置的指令, 这些指令是操作系统的一部分, 他们具有内存保护, 不可被用户态程序访问
- 这些指令称之为陷阱 (trap) 或者系统调用处理器 (system call handler). 他们会读取程序放入内存的数据参数, 并执行程序请求的服务
- 系统调用完成后, 操作系统会重置 CPU 为用户态并返回系统调用的结果
内存管理
虚拟内存
- 每个进程拥有独立的地址空间, 这个空间被分为大小相等的多个块, 称为页 (Page), 每个页都是一段连续的地址, 这些页被映射到物理内存, 但并不是所有的页都必须在内存中才能运行程序
- 对于进程而言, 逻辑上似乎有很大的内存空间, 实际上其中一部分对应物理内存上的一块 (称为帧, 通常页和帧大小相等), 还有一些没加载在内存中的对应在硬盘上, 注意, 请求分页系统, 请求分段系统和请求段页式系统都是针对虚拟内存的, 通过请求实现内存与外存的信息置换
- 页表实际上存储在 CPU 的内存管理单元(MMU) 中, 于是 CPU 就可以直接通过 MMU, 找出要实际要访问的物理内存地址, 而当进程访问的虚拟地址在页表中查不到时, 系统会产生一个缺页异常, 进入系统内核空间分配物理内存, 更新进程页表, 最后再返回用户空间, 恢复进程的运行
页面置换算法
- 如果内存空间不够, 操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉, 也就是暂时写在硬盘上, 称为换出(Swap Out), 一旦需要的时候, 再加载进来, 称为换入(Swap In), 所以, 一次性写入磁盘的也只有少数的一个页或者几个页, 不会花太多时间,内存交换的效率就相对比较高,
- FIFO 先进先出算法: 将先进入的页面置换出去
- LRU (Least recently use) 最近最少使用算法: 选择未使用时间最长的页面置换出去
- LFU (Least frequently use) 最少使用次数算法: 将一段时间内使用次数最少页面置换出去
- OPT (Optimal replacement) 最优置换算法: 理论的最优, 将实际内存中最晚使用的页面置换出去
内存碎片
- 内部碎片: 给一个进程分配一块空间, 这块空间没有用完的部分叫做内部碎片
- 外部碎片: 给每个进程分配空间以后, 内存中会存在一些区域由于太小而无法利用的空间, 叫做外部碎片
连续内存分配方式
- 概念: 连续分配为用户分配一个连续的内存空间, 比如某个作业需要 100 mb 的内存空间, 就为这个作业在内存中划分一个 100 mb 的内存空间
单一连续分配
- 分配方法: 将内存去划分为系统区域用户区, 系统区为操作系统使用, 剩下的用户区给一个进程或作业使用
- 特点: 操作简单, 没有外部碎片, 适合单道处理系统, 但是会有大量的内部碎片浪费资源, 存储效率低
分区式存储管理
固定分区分配
- 分配方法: 将内存划分成若干个固定大小的块, 分区大小可以相等也可不相等 (划分之后不再改变), 根据程序的大小, 分配当前空闲的, 适当大小的分区
- 特点: 固定分区分配虽然没有外部碎片, 但是会造成大量的内部碎片, 分区大小相等缺乏灵活性, 大的进程可能放不进去, 分区大小不等可能会造成大量的内部碎片, 利用率极低
动态分区分配
- 分配方法: 不会先划分内存区域, 当进程进入内存的时候才会根据进程大小动态的为其建立分区, 使分区大小刚好适合进程的需要
- 特点: 随着进程的消亡, 会出现很多成段的内存空间, 时间越来越长就会导致很多不可利用的外部碎片, 降低内存的利用率, 这时需要分配算法来解决
分配算法
- 首次适应算法: 进程进入内存之后从头开始查找第一个适合自己大小的分区, 空间分区就是按照地址递增的顺序排列, 算法开销小, 回收后放到原位置就好, 综合看这个算法性能最好
- 最佳适应算法: 将分区从从小到大排列 (容量递增), 找到最适合自己的分区, 这样会有更大的分区被保留下来, 满足别的进程需要, 但是算法开销大, 每次进程消亡产生新的区域后要重新排序, 并且当使用多次后会产生很多外部碎片
- 最坏适应算法: 将分区从从大到小排列 (容量递减), 进程每次都找最大的区域来使用, 可以减少难以利用的外部碎片, 但是大分区很快就被用完了, 大的进程可能会有饥饿的现象, 算法开销也比较大
- 邻近适应算法: 空间分区按照地址递增的顺序进行排列, 是由首次适应演变而来, 进程每次寻找空间, 从上一次查找的地址以后开始查找 (不同于首次适应, 首次适应每次从开头查找), 算法开销小, 大的分区也会很快被用完
可重定位分区分配
- 分区式存储管理常采用的一项技术就是内存紧缩 (compaction): 将各个占用分区向内存一端移动, 然后将各个空闲分区合并成为一个空闲分区, 这种技术在提供了某种程度上的灵活性的同时, 也存在着一些弊端, 例如: 对占用分区进行内存数据搬移占用 CPU 时间, 如果对占用分区中的程序进行"浮动”, 则其重定位需要硬件支持
- 由于若干次内存分配与回收之后, 各个空闲的内存块不连续了, 通过"重定位”将已经分配的内存"紧凑”在一块, 从而空出一大块空闲的内存,"紧凑”是需要开销的, 比如需要重新计算地址
- 而离散分配方式—> 不管是分页还是分段, 都是直接将程序放到各个离散的页中, 从而就不存在"紧凑”一说
非连续分配方式
分段管理
- 段式存储管理是一种符合用户视角的内存分配管理方案, 在段式存储管理中, 将程序的地址空间划分为若干段 (segment), 如代码段, 数据段, 堆栈段
- 这样每个进程有一个二维地址空间, 相互独立, 互不干扰
- 段式管理的优点是没有内碎片(因为段大小可变, 改变段大小来消除内碎片), 但段换入换出时, 会产生外碎片

分页管理
- 在页式存储管理中, 将程序的逻辑地址划分为固定大小的页 (page), 而物理内存划分为同样大小的帧, 程序加载时, 可以将任意一页放入内存中任意一个帧, 这些帧不必连续, 从而实现了离散分离
- 页式存储管理的优点是没有外碎片(因为页的大小固定), 但会产生内碎片(一个页可能填充不满)

多级页表
- 如果使用了二级分页, 一级页表就可以覆盖整个 4 GB 虚拟地址空间, 但如果某个一级页表的页表项没有被用到, 也就不需要创建这个页表项对应的二级页表了, 即可以在需要时才创建二级页表, 做个简单的计算, 假设只有 20% 的一级页表项被用到了, 那么页表占用的内存空间就只有 4 KB (一级页表)+ 20% * 4 MB (二级页表)=
0.804 MB
, 这对比单级页表的4 MB
是一个巨大的节约

段页式管理
- 先将程序划分为多个有逻辑意义的段, 也就是前面提到的分段机制, 接着再把每个段划分为多个页, 也就是对分段划分出来的连续空间, 再划分固定大小的页
- 这样, 地址结构就由段号, 段内页号和页内位移三部分组成
- 用于段页式地址变换的数据结构是每一个程序一张段表, 每个段又建立一张页表, 段表中的地址是页表的起始地址, 而页表中的地址则为某页的物理页号
- 段页式地址变换中要得到物理地址须经过三次内存访问
- 第一次访问段表, 得到页表起始地址
- 第二次访问页表, 得到物理页号
- 第三次将物理页号与页内位移组合, 得到物理地址

- 快表: 把最常访问的几个页表项存储到访问速度更快的硬件, 在 CPU 芯片中, 加入了一个专门存放程序最常访问的页表项的 Cache, 这个 Cache 就是 TLB (Translation Lookaside Buffer), 通常称为页表缓存, 转址旁路缓存, 快表等
重定位
- 对程序进行重定位的技术按重定位的时机可分为两种: 静态重定位和动态重定位
- 静态重定位: 是在目标程序装入内存时, 由装入程序对目标程序中的指令和数据的地址进行修改, 即把程序的逻辑地址都改成实际的地址, 对每个程序来说, 这种地址变换只是在装入时一次完成, 在程序运行期间不再进行重定位
- 优点: 是无需增加硬件地址转换机构, 便于实现程序的静态连接, 在早期计算机系统中大多采用这种方案
缺点- 程序的存储空间只能是连续的一片区域, 而且在重定位之后就不能再移动, 这不利于内存空间的有效使用
- 各个用户进程很难共享内存中的同一程序的副本
- 优点: 是无需增加硬件地址转换机构, 便于实现程序的静态连接, 在早期计算机系统中大多采用这种方案
- 动态重定位: 是在程序执行期间每次访问内存之前进行重定位, 这种变换是靠硬件地址变换机构实现的, 通常采用一个重定位寄存器, 其中放有当前正在执行的程序在内存空间中的起始地址, 而地址空间中的代码在装入过程中不发生变化, 现在一般计算机系统中都采用动态重定位方法
- 优点
- 程序占用的内存空间动态可变, 不必连续存放在一处
- 比较容易实现几个进程对同一程序副本的共享使用
- 缺点: 是需要附加的硬件支持, 增加了机器成本, 而且实现存储管理的软件算法比较复杂
- 优点
文件管理
位图
- 位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配
- 在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。
文件描述符
- 一个 Linux 进程启动后,会在内核空间中创建一个 PCB 控制块,PCB 内部有一个文件描述符表(File descriptor table),记录着当前进程所有可用的文件描述符,也即当前进程所有打开的文件。
- 除了文件描述符表,系统还需要维护另外两张表:
- 打开文件表(Open file table)
- i-node 表(i-node table)
- 文件描述符表每个进程都有一个,打开文件表和 i-node 表整个系统只有一个,它们三者之间的关系如下图所示

inode
- 储存文件元信息的区域就叫做 inode, 也称为索引节点
- 每个 inode 都有一个编号, 操作系统用 inode 编号来识别不同的文件
- Unix/Linux 系统内部不使用文件名, 而使用 inode 编号来识别文件, 对于系统来说, 文件名只是 inode 编号便于识别的别称或者绰号
- 表面上, 用户通过文件名, 打开文件, 实际上, 系统内部这个过程分成三步: 首先, 系统找到这个文件名对应的 inode 编号, 其次, 通过 inode 编号, 获取 inode 信息, 最后, 根据 inode 信息, 找到文件数据所在的 block, 读出数据
- inode 包含文件的元信息, 具体来说有以下内容
- 文件的字节数
- 文件拥有者的 User ID
- 文件的 Group ID
- 文件的读, 写, 执行权限
- 文件的时间戳, 共有三个:
- ctime 指 inode 上一次变动的时间
- mtime 指文件内容上一次变动的时间
- atime 指文件上一次打开的时间
- 链接数, 即有多少文件名指向这个 inode
- 文件数据 block 的位置
- superblock: 记录此 filesystem 的整体信息, 包括 inode/block 的总量, 使用量, 剩余量, 以及文件系统的格式与相关信息等
文件空间分配
- 连续分配: 为文件分配连续的磁盘块
- 目录项: 起始块号, 文件长度
- 优点: 顺序存取速度快, 支持随机访问
- 缺点: 会产生碎片, 不利于文件扩展
- 链接分配
- 隐式链接: 除文件的最后一个盘块之外, 每个盘块中都存在有指向下一个盘块的指针
- 目录项: 起始块号, 结束块号
- 优点: 可解决碎片问题, 外存利用率高, 文件扩展实现方便
- 缺点: 只能顺序访问, 不能随机访问
- 显示链接: 建立一张常驻内存的文件分配表 (FAT), 显示记录盘块的先后关系
- 目录项: 起始块号
- 优点: 除了隐式连接的优点外, 还支持随机访问
- 缺点: FAT 需要专用一定的存储空间
- 隐式链接: 除文件的最后一个盘块之外, 每个盘块中都存在有指向下一个盘块的指针
- 索引分配: 为文件数据块建立索引表, 若文件太大, 可采用链接方案, 多层索引, 混合索引
- 目录项: 链接方案记录的是第一个索引块的块号, 多层, 混合索引记录的是顶级索引块的块
- 优点: 支持随机访问, 易于实现文件的扩展
- 缺点: 索引表需要占用一定的存储空间, 访问数块前需要先读入索引块, 若采用链接方案, 查找索引块时可能需要很多次读磁盘操作
- 显示链接与索引分配的区别
- 显示链接分配只是将指针信息按照先后顺序记录在 FAT 中, 解决的隐式链接无法随机访问的问题, 但是在逻辑上还是顺序的记录磁盘块的信息
- 索引分配在逻辑上更像是包含关系, 因为索引块记录的是顶层索引块, 顶层索引块中记录的是一级索引块, 而一级索引块中又记录的是 (若有) 二级索引块
磁盘模型
- 磁头磁头是硬盘中对盘片进行读写工作的工具
- 盘片: 硬盘中一般会有多个盘片组成, 每个盘片包含两个面, 每个盘面都对应地有一个读/写磁头
- 磁道: 当磁盘旋转时, 磁头若保持在一个位置上, 则每个磁头都会在磁盘表面划出一个圆形轨迹, 这些圆形轨迹就叫做磁道
- 扇区: 磁盘上的每个磁道被等分为若干个弧段, 这些弧段便是磁盘的扇区
- 柱面: 硬盘通常由重叠的一组盘片构成, 每个盘面都被划分为数目相等的磁道, 并从外缘的
0
开始编号, 具有相同编号的磁道形成一个圆柱, 称之为磁盘的柱面 - 容量: 存储容量=磁头数 × 磁道 (柱面) 数 × 每道扇区数 × 每扇区字节数
- 块/簇: 块是操作系统中最小的逻辑存储单位, 操作系统与磁盘打交道的最小单位是磁盘块
- 在 Windows 下如 NTFS 等文件系统中叫做簇
- 在 Linux 下如 Ext 4 等文件系统中叫做块 (block)
- 每个簇或者块可以包括 2,4,8,16,32,64…2 的 n 次方个扇区
磁盘寻道算法
- 先来先服务算法 (FCFS): 根据进程请求访问磁盘的先后次序进行调度
- 最短寻道时间优先算法 (SSTF): 访问的磁道与当前磁头所在的磁道距离最近, 以使每次的寻道时间最短, 该算法可以得到比较好的吞吐量, 但却不能保证平均寻道时间最短
- 扫描算法 (SCAN) 电梯调度: 扫描算法不仅考虑到欲访问的磁道与当前磁道的距离, 更优先考虑的是磁头的当前移动方向
- 循环扫描算法 (CSCAN): 循环扫描算法是对扫描算法的改进, 如果对磁道的访问请求是均匀分布的, 当磁头到达磁盘的一端, 并反向运动时落在磁头之后的访问请求相对较少, 这是由于这些磁道刚被处理, 而磁盘另一端的请求密度相当高, 且这些访问请求等待的时间较长, 为了解决这种情况, 循环扫描算法规定磁头单向移动, 例如, 只自里向外移动, 当磁头移到最外的被访问磁道时, 磁头立即返回到最里的欲访磁道, 即将最小磁道号紧接着最大磁道号构成循环, 进行扫描
IO 管理
通道
- 通道是一个独立于 CPU 的 I/O 处理机, 它控制 I/O 设备与内存直接进行数据交换, 通道有自己的通道指令, 这些通道指令由 CPU 启动, 并在操作结束时向 CPU 发中断信号
- CPU 把数据传输功能下放给通道, 这样, 通道与 CPU 分时使用内存 (资源), 就可以实现 CPU 与 I/O 设备的并行工作
- 用通道指令编制通道程序, 存入存储器
- 当需要进行 I/O 操作时, CPU 只需启动通道, 然后可以继续执行自身程序
- 通道则执行通道程序, 管理与实现 I/O 操作
- 整个系统分为二级管理
- 一级是 CPU 对通道的管理
- 二级是通道对设备控制的管理
虚拟设备
- 虚拟设备是通过 SPOOLing 技术把独占设备变成能为若干用户共享的设备
缓冲
- 缓冲技术是用在外部设备与其他硬件部件之间的一种数据暂存技术, 它利用存储器件在外部设备中设置了数据的一个存储区域, 称为缓冲区
- 采用缓冲技术的原因是, CPU 处理数据速度与设备传输数据速度不相匹配, 需要用缓冲区缓解其间的速度矛盾
- 缓冲技术一般有两种用途, 一种是用在外部设备与外部设备之间的通信上的, 还有一种是用在外部设备和处理器之间的
- 缓冲区溢出
- 缓冲区溢出是指当计算机向缓冲区内填充数据时超过了缓冲区本身的容量, 溢出的数据覆盖在合法数据上, 造成缓冲区溢出的主原因是程序中没有仔细检查用户输入的参数
- 危害有以下两点
- 程序崩溃, 导致拒绝服务
- 跳转并且执行一段恶意代码
IO 多路复用机制
- select,poll,epoll 都是 IO 多路复用的机制。I/O 多路复用就通过一种机制,可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
- select,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。
- select
- select 本质上是通过轮询文件描述符并进行下一步处理。效率较低,仅知道有 I/O 事件发生,却不知是哪几个流,只会无差异轮询所有流,找出能读数据或写数据的流进行操作。同时处理的流越多,无差别轮询时间越长
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
- 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大
- select 支持的文件描述符数量太小了,默认是 1024
- poll
- poll 的实现和 select 非常相似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而不是 select 的 fd_set 结构,其他的都差不多, 管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll 没有最大文件描述符数量的限制。
- epoll
- 可理解为event poll,epoll 会把哪个流发生哪种 I/O 事件通知我们。所以 epoll 是事件驱动(每个事件关联 fd)的,此时我们对这些流的操作都是有意义的。复杂度也降低到了 O (1)。
- select 和 poll 都只提供了一个函数——select 或者 poll 函数。而 epoll 提供了三个函数,epoll_create 是创建一个 epoll 句柄;epoll_ctl 是注册要监听的事件类型;epoll_wait 则是等待事件的产生。
- epoll 的解决方案不像 select 或 poll 一样每次都把当前进程轮流加入 fd 对应的设备等待队列中,而只在 epoll_ctl 时把当前进程挂载一遍并为每个 fd 指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的 fd 加入一个就绪链表。epoll_wait 的工作实际上就是在这个就绪链表中查看有没有就绪的 fd
- 在 epoll_ctl 函数中。每次注册新的事件到 epoll 句柄中时(在 epoll_ctl 中指定 EPOLL_CTL_ADD),会把所有的 fd 拷贝进内核,而不是在 epoll_wait 的时候重复拷贝。epoll 保证了每个 fd 在整个过程中只会拷贝一次。
- 文件描述符:在形式上是一个非负整数, 实际上是一个索引值, 指向文件描述符表的一条记录
计算机网络
OSI 七层模型和协议
TCP 协议
TCP 的可靠性传输
- TCP 主要提供了检验和, 序列号/确认应答, 超时重传, 最大消息长度, 滑动窗口控制等方法实现了可靠性传输
- 检验和: 通过检验和的方式, 接收端可以检测出来数据是否有差错和异常, 假如有差错就会直接丢弃 TCP 段, 重新发送, TCP 在计算检验和时, 会在 TCP 首部加上一个 12 字节的伪首部, 检验和总共计算 3 部分: TCP 首部, TCP 数据, TCP 伪首部
- 序列号/确认应答: 发送端发送信息给接收端, 接收端会回应一个包, 这个包就是应答包, 只要发送端有一个包传输, 接收端没有回应确认包 (ACK 包), 都会重发, 或者接收端的应答包, 发送端没有收到也会重发数据, 这就可以保证数据的完整性
- 超时重传: 超时重传是指发送出去的数据包到接收到确认包之间的时间, 如果超过了这个时间会被认为是丢包了, 需要重传
- 超时重传时间设置要比数据报往返时间 (往返时间, 简称 RTT) 长一点
- 一来一回的时间总是差不多的, 都会有一个类似于平均值的概念, 比如发送一个包到接收端收到这个包一共是 0.5 s, 然后接收端回发一个确认包给发送端也要 0.5 s, 这样的两个时间就是 RTT (往返时间), 然后可能由于网络原因的问题, 时间会有偏差, 称为抖动 (方差)
- 最大消息长度: 在建立 TCP 连接的时候, 双方约定一个最大的长度 (MSS) 作为发送的单位, 重传的时候也是以这个单位来进行重传, 理想的情况下是该长度的数据刚好不被网络层分块
TCP 报文首部格式
三次握手四次挥手的过程

- TCP 连接建立过程: 首先 Client 端发送连接请求报文 SYN 进入 SYN-SEND 状态, Server 端接受连接后回复 ACK 报文进入 SYN-RCVD 状态, Client 端接收到 ACK 报文后也向 Server 端发送 ACK 报文进入 ESTABLISED 状态, Server 端收到 ACK 报文也进入 ESTABLISED 状态, 这样 TCP 连接就建立了
- TCP 连接断开过程: Client 端发起发送 FIN 报文中断连接请求进入 FIN-WAIT-1 状态, Server 端接到 FIN 报文后, 如果服务端还有数据没有发送完成, 则不必急着关闭 Socket, 可以继续发送数据, 所以服务端先发送 ACK 报文进入 CLOSE-WAIT 状态, 这个时候 Client 端收到 ACK 报文就进入 FIN_WAIT-2 状态, 继续等待 Server 端的 FIN 报文, 当 Server 端确定数据已发送完成, 则向 Client 端发送 FIN 报文进入 LAST-ACK 状态, Client 端收到 FIN 报文后发送 ACK 后进入 TIME_WAIT 状态, 如果 Server 端没有收到 ACK 则可以重传, Server 端收到 ACK 后进入 CLOSED 状态, Client 端等待了 2 MSL 后依然没有收到回复, 则证明 Server 端已正常关闭, Client 进入 CLOSED 状态
- 为什么要三次握手?
- 在只有两次"握手"的情形下, 假设 Client 想跟 Server 建立连接, 但是却因为中途连接请求的数据报丢失了, 故 Client 端不得不重新发送一遍, 这个时候 Server 端仅收到一个连接请求, 因此可以正常的建立连接, 但是, 有时候 Client 端重新发送请求不是因为数据报丢失了, 而是有可能数据传输过程因为网络并发量很大在某结点被阻塞了, 这种情形下 Server 端将先后收到 2 次请求, 并持续等待两个 Client 请求向他发送数据 Cient 端实际上只有一次请求, 而 Server 端却有 2 个响应, 极端的情况可能由于 Client 端多次重新发送请求数据而导致 Server 端最后建立了 N 多个响应在等待, 因而造成极大的资源浪费
- 为什么要四次挥手?
- 假如现在 Client 想断开跟 Server 的所有连接, 第一步, Client 先停止向 Server 端发送数据, 并等待 Server 的回复, 虽然 Client 不往 Server 发送数据了, 但是因为之前已经建立好平等的连接, 所以此时 Server 也有主动权向 Client 发送数据, 故 Server 端还得终止主动向 Client 发送数据, 并等待 Client 的确认
- 为什么建立连接是三次握手, 而关闭连接却是四次挥手呢?
- 这是因为服务端在 LISTEN 状态下, 收到建立连接请求的 SYN 报文后, 把 ACK 和 SYN 放在一个报文里发送给客户端, 而关闭连接时, 当收到对方的 FIN 报文时, 仅仅表示对方不再发送数据了但是还能接收数据, 己方是否现在关闭发送数据通道, 需要上层应用来决定, 因此, 己方 ACK 和 FIN 一般都会分开发送
- 为什么客户端最后还要等待 2 MSL?
- MSL (Maximum Segment Lifetime), TCP 允许不同的实现可以设置不同的 MSL 值
- 第一, 保证客户端发送的最后一个 ACK 报文能够到达服务器, 因为这个 ACK 报文可能丢失, 站在服务器的角度看来, 我已经发送了 FIN+ACK 报文请求断开了, 客户端还没有给我回应, 应该是我发送的请求断开报文它没有收到, 于是服务器又会重新发送一次, 而客户端就能在这个 2 MSL 时间段内收到这个重传的报文, 接着给出回应报文, 并且会重启 2 MSL 计时器
- 第二, 防止类似与"三次握手”中提到了的"已经失效的连接请求报文段”出现在本连接中, 客户端发送完最后一个确认报文后, 在这个 2 MSL 时间中, 就可以使本连接持续的时间内所产生的所有报文段都从网络中消失, 这样新的连接中不会出现旧连接的请求报文
半连接队列和全连接队列
- 半连接队列, 被称为 SYN 队列
- 全连接队列, 被称为 accept 队列
- 流程
- 客户端发送 SYN 包, 并进入 SYN_SENT 状态
- 服务端接收到数据包将相关信息放入半连接队列 (SYN 队列), 并返回 SYN+ACK 包给客户端
- 服务端接收客户端 ACK 数据包, 这时如果全连接队列 (accept 队列) 没满, 就会从半连接队列里面将数据取出来放入全连接队列, 等待应用使用, 当队列已满就会跟据 tcp_abort_on_overflow 配置执行策略
- 全连接队列大小取决于 backlog 和 somaxconn 的最小值, 也就是 min (backlog, somaxconn)
- somaxconn 是 Linux 内核参数, 默认 128, 可通过/proc/sys/net/core/somaxconn 进行配置
- backlog 是 listen (int sockfd, int backlog) 函数中的参数 backlog, Tomcat 默认 100, Nginx 默认 511.
流量控制
- 如果发送者发送数据过快, 接收者来不及接收, 那么就会有分组丢失, 为了避免分组丢失, 控制发送者的发送速度, 使得接收者来得及接收, 这就是流量控制, 流量控制根本目的是防止分组丢失, 它是构成 TCP 可靠性的一方面
- 如何实现流量控制
- 由滑动窗口协议 (连续 ARQ 协议) 实现, 滑动窗口协议既保证了分组无差错, 有序接收, 也实现了流量控制, 主要的方式就是接收方返回的 ACK 中会包含自己的接收窗口的大小, 并且利用大小来控制发送方的数据发送
- 怎么避免流量控制引发的死锁
- 当发送者收到了一个窗口为 0 的应答, 发送者便停止发送, 等待接收者的下一个应答, 但是如果这个窗口不为 0 的应答在传输过程丢失, 发送者一直等待下去, 而接收者以为发送者已经收到该应答, 等待接收新数据, 这样双方就相互等待, 从而产生死锁
- 为了避免流量控制引发的死锁, TCP 使用了持续计时器, 每当发送者收到一个零窗口的应答后就启动该计时器, 时间一到便主动发送报文询问接收者的窗口大小, 若接收者仍然返回零窗口, 则重置该计时器继续等待, 若窗口不为 0, 则表示应答报文丢失了, 此时重置发送窗口后开始发送, 这样就避免了死锁的产生
拥塞控制
慢开始算法
- 发送方维持一个叫做拥塞窗口 cwnd (congestion window) 的状态变量, 拥塞窗口的大小取决于网络的拥塞程度, 并且动态地在变化, 发送方让自己的发送窗口等于拥塞窗口, 另外考虑到接受方的接收能力, 发送窗口可能小于拥塞窗口
- 慢开始算法的思路就是, 不要一开始就发送大量的数据, 先探测一下网络的拥塞程度, 也就是说由小到大逐渐增加拥塞窗口的大小
- 从上图可以看到, 一个传输轮次所经历的时间其实就是往返时间 RTT, 而且每经过一个传输轮次 (transmission round), 拥塞窗口 cwnd 就加倍
- 为了防止 cwnd 增长过大引起网络拥塞, 还需设置一个慢开始门限 ssthresh 状态变量, ssthresh 的用法如下
- 当 cwnd<ssthresh 时, 使用慢开始算法
- 当 cwnd>ssthresh 时, 改用拥塞避免算法
- 当 cwnd=ssthresh 时, 慢开始与拥塞避免算法任意
- 注意, 这里的"慢”并不是指 cwnd 的增长速率慢, 而是指在 TCP 开始发送报文段时先设置 cwnd=1, 然后逐渐增大, 这当然比按照大的 cwnd 一下子把许多报文段突然注入到网络中要"慢得多”
拥塞避免算法
- 拥塞避免算法让拥塞窗口缓慢增长, 即每经过一个往返时间 RTT 就把发送方的拥塞窗口 cwnd 加 1, 而不是加倍, 这样拥塞窗口按线性规律缓慢增长
- 无论是在慢开始阶段还是在拥塞避免阶段, 只要发送方判断网络出现拥塞, 就把慢开始门限 ssthresh 设置为出现拥塞时的发送窗口大小的一半 (但不能小于 2), 然后把拥塞窗口 cwnd 重新设置为 1, 执行慢开始算法, 这样做的目的就是要迅速减少主机发送到网络中的分组数, 使得发生拥塞的路由器有足够时间把队列中积压的分组处理完毕

- 乘法减小 (Multiplicative Decrease) 和加法增大 (Additive Increase)
- 乘法减小: 指的是无论是在慢开始阶段还是在拥塞避免阶段, 只要发送方判断网络出现拥塞, 就把慢开始门限 ssthresh 设置为出现拥塞时的发送窗口大小的一半, 并执行慢开始算法, 所以当网络频繁出现拥塞时, ssthresh 下降的很快, 以大大减少注入到网络中的分组数
- 加法增大: 是指执行拥塞避免算法后, 使拥塞窗口缓慢增大, 以防止过早出现拥塞, 常合起来成为 AIMD 算法
- 注意: "拥塞避免”并非完全能够避免了阻塞, 而是使网络比较不容易出现拥塞
快重传算法
- 快重传要求接收方在收到一个失序的报文段后就立即发出重复确认, 使发送方及早知道有报文段没有到达对方, 而不要等到自己发送数据时捎带确认, 快重传算法规定, 发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段, 而不必继续等待设置的重传计时器时间到期

快恢复算法
- 快重传配合使用的还有快恢复算法: 当发送方连续收到三个重复确认时, 就执行"乘法减小”算法, 把 ssthresh 门限减半 (为了预防网络发生拥塞), 考虑到如果网络出现拥塞的话就不会收到好几个重复的确认, 所以发送方现在认为网络可能没有出现拥塞, 所以此时不执行慢开始算法, 而是将 cwnd 设置为 ssthresh 减半后的值, 然后执行拥塞避免算法, 使 cwnd 缓慢增大

- 注意: 在采用快恢复算法时, 慢开始算法只是在 TCP 连接建立时和网络出现超时时才使用
拥塞控制和流量控制的区别
- 拥塞控制: 拥塞控制是作用于网络的, 它是防止过多的数据注入到网络中, 避免出现网络负载过大的情况, 常用的方法
- 慢开始, 拥塞避免
- 快重传, 快恢复
- 流量控制: 流量控制是作用于接收者的, 它是控制发送者的发送速度从而使接收者来得及接收, 防止分组丢失的
UDP 协议
- UDP 用户数据报协议, 是无连接的通讯协议, 由于通讯不需要连接, 所以可以实现广播发送
- UDP 通讯时不需要接收方确认, 属于不可靠的传输, 可能会出现丢包现象
- 每个 UDP 报文分 UDP 报头和 UDP 数据区两部分, 报头由四个 16 位长 (2 字节) 字段组成, 分别说明该报文的源端口, 目的端口, 报文长度以及校验值, UDP 报头由 4 个域组成, 其中每个域各占用 2 个字节
- 使用 UDP 协议包括: TFTP (简单文件传输协议), SNMP (简单网络管理协议), DNS (域名解析协议), NFS, BOOTP
- TCP 与 UDP 的区别
- TCP 是面向连接而 UDP 无连接
- TCP 要保证所有的数据包都可以到达,所以有重传机制(快重传,快恢复,超时重传),UDP 不会进行重传。
- UDP 是不可靠的传输, 可能会出现丢包现象, 由于通讯不需要连接, 所以可以实现广播发送
- TCP 面向字节流而 UDP 面向报文
- TCP 通过字节流传输,即 TCP 将应用程序看成是一连串的无结构的字节流。每个 TCP 套接口有一个发送缓冲区,如果字节流太长时,TCP 会将其拆分进行发送。当字节流太短时,TCP 会等待缓冲区中的字节流达到一定程度时再构成报文发送出去,TCP 发给对方的数据,对方在收到数据时必须给矛确认,只有在收到对方的确认时,本方 TCP 才会把 TCP 发送缓冲区中的数据删除。
- UDP 传输报文的方式是由应用程序控制的,应用层交给 UDP 多长的报文,UDP 照样发送,既不拆分,也不合并,而是保留这些报文的边界,即一次发送一个报文。
- TCP 是面向连接而 UDP 无连接
HTTP 协议
HTTP 与 HTTPS 的区别
- HTTPS 协议需要到 CA 申请证书, 一般免费证书较少, 因而需要一定费用
- HTTP 是超文本传输协议, 信息是明文传输, HTTPS 则是具有安全性的 SSL 加密传输协议
- HTTP 和 HTTPS 使用的是完全不同的连接方式, 用的端口也不一样, 前者是 80, 后者是 443
- HTTP 页面响应速度比 HTTPS 快, 主要是因为 HTTP 使用 TCP 三次握手建立连接, 客户端和服务器需要交换 3 个包, 而 HTTPS 除了 TCP 的三个包, 还要加上 SSL 握手需要的 9 个包, 所以一共是 12 个包
HTTPS 基本工作原理
- 客户端使用 HTTPS 的 URL 访问服务器, 要求与服务器建立 SSL 连接,向服务端发送一个随机数(Client random)和客户端支持的加密方法, 比如 RSA 公钥加密
- 服务器收到客户端请求后, 回复一种客户端支持的加密方法, 一个随机数(Server random)和 SSL 证书(其中包括非对称加密的公钥)
- 浏览器通过内置的 CA 公钥解密证书,并通过数字签名验证收到的证书没有被篡改,随后校验证书是否过期,证书中包含的网址是否与当前访问网址一致等等。验证通过后客户端利用服务端的公钥及加密方法对新的随机数(Premaster secret)进行加密并发送给服务器
- 服务端通过私钥解密出 Premaster secret,利用同时利用 Client random, Server random 通过一定的算法生成会话密钥(session key)
- 此后双方通过会话密钥的对称加密进行通信
HTTPS 连接中的三个随机数的作用
- 对于客户端:当其生成了Pre-master secret之后,会结合原来的 A、B 随机数,用 DH 算法计算出一个master secret,紧接着根据这个master secret推导出hash secret和session secret。
- 对于服务端:当其解密获得了Pre-master secret之后,会结合原来的 A、B 随机数,用 DH 算法计算出一个master secret,紧接着根据这个master secret推导出hash secret和session secret。
- 在客户端和服务端的master secret是依据三个随机数推导出来的,它是不会在网络上传输的,只有双方知道,不会有第三者知道。同时,客户端推导出来的session secret和hash secret与服务端也是完全一样的。
- 那么现在双方如果开始使用对称算法加密来进行通讯,使用哪个作为共享的密钥呢?过程是这样子的:
- 双方使用对称加密算法进行加密,用hash secret对 HTTP 报文做一次运算生成一个 MAC,附在 HTTP 报文的后面,然后用session-secret加密所有数据(HTTP+MAC),然后发送。
- 接收方则先用session-secret解密数据,然后得到 HTTP+MAC,再用相同的算法计算出自己的 MAC,如果两个 MAC 相等,证明数据没有被篡改。
MAC (Message Authentication Code) 称为报文摘要,能够查知报文是否遭到篡改,从而保护报文的完整性。
HTTP 长连接短连接
- Connection: keep-alive
- 无状态连接:
- 短连接
- 建立连接——数据传输——关闭连接… 建立连接——数据传输——关闭连接
- 由于 Web 服务器不保存发送请求的 Web 浏览器进程的任何信息, 因此 HTTP 协议属于无状态协议 (Stateless Protocol)
- 长连接的操作步骤是:
- 建立连接——数据传输… (保持连接)… 数据传输——关闭连接
- 多用于操作频繁, 点对点的通讯, 而且连接数不能太多情况, 每个 TCP 连接都需要三步握手, 这需要时间, 如果每个操作都是先连接, 再操作的话那么处理速度会降低很多, 所以每个操作完后都不断开, 下次处理时直接发送数据包就 OK 了, 不用建立 TCP 连接, 例如: 数据库的连接用长连接, 如果用短连接频繁的通信会造成 socket 错误, 而且频繁的 socket 创建也是对资源的浪费
- 从
HTTP/1.1
起, 默认都开启了 Keep-Alive, 保持连接特性, 简单地说, 当一个网页打开完成后, 客户端和服务器之间用于传输 HTTP 数据的 TCP 连接不会关闭, 如果客户端再次访问这个服务器上的网页, 会继续使用这一条已经建立的连接 Keep-Alive
不会永久保持连接, 它有一个保持时间, 可以在不同的服务器软件 (如 Apache) 中设定这个时间
常见状态码
状态码 | 原因短语 |
---|---|
200 | OK (成功) |
301 | Moved Permanently (永久移动) |
302 | Found (临时移动) |
304 | Not Modified (未修改) |
400 | Bad Request (错误请求) |
401 | Unauthorized (未授权) |
403 | Forbidden (禁止访问) |
404 | Not Found (未找到) |
500 | Internal Server Error (内部服务器错误) |
502 | Bad Gateway (网关错误) |
503 | Service Unavailable (服务不可用) |
POST 与 GET 的区别
- 请求参数: GET 请求参数是通过 URL 传递的, 多个参数以&连接, POST 请求放在 RequestBody 中
- 请求缓存: GET 请求会被缓存, 而 POST 请求不会, 除非手动设置
- 安全性: POST 比 GET 安全, GET 请求在浏览器回退时是无害的, 而 POST 会再次请求
- 历史记录: GET 请求参数会被完整保留在浏览历史记录里, 而 POST 中的参数不会被保留
- 编码方式: GET 请求只能进行 URL 编码, 而 POST 支持多种编码方式
- 对参数的数据类型: GET 只接受 ASCII 字符, 而 POST 没有限制
ARP 协议
- 地址解析协议, 即 ARP (Address Resolution Protocol), 是根据 IP 地址获取物理 MAC 地址的一个 TCP/IP 协议
- 主机发送信息时将包含目标 IP 地址的 ARP 请求广播到网络上的所有主机, 并接收返回消息, 以此确定目标的物理地址
- 收到返回消息后将该 IP 地址和物理地址存入本机 ARP 缓存中并保留一定时间, 下次请求时直接查询 ARP 缓存以节约资源
RARP 协议
- 逆地址解析协议, 即 RARP, 功能和 ARP 协议相对, 其将局域网中某个主机的物理地址转换为 IP 地址
- 比如局域网中有一台主机只知道物理地址而不知道 IP 地址, 那么可以通过 RARP 协议发出征求自身 IP 地址的广播请求, 然后由 RARP 服务器负责回答
ICMP 协议
- Ping 的原理是 ICMP 协议: 确认 IP 包是否成功送达目标地址以及通知在发送过程当中 IP 包被废弃的具体原因
- 差错报文, 例如: 差错报文, 时间超过报文
- 询问报文: 回送请求, 应答报文, 时间戳报文
IP 协议
IP 数据报的格式
分类的 IP 地址

路由转发算法
- 先看是不是路由器就在目标网络里, 如果在直接发给目的主机
- 若路由表中有这个主机的地址, 就直接发送给这个主机
- 如果路由表有这个网络的路由地址, 就发送到目标网络路由中去
- 再没有就发送到默认路由
- 都没有就直接 ICMP 差错报文
路由选择协议
RIP 路由协议
- 它选择路由的度量标准 (metric) 是跳数, 最大跳数是 15 跳, 如果大于 15 跳, 它就会丢弃数据包
- 每 30 s 都都广播一次 RIP 路由更新信息, 把跳数最少的路径更新
OSPF 协议
- Open Shortest Path First 开放式最短路径优先, 底层是迪杰斯特拉算法, 是链路状态路由选择协议, 它选择路由的度量标准是带宽, 延迟
- 直接广播, 利用 Dijkstra 算法构造最优的路由表
DNS 协议
- DNS 就是进行域名解析的服务器, 可以简单地理解为将 URL 转换为 IP 地址
- 浏览器将会检查缓存中有没有这个域名对应的解析过的 IP 地址, 如果有该解析过程将会结束
- 如果用户的浏览器中缓存中没有, 操作系统会先检查自己本地的 hosts 文件是否有这个网址映射关系, 如果有, 就先调用这个 IP 地址映射关系, 完成域名解析
- 如果 hosts 里没有这个域名的映射, 则查找本地 DNS 解析器缓存, 是否有这个网址映射关系或缓存信息, 如果有, 直接返回给浏览器, 完成域名解析
- 如果 hosts 与本地 DNS 解析器缓存都没有相应的网址映射关系, 则会首先找本地 DNS 服务器, 一般是公司内部的 DNS 服务器, 此服务器收到查询, 如果此本地 DNS 服务器查询到相对应的 IP 地址映射或者缓存信息, 则返回解析结果给客户机, 完成域名解析, 此解析具有权威性
- 如果本地 DNS 服务器无法查询到, 则根据本地 DNS 服务器设置的转发器进行查询
- 未用转发模式: 本地 DNS 就把请求发至根 DNS 进行 (迭代) 查询, 根 DNS 服务器收到请求后会判断这个域名 (. com) 是谁来授权管理, 并会返回一个负责该顶级域名服务器的一个 IP, 本地 DNS 服务器收到 IP 信息后, 将会联系负责. com 域的这台服务器, 这台负责. com 域的服务器收到请求后, 如果自己无法解析, 它就会找一个管理. com 域的下一级 DNS 服务器地址给本地 DNS 服务器, 当本地 DNS 服务器收到这个地址后, 就会找域名域服务器, 重复上面的动作, 进行查询, 直至找到域名对应的主机
- 使用转发模式: 此 DNS 服务器就会把请求转发至上一级 DNS 服务器, 由上一级服务器进行解析, 上一级服务器如果不能解析, 或找根 DNS 或把转请求转至上上级, 以此循环, 不管是本地 DNS 服务器用是是转发, 还是根提示, 最后都是把结果返回给本地 DNS 服务器, 由此 DNS 服务器再返回给客户机
网络地址转换
- NAT 的实现方式有三种, 即静态转换 Static Nat, 动态转换 Dynamic Nat 和端口多路复用 OverLoad
- 静态转换(Static Nat): 是指将内部网络的私有 IP 地址转换为公有 IP 地址, IP 地址对是一对一的, 是一成不变的, 某个私有 IP 地址只转换为某个公有 IP 地址, 借助于静态转换, 可以实现外部网络对内部网络中某些特定设备 (如服务器) 的访问
- 动态转换(Dynamic Nat): 是指将内部网络的私有 IP 地址转换为公用 IP 地址时, IP 地址对是不确定的, 而是随机的, 所有被授权访问上 Internet 的私有 IP 地址可随机转换为任何指定的合法 IP 地址, 也就是说, 只要指定哪些内部地址可以进行转换, 以及用哪些合法地址作为外部地址时, 就可以进行动态转换, 动态转换可以使用多个合法外部地址集, 当 ISP 提供的合法 IP 地址略少于网络内部的计算机数量时, 可以采用动态转换的方式
- 端口多路复用(OverLoad): 是指改变外出数据包的源端口并进行端口转换, 即端口地址转换 (PAT, Port Address Translation). 采用端口多路复用方式, 内部网络的所有主机均可共享一个合法外部 IP 地址实现对 Internet 的访问, 从而可以最大限度地节约 IP 地址资源, 同时, 又可隐藏网络内部的所有主机, 有效避免来自 internet 的攻击, 因此, 目前网络中应用最多的就是端口多路复用方式
DHCP 协议
- DHCP 协议采用 UDP 作为传输协议, 主机发送请求消息到 DHCP 服务器的 67 号端口, DHCP 服务器回应应答消息给主机的 68 号端口
- 服务器控制一段 IP 地址范围, 客户机登录服务器时就可以自动获得服务器分配的 IP 地址和子网掩码
网络字节序和主机字节序
- 主机字节序: 自己的主机内部, 内存中数据的处理方式, 可以分为两种
- 大端字节序: 按照内存的增长方向, 高位数据存储于低位内存中 (最直观的字节序)
- 小端字节序: 按照内存的增长方向, 高位数据存储于高位内存中 (计算机电路先处理低位字节, 效率比较高)
- 网络字节序: 网络数据流也有大小端之分, 网络数据流的地址规定: 先发出的数据是低地址, 后发出的数据是高地址, 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出, 为了不使数据流乱序, 接收主机也会把从网络上接收的数据按内存地址从低到高的顺序保存在接收缓冲区中
- TCP/IP 协议规定: 网络数据流应采用大端字节序, 即低地址高字节
网络连接过程
- 读取本地缓存
- DNS 解析, 找到 IP 地址
- 根据 IP 地址, 找到对应的服务器
- 建立 TCP 连接 ( 三次握手)
- 连接建立后, 发出 HTTP 请求
- 服务器根据请求作出 HTTP 响应
- 浏览器得到响应内容, 进行解析与渲染, 并显示
- 断开连接 (四次挥手)
断点续传
- HTTP 1.1 默认支持断点续传。
- Range: 用于客户端到服务端的请求,可以通过改字段指定下载文件的某一段大小及其单位,字节偏移从 0 开始。
- If-Range: 用于客户端到服务端的请求,用于判断实体是否发生改变,必须与 Range 配合使用。若实体未被修改,则响应所缺少的那部分;否则,响应整个新的实体。
- Accept-Ranges: 用于 server 到 client 的应答,client 通过该自段判断 server 是否支持断点续传。
- Content-Ranges: 用于 sever 到 client 的应答,与 Accept-Ranges 在同一个报文内,通过该字段指定了返回的文件资源的字节范围。
- 状态码:断点续传,如果返回文件的一部分,则使用 HTTP 206 状态码;如果返回整个文件,则使用 HTTP 200 响应码。
中间人攻击
- 中间人攻击 (Man-in-the-MiddleAttack,简称“MITM攻击”) 是指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方直接对话,但事实上整个会话都被攻击者完全控制。在中间人攻击中,攻击者可以拦截通讯双方的通话并插入新的内容
- 数字签名:用私钥对某个文件的散列值进行签名就像一个人亲手在信件最后签上了自己的名字一样,证明这份文件 / 这段消息确实来自私钥的拥有者。在通信中,双方每次在写完消息之后,计算消息的散列值,并用自己的私钥加密生成数字签名,附在报文后面,接收者在收到消息和数字签名之后,先计算散列值,再使用对方的公钥解密数字签名中的散列值,进行对比,如果一致,就可以确保该消息确实是来自于对方,并且没有被篡改过。
- 数字证书认证机构:如果中间人在会话建立阶段把双方交换的真实公钥替换成自己的公钥了,那么中间人还是可以篡改消息的内容而双方并不知情。为了解决这个问题,需要找一个通信双方都信任的第三方来为双方确认身份。这就像大家都相信公证处,公证处拿着自己的公章为每一封信件都盖上了自己的章,证明这封信确实是由本人发出的,这样就算中间人可以替换掉通信双方消息的签名,也无法替换掉公证处的公章。这个公章,在二进制的世界里,就是数字证书,公证处就是 CA(数字证书认证机构)。
- 数字证书:申请人将一些必要信息(包括公钥、姓名、电子邮件、有效期)等提供给 CA,CA 在通过各种手段确认申请人确实是他所声称的人之后,用自己的私钥对申请人所提供信息计算散列值进行加密,形成数字签名,附在证书最后,再将数字证书颁发给申请人,申请人就可以使用 CA 的证书向别人证明他自己的身份了。对方收到数字证书之后,只需要用 CA 的公钥解密证书最后的签名得到加密之前的散列值,再计算数字证书中信息的散列值,将两者进行对比,只要散列值一致,就证明这张数字证书是有效且未被篡改过的。
- 中间人攻击过程
- 客户端发送请求到服务端,请求被中间人截获
- 服务器向客户端发送公钥
- 中间人截获公钥,保留在自己手上。然后自己生成一个伪造的公钥,发给客户端
- 客户端收到伪造的公钥后,生成加密值发给服务器
- 中间人获得加密值,用自己的私钥解密获得真秘钥。同时生成假的加密值,发给服务器
- 服务器用私钥解密获得假密钥。然后加密数据传输给客户端
分布式与微服务
- 随着互联网的发展, 网站应用的规模不断扩大, 常规的垂直应用架构已无法应对, 分布式服务架构势在必行
- 分布式系统是由一组通过网络进行通信, 为了完成共同的任务而协调工作的计算机节点组成的系统, 其目的是利用更多的机器, 处理更多的数据
- 微服务是一种开发软件的架构和组织方法, 其中软件由通过明确定义的 API 进行通信的小型独立服务组成, 服务之间通过接口来进行交互, 接口契约不变的情况下可独立变化
CAP 理论
- CAP 理论指出对于一个分布式计算系统来说, 不可能同时满足以下三点
- 一致性: 代表更新操作成功后, 所有节点在同一时间的数据完全一致
- 可用性: 代表用户访问数据时, 系统是否能在正常响应时间内返回预期的结果
- 分区容错性: 代表分布式系统在遇到节点或网络故障时, 仍然能够对外提供一致性或可用性服务
- 权衡
- 根据定理,分布式系统只能满足三项中的两项而不可能满足全部三项。理解 CAP 理论的最简单方式是想象两个节点分处分区两侧。允许至少一个节点更新状态会导致数据不一致,即丧失了 C 性质。如果为了保证数据一致性,将分区一侧的节点设置为不可用,那么又丧失了 A 性质。除非两个节点可以互相通信,才能既保证 C 又保证 A,这又会导致丧失 P 性质。
- 可用性和一致性往往是冲突的, 很难使它们同时满足, 在多个节点之间进行数据同步时
- 为了保证一致性 (CP), 不能访问未同步完成的节点, 也就失去了部分可用性
- 为了保证可用性 (AP), 允许读取所有节点的数据, 但是数据可能不一致
- Zookeeper 保证的是 CP, 对比 Spring Cloud 系统中的注册中心 eruka 实现的是 AP
BASE 理论
- BASE 是 Basically Available (基本可用), Soft-state (软状态) 和 Eventually Consistent (最终一致性) 三个短语的缩写
- BASE 理论是对 CAP 中一致性和可用性权衡的结果, 它的核心思想是: 即使无法做到强一致性, 但每个应用都可以根据自身业务特点, 采用适当的方式来使系统达到最终一致性
- 基本可用: 指分布式系统在出现故障的时候, 保证核心可用, 允许损失部分可用性, 例如, 电商在做促销时, 为了保证购物系统的稳定性, 部分消费者可能会被引导到一个降级的页面
- 软状态: 指允许系统中的数据存在中间状态, 并认为该中间状态不会影响系统整体可用性, 即允许系统不同节点的数据副本之间进行同步的过程存在时延
- 最终一致性:
- 最终一致性强调的是系统中所有的数据副本, 在经过一段时间的同步后, 最终能达到一致的状态
- ACID 要求强一致性, 通常运用在传统的数据库系统上, 而 BASE 要求最终一致性, 通过牺牲强一致性来达到可用性, 通常运用在大型分布式系统中
- 在实际的分布式场景中, 不同业务单元和组件对一致性的要求是不同的, 因此 ACID 和 BASE 往往会结合在一起使用
RPC
- RPC (Remote Procedure Call) 是指远程过程调用, 是一种进程间通信方式, 是一种技术的思想, 而不是规范, 它允许程序调用另一个地址空间 (通常是共享网络的另一台机器上) 的过程或函数, 而不用程序员显式编码这个远程调用的细节
Dubbo
- Apache Dubbo 是一款高性能, 轻量级的开源 Java RPC 框架, 它提供了三大核心能力: 面向接口的远程方法调用, 智能容错和负载均衡, 以及服务自动注册和发现
- 服务提供者(Provider): 暴露服务的服务提供方, 服务提供者在启动时, 向注册中心注册自己提供的服务
- 服务消费者(Consumer): 调用远程服务的服务消费方, 服务消费者在启动时, 向注册中心订阅自己所需的服务, 服务消费者, 从提供者地址列表中, 基于软负载均衡算法, 选一台提供者进行调用, 如果调用失败, 再选另一台调用
- 注册中心(Registry): 注册中心返回服务提供者地址列表给消费者, 如果有变更, 注册中心将基于长连接推送变更数据给消费者
- 监控中心(Monitor): 服务消费者和提供者, 在内存中累计调用次数和调用时间, 定时每分钟发送一次统计数据到监控中心
- 调用关系说明
- 服务容器负责启动, 加载, 运行服务提供者
- 服务提供者在启动时, 向注册中心注册自己提供的服务
- 服务消费者在启动时, 向注册中心订阅自己所需的服务
- 注册中心返回服务提供者地址列表给消费者, 如果有变更, 注册中心将基于长连接推送变更数据给消费者
- 服务消费者, 从提供者地址列表中, 基于软负载均衡算法, 选一台提供者进行调用, 如果调用失败, 再选另一台调用
- 服务消费者和提供者, 在内存中累计调用次数和调用时间, 定时每分钟发送一次统计数据到监控中心
分布式事务
- 两阶段提交(Two-phase Commit, 2 PC): 通过引入协调者 (Coordinator) 来协调参与者的行为, 并最终决定这些参与者是否要真正执行事务
- 第一阶段 (prepare): 协调者询问参与者事务是否执行成功, 参与者发回事务执行结果, 询问可以看成一种投票, 需要参与者都同意才能执行
- 第二阶段 (commit/rollback): 如果事务在每个参与者上都执行成功, 事务协调者发送通知让参与者提交事务, 否则, 协调者发送通知让参与者回滚事务, 需要注意的是, 在准备阶段, 参与者执行了事务, 但是还未提交, 只有在提交阶段接收到协调者发来的通知后, 才进行提交或者回滚
- 存在的问题
- 同步阻塞: 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞等待状态, 无法进行其它操作
- 单点问题: 协调者在 2 PC 中起到非常大的作用, 发生故障将会造成很大影响, 特别是在提交阶段发生故障, 所有参与者会一直同步阻塞等待, 无法完成其它操作
- 数据不一致: 在提交阶段, 如果协调者只发送了部分 Commit 消息, 此时网络发生异常, 那么只有部分参与者接收到 Commit 消息, 也就是说只有部分参与者提交了事务, 使得系统数据不一致
- 太过保守: 任意一个节点失败就会导致整个事务失败, 没有完善的容错机制
- TCC (Try Confirm Cancel)
- Try 阶段:尝试执行,完成所有业务检查 (一致性), 预留必须业务资源 (准隔离性), 在 Try 阶段, 是对业务系统进行检查及资源预览, 比如订单和存储操作, 需要检查库存剩余数量是否够用, 并进行预留, 预留操作的话就是新建一个可用库存数量字段, Try 阶段操作是对这个可用库存数量进行操作
- Confirm 阶段: 确认执行真正执行业务, 不作任何业务检查, 只使用 Try 阶段预留的业务资源, Confirm 操作满足幂等性, 要求具备幂等设计, Confirm 失败后需要进行重试
- Cancel 阶段: 取消执行, 释放 Try 阶段预留的业务资源 Cancel 操作满足幂等性 Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致
- 特性
- 基于 TCC 实现分布式事务, 会将原来只需要一个接口就可以实现的逻辑拆分为 Try, Confirm, Cancel 三个接口, 所以代码实现复杂度相对较高
- TCC 设计之初认为 Confirm 与 Cance 一定会成功
- Confirm 与 Cance 尽可能不要产生服务通信,只做最简单的事情
- Confirm 与 Cance 如果失败,由事务中间件进行“重试”补偿
- 极小概率情况下,C/C 彻底失败,则需要定时任务检测或人工介入
- 本地消息表: 本地消息表与业务数据表处于同一个数据库中, 这样就能利用本地事务来保证在对这两个表的操作满足事务特性, 并且使用了消息队列来保证最终一致性
- 在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息, 本地事务能保证这个消息一定会被写入本地消息表中
- 之后将本地消息表中的消息转发到消息队列中, 如果转发成功则将消息从本地消息表中删除, 否则继续重新转发
- 在分布式事务操作的另一方从消息队列中读取一个消息, 并执行消息中的操作
- 尽最大努力通知
- 最大努力通知是最简单的一种柔性事务, 适用于一些最终一致性时间敏感度低的业务, 且被动方处理结果不影响主动方的处理结果
- 这个方案的大致意思就是:
- 系统 A 本地事务执行完之后, 发送个消息到 MQ
- 这里会有个专门消费 MQ 的服务, 这个服务会消费 MQ 并调用系统 B 的接口
- 要是系统 B 执行成功就 OK 了, 要是系统 B 执行失败了, 那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次, 最后还是不行就放弃
Raft 算法
选举过程
-
初始状态:初始状态下,集群中所有节点都是跟随者的状态,任期(Term)都为 0
-
发起投票:Raft 算法中每个节点等待领导者节点心跳信息的超时时间间隔是随机的。如果 NodeA 因为没有等到领导者的心跳信息先超时,则 A 节点成为候选者,并增加自己的任期编号,Term 值从 0 更新为 1,并给自己投了一票。
-
成为领导者
-
节点 A 成为候选者后,向其他节点发送请求投票 RPC 信息,请它们选举自己为领导者。
-
节点 B 和节点 C 接收到节点 A 发送的请求投票信息后,在编号为 1 的这届任期内,还没有进行过投票,就把选票投给节点 A,并增加自己的任期编号。
-
节点 A 收到了大多数节点(n/2+1) 的投票,从候选者成为本届任期内的新的领导者。
-
-
与其他节点通讯:节点 A 作为领导者,固定的时间间隔给节点 B 和节点 C 发送心跳信息,告诉节点 B 和 C,我是领导者,节点 B 和节点 C 发送响应信息给节点 A,告诉节点 A 我是正常的。
选举中遇到的问题
- 领导者的任期
- 自动增加:跟随者在等待领导者心跳信息超时后,推荐自己为候选人,会增加自己的任期号,节点 A 任期为 0,推举自己为候选人时,任期编号增加为 1。
- 更新为较大值:当节点发现自己的任期编号比其他节点小时,会更新到较大的编号值。比如节点 A 的任期为 1,请求投票,投票消息中包含了节点 A 的任期编号,且编号为 1,节点 B 收到消息后,会将自己的任期编号更新为 1。
- 恢复为跟随者:如果一个候选人或者领导者,发现自己的任期编号比其他节点小,那么它会立即恢复成跟随者状态。这种场景出现在分区错误恢复后,任期为 3 的领导者受到任期编号为 4 的心跳消息,那么前者将立即恢复成跟随者状态。
- 拒绝消息:如果一个节点接收到较小的任期编号值的请求,那么它会直接拒绝这个请求,比如任期编号为 6 的节点 A,收到任期编号为 5 的节点 B 的请求投票 RPC 消息,那么节点 A 会拒绝这个消息。
- 一个任期内,领导者一直都会领导者,直到自身出现问题(如宕机),或者网络问题(延迟),其他节点发起一轮新的选举。
- 防止多个节点同时发起投票:为了防止多个节点同时发起投票,会给每个节点分配一个随机的选举超时时间。这个时间内,节点不能成为候选者,只能等到超时。比如上述例子,节点 A 先超时,先成为了候选者。这种巧妙的设计,在大多数情况下只有一个服务器节点先发起选举,而不是同时发起选举,减少了因选票瓜分导致选举失败的情况。
- 领导者节点下线:如果领导者节点出现故障,则会触发新的一轮选举
一致性 Hash
- 将 Hash 空间 [0, 232-1] 看成一个 Hash 环, 每个服务器节点都配置到 Hash 环上, 每个数据对象通过 Hash 取模得到 Hash 值之后, 存放到 Hash 环中顺时针方向第一个大于等于该 Hash 值的节点上
- 一致性 Hash 在增加或者删除节点时只会影响到 Hash 环中相邻的节点, 例如新增节点 X, 只需要将它前一个节点 C 上的数据重新进行分布即可, 对于其他节点
- 虚拟节点
- 上面描述的一致性 Hash 存在数据分布不均匀的问题, 节点存储的数据量有可能会存在很大的不同
- 数据不均匀主要是因为节点在 Hash 环上分布的不均匀, 这种情况在节点数量很少的情况下尤其明显
- 解决方式是通过增加虚拟节点, 然后将虚拟节点映射到真实节点上, 虚拟节点的数量比真实节点来得多, 那么虚拟节点在 Hash 环上分布的均匀性就会比原来的真实节点好, 从而使得数据分布也更加均匀
链路追踪
- 链路追踪是分布式系统下的一个概念,将一次分布式请求还原成调用链路,将一次分布式请求的调用情况集中展示,比如,各个服务节点上的耗时、请求具体到达哪台机器上、每个服务节点的请求状态等等。
- 作用
- 自动采取数据
- 分析数据,产生完整调用链:有了请求的完整调用链,问题有很大概率可复现
- 数据可视化:每个组件的性能可视化,能帮助我们很好地定位系统的瓶颈,及时找出问题所在
- 分布式调用链标准(OpenTracing):OpenTracing 是一个轻量级的标准化层,它位于应用程序/类库和追踪或日志分析程序之间。它的出现是为了解决不同的分布式追踪系统 API 不兼容的问题。
- OpenTracing 的数据模型,主要有以下三个:
- Trace: 一个完整请求链路
- Span: 一次调用过程(需要有开始时间和结束时间)
- SpanContext: Trace 的全局上下文信息,如里面有 traceId
- 例如,一次下单的完整请求就是一个 Trace。TraceId 是这个请求的全局标识。内部的每一次调用就称为一个 Span,每个 Span 都要带上全局的 TraceId,这样才可把全局 TraceId 与每个调用关联起来。这个 TraceId 是通过 SpanContext 传输的,既然要传输,显然都要遵循协议来调用。
- OpenTracing 的数据模型,主要有以下三个:
设计模式
设计模式种类
- 创建型模式: 关注对象的创建过程
- 单例模式
- 工厂模式
- 抽象工厂模式
- 建造者模式
- 原型模式
- 结构型模式: 关注系统中对象之间的相互交互, 研究系统在运行时对象之间的相互通信和协作, 进一步明确对象的职责
- 适配器模式
- 桥接模式
- 装饰模式
- 组合模式
- 外观模式
- 享元模式
- 代理模式
- 行为型模式: 关注对象和类的组织
- 模板方法模式
- 命令模式
- 迭代器模式
- 观察者模式
- 中介者模式
- 备忘录模式
- 解释器模式
- 状态模式
- 策略模式
- 职责链模式
- 访问者模式
单例模式
静态内部类式
- 使用静态内部类解决了线程安全问题, 并实现了延时加载
1 |
|
DCL
- DCL 使用 volatile 关键字, 是为了禁止指令重排序, 避免返回还没完成初始化的 singleton 对象, 导致调用报错, 也保证了线程的安全
1 |
|
- 第一个 singleton == null 的判断是为了避免线程串行化, 如果为空, 就进入 synchronized 代码块中, 获取锁后再操作, 如果不为空, 直接就返回 singleton 对象了, 无需再进行锁竞争和等待了
- 第二个 singleton == null 的判断是为了防止有多个线程同时跳过第一个 singleton == null 的判断, 比如线程一先获取到锁, 进入同步代码块中, 发现 singleton 实例还是 null, 就会做 new 操作, 然后退出同步代码块并释放锁, 这时一起跳过第一层 singleton == null 的判断的还有线程二, 这时线程一释放了锁, 线程二就会获取到锁, 如果没有第二层的 singleton == null 这个判断挡着, 那就会再创建一个 singleton 实例, 就违反了单例的约束了
JS
什么是闭包
- 闭包就是能够读取其他函数内部变量的函数
- 由于在 Javascript 语言中, 只有函数内部的子函数才能读取局部变量, 因此可以把闭包简单理解成"定义在一个函数内部的函数"
- 所以在本质上, 闭包就是将函数内部和函数外部连接起来的一座桥梁
场景题
对大量元素出现的频率排序或取前 n 个
- Hash 映射:遍历文件,对于每个词代入 Hash 函数,分布到多个小文件中。这样相同的元素在同一个文件中
- HashMap 统计:对每个小文件,采用 HashMap 等统计每个文件中出现的元素以及相应的频率。并根据出现频率进行排序
- 堆/归并排序:
- 只取前 n 个:取出出现频率最大的 n 个元素后存入文件,最后对这些文件进行堆排序或归并排序(内排序与外排序相结合)
- 频率排序:将排序好的键值对输出到文件中。最后对这些文件进行归并排序(内排序与外排序相结合)
找出两个大文件中相同的元素
- 方案 1
- Hash 映射:遍历文件,对于每个词代入 Hash 函数,分布到多个小文件中。这样相同的元素在同一个文件中
- HashSet 统计:求每对小文件中相同的 url 时,可以把其中一个小文件的 url 存储到 HashSet 中。然后遍历另一个小文件的每个 url,看其是否在刚才构建的 hash_set 中,如果是,那么就是共同的 url,存到文件里面就可以了。
- 方案 2:如果允许有一定的错误率,可以使用 Bloom filter,4 G 内存大概可以表示 340 亿 bit。将其中一个文件中的 url 使用 Bloom filter 映射为这 340 亿 bit,然后挨个读取另外一个文件的 url,检查是否与 Bloom filter,如果是,那么该 url 应该是共同的 url(注意会有一定的错误率)。
对大量元素去重
- Hash 映射:遍历文件,对于每个词代入 Hash 函数,分布到多个小文件中。这样相同的元素在同一个文件中
- HashSet 统计:将各个文件中的元素放入 HashSet 中,利用 Set 性质去重
- 归并:将各个文件的 HashSet 集合合并
找出大量数字中只出现一次的
- 方案 1:hash 映射,然后 HashMap 统计,最后找出所有 value 为 1 的 key 值
- 方案 2:采用 2-BitMap(每个数分配 2 bit,00 表示不存在,01 表示出现一次,10 表示多次,11 无意义)进行,然后遍历所有数字,设置 BitMap 中相对应位,如果是 00 变 01,01 变 10,10 保持不变。初始化 BitMap 之后,把 BitMap 中对应位是 01 的整数输出即可。
判断某个数字是否在大量数据中
- 方案 1:使用 BitMap,一个 bit 位代表一个 unsigned int 值。遍历所有数字并设置相应的 bit 位,初始化 BitMap 之后,读入要查询的数,查看相应 bit 位是否为 1,为 1 表示存在,为 0 表示不存在。
- 方案 2
- 将所有数字分成两类: 最高位为 0 和最高位为 1,并将这两类分别写入到两个文件中,与要查找的数的最高位比较并接着进入相应的文件再查找。
- 再然后把这个文件为又分成两类: 次最高位为 0 和次最高位为 1。并将这两类分别写入到两个文件中,以此类推,时间复杂度为 O (logn)
对大量非重复数字排序
- 使用 BitMap,将每个数字映射在 BitMap 的一个位置上并置为 1,最后按顺序读取
- 初始化 bitMap[capacity]
- 顺序所有读入数字,并转换为 int 类型,修改位向量值 bitMap[number]=1
- 遍历 bitMap 数组,如果 bitMap[index]=1,则输出 index
对大量重复数字排序并取前 n 个
- 方案 1:用一个含 n 元素的最大堆完成。复杂度为 O (总数据量*lgn)。
- 方案 2:采用快速排序的思想,每次分割之后只考虑比轴小的一部分,直到比轴小的一部分比 n 多的时候,采用传统排序算法排序,取前 n 个。复杂度为 O (总数据量*n)。
- 方案 3:采用局部淘汰法。选取前 n 个元素并排序,然后逐个遍历剩余的元素,如果这个元素比前 n 个元素中最大的要小,那么把这个最大的元素移除,并利用插入排序的思想,插入到前 n 个元素中。复杂度为 O (总数据量*n)。
超卖问题
- 应用层加互斥锁(注意分布式应用环境下不适用)
- 通过 Redis 分布式锁解决,根据商品 ID 分段
- MySQL 数据库层加互斥锁
- 共享锁(S):
SELECT * FROM table_name WHERE … LOCK IN SHARE MODE
- 排他锁(X):
SELECT * FROM table_name WHERE … FOR UPDATE
- 共享锁(S):
- 通过 Redis 原子操作及 MySQL 锁实现
- 去 Redis 查询该 key 对应的键值对是否存在,如果查到了,则直接获取到了目前 redis 中的商品库存数量,这个数量和 MySQL 中的数量是一致的
- 如果查不到则需要到 MySQL 中查出该 id 对应的商品库存数量,并通过 Redis 的 setnx 命令将该商品库存存入 Redis 中,key 为商品 ID,value 为商品数量,如果 setnx 命令返回值为 1 则说明设置成功,如果返回值 0 则说明设置失败,设置失败则说明有其它线程先完成了设置,此时需要再查一遍 Redis 中此商品对应的库存
- 判断该商品库存数量是否满足下单需要,如果不满足直接返回库存不足
- 对 Redis 库存进行减操作,通过
decrby key numbers
命令减 Redis 库存,然后得到返回值,对返回值进行判断,如果返回值<0则说明库存不足,此时将减去的库存加上incrby key numbers
,然后返回库存不足;如果返回值>=0 则说明库存足够,则执行 MySQL 减库存操作(update 语句),然后执行下单的后续操作。
其他
Maven 依赖加载规则
- Maven 解析 pom. xml 文件时,同一个 jar 包只会保留一个,如果面对多个版本的 jar 包则使用一下规则处理
- 依赖路径最短优先原则:一个项目 Demo 依赖了两个 jar 包,其中 A-B-C-X (1.0) , A-D-X (2.0)。由于 X (2.0) 路径最短,所以项目使用的是 X (2.0)
- pom 文件中申明顺序优先:当路径长度相同时,maven 会根据 pom 文件声明的顺序加载,如果先声明了 B,后声明了 C,那就最后的依赖就会是 X (1.0)
- 覆写优先:子 pom 内声明的优先于父 pom 中的依赖
幂等性
- 数据库唯一主键: 数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性, 一般来说唯一主键比较适用于"插入”时的幂等性, 其能保证一张表中只能存在一条带该唯一主键的记录
- 数据库乐观锁: 数据库乐观锁方案一般只能适用于执行"更新操作”的过程, 我们可以提前在对应的数据表中多添加一个字段, 充当当前数据的版本标识, 这样每次对该数据库该表的这条数据执行更新时, 都会将该版本标识作为一个条件, 值为上次待更新数据中的版本标识的值
- 下游传递唯一序列号: 所谓请求序列号, 其实就是每次向服务端请求时候附带一个短时间内唯一不重复的序列号, 该序列号可以是一个有序 ID, 也可以是一个订单号, 一般由下游生成, 在调用上游服务端接口时附加该序列号和用于认证的 ID
- 当上游服务器收到请求信息后拿取该序列号和下游认证 ID 进行组合, 形成用于操作 Redis 的 Key, 然后到 Redis 中查询是否存在对应的 Key 的键值对
- 如果存在, 就说明已经对该下游的该序列号的请求进行了业务处理, 这时可以直接响应重复请求的错误信息
- 如果不存在, 就以该 Key 作为 Redis 的键, 以下游关键信息作为存储的值 (例如下游商传递的一些业务逻辑信息), 将该键值对存储到 Redis 中, 然后再正常执行对应的业务逻辑即可
OOP 六原则一法则
- 单一职责原则
- 一个类只做它该做的事情
- 单一职责原则想表达的就是"高内聚", 写代码最终极的原则只有六个字"高内聚, 低耦合", 所谓的高内聚就是一个代码模块只完成一项功能, 在面向对象中, 如果只让一个类完成它该做的事, 而不涉及与它无关的领域就是践行了高内聚的原则, 这个类就只有单一职责, 我们都知道一句话叫"因为专注, 所以专业", 一个对象如果承担太多的职责, 那么注定它什么都做不好, 一个好的软件系统, 它里面的每个功能模块也应该是可以轻易的拿到其他系统中使用的, 这样才能实现软件复用的目标
- 开闭原则
- 软件实体应当对扩展开放, 对修改关闭
- 在理想的状态下, 当我们需要为一个软件系统增加新功能时, 只需要从原来的系统派生出一些新类就可以, 不需要修改原来的任何一行代码
- 要做到开闭有两个要点:
- 抽象是关键, 一个系统中如果没有抽象类或接口系统就没有扩展点
- 封装可变性, 将系统中的各种可变因素封装到一个继承结构中, 如果多个可变因素混杂在一起, 系统将变得复杂而换乱, 如果不清楚如何封装可变性, 可以参考《设计模式精解》一书中对桥梁模式的讲解的章节
- 依赖倒转原则
- 面向接口编程
- 该原则说得直白和具体一些就是声明方法的参数类型, 方法的返回类型, 变量的引用类型时, 尽可能使用抽象类型而不用具体类型, 因为抽象类型可以被它的任何一个子类型所替代, 请参考下面的里氏替换原则
- 里氏替换原则
- 任何时候都可以用子类型替换掉父类型
- 关于里氏替换原则的描述, Barbara Liskov 女士的描述比这个要复杂得多, 但简单的说就是能用父类型的地方就一定能使用子类型, 里氏替换原则可以检查继承关系是否合理, 如果一个继承关系违背了里氏替换原则, 那么这个继承关系一定是错误的, 需要对代码进行重构, 例如让猫继承狗, 或者狗继承猫, 又或者让正方形继承长方形都是错误的继承关系, 因为你很容易找到违反里氏替换原则的场景, 需要注意的是: 子类一定是增加父类的能力而不是减少父类的能力, 因为子类比父类的能力更多, 把能力多的对象当成能力少的对象来用当然没有任何问题
- 接口隔离原则
- 接口要小而专, 绝不能大而全
- 臃肿的接口是对接口的污染, 既然接口表示能力, 那么一个接口只应该描述一种能力, 接口也应该是高度内聚的, 例如, 琴棋书画就应该分别设计为四个接口, 而不应设计成一个接口中的四个方法, 因为如果设计成一个接口中的四个方法, 那么这个接口很难用, 毕竟琴棋书画四样都精通的人还是少数, 而如果设计成四个接口, 会几项就实现几个接口, 这样的话每个接口被复用的可能性是很高的, Java 中的接口代表能力, 代表约定, 代表角色, 能否正确的使用接口一定是编程水平高低的重要标识
- 合成聚合复用原则
- 优先使用聚合或合成关系复用代码
- 尽量采用组合 (contains-a), 聚合 (has-a) 的方式而不是继承 (is-a) 的关系来达到软件的复用目的, 组合/聚合复用原则是通过将已有的对象纳入新对象中, 作为新对象的成员对象来实现的, 新对象可以调用已有对象的功能, 从而达到复用, 原则是尽量首先使用合成 / 聚合的方式, 而不是使用继承
- 迪米特法则
- 迪米特法则又叫最少知识原则, 一个对象应当对其他对象有尽可能少的了解
- 迪米特法则简单的说就是如何做到"低耦合", 门面模式和调停者模式就是对迪米特法则的践行
加密算法
- 对称性加密算法:AES、DES、3 DES
- 非对称性算法:RSA、DSA、ECC
- 哈希算法(签名算法):MD 5、SHA 1、HMAC
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!