_little-star_

学习的博客

0%

[TOC]

面试

JAVA SE

1、自增变量

面试

面试代码:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
int i = 1;
i = i++;
int j = i++;
int k = i + ++i * i++;
System.out.println("i=" + i);
System.out.println("j=" + j);
System.out.println("k=" + k);
}

运行结果:

1
2
3
4
1
11

解析

代码相关字节码

image-20210924204445223

对应的操作数栈与局部变量表执行顺序

image-20210924204626492

image-20210924204658561

image-20210924205010565

小结

  • 赋值=,最后计算
  • =右边的从左到右加载值依次压入操作数栈
  • 实际先算哪个,看运算符优先级
  • 自增、自减操作都是直接修改局部变量表当中变量的值,不经过操作数栈
  • 最后的赋值之前,临时结果也是存储在操作数栈中

2、单例模式——Singleton

面试

编程题:写一个Singleton示例

解析

1、什么是Singleton?
  • Singleton:在Java中即指单例设计模式,它是软件开发中最常用的设计模式之一。
    • 单:唯一
    • 例:实例
  • 单例设计模式,即某个类在整个系统中只能有一个实例对象可被获取和使用的代码模式。
    • 例如:代表JVM运行环境的Runtime类
2、要点
  1. 一是某个类只能有一个实例;
    • 构造器私有化
  2. 二是它必须自行创建这个实例;
    • 含有一个该类的静态变量来保存这个唯一的实例
  3. 三是它必须自行向整个系统提供这个实例;
    • 对外提供获取该实例对象的方式:
      1. 直接暴露
      2. 用静态变量的get方法获取
3、几种常见形式
  • 饿汉式:直接创建对象,不存在线程安全问题
    • 直接实例化饿汉式(简洁直观)
    • 枚举式(最简洁、最安全)
    • 静态代码块饿汉式(适合复杂实例化)
  • 懒汉式:延迟创建对象
    • 线程不安全(适用于单线程)
    • 线程安全(加锁,适用于多线程)
    • 双重检查(适用于多线程,效率比加锁高)
    • 静态内部类形式(适用于多线程)
4、实现
1、饿汉式——直接实例化饿汉式(简洁直观)
1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* 饿汉式:
* 在类初始化时直接创建实例对象,不管你是否需要这个对象都会创建
*
* (1)构造器私有化
* (2)自行创建,并且用静态变量保存
* (3)向外提供这个实例
* (4)强调这是一个单例,我们可以用final修改
*/
public class Singleton {
pubilc static final Singleton INSTANCE = new Singleton();
private Singleton() {}
}
2、饿汉式——枚举式(最简洁、最安全)
1
2
3
4
5
6
7
/*
* 枚举类型:表示该类型的对象是有限的几个
* 我们可以限定为一个,就成了单例
*/
public enum Singleton {
INSTANCE
}
3、饿汉式——静态代码块饿汉式(适合复杂实例化)(可以使用配置文件来解耦合)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Singleton {
public static final Singleton IASTANCE;
static {
try {
Properties pro = new Properties();
pro.load(Singleton.class.getClassLoader().getResourceAsStream("single.properties"));
INSTANCE = new Singleton(pro.getProperty("info"));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private Singleton(String info){
this.info = info;
}
public String getInfo() {
return info;
}
public void setInfo(String info) {
this.info = info;
}
@Override
public String toString() {
return "Singleton [info=" + info + "]";
}
}

对应的配置文件single.properties:

1
info=xxx

注:该配置文件要放在src目录下面,否则Properties加载不到

4、懒汉式——线程不安全(适用于单线程)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
* 懒汉式:
* 延迟创建这个实例对象
*
* (1)构造器私有化
* (2)用一个静态变量保存这个唯一的实例
* (3)提供一个静态方法,获取这个实例对象
*/
public class Singleton {
private static final Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
5、懒汉式——线程安全(加锁,适用于多线程)
1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
private volatile static final Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
Synchronized(Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
}
6、懒汉式——双重检查(适用于多线程,效率比加锁高)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private volatile static final Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
Synchronized(Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
7、懒汉式——静态内部类形式(适用于多线程)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* 在内部类被加载和初始化时,才创建INSTANCE实例对象
* 静态内部类不会自动随着外部类的加载和初始化而初始化,它是要单独去加载和初始化的。
* 因为是在内部类加载和初始化时,创建的,因此是线程安全的
*/
public class Singleton {
private Singleton() {}
private static class Inner {
private static final volatile Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Inner.INSTANCE;
}
}

小结

  • 如果是饿汉式,枚举形式最简单
  • 如果是懒汉式,静态内部类形式最简单

3、类初始化与实例初始化

面试

image-20210924212654176

运行结果:

1
2
(5)(1)(10)(6)(9)(3)(2)(9)(8)(7)
(9)(3)(2)(9)(8)(7)

解析

1、类初始化过程
  1. 一个类要创建实例需要先加载并初始化该类
    • main方法所在的类需要先加载和初始化
  2. 一个子类要初始化需要先初始化父类
  3. 一个类初始化就是执行()方法
    • ()方法由静态类变量显示赋值代码和静态代码块组成
    • 类变量显示赋值代码和静态代码块代码从上到下顺序执行
    • ()方法只执行一次
2、实例初始化过程

实例初始化就是执行()方法

  • ()方法可能重载有多个,有几个构造器就有几个方法
  • ()方法由非静态实例变量显示赋值代码和非静态代码块对应构造器代码组成
  • 非静态实例变量显示赋值代码和非静态代码块代码从上到下顺序执行,而对应构造器的代码最后执行
  • 每次创建实例对象,调用对应构造器,执行的就是对应的方法
  • 方法的首行是super()或super(实参列表),即对应父类的方法
3、方法的重写
  • 哪些方法不可以被重写
    • final方法
    • 静态方法
    • private等子类中不可见方法
  • 对象的多态性
    • 子类如果重写了父类的方法,通过子类对象调用的一定是子类重写过的代码
    • 非静态方法默认的调用对象是this
    • this对象在构造器或者说**方法中就是正在创建的对象**
  • 方法的重写与重载的区别
    • 方法重写(Override):
      • 是子类对父类的允许访问的方法的实现过程进行重新编写,返回值和形参都不能改变。即外壳不变,核心重写!
      • 好处在于子类可以根据需要,定义特定于自己的行为。 也就是说子类能够根据需要实现父类的方法。
      • 方法的重写规则
        • 参数列表与被重写方法的参数列表必须完全相同。
        • 返回类型与被重写方法的返回类型可以不相同,但是必须是父类返回值的派生类(java5 及更早版本返回类型要一样,java7 及更高版本可以不同)。
        • 访问权限不能比父类中被重写的方法的访问权限更低。例如:如果父类的一个方法被声明为 public,那么在子类中重写该方法就不能声明为 protected。
        • 父类的成员方法只能被它的子类重写。
        • 声明为 final 的方法不能被重写。
        • 声明为 static 的方法不能被重写,但是能够被再次声明。
        • 子类和父类在同一个包中,那么子类可以重写父类所有方法,除了声明为 private 和 final 的方法。
        • 子类和父类不在同一个包中,那么子类只能够重写父类的声明为 public 和 protected 的非 final 方法。
        • 重写的方法能够抛出任何非强制异常,无论被重写的方法是否抛出异常。但是,重写的方法不能抛出新的强制性异常,或者比被重写方法声明的更广泛的强制性异常,反之则可以。
        • 构造方法不能被重写。
        • 如果不能继承一个类,则不能重写该类的方法。
    • 方法重载(Overload):
      • 重载(overloading) 是在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。
      • 每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。
      • 最常用的地方就是构造器的重载。
      • 方法重载规则
        • 被重载的方法必须改变参数列表(参数个数或类型不一样);
        • 被重载的方法可以改变返回类型;
        • 被重载的方法可以改变访问修饰符;
        • 被重载的方法可以声明新的或更广的检查异常;
        • 方法能够在同一个类中或者在一个子类中被重载。
        • 无法以返回值类型作为重载函数的区分标准。

4、方法参数传递机制

面试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.Arrays;

public class Exam4 {
public static void main(String[] args) {
int i = 1;
String str = "hello";
Integer num = 200;
int[] arr = {1,2,3,4,5};
MyData my = new MyData();

change(i,str,num,arr,my);

System.out.println("i = " + i);
System.out.println("str = " + str);
System.out.println("num = " + num);
System.out.println("arr = " + Arrays.toString(arr));
System.out.println("my.a = " + my.a);
}
public static void change(int j, String s, Integer n, int[] a,MyData m){
j += 1;
s += "world";
n += 1;
a[0] += 1;
m.a += 1;
}
}
class MyData{
int a = 10;
}

运行结果

1
2
3
4
5
i = 1
str = hello
num = 200
arr = [2,3,4,5,6]
my.a = 11

解析

以上代码的方法参数传递机制

image-20210924230643727

小结

方法的参数传递机制:

  • 形参是基本数据类型
    • 传递数据值
  • 实参是引用数据类型
    • 传递地址值
  • 特殊的类型:String包装类等对象不可变性

5、递归与迭代

面试

编程题:有n步台阶,一次只能上1步或2步,共有多少种走法?

  1. 递归
  2. 迭代

解析

1、递归

image-20210925002308721

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.junit.Test;

public class TestStep{
@Test
public void test(){
long start = System.currentTimeMillis();
System.out.println(f(100));//165580141
long end = System.currentTimeMillis();
System.out.println(end-start);//586ms
}

//实现f(n):求n步台阶,一共有几种走法
public int f(int n){
if(n<1){
throw new IllegalArgumentException(n + "不能小于1");
}
if(n==1 || n==2){
return n;
}
return f(n-2) + f(n-1);
}
}
2、循环迭代

image-20210925002343782

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import org.junit.Test;

public class TestStep2 {
@Test
public void test(){
long start = System.currentTimeMillis();
System.out.println(loop(100));//165580141
long end = System.currentTimeMillis();
System.out.println(end-start);//<1ms
}

public int loop(int n){
if(n<1){
throw new IllegalArgumentException(n + "不能小于1");
}
if(n==1 || n==2){
return n;
}

int one = 2;//初始化为走到第二级台阶的走法
int two = 1;//初始化为走到第一级台阶的走法
int sum = 0;

for(int i=3; i<=n; i++){
//最后跨2步 + 最后跨1步的走法
sum = two + one;
two = one;
one = sum;
}
return sum;
}
}

小结

  • 方法调用自身称为递归,利用变量的原值推出新值称为迭代。
  • 递归
    • 优点:大问题转化为小问题,可以减少代码量,同时代码精简,可读性好;
    • 缺点:递归调用浪费了空间,而且递归太深容易造成堆栈的溢出。
  • 迭代
    • 优点:代码运行效率好,因为时间只因循环次数增加而增加,而且没有额外的空间开销;
    • 缺点:代码不如递归简洁,可读性好

6、成员变量与局部变量

面试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Exam5 {
static int s;//成员变量,类变量
int i;//成员变量,实例变量
int j;//成员变量,实例变量
{
int i = 1;//非静态代码块中的局部变量 i
i++;
j++;
s++;
}
public void test(int j){//形参,局部变量,j
j++;
i++;
s++;
}
public static void main(String[] args) {//形参,局部变量,args
Exam5 obj1 = new Exam5();//局部变量,obj1
Exam5 obj2 = new Exam5();//局部变量,obj1
obj1.test(10);
obj1.test(20);
obj2.test(30);
System.out.println(obj1.i + "," + obj1.j + "," + obj1.s);
System.out.println(obj2.i + "," + obj2.j + "," + obj2.s);
}
}

运行结果

1
2
2,1,5
1,1,5

解析

  • 就近原则
    • 如果变量同名而且前面没有加this的话,采用的就是就近原则
  • 变量的分类
    • 成员变量:类变量、实例变量
    • 局部变量
  • 非静态代码块的执行:每次创建实例对象都会执行
  • 方法的调用规则:调用一次执行一次

小结

局部变量与成员变量的区别:

  1. 声明的位置
    • 局部变量:方法体{}中,形参,代码块{}中
    • 成员变量:类中方法外
      • 类变量:有static修饰
      • 实例变量:没有static修饰
  2. 修饰符
    • 局部变量:final
    • 成员变量:public、protected、private、final、static、volatile、transient
  3. 值存储的位置
    • 局部变量:栈
    • 实例变量:堆
    • 类变量:方法区
  4. 作用域
    • 局部变量:从声明处开始,到所属的}结束
    • 实例变量:在当前类中“this.”(有时this.可以缺省),在其他类中“对象名.”访问
    • 类变量:在当前类中“类名.”(有时类名.可以省略),在其他类中“类名.”或“对象名.”访问
  5. 生命周期
    • 局部变量:每一个线程,每一次调用执行都是新的生命周期
    • 实例变量:随着对象的创建而初始化,随着对象的被回收而消亡,每一个对象的实例变量是独立的
    • 类变量:随着类的初始化而初始化,随着类的卸载而消亡,该类的所有对象的类变量是共享的

当局部变量与xx变量重名时,如何区分:

  1. 局部变量与实例变量重名
    • 在实例变量前面加“this.”
  2. 局部变量与类变量重名
    • 在类变量前面加“类名.”

7、字符串常量java内部加载

面试——《深入理解Java虚拟机第三版》2.4.3

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
String str1 = new StringBuilder("58").append("tongcheng").toString();
System.out.println(str1);
System.out.println(str1.intern());
System.out.println(str1 == str1.intern());

System.out.println("------------------");

String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2);
System.out.println(str2.intern());
System.out.println(str2 == str2.intern());
}

运行结果:

1
2
3
4
5
6
7
58tongcheng
58tongcheng
true
------------------
java
java
false

解析

按照代码结果,java字符串答案为false 必然是两个不同的java,那另外一个java字符串如何加载进来的?

因为有一个初始化的java字符串**(JDK加载时自带的), 在加载sun.misc.Version这个类的时候进入常量池**。

img

img

1、OpenJDK8底层源码说明问题
1、System源码解析

System→ initializeSystemClass→Version:

img

img

img

img

2、类加载器和rt.jar

根加载器提前部署加载rt.jar

img

2、关于String 的 intern方法
1
public native String intern();

Returns a canonical representation for the string object.
A pool of strings, initially empty, is maintained privately by the class String.
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.
All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java™ Language Specification.
Returns:
a string that has the same contents as this string, but is guaranteed to be from a pool of unique strings.

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到字符串常量池中,并且返回次String对象的引用。

方法区和运行时常量池的溢出

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放在一起进行。在JDK6或者更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量。而在HotSpot虚拟机在JDK8中完全使用元空间来代替永久代,原本存放在永久代的字符串常量池被移至java堆当中,所以通过限制方法区容量已经没有了意义。如果我们在JDK7以及以上版本,通过限制java堆空间的大小,如:-Xmx。就能看到OOM:Java heap space。

小结

如果在JDK6当中,面试的代码返回的是两个false,因为在JD6当中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池当中存储,返回的也是永久代里面这个字符串实例的引用,与StringBuilder创建的字符串在java堆空间当中,所以必然不可能是同一个引用,结果返回false。

而在JDK7当中,intern()方法实现就不需要拷贝字符串实例到永久代了,因为字符串常量池已经从方法区移动到java堆空间当中,因此intern()方法只需要在字符串常量池里记录一下首次出现的字符串实例即可,因此intern()返回的引用和StringBuilder创建的字符串实例的引用就是同一个,结果返回true。

由于字符串”java”在类加载的时候就已经加载进字符串常量池当中了(不是首次创建),所以之后在java堆空间创建的字符串”java”的引用与在java堆空间的字符串常量池的字符串”java”的引用不是同一个,因此返回false。

注:不只是字符串”java”,在JDK7以及之后的版本中,其他字符串如果有在java初始化类加载的时候进行过加载的话,也是已经加载进java堆的字符串常量池当中了,返回也是false。如zip等等。


JAVA EE

1、Spring Bean 的作用域之间有什么区别

解析:

在Spring中,可以在元素的scope属性你设置bean的作用域,以决定这个bean是单实例的还是多实例的。

默认情况下,Spring只为每个在IOC容器里声明的bean创建唯一一个实例,整个IOC容器范围内都能共享该实例:所有后续的getBean()调用和bean引用都将返回这个唯一的bean实例。该作用域被称为singleton,它是所有bean的默认作用域。

Spring Bean一共有四个作用域,可以通过scope属性来指定bean的作用域:

类别 说明
singleton 默认值当IOC容器一创建就会创建bean的实例(饿汉式),而且是单例的,在SpringIOC容器中仅存在一个Bean实例
prototype 原型的当IOC容器一创建不再实例化该bean(懒汉式)每次调用getBean方法时在实例化该bean,而且每调用一次getBean方法就创建一个新的实例对象
request 每次HTTP请求实例化一个新的bean,该作用域仅适用于WebApplicationContext环境
session 在同一个HTTP Session会话中共享一个bean,不同的HTTP Session使用不同的Bean。该作用域仅适用于WebApplicationContext环境

2、Spring支持的常用数据库事务传播属性和事务隔离级别

解析:

1、什么是事务的传播?

当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。

2、事务的传播行为种类

事务的传播行为可以由传播属性指定。Spring定义了7种类传播行为

传播属性 描述
REQUIRED 如果有事务在运行,当前的方法就在这个事务内运行,否则,就启动一个新的事务,并在自己的事务内运行
REQUIRES_NEW 当前的方法必须启动新事务,并在它自己的事务内运行。如果有事务正在运行,应该将它挂起
SUPPORTS 如果有事务在运行,当前的方法就在这个事务内运行。否则它可以不运行在事务中
NOT_SUPPORTED 当前的方法不应该运行在事务中,如果有运行的事务,将它挂起
MANDATORY 当前的方法必须运行在事务内部,如果没有运行的事务,就抛出异常
NEVER 当前的方法不应该运行在事务内部,如果有运行的事务,就抛出异常
NESTED 如果有事务在运行,当前的方法就应该在这个事务的嵌套事务内运行,否则,就启动一个新的事务,并在它自己的事务内运行

事务传播属性可以在 @Transactional 注解的 propagation 属性中定义。

现在重点来讲一下 REQUIREDREQUIRES_NEW

3、例子

现有100现金,去购买价值60元、库存100件的《Spring》与价值50元、库存100件的《SpringMVC》

image-20210925014930971

有两种结果:

  1. 两本书都买不成,现金依旧有100元,两本书的库存依旧是100件
  2. 买成了第一本书《Spring》,买不了第二本书《SpringMVC》,现金为40元,《Spring》的库存只剩下99件,而《SpringMVC》的库存依旧有100件

导致以上两种结果的原因是事务的传播行为的不同。

1、REQUIRED

Propagation.REQUIRED:默认值,如果有事务在运行,当前的方法就在这个事务内运行,否则,就启动一个新的事务,并在自己的事务内运行。

结果:两本书都买不成,现金依旧有100元,两本书的库存依旧是100件

解析:

REQUIRED 传播行为:当bookService 的 purchase()方法被另外一个事务方法checkout()调用时,它默认会在现有的事务内运行。这个默认的传播行为就是REQUIRED 。因此在checkout()方法的开始和终止边界类内只有一个事务。这个事务只在checkout()方法结束的时候被提交,结果用户一本书都买不了

image-20210925014831510

2、REQUIRES_NEW

Propagation.REQUIRES_NEW:表示该方法必须启动一个新事务,并在自己的事务内运行。如果有事务在运行,就应该先挂起它。

结果:买成了第一本书《Spring》,买不了第二本书《SpringMVC》,现金为40元,《Spring》的库存只剩下99件,而《SpringMVC》的库存依旧有100件

解析:

image-20210925015219816

4、数据库的事务并发问题

假设现在有两个事务:Transactional01 和 Transactional02 并发执行。

  1. 脏读
    1. Transaction01将某条记录的AGE值从20修改为30。
    2. Transaction02读取了Transaction01更新后的值:30。
    3. Transaction01回滚,AGE值恢复到了20。
    4. Transaction02读取到的30就是一个无效的值。
  2. 不可重复读
    1. Transaction01读取了AGE值为20。
    2. Transaction02将AGE值修改为30。
    3. Transaction01再次读取AGE值为30,和第一次读取不一致。
  3. 幻读
    1. Transaction01读取了STUDENT表中的一部分数据。
    2. Transaction02向STUDENT表中插入了新的行。
    3. Transaction01读取了STUDENT表时,多出了一些行。

5、隔离级别

数据库系统必须具有隔离并发运行各个事务的能力,使它们不会相互影响,避免各种并发问题。一个事务与其他事务隔离的程度称为隔离级别。SQL标准中规定了多种事务隔离级别,不同隔离级别对应不同的干扰程度,隔离级别越高,数据一致性就越好,但并发性越弱。

  1. 读未提交:READ UNCOMMITTED
    • 允许TranSaction01读取Transaction02未提交的修改。
  2. 读已提交:READ COMMITTED
    • 要求Transaction01只能读取Transaction02已提交的修改。
  3. 可重复读:REPEATABLE READ
    • 确保Transaction01可以多次从一个字段中读取到相同的值,即Transaction01执行期间禁止其它事务对这个字段进行更新。
  4. 串行化:SERIALIZABLE
    • 确保Transaction01可以多次从一个表中读取到相同的行,在Transaction01执行期间,禁止其它事务对这个表进行添加、更新、删除操作。可以避免任何并发问题,但性能十分低下。

隔离级别属性可以在 @Transactional 注解的 isolation 属性中定义。如:

  • Isolation.READ_UNCOMMITTED
  • Isolation.READ_COMMITTED

6、各个隔离级别解决并发问题的能力

脏读 不可重复读 幻读
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ
SERIALIZABLE

表格当中的“有”“无”代表的是当前的左边隔离级别有没有上面的并发问题

7、各种数据库产品对事务隔离级别的支持程度

Oracle MySql
READ UNCOMMITTED ×
READ COMMITTED √(默认)
REPEATABLE READ × √(默认)
SERIALIZABLE

3、SpringMVC如何解决POST请求中文乱码问题

解析:

需要配置一个字符编码过滤器CharacterEncodingFilter来解决POST请求中文乱码问题

字符编码过滤器CharacterEncodingFilter源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class CharacterEncodingFilter extends OncePerRequestFilter {
// 设置的字符集
private String encoding;
// 响应的时候是否需要使用上面设置的字符集
private boolean forceEncoding = false;

// ...

// 核心方法
protected void dpFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

/**
* 如果没有设置 encoding的话,默认就为null。如果设置了encoding的话就为true
* forceEncoding如果设置为true的话,或者request.getCharacterEncoding()为null的话
* 就能进入if语句块
*/
if (this.encoding != null && (this.forceEncoding || request.getCharacterEncoding()==null)) {
// 将设置的encoding字符集放进去
request.setCharacterEncoding(this.encoding);
if (this.forceEncoding) {
// 响应的时候使用上面设置的字符集
response.setCharacterEncoding(this.encoding) ;
}
}
filterChain.doFliter(request, response);
}

解决问题:

  1. 在web.xml中配置CharacterEncodingFilter过滤器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!--解决POST请求的请求乱码问题-->
    <filter>
    <filter-name>CharacterEncodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
    <param-name>encoding</param-name>
    <param-value>UTF-8</param-value>
    </init-param>
    <init-param>
    <param-name>forceEncoding</param-name>
    <param-value>true</param-value>
    </init-param>
    </filter>

    注:该过滤器要配置在web.xml的最上面

  2. 在CharacterEncodingFilter过滤器的下面在配置一些过滤器的相关映射

    1
    2
    3
    4
    5
    <filter-mapping>
    <!-- 注意这里的名字需要和上面配置的filter-name一致-->
    <filter-name>CharacterEncodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
    </filter-mapping>

注意:

  • 以上方法只能解决的是POST请求的中文乱码问题
  • 如果把请求修改为GET,依旧会出现中文乱码问题

那么,怎么解决GET请求的中文乱码问题?

最简单的一种方式:修改tomcat服务器的server.xml文件

  1. 先找到tomcat服务器的server.xml文件

  2. 找到第一个Connector标签

  3. 在后面添加URIEncoding="UTF-8"

    image-20210925023912627

4、简单谈一下SpringMVC的工作流程

解析:

SpringMVC的工作流程图:

Snipaste_2021-09-24_20-33-17

代码执行流程:

image-20210925032612325

5、MyBatis中当实体类中的属性名和表中的字段名不一样的解决方法

解析:

  1. 在对应的mapper文件写sql语句时起别名

    image-20210925141914079

  2. 在Mybatis的全局配置文件mybatis-config.xml中开启驼峰命名规则

    1
    2
    3
    4
    5
    6
    <settings>
    <!-- 开启驼峰命名规则,可以将数据库中的下划线映射为驼峰命名
    例如: last_name 可以映射为lastName
    -->
    <setting name= "mapUnderscoreToCameLCase" value="true"/>
    </settings>
  3. 在Mapper映射文件中使用resultMap来自定义映射规则

    image-20210925142532325


Spring:主要考察IOC + AOP +TX(事务相关)

1、Aop的常用注解

img

2、有关于Spring aop的面试题

1、Srping aop全部通知顺序问题

你肯定知道spring,那说说aop的全部通知顺序?springboot或springboot2对aop的执行顺序影响?

  • SpringBoot1底层使用的是Spring4
  • SpringBoot2底层使用的是Spring5

其实问的就是Spring4与Spring5的aop的全部通知顺序的区别?

1、Spring4的aop的全部通知顺序

版本:Spring4.3.13 +springboot1.5.9

spring4默认用的是JDK的动态代理

正常执行:@Before (前置通知)@After(后置通知)@AfterReturning (正常返回)

异常执行:@Before (前置通知)@After(后置通知)@AfterThrowing (方法异常)

Spring4.3.13 +springboot1.5.9:正常aop顺序:

img

Spring4.3.13 +springboot1.5.9:异常aop顺序:

img

总结:

img

2、Spring5的aop的全部通知顺序

版本:Spring5.2.8 +springboot2.3.3

spring5默认动态代理用的是cglib,不再是JDK的动态代理, 因为JDK必须要实现接口,但有些类它并没有实现接口,所以更加通用的话就是cglib

正常执行:@Before(前置通知)@AfterReturning(正常返回)@After(后置通知))

异常执行:@Before(前置通知)@AfterThrowing(方法异常)@After(后置通知))

Spring5+springboot2.3.3:aop正常顺序:

image-20210122160308629

Spring5+springboot2.3.3:aop异常顺序:

image-20210122160313858

总结:

image-20211007004349302

3、Srping aop全部通知顺序总结

image-20210122160250883

2、Spring 的循环依赖问题

1、相关面试题
  1. 你解释下spring中的三级缓存?
  2. 三级缓存分别是什么?三个Map有什么异同?
  3. 什么是循环依赖?请你谈谈?看过 Spring源码吗?一般我们说的 Spring容器是什么?
  4. 如何检测是否存在循环依赖?实际开发中见过循环依赖的异常吗?
  5. 多例的情况下,循环依赖问题为什么无法解决?
2、什么是循环依赖?

多个 bean 之间相互依赖,形成了一个闭环

比如:A 依赖于 B、B 依赖于 C、C 依赖于 A。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CircularDependency {
class A {
B b;
}

class B {
C c;
}

class C {
A a;
}
}

通常来说,如果问 Spring 容器内部如何解决循环依赖, 一定是指默认的单例 Bean 中,属性互相引用的场景。也就是说,Spring 的循环依赖,是 Spring 容器注入时候出现的问题

image-20210122181643319

3、两种注入方式对循环依赖的影响

官网对循环依赖的说明

image-20211007005057732

两种注入方式对循环依赖的影响

  • 构造器注入:容易造成无法解决的循环依赖,不推荐使用(If you use predominantly constructor injection, it is possible to create an unresolvable circular dependency scenario.)
  • Setter 注入:推荐使用 setter 方式注入单例 bean

spring容器:

  • 默认的单例(singleton)的场景是支持循环依赖的,不报错。
  • 原型(Prototype)的场景是不支持循环依赖的,会报错。默认单例,修改为原型scope=”prototype” 就导致了循环依赖错误

结论我们 AB 循环依赖问题只要注入方式是 setter 且bean 的 scope 属性是 singleton,就不会有循环依赖问题

4、那么Spring底层是怎么解决循环依赖的呢

Spring 内部通过 3 级缓存来解决循环依赖

所谓的三级缓存其实就是 Spring 容器内部用来解决循环依赖问题的三个 Map,这三个 Map 在 DefaultSingletonBeanRegistry 类中

只有单例的bean会通过三级缓存提前暴露来解决循环依赖问题,而非单例的bean,每次从容器中获取都是一个新的对象,都会重新创建,所以非单例的bean是没有缓存的,不会将其放到三级缓存中,因此也解决不了循环依赖问题。

那么三级缓存的这三个Map分别是什么?有什么异同?

  1. 第一级缓存〈也叫单例池Map<String, Object> singletonObjects:常说的 Spring 容器就是指它,我们获取单例 bean 就是在这里面获取的,存放已经经历了完整生命周期的Bean对象
  2. 第二级缓存:Map<String, Object> earlySingletonObjects,存放早期暴露出来的Bean对象,Bean的生命周期未结束(属性还未填充完整,可以认为是半成品的 bean)
  3. 第三级缓存: Map<String, ObjectFactory<?>> singletonFactories,存放可以生成Bean的工厂,用于生产(创建)bean对象
5、源码 Deug 前置知识
1、实例化 & 初始化

实例化和初始化的区别

  • 实例化:堆内存中申请一块内存空间

    image-20210123101131093

  • 初始化:完成属性的填充

    image-20210123101143372

2、3个Map & 5个方法

三级缓存 + 五大方法

image-20211007165015054

img

三级缓存《==》3个Map

  1. 第一级缓存singletonObjects:存放的是已经初始化好了的Bean,bean名称与bean实例相对应,即所谓的单例池。表示已经经历了完整生命周期的Bean对象
  2. 第二级缓存earlySingletonObjects:存放的是实例化了,但是未初始化的Bean,bean名称与bean实例相对应。表示Bean的生命周期还没走完(Bean的属性还未填充)就把这个Bean存入该缓存中。也就是实例化但未初始化的bean放入该缓存里
  3. 第三级缓存singletonFactories:表示存放生成bean的工厂,存放的是FactoryBean,bean名称与bean工厂对应。假如A类实现了FactoryBean,那么依赖注入的时候不是A类,而是A类产生的Bean

五大方法

  1. getSingleton():从容器里面获得单例的bean,没有的话则会创建 bean
  2. doCreateBean():执行创建 bean 的操作(在 Spring 中以 do 开头的方法都是干实事的方法)
  3. populateBean():创建完 bean 之后,对 bean 的属性进行填充
  4. initializeBean():初始化bean对象,也是在这里完成AOP代理
  5. addSingleton():bean 初始化完成之后,添加到单例容器池中,下次执行 getSingleton() 方法时就能获取到

注:关于第三级缓存 Map<String, ObjectFactory<?>> singletonFactories的说明:singletonFactories 的 value 为 ObjectFactory 接口实现类的实例。ObjectFactory 为函数式接口,在该接口中定义了一个 getObject() 方法用于获取 bean,这也正是工厂思想的体现(工厂设计模式)

image-20211007011505668

3、对象在三级缓存中的迁移

A/B 两对象在三级缓存中的迁移说明

  1. A创建过程中需要B,于是A将自己放到三级缓存里面,去实例化B
  2. B实例化的时候发现需要A,于是B先查一级缓存,没有,再查二级缓存,还是没有,再查三级缓存,找到了A,然后把三级缓存里面的这个A放到二级缓存里面,并删除三级缓存里面的A
  3. B顺利初始化完毕,将自己放到一级缓存里面(此时B里面的A依然是创建中状态),然后回来接着创建A,此时B已经创建结束,直接从一级缓存里面拿到B,然后完成创建,并将A自己放到一级缓存里面。
6、详细 Debug 流程

技巧:如何阅读框架源码?答:打断点 + 看日志

1、beanA的实例化

ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml"); 代码处打上断点,逐步执行(Step Over),发现执行 new ClassPathXmlApplicationContext("applicationContext.xml") 操作时,beanA 和 beanB 都已经被创建好了,因此我们需要进入 new ClassPathXmlApplicationContext("applicationContext.xml")

image-20210122230104635

进入 new ClassPathXmlApplicationContext("applicationContext.xml")

点击 Step Into,首先进入了静态代码块中,不管我们的事,使用 Step Out 退出此方法

image-20210122230423326

再次 Step Into,进入 ClassPathXmlApplicationContext 类的构造函数,该构造函数使用 this 调用了另一个重载构造函数

image-20210122230632877

继续 Step Into,进入重载构造函数后单步 Step Over,发现执行完 refresh() 方法后输出如下日志,于是我们将断点打在 refresh() 那一行

image-20210122230823849

进入 refresh() 方法

Step Into 进入 refresh() 方法,发现执行完 finishBeanFactoryInitialization(beanFactory) 方法后输出日志,于是我们将断点打在 finishBeanFactoryInitialization(beanFactory) 那一行

从注释也可以看出本方法完成了非懒加载单例 bean的初始化(Instantiate all remaining (non-lazy-init) singletons.)

image-20210122231301414

进入 finishBeanFactoryInitialization(beanFactory) 方法

Step Into 进入 finishBeanFactoryInitialization(beanFactory) 方法,发现执行完 beanFactory.preInstantiateSingletons() 方法后输出日志,于是我们将断点打在 beanFactory.preInstantiateSingletons() 那一行

从注释也可以看出本方法完成了非懒加载单例 bean的初始化(Instantiate all remaining (non-lazy-init) singletons.)

image-20210122231617225

进入 beanFactory.preInstantiateSingletons() 方法

Step Into 进入 beanFactory.preInstantiateSingletons() 方法,发现执行完 getBean(beanName) 方法后输出日志,于是我们将断点打在 getBean(beanName) 那一行

image-20210122231902178

进入 getBean(beanName) 方法

getBean(beanName) 调用了 doGetBean(name, null, null, false) 方法,即:在 Spring 里面,以do 开头的方法都是干实事的方法

image-20210122232111871

进入 doGetBean(name, null, null, false) 方法

我们可以给 bean 配置别名,这里的 transformedBeanName(name) 方法就是将用户别名转换为 bean 的真实名称

image-20210122232301105

进入 getSingleton(beanName) 方法

image-20210122232610296

调用了其重载的方法,allowEarlyReference == true 表示可以从三级缓存 earlySingletonObjects 中获取 bean,allowEarlyReference == false 表示不可以从三级缓存 earlySingletonObjects 中获取 bean

image-20210122232708127

getSingleton(beanName, true) 方法尝试从一级缓存 singletonObjects 中获取 beanA,beanA 现在还没有开始造,(isSingletonCurrentlyInCreation(beanName) 返回 false),获取不到返回 null

image-20210122232923347

回到 doGetBean(name, null, null, false) 方法中

getSingleton(beanName)方法返回null

image-20210122233201164

我们所说的 bean 对于 Spring 来说就是一个个的 RootBeanDefinition 实例

image-20210122233255044

这个 dependsOn 变量对应于 bean 的 depends-on="" 属性,我们没有配置过,因此为 null

image-20210122233409563

转了一圈发现并没有 beanA,终于要开始准备创建 beanA 啦

image-20210122233943061

进入 getSingleton(beanName, () -> {...} 方法

首先尝试从一级缓存 singletonObjects 获取 beanA,那肯定是获取不到,因此 singletonObject == null,那么就需要创建 beanA,此时日志会输出:【Creating shared instance of singleton bean ‘a’】

image-20210123112301014

当执行完 singletonObject = singletonFactory.getObject(); 时,会输出【A created success】,这说明执行 singletonFactory.getObject() 方法时将会实例化 beanA,并且根据代码变量名可得知单例工厂创建的,这个单例工厂就是我们传入的 Lambda 表达式

image-20210123112653528

进入 createBean(beanName, mbd, args) 方法:

mbdToUse 将用于创建 beanA:

image-20210123113109315

来了,终于要执行 doCreateBean(beanName, mbdToUse, args) 实例化 beanA:

image-20210123113439196

进入 doCreateBean(beanName, mbdToUse, args) 方法

factoryBeanInstanceCache 中并不存在 beanA 对应的 Wrapper 缓存,instanceWrapper == null,因此我们要去创建 beanA 对应的 instanceWrapper,Wrapper 有包裹之意思,instanceWrapper 翻译过来为实例包裹器的意思,形象理解为:beanA 实例化需要经过 instanceWrapper 之手,beanA 实例被 instanceWrapper 包裹在其中

image-20210123113721708

进入 createBeanInstance(beanName, mbd, args) 方法

这一看就是反射的操作

image-20210123114509257

这里有个 resolved 变量,写着注释:Shortcut when re-creating the same bean…,我个人理解是 resolved 标志该 bean 是否已经被实例化了,如果已经被实例化了,那么 resolved == true,这样就不用重复创建同一个 bean 了

image-20210123114722004

Candidate constructors for autowiring? 难道是构造器自动注入?在 return 的时候调用 instantiateBean(beanName, mbd) 方法实例化 beanA,并将其返回

image-20210123115049589

进入 instantiateBean(beanName, mbd) 方法

getInstantiationStrategy().instantiate(mbd, beanName, this) 方法完成了 beanA 的实例化

image-20210123115807740

进入 getInstantiationStrategy().instantiate(mbd, beanName, this) 方法

首先获取已经解析好的构造器 bd.resolvedConstructorOrFactoryMethod,这是第一次创建,当然还没有啦,因此 constructorToUse == null。然后获取 A 的类型,如果发现是接口则直接抛异常。最后获取 A 的公开构造器,并将其赋值给 bd.resolvedConstructorOrFactoryMethod

image-20210123120436544

获取构造器的目的当然是为了实例化 beanA

image-20210123120804415

进入 BeanUtils.instantiateClass(constructorToUse) 方法

通过构造器创建 beanA 实例,Step Over 后会输出:【A created success】,并且会回到 getInstantiationStrategy().instantiate(mbd, beanName, this) 方法中

image-20210123120941726

回到 getInstantiationStrategy().instantiate(mbd, beanName, this) 方法中

BeanUtils.instantiateClass(constructorToUse) 方法中创建好了 beanA 实例,不过还没有进行初始化,可以看到属性 b = null,Step Over 后会回到 instantiateBean(beanName, mbd) 方法中

image-20210123163945288

回到 instantiateBean(beanName, mbd) 方法中

得到刚才创建的 beanA 实例,但其属性并未被初始化

image-20210123164916170

将实例化的 beanA 装进 BeanWrapper 中并返回 bw

image-20210123165203819

回到 createBeanInstance(beanName, mbd, args) 方法中

得到刚才创建的 beanWrapper 实例,该 beanWrapper 包裹(封装)了刚才创建的 beanA 实例

image-20210123165447074

回到 doCreateBean(beanName, mbdToUse, args) 方法中

doCreateBean(beanName, mbdToUse, args) 方法获得 BeanWrapper instanceWrapper,用于封装 beanA 实例

image-20210123165629641

获取并记录 A 的全类名:

image-20210123170700410

执行 BeanPostProcessor

image-20210123170837080

如果该 bean 是单例 bean(mbd.isSingleton()),并且允许循环依赖(this.allowCircularReferences),并且当前 bean 正在创建过程中(isSingletonCurrentlyInCreation(beanName)),那么就允许提前暴露该单例 bean(earlySingletonExposure = true),则会执行 addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)) 方法将该 bean 放到三级缓存 singletonFactories

image-20210123170940500

进入 addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)) 方法

首先去一级缓存 singletonObjects 中找一下有没有 beanA,肯定没有啦~然后将 beanA 添加到三级缓存 singletonFactories 中,并将 beanA 从二级缓存 earlySingletonObjects 中移除(虽然此时的二级缓存当中没有beanA),最后将 beanName 添加至 registeredSingletons 中,表示该 bean 实例已经被注册

image-20210123171328459

2、beanA 的属性填充——初始化

回到 doCreateBean(beanName, mbdToUse, args) 方法中

接着回到 doCreateBean(beanName, mbdToUse, args) 方法中,需要执行 populateBean(beanName, mbd, instanceWrapper) 方法对 beanA 中的属性进行填充

image-20210123171726753

进入 populateBean(beanName, mbd, instanceWrapper) 方法

反射获取 beanA 的属性列表

image-20210123171917156

执行 applyPropertyValues(beanName, mbd, bw, pvs) 方法完成 beanA 属性的填充

image-20210123172048186

进入 applyPropertyValues(beanName, mbd, bw, pvs) 方法

取到 beanA 的属性列表,发现有个属性为 b

image-20210123172426598

遍历每一个属性,并对每一个属性进行注入,valueResolver.resolveValueIfNecessary(pv, originalValue) 的作用:Given a PropertyValue, return a value, resolving any references to other beans in the factory if necessary.

image-20210123172613391

进入 valueResolver.resolveValueIfNecessary(pv, originalValue) 方法

通过 resolveReference(argName, ref) 解决依赖注入的问题:

image-20210123173739270

进入 resolveReference(argName, ref) 方法

先获得属性 b 的名称,再通过 this.beanFactory.getBean(resolvedName) 方法获取 beanB 的实例

image-20210123174227557

3、beanB 的实例化

进入 this.beanFactory.getBean(resolvedName) 方法

哦,这熟悉的 doGetBean(name, null, null, false) 方法:

image-20210123174510971

再次执行 doGetBean(name, null, null, false) 方法

beanB 还没有实例化,因此 getSingleton(beanName) 方法返回 null

image-20210123174631167

又来到了这个熟悉的地方,先尝试获取 beanB 实例,获取不到就执行 createBean() 的操作

image-20210123181159951

进入 getSingleton(beanName, () -> {... } 方法

首先尝试从一级缓存 singletonObjects 中获取 beanB,那肯定是获取不到的呀

image-20210123181540128

然后就调用 singletonFactory.getObject() 创建 beanB

image-20210123230319469

进入 createBean(beanName, mbd, args) 方法

获取到 beanB 的类型为 com.heygo.spring.circulardependency.B

image-20210123230722139

之前创建 beanA 的时候没有看到,现在看到挺有趣的:Give BeanPostProcessors a chance to return a proxy instead of the target bean instance. 也就是说我们可以通过 BeanPostProcessors 返回 bean 的代理,而非 bean 本身。然后喜闻乐见,又来到了 doCreateBean(beanName, mbdToUse, args) 环节image-20210123231047179

进入 doCreateBean(beanName, mbdToUse, args) 方法

老样子,创建 beanB 对应的 BeanWrapper instanceWrapper

image-20210123231513068

进入 createBeanInstance(beanName, mbd, args) 方法

调用 instantiateBean(beanName, mbd) 创建 beanWrapper

image-20210123231659756

进入 instantiateBean(beanName, mbd) 方法

调用 getInstantiationStrategy().instantiate(mbd, beanName, this) 创建 beanWrapper

image-20210123231815848

进入 getInstantiationStrategy().instantiate(mbd, beanName, this) 方法

获取 com.heygo.spring.circulardependency.B 的构造器,并将构造器信息记录在 bd.resolvedConstructorOrFactoryMethod 字段中

image-20210123232017300

调用 BeanUtils.instantiateClass(constructorToUse) 方法创建 beanB 实例:

image-20210123232156728

进入 BeanUtils.instantiateClass(constructorToUse) 方法

通过调用 B 类的构造器创建 beanB 实例,此时控制台会输出:【B created success】

image-20210123232344101

回到 instantiateBean(beanName, mbd) 方法中

instantiateBean(beanName, mbd) 方法中得到创建好的 beanB 实例,并将其丢进 beanWrapper 中,封装为 BeanWrapper bw 对象

image-20210123232617698

回到 doCreateBean(beanName, mbdToUse, args) 方法中

createBeanInstance(beanName, mbd, args)方法将返回包装着 beanB 的beanWrapper

image-20210123233026527

执行 BeanPostProcessor 的处理过程:

image-20210123233404820

beanB 由于满足单例并且正在被创建,因此 beanB 可以被提前暴露出去(在属性还未初始化的时候可以提前暴露出去),于是执行 addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)) 方法将其添加至三级缓存 singletonFactory

image-20210123233523439

进入 addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)) 方法

将 beanB 实例添加至三级缓存 singletonFactory 中,从二级缓存 earlySingletonObjects 中移除(虽然此时的第二级缓存当中也没有beanB),并注册其 beanName

注:此时第三级缓存有两个值:

  • K:beanA,V:beanAFactory的lambda表达式
  • K:beanB,V:beanBFactory的lambda表达式

image-20210123233916978

回到 doCreateBean(beanName, mbdToUse, args) 方法中

执行 populateBean(beanName,mbd,instancewrapper) 方法填充 beanB 的属性

image-20210123234133479

4、beanB 的属性填充——初始化

进入 populateBean(beanName, mbd, instanceWrapper) 方法

执行 mbd.getPropertyValues() 方法获取 beanB 的属性列表

image-20210123234301151

执行 applyPropertyValues(beanName, mbd, bw, pvs) 方法完成 beanB 属性的填充

image-20210123234518400

进入 applyPropertyValues(beanName, mbd, bw, pvs) 方法

执行 mpvs.getPropertyValuelist() 方法获取 beanB 的属性列表

image-20210123234654991

遍历每一个属性,并对每一个属性进行注入,valueResolver.resolveValueIfNecessary(pv, originalValue) 的作用:Given a PropertyValue, return a value, resolving any references to other beans in the factory if necessary.

image-20210123234750025

进入 valueResolver.resolveValueIfNecessary(pv, originalValue) 方法

执行 resolveReference(argName, ref) 方法为 beanB 注入名为 a 属性

image-20210123234916337

进入 resolveReference(argName, ref) 方法

执行 this.beanFactory.getBean(resolvedName) 方法获取 beanA 实例,其实就是执行 doGetBean(name, null, null, false) 方法

image-20210123235249422

进入 doGetBean(name, null, null, false) 方法

关键来了,这里执行 getSingleton(beanName) 是够能够获取到 beanA 实例呢?答案是可以

image-20210123235540413

进入 getSingleton(beanName, true) 方法

getSingleton(beanName) 调用了其重载方法 getSingleton(beanName, true),接下来的逻辑很重要:

  1. beanA 并没有存放在一级缓存 singletonObjects 中,因此执行 Object singletonObject = this.singletonObjects.get(beanName) 后,singletonObject == null,再加上 beanA 正在满足创建的条件(isSingletonCurrentlyInCreation(beanName) == true),因此可以进入第一层 if 判断

    image-20210124000622676

  2. beanA 被存放在三级缓存 singletonFactories 中,从二级缓存 earlySingletonObjects 中获取也是 null,因此可以进入第二层 if 判断

    image-20210124000702685

  3. 从三级缓存中获取 beanA 肯定不为空,因此可以进入第三层 if 判断

    image-20210124000956873

  4. 进入第三个if块之后:

    1. 从单例工厂 singletonFactory 中获取 beanA;
    2. 将 beanA 添加至二级缓存 earlySingletonObjects 中;
    3. 将 beanA 从三级缓存 singletonFactories 中移除

    image-20210124001103112

注:

  • 此时的第三级缓存当中有K:beanB,V:beanBFactory的lambda表达式
  • 此时的第二级缓存当中有K:属性A,V:beanA的实例(未完成初始化)

回到 doGetBean(name, null, null, false) 方法中

执行 Object sharedInstance = getSingleton(beanName) 将获得之前存入三级缓存 singletonFactories 中的 beanA

image-20210124001710344

好家伙,获取到 beanA 后就直接返回了

image-20210124001902295

回到 applyPropertyValues(beanName, mbd, bw, pvs) 方法中

执行 valueResolver.resolveValueIfNecessary(pv, originalValue) 方法获取到 beanA 实例

image-20210124002251839

将属性 beanA 添加到 deepCopy 集合中(List<PropertyValue> deepCopy = new ArrayList<>(original.size())

image-20210124003102723

执行 bw.setPropertyValues(new MutablePropertyValues(deepCopy)) 方法将会填充 beanB 中的 a 属性

image-20210124113724764

进入 bw.setPropertyValues(new MutablePropertyValues(deepCopy)) 方法

调用了其重载方法 setPropertyValues(pvs, false, false)

image-20210124113855809

进入 setPropertyValues(pvs, false, false) 方法

在该方法中会对 bean 的每一个属性进行填充(通过 setPropertyValues(pvs, false, false) 方法对属性进行赋值)

image-20210124114022056

回到 applyPropertyValues(beanName, mbd, bw, pvs) 方法中

此时 bw 包裹着 beanB,执行 bw.setPropertyValues(new MutablePropertyValues(deepCopy)) 方法会将 deepCopy 中的元素依次赋值给 beanB 的各个属性,此时 beanB 中的 a 属性已经赋值为 beanA

image-20210124003407181

回到 doCreateBean(beanName, mbdToUse, args) 方法中

因为 instanceWrapper 封装了 beanB,所以执行了 populateBean(beanName, mbd, instanceWrapper) 方法后,beanB 中的 a 属性就已经被填充啦,可以看到 beanB 中有 beanA,但 beanA 中没有 beanB

image-20210124102307241

执行 getSingleton(beanName, false) 方法,传入的参数 allowEarlyReference = false,表示不允许从三级缓存 singletonFactories 中获取 beanB

image-20210124102706141

进入 getSingleton(beanName, false) 方法

由于传入的参数 allowEarlyReference = false,因此第三层 if 判断铁定进不去,而 beanB 在三级缓存 singletonFactories 中存着,因此返回的 singletonObjectnull

image-20210124102941902

回到 doCreateBean(beanName, mbdToUse, args) 方法中

这里应该是执行 bean 的 destroy-method ,应该只会在工厂销毁的时候并且 bean 为单例的条件下,其内部逻辑才会执行。registerDisposableBeanIfNecessary(beanName, bean, mbd) 方法的注释如下:Add the given bean to the list of disposable beans in this factory, registering its DisposableBean interface and/or the given destroy method to be called on factory shutdown (if applicable). Only applies to singletons. 最后将 beanB 返回(属性 a 已经填充完毕)

image-20210124103604267

回到 createBean(beanName, mbd, args) 方法

执行 doCreateBean(beanName, mbdToUse, args) 方法得到包装 beanB 实例(属性 a 已经填充完毕),并将其返回

image-20210124104113201

回到 getSingleton(beanName, () -> { ... } 方法中

执行 singletonFactory.getObject() 方法获取到 beanB 实例,这里的 singletonFactory 是之前调用 getSingleton(beanName, () -> { … } 方法传入的 Lambda 表达式,然后将 newSingleton 设置为 true

image-20210124104331897

执行 addSingleton(beanName, singletonObject) 方法将 beanB 实例添加到一级缓存 singletonObjects

image-20210124105036099

进入 addSingleton(beanName, singletonObject) 方法

  1. 将 beanB 放入一级缓存 singletonObjects
  2. 将 beanB 从三级缓存 singletonFactories 中删除(beanB 确实在三级缓存中)
  3. 将 beanB 从二级缓存 earlySingletonObjects 中删除(beanB 并不在二级缓存中)
  4. 将 beanB 的 beanName 注册到 registeredSingletons 中(之前添加至三级缓存的时候已经注册过啦~)

image-20210124105602572

注:

  • 此时的第三级缓存当中已经没有东西了
  • 第二级缓存当中有K:A,V:beanA的实例(半成品,未初始化)
  • 第一级缓存当中有K:B,V:beanB的实例(成品)

回到 getSingleton(beanName, () -> { ... } 方法中

执行 addSingleton(beanName, singletonObject) 将 beanB 添加到一级缓存 singletonObjects 后,将 beanB 返回

image-20210124105914335

回到 doGetBean(name, null, null, false) 方法中

执行完 getSingleton(beanName, () -> { ... } 方法后,得到属性已经填充好的 beanB,并且已经将其添加至一级缓存 singletonObjects

image-20210124110528798

将 beanB 返回,想想返回到哪儿去了呢?当初时因为 beanA 要填充其属性 b,才执行了创建 beanB 的操作,现在返回肯定是将 beanB 返回给 beanA

image-20210124110743721

5、beanA 的属性填充

回到 resolveReference(argName, ref) 方法中

执行完 this.beanFactory.getBean(resolvedName) 方法后,获得了属性填充好的 beanB 实例,并将其实例返回

image-20210124112123075

回到 applyPropertyValues(beanName, mbd, bw, pvs) 方法中

执行完 valueResolver.resolveValueIfNecessary(pv, originalValue) 方法后,将获得属性填充好的 beanB 实例

image-20210124112327554

b 属性添加至 deepCopy 集合中:

image-20210124112557933

执行 bw.setPropertyValues(new MutablePropertyValues(deepCopy)) 方法对 beanA 的 b 属性进行填充

image-20210124112719836

进入 setPropertyValues(pvs, false, false) 方法

bw.setPropertyValues(new MutablePropertyValues(deepCopy)) 方法中调用了 setPropertyValues(pvs, false, false) 方法,在该方法中会对 bean 的每一个属性进行填充(通过 setPropertyValues(pvs, false, false) 方法对属性进行赋值)

image-20210124113319078

回到 applyPropertyValues(beanName, mbd, bw, pvs) 方法中

此时 bw 中包裹着 beanA,执行 bw.setPropertyValues(new MutablePropertyValues(deepCopy)) 方法会将 deepCopy 中的元素依次赋值给 beanA 的各个属性,此时 beanA 中的 b 属性已经赋值为 beanA,又加上之前 beanB 中的 a 属性已经赋值为 beanA,此时可开启无限套娃模式

image-20210124114401647

回到 doCreateBean(beanName, mbdToUse, args) 方法中

执行完 populateBean(beanName, mbd, instanceWrapper) 方法后,可以开启无限套娃模式

image-20210124115239983

这次执行 getSingleton(beanName, false) 方法能获取到 beanA 吗?答:可以

image-20210124115314625

进入 getSingleton(beanName, false) 方法

之前 beanB 中注入 a 属性时,将 beanA 从三级缓存 singletonFactories 移动到了二级缓存 earlySingletonObjects 中,因此可以从二级缓存 earlySingletonObjects 中获取到 beanA

image-20210124211309555

回到 doCreateBean(beanName, mbdToUse, args) 方法中

最终将获取到的 beanA 返回

image-20210124211546214

回到 createBean(beanName, mbd, args) 方法中

执行 doCreateBean(beanName, mbdToUse, args) 方法后得到 beanA 实例,并将此实例返回

image-20210124211750032

回到 getSingleton(beanName, () -> { ... } 方法

执行 singletonFactory.getObject() 方法后将获得 beanA 实例,这里的 singletonFactory 是我们传入的 Lambda 表达式(专门用于创建 bean 实例)

image-20210124212010249

执行 addSingleton(beanName, singletonObject) 方法将 beanA 添加到一级缓存 singletonObjects

image-20210124212134609

进入 addSingleton(beanName, singletonObject) 方法

  1. 将 beanA 放入一级缓存 singletonObjects 中
  2. 将 beanA 从三级缓存 singletonFactories 中删除(beanA 并不在三级缓存中)
  3. 将 beanA 从二级缓存 earlySingletonObjects 中删除(beanA 确实在二级缓存中)
  4. 将 beanA 的 beanName 注册到 registeredSingletons 中(之前添加至三级缓存的时候已经注册过啦~)

image-20210124212338035

注:

  • 此时的第三级、第二级缓存都没有东西了
  • 在第一级缓存有两个已经实例化初始化好的成品:beanA与beanB

回到 getSingleton(beanName, () -> { ... } 方法中

将 beanA 添加至一级缓存 singletonObjects 后,将其返回

image-20210124212537559

回到 doGetBean(name, null, null, false) 方法中

执行 getSingleton(beanName, () -> { ... } 方法得到 beanA 实例后,将其返回

image-20210124212741165

回到 preInstantiateSingletons() 方法中

终于要结束了。。。执行完 getBean(beanName) 方法后,将得到无限套娃版本的 beanA 和 beanB 实例

image-20210124213019608

6、循环依赖总结

全部 Debug 断点

导出 Debug 所有断点:点击【View Breakpoints】

image-20210124214526041

image-20210124214624664

Debug 步骤总结

  1. 调用doGetBean()方法,想要获取beanA,于是调用getSingleton()方法从缓存中查找beanA
  2. getSingleton()方法中,从一级缓存中查找,没有,返回null
  3. doGetBean()方法中获取到的beanA为null,于是走对应的处理逻辑,调用getSingleton()的重载方法(参数为ObjectFactory的)去获取一个beanA单例
  4. getSingleton()方法中,先将beanA_name添加到一个集合中,用于标记该bean正在创建中。然后回调匿名内部类的creatBean()方法创建beanA对象
  5. 进入AbstractAutowireCapableBeanFactory#doCreateBean()先反射调用构造器创建出beanA的实例,然后判断。是否为单例是否允许提前暴露引用(对于单例一般为true)**、是否正在创建中〈即是否在第四步的集合中)。判断为true则将beanA添加到【三级缓存】中**
  6. 调用populateBean()对beanA进行属性填充,此时检测到beanA依赖于beanB,于是开始从一级缓存当中查找beanB
  7. 调用doGetBean()方法,和上面beanA的过程一样,到一级缓存中查找beanB,没有则创建,然后给beanB填充属性
  8. 此时beanB依赖于beanA,调用getSingleton()获取beanA,依次从一级、二级、三级缓存中找,此时从三级缓存中获取到beanA的创建工厂,通过创建工厂获取到singletonObject,此时这个singletonObject指向的就是上面在doCreateBean()方法中实例化的beanA
  9. 这样beanB就获取到了beanA的依赖,于是beanB顺利完成实例化将beanB从第三级缓存放在第一级缓存当中,并将beanA从三级缓存移动到二级缓存中
  10. 随后beanA继续他的属性填充工作,此时也获取到了beanB,beanA也随之完成了创建,回到getsingleton()方法中继续向下执行,将beanA从二级缓存移动到一级缓存中

image-20210124215934076

7、三级缓存总结

Spring 创建 Bean 的两大步骤

  1. 创建原始bean对象
  2. 填充对象属性和初始化

每次创建bean之前,我们都会从缓存中查下有没有该bean,因为是单例,只能有一个。当我们创建 beanA的原始对象后,并把它放到三级缓存中,接下来就该填充对象属性了,这时候发现依赖了beanB,接着就又去创建beanB,同样的流程,创建完 beanB填充属性时又发现它依赖了beanA又是同样的流程。

==不同的是==:这时候可以在三级缓存中查到刚放进去的原始对象beanA,所以不需要继续创建,用它注入beanB,完成beanB的创建。既然 beanB创建好了,所以beanA就可以完成填充属性的步骤了,接着执行剩下的逻辑,闭环完成。

Spring解决循环依赖依靠的是Bean的“中间态”这个概念,而这个中间态指的是已经实例化但还没初始化的状态,也即半成品。

实例化的过程又是通过构造器创建的,如果A还没创建好出来怎么可能提前曝光,所以构造器的循环依赖无法解决。

Spring为了解决单例的循环依赖问题,使用了三级缓存:

  1. 其中一级缓存为单例池〈 singletonObjects),我们的应用中使用的bean对象就是一级缓存中的
  2. 二级缓存为提前曝光对象( earlySingletonObjects),用来解决对象创建过程中的循环依赖问题
  3. 三级缓存为提前曝光对象工厂( singletonFactories),用于处理存在 AOP 时的循环依赖问题

image-20210124220433578

假设A、B循环引用,实例化A的时候就将其放入三级缓存中,接着填充属性的时候,发现依赖了B,同样的流程也是实例化后放入三级缓存,接着去填充属性时又发现自己依赖A,这时候从缓存中查找到早期暴露的A,没有AOP代理的话,直接将A的原始对象注入B,完成B的初始化后,进行属性填充和初始化,这时候B完成后,就去完成剩下的A的步骤,如果有AOP代理,就进行AOP处理获取代理后的对象A,注入B,走剩下的流程

8、Spring循环依赖+AOP源码分析
1、情况一:没有依赖,有 AOP

此时, SimpleBean 对象在 Spring 中是如何创建的呢,我们一起来跟下源码

img

接下来,我们从 DefaultListableBeanFactorypreInstantiateSingletons 方法开始 debug

img

没有跟进去的方法,或者快速跳过的,我们可以先略过,重点关注跟进去了的方法和停留了的代码,此时有几个属性值中的内容值得我们留意下:

img

我们接着从 createBean 往下跟

img

关键代码在 doCreateBean 中,其中有几个关键方法的调用值得大家去跟下:

img

此时:代理对象的创建是在对象实例化完成,并且初始化也完成之后进行的,是对一个成品对象创建代理对象

所以此种情况下:只用一级缓存就够了,其他两个缓存可以不要

2、情况二:循环依赖,没有AOP

此时循环依赖的两个类是: CircleLoop

对象的创建过程与前面的基本一致,只是多了循环依赖,少了 AOP,所以我们重点关注: populateBeaninitializeBean 方法

先创建的是 Circle 对象,那么我们就从创建它的 populateBean 开始,再开始之前,我们先看看三级缓存中的数据情况

img

img

我们开始跟 populateBean ,它完成属性的填充,与循环依赖有关,一定要仔细看,仔细跟

img

circle 对象的属性 loop 进行填充的时候,去 Spring 容器中找 loop 对象,发现没有则进行创建,又来到了熟悉的 createBean

此时三级缓存中的数据没有变化,但是 Set<String> singletonsCurrentlyInCreation 中多了个 loop

相信到这里大家都没有问题,我们继续往下看

img

loop 实例化完成之后,对其属性 circle 进行填充,去 Spring 中获取 circle 对象,又来到了熟悉的 doGetBean

此时一、二级缓存中都没有 circleloop ,而三级缓存中有这两个,我们接着往下看,重点来了,仔细看哦

img

通过 getSingleton 获取 circle 时,三级缓存调用了 getEarlyBeanReference ,但**由于没有 AOP,所以 getEarlyBeanReference 直接返回了普通的半成品 circle**,然后将 半成品 circle 放到了二级缓存,并将其返回,然后填充到了 loop 对象中

此时的 loop 对象就是一个成品对象了;接着将 loop 对象返回,填充到 circle 对象中,如下如所示

img

我们发现直接将 成品 loop 放到了一级缓存中,二级缓存自始至终都没有过 loop ,三级缓存虽说存了 loop ,但没用到就直接 remove 了

此时缓存中的数据,相信大家都能想到了:

img

虽说 loop 对象已经填充到了 circle 对象中,但还有一丢丢流程没走完,我们接着往下看

img

将 成品 circle 放到了一级缓存中,二级缓存中的 circle 没有用到就直接 remove 了,最后各级缓存中的数据相信大家都清楚了,就不展示了

我们回顾下这种情况下各级缓存的存在感,一级缓存存在感十足,二级缓存可以说无存在感,三级缓存有存在感(向 loop 中填充 circle 的时候有用到)

所以此种情况下:可以减少某个缓存,只需要两级缓存就够了

3、情况三:循环依赖 + AOP

比上一种情况多了 AOP,我们来看看对象的创建过程有什么不一样;同样是先创建 Circle ,在创建 Loop

创建过程与上一种情况大体一样,只是有小部分区别,跟源码的时候我会在这些区别上有所停顿,其他的会跳过,大家要仔细看

实例化 Circle ,然后填充 半成品 circle 的属性 loop ,去 Spring 容器中获取 loop 对象,发现没有

则实例化 Loop ,接着填充 半成品 loop 的属性 circle ,去 Spring 容器中获取 circle 对象

img

这个过程与前一种情况是一致的,就直接跳过了,我们从上图中的红色步骤开始跟源码,此时三级缓存中的数据如下

img

img

我们发现从第三级缓存获取 circle 的时候,调用了 getEarlyBeanReference 创建了 半成品 circle 的代理对象

将 半成品 circle 的代理对象放到了第二级缓存中,并将代理对象返回赋值给了 半成品 loopcircle 属性

注意:此时是在进行 loop 的初始化,但却把 半成品 circle 的代理对象提前创建出来了

loop 的初始化还未完成,我们接着往下看,又是一个重点,仔细看

img

initializeBean 方法中完成了 半成品 loop 的初始化,并在最后创建了 loop 成品 的代理对象

loop 代理对象创建完成之后会将其放入到第一级缓存中(移除第三级缓存中的 loop ,第二级缓存自始至终都没有 loop )

然后将 loop 代理对象返回并赋值给 半成品 circle 的属性 loop ,接着进行 半成品 circleinitializeBean

img

因为 circle 的代理对象已经生成过了(在第二级缓存中),所以不用再生成代理对象了;

将第二级缓存中的 circle 代理对象移到第一级缓存中,并返回该代理对象

此时各级缓存中的数据情况如下(普通 circle 、 loop 对象在各自代理对象的 target 中

img

我们回顾下这种情况下各级缓存的存在感,一级缓存仍是存在感十足,二级缓存有存在感,三级缓存挺有存在感

第三级缓存提前创建 circle 代理对象,不提前创建则只能给 loop 对象的属性 circle 赋值成 半成品 circle ,==那么 loop 对象中的 circle 对象就无 AOP 增强功能了==

第二级缓存用于存放 circle 代理,用于解决循环依赖;也许在这个示例体现的不够明显,因为依赖比较简单,依赖稍复杂一些,就能感受到了

第一级缓存存放的是对外暴露的对象,可能是代理对象,也可能是普通对象

img

所以此种情况下:三级缓存一个都不能少

4、情况四:循环依赖 + AOP + 删除第三级缓存

没有依赖,有AOP 这种情况中,我们知道 AOP 代理对象的生成是在成品对象创建完成之后创建的,这也是 Spring 的设计原则,代理对象尽量推迟创建

循环依赖 + AOP 这种情况中, circle 代理对象的生成提前了,因为必须要保证其 AOP 功能,但 loop 代理对象的生成还是遵循的 Spring 的原则

如果我们打破这个原则,将代理对象的创建逻辑提前,那是不是就可以不用三级缓存了,而只用两级缓存了呢?

对 Spring 的源码做了非常小的改动,改动如下:

img

去除了第三级缓存,并将代理对象的创建逻辑提前,置于实例化之后,初始化之前;我们来看下执行结果:

img

并没有什么问题。

5、情况五:循环依赖 + AOP + 注解

目前基于 xml 的配置越来越少,而基于注解的配置越来越多,所以了也提供了一个注解的版本供大家去跟源码

代码还是很简单:spring-circle-annotation

跟踪流程与 循环依赖 + AOP 那种情况基本一致,只是属性的填充有了一些区别,具体可查看:Spring 的自动装配 → 骚话 @Autowired 的底层工作原理

6、总结
  1. 三级缓存各自的作用
    1. 第一级缓存的是对外暴露的对象,也就是我们应用需要用到的
    2. 第二级缓存的作用是==为了处理循环依赖的对象创建问题==,里面存的是半成品对象或半成品对象的代理对象
    3. 第三级缓存的作用==处理存在 AOP + 循环依赖的对象创建问题==,能将==代理对象提前创建==
  2. Spring 为什么要引入第三级缓存
    1. 严格来讲,第三级缓存并非缺它不可,因为可以提前创建代理对象
    2. 提前创建代理对象只是会节省那么一丢丢内存空间,并不会带来性能上的提升,但是会破环 Spring 的设计原则
    3. Spring 的设计原则是尽可能保证普通对象创建完成之后,再生成其 AOP 代理(尽可能延迟代理对象的生成)
    4. 所以 Spring 用了第三级缓存,既维持了设计原则,又处理了循环依赖;牺牲那么一丢丢内存空间是愿意接受的
9、循环依赖于三级缓存相关面试解答
1、说下什么是循环依赖

两个或则两个以上的bean对象互相依赖对方,最终形成 闭环 。例如 A 对象依赖 B 对象,B 对象也依赖 A 对象

img

2、那循环依赖会有什么问题呢

在bean对象的创建过程会产生死循环,类似如下

img

3、那么Spring 是如何解决的呢?

通过三级缓存提前暴露bean对象来解决的

4、三级缓存里面分别存的什么

三级缓存就是三个map对象

  1. 一级缓存是一个ConcurrentHashMap,容量为256,里面存的是成品对象,实例化和初始化都完成了,我们的应用中使用的对象就是一级缓存中的
  2. 二级缓存是一个HashMap,容量为16,里面存的是半成品,用来解决对象创建过程中的循环依赖问题
  3. 三级缓存是一个HashMap,容量为16,里面存的是 ObjectFactory<?> 类型的 lambda 表达式,用于处理存在 AOP 时的循环依赖问题

img

5、Spring使用三级缓存解决循环依赖的前提是什么?

有两个前提:

  1. bean是单例实例,即scope的属性为singleton

    • 因为如果是scope的属性为prototype原型,那么每一次创建的bean对象都是新对象,不会走三级缓存。
  2. bean对象的依赖注入方式不能全是构造器注入的方式

    • Spring解决循环依赖依靠的是Bean的“中间态”这个概念,而这个中间态指的是已经实例化但还没初始化的状态,也即半成品。

    • 实例化的过程又是通过构造器创建的,如果A还没创建好出来怎么可能提前曝光,所以构造器的循环依赖无法解决

    • 也就是说:

      • 构造方法注入的方式,将实例化与初始化并在一起完成,能够快速创建一个可直接使用的对象
      • setter 方法注入的方式,是在对象实例化完成之后,再通过反射调用对象的 setter 方法完成属性的赋值。将对象的实例化与初始化分隔开了
    • 那又为什么说不能全是构造器注入的方式

      依赖情况 依赖注入方式 循环依赖是否被解决
      AB相互依赖(循环依赖) 均采用setter方式注入
      AB相互依赖(循环依赖) 均采用构造器方式注入
      AB相互依赖(循环依赖) A注入B的方式为setter,B注入A的方式为构造器
      AB相互依赖(循环依赖) B注入A的方式为setter,A注入B的方式为构造器

    为什么在下表中的第三种情况的循环依赖能被解决,而第四种情况不能被解决呢?

    Spring在创建Bean时默认会根据自然排序进行创建,所以A会先于B进行创建。

6、为什么要用三级缓存来解决循环依赖问题(只用一级缓存行不行,只用二级缓存行不行)

只用一级缓存也是可以解决的,但是会复杂化整个逻辑

半成品对象是没法直接使用的(存在 NPE 问题),所以 Spring 需要保证在启动的过程中,所有中间产生的半成品对象最终都会变成成品对象。

如果将半成品对象和成品对象都混在一级缓存中,那么为了区分他们,势必会增加一些而外的标记和逻辑处理,这就会导致对象的创建过程变得复杂化了。

将半成品对象与成品对象分开存放,两级缓存各司其职,能够简化对象的创建过程,更简单、直观。

如果 Spring 不引入 AOP,那么两级缓存就够了,但是作为 Spring 的核心之一,AOP 怎能少得了呢?

所以为了处理 AOP代理时的循环依赖,Spring 引入第三级缓存来处理循环依赖时的代理对象的创建。

如果要使用二级缓存解决循环依赖,意味着所有Bean在实例化后就要完成AOP代理,这样违背了Spring设计的原则,Spring在设计之初就是通过AnnotationAwareAspectJAutoProxyCreator这个后置处理器来在Bean生命周期的最后一步来完成AOP代理,而不是在实例化后就立马进行AOP代理。

7、如果将代理对象的创建过程提前,紧随于实例化之后,而在初始化之前,那是不是就可以只用两级缓存了?

可以,没有问题。但是会破坏Spring的设计原则:尽可能保证普通对象创建完成之后,再生成其 AOP 代理(尽可能延迟代理对象的生成)


Linux

1、Linux常用服务类相关命令

1、service (Centos6)

  • 注册在系统中的标准化程序

  • 有方便统一的管理方式(常用的方法)

    • service 服务名 start:开启服务
    • service 服务名 stop:关闭服务
    • service 服务名 restart:重启服务
    • service 服务名 reload:重新加载服务
    • service 服务名 status:查看当前服务的状态
  • 查看服务的方法/etc/init.d/服务名

  • 通过chkconfig命令设置自启动

    • 查看服务

      1
      chkconfig --list | grep xxx
    • 设置服务是否开机自启动

      1
      chkconfig --level 5 服务名 off

image-20210925145421487

2、systemctl (Centos7)

  • 注册在系统中的标准化程序

  • 有方便统一的管理方式(常用的方法)

    • systemctl start 服务名(xxxx.service):开启服务
    • systemctl restart 服务名(xxxx.service):重启服务
    • systemctl stop 服务名(xxxx.service):关闭服务
    • systemctl reload 服务名(xxxx.service):重新加载服务
    • systemctl status 服务名(xxxx.service):查看当前服务的状态
  • 查看服务的方法/usr/lib/systemd/system

  • 查看服务的命令

    1
    2
    3
    4
    5
    6
    systemctl list-unit-files

    # 可以使用grep进行过滤
    systemctl list-unit-files | grep xxx

    systemctl --type service
  • 通过systemctl命令设置自启动

    1
    2
    3
    4
    5
    # 开机自启动
    systemctl enable service_name

    # 开机不自启动
    systemctl disable service_name

Git

1、git分支相关命令

  • 创建分支

    1
    2
    3
    4
    5
    # 创建分支
    git branch <分支名>

    # 查看分支
    git branch -v <分支名>
  • 切换分支

    1
    2
    3
    4
    git checkout <分支名>

    # 一步完成:创建分支并切换到该分支(最常用)
    git checkout -b <分支名>
  • 合并分支

    1
    2
    3
    4
    5
    # 先切换到主分支
    git checkout master

    # 合并分支
    git merge <分支名>
  • 删除分支

    1
    2
    3
    4
    5
    # 先切换到主分支
    git checkout master

    # 删除分支
    git branch -D <分支名>

2、请讲一下Git的工作流

  • 在master主分支下分出develop开发分支进行开发工作
    • master一般是项目经理或者运维人员才能操作的
    • 开发人员接触的最多的就是develop分支
    • 注意:master分支和develop要保持一致
  • 在develop分支下,根据项目的不同模块再分出对应的开发分支
  • 如果在master主分支(上线)出现了bug,那么master会分出hotfix分支进行bug的修复工作
    • bug修复过后在下线master分支,将hotfix分支合并到master之后在上线
    • 同时要将hotfix分支合并到develop分支当中,保证master分支和develop要保持一致,防止将来把develop分支合并到master主分支出现重复的bug
  • 当模块的开发工作完成之后,将对应模块的开发分支提交到develop分支当中,并创建release分支查看是否有bug,如果有bug的话进行修复。最终合并到master主分支当中进行上线

image-20210925150649625

3、GitHub的常用词

  • watch:会持续收到该项目的动态
  • fork:复制某个项目到自己的GitHub仓库
  • star:点赞
  • clone:将项目下载至本地
  • follow:关注你感兴趣的作者,会收到他们的动态

4、in关键字限制搜索范围

公式:

1
[要搜索的关键字] in name 或description 或readme
  • xxx in:name——项目名包含xxx的
  • xxx in:description——项目描述包含xxx的
  • xxx in:readme——项目的readme文件中包含xxx的

组合使用:

1
2
# 搜索项目名或者readme或者项目描述中包含秒杀的项目
seckill in:name,readmw,description

5、stars或fork数量关键词去查找

stars的公式:

1
2
3
4
5
6
7
[要搜索的关键字] stars 通配符[:> 或者 :>=]

# 区间范围数字
[要搜索的关键字] stars:数字1..数字2

# eg:查找stars数大于等于5000的springboot项目
springboot stars:>=5000

forks的公式:

1
2
3
4
5
6
7
[要搜索的关键字] forks 通配符[:> 或者 :>=]

# 区间范围数字
[要搜索的关键字] forks:数字1..数字2

# eg:查找forks数大于500的springcloud项目
springcloud forks:>500

组合使用:

1
2
# 查找fork在100到200之间并且stars数在80到100之间的springboot项目
springboot forks:100..200 stars:80..100

6、awesome加强搜索

公式:

1
awesome 关键字

awesome系列一般是用来学习、工具、书籍类相关的项目

使用:搜索优秀的redis相关的项目,包括框架、教程等

1
awesome redis

7、高亮显示某一行代码

公式:

1
2
3
4
5
6
7
8
9
# 1行
地址后面紧跟#L数字
# eg:高亮显示在Dao层的SeckillDao.java的第13行
https://github.com/codingXiaxw/seckill/blob/master/scr/main/java/cn/codingxiaxw/dao/SeckillDao.java#L13

# 多行
地址后面紧跟#L数字-L数字2
# eg
https://github.com/codingXiaxw/seckill/blob/master/scr/main/java/cn/codingxiaxw/dao/SeckillDao.java#L13-L23

8、项目内搜索

在键盘输入英文t,此时路径会变成xxx/find/master。网页的代码呈现一个树状的结构,方便我们查看源码和相关框架。

github的其他快捷键

9、搜索某个地区内的大佬

公式:

1
location:地区 language:语言

使用:

1
2
# 搜索地区在北京的Java方向的大佬
location:beijing language:java

Redis

1、redis持久化

Redis提供了2个不同形式的持久化方式。

  • RDB (Redis DataBase)
  • AOF (Append Of File)

1、RDB

在指定的时间间隔内将内存中的数据集快照写入磁盘,也就是行话讲的Snapshot快照,它恢复时是将快照文件直接读到内存里。

备份是如何执行的?

  • Redis会单独创建(fork) 一个子进程来进行持久化,会先将数据写入到一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能。
  • 如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那RDB方式要比AOF方式更加的高效。
  • RDB的缺点是最后一次持久化后的数据可能丢失。

RDB的优缺点:

  • 优点:
    • 节省磁盘空间
    • 恢复速度快
  • 缺点:
    • 虽然Redis在fork时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能。
    • 在备份周期在一定间隔时间做一次备份,所以如果Redis意外down掉的话,就会丢失最后一次快照后的所有修改。

image-20210925152036088

2、AOF

以日志的形式来记录每个==写==操作,将Redis执行过的所有写指令记录下来(读操作不记录),只许追加文件但不可以改写文件,Redis启动之初会读取该文件重新构建数据,换言之,Redis重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作

备份是如何执行的?

AOF与RDB类似,都是依靠一个fork子进程和一个缓冲区进行写指令的备份的。而且AOF在进行写指令备份的时候,可以开启重写机制来提高性能

AOF的优缺点:

  • 优点:
    • 备份机制更稳健,丢失数据概率更低。
    • 可读的日志文本,通过操作AOF稳健,可以处理误操作。
  • 缺点:
    • 恢复备份速度要慢。
    • 每次读写都同步的话,有一定的性能压力。
    • 存在个别Bug,造成恢复不能。

image-20210925152312959

2、Redis在项目中的使用场景

数据类型 使用场景
String 比如说,①我想知道什么时候封锁一个IP地址
可以使用命令Incrby记录当前IP访问的次数,达到一定的次数就封锁该IP
②设置分布式锁:set key value [Ex seconds] [PX milliseconds] [NX|XX]
参数解释
1.EX:key在多少秒之后过期
2.PX:key在多少毫秒之后过期
3.NX:当key不存在的时候,才创建key,效果等同于setnx key value
4.XX:当key存在的时候,覆盖key
③商品编号、订单号采用 INCR 命令生成
④文章阅读量、点赞数和在看数
Hash redis 中的 hash 类似于 java 中的 Map<String,Map<Object,object>> 数据结构,即以字符串为 key,以 Map 对象为 value
①存储用户信息【id,name,age】
hset(key,field,value)
hset(userKey,id,101)
hset(userKey,name,admin)
hset(userKey,age,23)
—-修改案例—-
hget(userKey,id)
hset(userKey,id,102)
为什么不使用String类型来存储
set(userKey,用信息的字符串)
get(userKey)
不建议使用 String 类型存储用户信息
②购物车早期版本,可在小中厂项目中使用
1.新增商品:hset shopcut:uid1024 sid334488 1
2.新增商品:hset shopcut:uid1024 sid337788 1
3.增加商品数量2:hincrby shopcut:uid1024 sid337788 2
4.查看商品总数:hlen shopcut:uid1024
5.全部选择:hgetall shopcut:uid1024
List 与其说 list 是个集合,还不如说 list 是个双端队列。
①实现最新消息的排行,还可以利用List的push命令,将任务存在list集合中,同时使用另一个命令pop,将任务从集合中取出。
Redis—list数据类型来模拟消息队列。【电商中的秒杀就可以采用这种方式来完成一个秒杀活动】
②微信文章订阅公众号
1.比如我订阅了如下两个公众号,他们发布了两篇文章,文章 ID 分别为 666 和 888,可以通过执行 LPUSH likearticle:onebyId 666 888 命令推送给我
2.查看我自己的号订阅的全部文章,类似分页,下面0~10就是一次显示10条:LPUSH likearticle:onebyId 0 10
Set 特殊之处:可以自动排重。比如说微博中将每个人的好友存在集合(Set)中,这样①求两个人的共通好友的操作。我们只需要求交集即可
②微信抽奖小程序
1.如果某个用户点击了立即参与按钮,则执行 sadd key useId 命令将该用户 ID 添加至 set 中
2.显示已经有多少人参与了抽奖:SCARD key
3.抽奖(从set中任意选取N个中奖人)
3.1.随机抽奖2个人,元素不删除:SRANDMEMBER key 2
3.2.随机抽奖3个人,元素会删除:SPOP key 3
③微信朋友圈点赞
1.新增点赞:SADD pub:msgID 点赞用户ID1 点赞用户ID2
2.取消点赞:SREM pub:msgID 点赞用户ID
3.展现所有点赞过的用户:SMEMBERS pub:msgID
4.点赞用户数统计,就是常见的点赞红色数字:SCARD pub.msgID
5.判断某个朋友是否对楼主点赞过:SISMEMBER pub:msgID 用户ID
④QQ内推可能认识的人
QQ 内推可能认识的好友:SDIFF 我的好友 Ta的好友
ZSet 以某一个条件为权重,进行排序
京东:①商品详情的时候,都会有一个综合排名,还可以按照商品销售量进行排名。
思路:定义商品销售排行榜(sorted set集合),key为goods:sellsort,分数为商品销售数量。
1.商品编号1001的销量是9,商品编号1002的销量是15:ZADD goods:sellsort 9 1001 15 1002
2.有一个客户又买了2件商品1001,商品编号1001销量加2:ZINCRBY goods:sellsort 2 1001
3.求商品销量前10名:ZRANGE goods:sellsort 0 10 WITHSCORES
②抖音/微博热搜
1.点击视频增加播放量:ZINCRBY hotvcr:20200919 1八佰ZINCRBY hotvcr:20200919 15 八佰 2 花木兰
2.展示当日排行前10条:ZREVRANGE hotvcr:20200919 0 9 WITHSCORES

3、Redis 6.0.7的bug

Redis突然发布了紧急版本 6.0.8 ,之前消息称 6.0.7 被称作最后一个 6.x 版本,但 Redis 团队表示 6.0.8 版本升级迫切性等级为高:任何将 Redis 6.0.7 与 Sentinel 或 CONFIG REWRITE 命令配合使用的人都会受到影响,应尽快升级。

image-20210126163514224

从官方给出的信息来看,估计是出现了Bug,具体更新的内容如下:

  1. Bug修复
    1. CONFIG REWRITE在通过CONFIG设置oom-score-adj-values后,可以通过CONFIG设置或从配置文件中加载,会生成一个损坏的配置文件。将会导致Redis无法启动
    2. 修正MacOS上redis-cli –pipe的问题。
    3. 在不存在的密钥上,修复HKEYS/HVALS的RESP3响应。
    4. 各种小的错误修复
  2. 新功能
    1. 当设置为madvise时,移除THP警告。
    2. 允许在群集中只读副本上使用读命令进行EXEC。
    3. 在redis-cli-cluster调用命令中增加master/replicas选项。
  3. 模块化API
    1. 添加RedisModule_ThreadSafeContextTryLock

官网地址

查看当前Redis版本的方法

  1. 在 Linux 命令行下:在 redis 安装目录下执行 redis-server -v 命令
  2. 在 redis 客户端命令行下:执行 info 命令,第二行就是当前启动的Redis的版本

4、Redis 命令大全

官网命令大全

直接搜索即可

如果当前不能使用外网或者不能进行线上查看,可以使用Redis自带的help指令:help @关键字

eg:help @String

注意事项:执行 redis 指令可能会出现如下错误:(error) MISCONF Redis is configured to save RDB snapshots, but it is currently not able to persist on disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-writes-on-bgsave-error option). Please check the Redis logs for details about the RDB error.

原因分析:究其原因是因为强制把 redis 快照关闭了导致不能持久化的问题,在网上查了一些相关解决方案,通过 stop-writes-on-bgsave-error 值设置为 no 即可避免这种问题。

解决方案一:通过 redis 命令行修改,在 redis 命令行执行 config set stop-writes-on-bgsave-error no 指令

解决方案二:直接修改 redis.conf 配置文件,使用 vim 编辑器打开 redis-server 配置的 redis.conf 文件,然后使用快捷匹配模式:/stop-writes-on-bgsave-error 定位到 stop-writes-on-bgsave-error 字符串所在位置,接着把后面的 yes 设置为 no 即可。

5、Redis的分布式锁

知道分布式锁吗?有哪些实现方案? 你谈谈对redis分布式锁的理解, 删key的时候有什么问题?

单机版的锁与分布式锁:

  • JVM层面的加锁,单机版的锁
  • 分布式微服务架构,拆分后各个微服务组件为了避免冲突和数据故障而加入的一种锁,分布式的锁

分布式锁的几种实现方式:

  1. mysql
  2. zookeeper
  3. redis

一般的互联网公司,大家都习惯用redis做分布式锁

redis ==》 redlock ==》 redisson

image-20211007230228198

1、分布式锁的常见面试题

  1. Redis除了拿来做缓存,你还见过基于Redis的什么用法?
  2. Redis做分布式锁的时候有需要注意的问题?
  3. 如果是Redis是单点部署的,会带来什么问题? 那你准备怎么解决单点问题呢?
  4. 集群模式下,比如主从模式,有没有什么问题呢?
  5. 那你简单的介绍一下Redlock吧? 你简历上写redisson,你谈谈
  6. Redis分布式锁如何续期?看门狗知道吗?

2、为什么需要分布式锁?分布式锁的最终形成

1、版本1.0:单机版&没加锁

问题:单机版程序没有加锁,在并发测试下数字不对,会出现并发安全问题

解决:加锁,那么问题又来了,加 synchronized 锁还是 ReentrantLock 锁呢?

  1. synchronized:不见不散,等不到锁就会死等
  2. ReentrantLock:过时不候,lock.tryLock() 提供一个过时时间的参数,时间一到自动放弃锁

如何选择:根据业务需求来选,如果非要抢到锁不可,就使用 synchronized 锁;如果可以暂时放弃锁,等会再来强,就使用 ReentrantLock

2、版本2.0:单机版&加锁

使用 synchronized 锁保证单机版程序在并发下的安全性

注意事项

  1. 在单机环境下,可以使用 synchronized 锁或 Lock 锁来实现。
  2. 但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个 jvm 中)即不同线程抢夺的不是同一把锁,所以需要一个让所有进程都能访问到的锁来实现,比如 redis 或者 zookeeper 来构建;
  3. 不同进程 jvm 层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程
3、版本3.0:分布式版&不加分布式锁

分布式部署之后,单机版的锁失效,撤销单机版的锁,并且在微服务之上,挡了一个 nginx 服务器,用于实现负载均衡的功能

在jmeter的压测下出现并发问题

4、版本4.0:分布式版&用redis做分布式锁

Redis具有极高的性能,且其命令对分布式锁支持友好,借助 SET 命令即可实现加锁处理

设置分布式锁:set key value [Ex seconds] [PX milliseconds] [NX|XX]

参数解释:

  1. EX:key在多少秒之后过期
  2. PX:key在多少毫秒之后过期
  3. NX:当key不存在的时候,才创建key,效果等同于setnx key value
  4. XX:当key存在的时候,覆盖key

代码层面:

使用当前请求的 UUID + 线程名作为分布式锁的 value:

1
2
// 当前请求的 UUID + 线程名
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();

使用setnx命令的jedis来加分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
// 分布式锁
private static final Object REDIS_LOCK_KEY = new Object();

// setIfAbsent() 就相当于 setnx,如果不存在就新建锁
Boolean lockFlag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value);

// 抢锁失败
if(lockFlag == false){
return "抢锁失败 o(╥﹏╥)o";
}

// 业务代码

进程抢占分布式锁,如果抢占失败,则返回值为 false;如果抢占成功,则返回值为 true。

最后记得解锁:

1
2
// 释放分布式锁
stringRedisTemplate.delete(REDIS_LOCK_KEY);
5、版本5.0:finally版

上面代码存在的问题:如果代码在执行的过程中出现异常,那么就可能无法释放锁,因此必须要在代码层面加上 finally 代码块,保证锁的释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
// 加锁
// setIfAbsent() 就相当于 setnx,如果不存在就新建锁
Boolean lockFlag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value);

// 抢锁失败
if (lockFlag == false) {
return "抢锁失败 o(╥﹏╥)o";
}

// 业务代码
} finally {
// 释放分布式锁
stringRedisTemplate.delete(REDIS_LOCK_KEY);
}
6、版本6.0:过期时间版

上面代码存在的问题:假设部署了微服务 jar 包的服务器挂了,代码层面根本没有走到 finally 这块,也没办法保证解锁。既使之后该服务器重新启动,这个 key 也没有被删除,其他微服务就一直抢不到锁,因此我们需要加入一个过期时间限定的 key

使用setex命令的jedis来加分布式锁的过期时间

1
2
3
4
// setIfAbsent() 就相当于 setnx,如果不存在就新建锁
Boolean lockFlag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value);
// 设置过期时间为 10s
stringRedisTemplate.expire(REDIS_LOCK_KEY, 10L, TimeUnit.SECONDS);
7、版本7.0:加锁原子版

上面代码存在的问题:加锁与设置过期时间的操作分开了,假设服务器刚刚执行了加锁操作,然后宕机了,也没办法保证解锁。——没能保证加锁与过期时间的原子性

使用setIfAbsent的重载方法来保证原子性:

1
2
3
4
5
6
7
// setIfAbsent() 就相当于 setnx,如果不存在就新建锁,同时加上过期时间保证原子性
Boolean lockFlag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value, 10L, TimeUnit.SECONDS);

// 抢锁失败
if (lockFlag == false) {
return "抢锁失败 o(╥﹏╥)o";
}
8、版本8.0:加UUID防止张冠李戴

上面代码存在的问题:张冠李戴,删除了别人的锁:我们无法保证一个业务的执行时间,有可能是 10s,有可能是 20s,也有可能更长。因为执行业务的时候可能会调用其他服务,我们并不能保证其他服务的调用时间。如果设置的锁过期了,当前业务还正在执行,那么就有可能出现并发问题,并且还有可能出现当前业务执行完成后,释放了其他业务的锁

如下图,假设进程 A 在 T2 时刻设置了一把过期时间为 30s 的锁,在 T5 时刻该锁过期被释放,在 T5 和 T6 期间,Test 这把锁已经失效了,并不能保证进程 A 业务的原子性了。于是进程 B 在 T6 时刻能够获取 Test 这把锁,但是进程 A 在 T7 时刻删除了进程 B 加的锁,进程 B 在 T8 时刻删除锁的时候就蒙蔽了,我 TM 锁呢?

image-20210204161115030

因此可以在解锁之前使用UUID判断当前的锁是不是自己的锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 当前请求的 UUID + 线程名
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
// setIfAbsent() 就相当于 setnx,如果不存在就新建锁,同时加上过期时间保证原子性
Boolean lockFlag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value, 10L, TimeUnit.SECONDS);

// 抢锁失败
if (lockFlag == false) {
return "抢锁失败 o(╥﹏╥)o";
}
// ...
} finally {
// 判断是否是自己加的锁
if(value.equalsIgnoreCase(stringRedisTemplate.opsForValue().get(REDIS_LOCK_KEY))){
stringRedisTemplate.delete(REDIS_LOCK_KEY); // 释放分布式锁
}
}
9、版本9.0:解锁原子性

上面代码存在的问题:在 finally 代码块中的判断与删除并不是原子操作,假设执行 if 判断的时候,这把锁还是属于当前业务,但是有可能刚执行完 if 判断,这把锁就被其他业务给释放了,还是会出现误删锁的情况

1、版本9.1:解锁原子性——lura脚本保证解锁原子性操作(常用)

lua 脚本

redis 可以通过 eval 命令保证代码执行的原子性

image-20210204173538862

代码:

1、**RedisUtils 工具类**:

getJedis() 方法用于从 jedisPool 中获取一个连接块对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class RedisUtils {

private static JedisPool jedisPool;

// Redis服务器的IP地址
private static String hostAddr = "192.168.152.233";

static {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(20);
jedisPoolConfig.setMaxIdle(10);
jedisPool = new JedisPool(jedisPoolConfig, hostAddr, 6379, 100000);
}

public static Jedis getJedis() throws Exception {
if (null != jedisPool) {
return jedisPool.getResource();
}
throw new Exception("Jedispool is not ok");
}
}

2、使用 lua 脚本保证解锁操作的原子性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
try {
// ...
} finally {
// 获取连接对象
Jedis jedis = RedisUtils.getJedis();
// lua 脚本,摘自官网
String script = "if redis.call('get', KEYS[1]) == ARGV[1]" + "then "
+ "return redis.call('del', KEYS[1])" + "else " + " return 0 " + "end";
try {
// 执行 lua 脚本
Object result = jedis.eval(script, Collections.singletonList(REDIS_LOCK_KEY), Collections.singletonList(value));
// 获取 lua 脚本的执行结果
if ("1".equals(result.toString())) {
System.out.println("------del REDIS_LOCK_KEY success");
} else {
System.out.println("------del REDIS_LOCK_KEY error");
}
} finally {
// 关闭链接
if (null != jedis) {
jedis.close();
}
}
}
2、版本9.2:解锁原子性——Redis事务保证解锁原子性操作

1、事务介绍

  • Redis的事务是通过MULTlEXECDISCARDWATCH这四个命令来完成。
  • Redis的单个命令都是原子性的,所以这里确保事务性的对象是命令集合
  • Redis将命令集合序列化并确保处于同一事务的命令集合连续且不被打断的执行。
  • Redis不支持回滚的操作。

2、相关命令

  1. MULTI
    1. 用于标记事务块的开始。
    2. Redis会将后续的命令逐个放入队列中,然后使用EXEC命令原子化地执行这个命令序列。
    3. 语法:MULTI
  2. EXEC
    1. 在一个事务中执行所有先前放入队列的命令,然后恢复正常的连接状态。
    2. 语法:EXEC
  3. DISCARD
    1. 清除所有先前在一个事务中放入队列的命令,然后恢复正常的连接状态。
    2. 语法:DISCARD
  4. WATCH
    1. 当某个事务需要按条件执行时,就要使用这个命令将给定的键设置为受监控的状态。
    2. 语法:WATCH key[key..…]
    3. 注:该命令可以实现redis的乐观锁,不会有ABA问题
  5. UNWATCH
    1. 清除所有先前为一个事务监控的键。
    2. 语法:UNWATCH

代码:

开启事务不断监视 REDIS_LOCK_KEY 这把锁有没有被别人动过,如果已经被别人动过了,那么继续重新执行删除操作,否则就解除监视

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
try {
// ...
} finally {
while (true) {
//加事务,乐观锁
stringRedisTemplate.watch(REDIS_LOCK_KEY);
// 判断是否是自己加的锁
if (value.equalsIgnoreCase(stringRedisTemplate.opsForValue().get(REDIS_LOCK_KEY))) {
// 开启事务
stringRedisTemplate.setEnableTransactionSupport(true);
stringRedisTemplate.multi();
stringRedisTemplate.delete(REDIS_LOCK_KEY);
// 判断事务是否执行成功,如果等于 null,就是没有删掉,删除失败,再回去 while 循环那再重新执行删除
List<Object> list = stringRedisTemplate.exec();
if (list == null) {
continue;
}
}
//如果删除成功,释放监控器,并且 break 跳出当前循环
stringRedisTemplate.unwatch();
break;
}
}
10、版本10.0:自动续期版

上面代码存在的问题:我们无法保证一个业务的执行时间,有可能是 10s,有可能是 20s,也有可能更长。因为执行业务的时候可能会调用其他服务,我们并不能保证其他服务的调用时间。如果设置的锁过期了,当前业务还正在执行,那么之前设置的锁就失效了,就有可能出现并发问题。

因此我们需要确保 redisLock 过期时间大于业务执行时间的问题,即面临如何对 Redis 分布式锁进行续期的问题:

redis 与 zookeeper 在 CAP 方面的对比

  • redis
    • redis 异步复制造成的锁丢失, 比如:主节点没来的及把刚刚 set 进来这条数据给从节点,就挂了,那么主节点和从节点的数据就不一致。此时如果集群模式下,就得上 Redisson 来解决
  • zookeeper
    • zookeeper 保持强一致性原则,对于集群中所有节点来说,要么同时更新成功,要么失败,因此使用 zookeeper 集群并不存在主从节点数据丢失的问题,但丢失了速度方面的性能

使用 Redisson 实现自动续期功能

redis 集群环境下,我们自己写的也不OK,直接上 RedLock 之 Redisson 落地实现

  1. redis 分布式锁
  2. redisson GitHub 地址

代码:

注入 Redisson 对象

RedisConfig 配置类中注入 Redisson 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Configuration
public class RedisConfig {

@Value("${spring.redis.host}")
private String redisHost;

@Bean
public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory) {
// 新建 RedisTemplate 对象,key 为 String 对象,value 为 Serializable(可序列化的)对象
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
// key 值使用字符串序列化器
redisTemplate.setKeySerializer(new StringRedisSerializer());
// value 值使用 json 序列化器
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// 传入连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 返回 redisTemplate 对象
return redisTemplate;
}

@Bean
public Redisson redisson() {
Config config = new Config();
config.useSingleServer().setAddress("redis://" + redisHost + ":6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}
}

2、业务逻辑

直接 redissonLock.lock()redissonLock.unlock() 完事儿,这尼玛就是 juc 版本的 redis 分布式锁啊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* @ClassName GoodController
* @Description TODO
* @Author Oneby
* @Date 2021/2/2 18:59
* @Version 1.0
*/
@RestController
public class GoodController {

private static final String REDIS_LOCK_KEY = "lockOneby";

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Value("${server.port}")
private String serverPort;

@Autowired
private Redisson redisson;

@GetMapping("/buy_goods")
public String buy_Goods() throws Exception {
// 当前请求的 UUID + 线程名
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
// 获取锁
RLock redissonLock = redisson.getLock(REDIS_LOCK_KEY);
// 上锁
redissonLock.lock();

try {
// 从 redis 中获取商品的剩余数量
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
String retStr = null;

// 商品数量大于零才能出售
if (goodsNumber > 0) {
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001", realNumber + "");
retStr = "你已经成功秒杀商品,此时还剩余:" + realNumber + "件" + "\t 服务器端口: " + serverPort;
} else {
retStr = "商品已经售罄/活动结束/调用超时,欢迎下次光临" + "\t 服务器端口: " + serverPort;
}
System.out.println(retStr);
return retStr;
} finally {
// 解锁
redissonLock.unlock();
}
}
}

完善代码,修改可能出现的bug

问题:在超高并发的情况下,可能会抛出如下异常,原因是解锁 lock 的线程并不是当前线程

image-20210204182943599

代码:在释放锁之前加一个判断:还在持有锁的状态,并且是当前线程持有的锁再解锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@RestController
public class GoodController {

private static final String REDIS_LOCK_KEY = "lockOneby";

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Value("${server.port}")
private String serverPort;

@Autowired
private Redisson redisson;

@GetMapping("/buy_goods")
public String buy_Goods() throws Exception {
// 当前请求的 UUID + 线程名
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
// 获取锁
RLock redissonLock = redisson.getLock(REDIS_LOCK_KEY);
// 上锁
redissonLock.lock();

try {
// 从 redis 中获取商品的剩余数量
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
String retStr = null;

// 商品数量大于零才能出售
if (goodsNumber > 0) {
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001", realNumber + "");
retStr = "你已经成功秒杀商品,此时还剩余:" + realNumber + "件" + "\t 服务器端口: " + serverPort;
} else {
retStr = "商品已经售罄/活动结束/调用超时,欢迎下次光临" + "\t 服务器端口: " + serverPort;
}
System.out.println(retStr);
return retStr;
} finally {
// 还在持有锁的状态,并且是当前线程持有的锁再解锁
if (redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()){
redissonLock.unlock();
}
}
}
}
11、分布式锁总结
  1. synchronized 锁:单机版 OK,上 nginx分布式微服务,单机锁就不 OK,
  2. 分布式锁:取消单机锁,上 redis 分布式锁 SETNX
  3. 如果出异常的话,可能无法释放锁, 必须要在 finally 代码块中释放锁
  4. 如果宕机了,部署了微服务代码层面根本没有走到 finally 这块,也没办法保证解锁,因此需要有设置锁的过期时间
  5. 除了增加过期时间之外,还必须要 SETNX 操作和设置过期时间的操作必须为原子性操作
  6. 规定只能自己删除自己的锁,你不能把别人的锁删除了,防止张冠李戴,可使用 lua 脚本或者事务
  7. 判断锁所属业务与删除锁的操作也需要是原子性操作
  8. redis 集群环境下,我们自己写的也不 OK,直接上 RedLock 之 Redisson 落地实现

3、Redis 删除策略与缓存淘汰策略

1、Redis 缓存淘汰策略相关的面试题
  1. 生产上你们的redis内存设置多少?
  2. 如何配置、修改redis的内存大小?
  3. 如果内存满了你怎么办?
  4. redis 清理内存的方式?定期删除和惰性删除了解过吗
  5. redis 的缓存淘汰策略
  6. redis 的 LRU 淘汰机制了解过吗?可否手写一个 LRU 算法
2、redis 内存满了怎么办

redis 默认内存多少?在哪里查看? 如何设置修改?

1、如何查看 redis 最大占用内存

在 redis.conf 配置文件中有一个,输入 :set nu 显示行号,大约在 859 多行有一个 maxmemory 字段,用预设值 redis 的最大占用内存

image-20210207172808438

2、redis 会占用物理机器多少内存?

如果不设置最大内存大小或者设置最大内存大小为 0,在 64 位操作系统下不限制内存大小,在32位操作系统下最多使用 3GB 内存

3、一般生产上如何配置 redis 的内存

一般推荐Redis设置内存为最大物理内存的四分之三,也就是 0.75

4、如何修改 redis 内存设置

1、通过修改文件配置(永久生效):修改 maxmemory 字段,单位为字节

image-20210207173214138

2、通过命令修改(重启失效):config set maxmemory 104857600 设置 redis 最大占用内存为 100MB,config get maxmemory 获取 redis 最大占用内存

image-20210207173506155

5、通过命令查看 redis 内存使用情况?

通过 info 指令info memory可以查看 redis 内存使用情况:used_memory_human 表示实际已经占用的内存,maxmemory 表示 redis 最大占用内存

image-20210207173935763

6、如果把 redis 内存打满了会发生什么? 如果 redis 内存使用超出了设置的最大值会怎样?

redis 将会报错:(error) OOM command not allowed when used memory > ‘maxmemory’

如果设置了 maxmemory 的选项,假如 redis 内存使用达到上限,并且 key 都没有加上过期时间,就会导致数据写爆 redis 内存。为了避免类似情况,于是引出下一部分的内存淘汰策略

3、redis 删除策略

redis 如何删除设置了过期时间的 key

1、redis过期键的删除策略

如果一个键是过期的,那它到了过期时间之后是不是马上就从内存中被被删除呢?那么过期后到底什么时候被删除呢?redis 如何操作的呢

通过查看 redis 配置文件可知,默认淘汰策略是【noeviction(Don’t evict anything, just return an error on write operations.)】,如果 redis 内存被写爆了,直接返回 error

image-20210207174720294

redis 对于过期 key 的三种不同删除策略

  1. 立即删除
    • 立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。但是立即删除对 CPU 是最不友好的。因为删除操作会占用 CPU 的时间,如果刚好碰上了 CPU 很忙的时候,比如正在做交集或排序等计算的时候,就会给 CPU 造成额外的压力,让 CPU 心累,时时需要删除,忙死
    • 这会产生大量的性能消耗,同时也会影响数据的读取操作。
    • 总结:定时删除对 CPU 不友好,但对 memory 友好,用处理器性能换取存储空间(拿时间换空间)
  2. 惰性删除
    • 惰性删除的策略刚好和定时删除相反,惰性删除在数据到达过期时间后不做处理,等下次访问该数据时:如果发现未过期,则返回该数据;如果发现已过期,则将其删除,并返回不存在。
    • 如果一个键已经过期并且未被访问到,那么这个键仍然保留在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放。因此惰性删除策略的缺点是:它对内存是最不友好的。
    • 在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行 FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏——无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的 redis 服务器来说,肯定不是一个好消息
    • 总结:惰性删除对 memory 不友好,但对 CPU 友好,用存储空间换取处理器性能(拿空间换时间)
  3. 定期删除:(折中方案)
    • 上面两种删除策略都走极端,因此引出我们的定期删除策略。
    • 定期删除策略是前两种策略的折中:定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
    • 其做法为:**==周期性轮询== redis 库中的时效性数据,采用==随机抽取==的策略,利用==过期数据占比==的方式控制删除频度。**
    • 定期删除的特点
      • CPU 性能占用设置有峰值,检测频度可自定义设置
      • 内存压力不是很大,长期占用内存的冷数据会被持续清理
    • 总结:周期性抽查存储空间(随机抽查,重点抽查)
    • 定期删除的举例
      • redis 默认每间隔 100ms 检查是否有过期的 key,如果有过期 key 则删除。
      • 注意:redis 不是每隔100ms 将所有的 key 检查一次而是随机抽取进行检查(如果每隔 100ms,全部 key 进行检查,redis 直接进去ICU)。因此,如果只采用定期删除策略,会导致很多 key 到时间没有删除。
    • 定期删除的难点
      • 定期删除策略的难点是确定删除操作执行的时长和频率:redis 不可能时时刻刻遍历所有被设置了生存时间的 key,来检测数据是否已经到达过期时间,然后对它进行删除。
      • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将 CPU 时间过多地消耗在删除过期键上面。
      • 如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除束略一样,出现浪费内存的情况。
      • 因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

总结

惰性删除和定期删除都存在数据没有被抽到的情况,如果这些数据已经到了过期时间,没有任何作用,这会导致大量过期的 key 堆积在内存中,导致 redis 内存空间紧张或者很快耗尽

因此必须要有一个更好的兜底方案,接下来引出 redis 内存淘汰策略

4、Redis的缓存淘汰策略

redis 6.0.8 版本的内存淘汰策略有哪些?

8 种内存淘汰策略

  • noeviction:不会驱逐任何key
  • allkeys-lru:对所有key使用LRU算法进行删除
  • volatile-lru:对所有设置了过期时间的key使用LRU算法进行删除
  • allkeys-random:对所有key随机删除
  • volatile-random:对所有设置了过期时间的key随机删除
  • volatile-ttl:删除马上要过期的key
  • allkeys-lfu:对所有key使用LFU算法进行删除
  • volatile-lfu:对所有设置了过期时间的key使用LFU算法进行删除

总结:

  • 2个维度
    1. 过期键中筛选
    2. 所有键中筛选
  • 4个方面
    1. lru
    2. lfu
    3. random
    4. ttl、noeviction

如何配置 redis 的内存淘汰策略

1、通过修改文件配置永久生效):配置 maxmemory-policy 字段:

image-20210207181430526

通过命令修改(重启失效):config set maxmemory-policy allkeys-lru 命令设置内存淘汰策略,config get maxmemory-policy 命令获取当前采用的内存淘汰策略

image-20210207182204402

5、redis LRU 算法
1、LRU 算法简介

LRU 是 Least Recently Used 的缩写,即最近最少使用,是一种常用的页面置换算法,每次选择最近最久未使用的页面予以淘汰

2、LRU 算法题来源

146. LRU 缓存机制

运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。

实现 LRUCache 类:

  1. LRUCache(int capacity):以正整数作为容量 capacity 初始化 LRU 缓存
  2. int get(int key):如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  3. void put(int key, int value):如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间.

进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1); // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2); // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1); // 返回 -1 (未找到)
lRUCache.get(3); // 返回 3
lRUCache.get(4); // 返回 4
3、LRU 算法设计思想

查找和插入的时间复杂度为 O(1),HashMap 没得跑了,但是 HashMap 是无序的集合,怎么样将其改造为有序集合呢?答案就是在各个 Node 节点之间增加 prev 指针和 next 指针,构成双向链表。即:HashMap + 双向链表

image-20210207184327176

LRU 的算法核心是哈希链表,本质就是 HashMap+DoubleLinkedList 时间复杂度是O(1),哈希表+双向链表的结合体,下面这幅动图完美诠释了 HashMap+DoubleLinkedList 的工作原理:

image-20210206182454498

4、LRU 算法编码实现

1、**借助 JDK 自带的 LinkedHashMap**:

LinkedHashMap 的注释中写明了: LinkedHashMap 非常适合用来构建 LRU 缓存

image-20210207185047179

LRU 代码

通过继承 LinkedHashMap,重写 boolean removeEldestEntry(Map.Entry<K, V> eldest) 方法就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class LRUCacheDemo<K, V> extends LinkedHashMap<K, V> {

// 缓存容量
private int capacity;

public LRUCacheDemo(int capacity) {
// accessOrder:the ordering mode. true for access-order;false for insertion-order
super(capacity, 0.75F, true);
this.capacity = capacity;
}

// 用于判断是否需要删除最近最久未使用的节点
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return super.size() > capacity;
}

public static void main(String[] args) {
LRUCacheDemo lruCacheDemo = new LRUCacheDemo(3);

lruCacheDemo.put(1, "a");
lruCacheDemo.put(2, "b");
lruCacheDemo.put(3, "c");
System.out.println(lruCacheDemo.keySet());

lruCacheDemo.put(4, "d");
System.out.println(lruCacheDemo.keySet());

lruCacheDemo.put(3, "c");
System.out.println(lruCacheDemo.keySet());
lruCacheDemo.put(3, "c");
System.out.println(lruCacheDemo.keySet());
lruCacheDemo.put(3, "c");
System.out.println(lruCacheDemo.keySet());
lruCacheDemo.put(5, "x");
System.out.println(lruCacheDemo.keySet());
}
}

为何要重写 boolean removeEldestEntry(Map.Entry<K, V> eldest) 方法

先来看看 LinkedHashMap 中的 boolean removeEldestEntry(Map.Entry<K, V> eldest) 方法,直接 return false,缓存爆就爆,反正就是不会删除 EldestEntry

1
2
3
4
5
6
7
8
9
10
11
/**
* Returns <tt>true</tt> if this map should remove its eldest entry.
* This method is invoked by <tt>put</tt> and <tt>putAll</tt> after
* inserting a new entry into the map. It provides the implementor
* with the opportunity to remove the eldest entry each time a new one
* is added. This is useful if the map represents a cache: it allows
* the map to reduce memory consumption by deleting stale entries.
*/
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}

boolean removeEldestEntry(Map.Entry<K, V> eldest) 方法在 void afterNodeInsertion(boolean evict) 方法中被调用,只有当 boolean removeEldestEntry(Map.Entry<K, V> eldest) 方法返回 true 时,才能够删除 EldestEntry

1
2
3
4
5
6
7
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}

因此我们重写之后的判断条件为:如果 LinkedHashMap 中存储的元素个数已经大于缓存容量 capacity,则返回 true,表示允许删除 EldestEntry;否则返回 false,表示无需删除 EldestEntry

1
2
3
4
5
// 用于判断是否需要删除最近最久未使用的节点
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return super.size() > capacity;
}

举例说明构造函数中的 accessOrder 的含义

构造函数中的 accessOrder 字段

LRUCacheDemo 的构造方法中,我们调用了 LinkedHashMap 的构造方法,其中有一个字段为 accessOrder

image-20210207193458904

accessOrder = trueaccessOrder = false 的情况:

  • accessOrder = true 时,每次使用 key 时(put 或者 get 时),都将 key 对应的数据移动到队尾(右边),表示最近经常使用;
  • accessOrder = false 时,key 的顺序为插入双向链表时的顺序

image-20210207194714608

注:false可以说是一种优化,没有去移动相关节点,性能会更好一点

LinkedHashMapput() 方法

LinkedHashMap 其实没有 put() 方法,LinkedHashMapput()方法其实就是 HashMapput() 方法,我就奇了怪了,LinkedHashMap 就是 HashMap???其实并不是。。。一起来看一下HashMap的putVal()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

putval() 方法的调用了两个方法:afterNodeAccess(e) 方法和 afterNodeInsertion(evict) 方法,这两个方法就是专门针对于 LinkedHashMap 写的方法:在 HashMap 中这些方法均为空实现的方法,没有任何代码逻辑,需要推迟到子类 LinkedHashMap 中去实现,就是模板方法设计模式

1
2
3
4
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

注释也写了:Callbacks to allow LinkedHashMap post-actions

LinkedHashMapvoid afterNodeAccess(Node<K,V> e) 方法中:如果设置了 accessOrder = true 时,则每次使用 key 时(put 或者 get 时),都将 key 对应的数据移动到队尾(右边),表示这是最近经常使用的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
// accessOrder如果设置为true,进入if块
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}

LinkedHashMapvoid afterNodeInsertion(boolean evict) 方法中:如果头指针不为空并且当前需要删除老节点,则执行 removeNode(hash(key), key, null, false, true) 方法删除 EldestEntry(若 accessOrder = true 时,EldestEntry 表示最近最少使用的数据,若 accessOrder = false 时,EldestEntry 表示最先插入链表的节点)

1
2
3
4
5
6
7
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}

LinkedHashMapget() 方法

LinkedHashMapget() 方法中:若 accessOrder = true 时,则每次 get(key) 之后都会将 key 对应的数据移动至双向链表的尾部:

1
2
3
4
5
6
7
8
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}

LinkedHashMap 中如何构造双向链表?

Entry<K,V> 继承了 HashMap.Node<K,V>,并且有 Entry<K,V> before, after; 两个字段,这不就是双线链表的标配嘛

1
2
3
4
5
6
7
8
9
/**
* HashMap.Node subclass for normal LinkedHashMap entries.
*/
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}

LinkedHashMap 中定义了 headtail分别指向双向链表的头部和尾部,人家注释中也说了,**head 用于指向双向链表中最老的节点,tail 用于指向最年轻的节点,至于最老和最年轻的定义,就得看 accessOrder 字段的值了:如果 accessOrder = false,那么最老的节点就是最久没有被使用过的节点,最年轻的节点就是最近被刚被使用过的节点;如果 accessOrder = true,那么最老的节点就是链表头部的节点,最年轻的节点就是链表尾部的节点**

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;

/**
* The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;

/**
* The iteration ordering method for this linked hash map: <tt>true</tt>
* for access-order, <tt>false</tt> for insertion-order.
*
* @serial
*/
final boolean accessOrder;

2、完全自己手写

  1. 依葫芦画瓢,先定义 Node 类作为数据的承载体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 1.构造一个node节点作为数据载体
    class Node<K, V> {
    K key;
    V value;
    Node<K, V> prev;
    Node<K, V> next;

    public Node() {
    this.prev = this.next = null;
    }

    public Node(K key, V value) {
    this.key = key;
    this.value = value;
    this.prev = this.next = null;
    }
    }
  2. 定义双向链表,里面存放的就是 Node 对象,Node 节点之间通过 prevnext 指针连接起来

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    // 2.构建一个虚拟的双向链表,,里面存放的就是我们的Node
    class DoubleLinkedList<K, V> {
    Node<K, V> head;
    Node<K, V> tail;

    public DoubleLinkedList() {
    head = new Node<>();
    tail = new Node<>();
    head.next = tail;
    tail.prev = head;
    }

    // 3.添加到头(头插法)
    public void addHead(Node<K, V> node) {
    node.next = head.next;
    node.prev = head;
    head.next.prev = node;
    head.next = node;
    }

    // 4.删除节点
    public void removeNode(Node<K, V> node) {
    node.next.prev = node.prev;
    node.prev.next = node.next;
    node.prev = null;
    node.next = null;
    }

    // 5.获得最后一个节点
    public Node getLast() {
    return tail.prev;
    }
    }
  3. 通过 HashMapDoubleLinkedList 构建 LinkedHashMap,我们这里可是将最近最常使用的节点放在了双向链表的头部(和 LinkedHashMap 不同哦)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    private int cacheSize;
    Map<Integer, Node<Integer, Integer>> map;
    DoubleLinkedList<Integer, Integer> doubleLinkedList;

    public LRUCacheDemo(int cacheSize) {
    this.cacheSize = cacheSize;//坑位
    map = new HashMap<>();//查找
    doubleLinkedList = new DoubleLinkedList<>();
    }

    public int get(int key) {
    if (!map.containsKey(key)) {
    return -1;
    }

    Node<Integer, Integer> node = map.get(key);
    doubleLinkedList.removeNode(node);
    doubleLinkedList.addHead(node);

    return node.value;
    }

    public void put(int key, int value) {
    if (map.containsKey(key)) { //update
    Node<Integer, Integer> node = map.get(key);
    node.value = value;
    map.put(key, node);

    doubleLinkedList.removeNode(node);
    doubleLinkedList.addHead(node);
    } else {
    if (map.size() == cacheSize) //坑位满了
    {
    Node<Integer, Integer> lastNode = doubleLinkedList.getLast();
    map.remove(lastNode.key);
    doubleLinkedList.removeNode(lastNode);
    }

    //新增一个
    Node<Integer, Integer> newNode = new Node<>(key, value);
    map.put(key, newNode);
    doubleLinkedList.addHead(newNode);
    }
    }
  4. 测试 LRUCacheDemo

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public static void main(String[] args) {

    LRUCacheDemo lruCacheDemo = new LRUCacheDemo(3);

    lruCacheDemo.put(1, 1);
    lruCacheDemo.put(2, 2);
    lruCacheDemo.put(3, 3);
    System.out.println(lruCacheDemo.map.keySet());

    lruCacheDemo.put(4, 1);
    System.out.println(lruCacheDemo.map.keySet());

    lruCacheDemo.put(3, 1);
    System.out.println(lruCacheDemo.map.keySet());
    lruCacheDemo.put(3, 1);
    System.out.println(lruCacheDemo.map.keySet());
    lruCacheDemo.put(3, 1);
    System.out.println(lruCacheDemo.map.keySet());
    lruCacheDemo.put(5, 1);
    System.out.println(lruCacheDemo.map.keySet());

    }
  5. 测试结果:我们实现的是accessOrder = false最近最少使用的数据已经被删除了

    image-20210207220552698

全部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
public class LRUCacheDemo {

// map 负责查找,构建一个虚拟的双向链表,它里面装的就是一个个 Node 节点,作为数据载体

// 1.构造一个node节点作为数据载体
class Node<K, V> {
K key;
V value;
Node<K, V> prev;
Node<K, V> next;

public Node() {
this.prev = this.next = null;
}

public Node(K key, V value) {
this.key = key;
this.value = value;
this.prev = this.next = null;
}
}

// 2.构建一个虚拟的双向链表,,里面安放的就是我们的Node
class DoubleLinkedList<K, V> {
Node<K, V> head;
Node<K, V> tail;

public DoubleLinkedList() {
head = new Node<>();
tail = new Node<>();
head.next = tail;
tail.prev = head;
}

// 3.添加到头
public void addHead(Node<K, V> node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}

// 4.删除节点
public void removeNode(Node<K, V> node) {
node.next.prev = node.prev;
node.prev.next = node.next;
node.prev = null;
node.next = null;
}

// 5.获得最后一个节点
public Node getLast() {
return tail.prev;
}
}

private int cacheSize;
Map<Integer, Node<Integer, Integer>> map;
DoubleLinkedList<Integer, Integer> doubleLinkedList;

public LRUCacheDemo(int cacheSize) {
this.cacheSize = cacheSize;//坑位
map = new HashMap<>();//查找
doubleLinkedList = new DoubleLinkedList<>();
}

public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}

Node<Integer, Integer> node = map.get(key);
doubleLinkedList.removeNode(node);
doubleLinkedList.addHead(node);

return node.value;
}

public void put(int key, int value) {
if (map.containsKey(key)) { //update
Node<Integer, Integer> node = map.get(key);
node.value = value;
map.put(key, node);

doubleLinkedList.removeNode(node);
doubleLinkedList.addHead(node);
} else {
if (map.size() == cacheSize) //坑位满了
{
Node<Integer, Integer> lastNode = doubleLinkedList.getLast();
map.remove(lastNode.key);
doubleLinkedList.removeNode(lastNode);
}

//新增一个
Node<Integer, Integer> newNode = new Node<>(key, value);
map.put(key, newNode);
doubleLinkedList.addHead(newNode);

}
}

public static void main(String[] args) {

LRUCacheDemo lruCacheDemo = new LRUCacheDemo(3);

lruCacheDemo.put(1, 1);
lruCacheDemo.put(2, 2);
lruCacheDemo.put(3, 3);
System.out.println(lruCacheDemo.map.keySet());

lruCacheDemo.put(4, 1);
System.out.println(lruCacheDemo.map.keySet());

lruCacheDemo.put(3, 1);
System.out.println(lruCacheDemo.map.keySet());
lruCacheDemo.put(3, 1);
System.out.println(lruCacheDemo.map.keySet());
lruCacheDemo.put(3, 1);
System.out.println(lruCacheDemo.map.keySet());
lruCacheDemo.put(5, 1);
System.out.println(lruCacheDemo.map.keySet());

}
}

图解双向队列

  1. DoubleLinkedList 双向链表的初始化

    image-20210207220712585

  2. 双向链表插入节点

    image-20210207220859295

  3. 双向链表删除节点

    image-20210207221116712


MySql

1、Mysql什么时候建索引,什么时候不能建索引

1、索引是什么?

MySQL官方对索引的定义为:索引(Index)是希切MySQL高效获取数据的数据结构。

可以得到索引的本质:索引是数据结构(大部分索引是B+树)

一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上

2、索引的优缺点

优点:

  • 类似大学图书馆建书目索引,提高数据检索的效率,降低数据库的IO成本
  • 通过索引列对数据进行排序,降低数据排序的成本,降低了CPU的消耗

缺点:

  • 虽然索引大大提高了查询速度,同时却会降低更新表的速度,如对表进行INSERT、UPDATE和DELETE。因为更新表时,MySQL不仅要保存数据,还要保存一下索引文件每次更新添加了索引列的字段,都会调整因为更新所带来的键值变化后的索引信息
  • 实际上索引也是一张表,该表保存了主键与索引字段,并指向实体表的记录,所以索引列也是要占用空间的

3、什么时候建索引,什么时候不能建索引

哪些情况需要创建索引:(从索引的优点去考虑)

  • 主键自动建立唯一索引
  • 频繁作为查询条件的字段应该创建索引
  • 查询中与其它表关联的字段,外键关系建立索引
  • 单键/组合索引的选择问题,组合索引性价比更高
  • 查询中排序的字段,排序字段若通过索引去访问将大大提高排序速度
  • 查询中统计或者分组字段
    • 分组字段创建索引会比排序字段创建索引的效率更高,因为分组本身就包含了排序,分组是先排序后分组

哪些情况不要创建索引:(从索引的缺点去考虑)

  • 表记录太少
    • 因为还需要维护一张索引表
  • 经常增删改的表或者字段
    • 因为索引会降低更新表的速度
  • Where条件里用不到的字段不创建索引
    • 索引是为了更好更快的查询数据,如果该字段不用来查询数据,就没有必要创建索引
  • 过滤性不好的不适合建索引
    • 过滤性不好,如:性别
    • 过滤性好,如:身份证号

JUC多线程及高并发

1、谈谈你对volatile的理解

1、解答

volatile是Java虚拟机提供的轻量级同步机制

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

2、谈谈JMM(Java内存模型)

JMM(Java内存模型,简称JMM)本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),==工作内存是每个线程的私有数据区域==,而Java内存模型中规定所有变量都存储到主内存,==主内存是共享内存区域==,所有线程都可以访问,但线程对变量的操作(读取、复制等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此==不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成==,其简要访问过程如下图:

image-20210925231133733

3、解析

1、volatile解决可见性问题

通过前面对JMM的介绍,我们知道:

  • 各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回主内存中的。
  • 这就可能存在一个线程A修改了共享变量X的值但还未写回主内存时,另一个线程B又对准内存中同一个共享变量X进行操作,
  • 但此时A线程工作内存中共享变量X对线程B来说并不是可见,这种工作内存与主内存同步存在延迟现象就造成了可见性问题。

可见性问题:

image-20210925232120695


volatile解决可见性问题:

image-20210925232730948

2、volatile不能解决原子性问题

编程代码:20个线程对int类型的变量num进行1000次的自增操作num++,最终得到的num结果小于等于20000

尝试解决:在num前面使用volatile进行修饰,运行发现num结果的依旧小于等于20000

  • volatile不能解决原子性问题

为什么volatile不能解决原子性问题?

  • volatile解决的是可见性的问题,当一个线程修改自己工作流程的num时,要将num写回主内存当中并对其他线程可见,但是它不能保证
    1. 从主存获取num数据
    2. 在自己的工作内存当中将num的值加1
    3. 在把修改过后的num的值写回主内存
  • 以上三个操作是原子性的,所以就有可能出现丢失写值的情况:多个线程把自己修改的num值同时写回主内存,而num只增加了1。就会出现最终num的结果小于等于20000的情况了。

注:num++并不是一个原子操作,它是三个操作。相关的JVM字节码:

image-20210925235908134

那么怎么解决num++的原子性问题

  1. 方法1:synchronized加锁
    • 不推荐,锁的粒度太大
  2. 方法2:使用原子整形类AutomicInteger的getAndIncrement方法进行自增
    • 底层:使用了CAS轻量级锁
3、volatile禁止指令重排序——保证有序性

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下3种

image-20210926005539237

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致

处理器在进行重排序时必须考虑指令之间的数据依赖性

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

image-20210926005610144

4、volatile底层原理

为什么volatile可以实现可见性与有序性?

先了解一个概念,内存屏障又称内存栅栏(Memory Barrier),是一个CPU指令,它的作用有两个:

  1. 保证特定操作的执行顺序
  2. 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

内存屏障(Memory Barrier)的分类:

  • 关于Volatile写的内存屏障指令

    • StoreStore屏障:禁止上面的普通写和下面的volatile写重排序——保证了使用volatile修饰的变量的写是在普通写之后执行的

    • StoreLoad屏障:防止上面的volatile写和下面可能有的volatile读/写重排序——保证了使用volatile修饰的变量的写是在其他在该volatile变量之后的使用volatile修饰的变量之前执行的

      1
      2
      3
      4
      int a = 10; // 1
      volatile int b = 20; // 2
      int c = 30; // 3
      volatile int d = 40; // 4
      • 2的volatile可以保证1在2之前执行,2在3/4之前执行
      • 4的volatile可以保证1/2/3在4之前执行
  • 关于Volatile读的内存屏障指令

    • LoadLoad屏障:禁止下面所有普通读操作和上面的volatile读重排序——保证了使用volatile修饰的变量的读是在其他普通变量读的之前执行

    • LoadStore屏障:禁止下面所有的写操作和上面的volatile读重排序——保证了使用volatile修饰的变量的读是在其他普通变量写的之前执行

      1
      2
      3
      4
      5
      6
      7
      volatile int a = 10;
      int b = 20;
      int c = 30;

      System.out.println(a); // 1
      System.out.println(b); // 2
      c = 80; // 3
      • a的volatile保证了1在2/3之前执行

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重新排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。——保证有序性

内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。——保证可见性

image-20210926005930287

4、保证线程的安全
  1. 工作内存和主内存同步延迟现象导致的可见性问题
    • 可以使用synchronizedvolatile关键字解决,他们都可以使一个线程修改后的变量立即对其他线程可见
  2. 对于指令重排导致的可见性问题和有序性问题
    • 可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。

4、你在哪些地方用过volatile?

解答:

  1. 在单例模式Singleton的DCL写法(Double Check Lock双端检索机制)使用过volatile来反正指令的重排序
  2. 在手写读写锁的缓存的时候
  3. 在翻阅CAS的底层的JUC源码时经常能看到volatile

为什么单例模式Singleton的DCL写法会出现指令的重排序问题导的线程安全问题?

原因在于对象的创建并不是一个原子操作,对象的操作分为3个步骤,而且步骤之间不存在数据依赖,可以进行指令重排序。

导致某一个线程执行到第一次检测,读取到的instance不为null时,可能会出现两种情况:

  1. instance的引用对象真的创建成功了(绝大部分都是)
  2. instance的引用对象可能没有完成初始化(还是可能出现的,出现了就是线程安全问题了)

instance = new SingletonDemo();——创建对象可以分为以下3步完成(伪代码)

  1. 分配对象内存空间
    • memory = allocate();
  2. 初始化对象
    • instance(memory);
  3. 设置instance指向刚分配的内存地址,此时instance! =null
    • instance = memory;

如果JVM依靠的以上的指令进行对象的创建的话,就不会出现线程安全问题了——第一种情况

但是步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。

  1. 分配对象内存空间
    • memory = allocate();
  2. 设置instance指向刚分配的内存地址,此时instance! =null,但是对象还没有初始化完成!对象里面的实例变量可能还没有赋值等等
    • instance = memory;
  3. 初始化对象
    • instance(memory);

因此此时线程得到的instance实例就是有问题的,就出现了线程安全问题——第二种情况

2、CAS你知道吗

1、什么是CAS

CAS的全称是Compare-And-Swap(比较并交换),它是一条CPU并发原语

它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。

CAS就是一个线程如果想要将当前修改值放进主内存之前需要进行一次比较操作:拿一开始从主内存获取的变量副本与当前主内存当中的该变量的值进行比较。如果一样,则表示在该线程修改该变量的值的这段时间内,没有其他线程动过主内存当中该变量的值(除了出现ABA问题),则可以进行写回操作,并且返回true。否则返回false并且写回失败。

CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法。调用UnSafe类中CAS方法,JVM会帮我们实现CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现原子性。再次强调,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干指令组成的,用语完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致性问题。

CAS应用:CAS有3个操作数,内存值V,旧的预期值A,要修改的更新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

为什么说CAS的粒度要比Synchronized低呢?——这就需要从CAS的底层说起

  • 因为Synchronized使用了锁,线程的并发性下降了
  • 而CAS没有使用锁,底层是使用了自旋锁(自旋锁不是锁,而是一个循环),因此线程的并发性并没有下降

2、CAS的底层

CAS的底层:Unsafe + 自旋锁

  • Unsafe保证CAS操作的原子性
  • 自旋锁保证CAS操作的成功性

以atomicInteger.getAndIncrement();为例:

image-20210926022649349

假设线程A和线程B两个线程同时执行getAndAddInt操作(分别在不同CPU上)

  1. AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据JMM模型,线程A和线程B各自持有一份值为3的value的副本分别到各自的工作内存。
  2. 线程A通过getAndIncrement(var1,var2)拿到value值3,这时线程A被挂起。
  3. 线程B也通过getAndIncrement(var1,var2)方法获得value值3,此时刚好线程B没有被挂起并执行compareAndSwap方法比较内存值也为3,成功修改内存值为4,线程B打完收工,一切OK。
  4. 这是线程A恢复运行,执行compareAndSwapInt方法比较,发现手里的值3与内存值4不一致,说明该值已经被其他线程抢先异步修改过了,那A线程本次修改失败,只能重新读取重新来一遍了。
  5. 线程A重新获取value值,因为变量value被volatile修饰,所以其他线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。

底层汇编:

Unsafe类中的compareAndSwaplnt,是一个本地方法,该方法的实现位于unsafe.cpp中

1
2
3
4
5
6
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x,addr,e)) == e;
UNSAFE_END

(Atomic::cmpxchg(x,addr,e)) == e

  • 先想办法拿到变量value在内存中的地址。
  • 通过Atomic::cmpxchg实现比较替换,其中参数x是即将更新的值,参数e是原内存的值。

3、Unsafe

Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中CAS操作的执行依赖于Unsafe类的方法。

注意Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。

其中:

  • 变量valueOffset,表示该变量在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
  • 变量value用volatile修饰,保证了多线程之间的内存可见性。

4、CAS的缺点是什么?原子类Atomiclnteger的ABA问题谈谈?原子更新引用知道吗?

  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作
  • ABA问题
1、ABA问题

因为CAS需要在操作值的时候,检查值有没有发生变化,比如没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时则会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。

从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值

2、循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。

pause指令有两个作用:

  1. 第一,它可以延迟流水线执行命令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
  2. 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3、只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁

还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。

从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作

3、我们知道ArrayList是线程不安全,请编写一个不安全的案例并给出解决方案

ArrayList的add操作在多线程下是不安全的,会出现ConcurrentModificationException异常——并发修改异常

解决方法:

  • 使用Vector集合代替ArrayList集合
    • Vector的底层方法,如add方法加了synchronized锁,粒度大,并发性低,不推荐
  • 使用Collections.synchronizedList集合包装ArrayList集合
    • 底层也是加了synchronized锁,粒度大,并发性低,不推荐
  • 使用JUC的CopyOnWriteArrayList集合代替ArrayList集合
    • 推荐

JUC的CopyOnWriteArrayList集合:——解决ArrayList多线程不安全问题

image-20210926141136813

CopyOnWriteArrayList集合的add方法实现:

image-20210926141201995

写时复制技术:

  • CopyOnWrite容器即写时复制的容器。
  • 往一个容器添加元素的时候,不直接往当前容器Object[]添加,而是先将当前object[]进行Copy,复制出一个新的容器Object[] newElements,然后新的容器Object[] newElements里添加元素,
  • 添加完元素之后,再将原容器的引用指向新的容器:setArray(newElements);
  • 这样做的好处是可以对copyonwrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
  • 所以copyonwrite容器也是一种读写分离的思想,读和写不同的容器

JUC的CopyOnWriteArraySet集合:——解决HashSet多线程不安全问题

  • 底层使用了上面的CopyOnWriteArrayList集合

image-20210926141050834

注:

  • HashSet的底层是一个容量为18,负载因子为0.75的HashMap
    • HashSet集合add的值是底层HashMap的key(不可重复)
    • 底层HashMap的value是PRESENT——一个Object类型的常量
  • CopyOnWriteArraySet的底层是一个CopyOnWriteArrayList

JUC的ConcurrentHashMap集合:——解决HashMap多线程不安全问题

注意:

  • ConcurrentHashMap集合在JDK1.7与JDK1.8的底层实现不一样
    • ConcurrentHashMap JDK1.7:使用分段锁机制实现;
    • ConcurrentHashMap JDK1.8:则使用数组+链表+红黑树数据结构和CAS原子操作实现;
  • HashMap集合在JDK1.7与JDK1.8的底层实现也略微有一点差别
    • JDK1.7的HashMap在多线程的环境下,扩容的过程中可能会出现并发死链问题
      • 出现原因:JDK1.7的HashMap在添加元素是使用的是头插法
    • JDK1.8的HashMap使用尾插法解决了并发死链问题
    • 但是两种HashMap都不能解决在多线程的环境下出现ConCurrentModificationException问题

4、公平锁/非公平锁/可重入锁/递归锁/自旋锁谈谈你的理解?请手写一个自旋锁

1、公平锁和非公平锁

1、是什么
  • 公平锁:是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。
  • 非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。在高并发的情况下,有可能会造成优先级反转或者饥饿现象
2、两者区别

公平锁/非公平锁:并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁,默认是非公平锁

关于两者区别:

  • 公平锁:Threads acquire a fair lock in the order in which they requested it.
    • 公平锁,就是很公平,在并发情况下,每个线程在获取锁时会查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。
  • 非公平锁:非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采取类似公平锁那种方式。
3、ReentrantLock 与 Synchronized

Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁非公平锁的优点在于吞吐量比公平锁大

对于Synchronized而言,也是一种非公平锁

2、可重入锁(又名递归锁)

可重入锁(也就是递归锁):指的是同一个线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。

也就是说:线程可以进入任何一个它已经拥有的锁所有同步着的代码块。

  • ReentrantLock/Synchronized就是一个典型的可重入锁
  • 可重入锁最大的作用是避免死锁
  • ReentrantLock可以获取多把的锁,但是需要配对进行解锁,否则会出现死锁问题

3、独占锁/共享锁

  • 独占锁:指该锁一次只能被一个线程所持有

    • ReentrantLockSynchronized而言都是独占锁。
  • 共享锁:指该锁可被多个线程所持有。

    • ReentrantReadWriteLock其读锁是共享锁,其写锁是独占锁。读锁的共享锁可保证并发读是非常高效的,
      • 读写,写读,写写的过程是互斥的。
      • 读读是共享的

4、自旋锁

自旋锁:是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁

这样的好处是减少线程上下切换的消耗缺点是循环会消耗CPU

自己实现自旋锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

public class SpinLockDemo {
// 原子引用线程
AtomicReference<Thread> atomicReference = new AtomicReference<>();

public static void main(String[] args) {
// 测试写好的自旋锁
SpinLockDemo spinLockDemo = new SpinLockDemo();
new Thread(() -> {
spinLockDemo.myLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (Exception e) {
e.printStackTrace();
}
spinLockDemo.myUnlock();
}, "AA").start();

try {
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}

new Thread(() -> {
spinLockDemo.myLock();
try {
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
spinLockDemo.myUnlock();
}, "BB").start();
}

// 手写自旋锁
// 上锁
public void myLock() {
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName() + "\t" + " come in ...");
while (!atomicReference.compareAndSet(null, thread)) { }
}

// 解锁
public void myUnlock() {
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread, null);
System.out.println(Thread.currentThread().getName() + "\t" + " unlock ...");
}

}

5、synchronized和lock有什么区别?用新的lock有什么好处?你举例说说

  1. 原始构成
    • synchronized是关键字,属于JVM层面,monitorenter(底层是通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象,只有在同步块或者方法中才能调用wait/notify等方法)
    • Lock是具体类(java.util.concurrent.locks.lock)是api层面的锁。
  2. 使用方法
    • synchronized不需要用户去手动释放锁,当synchronized代码执行完后系统会自动让线程释放对锁的占用
    • ReentrantLock则需要用户去手动释放锁,若没有主动释放锁,就有可能导致出现死锁现象。
      • 需要lock()和unlock()方法配合try/finally语句块来完成。
  3. 等待是否可中断
    • synchronized不可中断,除非抛出异常或者正常运行完成
    • ReentrantLock可中断
      1. 设置超时方法:tryLock(long timeout,TimeUnit unit);
      2. lockInterruptibly()放代码块中,调用interrupt()方法可中断。
  4. 加锁是否公平
    • synchronized非公平锁
    • ReentrantLock两者都可以,默认非公平锁,构造方法可以传入boolean值,true为公平锁,false为非公平锁。
  5. 锁绑定多个条件Condition
    • synchronized没有Condition,进行唤醒时要不就是随机唤醒一个沉睡的线程,要不就是唤醒全部沉睡的线程
    • ReentrantLock用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized要么随机唤醒一个要么唤醒全部线程。

5、CountDownLatch/CyclicBarrier/Semaphore

1、CountDownLatch——做减法

  • 让一些线程阻塞直到另一个线程完成一系列操作后才被唤醒
  • CountDownLatch主要有两个方法,当一个或多个线程调用await方法时,调用线程会被阻塞。其他线程调用countDown方法会将计数器减1(调用CountDown方法的线程不会阻塞),当计数器的值变为0时,因调用await方法被阻塞的线程会被唤醒,继续执行
  • 鲜明的例子:秦灭六国,统一中原(减法)

枚举与CountDownLatch实现对应线程的调用

CountDownLatch例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.concurrent.CountDownLatch;

public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
county();
}

private static void county() throws InterruptedException {
CountDownLatch countDownLatch=new CountDownLatch(6);
// 通过枚举将数字i与国家进行匹配
for (int i = 1; i <=6 ; i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t 国被灭");
countDownLatch.countDown();
}, CountryEnum.list(i).getRetMsg()).start();
}
countDownLatch.await();
System.out.println(Thread.currentThread().getName()+"\t ******秦国一统华夏");
}
}

国家枚举:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public enum CountryEnum {
ONE(1, "齐"), TWO(2, "楚"), THREE(3, "燕"), FOUR(4, "赵"), FIVE(5, "魏"), SIX(6, "韩");
@Getter private Integer retCode;
@Getter private String retMsg;

CountryEnum(Integer retCode, String retMsg) {
this.retCode = retCode;
this.retMsg = retMsg;
}

public static CountryEnum list(int idx) {
CountryEnum[] countryEnums = CountryEnum.values();
for (CountryEnum countryEnum : countryEnums) {
if (idx==countryEnum.getRetCode())
return countryEnum;
}
return null;
}
}

2、CyclicBarrier——做加法

  • CyslicBarrier的字面意思是可循环(Cyclic)使用的屏障(Barrier)。
  • 它要做的事情是,让一组线程到达屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会打开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await()方法。
  • 鲜明的例子:集七颗龙珠召唤神龙(加法)

3、Semaphore——伸缩(加减法)

  • 信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制
  • 主要的方法:acquire()方法做减法,release()做加法,放在try/finally块当中执行
  • 鲜明的例子:争抢车位

6、阻塞队列知道吗

1、队列+阻塞队列

阻塞队列,首先它是一个队列,而一个阻塞队列在数据结构中所起的作用大致是:线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素

  1. 当阻塞队列是时,从队列中获取元素的操作将被阻塞
  2. 当阻塞队列是时,往队列里添加元素的操作将被阻塞
  3. 试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他的线程往空的队列插入新的元素
  4. 同样,试图从已满的阻塞队列中添加元素的线程同样也会被阻塞,直到其他的线程从队列中移除一个或者多个元素或者完全清空队列后使队列重新变得空闲起来并后续新增

image-20210927150822979

2、为什么用?有什么好处?

在多线程领域:所谓阻塞,在某些情况下会挂起线程(即阻塞),一旦满足条件,被挂起的线程又会自动被唤醒

为什么需要BlockingQueue?
好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue都给你包办了。

concurrent包发布之前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给程序带来不小的复杂度。

3、架构梳理+种类分析

BlockingQueue和list都是Collections的接口。常用的BlockingQueue的实现类有

  • ArrayBlockingQueue**:由数组结构组成的有界**阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序
  • LinkedBlockingQueue**:由链表结构组成的有界**(但大小默认值为Integer.MAX_VALUE)阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序,吞吐量通常要高于ArrayBlockingQueue。但是它要慎用,LinkedBlockingQueue虽然说是一个有界的阻塞队列,但是它的默认容量是Integer.MAX_VALUE——2147483647(21亿多,与无界差不了多少)
  • SynchronousQueue**:一个不存储元素的阻塞队列,也即单个元素的队列**。每一个put操作必须要等待一个take操作,否则不能继续添加元素,反之亦然。
    • SynchronousQueue没有容量。
    • 与其他BlockingQueue不同,SynchronousQueue是一个不存储元素的BlockingQueue。
    • 吞吐量通常要高于LinkedBlockingQueue
  • PriorityBlockingQueue:支持优先级排序无界阻塞队列。
  • DelayQueue:使用优先级队列实现的延迟无界阻塞队列。
  • LinkedTransferQueue:由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:由链表结构组成的双向阻塞队列。

4、BlockingQueue的核心方法

方法类型 抛出异常 特殊值 阻塞 超时
插入 add(e) offer(e) put(e) offer(e,time,unit)
移除 remove() poll() take() poll(time,unit)
检查 element() peek() 不可用 不可用
  • 抛出异常
    • 当阻塞队列满时,再往队列里add插入元素会抛IllegalStateException Queue Full
    • 当阻塞队列空时,再往队列里remove移除元素会抛NoSuchElementException
  • 特殊值
    • 插入方法,成功true失败false
    • 移除方法,成功返回出队列的元素,队列里面没有就返回null
  • 一直阻塞
    • 当阻塞队列满时,生产者线程继续往队列里put元素,队列会一直阻塞生产线程直到put数据or响应中断退出
    • 当阻塞队列空时,消费者线程试图从队列里take元素,队列会一直阻塞消费者线程直到队列可用
  • 超时退出
    • 当阻塞队列满时,队列会阻塞线程一定的时间,超过限时后生产者线程会退出,返回false

5、阻塞队列用在哪里

  1. 生产者消费者模式
    • 传统版
      • 使用的是RentranLock与Condition的await与signalAll返回
      • 注意虚假唤醒问题
    • 阻塞队列版
      • 在资源类当中添加阻塞队列和相关和生产与消费的方法,通过offer与poll的进行超时调用
  2. 线程池
  3. 消息中间件的底层使用的就是阻塞队列

7、使用FutureTask 与 Callable的几个注意事项

1、使用FutureTask的注意事项

  1. 多个线程抢FutureTask,FutureTask只会执行一次
  2. 如果想要执行多次FutureTask,需要新建新的FutureTask对象给线程使用
  3. futureTask的get的方法可以获取运行futureTask的线程的运行结果,建议将该方法放在最后使用。
    • 因为该方法需要得到Callable结果的计算结果,放在前面可能会导致当前线程出现阻塞等待Callable的结果的获取之后才能往下运行
    • 如果不得不先获取futureTask的结果,可以使用idDone方法,以自旋锁的方式获取

2、Java有了Runnable接口,为什么还需要Callable接口?

先看看两者的源码:

Runnable.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// JDK1.0开始
@FunctionalInterface
public interface Runnable {
/**
* When an object implementing interface <code>Runnable</code> is used
* to create a thread, starting the thread causes the object's
* <code>run</code> method to be called in that separately executing
* thread.
* <p>
* The general contract of the method <code>run</code> is that it may
* take any action whatsoever.
*
* @see java.lang.Thread#run()
*/
public abstract void run();
}

Callable.java

1
2
3
4
5
6
7
8
9
10
11
// JDK1.5开始
@FunctionalInterface
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}

由他们本身的接口定义我们就能够看出它们的区别:

  1. callable的核心是call方法,允许返回值,runnable的核心是run方法,没有返回值
  2. call方法可以抛出异常,但是run方法不行
    • 因为runnable是java1.0就有了,所以他不存在返回值,后期在java1.5进行了优化,就出现了callable,就有了返回值和抛异常
    • 因此Callable接口实现类中run()方法允许将异常向上抛出,也可以直接在内部处理(try…catch)
    • 而Runnable接口实现类中run()方法的异常必须在内部处理掉,不能向上抛出
  3. callable和runnable都可以应用于executors。而thread类只支持runnable
  4. runnable对象实例可以作为线程池方法execute()的入口参数来执行任务;
  5. callable对象实例可以作为线程池的submit()方法入口参数来执行任务;
  6. 运行Callable任务可拿到一个Future对象, Future表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。 通过Future对象可了解任务执行情况,可取消任务的执行,还可获取任务执行的结果。
    • Callable接口支持通过FutureTask异步返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞线程直到获取“将来”的结果,当不调用此方法时,主线程不会阻塞。

8、线程池用过吗?ThreadPoolExecutor谈谈你的理解?

1、为什么用线程池?——线程池的优势

线程池主要是控制运行线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。

主要特点是:线程复用、控制最大并发数、管理线程

  1. 第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  3. 第三:提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

2、线程池如何使用?

1、架构说明

Java中的线程池是通过Executor框架实现的,该框架中用到了Executor,Executors,ExecutorService,ThreadPoolExecutor这几个类。

2、编码实现
  • Executors.newScheduledThreadPool()
  • java8新出:Executors.newWorkStealingPool(int)——使用所有可用处理器作为目标并行度,创建一个窃取线程的池。
    • 使用目前机器上可用的处理器作为它的并行级别
  • **Executors.newFixedThreadPool(int)**:一池固定线程数,执行长期的任务,性能好很多
  • **Executors.newSingleThreadExecutor()**:一池一线程,一个任务一个任务执行的场景
  • **Executors.newCachedThreadPool()**:一池多线程(可扩容),适用与执行很多短期异步的小程序或者负载较轻的服务器
1、Executors.newFixedThreadPool(int)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Creates a thread pool that reuses a fixed number of threads
* operating off a shared unbounded queue, using the provided
* ThreadFactory to create new threads when needed. At any point,
* at most {@code nThreads} threads will be active processing
* tasks. If additional tasks are submitted when all threads are
* active, they will wait in the queue until a thread is
* available. If any thread terminates due to a failure during
* execution prior to shutdown, a new one will take its place if
* needed to execute subsequent tasks. The threads in the pool will
* exist until it is explicitly {@link ExecutorService#shutdown
* shutdown}.
*
* @param nThreads the number of threads in the pool
* @param threadFactory the factory to use when creating new threads
* @return the newly created thread pool
* @throws NullPointerException if threadFactory is null
* @throws IllegalArgumentException if {@code nThreads <= 0}
*/
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory);
}

主要特点:

  1. 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  2. newFixedThreadPool创建的线程池corePoolSize和maximumPoolSize值是相等的,它使用的LinkedBlockingQueue
2、Executors.newSingleThreadExecutor()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Creates an Executor that uses a single worker thread operating
* off an unbounded queue. (Note however that if this single
* thread terminates due to a failure during execution prior to
* shutdown, a new one will take its place if needed to execute
* subsequent tasks.) Tasks are guaranteed to execute
* sequentially, and no more than one task will be active at any
* given time. Unlike the otherwise equivalent
* {@code newFixedThreadPool(1)} the returned executor is
* guaranteed not to be reconfigurable to use additional threads.
*
* @return the newly created single-threaded Executor
*/
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

主要特点:

  1. 创建一个单线程化的线程池,它只会唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。
  2. newSingleThread将corePoolSize和maximumPoolSize都设置为1,它使用的是LinkedBlockingQueue
3、Executors.newCachedThreadPool()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Creates a thread pool that creates new threads as needed, but
* will reuse previously constructed threads when they are
* available. These pools will typically improve the performance
* of programs that execute many short-lived asynchronous tasks.
* Calls to {@code execute} will reuse previously constructed
* threads if available. If no existing thread is available, a new
* thread will be created and added to the pool. Threads that have
* not been used for sixty seconds are terminated and removed from
* the cache. Thus, a pool that remains idle for long enough will
* not consume any resources. Note that pools with similar
* properties but different details (for example, timeout parameters)
* may be created using {@link ThreadPoolExecutor} constructors.
*
* @return the newly created thread pool
*/
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

主要特点:

  1. 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
  2. newCachedThreadPool将corePoolSize设置为0,将maximumPoolSize设置为Integer.MAX_VALUE,使用的SynchronousQueue,也就是说来了任务就创建线程运行,当线程空闲超过60秒,就销毁线程。
3、ThreadPoolExcutor

image-20210928224559619

以上线程池的底层都是使用了ThreadPoolExcutor的构造方法,只是传入的参数有差别。这也是我们自定义线程池的方法

3、线程池的几个重要参数介绍

image-20210928224539153

  • corePoolSize:线程池中的常驻核心线程数
    • 在创建线程池后,当有请求任务来之后,就会安排池中的线程去执行请求任务,近似理解为今日当值线程。
    • 当线程池中的线程数目到达corePoolSize后,就会把到达的任务放到缓存队列当中。
  • maximumPoolSize:线程池能够容纳同时执行的最大线程数,此值必须大于等于1.
  • keepAliveTime:多余的空闲线程的存活时间。
    • 当前线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime值时,多余空闲线程会被销毁直到只剩下corePoolSize个线程为止。
    • 默认情况下:只有当线程池中的线程数大于corePoolSize时keepAliveTime才会起作用,直到线程池中的线程数不大于corePoolSize。
  • unit:keepAliveTime的单位
  • workQueue:任务队列,被提交但尚未被执行的任务。
  • threadFactory:表示生成线程池中工作线程的线程工厂,用于创建线程一般用默认的即可
    • 也可以直接cv默认的,然后定义一下线程名字,出问题了好排错
  • handler:拒绝策略,表示当队列满了并且工作线程大于等于线程池的最大线程数,当有新的任务到来时采用的策略。
为什么在ThreadPoolExecutor的底层构造函数有七种参数,而线程池的创建却只有5个?剩下的那两个参数是什么?有什么作用?

答:

  • 在线程池的创建当中使用的参数是前五个,剩下的那两个参数:

  • 一个是生成线程池当中的线程的线程工厂,一般用默认的线程工厂进行工作线程的创建,因此提取出来在底层进行赋值,

  • 最后一个参数是拒绝策略,作用是当队列满了并且工作线程大于等于线程池的最大线程数,当有新的任务到来时采用的策略。以上三个线程池的拒绝策略都是一样的——丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息(线程池默认的拒绝策略)。因此也提取出来在底层进行赋值。

    image-20210929015055562

4、说说线程池的底层工作原理?

image-20210929014307611

  1. 在创建了线程池后,线程池中的线程数为零,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。

  2. 当调用 execute()方法添加一个请求任务时,线程池会做出如下判断:

    1. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
    2. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入workQueue 队列;
    3. 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务(救急);
    4. 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
  3. 当一个线程完成任务时,它会从队列中取下一个任务来执行

  4. 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:

    1. 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
    2. 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

9、线程池用过吗?生产上你是如何设置合理参数?

1、线程池的拒绝策略你谈谈?JDK内置的拒绝策略有哪些?

答:

线程池中,有三个重要的参数,决定影响了拒绝策略:corePoolSize - 核心线程数,也即最小的线程数。workQueue - 阻塞队列 。 maximumPoolSize -最大线程数。

当提交任务数大于 corePoolSize 的时候,会优先将任务放到 workQueue 阻塞队列中。当阻塞队列饱和后,会扩充线程池中线程数,直到达到maximumPoolSize 最大线程数配置。此时,再多余的任务,则会触发线程池的拒绝策略了。

总结起来,也就是一句话,当提交的任务数大于(workQueue.size() + maximumPoolSize ),就会触发线程池的拒绝策略。

四种拒绝策略:

  • CallerRunsPolicy:当触发拒绝策略,只要线程池没有关闭的话,则使用调用线程直接运行任务。一般并发比较小,性能要求不高,不允许失败。但是,由于调用者自己运行任务,如果任务提交速度过快,可能导致程序阻塞,性能效率上必然的损失较大;
  • AbortPolicy:丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息。线程池默认的拒绝策略。必须处理好抛出的异常,否则会打断当前的执行流程,影响后续的任务执行。
  • DiscardPolicy:直接丢弃,其他啥都没有;
  • DiscardOldestPolicy:当触发拒绝策略,只要线程池没有关闭的话,丢弃阻塞队列 workQueue 中最老的一个任务,并将新任务加入

除了线程池给的四种拒绝策略的实现,其他著名框架也提供了拒绝策略实现:

  • Dubbo 的实现:在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息方便定位问题
  • Netty 的实现:创建一个新线程来执行任务
  • ActiveMQ 的实现:带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略
  • PinPoint 的实现:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略

以上内置拒绝策略均实现了RejectedExecutionHandler接口(函数式接口)

2、你在工作中单一的/固定数的/可变的单重创建线程池的方法,你用哪个多?

答:一个都不用,我们生产上只能使用自定义的

Executors中JDK已经给你提供了,为什么不用?

image-20210929015024492

3、合理配置线程池你是如何考虑的?

  • CPU密集型
    • CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行。
    • CPU密集型任务配置尽可能少的线程数量:一般公式为:CPU核数+1个线程的线程的线程池
  • IO密集型
    1. 第一种:由于IO密集型任务线程并不是一直执行任务,则应配置尽可能多的线程,如*CPU核数 * 2*
    2. 第二种:IO密集型,即该任务需要大量的IO,即大量的阻塞。
      • 在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力浪费在等待。
      • 所以IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。
      • IO密集型时,大部分线程都阻塞,故需要多配置线程数:参考公式:CPU核数/1-阻塞系数 阻塞系数在0.8-0.9之间。(一般乐观就设置为0.9)
  • 混合型
    • CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。

10、死锁编程及定位分析

答:

  • 死锁是指两个或者两个以上的进程在执行过程中,因抢夺资源而造成的一种互相等待的现象,若无外力干涉它们将都无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性也就很低,否则就会因争夺有限的资源而陷入死锁。
  • 产生死锁的主要原因:
    1. 系统资源不足
    2. 进程运行推进的顺序不合适
    3. 资源分配不当
  • 解决
    • jps命令定位进程号
    • jstack找到死锁查看
    • 在服务器上可以用jatack 拉取出项目的内存日志 看线程的情况 可以看出死锁

image-20210929020401653

11、Java里面锁请谈谈你的理解,能说多少说多少

img

  • 公平锁与非公平锁

    • 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
      • 优点:所有的线程都能得到资源,不会饿死在队列中。
      • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
    • 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
      • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
      • 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
  • 可重入锁与不可重入锁

    • 可重入锁:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
      • synchronized的重入的实现机理:
        • 每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
        • 当执行monitorenter时,如果目标对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。
        • 在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。
        • 当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。
    • 不可重入锁:如果是不可重入锁的话,第一个锁没有解锁就不能操作第二个锁的内容
  • 死锁

    • 两个或多个进程在运行过程中,因争夺资源而造成的一种相互等待的现象,当进程处于这种相互等待的状态时,若无外力作用,它们都将无法再向前推进。
  • 活锁

    • 活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束
  • 乐观锁与悲观锁

    • 悲观锁:当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。
    • 悲观锁主要分为共享锁排他锁
      • 共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
      • 排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。
    • 乐观锁:不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现有CAS与版本号控制这两种方式
  • 自旋锁与自适应自旋锁

    • 自旋锁:在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但==不放弃CPU的执行时间==。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。
    • 自适应自旋锁:自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的
  • 无锁、偏向锁、轻量级锁与重量级锁

    • 无锁
      • 无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
      • 无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
    • 偏向锁
      • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
      • 在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
      • 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
      • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
      • 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
    • 轻量级锁
      • 是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
      • 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
      • 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
      • 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
      • 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
      • 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
    • 重量级锁
      • 升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
  • 饥饿问题

    • 一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束
  • 表锁与行锁

    • 表锁:当一个线程在给一张表进行数据操作的时候,会将整一张表都锁起来。在它操作完成之前,其他线程不能对这张表的所有数据进行操作。

      • 表锁:意向锁

        • 表锁,其实也可以叫意向锁,表明“某个事务正在某些行持有了锁、或该事务准备去持有锁”。

        • 意向锁产生的主要目的是为了处理行锁和表锁之间的冲突

          • 事务在请求S锁和X锁前,需要先获得对应的IS、IX锁,
          • 在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。
          • 意向共享锁(IS锁):事务在请求S锁前,要先获得IS锁
          • 意向排他锁(IX锁):事务在请求X锁前,要先获得IX锁
    • 行锁:一个线程在对一张表的某一行数据进行操作的时候,会将那一行数据锁起来,在它操作完成之前,其他线程不能对这一行数据进行操作,但是对原这张表的其他行数据进行操作是允许的。

      • 行锁:记录锁(Record Locks)
        • mysql的行锁是通过索引加载的,即行锁是加在索引响应的行上的,要是对应的SQL语句没有走索引,则会全表扫描.
      • 行锁:间隙锁(Gap Locks)
        • 区间锁,仅仅锁住一个索引区间(开区间,不包括双端端点)
        • 在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,==并不包括该索引记录本==身。比如在 1、2、3中,间隙锁的可能值有 (∞, 1),(1, 2),(2, ∞),
        • 间隙锁可用于防止幻读,保证索引间的不会被插入数据
      • 行锁:临键锁(Next-Key Locks)
        • record lock + gap lock,左开右闭区间。
        • 默认情况下,innodb使用next-key locks来锁定记录。
        • 但当查询的索引含有唯一属性的时候,Next-Key Lock 会进行优化,将其降级为Record Lock,即仅锁住索引本身,不是范围。
        • Next-Key Lock在不同的场景中会退化成记录锁或间隙锁

关于锁的优化:

  • 锁消除
    • 锁消除时指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持
    • 意思就是:JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。
    • 当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。(eg:StringBuffer到StringBulider的转换)
  • 锁粗化
    • 如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的外部,使整个一连串地append()操作只需要加锁一次就可以了。
  • 锁升级/锁膨胀
    • 如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
    • img

12、关于LockSupport的面试题

1、为什么可以先唤醒线程后阻塞线程?

因为unpark获得了一个凭证,之后再调用park方法,就可以名正言顺的凭证消费,故不会阻塞

2、为什么唤醒两个后阻塞两次。但是最终结果还会阻塞线程?

因为凭证的数量最多为1,连续调用两个unpark和调用一次unpark效果一样,只会增加一个凭证;而调用两次park却需要消费两个凭证,凭证不够,故会阻塞。

13、关于AQS

1、AQS 是什么

字面意思:AQS(AbstractQueuedSynchronizer):抽象的队列同步器

一般我们说的 AQS 指的是 java.util.concurrent.locks 包下的 AbstractQueuedSynchronizer,但其实还有另外三种抽象队列同步器:AbstractOwnableSynchronizer、AbstractQueuedLongSynchronizer 和 AbstractQueuedSynchronizer

技术翻译:AQS 是用来构建锁或者其它同步器组件的重量级基础框架及整个JUC体系的基石, 通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量(state)表示持有锁的状态

CLH:Craig、Landin and Hagersten 队列,是一个双向链表,AQS中的队列是CLH变体的虚拟FIFO双向队列

image-20201227165645081

2、AQS 是 JUC 的基石

和AQS有关的并发编程类

image-20201227165833625

进一步理解锁和同步器的关系

  • 面向锁的使用者。定义了程序员和锁交互的使用层API,隐藏了实现细节,你调用即可,可以理解为用户层面的 API。
  • 同步器面向锁的实现者。比如Java并发大神Douglee,提出统一规范并简化了锁的实现,屏蔽了同步状态管理、阻塞线程排队和通知、唤醒机制等,Java 中有那么多的锁,就能简化锁的实现。

3、AQS 能干嘛

AQS:加锁会导致阻塞

有阻塞就需要排队,实现排队必然需要有某种形式的队列来进行管理

抢到资源的线程直接使用办理业务,抢占不到资源的线程的必然涉及一种排队等候机制,抢占资源失败的线程继续去等待(类似办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。

既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node) ,通过CAS、自旋以及LockSuport.park()的方式,维护state变量的状态,使并发达到同步的效果

image-20201227165645081

4、AQS 初步认识

1、AQS初识

官网解释:

image-20201227171157475

有阻塞就需要排队,实现排队必然需要队列:

  1. AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的 FIFO队列来完成资源获取的排队工作将每条要去抢占资源的线程封装成 一个Node节点来实现锁的分配,通过CAS完成对State值的修改
  2. Node 节点是啥?
    • 答:你有见过 HashMap 的 Node 节点吗?JDK 用 static class Node<K,V> implements Map.Entry<K,V> 来封装我们传入的 KV 键值对。
    • 这里也是一样的道理,JDK 使用 Node 来封装(管理)Thread
  3. 可以将 Node 和 Thread 类比于候客区的椅子和等待用餐的顾客

image-20201227171739495

image-20211006145617321

2、AQS内部体系架构

image-20201227182248032

  • AQS的int变量

    1
    2
    3
    4
    /**
    * The synchronization state.
    */
    private volatile int state;

    AQS的同步状态State成员变量,类似于银行办理业务的受理窗口状态:零就是没人,自由状态可以办理;大于等于1,有人占用窗口,需要等待

  • AQS的CLH队列

    image-20201227180550246

    CLH队列(三个大牛的名字组成),原是一个双向链表,被AQS修改为一个双向队列,类似于银行侯客区的等待顾客

  • 内部类Node(Node类在AQS类内部)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    /**
    * Status field, taking on only the values:
    * SIGNAL: The successor of this node is (or will soon be)
    * blocked (via park), so the current node must
    * unpark its successor when it releases or
    * cancels. To avoid races, acquire methods must
    * first indicate they need a signal,
    * then retry the atomic acquire, and then,
    * on failure, block.
    * CANCELLED: This node is cancelled due to timeout or interrupt.
    * Nodes never leave this state. In particular,
    * a thread with cancelled node never again blocks.
    * CONDITION: This node is currently on a condition queue.
    * It will not be used as a sync queue node
    * until transferred, at which time the status
    * will be set to 0. (Use of this value here has
    * nothing to do with the other uses of the
    * field, but simplifies mechanics.)
    * PROPAGATE: A releaseShared should be propagated to other
    * nodes. This is set (for head node only) in
    * doReleaseShared to ensure propagation
    * continues, even if other operations have
    * since intervened.
    * 0: None of the above
    *
    * The values are arranged numerically to simplify use.
    * Non-negative values mean that a node doesn't need to
    * signal. So, most code doesn't need to check for particular
    * values, just for sign.
    *
    * The field is initialized to 0 for normal sync nodes, and
    * CONDITION for condition nodes. It is modified using CAS
    * (or when possible, unconditional volatile writes).
    */
    volatile int waitStatus;

    Node的等待状态waitState成员变量,类似于等候区其它顾客(其它线程)的等待状态,队列中每个排队的个体就是一个Node

    Node类的内部结构:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    static final class Node{
    //共享
    static final Node SHARED = new Node();

    //独占
    static final Node EXCLUSIVE = null;

    //线程被取消了
    static final int CANCELLED = 1;

    //后继线程需要唤醒
    static final int SIGNAL = -1;

    //等待condition唤醒
    static final int CONDITION = -2;

    //共享式同步状态获取将会无条件地传播下去
    static final int PROPAGATE = -3;

    // 初始为e,状态是上面的几种
    volatile int waitStatus;

    // 前置节点
    volatile Node prev;

    // 后继节点
    volatile Node next;

    // ...
3、总结

有阻塞就需要排队,实现排队必然需要队列

  • AQS就是通过state 变量 + CLH双端 Node 队列(头尾指针)实现
  • Node就是通过waitStatus + 前后指针 实现

image-20201227181324215

Node当中的属性说明:

两种模式:

模式 含义
SHARED 表示线程以共享的模式等待锁
EXCLUSIVE 表示线程正在以独占的方式等待锁

六个属性:

方法和属性值 含义
waitStatus 当前节点在队列当中的状态
thread 表示处于该节点的线程
prev 前驱指针
next 后继指针
predecessor 返回前驱节点,没有的话抛npe
nextWaiter 指向下一个处于CONDITION状态的节点

waitStatus的五种取值:

枚举 含义
0 当一个Node被初始化的时候的默认值
CANCELLED 为1,表示线程获取锁的请求已经取消了
CONDITION 为-2,表示节点在等待队列当中,节点线程等待唤醒
PROPAGATE 为-3,当前线程处于SHARED情况下,该字段才会使用。共享式同步状态获取将会无条件地传播下去
SIGNAL 为-1,表示线程已经准备好了,就等资源释放了
4、AQS底层是怎么排队的?

通过调用 LockSupport.pork() 来进行排队

5、AQS的源码解读(通过ReentrantLock为突破口)

1、加锁

只要涉及到加锁,底层会调用NonfairSync的lock()方法来进行加锁。(以ReentrantLock的非公平锁为例)

1
2
3
4
5
6
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
  1. 如果当前资源锁的状态为0,则表示当前没有线程占有锁
  2. 使用CAS将锁状态从0设置为1,并将当前线程设置为占有此锁
  3. CAS设置失败表示当前已经有线程占用该锁,进入acquire()方法

而AQS的acquire()方法是一个模板方法,如果子类没有重写该方法就会抛出异常。

而子类的重写肯定会调用如下三个方法进行锁的抢夺与线程阻塞:

1
2
3
4
5
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
  • tryAcquire():试图获取锁

    1. 首先再次获取当前资源的锁的状态——因为此时可能出现此时占用锁的线程已经释放锁的情况

      1. 如果当前资源锁的状态为0,则表示当前没有线程占有锁——证明之前占用锁的线程已经释放了锁
      2. 使用CAS将锁状态从0设置为1,并将当前线程设置为占有此锁
      3. 返回true,由于前面的!取反之后为false,退出if块
    2. 如果当前锁的状态不等于0,表示当前有线程占有锁,则判断当前占用锁的线程是不是当前线程

      1. 如果是当前线程,表示此时正是锁的重入
      2. 增加重入的次数,并将其设置进锁的状态status当中
      3. 返回true,由于前面的!取反之后为false,退出if块
    3. 如果不是以上两种情况,返回false。由于前面的!取反之后为true,继续执行addWaiter()方法。

      注:公平锁与非公平锁的区别就是在第一个if判断中:公平锁的最前面有!hasQueuePredecessors()方法判断当前的CLH等待队列中是否有等待的线程,而非公平锁没有该方法。

      hasQueuePredecessors()方法如何能体现公平锁与非公平锁的区别?

      • 当一个新的线程来抢占锁,如果当前的CLH等待队列中有等待的线程,那么直接返回true,在!取反为false直接退出if块,不进行之后锁的CAS抢占。tryAcquire()方法直接返回false
      • 之后由addWaiter()方法添加进CLH等待队列——体现了公平
      • 如果没有hasQueuePredecessors()方法,那么新来的线程会与等待队列中头结点的下一个线程(正在被唤醒的线程)一同争抢锁
      • 当前等待队列中头结点的下一个线程有可能争抢失败继续进入等待队列的最尾部继续等待唤醒,有可能导致饥饿现象——体现了非公平
  • addWaiter():将当前线程封装成一个节点Node加入CLH等待队列

    1. 将当前的线程包装成一个节点Node,此时Node的属性为
      • Thread:当前线程
      • mode:Node.EXCLUSIVE(独占模式)
      • waitStatus:0
      • prev/next:null
    2. CLH等待队列的尾节点赋值pred——一开始肯定为null,后面为tail指向的节点
    3. 判断尾结点是否为null——一开始肯定为null,不进入if块,执行下面的enq(node)入队方法
      1. 不为null的话,将当前节点(也就是线程的节点)的prev指针指向尾节点
      2. 使用CAS将CLH等待队列的尾节点从尾节点指向当前节点(线程节点)
      3. 将尾节点的next指针指向当前节点(线程节点)
      4. 返回当前节点(线程节点)——此时当前节点(线程节点)的属性
        • Thread:当前线程
        • mode:Node.EXCLUSIVE(独占模式)
        • waitStatus:0
        • prev:尾节点
        • next:null
    4. 如果尾节点为null的话,进入enq(node)入队方法:enq(node)方法是一个自旋锁for(;;)
      1. CLH等待队列的尾节点赋值t
      2. 如果当前尾结点为null的话,就要进行一些初始化操作——一开始肯定为null
        • 使用CAS创建一个哨兵节点(空节点),并且将CLH等待队列的头结点与尾结点都指向该哨兵节点
        • 结束f块继续进入自旋
      3. 如果当前尾结点不为null的话——初始化操作之后的尾结点就不为null了
        1. 将当前节点当前节点(线程节点)的prev指向尾节点
        2. 使用CAS将CLH等待队列的尾节点从尾节点指向当前节点(线程节点)
        3. 将尾节点的next指针指向当前节点(线程节点)
    5. 返回尾结点

    注:

    • 第一个进入CLH等待队列的线程执行路线:1 ==》 2 ==》 4 ==》 5
    • 后面进入CLH等待队列的线程执行路线:1 ==》 2 ==》 3
  • acquireQueued(final Node node, int arg):将当前节点的前驱节点的waitStatus状态修改为SINGAL(-1)——acquireQueued)方法也是一个自旋锁for(;;)

    • 设置一个失败的标志变量failed为true,目的是为了下面finally块当中线程的取消做准备

    • try块

      1. 设置一个中断的标志变量interrupted为false

      2. 自旋:

        1. 获取当前节点(线程节点)的前驱节点

        2. 如果当前节点(线程节点)的前驱节点为头结点并且当前节点(线程节点)试图获取锁成功——当前节点(线程节点)依旧没有放弃获取锁(或者被唤醒之后试图获取锁),注意此时是头结点的下一个线程结点

          1. 将当前节点(线程节点)设置为CLH等待队列的头结点——此时的当前节点(线程节点)已经抢到了锁
          2. 将前哨兵节点的next指向null——此时的前哨兵节点就没有任何的引用指向,慢慢地就会被GC回收掉,此时的当前节点(线程节点)的就是最新的哨兵节点
          3. 将失败的标志变量failed修改为false
          4. 返回中断的标志变量interrupted为false
        3. 判断当前节点试图获取锁失败之后是否应该被park挂起shouldParkAfterFailedAcquire(p, node)方法——别忘了acquireQueued方法是一个自旋方法,即shouldParkAfterFailedAcquire(p, node)方法会被多次执行直至线程被挂起或者抢到锁

          1. 获取前驱节点的waitStatus
          2. 如果前驱节点的waitStatus是SINGAL(-1)——负责唤醒它的下一个线程,返回true
          3. 如果前驱节点的waitStatus大于0,即CANCELLED(1)——表示前驱节点要被取消了
            1. 使用自旋锁的方式当前节点的prev节点修改为前驱节点的prev
            2. 前驱节点的前驱节点的next指向当前节点
            3. 返回false
          4. 否则,使用CAS将前驱节点的waitStatus从ws修改为SINGAL
          5. 返回false

          返回false之后继续进行acquireQueued方法的自旋,继续试图去抢夺锁,失败之后继续进入shouldParkAfterFailedAcquire()方法

          此时前驱节点的waitStatus为SINGAL(-1),返回true。继续执行parkCheckInterrupt()方法

        4. 执行parkCheckInterrupt()方法将线程进行park挂起

          1. 调用LockSupport的park方法将当前线程挂起,等待抢占锁的节点的unpark进行唤醒
    • finally块

      1. 如果失败的标志变量failed为true
        1. 取消当前节点

以上6个方法的相关源码:

tryAcquire():试图获取锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 非公平锁实现
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取当前CLH等待队列的状态
int c = getState();
// 如果状态为0,表示当前没有人抢占锁
if (c == 0) {
// 使用CAS抢占锁,修改状态值从0到acquires
if (compareAndSetState(0, acquires)) {
// 设置当前的锁为当前线程
setExclusiveOwnerThread(current);
return true;
}
}
// 如果当前线程是当前锁的线程——可重入锁
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
// 将当前状态设置为可重入的次数
setState(nextc);
return true;
}
return false;
}

// 公平锁实现
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 判断当前的CLH等待队列有没有线程等待
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}

addWaiter():将当前线程封装成一个节点Node加入CLH等待队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private Node addWaiter(Node mode) {
// 将当前线程封装成一个Node节点
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// 将CLH等待队列的尾节点赋值给pred
Node pred = tail;
// 如果当前CLH尾节点不为null
if (pred != null) {
// 将当前节点的前节点prev指向CLH队列的尾节点
node.prev = pred;
// 使用CAS将当前CLH尾节点从pred设置为当前节点
if (compareAndSetTail(pred, node)) {
// pred的next指向当前节点
pred.next = node;
// 返回当前节点
return node;
}
}
// 构建CLH等待队列,将当前节点加入CLH等待队列
enq(node);
// 返回当前节点
return node;
}

enq():构建CLH等待队列,将当前节点加入CLH等待队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private Node enq(final Node node) {
// 自旋锁
for (;;) {
// 将CLH等待队列的尾节点赋值给t
Node t = tail;
// 如果当前CLH等待队列的尾节点为null,进行初始化
if (t == null) { // Must initialize
// 使用CAS将CLH等待队列的头节点指向一个哨兵节点——空节点
if (compareAndSetHead(new Node()))
// 将CLH等待队列的头节点指向尾节点
tail = head;
} else { // 当前CLH等待队列的尾节点不为null
// 将当前节点的prev指向尾节点
node.prev = t;
// 使用CAS将当前CLH等待队列的尾节点从t指向当前节点
if (compareAndSetTail(t, node)) {
// 将t的next节点指向当前节点
t.next = node;
// 返回CLH等待队列的尾节点
return t;
}
}
}
}

acquireQueued(final Node node, int arg):将当前节点的前驱节点的waitStatus状态修改为SINGAL(-1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
final boolean acquireQueued(final Node node, int arg) {
// 设置一个失败的标志变量failed为true,目的是为了下面finally块当中线程的取消做准备
boolean failed = true;
try {
// 设置一个中断的标志变量interrupted为false
boolean interrupted = false;
// 自旋
for (;;) {
// 获取当前节点的前驱节点
final Node p = node.predecessor();
// 如果前驱节点为头节点,并且当前节点抢夺锁成功
if (p == head && tryAcquire(arg)) {
// 将当前节点设置为CLH等待队列的头节点
setHead(node);
// 将前驱节点(此时为头节点,即哨兵节点)的next指向null
p.next = null; // help GC,此时的哨兵节点就没有任何的引用指向,慢慢地就会被GC回收掉。而当前节点就变成了新的哨兵节点
failed = false;
return interrupted;
}
// 当前节点在获取锁失败之后是否应该挂起
if (shouldParkAfterFailedAcquire(p, node) &&
// 挂起当前节点线程
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
// 取消当前线程
cancelAcquire(node);
}
}

shouldParkAfterFailedAcquire(p, node):判断当前节点在获取锁失败之后是否应该挂起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱节点的waitStatus
int ws = pred.waitStatus;
// 如果前驱节点的waitStatus为SNGAL
if (ws == Node.SIGNAL)
// 返回true,执行parkAndCheckInterrupt()挂起线程
return true;
// 如果前驱节点的waitStatus大于0,即CANCELLED取消当前线程
if (ws > 0) {
// 自旋将当前节点的prev节点指向取消节点的前一个节点
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
// 取消节点的前一个节点的next指向当前节点
pred.next = node;
} else {
// CAS将前驱节点的waitStatus从ws设置为SINGAL(-1)
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
// 返回false,进行执行shouldParkAfterFailedAcquire()方法
return false;
}

parkAndCheckInterrupt():挂起线程

1
2
3
4
5
private final boolean parkAndCheckInterrupt() {
// 调用LockSupport的park方法将线程挂起
LockSupport.park(this);
return Thread.interrupted();
}

流程图:

image-20210121204517530

image-20210121204530559

image-20210121212502988

image-20211006234534626

2、解锁

ReentrantLock调用unLock进行解锁,unlock() 方法底层调用了 sync.release(1) 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public final boolean release(int arg) {
// 试图释放锁,成功
if (tryRelease(arg)) {
// 将CLH等待队列的头节点赋值给h
Node h = head;
// 如果不为null并且哨兵节点的waitStatus不为0
if (h != null && h.waitStatus != 0)
// 唤醒CLH等待队列哨兵节点之后的第一个线程节点
unparkSuccessor(h);
return true;
}
return false;
}

以上主要是两个方法:

  • tryRelease():试图释放锁
  • unparkSuccessor():唤醒CLH等待队列哨兵节点之后的第一个线程节点

tryRelease():试图释放锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected final boolean tryRelease(int releases) {
// 将当前的状态减去释放的锁得到c
int c = getState() - releases;
// 判断当前释放锁的线程是否是占用锁的线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
// 释放是否成功的标志变量
boolean free = false;
// 如果c等于0,代表锁应经释放结束
if (c == 0) {
free = true;
// 将锁的占用情况设置为null,即没有线程占用该锁
setExclusiveOwnerThread(null);
}
// 将c设置为当前的状态
setState(c);
// 返回锁是否释放成功
return free;
}

unparkSuccessor():唤醒CLH等待队列哨兵节点之后的第一个线程节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void unparkSuccessor(Node node) {
// 获取当前节点(此时为头节点)的waitStatus
int ws = node.waitStatus;
// 如果当前节点(此时为头节点)的waitStatus小于0
if (ws < 0)
// 使用CAS将当前节点的waitStatus设置为0
compareAndSetWaitStatus(node, ws, 0);

// 获取当前节点(头节点)的下一个结点(即将被唤醒的节点)
Node s = node.next;
// 如果s为null或者s的waitStatus为0,即CANCELLED取消状态
if (s == null || s.waitStatus > 0) {
// 将s赋值为null
s = null;
// 从CLH等等队列的尾到头进行遍历,查找符合waitStatus小于等于0的节点,将它赋值给s
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 如果s不为null
// 如果当前节点(头节点)的下一个结点不为null并且waitStatus不为CANCELLED的话
if (s != null)
// 调用LockSupport的unpark对s进行唤醒
LockSupport.unpark(s.thread);
}

流程图:

image-20211007000856860

image-20211007001005389

image-20211007001033334

3、AQS 的面试考点

第一个考点:我相信你应该看过源码了,那么AQS里面有个变量叫State,它的值有几种?

:3个状态:没占用是0,占用了是1,大于1是可重入锁

第二个考点:如果锁正在被占用,AB两个线程进来了以后,请问这个时候CLH等待队列当中总共有多少个Node节点?

:答案是3个,分别是哨兵节点、nodeA、nodeB

第三个考点:在占有锁的线程释放锁的过程中,此时CLH等待队列中的头节点的下一个节点被取消了,此时占有锁的线程唤醒的是CLH队列当中的哪一个挂起线程?

答:唤醒的是CLH等待队列当中从尾到头数的第一个符合唤醒条件的线程。唤醒条件:

  • 线程不为空
  • 线程的waitStatus小于等于0

JVM

1、JVM垃圾回收机制,GC发生在JVM哪部分,有几种GC,它们的算法是什么

GC发生在JVM哪部分?有几种GC?

  • GC发生在堆与方法区中
  • 次数上频繁收集Young区(新生代)——Minor GC
  • 次数上较少收集Old区(老年代)——Major GC
  • 基本不动Perm方法区

关于 Full GC 与 major GC 的区别

  • 部分收集(Partial GC):指目标不是完整收集整个java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
      • 注意:”Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,需按上下文区分到底是指老年代的收集还是整堆收集
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集

垃圾收集的算法是什么

  1. 引用计数法
    • 计数该对象有多少个引用,如果计数的值不为0,则代表该对象存在引用,不对它进行垃圾回收
    • 这种方法存在循环引用问题,JVM没有采用该方法
  2. 复制算法(Copying)
    • 该算法常在JVM回收新生代Young GC使用
    • 从跟集合(GC Root)开始,通过可达性分析算法Tracing从From中找到存活对象,拷贝到To区中;
    • 之后From、To区交换身份下次内存分配依旧是从From区开始
    • 优点:
      • 没有标记和清除的过程,效率高
      • 没有内存碎片,可以利用bump-the-pointer(碰撞指针)实现快速分配内存
    • 缺点:
      • 需要双倍的空间
  3. 标记清除算法(Mark-Sweep)
    • 标记(Mark):
      • 从根集合(GC Root)开始扫描,对存活的对象进行标记
    • 清除(Sweep):
      • 扫描整个内存空间,回收未被标记的对象,使用free-list记录可用区域
    • 优点:
      • 不需要额外的空间
    • 缺点:
      • 两次扫描,耗时严重
      • 会产生内存碎片
  4. 标记压缩算法(Mark-Compact):
    • 标记(Mark):
      • 从根集合(GC Root)开始扫描,对存活的对象进行标记
    • 压缩(Compact):
      • 再次扫描,并往一端滑动存活对象
      • 在整理压缩阶段,不在对标记的对象做回收,而是通过所有存活对象都向一端移动,然后直接消除边界以外的内存
    • 优点:
      • 没有内存碎片,可以利用bump-the-pointer(碰撞指针)实现快速分配内存
    • 缺点:
      • 需要移动对象的成本
  5. 标记清除压缩算法(Mark-Sweep-Compact):
    • 老年代一般都是使用由标记清除或者是标记清除与标记整理的混合实现进行垃圾回收
    • 就是将以上的标记清除算法和标记压缩算法混合实现

2、JVM内存结构

image-20210929154440600

3、关于类加载子系统常问的问题

1、类加载器有哪些?

  1. JVM支持两种类型的类加载器,分别为引导类加载器( Bootstrap ClassLoader)**和自定义类加载器(User-Defined ClassLoader)** 。

    1. 引导类加载器( Bootstrap ClassLoader):

      • 本身不是使用java语言编写,而是使用C与C++进行编写
    2. 自定义类加载器(User-Defined ClassLoader)

      • 使用java语言编写

      • 派生于抽象类ClassLoader。所以扩展类加载器(Extinction Class Loader)与系统类加载器(System Class Loader)都属于自定义类加载器

        其中sun.misc.Launcher它是一个java虚拟机的入口应用

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:

image-20210929154740187

这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。

对于引导类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)与系统类加载器(System Class Loader)三者的关系:

  • 系统类加载器(System Class Loader)的上层就是扩展类加载器(Extension Class Loader):对于用户自定义类来说:默认使用系统类加载器进行加载
  • 扩展类加载器(Extension Class Loader)的上层是引导类加载器(Bootstrap Class Loader)
  • 引导类加载器(Bootstrap Class Loader)是最高层的类加载器:Java的核心类库都是使用引导类加载器进行加载的。并且我们获取不到引导类加载器。因为引导类加载器并不是所以java语言进行编写的。

2、双亲委派机制

3、沙箱安全机制

4、GC Roots的作用域

1、什么是垃圾

  • 内存中已经不再被使用到的空间就是垃圾
  • 即没有被引用的对象

2、要进行垃圾回收,如何判断一个对象是否可以被回收?——两种算法

1、引用计数法

Java中,引用和对象是有关联的。如果要操作对象则必须引用进行。

因此,简单的办法是通过引用计数来判断一个对象是否可以回收。简单的说,给对象中添加一个引用计数,每当有一个引用失效时,计数器值减1.

任何时刻计数器值为0的对象就是不可能再被利用的,那么这个对象就是可回收对象。

那么为什么主流的Java虚拟机里面都没有选择这种算法呢?主要的原因是它很难解决对象之间==相互循环引用==的问题。

2、枚举根节点做可达性分析(根搜索路径算法)

为了解决引用计数法的循环引用问题,Java使用了可达性分析的方法。

所谓“GC Roots”或者tracing GC的“根集合”就是==一组==必须活跃的引用

基本思路就是通过一系列名为“GC Roots”的对象作为起点,从这个被称为GC Roots的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象不可用。即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可达性的)对象就被判定为存活,没有被遍历到的就被判断为死亡。

3、Java中可以作为GC Roots的对象

  • 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  • 方法区中的类静态属性引用的对象。
    • 比如:Java类的引用类型静态变量
  • 方法区中常量引用的对象
    • 比如:字符串常量池(String Table)里的引用
  • 本地方法栈中JNI(Native方法)引用的对象。
  • 所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用。
  • 反映java虛拟机内部情况的JMXBeanJVMTI中注册的回调本地代码缓存等。

5、你说你做过JVM调优和参数配置,请问如何查看JVM系统默认值

1、JVM的参数类型

  1. 标配参数
    • -version
    • -help
    • java -showversion
  2. x参数(了解)
    • -Xint:解释执行
    • -Xcomp:第一次使用就编译成本地代码
    • -Xmixed:混合模式
  3. xx参数(重点)
    • Boolean类型
      • 公式:-XX:+或者-某个属性值
        • +表示开启
        • -表示关闭
      • 是否打印GC收集细节
        • -XX:-PrintGCDetails:不打印GC收集细节
        • -XX:+PrintGCDetails:打印GC收集细节
      • 是否使用串行垃圾回收器
        • -XX:-UseSerialGC:不使用串行垃圾回收器
        • -XX:+UseSerialGC:使用串行垃圾回收器
    • KV设值类型
      • 公式:-XX:属性key=属性值value
        • -XX:MetaspaceSize=128m
        • -XX:MaxTenuringThreadhold=15

jinfo举例,如何查看当前运行程序的配置:

先使用jps查看需要的java进程的进程id,然后使用jinfo查看当前运行程序的配置

image-20210929160007999

2、-Xms和-Xmx是属于哪一种参数类型?

  • -Xms:等价于-XX:InitialHeapSize——用来设置初始堆的大小
  • -Xmx:等价于-XX:MaxHeapSize——用来设置最大的对的大小

因此这两个参数属于xx参数的类型

3、怎么不使用VM参数,使用java代码的方式查看当前进程的内存总量等等信息

1
2
3
4
5
// 查看当前java虚拟机中的内存总量
long totalMemory = Runtime.getRuntime().totalMemory();

// 查看当前java虚拟机试图使用的最大内存量
long maxMemory = Runtime.getRuntime().maxMemory();

4、盘点家底查看JVM默认值

1、查看当前java虚拟机的初始默认值

-XX:+PrintFlagsInitial

公式:

  • java -XX:+PrintFlagsInitial -version
  • java -XX:+PrintFlagsInitial
2、查看当前java虚拟机修改更新之后的值

-XX:+PrintFlagsFinal

公式:

  • java -XX:+PrintFlagsFinal -version
  • java -XX:+PrintFlags
3、查看在VM当中设置的参数值

-XX:+PrintCommandLineFlags

公式:

  • java -XX:+PrintCommandLineFlags -version

对于上面命令查看到的默认值的相关说明

  • 如果当前默认值是true或者false,表示当前的属性是Boolean类型
  • 如果当前默认值是具体的值,表示当前的属性是KV设值类型
  • =表示当前值没有被修改更新过
  • :=表示当前默认值因虚拟机启动时进行了修改或者人为修改更新过

6、你平时工作用过的JVM常用基本配置参数有哪些?

1、常用参数

1、-Xms
  • 初始大小内存,默认为物理内存的1/64
  • 等价于-XX:InitialHeapSize——初始化堆的大小
2、-Xmx
  • 最大分配内存,默认为物理内存的1/4
  • 等价于-XX:MaxHeapSize——最大堆内存大小
3、-Xss
  • 设置单个线程栈的大小,一般默认为512k~1024k
  • 等价于-XX:ThreadStackSize——当前线程栈的大小

如果使用jinfo去查看-Xss的初始默认值,发现:

image-20210929161910910

-XX:ThreadStackSize的默认值是0,而不是512k或者1024k?

  • 这里的0代表的是JVM已经设置好的初始值

这个初始值在不同的平台有不同的取值:

image-20210929162641205

在64位的Linux系统一般为1024KB

在Windows系统的话则取决于当前的虚拟内存

4、-Xmn
  • 设置年轻代的大小
5、-XX:MetaspaceSize
  • 设置元空间大小

    • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:**==元空间并不在虚拟机中,使用的是本地内存==**
    • 因此,默认情况下,元空间的大小受本地内存限制。类的元数据放入本地内存,字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不在由MaxPermSize控制,而由系统的实际可用空间来控制。
  • 元空间并不在虚拟机中,使用的是本地内存。本地内存的空间一般比虚拟机大,但是为什么在本地空间充足的情况下,依旧会出现元空间的OOM?

    • 使用jinfo去查看MetaspaceSize的默认初始值时,发现:

      image-20210929163807231

    • 元空间的大小并不是如我们所想那样是本地内存的大小,这个初始值的大小与当前虚拟机的核数有关

    • 所以在生产环境下,会适当的调高元空间的默认初始值。使其不会轻易出现OOM。

    • 经典的VM参数设置:(这里指的是经常使用的,具体还是的由具体的业务决定)

      1
      -Xms128m -Xmx4096m -Xss1024k -XX:MetaspaceSize=512m -XX:+PrintCommandLineFlags  -XX:+PrintGCDetails
6、-XX:PrintGCDetails
  • 输出详细的GC收集日志信息
1、MinorGC(或 young GC 或 YGC)日志

image-20210929164652331

image-20210929164702060

2、Full GC 日志

image-20210929164525230

image-20210929164531215

7、-XX:SurvivorRatio
  • 设置新生代中Eden和S0/S1空间的比例
  • 默认为:-XX:SurvivorRatio=8——即Eden:S0:S1 = 8:1:1
  • 假如设置-XX:SurvivorRatio=4——即Eden:S0:S1 = 4:1:1
  • SurvivorRatio值就是设置Eden区的比例占多少,S0/S1相同
8、-XX:NewRatio
  • 配置年轻代与老年代在堆结构的占比
  • 默认为:-XX:NewRatio=2——新生代占1,老年代占2。年轻代占整一个堆的1/3
  • 假如设置-XX:NewRatio=4——新生代占1,老年代占4。年轻代占整一个堆的1/5
  • NewRatio值就是设置老年代的占比,剩下的1给新生代
9、-XX:MaxTenuringThreshold
  • 设置垃圾最大年龄

使用jinfo查看默认进入老年代的年龄

image-20210929165529641

-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。

  • 如果设置为0的话,则年轻代对象不经过Survivor区,直接进入老年代。对于老年代比较多的应用,可以提高效率。

  • 如果将此值设置为一个较大值,这年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代即被回收的概论

    • 在JDk1.8之前,一般会将这个值设置为31,使更多的对象在年轻代就被回收掉,不让对象轻易进入老年代。以此来减少Full GC的次数。

    • 这种方法在JDK1.8之后就不能实现了。因为在JDK1.8的环境下,如果设置的设置大于15的话,会报一个Error:Could not create the Java Virtual Machine

      image-20210929170211758

7、强引用、软引用、弱引用、虚引用分别是什么?

1、整体架构

image-20210930022132677

2、强引用(默认支持模式)——StrongReference

当内存不足,JVM开始垃圾回收,对于强引用的对象,就算是出现了OOM也不会对该对象进行回收,死都不收。

强引用是最常见的普通对象引用,只要还有引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰到这种对象。在Java中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾机制回收的,即使该对象以后永远都不能被用到,JVM也不会回收。因此强引用是造成Java内存泄露的主要原因之一。

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显示地将相应(强)引用赋值为null,一般认为就是可以被垃圾收集的了(当然具体回收时还要看垃圾收集策略)。

3、软引用——SoftReference

软引用是一种相对强化了一些的引用,需要用java.lang.ref.SoftReference类来实现,可以让对象豁免一次垃圾收集。

对于只有软引用的对象来说:

  1. 当系统内存充足时,它不会被回收
  2. 当系统内存不足时,它会被回收

软引用通常用在对内存敏感的程序中,比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收。

4、弱引用——WeakReference

弱引用需要用java.lang.ref.WeakReference类来实现,它比软引用的生存期更短。

对于软引用对象来说,只要垃圾回收机制一运行,不管JVM的内存空间是否足够,都会回收该对象占用的内存

5、软引用和弱引用的使用场景

假如有一个应用需要读取大量的本地图片:

  1. 如果每次读取图片都从硬盘读取则会严重影响性能。
  2. 如果一次性全部加载到内存中有可能造成内存泄露。

此时使用软引用可以解决这个问题。

设计思路:用一个HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免OOM的问题。

1
Map<String,SoftReference<Bitmap>> imageCache = new HashMap<String,SoftReference<Bitmap>>();

你知道所引用的话,能谈谈weakHashMap吗?

weakHashMap的key都是弱引用对象,如果发生了垃圾回收并且在finally块中不能复活的话,被回收的key会被移除出Map。

image-20210930022952843

6、虚引用——PhantomReference

虚引用需要java.lang.refPhantonReference类来实现。

顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。

如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和队列(ReferenceQueue)联合使用

虚引用的主要作用是跟踪对象被垃圾回收的状态。仅仅是提供了一种确保对象被finalize以后,做某些事情的机制

PhantomReference的get方法总是返回null,因此无法访问对应的引用对象。其意义在于说明一个对象那个已经进入finalization阶段,可以被gc回收,用来实现比finalization机制更灵活的回收操作。

换句话说,设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步的处理

Java技术允许使用finalize()方法在垃圾收集器将对象从内存中清楚之前做必要的清理工作。

引用队列

在对象被回收前需要被引用队列保存一下。

image-20210930023036859

7、GCRoots和四大引用小总结

java提供了4种引用类型,在垃圾回收的时候,都有自己各自的特点。

  • ReferenceQueue是用来配合引用工作的,没有ReferenceQueue一样可以运行。
  • 创建引用的时候可以指定关联的队列,当GC释放对象内存的时候,会将引用加入到引用队列,如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动
  • 这相当于是一种通知机制。
  • 当关联的引用队列中有数据的时候,意味着引用指向的堆内存中的对象被回收。通过这种方式,JVM允许我们在对象被销毁后,做一些我们自己想做的事情。

image-20210930024037129

8、请谈谈你对OOM的认识?

1、java.lang.StackOverFlowError

java.lang.StackOverFlowError——栈溢出异常

一般的线程栈的大小也只有512Kb或者1024Kb,当递归的深度太深或者出现不合理的递归时,容易出现以上异常

这里说的”异常”并不是说StackOverFlowError是一个异常,StackOverFlowError不是异常而是错误。只是平时大家习以为常说是异常,其实StackOverFlowError是一个Error错误。

2、java.lang.OutOfMemoryError:Java heap space

java.lang.OutOfMemoryError:Java heap space——堆空间溢出异常

若当前的堆空间不满足创建对象的大小,就会出现Java heap space——堆空间溢出异常

同理,Java heap space也不是异常,而是一个Error错误。

3、java.lang.OutOfMemoryError:GC overhead limit exceeded——GC时间过长异常

GC回收时间长时会抛出OutOfMemoryError。过长的定义是,超过98%的时间用来做GC并且回收了不到2%的堆内存,连续多次GC都只回收了不到2%的极端情况下才会抛出。

假设不抛出GC overhead limit错误会发生什么情况呢?

  • 那就是GC清理的这么点内存很快会再次填满,迫使GC再次执行,这样就形成恶性循环,CPU使用率一直是100%,而GC缺没有任何成果。

同理,GC overhead limit exceeded也不是异常,而是一个Error错误。

4、java.lang.OutOfMemoryError:Direct buffer memory——本地内存溢出异常

导致原因:

  • 写NIO程序经常使用ByteBuffer来读取或者写入数据,这是一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。
  • 这样能在一些场景中显著提高性能,因为避免了在java堆和Native堆中来回复制数据。
  • ByteBuffer.allocate(capability)第一种方式是分配JVM堆内存,属于GC管辖范围,由于需要拷贝所以速度相对较慢。
  • ByteBuffer.allocateDirect(capability)第一种方式是分配OS本地内存,不属于GC管辖范围,由于不需要内存拷贝,所以速度相对较快。
  • 但如果不断分配内存,堆内存很少使用,那么JVM就不需要执行GC,DirectByteBuffer对象们就不会被回收,这时候堆内存充足,但本地内存可能已经使用光了,再次尝试分配本地内存就会出现OutOfMemoryError,那程序就直接崩溃了。

同理,也不是异常,而是一个Error错误。

5、java.langOutOfMemoryError:Metaspace

使用java -XX:+PrintFlagsInitial命令查看本机的初始化参数:-XX:Metaspacesize为218103768(大约为20.8M)

Java 8及以后的版本使用Metaspace来代替永久代

Metaspace是方法区在HotSpot中的实现,它与永久代最大的区别在于:Metaspace并不在虚拟机内存中,而是使用本地内存,也就是说在Java8中,class metadata(the virtual machines internal presentation of Java class),被存储在叫做Metaspace的native memory

Metaspace存放了以下信息:

  • 虚拟机加载的类信息
  • 常量池
  • 静态变量
  • 即时编译后的代码

6、java.lang.OutOfMemoryError:unable to create new native thread——无法创建本地线程异常

高并发请求服务器时,经常出现如下异常:java.lang.OutOfMemoryError:unbale to create new native thread

准确的说该native thread异常与对应的平台有关

导致原因:

  1. 应用创建了太多线程,一个应用进程创建多个线程,超过系统承载极限。
  2. 服务器并不允许应用程序创建那么多线程,linux系统默认允许单个进程可以创建的线程数是1024个,如果应用创建超过这个数量,就会报java.lang.OutOfMemoryError:unable to create new native thread

解决办法:

  1. 想办法降低应用程序创建线程的数量,分析应用是否真的需要创建那么多线程,如果不是,改代码将线程数降到最低
  2. 对于有的应用,确实需要创建多个线程,远超过linux系统默认的1024个线程的限制,可以通过修改linux服务器配置,扩大linux默认限制
1、非root用户登录linux系统测试

查看最大线程数量命令:

1
ulimit -u
2、服务器级别调参调优

扩大服务器线程数:

1
vim/etc/security/limits.d/90-nproc.conf

9、GC回收算法和垃圾收集器的关系?分别是什么请你谈谈

1、GC回收算法

GC算法是内存回收的方法论,垃圾收集器就是算法落地实现。常见的GC算法有:

  • 引用计数
  • 复制算法
  • 标记清除算法
  • 标记整理算法(标记压缩算法)

因为目前为止还没有完美的收集器出现,更加没有万能的收集器,知识针对具体应用最合适的收集器,进行分代收集。

2、4种主要垃圾收集器

image-20211003192451779

  • 串行垃圾回收器(Serial):它为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程。所以不适合服务器环境。
  • 并行垃圾回收器(Parallel):多个垃圾收集线程并行工作,此时用户线程是暂停的,适用于科学计算/大数据处理平台处理等弱交互场景。
  • 并发垃圾回收器(CMS):用户线程和垃圾收集线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程,使用对响应时间有要求的场景。
  • GI垃圾回收器:G1垃圾回收器将堆内存分割成不同的区域然后并发的对其进行垃圾回收。

3、串行(Serial) VS 并行(Parallel)

image-20211003193243584

注:不管是串行还是并行,都存在STW问题。只是并行的STW时间比串行的要短很多。

4、STW(Stop-the-World) VS 并发(Concurrent)

image-20211003193407527

并发使得在进行垃圾收集时也可以执行工作线程。

10、怎么查看服务器默认的垃圾收集器是哪个?生产上你是如何配置垃圾收集器的?谈谈你的理解?

1、怎么查看默认的垃圾收集器是哪个?

使用JVM参数:

1
java -XX:+PrintCommandLineFlags -version

2、默认的垃圾收集器有哪些?

Java的GC回收的类型主要有以下七种

  • UseSerialGC:串行垃圾回收器——新生代
  • UseParallelGC:并行垃圾回收器——新生代
  • UseConcMarkSweepGC(CMS):并发垃圾回收器——老年代
  • UseParNewGC——新生代并行垃圾回收器
  • UseParallelOldGC——老年代并行垃圾回收器
  • UseG1GC——G1垃圾回收器——整堆

相关的源码:

image-20211003193748985

垃圾收集器的组合关系

image-20210429023609239

  1. 两个收集器间有连线,表明它们可以搭配使用:

    Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中Serial Old作为CMS出现”Concurrent Mode Failure” 失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即:移除。

  4. (绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)

  5. (青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)

Java不同版本的默认垃圾回收器组合

版本 Young Old
JDK6 PSScavenge(Parallel Scavenge) PSMarkSweep (Parallel Scavenge)
JDK7 PSScavenge(Parallel Scavenge) PSParallelCompact(Parallel Old)
JDK8 PSScavenge(Parallel Scavenge) PSParallelCompact(Parallel Old)
JDK9 G1 G1

3、垃圾收集器详解

1、部分参数预先说明
  • DefNew——Default New Generation
  • Tenured——Old
  • ParNew——Parallel New Generation
  • PSYoungGen——Parallel Scavenge
  • ParOldGen——Parallel Old Generation
2、Server/Client模式分别是什么意思
  1. 使用范围:只需要掌握Server模式即可,Client模式基本不会用。
  2. 操作系统:
    • 32位操作系统,不论硬件如何都默认使用Client的JVM模式。
    • 32位操作系统,2G内存同时有2个CPU以上用Server模式,低于该配置还是Client模式。
    • 64位only server模式
3、新生代
1、串行GC(Serial)/(Serial Copying)

串行收集器:Serial收集器。一个单线程的收集器,在进行垃圾收集时候,必须暂停其他所有的工作线程知道它收集结束。

image-20211003195511732

串行收集器是最古老,最稳定以及效率高的收集器,只使用一个线程去回收但其在进行垃圾收集过程中可能会产生较长的停顿(Stor-the-World状态)。虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个CPU环境来说,没有线程交互的开销可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在Client模式下默认的新生代垃圾收集器

对应JVM参数是:**-XX:+UseSerialGC**

开启后会使用:Serial(Young区用)+ Serial Old(Old区用)的收集器组合。

表示:新生代、老年代都会使用串行回收收集器,新生代使用复制算法老年代使用标记-整理算法

2、并行GC(ParNew)

ParNew(并行)收集器。一句话:使用多线程进行垃圾回收,在垃圾收集时,会Stop-the-World暂停其他所有的工作线程直到它收集结束,时间较串行垃圾收集器来说要短。

image-20211003220332605

ParNew收集器其实就是Serial收集器新生代的并行多线程版本,最常见的应用场景是配合老年代的CMS GC工作,其余的行为和Serial收集器完全一样,ParNew垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。它是很多java虚拟机运行在Server模式下新生代的默认垃圾收集器。

常用对应JVM参数:**-XX:+UseParNewGC 启用ParNew收集器,只影响新生代的收集,不影响老年代。**

开启上述参数后,会使用:ParNew(Young区用)+ Serial Old的收集器组合,新生代使用复制算法,老年代采用标记-整理算法。

但是,ParNew + Tenured这样的搭配,java8已经不在推荐

Java HotSpot(TM) 64-Bit Server VM warning:

Using the ParNew young collector with the Serial old collecto is deprecated and will likely be removed in a future release

备注:关于新生代并行线程数的设置:

1
-XX:ParallelGCThreads

限制线程数量,默认开启和CPU数目相同的线程数。

3、并行回收GC(Parallel)/(Parallel Scavenge)

Parallel Scavenge收集器类似ParNew也是一个新生代垃圾收集器,使用复制算法,也是一个并行的多线程的垃圾收集器,俗称吞吐量有限收集器。一句话:串行收集器在新生代和老年代的并行化

image-20211003220751051

它重点关注的是:

  • 可控制的吞吐量(Thoughput=运行用户代码时间/(运行用户代码时间+垃圾收集时间),也即比如程序运行100分钟,垃圾收集时间1分钟,吞吐量就是99%)。
    • 高吞吐量意味着高效利用CPU的时间,它多用于在后台运算而不需要太多交互的任务。
  • 自适应调节策略也是ParallelScavenge收集器与ParNew收集器的一个重要区别
    • 自使用调节策略:虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间(-XX:MaxGCPauseMillis)或最大的吞吐量。

常用JVM参数:**-XX:+UseParallelGC或-XX:+UseParallelOldGC(可互相激活)**使用Parallel Scanvenge收集器

备注:关于新生代与老年代并行线程数的设置:

1
-XX:ParallelGCThreads=[数字N]

表示启动多少个GC线程:

  • 当CPU > 8:N = 5/8
  • 当CPU < 8:N = CPU实际个数
4、老年代
1、串行GC(Serial Old)/(Serial MSC)

Serial Old是Serial垃圾收集器老年代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在Client默认的java虚拟机默认的老年代垃圾收集器。

在Server模式下,主要有两个用途(了解,版本已经到8及以后):

  1. 在JDK1.5之前版本中与新生代的Parallel Scavenge收集器搭配使用。 (Parallel Scavenge + Serial Old )
  2. 作为老年代版中使用CMS收集器的后备垃圾收集方案。

注:理论了解即可,实际中java8已经被优化掉了,没有了。

如果在java8的VM配置相关参数启动Serial Old垃圾收集器,会发生:

image-20211003221514373

2、并行GC(Parallel Old)/(Parallel MSC)

Parallel Old收集器是Parallel Scavenge的老年代版本,使用多线程的标记-整理算法,Parallel Old收集器在JDK1.6才开始提供。

JVM常用参数:**-XX:+UseParallel Old** 使用Parallel Old收集器,设置该参数后,新生代Parallel+老年代Parallel Old

3、并发标记清除GC(CMS)

CMS收集器(Concurrent Mark Sweep:并发标记清除)是一种以获取==最短回收停顿时间==为目标的收集器

适合应用在互联网站或者B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短

CMS非常适合堆内存大CPU核数多的服务器端应用,也是G1出现之前大型应用的首选收集器。

Concurrent Mark Sweep并发标记清除,并发收集低停顿,并发指的是与用户线程一起执行

开启该收集器的JVM参数:**-XX:+UseConcMarkSweepGC** 开启该参数后会自动将-XX:+UseParNewGC打开

开启该参数后,使用ParNew(Young区用)+CMS(Old区用)+Serial Old的收集器组合,Serial Old将作为CMS出错的后备收集器。

image-20211003221017872

CMS垃圾收集的过程:

  1. 初始标记(CMS initial mark)
    • 只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
  2. 并发标记(CMS concurrent mark)和用户线程一起
    • 进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。主要标记过程,标记全部对象。
  3. 重新标记(CMS remark)
    • 为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。
    • 由于并发标记时,用户线程依然运行,因此在正式清理前,再做修正。
  4. 并发清除(CMS concurrent sweep)和用户线程一起
    • 清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。基于标记结果,直接清理对象。
    • 由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS收集器的内存回收和用户线程是一起并发执行。

CMS也存在STW,发生在第一步:初始标记和第三步:重新标记。时间很短。

image-20211003221029445

image-20211003221035370

CMS的优缺点

  • 优点:
    • 并发收集低停顿
  • 缺点
    • 并发执行,对CPU资源压力大
      • 由于并发进行,CMS在收集与应用线程会同时增加对堆内存的占用,也就是说,CMS必须要在老年代堆内存用尽之前完成垃圾回收,否则CMS回收失败时,将触发担保机制,串行老年代收集器会以STW的方式进行一次GC,从而造成较大停顿时间。
    • 采用的标记清除算法会导致大量碎片
      • 标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩。CMS也提供了参数-XX:CMSFullGCsBeForeCompaction(默认0,即每次都进行内存整理)来指定多少次CMS收集之后,进行一次压缩的Full GC。

4、如何选择垃圾收集器

组合的选择:

  1. 单CPU或小内存,单机程序——使用串行垃圾回收器

    1
    -XX:+UseSerialGC
  2. 多CPU,需要最大吞吐量,如后台计算型应用——使用并行垃圾回收器

    1
    2
    3
    -XX:+UseParallelGC
    // 或(相互激活)
    -XX:+UseParallelOldGC
  3. 多CPU,追求低停顿时间,需要快速响应如互联网应用——CMS垃圾回收器

    1
    2
    -XX:+UseConcMarkSweepGC
    -XX:+ParNewGC

5、垃圾收集器总结

参数 新生代垃圾收集器 新生代算法 老年代垃圾收集器 老年代算法
-XX:+UseSerialGC SerialGC 复制 SerialOldGC 标记-整理
-XX:+UseParNewGC ParNew 复制 SerialOldGC 标记-整理
-XX:+UseParallelGC/+XX:+UsePallallelOldGC Parallel[Scavenge] 复制 Parallel Old 标记-整理
-XX:+UseConcMarkSweepGC ParNew 复制 CMS + SerialOld的收集器组合(Serial Old作为CMS出错的后备收集器) 标记-清除
-XX:+UseG1GC G1整体上采用标记-整理算法 局部是通过复制算法,不会产生内存碎片

11、G1垃圾收集器

1、以前收集器特点

  1. 年轻代和老年代是各自独立且连续的内存块
  2. 年轻代收集使用单eden+S0+S1进行复制算法
  3. 老年代收集必须扫描整个老年代区域
  4. 都是以尽可能少而快速地执行GC为设计原则。

2、G1是什么?

CMS垃圾收集器虽然减少了暂停应用程序的运行时间,但是它还是存在着内存碎片问题。于是,为了去除内存碎片问题,同时又保留CMS垃圾收集器低暂停时间的优点,JAVA7发布了一个新的垃圾收集器——G1垃圾收集器。

G1是在2012年才在jdk1.7u4中可用。oracle官方计划在jdk9中将G1变成默认的垃圾收集器以替代CMS。它是一款面向服务端应用的收集器,主要应用在多CPU和大内存服务器环境下,极大的减少垃圾收集的停顿时间,全面提升服务器的性能,逐步替换java8以前的CMS收集器。

主要改变是Eden,Survivor和Tenured等内存区域不再是连续的了,而是变成了一个个大小一样的region,每个region从1M到32M不等。一个region有可能属于Eden,Survivor或者Tenured内存区域。

G1(Garbage-First)收集器,是一款面向服务端应用的收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。另外还具有以下特性:

  • 像CMS收集器一样,能与应用程序线程并发执行。
  • 整理空间空间更快
  • 需要更多的时间来预测GC停顿时间
  • 不希望牺牲大量的吞吐性能
  • 不需要更大的Java Heap

G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
  • G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

3、G1的特点

  • G1能充分利用多CPU、多核环境硬件优势,尽量缩短STW
  • G1 整体上采用标记-整理算法局部是通过复制算法不会产生内存碎片
  • 宏观上看G1之中不再区分年轻代和老年代。**把内存划分成多个独立的子区域(Region)**,可以近似理解为一个围棋的棋盘。
  • G1收集器里面讲整个的内存区都混合在一起了,但其本身依然在小范围内要进行年轻代和老年代的区分,保留了新生代和老年代,但它们不再是物理隔离的,而是一部分Region的集合且不需要Region是连续的,也就是说依然会采用不同的GC方式来处理不同的区域。
  • G1 虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换

4、底层原理

1、Region区域化垃圾收集器

区域化内存划片Region,整体编为一系列不连续的内存区域,避免了全内存的GC操作

核心思想将整个堆内存区域分成大小相同的子区域(Region),在JVM启动时会自动设置这些子区域的大小。

在堆的使用上,G1并不要求对象的存储一定是物理上连续的只要逻辑上连续即可,每个分区也不会固定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB-32MB,且必须是2的幂),默认将整个堆划分为2048个分区。大小范围在1MB-32MB,最多能设置2048个区域。也即能够支持的最大内存为:32MB * 2048 = 65536MB = 64G内存。

G1将新生代、老年代的物理空间取消了。

最大好处是化整为零,避免全内存扫描,只需要按照区域来进行扫描即可。

image-20211004023839443

G1算法将堆划分为若干个区域(Region),它仍然属于分代收集器

这些Region的一部分包含新生代新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间

这些Region的一部分包含老年代,G1收集器通过将对象从一个区域复制到另一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有CMS内存碎片问题的存在了。

在G1中,还有一种特殊的区域,叫Humongous(巨大的)区域如果一个对象占用的空间超过了分区容量的50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。

为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC——所以说要尽量避免创建大对象。

2、回收步骤

G1收集器下的Young GC

针对Eden区进行收集,Eden区耗尽后会被触发,主要是小区域收集+形成连续的内存块,避免内存碎片

  1. Eden区的数据移动到新的Survivor区,部分数据晋升到Old区。
  2. Survivor区的数据移动到新的Survivor区,部分数据晋升到Old区。
  3. 最后Eden区收集干净了,GC结束,用户的应用程序继续执行。

image-20211004024015511

image-20211004024112912

3、4步过程
  • 初始标记:只标记GC Roots能直接关联到的对象
  • 并发标记:进行GC Roots Tracing的过程
  • 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象
  • 筛选回收:根据时间来进行价值最大化的回收。

image-20211004024222685

5、常用配置参数(了解)

开发人员仅仅声明一下参数即可

三步归纳:开始G1 + 设置最大内存 + 设置最大停顿时间

  • -XX:+UseG1GC——使用G1垃圾回收器
  • -Xmx32g——设置最大内存
  • -XX:MaxGCPauseMillis=100——设置最大停顿时间

注:-XX:MaxGCPauseMillis=n:最大GC停顿时间单位毫秒,这是个软目标,JVM将尽可能(但不保证)停顿小于这个时间

1、-XX:+UseG1GC——常用

使用G1垃圾回收器

2、-XX:G1HeapRegionSize=n——常用

设置的G1区域的大小。值是2的幂,范围是1MB到32MB。目标是根据最小的Java堆大小划分出约2048个区域。

3、-XX:MaxGCPauseMillis=n——常用

最大GC停顿时间,这是个软目标,JVM将尽可能(但不保证)停顿小于这个时间。

4、-XX:InitiatingHeapOccupancyPercent=n——默认

堆占用了多少的时候就触发GC,默认为45

5、-XX:ConcGCThreads=n——默认

并发GC使用的线程数。

6、-XX:G1ReservePercent=n——默认

设置作为空闲的预留内存百分比,以降低目标空间溢出的风险,默认值是10%

6、和CMS相比的优势

两个优势:

  1. G1不会产生内存碎片
  2. G1可以精确控制停顿。该收集器是把整个堆(新生代、老生代)划分成多个固定大小的区域,每次根据允许停顿时间去收集垃圾最多的区域。

7、在将要上线的项目中配置JVM参数

在有包的路径下,运行jar命令,公式如下:

1
2
3
4
java -server [JVM的各种参数] -jar jar/war包的名字

# eg
java -server -Xms1024M -Xmx1024M -XX:+UseG1GC -jar Hello.jar

12、生产环境服务器变慢,诊断思路和性能评估谈谈?

1、整机:top

主要关注的几个参数:

  • lad average:三个负载值,分别代表系统在1、5、15内的负载情况。
    • 如果三个负载值的平均值超过0.6或者0.7,说明当前系统的负载比较大,需要进行优化
  • %CPU:CPU占比
  • %MEM:内存占比
1
2
# 系统性能命令的精简版
uptime

2、CPU:vmstat

1、vmstat:查看CPU(包含不限于)
1
vmstat -n 2 3

image-20211004034427789

一般vmstat工具的使用是通过两个数字参数来完成的,第一个参数是采样的时间间隔数单位是秒第二个参数是采样的次数

-procs

  • r:运行等待CPU时间片的进程数,原则上1核的CPU运行队列不要超过2,整个系统的运行队列不能超过总核数的2倍,否则代表系统压力过大。
  • b:等待资源的进程数,比如正在等待磁盘I/O、网络I/O等。

-cpu

  • us用户进程消耗CPU时间百分比,us值高,用户进程小号CPU时间多,如果长期大于50%,优化程序;
  • sy内核进程消耗的CPU百分比
    • us + sy参考值为80%,如果us + sy大于80%,说明可能存在CPU不足
  • id:处于空闲的CPU百分比
  • wa:系统等待IO的CPU时间百分比
  • st:来自于一个虚拟机偷取的CPU时间的百分比
2、vmstat:查看额外

查看所有cpu核信息:

1
mpstat -P ALL 2

每个进程使用cpu的用量分解信息:

1
pidstat -u1 -p 进程编号

3、内存:free

1、应用程序可用内存数
1
2
3
free 
free -g
free -m

image-20211004035000114

经验:

  • 应用程序可用内存/系统物理内存>70%内存充足。
  • 应用程序可用内存/系统物理内存<20%内存不足,需要增加内存。
  • 20%<应用程序可用内存/系统物理内存<70%内训基本够用
2、查看额外
1
pidstat -p 进程号 -r 采样间隔秒数

4、硬盘:df

查看磁盘剩余空间数:

1
df -h

5、磁盘IO:iostat

磁盘I/O性能评估

1
iostat -xdk 2 3

image-20211004035101683

磁盘块设备发布:

  • rkB/s:每秒读取数据量kB
  • wkB/s:每秒写入数据量kB
  • svctm I/O:请求的平均等待时间,单位毫秒
  • await I/O:请求的拼接等待杰斯安,单位毫秒;值越小,性能越好
  • util:疫苗中有百分几的时间用于I/O操作。接近100%时,表示磁盘带宽跑满,需要优化程序或者增加硬盘

rkB/s、wkB/s根据系统应用不同会有不同的值,但有规律遵循:长期、超大数据读写,肯定不正常,需要优化程序读取。

svctm的值与await的值很接近,表示几乎没有I/O等待,磁盘性能好,

如果await的值远高于svctm的值,则表示I/O队列等待太长,需要优化程序或者更换磁盘。

查看额外

1
pidstat -d 采样间隔秒数 -p 进程号

6、网络IO:ifstat

默认本地没有,下载ifstat:

1
2
3
4
5
6
wget http://gael.roualland.free.fr/ifstat/ifstat-1.1.tar.gz
tar -xzvf ifstat-1.1.war.gz
cd ifstat-1.1
./configure
make
make install

查看网络IO:

image-20211004040241718

13、假设生产环境出现CPU占用过高,请谈谈你的分析思路和定位

需要结合Linux和JDK命令一块分析

案例步骤:

  1. 先用top命令找出CPU占比最高的
  2. ps -ef或者jps进一步定位,得知是一个怎么样的一个后台程序惹事
  3. 定位到具体线程或者代码
    • ps -mp pid -o THREAD,tid,time
      • -m:显示所有的线程
      • -p pid进程使用cpu的时间
      • -o该参数后是用户自定义格式
  4. 将需要的线程ID转换为16进制格式(英文小写格式)
    • printf “%x\n”有问题的线程ID
  5. jstack 进程ID | grep(16进制线程ID小写英文) -A60
    • -A60指的是打印出前60行

14、对于JDK自带的JVM监控和性能分析工具用过哪些?一般你是怎么用的?

1、概览

相关网站

2、性能监控工具

  • jps
  • jinfo
  • jmap
  • jstat
  • jstack

Elasticsearch

1、Elasticsearch 和 solr 的区别

背景:

  • 它们都是基于Lucene搜索服务器基础之上开发,一款优秀的,高性能的企业级搜索服务器。
    • 高性能是因为他们都是基于分词技术构建的倒排索引的方式进行查询
  • 开发语言:java语言开发
  • 诞生时间:
    • Solr :2004年诞生。
    • Es:2010年诞生。
  • Es 比Solr更新,因此Es的功能越强大

区别:

  1. 实时建立索引的时候,solr会产生io阻塞,而es则不会,es查询性能要高于solr。
  2. 不断动态添加数据的时候,solr的检索效率会变的低下,而es则没有什么变化。
  3. Solr利用zookeeper进行分布式管理,而es自身带有分布式系统管理功能
  4. Solr一般都要部署到web服务器上,比如tomcat。启动tomcat的时候需要配置tomcat与solr的关联。
    • Solr 的本质是一个动态web项目
  5. Solr支持更多的格式数据[xml,json,csv等],而es仅支持json文件格式。
  6. Solr是传统搜索应用的有力解决方案,但是es更适用于新兴的实时搜索应用
    • 单纯的对已有数据进行检索的时候,solr效率更好,高于es。
    • 如果是对实时建立索引的数据,而且需要不断动态添加数据,使用es会更好
    • 现在的互联网数据基本上都是不断动态变化的,因此Es运用得比solr更多
  7. Solr官网提供的功能更多,而es本身更注重于核心功能,高级功能多有第三方插件。

关于solr与Es的集群:

  • SolrCloud:集群图:

    image-20210925223828690

    image-20210925223911772

  • Elasticsearch:集群图

    image-20210925223950346


面试相关

1、单点登陆的实现

单点登录:一处登录多处使用!

  • 如:使用浏览器在网页当中登陆京东账号,打开另外有关京东的网页,发现已经是登陆的状态了

单点登陆的前提:单点登录多使用在分布式系统中

  • 也不是说普通的web项目不能使用单点登陆,只能说是没有必要

实现思路流程图:

image-20210925224505115

注意:这里的单点登陆是基于cookie的,是将token放入到cookie中的。如果浏览器禁用cookie的话,那么就无法实现单点登陆功能,甚至还会登陆失败。

2、购物车的实现

分析:

购物车:

  1. 购物车跟用户的关系?
    • 一个用户必须对应一个购物车【一个用户不管买多少商品,都会存在属于自己的购物车中。】
    • 单点登录一定在购物车之前。
  2. 跟购物车有关的操作有哪些?
    • 添加购物车
      • 用户未登录状态
        • 添加到什么地方?未登录将数据保存到什么地方?
          • Redis — 京东
          • Cookie — 自己开发项目的时候【如果浏览器禁用cookie,就只能使用Redis了】
      • 用户登录状态
        • Redis 缓存中 【读写速度快】
          • Hash :hset(key,field,value)
            • Key:user:userId:cart
            • Hset(key,skuId,value);
        • 存在数据库中【oracle,mysql】
    • 展示购物车
      • 未登录状态展示
        • 直接从cookie 中取得数据展示即可
      • 登录状态
        • 用户一旦登录:必须显示数据库【redis】+ cookie 中的购物车的数据
          • Cookie 中有三条记录
          • Redis中有五条记录
          • 真正展示的时候应该是八条记录

消息队列

1、消息队列在项目中的使用

背景:在分布式系统中是如何处理高并发的

由于在高并发的环境下,来不及同步处理用户发送的请求,则会导致请求发生阻塞。比如说,大量的insert,update之类的请求同时到达数据库MYSQL,直接导致了无数的行锁表锁的产生,甚至会导致请求堆积很多。从而触发 too many connections 错误。使用消息队列可以解决【异步通信】

  1. 异步

    image-20210925225359975

  2. 并行

    image-20210925225423420

  3. 排队(起到削峰填谷的作用)

    image-20210925225451607

消息队列在电商的使用场景:

image-20210925225658814

消息队列的弊端:

  • 消息的不确定性:使用延迟队列,轮询技术来解决该问题即可!

资料来源

不可不说的Java“锁”事

第 4 章 Spring

Spring 的循环依赖,源码详细分析 → 真的非要三级缓存吗

spring循环依赖一定要三级缓存吗?就算用来解决AOP,也需要三级缓存吗?

[TOC]

Linux

1、Linux 介绍

1、概述

  1. linux 是一个开源、免费的操作系统,其稳定性、安全性、处理多并发已经得到业界的认可,目前很多企业级的项目(c/c++/php/python/java/go)都会部署到Linux/unix 系统上。

  2. 常见的操作系统:windows、IOS、Android、MacOS、Linux、Unix

  3. Linux 吉祥物:一只叫做tux的企鹅

  4. Linus Torvalds

    • Linux 之父(最初的Linux,即看inux0.01 版源码是使用C语言写的,不到1w 行)
    • Git 创作者
    • 世界著名黑客
  5. Linux 主要的发行版:

    • Ubuntu(乌班图)**、RedHat(红帽)CentOS**、Debain[蝶变]、Fedora、SuSE、OpenSUSE

    VM与Linux的关系

2、Linux 的应用领域

1、个人桌面领域的应用

此领域是传统linux 应用薄弱的环节,近些年来随着ubuntu、fedora [fɪˈdɔ:rə] 等优秀桌面环境的兴起,linux 在个人桌面领域的占有率在逐渐的提高。

image-20210911202159975

2、服务器领域

  • linux 在服务器领域的应用是最强的。
  • linux 免费、稳定、高效等特点在这里得到了很好的体现,尤其在一些高端领域尤为广泛(c/c++/php/java/python/go)。

3、嵌入式领域

  • linux 运行稳定、对网络的良好支持性、低成本,且可以根据需要进行软件裁剪,内核最小可以达到几百KB 等特点,使其近些年来在嵌入式领域的应用得到非常大的提高
  • 主要应用:机顶盒、数字电视、网络电话、程控交换机、手机、PDA、智能家居、智能硬件等都是其应用领域。以后在物联网中应用会更加广泛。

3、Linux的目录结构

1、基本介绍

  1. linux 的文件系统是采用级层式的树状目录结构,在此结构中的最上层是根目录"/",然后在此目录下再创建其他的目录。
  2. 深刻理解linux 树状文件目录是非常重要的
  3. 记住一句经典的话:在Linux 世界里,一切皆文件

2、具体的目录结构

  • /bin[常用] (/usr/bin/usr/local/bin):是Binary 的缩写,这个目录存放着最经常使用的命令。如cdllclear等等
  • /sbin (/usr/sbin/usr/local/sbin):s 就是Super User 的意思,这里存放的是系统管理员使用的系统管理程序
  • /home[常用]:存放普通用户的主目录,在Linux 中每个用户都有一个自己的目录,一般该目录名是以用户的账号命名。
  • /root[常用]:该目录为系统管理员,也称作超级权限者的用户主目录
  • /lib系统开机所需要最基本的动态连接共享库,其作用类似于Windows 里的DLL 文件。几乎所有的应用程序都需要用到这些共享库
  • /lost+found:这个目录一般情况下是空的,当系统非法关机后,这里就存放了一些文件
    • 这个目录一般是隐藏起来的。可以通过命令:ls进行查看
  • /etc[常用]:所有的系统管理所需要的配置文件和子目录,比如安装mysql 数据库my.conf
  • /usr[常用]:这是一个非常重要的目录,用户的很多应用程序和文件都放在这个目录下,类似与windows 下的program files 目录。
  • /boot[常用]:存放的是启动Linux 时使用的一些核心文件,包括一些连接文件以及镜像文件
  • /proc[不能动]:这个目录是一个虚拟的目录,它是系统内存的映射,访问这个目录来获取系统信息
  • /srv[不能动]:service 缩写,该目录存放一些服务启动之后需要提取的数据
  • /sys[不能动]:这是linux2.6 内核的一个很大的变化。该目录下安装了2.6 内核中新出现的一个文件系统sysfs
    • 以上三个文件最后不去改动它们,因为一不小心就有可能损坏Linux,让Linux不能正常启动
  • /tmp:这个目录是用来存放一些临时文件的
  • /dev:类似于windows 的设备管理器,把所有的硬件用文件的形式存储
    • 因为在Linux上,一切皆文件。所以Linux把所有的硬件都抽象成一个文件。在这个文件的那种,你可以看到 cpu 等文件夹
  • /media[常用]:linux 系统会自动识别一些设备,例如U 盘、光驱等等,当识别后,linux 会把识别的设备当成一个文件挂载到这个目录下
  • /mnt[常用]:系统提供该目录是为了让用户临时挂载别的文件系统的,我们可以将外部的存储挂载在/mnt/上,然后进入该目录就可以查看里的内容了。
    • 这里的文件系统可以是一个共享文件夹,也可以是一个一块新的硬盘等等。
  • /opt:这是给主机额外安装软件所存放的目录。如安装ORACLE 数据库就可放到该目录下。默认为空
  • /usr/local[常用]:这是另一个给主机额外安装软件所安装的目录。一般是通过编译源码方式安装的程序
  • /var[常用]:这个目录中存放着在不断扩充着的东西,习惯将经常被修改的目录放在这个目录下。包括各种日志文件
  • /selinux[security-enhanced linux]:SELinux是一种安全子系统,它能控制程序只能访问特定文件,有三种工作模式,可以自行设置
    • 一般也是看不到这个文件的,只有在你启动了之后才会出现

4、远程登录到Linux 服务器

1、为什么需要远程登录Linux

说明:公司开发时候, 具体的应用场景是这样的:

  1. linux 服务器是开发小组共享
  2. 正式上线的项目是运行在公网
    • 所谓公网就是:具有公网IP可以访问互联网的网络;
    • 公网就是一个对外的IP。
  3. 因此程序员需要远程登录到Linux 进行项目管理或者开发
  4. 远程登录客户端有Xshell6、Xftp6,我们学习使用Xshell 和Xftp6,其它的远程工具大同小异

2、远程登录Linux——Xshell6

1、介绍

说明:

  1. Xshell 是目前最好的远程登录到Linux 操作的软件,流畅的速度并且完美解决了中文乱码的问题, 是目前程序员首选的软件。
  2. Xshell 是一个强大的安全终端模拟软件,它支持SSH1、SSH2、以及Microsoft Windows 平台的 TELNET 协议。
  3. Xshell 可以在Windows 界面下用来访问远端不同系统下的服务器,从而比较好的达到远程控制终端的目的。
2、下载、安装、配置和使用

下载 free-for-home-school 版本,地址

image-20210912021946390

3、远程上传下载文件——Xftp6

1、介绍

是一个基于windows 平台的功能强大的SFTP、FTP 文件传输软件。

使用了Xftp 以后,windows 用户能安全地在UNIX/Linux 和Windows PC 之间传输文件

2、Xftp6 安装配置和使用

image-20210912022103309

3、如何处理Xftp 中文乱码问题

image-20210912022137288


2、Linux Vi 和Vim 编辑器

1、vi 和 vim 的基本介绍

  • Linux 系统会内置vi 文本编辑器
  • Vim 具有程序编辑的能力,可以看做是Vi 的增强版本,可以主动的以字体颜色辨别语法的正确性,方便程序设计。代码补完编译及错误跳转等方便编程的功能特别丰富,在程序员中被广泛使用。

2、vi 和 vim 常用的三种模式

1、正常模式

以 vim 打开一个档案就直接进入一般模式了(这是默认的模式)。在这个模式中, 你可以使用『上下左右』按键来
移动光标,你可以使用『删除字符』或『删除整行』来处理档案内容,也可以使用『复制、粘贴』来处理你的文件数据。

2、插入模式

按下 i,、I、o、O、a、A、r、R 等任何一个字母之后才会进入编辑模式,一般来说按 i 即可

3、命令行模式

esc 再输入:

  • 在这个模式当中, 可以提供你相关指令,完成读取、存盘、替换、离开vim 、显示行号等的动作则是在此模式中达成的!

  • eg:

    1
    2
    # 在进行编辑之后保存并退出
    :wq
    • :——表示进入命令行模式
    • w——表示写入保存
    • q——退出
    • wq——保存并退出

3、各种模式的相互切换

image-20210912022719938

在普通模式:

  • :q:如果不保存的话就不让进行
  • :q!:退出之后不保存

4、vi 和 vim 快捷键

1、快捷键的使用

  • yy:拷贝当前行
    • 5yy:拷贝当前行向下的 5 行
  • p:粘贴
  • dd:删除当前行
    • 5dd:删除当前行向下的5行
  • /关键字:在命令行模式下,在文件中查找某个单词,回车查找,输入 n 就是查找下一个
    • 查找的关键字区分大小写
  • set nu:在命令行模式下,设置文件的行号
  • set nonu:在命令行模式下,取消文件的行号
  • [G]:在一般模式下,使用 vim 编辑/etc/profile 文件,使用快捷键到文件的最末行
  • [gg]:在一般模式下,使用 vim 编辑/etc/profile 文件,使用快捷键到文件的最首行
  • u:在一般模式下,撤销前一个动作。相当于windows的 ctrl + z
  • 20+shift+g:在一般模式下,编辑/etc/profile 文件,把光标移动到第20行

更多的看整理的文档

2、快捷键的键盘对应图

1、vim基础键盘图中文

vim基础键盘图中文

2、vim进阶键盘图

vim进阶键盘图


3、Linux 开机、重启和用户登录注销

1、关机&重启命令

1、基本介绍

  1. 立该进行关机

    1
    2
    # -h:halt:停止
    shutdown –h now

    权限:root

  2. “hello, 1 分钟后会关机了”

    1
    shudown -h 1

    直接输入shutdown默认执行的就是以上命令

  3. 现在重新启动计算机

    1
    2
    # -r:reboot:重启
    shutdown –r now
  4. 关机,作用和上面一样

    1
    halt 
  5. 关机,作用和上面一样

    1
    poweroff
  6. 关机,作用和上面一样

    1
    init 0
  7. 现在重新启动计算机

    1
    init 6
  8. 重新启动计算机,作用和上面一样

    1
    reboot
  9. 把内存的数据同步到磁盘

    1
    sync

2、注意细节

  1. 不管是重启系统还是关闭系统,首先要运行sync 命令,把内存中的数据写到磁盘中
  2. 目前的 shutdown/reboot/halt 等命令均已经在关机前进行了sync,但是:小心驶得万年船!

3、几个关机重启命令介绍

1、shutdown命令
  • 我们较常使用的是shutdown这个命令,这个命令可以安全地关闭或重启Linux系统。

  • 它在系统关闭之前给系统上的所有登录用户提示一条警告信息

  • 该命令还允许用户指定一个时间参数,可以是一个精确的时间,也可以是从现在开始的一个时间段。

  • 精确时间的格式是hh:mm,表示小时和分钟,时间段由时钟数和分钟数表示。

  • 系统执行该命令后会自动进行数据同步的工作。

  • 需要特别说明的是该命令只能由超级用户使用

    • 因为该命令在sbin目录下

shutdown可以达成如下的工作:

  • 可以自由选择关机模式:是要关机、重新启动或进入单人操作模式均可;
  • 可以配置关机时间:可以配置成现在立刻关机,也可以配置某一个特定的时间才关机。
  • 可以自定义关机信息:在关机之前,可以将自己配置的信息传送给在线user 。
  • 可以仅发出警告信息:有时有可能你要进行一些测试,而不想让其他的使用者干扰,或者是明白的告诉使用者某段时间要注意一下!这个时候可以使用 shutdown 来吓一吓使用者,但却不是真的要关机!
  • 可以选择是否要fsck检查文件系统

可以用man命令来查看其用法,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
[root@www ~]# /sbin/shutdown [-t 秒] [-arkhncfF] 时间 [警告信息]

# 选项与参数:

-t sec : -t 后面加秒数,亦即『过几秒后关机』的意思

-k : 不要真的关机,只是发送警告信息出去!

-r : 在将系统的服务停掉之后就重新启动(常用)

-h : 将系统的服务停掉后,立即关机。 (常用)

-n : 不经过 init 程序,直接以 shutdown 的功能来关机

-f : 关机并启动之后,强制略过 fsck 的磁盘检查

-F : 系统重新启动之后,强制进行 fsck 的磁盘检查

-c : 取消已经在进行的 shutdown 命令内容。

# 时间 : 这是一定要加入的参数!指定系统关机的时间!时间的范例底下会说明。

# 范例:

[root@www ~]# /sbin/shutdown -h 10 'I will shutdown after 10 mins'

# 立刻关机,其中 now 相当于时间为 0 的状态
[root@www ~]# shutdown -h now

# 系统在今天的 20:25 分会关机,若在21:25才下达此命令,则隔天才关机
[root@www ~]# shutdown -h 20:25

# 系统再过十分钟后自动关机
[root@www ~]# shutdown -h +10

# 系统立刻重新启动
[root@www ~]# shutdown -r now

# 再过三十分钟系统会重新启动,并显示后面的信息给所有在在线的使用者
[root@www ~]# shutdown -r +30 'The system will reboot'

# 仅发出警告信件的参数!系统并不会关机啦!吓唬人!
[root@www ~]# shutdown -k now 'This system will reboot'
2、reboot,halt与poweroff

这三个命令可以进行重新启动与关机的任务,其实这三个命令调用的函式库都差不多,所以当你使用『man reboot』时,会同时出现三个命令的用法给你看,如下图所示:

img

3、init 0

init所有进程的祖先,进程号永远为1,linux系统操作中不可缺少的程序之一,所有发送TERM信号给init会终止所有用户进程、守护进程等。init定义了8个运行级别,这里相关的主要是0关机6重启

img

4、几个关机重启命令的区别

几个命令的作用都是用来关机,但是又有细微区别:

  • halt:halt被称为最简单的关机命令,它会

    • 通知硬件停止所有的CPU功能;
    • 执行时会杀死进程;
    • 执行sync系统调用文件系统写操作;
    • 完成后就会停止内核。
    1
    2
    3
    4
    5
    6
    7
    8
    # 相当于poweroff
    halt -p

    # 强制关机
    halt -f

    # 关机或重启前关闭所有网络接口
    halt -i
  • poweroff:关机同时关闭电源,会发送一个ACPI信号通知系统关机,在多用户方式下(run level3)下不建议使用。

    1
    2
    # 强制关机
    poweroff -f
  • shutdown:关机同时关闭电源

    • 只有拥有root权限的用户才可以执行(普通用户需要root授权);

    • 发送信号给init,使之改变运行级别(run level)来实现关机,关机或重启实质上就是运行级别的调整,所以也可以直接使用# init 0来关机,#init 6来重启。

    • shutdown可设置广播信息来通知已登录的用户将关机,且会创建/run/nologin文件,禁止新用户登录。

    • 也就给了一定时间给给进程进行保存操作,被视为安全的关机命令。

    • 加参数时为如下意义:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      # 重启
      shutdown -r

      # 相当于poweroff
      shutdown -P

      # 相当于hatl
      shutdown -H

      # 不关机但是发送警告给用户
      shutdown -k

      # shutdown 加时间,可以在指定时间关机
      # 但是此指令没有-f强制参数
      shutdown now
      shutdown 22:22

      # 在关机前,也可以取消关机。
      shutdown -c
  • init:所有进程的祖先,进程号永远为1,linux系统操作中不可缺少的程序之一,所有发送TERM信号给init会终止所有用户进程、守护进程等。init定义了8个运行级别,这里相关的主要是0关机,6重启。

init一共分为7个级别,这7个级别的所代表的含义如下

  • 0:停机或者关机(千万不能将initdefault设置为0)
  • 1:单用户模式,只root用户进行维护
  • 2:多用户模式,不能使用NFS(Net File System)
  • 3:完全多用户模式(标准的运行级别)
  • 4:安全模式
  • 5:图形化(即图形界面)
  • 6:重启(千万不要把initdefault设置为6)

2、用户登录和注销

1、基本介绍

  1. 登录时尽量少用root 帐号登录,因为它是系统管理员,最大的权限,避免操作失误。
  2. 可以利用普通用户登录,登录后再用 su - 用户名 命令来切换成系统管理员身份
  3. 在提示符下输入 logout 即可注销用户

2、使用细节

  1. logout 注销指令在图形运行级别无效,在运行级别3 下有效
  2. 如果一开始登陆就是普通用户,当执行su - root 来切换成系统管理员身份后,再执行logout会回到普通用户,再一次查询logout才会退出系统

4、Linux 用户管理

1、基本介绍

Linux 系统是一个多用户多任务的操作系统,任何一个要使用系统资源的用户,都必须首先向系统管理员申请一个账号,然后以这个账号的身份进入系统。不同的用户的权限可能不同。

2、添加用户

1、基本语法

1
2
3
4
5
useradd 用户名

# eg:添加一个用户milan
useradd milan
# 默认该用户的家目录在/home/milan

2、细节说明

  1. 当创建用户成功后,会自动的创建和用户同名的家目录
  2. 也可以通过useradd -d 指定目录新的用户名,给新创建的用户指定家目录
  3. 直接创建用户没有对其分组的话,该用户会自成一个组,组的名字就是该用户的名字,组的成员只有他一个

3、指定/修改密码

1、基本语法

1
2
3
4
passwd 用户名

# eg 给milan 指定密码
passwd milan

2、细节说明

  1. 在指定/修改密码的时候注意应定在后面要加上要指定的用户名,不然默认给当前用户修改密码
  2. 一不注意的话可能出现大麻烦

4、删除用户

1、基本语法

1
2
3
4
5
# userdel命令在sbin目录下,表示只有拥有root权限才能执行
userdel 用户名

# eg:删除用户milan
userdel milan

2、删除用户的两种方式

1、删除用户,但是保存他的家目录(推荐)
1
2
3
4
userdel 用户名

# eg:删除用户milan,但是要保留家目录
userdel milan
2、删除用户,同时删除他的家目录(慎用)
1
2
3
4
userdel -r 用户名

# eg:删除用户tom,以及他的用户主目录
userdel -r tom

一般我们会选择仅仅删除该用户但是不删除他的家目录

  • 因为删除该用户的话,就可以让该用户登陆不能Linux系统了。但是之前改用户在Linux上操作的文件或代码还是可以获取的
  • 但是如果连该用户的加目录也一起删除了,就获取不到该用户在Linux系统留下的文件或代码了

5、查询用户信息指令

1、基本语法

1
2
3
4
id 用户名

# eg:查询root用户的信息
id root

当用户不存在时,返回无此用户

2、展示的相关信息

image-20210912210445330

  • uid:用户id
  • gid:组id
    • root的组id为0
  • 组:用户当前所在的组

6、切换用户

1、介绍

在操作Linux 中,如果当前用户的权限不够,可以通过su - 指令,切换到高权限用户,比如root.

当然切换为别的用户也可以。

2、基本语法

1
2
3
4
su - 切换用户名

# eg:切换为root用户
su - root

3、细节说明

  1. 从权限高的用户切换到权限低的用户,不需要输入密码,反之需要。
  2. 当需要返回到原来用户时,使用 exit/logout 指令

7、查看当前用户/登录用户

基本语法

1
2
3
# 以下两种方法
whoami
who am I

8、用户组

1、介绍

类似于角色,系统可以对有共性(有一样的权限的)的多个用户进行统一的管理

2、新增组

1
2
3
4
5
6
7
8
9
10
11
groupadd 组名

# eg:增加组名为China的组
groupadd China

# 在添加用户的同时指派到对应的组
useradd –g 用户组用户名

# eg:增加一个用户zwj, 直接将他指定到wudang
groupadd wudang
useradd -g wudang zwj

注意:如果直接添加用户,没有指定组。那么会生成一个与当前用户名称相同的一个组并且将该用户加入该组

3、删除组

1
2
3
4
groupdel 组名

# eg:删除组名为China的组
groupdel China

4、修改用户的组

1
2
3
4
5
usermod –g 用户组 用户名

# eg:将用户zwj,从wudang组修改为mojiao组
groupadd mojiao
usermod -g mojiao zwj

5、用户和组相关文件

1、/etc/passwd 文件

用户(user)的配置文件,记录用户的各种信息

image-20210912233154470

每行的含义:用户名:口令:用户标识号:组标识号:注释性描述:主目录:登录Shell

  • 用户名:用户名
  • 口令:一般为x或者空格
  • 用户标识号:用户id
  • 组标识号:组id
  • 注释性描述:对该用户的注释说明,没有就为空
  • 主目录:用户对应的家目录
  • 登录Shell:用户登陆的指令通过shell解释之后得到
    • Linux的内核并不能直接运行我们输入的命令
    • 在输入命令之后,需要通过Shell解释器将命令解释成Linux可以运行的语言
    • 常用的Shell解释器有:Bash、tcsh、csh等等
2、/etc/shadow 文件

口令的配置文件

image-20210912233841545

每行的含义:登录名:加密口令:最后一次修改时间:最小时间间隔:最大时间间隔:警告时间:不活动时间:失效时间:标志

  • 登录名:用户名
  • 加密口令:用户密码,如果没有设置用户密码,则显示为!!,如果设置了用户密码的话,显示的就是加密之后的密码
  • 最后一次修改时间:最后一次修改用户的时间,单位s
  • 最小时间间隔:0
  • 最大时间间隔:99999
  • 警告时间:7
  • 不活动时间:失效时间:标志:都为空
3、/etc/group 文件

组(group)的配置文件,记录Linux 包含的组的信息

image-20210912234246659

每行含义:组名:口令:组标识号:组内用户列表

  • 组名:组的名字
    • 如果添加用户的时候没有为其添加组,他自己会自成一个组
  • 口令:一般为x或者空格
  • 组标识号:组id
  • 组内用户列表:组内成员用户的列表
    • 一般隐藏起来了,看不见的

5、Linux 实用指令

1、指定运行级别

1、基本介绍

运行级别说明:

  • 0:关机
  • 1:单用户【找回丢失密码】
  • 2:多用户状态没有网络服务
    • Linux最大的优势就是它的网络服务
    • 用的非常少
  • 3:多用户状态有网络服务
  • 4:系统未使用保留给用户
    • 用的非常少
  • 5:图形界面
  • 6:系统重启

常用运行级别是3 和5 ,也可以指定默认运行级别。

2、相关命令

1、执行对应的init命令
1
init 0/1/2/3/4/5/6
2、查看当前的默认运行级别
1
systemctl get-default
3、设置 默认的运行级别
1
2
3
4
systemctl set-default TARGET.target

# eg:将当前的默认的运行级别设置为多用户状态有网络服务
systemctl set-default multi-user.target

3、CentOS7 后运行级别说明

在centos7以前,是在文件/etc/inittab当中进行修改,在里面的一行配置runlevel中指定一个数字,代表默认启动的级别

在centos7以后进行了简化,如下:

1
2
3
4
5
# multi-user.target 相当于 entos7以前的 runlevel 3
multi-user.target: analogous to runlevel 3

# graphical.target 相当于 entos7以前的 runlevel 5
graphical.target: analogous to runlevel 5

2、找回 root 密码

  1. 首先,启动系统,进入开机界面,在界面中按 “e” 进入编辑界面

    • 速度要快,只有5s。超过5s就进入登陆页面了

    image-20210913000538819

  2. 进入编辑界面,使用键盘上的上下键把光标往下移动,找到以“Linux16”开头内容所在的行数”,在行的最后面输入:init=/bin/sh

    image-20210913000759680

  3. 接着,输入完成后,直接按快捷键:Ctrl+x 进入单用户模式

  4. 接着,在光标闪烁的位置中输入:mount -o remount,rw /(注意:各个单词间有空格),完成后按键盘的回车键(Enter)。

    image-20210913001003764

  5. 在新的一行最后面输入:passwd, 完成后按键盘的回车键(Enter)。输入密码,然后再次确认密码即

    • (密码长度最好8位以上,但不是必须的)
  6. 密码修改成功后,会显示passwd…..的样式,说明密码修改成功

    image-20210913001054629

  7. 接着,在鼠标闪烁的位置中(最后一行中)输入:touch /.autorelabel(注意:touch与 /后面有一个空格),完成后按键盘的回车键(Enter)

  8. 继续在光标闪烁的位置中,输入:exec /sbin/init(注意:exec与 /后面有一个空格),完成后按键盘的回车键(Enter),等待系统自动修改密码(这个过程时间可能有点长,耐心等待),完成后,系统会自动重启,新的密码生效

    image-20210913002016278

3、帮助指令

1、man 获得帮助信息

基本语法:(功能描述:获得帮助信息)

1
2
3
4
5
6
7
8
9
10
man [命令或配置文件]

# 查看ls命令的详细信息
man ls
# 1、如果显示的信息太多展示不完,可以使用 空格 进行翻页
# 2、CTRL+F,CTRL+B可以像vim一样翻页,也可以用G和gg
# 3、q:退出man ls
# 4、在 linux 下,隐藏文件是以.开头, 选项可以组合使用比如ls -al:, 比如ls -al /root
# 4.1、ls -al:查看当前目录下的所有文件
# 4.2、ls -al /root:指定查看某个具体目录下的所有文件

2、help 指令

基本语法:(功能描述:获得 shell 内置命令的帮助信息)

1
2
3
4
5
help 命令

# eg
# 如果英语不太好的,也可以直接百度靠谱。
help cd

4、文件目录类

1、pwd 指令

功能描述:显示当前工作目录的绝对路径

1
pwd

2、ls 指令

1
ls [选项] [目录或是文件]

常用选项:

  • -a:显示当前目录所有的文件和目录,包括隐藏的
  • -l:以列表的方式显示信息
  • -h:展示更加人性化,如把字节转化为兆等等

3、cd 指令

功能描述:切换到指定目录

1
cd [参数]

理解:绝对路径和相对路径

  • 绝对路径:从根目录下往下寻找——/home/tom
  • 相对路径:从当前目录上寻找
1
2
3
4
5
# 回到自己的家目录, 比如你是root , cd ~ 到/root
cd ~ 或者cd:

# 回到当前目录的上一级目录
cd ..

应用实例:

1
2
3
4
5
6
7
8
9
10
11
# 使用绝对路径切换到 root 目录
cd /root

# 使用相对路径到/root 目录,比如在/home/tom
cd ../../root

# 表示回到当前目录的上一级目录
cd ..

# 回到家目录
cd ~

4、mkdir 指令

功能描述:用于创建目录

1
mkdir [选项] 要创建的目录

选项:

  • -p:创建多级目录

应用实例:

1
2
3
4
5
# 创建一个目录/home/dog
mkdir /home/dog

# 创建多级目录/home/animal/tiger
mkdir -p /home/animal/tiger

注意:

  • mkdir 默认创建以及目录
  • 如果要创建多级目录,需要加上-p

5、rmdir 指令

功能描述:删除空目录

1
2
3
4
rmdir [选项] 要删除的空目录

# eg:删除一个目录/home/dog
rmdir /home/dog

使用细节

  • rmdir 删除的是空目录,如果目录下有内容时无法删除的。
  • 提示:如果需要删除非空目录,需要使用rm -rf 要删除的目录
    • -f:不需要提示
    • -r:递归删除

6、touch 指令

功能描述:用于创建空文件

1
2
3
4
5
touch 文件名称

# eg:在/home 目录下, 创建一个空文件hello.txt
cd /home
touch hello.txt

7、cp 指令

功能描述:拷贝文件到指定目录

1
2
3
4
5
6
7
cp [选项] source dest

# eg:将/home/hello.txt 拷贝到/home/bbb 目录下
cp hello.txt /home/bbb

# eg:递归复制整个文件夹,举例, 比如将/home/bbb 整个目录, 拷贝到/opt
cp -r /home/bbb /opt

常用选项

  • -r :递归复制整个文件夹

使用细节

  • 强制覆盖不提示的方法:\cp

    1
    \cp -r /home/bbb /opt

8、rm 指令

功能描述:移除文件或目录

1
2
3
4
5
6
7
rm [选项] 要删除的文件或目录

# eg:将/home/hello.txt 删除
rm /home/hello.txt

# eg:递归删除整个文件夹/home/bbb[删除整个文件夹,不提示]
rm -rf /home/bbb

常用选项

  • -r:递归删除整个文件夹
  • -f: 强制删除不提示

使用细节:

  • 强制删除不提示的方法:带上-f 参数即可

9、mv 指令

功能描述:移动文件与目录或重命名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 功能描述:重命名
mv oldNameFile newNameFile

# eg:将/home/cat.txt 文件重新命名为pig.txt
mv /home/cat.txt /home/pig.txt

# 功能描述:移动文件
mv /temp/movefile /targetFolder

# eg:将/home/pig.txt 文件移动到/root 目录下
mv /home/pig.txt /root/
# eg:移动整个目录, 比如将/opt/bbb 移动到/home 下
mv /opt/bbb/ /home/

# 功能描述:移动并且重命名文件
mv /temp/oldNameFile /targetFolder/newNameFile

# eg:将/home/pig.txt 文件移动到/root 目录下并重命名为cat.txt
mv /home/pig.txt /root/cat.txt

使用细节:

  • 如果mv的两个文件或者目录在同一个目录下,那么mv的功能就是重命名
  • 如果mv的两个文件或者目录在不在同一个目录下,那么mv的功能就是移动
  • 如果想要移动并且重命名的话,两个文件或者目录在不在同一个目录下,并且在移动之后添加重命名后的文件名称
  • 注意:
    • 在Linux中,如果一个文件后面有加上/,那么该文件代表的就是一个目录
    • 如果没有加上/,那么就是一个文件

10、cat 指令

功能描述:查看文件内容

1
2
3
4
cat [选项] 要查看的文件

# eg:查看/etc/profile 文件内容,并显示行号
cat -n /etc/profile

选项:

  • -n :显示行号

使用细节:

  • cat 只能浏览文件,而不能修改文件,为了浏览方便,一般会带上管道命令| more
  • cat -n /etc/profile | more [进行交互]

11、more 指令

more 指令是一个基于VI 编辑器的文本过滤器,它以全屏幕的方式按页显示文本文件的内容。

more 指令中内置了若干快捷键(交互的指令)。

1
more 要查看的文件

快捷键说明:

操作 功能说明
空格键(space) 代表向下翻一页
Enter 代表向下翻一行
q 代表立即离开more,不再显示该文件的内容
ctrl + F 向下滚动一屏
ctrl + B 返回上一屏
= 输出当前行的行号
:f 输出当前文件名和当前行的行号

12、less 指令

less 指令用来分屏查看文件内容,它的功能与more 指令类似,但是比more 指令更加强大,支持各种显示终端

less指令在显示文件内容时,并不是一次将整个文件加载之后才显示,而是根据显示需要加载内容,对于显示大型文件具有较高的效率。(懒加载)

1
less 要查看的文件

快捷键说明:

操作 功能说明
空格键(space) 代表向下翻一页
[pagedown] 代表向下翻一页
[pageup] 代表向上翻一页
q 代表立即离开less,不再显示该文件的内容
/字串 向下搜索[字串]的功能;n:向下查找;N:向上查找
?字串 向上搜索[字串]的功能;n:向上查找;N:向下查找

13、echo 指令

功能描述:输出内容到控制台

1
2
3
4
5
6
echo [选项] [输出内容]

# eg:使用echo 指令输出环境变量, 比如输出$PATH $HOSTNAME
echo $HOSTNAME
# eg:使用echo 指令输出hello,world!
echo "hello,world!"

14、head 指令

功能描述:显示文件的开头部分内容,默认情况下head 指令显示文件的前10 行内容

1
2
3
4
5
6
7
8
# 功能描述:查看文件头10行内容
head 文件

# 功能描述:查看文件头5行内容,5可以是任意行数
head -n 5 文件

# eg:查看/etc/profile 的前面5 行代码
head -n 5 /etc/profile

15、tail 指令

功能描述:输出文件中尾部的内容,默认情况下tail 指令显示文件的最后10 行内容

1
2
3
4
5
6
7
8
# 功能描述:查看文件尾10行内容
tail 文件

# 功能描述:查看文件尾5行内容,5可以是任意行数
tail -n 5 文件

# 功能描述:实时追踪该文档的所有更新(经常用于查看日志)
tail -f 文件

16、> 指令和 >> 指令

功能描述:> 输出重定向 和 >> 追加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 功能描述:文件的列表的内容写入文件a.txt 中(覆盖写)
# 文件目录没写默认为当前目录
# 如果没有文件的话,则会创建
ls -l [文件目录] >文件

# 将/home 目录下的文件列表写入到/home/info.txt 中
ls -l /home > /home/info.txt

# 功能描述:列表的内容追加到文件aa.txt 的末尾
ls -al >>文件

# 功能描述:将文件1 的内容覆盖到文件2
# 如果文件2是一个空文件的话,该命令很适合被当做一个复制指令
cat 文件1 > 文件2

echo "内容" >> 文件(追加)

使用细节

  • >输出重定向是覆盖写
  • >>追加是在文件后面追加内容

17、ln 指令

功能描述:给原文件创建一个软链接

软链接也称为符号链接,类似于windows 里的快捷方式,主要存放了链接其他文件的路径。

1
2
3
4
5
6
7
8
9
10
11
# 给原文件创建一个软链接
ln -s [原文件或目录] [软链接名]

# eg:在/home 目录下创建一个软连接myroot,连接到/root 目录
ln -s /root /home/myroot

# 删除软连接
rm [软链接名]

# eg:删除软连接myroot
rm /home/myroot

细节说明:

  • 当我们使用pwd 指令查看目录时,仍然看到的是软链接所在目录。
  • 在/home/myroot目录下ls和创建新文件等同在/root下操作

18、history 指令

功能描述:查看已经执行过历史命令,也可以执行历史指令

1
2
3
4
5
6
7
8
# 查看已经执行过历史命令
history

# 显示最近使用过的10 个指令。
history 10

# 执行历史编号为5的指令
!5

5、时间日期类

1、date 指令——显示当前日期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 功能描述:显示当前时间
date

# 功能描述:显示当前年份
date +%Y

# 功能描述:显示当前月份
date +%m

# 功能描述:显示当前是哪一天
date +%d

# 功能描述:显示年月日时分秒
date "+%Y-%m-%d %H:%M:%S"

2、date 指令——设置日期

1
2
3
4
date -s 字符串时间

# eg:设置当前时间为 2021-9-13 14:04:10
date -s "2021-9-13 14:04:10"

3、cal 指令

查看当前日历

1
2
3
4
5
6
7
8
# 功能描述:不加选项,显示本月日历
cal [选项]

# eg:显示当前日历cal
cal

# eg:显示2020 年日历
cal 2020

6、搜索查找类

1、find 指令

功能描述:从指定目录向下递归地遍历其各个子目录,将满足条件的文件或者目录显示在终端。

1
find [搜索范围] [选项]
选项 功能
-name<查询方式> 按照指定的文件名查找模式查找文件
-user<用户名> 查找属于指定用户的所有文件
-size<文件大小> 按照指定文件的大小查找文件

应用实例:

1
2
3
4
5
6
7
8
# 按文件名:根据名称查找/home 目录下的hello.txt 文件
find /home -name hello.txt

# 按拥有者:查找/opt 目录下,用户名称为nobody 的文件
find /opt -user nobody

# 查找整个linux 系统下大于200M 的文件(n为输入的size的值:+n 大于、-n 小于、n 等于, 单位有k,M,G)
find / -size +200M

2、locate 指令

locate 指令可以快速定位文件路径。

locate 指令利用事先建立的系统中所有文件名称及路径的locate 数据库实现快速定位给定的文件。

Locate 指令无需遍历整个文件系统,查询速度较快。为了保证查询结果的准确度,管理员必须定期更新locate 数据库

1
locate 搜索文件

特别说明:

  • 由于locate 指令基于数据库进行查询,所以第一次运行前,必须使用updatedb 指令创建locate 数据库。

3、which 指令

功能描述:可以查看某个指令在哪个目录下

1
2
3
4
which 指令名称

# eg:查看ls 指令在哪个目录
which ls

4、grep 指令和管道符号|

grep 过滤查找, 管道符,“|”,表示将前一个命令的处理结果输出传递给后面的命令处理

1
grep [选项] 查找内容源文件

常用选项:

选项 功能
-n 显示匹配行以及行号
-i 忽略字母大小写
-v 反向匹配(匹配当前不存在的结果)

应用实例:

1
2
3
4
5
6
7
# 请在hello.txt 文件中,查找"yes" 所在行,并且显示行号
cat /home/hello.txt | grep "yes"
# 或
grep -n "yes" /home/hello.txt

# 查询Linux 中的rsyslogd 服务(日志管理服务)是否启动,查询结果不包含grep本身
ps -aux | grep "rsyslog" | grep -v "grep"

7、压缩和解压类

1、gzip/gunzip 指令

功能描述:gzip 用于压缩文件, gunzip 用于解压的

1
2
3
4
5
6
7
8
9
10
11
# 功能描述:压缩文件,只能将文件压缩为*.gz 文件
gzip 文件

# eg:将/home 下的hello.txt 文件进行压缩
gzip /home/hello.txt

# 功能描述:解压缩文件命令
gunzip 文件.gz

# eg:将/home 下的hello.txt.gz 文件进行解压缩
gunzip /home/hello.txt.gz

2、zip/unzip 指令

功能描述:zip 用于压缩文件, unzip 用于解压的,这个在项目打包发布中很有用的

1
2
3
4
5
# 功能描述:压缩文件和目录的命令
zip [选项] XXX.zip 将要压缩的内容

# 功能描述:解压缩文件
unzip [选项] XXX.zip

zip 常用选项:

  • -r:递归压缩,即压缩目录

unzip 的常用选项:

  • -d<目录> :指定解压后文件的存放目录

应用实例

1
2
3
4
5
6
7
# 将/home 下的所有文件/文件夹进行压缩成myhome.zip
# 将home 目录及其包含的文件和子文件夹都压缩
zip -r myhome.zip /home/

# 将myhome.zip 解压到/opt/tmp 目录下
mkdir /opt/tmp
unzip -d /opt/tmp /home/myhome.zip

3、tar 指令

功能描述:tar 指令是打包指令,最后打包后的文件是.tar.gz 的文件。

1
2
# 功能描述:打包目录,压缩后的文件格式.tar.gz
tar [选项] XXX.tar.gz 打包的内容

选项说明:

选项 功能
-c 产生.tar打包文件
-v 显示详细信息
-f 指定压缩后的文件名
-z 用gzip对文档(是否同时具有 gzip 的属性)进行压缩或解压
-x 解包.tar文件

应用实例:

1
2
3
4
5
6
7
8
9
10
11
12
# 压缩多个文件,将/home/pig.txt 和/home/cat.txt 压缩成pc.tar.gz
tar -zcvf pc.tar.gz /home/pig.txt /home/cat.txt

# 将/home 的文件夹压缩成myhome.tar.gz
tar -zcvf myhome.tar.gz /home/

# 将pc.tar.gz 解压到当前目录
tar -zxvf pc.tar.gz

# 将myhome.tar.gz 解压到/opt/tmp2 目录下
mkdir /opt/tmp2
tar -zxvf /home/myhome.tar.gz -C /opt/tmp2

细节说明:

  • -C::-C<目的目录>或–directory=<目的目录> 切换到指定的目录。

6、Linux 组管理和权限管理

1、Linux 组基本介绍

在linux 中的每个用户必须属于一个组,不能独立于组外。在linux 中每个文件有所有者、所在组、其它组的概念。

  1. 所有者
  2. 所在组
  3. 其它组
  4. 改变用户所在的组

小a是属于A组的,小a创建的文件1就是A组所在的文件,A部门其他用户有一定的权限,而组B、组C对于文件1来说就是其他组,其他组对文件1有另外的权限。

小a也可以改变组,小a去了B组后,小a的文件所在组也就更改为组B,而组A、组C对文件1来说就更改为其他组

文件1也可以更改所有者

2、文件/目录所有者

一般为文件的创建者,谁创建了该文件,就自然的成为该文件的所有者。

1、查看文件的所有者

1
ls –ahl

image-20210913150436360

2、修改文件所有者

1
2
3
4
chown 用户名文件名

# eg:使用root 创建一个文件apple.txt ,然后将其所有者修改成tom
chown tom apple.txt

3、组的创建

1、基本指令

1
groupadd 组名

2、应用实例

  • 创建一个组monster
  • 创建一个用户fox ,并放入到monster 组中
1
2
3
groupadd monster

useradd -g monster fox

4、文件/目录所在组

当某个用户创建了一个文件后,这个文件的所在组就是该用户所在的组(默认)。

1、查看文件/目录所在组

1、基本指令
1
ls –ahl
2、应用实例

使用fox 来创建一个文件,看看该文件属于哪个组?

1
2
3
ls –ahl

-rw-r--r--. 1 fox 0 11 月5 12:50 ok.txt

2、修改文件/目录所在的组

1、基本指令
1
chgrp 组名文件名
2、应用实例

使用root 用户创建文件orange.txt ,看看当前这个文件属于哪个组,然后将这个文件所在组,修改到fruit 组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 创建fruit组
groupadd fruit

# 创建orange.txt
touch orange.txt

# 看看当前这个文件属于哪个组-> root 组
ls –ahl
-rw-r--r--. 1 root 0 11 月5 12:50 orange.txt

# 将orange.txt文件所在组,修改到fruit 组。
chgrp fruit orange.txt

# 看看当前这个文件属于哪个组-> fruit 组
ls -alh
-rw-r--r--. 1 root fruit 11 月5 12:50 orange.txt

5、其它组

除文件的所有者和所在组的用户外,系统的其它用户都是文件的其它组

改变用户所在组

在添加用户时,可以指定将该用户添加到哪个组中,同样的用root 的管理权限可以改变某个用户所在的组。

改变用户所在组
1
2
3
4
5
6
# 改变用户所在组
usermod –g 新组名 用户名

# 改变该用户登陆的初始目录。
# 特别说明:用户需要有进入到新目录的权限。
usermod –d 目录名 用户名

6、权限的基本介绍

ls -l 中显示的内容如下:

1
-rwxrw-r-- 1 root root 1213 Feb 2 09:39 abc

-rwxrw-r--这10位数,0-9 位说明:

  • 第 0 位确定文件类型(d, - , l , c , b)
  • l是链接,相当于windows 的快捷方式
  • d 是目录,相当于windows 的文件夹
  • c 是字符设备文件,鼠标,键盘
  • b 是块设备,比如硬盘
  • -是普通文件
  • 第1-3 位rwx确定所有者(该文件的所有者)拥有该文件的权限。—User
  • 第4-6 位rw-确定所属组(同用户组的)拥有该文件的权限,—Group
  • 第7-9 位r--确定其他用户拥有该文件的权限—Other

7、rwx 权限详解

1、rwx 作用到文件

  1. [ r ]:代表可读(read),可以读取、查看
  2. [ w ]:代表可写(write),可以修改,但是不代表可以删除该文件,删除一个文件的前提条件是对该文件所在的==目录==有写权限,才能删除该文件。
  3. [ x ]:代表可执行(execute),可以被执行

2、rwx 作用到目录

  1. [ r ]:代表可读(read),可以读取,ls 查看目录内容
  2. [ w ]:代表可写(write),可以修改,对目录内创建文件+删除文件+重命名目录
  3. [ x ]:代表可执行(execute),可以进入该目录(cd),并且可以对里面的文件进行修改,但是不能使用ls查看目录内容

8、文件及目录权限实际案例

ls -l 中显示的内容如下

1
-rwxrw-r-- 1 root root 1213 Feb 2 09:39 abc
  1. -rwxrw-r--这10 个字符确定不同用户能对文件干什么
    • 第一个字符代表文件类型: -、l、d、c、b
    • 其余字符每3 个一组(rwx) 读(r) 写(w) 执行(x)
    • 第一组rwx : 文件拥有者的权限是读、写和执行——即root用户对该文件具有读写执行的权限
    • 第二组rw- : 与文件拥有者同一组的用户的权限是读、写但不能执行——即root组对该文件具有读写的权限
    • 第三组r– : 不与文件拥有者同组的其他用户的权限是读不能写和执行
  2. 可用数字表示为: r=4,w=2,x=1 因此rwx=4+2+1=7 , 数字可以进行组合
  3. 其它说明
    • 1:文件的硬连接数
      • 如果当前是一个文件,则为1
      • 如果不是一个文件,而是一个目录,则为子目录数 + 文件数
    • 第一个root:用户
    • 第二个root:组
    • 1213:文件大小(字节),如果是文件夹,显示4096 字节
    • Feb 2 09:39:最后修改日期
    • abc:文件名

9、修改权限——chmod

1、基本说明

通过chmod 指令,可以修改文件或者目录的权限。

2、第一种方式:+ 、-、= 变更权限

  • u:所有者、g:所在组、o:其他人、a:所有人 (u、g、o 的总和)

    • # 给文件所有者读写执行的权限,给文件所在组读执行权限,给文件的其他人执行的权限
      chmod u=rwx,g=rx,o=x 文件/目录名
      
      1
      2
      3
      4

      - ```sh
      # 给文件的其他用户赋予写的权限
      chmod o+w 文件/目录名
    • # 去掉所有人对该文件的执行权限
      chmod a-x 文件/目录名
      
      1
      2
      3
      4
      5
      6
      7

      - 案例演示

      1. 给abc 文件的所有者读写执行的权限,给所在组读执行权限,给其它组读执行权限。

      ```sh
      chmod u=rwx,g=rx,o=rx abc
    1. 给abc 文件的所有者除去执行的权限,增加组写的权限

      1
      chmod u-x,g+w abc
    2. 给abc 文件的所有用户添加读的权限

      1
      chmod a+r abc

image-20210914021353080

3、第二种方式:通过数字变更权限

r=4 w=2 x=1 《==》 rwx=4+2+1=7

1
2
3
chmod u=rwx,g=rx,o=x 文件/目录名
# 相当于
chmod 751 文件/目录名

案例演示:

1
2
# 将/home/abc.txt 文件的权限修改成rwxr-xr-x, 使用给数字的方式实现
chmod 755 /home/abc.txt

10、修改文件/目录所有者——chown

基本使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 修改文件/目录所有者
chown newowner 文件/目录改变所有者

# 修改文件/目录所有者和所在组
chown newowner:newgroup 文件/目录改变所有者和所在组

-R 如果是目录则使其下所有子文件或目录递归生效

# eg:请将/home/abc.txt 文件的所有者修改成tom
chown tom /home/abc.txt

# eg:请将/home/test 目录下所有的文件和目录的所有者都修改成tom
chown -R tom /home/test

11、修改文件/目录所在组——chgrp

基本使用:

1
2
3
4
5
6
7
8
9
10
11
# 修改文件/目录所在组
chgrp newgroup 文件/目录【改变所在组】

-R 如果是目录则使其下所有子文件或目录递归生效

# eg:请将/home/abc .txt 文件的所在组修改成shaolin (少林)
groupadd shaolin
chgrp shaolin /home/abc.txt

# eg:请将/home/test 目录下所有的文件和目录的所在组都修改成shaolin(少林)
chgrp -R shaolin /home/test

7、Linux 定时任务调度

1、crond 任务调度

1
crontab 进行定时任务的设置

1、概述

  • 任务调度:是指系统在某个时间执行的特定的命令或程序。
  • 任务调度分类:
    • 系统工作:有些重要的工作必须周而复始地执行。如病毒扫描等
    • 个别用户工作:个别用户可能希望执行某些程序,比如对mysql 数据库的备份。

image-20210915162112393

2、基本语法

1
crontab [选项]

3、常用选项

选项 功能
-e 编辑crontab定时任务
-l 查询crontab定时任务
-r 删除当前用户所有的crontab定时任务

4、快速入门

1
2
3
4
5
# 设置任务调度文件:/etc/crontab
# 设置个人任务调度。执行crontab –e 命令。
# 接着输入任务到调度文件
*/1 * * * * ls –l /etc/ > /tmp/to.txt
# 意思说每小时的每分钟执行ls –l /etc/ > /tmp/to.txt 命令

参数细节说明:5 个占位符的说明

项目 含义 范围
第一个 “*” 一小时当中的第几分钟 0 - 59
第二个 “*” 一天当中的第几小时 0 - 23
第三个 “*” 一个月当中的第几天 1 - 31
第四个 “*” 一年当中的第几月 1 - 12
第五个 “*” 一周当中的星期几 0 - 7(0和7都代表星期日)

特殊符号的说明:

特殊符号 含义
* 代表任何时间。比如第一个”*” 就代表一小时当中的每分钟都执行一次的意思
, 代表不连续的时间。比如”0 8,12,16 * * * 命令”,就代表在每天的8点0分,12点0分,16点0分都执行一次命令
- 代表连续的时间范围。比如”0 5 * * 1-6 命令”,代表在周一到周六的凌晨5点0分执行一次命令
*/n 癌变每隔多久执行一次。比如”/10 * * * * 命令”,代表每隔10分钟就执行一次

特殊时间执行案例:

时间 含义
45 22 * * * 命令 在22点45分执行命令
0 17 * * 1 命令 每周一的17点0分执行命令
0 5 1,15 * * 命令 在每月的1号和15号的凌晨5点0分执行命令
40 4 * * 1-5 命令 在每周的周一到周五的凌晨4点40分执行命令
*/10 4 * * * 命令 每天的凌晨4点,每隔10分钟执行一次命令
0 0 1,15 * 1 命令 每月的1号和15号,每周1的0点0分都会执行命令。注意:星期几和几号最好不要同时出现,因为他们定义的都是天。非常容易让管理员混乱。

5、应用实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 执行crontab –e 命令。

# 每隔1分钟,就将当前的日期信息,追加到/tmp/mydate 文件中
*/1 * * * * date >> /tmp/mydate

# 每隔1分钟, 将当前日期和日历都追加到/home/mycal 文件中
# 步骤:
# 1、编辑
vim /home/my.sh
date >> /home/mycal 和cal >> /home/mycal
# 2、给my.sh 增加执行权限
chmod u+x /home/my.sh
# 3
crontab -e
*/1 * * * * /home/my.sh

# 每天凌晨2:00 将mysql 数据库testdb ,备份到文件中。
# 提示: 指令为 mysqldump -u root -p 密码数据库> /home/db.bak
crontab -e
0 2 * * * mysqldump -u root -proot testdb > /home/db.bak

6、crond 相关指令

1
2
3
4
5
6
7
8
# 终止任务调度
conrtab –r

# 列出当前有那些任务调度
crontab –l

# 重启任务调度
service crond restart

2、at 定时任务

1、基本介绍

  1. at 命令是==一次性==定时计划任务at 的守护进程 atd 会以后台模式运行,检查作业队列来运行。

  2. 默认情况下,atd 守护进程每60 秒检查作业队列,有作业时,会检查作业运行时间,如果时间与当前时间匹配,则运行此作业。

  3. at 命令是一次性定时计划任务,执行完一个任务后不再执行此任务了

  4. 在使用at 命令的时候,一定要保证atd 进程的启动,可以使用相关指令来查看

    1
    2
    # 检测atd是否在运行
    ps -ef | grep atd

image-20210915170025630

2、at 命令格式

1
2
3
4
at [选项] [时间]

# 结束at命令的输入,输出两次
Ctrl + D

3、at 命令选项

选项 含义
-m 当指定任务被完成后,将给用户发送邮件,即使没有标准输出
-I atq的别名
-d atrm的别名
-v 显示任务将被执行的时间
-c 打印任务的内容到标准输出
-V 显示版本信息
-q<队列> 使用指定的任务队列
-f<文件> 从指定文件读入任务而不是从标准输入读入
-t<数据参数> 以时间参数的形式提交要运行的任务

4、at 时间定义

at 指定时间的方法:

  1. 接受在当天的 hh:mm(小时:分钟)式的时间指定。假如该时间已过去,那么就放在第二天执行。
    • 例如:04:00(凌晨用户较少)
  2. 使用 midnight(深夜)noon(中午)teatime饮茶时间(西方),一般是下午4 点)等比较模糊的词语来指定时间。
  3. 采用12 小时计时制,即在时间后面加上AM(上午)或PM(下午)来说明是上午还是下午
    • 例如:12pm
  4. 指定命令执行的具体日期,指定格式为 month day(月日)mm/dd/yy(月/日/年)dd.mm.yy(日.月.年)指定的日期必须跟在指定时间的后面
  • 例如:04:00 2021-03-1
  1. 使用相对计时法。指定格式为:now + count time-units
  • now 就是当前时间
  • time-units 是时间单位
    • 这里能够是minutes(分钟)、hours(小时)、days(天)、weeks(星期)。
  • count 是时间的数量,几天,几小时。
  • 例如:now + 5 minutes
  1. 直接使用today(今天)、tomorrow(明天)来指定完成命令的时间。

5、应用实例

  • 2天后的下午5点执行/bin/ls /home

    image-20210915171508956

    如果输入错误可通过 ctrl+backspace进行删除

  • atq 命令来查看系统中没有执行的工作任务

  • 明天17 点钟,输出时间到指定文件内比如/root/date100.log

    image-20210915171554271

  • 2 分钟后,输出时间到指定文件内比如/root/date200.log

    image-20210915171624032

  • 删除已经设置的任务:atrm 任务编号

    1
    2
    # 表示将job 队列,编号为4 的job 删除.
    atrm 4

8、Linux 磁盘分区、挂载

1、Linux 分区

1、原理介绍

  1. Linux 来说无论有几个分区,分给哪一目录使用,它归根结底就只有一个根目录,一个独立且唯一的文件结构, Linux中每个分区都是用来组成整个文件系统的一部分
  2. Linux 采用了一种叫“载入”的处理方法,它的整个文件系统中包含了一整套的文件和目录,且将一个分区和一个目录联系起来。这时要载入的一个分区将使它的存储空间在一个目录下获得。

image-20210915193714703

2、硬盘说明

  1. Linux 硬盘分 IDE 硬盘SCSI 硬盘目前基本上是SCSI 硬盘
  2. 对于IDE 硬盘,驱动器标识符为“hdx~”,其中“hd”表明分区所在设备的类型,这里是指IDE 硬盘了。
  • “x”为盘号(a 为基本盘,b 为基本从属盘,c 为辅助主盘,d 为辅助从属盘);
  • “~”代表分区,前四个分区用数字1到4表示,它们是主分区或扩展分区从5 开始就是逻辑分区。例:
    • hda3 表示为第一个IDE 硬盘上的第三个主分区或扩展分区
    • hdb2 表示为第二个IDE 硬盘上的第二个主分区或扩展分区。
  1. 对于SCSI 硬盘则标识为“sdx~”,SCSI 硬盘是用“sd”来表示分区所在设备的类型的,其余则和IDE 硬盘的表示方法一样

image-20210915172135886

3、查看所有设备挂载情况

1
2
3
lsblk
# 或者
lsblk -f

image-20210915172219842

image-20210915172230064

2、挂载的经典案例

1、说明

下面我们以增加一块硬盘为例来熟悉下磁盘的相关指令和深入理解磁盘分区、挂载、卸载的概念

2、如何增加一块硬盘

  1. 虚拟机添加硬盘
  2. 分区
  3. 格式化
  4. 挂载
  5. 设置可以自动挂载

3、虚拟机增加硬盘步骤1——添加硬盘

在【虚拟机】菜单中,选择【设置】,然后设备列表里添加硬盘,然后一路【下一步】,中间只有选择磁盘大小的地方需要修改,至到完成。然后重启系统(才能识别)!

image-20210915234034818

image-20210915234045074

image-20210915234214215

image-20210915234218241

image-20210915234222543

image-20210915234227448

4、虚拟机增加硬盘步骤2——分区

分区命令:

1
fdisk /dev/sdb

开始对/sdb 分区:

  • m:显示命令列表
  • p:显示磁盘分区同fdisk –l
  • n:新增分区
  • d:删除分区
  • w:写入并退出

说明: 开始分区后输入n,新增分区,然后选择p ,分区类型为主分区两次回车默认剩余全部空间。最后输入w写入分区并退出,若不保存退出输入q

image-20210915234326475

image-20210915234420513

image-20210915234556831

image-20210915234604554

5、虚拟机增加硬盘步骤3——格式化磁盘

分区命令:

1
mkfs -t ext4 /dev/sdb1

其中ext4 是分区类型。

在未格式化磁盘之前,刚分完区的分区是没有唯一的UUID的

image-20210915234831145

在格式化磁盘之后,给对应分区分配了唯一的UUID

image-20210915234901637

6、虚拟机增加硬盘步骤4——挂载

挂载:将一个分区与一个目录联系起来

1
2
3
4
mount 设备名称挂载目录

# eg
mount /dev/sdb1 /newdisk

卸载:解除一个分区与一个目录之间的联系

1
2
3
4
5
umount 设备名称或挂载目录

# eg
umount /dev/sdb1
umount /newdisk

细节:

  • 不要在设备名称文件或者挂载目录下进行卸载操作,否则会显示正在状态
  • 卸载后里面的文件依然在分区中,重新挂载一个目录就能够读取了。

分配UUID的分区是没有挂载在任何目录下的

image-20210915235211105

为分区和目录联系起来之后,也就是挂载之后。在挂载点会显示挂载的目录

image-20210915235225446

挂载后的Linux文件与硬盘存储的关系:

image-20210915235550868

7、虚拟机增加硬盘步骤5——自动挂载

用命令行挂载的分区和目录,重启后会失效。即:命令行的挂载只是临时的

永久挂载:通过修改/etc/fstab实现挂载,添加完成后执行mount –a 即刻生效或者重启之后也可以生效

image-20210915235457076

永久挂载时由于书写错误导致进入紧急模式的解决方法:重启后输入root密码回车,去到root终端,然后vim /etc/fstab,修改回正确的,然后reboot即可。

3、磁盘情况查询

1、查询系统整体磁盘使用情况

1、基本语法
1
df -h 
2、应用实例

查询系统整体磁盘使用情况

image-20210915235703077

注意:当已用占比达到80%以上,就需要适当清理分区或者增加分区的容量,以防出现分区容量不够导致运行出错。

2、查询指定目录的磁盘占用情况

1、基本语法
1
du -h 目录名称

细节:查询指定目录的磁盘占用情况,默认为当前目录(没写的时候)

2、相关选项
  • -s:指定目录占用大小汇总
  • -h:带计量单位
  • -a:含文件
  • --max-depth=1:子目录深度
  • -c:列出明细的同时,增加汇总值
3、应用实例

查询/opt 目录的磁盘占用情况,深度为1

image-20210916000113420

4、磁盘情况——工作实用指令

  1. 统计/opt 文件夹下文件的个数

    1
    ls -l /opt | grep "^-" | wc -l
    • "^-":正则表达式:表示文件
    • | grep "^-":过滤出文件夹里面的文件
    • wc -l:把过滤后的结果进行计数(wc命令)
  2. 统计/opt 文件夹下目录的个数

    1
    ls -l /opt | grep "^d" | wc -l
  • "^d":正则表达式:表示目录
  1. 统计/opt 文件夹下文件的个数,包括子文件夹里的

    1
    ls -lR /opt | grep "^-" | wc -l
    • -R:表示递归
  2. 统计/opt 文件夹下目录的个数,包括子文件夹里的

    1
    ls -lR /opt | grep "^d" | wc -l
  3. 以树状显示目录结构tree 目录, 注意,如果没有tree,则使用yum install tree 安装

    image-20210916000437067


9、Linux 网络配置

1、Linux 网络配置原理图

image-20210916002542475

2、查看网络IP 和网关

1、查看虚拟网络编辑器和修改IP 地址

image-20210916002622540

2、查看网关

image-20210916002650595

3、查看windows 环境的中VMnet8 网络配置

1、方式一:ipconfig 指令

image-20210916002854336

2、方式二:网络和共享中心

在Windows当中的 控制面板 => 所有控制面板项 => 网络和共享中心 => 更改适配器设置 => VMnet8(右键属性) => Internet 协议版本 4(TCP/IPv4)(属性)

image-20210916004102557

4、查看linux 的网络配置ifconfig

image-20210916004131535

5、ping 测试主机之间网络连通性

1、基本语法

功能描述:测试当前服务器是否可以连接目的主机:

1
ping 目的主机
2、应用实例

测试当前服务器是否可以连接百度:

1
ping www.baidu.com

6、linux 网络环境配置

1、第一种方法(自动获取)

说明:登陆后,通过界面的来设置自动获取ip。

优缺点:

  • 优点:linux 启动后会自动获取IP,可以有效的防止IP冲突问题
  • 缺点:每次自动获取的ip 地址可能不一样,不能作为线上的服务器

image-20210916004955709

2、第二种方法(指定ip)

说明:直接修改配置文件来指定IP,并可以连接到外网(程序员推荐)

编辑:

1
vim /etc/sysconfig/network-scripts/ifcfg-ens33

要求:将ip 地址配置的静态的

比如:ip 地址为192.168.200.130

ifcfg-ens33 文件说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 接口名(设备,网卡)
DEVICE=eth0
# MAC 地址
HWADDR=00:0C:2x:6x:0x:xx
# 网络类型(通常是Ethemet)
TYPE=Ethernet
# 随机id
UUID=926a57ba-92c6-4231-bacb-f27e5e6a9f44
# 系统启动的时候网络接口是否有效(yes/no)
ONBOOT=yes
# IP 的配置方法[none|static|bootp|dhcp](引导时不使用协议|静态分配IP:指定分配|BOOTP 协议|DHCP 协议:自动分配)
BOOTPROTO=static
# IP 地址
IPADDR=192.168.200.130
# 子网掩码
NETMASK=255.255.255.0
# 网关
GATEWAY=192.168.200.2
# 域名解析器
DNS1=192.168.200.2

重启网络服务或者重启系统生效:

1
2
3
service network restart
# 或者
reboot

3、设置主机名和hosts 映射

1、设置主机名

  1. 为了方便记忆,可以给 linux 系统设置主机名,也可以根据需要修改主机名
  2. 指令hostname:查看主机名
  3. 修改文件在/etc/hostname 指定
  4. 修改后,重启生效

2、设置hosts 映射

思考:如何通过主机名能够找到(比如ping) 某个linux 系统?

  • Windows
    • 在C:\Windows\System32\drivers\etc\hosts 文件指定即可
    • 案例:192.168.200.130 hspedu100
  • Linux
    • 在/etc/hosts 文件指定
    • 案例:192.168.200.1 ThinkPad-PC
      • IP:192.168.200.1
      • 主机名:ThinkPad-PC(可以在控制面板所有控制面板系统查看Windows的主机名)

4、主机名解析过程分析(Hosts、DNS)

1、Hosts 是什么

一个文本文件,用来记录IP 和Hostname(主机名)的映射关系

2、DNS

DNS,就是 Domain Name System 的缩写,翻译过来就是域名系统

是互联网上作为域名和IP 地址相互映射的一个分布式数据库

3、应用实例

用户在浏览器输入了www.baidu.com

  1. 浏览器先检查浏览器缓存中有没有该域名解析IP 地址,有就先调用这个IP 完成解析;如果没有,就检查DNS解析器缓存,如果有直接返回IP 完成解析。这两个缓存,可以理解为本地解析器缓存

  2. 一般来说,当电脑第一次成功访问某一网站后,在一定时间内,浏览器或操作系统会缓存他的IP 地址(DNS 解析记录).如在cmd 窗口中输入

    1
    2
    3
    4
    5
    # DNS 域名解析缓存
    ipconfig /displaydns

    # 手动清理dns 缓存
    ipconfig /flushdns
  3. 如果本地解析器缓存没有找到对应映射,检查系统中hosts 文件中有没有配置对应的域名IP 映射,如果有,则完成解析并返回。

  4. 如果本地DNS 解析器缓存和hosts 文件中均没有找到对应的IP,则到域名服务DNS 进行解析域

image-20210916013037834

注意:DNS域名劫持问题——运营商劫持,往里面注入一些广告之类的东西


10、Linux 进程管理

1、基本介绍

  1. 在LINUX 中,每个执行的程序都称为一个进程。每一个进程都分配一个ID 号(pid,进程号)。
  2. 每个进程都可能以两种方式存在的。前台与后台,所谓前台进程就是用户目前的屏幕上可以进行操作的。后台进程则是实际在操作,但由于屏幕上无法看到的进程,通常使用后台方式执行
  3. 一般系统的服务都是以后台进程的方式存在,而且都会常驻在系统中。直到关机才结束

image-20210916224137272

2、显示系统执行的进程

1、基本介绍

ps 命令是用来查看目前系统中,有哪些正在执行,以及它们执行的状况。可以不加任何参数。

image-20210916235328878

只是不带参数能够才查看的信息有限。

2、ps 详解

指令:

1
2
3
4
ps –aux | grep xxx

# eg:比如我看看有没有sshd服务
ps –aux | grep sshd

image-20210916235620566

指令说明:

System V 展示风格

  • USER:用户名称
  • PID:进程号
  • %CPU:进程占用CPU 的百分比
  • %MEM:进程占用物理内存的百分比
  • VSZ:进程占用的虚拟内存大小(单位:KB)
  • RSS:进程占用的物理内存大小(单位:KB)
  • TTY:终端名称,缩写.
  • STAT:进程状态,其中:
    • S:睡眠
    • s:表示该进程是会话的先导进程
    • N:表示进程拥有比普通优先级更低的优先级
    • R:正在运行
    • D:短期等待
    • Z:僵死进程
    • T:被跟踪或者被停止等等
  • STARTED:进程的启动时间
  • TIME:CPU 时间,即进程使用CPU 的总时间
  • COMMAND:启动进程所用的命令和参数,如果过长会被截断显示

3、应用实例

要求:以全格式显示当前所有的进程,查看进程的父进程。查看sshd 的父进程信息

ps -ef 是以全格式显示当前所有的进程

  • -e:显示所有进程
  • -f:全格式
1
ps -ef|grep sshd

image-20210917135017769

是BSD 风格:

  • UID:用户ID
  • PID:进程ID
  • PPID:父进程ID
  • CCPU 用于计算执行优先级的因子。数值越大,表明进程是CPU 密集型运算,执行优先级会降低;数值越小,表明进程是I/O 密集型运算,执行优先级会提高
  • STIME:进程启动的时间
  • TTY:完整的终端名称
  • TIME:CPU 时间
  • CMD:启动进程所用的命令和参数

3、终止进程kill 和killall

1、介绍

若是某个进程执行一半需要停止时,或是已消了很大的系统资源时,此时可以考虑停止该进程。使用kill 命令来完
成此项任务。

2、基本语法

1
2
3
4
5
# 通过进程号杀死/终止进程
kill [选项] 进程号

# 通过进程名称杀死进程,也支持通配符,这在系统因负载过大而变得很慢时很有用
killall 进程名称

3、常用选项

  • -9:表示强迫进程立即停止

4、最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
# 踢掉某个非法登录用户,kill 进程号
kill 11421

# 终止远程登录服务sshd, 在适当时候再次重启sshd 服务
kill sshd 对应的进程号
/bin/systemctl start sshd.service

# 终止多个gedit , 演示killall
killall gedit

# 强制杀掉一个终端,
kill -9 bash 对应的进程号

4、查看进程树pstree

1、基本语法

1
2
# 可以更加直观的来看进程信息
pstree [选项]

注:如果没有安装pstree,可以使用yum -y install psmisc 安装pstree

2、常用选项

  • -p:显示进程的PID
  • -u:显示进程的所属用户

3、应用实例

1
2
3
4
5
# 请你树状的形式显示进程的pid
pstree -p

# 请你树状的形式进程的用户
pstree -u

image-20210917135709624

5、服务(service)管理

1、介绍

服务(service) 本质就是进程,但是是运行在后台的,通常都会监听某个端口,等待其它程序的请求,比如(mysqld , sshd防火墙等),因此我们又称为守护进程,是Linux 中非常重要的知识点。

image-20210917135746237

注:在Linux当中,在一个服务后面加上一个’d’表示当前服务是在后台运行的,’d’表示该进程是守护进程(daemon)

  • 如:mysqld、sshd

2、service 管理指令

  1. service 服务名[start | stop | restart | reload | status]
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24

    2. 在CentOS7.0 后很多服务不再使用service,而是`systemctl`

    3. service 指令管理的服务在/etc/init.d 查看

    ![image-20210917135951061](Linux/image-20210917135951061.png)



    #### 3、service 管理指令案例

    请使用service 指令,查看,关闭,启动network [注意:在虚拟系统演示,因为网络连接会关闭]。

    相关指令:

    ```sh
    # 查看network的状态
    service network status

    # 停止network服务
    service network stop

    # 开启network服务
    service network start

4、查看服务名

1、方式一:使用setup -> 系统服务就可以看到全部
1
setup

image-20210917140137313

注:按下空格可以进行开启或关闭

2、方式二:/etc/init.d 看到service 指令管理的服务
1
ls -l /etc/init.d

5、服务的运行级别(runlevel)

1、Linux 系统的7 种运行级别(runlevel)

Linux 系统有7 种运行级别(runlevel):常用的是级别3 和5

  • 运行级别0:系统停机状态,系统默认运行级别不能设为0,否则不能正常启动
  • 运行级别1:单用户工作状态,root 权限,用于系统维护,禁止远程登陆
  • 运行级别2:多用户状态(没有NFS),不支持网络
  • 运行级别3:完全的多用户状态(有NFS),无界面,登陆后进入控制台命令行模式
  • 运行级别4:系统未使用,保留
  • 运行级别5:X11 控制台,登陆后进入图形GUI 模式
  • 运行级别6:系统正常关闭并重启,默认运行级别不能设为6,否则不能正常启动

开机的流程说明:

image-20210917140408756

2、CentOS7 后运行级别说明
1
2
3
4
5
6
7
8
9
10
# 在/etc/initab进行了简化,如下:
multi-user.target: analogous to runlevel 3
graphical.target: analogous to runlevel 5
init 0

# To view current default target, run:
systemctl get-default

# To set a default target, run:
systemctl set-default TARGET.target

6、chkconfig 指令

1、介绍
  • 通过chkconfig 命令可以给服务的各个运行级别设置自启动/关闭
  • chkconfig 指令管理的服务在/etc/init.d 查看
  • 注意:Centos7.0 后,很多服务使用systemctl 管理
2、chkconfig 基本语法
1
2
3
4
5
6
# 查看服务
chkconfig --list [| grep xxx]

chkconfig 服务名--list

chkconfig --level 5 服务名on/off

image-20210917140802260

3、案例演示

对network 服务进行各种操作,把network 在3 运行级别,关闭自启动

1
2
3
chkconfig --level 3 network off

chkconfig --level 3 network on
4、使用细节

chkconfig 重新设置服务后自启动或关闭,需要重启机器reboot 生效。

7、systemctl 管理指令

1、基本语法
1
systemctl [start | stop | restart | status] 服务名

systemctl 指令管理的服务在/usr/lib/systemd/system 查看

2、systemctl 设置服务的自启动状态
1
2
3
4
5
6
7
8
9
10
11
# 查看服务开机启动状态, grep 可以进行过滤
systemctl list-unit-files [ | grep 服务名]

# 设置服务开机启动
systemctl enable 服务名

# 关闭服务开机启动
systemctl disable 服务名

# 查询某个服务是否是自启动的
systemctl is-enabled 服务名
3、应用案例

查看当前防火墙的状况,关闭防火墙和重启防火墙

  1. 怎么查看防火墙服务:(其他服务类似)

    1
    ls -l /usr/lib/systemd/system/ | grep fire
  2. 查看当前防火墙的状况

    1
    systemctl status firewalld
  3. 关闭防火墙和重启防火墙

    1
    2
    3
    systemctl stop firewalld

    systemctl start firewalld
4、细节讨论
  • 关闭或者启用防火墙后,立即生效。[telnet 测试某个端口即可]
  • 这种方式只是临时生效,当重启系统后,还是回归以前对服务的设置
  • 如果希望设置某个服务自启动或关闭永久生效,要使用systemctl [enable|disable] 服务名
    • systemctl enable/disable是设置服务是否自启动,对服务永久生效;
    • systemctl stop/start是设置当前服务的状态,重启后服务的状态会被重置

在关闭防火墙之后,查询发现还是防火墙还是开机自启动的

image-20210917141427623

只有使用systemctl [enable|disable] 服务名设置了以后,才能设置防火墙开机自启动关闭永久生效

image-20210917141603231

5、关于防火墙

防火墙就是在外界申请的请求进入Linux系统之前进行拦截一道“城墙”,可以防止外界无脑对Linux服务进行访问。只对防护墙中开放的端口的服务才能进行访问,那些关闭的端口,外界并不能得到Linux的对应服务。

image-20210917141636006

那么怎么得到关闭端口的对应的Linux的相关服务呢?

  1. 方法1:关闭防火墙服务
  2. 方法2:开放关闭的端口

8、打开或者关闭指定端口

1、简介

在真正的生产环境,往往需要将防火墙打开,但问题来了,如果我们把防火墙打开,那么外部请求数据包就不能跟
服务器监听端口通讯。这时,需要打开指定的端口。比如80、22、8080 等,这个又怎么做呢?

image-20210917142336317

2、firewall 指令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 打开端口
firewall-cmd --permanent --add-port=端口号/协议

# 关闭端口
firewall-cmd --permanent --remove-port=端口号/协议

# 重新载入,才能生效
firewall-cmd --reload

# 查询端口是否开放
firewall-cmd --query-port=端口/协议

# 查看已经开放的端口
firewall-cmd --zone=public --list-ports

对于协议的查看:

1
nestat -anp | more

image-20210917143103427

3、应用案例
  1. 启用防火墙, 测试111 端口是否能telnet——不行

    image-20210917142920490

  2. 开放111 端口,测试111 端口是否能telnet——行

    1
    2
    3
    4
    5
    # 开放111 端口
    firewall-cmd --permanent --add-port=111/tcp

    # 重新载入,才能生效
    firewall-cmd --reload
  3. 再次关闭111 端口,测试111 端口是否能telnet——不行

    1
    2
    3
    4
    5
    # 关闭111 端口
    firewall-cmd --permanent --remove-port=111/tcp

    # 重新载入,才能生效
    firewall-cmd --reload

9、动态监控进程

1、介绍

top 与ps 命令很相似。它们都用来显示正在执行的进程

top 与ps 最大的不同之处,在于top 在执行一段时间可以更新正在运行的的进程

2、基本语法
1
top [选项]

image-20210917145802831

  • 20:10:57:当前时间
  • 4:47:系统运行时间
  • 2 users:用户数量
  • load average: 0.00, 0.01, 0.05:负载值
    • 这是负载均衡的三个平均负载值,分别代表着系统在1、5、15分钟的负载值
    • 要是这三个负载值求平均值后数值超过0.7,就清除当前的系统的负载比较大了,就需要对它的性能进行提升
  • Tasks: 185 total:当前总任务数
  • 1 running,184 sleeping,0 stopped,0 zombie
    • 1 running:1个进程正在运行
    • 184 sleeping:184个进程正在休眠
    • 0 stopped:0个进程停止
    • 0 zombie:0个僵尸进程
      • 注意:僵尸进程指定是那些已经执行结束但是却没有被系统回收的进程
      • 出现大量僵尸进程要十分小心,因为系统不能回收那些执行结束的进程,僵尸进程会占用系统的空间而且是无效的,这样会对系统产生影响。
      • 需要检查哪里出现了问题
    • %Cpu(s):CPU的占用情况(单位:s)
    • 1.3 us,5.1 sy,0.0 ni,93.6 id,0.0 wa,0.0 hi,0.0 si,0.0 st
      • 1.3 us:用户占用时间
      • 5.1 sy:系统占用时间
      • 0.0 ni:ni(Niceness),用户进程空间内改变过优先级的进程占用CPU百分比
      • 93.6 id:id(idle),CPU空闲
    • KiB Mem:内存占用情况(单位:kb)
    • 2028116 total,1550960 free,191704 used,285452 buff/cache
      • 2028116 total:总内存
      • 1550960 free:空闲内存
      • 191704 used:已用内存
      • 285452 buff/cache:缓存内存
    • KiB Swap:交换分区的占用情况(单位:kb)
    • 2097148 total,2097148 free,0 used,1631792 avail Mem
      • 2097148 total:总内存
      • 2097148 free:空闲内存
      • 0 used:已用内存
      • 1631792 avail Mem:可获取的内存

注意:主要观察的就是CPU占用情况、内存占用情况和僵尸进程的数量

  • 要是CPU占用或内存占用超过70%或80%,则需要查看当前是否存在内存泄露或者提升一下系统的性能
3、选项说明
选项 功能
-d 秒数 指定top每隔几秒更新。默认是3s。修改:top -d 10
-i 使top不显示任何闲置或僵尸进程
-p 通过指定监控进程ID来仅仅监控某个进程的状态
4、交互操作说明
操作 功能
P 以CPU使用率排序,默认就是此项
M 以内存的使用率排序
N 以PID排序
q 退出top
5、应用实例
1
2
3
4
5
6
7
8
9
10
# 监视特定用户, 比如我们监控tom 用户
top:输入此命令,按回车键,查看执行的进程。
u:然后输入“u”回车,再输入用户名,即可,

# 终止指定的进程, 比如我们要结束tom 登录
top:输入此命令,按回车键,查看执行的进程。
k:然后输入“k”回车,再输入要结束的进程ID 号

# 指定系统状态更新的时间(每隔10 秒自动更新), 默认是3 秒
top -d 10

10、监控网络状态

1、查看系统网络情况——netstat
1、基本语法
1
netstat [选项]
2、选项说明
  • -an:按一定顺序排列输出
  • -p:显示哪个进程在调用
3、应用案例

请查看服务名为sshd 的服务的信息。

1
netstat -anp | grep sshd

image-20210917153128170

4、原理说明
1
nestat -anp | more

image-20210917143757254

原理图:

image-20210917143130420

当tom用户logout之后,当前的连接状态不会立即消失,而是进入TINE_WAIT状态

注:这是tcp协议防止客户端方面因为网络不好而暂时断开连接,会立即断开与客户端的连接,而是会进入一个30s或者一分钟的TINE_WAIT(不同操作系统不一样)。超过TINE_WAIT时间后才会真正消失

image-20210917144023265

2、检测主机连接命令——ping

是一种网络检测工具,它主要是用检测远程主机是否正常,或是两部主机间的网线或网卡故障。

1
ping 对方ip 地址

11、RPM 与 YUM

1、rpm 包的管理

1、介绍

rpm 用于互联网下载包的打包及安装工具,它包含在某些Linux 分发版中。它生成具有.RPM 扩展名的文件。RPM
是RedHat Package Manager(RedHat 软件包管理工具)的缩写,类似windows 的setup.exe,这一文件格式名称虽然打上了RedHat 的标志,但理念是通用的。

Linux 的分发版本都有采用(suse,redhat, centos 等等),可以算是公认的行业标准了。

2、rpm 包的简单查询指令

查询已安装的rpm 列表

1
2
3
4
rpm –qa | grep xx

# eg:看看当前系统,是否安装了firefox
rpm -qa | grep firefox

image-20210918001137522

3、rpm 包名基本格式

一个rpm 包名:firefox-60.2.2-1.el7.centos.x86_64

  • 名称:firefox
  • 版本号:60.2.2-1
  • 适用操作系统:el7.centos.x86_64
    • 表示centos7.x 的64 位系统
    • 如果是i686、i386 表示32 位系统
    • noarch 表示通用

4、rpm 包的其它查询指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 查询所安装的所有rpm 软件包
rpm -qa
rpm -qa | more
rpm -qa | grep X

# 查询软件包是否安装
rpm -q 软件包名

# eg:查询火狐是否安装
rpm -q firefox

# 查询软件包信息
rpm -qi 软件包名

# eg
rpm -qi firefox

# 查询软件包中的文件
rpm -ql 软件包名

# eg
rpm -ql firefox

# 查询文件所属的软件包
rpm -qf 文件全路径名

# eg
rpm -qf /etc/passwd
rpm -qf /root/install.log

image-20210918001146493

5、卸载rpm 包

1、基本语法
1
2
3
4
5
# -e:erase擦除
rpm -e RPM 包的名称

# eg:删除firefox 软件包
rpm -e firefox
2、细节讨论

如果其它软件包依赖于您要卸载的软件包,卸载时则会产生错误信息。

1
rpm -e foo
1
removing these packages would break dependencies:foo is needed by bar-1.0-1

如果我们就是要删除 foo 这个rpm 包,可以增加参数 --nodeps ,就可以强制删除,但是一般不推荐这样做,因为依赖于该软件包的程序可能无法运行

1
rpm -e --nodeps foo

6、安装rpm 包

1、基本语法
1
rpm -ivh RPM 包全路径名称
2、参数说明
  • i=install:安装
  • v=verbose:提示
  • h=hash:进度条
3、应用实例
1
2
3
4
5
6
# 演示卸载和安装firefox 浏览器
# 卸载
rpm -e firefox

# 安装
rpm -ivh firefox

2、yum

1、介绍

Yum 是一个 Shell 前端软件包管理器。基于RPM 包管理,能够从指定的服务器自动下载RPM 包并且安装,可以自动处理依赖性关系,并且一次安装所有依赖的软件包。

与Maven有点像

2、yum 的基本指令

查询 yum 服务器是否有需要安装的软件

1
yum list|grep xx 软件列表

3、安装指定的yum 包

1
yum install xxx 下载安装

4、yum 应用实例

请使用yum 的方式来安装firefox

1
2
3
4
5
6
7
8
# 先卸载firefox
rpm -e firefox

# 使用yum list查看 yum 服务器当中有没有firefox的的rpm包
yum list | grep firefox

# 下载安装
yum install firefox

12、Linux 搭建 JavaEE 环境

1、概述

如果需要在Linux 下进行JavaEE 的开发,我们需要安装如下软件

image-20210918001951211

2、安装JDK

1、安装步骤

  1. # 把JDK下载到opt目录下
    mkdir /opt/jdk
    
    1
    2
    3
    4
    5
    6

    2. 通过xftp6 上传到/opt/jdk 下

    3. ```sh
    # 进入该目录
    cd /opt/jdk
  2. # 解压传入的JDK的tar.gz包
    tar -zxvf jdk-8u261-linux-x64.tar.gz
    
    1
    2
    3
    4

    5. ```sh
    # 把解压的tar.gz在usr目录的local目录下
    mkdir /usr/local/java
  3. # 把解压的tar.gz移动到已经创建好的目录下
    mv /opt/jdk/jdk1.8.0_261 /usr/local/java
    
    1
    2
    3
    4

    7. ```sh
    # 配置环境变量的配置文件
    vim /etc/profile
    只有配置了环境变量,才能在任何目录下启动java服务 Linux查找服务的过程: 1. 先在当前目录下进行查找 2. 然后到/etc/profile配置文件当中查找 3. 以上两步都找不到的话,返回找不到服务
  4. 在/etc/profile配置文件当中的配置(在最后加入以下两行配置)

    1
    2
    export JAVA_HOME=/usr/local/java/jdk1.8.0_261
    export PATH=$JAVA_HOME/bin:$PATH
  5. 让新的环境变量生效

    1
    source /etc/profile

2、测试是否安装成功

在任何目录下输入

1
java -version

3、tomcat 的安装

1、步骤

  1. 上传安装文件,并解压缩到

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 把JDK下载到opt目录下
    mkdir /opt/tomcat

    # 通过xftp6 上传到/opt/jdk 下,进入该目录
    cd /opt/jdk

    # 解压缩
    tar -zxvf apache-tomcat-8.5.59.tar.gz

    # 把解压的tar.gz在usr目录的local目录下
    mkdir /usr/local/tomcat
    mv /opt/tomcat/apache-tomcat-8.5.59 /usr/local/java
  2. 进入解压目录/bin , 启动tomcat

    1
    ./startup.sh
  3. 开放端口8080

    1
    2
    3
    firewall-cmd --permanent --add-port=8080/tcp
    firewall-cmd --reload
    firewall-cmd --query-port=8080/tcp

2、测试是否安装成功

在windows、Linux 下访问http://linuxip:8080

image-20210918004936050

4、idea2020 的安装

步骤

  1. 下载地址: https://www.jetbrains.com/idea/download/#section=windows
  2. 解压缩到/opt/idea
  3. 把解压过后的目录移动到 /usr/local/idea2020下,启动idea bin 目录下./idea.sh,配置jdk
    • 注意:这里只有在图形化界面才能启动成功,在命令行模式下不能成功
  4. 编写Hello world 程序并测试成功!

5、mysql5.7 的安装(!!)

  1. 新建文件夹/opt/mysql,并cd进去

  2. 运行wget http://dev.mysql.com/get/mysql-5.7.26-1.el7.x86_64.rpm-bundle.tar,下载mysql安装包

    • 这个mysql安装包很大,你要忍一下
  3. centos7.6自带的类mysql数据库是mariadb,会跟mysql冲突,要先删除

  4. # 使用tar进行解压
    tar -xvf mysql-5.7.26-1.el7.x86_64.rpm-bundle.tar 
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18

    注意:这里的选项没有-z,因为该文件不是tar.gz文件,只是一个tar文件

    5. 运行 `rpm -qa|grep mari`,查询mariadb相关安装包

    ![image-20210918005511185](Linux/image-20210918005511185.png)

    6. 运行rpm -e --nodeps mariadb-libs,卸载

    7. 运行rpm -e --nodeps marisa,卸载

    8. 然后开始真正安装mysql,依次运行以下几条

    ```sh
    rpm -ivh mysql-community-common-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-libs-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-client-5.7.26-1.el7.x86_64.rpm
    rpm -ivh mysql-community-server-5.7.26-1.el7.x86_64.rpm
    如果第四条报错, 可能是因为缺少libaio
    1
    yum install li'baio
  5. 运行systemctl start mysqld.service,启动mysql

  6. 然后开始设置root用户密码

    • Mysql自动给root用户设置随机密码,运行 grep "password" /var/log/mysqld.log 可看到当前密码

      image-20210918005706896

  7. 运行mysql -u root -p,用root用户登录,提示输入密码可用上述的,可以成功登陆进入mysql命令行

  8. 设置root密码,对于个人开发环境,如果要设比较简单的密码(生产环境服务器要设复杂密码),可以运行

    1
    2
    -- 提示密码设置策略
    set global validate_password_policy=0;

    validate_password_policy默认值1

    image-20210918005849451

    1
    2
    # 设置mysql的密码
    set password for 'root'@'localhost' =password('your_paddword');
  9. 运行flush privileges;使密码设置生效

关于mysql密码的设置:

如果设置的密码不能满足当前设置的密码设置策略,会出现:

1
Your password does not satisfy the current policy requirements
  • 注意密码设置的不合格;使用ALTER USER命令就能重新设置合格密码。
  • 密码默认8位。不能低于8位
  • mysql8的新版本在第一次进入MySQL时是不能修改密码强度的,必须先设置符合条件的密码,之后再该密码强度,再改密码

13、Shell 编程

1、为什么要学习Shell 编程

  1. Linux 运维工程师在进行服务器集群管理时,需要编写Shell 程序来进行服务器管理
  2. 对于JavaEE 和Python 程序员来说,工作的需要,你的老大会要求你编写一些Shell 脚本进行程序或者是服务器的维护,比如编写一个定时备份数据库的脚本
  3. 对于大数据程序员来说,需要编写Shell 程序来管理集群

2、Shell 是什么

Shell 是一个命令行解释器,它为用户提供了一个向Linux 内核发送请求以便运行程序的界面系统级程序,用户可以用Shell 来启动、挂起、停止甚至是编写一些程序。

image-20210918010320762

3、Shell 脚本的执行方式

1、脚本格式要求

  1. 脚本以#!/bin/bash 开头
    • 在Linux当中不止一个shell命令解释器,只是国内用得最多的是bash
  2. 脚本需要有可执行权限(-x)

2、编写第一个Shell 脚本

需求说明:创建一个Shell 脚本,输出hello world!

1
vim hello.sh
  • 大家公认的shell脚本以sh为后缀
  • 在sh脚本当中,注释使用的是’#’
1
2
#!/bin/bash
echo "hello,world~"

3、脚本的常用执行方式

1、方式1:给脚本-x权限

说明:首先要赋予helloworld.sh 脚本的+x 权限, 再执行脚本

1
2
chmod u+x hello.sh
hello.sh
2、方式2:sh+脚本

说明:不用赋予脚本+x 权限,直接执行即可。

1
sh hello.sh

4、Shell 的变量

1、Shell 变量介绍
  1. Linux Shell 中的变量分为,系统变量用户自定义变量
  2. 系统变量:$HOME、$PWD、$SHELL、$USER 等等,比如: echo $HOME 等等
  3. 显示当前shell 中所有变量:set
2、shell 变量的定义
1、基本语法
  1. 定义变量:变量名=值

  2. 撤销变量:unset 变量

  3. 声明静态变量readonly 变量

    • 注意:静态变量不能unset进行撤销

      image-20210918012236177

2、快速入门
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
# 案例1:定义变量A
A=100
# 输出变量需要加上$
echo A=$A
echo "A=$A"

# 案例2:撤销变量A
unset A
echo "A=$A"

# 案例3:声明静态的变量B=2,不能unset
readonly B=2
echo "B=$B"
# uset不能撤销静态变量
#unset B

# 案例4:返回当前日期
:<<!
C=`date`
D=$(date)
echo "C=$C"
echo "D=$D"
!

细节:

  1. 在Linux当中不要随便加空格

    • 如在定义变量的时候变量名称与值之间的’=’左右不能有空格
  2. 输出变量需要加上’$’,如$A

  3. $表示:让shell解释器知道后面是一个变量,会转换为该变量的值,否则当做文本处理

    • 如:

      1
      2
      3
      4
      5
      6
      A=100
      echo A=$A
      # 或者
      echo "A=$A"

      echo "A=A"
    • Linux输出:

      1
      2
      3
      A=100
      A=100
      A=A
  4. 如果$后面的变量不存在或者已被销毁,则为空

    • 如Linux输出:A=
  5. 将命令的返回值赋给变量

    • A=`date`反引号,运行里面的命令,并把结果返回给变量A
    • A=$(date) 等价于反引号
  6. shell 脚本的多行注释

    • :<<! 内容 !
    • 注意:开头的:<<!与结尾的!都要另起一行
3、shell 定义变量的规则
  1. 变量名称可以由字母、数字和下划线组成,但是不能以数字开头
    • _A(√)
    • 5A=200(×)
  2. 等号两侧不能有空格
  3. 变量名称一般习惯为大写, 这是一个规范,我们遵守即可
3、设置环境变量
1、基本语法
1
2
3
4
5
6
7
8
# 功能描述:将shell 变量输出为环境变量/全局变量
export 变量名=变量值

# 功能描述:让修改后的配置信息立即生效
source 配置文件

# 功能描述:查询环境变量的值
echo $变量名
2、快速入门
  1. 在/etc/profile 文件中定义 TOMCAT_HOME 环境变量

  2. 查看环境变量TOMCAT_HOME 的值

    image-20210918012319903

  3. 在另外一个shell 程序中使用TOMCAT_HOME

    1
    2
    3
    #!/bin/bash
    # 使用环境变量TOMCAT_HOME
    echo "tomcat_home=$TOMCAT_HOME"
  4. 注意:在输出TOMCAT_HOME 环境变量前,需要让其生效

    1
    source /etc/profile
4、位置参数变量
1、介绍

当我们执行一个shell 脚本时,如果希望获取到命令行的参数信息,就可以使用到位置参数变量

比如: ./myshell.sh 100 200 , 这个就是一个执行shell 的命令行,可以在myshell 脚本中获取到参数100 200的信息

2、基本语法
  • $n:功能描述:n 为数字
    • $0 代表命令本身,$1-$9 代表第一到第九个参数;
    • 十以上的参数,十以上的参数需要用大括号包含,如${10}
  • $*:功能描述:这个变量代表命令行中所有的参数,$*把所有的参数看成一个整体
  • $@:功能描述:这个变量也代表命令行中所有的参数,不过$@把每个参数区分对待
  • $#:功能描述:这个变量代表命令行中所有参数的个数
3、位置参数变量案例

案例:编写一个shell 脚本position.sh , 在脚本中获取到命令行的各个参数信息。

1
2
3
4
5
#!/bin/bash
echo "0=$0 1=$1 2=$2"
echo "所有的参数=$*"
echo "$@"
echo "参数的个数=$#"

image-20210918012853571

5、预定义变量
1、基本介绍

就是shell 设计者事先已经定义好的变量,可以直接在shell 脚本中使用

2、基本语法
  1. $$:功能描述:当前进程的进程号(PID)
  2. $!:功能描述:后台运行的最后一个进程的进程号(PID)
  3. $?:功能描述:最后一次执行的命令的返回状态
  • 如果这个变量的值为0,证明上一个命令正确执行
  • 如果这个变量的值为非0(具体是哪个数,由命令自己来决定),则证明上一个命令执行不正确了
3、应用实例

在一个shell 脚本中简单使用一下预定义变量

1
2
3
4
5
6
#!/bin/bash
echo "当前执行的进程id=$$"
# 以后台的方式运行一个脚本,并获取他的进程号
/root/shcode/myshell.sh &
echo "最后一个后台方式运行的进程id=$!"
echo "执行的结果是=$?"

细节:上面脚本的第四行的’&’表示以后台方式运行脚本

image-20210918013732542

5、运算符

1、基本介绍

学习如何在shell 中进行各种运算操作。

2、基本语法
  1. 如果要执行的运算式:

    1. $((运算式))
    2. $[运算式]
    3. expr m + n
      • expr是expression:表达式
  2. 注意:

    • expr 运算符间要有空格

    • 如果希望将expr 的结果赋给某个变量,使用``

      1
      TEMP=`expr 2 + 3`
  3. expr m - n

    • 注意:m、n与运算符之间要有空格,否则shell解释器会把m-n当成一个文本
  4. 运算符

    • \*:乘
      • 注意:这里的乘法需要转义字符\,否则语法报错
    • /:除
    • % :取余
3、应用实例
  1. 案例1:计算(2+3)X4 的值
  2. 案例2:请求出命令行的两个参数[整数]的和20 50
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
# 案例1:计算(2+3)X4 的值
# 使用第一种方式
RES1=$(((2+3)*4))
echo "res1=$RES1"

# 使用第二种方式, 推荐使用
RES2=$[(2+3)*4]
echo "res2=$RES2"

# 使用第三种方式expr
TEMP=`expr 2 + 3`
RES4=`expr $TEMP \* 4`
echo "temp=$TEMP"
echo "res4=$RES4"

# 案例2:请求出命令行的两个参数[整数]的和20 50
SUM=$[$1+$2]
echo "sum=$SUM"

细节:注意上面脚本的第13行的乘法需要转义字符\,否则语法报错

image-20210918013941637

6、条件判断

1、判断语句

基本语法:

  • [ condition ]
    • 注意condition 前后要有空格
  • condition非空返回true,可使用$?验证
    • 0 为true,>1 为false
2、应用实例
  • [ hspEdu ]:返回true

  • [ ]:返回false

  • [ condition ] && echo OK || echo notok:条件满足,执行后面的语句

  • 也可以使用

    1
    2
    3
    4
    if [ condition ]
    then
    执行语句
    fi
  • if开始,then执行,fi结束

  • 注意:

    1. 如果[ condition ]当中的condition是一个空,也需要加一个空格,否则语法报错

      1
      2
      if [](×)
      if [ ](√)
    2. 如果condition当中有任何值,如[ abc ],则condition不为空,非空返回true

3、判断语句

常用判断条件:

  • = 字符串比较
  • 两个整数的比较
    • -lt:小于(less then)
    • -le:小于等于(less or equal)
    • -eq:等于(equal)
    • -gt:大于(greater than)
    • -ge:大于等于(greater or equal)
    • -ne:不等于(not equal)
  • 按照文件权限进行判断
    • -r:有读的权限
    • -w:有写的权限
    • -x:有执行的权限
  • 按照文件类型进行判断
    • -f:文件存在并且是一个常规的文件
    • -e:文件存在
    • -d:文件存在并是一个目录
4、应用实例
  1. 案例1:”ok”是否等于”ok”
    • 判断语句:使用=
  2. 案例2:23 是否大于等于22
    • 判断语句:使用-ge
  3. 案例3:/root/shcode/aaa.txt 目录中的文件是否存在
    • 判断语句: 使用-f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# 案例1: "ok"是否等于"ok"
# 判断语句:使用=
if [ "ok" = "ok" ]
then
echo "equa 1"
fi

# 案例2: 23是否大于等于22
# 判断语句:使用-ge
if[ 23 -ge 22 ]
then
echo "大于”
fi

# 案例3: /root/shcode/aaa.txt 目录中的文件是否存在
# 判断语句: 使用-f
if [ -f /root/shcode/aaa.txt ]
then
echo "存在"
fi

# condition当中有任何值,则condition不为空,非空返回true
if [ hspedu ]
then
# 一定执行
echo "hello ,hspedu"
fi

7、流程控制

1、if 判断

基本语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
if [ 条件判断式 ]
then
代码
fi

# 或者:多分支
if [ 条件判断式 ]
then
代码
elif [ 条件判断式 ]
then
代码
fi

注意事项:[ 条件判断式 ],中括号和条件判断式之间必须有空格

应用实例:请编写一个shell 程序,如果输入的参数,大于等于60,则输出”及格了”,如果小于60,则输出”不及格”

1
2
3
4
5
6
7
8
9
#!/bin/bash
# 案例:请编写一个shell程序,如果输入的参数,大于等于60
if[ $1 -ge 60 ]
then
echo "及格了"
elif[ $1 -lt 60 ]
then
echo "不及格”
fi
2、case 语句

基本语法:

1
2
3
4
5
6
7
8
9
10
11
12
case $变量名 in
"值1")
# 如果变量的值等于值1,则执行程序1
;;
"值2")
# 如果变量的值等于值2,则执行程序2
;;
# …省略其他分支…
*)
# 如果变量的值都不是以上的值,则执行此程序
;;
esac

应用实例:当命令行参数是1 时,输出”周一”, 是2 时,就输出”周二”, 其它情况输出”other”

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
# 案例1:当命令行参数是1时,输出
case $l in
"1")
echo "周一"
;;
"2")
echo "周二"
;;
*)
echo "other..."
;;
esac
3、for 循环

基本语法1:

1
2
3
4
for 变量in 值1 值2 值3…
do
程序/代码
done

应用实例:打印命令行输入的参数[这里可以看出$* 和$@ 的区别]

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
# 案例1 :打印命令行输入的参数[这里可以看出$*和$@的区别]
# 注意$*是把输入的参数,当做一个整体,所以,只会输出一句
for i in "$*"
do
echo "num is $i"
done
# 使用$@来获取输入的参数,注意,这时是分别对待,所以有几个参数,就输出几个
echo "========================"
for j in "$@"
do
echo "num is $j”
done

基本语法2:

1
2
3
4
for (( 初始值;循环控制条件;变量变化 ))
do
程序/代码
done

应用实例:从1 加到100 的值输出显示

1
2
3
4
5
6
7
8
9
10
#!/bin/ bash
# 案例1:从1加到100的值输出显示,如何把100做成个变量I
# 定义一个变量SUM
SUM=0
for(( i=1; i<=$1; i++ ))
do
#写上你的业务代码
SUM=$[$SUM+$i]
done
echo "总和SUM=$SUM"
4、while 循环

基本语法1:

1
2
3
4
while [ 条件判断式 ]
do
程序/代码
done

注意:while 和[有空格,条件判断式和[也有空格

应用实例:从命令行输入一个数n,统计从1+..+ n 的值是多少?

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
# 案例1 :从命令行输入一个数n,统计从1+..+ n 的值是多少?
SUM=0
i=0
while [ $i -le $1 ]
do
SUM=$[$SUM+$i]
#i 自增
i=$[$i+1]
done
echo "执行结果=$SUM"

8、read 读取控制台输入

1、基本语法
1
read (选项)(参数)

选项:

  • -p:指定读取值时的提示符;
  • -t:指定读取值时等待的时间(秒),如果没有在指定的时间内输入,就不再等待了。

参数

  • 变量:指定读取值的变量名
2、应用实例
  1. 案例1:读取控制台输入一个NUM1 值
  2. 案例2:读取控制台输入一个NUM2 值,在10 秒内输入。
1
2
3
4
5
6
7
8
#!/bin/bash
# 案例1:读取控制台输入一个NUM1 值
read -p "请输入一个数NUM1=" NUM1
echo "你输入的NUM1=$NUM1"

# 案例2:读取控制台输入一个NUM2 值,在10 秒内输入。
read -t 10 -p "请输入一个数NUM2=" NUM2
echo "你输入的NUM2=$NUM2"

9、函数

1、函数介绍

shell 编程和其它编程语言一样,有系统函数,也可以自定义函数。

2、系统函数——basename

功能:返回完整路径最后/ 的部分,常用于获取文件名

basename 基本语法:

1
2
3
4
basename [pathname] [suffix]

# 功能描述:basename 命令会删掉所有的前缀包括最后一个(‘/’)字符,然后将字符串显示出来。
basename [string] [suffix]

选项:

  • suffix:为后缀,如果suffix 被指定了,basename 会将pathname 或string 中的suffix 去掉。

应用实例:

  1. 案例1:请返回/home/aaa/test.txt 的”test.txt” 部分
  2. 案例1:请返回/home/aaa/test.txt 的”test” 部分
1
basename /home/aaa/test.txt

image-20210918020932577

3、系统函数——dirname

功能:返回完整路径最后/ 的前面的部分,常用于返回路径部分

1
2
# 功能描述:从给定的包含绝对路径的文件名中去除文件名(非目录的部分),然后返回剩下的路径(目录的部分)
dirname 文件绝对路径

应用实例:请返回/home/aaa/test.txt 的/home/aaa

1
dirname /home/aaa/test.txt

image-20210918021128201

4、自定义函数
1、基本语法
1
2
3
4
5
[ function ] funname[()]
{
Action;
[ return int; ]
}

调用直接写函数名:funname [值]

2、应用实例

案例1:计算输入两个参数的和(动态的获取), getSum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash
# 案例1:计算输入两个参数的和(动态的获取), getSum
#定义函数getSum
function getSum() {
SUM=$[$n1+$n2]
echo "和是=$SUM"
}

# 输入两个值
read -p "请输入一个数n1=" n1
read -p "请输入一个数n2=" n2

# 调用自定义函数
getSum $n1 $n2

4、Shell 编程综合案例

数据库备份

1、需求
  1. 每天凌晨2:30 备份数据库 hspedu 到/data/backup/db
  2. 备份开始和备份结束能够给出相应的提示信息
  3. 备份后的文件要求以备份时间为文件名,并打包成.tar.gz 的形式,比如:2021-03-12_230201.tar.gz
  4. 在备份的同时,检查是否有10 天前备份的数据库文件,如果有就将其删除。
2、原理

image-20210918021326999

3、编写备份数据库的shell脚本

代码:/usr/sbin/mysql_db.backup.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 备份目录
BACKUP=/data/backup/db
# 当前时间
DATETIME=$(date +%Y-%m-%d_%H%M%S)
echo $DATETIME
# 数据库的地址
HOST=localhost
# 数据库用户名
DB_USER=root
# 数据库密码
DB_PW=hspedu100
# 备份的数据库名
DATABASE=hspedu
# 创建备份目录, 如果不存在,就创建
[ ! -d "${BACKUP}/${DATETIME}" ] && mkdir -p "${BACKUP}/${DATETIME}"
# 备份数据库,其中
# -q --quick:加上 -q 后,不会把SELECT出来的结果放在buffer中,而是直接dump到标准输出中,顶多只是buffer当前行结果,正常情况下是不会超过 max_allowed_packet 限制的,它默认情况下是开启的。
# -R:备份存储过程等
mysqldump -u${DB_USER} -p${DB_PW} --host=${HOST} -q -R --databases ${DATABASE} | gzip > ${BACKUP}/${DATETIME}/$DATETIME.sql.gz
# 将文件处理成tar.gz
cd ${BACKUP}
tar -zcvf $DATETIME.tar.gz ${DATETIME}
# 删除对应的备份目录
rm -rf ${BACKUP}/${DATETIME}
# 删除10 天前的备份文件
# -exec继续执行之后的命令 {}前面指令得到的内容 \;结尾符号
find ${BACKUP} -atime +10 -name "*.tar.gz" -exec rm -rf {} \;
echo "备份数据库${DATABASE} 成功~"
4、编写定时任务
1
crontab -e
1
30 2 * * * /usr/sbin/mysql_db.backup.sh

注意:这里编写到.sh的时候需要包光标打回去h,不能把光标停留到h的后面,在进行:wq退出,不然会出现很奇怪的问题:——会不断重复刚才打的这句话


14、Linux 日志管理

1、基本介绍

  1. 日志文件是重要的系统信息文件,其中记录了许多重要的系统事件,包括用户的登录信息系统的启动信息系统的安全信息邮件相关信息各种服务相关信息等。
  2. 日志对于安全来说也很重要,它记录了系统每天发生的各种事情,通过日志来检查错误发生的原因,或者受到攻击时攻击者留下的痕迹
  3. 可以这样理解日志是用来记录重大事件的工具。

2、系统常用的日志

/var/log/ 目录就是系统日志文件的保存位置

image-20210919184657185

系统常用的日志:

日志文件 说明
==/var/log/boot.log== 系统启动日志
==/var/log/cron== 记录与系统定时任务相关的日志
/var/log/cups 记录打印信息的日志
/var/log/dmesg 记录了系统在开机时内核自检的信息。也可以使用dmesg命令直接查看内核自检信息
/var/log/firewalld 记录了防火墙信息的日志
/var/log/btmp 记录错误登陆的日志。这个文件是二进制文件,不能直接使用Vi查看,而要使用lastb命令查看。命令如下:[root@localhost log]#lastb
==/var/log/lastlog== 记录了系统中所有用户最后一次登陆时间的日志。这个日志也是一个二进制文件。需要使用命令lastlog查看
==/var/log/maillog== 记录了邮件信息的日志
==/var/log/message== 记录了系统重要信息的日志。这个日志文件中会记录Linux系统的绝大多数重要信息。如果系统出现问题,首先要检查的就是这个日志文件
==/var/log/secure== 记录了验证和授权方面的信息,重要涉及账户和密码的程序都会记录。比如系统的登陆、ssh的登陆、su切换用户、sudo授权,甚至添加用户和修改用户密码都会记录在这个日志文件当中
/var/log/wtmp 永久记录所有用户的登陆、注销信息,同时记录系统的启动、重启、关机事件。这也是一个二进制文件,需要使用命令last查看
==/var/log/utmp== 记录当前已经登陆的用户的信息。这个文件会随着用户的登陆和注销而不断变化,只记录当前登陆用户的信息。这个文件也是一个二进制文件,不能使用Vi查看,需要使用命令wwhousers等查看

应用案例:

使用root 用户通过xshell6 登陆,第一次使用错误的密码,第二次使用正确的密码登录成功看看在日志文件/var/log/secure 里有没有记录相关信息

image-20210919193524935

3、日志管理服务——rsyslogd

1、介绍

CentOS7.6 日志服务是rsyslogd , CentOS6.x 日志服务是syslogd 。

rsyslogd 功能更强大。rsyslogd 的使用、日志文件的格式,和syslogd 服务兼容的。

image-20210919211837130

2、日志相关命令

1
2
3
4
5
# 查询Linux 中的rsyslogd 服务是否启动
ps -aux | grep "rsyslog" | grep -v "grep"

# 查询rsyslogd 服务的自启动状态
systemctl list-unit-files | grep rsyslog

3、配置文件:/etc/rsyslog.conf

编辑文件时的格式为:

1
*.*	存放日志文件

其中第一个*代表日志类型,第二个*代表日志级别

1、日志类型
  • auth:pam 产生的日志
  • authpriv:ssh、ftp 等登录信息的验证信息
  • corn:时间任务相关
  • kern:内核
  • lpr:打印
  • mail:邮件
  • mark(syslog)-rsyslog:服务内部的信息,时间标识
  • news:新闻组
  • user:用户程序产生的相关信息
  • uucp:unix to nuix copy 主机之间相关的通信
  • local 1-7:自定义的日志设备
2、日志级别
  • debug:有调试信息的,日志通信最多
  • info:一般信息日志,最常用
  • notice:最具有重要性的普通条件的信息
  • warning:警告级别
  • err:错误级别,阻止某个功能或者模块不能正常工作的信息
  • crit:严重级别,阻止整个系统或者整个软件不能正常工作的信息
  • alert:需要立刻修改的信息
  • emerg:内核崩溃等重要信息
  • none:什么都不记录

注意:从上到下,级别从低到高,记录信息越来越少

3、日志记录内容

由日志服务rsyslogd 记录的日志文件,日志文件的格式包含以下4 列

  1. 事件产生的时间
  2. 产生事件的服务器的主机名
  3. 产生事件的服务名或程序名
  4. 事件的具体信息

日志如何查看实例:查看一下/var/log/secure 日志,这个日志中记录的是用户验证和授权方面的信息来分析如何查看

image-20210919213759642

日志管理服务应用实例:

在/etc/rsyslog.conf 中添加一个日志文件/var/log/hsp.log,当有事件发送时(比如sshd 服务相关事件),该文件会接收到信息并保存。

image-20210919213835480

进行登陆,然后查看hsp.log:

image-20210919213841043

4、日志轮替

1、基本介绍

日志轮替就是把旧的日志文件移动并改名,同时建立新的空日志文件,当旧日志文件超出保存的范围之后,就会进
行删除

2、日志轮替文件命名

  • centos7 使用logrotate 进行日志轮替管理,要想改变日志轮替文件名字,通过/etc/logrotate.conf 配置文件中“dateext”参数
  • 如果配置文件中有“dateext”参数,那么日志会用日期来作为日志文件的后缀,例如“secure-20201010”。这样日志文件名不会重叠,也就不需要日志文件的改名, 只需要指定保存日志个数,删除多余的日志文件即可。
  • 如果配置文件中没有“dateext”参数,日志文件就需要进行改名了
    1. 当第一次进行日志轮替时,当前的“secure”日志会自动改名为“secure.1”,
    2. 然后新建“secure”日志, 用来保存新的日志。
    3. 当第二次进行日志轮替时,“secure.1”会自动改名为“secure.2”, 当前的“secure”日志会自动改名为“secure.1”,
    4. 然后也会新建“secure”日志,用来保存新的日志,以此类推。
    5. 当达到最大保存的日志个数时,会把最大数的那个日志文件删除(最久之前的)

3、logrotate 配置文件

/etc/logrotate.conf 为 logrotate 的全局配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# rotate log files weekly, 每周对日志文件进行一次轮替
weekly

# keep 4 weeks worth of backlogs, 共保存4 份日志文件,当建立新的日志文件时,旧的将会被删除
rotate 4

# create new (empty) log files after rotating old ones, 创建新的空的日志文件,在日志轮替后
create

# use date as a suffix of the rotated file, 使用日期作为日志轮替文件的后缀
dateext

# uncomment this if you want your log files compressed, 日志文件是否压缩。如果取消注释,则日志会在转储的同时进
行压缩

#compress
#RPM packages drop log rotation information into this directory
# 包含/etc/logrotate.d/ 目录中所有的子配置文件。也就是说会把这个目录中所有子配置文件读取进来
include /etc/logrotate.d

#下面是单独设置,优先级更高。
# no packages own wtmp and btmp -- we'll rotate them here
/var/log/wtmp {
monthly # 每月对日志文件进行一次轮替
create 0664 root utmp # 建立的新日志文件,权限是0664 ,所有者是root ,所属组是utmp 组
minsize 1M # 日志文件最小轮替大小是1MB 。也就是日志一定要超过1MB 才会轮替,否则就算时间达到一个月,也不进行日志转储
rotate 1 # 仅保留一个日志备份。也就是只有wtmp 和wtmp.1 日志保留而已
}
/var/log/btmp {
missingok # 如果日志不存在,则忽略该日志的警告信息
monthly
create 0600 root utmp
rotate 1
}

参数说明

  • daily:日志的轮替周期是每天
  • weekly:日志的轮替周期是每周
  • monthly:日志的轮替周期是每月
  • rotate:数字保留的日志文件的个数。0 指没有备份
  • compress:日志轮替时,旧的日志进行压缩
  • create mode owner group:建立新日志,同时指定新日志的权限与所有者和所属组。
  • mail address:当日志轮替时,输出内容通过邮件发送到指定的邮件地址。
  • missingok:如果日志不存在,则忽略该日志的警告信息
  • notifempty:如果日志为空文件,则不进行日志轮替
  • minsize:大小日志轮替的最小值。也就是日志一定要达到这个最小值才会轮替,否则就算时间达到也
    不轮替
  • size:大小日志只有大于指定大小才进行日志轮替,而不是按照时间轮替。
  • dateext:使用日期作为日志轮替文件的后缀。
  • sharedscripts:在此关键字之后的脚本只执行一次。
  • prerotate/endscript:在日志轮替之前执行脚本命令。
  • postrotate/endscript:在日志轮替之后执行脚本命令。

/etc/logrotate.d目录中所有的子配置文件

因为可能的日志配置项会很多,将所有日志配置都配置到/etc/logrotate.conf配置文件当中的话,不仅可读性不高,而且也不好管理。因此Linux将一些日志配置在/etc/logrotate.d目录下,然后在/etc/logrotate.conf配置文件当中使用include进行导入。例如boot.log启动日志的配置就是在/etc/logrotate.d目录下,然后在导入/etc/logrotate.conf配置文件当中使用。

image-20210919214909133

4、把自己的日志加入日志轮替

  1. 第一种方法是直接在/etc/logrotate.conf 配置文件中写入该日志的轮替策略
  2. 第二种方法是在/etc/logrotate.d/目录中新建立该日志的轮替文件,在该轮替文件中写入正确的轮替策略,因为该目录中的文件都会被“include”到主配置文件中,所以也可以把日志加入轮替。
  3. 推荐使用第二种方法,因为系统中需要轮替的日志非常多,如果全都直接写入/etc/logrotate.conf 配置文件,那么这个文件的可管理性就会非常差,不利于此文件的维护。
  4. 在/etc/logrotate.d/ 配置轮替文件一览

image-20210919214909133

5、应用实例

在/etc/logrotate.conf 进行配置, 或者直接在/etc/logrotate.d/ 下创建文件hsplog 编写如下内容,具体
轮替的效果可以参考/var/log 下的boot.log 情况。

1
2
3
4
5
6
7
8
/var/log/hsplog.log
{
missingok
daily
copyturncate
rotate 7
notifempty
}

image-20210919215445014

6、日志轮替机制原理

日志轮替之所以可以在指定的时间备份日志,是依赖系统定时任务。在/etc/cron.daily/目录,就会发现这个目录中是有logrotate 文件(可执行),logrotate 通过这个文件依赖定时任务执行的。

image-20210919215716623

image-20210919215742648

5、查看内存日志

journalctl 可以查看内存日志, 这里我们看看常用的指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 查看全部内存日志
journalctl

# 查看最新3条内存日志
journalctl -n 3

# 查看起始时间到结束时间的日志可加日期
journalctl --since 19:00 --until 19:10:10

# 报错日志
journalctl -p err

# 日志详细内容
journalctl -o verbose

# 查看包含这些参数的日志(在详细日志查看)
journalctl _PID=1245 _COMM=sshd
# 或者
journalctl | grep sshd

注意:journalctl 查看的是内存日志,重启清空!


15、Linux 内核源码介绍 & 内核升级

1、linux0.01 内核源码

1、基本介绍

Linux 的内核源代码可以从网上下载, 解压缩后文件一般也都位于linux 目录下。内核源代码有很多版本,可以从
linux0.01 内核入手,总共的代码1w 行左右, 最新版本5.9.8 总共代码超过700w 行,非常庞大

内核地址

2、linux0.01 内核源码目录&阅读

1、阅读内核源码技巧
  1. linux0.01 的阅读需要懂c 语言
  2. 阅读源码前,应知道Linux 内核源码的整体分布情况。现代的操作系统一般由进程管理、内存管理、文件系统、驱动程序和网络等组成。Linux 内核源码的各个目录大致与此相对应。
  3. 在阅读方法或顺序上,有纵向与横向之分。
  • 所谓纵向就是顺着程序的执行顺序逐步进行;
  • 所谓横向,就是按模块进行。它们经常结合在一起进行。
  1. 对于Linux 启动的代码可顺着Linux 的启动顺序一步步来阅读;对于像内存管理部分,可以单独拿出来进行阅读分析。实际上这是一个反复的过程,不可能读一遍就理解。
2、linux 内核源码目录介绍

image-20210922230530871

  • boot:和系统引导相关的代码
  • fs:存放Linux支持的文件系统代码
  • incloud:存放Linux核心需要的头文件。比如asm、Linux、sys
  • init:初始化的代码。里面存放了mian.c函数入口
  • kernel:和系统内核相关的代码
  • lib:存放库代码
  • Markfile:做编译使用
  • mm:与内存管理相关的代码
  • tools:一些工具代码
3、linux 内核源码阅读——main.c说明

main.c中的main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void main(void)		/* This really IS void, no error here. */
{ /* The startup routine assumes (well, ...) this */
/*
* Interrupts are still disabled. Do necessary setups, then
* enable them
*/
time_init();
tty_init();
trap_init();
sched_init();
buffer_init();
hd_init();
sti();
move_to_user_mode();
if (!fork()) { /* we count on this going ok */
init();
}
/*
* NOTE!! For any other task 'pause()' would mean we have to get a
* signal to awaken, but task0 is the sole exception (see 'schedule()')
* as task 0 gets activated at every idle moment (when no other tasks
* can run). For task0 'pause()' just means we go check if some other
* task can run, and if not we return here.
*/
for(;;) pause();
}
  • time_init();:初始化运行时间
  • tty_init();:tty初始化
  • trap_init();:陷阱门(硬件中断向量)初始化
  • sched_init();:调度程序初始化
  • buffer_init();:缓冲管理初始化
  • hd_init();:硬盘初始化
  • sti();:所有初始化工作完成之后,开启中断
  • move_to_user_mode();:进入到用户模式

2、linux 内核最新版和内核升级

1、内核地址的查看

内核地址

2、下载&解压最新版

1
2
3
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.8.16.tar.gz

tar -zxvf linux-5.8.16.tar.gz

3、linux 内核升级应用实例

将Centos 系统从7.6 内核升级到7.8 版本内核(兼容性问题)

image-20210922231631243

具体步骤:

1
2
3
4
5
6
7
8
9
10
11
# 查看当前的内核版本
uname -a

# 检测内核版本,显示可以升级的内核
yum info kernel -q

# 升级内核
yum update kernel

# 查看已经安装的内核
yum list kernel -q

注意:

  • 升级内核之后并不是说就应经把当前内核替换成升级的内核,当前的内核依旧是老的内核,只是在老的内核的基础上安装了与当前版本兼容的一个新的内核。
  • 当你重启之后,会让你选择一个内核,这时候选择升级的内核才是真正的将升级的内核当做当前Linux的内核。

在升级内核之后,使用yum list kernel -q命令查看已经安装的内核:

image-20210922231836518

此时使用uname -a去查看当前内核,发现:

image-20210922231928057

使用依旧是老的内核

重启之后,选择升级的内核。在使用uname -a去查看当前内核,发现:

image-20210922232011075

已经修改成升级的内核。因为升级的内核与当前版本是兼容的,所以Linux当中下载的软件和插件,以及里面保存的文件都没有什么改变。


16、Linux 的备份与恢复

1、基本介绍

实体机无法做快照,如果系统出现异常或者数据损坏,后果严重, 要重做系统,还会造成数据丢失。

所以我们可以使用备份和恢复技术

linux 的备份和恢复很简单, 有两种方式:

  1. 把需要的文件(或者分区)用TAR 打包就行,下次需要恢复的时候,再解压开覆盖即可
  2. 使用dumprestore 命令

2、安装dump 和restore

如果linux 上没有dump 和restore 指令(可以使用下面指令查看)

1
2
3
dump

restore

需要先按照下面命令对dump 和restore进行安装

1
2
3
yum -y install dump

yum -y install restore

3、使用dump 完成备份

1、基本介绍

dump 支持分卷和增量备份(所谓增量备份是指备份上次备份后修改/增加过的文件,也称差异备份)。

什么是增量备份?

  • 增量备份就是每一次更新文件时,不进行全文件的备份。只是将增加的部分备份起来,进行一次循环之后在进行一次全文件备份
  • 在Linux当中,0代表全文件备份,其他1-9代表增量备份。
  • 等到增量备份到了数字9之后,在进行一次全文件备份,也就是0备份。如此循环

2、dump 语法说明

1
2
3
dump [ -cu] [-123456789] [ -f <备份后文件名>] [-T <日期>] [目录或文件系统]

dump []-wW
  • -c : 创建新的归档文件,并将由一个或多个文件参数所指定的内容写入归档文件的开头。
    • c就是以下数字:
    • -0123456789: 备份的层级。0 为最完整备份,会备份所有文件。若指定0 以上的层级,则备份至上一次备份以来修改或新增的文件, 到9 后,可以再次轮替.
  • -u : 备份完毕后,在/etc/dumpdares 中记录备份的文件系统,层级,日期与时间等。
  • -f <备份后文件名>: 指定备份后文件名
  • -T <日期>: 指定开始备份的时间与日期
  • -j:==调用bzlib 库压缩备份文件==,也就是将备份后的文件==压缩成bz2 格式==,让文件更小
  • -t : 指定文件名,若该文件已存在备份文件中,则列出名称
  • -W :显示需要备份的文件及其最后一次备份的层级,时间,日期。
  • -w:与-W 类似,但仅显示需要备份的文件。

3、dump 应用案例

1
2
3
4
5
6
# 将/boot 分区所有内容备份到/opt/boot.bak0.bz2 文件中,备份层级为“0”
dump -0uj -f /opt/boot.bak0.bz2 /boot

# 在/boot 目录下增加新文件,备份层级为“1”(只备份上次使用层次“0”备份后发生过改变的数据)
# 注意比较看看这次生成的备份文件boot1.bak 有多大
dump -1uj -f /opt/boot.bak1.bz2 /boot

注:通过dump 命令在配合crontab 可以实现无人值守备份

4、dump -W

显示需要备份的文件及其最后一次备份的层级,时间,日期

image-20210923001029130

5、查看备份时间文件

在etc目录下有dumpdates,里面存放着备份时间文件

1
cat /etc/dumpdates

image-20210923001055385

6、dump 备份文件或者目录

前面我们在备份分区时,是可以支持增量备份的,如果备份文件或者目录,不再支持增量备份,即只能使用 0 级别备份

1
2
3
4
5
6
# 使用dump 备份/etc 整个目录
# 注意,这里也不能使用-u参数,因为目录不支持增量备份
dump -0j -f /opt/etc.bak.bz2 /etc/

#下面这条语句会报错,提示DUMP: Only level 0 dumps are allowed on a subdirectory
dump -1j -f /opt/etc.bak.bz2 /etc/

如果是重要的备份文件, 比如数据区,建议将文件上传到其它服务器保存,不要将鸡蛋放在同一个篮子

4、使用restore 完成恢复

1、基本介绍

restore 命令用来恢复已备份的文件,可以从dump 生成的备份文件中恢复原文件

2、restore 基本语法

1
restore [模式选项] [选项]

说明下面四个模式, 不能混用,在一次命令中, 只能指定一种。

  • -C :使用对比模式,将备份的文件与已存在的文件相互对比。
  • -i:使用交互模式,在进行还原操作时,restors 指令将依序询问用户
  • -r:进行还原模式,最常用
  • -t查看模式,看备份文件有哪些文件

选项:

  • -f <备份设备>:从指定的文件中读取备份数据,进行还原操作

3、restore 应用案例

restore 命令比较模式,比较备份文件和原文件的区别:

1
2
3
4
5
6
7
8
9
10
11
12
# 假设下述文件已经备份
# 重命名
mv /boot/hello.java /boot/hello100.java

# 查看备份文件(注意和最新的文件比较)
restore -C -f boot.bak1.bz2

# 重新修改回去
mv /boot/hello100.java /boot/hello.java

# 再次查看备份文件(注意和上一次的文件比较)
restore -C -f boot.bak1.bz2

image-20210923001904727

restore 命令查看模式,看备份文件有哪些数据/文件:

1
restore -t -f boot.bak0.bz2

restore 命令还原模式, 注意细节: 如果你有增量备份,需要把增量备份文件也进行恢复, 有几个增量备份文件,
就要恢复几个,按顺序来恢复即可。

1
2
3
4
5
6
7
8
9
mkdir /opt/boottmp

cd /opt/boottmp

# 恢复到第1次完全备份状态
restore -r -f /opt/boot.bak0.bz2

# 恢复到第2 次增量备份状态
restore -r -f /opt/boot.bak1.bz2

image-20210923002029549

restore 命令恢复备份的文件,或者整个目录的文件

1
2
# 基础语法
restore -r -f 备份好的文件
1
2
3
[root@hspedu100 opt]# mkdir etctmp
[root@hspedu100 opt]# cd etctmp/
[root@hspedu100 etctmp]# restore -r -f /opt/etc.bak0.bz2

17、Linux 可视化管理——webmin 和bt 运维工具

1、webmin

1、基本介绍

Webmin 是功能强大的基于Web 的Unix/linux 系统管理工具。管理员通过浏览器访问Webmin 的各种管理功能并完成相应的管理操作。除了各版本的linux 以外还可用于:AIX、HPUX、Solaris、Unixware、Irix 和FreeBSD 等系统

2、安装webmin&配置

1、下载地址:http://download.webmin.com/download/yum/ , 用下载工具下载即可

也可以使用:

1
wget http://download.webmin.com/download/yum/webmin-1.700-1.noarch.rpm

2、安装:

1
rpm -ivh webmin-1.700-1.noarch.rpm

3、重置密码:

1
/usr/libexec/webmin/changepass.pl /etc/webmin root test

root 是webmin 的用户名,不是OS 的,这里就是把webmin 的root 用户密码改成了test

4、修改webmin 服务的端口号(默认是10000 出于安全目的):

1
2
# 修改端口
vim /etc/webmin/miniserv.conf

将port=10000 修改为其他端口号,如port=6666

5、重启webmin

1
2
3
/etc/webmin/restart # 重启
/etc/webmin/start # 启动
/etc/webmin/stop # 停止

6、防火墙放开6666 端口

1
2
3
firewall-cmd --zone=public --add-port=6666/tcp --permanent # 配置防火墙开放6666 端口
firewall-cmd --reload # 更新防火墙配置
firewall-cmd --zone=public --list-ports # 查看已经开放的端口号

7、登录webmin

使用http://ip:6666 可以访问了。用root 账号和重置的新密码test

image-20210923002634856

8、修改语言

image-20210923002705980

注:以上有些可以在网页上实现

3、bt(宝塔)

1、基本介绍

bt 宝塔Linux 面板是提升运维效率的服务器管理软件,支持一键LAMP/LNMP/集群/监控/网站/FTP/数据库/JAVA 等多项服务器管理功能。

2、安装和使用

安装:

1
yum install -y wget && wget -O install.sh http://download.bt.cn/install/install_6.0.sh && sh install.sh

安装成功后控制台会显示登录地址,账户密码,复制浏览器打开登录

image-20210923002839587

3、使用介绍

比如可以登录终端、配置、快捷安装运行环境和系统工具、添加计划任务脚本

http://192.168.200.130:8888/2e673418/

image-20210923002945353

4、如果bt 的用户名,密码忘记了,使用bt default 可以查看

1
bt default

image-20210923003013335


18、Linux 面试题

1、分析日志t.log(访问量),将各个ip 地址截取,并统计出现次数,并按从大到小排序(腾讯)

1
2
3
4
5
6
7
http://192.168.200.10/index1.html
http://192.168.200.10/index2.html
http://192.168.200.20/index1.html
http://192.168.200.30/index1.html
http://192.168.200.40/index1.html
http://192.168.200.30/order.html
http://192.168.200.10/order.html

答:

1
cat t.log | cut -d '/' -f 3 | sort | uniq -c | sort -nr

解析:

  • cat t.log先将文件当中的信息显示出来

  • 通过管道之后,使用cut -d '/' -f 3命令将IP分割出来

    • cut用来对每一行数据进行切割
    • -d '/' 表示以’/‘为边界进行切割,将每一行分割成4部分
      • 第一部分:http:
      • 第二部分:
      • 第三部分:192.168.200.*(IP地址)
      • 第四部分:*.html(访问的页面)
    • -f 3表示将每一行分割的第三部分取出来——就是将IP取出来
  • 通过管道之后,将取出的IP地址进行排序

    • 为什么后面也进行排序了,这里依旧需要排序?

      • 这里不进行sort的话,uniq -c统计出来的结果有误

        image-20210923005746672

  • 通过管道之后,使用uniq -c进行计数

  • 通过管道之后,使用sort -nr将计数结果按从大到小排序

    • 因为sort默认是从小到大排序的,所以需要使用-nr参数让其按从大到小排序

2、统计连接到服务器的各个ip 情况,并按连接数从大到小排序(腾讯)

image-20210923010207922

答:

1
netstat -an | grep ESTABLISHED | awk -F " " '{print $5}' | cut -d ':' -f 1 | sort | uniq -c | sort -nr

解析:

  • 先使用netstat -an查看当前网络连接的情况
  • 通过管道之后,使用grep ESTABLISHED对已经建立连接正在通信的服务器进行过滤
  • 通过管道之后,使用awk -F " " '{print $5}'命令将IP加端口分割出来
    • 为什么这里不使用cut
    • 因为cut不能使用空格 当中分割符,所以使用awk命令对每一行进行分割
    • 对于awk -F " "来说表示将每一行由空格进行分割,就算有多个空格按一个来算。
    • 'print $5'表示将第五部分——IP加端口分割出来
  • 通过管道之后,使用cut -d ':' -f 1将每一行的IP与端口分割开,取出IP
  • 通过管道之后,进行sort排序
  • 通过管道之后,使用uniq -c进行计数
  • 通过管道之后,使用sort -nr将计数结果从大到小排序

3、如忘记了mysql5.7 数据库的ROOT 用户的密码,如何找回? (滴滴)

  1. 首先修改MySQL的配置文件/etc/mt.cnf,使mysql登陆时不使用权限表进行登陆——即:可以进行空密码进入mysql

    1
    vim /etc/mt.cnf

    在mysqld当中添加一行

    1
    2
    # 使mysql登陆时不使用权限表进行登陆
    skip-grant-tables

    image-20210923011810935

  2. 修改完成之后需要重启一下mysqld服务

    1
    service mysqld restart
  3. 这时候就可以空密码进入mysql,在mysql当中的,有一个mysql数据库,当中有一个user表,用来保存一些mysql用户的数据,修改当中的authentication_string字段

    1
    2
    3
    4
    5
    6
    7
    8
    9
    show databases;

    use mysql;

    show tables;

    desc user;

    update user set authentication_string=password("mysql") where user = 'root';

    image-20210923012149564

    image-20210923012259818

    image-20210923012424463

    image-20210923012742552

  4. 在修改authentication_string字段之后,需要刷新一下权限

    1
    flush privileges;
  5. 退出mysql服务,进入MySQL的配置文件/etc/mt.cnf,将之前的修改的地方注释掉

    image-20210923013034380

注:如果为mysql8版本的数据库,要先设置为空密码,再alter user ‘root’@’%’ identified by ‘新密码’;

4、写出指令:统计ip 访问情况,要求分析nginx 访问日志(access.log),找出访问页面数量在前2位的ip(美团)

image-20210923013422398

答:

1
cat access.log | awk -F " " '{print $1}' | sort | uniq -c | sort -nr | head -2

解析:

  • 最后的head -2表示取出管道筛选出来的前两条数据

5、使用tcpdump 监听本机, 将来自ip 192.168.200.1,tcp 端口为22 的数据,保存输出到tcpdump.log , 用做将来数据分析(美团)

答:

1
tcpdump -i ens33 host 192.168.200.1 and port 22 >> /var/tcpdump.log

前提:需要在当前的Linux中安装tcpdump

6、常用的Nginx 模块,用来做什么(头条)

答:

  • rewrite 模块:实现重写功能
  • access 模块:来源控制
  • ssl 模块:安全加密
  • ngx_http_gzip_module:网络传输压缩模块
  • ngx_http_proxy_module:模块实现代理
  • ngx_http_upstream_module:模块实现定义后端服务器列表
  • ngx_cache_purge:实现缓存清除功能

7、如果你是系统管理员,在进行Linux 系统权限划分时,应考虑哪些因素?(腾讯)

答:

  1. 首先阐述Linux 权限的主要对象

    image-20210923020714672

  2. 根据自己实际经验谈考虑因素

    • 注意权限分离,比如:工作中,Linux 系统权限和数据库权限不要在同一个部门
    • 权限最小原则(即:在满足使用的情况下最少优先)
    • 减少使用root 用户,尽量用普通用户+sudo 提权进行日常操作。
    • 重要的系统文件,比如/etc/passwd, /etc/shadow etc/fstab,/etc/sudoers 等,日常建议使chattr(change attribute)锁定,需要操作时再打开。
      • 演示比如:锁定/etc/passwd 让任何用户都不能随意useradd,除非解除锁定
      • 注意:锁定的时候要小心,不要锁定了那些系统运行需要的文件,别锁定一个文件之后,系统都运行不了
    • 使用SUID, SGID, Sticky 设置特殊权限。
    • 可以利用工具,比如chkrootkit/rootkit hunter 检测rootkit 脚本(rootkit 是入侵者使用工具,在不察觉的建立了入侵系统途径)
    • 利用工具Tripwire 检测文件系统完整性

1、权限分离

工作中,Linux 系统权限和数据库权限不要在同一个部门。这样可以保证一定的安全性

2、权限最小原则

在满足使用的情况下最少优先。

如:

  • 如果tom用户在一个项目当中实现的是订单模块,那么就只需让他在订单模块文件中进行修改和提交,
  • 不要跑到其他模块,如用户模块当中。不给他其它模块的相关权限(最高给一个读的权限)

3、chattr

chattr(change attribute)可以锁定一些重要的文件,在解锁之前,就算是root用户也没有操作权限,需要操作时再打开。

使用:

1
2
3
4
5
# 锁定
chattr +i [重要的文件]

# 解锁
chattr -i [重要的文件]

演示:

对/etc/passwd文件进行锁定,这样就算是root也不能添加用户了

image-20210923021401515

当然,如果黑客拿到了root权限,那么他可以先使用chattr -i进行解锁,在进行用户添加操作

改进:移动chattr命令的位置

image-20210923021726892

这样,就算黑客使用chattr命令的话就会报错

但是,黑客可以使用find / -name chattr命令在全盘当中查找chattr命令的位置

改进:在移动chattr命令的位置的基础上,再修改chattr命令的名称——重命名

image-20210923021955832

这样一来,就算黑客全盘搜索chattr命令也搜索不到

注:以上操作需要操作者自己记住自己修改过的东西,忘了就完了

怎么解除锁定?反方向执行一遍

image-20210923022149738

4、chkrootkit

chkrootkit可以去扫描一些敏感和危险的文件,看看有没有被感染(入侵)

下载:

1
2
3
wget ftp://ftp.pangeia.com.br/pub/seg/pac/chkrootkit.tar.gz

tar -zxvf chkrootkit.tar.gz

目录:

image-20210923022611050

使用c语言编写的

使用:

image-20210923022415231

8、权限操作思考题

(1)用户tom 对目录/home/test 有执行x 和读r 写w 权限,/home/test/hello.java 是只读文件,问tom 对hello.java 文件能读吗? 能修改吗?能删除吗?

答:

  • tom 对hello.java 文件可以读,
  • 由于hello.java 是只读文件,所以不可以修改,
  • 由于yom对目录/home/test 有写w 权限,所以tom对hello.java可以删除

(2)用户tom 对目录/home/test 只有读写权限,/home/test/hello.java 是只读文件,问tom 对hello.java 文件能读吗? 能修改吗?能删除吗?

答:

  • 由于tom 对目录/home/test没有执行的权限,所以tom对hello.java 文件不可以读、修改和删除

(3)用户tom 对目录/home/test 只有执行权限x,/home/test/hello.java 是只读文件,问tom 对hello.java 文件能读吗)?能修改吗?能删除吗?

答:

  • tom 对hello.java 文件可以读,
  • 由于hello.java 是只读文件,所以不可以修改,
  • 由于yom对目录/home/test 没有写w 权限,所以tom不可以删除hello.java

(4)用户tom 对目录/home/test 只有执行x和写w权限,/home/test/hello.java 是只读文件,问tom 对hello.java 文件能读吗? 能修改吗?能删除吗?

  • tom 对hello.java 文件可以读,
  • 由于hello.java 是只读文件,所以不可以修改,
  • 由于yom对目录/home/test 有写w 权限,所以tom对hello.java可以删除

9、说明Centos7 启动流程,并说明和CentOS6 相同和不同的地方

答:

先看两张图,对照图来分析

image-20210923205414955

image-20210923205427905

  1. 第一步、硬件启动阶段
    • 这一步和CentOS6差不多,详细请看图
  2. 第二步、GRUB2引导阶段
    • 从这一步开始,CentOS6和CentOS7的启动流程区别开始展现出来了。
      • CentOS7的主引导程序使用的是grub2。
    • 这一步的流程:
      1. 显示加载两个镜像,
      2. 再加载MOD模块文件,
      3. 把grub2程序加载执行,
      4. 接着解析配置文件/boot/grub2/grub.cfg,根据配置文件加载内核镜像到内存,
      5. 之后构建虚拟根文件系统,
      6. 最后转到内核。
    • 在这里grub.cfg配置文件已经比较复杂了,但并不用担心,到了CentOS7中一般是使用命令进行配置,而不直接去修改配置文件了。不过我们可以看到grub.cfg配置文件开头注释部分说明了由/etc/grub.d/目录下文件和/etc/default/grub文件组成。
    • 一般修改好配置后都需要使用命令grub2-mkconfig -o /boot/grub2/grub.cfg,将配置文件重新生成。
  3. 第三步、内核引导阶段
    • 这一步与CentOS6也差不多,加载驱动,切换到真正的根文件系统,
    • 唯一不同的是执行的初始化程序变成了/usr/lib/systemd/systemd
  4. 第四步、systemed初始化阶段(又叫系统初始化阶段)
    1. CentOS7中我们的初始化进程变为了systemd。执行默认target配置文件/etc/systemd/system/default.target(这是一个软链接,与默认运行级别有关)。
    2. 然后执行sysinit.target来初始化系统和basic.target来准备操作系统。
    3. 接着启动multi-user.target下的本机与服务器服务,并检查/etc/rc.d/rc.local文件是否有用户自定义脚本需要启动。
    4. 最后执行multi-user下的getty.target及登录服务,检查default.target是否有其他的服务需要启动
    5. 注意:
      • /etc/systemd/system/default.target指向了/lib/systemd/system/目录下的graphical.target或multiuser.target。
      • 而graphical.target依赖multiuser.target,multiuser.target依赖basic.target,basic.target依赖sysinit.target,所以倒过来执行

System概述(了解):systemd即为system daemon,是Linux下的一种init软件,开发目标是提供更优秀的框架以表示系统服务间的以来关系,并依此实现系统初始化时服务的并行启动,同时达到降低Shell系统开销的效果,最终代替现在常用的System V与BSD风格的init程序。

与多数发行版使用的System V风格的init相比,systemd采用了以下的新技术:

  • 采用Socket激活式与总线激活式服务,以提高相互依赖的各服务的并行运行性能;
  • 用Cgroup代替PID来追踪进程,即使是两次fork之后生成的守护进程也不会脱离systemd的控制。

unit对象:unit表示不同类型的systemd对象,通过配置文件进行标识和配置;文件中主要包含了系统服务、监听socket、保存的系统快照以及其他与init相关的信息。(也就是CentOS6中的服务器启动脚本)

10、列举Linux 高级命令,至少6 个(百度)

答:

  • netstat:网络状态监控
  • top:系统运行状态
  • lsblk:查看硬盘分区
  • find:查找
  • ps -aux:查看运行进程
  • chkconfig:查看服务启动状态
  • systemctl:管理系统服务器

11、Linux 查看内存、io 读写、磁盘存储、端口占用、进程查看命令是什么?(瓜子)

答:

  • top:Linux 查看内存
  • iotop:Linux 查看 io 读写
  • df -lh:Linux 查看磁盘存储
  • netstat -tunlp:Linux 查看端口占用
  • ps -aux | grep 关心的进程:Linux 查看进程

12、使用Linux 命令计算t2.txt 第二列的和并输出(美团)

1
2
3
张三 40
李四 50
王五 60

答:

1
cat t2.txt | awk -F " " '{sum+=$2}' END '{print sum}'

13、Shell 脚本里如何检查一个文件是否存在?并给出提示(百度)

答:

1
2
3
4
5
6
if [ -f 文件名 ]
then
echo "存在"
else
echo "不存在"
fi

14、用shell 写一个脚本,对文本t3.txt 中无序的一列数字排序, 并将总和输出(百度)

1
2
3
4
5
6
7
8
9
9
8
7
6
5
4
3
2
10

答:

1
cat t3.txt | sort -nr | awk '{sum+=$0;print $0}' END '{print "和="sum}'

15、请用指令写出查找当前文件夹(/home)下所有的文本文件内容中包含有字符“cat”的文件名称(金山)

答:

1
grep -r "cat" /home | cut -d ":" -f 1

16、请写出统计/home 目录下所有文件个数和所有文件总行数的指令(在金山面试题扩展)

答:

1
2
find /home -name "*.*" | wc -l
find /home -name "*.*" | xargs wc -l

17、列出你了解的web 服务器负载架构(滴滴)

答:

  • Nginx
  • Haproxy
  • Keepalived
  • LVS

18、每天晚上10 点30 分,打包站点目录/var/spool/mail 备份到/home 目录下(每次备份按时间生成不同的备份包比如按照年月日时分秒)(滴滴)

答:

shell脚本:

1
2
#!/bin/bash
cd /var/spool && /bin/tar -zcf /home/mail-`data+%Y_%m_%d_%H%M%S`.tar.gz mail/

使用crond进行定时任务设置:

1
crontab -e
1
30 22 * * * /root/mail.sh

19、如何优化Linux 系统, 说出你的方法(瓜子)

  1. 对Linux 的架构的优化和原则分析

    Snipaste_2021-09-23_20-21-56

    几个考虑的因素:

    1. 网络速度
    2. 磁盘IO速度
    3. 文件连接数
    4. 安全性
    5. 防火墙的设置
    6. 内存的设置
  2. 对linux 系统本身的优化-规则

    1. 不用root,使用 sudo 提示权限
    2. 定时的自动更新服务时间
      • 使用nptdate npt1.aliyun.com,让croud 定时更新
    3. 配置yum 源,指向国内镜像(清华,163)
    4. 配置合理的防火墙策略,打开必要的端口,关闭不必要的端口
    5. 打开最大文件数(调整文件的描述的数量)
      • vim /etc/profile ulimit -SHn 65535
    6. 配置合理的监控策略
    7. 配置合理的系统重要文件的备份策略
    8. 对安装的软件进行优化,比如nginx ,apache
    9. 内核参数进行优化/etc/sysctl.conf
    10. 锁定一些重要的系统文件
      • chattr +i /etc/passwd /ect/shadow /etc/inittab
    11. 禁用不必要的服务
      • setup
      • ntsysv

0、补充知识

1、虚拟机相关——CentOS

1、CentOS 安装难点-网络连接方式理解

image-20210911203753391

  • 桥接模式虚拟机可以直接与外部主机进行通信,占用网段的实际IP地址
    • 即:张三的虚拟系统Linux可以与李四的主机直接通信(双方)
    • 即:当前网段有200个IP,如果每一个计算机都使用桥接模式创建虚拟机的话,每一个虚拟机占用的也是当前网段的实际IP,即200个IP
    • 那么一起一共400个IP超过了255个(实际上除去一个全0和一个全1只有254个)
    • 总结:在本机的环境中占用一个ip地址,如果本机的环境有多个设备,可能会造成ip不够用的情况
  • NAT模式:虚拟机自己会生成一个新的网段的IP,在虚拟机生成的网段下,主机也会生成一个与之对应的IP,相当于主机和虚拟机构成了一个新的局域网。虚拟机可以通过主机在原本网段的IP对外进行通信(间接),但是外部的主机不能直接与虚拟机进行通信。
    • 即:不会占用当前网段的IP地址
    • 即:王五的虚拟系统Linux可以与李四的主机进行通信,通过王五主机IP作为对外通信的IP
    • 但是李四的主机不能与王五的虚拟系统Linux进行通信——单方面的通信
    • 总结:借用本机的ip与外界进行单方面通信——只能你访问别人,就是别人不能访问你
  • 主机模式:不与外界发生联系

2、用户密码的生成

如果只是用来进行简单测试用的虚拟机,用户余密码可以进行简单的设置。

但是在生产环境当中,一般虚拟机不会使用root这个根用户,root也不会出现在用户列表当中让你选择。一般使用的是有一定权限限制的用户进行虚拟机的操作。而且密码的设置也是要很复杂的,以下网站可以帮你生成复杂密码

生成复杂密码:https://suijimimashengcheng.51240.com/

生成之后就用自己的密码本记录下来,防止记不住。

3、虚拟机克隆

如果你已经安装了一台 linux 操作系统,你还想再更多的,没有必要再重新安装,你只需要克隆就可
以。克隆方法:

  1. 方式1:直接拷贝一份安装好的虚拟机文件,然后在VM当中打开文件名为vmx为后缀的文件就行了
  2. 方式2:使用vmware 的克隆操作,注意, 克隆时,需要先关闭linux 系统
    • 克隆生成的VM文件不仅可以在当前的计算机通过VM进行运行,也可以将VM文件拷贝到另外一台计算机里面,使用VM也可以进行运行
    • 克隆产生的两份虚拟机完全一样,不仅里面的内容完全一样,而且年用户余密码也是一样的
    • 可以用来快速搭建集群

4、虚拟机快照

如果你在使用虚拟机系统的时候(比如linux),你想回到原先的某一个状态,也就是说你担心可能有些误操作造成系
统异常,需要回到原先某个正常运行的状态,vmware 也提供了这样的功能,就叫快照管理。

应用实例
  1. 安装好系统以后,先做一个快照A
  2. 进入到系统。创建一个文件夹,再保存一个快照B
  3. 回到系统刚刚安装好的状态,即快照A
  4. 试试看,是否还能再次回到快照B

image-20210911230145598

5、vmtools

1、介绍
  1. vmtools 安装后,可以让我们在windows 下更好的管理vm 虚拟机
  1. 可以设置windows 和centos 的共享文件夹
2、安装vmtools 的步骤
  1. 进入centos

  2. 点击vm 菜单的 -> install vmware tools

  3. centos 会出现一个vm 的安装包——xx.tar.gz

  4. 拷贝到/opt

  5. 使用解压命令tar,得到一个安装文件

    1
    2
    3
    # 进入到 opt 目录
    cd /opt
    tar -zxvf xx.tar.gz
  6. 进入该vm 解压的目录/opt 目录下

    1
    cd vmware...
  7. 安装./vmware-install.pl

  8. 全部使用默认设置即可,就可以安装成功

  9. 注意:安装vmtools 需要有gcc

    1
    gcc -v
3、设置共享文件夹
1、基本介绍

为了方便,可以windows上创建一个共享文件夹,比如d:/myshare

2、具体步骤
  1. 菜单->vm->setting,如图设置即可。注意:设置选项为always enable,这样可以读写了

    image-20210911232512634

  2. windows 和centos 可共享d:/myshare 目录可以读写文件了

  3. 共享文件夹在centos 的/mnt/hgfs/ 下

  4. 注意事项和细节说明

  • windows 和contos 就可以共享文件了,但是在实际开发中,文件的上传下载是需要使用远程方式完成的

资料来源

【小白入门 通俗易懂】2021韩顺平 一周学会Linux

Linux中shutdown,halt,poweroff,init 0区别

[TOC]

1、Redis初级(Windows)

1、Redis入门

1、Redis 简介

1、Nosql的出现

NoSql出现的解决的问题:

  • 海量用户
  • 高并发

罪魁祸首——关系型数据库:

  • 性能瓶颈:磁盘IO性能低下
  • 扩展瓶颈:数据关系复杂,扩展性差,不便于大规模集群

解决思路:

  • 降低磁盘IO次数,越低越好—— 内存存储
  • 去除数据间关系,越简单越好——不存储关系,仅存储数据

以上解决思路的实际实现:NoSql

2、Nosql 简介

NoSQL:即 Not-Only SQL( 泛指非关系型的数据库),作为关系型数据库的补充。

作用:应对基于海量用户和海量数据前提下的数据处理问题

特征:

  • 可扩容,可伸缩
  • 大数据量下高性能
  • 灵活的数据模型
  • 高可用

常见 Nosql 数据库:

  • ==Redis==
  • memcache
  • HBase
  • MongoDB

3、具体解决方案 ——(电商场景)

  1. 商品基本信息 ——MySQL
    • 名称
    • 价格
    • 厂商
  2. 商品附加信息 —— MongoDB
    • 描述
    • 详情
    • 评论
  3. 图片信息 —— 分布式文件系统
  4. 搜索关键字 —— ES、Lucene、solr
  5. 热点信息 —— Redis、memcache、tair
    • 高频
    • 波段性

image-20210904222259291

4、Redis

概念:Redis (REmote DIctionary Server) 是用 ==C 语言==开发的一个==开源==的高性能==键值对==(key-value)数据库。

在线测试:http://try.redis.io/

使用文档:http://doc.redisfans.com/

特征:

  1. 数据间没有必然的关联关系
  2. 内部采用单线程机制进行工作
  3. 高性能。官方提供测试数据,50个并发执行100000 个请求,读的速度是110000 次/s,写的速度是81000次/s。
  4. 多数据类型支持
    • 字符串类型——string
    • 列表类型——list
    • 散列类型——hash
    • 集合类型——set
    • 有序集合类型——sorted_set
  5. 这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。
  6. 在此基础上,Redis支持各种不同方式的排序
  7. 与memcached一样,为了保证效率,数据都是缓存在内存中。
  8. 区别的是Redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件。
  9. 持久化支持。可以进行数据灾难恢复
  10. 并且在此基础上实现了**master-slave(主从)**同步

Redis是单线程+多路IO复用技术

多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)

==串行 vs 多线程+锁(memcached) vs 单线程+多路IO复用(Redis)==

(与Memcache三点不同: 支持多数据类型,支持持久化,单线程+多路IO复用)

1

5、Redis 的应用

  • 配合关系型数据库做高速缓存
    • 为热点数据加速查询(主要场景),如热点商品、热点新闻、热点资讯、推广类等高访问量信息等
    • 任务队列,如秒杀、抢购、购票排队等
    • 即时信息查询,如各位排行榜、各类网站访问统计、公交到站信息、在线人数信息(聊天室、网站)、设备信号等
    • 时效性信息控制,如验证码控制、投票控制等
    • 布式数据共享,如分布式集群架构中的 session 分离
  • 多样的数据结构存储持久化数据
  • 消息队列
  • 分布式锁

2、Redis 的下载与安装

1、Redis 的下载

Linux 版:(适用于企业级开发)

  • Redis 高级开始使用
  • 以4.0 版本作为主版本

Windows 版本(适合零基础学习)

  • Redis 入门使用
  • 以 3.2 版本作为主版本
  • 下载地址

2、安装 Redis

image-20210904224324322

核心文件:

  • redis-server.exe:服务器启动命令
  • redis-cli.exe:命令行客户端
  • redis.windows.conf:redis核心配置文件
    • Linux环境下是redis.conf
  • redis-benchmark.exe :性能测试工具,可以在自己本子运行,看看自己本子性能如何
  • redis-check-aof.exe:AOF文件修复工具,修复有问题的AOF文件
  • redis-check-dump.exe:RDB文件检查工具(快照持久化文件),修复有问题的dump.rdb文件
  • 在Linux环境下还有一个redis-sentinel:Redis集群使用

3、启动 Redis

服务器启动:

1、前台启动(不推荐)
  • 端口:6379
  • PID:随机生成

image-20210904224540396

客户端连接:image-20210904224628786

前台启动,命令行窗口不能关闭,否则服务器停止。

2、后台启动(推荐)

修改redis.windows.conf文件将里面的daemonize no 改成 yes,让服务在后台启动。

可以使用客户端Ping一下看看能不能连接成功

3、Redis 的基本操作

1、命令行模式工具使用思考

  • 功能性命令
  • 清除屏幕信息
  • 帮助信息查阅
  • 退出指令

2、信息添加

  • 功能:设置 key,value 数据

  • 命令

    1
    set key value
  • 范例

    1
    set name zhangsan

3、信息查询

  • 功能:根据 key 查询对应的 value,如果不存在,返回空(nil)

  • 命令

    1
    get key
  • 范例

    1
    get name

4、清除屏幕信息

  • 功能:清除屏幕中的信息

  • 命令

    1
    clear

5、退出客户端命令行模式

  • 功能:退出客户端

  • 命令

    1
    2
    3
    quit
    exit
    <ESC>

6、帮助

  • 功能:获取命令帮助文档,获取组中所有命令信息名称

  • 命令

    1
    2
    help 命令名称
    help @组名

    image-20210904225148243

    image-20210904225203708


2、Redis 数据类型

1、数据存储类型介绍

1、业务数据的特殊性

1、作为缓存使用
  1. 原始业务功能设计
    • 秒杀
    • 618活动
    • 双11活动
    • 排队购票
  2. 运营平台监控到的突发高频访问数据
    • 突发时政要闻,被强势关注围观
  3. 高频、复杂的统计数据
  • 在线人数
  • 投票排行榜
2、附加功能

系统功能优化或升级

  • 单服务器升级集群
  • Session 管理
  • Token 管理

2、Redis 数据类型(5种常用)

redis java
string String
hash HashMap
list LinkedList
set HashSet
sorted_set TreeSet

2、String

1、redis 数据存储格式

redis 自身是一个 Map,其中所有的数据都是采用 key : value 的形式存储

数据类型指的是存储的数据的类型,也就是 value 部分的类型,==key 部分永远都是字符串==

image-20210905002926897

2、string 类型

  • 存储的数据:单个数据,最简单的数据存储类型,也是最常用的数据存储类型
  • 存储数据的格式:一个存储空间保存一个数据
  • 存储内容:通常使用字符串,如果字符串以整数的形式展示,可以作为数字操作使用
    • String类型是二进制安全的。
      • 意味着Redis的string可以包含任何数据。比如jpg图片或者序列化的对象。
    • String类型是Redis最基本的数据类型,一个Redis中字符串value最多可以是512M

image-20210905003004908

3、String类型的数据结构

String的数据结构为简单动态字符串(Simple Dynamic String,缩写SDS)。是可以修改的字符串,内部结构实现上类似于Java的ArrayList,采用==预分配冗余空间==的方式来减少内存的频繁分配.

image-20210907223152303

如图中所示:

  • 内部为当前字符串实际分配的空间capacity一般要高于实际字符串长度len
  • 当字符串长度小于1M时,扩容都是加倍现有的空间,如果超过1M,扩容时一次只会多扩1M的空间
  • 需要注意的是字符串最大长度为512M

4、string 类型数据的基本操作

  • 添加/修改数据

    1
    set key value
  • 只有在 key 不存在时 设置 key 的值

    1
    setnx key value
  • 用 value 覆写 key 所储存的字符串值,从<起始位置>开始(索引从0开始)。

    1
    setrange <key><起始位置><value>
  • 获取数据

    1
    get key
  • 获得值的范围,类似java中的substring,前包,后包

    1
    getrange  <key><起始位置><结束位置>
  • 删除数据

    1
    del key
  • 添加/修改多个数据(m:Multiple[ˈmʌltɪpl])

    1
    mset key1 value1 key2 value2 …
  • 获取多个数据

    1
    mget key1 key2 …
  • 获取数据字符个数(字符串长度)

    1
    strlen key
  • 追加信息到原始信息后部(如果原始信息存在就追加,否则新建)

    1
    append key value
  • 设置数值数据增加指定范围的值(操作具有原子性)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 将指定的key的值加1
    incr key

    # 将指定的key的值加 increment (increment为整型)
    # 当然increment也可以为负数,若increment为负数,则功能相当于decrby
    incrby key increment

    # 将指定的key的值加 increment,increment为浮点数
    incrbyfloat key increment
  • 设置数值数据减少指定范围的值

    1
    2
    3
    4
    5
    6
    # 将指定的key的值减1
    decr key

    # 将指定的key的值减 increment (increment为整型)
    # 当然increment也可以为负数,若increment为负数,则功能相当于incrby
    decrby key increment
  • 设置数据具有指定的生命周期

    1
    2
    3
    setex key seconds value

    psetex key milliseconds value

单数据操作与多数据操作的选择之惑:

1
2
3
set key value

mset key1 value1 key2 value2 …

image-20210905003405704

  • 假设需要花费时间的操作有:
    • set 过程
    • 存储过程
    • 返回结果的过程(result)
  • 并且set与result的时间一样
  • 发送100条数据
    • 单指令发送:200 * set/result过程 + 100 * 存储过程
    • 多指令发送:2 * set/result过程 + 100 * 存储过程
  • 这样看来似乎多数据操作会比单数据操作好
  • 其实不然,看似多数据操作会比单数据操作好要快,但是多数据操作数据的回馈并没有比单数据操作好
    • 这里的数据的回馈指的是进行展示的数据
  • 当数据量达到一亿,一次性发送一亿的数据,客户端这边需要等待数据存储的过程将会更长,而使用100万次发送100万次数据的复合操作来说,用户的体验会更好
  • 结论:具体情况具体分析。

5、string 作为数值操作

  • string在redis内部存储默认就是一个字符串,当遇到增减类操作incr,decr时会转成数值型进行计算
  • redis所有的操作都是原子性的,采用单线程处理所有业务,命令是一个一个执行的,因此无需考虑并发带来的数据影响。
  • 注意:按数值进行操作的数据,如果原始数据不能转成数值,或超越了redis 数值上限范围,将报错。
    9223372036854775807(java中long型数据最大值,Long.MAX_VALUE)

6、string 类型数据操作的注意事项

  • 数据操作不成功的反馈与数据正常操作之间的差异
    • 表示运行结果是否成功
      • (integer) 0 → false:失败
      • (integer) 1 → true:成功
    • 表示运行结果值
      • (integer) 3 → 3:3个
      • (integer) 1 → 1:1个
  • 数据未获取到
    • (nil)等同于null
  • 数据最大存储量
    • 512MB
  • 数值计算最大范围(java中的long的最大值)
    • 9223372036854775807

7、string 类型应用场景

1、业务场景

主页高频访问信息显示控制,例如新浪微博大V主页显示粉丝数与微博数量

image-20210905005605687

2、解决方案
  • 在redis中为大V用户设定用户信息,以用户主键和属性值作为key,后台设定定时刷新策略即可

    1
    2
    3
    4
    5
    user:id:3506728370:fans → 12210947

    user:id:3506728370:blogs → 6164

    user:id:3506728370:focuss → 83
  • 在redis中以json格式存储大V用户信息,定时刷新(也可以使用hash类型)

    1
    user:id:3506728370 → {"id":3506728370,"name":"春晚","fans":12210862,"blogs":6164, "focus":83}

8、key 的设置约定

数据库中的热点数据key命名惯例

image-20210905010101165

9、string 类型应用场景

  • Tips 1

    • redis用于控制数据库表主键id,为数据库表主键提供生成策略,保障数据库表的主键唯一性

    • 此方案适用于所有数据库,且支持数据库集群

      • 大型企业级应用中,分表操作是基本操作,使用多张表存储同类型数据,但是对应的主键 id 必须保证统一性,不能重复。Oracle 数据库具有 sequence 设定,可以解决该问题,redis 可以解决 MySQL数据库该问题

        image-20210905004145945

  • Tips 2

    • redis 控制数据的生命周期,通过数据是否失效控制业务行为,适用于所有具有时效性限定控制的操作
      • “最强女生”启动海选投票,只能通过微信投票,每个微信号每 4 小时只能投1票。
      • 电商商家开启热门商品推荐,热门商品不能一直处于热门期,每种商品热门期维持3天,3天后自动取消热门。
      • 新闻网站会出现热点新闻,热点新闻最大的特征是时效性,如何自动控制热点新闻的时效性。
  • Tips 3

    • redis应用于各种结构型和非结构型高热度数据访问加速
      • 主页高频访问信息显示控制,例如新浪微博大V主页显示粉丝数与微博数量

3、hash

1、hash 类型

存储的困惑

对象类数据的存储如果具有较频繁的更新需求操作会显得笨重

image-20210905010321037

  • 新的存储需求:对一系列存储的数据进行编组,方便管理,典型应用存储对象信息
  • 需要的存储结构:一个存储空间保存多个键值对数据
  • hash类型:底层使用哈希表结构实现数据存储

image-20210905010413422

hash存储结构优化:

  • 如果field数量较少,存储结构优化为类数组结构
  • 如果field数量较多,存储结构使用HashMap结构

2、Hash 的数据结构

Hash类型对应的数据结构是两种:

  • ziplist(压缩列表)
  • hashtable(哈希表)。

当field-value长度较短且个数较少时,使用ziplist,否则使用hashtable。

3、hash 类型数据的基本操作

  • 添加/修改数据

    1
    2
    # 设置存储的hashMap 的key 和 value
    hset key field value
  • 获取数据

    1
    2
    3
    4
    5
    # 获取存储的 hashMap的key——field
    hget key field

    # 获取存储的hashMap的所有key
    hgetall key
  • 删除数据

    1
    hdel key field1 [field2]
  • 添加/修改多个数据

    1
    hmset key field1 value1 field2 value2 …
  • 获取多个数据

    1
    hmget key field1 field2 …
  • 获取哈希表中字段的数量

    1
    hlen key
  • 获取哈希表中是否存在指定的字段

    1
    hexists key field
  • 获取哈希表中所有的字段名或字段值

    1
    2
    3
    hkeys key

    hvals key
  • 设置指定字段的数值数据增加指定范围的值

    1
    2
    3
    4
    5
    # 整型
    hincrby key field increment

    # 浮点
    hincrbyfloat key field increment
  • 如果key存在就不改变,如果key不存在就设置filed 与 value

    1
    hsetnx key field value

4、hash 类型数据操作的注意事项

  • hash类型下的value只能存储==字符串==**,不允许存储其他数据类型,不存在嵌套现象如果数据未获取到,对应的值为(nil)**
  • 每个 hash 可以存储 2^32 - 1 个键值对
  • hash类型十分贴近对象的数据存储形式,并且可以灵活添加删除对象属性。但hash设计初衷不是为了存储大量对象而设计的,切记不可滥用,更不可以将hash作为对象列表使用
  • hgetall 操作可以获取全部属性,如果内部field过多,遍历整体数据效率就很会低,有可能成为数据访问瓶颈

5、string存储对象(json)与hash存储对象

  • string存储对象(json)
    • 讲究整体性——一次性数据以整体操作:要么一次性更新,要么一次性获取
    • 讲究的是以 ==读== 为主
  • hash存储对象
    • 由于使用hash存储的话可以使用field将属性隔离开,所以hash讲究的是==更新==操作
    • hash讲究的是==群组==概念,把一系列的数据包装成一个群组,对外产生唯一一个接口——key
    • 如果业务环境以更新操作或修改数量比较多的操作,推荐使用hash的方法存储对象
  • 总结:具体情况具体分析

6、hash 类型应用场景

  • Tips 4
    • redis 应用于购物车数据存储设计
      • 电商网站购物车设计与实现
  • Tips 5
    • redis 应用于抢购,限购类、限量发放优惠卷、激活码等业务的数据存储设计
      • 双11活动日,销售手机充值卡的商家对移动、联通、电信的30元、50元、100元商品推出抢购活动,每种商品抢购上限1000张

4、list

1、list 类型

  • 数据存储需求:存储多个数据,并对数据进入存储空间的顺序进行区分
  • 需要的存储结构:一个存储空间保存多个数据,且通过数据可以体现进入顺序
  • list类型:保存多个数据,底层使用双向链表存储结构实现

image-20210905013542306

image-20210905013558179

2、list 的数据结构

List的数据结构为快速链表quickList

首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是ziplist,也即是压缩列表

  • 它将所有的元素紧挨着一起存储,分配的是一块连续的内存。

当数据量比较多的时候才会改成quicklist

因为普通的链表需要的附加指针空间太大,会比较浪费空间。比如这个列表里存的只是int类型的数据,结构上还需要两个额外的指针prev和next。

image-20210907224104786

Redis将链表和ziplist结合起来组成了quicklist。也就是将多个ziplist使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。

3、list 类型数据基本操作

  • 添加/修改数据

    1
    2
    3
    4
    5
    # 从队列左边添加数据
    lpush key value1 [value2] ……

    # 从队列右边添加数据
    rpush key value1 [value2] ……
  • 获取数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 从队列的左边获取数据,从start到stop(队列的最右边第一个数据的下标为-1)
    # 所以取出所有数据的命令为:lrange key 0 -1
    lrange key start stop

    # 从队列的左边获取第index个数据
    lindex key index

    # 队列的key个数
    llen key
  • 获取并移除数据(值在键在,值光键亡

    1
    2
    lpop key
    rpop key
  • 规定时间内获取并移除数据

    1
    2
    3
    4
    5
    6
    7
    8
    # 在规定时间内从左/右边获取并移除数据,若以达规定时间key1没有数据,返回(nil)
    blpop key1 [key2] timeout
    brpop key1 [key2] timeout

    # 从列表中取出最后一个元素,并插入到另外一个列表的头部;
    # 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
    # source 为 想要取出数据的列表,destination 为 目标列表,timeout 为超时时间
    brpoplpush source destination timeout
  • 从key1列表右边吐出一个值,插到key2列表左边。

    1
    rpoplpush  <key1><key2>
  • 在value的后面插入newvalue插入值

    1
    linsert <key>  before <value><newvalue>
  • 将列表key下标为index的值替换成value

    1
    lset <key><index><value>
  • 移除指定数据

    1
    lrem key count value

4、list 类型数据操作注意事项

  • list中保存的数据都是string类型的,数据总容量是有限的,最多2^32 - 1 个元素 (4294967295)。
  • list具有索引的概念,但是操作数据时通常以队列的形式进行入队出队操作,或以栈的形式进行入栈出栈操作
  • 获取全部数据操作结束索引设置为-1
  • list可以对数据进行分页操作,通常第一页的信息来自于list,第2页及更多的信息通过数据库的形式加载

5、list 类型应用场景

  • **Tips 6 **

    • redis 应用于具有操作先后顺序的数据控制
      • 微信朋友圈点赞,要求按照点赞顺序显示点赞好友信息;如果取消点赞,移除对应好友信息
  • **Tips 7 **

    • redis 应用于最新消息展示

      • twitter、新浪微博、腾讯微博中个人用户的关注列表需要按照用户的关注顺序进行展示,粉丝列表需要将最近关注的粉丝列在前面

      • 新闻、资讯类网站将最新的新闻或资讯按照发生的时间顺序展示

      • 企业运营过程中,系统将产生出大量的运营数据,保障多台服务器操作日志的统一顺序输出

        image-20210905021040556

5、set

1、set 类型

  • 新的存储需求:存储大量的数据,在查询方面提供更高的效率
  • 需要的存储结构:能够保存大量的数据,高效的内部存储机制,便于查询
  • set类型:与hash存储结构完全相同,仅存储键,不存储值(nil),并且值是不允许重复的(自动排重)
  • Redis的Set是string类型的无序集合。它底层其实是一个value为null的hash表,所以添加,删除,查找的==复杂度都是O(1)。==

image-20210905021259230

image-20210905021315084

2、Set 的数据结构

Set数据结构是dict字典,字典是用哈希表实现的。

  • Java中HashSet的内部实现使用的是HashMap,只不过所有的value都指向同一个对象。
  • Redis的set结构也是一样,它的内部也使用hash结构,所有的value都指向同一个内部值。

3、set 类型数据的基本操作

  • 添加数据

    1
    sadd key member1 [member2]
  • 获取全部数据

    1
    smembers key
  • 删除数据

    1
    srem key member1 [member2]
  • 获取集合数据总量

    1
    scard key
  • 判断集合中是否包含指定数据

    1
    sismember key member
  • 随机获取集合中指定数量的数据

    1
    srandmember key [count]
  • 随机获取集合中的某个数据并将该数据移出集合

    1
    spop key [count]
  • 求两个集合的交、并、差集

    1
    2
    3
    4
    5
    6
    7
    8
    # 交集
    sinter key1 [key2]

    # 并集
    sunion key1 [key2]

    # 差集(注意差集的key1与key2互换的话可能导致结果不同)
    sdiff key1 [key2]
  • 求两个集合的交、并、差集并存储到指定集合中

    1
    2
    3
    4
    5
    6
    7
    8
    # 交集
    sinterstore destination key1 [key2]

    # 并集
    sunionstore destination key1 [key2]

    # 差集(注意差集的key1与key2互换的话可能导致结果不同)
    sdiffstore destination key1 [key2]
  • 将指定数据从原始集合中==移动==到目标集合中

    1
    smove source destination member

4、set 类型数据操作的注意事项

  • set 类型不允许数据重复,如果添加的数据在 set 中已经存在,将只保留一份
  • set 虽然与hash的存储结构相同,但是无法启用hash中存储值的空间

5、set 类型应用场景

  • **Tips 8 **
    • redis 应用于随机推荐类信息检索,例如热点歌单推荐,热点新闻推荐,热卖旅游线路,应用APP推荐,大V推荐等
      • 每位用户首次使用今日头条时会设置3项爱好的内容,但是后期为了增加用户的活跃度、兴趣点,必须让用户对其他信息类别逐渐产生兴趣,增加客户留存度
  • **Tips 9 **
    • redis 应用于同类信息的关联搜索,二度关联搜索,深度关联搜索
      • 显示共同关注(一度)
      • 显示共同好友(一度)
      • 由用户A出发,获取到好友用户B的好友信息列表(一度)
      • 由用户A出发,获取到好友用户B的购物清单列表(二度)
      • 由用户A出发,获取到好友用户B的游戏充值列表(二度)
      • 脉脉为了促进用户间的交流,保障业务成单率的提升,需要让每位用户拥有大量的好友,事实上职场新人不具有更多的职场好友,如何快速为用户积累更多的好友?
      • 新浪微博为了增加用户热度,提高用户留存性,需要微博用户在关注更多的人,以此获得更多的信息或热门话题,如何提高用户关注他人的总量?
      • QQ新用户入网年龄越来越低,这些用户的朋友圈交际圈非常小,往往集中在一所学校甚至一个班级中,如何帮助用户快速积累好友用户带来更多的活跃度?
      • 微信公众号是微信信息流通的渠道之一,增加用户关注的公众号成为提高用户活跃度的一种方式,如何帮助用户积累更多关注的公众号?
      • 美团外卖为了提升成单量,必须帮助用户挖掘美食需求,如何推荐给用户最适合自己的美食?
  • **Tips 10 **
    • redis应用于同类型不重复数据的合并操作
      • 集团公司共具有12000名员工,内部OA系统中具有700多个角色,3000多个业务操作,23000多种数据,每位员工具有一个或多个角色,如何快速进行业务操作的权限校验?
  • **Tips 11 **
    • redis 应用于同类型数据的快速去重
      • 公司对旗下新的网站做推广,统计网站的PV(访问量),UV(独立访客),IP(独立IP)。
        • PV:网站被访问次数,可通过刷新页面提高访问量
        • UV:网站被不同用户访问的次数,可通过cookie统计访问量,相同用户切换IP地址,UV不变
        • IP:网站被不同IP地址访问的总次数,可通过IP地址统计访问量,相同IP不同用户访问,IP不变
  • **Tips 12 **
    • redis 应用于基于黑名单与白名单设定的服务控制
      • 黑名单
        • 资讯类信息类网站追求高访问量,但是由于其信息的价值,往往容易被不法分子利用,通过爬虫技术,快速获取信息,个别特种行业网站信息通过爬虫获取分析后,可以转换成商业机密进行出售。例如第三方火车票、机票、酒店刷票代购软件,电商刷评论、刷好评。
        • 同时爬虫带来的伪流量也会给经营者带来错觉,产生错误的决策,有效避免网站被爬虫反复爬取成为每个网站都要考虑的基本问题。在基于技术层面区分出爬虫用户后,需要将此类用户进行有效的屏蔽,这就是黑名单的典型应用。
        • ps:不是说爬虫一定做摧毁性的工作,有些小型网站需要爬虫为其带来一些流量。
      • 白名单
        • 对于安全性更高的应用访问,仅仅靠黑名单是不能解决安全问题的,此时需要设定可访问的用户群体,依赖白名单做更为苛刻的访问验证。

6、sorted_set

1、sorted_set 类型

  • 新的存储需求:数据排序有利于数据的有效展示,需要提供一种可以根据自身特征进行排序的方式
  • 需要的存储结构:新的存储模型,可以保存可排序的数据
  • sorted_set类型:在set的存储结构基础上添加可排序字段
  • 集合的成员是唯一的,但是评分可以是重复了 。

image-20210905023025448

2、Sorted_set 的数据结构

Sorted_set(zset)是Redis提供的一个非常特别的数据结构:

  1. 一方面它等价于Java的数据结构Map<String, Double>,可以给每一个元素value赋予一个权重score;
  2. 另一方面它又类似于TreeSet,内部的元素会按照权重score进行排序,可以得到每个元素的名次,还可以通过score的范围来获取元素的列表。

zset底层使用了两个数据结构:

  1. hash,hash的作用就是关联元素value和权重score,保障元素value的唯一性,可以通过元素value找到相应的score值。
  2. 跳跃表,跳跃表的目的在于给元素value排序,根据score的范围获取元素列表
跳跃表(跳表)
1、简介

有序集合在生活中比较常见,例如根据成绩对学生排名,根据得分对玩家排名等。

对于有序集合的底层实现,可以用:

  • 数组
    • 数组不便元素的插入、删除
  • 平衡树
    • 平衡树或红黑树虽然效率高但结构复杂
  • 链表
    • 链表查询需要遍历所有效率低。

Redis采用的是跳跃表。跳跃表效率堪比红黑树,实现远比红黑树简单。

2、实例

对比有序链表和跳跃表,从链表中查询出51

(1) 有序链表

image-20210907225230388

要查找值为51的元素,需要从第一个元素开始依次查找、比较才能找到。共需要6次比较

(2) 跳跃表

image-20210907225311811

  1. 从第2层开始,1节点比51节点小,向后比较。
  2. 21节点比51节点小,继续向后比较,后面就是NULL了,所以从21节点向下到第1层
  3. 在第1层,41节点比51节点小,继续向后,61节点比51节点大,所以从41向下
  4. 在第0层,51节点为要查找的节点,节点被找到,共查找4次

从此可以看出跳跃表比有序链表效率要高

3、sorted_set 类型数据的基本操作

  • 添加数据

    1
    zadd key score1 member1 [score2 member2]
  • 获取全部数据

    1
    2
    3
    4
    5
    6
    # 从start到stop顺序获取key当中的数据
    # WITHSCORES 获取key的member的同时获取member的scores
    zrange key start stop [WITHSCORES]

    # 从start到stop逆序获取key当中的数据
    zrevrange key start stop [WITHSCORES]
  • 删除数据

    1
    zrem key member [member ...]
  • 按条件获取数据

    1
    2
    3
    4
    5
    # min与max用于限定搜索查询的条件
    # LIMIT 与mysql的LIMIT用法一样,用来限制数据的数量
    zrangebyscore key min max [WITHSCORES] [LIMIT]

    zrevrangebyscore key max min [WITHSCORES]
  • 条件删除数据

    1
    2
    3
    zremrangebyrank key start stop

    zremrangebyscore key min max
  • 获取集合数据总量

    1
    2
    3
    zcard key

    zcount key min max

    注意:

    • min与max用于限定搜索查询的条件
    • start与stop用于限定查询范围,作用于索引,表示开始和结束索引
    • offset与count用于限定查询范围,作用于查询结果,表示开始位置和数据总量
  • 集合交、并操作

    1
    2
    3
    4
    5
    6
    7
    # 求最大最小值
    # zinterstore sss 3 s1 s2 s3 agggregate max/min
    zinterstore destination numkeys key [key ...]

    # 求公共部分的和
    # zinterstore ss 3 s1 s2 s3
    zunionstore destination numkeys key [key ...]
  • 获取数据对应的索引(排名)

    1
    2
    3
    zrank key member

    zrevrank key member
  • score值获取与修改

    1
    2
    3
    zscore key member

    zincrby key increment member
  • 获取当前系统时间

    1
    2
    3
    # score 秒
    # member 毫秒
    time

4、sorted_set 类型数据操作的注意事项

  • score保存的数据存储空间是64位,如果是整数范围是-9007199254740992~9007199254740992
  • score保存的数据也可以是一个双精度的double值,基于双精度浮点数的特征,可能会丢失精度,使用时候要慎重
  • sorted_set 底层存储还是基于set结构的,因此数据不能重复如果重复添加相同的数据,score值将被反复覆盖,保留最后一次修改的结果

5、sorted_set 类型应用场景

  • **Tips 13 **
    • redis 应用于计数器组合排序功能对应的排名
      • 票选广东十大杰出青年,各类综艺选秀海选投票
      • 各类资源网站TOP10(电影,歌曲,文档,电商,游戏等)
      • 聊天室活跃度统计
      • 游戏好友亲密度
  • **Tips 14 **
    • redis 应用于定时任务执行顺序管理或任务过期管理
      • 基础服务+增值服务类网站会设定各位会员的试用,让用户充分体验会员优势。例如观影试用VIP、游戏VIP体验、云盘下载体验VIP、数据查看体验VIP。当VIP体验到期后,如果有效管理此类信息。即便对于正式VIP用户也存在对应的管理方式。
      • 网站会定期开启投票、讨论,限时进行,逾期作废。如何有效管理此类过期信息。
  • **Tips 15 **
    • redis 应用于即时任务/消息队列执行管理
      • 任务/消息权重设定应用:
        • 当任务或者消息待处理,形成了任务队列或消息队列时,对于高优先级的任务要保障对其优先处理,如何实现任务权重管理。

7、数据类型实践案例

1、业务场景1

人工智能领域的语义识别与自动对话将是未来服务业机器人应答呼叫体系中的重要技术,百度自研用户评价语义识别服务,免费开放给企业试用,同时训练百度自己的模型。现对试用用户的使用行为进行限速,限制每个用户每分钟最多发起10次调用

image-20210905024857928

2、解决方案

  • 设计计数器,记录调用次数,用于控制业务执行次数。以用户id作为key,使用次数作为value
  • 在调用前获取次数,判断是否超过限定次数
    • 不超过次数的情况下,每次调用计数+1
    • 业务调用失败,计数-1
  • 为计数器设置生命周期为指定周期,例如1秒/分钟,自动清空周期内使用次数

image-20210905024953197

3、解决方案改良

  • 取消最大值的判定,利用incr操作超过最大值抛出异常的形式替代每次判断是否大于最大值
  • 判断是否为nil,
    • 如果是,设置为Max-次数
    • 如果不是,计数+1
    • 业务调用失败,计数-1
  • 遇到异常即+操作超过上限,视为使用达到上限

image-20210905025121657

**Tips 16 **

  • redis 应用于限时按次结算的服务控制

4、业务场景2

使用微信的过程中,当微信接收消息后,会默认将最近接收的消息置顶,当多个好友及关注的订阅号同时发送消息时,该排序会不停的进行交替。同时还可以将重要的会话设置为置顶。一旦用户离线后,再次打开微信时,消息该按照什么样的顺序显示?

5、业务分析

image-20210905025305653

6、解决方案

  • 依赖list的数据具有顺序的特征对消息进行管理,将list结构作为栈使用
  • 对置顶与普通会话分别创建独立的list分别管理
  • 当某个list中接收到用户消息后,将消息发送方的id从list的一侧加入list(此处设定左侧)
  • 多个相同id发出的消息反复入栈会出现问题,在入栈之前无论是否具有当前id对应的消息,先删除对应id
  • 推送消息时先推送置顶会话list,再推送普通会话list,推送完成的list清除所有数据
  • 消息的数量,也就是微信用户对话数量采用计数器的思想另行记录,伴随list操作同步更新

**Tips 17 **

redis 应用于基于时间顺序的数据操作,而不关注具体时间

8、解决方案列表

  1. Tips 1:redis用于控制数据库表主键id,为数据库表主键提供生成策略,保障数据库表的主键唯一性
  2. Tips 2:redis 控制数据的生命周期,通过数据是否失效控制业务行为,适用于所有具有时效性限定控制的操作
  3. Tips 3:redis应用于各种结构型和非结构型高热度数据访问加速
  4. Tips 4:redis 应用于购物车数据存储设计
  5. Tips 5:redis 应用于抢购,限购类、限量发放优惠卷、激活码等业务的数据存储设计
  6. Tips 6:redis 应用于具有操作先后顺序的数据控制
  7. Tips 7:redis 应用于最新消息展示
  8. Tips 8:redis 应用于随机推荐类信息检索,例如热点歌单推荐,热点新闻推荐,热卖旅游线路,应用APP推荐,大V推荐等
  9. Tips 9:redis 应用于同类信息的关联搜索,二度关联搜索,深度关联搜索
  10. Tips 10:redis 应用于同类型不重复数据的合并、取交集操作
  11. Tips 11:redis 应用于同类型数据的快速去重
  12. Tips 12:redis 应用于基于黑名单与白名单设定的服务控制
  13. Tips 13:redis 应用于计数器组合排序功能对应的排名
  14. Tips 14:redis 应用于定时任务执行顺序管理或任务过期管理
  15. Tips 15:redis 应用于及时任务/消息队列执行管理
  16. Tips 16:redis 应用于按次结算的服务控制
  17. Tips 17:redis 应用于基于时间顺序的数据操作,而不关注具体时间

3、Redis 通用指令

1、key通用指令

1、key 特征

  • key是一个字符串,通过key获取redis中保存的数据
  • key应该设计哪些操作?
    • 对于key自身状态的相关操作,例如:删除,判定存在,获取类型等
    • 对于key有效性控制相关操作,例如:有效期设定,判定是否有效,有效状态的切换等
    • 对于key快速查询操作,例如:按指定策略查询key
    • ……

2、key 基本操作

  • 删除指定key

    1
    del key
  • 根据value选择非阻塞删除

    1
    unlink key

    仅将keys从keyspace元数据中删除,真正的删除会在后续异步操作。

    • 惰性删除lazyfree的机制,它可以将删除键或数据库的操作放在后台线程里执行,删除对象时只是进行逻辑删除,从而尽可能地避免服务器阻塞。
  • 获取key是否存在

    1
    exists key
  • 获取key的类型

    1
    type key

3、key 扩展操作

1、key 扩展操作——时效性控制
  • 为指定key设置有效期

    1
    2
    3
    4
    5
    6
    7
    # 设置的是时间
    expire key seconds
    pexpire key milliseconds

    # 设置的是时间戳
    expireat key timestamp
    pexpireat key milliseconds-timestamp
  • 获取key的有效时间

    1
    2
    3
    4
    5
    6
    # time to live
    # 不存在返回-2
    # 存在返回-1(永久)
    # 存在并且设置了有效期(返回有效时间)
    ttl key
    pttl key
  • 切换key从时效性转换为永久性

    1
    persist key
2、key 扩展操作——查询模式
  • 查询key

    1
    keys pattern

查询模式规则

  • *:匹配任意数量的任意符号
  • ?:配合一个任意符号
  • []:匹配一个指定符号

image-20210905030341819

4、key 其他操作

  • 为key改名

    1
    2
    3
    4
    5
    # 如果修改的名称在redis当中存在,则会进行覆盖(将里面的内容进行覆盖)
    # 解决方法:renamenx(如果存在则改名失败)
    rename key newkey

    renamenx key newkey
  • 对所有key排序

    1
    2
    # 只是排序,不动元数据存储的顺序
    sort
  • 其他key通用操作

    1
    help @generic

2、数据库通用指令

1、数据库

key 的重复问题

  • key是由程序员定义的
  • redis在使用过程中,伴随着操作数据量的增加,会出现大量的数据以及对应的key
  • 数据不区分种类、类别混杂在一起,极易出现重复或冲突

解决方案

  • redis为每个服务提供有16个数据库,编号从0到15
    • 默认使用的是第0号数据库
  • 每个数据库之间的数据相互独立
    • 这些数据库共用一块空间

image-20210905030731903

2、db 基本操作

  • 切换数据库

    1
    select index
  • 其他操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    quit

    # PONG
    # 进行数据回显,测试海外是否连通
    ping

    # 给redis控制台输出日志
    # eg:
    # 127.0.0.1:6379> echo abc
    # 127.0.0.1:6379> "abc"
    echo message
  • 数据移动

    1
    move key db
    1. move相当于剪切操作
    2. 如果原数据库没有数据,move失败
    3. 如果目标数据库已存在数据,move失败
    4. 注意:
      • 进行move操作的是原数据库
      • 数据移动的数据库是目标数据库
  • 数据清除

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 检查当前数据库有多少个key
    dbsize

    # 清除当前数据库的所有数据
    flushdb

    # 清除redis当中所有数据(最强大的一个命令,慎用)
    # 执行flushall命令,也会产生dump.rdb文件,但里面是空的,无意义
    flushall

3、常用服务器命令

  • 检验连接状态

    1
    2
    #如果连接成功返回PONG,连接失败返回错误信息
    PING

    img

  • 验证密码是否正确

    1
    auth password

    img

  • 查看服务器信息

    1
    INFO [section]

    img

  • 查看配置信息

    1
    config get patten

    img

  • 修改当前配置信息

    Config Set 命令可以动态地调整 Redis 服务器的配置(configuration)而无须重启,但此时配置文件中仍是修改前的配置,可搭配config rewrite命令一起使用:

    1
    CONFIG SET parameter value

    img

  • 重写配置文件

    Config rewrite 命令对启动 Redis 服务器时所指定的 redis.conf 配置文件进行改写。与config set不同,set之后会将配置信息修改而无需重启服务,但此时redis.conf配置文件里记录的参数仍是set之前的值,如果将redis服务重启后会读取conf文件中的配置,这时候读到的还是set之前的配置,因此我们可以在set配置之后使用rewrite命令将当前的配置回写至配置文件内,这样就能不停机修改配置信息了,因此config set和config rewrite是配合使用的:

    1
    CONFIG REWRITE

    img

  • 重置统计信息

    • 使用Config Resetstat 命令重置 INFO 命令中的某些统计数据,包括:
      • Keyspace hits (键空间命中次数)
      • Keyspace misses (键空间不命中次数)
      • Number of commands processed (执行命令的次数)
      • Number of connections received (连接服务器的次数)
      • Number of expired keys (过期key的数量)
      • Number of rejected connections (被拒绝的连接数量)
      • Latest fork(2) time(最后执行 fork(2) 的时间)
      • The aof_delayed_fsync counter(aof_delayed_fsync 计数器的值)
    1
    CONFIG RESETSTAT
  • 获取当前时间

    Time 命令用于返回当前服务器时间,返回一个包含两个字符串的列表: 第一个字符串是当前时间(以 UNIX 时间戳格式表示),而第二个字符串是当前这一秒钟已经逝去的微秒数。

    1
    time

    img

  • DeBug

    debug object key获取 key 的调试信息,当key不存在时返回错误信息。

    debug segfault 命令执行一个非法的内存访问从而让 Redis 崩溃,仅在开发时用于 BUG 调试,执行后需要重启服务。

    1
    2
    debug object key
    debug segfault

    img

  • 查看当前Redis中所有可用命令

    • 使用Command 命令用于返回所有的Redis命令的详细信息,以数组形式展示:

      1
      command
    • 使用command count命令查看当前Redis中命令的数量:

      1
      command count
    • 使用command info命令查看当前Redis中指定的命令的详细信息:

      1
      COMMAND INFO command-name [command-name ...]

    img

  • 彩蛋

    Redis5之后新增的彩蛋,使用LOLWUT命令即可返回一副随机图像以及当前redis的版本信息。事实上LOLWUT没有任何作用,但它想告诉我们的是:”编程不仅仅是把一些代码放在一起创建有用的东西,也可以是无用但有趣的。

    1
    LOLWUT

    img


4、Jedis

1、Jedis简介

编程语言与redis:

  • Java语言连接redis服务
    • Jedis
    • SpringData Redis
    • Lettuce
  • C 、C++ 、C# 、Erlang、Lua 、Objective-C 、Perl 、PHP 、Python 、Ruby 、Scala
  • 可视化连接redis客户端
    • Redis Desktop Manager
    • Redis Client
    • Redis Studio

2、HelloWorld(Jedis版)

1、准备工作

  • jar包导入

  • 基于maven

    1
    2
    3
    4
    5
    <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
    </dependency>

2、客户端连接redis

  • API文档

  • 连接redis

    1
    Jedis jedis = new Jedis("localhost", 6379);
  • 操作redis(jedis的API与redis的命令是一样的)

    1
    2
    jedis.set("name", "itheima");
    jedis.get("name");
  • 关闭redis连接

    1
    jedis.close();

3、Jedis简易工具类开发

1、基于连接池获取连接

  • JedisPool:Jedis提供的连接池技术
    • poolConfig:连接池配置对象
    • host:redis服务地址
    • port:redis服务端口号
1
2
3
4
5
6
/**
* JedisPool(org.apache.commons.pool2.impl.GenericObjectPoolConfig poolConfig, String host, int port, int timeout, String password, int database, String clientName)
*/
public JedisPool(GenericObjectPoolConfig poolConfig, String host, int port) {
this(poolConfig, host, port, 2000, (String)null, 0, (String)null);
}

2、封装连接参数

jedis.properties:

1
2
3
4
jedis.host=localhost
jedis.port=6379
jedis.maxTotal=30
jedis.maxIdle=10

3、加载配置信息

静态代码块初始化资源:

1
2
3
4
5
6
7
8
9
10
11
12
static{
//读取配置文件 获得参数值
ResourceBundle rb = ResourceBundle.getBundle("jedis");
host = rb.getString("jedis.host");
port = Integer.parseInt(rb.getString("jedis.port"));
maxTotal = Integer.parseInt(rb.getString("jedis.maxTotal"));
maxIdle = Integer.parseInt(rb.getString("jedis.maxIdle"));
poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(maxTotal);
poolConfig.setMaxIdle(maxIdle);
jedisPool = new JedisPool(poolConfig,host,port);
}

4、加载配置信息

对外访问接口,提供jedis连接对象,连接从连接池获取:

1
2
3
4
public static Jedis getJedis(){
Jedis jedis = jedisPool.getResource();
return jedis;
}

4、可视化客户端

Redis Desktop Manager:

image-20210905032008447


2、Redis高级(Linux)

1、基于Linux环境安装Redis

1、Redis在Linux环境下的安装

  • 下载安装包

    1
    wget http://download.redis.io/releases/redis-?.?.?.tar.gz
  • 解压

    1
    tar –xvf 文件名.tar.gz
  • 编译

    1
    make
  • 安装

    1
    make install [destdir=/目录]

2、Redis基础环境设置

  • 创建软链接

    1
    ln -s 原始目录名 快速访问目录名
  • 创建配置文件管理目录

    1
    2
    3
    mkdir conf
    # 或者
    mkdir config
  • 创建数据文件管理目录

    1
    mkdir data

3、Redis服务启动

  • 默认配置启动

    1
    2
    3
    redis-server
    redis-server –-port 6379
    redis-server –-port 6380 ……
  • 指定配置文件启动

    1
    2
    3
    4
    5
    redis-server redis.conf
    redis-server redis-6379.conf
    redis-server redis-6380.conf ……
    redis-server conf/redis-6379.conf
    redis-server config/redis-6380.conf ……
  • 开机自启动

    • 注册服务:

      1
      vim /lib/systemd/system/redis.service
    • 配置文件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      [Unit]
      Description=Redis
      After=network.target

      [Service]
      Type=forking
      PIDFile=/var/run/redis_6379.pid
      ExecStart=/opt/app/redis6/bin/redis-server /opt/app/redis6/bin/redis.conf
      ExecReload=/bin/kill -s HUP $MAINPID
      ExecStop=/bin/kill -s QUIT $MAINPID
      PrivateTmp=true

      [Install]
      WantedBy=multi-user.target
    • 使用systemctl命令:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      # 重载服务
      systemctl daemon-reload
      # 开机自启
      systemctl enable redis
      # 启动
      systemctl start redis

      # 重启
      systemctl restart redis   

      # 停止
      systemctl stop redis
      # 查看状态
      systemctl status redis

      # 关闭开机启动
      systemctl disable redis

      img

4、Redis客户端连接

  • 默认连接

    1
    redis-cli

    其中加上–raw可以防止中文乱码

    1
    redis-cli --raw
  • 连接指定服务器

    1
    2
    3
    redis-cli -h 127.0.0.1
    redis-cli –port 6379
    redis-cli -h 127.0.0.1 –port 6379

5、Redis服务端配置

  • 基本配置

    • 以守护进程方式启动,使用本启动方式,redis将以服务的形式存在,日志将不再打印到命令窗口中

      1
      daemonize yes
    • 取消绑定ip,监听所有IP

      1
      2
      # 把这一行注释,监听所有IP
      #bind 127.0.0.1
    • 开启保护模式

      1
      2
      # protected-mode yes 如果改为no,则是关闭保护模式,这种模式下不能配置系统服务,建议还是开启
      protected-mode yes
    • 设定当前服务启动端口号

      1
      port 6***
    • 设定当前服务文件保存位置,包含日志文件、持久化文件(后面详细讲解)等

      1
      dir "/自定义目录/redis/data"
    • 设定日志文件名,便于查阅

      1
      logfile "6***.log"

2、Redis 持久化

1、持久化简介

1、什么是持久化

利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化。

2、为什么要进行持久化

防止数据的意外丢失,确保数据安全性

3、持久化过程保存什么

  • 当前数据状态进行保存,快照形式,存储数据结果,存储格式简单,关注点在数据

    image-20210905174735216

  • 数据的操作过程进行保存,日志形式,存储操作过程,存储格式复杂,关注点在数据的操作过程

    image-20210905174748200

2、RDB

1、RDB普通启动方式

1、RDB启动方式 —— save指令
1、RDB启动方式

谁,什么时间,干什么事情

命令执行:

  • 谁:redis操作者(用户)
  • 什么时间:即时(随时进行)
  • 干什么事情:保存数据
2、RDB启动方式 —— save指令
  • 命令

    1
    save
  • 作用:手动执行一次保存操作

3、RDB启动方式 —— save指令相关配置
  • dbfilename dump.rdb
    
    1
    2
    3
    4
    5
    6

    - 说明:**设置本地数据库文件名,默认值为 dump.rdb**
    - 经验:通常设置为==dump-端口号.rdb==,方便查看

    - ```sh
    dir
    - 说明:**设置存储.rdb文件的路径** - 经验:通常设置成存储空间较大的目录中,==目录名称data==
  • rdbcompression yes
    
    1
    2
    3
    4
    5
    6

    - 说明:**设置存储至本地数据库时是否压缩数据,默认为 yes**,采用 `LZF 压缩`
    - 经验:通常默认为开启状态,如果设置为no,可以节省 CPU 运行时间,但会使存储的文件变大(巨大)

    - ```sh
    rdbchecksum yes
    - 说明:**设置是否进行RDB文件格式校验,该校验过程在写文件和读文件过程均进行**(让redis使用CRC64算法来进行数据校验) - 经验:**通常默认为开启状态**,如果设置为no,可以节约读写性过程约10%时间消耗,但是==存储一定的数据损坏风险==
4、RDB启动方式 —— save指令工作原理

image-20210905175219504

image-20210905175236464

注意:save指令的执行会阻塞当前Redis服务器,直到当前RDB过程完成为止,有可能会造成长时间阻塞,线上环境不建议使用

2、RDB启动方式 —— bgsave指令
1、RDB启动方式

数据量过大,单线程执行方式造成效率过低如何处理?

后台执行:

  • 谁:redis操作者(用户)发起指令;redis服务器控制指令执行
  • 什么时间:即时(发起);合理的时间(执行)
  • 干什么事情:保存数据
2、RDB启动方式 —— bgsave指令
  • 命令

    1
    bgsave
  • 作用:手动启动后台保存操作,但不是立即执行

3、RDB启动方式 —— bgsave指令工作原理

image-20210905175450021

  1. Redis会单独创建(fork)一个子进程来进行持久化;
  2. Redis会先将数据写入到 一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。
  3. 整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能 如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那RDB方式要比AOF方式更加的高效。

关于fork:

  1. Fork的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等) 数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程
  2. 在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,Linux中引入了“写时复制技术
  3. 一般情况父进程和子进程会共用同一段物理内存,只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。

注意:

  • bgsave命令是针对save阻塞问题做的优化。
  • Redis内部所有涉及到RDB操作都采用bgsave的方式
  • save命令可以放弃使用。
4、RDB启动方式 —— bgsave指令相关配置
  • dbfilename dump.rdb

  • dir

  • rdbcompression yes

  • rdbchecksum yes

  • stop-writes-on-bgsave-error yes
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

    - 说明:**后台存储过程中如果出现错误现象,是否停止保存操作**
    - 经验:通常**默认为开启状态**

    ##### 3、RDB启动方式 ——save配置

    ###### 1、RDB启动方式

    反复执行保存指令,忘记了怎么办?不知道数据产生了多少变化,何时保存?

    自动执行:

    - 谁:redis服务器发起指令(基于条件)
    - 什么时间:满足条件
    - 干什么事情:保存数据

    ###### 2、RDB启动方式 ——save配置

    - 配置

    ```sh
    save second changes
    底层使用了`bgsave`指令
  • 作用:满足限定时间范围内key的变化数量达到指定数量即进行持久化

  • 参数

    • second:监控时间范围
    • changes:监控key的变化量
  • 位置:在conf文件中进行配置

  • 范例:

    1
    2
    3
    save 900 1
    save 300 10
    save 60 10000

    注意:

    • 一般second与changes两个值的设置差别会比较大,要不就前小后大,要不就前大后小。具体看相关的业务。
    • 两个值差别不大的话,设置没什么意义。
3、RDB启动方式 ——save配置原理

image-20210905180056763

注意:

  • save配置要根据实际业务情况进行设置,频度过高或过低都会出现性能问题,结果可能是灾难性的
  • save配置中对于second与changes设置通常具有互补对应关系,尽量不要设置成包含性关系
  • save配置启动后执行的是bgsave操作
4、save配置相关配置
  • dbfilename dump.rdb
  • dir
  • rdbcompression yes
  • rdbchecksum yes

2、RDB的备份

  1. 先通过config get dir 查询rdb文件的目录
  2. 将*.rdb的文件拷贝到别的地方
    1. rdb的恢复
      1. 关闭Redis
      2. 先把备份的文件拷贝到工作目录下 cp dump2.rdb dump.rdb
      3. 启动Redis, 备份数据会直接加载

3、RDB的停止

动态停止RDB:

1
2
#save后给空值,表示禁用保存策略
redis-cli config set save ""

4、RDB三种启动方式对比

方式 save指令 bgsave指令
读写 同步 异步
阻塞客户端指令
额外内存消耗
启动新进程

注:由于替换save配置启动RDB在底层也是调用了bgsave指令,所以这里不做展示。

5、RDB特殊启动形式

  • 全量复制

    • 在主从复制中详细讲解
  • 服务器运行过程中重启

    1
    debug reload
  • 关闭服务器时指定保存数据

    1
    shutdown save

    默认情况下执行shutdown命令时,自动执行bgsave(如果没有开启AOF持久化功能)

6、RDB优缺点

1、RDB优点
  1. RDB是一个==紧凑压缩的二进制文件,存储效率较高==
  2. RDB内部存储的是redis在==某个时间点==的数据快照,非常适合用于数据备份,全量复制等场景
  3. RDB==恢复数据==的速度要比AOF==快==很多
  4. 应用:服务器中每X小时执行bgsave备份,并将RDB文件拷贝到远程机器中,==用于灾难恢复==。
2、RDB缺点
  1. RDB方式无论是执行指令还是利用配置,==无法做到实时持久化,具有较大的可能性丢失数据==
  2. bgsave指令==每次运行要执行fork操作创建子进程,内存中的数据被克隆了一份,大致2倍的膨胀性需要考虑,要牺牲掉一些性能==
  3. 虽然Redis在fork时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能。
  4. Redis的==众多版本中未进行RDB文件格式的版本统一,有可能出现各版本服务之间数据格式无法兼容现象==
  5. 在备份周期在一定间隔时间做一次备份,所以如果Redis意外down掉的话,就会丢失最后一次快照后的所有修改。

关于第四缺点的相关说明:

  • redis2.0的RDB文件不能恢复成redis4.0的数据

一个解决方法:(不得已的方法)

  1. 先将redis2.0的RDB文件恢复成redis2.0的数据;
  2. 在将数据存储到数据库当中;
  3. 最后将数据库作为数据源将数据恢复成redis4.0的数据,并生成redis4.0的RDB文件

3、AOF

1、RDB存储的弊端

  • 存储数据量较大,效率较低
    • 基于快照思想,每次读写都是全部数据,当数据量巨大时,效率非常低
  • 大数据量下的IO性能较低
  • 基于fork创建子进程,内存产生额外消耗
  • 宕机带来的数据丢失风险

解决思路:

  • 不写全数据,仅记录部分数据
  • 降低区分数据是否改变的难度,改记录数据为记录操作过程
  • 对所有操作均进行记录,排除丢失数据的风险

2、AOF概念

  • AOF(append only file)持久化:
    • 以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中命令达到恢复数据的目的。
    • 与RDB相比可以简单描述为改记录数据为记录数据产生的过程
  • AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式

3、AOF写数据过程

image-20210905181851353

  1. 客户端的请求写命令会被append追加到AOF缓冲区内;
  2. AOF缓冲区根据AOF持久化策略[always,everysec,no]将操作sync同步到磁盘的AOF文件中;
  3. AOF文件大小超过重写策略或手动重写时,会对AOF文件rewrite重写,压缩AOF文件容量;
  4. Redis服务重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的;

4、AOF写数据三种策略(appendfsync)

  • always(每次):
    • 每次写入操作均同步到AOF文件中,==数据零误差,性能较低==,不建议使用
  • everysec(每秒):
    • 每秒将缓冲区中的指令同步到AOF文件中,==数据准确性较高,性能较高==,建议使用,也是默认配置
    • 在系统突然==宕机的情况下丢失1秒内的数据==
  • no(系统控制):
    • 由操作系统控制每次同步到AOF文件的周期,==整体过程不可控==

5、AOF功能开启

  • 配置

    1
    appendonly yes|no
  • 作用:是否开启AOF持久化功能,默认为不开启状态

  • 配置

    1
    appendfsync always|everysec|no
  • 作用:AOF写数据策略

6、AOF相关配置

  • 配置

    1
    appendfilename filename
  • 作用:

    • AOF持久化文件名,默认文件名为appendonly.aof
    • **建议配置为appendonly-端口号.aof**,方便查看
  • 配置

    1
    dir
  • 作用:AOF持久化文件保存路径,与RDB持久化文件保持一致即可

7、AOF写数据遇到的问题

如果连续执行如下指令该如何处理?

image-20210905183132934

8、AOF重写

  • 随着命令不断写入AOF,文件会越来越大,为了解决这个问题,Redis引入了AOF重写机制压缩文件体积。

  • AOF文件重写是将Redis进程内的数据转化为写命令同步到新AOF文件的过程。

  • 简单说就是将对同一个数据的若干个条命令执行结果转化成最终结果数据对应的指令进行记录

9、AOF重写作用

  • 降低磁盘占用量,提高磁盘利用率
  • 提高持久化效率,降低持久化写时间,提高IO性能
  • 降低数据恢复用时,提高数据恢复效率

10、AOF重写规则

  • 进程内已超时的数据不再写入文件
  • 忽略无效指令,重写时使用进程内数据直接生成,这样新的AOF文件只保留最终数据的写入命令
    • 如del key1、 hdel key2、srem key3、set key4 111、set key4 222等
  • 对同一数据的多条写命令合并为一条命令
    • 如lpush list1 a、lpush list1 b、 lpush list1 c 可以转化为:lpush list1 a b c。
    • 为防止数据量过大造成客户端缓冲区溢出,对list、set、hash、zset等类型,每条指令最多写入64个元素

11、AOF重写方式

  • 手动重写

    1
    bgrewriteaof
  • 自动重写

    1
    2
    auto-aof-rewrite-min-size size
    auto-aof-rewrite-percentage percentage

12、AOF手动重写 —— bgrewriteaof指令工作原理

image-20210905183605904

AOF文件持续增长而过大时,会fork出一条新进程来将文件重写(也是先写临时文件最后再rename),redis4.0版本后的重写,是指上就是把rdb 的快照,以二级制的形式附在新的aof头部,作为已有的历史数据,替换掉原来的流水账操作

1
no-appendfsync-on-rewrite=yes
  • 如果 no-appendfsync-on-rewrite=yes,不写入aof文件只写入缓存,用户请求不会阻塞,但是在这段时间如果宕机会丢失这段时间的缓存数据。(降低数据安全性,提高性能)
  • 如果 no-appendfsync-on-rewrite=no,还是会把数据往磁盘里刷,但是遇到重写操作,可能会发生阻塞。(数据安全,但是性能降低)

13、AOF自动重写方式

  • 自动重写触发条件设置

    1
    2
    3
    4
    5
    # 设置重写的基准值,最小文件64MB。达到这个值开始重写。
    auto-aof-rewrite-min-size size

    # 设置重写的基准值,文件达到100%时开始重写(文件是原来重写后文件的2倍时触发)
    auto-aof-rewrite-percentage percent
  • 自动重写触发比对参数( 运行指令info Persistence获取具体信息)

    1
    2
    aof_current_size
    aof_base_size
  • 自动重写触发条件

    image-20210905184840300

  • 列出当前redis的所有的运行属性值

    1
    info

AOF什么时候会自动重写?

  • Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍文件大于64M时触发
  • 重写虽然可以节约大量磁盘空间,减少恢复时间。但是每次重写还是有一定的负担的,因此设定Redis要满足一定条件才会进行重写。

例如:文件达到70MB开始重写,降到50MB,下次什么时候开始重写?100MB

系统载入时或者上次重写完毕时,Redis会记录此时AOF大小,设为base_size,如果Redis的AOF当前大小 >= base_size +base_size*100% (默认)且当前大小>=64mb(默认)的情况下,Redis会对AOF进行重写。

14、AOF工作流程

image-20210905184924236

15、AOF重写流程

image-20210905185022041

image-20210905185033618

  1. bgrewriteaof触发重写,判断是否当前有bgsave或bgrewriteaof在运行,如果有,则等待该命令结束后再继续执行。
  2. 主进程fork出子进程执行重写操作,保证主进程不会阻塞。
  3. 子进程遍历redis内存中数据到临时文件,客户端的写请求同时写入aof_buf缓冲区和aof_rewrite_buf重写缓冲区保证原AOF文件完整以及新AOF文件生成期间的新的数据修改动作不会丢失。
  4. 子进程写完新的AOF文件后,向主进程发信号,父进程更新统计信息。
  5. 主进程把aof_rewrite_buf中的数据写入到新的AOF文件。
  6. 使用新的AOF文件覆盖旧的AOF文件,完成AOF重写。

16、系统调用write和fsync说明

AOF缓冲区同步文件策略,由参数appendfsync控制

系统调用write和fsync说明:

  • write操作会触发延迟写(delayed write)机制,Linux在内核提供页缓冲区用来提高硬盘IO性能。
    • write操作在写入系统缓冲区后直接返回。
    • 同步硬盘操作依赖于系统调度机制,列如:缓冲区页空间写满或达到特定时间周期。
    • 同步文件之前,如果此时系统故障宕机,缓冲区内数据将丢失。
  • fsync针对单个文件操作(比如AOF文件),做强制硬盘同步,fsync将阻塞知道写入硬盘完成后返回,保证了数据持久化。

除了write、fsync、Linx还提供了sync、fdatasync操作,具体参见API说明。

17、AOF的优缺点

1、优点
  • 备份机制更稳健,丢失数据概率更低。
  • 可读的日志文本,通过操作AOF稳健,可以处理误操作。
2、缺点
  • 比起RDB占用更多的磁盘空间。
  • 恢复备份速度要慢。
  • 每次读写都同步的话,有一定的性能压力。
  • 存在个别Bug,造成恢复不能。

4、RDB与AOF区别

1、RDB VS AOF

持久化方式 RDB AOF
占用存储空间 小(数据级:压缩) 大(指令级:重写)
存储速度
恢复速度
数据安全性 会丢失数据 依据策略决定
资源消耗 高/重量级 低/轻量级
启动优先级

2、RDB与AOF的选择之惑

  • 对数据非常敏感,建议使用默认的AOF持久化方案
    • AOF持久化策略使用everysecond,每秒钟fsync一次。该策略redis仍可以保持很好的处理性能,当出现问题时,最多丢失0-1秒内的数据。
    • 注意:由于AOF文件存储体积较大,且恢复速度较慢
  • 数据呈现阶段有效性,建议使用RDB持久化方案
    • 数据可以良好的做到阶段内无丢失(该阶段是开发者或运维人员手工维护的),且恢复速度较快,阶段点数据恢复通常采用RDB方案
    • 注意:利用RDB实现紧凑的数据持久化会使Redis降的很低,慎重总结:
  • 综合比对
    • RDB与AOF的选择实际上是在做一种权衡,每种都有利有弊
    • 如不能承受数分钟以内的数据丢失,对业务数据非常敏感,选用AOF
    • 如能承受数分钟以内的数据丢失,且追求大数据集的恢复速度,选用RDB
    • 灾难恢复选用RDB
    • 双保险策略,同时开启 RDB 和 AOF,重启后,Redis优先使用 AOF 来恢复数据,降低丢失数据的量

官方推荐两个都启用。

  • 如果对数据不敏感,可以选单独用RDB。
  • 不建议单独用 AOF,因为可能会出现Bug。
  • 如果只是做纯内存缓存,可以都不用。

image-20210908114833273

  • RDB持久化方式能够在指定的时间间隔能对你的数据进行快照存储
  • AOF持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF命令以redis协议追加保存每次写的操作到文件末尾。
  • Redis还能对AOF文件进行后台重写,使得AOF文件的体积不至于过大
  • 只做缓存:如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
  • 同时开启两种持久化方式
    • 在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
    • RDB的数据不实时,同时使用两者时服务器重启也只会找AOF文件。那要不要只使用AOF呢?
      • 建议不要,因为RDB更适合用于备份数据库(AOF在不断变化不好备份), 快速重启,而且不会有AOF可能潜在的bug,留着作为一个万一的手段。
  • 性能建议
    • 因为RDB文件只用作后备用途,建议只在Slave上持久化RDB文件,而且只要15分钟备份一次就够了,只保留save 900 1这条规则。
    • 如果使用AOF,好处是在最恶劣情况下也只会丢失不超过两秒数据,启动脚本较简单只load自己的AOF文件就可以了。
    • 代价:
      1. 一是带来了持续的IO
      2. 二是AOF rewrite的最后将rewrite过程中产生的新数据写到新文件造成的阻塞几乎是不可避免的。
    • 只要硬盘许可,应该尽量减少AOF rewrite的频率,AOF重写的基础大小默认值64M太小了,可以设到5G以上。
    • 默认超过原大小100%大小时重写可以改到适当的数值。

5、AOF+RDB混合[推荐]

1、介绍

看了上面的RDB和AOF的介绍后,我们可以发现:

  • 使用RDB持久化会有数据丢失的风险,但是恢复速度快,
  • 而使用AOF持久化可以保证数据完整性,但恢复数据的时候会很慢。

于是从Redis4之后新增了混合AOF和RDB的模式:

  1. 先使用RDB进行快照存储,然后使用AOF持久化记录所有的写操作,
  2. 当重写策略满足或手动触发重写的时候,将最新的数据存储为新的RDB记录。
  3. 这样的话,重启服务的时候会从RDB何AOF两部分恢复数据,即保证了数据完整性,又提高了恢复的性能。

开启混合模式后:

  • 每当bgrewriteaof命令之后会在AOF文件中以RDB格式写入当前最新的数据,之后的新的写操作继续以AOF的追加形式追加写命令。
  • 当redis重启的时候,加载 aof 文件进行恢复数据:先加载 rdb 的部分再加载剩余的 aof部分。

img

2、配置

修改下面的参数即可开启AOF,RDB混合持久化:

1
aof-use-rdb-preamble yes

3、使用

开启混合持久化模式后,重写之后的aof文件里和rdb一样存储二进制的 快照数据,继续往redis中进行写操作,后续操作在aof中仍然是以命令的方式追加。

因此重写后aof文件由两部分组成:

  • 一部分是类似rdb的二进制快照
  • 另一部分是追加的命令文本:

img

6、持久化应用场景

  • Tips 1:redis用于控制数据库表主键id,为数据库表主键提供生成策略,保障数据库表的主键唯一性
  • Tips 3:redis应用于各种结构型和非结构型高热度数据访问加速
  • Tips 4:redis 应用于购物车数据存储设计
  • Tips 5:redis 应用于抢购,限购类、限量发放优惠卷、激活码等业务的数据存储设计
  • Tips 6:redis 应用于具有操作先后顺序的数据控制
  • Tips 7:redis 应用于最新消息展示
  • Tips 9:redis 应用于同类信息的关联搜索,二度关联搜索,深度关联搜索
  • Tips 12:redis 应用于基于黑名单与白名单设定的服务控制
  • Tips 13:redis 应用于计数器组合排序功能对应的排名
  • Tips 15:redis 应用于即时任务/消息队列执行管理
  • Tips 16:redis 应用于按次结算的服务控制

3、Redis 事务

1、事务简介

1、什么是事务

image-20210908022740502

Redis执行指令过程中,多条连续执行的指令被干扰,打断,插队

Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

redis事务就是一个命令执行的队列,将一系列预定义命令包装成一个整体(一个队列)。当执行时,一次性按照添加顺序依次执行,中间不会被打断或者干扰。

Redis事务的主要作用就是串联多个命令防止别的命令插队。

一个队列中,一次性、顺序性、排他性的执行一系列命令

image-20210905203742252

2、事务基本操作

1、事务的边界

redis的事务发生在 multiexec之间,能保证一系列预定义命令一次性按照添加顺序依次执行,中间不会被打断或者干扰。在执行事务当中出现错误,可以使用discard取消事务。

image-20210908023051641

2、事务的基本操作

  • 开启事务

    1
    multi
  • 作用:设定事务的开启位置,此指令执行后,后续的所有指令均加入到事务中

  • 执行事务

    1
    exec
  • 作用:设定事务的结束位置,同时执行事务。与multi成对出现,成对使用

注意:加入事务的命令暂时进入到任务队列中,并没有立即执行,只有执行exec命令才开始执行

事务定义过程中发现出了问题,怎么办?

  • 取消事务

    1
    discard
  • 作用:终止当前事务的定义,发生在multi之后,exec之前

    image-20210905204241205

3、事务的工作流程

image-20210905204405680

4、事务的注意事项

1、定义事务的过程中,命令格式输入错误怎么办?
  • 语法错误
    • 指命令书写格式有误
  • 处理结果
    • 如果定义的事务中所包含的命令存在语法错误,整体事务中所有命令均不会执行。包括那些语法正确的命令。
2、定义事务的过程中,命令执行出现错误怎么办?
  • 运行错误
    • 指命令格式正确,但是无法正确的执行。例如对list进行incr操作
  • 处理结果
    • 能够正确运行的命令会执行,运行错误的命令不会被执行

注意:已经执行完毕的命令对应的数据不会自动回滚,需要程序员自己在代码中实现回滚。

3、手动进行事务回滚
  • 记录操作过程中被影响的数据之前的状态
    • 单数据:string
    • 多数据:hash、list、set、zset
  • 设置指令恢复所有的被修改的项
    • 单数据:直接set(注意周边属性,例如时效)
    • 多数据:修改对应值或整体克隆复制

由于redis的事务没有自动进行回滚的功能,需要程序员进行手动的回滚,需要程序员自己记录事务执行前变量的值,非常的不方便。因此,redis的事务控制很少使用。

5、Redis 事务三特性

  • 单独的隔离操作
    • 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 没有隔离级别的概念
    • 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行
  • 不保证原子性
    • 事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

3、锁

1、基于特定条件的事务执行——锁

Redis是的锁基于乐观锁的,乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种check-and-set机制实现事务的

  • 对 key 添加监视锁,在执行exec前如果key发生了变化,终止事务执行

    1
    watch key1 [key2……]

    注意:不能在事务当中进行watch操作,即在mutil当中使用,会报错。

  • 取消对所有 key 的监视

    1
    unwatch
  • 使用 setnx 设置一个公共锁

    1
    setnx lock-key value

    利用setnx命令的返回值特征,有值则返回设置失败,无值则返回设置成功

    • 对于返回设置成功的,拥有控制权,进行下一步的具体业务操作
    • 对于返回设置失败的,不具有控制权,排队或等待 操作完毕通过del操作释放锁

    注意:上述解决方案是一种设计概念,依赖规范保障,具有风险性

  • 使用 expire 为锁key添加时间限定,到时不释放,放弃锁

    1
    2
    expire lock-key second
    pexpire lock-key milliseconds

    由于操作通常都是微秒或毫秒级,因此该锁定时间不宜设置过大。具体时间需要业务测试后确认。

    • 例如:持有锁的操作最长执行时间127ms,最短执行时间7ms。
    • 测试百万次最长执行时间对应命令的最大耗时,测试百万次网络延迟平均耗时
    • 锁时间设定推荐:最大耗时 * 120% + 平均网络延迟 * 110%
    • 如果业务最大耗时<<网络平均延迟,通常为2个数量级,取其中单个耗时较长即可

2、锁的应用场景

  • **Tips 18 **
    • redis 应用基于状态控制的批量任务执行
      • 天猫双11热卖过程中,对已经售罄的货物追加补货,4个业务员都有权限进行补货。补货的操作可能是一系列的操作,牵扯到多个连续操作,如何保障不会重复操作?
  • **Tips 19 **
    • redis 应用基于分布式锁对应的场景控制
      • 天猫双11热卖过程中,对已经售罄的货物追加补货,且补货完成。客户购买热情高涨,3秒内将所有商品购买完毕。本次补货已经将库存全部清空,如何避免最后一件商品不被多人同时购买?【超卖问题】

4、redssion

众所周知,Redis 其实并没有对 Java 提供原生支持。作为 Java 开发人员,我们若想在程序中集成 Redis,必须使用 Redis 的第三方库。而 Redisson 就是用于在 Java 程序中操作 Redis 的库,它使得我们可以在程序中轻松地使用 Redis。Redisson 在 java.util 中常用接口的基础上,为我们提供了一系列具有分布式特性的工具类。

1、如何安装 Redisson

安装 Redisson 最便捷的方法是使用 Maven

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.4</version>
</dependency>

你可以通过搜索 Maven 中央仓库 mvnrepository 来找到 Redisson 的各种版本。

2、如何编译运行 Redisson

安装 Redisson 后,只需使用 Java 编译器即可编译和运行 Redisson 代码:

1
2
3
javac RedissonExamples.java

java RedissonExamples

3、对Redisson API的相关使用

对Redisson API的相关使用,可以参考以下博客:


4、Redis 删除策略

1、过期数据

1、Redis中的数据特征

Redis是一种内存级数据库,所有数据均存放在内存中,内存中的数据可以通过TTL指令获取其状态:

  • XX :具有时效性的数据
  • -1 :永久有效的数据
  • -2 :已经过期的数据 或 被删除的数据 或 未定义的数据

过期的数据真的删除了吗?

并不是,过期数据的删除其实主要是由redis的删除策略进行控制。但一般来说,过期的数据并不是马上删除的,还是存放在redis的内存当中,只是根据redis的删除策略对过期的数据在不同情况下进行真正删除。

2、数据删除策略

redis有三种数据删除策略,分别是:

  1. 定时删除
  2. 惰性删除
  3. 定期删除

2、数据删除策略

官网:https://redis.io/commands/expire#expire-accuracy

1、时效性数据的存储结构

image-20210905210951460

2、数据删除策略的目标

在内存占用与CPU占用之间寻找一种平衡,顾此失彼都会造成整体redis性能的下降,甚至引发服务器宕机或内存泄露。

3、数据删除策略——定时删除

  • 创建一个定时器,当key设置有过期时间,且过期时间到达时,由定时器任务立即执行对键的删除操作
  • 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
  • 缺点:CPU压力很大,无论CPU此时负载量多高,均占用CPU,会影响redis服务器响应时间和指令吞吐量
  • 总结:用处理器性能换取存储空间(拿时间换空间)

image-20210905211328753

4、数据删除策略——惰性删除

数据到达过期时间,不做处理。等下次访问该数据时

  • 如果未过期,返回数据
  • 发现已过期,删除,返回不存在
  • 优点:节约CPU性能,发现必须删除的时候才删除
  • 缺点:内存压力很大,出现长期占用内存的数据
  • 总结:用存储空间换取处理器性能(拿空间换时间)

image-20210905211533959

5、数据删除策略——定期删除

两种方案都走极端,有没有折中方案?

  • Redis启动服务器初始化时,读取配置server.hz的值,默认为10
    • 每秒钟执行server.hz次serverCron() –》 activeExpireCycle() – 》activeExpireCycle()
    • *activeExpireCycle()**对每个expires[]逐一进行检测,每次执行250ms/server.hz
    • 对某个expires[*]检测时,随机挑选W个key检测
      • 如果key超时,删除key
      • 如果一轮中删除的key的数量 > W * 25%,循环该过程
      • 如果一轮中删除的key的数量 ≤ W * 25%,检查下一个expires[*],0-15循环
      • W取值 = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP属性值
    • 参数current_db用于记录activeExpireCycle() 进入哪个expires[*] 执行
    • 如果**activeExpireCycle()**执行时间到期,下次从current_db继续向下执行databasesCron

image-20210905212056307

  • 周期性轮询redis库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度
  • 特点1:CPU性能占用设置有峰值,检测频度可自定义设置
  • 特点2:内存压力不是很大,长期占用内存的冷数据会被持续清理
  • 总结:周期性抽查存储空间(随机抽查,重点抽查

6、删除策略比对

定时删除 节约内存,无占用 不分时段占用CPU资源,频度高 拿时间换空间
惰性删除 内存占用严重 延时执行,CPU利用率高 拿空间换时间
定期删除 内存定期随机清理 每秒花费固定的CPU资源维护内存 随机抽查,重点抽查

redis会使用的两个删除策略:

  • 惰性删除
  • 定期删除

3、逐出算法

1、新数据进入检测

当新数据进入redis时,如果内存不足怎么办?

  • Redis使用内存存储数据,在执行每一个命令前,会调用freeMemoryIfNeeded()检测内存是否充足。如果内存不满足新加入数据的最低存储要求,redis要临时删除一些数据为当前指令清理存储空间清理数据的策略称为逐出算法

  • 注意:逐出数据的过程不是100%能够清理出足够的可使用的内存空间,如果不成功则反复执行。当对所有数据尝试完毕后,如果不能达到内存清理的要求,将出现错误信息。

    1
    (error) OOM command not allowed when used memory >'maxmemory'

2、影响数据逐出的相关配置

  • 最大可使用内存

    1
    maxmemory

    占用物理内存的比例,默认值为0,表示不限制生产环境中根据需求设定,通常设置在50%以上

  • 查看当前最大可使用内存

    1
    2
    3
    127.0.0.1:6379> config get maxmemory
    1) "maxmemory"
    2) "0"

    默认值为0

  • 设置当前最大可使用内存

    1
    2
    3
    4
    5
    127.0.0.1:6379> config set maxmemory 1GB
    OK
    127.0.0.1:6379> config get maxmemory
    1) "maxmemory"
    2) "1073741824"
  • 也可以通过配置文件对最大可使用内存进行配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 配置文件
    maxmemory <bytes>
    # 下面的写法均合法:
    maxmemory 1024000
    maxmemory 1GB
    maxmemory 1G
    maxmemory 1024KB
    maxmemory 1024K
    maxmemory 1024MB

    img

    maxmemory参数默认值为0。因32位系统支持的最大内存为4GB,所以在32位系统上Redis的默认最大内存限制为3GB;在64位系统上默认Redis最大内存即为物理机的可用内存;

  • 每次选取待删除数据的个数

    1
    maxmemory-samples

    选取数据时并不会全库扫描,导致严重的性能消耗,降低读写性能。因此采用随机获取数据的方式作为待检测删除数据

  • 删除策略

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 配置文件
    maxmemory-policy noeviction

    #命令行
    127.0.0.1:6379> config get maxmemory-policy
    1) "maxmemory-policy"
    2) "noeviction"
    127.0.0.1:6379> config set maxmemory-policy allkeys-random
    OK
    127.0.0.1:6379> config get maxmemory-policy
    1) "maxmemory-policy"
    2) "allkeys-random"

    达到最大内存后的,对被挑选出来的数据进行删除的策略

redis有8种删除策略

  • 检测易失数据(可能会过期的数据集server.db[i].expires)

    • volatile-lru:挑选最近最少使用的数据淘汰(早期redis一般的默认策略

    • volatile-lfu:挑选最近使用次数最少的数据淘汰

      image-20210905213148222

    • volatile-ttl:挑选将要过期的数据淘汰

    • volatile-random任意选择数据淘汰

  • 检测全库数据(所有数据集server.db[i].dict )

    • allkeys-lru:挑选最近最少使用的数据淘汰
    • allkeys-lfu:挑选最近使用次数最少的数据淘汰
    • allkeys-random:任意选择数据淘汰
  • 放弃数据驱逐

    • no-enviction(驱逐)禁止驱逐数据(redis4.0中默认策略),会引发错误OOM(Out Of Memory)。当内存达到设置的最大值时,所有申请内存的操作都会报错(如set,lpush等),只读操作如get命令可以正常执行

在配置启动的文件中配置:

1
maxmemory-policy volatile-lru

3、LRU算法

1、介绍

LRU(Least Recently Used)表示最近最少使用,该算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

2、底层实现

LRU算法的常见实现方式为链表:新数据放在链表头部 ,链表中的数据被访问就移动到链头,链表满的时候从链表尾部移出数据。

img

而在Redis中使用的是近似LRU算法,为什么说是近似呢?Redis中是随机采样5个(可以修改参数maxmemory-samples配置)key,然后从中选择访问时间最早的key进行淘汰,因此当采样key的数量与Redis库中key的数量越接近,淘汰的规则就越接近LRU算法。但官方推荐5个就足够了,最多不超过10个,越大就越消耗CPU的资源。

但在LRU算法下,如果一个热点数据最近很少访问,而非热点数据近期访问了,就会误把热点数据淘汰而留下了非热点数据,因此在Redis4.x中新增了LFU算法。

LRU算法下,Redis会为每个key新增一个3字节的内存空间用于存储key的访问时间

4、LFU算法

1、介绍

LFU(Least Frequently Used)表示最不经常使用,它是根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

LFU算法反映了一个key的热度情况,不会因LRU算法的偶尔一次被访问被误认为是热点数据。

2、底层实现

LFU算法的常见实现方式为链表:新数据放在链表尾部 ,链表中的数据按照被访问次数降序排列,访问次数相同的按最近访问时间降序排列,链表满的时候从链表尾部移出数据。

img

5、数据逐出策略配置依据

使用INFO命令输出监控信息,查询缓存 hit 和 miss 的次数,根据业务需求调优Redis配置

image-20210905213301214


5、Redis 核心配置

服务器基础配置

1、服务器端设定

  • 设置服务器以守护进程的方式运行

    1
    daemonize yes|no
  • 绑定主机地址

    1
    bind 127.0.0.1

    没有配置bind的话,默认使用的是127.0.0.1,localhost也是可以。

    但是一旦配置了bind,就必须使用配置的IP进行访问,localhost也不行了

  • 设置服务器端口号

    1
    port 6379
  • 设置数据库数量

    1
    databases 16

2、日志配置

  • 设置服务器以指定日志记录级别

    1
    loglevel debug|verbose|notice|warning
  • 日志记录文件名

    1
    logfile 端口号.log

注意:日志级别==开发期设置为verbose==即可,==生产环境中配置为notice==,简化日志输出量,降低写日志IO的频度

3、客户端配置

  • 设置同一时间最大客户端连接数,默认无限制。当客户端连接到达上限,Redis会关闭新的连接

    1
    maxclients 0
  • 客户端闲置等待最大时长,达到最大值后关闭连接。如需关闭该功能,设置为 0,单位是:秒/s

    1
    timeout 300

4、多服务器快捷配置

  • 导入并加载指定配置文件信息,用于快速创建redis公共配置较多的redis实例配置文件,便于维护

    1
    include /path/server-端口号.conf

即:如果配置文件过多,可以将一些公共部分抽取出来作为一个公共的配置文件,在其他的配置文件当中,使用以上配置将公共配置文件进行导入


6、高级数据类型

1、Bitmaps

1、存储需求

image-20210905214141050

计算机所能操作的最小单位是:Byte字节,1Byte = 8bit

而使用Bitmaps能让我们去操作bit,用于状态的判断(即:非真既假的情况)

合理地使用操作位能够有效地提高内存使用率和开发效率

Redis提供了Bitmaps这个“数据类型”可以实现对位的操作:

  1. Bitmaps本身不是一种数据类型, 实际上它就是字符串(key-value) , 但是它可以对字符串的位进行操作
  2. Bitmaps单独提供了一套命令, 所以在Redis中使用Bitmaps和使用字符串的方法不太相同。 可以把Bitmaps想象成一个以位为单位的数组, 数组的每个单元只能存储0和1, 数组的下标在Bitmaps中叫做偏移量。

image-20210908021224660

2、Bitmaps类型的基础操作

  • 获取指定key对应偏移量上的bit值

    1
    2
    # a:0110 0011 --》 getbit a 6 <-> 1
    getbit key offset
  • 设置指定key对应偏移量上的bit值,value只能是1或0

    1
    2
    # a:0110 0011 --》 getbit a 6 0 <-> a:0100 0011
    setbit key offset value
  • 对指定key按位进行交、并、非、异或操作,并将结果保存到destKey中

    1
    2
    3
    # a:01010011 b:11011001
    # bitop or c a b <-> c:11011011
    bitop op destKey key1 [key2...]
    • and:交
    • or:并
    • not:非
    • xor:异或
  • 统计指定key中1的数量

    1
    2
    # bitcount c <-> 6
    bitcount key [start end]

3、Bitmaps与set对比

假设网站有1亿用户, 每天独立访问的用户有5千万, 如果每天用集合类型和Bitmaps分别存储活跃用户可以得到表

set和Bitmaps存储一天活跃用户对比:

数据类型 每个用户id占用空间 需要存储的用户量 全部内存量
集合类型 64位 50000000 64位*50000000 = 400MB
Bitmap 1位 100000000 1位*100000000 = 12.5MB

很明显, 这种情况下使用Bitmaps能节省很多的内存空间, 尤其是随着时间推移节省的内存还是非常可观的

set和Bitmaps存储独立用户空间对比:

数据类型 一天 一个月 一年
集合类型 400MB 12GB 144GB
Bitmaps 12.5MB 375MB 4.5GB

但Bitmaps并不是万金油, 假如该网站每天的独立访问用户很少, 例如只有10万(大量的僵尸用户) , 那么两者的对比如下表所示, 很显然, 这时候使用Bitmaps就不太合适了, 因为基本上大部分位都是0。

set和Bitmaps存储一天活跃用户对比(独立用户比较少)

数据类型 每个userid占用空间 需要存储的用户量 全部内存量
集合类型 64位 100000 64位*100000 = 800KB
Bitmaps 1位 100000000 1位*100000000 = 12.5MB

4、Bitmaps的应用场景

  • **Tips 21 **
    • redis 应用于信息状态统计
      • 电影网站
        • 统计每天某一部电影是否被点播
        • 统计每天有多少部电影被点播
        • 统计每周/月/年有多少部电影被点播
        • 统计年度哪部电影没有被点播

2、HyperLogLog

1、基数

  • 基数是数据集去重后元素个数
  • HyperLogLog 是用来做基数统计的,运用了LogLog的算法
  • HyperLogLog 的优点是:在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
  • 在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
  • 但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。
  • 示例:
    • {1, 3, 5, 7, 5, 7, 8}
      • 基数集: {1, 3, 5 ,7, 8}
      • 基数:5
    • {1, 1, 1, 1, 1, 7, 1}
      • 基数集: {1,7}
      • 基数:2

2、LogLog算法(跳过)

image-20210905215436319

3、HyperLogLog类型的基本操作

  • 添加数据

    1
    pfadd key element [element ...]
  • 统计数据

    1
    pfcount key [key ...]
  • 合并数据

    1
    pfmerge destkey sourcekey [sourcekey...]

4、HyperLogLog的相关说明

  • 用于进行基数统计,不是集合,不保存数据,只记录数量而不是具体数据
  • 核心是基数估算算法,最终数值存在一定误差
  • 误差范围:基数估计的结果是一个带有 0.81% 标准错误的近似值
  • 耗空间极小,每个hyperloglog key占用了12K的内存用于标记基数
  • pfadd命令不是一次性分配12K内存使用,会随着基数的增加内存逐渐增大
  • Pfmerge命令合并后占用的存储空间为12K,无论合并之前数据量多少

5、HyperLogLog的应用场景

  • **Tips 22 **
    • redis 应用于独立信息统计
      • 统计独立UV
        • 原始方案:set
          • 存储每个用户的id(字符串)
        • 改进方案:Bitmaps
          • 存储每个用户状态(bit)
        • 全新的方案:Hyperloglog

3、GEO

1、GEO简介

  • Redis 3.2 中增加了对GEO类型的支持。
  • GEO,Geographic,地理信息的缩写。
  • 该类型,就是元素的2维坐标,在地图上就是经纬度。
  • redis基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度Hash等常见操作

GEO用于计算两地经纬度的距离

image-20210905220109563

2、GEO类型的基本操作

  • 添加坐标点

    1
    2
    # geoadd + 容器key + 经度 + 维度 + 名称
    geoadd key longitude latitude member [longitude latitude member ...]
  • 获取坐标点

    1
    geopos key member [member ...]

    它会做一些经纬度的度分秒的转换

  • 计算坐标点距离,单位:米/m

    1
    2
    # unit为单位,默认为米/m,可以设置成千米/km
    geodist key member1 member2 [unit]

    注意:geo计算的是水平位置的距离

  • 根据坐标求范围内的数据(不定点,如移动当中的位置)

    1
    georadius key longitude latitude radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]
    • [withcoord]:结果跟随坐标
    • [withdist]:结果跟随距离
    • [withhash]:结果跟坐标的hash值
    • [count count]:结果取一定的范围,从count到count

    另外,还能再加上两个参数:

    • asc/desc:按照距离进行升序/降序
  • 根据点求范围内的数据(定点)

    1
    georadiusbymember key member radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]

    一些操作与上面一样

  • 获取指定点对应的坐标hash值

    1
    geohash key member [member ...]

3、GEO的应用场景

  • **Tips 23 **
    • redis 应用于地理位置计算
      • 火热的生活服务类软件(当中显示的距离)
        • 微信 / 陌陌
        • 美团 / 饿了么
        • 携程 / 马蜂窝
        • 高德 / 百度

7、主从复制

1、主从复制简介

1、互联网“三高”架构

  • 高并发
  • 高性能
  • 高可用

对于高可用:

假设在一年当中,服务器的宕机有:

  • 在一月,服务器宕机4小时27分15秒
  • 在四月,服务器宕机11分36秒
  • 在十月,服务器宕机2分16秒

那么在这一年当中服务器的可用性为:

image-20210907010831610

2、“Redis”是否高可用

单机redis的风险与问题:

  • 问题1:机器故障
    • 现象:硬盘故障、系统崩溃
    • 本质:数据丢失,很可能对业务造成灾难性打击
    • 结论:基本上会放弃使用redis.
  • 问题2:容量瓶颈
    • 现象:内存不足,从16G升级到64G,从64G升级到128G,无限升级内存
    • 本质:穷,硬件条件跟不上
    • 结论:放弃使用redis
  • 结论:
    • 为了避免单点Redis服务器故障,准备多台服务器,互相连通
    • 将数据复制多个副本保存在不同的服务器上,连接在一起,并保证数据是同步的
    • 即使有其中一台服务器宕机,其他服务器依然可以继续提供服务,实现Redis的高可用,同时实现数据冗余备份。

3、多台服务器连接方案

  • 提供数据方:master
    • 主服务器,主节点,主库
    • 主客户端
  • 接收数据方:slave
    • 从服务器,从节点,从库
    • 从客户端
  • 需要解决的问题: 数据同步
  • 核心工作: master的数据复制到slave中

image-20210907011143290

4、主从复制

主从复制即将master中的数据即时、有效的复制到slave中

特征:一个master可以拥有多个slave,一个slave只对应一个master

职责:(读写分离)

  • master:
    • 写数据
    • 执行写操作时,将出现变化的数据自动同步到slave
    • 读数据(可忽略)
  • slave:
    • 读数据
    • 写数据(禁止)

5、高可用集群

1、在一个slave结点宕机之后,并不影响redis可用性

image-20210907011448753

2、在一个master结点宕机之后,可以有一个slave升级为master继续使用,并不影响redis可用性

image-20210907011537226

3、在一个master结点压力过大,可以将一部分工作交给一个slave结点去做,让这个slave作为master去管理它的从结点(master与slave只是相对来说的)

image-20210907011704140

4、如果一个master来接收外界数据不太安全的话,也可以将多个master做成集群

image-20210907011806247

6、主从复制的作用

  • 读写分离master写、slave读,提高服务器的读写负载能力
  • 负载均衡:基于主从结构,配合读写分离,由slave分担master负载,并根据需求的变化,改变slave的数量,通过多个从节点分担数据读取负载,大大提高Redis服务器并发量与数据吞吐量
  • 故障恢复当master出现问题时,由slave提供服务,实现快速的故障恢复
  • 数据冗余实现数据热备份,是持久化之外的一种数据冗余方式
  • 高可用基石:基于主从复制,构建哨兵模式与集群,实现Redis的高可用方案

2、主从复制工作流程

1、总述

  • 主从复制过程大体可以分为3个阶段
    1. 建立连接阶段(即准备阶段)
    2. 数据同步阶段
    3. 命令传播阶段

image-20210907012115436

2、阶段一:建立连接阶段

  • 建立slave到master的连接,使master能够识别slave,并保存slave端口号
1、建立连接阶段工作流程
  1. 步骤1:设置master的地址和端口,保存master信息
  2. 步骤2:建立socket连接
  3. 步骤3:发送ping命令(定时器任务)
  4. 步骤4:身份验证
  5. 步骤5:发送slave端口信息
  6. 至此,主从连接成功!

image-20210907012520884

状态:

  • slave:
    • 保存master的地址与端口
  • master:
    • 保存slave的端口
  • 总体:
    • 之间创建了连接的socket
2、主从连接(slave连接master)
  • 方式一:客户端发送命令

    1
    slaveof <masterip> <masterport>
  • 方式二:启动服务器参数

    1
    redis-server -slaveof <masterip> <masterport>
  • 方式三:服务器配置(常用)

    1
    slaveof <masterip> <masterport>

slave系统信息:

  • master_link_down_since_seconds:主从断开的持续时间(以秒为单位) .
  • masterhost
  • masterport

master系统信息:

  • slave_listening_port(多个)
3、主从断开连接
  • 客户端发送命令

    1
    slaveof no one

说明: slave断开连接后,不会删除已有数据,只是不再接受master发送的数据

4、授权访问
  • master客户端发送命令设置密码

    1
    requirepass <password>
  • master配置文件设置密码

    1
    2
    3
    config set requirepass <password>

    config get requirepass
  • slave客户端发送命令设置密码

    1
    auth <password>
  • slave配置文件设置密码

    1
    masterauth <password>
  • slave启动服务器设置密码

    1
    redis-server –a <password>

由于redis在主从进行数据交流的是在内网上进行的,所以一般不设置密码也没有关系。

2、阶段二:数据同步阶段工作流程

  • 在slave初次连接master后,复制master中的所有数据到slave
  • 将slave的数据库状态更新成master当前的数据库状态
1、数据同步阶段工作流程
  1. 步骤1:请求同步数据
  2. 步骤2:创建RDB同步数据(全量复制)
  3. 步骤3:恢复RDB同步数据
  4. 步骤4:请求部分同步数据(部分复制)(AOF同步)
  5. 步骤5:恢复部分同步数据
  6. 至此,数据同步工作完成!

image-20210907013653595

状态:

  • slave: 具有master端全部数据,包含RDB过程接收的数据
  • master: 保存slave当前数据同步的位置
  • 总体: 完成了数据克隆
2、数据同步阶段master说明
  1. 如果master数据量巨大,数据同步阶段应避开流量高峰期,避免造成master阻塞,影响业务正常执行

    • 可以选择在半夜的3、4点钟进行数据同步
    • 注意:这是全量复制的时候,也就是你新增从属服务器要同步的时候,一般之后的实时同步都是部分复制 量很少的,速度很快。
  2. 复制缓冲区大小设定不合理,会导致数据溢出。如进行全量复制周期太长,进行部分复制时发现数据已经存在丢失的情况,必须进行第二次全量复制,致使slave陷入死循环状态。

    image-20210907014215104

    1
    repl-backlog-size 1mb

    通过设置复制缓冲区大小就能解决这个问题

  3. master单机内存占用主机内存的比例不应过大,建议使用50%-70%的内存,留下30%-50%的内存用于执行bgsave命令和创建复制缓冲区

3、数据同步阶段slave说明
  1. 为避免slave进行全量复制、部分复制时服务器响应阻塞或数据不同步,建议关闭此期间的对外服务

    1
    2
    3
    4
    5
    # 开启只读服务应该是这个指令:
    slave-read-only yes

    # 当主服务器挂掉时是否提供过期数据
    slave-serve-stale-data yes|no
  2. 数据同步阶段,master发送给slave信息可以理解master是slave的一个客户端,主动向slave发送命令

  3. 多个slave同时对master请求数据同步,master发送的RDB文件增多,会对带宽造成巨大冲击,如果master带宽不足,因此数据同步需要根据业务需求,适量错峰

  4. slave过多时,建议调整拓扑结构,由一主多从结构变为树状结构,中间的节点既是master,也是slave。

    • 注意使用树状结构时,由于层级深度,导致深度越高的slave与最顶层master间数据同步延迟较大,数据一致性变差,应谨慎选择

3、阶段三:命令传播阶段

  • 当master数据库状态被修改后,导致主从服务器数据库状态不一致,此时需要让主从数据同步到一致的状态,同步的动作称为命令传播
  • master将接收到的数据变更命令发送给slave,slave接收命令后执行命令
1、命令传播阶段的部分复制
  • 命令传播阶段出现了断网现象
    • 网络闪断闪连:忽略
    • 短时间网络中断:部分复制
    • 长时间网络中断:全量复制
  • 部分复制的三个核心要素
    • 服务器的运行 id(run id)
    • 主服务器的复制积压缓冲区
    • 主从服务器的复制偏移量
2、服务器运行ID(runid)
  • 概念:服务器运行ID是每一台服务器每次运行的身份识别码,一台服务器多次运行可以生成多个运行id
  • 组成:运行id由40位字符组成,是一个随机的十六进制字符
    • 例如:fdc9ff13b9bbaab28db42b3d50f852bb5e3fcdce
  • 作用:运行id被用于在服务器间进行传输,识别身份
    • 如果想两次操作均对同一台服务器进行,必须每次操作携带对应的运行id,用于对方识别
  • 实现方式:运行id在每台服务器启动时自动生成的,master在首次连接slave时,会将自己的运行ID发送给slave,slave保存此ID,通过info Server命令,可以查看节点的runid
3、复制缓冲区
  • 概念:复制缓冲区,又名复制积压缓冲区,是一个先进先出(FIFO)的队列,用于存储服务器执行过的命令,每次传播命令,master都会将传播的命令记录下来,并存储在复制缓冲区

    image-20210907015127642

  • 由来:每台服务器启动时,如果开启有AOF或被连接成为master节点,即创建复制缓冲区

  • 作用:用于保存master收到的所有指令仅影响数据变更的指令,例如set,select)

  • 数据来源:当master接收到主客户端的指令时,除了将指令执行,会将该指令存储到缓冲区中

  • 组成:

    • 偏移量
    • 字节值
  • 工作原理

    • 通过offset区分不同的slave当前数据传播的差异
    • master记录已发送的信息对应的offset
    • slave记录已接收的信息对应的offset

image-20210907015201028

4、主从服务器复制偏移量(offset)
  • 概念:一个数字,描述复制缓冲区中的指令字节位置
  • 分类:
    • master复制偏移量:记录发送给所有slave的指令字节对应的位置(多个)
    • slave复制偏移量:记录slave接收master发送过来的指令字节对应的位置(一个)
    • 之后通过master与slave之间的offset对比,就知道当前的slave有多少数据没有复制过去,相等表示当前slave已经有master的全部数据
  • 数据来源: master端:发送一次记录一次 slave端:接收一次记录一次
  • 作用:同步信息,比对master与slave的差异,当slave断线后,恢复数据使用

4、数据同步+命令传播阶段工作流程

image-20210907015608735

5、心跳机制

  • 进入命令传播阶段候,master与slave间需要进行信息交换,使用心跳机制进行维护,实现双方连接保持在线
  • master心跳:
    • 指令:PING
    • 周期:由repl-ping-slave-period决定,默认10秒(由于一个master会有多个slave,所以周期相对于slave来说会比较长)
    • 作用:判断slave是否在线
    • 查询:INFO replication 获取slave最后一次连接时间间隔lag项维持在0或1视为正常
      • 关于lag:如果在网络上的话,较为稳定,出现0的次数会比较少
  • slave心跳任务
    • 指令:REPLCONF ACK {offset}
    • 周期:1秒(由于一个slave会对应一个master,所以周期会比较短)
    • 作用1:汇报slave自己的复制偏移量,获取最新的数据变更指令
    • 作用2:判断master是否在线

6、心跳阶段注意事项

  • 当slave多数掉线,或延迟过高时,master为保障数据稳定性,将拒绝所有信息同步操作

    1
    2
    3
    min-slaves-to-write 2

    min-slaves-max-lag 10

    slave数量少于2个,或者所有slave的延迟都大于等于10秒时,强制关闭master写功能,停止数据同步

  • slave数量由slave发送REPLCONF ACK命令做确认

  • slave延迟由slave发送REPLCONF ACK命令做确认

7、主从复制工作流程(完整)

image-20210907020216293

3、主从复制常见问题

1、频繁的全量复制(1)

伴随着系统的运行,master的数据量会越来越大,一旦master重启,runid将发生变化,会导致全部slave的全量复制操作

内部优化调整方案:

  1. master内部创建master_replid变量,使用runid相同的策略生成,长度41位,并发送给所有slave
  2. 在master关闭时执行命令 shutdown save进行RDB持久化,将runid与offset保存到RDB文件中
    • repl-id repl-offset
    • 通过redis-check-rdb命令可以查看该信息
  3. master重启后加载RDB文件,恢复数据
    • 重启后,将RDB文件中保存的repl-id与repl-offset加载到内存中
      • master_repl_id = repl
      • master_repl_offset = repl-offset
    • 通过info命令可以查看该信息
  4. 作用:本机保存上次runid,重启后恢复该值,使所有slave认为还是之前的master

2、频繁的全量复制(2)

  • 问题现象:网络环境不佳,出现网络中断,slave不提供服务

  • 问题原因:复制缓冲区过小,断网后slave的offset越界,触发全量复制

  • 最终结果:slave反复进行全量复制,对外不提供服务

  • 解决方案:修改复制缓冲区大小

    1
    repl-backlog-size
  • 建议设置如下:

    1. 测算从master到slave的重连平均时长second
    2. 获取master平均每秒产生写命令数据总量write_size_per_second
    3. 最优复制缓冲区空间 = 2 * second * write_size_per_second

3、频繁的网络中断(1)

  • 问题现象:master的CPU占用过高 或 slave频繁断开连接

  • 问题原因:

    • slave每1秒发送REPLCONF ACK命令到master
    • 当slave接到了慢查询时(keys * ,hgetall等),会大量占用CPU性能
    • master每1秒调用复制定时函数replicationCron(),比对slave发现长时间没有进行响应
  • 最终结果:master各种资源(输出缓冲区、带宽、连接等)被严重占用

  • 解决方案:通过设置合理的超时时间,确认是否释放slave

    1
    repl-timeout

    该参数定义了超时时间的阈值默认60秒),超过该值,释放slave

4、频繁的网络中断(2)

  • 问题现象:slave与master连接断开

  • 问题原因:

    • master发送ping指令频度较低
    • master设定超时时间较短
    • ping指令在网络中存在丢包
  • 解决方案:提高ping指令发送的频度

    1
    repl-ping-slave-period

    超时时间repl-time的时间至少是ping指令频度的5到10倍,否则slave很容易判定超时

5、数据不一致

  • 问题现象:多个slave获取相同数据不同步

  • 问题原因:网络信息不同步,数据发送有延迟

  • 解决方案

    • 优化主从间的网络环境,通常放置在同一个机房部署

      • 如使用阿里云等云服务器时要注意此现象,因为对于云服务器来说,在同一城市服务器不一定同一个机房
    • 监控主从节点延迟(通过offset)判断,如果slave延迟过大,暂时屏蔽程序对该slave的数据访问

      1
      slave-serve-stale-data yes|no

      开启后仅响应info、slaveof等少数命令(慎用,除非对数据一致性要求很高)

      注意:

      • 开启后并不是说关掉这台服务器,而是关掉对这台服务器数据的访问,一般在==调试==当中使用
      • 另外,数据不同步在分布式的数据层级上面是属于非常正常的一件事,主要看你的业务需求对该数据的一致性是否有严格的要求。
      • 如果对某些数据的一致性特别严格的话,建议把这一部分数据单独存放,找一台机器又读又写,数据量不是特别大。
      • 对那些数据特别不是特别高的分开放。
      • 这样可以在一定程度上解决问题

8、哨兵模式

1、哨兵简介

1、主机“宕机”

image-20210907021639156

当主机宕机了怎么办?

  • 关闭master和所有slave
  • 找一个slave作为master
  • 修改其他slave的配置,连接新的主
  • 启动新的master与slave
  • 全量复制 * N + 部分复制 * N

相关问题:

  • 关闭期间的数据服务谁来承接?
  • 找一个主?怎么找法?
  • 修改配置后,原始的主恢复了怎么办?

问题解决:哨兵机制

image-20210907021811296

2、哨兵

哨兵(sentinel) 是一个分布式系统,用于对主从结构中的每台服务器进行==监控==,当出现故障时通过投票机制==选择==新的master并将所有slave连接到新的master。

哨兵(sentinel) 也是一个redis服务器集群,只是配置文件的与平常的redis服务器有一点不同

image-20210907021944455

3、哨兵的作用

  • 监控
    • 不断的检查master和slave是否正常运行
    • master存活检测master与slave运行情况检测
  • 通知(提醒)
    • 当被监控的服务器出现问题时,向其他(哨兵间,客户端)发送通知
  • 自动故障转移
    • 断开master与slave连接,选取一个slave作为master,将其他slave连接到新的master,并告知客户端新的服务器地址
  • 注意:
    • 哨兵也是一台redis服务器,只是不提供数据服务
    • 通常哨兵配置数量为==单数==
      • 防止哨兵在竞选中打平的这种尴尬局面

2、启用哨兵模式

1、配置哨兵

  • 配置一拖二的主从结构——1个master对应2个slave

  • 配置三个哨兵(配置相同,端口不同) 参看sentinel.conf

  • 启动哨兵

    1
    redis-sentinel sentinel-端口号.conf

    启动哨兵的时候,哨兵相应的配置文件也会改变。

    添加进去哨兵的相关内容:其他哨兵的主机名、IP、端口、runid等等

2、配置哨兵

查看redis原配置的一个好命令:如果你不想看配置文件当中的注释,使用如下命令:

1
cat sentinel.conf | grep -v "#" | grep -v "^$" 
配置项 范例 说明
sentinel auth-pass <自定义服务器名称> <password> sentinel auth-pass mymaster itcast 连接服务器口令
sentinel monitor <自定义服务名称><主机地址><端口><主从服务器总量> sentinel monitor mymaster 192.168.194.131 6381 1 设置哨兵监听的主服务器信息,最后的参数决定了最终参与选举的服务器数量(-1)
sentinel down-after-milliseconds<自定义服务名称><毫秒数(整数)> sentinel down-after-milliseconds mymaster 3000 指定哨兵在监控Redis服务时,判定服务器挂掉的时间周期,默认30秒(30000),也是主从切换的启动条件之一
sentinel parallel-syncs<服务名称><服务器数(整数)> sentinel parallel-syncs mymaster 1 指定每次同时进行主从的slave数量,数值越大,要求网络资源越高,要求越小,同步时间越长
sentinel failover-timeout<服务名称><毫秒数(整数)> sentinel failover-timeout mymaster 9000 指定出现故障后,故障切换的最大超时时间,超过该值,认定切换失败,默认3分钟。即在进行同步的时候,如果同步时间过慢也算失败
sentinel notification-script<服务名称><脚本路径> 服务器无法正常联通时,设定的执行脚本,通常调试使用。

注意:

  • 关于<自定义服务名称>,上面设定的是mymaster,设定之后在配置文件当中的各项配置中就不要修改
  • 关于sentinel monitor 的最后一个参数<主从服务器总量>,上面设定这个值为x(这里的x = 1)
    • x 的意义:如果有x个哨兵认为当前master宕机了,那么就认定该master已经宕机了——这是判断master是否宕机的一个标准
    • 这个值通常设定为哨兵的数量 的一半+1——这里也是为什么设定哨兵的数量最好是单数(防止出现打平的局面)

3、哨兵工作原理

1、主从切换

  • 哨兵在进行主从切换过程中经历三个阶段:
    • 监控
    • 通知
    • 故障转移

2、阶段一:监控阶段

  • 用于同步各个节点的状态信息
    • 获取各个sentinel的状态(是否在线)
    • 获取master的状态
      • master属性
        • runid
        • role:master
      • 各个slave的详细信息
    • 获取所有slave的状态(根据master中的slave信息)
      • slave属性
        • runid
        • role:slave
        • master_host、master_port
        • offset
        • ……

image-20210907024141976

image-20210907024754653

3、阶段二:通知阶段

image-20210907025006909

4、阶段三:故障转移阶段

1、sentinel1发现master宕机
  1. 先将master的状态修改为flags:SRI_S_DOWN——主观下线
  2. 将这个信息在sentinel集群当中传播
    • sentinel1报出sdown,并通知其他哨兵,发送指令sentinel is-master-down-by-address-port给其余哨兵节点;
    • 哨兵的选举机制是以各哨兵节点接收到发送sentinel is-master-down-by-address-port指令的哨兵id 投票,票数最高的哨兵id会成为本次故障转移工作的哨兵Leader;
  3. 其他的sentinel前往围观,查看master是不是真的宕机
  4. 当有一半以上的sentinel认定master已经宕机,则将master的状态修改为flags:SRI_O_DOWN——客观下线

image-20210907025536975

2、选举一个sentinel去解决当前master宕机问题

image-20210907025643655

每竞选轮回一次,竞选次数加1

3、服务器列表中挑选备选master
  • 在线的
  • 响应快的
  • 与原master断开时间短的
  • 优先原则
    • 优先级,优先级越高胜出
    • offset,offset越大胜出
    • runid,runid越小胜出
  • 发送指令( sentinel )
    • 向新的master发送slaveof no one
    • 向其他slave发送slaveof 新masterIP端口

image-20210907030402598

4、故障转移阶段总结
  • 监控
    • 同步信息
  • 通知
    • 保持联通
    • 故障转移
    • 发现问题
    • 竞选负责人
    • 优选新master
    • 新master上任,其他slave切换master,原master作为slave故障回复后连接

4、日志查看

哨兵1(sentinel1)日志:

master6379下线之后:

image-20210907031723596

master6379重新上线:

image-20210907032103510


9、集群

1、集群简介

1、现状问题

业务发展过程中遇到的峰值瓶颈:

  • redis提供的服务OPS可以达到10万/秒,当前业务OPS已经达到10万/秒
  • 内存单机容量达到256G,当前业务需求内存容量1T

使用集群的方式可以快速解决上述问题

2、集群架构

集群就是使用网络将若干台计算机联通起来,并提供统一的管理方式,使其====。

image-20210907032507122

3、集群作用

  • 分散单台服务器的访问压力,实现负载均衡
  • 分散单台服务器的存储压力,实现可扩展性
  • 降低单台服务器宕机带来的业务灾难

image-20210907032548910

4、Redis 集群的限制

  • db库:单机的Redis默认有16个db数据库,但在集群模式下只有一个db0;
  • 复制结构:上面的复制结构有树状结构,但在集群模式下只允许单层复制结构;
  • 事务/lua脚本仅允许操作的key在同一个节点上才可以在集群下使用事务或lua脚本;(使用Hash Tag可以解决)
    • 多键的Redis事务是不被支持的。
    • lua脚本不被支持
  • key的批量操作:如mget、mset操作,只有当操作的key都在同一个节点上才可以执行;(使用Hash Tag可以解决)
    • 多键操作是不被支持的
  • keys/flushall:只会在该节点之上进行操作,不会对集群的其他节点进行操作;
  • 由于集群方案出现较晚,很多公司已经采用了其他的集群方案,而代理或者客户端分片的方案想要迁移至redis cluster,需要整体迁移而不是逐步过渡,复杂度较大。
Hash Tag

上面介绍集群限制的时候,由于key被分布在不同的节点之上,因此无法跨节点做事务或lua脚本操作,但我们可以使用hash tag方式解决。

hash tag:当key包含{}的时候,不会对整个key做hash,只会对{}包含的部分做hash然后分配槽slot;因此我们可以让不同的key在同一个槽内,这样就可以解决key的批量操作和事务及lua脚本的限制了;

但由于hash tag会将不同的key分配在相同的slot中,如果使用不当,会造成数据分布不均的情况,需要注意。

img

2、Redis集群结构设计

1、数据存储设计

  • 通过算法设计,计算出key应该保存的位置

  • 将所有的存储空间计划切割成16384份,每台主机保存一部分

    • ==每份代表的是一个存储空间==,不是一个key的保存空间
  • 将key按照计算出的结果放到对应的存储空间

  • 增强可扩展性

    image-20210907033026543

原本redis的数据存储:

image-20210907032817263

经过Redis集群结构的数据存储:

image-20210907032922698

2、集群内部通讯设计(迭代查询)

  • 各个数据库相互通信,保存各个库中槽的编号数据
  • 一次命中,直接返回
  • 一次未命中,告知具体位置

image-20210907033351047

3、原理

1、数据分区规则

衡量数据分区方法的标准有两个重要因素:

  1. 是否均匀分区;
  2. 增减节点对数据分布的影响;

由于哈希算法具有随机性,可以保证数据均匀分布,因此Redis集群采用哈希分区的方式对数据进行分区,哈希分区就是对数据的特征值进行哈希,然后根据哈希值决定数据放在哪里。

2、常见的哈希分区
1、哈希取余:

计算key的hash值,对节点数量做取余计算,根据结果将数据映射到对应节点;但当节点增减时,系统中所有数据都需要重新计算映射关系,引发大量数据迁移

2、一致性哈希

将hash值区间抽象为一个环形,节点均匀分布在该环形之上,然后根据数据的key计算hash值,在该hash值所在的圆环上的位置延顺时针行走找到的第一个节点的位置,该数据就放在该节点之上。相比于哈希取余,一致性哈希分区将增减节点的影响限制为相邻节点

例:在AB节点中新增一个节点E时,因为B上的数据的key的hash值在A和B所在的hash区间之内,因此只有C上的一部分数据会迁移到B节点之上;同理如果从BCD中移除C节点,由于C上的数据的key的hash值在B和C所在的hash区间之内,因此C上的数据顺时针找到的第一个节点就是D节点,因此C的数据会全部迁移到D节点之上。 但当节点数量较少的时候,增删节点对单个节点的影响较大,会造成数据分布不均,如移除C节点时,C的数据会全部迁移到D节点上,此时D节点拥有的数据由原来的1/4变成现在的1/2,相比于节点A和B来说负载更高。

img

3、带虚拟节点的一致性哈希 (Redis集群)

Redis采用的方案,在一致性哈希基础之上,引入虚拟节点的概念,虚拟节点被称为槽(slot)。Redis集群中,槽的数量为16384。

槽介于数据和节点之间,将节点划分为一定数量的槽,每个槽包含哈希值一定范围内的数据。由原来的hash–>node 变为 hash–>slot–>node。

当增删节点时,该节点所有拥有的槽会被重新分配给其他节点,可以避免在一致性哈希分区中由于某个节点的增删造成数据的严重分布不均。

img

3、通信机制

在上面的哨兵方案中,节点被分为数据节点和哨兵节点,哨兵节点也是redis服务,但只作为选举监控使用,只有数据节点会存储数据。而在Redis集群中,所有节点都是数据节点,也都参与集群的状态维护

在Redis集群中,数据节点提供两个TCP端口,在配置防火墙时需要同时开启下面两类端口:

  • 普通端口:即客户端访问端口,如默认的6379;
  • 集群端口:普通端口号加10000,如6379的集群端口为16379,用于集群节点之间的通讯;

集群的节点之间通讯采用Gossip协议,节点根据固定频率(每秒10次)定时任务进行判断,当集群状态发生变化,如增删节点、槽状态变更时,会通过节点间通讯同步集群状态,使集群收敛

集群间发送的Gossip消息有下面五种消息类型:

  • MEET:在节点握手阶段,对新加入的节点发送meet消息,请求新节点加入当前集群,新节点收到消息会回复PONG消息;
  • PING:节点之间互相发送ping消息,收到消息的会回复pong消息。ping消息内容包含本节点和其他节点的状态信息,以此达到状态同步;
  • PONG:pong消息包含自身的状态数据,在接收到ping或meet消息时会回复pong消息,也会主动向集群广播pong消息;
  • FAIL:当一个主节点判断另一个主节点进入fail状态时,会向集群广播这个消息,接收到的节点会保存该消息并对该fail节点做状态判断;
  • PUBLISH:当节点收到publish命令时,会先执行命令,然后向集群广播publish消息,接收到消息的节点也会执行publish命令;
4、访问集群

上面介绍了槽的概念,在每个节点存储着不同范围的槽,数据也分布在不同的节点之上,我们在访问集群的时候,如何知道数据在哪个节点或者在哪个槽之上呢? 下面介绍两种访问连接:

1、Dummy客户端

使用redis-cli客户端连接集群被称为dummy客户端,只会在执行命令之后通过MOVED错误重定向找到对应的节点,如图,我们可以使用redis-cli -c命令进入集群命令行,当查看或设置key的时候会根据上面提到的CRC16算法计算key的hash值找到对应的槽slot,然后重定向到对应的节点之后才能操作,我们也使用cluster keyslot命令查看key所在的槽solt:

1
2
3
4
5
# 使用-c进入集群命令行模式
redis-cli -c -p 6381

# 使用命令查看key所在的槽
cluster keyslot key1

img

img

2、Smart客户端

相比于dummy客户端,smart客户端在初始化连接集群时就缓存了槽slot和节点node的对应关系, 也就是在连接任意节点后执行cluster slots,我们使用的JedisCluster就是smart客户端:

1
cluster slots

img

集群代理:Redis6版本中新增的特性,客户端不需要知道集群中的具体节点个数和主从身份,可以直接通过代理访问集群。与Redis在不同的分支,将在后面的文章中具体介绍。

3、cluster集群结构搭建

1、搭建方式

  • 原生安装(单条命令)
    • 配置服务器(3主3从)
    • 建立通信(Meet)
    • 分槽(Slot)
    • 搭建主从(master-slave)
  • 工具安装(批处理)

2、Cluster配置

配置一个配置文件,借助这个配置文件去配置其他类型配置文件的命令:

1
sed "s/6379/6380/g" redis-6379.conf > redis-7380.conf

将redis-6379.conf配置文件当中的6379修改为6380之后生成一个redis-6380.conf的配置文件

  • 添加节点

    1
    cluster-enabled yes|no
  • cluster配置文件名,该文件属于自动生成,仅用于快速查找文件并查询文件内容

    1
    cluster-config-file <filename>

    这里建议修改cluster的配置文件的名字,因为如果在同一个目录下有多个cluster结点的话,可能会因为相关的配置文件的同名而导致一定的问题。
    建议改名:nodes-端口.conf

  • 节点服务响应超时时间,用于判定该节点是否下线或切换为从节点

    1
    cluster-node-timeout <milliseconds>

    与后面当master宕机之后,slave日志的展示有关

    对于线上,30s或60s都行,看具体的业务

  • master连接的slave最小数量

    1
    cluster-migration-barrier <count>

3、启动redis服务

1、启动master结点
1
redis-server /redis-4.0.0/conf/redis-6379.conf

image-20210907135258255

按照上面的方法依次启动另外的五个结点(三主三从)

2、查看当前redis服务
1
ps -ef | grep redis

image-20210907135434580

3、将当前的六个结点相连

把启动的一个个redis结点进行连接

相关命令:下载的redis包下的src目录下的redis-trib.rb

要想启动redis-trib.rb,需要两个工具:

  1. ruby
  2. rubygem

需要将它们先进行下载。
注意:

  • redis的版本不同,对应下载的ruby也会有所不同
  • 如果ruby和gem的版本不够,它会提醒你升级到对应的版本

在Redis 6当中,redis-cli –cluster代替了之前的redis-trib.rb,我们无需安装ruby环境即可直接使用它附带的所有功能:创建集群、增删节点、槽迁移、完整性检查、数据重平衡等等。

redis-trib.rb命令的执行:

1
2
# 如果直接执行redis-trib.rb它是识别不出来的,而且只有在当前目录下有效,需要将它用./redis-trib.rb方式执行
./redis-trib.rb create --replicas 1 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384

当中的1表示master与slave之间的数量

  • eg:
    • 1:1个master有1个slave
    • 2:1个master有2个slave

对应的,后面的结点IP和端口需要与前面的数字相对应

  • eg:
    • 前面1,后面6:3对——1个muster1个slave
    • 前面2,后面6:2对——1个muster2个slave

image-20210907140357232

在选择yes之前,也就是生成相关配置文件之前:

image-20210907140650632

选择yes之后生成相关配置文件:

image-20210907141043430

image-20210907141310592

4、此时redis服务端的日志

master服务端:

image-20210907141859784

slave服务端:

image-20210907142218680

4、使用cluster设置与获取数据

存取数据:

  • 若是按照之前的方法启动:

    • redis-cli
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 则你在进行set/get等操作的时候会报错,redis会告诉你当前数据应该设置在哪一个槽当中,很麻烦

      ![image-20210907142834922](redis高级/image-20210907142834922.png)

      - 所以应当商量说过的另一个启动方式:

      ```sh
      redis-cli -c
  • 再进行set/get操作,发现成功,redis会返回该值已经重定向到对应的槽当中,并且返回OK

    image-20210907142908730

    image-20210907142958347

5、在Cluster集群下出现相关问题的解决方法

在Cluster集群下测试出现的相关问题:

  1. 当slave结点宕机会出现什么问题?
  2. 当master结点宕机会出现什么问题?
1、当slave结点宕机会出现什么问题?

宕机的slave结点对应的master:

  • 宕机前:

    image-20210907143341529

  • 宕机后:

    image-20210907143626170

  • 对应的从结点重新上线:

    image-20210907143842881

其他master结点:

  • 宕机前:

    image-20210907143414489

  • 宕机后:(这里包括其他的从结点也一样)

    image-20210907143641578

  • 重新上线:

    image-20210907144153900

宕机的从结点:

  • 宕机前:

    image-20210907143504078

  • 宕机后:

    image-20210907143531076

  • 重新上线

由上面可以得到,在Cluster集群当中,当一个slave结点宕机并不会产生多大的影响,只是将相应宕机的从结点进行标记而已,整一个redis集群依旧是可用的。当宕机的slave结点重新上线之后在将它加入对应的主节点就行。

2、当master结点宕机会出现什么问题?

宕机的master结点:

  • 宕机前:

    image-20210907144324055

  • 宕机后:

    image-20210907144225353

  • 重新上线

宕机的master结点对应的slave结点:

  • 宕机前:

    image-20210907144755729

  • 宕机后:

    image-20210907144740458

    image-20210907145044928

    此时通过cluster nodes命令去查看当前cluster集群的状态:

    image-20210907145201995

    把宕机的master结点标记为fail,因为宕机的结点可能重新上线,所以这里只是做了标记

  • 重新上线:

    image-20210907145908556

    使用cluster nodes查看当前cluster集群的状态:

    image-20210907150019576

其他结点只是更新一下当前结点的状态而已

image-20210907150133315

6、Cluster节点操作命令

  • 查看集群节点信息

    1
    cluster nodes
  • 进入一个从节点 redis,切换其主节点

    1
    cluster replicate <master-id>
  • 发现一个新节点,新增主节点

    1
    cluster meet ip:port
  • 忽略一个没有solt的节点

    1
    cluster forget <id>
  • 手动故障转移

    1
    cluster failover

7、redis-trib命令

  • 添加节点

    1
    redis-trib.rb add-node
  • 删除节点

    1
    redis-trib.rb del-node
  • 重新分片

    1
    redis-trib.rb reshard

4、集群参数优化

cluster_node_timeout

  • 默认值为15s
  • 影响ping消息接收节点的选择,值越大对延迟容忍度越高,选择的接收节点就越少,可以降低带宽,但会影响收敛速度。应该根据带宽情况和实际要求具体调整。
  • 影响故障转移的判定,值越大越不容易误判,但完成转移所消耗的时间就越长。应根据网络情况和实际要求具体调整。

cluster-require-full-coverage

  • 为了保证集群的完整性,只有当16384个槽slot全部分配完毕,集群才可以上线,但同时,若主节点发生故障且故障转移还未完成时,原主节点的槽不在任何节点中,集群会处于下线状态,影响客户端的使用。
  • 该参数可以改变此设定:
    • no:表示当槽没有完全分配时,集群仍然可以上线;
    • yes:默认配置,只有槽完全分配,集群才可以上线;

10、企业级解决方案

1、缓存预热

1、“宕机”

服务器启动后迅速宕机

2、问题排查

  1. 请求数量较高
  2. 主从之间数据吞吐量较大,数据同步操作频度较高

3、解决方案

前置准备工作:

  1. 日常例行统计数据访问记录,统计访问频度较高的热点数据
  2. 利用LRU数据删除策略,构建数据留存队列
    • 例如:storm与kafka配合

准备工作:

  1. 将统计结果中的数据分类,根据级别,redis优先加载级别较高的热点数据
  2. 利用分布式多服务器同时进行数据读取,提速数据加载过程
  3. 热点数据主从同时预热

实施:

  1. 使用脚本程序固定触发数据预热过程
  2. 如果条件允许,使用了CDN(内容分发网络),效果会更好

4、结论

缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

2、缓存雪崩

1、数据库服务器崩溃(1)

  1. 系统平稳运行过程中,忽然数据库连接量激增
  2. 应用服务器无法及时处理请求
  3. 大量408,500错误页面出现
  4. 客户反复刷新页面获取数据
  5. 数据库崩溃
  6. 应用服务器崩溃
  7. 重启应用服务器无效
  8. Redis服务器崩溃
  9. Redis集群崩溃
  10. 重启数据库后再次被瞬间流量放倒

img

2、问题排查

  1. 在一个==较短==的时间内,缓存中==较多==的key==集中过期==
  2. 此周期内请求访问过期的数据,redis未命中,redis向数据库获取数据
  3. 数据库同时接收到大量的请求无法及时处理
  4. Redis大量请求被积压,开始出现超时现象
  5. 数据库流量激增,数据库崩溃
  6. 重启后仍然面对缓存中无数据可用
  7. Redis服务器资源被严重占用,Redis服务器崩溃
  8. Redis集群呈现崩塌,集群瓦解
  9. 应用服务器无法及时得到数据响应请求,来自客户端的请求数量越来越多,应用服务器崩溃
  10. 应用服务器,redis,数据库全部重启,效果不理想

3、问题分析

  • 短时间范围内
  • 大量key集中过期

4、解决方案(道)

  1. 更多的页面静态化处理
  2. 构建多级缓存架构
    • Nginx缓存+redis缓存+ehcache缓存
  3. 检测Mysql严重耗时业务进行优化
    • 对数据库的瓶颈排查:例如超时查询、耗时较高事务等
  4. 灾难预警机制
    • 监控redis服务器性能指标
      • CPU占用、CPU使用率
      • 内存容量
      • 查询平均响应时间
      • 线程数
  5. 限流、降级
    • 短时间范围内牺牲一些客户体验,限制一部分请求访问,降低应用服务器压力,待业务低速运转后再逐步放开访问

5、解决方案(术)

  1. LRU与LFU切换

  2. 数据有效期策略调整

    • 根据业务数据有效期进行分类错峰,A类90分钟,B类80分钟,C类70分钟

      img

    • 过期时间使用固定时间+随机值的形式,稀释集中到期的key的数量

      img

  3. 超热数据使用永久key

  4. 定期维护(自动+人工)

    • 对即将过期数据做访问量分析,确认是否延时,配合访问量统计,做热点数据的延时
  5. 加锁

    • 慎用!

6、总结

缓存雪崩就是瞬间过期数据量太大,导致对数据库服务器造成压力。如能够有效避免过期时间集中,可以有效解决雪崩现象的出现(约40%),配合其他策略一起使用,并监控服务器的运行数据,根据运行记录做快速调整

原本情况:

image-20210907151332317

服务雪崩的情况:

image-20210907151254348

3、缓存击穿

1、数据库服务器崩溃(2)

  1. 系统平稳运行过程中
  2. 数据库连接量瞬间激增
  3. Redis服务器无大量key过期
  4. Redis内存平稳,无波动
  5. Redis服务器CPU正常
  6. 数据库崩溃

image-20210908131731307

img

2、问题排查

  1. Redis中某个key过期,该key访问量巨大
  2. 多个数据请求从服务器直接压到Redis后,均未命中
  3. Redis在短时间内发起了大量对数据库中同一数据的访问

3、问题分析

  • 单个key高热数据
  • key过期

4、解决方案(术)

  1. 预先设定

    • 以电商为例,每个商家根据店铺等级,指定若干款主打商品,在购物节期间,加大此类信息key的过期时长
    • 注意:购物节不仅仅指当天,以及后续若干天,访问峰值呈现逐渐降低的趋势
  2. 现场调整

    • 监控访问量,对自然流量激增的数据延长过期时间或设置为永久性key
  3. 后台刷新数据

    • 启动定时任务,高峰期来临之前,刷新数据有效期,确保不丢失
  4. 二级缓存

    • 设置不同的失效时间,保障不会被同时淘汰就行
  5. 加锁(但是要注意也是性能瓶颈,慎重!)

    • 分布式锁,防止被击穿,

    • 利用互斥锁保证同一时刻只有一个客户端可以查询底层数据库的这个数据,一旦查到数据就缓存至Redis内,避免其他大量请求同时穿过Redis访问底层数据库;

      img

      在使用互斥锁的时候需要避免出现死锁或者锁过期的情况:

      • 使用lua脚本或事务将获取锁和设置过期时间作为一个原子性操作(如:set kk vv nx px 30000),以避免出现某个客户端获取锁之后宕机导致的锁不被释放造成死锁现象;
      • 另起一个线程监控获取锁的线程的查询状态,快到锁过期时间时还没查询结束则延长锁的过期时间,避免多次查询多次锁过期造成计算资源的浪费;

5、总结

缓存击穿就是单个高热数据过期的瞬间,数据访问量较大,未命中redis后,发起了大量对同一数据的数据库访问,导致对数据库服务器造成压力。应对策略应该在业务数据分析与预防方面进行,配合运行监控测试与即时调整策略,毕竟单个key的过期监控难度较高,配合雪崩处理策略即可。

4、缓存穿透

1、数据库服务器崩溃(3)

  1. 系统平稳运行过程中
  2. 应用服务器流量随时间增量较大
  3. Redis服务器命中率随时间逐步降低
  4. Redis内存平稳,内存无压力
  5. Redis服务器CPU占用激增
  6. 数据库服务器压力激增
  7. 数据库崩溃

image-20210908131718336

img

2、问题排查

  • Redis中大面积出现未命中
  • 出现非正常URL访问

3、问题分析

  • 获取的数据在数据库中也不存在,数据库查询未得到对应数据
  • Redis获取到null数据未进行持久化,直接返回
  • 下次此类数据到达重复上述过程
  • ==出现黑客攻击服务器==

4、解决方案(术)

  1. 缓存null

    • 对查询结果为null的数据进行缓存(长期使用,定期清理),设定短时限,例如30-60秒,最高5分钟
  2. 白名单策略

    • 提前预热各种分类数据id对应的bitmaps,id作为bitmaps的offset,相当于设置了数据白名单。当加载正常数据时,放行,加载异常数据时直接拦截(效率偏低)

    • 使用布隆过滤器(有关布隆过滤器的命中问题对当前状况可以忽略)

      • (布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数)。
      • 布隆过滤器可以用于检索一个元素是否在一个集合中
      • 它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难
      • 将所有可能存在的数据哈希到一个足够大的bitmaps中,一个一定不存在的数据会被这个bitmaps拦截掉,从而避免了对底层存储系统的查询压力

      img

      布隆过滤器有误判率,虽然不能完全避免数据穿透的现象,但已经可以将99.99%的穿透查询给屏蔽在Redis层了,极大的降低了底层数据库的压力,减少了资源浪费。

  3. 实施监控

    • 实时监控redis命中率(业务正常范围时,通常会有一个波动值)与null数据的占比
      • 非活动时段波动:通常检测3-5倍,超过5倍纳入重点排查对象
      • 活动时段波动:通常检测10-50倍,超过50倍纳入重点排查对象 根据倍数不同,启动不同的排查流程。然后使用黑名单进行防控(运营)
  4. key加密

    • 问题出现后,临时启动防灾业务key,对key进行业务层传输加密服务,设定校验程序,过来的key校验
    • 例如每天随机分配60个加密串,挑选2到3个,混淆到页面数据id中,发现访问key不满足规则,驳回数据访问

5、总结

缓存穿透访问了不存在的数据,跳过了合法数据的redis数据缓存阶段,每次访问数据库,导致对数据库服务器造成压力。通常此类数据的出现量是一个较低的值,当出现此类情况以毒攻毒,==并及时报警==。应对策略应该在临时预案防范方面多做文章。

无论是黑名单还是白名单,都是对整体系统的压力,警报解除后尽快移除。

5、缓存更新

缓存服务(Redis)和数据服务(底层数据库)是相互独立且异构的系统,在更新缓存或更新数据的时候无法做到原子性的同时更新两边的数据,因此在并发读写或第二步操作异常时会遇到各种数据不一致的问题。如何解决并发场景下更新操作的双写一致是缓存系统的一个重要知识点。

第二步操作异常:缓存和数据的操作顺序中,第二个动作报错。如数据库被更新, 此时失效缓存的时候出错,缓存内数据仍是旧版本;

缓存更新的设计模式有四种:

  • Cache aside

    • 查询:先查缓存,缓存没有就查数据库,然后加载至缓存内;

    • 更新:先更新数据库,然后让缓存失效;或者先失效缓存然后更新数据库;

    • 为了避免在并发场景下,多个请求同时更新同一个缓存导致脏数据,因此不能直接更新缓存而是另缓存失效。(看Redis 的缓存一致性)

    • 推荐使用先失效缓存,后更新数据库,配合延迟失效来更新缓存的模式;

      img

  • Read through:在查询操作中更新缓存,即当缓存失效时,Cache Aside 模式是由调用方负责把数据加载入缓存而 Read Through 则用缓存服务自己来加载

  • Write through:在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由缓存自己更新数据库

  • Write behind caching:俗称write back,在更新数据的时候,只更新缓存,不更新数据库,缓存会异步地定时批量更新数据库

四种缓存更新模式的优缺点

  • Cache Aside:实现起来较简单,但需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository);
  • Read/Write Through:只需要维护一个数据存储(缓存),但是实现起来要复杂一些;
  • Write Behind Caching:与Read/Write Through 类似,区别是Write Behind Caching的数据持久化操作是异步的,但是Read/Write Through 更新模式的数据持久化操作是同步的。
    • 优点是直接操作内存速度快,多次操作可以合并持久化到数据库。
    • 缺点是数据可能会丢失,例如系统断电等。

缓存本身就是通过牺牲强一致性来提高性能,因此使用缓存提升性能,就会有数据更新的延迟性。这就需要我们在评估需求和设计阶段根据实际场景去做权衡了。

6、缓存降级

缓存降级是指当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,即使是有损部分其他服务,仍然需要保证主服务可用。可以将其他次要服务的数据进行缓存降级,从而提升主服务的稳定性。

降级的目的是保证核心服务可用,即使是有损的。如去年双十一的时候淘宝购物车无法修改地址只能使用默认地址,这个服务就是被降级了,这里阿里保证了订单可以正常提交和付款,但修改地址的服务可以在服务器压力降低,并发量相对减少的时候再恢复。

降级可以根据实时的监控数据进行自动降级也可以配置开关人工降级。是否需要降级,哪些服务需要降级,在什么情况下再降级,取决于大家对于系统功能的取舍。

7、性能指标监控

1、监控指标

  • 性能指标:Performance
  • 内存指标:Memory
  • 基本活动指标:Basic activity
  • 持久性指标:Persistence
  • 错误指标:Error
1、性能指标:Performance
Name Description
latency Redis响应一个请求的时间
instantaneous_ops_per_sec 平均每秒处理请求总数
hit rate(calculated) 缓存命中率(计算出来的)
2、内存指标:Memory
Name Description
used_menory 已使用内存
mem_fragmentation_ratio 内存碎片率
evicted_keys 由于最大内存限制被移除的key的数量
blocked_clients 由于BLPOP,BRPOP,or BRPOPlPUSH而备阻塞的客户端
3、基本活动指标:Basic activity
Name Description
connected_clients 客户端连接数
connected_slaves Slave数量
master_last_io_seconds_ago 最近一次主从交互之后的秒数
keyspace 数据库中的key值总数
4、持久性指标:Persistence
Name Description
rdb_last_save_time 最后一次持久化保存到磁盘的时间戳
rdb_changes_since_last_save 自最后一次持久化以来数据库的更改数
5、错误指标:Error
Name Description
rejected_connections 由于达到maxclient限制而被拒绝的连接数
keyspace_misses Key值查找失败(没有命中)次数
master_link_down_since_seconds 主从断开的持续时间

2、redis相关的工具与监控命令

  • 工具
    • Cloud Insight Redis
    • Prometheus
    • Redis-stat
    • Redis-faina
    • RedisLive
    • zabbix
  • 命令
    • benchmark
    • redis cli
    • monitor
    • showlog
1、命令——benchmark

注意:

  • benchmark是一个指令,而不是一个redis命令

  • 所以不是在redis的客户端上启动的,而是像启动redis的服务端或客户端那样直接执行的

  • 命令

    1
    redis-benchmark [-h ] [-p ] [-c ] [-n <requests]> [-k ]
  • 范例1

    1
    redis-benchmark
  • 范例2

    1
    redis-benchmark -c 100 -n 5000

    说明:100个连接,5000次请求对应的性能

image-20210907163642955

2、命令——monitor

注意:

  • monitor它是一个redis命令,而不是一个指令

  • 所以需要在redis的客户端上启动的

  • 命令

    1
    monitor

    打印服务器调试信息

3、命令——showlong
  • 命令

    1
    showlong [operator]

    operator:

    • get :获取慢查询日志
    • len :获取慢查询日志条目数
    • reset :重置慢查询日志
  • 相关配置

    1
    2
    slowlog-log-slower-than 1000 #设置慢查询的时间下线,单位:微妙
    slowlog-max-len 100 #设置慢查询命令对应的日志显示长度,单位:命令数

3、Redis 6

1、NoSQL数据库简介

1、技术发展

技术的分类

  1. 解决功能性的问题:Java、Jsp、RDBMS、Tomcat、HTML、Linux、JDBC、SVN
  2. 解决扩展性的问题:Struts、Spring、SpringMVC、Hibernate、Mybatis
  3. 解决性能的问题:NoSQL、Java线程、Hadoop、Nginx、MQ、ElasticSearch

1、Web 1.0时代

Web1.0的时代,数据访问量很有限,用一夫当关的高性能的单点服务器可以解决大部分问题。

image-20210907203723504

2、Web 2.0时代

随着Web2.0的时代的到来,用户访问量大幅度提升,同时产生了大量的用户数据。加上后来的智能移动设备的普及,所有的互联网平台都面临了巨大的性能挑战。

image-20210907203751485

3、解决CPU及内存压力

image-20210907214051175

4、解决IO压力

image-20210907214058698

2、NoSQL数据库

1、NoSQL数据库概述

NoSQL(NoSQL = Not Only SQL ),意即“不仅仅是SQL”,泛指非关系型的数据库

NoSQL 不依赖业务逻辑方式存储,而以简单的key-value模式存储。因此大大的增加了数据库的扩展能力。

  • 不遵循SQL标准。
  • 不支持ACID。(事务的四大特性)
  • 远超于SQL的性能。

2、NoSQL适用场景

  • 对数据高并发的读写
  • 海量数据的读写
  • 对数据高可扩展性的

3、NoSQL不适用场景

  • 需要事务支持
  • 基于sql的结构化查询存储,处理复杂的关系,需要即席查询
    • 即席查询是用户根据自己的需求,灵活的选择查询条件,系统能够根据用户的选择生成相应的统计报表
  • (用不着sql的和用了sql也不行的情况,请考虑用NoSql)

4、常用的NoSQL数据库

1、Memcache
  • 很早出现的NoSql数据库
  • 数据都在内存中,一般不持久化
  • 支持简单的key-value模式,支持类型单一
  • 一般是作为缓存数据库辅助持久化的数据库
2、Redis

几乎覆盖了Memcached的绝大部分功能

数据都在内存中,支持持久化,主要用作备份恢复

除了支持简单的key-value模式,还支持多种数据结构的存储,比如 list、set、hash、zset等。

一般是作为缓存数据库辅助持久化的数据库

3、MongoDb
  • 高性能、开源、模式自由(schema free)的文档型数据库
  • 数据都在内存中, 如果内存不足,把不常用的数据保存到硬盘
  • 虽然是key-value模式,但是对value(尤其是json)提供了丰富的查询功能
  • 支持二进制数据及大型对象
  • 可以根据数据的特点替代RDBMS ,成为独立的数据库。或者配合RDBMS,存储特定的数据。

3、行式存储数据库(大数据时代)

1、行式数据库

image-20210907215836602

2、列式数据库

image-20210907215842362

1、Hbase

HBase是Hadoop项目中的数据库。它用于需要对大量的数据进行==随机==、==实时==的读写操作的场景中。

HBase的目标就是处理数据量非常庞大的表,可以用普通的计算机处理超过10亿行数据,还可处理有数百万元素的数据表。

2、Cassandra[kəˈsændrə]

Apache Cassandra是一款免费的开源NoSQL数据库,其设计目的在于管理由大量商用服务器构建起来的庞大集群上的**海量数据集(数据量通常达到PB级别)**。在众多显著特性当中,Cassandra最为卓越的长处是对写入及读取操作进行规模调整,而且其不强调主集群的设计思路能够以相对直观的方式简化各集群的创建与扩展流程。

计算机存储单位 计算机存储单位一般用B,KB,MB,GB,TB,EB,ZB,YB,BB来表示,它们之间的关系是:

位 bit (比特)(Binary Digits):存放一位二进制数,即 0 或 1,最小的存储单位。

字节 byte:8个二进制位为一个字节(B),最常用的单位。

1KB (Kilobyte 千字节)=1024B,

1MB (Megabyte 兆字节 简称“兆”)=1024KB,

1GB (Gigabyte 吉字节 又称“千兆”)=1024MB,

1TB (Trillionbyte 万亿字节 太字节)=1024GB,其中1024=2^10 ( 2 的10次方),

1PB(Petabyte 千万亿字节 拍字节)=1024TB,

1EB(Exabyte 百亿亿字节 艾字节)=1024PB,

1ZB (Zettabyte 十万亿亿字节 泽字节)= 1024 EB,

1YB (Jottabyte 一亿亿亿字节 尧字节)= 1024 ZB,

1BB (Brontobyte 一千亿亿亿字节)= 1024 YB.

注:“兆”为百万级数量单位。

4、图关系型数据库

主要应用:社会关系,公共交通网络,地图及网络拓谱(n*(n-1)/2)

image-20210907220207887

5、DB-Engines 数据库排名

http://db-engines.com/en/ranking

image-20210907220243094


2、Redis配置文件介绍

自定义目录:/myredis/redis.conf

1、###Units单位###

配置大小单位,开头定义了一些基本的度量单位,只支持bytes,不支持bit

大小写不敏感

image-20210908000334131

2、###INCLUDES包含###

image-20210908000425633

类似jsp中的include,多实例的情况可以把公用的配置文件提取出来

3、###网络相关配置

1、bind

默认情况bind=127.0.0.1只能接受本机的访问请求

不写的情况下,无限制接受任何ip地址的访问

生产环境肯定要写你应用服务器的地址;服务器是需要远程访问的,所以需要将其注释掉

如果开启了protected-mode,那么在没有设定bind ip且没有设密码的情况下,Redis只允许接受本机的响应

如果配置了bind * -::*:表示无限制接受任何ip地址的访问

image-20210908000556177

保存配置,停止服务,重启启动查看进程,不再是本机访问了。

image-20210908000618247

2、protected-mode

将本机访问保护模式设置no

默认为yes,表示只能进行本机访问

image-20210908000707970

3、port

端口号,默认 6379

image-20210908000750174

4、tcp-backlog

设置tcp的backlog,backlog其实是一个连接队列,backlog队列总和=未完成三次握手队列 + 已经完成三次握手队列。

在高并发环境下你需要一个高backlog值来避免慢客户端连接问题。默认是511

注意Linux内核会将这个值减小到/proc/sys/net/core/somaxconn的值(128),所以需要确认增大/proc/sys/net/core/somaxconn/proc/sys/net/ipv4/tcp_max_syn_backlog(128)两个值来达到想要的效果

image-20210908000853907

5、timeout

一个空闲的客户端维持多少秒会关闭,0表示关闭该功能。即永不关闭

默认为0,单位:秒/s

image-20210908001813330

6、tcp-keepalive

对访问客户端的一种心跳检测,每个n秒检测一次。默认是300s

单位为秒,如果设置为0,则不会进行Keepalive检测,建议设置成60

image-20210908002103758

4、###GENERAL通用###

1、daemonize

是否为后台进程,设置为yes

守护进程,后台启动

image-20210908002228723

2、pidfile

存放pid文件的位置,每个实例会产生一个不同的pid文件

image-20210908002305616

3、loglevel

指定日志记录级别,Redis总共支持四个级别:debug、verbose、notice、warning,默认为notice

四个级别根据使用阶段来选择,生产环境选择notice 或者warning

image-20210908002345229

4、logfile

日志文件名称,默认为空

image-20210908002458862

5、databases 16

设定库的数量 默认16默认数据库为0,可以使用SELECT <dbid>命令在连接上指定数据库id

image-20210908002536352

5、###SECURITY安全###

设置密码

image-20210908002619005

访问密码的查看、设置和取消。默认是没有密码的

在命令中设置密码,只是临时的。重启redis服务器,密码就还原了。

永久设置,需要再配置文件中进行设置。

image-20210908002648683

6、####LIMITS限制###

1、maxclients

  • 设置redis同时可以与多少个客户端进行连接。
  • 默认情况下为10000个客户端。
  • 如果达到了此限制,redis则会拒绝新的连接请求,并且向这些连接请求方发出“max number of clients reached”以作回应。

image-20210908002803930

2、maxmemory

  • 建议必须设置,否则,将内存占满,造成服务器宕机
  • 设置redis可以使用的内存量。一旦到达内存使用上限,redis将会试图移除内部数据,移除规则可以通过maxmemory-policy来指定。(逐出算法)
  • 如果redis无法根据移除规则来移除内存中的数据,或者设置了“不允许移除”,那么redis则会针对那些需要申请内存的指令返回错误信息,比如SET、LPUSH等。
  • 但是对于无内存申请的指令,仍然会正常响应,比如GET等。如果你的redis是主redis(说明你的redis有从redis),那么在设置内存使用上限时,需要在系统中留出一些内存空间给同步队列缓存,只有在你设置的是“不移除”的情况下,才不用考虑这个因素。

image-20210908003003737

3、maxmemory-policy

  • volatile-lru:使用LRU算法移除key,只对设置了过期时间的键;(最近最少使用)
  • allkeys-lfu:在所有集合key中,使用LFU算法移除key(最近使用次数最少)
  • volatile-random:在过期集合中移除随机的key,只对设置了过期时间的键
  • allkeys-random:在所有集合key中,移除随机的key
  • volatile-ttl:移除那些TTL值最小的key,即那些最近要过期的key
  • noeviction:不进行移除。针对写操作,只是返回错误信息

image-20210908004811841

4、maxmemory-samples

  • 设置样本数量,LRU算法和最小TTL算法都并非是精确的算法,而是估算值,所以你可以设置样本的大小,redis默认会检查这么多个key并选择其中LRU的那个。
  • 一般设置3到7的数字,数值越小样本越不准确,但性能消耗越小。

image-20210908004839416


3、Redis的发布和订阅

1、什么是发布和订阅

Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者 (sub) 接收消息。

Redis 客户端可以订阅任意数量的频道。

2、Redis的发布和订阅

Redis中的订阅、发布实现了发布/订阅消息范式,发布者不是计划发送消息给特定的订阅者,而是发布消息到不同的频道,发布者不需要知道是哪些订阅者订阅了消息订阅者对一个或多个频道感兴趣,只需接收感兴趣的消息,不需要知道是什么样的发布者发布的消息。这种发布者和订阅者的解耦合可以带来更大的扩展性和更加动态的网络拓扑。

在Redis的发布订阅模式中,有三个部分:

  • Publisher(发布者):发送消息到频道中,每次只能往一个频道发送一条消息;
  • Subscriber(订阅者):订阅频道,订阅者可以同时订阅多个频道;
  • Channel(频道):将发布者发布的消息转发给当前订阅此频道的订阅者

img

  1. 客户端可以订阅频道如下图

    image-20210908014220373

  2. 当给这个频道发布消息后,消息就会发送给订阅的客户端

    image-20210908014312523

3、发布订阅命令行实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 发布消息到指定的频道
PUBLISH channel message

# 订阅给定的一个或多个频道的信息
SUBSCRIBE channel [channel ...]

# 订阅一个或多个符合给定模式的频道
PSUBSCRIBE pattern [pattern ...]

# 指退订给定的频道
UNSUBSCRIBE [channel [channel ...]]

# 退订所有给定模式的频道
PUNSUBSCRIBE [pattern [pattern ...]]

# 查看订阅与发布系统状态
PUBSUB subcommand [argument [argument ...]]

4、使用

使用搭建的集群来测试Redis的订阅发布模式,A节点作为发布者,A,B,C节点作为订阅者消费A节点发布的消息:

  • 订阅者6381:与发布者在同一节点,订阅www,csdn,wyk三个频道;
  • 订阅者6382:订阅符合csdn和wyk模式的所有频道;
  • 订阅者6383:订阅csdn频道;
  • 发布者6381:分别往csdn1,csdn2,csdn,wyk四个频道发送消息,验证三个订阅者接收消息的情况以及发布者发布消息后的返回值;

img

断开后的订阅者重新订阅后会丢失断开期间发布者发布的消息:

img

在集群模式中,发布者发布消息后的返回值取决于订阅者与发布者在不在同一个节点上

  • 发布者发布消息后返回值为与发布者相同节点当前订阅了该频道的客户端数量

5、对比

在上面的示例中,大家也可以看到,Redis中的发布订阅非常像消息队列,但还是有不同,我们就来对比一下Redis的List实现消息队列以及传统消息队列Kafka看看有哪些不同:

1、对比List

与Redis中的List对比,基于List实现的消息队列需要结合lpush + brpop来实现。

  • (多消费组)
    • 当多个客户端同时消费同一个List消息队列时,消费者A使用brpop消费的数据就从list中弹出了,消费者B就再也读不到该数据;
    • 而在发布订阅中,多个订阅者可以订阅相同的频道,频道内的数据会分发到各个订阅者,不会出现某一个订阅者消费了之后,另一个订阅者读不到该数据的情况。
  • (断点消费)
    • 但对于List的消息队列来说,当消费者断开后重连,仍然可以从List中断点消费还没消费的数据;
    • 而发布订阅中,如果订阅者断开重连,会丢失断开期间发布者发布的数据,无法恢复。

2、对比Kafka

Redis的发布订阅以及List并不是要和专业的消息队列对标,而是可以实现类似的功能,真正在消息队列领域做的好的有很多,RabbitMQ、ActiveMQ、RocketMQ、Kafka、Pulsar等等,发布订阅相比于它们有什么异同呢?

  • 不同点
    • 持久化:Kafka会将数据持久化到磁盘内,而Redis的发布订阅做不到;
    • 断点消费:上面也提到,当订阅者断开重连会丢失断开期间发布者发布的消息,而kafka中会记录每个消费者消费的topic的offset,因此kafka可以从断开的offset继续消费;
    • 偏移量:基于上一条,同样的kafka的消费者可以指定从某个offset开始重新消费,而Redis发布订阅根本不会记录订阅者消费的偏移量;
    • 消费方式在Redis发布订阅中,数据消费情况是由发布者控制的,当发布者发布到频道中后,只有当前连接了频道的订阅者才能消费到数据,断开重连的会失去那部分数据。而kafka中消费进度是由消费者控制的,消费者从topic中拉取数据并记录消费的offset。
  • 相同点
    • 消息模型:在JMS消息模型中有点对点和订阅发布两种,Kafka和Redis发布订阅都是采用发布订阅的模型。
    • 消费者组:Kafka里在不同的消费者组中的消费者消费相同的topic时会各自维护一个offset,因此不会出现A消费之后的数据,B就消费不到的情况。Redis中订阅者订阅相同的频道也不会出现类似的情况。

4、解决库存遗留问题——LUA脚本

1、LUA脚本

Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一个完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。

很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。

这其中包括魔兽争霸地图、魔兽世界、博德之门、愤怒的小鸟等众多游戏插件或外挂。

https://www.w3cschool.cn/lua/

2、LUA脚本在Redis中的优势

将复杂的或者多步的redis操作,写为一个脚本,一次提交给redis执行,减少反复连接redis的次数。提升性能。

LUA脚本是类似redis事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作。

但是注意redis的lua脚本功能,只有在Redis 2.6以上的版本才可以使用

利用lua脚本淘汰用户,解决超卖问题。

redis 2.6版本以后,通过lua脚本解决争抢问题,实际上是redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题

image-20210908024325395

3、在Redis中使用LUA脚本示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local userid=KEYS[1]; 
local prodid=KEYS[2];
local qtkey="sk:"..prodid..":qt";
local usersKey="sk:"..prodid.":usr';
local userExists=redis.call("sismember",usersKey,userid);
if tonumber(userExists)==1 then
return 2;
end
local num= redis.call("get" ,qtkey);
if tonumber(num)<=0 then
return 0;
else
redis.call("decr",qtkey);
redis.call("sadd",usersKey,userid);
end
return 1;

5、Redis 6新功能

1、ACL

1、简介

Redis ACL是 Access Control List(访问控制列表)的缩写,该功能允许根据可以执行的命令和可以访问的键来限制某些连接。

在Redis 5版本之前,Redis 安全规则只有密码控制,还有通过rename 来调整高危命令比如 flushdb , KEYS* , shutdown 等。Redis 6 则提供ACL的功能对用户进行更细粒度的权限控制 :

  1. 接入权限:用户名和密码
  2. 可以执行的命令
  3. 可以操作的 KEY

参考官网

2、命令

  • 展现用户权限列表

    1
    acl list

    数据说明:

    image-20210908132433758

  • 查看添加权限指令类别

    1
    2
    # 加参数类型名可以查看类型下具体命令
    acl cat <参数类型名>
    1. 查看添加权限指令类别

      image-20210908132718079

    2. 加参数类型名可以查看类型下具体命令

      image-20210908132730836

  • 查看当前用户

    1
    acl whoami
  • 创建和编辑用户ACL

    1
    aclsetuser

3、ACL规则

1、有效ACL规则的列表

某些规则只是用于激活或删除标志,或对用户ACL执行给定更改的单个单词。其他规则是字符前缀,它们与命令或类别名称、键模式等连接在一起。

类型 参数 说明
启动用户 on 激活某用户账号
禁用用户 off 禁用某用户账号。注意:已验证的连接仍然可以工作。如果默认用户被标记为off,则新连接将在未进行身份验证的情况下启动,并要求用户使用AUTH选项发送AUTH或HELLO,以便以某种方式进行身份验证。
权限的添加删除 + 将指令添加到用户可以调用的指令列表中
- 从用户可执行指令列表移除指令
+@ 添加该类别中用户要调用的所有指令,有效类别为@admin、@set、@sortedset…等,通过调用ACL CAT命令查看完整列表。特殊类别@all表示所有命令,包括当前存在于服务器中的命令,以及将来将通过模块加载的命令。
-@ 从用户可调用指令中移除类别
allcommands +@all的别名
nocommand -@all的别名
可操作键的添加或删除 ~ 添加可作为用户可操作的键的模式。例如~*允许所有的键
2、查看ACL的有哪些命令
1
help @server

img

3、通过命令创建新用户默认权限
1
acl setuser user1

image-20210908133631954

在上面的示例中,我根本没有指定任何规则。如果用户不存在,这将使用just created的默认属性来创建用户。如果用户已经存在,则上面的命令将不执行任何操作。

4、设置有用户名、密码、ACL权限、并启用的用户
1
acl setuser user2 on >password ~cached:* +get

image-20210908133725918

5、切换用户,验证权限

image-20210908133748625

2、IO 多线程

1、简介

Redis6终于支撑多线程了,告别单线程了吗?

IO多线程其实指客户端交互部分网络IO交互处理模块多线程,而非执行命令多线程

**Redis6执行命令依然是==单线程==**。

2、原理架构

Redis 6 加入多线程,但跟 Memcached 这种从 IO处理到数据访问多线程的实现模式有些差异。

Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程

之所以这么设计是不想因为多线程而变得复杂,需要去控制 key、lua、事务,LPUSH/LPOP 等等的并发问题。

整体的设计大体如下:

image-20210908134006188

另外,多线程IO默认也是不开启的,需要再配置文件中配置

1
2
3
io-threads-do-reads yes 

io-threads 4

3、工具支持 Cluster

之前老版Redis想要搭集群需要单独安装ruby环境Redis 5 将 redis-trib.rb 的功能集成到 redis-cli

另外官方 redis-benchmark 工具开始支持 cluster 模式了,通过多线程的方式对多个分片进行压测。

image-20210908134140316

4、Redis新功能持续关注

Redis6新功能还有:

  1. RESP3新的 Redis 通信协议:优化服务端与客户端之间通信
  2. Client side caching客户端缓存:基于 RESP3 协议实现的客户端缓存功能。为了进一步提升缓存的性能,将客户端经常访问的数据cache到客户端。减少TCP网络交互。
  3. Proxy集群代理模式:Proxy 功能,让 Cluster 拥有像单实例一样的接入方式,降低大家使用cluster的门槛。不过需要注意的是代理不改变 Cluster 的功能限制,不支持的命令还是不会支持,比如跨 slot 的多Key操作。
  4. Modules API
    • Redis 6中模块API开发进展非常大,因为Redis Labs为了开发复杂的功能,从一开始就用上Redis模块。
    • Redis可以变成一个框架,利用Modules来构建不同系统,而不需要从头开始写然后还要BSD许可。
    • Redis一开始就是一个向编写各种系统开放的平台。

6、Redis 的缓存一致性

1、Redis 数据一致性简介

在做系统优化时,想到了将数据进行分级存储的思路。因为在系统中会存在一些数据,有些数据的实时性要求不高,比如一些配置信息。

基本上配置了很久才会变一次。而有一些数据实时性要求非常高,比如订单和流水的数据。所以这里根据数据要求实时性不同将数据分为三级:

  • 第1级:订单数据和支付流水数据;这两块数据对实时性和精确性要求很高,所以不添加任何缓存,读写操作将直接操作数据库
  • 第2级:用户相关数据;这些数据和用户相关,具有读多写少的特征,所以我们使用redis进行缓存
  • 第3级:支付配置信息;这些数据和用户无关,具有数据量小,频繁读,几乎不修改的特征,所以我们使用本地内存进行缓存

但是只要使用到缓存,无论是本地内存做缓存还是使用 redis 做缓存,那么就会存在数据同步的问题,因为配置信息缓存在内存中,而内存时无法感知到数据在数据库的修改。这样就会造成数据库中的数据与缓存中数据不一致的问题。

接下来就讨论一下关于保证缓存和数据库双写时的数据一致性

2、解决方案

那么我们这里列出来所有策略,并且讨论他们优劣性。

  1. 先更新数据库,后更新缓存
  2. 先更新数据库,后删除缓存
  3. 先删除缓存,后更新数据库
  4. 先更新缓存,后更新数据库
  5. 数据异步同步(最佳实现)

3、详解

1、先更新数据库,后更新缓存(不推荐)

这种场景一般是没有人使用的,主要原因是在==更新缓存==那一步,为什么呢?

  1. 其一:因为有的业务需求缓存中存在的值并不是直接从数据库中查出来的,有的是需要经过一系列计算来的缓存值,那么这时候后你要更新缓存的话其实代价是很高的。如果此时有大量的对数据库进行写数据的请求,但是读请求并不多,那么此时如果每次写请求都更新一下缓存,那么性能损耗是非常大的。
    • 例子:比如在数据库中有一个值为 1 的值,此时我们有 10 个请求对其每次加一的操作,但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有十个请求对缓存进行更新,会有大量的冷数据产生,如果我们不更新缓存而是删除缓存,那么在有读请求来的时候那么就会只更新缓存一次。
  2. 其二:存在缓存数据和数据库数据不一致情况
    • 例子:当有两个线程A、B,同时对一条数据进行操作,一开始数据库和redis的数据都为tony,当线程A去修改数据库,将tong改为allen,然后线程A在修改缓存中的数据,可能因为网络原因出现延迟,这个时候线程B,将数据库中的数据修改成了Mike、然后将redis中的tony,也改成了Mike,然后线程A恢复正常,将redis中的缓存改成了allen,此时就出现了缓存数据和数据库数据不一致情况。
    • 这种情况是很致命的,因为在这个值被重新修改或过期之前,A和B读取的都是错误数据。
2、先更新缓存,后更新数据库(不推荐)

这一种情况和第一种情况是一样的,主要原因是在==更新缓存==那一步:

  1. 其一:缓存的数据可能需要经过计算,如果每次写请求都更新一下缓存,那么性能损耗是非常大的。
  2. 其二:存在缓存数据和数据库数据不一致情况
    • 当有两个线程A、B,同时对一条数据进行操作,线程A先将redis中 的数据修改为了allen,然后CPU切换到了线程B,将redis中的数据修改为了mike,然后将数据库中的信息也修改了mike,然后线程A获得CPU执行,将数据库中的信息改为了allen,此时出现缓存和数据库数据不一致情况。
3、先删除缓存,后更新数据库(存在问题)(推荐)

该方案也会出问题,具体出现的原因如下:

先删除缓存,后更新数据库

此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)

  1. 请求 A 会先删除 Redis 中的数据,然后去数据库进行更新操作
  2. 此时请求 B 看到 Redis 中的数据时空的,会去数据库中查询该值,补录到 Redis 中
  3. 但是此时请求 A 并没有更新成功,或者事务还未提交,那么这时候就会产生数据库和 Redis 数据不一致的问题

如何解决呢?

  • 其实最简单的解决办法就是==延时双删==的策略。

延时双删

但是上述的保证事务提交完以后再进行删除缓存还有一个问题:

  • 就是如果你使用的是 Mysql 的读写分离的架构的话,那么其实主从同步之间也会有时间差。

主从同步时间差

此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)

  1. 请求 A 更新操作,删除了 Redis
  2. 请求主库进行更新操作,主库与从库进行同步数据的操作
  3. 请 B 查询操作,发现 Redis 中没有数据
  4. 去从库中拿去数据
  5. 此时同步数据还未完成,拿到的数据是旧数据

此时的解决办法:如果是对 Redis 进行填充数据的查询数据库操作,那么就强制将其指向主库进行查询

从主库中拿数据

4、先更新数据库,后删除缓存(推荐)

问题:这一种情况也会出现问题:

  1. 可能会短暂出现数据不一致情况,但最终都会一致
    • 当有两个线程A、B,线程A先去将数据库的值修改为allen,然后需要去删除redis中的缓存,当线程B去读取缓存时,线程A已经完成delete操作时,缓存不命中,需要去查询数据库,然后在更新缓存,数据一致性;如果线程A没有完成delete操作,线程B直接命中,返回的数据与数据库中的数据不一致,可能会短暂出现数据不一致情况,但最终都会一致。
    • 当有两个线程A、B,线程A去修改数据库中的值改为allen,然后出现网络波动,线程B将数库中的值修改为了Mike,然后两个线程都会删除缓存,保证数据一致性
  2. 当数据过期或者初始化时,会出现数据不一致情况,也就是线程B从数据库中,查询到数据为tony,然后线程A将tony修改为了allen,然后去删除redis中的数据,然后线程B将读到的tony,更新到了数据库中,出现了数据不一致问题
    • 解决方案:对于不过期的数据我们要在上线的时候做好数据的预热,保证缓存命中。对于存在过期的数据,因为有过期时间,只会在特定的时间段内数据不一致,下次数据过期后,可以恢复,对于实时性要求不高时,可以接受。
  3. 更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功,那么此时再读取缓存的时候每次都是错误的数据了。

先更新数据库,后删除缓存

此时解决方案就是利用消息队列进行删除的补偿。具体的业务逻辑用语言描述如下:

  1. 请求 A 先对数据库进行更新操作
  2. 在对 Redis 进行删除操作的时候发现报错,删除失败
  3. 此时将Redis 的 key 作为消息体发送到消息队列中
  4. 系统接收到消息队列发送的消息后再次对 Redis 进行删除操作

但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。

这也是第5种解决方法:数据异步同步

5、数据异步同步(最佳实现)

对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。

Canal:基于数据库增量日志解析,提供增量数据订阅和消费https://github.com/alibaba/canal

mysql会将操作记录在Binary log日志中,通过canal去监听数据库日志二进制文件,解析log日志,同步到redis中进行增删改操作。

canal的工作原理:canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议;MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal );canal 解析 binary log 对象(原始为 byte 流)。

利用订阅 binlog 删除缓存

4、总结

每种方案各有利弊,比如在第二种先删除缓存,后更新数据库这个方案我们最后讨论了要更新 Redis 的时候强制走主库查询就能解决问题,那么这样的操作会对业务代码进行大量的侵入,但是不需要增加的系统,不需要增加整体的服务的复杂度。

最后一种方案我们最后讨论了利用订阅 binlog 日志进行搭建独立系统操作 Redis,这样的缺点其实就是增加了系统复杂度。其实每一次的选择都需要我们对于我们的业务进行评估来选择,没有一种技术是对于所有业务都通用的。没有最好的,只有最适合我们的。

7、LUA脚本

1、介绍

Redis2.6之后新增的功能,我们可以在redis中通过lua脚本操作redis。与事务不同的是事务是将多个命令添加到一个执行的集合,执行的时候仍然是多个命令,会受到其他客户端的影响,而脚本会将多个命令和操作当成一个命令在redis中执行,也就是说该脚本在执行的过程中,不会被任何其他脚本或命令打断干扰。正是因此这种原子性,lua脚本才可以代替multi和exec的事务功能。同时也是因此,在lua脚本中不宜进行过大的开销操作,避免影响后续的其他请求的正常执行。

2、使用lua脚本的好处

  • lua脚本是作为一个整体执行的,所以中间不会被其他命令插入
  • 可以把多条命令一次性打包,所以可以有效减少网络开销;
  • lua脚本可以常驻在redis内存中,所以在使用的时候,可以直接拿来复用。也减少了代码量

3、应用

redis脚本使用eval命令执行lua脚本,其中numkeys表示lua script里有多少个key参数,redis脚本根据该数字从后面的key和arg中取前n个作为key参数,之后的都作为arg参数:

1
eval script numkeys key [key ...] arg [arg ...]

1、例1:记录IP登录次数

1
2
3
4
5
# 利用hash记录所有登录的IP次数
# key参数的数量必须和numkey一致,使用key或者argv可以实现一样的效果。如下面第一个命令里用了三个key,代表后面的三个参数分别对应脚本里的key1 key2 key3.第二个命令里用了一个key,代表了后面第一个参数对应脚本里的key1,后面第二和第三个参数对应脚本里的argv1和argv2
eval "return redis.call('hincrby', KEYS[1], KEYS[2], KEYS[3])" 3 h_host host_192.168.145.1 1

eval "return redis.call('hincrby', KEYS[1], ARGV[1], ARGV[2])" 1 h_host host_192.168.145.1 1

img

2、例2:当10秒内请求3次后拒绝访问

1
2
3
4
5
# 1.给访问ip的key递增
# 2.判断该访问次数若为首次登录则设置过期时间10
# 3.若不是首次登录则判断是否大于3次,若大于则返回0,否则返回1

eval "local request_times = redis.call('incr',KEYS[1]);if request_times == 1 then redis.call('expire',KEYS[1], ARGV[1]) end;if request_times > tonumber(ARGV[2]) then return 0 end return 1;" 1 test_127.0.0.1 10 3

img

通过上面的例子也可以看出,我们可以在redis里使用eval命令调用lua脚本,且该脚本在redis里作为单条命令去执行不会受到其余命令的影响,非常适用于高并发场景下的事务处理。同样我们可以在lua脚本里实现任何想要实现的功能,迭代,循环,判断,赋值 都是可以的。

4、lua脚本缓存

redis脚本也支持将脚本进行持久化,这样的话,下次再使用就不用输入那么长的lua脚本了。事实上使用eval执行的时候也会缓存,eval与load不同的是eval会将lua脚本执行并缓存,而load只会将脚本缓存

相同点是它们都使用sha算法进行缓存,因此只要lua脚本内容相同,eval与load缓存的sha码就是一样的。而缓存后的脚本,我们可以使用evalsha命令直接调用,极大的简化了我们的代码量,不用重复的将lua脚本写出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#eval 执行脚本并缓存
eval script numkeys key [key ...] arg [arg ...]

#load 缓存lua脚本
SCRIPT LOAD script

#使用缓存的脚本sha码调用脚本
EVALSHA sha1 numkeys key [key ...] arg [arg ...]

#使用sha码判断脚本是否已缓存
SCRIPT EXISTS sha1 [sha1 ...]

#清空所有缓存的脚本
SCRIPT FLUSH

#杀死当前正在执行的所有lua脚本
SCRIPT KILL

img

img

8、Redis6新特性之ACL安全策略(用户权限管理)

自从Redis6.0以来,大家呼吁了很久的权限管理功能**(ACL[access control list 访问控制列表])终于发布了,通过此功能,我们可以设置不同的用户并对他们授权命令或数据权限。这样我们可以避免有些用户的误操作导致数据丢失或避免数据泄露**的安全风险。

1、介绍

在Redis6之前的版本,我们只能使用requirepass参数给default用户配置登录密码,同一个redis集群的所有开发都共享default用户,难免会出现误操作把别人的key删掉或者数据泄露的情况,那之前我们也可以使用rename command的方式给一些危险函数重命名或禁用,但是这样也防止不了自己的key被其他人访问

因此Redis6版本推出了ACL(Access Control List)访问控制权限的功能,基于此功能,我们可以设置多个用户,并且给每个用户单独设置命令权限和数据权限。 为了保证向下兼容,Redis6保留了default用户和使用requirepass的方式给default用户设置密码,默认情况下default用户拥有Redis最大权限,我们使用redis-cli连接时如果没有指定用户名,用户也是默认default。

我们可以在配置文件中或者命令行中设置ACL,如果使用配置config文件的话需要重启服务使用配置aclfile文件或者命令行授权的话无需重启Redis服务但需要及时将权限持久化到磁盘,否则下次重启的时候无法恢复该权限。

官网:https://redis.io/topics/acl

2、配置文件模式

配置ACL的方式有两种,一种是在config文件中直接配置,另一种是在外部aclfile中配置。配置的命令是一样的,但是两种方式只能选择其中一种,我们之前使用requirepass给default用户设置密码,默认就是使用config的方式,执行config rewrite重写配置后会自动在config文件最下面新增一行记录配置default的密码和权限

1、conf文件模式

使用redis.conf文件配置default和其他用户的ACL权限

1
2
3
4
5
6
7
8
9
10
11
# 1.在config文件中配置default用户的密码
requirepass 123456

# 2.在config文件中添加DSL命令配置用户ACL权限
【使用方式在下文】

# 3.在config文件中注释aclfile的路径配置(默认是注释的)
#aclfile /opt/app/redis6/users.acl

# 4.重启redis服务
systemctl restart redis

img

因此我们可以直接在config配置文件中使用上面default用户ACL这行DSL命令设置用户权限,或者我们也可以配置外部aclfile配置权限。

配置aclfile需要先将config中配置的DSL注释或删除,因为Redis不允许两种ACL管理方式同时使用,否则在启动redis的时候会报下面的错误:

1
# Configuring Redis with users defined in redis.conf and at the same setting an ACL file path is invalid. This setup is very likely to lead to configuration errors and security holes, please define either an ACL file or declare users directly in your redis.conf, but not both.

2、外部ACLFILE模式

使用外部aclfile文件配置Default和其他用户的ACL权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1.注释redis.conf中所有已授权的ACL命令,如:
#user default on #8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92 ~* +@all

# 2.在config文件中注释default用户的密码,因为开启aclfile之后,requirepass的密码就失效了:
redis.conf
#requirepass 123456

# users.acl
user default on #8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92 ~* +@all

# 3.在config文件中配置aclfile的路径,然后创建该文件,否则重启redis服务会报错找不到该文件
aclfile /opt/app/redis6/users.acl
touch /opt/app/redis6/users.acl

# 4.在外部aclfile文件中添加DSL命令配置用户ACL权限
【使用方式在下文】

## 5.重启redis服务或使用aclfile load命令加载权限
systemctl restart redis

在redis命令行中执行:
aclfile load

开启aclfile之后不再推荐在redis.conf文件中通过requirepass配置default的密码,因为它不再生效,同时开启aclfile之后也不能使用redis-cli -a xxx登陆,必须使用redis-cli –user xxx –pass yyy来登陆:

img

3、对比conf和aclfile模式

在redis.conf和aclfile模式中配置DSL 官方更推荐使用aclfile,因为如果在redis.conf中配置了权限之后需要重启redis服务才能将配置的权限加载至redis服务中来,但如果使用aclfile模式,可以调用acl load命令将aclfile中配置的ACL权限热加载进环境中,类似于Mysql中的flush privileges。

redis.conf users.acl
配置方式 DSL DSL
加载ACL配置 重启Redis服务 ACL LOAD命令
持久化ACL配置 CONFIG REWRITE命令 ACL SAVE命令

4、命令行模式

1、介绍

上面可以看到,我们在配置文件中配置的ACL权限,需要执行ACL LOAD或者重启Redis服务才能生效,事实上我们可以直接在命令行下配置ACL,在命令行模式下配置的权限无需重启服务即可生效。我们也可以在命令行模式下配置ACL并将其持久化到aclfile或者config文件中(这取决于配置文件中选择的是config模式还是外部aclfile模式),一旦将权限持久化到aclfile或cofig文件中,下次重启就会自动加载该权限,如果忘记持久化,一旦服务宕机或重启,该权限就会丢失。

1
2
3
4
5
# 如果使用config模式,将ACL权限持久化到redis.conf文件中使用下面的命令:
config rewrite

# 如果使用aclfile模式,将ACL权限持久化到users.acl文件中使用下面的命令:
acl save

2、ACL规则

ACL是使用DSL(Domain specific language)定义的,该DSL描述了用户能够执行的操作。该规则始终从上到下,从左到右应用,因为规则的顺序对于理解用户的实际权限很重要。ACL规则可以在redis.conf文件以及users.acl文件中配置DSL,也可以在命令行中通过ACL命令配置。

某些规则只是用于激活或删除标志,或对用户ACL执行给定更改的单个单词。其他规则是字符前缀,它们与命令或类别名称、键模式等连接在一起。

类型 参数 说明
启动用户 on 激活某用户账号
禁用用户 off 禁用某用户账号。注意:已验证的连接仍然可以工作。如果默认用户被标记为off,则新连接将在未进行身份验证的情况下启动,并要求用户使用AUTH选项发送AUTH或HELLO,以便以某种方式进行身份验证。
权限的添加删除 + 将指令添加到用户可以调用的指令列表中
- 从用户可执行指令列表移除指令
+ | subcommand 允许使用已禁用命令的特定子命令
+@ 添加该类别中用户要调用的所有指令,有效类别为@admin、@set、@sortedset…等,通过调用ACL CAT命令查看完整列表。特殊类别@all表示所有命令,包括当前存在于服务器中的命令,以及将来将通过模块加载的命令。
-@ 从用户可调用指令中移除类别
allcommands +@all的别名
nocommand -@all的别名
可操作键的添加或删除 ~ 添加可作为用户可操作的键的模式。例如~*允许所有的键
禁止访问某些Key * resetkeys 使用当前模式覆盖所有允许的模式。如: ~foo:* ~bar:* resetkeys ~objects:* ,客户端只能访问匹配 object:* 模式的 KEY。

为用户配置有效密码

  • ><password>:将此密码添加到用户的有效密码列表中。例如,>mypass将“mypass”添加到有效密码列表中。该命令会清除用户的nopass标记。每个用户可以有任意数量的有效密码。
  • <<password>:从有效密码列表中删除此密码。若该用户的有效密码列表中没有此密码则会返回错误信息。
  • #<hash>:将此SHA-256哈希值添加到用户的有效密码列表中。该哈希值将与为ACL用户输入的密码的哈希值进行比较。允许用户将哈希存储在users.acl文件中,而不是存储明文密码。仅接受SHA-256哈希值,因为密码哈希必须为64个字符且小写的十六进制字符。
  • !<hash>:从有效密码列表中删除该哈希值。当不知道哈希值对应的明文是什么时很有用。
  • nopass:移除该用户已设置的所有密码,并将该用户标记为nopass无密码状态:任何密码都可以登录。resetpass命令可以清除nopass这种状态。
  • resetpass:清空该用户的所有密码列表。而且移除nopass状态。resetpass之后用户没有关联的密码同时也无法使用无密码登录,因此resetpass之后必须添加密码或改为nopass状态才能正常登录。
  • reset:重置用户状态为初始状态。执行以下操作resetpass,resetkeys,off,-@all。

5、ACL HELP

使用下面的命令查看help文档:

1
acl help

img

6、ACL LIST

我们可以使用ACL LIST命令来查看当前活动的ACL,默认情况下,有一个“default”用户:

1
2
127.0.0.1:6379> acl list
1) "user default on nopass ~* +@all"

其中user为关键词,default为用户名,后面的内容为ACL规则描述,on表示活跃的,nopass表示无密码, ~* 表示所有key,+@all表示所有命令。所以上面的命令表示活跃用户default无密码且可以访问所有命令以及所有数据。

7、ACL USERS

返回所有用户名:

1
2
127.0.0.1:6379> acl users
1) "default"

8、ACL WHOAMI

返回当前用户名:

1
2
127.0.0.1:6379> acl whoami
1) "default"

9、ACL CAT

查看命令类别,用于授权:

1
2
ACL CAT:显示所有的命令类别 。
ACL CAT <category>:显示所有指定类别下的所有命令。

img

10、ACL SETUSER

使用下面的命令创建或修改用户属性,username区分大小写

1
2
3
4
5
6
7
8
9
10
# username区分大小写
# 若用户不存在则按默认规则创建用户,若存在则修改用户属性
SETUSER <username> [attribs ...]

# 若用户不存在,则按默认规则创建用户。若用户存在则该命令不做任何操作。
ACL SETUSER <username>

# 若用户不存在,则按默认规则创建用户,并为其增加<rules>。
# 若用户存在则在已有规则上增加 <rules>。
ACL SETUSER <username> <rules>

默认规则下新增的用户处于非活跃状态,且没有密码,同时也没有任何命令和key的权限:

img

例:使用下面的命令新增用户/修改用户的权限:

1
2
3
4
5
6
7
8
#on为活跃状态,密码为wyk123456,允许对所有csdn开头的key使用get和set命令
ACL SETUSER wyk on >wyk123456 ~csdn* +get +set

#为wyk用户新增一个可用密码csdn8888
ACL SETUSER wyk on >csdn8888

#为wyk用户新增list类别下所有命令的权限
ACL SETUSER wyk on +@list

img

img

img

11、ACL GETUSER

使用下面的命令查看用户的ACL权限:

1
2
#查看用户的ACL权限
acl getuser <username>

img

12、ACL DELUSER

删除指定的用户:

1
2
#删除指定的用户
acl deluser <username>

img

13、ACL SAVE

前面提到过,我们可以使用acl save命令将当前服务器中的ACL权限持久化到aclfile中,如果没持久化就关闭redis服务,那些ACL权限就会丢失,因此我们每次授权之后一定要记得ACL SAVE将ACL权限持久化到aclfile中:

1
2
3
4
5
# 将acl权限持久化到磁盘的aclfile中
acl save

# 如果使用redis.conf配置ACL,则使用config rewrite命令将ACL持久化到redis.conf中
config rewrite

img

img

14、ACL LOAD

我们也可以直接在aclfile中修改或新增ACL权限,修改之后不会立刻生效,我们可以在redis命令行中执行acl load将该aclfile中的权限加载至redis服务中:

1
2
# 将aclfile中的权限加载至redis服务中
acl load

img

15、ACL GENPASS

随机返回sha256密码,我们可以直接使用该密文配置ACL密码:

1
2
3
4
5
6
# 随机返回一个256bit的32字节的伪随机字符串,并将其转换为64字节的字母+数字组合字符串
acl genpass

# 可指定位数
acl genpass 32
acl genpass 64

img

16、ACL LOG

查看ACL安全日志:

1
acl log

17、AUTH

使用auth命令切换用户:

1
AUTH <username> <password>

img

18、总结

由于Redis是高性能的数据库,正常情况下每秒可以接收百万级别的请求,因此我们的用户密码一定要是非常复杂的组合,否则很容易就会被暴力跑字典给破解了,不管怎么说,这次Redis6版本带来的新特性ACL权限控制也是解决了我们很大的痛点,终于可以权限隔离了!

9、Redis6新特性之RESP3与客户端缓存(Client side caching)

Redis6引入新的RESP3协议,并以此为基础加入了客户端缓存的新特性,在此特性下,大大提高了应用程序的响应速度,并降低了数据库的压力。

1、什么是客户端缓存

1、介绍

客户端缓存是一种用于创建高性能服务的技术,在此技术下,应用程序端将数据库中的数据缓存在应用端的内存中,当应用程序访问数据时直接从本机内存中读取,而无需连接数据库端,减少了网络IO,提升了应用程序的响应速度,同时也减少了数据库端的压力。

官网:https://redis.io/topics/client-side-caching

Why RESP3:http://antirez.com/news/125

没有客户端缓存:

img

应用端先查询Redis端,如果没有Redis缓存则到源数据库端查询,如果有则直接从Redis端查询数据,更新数据时直接更新MySQL端并同步至Redis内;

有客户端缓存:

img

应用端先查询本地缓存如Guava、Caffeine,若没有本地缓存则访问Redis缓存,如果Redis缓存中也没有则查询源数据库;

2、客户端缓存的优点

  • 降低了客户端的数据延迟,提升客户端的响应速度;
  • 数据库端接收的查询减少,降低了数据库端的压力,因此在相同的数据集下可以使用更少的节点提供服务;

疑问:

为了实现客户端缓存,我们面临这样的问题,当进程中缓存了数据,而数据库端数据发生变更,该如何通知到进程,避免客户端显示失效的数据呢?(缓存一致性)

在Redis中可以使用发布订阅机制,向客户端发布数据失效的通知,但该模式下即使某些客户端中没有包含过期数据也会向所有客户端发送无效的消息,非常影响数据库的性能

在之前的版本中,客户端缓存采用缓存槽(caching slot)**的方式记录每个客户端内的key是否发生变化以及时同步,最新版中已弃用该方式,而是采用记录key的名称或前缀**。

2、什么是RESP3

RESP 全称 REdisSerializationProtocol,是 Redis 服务端与客户端之间通信的协议。在Reds6之前的版本,使用的是RESP2协议,数据都是字符串数组的形式返回给客户端,不管是 list 还是 sorted set。因此客户端需要自行去根据类型进行解析,这样会增加了客户端实现的复杂性。

为了照顾老用户,Redis6在兼容 RESP2 的基础上,开始支持 RESP3,但未来会全面切换到RESP3之上。今天的客户端缓存在基于RESP3才能有更好的实现,可以在同一个连接中运行数据的查询和接收失效消息。而目前在RESP2上实现的客户端缓存,需要两个客户端连接以转发重定向的形式实现。

1
2
3
4
5
# 使用RESP2协议
HELLO 2

# 使用RESP3协议
HELLO 3

img

3、客户端缓存的实现方式

Redis客户端缓存被称为Tracking,在RESP3协议下,有两种模式:

  • 默认模式:服务器记录客户端访问了哪些key,当其中的key发生变更时给客户端发送失效信息,消耗服务器端内存;
  • 广播模式:客户端订阅访问过的key的前缀,当符合模式的key发生变更就会被通知(即使变更的key没有被客户端缓存),服务器端不记录客户端访问的key,因此不会消耗服务器端的内存;

4、默认模式

1、原理

服务器端会记录访问key的客户端列表并维护一个表,这个表被称为==失效表==(Invalidation Table),如果插入一个新的key,服务器端会给客户端发送失效信息并从客户端踢除该key,避免提供过时数据。

在失效表中不会记录key和客户端内对应指针的映射关系,只会记录key的指针和各客户端ID(每个Redis客户端都有一个唯一ID)的映射关系,当发送完失效信息后,客户端剔除key服务端从失效表中删除key的指针和客户端ID的映射关系

在失效表中key的命名空间只有一个,即是说,在db0~db15中相同的key名,在失效表中会记录在同一个命名空间内,即使客户端缓存的是db0内的key,如果db1内的同名key被更新,也会通知客户端剔除db0内的同名key。

客户端缓存的操作就是对key的内存地址进行操作

  1. 当开启客户端缓存的客户端从Redis获取数据时,Redis服务端会调用 enableTracking 方法在上面的失效表中记录key和客户端ID的映射关系;
  2. 若key被修改,则Redis服务端会调用 trackingInvalidateKey 函数根据该key被缓存的客户端列表ID调用 sendTrackingMessage 函数向它们发送失效消息。(发送失效消息前会检查客户端的Client_TrackingNOLOOP状态)
  3. 服务端发送完失效消息后会从失效表中将该key与客户端ID的映射关系删除;
  4. 由于客户端可能会在开启之后关闭了缓存功能,在失效表中删除key和该客户端ID之间的映射关系比较消耗性能,因此服务端采用懒删除的方式只是将该客户端的Client_Tracking相关标志位删除

img

2、应用

上面提到我们可以使用HELLO命令切换RESP3协议,在此协议下我们使用tracking命令开启track追踪,此时服务端会记录客户端在连接的生命周期内的只读的key,当客户端开启track追踪后,key的数据会被缓存在客户端内存中:

1
2
3
4
5
6
7
8
# 开启RESP3协议
HELLO 3

# 开启tracking 客户端缓存
client tracking on

# 关闭tracking 客户端缓存
client tracking off

为了演示失效消息的通知,这里使用telnet测试客户端缓存,然后在另一个redis-cli对key做操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 使用telnet连接客户端
telnet wykd 6379

# auth命令登录服务器(如果没有密码可以忽略)
auth default wyk123456

# 开启RESP3
hello 3

# 开启客户端缓存 tracking
client tracking on

# 查询一个key 同时该key会被缓存
get name

# 在另一个redis客户端中 修改/删除/过期/淘汰 该key
set name new_values

# 在telnet窗口会收到key失效的消息如下:
get name #客户端缓存key
$3
wyk

>2 #失效消息
$10
invalidate
*1
$4
name

# 关闭客户端缓存 tracking,关闭后不会再收到key的失效消息
client tracking off

img

当开启了tracking后,客户端缓存的key如果在别处被修改为与原值一样,也会收到失效消息

当客户端缓存失效后,该key再被修改时,客户端不会再收到消息,也就是再查询该key之后 才会在客户端缓存key的值;

当客户端缓存的key因过期策略内存淘汰策略被驱逐时,服务端也会发送失效消息给开启了tracking的客户端:

img

当开启了tracking的客户端获取的key不存在时,如果在另一个客户端新增/修改了该key,那个tracking的客户端也会收到失效消息,可见如果key不存在也会在客户端缓存中缓存空值,这种结果因人而异,个人认为这样不太好:

  1. 一是客户端会徒增大量的无用缓存(空值)
  2. 二是服务端的失效表会维护更多的key->clientID的映射关系。

img

5、广播模式

1、原理

另一个客户端缓存的实现方式是广播模式(broadcasting)**,广播不会消耗服务端的内存,而是向各客户端发送更多的失效消息。广播模式与默认模式类似,不同的是广播模式下维护的是前缀表,在前缀表中存储客户端订阅的key前缀与客户端ID之间的映射关系。**

在这种模式下,有以下的主要行为:

  1. 客户端使用 BCAST 选项开启客户端缓存的广播模式,并使用 PREFIX 指定一个或多个前缀。如果不指定前缀则默认客户端接收所有的key的失效消息,如果指定则只会接收匹配该前缀的key的失效消息;
  2. 在广播模式下,服务端维护的不是失效表,而是前缀表(Prefix Table),每个前缀映射一些客户端ID
  3. 每次修改跟任意前缀匹配的键时,所有订阅该前缀的客户端都将收到失效消息
  4. 服务端的CPU消耗与订阅的key前缀数量成正比,订阅的key前缀数量越多服务器端压力越大
  5. 服务器可以为订阅特定前缀的客户端创建单个回复,并向所有的客户端发送相同的回复来进行优化,有助于降低CPU使用率。

img

2、应用

同样,在广播模式下也需要开启RESP3协议,这里我们仍然使用刚才的telnet会话进行演示。

使用下面的命令开启广播模式的客户端缓存,上面提到广播模式下服务端维护一个前缀表,记录key的前缀和客户端id的映射关系,因此我们也可以在客户端指定需要接收失效消息的key前缀:

1
2
3
4
5
6
7
8
9
# telnet访问redis客户端(略)
# 开启RESP3
hello 3

# 开启广播模式的客户端缓存tracking,默认会收到所有的key的失效信息
client tracking on bcast

# 开启广播模式的客户端缓存tracking,只接受指定前缀'wyk'的key的失效信息
client tracking on bcast prefix wyk

img

广播模式下,只要符合客户端设置的key前缀的key发生新增、修改、删除、过期、淘汰等动作,即使该key没有被该客户端缓存,也会收到key的失效消息

6、重定向模式

为了兼容RESP2协议,在Redis6中客户端缓存可以以重定向(Redirect)的方式实现,不再使用 RESP3 原生支持的PUSH消息,而是将消息通过 Pub/Sub 通知给另外一个客户端连接

img

1
2
3
4
5
6
7
8
# 查看客户端id
client id

# 用于接收失效消息的客户端订阅频道
subscribe _redis_:invalidate

# 客户端开启Tracking客户端缓存 并指定需要接收失效消息的客户端ID
client tracking on bcast redirect receive_client_id

img

7、OPTIN 和 OPTOUT

在默认模式或重定向模式下,我们可以有选择的对需要的key进行缓存,而由于广播模式是匹配key前缀,因此不能使用此命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# RESP3 默认模式
# 切换RESP3协议
hello 3

# 开启客户端缓存optin选项
client tracking on optin

# 此命令后面第一个只读key会被缓存
client caching yes


# RESP2 重定向模式
hello 2

# 开启客户端缓存optin选项,1234是接收失效消息的客户端id
client tracking on REDIRECT 1234 OPTIN

# 此命令后面第一个只读key会被缓存
client caching yes
  • OPTIN:只有执行client caching yes之后的第一个key才会被缓存;
  • OPTOUT:与OPTIN相反,执行client caching no之后的第一个只读key不会被缓存;

img

img

注意:在redis6.0.3版本中outin和optout选项时灵时不灵,可能还有BUG;

8、NOLOOP选项

我们的客户端修改自己已缓存的key的时候也会收到这个key的过期信息,事实上这个客户端是不需要收到该消息的,这造成了浪费,因此我们可以使用NOLOOP选项将该客户端设置为:本客户端修改的key不会收到相关的失效信息。

1
2
# 开启客户端缓存的NOLOOP选项
client tracking on noloop

开启noloop选项的客户端,如果在该客户端上修改它已经缓存的key,自己不会收到该key的失效消息:

img

没开启noloop选项的客户端,如果在该客户端上修改它已经缓存的key,自己也会收到该key的失效消息:

img

9、失效表key上限

可以使用 tracking_table_max_keys 参数修改服务端失效表内记录的缓存的key的数量,当失效表内记录的缓存key达到配置的数量时会随机从失效表内移除缓存:

1
2
3
4
5
# 查询最大缓存的数量
config get tracking-table-max-keys

# 设置最大缓存数量为300
config set tracking-table-max-keys 300

img

10、Redis6新特性之集群代理(Cluster Proxy)

在之前的文章中介绍了Redis6的集群搭建和原理,我们可以使用dummy和smart客户端连接集群,本篇介绍Redis6新增的一个功能:集群代理。客户端不需要知道集群中的具体节点个数和主从身份,可以直接通过代理访问集群,对于客户端来说通过集群代理访问的集群就和单机的Redis一样,因此也能解决很多集群的使用限制。

1、介绍

在Redis6的release note中可以看到新功能中的ACL,RESP3,客户端缓存我们在前面的文章中已经介绍过,本篇就看一下集群代理。集群代理与Redis在Github上是不同的项目,地址如下:

Github:https://github.com/RedisLabs/redis-cluster-proxy

集群代理(Redis Cluster Proxy): 将集群抽象为单实例,客户端不需要知道集群中的具体节点个数和主从身份,通过代理访问集群,就像访问单机Redis一样。同时集群代理也能解决在集群模式下multiple操作的限制及跨slot操作限制(如mget,mset…)。

img

Redis集群代理的特点:

  • 自动化路由:每个查询被自动路由到集群的正确节点;
  • 多线程:多路复用通信模型,每个线程都有自己的集群连接;
  • 顺序性:在多路复用上下文中,保证查询的执行和应答顺序;
  • 无感知更新集群信息:当请求/重定向错误时会自动更新集群信息,客户端提交的查询会在集群信息更新完成后重新执行,对于客户端来说这一切是无感的,客户端不会收到请求/重定向的错误信息,而是直接收到查询的结果;
  • 跨槽/节点查询:支持跨slot或node的mutiple操作key,如mget,mset,del等。但由于mset,del会破坏原子性,因此该配置默认关闭;
  • ACL:支持连接开启了ACL的Redis集群;
  • DBSIZE:对于没有指定节点的命令,将会合并所有的信息的总和并返回;

img

2、安装

1、下载解压

从github上下载解压源码(2020-06-30:目前最新版是unstable版本)

1
2
3
4
5
# git命令
git clone https://github.com/artix75/redis-cluster-proxy

# 手动下载zip解压
unzip redis-cluster-proxy-unstable.zip

2、安装gcc4.9+版本

在之前安装Redis6的文章中有介绍,此处略过安装gcc9.1:

1
2
3
4
5
# 开启gcc9.1
scl enable devtoolset-9 bash

# 查看gcc版本
gcc -v

img

3、编译

执行下面的命令编译源码,出现下图表示安装成功:

1
2
3
4
5
6
# 进入目录并编译
cd redis-cluster-proxy-unstable
make

# 如果编译出错之后再编译可以先执行命令删除之前的编译文件
make distclean

如果遇到错误 unknown type name ‘_Atomic’ ,请检查gcc版本重新安装;

img

4、安装

编译成功后使用下面的命令安装Redis集群代理服务,出现下图表示安装成功:

1
2
# 安装Redis集群代理,可指定安装目录
make install PREFIX=/opt/app/redis-cluster-proxy

img

5、使用

1、配置启动

从源码中将配置文件copy到安装目录:

1
cp /home/wyk/redis-cluster-proxy-unstable/proxy.conf /opt/app/redis-cluster-proxy/

修改配置文件:vim /opt/app/redis-cluster-proxy/proxy.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#配置Redis集群,这里我使用前几篇文章中配置的Redis6集群,三主三从
cluster 127.0.0.1:6381 #主1
cluster 127.0.0.1:6382 #主2
cluster 127.0.0.1:6383 #主3
cluster 127.0.0.1:6391 #从1
cluster 127.0.0.1:6392 #从2
cluster 127.0.0.1:6393 #从3

#默认端口
port 7777

#线程数
threads 8

#后台运行
daemonize yes

#日志文件
logfile "/opt/app/redis-cluster-proxy/redis-cluster-proxy.log"

#允许跨slot查询
enable-cross-slot yes

#最大客户端连接数
max-clients 10000

#ACL用户密码(也可以在启动服务时指定)
auth-user myuser #ACL用户
auth mypassw #ACL密码

#连接池
connections-pool-size 10
connections-pool-min-size 10
connections-pool-spawn-every 50
connections-pool-spawn-rate 50

创建日志文件并使用下面的命令指定配置文件启动集群代理:

1
2
3
4
5
# 创建日志文件
touch /opt/app/redis-cluster-proxy/redis-cluster-proxy.log

# 启动Redis集群代理服务
/opt/app/redis-cluster-proxy/bin/redis-cluster-proxy -c /opt/app/redis-cluster-proxy/proxy.conf

img

连接集群代理客户端

Redis集群代理服务监听7777端口,我们可以使用Redis命令行指定7777端口启动集群代理客户端:

1
2
# 连接Redis集群代理客户端
/opt/app/redis6/bin/redis-cli -p 7777

img

2、跨节点slot操作

上面提到在集群代理中,会将集群抽象成一个Redis实例,对用户来说跨slot/node操作是无感的,而在默认集群中会重定向到对应slot所在的节点进行操作。

默认集群模式:

在之前的Redis集群文章中演示了在dummy客户端中操作集群内的key时会重定向到该key存储的slot所在的节点:

1
2
3
4
5
# 使用-c进入集群命令行模式
redis-cli -c -p 6381

# 使用命令查看key所在的槽
cluster keyslot key1

img

img

集群代理模式:

在集群代理模式下,可以跨slot甚至跨节点操作key,而在集群模式下链接客户端是做不到的。下图演示了如果在集群代理中使用mset和mget跨slot跨node设置或查询key,对于用户来说仿佛是在使用一个单实例的Redis:

img

6、故障转移

手动的使集群中一个主节点宕机,测试集群代理能否感知到集群的故障转移:

1
2
3
# 在6381主节点执行命令,手动的让其宕机
# 命令执行一个非法的内存访问从而让 Redis 崩溃,仅在开发时用于 BUG 调试,执行后需要重启服务
debug segfault

情况一、主节点6381宕机,6391节点升级为主节点,集群恢复正常,但6381节点还没启动,此时集群代理无法使用,需要启动6381节点之后集群代理才能恢复使用:

img

img

情况二、手动将6381主节点宕机,当从节点6391升级为主节点后,重启6381节点作为6391的从节点,此时集群的主从机器全部正常启动,查询集群代理,不会收到影响:

img

3、结尾

目前在Github上最新的版本仍是unstable版,毕竟是新功能,还是有很多BUG的,像集群的故障转移在集群代理中就没有做的很好,其次就是如果集群代理服务本身没有解决单点故障(可以尝试配合HAProxy等代理服务做负载均衡)。

官方最后声明中也提到【当前处于α版本,不推荐在生产环境使用]】:

This project is currently alpha code that is indented to be evaluated by the community in order to get suggestions and contributions. We discourage its usage in any production environment.

但不可否认的是集群代理给redis集群提供了轻量的代理层,也解决了很多在集群模式中的使用限制,未来的潜力还很大,让我们拭目以待吧!

11、Redis6新特性之IO多线程

终于,Redis的多线程版本横空出世,大大提高了并发,本篇就带大家来看看什么是IO多线程,和我们理解的多线程有什么区别,与Memcached的多线程又有什么区别。

1、介绍

作为Redis6版本中的其中一大新特性,IO多线程大大提升了Redis的并发性能。该功能也是在社区内被反复提起,而之前Antirez在自己的博客中也曾经做过简单的介绍:http://antirez.com/news/126

2、为什么Redis6.0之前是单线程模型

首先我们要明确一个共识,我们通常所说的Redis单线程是指获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这个主线程就是我们平时说的”单线程”**,而其他的清理脏数据、无用连接的释放、LRU淘汰策略等等也是有其他线程在处理的,因此其实在Redis6之前的Redis本质上也是多线程的。**

为什么这些操作要放在同一个主线程中,官方给出的解释:传送门

  • 通常瓶颈不在 CPU,而是在内存和网络IO;
  • 多线程会带来线程不安全的情况;
  • 多线程可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗;
  • 单线程降低了Redis内部实现复杂度;
  • hash的惰性rehash,lpush等线程不安全的命令可以无锁执行;

3、什么是IO多线程

既然上面说单线程那么好,为什么Redis6.0又要引入多线程呢?

Redis 抽象了一套 AE 事件模型,将 IO 事件和时间事件融入一起,同时借助多路复用机制(linux上用epoll) 的回调特性,使得 IO 读写都是非阻塞的,实现高性能的网络处理能力。加上 Redis 基于内存的数据处理,这就是 “单线程,但却高性能” 的核心原因。

但 IO 数据的读写依然是阻塞的,这也是 Redis 目前的主要性能瓶颈之一,特别是在数据吞吐量特别大的时候,具体情况如下:

img

上图的下半部分,当 socket 中有数据时,Redis 会通过系统调用将数据从内核态拷贝到用户态,供 Redis 解析用。这个拷贝过程是阻塞的,术语称作 “同步 IO”,数据量越大拷贝的延迟越高,时间消耗也越大,糟糕的是这些操作都是单线程处理的。(写 reponse 时也是一样)

这是 Redis 目前的瓶颈之一,Redis6.0 引入的 “多线程” 机制就是对于该瓶颈的优化。核心思路是,将主线程的 IO 读写任务拆分出来给一组独立的线程执行,使得多个 socket 的读写可以并行化。

与 Memcached 从 IO 处理到数据访问多线程的实现模式有些差异。Redis 的IO多线程只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。之所以这么设计是不想 Redis 因为多线程而变得复杂,需要去控制 key、lua、事务,LPUSH/LPOP 等等的并发问题。

4、开启IO多线程

默认情况下,Redis多线程是禁用的,我们可以在配置文件选择开启:vim redis.conf

1
2
3
4
5
# 开启IO多线程
io-threads-do-reads yes

# 配置线程数量,如果设为1就是主线程模式。
io-threads 4

官方建议:至少4核的机器才开启IO多线程,并且除非真的遇到了性能瓶颈,否则不建议开启此配置 ,且配置的线程数少于机器总线程数,如果有4核建议开启2,3个线程,如果有8核建议开6线程。 线程并不是越多越好,多于8个线程意义不大。

5、性能对比

因资源有限,我手边的机器渣渣配置如下,开启3个线程对比单线程:

配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[root@BD-T-uatredis9 ~]# free -h
total used free shared buff/cache available
Mem: 15G 1.0G 13G 64M 1.2G 14G
Swap: 4.0G 0B 4.0G
[root@BD-T-uatredis9 ~]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: 1
Core(s) per socket: 1
Socket(s): 4
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 79
Model name: Intel(R) Xeon(R) CPU E7-4809 v4 @ 2.10GHz
Stepping: 1
CPU MHz: 2094.952
BogoMIPS: 4189.90
Hypervisor vendor: VMware
Virtualization type: full
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 20480K
NUMA node0 CPU(s): 0-3

测试命令:

使用redis-benchmark进行压测,这里模拟在4核4线程的机器上分别测试3线程和单线程在100W请求,数据大小在128b,512b,1024b,200个客户端,执行SET和GET的QPS性能对比

1
2
3
4
5
6
7
8
9
10
11
12
13
#三线程
./redis-benchmark -h localhost -p 6380 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 --threads 3 -d 128 -c 200 -q

./redis-benchmark -h localhost -p 6380 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 --threads 3 -d 512 -c 200 -q

./redis-benchmark -h localhost -p 6380 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 --threads 3 -d 1024 -c 200 -q

#单线程
./redis-benchmark -h localhost -p 6381 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 -d 128 -c 200 -q

./redis-benchmark -h localhost -p 6381 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 -d 512 -c 200 -q

./redis-benchmark -h localhost -p 6381 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 -d 1024 -c 200 -q

结果:

可能是我机器太渣了,3线程比单线程的QPS提升有120%~140%,网友测试的在4线程下QPS提升了100%。

img

网友的测试结果:

1
2
Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 内存,主机型号 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 内存,主机型号 ecs.ic5.2xlarge

img

img

注意,数据仅供验证参考,不能作为线上指标:

  • 本测试只是使用早期的 unstble 分支的性能,不排除稳定版的性能会更好。
  • 本测试并没有针对严谨的延时控制和不同并发的场景进行压测。

6、源码解析

刚才提到IO多线程只是在网络数据的读写上是多线程了,具体流程如下:

img

流程:

  1. 主线程获取 socket 放入等待列表
  2. 将 socket 分配给各个 IO 线程(并不会等列表满)
  3. 主线程阻塞等待 IO 线程读取 socket 完毕
  4. 主线程以单线程执行命令 (如果命令没有接收完毕,会等 IO 下次继续)
  5. 主线程阻塞等待 IO 线程将数据回写 socket 完毕(一次没写完,会等下次再写)
  6. 解除绑定,清空等待队列
  • IO 线程要么同时在读 socket,要么同时在写,不会同时读或写;
  • IO 线程只负责读写 socket 解析命令,不负责执行命令,由主线程串行执行命令;
  • IO 线程数可配置,默认为 1;
  • 上面的过程是完全无锁的,因为在 IO 线程处理的时主线程会等待全部的 IO 线程完成,所以不会出现 data race 的场景。

源码

redis-server 逻辑首先执行 initThreadedIO()函数对 线程进行初始化,当然,也包括 根据配置 server.io_threads_num 控制线程个数,其中主线程的处理逻辑为 IOThreadMain() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* networking.c: line 2666 */
void *IOThreadMain(void *myid) {
/* The ID is the thread number (from 0 to server.iothreads_num-1), and is used by the thread to just manipulate a single sub-array of clients. */
// 线程 ID,跟普通线程池的操作方式一样,都是通过 线程ID 进行操作
long id = (unsigned long)myid;
while(1) {
/* Wait for start */
// 这里的等待操作比较特殊,没有使用简单的 sleep,避免了 sleep 时间设置不当可能导致糟糕的性能,但是也有个问题就是频繁 loop 可能一定程度上造成 cpu 占用较长
for (int j = 0; j < 1000000; j++) {
if (io_threads_pending[id] != 0) break;
}
/* Give the main thread a chance to stop this thread. */
if (io_threads_pending[id] == 0) {
pthread_mutex_lock(&io_threads_mutex[id]);
pthread_mutex_unlock(&io_threads_mutex[id]);
continue;
}
serverAssert(io_threads_pending[id] != 0);
// debug 模式
if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));
/* Process: note that the main thread will never touch our list
* before we drop the pending count to 0. */
// 根据线程 id 以及待分配列表进行 任务分配
listIter li;
listNode *ln;
listRewind(io_threads_list[id],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// 判断读写类型
if (io_threads_op == IO_THREADS_OP_WRITE) {
writeToClient(c,0);
} else if (io_threads_op == IO_THREADS_OP_READ) {
// 这里需要注意重复调用了 readQueryFromClient,不过不用担心,有 CLIENT_PENDING_READ 标识可以进行识别
readQueryFromClient(c->conn);
} else {
serverPanic("io_threads_op value is unknown");
}
}
listEmpty(io_threads_list[id]);
io_threads_pending[id] = 0;
if (tio_debug) printf("[%ld] Done\n", id);
}
}

handleClientsWithPendingReadsUsingThreads() 待处理任务分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* networking.c: line 2871 */
/* When threaded I/O is also enabled for the reading + parsing side, the readable handler will just put normal clients into a queue of clients to process (instead of serving them synchronously). This function runs the queue using the I/O threads, and process them in order to accumulate the reads in the buffers, and also parse the first command available rendering it in the client structures. */
int handleClientsWithPendingReadsUsingThreads(void) {
// 是否开启 线程读
if (!io_threads_active || !server.io_threads_do_reads) return 0;
int processed = listLength(server.clients_pending_read);
if (processed == 0) return 0;
if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);
/* Distribute the clients across N different lists. */
// 将待处理任务进行分配,分配方式为 RR (round robin) 即基于任务到达时间片进行分配
listIter li;
listNode *ln;
listRewind(server.clients_pending_read,&li);
int item_id = 0;
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
int target_id = item_id % server.io_threads_num;
listAddNodeTail(io_threads_list[target_id],c);
item_id++;
}

/* Give the start condition to the waiting threads, by setting the start condition atomic var. */
// 设定任务个数参数
io_threads_op = IO_THREADS_OP_READ;
for (int j = 0; j < server.io_threads_num; j++) {
int count = listLength(io_threads_list[j]);
io_threads_pending[j] = count;
}
/* Wait for all threads to end their work. */
// 等待所有线程任务都处理完毕
while(1) {
unsigned long pending = 0;
for (int j = 0; j < server.io_threads_num; j++)
pending += io_threads_pending[j];
if (pending == 0) break;
}
if (tio_debug) printf("I/O READ All threads finshed\n");
/* Run the list of clients again to process the new buffers. */
// 继续运行,等待新的处理任务
listRewind(server.clients_pending_read,&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
c->flags &= ~CLIENT_PENDING_READ;
if (c->flags & CLIENT_PENDING_COMMAND) {
c->flags &= ~ CLIENT_PENDING_COMMAND;
processCommandAndResetClient(c);
}
processInputBufferAndReplicate(c);
}
listEmpty(server.clients_pending_read);
return processed;
}

readQueryFromClient() 函数

1
2
3
4
5
6
7
8
9
10
11
/* networking.c: line 1791 */
void readQueryFromClient(connection *conn) {
client *c = connGetPrivateData(conn);
int nread, readlen;
size_t qblen;
/* Check if we want to read from the client later when exiting from the event loop. This is the case if threaded I/O is enabled. */
// 加入多线程模型已经启用
if (postponeClientRead(c)) return;
// 如果没有启用多线程模型,则走下面继续处理读逻辑
// ....还有后续老逻辑
}

函数 postponeClientRead() 将任务放入处理队列,而根据上面 IOThreadMain()handleClientsWithPendingReadsUsingThreads() 的任务处理逻辑进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* networking.c: line 2852 */
int postponeClientRead(client *c) {
// 如果启用多线程模型,并且判断全局配置中是否支持多线程读
if (io_threads_active &&
server.io_threads_do_reads &&
// 这里有个点需要注意,如果是 master-slave 同步也有可能被认为是普通 读任务,所以需要标识
!(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
{
c->flags |= CLIENT_PENDING_READ;
// 将任务放入处理队列
listAddNodeHead(server.clients_pending_read,c);
return 1;
} else {
return 0;
}
}

7、对比Memcached

前些年memcached 是各大互联网公司常用的缓存方案,因此redis 和 memcached 的区别基本成了面试官缓存方面必问的面试题,最近几年memcached用的少了,基本都是 redis。不过随着Redis6.0加入了多线程特性,类似的问题可能还会出现,接下来我们只针对多线程模型来简单比较一下它们。

首先看一下Memcached的线程模型:

img

如上图所示:Memcached 服务器采用 master-woker 模式进行工作,服务端采用 socket 与客户端通讯。主线程、工作线程 采用 pipe管道进行通讯。主线程采用 libevent 监听 listen、accept 的读事件,事件响应后将连接信息的数据结构封装起来,根据算法选择合适的工作线程,将连接任务携带连接信息分发出去,相应的线程利用连接描述符建立与客户端的socket连接 并进行后续的存取数据操作。

Redis6.0与Memcached多线程模型对比:

  • 相同点:都采用了 master线程-worker 线程的模型
  • 不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而 Redis 把处理逻辑交还给 master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题。

8、结尾

大家都会拿Redis和memcached对比,但Redis不是memcached,它只是做到like memcached的多线程,而不是跟memcached一样的完全隔离的多线程模型。Redis中因为有lua脚本,事务,Lpush等等复杂性,需要考虑的问题很多,不管怎么样,最新版的Redis6带给我们的IO多线程着实是个惊喜,互联网大厂们应该很快就会纷纷上线此功能了!


资料来源

黑马程序员Redis入门到精通,Java企业级解决方案必看

【尚硅谷】2021 最新 Redis 6 入门到精通 超详细 教程

Redis系列

Redis6使用指导(完整版)

如何保证 Redis 缓存与数据库双写一致性?

[TOC]

Mysql 高级篇

1、索引(Index)

1、索引概述

MySQL官方对索引的定义为:索引(index)是帮助MySQL高效获取数据的数据结构(有序)。

在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用(指向)数据, 这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。

如下面的==示意图==所示:

1555902055367

相关说明:

  • 左边是数据表,一共有两列七条记录,最左边的是数据记录的物理地址(注意逻辑上相邻的记录在磁盘上也并不是一定物理相邻的)。
  • 为了加快Col2的查找,可以维护一个右边所示的二叉查找树,每个节点分别包含索引键值和一个指向对应数据记录物理地址的指针,这样就可以运用二叉查找快速获取到相应数据。

一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储在磁盘上索引是数据库中用来提高性能的最常用的工具。

2、索引优势劣势

索引的优势:

  1. 类似于书籍的目录索引,提高数据检索的效率,降低数据库的IO成本
  2. 通过索引列对数据进行排序,降低数据排序的成本,降低CPU的消耗

索引的劣势:

  1. 实际上索引也是一张表,该表中保存了主键与索引字段,并指向实体类的记录,所以索引列也是要占用空间的。
  2. 虽然索引大大提高了查询效率,同时却也降低更新表的速度,如对表进行INSERT、UPDATE、DELETE。因为更新表时,MySQL 不仅要保存数据,还要保存一下索引文件每次更新添加了索引列的字段,都会调整因为更新所带来的键值变化后的索引信息

3、索引结构

索引是在MySQL的==存储引擎层==中实现的,而不是在服务器层实现的。所以每种存储引擎的索引都不一定完全相同,也不是所有的存储引擎都支持所有的索引类型的。

MySQL目前提供了以下4种索引:

  • BTREE 索引 :最常见的索引类型,大部分索引都支持 B 树索引。
  • HASH 索引:只有Memory引擎支持 , 使用场景简单 。
  • R-tree 索引(空间索引):空间索引是MyISAM引擎的一个特殊索引类型,主要用于地理空间数据类型,通常使用较少,不做特别介绍。
  • Full-text (全文索引) :全文索引也是MyISAM的一个特殊索引类型,主要用于全文索引,InnoDB从Mysql5.6版本开始支持全文索引。
MyISAM、InnoDB、Memory三种存储引擎对各种索引类型的支持
索引 InnoDB引擎 MyISAM引擎 Memory引擎
BTREE索引 支持 支持 支持
HASH 索引 不支持 不支持 支持
R-tree 索引 不支持 支持 不支持
Full-text 5.6版本之后支持 支持 不支持
  • 我们平常所说的索引,如果没有特别指明,都是指B+树(多路搜索树,并不一定是二叉的)结构组织的索引。
  • 其中==聚集索引==、==次要索引==、==覆盖索引==、==复合索引==、==前缀索引==、==唯一索引==默认都是使用 B+tree 索引,统称为 索引。

注意:关于 InnoDB引擎 支不支持 HASH 索引问题

  • InnoDB用户无法手动创建哈希索引,这一层上说,InnoDB确实不支持哈希索引
  • InnoDB会自调优(self-tuning),如果判定建立自适应哈希索引(Adaptive Hash Index, AHI),能够提升查询效率,InnoDB自己会建立相关哈希索引,这一层上说,InnoDB又是支持哈希索引的。
1、InnoDB的自调优

那什么是自适应哈希索引(Adaptive Hash Index, AHI)呢?原理又是怎样的呢? 咱们先从一个例子开始。

不妨设有InnoDB数据表:t(id PK, name KEY, sex, flag)

id是主键,name建了普通索引。

假设表中有四条记录:

  • 1, shenjian, m, A
  • 3, zhangsan, m, A
  • 5, lisi, m, A
  • 9, wangwu, f, B

img

如上图,通过前序知识,容易知道InnoDB在主键id上会建立聚集索引(Clustered Index),叶子存储记录本身,在name上会建立普通索引(Secondary Index),叶子存储主键值。

发起主键id查询时,能够通过聚集索引,直接定位到行记录。

img

1
select * from t where name='ls'; 

发起普通索引查询时:

  • 会先从普通索引查询出主键(上图右边);
  • 再由主键,从聚集索引上二次遍历定位到记录(上图左边)。

不管聚集索引还是普通索引,记录定位的寻路路径(Search Path)都很长。

在MySQL运行的过程中,如果InnoDB发现,有很多SQL存在这类很长的寻路,并且有很多SQL会命中相同的页面(page),InnoDB会在自己的内存缓冲区(Buffer)里,开辟一块区域,建立自适应哈希所有AHI,以加速查询。

img

从这个层面上来说,InnoDB的自使用哈希索引,更像“索引的索引”,毕竟其目的是为了加速索引寻路。

既然是哈希,key是什么,value是什么?

  • ==key是索引键值(或者键值前缀)。==
  • ==value是索引记录页面位置。==

为啥叫“自适应(adaptive)”哈希索引?

系统自己判断“应该可以加速查询”而建立的,不需要用户手动建立,故称“自适应”。

系统会不会判断失误,是不是一定能加速?

不是一定能加速,有时候会误判。 当业务场景为下面几种情况时:

  • 很多单行记录查询(例如passport,用户中心等业务)
  • 索引范围查询(此时AHI可以快速定位首行记录)
  • 所有记录内存能放得下

AHI往往是有效的。

任何脱离业务的技术方案,都是耍流氓。

当业务有大量like或者join,AHI的维护反而可能成为负担,降低系统效率,此时可以手动关闭AHI功能。

2、BTREE(B树)结构

BTree又叫多路平衡搜索树,一颗m叉的BTree特性如下:

  • 树中每个节点最多包含m个孩子。
  • 除根节点与叶子节点外,每个节点至少有[ceil(m/2)]个孩子。
  • 若根节点不是叶子节点,则至少有两个孩子。
  • 所有的叶子节点都在同一层。
  • 每个非叶子节点由n个key与n+1个指针组成,其中[ceil(m/2)-1] <= n <= m-1

以5叉BTree为例,key的数量:公式推导[ceil(m/2)-1] <= n <= m-1。所以 2 <= n <=4 。当n>4时,中间节点分裂到父节点,两边节点分裂。

插入 C N G A H E K Q M F W L T Z D P R X Y S 数据为例。

演变过程如下:

  1. 插入前4个字母 C N G A

    1555944126588

  2. 插入H,n>4,中间元素G字母向上分裂到新的节点

    1555944549825

  3. 插入E,K,Q不需要分裂

    1555944596893

  4. 插入M,中间元素M字母向上分裂到父节点G

    1555944652560

  5. 插入F,W,L,T不需要分裂

    1555944686928

  6. 插入Z,中间元素T向上分裂到父节点中

    1555944713486

  7. 插入D,中间元素D向上分裂到父节点中。然后插入P,R,X,Y不需要分裂

    1555944749984

  8. 最后插入S,NPQR节点n>5,中间节点Q向上分裂,但分裂后父节点DGMT的n>5,中间节点M向上分裂

    1555944848294

到此,该BTREE树就已经构建完成了。

BTREE树 和 二叉树 相比:

  • 查询数据的效率更高, 因为对于相同的数据量来说,BTREE的层级结构比二叉树小,因此搜索速度快。
3、B+TREE(B+树)结构

B+Tree为BTree的变种,B+Tree与BTree的区别为:

  1. n叉B+Tree最多含有n个key,而BTree最多含有n-1个key。
  2. B+Tree的叶子节点保存所有的key信息,依key大小顺序排列。
  3. 所有的非叶子节点都可以看作是key的索引部分。

1555906287178

由于B+Tree只有叶子节点保存key信息,查询任何key都要从root走到叶子。所以B+Tree的查询效率更加稳定

4、MySQL中的B+Tree

MySql索引数据结构对经典的B+Tree进行了优化:在原B+Tree的基础上,增加一个指向相邻叶子节点的链表指针,就形成了带有顺序指针的B+Tree,提高区间访问的性能

MySQL中的 B+Tree 索引结构示意图:

1555906287178

5、聚簇索引与非聚簇索引(了解)
  • 聚簇索引:将数据存储与索引放到了一块,找到索引也就找到了数据
  • 非聚簇索引:将数据存储于索引分开结构,索引结构的叶子节点指向了数据的对应行,myisam通过key_buffer把索引先缓存到内存中,当需要访问数据时(通过索引访问数据),在内存中直接搜索索引,然后通过索引找到磁盘相应数据,这也就是为什么索引不在key buffer命中时,速度慢的原因

澄清一个概念:innodb中,在聚簇索引之上创建的索引称之为辅助索引,辅助索引访问数据总是需要二次查找,非聚簇索引都是辅助索引,像复合索引、前缀索引、唯一索引,辅助索引叶子节点存储的不再是行的物理位置,而是主键值。

何时使用聚簇索引与非聚簇索引

rcil81aoyd

聚簇索引具有唯一性

由于聚簇索引是将数据跟索引结构放到一块,因此一个表仅有一个聚簇索引

一个误区:把主键自动设为聚簇索引

聚簇索引默认是主键,如果表中没有定义主键,InnoDB 会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键来作为聚簇索引。InnoDB 只聚集在同一个页面中的记录。包含相邻键值的页面可能相距甚远。如果你已经设置了主键为聚簇索引,必须先删除主键,然后添加我们想要的聚簇索引,最后恢复设置主键即可

此时其他索引只能被定义为非聚簇索引。这个是最大的误区。有的主键还是无意义的自动增量字段,那样的话Clustered index对效率的帮助,完全被浪费了。

刚才说到了,聚簇索引性能最好而且具有唯一性,所以非常珍贵,必须慎重设置。一般要根据这个表最常用的SQL查询方式来进行选择,某个字段作为聚簇索引,或组合聚簇索引,这个要看实际情况。

记住我们的最终目的就是在相同结果集情况下,尽可能减少逻辑IO

结合图再仔细点看

2w157wzq2u

2q05hsflfa

  1. InnoDB使用的是聚簇索引,将主键组织到一棵B+树中,而行数据就储存在叶子节点上,若使用”where id = 14”这样的条件查找主键,则按照B+树的检索算法即可查找到对应的叶节点,之后获得行数据
  2. 对Name列进行条件搜索,则需要两个步骤第一步在辅助索引B+树中检索Name,到达其叶子节点获取对应的主键。第二步使用主键在主索引B+树种再执行一次B+树检索操作,最终到达叶子节点即可获取整行数据。(重点在于通过其他键需要建立辅助索引

MyISM使用的是非聚簇索引,非聚簇索引的两棵B+树看上去没什么不同,节点的结构完全一致只是存储的内容不同而已,主键索引B+树的节点存储了主键,辅助键索引B+树存储了辅助键。表数据存储在独立的地方,这两颗B+树的叶子节点都使用一个地址指向真正的表数据,对于表数据来说,这两个键没有任何差别。由于索引树是独立的,通过辅助键检索无需访问主键的索引树

聚簇索引的优势

看上去聚簇索引的效率明显要低于非聚簇索引,因为每次使用辅助索引检索都要经过两次B+树查找,这不是多此一举吗?聚簇索引的优势在哪?

  1. 由于行数据和叶子节点存储在一起,同一页中会有多条行数据,访问同一数据页不同行记录时,已经把页加载到了Buffer中,再次访问的时候,会在内存中完成访问,不必访问磁盘。这样主键和行数据是一起被载入内存的,找到叶子节点就可以立刻将行数据返回了,如果按照主键Id来组织数据,获得数据更快
  2. 辅助索引使用主键作为”指针”而不是使用地址值作为指针的好处是,减少了当出现行移动或者数据页分裂时辅助索引的维护工作使用主键值当作指针会让辅助索引占用更多的空间,换来的好处是InnoDB在移动行时无须更新辅助索引中的这个”指针”**。也就是说行的位置(实现中通过16K的Page来定位)会随着**数据库里数据的修改而发生变化(前面的B+树节点分裂以及Page的分裂),使用聚簇索引就可以保证不管这个主键B+树的节点如何变化,辅助索引树都不受影响
  3. 聚簇索引适合用在排序的场合,非聚簇索引不适合
  4. 取出一定范围数据的时候,使用用聚簇索引
  5. 二级索引需要两次索引查找,而不是一次才能取到数据,因为存储引擎第一次需要通过二级索引找到索引的叶子节点,从而找到数据的主键,然后在聚簇索引中用主键再次查找索引,再找到数据
  6. 可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户 ID 来聚集数据,这样只需要从磁盘读取少数的数据页就能获取某个用户的全部邮件。如果没有使用聚簇索引,则每封邮件都可能导致一次磁盘 I/O。

聚簇索引的劣势

  1. 维护索引很昂贵,特别是插入新行或者主键被更新导至要分页(page split)的时候。建议在大量插入新行后,选在负载较低的时间段,通过OPTIMIZE TABLE优化表,因为必须被移动的行数据可能造成碎片。使用独享表空间可以弱化碎片
  2. 表因为使用UUId(随机ID)作为主键,使数据存储稀疏,这就会出现聚簇索引有可能有比全表扫面更慢,

iywj5q0imm

所以建议使用int的auto_increment作为主键

td2fso5cth

主键的值是顺序的,所以 InnoDB 把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时(InnoDB 默认的最大填充因子是页大小的 15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满(二级索引页可能是不一样的)

  1. 如果主键比较大的话,那辅助索引将会变的更大,因为辅助索引的叶子存储的是主键值;过长的主键值,会导致非叶子节点占用占用更多的物理空间

为什么主键通常建议使用自增id

聚簇索引的数据的物理存放顺序与索引顺序是一致的,即:只要索引是相邻的,那么对应的数据一定也是相邻地存放在磁盘上的。如果主键不是自增id,那么可以想 象,它会干些什么,不断地调整数据的物理地址、分页,当然也有其他一些措施来减少这些操作,但却无法彻底避免。但,如果是自增的,那就简单了,它只需要一 页一页地写,索引结构相对紧凑,磁盘碎片少,效率也高。

因为MyISAM的主索引并非聚簇索引,那么他的数据的物理地址必然是凌乱的,拿到这些物理地址,按照合适的算法进行I/O读取,于是开始不停的寻道不停的旋转聚簇索引则只需一次I/O。(强烈的对比)

不过,如果涉及到大数据量的排序、全表扫描、count之类的操作的话,还是MyISAM占优势些,因为索引所占空间小,这些操作是需要在内存中完成的

mysql中聚簇索引的设定

聚簇索引默认是主键,如果表中没有定义主键,InnoDB 会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键来作为聚簇索引。InnoDB 只聚集在同一个页面中的记录。包含相邻健值的页面可能相距甚远。

6、full-text全文索引

全文索引(也称全文检索)是目前搜索引擎使用的一种关键技术。它能够利用【分词技术】等多种算法智能分析出文本文字中关键词的频率和重要性,然后按照一定的算法规则智能地筛选出我们想要的搜索结果。

1
2
3
4
5
6
7
CREATE TABLE `article` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(200) DEFAULT NULL,
`content` text,
PRIMARY KEY (`id`),
FULLTEXT KEY `title` (`title`,`content`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

不同于like方式的的查询:

1
SELECT * FROM article WHERE content LIKE ‘%查询字符串%’;

全文索引用match+against方式查询:

1
SELECT * FROM article WHERE MATCH(title,content) AGAINST (‘查询字符串’);

明显的提高查询效率。

限制:

  • mysql5.6.4以前只有Myisam支持,5.6.4版本以后innodb才支持,但是官方版本不支持中文分词,需要第三方分词插件。
  • 5.7以后官方支持中文分词。
  • 随着大数据时代的到来,关系型数据库应对全文索引的需求已力不从心,逐渐被 solr,elasticSearch等专门的搜索引擎所替代。
7、Hash索引
  • Hash索引只有Memory,NDB两种引擎支持,Memory引擎默认支持Hash索引,如果多个hash值相同,出现哈希碰撞,那么索引以链表方式存储。
  • NoSql采用此索引结构。
8、R-Tree索引
  • R-Tree在mysql很少使用,仅支持geometry数据类型,支持该类型的存储引擎只有myisam、bdb、innodb、ndb、archive几种。
  • 相对于b-tree,r-tree的优势在于==范围查找==。

4、索引分类

  1. 单值索引 :即一个索引只包含单个列,一个表可以有多个单列索引

    image-20210901011454316

  2. 唯一索引 :索引列的值必须唯一,但允许有空值

    image-20210901011537465

  3. 复合索引 :即一个索引包含多个列

    image-20210901011618614

  4. 主键索引:设定为主键后数据库会自动建立索引,innodb为聚簇索引

    • 随表一起建索引:

      1
      2
      3
      CREATE TABLE customer (id INT(10) UNSIGNED  AUTO_INCREMENT ,customer_no VARCHAR(200),customer_name VARCHAR(200),
      PRIMARY KEY(id)
      );

      使用 AUTO_INCREMENT 关键字的列必须有索引(只要有索引就行)。

    • 单独建主键索引:

      1
      ALTER TABLE customer add PRIMARY KEY customer(customer_no);  
    • 删除建主键索引:

      1
      ALTER TABLE customer drop PRIMARY KEY ;  
    • 修改建主键索引:必须先删除掉(drop)原索引,再新建(add)索引

5、索引语法

索引在创建表的时候,可以同时创建, 也可以随时增加新的索引。

准备环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 创建数据库
create database demo_01 default charset=utf8mb4;

use demo_01;

-- 创建city表
CREATE TABLE `city` (
`city_id` int(11) NOT NULL AUTO_INCREMENT,
`city_name` varchar(50) NOT NULL,
`country_id` int(11) NOT NULL,
PRIMARY KEY (`city_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 创建country表
CREATE TABLE `country` (
`country_id` int(11) NOT NULL AUTO_INCREMENT,
`country_name` varchar(100) NOT NULL,
PRIMARY KEY (`country_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 插入数据
insert into `city` (`city_id`, `city_name`, `country_id`) values(1,'西安',1);
insert into `city` (`city_id`, `city_name`, `country_id`) values(2,'NewYork',2);
insert into `city` (`city_id`, `city_name`, `country_id`) values(3,'北京',1);
insert into `city` (`city_id`, `city_name`, `country_id`) values(4,'上海',1);

-- 插入数据
insert into `country` (`country_id`, `country_name`) values(1,'China');
insert into `country` (`country_id`, `country_name`) values(2,'America');
insert into `country` (`country_id`, `country_name`) values(3,'Japan');
insert into `country` (`country_id`, `country_name`) values(4,'UK');
1、创建索引

语法:

1
2
3
4
5
CREATE 	[UNIQUE|FULLTEXT|SPATIAL]  INDEX index_name 
[USING index_type]
ON tbl_name(index_col_name,...)

index_col_name : column_name[(length)][ASC | DESC]

示例 : 为city表中的city_name字段创建索引 ;

1551438009843

2、查看索引

语法:

1
show index from table_name;

示例:查看city表中的索引信息(其中加上\G可以将查询到的数据以row的方式展示,方便查看)

1551440511890

1551440544483

3、删除索引

语法 :

1
DROP INDEX index_name ON tbl_name;

示例 : 想要删除city表上的索引idx_city_name,可以操作如下:

1551438238293

4、ALTER命令(添加索引)
1
2
3
4
5
6
7
8
9
10
11
-- 该语句添加一个主键,这意味着索引值必须是唯一的,且不能为NULL
alter table tb_name add primary key(column_list);

-- 这条语句创建索引的值必须是唯一的(除了NULL外,NULL可能会出现多次)
alter table tb_name add unique index_name(column_list);

-- 添加普通索引, 索引值可以出现多次。
alter table tb_name add index index_name(column_list);

-- 该语句指定了索引为FULLTEXT, 用于全文索引
alter table tb_name add fulltext index_name(column_list);

6、索引设计原则

索引的设计可以遵循一些已有的原则,创建索引的时候请尽量考虑符合这些原则,便于提升索引的使用效率,更高效的使用索引。

  • 主键自动建立唯一索引

  • 查询频次较高,且数据量比较大的表建立索引。

  • 索引字段的选择,最佳候选列应当从where子句的条件中提取,如果where子句中的组合比较多,那么应当挑选最常用、过滤效果最好的列的组合。

  • 使用唯一索引,区分度越高,使用索引的效率越高。

  • 查询中与其它表关联的字段,外键关系建立索引

  • 查询中排序的字段,排序字段若通过索引去访问将大大提高排序速度

    • group by 和 order by 后面的字段有索引大大提高效率
  • 查询中统计或者分组字段(分组包含着排序,因为在分组之前会先进行排序(当然也可以设置不排序))

  • 索引可以有效的提升查询数据的效率,但索引数量不是多多益善,索引越多,维护索引的代价自然也就水涨船高。对于插入、更新、删除等DML操作比较频繁的表来说,索引过多,会引入相当高的维护代价,降低DML操作的效率,增加相应操作的时间消耗。另外索引过多的话,MySQL也会犯选择困难病,虽然最终仍然会找到一个可用的索引,但无疑提高了选择的代价

  • 使用短索引,索引创建之后也是使用硬盘来存储的,因此提升索引访问的I/O效率,也可以提升总体的访问效率。假如构成索引的字段总长度比较短,那么在给定大小的存储块内可以存储更多的索引值,相应的可以有效的提升MySQL访问索引的I/O效率。

  • 在高并发下倾向创建组合索引

  • 利用最左前缀,N个列组合而成的组合索引,那么相当于是创建了N个索引,如果查询时where子句中使用了组成该索引的前几个字段,那么这条查询SQL可以利用组合索引来提升查询效率。

    • -- 创建复合索引
      CREATE INDEX idx_name_email_status ON tb_seller(NAME,email,STATUS);
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53

      - 就相当于:

      - 对name 创建索引 ;
      - 对name , email 创建了索引 ;
      - 对name , email, status 创建了索引 ;

      **哪些情况不要创建索引:**

      - 表记录太少(没必要)
      - 经常增删改的表
      - 提高了查询速度,同时却会降低更新表的速度,如对表进行INSERT、UPDATE和DELETE。
      - 因为更新表时,MySQL不仅要保存数据,还要保存一下索引文件
      - Where条件里用不到的字段不创建索引,索引建多了影响 增删改 的效率
      - 数据重复且分布平均的表字段,因此应该**只为最经常查询和最经常排序的数据列建立索引**。
      - **注意,如果某个数据列包含许多重复的内容,为它建立索引就没有太大的实际效果。**
      - 假如一个表有10万行记录,有一个字段A只有T和F两种值,且每一个值的分布概率大约为50%,那么对这种表A字段建索引一般不会提高数据库的程序速度
      - 索引的选择性是指索引列中不同值的数目与表中记录数的比。即:如果一个表中有2000条记录,表索引列有1980个不同的值,那么这个索引的选择性就是1980/2000 = 0.99。**一个索引的选择性越接近于1,这个索引的效率就越高。**

      -----





      ### 2、视图(View)

      #### 1、视图概述

      **视图(View)是一种虚拟存在的表**。**视图并不在数据库中实际存在,行和列数据来自定义视图的==查询==中使用的表,并且是在使用视图时动态生成的。**

      通俗的讲,**视图就是一条SELECT语句执行后返回的结果集**。所以我们在创建视图的时候,主要的工作就落在创建这条SQL查询语句上。

      视图相对于普通的表的优势主要包括以下几项:

      - **简单**:**使用视图的用户完全不需要关心后面对应的表的结构、关联条件和筛选条件**,对用户来说已经是过滤好的复合条件的结果集。
      - **安全**:**使用视图的用户只能访问他们被允许查询的结果集**,对表的权限管理并不能限制到某个行某个列,但是通过视图就可以简单的实现。
      - **数据独立**:一旦视图的结构确定了,可以**屏蔽表结构变化对用户的影响,源表增加列对视图没有影响;源表修改列名,则可以通过修改视图来解决,不会造成对访问者的影响**。



      #### 2、创建或者修改视图

      创建视图的语法为:

      ```sql
      CREATE [OR REPLACE] [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]

      VIEW view_name [(column_list)]

      AS select_statement

      [WITH [CASCADED | LOCAL] CHECK OPTION]

修改视图的语法为:

1
2
3
4
5
6
7
ALTER [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]

VIEW view_name [(column_list)]

AS select_statement

[WITH [CASCADED | LOCAL] CHECK OPTION]

其中:选项WITH [CASCADED | LOCAL] CHECK OPTION 决定了是否允许更新数据使记录不再满足视图的条件

  • LOCAL : 只要满足本视图的条件就可以更新。
  • CASCADED : 必须满足所有针对该视图的所有视图的条件才可以更新。 默认值

示例:创建city_country_view视图,执行如下SQL:

1
2
3
create or replace view city_country_view 
as
select t.*,c.country_name from country c , city t where c.country_id = t.country_id;

由于视图是一种虚拟的表,所以可以使用操作表的SQL语句对视图进行查询:

查询视图 :

1551503428635

当然,由于视图是一种虚拟的表,所以也可以对视图进行修改操作:

1
update city_country_view set city_name = 'GuangDong' where city_id = 1; 

此时,修改操作不仅会修改视图上的表的数据,而且也会同步修改底层的表中的数据。

注意:一般不要在视图上进行修改,视图是用来简化我们的==查询==操作,方便我们进行数据查询的。

3、 查看视图

从 MySQL 5.1 版本开始,使用 SHOW TABLES 命令的时候不仅显示表的名字,同时也会显示视图的名字,而不存在单独显示视图的 SHOW VIEWS 命令。

1551537565159

同样,在使用 SHOW TABLE STATUS 命令的时候,不但可以显示表的信息,同时也可以显示视图的信息。

1551537646323

如果需要查询某个视图的定义,可以使用 SHOW CREATE VIEW 命令进行查看:

1551588962944

4、删除视图

语法 :

1
DROP VIEW [IF EXISTS] view_name [, view_name] ...[RESTRICT | CASCADE]	

示例,删除视图city_country_view :

1
DROP VIEW city_country_view ;

3、存储过程(Procedure)和函数(Function)

1、存储过程和函数概述

存储过程和函数是:事先经过编译并存储在数据库中的一段 SQL 语句的集合,调用存储过程和函数可以简化应用开发人员的很多工作,减少数据在数据库和应用服务器之间的传输,对于提高数据处理的效率是有好处的。

存储过程和函数的区别在于函数必须有返回值,而存储过程没有:

  • 函数(function): 是一个有返回值的过程 ;
  • 过程(procedure): 是一个没有返回值的函数 ;

其实存储过程与存储函数的作用并没有太大的区别:

  • 存储函数可以获取返回值
  • 存储过程可以通过OUT也能获取返回值

2、存储过程

1、创建存储过程
1
2
3
4
CREATE PROCEDURE procedure_name ([proc_parameter[,...]])
begin
-- SQL语句
end ;

示例:

1
2
3
4
5
6
7
8
delimiter $

create procedure pro_test1()
begin
select 'Hello Mysql' ;
end$

delimiter ;

知识小贴士

DELIMITER

  • 该关键字用来声明SQL语句的分隔符,告诉 MySQL 解释器,该段命令是否已经结束了,mysql是否可以执行了。
  • 默认情况下,delimiter是分号;
  • 在命令行客户端中,如果有一行命令以;结束,那么回车后,mysql将会执行该命令。
  • 如果要创建存储过程的话,在定义里面的sql语句时,使用;会让mysql执行命令,而此时的命令是不完全的,会报错。此时使用DELIMITER将分隔符修改为其他符号,等到创建存储过程之后,在将分隔符改回;就行。
2、调用存储过程
1
call procedure_name() ;	
3、查看存储过程
1
2
3
4
5
6
7
8
-- 查询db_name数据库中的所有的存储过程
select name from mysql.proc where db='db_name';

-- 查询存储过程的状态信息
show procedure status;

-- 查询某个存储过程的定义
show create procedure test.pro_test1 \G;
4、删除存储过程
1
DROP PROCEDURE  [IF EXISTS] sp_name;
5、在存储过程的sql当中的语法

存储过程是可以编程的,意味着可以使用变量表达式控制结构 , 来完成比较复杂的功能。

1、变量
  • DECLARE

    • 通过 DECLARE 可以定义一个局部变量,该变量的作用范围只能在 BEGIN…END 块中。

    • DECLARE var_name[,...] type [DEFAULT value]
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15

      - 示例:

      - ```sql
      delimiter $

      create procedure pro_test2()
      begin
      declare num int default 5;
      select num+ 10;
      -- concat('xxx','ooo') 把里面的东西连接成一个字符串
      -- select concat('num的值为:',num);
      end$

      delimiter;
  • SET

    • 直接赋值使用 SET,可以赋常量或者赋表达式,具体语法如下:

    • SET var_name = expr [, var_name = expr] ...
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14

      - 示例:

      - ```sql
      DELIMITER $

      CREATE PROCEDURE pro_test3()
      BEGIN
      DECLARE NAME VARCHAR(20);
      SET NAME = 'MYSQL';
      SELECT NAME ;
      END$

      DELIMITER ;
    • 也可以通过select … into 方式进行赋值操作:

    • DELIMITER $
      
      CREATE  PROCEDURE pro_test5()
      BEGIN
          declare  countnum int;
          select count(*) into countnum from city;
          select concat('city表当中的记录数为:',num);
      END$
      
      DELIMITER ;
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      ###### 2、if条件判断

      语法结构:

      ```sql
      if search_condition then statement_list

      [elseif search_condition then statement_list] ...

      [else statement_list]

      end if;

需求:根据定义的身高变量,判定当前身高的所属的身材类型

  • 180 及以上:身材高挑
  • 170 - 180:标准身材
  • 170 以下:一般身材

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
delimiter $

create procedure pro_test6()
begin
declare height int default 175;
declare description varchar(50);

if height >= 180 then
set description = '身材高挑';
elseif height >= 170 and height < 180 then
set description = '标准身材';
else
set description = '一般身材';
end if;

select description ;
-- select concat('身高:',height,'对应的身材类型:',description)
end$

delimiter ;

调用结果为:

1552057035580

3、传递参数

语法格式:

1
create procedure procedure_name([in/out/inout] 参数名   参数类型)
  • IN该参数可以作为输入,也就是需要调用方传入值,默认
  • OUT该参数作为输出,也就是该参数可以作为返回值
  • INOUT既可以作为输入参数,也可以作为输出参数

IN - 输入

需求:根据定义的身高变量,判定当前身高的所属的身材类型

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
delimiter $

create procedure pro_test5(in height int)
begin
declare description varchar(50) default '';
if height >= 180 then
set description='身材高挑';
elseif height >= 170 and height < 180 then
set description='标准身材';
else
set description='一般身材';
end if;
select concat('身高 ', height , '对应的身材类型为:',description);
end$

delimiter ;

OUT-输出

需求:根据传入的身高变量,获取当前身高的所属的身材类型

示例:

1
2
3
4
5
6
7
8
9
10
create procedure pro_test5(in height int , out description varchar(100))
begin
if height >= 180 then
set description='身材高挑';
elseif height >= 170 and height < 180 then
set description='标准身材';
else
set description='一般身材';
end if;
end$

调用:

1
2
3
call pro_test5(168, @description)$

select @description$

小知识 

  • @description:这种变量要在变量名称前面加上“@”符号,叫做用户会话变量,代表整个会话过程他都是有作用的,这个类似于全局变量一样
  • @@global.sort_buffer_size:这种在变量前加上 “@@” 符号,叫做 系统变量
4、case结构

语法结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 方式一
CASE case_value

WHEN when_value THEN statement_list

[WHEN when_value THEN statement_list] ...

[ELSE statement_list]

END CASE;


-- 方式二

CASE

WHEN search_condition THEN statement_list

[WHEN search_condition THEN statement_list] ...

[ELSE statement_list]

END CASE;

需求:给定一个月份,然后计算出所在的季度

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
delimiter $

create procedure pro_test9(month int)
begin
declare result varchar(20);
case
when month >= 1 and month <=3 then
set result = '第一季度';
when month >= 4 and month <=6 then
set result = '第二季度';
when month >= 7 and month <=9 then
set result = '第三季度';
when month >= 10 and month <=12 then
set result = '第四季度';
end case;

select concat('您输入的月份为 :', month , ' , 该月份为 : ' , result) as content ;

end$

delimiter ;
5、while循环

语法结构:

1
2
3
4
5
while search_condition do

statement_list

end while;

需求:计算从1加到n的值

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
delimiter $

create procedure pro_test8(n int)
begin
declare total int default 0;
declare num int default 1;
while num<=n do
set total = total + num;
set num = num + 1;
end while;
select total;
end$

delimiter ;
6、repeat结构

有条件的循环控制语句,当满足条件的时候退出循环。

while 是满足条件才执行,repeat 是满足条件就退出循环。

语法结构:

1
2
3
4
5
6
7
REPEAT

statement_list

UNTIL search_condition

END REPEAT;

需求:计算从1加到n的值

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
delimiter $

create procedure pro_test10(n int)
begin
declare total int default 0;

repeat
set total = total + n;
set n = n - 1;
until n=0
end repeat;

select total ;

end$


delimiter ;

注意:until后面的语句不要加上 ; ,否则语法报错。

7、loop语句

LOOP 实现简单的循环,退出循环的条件需要使用其他的语句定义,通常可以使用 LEAVE 语句实现,具体语法如下:

1
2
3
4
5
[begin_label:] LOOP

statement_list

END LOOP [end_label]

如果不在 statement_list 中增加退出循环的语句,那么 LOOP 语句可以用来实现简单的死循环。

8、leave语句

用来从标注的流程构造中退出,通常和 BEGIN … END 或者循环一起使用。下面是一个使用 LOOP 和 LEAVE 的简单例子,退出循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
delimiter $

CREATE PROCEDURE pro_test11(n int)
BEGIN
declare total int default 0;

ins: LOOP

IF n <= 0 then
leave ins;
END IF;

set total = total + n;
set n = n - 1;

END LOOP ins;

select total;
END$

delimiter ;
9、游标/光标(Cursor)

游标是用来存储查询结果集的数据类型,在存储过程和函数中可以使用光标对结果集进行循环的处理。

光标的使用包括光标的声明、OPEN、FETCH 和 CLOSE,其语法分别如下:

声明光标:

1
DECLARE cursor_name CURSOR FOR select_statement ;

OPEN 光标:

1
OPEN cursor_name ;

FETCH 光标:(每fetch一次,指针往下走一个)

1
FETCH cursor_name INTO var_name [, var_name] ...

CLOSE 光标:

1
CLOSE cursor_name ;

示例:

初始化脚本:

1
2
3
4
5
6
7
8
9
10
create table emp(
id int(11) not null auto_increment ,
name varchar(50) not null comment '姓名',
age int(11) comment '年龄',
salary int(11) comment '薪水',
primary key(`id`)
)engine=innodb default charset=utf8 ;

insert into emp(id,name,age,salary) values(null,'金毛狮王',55,3800),(null,'白眉鹰王',60,4000),(null,'青翼蝠王',38,2800),(null,'紫衫龙王',42,1800);

查询emp表中数据,并逐行获取进行展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
create procedure pro_test11()
begin
declare e_id int(11);
declare e_name varchar(50);
declare e_age int(11);
declare e_salary int(11);
declare emp_result cursor for select * from emp;

open emp_result;

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

close emp_result;
end$

结果:前四条数据被成功fetch出来展示,最后一次fetch由于表中已经没有数据,所以会报错:

1
ERROR 1329 (020000): No data - zero rows fetched ,selected, or processed

通过循环结构,获取游标中的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
DELIMITER $

create procedure pro_test12()
begin
DECLARE id int(11);
DECLARE name varchar(50);
DECLARE age int(11);
DECLARE salary int(11);
DECLARE has_data int default 1;

DECLARE emp_result CURSOR FOR select * from emp;
-- 这里的条件声明必须放在游标声明之后,否则报错
DECLARE EXIT HANDLER FOR NOT FOUND set has_data = 0;

open emp_result;

repeat
fetch emp_result into id , name , age , salary;
select concat('id为',id, ', name 为' ,name , ', age为 ' ,age , ', 薪水为: ', salary);
until has_data = 0
end repeat;

close emp_result;
end$

DELIMITER ;

注意:这里的条件声明必须放在游标声明之后,否则报错

3、存储函数

语法结构:

1
2
3
4
5
CREATE FUNCTION function_name([param type ... ]) 
RETURNS type
BEGIN
-- SQL语句
END;

案例:定义一个存储函数,请求满足条件的总记录数

1
2
3
4
5
6
7
8
9
10
11
12
13
delimiter $

create function count_city(countryId int)
returns int
begin
declare cnum int ;

select count(*) into cnum from city where country_id = countryId;

return cnum;
end$

delimiter ;

调用:

1
2
3
select count_city(1);

select count_city(2);

4、触发器(Trigger)

1、介绍

触发器是与表有关的数据库对象,指在 insert/update/delete 之前或之后,触发并执行触发器中定义的SQL语句集合

触发器的这种特性可以协助应用在数据库端确保数据的完整性,日志记录,数据校验等操作 。

使用别名 OLDNEW 来引用触发器中发生变化的记录内容,这与其他的数据库是相似的。

  • mysql触发器还只支持行级触发器,不支持语句级触发器。
  • Oracle数据库既支持行级触发器,又支持语句级触发器。
触发器类型 NEW 和 OLD 的使用
INSERT 型触发器 NEW 表示将要或者已经新增的数据
UPDATE 型触发器 OLD 表示修改之前的数据,NEW 表示将要或已经修改后的数据
DELETE 型触发器 OLD 表示将要或者已经删除的数据

2、创建触发器

语法结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
create trigger trigger_name 

before/after insert/update/delete

on tbl_name

[ for each row ] -- 行级触发器

begin

trigger_stmt ;

end;

示例:

需求:通过触发器记录 emp 表的数据变更日志,包含增加、修改、删除

首先创建一张日志表:

1
2
3
4
5
6
7
8
create table emp_logs(
id int(11) not null auto_increment,
operation varchar(20) not null comment '操作类型, insert/update/delete',
operate_time datetime not null comment '操作时间',
operate_id int(11) not null comment '操作表的ID',
operate_params varchar(500) comment '操作参数',
primary key(`id`)
)engine=innodb default charset=utf8;

创建 insert 型触发器,完成插入数据时的日志记录:

1
2
3
4
5
6
7
8
9
10
11
DELIMITER $

create trigger emp_logs_insert_trigger
after insert
on emp
for each row
begin
insert into emp_logs (id,operation,operate_time,operate_id,operate_params) values(null,'insert',now(),new.id,concat('插入后(id:',new.id,', name:',new.name,', age:',new.age,', salary:',new.salary,')'));
end $

DELIMITER ;

创建 update 型触发器,完成更新数据时的日志记录:

1
2
3
4
5
6
7
8
9
10
11
DELIMITER $

create trigger emp_logs_update_trigger
after update
on emp
for each row
begin
insert into emp_logs (id,operation,operate_time,operate_id,operate_params) values(null,'update',now(),new.id,concat('修改前(id:',old.id,', name:',old.name,', age:',old.age,', salary:',old.salary,') , 修改后(id',new.id, 'name:',new.name,', age:',new.age,', salary:',new.salary,')'));
end $

DELIMITER ;

创建delete 行的触发器 , 完成删除数据时的日志记录:

1
2
3
4
5
6
7
8
9
10
11
DELIMITER $

create trigger emp_logs_delete_trigger
after delete
on emp
for each row
begin
insert into emp_logs (id,operation,operate_time,operate_id,operate_params) values(null,'delete',now(),old.id,concat('删除前(id:',old.id,', name:',old.name,', age:',old.age,', salary:',old.salary,')'));
end $

DELIMITER ;

测试:

1
2
3
4
5
6
7
8
insert into emp(id,name,age,salary) values(null, '光明左使',30,3500);
insert into emp(id,name,age,salary) values(null, '光明右使',33,3200);

update emp set age = 39 where id = 3;

delete from emp where id = 5;

select * from emp_logs;

3、删除触发器

语法结构:

1
drop trigger [schema_name.]trigger_name

如果没有指定 schema_name,默认为当前数据库 。

4、查看触发器

可以通过执行 SHOW TRIGGERS 命令查看触发器的状态、语法等信息。

语法结构:

1
show triggers

5、Mysql的体系结构概览

171214401286615

整个MySQL Server由以下组成

  • Connection Pool:连接池组件
  • Management Services & Utilities:管理服务和工具组件
  • SQL Interface:SQL接口组件
  • Parser:查询分析器组件
  • Optimizer:优化器组件
  • Caches & Buffers:缓冲池组件
  • Pluggable Storage Engines:存储引擎
  • File System:文件系统

整个MySQL Server 从上往下可以分为以下四层:

  1. 连接层
    • 最上层是一些客户端和链接服务,包含本地sock 通信和大多数基于客户端/服务端工具实现的类似于 TCP/IP的通信。
    • 主要完成一些类似于连接处理、授权认证、及相关的安全方案。
    • 在该层上引入了线程池的概念,为通过认证安全接入的客户端提供线程。
    • 同样在该层上可以实现基于SSL的安全链接。
    • 服务器也会为安全接入的每个客户端验证它所具有的操作权限。
  2. 服务层
    • 第二层架构主要完成大多数的核心服务功能,如SQL接口,并完成缓存的查询,SQL的分析和优化,部分内置函数的执行。
    • 所有跨存储引擎的功能也在这一层实现,如 过程、函数等。
    • 在该层,服务器会解析查询并创建相应的内部解析树,并对其完成相应的优化如确定表的查询的顺序,是否利用索引等, 最后生成相应的执行操作。
    • 如果是select语句,服务器还会查询内部的缓存,如果缓存空间足够大,这样在解决大量读操作的环境中能够很好的提升系统的性能。
  3. 引擎层
    • 存储引擎层, 存储引擎真正的负责了MySQL中数据的存储和提取,服务器通过API和存储引擎进行通信。
    • 不同的存储引擎具有不同的功能,这样我们可以根据自己的需要,来选取合适的存储引擎。
  4. 存储层
    • 数据存储层, 主要是将数据存储在文件系统之上,并完成与存储引擎的交互。

和其他数据库相比,MySQL有点与众不同,它的架构可以在多种不同场景中应用并发挥良好作用。主要体现在存储引擎上,插件式的存储引擎架构,将查询处理和其他的系统任务以及数据的存储提取分离。这种架构可以根据业务的需求和实际需要选择合适的存储引擎。

查询流程图:

img

首先,mysql的查询流程大致是:

  • mysql客户端通过协议与mysql服务器建连接,发送查询语句,先检查查询缓存,如果命中(一模一样的sql才能命中),直接返回结果,否则进行语句解析。也就是说,在解析查询之前,服务器会先访问查询缓存(query cache)——它存储SELECT语句以及相应的查询结果集。如果某个查询结果已经位于缓存中,服务器就不会再对查询进行解析、优化、以及执行。它仅仅将缓存中的结果返回给用户即可,这将大大提高系统的性能。

  • 语法解析器和预处理:首先mysql通过关键字将SQL语句进行解析,并生成一颗对应的“解析树”。mysql解析器将使用mysql语法规则验证和解析查询;预处理器则根据一些mysql规则进一步检查解析数是否合法。

  • 查询优化器当解析树被认为是合法的了,并且由优化器将其转化成执行计划。一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。

  • 然后,mysql默认使用的BTREE索引,并且一个大致方向是:无论怎么折腾sql,至少在目前来说,mysql最多只用到表中的一个索引。


6、存储引擎

1、存储引擎概述

  • 和大多数的数据库不同,MySQL中有一个存储引擎的概念,针对不同的存储需求可以选择最优的存储引擎。

  • 存储引擎就是存储数据,建立索引,更新查询数据等等技术的实现方式

  • 存储引擎是基于表的,而不是基于库的。所以存储引擎也可被称为表类型。所以数据库的每一张表都可以使用不同的存储引擎。

  • Oracle,SqlServer等数据库只有一种存储引擎。MySQL提供了插件式的存储引擎架构。所以MySQL存在多种存储引擎,可以根据需要使用相应引擎,或者编写存储引擎

  • MySQL5.0支持的存储引擎包含 : InnoDBMyISAMBDBMEMORYMERGEEXAMPLENDB ClusterARCHIVECSVBLACKHOLEFEDERATED等,其中InnoDB和BDB提供事务安全表,其他存储引擎是非事务安全表。

可以通过指定 show engines , 来查询当前数据库支持的存储引擎:

1551186043529

创建新表时如果不指定存储引擎,那么系统就会使用默认的存储引擎,MySQL5.5之前的默认存储引擎是MyISAM,5.5之后就改为了InnoDB。

查看Mysql数据库默认的存储引擎,指令:

1
show variables like '%storage_engine%';

1556086372754

2、各种存储引擎特性

下面重点介绍几种常用的存储引擎, 并对比各个存储引擎之间的区别, 如下表所示 :

特点 InnoDB MyISAM MEMORY MERGE NDB
存储限制 64TB 没有
事务安全 ==支持==
锁机制 ==行锁(适合高并发)== ==表锁== 表锁 表锁 行锁
B树索引 支持 支持 支持 支持 支持
哈希索引 支持
全文索引 支持(5.6版本之后) 支持
集群索引 支持
数据索引 支持 支持 支持
索引缓存 支持 支持 支持 支持 支持
数据可压缩 支持
空间使用 N/A
内存使用 中等
批量插入速度
支持外键 ==支持==(唯一支持外键的存储引擎)

下面我们将重点介绍最长使用的两种存储引擎: ==InnoDB==、==MyISAM== , 另外两种 MEMORY、MERGE , 了解即可。

1、InnoDB

InnoDB存储引擎是Mysql的默认存储引擎。InnoDB存储引擎提供了具有提交、回滚、崩溃恢复能力的事务安全。但是对比MyISAM的存储引擎,InnoDB写的处理效率差一些,并且会占用更多的磁盘空间以保留数据和索引。

InnoDB存储引擎不同于其他存储引擎的特点:

  • 事务控制

    • create table goods_innodb(
          id int NOT NULL AUTO_INCREMENT,
          name varchar(20) NOT NULL,
          primary key(id)
      )ENGINE=innodb DEFAULT CHARSET=utf8;
      
      1
      2
      3
      4
      5
      6
      7

      - ```sql
      start transaction;

      insert into goods_innodb(id,name)values(null,'Meta20');

      commit;
    • 1556075130115

    • 测试,发现在InnoDB中是存在事务的

  • 外键约束

    • MySQL支持外键的存储引擎只有InnoDB,在创建外键的时候,要求父表必须有对应的索引,子表在创建外键的时候,也会自动的创建对应的索引。

    • 下面两张表中 , country_innodb是父表 , country_id为主键索引,city_innodb表是子表,country_id字段为外键,对应于country_innodb表的主键country_id:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      create table country_innodb(
      country_id int NOT NULL AUTO_INCREMENT,
      country_name varchar(100) NOT NULL,
      primary key(country_id)
      )ENGINE=InnoDB DEFAULT CHARSET=utf8;

      create table city_innodb(
      city_id int NOT NULL AUTO_INCREMENT,
      city_name varchar(50) NOT NULL,
      country_id int NOT NULL,
      primary key(city_id),
      key idx_fk_country_id(country_id),
      CONSTRAINT `fk_city_country` FOREIGN KEY(country_id) REFERENCES country_innodb(country_id) ON DELETE RESTRICT ON UPDATE CASCADE
      )ENGINE=InnoDB DEFAULT CHARSET=utf8;

      insert into country_innodb values(null,'China'),(null,'America'),(null,'Japan');
      insert into city_innodb values(null,'Xian',1),(null,'NewYork',2),(null,'BeiJing',1);
    • 在创建索引时, 可以指定在删除、更新父表时,对子表进行的相应操作,包括:

      • RESTRICT
      • CASCADE
      • SET NULL
      • NO ACTION
    • RESTRICT和NO ACTION相同, 是指限制在子表有关联记录的情况下, 父表不能更新

      • 如 DELETE RESTRICT :表示在删除父表当中数据的时候,如果该数据有外键关联着子表的数据的话,则删除失败。
    • CASCADE 表示父表在更新或者删除时,更新或者删除子表对应的记录;

      • 如 UPDATE CASCADE : 表示在更新父表数据的时候,如果该数据有外键关联着子表的数据的话,则子表的数据也会跟着一起更新。
    • SET NULL 则表示父表在更新或者删除的时候,子表的对应字段被SET NULL

      • 如 DELETE SET NULL : 表示在删除数据的时候,如果该数据有外键关联着子表的数据的话,则子表对应的数据会被设置为null
    • 针对上面创建的两个表, 子表的外键指定是ON DELETE RESTRICT ON UPDATE CASCADE 方式的:

      • ON DELETE RESTRICT:那么在主表删除记录的时候, 如果子表有对应记录, 则不允许删除
      • ON UPDATE CASCADE:主表在更新记录的时候, 如果子表有对应记录, 则子表对应更新
    • 表中数据如下图所示:

      1556087540767

    • 外键信息可以使用如下两种方式查看:

      1
      show create table city_innodb ;

      1556087611295

    • 删除country_id为1 的country数据:(在主表删除记录的时候, 如果子表有对应记录, 则不允许删除)

      1
      delete from country_innodb where country_id = 1;

      1556087719145

    • 更新主表country表的字段 country_id:

      1
      update country_innodb set country_id = 100 where country_id = 1;

      1556087759615

    • 更新后, 子表的数据信息为:(主表在更新记录的时候, 如果子表有对应记录, 则子表对应更新)

      1556087793738

  • 存储方式

    • 在Linux环境下,数据库表的数据信息默认存储在 var/lib/mysql 下

    • InnoDB 存储表和索引有以下两种方式:

      1. 使用共享表空间存储, 这种方式创建的表的表结构保存在==.frm文件==中, 数据和索引保存在 ==innodb_data_home_dir== 和 ==innodb_data_file_path== 定义的表空间中,可以是多个文件。

      2. 使用多表空间存储, 这种方式创建的表的表结构仍然存在 ==.frm 文件==中,但是每个表的数据和索引单独保存在 ==.ibd== 中。

        1556075336630

2、MyISAM

MyISAM 不支持事务、也不支持外键,其优势是访问的速度快对事务的完整性没有要求或者以SELECT、INSERT为主的应用基本上都可以使用这个引擎来创建表

MyISAM有以下两个比较重要的特点:

  • 不支持事务

    1
    2
    3
    4
    5
    create table goods_myisam(
    id int NOT NULL AUTO_INCREMENT,
    name varchar(20) NOT NULL,
    primary key(id)
    )ENGINE=myisam DEFAULT CHARSET=utf8;
    • image-20210901130955510
    • 通过测试,我们发现,就算mysql显示的好像开启了事务,但是在MyISAM存储引擎中,是没有事务控制的,因此就算 start transaction 之后,执行sql在commit之前还是可以执行到mysql数据库当中的。
  • 文件存储方式

    • 每个MyISAM在磁盘上存储成3个文件,其文件名都和表名相同,但拓展名分别是:

      • .frm (存储表定义)
      • .MYD(MYData , 存储数据)
      • .MYI(MYIndex , 存储索引)

      1556075073836

3、MEMORY
  • Memory存储引擎将表的数据存放在内存中。
  • 每个MEMORY表实际对应一个磁盘文件,格式是==.frm== ,该文件中只存储表的结构,而其数据文件,都是存储在内存中,这样有利于数据的快速处理,提高整个表的效率
  • 优点:MEMORY 类型的表访问非常地快,因为他的数据是存放在内存中的,并且默认使用HASH索引
  • 缺点:
    • 但是对于内存来说,存储空间是很宝贵的,因此MEMORY 类型的表存储的数据量不能很大
    • 而且服务一旦关闭,表中的数据就会丢失
4、MERGE

MERGE存储引擎是一组MyISAM表的组合,这些MyISAM表必须结构完全相同,MERGE表本身并没有存储数据,对MERGE类型的表可以进行查询、更新、删除操作,这些操作实际上是对内部的MyISAM表进行的。类似于视图(View)

对于MERGE类型表的插入操作,是通过INSERT_METHOD子句定义插入的表,可以有3个不同的值:

  1. 使用FIRST 或 LAST 值使得插入操作被相应地作用在第一或者最后一个表上
  2. 不定义这个子句或者定义为NO,表示不能对这个MERGE表执行插入操作。

可以对MERGE表进行DROP操作,但是这个操作只是删除MERGE表的定义,对内部的表是没有任何影响的。

1556076359503

下面是一个创建和使用MERGE表的示例:

  1. 创建3个测试表 order_1990,order_1991,order_all,其中order_all是前两个表的MERGE表:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    create table order_1990(
    order_id int ,
    order_money double(10,2),
    order_address varchar(50),
    primary key (order_id)
    )engine = myisam default charset=utf8;

    create table order_1991(
    order_id int ,
    order_money double(10,2),
    order_address varchar(50),
    primary key (order_id)
    )engine = myisam default charset=utf8;

    create table order_all(
    order_id int ,
    order_money double(10,2),
    order_address varchar(50),
    primary key (order_id)
    )engine = merge union = (order_1990,order_1991) INSERT_METHOD=LAST default charset=utf8;
  2. 分别向两张表中插入记录

    1
    2
    3
    4
    5
    insert into order_1990 values(1,100.0,'北京');
    insert into order_1990 values(2,100.0,'上海');

    insert into order_1991 values(10,200.0,'北京');
    insert into order_1991 values(11,200.0,'上海');
  3. 查询3张表中的数据:

    • order_1990中的数据:

      1551408083254

    • order_1991中的数据:

      1551408133323

    • order_all中的数据:

      1551408216185

  4. 往order_all中插入一条记录,由于在MERGE表定义时,INSERT_METHOD 选择的是LAST,那么插入的数据会想最后一张表中插入。

    1
    insert into order_all values(100,10000.0,'西安');

    1551408519889

3、存储引擎的选择

在选择存储引擎时,应该根据应用系统的特点选择合适的存储引擎。对于复杂的应用系统,还可以根据实际情况选择多种存储引擎进行组合。

以下是几种常用的存储引擎的使用环境:

  • InnoDB:是Mysql的默认存储引擎,用于事务处理应用程序,支持外键。如果应用对事务的完整性有比较高的要求,在并发条件下要求数据的一致性,数据操作除了插入和查询意外,还包含很多的更新、删除操作,那么InnoDB存储引擎是比较合适的选择。InnoDB存储引擎除了有效的降低由于删除和更新导致的锁定, 还可以确保事务的完整提交和回滚,对于类似于计费系统或者财务系统等对数据准确性要求比较高的系统,InnoDB是最合适的选择。
  • MyISAM : 如果应用是以读操作和插入操作为主,只有很少的更新和删除操作,并且对事务的完整性、并发性要求不是很高,那么选择这个存储引擎是非常合适的
  • MEMORY将所有数据保存在RAM中,在需要快速定位记录和其他类似数据环境下,可以提供几块的访问。MEMORY的缺陷就是对表的大小有限制,太大的表无法缓存在内存中,其次是要确保表的数据可以恢复,数据库异常终止后表中的数据是可以恢复的。MEMORY表通常用于更新不太频繁的小表,用以快速得到访问结果
  • MERGE:用于将一系列等同的MyISAM表以逻辑方式组合在一起,并作为一个对象引用他们。MERGE表的优点在于可以突破对单个MyISAM表的大小限制,并且通过将不同的表分布在多个磁盘上,可以有效的改善MERGE表的访问效率。这对于存储诸如数据仓储等VLDB环境十分合适。

7、优化SQL步骤

在应用的的开发过程中,由于初期数据量小,开发人员写 SQL 语句时更重视功能上的实现,但是当应用系统正式上线后,随着生产数据量的急剧增长,很多 SQL 语句开始逐渐显露出性能问题,对生产的影响也越来越大,此时这些有问题的 SQL 语句就成为整个系统性能的瓶颈,因此我们必须要对它们进行优化,这里将详细介绍在 MySQL 中优化 SQL 语句的方法。

当面对一个有 SQL 性能问题的数据库时,我们应该从何处入手来进行系统的分析,使得能够尽快定位问题 SQL 并尽快解决问题

1、查看SQL执行频率

MySQL 客户端连接成功后,通过 show [session|global] status 命令可以提供服务器状态信息。

1
show [session|global] status;

show [session|global] status 可以根据需要加上参数“session”或者“global”来显示 session 级(当前连接)的计结果和 global 级(自数据库上次启动至今)的统计结果。如果不写,默认使用参数是“session”。

下面的命令显示了当前 session 中整个数据库所有统计参数的值:

1
2
-- 七个下划线
show status like 'Com_______';

1552487172501

下面的命令显示了当前Innodb存储引擎的所有统计参数的值:

1
show status like 'Innodb_rows_%';

image-20210901214257185

以上sql语句查询的是当前连接的相关的状态信息,如果想要查看全局的状态信息,即整一个数据库的状态信息,需要在show与status之间加入 global

1
2
3
show global status like 'Com_______';

show global status like 'Innodb_rows_%';

Com_xxx 表示每个 xxx 语句执行的次数,我们通常比较关心的是以下几个统计参数。

参数 含义
==Com_select== ==执行 select 操作的次数,一次查询只累加 1。==
==Com_insert== ==执行 INSERT 操作的次数,对于批量插入的 INSERT 操作,只累加一次。==
==Com_update== ==执行 UPDATE 操作的次数。==
==Com_delete== ==执行 DELETE 操作的次数。==
Innodb_rows_read select 查询返回的行数。
Innodb_rows_inserted 执行 INSERT 操作插入的行数。
Innodb_rows_updated 执行 UPDATE 操作更新的行数。
Innodb_rows_deleted 执行 DELETE 操作删除的行数。
Connections 试图连接 MySQL 服务器的次数。
Uptime 服务器工作时间。
Slow_queries 慢查询的次数。
  • Com_***:这些参数对于==所有存储引擎的表操作==都会进行累计。
  • Innodb_***:这几个参数==只是针对InnoDB 存储引擎==的,累加的算法也略有不同。

2、定位低效率执行SQL

可以通过以下两种方式定位执行效率较低的 SQL 语句:

  • 慢查询日志:通过慢查询日志定位那些执行效率较低的 SQL 语句,用--log-slow-queries[=file_name]选项启动时,mysqld 写一个包含所有执行时间超过 long_query_time 秒的 SQL 语句的日志文件。具体可以查看日志管理的相关部分。
  • show processlist慢查询日志在查询结束以后才纪录,所以在应用反映执行效率出现问题的时候查询慢查询日志并不能定位问题,可以使用show processlist命令查看当前MySQL在进行的线程,包括线程的状态、是否锁表等,可以实时地查看 SQL 的执行情况,同时对一些锁表操作进行优化。

1556098544349

其中几个表头的相关信息:

  • id:用户登录mysql时,系统分配的”connection_id”**,可以使用函数connection_id()查看**
  • user:显示当前用户。如果不是root,这个命令就只显示用户权限范围的sql语句
  • host:显示这个语句是从哪个ip的哪个端口上发的,可以用来跟踪出现问题语句的用户
  • db:显示这个进程目前连接的是哪个数据库
  • command显示当前连接的执行的命令,一般取值为
    • 休眠(sleep)
    • 查询(query)
    • 连接(connect)等
  • time:显示这个状态持续的时间,单位是秒
  • state:显示使用当前连接的sql语句的状态,很重要的列。
    • state描述的是语句执行中的某一个状态。
    • 一个sql语句,以查询为例,可能需要经过:
      1. copying to tmp table
      2. sorting result
      3. sending data等状态才可以完成
  • info显示这个sql语句,是判断问题语句的一个重要依据

3、explain分析执行计划

通过以上步骤查询到效率低的 SQL 语句后,可以通过 EXPLAIN 或者 DESC 命令获取 MySQL如何执行 SELECT 语句的信息,包括在 SELECT 语句执行过程中表如何连接和连接的顺序。

查询SQL语句的执行计划 :

1
explain select * from tb_item where id = 1;

1552487489859

1
explain select * from tb_item where title = '阿尔卡特 (OT-979) 冰川白 联通3G手机3';

1552487526919

字段 含义
id select查询的序列号,是一组数字,表示的是查询中执行select子句或者是操作表的顺序。(与表结构的执行顺序有关)
select_type 表示 SELECT 的类型,常见的取值有 SIMPLE(简单表,即不使用表连接或者子查询)、PRIMARY(主查询,即外层的查询)、UNION(UNION 中的第二个或者后面的查询语句)、SUBQUERY(子查询中的第一个 SELECT)等
table 输出结果集的表
type 表示表的连接类型,性能由好到差的连接类型为( system —> const —–> eq_ref ——> ref ——-> ref_or_null—-> index_merge —> index_subquery —–> range —–> index ——> all )
possible_keys 表示查询时,可能使用的索引
key 表示实际使用的索引
key_len 索引字段的长度
rows 扫描行的数量
extra 执行情况的说明和描述

现在对以上字段进行相关说明:

1、环境准备

1556122799330

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
CREATE TABLE `t_role` (
`id` varchar(32) NOT NULL,
`role_name` varchar(255) DEFAULT NULL,
`role_code` varchar(255) DEFAULT NULL,
`description` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_role_name` (`role_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `t_user` (
`id` varchar(32) NOT NULL,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `user_role` (
`id` int(11) NOT NULL auto_increment ,
`user_id` varchar(32) DEFAULT NULL,
`role_id` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_ur_user_id` (`user_id`),
KEY `fk_ur_role_id` (`role_id`),
CONSTRAINT `fk_ur_role_id` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_ur_user_id` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8;


insert into `t_user` (`id`, `username`, `password`, `name`) values('1','super','$2a$10$TJ4TmCdK.X4wv/tCqHW14.w70U3CC33CeVncD3SLmyMXMknstqKRe','超级管理员');
insert into `t_user` (`id`, `username`, `password`, `name`) values('2','admin','$2a$10$TJ4TmCdK.X4wv/tCqHW14.w70U3CC33CeVncD3SLmyMXMknstqKRe','系统管理员');
insert into `t_user` (`id`, `username`, `password`, `name`) values('3','itcast','$2a$10$8qmaHgUFUAmPR5pOuWhYWOr291WJYjHelUlYn07k5ELF8ZCrW0Cui','test02');
insert into `t_user` (`id`, `username`, `password`, `name`) values('4','stu1','$2a$10$pLtt2KDAFpwTWLjNsmTEi.oU1yOZyIn9XkziK/y/spH5rftCpUMZa','学生1');
insert into `t_user` (`id`, `username`, `password`, `name`) values('5','stu2','$2a$10$nxPKkYSez7uz2YQYUnwhR.z57km3yqKn3Hr/p1FR6ZKgc18u.Tvqm','学生2');
insert into `t_user` (`id`, `username`, `password`, `name`) values('6','t1','$2a$10$TJ4TmCdK.X4wv/tCqHW14.w70U3CC33CeVncD3SLmyMXMknstqKRe','老师1');


INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('5','学生','student','学生');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('7','老师','teacher','老师');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('8','教学管理员','teachmanager','教学管理员');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('9','管理员','admin','管理员');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('10','超级管理员','super','超级管理员');


INSERT INTO user_role(id,user_id,role_id) VALUES(NULL, '1', '5'),(NULL, '1', '7'),(NULL, '2', '8'),(NULL, '3', '9'),(NULL, '4', '8'),(NULL, '5', '10') ;
2、explain 之 id

id 字段是 select查询的序列号,是一组数字,表示的是查询中执行select子句或者是操作表的顺序。

id 情况有三种:

  1. id 相同表示加载表的顺序是从上到下

    1
    2
    -- 一次性查询多张表
    explain select * from t_role r, t_user u, user_role ur where r.id = ur.role_id and u.id = ur.user_id;

    1556102471304

    此例中 先执行where 后的第一条语句 r.id = ur.role_id 通过 r.id 关联 ur.role_id 。 而 ur.role_id 的结果建立在 u.id = ur.user_id 的基础之上。

  2. id 不同id值越大,优先级越高,越先被执行。

    1
    2
    -- 采用子查询的方式
    EXPLAIN SELECT * FROM t_role WHERE id = (SELECT role_id FROM user_role WHERE user_id = (SELECT id FROM t_user WHERE username = 'stu1'))

    1556103009534

  3. id 有相同,也有不同,同时存在id相同的可以认为是一组,从上往下顺序执行;在所有的组中,id的值越大,优先级越高,越先执行。

    1
    2
    -- 既查询了多张表,又进行了子查询
    EXPLAIN SELECT * FROM t_role r , (SELECT * FROM user_role ur WHERE ur.`user_id` = '2') a WHERE r.id = a.role_id ;

    1556103294182

3、explain 之 select_type

表示 SELECT 的类型,有哪些:

img

常见的取值,如下表所示:

select_type 含义
SIMPLE 简单的select查询,查询中不包含子查询或者UNION
PRIMARY 查询中若包含任何复杂的子查询,最外层查询标记为该标识
SUBQUERY 在SELECT 或 WHERE 列表中包含了子查询
DERIVED 在FROM 列表中包含的子查询,被标记为 DERIVED(衍生) MYSQL会递归执行这些子查询,把结果放在临时表中
UNION 若第二个SELECT出现在UNION之后,则标记为UNION ; 若UNION包含在FROM子句的子查询中,外层SELECT将被标记为 : DERIVED
UNION RESULT 从UNION表获取结果的SELECT
1、select_type 之 SIMPLE

SIMPLE:简单的select查询,查询中不包含子查询或者UNION

image-20210901220448958

2、select_type 之 PRIMARY

PRIMARY:查询中若包含任何复杂的子查询,最外层查询标记为该标识

3、select_type 之 SUBQUERY

SUBQUERY:在SELECT 或 WHERE 列表中包含了子查询

1
2
3
-- t_user表是查询的最外层的表,所以t_user表是PRIMARY
-- user_role表是在WHERE 语句当中的子查询当中出现的表,所以user_role表是SUBQUERY
explain select * from t_user where id = (select id from user_role where role_id = '9') ;

image-20210901220550625

4、select_type 之 DERIVED

DERIVED:在FROM 列表中包含的子查询,被标记为 DERIVED(衍生) MYSQL会递归执行这些子查询,把结果放在临时表中

1
2
3
-- t_user表是在FROM 列表中包含的子查询当中查询的表,所以t_user表是DERIVED,而生成的临时表就是<derived2>(里面的2表示产生这个临时表的DERIVED的id)
-- 结果从临时表<derived2>当中获取,即临时表<derived2>是最外层的表,因此是PRIMARY
explain select a.* from (select * from t_user where id in ('1', '2')) a;

image-20210901220947928

5、select_type 之 UNION

UNION:若第二个SELECT出现在UNION之后,则标记为UNION ; 若UNION包含在FROM子句的子查询中,外层SELECT将被标记为 : DERIVED

6、select_type 之 UNION RESULT

UNION RESULT:从UNION表获取结果的SELECT

1
2
3
4
-- t_user表是查询的最外层的表,所以t_user表是PRIMARY
-- 在union之后也查询了t_user表,所以在id=2的t_user表是UNION
-- <union1,2>表是连接了id=1与id=2的两张t_user表的结果表,标记为UNION RESULT
explain select * from t_user where id = '1' union select * from t_user where id = '2'

image-20210901221658084

7、其他不常见的类型:select_type 之 DEPENDENT SUBQUERY

DEPENDENT SUBQUERY:在SELECT或WHERE列表中包含了子查询,子查询基于外层

img

dependent subquery 与 subquery 的区别:

  • dependent subquery(依赖子查询) : 子查询结果为 多值
  • subquery (子查询):查询结果为 单值
8、其他不常见的类型:select_type 之 UNCACHEABLE SUBQUREY

UNCACHEABLE SUBQUREY:无法被缓存的子查询

@@ 表示查的环境参数 。没办法缓存

4、explain 之 table

展示这一行的数据是关于哪一张表的

5、explain 之 type

type 显示的是访问类型,是较为重要的一个指标,可取值为:

type 含义
NULL MySQL不访问任何表,索引,直接返回结果
system 表只有一行记录(等于系统表),这是const类型的特例,一般不会出现
const 表示通过索引一次就找到了,const 用于比较primary key 或者 unique 索引。因为只匹配一行数据,所以很快。如将主键置于where列表中,MySQL 就能将该查询转换为一个常量。const于将 “主键” 或 “唯一” 索引的所有部分与常量值进行比较
eq_ref 类似ref,区别在于使用的是唯一索引,使用主键的关联查询,关联查询出的记录只有一条。常见于主键或唯一索引扫描
ref 非唯一性索引扫描,返回匹配某个单独值的所有行。本质上也是一种索引访问,返回所有匹配某个单独值的所有行(多个)
range 只检索给定返回的行,使用一个索引来选择行。 where 之后出现 between , < , > , in 等操作。
index index 与 ALL的区别为 index 类型只是遍历了索引树, 通常比ALL 快, ALL 是遍历数据文件。
all 将遍历全表以找到匹配的行

结果值从最好到最坏以此是:

1
2
3
4
NULL > system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL


system > const > eq_ref > ref > range > index > ALL

==一般来说, 我们需要保证查询至少达到 range 级别, 最好达到ref 。==

相关补充:

type 含义
index_merge 在查询过程中需要多个索引组合使用,通常出现在有 or 的关键字的sql中
ref_or_null 对于某个字段既需要关联条件,也需要null值得情况下。查询优化器会选择用ref_or_null连接查询。
index_subquery 利用索引来关联子查询,不再全表扫描。
unique_subquery 该联接类型类似于index_subquery。 子查询中的唯一索引
1、type 之 NULL

NULL:MySQL不访问任何表,索引,直接返回结果

image-20210901223108340

2、type 之 system

system:表只有一行记录(等于系统表),这是const类型的特例,一般不会出现

image-20210901223220731

3、type 之 const

const:表示通过索引一次就找到了const 用于比较primary key 或者 unique 索引。因为只匹配一行数据,所以很快。如将主键置于where列表中,MySQL 就能将该查询转换为一个常量。const于将 “主键” 或 “唯一” 索引的所有部分与常量值进行比较

通过比较primary key

image-20210901223310082

通过比较unique 索引

image-20210901223455304

4、type 之 eq_ref

eq_ref:类似ref,区别在于使用的是唯一索引,使用主键的关联查询,关联查询出的记录只有一条。常见于主键或唯一索引扫描

image-20210901223635631

5、type 之 ref

ref:非唯一性索引扫描,返回匹配某个单独值的所有行。本质上也是一种索引访问,返回所有匹配某个单独值的所有行(多个)

image-20210901223819993

image-20210901223749200

6、type 之 range

range:只检索给定返回的行,使用一个索引来选择行where 之后出现 between , < , > , in 等操作。

7、type 之 index

index:index 与 ALL的区别为 index 类型只是遍历了索引树, 通常比ALL 快, ALL 是遍历数据文件。

查询的是id主键,mysql的主键默认有着主键索引:

image-20210901223945702

8、type 之 all

all:将遍历全表以找到匹配的行

image-20210901223912058

9、补充:type 之 index_merge

index_merge:在查询过程中需要多个索引组合使用,通常出现在有 or 的关键字的sql中

img

10、补充:type 之 ref_or_null

ref_or_null:对于某个字段既需要关联条件,也需要null值得情况下。查询优化器会选择用ref_or_null连接查询。

img

11、补充:type 之 index_subquery

index_subquery:利用索引来关联子查询,不再全表扫描。

img

img

img

12、 补充:type 之 unique_subquery

unique_subquery:该联接类型类似于index_subquery。 子查询中的唯一索引

img

6、explain 之 key
  • possible_keys:显示==可能==应用在这张表的索引, 一个或多个。
  • key:==实际使用==的索引, 如果为NULL, 则没有使用索引。
  • key_len:表示==索引中使用的字节数==, 该值为索引字段最大可能长度,并非实际使用长度,即key_len是根据表定义计算而得,不是通过表内检索出的
    • 在不损失精确性的前提下, 长度越短越好,越短执行效率越高。
1、key_len的长度如何计算
1
EXPLAIN SELECT * FROM emp WHERE emp.deptno=109 AND emp.`ename`='AvDEjl'

img

如何计算

img

总结一下:char(30) utf8 –> key_len = 30*3 +1 表示:

  • utf8 格式需要 *3 (跟数据类型有关)
  • 允许为 NULL +1 ,不允许 +0
  • 动态类型 +2 (动态类型包括 : varchar , detail text() 截取字符窜)

img

  • 第一组:key_len = deptno(int) + null + ename(varchar(20) * 3 + 动态) = 4 + 1+ 20 * 3 + 2= 67
  • 第二组:key_len = deptno(int) + null = 4 + 1 = 5
7、explain 之 rows

rows列显示MySQL认为它执行查询时必须检查的行数。 越少越好

8、explain 之 extra

其他的额外的执行计划信息,在该列展示 。

extra 含义
using filesort 说明mysql会对数据使用一个外部的索引排序,而不是按照表内的索引顺序进行读取, 称为 “文件排序”, 效率低。
using temporary 使用了临时表保存中间结果,MySQL在对查询结果排序时使用临时表。常见于 order by 和 group by; 效率低
using index 表示相应的select操作使用了覆盖索引, 避免访问表的数据行, 效率不错。
Using where 表明使用了where过滤
using join buffer 使用了连接缓存:
impossible where where子句的值总是false,不能用来获取任何元组
select tables optimized away 在没有GROUPBY子句的情况下,基于索引优化MIN/MAX操作或者
distinct 优化distinct操作,在找到第一匹配的元祖后即停止找同样值的动作

如果出现的是前面两个,就需要考虑优化了,因为前面那两个是非常耗费性能的。如果出现的是最后一个,则需要保持,因为使用到了索引,性能较高。

1、extra 之 using filesort(重要)

using filesort:说明mysql会对数据使用一个外部的索引排序,而不是按照表内的索引顺序进行读取, 称为 “文件排序”;效率低。

image-20210901224456653

image-20210901224517422

image-20210901224657926

2、extra 之 using temporary(重要)

using temporary:使用了临时表保存中间结果,MySQL在对查询结果排序时使用临时表。常见于 order by 和 group by;效率低

image-20210901224752479

3、extra 之 using index(重要)

using index:表示相应的select操作使用了覆盖索引,避免访问表的数据行,效率不错。

  • 如果同时出现using where,表明索引被用来执行索引键值的查找;
  • 如果没有同时出现using where,表明索引只是用来读取数据而非利用索引执行查找。

**覆盖索引(Covering Index)**:

索引是高效找到行的一个方法,但是一般数据库也能使用索引找到一个列的数据,因此它不必读取整个行。毕竟索引叶子节点存储了它们索引的数据;当能通过读取索引就可以得到想要的数据,那就不需要读取行了。

  1. ==一个索引==
  2. ==包含了(或覆盖了)[select子句]与查询条件[Where子句]中==
  3. ==所有需要的字段就叫做覆盖索引==。

上句理解:

1
select id , name from t_xxx where age=18;

有一个组合索引 idx_id_name_age_xxx 包含了(覆盖了),id,name,age三个字段。查询时直接将建立了索引的列读取出来了,而不需要去查找所在行的其他数据。所以很高效。

(个人认为:在数据量较大,固定字段查询情况多时可以使用这种方法。)

注意:

  • *如果要使用覆盖索引,一定要注意select列表中只取出需要的列,不可select **
  • 因为如果将所有字段一起做索引会导致索引文件过大,查询性能下降
4、extra 之 using join buffer(了解)

using join buffer:使用了连接缓存

img

出现在当两个连接时:

  • 驱动表(被连接的表,left join 左边的表。inner join 中数据少的表) 没有索引的情况下,给驱动表建立索引可解决此问题。且 type 将改变成 ref
5、extra 之 impossible where(了解)

impossible where:where子句的值总是false,不能用来获取任何元组

img

4、show profile分析SQL

Mysql从5.0.37版本开始增加了对 show profiles 和 show profile 语句的支持。show profiles 能够在做SQL优化时帮助我们了解时间都耗费到哪里去了。

通过 have_profiling 参数,能够看到当前MySQL是否支持profile

1552488401999

默认profiling是关闭的,可以通过set语句在Session级别开启profiling

1552488372405

1
2
-- 开启profiling 开关;
set profiling = 1;

通过profile,我们能够更清楚地了解SQL执行的过程。

首先,我们可以执行一系列的操作,如下图所示:

1
2
3
4
5
6
7
8
9
show databases;

use db01;

show tables;

select * from tb_item where id < 5;

select count(*) from tb_item;

执行完上述命令之后,再执行show profiles 指令, 来查看SQL语句执行的耗时:

1552489017940

通过show profile for query query_id 语句可以查看到该SQL执行过程中每个线程的状态和消耗的时间:

1552489053763

注意:Sending data 状态表示==MySQL线程开始访问数据行并把结果返回给客户端==,而不仅仅是返回个客户端。由于在Sending data状态下,MySQL线程往往需要做大量的磁盘读取操作,所以经常是整各查询中耗时最长的状态。

在获取到最消耗时间的线程状态后,MySQL支持进一步选择allcpublock iocontext switchpage faults等明细类型类查看MySQL在使用什么资源上耗费了过高的时间。

例如,选择查看CPU的耗费时间 :

1552489671119

字段 含义
Status sql 语句执行的状态
Duration sql 执行过程中每一个步骤的耗时
CPU_user 当前用户占有的cpu
CPU_system 系统占有的cpu

5、trace分析优化器执行计划

MySQL5.6提供了对SQL的跟踪trace,通过trace文件能够进一步了解为什么优化器选择A计划,而不是选择B计划。

打开trace,设置格式为 JSON,并设置trace最大能够使用的内存大小,避免解析过程中因为默认内存过小而不能够完整展示

1
2
SET optimizer_trace="enabled=on",end_markers_in_json=on;
set optimizer_trace_max_mem_size=1000000;

执行SQL语句 :

1
select * from tb_item where id < 4;

最后, 检查information_schema.optimizer_trace就可以知道MySQL是如何执行SQL的 :

1
select * from information_schema.optimizer_trace\G;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
*************************** 1. row ***************************
QUERY: select * from tb_item where id < 4
TRACE: {
"steps": [
{
"join_preparation": {
"select#": 1,
"steps": [
{
"expanded_query": "/* select#1 */ select `tb_item`.`id` AS `id`,`tb_item`.`title` AS `title`,`tb_item`.`price` AS `price`,`tb_item`.`num` AS `num`,`tb_item`.`categoryid` AS `categoryid`,`tb_item`.`status` AS `status`,`tb_item`.`sellerid` AS `sellerid`,`tb_item`.`createtime` AS `createtime`,`tb_item`.`updatetime` AS `updatetime` from `tb_item` where (`tb_item`.`id` < 4)"
}
] /* steps */
} /* join_preparation */
},
{
"join_optimization": {
"select#": 1,
"steps": [
{
"condition_processing": {
"condition": "WHERE",
"original_condition": "(`tb_item`.`id` < 4)",
"steps": [
{
"transformation": "equality_propagation",
"resulting_condition": "(`tb_item`.`id` < 4)"
},
{
"transformation": "constant_propagation",
"resulting_condition": "(`tb_item`.`id` < 4)"
},
{
"transformation": "trivial_condition_removal",
"resulting_condition": "(`tb_item`.`id` < 4)"
}
] /* steps */
} /* condition_processing */
},
{
"table_dependencies": [
{
"table": "`tb_item`",
"row_may_be_null": false,
"map_bit": 0,
"depends_on_map_bits": [
] /* depends_on_map_bits */
}
] /* table_dependencies */
},
{
"ref_optimizer_key_uses": [
] /* ref_optimizer_key_uses */
},
{
"rows_estimation": [
{
"table": "`tb_item`",
"range_analysis": {
"table_scan": {
"rows": 9816098,
"cost": 2.04e6
} /* table_scan */,
"potential_range_indices": [
{
"index": "PRIMARY",
"usable": true,
"key_parts": [
"id"
] /* key_parts */
}
] /* potential_range_indices */,
"setup_range_conditions": [
] /* setup_range_conditions */,
"group_index_range": {
"chosen": false,
"cause": "not_group_by_or_distinct"
} /* group_index_range */,
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "PRIMARY",
"ranges": [
"id < 4"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 3,
"cost": 1.6154,
"chosen": true
}
] /* range_scan_alternatives */,
"analyzing_roworder_intersect": {
"usable": false,
"cause": "too_few_roworder_scans"
} /* analyzing_roworder_intersect */
} /* analyzing_range_alternatives */,
"chosen_range_access_summary": {
"range_access_plan": {
"type": "range_scan",
"index": "PRIMARY",
"rows": 3,
"ranges": [
"id < 4"
] /* ranges */
} /* range_access_plan */,
"rows_for_plan": 3,
"cost_for_plan": 1.6154,
"chosen": true
} /* chosen_range_access_summary */
} /* range_analysis */
}
] /* rows_estimation */
},
{
"considered_execution_plans": [
{
"plan_prefix": [
] /* plan_prefix */,
"table": "`tb_item`",
"best_access_path": {
"considered_access_paths": [
{
"access_type": "range",
"rows": 3,
"cost": 2.2154,
"chosen": true
}
] /* considered_access_paths */
} /* best_access_path */,
"cost_for_plan": 2.2154,
"rows_for_plan": 3,
"chosen": true
}
] /* considered_execution_plans */
},
{
"attaching_conditions_to_tables": {
"original_condition": "(`tb_item`.`id` < 4)",
"attached_conditions_computation": [
] /* attached_conditions_computation */,
"attached_conditions_summary": [
{
"table": "`tb_item`",
"attached": "(`tb_item`.`id` < 4)"
}
] /* attached_conditions_summary */
} /* attaching_conditions_to_tables */
},
{
"refine_plan": [
{
"table": "`tb_item`",
"access_type": "range"
}
] /* refine_plan */
}
] /* steps */
} /* join_optimization */
},
{
"join_execution": {
"select#": 1,
"steps": [
] /* steps */
} /* join_execution */
}
] /* steps */
}

8、索引的使用

索引是数据库优化最常用也是最重要的手段之一,通过索引通常可以帮助用户解决大多数的MySQL的性能优化问题。

1、验证索引提升查询效率

在我们准备的表结构tb_item 中, 一共存储了 300 万记录;

1、根据ID查询
1
select * from tb_item where id = 1999\G;

查询速度很快, 接近0s , 主要的原因是因为id为主键, 有索引;

image-20210901225426139

查看SQL语句的执行计划:

image-20210901225525932

2、根据 title 进行精确查询
1
select * from tb_item where title = 'iphoneX 移动3G 32G941'\G; 

image-20210901225650475

查看SQL语句的执行计划:

image-20210901225744561

处理方案 , 针对title字段, 创建索引:

1
create index idx_item_title on tb_item(title);

image-20210901225830193

索引创建完成之后,再次进行查询:

image-20210901225903333

通过explain , 查看执行计划,执行SQL时使用了刚才创建的索引:

image-20210901225936532

2、索引的使用

1、准备环境
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
create table `tb_seller` (
`sellerid` varchar (100),
`name` varchar (100),
`nickname` varchar (50),
`password` varchar (60),
`status` varchar (1),
`address` varchar (100),
`createtime` datetime,
primary key(`sellerid`)
)engine=innodb default charset=utf8mb4;

insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('alibaba','阿里巴巴','阿里小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('baidu','百度科技有限公司','百度小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('huawei','华为科技有限公司','华为小店','e10adc3949ba59abbe56e057f20f883e','0','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('itcast','传智播客教育科技有限公司','传智播客','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('itheima','黑马程序员','黑马程序员','e10adc3949ba59abbe56e057f20f883e','0','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('luoji','罗技科技有限公司','罗技小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('oppo','OPPO科技有限公司','OPPO官方旗舰店','e10adc3949ba59abbe56e057f20f883e','0','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('ourpalm','掌趣科技股份有限公司','掌趣小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('qiandu','千度科技','千度小店','e10adc3949ba59abbe56e057f20f883e','2','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('sina','新浪科技有限公司','新浪官方旗舰店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('xiaomi','小米科技','小米官方旗舰店','e10adc3949ba59abbe56e057f20f883e','1','西安市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('yijia','宜家家居','宜家家居旗舰店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');

-- 创建name,status,address的复合索引
create index idx_seller_name_sta_addr on tb_seller(name,status,address);
2、避免索引失效
1、全值匹配 ,对索引中所有列都指定具体值

该情况下,索引生效,执行效率高。

1
2
-- 对索引中所有列(name,status,address)都指定具体值
explain select * from tb_seller where name='小米科技' and status='1' and address='北京市'\G;

1556170997921

2、最左前缀法则

如果索引了多列,即复合索引,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列

注意:

  • 创建的复合索引为name,status,address的复合索引,在该表中会创建三个索引:
    • idx_seller_name:name的索引——对应的索引长度为:403
    • idx_seller_name_sta:name与status的索引——对应的索引长度为:410
    • idx_seller_name_sta_addr:name、status与address的索引——对应的索引长度为:813
  • 匹配最左前缀法则,走索引:

    1556171348995

  • 把name放在最后面,走索引:(与where之后的条件先后顺序没有关系,只与查询条件有无有关)

    • and 忽略左右关系。既即使没有没有按顺序 由于优化器的存在,会自动优化。

    image-20210901141116945

  • 违法最左前缀法则 , 索引失效:

    1556171428140

  • 如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效:

    1556171662203

节点比较排序是先比较第一列,第一列相同就比较第二列,接着第三列,以此类推。所以不使用第一列的话,后面就乱序了,走不了索引。

举一个简单的例子:走楼层

  • 对应的三个索引就是上层楼:
    • name——第一层
    • name&status——第二层
    • name$status$address——第三层
  • 匹配最左前缀法则,走索引——成功走到楼顶
  • 违法最左前缀法则 , 索引失效
    • 只是使用了status或者address:未走第一层楼就想走到第二层楼或者第三层楼——失败
    • 同时使用了status与address:也是一样,走第一层楼就想走到第二层楼和第三层楼——失败
  • 如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效
    • 同时使用了name与address:走了第一层,但是没走第二层就想到第三层楼——第一层楼成功,第二第三层楼失败
3、范围查询右边的列,不能使用索引

1556172256791

根据前面的两个字段name , status 查询是走索引的, 但是最后一个条件address 没有用到索引。

4、不要在索引列上进行运算操作, 索引将失效

1556172813715

5、字符串不加单引号,造成索引失效

1556172967493

由于在查询时,没有对字符串加单引号,MySQL的查询优化器,会自动的进行类型转换(隐式类型转换),造成索引失效(底层对索引进行了运算操作)。

6、尽量使用覆盖索引,避免select *

尽量使用覆盖索引(只访问索引的查询(索引列完全包含查询列)),减少select * 。

1556173928299

如果查询列,超出索引列,也会降低性能。

1556173986068

注意:

  • using index:使用覆盖索引的时候就会出现
  • using where:在查找使用索引的情况下,需要回表去查询所需的数据
  • using index condition:查找使用了索引,但是索引只是记录当前索引值的数据,需要回表查询其他数据
    (回调查询)
  • using index ; using where:查找使用了索引,但是需要的数据都在索引列中能找到,所以不需要回表查询数据
7、用or分割开的条件, 如果or前的条件中的列有索引,而后面的列中没有索引,那么涉及的索引都不会被用到

示例,name字段是索引列 , 而createtime不是索引列,中间是or进行连接是不走索引的 :

1
explain select * from tb_seller where name='黑马程序员' or createtime = '2088-01-01 12:00:00'\G;	

1556174994440

8、以%开头的Like模糊查询,索引失效

如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效。

1556175114369

解决方案 :

通过覆盖索引来解决

1556247686483

9、如果MySQL评估使用索引比全表更慢,则不使用索引

1556175445210

10、is NULL , is NOT NULL 有时索引失效

1556180634889

mysql底层会判定该字段中的数据大部分是null还是not null:

  • 如果大部分是not null,则使用is NOT NULL 的时候查询的是大部分数据,mysql底层会评估使用索引比全表更慢,则不使用索引,使用全表扫描;同理,如果使用的是is NULL的话,查询的是少量数据,这时候mysql使用索引查询的效率会比全表扫描高,使用mysql会使用索引。
  • 而且因为这里是select * 查询全部数据,如果使用了索引,那么还需要回表查询索引匹配的其他数据,速度上还不如直接全表扫描。
11、in 走索引, not in 索引失效

1556249602732

12、 单列索引和复合索引

尽量使用复合索引,而少使用单列索引 。

创建复合索引:

1
2
3
4
5
6
create index idx_name_sta_address on tb_seller(name, status, address);

-- 就相当于创建了三个索引 :
-- name
-- name + status
-- name + status + address

创建单列索引:

1
2
3
create index idx_seller_name on tb_seller(name);
create index idx_seller_status on tb_seller(status);
create index idx_seller_address on tb_seller(address);
  • 数据库会选择一个最优的索引(辨识度最高索引)来使用,并不会使用全部索引 。
    • 最优索引:表中的数据辨识度,辨识度越高,索引越优。(当查询的常量在表中只有一条数据,此时的辨识度是很高的)
  • 3个单列索引对应3个b+tree数据结构,通过索引查找只能以一个b+tree为标准来查,所以就算可能涉及到多个索引,但是只能使用一个索引。
13、mysql 在使用不等于(!= 或者<>)的时候无法使用索引会导致全表扫描

索引:

  • idx_nameAgeJob
  • idx_name

使用 != 和<>的字段索引失效( != 针对数值类型。 针对字符类型 != 针对数值类型)

前提 where and 后的字段在混合索引中的位置比当前字段靠后 where age != 10 and name=’xxx’,这种情况下,mysql自动优化,将 name=’xxx’ 放在 age !=10 之前,name 依然能使用索引。只是 age 的索引失效)

img

3、一般性建议
  • 对于单键索引,尽量选择针对当前query过滤性更好的索引
  • 在选择组合索引的时候,当前Query中过滤性最好的字段在索引字段顺序中,位置越靠前越好。(避免索引过滤性好的索引失效)
  • 在选择组合索引的时候,尽量选择可以能够包含当前query中的where字句中更多字段的索引
  • 尽可能通过分析统计信息和调整query的写法来达到选择合适索引的目的
4、查看索引使用情况
1
2
3
4
-- 查看当前会话的索引情况
show status like 'Handler_read%';
-- 查看全局的索引情况
show global status like 'Handler_read%';

1552885364563

  • Handler_read_first索引中第一条被读的次数。如果较高,表示服务器正执行大量全索引扫描(这个值越低越好)。
  • Handler_read_key如果索引正在工作,这个值代表一个行被索引值读的次数,如果值越低,表示索引得到的性能改善不高,因为索引不经常使用(这个值越高越好)
  • Handler_read_next按照键顺序读下一行的请求数。如果你用范围约束或如果执行索引扫描来查询索引列,该值增加。
  • Handler_read_prev按照键顺序读前一行的请求数。该读方法主要用于优化ORDER BY … DESC
  • Handler_read_rnd根据固定位置读一行的请求数。如果你正执行大量查询并需要对结果进行排序该值较高。你可能使用了大量需要MySQL扫描整个表的查询或你的连接没有正确使用键。这个值较高,意味着运行效率低,应该建立索引来补救
  • Handler_read_rnd_next在数据文件中读下一行的请求数。如果你正进行大量的表扫描,该值较高。通常说明你的表索引不正确或写入的查询没有利用索引

9、SQL优化

什么时候需要用到SQL优化?

步骤:

  1. 查询优化
  2. 观察,至少跑1天,看看生产的慢SQL情况。
  3. 开启慢查询日志,设置阙值,比如超过5秒钟的就是慢SQL,并将它抓取出来。
  4. explain+慢SQL分析
  5. show profile
  6. 运维经理or DBA,进行SQL数据库服务器的参数调优。

优化原则:小表驱动大表(原理:RBO)

1
2
3
4
5
select * from A where id in (select id from B)

-- 等价于:
for select id from B
for select * from A where A.id = B.id

当B表的数据集必须小于A表的数据集时,用in优于exists。

1
2
3
4
5
select * from A where exists (select 1 from B where B.id = A.id)

-- 等价于:
for select * from A
for select * from B where B.id = A.id

当A表的数据集系小于B表的数据集时,用exists优于in。

注意:A表与B表的ID字段应建立索引。

  • EXISTS

    1
    SELECT .. FROM table WHERE EXISTS (subquery)

    该语法可以理解为:将主查询的数据,放到子查询中做条件验证,根据验证结果(TRUE或FALSE)来决定主查询的数据结果是否得以保留。

  • 提示:

    1. *EXISTS (subquery)只返回TRUE或FALSE,因此子查询中的SELECT 也可以是SELECT 1或select’X’,官方说法是实际执行时会忽略SELECT清单,因此没有区别
    2. EXISTS子查询的实际执行过程可能经过了优化而不是我们理解上的逐条对比,如果担忧效率问题,可进行实际检验以确定是否有效率问题。
    3. EXISTS子查询往往也可以用条件表达式、其他子查询或者JOIN来替代,何种最优需要具体问题具体分析

1、大批量插入数据

环境准备 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- 两个表示一模一样的,一个用来测试有顺序的插入,一个用来测试没有顺序的插入
CREATE TABLE `tb_user_1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
`birthday` datetime DEFAULT NULL,
`sex` char(1) DEFAULT NULL,
`email` varchar(45) DEFAULT NULL,
`phone` varchar(45) DEFAULT NULL,
`qq` varchar(32) DEFAULT NULL,
`status` varchar(32) NOT NULL COMMENT '用户状态',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

CREATE TABLE `tb_user_2` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
`birthday` datetime DEFAULT NULL,
`sex` char(1) DEFAULT NULL,
`email` varchar(45) DEFAULT NULL,
`phone` varchar(45) DEFAULT NULL,
`qq` varchar(32) DEFAULT NULL,
`status` varchar(32) NOT NULL COMMENT '用户状态',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

当使用load 命令导入数据的时候,适当的设置可以提高导入的效率。

1556269346488

对于 InnoDB 类型的表,有以下几种方式可以提高导入的效率:

1、主键顺序插入

因为InnoDB类型的表是按照主键的顺序保存的,所以将导入的数据按照主键的顺序排列,可以有效的提高导入数据的效率(底层使用的是B+树)。如果InnoDB表没有主键,那么系统会自动默认创建一个内部列作为主键,所以如果可以给表创建一个主键,将可以利用这点,来提高导入数据的效率。

脚本文件介绍:

  • sql1.log —-> 主键有序
  • sql2.log —-> 主键无序

插入ID顺序排列数据:

1
2
-- 记忆:load(加载) data(数据) 到本地文件 + 本地文件地址 into table(到哪一张表) + 表名 fields (属性) 之间的 terminated(分隔符)by 确定的分隔符 + lines(行)之间的 terminated(分隔符)by 确定的分隔符
load data local infile '/root/sql1.log' into table 'tb_user_1' fields terminated by ',' lines terminated by '\n';

1555771750567

插入ID无序排列数据:

1555771959734

2、关闭唯一性校验

如果保证自己数据没问题,在导入数据前执行 SET UNIQUE_CHECKS=0,关闭唯一性校验,在导入结束后执行SET UNIQUE_CHECKS=1,恢复唯一性校验,可以提高导入的效率。

1555772132736

3、手动提交事务

如果应用使用自动提交的方式,建议在导入前执行 SET AUTOCOMMIT=0,关闭自动提交,导入结束后再执行 SET AUTOCOMMIT=1,打开自动提交,也可以提高导入的效率。

1555772351208

2、优化 insert 语句

当进行数据的insert操作的时候,可以考虑采用以下几种优化方案:

  1. 如果需要同时对一张表插入很多行数据时,应该尽量使用多个值表的insert语句,这种方式将大大的缩减客户端与数据库之间的连接、关闭等消耗。使得效率比分开执行的单个insert语句快。

    示例, 原始方式为:

    1
    2
    3
    insert into tb_test values(1,'Tom');
    insert into tb_test values(2,'Cat');
    insert into tb_test values(3,'Jerry');

    优化后的方案为 :

    1
    insert into tb_test values(1,'Tom'),(2,'Cat'),(3,'Jerry');
  2. 在事务中进行数据插入

    1
    2
    3
    4
    5
    start transaction;
    insert into tb_test values(1,'Tom');
    insert into tb_test values(2,'Cat');
    insert into tb_test values(3,'Jerry');
    commit;
  3. 数据有序插入

    1
    2
    3
    4
    5
    insert into tb_test values(4,'Tim');
    insert into tb_test values(1,'Tom');
    insert into tb_test values(3,'Jerry');
    insert into tb_test values(5,'Rose');
    insert into tb_test values(2,'Cat');

    优化后

    1
    2
    3
    4
    5
    insert into tb_test values(1,'Tom');
    insert into tb_test values(2,'Cat');
    insert into tb_test values(3,'Jerry');
    insert into tb_test values(4,'Tim');
    insert into tb_test values(5,'Rose');

3、优化 order by 语句

1、环境准备
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CREATE TABLE `emp` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`age` int(3) NOT NULL,
`salary` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

insert into `emp` (`id`, `name`, `age`, `salary`) values('1','Tom','25','2300');
insert into `emp` (`id`, `name`, `age`, `salary`) values('2','Jerry','30','3500');
insert into `emp` (`id`, `name`, `age`, `salary`) values('3','Luci','25','2800');
insert into `emp` (`id`, `name`, `age`, `salary`) values('4','Jay','36','3500');
insert into `emp` (`id`, `name`, `age`, `salary`) values('5','Tom2','21','2200');
insert into `emp` (`id`, `name`, `age`, `salary`) values('6','Jerry2','31','3300');
insert into `emp` (`id`, `name`, `age`, `salary`) values('7','Luci2','26','2700');
insert into `emp` (`id`, `name`, `age`, `salary`) values('8','Jay2','33','3500');
insert into `emp` (`id`, `name`, `age`, `salary`) values('9','Tom3','23','2400');
insert into `emp` (`id`, `name`, `age`, `salary`) values('10','Jerry3','32','3100');
insert into `emp` (`id`, `name`, `age`, `salary`) values('11','Luci3','26','2900');
insert into `emp` (`id`, `name`, `age`, `salary`) values('12','Jay3','37','4500');

create index idx_emp_age_salary on emp(age,salary);
2、两种排序方式
1、filesort 排序

第一种是通过对返回数据进行排序,也就是通常说的 filesort 排序,所有不是通过索引直接返回排序结果的排序都叫 FileSort 排序。

1556335817763

2、using index

第二种通过有序索引顺序扫描直接返回有序数据,这种情况即为 using index,不需要额外排序,操作效率高。

1556335866539

多字段排序

1556336352061

了解了MySQL的排序方式,优化目标就清晰了:

  • 尽量减少额外的排序,通过索引直接返回有序数据。
  • where 条件和Order by 使用相同的索引,并且Order By 的顺序和索引顺序相同,
  • 并且Order by 的字段都是升序,或者都是降序。否则肯定需要额外的操作,这样就会出现FileSort。
3、Filesort 的优化

通过创建合适的索引,能够减少 Filesort 的出现,但是在某些情况下,条件限制不能让Filesort消失,那就需要加快 Filesort的排序操作。

对于Filesort , MySQL 有两种排序算法:

  1. 两次扫描算法:MySQL4.1 之前,使用该方式排序。首先根据条件取出排序字段和行指针信息,然后在排序区 sort buffer 中排序,如果sort buffer不够,则在临时表 temporary table 中存储排序结果(一次扫描)。完成排序之后,再根据行指针回表读取记录(两次扫描),该操作可能会导致大量随机I/O操作。
    • 多路排序需要借助 磁盘来进行排序。所以 取数据,排好了取数据。两次 io操作。比较慢
  2. 一次扫描算法:一次性取出满足条件的所有字段,然后在排序区 sort buffer 中排序后直接输出结果集排序时内存开销较大,但是排序效率比两次扫描算法要高
    • 单路排序 ,将排好的数据存在内存中,省去了一次 io 操作,所以比较快,但是需要内存空间足够。
    • 但是用单路也有它的问题:
      • 在sort_buffer中,方法B比方法A要多占用很多空间,因为方法B是把所有字段都取出,所以有可能取出的数据的总大小超出了sort_buffer的容量,导致每次只能取sort_buffer容量大小的数据,进行排序(创建tmp文件,多路合并),排完再取取sort_buffer容量大小,再排……从而多次I/O。
      • 本来想省一次I/O操作,反而导致了大量的I/O操作,反而得不偿失。

MySQL 通过比较系统变量 max_length_for_sort_data 的大小和==Query语句取出的字段总大小==, 来判定是否那种排序算法,如果max_length_for_sort_data 更大,那么使用第二种优化之后的算法;否则使用第一种。

可以适当提高 sort_buffer_size 和 max_length_for_sort_data 系统变量,来增大排序区的大小,提高排序的效率。

1556338367593

4、优化 group by 语句

由于GROUP BY 实际上也同样会进行排序操作,而且与ORDER BY 相比,GROUP BY 主要只是多了排序之后的分组操作。当然,如果在分组的时候还使用了其他的一些聚合函数,那么还需要一些聚合函数的计算。所以,在GROUP BY 的实现过程中,与 ORDER BY 一样也可以利用到索引。

如果查询包含 group by 但是用户想要避免排序结果的消耗, 则可以执行order by null 禁止排序。如下:

1
2
3
drop index idx_emp_age_salary on emp;

explain select age,count(*) from emp group by age;

1556339573979

优化后:

1
explain select age,count(*) from emp group by age order by null;

1556339633161

从上面的例子可以看出,第一个SQL语句需要进行”filesort”,而第二个SQL由于order by null 不需要进行 “filesort”, 而上文提过Filesort往往非常耗费时间。

创建索引:

1
create index idx_emp_age_salary on emp(age,salary);

1556339688158

一些建议:

  • group by实质是先排序后进行分组,遵照索引建的最佳左前缀
  • 当无法使用索引列,增大max_length_for_sort_data参数的设置+增大sort_buffer_size参数的设置
  • where高于having,能写在where限定的条件就不要去having限定了。

5、优化嵌套查询

1、使用join替代子查询

Mysql4.1版本之后,开始支持SQL的子查询。这个技术可以使用SELECT语句来创建一个单列的查询结果,然后把这个结果作为过滤条件用在另一个查询中。使用子查询可以一次性的完成很多逻辑上需要多个步骤才能完成的SQL操作,同时也可以避免事务或者表锁死,并且写起来也很容易。但是,有些情况下,子查询是可以被更高效的连接(JOIN)替代。

示例 ,查找有角色的所有的用户信息:

1
explain select * from t_user where id in (select user_id from user_role );

执行计划为:

1556359399199

优化后:

1
explain select * from t_user u , user_role ur where u.id = ur.user_id;

1556359482142

连接(Join)查询之所以更有效率一些 ,是因为MySQL不需要在内存中创建临时表来完成这个逻辑上需要两个步骤的查询工作。

2、where 之后使用的是 用in 还是 exists
1、实验

1、有索引&大表驱动小表

img

img

img

img

2、有索引&小表驱动大表

img

结论:有索引 小驱动大表 性能优于 大表驱动小表

3、无索引&小表驱动大表

img

img

img

4、无索引&大表驱动小表

img

img

3、结论
  • 有索引的情况下 用 inner join 是最好的 其次是 in ,exists最糟糕
  • 无索引的情况下用:
    • 小表驱动大表 因为join 方式需要distinct ,没有索引distinct消耗性能较大
    • 所以 exists性能最佳 in其次 join性能最差
  • 无索引的情况下大表驱动小表
    • in 和 exists 的性能应该是接近的 都比较糟糕 exists稍微好一点 超不过5% 但是inner join 优于使用了 join buffer 所以快很多
    • 如果left join 则最慢

6、优化OR条件

对于包含OR的查询子句,如果要利用索引,则OR之间的每个条件列都必须用到索引 , 而且==不能使用到复合索引,要使用单列索引==; 如果没有索引,则应该考虑增加索引。

获取 emp 表中的所有的索引:

1556354464657

示例:

1
explain select * from emp where id = 1 or age = 30;

1556354887509

1556354920964

建议使用 union 替换 or:

1556355027728

image-20210901151907930

image-20210901151933870

我们来比较下重要指标,发现主要差别是 type 和 ref 这两项

type 显示的是访问类型,是较为重要的一个指标,结果值从好到坏依次是:

1
system > const > eq_ref > ref > fulltext > ref_or_null  > index_merge > unique_subquery > index_subquery > range > index > ALL

UNION 语句的 type 值为 ref,OR 语句的 type 值为 range,可以看到这是一个很明显的差距

UNION 语句的 ref 值为 const,OR 语句的 type 值为 null,const 表示是常量值引用,非常快

这两项的差距就说明了 UNION 要优于 OR

7、优化分页查询

一般分页查询时,通过创建覆盖索引能够比较好地提高性能。一个常见又非常头疼的问题就是 limit 2000000,10 ,此时需要MySQL排序前2000010 记录,仅仅返回2000000 - 2000010 的记录,其他记录丢弃,因此使用limit进行分页查询的时候,越往后,耗费的时间越长,查询排序的代价就越大 。

1556361314783

1、优化思路一

在索引上完成排序分页操作,最后根据主键关联回原表查询所需要的其他列内容

1556416102800

2、优化思路二

该方案适用于==主键自增==的表,可以把Limit 查询转换成某个位置的查询 。

1556363928151

一般来说思路二要比思路一要简单而且效率要好,但是思路二有许多的限制:

  • 主键自增
  • 不能出现断层

8、优化单表查询

1、建表SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE IF NOT EXISTS `article` (
`id` INT(10) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
`author_id` INT(10) UNSIGNED NOT NULL,
`category_id` INT(10) UNSIGNED NOT NULL,
`views` INT(10) UNSIGNED NOT NULL,
`comments` INT(10) UNSIGNED NOT NULL,
`title` VARBINARY(255) NOT NULL,
`content` TEXT NOT NULL
);

INSERT INTO `article`(`author_id`, `category_id`, `views`, `comments`, `title`, `content`) VALUES
(1, 1, 1, 1, '1', '1'),
(2, 2, 2, 2, '2', '2'),
(1, 1, 3, 3, '3', '3');

SELECT * FROM article;
2、案例

查询 category_id 为1 且 comments 大于 1 的情况下,views 最多的 article_id。

1
EXPLAIN SELECT id,author_id FROM article WHERE category_id = 1 AND comments > 1 ORDER BY views DESC LIMIT 1;

结论:很显然,type 是 ALL,即最坏的情况。Extra 里还出现了 Using filesort,也是最坏的情况。优化是必须的。

开始优化:

  1. 新建索引+删除索引

    1
    2
    3
    -- ALTER TABLE `article` ADD INDEX idx_article_ccv ( `category_id` , `comments`, `views` );
    create index idx_article_ccv on article(category_id,comments,views);
    DROP INDEX idx_article_ccv ON article
  2. 第2次EXPLAIN

    1
    EXPLAIN SELECT id,author_id FROM `article` WHERE category_id = 1 AND comments >1 ORDER BY views DESC LIMIT 1;

    结论:type 变成了 range,这是可以忍受的。但是 extra 里使用 Using filesort 仍是无法接受的。

    但是我们已经建立了索引,为啥没用呢?

    • 这是因为按照 BTree 索引的工作原理,先排序 category_id,如果遇到相同的 category_id 则再排序 comments,如果遇到相同的 comments 则再排序 views。
    • 当 comments 字段在联合索引里处于中间位置时,因comments > 1 条件是一个范围值(所谓 range),MySQL 无法利用索引再对后面的 views 部分进行检索,即 range 类型查询字段后面的索引无效。
  3. 删除第一次建立的索引

    1
    DROP INDEX idx_article_ccv ON article;
  4. 第2次新建索引

    1
    2
    -- ALTER TABLE `article` ADD INDEX idx_article_cv ( `category_id` , `views` ) ;
    create index idx_article_cv on article(category_id,views);
  5. 第3次EXPLAIN

    1
    EXPLAIN SELECT id,author_id FROM article WHERE category_id = 1 AND comments > 1 ORDER BY views DESC LIMIT 1;

    结论:可以看到,type 变为了 ref,Extra 中的 Using filesort 也消失了,结果非常理想。

9、优化关联查询

1、建表SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
CREATE TABLE IF NOT EXISTS `class` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`card` INT(10) UNSIGNED NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE IF NOT EXISTS `book` (
`bookid` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`card` INT(10) UNSIGNED NOT NULL,
PRIMARY KEY (`bookid`)
);

INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
2、案例

下面开始explain分析

1
EXPLAIN SELECT * FROM class LEFT JOIN book ON class.card = book.card;

结论:type 有All

添加索引优化:

1
ALTER TABLE `book` ADD INDEX Y ( `card`);

第2次explain:

1
EXPLAIN SELECT * FROM class LEFT JOIN book ON class.card = book.card;

结论:可以看到第二行的 type 变为了 ref,rows 也变成了优化比较明显。

这是由左连接特性决定的。LEFT JOIN 条件用于确定如何从右表搜索行,左边一定都有,所以右边是我们的关键点,一定需要建立索引。

3、建议
  1. 保证**==被驱动表==的join字段已经被索引**

    • 被驱动表 join 后的表为被驱动表 (需要被查询)
  2. left join 时,选择小表作为驱动表,大表作为被驱动表。(原则:小表驱动大表)

    • left join 时一定是左边是驱动表,右边是被驱动表
  3. inner join 时,mysql会自己帮你把小结果集的表选为驱动表

  4. 子查询尽量不要放在被驱动表,有可能使用不到索引。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    select 
    a.name,bc.name
    from
    t_emp a
    left join
    (select
    b.id,c.name
    from
    t_dept b
    inner join
    t_emp c
    on b.ceo = c.id)
    on bc.id = a.deptid;

    上段查询中用到了子查询,必然 bc 表没有索引。肯定会进行全表扫描

    上段查询 可以直接使用 两个 left join 优化:

    1
    2
    3
    select a.name , c.name from t_emp a
    left outer join t_dept b on a.deptid = b.id
    left outer join t_emp c on b.ceo=c.id

    所有条件都可以使用到索引

    若必须用到子查询,可将子查询设置为驱动表。

    • 因为驱动表的type 肯定是 all,而子查询返回的结果表没有索引,必定也是all

8、使用SQL提示

SQL提示,是优化数据库的一个重要手段,简单来说,就是在SQL语句中加入一些人为的提示来达到优化操作的目的

1、USE INDEX

在查询语句中表名的后面,添加 use index 来提供希望MySQL去==参考==的索引列表(数据库不一定使用),就可以让MySQL不再考虑其他可用的索引。

1
create index idx_seller_name on tb_seller(name);

1556370971576

2、IGNORE INDEX

如果用户只是单纯的想让MySQL忽略一个或者多个索引,则可以使用 ignore index 作为 hint 。

1
explain select * from tb_seller ignore index(idx_seller_name) where name = '小米科技';

1556371004594

3、FORCE INDEX

为强制MySQL使用一个特定的索引,可在查询中使用 force index 作为hint 。

1
create index idx_seller_address on tb_seller(address);

1556371355788


10、应用优化

前面章节,我们介绍了很多数据库的优化措施。但是在实际生产环境中,由于数据库本身的性能局限,就必须要对前台的应用进行一些优化,来降低数据库的访问压力。

1、使用连接池

对于访问数据库来说,建立连接的代价是比较昂贵的,因为我们频繁的创建关闭连接,是比较耗费资源的,我们有必要建立 数据库连接池,以提高访问的性能。

2、减少对MySQL的访问

1、避免对数据进行重复检索

在编写应用代码时,需要能够理清对数据库的访问逻辑。能够一次连接就获取到结果的,就不用两次连接,这样可以大大减少对数据库无用的重复请求。

比如 ,需要获取书籍的id 和name字段 , 则查询如下:

1
select id , name from tb_book;

之后,在业务逻辑中有需要获取到书籍状态信息, 则查询如下:

1
select id , status from tb_book;

这样,就需要向数据库提交两次请求,数据库就要做两次查询操作。其实完全可以用一条SQL语句得到想要的结果。

1
select id, name , status from tb_book;
2、增加cache层

在应用中,我们可以在应用中增加 缓存 层来达到减轻数据库负担的目的。缓存层有很多种,也有很多实现方式,只要能达到降低数据库的负担又能满足应用需求就可以。

因此可以部分数据从数据库中抽取出来放到应用端以文本方式存储(采用配置文件的方式), 或者使用框架(Mybatis, Hibernate)提供的一级缓存/二级缓存,或者使用redis数据库来缓存数据

3、负载均衡

负载均衡是应用中使用非常普遍的一种优化方法,它的机制就是利用某种均衡算法,将固定的负载量分布到不同的服务器上, 以此来降低单台服务器的负载,达到优化的效果

1、利用MySQL复制分流查询

通过MySQL的主从复制,实现读写分离,使增删改操作走主节点,查询操作走从节点,从而可以降低单台服务器的读写压力

1

2、采用分布式数据库架构

分布式数据库架构适合大数据量、负载高的情况,它有良好的拓展性和高可用性。通过在多台服务器之间分布数据,可以实现在多台服务器之间的负载均衡,提高访问效率


11、Mysql中查询缓存优化

1、概述

开启Mysql的查询缓存,当执行完全相同的SQL语句的时候,服务器就会直接从缓存中读取结果,当数据被修改,之前的缓存会失效,修改比较频繁的表不适合做查询缓存。MySQL 5.6(2013)以来,查询缓存已被禁用。

2、操作流程

20180919131632347

  1. 客户端发送一条查询给服务器;
  2. 服务器先会检查查询缓存,如果命中了缓存,则立即返回存储在缓存中的结果。否则进入下一阶段;
  3. 服务器端进行SQL解析、预处理,再由优化器生成对应的执行计划;
  4. MySQL根据优化器生成的执行计划,调用存储引擎的API来执行查询;
  5. 将结果返回给客户端。

3、查询缓存配置

  1. 查看当前的MySQL数据库是否支持查询缓存:

    1
    SHOW VARIABLES LIKE 'have_query_cache';	

    ![1555249929012](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555249929012.png)

  2. 查看当前MySQL是否开启了查询缓存:

    1
    SHOW VARIABLES LIKE 'query_cache_type';

    ![1555250015377](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555250015377.png)

  3. 查看查询缓存的占用大小 :(单位:字节)默认:1M。如果想要增加的话,建议增加的值为1024的倍数

    1
    SHOW VARIABLES LIKE 'query_cache_size';

    ![1555250142451](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555250142451.png)

  4. 查看查询缓存的状态变量:

    1
    SHOW STATUS LIKE 'Qcache%';

    ![1555250443958](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555250443958.png)

    各个变量的含义如下:

    参数 含义
    Qcache_free_blocks 查询缓存中的可用内存块数
    Qcache_free_memory 查询缓存的可用内存量
    ==Qcache_hits== 查询缓存命中数
    ==Qcache_inserts== 添加到查询缓存的查询数
    Qcache_lowmen_prunes 由于内存不足而从查询缓存中删除的查询数
    ==Qcache_not_cached== 非缓存查询的数量(由于 query_cache_type 设置而无法缓存或未缓存)
    Qcache_queries_in_cache 查询缓存中注册的查询数
    Qcache_total_blocks 查询缓存中的块总数

4、开启查询缓存

MySQL的查询缓存默认是关闭的,需要手动配置参数 query_cache_type , 来开启查询缓存

query_cache_type 该参数的可取值有三个:

含义
OFF 或 0 查询缓存功能关闭
ON 或 1 查询缓存功能打开,SELECT的结果符合缓存条件即会缓存,否则,不予缓存,显式指定 SQL_NO_CACHE,不予缓存
DEMAND 或 2 查询缓存功能按需进行,显式指定 SQL_CACHE 的SELECT语句才会缓存;其它均不予缓存

在 /usr/my.cnf 配置中,增加以下配置:

1555251383805

配置完毕之后,重启服务既可生效 ;

然后就可以在命令行执行SQL语句进行验证 ,执行一条比较耗时的SQL语句,然后再多执行几次,查看后面几次的执行时间;获取通过查看查询缓存的缓存命中数,来判定是否走查询缓存。

5、查询缓存SELECT选项

可以在SELECT语句中指定两个与查询缓存相关的选项:

  • SQL_CACHE:如果查询结果是可缓存的,并且 query_cache_type 系统变量的值为ON或 DEMAND ,则缓存查询结果 。
  • SQL_NO_CACHE:服务器不使用查询缓存。它既不检查查询缓存,也不检查结果是否已缓存,也不缓存查询结果。

例子:

1
2
SELECT SQL_CACHE id, name FROM customer;
SELECT SQL_NO_CACHE id, name FROM customer;

6、查询缓存失效的情况

1、SQL 语句不一致的情况, 要想命中查询缓存,查询的SQL语句必须一致
1
2
SQL1 : select count(*) from tb_item;
SQL2 : Select count(*) from tb_item;
2、当查询语句中有一些不确定的时,则不会缓存

如 : now() , current_date() , curdate() , curtime() , rand() , uuid() , user() , database() 。

1
2
3
SQL1 : select * from tb_item where updatetime < now() limit 1;
SQL2 : select user();
SQL3 : select database();
3、不使用任何表查询语句
1
select 'A';
4、查询 mysql, information_schema或 performance_schema 数据库中的表时,不会走查询缓存
1
select * from information_schema.engines;
5、在存储的函数,触发器或事件的主体内执行的查询
6、如果表更改,则使用该表的所有高速缓存查询都将变为无效并从高速缓存中删除

这包括使用MERGE映射到已更改表的表的查询。一个表可以被许多类型的语句,如被改变 INSERT, UPDATE, DELETE, TRUNCATE TABLE, ALTER TABLE, DROP TABLE,或 DROP DATABASE 。当然,与此同时也会将更改之后的表重新加入查询缓存当中


12、Mysql内存管理及优化

1、内存优化原则

  1. 将尽量多的内存分配给MySQL做缓存,但要给操作系统和其他程序预留足够内存。
  2. MyISAM 存储引擎的数据文件读取依赖于操作系统自身的IO缓存,因此,如果有MyISAM表,就要预留更多的内存给操作系统做IO缓存
  3. 排序区、连接区等缓存是分配给每个数据库会话(session)专用的,其默认值的设置要根据最大连接数合理分配,如果设置太大,不但浪费资源,而且在并发连接较高时会导致物理内存耗尽。

2、MyISAM 内存优化

myisam存储引擎使用 key_buffer 缓存索引块,加速myisam索引的读写速度。对于myisam表的数据块,mysql没有特别的缓存机制,完全依赖于操作系统的IO缓存

1、key_buffer_size

key_buffer_size决定MyISAM索引块缓存区的大小,直接影响到MyISAM表的存取效率。可以在MySQL参数文件中设置key_buffer_size的值,对于一般MyISAM数据库,建议至少将1/4可用内存分配给key_buffer_size。

在/usr/my.cnf 中做如下配置:(默认值为8388608字节 = 8M)

1
key_buffer_size=512M

image-20210902012301229

2、read_buffer_size

如果需要经常顺序扫描myisam表,可以通过增大read_buffer_size的值来改善性能。但需要注意的是read_buffer_size是每个session独占的,如果默认值设置太大,就会造成内存浪费

3、read_rnd_buffer_size

对于需要做排序的myisam表的查询,如带有order by子句的sql,适当增加 read_rnd_buffer_size 的值,可以改善此类的sql性能。但需要注意的是 read_rnd_buffer_size 是每个session独占的,如果默认值设置太大,就会造成内存浪费。

3、InnoDB 内存优化

innodb用一块内存区做IO缓存池,该缓存池不仅用来缓存innodb的索引块,而且也用来缓存innodb的数据块。

1、innodb_buffer_pool_size

该变量决定了 innodb 存储引擎表数据和索引数据的最大缓存区大小

在保证操作系统及其他程序有足够内存可用的情况下,innodb_buffer_pool_size 的值越大,缓存命中率越高,访问InnoDB表需要的磁盘I/O 就越少,性能也就越高。

1
innodb_buffer_pool_size=512M

innodb_buffer_pool_size 的默认大小为134217728字节 = 128M

image-20210902012643822

2、innodb_log_buffer_size

决定了innodb重做日志缓存的大小,对于可能产生大量更新记录的大事务,增加innodb_log_buffer_size的大小,可以避免innodb在事务提交前就执行不必要的日志写入磁盘操作

1
innodb_log_buffer_size=10M

13、Mysql并发参数调整

从实现上来说,MySQL Server 是多线程结构,包括后台线程和客户服务线程。多线程可以有效利用服务器资源,提高数据库的并发性能

在Mysql中,控制并发连接和线程的主要参数包括:

  • max_connections
  • back_log
  • thread_cache_size
  • table_open_cahce

1、max_connections

采用max_connections 控制允许连接到MySQL数据库的最大数量,默认值是 151。

如果状态变量 connection_errors_max_connections 不为零,并且一直增长,则说明不断有连接请求因数据库连接数已达到允许最大值而失败,这是可以考虑增大max_connections 的值。

Mysql 最大可支持的连接数,取决于很多因素,包括给定==操作系统平台的线程库的质量==、==内存大小==、==每个连接的负荷==、==CPU的处理速度==,==期望的响应时间==等。在Linux 平台下,性能好的服务器,支持 500-1000 个连接不是难事,需要根据服务器性能进行评估设定。

image-20210902013047619

2、back_log

back_log 参数控制MySQL监听TCP端口时设置的积压请求栈大小

如果MySql的连接数达到max_connections时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即back_log,如果等待连接的数量超过back_log,将不被授予连接资源,将会报错。

5.6.6 版本之前默认值为 50 , 之后的版本默认为 50 + (max_connections / 5), 但最大不超过900。

如果需要数据库在较短的时间内处理大量连接请求, 可以考虑适当增大back_log 的值。

image-20210902013106674

3、table_open_cache

该参数用来控制==所有SQL语句==执行线程可打开表缓存的数量, 而在执行SQL语句时,每一个SQL执行线程至少要打开 1 个表缓存。该参数的值应该根据设置的最大连接数 max_connections 以及每个连接执行关联查询中涉及的表的最大数量来设定max_connections x N默认值为:2000

image-20210902013213494

4、thread_cache_size

为了加快连接数据库的速度,MySQL 会缓存一定数量的客户服务线程以备重用,通过参数 thread_cache_size 可控制 MySQL 缓存客户服务线程的数量。

默认大小为:9

image-20210902013312194

5、innodb_lock_wait_timeout

该参数是用来设置InnoDB 事务等待行锁的时间,默认值是50ms ,可以根据需要进行动态设置

  • 对于需要快速反馈的业务系统来说,可以将行锁的等待时间调小,以避免事务长时间挂起
  • 对于后台运行的批量处理程序来说, 可以将行锁的等待时间调大, 以避免发生大的回滚操作

image-20210902013414184


14、Mysql锁问题

1、锁概述

锁是计算机协调多个进程或线程并发访问某一资源的机制(避免争抢)。

在数据库中,除传统的计算资源(如 CPU、RAM、I/O 等)的争用以外,数据也是一种供许多用户共享的资源

如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂。

2、锁分类

从对数据操作的粒度分:

  1. 表锁:操作时,会锁定整个表。
  2. 行锁:操作时,会锁定当前操作行。

从对数据操作的类型分:

  1. 读锁(共享锁)(S Lock):针对同一份数据,多个读操作可以同时进行而不会互相影响。
  2. 写锁(排它锁)(X Lock):当前操作没有完成之前,它会阻断其他写锁和读锁。

3、行锁:记录锁(Record Locks)

mysql的行锁是通过索引加载的,即行锁是加在索引响应的行上的,要是对应的SQL语句没有走索引,则会全表扫描.

  1. 记录锁,仅仅锁住索引记录的一行,在单条索引记录上加锁
  2. record lock锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。

所以说当一条sql没有走任何索引时,那么将会在每一条聚合索引后面加X锁,这个类似于表锁,但原理上和表锁应该是完全不同的。

4、行锁:间隙锁(Gap Locks)

  1. 区间锁,仅仅锁住一个索引区间(开区间,不包括双端端点)
  2. 在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,==并不包括该索引记录本==身。比如在 1、2、3中,间隙锁的可能值有 (∞, 1),(1, 2),(2, ∞),
  3. 间隙锁可用于防止幻读,保证索引间的不会被插入数据

5、行锁:临键锁(Next-Key Locks)

  1. record lock + gap lock,左开右闭区间。

  2. 默认情况下,innodb使用next-key locks来锁定记录。

    1
    selectfor update
  3. 但当查询的索引含有唯一属性的时候,Next-Key Lock 会进行优化,将其降级为Record Lock,即仅锁住索引本身,不是范围。

  4. Next-Key Lock在不同的场景中会退化:

场景 退化锁类型
使用unique index精确匹配(=),且记录存在 Record Locks
使用unique index精确匹配(=),且记录不存在 Gap Locks
使用unique index范围匹配(<和> Record Locks + Gap Locks,锁上界不锁下界(左开右闭区间)

6、表锁:意向锁

表锁,其实也可以叫意向锁,表明“某个事务正在某些行持有了锁、或该事务准备去持有锁”。

  • 注意:表锁可以是意向锁,但是意向锁不一定是表锁

意向锁产生的主要目的是为了处理行锁和表锁之间的冲突

  • 事务在请求S锁和X锁前,需要先获得对应的IS、IX锁,
  • 在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。

例子:事务A修改user表的记录r,会给记录r上一把行级的排他锁(X),同时会给user表上一把意向排他锁(IX),这时事务B要给user表上一个表级的排他锁就会被阻塞。意向锁通过这种方式实现了行锁和表锁共存且满足事务隔离性的要求。

  1. 意向共享锁(IS锁):事务在请求S锁前,要先获得IS锁
  2. 意向排他锁(IX锁):事务在请求X锁前,要先获得IX锁

q1:为什么意向锁是表级锁呢?

当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定(行锁);

  • 如果意向锁是行锁,则需要遍历每一行数据去确认;
  • 如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能。

q2:意向锁怎么支持表锁和行锁并存?

  • 首先明确并存的概念是指数据库同时支持表、行锁,而不是任何情况都支持一个表中同时有一个事务A持有行锁、又有一个事务B持有表锁,因为表一旦被上了一个表级的写锁,肯定不能再上一个行级的锁

  • 如果事务A对某一行上锁,其他事务就不可能修改这一行。这与“事务B锁住整个表就能修改表中的任意一行”形成了冲突。所以,没有意向锁的时候,让行锁与表锁共存,就会带来很多问题。于是有了意向锁的出现,如q1的答案中,数据库不需要在检查每一行数据是否有锁,而是直接判断一次意向锁是否存在即可,能提升很多性能。

    1. 共享锁和排他锁,系统在特定的条件下会自动添加共享锁或者排他锁,也可以手动添加共享锁或者排他锁。
    2. 意向共享锁和意向排他锁都是系统自动添加和自动释放的,整个过程无需人工干预
    3. 共享锁和排他锁都是锁的行记录,意向共享锁和意向排他锁锁定的是表
    4. 由于InnoDB存储引擎支持的是行级别的锁,因此意向锁不会阻塞除全表扫描以外的任何请求。
  • 意向共享锁与排他锁冲突,也就是说如果A表中一行被加了排它锁,那么当有select * 这样的全表扫描语句的时候将会加锁失败,因为全表扫描需要对表加意向共享锁,但是表上有排他行锁,于是加锁失败;

  • 意向排他锁与排他锁和共享锁都冲突,同理也就是说如果A表中一行被加了排它锁或者共享锁,那么当有需要加表级的意向排它锁的时候,加锁失败;

3、Mysql 锁

相对其他数据库而言,MySQL的锁机制比较简单,其最显著的特点是不同的存储引擎支持不同的锁机制

下表中罗列出了各存储引擎对锁的支持情况:

存储引擎 表级锁 行级锁 页面锁
MyISAM 支持 不支持 不支持
==InnoDB== 支持 支持 不支持
MEMORY 支持 不支持 不支持
BDB 支持 不支持 支持

MySQL这3种锁的特性可大致归纳如下:

锁类型 特点
表级锁 偏向MyISAM 存储引擎,开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
行级锁 偏向InnoDB 存储引擎,开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
页面锁 开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。

从上述特点可见,很难笼统地说哪种锁更好,只能就具体应用的特点来说哪种锁更合适!

  • 仅从锁的角度来说:表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如Web 应用
  • 行级锁则更适合于有大量按索引条件并发更新少量不同数据,同时又有并查询的应用,如一些在线事务处理(OLTP)系统。

4、MyISAM 表锁

MyISAM 存储引擎只支持表锁,这也是MySQL开始几个版本中唯一支持的锁类型

1、如何加表锁
  • MyISAM 在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁
  • 在执行更新操作(UPDATEDELETEINSERT 等)前,会自动给涉及的表加写锁
  • 这个过程并不需要用户干预,因此用户一般不需要直接用 LOCK TABLE 命令给 MyISAM 表显式加锁

显示加表锁语法:

1
2
3
4
5
6
7
8
-- 加读锁
lock table table_name read;

-- 加写锁
lock table table_name write;

-- 解锁
unlock tables
2、读锁案例

准备环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
create database demo_03 default charset=utf8mb4;

use demo_03;

CREATE TABLE `tb_book` (
`id` INT(11) auto_increment,
`name` VARCHAR(50) DEFAULT NULL,
`publish_time` DATE DEFAULT NULL,
`status` CHAR(1) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=myisam DEFAULT CHARSET=utf8 ;

INSERT INTO tb_book (id, name, publish_time, status) VALUES(NULL,'java编程思想','2088-08-01','1');
INSERT INTO tb_book (id, name, publish_time, status) VALUES(NULL,'solr编程思想','2088-08-08','0');



CREATE TABLE `tb_user` (
`id` INT(11) auto_increment,
`name` VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=myisam DEFAULT CHARSET=utf8 ;

INSERT INTO tb_user (id, name) VALUES(NULL,'令狐冲');
INSERT INTO tb_user (id, name) VALUES(NULL,'田伯光');

客户端一:

1)获得tb_book 表的读锁

1
lock table tb_book read;

2) 执行查询操作

1
select * from tb_book;

1553906896564

可以正常执行 , 查询出数据。

客户端二:

3) 执行查询操作

1
select * from tb_book;

1553907044500

客户端一:

4)查询未锁定的表

1
select name from tb_seller;

1553908913515

客户端二:

5)查询未锁定的表

1
select name from tb_seller;

1553908973840

可以正常查询出未锁定的表;

客户端一:

6) 执行插入操作

1
insert into tb_book values(null,'Mysql高级','2088-01-01','1');

1553907198462

执行插入, 直接报错 , 由于当前tb_book 获得的是 读锁, 不能执行更新操作。

客户端二:

7) 执行插入操作

1
insert into tb_book values(null,'Mysql高级','2088-01-01','1');

1553907403957

当在客户端一中释放锁指令 unlock tables 后 , 客户端二中的 inesrt 语句 , 立即执行 ;

3、写锁案例

客户端一:

1)获得tb_book 表的写锁

1
lock table tb_book write;

2)执行查询操作

1
select * from tb_book;

1553907849829

查询操作执行成功;

3)执行更新操作

1
update tb_book set name = 'java编程思想(第二版)' where id = 1;

1553907875221

更新操作执行成功 ;

客户端二:

4)执行查询操作

1
select * from tb_book ;

1553908019755

当在客户端一中释放锁指令 unlock tables 后 , 客户端二中的 select 语句 , 立即执行 ;

1553908131373

4、结论

锁模式的相互兼容性如表中所示:

image-20210902030926626

由上表可见:

  1. 对MyISAM 表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;
  2. 对MyISAM 表的写操作,则会阻塞其他用户对同一表的读和写操作;

简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁,则既会阻塞读,又会阻塞写

此外,MyISAM 的读写锁调度是==写优先==,这也是MyISAM不适合做写为主的表的存储引擎的原因。因为写锁后,其他线程不能做任何操作,大量的更新会使查询很难得到锁,从而造成永远阻塞。

5、查看锁的争用情况
1
show open tables

image-20210902031019830

  • In_user表当前被查询使用的次数。如果该数为零,则表是打开的,但是当前没有被使用。
  • Name_locked表名称是否被锁定名称锁定用于==取消表==或==对表进行重命名==等操作
1
show status like 'Table_locks%';
  • Table_locks_immediate:指的是能够立即获得表级锁的次数,每立即获取锁,值加1
  • Table_locks_waited:指的是不能立即获取表级锁而需要等待的次数,每等待一次,该值加1,此值高说明存在着较为严重的表级锁争用情况。

5、InnoDB 行锁

1、行锁介绍

行锁特点 :

  • 偏向InnoDB 存储引擎,开销大,加锁慢
  • 会出现死锁
  • 锁定粒度最小,发生锁冲突的概率最低,并发度也最高

InnoDB 与 MyISAM 的最大不同有两点:

  1. 一是==支持事务==;
  2. 二是 ==采用了行级锁==。
2、背景知识
1、事务及其ACID属性

事务是由一组SQL语句组成的逻辑处理单元。

事务具有以下4个特性,简称为事务==ACID属性==。

ACID属性 含义
原子性(Atomicity) 事务是一个原子操作单元,其对数据的修改,要么全部成功,要么全部失败。
一致性(Consistent) 在事务开始和完成时,数据都必须保持一致状态。
隔离性(Isolation) 数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的 “独立” 环境下运行。
持久性(Durable) 事务完成之后,对于数据的修改是永久的。
2、并发事务处理带来的问题
问题 含义
丢失更新(Lost Update) 当两个或多个事务选择同一行,最初的事务修改的值,会被后面的事务修改的值覆盖。
脏读(Dirty Reads) 当一个事务正在访问数据,并且对数据进行了修改,而这种修改还没有提交到数据库中,这时,另外一个事务也访问这个数据,然后使用了这个数据。
不可重复读(Non-Repeatable Reads) 一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现和以前读出的数据不一致。
幻读(Phantom Reads) 一个事务按照相同的查询条件重新读取以前查询过的数据,却发现其他事务插入了满足其查询条件的新数据。
3、事务隔离级别

为了解决上述提到的事务并发问题,数据库提供一定的事务隔离机制来解决这个问题。数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使用事务在一定程度上“串行化” 进行,这显然与“并发” 是矛盾的。

数据库的隔离级别有4个,由低到高依次为:

  • Read uncommitted
  • Read committed(Oracle默认)
  • Repeatable read(Mysql默认)
  • Serializable

这四个级别可以逐个解决脏写、脏读、不可重复读、幻读这几类问题。

隔离级别 丢失更新 脏读 不可重复读 幻读
Read uncommitted ×
Read committed × ×
Repeatable read(默认) × × ×
Serializable × × × ×

备注 : √ 代表可能出现 , × 代表不会出现 。

Mysql 的数据库的默认隔离级别为 Repeatable read , 查看方式:

1
show variables like 'tx_isolation';

1554331600009

4、解决方案解析

解决脏读——使用 读已提交:

首先我们要理解什么是脏读,既然脏读是一个事物读到了另一个事务未提交的数据,那么我们就让它提交后再让别的事物读就好了,读已提交就是改变了释放锁的时机,让事务完成提交后再去释放锁。这样就解决了脏读问题。

解决不可重复读——使用 可重复读:

不可重复读是因为读取过程中有其他事务修改数据,导致读取数据不一致。那我们就要保证一个事务读取数据的时候就让他老老实实读那个数据。可重复读是用一个MVCC(多版本并发控制)机制去解决的

MVCC(多版本并发控制)

MVCC其实就是行级锁的一个升级版。我们都知道数据库中有表锁和行锁,在表锁中读写操作是阻塞的,而MVCC的读写一般是不会阻塞的,这样避免了很多加锁过程。

MVCC具体实现:通过在每行记录后面保存两个隐藏的字段,一个保存的是此行的创建时间,一个保存的是指向旧版本的指针。它们存储的也并不是真的时间,而是系统版本号。就跟我们使用软件都有1.0,2.0这些版本,每个版本有它们自己的特点和数据。MVCC就是在每次开始事务时,都会对应自动递增并保存一个版本号,通过这个版本号去生成对应的一个时间点的数据快照,利用这个快照就可以保证数据读取的一致性。

MVCC把SQL分为两类:一种是快照读,就是普通的select操作,读的就是历史版本的数据。另一种是当前读,比如select … for update,insert,update,delete 读的都是最新的数据,不可重复读就是利用快照保存数据,然后就解决啦!

通俗的说就是:MVCC就是给每次事务操作的数据行都加个字段,代表这次事务的版本,那么如果我现在读取这行数据时,就会通过这个版本生成一个数据快照,那么我这个事务再读的时候,会直接从版本快照中获得数据,相当于帮我们缓存了一份数据,注意喔,我这两次读取都是同一个事务喔!

解决幻读——使用 串行化:

上文说到解决不可重复读用MVCC就可以根据版本保证读取的数据一致,那幻读不是也可以用这个去解决吗?

那么这里又要重申幻读和不可重复读的区别,不可重复读是针对某行数据,幻读是特指查询到记录条数,也就是多条数据的查询。

所以我们使用MVCC + Next-key Lock锁去解决幻读问题。

科普一下Innodb的三种行锁的算法:

  • 行锁:就是对单条记录上锁。
  • 间隙锁:锁定一个范围,但是不会包括记录自己,就是如果要查询一个id=10的数据时,就会把它范围外的加上锁防止插入数据操作,这个就是间隙锁。
  • Next-key Lock:行锁+间隙锁的合体算法,使用它时不仅会把id=10 的范围加上行锁,也会把它间隙加上锁,对于行的查询,使用此法便ok。

具体实现:

当事务执行的是select…for update 时,Next-key Lock对范围加锁,这样事务A执行这个查询当前读语句时,事务B是不能去修改范围内的数据的。

遇到的问题:

我们使用串行化解决幻读会有什么问题产生呢?
我们知道解决幻读使用了间隙锁,那么我们在并发情况下很容易造成死锁!

举个栗子:

事务A、事务B同时执行select * from table where id = 10 for update ,前提是我们没有id=10这条数据,事务A执行时因加上了间隙锁,同时事务B也执行这条语句。这时,事务B如果去添加数据就会因为事务A的间隙锁造成阻塞,事务A再执行添加数据也会因为事务B间隙锁造成阻塞,这样就形成了一个死锁。

所以串行化的并发性不好,那我们实际项目中要合理的选择取舍。

3、InnoDB 的行锁模式

InnoDB 实现了以下两种类型的行锁:

  • 共享锁(S):又称为==读锁==,简称==S锁==,**共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是==只能读不能修改==**。
  • 排他锁(X):又称为==写锁==,简称==X锁==,排他锁就是不能与其他锁并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。

对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁(X);

对于普通SELECT语句,InnoDB不会加任何锁;

可以通过以下语句显示给记录集加共享锁或排他锁 。

1
2
3
4
5
-- 共享锁(S)
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE

-- 排他锁(X)
SELECT * FROM table_name WHERE ... FOR UPDATE
4、案例准备工作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
create table test_innodb_lock(
id int(11),
name varchar(16),
sex varchar(1)
)engine = innodb default charset=utf8;

insert into test_innodb_lock values(1,'100','1');
insert into test_innodb_lock values(3,'3','1');
insert into test_innodb_lock values(4,'400','0');
insert into test_innodb_lock values(5,'500','1');
insert into test_innodb_lock values(6,'600','0');
insert into test_innodb_lock values(7,'700','0');
insert into test_innodb_lock values(8,'800','1');
insert into test_innodb_lock values(9,'900','1');
insert into test_innodb_lock values(1,'200','0');

-- 创建两个单列索引
create index idx_test_innodb_lock_id on test_innodb_lock(id);
create index idx_test_innodb_lock_name on test_innodb_lock(name);
5、行锁基本演示
Session-1 Session-2
image-20210902025707245 关闭自动提交功能 image-20210902025719304 关闭自动提交功能
image-20210902025728670 可以正常的查询出全部的数据 image-20210902025737987可以正常的查询出全部的数据
image-20210902025748385查询id 为3的数据 ; image-20210902025758851查询id为3的数据 ;
image-20210902025807893更新id为3的数据,但是不提交; image-20210902025816158更新id为3 的数据, 出于等待状态
image-20210902025826632通过commit, 提交事务 image-20210902025833220解除阻塞,更新正常进行
以上, 操作的都是同一行的数据,接下来,演示不同行的数据 :
image-20210902025841993 更新id为3数据,正常的获取到行锁 , 执行更新 ; image-20210902025847203由于与Session-1 操作不是同一行,获取当前行锁,执行更新;
6、无索引行锁升级为表锁

条件不具备索引性质(索引失效或不是索引),则会导致行锁变为表锁

如果不通过索引条件检索数据,那么InnoDB将对表中的所有记录加锁,实际效果跟表锁一样

查看当前表的索引:

1
show  index  from test_innodb_lock ;

1554385956215

Session-1 Session-2
关闭事务的自动提交image-20210902025946304 关闭事务的自动提交image-20210902025952429
执行更新语句 :image-20210902025958345 执行更新语句, 但处于阻塞状态:image-20210902030003757
提交事务:image-20210902030008546 解除阻塞,执行更新成功 :image-20210902030013887
执行提交操作 :image-20210902030020857

由于执行更新时 , name字段本来为varchar类型, 我们是作为数组类型使用,存在类型转换,索引失效,最终行锁变为表锁

7、间隙锁危害

当我们用范围条件,而不是使用相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据进行加锁; 对于键值在条件范围内但并不存在的记录,叫做 “间隙(GAP)” , InnoDB也会对这个 “间隙” 加锁,这种锁机制就是所谓的 间隙锁(Next-Key锁)

示例 :

Session-1 Session-2
关闭事务自动提交 image-20210902030147061 关闭事务自动提交image-20210902030153667
根据id范围更新数据image-20210902030200257 此时id=2的数据不存在(间隙)
插入id为2的记录, 出于阻塞状态image-20210902030211686
提交事务 ;image-20210902030217111
解除阻塞 , 执行插入操作 :image-20210902030222274
提交事务 :
8、InnoDB 行锁争用情况
1
show status like 'innodb_row_lock%';

1556455943670

  • Innodb_row_lock_current_waits当前正在等待锁定的数量
  • Innodb_row_lock_time从系统启动到现在锁定总时间长度
  • Innodb_row_lock_time_avg:**==每次等待所花平均时长==**
  • Innodb_row_lock_time_max从系统启动到现在等待最长的一次所花的时间
  • Innodb_row_lock_waits:**==系统启动后到现在总共等待的次数==**

当等待的次数很高,而且每次等待的时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。

9、总结

InnoDB存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面带来了性能损耗可能比表锁会更高一些,但是在整体并发处理能力方面要远远由于MyISAM的表锁的。当系统并发量较高的时候,InnoDB的整体性能和MyISAM相比就会有比较明显的优势。

但是,InnoDB的行级锁同样也有其脆弱的一面,当我们使用不当的时候,可能会让InnoDB的整体性能表现不仅不能比MyISAM高,甚至可能会更差。

优化建议:

  • 尽可能让所有数据检索都能通过索引来完成,避免无索引行锁升级为表锁
  • 合理设计索引,尽量缩小锁的范围
  • 尽可能减少索引条件,及索引范围,避免间隙锁
  • 尽量控制事务大小,减少锁定资源量和时间长度
  • 尽可使用低级别事务隔离(但是需要业务层面满足需求)

15、Mysql 事务隔离的底层实现

1、MVCC(Multi-Version Concurrency Control)多版本并发控制

1、什么是MVCC?

MVCC(Multi-Version Concurrency Control)多版本并发控制,是数据库控制并发访问的一种手段。

在Mysql的InnoDB引擎中就是指在已提交读(READ COMMITTD)和可重复读(REPEATABLE READ)这两种隔离级别下的事务对于SELECT操作会访问版本链中的记录的过程。

这就使得别的事务可以修改这条记录,反正每次修改都会在版本链中记录。SELECT可以去版本链中拿记录,这就实现了读-写,写-读的并发执行,提升了系统的性能。

总结:

  • MVCC只在 读已提交(RC)可重复度(RR) 这两种事务隔离级别下才有效
  • 数据库引擎(InnoDB) 层面实现的,用来处理读写冲突的手段(不用加锁),提高访问性能
2、MVCC的底层原理——版本链和一致性视图
1、版本链
  • 版本链是一条链表,链接的是每条数据曾经的修改记录

那么这个版本链又是如何形成的呢,每条数据又是靠什么链接起来的呢?

其实是这样的,对于InnoDB存储引擎的表来说,它的聚簇索引记录包含两个隐藏字段:

  • trx_id:==存储修改此数据的事务id,只有这个事务操作了某些表的数据后当更改操作发生的时候(update,delete,insert),才会分配唯一的事务id,并且此事务id是递增的==
  • roll_pointer:指针,==指向上一次修改的记录==
  • row_id(非必须):当有==主键==或者有==不允许为null的unique键==时,不包含此字段

假如说当前数据库有一条这样的数据,假设是事务ID为100的事务插入的这条数据,那么此条数据的结构如下:

img

后来,事务200,事务300,分别来修改此数据:

时间T trx_id 200 trx_id 300
T1 开始事务 开始事务
T2 更改名字为A
T3 更改名字为B
T4 提交事务 更改名字为C
T6 提交事务

所以此时的版本链如下:

img

我们每更改一次数据,就会插入一条undo日志,并且记录的roll_pointer指针会指向上一条记录,如图所示:(注意:插入操作的undo日志没有roll_pointer这个属性,因为它没有老版本)

  1. 第一条数据是小杰,事务ID为100
  2. 事务ID为200的事务将名称从小杰改为了A
  3. 事务ID为200的事务将名称从A又改为了B
  4. 事务ID为300的事务将名称从B又改为了C

所以串成的链表就是 C -> B -> A -> 小杰 (从最新的数据到最老的数据)

2、一致性视图(ReadView)

需要判断版本链中的哪个版本是是当前事务可见的,因此有了一致性视图的概念。其中有四个属性比较重要:

  • m_ids:在生成ReadView时,当前活跃的读写事务的事务id列表
    • 当前活跃的读写事务就是begin了还未提交的事务
  • min_trx_id:m_ids的最小值
  • max_trx_id:m_ids的最大值+1
  • creator_trx_id:生成该事务的事务id,单纯开启事务是没有事务id的,默认为0,creator_trx_id是0

版本链中的当前版本是否可以被当前事务可见的要根据这四个属性按照以下几种情况来判断:

  • 当 trx_id = creator_trx_id 时:当前事务可以看见自己所修改的数据, 可见
  • 当 trx_id < min_trx_id 时:生成此数据的事务已经在生成readView前提交了, 可见
  • 当 trx_id >= max_trx_id 时:表明生成该数据的事务是在生成ReadView后才开启的, 不可见
  • 当 min_trx_id <= trx_id < max_trx_id 时
    • trx_id 在 m_ids 列表里面 :生成ReadView时,活跃事务还未提交,不可见
    • trx_id 不在 m_ids 列表里面 :事务在生成readView前已经提交了,可见

img

如果某个版本数据对当前事务不可见,那么则要顺着版本链继续向前寻找下个版本,继续这样判断,以此类推。

3、对于RR(可重复读)和RC(读已提交)在生成一致性视图时机的区别

对于事务的隔离级别:RR(可重复读)和RC(读已提交)在生成一致性视图的时机是不一样的:

  • 读提交(read committed RC) 是在每一次select的时候生成ReadView的

  • 可重复读(repeatable read RR)是在第一次select的时候生成ReadView的

示例:

多个事务如下执行,我们通过这个例子来分析当数据库隔离级别为RC和RR的情况下,当时读数据的一致性视图版本链,也就是MVCC,分别是怎么样的。

  • 假设数据库中有一条初始数据 姓名是小杰,id是1 (id,姓名,trx_id,roll_point),插入此数据的事务id是1
  • 尤其要指出的是,只有这个事务操作了某些表的数据后当更改操作发生的时候(update,delete,insert),才会分配唯一的事务id,并且此事务id是递增的,单纯开启事务是没有事务id的,默认为0,creator_trx_id是0。
  • 以下例子中的A,B,C的意思是将姓名更改为A,B,C,读也是读取当前时刻的姓名,默认全都开启事务,并且此事务都经历过某些操作产生了事务id
时间 事务100 事务200 事务300 事务400
T1 A
T2 B
T3 C
T4
T5 提交
T6 D
T7
T8 E
T9 提交
T10

1、读已提交(RC)与MVCC

  • 一个事务提交之后,它做的变更才会被其他事务看到
    • ==每次读==的时候,ReadView(一致性视图)都会重新生成
  1. 当T1时刻时,事务100修改名字为A
  2. 当T2时刻时,事务100修改名字为B
  3. 当T3时刻时,事务200修改名字为C
  4. 当T4时刻时,事务300开始读取名字

此时这条数据的版本链如下:(同颜色代表是同一事务内的操作)

img

此时T4时刻事务300要读了,究竟会读到什么数据

当前最近的一条数据是,C,事务200修改的,还记得我们前文说的一致性视图的几个属性和按照什么规则判断这个数据能不能被当前事务读。我们就分析这个例子。

此时 (生成一致性视图ReadView

  • m_ids 是{100,200}: 当前活跃的读写事务的事务id列表
  • min_trx_id 是 100: m_ids的最小值
  • max_trx_id 是 201: m_ids的最大值+1

当前数据的trx_id(事务id)是 200,符合min_trx_id<=trx_id<max_trx_id 此时需要判断:

  • trx_id 是否在m_ids活跃事务列表里面,
    • 一看,活跃事务列表里面是{100,200},只有两个事务活跃,而此时的trx_id是200,则trx_id在活跃事务列表里面,
  • 活跃事务列表代表还未提交的事务,所以该版本数据不可见,就要根据roll_point指针指向上一个版本,
  • 继续这样的判断,上一个版本事务id是100,数据是B,发现100也在活跃事务列表里面,所以不可见,
  • 继续找到上个版本,事务是100,数据是A,发现是同样的情况,
  • 继续找到上个版本,发现事务是1,数据是小杰,1小于100,trx_id<min_trx_id,代表生成这个数据的事务已经在生成ReadView前提交了,此数据可以被读到。所以读取的数据就是小杰

分析完第一个读,我们继续向下分析:

  1. 当T5时刻时,事务100提交
  2. 当T6时刻时,事务300将名字改为D
  3. 当T7时刻时,事务400读取当前数据

此时这条数据的版本链如下:

img

此时 (重新生成一致性视图ReadView

  • m_ids 是{200,300}: 当前活跃的读写事务的事务id列表

  • min_trx_id 是 200: m_ids的最小值

  • max_trx_id 是 301: m_ids的最大值+1

  • 当前数据事务id是300,数据为D,符合min_trx_id<=trx_id<max_trx_id

  • 此时需要判断数据是否在活跃事务列表里,300在这里面,所以就是还未提交的事务就是不可见,所以就去查看上个版本的数据,

  • 上个版本事务id是200,数据是C,也在活跃事务列表里面,也不可见,继续向上个版本找,

  • 上个版本事务id是100,数据是B,100小于min_trx_id,就代表,代表生成这个数据的事务已经在生成ReadView前提交了,此数据可见,所以读取出来的数据就是B

分析完第二个读,我们继续向下分析:

  1. 当T8时刻时,事务200将名字改为E
  2. 当T9时刻时,事务200提交
  3. 当T10时刻时,事务300读取当前数据

此时这条数据的版本链如下:

img

此时 (重新生成一致性视图ReadView

  • m_ids 是[300]: 当前活跃的读写事务的事务id列表
  • min_trx_id 是 300: m_ids的最小值
  • max_trx_id 是 301: m_ids的最大值+1

当前事务id是200,200<min_trx_id ,代表生成这个数据的事务已经在生成ReadView前提交了,此数据可见,所以读出的数据就是E.

总结:当隔离级别是读已提交RC的情况下,每次读都会重新生成 一致性视图(ReadView)

  • T4时刻 事务300读取到的数据是小杰
  • T7时刻 事务400读取到的数据是B
  • T10时刻 事务300读取到的数据是E

2、可重复读(RR)与MVCC

  • 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的
    • 所以对于事务300来讲,它分别在T4和T10的时候,读取数据,但是它的一致性视图,用的永远都是第一次读取时的视图,就是T3时刻产生的一致性视图

==RR和RC的版本链是一样的==,但是判断当前数据可见与否用到的一致性视图不一样

在此可重复读RR隔离级别下:

  1. T4时刻时事务300第一次读时的分析和结果与RC都一样,可以见上文分析与结果
  2. T7时刻时事务400第一次读时的分析和结果与RC都一样,可以见上文分析与结果
  3. T10时刻时事务300第二次读时的一致性视图和第一次读时的一样,所以此时到底读取到什么数据就要重新分析了

此时 (用的是第一次读时生成的一致性视图ReadView

  • m_ids 是[100,200]: 当前活跃的读写事务的事务id列表
  • min_trx_id 是 100: m_ids的最小值
  • max_trx_id 是 201: m_ids的最大值+1

此时的版本链是:

img

当前数据的事务id是200,数据是E,在当前事务活跃列表里面,所以数据不可见,根据回滚指针找到上个版本,发现事务id是300,当前事务也是300,可见,所以读取的数据是D

总结:当隔离级别是可重复读RR的情况下,每次读都会用第一次读取数据时生成的一致性视图(ReadView)

  • T4时刻 事务300读取到的数据是小杰
  • T7时刻 事务400读取到的数据是B
  • T10时刻 事务300读取到的数据是D

2、关于间隙锁与Next-Key Lock

1、简介

科普一下Innodb的三种行锁的算法:

  • 行锁:就是对单条记录上锁。记录锁(Record Lock)
  • 间隙锁:gap锁,又称为间隙锁。存在的主要目的就是为了防止在可重复读的事务级别下,出现幻读问题
  • Next-key Lock:Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁。innodb默认的锁就是Next-Key locks。是行锁和gap锁的组合。
    • 在可重复读的事务级别下面,普通的select读的是快照,不存在幻读情况,但是如果加上for update的话,读取是已提交事务数据,Next-key Lock锁保证for update情况下,不出现幻读。
2、那么gap锁到底是如何加锁的呢?

假如是for update级别操作,先看看几条总结的何时加锁的规则:

  • 唯一索引
    • 精确等值检索:Next-Key Locks就退化为记录锁,不会加gap锁
    • 范围检索会锁住where条件中相应的范围,范围中的记录以及间隙,换言之就是加上记录锁和 gap 锁(至于区间是多大稍后讨论)。
    • 不走索引检索:全表间隙加gap锁、全表记录加记录锁——>升级为表锁
  • 非唯一索引
    • 精确等值检索:Next-Key Locks会对间隙加gap锁(至于区间是多大稍后讨论),以及对应检索到的记录加记录锁
    • 范围检索会锁住where条件中相应的范围,范围中的记录以及间隙,换言之就是加上记录锁和gap 锁(至于区间是多大稍后讨论)。
    • 非索引检索全表间隙gap lock,全表记录record lock
3、相关实例
1、建表
1
2
3
4
5
6
7
8
9
10
11
12
13
create table gap_table
(
letter varchar(2) default '' not null
primary key,
num int null
);

create index gap_table_num_uindex
on gap_table (num);

INSERT INTO gap_table (letter, num) VALUES ('d', 3);
INSERT INTO gap_table (letter, num) VALUES ('g', 6);
INSERT INTO gap_table (letter, num) VALUES ('j', 8);

表结构:主键简单点是字符,属性列只有一个数字,是非唯一索引。

2、无gap锁

假如没有gap锁,也就是把事务级别调到读已提交,执行以下两个session

session1 session2
select * from gap_table where num=6 for update;结果是一条
INSERT INTO gap_table (letter, num) VALUES (’’, 6);
select * from gap_table where num=6 for update;结果是二条,出现幻读
3、非唯一索引等值检索gap锁

假如有gap锁,演示一个非唯一索引等值检索gap锁。也就是把事务级别调到可重复读,执行以下两个session

session1 session2
select * from gap_table where num=6 for update;结果是一条。
INSERT INTO gap_table (letter, num) VALUES (’’, 6);gap锁住间隙,阻塞无法插入数据。
select * from gap_table where num=6 for update;结果是一条。不出现幻读
4、唯一索引(主键)范围检索gap锁

假如有gap锁,演示一个唯一索引范围检索gap锁。也就是把事务级别调到可重复读,执行以下两个session

session1 session2
select * from gap_table where letter>’d’ for update;结果是两条。
INSERT INTO gap_table (letter, num) VALUES (‘z’, 10);gap锁住间隙,阻塞无法插入数据。
select * from gap_table where letter>’d’ for update;结果是两条。不出现幻读
4、gap锁是如何锁区间?

经过上面的演示可以知道gap锁的基本作用就是保证可重复读的情况下不出现幻读

那么还有一点就是gap是按照什么原则进行锁的呢?

要了解gap锁的原则,需要先了解innodb中索引树的结构。

下面一张图片描述了在innodb中,索引的数据结构是如何组织的:

img

从上面的图片可以看出,索引结构分为主索引树和辅助索引树,辅助索引树的叶子节点中包含了主键数据,主键数据影响着叶子节点的排序,gap锁的关键就是锁住索引树的叶子节点之间的间隙,不让新的记录插入到间隙之中,说起来可能拗口,下面画图分析。

1、非唯一索引gap锁原则分析

假如还是使用一开始演示的表结构和数据,那么当前的辅助索引树(数字列)叶子节点的排序结构应该如下。

img

假如执行以下sql的话:

1
INSERT INTO gap_table (letter, num) VALUES ('k', 6);

辅助索引树的叶子节点结构变为以下图片结构,k大于g,所以(6,k)排在后面,我们先把(6,k)这条数据删除,方便后面演示:

img

了解了以上的规则,我们进行实际操作演示gap锁区间原则,从而推测锁住哪些区间。

情况1

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行成功

1
INSERT INTO gap_table (letter, num) VALUES ('a', 3);

按照排序规则,叶子节点插入结构如下:

在这里插入图片描述

情况2

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行失败

1
INSERT INTO gap_table (letter, num) VALUES ('e', 3);

按照排序规则,叶子节点应该插入如下地方,但是因为区间被锁插入失败。

img

情况3

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行失败

1
INSERT INTO gap_table (letter, num) VALUES ('h', 6);

按照排序规则,叶子节点应该插入如下地方,但是因为区间被锁插入失败。

在这里插入图片描述

情况4

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行失败

1
INSERT INTO gap_table (letter, num) VALUES ('h', 7);

按照排序规则,叶子节点应该插入如下地方,但是因为区间被锁插入失败。

在这里插入图片描述

情况5

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行成功

1
INSERT INTO gap_table (letter, num) VALUES ('h', 9);

按照排序规则,插入在未锁区间就能插入成功。

在这里插入图片描述

1、总结

当session1执行以下语句:

1
select * from gap_table where num=6 for update

锁住的区间如图所示。

在这里插入图片描述

按照B+索引树排序规则,计算好叶子节点插入位置时,在被gap锁住的区间段内,不能插入任何数据,只有在gap锁释放时才能进行插入。

在上面的各种情况中锁住的区间其实是(3,d)到(6,g)和(6,g)到(8,j),落到这个区间段的叶子节点都是无法插入的。主键也作为一个信息参与到叶子节点的排序规则中。这里面边界都是开区间,插入(3,d),(8,j)的数据会报错主键重复而不是lock等待超时。

2、唯一索引或者非唯一索引范围检索gap锁原则分析

另一种会出现gap锁的情况就是使用索引时,用到范围检索,就会出现gap 锁。

使用以下表结构:

1
2
3
4
5
6
7
8
9
10
11
create table gap_tbz
(
id int default 0 not null
primary key,
name varchar(11) null
);

INSERT INTO test.gap_tbz (id, name) VALUES (1, 'a');
INSERT INTO test.gap_tbz (id, name) VALUES (5, 'h');
INSERT INTO test.gap_tbz (id, name) VALUES (8, 'm');
INSERT INTO test.gap_tbz (id, name) VALUES (11, 'ds');

情况1

分别有两个session,session1执行以下语句:

1
select * from gap_tbz where id > 5 for update;

session2执行以下sql,执行失败

1
insert into gap_tbz values(6,'cc');

按照排序规则,这里应该是在主键索引树检索,叶子节点插入结构如下。由于session1执行了范围的for update sql语句,因此范围内添加了gap锁,gap锁的区间是id在(5,+无限)

在这里插入图片描述

当执行插入的id范围在5之前,如下sql,能够执行成功

1
insert into gap_tbz values(4,'cc');

但若此时将session1的sql修改为:

1
select * from gap_tbz where id >= 5 for update;

此时gap锁的区间为id区间(1,5)和(5,+无限),也就是说以下sql会执行失败:

1
insert into gap_tbz values(4,'cc');
  • 这也同时反映了间隙锁Gap的危害:在数据行2/3/4进行插入操作不会影响session1的sql的执行效果,但是此时数据行2/3/4却因为被加上了间隙锁而导致不能实现插入操作

情况2

分别有两个session,session1执行以下语句:

1
select * from gap_tbz where id > 5 and id < 11 for update;

session2执行以下sql,执行失败

1
2
3
4
5
6
7
#以下报错 lock等待超时
insert into gap_tbz values(11,'cc');

#以下报错 主键重复
insert into gap_tbz values(5,'cc');

#从两种报错来看也可以看出gap锁区间是左开右闭

按照排序规则,这里应该是在主键索引树检索,由于session1执行了范围的for update sql语句,因此范围内添加了gap锁,gap锁的区间是id在(5,11]**,唯一索引gap锁区间是左开右闭**。

2、总结

当session1执行以下语句:

1
select * from gap_tbz where id > 5 for update;

此时gap锁的区间是id在(5,+无限)

在这里插入图片描述

当session1执行以下语句:

1
select * from gap_tbz where id >= 5 for update;

此时gap锁的区间为id区间(1,5)和(5,+无限)

当session1执行以下语句:

1
select * from gap_tbz where id > 5 and id < 11 for update;

此时gap锁的区间是id在(5,11],唯一索引gap锁区间是左开右闭

3、思考(在session1执行for update语句)

假如条件是一个非索引列,那么如何处理?

  • 假如是非索引列,那么将会全表间隙加上gap锁。
  • 如果此时整个锁没有数据的话,那么整个表都会加上一个间隙锁

条件是唯一索引等值检索且记录不存在的情况,会使用gap lock?

  • 我们要考虑,gap lock是防止幻读,那么尝试思考,使用唯一索引所谓条件查找数据for update,如果对应的记录不存在的话,是无法使用行锁的。
  • 这时候,会使用gap lock来锁住区间,保证记录不会插入,防止出现幻读。

使用了间隙锁可以在可重复读的隔离级别下解决幻读问题,那么间隙锁Gap在并发情况下很容易造成死锁问题

  • 字段id为不是主键,可以查询出多个值,数据库没有id=10这些数据

  • 事务A、事务B同时执行select * from table where id = 10 for update 语句

  • 事务A执行时SQL语句时加上了间隙锁,同时添加数据一条数据

    1
    insert into table(id,name) values(10,"zs"); 

    此时事务A对除了(10,”zs”)这条数据以外的数据都设置了间隙锁

  • 事务B 执行select * from table where id = 10 for update 语句查出(10,”zs”)这条数据,并对除了(10,”zs”)这条数据以外的数据都设置了间隙锁

  • 此时事务A如果去添加数据一条其他数据,如(10,”ls”)或者事务B如果去添加数据一条其他数据,如(10,”ww”)

  • 事务A与事务B都会因为对方的间隙锁而导致插入阻塞,从而导致死锁。

  • 使用Next-Key Lock 可以解决以上问题:因为使用Next-Key Lock可以把(10,”zs”)这条数据也加上锁

4、总结

间隙锁的危害

  1. 在数据行2/3/4(间隙)进行插入操作不会影响session1的sql的执行效果的情况下,此时数据行2/3/4(间隙)却因为被加上了间隙锁而导致不能实现插入操作
  2. 在使用for update的情况下,会有大面积的间隙锁产生,此时其他连接不能操作当前数据
  3. 死锁问题

Next-Key Lock的作用

  1. 非唯一索引精确等值检索时,Next-Key Locks会对间隙加gap锁,以及对应检索到的记录加记录锁。防止出现幻读问题
  2. 解决死锁问题
  3. Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁。

补充:关于Next-Key Lock加的记录锁是读锁还是写锁的问题

经过测试得:在Mysql8,存储引擎为InnoDB的情况下

  • 如果使用的select……lock in share mode进行搜索,使用的是共享锁,则记录锁是读锁
  • 如果使用的select……for update进行搜索,使用的是独占锁,则记录锁是写锁

16、常用SQL技巧

1、常见通用的Join查询

1、SQL执行顺序

编写顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SELECT DISTINCT
<select list>
FROM
<left_table> <join_type>
JOIN
<right_table> ON <join_condition>
WHERE
<where_condition>
GROUP BY
<group_by_list>
HAVING
<having_condition>
ORDER BY
<order_by_condition>
LIMIT
<limit_params>

执行顺序(随着Mysql版本的更新换代,其优化器也在不断的升级,优化器会分析不同执行顺序产生的性能消耗不同而动态调整执行顺序)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM	<left_table>

ON <join_condition>

<join_type> JOIN <right_table>

WHERE <where_condition>

GROUP BY <group_by_list>

HAVING <having_condition>

SELECT DISTINCT <select list>

ORDER BY <order_by_condition>

LIMIT <limit_params>

img

2、Join图

img

共有与独有(理解):

  • 共有:满足 a.deptid = b.id 的叫共有
  • A独有:A 表中所有不满足 a.deptid = b.id 连接关系的数据

同时参考 join 图

1、案例准备
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
CREATE TABLE `t_dept` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`deptName` VARCHAR(30) DEFAULT NULL,
`address` VARCHAR(40) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE `t_emp` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(20) DEFAULT NULL,
`age` INT(3) DEFAULT NULL,
`deptId` INT(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_dept_id` (`deptId`)
#CONSTRAINT `fk_dept_id` FOREIGN KEY (`deptId`) REFERENCES `t_dept` (`id`)
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

INSERT INTO t_dept(deptName,address) VALUES('华山','华山');
INSERT INTO t_dept(deptName,address) VALUES('丐帮','洛阳');
INSERT INTO t_dept(deptName,address) VALUES('峨眉','峨眉山');
INSERT INTO t_dept(deptName,address) VALUES('武当','武当山');
INSERT INTO t_dept(deptName,address) VALUES('明教','光明顶');
INSERT INTO t_dept(deptName,address) VALUES('少林','少林寺');
INSERT INTO t_emp(NAME,age,deptId) VALUES('风清扬',90,1);
INSERT INTO t_emp(NAME,age,deptId) VALUES('岳不群',50,1);
INSERT INTO t_emp(NAME,age,deptId) VALUES('令狐冲',24,1);
INSERT INTO t_emp(NAME,age,deptId) VALUES('洪七公',70,2);
INSERT INTO t_emp(NAME,age,deptId) VALUES('乔峰',35,2);
INSERT INTO t_emp(NAME,age,deptId) VALUES('灭绝师太',70,3);
INSERT INTO t_emp(NAME,age,deptId) VALUES('周芷若',20,3);
INSERT INTO t_emp(NAME,age,deptId) VALUES('张三丰',100,4);
INSERT INTO t_emp(NAME,age,deptId) VALUES('张无忌',25,5);
INSERT INTO t_emp(NAME,age,deptId) VALUES('韦小宝',18,null);
2、7种JOIN
  1. A、B两表共有(inner join)

    1
    select * from t_emp a inner join t_dept b on a.deptId = b.id;
  2. A、B两表共有 + A的独有(a left join b)

    1
    select * from t_emp a left join t_dept b on a.deptId = b.id;
  3. A、B两表共有 + B的独有(a right join b)

    1
    select * from t_emp a right join t_dept b on a.deptId = b.id;
  4. A的独有(a left join b + on a.Id = b.id + where b.id is null)

    1
    select * from t_emp a left join t_dept b on a.deptId = b.id where b.id is null; 
  5. B的独有(a right join b + on a.Id = b.id where a.Id is null)

    1
    select * from t_emp a right join t_dept b on a.deptId = b.id where a.deptId is null;
  6. AB全有(a full outer join b)

    1
    2
    3
    4
    5
    6
    7
    8
    -- MySQL Full Join的实现 因为MySQL不支持FULL JOIN,下面是替代方法:
    -- left join + union(可去除重复数据)+ right join

    SELECT * FROM t_emp A LEFT JOIN t_dept B ON A.deptId = B.id

    UNION

    SELECT * FROM t_emp A RIGHT JOIN t_dept B ON A.deptId = B.id

    这里因为要联合的缘故,不能考虑到小表驱动大表的情况。只能用right join。要保证查询出来的数字要一致。

  7. A的独有 + B的独有(a full outer join b + on a.Id = b.id + where a.Id is null or b,id = null)

    1
    2
    3
    4
    5
    select * FROM t_emp A LEFT JOIN t_dept B ON A.deptId = B.id WHERE B.`id` IS NULL

    UNION

    SELECT * FROM t_emp A RIGHT JOIN t_dept B ON A.deptId = B.id WHERE A.`deptId` IS NULL;
3、子查询与join两者区别

思想上的区别:

  • 子查询理解:
    1. 先知道需要查询并将数据拿出来(若from 后的表也是一个子查询结果)。
    2. 在去寻找满足判断条件的数据(where,on,having 后的参数等)。而这些查询条件通常是通过子查询获得的。
    3. 子查询是一种根据结果找条件的倒推的顺序。比较好理解与判断
  • join理解:
    1. 执行完第一步后的结果为一张新表。
    2. 在将新表与 t_emp 进行下一步的 left join 关联。
    3. 先推出如何获得条件,再像算数题一样一步一步往下 join。可以交换顺序,但只能是因为条件间不相互关联时才能交换顺序。
    4. join 比 子查询难一点
    5. join 能用到索引,但是子查询出来的表会使索引失效。

2、正则表达式使用

正则表达式(Regular Expression)是指一个用来描述或者匹配一系列符合某个句法规则的字符串的单个字符串。

符号 含义
^ 在字符串开始处进行匹配
$ 在字符串末尾处进行匹配
. 匹配任意单个字符, 包括换行符
[…] 匹配出括号内的任意字符
[^…] 匹配不出括号内的任意字符
a* 匹配零个或者多个a(包括空串)
a+ 匹配一个或者多个a(不包括空串)
a? 匹配零个或者一个a
a1|a2 匹配a1或a2
a(m) 匹配m个a
a(m,) 至少匹配m个a
a(m,n) 匹配m个a 到 n个a
a(,n) 匹配0到n个a
(…) 将模式元素组成单一元素
1
2
3
4
5
select * from emp where name regexp '^T';

select * from emp where name regexp '2$';

select * from emp where name regexp '[uvw]';

3、MySQL 常用函数

数字函数

函数名称 作 用
ABS 求绝对值
SQRT 求二次方根
MOD 求余数
CEIL 和 CEILING 两个函数功能相同,都是返回不小于参数的最小整数,即向上取整
FLOOR 向下取整,返回值转化为一个BIGINT
RAND 生成一个0~1之间的随机数,传入整数参数是,用来产生重复序列
ROUND 对所传参数进行四舍五入
SIGN 返回参数的符号
POW 和 POWER 两个函数的功能相同,都是所传参数的次方的结果值
SIN 求正弦值
ASIN 求反正弦值,与函数 SIN 互为反函数
COS 求余弦值
ACOS 求反余弦值,与函数 COS 互为反函数
TAN 求正切值
ATAN 求反正切值,与函数 TAN 互为反函数
COT 求余切值

字符串函数

函数名称 作 用
LENGTH 计算字符串长度函数,返回字符串的字节长度
CONCAT 合并字符串函数,返回结果为连接参数产生的字符串,参数可以使一个或多个
INSERT 替换字符串函数
LOWER 将字符串中的字母转换为小写
UPPER 将字符串中的字母转换为大写
LEFT 从左侧字截取符串,返回字符串左边的若干个字符
RIGHT 从右侧字截取符串,返回字符串右边的若干个字符
TRIM 删除字符串左右两侧的空格
REPLACE 字符串替换函数,返回替换后的新字符串
SUBSTRING 截取字符串,返回从指定位置开始的指定长度的字符换(下标从1开始)
REVERSE 字符串反转(逆序)函数,返回与原始字符串顺序相反的字符串

日期函数

函数名称 作 用
CURDATE 和 CURRENT_DATE 两个函数作用相同,返回当前系统的日期值
CURTIME 和 CURRENT_TIME 两个函数作用相同,返回当前系统的时间值
NOW 和 SYSDATE 两个函数作用相同,返回当前系统的日期和时间值
MONTH 获取指定日期中的月份
MONTHNAME 获取指定日期中的月份英文名称
DAYNAME 获取指定曰期对应的星期几的英文名称
DAYOFWEEK 获取指定日期对应的一周的索引位置值
WEEK 获取指定日期是一年中的第几周,返回值的范围是否为 0〜52 或 1〜53
DAYOFYEAR 获取指定曰期是一年中的第几天,返回值范围是1~366
DAYOFMONTH 获取指定日期是一个月中是第几天,返回值范围是1~31
YEAR 获取年份,返回值范围是 1970〜2069
TIME_TO_SEC 将时间参数转换为秒数
SEC_TO_TIME 将秒数转换为时间,与TIME_TO_SEC 互为反函数
DATE_ADD 和 ADDDATE 两个函数功能相同,都是向日期添加指定的时间间隔
DATE_SUB 和 SUBDATE 两个函数功能相同,都是向日期减去指定的时间间隔
ADDTIME 时间加法运算,在原始时间上添加指定的时间
SUBTIME 时间减法运算,在原始时间上减去指定的时间
DATEDIFF 获取两个日期之间间隔,返回参数 1 减去参数 2 的值
DATE_FORMAT 格式化指定的日期,根据参数返回指定格式的值
WEEKDAY 获取指定日期在一周内的对应的工作日索引

聚合函数

函数名称 作用
MAX 查询指定列的最大值
MIN 查询指定列的最小值
COUNT 统计查询结果的行数
SUM 求和,返回指定列的总和
AVG 求平均值,返回指定列数据的平均值

17、MySql中常用工具

1、mysql

该mysql不是指mysql服务,而是指mysql的客户端工具。

语法:

1
mysql [options] [database]
1、连接选项

参数[options]:

  • -u, –user=name:指定用户名
  • -p, –password[=name]:指定密码
  • -h, –host=name:指定服务器IP或域名
  • -P, –port=#:指定连接端口

示例:

1
2
3
mysql -h 127.0.0.1 -P 3306 -u root -p

mysql -h127.0.0.1 -P3306 -uroot -p2143
2、执行选项
1
2
# 执行SQL语句并退出
-e, --execute=name

此选项可以在Mysql客户端执行SQL语句,而不用连接到MySQL数据库再执行,对于一些批处理脚本,这种方式尤其方便。

示例:

1
mysql -uroot -p2143 db01 -e "select * from tb_book";

1555325632715

2、mysqladmin

mysqladmin 是一个执行管理操作的客户端程序。可以用它来==检查服务器的配置和当前状态==、==创建并删除数据库==等。也是对于一些批处理脚本,这种方式尤其方便。

可以通过 : mysqladmin --help 指令查看帮助文档

1555326108697

示例 :

1
2
3
4
5
mysqladmin -uroot -p2143 create 'test01';  

mysqladmin -uroot -p2143 drop 'test01';

mysqladmin -uroot -p2143 version;

3、mysqlbinlog

由于服务器生成的二进制日志文件以二进制格式保存,所以如果想要检查这些文本的文本格式,就会使用到mysqlbinlog 日志管理工具。

语法 :

1
mysqlbinlog [options]  log-files1 log-files2 ...

选项[options]:

  • -d, –database=name:指定数据库名称,只列出指定的数据库相关操作。
  • -o, –offset=#:忽略掉日志中的前n行命令。
  • -r,–result-file=name:将输出的文本格式日志输出到指定文件。
  • -s, –short-form:显示简单格式, 省略掉一些信息。
  • –start-datatime=date1 –stop-datetime=date2:指定日期间隔内的所有日志。
  • –start-position=pos1 –stop-position=pos2:指定位置间隔内的所有日志。

4、mysqldump

mysqldump 客户端工具用来==备份数据库==或==在不同数据库之间进行数据迁移==。

备份内容包含创建表,及插入表的SQL语句。

语法 :

1
2
3
4
5
mysqldump [options] db_name [tables]

mysqldump [options] --database/-B db1 [db2 db3...]

mysqldump [options] --all-databases/-A
1、连接选项

参数[options]:

  • -u, –user=name:指定用户名
  • -p, –password[=name]:指定密码
  • -h, –host=name:指定服务器IP或域名
  • -P, –port=#:指定连接端口
2、输出内容选项

参数[options]:

  • –add-drop-database:在每个数据库创建语句前加上 Drop database 语句(如果数据库存在就删除旧数据库)
  • –add-drop-table:在每个表创建语句前加上 Drop table 语句 , 默认开启 ; 不开启 (--skip-add-drop-table)
  • -n, –no-create-db:不包含数据库的创建语句
  • -t, –no-create-info:不包含数据表的创建语句
  • -d –no-data:不包含数据
  • -q –quick:加上 -q 后,不会把SELECT出来的结果放在buffer中,而是直接dump到标准输出中,顶多只是buffer当前行结果,正常情况下是不会超过 max_allowed_packet 限制的,它默认情况下是开启的。
  • -R:备份存储过程等
  • -T, –tab=name:自动生成两个文件:
    • 一个.sql文件,创建表结构的语句;
    • 一个.txt文件,数据文件,相当于select into outfile

示例:

1
2
3
mysqldump -uroot -p2143 db01 tb_book --add-drop-database --add-drop-table > a

mysqldump -uroot -p2143 -T /tmp test city

image-20210902222238916

5、mysqlimport/source

mysqlimport 是客户端数据导入工具,用来导入mysqldump 加 -T 参数后导出的文本文件

语法:

1
mysqlimport [options]  db_name  textfile1  [textfile2...]

示例:

1
mysqlimport -uroot -p2143 test /tmp/city.txt

如果需要导入sql文件,可以使用mysql中的source 指令 :

1
source /root/tb_book.sql

6、mysqlshow

mysqlshow 客户端对象查找工具,用来很快地查找存在哪些数据库、数据库中的表、表中的列或者索引

语法:

1
mysqlshow [options] [db_name [table_name [col_name]]]

参数[options]:

  • –count:显示数据库及表的统计信息(数据库,表 均可以不指定)
  • -i:显示指定数据库或者指定表的状态信息

示例:

1
2
3
4
5
6
7
8
#查询每个数据库的表的数量及表中记录的数量
mysqlshow -uroot -p2143 --count

#查询test库中每个表中的字段书,及行数
mysqlshow -uroot -p2143 test --count

#查询test库中book表的详细情况
mysqlshow -uroot -p2143 test book --count

18、Mysql 日志

在任何一种数据库中,都会有各种各样的日志,记录着数据库工作的方方面面,以帮助数据库管理员追踪数据库曾经发生过的各种事件。MySQL 也不例外。

在 MySQL 中,有 4 种不同的日志,分别是:

  • 错误日志
  • 二进制日志(BINLOG 日志)
  • 查询日志
  • 慢查询日志

这些日志记录着数据库在不同方面的踪迹。

全局查询日志:

配置启用:

在mysql的my.cnf中,设置如下:

1
2
3
4
5
6
#开启
general_log=1
# 记录日志文件的路径
general_log_file=/path/logfile
#输出格式
log_output=FILE

编码启用:

1
2
3
4
5
6
7
set global general_log=1;

-- 全局日志可以存放到日志文件中,也可以存放到Mysql系统表中。存放到日志中性能更好一些,存储到表中
set global log_output='TABLE';

-- 此后 ,你所编写的sql语句,将会记录到mysql库里的general_log表,可以用下面的命令查看
select * from mysql.general_log;

1、错误日志

错误日志是 MySQL 中最重要的日志之一,它记录了当 mysqld 启动和停止时,以及服务器在运行过程中发生任何严重错误时的相关信息。当数据库出现任何故障导致无法正常使用时,可以首先查看此日志

该日志是默认开启的 , 默认存放目录为 mysql 的数据目录(var/lib/mysql), 默认的日志文件名为 hostname.err(hostname是主机名)。

查看日志位置指令:

1
show variables like 'log_error%';

1553993244446

查看日志内容:

1
tail -f /var/lib/mysql/xaxh-server.err

1553993537874

2、二进制日志

1、概述

二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但是不包括数据查询语句

此日志对于灾难时的数据恢复起着极其重要的作用,MySQL的主从复制, 就是通过该binlog实现的。

二进制日志,默认情况下是没有开启的,需要到MySQL的配置文件中开启,并配置MySQL日志的格式

配置文件位置 : /usr/my.cnf

日志存放位置:配置时,给定了文件名但是没有指定路径,日志默认写入Mysql的数据目录(var/lib/mysql)

1
2
3
4
5
#配置开启binlog日志, 日志的文件前缀为 mysqlbin -----> 生成的文件名如 : mysqlbin.000001,mysqlbin.000002
log_bin=mysqlbin

#配置二进制日志的格式
binlog_format=STATEMENT
2、日志格式
1、STATEMENT

该日志格式在日志文件中记录的都是==SQL语句(statement)==,每一条对数据进行修改的SQL都会记录在日志文件中,通过Mysql提供的mysqlbinlog工具,可以清晰的查看到每条语句的文本。

主从复制的时候,从库(slave)会将日志解析为原文本,并在从库重新执行一次。

2、ROW

该日志格式在日志文件中记录的是==每一行的数据变更==,而不是记录SQL语句。

比如,执行SQL语句 : update tb_book set status=’1’:

  • 如果是STATEMENT 日志格式,在日志中会记录一行SQL文件;
  • 如果是ROW,由于是对全表进行更新,也就是每一行记录都会发生变更,ROW 格式的日志中会记录每一行的数据变更。
3、MIXED

这是目前MySQL默认的日志格式,即混合了STATEMENT 和 ROW两种格式。

默认情况下采用STATEMENT,但是在一些特殊情况下采用ROW来进行记录。MIXED 格式能尽量利用两种模式的优点,而避开他们的缺点。

3、日志读取

由于日志以二进制方式存储,不能直接读取,需要用mysqlbinlog工具来查看,语法如下 :

1
mysqlbinlog log-file;
1、查看STATEMENT格式日志

执行插入语句:

1
insert into tb_book values(null,'Lucene','2088-05-01','0');

查看日志文件:

1554079717375

  • mysqlbin.index:该文件是日志索引文件 , 记录日志的文件名;
  • mysqlbing.000001:日志文件

查看日志内容:

1
mysqlbinlog mysqlbing.000001;

1554080016778

2、查看ROW格式日志

配置:

1
2
3
4
5
#配置开启binlog日志, 日志的文件前缀为 mysqlbin -----> 生成的文件名如 : mysqlbin.000001,mysqlbin.000002
log_bin=mysqlbin

#配置二进制日志的格式
binlog_format=ROW

插入数据:

1
insert into tb_book values(null,'SpringCloud实战','2088-05-05','0');

如果日志格式是 ROW , 直接查看数据 , 是查看不懂的;可以在mysqlbinlog 后面加上参数 -vv

1
mysqlbinlog -vv mysqlbin.000002 

1554095452022

4、日志删除

对于比较繁忙的系统,由于每天生成日志量大 ,这些日志如果长时间不清楚,将会占用大量的磁盘空间

下面我们将会讲解几种删除日志的常见方法 :

1、方式一:Reset Master

通过 Reset Master 指令删除全部 binlog 日志,删除之后,日志编号,将从 xxxx.000001重新开始 。

查询之前 ,先查询下日志文件:

1554118609489

执行删除日志指令:

1
Reset Master

执行之后, 查看日志文件:

1554118675264

2、方式二:purge master logs to ‘mysqlbin.*’

执行指令 purge master logs to 'mysqlbin.******' ,该命令将删除 ****** 编号之前的所有日志。

3、方式三:purge master logs before ‘yyyy-mm-dd hh24:mi:ss’

执行指令 purge master logs before 'yyyy-mm-dd hh24:mi:ss' ,该命令将删除日志为 “yyyy-mm-dd hh24:mi:ss” 之前产生的所有日志 。

4、方式四:–expire_logs_days=#

设置参数 --expire_logs_days=# ,此参数的含义是设置日志的过期天数, 过了指定的天数后日志将会被自动删除,这样将有利于减少DBA 管理日志的工作量

配置如下:

1554125506938

3、查询日志

查询日志中记录了客户端的所有操作语句,而二进制日志不包含查询数据的SQL语句。

默认情况下, 查询日志是未开启的。如果需要开启查询日志,可以设置以下配置:

1
2
3
4
5
#该选项用来开启查询日志 , 可选值 : 0 或者 1 ; 0 代表关闭, 1 代表开启 
general_log=1

#设置日志的文件名 , 如果没有指定, 默认的文件名为 host_name.log,默认生成在mysql的数据目录下(var/lib/mysql)
general_log_file=file_name

在 mysql 的配置文件 /usr/my.cnf 中配置如下内容:

1554128184632

配置完毕之后,在数据库执行以下操作:

1
2
3
4
select * from tb_book;
select * from tb_book where id = 1;
update tb_book set name = 'lucene入门指南' where id = 5;
select * from tb_book where id < 8;

执行完毕之后, 再次来查询日志文件:

1554128089851

4、慢查询日志

慢查询日志记录了所有执行时间超过参数 long_query_time 设置值并且扫描记录数不小于 min_examined_row_limit 的所有的SQL语句的日志

long_query_time 默认为 10 秒,最小为 0, 精度可以到微秒。

1、文件位置和格式

慢查询日志默认是关闭的

1
SHOW VARIABLES LIKE '%slow_query_log%';

img

使用set global slow_query_log=1开启了慢查询日志只对当前数据库生效,

如果MySQL重启后则会失效。

img

img

全局变量设置,对当前连接不影响

img

对当前连接立刻生效

img

如果要永久生效,可以通过两个参数来控制慢查询日志 :

1
2
3
4
5
6
7
8
9
10
11
# 该参数用来控制慢查询日志是否开启, 可取值: 1 和 0 , 1 代表开启, 0 代表关闭
slow_query_log=1

# 该参数用来指定慢查询日志的文件名
slow_query_log_file=slow_query.log

# 该选项用来配置查询的时间限制, 超过这个时间将认为值慢查询, 将需要进行日志记录, 默认10s
# 假如运行时间正好等于long_query_time的情况,并不会被记录下来。也就是说,在mysql源码里是判断大于long_query_time,而非大于等于。
long_query_time=10

log_output=FILE

当然,如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响。慢查询日志支持将日志记录写入文件。

2、日志的读取

和错误日志、查询日志一样,慢查询日志记录的格式也是纯文本,可以被直接读取。

  1. 查询long_query_time 的值。

    1554130333472

  2. 执行查询操作

    1
    select id, title,price,num ,status from tb_item where id = 1;

    1554130448709

    由于该语句执行时间很短,为0s , 所以不会记录在慢查询日志中。

    1
    select * from tb_item where title like '%阿尔卡特 (OT-927) 炭黑 联通3G手机 双卡双待165454%' ;

    1554130532577

    该SQL语句 , 执行时长为 26.77s ,超过10s , 所以会记录在慢查询日志文件中。

    查询当前系统中有多少条慢查询记录:

    1
    show global status like '%Slow_queries%';

    img

  3. 查看慢查询日志文件

    • 直接通过cat\tail 指令查询该日志文件:

      1554130669360

    • 如果慢查询日志内容很多, 直接查看文件,比较麻烦, 这个时候可以借助于mysql自带的 mysqldumpslow 工具, 来对慢查询日志进行分类汇总。

      1554130856485


19、Mysql复制

1、复制概述

复制是指将主数据库的DDL 和 DML 操作通过二进制日志传到从库服务器中,然后在从库上对这些日志重新执行(也叫重做),从而使得从库和主库的数据保持同步。

MySQL支持一台主库同时向多台从库进行复制, 从库同时也可以作为其他从服务器的主库,实现链状复制。

2、复制原理

MySQL 的主从复制原理如下:

1554423698190

从上层来看,复制分成三步:

  • Master 主库在事务提交时,会把数据变更作为时间 Events 记录在二进制日志文件 Binlog 中。

  • 主库推送二进制日志文件 Binlog 中的日志事件到从库的中继日志 Relay Log 。

  • slave重做中继日志中的事件,将改变反映它自己的数据。

3、复制优势

MySQL 复制的有点主要包含以下三个方面:

  • 主库出现问题,可以快速切换到从库提供服务

  • 可以在从库上执行查询操作,从主库中更新,实现读写分离,降低主库的访问压力

  • 可以在从库中执行备份,以避免备份期间影响主库的服务

4、搭建步骤

1、master
  1. 在master 的配置文件(/usr/my.cnf)中,配置如下内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #mysql 服务ID,保证整个集群环境中唯一
    server-id=1

    #mysql binlog 日志的存储路径和文件名
    log-bin=/var/lib/mysql/mysqlbin

    #错误日志,默认已经开启
    #log-err

    #mysql的安装目录
    #basedir

    #mysql的临时目录
    #tmpdir

    #mysql的数据存放目录
    #datadir

    #是否只读,1 代表只读, 0 代表读写
    read-only=0

    #忽略的数据, 指不需要同步的数据库
    binlog-ignore-db=mysql

    #指定同步的数据库
    #binlog-do-db=db01
  2. 执行完毕之后,需要重启Mysql:

    1
    service mysql restart ;
  3. 创建同步数据的账户,并且进行授权操作:

    1
    2
    3
    4
    5
    -- 创建主节点的账户完成主从复制、指定对所有数据库(如果相关忽略的在mysql配置文件中配置)、主节点账户的名字、从结点的账户IP、主节点账户的密码
    grant replication slave on *.* to 'itcast'@'192.168.192.131' identified by 'itcast';

    -- 刷新权限
    flush privileges;
  4. 查看当前master结点状态信息:

    1
    show master status;

    image-20210902224311154

    字段含义:

    • File:从哪个日志文件开始推送日志文件
    • Position:从哪个位置开始推送日志
    • Binlog_Ignore_DB:指定不需要同步的数据库
2、slave
  1. 在 slave 端配置文件中,配置如下内容:

    1
    2
    3
    4
    5
    #mysql服务端ID,唯一
    server-id=2

    #指定binlog日志
    log-bin=/var/lib/mysql/mysqlbin
  2. 执行完毕之后,需要重启Mysql:

    1
    service mysql restart;
  3. 执行如下指令:

    1
    2
    -- 指定哪一个主节点、主节点IP地址、主节点账户名称	、主节点的账户密码、主节点二进制日志名称、主节点日志的位置
    change master to master_host= '192.168.192.130', master_user='itcast', master_password='itcast', master_log_file='mysqlbin.000001', master_log_pos=413;

    指定当前从库对应的主库的IP地址,用户名,密码,从哪个日志文件开始的那个位置开始同步推送日志。

  4. 开启同步操作:

    1
    2
    3
    start slave;

    show slave status\G;

    image-20210902224537288

  5. 停止同步操作:

    1
    stop slave;
3、验证同步操作
  1. 在主库中创建数据库,创建表,并插入数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    create database db01;

    user db01;

    create table user(
    id int(11) not null auto_increment,
    name varchar(50) not null,
    sex varchar(1),
    primary key (id)
    )engine=innodb default charset=utf8;

    insert into user(id,name,sex) values(null,'Tom','1');
    insert into user(id,name,sex) values(null,'Trigger','0');
    insert into user(id,name,sex) values(null,'Dawn','1');
  2. 在从库中查询数据,进行验证:

    在从库中,可以查看到刚才创建的数据库:

    1554544658640

    在该数据库中,查询user表中的数据:

    1554544679538


20、综合案例

1、需求分析

在业务系统中,需要记录当前业务系统的访问日志,该访问日志包含:操作人,操作时间,访问类,访问方法,请求参数,请求结果,请求结果类型,请求时长 等信息。记录详细的系统访问日志,主要便于对系统中的用户请求进行追踪,并且在系统 的管理后台可以查看到用户的访问记录。

记录系统中的日志信息,可以通过Spring 框架的AOP来实现。具体的请求处理流程,如下:

1555075760661

2、搭建案例环境

1、数据库表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
-- 创建数据库
CREATE DATABASE mysql_demo DEFAULT CHARACTER SET utf8mb4 ;

-- 品牌表
CREATE TABLE `brand` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL COMMENT '品牌名称',
`first_char` varchar(1) DEFAULT NULL COMMENT '品牌首字母',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 商品表
CREATE TABLE `item` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '商品id',
`title` varchar(100) NOT NULL COMMENT '商品标题',
`price` double(10,2) NOT NULL COMMENT '商品价格,单位为:元',
`num` int(10) NOT NULL COMMENT '库存数量',
`categoryid` bigint(10) NOT NULL COMMENT '所属类目,叶子类目',
`status` varchar(1) DEFAULT NULL COMMENT '商品状态,1-正常,2-下架,3-删除',
`sellerid` varchar(50) DEFAULT NULL COMMENT '商家ID',
`createtime` datetime DEFAULT NULL COMMENT '创建时间',
`updatetime` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品表';

-- 用户表
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
`birthday` datetime DEFAULT NULL,
`sex` char(1) DEFAULT NULL,
`email` varchar(45) DEFAULT NULL,
`phone` varchar(45) DEFAULT NULL,
`qq` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 操作日志表
CREATE TABLE `operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`operate_class` varchar(200) DEFAULT NULL COMMENT '操作类',
`operate_method` varchar(200) DEFAULT NULL COMMENT '操作方法',
`return_class` varchar(200) DEFAULT NULL COMMENT '返回值类型',
`operate_user` varchar(20) DEFAULT NULL COMMENT '操作用户',
`operate_time` varchar(20) DEFAULT NULL COMMENT '操作时间',
`param_and_value` varchar(500) DEFAULT NULL COMMENT '请求参数名及参数值',
`cost_time` bigint(20) DEFAULT NULL COMMENT '执行方法耗时, 单位 ms',
`return_value` varchar(200) DEFAULT NULL COMMENT '返回值',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2、pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.7</maven.compiler.source>
<maven.compiler.target>1.7</maven.compiler.target>

<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<spring.version>5.0.2.RELEASE</spring.version>
<slf4j.version>1.6.6</slf4j.version>
<log4j.version>1.2.12</log4j.version>
<mybatis.version>3.4.5</mybatis.version>
</properties>

<dependencies> <!-- spring -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.6.8</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.16</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>javax.servlet.jsp</groupId>
<artifactId>jsp-api</artifactId>
<version>2.0</version>
<scope>provided</scope>
</dependency>


<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>${log4j.version}</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>${mybatis.version}</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>1.3.0</version>
</dependency>

<dependency>
<groupId>c3p0</groupId>
<artifactId>c3p0</artifactId>
<version>0.9.1.2</version>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.5</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.0</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.0</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.9.0</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<port>8080</port>
<path>/</path>
<uriEncoding>utf-8</uriEncoding>
</configuration>
</plugin>
</plugins>
</build>

3、通过AOP记录操作日志

1、自定义注解

通过自定义注解,来标示方法需不需要进行记录日志,如果该方法在访问时需要记录日志,则在该方法上标示该注解既可。

1
2
3
4
5
6
@Inherited
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperateLog {
}
2、定义通知类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Component
@Aspect
public class OperateAdvice {

private static Logger log = Logger.getLogger(OperateAdvice.class);

@Autowired
private OperationLogService operationLogService;


@Around("execution(* cn.itcast.controller.*.*(..)) && @annotation(operateLog)")
public Object insertLogAround(ProceedingJoinPoint pjp , OperateLog operateLog) throws Throwable{
System.out.println(" ************************ 记录日志 [start] ****************************** ");

OperationLog op = new OperationLog();

DateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

op.setOperateTime(sdf.format(new Date()));
op.setOperateUser(DataUtils.getRandStr(8));

op.setOperateClass(pjp.getTarget().getClass().getName());
op.setOperateMethod(pjp.getSignature().getName());

//获取方法调用时传递的参数
Object[] args = pjp.getArgs();
op.setParamAndValue(Arrays.toString(args));

long start_time = System.currentTimeMillis();

//放行
Object object = pjp.proceed();

long end_time = System.currentTimeMillis();
op.setCostTime(end_time - start_time);

if(object != null){
op.setReturnClass(object.getClass().getName());
op.setReturnValue(object.toString());
}else{
op.setReturnClass("java.lang.Object");
op.setParamAndValue("void");
}

log.error(JsonUtils.obj2JsonString(op));

operationLogService.insert(op);

System.out.println(" ************************** 记录日志 [end] *************************** ");

return object;
}

}
3、方法上加注解

在需要记录日志的方法上加上注解@OperateLog。

1
2
3
4
5
6
7
8
9
10
11
@OperateLog
@RequestMapping("/insert")
public Result insert(@RequestBody Brand brand){
try {
brandService.insert(brand);
return new Result(true,"操作成功");
} catch (Exception e) {
e.printStackTrace();
return new Result(false,"操作失败");
}
}

4、日志查询后端代码实现

1、Mapper接口
1
2
3
4
5
6
7
8
9
public interface OperationLogMapper {

public void insert(OperationLog operationLog);

public List<OperationLog> selectListByCondition(Map dataMap);

public Long countByCondition(Map dataMap);

}
2、Mapper.xml 映射配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.itcast.mapper.OperationLogMapper" >

<insert id="insert" parameterType="operationLog">
INSERT INTO operation_log(id,return_value,return_class,operate_user,operate_time,param_and_value,
operate_class,operate_method,cost_time)
VALUES(NULL,#{returnValue},#{returnClass},#{operateUser},#{operateTime},#{paramAndValue},
#{operateClass},#{operateMethod},#{costTime})
</insert>

<select id="selectListByCondition" parameterType="map" resultType="operationLog">
select
id ,
operate_class as operateClass ,
operate_method as operateMethod,
return_class as returnClass,
operate_user as operateUser,
operate_time as operateTime,
param_and_value as paramAndValue,
cost_time as costTime,
return_value as returnValue
from operation_log
<include refid="oplog_where"/>
limit #{start},#{size}
</select>


<select id="countByCondition" resultType="long" parameterType="map">
select count(*) from operation_log
<include refid="oplog_where"/>
</select>


<sql id="oplog_where">
<where>
<if test="operateClass != null and operateClass != '' ">
and operate_class = #{operateClass}
</if>
<if test="operateMethod != null and operateMethod != '' ">
and operate_method = #{operateMethod}
</if>
<if test="returnClass != null and returnClass != '' ">
and return_class = #{returnClass}
</if>
<if test="costTime != null">
and cost_time = #{costTime}
</if>
</where>
</sql>

</mapper>
3、Service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Service
@Transactional
public class OperationLogService {

//private static Logger logger = Logger.getLogger(OperationLogService.class);

@Autowired
private OperationLogMapper operationLogMapper;

//插入数据
public void insert(OperationLog operationLog){
operationLogMapper.insert(operationLog);
}

//根据条件查询
public PageResult selectListByCondition(Map dataMap, Integer pageNum , Integer pageSize){

if(paramMap ==null){
paramMap = new HashMap();
}
paramMap.put("start" , (pageNum-1)*rows);
paramMap.put("rows",rows);

Object costTime = paramMap.get("costTime");
if(costTime != null){
if("".equals(costTime.toString())){
paramMap.put("costTime",null);
}else{
paramMap.put("costTime",new Long(paramMap.get("costTime").toString()));
}
}

System.out.println(dataMap);


long countStart = System.currentTimeMillis();
Long count = operationLogMapper.countByCondition(dataMap);
long countEnd = System.currentTimeMillis();
System.out.println("Count Cost Time : " + (countEnd-countStart)+" ms");


List<OperationLog> list = operationLogMapper.selectListByCondition(dataMap);
long queryEnd = System.currentTimeMillis();
System.out.println("Query Cost Time : " + (queryEnd-countEnd)+" ms");


return new PageResult(count,list);

}

}
4、Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/operationLog")
public class OperationLogController {

@Autowired
private OperationLogService operationLogService;

@RequestMapping("/findList")
public PageResult findList(@RequestBody Map dataMap, Integer pageNum , Integer pageSize){
PageResult page = operationLogService.selectListByCondition(dataMap, pageNum, pageSize);
return page;
}

}

5、日志查询前端代码实现

前端代码使用 BootStrap + AdminLTE 进行布局, 使用Vuejs 进行视图层展示。

1、js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<script>
var vm = new Vue({
el: '#app',
data: {
dataList:[],
searchEntity:{
operateClass:'',
operateMethod:'',
returnClass:'',
costTime:''
},

page: 1, //显示的是哪一页
pageSize: 10, //每一页显示的数据条数
total: 150, //记录总数
maxPage:8 //最大页数
},
methods: {
pageHandler: function (page) {
this.page = page;
this.search();
},

search: function () {
var _this = this;
this.showLoading();
axios.post('/operationLog/findList.do?pageNum=' + _this.page + "&pageSize=" + _this.pageSize, _this.searchEntity).then(function (response) {
if (response) {
_this.dataList = response.data.dataList;
_this.total = response.data.total;
_this.hideLoading();
}
})
},

showLoading: function () {
$('#loadingModal').modal({backdrop: 'static', keyboard: false});
},

hideLoading: function () {
$('#loadingModal').modal('hide');
},
},

created:function(){
this.pageHandler(1);
}
});

</script>
2、列表数据展示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<tr v-for="item in dataList">
<td><input name="ids" type="checkbox"></td>
<td>{{item.id}}</td>
<td>{{item.operateClass}}</td>
<td>{{item.operateMethod}}</td>
<td>{{item.returnClass}}</td>
<td>{{item.returnValue}}</td>
<td>{{item.operateUser}}</td>
<td>{{item.operateTime}}</td>
<td>{{item.costTime}}</td>
<td class="text-center">
<button type="button" class="btn bg-olive btn-xs">详情</button>
<button type="button" class="btn bg-olive btn-xs">删除</button>
</td>
</tr>
3、分页插件
1
2
3
4
5
<div class="wrap" id="wrap">
<zpagenav v-bind:page="page" v-bind:page-size="pageSize" v-bind:total="total"
v-bind:max-page="maxPage" v-on:pagehandler="pageHandler">
</zpagenav>
</div>

6、联调测试

可以通过postman来访问业务系统,再查看数据库中的日志信息,验证能不能将用户的访问日志记录下来。

1555077276426

7、分析性能问题

系统中用户访问日志的数据量,随着时间的推移,这张表的数据量会越来越大,因此我们需要根据业务需求,来对日志查询模块的性能进行优化。

1、分页查询优化

由于在进行日志查询时,是进行分页查询,那也就意味着,在查看时,至少需要查询两次:

  1. 查询符合条件的总记录数。–> count 操作
  2. 查询符合条件的列表数据。–> 分页查询 limit 操作

通常来说,count() 都需要扫描大量的行(意味着需要访问大量的数据)才能获得精确的结果,因此是很难对该SQL进行优化操作的。如果需要对count进行优化,可以采用另外一种思路,可以==增加汇总表==,或者==redis缓存来专门记录该表对应的记录数==,这样的话,就可以很轻松的实现汇总数据的查询,而且效率很高。

但是这种统计并不能保证百分之百的准确 。对于数据库的操作,“快速、精确、实现简单”,三者永远只能满足其二,必须舍掉其中一个。

2、条件查询优化

针对于条件查询,需要==对查询条件,及排序字段建立索引==。

3、读写分离

通过==主从复制集群,来完成读写分离,使写操作走主节点, 而读操作,走从节点==。

4、MySQL服务器优化
5、应用优化

8、性能优化 - 分页

1、优化count

创建一张表用来记录日志表的总数据量:

1
2
3
create table log_counter(
logcount bigint not null
)engine = innodb default CHARSET = utf8;

在每次插入数据之后,更新该表:

1
2
3
<update id="updateLogCounter" >
update log_counter set logcount = logcount + 1
</update>

在进行分页查询时,获取总记录数,从该表中查询既可。

1
2
3
<select id="countLogFromCounter" resultType="long">
select logcount from log_counter limit 1
</select>
2、优化 limit

在进行分页时,一般通过创建覆盖索引,能够比较好的提高性能。一个非常常见,而又非常头疼的分页场景就是 “limit 1000000,10” ,此时MySQL需要搜索出前1000010 条记录后,仅仅需要返回第 1000001 到 1000010 条记录,前1000000 记录会被抛弃,查询代价非常大。

1555081714638

当点击比较靠后的页码时,就会出现这个问题,查询效率非常慢。

优化前的SQL:

1
select * from operation_log limit 3000000 , 10;

将上述SQL优化为:(使用子查询的方式进行优化)

1
select * from operation_log t , (select id from operation_log order by id limit 3000000,10) b where t.id = b.id ;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<select id="selectListByCondition" parameterType="map" resultType="operationLog">
select
id ,
operate_class as operateClass ,
operate_method as operateMethod,
return_class as returnClass,
operate_user as operateUser,
operate_time as operateTime,
param_and_value as paramAndValue,
cost_time as costTime,
return_value as returnValue
from operation_log t,

(select id from operation_log
<where>
<include refid="oplog_where"/>
</where>
order by id limit #{start},#{rows}) b where t.id = b.id
</select>

9、性能优化 - 索引

1555152703824

当根据操作人进行查询时, 查询的效率很低,耗时比较长。原因就是因为在创建数据库表结构时,并没有针对于 操作人 字段建立索引。

1
CREATE INDEX idx_user_method_return_cost ON operation_log(operate_user,operate_method,return_class,cost_time);

同上,为了查询效率高,我们也需要对 ==操作方法==、==返回值类型==、==操作耗时== 等字段进行创建索引,以提高查询效率。

1
2
3
4
5
CREATE INDEX idx_optlog_method_return_cost ON operation_log(operate_method,return_class,cost_time);

CREATE INDEX idx_optlog_return_cost ON operation_log(return_class,cost_time);

CREATE INDEX idx_optlog_cost ON operation_log(cost_time);

10、性能优化 - 排序

在查询数据时,如果业务需求中需要我们对结果内容进行了排序处理 , 这个时候,我们还需要==对排序的字段建立适当的索引==,来提高排序的效率 。

11、性能优化 - 读写分离

1、概述

在Mysql主从复制的基础上,可以使用读写分离来降低单台Mysql节点的压力,从而来提高访问效率,读写分离的架构如下:

1555235426739

对于读写分离的实现,可以通过Spring AOP 来进行动态的切换数据源,进行操作:

2、实现方式

db.properties

1
2
3
4
5
6
7
8
9
jdbc.write.driver=com.mysql.jdbc.Driver
jdbc.write.url=jdbc:mysql://192.168.142.128:3306/mysql_demo
jdbc.write.username=root
jdbc.write.password=itcast

jdbc.read.driver=com.mysql.jdbc.Driver
jdbc.read.url=jdbc:mysql://192.168.142.129:3306/mysql_demo
jdbc.read.username=root
jdbc.read.password=itcast

applicationContext-datasource.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">


<!-- 配置数据源 - Read -->
<bean id="readDataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close" lazy-init="true">
<property name="driverClass" value="${jdbc.read.driver}"></property>
<property name="jdbcUrl" value="${jdbc.read.url}"></property>
<property name="user" value="${jdbc.read.username}"></property>
<property name="password" value="${jdbc.read.password}"></property>
</bean>


<!-- 配置数据源 - Write -->
<bean id="writeDataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close" lazy-init="true">
<property name="driverClass" value="${jdbc.write.driver}"></property>
<property name="jdbcUrl" value="${jdbc.write.url}"></property>
<property name="user" value="${jdbc.write.username}"></property>
<property name="password" value="${jdbc.write.password}"></property>
</bean>


<!-- 配置动态分配的读写 数据源 -->
<bean id="dataSource" class="cn.itcast.aop.datasource.ChooseDataSource" lazy-init="true">
<property name="targetDataSources">
<map key-type="java.lang.String" value-type="javax.sql.DataSource">
<entry key="write" value-ref="writeDataSource"/>
<entry key="read" value-ref="readDataSource"/>
</map>
</property>

<property name="defaultTargetDataSource" ref="writeDataSource"/>

<property name="methodType">
<map key-type="java.lang.String">
<entry key="read" value=",get,select,count,list,query,find"/>
<entry key="write" value=",add,create,update,delete,remove,insert"/>
</map>
</property>
</bean>

</beans>

ChooseDataSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ChooseDataSource extends AbstractRoutingDataSource {

public static Map<String, List<String>> METHOD_TYPE_MAP = new HashMap<String, List<String>>();

/**
* 实现父类中的抽象方法,获取数据源名称
* @return
*/
protected Object determineCurrentLookupKey() {
return DataSourceHandler.getDataSource();
}

// 设置方法名前缀对应的数据源
public void setMethodType(Map<String, String> map) {
for (String key : map.keySet()) {
List<String> v = new ArrayList<String>();
String[] types = map.get(key).split(",");
for (String type : types) {
if (!StringUtils.isEmpty(type)) {
v.add(type);
}
}
METHOD_TYPE_MAP.put(key, v);
}
System.out.println("METHOD_TYPE_MAP : "+METHOD_TYPE_MAP);
}
}

DataSourceHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DataSourceHandler {

// 数据源名称
public static final ThreadLocal<String> holder = new ThreadLocal<String>();

/**
* 在项目启动的时候将配置的读、写数据源加到holder中
*/
public static void putDataSource(String datasource) {
holder.set(datasource);
}

/**
* 从holer中获取数据源字符串
*/
public static String getDataSource() {
return holder.get();
}
}

DataSourceAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Aspect
@Component
@Order(-9999)
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class DataSourceAspect {

protected Logger logger = LoggerFactory.getLogger(this.getClass());

/**
* 配置前置通知,使用在方法aspect()上注册的切入点
*/
@Before("execution(* cn.itcast.service.*.*(..))")
@Order(-9999)
public void before(JoinPoint point) {

String className = point.getTarget().getClass().getName();
String method = point.getSignature().getName();
logger.info(className + "." + method + "(" + Arrays.asList(point.getArgs())+ ")");

try {
for (String key : ChooseDataSource.METHOD_TYPE_MAP.keySet()) {
for (String type : ChooseDataSource.METHOD_TYPE_MAP.get(key)) {
if (method.startsWith(type)) {
System.out.println("key : " + key);
DataSourceHandler.putDataSource(key);
break;
}
}
}
} catch (Exception e) {
e.printStackTrace();
}

}
}

通过 @Order(-9999) 注解来控制事务管理器,与该通知类的加载顺序,需要让通知类,先加载,来判定使用哪个数据源。

3、验证

在主库和从库中,执行如下SQL语句,来查看是否读的时候,从从库中读取;写入操作的时候,是否写入到主库。

1
show status like 'Innodb_rows_%' ;

1555235982584

4、原理

1555235982584

12、性能优化 - 应用优化

1、缓存

可以在业务系统中使用==redis==或者==框架本身的一级二级缓存==来做缓存,缓存一些基础性的数据,来降低关系型数据库的压力,提高访问效率。

2、全文检索

如果业务系统中的数据量比较大(达到千万级别),这个时候,如果再对数据库进行查询,特别是进行分页查询,速度将变得很慢(因为在分页时首先需要count求合计数),为了提高访问效率,这个时候,可以考虑加入==Solr== 或者 ==ElasticSearch==全文检索服务,来提高访问效率。

3、非关系数据库

也可以考虑将非核心(重要)数据,存在 ==MongoDB== 中,这样可以提高插入以及查询的效率。


0、在Linux系统安装Mysql

1、下载Linux 安装包

官网下载mysql的安装包

image-20210901003856739

2、安装MySQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 卸载 Linux 中预安装的 mysql
rpm -qa | grep -i mysql
rpm -e mysql-libs-5.1.71-1.el6.x86_64 --nodeps

-- 上传 mysql 的安装包
alt + p -- 切换到ftp模式
put E:/test/MySQL-5.6.22-1.el6.i686.rpm-bundle.tar

-- 解压 mysql 的安装包
mkdir mysql
tar -xvf MySQL-5.6.22-1.el6.i686.rpm-bundle.tar -C /root/mysql

-- 安装依赖包
yum -y install libaio.so.1 libgcc_s.so.1 libstdc++.so.6 libncurses.so.5 --setopt=protected_multilib=false
yum update libstdc++-4.4.7-4.el6.x86_64

-- 安装 mysql-client
rpm -ivh MySQL-client-5.6.22-1.el6.i686.rpm

-- 安装 mysql-server
rpm -ivh MySQL-server-5.6.22-1.el6.i686.rpm

3、启动 MySQL 服务

1
2
3
4
5
6
7
8
9
10
11
-- 开启mysql服务
service mysql start

-- 停止mysql服务
service mysql stop

-- 查看mysql的状态
service mysql status

-- 重启mysql
service mysql restart

4、登录MySQL

1
2
3
4
5
6
7
8
9
10
11
12
13
-- mysql 安装完成之后, 会自动生成一个随机的密码, 并且保存在一个密码文件中 : /root/.mysql_secret
cd /root/.mysql_secret
cat

-- 登陆mysql
mysql -u root -p

-- 登录之后, 修改密码
set password = password('your password');

-- 授权远程访问
grant all privileges on *.* to 'root' @'%' identified by 'your password';
flush privileges;

5、Mysql 的用户与权限管理

1、MySQL的用户管理
1、创建用户
1
create user zhang3 identified by '123123';

表示创建名称为zhang3的用户,密码设为123123;

2、查看用户
1
2
3
select host,user,password,select_priv,insert_priv,drop_priv from mysql.user;

select * from user\G;

将 user 中的数据以行的形式显示出来(针对列很长的表可以采用这个方法 )

img

  • host:表示连接类型
    • % 表示所有远程通过 TCP方式的连接
    • IP 地址 如 (192.168.1.2,127.0.0.1) 通过制定ip地址进行的TCP方式的连接
    • ::1 IPv6的本地ip地址 等同于IPv4的 127.0.0.1
    • localhost 本地方式通过命令行方式的连接 ,比如mysql -u xxx -p 123xxx 方式的连接。
  • user:表示用户名
    • 同一用户通过不同方式链接的权限是不一样的。
  • password:密码
    • 所有密码串通过 password(明文字符串) 生成的密文字符串。加密算法为MYSQLSHA1 ,不可逆 。
    • mysql 5.7 的密码保存到 authentication_string,字段中不再使用password 字段。
  • select_priv , insert_priv等
    • 为该用户所拥有的权限。
3、设置密码

修改当前用户的密码:

1
set password =password('123456')

修改某个用户的密码:

1
2
3
4
update mysql.user set password=password('123456') where user='li4';

-- 所有通过user表的修改,必须用该命令才能生效。
flush privileges;
4、修改用户

修改用户名:

1
2
3
4
update mysql.user set user='li4' where user='wang5';

-- 所有通过user表的修改,必须用该命令才能生效。
flush privileges;

img

5、删除用户
1
drop user li4 ;

img

不要通过delete from user u where user=’li4’ 进行删除,系统会有残留信息保留。

2、权限管理
1、授予权限

授权命令:

1
grant 权限1,权限2,…权限n on 数据库名称.表名称 to 用户名@用户地址 identified by ‘连接口令’;

该权限如果发现没有该用户,则会直接新建一个用户。

比如:

1
2
3
4
5
-- 给li4用户用本地命令行方式下,授予atguigudb这个库下的所有表的插删改查的权限。
grant select,insert,delete,drop on atguigudb.* to li4@localhost ;

-- 授予通过网络方式登录的的joe用户 ,对所有库所有表的全部权限,密码设为123.
grant all privileges on *.* to joe@'%' identified by '123';

就算 all privileges 了所有权限,grant_priv 权限也只有 root 才能拥有。

给 root 赋连接口令 grant all privileges on *.* to root@'%' ;后新建的连接没有密码,需要设置密码才能远程连接。

1
update user set password=password('root') where user='root' and host='%';
2、收回权限
1
2
3
4
5
6
7
8
-- 收回权限命令:
revoke 权限1,权限2,…权限n on 数据库名称.表名称 from 用户名@用户地址 ;

-- 若赋的全库的表就 收回全库全表的所有权限
REVOKE ALL PRIVILEGES ON mysql.* FROM joe@localhost;

-- 收回mysql库下的所有表的插删改查权限
REVOKE select,insert,update,delete ON mysql.* FROM joe@localhost;

对比赋予权限的方法:必须用户重新登录后才能生效

3、查看权限
1
2
3
4
5
6
7
8
9
10
11
-- 查看当前用户权限
show grants;

-- 查看某用户的全局权限
select * from user ;

-- 查看某用户的某库的权限
select * from db;

-- 查看某用户的某个表的权限
select * from tables_priv;
3、通过工具远程访问
  1. 先 ping 一下数据库服务器的ip 地址确认网络畅通。

  2. 关闭数据库服务的防火墙

    1
    service iptables stop
  3. 确认Mysql中已经有可以通过远程登录的账户

    1
    select * from mysql.user where user='li4' and host='%';

    如果没有用户,先执行如下命令:

    1
    grant all privileges on *.* to li4@'%' identified by '123123';
  4. 测试连接:

    img

6、修改字符集问题

1、查看字符集
1
2
show variables like 'character%'; 
show variables like '%char%';

默认的是客户端和服务器都用了latin1,所以会乱码。

2、修改my.cnf

在/usr/share/mysql/ 中找到my.cnf的配置文件,

拷贝其中的my-huge.cnf 到 /etc/ 并命名为my.cnf

mysql 优先选中 /etc/ 下的配置文件

然后修改my.cnf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[client]

default-character-set=utf8

[mysqld]

character_set_server=utf8

character_set_client=utf8

collation-server=utf8_general_ci

[mysql]

default-character-set=utf8

img

img

3、重新启动mysql

但是原库的设定不会发生变化,参数修改之对新建的数据库生效

4、已生成的库表字符集如何变更
1
2
3
4
5
-- 修改数据库的字符集
alter database mytest character set 'utf8';

-- 修改数据表的字符集
alter table user convert to character set 'utf8';

但是原有的数据如果是用非’utf8’编码的话,数据本身不会发生改变。

7、Mysql的一些杂项配置

1、大小写问题
1
SHOW VARIABLES LIKE '%lower_case_table_names%' 

img

windows系统默认大小写不敏感,但是linux系统是大小写敏感的

  • 默认为0,大小写敏感。
  • 设置1,大小写不敏感。创建的表,数据库都是以小写形式存放在磁盘上,对于sql语句都是转换为小写对表和DB进行查找。
  • 设置2,创建的表和DB依据语句上格式存放,凡是查找都是转换为小写进行。

设置变量常采用 setlower_case_table_names = 1; 的方式,但此变量是只读权限,所以需要在配置文件中改。

当想设置为大小写不敏感时,要在my.cnf这个配置文件 [mysqld] 中加入 lower_case_table_names = 1 ,然后重启服务器。

但是要在重启数据库实例之前就需要将原来的数据库和表转换为小写,否则更改后将找不到数据库名。

在进行数据库参数设置之前,需要掌握这个参数带来的影响,切不可盲目设置。

2、(生产环境)sql_mode

MySQL的sql_mode合理设置

sql_mode是个很容易被忽视的变量,默认值是空值,在这种设置下是可以允许一些非法操作的,比如允许一些非法数据的插入。在生产环境必须将这个值设置为严格模式,所以开发、测试环境的数据库也必须要设置,这样在开发测试阶段就可以发现问题。

img

使用 set sql_mode=ONLY_FULL_GROUP_BY; 的方式设置会将之前的设置覆盖掉ONLY_FULL_GROUP_BY; 的方式设置会将之前的设置覆盖掉

同时设置多个限制:

1
set sql_mode='ONLY_FULL_GROUP_BY,NO_AUTO_VALUE_ON_ZERO';

sql_mode常用值如下:

  • ONLY_FULL_GROUP_BY:
    • 对于GROUP BY聚合操作,如果在SELECT中的列,没有在GROUP BY中出现,那么这个SQL是不合法的,因为列不在GROUP BY从句中
  • NO_AUTO_VALUE_ON_ZERO:
    • 该值影响自增长列的插入。默认设置下,插入0或NULL代表生成下一个自增长值。如果用户希望插入的值为0,而该列又是自增长的,那么这个选项就有用了。
  • STRICT_TRANS_TABLES:
    • 在该模式下,如果一个值不能插入到一个事务表中,则中断当前的操作,对非事务表不做限制
  • NO_ZERO_IN_DATE:
    • 在严格模式下,不允许日期和月份为零
  • NO_ZERO_DATE:
    • 设置该值,mysql数据库不允许插入零日期,插入零日期会抛出错误而不是警告。
  • ERROR_FOR_DIVISION_BY_ZERO:
    • 在INSERT或UPDATE过程中,如果数据被零除,则产生错误而非警告。如 果未给出该模式,那么数据被零除时MySQL返回NULL
  • NO_AUTO_CREATE_USER:
    • 禁止GRANT创建密码为空的用户
  • NO_ENGINE_SUBSTITUTION:
    • 如果需要的存储引擎被禁用或未编译,那么抛出错误。不设置此值时,用默认的存储引擎替代,并抛出一个异常
  • PIPES_AS_CONCAT:
    • 将”||”视为字符串的连接操作符而非或运算符,这和Oracle数据库是一样的,也和字符串的拼接函数Concat相类似
  • ANSI_QUOTES:
    • 启用ANSI_QUOTES后,不能用双引号来引用字符串,因为它被解释为识别符
  • ORACLE:
    • 设置等同:PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, NO_KEY_OPTIONS, NO_TABLE_OPTIONS, NO_FIELD_OPTIONS, NO_AUTO_CREATE_USER.
3、查看当前系统的性能状态

服务器硬件的性能瓶颈:topfreeiostatvmstat来查看系统的性能状态


相关资料

InnoDB到底支不支持哈希索引,为啥不同的人说的不一样?

黑马程序员MySQL全套教程,超详细的MySQL数据库优化,MySQL面试热点必考

聚簇索引与非聚簇索引(也叫二级索引)–最清楚的一篇讲解

一文搞懂数据库隔离级别及解决方案

京东面试官问我:“聊聊MySql事务,MVCC?”

深入了解mysql–gap locks,Next-Key Locks

Mysql的行锁、表锁、间隙锁、意向锁

[TOC]

1、计算机网络概念

1、什么是计算机网络?

计算机网络是指将==地理位置不同==的具有独立功能的==多台计算机及其外部设备,通过通信线路连接(有线性、无线)起来==,在网络操作系统,网络管理软件及==网络通信协议==的管理和协调下,实现==资源共享==和==信息传递==的计算机系统。

类比:信件

image-20210815223740558

2、网络编程的目的

  • ==传播交流信息==
  • ==数据交换、通信==

3、想要达到这个效果,需要什么

  1. 如何==准确的定位网络上的一台主机== 192.168.1.100: 端口,定位到这个计算机上的某个资源。
  2. 找到了这个主机,如何传输数据呢?

JavaWeb : 网页编程 B/S架构

网络编程: TCP/IP C/S架构

4、网络通信要素

如何实现网络的通信?

  • ==通信双方的地址==:
    • IP:
      • 192.168.1.100
    • 端口号
      • 8080
  • ==规则:网络通信的协议==

5、TCP/IP参考模型

image-20210815222644766

6、OSI七层参考模型 与 TCP/IP参考模型

image-20210815225710618

7、小结

  1. 网络编程中两个主要问题
    • 如何准确定位到网络上的一台或多台主机
    • 找到主机之后如何进行通信
  2. 网络编程中的要素
    • IP 和 端口号
    • 网络通信协议
  3. Java 万物皆对象

2、IP——InetAddress

ip地址:InetAddress

  1. ==唯一定位一台网络上计算机==
  2. ==127.0.0.1: 本机localhost==
  3. ip地址的分类:IPV4/IPv6
    • IPV4 127.0.0.1 4个字节组成,0-255 42亿个 30亿都在北美,亚洲4亿。2011年就用尽
    • IPV6 ;128位。8个无符号整数!
  4. 公网-私网

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package cn.bloghut.lesson01;

import java.net.InetAddress;
import java.net.UnknownHostException;

/**
* @description 测试IP
*/
public class TestInetAddress {
public static void main(String[] args) throws Exception {
//查询本机的ip地址
InetAddress localhost = InetAddress.getByName("localhost");
// InetAddress localhost = InetAddress.getByName("127.0.0.1");
// InetAddress localhost = InetAddress.getLocalHost();
System.out.println(localhost);

InetAddress localHost = InetAddress.getLocalHost();
System.out.println(localHost);
System.out.println("=================");
//查询网站ip地址
InetAddress name = InetAddress.getByName("www.baidu.com");
System.out.println(name);
System.out.println("=================");
//常用方法
//System.out.println(name.getAddress());
System.out.println(name.getHostAddress());//获取主机ip地址
System.out.println(name.getHostName());// 获取域名
System.out.println(name.getCanonicalHostName());//获取规范的主机ip地址
}
}

3、端口——InetSocketAddress

==端口表示计算机上的一个程序的进程。==

  1.  一栋楼表示一个ip ,这栋楼里面的 门牌号 就是端口号。
  2. ==不同的进程有不同的端口号==!用来区分软件的。
  3. 端口被规定为:0-65535
  4. TCP ,UDP: 每个都有 0-65535 * 2 ,单个协议下,端口号不能冲突。
  5. 端口分类
    • 公有端口:0-1023
      • HTTP : 80
      • HTTPS :443
      • FTP : 21
      • Telnet: 23
    • 程序注册端口:1024-49151,分配给用户或者程序
      • Tomcat:8080
      • Mysql:3306
      • Oracle:1521
    • 动态、私有:49152-65535
1
2
3
4
netstat -ano #查看所有端口
netstat -ano | findstr "5900" #查看指定的端口
tasklist | findstr "8696" #查看指定端口的进程
Ctrl + Shift + ESC #打开任务管理器

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package cn.bloghut.lesson01;

import java.net.InetSocketAddress;

/**
* @description TODO
*/
public class TesyInetSocketAddress {
public static void main(String[] args) {

InetSocketAddress socketAddress = new InetSocketAddress("127.0.0.1", 8080);
InetSocketAddress socketAddress1 = new InetSocketAddress("localhost", 8080);

// /127.0.0.1:8080
System.out.println(socketAddress);
// localhost/127.0.0.1:8080
System.out.println(socketAddress1);
System.out.println("====================");

System.out.println(socketAddress.getAddress());//ip地址
System.out.println(socketAddress.getHostName());//主机名称
System.out.println(socketAddress.getHostString());
System.out.println(socketAddress.getPort());//端口

}
}

4、通信协议

协议:约定,就好比我们现在说的是普通话。

网络通信协议:

  1. 速率
  2. 传输码率
  3. 代码结构 
  4. 传输控制

问题:非常的复杂

TCP/IP协议簇:实际上是一组协议

重要:

  • TCP:用户传输协议
  • UDP:用户数据报协议

出名的协议:

  1. TCP
  2. IP

TCP和UDP 对比:

  • TCP:打电话
    • 连接: 稳定
      • 三次握手
        • A:你愁啥?
        • B:瞅你咋地?
        • A:干一次!
      • 四次挥手
        • A:我要断开了 (我要走了)
        • B:我知道你要断开了(你真的要走了吗?)
        • B:你真的断开了吗?(你真的真的要走了吗?)
        • A:我真的断开了 (我真的要走了)

客户端,服务端

传输完成,释放连接、效率低

image-20210816004713109

1、三次握手

  1. 第一次握手:建立连接时,客户端发送syn包(syn=j)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。
  2. 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;
  3. 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。

2、四次挥手

image-20210816004806220

UDP:发短信

  1. 不连接,不稳定
  2. 客户端、服务端:没有明确的界限
  3. 不管有没有准备好,都可以发给你…
  4. 导弹
  5. DDOS:洪水攻击!(饱和攻击)

3、客户端

  1. 建立连接
  2. 发送消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package cn.bloghut.lesson02;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.Socket;

/**
* @description 客户端
*/
public class TcpClientDemo1 {

public static void main(String[] args) throws Exception{
Socket socket = null;
OutputStream os = null;
//要知道服务器地址
try {
InetAddress serverIp = InetAddress.getByName("localhost");
int port = 9999;
//2.创建连接
socket = new Socket(serverIp,port);
//3.发生消息 IO流
os = socket.getOutputStream();
os.write("你好,世界".getBytes());
} catch (IOException e) {
e.printStackTrace();
}finally {
if (os != null) {
os.close();
}
if (socket != null) {
socket.close();
}
}
}
}

4、服务器

  1. 建立服务连接的端口 ServerSocket
  2. 等待用户的连接 accept
  3. 接收用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package cn.bloghut.lesson02;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @description 服务器端
*/
public class TcpServerDemo01 {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = null;
Socket accept = null;
InputStream is = null;
ByteArrayOutputStream baos = null;
try {
//1. 我得有一个地址
serverSocket = new ServerSocket(9999);
//2.等待客户端连接过来
accept = serverSocket.accept();
//3.读取客户端消息
is = accept.getInputStream();

/*byte[] buf = new byte[1024];
int len;
while ((len = is.read(buf)) != -1 ){
String s = new String(buf, 0, len);
System.out.println(s);
}*/

//管道流
baos = new ByteArrayOutputStream();
byte[] buff = new byte[1024];
int len = -1;

while ((len = is.read(buff)) != -1) {
baos.write(buff, 0, len);
}
System.out.println(baos.toString());

} catch (IOException e) {
e.printStackTrace();
} finally {
if (baos != null) {
baos.close();
}
if (is != null) {
is.close();
}
if (accept != null) {
accept.close();
}
if (serverSocket != null) {
serverSocket.close();
}
}
}
}

5、TCP实现文件上传

1、客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package cn.bloghut.lesson02;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @description 服务端
*/
public class TcpServerDemo2 {
public static void main(String[] args) throws Exception{
//1.创建服务
ServerSocket serverSocket = new ServerSocket(9999);
//2.监听客户端连接
Socket accept = serverSocket.accept();
//3.获取输入流
InputStream is = accept.getInputStream();

//4.文件输出
FileOutputStream fos = new FileOutputStream(new File("receive.jpg"));//接收文件就要用文件的管道流
byte[] buff = new byte[1024];
int len;
while ((len = is.read(buff)) != -1){
fos.write(buff,0,len);
}

//通过客户端我接收完毕了
OutputStream os = accept.getOutputStream();
os.write("我接收完毕了,你可以断开了".getBytes());

fos.close();
is.close();
accept.close();
serverSocket.close();
}
}

2、服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package cn.bloghut.lesson02;

import java.io.*;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;

/**
* @description 客户端
*/
public class TcpClientDemo2 {

public static void main(String[] args) throws Exception {
//1.建立连接
Socket socket = new Socket(InetAddress.getByName("127.0.0.1"), 9999);
//2.创建一个输出流
OutputStream os = socket.getOutputStream();
//3.读取文件
FileInputStream is = new FileInputStream(new File("1.jpg"));
byte[] buff = new byte[1024];
int len;
//4.写出文件
while ((len = is.read(buff)) != -1) {
os.write(buff, 0, len);
}

//通知服务器,我已经结束了
socket.shutdownOutput();//我已经传输完了的意思

//确定服务器接收完毕,才能够断开连接
InputStream inputStream = socket.getInputStream();//接收字符、就用字节的管道流
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buff2 = new byte[1024];
int len2;
while ((len2 = inputStream.read(buff)) != -1) {
bos.write(buff2, 0, len2);
}
System.out.println(bos.toString());

//5.释放资源
bos.close();
inputStream.close();
is.close();
os.close();
socket.close();
}
}

6、UDP:发短信,需要IP地址

1、发送端:发送消息——DatagramPacket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package cn.bloghut.lesson3;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

/**
* @description 不需要连接服务器
*/
public class UdpClientDemo1 {
public static void main(String[] args) throws Exception{

//1.建立一个Socket
DatagramSocket socket = new DatagramSocket();
//2.建个包
String msg = "你好啊,服务器";
//3.发送给谁
InetAddress address = InetAddress.getByName("localhost");
int port = 9090;

DatagramPacket packet = new DatagramPacket(msg.getBytes(), 0, msg.getBytes().length, address, port);
//4.发送包
socket.send(packet);
}
}

2、接收端:接收消息——DatagramSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package cn.bloghut.lesson3;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.ServerSocket;

/**
* @description TODO
*/
public class UdpServerDemo1 {
public static void main(String[] args) throws Exception{
//开放端口
DatagramSocket socket = new DatagramSocket(9090);
//接收数据包
byte[] buff =new byte[1024];
DatagramPacket packet = new DatagramPacket(buff, 0, buff.length);

socket.receive(packet);//阻塞接收

System.out.println(packet.getAddress());
System.out.println(new String(packet.getData(),0,packet.getData().length));

socket.close();
}
}

7、UDP 实现聊天实现

1、发送方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package cn.bloghut.chat;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;

/**
* @description 接收端
*/
public class UdpSenderDemo01 {
public static void main(String[] args) throws Exception {
//获取连接
DatagramSocket socket = new DatagramSocket(8080);
while (true) {
//准备数据
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String data = reader.readLine();
byte[] datas = data.getBytes();
DatagramPacket packet = new DatagramPacket
(datas, 0,datas.length, new InetSocketAddress("localhost", 6666));
//发送数据
socket.send(packet);
if (data.equals("bye")) {
break;
}
}
socket.close();
}
}

2、接收端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package cn.bloghut.chat;

import java.net.DatagramPacket;
import java.net.DatagramSocket;

/**
* @description 接收端
*/
public class UdpReceiveDemo01 {

public static void main(String[] args) throws Exception {
DatagramSocket socket = new DatagramSocket(6666);
while (true) {
//准备接收包裹
byte[] container = new byte[1024];
DatagramPacket packet = new DatagramPacket(container, 0, container.length);
socket.receive(packet);//阻塞式接收包裹


byte[] data = packet.getData();
String receiveData = new String(data, 0, data.length);

System.out.println(receiveData);

//断开连接 bye
if (receiveData.equals("bye")){
break;
}
}
socket.close();
}
}

5、参考文档

B站【狂神说Java笔记】-网络编程

[TOC]

Netty

1、Netty的介绍以及应用场景

1、Netty的基本介绍

  1. Netty 是由 JBOSS 提供的一个 Java 开源框架,现为 Github上的独立项目。

  2. Netty 是一个==异步的==、==基于事件驱动==的==网络应用框架==,用以快速开发高性能、高可靠性的网络 IO 程序

    image-20210816030806875

  3. Netty主要针对在==TCP协议==下,==面向Clients端==的高并发应用,或者==Peer-to-Peer场景==下的大量数据持续传输的应用。

  4. Netty本质是一个==NIO框架==,适用于服务器通讯相关的多种应用场景

  5. 要透彻理解Netty , 需要先学习 NIO , 这样我们才能阅读 Netty 的源码。

    image-20210816030833354

2、Netty的应用场景

1、互联网行业

  1. 互联网行业:在分布式系统中,各个节点之间需要远程服务调用,高性能的 RPC 框架必不可少,Netty 作为异步高性能的通信框架,往往作为基础通信组件被这些 RPC 框架使用

  2. 典型的应用有:阿里分布式服务框架 Dubbo 的 RPC 框架使用 Dubbo 协议进行节点间通信,Dubbo 协议默认使用 Netty 作为基础通信组件,用于实现各进程节点之间的内部通信

    image-20210816031145708

2、游戏行业

  1. 无论是手游服务端还是大型的网络游戏,Java 语言得到了越来越广泛的应用
  2. Netty 作为高性能的基础通信组件,提供了 TCP/UDP 和 HTTP 协议栈,方便定制和开发私有协议栈,账号登录服务器
  3. 地图服务器之间可以方便的通过 Netty 进行高性能的通信

3、大数据领域

  1. 经典的 Hadoop 的高性能通信和序列化组件 Avro(实现数据文件共享) 的 RPC 框架,默认采用 Netty 进行跨界点通信

  2. 它的 Netty Service 基于 Netty 框架二次封装实现。

    image-20210816031437724

4、其它开源项目使用到Netty

网址: https://netty.io/wiki/related-projects.html

image-20210816031742962

3、Netty的学习参考资料

  • 《Netty IN Action》
    • image-20210816031941432
  • Netty权威指南
    • image-20210816032001424

2、Java BIO编程

1、I/O模型

1、I/O模型的基本说明

  • I/O 模型简单的理解:就是用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能

  • Java共支持3种网络编程模型/IO模式:BIONIOAIO

    • Java BIO: 同步并阻塞(传统阻塞型),服务器实现模式为==一个连接一个线程==,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销

      image-20210816032943453

    • Java NIO同步非阻塞,服务器实现模式为==一个线程处理多个请求(连接)==,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理

      image-20210816033058098

    • Java AIO(NIO.2)异步非阻塞,AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用

2、BIO、NIO、AIO适用场景分析

  • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序简单易理解。
  • NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4开始支持。
  • AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。

2、Java BIO 基本介绍

  1. Java BIO 就是传统的java io编程,其相关的类和接口在 java.io
  2. BIO(blocking I/O)**: **同步阻塞,服务器实现模式为==一个连接一个线程==,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)。 【后有应用实例
  3. BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,程序简单易理解

3、Java BIO 工作机制

1、工作原理图

image-20210816033912512

2、BIO编程简单流程

  1. 服务器端启动一个ServerSocket
  2. 客户端启动Socket对服务器进行通信,默认情况下服务器端需要对每个客户建立一个线程与之通讯
  3. 客户端发出请求后,先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
  4. 如果有响应,客户端线程会等待请求结束后,在继续执行

4、Java BIO 应用实例

实例说明:

  1. 使用BIO模型编写一个服务器端,监听6666端口,当有客户端连接时,就启动一个线程与之通讯。
  2. 要求使用线程池机制改善,可以连接多个客户端;
  3. 服务器端可以接收客户端发送的数据(telnet 方式即可)。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.awo.bio;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BIOServer {
public static void main(String[] args) throws IOException {

//线程池机制

//思路
//1. 创建一个线程池
//2. 如果有客户端连接,就创建一个线程,与之通讯(单独写一个方法)

//1. 创建一个线程池
ExecutorService pool = Executors.newCachedThreadPool();

//创建ServerSocket
ServerSocket serverSocket = new ServerSocket(6666);

System.out.println("服务器启动了");

while (true) {

//监听,等待客户端连接
System.out.println("等待连接....");
final Socket socket = serverSocket.accept();
System.out.println("连接到一个客户端");

//就创建一个线程,与之通讯(单独写一个方法)
pool.execute(() -> {
//可以和客户端通讯
handler(socket);
});
}
}

//编写一个handler方法,和客户端通讯
public static void handler(Socket socket) {
InputStream is = null;
try {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
byte[] bytes = new byte[1024];
//通过socket 获取输入流
is = socket.getInputStream();

//循环的读取客户端发送的数据
while (true) {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
System.out.println("read....");
int read;
if ((read = is.read(bytes)) != -1) {
//输出客户端发送的数据
System.out.println(new String(bytes, 0, read));
} else {
break;
}

}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (is == null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println("关闭和client的连接");
if (socket == null) {
try {
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}

5、Java BIO 问题分析

  1. 每个请求都需要创建独立的线程,与对应的客户端进行数据 Read,业务处理,数据 Write 。
  2. 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
  3. 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费

3、Java NIO编程

1、Java NIO 基本介绍

  1. Java NIO 全称 java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO),是同步非阻塞

  2. NIO 相关类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写。

    image-20210816204510649

  3. NIO 有三大核心部分:Channel(通道)Buffer(缓冲区)Selector(选择器)

  4. NIO是 ==面向缓冲区== ,或者==面向 块 编程==的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络

  5. Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。【后面有案例说明

  6. 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来,根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞IO那样,非得分配10000个。

  7. HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比HTTP1.1大了好几个数量级。

2、NIO 和 BIO的比较

  1. BIO 以==流==的方式处理数据,而 NIO 以==块==的方式处理数据,块 I/O 的效率比流 I/O 高很多
  2. BIO 是==阻塞==的,NIO 则是==非阻塞==的
  3. BIO基于==字节流==和==字符流==进行操作,而 NIO 基于 ==Channel(通道)==和 ==Buffer(缓冲区)==进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于==监听多个通道的事件==(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道

3、NIO 三大核心原理示意图

一张图描述NIO 的 SelectorChannelBuffer 的关系:

image-20210816205036305

SelectorChannelBuffer 的关系图(简单版)关系图的说明:

  1. 每个channel 都会对应一个Buffer
  2. Selector 对应一个线程, 一个线程对应多个channel(连接)
  3. 该图反应了有三个channel 注册到 该selector //程序
  4. 程序切换到哪个channel 是有事件决定的,Event 就是一个重要的概念
  5. Selector 会根据不同的事件,在各个通道上切换
  6. Buffer 就是一个内存块 ,底层是有一个数组
  7. 数据的读取写入是通过Buffer,这个和BIO有着本质的不同:BIO 中要么是输入流,要么是输出流,不能双向,但是NIO的Buffer 是可以读也可以写,需要 flip 方法切换
  8. channel 是双向的,可以返回底层操作系统的情况,比如Linux:底层的操作系统通道就是双向的。

4、缓冲区(Buffer)

1、基本介绍

缓冲区(Buffer):缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组)**,该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况**。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer,如图: 【后面举例说明】

image-20210816220148414

2、Buffer 类及其子类

1、Buffer类继承关系

在 NIO 中,Buffer 是一个顶层父类,它是一个抽象类,类的层级关系图

  • 常用Buffer子类一览
    • ByteBuffer:存储字节数据到缓冲区
    • ShortBuffer:存储字符串数据到缓冲区
    • CharBuffer:存储字符数据到缓冲区
    • IntBuffer:存储整数数据到缓冲区
    • LongBuffer:存储长整型数据到缓冲区
    • DoubleBuffer:存储小数到缓冲区
    • FloatBuffer:存储小数到缓冲区
    • image-20210816222453542
    • image-20210816222527728
  • 每一个Buffer的实现类都有一个属性:hb(不同实现类该属性的类型不同,但都是一个数组),数据实际上就是存放在hb数组里面的
2、Buffer的四个主要属性

Buffer类定义了所有的缓冲区都具有的四个属性来提供关于其所包含的数据元素的信息:

  • Capacity:容量,即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变
  • Limit:表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作(左闭右开)。且极限是可以修改的
  • Position:位置,下一个要被读或写的元素的索引,每次读写缓冲区数据时都会改变改值,为下次读写作准备
  • Mark:标记(很少主动修改)
1
2
3
4
5
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;

其中最重要的flip()方法:用来切换Buffer的读写(其中对于limit是一个“左闭右开区间”)

1
2
3
4
5
6
7
8
public final Buffer flip() {
// 将当前所在位置设置成缓存区的终点
limit = position;
// 将当前位置归0
position = 0;
mark = -1;
return this;
}

左闭右开:

image-20210816233421568

3、Buffer类相关方法一览
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public abstract class Buffer {
//JDK1.4时,引入的api
public final int capacity();//返回此缓冲区的容量(重要)
public final int position();//返回此缓冲区的位置(重要)
public final Buffer position (int newPosition);//设置此缓冲区的位置(重要)
public final int limit();//返回此缓冲区的限制(重要)
public final Buffer limit (int newLimit);//设置此缓冲区的限制(重要)

public final Buffer mark();//在此缓冲区的位置设置标记
public final Buffer reset();//将此缓冲区的位置重置为以前标记的位置

public final Buffer clear();//清除此缓冲区, 即将各个标记恢复到初始状态,但是数据并没有真正擦除, 后面操作会覆盖(重要)
public final Buffer flip();//反转此缓冲区(重要)

public final Buffer rewind();//重绕此缓冲区
public final int remaining();//返回当前位置与限制之间的元素数

public final boolean hasRemaining();//告知在当前位置和限制之间是否有元素(重要)
public abstract boolean isReadOnly();//告知此缓冲区是否为只读缓冲区(重要)

//JDK1.6时引入的api
public abstract boolean hasArray();//告知此缓冲区是否具有可访问的底层实现数组(重要)
public abstract Object array();//返回此缓冲区的底层实现数组(重要)

public abstract int arrayOffset();//返回此缓冲区的底层实现数组中第一个缓冲区元素的偏移量
public abstract boolean isDirect();//告知此缓冲区是否为直接缓冲区
}

注:什么是直接缓冲区?

  • 直接缓冲区指的是操作系统的缓冲区
  • 而平常的缓冲区通常都是JVM分配的缓冲区
4、ByteBuffer(最常用)

从前面可以看出对于 Java 中的基本数据类型(boolean除外),都有一个 Buffer 类型与之相对应,最常用的自然是ByteBuffer 类(二进制数据),该类的主要方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class ByteBuffer {
//缓冲区创建相关api
public static ByteBuffer allocateDirect(int capacity);//创建直接缓冲区(重要)
public static ByteBuffer allocate(int capacity);//设置缓冲区的初始容量(重要)

public static ByteBuffer wrap(byte[] array);//把一个数组放到缓冲区中使用
//构造初始化位置offset和上界length的缓冲区
public static ByteBuffer wrap(byte[] array,int offset, int length);

//缓存区存取相关API
public abstract byte get( );//从当前位置position上get,get之后,position会自动+1(重要)
public abstract byte get (int index);//从绝对位置get(重要)
//从当前位置上添加,put之后,position会自动+1(重要)
public abstract ByteBuffer put (byte b);
public abstract ByteBuffer put (int index, byte b);//从绝对位置上put(重要)
}

image-20210816234508355

5、通道(Channel)

1、基本介绍

  1. NIO的通道类似于流,但有些区别如下:

    • 通道可以同时进行读写,而流只能读或者只能写
    • 通道可以实现异步读写数据
    • 通道可以从缓冲读数据,也可以写数据到缓冲:
      • image-20210816235045647
  2. BIO 中的 stream 是单向的,例如 FileInputStream 对象只能进行读取数据的操作,而 NIO 中的通道(Channel)是双向的,可以读操作,也可以写操作。

  3. Channel在NIO中是一个接口:

    • public interface Channel extends Closeable{} 
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32

      4. 常用的 Channel 类有:

      - `FileChannel`:用于文件的数据读写
      - `DatagramChannel`:用于 UDP 的数据读写
      - `ServerSocketChannel`:用于 TCP 的数据读写
      - ServerSocketChanne 类似 ServerSocket
      - `SocketChannel`:用于 TCP 的数据读写
      - SocketChannel 类似 Socket

      ![image-20210817001954099](Netty/image-20210817001954099.png)

      ![image-20210817002125912](Netty/image-20210817002125912.png)



      #### 2、FileChannel类

      FileChannel主要用来对本地文件进行 IO 操作,常见的方法有:

      ```java
      // 从通道读取数据并放到缓冲区中
      public int read(ByteBuffer dst);

      // 把缓冲区的数据写到通道中
      public int write(ByteBuffer src);

      // 从目标通道中复制数据到当前通道(可以用来做文件的拷贝,速度很快)
      public long transferFrom(ReadableByteChannel src, long position, long count);

      // 把数据从当前通道复制给目标通道(底层实现了零拷贝,速度很快)
      public long transferTo(long position, long count, WritableByteChannel target);

3、应用实例

1、应用实例1——本地文件写数据

实例要求:

  1. 使用前面的ByteBuffer(缓冲) 和 FileChannel(通道), 将 “hello,world” 写入到file01.txt 中
  2. 文件不存在就创建

分析:

image-20210817012929177

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.awo.nio;

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel01 {

public static void main(String[] args) throws IOException {
String str = "hello,world";
//创建一个输出流->channel

FileOutputStream fos = new FileOutputStream("D:\\编程\\netty\\src\\file01.txt");

//通过 fileOutputStream 获取 对应的 FileChannel
//这个 fileChannel 真实 类型是 FileChannelImpl
FileChannel fileChannel = fos.getChannel();

//创建一个缓冲区 ByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

//将 str 放入 byteBuffer
byteBuffer.put(str.getBytes());

//对byteBuffer 进行flip
byteBuffer.flip();

//将byteBuffer 数据写入到 fileChannel
fileChannel.write(byteBuffer);
fos.close();
}
}
2、应用实例2——本地文件读数据

实例要求:

  1. 使用前面的ByteBuffer(缓冲) 和 FileChannel(通道), 将 file01.txt 中的数据读入到程序,并显示在控制台屏幕
  2. 假定文件已经存在

分析:

image-20210817040423887

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.awo.nio;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel02 {
public static void main(String[] args) throws IOException {

//创建文件的输入流
File file = new File("D:\\编程\\netty\\src\\file01.txt");
FileInputStream fis = new FileInputStream(file);

//通过fileInputStream 获取对应的FileChannel -> 实际类型 FileChannelImpl
FileChannel channel = fis.getChannel();

//创建缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

//将 通道的数据读入到Buffer
channel.read(byteBuffer);

//将byteBuffer 的 字节数据 转成String
System.out.println(new String(byteBuffer.array()));
fis.close();
}
}
3、应用实例3——使用一个Buffer完成文件读取

实例要求:

  1. 使用 FileChannel(通道) 和 方法 read , write,完成文件的拷贝
  2. 拷贝一个文本文件 1.txt , 放在项目下即可

分析:

image-20210817040359556

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.awo.nio;

import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel03 {
public static void main(String[] args) throws IOException {

File file = new File("1.txt");
FileInputStream fis = new FileInputStream(file);
FileChannel readChannel = fis.getChannel();

FileOutputStream fos = new FileOutputStream("2.txt");
FileChannel writeChannel = fos.getChannel();

ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

//循环读取
while (true) {

//这里有一个重要的操作,一定不要忘了
//清空buffer,其实就是复位一下属性值
byteBuffer.clear();
int read = readChannel.read(byteBuffer);
//表示读完
if (read == -1) {
break;
}
//将buffer 中的数据写入到 fileChannel02 -- 2.txt
byteBuffer.flip();
writeChannel.write(byteBuffer);
}

//关闭相关的流
fis.close();
fos.close();
}
}

clear()的相关代码:

1
2
3
4
5
6
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
4、应用实例4——拷贝文件transferFrom 方法

实例要求:

  1. 使用 FileChannel(通道) 和 方法 transferFrom ,完成文件的拷贝
  2. 拷贝一张图片

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.awo.nio;

import java.io.*;
import java.nio.channels.FileChannel;

public class NIOFileChannel04 {
public static void main(String[] args) throws IOException {
//创建相关流
File file = new File("Koala.jpg");
FileInputStream fis = new FileInputStream(file);
FileOutputStream fos = new FileOutputStream("Koala01.jpg");

//获取各个流对应的filechannel
FileChannel sourceCh = fis.getChannel();
FileChannel destCh = fos.getChannel();

//使用transferForm完成拷贝
destCh.transferFrom(sourceCh, 0, sourceCh.size());
//关闭相关通道和流
destCh.close();
sourceCh.close();
fos.close();
fis.close();
}
}

4、关于Buffer 和 Channel的注意事项和细节

  1. ByteBuffer 支持类型化的put 和 get, put 放入的是什么数据类型,get就应该使用相应的数据类型来取出,否则可能有 BufferUnderflowException 异常。
  2. 可以将一个普通Buffer 转成只读Buffer:使用buffer.asReadOnlyBuffer();方法将一个普通buffer转换成只读Buffer
    • 如果在只读Buffer当中添加数据,会抛出一个ReadOnlyBufferException异常
  3. NIO 还提供了 MappedByteBuffer, 可以让文件直接在内存(堆外的内存)中进行修改, 而如何同步到文件由NIO 来完成。调用channel.map(FileChannel.MapMode.READ_WRITE, 0, 6);方法生成一个MappedByteBuffer对象
    • 注意:map的几个参数
      • MapMode mode:映射的模式——与上面创建流的模式对应
      • long position:从哪里开修改,即修改的开始位置
      • long size:修改的大小,如果修改的地方超过设置的修改大小,会抛出一个IndexOutOfBoundsException异常
  4. 前面我们讲的读写操作,都是通过一个Buffer 完成的,NIO 还支持 通过多个Buffer (即 Buffer 数组) 完成读写操作,即 ScatteringGathering
    • Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入 [分散读取]
    • Gathering: 从buffer读取数据时,可以采用buffer数组,依次读取 [聚集写入]
1、关于MappedByteBuffer的相关示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.awo.nio;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

/**
* 说明:MappedByteBuffer 可让文件直接在内存(堆外内存)修改, 操作系统不需要拷贝一次
*/
public class MappedByteBufferTest {
public static void main(String[] args) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile("1.txt", "rw");
//获取对应的通道
FileChannel channel = randomAccessFile.getChannel();

/**
* 参数1: FileChannel.MapMode.READ_WRITE 使用的读写模式
* 参数2: 0 : 可以直接修改的起始位置
* 参数3: 6: 是映射到内存的大小(不是索引位置) ,即将 1.txt 的多少个字节映射到内存
* 可以直接修改的范围就是 0-6
* 实际类型 DirectByteBuffer
*/
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 6);

mappedByteBuffer.put(0, (byte) 'H');
mappedByteBuffer.put(5, (byte) '-');
// 抛出异常:IndexOutOfBoundsException
// mappedByteBuffer.put(6, (byte) '9');

randomAccessFile.close();
System.out.println("修改成功");
}
}
2、关于 Scattering 和 Gathering的相关示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.awo.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Arrays;

/**
* Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入 [分散读取]
* Gathering: 从buffer读取数据时,可以采用buffer数组,依次读取 [聚集写入]
*/
public class ScatteringAndGatheringTest {
public static void main(String[] args) throws IOException {
//使用 ServerSocketChannel 和 SocketChannel 网络
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress inetSocketAddress = new InetSocketAddress(7777);

//绑定端口到socket ,并启动
serverSocketChannel.socket().bind(inetSocketAddress);

//创建buffer数组
ByteBuffer[] byteBuffers = new ByteBuffer[2];
byteBuffers[0] = ByteBuffer.allocate(5);
byteBuffers[1] = ByteBuffer.allocate(3);

//等客户端连接(telnet)
SocketChannel socketChannel = serverSocketChannel.accept();
//假定从客户端接收8个字节
int messageLength = 8;

//循环的读取
while (true) {
int byteRead = 0;
while (byteRead < messageLength) {
long read = socketChannel.read(byteBuffers);
//累计读取的字节数
byteRead += read;
System.out.println("byteRead=" + byteRead);
//使用流打印, 看看当前的这个buffer的position 和 limit
Arrays.asList(byteBuffers).stream().map(buffer -> "position=" + buffer.position() + ", limit=" + buffer.limit())
.forEach(System.out::println);
}
//将所有的buffer进行flip
Arrays.asList(byteBuffers).forEach(buffer -> buffer.flip());

//将数据读出显示到客户端
long byteWirte = 0;
while (byteWirte < messageLength) {
long write = socketChannel.write(byteBuffers);
byteWirte += write;
}

//将所有的buffer 进行clear
Arrays.asList(byteBuffers).forEach(buffer -> buffer.clear());
System.out.println("byteRead:=" + byteRead + " byteWrite=" + byteWirte + ", messagelength" + messageLength);
}

}
}

6、Selector(选择器)

1、基本介绍

  1. Java 的 NIO,用非阻塞的 IO 方式。可以用一个线程,处理多个的客户端连接,就会使用到Selector选择器
  2. **Selector 能够检测多个注册的通道上是否有事件发生(注意:多个Channel以事件的方式可以注册到同一个Selector)**,如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。
  3. 只有在 连接/通道 真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程
  4. 避免了多线程之间的上下文切换导致的开销

2、Selector示意图和特点说明

1、Selector示意图

image-20210817171431861

2、特点说明
  1. Netty 的 IO 线程 NioEventLoop 聚合了 Selector(选择器,也叫多路复用器),可以同时并发处理成百上千个客户端连接。
  2. 当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。
  3. 线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。
  4. 由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。
  5. 一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

3、Selector类相关方法

Selector 类是一个抽象类,以下为Selector的相关方法:

image-20210817172751502

常用方法和说明如下:

1
2
3
4
5
6
7
8
9
10
public abstract class Selector implements Closeable { 
//得到一个选择器对象
public static Selector open();

//监控所有注册的通道,当其中有 IO 操作可以进行时,将对应的 SelectionKey 加入到内部集合中并返回,参数用来设置超时时间
public int select(long timeout);

//从内部集合中得到所有的 SelectionKey
public Set<SelectionKey> selectedKeys();
}

4、注意事项

  1. NIO中的 ServerSocketChannel功能类似ServerSocket,SocketChannel功能类似Socket

  2. selector 相关方法说明:

    • selector.select()//阻塞
      
      selector.select(1000);//阻塞1000毫秒,在1000毫秒后返回
      
      selector.wakeup();//唤醒selector
      
      selector.selectNow();//不阻塞,立马返还
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118



      ### 7、NIO 非阻塞 网络编程

      #### 1、NIO 非阻塞 网络编程原理分析图

      NIO 非阻塞 网络编程相关的(`Selector`、`SelectionKey`、`ServerScoketChannel`和`SocketChannel`) 关系梳理图:

      ![image-20210817173445617](Netty/image-20210817173445617.png)

      对上图的说明:

      1. 当客户端连接时,会通过 ServerSocketChannel 得到 SocketChannel
      2. Selector 进行监听 select 方法, 返回有事件发生的通道的个数.
      3. 将 socketChannel 注册到Selector上,register(Selector sel, int ops), 一个selector上可以注册多个SocketChannel
      - 其中register(Selector sel, int ops)方法的两个参数:
      - Selector sel:想要注册到的选择器
      - int ops:SelectionKey与Channel的注册关系
      - `int OP_ACCEPT`:有新的网络连接可以 accept,值为 16
      - `int OP_CONNECT`:代表连接已经建立,值为 8
      - `int OP_READ`:代表读操作,值为 1
      - `int OP_WRITE`:代表写操作,值为 4
      4. 注册后返回一个 SelectionKey,会和该Selector 关联(集合)
      5. 进一步得到各个 SelectionKey (有事件发生)
      6. 在通过 SelectionKey 反向获取 SocketChannel,方法 channel()
      7. 可以通过channel()方法得到的 channel , 完成业务处理



      #### 2、NIO 非阻塞 网络编程快速入门

      案例要求:

      1. 编写一个 NIO 入门案例,实现服务器端和客户端之间的数据简单通讯(非阻塞)
      2. 目的:理解NIO非阻塞网络编程机制

      代码示例:

      - 服务端:

      ```java
      package com.awo.nio;

      import java.io.IOException;
      import java.net.InetSocketAddress;
      import java.nio.ByteBuffer;
      import java.nio.channels.*;
      import java.util.Iterator;
      import java.util.Set;

      public class NIOServer {

      public static void main(String[] args) throws IOException {
      //创建ServerSocketChannel -> ServerSocket
      ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
      //得到一个Selector对象
      Selector selector = Selector.open();
      //绑定一个端口6666, 在服务器端监听
      serverSocketChannel.socket().bind(new InetSocketAddress(6666));
      //设置为非阻塞
      serverSocketChannel.configureBlocking(false);
      //把 serverSocketChannel 注册到 selector 关心 事件为 OP_ACCEPT
      serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

      // 1
      System.out.println("注册后的Selectionkey 数量=" + selector.keys().size());

      //循环等待客户端连接
      while (true) {

      //这里我们等待1秒,如果没有事件发生, 返回
      if (selector.select(1000) == 0) {
      //没有事件发生
      System.out.println("服务器等待了1秒,无连接");
      continue;
      }
      //如果返回的>0, 就获取到相关的 selectionKey集合
      //1.如果返回的>0, 表示已经获取到关注的事件
      //2. selector.selectedKeys() 返回关注事件的集合
      // 通过 selectionKeys 反向获取通道
      Set<SelectionKey> selectionKeys = selector.selectedKeys();
      System.out.println("selectionKeys 数量 = " + selectionKeys.size());
      //遍历 Set<SelectionKey>, 使用迭代器遍历
      Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
      while (keyIterator.hasNext()) {
      //获取到SelectionKey
      SelectionKey selectionKey = keyIterator.next();
      //根据key 对应的通道发生的事件做相应处理
      //如果是 OP_ACCEPT, 有新的客户端连接
      if (selectionKey.isAcceptable()) {
      //该该客户端生成一个 SocketChannel
      SocketChannel socketChannel = serverSocketChannel.accept();
      System.out.println("客户端连接成功 生成了一个 socketChannel " + socketChannel.hashCode());
      //将 SocketChannel 设置为非阻塞
      socketChannel.configureBlocking(false);
      //将socketChannel 注册到selector, 关注事件为 OP_READ, 同时给socketChannel关联一个Buffer
      ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
      socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer);
      //2,3,4..
      System.out.println("客户端连接后 ,注册的selectionkey 数量=" + selector.keys().size());
      }
      //发生 OP_READ
      if (selectionKey.isReadable()) {
      //通过key 反向获取到对应channel
      SocketChannel channel = (SocketChannel) selectionKey.channel();
      //获取到该channel关联的buffer
      ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
      channel.read(buffer);
      System.out.println("form 客户端 " + new String(buffer.array()));
      }

      //手动从集合中移动当前的selectionKey, 防止重复操作
      keyIterator.remove();
      }
      }
      }
      }
  • 客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.awo.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class NIOClient {
public static void main(String[] args) throws IOException {
//得到一个网络通道
SocketChannel socketChannel = SocketChannel.open();
//设置非阻塞
socketChannel.configureBlocking(false);
//提供服务器端的ip 和 端口
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
//连接服务器
if (!socketChannel.connect(inetSocketAddress)) {
while (!socketChannel.finishConnect()) {
System.out.println("因为连接需要时间,客户端不会阻塞,可以做其它工作..");
}
}
//...如果连接成功,就发送数据
String str = "hello, world";
//Wraps a byte array into a buffer
ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes());
//发送数据,将 buffer 数据写入 channel
socketChannel.write(byteBuffer);
System.in.read();
}
}

8、SelectionKey

1、SelectionKey和网络通道的注册关系

SelectionKey,表示 Selector 和网络通道的注册关系, 共四种:

  • int OP_ACCEPT:有新的网络连接可以 accept,值为 16
  • int OP_CONNECT:代表连接已经建立,值为 8
  • int OP_READ:代表读操作,值为 1
  • int OP_WRITE:代表写操作,值为 4

相关源代码:

1
2
3
4
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

2、SelectionKey相关方法

image-20210817224717376

其中几个比较常用的方法:

1
2
3
4
5
6
7
8
9
public abstract class SelectionKey {
public abstract Selector selector();//得到与之关联的 Selector 对象
public abstract SelectableChannel channel();//得到与之关联的通道
public final Object attachment();//得到与之关联的共享数据
public abstract SelectionKey interestOps(int ops);//设置或改变监听事件
public final boolean isAcceptable();//是否可以 accept
public final boolean isReadable();//是否可以读
public final boolean isWritable();//是否可以写
}

9、ServerSocketChannel

ServerSocketChannel 在服务器端监听新的客户端 Socket 连接

ServerSocketChannel相关方法如下:

image-20210817225056336

常用方法以及说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class ServerSocketChannel extends AbstractSelectableChannel  implements NetworkChannel{
// 得到一个 ServerSocketChannel 通道(静态方法)
public static ServerSocketChannel open();

// 设置服务器端端口号
public final ServerSocketChannel bind(SocketAddress local);

// 设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);

// 接受一个连接,返回代表这个连接的通道对象
public SocketChannel accept();

// 注册一个选择器并设置监听事件
public final SelectionKey register(Selector sel, int ops);
}

10、SocketChannel

SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通道,或者把通道里的数据读到缓冲区。

相关方法如下:

image-20210817225834704

常用方法以及说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public abstract class SocketChannel extends AbstractSelectableChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel{

//得到一个 SocketChannel 通道
public static SocketChannel open();

//设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);

//连接服务器
public boolean connect(SocketAddress remote);

//如果上面的方法连接失败,接下来就要通过该方法完成连接操作
public boolean finishConnect();

//往通道里写数据
public int write(ByteBuffer src);

//从通道里读数据
public int read(ByteBuffer dst);

//注册一个选择器并设置监听事件,最后一个参数可以设置共享数据
public final SelectionKey register(Selector sel, int ops, Object att);

//关闭通道
public final void close();
}

11、NIO 网络编程应用实例——群聊系统

1、实例要求

  1. 编写一个 NIO 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
  2. 实现多人群聊
  3. 服务器端:可以监测用户上线,离线,并实现消息转发功能
  4. 客户端:通过channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(有服务器转发得到)
  5. 目的:进一步理解NIO非阻塞网络编程机制

2、实例需求图

image-20210817230238423

3、分析

  1. 先编写服务器端
    1. 服务器启动并监听 6667
    2. 服务器接收客户端信息,并实现转发 [处理上线和离线]
    3. 其中转发注意需要排除发送消息的客户端
  2. 编写客户端
    1. 连接服务器
    2. 发送消息
    3. 接收服务器消息

4、代码

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
package com.awo.nio.groupchat;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

public class GroupChatServer {
// 定义属性

private ServerSocketChannel listenChannel;
private Selector selector;
private static final int PORT = 6667;

// 构造器
// 初始化工作
public GroupChatServer() {
try {
// ServerSocketChannel
listenChannel = ServerSocketChannel.open();
// 得到选择器
selector = Selector.open();
// 绑定端口
listenChannel.socket().bind(new InetSocketAddress(PORT));
// 设置非阻塞模式
listenChannel.configureBlocking(false);
// 将该listenChannel 注册到selector
listenChannel.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 监听
*/
public void listen() {
System.out.println("监听线程: " + Thread.currentThread().getName());

try {
// 循环处理
while (true) {
int count = selector.select();
// 有事件处理
if (count > 0) {
// 得到SelectionKeys的迭代器
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
// 取出selectionkey
SelectionKey key = iterator.next();
// 监听到accept
if (key.isAcceptable()) {
// 得到socketChannel
SocketChannel socketChannel = listenChannel.accept();
// 设置非阻塞模式
socketChannel.configureBlocking(false);
// 将该 socketChannel 注册到 selector
socketChannel.register(selector, SelectionKey.OP_READ);
// 提示
System.out.println(socketChannel.getRemoteAddress() + " 上线 ");
}
// 通道发送read事件,即通道是可读的状态
if (key.isReadable()) {
// 调用readData处理读事件
readData(key);
}
// 当前的key 删除,防止重复处理
iterator.remove();
}
} else {
System.out.println("等待....");
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
//发生异常处理....
}
}

/**
* 读取客户端消息
* @param key
*/
private void readData(SelectionKey key) {
// 定义一个SocketChannel
SocketChannel channel = null;

try {
// 取到关联的channel
channel = (SocketChannel) key.channel();
// 创建buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从buffer读取数据到channel
int count = channel.read(buffer);
// 根据count的值做处理
if (count > 0) {
// 把缓存区的数据转成字符串
String msg = new String(buffer.array());
// 输出该消息
System.out.println("form 客户端: " + msg);
// 向其它的客户端转发消息(去掉自己), 专门写一个方法来处理
sendInfoToOtherClients(msg, channel);
}
} catch (IOException e) {
try {
System.out.println(channel.getRemoteAddress() + " 离线了..");
// 取消注册
key.cancel();
} catch (IOException ioException) {
ioException.printStackTrace();
} finally {
// 关闭通道
try {
channel.close();
} catch (IOException ioException) {
ioException.printStackTrace();
}
}
}
}

/**
* 向其它的客户端转发消息(去掉自己)
* @param msg 转发的消息
* @param self 自己
*/
private void sendInfoToOtherClients(String msg, SocketChannel self) {
System.out.println("服务器转发消息中...");
System.out.println("服务器转发数据给客户端线程: " + Thread.currentThread().getName());
// 遍历 所有注册到selector 上的 SocketChannel,并排除 self
for (SelectionKey key : selector.keys()) {
//通过 key 取出对应的 SocketChannel
Channel targetChannel = key.channel();

//排除自己
if (targetChannel instanceof SocketChannel && targetChannel != self) {
try {
// 转型
SocketChannel dest = (SocketChannel) targetChannel;
// 将msg 存储到buffer
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
// 将buffer 的数据写入 通道
dest.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

public static void main(String[] args) {
//创建服务器对象
GroupChatServer groupChatServer = new GroupChatServer();
groupChatServer.listen();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package com.awo.nio.groupchat;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;

public class GroupChatClient {

//定义相关的属性
private static final String HOST = "127.0.0.1";
private static final int PORT = 6667;
private SocketChannel socketChannel;
private Selector selector;
private String username;

//构造器, 完成初始化工作


public GroupChatClient() {
try {
socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT));
socketChannel.configureBlocking(false);
selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_READ);
username = socketChannel.getLocalAddress().toString().substring(1);
System.out.println(username + " is ok...");
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 向服务器发送消息
*
* @param info
*/
public void sendInfo(String info) {
info = username + " 说:" + info;
try {
socketChannel.write(ByteBuffer.wrap(info.getBytes()));
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 读取从服务器端回复的消息
*/
public void readInfo() {
try {
int readChannels = selector.select();
// 有可以用的通道
if (readChannels > 0) {
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isReadable()) {
// 得到相关的通道
SocketChannel channel = (SocketChannel) key.channel();
// 得到一个Buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 读取
channel.read(buffer);
// 把读到的缓冲区的数据转成字符串
String msg = new String(buffer.array());
System.out.println(msg.trim());
}
// 删除当前的selectionKey, 防止重复操作
iterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
GroupChatClient groupChatClient = new GroupChatClient();
// 启动一个线程, 每个3秒,读取从服务器发送数据
new Thread(() -> {
while (true) {
groupChatClient.readInfo();
try {
Thread.sleep(3000);
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();

// 发送数据给服务器端
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()) {
String s = scanner.nextLine();
groupChatClient.sendInfo(s);
}
}
}

12、NIO与零拷贝

1、零拷贝基本介绍

  1. 零拷贝是网络编程的关键,很多性能优化都离不开零拷贝。
  2. 在 Java 程序中,常用的零拷贝有 mmap(内存映射)sendFile。那么,他们在 OS 里,到底是怎么样的一个的设计?我们分析 mmap 和 sendFile 这两个零拷贝
  3. 另外我们看下NIO 中如何使用零拷贝

2、传统IO数据读写

代码:

1
2
3
4
5
6
7
8
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

byte[] arr = new byte[(int) file.length()];
raf.read(arr);

Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

3、传统IO模型

image-20210818031152788

注意:

  1. DMA:direct memory access——直接内存拷贝(不使用CPU)
  2. 这个IO经过了四次拷贝(两次CPU拷贝、两次DMA拷贝)和三次状态切换(用户态->内核态->用户态->内核态),代价较高
    • 四次拷贝
      1. 第一次: 从硬盘 经过 DMA 拷贝 到 kernel buffer (内核buferr)
      2. 第二次: 从kernel buffer 经过cpu 拷贝到 user buffer,比如拷贝到应用程序
      3. 第三次: 从user buffer 拷贝到 socket buffer
      4. 第四次: 从socket buffer 拷贝到 protocol engine 协议栈
    • 三次状态切换
      1. 第一次状态切换: 用户态 —> 内核态 (或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
      3. 第三次状态切换: 用户态—> 内核态
  3. 有一个观点认为状态切换变成了四次(最后需要从内核态切换为用户态)
    • 第四次状态切换:内核态—> 用户态

4、mmap优化

mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数

image-20210818032518521

注意:

  1. 通过mmap内存映射优化之后,拷贝次数变成了3次,状态切换还是3次
    • 三次拷贝
      1. 第一次拷贝: DMA拷贝,从硬件拷贝到内核空间
        • 因为user buffer 与kernel buffer共享数据 ,所以不需要将数据从kernel buffer 拷贝到 user buffer , 数据可以直接在内核空间修改
      2. 第二次拷贝: kernel buffer 中的数据经过 cpu 拷贝到 socket buffer
      3. 第三次拷贝: socket buffer 过DMA拷贝到protocol engine 协议栈
    • 三次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
      3. 第三次状态切换: 用户态—> 内核态
  2. 有一个观点认为状态切换变成了四次(最后需要从内核态切换为用户态)
    • 第四次状态切换:内核态—> 用户态

5、sendFile优化

Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。具体如下图和小结:

image-20210818033948497

注意:

  • Linux 2.1 版本中,通过sendFile优化之后,拷贝次数变成了3次,状态切换还是2次
    • 三次拷贝
      1. 第一次拷贝: DMA拷贝,从硬件拷贝到内核空间
      2. 第二次拷贝: kernel buffer 中的数据经过 cpu 拷贝到 socket buffer
      3. 第三次拷贝: socket buffer 过DMA拷贝到protocol engine 协议栈
    • 两次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
        • 由于和用户态完全无关,所以就不用切换到用户态后再切换到内核态了,减少了一次上下文切换

注:

  • 零拷贝从操作系统角度,是没有cpu 拷贝(DMA不可避免)
  • Linux 2.1 版本 提供了 sendFile 函数并没有完全实现零拷贝(存在CPU拷贝)

Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图和小结:

image-20210818040744312

注意:

  • Linux 在 2.4 版本中,通过sendFile优化之后,拷贝次数变成了2次,状态切换还是2次
    • 两次拷贝
      1. 第一次拷贝: DMA拷贝,将数据从硬盘拷贝到kernel buffer
      2. 第二次拷贝: DMA拷贝,将数据从kernel buffer拷贝到protocol engine
        • 没有经过cpu拷贝,也就是操作系统级别的拷贝,实现了真正的零拷贝
    • 两次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态

注:

  1. Linux2.4 提供的sendFile实现了真正的零拷贝
  2. 这里其实有 一次cpu 拷贝 kernel buffer -> socket buffer 但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略

6、零拷贝的再次理解

  1. 我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)
  2. 零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

7、mmap与sendFile的区别

  1. mmap 适合小数据量读写,sendFile 适合大文件传输。
  2. mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
  3. sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。

8、NIO零拷贝案例

案例要求:

  1. 使用传统的IO 方法传递一个大文件
  2. 使用NIO 零拷贝方式传递(transferTo)一个大文件
  3. 看看两种传递方式耗时时间分别是多少

代码:

传统IO的服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

//java IO 的服务器
public class OldIOServer {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(7001);

while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

try {
byte[] byteArray = new byte[4096];

while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);

if (-1 == readCount) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}

传统IO的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

//java IO 的服务器
public class OldIOServer {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(7001);

while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

try {
byte[] byteArray = new byte[4096];

while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);

if (-1 == readCount) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}

零拷贝的服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

//服务器
public class NewIOServer {
public static void main(String[] args) throws Exception {

InetSocketAddress address = new InetSocketAddress(7001);

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

ServerSocket serverSocket = serverSocketChannel.socket();

serverSocket.bind(address);

//创建buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();

int readcount = 0;
while (-1 != readcount) {
try {

readcount = socketChannel.read(byteBuffer);

}catch (Exception ex) {
// ex.printStackTrace();
break;
}
//
byteBuffer.rewind(); //倒带 position = 0 mark 作废
}
}
}
}

零拷贝的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;

public class NewIOClient {
public static void main(String[] args) throws Exception {

SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "protoc-3.6.1-win32.zip";

//得到一个文件channel
FileChannel fileChannel = new FileInputStream(filename).getChannel();

//准备发送
long startTime = System.currentTimeMillis();

//在linux下一个transferTo 方法就可以完成传输
//在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件, 而且要主要
//传输时的位置 =》 课后思考...
//transferTo 底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);

System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() - startTime));

//关闭
fileChannel.close();

}
}

结果:

传统IO:

1
发送的总的字节数 = 1,007,473 耗时:60

零拷贝:

1
发送的总的字节数 = 1,007,473 耗时:21

4、Java AIO 以及 三种IO模型的对比

1、Java AIO 基本介绍

  1. JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,常用到两种模式:ReactorProactorJava 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理
  2. AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
  3. 目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO, 因此我们就不详解AIO了,有兴趣的同学可以参考 <<Java新一代网络编程模型AIO原理及Linux系统AIO介绍>>

2、BIO、NIO、AIO对比表

BIO NIO AIO
IO 模型 同步阻塞 同步非阻塞(多路复用) 异步非阻塞
编程难度 简单 复杂 复杂
可靠性
可靠性

举例说明:

  1. 同步阻塞:到理发店理发,就一直等理发师,直到轮到自己理发。
  2. 同步非阻塞:到理发店理发,发现前面有其它人理发,给理发师说下,先干其他事情,一会过来看是否轮到自己。
  3. 异步非阻塞:给理发师打电话,让理发师上门服务,自己干其它事情,理发师自己来家给你理发

4、Netty概述

1、原生NIO存在的问题

  1. NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
  2. 需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序。
  3. 开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等等。
  4. JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。直到 JDK 1.7 版本该问题仍旧存在,没有被根本解决。

2、Netty官网

Netty官网上的说明:

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients

image-20210818173529694

3、Netty官网说明

  • Netty 是由 JBOSS 提供的一个 Java 开源框架。Netty 提供==异步==的、==基于事件驱动==的网络应用程序框架,用以快速开发高性能、高可靠性的网络 IO 程序
  • Netty 可以帮助你快速、简单的开发出一个网络应用,相当于简化和流程化了 NIO 的开发过程
  • Netty 是目前最流行的 NIO 框架,Netty 在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,知名的 Elasticsearch 、Dubbo 框架内部都采用了 Netty。

4、Netty的优点

Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。

  1. 设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池。
  2. 使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就足够了。
  3. 高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。
  4. 安全:完整的 SSL/TLS 和 StartTLS 支持。
  5. 社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入

5、Netty版本说明

  1. netty版本分为 netty3.x 和 netty4.x、netty5.x
  2. 因为Netty5出现重大bug,已经被官网废弃了,目前推荐使用的是Netty4.x的稳定版本
  3. 目前在官网可下载的版本 netty3.x netty4.0.x 和 netty4.1.x
  4. 本次以 Netty4.1.x 版本为主
  5. netty 下载地址

5、Netty 高性能架构设计

1、线程模型基本介绍

  1. 不同的线程模式,对程序的性能有很大影响,为了搞清Netty 线程模式,我们来系统的讲解下各个线程模式, 最后看看Netty 线程模型有什么优越性。
  2. 目前存在的线程模型有:
    • 传统阻塞 I/O 服务模型
    • Reactor 模式
  3. 根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现
    • 单 Reactor 单线程
    • 单 Reactor 多线程
    • 主从 Reactor 多线程
  4. Netty 线程模式(Netty 主要基于主从 Reactor 多线程模型做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor)

2、传统阻塞 I/O 服务模型

1、工作原理图

image-20210818180105435

黄色的框表示对象, 蓝色的框表示线程,白色的框表示方法(API)

2、模型特点

  1. 采用阻塞IO模式获取输入的数据
  2. 每个连接都需要独立的线程完成数据的输入,业务处理,数据返回

3、问题分析

  1. 当并发数很大,就会创建大量的线程,占用很大系统资源
  2. 连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在read 操作,造成线程资源浪费

3、Reactor 模式(整体)

1、针对传统阻塞 I/O 服务模型的 2 个缺点,解决方案

  1. ==基于 I/O 复用模型==:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理
    • `Reactor 对应的叫法:
      • 反应器模式
      • 分发者模式(Dispatcher)
      • 通知者模式(notifier)
  2. ==基于线程池复用线程资源==:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以处理多个连接的业务。

image-20210818181829563

2、工作原理图

I/O 复用结合线程池,就是 Reactor 模式基本设计思想,如图

image-20210818181932485

3、模型特点

  1. Reactor 模式,通过一个或多个输入同时传递给服务处理器的模式(基于事件驱动)
  2. 服务器端程序处理传入的多个请求,并将它们同步分派到相应的处理线程, 因此Reactor模式也叫 Dispatcher模式
  3. Reactor 模式使用IO复用监听事件,收到事件后,分发给某个线程(进程),这点就是网络服务器高并发处理关键

4、Reactor 模式中 核心组成

  • ReactorReactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
  • Handlers处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

5、Reactor 模式分类

根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:

  1. 单 Reactor 单线程
  2. 单 Reactor 多线程
  3. 主从 Reactor 多线程

4、单 Reactor 单线程

1、工作原理图

image-20210818182434248

2、原理图说明
  1. Select 是前面 I/O 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞对象监听多路连接请求
  2. Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发
  3. 如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理
  4. 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应
  5. Handler 会完成 Read→业务处理→Send 的完整业务流程

结合实例:服务器端用一个线程通过多路复用搞定所有的 IO 操作(包括连接,读、写等),编码简单,清晰明了,但是如果客户端连接数量较多,将无法支撑,前面的 NIO 案例就属于这种模型。

3、单 Reactor 单线程的优缺点
  • 优点:
    • 模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成
  • 缺点:
    • 性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈
    • 可靠性问题,线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障
  • 使用场景:
    • 客户端的数量有限,业务处理非常快速,比如 Redis在业务处理的时间复杂度 O(1) 的情况

5、单Reactor 多线程

1、工作原理图

image-20210818194738294

2、原理图说明

  1. Reactor 对象通过select 监控客户端请求事件,收到事件后,通过dispatch进行分发
  2. 如果建立连接请求,则由 Acceptor 通过accept 处理连接请求,然后创建一个Handler对象处理完成连接后的各种事件
  3. 如果不是连接请求,则由reactor分发调用连接对应的handler 来处理
  4. handler 只负责响应事件,不做具体的业务处理,通过read 读取数据后,会分发给后面的worker线程池的某个线程处理业务
  5. worker 线程池会分配独立线程完成真正的业务,并将结果返回给handler
  6. handler收到响应后,通过send 将结果返回给client

3、单Reactor 多线程的优缺点

  • 优点:可以充分的利用多核cpu 的处理能力
  • 缺点:多线程数据共享和访问比较复杂, reactor 处理所有的事件的监听和响应,在单线程运行, 在高并发场景容易出现性能瓶颈。

6、主从 Reactor 多线程

1、工作原理图

image-20210818195224698

针对单 Reactor 多线程模型中,Reactor 在单线程中运行,高并发场景下容易成为性能瓶颈,可以让 Reactor 在多线程中运行

2、原理图说明

  1. Reactor主线程 MainReactor 对象通过select 监听连接事件,收到事件后,通过Acceptor 处理连接事件
  2. 当 Acceptor 处理连接事件后,MainReactor 将连接分配给SubReactor
  3. subreactor 将连接加入到连接队列进行监听,并创建handler进行各种事件处理
  4. 当有新事件发生时, subreactor 就会调用对应的handler处理
  5. handler 通过read 读取数据,分发给后面的worker 线程处理
  6. worker 线程池分配独立的worker 线程进行业务处理,并返回结果
  7. handler 收到响应的结果后,再通过send 将结果返回给client
  8. Reactor 主线程可以对应多个Reactor 子线程,即MainRecator 可以关联多个SubReactor

3、Scalable IO in Java 对 Multiple Reactors 的原理图解

image-20210818195414795

4、主从 Reactor 多线程的优缺点

  • 优点:
    • 父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。
    • 父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。
  • 缺点:
    • 编程复杂度较高
  • 结合实例:这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持

7、Reactor 模式小结

1、3 种模式用生活案例来理解

  1. 单 Reactor 单线程,前台接待员和服务员是同一个人,全程为顾客服务
  2. 单 Reactor 多线程,1 个前台接待员,多个服务员,接待员只负责接待
  3. 主从 Reactor 多线程,多个前台接待员,多个服务生

2、Reactor 模式具有如下的优点

  • 响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的
  • 可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销
  • 扩展性好,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源
  • 复用性好,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性

8、Netty模型

1、工作原理图1——简单版

Netty 主要基于主从 Reactors 多线程模型(如图)做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor。

image-20210818210506772

  1. BossGroup 线程维护 Selector ,只关注 Accecpt 事件
  2. 当接收到Accept事件,获取到对应的SocketChannel,封装成 NIOScoketChannel并注册到Worker 线程(事件循环),并进行维护
  3. 当Worker线程监听到 selector 中通道发生自己感兴趣的事件后,就进行处理(就由handler进行处理), 注意handler 已经加入到通道

2、工作原理图2——进阶版

image-20210818213124808

Netty 主要基于主从 Reactors 多线程模型(如图)做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor

3、工作原理图3——详细版

image-20210818213257551

4、原理图说明

  1. Netty抽象出两组线程池:
    • BossGroup:专门负责接收客户端的连接
    • WorkerGroup: 专门负责网络的读写
  2. BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup
  3. NioEventLoopGroup 相当于一个事件循环组, 这个组中含有多个事件循环 ,每一个事件循环是 NioEventLoop
  4. NioEventLoop 表示一个不断循环的执行处理任务的线程, 每个NioEventLoop 都有一个selector,用于监听绑定在其上的socket的网络通讯
  5. NioEventLoopGroup 可以有多个线程,即可以含有多个NioEventLoop
  6. 每个Boss NioEventLoop 循环执行的步骤有3步
    1. 轮询accept 事件
    2. 处理accept 事件,与client建立连接,生成NioScocketChannel,并将其注册到某个worker NIOEventLoop 上的 selector
    3. 处理任务队列的任务,即 runAllTasks
  7. 每个 Worker NIOEventLoop 循环执行的步骤
    1. 轮询read/write 事件
    2. 处理i/o事件,即read/write 事件,在对应NioScocketChannel上进行处理
    3. 处理任务队列的任务 , 即 runAllTasks
  8. 每个Worker NIOEventLoop 处理业务时,会使用pipeline(管道),pipeline 中包含了 channel,即通过pipeline 可以获取到对应通道,管道中维护了很多的处理器(可对数据进行相关的拦截与过滤等等)。

5、Netty快速入门实例——TCP服务

  1. 实例要求:使用IDEA 创建Netty项目
  2. Netty 服务器在 6668 端口监听,客户端能发送消息给服务器 “hello, 服务器~”
  3. 服务器可以回复消息给客户端 “hello, 客户端~”
  4. 目的:对Netty 线程模型 有一个初步认识,便于理解Netty 模型理论
    1. 编写服务端
    2. 编写客户端
    3. 对netty 程序进行分析,看看netty模型特点

代码:

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.awo.netty.simple;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class NettyServer {

public static void main(String[] args) {

EventLoopGroup bossGroup = null;
EventLoopGroup workerGroup = null;

try {
//创建BossGroup 和 WorkerGroup
//说明
//1. 创建两个线程组 bossGroup 和 workerGroup
//2. bossGroup 只是处理连接请求 , 真正的和客户端业务处理,会交给 workerGroup完成
//3. 两个都是无限循环
//4. bossGroup 和 workerGroup 含有的子线程(NioEventLoop)的个数
// 默认实际 cpu核数 * 2
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();

//创建服务器端的启动对象,配置参数
ServerBootstrap bootstrap = new ServerBootstrap();
//使用链式编程来进行设置
bootstrap.group(bossGroup, workerGroup) //设置两个线程组
.channel(NioServerSocketChannel.class) //使用NioSocketChannel 作为服务器的通道实现
.option(ChannelOption.SO_BACKLOG,128) // 设置线程队列得到连接个数
.childOption(ChannelOption.SO_KEEPALIVE,true) //设置保持活动连接状态
// .handler(null) // 该 handler对应 bossGroup , childHandler 对应 workerGroup
.childHandler(new ChannelInitializer<SocketChannel>() { //创建一个通道初始化对象(匿名对象)
//给pipeline 设置处理器
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//可以使用一个集合管理 SocketChannel, 再推送消息时,可以将业务加入到各个channel 对应的 NIOEventLoop 的 taskQueue 或者 scheduleTaskQueue
System.out.println("客户socketchannel hashcode=" + socketChannel.hashCode());
socketChannel.pipeline().addLast(new NettyServerHandler());
}
}); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器

System.out.println(".....服务器 is ready...");

//绑定一个端口并且同步, 生成了一个 ChannelFuture 对象
//启动服务器(并绑定端口)
ChannelFuture channelFuture = bootstrap.bind(6668).sync();

//给ChannelFuture注册监听器,监控我们关心的事件
channelFuture.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
if (channelFuture.isSuccess()) {
System.out.println("监听端口 6668 成功");
} else {
System.out.println("监听端口 6668 失败");
}
}
});

//对关闭通道进行监听
channelFuture.channel().closeFuture().sync();

} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
}
}
}

服务端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.awo.netty.simple;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;

/**
* 说明:
* 1、我们自定义一个Handler 需要继承netty 规定好的某个HandlerAdapter(规范)
* 2、这时我们自定义一个Handler , 才能称为一个handler
*/
public class NettyServerHandler extends ChannelInboundHandlerAdapter {

/**
* 读取数据实际(这里我们可以读取客户端发送的消息)
* 1. ChannelHandlerContext ctx:上下文对象, 含有 管道pipeline , 通道channel, 地址
* 2. Object msg: 就是客户端发送的数据 默认Object
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//将 msg 转成一个 ByteBuf
//ByteBuf 是 Netty 提供的,不是 NIO 的 ByteBuffer.
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println("客户端发送消息是:" + byteBuf.toString(CharsetUtil.UTF_8));
System.out.println("客户端地址:" + ctx.channel().remoteAddress());
}

/**
* 数据读取完毕
* @param ctx
* @throws Exception
*/
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
//writeAndFlush 是 write + flush
//将数据写入到缓存,并刷新
//一般讲,我们对这个发送的数据进行编码
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵",CharsetUtil.UTF_8));
}

/**
* 处理异常, 一般是需要关闭通道
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.awo.netty.simple;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;

public class NettyClient {
public static void main(String[] args) {
//客户端需要一个事件循环组
EventLoopGroup group = new NioEventLoopGroup();
try {
//创建客户端启动对象
//注意客户端使用的不是 ServerBootstrap 而是 Bootstrap
Bootstrap bootstrap = new Bootstrap();
//设置相关参数
bootstrap.group(group) //设置线程组
.channel(NioSocketChannel.class) // 设置客户端通道的实现类(反射)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//加入自己的处理器
socketChannel.pipeline().addLast(new NettyClientHandler());
}
});

System.out.println("客户端 ok..");
//启动客户端去连接服务器端
//关于 ChannelFuture 要分析,涉及到netty的异步模型
ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 6668).sync();

//给关闭通道进行监听
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
group.shutdownGracefully();
}
}
}

客户端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.awo.netty.simple;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;

public class NettyClientHandler extends ChannelInboundHandlerAdapter {
/**
* 当通道就绪就会触发该方法
*
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, server: (>^ω^<)喵", CharsetUtil.UTF_8));
}

/**
* 当通道有读取事件时,会触发
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
System.out.println("服务器回复的消息:" + buf.toString(CharsetUtil.UTF_8));
System.out.println("服务器的地址: "+ ctx.channel().remoteAddress());
}

/**
* 处理异常, 一般是需要关闭通道
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

6、相关问题以及解答

问题1:bossGroup与workerGroup含有的子线程的数量

解答:默认为CPU核数的两倍,即CPU核数*2

相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
// NioEventLoopGroup构造函数(空参)
public NioEventLoopGroup() {
this(0);
}
// 最终调用了其父类MultithreadEventLoopGroup的构造方法
protected MultithreadEventLoopGroup(int nThreads, Executor executor, Object... args) {
// 这里的nThread就是上面this的参数
super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, executor, args);
}
// DEFAULT_EVENT_LOOP_THREADS相关说明
// 其中的NettyRuntime.availableProcessors()返回的就是CPU核数,然后乘以2返回
private static final int DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt("io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));
问题2:Netty服务端接收的新连接是如何绑定到worker线程池的(即worker线程池是怎么分配线程的)?

解答:通过==轮询==的方式,(假设worker线程的个数为8)首先为第一个连接分配线程1,接着为第二个连接分配线程2……然后为第八个连接分配线程8;如果之后还有连接到来的的话,在线程1空闲的情况下,会分配线程1到第九个连接。

问题3:ctx(上下文对象)里面包含的内容

image-20210819024626333

ctx实际上是一个数据流,有着出站与入站(inbound 入站 ,outbound 出站)

问题4:channel与 pipeline 之间的关系

pipeline(管道) 本质上是一个双向链表,有着头尾指针。一个pipeline 与一个 channel对应,可以通过pipeline 获取到它对应的channel

image-20210819025333778

channel(通道) :其中也包含了与channel对应的pipeline对象

image-20210819025741518

7、任务队列中的 Task 有 3 种典型使用场景

如果当前有一个非常耗时长(长时间的操作)的业务,如果正常地放在handler中去执行的话,势必会造成pipeline的阻塞。因此,对于某些任务的执行可以提交到NioEventLoop的TaskQueue任务队列中去异步执行。其实TaskQueue与Channel之间存在绑定关系。对于这些任务有以下3种典型的应用:

  1. 用户程序自定义的普通任务
  2. 用户自定义定时任务
  3. 非当前 Reactor 线程调用 Channel 的各种方法

例如在推送系统的业务线程里面,根据用户的标识,找到对应的 Channel 引用,然后调用 Write 类方法向该用户推送消息,就会进入到这种场景。最终的 Write 会提交到任务队列中后被异步消费

将这些任务从handler中提交到channel对应的NIOEventLoop 的 TaskQueue的方法:

1、用户程序自定义的普通任务 -> 提交到该channel 对应的NioEventLoop 的 taskQueue中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 用户程序自定义的普通任务
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵2", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});

/*
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(20 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵3", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});
*/

注意:

  • 该方法是通过ctx获得channel对象,在通过channel对象去获取该channel所在的evevtLoop,最后在将任务提交到eventLoop的taskQueue中

  • eventLoop会起一个线程去异步解决taskQueue当中的任务,==注意是一个线程==。如果taskQueue当中有多个任务的话,那么该线程会按照taskQueue中任务的顺序依次执行任务,即执行taskQueue任务的时间是累加的

    • eg:taskQueue的第一个任务花费10s,taskQueue的第二个任务花费20s,那么该线程执行完taskQueue当中的任务总共要花费30s
  • 解决方法:

    1. 在当前Handler中创建一个业务线程池,把耗时任务放到创建的线程池中执行。此时就变成了一个线程有一个业务线程池,来完成耗时任务的异步操作。(局部异步)

      • 创建线程池的方法:

        • // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);
          
          // 调用一下方法将耗时任务放在线程池创建的线程中进行执行
          group.sumbit(Callable task);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15

          2. **在Server端中创建一个业务线程池(Context中添加线程池)**(整个异步)

          - 创建线程池的方法:

          - ```java
          // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);

          // 在ChannelInitializer的initChannel方法中
          ChannelPipeline p = chpipeline();
          // 在这里将group设置进去:如果这样设置的话,该handler会优先加入到该线程池中,这样一来,workerGroup主要接收任务 然后在将任务提交给线程池来处理。
          // 默认没添加group的话,handler会进入workerLoopGroup的某一个workerLoop子线程
          p.addLast(group,new MyServerHandler());
2、用户自定义定时任务 -> 提交到该channel 对应的NioEventLoop 的 scheduleTaskQueue中
1
2
3
4
5
6
7
8
9
10
11
12
13
ctx.channel().eventLoop().schedule(new Runnable() {
@Override
public void run() {

try {
Thread.sleep(5 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵4", CharsetUtil.UTF_8));
System.out.println("channel code=" + ctx.channel().hashCode());
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
}, 5, TimeUnit.SECONDS);

注意:

  • 该任务在5s后执行
  • sleep 需要占用线程资源,这5s线程啥都干不了,延时5s执行任务,这5s线程可以做别的事情
  • taskQueue里的任务执行完毕后,会再执行scheduledtaskQueue。并且scheduled里的延迟时间是从taskQueue执行第一个任务之前开始算的
  • 并且如果scheduled延迟时间若小于taskQueue里的总执行时间,在后者执行完后前者会立即执行,而不会在后者运行期间执行前者。
  • 以上代码只是一个延迟任务,如果是定时任务的话还少了个参数,在第一个数字(延迟时间)后加一个间隔时间
3、非当前 Reactor 线程调用 Channel 的各种方法
1
2
3
4
5
6
7
8
9
10
// 在Server端的ServerBootstrap的配置中的childHandler进行初始化的时候就可以将客户端的SocketChannel维护在一个集合里面,方便之后的获取
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道初始化对象(匿名对象)
//给pipeline 设置处理器
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//可以使用一个集合管理 SocketChannel, 再推送消息时,可以将业务加入到各个channel 对应的 NIOEventLoop 的 taskQueue 或者 scheduleTaskQueue
System.out.println("客户socketchannel hashcode=" + ch.hashCode());
ch.pipeline().addLast(new NettyServerHandler());
}
}); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器

8、方案再说明

  1. Netty 抽象出两组线程池BossGroup 专门负责接收客户端连接WorkerGroup 专门负责网络读写操作
  2. NioEventLoop 表示一个不断循环执行处理任务的线程,每个 NioEventLoop 都有一个 selector,用于监听绑定在其上的 socket 网络通道
  3. NioEventLoop 内部采用==串行化设计==,从消息的读取->解码->处理->编码->发送,始终由 IO 线程 NioEventLoop 负责(如果在处理方面需要花费长时间的话就会阻塞这个流程,所以通常将花费长时间的任务放在taskQueue当中取异步执行)
    • NioEventLoopGroup 下包含多个 NioEventLoop
    • 每个 NioEventLoop 中包含有一个 Selector,一个 taskQueue
    • 每个 NioEventLoop 的 Selector 上可以注册监听多个 NioChannel
    • 每个 NioChannel 只会绑定在唯一的 NioEventLoop 上
    • 每个 NioChannel 都绑定有一个自己的 ChannelPipeline

6、异步模型

1、基本介绍

  1. 异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的组件在完成后,通过状态、通知和回调来通知调用者。
  2. Netty 中的 I/O 操作是异步的,包括 Bind、Write、Connect 等操作会简单的返回一个 ChannelFuture。
  3. 调用者并不能立刻获得结果,而是通过 ==Future-Listener 机制==,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果
  4. Netty 的异步模型是建立在 future 和 callback 的之上的。callback 就是回调。重点说 Future,它的核心思想是:假设一个方法 fun,计算过程可能非常耗时,等待 fun返回显然不合适。那么可以在调用 fun 的时候,立马返回一个 Future,后续可以通过 Future去监控方法 fun 的处理过程(即 : Future-Listener 机制)

2、Future说明

  1. 表示异步的执行结果,可以通过它提供的方法来检测执行是否完成,比如检索计算等等。

  2. ChannelFuture 是一个接口,我们可以添加监听器,当监听的事件发生时,就会通知到监听器。

    • public interface ChannelFuture extends Future<Void> {}
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40



      ### 3、工作原理图

      ![image-20210819212841178](Netty/image-20210819212841178.png)

      ![image-20210819212905885](Netty/image-20210819212905885.png)

      说明:

      1. 在使用 Netty 进行编程时,拦截操作和转换出入站数据只需要您提供 callback 或利用future 即可。这使得**链式操作**简单、高效, 并有利于编写可重用的、通用的代码。
      2. Netty 框架的目标就是让你的业务逻辑从网络基础应用编码中分离出来、解脱出来



      ### 4、Future-Listener 机制

      1. 当 Future 对象刚刚创建时,处于非完成状态,调用者可以通过返回的 ChannelFuture 来获取操作执行的状态,注册监听函数来执行完成后的操作。

      2. 常见有如下操作

      - 通过 `isDone` 方法来**判断当前操作是否完成**;
      - 通过 `isSuccess` 方法来**判断已完成的当前操作是否成功**;
      - 通过 `getCause` 方法来**获取已完成的当前操作失败的原因**;
      - 通过 `isCancelled` 方法来**判断已完成的当前操作是否被取消**;
      - 通过 `addListener` 方法来注册监听器,**当操作已完成(isDone 方法返回完成),将会通知指定的监听器;如果 Future 对象已完成,则通知指定的监听器**

      3. 举例说明

      - 演示:绑定端口是异步操作,当绑定操作处理完,将会调用相应的监听器处理逻辑

      - ```java
      serverBootstrap.bind(port).addListener(future -> {
      if(future.isSuccess()) {
      System.out.println(newDate() + ": 端口["+ port + "]绑定成功!");
      } else{
      System.err.println("端口["+ port + "]绑定失败!");
      }
      });

小结:相比传统阻塞 I/O,执行 I/O 操作后线程会被阻塞住,直到操作完成;异步处理的好处是不会造成线程阻塞,线程在 I/O 操作期间可以执行别的程序,在高并发情形下会更稳定和更高的吞吐量。

5、快速入门实例——HTTP服务

  1. 实例要求:使用IDEA 创建Netty项目
  2. Netty 服务器在 7777端口监听,浏览器发出请求 “http://localhost:7777/
  3. 服务器可以回复消息给客户端 “Hello! 我是服务器 5 “ ,并对特定请求资源进行过滤。
  4. 目的:Netty 可以做Http服务开发,并且理解Handler实例和客户端及其请求的关系。
  5. 效果:
    • image-20210819213544007

代码:

Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.awo.netty.http;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class TestServer {
public static void main(String[] args) {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).childHandler(new TestServerInitializer());
ChannelFuture channelFuture = serverBootstrap.bind(7777).sync();
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}

TestServerInitializer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.awo.netty.http;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpServerCodec;

public class TestServerInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//向管道加入处理器

//得到管道
ChannelPipeline pipeline = socketChannel.pipeline();
//加入一个netty 提供的httpServerCodec codec =>[coder - decoder]
//HttpServerCodec 说明
//1. HttpServerCodec 是netty 提供的处理http的 编-解码器
pipeline.addLast("MyHttpServerCodec", new HttpServerCodec());
//2. 增加一个自定义的handler
pipeline.addLast("MyTestHttpServerHandler", new TestHttpServerHandler());
System.out.println("ok~~~~");
}
}

TestHttpServerHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.awo.netty.http;


import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;

import java.net.URI;

/**
* 说明
* 1. SimpleChannelInboundHandler 是 ChannelInboundHandlerAdapter
* 2. HttpObject 客户端和服务器端相互通讯的数据被封装成 HttpObject
*/
public class TestHttpServerHandler extends SimpleChannelInboundHandler<HttpObject> {

/**
* channelRead0 读取客户端数据
*
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {

System.out.println("对应的channel=" + ctx.channel() + " pipeline=" + ctx
.pipeline() + " 通过pipeline获取channel" + ctx.pipeline().channel());

System.out.println("当前ctx的handler=" + ctx.handler());

//判断 msg 是不是 httprequest请求
if(msg instanceof HttpRequest) {

System.out.println("ctx 类型="+ctx.getClass());

System.out.println("pipeline hashcode" + ctx.pipeline().hashCode() + " TestHttpServerHandler hash=" + this.hashCode());

System.out.println("msg 类型=" + msg.getClass());
System.out.println("客户端地址" + ctx.channel().remoteAddress());

//获取到
HttpRequest httpRequest = (HttpRequest) msg;
//获取uri, 过滤指定的资源
URI uri = new URI(httpRequest.uri());
if("/favicon.ico".equals(uri.getPath())) {
System.out.println("请求了 favicon.ico, 不做响应");
return;
}
//回复信息给浏览器 [http协议]

ByteBuf content = Unpooled.copiedBuffer("hello, 我是服务器", CharsetUtil.UTF_8);

//构造一个http的相应,即 httpresponse
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, content);

response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json");
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());

//将构建好 response返回
ctx.writeAndFlush(response);
}
}
}

注意:

  • Http是无状态协议,而且建立的一般都是长链接,所以在刷新浏览器后,服务端会为本次的http请求创建新的handler和pipeline(一个handler与一个pipeline对应,为一组。多个http请求就会有多组handler与pipeline)

7、Netty 核心模块组件

1、Bootstrap、ServerBootstrap

  • Bootstrap 意思是引导,一个 Netty 应用通常由一个 Bootstrap 开始,主要作用是配置整个 Netty 程序,串联各个组件。
  • Netty 中 Bootstrap 类是==客户端==程序的启动引导类,ServerBootstrap 是==服务端==启动引导类

常见的方法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 该方法用于服务器端,用来设置两个 EventLoop
public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup);

// 该方法用于客户端,用来设置一个 EventLoop
public B group(EventLoopGroup group);

// 该方法用来设置一个服务器端的通道实现
public B channel(Class<? extends C> channelClass);

// 用来给 ServerChannel 添加配置
public <T> B option(ChannelOption<T> option, T value);

// 用来给接收到的通道添加配置
public <T> ServerBootstrap childOption(ChannelOption<T> childOption, T value);

// 该方法用来设置业务处理类(自定义的 handler)
// handler对应 bossGroup , childHandler 对应 workerGroup
public ServerBootstrap childHandler(ChannelHandler childHandler);

// 该方法用于服务器端,用来设置占用的端口号
public ChannelFuture bind(int inetPort);

// 该方法用于客户端,用来连接服务器端
public ChannelFuture connect(String inetHost, int inetPort);

2、Channel

  1. Netty 网络通信的组件,能够用于执行网络 I/O 操作。
  2. 通过Channel 可获得 当前网络连接的通道的状态
  3. 通过Channel 可获得 网络连接的配置参数 (例如接收缓冲区大小)
  4. Channel 提供异步的网络 I/O 操作(如建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成
  5. 调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以 I/O 操作成功、失败或取消时回调通知调用方
  6. 支持关联 I/O 操作与对应的处理程序
  7. 不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应,常用的 Channel 类型:
    • NioSocketChannel异步的客户端 TCP Socket 连接。
    • NioServerSocketChannel异步的服务器端 TCP Socket 连接。
    • NioDatagramChannel异步的 UDP 连接。
    • NioSctpChannel异步的客户端 Sctp 连接。
    • NioSctpServerChannel异步的 Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO。

3、Selector

  1. Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。
  2. 当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel

4、ChannelHandler 及其实现类

  1. ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。
  2. ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承它的子类

ChannelHandler 及其实现类一览图:

image-20210819233354647

  • ChannelInboundHandler:用于处理入站 I/O 事件。
  • ChannelOutboundHandler:用于处理出站 I/O 操作。

适配器模式:

  • ChannelInboundHandlerAdapter:用于处理入站 I/O 事件。
  • ChannelOutboundHandlerAdapter:用于处理出站 I/O 操作。
  • ChannelDuplexHandler:用于处理入站和出站事件。

为什么ChannelDuplexHandler既能解决入站事件,又能解决出站事件?

查看ChannelDuplexHandler的实现

1
public class ChannelDuplexHandler extends ChannelInboundHandlerAdapter implements ChannelOutboundHandler {...}
  • ChannelDuplexHandler继承了ChannelInboundHandlerAdapter类,所以能解决入站事件
  • ChannelDuplexHandler实现了ChannelOutboundHandler接口,所以能解决出站事件

我们经常需要自定义一个 Handler 类去继承 ChannelInboundHandlerAdapter,然后通过重写相应方法实现业务逻辑,我们接下来看看一般都需要重写哪些方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ChannelInboundHandlerAdapter extends ChannelHandlerAdapter implements ChannelInboundHandler {
public ChannelInboundHandlerAdapter() {}
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelRegistered();
}
public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelUnregistered();
}
// 通道就绪事件
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelActive();
}
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelInactive();
}
// 通道读取数据事件
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ctx.fireChannelRead(msg);
}
// 数据读取完毕事件
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelReadComplete();
}
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
ctx.fireUserEventTriggered(evt);}
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelWritabilityChanged();
}
// 通道发生异常事件
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.fireExceptionCaught(cause);
}
}

ChannelInboundHandlerAdapter的所有实现方法:

image-20210819234347536

image-20210819234419147

5、Pipeline 和 ChannelPipeline

ChannelPipeline 是一个重点:

  1. ChannelPipeline 是一个 Handler 的集合,它负责处理和拦截 inbound 或者 outbound 的事件和操作,相当于一个贯穿 Netty 的链。(也可以这样理解:ChannelPipeline 是 保存 ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作)

  2. ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互

  3. 在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应,它们的组成关系如下

    • image-20210819234704352

    • 一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler

      • 可以通过Channel拿到ChannelPipeline,也可以通过ChannelPipeline拿到Channel(双方都包含对方的引用)

      • ChannelHandlerContext实际上是一个接口,在双向链表当中的ChannelHandlerContext实际上是ChannelHandlerContext的实现类 DefaultChannelHandlerContext

        • public interface ChannelHandlerContext extends AttributeMap, ChannelInboundInvoker, ChannelOutboundInvoker {...}
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16

          - 入站事件和出站事件在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 handler,出站事件会从链表 tail 往前传递到最前一个出站的 handler,两种类型的 handler 互不干扰

          - ChannelPipeline提供了ChannelHandler链的容器。以==客户端==应用程序为例:
          - **如果事件的运动方向是从客户端到服务端的,那么我们称这些事件为出站的**,即客户端发送给服务端的数据会通过pipeline中的一系列ChannelOutboundHandler,并被这些Handler处理,以上图就是从链表 tail 往前传递到最前一个出站的 handler (head)
          - **如果事件的运动方向是从服务端到客户端的,那么我们称这些事件为入站的**,即服务端发送给客户端的数据会通过pipeline中的一系列ChannelInboundHandler,并被这些Handler处理,以上图就是从链表 head 往后传递到最后一个入站的 handler(tail)。
          - 前面客户端和服务端都是Inbound是因为他们都要读对方的消息,读取对方的消息就是入站

          4. 常用方法

          - ```java
          // 把一个业务处理类(handler)添加到链中的第一个位置
          ChannelPipeline addFirst(ChannelHandler... handlers);

          // 把一个业务处理类(handler)添加到链中的最后一个位置
          ChannelPipeline addLast(ChannelHandler... handlers);

6、ChannelHandlerContext

  1. 保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象

  2. 即ChannelHandlerContext 中包含一个具体的事件处理器 ChannelHandler , 同时ChannelHandlerContext 中也绑定了对应的 pipeline 和 Channel 的信息,方便对 ChannelHandler进行调用。

  3. 常用方法

    • // 关闭通道
      ChannelFuture close();
      
      // 刷新
      ChannelOutboundInvoker flush();
      
      // 将数据写到 ChannelPipeline 中当前ChannelHandler 的下一个 ChannelHandler 开始处理(出站)
      ChannelFuture writeAndFlush(Object msg);
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33



      ### 7、ChannelOption

      1. Netty 在创建 Channel 实例后,一般都需要设置 ChannelOption 参数。
      2. ChannelOption 参数如下:
      - `ChannelOption.SO_BACKLOG`:对应 TCP/IP 协议 listen 函数中的 backlog 参数,**用来初始化服务器可连接队列大小**。服务端处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接。多个客户端来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理,backlog 参数指定了队列的大小。
      - `ChannelOption.SO_KEEPALIVE`:一直保持连接活动状态



      ### 8、EventLoopGroup 和其实现类 NioEventLoopGroup

      1. EventLoopGroup 是一组 EventLoop 的抽象,Netty 为了更好的利用多核 CPU 资源,一般会有多个 EventLoop 同时工作,每个 EventLoop 维护着一个 Selector 实例。

      2. EventLoopGroup 提供 next 接口,可以从组里面按照一定规则获取其中一个 EventLoop来处理任务。在 Netty ==服务器端==编程中,我们一般都需要提供两个 EventLoopGroup,例如:`BossEventLoopGroup` 和 `WorkerEventLoopGroup`。

      3. 通常一个服务端口,即一个 ServerSocketChannel 对应一个Selector 和一个EventLoop线程。BossEventLoop 负责接收客户端的连接并将 SocketChannel 交给 WorkerEventLoopGroup 来进行 IO 处理,如下图所示

      - ![image-20210820000928747](Netty/image-20210820000928747.png)
      - BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护着一个注册了ServerSocketChannel 的 Selector 实例,BossEventLoop 不断轮询 Selector 将连接事件分离出来
      - 通常是 `OP_ACCEPT` 事件,然后将接收到的 SocketChannel 交给 WorkerEventLoopGroup
      - WorkerEventLoopGroup 会由 next 选择其中一个 EventLoop来将这个 SocketChannel 注册到其维护的 Selector 并对其后续的 IO 事件进行处理

      4. 常用方法

      - ```java
      // 构造方法
      public NioEventLoopGroup();

      // 断开连接,关闭线程
      public Future<?> shutdownGracefully();

9、Unpooled 类

  1. Netty 提供一个专门用来操作缓冲区(即Netty的数据容器)的工具类

  2. 常用方法如下所示

    • //通过给定的数据和字符编码返回一个 ByteBuf 对象(类似于 NIO 中的 ByteBuffer 但有区别)
      public static ByteBuf copiedBuffer(CharSequence string, Charset charset)
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19

      ![image-20210820003558340](Netty/image-20210820003558340.png)

      ```java
      // 创建一个ByteBuf
      ByteBuf buffer = Unpooled.buffer(10);

      // 常用方法
      // 写入
      buffer.writeByte(i);

      // 读取
      buffer.readByte();

      // 根据下标读取
      buffer.getByte(i);

      // 获取buffer的长度
      buffer.capacity();

结合上图与代码对ByteBuf进行讲解:

  • 创建一个ByteBuf对象,该对象包含一个数组arr , 是一个byte[10]
  • 在netty 的ByteBuf中,不需要先nio中的ByteBuffer一样,使用flip 进行读写反转
    • 原因:netty 的ByteBuf在底层维护了两个变量:readerindexwriterIndex(双指针模式)。其中
      • readerindex:用于记录ByteBuf读时的位置
      • writerIndex:用于记录ByteBuf写时的位置
    • netty 的ByteBuf在底层还维护了一个重要的变量:capacity——用来保存ByteBuf的底层byte[]数组的长度
    • 通过这3个变量的合作,完成了netty的ByteBuf的读写相关操作
  • 通过 readerindex 和 writerIndex 和 capacity, 将buffer分成三个区域(从上图可以看出)
    • [0,readerindex):已经读取的区域
    • [readerindex,writerIndex):可读的区域
    • [writerIndex,capacity):可写的区域
  • 在ByteBuf读取的方法中,有着 getByte(int) 与 readByte()两个方法,两者的区别:
    • 对于readByte()方法:readerindex会随着readByte()方法的执行而增加
    • 对于getByte(int)方法:readerindex不会随着readByte()方法的执行而增加
  • 对于ByteBuf的写方法:writeByte()——writerIndex会随着writeByte()方法的执行而增加
  • 注意:
    • 如果使用了ByteBuf的readByte()方法进行读取的时候,由于readerindex会随着readByte()方法的执行而增加,所以在进行第二次读取的时候会发生数组下标越界异常,需要我们调用ByteBuf的readerIndex(int readIndex)方法重新设置读取位置。

也可以通过以下方法创建一个ByteBuf对象:

1
2
//创建ByteBuf
ByteBuf byteBuf = Unpooled.copiedBuffer("hello,world!", Charset.forName("utf-8"));

ByteBuf的一些API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 查看当前的ByteBuf是否有数组支撑
byteBuf.hasArray();

// 将当前ByteBuf转成char[]数组
byte[] content = byteBuf.array();

// 将 content 转成字符串
new String(content, Charset.forName("utf-8"));

// 获取ByteBuf的偏移量
byteBuf.arrayOffset(); // 0

// 获取ByteBuf的readerindex
byteBuf.readerIndex(); // 0

// 获取ByteBuf的writerIndex
byteBuf.writerIndex(); // 12

// 获取ByteBuf的容量
byteBuf.capacity(); // 36

// 获取ByteBuf的可读的字节数,根据readerIndex推出来的
// 如果在这之前调用了ByteBuf的readByte()方法,则byteBuf.readableBytes();返回的值为11
byteBuf.readableBytes(); // 12

// 按照某个范围读取,其中第一个参数:从哪里开始;第二个参数:读取的长度;第三参数:格式
byteBuf.getCharSequence(0, 4, Charset.forName("utf-8"));

注意:通过这种方式创建出来的bytebuf对象的底层实际上是UnpooledByteBufAllocator的内部类InstrumentedUnpooledUnsafeHeapByteBuf类型。

image-20210820035309447

10、Netty应用实例-群聊系统

实例要求:

  1. 编写一个 Netty 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
  2. 实现多人群聊
  3. 服务器端:可以监测用户上线,离线,并实现消息转发功能
  4. 客户端:通过channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(有服务器转发得到)
  5. 目的:进一步理解Netty非阻塞网络编程机制
  6. 效果:
    • image-20210820001615735

代码:

服务器端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.awo.netty.groupchat;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class GroupChatServer {

private final int port;

public GroupChatServer(int port) {
this.port = port;
}

/**
* 编写run方法,处理客户端的请求
*/
public void run() {
//创建两个线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
ServerBootstrap serverBootstrap = new ServerBootstrap()
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG,128)
.childOption(ChannelOption.SO_KEEPALIVE,true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//获取到pipeline
ChannelPipeline pipeline = socketChannel.pipeline();
//向pipeline加入解码器
pipeline.addLast("decoder", new StringDecoder());
//向pipeline加入编码器
pipeline.addLast("encoder", new StringEncoder());
//加入自己的业务处理handler
pipeline.addLast(new GroupChatServerHandler());
}
});
System.out.println("netty 服务器启动");
ChannelFuture future = serverBootstrap.bind(port).sync();
//监听关闭
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}

public static void main(String[] args) {
new GroupChatServer(7777).run();
}
}

服务器端的handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package com.awo.netty.groupchat;

import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.util.concurrent.GlobalEventExecutor;

import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;

public class GroupChatServerHandler extends SimpleChannelInboundHandler<String> {

//定义一个channle 组,管理所有的channel
//GlobalEventExecutor.INSTANCE) 是全局的事件执行器,是一个单例
private static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

/**
* handlerAdded 表示连接建立,一旦连接,第一个被执行
* 将当前channel 加入到 channelGroup
* @param ctx
* @throws Exception
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
//将该客户加入聊天的信息推送给其它在线的客户端
/*
该方法会将 channelGroup 中所有的channel 遍历,并发送 消息,
我们不需要自己遍历
*/
channel.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 加入聊天" + " \n");
channelGroup.add(channel);
}

/**
* 断开连接, 将xx客户离开信息推送给当前在线的客户
* @param ctx
* @throws Exception
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
channel.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 离开了" + " \n");
}

/**
* 表示channel 处于活动状态, 提示 xx上线
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
System.out.println("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 上线了~");
}

/**
* 表示channel 处于不活动状态, 提示 xx离线了
* @param ctx
* @throws Exception
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
System.out.println("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 离线了~");
}

/**
* 读取数据
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
//获取到当前channel
Channel channel = ctx.channel();
//这时我们遍历channelGroup, 根据不同的情况,回送不同的消息
channelGroup.forEach(ch -> {
if (ch != channel) {
ch.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 发送了消息" + msg + "\n");
} else { //回显自己发送的消息给自己
channel.writeAndFlush("[自己 " + sdf.format(new Date()) + "]" + msg + "\n");
}
});
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//关闭通道
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.awo.netty.groupchat;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

import java.util.Scanner;

public class GroupChatClient {
//属性
private final String host;
private final int port;

public GroupChatClient(String host, int port) {
this.host = host;
this.port = port;
}

public void run() {
EventLoopGroup group = new NioEventLoopGroup();

try {
Bootstrap bootstrap = new Bootstrap()
.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//获取到pipeline
ChannelPipeline pipeline = socketChannel.pipeline();
//向pipeline加入解码器
pipeline.addLast("decoder", new StringDecoder());
//向pipeline加入编码器
pipeline.addLast("encoder", new StringEncoder());
//加入自己的业务处理handler
pipeline.addLast(new GroupChatClientHandler());
}
});

ChannelFuture channelFuture = bootstrap.connect(host, port).sync();
//得到channel
Channel channel = channelFuture.channel();
System.out.println("-------" + channel.localAddress()+ "--------");
//客户端需要输入信息,创建一个扫描器
Scanner sc = new Scanner(System.in);
while (sc.hasNextLine()) {
String msg = null;
msg = sc.nextLine();
//通过channel 发送到服务器端
channel.writeAndFlush(msg + "\r\n");
}
//channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
group.shutdownGracefully();
}
}

public static void main(String[] args) {
new GroupChatClient("localhost", 7777).run();
}
}

客户端的handler:

1
2
3
4
5
6
7
8
9
10
11
package com.awo.netty.groupchat;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public class GroupChatClientHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, String msg) throws Exception {
System.out.println(msg.trim());
}
}

sync()是因为:本身bootstrap里的任务如:监听器等等是异步的。所以适用此方法等待异步方法处理完毕再完成启动

11、Netty心跳检测机制案例

实例要求:

  1. 编写一个 Netty心跳检测机制案例, 当服务器超过3秒没有读时,就提示读空闲
  2. 当服务器超过5秒没有写操作时,就提示写空闲
  3. 实现当服务器超过7秒没有读或者写操作时,就提示读写空闲

代码:

服务器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.atguigu.netty.heartbeat;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.timeout.IdleStateHandler;

import java.util.concurrent.TimeUnit;

public class MyServer {
public static void main(String[] args) throws Exception{


//创建两个线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(); //8个NioEventLoop
try {

ServerBootstrap serverBootstrap = new ServerBootstrap();

serverBootstrap.group(bossGroup, workerGroup);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.handler(new LoggingHandler(LogLevel.INFO));
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {

@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
//加入一个netty 提供 IdleStateHandler
pipeline.addLast(new IdleStateHandler(7000,7000,10, TimeUnit.SECONDS));
//加入一个对空闲检测进一步处理的handler(自定义)
pipeline.addLast(new MyServerHandler());
}
});

//启动服务器
ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}

服务器的handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.atguigu.netty.heartbeat;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent;

public class MyServerHandler extends ChannelInboundHandlerAdapter {

/**
*
* @param ctx 上下文
* @param evt 事件
* @throws Exception
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {

if(evt instanceof IdleStateEvent) {

//将 evt 向下转型 IdleStateEvent
IdleStateEvent event = (IdleStateEvent) evt;
String eventType = null;
switch (event.state()) {
case READER_IDLE:
eventType = "读空闲";
break;
case WRITER_IDLE:
eventType = "写空闲";
break;
case ALL_IDLE:
eventType = "读写空闲";
break;
}
System.out.println(ctx.channel().remoteAddress() + "--超时时间--" + eventType);
System.out.println("服务器做相应处理..");

//如果发生空闲,我们关闭通道
// ctx.channel().close();
}
}
}

对于以上代码的几点说明:

  • **handler(new LoggingHandler(LogLevel.INFO));**:这代码的作用是在bossGroup开启日志处理

  • IdleStateHandler类的相关说明:

    • IdleStateHandler 是netty 提供的处理空闲状态的处理器

    • 文档说明:==triggers an {@link IdleStateEvent} when a {@link Channel} has not performed read, write, or both operation for a while.==

    • public class IdleStateHandler extends ChannelDuplexHandler {
      
          public IdleStateHandler(long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit) {
              this(false, readerIdleTime, writerIdleTime, allIdleTime, unit);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61

      - IdleStateHandler继承了ChannelDuplexHandler,说明了它既能处理入站事件,也能处理出站事件

      - IdleStateHandler的构造方法的参数说明:

      1. **long readerIdleTime**:表示多长时间没有读,就会发送一个心跳检测包检测是否连接
      2. **long writerIdleTime**:表示多长时间没有写,就会发送一个心跳检测包检测是否连接
      3. **long allIdleTime**:表示多长时间没有读写,就会发送一个心跳检测包检测是否连接
      4. **TimeUnit unit**:时间单位

      - 当 IdleStateEvent 触发后,就会传递给管道 的下一个handler去处理
      * 通过调用(触发)下一个handler 的 `userEventTiggered`方法,在该方法中去处理 IdleStateEvent(读空闲,写空闲,读写空闲)



      ### 12、Netty 通过WebSocket编程实现服务器和客户端长连接

      实例要求:

      1. Http协议是无状态的, 浏览器和服务器间的请求响应一次,下一次会重新创建连接.
      2. 要求:实现基于webSocket的长连接的全双工的交互
      3. 改变Http协议多次请求的约束,实现长连接了, 服务器可以发送消息给浏览器
      4. 客户端浏览器和服务器端会相互感知,比如服务器关闭了,浏览器会感知,同样浏览器关闭了,服务器会感知
      5. 效果:
      - ![image-20210820001859808](Netty/image-20210820001859808.png)

      代码:

      服务器端:(ChannelInitializer<SocketChannel>当中的内容,其他与上面类似)

      ```java
      serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {

      @Override
      protected void initChannel(SocketChannel ch) throws Exception {
      ChannelPipeline pipeline = ch.pipeline();

      //因为基于http协议,使用http的编码和解码器
      pipeline.addLast(new HttpServerCodec());
      //是以块方式写,添加ChunkedWriteHandler处理器
      pipeline.addLast(new ChunkedWriteHandler());

      /*
      说明
      1. http数据在传输过程中是分段, HttpObjectAggregator ,就是可以将多个段聚合
      2. 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
      */
      pipeline.addLast(new HttpObjectAggregator(8192));
      /*
      说明
      1. 对应websocket ,它的数据是以 帧(frame) 形式传递
      2. 可以看到WebSocketFrame 下面有六个子类
      3. 浏览器请求时 ws://localhost:7000/hello 表示请求的uri
      4. WebSocketServerProtocolHandler 核心功能是将 http协议升级为 ws协议 , 保持长连接
      */
      pipeline.addLast(new WebSocketServerProtocolHandler("/hello2"));

      //自定义的handler ,处理业务逻辑
      pipeline.addLast(new MyTextWebSocketFrameHandler());
      }
      });

服务器端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.atguigu.netty.websocket;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;

import java.time.LocalDateTime;

//这里 TextWebSocketFrame 类型,表示一个文本帧(frame)
public class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame>{
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {

System.out.println("服务器收到消息 " + msg.text());

//回复消息
ctx.channel().writeAndFlush(new TextWebSocketFrame("服务器时间" + LocalDateTime.now() + " " + msg.text()));
}

//当web客户端连接后, 触发方法
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
//id 表示唯一的值,LongText 是唯一的 ShortText 不是唯一
System.out.println("handlerAdded 被调用" + ctx.channel().id().asLongText());
System.out.println("handlerAdded 被调用" + ctx.channel().id().asShortText());
}


@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {

System.out.println("handlerRemoved 被调用" + ctx.channel().id().asLongText());
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("异常发生 " + cause.getMessage());
ctx.close(); //关闭连接
}
}

对以上代码的几点说明:

  • 由于建立的是webSocket长连接,http为短连接,需要将http协议升级为ws协议
  • 对于http:
    • http数据在传输过程中是分段传输,所以需要添加HttpObjectAggregator ,可以将多个段的数据进行聚合
    • 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
  • 对于webSocket:
    • webSocket的数据是以 帧(frame) 形式传递,所以在进行数据处理的时候都是以帧为单位进行处理的
    • 对于ws协议:
      • 浏览器请求时 ws://localhost:7000/xxx:表示请求的uri
      • http协议升级为ws协议的方法:是通过一个 状态码 101
        • image-20210821000047461
    • netty与ws协议的一些方法:
      • webSocket数据对应类:WebSocketFrame,其下有六个子类,分别应用在不同的场景
        • image-20210820233534694
      • WebSocketServerProtocolHandler:核心功能是将 http协议升级为 ws协议,保持长连接

8、Google Protobuf

1、编码和解码的基本介绍

  1. 编写网络应用程序时,因为数据在网络中传输的都是二进制字节码数据,在发送数据时就需要编码,接收数据时就需要解码
    • image-20210821000350763
  2. codec(编解码器) 的组成部分有两个:decoder(解码器)encoder(编码器)
    • encoder:负责把业务数据转换成字节码数据
    • decoder:负责把字节码数据转换成业务数据

2、Netty 本身的编码解码的机制和问题分析

  1. Netty 自身提供了一些 codec(编解码器)
  2. Netty 提供的编码器encoder
    • StringEncoder:对字符串数据进行编码
    • ObjectEncoder:对 Java 对象进行编码
    • ……
  3. Netty 提供的解码器decoder
    • StringDecoder:对字符串数据进行解码
    • ObjectDecoder:对 Java 对象进行解码
    • ……
  4. Netty 本身自带的 ObjectDecoder 和 ObjectEncoder 可以用来实现 POJO 对象或各种业务对象的编码和解码,底层使用的仍是 Java 序列化技术,而Java 序列化技术本身效率就不高,存在如下问题:
    • 无法跨语言
    • 序列化后的体积太大,是二进制编码的 5 倍多。
    • 序列化性能太低
    • => 引出 新的解决方案 [Google 的 Protobuf]

3、Protobuf

1、Protobuf基本介绍和使用示意图

  1. Protobuf 是 Google 发布的开源项目,全称 Google Protocol Buffers,是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC[远程过程调用 remote procedure call ] 数据交换格式 。
    目前很多公司将http+json 替换成 tcp+protobuf
  2. 参考文档 :语言指南
  3. Protobuf 是以 message 的方式来管理数据的
  4. 支持跨平台跨语言,即[客户端和服务器端可以是不同的语言编写的] (支持目前绝大多数语言,例如 C++、C#、Java、python 等)
  5. 高性能,高可靠性
  6. 使用 protobuf 编译器能自动生成代码,Protobuf 是将类的定义使用.proto 文件进行描述
    • 说明,在idea 中编写 .proto 文件时,会自动提示是否下载 .ptotot 编写插件. 可以让语法高亮
  7. 然后通过 protoc.exe 编译器根据.proto 自动生成.java 文件
  8. protobuf 使用示意图:
    • image-20210821023654601

2、Netty中Protobuf的使用流程

  1. 在Maven 项目中引入 Protobuf 坐标,下载相关的jar包

    • <dependencies>
          <dependency>
              <groupId>com.google.protobuf</groupId>
              <artifactId>protobuf-java</artifactId>
              <version>3.6.1</version>
          </dependency>
      </dependencies>
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      2. 在IDEA创建.proto文件,进行.proto文件的编写(以Student.proto为例)

      - ```protobuf
      syntax = "proto3"; //版本
      option java_outer_classname = "StudentPOJO";//生成的外部类名,同时也是文件名
      //protobuf 使用message 管理数据
      message Student { //会在 StudentPOJO 外部类生成一个内部类 Student, 他是真正发送的POJO对象
      int32 id = 1; // Student 类中有一个属性 名字为 id 类型为int32(protobuf类型) 1表示属性序号,不是值
      string name = 2;
      }
  2. 利用protoc.exe 编译器对刚刚编写好的.proto文件进行编译,生成一个java文件

    • 执行指令(cmd)

      • protoc.exe --java_out=. Student.proto
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10

        - idea里面也可以下载相应的maven插件进行编译:有工具mave protobuf-java-util

        4. 之后会生成一个Student.java文件

        - 这里主要是看两点:

        - ```java
        // DO NOT EDIT!
        public static final class Student extends com.google.protobuf.GeneratedMessageV3 implements // 说明真正的PoJo 类是Student
  3. 把生成的 StudentPoJo.java 拷贝到自己的项目中打开

  4. 在项目的服务端ChannelInitializer<SocketChannel>中的initChannel方法里面添加解码的handler(服务端<—>解码),在解码的handler中添加StudentPOJO.Student.getDefaultInstance()

    • serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道初始化对象(匿名对象)
          //给pipeline 设置处理器
          @Override
          protected void initChannel(SocketChannel ch) throws Exception {
              ChannelPipeline pipeline = ch.pipeline();
              //在pipeline加入ProtoBufDecoder
              //指定对哪种对象进行解码
              pipeline.addLast("decoder", new ProtobufDecoder(StudentPOJO.Student.getDefaultInstance()));
              pipeline.addLast(new NettyServerHandler());
          }
      }); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      7. 在项目的客户端`ChannelInitializer<SocketChannel>`中的`initChannel`方法里面添加编码的handler(客户端<--->编码)

      - ```java
      bootstrap.handler(new ChannelInitializer<SocketChannel>() {
      @Override
      protected void initChannel(SocketChannel ch) throws Exception {
      ChannelPipeline pipeline = ch.pipeline();
      //在pipeline中加入 ProtoBufEncoder
      pipeline.addLast("encoder", new ProtobufEncoder());
      pipeline.addLast(new NettyClientHandler()); //加入自己的处理器
      }
      });
  5. 在服务端的自定义handler中可以选择继承SimpleChannelInboundHandler并设置泛型StudentPOJO.Student,这样一来重写的channelRead0方法的第二个参数就变成了StudentPOJO.Student msg(而不是Object,还需要我们去判断Object类型向下转型),我可以通过该msg获取Student的相关信息

  6. 而在客户端就需要我们去生成一个StudentPOJO.Student,往StudentPOJO.Student设置一些信息:

    • StudentPOJO.Student student = StudentPOJO.Student.newBuilder().setId(4).setName("智多星 吴用").build();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55



      #### 3、使用Protobuf的几点说明

      1. Protobuf是以`message`的方式来管理数据的

      2. 在.proto文件的编写中使用message声明的变量,在之后生成java文件后会成为java文件的内部类,也是真正存储PoJo 类信息的地方,使用option java_outer_classname方式生成的类对象其实是java的外部类,包裹着存储PoJo 类信息的内部类

      3. java PoJo 类的属性数据类型 与 Protobuf文件中的属性数据类型的对比:

      ![image-20210821025959285](Netty/image-20210821025959285.png)

      4. 在Protobuf文件中 `int32 id = 1`中的`1`并不是属性的值,而是该属性在Protobuf文件的属性序号,即该属性是Protobuf文件的第几个属性(**从1开始**)

      5. 通过以上的项目的服务端与客户端可以发现一个问题:项目的handler与PoJo的耦合很高。基本上一个handler只能为一个PoJo服务

      6. 解决方法:**Protobuf可以使用 message 管理其他的message**

      7. Protobuf文件中可以使用一个总的message作为大包裹,里面包含了各式各样的PoJo信息——使用枚举的方式(注意:**在proto3 要求enum的编号从0开始**)

      - ```protobuf
      syntax = "proto3";
      option optimize_for = SPEED; // 加快解析
      option java_package="com.atguigu.netty.codec2"; //指定生成到哪个包下
      option java_outer_classname="MyDataInfo"; // 外部类名, 文件名

      //protobuf 可以使用message 管理其他的message
      message MyMessage {

      //定义一个枚举类型
      enum DataType {
      StudentType = 0; //在proto3 要求enum的编号从0开始
      WorkerType = 1;
      }

      //用data_type 来标识传的是哪一个枚举类型
      DataType data_type = 1;

      //表示每次枚举类型最多只能出现其中的一个, 节省空间
      oneof dataBody {
      Student student = 2;
      Worker worker = 3;
      }

      }

      message Student {
      int32 id = 1;//Student类的属性
      string name = 2; //
      }
      message Worker {
      string name=1;
      int32 age=2;
      }
  7. 这样的话,在服务端的ChannelInitializer<SocketChannel>中的initChannel方法的里面ProtobufDecoder的里面就不能写某个PoJo的getDefaultInstance(),而是得写整个大包裹的getDefaultInstance()

    • pipeline.addLast("decoder", new ProtobufDecoder(MyDataInfo.MyMessage.getDefaultInstance()));
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10

      9. 各种PoJo的信息的设置与获取在各自的handler中

      - 设置(客户端)

      - ```java
      MyDataInfo.MyMessage myMessage = null;
      myMessage = MyDataInfo.MyMessage.newBuilder().setDataType(MyDataInfo.MyMessage.DataType.StudentType).setStudent(MyDataInfo.Student.newBuilder().setId(5).setName("玉麒麟 卢俊义").build()).build();

      myMessage = MyDataInfo.MyMessage.newBuilder().setDataType(MyDataInfo.MyMessage.DataType.WorkerType).setWorker(MyDataInfo.Worker.newBuilder().setAge(20).setName("老李").build()).build();
    • 获取(服务端)(继承的SimpleChannelInboundHandler的泛型要修改成大包裹:MyDataInfo.MyMessage

      • //根据dataType 来显示不同的信息
        MyDataInfo.MyMessage.DataType dataType = msg.getDataType();
        if(dataType == MyDataInfo.MyMessage.DataType.StudentType) {
            MyDataInfo.Student student = msg.getStudent();
            System.out.println("学生id=" + student.getId() + " 学生名字=" + student.getName());
        
        } else if(dataType == MyDataInfo.MyMessage.DataType.WorkerType) {
            MyDataInfo.Worker worker = msg.getWorker();
            System.out.println("工人的名字=" + worker.getName() + " 年龄=" + worker.getAge());
        } else {
            System.out.println("传输的类型不正确");
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46

        -----





        ## 9、Netty编解码器和handler的调用机制

        ### 1、基本说明

        1. netty的组件设计:Netty的主要组件有Channel、EventLoop、ChannelFuture、ChannelHandler、ChannelPipe等
        2. **ChannelHandler充当了处理入站和出站数据的应用程序逻辑的容器**。例如,实现ChannelInboundHandler接口(或ChannelInboundHandlerAdapter),你就可以接收入站事件和数据,这些数据会被业务逻辑处理。当要给客户端发送响应时,也可以从ChannelInboundHandler冲刷数据。业务逻辑通常写在一个或者多个ChannelInboundHandler中。ChannelOutboundHandler原理一样,只不过它是用来处理出站数据的。
        3. **ChannelPipeline提供了ChannelHandler链的容器**。**以客户端应用程序为例,如果事件的运动方向是从客户端到服务端的,那么我们称这些事件为出站的,即客户端发送给服务端的数据会通过pipeline中的一系列ChannelOutboundHandler,并被这些Handler处理**,反之则称为入站的

        ![image-20210821034142241](Netty/image-20210821034142241.png)



        ### 2、编码解码器

        1. **当Netty发送或者接受一个消息的时候,就将会发生一次数据转换。==入站消息会被解码==:从字节转换为另一种格式(比如java对象);如果是==出站消息,它会被编码成字节==**。
        2. **Netty提供一系列实用的编解码器,他们都实现了ChannelInboundHadnler或者ChannelOutboundHandler接口。在这些类中,channelRead方法已经被重写了**。以入站为例,对于每个从入站Channel读取的消息,这个方法会被调用。随后,它将调用由解码器所提供的decode()方法进行解码,并将已经解码的字节转发给ChannelPipeline中的下一个ChannelInboundHandler。



        ### 3、解码器——ByteToMessageDecoder(服务器端,入站)

        1. 关系继承图

        - ![image-20210821034417112](Netty/image-20210821034417112.png)

        2. **由于不可能知道远程节点是否会一次性发送一个完整的信息,==tcp有可能出现粘包拆包的问题==**,这个类会对入站数据进行缓冲,直到它准备好被处理。

        3. 一个关于ByteToMessageDecoder实例分析

        - ```java
        public class ToIntegerDecoder extends ByteToMessageDecoder {
        // 读取一个int类型
        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        if (in.readableBytes() >= 4) {
        out.add(in.readInt());
        }
        }
        }
    • 说明:

      • 这个例子,每次入站从ByteBuf中读取4字节,将其解码为一个int,然后将它添加到下一个List中。当没有更多元素可以被添加到该List中时,它的内容将会被发送给下一个ChannelInboundHandler。int在被添加到List中时,会被自动装箱为Integer
      • 在调用readInt()方法前必须验证所输入的ByteBuf是否具有足够的数据。
      • 关于if还是while的问题,”decode”方法确实会被循环调用,只要还有可读就会一直循环,除非”decode”没有再读出数据,则会退出循环。
      • 所以如果把”if”改成”while”也是可以的没有区别,相当于自己先把buf处理完了,外层循环就不会再调用了
      • decode 会根据接收的数据,被调用多次,直到确定没有新的元素被添加到list,或者是ByteBuf 没有更多的可读字节为止
      • 如果list out 不为空,就会将list的内容传递给下一个 channelinboundhandler处理,该channelinboundhandler的方法也会被调用多次(不管是if还是while,因为循环调用的依据是list的内容)

      image-20210821035333607

Netty的handler链的调用机制

实例要求:使用自定义的编码器和解码器来说明Netty的handler 调用机制

  • 客户端发送long -> 服务器
  • 服务端发送long -> 客户端

思路:

image-20210821050856155

注意:

  • ctx.write 会去调用outbound的方法
  • outbound一定要放到最后一个inbound之前,保证inbound在write的时候,可以往前找到outbound

代码:

自定义编码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public class MyLongToByteEncoder extends MessageToByteEncoder<Long> {
//编码方法
@Override
protected void encode(ChannelHandlerContext ctx, Long msg, ByteBuf out) throws Exception {

System.out.println("MyLongToByteEncoder encode 被调用");
System.out.println("msg=" + msg);
out.writeLong(msg);

}
}

自定义解码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;

import java.util.List;

public class MyByteToLongDecoder extends ByteToMessageDecoder {
/**
*
* decode 会根据接收的数据,被调用多次, 直到确定没有新的元素被添加到list
* , 或者是ByteBuf 没有更多的可读字节为止
* 如果list out 不为空,就会将list的内容传递给下一个 channelinboundhandler处理, 该处理器的方法也会被调用多次
*
* @param ctx 上下文对象
* @param in 入站的 ByteBuf
* @param out List 集合,将解码后的数据传给下一个handler
* @throws Exception
*/
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {

System.out.println("MyByteToLongDecoder 被调用");
//因为 long 8个字节, 需要判断有8个字节,才能读取一个long
if(in.readableBytes() >= 8) {
out.add(in.readLong());
}
}
}

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}

服务端的处理器的初始化:MyServerInitializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyServerInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();//一会下断点

//入站的handler进行解码 MyByteToLongDecoder
pipeline.addLast(new MyByteToLongDecoder());
//出站的handler进行编码
pipeline.addLast(new MyLongToByteEncoder());
//自定义的handler 处理业务逻辑
pipeline.addLast(new MyServerHandler());
System.out.println("xx");
}
}

服务端的自定义处理器:MyServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public class MyServerHandler extends SimpleChannelInboundHandler<Long> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception {

System.out.println("从客户端" + ctx.channel().remoteAddress() + " 读取到long " + msg);

//给客户端发送一个long
ctx.writeAndFlush(98765L);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

public class MyClient {
public static void main(String[] args) throws Exception{

EventLoopGroup group = new NioEventLoopGroup();

try {

Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioSocketChannel.class)
.handler(new MyClientInitializer()); //自定义一个初始化类

ChannelFuture channelFuture = bootstrap.connect("localhost", 7000).sync();

channelFuture.channel().closeFuture().sync();

}finally {
group.shutdownGracefully();
}
}
}

客户端的处理器的初始化:MyClientInitializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyClientInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {

ChannelPipeline pipeline = ch.pipeline();

//加入一个出站的handler 对数据进行一个编码
pipeline.addLast(new MyLongToByteEncoder());

//这时一个入站的解码器(入站handler )
pipeline.addLast(new MyByteToLongDecoder());
//加入一个自定义的handler , 处理业务
pipeline.addLast(new MyClientHandler());

}
}

客户端的自定义处理器:MyServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.CharsetUtil;

import java.nio.charset.Charset;

public class MyClientHandler extends SimpleChannelInboundHandler<Long> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception {

System.out.println("服务器的ip=" + ctx.channel().remoteAddress());
System.out.println("收到服务器消息=" + msg);

}

//重写channelActive 发送数据
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("MyClientHandler 发送数据");
ctx.writeAndFlush(123456L); //发送的是一个long

//分析
//1. "abcdabcdabcdabcd" 是 16个字节
//2. 该处理器的前一个handler 是 MyLongToByteEncoder
//3. MyLongToByteEncoder 父类 MessageToByteEncoder
//4. 父类 MessageToByteEncoder
/*

public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
ByteBuf buf = null;
try {
if (acceptOutboundMessage(msg)) { //判断当前msg 是不是应该处理的类型,如果是就处理,不是就跳过encode
@SuppressWarnings("unchecked")
I cast = (I) msg;
buf = allocateBuffer(ctx, cast, preferDirect);
try {
encode(ctx, cast, buf);
} finally {
ReferenceCountUtil.release(cast);
}

if (buf.isReadable()) {
ctx.write(buf, promise);
} else {
buf.release();
ctx.write(Unpooled.EMPTY_BUFFER, promise);
}
buf = null;
} else {
ctx.write(msg, promise);
}
}
4. 因此我们编写 Encoder 是要注意传入的数据类型和处理的数据类型一致
*/
// ctx.writeAndFlush(Unpooled.copiedBuffer("abcdabcdabcdabcd",CharsetUtil.UTF_8));
}
}

执行流程:

image-20210821035744141

结论:

  • 不论解码器handler 还是 编码器handler 即接收的消息类型必须与待处理的消息类型一致,否则该handler不会被执行

    • 底层调用了父类 MessageToByteEncoder的write方法,源码:

    • public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
          ByteBuf buf = null;
          try {
              //判断当前msg 是不是应该处理的类型,如果是就处理,不是就跳过encode
              if (acceptOutboundMessage(msg)) { 
                  @SuppressWarnings("unchecked")
                  I cast = (I) msg;
                  buf = allocateBuffer(ctx, cast, preferDirect);
                  try {
                      encode(ctx, cast, buf);
                  } finally {
                      ReferenceCountUtil.release(cast);
                  }
      
                  if (buf.isReadable()) {
                      ctx.write(buf, promise);
                  } else {
                      buf.release();
                      ctx.write(Unpooled.EMPTY_BUFFER, promise);
                  }
                  buf = null;
              } else {
                  ctx.write(msg, promise);
              }
          }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - **在解码器 进行数据解码时,需要判断 缓存区(ByteBuf)的数据是否足够 ,否则接收到的结果会期望结果可能不一致**



      ### 4、解码器——ReplayingDecoder(客户端,出站)

      1. ```java
      public abstract class ReplayingDecoder<S> extends ByteToMessageDecoder
  1. ReplayingDecoder扩展了ByteToMessageDecoder类,使用这个类,我们不必调用readableBytes()方法。参数S指定了用户状态管理的类型,其中Void代表不需要状态管理

  2. ReplayingDecoder使用方便,但它也有一些局限性:

    • 并不是所有的 ByteBuf 操作都被支持,如果调用了一个不被支持的方法,将会抛出一个 UnsupportedOperationException
    • ReplayingDecoder 在某些情况下可能稍慢于 ByteToMessageDecoder,例如网络缓慢并且消息格式复杂时,消息会被拆成了多个碎片,速度变慢

5、其它编解码器

  • 其它解码器:
    1. LineBasedFrameDecoder:这个类在Netty内部也有使用,它使用行尾控制字符(\n或者\r\n)作为分隔符来解析数据
    2. DelimiterBasedFrameDecoder使用自定义的特殊字符作为消息的分隔符
    3. HttpObjectDecoder:一个HTTP数据的解码器
    4. LengthFieldBasedFrameDecoder通过指定长度来标识整包消息,这样就可以自动的处理==黏包==和==半包==消息
    5. image-20210821053430472
    6. image-20210821053547439
  • 其它编码器:
    1. image-20210821053839116
    2. image-20210821053849720
  • 例子:如果客户端传输大量数据到服务端的时候,为了节省时间与开销。可以在客户端使用ZlibEncoder对数据进行压缩编码,然后在服务端使用ZlibDecoder进行压缩阶码就能得到数据。这些操作Netty都帮助我们完成了,我们只需要在将Initializer对象的initChennel方法中将对应的编解码器加入pipeline当中

6、Log4j 整合到Netty

  1. 在Maven 中添加对Log4j的依赖 在 pom.xml

    • <dependency>
          <groupId>log4j</groupId>
          <artifactId>log4j</artifactId>
          <version>1.2.17</version>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-api</artifactId>
          <version>1.7.25</version>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-log4j12</artifactId>
          <version>1.7.25</version>
          <scope>test</scope>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-simple</artifactId>
          <version>1.7.25</version>
          <scope>test</scope>
      </dependency>
      
      1
      2
      3
      4
      5
      6
      7
      8

      2. 配置 Log4j,在 resources/log4j.properties

      - ```properties
      log4j.rootLogger=DEBUG, stdout
      log4j.appender.stdout=org.apache.log4j.ConsoleAppender
      log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
      log4j.appender.stdout.layout.ConversionPattern=[%p] %C{1} - %m%n
  2. 演示:

    • image-20210821054850668

10、TCP 粘包和拆包 及解决方案

1、TCP 粘包和拆包基本介绍

  1. TCP是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发给接收端的包,更有效的发给对方,使用了优化方法(==Nagle算法==),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界
  2. 由于TCP无消息保护边界,需要在接收端处理消息边界问题,也就是我们所说的粘包、拆包问题,看一张图:
    • image-20210821155622581
  3. 客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下四种情况:
    1. 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包
    2. 服务端一次接受到了两个数据包,D1和D2粘合在一起,称之为==TCP粘包==
    3. 服务端分两次读取到了数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这称之为==TCP拆包==
    4. 服务端分两次读取到了数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余部分内容D1_2和完整的D2包。

2、TCP 粘包和拆包解决方案

  1. 使用自定义协议 + 编解码器 来解决
  2. 关键就是要解决 服务器端每次读取数据长度的问题,这个问题解决,就不会出现服务器多读或少读数据的问题,从而避免的TCP 粘包、拆包 。

看一个具体的实例:

  1. 要求客户端发送 5 个 Message 对象,客户端每次发送一个 Message 对象
  2. 服务器端每次接收一个Message,分5次进行解码, 每读取到 一个Message,会回复一个Message 对象 给客户端。

image-20210821155936176

代码:

由于我们是自定义协议,需要我们编写一个协议包类,规定每次发送的协议包的内容和大小(重要)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//协议包
public class MessageProtocol {
private int len; //关键
private byte[] content;

public int getLen() {
return len;
}

public void setLen(int len) {
this.len = len;
}

public byte[] getContent() {
return content;
}

public void setContent(byte[] content) {
this.content = content;
}
}

由于我们是自定义协议,需要我们自定义编解码器,将我们自定义的协议包进行编解码

编码器:

1
2
3
4
5
6
7
8
9
10
11
12
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public class MyMessageEncoder extends MessageToByteEncoder<MessageProtocol> {
@Override
protected void encode(ChannelHandlerContext ctx, MessageProtocol msg, ByteBuf out) throws Exception {
System.out.println("MyMessageEncoder encode 方法被调用");
out.writeInt(msg.getLen());
out.writeBytes(msg.getContent());
}
}

解码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ReplayingDecoder;

import java.util.List;

public class MyMessageDecoder extends ReplayingDecoder<Void> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
System.out.println("MyMessageDecoder decode 被调用");
//需要将得到二进制字节码-> MessageProtocol 数据包(对象)
int length = in.readInt();

byte[] content = new byte[length];
in.readBytes(content);

//封装成 MessageProtocol 对象,放入 out, 传递下一个handler业务处理
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(length);
messageProtocol.setContent(content);
// 将协议包放入list当中
out.add(messageProtocol);

}
}

服务器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

import java.nio.charset.Charset;
import java.util.UUID;


//处理业务的handler
public class MyServerHandler extends SimpleChannelInboundHandler<MessageProtocol>{
private int count;

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//cause.printStackTrace();
ctx.close();
}

@Override
protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception {

//接收到数据,并处理
int len = msg.getLen();
byte[] content = msg.getContent();

System.out.println();
System.out.println();
System.out.println();
System.out.println("服务器接收到信息如下");
System.out.println("长度=" + len);
System.out.println("内容=" + new String(content, Charset.forName("utf-8")));

System.out.println("服务器接收到消息包数量=" + (++this.count));

//回复消息
String responseContent = UUID.randomUUID().toString();
int responseLen = responseContent.getBytes("utf-8").length;
byte[] responseContent2 = responseContent.getBytes("utf-8");
//构建一个协议包
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(responseLen);
messageProtocol.setContent(responseContent2);
ctx.writeAndFlush(messageProtocol);
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

public class MyClient {
public static void main(String[] args) throws Exception{

EventLoopGroup group = new NioEventLoopGroup();

try {

Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioSocketChannel.class)
.handler(new MyClientInitializer()); //自定义一个初始化类

ChannelFuture channelFuture = bootstrap.connect("localhost", 7000).sync();

channelFuture.channel().closeFuture().sync();

}finally {
group.shutdownGracefully();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyClientInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {

ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new MyMessageEncoder()); //加入编码器
pipeline.addLast(new MyMessageDecoder()); //加入解码器
pipeline.addLast(new MyClientHandler());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

import java.nio.charset.Charset;

public class MyClientHandler extends SimpleChannelInboundHandler<MessageProtocol> {

private int count;
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//使用客户端发送10条数据 "今天天气冷,吃火锅" 编号

for(int i = 0; i< 5; i++) {
String mes = "今天天气冷,吃火锅";
byte[] content = mes.getBytes(Charset.forName("utf-8"));
int length = mes.getBytes(Charset.forName("utf-8")).length;

//创建协议包对象
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(length);
messageProtocol.setContent(content);
ctx.writeAndFlush(messageProtocol);

}

}

// @Override
protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception {

int len = msg.getLen();
byte[] content = msg.getContent();

System.out.println("客户端接收到消息如下");
System.out.println("长度=" + len);
System.out.println("内容=" + new String(content, Charset.forName("utf-8")));

System.out.println("客户端接收消息数量=" + (++this.count));

}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("异常消息=" + cause.getMessage());
ctx.close();
}
}

效果:

客户端:

image-20210821160639783

服务端:

image-20210821160731328

调用流程说明:

  1. 客户端发送5条数据到服务端(编码5次)
  2. 服务端调用解码器将客户端发送过来的数据进行解码,收到信息后,发送一个协议包给客户端(编码1次),由于客户端发送了5条数据,所以这个过程会执行5次
  3. 客户端接收到服务端发送过来的协议包,调用解码器进行解码,接收数据。由于服务端会回发5次数据,所以客户端也会接收到5次数据,每一次接收都要调用一次解码器进行数据解码
  4. 由于数据都是通过自定义的协议包进行传输的,协议包中规定了每一次传输的数据的长度,所以不会出现TCP的粘包拆包问题。

11、Netty 核心源码剖析

1、Netty 启动过程源码剖析

说明:

  1. 源码需要剖析到Netty 调用doBind方法, 追踪到 NioServerSocketChannel的doBind
  2. 并且要Debug 程序到 NioEventLoop类 的run代码 ,无限循环,在服务器端运行。

image-20210823041445976

Netty启动过程梳理:

  1. 创建2个 EventLoopGroup 线程池数组。数组默认大小CPU*2,方便chooser选择线程池时提高性能
  2. BootStrap 将 boss 设置为 group属性,将 worker 设置为 childer 属性
  3. 通过 bind 方法启动,内部重要方法为 initAndRegisterdobind 方法
  4. initAndRegister 方法会反射创建 NioServerSocketChannel 及其相关的 NIO 的对象, pipeline , unsafe,同时也为 pipeline 初始了 head 节点和 tail 节点。
  5. register0 方法成功以后调用在 dobind 方法中调用 doBind0 方法,该方法会 调用 NioServerSocketChannel 的 doBind 方法对 JDK 的 channel 和端口进行绑定,完成 Netty 服务器的所有启动,并开始监听连接事件

2、Netty 接受请求过程源码剖析

说明:

  1. 从之前服务器启动的源码中,我们得知,服务器最终注册了一个 Accept 事件等待客户端的连接。我们也知道,NioServerSocketChannel 将自己注册到了 boss 单例线程池(reactor 线程)上,也就是 EventLoop 。
  2. 先简单说下EventLoop的逻辑(后面我们详细讲解EventLoop)
    • EventLoop 的作用是一个死循环,而这个循环中做3件事情:
      1. 有条件的等待 Nio 事件。
      2. 处理 Nio 事件。
      3. 处理消息队列中的任务。
  3. 仍用前面的项目来分析:进入到 NioEventLoop 源码中后,在private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) 方法开始调试
  4. 最终我们要分析到AbstractNioChannel 的 doBeginRead 方法, 当到这个方法时,针对于这个客户端的连接就完成了,接下来就可以监听读事件了

Netty接受请求过程梳理:

总体流程

接受连接 —–> 创建一个新的NioSocketChannel ———–> 注册到一个 worker EventLoop 上 ——–> 注册selecot Read 事件。

  1. 服务器轮询 Accept 事件,获取事件后调用 unsafe 的 read 方法,这个 unsafe 是 ServerSocket 的内部类,该方法内部由2部分组成
  2. doReadMessages 用于创建 NioSocketChannel 对象,该对象包装 JDK 的 Nio Channel 客户端。该方法会像创建 ServerSocketChanel 类似创建相关的 pipeline , unsafe,config
  3. 随后执行 pipeline.fireChannelRead 方法,并将自己绑定到一个 chooser 选择器选择的 workerGroup 中的一个 EventLoop。并且注册一个0,表示注册成功,但并没有注册读(1)事件

3、Pipeline Handler HandlerContext创建源码剖析

  1. 每当创建 ChannelSocket 的时候都会创建一个绑定的 pipeline,一对一的关系,创建 pipeline 的时候也会创建 tail 节点和 head 节点,形成最初的链表
  2. 在调用 pipeline 的 addLast 方法的时候,会根据给定的 handler 创建一个 Context,然后将这个 Context 插入到链表的尾端(tail 前面)
  3. Context 包装 handler,多个 Context 在 pipeline 中形成了双向链表
  4. 入站方向叫 inbound,由 head 节点开始,出站方法叫 outbound ,由 tail 节点开始

4、ChannelPipeline 调度 handler 的源码剖析

  1. 当一个请求进来的时候,ChannelPipeline 是如何调用内部的这些 handler 的呢?
  2. 首先,当一个请求进来的时候,会第一个调用 pipeline 的 相关方法,如果是入站事件,这些方法由 fire 开头,表示开始管道的流动。让后面的 handler 继续处理

示意图

ChannelPipeline 调度 handler 梳理:

  1. Context 包装 handler,多个 Context 在 pipeline 中形成了双向链表,入站方向叫 inbound,由 head 节点开始,出站方法叫 outbound ,由 tail 节点开始。
  2. 而节点中间的传递通过 AbstractChannelHandlerContext 类内部的 fire 系列方法,找到当前节点的下一个节点不断的循环传播。是一个过滤器形式完成对handler 的调度

5、Netty 心跳(heartbeat)服务源码剖析

Netty 作为一个网络框架,提供了诸多功能,比如编码解码等,Netty 还提供了非常重要的一个服务——心跳机制heartbeat。通过心跳检查对方是否有效,这是 RPC 框架中是必不可少的功能。

说明:

  1. Netty 提供了 IdleStateHandlerReadTimeoutHandlerWriteTimeoutHandler 三个Handler 检测连接的有效性,重点分析 IdleStateHandler
  2. image-20210823042900872

hasOutputChanged流程图:

hasOutputChanged流程图

6、Netty 核心组件 EventLoop 源码剖析

eventloop继承图:

eventloop继承图

handler 中加入线程池和Context 中添加线程池的源码剖析

  1. 在 Netty 中做耗时的,不可预料的操作,比如数据库,网络请求,会严重影响 Netty 对 Socket 的处理速度。
  2. 而解决方法就是将耗时任务添加到异步线程池中。但就添加线程池这步操作来讲,可以有2种方式,而且这2种方式实现的区别也蛮大的。
    • 处理耗时业务的第一种方式——handler 中加入线程池
    • 处理耗时业务的第二种方式——Context 中添加线程池

将这些任务从handler中提交到channel对应的NIOEventLoop 的 TaskQueue的方法:

用户程序自定义的普通任务 -> 提交到该channel 对应的NioEventLoop 的 taskQueue中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 用户程序自定义的普通任务
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵2", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});

/*
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(20 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵3", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});
*/

注意:

  • 该方法是通过ctx获得channel对象,在通过channel对象去获取该channel所在的evevtLoop,最后在将任务提交到eventLoop的taskQueue中

  • eventLoop会起一个线程去异步解决taskQueue当中的任务,==注意是一个线程==。如果taskQueue当中有多个任务的话,那么该线程会按照taskQueue中任务的顺序依次执行任务,即执行taskQueue任务的时间是累加的

    • eg:taskQueue的第一个任务花费10s,taskQueue的第二个任务花费20s,那么该线程执行完taskQueue当中的任务总共要花费30s
  • 解决方法:

    1. 在当前Handler中创建一个业务线程池,把耗时任务放到创建的线程池中执行。此时就变成了一个线程有一个业务线程池,来完成耗时任务的异步操作。(局部异步)

      • 创建线程池的方法:

        • // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);
          
          // 调用一下方法将耗时任务放在线程池创建的线程中进行执行
          group.sumbit(Callable task);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15

          2. **在Server端中创建一个业务线程池(Context中添加线程池)**(整个异步)

          - 创建线程池的方法:

          - ```java
          // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);

          // 在ChannelInitializer的initChannel方法中
          ChannelPipeline p = chpipeline();
          // 在这里将group设置进去:如果这样设置的话,该handler会优先加入到该线程池中,这样一来,workerGroup主要接收任务 然后在将任务提交给线程池来处理。
          // 默认没添加group的话,handler会进入workerLoopGroup的某一个workerLoop子线程
          p.addLast(group,new MyServerHandler());

流程图:

流程图


12、用Netty 自己 实现 dubbo RPC

1、RPC基本介绍

  1. RPC(Remote Procedure Call)——远程过程调用,是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互作用编程
  2. 两个或多个应用程序都分布在不同的服务器上,它们之间的调用都像是本地方法调用一样
    • image-20210823043804591
  3. 常见的 RPC 框架有:比较知名的如:
    • 阿里的Dubbo
    • google的gRPC
    • Go语言的rpcx
    • Apache的thrift
    • Spring 旗下的 Spring Cloud

2、RPC调用流程

1、RPC调用流程图

image-20210823043954338

术语说明:在RPC 中, Client 叫服务消费者,Server 叫服务提供者

2、RPC调用流程说明

  1. 服务消费方(client)以本地调用方式调用服务
  2. client stub 接收到调用后负责将方法、参数等封装成能够进行网络传输的消息体
  3. client stub 将消息进行编码并发送到服务端
  4. server stub 收到消息后进行解码
  5. server stub 根据解码结果调用本地的服务
  6. 本地服务执行并将结果返回给 server stub
  7. server stub 将返回导入结果进行编码并发送至消费方
  8. client stub 接收到消息并进行解码
  9. 服务消费方(client)得到结果

小结:RPC 的目标就是将 2-8 这些步骤都封装起来,用户无需关心这些细节,可以像调用本地方法一样即可完成远程服务调用。

3、自己实现 dubbo RPC(基于Netty)

1、需求说明

  1. dubbo 底层使用了 Netty 作为网络通讯框架,要求用 Netty 实现一个简单的 RPC 框架
  2. 模仿 dubbo,消费者和提供者约定接口和协议,消费者远程调用提供者的服务,提供者返回一个字符串,消费者打印提供者返回的数据。底层网络通信使用 Netty 4.1.20

2、设计说明

  1. 创建一个接口,定义抽象方法。用于消费者和提供者之间的约定。
  2. 创建一个提供者,该类需要监听消费者的请求,并按照约定返回数据。
  3. 创建一个消费者,该类需要透明的调用自己不存在的方法,内部需要使用 Netty 请求提供者返回数据

image-20210823044457880


参考资料

linux I/O–IO原理和几种零拷贝机制

由传统IO演化至零拷贝的过程

[TOC]

JUC高并发编程

1、Java并发知识体系详解

1、知识体系

img

2、java高并发

Java并发1

Java并发2

Java并发3

Java并发4

Java并发思维导图(含面试问题整理)


2、Java 并发 - 理论基础

从理论的角度引入并发安全问题以及JMM应对并发问题的原理。

1、BAT大厂的面试问题

  • 多线程的出现是要解决什么问题的?
  • 线程不安全是指什么? 举例说明
  • 并发出现线程不安全的本质什么?
    • 并发的三要素:可见性,原子性和有序性。
  • Java是怎么解决并发问题的?
    • 3个关键字,JMM和8个Happens-Before
  • 线程安全是不是非真即假?
    • 不是
  • 线程安全有哪些实现思路?
  • 如何理解并发和并行的区别?

2、并发与并行

1、串行模式

串行表示所有任务都按先后顺序进行

串行意味着必须先装完一车柴才能运送这车柴,只有运送到了,才能卸下这车柴,并且只有完成了这整个三个步骤,才能进行下一个步骤。

串行是一次只能取得一个任务,并执行这个任务

2、并行模式

并行意味着可以同时取得多个任务,并同时去执行所取得的这些任务

并行模式相当于将长长的一条队列,划分成了多条短队列,所以并行缩短了任务队列的长度。

并行的效率从代码层次上强依赖于多进程/多线程代码从硬件角度上则依赖于多核 CPU

多核 cpu下,每个核(core)都可以调度运行线程,这时候线程可以是并行的。

image-20210802205153125

3、并发

并发(concurrent)指的是多个程序可以同时运行的现象,更细化的是多进程可以同时运行或者多指令可以同时运行。但这不是重点,在描述并发的时候也不会去扣这种字眼是否精确,==并发的重点在于它是一种现象==, ==并发描述的是多进程同时运行的现象==。但实际上,对于单核心 CPU 来说,同一时刻只能运行一个线程,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。所以,这里的”同时运行”表示的不是真的同一时刻有多个线程运行的现象,这是并行的概念,而是提供一种功能让用户看来多个程序同时运行起来了,但实际上这些程序中的进程不是一直霸占 CPU 的,而是执行一会停一会。 总结为一句话就是: ==微观串行,宏观并行== ,一般会将这种 线程轮流使用 CPU 的做法称为并发( concurrent

image-20210802204936365

要解决大并发问题,通常是将大任务分解成多个小任务,由于操作系统对进程的调度是随机的,所以切分成多个小任务后,可能会从任一小任务处执行。这可能会出现一些现象:

  • 可能出现一个小任务执行了多次,还没开始下个任务的情况。这时一般会采用队列或类似的数据结构来存放各个小任务的成果;
  • 可能出现还没准备好第一步就执行第二步的可能。这时,一般采用多路复用或异步的方式,比如只有准备好产生了事件通知才执行某个任务;
  • 可以多进程/多线程的方式并行执行这些小任务。也可以单进程/单线程执行这些小任务,这时很可能要配合多路复用才能达到较高的效率。

4、并发与并行的区别

  • 并发是指一个处理器同时处理多个任务。并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。

  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。就好像两个人各拿一把铁锨在挖坑,一小时后,每人一个大坑。所以无论从微观还是从宏观来看,二者都是一起执行的。

    并行和并发哪个好?并行和并发的概念和区别

  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。这就好像两个人用同一把铁锨,轮流挖坑,一小时后,两个人各挖一个小一点的坑,要想挖两个大一点得坑,一定会用两个小时。

    并行和并发哪个好?并行和并发的概念和区别

  • 并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)。(下图来自Erlang 之父 Joe Armstrong)

    img

  • 当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态。这种方式我们称之为并发(Concurrent)。

  • 当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

    并行和并发哪个好?并行和并发的概念和区别

  • 引用 Rob Pike(golang 语言的创造者) 的一段描述:

    • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力
    • 并行(parallel)是同一时间动手做(doing)多件事情的能力
    • 例子:
      • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
      • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
      • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
  • 并发:同一时刻多个线程在访问同一个资源,多个线程对一个点

    • 例子:春运抢票 电商秒杀…
  • 并行:多项工作一起执行,之后再汇总

    • 例子:泡方便面,电水壶烧水,一边撕调料倒入桶中

5、管程

管程(monitor)是保证了同一时刻只有一个进程在管程内活动,即**管程内定义的操作在同一时刻只被一个进程调用(由编译器实现)**。但是这样并不能保证进程以设计的顺序执行。

JVM 中同步是基于进入和退出管程(monitor)对象实现的,每个对象都会有一个管程(monitor)对象,管程(monitor)会随着 java 对象一同创建和销毁。

执行线程首先要持有管程对象,然后才能执行方法,当方法完成之后会释放管程,方法在执行时候会持有管程,其他线程无法再获取同一个管程。

管程,在java中叫锁,在操作系统中叫监视器,是一种同步机制。

image-20210721233218350

6、用户线程和守护线程

  • 用户线程:平时用到的普通线程、自定义线程

  • 守护线程:运行在后台,是一种特殊的线程。比如垃圾回收线程。

  • 当主线程结束后,用户线程还在运行,JVM 存活;

  • 如果没有用户线程,都是守护线程,JVM 结束 。

    image-20210721233249742

  • 可以通过调用Thread.currentThread().isDaemon()查看当前线程是不是守护线程

  • 可以通过调用当前线程.setDaemon(true)将当前线程设置为守护线程

    • 这个方法应该在当前线程.start()执行之前设置

注意:

  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求

3、为什么需要多线程

众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异;
    • 导致 可见性问题
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
    • 导致 原子性问题
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
    • 导致 有序性问题

4、多线程的应用

1、应用之异步调用

1、异步与同步

以调用方角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步
2、设计

多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…

3、结论
  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
  • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
  • ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

2、应用之提高效率

充分利用多核 cpu 的优势,提高运行效率。想象下面的场景,执行 3 个计算,最后将计算结果汇总:

1
2
3
4
计算 1 花费 10 ms
计算 2 花费 11 ms
计算 3 花费 9 ms
汇总需要 1 ms
  • 如果是串行执行,那么总共花费的时间是 10 + 11 + 9 + 1 = 31ms
  • 但如果是四核 cpu,各个核心分别使用线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3,那么 3 个线程是并行的,花费时间只取决于最长的那个线程运行的时间,即 11ms 最后加上汇总时间只会花费 12ms

注意:需要在多核 cpu 才能提高效率,单核仍然时是轮流执行

结论
  1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活
  2. 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
    • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
    • 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
  3. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化

5、线程不安全示例

如果多个线程同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。

1
2
3
4
5
6
7
8
9
public class ThreadUnsafeExample {
private int cnt = 0;
public void add() {
cnt++;
}
public int get() {
return cnt;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
ThreadUnsafeExample example = new ThreadUnsafeExample();
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(example.get());
}

结果:

1
997 // 结果总是小于1000

6、并发出现问题的根源:并发三要素

上述代码输出的值为什么总是小于1000?并发出现问题的根源是什么?

  • 并发的三要素
    • 可见性
    • 原子性
    • 有序性

1、可见性:CPU缓存引起

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

例子:

代码:

1
2
3
4
5
6
//线程1执行的代码
int i = 0; // 在主存中的值
i = 10; // 在CPU1中高速缓存中

//线程2执行的代码
j = i; // 读取的依旧是主存当中i的值,对于CPU修改的i的值不可见

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值

2、原子性:分时复用引起

原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

经典的银行取钱问题:比如从账户A和账户B同时对一个银行账号取钱1000元,那么必然包括2个操作:

  1. 从银行账号读取余额,取钱1000元
  2. 取完钱之后,银行将账号的余额进行更新(-1000)

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A取钱1000元之后,操作突然中止。然后又从B取出了1000元,取出1000元之后,再执行银行余额更新减去1000元的操作。这样就会导致账号减去了1000元,但是账户A与账户B一共取到了2000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

3、有序性:重排序引起

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

1
2
3
4
int i = 0;              
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?

  • 不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
1、CPU的指令重排序
1、名词
  • Clock Cycle Time:主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的Cycle Time 是 1s
    • 例如,运行一条加法指令一般需要一个时钟周期时间
  • CPI:有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数
  • IPC:IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数
  • CPU 执行时间:程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示
    • 程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time
2、指令重排序优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?

可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段

image-20210806220551047

术语参考:

  • instruction fetch (IF)
  • instruction decode (ID)
  • execute (EX)
  • memory access (MEM)
  • register write back (WB)

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。

指令重排的前提是,重排指令不能影响结果,例如:

1
2
3
4
5
6
7
 // 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
3、支持流水线的处理器

现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。

image-20210806220613006

4、SuperScalar 处理器

大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC > 1

image-20210806220634123

2、重排序(Java 内存模型JMM)
1、重排序的分类

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

img

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

2、处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:

1
2
3
4
5
6
7
8
9
// Processor A
a = 1; //A1
x = b; //A2

// Processor B
b = 2; //B1
y = a; //B2

// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:

img

这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。

下面是常见处理器允许的重排序类型的列表:

Load-Load Load-Store Store-Store Store-Load 数据依赖
sparc-TSO N N N Y
x86 N N N Y
ia64 Y Y Y Y
PowerPC Y Y Y Y

上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。

从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。

  • ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
  • ※注 2:上表中的 x86 包括 x64 及 AMD64。
  • ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
  • ※注 4:数据依赖性后文会专门说明。

为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。

StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)

3、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

注意:

  • 这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,
  • 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑
4、as-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

1
2
3
double pi  = 3.14;    //A
double r = 1.0; //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:

img

如上图所示:

  1. A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。
  2. 因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。
  3. 但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。

下图是该程序的两种执行顺序:

img

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

5、程序顺序规则

根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:

  • A happens- before B;
  • B happens- before C;
  • A happens- before C;

这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。

这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果 A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。

6、重排序对多线程的影响

重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReorderExample {
int a = 0;
boolean flag = false;

public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}

flag 变量是个标记,用来标识变量 a 是否已被写入。

这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?

答案是:不一定能看到。

由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

当操作 1 和操作 2 重排序时,可能会产生什么效果? 请看下面的程序执行时序图:(注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。)

img

如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!

下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:

img

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。

从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!

结论:

  • 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);
  • 在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果
3、顺序一致性(Java 内存模型JMM)
1、数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量
  • 在另一个线程读同一个变量
  • 而且写和读没有通过同步来排序

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序

JMM 对正确同步的多线程程序的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)
  • 程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同
  • 这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。
2、顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序

在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:

img

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读 / 写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 / 写操作串行化。

为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。

  • 假设有两个线程 A 和 B 并发执行。
    • 其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。
    • B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
  • 假设这两个线程使用监视器来正确同步:
    • A 线程的三个操作执行后释放监视器,
    • 随后 B 线程获取同一个监视器。

那么程序在顺序一致性模型中的执行效果将如下图所示:

img

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

img

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如:在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

3、同步程序的顺序一致性效果

下面我们对前面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具有顺序一致性。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
a = 1;
flag = true;
}

public synchronized void reader() {
if (flag) {
int i = a;
// ……
}
}
}

上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。

下面是该程序在两个内存模型中的执行时序对比图:

img

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门

4、未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来

为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。

和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
  • JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。

第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。下面让我们通过一个示意图来说明总线的工作机制:

img

如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。

在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。

当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:

img

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A”写了一半”的无效值。

4、总结
1、处理器内存模型

顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  • 放松了程序中写 - 读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
  • 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
  • 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。

注意:

  • 这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

下面的表格展示了常见处理器内存模型的细节特征:

内存模型名称 对应的处理器 Store-Load 重排序 Store-Store 重排序 Load-Load 和 Load-Store 重排序 可以更早读取到其它处理器的写 可以更早读取到当前处理器的写
TSO sparc-TSO X64 Y Y
PSO sparc-PSO Y Y Y
RMO ia64 Y Y Y Y
PowerPC PowerPC Y Y Y Y Y

在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序,原因在前面说明过:它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写

上面表格中的各种处理器内存模型,从上到下,模型由强变弱越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能

由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:

img

如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。

2、JMM、处理器内存模型与顺序一致性内存模型之间的关系
  • JMM 是一个语言级的内存模型
  • 处理器内存模型是硬件级的内存模型
  • 顺序一致性内存模型是一个理论参考模型

下面是语言内存模型、处理器内存模型和顺序一致性内存模型的强弱对比示意图:

img

从上图我们可以看出:

  • 常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱
  • 处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。
  • 同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。
3、JMM 的设计

从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型

由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:

  1. 一方面要**为程序员提供足够强的内存可见性保证**;
  2. 另一方面,**对编译器和处理器的限制要尽可能的放松**。

下面让我们看看 JSR-133 是如何实现这一目标的。为了具体说明,请看前面提到过的计算圆面积的示例代码:

1
2
3
double pi  = 3.14;    //A
double r = 1.0; //B
double area = pi * r * r; //C

上面计算圆的面积的示例代码存在三个 happens- before 关系:

  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;

由于 A happens- before B,happens- before 的定义会要求:

  1. A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。
  2. 但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。
  3. 也就是说,上面这 3 个 happens- before 关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:
    • 会改变程序执行结果的重排序。
    • 不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。

下面是 JMM 的设计示意图:

img

从上图可以看出两点:

  • JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens - before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
  • JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除(锁消除)。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
4、JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证
  • 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:

img

只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

5、JSR-133 对旧内存模型的修补

JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:

  • 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。
  • 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。

7、JAVA是怎么解决并发问题的:JMM(Java内存模型)

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。

JMM 体现在以下几个方面:

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响
  • 理解的第一个维度:核心知识点

    • JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
      • volatilesynchronizedfinal 三个关键字
      • Happens-Before 规则
  • 理解的第二个维度:可见性,有序性,原子性

    • 原子性

      • 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

      • 请分析以下哪些操作是原子性操作:

        1. x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
          
          1
          2
          3
          4

          2. ```java
          y = x;
          //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。
        2. x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
          
          1
          2
          3

          4. ```java
          x = x + 1; //语句4: 同语句3
      • 上面4个语句只有语句1的操作具备原子性。

      • 也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

      • 从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronizedLock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

    • 可见性

      • Java提供了volatile关键字来保证可见性。
      • 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
      • 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
      • 另外,通过synchronizedLock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
    • 有序性

      • 在Java里面,可以通过volatile关键字来保证一定的”有序性”(具体原理在下面讲述)。另外可以通过synchronizedLock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的

1、关键字:volatile、synchronized 和 final

1、volatile
2、synchronized
3、final

2、Happens-Before 规则

上面提到了可以用 volatilesynchronized保证有序性。除此之外,JVM 还规定了先行发生(Happens-Before)原则让一个操作无需控制就能先于另一个操作完成

从 JDK5 开始,java 使用新的 JSR-133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见:(变量都是指成员变量或静态成员变量)

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    • static int x;
      static Object m = new Object();
      new Thread(()->{
          synchronized(m) {
              x = 10;
          }
      },"t1").start();
      
      new Thread(()->{
          synchronized(m) {
              System.out.println(x);
          }
      },"t2").start();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

      - ```java
      volatile static int x;
      new Thread(()->{
      x = 10;
      },"t1").start();
      new Thread(()->{
      System.out.println(x);
      },"t2").start();
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见

    • static int x;
      x = 10;
      new Thread(()->{
          System.out.println(x); 
      },"t2").start();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

      - ```java
      static int x;
      Thread t1 = new Thread(()->{
      x = 10;
      },"t1");
      t1.start();
      t1.join();
      System.out.println(x);
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)

    • static int x;
      public static void main(String[] args) {
          Thread t2 = new Thread(()->{
              while(true) {
                  if(Thread.currentThread().isInterrupted()) {
                      System.out.println(x);
                      break;
                  }
              }
          },"t2");
          t2.start();
      
          new Thread(()->{
              sleep(1);
              x = 10;
              t2.interrupt();
          },"t1").start();
      
          while(!t2.isInterrupted()) {
              Thread.yield();
          }
          System.out.println(x);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17

      - 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

      - 具有传递性,如果 `x hb-> y` 并且 `y hb-> z` 那么有 `x hb-> z` ,配合 `volatile` 的防指令重排,有下面的例子

      - ```java
      volatile static int x;
      static int y;
      new Thread(()->{
      y = 10;
      x = 20;
      },"t1").start();

      new Thread(()->{
      // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
      System.out.println(x);
      },"t2").start();

注意:

  • 两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!
  • happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

happens-before 与 JMM 的关系如下图所示:

img

如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

1、单一线程原则(Single Thread rule)

在一个线程内,在程序前面的操作先行发生于后面的操作。

image

2、管程锁定规则(Monitor Lock Rule)

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

image

3、volatile 变量规则(Volatile Variable Rule)

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

image

4、线程启动规则(Thread Start Rule)

Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

image

5、线程加入规则(Thread Join Rule)

Thread 对象的结束先行发生于 join() 方法返回。

image

6、线程中断规则(Thread Interruption Rule)

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

7、对象终结规则(Finalizer Rule)

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8、传递性(Transitivity)

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

8、线程安全:不是一个非真即假的命题

一个类在可以被多个线程安全调用时就是线程安全的。

线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类:

  1. 不可变
  2. 绝对线程安全
  3. 相对线程安全
  4. 线程兼容
  5. 线程对立。

1、不可变(Immutable)

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。

多线程环境下,应当尽量使对象成为不可变,来满足线程安全

不可变的类型:

  • final 关键字修饰的基本数据类型

  • String

  • 枚举类型

  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。

    • 但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
  • 日期格式转换类 DateTimeFormatter

    • 平常用的的日期格式转换类 SimpleDateFormat 在多线程下是不安全的,有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果

    • 在 Java 8 后通过了 DateTimeFormatter 解决这个问题,在文档中你可以发现对DateTimeFormatter的描述:

      1
      2
      @implSpec
      This class is immutable and thread-safe.

对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。

Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常

1
2
3
4
5
6
7
public class ImmutableExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
unmodifiableMap.put("a", 1);
}
}
1
2
3
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
1
2
3
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
at ImmutableExample.main(ImmutableExample.java:9)
1、不可变的设计要素

String为例,说明一下不可变设计的要素:

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];

/** Cache the hash code for the string */
private int hash; // Default to 0

/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;

// ...
}
  • String整一个类被final修饰了,保证了String没有任何子类,所以也不用担心子类去修改重写它的方法而导致破坏不可变性
  • hash虽然没有加上什么final修饰,但是hash是私有的并且String类没有提供hash的set方法,外部没有办法修改hash的值,所以也算保证了hash的不可变性
  • char[]数组使用了final修饰,在构造方法当中赋值,保证了value值的不可变性;
  • 但是这样只是保证了char[]数组这个引用变量的不可变性,怎么保证char[]数组里面的值具有不可变性呢?
    • 主要是依赖了String的构造方法。

String的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 无参
public String() {
this.value = "".value;
}

// 传递一个原始字符串,根据该字符串生成新字符串,它会与原始字符串共用一个value数组
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

// 传递一个char[]数组,它会对char[]数组的内容进行拷贝,就复制一个新数组,新数组在作为String的value数组
// 这种思想:保护性拷贝
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
2、保护性拷贝(defensive copy)

使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那不就破坏了String的不可变性了吗?那么下面就看一看这些方法是如何实现的,就以 substring 为例:

1
2
3
4
5
6
7
8
9
10
11
12
public String substring(int beginIndex) {
// 一些常规判断
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 主要是这里:它会新new一个String并把value作为参数传递进去,保证不可变性
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

发现其内部是调用 String 的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value 做出了修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
// 它会对数组的内容进行拷贝,就复制一个新数组,新数组在作为String的value数组(保护性拷贝)
this.value = Arrays.copyOfRange(value, offset, offset+count);
}

结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】

2、绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施。

3、相对线程安全

相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。

对于下面的代码,如果删除元素的线程删除了 Vector 的一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class VectorUnsafeExample {
private static Vector<Integer> vector = new Vector<>();

public static void main(String[] args) {
while (true) {
for (int i = 0; i < 100; i++) {
vector.add(i);
}
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
});
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
});
executorService.shutdown();
}
}
}
1
2
3
4
5
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
at java.util.Vector.remove(Vector.java:831)
at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
}
});

4、线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。

5、线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免

9、线程安全的实现方法

1、互斥同步

synchronizedReentrantLock

2、非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

1、CAS(JUC中CAS, Unsafe和原子类相关)

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:==先进行操作==**,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)**。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

乐观锁需要==操作和冲突检测这两个步骤具备原子性==,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:**比较并交换(Compare-and-Swap,CAS)**。

CAS 指令需要有 3 个操作数,分别是:

  • 内存地址 V
  • 旧的预期值 A
  • 新值 B。

当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。(否则一直循环重试,直到成功为止)

2、AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet()getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作

以下代码使用了 AtomicInteger 执行了自增的操作:

1
2
3
4
5
private AtomicInteger cnt = new AtomicInteger();

public void add() {
cnt.incrementAndGet();
}

以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt()

1
2
3
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是 getAndAddInt() 源码,其中:

  • var1 指示对象内存地址
  • var2 指示该字段相对对象内存地址的偏移
  • var4 指示操作需要加的数值,这里为 1

具体过程:

  1. 通过 getIntVolatile(var1, var2) 得到旧的预期值;
  2. 通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
  3. 可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
1
2
3
4
5
6
7
8
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}
3、ABA

ABA问题:如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效

3、无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

1、栈封闭(JUC中线程池相关)

多个线程访问同一个方法的==局部变量==时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class StackClosedExample {
public void add100() {
int cnt = 0;
for (int i = 0; i < 100; i++) {
cnt++;
}
System.out.println(cnt);
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {
StackClosedExample example = new StackClosedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> example.add100());
executorService.execute(() -> example.add100());
executorService.shutdown();
}
1
2
100
100
2、线程本地存储(Thread Local Storage)(JUC中ThreadLocal详解)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“==一个请求对应一个服务器线程”(Thread-per-Request)==的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 ==java.lang.ThreadLocal== 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal.set(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadLocal.get());
threadLocal.remove();
});
Thread thread2 = new Thread(() -> {
threadLocal.set(2);
threadLocal.remove();
});
thread1.start();
thread2.start();
}
}

输出结果:1

为了理解 ThreadLocal,先看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadLocalExample1 {
public static void main(String[] args) {
ThreadLocal threadLocal1 = new ThreadLocal();
ThreadLocal threadLocal2 = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal1.set(1);
threadLocal2.set(1);
});
Thread thread2 = new Thread(() -> {
threadLocal1.set(2);
threadLocal2.set(2);
});
thread1.start();
thread2.start();
}
}

它所对应的底层结构图为:

image

每个 Thread 都有一个 ==ThreadLocal.ThreadLocalMap 对象==,Thread 类中就定义了 ThreadLocal.ThreadLocalMap 成员。

1
2
3
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

当调用一个 ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLoca -> value 键值对插入到该 Map 中。

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

get() 方法类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

hreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争

注意:

  • **在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ==ThreadLocal 有内存泄漏的情况==**;
  • 应该尽可能在每次使用 ThreadLocal 后==手动调用 remove()==,以==避免出现 ThreadLocal 经典的内存泄漏==甚至是造成自身业务混乱的风险。
3、可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征:

  • 例如不依赖存储在堆上的数据和公用的系统资源
  • 用到的状态量都由参数中传入
  • 不调用非可重入的方法等。
4、无状态

在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的

因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】


3、Java 并发 - 线程基础

1、BAT大厂的面试问题

  • 线程有哪几种状态?分别说明从一种状态到另一种状态转变有哪些方式?
  • 通常线程有哪几种使用方式?
  • 基础线程机制有哪些?
  • 线程的中断方式有哪些?
  • 线程的互斥同步方式有哪些?如何比较和选择?
  • 线程之间有哪些协作方式?

2、进程与线程

1、进程

进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。

在当代面向线程设计的计算机结构中,进程是线程的容器

进程:

  • 是程序的实体;
  • 是计算机中的程序关于某数据集合上的一次运行活动;
  • 是系统进行资源分配和调度的基本单位;
  • 是操作系统结构的基础。
  • 程序是指令、数据及其组织形式的描述,进程是程序的实体。

进程:

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器
    等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

2、线程

线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

线程:

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
  • Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器

3、进程与线程的区别

  • 进程:指在系统中正在运行的一个应用程序;程序一旦运行就是进程;
    • 进程——资源分配的最小单位。
  • 线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。
    • 线程——程序执行的最小单位。
  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为 IPC(Inter-process communication)
      • 信号量:信号量是一个计数器,用于多进程对共享数据的访问,解决同步相关的问题并避免竞争条件
      • 共享存储:多个进程可以访问同一块内存空间,需要使用信号量用来同步对共享存储的访问
      • 管道通信:管道是用于连接一个读进程和一个写进程以实现它们之间通信的一个共享文件,pipe文件
        • 匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信,只支持半双工通信
        • 命名管道(Names Pipes):以磁盘文件的方式存在,可以实现本机任意两个进程通信,遵循FIFO
      • 消息队列:内核中存储消息的链表,由消息队列标识符标识,能在不同进程之间提供全双工通信,对比管道:
        • 匿名管道存在于内存中的文件;命名管道存在于实际的磁盘介质或者文件系统;消息队列存放在内核中,只有在内核重启(操作系统重启)或者显示地删除一个消息队列时,该消息队列才被真正删除
        • 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
      • 套接字:与其它通信机制不同的是,它可用于不同机器间的进程通信
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
    • Java中的通信机制:volatile、等待/通知机制、join方式、InheritableThreadLocal、MappedByteBuffer
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

3、线程状态转换

1、线程的五状态模型(操作系统)

image-20210803042737724

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
  • 【运行状态】指获取了 CPU 时间片运行中的状态
    • 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
  • 【阻塞状态】
    • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
    • 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

2、线程的七状态模型(操作系统)

img

3、线程的六状态模型(java)

这是从 Java API 层面来描述的

根据 Thread.State 枚举,分为六种状态:

image-20210803043141966

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的【==可运行状态==】、【==运行状态==】和【==阻塞状态==】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
  • BLOCKEDWAITINGTIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述
  • TERMINATED 当线程代码运行结束

image

假设有线程 Thread t

  1. NEW –> RUNNABLE
    • 当调用 t.start() 方法时,由 NEW --> RUNNABLE
  2. RUNNABLE <–> WAITING
    • t 线程用 synchronized(obj) 获取了对象锁后
      • 调用 obj.wait() 方法时,t 线程从 RUNNABLE --> WAITING
      • 调用 obj.notify()obj.notifyAll()t.interrupt()
        • 线程被notify之后直接从waitset进入entrylist,对应的状态就是WAITING --> BLOCKED
        • 等到锁释放之后,t线程进入锁的竞争
          • 竞争锁成功,t 线程从 WAITING --> RUNNABLE
          • 竞争锁失败,t 线程从 WAITING --> BLOCKED
  3. RUNNABLE <–> WAITING
    • 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE --> WAITING
      • 注意是当前线程在t 线程对象的监视器上等待
    • t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE
  4. RUNNABLE <–> WAITING
    • 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE --> WAITING
    • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE
  5. RUNNABLE <–> TIMED_WAITING
    • t 线程用 synchronized(obj) 获取了对象锁后
      • 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
      • t 线程等待时间超过了 n 毫秒,或调用 obj.notify()obj.notifyAll()t.interrupt()
        • 线程被notify之后直接从waitset进入entrylist,对应的状态就是WAITING --> BLOCKED
        • 等到锁释放之后,t线程进入锁的竞争
          • 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
          • 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
  6. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING
      • 注意是当前线程在t 线程对象的监视器上等待
    • 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从TIMED_WAITING --> RUNNABLE
  7. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
    • 当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING --> RUNNABLE
  8. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
    • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE
  9. RUNNABLE <–> BLOCKED
    • t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE --> BLOCKED
    • 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED --> RUNNABLE ,其它失败的线程仍然 BLOCKED
  10. RUNNABLE <–> TERMINATED
    • 当前线程所有代码运行完毕,进入 TERMINATED

线程一共有六种状态:

  • 新建(new)
  • 可运行(runnable)
  • 阻塞(blocking)
  • 无限期等待(waiting)
  • 限期等待(timed waiting)
  • 死亡(terminated)
1、新建(New)

创建后尚未启动

2、可运行(Runnable)

可能正在运行,也可能正在等待 CPU 时间片。

包含了操作系统线程状态中的 RunningReady

3、阻塞(Blocking)

等待获取一个排它锁,如果其线程释放了锁就会结束此状态

4、无限期等待(Waiting)

等待其它线程显式地唤醒,否则不会被分配 CPU 时间片

进入方法 退出方法
没有设置 Timeout 参数的 Object.wait() 方法 Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法 被调用的线程执行完毕
LockSupport.park() 方法 -
5、限期等待(Timed Waiting)

无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。

  • 调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。
  • 调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。

睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态

阻塞和等待的区别:

  • 阻塞是被动的,它是在等待获取一个排它锁
  • 等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入
进入方法 退出方法
Thread.sleep() 方法 时间结束
设置了 Timeout 参数的 Object.wait() 方法 时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法 时间结束 / 被调用的线程执行完毕
LockSupport.parkNanos() 方法 -
LockSupport.parkUntil() 方法 -
6、死亡(Terminated)

可以是线程结束任务之后自己结束,或者产生了异常而结束。

4、线程的四种使用方式

有三种使用线程的方法:

  • 实现 Runnable 接口
  • 实现 Callable 接口
  • 继承 Thread 类
  • 使用线程池

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。

1、实现 Runnable 接口

  1. 编写需要的类并实现Runnable接口,实现里面的 run() 方法。
  2. 通过 Thread 调用 start() 方法来启动线程。

代码:

1
2
3
4
5
6
public class MyRunnable implements Runnable {
@Override
public void run() {
// ...
}
}
1
2
3
4
5
6
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}

实现 Runnable 接口的优缺点:

  • 缺点:代码复杂一点。
  • 优点:
    1. 线程任务类只是实现了Runnable接口,可以继续继承其他类,避免了单继承的局限性
    2. 同一个线程任务对象可以被包装成多个线程对象
    3. 适合多个多个线程去共享同一个资源
    4. 实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立
    5. 线程池可以放入实现Runnable或Callable线程任务对象

2、实现 Callable 接口

与Runnable 接口大致相同:

  1. 编写需要的类并实现Callable接口,实现里面的 call() 方法,该方法有返回值
  2. 通过 Thread 调用 start() 方法来启动线程。

区别是:与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装

1
2
3
4
5
6
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
return 123;
}
}
1
2
3
4
5
6
7
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}

实现 Callable 接口的优缺点:

  • 优点:同 Runnable,并且能得到线程执行的结果
  • 缺点:编码复杂

3、继承 Thread 类

同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。

当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。

建议线程先创建子线程,主线程的任务放在之后,否则主线程(main)永远是先执行完

1
2
3
4
5
6
public class MyThread extends Thread {
@Override
public void run() {
// ...
}
}
1
2
3
4
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}

继承 Thread 类的优缺点:

  • 优点:编码简单
  • 缺点:线程类已经继承了Thread类无法继承其他类了,功能不能通过继承拓展(单继承的局限性)

4、使用线程池

Java标准库提供了ExecutorService接口表示线程池,因为ExecutorService只是接口,Java标准库提供的几个常用实现类有:

  • FixedThreadPool:线程数固定的线程池;
  • CachedThreadPool:线程数根据任务动态调整的线程池;
  • SingleThreadExecutor:仅单线程执行的线程池。

创建这些线程池的方法都被封装到Executors这个类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.concurrent.*;

public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}

class Task implements Runnable {
private final String name;

public Task(String name) {
this.name = name;
}

@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}

线程池的具体细节放在下面线程池篇具体说明

5、Thread 与 Runnable 的底层关系

使用Runnable的方法创建线程的代码:

1
2
Thread t = new Thread(()->{ log.debug("running"); }, "t2");
t.start();

Thread底层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Thread的一个构造函数
public Thread(Runnable target, String name) {
init(null, target, name, 0);
}

// init调用了Thread的init初始化函数,其中将Runnable作为target对象传入
private void init(ThreadGroup g, Runnable target, String name,
long stackSize) {
init(g, target, name, stackSize, null, true);
}

// 该init函数调用了重载的其他init函数
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
if (name == null) {
throw new NullPointerException("name cannot be null");
}

this.name = name;

Thread parent = currentThread();
SecurityManager security = System.getSecurityManager();
if (g == null) {
if (security != null) {
g = security.getThreadGroup();
}
if (g == null) {
g = parent.getThreadGroup();
}
}
g.checkAccess();

if (security != null) {
if (isCCLOverridden(getClass())) {
security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
}
}

g.addUnstarted();

this.group = g;
this.daemon = parent.isDaemon();
this.priority = parent.getPriority();
if (security == null || isCCLOverridden(parent.getClass()))
this.contextClassLoader = parent.getContextClassLoader();
else
this.contextClassLoader = parent.contextClassLoader;
this.inheritedAccessControlContext =
acc != null ? acc : AccessController.getContext();
// 主要是这一句代码:将Runnable对象的target赋值给Thread本身的target
this.target = target;
setPriority(priority);
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
/* Stash the specified stack size in case the VM cares */
this.stackSize = stackSize;

/* Set thread ID */
tid = nextThreadID();
}

// 在该init函数里面将Runnable对象的target赋值给Thread本身的target,然后在Thread内部调用了run方法
@Override
public void run() {
if (target != null) {
target.run();
}
}

总结:

  • Thread类本身实现了Runnable接口
  • 如果直接使用Thread的方式创建线程对象,则原理是重写了Thread的run方法
  • 如果使用的Runnable的方式创建线程对象,在原理是将Runnable对象封装成target,在Thread中调用target.run方法

6、实现接口 VS 继承 Thread

实现接口会更好一些,因为:

  • 使用接口更容易与线程池等高级 API 配合
  • 使用接口让任务类脱离了 Thread 继承体系,更灵活
  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大

7、调用start()方法,线程是否会马上创建?

  • 线程不一定马上创建的
  • 看start()方法的源码知道start()方法底层调用了start0()方法,这是一个被native修饰的方法,它的调用依赖于操作系统
  • 当操作系统认为当前可以创建线程的时候,线程才会被创建

8、查看进程线程的方法

  • windows
    • 任务管理器可以查看进程和线程数,也可以用来杀死进程
    • tasklist 查看进程
    • taskkill 杀死进程
  • linux
    • ps -fe 查看所有进程
    • ps -fT -p <PID> 查看某个进程(PID)的所有线程
    • kill 杀死进程
    • top 按大写 H 切换是否显示线程
    • top -H -p <PID> 查看某个进程(PID)的所有线程
  • Java
    • jps 命令 查看所有 Java 进程
    • jstack <PID> 查看某个 Java 进程(PID)的所有线程状态
    • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)
      • jconsole 远程监控配置:
        • 需要以如下方式运行你的 java 类
          • java -Djava.rmi.server.hostname=ip地址 -Dcom.sun.management.jmxremote -
            Dcom.sun.management.jmxremote.port=连接端口 -Dcom.sun.management.jmxremote.ssl=是否安全连接 -Dcom.sun.management.jmxremote.authenticate=是否认证 java类
        • 修改 /etc/hosts 文件将 127.0.0.1 映射至主机名
        • 如果要认证访问,还需要做如下步骤:
          • 复制 jmxremote.password 文件
          • 修改 jmxremote.password 和 jmxremote.access 文件的权限为 600 即文件所有者可读写
          • 连接时填入 controlRole(用户名),R&D(密码)

9、线程运行的原理

1、栈与栈帧

JVM 中由堆、栈、方法区所组成。

Java Virtual Machine Stacks (Java 虚拟机栈):每个线程启动后,虚拟机就会为其分配一块栈内存

我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?

其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
2、线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。原因:

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleepyieldwaitjoinparksynchronizedlock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Java 创建的线程是内核级线程,线程的调度是在内核态运行的,而线程中的代码是在用户态运行,所以线程切换(状态改变)会导致用户与内核态转换,这是非常消耗性能
  • Java 中 main 方法启动的是一个进程也是一个主线程,main 方法里面的其他线程均为子线程

5、线程的常见方法

方法名 static(静态) 功能说明 注意
start() 启动一个新线程,在新的线程运行 run 方法中的代码 start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException
run() 新线程启动后会调用的方法 如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象,来覆盖默认行为
join() 等待线程运行结束
join(long n) 等待线程运行结束,最多等待 n 毫秒
getId() 获取线程长整型的 id id 唯一
getName() 获取线程名
setName(String) 修改线程名
getPriority() 获取线程优先级
setPriority(int) 修改线程优先级 java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率
getState() 获取线程状态 Java 中线程状态是用 6 个 enum 表示,分别为:NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
isInterrupted() 判断是否被打断 不会清除==打断标记==
isAlive() 线程是否存活(还没有运行完毕)
interrupt() 打断线程 如果被打断线程正在 sleepwaitjoin 会导致被打断的线程抛出 InterruptedException,并清除==打断标记== ;如果打断的正在运行的线程,则会设置==打断标记==;park 的线程被打断,也会设置==打断标记==
interrupted() static 判断当前线程是否被打断 会清除==打断标记==
currentThread() static 获取当前正在执行的线程
sleep(long n) static 让当前执行的线程休眠n毫秒,休眠时让出 cpu 的时间片给其它线程
yield() static 提示线程调度器让出当前线程对CPU的使用 主要是为了测试和调试

6、不推荐的方法

还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁

方法名 static 功能说明
stop() 停止线程运行
suspend() 挂起(暂停)线程运行
resume() 恢复线程运行

7、基础线程机制

1、Executor

Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。

主要有三种 Executor:

  • CachedThreadPool: 一个任务创建一个线程;
  • FixedThreadPool:所有任务只能使用固定大小的线程;
  • SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。

具体使用:(代码)

1
2
3
4
5
6
7
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.execute(new MyRunnable());
}
executorService.shutdown();
}

2、Daemon

守护线程是程序运行时在后台提供服务的线程,属于程序中不可或缺的一部分

当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程

main() 属于非守护线程。

使用 setDaemon() 方法将一个线程设置为守护线程。

1
2
3
4
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}

3、sleep()

Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为==毫秒==。

sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。

1
2
3
4
5
6
7
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
案例——防止CPU占用100%

在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权给其他程序

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestCpu {
public static void main(String[] args) {
new Thread(() -> {
while(true) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
  • 可以用 wait 或 条件变量达到类似的效果
  • 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
  • sleep 适用于无需锁同步的场景

4、yield()

对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行(让位操作)。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。

1
2
3
public void run() {
Thread.yield();
}

5、run/start、sleep/yield、线程优先级

1、run与start
1、run

run:称为线程体,包含了要执行的这个线程的内容,方法运行结束,此线程随即终止。直接调用 run 是在主线程中执行了 run,没有启动新的线程,需要顺序执行

2、start

start:使用 start 是启动新的线程,此线程处于就绪(可运行)状态,通过新的线程间接执行 run 中的代码

说明:线程控制资源类

3、面试问题:run() 方法中的异常不能抛出,只能 try/catch
  • 因为父类中没有抛出任何异常,子类不能比父类抛出更多的异常
  • 异常不能跨线程传播回 main() 中,因此必须在本地进行处理
4、run与start之间的区别
  • 直接调用 run 是在主线程中执行了 run,没有启动新的线程,相当于变成了普通类的执行,此时将只有主线程在执行该线程

  • 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码

  • 调用start()方法之前与之后线程的状态:

    • 代码:

      • public class Test5 {
            public static void main(String[] args) {
                Thread t1 = new Thread("t1") {
                    @Override
                    public void run() {
                        log.debug("running...");
                    }
                };
        
                System.out.println(t1.getState());
                t1.start();
                System.out.println(t1.getState());
            }
        }
        
        1
        2
        3
        4
        5
        6
        7

        - 结果:

        - ```sh
        NEW
        RUNNABLE
        12:51:05.298 [t1] c.Test5 - running...
2、sleep与yield之间的区别
1、sleep
  • 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
    • 使用sleep后,线程失去cpu的时间片。同时也不能在获取cpu的时间片。
  1. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  2. 睡眠结束后的线程未必会立刻得到执行
  3. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
2、yield
  • 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
    • 使用yield后,如果线程进入Runnable就绪状态还是有可能签到cpu时间片的,这是与sleep()最大的不同
  1. 具体的实现依赖于操作系统的任务调度器
  2. 会放弃 CPU 资源,锁资源不会释放
3、线程优先级(priority)
  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用

8、线程中断

一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。

1、InterruptedException

通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞

对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InterruptExample {

private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
1
2
3
4
5
6
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
1
2
3
4
5
6
7
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

2、interrupted()

如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束

但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程

1
2
3
4
5
6
7
8
9
10
11
12
public class InterruptExample {

private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
1
2
3
4
5
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
1
Thread end

3、Executor 的中断操作

  1. 调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,
  2. 但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。

以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
executorService.shutdownNow();
System.out.println("Main run");
}
1
2
3
4
5
6
7
8
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at ExecutorInterruptExample.lambda$main$0(ExecutorInterruptExample.java:9)
at ExecutorInterruptExample$$Lambda$1/1160460865.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)

如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future<?> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程

1
2
3
4
Future<?> future = executorService.submit(() -> {
// ..
});
future.cancel(true);

9、线程互斥同步

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问:

  1. 第一个是 JVM 实现的 synchronized;
  2. 而另一个是 JDK 实现的 ReentrantLock。

1、synchronized

1、同步一个代码块
1
2
3
4
5
public void func() {
synchronized (this) {
// ...
}
}

它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步

对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。

1
2
3
4
5
6
7
8
9
10
public class SynchronizedExample {

public void func1() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func1());
executorService.execute(() -> e1.func1());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
2、同步一个方法
1
2
3
public synchronized void func () {
// ...
}

它和同步代码块一样,作用于同一个对象。

3、同步一个类
1
2
3
4
5
public void func() {
synchronized (SynchronizedExample.class) {
// ...
}
}

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步

1
2
3
4
5
6
7
8
9
10
public class SynchronizedExample {

public void func2() {
synchronized (SynchronizedExample.class) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
SynchronizedExample e2 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func2());
executorService.execute(() -> e2.func2());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
4、同步一个静态方法
1
2
3
public synchronized static void fun() {
// ...
}

作用于整个类。

2、ReentrantLock(JUC中的ReentrantLock)

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LockExample {

private Lock lock = new ReentrantLock();

public void func() {
lock.lock();
try {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
} finally {
lock.unlock(); // 确保释放锁,从而避免发生死锁。
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
LockExample lockExample = new LockExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> lockExample.func());
executorService.execute(() -> lockExample.func());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

3、比较

  • 锁的实现
    • synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
  • 性能
    • 新版本 Java 对 synchronized 进行了很多优化,例如==自旋锁==等,synchronized 与 ReentrantLock 大致相同。
  • 等待可中断
    • 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
      • ReentrantLock 可中断
      • 而 synchronized 不行
  • 公平锁
    • 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
      • synchronized 中的锁是非公平的
      • ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
  • 锁绑定多个条件
    • 一个 ReentrantLock 可以同时绑定多个 Condition 对象。

4、使用选择

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized

这是因为:

  1. synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。
  2. 并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

10、线程之间的协作

当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调

1、join()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。

原理:调用者轮询检查线程 alive 状态,t1.join()等价于:原理:调用者轮询检查线程 alive 状态,t1.join()等价于:

1
2
3
4
5
6
synchronized (t1) {
// 调用者线程进入 t1 的 waitSet 等待, 直到 t1 运行结束
while (t1.isAlive()) {
t1.wait(0);
}
}
  • join 方法是被 synchronized 修饰的,本质上是一个对象锁,其内部的 wait 方法调用也是释放锁的,但是释放的是当前线程的对象锁,而不是外面的锁
  • t1 会强占 CPU 资源,直至线程执行结束,当调用某个线程的 join 方法后,该线程抢占到 CPU 资源,就不再释放,直到线程执行完毕

线程同步:

  • join 实现线程同步,因为会阻塞等待另一个线程的结束,才能继续向下运行
    • 需要外部共享变量,不符合面向对象封装的思想
    • 必须等待线程结束,不能配合线程池使用
  • Future 实现(同步):get() 方法阻塞等待执行结果
    • main 线程接收结果
    • get 方法是让调用线程同步等待
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test {
static int r = 0;
public static void main(String[] args) throws InterruptedException {
test1();
}
private static void test1() throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
r = 10;
});
t1.start();
t1.join();//不等待线程执行结束,输出的10
System.out.println(r);
}
}
1、为什么需要join()

如果想要某线程(A)优先于某线程(B)运行(场景:线程B需要线程A的运算结果),这个时候就得线程B就需要使用join()来挂起当前线程,直到目标线程(A)结束。

对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class JoinExample {

private class A extends Thread {
@Override
public void run() {
System.out.println("A");
}
}

private class B extends Thread {

private A a;

B(A a) {
this.a = a;
}

@Override
public void run() {
try {
a.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("B");
}
}

public void test() {
A a = new A();
B b = new B(a);
b.start();
a.start();
}
}
1
2
3
4
public static void main(String[] args) {
JoinExample example = new JoinExample();
example.test();
}
1
2
A
B
2、为什么不用sleep(),而使用join()

使用sleep也可以实现以上效果,但是不好:因为在设计情况下你不清楚A线程需要多次时间得到运算结果,所以B线程不知道要sleep多少时间。

3、join(long)

join(long)可以设置等待时间,单位是ms。

  • 如果到了设置的时间还没有结果,线程会结束等待,继续往下运行
  • 如果在设置的时间之前就应经有结果了,线程会立即往下运行,不会等到设定的时间
4、join的底层原理——保护性暂停模式的时间增强

先看一下join的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public final void join() throws InterruptedException {
join(0);
}

public final synchronized void join(long millis)
throws InterruptedException {
// 开始时间
long base = System.currentTimeMillis();
// 经历时间
long now = 0;

// 相关判断
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}

if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
// 延迟时间,相当于保护性暂停中的waitTime
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}

可以将join的底层实现与保护性暂停模式的时间增强进行对比,会发现join的底层用的是保护性暂停模式的时间增强

2、wait()、notify()、notifyAll()

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待

    • wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到notify 为止

    • wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify

    • 其实还有一个wait(long timeout, int nanos)方法,只是这个方法是一个无效方法:它的意思是可以把时间精确到纳秒,而实际上无论你在第二个参数填写什么值(大于0小于999999),他都只是将第一个参数的值加一

    • public final void wait(long timeout, int nanos) throws InterruptedException {
          if (timeout < 0) {
              throw new IllegalArgumentException("timeout value is negative");
          }
      
          if (nanos < 0 || nanos > 999999) {
              throw new IllegalArgumentException(
                  "nanosecond timeout value out of range");
          }
      
          if (nanos > 0) {
              timeout++;
          }
      
          wait(timeout);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27

      - `obj.notify()` 在 object 上正在 waitSet 等待的线程中挑一个唤醒

      - `obj.notifyAll()` 让 object 上正在 waitSet 等待的线程全部唤醒

      **它们都属于 Object 的一部分,而不属于 Thread**。

      **==只能用在同步方法或者同步控制块中使用==**,否则会在运行时抛出 `IllegalMonitorStateExeception`。也侧面说明了wait/notify只能用在重量级锁。

      **使用 wait() 挂起期间,线程会释放锁**。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

      ```java
      public class WaitNotifyExample {
      public synchronized void before() {
      System.out.println("before");
      notifyAll();
      }

      public synchronized void after() {
      try {
      wait();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println("after");
      }
      }
1
2
3
4
5
6
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
WaitNotifyExample example = new WaitNotifyExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after
1、wait() 和 sleep() 的区别
  • sleep 是 Thread 的静态方法,wait 是 Object 的方法,任何对象实例都能调用
  • **sleep 不会释放锁,它也不需要占用锁。wait 会释放锁,但调用它的前提是当前线程占有锁(即代码要在 synchronized 中)**。
  • sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
2、wait() 和 sleep() 的共同点
  • 它们都可以被 interrupted 方法中断
  • 它们状态 TIMED_WAITING
  • 在哪里睡着,就在哪里醒来

3、await()、signal()、signalAll()

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活

使用 Lock 来获取一个 Condition 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class AwaitSignalExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

public void before() {
lock.lock();
try {
System.out.println("before");
condition.signalAll();
} finally {
lock.unlock();
}
}

public void after() {
lock.lock();
try {
condition.await();
System.out.println("after");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
AwaitSignalExample example = new AwaitSignalExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after

4、interrupt

1、打断 sleep,wait,join 的线程

sleep,wait,join、这几个方法都会让线程进入阻塞状态(join底层就是wait,其实join与wait本质上是一样的)

可以使用interrupt方法来打断线程:

  • 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
  • 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
    • 对于Running的线程,也就是正常运行的线程被打断(interrupt)后,不会立刻中断它,而是将其的打断标记isInterrupted()设置为true,可以在正常运行的线程中通过这个打断标记来选择是否终止自身线程。
    • 也就是说:因为直接把线程终结了,人家线程事情都没干完。不如跟他说一声,说我要打断你,他处理完事情后自行了断不更好
2、多线程设计模式——两阶段终止

详细请看——并发的相关多线程设计模式

3、打断 park 线程

打断 park 线程,不会清空打断状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) {
test3();
}

private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}, "t1");
t1.start();

sleep(1);
t1.interrupt();
}

输出:

1
2
3
21:11:19.373 [t1] c.TestInterrupt - park... 
21:11:20.371 [t1] c.TestInterrupt - unpark...
21:11:20.371 [t1] c.TestInterrupt - 打断状态:true

如果打断标记已经是 true,则 park 会失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
test3();
}

private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
LockSupport.park();
log.debug("unpark...");
}, "t1");
t1.start();

sleep(1);
t1.interrupt();
}

输出:

1
2
3
4
21:11:54.003 [t1] c.TestInterrupt - park... 
21:11:55.002 [t1] c.TestInterrupt - unpark...
21:11:55.002 [t1] c.TestInterrupt - 打断状态:true
21:11:55.005 [t1] c.TestInterrupt - unpark...

提示:可以使用 Thread.interrupted() 清除打断状态

1
log.debug("打断状态:{}", Thread.currentThread().interrupted());

4、关键字:synchronized详解

在C程序代码中我们可以利用操作系统提供的互斥锁来实现同步块的互斥访问及线程的阻塞及唤醒等工作。在Java中除了提供Lock API外还在语法层面上提供了synchronized关键字来实现互斥同步原语。

1、BAT大厂的面试问题

  • Synchronized可以作用在哪里?分别通过对象锁和类锁进行举例。
  • Synchronized本质上是通过什么保证线程安全的?
    • 分三个方面回答:
      • 加锁和释放锁的原理
      • 可重入原理
      • 保证可见性原理
  • Synchronized有什么样的缺陷?Java Lock是怎么弥补这些缺陷的?
  • Synchronized和Lock的对比,和选择?
  • Synchronized在使用时有何注意事项?
  • Synchronized修饰的方法在抛出异常时,会释放锁吗?
  • 多个线程等待同一个snchronized锁的时候,JVM如何选择下一个获取锁的线程?
  • Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
  • 我想更加灵活地控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
  • 什么是锁的升级和降级?什么是JVM里的偏斜锁、轻量级锁、重量级锁?
  • 不同的JDK中对Synchronized有何优化?

2、Synchronized的使用

在应用Sychronized关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响
  • 例外:*锁对象是.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁**。
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会==释放锁==

1、对象锁

包括==方法锁==(默认锁对象为this当前实例对象)和==同步代码块锁==(自己指定锁对象)

1、代码块形式

手动指定锁定对象:

  • 可以是this:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instence = new SynchronizedObjectLock();

    @Override
    public void run() {
    // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
    synchronized (this) {
    System.out.println("我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println(Thread.currentThread().getName() + "结束");
    }
    }

    public static void main(String[] args) {
    Thread t1 = new Thread(instence);
    Thread t2 = new Thread(instence);
    t1.start();
    t2.start();
    }
    }
    1
    2
    3
    4
    我是线程Thread-0
    Thread-0结束
    我是线程Thread-1
    Thread-1结束
  • 也可以是自定义的锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instence = new SynchronizedObjectLock();
    // 创建2把锁
    Object block1 = new Object();
    Object block2 = new Object();

    @Override
    public void run() {
    // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
    synchronized (block1) {
    System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
    }

    synchronized (block2) {
    System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
    }
    }

    public static void main(String[] args) {
    Thread t1 = new Thread(instence);
    Thread t2 = new Thread(instence);
    t1.start();
    t2.start();
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    block1锁,我是线程Thread-0
    block1锁,Thread-0结束
    block2锁,我是线程Thread-0  // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
    block1锁,我是线程Thread-1
    block2锁,Thread-0结束
    block1锁,Thread-1结束
    block2锁,我是线程Thread-1
    block2锁,Thread-1结束
2、方法锁形式:synchronized修饰普通方法,锁对象默认为this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

2、类锁

指synchronize修饰静态的方法或指定锁对象为Class对象。

1、synchronize修饰静态方法

synchronize修饰普通方法与修饰静态方法的区别:

  • synchronized用在普通方法上,默认的锁就是this,当前实例
  • synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把

修饰普通方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

// synchronized用在普通方法上,默认的锁就是this,当前实例
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
// t1和t2对应的this是两个不同的实例,所以代码不会串行
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
我是线程Thread-1
Thread-1结束
Thread-0结束

修饰静态方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

// synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
public static synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
2、synchronized指定锁对象为Class对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
// 所有线程需要的锁都是同一把
synchronized(SynchronizedObjectLock.class){
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}

public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

3、关于synchronized锁的总结

对于Synchronized实现同步的基础:java中每一个对象都可以作为锁。

具体可以分为以下三种情况:

  • 对于普通同步方法,锁是当前实例对象;(对象锁)
  • 对于静态同步方法,锁是当前类的Class 对象;(类锁)
  • 对于同步方法块,锁是Synchonized 括号里配置的对象

对于对象锁:

  • 如果一个实例对象非静态同步方法获取锁后,该实例对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁;
  • 别的实例对象的非静态同步方法因为跟该实例对象的非静态同步方法用的是不同的锁, 所以无须等待该实例对象已获取锁的非静态同步方法释放锁就可以获取他们自己的锁;
  • 每一个对象都有属于自己的对象锁(可以有多把对象锁)

对于类锁:

  • 所有的静态同步方法用的也是同一把锁——类对象本身(类锁),这与对象锁是两个不同的对象,所以静态同步方法与非静态同步方法之间是不会有竞态条件的;
  • 一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁;
  • 但不管是同一个实例对象的静态同步方法之间,还是不同的实例对象的静态同步方法之间,只要它们同一个类的实例对象
  • 类锁只有一把

对于同步代码块:

  • 同步代码块的锁是Synchonized 括号里配置的对象;
  • 如果Synchonized 括号里是对象,那么他就是对象锁;如果Synchonized 括号里是类,那么他就是类锁;
  • 所以他可以有一把,也可以有多把(主要看如果Synchonized 括号里是类还是对象)

举个例子:把synchronized的锁看成一座大楼

  • 类锁就是锁住大楼的锁
  • 对象锁就是锁住大楼里面房间的锁,每一个房间都有属于它的一把锁

3、Synchronized的原理分析

1、加锁和释放锁的原理

现象、时机(内置锁this)、深入JVM看字节码(反编译看monitor指令)

  1. 深入JVM看字节码,创建如下的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    public class SynchronizedDemo2{
    static final Object lock = new Object(); static int counter = 0;
    public static void main(String[] args) {
    synchronized (lock) {
    counter++;
    }
    }
    }
  2. 使用javac命令进行编译生成.class文件

    1
    >javac SynchronizedDemo2.java
  3. 使用javap命令反编译查看.class文件的信息

    1
    >javap -verbose SynchronizedDemo2.class
  4. 得到如下的信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    Code:
    stack=2,locals=3,args_size=1
    0: getstatic #2 // <- lock引用( synchronized开始)
    3: dup
    4: astore_1 // lock引用 -> slot 1
    5: monitorenter // 将lock对象 MarkWord 置为 Monitor 指针
    6: getstatic #3 // <- i
    9: iconst_1 // 准备常数1
    10: iadd // +1
    11: putstatic #3 // -> i
    14: aload_1 // <- lock引用
    15: monitorexit // 将lock对象 MarkWord 重置,唤醒EntryList
    16: goto 24
    19: astore_2 // e -> slot2
    20: aload_1 // <- lock引用
    21: monitorexit // 将 lock 对象 MarkWord 重置,唤醒EntryList
    22: aload_2 // <- slot 2 (e)
    23: athrow // throw e
    24: return
    Exception table:
    from to target type
    6 16 19 any
    19 22 19 any
    LineNumberTable:
    line 8: 0
    line 9: 6
    line 10: 14
    line 11: 24
    LocalVar iableTable:
    Start Length Slot Name Signature
    0 25 0 args [Ljava/lang/String;
    StackMapTable: number_of_entries = 2
    frame_type = 255 /* full_ _frame */
    offset_delta = 19
    locals = [ class "[Ljava/lang/String;", class java/lang/object ]
    stack = [ class java/lang/Throwable ]
    frame_type = 250 /* chop */
    offset_delta = 4

注意:

  • 方法级别的 synchronized 不会在字节码指令中有所体现
  • 在字节码中的16: goto 24当中,执行到这里会跳转到第24行的字节码执行24:return返回
  • 那么第19行到第23行的字节码的作用是什么?
    • 仔细阅读字节码的内容会发现:他们的作用是当同步代码块中的内容出现异常的时候,为了防止当前的锁得不到释放而造成死锁,在第19到第23行进行异常的抛出锁的释放

关注字节码当中的monitorentermonitorexit即可。

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

img

该图可以看出,任意线程对Object的访问,首先要获得Object的监视器monitor,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

2、可重入原理:加锁次数计数器

上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁

Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

3、保证可见性的原理:内存模型和happens-before规则

Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。继续来看代码:

1
2
3
4
5
6
7
8
9
10
11
public class MonitorDemo {
private int a = 0;

public synchronized void writer() { // 1
a++; // 2
} // 3

public synchronized void reader() { // 4
int i = a; // 5
} // 6
}

该代码的happens-before关系如图所示:

img

在图中每一个箭头连接的两个节点就代表之间的happens-before关系:

  • 黑色的是通过程序顺序规则推导出来,
  • 红色的为监视器锁规则推导而出:
    • 线程A释放锁happens-before线程B加锁;
  • 蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。
    • 现在我们来重点关注:2 happens-before 5,通过这个关系我们可以得出什么?
      • 根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。

4、synchronized 是给对象加锁的原理——对象的对象头

synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:
    • Mark Word(标记字段)
    • Klass Pointer(类型指针)

以 32 位虚拟机为例:

普通对象:

1
2
3
4
5
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|

数组对象:

1
2
3
4
5
|---------------------------------------------------------------------------------| 
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|

其中 Mark Word 结构为:

1
2
3
4
5
6
7
8
9
10
11
12
13
|-------------------------------------------------------|--------------------| 
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|

64 位虚拟机 Mark Word:

1
2
3
|--------------------------------------------------------------------|--------------------| |                        Mark Word (64 bits)                         |       State        | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal       | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |       Biased       | |--------------------------------------------------------------------|--------------------| |             ptr_to_lock_record:62                          | 00    | Lightweight Locked | |--------------------------------------------------------------------|--------------------| |             ptr_to_heavyweight_monitor:62                  | 10    | Heavyweight Locked | |--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

Monitor 被翻译为监视器管程

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针,如下图所示,右侧就是对象对应的 Monitor 对象。

图片

当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。

另外 Monitor 中还有两个队列分别是EntryListWaitList,主要是用来存放进入及等待获取锁的线程

如果线程进入,则得到当前对象锁,那么别的线程在该类所有对象上的任何操作都不能进行。

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor 结构如下

image-20210804223435390

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程
  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争

注意:

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

在对象级使用锁通常是一种比较粗糙的方法,为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?

如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。

由于每个对象都有锁,可以如下所示使用虚拟对象来上锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FineGrainLock{
MyMemberClass x,y;
Object xlock = new Object(), ylock = newObject();
public void foo(){
synchronized(xlock){
//accessxhere
}
//dosomethinghere-butdon'tusesharedresources
synchronized(ylock){
//accessyhere
}
}
public void bar(){
synchronized(this){
//accessbothxandyhere
}
//dosomethinghere-butdon'tusesharedresources
}
}

4、JVM中锁的优化

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)下,如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁
  • 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(栈上分配)(同时还可以减少Heap上的垃圾收集开销)。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋(Adaptive Spinning)当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态

1、锁的类型

在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁偏向锁轻量级所重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

2、自旋锁与自适应自旋锁

1、自旋锁

引入背景:

大家都知道,在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但==不放弃CPU的执行时间==。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。

自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。

自旋重试成功的情况:

线程1 (core1上) 对象Mark 线程2 ( core2上)
- 10(重量锁) -
访问同步块,获取monitor 10 (重量锁)重量锁指针 -
成功(加锁) 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 访问同步块,获取monitor
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行完毕 10 (重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10 (重量锁)重量锁指针 成功(加锁)
- 10 (重量锁)重量锁指针 执行同步块
- …… ……

自旋重试失败的情况:

线程1 (core1上) 对象Mark 线程2 ( core2上)
- 10(重量锁) -
访问同步块,获取monitor 10 (重量锁)重量锁指针 -
成功(加锁) 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 访问同步块,获取monitor
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 阻塞
- …… ……

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

2、自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100次循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。

总结:

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

3、锁消除

锁消除时指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。

当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。

比如如下操作:在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象(线程安全)的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象(线程不安全)的连续append()操作。

1
2
3
4
public static String test03(String s1, String s2, String s3) {
String s = s1 + s2 + s3;
return s;
}

对上述代码使用javap 编译的结果:

img

众所周知,StringBuilder不是安全同步的,但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

4、锁粗化

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。

这里贴上根据上述Javap 编译地情况编写的实例java类

1
2
3
4
5
6
7
public static String test04(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

在上述地连续append()操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的外部,使整个一连串地append()操作只需要加锁一次就可以了。

5、轻量级锁

在JDK 1.6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能

如果要理解轻量级锁,那么必须先要了解HotSpot虚拟机中对象头的内存布局。在对象头中(Object Header)存在两部分:(对象头的大小:(压缩指针)12字节,(不支持压缩指针)16字节)

  1. 第一部分用于存储对象自身的运行时数据HashCodeGC Age锁标记位是否为偏向锁。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word它是实现轻量级锁和偏向锁的关键
  2. 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度
轻量级锁加锁

在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward)这个时候线程堆栈与对象头的状态如图:

img

image-20210804232649899

如上图所示:如果当前对象没有被锁定,那么锁标志位为01状态,JVM在执行当前线程时,首先会在当前线程栈帧中创建锁记录Lock Record的空间用于存储锁对象目前的Mark Word的拷贝。

image-20210804233935482

然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word更新为指向Lock Record的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word中最后的2bit)00,即表示此对象处于轻量级锁定状态,如图:

img

image-20210804233158129

如果这个更新操作失败:

  • JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。(可重入锁)
    • image-20210804233645213
  • 如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那**轻量级锁就不再有效,直接膨胀为重量级锁(锁膨胀)**,没有获得锁的线程会被阻塞。此时,锁的标志位为10Mark Word中存储的时指向重量级锁的指针。

轻量级解锁时:

  • 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
    • image-20210804234421675
  • 锁记录的值不为 null,这时会使用原子的CAS操作将Displaced Mark Word替换回到对象头中:
    • 如果成功,则表示没有发生竞争关系,解锁成功
    • 如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁,进入重量级锁的解锁流程

6、锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

1
2
3
4
5
6
static Object obj = new Object(); 
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    • image-20210805013827060
  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
    • 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
    • 然后自己进入 Monitor 的 EntryList BLOCKED
    • image-20210805013917321
    • 此时Object的对象头的锁标志为10
  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

两个线程同时争夺锁,导致锁膨胀的流程图如下:

img

7、偏向锁

引入背景:

在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换

为了解决这一问题,HotSpot的作者在Java SE 1.6 中对Synchronized进行了优化,引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁。只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁

1、偏向状态

64 位虚拟机 Mark Word:

1
2
3
|--------------------------------------------------------------------|--------------------| |                        Mark Word (64 bits)                         |       State        | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal       | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |       Biased       | |--------------------------------------------------------------------|--------------------| |             ptr_to_lock_record:62                          | 00    | Lightweight Locked | |--------------------------------------------------------------------|--------------------| |             ptr_to_heavyweight_monitor:62                  | 10    | Heavyweight Locked | |--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
    • 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中(54位的threadID)
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
  • 如果你想禁用偏向锁,添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

img

2、偏向锁的撤销
1、方法一:调用对象的hashCode方法(对象仍可偏向)
  • 如果默认开启了偏向锁,但当调用了对象的hashCode方法则会破坏对象的偏向锁
    • 正常状态对象一开始是没有 hashCode 的,第一次调用才生成
    • 调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
      • 轻量级锁会在锁记录中记录 hashCode
      • 重量级锁会在 Monitor 中记录 hashCode
      • 偏向锁没有其它记录hashCode的方法,所以调用对象的hashCode会撤销对象的偏向锁
    • 在调用 hashCode 后使用偏向锁,记得去掉 -XX:-UseBiasedLocking(禁用偏向锁)
2、方法二:当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁(对象变为不可偏向)

演示代码:(加上了VM参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Slf4j(topic = "c.TestBiased")
public class TestBiased {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();

new Thread(() -> {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
},"t1").start();

new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
},"t2").start();
}
}

class Dog {

}

输出:

1
2
3
4
5
6
20:48:31.674 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
20:48:31.677 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.677 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.677 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.678 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00100000 01001011 11110011 00100000
20:48:31.678 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001

分析:

  • 由于t2线程使用了wait,所以t2需要t1线程的notify唤醒,所以t1线程肯定由于t2线程,得到偏向锁。然后唤醒t2线程后,t2线程去争夺锁,导致了t1线程的偏向锁的破坏,并且t1线程变为不可偏向。
  • 第一行:由于禁用延迟,所以t1线程一开始就处于101的偏向锁,只是此时t1线程还没得到锁,所以它的 thread、epoch、age 都为 0
  • 第二行:t1线程拿到了锁,Mark Word记录了当前线程的ThreadID(54位)、epoch(2位)、unused(1位)和age(4位)
  • 第三行:t1线程释放了锁,由于t1线程为偏向锁,所以Mark Word依旧记录了t1线程的ThreadID(54位)
  • 递四行:t1线程唤醒了t2线程,当此时t2x线程还没有抢夺t1线程的偏向锁,所以Mark Word没变
  • 第五行:t2线程抢夺t1的偏向锁,破坏了t1线程的偏向锁,偏向锁膨胀为轻量级锁(Mark Word后三位为000
    • 此时Mark Word记录的是ptr_to_lock_record:62
  • 第六行:t2线程释放锁,Mark Word后三位为001

底层:

偏向锁使用了一种==等待竞争出现才会释放锁==的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放偏向锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁

img

3、调用 wait/notify

因为 wait/notify(等待唤醒)模式是应用在重量级锁上的,所以调用 wait/notify就意味着此时是重量级锁,而不是偏向锁与轻量级锁。

3、批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private static void test3() throws InterruptedException {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
synchronized (list) {
list.notify();
}
},"t1");
t1.start();

Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
log.debug("===============> ");
for (int i = 0; i < 30; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
},"t2");
t2.start();
}

class Dog {

}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
[t1] - 0 		00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 1 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 2 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 3 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 4 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 5 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 6 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 7 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 8 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 9 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 10 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 11 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 12 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 13 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 14 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 15 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 16 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 17 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 18 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - ===============>
[t2] - 0 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 0 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 0 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 1 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 1 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 2 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 2 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 2 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 3 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 3 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 3 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 4 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 4 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 4 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 5 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 5 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 5 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 6 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 6 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 6 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 7 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 7 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 7 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 8 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 8 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 8 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 9 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 9 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 9 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 10 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 10 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 10 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 11 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 11 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 11 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 12 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 12 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 12 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 13 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 13 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 13 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 14 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 14 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 14 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 15 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 15 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 15 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 16 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 16 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 16 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 17 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 17 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 17 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 18 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 18 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 18 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101

注意:

1
2
3
[t2] - 19		00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101

在第20次(从0开始,到19)后,批量重偏向

4、批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

import java.util.Vector;
import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.TestBiased")
public class TestBiased {

static Thread t1,t2,t3;

public static void main(String[] args) throws InterruptedException {
test4();
}
private static void test4() throws InterruptedException {
Vector<Dog> list = new Vector<>();

int loopNumber = 39;
t1 = new Thread(() -> {
for (int i = 0; i < loopNumber; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
LockSupport.unpark(t2);
}, "t1");
t1.start();

t2 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
LockSupport.unpark(t3);
}, "t2");
t2.start();

t3 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}, "t3");
t3.start();

t3.join();
log.debug(ClassLayout.parseInstance(new Dog()).toPrintableSimple(true));
}
}

class Dog {

}

输出:

  • t1线程前面的39个对象全部拥有了偏向锁
    • image-20210805043937707
  • t2线程前19次因为破坏了t1线程对象的偏向锁,升级为轻量级锁
    • image-20210805044214898
  • t2线程从第20次后进入批量重偏向,从第20次到第39次全部都是批量重偏向,t2线程拥有偏向锁
    • image-20210805044307591
  • t3线程的前19个对象为轻量级锁(t2修改为轻量级锁)
    • image-20210805044616494
  • t3线程从第20个对象开始,此时对象的偏向锁是偏向t2线程的,所以t3线程会破坏t2线程的偏向锁,升级为轻量级锁,从第20个到第39个都是这样。
    • image-20210805044906263
  • 由于JVM进行了前39次的偏向锁撤销,在进行第40次撤销操作时,JVM会将整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
    • image-20210805045205698
  • 如果把loopNumber的值修改为38,即只进行38次偏向锁撤销,那么在第39次偏向锁撤销,JVM依旧会采用偏向锁升级为轻量级锁,此时的对象依旧是可偏向的(101
    • image-20210805045456551

8、锁的优缺点对比

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步快的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步快执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步快执行速度较长

5、Synchronized与Lock

1、synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时;
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,….,如果获取失败,…..
  • 如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
    • 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
    • 线程执行发生异常,此时 JVM 会让线程自动释放锁。
  • 那么如果这个获取锁的线程由于要等待 I/O 或者其他原因(比如调用 sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。

2、Lock解决相应问题

Lock类这里不做过多解释,主要看里面的4个方法:

  • lock():加锁
  • unlock():解锁
  • tryLock():尝试获取锁,返回一个boolean值
  • tryLock(long,TimeUtil):尝试获取锁,可以设置超时

Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。

Lock可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断)。

ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了

注:

  • ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大
  • JUC中的JUC锁:ReentrantLock

3、总结:Lock 与的 Synchronized 区别

  • Lock 不是 Java 语言内置的,synchronized 是 Java 语言的关键字,因此是内置特性。Lock 是一个类,通过这个类可以实现同步访问;
  • synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;
  • Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用synchronized 时,等待的线程会一直等待下去,不能够响应中断;
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到;
  • Lock 可以提高多个线程进行读操作的效率。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时 Lock 的性能要远远优于synchronized

6、再深入理解

synchronized是通过软件(JVM)实现的,简单易用,即使在JDK5之后有了Lock,仍然被广泛地使用。

  • 使用Synchronized有哪些要注意的?
    • 锁对象不能为空,因为锁的信息都保存在对象头里
    • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
    • 避免死锁
    • 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错
  • synchronized是公平锁吗?
    • synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待
    • 不过这种抢占的方式可以预防饥饿
  • 使用Synchronized可以解决可见性问题吗?
    • 可以在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存→在主内存中拷贝最新变量的副本到工作内存 →执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。

5、关键字:volatile详解

相比Sychronized(重量级锁,对系统性能影响较大),volatile提供了另一种解决可见性和有序性问题的方案。

1、BAT大厂的面试问题

  • volatile关键字的作用是什么?
  • volatile能保证原子性吗?
  • 之前32位机器上共享的long和double变量的为什么要用volatile?现在64位机器上是否也要设置呢?
  • i++为什么不能保证原子性?
  • volatile是如何实现可见性的?
    • 内存屏障
  • volatile是如何实现有序性的?
    • happens-before等
  • 说下volatile的应用场景?

2、volatile的作用详解

1、防重排序

我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
// 向外界通过一个getInstance()方法
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

  • 分配内存空间。
  • 初始化对象。
  • 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • 分配内存空间。
  • 将内存空间的地址赋值给对应的引用。
  • 初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

注意:加上volatile的变量会保证在它之前的指令不会被重排序。原因:在加上volatile的变量的地方会加上一个内存屏障,保证在它之前的指令不会重排序到它下面去。

2、实现可见性

可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题,我们看下面的例子,就可以知道其作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class VolatileTest {
int a = 1;
int b = 2;

public void change(){
a = 3;
b = a;
}

public void print(){
System.out.println("b=" + b + ";a=" + a);
}

public static void main(String[] args) {
while (true){
final VolatileTest test = new VolatileTest();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}

直观上说,这段代码的结果只可能有两种:

  1. b=3;a=3
  2. b=2;a=1

不过运行上面的代码(可能时间上要长一点,概率要小很多),你会发现除了上两种结果之外,还出现了第三种结果:

  • b=3;a=1

分析:为什么会出现b=3;a=1这种结果呢?

  • 正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。
  • 相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。
  • 那b=3;a=1的结果是怎么出来的?
    • 原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。
    • 如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。

3、保证原子性:单次读/写

volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性

先从如下两个问题来理解(后文再从内存屏障的角度理解):

  1. 问题1: i++为什么不能保证原子性?
  2. 问题2: 共享的long和double变量的为什么要用volatile?
1、问题1: i++为什么不能保证原子性?

对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。

现在我们就通过下列程序来演示一下这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class VolatileTest01 {
volatile int i;

public void addI(){
i++;
}

public static void main(String[] args) throws InterruptedException {
final VolatileTest01 test01 = new VolatileTest01();
for (int n = 0; n < 1000; n++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test01.addI();
}
}).start();
}
Thread.sleep(10000);//等待10秒,保证上面程序执行完成
System.out.println(test01.i);
}
}

大家可能会误认为对变量i加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:981 可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤:

  • 读取i的值。
  • 对i加1。
  • 将i的值写回内存。

i++的相关字节码指令:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
iadd //自增
putstatic i // 将修改后的值存入静态变量i

对于i–也是类似:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
isub //自减
putstatic i // 将修改后的值存入静态变量i

volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。

注:上面几段代码中多处执行了Thread.sleep()方法,目的是为了增加并发问题的产生几率,无其他作用。

2、问题2: 共享的long和double变量的为什么要用volatile?

因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。

如下是JLS中的解释:

17.7 Non-Atomic Treatment of double and long

  • For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
  • Writes and reads of volatile long and double values are always atomic.
  • Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
  • Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
  • Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.

目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。

3、volatile的实现原理

1、volatile 可见性实现

volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现

  • 内存屏障,又称内存栅栏,是一个 CPU 指令。
  • 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入==特定类型的内存屏障来禁止==+ ==特定类型的编译器重排序和处理器重排序==**,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序**。
  • 对 volatile 变量的写指令后会加入写屏障
    • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 对 volatile 变量的读指令前会加入读屏障
    • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

int num = 0;
volatile boolean ready = false;

public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready){
r1 = num + num;
} else {
r.r1 = 1;
}
}

public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
}

image-20210806223239737

写一段简单的 Java 代码,声明一个 volatile 变量,并赋值。

1
2
3
4
5
6
7
8
9
10
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}

通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......

lock 前缀的指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值

volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值

1、lock 指令

在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证

2、缓存一致性

缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节

LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议

缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效

2、volatile 有序性实现

1、volatile 的 happens-before 关系

happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; // 1 线程A修改共享变量
flag = true; // 2 线程A写volatile变量
}

public void reader() {
if (flag) { // 3 线程B读同一个volatile变量
int i = a; // 4 线程B读共享变量
……
}
}
}

根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。

  • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
  • 根据 volatile 规则:2 happens-before 3。
  • 根据 happens-before 的传递性规则:1 happens-before 4。

img

因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。

2、volatile 禁止重排序

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序

Java 编译器会在==生成指令系列==时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序

JMM 会针对编译器制定 volatile 重排序规则表:

img

“ NO “ 表示禁止重排序。

为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
内存屏障 说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。

img

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

int num = 0;
volatile boolean ready = false;

public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready){
r1 = num + num;
} else {
r.r1 = 1;
}
}

public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
}

image-20210806223239737

还是那句话,不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

image-20210806224057799

3、double-checked locking 问题

以著名的 double-checked locking 单例模式为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有
synchronized synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0: getstatic    	#2		// Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

其中:

  • 17 表示创建对象,将对象引用入栈 // new Singleton
  • 20 表示复制一份对象引用 // 引用地址
  • 21 表示利用一个对象引用,调用构造方法
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

image-20210806224747694

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

可能有人注意到:在synchronized内部的变量不是可以保证原子性、有序性和可见性吗?为什么21与24还会被重排序?

  • 被synchronized完全接管的变量确实可以保证原子性、有序性和可见性,但是必须是被synchronized完全接管的变量;
  • 在代码上INSTANCE并没有被synchronized完全接管,线程在synchronized内部使用INSTANCE的时候,在synchronized外部还是可能有其它线程接触INSTANCE
  • 所以在synchronized内部,INSTANCE还是有可能被重排序(24与21重排序)

解决方法:对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

4、double-checked locking 解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
private volatile static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有
synchronized synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

字节码上看不出来 volatile 指令的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 // -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter // -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit // ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  1. 可见性
    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  2. 有序性
    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  3. 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

image-20210806225125974

4、volatile的应用场景

使用 volatile 必须具备的条件

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中
  • 只有在状态真正独立于程序内其他内容时才能使用 volatile

1、模式1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机

1
2
3
4
5
6
7
8
volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}

2、模式2:一次性安全发布(one-time safe publication)

缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;

public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}

public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}

3、模式3:独立观察(independent observation)

安全使用 volatile 的另一种简单模式是定期发布观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserManager {
public volatile String lastUser;

public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}

4、模式4:volatile bean 模式

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@ThreadSafe
public class Person {
private volatile String firstName;
private volatile String lastName;
private volatile int age;

public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }

public void setFirstName(String firstName) {
this.firstName = firstName;
}

public void setLastName(String lastName) {
this.lastName = lastName;
}

public void setAge(int age) {
this.age = age;
}
}

5、模式5:开销较低的读-写锁策略

volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销

1
2
3
4
5
6
7
8
9
10
11
12
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;

public int getValue() { return value; }

public synchronized int increment() {
return value++;
}
}

6、模式6:双重检查(double-checked)

单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。


6、关键字:final详解

1、BAT大厂的面试问题

  • 所有的final修饰的字段都是编译期常量吗?
  • 如何理解private所修饰的方法是隐式的final?
  • 说说final类型的类如何拓展?比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
  • final方法可以被重载吗?
    • 可以
  • 父类的final方法能不能够被子类重写?
    • 不可以
  • 说说final域重排序规则?
  • 说说final的原理?
  • 使用 final 的限制条件和局限性?
  • 看本文最后的一个思考题

2、final基础使用

1、修饰类

当某个类的整体定义为final时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的

注意:final类中的所有方法都隐式为final,因为无法覆盖他们,所以在final类中给任何方法添加final关键字是没有任何意义的

那么final类型的类如何拓展?

  • 比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?

设计模式中最重要的两种关系,一种是继承/实现;另外一种是组合关系。所以当遇到不能用继承的(final修饰的类),应该考虑用组合,如下代码大概写个组合实现的意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @pdai
*/
class MyString{

private String innerString;

// ...init & other methods

// 支持老的方法
public int length(){
return innerString.length(); // 通过innerString调用老的方法
}

// 添加新方法
public String toMyString(){
//...
}
}

2、修饰方法

  • private 方法是隐式的final

  • final方法是可以被重载的

1、private final

类中所有private方法都隐式地指定为final的,由于无法取用private方法,所以也就不能覆盖它。可以对private方法增添final关键字,但这样做并没有什么好处

看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Base {
private void test() {
}
}

public class Son extends Base{
public void test() {
}
public static void main(String[] args) {
Son son = new Son();
Base father = son;
//father.test();
}
}

Base和Son都有方法test(),但是这并不是一种覆盖,因为private所修饰的方法是隐式的final,也就是无法被继承,所以更不用说是覆盖了,在Son中的test()方法不过是属于Son的新成员罢了,Son进行向上转型得到father,但是father.test()是不可执行的,因为Base中的test方法是private的,无法被访问到。

2、final方法是可以被重载的

我们知道父类的final方法是不能够被子类重写的,那么final方法可以被重载吗?

答案是可以的,下面代码是正确的。

1
2
3
4
5
6
7
public class FinalExampleParent {
public final void test() {
}

public final void test(String str) {
}
}

3、修饰参数

Java允许在参数列表中以声明的方式将参数指明为final,这意味这你无法在方法中更改参数引用所指向的对象

这个特性主要用来向匿名内部类传递数据

4、修饰变量

1、所有final修饰的字段都是编译器常量吗?

现在来看编译期常量和非编译期常量,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test {
//编译期常量
final int i = 1;
final static int J = 1;
final int[] a = {1,2,3,4};
//非编译期常量
Random r = new Random();
final int k = r.nextInt();

public static void main(String[] args) {

}
}

k的值由随机数对象决定,所以不是所有的final修饰的字段都是编译期常量,只是k的值在被初始化后无法被更改。

2、static final

一个既是static又是final 的字段只占据一段不能改变的存储空间,它必须在定义的时候进行赋值,否则编译器将不予通过

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.Random;
public class Test {
static Random r = new Random();
final int k = r.nextInt(10);
static final int k2 = r.nextInt(10);
public static void main(String[] args) {
Test t1 = new Test();
System.out.println("k="+t1.k+" k2="+t1.k2);
Test t2 = new Test();
System.out.println("k="+t2.k+" k2="+t2.k2);
}
}

上面代码某次输出结果:

1
2
k=2 k2=7
k=8 k2=7

我们可以发现对于不同的对象k的值是不同的,但是k2的值却是相同的,这是为什么呢?

  • 因为static关键字所修饰的字段并不属于一个对象,而是属于这个类的。
  • 也可简单的理解为static final所修饰的字段仅占据内存的一个一份空间,一旦被初始化之后便不会被更改
3、blank final

Java允许生成空白final,也就是说被声明为final但又没有给出定值的字段,但是必须在该字段被使用之前被赋值,这给予我们两种选择:

  • 在定义处进行赋值(这不叫空白final)
  • 在构造器中进行赋值,保证了该值在被使用前赋值。

这增强了final的灵活性。

看下面代码:

1
2
3
4
5
6
7
8
9
10
public class Test {
final int i1 = 1;
final int i2;//空白final
public Test() {
i2 = 1;
}
public Test(int x) {
this.i2 = x;
}
}

可以看到i2的赋值更为灵活。

但是请注意,如果字段由static和final修饰,仅能在定义处赋值,因为该字段不属于对象,属于这个类。

3、final域重排序规则

上面final的使用,应该属于Java基础层面的,当理解这些后我们就真的算是掌握了final吗? 有考虑过final在多线程并发的情况吗?

在java内存模型中我们知道java内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说java内存模型就是弱内存数据模型。同时,处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。

那么,在多线程情况下,final会进行怎样的重排序? 会导致线程安全的问题吗?

下面,就来看看final的重排序。

1、final域为基本类型

先看一段示例性的代码:(假设线程A在执行writer()方法,线程B执行reader()方法。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo;

public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}

public static void writer() {
finalDemo = new FinalDemo();
}

public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
1、写final域重排序规则

写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外;
  • 编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。

我们再来分析writer方法,虽然只有一行代码,但实际上做了两件事情:

  • 构造了一个FinalDemo对象;
  • 把这个对象赋值给成员变量finalDemo。

我们来画下存在的一种可能执行时序图,如下:

img

由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。

因此,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo

2、读final域重排序规则

读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。

read()方法主要包含了三个操作:

  • 初次读引用变量finalDemo;
  • 初次读引用变量finalDemo的普通域a;
  • 初次读引用变量finalDemo的final域b;

假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:

img

读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用

2、final域为引用类型

1、对final域修饰的对象的成员域写操作

针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的

注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;

public FinalReferenceDemo() {
arrays = new int[1]; //1
arrays[0] = 1; //2
}

public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //3
}

public void writerTwo() {
arrays[0] = 2; //4
}

public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}

针对上面的实例程序,线程线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种情况来讨论。

img

由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序

2、对final域修饰的对象的成员域读操作

JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。

3、关于final重排序的总结

按照final修饰的数据类型分类:

  • 基本数据类型:
    • final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
    • final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
  • 引用数据类型:
    • 额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序

4、final再深入理解

1、final的实现原理

上面我们提到过,写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障。

很有意思的是,如果以X86处理为例,X86不会对写-写重排序,所以StoreStore屏障可以省略。由于不会对有间接依赖性的操作重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说,以X86为例的话,对final域的读/写的内存屏障都会被省略!具体是否插入还是得看是什么处理器

1、设置 final 变量的原理:
1
2
3
public class TestFinal {
final int a = 20;
}

字节码:

1
2
3
4
5
6
7
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0 20
5: bipush #2 // Field a:I
7: putfield
<-- 写屏障
10: return

发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为 0 的情况。

2、获取 final 变量的原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class TestFinal {
static int A = 10;
final static int B = Short.MAX_VALUE+1;

final int a = 20;
final int b = Integer.MAX_VALUE;

final void test1() {
final int c = 30;
new Thread(()->{
System.out.println(c);
}).start();

final int d = 30;
class Task implements Runnable {

@Override
public void run() {
System.out.println(d);
}
}
new Thread(new Task()).start();
}

}

class UseFinal1 {
public void test() {
System.out.println(TestFinal.A);
System.out.println(TestFinal.B);
System.out.println(new TestFinal().a);
System.out.println(new TestFinal().b);
new TestFinal().test1();
}
}

class UseFinal2 {
public void test() {
System.out.println(TestFinal.A);
}
}

结果:

1

分析:

  • 如果final static int A = 10;加入final的话:那么在字节码的层面可以看到:BIPUSH 10,即在读取A的时候,它是从栈中直接复制了一个10给到了A,走的不是共享这条路(没有从其他类中读取数据)
    • image-20210810231107766
  • 如果static int A = 10;没有加final的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.A : I,即在读取A的时候,它是从TestFinal类中获取的到的10,走的是共享这条路(从其他类中读取数据),那么A就是==共享内存==,性能比==栈内存==要低。
    • image-20210810231345476
  • 对于final static int B = Short.MAX_VALUE+1;来说,B是Short的最大值在加上1(超过了极限)。加入final的话:那么在字节码的层面可以看到:LDC 32768(Short的最大值是32767),即读取的是常量池当中的内容,同理也没有走共享内存这条路(没有从其他类中读取数据)
    • image-20210810232209213
  • 如果B没有加final的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.B : I,即在读取B的时候,它是从TestFinal类中获取的,走的是共享这条路(从其他类中读取数据)
    • image-20210810232531747
  • 下面的成员变量ab也是同样的道理:加入final修饰的,在引用到a/b的时候,会复制一份到调用方的常量池当中,直接从栈内存获取就行(==栈内存==),效率高。没有加final修饰的,在引用到a/b的时候,会直接到类中获取(==共享内存==),效率比较低。
  • 总结:一个final修饰的基本变量可以完全等价于一个常量,整个jvm实例生命周期内都不会变化了,这个值在编译时就已经写死成直接引用了

2、为什么final引用不能从构造函数中”溢出”

这里还有一个比较有意思的问题:上面对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了。

但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“溢出”。以下面的例子来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FinalReferenceEscapeDemo {
private final int a;
private FinalReferenceEscapeDemo referenceDemo;

public FinalReferenceEscapeDemo() {
a = 1; //1
referenceDemo = this; //2
}

public void writer() {
new FinalReferenceEscapeDemo();
}

public void reader() {
if (referenceDemo != null) { //3
int temp = referenceDemo.a; //4
}
}
}

可能的执行时序如图所示:

img

假设一个线程A执行writer方法另一个线程执行reader方法。因为构造函数中操作1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时候引用对象referenceDemo是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化成功。但是,引用对象“this”逸出,该代码依然存在线程安全的问题。

3、使用final的限制条件和局限性

  • 当声明一个 final 成员时,必须在构造函数退出前设置它的值。

    1
    2
    3
    4
    5
    6
    public class MyClass {
    private final int myField = 1;
    public MyClass() {
    ...
    }
    }
    • 或者
    1
    2
    3
    4
    5
    6
    7
    8
    public class MyClass {
    private final int myField;
    public MyClass() {
    ...
    myField = 1;
    ...
    }
    }
  • 将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。

    • 下面的方法仍然可以修改该 list。

      1
      2
      private final List myList = new ArrayList();
      myList.add("Hello");
    • 声明为 final 可以保证如下操作不合法

      1
      2
      myList = new ArrayList();
      myList = someOtherList;
  • 如果一个对象将会在多个线程中访问并且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。

    • “ 其他方式 “ 可以包括声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。

4、再思考一个有趣的现象

1
2
3
byte b1=1;
byte b2=3;
byte b3=b1+b2;//当程序执行到这一行的时候会出错,因为b1、b2可以自动转换成int类型的变量,运算时java虚拟机对它进行了转换,结果导致把一个int赋值给byte-----出错

如果对b1 b2加上final就不会出错:

1
2
3
final byte b1=1;
final byte b2=3;
byte b3=b1+b2;//不会出错,相信你看了上面的解释就知道原因了。

7、JUC(java.util.concurrent)

0、JUC - 类汇总和学习总览

1、BAT大厂的面试问题

  • JUC框架包含几个部分?
  • 每个部分有哪些核心的类?
  • 最最核心的类有哪些?

2、Overview

JUC相关的五大类与框架:

image

主要包含: (注意: 上图是网上找的图,无法表述一些继承关系,同时少了部分类;但是主体上可以看出其分类关系也够了)

  • Lock框架和Tools类(把图中这两个放到一起理解)
  • Collections: 并发集合
  • Atomic: 原子类
  • Executors: 线程池

3、相关类与框架

1、Lock框架和Tools类

类结构总览:

image

  • 接口
    • Condition
      • Condition为接口类型,它将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。
      • 其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。可以通过await(),signal()来休眠/唤醒线程。
    • Lock
      • Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象
    • ReadWriteLock
      • ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。
  • 抽象类
    • AbstractOwnableSynchonizer
      • AbstractOwnableSynchonizer为抽象类,可以由线程以独占方式拥有的同步器。
      • 此类为创建锁和相关同步器(伴随着所有权的概念)提供了基础。
      • AbstractOwnableSynchronizer 类本身不管理或使用此信息。但是,子类和工具可以使用适当维护的值帮助控制和监视访问以及提供诊断。
    • (Long)AbstractQueuedLongSynchonizer
      • AbstractQueuedLongSynchronizer为抽象类,以 long 形式维护同步状态的一个 AbstractQueuedSynchronizer 版本。
      • 此类具有的结构、属性和方法与 AbstractQueuedSynchronizer 完全相同,但所有与状态相关的参数和结果都定义为 long 而不是 int。
      • 当创建需要 64 位状态的多级别锁和屏障等同步器时,此类很有用。
    • 核心抽象类(int):AbstractQueuedSynchonizer
      • AbstractQueuedSynchonizer为抽象类,其为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。
      • 此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。
  • 锁常用类
    • LockSupport
      • LockSupport为常用类,用来创建锁和其他同步类的基本线程阻塞原语。
      • LockSupport的功能和”Thread中的 Thread.suspend()和Thread.resume()有点类似”,LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程。
      • 但是park()和unpark()不会遇到“Thread.suspend 和 Thread.resume所可能引发的死锁”问题。
    • ReentrantLock
      • ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
    • ReentrantReadWriteLock
      • ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁。
    • StampedLock
      • 它是java8在java.util.concurrent.locks新增的一个API。
      • StampedLock控制锁有三种模式(写,读,乐观读),一个StampedLock状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据stamp,它用相应的锁状态表示并控制访问,数字0表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。
  • 工具常用类
    • CountDownLatch
      • CountDownLatch为常用类,它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。
    • CyclicBarrier
      • CyclicBarrier为常用类,其是一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。
      • 在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环 的 barrier。
    • Phaser
      • Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。
    • Semaphore
      • Semaphore为常用类,其是一个计数信号量,从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。
      • 但是,不使用实际的许可对象,Semaphore 只对可用许可的号码进行计数,并采取相应的行动。通常用于限制可以访问某些资源(物理或逻辑的)的线程数目。
    • Exchanger
      • Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换。
      • 它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
2、Collections:并发集合

类结构关系:

image

  • Queue
    • ArrayBlockingQueue
      • 一个由数组支持的有界阻塞队列。此队列按 FIFO(先进先出)原则对元素进行排序。
      • 队列的头部是在队列中存在时间最长的元素。队列的尾部是在队列中存在时间最短的元素。
      • 新元素插入到队列的尾部,队列获取操作则是从队列头部开始获得元素。
    • LinkedBlockingQueue
      • 一个基于已链接节点的、范围任意的 blocking queue。此队列按 FIFO(先进先出)排序元素。
      • 队列的头部是在队列中时间最长的元素。队列的尾部是在队列中时间最短的元素。
      • 新元素插入到队列的尾部,并且队列获取操作会获得位于队列头部的元素。
      • 链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能要低。
    • LinkedBlockingDeque
      • 一个基于已链接节点的、任选范围的阻塞双端队列。
    • ConcurrentLinkedQueue
      • 一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。
      • 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素。
      • 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
      • 当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。
    • ConcurrentLinkedDeque
      • 是双向链表实现的无界队列,该队列同时支持FIFO和FILO两种操作方式。
    • DelayQueue
      • 延时无界阻塞队列,使用Lock机制实现并发访问。
      • 队列里只允许放可以“延期”的元素,队列中的head是最先“到期”的元素。
      • 如果队里中没有元素到“到期”,那么就算队列中有元素也不能获取到。
    • PriorityBlockingQueue
      • 无界优先级阻塞队列,使用Lock机制实现并发访问。
      • priorityQueue的线程安全版,不允许存放null值,依赖于comparable的排序,不允许存放不可比较的对象类型。
    • SynchronousQueue
      • 没有容量的同步队列,通过CAS实现并发访问,支持FIFO和FILO。
    • LinkedTransferQueue
      • JDK 7新增,单向链表实现的无界阻塞队列,通过CAS实现并发访问,队列元素使用 FIFO(先进先出)方式。
      • LinkedTransferQueue可以说是ConcurrentLinkedQueue、SynchronousQueue(公平模式)和LinkedBlockingQueue的超集,它不仅仅综合了这几个类的功能,同时也提供了更高效的实现。
  • List
    • CopyOnWriteArrayList
      • ArrayList 的一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行一次新的复制来实现的。
      • 这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更有效。在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。
  • Set
    • CopyOnWriteArraySet
      • 对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。
      • 在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList。
    • ConcurrentSkipListSet
      • 一个基于ConcurrentSkipListMap 的可缩放并发 NavigableSet 实现。set 的元素可以根据它们的自然顺序进行排序,也可以根据创建 set 时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
  • Map
    • ConcurrentHashMap
      • 是线程安全HashMap的。ConcurrentHashMap在JDK 7之前是通过Lock和segment(分段锁)实现,JDK 8 之后改为CAS+synchronized来保证并发安全。
    • ConcurrentSkipListMap
      • 线程安全的有序的哈希表(相当于线程安全的TreeMap);映射可以根据键的自然顺序进行排序,也可以根据创建映射时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
3、Atomic: 原子类

其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。

  • 基础类型
    • AtomicBoolean
      • 针对bool的原子类。
    • AtomicInteger
      • 针对interger的原子类。
    • AtomicLong
      • 针对long的原子类。
  • 数组
    • AtomicIntegerArray
    • AtomicLongArray
    • BooleanArray
  • 引用
    • AtomicReference
    • AtomicMarkedReference
    • AtomicStampedReference
  • FieldUpdater
    • AtomicLongFieldUpdater
    • AtomicIntegerFieldUpdater
    • AtomicReferenceFieldUpdater
4、Executors:线程池

类结构关系:

img

  • 接口:Executor
    • Executor接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。
  • ExecutorService
    • ExecutorService继承自Executor接口,ExecutorService提供了管理终止的方法,以及可为跟踪一个或多个异步任务执行状况而生成 Future 的方法。 可以关闭 ExecutorService,这将导致其停止接受新任务。关闭后,执行程序将最后终止,这时没有任务在执行,也没有任务在等待执行,并且无法提交新任务。
  • ScheduledExecutorService
    • ScheduledExecutorService继承自ExecutorService接口,可安排在给定的延迟后运行或定期执行的命令。
  • AbstractExecutorService
    • AbstractExecutorService继承自ExecutorService接口,其提供 ExecutorService 执行方法的默认实现。此类使用 newTaskFor 返回的 RunnableFuture 实现 submit、invokeAny 和 invokeAll 方法,默认情况下,RunnableFuture 是此包中提供的 FutureTask 类。
  • FutureTask
    • FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。
    • 如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。
    • FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。
    • 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。
    • FutureTask 的线程安全由CAS来保证。
  • 核心
    • ThreadPoolExecutor
      • ThreadPoolExecutor实现了AbstractExecutorService接口,也是一个 ExecutorService,它使用可能的几个池线程之一执行每个提交的任务,通常使用 Executors 工厂方法配置。
      • 线程池可以解决两个不同问题:由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个 ThreadPoolExecutor 还维护着一些基本的统计数据,如完成的任务数。
    • ScheduledThreadExecutor
      • ScheduledThreadPoolExecutor实现ScheduledExecutorService接口,可安排在给定的延迟后运行命令,或者定期执行命令。需要多个辅助线程时,或者要求 ThreadPoolExecutor 具有额外的灵活性或功能时,此类要优于 Timer。
    • Fork/Join框架
      • ForkJoinPool 是JDK 7加入的一个线程池类。
      • Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。
      • 目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。
  • 工具类:Executors
    • Executors是一个工具类,用其可以创建ExecutorService、ScheduledExecutorService、ThreadFactory、Callable等对象。
    • 它的使用融入到了ThreadPoolExecutor、ScheduledThreadExecutor和ForkJoinPool中。

1、JUC概述

1、什么是JUC

在 Java 中,线程部分是一个重点,本篇文章说的 JUC 也是关于线程的。JUC就是 java.util .concurrent 工具包的简称。这是一个处理线程的工具包,JDK 1.5 开始出现的。

image-20210721220607339

2、多线程编程步骤

  1. 第一:创建资源类,创建属性和操作方法
  2. 第二:在资源类的操作方法中
    1. 判断(使用while,不使用if,或者会出现虚假唤醒问题)
    2. 干活
    3. 通知
  3. 第三:创建多线程调用资源类的方法
  4. 第四:防止出现虚假唤醒问题
虚假唤醒问题
1、什么是虚假唤醒问题?

当一个条件满足时,很多线程都被唤醒了,但是只有其中部分是有用的唤醒,其它的唤醒都是无用的唤醒;这些无用的唤醒会导致出现一些问题。

2、为什么会出现虚假唤醒问题?

在多线程编程步骤的第二步中的判断中,如果使用的是if语句的话,就会出现虚假唤醒问题。原因:

  • if语句块只会判断一次
  • wait()方法的特性:在哪里等待,就在哪里开始

在官方文档中就明确规定要**==使用while语句块==,不要使用if语句块**:(因为while语句块可以不断的进行判断)

在这里插入图片描述

3、示例(参考下面进程间通信的代码——将其中的while修改为if,并运行发现)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
A=>1
B=>0
A=>1
B=>0
A=>1
B=>0
C=>1
A=>2
C=>3
B=>2
B=>1
B=>0
C=>1
A=>2
C=>3
B=>2
D=>1
D=>0
D=>-1
C=>0
C=>1
A=>2
C=>3
D=>2
D=>1
D=>0
C=>1
A=>2
C=>3
D=>2
D=>1
D=>0
C=>1
D=>0
C=>1
D=>0
4、分析(假设一开始为0)
  • 调用A –> 1
  • 调用C –> 1 C wait
  • 如果再调用A,那么A也会wait,A wait –> 1
  • 再调用B减1后, –> 0
  • 唤醒了A和C,执行A(C没抢到), –> 1
  • A执行完后,C抢到了CPU,此时C没有再进行判断,直接执行+1操作, –> 2
5、使用wait/notify的正确姿势——防止虚假唤醒问题
1
2
3
4
5
6
7
8
9
10
11
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}

//另一个线程
synchronized(lock) {
lock.notifyAll();
}

2、JUC原子类:CAS,Unsafe和原子类详解

JUC中多数类是通过volatile和CAS来实现的,CAS本质上提供的是一种无锁方案,而Synchronized和Lock是互斥锁方案;java原子类本质上使用的是CAS,而CAS底层是通过Unsafe类实现的。

1、BAT大厂的面试问题

  • 线程安全的实现方法有哪些?
  • 什么是CAS?
  • CAS使用示例,结合AtomicInteger给出示例?
  • CAS会有哪些问题?
  • 针对这这些问题,Java提供了哪几个解决的?
  • AtomicInteger底层实现?
    • CAS + volatile
  • 请阐述你对Unsafe类的理解?
  • 说说你对Java原子类的理解?包含13个,4组分类,说说作用和使用场景。
  • AtomicStampedReference是什么?
  • AtomicStampedReference是怎么解决ABA的?
    • 内部使用Pair来存储元素值及其版本号
  • java中还有哪些类可以解决ABA的问题?
    • AtomicMarkableReference

2、CAS

前面我们说到,线程安全的实现方法包含:

  • 互斥同步:synchronizedReentrantLock
  • 非阻塞同步:CASAtomicXXXX
  • 无同步方案:栈封闭Thread Local可重入代码
1、什么是CAS

CAS的全称为Compare-And-Swap,直译就是对比交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。

简单解释:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。

CAS操作是原子性的,所以多线程并发使用CAS更新数据时,可以不使用锁。JDK中大量使用了CAS来更新数据而防止加锁(synchronized 重量级锁)来保持原子更新。

相信sql大家都熟悉,类似sql中的条件更新一样:update set id=3 from table where id=2。因为单条sql执行具有原子性,如果有多个线程同时执行此sql语句,只有一条能更新成功。但如果有多条sql的话,要保证操作的原子性,就要使用事务了。

2、CAS使用实例

如果不使用CAS,在高并发下,多线程同时修改一个变量的值我们需要synchronized加锁(可能有人说可以用Lock加锁,Lock底层的AQS也是基于CAS进行获取锁的)。

1
2
3
4
5
6
public class Test {
private int i = 0;
public synchronized int add(){
return i++;
}
}

java中为我们提供了AtomicInteger 原子类(底层基于CAS进行更新数据的),不需要加锁就在多线程并发场景下实现数据的一致性。

1
2
3
4
5
6
public class Test {
private AtomicInteger i = new AtomicInteger(0);
public int add(){
return i.addAndGet(1);
}
}
3、为什么无锁效率高
  • 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
  • 打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
4、CAS 的特点

结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
  • CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思:
    • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
5、CAS问题

CAS 方式为乐观锁,synchronized 为悲观锁。因此使用 CAS 解决并发问题通常情况下性能更优。

但使用 CAS 方式也会有几个问题:

  • ABA问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作
1、ABA问题

因为CAS需要在操作值的时候,检查值有没有发生变化,比如没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时则会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。

从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值

2、循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。

pause指令有两个作用:

  1. 第一,它可以延迟流水线执行命令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
  2. 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3、只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。

从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作

3、Unsafe类详解

Java原子类是通过UnSafe类实现的,UnSafe类在J.U.C中CAS操作有很广泛的应用。

Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。

由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

这个类尽管里面的方法都是 public 的,但是并没有办法使用它们,JDK API 文档也没有提供任何关于这个类的方法的解释。总而言之,对于 Unsafe 类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然 JDK 库里面的类是可以随意使用的。

Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 通过反射获取一个unsafe对象
public class UnsafeAccessor {
static Unsafe unsafe;
static {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
throw new Error(e);
}
}

static Unsafe getUnsafe() {
return unsafe;
}
}

先来看下这张图,对UnSafe类总体功能:

img

如上图所示,Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类。

1、Unsafe与CAS

反编译出来的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public final int getAndAddInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt));
return i;
}

public final long getAndAddLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, l + paramLong2));
return l;
}

public final int getAndSetInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, paramInt));
return i;
}

public final long getAndSetLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, paramLong2));
return l;
}

public final Object getAndSetObject(Object paramObject1, long paramLong, Object paramObject2)
{
Object localObject;
do
localObject = getObjectVolatile(paramObject1, paramLong);
while (!compareAndSwapObject(paramObject1, paramLong, localObject, paramObject2));
return localObject;
}

从源码中发现,**内部使用自旋的方式进行CAS更新(while循环进行CAS更新,如果更新失败,则循环再次重试)**。

又从Unsafe类中发现,原子操作其实只支持下面3种CAS方法:(都是native方法)

  1. compareAndSwapObject
  2. compareAndSwapInt
  3. compareAndSwapLong
1
2
3
4
5
public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);

public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);

public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);

三个方法都是有四个参数,这四个参数的含义都是一样的,分别是:(以compareAndSwapInt为例)

  • Object paramObject:操作的对象
  • long paramLong:操作对象的操作域的偏移地址
  • int paramInt1:原值
  • int paramInt2:修改值
2、使用unsafe

我们使用反射得到的unsafe来完成一些操作

1、使用unsafe去修改一个对象的字段(域)的取值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import lombok.Data;
import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class TestUnsafe {

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

// System.out.println(unsafe);

// 1. 获取域的偏移地址
long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));
long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));

Teacher t = new Teacher();
// 2. 执行 cas 操作
unsafe.compareAndSwapInt(t, idOffset, 0, 1);
unsafe.compareAndSwapObject(t, nameOffset, null, "张三");

// 3. 验证
System.out.println(t);
}
}
@Data
class Teacher {
volatile int id;
volatile String name;
}

输出:

1
Theater{id=1,name="张三"}
2、使用unsafe模拟实现原值整数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import cn.itcast.n4.UnsafeAccessor;
import lombok.extern.slf4j.Slf4j;
import sun.misc.Unsafe;

@Slf4j(topic = "c.Test42")
public class Test42 {
public static void main(String[] args) {
Account.demo(new MyAtomicInteger(10000));
}
}

class MyAtomicInteger implements Account {
private volatile int value;
private static final long valueOffset;
private static final Unsafe UNSAFE;
static {
UNSAFE = UnsafeAccessor.getUnsafe();
try {
valueOffset = UNSAFE.objectFieldOffset(MyAtomicInteger.class.getDeclaredField("value"));
} catch (NoSuchFieldException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}

public int getValue() {
return value;
}

public void decrement(int amount) {
while(true) {
int prev = this.value;
int next = prev - amount;
if (UNSAFE.compareAndSwapInt(this, valueOffset, prev, next)) {
break;
}
}
}

public MyAtomicInteger(int value) {
this.value = value;
}

@Override
public Integer getBalance() {
return getValue();
}

@Override
public void withdraw(Integer amount) {
decrement(amount);
}
}
2、Unsafe底层

Unsafe的compareAndSwap方法来实现CAS操作,它是一个本地方法,实现位于unsafe.cpp中。

1
2
3
4
5
6
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

可以看到它通过 Atomic::cmpxchg 来实现比较和替换操作。其中参数x是即将更新的值,参数e是原内存的值。

如果是Linux的x86,Atomic::cmpxchg方法的实现如下:

1
2
3
4
5
6
7
8
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP();
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}

而windows的x86的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::isMP(); //判断是否是多处理器
_asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:

如果是多处理器,为cmpxchg指令添加lock前缀。反之,就省略lock前缀(单处理器会不需要lock前缀提供的内存屏障效果)。这里的lock前缀就是使用了处理器的总线锁(最新的处理器都使用缓存锁代替总线锁来提高性能)。

cmpxchg(void* ptr, int old, int new),如果ptr和old的值一样,则把new写到ptr内存,否则返回ptr的值,整个操作是原子的。在Intel平台下,会用lock cmpxchg来实现,使用lock触发缓存锁,这样另一个线程想访问ptr的内存,就会被block住。

3、Unsafe其他功能

Unsafe 提供了硬件级别的操作,比如说获取某个属性在内存中的位置,比如说修改对象的字段值,即使它是私有的。不过 Java 本身就是为了屏蔽底层的差异,对于一般的开发而言也很少会有这样的需求。

举两个例子,比方说:这个方法可以用来获取给定的 paramField 的内存地址偏移量,这个值对于给定的 field 是唯一的且是固定不变的。

1
public native long staticFieldOffset(Field paramField);

再比如说:前一个方法是用来获取数组第一个元素的偏移地址,后一个方法是用来获取数组的转换因子即数组中元素的增量地址的。

1
2
public native int arrayBaseOffset(Class paramClass);
public native int arrayIndexScale(Class paramClass);

最后看三个方法:分别用来分配内存,扩充内存和释放内存的。

1
2
3
public native long allocateMemory(long paramLong);
public native long reallocateMemory(long paramLong1, long paramLong2);
public native void freeMemory(long paramLong);

更多相关功能,推荐你看下这篇文章:来自美团技术团队:Java魔法类:Unsafe应用解析

4、AutomicIntrger

1、使用举例

以 AtomicInteger 为例,常用 API:

1
2
3
4
5
6
public final int get():获取当前的值
public final int getAndSet(int newValue):获取当前的值,并设置新的值
public final int getAndIncrement():获取当前的值,并自增
public final int getAndDecrement():获取当前的值,并自减
public final int getAndAdd(int delta):获取当前的值,并加上预期的值
void lazySet(int newValue): 最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

相比 Integer 的优势,多线程中让变量自增:

1
2
3
4
5
6
7
8
private volatile int count = 0;
// 若要线程安全执行执行 count++,需要加锁
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}

使用 AtomicInteger 后:

1
2
3
4
5
6
7
8
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
// 使用 AtomicInteger 后,不需要加锁,也可以实现线程安全
public int getCount() {
return count.get();
}
2、源码解析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
//用于获取value字段相对当前对象的“起始地址”的偏移量
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

//返回当前值
public final int get() {
return value;
}

//递增加detla
public final int getAndAdd(int delta) {
//三个参数,1、当前的实例 2、value实例变量的偏移量 3、当前value要加上的数(value+delta)。
return unsafe.getAndAddInt(this, valueOffset, delta);
}

//递增加1
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// ...
}

我们可以看到 AtomicInteger 底层用的是volatile的变量和CAS来进行更改数据的。

  • volatile保证线程的可见性,多线程并发时,一个线程修改数据,可以保证其它线程立马看到修改后的值
  • CAS 保证数据更新的原子性。

5、延伸到所有原子类:共13个

JDK中提供了13个原子操作类。

1、原子更新基本类型

使用原子的方式更新基本类型,Atomic包提供了以下3个类。

  • AtomicBoolean:原子更新布尔类型。
  • AtomicInteger:原子更新整型。
  • AtomicLong:原子更新长整型。

以上3个类提供的方法几乎一模一样,可以参考上面AtomicInteger中的相关方法。

其它方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
AtomicInteger i = new AtomicInteger(0);
// 获取并自增(i = 0, 结果 i = 1, 返回 0),类似于 i++
System.out.println(i.getAndIncrement());
// 自增并获取(i = 1, 结果 i = 2, 返回 2),类似于 ++i
System.out.println(i.incrementAndGet());
// 自减并获取(i = 2, 结果 i = 1, 返回 1),类似于 --i
System.out.println(i.decrementAndGet());
// 获取并自减(i = 1, 结果 i = 0, 返回 1),类似于 i--
System.out.println(i.getAndDecrement());
// 获取并加值(i = 0, 结果 i = 5, 返回 0)
System.out.println(i.getAndAdd(5));
// 加值并获取(i = 5, 结果 i = 0, 返回 0)
System.out.println(i.addAndGet(-5));
// 获取并更新(i = 0, p 为 i 的当前值, 结果 i = -2, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.getAndUpdate(p -> p - 2));
// 更新并获取(i = -2, p 为 i 的当前值, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.updateAndGet(p -> p + 2));
// 获取并计算(i = 0, p 为 i 的当前值, x 为参数1, 结果 i = 10, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
// getAndUpdate 如果在 lambda 中引用了外部的局部变量,要保证该局部变量是 final 的
// getAndAccumulate 可以通过 参数1 来引用外部的局部变量,但因为其不在 lambda 中因此不必是 final
System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
// 计算并获取(i = 10, p 为 i 的当前值, x 为参数1, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));
2、原子更新数组

通过原子的方式更新数组里的某个元素,Atomic包提供了以下的4个类:

  • AtomicIntegerArray:原子更新整型数组里的元素。

  • AtomicLongArray:原子更新长整型数组里的元素。

  • AtomicReferenceArray:原子更新引用类型数组里的元素。

    这三个类的最常用的方法是如下两个方法:

  • get(int index):获取索引为index的元素值。

  • compareAndSet(int i, E expect, E update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置为update值。

举个AtomicIntegerArray例子:

1
2
3
4
5
6
7
8
9
10
import java.util.concurrent.atomic.AtomicIntegerArray;

public class Demo5 {
public static void main(String[] args) throws InterruptedException {
AtomicIntegerArray array = new AtomicIntegerArray(new int[] { 0, 0 });
System.out.println(array);
System.out.println(array.getAndAdd(1, 2));
System.out.println(array);
}
}
1
2
3
[0, 0]
0
[0, 2]
3、原子更新引用类型

Atomic包提供了以下三个类:

  • AtomicReference:原子更新引用类型。
  • AtomicStampedReference:原子更新引用类型,内部使用Pair来存储元素值及其版本号。(可以解决CAS的ABA问题)
  • AtomicMarkableReferce:原子更新带有标记位的引用类型。
    • 有时候,并不关心引用变量更改了几次(ABA问题),只是单纯的关心是否更改过,所以就有了AtomicMarkableReference

这三个类提供的方法都差不多:

  1. 首先构造一个引用对象;
  2. 然后把引用对象set进Atomic类;
  3. 然后调用compareAndSet等一些方法去进行原子操作。

原理都是基于Unsafe实现,但AtomicReferenceFieldUpdater略有不同,更新的字段必须用volatile修饰

举个AtomicReference例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceTest {

public static void main(String[] args){

// 创建两个Person对象,它们的id分别是101和102。
Person p1 = new Person(101);
Person p2 = new Person(102);
// 新建AtomicReference对象,初始化它的值为p1对象
AtomicReference ar = new AtomicReference(p1);
// 通过CAS设置ar。如果ar的值为p1的话,则将其设置为p2。
ar.compareAndSet(p1, p2);

Person p3 = (Person)ar.get();
System.out.println("p3 is "+p3);
System.out.println("p3.equals(p2)="+p3.equals(p2));
}
}

class Person {
volatile long id;
public Person(long id) {
this.id = id;
}
public String toString() {
return "id:"+id;
}
}
1
2
p3 is id:102
p3.equals(p2)=false

结果说明:

  • 新建AtomicReference对象ar时,将它初始化为p1。
  • 紧接着,通过CAS函数对它进行设置。如果ar的值为p1的话,则将其设置为p2。
  • 最后,获取ar对应的对象,并打印结果。p3.equals(p2)的结果为false。
    • 这是因为Person并没有覆盖equals()方法,而是采用继承自Object.java的equals()方法;而Object.java中的equals()实际上是调用”==”去比较两个对象,即比较两个对象的地址是否相等。

举个AtomicMarkableReference例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.atomic.AtomicMarkableReference;

import static cn.itcast.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.Test38")
public class Test38 {
public static void main(String[] args) throws InterruptedException {
GarbageBag bag = new GarbageBag("装满了垃圾");
// 参数2 mark 可以看作一个标记,表示垃圾袋满了
AtomicMarkableReference<GarbageBag> ref = new AtomicMarkableReference<>(bag, true);

log.debug("start...");
GarbageBag prev = ref.getReference();
log.debug(prev.toString());

new Thread(() -> {
log.debug("start...");
bag.setDesc("空垃圾袋");
ref.compareAndSet(bag, bag, true, false);
log.debug(bag.toString());
},"保洁阿姨").start();

sleep(1);
log.debug("想换一只新垃圾袋?");
boolean success = ref.compareAndSet(prev, new GarbageBag("空垃圾袋"), true, false);
log.debug("换了么?" + success);
log.debug(ref.getReference().toString());
}
}

class GarbageBag {
String desc;

public GarbageBag(String desc) {
this.desc = desc;
}

public void setDesc(String desc) {
this.desc = desc;
}

@Override
public String toString() {
return super.toString() + " " + desc;
}
}
4、原子更新字段类(原子更新器)

Atomic包提供了四个类进行原子字段更新:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。
  • AtomicStampedFieldUpdater:原子更新带有版本号的引用类型。
  • AtomicReferenceFieldUpdater:上面已经说过此处不在赘述。

这四个类的使用方式都差不多,是基于反射的原子更新字段的值。要想原子地更新字段类需要两步:

  1. 第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性
  2. 第二步,更新类的字段必须使用public volatile修饰。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class TestAtomicIntegerFieldUpdater {

public static void main(String[] args){
TestAtomicIntegerFieldUpdater tIA = new TestAtomicIntegerFieldUpdater();
tIA.doIt();
}

public AtomicIntegerFieldUpdater<DataDemo> updater(String name){
return AtomicIntegerFieldUpdater.newUpdater(DataDemo.class,name);
}

public void doIt(){
DataDemo data = new DataDemo();
System.out.println("publicVar = "+updater("publicVar").getAndAdd(data, 2));
/*
* 由于在DataDemo类中属性value2/value3,在TestAtomicIntegerFieldUpdater中不能访问
*/
//System.out.println("protectedVar = "+updater("protectedVar").getAndAdd(data,2));
//System.out.println("privateVar = "+updater("privateVar").getAndAdd(data,2));

//报java.lang.IllegalArgumentException
//System.out.println("staticVar = "+updater("staticVar").getAndIncrement(data));

/*
* 下面报异常:must be integer
*/
//System.out.println("integerVar = "+updater("integerVar").getAndIncrement(data));
//System.out.println("longVar = "+updater("longVar").getAndIncrement(data));
}

}

class DataDemo{
public volatile int publicVar=3;
protected volatile int protectedVar=4;
private volatile int privateVar=5;

public volatile static int staticVar = 10;
//public final int finalVar = 11;

public volatile Integer integerVar = 19;
public volatile Long longVar = 18L;

}

再说下对于AtomicIntegerFieldUpdater 的使用稍微有一些限制和约束,约束如下:

  • 字段必须是volatile类型的,在线程之间共享变量时保证立即可见
    • eg:volatile int value = 3
  • 字段的描述类型(修饰符public/protected/default/private)是与调用者与操作对象字段的关系一致。也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作。但是对于父类的字段,子类是不能直接操作的,尽管子类可以访问父类的字段。
  • 只能是实例变量,不能是类变量,也就是说不能加static关键字
  • 只能是可修改变量,不能是final变量,因为final的语义就是不可修改实际上final的语义和volatile是有冲突的,这两个关键字不能同时存在。
  • 对于AtomicIntegerFieldUpdater和AtomicLongFieldUpdater只能修改int/long类型的字段,不能修改其包装类型(Integer/Long)。如果要修改包装类型就需要使用AtomicReferenceFieldUpdater
5、原子累加器

原子类型累加器JDK1.8引进的并发新技术,它可以看做AtomicLongAtomicDouble的部分加强类型。

原子类型累加器有如下四种:

  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder

由于上面四种累加器的原理类似,下面以LongAdder为列来介绍累加器的使用。

已经有AtomicLong的getAndIncrement()方法进行累加效果,为什么还要有LongAdder累加器?

  • 我们知道,AtomicLong是利用了底层的CAS操作来提供并发性的,比如addAndGet方法:

  • public final long addAndGet(long delta) {
        return unsafe.getAndAddLong(this, valueOffset, delta) + delta;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39

    - 上述方法调用了**Unsafe**类的**getAndAddLong**方法,该方法是个**native**方法,它的逻辑是采用自旋的方式不断更新目标值,直到更新成功。

    - 在并发量较低的环境下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发环境下,N个线程同时进行自旋操作,会出现大量失败并不断自旋的情况,此时**AtomicLong**的自旋会成为瓶颈。

    - 这就是**LongAdder**引入的初衷——解决高并发环境下**AtomicLong**的自旋瓶颈问题。

    - 而**LongAdder**的基本思路就是**分散热点**,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。

    - 如果要获取真正的long值,只要将各个槽中的变量值累加返回。

    - ConcurrentHashMap中的“分段锁”其实就是类似的思路。

    ###### 1、累加器性能比较——比较 AtomicLong 与 LongAdder

    ```java
    private static <T> void demo(Supplier<T> adderSupplier, Consumer<T> action) {
    T adder = adderSupplier.get();
    long start = System.nanoTime();
    List<Thread> ts = new ArrayList<>(); // 4 个线程,每人累加 50 万
    for (int i = 0; i < 40; i++) {
    ts.add(new Thread(() -> {
    for (int j = 0; j < 500000; j++) {
    action.accept(adder);
    }
    }));
    }
    ts.forEach(t -> t.start());
    ts.forEach(t -> {
    try {
    join();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    });

    long end = System.nanoTime();
    System.out.println(adder + " cost:" + (end - start)/1000_000);
    }

比较 AtomicLong 与 LongAdder:

1
2
3
4
5
6
7
for (int i = 0; i < 5; i++) {
demo(() -> new LongAdder(), adder -> adder.increment());
}

for (int i = 0; i < 5; i++) {
demo(() -> new AtomicLong(), adder -> adder.getAndIncrement());
}

输出:

1
2
3
4
5
6
7
8
9
10
11
1000000 cost:43 
1000000 cost:9
1000000 cost:7
1000000 cost:7
1000000 cost:7

1000000 cost:31
1000000 cost:27
1000000 cost:28
1000000 cost:24
1000000 cost:22

性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。

2、LongAdder源码

LongAdder 是并发大师 @author Doug Lea (大哥李)的作品,设计的非常精巧

类的继承关系:

1
2
3
public class LongAdder extends Striped64 implements Serializable {...}
// Striped64这个类实现一些核心操作,处理64位数据。
abstract class Striped64 extends Number {...}

LongAdder 类有几个关键域:(这几个的关键域定义在Striped64抽象类中)

1
2
3
4
5
6
// 累加单元数组, 懒惰初始化
transient volatile Cell[] cells;
// 基础值, 如果没有竞争, 则用 cas 累加这个域
transient volatile long base;
// 在 cells 创建或扩容时, 置为 1, 表示加锁(cas锁)
transient volatile int cellsBusy;

其中 Cell 即为累加单元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 防止缓存行伪共享
@sun.misc.Contended
static final class Cell {
volatile long value;
Cell(long x) { value = x; }
// 最重要的方法, 用来 cas 方式进行累加, prev 表示旧值, next 表示新值
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}

// Unsafe mechanics
private static final sun.misc.Unsafe UNSAFE;
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Cell.class;
valueOffset = UNSAFE.objectFieldOffset
(ak.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
}
3、缓存(伪共享问题)

缓存与内存的速度比较:

image-20210807034923018

因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。

而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)

缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中

CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。

可以通过缓存一致性协议(MESI)保证:

缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效

伪共享:

image-20210807035727427

因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因此缓存行可以存下 2 个的 Cell 对象。这样问题来了:

  • Core-0 要修改 Cell[0]
  • Core-1 要修改 Cell[1]

无论谁修改成功,都会导致对方 Core 的缓存行失效,比如 Core-0 中 Cell[0]=6000, Cell[1]=8000 要累加Cell[0]=6001, Cell[1]=8000 Q,这时会让 Core-1 的缓存行失效。这种问题被叫做伪共享问题。

@sun.misc.Contended 用来解决这个伪共享问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效

image-20210807040114815

4、核心方法——increment()
1
2
3
public void increment() {
add(1L);
}

说明:increment()方法调用了add()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void add(long x) {
// as 为累加单元数组
// b 为基础值
// x 为累加值
Cell[] as; long b, v; int m; Cell a;
// 进入 if 的两个条件
// 1. as 有值, 表示已经发生过竞争, 进入 if
// 2. cas 给 base 累加时失败了, 表示 base 发生了竞争, 进入 if
if ((as = cells) != null || !casBase(b = base, b + x)) {
// uncontended 表示 cell 没有竞争
boolean uncontended = true;
if (
// as 还没有创建
as == null || (m = as.length - 1) < 0 ||
// 当前线程对应的 cell 还没有
(a = as[getProbe() & m]) == null ||
// cas 给当前线程的 cell 累加失败 uncontended=false ( a 为当前线程的 cell )
!(uncontended = a.cas(v = a.value, v + x)))
// 进入 cell 数组创建、cell 创建的流程
longAccumulate(x, null, uncontended);
}
}

add 流程图:

image-20210807040735271

说明:

  • cells是懒惰式创建的,当有竞争的才会创建cells数组,进而创建cells数组里面的cell对象
  • 当cells为空是说明当前竞争并不激烈,累加操作交给base去操作
    • 成功:返回
    • 失败:进入longAccumulate()方法
  • 当cells不为空说明当前存在竞争,查看当前线程cell是否创建
    • 没创建:进入longAccumulate()方法创建cell
    • 创建:累加操作交给创建的cell去操作
      • 成功:返回
      • 失败:进入longAccumulate()方法

由此刻看出,当累加失败或者没有创建cell时都会调用longAccumulate()方法,以下为longAccumulate()方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {
int h;
// 当前线程还没有对应的 cell, 需要随机生成一个 h 值用来将当前线程绑定到 cell
if ((h = getProbe()) == 0) {
// 初始化 probe
ThreadLocalRandom.current(); // force initialization
// h 对应新的 probe 值, 用来对应 cell
h = getProbe();
wasUncontended = true;
}
// collide 为 true 表示需要扩容
boolean collide = false; // True if last slot nonempty
for (;;) {
Cell[] as; Cell a; int n; long v;
// 已经有了 cells
if ((as = cells) != null && (n = as.length) > 0) {
// 还没有 cell
if ((a = as[(n - 1) & h]) == null) {
// 为 cellsBusy 加锁, 创建 cell, cell 的初始累加值为 x
// 成功则 break, 否则继续 continue 循环
if (cellsBusy == 0) { // Try to attach new Cell
// 创建cell对象
Cell r = new Cell(x); // Optimistically create
// 上锁
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try { // Recheck under lock
Cell[] rs; int m, j;
if ((rs = cells) != null && // cells数组不为空
(m = rs.length) > 0 && // cells大小大于0
rs[j = (m - 1) & h] == null) { // cells是否有空槽位
// 如果槽位为空,则将创建的cell设置到空槽位当中
rs[j] = r;
created = true;
}
} finally {
// 解锁
cellsBusy = 0;
}
// 创建成功
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
// 有竞争, 改变线程对应的 cell 来重试 cas
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 cas
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了
else if (!collide)
collide = true;
// 加锁
else if (cellsBusy == 0 && casCellsBusy()) {
// 加锁成功, 扩容
try {
if (cells == as) { // Expand table unless stale
Cell[] rs = new Cell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 改变线程对应的 cell
h = advanceProbe(h);
}
/*
* 三个判断:
* 1、判断cellsBusy锁是否上锁
* 2、是否有其他线程创建了cells
* 3、尝试对cellsBusy上锁:把cellsBusy的值从0改到1
*/
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
// 加锁成功, 初始化 cells, 最开始长度为 2, 并填充一个 cell
// 成功则 break;
boolean init = false;
try { // Initialize table
// 再次判断是否其他线程已经了创建cells
if (cells == as) {
// 创建cells,初始大小是2,
// 但是同时创建了一个cell(只创建了一个cell,有一个cells的空间是空的)
// 线程不到万不得已才会使用到这个空的cell,体现了:线程对cell的懒惰初始化
Cell[] rs = new Cell[2];
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
// 解锁
cellsBusy = 0;
}
// 初始化成功
if (init)
break;
}
// 上两种情况失败, 尝试给 base 累加
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break; // Fall back on using base
}
}

longAccumulate流程图:

1
2
3
4
5
6
// 加锁成功,进入下面else if块的逻辑:创建cells
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {...}

// 加锁失败,进入下面else if块的逻辑:尝试给 base 累加
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))

image-20210807042131270

1
2
// cells不为空且cells的长度大于0:创建cell
if ((as = cells) != null && (n = as.length) > 0) {...}

image-20210807042151516

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 cas
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了
else if (!collide)
collide = true;
// 加锁
else if (cellsBusy == 0 && casCellsBusy()) {
// 加锁成功, 扩容
try {
if (cells == as) { // Expand table unless stale
// 先将长度进行翻倍 n<<1
Cell[] rs = new Cell[n << 1];
// 把老数组的对象复制到新数组当中
for (int i = 0; i < n; ++i)
rs[i] = as[i];
// 用新数组替换掉旧数组
cells = rs;
}
} finally {
// 解锁
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 改变线程对应的 cell
h = advanceProbe(h);

每个线程刚进入 longAccumulate 时,会尝试对应一个 cell 对象(找到一个坑位)

image-20210807042217185

获取最终结果通过 sum 方法:

1
2
3
4
5
6
7
8
9
10
11
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}

6、再说AutomicStampedReference解决CAS的ABA问题

1、AutomicStampedReference解决CAS的ABA问题

AtomicStampedReference主要维护包含一个对象引用以及一个可以自动更新的整数”stamp”的pair对象来解决ABA问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference; //维护对象引用
final int stamp; //用于标志版本
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
....

/**
* expectedReference :更新之前的原始值
* newReference : 将要更新的新值
* expectedStamp : 期待更新的标志版本
* newStamp : 将要更新的标志版本
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
// 获取当前的(元素值,版本号)对
Pair<V> current = pair;
return
// 引用没变
expectedReference == current.reference &&
// 版本号没变
expectedStamp == current.stamp &&
// 新引用等于旧引用
((newReference == current.reference &&
// 新版本号等于旧版本号
newStamp == current.stamp) ||
// 构造新的Pair对象并CAS更新
casPair(current, Pair.of(newReference, newStamp)));
}

private boolean casPair(Pair<V> cmp, Pair<V> val) {
// 调用Unsafe的compareAndSwapObject()方法CAS更新pair的引用为新引用
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
}
  • 如果元素值和版本号都没有变化,并且和新的也相同,返回true;
  • 如果元素值和版本号都没有变化,并且和新的不完全相同,就构造一个新的Pair对象并执行CAS更新pair。

可以看到,java中的实现跟我们上面讲的ABA的解决方法是一致的。

  • 首先,使用版本号控制;
  • 其次,不重复使用节点(Pair)的引用,每次都新建一个新的Pair来作为CAS比较的对象,而不是复用旧的;
  • 最后,外部传入元素值及版本号,而不是节点(Pair)的引用。
2、使用举例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static AtomicStampedReference<Integer> atomicStampedRef =
new AtomicStampedReference<>(1, 0);
public static void main(String[] args){
Thread main = new Thread(() -> {
System.out.println("操作线程" + Thread.currentThread() +",初始值 a = " + atomicStampedRef.getReference());
int stamp = atomicStampedRef.getStamp(); //获取当前标识别
try {
Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean isCASSuccess = atomicStampedRef.compareAndSet(1,2,stamp,stamp +1); //此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败
System.out.println("操作线程" + Thread.currentThread() +",CAS操作结果: " + isCASSuccess);
},"主操作线程");

Thread other = new Thread(() -> {
Thread.yield(); // 确保thread-main 优先执行
atomicStampedRef.compareAndSet(1,2,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【increment】 ,值 = "+ atomicStampedRef.getReference());
atomicStampedRef.compareAndSet(2,1,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【decrement】 ,值 = "+ atomicStampedRef.getReference());
},"干扰线程");

main.start();
other.start();
}
1
2
3
4
5
// 输出
> 操作线程Thread[主操作线程,5,main],初始值 a = 2
> 操作线程Thread[干扰线程,5,main],【increment】 ,值 = 2
> 操作线程Thread[干扰线程,5,main],【decrement】 ,值 = 1
> 操作线程Thread[主操作线程,5,main],CAS操作结果: false
3、java中还有哪些类可以解决ABA问题?

AtomicMarkableReference,它不是维护一个版本号,而是维护一个boolean类型的标记,标记值有修改,了解一下。

4、在日常的业务中怎么解决ABA问题?(用乐观锁的方法)
  • 加标志位做版本号,例如搞个自增的字段,操作一次就自增加一;
  • 加个时间戳,比较时间戳的值

3、JUC锁:LockSupport详解

LockSupport是锁中的基础,是一个提供锁机制的工具类。

1、BAT大厂的面试问题

  • 为什么LockSupport也是核心基础类?
    • AQS框架借助于两个类:Unsafe(提供CAS操作)LockSupport(提供park/unpark操作)
  • 写出分别通过wait/notify和LockSupport的park/unpark实现同步?
  • LockSupport.park()会释放锁资源吗?那么Condition.await()呢?
  • Thread.sleep()、Object.wait()、Condition.await()、LockSupport.park()的区别?重点
  • 如果在wait()之前执行了notify()会怎样?
  • 如果在park()之前执行了unpark()会怎样?

2、LockSupport简介

LockSupport用来创建锁和其他同步类的基本线程阻塞原语。简而言之,当调用LockSupport.park时,表示当前线程将会等待,直至获得许可,当调用LockSupport.unpark时,必须把等待获得许可的线程作为参数进行传递,好让此线程继续运行。

3、LockSupport源码分析

1、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class LockSupport {
// Hotspot implementation via intrinsics API
private static final sun.misc.Unsafe UNSAFE;
// 表示内存偏移地址
private static final long parkBlockerOffset;
// 表示内存偏移地址
private static final long SEED;
// 表示内存偏移地址
private static final long PROBE;
// 表示内存偏移地址
private static final long SECONDARY;

static {
try {
// 获取Unsafe实例
UNSAFE = sun.misc.Unsafe.getUnsafe();
// 线程类类型
Class<?> tk = Thread.class;
// 获取Thread的parkBlocker字段的内存偏移地址
parkBlockerOffset = UNSAFE.objectFieldOffset
(tk.getDeclaredField("parkBlocker"));
// 获取Thread的threadLocalRandomSeed字段的内存偏移地址
SEED = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSeed"));
// 获取Thread的threadLocalRandomProbe字段的内存偏移地址
PROBE = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomProbe"));
// 获取Thread的threadLocalRandomSecondarySeed字段的内存偏移地址
SECONDARY = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSecondarySeed"));
} catch (Exception ex) { throw new Error(ex); }
}
}

说明:UNSAFE字段表示sun.misc.Unsafe类,查看其源码,点击在这里,一般程序中不允许直接调用,而long型的表示实例对象相应字段在内存中的偏移地址,可以通过该偏移地址获取或者设置该字段的值。

2、类的构造函数
1
2
// 私有构造函数,无法被实例化
private LockSupport() {}

说明:LockSupport只有一个私有构造函数,无法被实例化。

3、核心函数分析

在分析LockSupport函数之前,先引入sun.misc.Unsafe类中的park和unpark函数,因为LockSupport的核心函数都是基于Unsafe类中定义的park和unpark函数,下面给出两个函数的定义:

1
2
public native void park(boolean isAbsolute, long time);
public native void unpark(Thread thread);

说明:对两个函数的说明如下:

  • park函数,阻塞线程,并且该线程在下列情况发生之前都会被阻塞:
    1. 调用unpark函数,释放该线程的许可。
    2. 该线程被中断。
    3. 设置的时间到了。并且,当time为绝对时间时,isAbsolute为true,否则,isAbsolute为false。当time为0时,表示无限等待,直到unpark发生。
  • unpark函数,释放线程的许可,即激活调用park后阻塞的线程。这个函数不是安全的,调用这个函数时要确保线程依旧存活。
1、park函数

park函数有两个重载版本,方法摘要如下:

1
2
public static void park()
public static void park(Object blocker)

说明:两个函数的区别在于park()函数没有没有blocker,即没有设置线程的parkBlocker字段。park(Object)型函数如下:

1
2
3
4
5
6
7
8
9
10
public static void park(Object blocker) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可
UNSAFE.park(false, 0L);
// 重新可运行后再此设置Blocker
setBlocker(t, null);
}

说明:调用park函数时,首先获取当前线程,然后设置当前线程的parkBlocker字段,即调用setBlocker函数,之后调用Unsafe类的park函数,之后再调用setBlocker函数。

那么问题来了,为什么要在此park函数中要调用两次setBlocker函数呢?

原因其实很简单,调用park函数时,当前线程首先设置好parkBlocker字段,然后再调用Unsafe的park函数,此后,当前线程就已经阻塞了,等待该线程的unpark函数被调用,所以后面的一个setBlocker函数无法运行,unpark函数被调用,该线程获得许可后,就可以继续运行了,也就运行第二个setBlocker,把该线程的parkBlocker字段设置为null,这样就完成了整个park函数的逻辑。

如果没有第二个setBlocker,那么之后没有调用park(Object blocker),而直接调用getBlocker函数,得到的还是前一个park(Object blocker)设置的blocker,显然是不符合逻辑的。总之,必须要保证在park(Object blocker)整个函数执行完后,该线程的parkBlocker字段又恢复为null。所以,park(Object)型函数里必须要调用setBlocker函数两次。

setBlocker方法如下:

1
2
3
4
private static void setBlocker(Thread t, Object arg) {
// 设置线程t的parkBlocker字段的值为arg
UNSAFE.putObject(t, parkBlockerOffset, arg);
}

说明:此方法用于设置线程t的parkBlocker字段的值为arg。

另外一个无参重载版本,park()函数如下:

1
2
3
4
public static void park() {
// 获取许可,设置时间为无限长,直到可以获取许可
UNSAFE.park(false, 0L);
}

说明:调用了park函数后,会禁用当前线程,除非许可可用。在以下三种情况之一发生之前,当前线程都将处于休眠状态,即下列情况发生时,当前线程会获取许可,可以继续运行:

  • 其他某个线程将当前线程作为目标调用 unpark。
  • 其他某个线程中断当前线程。
  • 该调用不合逻辑地(即毫无理由地)返回。
2、parkNanos函数

此函数表示在许可可用前禁用当前线程,并最多等待指定的等待时间。具体函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static void parkNanos(Object blocker, long nanos) {
if (nanos > 0) { // 时间大于0
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可,并设置了时间
UNSAFE.park(false, nanos);
// 设置许可
setBlocker(t, null);
}
}

说明:该函数也是调用了两次setBlocker函数,nanos参数表示相对时间,表示等待多长时间。

3、parkUntil函数

此函数表示在指定的时限前禁用当前线程,除非许可可用,具体函数如下:

1
2
3
4
5
6
7
8
9
public static void parkUntil(Object blocker, long deadline) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
UNSAFE.park(true, deadline);
// 设置Blocker为null
setBlocker(t, null);
}

说明:该函数也调用了两次setBlocker函数,deadline参数表示绝对时间,表示指定的时间。

4、unpark函数

此函数表示如果给定线程的许可尚不可用,则使其可用。如果线程在 park 上受阻塞,则它将解除其阻塞状态。否则,保证下一次调用 park 不会受阻塞。如果给定线程尚未启动,则无法保证此操作有任何效果。具体函数如下:

1
2
3
4
public static void unpark(Thread thread) {
if (thread != null) // 线程为不空
UNSAFE.unpark(thread); // 释放该线程许可
}

说明:释放许可,指定线程可以继续运行。

4、park/unpark 原理

每个线程都有自己的一个 Parker 对象(有C++编写),由三部分组成 _counter_cond_mutex 打个比喻

  • 线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
  • 调用 park 就是要看需不需要停下来歇息
    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用 unpark,就好比令干粮充足
    • 如果这时线程还在帐篷,就唤醒让他继续前进
    • 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
      • 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
1、先调用park()

image-20210806000710338

  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
  3. 线程进入 _cond 条件变量阻塞
  4. 设置 _counter = 0
2、再调用unpark():

image-20210806000820108

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 唤醒 _cond 条件变量中的 Thread_0
  3. Thread_0 恢复运行
  4. 设置 _counter 为 0
3、先调用unpark(),再调用park():

image-20210806000922500

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 当前线程调用 Unsafe.park() 方法
  3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
  4. 设置 _counter 为 0

5、LockSupport示例说明

1、使用wait/notify实现线程同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyThread extends Thread {
@Override
public void run() {
synchronized (this) {
System.out.println("before notify");
notify();
System.out.println("after notify");
}
}
}

public class WaitAndNotifyDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
synchronized (myThread) {
try {
myThread.start();
// 主线程睡眠3s
Thread.sleep(3000);
System.out.println("before wait");
// 阻塞主线程
myThread.wait();
System.out.println("after wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

运行结果:

1
2
3
4
before wait
before notify
after notify
after wait

说明:具体的流程图如下:

img

使用wait/notify实现同步时,必须先调用wait,后调用notify,如果先调用notify,再调用wait,将起不了作用。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyThread extends Thread {
@Override
public void run() {
synchronized (this) {
System.out.println("before notify");
notify();
System.out.println("after notify");
}
}
}

public class WaitAndNotifyDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
myThread.start();
// 主线程睡眠3s
Thread.sleep(3000);
synchronized (myThread) {
try {
System.out.println("before wait");
// 阻塞主线程
myThread.wait();
System.out.println("after wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

运行结果:

1
2
3
before notify
after notify
before wait

说明:由于先调用了notify,再调用的wait,此时主线程还是会一直阻塞。

2、使用park/unpark实现线程同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before unpark");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));
// 释放许可
LockSupport.unpark((Thread) object);
// 休眠500ms,保证先执行park中的setBlocker(t, null);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 再次获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));

System.out.println("after unpark");
}
}

public class test {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
5
6
before park
before unpark
Blocker info ParkAndUnparkDemo
after park
Blocker info null
after unpark

说明:本程序先执行park,然后在执行unpark,进行同步,并且在unpark的前后都调用了getBlocker,可以看到两次的结果不一样,并且第二次调用的结果为null,这是因为在调用unpark之后,执行了Lock.park(Object blocker)函数中的setBlocker(t, null)函数,所以第二次调用getBlocker时为null。

上例是先调用park,然后调用unpark,现在修改程序,先调用unpark,然后调用park,看能不能正确同步。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before unpark");
// 释放许可
LockSupport.unpark((Thread) object);
System.out.println("after unpark");
}
}

public class ParkAndUnparkDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
try {
// 主线程睡眠3s
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
before unpark
after unpark
before park
after park

说明:可以看到,在先调用unpark,再调用park时,仍能够正确实现同步,不会造成由wait/notify调用顺序不当所引起的阻塞。因此park/unpark相比wait/notify更加的灵活。

3、中断响应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before interrupt");
try {
// 休眠3s
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread thread = (Thread) object;
// 中断线程
thread.interrupt();
System.out.println("after interrupt");
}
}

public class InterruptDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
before park
before interrupt
after interrupt
after park

说明:可以看到,在主线程调用park阻塞后,在myThread线程中发出了中断信号,此时主线程会继续运行,也就是说明此时interrupt起到的作用与unpark一样。

6、更深入的理解

1、Thread.sleep()和Object.wait()的区别

首先,我们先来看看Thread.sleep()和Object.wait()的区别,这是一个烂大街的题目了,大家应该都能说上来两点:

  • Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁;
  • Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去;
  • Thread.sleep()到时间了会自动唤醒,然后继续执行;
  • Object.wait()不带时间的,需要另一个线程使用Object.notify()唤醒;
  • Object.wait()带时间的,假如没有被notify,到时间了会自动唤醒,这时又分好两种情况,一是立即获取到了锁,线程自然会继续执行;二是没有立即获取锁,线程进入同步队列等待获取锁;

其实,他们俩最大的区别就是Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。

2、Thread.sleep()和Condition.await()的区别

Object.wait()和Condition.await()的原理是基本一致的,不同的是Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。

实际上,它在阻塞当前线程之前还干了两件事:

  1. 一是把当前线程添加到条件队列中
  2. 二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()阻塞当前线程。
3、Thread.sleep()和LockSupport.park()的区别

LockSupport.park()还有几个兄弟方法——parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。

  • 从功能上来说,Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
  • Thread.sleep()没法从外部唤醒,只能自己醒过来;
  • LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒;
  • Thread.sleep()方法声明上抛出了InterruptedException中断异常,所以调用者需要捕获这个异常或者再抛出;
  • LockSupport.park()方法不需要捕获中断异常;
  • Thread.sleep()本身就是一个native方法;
  • LockSupport.park()底层是调用的Unsafe的native方法;
4、Object.wait()和LockSupport.park()的区别

二者都会阻塞当前线程的运行,他们有什么区别呢? 经过上面的分析相信你一定很清楚了,真的吗? 往下看!

  • Object.wait()方法需要在synchronized块中执行;
  • LockSupport.park()可以在任意地方执行;
  • Object.wait()方法声明抛出了中断异常,调用者需要捕获或者再抛出;
  • LockSupport.park()不需要捕获中断异常;
  • Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容;
  • LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容;
  • 如果在wait()之前执行了notify()会怎样? 抛出IllegalMonitorStateException异常;
  • 如果在park()之前执行了unpark()会怎样? 线程不会被阻塞,直接跳过park(),继续执行后续内容;

park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。

5、LockSupport.park()会释放锁资源吗?

不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。

4、AbstractQueuedSynchronizer(AQS)

AbstractQueuedSynchronizer抽象类是核心,需要重点掌握。它提供了一个基于FIFO队列,可以用于构建锁或者其他相关同步装置的基础框架。

1、BAT大厂的面试问题

  • 什么是AQS?为什么它是核心?
  • AQS的核心思想是什么?它是怎么实现的?底层数据结构等
  • AQS有哪些核心的方法?
  • AQS定义什么样的资源获取方式?
    • AQS定义了两种资源获取方式:
      • 独占(只有一个线程能访问执行,又根据是否按队列的顺序分为公平锁非公平锁,如ReentrantLock)
      • 共享(多个线程可同时访问执行,如SemaphoreCountDownLatchCyclicBarrier )。ReentrantReadWriteLock可以看成是组合式,允许多个线程同时对某一资源进行读。
  • AQS底层使用了什么样的设计模式?
    • 模板
  • AQS的应用示例?

2、AbstractQueuedSynchronizer简介

AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

1、AQS核心思想

AQS核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。

AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

1
private volatile int state;//共享变量,使用volatile修饰保证线程可见性

状态信息通过protected类型的getState,setState,compareAndSetState进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
2、AQS对资源的共享方式

AQS定义两种资源共享方式:

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。
    • 又可分为公平锁和非公平锁:
      • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
      • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的(抢不到就乖乖排队吧)
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。

3、AQS底层使用了模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用)

使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放) 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。这和我们以往通过实现接口的方式有很大区别。

AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:

1
2
3
4
5
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。

以ReentrantLock为例:

  1. state初始化为0,表示未锁定状态。
  2. A线程lock()时,会调用tryAcquire()独占该锁并将state+1。
  3. 此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。
  4. 当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
4、总结

AbstractQueuedSynchronizer是阻塞式锁和相关的同步器工具的框架,特点:

  • 用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁
    • getState - 获取 state 状态
    • setState - 设置 state 状态
    • compareAndSetState - cas 机制设置 state 状态
    • 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
  • 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
  • 子类主要实现这样一些方法(默认抛出 UnsupportedOperationException
    • tryAcquire
    • tryRelease
    • tryAcquireShared
    • tryReleaseShared
    • isHeldExclusively

3、AbstractQueuedSynchronizer数据结构

AbstractQueuedSynchronizer类底层的数据结构是使用CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配

  • 其中Sync queue,即同步队列,是双向链表,包括head结点和tail结点,head结点主要用作后续的调度
  • Condition queue不是必须的,其是一个单向链表,只有当使用Condition时,才会存在此单向链表。并且可能会有多个Condition queue

image

4、AbstractQueuedSynchronizer源码分析

1、类的继承关系

AbstractQueuedSynchronizer继承自AbstractOwnableSynchronizer抽象类,并且实现了Serializable接口,可以进行序列化。

1
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable

其中AbstractOwnableSynchronizer抽象类的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {

// 版本序列号
private static final long serialVersionUID = 3737899427754241961L;
// 构造方法
protected AbstractOwnableSynchronizer() { }
// 独占模式下的线程
private transient Thread exclusiveOwnerThread;

// 设置独占线程
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}

// 获取独占线程
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
}

AbstractOwnableSynchronizer抽象类中,可以设置独占资源线程和获取独占资源线程。分别为setExclusiveOwnerThread与getExclusiveOwnerThread方法,这两个方法会被子类调用。

2、类的内部类

AbstractQueuedSynchronizer类有两个内部类,分别为Node类与ConditionObject类。

3、类的内部类——Node类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
static final class Node {
// 模式,分为共享与独占
// 共享模式
static final Node SHARED = new Node();
// 独占模式
static final Node EXCLUSIVE = null;
// 结点状态
// CANCELLED,值为1,表示当前的线程被取消
// SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark
// CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列中
// PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行
// 值为0,表示当前节点在sync队列中,等待着获取锁
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;

// 结点状态
volatile int waitStatus;
// 前驱结点
volatile Node prev;
// 后继结点
volatile Node next;
// 结点所对应的线程
volatile Thread thread;
// 下一个等待者
Node nextWaiter;

// 结点是否在共享模式下等待
final boolean isShared() {
return nextWaiter == SHARED;
}

// 获取前驱结点,若前驱结点为空,抛出异常
final Node predecessor() throws NullPointerException {
// 保存前驱结点
Node p = prev;
if (p == null) // 前驱结点为空,抛出异常
throw new NullPointerException();
else // 前驱结点不为空,返回
return p;
}

// 无参构造方法
Node() { // Used to establish initial head or SHARED marker
}

// 构造方法
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}

// 构造方法
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}

每个线程被阻塞的线程都会被封装成一个Node结点,放入队列。每个节点包含了一个Thread类型的引用,并且每个节点都存在一个状态,具体状态如下:

  • CANCELLED,值为1,表示当前的线程被取消。
  • SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,需要进行unpark操作。
  • CONDITION,值为-2,表示当前节点在等待condition,也就是在condition queue中。
  • PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行。
  • 值为0,表示当前节点在sync queue中,等待着获取锁。
4、类的内部类——ConditionObject类

这个类有点长,耐心看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
// 内部类
public class ConditionObject implements Condition, java.io.Serializable {
// 版本号
private static final long serialVersionUID = 1173984872572414699L;
/** First node of condition queue. */
// condition队列的头结点
// 第一个等待节点
private transient Node firstWaiter;
/** Last node of condition queue. */
// condition队列的尾结点
// 最后一个等待节点
private transient Node lastWaiter;

/**
* Creates a new {@code ConditionObject} instance.
*/
// 构造方法
public ConditionObject() { }

// Internal methods

/**
* Adds a new waiter to wait queue.
* @return its new wait node
*/
// 添加新的Node(waiter)到wait队列
private Node addConditionWaiter() {
// 保存尾结点
Node t = lastWaiter;
// If lastWaiter is cancelled, clean out.
// 所有已取消的 Node 从队列链表删除
if (t != null && t.waitStatus != Node.CONDITION) { // 尾结点不为空,并且尾结点的状态不为CONDITION
// 清除状态为CONDITION的结点
unlinkCancelledWaiters();
// 将最后一个结点重新赋值给t
t = lastWaiter;
}
// 创建一个关联当前线程的新 Node, 添加至队列尾部
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null) // 尾结点为空
// 设置condition队列的头结点
firstWaiter = node;
else // 尾结点不为空
// 设置为节点的nextWaiter域为node结点
t.nextWaiter = node;
// 更新condition队列的尾结点
lastWaiter = node;
return node;
}

/**
* Removes and transfers nodes until hit non-cancelled one or
* null. Split out from signal in part to encourage compilers
* to inline the case of no waiters.
* @param first (non-null) the first node on condition queue
*/
// 唤醒 - 将没取消的第一个节点转移至 AQS 队列
private void doSignal(Node first) {
// 循环
do {
// 已经是尾节点了
if ( (firstWaiter = first.nextWaiter) == null) // 该节点的nextWaiter为空
// 设置尾结点为空
lastWaiter = null;
// 设置first结点的nextWaiter域
first.nextWaiter = null;
} while (// 将结点从condition队列转移到sync队列失败并且condition队列中的头结点不为空,一直循环
// 将等待队列中的 Node 转移至 AQS 队列, 不成功且还有节点则继续循环
!transferForSignal(first) &&
// 队列还有节点
(first = firstWaiter) != null);
}

/**
* Removes and transfers all nodes.
* @param first (non-null) the first node on condition queue
*/
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}

/**
* Unlinks cancelled waiter nodes from condition queue.
* Called only while holding lock. This is called when
* cancellation occurred during condition wait, and upon
* insertion of a new waiter when lastWaiter is seen to have
* been cancelled. This method is needed to avoid garbage
* retention in the absence of signals. So even though it may
* require a full traversal, it comes into play only when
* timeouts or cancellations occur in the absence of
* signals. It traverses all nodes rather than stopping at a
* particular target to unlink all pointers to garbage nodes
* without requiring many re-traversals during cancellation
* storms.
*/
// 从condition队列中清除状态为CANCEL的结点
private void unlinkCancelledWaiters() {
// 保存condition队列头结点
Node t = firstWaiter;
Node trail = null;
while (t != null) { // t不为空
// 下一个结点
Node next = t.nextWaiter;
if (t.waitStatus != Node.CONDITION) { // t结点的状态不为CONDTION状态
// 设置t节点的nextWaiter域为空
t.nextWaiter = null;
if (trail == null) // trail为空
// 重新设置condition队列的头结点
firstWaiter = next;
else // trail不为空
// 设置trail结点的nextWaiter域为next结点
trail.nextWaiter = next;
if (next == null) // next结点为空
// 设置condition队列的尾结点
lastWaiter = trail;
}
else // t结点的状态为CONDTION状态
// 设置trail结点
trail = t;
// 设置t结点
t = next;
}
}

// public methods

/**
* Moves the longest-waiting thread, if one exists, from the
* wait queue for this condition to the wait queue for the
* owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
// 唤醒 - 必须持有锁才能唤醒, 因此 doSignal 内无需考虑加锁
public final void signal() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒一个等待线程
doSignal(first);
}

/**
* Moves all threads from the wait queue for this condition to
* the wait queue for the owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
// 全部唤醒 - 必须持有锁才能唤醒, 因此 doSignalAll 内无需考虑加锁
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}

/**
* Implements uninterruptible condition wait.
* <ol>
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* </ol>
*/
// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
// 不可打断等待 - 直到被唤醒
public final void awaitUninterruptibly() {
// 添加一个结点到等待队列
Node node = addConditionWaiter();
// 获取释放的状态,释放节点持有的锁
int savedState = fullyRelease(node);
boolean interrupted = false;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) { // 判断当前结点在不在同步队列之中
// 阻塞当前线程
LockSupport.park(this);
// 如果被打断, 仅设置打断状态
if (Thread.interrupted()) // 当前线程被中断
// 设置interrupted状态
interrupted = true;
}
// 唤醒后, 尝试竞争锁, 如果失败进入 AQS 队列
if (acquireQueued(node, savedState) || interrupted) //
selfInterrupt();
}

/*
* For interruptible waits, we need to track whether to throw
* InterruptedException, if interrupted while blocked on
* condition, versus reinterrupt current thread, if
* interrupted while blocked waiting to re-acquire.
*/

/** Mode meaning to reinterrupt on exit from wait */
// 打断模式 - 在退出等待时重新设置打断状态
private static final int REINTERRUPT = 1;
/** Mode meaning to throw InterruptedException on exit from wait */
// 打断模式 - 在退出等待时抛出异常
private static final int THROW_IE = -1;

/**
* Checks for interrupt, returning THROW_IE if interrupted
* before signalled, REINTERRUPT if after signalled, or
* 0 if not interrupted.
*/
// 判断打断模式
private int checkInterruptWhileWaiting(Node node) {
return Thread.interrupted() ?
(transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
0;
}

/**
* Throws InterruptedException, reinterrupts current thread, or
* does nothing, depending on mode.
*/
// 应用打断模式
private void reportInterruptAfterWait(int interruptMode)
throws InterruptedException {
if (interruptMode == THROW_IE)
throw new InterruptedException();
else if (interruptMode == REINTERRUPT)
selfInterrupt();
}

/**
* Implements interruptible condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled or interrupted.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// 等待,当前线程在接到信号或被中断之前一直处于等待状态
// 等待 - 直到被唤醒或打断
public final void await() throws InterruptedException {
if (Thread.interrupted()) // 当前线程被中断,抛出异常
throw new InterruptedException();
// 在wait队列上添加一个结点
// 添加一个 Node 至等待队列
Node node = addConditionWaiter();
// 获取释放的状态
// 释放节点持有的锁
int savedState = fullyRelease(node);
int interruptMode = 0;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) {
// 阻塞当前线程
LockSupport.park(this);
// 如果被打断, 退出等待队列
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) // 检查结点等待时的中断类型
break;
}
// 退出等待队列后, 还需要获得 AQS 队列的锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
// 所有已取消的 Node 从队列链表删除
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
// 应用打断模式
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}

/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
// 等待 - 直到被唤醒或打断或超时
public final long awaitNanos(long nanosTimeout)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
// 添加一个 Node 至等待队列
Node node = addConditionWaiter();
// 释放节点持有的锁
int savedState = fullyRelease(node);
// 获得最后期限
final long deadline = System.nanoTime() + nanosTimeout;
int interruptMode = 0;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) {
// 已超时, 退出等待队列
if (nanosTimeout <= 0L) {
transferAfterCancelledWait(node);
break;
}
// park 阻塞一定时间, spinForTimeoutThreshold 为 1000 ns
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
// 如果被打断, 退出等待队列
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
// 退出等待队列后, 还需要获得 AQS 队列的锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
// 所有已取消的 Node 从队列链表删除
if (node.nextWaiter != null)
unlinkCancelledWaiters();
// 应用打断模式
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return deadline - System.nanoTime();
}

/**
* Implements absolute timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
// 等待 - 直到被唤醒或打断或超时, 逻辑类似于 awaitNanos
public final boolean awaitUntil(Date deadline)
throws InterruptedException {
long abstime = deadline.getTime();
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (System.currentTimeMillis() > abstime) {
timedout = transferAfterCancelledWait(node);
break;
}
LockSupport.parkUntil(this, abstime);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}

/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
// 等待 - 直到被唤醒或打断或超时, 逻辑类似于 awaitNanos
public final boolean await(long time, TimeUnit unit)
throws InterruptedException {
long nanosTimeout = unit.toNanos(time);
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
final long deadline = System.nanoTime() + nanosTimeout;
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (nanosTimeout <= 0L) {
timedout = transferAfterCancelledWait(node);
break;
}
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}

// support for instrumentation

/**
* Returns true if this condition was created by the given
* synchronization object.
*
* @return {@code true} if owned
*/
final boolean isOwnedBy(AbstractQueuedSynchronizer sync) {
return sync == AbstractQueuedSynchronizer.this;
}

/**
* Queries whether any threads are waiting on this condition.
* Implements {@link AbstractQueuedSynchronizer#hasWaiters(ConditionObject)}.
*
* @return {@code true} if there are any waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 查询是否有正在等待此条件的任何线程
protected final boolean hasWaiters() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
return true;
}
return false;
}

/**
* Returns an estimate of the number of threads waiting on
* this condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitQueueLength(ConditionObject)}.
*
* @return the estimated number of waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回正在等待此条件的线程数估计值
protected final int getWaitQueueLength() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int n = 0;
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
++n;
}
return n;
}

/**
* Returns a collection containing those threads that may be
* waiting on this Condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitingThreads(ConditionObject)}.
*
* @return the collection of threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回包含那些可能正在等待此条件的线程集合
protected final Collection<Thread> getWaitingThreads() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
ArrayList<Thread> list = new ArrayList<Thread>();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION) {
Thread t = w.thread;
if (t != null)
list.add(t);
}
}
return list;
}
}

此类实现了Condition接口,Condition接口定义了条件操作规范,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface Condition {

// 等待,当前线程在接到信号或被中断之前一直处于等待状态
void await() throws InterruptedException;

// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
void awaitUninterruptibly();

//等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
long awaitNanos(long nanosTimeout) throws InterruptedException;

// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
boolean await(long time, TimeUnit unit) throws InterruptedException;

// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
boolean awaitUntil(Date deadline) throws InterruptedException;

// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
void signal();

// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
void signalAll();
}

Condition接口中定义了await、signal方法,用来等待条件、释放条件。之后会详细分析CondtionObject的源码。

5、类的属性

属性中包含了头结点head尾结点tail状态state自旋时间spinForTimeoutThreshold,还有AbstractQueuedSynchronizer抽象的属性在内存中的偏移地址,通过该偏移地址,可以获取和设置该属性的值,同时还包括一个静态初始化块,用于加载内存偏移地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = 7373984972572414691L;
// 头结点
private transient volatile Node head;
// 尾结点
private transient volatile Node tail;
// 状态
private volatile int state;
// 自旋时间
static final long spinForTimeoutThreshold = 1000L;

// Unsafe类实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
// state内存偏移地址
private static final long stateOffset;
// head内存偏移地址
private static final long headOffset;
// state内存偏移地址
private static final long tailOffset;
// tail内存偏移地址
private static final long waitStatusOffset;
// next内存偏移地址
private static final long nextOffset;
// 静态初始化块
static {
try {
stateOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
headOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
tailOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
waitStatusOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("waitStatus"));
nextOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("next"));
} catch (Exception ex) { throw new Error(ex); }
}
}
6、类的构造方法

此类构造方法为从抽象构造方法,供子类调用:

1
protected AbstractQueuedSynchronizer() {}
7、类的核心方法——acquire方法

该方法以独占模式获取(资源),忽略中断,即线程在aquire过程中,中断此线程是无效的。源码如下:

1
2
3
4
5
6
7
8
9
10
public final void acquire(int arg) {
// 当 tryAcquire 返回为 false 时, 先调用AQS的addWaiter , 接着 acquireQueued
if (
// 尝试获得写锁失败
!tryAcquire(arg) &&
// 将当前线程关联到一个 Node 对象上, 模式为独占模式
// 进入 AQS 队列阻塞
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

由上述源码可以知道,当一个线程调用acquire时,调用方法流程如下:

java-thread-x-juc-aqs-2

  • 首先调用tryAcquire方法,调用此方法的线程会试图在独占模式下获取对象状态。此方法应该查询是否允许它在独占模式下获取对象状态,如果允许,则获取它。在AbstractQueuedSynchronizer源码中默认会抛出一个异常,即需要子类去重写此方法完成自己的逻辑。之后会进行分析。
  • 若tryAcquire失败,则调用addWaiter方法,addWaiter方法完成的功能是将调用此方法的线程封装成为一个结点并放入Sync queue
  • 调用acquireQueued方法,此方法完成的功能是Sync queue中的结点不断尝试获取资源,若成功,则返回true,否则,返回false
  • 由于tryAcquire默认实现是抛出异常,所以此时,不进行分析,之后会结合一个例子进行分析。

首先分析addWaiter方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 添加等待者
private Node addWaiter(Node mode) {
// 将当前线程关联到一个 Node 对象上, 模式为独占模式
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// 如果 tail 不为 null, cas 尝试将 Node 对象加入 AQS 队列尾部
Node pred = tail;
if (pred != null) { // 尾结点不为空,即已经被初始化
// 将node结点的prev域连接到尾结点
node.prev = pred;
if (compareAndSetTail(pred, node)) { // 比较pred是否为尾结点,是则将尾结点设置为node
// 双向链表
// 设置尾结点的next域为node
pred.next = node;
return node; // 返回新生成的结点
}
}
// 尝试将 Node 加入 AQS
enq(node); // 尾结点为空(即还没有被初始化过),或者是compareAndSetTail操作失败,则入队列
return node;
}

addWaiter方法使用快速添加的方式往sync queue尾部添加结点,如果sync queue队列还没有初始化,则会使用enq插入队列中,enq方法源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
// 还没有, 设置 head 为哨兵节点(不对应线程,状态为 0)
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
// cas 尝试将 Node 对象加入 AQS 队列尾部
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}

enq方法会使用无限循环来确保节点的成功插入

现在,分析acquireQueue方法。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// sync队列中的结点在独占且忽略中断的模式下获取(资源)
final boolean acquireQueued(final Node node, int arg) {
// 标志
boolean failed = true;
try {
// 中断标志
boolean interrupted = false;
for (;;) { // 无限循环
// 获取node节点的前驱结点
final Node p = node.predecessor();
// 上一个节点是 head, 表示轮到自己(当前线程对应的 node)了, 尝试获取
if (p == head && tryAcquire(arg)) { // 前驱为头结点并且成功获得锁
// 获取成功, 设置自己(当前线程对应的 node)为 head
setHead(node); // 设置头结点
// 上一个节点 help GC
p.next = null; // help GC
failed = false; // 设置标志
// 返回中断标记 false
return interrupted;
}
if (// 判断是否应当 park,
shouldParkAfterFailedAcquire(p, node) &&
// park 等待, 此时 Node 的状态被置为 Node.SIGNAL
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

首先获取当前节点的前驱节点,如果前驱节点是头结点并且能够获取(资源),代表该当前节点能够占有锁,设置头结点为当前节点,返回。否则,调用shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法,首先,我们看shouldParkAfterFailedAcquire方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 当获取(资源)失败后,检查并且更新结点状态
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱结点的状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) // 状态为SIGNAL,为-1
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
// 前驱节点都在阻塞, 那么自己也阻塞好了
// 可以进行park操作
return true;
// > 0 表示取消状态
if (ws > 0) { // 表示状态为CANCELLED,为1
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
// 上一个节点取消, 那么重构删除前面所有取消的节点, 返回到外层循环重试
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0); // 找到pred结点前面最近的一个状态不为CANCELLED的结点
// 赋值pred结点的next域
pred.next = node;
} else { // 为PROPAGATE -3 或者是0 表示无状态,(为CONDITION -2时,表示此节点在condition queue中)
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
// 这次还没有阻塞
// 但下次如果重试不成功, 则需要阻塞,这时需要设置上一个节点状态为 Node.SIGNAL
// 比较并设置前驱结点的状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
// 不能进行park操作
return false;
}

只有当该节点的前驱结点的状态为SIGNAL时,才可以对该结点所封装的线程进行park操作。否则,将不能进行park操作。再看parkAndCheckInterrupt方法,源码如下:

1
2
3
4
5
6
// 进行park操作并且返回该线程是否被中断
private final boolean parkAndCheckInterrupt() {
// 在许可可用之前禁用当前线程,并且设置了blocker
LockSupport.park(this);
return Thread.interrupted(); // 当前线程是否已被中断,并清除中断标记位
}

parkAndCheckInterrupt方法里的逻辑是首先执行park操作,即禁用当前线程,然后返回该线程是否已经被中断。再看final块中的cancelAcquire方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 取消继续获取(资源)
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
// node为空,返回
if (node == null)
return;
// 设置node结点的thread为空
node.thread = null;

// Skip cancelled predecessors
// 保存node的前驱结点
Node pred = node.prev;
while (pred.waitStatus > 0) // 找到node前驱结点中第一个状态小于0的结点,即不为CANCELLED状态的结点
node.prev = pred = pred.prev;

// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
// 获取pred结点的下一个结点
Node predNext = pred.next;

// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
// 设置node结点的状态为CANCELLED
node.waitStatus = Node.CANCELLED;

// If we are the tail, remove ourselves.
if (node == tail && compareAndSetTail(node, pred)) { // node结点为尾结点,则设置尾结点为pred结点
// 比较并设置pred结点的next节点为null
compareAndSetNext(pred, predNext, null);
} else { // node结点不为尾结点,或者比较设置不成功
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) { // (pred结点不为头结点,并且pred结点的状态为SIGNAL)或者
// pred结点状态小于等于0,并且比较并设置等待状态为SIGNAL成功,并且pred结点所封装的线程不为空
// 保存结点的后继
Node next = node.next;
if (next != null && next.waitStatus <= 0) // 后继不为空并且后继的状态小于等于0
compareAndSetNext(pred, predNext, next); // 比较并设置pred.next = next;
} else {
unparkSuccessor(node); // 释放node的前一个结点
}

node.next = node; // help GC
}
}

该方法完成的功能就是取消当前线程对资源的获取,即设置该结点的状态为CANCELLED,接着我们再看unparkSuccessor方法,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 释放后继结点
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
// 获取node结点的等待状态
// 如果状态为 Node.SIGNAL 尝试重置状态为 0
// 不成功也可以
int ws = node.waitStatus;
if (ws < 0) // 状态值小于0,为SIGNAL -1 或 CONDITION -2 或 PROPAGATE -3
// 比较并且设置结点等待状态,设置为0
compareAndSetWaitStatus(node, ws, 0);

/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
// 获取node节点的下一个结点
// 找到需要 unpark 的节点, 但本节点从 AQS 队列中脱离, 是由唤醒节点完成的
Node s = node.next;
// 不考虑已取消的节点, 从 AQS 队列从后至前找到队列最前面需要 unpark 的节点
if (s == null || s.waitStatus > 0) { // 下一个结点为空或者下一个节点的等待状态大于0,即为CANCELLED
// s赋值为空
s = null;
// 从尾结点开始从后往前开始遍历
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0) // 找到等待状态小于等于0的结点,找到最前的状态小于等于0的结点
// 保存结点
s = t;
}
if (s != null) // 该结点不为为空,释放许可
LockSupport.unpark(s.thread);
}

该方法的作用就是为了释放node节点的后继结点

对于cancelAcquire与unparkSuccessor方法,如下示意图可以清晰的表示:

image

其中node为参数,在执行完cancelAcquire方法后的效果就是unpark了s结点所包含的t4线程

现在,再来看acquireQueued方法的整个的逻辑。逻辑如下:

  1. 判断结点的前驱是否为head并且是否成功获取(资源)。
  2. 若步骤1均满足,则设置结点为head,之后会判断是否finally模块,然后返回。
  3. 若步骤2不满足,则判断是否需要park当前线程,是否需要park当前线程的逻辑是判断结点的前驱结点的状态是否为SIGNAL,若是,则park当前结点,否则,不进行park操作。
  4. 若park了当前线程,之后某个线程对本线程unpark后,并且本线程也获得机会运行。那么,将会继续进行步骤①的判断。

注意:

  • 是否需要 unpark 是由当前节点的前驱节点的 waitStatus == Node.SIGNAL 来决定,而不是本节点的 waitStatus 决定
8、类的核心方法——release方法

以独占模式释放对象,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final boolean release(int arg) {
// 尝试释放锁
if (tryRelease(arg)) { // 释放成功
// 保存头结点
// 队列头节点 unpark
Node h = head;
// 队列不为 null
// waitStatus == Node.SIGNAL 才需要 unpark
if (h != null && h.waitStatus != 0) // 头结点不为空并且头结点状态不为0
// unpark AQS 中等待的线程
unparkSuccessor(h); //释放头结点的后继结点
return true;
}
return false;
}

其中,tryRelease的默认实现是抛出异常,需要具体的子类实现,如果tryRelease成功,那么如果头结点不为空并且头结点的状态不为0,则释放头结点的后继结点,unparkSuccessor方法已经分析过,不再累赘。

除了release()方法之外,还有一个方法——fullyRelease()用来释放锁:因为某线程可能重入,需要将 state 全部释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 因为某线程可能重入,需要将 state 全部释放
final int fullyRelease(Node node) {
boolean failed = true;
try {
int savedState = getState(); if (release(savedState)) {
failed = false;
return savedState;
} else {
throw new IllegalMonitorStateException();
}
} finally {
if (failed)
node.waitStatus = Node.CANCELLED;
}
}

对于其他方法我们也可以分析,与前面分析的方法大同小异,所以,不再累赘。

5、AbstractQueuedSynchronizer示例详解一

借助下面示例来分析AbstractQueuedSyncrhonizer内部的工作机制。示例源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}

public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
} finally {
lock.unlock();
}
}
}
public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) {
Lock lock = new ReentrantLock();

MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
t1.start();
t2.start();
}
}

运行结果(可能的一种):

1
2
Thread[t1,5,main] running
Thread[t2,5,main] running

结果分析:从示例可知,线程t1与t2共用了一把锁,即同一个lock。可能会存在如下一种时序:

image

说明:首先线程t1先执行lock.lock操作,然后t2执行lock.lock操作,然后t1执行lock.unlock操作,最后t2执行lock.unlock操作。基于这样的时序,分析AbstractQueuedSynchronizer内部的工作机制:

  • t1线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:其中,前面的部分表示哪个类,后面是具体的类中的哪个方法,AQS表示AbstractQueuedSynchronizer类,AOS表示AbstractOwnableSynchronizer类。
  • t2线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:经过一系列的方法调用,最后达到的状态是禁用t2线程,因为调用了LockSupport.lock。
  • t1线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:t1线程中调用lock.unlock后,经过一系列的调用,最终的状态是释放了许可,因为调用了LockSupport.unpark。这时,t2线程就可以继续运行了。此时,会继续恢复t2线程运行环境,继续执行LockSupport.park后面的语句,即进一步调用如下:
    • image
    • 说明:在上一步调用了LockSupport.unpark后,t2线程恢复运行,则运行parkAndCheckInterrupt,之后,继续运行acquireQueued方法,最后达到的状态是头结点head与尾结点tail均指向了t2线程所在的结点,并且之前的头结点已经从sync队列中断开了。
  • t2线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:t2线程执行lock.unlock后,最终达到的状态还是与之前的状态一样。

6、AbstractQueuedSynchronizer示例详解二

下面我们结合Condition实现生产者与消费者,来进一步分析AbstractQueuedSynchronizer的内部工作机制。

Depot(仓库)类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Depot {
private int size;
private int capacity;
private Lock lock;
private Condition fullCondition;
private Condition emptyCondition;

public Depot(int capacity) {
this.capacity = capacity;
lock = new ReentrantLock();
fullCondition = lock.newCondition();
emptyCondition = lock.newCondition();
}

public void produce(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size >= capacity) {
System.out.println(Thread.currentThread() + " before await");
fullCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int inc = (left + size) > capacity ? (capacity - size) : left;
left -= inc;
size += inc;
System.out.println("produce = " + inc + ", size = " + size);
emptyCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}

public void consume(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size <= 0) {
System.out.println(Thread.currentThread() + " before await");
emptyCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int dec = (size - left) > 0 ? left : size;
left -= dec;
size -= dec;
System.out.println("consume = " + dec + ", size = " + size);
fullCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Consumer {
private Depot depot;
public Consumer(Depot depot) {
this.depot = depot;
}

public void consume(int no) {
new Thread(new Runnable() {
@Override
public void run() {
depot.consume(no);
}
}, no + " consume thread").start();
}
}

class Producer {
private Depot depot;
public Producer(Depot depot) {
this.depot = depot;
}

public void produce(int no) {
new Thread(new Runnable() {

@Override
public void run() {
depot.produce(no);
}
}, no + " produce thread").start();
}
}

public class ReentrantLockDemo {
public static void main(String[] args) throws InterruptedException {
Depot depot = new Depot(500);
new Producer(depot).produce(500);
new Producer(depot).produce(200);
new Consumer(depot).consume(500);
new Consumer(depot).consume(200);
}
}

运行结果(可能的一种):

1
2
3
4
5
6
7
8
produce = 500, size = 500
Thread[200 produce thread,5,main] before await
consume = 500, size = 0
Thread[200 consume thread,5,main] before await
Thread[200 produce thread,5,main] after await
produce = 200, size = 200
Thread[200 consume thread,5,main] after await
consume = 200, size = 0

说明:根据结果,我们猜测一种可能的时序如下:

image

说明:p1代表produce 500的那个线程,p2代表produce 200的那个线程,c1代表consume 500的那个线程,c2代表consume 200的那个线程。

  1. p1线程调用lock.lock,获得锁,继续运行,方法调用顺序在前面已经给出。
  2. p2线程调用lock.lock,由前面的分析可得到如下的最终状态:
    • java-thread-x-juc-aqs-11
    • 说明:p2线程调用lock.lock后,会禁止p2线程的继续运行,因为执行了LockSupport.park操作。
  3. c1线程调用lock.lock,由前面的分析得到如下的最终状态:
    • image
    • 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含p2的结点)的waitStatus变为了SIGNAL。
  4. c2线程调用lock.lock,由前面的分析得到如下的最终状态:
    • image
    • 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含c1的结点)的waitStatus变为了SIGNAL。
  5. p1线程执行emptyCondition.signal,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:AQS.CO表示AbstractQueuedSynchronizer.ConditionObject类。此时调用signal方法不会产生任何其他效果。
  6. p1线程执行lock.unlock,根据前面的分析可知,最终的状态如下:
    • image
    • 说明:此时,p2线程所在的结点为头结点,并且其他两个线程(c1、c2)依旧被禁止,所以,此时p2线程继续运行,执行用户逻辑。
  7. p2线程执行fullCondition.await,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:最终到达的状态是新生成了一个结点,包含了p2线程,此结点在condition queue中;并且sync queue中p2线程被禁止了,因为在执行了LockSupport.park操作。从方法一些调用可知,在await操作中线程会释放锁资源,供其他线程获取。同时,head结点后继结点的包含的线程的许可被释放了,故其可以继续运行。由于此时,只有c1线程可以运行,故运行c1。
  8. 继续运行c1线程,c1线程由于之前被park了,所以此时恢复,继续之前的步骤,即还是执行前面提到的acquireQueued方法,之后,c1判断自己的前驱结点为head,并且可以获取锁资源,最终到达的状态如下:
    • image
    • 说明:其中,head设置为包含c1线程的结点,c1继续运行。
  9. c1线程执行fullCondtion.signal,其方法调用顺序如下,只给出了主要的方法调用:
    • java-thread-x-juc-aqs-17
    • 说明:signal方法达到的最终结果是将包含p2线程的结点从condition queue中转移到sync queue中,之后condition queue为null,之前的尾结点的状态变为SIGNAL。
  10. c1线程执行lock.unlock操作,根据之前的分析,经历的状态变化如下:
    • image
    • 说明:最终c2线程会获取锁资源,继续运行用户逻辑。
  11. c2线程执行emptyCondition.await,由前面的第七步分析,可知最终的状态如下:
    • image
    • 说明:await操作将会生成一个结点放入condition queue中与之前的一个condition queue是不相同的,并且unpark头结点后面的结点,即包含线程p2的结点。
  12. p2线程被unpark,故可以继续运行,经过CPU调度后,p2继续运行,之后p2线程在AQS:await方法中被park,继续AQS.CO:await方法的运行,其方法调用顺序如下,只给出了主要的方法调用:
    • java-thread-x-juc-aqs-20
  13. p2继续运行,执行emptyCondition.signal,根据第九步分析可知,最终到达的状态如下:
    • java-thread-x-juc-aqs-21
    • 说明:最终,将condition queue中的结点转移到sync queue中,并添加至尾部,condition queue会为空,并且将head的状态设置为SIGNAL。
  14. p2线程执行lock.unlock操作,根据前面的分析可知,最后的到达的状态如下:
    • image
    • 说明: unlock操作会释放c2线程的许可,并且将头结点设置为c2线程所在的结点。
      • c2线程继续运行,执行fullCondition. signal,由于此时fullCondition的condition queue已经不存在任何结点了,故其不会产生作用。
      • c2执行lock.unlock,由于c2是sync队列中最后一个结点,故其不会再调用unparkSuccessor了,直接返回true。即整个流程就完成了。

7、AbstractQueuedSynchronizer总结

对于AbstractQueuedSynchronizer的分析,最核心的就是sync queue的分析。

  • 每一个结点都是由前一个结点唤醒
  • 当结点发现前驱结点是head并且尝试获取成功,则会轮到该线程运行
  • condition queue中的结点向sync queue中转移是通过signal操作完成的
  • 当结点的状态为SIGNAL时,表示后面的结点需要运行

8、使用AQS自定义同步器——实现不可重入锁

自定义锁(不可重入锁):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 自定义锁(不可重入锁)
class MyLock implements Lock {

// 独占锁 同步器类
class MySync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
if(compareAndSetState(0, 1)) {
// 加上了锁,并设置 owner 为当前线程
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

@Override
protected boolean tryRelease(int arg) {
// 注意这里:把setState放在后面是因为state是有volatile修饰的,会在setState处放入写屏障
// 用来保证写屏障前面的写操作对其他线程可见
// 而setExclusiveOwnerThread(null);设置的exclusiveOwnerThread没有用volatile修饰
// 所以如果没把setState放在后面,exclusiveOwnerThrea不能保证可见性
setExclusiveOwnerThread(null);
setState(0);
return true;
}

@Override // 是否持有独占锁
protected boolean isHeldExclusively() {
return getState() == 1;
}

public Condition newCondition() {
return new ConditionObject();
}
}

private MySync sync = new MySync();

@Override // 加锁(不成功会进入等待队列)
public void lock() {
sync.acquire(1);
}

@Override // 加锁,可打断
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}

@Override // 尝试加锁(一次)
public boolean tryLock() {
return sync.tryAcquire(1);
}

@Override // 尝试加锁,带超时
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}

@Override // 解锁
public void unlock() {
sync.release(1);
}

@Override // 创建条件变量
public Condition newCondition() {
return sync.newCondition();
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class TestAqs {
public static void main(String[] args) {
MyLock lock = new MyLock();
new Thread(() -> {
lock.lock();
// lock.lock();
try {
log.debug("locking...");
sleep(1);
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t1").start();

new Thread(() -> {
lock.lock();
try {
log.debug("locking...");
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t2").start();
}
}

5、Lock接口

类结构总览:

image

1、什么是Lock

Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象。

Lock 锁实现提供了比使用同步方法和语句可以获得的更广泛的锁操作。它们允许更灵活的结构,可能具有非常不同的属性,并且可能支持多个关联的条件对象。Lock 提供了比 synchronized 更多的功能。

2、Lock接口(源码)

1
2
3
4
5
6
7
8
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}

下面来逐个讲述 Lock 接口中每个方法的使用。

1、lock()方法 与 unlock()方法
  • lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。
    • lock锁是不会被打断interrupt的,要想被interrupt打断的话,就得使用lock.lockInterruptibly()进行上锁
  • unlock()方法也是平常使用得最多的一个方法,就是用来释放锁。一般与lock()方法搭配使用。

采用 Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用 Lock 必须在 try{}catch{}finally{}块中进行,并且将释放锁的操作放在finally 块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用 Lock来进行同步的话,是以下面这种形式去使用的:

1
2
3
4
5
6
7
8
9
Lock lock = ...;// 具体实现类
lock.lock(); // 上锁
try {
// 处理任务
} catch(Exception e) {
// 处理异常
} finally {
lock.unlock(); // 释放锁
}

如果要预防可能发生的死锁,可以尝试使用下面这个方法:tryLock(long time, TimeUnit unit)方法

2、tryLock()方法 与 tryLock(long time, TimeUnit unit)方法

tryLock()方法:尝试获取锁,返回一个boolean值

tryLock(long time, TimeUnit unit)方法:尝试获取锁,可以设置超时

这是一个比单纯lock()更具有工程价值的方法,如果大家阅读过JDK的一些内部代码,就不难发现,tryLock()在JDK内部被大量的使用。

Lock可以通过这两个方法拿到当前线程的锁的状态,并且可以数组超时时间。并根据当前线程是否获得锁,做不同的选择:如果成功获取锁,….,如果获取失败,…..

与lock()相比,tryLock()至少有下面一个好处:

  • 可以不用进行无限等待。直接打破形成死锁的条件。如果一段时间等不到锁,可以直接放弃,同时释放自己已经得到的资源。这样,就可以在很大程度上,避免死锁的产生。因为线程之间出现了一种谦让机制。(这也是解决死锁问题的一种方案)
  • 可以在应用程序这层进行进行自旋,你可以自己决定尝试几次,或者是放弃。
  • 等待锁的过程中可以响应中断interrupt,如果此时,程序正好收到关机信号,中断就会触发,进入中断异常后,线程就可以做一些清理工作,从而防止在终止程序时出现数据写坏,数据丢失等悲催的情况。
  • 对于tryLock(空参)来说特别适合在应用层自己对锁进行管理,在应用层进行自旋等待。
3、newCondition()方法

lock可以通过newCondition()方法获得一个Condition对象。

关键字 synchronized 与 wait()/notify()这两个方法一起使用可以实现等待/通知模式, Lock 锁通过 newContition()方法返回 Condition 对象,Condition 类也可以实现等待/通知模式。

用 notify()通知时,JVM 会随机唤醒某个等待的线程, 使用 Condition 类可以进行选择性通知, Condition 比较常用的三个方法:

  • await():会使当前线程等待,同时会释放锁,当其他线程调用 signal()时,线程会重新获得锁并继续执行。
  • signal():用于唤醒一个等待的线程。
  • signaAll():用于唤醒所有等待的线程。

==注意:在调用 Condition 的 await()/signal()方法前,也需要线程持有相关的 Lock 锁,调用 await()后线程会释放这个锁,在 singal()调用后会从当前Condition 对象的等待队列中,唤醒 一个线程,唤醒的线程尝试获得锁, 一旦获得锁成功就继续执行。==

3、Lock接口的实现类——ReentrantLock(重点)

ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。

ReentrantLock 是唯一实现了 Lock 接口的类,并且 ReentrantLock 提供了更多的方法。

相对于 synchronized 它具备如下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量

与 synchronized 一样,都支持可重入

1、BAT大厂的面试问题
  • 什么是可重入,什么是可重入锁?它用来解决什么问题?
  • ReentrantLock的核心是AQS,那么它怎么来实现的,继承吗?说说其类内部结构关系。
  • ReentrantLock是如何实现公平锁的?
  • ReentrantLock是如何实现非公平锁的?
  • ReentrantLock默认实现的是公平还是非公平锁?
  • 使用ReentrantLock实现公平和非公平锁的示例?
  • ReentrantLock和Synchronized的对比?
2、ReentrantLock源码分析
1、类的继承关系

ReentrantLock实现了Lock接口,Lock接口中定义了lock与unlock相关操作,并且还存在newCondition方法,表示生成一个Condition条件。

1
public class ReentrantLock implements Lock, java.io.Serializable
2、类的内部类

ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:

image

说明:ReentrantLock类内部总共存在SyncNonfairSyncFairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

  • Sync类

    • 源码

      • abstract static class Sync extends AbstractQueuedSynchronizer {
            // 序列号
            private static final long serialVersionUID = -5179523762034025860L;
        
            // 获取锁
            abstract void lock();
        
            // 非公平方式获取
            final boolean nonfairTryAcquire(int acquires) {
                // 当前线程
                final Thread current = Thread.currentThread();
                // 获取状态
                int c = getState();
                if (c == 0) { // 表示没有线程正在竞争该锁
                    if (compareAndSetState(0, acquires)) { // 比较并设置状态成功,状态0表示锁没有被占用,这里体现了非公平性: 不去检查 AQS 队列
                        // 设置当前线程独占
                        setExclusiveOwnerThread(current); 
                        return true; // 成功
                    }
                }
                // 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入
                else if (current == getExclusiveOwnerThread()) { // 当前线程拥有该锁
                    int nextc = c + acquires; // 增加重入次数
                    if (nextc < 0) // overflow
                        throw new Error("Maximum lock count exceeded");
                    // 设置状态
                    setState(nextc); 
                    // 成功
                    return true; 
                }
                // 失败,回到调用处
                return false;
            }
        
            // 试图在共享模式下获取对象状态,此方法应该查询是否允许它在共享模式下获取对象状态,如果允许,则获取它
            protected final boolean tryRelease(int releases) {
                // state--
                int c = getState() - releases;
                if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不为独占线程
                    throw new IllegalMonitorStateException(); // 抛出异常
                // 释放标识
                boolean free = false; 
                // 支持锁重入, 只有 state 减为 0, 才释放成功
                if (c == 0) {
                    free = true;
                    // 已经释放,清空独占
                    setExclusiveOwnerThread(null); 
                }
                // 设置标识
                setState(c); 
                return free; 
            }
        
            // 判断资源是否被当前线程占有
            protected final boolean isHeldExclusively() {
                // While we must in general read state before owner,
                // we don't need to do so to check if current thread is owner
                return getExclusiveOwnerThread() == Thread.currentThread();
            }
        
            // 新生一个条件
            final ConditionObject newCondition() {
                return new ConditionObject();
            }
        
            // Methods relayed from outer class
            // 返回资源的占用线程
            final Thread getOwner() {        
                return getState() == 0 ? null : getExclusiveOwnerThread();
            }
            // 返回状态
            final int getHoldCount() {            
                return isHeldExclusively() ? getState() : 0;
            }
        
            // 资源是否被占用
            final boolean isLocked() {        
                return getState() != 0;
            }
        
            /**
                * Reconstitutes the instance from a stream (that is, deserializes it).
                */
            // 自定义反序列化逻辑
            private void readObject(java.io.ObjectInputStream s)
                throws java.io.IOException, ClassNotFoundException {
                s.defaultReadObject();
                setState(0); // reset to unlocked state
            }
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31

        - Sync类存在如下方法和作用如下:

        - ![image](JUC/java-thread-x-juc-reentrantlock-2.png)

        - NonfairSync类

        - NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现了Sync类中抽象的lock方法,源码如下:

        - ```java
        // 非公平锁
        static final class NonfairSync extends Sync {
        // 版本号
        private static final long serialVersionUID = 7316153563782823691L;

        // 获得锁
        final void lock() {
        // 首先用 cas 尝试(仅尝试一次)将 state 从 0 改为 1, 如果成功表示获得了独占锁
        if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
        // 把当前线程设置独占了锁
        setExclusiveOwnerThread(Thread.currentThread());
        else // 锁已经被占用,或者set失败
        // 以独占模式获取对象,忽略中断
        // 如果尝试失败,进入 AQS的acquire方法
        acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
        }
        }
      • 说明:从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。

    • FairSyn类

      • FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中的抽象lock方法,源码如下:

        • // 公平锁
          static final class FairSync extends Sync {
              // 版本序列化
              private static final long serialVersionUID = -3000897897090466540L;
          
              final void lock() {
                  // 以独占模式获取对象,忽略中断
                  acquire(1);
              }
          
              /**
                  * Fair version of tryAcquire.  Don't grant access unless
                  * recursive call or no waiters or is first.
                  */
              // 尝试公平获取锁
              protected final boolean tryAcquire(int acquires) {
                  // 获取当前线程
                  final Thread current = Thread.currentThread();
                  // 获取状态
                  int c = getState();
                  if (c == 0) { // 状态为0
                      if (!hasQueuedPredecessors() &&
                          compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
                          // 设置当前线程独占
                          setExclusiveOwnerThread(current);
                          return true;
                      }
                  }
                  else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
                      // 下一个状态
                      int nextc = c + acquires;
                      if (nextc < 0) // 超过了int的表示范围
                          throw new Error("Maximum lock count exceeded");
                      // 设置状态
                      setState(nextc);
                      return true;
                  }
                  return false;
              }
          }
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19

          - 说明:

          - 跟踪lock方法的源码可知,当资源空闲时,它总是会先判断sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。
          - 其中,FairSync类的lock的方法调用如下,只给出了主要的方法。
          - ![image](JUC/java-thread-x-juc-reentrantlock-3.png)
          - 说明:可以看出只要资源被其他线程占用,该线程就会添加到sync queue中的尾部,而不会先尝试获取资源。这也是和Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。

          ###### 3、类的属性

          ReentrantLock类的sync非常重要,对ReentrantLock类的操作大部分都直接转化为对Sync和AbstractQueuedSynchronizer类的操作。

          ```java
          public class ReentrantLock implements Lock, java.io.Serializable {
          // 序列号
          private static final long serialVersionUID = 7373984872572414699L;
          // 同步队列
          private final Sync sync;
          }
4、类的构造函数(默认是采用的非公平策略获取锁)
  • ReentrantLock()型构造函数(默认是采用的非公平策略获取锁)

    • public ReentrantLock() {
          // 默认非公平策略
          sync = new NonfairSync();
      }
      
      1
      2
      3
      4
      5
      6
      7

      - ReentrantLock(boolean)型构造函数(可以传递参数确定采用公平策略或者是非公平策略,参数为true表示公平策略,否则,采用非公平策略)

      - ```java
      public ReentrantLock(boolean fair) {
      sync = fair ? new FairSync() : new NonfairSync();
      }
5、核心函数分析——加锁与解锁

加锁与解锁:(默认为非公平锁实现)(配合上面的源码进行分析)

  • 没有竞争时

    • image-20210812043816368
  • 第一个竞争出现时

    • image-20210812043900546

    • Thread-1 执行了

      1. CAS 尝试将 state 由 0 改为 1,结果失败
      2. 进入 tryAcquire 逻辑,这时 state 已经是1,结果仍然失败
      3. 接下来进入 addWaiter 逻辑,构造 Node 队列
        • 图中黄色三角表示该 Node 的 waitStatus 状态,其中 0 为默认正常状态
        • Node 的创建是懒惰的
        • 其中第一个 Node 称为 Dummy(哑元)或哨兵,用来占位,并不关联线程
        • image-20210812044213292
    • 当前线程进入 acquireQueued 逻辑

      1. acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞

      2. 如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,当然这时 state 仍为 1,失败

      3. 进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node,即 head 的 waitStatus 改为 -1,这次返回 false.(waitStatue为-1表示该结点有责任唤醒它的后继结点)

        image-20210812044725103

      4. shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,当然这时state 仍为 1,失败

      5. 当再次进入 shouldParkAfterFailedAcquire 时,这时因为其前驱 node 的 waitStatus 已经是 -1,这次返回true

      6. 进入 parkAndCheckInterrupt, Thread-1 park(灰色表示)

        image-20210812045121967

  • 再次有多个线程经历上述过程竞争失败,变成这个样子

    image-20210812045347108

  • Thread-0 释放锁,进入 tryRelease 流程,如果成功

    • 设置 exclusiveOwnerThread 为 null
    • state = 0

    image-20210812045538702

  • 当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor 流程

  • 找到队列中离 head 最近的一个 Node(没取消的),unpark 恢复其运行,本例中即为 Thread-1

  • 回到 Thread-1 的 acquireQueued 流程

    image-20210812045816095

  • 如果加锁成功(没有竞争),会设置

    • exclusiveOwnerThread 为 Thread-1,state = 1
    • head 指向刚刚 Thread-1 所在的 Node,该 Node 清空 Thread
    • 原本的 head 因为从链表断开,而可被垃圾回收
  • 如果这时候有其它线程来竞争(非公平的体现),例如这时有 Thread-4 来了

    image-20210812050213728

  • 如果不巧又被 Thread-4 占了先

    • Thread-4 被设置为 exclusiveOwnerThread,state = 1
    • Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞
6、核心函数分析——可重入原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
static final class NonfairSync extends Sync {
// ...
// Sync 继承过来的方法, 方便阅读, 放在此处
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入
else if (current == getExclusiveOwnerThread()) {
// state++
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded"); setState(nextc);
return true;
}
return false;
}

// Sync 继承过来的方法, 方便阅读, 放在此处
protected final boolean tryRelease(int releases) {
// state--
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 支持锁重入, 只有 state 减为 0, 才释放成功
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
}
7、核心函数分析——可打断原理

不可打断模式

在此模式下,即使它被打断,仍会驻留在 AQS 队列中,一直要等到获得锁后方能得知自己被打断了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Sync 继承自 AQS
static final class NonfairSync extends Sync {
// ...
private final boolean parkAndCheckInterrupt() {
// 如果打断标记已经是 true, 则 park 会失效
LockSupport.park(this);
// interrupted 会清除打断标记
return Thread.interrupted();
}

final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
// 还是需要获得锁后, 才能返回打断状态
return interrupted;
}
if(shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()
) {
// 如果是因为 interrupt 被唤醒, 返回打断状态为 true
interrupted = true;
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}

public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
){
// 如果打断状态为 true
selfInterrupt();
}
}

static void selfInterrupt() {
// 重新产生一次中断
Thread.currentThread().interrupt();
}
}

可打断模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static final class NonfairSync extends Sync {
public final void acquireInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException(); // 如果没有获得到锁, 进入 ㈠
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}

// ㈠ 可打断的获取锁流程
private void doAcquireInterruptibly(int arg) throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) {
// 在 park 过程中如果被 interrupt 会进入此
// 这时候抛出异常, 而不会再次进入 for (;;)
throw new InterruptedException();
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}
}

通过分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。如将ReentrantLock的lock函数转化为对Sync的lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到Sync的不同子类。

所以可知,在ReentrantLock的背后,是AQS对其服务提供了支持,下面还是通过例子来更进一步分析源码。

8、核心函数分析——条件变量实现原理

每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject

await 流程

  • 开始 Thread-0 持有锁,调用 await,进入 ConditionObject 的 addConditionWaiter 流程

  • 创建新的 Node 状态为 -2(Node.CONDITION),关联 Thread-0,加入等待队列尾部

    image-20210812233522165

  • 接下来进入 AQS 的 fullyRelease 流程,释放同步器上的锁(为什么调用fullyRelease而不是调用realease:因为该线程可能有重入锁,调用fullyRelease可以将该线程所占的所有锁全部释放掉)

    image-20210812233738920

  • unpark AQS 队列中的下一个节点,竞争锁,假设没有其他竞争线程,那么 Thread-1 竞争成功

    image-20210812234025126

  • park 阻塞 Thread-0

    image-20210812234152831

这里其实是thread0 unpark thread1,但此时1并没有竞争到锁因为0还持有锁,等到thread0 park自己时,1才竞争到锁,因为unpark和park可以互换顺序

signal 流程

  • 假设 Thread-1 要来唤醒 Thread-0

    image-20210812234448884

  • 进入 ConditionObject 的 doSignal 流程,取得等待队列中第一个 Node,即 Thread-0 所在 Node

    image-20210812234608451

  • 执行 transferForSignal 流程,将该 Node 加入 AQS 队列尾部,将 Thread-0 的 waitStatus 改为 0,Thread-3 的waitStatus 改为 -1

    image-20210812234954747

  • Thread-1 释放锁,进入 unlock 流程,略

3、示例分析
公平锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}

public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}

public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock(true);

MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
MyThread t3 = new MyThread("t3", lock);
t1.start();
t2.start();
t3.start();
}
}

运行结果(某一次):

1
2
3
Thread[t1,5,main] running
Thread[t2,5,main] running
Thread[t3,5,main] running

说明:该示例使用的是公平策略,由结果可知,可能会存在如下一种时序。

image

说明:首先,t1线程的lock操作 -> t2线程的lock操作 -> t3线程的lock操作 -> t1线程的unlock操作 -> t2线程的unlock操作 -> t3线程的unlock操作。根据这个时序图来进一步分析源码的工作流程:

  • t1线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由调用流程可知,t1线程成功获取了资源,可以继续执行。
  • t2线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最后的结果是t2线程会被禁止,因为调用了LockSupport.park。
  • t3线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最后的结果是t3线程会被禁止,因为调用了LockSupport.park。
  • t1线程调用了lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:如上图所示,最后,head的状态会变为0,t2线程会被unpark,即t2线程可以继续运行。此时t3线程还是被禁止。
  • t2获得cpu资源,继续运行,由于t2之前被park了,现在需要恢复之前的状态,下图给出了方法调用中的主要方法:
    • image
    • 说明:在setHead函数中会将head设置为之前head的下一个结点,并且将pre域与thread域都设置为null,在acquireQueued返回之前,sync queue就只有两个结点了。
  • t2执行lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最终unpark t3线程,让t3线程可以继续运行。
  • t3线程获取cpu资源,恢复之前的状态,继续运行。
    • image
    • 说明:最终达到的状态是sync queue中只剩下了一个结点,并且该节点除了状态为0外,其余均为null。
  • t3执行lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:最后的状态和之前的状态是一样的,队列中有一个空节点,头结点为尾节点均指向它。

6、ReadWriteLock接口

ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。

在ReadWriteLock接口里面只定义了两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading.
*/
Lock readLock();

/**
* Returns the lock used for writing.
*
* @return the lock used for writing.
*/
Lock writeLock();
}

一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成 2 个锁来分配给线程,从而使得多个线程可以同时进行读操作。下面的ReentrantReadWriteLock 实现了 ReadWriteLock 接口。

1、ReadWriteLock接口实现类——ReentrantReadWriteLock读写锁(重要)

现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源, 就不应该允许其他线程对该资源进行读和写的操作了。类似于数据库中的select ...from ... lock in share mode

针对这种场景,JAVA 的并发包JUC提供了读写锁 ReentrantReadWriteLock, ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁

读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。

ReentrantReadWriteLock 里面提供了很多丰富的方法,不过最主要的有两个方法:readLock()和 writeLock()用来获取读锁和写锁。

  1. 线程进入读锁的前提条件:
    • 没有其他线程的写锁
    • 没有写请求,或者==有写请求,但调用线程和持有锁的线程是同一个(可重入锁)==。
  2. 线程进入写锁的前提条件:
    • 没有其他线程的读锁
    • 没有其他线程的写锁

而读写锁有以下三个重要的特性:

  1. ==公平选择性==:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
  2. ==重进入==:读锁和写锁都支持线程重进入。
  3. ==锁降级==:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁
1、BAT大厂的面试问题
  • 为了有了ReentrantLock还需要ReentrantReadWriteLock?
  • ReentrantReadWriteLock底层实现原理?
  • ReentrantReadWriteLock底层读写状态如何设计的?
    • 高16位为读锁,低16位为写锁
  • 读锁和写锁的最大数量是多少?
  • 本地线程计数器ThreadLocalHoldCounter是用来做什么的?
  • 缓存计数器HoldCounter是用来做什么的?
  • 写锁的获取与释放是怎么实现的?
  • 读锁的获取与释放是怎么实现的?
  • RentrantReadWriteLock为什么不支持锁升级?
  • 什么是锁的升降级?RentrantReadWriteLock为什么不支持锁升级?
2、ReentrantReadWriteLock数据结构

ReentrantReadWriteLock底层是基于ReentrantLockAbstractQueuedSynchronizer来实现的,所以,ReentrantReadWriteLock的数据结构也依托于AQS的数据结构。

3、ReentrantReadWriteLock源码分析
1、类的继承关系
1
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {}

说明:可以看到,ReentrantReadWriteLock实现了ReadWriteLock接口,ReadWriteLock接口定义了获取读锁和写锁的规范,具体需要实现类去实现;同时其还实现了Serializable接口,表示可以进行序列化,在源代码中可以看到ReentrantReadWriteLock实现了自己的序列化逻辑。

2、类的内部类

ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示:

img

说明:如上图所示,Sync继承自AQS、NonfairSync继承自Sync类、FairSync继承自Sync类;ReadLock实现了Lock接口、WriteLock也实现了Lock接口。

3、内部类——Sync类
  • 类的继承关系

    • abstract static class Sync extends AbstractQueuedSynchronizer {}
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      - 说明:Sync抽象类继承自AQS抽象类,Sync类提供了对ReentrantReadWriteLock的支持。

      - 类的内部类

      - Sync类内部存在两个内部类,分别为HoldCounter和ThreadLocalHoldCounter,其中HoldCounter主要与读锁配套使用,其中,HoldCounter源码如下:

      - ```java
      // 计数器
      static final class HoldCounter {
      // 计数
      int count = 0;
      // Use id, not reference, to avoid garbage retention
      // 获取当前线程的TID属性的值
      final long tid = getThreadId(Thread.currentThread());
      }
    • 说明:HoldCounter主要有两个属性,count和tid,其中count表示某个读线程重入的次数tid表示该线程的tid字段的值,该字段可以用来唯一标识一个线程

    • ThreadLocalHoldCounter的源码如下:

      • // 本地线程计数器
        static final class ThreadLocalHoldCounter
            extends ThreadLocal<HoldCounter> {
            // 重写初始化方法,在没有进行set的情况下,获取的都是该HoldCounter值
            public HoldCounter initialValue() {
                return new HoldCounter();
            }
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26

        - 说明:ThreadLocalHoldCounter重写了ThreadLocal的initialValue方法,ThreadLocal类可以将线程与对象相关联。在没有进行set的情况下,get到的均是initialValue方法里面生成的那个HolderCounter对象。

        - 类的属性

        - ```java
        abstract static class Sync extends AbstractQueuedSynchronizer {
        // 版本序列号
        private static final long serialVersionUID = 6317671515068378041L;
        // 高16位为读锁,低16位为写锁
        static final int SHARED_SHIFT = 16;
        // 读锁单位
        static final int SHARED_UNIT = (1 << SHARED_SHIFT);
        // 读锁最大数量
        static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
        // 写锁最大数量
        static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
        // 本地线程计数器
        private transient ThreadLocalHoldCounter readHolds;
        // 缓存的计数器
        private transient HoldCounter cachedHoldCounter;
        // 第一个读线程
        private transient Thread firstReader = null;
        // 第一个读线程的计数
        private transient int firstReaderHoldCount;
        }
    • 说明:该属性中包括了读锁写锁线程的最大量本地线程计数器等。

  • 类的构造函数

    • // 构造函数
      Sync() {
          // 本地线程计数器
          readHolds = new ThreadLocalHoldCounter();
          // 设置AQS的状态
          setState(getState()); // ensures visibility of readHolds
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 说明:在Sync的构造函数中**设置了本地线程计数器和AQS的状态state**。

      ###### 4、内部类——Sync核心函数分析

      对ReentrantReadWriteLock对象的操作绝大多数都转发至Sync对象进行处理。下面对Sync类中的重点函数进行分析:

      - sharedCount函数

      - 表示**占有读锁的线程数量**,源码如下:

      - ```java
      static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
    • 说明:直接将state右移16位,就可以得到读锁的线程数量,因为state的高16位表示读锁,对应的低十六位表示写锁数量

  • exclusiveCount函数

    • 表示占有写锁的线程数量,源码如下:

    • static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26

      - 说明:直接将状态state和(2^16 - 1)做与运算,其等效于将state模上2^16。**写锁数量由state的低十六位表示**。

      - tryRelease函数

      - ```java
      /*
      * Note that tryRelease and tryAcquire can be called by
      * Conditions. So it is possible that their arguments contain
      * both read and write holds that are all released during a
      * condition wait and re-established in tryAcquire.
      */

      protected final boolean tryRelease(int releases) {
      // 判断是否伪独占线程
      if (!isHeldExclusively())
      throw new IllegalMonitorStateException();
      // 计算释放资源后的写锁的数量
      int nextc = getState() - releases;
      // 因为可重入的原因, 写锁计数为 0, 才算释放成功
      boolean free = exclusiveCount(nextc) == 0; // 是否释放成功
      if (free)
      setExclusiveOwnerThread(null); // 设置独占线程为空
      setState(nextc); // 设置状态
      return free;
      }
    • 说明:此函数用于释放写锁资源:首先会判断该线程是否为独占线程,若不为独占线程,则抛出异常,否则,计算释放资源后的写锁的数量,若为0,表示成功释放,资源不将被占用,否则,表示资源还被占用。其函数流程图如下:

    • img

  • tryAcquire函数

    • protected final boolean tryAcquire(int acquires) {
          /*
              * Walkthrough:
              * 1. If read count nonzero or write count nonzero
              *    and owner is a different thread, fail.
              * 2. If count would saturate, fail. (This can only
              *    happen if count is already nonzero.)
              * 3. Otherwise, this thread is eligible for lock if
              *    it is either a reentrant acquire or
              *    queue policy allows it. If so, update state
              *    and set owner.
              */
          // 获取当前线程
          Thread current = Thread.currentThread();
          // 获取状态
          // 获得低 16 位, 代表写锁的 state 计数
          int c = getState();
          // 写线程数量
          int w = exclusiveCount(c);
          if (c != 0) { // 状态不为0
              // (Note: if c != 0 and w == 0 then shared count != 0)
              // 写线程数量为0或者当前线程没有占有独占资源
              if (
                  // c != 0 and w == 0 表示有读锁, 或者
                  w == 0 || 
                  // 如果 exclusiveOwnerThread 不是自己(可重入)
                  current != getExclusiveOwnerThread()) 
                  // 获得锁失败
                  return false;
              // 写锁计数超过低 16 位, 报异常
              if (w + exclusiveCount(acquires) > MAX_COUNT) // 判断是否超过最高写线程数量
                  throw new Error("Maximum lock count exceeded");
              // Reentrant acquire
              // 设置AQS状态
              // 写锁重入, 获得锁成功
              setState(c + acquires);
              return true;
          }
          if (
              // 判断写锁是否该阻塞, 或者
              writerShouldBlock() ||
              // 尝试更改计数失败
              !compareAndSetState(c, c + acquires))
              // 获得锁失败
              return false;
          // 设置独占线程
          // 获得锁成功
          setExclusiveOwnerThread(current);
          return true;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
          
      - 说明:此函数用于**获取写锁**:首先会获取state,判断是否为0,若为0,表示此时没有读锁线程,再判断写线程是否应该被阻塞,而**在非公平策略下总是不会被阻塞,在公平策略下会进行判断(判断同步队列中是否有等待时间更长的线程,若存在,则需要被阻塞,否则,无需阻塞**),之后在设置状态state,然后返回true。若state不为0,则表示此时存在读锁或写锁线程,若写锁线程数量为0或者当前线程为独占锁线程,则返回false,表示不成功,否则,判断写锁线程的重入次数是否大于了最大值,若是,则抛出异常,否则,设置状态state,返回true,表示成功。其函数流程图如下:

      - ![img](JUC/java-thread-x-readwritelock-3.png)

      - tryReleaseShared函数

      - ```java
      protected final boolean tryReleaseShared(int unused) {
      // 获取当前线程
      Thread current = Thread.currentThread();
      if (firstReader == current) { // 当前线程为第一个读线程
      // assert firstReaderHoldCount > 0;
      if (firstReaderHoldCount == 1) // 读线程占用的资源数为1
      firstReader = null;
      else // 减少占用的资源
      firstReaderHoldCount--;
      } else { // 当前线程不为第一个读线程
      // 获取缓存的计数器
      HoldCounter rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
      // 获取当前线程对应的计数器
      rh = readHolds.get();
      // 获取计数
      int count = rh.count;
      if (count <= 1) { // 计数小于等于1
      // 移除
      readHolds.remove();
      if (count <= 0) // 计数小于等于0,抛出异常
      throw unmatchedUnlockException();
      }
      // 减少计数
      --rh.count;
      }
      for (;;) { // 无限循环
      // 获取状态
      int c = getState();
      // 获取状态
      int nextc = c - SHARED_UNIT;
      if (compareAndSetState(c, nextc)) // 比较并进行设置
      // Releasing the read lock has no effect on readers,
      // but it may allow waiting writers to proceed if
      // both read and write locks are now free.
      // 读锁的计数不会影响其它获取读锁线程, 但会影响其它获取写锁线程
      // 计数为 0 才是真正释放
      return nextc == 0;
      }
      }
    • 说明:此函数表示读锁线程释放锁:首先判断当前线程是否为第一个读线程firstReader,若是,则判断第一个读线程占有的资源数firstReaderHoldCount是否为1,若是,则设置第一个读线程firstReader为空,否则,将第一个读线程占有的资源数firstReaderHoldCount减1;若当前线程不是第一个读线程,那么首先会获取缓存计数器(上一个读锁线程对应的计数器 ),若计数器为空或者tid不等于当前线程的tid值,则获取当前线程的计数器,如果计数器的计数count小于等于1,则移除当前线程对应的计数器,如果计数器的计数count小于等于0,则抛出异常,之后再减少计数即可。无论何种情况,都会进入无限循环,该循环可以确保成功设置状态state。其流程图如下:

    • img

  • tryAcquireShared函数

    • private IllegalMonitorStateException unmatchedUnlockException() {
          return new IllegalMonitorStateException(
              "attempt to unlock read lock, not locked by current thread");
      }
      
      // 共享模式下获取资源
      protected final int tryAcquireShared(int unused) {
          /*
              * Walkthrough:
              * 1. If write lock held by another thread, fail.
              * 2. Otherwise, this thread is eligible for
              *    lock wrt state, so ask if it should block
              *    because of queue policy. If not, try
              *    to grant by CASing state and updating count.
              *    Note that step does not check for reentrant
              *    acquires, which is postponed to full version
              *    to avoid having to check hold count in
              *    the more typical non-reentrant case.
              * 3. If step 2 fails either because thread
              *    apparently not eligible or CAS fails or count
              *    saturated, chain to version with full retry loop.
              */
          // 获取当前线程
          Thread current = Thread.currentThread();
          // 获取状态
          int c = getState();
          // 如果是其它线程持有写锁, 获取读锁失败
          if (exclusiveCount(c) != 0 &&
              getExclusiveOwnerThread() != current) // 写线程数不为0并且占有资源的不是当前线程
              return -1;
          // 读锁数量
          int r = sharedCount(c);
          if (// 读线程是否应该被阻塞、并且小于最大值、并且比较设置成功
              // 读锁不该阻塞(如果老二是写锁,读锁该阻塞), 并且
              !readerShouldBlock() &&
              // 小于读锁计数, 并且
              r < MAX_COUNT &&
              // 尝试增加计数成功
              compareAndSetState(c, c + SHARED_UNIT)) { 
              if (r == 0) { // 读锁数量为0
                  // 设置第一个读线程
                  firstReader = current;
                  // 读线程占用的资源数为1
                  firstReaderHoldCount = 1;
              } else if (firstReader == current) { // 当前线程为第一个读线程
                  // 占用资源数加1
                  firstReaderHoldCount++;
              } else { // 读锁数量不为0并且不为当前线程
                  // 获取计数器
                  HoldCounter rh = cachedHoldCounter;
                  if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
                      // 获取当前线程对应的计数器
                      cachedHoldCounter = rh = readHolds.get();
                  else if (rh.count == 0) // 计数为0
                      // 设置
                      readHolds.set(rh);
                  rh.count++;
              }
              return 1;
          }
          return fullTryAcquireShared(current);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
          
      - 说明:此函数表示**读锁线程获取读锁**。首先判断写锁是否为0并且当前线程不占有独占锁,直接返回;否则,判断读线程是否需要被阻塞并且读锁数量是否小于最大值并且比较设置状态成功,若当前没有读锁,则设置第一个读线程firstReader和firstReaderHoldCount;若当前线程线程为第一个读线程,则增加firstReaderHoldCount;否则,将设置当前线程对应的HoldCounter对象的值。流程图如下:

      - ![img](JUC/java-thread-x-readwritelock-5.png)

      - fullTryAcquireShared函数

      - ```java
      // 非公平锁 readerShouldBlock 看 AQS 队列中第一个节点是否是写锁
      // true 则该阻塞, false 则不阻塞
      final boolean readerShouldBlock() {
      return apparentlyFirstQueuedIsExclusive();
      }

      // 与 tryAcquireShared 功能类似, 但会不断尝试 for (;;) 获取读锁, 执行过程中无阻塞
      final int fullTryAcquireShared(Thread current) {
      /*
      * This code is in part redundant with that in
      * tryAcquireShared but is simpler overall by not
      * complicating tryAcquireShared with interactions between
      * retries and lazily reading hold counts.
      */
      HoldCounter rh = null;
      for (;;) { // 无限循环
      // 获取状态
      int c = getState();
      if (exclusiveCount(c) != 0) { // 写线程数量不为0
      if (getExclusiveOwnerThread() != current) // 不为当前线程
      return -1;
      // else we hold the exclusive lock; blocking here
      // would cause deadlock.
      } else if (readerShouldBlock()) { // 写线程数量为0并且读线程被阻塞
      // Make sure we're not acquiring read lock reentrantly
      if (firstReader == current) { // 当前线程为第一个读线程
      // assert firstReaderHoldCount > 0;
      } else { // 当前线程不为第一个读线程
      if (rh == null) { // 计数器不为空
      //
      rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current)) { // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
      rh = readHolds.get();
      if (rh.count == 0)
      readHolds.remove();
      }
      }
      if (rh.count == 0)
      return -1;
      }
      }
      if (sharedCount(c) == MAX_COUNT) // 读锁数量为最大值,抛出异常
      throw new Error("Maximum lock count exceeded");
      if (compareAndSetState(c, c + SHARED_UNIT)) { // 比较并且设置成功
      if (sharedCount(c) == 0) { // 读线程数量为0
      // 设置第一个读线程
      firstReader = current;
      //
      firstReaderHoldCount = 1;
      } else if (firstReader == current) {
      firstReaderHoldCount++;
      } else {
      if (rh == null)
      rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current))
      rh = readHolds.get();
      else if (rh.count == 0)
      readHolds.set(rh);
      rh.count++;
      cachedHoldCounter = rh; // cache for release
      }
      return 1;
      }
      }
      }
    • 说明:在tryAcquireShared函数中,如果下列三个条件不满足:

      • 读线程是否应该被阻塞
      • 小于最大值
      • 比较设置成功
    • 则会进行fullTryAcquireShared函数中,它用来保证相关操作可以成功。其逻辑与tryAcquireShared逻辑类似,不再累赘。

  • doAcquireShared()函数

    • private void doAcquireShared(int arg) {
          // 将当前线程关联到一个 Node 对象上, 模式为共享模式
          final Node node = addWaiter(Node.SHARED);
          boolean failed = true;
          try {
              boolean interrupted = false;
              for (;;) {
                  final Node p = node.predecessor();
                  if (p == head) {
                      // 再一次尝试获取读锁
                      int r = tryAcquireShared(arg); // 成功
                      if (r >= 0) {
                          // ㈠
                          // r 表示可用资源数, 在这里总是 1 允许传播
                          //(唤醒 AQS 中下一个 Share 节点) 
                          setHeadAndPropagate(node, r);
                          p.next = null; // help GC
                          if (interrupted)
                              selfInterrupt();
                          failed = false;
                          return;
                      }
                  }
                  if (
                      // 是否在获取读锁失败时阻塞(前一个阶段 waitStatus == Node.SIGNAL) 
                      shouldParkAfterFailedAcquire(p, node) &&
                      // park 当前线程
                      parkAndCheckInterrupt()
                  ){
                      interrupted = true;
                  }
              }
          }  finally {
              if (failed)
                  cancelAcquire(node);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34



      而其他内部类的操作基本上都是转化到了对Sync对象的操作,在此不再累赘。

      ###### 5、类的属性

      ```java
      public class ReentrantReadWriteLock
      implements ReadWriteLock, java.io.Serializable {
      // 版本序列号
      private static final long serialVersionUID = -6992448646407690164L;
      // 读锁
      private final ReentrantReadWriteLock.ReadLock readerLock;
      // 写锁
      private final ReentrantReadWriteLock.WriteLock writerLock;
      // 同步队列
      final Sync sync;

      private static final sun.misc.Unsafe UNSAFE;
      // 线程ID的偏移地址
      private static final long TID_OFFSET;
      static {
      try {
      UNSAFE = sun.misc.Unsafe.getUnsafe();
      Class<?> tk = Thread.class;
      // 获取线程的tid字段的内存地址
      TID_OFFSET = UNSAFE.objectFieldOffset
      (tk.getDeclaredField("tid"));
      } catch (Exception e) {
      throw new Error(e);
      }
      }
      }

说明:可以看到ReentrantReadWriteLock属性包括了一个ReentrantReadWriteLock.ReadLock对象,表示读锁;一个ReentrantReadWriteLock.WriteLock对象,表示写锁;一个Sync对象,表示同步队列。

6、类的构造函数
  • ReentrantReadWriteLock()型构造函数

    • public ReentrantReadWriteLock() {
          this(false);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14

      - 说明:此构造函数会调用另外一个有参构造函数。

      - ReentrantReadWriteLock(boolean)型构造函数

      - ```java
      public ReentrantReadWriteLock(boolean fair) {
      // 公平策略或者是非公平策略
      sync = fair ? new FairSync() : new NonfairSync();
      // 读锁
      readerLock = new ReadLock(this);
      // 写锁
      writerLock = new WriteLock(this);
      }
    • 说明:可以指定设置公平策略或者非公平策略,并且该构造函数中生成了读锁与写锁两个对象。如果调用的是空参的构造函数,则默认是非公平的策略

7、核心函数分析

对ReentrantReadWriteLock的操作基本上都转化为了对Sync对象的操作,而Sync的函数已经分析过,不再累赘。

8、图解ReentrantReadWriteLock执行流程

读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个。

t1 w.lock,t2 r.lock:t1线程为写锁,t2线程为读锁(默认为非公平锁)

  1. t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位

    image-20210813022016104

  2. t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。如果有写锁占据,那么 tryAcquireShared 返回 -1 表示失败

    • tryAcquireShared 返回值表示
      • -1 表示失败
      • 0 表示成功,但后继节点不会继续唤醒
      • 正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回 1

    image-20210813023347980

  3. 这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态

    image-20210813023500583

  4. t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁

  5. 如果没有成功,在 doAcquireShared 内 for (;;) 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;;) 循环一次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park

    image-20210813023653011

  6. t3 r.lock,t4 w.lock:t3线程为读锁,t4线程为写锁。这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子(由于t3是读锁,为共享锁,所以状态是Shared,而t4是写锁,为独占锁,所以状态是Ex)这里状态的不同是为了之后的解锁做准备,不同状态的解锁方式不同

    image-20210813023151128

  7. t1 w.unlock:t1线程释放了写锁。这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子

    image-20210813024250098

  8. 接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行

  9. 这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一(其中的readerShouldBlock()方法是区别公平锁和非公平锁的关键,非公平锁不阻塞,公平锁就阻塞)(同样的writerShouldBlock()也是区别写锁公平和非公平的关键)

    image-20210813024836077

  10. 这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

    image-20210813024953061

  11. 事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行((setHead(node)之后的代码的意思是,当阻塞队列中有多个连续的读线程时,会传播式地逐一唤醒,if(s.isShared()){doReleaseShared()}这段代码是关键,吧读锁的状态设置成Shared也是为了这里))

    image-20210813025411978

  12. 这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一

    image-20210813025646877

  13. 这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

    image-20210813030410741

  14. 下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点

  15. t2 r.unlock,t3 r.unlock:t2线程解锁,t3线程解锁:t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零

    image-20210813030740325

  16. t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即

    image-20210813031008456

  17. 之后 t4 在 acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;;) 这次自己是老二,并且没有其他竞争,tryAcquire(1) 成功,修改头结点,流程结束

    image-20210813031102400

4、ReentrantReadWriteLock示例

下面给出了一个使用ReentrantReadWriteLock的示例,源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import java.util.concurrent.locks.ReentrantReadWriteLock;

class ReadThread extends Thread {
private ReentrantReadWriteLock rrwLock;

public ReadThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.readLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rrwLock.readLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}

class WriteThread extends Thread {
private ReentrantReadWriteLock rrwLock;

public WriteThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.writeLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
} finally {
rrwLock.writeLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}

public class ReentrantReadWriteLockDemo {
public static void main(String[] args) {
ReentrantReadWriteLock rrwLock = new ReentrantReadWriteLock();
ReadThread rt1 = new ReadThread("rt1", rrwLock);
ReadThread rt2 = new ReadThread("rt2", rrwLock);
WriteThread wt1 = new WriteThread("wt1", rrwLock);
rt1.start();
rt2.start();
wt1.start();
}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
rt1 trying to lock
rt2 trying to lock
wt1 trying to lock
rt1 lock successfully
rt2 lock successfully
rt1 unlock successfully
rt2 unlock successfully
wt1 lock successfully
wt1 unlock successfully

说明:程序中生成了一个ReentrantReadWriteLock对象,并且设置了两个读线程,一个写线程。根据结果,可能存在如下的时序图:

img

  • rt1线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的状态state为2^16 次方,即表示此时读线程数量为1
  • rt2线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的状态state为2 * 2^16次方,即表示此时读线程数量为2
  • wt1线程执行rrwLock.writeLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,在同步队列Sync queue中存在两个结点,并且wt1线程会被禁止运行。
  • rt1线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的state为2^16次方,表示还有一个读线程。
  • rt2线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:当rt2线程执行unlock操作后,AQS的state为0,并且wt1线程将会被unpark,其获得CPU资源就可以运行。
  • wt1线程获得CPU资源,继续运行,需要恢复。由于之前acquireQueued函数中的parkAndCheckInterrupt函数中被禁止的,所以,恢复到parkAndCheckInterrupt函数中,主要的函数调用如下:
    • img
    • 说明:最后,sync queue队列中只有一个结点,并且头结点尾节点均指向它,AQS的state值为1,表示此时有一个写线程。
  • wt1执行rrwLock.writeLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的state为0,表示没有任何读线程或者写线程了。并且Sync queue结构与上一个状态的结构相同,没有变化。
5、更深入理解
1、什么是锁升降级?

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程

接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,如代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void processData() {
readLock.lock();
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}

上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。

image-20210726213514196

2、锁降级中读锁的获取是否必要呢?

答案是必要的。主要是为了==保证数据的可见性==,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

3、RentrantReadWriteLock支不支持锁升级?

RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是**==保证数据可见性==,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。**

6、使用读写锁实现一致性缓存(保证缓存与数据库数据一致)
1、缓存更新策略

更新时,是先清缓存还是先更新数据库?

先清缓存:

image-20210813015618419

先更新数据库:

image-20210813015913860

补充一种情况,假设查询线程 A 查询数据时恰好缓存数据由于时间到期失效,或是第一次查询

image-20210813021033756

这种情况的出现几率非常小,见 facebook 论文

2、使用读写锁实现一个简单的按需加载缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import java.util.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class TestGenericDao {
public static void main(String[] args) {
GenericDao dao = new GenericDaoCached();
System.out.println("============> 查询");
String sql = "select * from emp where empno = ?";
int empno = 7369;
Emp emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);

System.out.println("============> 更新");
dao.update("update emp set sal = ? where empno = ?", 800, empno);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
}
}

class GenericDaoCached extends GenericDao {
private GenericDao dao = new GenericDao();
private Map<SqlPair, Object> map = new HashMap<>();
private ReentrantReadWriteLock rw = new ReentrantReadWriteLock();

@Override
public <T> List<T> queryList(Class<T> beanClass, String sql, Object... args) {
return dao.queryList(beanClass, sql, args);
}

@Override
public <T> T queryOne(Class<T> beanClass, String sql, Object... args) {
// 先从缓存中找,找到直接返回
SqlPair key = new SqlPair(sql, args);;
rw.readLock().lock();
try {
T value = (T) map.get(key);
if(value != null) {
return value;
}
} finally {
rw.readLock().unlock();
}
rw.writeLock().lock();
try {
// 多个线程
T value = (T) map.get(key);
if(value == null) {
// 缓存中没有,查询数据库
value = dao.queryOne(beanClass, sql, args);
map.put(key, value);
}
return value;
} finally {
rw.writeLock().unlock();
}
}

@Override
public int update(String sql, Object... args) {
rw.writeLock().lock();
try {
// 先更新库
int update = dao.update(sql, args);
// 清空缓存
map.clear();
return update;
} finally {
rw.writeLock().unlock();
}
}

class SqlPair {
private String sql;
private Object[] args;

public SqlPair(String sql, Object[] args) {
this.sql = sql;
this.args = args;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
SqlPair sqlPair = (SqlPair) o;
return Objects.equals(sql, sqlPair.sql) &&
Arrays.equals(args, sqlPair.args);
}

@Override
public int hashCode() {
int result = Objects.hash(sql);
result = 31 * result + Arrays.hashCode(args);
return result;
}
}
}

注意:

  • 以上实现体现的是读写锁的应用,保证缓存和数据库的一致性,但有下面的问题没有考虑
    • 适合读多写少,如果写操作比较频繁,以上实现性能低
    • 没有考虑缓存容量
    • 没有考虑缓存过期
    • 只适合单机
    • 并发性还是低,目前只会用一把锁(其实可以把锁再细化,不同的表用不同的锁)
    • 更新方法太过简单粗暴,清空了所有 key(考虑按类型分区或重新设计 key)
  • 乐观锁实现:用 CAS 去更新
7、ReentrantReadWriteLock总结
  • 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

原因:当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。

image-20210726213440799

注意事项:

  • 读锁不支持条件变量

  • 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待

    • r.lock();
      try {
          // ...
          w.lock();
          try {
              // ...
          } finally{
              w.unlock();
          }
      } finally{
          r.unlock();
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      - 重入时降级支持:即持有写锁的情况下去获取读锁



      #### 2、对ReentrantReadWriteLock性能再提升——`StampedLock`

      ##### 1、StampedLock概述

      该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是**在使用读锁、写锁时都必须配合【戳】使用**

      **加解读锁**:

      ```java
      long stamp = lock.readLock();
      lock.unlockRead(stamp);

加解写锁

1
2
long stamp = lock.writeLock();
lock.unlockWrite(stamp);

乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。

1
2
3
4
long stamp = lock.tryOptimisticRead(); // 验戳
if(!lock.validate(stamp)){
// 锁升级
}
2、StampedLock示例

提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j(topic = "c.DataContainerStamped")
class DataContainerStamped {
private int data;
private final StampedLock lock = new StampedLock();

public DataContainerStamped(int data) {
this.data = data;
}

public int read(int readTime) {
long stamp = lock.tryOptimisticRead();
log.debug("optimistic read locking...{}", stamp);
sleep(readTime);
if (lock.validate(stamp)) {
log.debug("read finish...{}, data:{}", stamp, data);
return data;
}
// 锁升级 - 读锁
log.debug("updating to read lock... {}", stamp);
try {
stamp = lock.readLock();
log.debug("read lock {}", stamp);
sleep(readTime);
log.debug("read finish...{}, data:{}", stamp, data);
return data;
} finally {
log.debug("read unlock {}", stamp);
lock.unlockRead(stamp);
}
}

public void write(int newData) {
long stamp = lock.writeLock();
log.debug("write lock {}", stamp);
try {
sleep(2);
this.data = newData;
} finally {
log.debug("write unlock {}", stamp);
lock.unlockWrite(stamp);
}
}
}

测试 读-读 (乐观读)

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j(topic = "c.TestStampedLock")
public class TestStampedLock {
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.read(0);
}, "t2").start();
}
}

输出结果,可以看到实际没有加读锁

1
2
3
4
15:58:50.217 c.DataContainerStamped [t1] - optimistic read locking...256 
15:58:50.717 c.DataContainerStamped [t2] - optimistic read locking...256
15:58:50.717 c.DataContainerStamped [t2] - read finish...256, data:1
15:58:51.220 c.DataContainerStamped [t1] - read finish...256, data:1

测试 读-写 时优化读补加读锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j(topic = "c.TestStampedLock")
public class TestStampedLock {
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.write(100);
}, "t2").start();
}
}

输出结果

1
2
3
4
5
6
7
15:57:00.219 c.DataContainerStamped [t1] - optimistic read locking...256 
15:57:00.717 c.DataContainerStamped [t2] - write lock 384
15:57:01.225 c.DataContainerStamped [t1] - updating to read lock... 256
15:57:02.719 c.DataContainerStamped [t2] - write unlock 384
15:57:02.719 c.DataContainerStamped [t1] - read lock 513
15:57:03.719 c.DataContainerStamped [t1] - read finish...513, data:1000
15:57:03.719 c.DataContainerStamped [t1] - read unlock 513
3、StampedLock是否可以替代ReentrantReadWriteLock

当然是不可以,虽然StampedLock在读写锁上的性能比ReentrantReadWriteLock好,但是它有以下几个缺点:

  • StampedLock 不支持条件变量
  • StampedLock 不支持可重入

7、线程间通信

线程间通信的模型有两种:==共享内存==和==消息传递==

1、场景

我们来基本一道面试常见的题目来分析:场景——四个线程,两个线程对当前数值加 1,另外两个线程对当前数值减 1,要求用线程间通信。

2、分析

1、关于i++与i–的字节码及其执行流程

i++其实是一个复合操作,包括三步骤:

  • 读取i的值。
  • 对i加1。
  • 将i的值写回内存。

i++的相关字节码指令:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
iadd //自增
putstatic i // 将修改后的值存入静态变量i

对于i–也是类似:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
isub //自减
putstatic i // 将修改后的值存入静态变量i

而Java的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:(下图只显示了两个线程,分别做自增和自减)

image-20210804150447015

如果是单线程以上 8 行字节码是顺序执行(不会交错)没有问题:

image-20210804152755128

但多线程下这 8 行字节码可能交错运行:

出现负数的情况:

image-20210804152921570

出现正数的情况:

image-20210804152947980

2、临界区 Critical Section
  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
3、竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

3、解决方法

image-20210804154458987

image-20210804154522110

使用Lock的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.atguigu.lock;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

//第一步 创建资源类,定义属性和操作方法
class Share {
private int number = 0;

//创建Lock
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

//+1
public void incr() throws InterruptedException {
//上锁
lock.lock();
try {
//判断
while (number != 0) {
condition.await();
}
//干活
number++;
System.out.println(Thread.currentThread().getName()+" :: "+number);
//通知
condition.signalAll();
}finally {
//解锁
lock.unlock();
}
}

//-1
public void decr() throws InterruptedException {
lock.lock();
try {
while(number != 1) {
condition.await();
}
number--;
System.out.println(Thread.currentThread().getName()+" :: "+number);
condition.signalAll();
}finally {
lock.unlock();
}
}
}

public class ThreadDemo2 {

public static void main(String[] args) {
Share share = new Share();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.incr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"A").start();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.decr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"B").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.incr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"C").start();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.decr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"D").start();
}
}

8、线程间定制化通信

案例介绍:

问题:A 线程打印 5 次 A,B 线程打印 10 次 B,C 线程打印 15 次 C,按照此顺序循环 10 轮。

实现方法:

image-20210722025741403

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
//第一步 创建资源类
class ShareResource {
//定义标志位
private int flag = 1; // 1 AA 2 BB 3 CC

//创建Lock锁
private Lock lock = new ReentrantLock();

//创建三个condition
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
private Condition c3 = lock.newCondition();

//打印5次,参数第几轮
public void print5(int loop) throws InterruptedException {
//上锁
lock.lock();
try {
//判断
while(flag != 1) {
//等待
c1.await();
}
//干活
for (int i = 1; i <=5; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//通知
flag = 2; //修改标志位 2
c2.signal(); //通知BB线程
}finally {
//释放锁
lock.unlock();
}
}

//打印10次,参数第几轮
public void print10(int loop) throws InterruptedException {
lock.lock();
try {
while(flag != 2) {
c2.await();
}
for (int i = 1; i <=10; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//修改标志位
flag = 3;
//通知CC线程
c3.signal();
}finally {
lock.unlock();
}
}

//打印15次,参数第几轮
public void print15(int loop) throws InterruptedException {
lock.lock();
try {
while(flag != 3) {
c3.await();
}
for (int i = 1; i <=15; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//修改标志位
flag = 1;
//通知AA线程
c1.signal();
}finally {
lock.unlock();
}
}
}

public class ThreadDemo3 {
public static void main(String[] args) {
ShareResource shareResource = new ShareResource();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print5(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"AA").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print10(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"BB").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print15(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"CC").start();
}
}

9、集合的线程安全

类结构关系:

image

image-20210813203912863

线程安全集合类可以分为三大类:

  • 遗留的线程安全集合如 Hashtable , Vector
  • 使用 Collections 装饰的线程安全集合,如:
    • Collections.synchronizedCollection
    • Collections.synchronizedList
    • Collections.synchronizedMap
    • Collections.synchronizedSet
    • Collections.synchronizedNavigableMap
    • Collections.synchronizedNavigableSet
    • Collections.synchronizedSortedMap
    • Collections.synchronizedSortedSet
  • java.util.concurrent.*

重点介绍 java.util.concurrent.* 下的线程安全集合类,可以发现它们有规律,里面包含三类关键词:BlockingCopyOnWriteConcurrent

  • Blocking 大部分实现基于锁,并提供用来阻塞的方法
  • CopyOnWrite 之类容器修改开销相对较重
  • Concurrent 类型的容器
    • 内部很多操作使用 cas 优化,一般可以提供较高吞吐量
    • 弱一致性
      • 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
      • 求大小弱一致性,size 操作未必是 100% 准确
      • 读取弱一致性

遍历时如果发生了修改,对于非安全容器来讲,使用 fail-fast 机制也就是让遍历立刻失败,抛出ConcurrentModificationException,不再继续遍历

1、ArrayList不安全

ArrayList的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,ArrayList是不安全的。

示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadDemo4 {
public static void main(String[] args) {
//创建ArrayList集合
List<String> list = new ArrayList<>();

for (int i = 0; i <30; i++) {
new Thread(()->{
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Exception in thread "27" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
at java.util.ArrayList$Itr.next(ArrayList.java:851)
at java.util.AbstractCollection.toString(AbstractCollection.java:461)
at java.lang.String.valueOf(String.java:2994)
at java.io.PrintStream.println(PrintStream.java:821)
at test.ThreadDemo.lambda$main$0(ThreadDemo.java:17)
at java.lang.Thread.run(Thread.java:748)
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa, 7d121289]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa, 7d121289]
...

解决ArrayList在多线程环境下不安全的问题:

  1. 方案1:用Vector代替ArrayList
  2. 方案2:Collections.synchronizedList创建一个同步的ArrayList
  3. 方案3:所以JUC的CopyOnWriteArrayList
1、方案1:用Vector代替ArrayList
1
2
// Vector解决
List<String> list = new Vector<>();

在Vector底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下Vector是安全的。

但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

2、方案2:Collections.synchronizedList创建一个同步的ArrayList
1
2
//Collections解决
List<String> list = Collections.synchronizedList(new ArrayList<>());

同理这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

那么有没有好的方法,既解决了ArrayList不安全的问题,又不会对程序的效率造成很大的影响?

答:方案3:所以JUC的CopyOnWriteArrayList

1
2
// CopyOnWriteArrayList解决
List<String> list = new CopyOnWriteArrayList<>();

2、JUC的CopyOnWriteArrayList

CopyOnWriteArraySet 是它的马甲 底层实现采用了 写入时拷贝 的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离

1、BAT大厂的面试问题
  • 请先说说非并发集合中Fail-fast机制?
  • 再为什么说ArrayList查询快而增删慢?
  • 对比ArrayList说说CopyOnWriteArrayList的增删改查实现原理?
    • COW基于拷贝
  • 再说下弱一致性的迭代器原理是怎么样的?
    • COWIterator<E>
  • CopyOnWriteArrayList为什么并发安全且性能比Vector好?
  • CopyOnWriteArrayList有何缺陷,说说其应用场景?
2、CopyOnWriteArrayList源码分析
1、类的继承关系

CopyOnWriteArrayList

  • 实现了List接口,List接口定义了对列表的基本操作;
  • 同时实现了RandomAccess接口,表示可以随机访问(数组具有随机访问的特性);
  • 同时实现了Cloneable接口,表示可克隆;
  • 同时也实现了Serializable接口,表示可被序列化。
1
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {}
2、类的内部类——COWIterator类

COWIterator表示迭代器,其也有一个Object类型的数组作为CopyOnWriteArrayList数组的快照,这种快照风格的迭代器方法在创建迭代器时使用了对当时数组状态的引用。此数组在迭代器的生存期内不会更改,因此不可能发生冲突,并且迭代器保证不会抛出 ConcurrentModificationException

创建迭代器以后,迭代器就不会反映列表的添加、移除或者更改。在迭代器上进行的元素更改操作(remove、set 和 add)不受支持。这些方法将抛出 UnsupportedOperationException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
// 快照
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
// 游标
private int cursor;
// 构造函数
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
// 是否还有下一项
public boolean hasNext() {
return cursor < snapshot.length;
}
// 是否有上一项
public boolean hasPrevious() {
return cursor > 0;
}
// next项
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext()) // 不存在下一项,抛出异常
throw new NoSuchElementException();
// 返回下一项
return (E) snapshot[cursor++];
}

@SuppressWarnings("unchecked")
public E previous() {
if (! hasPrevious())
throw new NoSuchElementException();
return (E) snapshot[--cursor];
}

// 下一项索引
public int nextIndex() {
return cursor;
}

// 上一项索引
public int previousIndex() {
return cursor-1;
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code remove}
* is not supported by this iterator.
*/
// 不支持remove操作
public void remove() {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code set}
* is not supported by this iterator.
*/
// 不支持set操作
public void set(E e) {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code add}
* is not supported by this iterator.
*/
// 不支持add操作
public void add(E e) {
throw new UnsupportedOperationException();
}

@Override
public void forEachRemaining(Consumer<? super E> action) {
Objects.requireNonNull(action);
Object[] elements = snapshot;
final int size = elements.length;
for (int i = cursor; i < size; i++) {
@SuppressWarnings("unchecked") E e = (E) elements[i];
action.accept(e);
}
cursor = size;
}
}
3、类的属性

属性中有一个可重入锁,用来保证线程安全访问,还有一个Object类型的数组,用来存放具体的元素。当然,也使用到了反射机制CAS来保证原子性的修改lock域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 8673264195747942595L;
// 可重入锁
final transient ReentrantLock lock = new ReentrantLock();
// 对象数组,用于存放元素
private transient volatile Object[] array;
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// lock域的内存偏移量
private static final long lockOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = CopyOnWriteArrayList.class;
lockOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("lock"));
} catch (Exception e) {
throw new Error(e);
}
}
}
4、类的构造函数
  • 默认构造函数

    • public CopyOnWriteArrayList() {
          // 设置数组
          setArray(new Object[0]);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20

      - `CopyOnWriteArrayList(Collection<? extends E>)`型构造函数——该构造函数用于创建一个按 collection 的迭代器返回元素的顺序包含指定 collection 元素的列表。

      - ```java
      public CopyOnWriteArrayList(Collection<? extends E> c) {
      Object[] elements;
      if (c.getClass() == CopyOnWriteArrayList.class) // 类型相同
      // 获取c集合的数组
      elements = ((CopyOnWriteArrayList<?>)c).getArray();
      else { // 类型不相同
      // 将c集合转化为数组并赋值给elements
      elements = c.toArray();
      // c.toArray might (incorrectly) not return Object[] (see 6260652)
      if (elements.getClass() != Object[].class) // elements类型不为Object[]类型
      // 将elements数组转化为Object[]类型的数组
      elements = Arrays.copyOf(elements, elements.length, Object[].class);
      }
      // 设置数组
      setArray(elements);
      }
    • CopyOnWriteArrayList(Collection<? extends E>)型构造函数的处理流程如下:

      1. 判断传入的集合c的类型是否为CopyOnWriteArrayList类型,若是,则获取该集合类型的底层数组(Object[]),并且设置当前CopyOnWriteArrayList的数组(Object[]数组),进入步骤③;否则,进入步骤②
      2. 将传入的集合转化为数组elements,判断elements的类型是否为Object[]类型(toArray方法可能不会返回Object类型的数组),若不是,则将elements转化为Object类型的数组。进入步骤③
      3. 设置当前CopyOnWriteArrayList的Object[]为elements。
  • CopyOnWriteArrayList(E[])型构造函数

    • 该构造函数用于创建一个保存给定数组的副本的列表。

    • public CopyOnWriteArrayList(E[] toCopyIn) {
          // 将toCopyIn转化为Object[]类型数组,然后设置当前数组
          setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28

      ###### 5、核心函数

      - copyOf
      - add
      - addIfAbsent
      - set
      - remove

      对于CopyOnWriteArrayList的函数分析,主要明白**Arrays.copyOf方法**即可理解CopyOnWriteArrayList其他函数的意义。

      ###### 6、核心函数分析——copyOf

      该函数用于**复制指定的数组,截取或用 null 填充(如有必要),以使副本具有指定的长度**。

      ```java
      public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
      @SuppressWarnings("unchecked")
      // 确定copy的类型(将newType转化为Object类型,将Object[].class转化为Object类型,判断两者是否相等,若相等,则生成指定长度的Object数组
      // 否则,生成指定长度的新类型的数组)
      T[] copy = ((Object)newType == (Object)Object[].class)
      ? (T[]) new Object[newLength]
      : (T[]) Array.newInstance(newType.getComponentType(), newLength);
      // 将original数组从下标0开始,复制长度为(original.length和newLength的较小者),复制到copy数组中(也从下标0开始)
      System.arraycopy(original, 0, copy, 0,
      Math.min(original.length, newLength));
      return copy;
      }
7、核心函数分析——add
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public boolean add(E e) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 元素数组
// 获取旧的数组
Object[] elements = getArray();
// 数组长度
int len = elements.length;
// 复制数组
// 拷贝新的数组(这里是比较耗时的操作,但不影响其它读线程)
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 存放元素e
// 添加新元素
newElements[len] = e;
// 设置数组
// 替换旧的数组
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}

这里的源码版本是 Java 11,在 Java 1.8 中使用的是可重入锁而不是 synchronized

此函数用于将指定元素添加到此列表的尾部,处理流程如下(写时复制技术)(并发读,独立写)

  • 获取锁(保证多线程的安全访问),获取当前的Object数组,获取Object数组的长度为length,进入步骤②。
  • 根据Object数组复制一个长度为length+1的Object数组为newElements(此时,newElements[length]为null),进入下一步骤。
  • 将下标为length的数组元素newElements[length]设置为元素e,再设置当前Object[]为newElements,释放锁,返回。这样就完成了元素的添加。

其实就是写时复制技术,并发读,独立写

  • 读进程读的是原来的版本
  • 写进程写的是原来的版本的复制版本
  • 在写进程完成好写之后,再将复制的版本与原来的版本进行合并

image-20210723213936543

8、核心函数分析——addIfAbsent

该函数用于添加元素(如果数组中不存在,则添加;否则,不添加,直接返回),可以保证多线程环境下不会重复添加元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private boolean addIfAbsent(E e, Object[] snapshot) {
// 重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] current = getArray();
// 数组长度
int len = current.length;
if (snapshot != current) { // 快照不等于当前数组,对数组进行了修改
// Optimize for lost race to another addXXX operation
// 取较小者
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++) // 遍历
if (current[i] != snapshot[i] && eq(e, current[i])) // 当前数组的元素与快照的元素不相等并且e与当前元素相等
// 表示在snapshot与current之间修改了数组,并且设置了数组某一元素为e,已经存在
// 返回
return false;
if (indexOf(e, current, common, len) >= 0) // 在当前数组中找到e元素
// 返回
return false;
}
// 复制数组
Object[] newElements = Arrays.copyOf(current, len + 1);
// 对数组len索引的元素赋值为e
newElements[len] = e;
// 设置数组
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}

该函数的流程如下:

  1. 获取锁,获取当前数组为current,current长度为len,判断数组之前的快照snapshot是否等于当前数组current,若不相等,则进入步骤②;否则,进入步骤④
  2. 不相等,表示在snapshot与current之间,对数组进行了修改(如进行了add、set、remove等操作),获取长度(snapshot与current之间的较小者),对current进行遍历操作,若遍历过程发现snapshot与current的元素不相等并且current的元素与指定元素相等(可能进行了set操作),进入步骤⑤,否则,进入步骤③
  3. 在当前数组中索引指定元素,若能够找到,进入步骤⑤,否则,进入步骤④
  4. 复制当前数组current为newElements,长度为len+1,此时newElements[len]为null。再设置newElements[len]为指定元素e,再设置数组,进入步骤⑤
  5. 释放锁,返回。
9、核心函数分析——set

此函数用于用指定的元素替代此列表指定位置上的元素,也是基于数组的复制来实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public E set(int index, E element) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] elements = getArray();
// 获取index索引的元素
E oldValue = get(elements, index);

if (oldValue != element) { // 旧值不等于element
// 数组长度
int len = elements.length;
// 复制数组
Object[] newElements = Arrays.copyOf(elements, len);
// 重新赋值index索引的值
newElements[index] = element;
// 设置数组
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
// 设置数组
setArray(elements);
}
// 返回旧值
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}
10、核心函数分析——remove

此函数用于移除此列表指定位置上的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public E remove(int index) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] elements = getArray();
// 数组长度
int len = elements.length;
// 获取旧值
E oldValue = get(elements, index);
// 需要移动的元素个数
int numMoved = len - index - 1;
if (numMoved == 0) // 移动个数为0
// 复制后设置数组
setArray(Arrays.copyOf(elements, len - 1));
else { // 移动个数不为0
// 新生数组
Object[] newElements = new Object[len - 1];
// 复制index索引之前的元素
System.arraycopy(elements, 0, newElements, 0, index);
// 复制index索引之后的元素
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 设置索引
setArray(newElements);
}
// 返回旧值
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}

处理流程如下:

  1. 获取锁,获取数组elements,数组长度为length,获取索引的值elements[index],计算需要移动的元素个数(length - index - 1),若个数为0,则表示移除的是数组的最后一个元素,复制elements数组,复制长度为length-1,然后设置数组,进入步骤③;否则,进入步骤②
  2. 先复制index索引前的元素,再复制index索引后的元素,然后设置数组。
  3. 释放锁,返回旧值。
3、CopyOnWriteArrayList示例

下面通过一个示例来了解CopyOnWriteArrayList的使用:

在程序中,有一个PutThread线程会每隔50ms就向CopyOnWriteArrayList中添加一个元素,并且两次使用了迭代器,迭代器输出的内容都是生成迭代器时,CopyOnWriteArrayList的Object数组的快照的内容,在迭代的过程中,往CopyOnWriteArrayList中添加元素也不会抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

class PutThread extends Thread {
private CopyOnWriteArrayList<Integer> cowal;

public PutThread(CopyOnWriteArrayList<Integer> cowal) {
this.cowal = cowal;
}

public void run() {
try {
for (int i = 100; i < 110; i++) {
cowal.add(i);
Thread.sleep(50);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public class CopyOnWriteArrayListDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> cowal = new CopyOnWriteArrayList<Integer>();
for (int i = 0; i < 10; i++) {
cowal.add(i);
}
PutThread p1 = new PutThread(cowal);
p1.start();
Iterator<Integer> iterator = cowal.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
System.out.println();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}

iterator = cowal.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
}
}

运行结果(某一次)

1
2
0 1 2 3 4 5 6 7 8 9 100 
0 1 2 3 4 5 6 7 8 9 100 101 102 103
4、CopyOnWriteArrayList的弱一致性体现
1、get 弱一致性

image-20210814045103054

时间点 操作
1 Thread-0 getArray()
2 Thread-1 getArray()
3 Thread-1 setArray(arrayCopy)
4 Thread-0 array[index]
2、迭代器弱一致性
1
2
3
4
5
6
7
8
9
10
11
12
13
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iter = list.iterator();
new Thread(() -> {
list.remove(0);
System.out.println(list);
},"t1").start();
sleep1s();
while (iter.hasNext()) {
System.out.println(iter.next());
}

虽然线程t1已经将1从list中移除,但是迭代器当中迭代的list依旧是旧的list,有包括1

3、关于弱一致性

不要觉得弱一致性就不好

  • 数据库的 MVCC 都是弱一致性的表现
  • 并发高和一致性是矛盾的,需要权衡
5、更深入理解
1、CopyOnWriteArrayList的缺陷和使用场景

CopyOnWriteArrayList 有几个缺点:

  • 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc
  • 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求;

CopyOnWriteArrayList 合适==读多写少==的场景,不过这类慎用

因为谁也没法保证CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。

2、CopyOnWriteArrayList为什么并发安全性能比Vector好?
  • Vector对单独的add,remove等方法都是在方法上加了synchronized;
  • 并且如果一个线程A调用size时,另一个线程B 执行了remove,然后size的值就不是最新的,然后线程A调用remove就会越界(这时就需要再加一个Synchronized)。这样就导致有了双重锁,效率大大降低,何必呢。
  • 于是vector废弃了,要用就用CopyOnWriteArrayList 吧。

3、HashMap不安全

HashMap的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,HashMap是不安全的。

示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadDemo4 {
public static void main(String[] args) {
//演示HashMap
Map<String,String> map = new HashMap<>();

for (int i = 0; i <30; i++) {
String key = String.valueOf(i);
new Thread(()->{
//向集合添加内容
map.put(key,UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(map);
},String.valueOf(i)).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{22=b7638976, 23=918c6021, 24=254a542e, 26=effdaef0, 27=b0fd0006, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 8=a8951500, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 28=85c91660, 29=930cd3b4, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 28=85c91660, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
Exception in thread "4" Exception in thread "0" Exception in thread "5" Exception in thread "17" Exception in thread "1" Exception in thread "7" Exception in thread "14" Exception in thread "19" Exception in thread "26" Exception in thread "27" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1437)
at java.util.HashMap$EntryIterator.next(HashMap.java:1471)
at java.util.HashMap$EntryIterator.next(HashMap.java:1469)
at java.util.AbstractMap.toString(AbstractMap.java:554)
at java.lang.String.valueOf(String.java:2994)
at java.io.PrintStream.println(PrintStream.java:821)
at test.ThreadDemo4.lambda$main$0(ThreadDemo4.java:20)
at java.lang.Thread.run(Thread.java:748)

解决HashMap在多线程环境下不安全的问题:

  1. 方案1:用HashTable代替HashMap
  2. 方案2:JUC的ConcurrentHashMap
方案1:用HashTable代替HashMap
1
2
// 用HashTable代替HashMap
Map<String,String> map = new HashTable<>();

在HashTable底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下HashTable是安全的。

但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

那么有没有好的方法,既解决了HashMap不安全的问题,又不会对程序的效率造成很大的影响?

答:方案2:所以JUC的ConcurrentHashMap

1
2
// ConcurrentHashMap解决
Map<String,String> map = new ConcurrentHashMap<>();

4、JUC的ConcurrentHashMap

1、BAT大厂的面试问题
  • 为什么HashTable慢?它的并发度是什么?那么ConcurrentHashMap并发度是什么?
  • ConcurrentHashMap在JDK1.7和JDK1.8中实现有什么差别?JDK1.8解決了JDK1.7中什么问题?
  • ConcurrentHashMap JDK1.7实现的原理是什么?
    • 分段锁机制
  • ConcurrentHashMap JDK1.8实现的原理是什么?
    • 数组+链表+红黑树,CAS
  • ConcurrentHashMap JDK1.7中Segment数(concurrencyLevel)默认值是多少?为何一旦初始化就不可再扩容?
  • ConcurrentHashMap JDK1.7说说其put的机制?
  • ConcurrentHashMap JDK1.7是如何扩容的?
    • rehash(注:segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容)
  • ConcurrentHashMap JDK1.8是如何扩容的?
    • tryPresize
  • ConcurrentHashMap JDK1.8链表转红黑树的时机是什么?临界值为什么是8?
  • ConcurrentHashMap JDK1.8是如何进行数据迁移的?
    • transfer
  • JDK 7 HashMap 并发死链问题
2、为什么HashTable慢

Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。

3、ConcurrentHashMap - JDK1.7

在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap.

简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可实现多线程put操作。

  • 优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
  • 缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化

接下来分析JDK1.7版本中ConcurrentHashMap的实现原理。

1、数据结构

整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表“部分”或“一段”的意思,所以很多地方都会将其描述为分段锁。一般使用“槽”来代表一个 segment。

简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

img

concurrencyLevel:并行级别、并发数、Segment 数,怎么翻译不重要,重要的是理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。

再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。

2、初始化
  • initialCapacity初始容量。这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment
  • loadFactor负载因子。之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
// ssize 必须是 2^n, 即 2, 4, 8, 16 ... 表示了 segments 数组的大小
int sshift = 0;
int ssize = 1;
// 计算并行级别 ssize,因为要保持并行级别是 2 的 n 次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 我们这里先不要那么烧脑,用默认值,concurrencyLevel 为 16,sshift 为 4
// segmentShift 默认是 32 - 4 = 28
// 那么计算出 segmentShift 为 28,segmentMask 为 15,即 0000 0000 0000 1111后面会用到这两个值
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;// 掩码

if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;

// initialCapacity 是设置整个 map 初始的大小,
// 这里根据 initialCapacity 计算 Segment 数组中每个位置可以分到的大小
// 如 initialCapacity 为 64,那么每个 Segment 或称之为"槽"可以分到 4 个
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 默认 MIN_SEGMENT_TABLE_CAPACITY 是 2,这个值也是有讲究的,因为这样的话,对于具体的槽上,
// 插入一个元素不至于扩容,插入第二个的时候才会扩容
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;

// 创建 Segment 数组,
// 并创建数组的第一个元素 segment[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// 往数组写入 segment[0]
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}

初始化完成,我们得到了一个 Segment 数组。如下图所示:

image-20210814031243376

我们就当是用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:

  • Segment 数组长度为 16,不可以扩容
  • Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容
  • 这里初始化了 segment[0],其他位置还是 null,至于为什么要初始化 segment[0],后面的代码会介绍
  • 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数掩码,这两个值马上就会用到

可以看到 ConcurrentHashMap 没有实现懒惰初始化,空间占用不友好

其中 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment

例如,根据某一 hash 值求 segment 位置,先将高位向低位移动 this.segmentShift 位

image-20210814031359616

结果再与 this.segmentMask 做位于运算,最终得到 1010 即下标为 10 的 segment

image-20210814031416319

3、put过程分析

先看 put 的主流程,对于其中的一些关键细节操作,后面会进行详细介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 1. 计算 key 的 hash 值
int hash = hash(key);
// 2. 根据 hash 值找到 Segment 数组中的位置 j
// hash 是 32 位,无符号右移 segmentShift(28) 位,剩下高 4 位,
// 然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的高 4 位,也就是槽的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 刚刚说了,初始化的时候初始化了 segment[0],但是其他位置还是 null,
// 获得 segment 对象, 判断是否为 null, 是则创建该 segment
// ensureSegment(j) 对 segment[j] 进行初始化
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 这时不能确定是否真的为 null, 因为其它线程也发现该 segment 为 null,
// 因此在 ensureSegment 里用 cas 方式保证该 segment 安全性
s = ensureSegment(j);
// 3. 插入新值到 槽 s 中
// 进入 segment 的put 流程
return s.put(key, hash, value, false);
}

第一层皮很简单,根据 hash 值很快就能找到相应的 Segment,之后就是 Segment 内部的 put 操作了。

Segment 内部是由 数组+链表 组成的。segment 继承了可重入锁(ReentrantLock),它的 put 方法为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 segment 写入前,需要先获取该 segment 的独占锁
// 先看主流程,后面还会具体介绍这部分内容
// 尝试加锁
HashEntry<K,V> node = tryLock() ? null :
// 如果不成功, 进入 scanAndLockForPut 流程
// 如果是多核 cpu 最多 tryLock 64 次, 进入 lock 流程
// 在尝试期间, 还可以顺便看该节点在链表中有没有, 如果没有顺便创建出来
scanAndLockForPut(key, hash, value);
// 执行到这里 segment 已经被成功加锁, 可以安全执行
V oldValue;
try {
// 这个是 segment 内部的数组
HashEntry<K,V>[] tab = table;
// 再利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
HashEntry<K,V> first = entryAt(tab, index);

// 下面这串 for 循环虽然很长,不过也很好理解,想想该位置没有任何元素和已经存在一个链表这两种情况
for (HashEntry<K,V> e = first;;) {
if (e != null) {
// 更新
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
// 覆盖旧值
e.value = value;
++modCount;
}
break;
}
// 继续顺着链表走
e = e.next;
}
else {
// 新增
// 1) 之前等待锁时, node 已经被创建, next 指向链表头
// node 到底是不是 null,这个要看获取锁的过程,不过和这里都没有关系。
// 如果不为 null,那就直接将它设置为链表表头;如果是null,初始化并设置为链表表头。
if (node != null)
node.setNext(first);
else
// 2) 创建新 node
node = new HashEntry<K,V>(hash, key, value, first);

int c = count + 1;
// 3) 扩容
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 其实就是将新的节点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
// 解锁
unlock();
}
return oldValue;
}

整体流程还是比较简单的,由于有独占锁的保护,所以 segment 内部的操作并不复杂。至于这里面的并发问题,我们稍后再进行介绍。

到这里 put 操作就结束了,接下来,我们说一说其中几步关键的操作。

4、初始化槽:ensureSegment

ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。

这里需要考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k],不过只要有一个成功了就可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 这里看到为什么之前要初始化 segment[0] 了,
// 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k]
// 为什么要用“当前”,因为 segment[0] 可能早就扩容过了
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);

// 初始化 segment[k] 内部的数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // 再次检查一遍该槽是否被其他线程初始化了。

Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}

总的来说,ensureSegment(int k) 比较简单,对于并发操作使用 CAS 进行控制

5、获取写入锁:scanAndLockForPut

前面我们看到,在往某个 segment 中 put 的时候,首先会调用

1
node = tryLock() ? null : scanAndLockForPut(key, hash, value)

也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。

下面我们来具体分析这个方法中是怎么控制加锁的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node

// 循环获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
// 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
// 所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}

这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。

这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node

6、扩容:rehash

重复一下,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍

由于扩容发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全

首先,我们要回顾一下触发扩容的地方,put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值。

1
2
3
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析

该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 2 倍
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;

// 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素,那比较好办
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry<K,V> lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;

// 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
// 过一遍链表, 尽可能把 rehash 后 idx 不变的节点重用(直接搬移,没有进行头插法)
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的节点,剩余节点需要新建
// 这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 扩容完成, 才加入新的节点
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
// 替换为新的 HashEntry table
table = newTable;
}

这里的扩容比之前的 HashMap 要复杂一些,代码难懂一点。上面有两个挨着的 for 循环,第一个 for 有什么用呢?

仔细一看发现,如果没有第一个 for 循环,也是可以工作的,但是,这个 for 循环下来,如果 lastRun 的后面还有比较多的节点,那么这次就是值得的。因为我们只需要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 走就是了,不需要做任何操作。

我觉得 Doug Lea 的这个想法也是挺有意思的,不过比较坏的情况就是每次 lastRun 都是链表的最后一个元素或者很靠后的元素,那么这次遍历就有点浪费了。不过 Doug Lea 也说了,根据统计,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。

7、get过程分析

相对于 put 来说,get 就很简单了。

  • 计算 hash 值,找到 segment 数组中的具体位置,或我们前面用的“槽”
  • 槽中也是一个数组,根据 hash 找到数组中具体的位置
  • 到这里是链表了,顺着链表进行查找即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
// 1. hash 值
int h = hash(key);
// u 为 segment 对象在数组中的偏移量
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 2. 根据 hash 找到对应的 segment
// s 即为 segment
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 3. 找到segment 内部数组相应位置的链表,遍历
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
8、size计算流程
  • 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
  • 如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
// 超过重试次数, 需要创建所有 segment 并加锁
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 在这里处理的很巧妙
// 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
// 先判断最后得出来的结果是不是与上次得到的结果一致:
// 一致,跳出循环
// 不一致,将本次得到的结果设置为最后的结果,方面下一次的对比
// 重试次数超过 3,将所有 segment 锁住,重新计算个数返回
// 在上面if (retries++ == RETRIES_BEFORE_LOCK)块内上锁,在下面finally的if块中解锁
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
9、并发问题分析

现在我们已经说完了 put 过程和 get 过程,我们可以看到 get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。

添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。

  • put 操作的线程安全性:
    • 初始化槽,这个我们之前就说过了,使用了 CAS 来初始化 Segment 中的数组
    • 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 **get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject**。
    • 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 ==table 使用了 volatile 关键字==。
  • remove 操作的线程安全性:
    • remove 操作我们没有分析源码,所以这里说的读者感兴趣的话还是需要到源码中去求实一下的。
    • get 操作需要遍历链表,但是 remove 操作会”破坏”链表
    • 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题
    • 如果 remove 先破坏了一个节点,分两种情况考虑。
      1. 如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
      2. 如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的
4、ConcurrentHashMap - JDK1.8

在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现

1、数据结构

img

结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。

重要属性和内部类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 默认为 0
// 当初始化时, 为 -1
// 当扩容时, 为 -(1 + 扩容线程数)
// 当初始化或扩容完成后,为 下一次的扩容的阈值大小,也就是容量的3/4
private transient volatile int sizeCtl;

// 整个 ConcurrentHashMap 就是一个 Node[]
// Node里面的属性:键。值、hash码、next
static class Node<K,V> implements Map.Entry<K,V> {}

// hash 表
transient volatile Node<K,V>[] table;

// 扩容时的 新 hash 表
private transient volatile Node<K,V>[] nextTable;

// 扩容时如果某个 bin 迁移完毕, 用 ForwardingNode 作为旧 table bin 的头结点
static final class ForwardingNode<K,V> extends Node<K,V> {}

// 用在 compute 以及 computeIfAbsent 时, 用来占位, 计算完成后替换为普通
Node static final class ReservationNode<K,V> extends Node<K,V> {}

// 作为 treebin 的头节点, 存储 root 和 first
static final class TreeBin<K,V> extends Node<K,V> {}

// 作为 treebin 的节点, 存储 parent, left, right
static final class TreeNode<K,V> extends Node<K,V> {}

相关的重要方法

1
2
3
4
5
6
7
8
// 获取 Node[] 中第 i 个 Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i)

// cas 修改 Node[] 中第 i 个 Node 的值, c 为旧值, v 为新值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v)

// 直接修改 Node[] 中第 i 个 Node 的值, v 为新值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v)
2、初始化
1
2
3
4
5
6
7
8
9
10
11
// 这构造函数里,什么都不干
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}

这个初始化方法有点意思,通过提供初始容量,计算了 sizeCtl:sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。

sizeCtl 这个属性使用的场景很多,不过只要跟着文章的思路来,就不会被它搞晕了。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 有参构造
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
// tableSizeFor 仍然是保证计算的大小是 2^n, 即 16,32,64 ...
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}

可以看到实现了懒惰初始化,在构造方法中仅仅计算了 table 的大小,以后在第一次使用时才会真正创建

3、put过程分析

仔细地一行一行代码看下去:(以下数组简称(table),链表简称(bin))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 得到 hash 值
// 其中 spread 方法会综合高位低位, 具有更好的 hash 性
int hash = spread(key.hashCode());
// 用于记录相应链表的长度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
// f 是链表头节点
// fh 是链表头结点的 hash
// i 是链表在 table 中的下标
Node<K,V> f; int n, i, fh;
// 要创建 table
// 如果数组"空",进行数组初始化
if (tab == null || (n = tab.length) == 0)
// 初始化数组,后面会详细介绍
// 初始化 table 使用了 cas, 无需 synchronized 创建成功, 进入下一轮循环
tab = initTable();

// 找该 hash 值对应的数组下标,得到第一个节点 f
// 要创建链表头节点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组该位置为空,
// 用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了,可以拉到最后面了
// 如果 CAS 失败,那就是有并发操作,进到下一个循环就好了
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// hash 居然可以等于 MOVED,这个需要到后面才能看明白,不过从名字上也能猜到,肯定是因为在扩容
// 帮忙扩容
else if ((fh = f.hash) == MOVED)
// 帮助数据迁移,这个等到看完数据迁移部分的介绍后,再理解这个就很简单了、
// 帮忙之后, 进入下一轮循环
tab = helpTransfer(tab, f);

else { // 到这里就是说,f 是该位置的头结点,而且不为空

V oldVal = null;
// 获取数组该位置的头结点的监视器锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 头结点的 hash 值大于 0,说明是链表
// 用于累加,记录链表的长度
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 到了链表的最末端,将这个新值放到链表的最后面
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) { // 红黑树
Node<K,V> p;
binCount = 2;
// 调用红黑树的插值方法插入新节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}

if (binCount != 0) {
// 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
if (binCount >= TREEIFY_THRESHOLD)
// 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
// 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
// 具体源码我们就不看了,扩容部分后面说
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 增加 size 计数
addCount(1L, binCount);
return null;
}

addCount()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// check 是之前 binCount 的个数(也就是链表的长度)
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if (
// 已经有了 counterCells, 向 cell 累加
(as = counterCells) != null ||
// 还没有, 向 baseCount 累加
U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)
) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (
// 还没有 counterCells
as == null || (m = as.length - 1) < 0 ||
// 还没有 cell
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
// cell cas 增加计数失败
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
) {
// 创建累加单元数组和cell, 累加重试
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 获取元素个数
s = sumCount();
}
// 可能需要扩容
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// newtable 已经创建了,帮忙扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 需要扩容,这时 newtable 未创建
else if (U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}

这个增加size计数的方法与LongAdder的原理有点像:都是采用的分段累加的思想

它还有一个功能就是:扩容

4、初始化数组:initTable

这个比较简单,主要就是初始化一个合适大小的数组,然后会设置 sizeCtl。

初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 初始化的"功劳"被其他线程"抢去"了
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 一下,将 sizeCtl 设置为 -1,代表抢到了锁(表示初始化 table)
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 获得锁, 创建 table, 这时其它线程会在 while() 循环中 yield 直至 table 创建
if ((tab = table) == null || tab.length == 0) {
// DEFAULT_CAPACITY 默认初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化数组,长度为 16 或初始化时提供的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将这个数组赋值给 table,table 是 volatile 的
table = tab = nt;
// 如果 n 为 16 的话,那么这里 sc = 12
// 其实就是 0.75 * n
sc = n - (n >>> 2);
}
} finally {
// 设置 sizeCtl 为 sc,我们就当是 12 吧
sizeCtl = sc;
}
break;
}
}
return tab;
}
5、链表转红黑树:treeifyBin

前面我们在 put 源码分析也说过,treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容

我们还是进行源码分析吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// MIN_TREEIFY_CAPACITY 为 64
// 所以,如果数组长度小于 64 的时候,其实也就是 32 或者 16 或者更小的时候,会进行数组扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 后面我们再详细分析这个方法
tryPresize(n << 1);
// b 是头结点
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 加锁
synchronized (b) {

if (tabAt(tab, index) == b) {
// 下面就是遍历链表,建立一颗红黑树
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将红黑树设置到数组相应位置中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
6、扩容:tryPresize

如果说 Java8 ConcurrentHashMap 的源码不简单,那么说的就是扩容操作和迁移操作。

这个方法要完完全全看懂还需要看之后的 transfer 方法,读者应该提前知道这点。

这里的扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 首先要说明的是,方法参数 size 传进来的时候就已经翻了倍了(n << 1)
private final void tryPresize(int size) {
// c: size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方。
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;

// 这个 if 分支和之前说的初始化数组的代码基本上是一样的,在这里,我们可以不用管这块代码
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2); // 0.75 * n
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
// 我没看懂 rs 的真正含义是什么,不过也关系不大
int rs = resizeStamp(n);

if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 2. 用 CAS 将 sizeCtl 加 1,然后执行 transfer 方法
// 此时 nextTab 不为 null
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 1. 将 sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2)
// 我是没看懂这个值真正的意义是什么? 不过可以计算出来的是,结果是一个比较大的负数
// 调用 transfer 方法,此时 nextTab 参数为 null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}

这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。

所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。

7、数据迁移:transfer

下面这个方法有点长,将原来的 tab 数组的元素迁移到新的 nextTab 数组中。

虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。

此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。

阅读源码之前,先要理解并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。

第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;

// stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16
// stride 可以理解为”步长“,有 n 个位置是需要进行迁移的,
// 将这 n 个任务分为多个任务包,每个任务包有 stride 个任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range

// 如果 nextTab 为 null,先进行一次初始化
// 前面我们说了,外围会保证第一个发起迁移的线程调用此方法时,参数 nextTab 为 null
// 之后参与迁移的线程调用此方法时,nextTab 不会为 null
if (nextTab == null) {
try {
// 容量翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
// nextTable 是 ConcurrentHashMap 中的属性
nextTable = nextTab;
// transferIndex 也是 ConcurrentHashMap 的属性,用于控制迁移的位置
transferIndex = n;
}

int nextn = nextTab.length;

// ForwardingNode 翻译过来就是正在被迁移的 Node
// 这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
// 后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
// 就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
// 所以它其实相当于是一个标志。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);


// advance 指的是做完了一个位置的迁移工作,可以准备做下一个位置的了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab

/*
* 下面这个 for 循环,最难理解的在前面,而要看懂它们,应该先看懂后面的,然后再倒回来看
*
*/

// i 是位置索引,bound 是边界,注意是从后往前
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;

// 下面这个 while 真的是不好理解
// advance 为 true 表示可以进行下一个位置的迁移了
// 简单理解结局: i 指向了 transferIndex,bound 指向了 transferIndex-stride
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;

// 将 transferIndex 值赋给 nextIndex
// 这里 transferIndex 一旦小于等于 0,说明原数组的所有位置都有相应的线程去处理了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 看括号中的代码,nextBound 是这次迁移任务的边界,注意,是从后往前
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
// 所有的迁移操作已经完成
nextTable = null;
// 将新的 nextTab 赋值给 table 属性,完成迁移
table = nextTab;
// 重新计算 sizeCtl: n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}

// 之前我们说过,sizeCtl 在迁移前会设置为 (rs << RESIZE_STAMP_SHIFT) + 2
// 然后,每有一个线程参与迁移就会将 sizeCtl 加 1,
// 这里使用 CAS 操作对 sizeCtl 进行减 1,代表做完了属于自己的任务
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 任务结束,方法退出
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;

// 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
// 也就是说,所有的迁移任务都做完了,也就会进入到上面的 if(finishing){} 分支了
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果位置 i 处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode ”空节点“
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 该位置处是一个 ForwardingNode,代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 头结点的 hash 大于 0,说明是链表的 Node 节点
if (fh >= 0) {
// 下面这一块和 Java7 中的 ConcurrentHashMap 迁移是差不多的,
// 需要将链表一分为二,
// 找到原链表中的 lastRun,然后 lastRun 及其之后的节点是一起进行迁移的
// lastRun 之前的节点需要进行克隆,然后分到两个链表中
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 其中的一个链表放在新数组的位置 i
setTabAt(nextTab, i, ln);
// 另一个链表放在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
else if (f instanceof TreeBin) {
// 红黑树的迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果一分为二后,节点数少于 8,那么将红黑树转换回链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;

// 将 ln 放置在新数组的位置 i
setTabAt(nextTab, i, ln);
// 将 hn 放置在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
}
}
}
}
}

说到底,transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制

这个时候,再回去仔细看 tryPresize 方法可能就会更加清晰一些了。

8、get过程分析

get 方法从来都是最简单的,这里也不例外:

  • 计算 hash 值
  • 根据 hash 值找到数组对应位置:(n - 1) & h
  • 根据该位置处结点性质进行相应查找
    • 如果该位置为 null,那么直接返回 null 就可以了
    • 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
    • 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
    • 如果以上 3 条都不满足,那就是链表,进行遍历比对即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// spread 方法能确保返回结果是正数
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 判断头结点是否就是我们需要的节点
if ((eh = e.hash) == h) { // 如果头结点已经是要查找的 key
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果头结点的 hash 小于 0,说明 正在扩容,或者该位置是红黑树
// hash 为负数表示该 bin 在扩容中或是 treebin, 这时调用 find 方法来查找
else if (eh < 0)
// 参考 ForwardingNode.find(int h, Object k) 和 TreeBin.find(int h, Object k)
return (p = e.find(h, key)) != null ? p.val : null;

// 遍历链表
// 正常遍历链表, 用 equals 比较
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}

简单说一句,此方法的大部分内容都很简单,只有正好碰到扩容的情况,ForwardingNode.find(int h, Object k) 稍微复杂一些,不过在了解了数据迁移的过程后,这个也就不难了,所以限于篇幅这里也不展开说了。

9、size 计算流程

size 计算实际发生在 put,remove 改变集合元素的操作之中

  • 没有竞争发生,向 baseCount 累加计数
  • 有竞争发生,新建 counterCells,向其中的一个 cell 累加计数
    • counterCells 初始有两个 cell
    • 如果计数竞争比较激烈,会创建新的 cell 来累加计数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

final long sumCount() {
CounterCell[] as = counterCells;
CounterCell a;
// 将 baseCount 计数与所有 cell 计数累加
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
10、总结

Java 8 数组(Node) +( 链表 Node | 红黑树 TreeNode ) 以下数组简称(table),链表简称(bin)

  • 初始化,使用 cas 来保证并发安全,懒惰初始化 table
  • 树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程会用 synchronized 锁住链表头
  • put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素添加至 bin 的尾部
  • get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 它会让 get 操作在新 table 进行搜索
  • 扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中
  • size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加即可
5、对比总结
  • HashTable:使用了synchronized关键字对put等操作进行加锁;
  • ConcurrentHashMap JDK1.7:使用分段锁机制实现;
  • ConcurrentHashMap JDK1.8:则使用数组+链表+红黑树数据结构和CAS原子操作实现;
6、正确使用ConcurrentHashMap——computeIfAbsent()方法

示例:单词计数

生成测试数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static final String ALPHA = "abcedfghijklmnopqrstuvwxyz";

public static void main(String[] args) {
int length = ALPHA.length();
int count = 200;
List<String> list = new ArrayList<>(length * count);
for (int i = 0; i < length; i++) {
char ch = ALPHA.charAt(i);
for (int j = 0; j < count; j++) {
list.add(String.valueOf(ch));
}
}
Collections.shuffle(list);
for (int i = 0; i < 26; i++) {
try (PrintWriter out = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream("tmp/" + (i+1) + ".txt")))) {
String collect = list.subList(i * count, (i + 1) * count).stream()
.collect(Collectors.joining("\n"));
out.print(collect);
} catch (IOException e) {
}
}
}

模版代码,模版代码中封装了多线程读取文件的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private static <V> void demo(Supplier<Map<String,V>> supplier, BiConsumer<Map<String,V>,List<String>> consumer) {
Map<String, V> counterMap = supplier.get();
List<Thread> ts = new ArrayList<>();
for (int i = 1; i <= 26; i++) {
int idx = i;
Thread thread = new Thread(() -> {
List<String> words = readFromFile(idx);
consumer.accept(counterMap, words);
});
ts.add(thread);
}
ts.forEach(t->t.start());
ts.forEach(t-> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(counterMap);
}

public static List<String> readFromFile(int i) {
ArrayList<String> words = new ArrayList<>();
try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("tmp/" + i +".txt")))) {
while(true) {
String word = in.readLine(); if(word == null) {
break;
}
words.add(word);
}
return words;
} catch (IOException e) {
throw new RuntimeException(e);
}
}

你要做的是实现两个参数

  • 一是提供一个 map 集合,用来存放每个单词的计数结果,key 为单词,value 为计数
  • 二是提供一组操作,保证计数的安全性,会传递 map 集合以及 单词 List

正确结果输出应该是每个单词出现 200 次

1
{a=200, b=200, c=200, d=200, e=200, f=200, g=200, h=200, i=200, j=200, k=200, l=200, m=200, n=200, o=200, p=200, q=200, r=200, s=200, t=200, u=200, v=200, w=200, x=200, y=200, z=200} 

下面的实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
demo(
// 创建 map 集合
// 创建 ConcurrentHashMap 对不对?
() -> new HashMap<String, Integer>(),
// 进行计数
(map, words) -> {
for (String word : words) {
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter + 1;
map.put(word, newValue);
}
}
);

有没有问题?请改进

问题:使用HashMap,线程不安全

改进:使用ConcurrentHashMap替换HashMap

运行发现问题:就算加上了ConcurrentHashMap也不能保证线程安全

原因:原因不难发现,ConcurrentHashMap只是保证了单一操作的线程安全,但是单一线程的组合并不保证线程安全。我们可以发现:

  • Integer counter = map.get(word);:根据Key(word单词)获取Value(计数)——读操作
  • int newValue = counter == null ? 1 : counter + 1; map.put(word, newValue);
    • 如果Key(单词)存在,则计数加1
    • 如果Key(单词)不存在,则计数为1
    • 在将结果的Key与Value放入Map容器当中——写操作

虽然ConcurrentHashMap能保证单一的读操作或单一读操作的线程安全,但是读操作与写操作的组合并不能保证线程安全

  • 解决方法1:将读操作与写操作一起加入Synchronized(map)同步代码块当中

    • 缺点:锁的粒度太大,线程的效率降低
  • 解决方法2:使用ConcurrentHashMap的computeIfAbsent()方法

    • 注意:

      • 累加操作也是需要保证线程安全性,所以使用的是LongAdder累加器来完成累加操作
      • 注意不能使用 putIfAbsent,此方法返回的是上一次的 value,首次调用返回 null
    • demo(
          // 创建 map 集合
          // 创建 ConcurrentHashMap 对不对?
          () -> new ConcurrentHashMap<String, LongAdder>(8,0.75f,8),
      
          (map, words) -> {
              for (String word : words) {
      
                  // 如果缺少一个 key,则计算生成一个 value , 然后将  key value 放入 map
                  //                  a      0
                  LongAdder value = map.computeIfAbsent(word, (key) -> new LongAdder());
                  // 执行累加
                  value.increment(); // 2
      
                  /*// 检查 key 有没有
                              Integer counter = map.get(word);
                              int newValue = counter == null ? 1 : counter + 1;
                              // 没有 则 put
                              map.put(word, newValue);*/
              }
          }
      );
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 解决方法3:使用函数式编程,无需原子变量——使用ConcurrentHashMap的merge方法

      - ```java
      demo(
      () -> new ConcurrentHashMap<String, Integer>(),
      (map, words) -> {
      for (String word : words) {
      // 函数式编程,无需原子变量
      map.merge(word, 1, Integer::sum);
      }
      }
      );
7、JDK 7 HashMap 并发死链问题原理
1、JDK 7 HashMap 并发死链问题

JDK 7 HashMap会出现死链问题的原因:JDK 7 HashMap的扩容数组的方法

  • JDK 7 HashMap使用的是头插法进行扩容数组的(JDK 8 HashMap使用的是尾插法——“七上八下”)
  • 在多线程下,就有可能出现死链问题
    • 实际上就是一个线程在扩容时把链表节点倒过来了,而另一个线程在扩容时正好也在前一个节点,就死循环了

下面使用一些测试代码和用debug的模式来验证JDK 7 HashMap 并发死链问题

注意:

  • 要在 JDK 7 下运行,否则扩容机制和 hash 的计算方法都变了
  • 以下测试代码是精心准备的,不要随便改动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.HashMap;

public class TestDeadLink {
public static void main(String[] args) {
// 测试 java 7 中哪些数字的 hash 结果相等
System.out.println("长度为16时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 16 == 1) {
System.out.println(i);
}
}
System.out.println("长度为32时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 32 == 1) {
System.out.println(i);
}
}
// 1, 35, 16, 50 当大小为16时,它们在一个桶内
final HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
// 放 12 个元素
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
map.put(6, null);
map.put(7, null);
map.put(8, null);
map.put(9, null);
map.put(10, null);
map.put(16, null);
map.put(35, null);
map.put(1, null);

System.out.println("扩容前大小[main]:"+map.size());
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null);
System.out.println("扩容后大小[Thread-0]:"+map.size());
}
}.start();
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null);
System.out.println("扩容后大小[Thread-1]:"+map.size());
}
}.start();
}

final static int hash(Object k) {
int h = 0;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
}
2、死链复现

调试工具使用 idea

在 HashMap 源码 590 行加断点

1
int newCapacity = newTable.length;

断点的条件如下,目的是让 HashMap 在扩容为 32 时,并且线程为 Thread-0 或 Thread-1 时停下来

1
2
3
4
5
newTable.length==32 && 
(
Thread.currentThread().getName().equals("Thread-0")||
Thread.currentThread().getName().equals("Thread-1")
)

断点暂停方式选择 Thread,否则在调试 Thread-0 时,Thread-1 无法恢复运行运行代码,程序在预料的断点位置停了下来,输出

1
2
3
4
5
6
7
8
9
长度为16时,桶下标为1的key 
1
16
35
50
长度为32时,桶下标为1的key
1
35
扩容前大小[main]:12

接下来进入扩容流程调试

在 HashMap 源码 594 行加断点

1
2
3
Entry<K,V> next = e.next; // 593 
if (rehash) // 594
// ...

这是为了观察 e 节点和 next 节点的状态,Thread-0 单步执行到 594 行,再 594 处再添加一个断点,条件为

1
Thread.currentThread().getName().equals("Thread-0")

这时可以在 Variables 面板观察到 e 和 next 变量,使用 view as -> Object 查看节点状态

1
2
e		(1)->(35)->(16)->null
next (35)->(16)->null

在 Threads 面板选中 Thread-1 恢复运行,可以看到控制台输出新的内容如下,Thread-1 扩容已完成

1
2
newTable[1]  (35)->(1)->null 
扩容后大小:13

这时 Thread-0 还停在 594 处, Variables 面板变量的状态已经变化为

1
2
e		(1)->null
next (35)->(1)->null

为什么呢,因为 Thread-1 扩容时链表也是后加入的元素放入链表头,因此链表就倒过来了,但 Thread-1 虽然结果正确,但它结束后 Thread-0 还要继续运行

接下来就可以单步调试(F8)观察死链的产生了

下一轮循环到 594,将 e 搬迁到 newTable 链表头

1
2
3
newTable[1]		(35)->(1)->null 
e (1)->null
next (1)->null

下一轮循环到 594,将 e 搬迁到 newTable 链表头

1
2
3
newTable[1]		(35)->(1)->null 
e (1)->null
next null

再看看源码

1
2
3
4
5
6
7
e.next = newTable[1];
// 这时 e (1,35)
// 而 newTable[1] (35,1)->(1,35) 因为是同一个对象
newTable[1] = e;
// 再尝试将 e 作为链表头, 死链已成
e = next;
// 虽然 next 是 null, 会进入下一个链表的复制, 但死链已经形成了
3、通过JDK 7 HashMap源码分析死链问题

HashMap 的并发死链发生在扩容时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 将 table 迁移至 newTable
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
// 1 处
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 2 处
// 将新元素加入 newTable[i], 原 newTable[i] 作为新元素的
next e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}

假设 map 中初始元素是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
原始链表,格式:[下标] (key,next)
[1] (1,35)->(35,16)->(16,null)
线程 a 执行到 1 处 ,此时局部变量 e 为 (1,35),而局部变量 next 为 (35,16) 线程 a 挂起
线程 b 开始执行
第一次循环
[1] (1,null)
第二次循环
[1] (35,1)->(1,null)
第三次循环
[1] (35,1)->(1,null)
[17] (16,null)
切换回线程 a,此时局部变量 e 和 next 被恢复,引用没变但内容变了:e 的内容被改为 (1,null),而 next 的内容被改为 (35,1) 并链向 (1,null)
第一次循环
[1] (1,null)
第二次循环,注意这时 e 是 (35,1) 并链向 (1,null) 所以 next 又是 (1,null)
[1] (35,1)->(1,null)
第三次循环,e 是 (1,null),而 next 是 null,但 e 被放入链表头,这样 e.next 变成了 35 (2 处)
[1] (1,35)->(35,1)->(1,35)
已经是死链了
4、小结
  • 究其原因,是因为在多线程环境下使用了非线程安全的 map 集合
  • JDK 8 虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序)(尾插法),但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)

5、HashSet不安全

同理,HashSet在多线程的环境下也是不安全的。解决方法:JUC的CopyOnWriteArraySet

CopyOnWriteArraySet:对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList

10、JUC并发集合:BlockingQueue接口(阻塞队列)

JUC里的 BlockingQueue 接口表示一个线程安全放入和提取实例的队列。下面将给你演示如何使用这个 BlockingQueue,不会讨论如何在 Java 中实现一个你自己的 BlockingQueue。

1、BAT大厂的面试问题

  • 什么是BlockingDeque?
  • BlockingQueue大家族有哪些?
    • ArrayBlockingQueue, DelayQueue, LinkedBlockingQueue, SynchronousQueue…
  • BlockingQueue适合用在什么样的场景?
  • BlockingQueue常用的方法?
  • BlockingQueue插入方法有哪些?这些方法(add(o),offer(o),put(o),offer(o, timeout, timeunit))的区别是什么?
  • BlockingDeque 与BlockingQueue有何关系,请对比下它们的方法?
  • BlockingDeque适合用在什么样的场景?
  • BlockingDeque大家族有哪些?
  • BlockingDeque 与BlockingQueue实现例子?

2、BlockingQueue和BlockingDeque

1、BlockingQueue
1、什么是BlockQueue?

BlockingQueue 通常用于==一个线程生产对象,而另外一个线程消费这些对象==的场景。下图是对这个原理的阐述:

img

线程1往阻塞队列里添加元素,线程2从阻塞队列里移除元素。

  • 一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的
  • 如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。
  • 负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。
2、为什么需要BlockQueue?
  • 好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue 都给你一手包办了
  • 在 concurrent 包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。
3、适用场景——经典的“生产者”和 “消费者”模型

在多线程环境中,通过队列可以很容易实现数据共享,比如经典的“生产者”和 “消费者”模型中,通过队列可以很便利地实现两者之间的数据共享。

假设我们有若干生产者线程,另外又有若干个消费者线程。如果生产者线程需要把准备好的数据共享给消费者线程,利用队列的方式来传递数据,就可以很方便地解决他们之间的数据共享问题。

但如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,那么生产者必须暂停等待一下(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕,反之亦然。

  • 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列
  • 队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒
2、BlockingQueue的方法

BlockingQueue 具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

抛异常 特定值 阻塞 超时
插入 add(o) offer(o) put(o) offer(o, timeout, timeunit)
移除 remove(o) poll(o) take(o) poll(timeout, timeunit)
检查 element(o) peek(o)

四组不同的行为方式解释:

  • 抛异常:如果试图的操作无法立即执行,抛一个异常。
  • 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
  • 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
  • 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。

BlockingQueue 的核心方法:

  • 放入数据
    1. offer(anObject):表示如果可能的话,将 anObject 加到 BlockingQueue 里,即如果 BlockingQueue 可以容纳,则返回 true,否则返回 false。(本方法不阻塞当前执行方法的线程
    2. offer(E o, long timeout, TimeUnit unit):可以设定等待的时间,如果在指定的时间内,还不能往队列中加入 BlockingQueue,则返回失败。
    3. put(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程被阻断直到 BlockingQueue 里面有空间再继续。
    4. add(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程抛出一个异常:Queue full。
  • 获取数据
    1. poll(time):取走 BlockingQueue 里排在首位的对象,若不能立即取出,则可以等time 参数规定的时间,取不到时返回 null
    2. poll(long timeout, TimeUnit unit):从 BlockingQueue 取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回null。
    3. take():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,阻断进入等待状态直到 BlockingQueue 有新的数据被加入
    4. remove():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,抛出一个异常:NoSuchElementException
    5. drainTo():一次性从 BlockingQueue 获取所有可用的数据对象(还可以指定
      获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。

注意:

  • 无法向一个 BlockingQueue 中插入 null。如果你试图插入 null,BlockingQueue 将会抛出一个 NullPointerException。
  • 可以访问到 BlockingQueue 中的所有元素,而不仅仅是开始和结束的元素。比如说,你将一个对象放入队列之中以等待处理,但你的应用想要将其取消掉。那么你可以调用诸如 remove(o) 方法来将队列之中的特定对象进行移除。
  • 但是这么干效率并不高(译者注:基于队列的数据结构,获取除开始或结束位置的其他对象的效率不会太高),因此你尽量不要用这一类的方法,除非你确实不得不那么做。
3、BlockingDeque

java.util.concurrent 包里的 BlockingDeque 接口表示一个线程安全放入和提取实例的双端队列。

BlockingDeque 类是一个双端队列,在不能够插入元素时,它将阻塞住试图插入元素的线程;在不能够抽取元素时,它将阻塞住试图抽取的线程。 deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

使用情景:

  • 在线程既是一个队列的生产者又是这个队列的消费者的时候可以使用到 BlockingDeque
  • 如果生产者线程需要在队列的两端都可以插入数据,消费者线程需要在队列的两端都可以移除数据,这个时候也可以使用 BlockingDeque。

BlockingDeque 图解:

img

4、BlockingDeque的方法

一个 BlockingDeque - 线程在双端队列的两端都可以插入和提取元素。 一个线程生产元素,并把它们插入到队列的任意一端。如果双端队列已满,插入线程将被阻塞,直到一个移除线程从该队列中移出了一个元素。如果双端队列为空,移除线程将被阻塞,直到一个插入线程向该队列插入了一个新元素。

BlockingDeque 具有 4 组不同的方法用于插入、移除以及对双端队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

抛异常 特定值 阻塞 超时
插入 addFirst(o) offerFirst(o) putFirst(o) offerFirst(o, timeout, timeunit)
移除 removeFirst(o) pollFirst(o) takeFirst(o) pollFirst(timeout, timeunit)
检查 getFirst(o) peekFirst(o)
抛异常 特定值 阻塞 超时
插入 addLast(o) offerLast(o) putLast(o) offerLast(o, timeout, timeunit)
移除 removeLast(o) pollLast(o) takeLast(o) pollLast(timeout, timeunit)
检查 getLast(o) peekLast(o)

四组不同的行为方式解释:

  • 抛异常:如果试图的操作无法立即执行,抛一个异常。
  • 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
  • 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
  • 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
5、BlockingQueue和BlockingDeque的关系

BlockingDeque 接口继承自 BlockingQueue 接口。这就意味着你可以像使用一个 BlockingQueue 那样使用 BlockingDeque。如果你这么干的话,各种插入方法将会把新元素添加到双端队列的尾端,而移除方法将会把双端队列的首端的元素移除。正如 BlockingQueue 接口的插入和移除方法一样。

以下是 BlockingDeque 对 BlockingQueue 接口的方法的具体内部实现:

BlockingQueue BlockingDeque
add() addLast()
offer() x 2 offerLast() x 2
put() putLast()
remove() removeFirst()
poll() x 2 pollFirst()
take() takeFirst()
element() getFirst()
peek() peekFirst()

3、BlockingQueue的例子

这里是一个 Java 中使用 BlockingQueue 的示例。本示例使用的是 BlockingQueue 接口的 ArrayBlockingQueue 实现。 首先,BlockingQueueExample 类分别在两个独立的线程中启动了一个 Producer 和 一个 Consumer。Producer 向一个共享的 BlockingQueue 中注入字符串,而 Consumer 则会从中把它们拿出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BlockingQueueExample {

public static void main(String[] args) throws Exception {

BlockingQueue queue = new ArrayBlockingQueue(1024);

Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);

new Thread(producer).start();
new Thread(consumer).start();

Thread.sleep(4000);
}
}

以下是 Producer 类。注意它在每次 put() 调用时是为何休眠一秒钟的。这将导致 Consumer 在等待队列中对象的时候发生阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Producer implements Runnable{

protected BlockingQueue queue = null;

public Producer(BlockingQueue queue) {
this.queue = queue;
}

public void run() {
try {
queue.put("1");
Thread.sleep(1000);
queue.put("2");
Thread.sleep(1000);
queue.put("3");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

以下是 Consumer 类。它只是把对象从队列中抽取出来,然后将它们打印到 System.out。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Consumer implements Runnable{

protected BlockingQueue queue = null;

public Consumer(BlockingQueue queue) {
this.queue = queue;
}

public void run() {
try {
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
**1、数组阻塞队列——ArrayBlockingQueue**(常用)

ArrayBlockingQueue 类实现了 BlockingQueue 接口。

ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里

  • 有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。
  • 你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了(译者注: 因为它是基于数组实现的,也就具有数组的特性:一旦初始化,大小就无法修改)。
  • ArrayBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。
  • 除了一个定长数组外,ArrayBlockingQueue 内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置

ArrayBlockingQueue 与 LinkedBlockingQueue 的区别:

  • ArrayBlockingQueue 在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;

    • 按照实现原理来分析,ArrayBlockingQueue 完全可以采用分离锁,从而实现生产者和消费者操作的完全并行运行。Doug Lea 之所以没这样去做,也许是因为 ArrayBlockingQueue 的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。
  • ArrayBlockingQueue 和LinkedBlockingQueue 间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的Node 对象

    • 这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC 的影响还是存在一定的区别。
  • 在创建 ArrayBlockingQueue 时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁

    以下是在使用 ArrayBlockingQueue 的时候对其初始化的一个示例:

1
2
3
BlockingQueue queue = new ArrayBlockingQueue(1024);
queue.put("1");
Object object = queue.take();

以下是使用了 Java 泛型的一个 BlockingQueue 示例。注意其中是如何对 String 元素放入和提取的:

1
2
3
BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1024);
queue.put("1");
String string = queue.take();

==一句话总结:ArrayBlockingQueue 是由数组结构组成的有界阻塞队列。==

2、延迟队列——DelayQueue

DelayQueue 实现了 BlockingQueue 接口。

DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。

DelayQueue 对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口,该接口定义:

1
2
3
public interface Delayed extends Comparable<Delayed> {
public long getDelay(TimeUnit timeUnit);
}

DelayQueue 将会在每个元素的 getDelay() 方法返回的值的时间段之后才释放掉该元素。如果返回的是 0 或者负值,延迟将被认为过期,该元素将会在 DelayQueue 的下一次 take 被调用的时候被释放掉

传递给 getDelay 方法的 getDelay 实例是一个枚举类型,它表明了将要延迟的时间段。TimeUnit 枚举将会取以下值:

  • DAYS——天
  • HOURS——时
  • INUTES——分钟
  • SECONDS——秒
  • MILLISECONDS——毫秒
  • MICROSECONDS——微秒
  • NANOSECONDS——纳秒

正如你所看到的,Delayed 接口也继承了 java.lang.Comparable 接口,这也就意味着 Delayed 对象之间可以进行对比。这个可能在对 DelayQueue 队列中的元素进行排序时有用,因此它们可以根据过期时间进行有序释放。 以下是使用 DelayQueue 的例子:

1
2
3
4
5
6
7
8
public class DelayQueueExample {
public static void main(String[] args) {
DelayQueue queue = new DelayQueue();
Delayed element1 = new DelayedElement();
queue.put(element1);
Delayed element2 = queue.take();
}
}

DelayedElement 是我所创建的一个 DelayedElement 接口的实现类,它不在 java.util.concurrent 包里。你需要自行创建你自己的 Delayed 接口的实现以使用 DelayQueue 类。

==一句话总结:使用优先级队列实现的延迟无界阻塞队列。==

**3、链阻塞队列——LinkedBlockingQueue**(常用)

LinkedBlockingQueue 类实现了 BlockingQueue 接口。

LinkedBlockingQueue 内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。

LinkedBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。

LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

ArrayBlockingQueue 和 LinkedBlockingQueue 是两个最普通也是最常用的阻塞队列,一般情况下,在处理多线程间的生产者消费者问题,使用这两个类足以。

以下是 LinkedBlockingQueue 的初始化和使用示例代码:

1
2
3
4
BlockingQueue<String> unbounded = new LinkedBlockingQueue<String>();
BlockingQueue<String> bounded = new LinkedBlockingQueue<String>(1024);
bounded.put("Value");
String value = bounded.take();

==一句话总结:由链表结构组成的有界(但大小默认值为integer.MAX_VALUE)阻塞队列。==

4、具有优先级的阻塞队列——PriorityBlockingQueue

PriorityBlockingQueue 类实现了 BlockingQueue 接口。

PriorityBlockingQueue 是一个基于优先级的无界的并发队列。(优先级的判断通过构造函数传入的 Compator 对象来决定)

它使用了和类 java.util.PriorityQueue 一样的排序规则。

  • 你无法向这个队列中插入 null 值。
  • 所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。

注意:

  • PriorityBlockingQueue 对于具有相等优先级(compare() == 0)的元素并不强制任何特定行为。
  • 如果你从一个 PriorityBlockingQueue 获得一个 Iterator 的话,该 Iterator 并不能保证它对元素的遍历是以优先级为序的。
  • 由于PriorityBlockingQueue是无界的,所以PriorityBlockingQueue 并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者
    • 因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。
  • 在实现 PriorityBlockingQueue 时,内部控制线程同步的锁采用的是==公平锁==。

以下是使用 PriorityBlockingQueue 的示例:

1
2
3
4
BlockingQueue queue   = new PriorityBlockingQueue();
//String implements java.lang.Comparable
queue.put("Value");
String value = queue.take();

==一句话总结:支持优先级排序的无界阻塞队列。==

5、同步队列——SynchronousQueue

SynchronousQueue 类实现了 BlockingQueue 接口。

SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。 据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点。

声明一个 SynchronousQueue 有两种不同的方式——公平模式非公平模式,它们之间有着不太一样的行为:

  • 公平模式:SynchronousQueue 会采用公平锁,并配合一个 FIFO 队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;
  • 非公平模式(SynchronousQueue 默认):SynchronousQueue 采用非公平锁,同时配合一个 LIFO 队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。

==一句话总结:不存储元素的阻塞队列,也即单个元素的队列。==

6、链阻塞无界队列——LinkedTransferQueue

LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列。相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和transfer 方法。

LinkedTransferQueue 采用一种预占模式。意思就是消费者线程取元素时,如果队列不为空,则直接取走数据,若队列为空,那就生成一个节点(节点元素为 null)入队,然后消费者线程被等待在这个节点上,后面生产者线程入队时发现有一个元素为 null 的节点,生产者线程就不入队了,直接就将元素填充到该节点,并唤醒该节点等待的线程,被唤醒的消费者线程取走元素,从调用的方法返回。

==一句话总结:由链表组成的无界阻塞队列。==

4、BlockingDeque 的例子

既然 BlockingDeque 是一个接口,那么你想要使用它的话就得使用它的众多的实现类的其中一个。java.util.concurrent 包提供了以下 BlockingDeque 接口的实现类:LinkedBlockingDeque

以下是如何使用 BlockingDeque 方法的一个简短代码示例:

1
2
3
4
5
6
BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
deque.addFirst("1");
deque.addLast("2");

String two = deque.takeLast();
String one = deque.takeFirst();
链阻塞双端队列——LinkedBlockDeque

LinkedBlockingDeque 类实现了 BlockingDeque 接口。

LinkedBlockingDeque 是一个由链表结构组成的双向阻塞队列,即可以从队列的两端插入和移除元素。

deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

LinkedBlockingDeque 是一个双端队列,在它为空的时候,一个试图从中抽取数据的线程将会阻塞,无论该线程是试图从哪一端抽取数据。

对于一些指定的操作,在插入或者获取队列元素时如果队列状态不允许该操作可能会阻塞住,该线程直到队列状态变更为允许操作,这里的阻塞一般有两种情况:

  • 插入元素时:如果当前队列已满将会进入阻塞状态,一直等到队列有空的位置时再讲该元素插入,该操作可以通过设置超时参数,超时后返回 false 表示操作失败,也可以不设置超时参数一直阻塞,中断后抛出 InterruptedException 异常
  • 读取元素时:如果当前队列为空会阻塞住直到队列不为空然后返回元素,同样可以通过设置超时参数

以下是 LinkedBlockingDeque 实例化以及使用的示例:

1
2
3
4
5
6
BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
deque.addFirst("1");
deque.addLast("2");

String two = deque.takeLast();
String one = deque.takeFirst();

==一句话总结:由链表组成的双向阻塞队列==

11、JUC集合:LinkedBlockingQueue详解

1、LinkedBlockingQueue 原理

1、基本的入队出队
1
2
3
4
5
6
7
8
9
10
11
12
13
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
static class Node<E> {
E item;
/**
* 下列三种情况之一
* - 真正的后继节点
* - 自己, 发生在出队时
* - null, 表示是没有后继节点, 是最后了*/
Node<E> next;
Node(E x) { item = x; }
}
}

初始化链表 last = head = new Node<E>(null); Dummy 节点用来占位,item 为 null

image-20210814034851138

当一个节点入队 last = last.next = node;

image-20210814034913187

再来一个节点入队 last = last.next = node;

image-20210814034931731

出队

1
2
3
4
5
6
Node<E> h = head; 
Node<E> first = h.next;
h.next = h; // help GC head = first;
E x = first.item;
first.item = null;
return x;

h = head

image-20210814035037609

first = h.next

image-20210814035055942

h.next = h

image-20210814035113682

head = first

image-20210814035130035

1
2
3
E x = first.item; 
first.item = null;
return x;

image-20210814035147199

2、加锁分析

==高明之处==在于用了两把锁和 dummy 节点

  • 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
  • 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行(锁住的队列的头和尾)
    • 消费者与消费者线程仍然串行
    • 生产者与生产者线程仍然串行

线程安全分析

  • 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是 head 节点的线程安全。两把锁保证了入队和出队没有竞争
  • 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
    • 这里就体现了dummy 占位节点的用处了:就算只剩下一个正常的结点,两把锁锁住的依旧是两个对象,没有竞争
  • 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
1
2
3
4
// 用于 put(阻塞) offer(非阻塞)
private final ReentrantLock putLock = new ReentrantLock();
// 用户 take(阻塞) poll(非阻塞)
private final ReentrantLock takeLock = new ReentrantLock();

put 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
// count 用来维护元素计数
final AtomicInteger count = this.count; putLock.lockInterruptibly();
try {
// 满了等待
while (count.get() == capacity) {
// 倒过来读就好: 等待 notFull
notFull.await();
}
// 有空位, 入队且计数加一
enqueue(node);
c = count.getAndIncrement();
// 除了自己 put 以外, 队列还有空位, 由自己叫醒其他 put 线程
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
// 如果队列中有一个元素, 叫醒 take 线程
if (c == 0)
// 这里调用的是 notEmpty.signal() 而不是 notEmpty.signalAll() 是为了减少竞争
signalNotEmpty();
}

take 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
// 如果队列中只有一个空位时, 叫醒 put 线程
// 如果有多个线程进行出队, 第一个线程满足 c == capacity, 但后续线程 c < capacity
if (c == capacity)
// 这里调用的是 notFull.signal() 而不是 notFull.signalAll() 是为了减少竞争
signalNotFull();
return x;
}

由 put 唤醒 put 是为了避免信号不足

2、性能比较

主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较

  • Linked 支持有界,Array 强制有界
  • Linked 实现是链表,Array 实现是数组
  • Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
  • Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
  • Linked 两把锁,Array 一把锁

12、JUC集合:ConcurrentLinkedQueue详解

  • 一个基于链接节点的无界线程安全队列。此队列按照 ==FIFO(先进先出)原==则对元素进行排序。
  • 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素
  • 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素
  • 多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。
  • 此队列不允许使用 null 元素

ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是

  • 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
  • dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争
  • 只是这【锁】使用了 cas 来实现

事实上,ConcurrentLinkedQueue 应用还是非常广泛的

例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用

image-20210814043528344

1、BAT大厂的面试问题

  • 要想用线程安全的队列有哪些选择?
    • Vector,Collections.synchronizedList(List<T> list), ConcurrentLinkedQueue等
  • ConcurrentLinkedQueue实现的数据结构?
  • ConcurrentLinkedQueue底层原理?
    • 全程无锁(CAS)
  • ConcurrentLinkedQueue的核心方法有哪些?
    • offer(),poll(),peek(),isEmpty()等队列常用方法
  • 说说ConcurrentLinkedQueue的HOPS(延迟更新的策略)的设计?
  • ConcurrentLinkedQueue适合什么样的使用场景?

2、ConcurrentLinkedQueue数据结构

通过源码分析可知,ConcurrentLinkedQueue的数据结构与LinkedBlockingQueue的数据结构相同,都是使用的链表结构。

ConcurrentLinkedQueue的数据结构如下:

img

说明:ConcurrentLinkedQueue采用的链表结构,并且包含有一个头结点和一个尾结点。

3、ConcurrentLinkedQueue源码分析

1、类的继承关系
1
2
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {}

说明:ConcurrentLinkedQueue继承了抽象类AbstractQueue,AbstractQueue定义了对队列的基本操作;同时实现了Queue接口,Queue定义了对队列的基本操作,同时,还实现了Serializable接口,表示可以被序列化。

2、类的内部类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
private static class Node<E> {
// 元素
volatile E item;
// next域
volatile Node<E> next;

/**
* Constructs a new node. Uses relaxed write because item can
* only be seen after publication via casNext.
*/
// 构造函数
Node(E item) {
// 设置item的值
UNSAFE.putObject(this, itemOffset, item);
}
// 比较并替换item值
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}

void lazySetNext(Node<E> val) {
// 设置next域的值,并不会保证修改对其他线程立即可见
UNSAFE.putOrderedObject(this, nextOffset, val);
}
// 比较并替换next域的值
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}

// Unsafe mechanics
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// item域的偏移量
private static final long itemOffset;
// next域的偏移量
private static final long nextOffset;

static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = Node.class;
itemOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("item"));
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}

说明:Node类表示链表结点,用于存放元素,包含item域和next域,item域表示元素,next域表示下一个结点,**其利用反射机制和CAS机制来更新item域和next域,==保证原子性==**。

3、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 196745693267521676L;
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// head域的偏移量
private static final long headOffset;
// tail域的偏移量
private static final long tailOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = ConcurrentLinkedQueue.class;
headOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("head"));
tailOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("tail"));
} catch (Exception e) {
throw new Error(e);
}
}

// 头结点
private transient volatile Node<E> head;
// 尾结点
private transient volatile Node<E> tail;
}

说明:属性中包含了head域和tail域,表示链表的头结点和尾结点,同时,ConcurrentLinkedQueue也使用了反射机制和CAS机制来更新头结点和尾结点,==保证原子==性

4、 类的构造函数
  • ConcurrentLinkedQueue()型构造函数

    • public ConcurrentLinkedQueue() {
          // 初始化头结点与尾结点
          head = tail = new Node<E>(null);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31

      - 说明:**该构造函数用于创建一个最初为空的 ConcurrentLinkedQueue,头结点与尾结点指向同一个结点,该结点的item域为null,next域也为null**。

      - `ConcurrentLinkedQueue(Collection<? extends E>)`型构造函数

      - ```java
      public ConcurrentLinkedQueue(Collection<? extends E> c) {
      Node<E> h = null, t = null;
      for (E e : c) { // 遍历c集合
      // 保证元素不为空
      checkNotNull(e);
      // 新生一个结点
      Node<E> newNode = new Node<E>(e);
      if (h == null) // 头结点为null
      // 赋值头结点与尾结点
      h = t = newNode;
      else {
      // 直接头结点的next域
      t.lazySetNext(newNode);
      // 重新赋值头结点
      t = newNode;
      }
      }
      if (h == null) // 头结点为null
      // 新生头结点与尾结点
      h = t = new Node<E>(null);
      // 赋值头结点
      head = h;
      // 赋值尾结点
      tail = t;
      }
    • 说明:该构造函数用于创建一个最初包含给定 collection 元素的 ConcurrentLinkedQueue,按照此 collection 迭代器的遍历顺序来添加元素

5、核心函数分析
1、offer函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public boolean offer(E e) {
// 元素不为null
checkNotNull(e);
// 新生一个结点
final Node<E> newNode = new Node<E>(e);

for (Node<E> t = tail, p = t;;) { // 无限循环
// q为p结点的下一个结点
Node<E> q = p.next;
if (q == null) { // q结点为null
// p is last node
if (p.casNext(null, newNode)) { // 比较并进行替换p结点的next域
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // p不等于t结点,不一致 // hop two nodes at a time
// 比较并替换尾结点
casTail(t, newNode); // Failure is OK.
// 返回
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q) // p结点等于q结点
// We have fallen off list. If tail is unchanged, it
// will also be off-list, in which case we need to
// jump to head, from which all live nodes are always
// reachable. Else the new tail is a better bet.
// 原来的尾结点与现在的尾结点是否相等,若相等,则p赋值为head,否则,赋值为现在的尾结点
p = (t != (t = tail)) ? t : head;
else
// Check for tail updates after two hops.
// 重新赋值p结点
p = (p != t && t != (t = tail)) ? t : q;
}
}

说明:offer函数用于将指定元素插入此队列的尾部。下面模拟offer函数的操作,队列状态的变化(假设单线程添加元素,连续添加10、20两个元素)。

img

  • 若ConcurrentLinkedQueue的初始状态如上图所示,即队列为空。单线程添加元素,此时,添加元素10,则状态如下所示:
    • img
  • 如上图所示,添加元素10后,tail没有变化,还是指向之前的结点,继续添加元素20,则状态如下所示:
    • img
  • 如上图所示,添加元素20后,tail指向了最新添加的结点。
2、poll函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public E poll() {
restartFromHead:
for (;;) { // 无限循环
for (Node<E> h = head, p = h, q;;) { // 保存头结点
// item项
E item = p.item;

if (item != null && p.casItem(item, null)) { // item不为null并且比较并替换item成功
// Successful CAS is the linearization point
// for item to be removed from this queue.
if (p != h) // p不等于h // hop two nodes at a time
// 更新头结点
updateHead(h, ((q = p.next) != null) ? q : p);
// 返回item
return item;
}
else if ((q = p.next) == null) { // q结点为null
// 更新头结点
updateHead(h, p);
return null;
}
else if (p == q) // p等于q
// 继续循环
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}

说明:此函数用于获取并移除此队列的头,如果此队列为空,则返回null。下面模拟poll函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,poll两次)。

img

  • 队列初始状态如上图所示,在poll操作后,队列的状态如下图所示:
    • img
  • 如上图可知,poll操作后,head改变了,并且head所指向的结点的item变为了null。再进行一次poll操作,队列的状态如下图所示:
    • img
  • 如上图可知,poll操作后,head结点没有变化,只是指示的结点的item域变成了null。
3、remove函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean remove(Object o) {
// 元素为null,返回
if (o == null) return false;
Node<E> pred = null;
for (Node<E> p = first(); p != null; p = succ(p)) { // 获取第一个存活的结点
// 第一个存活结点的item值
E item = p.item;
if (item != null &&
o.equals(item) &&
p.casItem(item, null)) { // 找到item相等的结点,并且将该结点的item设置为null
// p的后继结点
Node<E> next = succ(p);
if (pred != null && next != null) // pred不为null并且next不为null
// 比较并替换next域
pred.casNext(p, next);
return true;
}
// pred赋值为p
pred = p;
}
return false;
}

说明:**此函数用于从队列中移除指定元素的单个实例(如果存在)**。其中,会调用到first函数和succ函数,first函数的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Node<E> first() {
restartFromHead:
for (;;) { // 无限循环,确保成功
for (Node<E> h = head, p = h, q;;) {
// p结点的item域是否为null
boolean hasItem = (p.item != null);
if (hasItem || (q = p.next) == null) { // item不为null或者next域为null
// 更新头结点
updateHead(h, p);
// 返回结点
return hasItem ? p : null;
}
else if (p == q) // p等于q
// 继续从头结点开始
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}

说明:first函数用于找到链表中第一个存活的结点。succ函数源码如下:

1
2
3
4
5
6
final Node<E> succ(Node<E> p) {
// p结点的next域
Node<E> next = p.next;
// 如果next域为自身,则返回头结点,否则,返回next
return (p == next) ? head : next;
}

说明:succ用于获取结点的下一个结点。如果结点的next域指向自身,则返回head头结点,否则,返回next结点。

下面模拟remove函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,执行remove(10)、remove(20)操作)。

img

  • 如上图所示,为ConcurrentLinkedQueue的初始状态,remove(10)后的状态如下图所示:
    • img
  • 如上图所示,当执行remove(10)后,head指向了head结点之前指向的结点的下一个结点,并且head结点的item域置为null。继续执行remove(20),状态如下图所示:
    • img
  • 如上图所示,执行remove(20)后,head与tail指向同一个结点,item域为null。
4、size函数
1
2
3
4
5
6
7
8
9
10
11
public int size() {
// 计数
int count = 0;
for (Node<E> p = first(); p != null; p = succ(p)) // 从第一个存活的结点开始往后遍历
if (p.item != null) // 结点的item域不为null
// Collection.size() spec says to max out
if (++count == Integer.MAX_VALUE) // 增加计数,若达到最大值,则跳出循环
break;
// 返回大小
return count;
}

说明:此函数用于返回ConcurrenLinkedQueue的大小,从第一个存活的结点(first)开始,往后遍历链表,当结点的item域不为null时,增加计数,之后返回大小

4、ConcurrentLinkedQueue示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.util.concurrent.ConcurrentLinkedQueue;

class PutThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public PutThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}

@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("add " + i);
clq.add(i);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

class GetThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public GetThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}

@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("poll " + clq.poll());
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

public class ConcurrentLinkedQueueDemo {
public static void main(String[] args) {
ConcurrentLinkedQueue<Integer> clq = new ConcurrentLinkedQueue<Integer>();
PutThread p1 = new PutThread(clq);
GetThread g1 = new GetThread(clq);

p1.start();
g1.start();

}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
add 0
poll null
add 1
poll 0
add 2
poll 1
add 3
poll 2
add 4
poll 3
add 5
poll 4
poll 5
add 6
add 7
poll 6
poll 7
add 8
add 9
poll 8

说明:GetThread线程不会因为ConcurrentLinkedQueue队列为空而等待,而是直接返回null,所以当实现队列不空时,等待时,则需要用户自己实现等待逻辑

5、再深入理解

1、HOPS(延迟更新的策略)的设计

通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:

  • tail更新触发时机:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。
  • head更新触发时机:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。

并且在更新操作时,源码中会有注释为:hop two nodes at a time。所以这种延迟更新的策略就被叫做HOPS的大概原因是这个(猜的 😃),从上面更新时的状态图可以看出,head和tail的更新是“跳着的”即中间总是间隔了一个。那么这样设计的意图是什么呢?

如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。

2、ConcurrentLinkedQueue适合的场景

ConcurrentLinkedQueue通过无锁来做到了更高的并发量,是个高性能的队列,但是使用场景相对不如阻塞队列常见,毕竟取数据也要不停的去循环,不如阻塞的逻辑好设计,但是在并发量特别大的情况下,是个不错的选择,性能上好很多,而且这个队列的设计也是特别费力,尤其的使用的改良算法和对哨兵的处理。整体的思路都是比较严谨的,这个也是使用了无锁造成的,我们自己使用无锁的条件的话,这个队列是个不错的参考

13、多线程锁

1、公平锁与非公平锁

1、公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
2、非公平锁

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
3、公平锁与非公平锁

公平和非公平都是排序队列的,但是公平的新创建的线程会排到所有的就绪队列之后,非公平的线程会和就绪队列直接竞争资源,也就是插队。

举个去KFC吃饭的例子(来自敖丙dalao的例子)

  • 现在是早餐时间,敖丙想去kfc搞个早餐,发现有很多人了,一过去没多想,就乖乖到队尾排队,这样大家都觉得很公平,先到先得,所以这是公平锁咯。
    • img
  • 那非公平锁就是,敖丙过去买早餐,发现大家都在排队,但是敖丙这个人有点渣的,就是喜欢插队,那他就直接怼到第一位那去,后面的鸡蛋,米豆都不行,我插队也不敢说什么,只能默默忍受了。
    • img
  • 但是偶尔,鸡蛋也会崛起,叫我滚到后面排队,我也是欺软怕硬,默默到后面排队,就插队失败了。
    • img
4、公平锁与非公平锁的实现——ReentrantLock(具体看一看上文的ReentrantLock)

在上文中介绍了ReentrantLock类,以及ReentrantLock类的三个内部类——SyncNonfairSyncFairSync

其中NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。

而NonfairSync实现的就是非公平锁(ReentrantLock的默认实现),FairSync实现的就是公平锁。

公平锁:(FairSync源码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
// 尝试公平获取锁
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取状态
int c = getState();
if (c == 0) { // 状态为0
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
// 设置当前线程独占
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
// 下一个状态
int nextc = c + acquires;
if (nextc < 0) // 超过了int的表示范围
throw new Error("Maximum lock count exceeded");
// 设置状态
setState(nextc);
return true;
}
return false;
}

仔细看FairSync的源码就能发现,它加了一个hasQueuedPredecessors的判断,那他判断里面有些什么玩意呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
public final boolean  hasQueuedPredecessors() {
// The correctness of this depends on head being initia Lized
// before tail and on head.next being accurate if the current
// thread is first in queue.
//其实这个赋值顺序也是很有讲究的,倒过来有可能会导致空指针
Node t = tail; // Read fields in reverse initia Lization orde r
Nodeh = head;
Node s;
return h != t && // h != t 时表示队列中有 Node
// (s = h.next) == null 表示队列中还有没有老二
// 或者队列中老二线程不是此线程
((s = h.next) == null | s. thread != Thread. currentThread());
}

代码的大概意思也是判断当前的线程是不是位于同步队列的首位,是就是返回true,否就返回false。

非公平锁:(NonfairSync源码)

1
2
3
4
5
6
7
8
9
// 获得锁
final void lock() {
if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
// 把当前线程设置独占了锁
setExclusiveOwnerThread(Thread.currentThread());
else // 锁已经被占用,或者set失败
// 以独占模式获取对象,忽略中断
acquire(1);
}

从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。

5、公平锁与非公平锁的实现过程

非公平锁:

  • A线程准备进去获取锁,首先判断了一下state状态,发现是0,所以可以CAS成功,并且修改了当前持有锁的线程为自己。
    • img
  • 这个时候B线程也过来了,也是一上来先去判断了一下state状态,发现是1,那就CAS失败了,真晦气,只能乖乖去等待队列,等着唤醒了,先去睡一觉吧。
    • img
  • A持有久了,也有点腻了,准备释放掉锁,给别的仔一个机会,所以改了state状态,抹掉了持有锁线程的痕迹,准备去叫醒B。
    • img
  • 这个时候有个带绿帽子的仔C过来了,发现state怎么是0啊,果断CAS修改为1,还修改了当前持有锁的线程为自己。
  • B线程被A叫醒准备去获取锁,发现state居然是1,CAS就失败了,只能失落的继续回去等待队列,路线还不忘骂A渣男,怎么骗自己,欺骗我的感情。
    • img

以上就是一个非公平锁的线程,这样的情况就有可能像B这样的线程长时间无法得到资源,优点就是可能有的线程减少了等待时间,提高了利用率。

公平锁:

  • 线A现在想要获得锁,先去判断下state,发现也是0,去看了看队列,自己居然是第一位,果断修改了持有线程为自己。
    • img
  • 线程B过来了,去判断一下state,嗯哼?居然是state=1,那cas就失败了呀,所以只能乖乖去排队了。
    • img
  • 线程A暖男来了,持有没多久就释放了,改掉了所有的状态就去唤醒线程B了,这个时候线程C进来了,但是他先判断了下state发现是0,以为有戏,然后去看了看队列,发现前面有人了,作为新时代的良好市民,果断排队去了。
    • img
  • 线程B得到A的召唤,去判断state了,发现值为0,自己也是队列的第一位,那很香呀,可以得到了。
    • img

以上就是一个公平锁的线程,这样的情况就不会出现线程长时间无法得到资源,缺点就是要判断当前的等待队列是否有线程在等待,花费的开销较大,效率不行。

6、深入:公平锁真的公平吗?

公平锁相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
//重入锁的代码
...
}
return false;
}
1
2
3
4
5
6
7
8
public final boolean hasQueuedPredecessors() {
//其实这个赋值顺序也是很有讲究的,倒过来有可能会导致空指针
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
//初始化
if (compareAndSetHead(new Node()))......①
tail = head;........................②
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}

情景:假设当前有三个线程A、B、C,分别取调用公平锁的lock.lock()

  • 假设线程A一马当先,先获取到锁,此时state == 1。然后线程B,也来到了tryAcquire方法
    • 公平锁与非公平锁的区别就是在tryAcquire中会判断是否有先驱节点,也就是方法hasQueuedPredecessors
  • 此时tailheadnull,所以肯定方法hasQueuedPredecessors返回false
  • 线程B回到tryAcquire中执行cas_state方法,由于A还没有释放锁,所以肯定获取不到,最终返回false,需要加入同步队列。在addWaiter中,由于tail == null 直接进入enq方法。
  • ①和②便是重点。
情景1:
  • 当线程B执行到①,此时head有值,但是tail还是为null

  • 此时线程C也执行到hasQueuedPredecessors

  • Node t = null;
    Node h = new Node();
    此时 h != t && ((s = h.next) == null)  为true
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    因此线程`C`不能插队,也要加入等待队列。

    ###### 情景2:

    - 当线程`B`执行到②,此时`head`有值,且`head == tail`

    - 此时线程`C`也执行到`hasQueuedPredecessors`

    - ```java
    Node t = h
    此时 h != t 为false 短路直接返回

因此线程C可以插队,去执行cas_state方法
假设在执行cas方法之前,线程A已经释放了锁,那么线程C就可以插队,先于B抢到锁。

关于公平锁源码中hasQueuedPredecessors()方法中tail和head赋值顺序问题

如果head先于tail赋值

1
2
3
4
5
6
7
8
public final boolean hasQueuedPredecessors() {
Node h = head; //如果此时head还没有初始化,获得的是null,赋值完后失去时间片
Node t = tail; //此时head完成初始化,且tail != null
Node s;
return h != t && // h != t 成立 没有短路
//h == null 因此h.next会产生NPE
((s = h.next) == null || s.thread != Thread.currentThread());
}
总结

ReentrantLock中的公平锁只有在等待队列中存在等待节点(不包括虚节点)的时候,才是真正意义上的公平锁。

2、可重入锁

1、什么是重入锁

通常情况下,锁可以用来控制多线程的访问行为。那对于同一个线程,如果连续两次对同一把锁进行lock,会怎么样了?

对于一般的锁来说,这个线程就会被永远卡死在那边,比如:

1
2
3
4
5
6
void handle() {
lock();
lock(); //和上一个lock()操作同一个锁对象,那么这里就永远等待了
unlock();
unlock();
}

这个特性相当不好用,因为在实际的开发过程中,函数之间的调用关系可能错综复杂,一个不小心就可能在多个不同的函数中,反复调用lock(),这样的话,线程就自己和自己卡死了。

所以,对于希望傻瓜式编程的我们来说,重入锁就是用来解决这个问题的。重入锁使得同一个线程可以对同一把锁,在不释放的前提下,反复加锁,而不会导致线程卡死。因此,如果我们使用的是重入锁,那么上述代码就可以正常工作。你唯一需要保证的,就是unlock()的次数和lock()一样多(否则会造成死锁)。

image-20210723031848803

2、重入锁的实现原理

java当中的重入锁——Lock接口的实现类ReentrantLock。其中最重要的方法——lock()

重入锁内部实现的主要类如下图:

图片

重入锁的核心功能委托给内部类Sync实现,并且根据是否是公平锁有FairSync和NonfairSync两种实现。这是一种典型的策略模式。

实现重入锁的方法很简单,就是基于一个状态变量state。这个变量保存在AbstractQueuedSynchronizer(AQS)对象中

1
private volatile int state;

当这个state==0时,表示锁是空闲的,大于零表示锁已经被占用, 它的数值表示当前线程重复占用这个锁的次数。因此,lock()的最简单的实现是:

1
2
3
4
5
6
7
8
final void lock() {
// compareAndSetState就是对state进行CAS操作,如果修改成功就占用锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//如果修改不成功,说明别的线程已经使用了这个锁,那么就可能需要等待
acquire(1);
}

下面是acquire() 的实现:

1
2
3
4
5
6
7
8
9
10
 public final void acquire(int arg) {
//tryAcquire() 再次尝试获取锁,
//如果发现锁就是当前线程占用的,则更新state,表示重复占用的次数,
//同时宣布获得锁成功,这正是重入的关键所在
if (!tryAcquire(arg) &&
// 如果获取失败,那么就在这里入队等待
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//如果在等待过程中 被中断了,那么重新把中断标志位设置上
selfInterrupt();
}
3、公平的重入锁与非公平的重入锁

默认情况下,重入锁是不公平的。

那公平锁和非公平锁实现的核心区别在哪里呢?

  • 对于lock()方法代码:

    • //非公平锁 
       final void lock() {
           //上来不管三七二十一,直接抢了再说
           if (compareAndSetState(0, 1))
               setExclusiveOwnerThread(Thread.currentThread());
           else
               //抢不到,就进队列慢慢等着
               acquire(1);
       }
      
       //公平锁
       final void lock() {
           //直接进队列等着
           acquire(1);
       }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52

      - 从上面的代码中也不难看到,非公平锁如果第一次争抢失败,后面的处理和公平锁是一样的,都是进入等待队列慢慢等。

      - 对于tryLock()方法代码:

      - ```java
      //非公平锁
      final boolean nonfairTryAcquire(int acquires) {
      final Thread current = Thread.currentThread();
      int c = getState();
      if (c == 0) {
      //上来不管三七二十一,直接抢了再说
      if (compareAndSetState(0, acquires)) {
      setExclusiveOwnerThread(current);
      return true;
      }
      }
      //如果就是当前线程占用了锁,那么就更新一下state,表示重复占用锁的次数
      //这是“重入”的关键所在
      else if (current == getExclusiveOwnerThread()) {
      //我又来了哦~~~(重入)
      int nextc = c + acquires;
      if (nextc < 0) // overflow
      throw new Error("Maximum lock count exceeded");
      setState(nextc);
      return true;
      }
      return false;
      }


      //公平锁
      protected final boolean tryAcquire(int acquires) {
      final Thread current = Thread.currentThread();
      int c = getState();
      if (c == 0) {
      //先看看有没有别人在等,没有人等我才会去抢,有人在我前面 ,我就不抢啦
      if (!hasQueuedPredecessors() &&
      compareAndSetState(0, acquires)) {
      setExclusiveOwnerThread(current);
      return true;
      }
      }
      else if (current == getExclusiveOwnerThread()) {
      int nextc = c + acquires;
      if (nextc < 0)
      throw new Error("Maximum lock count exceeded");
      setState(nextc);
      return true;
      }
      return false;
      }
4、Condition

Condition可以理解为重入锁的伴生对象。它提供了在重入锁的基础上,进行等待和通知的机制。可以使用 newCondition()方法生成一个Condition对象,如下所示:

1
2
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();

那Condition对象怎么用呢?在JDK内部就有一个很好的例子。让我们来看一下ArrayBlockingQueue吧。

ArrayBlockingQueue是一个队列,你可以把元素塞入队列(enqueue),也可以拿出来take()。但是有一个小小的条件,就是如果队列是空的,那么take()就需要等待,一直等到有元素了,再返回。

那这个功能,怎么实现呢?这就可以使用Condition对象了。

实际在ArrayBlockingQueue中,就维护一个Condition对象:

1
2
lock = new ReentrantLock(true);
notEmpty = lock.newCondition();

这个notEmpty 就是一个Condition对象。它用来通知其他线程,ArrayBlockingQueue是不是空着的。当我们需要拿出一个元素时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
// 如果队列长度为0,那么就在notEmpty condition上等待了,一直等到有元素进来为止
// 注意,await()方法,一定是要先获得condition伴生的那个lock,才可以使用。
notEmpty.await();
//一旦有人通知我队列里有东西了,我就弹出一个返回
return dequeue();
} finally {
lock.unlock();
}
}

当有元素入队时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
//先拿到锁,拿到锁才能操作对应的Condition对象
lock.lock();
try {
if (count == items.length)
return false;
else {
//入队了, 在这个函数里,就会进行notEmpty的通知,通知相关线程,有数据准备好了
enqueue(e);
return true;
}
} finally {
//释放锁了,等着的那个线程,现在可以去弹出一个元素试试了
lock.unlock();
}
}

private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//元素已经放好了,通知那个等着拿东西的人吧
notEmpty.signal();
}

因此,整个流程如图所示:

图片

5、显示重入锁(lock)与隐式重入锁(Synchronized
  • 显示重入锁(lock):需要手动的上锁与释放锁的重入锁
    • 如上文所说,lock的ReentrantLock就是显示重入锁
  • 隐式重入锁(Synchronized):自动的上锁与释放锁的重入锁
    • Synchronized的上锁与释放锁是由JVM自动控制的
6、重入锁的使用示例

使用重入锁,实现一个简单的计数器。这个计数器可以保证在多线程环境中,统计数据的精确性,请看下面示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Counter {
//重入锁
private final Lock lock = new ReentrantLock();
private int count;
public void incr() {
// 访问count时,需要加锁
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}

public int getCount() {
//读取数据也需要加锁,才能保证数据的可见性
lock.lock();
try {
return count;
}finally {
lock.unlock();
}
}
}
7、可重入锁总结
  • 显示重入锁(lock)与隐式重入锁(Synchronized
  • 对于同一个线程,重入锁允许你反复获得通一把锁,但是,申请和释放锁的次数必须一致。
  • 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
  • 重入锁的内部实现是基于CAS操作的
  • 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信
  • 如果是不可重入锁的话,第一个锁没有解锁就不能操作第二个锁的内容

3、死锁

1、什么是死锁

两个或多个进程在运行过程中,因争夺资源而造成的一种相互等待的现象,当进程处于这种相互等待的状态时,若无外力作用,它们都将无法再向前推进。

image-20210723034133100

2、产生死锁的三大原因
  1. 竞争可消耗资源
  2. 竞争不可抢占资源
    • 系统中的资源可以分为两类:
      • 可剥夺资源:是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;
      • 另一类资源是不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
      • 还有一种资源:临时资源。
        • 包括硬件中断、信号、消息、缓冲区内的消息等
        • 它可以是可剥夺资源,也可以是不可剥夺资源
    • 产生死锁中的竞争资源指的是竞争不可剥夺资源的临时资源
  3. 进程运行推进顺序不当
    • 若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁
    • 当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁
3、产生死锁的四大条件

产生死锁的必要条件:

  1. 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
  2. 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
  4. 循环等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
4、验证是否发生死锁的方法
  • jps
    • 类似linux的ps -ef
  • jstack
    • jvm自带的堆栈跟踪工具
  • JConsole等工具
5、解决死锁的方法

处理死锁的方法可归结为四种:

  • 预防死锁
  • 避免死锁
  • 检测死锁
  • 解除死锁
1、预防死锁

预防死锁:通过破坏产生死锁的四个必要条件中的一个或几个,以避免发生死锁的方法

  • 破坏“请求和条件”:
    • 必须一次性申请其在整个运行过程中所需的全部资源
      • 优点:简单、易行且安全
      • 缺点:
        • 资源被严重浪费,严重地恶化资源的利用率
        • 使进程经常会发生饥饿现象
    • 对上面方法的改进:允许一个进程只获得运行初期所需的资源后,便开始运行。进程运行过程中再逐步释放已分配给自己的、且已用完毕的全部资源,然后再请求新的所需资源。
  • 破坏“不可抢占条件”:
    • 当一个已经保存了某些不可抢占资源的进程,提出新的资源请求而不能满足时,它必须释放已经保持的所有资源,待以后需要时在重新申请。(这个方法代价太大,一般不使用这个方法)
  • 破坏“循环等待条件”:
    • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反

对于java来说:

  1. 以确定的顺序获得锁
    • 如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:
      • img
    • 如果此时把获得锁的时序改成:
      • img
    • 那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。
    • 问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“循环等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生。
  2. 超时放弃(Lock接口中的tryLock(long time, TimeUnit unit)使用的就是这个方法)
    • 当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,
    • 然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,时序图如下:
      • img
2、避免死锁

预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得较满意的系统性能。

由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。

其中最具有代表性的避免死锁算法是银行家算法

银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。

安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。

处在安全状态的进程==一定==不会发生死锁问题,不处在安全状态的进程==可能==发生死锁问题

3、检测死锁
  1. 首先为每个进程和每个资源指定一个唯一的号码;
  2. 然后建立资源分配表和进程等待表。
  3. 资源分配图 + 死锁定理
4、解除死锁

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:

  • 剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
  • 撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态消除为止;
    • 所谓代价是指优先级、运行代价、进程的重要性和价值等。
6、死锁代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 演示死锁
*/
public class DeadLock {

//创建两个对象
static Object a = new Object();
static Object b = new Object();

public static void main(String[] args) {
new Thread(()->{
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 持有锁a,试图获取锁b");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 获取锁b");
}
}
},"A").start();

new Thread(()->{
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 持有锁b,试图获取锁a");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 获取锁a");
}
}
},"B").start();
}
}

4、活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import lombok.extern.slf4j.Slf4j;

import static cn.itcast.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.TestLiveLock")
public class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();

public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
}

解决方法:使两个线程互相错开运行

5、饥饿

很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束。

下面我讲一下我遇到的一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题:

image-20210806020827445

顺序加锁的解决方案:

image-20210806020910048

6、乐观锁(Optimistic Locking)和悲观锁(Pessimistic Lock)

1、悲观锁(Pessimistic Lock)
  • 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
  • 悲观锁,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
  • 之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:
    • 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
    • Java 里面的同步 synchronized 关键字的实现。
  • 悲观锁主要分为共享锁排他锁
    • 共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
    • 排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。
  • 悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁
  • 说明:
    • 悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

image-20210726192457057

2、乐观锁(Optimistic Locking)
  • 乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量
  • 乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:
    • CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
    • 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
  • 乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作
  • 说明:
    • 乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

image-20210726202536158

乐观锁场景:在线文档

我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

7、表锁和行锁

表锁行锁主要是mysql数据库的悬挂知识,这里简单提一下,知道概念就行。

  • 表锁:当一个线程在给一张表进行数据操作的时候,会将整一张表都锁起来。在它操作完成之前,其他线程不能对这张表的所有数据进行操作。
    • image-20210726205202616
  • 行锁:一个线程在对一张表的某一行数据进行操作的时候,会将那一行数据锁起来,在它操作完成之前,其他线程不能对这一行数据进行操作,但是对原这张表的其他行数据进行操作是允许的。
    • image-20210726205505114

8、读锁与写锁

读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。

  • 读锁:共享锁。即允许多个线程一起读取某一个资源——“共享读”
    • 读锁存在”死锁”问题:线程1和线程2一起读取一张表,这时候线程1如果想要修改(写)这张表的数据,就需要线程2完成读操作后退出;同理,这个时候如果线程2也想修改(写)这张表的数据,那么也要等待线程1读取完后退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
    • 注意:如果读锁只读不写的话,就不存在”死锁”问题。
    • image-20210726210610999
  • 写锁:独占锁。即一次只允许一个线程对某一个资源进行写操作(这个时候不存在读线程,也不存在写线程(除非是它自己))——“单独写”
    • 写锁存在也”死锁”问题:线程1和线程2对一张表的不同行进行写操作。这个时候线程1想要写线程2操作的行,就需要等待线程2写完毕退出;同理,线程2想要写线程1操作的行,就需要等待线程1写完毕退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
    • image-20210726210629126

9、自旋锁与自适应自旋锁、偏向锁

4、关键字:synchronized篇里有详细说明。这里不在赘述。

14、JUC线程池——FutureTask(未来任务)

1、BAT大厂的面试问题

  • FutureTask用来解决什么问题的?为什么会出现?
  • FutureTask类结构关系怎么样的?
  • FutureTask的线程安全是由什么保证的?
  • FutureTask结果返回机制?
  • FutureTask内部运行状态的转变?
  • FutureTask通常会怎么用?举例说明。

2、FutureTask简介

  • FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)**和取消任务(cancel)**等。
  • 如果任务尚未完成,获取任务执行结果时将会阻塞
  • **一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)**。
  • FutureTask 常用来封装 CallableRunnable,也可以作为一个任务提交到线程池中执行
  • 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用
  • FutureTask 的线程安全由CAS来保证

3、FutureTask类关系

img

可以看到,FutureTask实现了RunnableFuture接口,则RunnableFuture接口继承了Runnable接口和Future接口,所以FutureTask既能当做一个Runnable直接被Thread执行,也能作为Future用来得到Callable的计算结果

4、FutureTask源码分析

1、Callable接口

Callable是个泛型接口,泛型V就是要call()方法返回的类型。若是不能返回成功,则抛异常。

对比Runnable接口,Runnable不会返回数据也不能抛出异常。

1
2
3
4
5
6
7
8
9
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
2、Future接口

Future接口代表异步计算的结果通过Future接口提供的方法可以查看异步计算是否执行完成,或者等待执行结果并获取执行结果,同时还可以取消执行

Future接口的定义如下:

1
2
3
4
5
6
7
8
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
  • cancel(boolean mayInterruptIfRunning)cancel()方法用来取消异步任务的执行。
    • 如果异步任务已经完成或者已经被取消,或者由于某些原因不能取消,则会返回false。
    • 如果任务还没有被执行,则会返回true并且异步任务不会被执行。
    • 如果任务已经开始执行了但是还没有执行完成,若mayInterruptIfRunning为true,则会立即中断执行任务的线程并返回true,若mayInterruptIfRunning为false,则会返回true且不会中断任务执行线程。
  • isCanceled()判断任务是否被取消,如果任务在结束(正常执行结束或者执行异常结束)前被取消则返回true,否则返回false。
  • isDone()判断任务是否已经完成,如果完成则返回true,否则返回false。
    • 需要注意的是:任务执行过程中发生异常、任务被取消也属于任务已完成,也会返回true
  • get()获取任务执行结果,如果任务还没完成则会阻塞等待直到任务执行完成
    • 如果任务被取消则会抛出CancellationException异常,如果任务执行过程发生异常则会抛出ExecutionException异常,如果阻塞等待过程中被中断则会抛出InterruptedException异常。
  • get(long timeout,Timeunit unit)带超时时间的get()版本,如果阻塞等待过程中超时则会抛出TimeoutException异常
3、核心属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//内部持有的callable任务,运行完毕后置空
private Callable<V> callable;

//从get()中返回的结果或抛出的异常
private Object outcome; // non-volatile, protected by state reads/writes

//运行callable的线程
private volatile Thread runner;

//使用Treiber栈保存等待线程
private volatile WaitNode waiters;

//任务状态
private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;

其中需要注意的是**state是volatile类型的**,也就是说只要有任何一个线程修改了这个变量,那么其他所有的线程都会知道最新的值。

7种状态具体表示:

  • NEW:表示是个新的任务或者还没被执行完的任务。这是初始状态
  • COMPLETING任务已经执行完成或者执行任务的时候发生异常,但是任务执行结果或者异常原因还没有保存到outcome字段(outcome字段用来保存任务执行结果,如果发生异常,则用来保存异常原因)的时候,状态会从NEW变更到COMPLETING。但是这个状态会时间会比较短,属于中间状态。
  • NORMAL任务已经执行完成并且任务执行结果已经保存到outcome字段,状态会从COMPLETING转换到NORMAL。这是一个最终态。
  • EXCEPTIONAL任务执行发生异常并且异常原因已经保存到outcome字段中后,状态会从COMPLETING转换到EXCEPTIONAL。这是一个最终态。
  • CANCELLED任务还没开始执行或者已经开始执行但是还没有执行完成的时候,用户调用了cancel(false)方法取消任务且不中断任务执行线程,这个时候状态会从NEW转化为CANCELLED状态。这是一个最终态。
  • INTERRUPTING任务还没开始执行或者已经执行但是还没有执行完成的时候,用户调用了cancel(true)方法取消任务并且要中断任务执行线程但是还没有中断任务执行线程之前,状态会从NEW转化为INTERRUPTING。这是一个中间状态。
  • INTERRUPTED:**调用interrupt()中断任务执行线程之后状态会从INTERRUPTING转换到INTERRUPTED。这是一个最终态。 **
    • 有一点需要注意的是,所有值大于COMPLETING的状态都表示任务已经执行完成(任务正常执行完成,任务执行异常或者任务被取消)。

各个状态之间的可能转换关系如下图所示:

img

4、构造函数
  • FutureTask(Callable callable)

    • public FutureTask(Callable<V> callable) {
          if (callable == null)
              throw new NullPointerException();
          this.callable = callable;
          this.state = NEW;       // ensure visibility of callable
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10

      - 这个构造函数会把传入的Callable变量保存在this.callable字段中,该字段定义为`private Callable<V> callable`;**用来保存底层的调用,在被执行完成以后会指向null,接着会初始化state字段为NEW**。

      - FutureTask(Runnable runnable, V result)

      - ```java
      public FutureTask(Runnable runnable, V result) {
      this.callable = Executors.callable(runnable, result);
      this.state = NEW; // ensure visibility of callable
      }
    • 这个构造函数会把传入的Runnable封装成一个Callable对象保存在callable字段中,同时如果任务执行成功的话就会返回传入的result。这种情况下如果不需要返回值的话可以传入一个null。

    • 顺带看下Executors.callable()这个方法,这个方法的功能是把Runnable转换成Callable,代码如下:

      • public static <T> Callable<T> callable(Runnable task, T result) {
            if (task == null)
               throw new NullPointerException();
            return new RunnableAdapter<T>(task, result);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16

        - 可以看到这里采用的是**适配器模式**,调用`RunnableAdapter<T>(task, result)`方法来适配,实现如下:

        - ```java
        static final class RunnableAdapter<T> implements Callable<T> {
        final Runnable task;
        final T result;
        RunnableAdapter(Runnable task, T result) {
        this.task = task;
        this.result = result;
        }
        public T call() {
        task.run();
        return result;
        }
        }
    • 这个适配器很简单,就是简单的实现了Callable接口,在call()实现中调用Runnable.run()方法,然后把传入的result作为任务的结果返回。

在new了一个FutureTask对象之后,接下来就是在另一个线程中执行这个Task,无论是通过直接new一个Thread还是通过线程池,执行的都是run()方法,接下来就看看run()方法的实现。

5、核心方法——run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void run() {
//新建任务,CAS替换runner为当前线程
if (state != NEW ||
!UNSAFE.compareAndSwapObject(this, runnerOffset,
null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);//设置执行结果
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);//处理中断逻辑
}
}

说明:

  • 运行任务,如果任务状态为NEW状态,则利用CAS修改为当前线程。执行完毕调用set(result)方法设置执行结果。set(result)源码如下:(使用的也是CAS修改state状态为COMPLETING)

    • protected void set(V v) {
          if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
              outcome = v;
              UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
              finishCompletion();//执行完毕,唤醒等待线程
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28

      - 首先利用cas修改state状态为COMPLETING,设置返回结果,然后使用 lazySet(UNSAFE.putOrderedInt)的方式设置state状态为NORMAL。结果设置完毕后,调用finishCompletion()方法唤醒等待线程,源码如下:

      - ```java
      private void finishCompletion() {
      // assert state > COMPLETING;
      for (WaitNode q; (q = waiters) != null;) {
      if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {//移除等待线程
      for (;;) {//自旋遍历等待线程
      Thread t = q.thread;
      if (t != null) {
      q.thread = null;
      LockSupport.unpark(t);//唤醒等待线程
      }
      WaitNode next = q.next;
      if (next == null)
      break;
      q.next = null; // unlink to help gc
      q = next;
      }
      break;
      }
      }
      //任务完成后调用函数,自定义扩展
      done();

      callable = null; // to reduce footprint
      }
  • 回到run方法,如果在 run 期间被中断,此时需要调用handlePossibleCancellationInterrupt方法来处理中断逻辑,确保任何中断(例如cancel(true))只停留在当前run或runAndReset的任务中,源码如下:

    • private void handlePossibleCancellationInterrupt(int s) {
          //在中断者中断线程之前可能会延迟,所以我们只需要让出CPU时间片自旋等待
          if (s == INTERRUPTING)
              while (state == INTERRUPTING)
                  Thread.yield(); // wait out pending interrupt
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      ##### 6、核心方法——get()

      ```java
      //获取执行结果
      public V get() throws InterruptedException, ExecutionException {
      int s = state;
      if (s <= COMPLETING)
      s = awaitDone(false, 0L);
      return report(s);
      }

说明:FutureTask 通过get()方法获取任务执行结果。如果任务处于未完成的状态(state <= COMPLETING),就调用awaitDone方法(后面单独讲解)等待任务完成。任务完成后,通过report方法获取执行结果或抛出执行期间的异常。

report源码如下:

1
2
3
4
5
6
7
8
9
//返回执行结果或抛出异常
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
7、核心方法——awaitDone(boolean timed, long nanos)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private int awaitDone(boolean timed, long nanos)
throws InterruptedException {
final long deadline = timed ? System.nanoTime() + nanos : 0L;
WaitNode q = null;
boolean queued = false;
for (;;) {//自旋
if (Thread.interrupted()) {//获取并清除中断状态
removeWaiter(q);//移除等待WaitNode
throw new InterruptedException();
}

int s = state;
if (s > COMPLETING) {
if (q != null)
q.thread = null;//置空等待节点的线程
return s;
}
else if (s == COMPLETING) // cannot time out yet
Thread.yield();
else if (q == null)
q = new WaitNode();
else if (!queued)
//CAS修改waiter
queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
q.next = waiters, q);
else if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
removeWaiter(q);//超时,移除等待节点
return state;
}
LockSupport.parkNanos(this, nanos);//阻塞当前线程
}
else
LockSupport.park(this);//阻塞当前线程
}
}

说明:awaitDone用于等待任务完成,或任务因为中断或超时而终止。返回任务的完成状态。函数执行逻辑如下:

如果线程被中断,首先清除中断状态,调用removeWaiter移除等待节点,然后抛出InterruptedException。

removeWaiter源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void removeWaiter(WaitNode node) {
if (node != null) {
node.thread = null;//首先置空线程
retry:
for (;;) { // restart on removeWaiter race
//依次遍历查找
for (WaitNode pred = null, q = waiters, s; q != null; q = s) {
s = q.next;
if (q.thread != null)
pred = q;
else if (pred != null) {
pred.next = s;
if (pred.thread == null) // check for race
continue retry;
}
else if (!UNSAFE.compareAndSwapObject(this, waitersOffset,q, s)) //cas替换
continue retry;
}
break;
}
}
}
  • 如果当前状态为结束状态(state>COMPLETING),则根据需要置空等待节点的线程,并返回 Future 状态;
  • 如果当前状态为正在完成(COMPLETING),说明此时 Future 还不能做出超时动作,为任务让出CPU执行时间片;
  • 如果state为NEW,先新建一个WaitNode,然后CAS修改当前waiters;
  • 如果等待超时,则调用removeWaiter移除等待节点,返回任务状态;如果设置了超时时间但是尚未超时,则park阻塞当前线程;
  • 其他情况直接阻塞当前线程。
8、核心方法——cancel(boolean mayInterruptIfRunning)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean cancel(boolean mayInterruptIfRunning) {
//如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED
if (!(state == NEW &&
UNSAFE.compareAndSwapInt(this, stateOffset, NEW,
mayInterruptIfRunning ? INTERRUPTING : CANCELLED)))
return false;
try { // in case call to interrupt throws exception
if (mayInterruptIfRunning) {//可以在运行时中断
try {
Thread t = runner;
if (t != null)
t.interrupt();
} finally { // final state
UNSAFE.putOrderedInt(this, stateOffset, INTERRUPTED);
}
}
} finally {
finishCompletion();//移除并唤醒所有等待线程
}
return true;
}

说明:尝试取消任务。如果任务已经完成或已经被取消,此操作会失败。

  • 如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED。
  • 如果当前状态不为NEW,则根据参数mayInterruptIfRunning决定是否在任务运行中也可以中断。中断操作完成后,调用finishCompletion移除并唤醒所有等待线程。

5、FutureTask示例

常用使用方式:

  • 第一种方式:Future + ExecutorService
  • 第二种方式:FutureTask + ExecutorService
  • 第三种方式:FutureTask + Thread
1、Future使用示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FutureDemo {
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
Future future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
Long start = System.currentTimeMillis();
while (true) {
Long current = System.currentTimeMillis();
if ((current - start) > 1000) {
return 1;
}
}
}
});

try {
Integer result = (Integer)future.get();
System.out.println(result);
}catch (Exception e){
e.printStackTrace();
}
}
}
2、FutureTask + Thread例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import java.util.concurrent.*;

public class CallDemo {

public static void main(String[] args) throws ExecutionException, InterruptedException {

/**
* 第一种方式:Future + ExecutorService
* Task task = new Task();
* ExecutorService service = Executors.newCachedThreadPool();
* Future<Integer> future = service.submit(task);
* service.shutdown();
*/


/**
* 第二种方式: FutureTask + ExecutorService
* ExecutorService executor = Executors.newCachedThreadPool();
* Task task = new Task();
* FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
* executor.submit(futureTask);
* executor.shutdown();
*/

/**
* 第三种方式:FutureTask + Thread
*/

// 2. 新建FutureTask,需要一个实现了Callable接口的类的实例作为构造函数参数
FutureTask<Integer> futureTask = new FutureTask<Integer>(new Task());
// 3. 新建Thread对象并启动
Thread thread = new Thread(futureTask);
thread.setName("Task thread");
thread.start();

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");

// 4. 调用isDone()判断任务是否结束
if(!futureTask.isDone()) {
System.out.println("Task is not done");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int result = 0;
try {
// 5. 调用get()方法获取任务结果,如果任务没有执行完成则阻塞等待
result = futureTask.get();
} catch (Exception e) {
e.printStackTrace();
}

System.out.println("result is " + result);

}

// 1. 继承Callable接口,实现call()方法,泛型参数为要返回的类型
static class Task implements Callable<Integer> {

@Override
public Integer call() throws Exception {
System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");
int result = 0;
for(int i = 0; i < 100;++i) {
result += i;
}

Thread.sleep(3000);
return result;
}
}
}

15、JUC强大的辅助类

1、CountDownLatch(减少计数)

CountDownLatch底层也是由AQS,用来同步一个或多个任务的常用并发工具类,强制它们等待由其他任务执行的一组操作完成。

1、BAT大厂的面试问题
  • 什么是CountDownLatch?
  • CountDownLatch底层实现原理?
  • CountDownLatch一次可以唤醒几个任务?
    • 多个
  • CountDownLatch有哪些主要方法?
    • await()、countDown()
  • CountDownLatch适用于什么场景?
  • 写道题:实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束?
    • 使用CountDownLatch 代替wait notify 好处。
2、CountDownLatch介绍

从源码可知,其底层是由AQS提供支持,所以其数据结构可以参考AQS的数据结构,而AQS的数据结构核心就是两个虚拟队列:同步队列sync queue条件队列condition queue,不同的条件会有不同的条件队列。

CountDownLatch主要用来进行线程同步协作,等待所有线程完成倒计时。其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一

CountDownLatch典型的用法是:将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,等待问题被解决的任务调用这个锁存器的await,将他们自己拦住,直至锁存器计数结束。

3、CountDownLatch源码分析
1、类的继承关系

CountDownLatch没有显示继承哪个父类或者实现哪个父接口,它底层是AQS是通过内部类Sync来实现的

1
public class CountDownLatch {}
2、类的内部类

CountDownLatch类存在一个内部类Sync,继承自AbstractQueuedSynchronizer,(AQS)其源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static final class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 4982264981922014374L;

// 构造器
Sync(int count) {
setState(count);
}

// 返回当前计数
int getCount() {
return getState();
}

// 试图在共享模式下获取对象状态
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}

// 试图设置状态来反映共享模式下的一个释放
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
// 无限循环
for (;;) {
// 获取状态
int c = getState();
if (c == 0) // 没有被线程占有
return false;
// 下一个状态
int nextc = c-1;
if (compareAndSetState(c, nextc)) // 比较并且设置成功
return nextc == 0;
}
}
}

说明:对CountDownLatch方法的调用会转发到对Sync或AQS的方法的调用,所以,AQS对CountDownLatch提供支持。

3、类的属性

CountDownLatch类的内部只有一个Sync类型的属性:

1
2
3
4
public class CountDownLatch {
// 同步队列
private final Sync sync;
}
4、类的构造函数
1
2
3
4
5
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
// 初始化状态数
this.sync = new Sync(count);
}

说明:该构造函数可以构造一个用给定计数初始化的CountDownLatch,并且构造函数内完成了sync的初始化,并设置了状态数

5、核心函数——await函数

此函数将会使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。其源码如下:

1
2
3
4
public void await() throws InterruptedException {
// 转发到sync对象上
sync.acquireSharedInterruptibly(1);
}

说明:由源码可知,对CountDownLatch对象的await的调用会转发为对Sync的acquireSharedInterruptibly(从AQS继承的方法)方法的调用

  • acquireSharedInterruptibly源码如下:

    • public final void acquireSharedInterruptibly(int arg)
              throws InterruptedException {
          if (Thread.interrupted())
              throw new InterruptedException();
          if (tryAcquireShared(arg) < 0)
              doAcquireSharedInterruptibly(arg);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 说明:从源码中可知,acquireSharedInterruptibly又调用了CountDownLatch的内部类Sync的tryAcquireShared和AQS的doAcquireSharedInterruptibly函数。

      - tryAcquireShared函数的源码如下:

      - ```java
      protected int tryAcquireShared(int acquires) {
      return (getState() == 0) ? 1 : -1;
      }
    • 说明:该函数只是简单的判断AQS的state是否为0,为0则返回1,不为0则返回-1。

  • doAcquireSharedInterruptibly函数的源码如下:

    • private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
          // 添加节点至等待队列
          final Node node = addWaiter(Node.SHARED);
          boolean failed = true;
          try {
              for (;;) { // 无限循环
                  // 获取node的前驱节点
                  final Node p = node.predecessor();
                  if (p == head) { // 前驱节点为头结点
                      // 试图在共享模式下获取对象状态
                      int r = tryAcquireShared(arg);
                      if (r >= 0) { // 获取成功
                          // 设置头结点并进行繁殖
                          setHeadAndPropagate(node, r);
                          // 设置节点next域
                          p.next = null; // help GC
                          failed = false;
                          return;
                      }
                  }
                  if (shouldParkAfterFailedAcquire(p, node) &&
                      parkAndCheckInterrupt()) // 在获取失败后是否需要禁止线程并且进行中断检查
                      // 抛出异常
                      throw new InterruptedException();
              }
          } finally {
              if (failed)
                  cancelAcquire(node);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41

      - 说明:在AQS的doAcquireSharedInterruptibly中可能会再次调用CountDownLatch的内部类Sync的tryAcquireShared方法和AQS的setHeadAndPropagate方法。

      - setHeadAndPropagate方法源码如下:

      - ```java
      private void setHeadAndPropagate(Node node, int propagate) {
      // 获取头结点
      Node h = head; // Record old head for check below
      // 设置头结点
      // 设置自己为 head
      setHead(node);
      /*
      * Try to signal next queued node if:
      * Propagation was indicated by caller,
      * or was recorded (as h.waitStatus either before
      * or after setHead) by a previous operation
      * (note: this uses sign-check of waitStatus because
      * PROPAGATE status may transition to SIGNAL.)
      * and
      * The next node is waiting in shared mode,
      * or we don't know, because it appears null
      *
      * The conservatism in both of these checks may cause
      * unnecessary wake-ups, but only when there are multiple
      * racing acquires/releases, so most need signals now or soon
      * anyway.
      */
      // propagate 表示有共享资源(例如共享读锁或信号量)
      // 原 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
      // 现在 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
      if (propagate > 0 || h == null || h.waitStatus < 0 ||
      (h = head) == null || h.waitStatus < 0) {
      // 获取节点的后继
      Node s = node.next;
      // 如果是最后一个节点或者是等待共享读锁的节点
      if (s == null || s.isShared()) // 后继为空或者为共享模式
      // 以共享模式进行释放
      doReleaseShared();
      }
      }
    • 说明:该方法设置头结点并且释放头结点后面的满足条件的结点,该方法中可能会调用到AQS的doReleaseShared方法。

  • AQS的doReleaseShared方法其源码如下:

    • private void doReleaseShared() {
          /*
              * Ensure that a release propagates, even if there are other
              * in-progress acquires/releases.  This proceeds in the usual
              * way of trying to unparkSuccessor of head if it needs
              * signal. But if it does not, status is set to PROPAGATE to
              * ensure that upon release, propagation continues.
              * Additionally, we must loop in case a new node is added
              * while we are doing this. Also, unlike other uses of
              * unparkSuccessor, we need to know if CAS to reset status
              * fails, if so rechecking.
              */
          // 无限循环
          // 如果 head.waitStatus == Node.SIGNAL ==> 0 成功, 下一个节点 unpark
          // 如果 head.waitStatus == 0 ==> Node.PROPAGATE, 为了解决 bug, 见后面分析
          for (;;) {
              // 保存头结点
              Node h = head;
              // 队列还有节点
              if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
                  // 获取头结点的等待状态
                  int ws = h.waitStatus; 
                  if (ws == Node.SIGNAL) { // 状态为SIGNAL
                      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
                          continue;            // loop to recheck cases
                      // 下一个节点 unpark 如果成功获取读锁
                      // 并且下下个节点还是 shared, 继续 doReleaseShared
                      // 释放后继结点
                      unparkSuccessor(h);
                  }
                  else if (
                      // 如果已经是 0 了,改为 -3,用来解决传播性,见后文信号量 bug 分析
                      ws == 0 &&
                      !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
                      continue;                // loop on failed CAS
              }
              if (h == head) // 若头结点改变,继续循环  
                  break;
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
          
      - 说明:**该方法在共享模式下释放**,具体的流程再之后会通过一个示例给出。

      所以,对CountDownLatch的await调用大致会有如下的调用链:

      ![img](JUC/java-thread-x-countdownlatch-1.png)

      说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。

      ###### 6、核心函数——countDown函数

      此函数将递减锁存器的计数,如果计数到达零,则释放所有等待的线程。

      ```java
      public void countDown() {
      sync.releaseShared(1);
      }

说明:对countDown的调用转换为对Sync对象的releaseShared(从AQS继承而来)方法的调用

  • releaseShared源码如下:

    • public final boolean releaseShared(int arg) {
          if (tryReleaseShared(arg)) {
              doReleaseShared();
              return true;
          }
          return false;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20

      - 说明:**此函数会以共享模式释放对象,并且在函数中会调用到CountDownLatch的tryReleaseShared函数,并且可能会调用AQS的doReleaseShared函数**。

      - tryReleaseShared源码如下:

      - ```java
      protected boolean tryReleaseShared(int releases) {
      // Decrement count; signal when transition to zero
      // 无限循环
      for (;;) {
      // 获取状态
      int c = getState();
      if (c == 0) // 没有被线程占有
      return false;
      // 下一个状态
      int nextc = c-1;
      if (compareAndSetState(c, nextc)) // 比较并且设置成功
      return nextc == 0;
      }
      }
    • 说明:此函数会试图设置状态来反映共享模式下的一个释放。具体的流程在下面的示例中会进行分析。

  • AQS的doReleaseShared的源码如下:

    • private void doReleaseShared() {
          /*
              * Ensure that a release propagates, even if there are other
              * in-progress acquires/releases.  This proceeds in the usual
              * way of trying to unparkSuccessor of head if it needs
              * signal. But if it does not, status is set to PROPAGATE to
              * ensure that upon release, propagation continues.
              * Additionally, we must loop in case a new node is added
              * while we are doing this. Also, unlike other uses of
              * unparkSuccessor, we need to know if CAS to reset status
              * fails, if so rechecking.
              */
          // 无限循环
          for (;;) {
              // 保存头结点
              Node h = head;
              if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
                  // 获取头结点的等待状态
                  int ws = h.waitStatus; 
                  if (ws == Node.SIGNAL) { // 状态为SIGNAL
                      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
                          continue;            // loop to recheck cases
                      // 释放后继结点
                      unparkSuccessor(h);
                  }
                  else if (ws == 0 &&
                              !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
                      continue;                // loop on failed CAS
              }
              if (h == head) // 若头结点改变,继续循环  
                  break;
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52

      - 说明:此函数在共享模式下释放资源。

      所以,对CountDownLatch的countDown调用大致会有如下的调用链:

      ![img](JUC/java-thread-x-countdownlatch-2.png)

      说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。

      ##### 4、CountDownLatch示例

      下面给出了一个使用CountDownLatch的示例:

      ```java
      import java.util.concurrent.CountDownLatch;

      class MyThread extends Thread {
      private CountDownLatch countDownLatch;

      public MyThread(String name, CountDownLatch countDownLatch) {
      super(name);
      this.countDownLatch = countDownLatch;
      }

      public void run() {
      System.out.println(Thread.currentThread().getName() + " doing something");
      try {
      Thread.sleep(1000);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + " finish");
      countDownLatch.countDown();
      }
      }

      public class CountDownLatchDemo {
      public static void main(String[] args) {
      CountDownLatch countDownLatch = new CountDownLatch(2);
      MyThread t1 = new MyThread("t1", countDownLatch);
      MyThread t2 = new MyThread("t2", countDownLatch);
      t1.start();
      t2.start();
      System.out.println("Waiting for t1 thread and t2 thread to finish");
      try {
      countDownLatch.await();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + " continue");
      }
      }

运行结果(某一次):

1
2
3
4
5
6
Waiting for t1 thread and t2 thread to finish
t1 doing something
t2 doing something
t1 finish
t2 finish
main continue

说明:本程序首先计数器初始化为2。根据结果,可能会存在如下的一种时序图:

img

说明:首先main线程会调用await操作,此时main线程会被阻塞,等待被唤醒,之后t1线程执行了countDown操作,最后,t2线程执行了countDown操作,此时main线程就被唤醒了,可以继续运行。下面,进行详细分析:

  • main线程执行countDownLatch.await操作,主要调用的函数如下:
    • img
    • 说明:在最后,main线程就被park了,即禁止运行了。此时Sync queue(同步队列)中有两个节点,AQS的state为2,包含main线程的结点的nextWaiter指向SHARED结点。
  • t1线程执行countDownLatch.countDown操作,主要调用的函数如下:
    • img
    • 说明:此时,Sync queue队列里的结点个数未发生变化,但是此时,AQS的state已经变为1了。
  • t2线程执行countDownLatch.countDown操作,主要调用的函数如下:
    • img
    • 说明:经过调用后,AQS的state为0,并且此时,main线程会被unpark,可以继续运行。当main线程获取cpu资源后,继续运行。
  • main线程获取cpu资源,继续运行,由于main线程是在parkAndCheckInterrupt函数中被禁止的,所以此时,继续在parkAndCheckInterrupt函数运行。
    • img
    • 说明:main线程恢复,继续在parkAndCheckInterrupt函数中运行,之后又会回到最终达到的状态为:AQS的state为0,并且head与tail指向同一个结点,该节点的nextWaiter域还是指向SHARED结点。
5、更深入理解
1、面试题

实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束。

2、使用wait和notify实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.ArrayList;
import java.util.List;

/**
* 必须先让t2先进行启动 使用wait 和 notify 进行相互通讯,wait会释放锁,notify不会释放锁
*/
public class T2 {

volatile List list = new ArrayList();

public void add (int i){
list.add(i);
}

public int getSize(){
return list.size();
}

public static void main(String[] args) {

T2 t2 = new T2();

Object lock = new Object();

new Thread(() -> {
synchronized(lock){
System.out.println("t2 启动");
if(t2.getSize() != 5){
try {
/**会释放锁*/
lock.wait();
System.out.println("t2 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**唤醒T1进程*/
lock.notify();
}
},"t2").start();

new Thread(() -> {
synchronized (lock){
System.out.println("t1 启动");
for (int i=0;i<9;i++){
t2.add(i);
System.out.println("add"+i);
if(t2.getSize() == 5){
/**不会释放锁*/
lock.notify();
try {
/**进程挂起,释放锁等待唤醒*/
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
},"t1").start();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
t2 启动
t1 启动
add0
add1
add2
add3
add4
t2 结束
add5
add6
add7
add8
3、CountDownLatch实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
* 使用CountDownLatch 代替wait notify 好处是通讯方式简单,不涉及锁定 Count 值为0时当前线程继续执行,
*/
public class T3 {

volatile List list = new ArrayList();

public void add(int i){
list.add(i);
}

public int getSize(){
return list.size();
}


public static void main(String[] args) {
T3 t = new T3();
CountDownLatch countDownLatch = new CountDownLatch(1);

new Thread(() -> {
System.out.println("t2 start");
if(t.getSize() != 5){
try {
countDownLatch.await();
System.out.println("t2 end");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t2").start();

new Thread(()->{
System.out.println("t1 start");
for (int i = 0;i<9;i++){
t.add(i);
System.out.println("add"+ i);
if(t.getSize() == 5){
System.out.println("countdown is open");
countDownLatch.countDown();
}
}
System.out.println("t1 end");
},"t1").start();
}

}

2、CyclicBarrier(循环栅栏)

CyclicBarrier底层是基于ReentrantLockAbstractQueuedSynchronizer来实现的,在理解的时候最好和CountDownLatch放在一起理解。

1、BAT大厂的面试问题
  • 什么是CyclicBarrier?
  • CyclicBarrier底层实现原理?
  • CountDownLatch和CyclicBarrier对比?
  • CyclicBarrier的核心函数有哪些?
  • CyclicBarrier适用于什么场景?
2、CyclicBarrier简介
  • 对于CountDownLatch,其他线程为游戏玩家,比如英雄联盟,主线程为控制游戏开始的线程。在所有的玩家都准备好之前,主线程是处于等待状态的,也就是游戏不能开始。当所有的玩家准备好之后,下一步的动作实施者为主线程,即开始游戏。
  • 对于CyclicBarrier,假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推。类比地,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。
  • 注意:CyclicBarrier的计数与线程数最好是一一对应才能达到我们的要求
    • 例子:一开始两个任务task1(执行1s)与task2(执行2s),需要循环执行3次,即两对三次总共六次任务,每一对任务执行完毕会执行CyclicBarrier当中的任务task3,所以设置CyclicBarrier的计数为2,对应每一组两个任务
    • 如果我们设置线程池的线程个数为2,那么会如我们所想执行——task1 task2 task3 task1 task2 task3 task1 task2 task3
    • 如果我们设置线程池的线程个数为3,那么就不会如我们所想执行了,因为一开始会有三个线程线执行任务:task1 task2 task1,而CyclicBarrier的task3会被两个task1执行(因为1 + 1 = 2)结束后执行,执行流程就变成——task1 task1 task3 task2 task1 task3 task2 task2 task3
3、CyclicBarrier源码分析
1、类的继承关系

CyclicBarrier没有显示继承哪个父类或者实现哪个父接口,所有AQS和重入锁不是通过继承实现的,而是通过组合实现的。

1
public class CyclicBarrier {}
2、类的内部类

CyclicBarrier类存在一个内部类Generation,每一次使用的CycBarrier可以当成Generation的实例,其源代码如下:

1
2
3
private static class Generation {
boolean broken = false;
}

说明:Generation类有一个属性broken,用来表示当前屏障是否被损坏

3、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CyclicBarrier {
/** The lock for guarding barrier entry */
// 可重入锁
private final ReentrantLock lock = new ReentrantLock();
/** Condition to wait on until tripped */
// 条件队列
private final Condition trip = lock.newCondition();
/** The number of parties */
// 参与的线程数量
private final int parties;
/* The command to run when tripped */
// 由最后一个进入 barrier 的线程执行的操作
private final Runnable barrierCommand;
/** The current generation */
// 当前代
private Generation generation = new Generation();
// 正在等待进入屏障的线程数量
private int count;
}

说明:该属性有一个为ReentrantLock对象,有一个为Condition对象,而Condition对象又是基于AQS的,所以,归根到底,底层还是由AQS提供支持

4、类的构造函数
  • CyclicBarrier(int, Runnable)型构造函数:

    • public CyclicBarrier(int parties, Runnable barrierAction) {
          // 参与的线程数量小于等于0,抛出异常
          if (parties <= 0) throw new IllegalArgumentException();
          // 设置parties
          this.parties = parties;
          // 设置count
          this.count = parties;
          // 设置barrierCommand
          this.barrierCommand = barrierAction;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 说明:该构造函数可以指定关联该CyclicBarrier的线程数量,并且可以指定在所有线程都进入屏障后的执行动作,该执行动作由最后一个进行屏障的线程执行。

      - CyclicBarrier(int)型构造函数:

      - ```java
      public CyclicBarrier(int parties) {
      // 调用含有两个参数的构造函数
      this(parties, null);
      }

    • 说明:该构造函数仅仅执行了关联该CyclicBarrier的线程数量,没有设置执行动作。

5、核心函数——dowait函数

此函数为CyclicBarrier类的核心函数,CyclicBarrier类对外提供的await函数在底层都是调用该类的doawait函数

await函数源代码如下:

1
2
3
4
5
6
7
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}

doawait函数源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
// 保存当前锁
final ReentrantLock lock = this.lock;
// 锁定
lock.lock();
try {
// 保存当前代
final Generation g = generation;

if (g.broken) // 屏障被破坏,抛出异常
throw new BrokenBarrierException();

if (Thread.interrupted()) { // 线程被中断
// 损坏当前屏障,并且唤醒所有的线程,只有拥有锁的时候才会调用
breakBarrier();
// 抛出异常
throw new InterruptedException();
}

// 减少正在等待进入屏障的线程数量
int index = --count;
if (index == 0) { // 正在等待进入屏障的线程数量为0,所有线程都已经进入
// 运行的动作标识
boolean ranAction = false;
try {
// 保存运行动作
final Runnable command = barrierCommand;
if (command != null) // 动作不为空
// 运行
command.run();
// 设置ranAction状态
ranAction = true;
// 进入下一代
nextGeneration();
return 0;
} finally {
if (!ranAction) // 没有运行的动作
// 损坏当前屏障
breakBarrier();
}
}

// loop until tripped, broken, interrupted, or timed out
// 无限循环
for (;;) {
try {
if (!timed) // 没有设置等待时间
// 等待
trip.await();
else if (nanos > 0L) // 设置了等待时间,并且等待时间大于0
// 等待指定时长
nanos = trip.awaitNanos(nanos);
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) { // 等于当前代并且屏障没有被损坏
// 损坏当前屏障
breakBarrier();
// 抛出异常
throw ie;
} else { // 不等于当前带后者是屏障被损坏
// We're about to finish waiting even if we had not
// been interrupted, so this interrupt is deemed to
// "belong" to subsequent execution.
// 中断当前线程
Thread.currentThread().interrupt();
}
}

if (g.broken) // 屏障被损坏,抛出异常
throw new BrokenBarrierException();

if (g != generation) // 不等于当前代
// 返回索引
return index;

if (timed && nanos <= 0L) { // 设置了等待时间,并且等待时间小于0
// 损坏屏障
breakBarrier();
// 抛出异常
throw new TimeoutException();
}
}
} finally {
// 释放锁
lock.unlock();
}
}

说明:dowait方法的逻辑会进行一系列的判断,大致流程如下:

img

6、核心函数——nextGenneration函数

此函数在所有线程进入屏障后会被调用,即生成下一个版本,所有线程又可以重新进入到屏障中,其源代码如下:

1
2
3
4
5
6
7
8
9
10
private void nextGeneration() {
// signal completion of last generation
// 唤醒所有线程
trip.signalAll();
// set up next generation
// 恢复正在等待进入屏障的线程数量
count = parties;
// 新生一代
generation = new Generation();
}

在此函数中会调用AQS的signalAll方法,即唤醒所有等待线程

如果所有的线程都在等待此条件,则唤醒所有线程。其源代码如下:

1
2
3
4
5
6
7
8
9
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}

说明:此函数判断头结点是否为空,即条件队列是否为空,然后会调用doSignalAll函数,doSignalAll函数源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 全部唤醒 - 等待队列的所有节点转移至 AQS 队列
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}

说明:此函数会依次将条件队列中的节点转移到同步队列中,会调用到transferForSignal函数,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 如果节点状态是取消, 返回 false 表示转移失败, 否则转移成功
final boolean transferForSignal(Node node) {
/*
* If cannot change waitStatus, the node has been cancelled.
*/
// 如果状态已经不是 Node.CONDITION, 说明被取消了
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;

/*
* Splice onto queue and try to set waitStatus of predecessor to
* indicate that thread is (probably) waiting. If cancelled or
* attempt to set waitStatus fails, wake up to resync (in which
* case the waitStatus can be transiently and harmlessly wrong).
*/
// 加入 AQS 队列尾部
Node p = enq(node);
int ws = p.waitStatus;
if (
// 上一个节点被取消
ws > 0 ||
// 上一个节点不能设置状态为 Node.SIGNAL
!compareAndSetWaitStatus(p, ws, Node.SIGNAL))
// unpark 取消阻塞, 让线程重新同步状态
LockSupport.unpark(node.thread);
return true;
}

说明:此函数的作用就是将处于条件队列中的节点转移到同步队列中,并设置结点的状态信息

其中会调用到enq函数,其源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}

说明:此函数完成了结点插入同步队列的过程,也很好理解。

综合上面的分析可知,newGeneration函数的主要方法的调用如下,之后会通过一个例子详细讲解:

img

7、breakBarrier函数

此函数的作用是损坏当前屏障,会唤醒所有在屏障中的线程。源代码如下:

1
2
3
4
5
6
7
8
private void breakBarrier() {
// 设置状态
generation.broken = true;
// 恢复正在等待进入屏障的线程数量
count = parties;
// 唤醒所有线程
trip.signalAll();
}

说明:可以看到,此函数也调用了AQS的signalAll函数,由signal函数提供支持。

4、CyclicBarrier示例

下面通过一个例子来详解CyclicBarrier的使用和内部工作机制,源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

class MyThread extends Thread {
private CyclicBarrier cb;
public MyThread(String name, CyclicBarrier cb) {
super(name);
this.cb = cb;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " going to await");
try {
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class CyclicBarrierDemo {
public static void main(String[] args) throws InterruptedException, BrokenBarrierException {
CyclicBarrier cb = new CyclicBarrier(3, new Thread("barrierAction") {
public void run() {
System.out.println(Thread.currentThread().getName() + " barrier action");
}
});
MyThread t1 = new MyThread("t1", cb);
MyThread t2 = new MyThread("t2", cb);
t1.start();
t2.start();
System.out.println(Thread.currentThread().getName() + " going to await");
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");

}
}

运行结果(某一次):

1
2
3
4
5
6
7
t1 going to await
main going to await
t2 going to await
t2 barrier action
t2 continue
t1 continue
main continue

说明:根据结果可知,可能会存在如下的调用时序:

java-thread-x-cyclicbarrier-3

说明:由上图可知,假设t1线程的cb.await是在main线程的cb.await之前,cb.barrierAction动作是由最后一个进入屏障的线程t2执行的。根据时序图,进一步分析出其内部工作流程。

  • main(主)线程执行cb.await操作,主要调用的函数如下:
    • img
    • 说明:由于ReentrantLock的默认采用非公平策略,所以在dowait函数中调用的是ReentrantLock.NonfairSync的lock函数,由于此时AQS的状态是0,表示还没有被任何线程占用,故main线程可以占用,之后在dowait中会调用trip.await函数,最终的结果是条件队列中存放了一个包含main线程的结点,并且被禁止运行了,同时,main线程所拥有的资源也被释放了,可以供其他线程获取。
  • t1线程执行cb.await操作,其中假设t1线程的lock.lock操作在main线程释放了资源之后,则其主要调用的函数如下:
    • img
    • 说明:可以看到,之后condition queue(条件队列)里面有两个节点,包含t1线程的结点插入在队列的尾部,并且t1线程也被禁止了,因为执行了park操作,此时两个线程都被禁止了。
  • t2线程执行cb.await操作,其中假设t2线程的lock.lock操作在t1线程释放了资源之后,则其主要调用的函数如下:
    • img
    • 说明:由上图可知,在t2线程执行await操作后,会直接执行command.run方法,不是重新开启一个线程,而是最后进入屏障的线程执行。同时,会将Condition queue中的所有节点都转移到Sync queue中,并且最后main线程会被unpark,可以继续运行。main线程获取cpu资源,继续运行。
  • main线程获取cpu资源,继续运行,下图给出了主要的方法调用:
    • img
    • 说明:其中,由于main线程是在AQS.CO的wait中被park的,所以恢复时,会继续在该方法中运行。运行过后,t1线程被unpark,它获得cpu资源可以继续运行。
  • t1线程获取cpu资源,继续运行,下图给出了主要的方法调用:
    • img
    • 说明:其中,由于t1线程是在AQS.CO的wait方法中被park,所以恢复时,会继续在该方法中运行。运行过后,Sync queue中保持着一个空节点。头结点与尾节点均指向它。

注意:在线程await过程中中断线程会抛出异常,所有进入屏障的线程都将被释放。至于CyclicBarrier的其他用法,读者可以自行查阅API。

5、新增一个容易理解的例子

场景:收集七龙珠召唤神龙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

//集齐7颗龙珠就可以召唤神龙
public class CyclicBarrierDemo {

//创建固定值
private static final int NUMBER = 7;

public static void main(String[] args) {
//创建CyclicBarrier
CyclicBarrier cyclicBarrier =
new CyclicBarrier(NUMBER,()->{
System.out.println("集齐7颗龙珠就可以召唤神龙");
});

//集齐七颗龙珠过程
for (int i = 1; i <=7; i++) {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+" 星龙珠被收集到了");
//等待
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}

某一次执行结果:

1
2
3
4
5
6
7
8
2 星龙珠被收集到了
4 星龙珠被收集到了
3 星龙珠被收集到了
1 星龙珠被收集到了
7 星龙珠被收集到了
6 星龙珠被收集到了
5 星龙珠被收集到了
集齐7颗龙珠就可以召唤神龙

若将主线程的循环从7改成6的话,由于只能收集到6颗龙珠,所以不能召唤神龙(其实就是没能达到破坏屏障的条件,所有的线程都在等待)

1
2
3
4
5
6
2 星龙珠被收集到了
5 星龙珠被收集到了
4 星龙珠被收集到了
3 星龙珠被收集到了
1 星龙珠被收集到了
6 星龙珠被收集到了
6、和CountDownLatch再对比
  • CountDownLatch减计数,CyclicBarrier加计数。
  • CountDownLatch是一次性的,CyclicBarrier可以重用。
  • CountDownLatch和CyclicBarrier都有让多个线程等待同步然后再开始下一步动作的意思,但是CountDownLatch的下一步的动作实施者是主线程,具有不可重复性;而CyclicBarrier的下一步动作实施者还是“其他线程”本身,具有往复多次实施动作的特点。

3、Semaphore(信号量)

Semaphore底层是基于AbstractQueuedSynchronizer(AQS)来实现的。Semaphore称为计数信号量,它允许n个任务同时访问某个资源,可以将信号量看做是在向外分发使用资源的许可证,只有成功获取许可证,才能使用资源,用来限制能同时访问共享资源的线程上限。

1、BAT大厂的面试问题
  • 什么是Semaphore?
  • Semaphore内部原理?
  • Semaphore常用方法有哪些?如何实现线程同步和互斥的?
  • Semaphore适合用在什么场景?
  • 单独使用Semaphore是不会使用到AQS的条件队列?
  • Semaphore中申请令牌(acquire)、释放令牌(release)的实现?
  • Semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?
  • Semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?
  • Semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?
  • Semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?
2、Semaphore源码分析
1、类的继承关系
1
public class Semaphore implements java.io.Serializable {}

说明:Semaphore实现了Serializable接口,即可以进行序列化

2、类的内部类

Semaphore总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:

image

说明:Semaphore与ReentrantLock的内部类的结构相同,类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

3、类的内部类——Sync类

Sync类的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 内部类,继承自AQS
abstract static class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 1192457210091910933L;

// 构造函数
Sync(int permits) {
// 设置状态数
// permits 即 state
setState(permits);
}

// 获取许可
final int getPermits() {
return getState();
}

// 共享模式下非公平策略获取
final int nonfairTryAcquireShared(int acquires) {
for (;;) { // 无限循环
// 获取许可数
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (
// 如果许可已经用完, 返回负数, 表示获取失败, 进入 doAcquireSharedInterruptibly
remaining < 0 ||
// 如果 cas 重试成功, 返回正数, 表示获取成功
compareAndSetState(available, remaining)) // 许可小于0或者比较并且设置状态成功
return remaining;
}
}

// 共享模式下进行释放
protected final boolean tryReleaseShared(int releases) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return true;
}
}

// 根据指定的缩减量减小可用许可的数目
final void reducePermits(int reductions) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current - reductions;
if (next > current) // underflow
throw new Error("Permit count underflow");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return;
}
}

// 获取并返回立即可用的所有许可
final int drainPermits() {
for (;;) { // 无限循环
// 获取许可
int current = getState();
if (current == 0 || compareAndSetState(current, 0)) // 许可为0或者比较并设置成功
return current;
}
}
}

说明:Sync类的属性相对简单,只有一个版本号,Sync类存在如下方法和作用如下:

img

4、类的内部类——NonfairSync类

NonfairSync类继承了Sync类,表示采用非公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
static final class NonfairSync extends Sync {
// 版本号
private static final long serialVersionUID = -2694183684443567898L;

// 构造函数
NonfairSync(int permits) {
super(permits);
}
// 共享模式下获取
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}

说明:从tryAcquireShared方法的源码可知,其会调用父类Sync的nonfairTryAcquireShared方法,表示按照非公平策略进行资源的获取。

5、类的内部类——FairSync类

FairSync类继承了Sync类,表示采用公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
protected int tryAcquireShared(int acquires) {
for (;;) { // 无限循环
if (hasQueuedPredecessors()) // 同步队列中存在其他节点
return -1;
// 获取许可
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining)) // 剩余的许可小于0或者比较设置成功
return remaining;
}
}

说明:从tryAcquireShared方法的源码可知,它使用公平策略来获取资源,它会判断同步队列中是否存在其他的等待节点

6、类的属性
1
2
3
4
5
6
public class Semaphore implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = -3222578661600680210L;
// 属性
private final Sync sync;
}

说明:Semaphore自身只有两个属性,最重要的是sync属性,基于Semaphore对象的操作绝大多数都转移到了对sync的操作

7、类的构造函数
  • Semaphore(int)型构造函数

    • public Semaphore(int permits) {
          sync = new NonfairSync(permits);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 说明:该构造函数会创建具有给定的许可数和**非公平的设置的Semaphore**。

      - Semaphore(int, boolean)型构造函数

      - ```java
      public Semaphore(int permits, boolean fair) {
      sync = fair ? new FairSync(permits) : new NonfairSync(permits);
      }
    • 说明:该构造函数会创建具有给定的许可数和给定的公平设置的Semaphore

8、核心函数——acquire函数

此方法从信号量获取一个(多个)许可,在提供一个许可前一直将线程阻塞,或者线程被中断,其源码如下:

1
2
3
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
1
2
3
4
public void acquire(int permits) throws InterruptedException {
if (permits < 0) throw new IllegalArgumentException();
sync.acquireSharedInterruptibly(permits);
}

说明:该方法中将会调用Sync对象的acquireSharedInterruptibly(从AQS继承而来的方法)方法,而acquireSharedInterruptibly方法在上面CountDownLatch中已经进行了分析,在此不再累赘。

最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:

img

说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。

9、核心函数——release函数

此方法释放一个(多个)许可,将其返回给信号量,源码如下:

1
2
3
public void release() {
sync.releaseShared(1);
}

说明:该方法中将会调用Sync对象的releaseShared(从AQS继承而来的方法)方法,而releaseShared方法在上面CountDownLatch中已经进行了分析,在此不再累赘。

最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:

img

说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。

10、图解Semaphore的执行流程

加锁解锁流程

  1. Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一

  2. 刚开始,permits(state)为 3,这时 5 个线程来获取资源

    image-20210813191406684

  3. 假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列park 阻塞

    image-20210813192404706

  4. 这时 Thread-4 释放了 permits,状态如下

    image-20210813192655068

  5. 接下来 Thread-0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态

    image-20210813193133463

3、Semaphore示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.concurrent.Semaphore;

class MyThread extends Thread {
private Semaphore semaphore;

public MyThread(String name, Semaphore semaphore) {
super(name);
this.semaphore = semaphore;
}

public void run() {
int count = 3;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(count);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(count);
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}

public class SemaphoreDemo {
public final static int SEM_SIZE = 10;

public static void main(String[] args) {
Semaphore semaphore = new Semaphore(SEM_SIZE);
MyThread t1 = new MyThread("t1", semaphore);
MyThread t2 = new MyThread("t2", semaphore);
t1.start();
t2.start();
int permits = 5;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(permits);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
main trying to acquire
main acquire successfully
t1 trying to acquire
t1 acquire successfully
t2 trying to acquire
t1 release successfully
main release successfully
t2 acquire successfully
t2 release successfully

说明:首先,生成一个信号量,信号量有10个许可,然后,main,t1,t2三个线程获取许可运行,根据结果,可能存在如下的一种时序:

img

说明:如上图所示,首先,main线程执行acquire操作,并且成功获得许可,之后t1线程执行acquire操作,成功获得许可,之后t2执行acquire操作,由于此时许可数量不够,t2线程将会阻塞,直到许可可用。之后t1线程释放许可,main线程释放许可,此时的许可数量可以满足t2线程的要求,所以,此时t2线程会成功获得许可运行,t2运行完成后释放许可。下面进行详细分析:

  • main线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到只是AQS的state变为了5,main线程并没有被阻塞,可以继续运行。
  • t1线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到只是AQS的state变为了2,t1线程并没有被阻塞,可以继续运行。
  • t2线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程获取许可不会成功,之后会导致其被禁止运行,值得注意的是,AQS的state还是为2。
  • t1执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程将会被unpark,并且AQS的state为5,t2获取cpu资源后可以继续运行。
  • main线程执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程还会被unpark,但是不会产生影响,此时,只要t2线程获得CPU资源就可以运行了。此时,AQS的state为10。
  • t2获取CPU资源,继续运行,此时t2需要恢复现场,回到parkAndCheckInterrupt函数中,也是在should继续运行。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到,Sync queue中只有一个结点,头结点与尾节点都指向该结点,在setHeadAndPropagate的函数中会设置头结点并且会unpark队列中的其他结点。
  • t2线程执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:t2线程经过release后,此时信号量的许可又变为10个了,此时Sync queue中的结点还是没有变化。
4、新增一个容易理解的例子

场景:6辆汽车,停3个车位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.Random;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

//6辆汽车,停3个车位
public class SemaphoreDemo {
public static void main(String[] args) {
//创建Semaphore,设置许可数量
Semaphore semaphore = new Semaphore(3);

//模拟6辆汽车
for (int i = 1; i <=6; i++) {
new Thread(()->{
try {
//抢占
semaphore.acquire();

System.out.println(Thread.currentThread().getName()+" 抢到了车位");

//设置随机停车时间
TimeUnit.SECONDS.sleep(new Random().nextInt(5));

System.out.println(Thread.currentThread().getName()+" ------离开了车位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放
semaphore.release();
}
},String.valueOf(i)).start();
}
}
}

某一次执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
1 抢到了车位
2 抢到了车位
3 抢到了车位
2 ------离开了车位
4 抢到了车位
1 ------离开了车位
3 ------离开了车位
5 抢到了车位
6 抢到了车位
4 ------离开了车位
5 ------离开了车位
6 ------离开了车位
5、Semaphore应用
  • 使用Semaphore限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比Tomcat LimitLatch的实现)
  • Semaphore比较适用于资源数与线程数相等的场景
  • 用 Semaphore 实现简单连接池(一个线程对应一个数据库连接),对比享元模式下的实现(用wait notify),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import lombok.extern.slf4j.Slf4j;

import java.sql.*;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicIntegerArray;

public class TestPoolSemaphore {
public static void main(String[] args) {
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}

@Slf4j(topic = "c.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;

// 2. 连接对象数组
private Connection[] connections;

// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;

private Semaphore semaphore;

// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
// 让许可数与资源数一致
this.semaphore = new Semaphore(poolSize);
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}

// 5. 借连接
public Connection borrow() {// t1, t2, t3
// 获取许可
try {
semaphore.acquire(); // 没有许可的线程,在此等待
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 不会执行到这里
return null;
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
log.debug("free {}", conn);
semaphore.release();
break;
}
}
}
}

class MockConnection implements Connection {

private String name;

public MockConnection(String name) {
this.name = name;
}

@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}

// 一些重写的方法
}
6、更深入理解
1、单独使用Semaphore是不会使用到AQS的条件队列的

不同于CyclicBarrier和ReentrantLock,单独使用Semaphore是不会使用到AQS的条件队列的,其实,只有进行await操作才会进入条件队列,其他的都是在同步队列中,只是当前线程会被park。

2、场景问题——semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?

答案:拿不到令牌的线程阻塞,不会继续往下运行。

3、场景问题——semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?

答案:线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,很好,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。(这和你一次性申请11个令牌是一样的)

4、场景问题——semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?

答案:能,原因是release方法会添加令牌,并不会以初始化的大小为准。

5、场景问题——semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?

答案:能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。

具体示例如下,如果不相信的话,可以运行一下下面的demo,在做实验之前,笔者也认为应该是不允许的。。(或许是开发者考虑不周到)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestSemaphore2 {
public static void main(String[] args) {
int permitsNum = 2;
final Semaphore semaphore = new Semaphore(permitsNum);
try {
System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS));
semaphore.release();
System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS));
}catch (Exception e) {

}
}
}

4、Phaser(移相器)

Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。

1、BAT大厂的面试问题
  • Phaser主要用来解决什么问题?
  • Phaser与CyclicBarrier和CountDownLatch的区别是什么?
  • 如果用CountDownLatch来实现Phaser的功能应该怎么实现?
  • Phaser运行机制是什么样的?
  • 给一个Phaser使用的示例?
2、Phaser运行机制

java-thread-x-juc-phaser-1

1、Registration(注册)

跟其他barrier不同,在phaser上注册的parties会随着时间的变化而变化。任务可以随时注册(使用方法register,bulkRegister注册,或者由构造器确定初始parties),并且在任何抵达点可以随意地撤销注册(方法arriveAndDeregister)。就像大多数基本的同步结构一样,注册和撤销只影响内部count;不会创建更深的内部记录,所以任务不能查询他们是否已经注册。(不过,可以通过继承来实现类似的记录)

2、Synchronization(同步机制)

和CyclicBarrier一样,Phaser也可以重复await。方法arriveAndAwaitAdvance的效果类似CyclicBarrier.await。phaser的每一代都有一个相关的phase number,初始值为0,当所有注册的任务都到达phaser时phase+1,到达最大值(Integer.MAX_VALUE)之后清零。使用phase number可以独立控制 ==到达phaser== 和 ==等待其他线程== 的动作,通过下面两种类型的方法:

  • Arrival(到达机制) arrive和arriveAndDeregister方法记录到达状态。这些方法不会阻塞,但是会返回一个相关的arrival phase number;也就是说,phase number用来确定到达状态。当所有任务都到达给定phase时,可以执行一个可选的函数,这个函数通过重写onAdvance方法实现,通常可以用来控制终止状态。重写此方法类似于为CyclicBarrier提供一个barrierAction,但比它更灵活。
  • Waiting(等待机制) awaitAdvance方法需要一个表示arrival phase number的参数,并且在phaser前进到与给定phase不同的phase时返回。和CyclicBarrier不同,即使等待线程已经被中断,awaitAdvance方法也会一直等待。中断状态和超时时间同样可用,但是当任务等待中断或超时后未改变phaser的状态时会遭遇异常。如果有必要,在方法forceTermination之后可以执行这些异常的相关的handler进行恢复操作,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务
3、Termination(终止机制)

可以用isTerminated方法检查phaser的终止状态。在终止时,所有同步方法立刻返回一个负值。在终止时尝试注册也没有效果。当调用onAdvance返回true时Termination被触发。当deregistration操作使已注册的parties变为0时,onAdvance的默认实现就会返回true。也可以重写onAdvance方法来定义终止动作。forceTermination方法也可以释放等待线程并且允许它们终止。

4、Tiering(分层结构)

Phaser支持分层结构(树状构造)来减少竞争。注册了大量parties的Phaser可能会因为同步竞争消耗很高的成本, 因此可以设置一些子Phaser来共享一个通用的parent。这样的话即使每个操作消耗了更多的开销,但是会提高整体吞吐量。 在一个分层结构的phaser里,子节点phaser的注册和取消注册都通过父节点管理。子节点phaser通过构造或方法register、bulkRegister进行首次注册时,在其父节点上注册。子节点phaser通过调用arriveAndDeregister进行最后一次取消注册时,也在其父节点上取消注册。

5、Monitoring(状态监控)

由于同步方法可能只被已注册的parties调用,所以phaser的当前状态也可能被任何调用者监控。在任何时候,可以通过getRegisteredParties获取parties数,其中getArrivedParties方法返回已经到达当前phase的parties数。当剩余的parties(通过方法getUnarrivedParties获取)到达时,phase进入下一代。这些方法返回的值可能只表示短暂的状态,所以一般来说在同步结构里并没有啥卵用。

3、Phaser源码详解
1、核心参数
1
2
3
4
5
6
7
8
9
10
11
12
private volatile long state;
/**
* The parent of this phaser, or null if none
*/
private final Phaser parent;
/**
* The root of phaser tree. Equals this if not in a tree.
*/
private final Phaser root;
//等待线程的栈顶元素,根据phase取模定义为一个奇数header和一个偶数header
private final AtomicReference<QNode> evenQ;
private final AtomicReference<QNode> oddQ;

state状态说明:Phaser使用一个long型state值来标识内部状态:

  • 低0-15位表示未到达parties数
  • 中16-31位表示等待的parties数
  • 中32-62位表示phase当前代
  • 高63位表示当前phaser的终止状态

注意:子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。这里在后面源码分析的reconcileState方法里会讲解。 Qnode是Phaser定义的内部等待队列,用于在阻塞时记录等待线程及相关信息。实现了ForkJoinPool的一个内部接口ManagedBlocker,上面已经说过,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务(通过内部实现方法isReleasable和block)。

2、函数列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//构造方法
public Phaser() {
this(null, 0);
}
public Phaser(int parties) {
this(null, parties);
}
public Phaser(Phaser parent) {
this(parent, 0);
}
public Phaser(Phaser parent, int parties)
//注册一个新的party
public int register()
//批量注册
public int bulkRegister(int parties)
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive()
//使当前线程到达phaser并撤销注册,返回arrival phase number
public int arriveAndDeregister()
/*
* 使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。
* 如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。
* 如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。
*/
public int arriveAndAwaitAdvance()
//等待给定phase数,返回下一个 arrival phase number
public int awaitAdvance(int phase)
//阻塞等待,直到phase前进到下一代,返回下一代的phase number
public int awaitAdvance(int phase)
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase) throws InterruptedException
public int awaitAdvanceInterruptibly(int phase, long timeout, TimeUnit unit)
throws InterruptedException, TimeoutException
//使当前phaser进入终止状态,已注册的parties不受影响,如果是分层结构,则终止所有phaser
public void forceTermination()
3、方法——register()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//注册一个新的party
public int register() {
return doRegister(1);
}
private int doRegister(int registrations) {
// adjustment to state
long adjust = ((long)registrations << PARTIES_SHIFT) | registrations;
final Phaser parent = this.parent;
int phase;
for (;;) {
long s = (parent == null) ? state : reconcileState();
int counts = (int)s;
int parties = counts >>> PARTIES_SHIFT;//获取已注册parties数
int unarrived = counts & UNARRIVED_MASK;//未到达数
if (registrations > MAX_PARTIES - parties)
throw new IllegalStateException(badRegister(s));
phase = (int)(s >>> PHASE_SHIFT);//获取当前代
if (phase < 0)
break;
if (counts != EMPTY) { // not 1st registration
if (parent == null || reconcileState() == s) {
if (unarrived == 0) // wait out advance
root.internalAwaitAdvance(phase, null);//等待其他任务到达
else if (UNSAFE.compareAndSwapLong(this, stateOffset,
s, s + adjust))//更新注册的parties数
break;
}
}
else if (parent == null) { // 1st root registration
long next = ((long)phase << PHASE_SHIFT) | adjust;
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, next))//更新phase
break;
}
else {
//分层结构,子phaser首次注册用父节点管理
synchronized (this) { // 1st sub registration
if (state == s) { // recheck under lock
phase = parent.doRegister(1);//分层结构,使用父节点注册
if (phase < 0)
break;
// finish registration whenever parent registration
// succeeded, even when racing with termination,
// since these are part of the same "transaction".
//由于在同一个事务里,即使phaser已终止,也会完成注册
while (!UNSAFE.compareAndSwapLong
(this, stateOffset, s,
((long)phase << PHASE_SHIFT) | adjust)) {//更新phase
s = state;
phase = (int)(root.state >>> PHASE_SHIFT);
// assert (int)s == EMPTY;
}
break;
}
}
}
}
return phase;
}

说明:register方法为phaser添加一个新的party,如果onAdvance正在运行,那么这个方法会等待它运行结束再返回结果。如果当前phaser有父节点,并且当前phaser上没有已注册的party,那么就会交给父节点注册。

register和bulkRegister都由doRegister实现,大概流程如下:

  • 如果当前操作不是首次注册,那么直接在当前phaser上更新注册parties数

  • 如果是首次注册,并且当前phaser没有父节点,说明是root节点注册,直接更新phase

  • 如果当前操作是首次注册,并且当前phaser由父节点,则注册操作交由父节点,并更新当前phaser的phase

  • 上面说过,子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。非首次注册时,如果Phaser有父节点,则调用reconcileState()方法解决root节点的phase延迟传递问题, 源码如下:

    • private long reconcileState() {
          final Phaser root = this.root;
          long s = state;
          if (root != this) {
              int phase, p;
              // CAS to root phase with current parties, tripping unarrived
              while ((phase = (int)(root.state >>> PHASE_SHIFT)) !=
                     (int)(s >>> PHASE_SHIFT) &&
                     !UNSAFE.compareAndSwapLong
                     (this, stateOffset, s,
                      s = (((long)phase << PHASE_SHIFT) |
                           ((phase < 0) ? (s & COUNTS_MASK) :
                            (((p = (int)s >>> PARTIES_SHIFT) == 0) ? EMPTY :
                             ((s & PARTIES_MASK) | p))))))
                  s = state;
          }
          return s;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57

      - 当root节点的phase已经advance到下一代,但是子节点phaser还没有,这种情况下它们必须通过更新未到达parties数完成它们自己的advance操作(如果parties为0,重置为EMPTY状态)。

      - 回到register方法的第一步,如果当前未到达数为0,说明上一代phase正在进行到达操作,此时调用internalAwaitAdvance()方法等待其他任务完成到达操作,源码如下:

      - ```java
      //阻塞等待phase到下一代
      private int internalAwaitAdvance(int phase, QNode node) {
      // assert root == this;
      releaseWaiters(phase-1); // ensure old queue clean
      boolean queued = false; // true when node is enqueued
      int lastUnarrived = 0; // to increase spins upon change
      int spins = SPINS_PER_ARRIVAL;
      long s;
      int p;
      while ((p = (int)((s = state) >>> PHASE_SHIFT)) == phase) {
      if (node == null) { // spinning in noninterruptible mode
      int unarrived = (int)s & UNARRIVED_MASK;//未到达数
      if (unarrived != lastUnarrived &&
      (lastUnarrived = unarrived) < NCPU)
      spins += SPINS_PER_ARRIVAL;
      boolean interrupted = Thread.interrupted();
      if (interrupted || --spins < 0) { // need node to record intr
      //使用node记录中断状态
      node = new QNode(this, phase, false, false, 0L);
      node.wasInterrupted = interrupted;
      }
      }
      else if (node.isReleasable()) // done or aborted
      break;
      else if (!queued) { // push onto queue
      AtomicReference<QNode> head = (phase & 1) == 0 ? evenQ : oddQ;
      QNode q = node.next = head.get();
      if ((q == null || q.phase == phase) &&
      (int)(state >>> PHASE_SHIFT) == phase) // avoid stale enq
      queued = head.compareAndSet(q, node);
      }
      else {
      try {
      ForkJoinPool.managedBlock(node);//阻塞给定node
      } catch (InterruptedException ie) {
      node.wasInterrupted = true;
      }
      }
      }

      if (node != null) {
      if (node.thread != null)
      node.thread = null; // avoid need for unpark()
      if (node.wasInterrupted && !node.interruptible)
      Thread.currentThread().interrupt();
      if (p == phase && (p = (int)(state >>> PHASE_SHIFT)) == phase)
      return abortWait(phase); // possibly clean up on abort
      }
      releaseWaiters(phase);
      return p;
      }
  • 简单介绍下第二个参数node,如果不为空,则说明等待线程需要追踪中断状态或超时状态。以doRegister中的调用为例,不考虑线程争用,internalAwaitAdvance大概流程如下:

    • 首先调用releaseWaiters唤醒上一代所有等待线程,确保旧队列中没有遗留的等待线程。
    • 循环SPINS_PER_ARRIVAL指定的次数或者当前线程被中断,创建node记录等待线程及相关信息。
    • 继续循环调用ForkJoinPool.managedBlock运行被阻塞的任务
    • 继续循环,阻塞任务运行成功被释放,跳出循环
    • 最后唤醒当前phase的线程
4、方法——arrive()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive() {
return doArrive(ONE_ARRIVAL);
}

private int doArrive(int adjust) {
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
//获取未到达数
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, s-=adjust)) {//更新state
if (unarrived == 1) {//当前为最后一个未到达的任务
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (root == this) {
if (onAdvance(phase, nextUnarrived))//检查是否需要终止phaser
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;
n |= (long)nextPhase << PHASE_SHIFT;
UNSAFE.compareAndSwapLong(this, stateOffset, s, n);
releaseWaiters(phase);//释放等待phase的线程
}
//分层结构,使用父节点管理arrive
else if (nextUnarrived == 0) { //propagate deregistration
phase = parent.doArrive(ONE_DEREGISTER);
UNSAFE.compareAndSwapLong(this, stateOffset,
s, s | EMPTY);
}
else
phase = parent.doArrive(ONE_ARRIVAL);
}
return phase;
}
}
}

说明:arrive方法手动调整到达数,使当前线程到达phaser。arrive和arriveAndDeregister都调用了doArrive实现,大概流程如下:

  • 首先更新state(state - adjust);
  • 如果当前不是最后一个未到达的任务,直接返回phase
  • 如果当前是最后一个未到达的任务:
    • 如果当前是root节点,判断是否需要终止phaser,CAS更新phase,最后释放等待的线程;
    • 如果是分层结构,并且已经没有下一代未到达的parties,则交由父节点处理doArrive逻辑,然后更新state为EMPTY。
5、方法——arriveAndAwaitAdvance()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public int arriveAndAwaitAdvance() {
// Specialization of doArrive+awaitAdvance eliminating some reads/paths
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);//获取未到达数
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s,
s -= ONE_ARRIVAL)) {//更新state
if (unarrived > 1)
return root.internalAwaitAdvance(phase, null);//阻塞等待其他任务
if (root != this)
return parent.arriveAndAwaitAdvance();//子Phaser交给父节点处理
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (onAdvance(phase, nextUnarrived))//全部到达,检查是否可销毁
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;//计算下一代phase
n |= (long)nextPhase << PHASE_SHIFT;
if (!UNSAFE.compareAndSwapLong(this, stateOffset, s, n))//更新state
return (int)(state >>> PHASE_SHIFT); // terminated
releaseWaiters(phase);//释放等待phase的线程
return nextPhase;
}
}
}

说明:使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。效果类似于CyclicBarrier.await。大概流程如下:

  • 更新state(state - 1);
  • 如果未到达数大于1,调用internalAwaitAdvance阻塞等待其他任务到达,返回当前phase
  • 如果为分层结构,则交由父节点处理arriveAndAwaitAdvance逻辑
  • 如果未到达数<=1,判断phaser终止状态,CAS更新phase到下一代,最后释放等待当前phase的线程,并返回下一代phase。
6、方法——awaitAdvance(int phase)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public int awaitAdvance(int phase) {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase)
return root.internalAwaitAdvance(phase, null);
return p;
}
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase)
throws InterruptedException {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase) {
QNode node = new QNode(this, phase, true, false, 0L);
p = root.internalAwaitAdvance(phase, node);
if (node.wasInterrupted)
throw new InterruptedException();
}
return p;
}

说明:awaitAdvance用于阻塞等待线程到达,直到phase前进到下一代,返回下一代的phase number。方法很简单,不多赘述。awaitAdvanceInterruptibly方法是响应中断版的awaitAdvance,不同之处在于,调用阻塞时会记录线程的中断状态。

5、Exchanger(交换器)

Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换

1、BAT大厂的面试问题
  • Exchanger主要解决什么问题?
  • 对比SynchronousQueue,为什么说Exchanger可被视为 SynchronousQueue 的双向形式?
  • Exchanger在不同的JDK版本中实现有什么差别?
  • Exchanger实现机制?
  • Exchanger已经有了slot单节点,为什么会加入arena node数组?什么时候会用到数组?
  • arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
  • 什么是伪共享,Exchanger中如何体现的?
  • Exchanger实现举例
2、Exchanger简介

Exchanger用于进行两个线程之间的数据交换。它提供一个==同步点==,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。

3、Exchanger实现机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (;;) {
if (slot is empty) { // offer
// slot为空时,将item 设置到Node 中
place item in a Node;
if (can CAS slot from empty to node) {
// 当将node通过CAS交换到slot中时,挂起线程等待被唤醒
wait for release;
// 被唤醒后返回node中匹配到的item
return matching item in node;
}
} else if (can CAS slot from node to empty) { // release
// 将slot设置为空
// 获取node中的item,将需要交换的数据设置到匹配的item
get the item in node;
set matching item in node;
// 唤醒等待的线程
release waiting thread;
}
// else retry on CAS failure
}

比如有2条线程A和B,A线程交换数据时,发现slot为空,则将需要交换的数据放在slot中等待其它线程进来交换数据,等线程B进来,读取A设置的数据,然后设置线程B需要交换的数据,然后唤醒A线程,原理就是这么简单。但是当多个线程之间进行交换数据时就会出现问题,所以Exchanger加入了arena数组

4、Exchanger源码解析
1、内部类——Participant
1
2
3
static final class Participant extends ThreadLocal<Node> {
public Node initialValue() { return new Node(); }
}

Participant的作用是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,说明每个线程具有不同的状态。

2、内部类——Node
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@sun.misc.Contended static final class Node {
// arena的下标,多个槽位的时候利用
int index;
// 上一次记录的Exchanger.bound
int bound;
// 在当前bound下CAS失败的次数;
int collides;
// 用于自旋;
int hash;
// 这个线程的当前项,也就是需要交换的数据;
Object item;
//做releasing操作的线程传递的项;
volatile Object match;
//挂起时设置线程值,其他情况下为null;
volatile Thread parked;
}

在Node定义中有两个变量值得思考:bound以及collides。前面提到了数组area是为了避免竞争而产生的,如果系统不存在竞争问题,那么完全没有必要开辟一个高效的arena来徒增系统的复杂性

  1. 首先通过单个slot的exchanger来交换数据,当探测到竞争时将安排不同的位置的slot来保存线程Node,并且可以确保没有slot会在同一个缓存行上。
  2. 如何来判断会有竞争呢?
    • CAS替换slot失败,如果失败,则通过记录冲突次数来扩展arena的尺寸,我们在记录冲突的过程中会跟踪“bound”的值,以及会重新计算在bound的值被改变时的冲突次数。
3、核心属性
1
2
3
private final Participant participant;
private volatile Node[] arena;
private volatile Node slot;
  • **为什么会有 arena数组槽**?

    • slot为单个槽,arena为数组槽,他们都是Node类型。
    • 在这里可能会感觉到疑惑,slot作为Exchanger交换数据的场景,应该只需要一个就可以了啊?为何还多了一个Participant和数组类型的arena呢?
    • 一个slot交换场所原则上来说应该是可以的,但实际情况却不是如此,多个参与者使用同一个交换场所时,会存在严重==伸缩性问题==。既然单个交换场所存在问题,那么我们就安排多个,也就是数组arena。
    • 通过数组arena来安排不同的线程使用不同的slot来降低竞争问题,并且可以保证最终一定会成对交换数据。但是**Exchanger不是一来就会生成arena数组来降低竞争,==只有当产生竞争是才会生成arena数组==**。
  • 那么怎么将Node与当前线程绑定呢?

    • Participant,Participant 的作用就是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,同时在Node节点中记录在arena中的下标index。
4、构造函数
1
2
3
4
5
6
/**
* Creates a new Exchanger.
*/
public Exchanger() {
participant = new Participant();
}

初始化participant对象。

5、核心方法——exchange(V x)

等待另一个线程到达此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。

1
2
3
4
5
6
7
8
9
10
11
12
public V exchange(V x) throws InterruptedException {
Object v;
// 当参数为null时需要将item设置为空的对象
Object item = (x == null) ? NULL_ITEM : x; // translate null args
// 注意到这里的这个表达式是整个方法的核心
if ((arena != null ||
(v = slotExchange(item, false, 0 L)) == null) &&
((Thread.interrupted() || // disambiguates null return
(v = arenaExchange(item, false, 0 L)) == null)))
throw new InterruptedException();
return (v == NULL_ITEM) ? null : (V) v;
}

这个方法比较好理解:

  • arena为数组槽,如果为null,则执行slotExchange()方法,
  • 否则判断线程是否中断,如果中断值抛出InterruptedException异常,
  • 没有中断则执行arenaExchange()方法。

整套逻辑就是:如果slotExchange(Object item, boolean timed, long ns)方法执行失败了就执行arenaExchange(Object item, boolean timed, long ns)方法,最后返回结果V。

NULL_ITEM 为一个空节点,其实就是一个Object对象而已,slotExchange()为单个slot交换

6、slotExchange(Object item, boolean timed, long ns)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
private final Object slotExchange(Object item, boolean timed, long ns) {
// 获取当前线程node对象
Node p = participant.get();
// 当前线程
Thread t = Thread.currentThread();
// 若果线程被中断,就直接返回null
if (t.isInterrupted()) // preserve interrupt status so caller can recheck
return null;
// 自旋
for (Node q;;) {
// 将slot值赋给q
if ((q = slot) != null) {
// slot 不为null,即表示已有线程已经把需要交换的数据设置在slot中了
// 通过CAS将slot设置成null
if (U.compareAndSwapObject(this, SLOT, q, null)) {
// CAS操作成功后,将slot中的item赋值给对象v,以便返回。
// 这里也是就读取之前线程要交换的数据
Object v = q.item;
// 将当前线程需要交给的数据设置在q中的match
q.match = item;
// 获取被挂起的线程
Thread w = q.parked;
if (w != null)
// 如果线程不为null,唤醒它
U.unpark(w);
// 返回其他线程给的V
return v;
}
// create arena on contention, but continue until slot null
// CAS 操作失败,表示有其它线程竞争,在此线程之前将数据已取走
// NCPU:CPU的核数
// bound == 0 表示arena数组未初始化过,CAS操作bound将其增加SEQ
if (NCPU > 1 && bound == 0 &&
U.compareAndSwapInt(this, BOUND, 0, SEQ))
// 初始化arena数组
arena = new Node[(FULL + 2) << ASHIFT];
}
// 上面分析过,只有当arena不为空才会执行slotExchange方法的
// 所以表示刚好已有其它线程加入进来将arena初始化
else if (arena != null)
// 这里就需要去执行arenaExchange
return null; // caller must reroute to arenaExchange
else {
// 这里表示当前线程是以第一个线程进来交换数据
// 或者表示之前的数据交换已进行完毕,这里可以看作是第一个线程
// 将需要交换的数据先存放在当前线程变量p中
p.item = item;
// 将需要交换的数据通过CAS设置到交换区slot
if (U.compareAndSwapObject(this, SLOT, null, p))
// 交换成功后跳出自旋
break;
// CAS操作失败,表示有其它线程刚好先于当前线程将数据设置到交换区slot
// 将当前线程变量中的item设置为null,然后自旋获取其它线程存放在交换区slot的数据
p.item = null;
}
}

// await release
// 执行到这里表示当前线程已将需要的交换的数据放置于交换区slot中了,
// 等待其它线程交换数据然后唤醒当前线程
int h = p.hash;
long end = timed ? System.nanoTime() + ns : 0 L;
// 自旋次数
int spins = (NCPU > 1) ? SPINS : 1;
Object v;
// 自旋等待直到p.match不为null,也就是说等待其它线程将需要交换的数据放置于交换区slot
while ((v = p.match) == null) {
// 下面的逻辑主要是自旋等待,直到spins递减到0为止
if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10;
if (h == 0)
h = SPINS | (int) t.getId();
else if (h < 0 && (--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield();
} else if (slot != p)
spins = SPINS;
// 此处表示未设置超时或者时间未超时
else if (!t.isInterrupted() && arena == null &&
(!timed || (ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this);
// 给p挂机线程的值赋值
p.parked = t;
if (slot == p)
// 如果slot还没有被置为null,也就表示暂未有线程过来交换数据,需要将当前线程挂起
U.park(false, ns);
// 线程被唤醒,将被挂起的线程设置为null
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
// 不是以上条件时(可能是arena已不为null或者超时)
} else if (U.compareAndSwapObject(this, SLOT, p, null)) {
// arena不为null则v为null,其它为超时则v为超市对象TIMED_OUT,并且跳出循环
v = timed && ns <= 0 L && !t.isInterrupted() ? TIMED_OUT : null;
break;
}
}
// 取走match值,并将p中的match置为null
U.putOrderedObject(p, MATCH, null);
// 设置item为null
p.item = null;
p.hash = h;
// 返回交换值
return v;
}

程序首先通过participant获取当前线程节点Node。检测是否中断,如果中断return null,等待后续抛出InterruptedException异常。

  • 如果slot不为null,则进行slot消除,成功直接返回数据V,否则失败,则创建arena消除数组。
  • 如果slot为null,但arena不为null,则返回null,进入arenaExchange逻辑。
  • 如果slot为null,且arena也为null,则尝试占领该slot,失败重试,成功则跳出循环进入spin+block(自旋+阻塞)模式。

在自旋+阻塞模式中,首先取得结束时间和自旋次数

  • 如果match(做releasing操作的线程传递的项)为null,其首先尝试spins+随机次自旋(改自旋使用当前节点中的hash,并改变之)和退让。
  • 当自旋数为0后,假如slot发生了改变(slot != p)则重置自旋数并重试。
  • 否则
    • 假如:当前未中断&arena为null&(当前不是限时版本或者限时版本+当前时间未结束):阻塞或者限时阻塞。
    • 假如:当前中断或者arena不为null或者当前为限时版本+时间已经结束:
      • 不限时版本:置v为null;
      • 限时版本:如果时间结束以及未中断则TIMED_OUT;
  • 否则给出null(原因是探测到arena非空或者当前线程中断)。
  • match不为空时跳出循环。
7、arenaExchange(Object item, boolean timed, long ns)

此方法被执行时表示多个线程进入交换区交换数据,arena数组已被初始化,此方法中的一些处理方式和slotExchange比较类似,它是通过遍历arena数组找到需要交换的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// timed 为true表示设置了超时时间,ns为>0的值,反之没有设置超时时间
private final Object arenaExchange(Object item, boolean timed, long ns) {
Node[] a = arena;
// 获取当前线程中的存放的node
Node p = participant.get();
//index初始值0
for (int i = p.index;;) { // access slot at i
// 遍历,如果在数组中找到数据则直接交换并唤醒线程,如未找到则将需要交换给其它线程的数据放置于数组中
int b, m, c;
long j; // j is raw array offset
// 其实这里就是向右遍历数组,只是用到了元素在内存偏移的偏移量
// q实际为arena数组偏移(i + 1) * 128个地址位上的node
Node q = (Node) U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 如果q不为null,并且CAS操作成功,将下标j的元素置为null
if (q != null && U.compareAndSwapObject(a, j, q, null)) {
// 表示当前线程已发现有交换的数据,然后获取数据,唤醒等待的线程
Object v = q.item; // release
q.match = item;
Thread w = q.parked;
if (w != null)
U.unpark(w);
return v;
// q 为null 并且 i 未超过数组边界
} else if (i <= (m = (b = bound) & MMASK) && q == null) {
// 将需要给其它线程的item赋予给p中的item
p.item = item; // offer
if (U.compareAndSwapObject(a, j, null, p)) {
// 交换成功
long end = (timed && m == 0) ? System.nanoTime() + ns : 0 L;
Thread t = Thread.currentThread(); // wait
// 自旋直到有其它线程进入,遍历到该元素并与其交换,同时当前线程被唤醒
for (int h = p.hash, spins = SPINS;;) {
Object v = p.match;
if (v != null) {
// 其它线程设置的需要交换的数据match不为null
// 将match设置null,item设置为null
U.putOrderedObject(p, MATCH, null);
p.item = null; // clear for next use
p.hash = h;
return v;
} else if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10; // xorshift
if (h == 0) // initialize hash
h = SPINS | (int) t.getId();
else if (h < 0 && // approx 50% true
(--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield(); // two yields per wait
} else if (U.getObjectVolatile(a, j) != p)
// 和slotExchange方法中的类似,arena数组中的数据已被CAS设置
// match值还未设置,让其再自旋等待match被设置
spins = SPINS; // releaser hasn't set match yet
else if (!t.isInterrupted() && m == 0 &&
(!timed ||
(ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this); // emulate LockSupport
// 线程t赋值
p.parked = t; // minimize window
if (U.getObjectVolatile(a, j) == p)
// 数组中对象还相等,表示线程还未被唤醒,唤醒线程
U.park(false, ns);
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
} else if (U.getObjectVolatile(a, j) == p &&
U.compareAndSwapObject(a, j, p, null)) {
// 这里给bound增加加一个SEQ
if (m != 0) // try to shrink
U.compareAndSwapInt(this, BOUND, b, b + SEQ - 1);
p.item = null;
p.hash = h;
i = p.index >>>= 1; // descend
if (Thread.interrupted())
return null;
if (timed && m == 0 && ns <= 0 L)
return TIMED_OUT;
break; // expired; restart
}
}
} else
// 交换失败,表示有其它线程更改了arena数组中下标i的元素
p.item = null; // clear offer
} else {
// 此时表示下标不在bound & MMASK或q不为null但CAS操作失败
// 需要更新bound变化后的值
if (p.bound != b) { // stale; reset
p.bound = b;
p.collides = 0;
// 反向遍历
i = (i != m || m == 0) ? m : m - 1;
} else if ((c = p.collides) < m || m == FULL ||
!U.compareAndSwapInt(this, BOUND, b, b + SEQ + 1)) {
// 记录CAS失败的次数
p.collides = c + 1;
// 循环遍历
i = (i == 0) ? m : i - 1; // cyclically traverse
} else
// 此时表示bound值增加了SEQ+1
i = m + 1; // grow
// 设置下标
p.index = i;
}
}
}

首先通过participant取得当前节点Node,然后根据当前节点Node的index去取arena中相对应的节点node。

5、前面提到过arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arena = new Node[(FULL + 2) << ASHIFT];
// 这个arena到底有多大呢? 我们先看FULL 和ASHIFT的定义:
static final int FULL = (NCPU >= (MMASK << 1)) ? MMASK : NCPU >>> 1;
private static final int ASHIFT = 7;

private static final int NCPU = Runtime.getRuntime().availableProcessors();
private static final int MMASK = 0xff; // 255
// 假如我的机器NCPU = 8 ,则得到的是768大小的arena数组。然后通过以下代码取得在arena中的节点:

Node q = (Node)U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 它仍然是通过右移ASHIFT位来取得Node的,ABASE定义如下:

Class<?> ak = Node[].class;
ABASE = U.arrayBaseOffset(ak) + (1 << ASHIFT);
// U.arrayBaseOffset获取对象头长度,数组元素的大小可以通过unsafe.arrayIndexScale(T[].class) 方法获取到。这也就是说要访问类型为T的第N个元素的话,你的偏移量offset应该是arrayOffset+N*arrayScale。也就是说BASE = arrayOffset+ 128 。
6、用@sun.misc.Contended来规避伪共享?

伪共享说明假设一个类的两个相互独立的属性a和b在内存地址上是连续的(比如FIFO队列的头尾指针),那么它们通常会被加载到相同的cpu cache line里面。并发情况下,如果一个线程修改了a,会导致整个cache line失效(包括b),这时另一个线程来读b,就需要从内存里再次加载了,这种多线程频繁修改ab的情况下,虽然a和b看似独立,但它们会互相干扰,非常影响性能。(在原子累加器篇也有伪共享问题的阐述)

我们再看Node节点的定义,在Java 8 中我们是可以利用sun.misc.Contended来规避伪共享的。所以说通过 << ASHIFT方式加上sun.misc.Contended,所以使得任意两个可用Node不会再同一个缓存行中。

1
2
3
@sun.misc.Contended static final class Node{
....
}

我们再次回到arenaExchange()。取得arena中的node节点后,如果定位的节点q 不为空,且CAS操作成功,则交换数据,返回交换的数据,唤醒等待的线程。

  • 如果q等于null且下标在bound & MMASK范围之内,则尝试占领该位置,如果成功,则采用自旋 + 阻塞的方式进行等待交换数据。
  • 如果下标不在bound & MMASK范围之内获取由于q不为null但是竞争失败的时候:消除p。加入bound 不等于当前节点的bond(b != p.bound),则更新p.bound = b,collides = 0 ,i = m或者m - 1。如果冲突的次数不到m 获取m 已经为最大值或者修改当前bound的值失败,则通过增加一次collides以及循环递减下标i的值;否则更新当前bound的值成功:我们令i为m+1即为此时最大的下标。最后更新当前index的值。
7、更深入理解
1、SynchronousQueue对比?

Exchanger是一种线程间安全交换数据的机制。可以和之前分析过的SynchronousQueue对比一下:

  • 线程A通过SynchronousQueue将数据a交给线程B;
  • 线程A通过Exchanger和线程B交换数据,线程A把数据a交给线程B,同时线程B把数据b交给线程A。

可见,SynchronousQueue是交给一个数据,Exchanger是交换两个数据。

2、不同JDK实现有何差别?
  • 在JDK5中Exchanger被设计成一个容量为1的容器,存放一个等待线程,直到有另外线程到来就会发生数据交换,然后清空容器,等到下一个到来的线程。
  • 从JDK6开始,Exchanger用了类似ConcurrentMap的分段思想,提供了多个slot,增加了并发执行时的吞吐量
8、Exchanger示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Test {
static class Producer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Producer(String name, Exchanger<Integer> exchanger) {
super("Producer-" + name);
this.exchanger = exchanger;
}

@Override
public void run() {
for (int i=1; i<5; i++) {
try {
TimeUnit.SECONDS.sleep(1);
data = i;
System.out.println(getName()+" 交换前:" + data);
data = exchanger.exchange(data);
System.out.println(getName()+" 交换后:" + data);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

static class Consumer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Consumer(String name, Exchanger<Integer> exchanger) {
super("Consumer-" + name);
this.exchanger = exchanger;
}

@Override
public void run() {
while (true) {
data = 0;
System.out.println(getName()+" 交换前:" + data);
try {
TimeUnit.SECONDS.sleep(1);
data = exchanger.exchange(data);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName()+" 交换后:" + data);
}
}
}

public static void main(String[] args) throws InterruptedException {
Exchanger<Integer> exchanger = new Exchanger<Integer>();
new Producer("", exchanger).start();
new Consumer("", exchanger).start();
TimeUnit.SECONDS.sleep(7);
System.exit(-1);
}
}

可以看到,其结果可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Consumer- 交换前:0
Producer- 交换前:1
Consumer- 交换后:1
Consumer- 交换前:0
Producer- 交换后:0
Producer- 交换前:2
Producer- 交换后:0
Consumer- 交换后:2
Consumer- 交换前:0
Producer- 交换前:3
Producer- 交换后:0
Consumer- 交换后:3
Consumer- 交换前:0
Producer- 交换前:4
Producer- 交换后:0
Consumer- 交换后:4
Consumer- 交换前:0

16、ThreadPool线程池

1、线程池简介

线程池(英语:thread pool):一种线程使用模式。线程过多会带来调度开销, 进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。

2、线程池的优势

线程池做的工作只要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量, 超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。

3、线程池的主要特点

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的销耗。
  • 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会销耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 ExecutorExecutorsExecutorServiceThreadPoolExecutor 这几个类:

image-20210728133227349

4、线程池参数说明

  • corePoolSize:线程池的核心线程数
  • maximumPoolSize:能容纳的最大线程数
  • keepAliveTime:空闲线程存活时间
  • unit:存活的时间单位
  • workQueue:存放提交但未执行任务的队列
  • threadFactory:创建线程的工厂类
  • handler:等待队列满后的拒绝策略

image-20210728134755032

5、拒绝策略(重点)

线程池中,有三个重要的参数,决定影响了拒绝策略:corePoolSize - 核心线程数,也即最小的线程数。workQueue - 阻塞队列 。 maximumPoolSize -最大线程数。

当提交任务数大于 corePoolSize 的时候,会优先将任务放到 workQueue 阻塞队列中。当阻塞队列饱和后,会扩充线程池中线程数,直到达到maximumPoolSize 最大线程数配置。此时,再多余的任务,则会触发线程池的拒绝策略了。

总结起来,也就是一句话,当提交的任务数大于(workQueue.size() + maximumPoolSize ),就会触发线程池的拒绝策略。

四种拒绝策略:

  • CallerRunsPolicy:当触发拒绝策略,只要线程池没有关闭的话,则使用调用线程直接运行任务。一般并发比较小,性能要求不高,不允许失败。但是,由于调用者自己运行任务,如果任务提交速度过快,可能导致程序阻塞,性能效率上必然的损失较大;
  • AbortPolicy:丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息。线程池默认的拒绝策略。必须处理好抛出的异常,否则会打断当前的执行流程,影响后续的任务执行。
  • DiscardPolicy:直接丢弃,其他啥都没有;
  • DiscardOldestPolicy:当触发拒绝策略,只要线程池没有关闭的话,丢弃阻塞队列 workQueue 中最老的一个任务,并将新任务加入

除了线程池给的四种拒绝策略的实现,其他著名框架也提供了拒绝策略实现:

  • Dubbo 的实现:在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息方便定位问题
  • Netty 的实现:创建一个新线程来执行任务
  • ActiveMQ 的实现:带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略
  • PinPoint 的实现:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略

6、线程池的种类与创建

  • newCachedThreadPool——线程池根据需求创建线程,可扩容,遇强则强

    • 作用:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

    • 特点:

      • 线程池中数量没有固定,可达到最大值(Interger. MAX_VALUE)
      • 线程池中的线程可进行缓存重复利用和回收(回收默认时间为 1 分钟)
      • 当线程池中,没有可用线程,会重新创建一个线程
    • 创建方式:

      • /**
          *    可缓存线程池
          * @return
          */
        public static ExecutorService newCachedThreadPool(){
            /**
            *    corePoolSize 线程池的核心线程数
            *    maximumPoolSize 能容纳的最大线程数
            *    keepAliveTime 空闲线程存活时间
            *    unit 存活的时间单位
            *    workQueue 存放提交但未执行任务的队列
            *    threadFactory 创建线程的工厂类:可以省略
            *    handler 等待队列满后的拒绝策略:可以省略
            */ 
            return new ThreadPoolExecutor(0, 
                   Integer.MAX_VALUE, 
                   60L,                        
                   TimeUnit.SECONDS,                        
                   new SynchronousQueue<>(),                        
                   Executors.defaultThreadFactory(),                       
                   new ThreadPoolExecutor.AbortPolicy());
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38

        - 场景:适用于创建一个可无限扩大的线程池,服务器负载压力较轻,执行时间较短,任务多的场景

        - newFixedThreadPool——一池N线程

        - 作用:创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

        - 特点:

        - 线程池中的线程处于一定的量,可以很好的控制线程的并发量
        - 线程可以重复被使用,在显示关闭之前,都将一直存在
        - 超出一定量的线程被提交时候需在队列中等待

        - 创建方式:

        - ```java
        /**
        * 固定长度线程池
        * @return
        */
        public static ExecutorService newCachedThreadPool(){
        /**
        * corePoolSize 线程池的核心线程数
        * maximumPoolSize 能容纳的最大线程数
        * keepAliveTime 空闲线程存活时间
        * unit 存活的时间单位
        * workQueue 存放提交但未执行任务的队列
        * threadFactory 创建线程的工厂类:可以省略
        * handler 等待队列满后的拒绝策略:可以省略
        */
        return new ThreadPoolExecutor(10,
        Integer.MAX_VALUE,
        0L,
        TimeUnit.SECONDS,
        new SynchronousQueue<>(),
        Executors.defaultThreadFactory(),
        new ThreadPoolExecutor.AbortPolicy());
        }
    • 场景:适用于可以预测线程数量的业务中,或者服务器负载较重,对线程数有严格限制的场景

  • newSingleThreadExecutor——一个任务一个任务执行,一池一线程

    • 作用:创建一个使用单个 worker 线程的 Executor,以无界队列方式来运行该线程。(注意,如果因为在关闭前的执行期间出现失败而终止了此单个线程, 那么如果需要,一个新线程将代替它执行后续的任务)。可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。与其他等效的newFixedThreadPool 不同,可保证无需重新配置此方法所返回的执行程序即可使用其他的线程。

    • 特点:线程池中最多执行 1 个线程,之后提交的线程活动将会排在队列中以此执行

    • 创建方式:

      • /**
          *    单一线程池
          * @return
          */
        public static ExecutorService newCachedThreadPool(){
            /**
            *    corePoolSize 线程池的核心线程数
            *    maximumPoolSize 能容纳的最大线程数
            *    keepAliveTime 空闲线程存活时间
            *    unit 存活的时间单位
            *    workQueue 存放提交但未执行任务的队列
            *    threadFactory 创建线程的工厂类:可以省略
            *    handler 等待队列满后的拒绝策略:可以省略
            */ 
            return new ThreadPoolExecutor(1, 
                   1, 
                   0L,                        
                   TimeUnit.SECONDS,                        
                   new SynchronousQueue<>(),                        
                   Executors.defaultThreadFactory(),                       
                   new ThreadPoolExecutor.AbortPolicy());
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19

        - 场景:适用于需要保证顺序执行各个任务,并且在任意时间点,不会同时有多个线程的场景

        - newScheduleThreadPool(了解)——定时以及周期性执行任务线程池

        - 作用:线程池支持定时以及周期性执行任务,创建一个 corePoolSize 为传入参数,最大线程数为整形的最大数的线程池

        - 特点:

        - 线程池中具有指定数量的线程,即便是空线程也将保留
        - 可定时或者延迟执行线程活动

        - 创建方式:

        - ```java
        public static ScheduledExecutorService newScheduledThreadPool
        (int corePoolSize, ThreadFactory threadFactory) {
        return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
        }
    • 场景:适用于需要多个后台线程执行周期任务的场景

  • newWorkStealingPool——多个任务队列的线程池

    • jdk1.8 提供的线程池,底层使用的是 ForkJoinPool 实现,创建一个拥有多个任务队列的线程池,可以减少连接数,创建当前可用 cpu 核数的线程来并行执行任务

    • 创建方式:

      • public static ExecutorService newWorkStealingPool(int parallelism) {
            /**
            *    parallelism:并行级别,通常默认为 JVM 可用的处理器个数
            *    factory:用于创建 ForkJoinPool 中使用的线程。
            *    handler:用于处理工作线程未处理的异常,默认为 null
            *    asyncMode:用于控制 WorkQueue 的工作模式:队列---反队列
            */ 
            return new ForkJoinPool(parallelism, 
                                    ForkJoinPool.defaultForkJoinWorkerThreadFactory, 
                                    null, 
                                    true);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        81
        82
        83
        84
        85
        86
        87
        88
        89
        90
        91
        92
        93
        94
        95
        96
        97
        98
        99
        100
        101
        102
        103
        104
        105
        106
        107
        108
        109
        110
        111
        112
        113
        114
        115
        116
        117
        118
        119
        120
        121
        122
        123
        124
        125
        126
        127
        128
        129
        130
        131
        132
        133
        134
        135
        136
        137
        138
        139
        140
        141
        142
        143
        144
        145
        146
        147
        148
        149
        150
        151
        152
        153
        154
        155
        156
        157
        158
        159
        160
        161
        162
        163
        164
        165
        166
        167
        168
        169
        170
        171
        172
        173
        174
        175
        176
        177
        178
        179
        180
        181
        182
        183
        184
        185
        186
        187
        188

        - 场景:适用于大耗时,可并行执行的场景



        #### 7、线程池底层的工作原理

        ![10-线程池底层工作流程](JUC/10-线程池底层工作流程.png)

        1. 在创建了线程池后,线程池中的线程数为零,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
        2. 当调用 execute()方法添加一个请求任务时,线程池会做出如下判断:
        1. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
        2. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入workQueue 队列;
        3. 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务(救急);
        4. 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
        3. 当一个线程完成任务时,它会从队列中取下一个任务来执行
        4. 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:
        1. 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
        2. 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

        ![image-20210728141353731](JUC/image-20210728141353731.png)



        #### 8、线程池的注意事项

        1. **线程的创建不是在创建线程池的时候创建,而是在执行execute()方法的时候,线程才真正地开始创建**;
        2. 项目中创建多线程时,使用常见的三种线程池创建方式,`单一`、`可变`、`定长`。但是它们都有一定问题,原因是 `FixedThreadPool` 和 `SingleThreadExecutor` 底层都是用LinkedBlockingQueue 实现的,这个队列最大长度为 Integer.MAX_VALUE,可能会堆积大量的请求,**容易导致 OOM**。而`CachedThreadPool`和`ScheduledThreadPool`允许创建的线程的数量为Integer.MAX_VALUE,可能会创建大量的线程,**容易导致 OOM**
        3. **所以实际生产一般自己通过 ThreadPoolExecutor 的 7 个参数,自定义线程池**。
        4. 创建线程池推荐适用 ThreadPoolExecutor 及其 7 个参数手动创建:
        - corePoolSize 线程池的核心线程数
        - maximumPoolSize 能容纳的最大线程数
        - keepAliveTime 空闲线程存活时间
        - unit 存活的时间单位
        - workQueue 存放提交但未执行任务的队列
        - threadFactory 创建线程的工厂类
        - handler 等待队列满后的拒绝策略
        5. 为什么不允许适用不允许 Executors.的方式手动创建线程池,如下图:
        - ![image-20210728141417446](JUC/image-20210728141417446.png)



        #### 9、自定义线程池

        ![image-20210810234602627](JUC/image-20210810234602627.png)

        注意:以下的==任务队列==和==拒绝策略的接口==其实不用我们编写,可以使用JUC为我们提供的BlockingQueue,而拒绝策略的话,直接使用lambda表达式实现JUC提供好的拒绝策略接口中的reject方法即可。

        ##### 1、步骤1:自定义任务队列

        ```java
        class BlockingQueue<T> {
        // 1. 任务队列
        private Deque<T> queue = new ArrayDeque<>();

        // 2. 锁
        private ReentrantLock lock = new ReentrantLock();

        // 3. 生产者条件变量
        private Condition fullWaitSet = lock.newCondition();

        // 4. 消费者条件变量
        private Condition emptyWaitSet = lock.newCondition();

        // 5. 容量
        private int capcity;

        public BlockingQueue(int capcity) {
        this.capcity = capcity;
        }

        // 带超时阻塞获取
        public T poll(long timeout, TimeUnit unit) {
        lock.lock();
        try {
        // 将 timeout 统一转换为 纳秒 (时间统一管理)
        long nanos = unit.toNanos(timeout);
        while (queue.isEmpty()) {
        try {
        // 返回值是剩余时间
        if (nanos <= 0) {
        return null;
        }
        // 返回值是 等待时间-执行时间
        nanos = emptyWaitSet.awaitNanos(nanos);
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        T t = queue.removeFirst();
        fullWaitSet.signal();
        return t;
        } finally {
        lock.unlock();
        }
        }

        // 阻塞获取
        public T take() {
        lock.lock();
        try {
        while (queue.isEmpty()) {
        try {
        emptyWaitSet.await();
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        T t = queue.removeFirst();
        fullWaitSet.signal();
        return t;
        } finally {
        lock.unlock();
        }
        }

        // 阻塞添加
        public void put(T task) {
        lock.lock();
        try {
        while (queue.size() == capcity) {
        try {
        log.debug("等待加入任务队列 {} ...", task);
        fullWaitSet.await();
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        } finally {
        lock.unlock();
        }
        }

        // 带超时时间阻塞添加
        public boolean offer(T task, long timeout, TimeUnit timeUnit) {
        lock.lock();
        try {
        long nanos = timeUnit.toNanos(timeout);
        while (queue.size() == capcity) {
        try {
        if(nanos <= 0) {
        return false;
        }
        log.debug("等待加入任务队列 {} ...", task);
        nanos = fullWaitSet.awaitNanos(nanos);
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        return true;
        } finally {
        lock.unlock();
        }
        }

        // 返回等待队列的长度
        public int size() {
        lock.lock();
        try {
        return queue.size();
        } finally {
        lock.unlock();
        }
        }

        // 尝试放入阻塞队列,若不能放进阻塞队列,执行拒绝策略
        public void tryPut(RejectPolicy<T> rejectPolicy, T task) {
        lock.lock();
        try {
        // 判断队列是否满
        if(queue.size() == capcity) {
        rejectPolicy.reject(this, task);
        } else { // 有空闲
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        }
        } finally {
        lock.unlock();
        }
        }
        }
2、步骤2:自定义拒绝策略接口
1
2
3
4
5
// 拒绝策略 由于只有一个方法,可以使用函数式接口(lambda表达式)
@FunctionalInterface
interface RejectPolicy<T> {
void reject(BlockingQueue<T> queue, T task);
}
3、步骤3:自定义线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class ThreadPool {
// 任务队列
private BlockingQueue<Runnable> taskQueue;

// 线程集合
// 这里将线程进行进一步的封装,封装成Worker对象,有更多的操作空间
private HashSet<Worker> workers = new HashSet<>();

// 核心线程数
private int coreSize;

// 获取任务时的超时时间
private long timeout;

// 超时时间的单位
private TimeUnit timeUnit;

// 拒绝策略
private RejectPolicy<Runnable> rejectPolicy;

// 执行任务
public void execute(Runnable task) {
// 当任务数没有超过 coreSize 时,直接交给 worker 对象执行
// 如果任务数超过 coreSize 时,执行拒绝策略
synchronized (workers) {
if(workers.size() < coreSize) {
Worker worker = new Worker(task);
log.debug("新增 worker{}, {}", worker, task);
workers.add(worker);
worker.start();
} else {
// taskQueue.put(task);
// 将拒绝策略封装成一个接口交由调用者自己选择执行什么拒绝策略
// 1) 死等
// 2) 带超时等待
// 3) 让调用者放弃任务执行
// 4) 让调用者抛出异常
// 5) 让调用者自己执行任务
taskQueue.tryPut(rejectPolicy, task);
}
}
}

// 构造方法
public ThreadPool(int coreSize, long timeout, TimeUnit timeUnit, int queueCapcity, RejectPolicy<Runnable> rejectPolicy) {
this.coreSize = coreSize;
this.timeout = timeout;
this.timeUnit = timeUnit;
this.taskQueue = new BlockingQueue<>(queueCapcity);
this.rejectPolicy = rejectPolicy;
}

// Worker的实现
class Worker extends Thread{
// 任务
private Runnable task;

public Worker(Runnable task) {
this.task = task;
}

@Override
public void run() {
// 执行任务
// 1) 当 task 不为空,执行任务
// 2) 当 task 执行完毕,再接着从任务队列获取任务并执行
// while(task != null || (task = taskQueue.take()) != null) {
while(task != null || (task = taskQueue.poll(timeout, timeUnit)) != null) {
try {
log.debug("正在执行...{}", task);
task.run();
} catch (Exception e) {
e.printStackTrace();
} finally {
task = null;
}
}
synchronized (workers) {
log.debug("worker 被移除{}", this);
workers.remove(this);
}
}
}
}
4、步骤4:测试编写好的自定义线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class TestPool {
public static void main(String[] args) {
ThreadPool threadPool = new ThreadPool(1,
1000, TimeUnit.MILLISECONDS, 1, (queue, task)->{
// 1. 死等
// queue.put(task);
// 2) 带超时等待
// queue.offer(task, 1500, TimeUnit.MILLISECONDS);
// 3) 让调用者放弃任务执行
// log.debug("放弃{}", task);
// 4) 让调用者抛出异常
// throw new RuntimeException("任务执行失败 " + task);
// 5) 让调用者自己执行任务
task.run();
});
for (int i = 0; i < 4; i++) {
int j = i;
threadPool.execute(() -> {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("{}", j);
});
}
}
}

17、ThreadPool线程池——ThreadPoolExecutor

1、BAT大厂的面试问题

  • 为什么要有线程池?
  • Java是实现和管理线程池有哪些方式?请简单举例如何使用。
  • 为什么很多公司不允许使用Executors去创建线程池?那么推荐怎么使用呢?
  • ThreadPoolExecutor有哪些核心的配置参数?请简要说明
  • ThreadPoolExecutor可以创建哪是哪三种线程池呢?
  • 当队列满了并且worker的数量达到maxSize的时候,会怎么样?
  • 说说ThreadPoolExecutor有哪些RejectedExecutionHandler策略?默认是什么策略?
  • 简要说下线程池的任务执行机制?
    • execute –> addWorker –>runworker (getTask)
  • 线程池中任务是如何提交的?
  • 线程池中任务是如何关闭的?
  • 在配置线程池的时候需要考虑哪些配置因素?
  • 如何监控线程池的状态?

2、为什么需要线程池

线程池能够对线程进行统一分配,调优和监控:

  • 降低资源消耗(线程无限制地创建,然后使用完毕后销毁)
  • 提高响应速度(无须创建线程)
  • 提高线程的可管理性

3、ThreadPoolExecutor例子

Java是如何实现和管理线程池的?

从JDK 5开始,把工作单元与执行机制分离开来,工作单元包括Runnable和Callable,而执行机制由Executor框架提供

WorkerThread:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class WorkerThread implements Runnable {

private String command;

public WorkerThread(String s){
this.command=s;
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName()+" Start. Command = "+command);
processCommand();
System.out.println(Thread.currentThread().getName()+" End.");
}

private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

@Override
public String toString(){
return this.command;
}
}

SimpleThreadPool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SimpleThreadPool {

public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
Runnable worker = new WorkerThread("" + i);
executor.execute(worker);
}
executor.shutdown(); // This will make the executor accept no new threads and finish all existing threads in the queue
while (!executor.isTerminated()) { // Wait until all threads are finish,and also you can use "executor.awaitTermination();" to wait
}
System.out.println("Finished all threads");
}

}

程序中我们创建了固定大小为五个工作线程的线程池。然后分配给线程池十个工作,因为线程池大小为五,它将启动五个工作线程先处理五个工作,其他的工作则处于等待状态,一旦有工作完成,空闲下来工作线程就会捡取等待队列里的其他工作进行执行。

这里是以上程序的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pool-1-thread-2 Start. Command = 1
pool-1-thread-4 Start. Command = 3
pool-1-thread-1 Start. Command = 0
pool-1-thread-3 Start. Command = 2
pool-1-thread-5 Start. Command = 4
pool-1-thread-4 End.
pool-1-thread-5 End.
pool-1-thread-1 End.
pool-1-thread-3 End.
pool-1-thread-3 Start. Command = 8
pool-1-thread-2 End.
pool-1-thread-2 Start. Command = 9
pool-1-thread-1 Start. Command = 7
pool-1-thread-5 Start. Command = 6
pool-1-thread-4 Start. Command = 5
pool-1-thread-2 End.
pool-1-thread-4 End.
pool-1-thread-3 End.
pool-1-thread-5 End.
pool-1-thread-1 End.
Finished all threads

输出表明线程池中至始至终只有五个名为 “pool-1-thread-1” 到 “pool-1-thread-5” 的五个线程,这五个线程不随着工作的完成而消亡,会一直存在,并负责执行分配给线程池的任务,直到线程池消亡。

Executors 类提供了使用了 ThreadPoolExecutor 的简单的 ExecutorService 实现,但是 ThreadPoolExecutor 提供的功能远不止于此。我们可以在创建 ThreadPoolExecutor 实例时指定活动线程的数量,我们也可以限制线程池的大小并且创建我们自己的 RejectedExecutionHandler 实现来处理不能适应工作队列的工作。

这里是我们自定义的 RejectedExecutionHandler 接口的实现:

1
2
3
4
5
6
7
8
9
10
11
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;

public class RejectedExecutionHandlerImpl implements RejectedExecutionHandler {

@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString() + " is rejected");
}

}

ThreadPoolExecutor 提供了一些方法,我们可以使用这些方法来查询 executor 的当前状态,线程池大小,活动线程数量以及任务数量。因此我是用来一个监控线程在特定的时间间隔内打印 executor 信息。

MyMonitorThread.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.util.concurrent.ThreadPoolExecutor;

public class MyMonitorThread implements Runnable {

private ThreadPoolExecutor executor;

private int seconds;

private boolean run=true;

public MyMonitorThread(ThreadPoolExecutor executor, int delay) {
this.executor = executor;
this.seconds=delay;
}

public void shutdown(){
this.run=false;
}

@Override
public void run() {
while(run){
System.out.println(
String.format("[monitor] [%d/%d] Active: %d, Completed: %d, Task: %d, isShutdown: %s, isTerminated: %s",
this.executor.getPoolSize(),
this.executor.getCorePoolSize(),
this.executor.getActiveCount(),
this.executor.getCompletedTaskCount(),
this.executor.getTaskCount(),
this.executor.isShutdown(),
this.executor.isTerminated()));
try {
Thread.sleep(seconds*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}
}

这里是使用 ThreadPoolExecutor 的线程池实现例子。

WorkerPool.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class WorkerPool {

public static void main(String args[]) throws InterruptedException{
//RejectedExecutionHandler implementation
RejectedExecutionHandlerImpl rejectionHandler = new RejectedExecutionHandlerImpl();
//Get the ThreadFactory implementation to use
ThreadFactory threadFactory = Executors.defaultThreadFactory();
//creating the ThreadPoolExecutor
ThreadPoolExecutor executorPool = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2), threadFactory, rejectionHandler);
//start the monitoring thread
MyMonitorThread monitor = new MyMonitorThread(executorPool, 3);
Thread monitorThread = new Thread(monitor);
monitorThread.start();
//submit work to the thread pool
for(int i=0; i<10; i++){
executorPool.execute(new WorkerThread("cmd"+i));
}

Thread.sleep(30000);
//shut down the pool
executorPool.shutdown();
//shut down the monitor thread
Thread.sleep(5000);
monitor.shutdown();

}
}

注意在初始化 ThreadPoolExecutor 时,我们保持初始池大小为 2,最大池大小为 4 而工作队列大小为 2。因此如果已经有四个正在执行的任务而此时分配来更多任务的话,工作队列将仅仅保留他们(新任务)中的两个,其他的将会被 RejectedExecutionHandlerImpl 处理。

上面程序的输出可以证实以上观点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pool-1-thread-1 Start. Command = cmd0
pool-1-thread-4 Start. Command = cmd5
cmd6 is rejected
pool-1-thread-3 Start. Command = cmd4
pool-1-thread-2 Start. Command = cmd1
cmd7 is rejected
cmd8 is rejected
cmd9 is rejected
[monitor] [0/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false
[monitor] [4/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false
pool-1-thread-4 End.
pool-1-thread-1 End.
pool-1-thread-2 End.
pool-1-thread-3 End.
pool-1-thread-1 Start. Command = cmd3
pool-1-thread-4 Start. Command = cmd2
[monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false
[monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false
pool-1-thread-1 End.
pool-1-thread-4 End.
[monitor] [4/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true
[monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true

注意 executor 的活动任务、完成任务以及所有完成任务,这些数量上的变化。我们可以调用 shutdown() 方法来结束所有提交的任务并终止线程池。

4、ThreadPoolExecutor使用详解

其实java线程池的实现原理很简单,说白了就是一个线程集合workerSet和一个阻塞队列workQueue。当用户向线程池提交一个任务(也就是线程)时,线程池会先将任务放入workQueue中。workerSet中的线程会不断的从workQueue中获取线程然后执行。当workQueue中没有任务的时候,worker就会阻塞,直到队列中有任务了就取出来继续执行。

java-thread-x-executors-1

1、Execute原理

当一个任务提交至线程池之后:

  1. 线程池首先当前运行的线程数量是否少于corePoolSize。如果是,则创建一个新的工作线程来执行任务。如果都在执行任务,则进入2.
  2. 判断BlockingQueue是否已经满了,倘若还没有满,则将线程放入BlockingQueue。否则进入3.
  3. 如果创建一个新的工作线程将使当前运行的线程数量超过maximumPoolSize,则交给RejectedExecutionHandler来处理任务。

当ThreadPoolExecutor创建新线程时,通过CAS来更新线程池的状态ctl。

2、参数
1
2
3
4
5
6
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler)
  • corePoolSize 线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize,即使有其他空闲线程能够执行新来的任务,也会继续创建线程;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。
  • workQueue 用来保存等待被执行的任务的阻塞队列。在JDK中提供了如下阻塞队列:
    • ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
    • LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
    • SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
    • PriorityBlockingQuene:具有优先级的无界阻塞队列;

LinkedBlockingQueueArrayBlockingQueue在插入删除节点性能方面更优,但是二者在put(), take()任务的时均需要加锁,SynchronousQueue使用无锁算法,根据节点的状态判断执行,而不需要用到锁,其核心是Transfer.transfer()

  • maximumPoolSize 线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;当阻塞队列是无界队列,则maximumPoolSize则不起作用,因为无法提交至核心线程池的线程会一直持续地放入workQueue。
  • keepAliveTime 线程空闲时的存活时间,即当线程没有任务执行时,该线程继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用,超过这个时间的空闲线程将被终止; ——针对救急线程
  • unit keepAliveTime的单位 —— 针对救急线程
  • threadFactory 创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。默认为DefaultThreadFactory
  • handler 线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
    • AbortPolicy:直接抛出异常,默认策略;
    • CallerRunsPolicy:用调用者所在的线程来执行任务;
    • DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    • DiscardPolicy:直接丢弃任务;

当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义拒绝策略,如记录日志或持久化存储不能处理的任务

image-20210811040044445

根据这个构造方法,JDK Executors 类中提供了众多工厂方法来创建各种用途的线程池

3、三种类型
1、newFixedThreadPool
1
2
3
4
5
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

特点:

  • 线程池的线程数量达corePoolSize后,即使线程池没有可执行任务时,也不会释放线程。
  • 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间
  • 阻塞队列是无界的,可以放任意数量的任务

FixedThreadPool的工作队列为无界队列LinkedBlockingQueue(队列容量为Integer.MAX_VALUE),这会导致以下问题:

  • 线程池里的线程数量不超过corePoolSize,这导致了maximumPoolSize和keepAliveTime将会是个无用参数
  • 由于使用了无界队列,所以FixedThreadPool永远不会拒绝,即饱和策略失效

评价:适用于任务量已知,相对耗时的任务

2、newSingleThreadPool
1
2
3
4
5
6
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

特点:

  • 初始化的线程池中只有一个线程,如果该线程异常结束,会重新创建一个新的线程继续执行任务,唯一的线程可以保证所提交任务的顺序执行。

由于使用了无界队列,所以SingleThreadPool永远不会拒绝,即饱和策略失效。

使用场景:

  • 希望多个任务排队执行。
  • 线程数固定为 1,任务数多于 1 时,会放入无界队列排队。
  • 任务执行完毕,这唯一的线程也不会被释放。
3、newCachedThreadPool
1
2
3
4
5
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

特点:

  • 核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着
    • 全部都是救急线程(60s 后可以回收)
    • 救急线程可以无限创建
  • 队列采用了 SynchronousQueue 实现特点是,它没有容量,没有线程来取是放不进去的(一手交钱、一手交货)

线程池的线程数可达到Integer.MAX_VALUE,即2147483647,内部使用SynchronousQueue作为阻塞队列; 和newFixedThreadPool创建的线程池不同,newCachedThreadPool在没有任务执行时,当线程的空闲时间超过keepAliveTime,会自动释放线程资源,当提交新任务时,如果没有空闲线程,则创建新线程执行任务,会导致一定的系统开销; 执行过程与前两种稍微不同:

  1. 主线程调用SynchronousQueue的offer()方法放入task,倘若此时线程池中有空闲的线程尝试读取 SynchronousQueue的task,即调用了SynchronousQueue的poll(),那么主线程将该task交给空闲线程。否则执行(2)
  2. 当线程池为空或者没有空闲的线程,则创建新的线程执行任务。
  3. 执行完任务的线程倘若在60s内仍空闲,则会被终止。因此长时间空闲的CachedThreadPool不会持有任何线程资源。

评价:整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲 1分钟后释放线程。 适合任务数比较密集,但每个任务执行时间较短的情况

4、区别
  • 自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作
  • Executors.newSingleThreadExecutor() 线程个数始终为1,不能修改
    • FinalizableDelegatedExecutorService 应用的是==装饰器模式==,只对外暴露了 ExecutorService 接口,因此不能调用 ThreadPoolExecutor 中特有的方法
  • Executors.newFixedThreadPool(1) 初始时为1,以后还可以修改
    • 对外暴露的是 ThreadPoolExecutor 对象,可以强转后调用 setCorePoolSize 等方法进行修改
4、关闭线程池

遍历线程池中的所有线程,然后逐个调用线程的interrupt方法来中断线程。

1、关闭方式——shutdown

将线程池里的线程状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。

2、关闭方式——shutdownNow

将线程池里的线程状态设置成STOP状态,然后停止所有正在执行或暂停任务的线程。只要调用这两个关闭方法中的任意一个,isShutDown() 返回true。当所有任务都成功关闭了,isTerminated()返回true。

5、ThreadPoolExecutor源码详解

1、几个关键属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//这个属性是用来存放 当前运行的worker数量以及线程池状态的
//int是32位的,这里把int的高3位拿来充当线程池状态的标志位,后29位拿来充当当前运行worker的数量
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
//存放任务的阻塞队列
private final BlockingQueue<Runnable> workQueue;
//worker的集合,用set来存放
private final HashSet<Worker> workers = new HashSet<Worker>();
//历史达到的worker数最大值
private int largestPoolSize;
//当队列满了并且worker的数量达到maxSize的时候,执行具体的拒绝策略
private volatile RejectedExecutionHandler handler;
//超出coreSize的worker的生存时间
private volatile long keepAliveTime;
//常驻worker的数量
private volatile int corePoolSize;
//最大worker的数量,一般当workQueue满了才会用到这个参数
private volatile int maximumPoolSize;
2、内部状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

其中AtomicInteger变量ctl的功能非常强大:利用低29位表示线程池中线程数,通过高3位表示线程池的运行状态:

  • RUNNING:-1 << COUNT_BITS,即高3位为111该状态的线程池会接收新任务,并处理阻塞队列中的任务
  • SHUTDOWN:0 << COUNT_BITS,即高3位为000该状态的线程池不会接收新任务,但会处理阻塞队列中的任务
  • STOP:1 << COUNT_BITS,即高3位为001该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务
  • TIDYING:2 << COUNT_BITS,即高3位为010所有任务全执行完毕,活动线程为 0 即将进入终结
  • TERMINATED:3 << COUNT_BITS,即高3位为011terminated()方法已经执行完成

img

状态名 高3位 接收新任务 处理阻塞队列任务 说明
RUNNING 111 Y Y
SHUTDOWN 000 N Y 不会接收新任务,但会处理阻塞队列剩余任务
STOP 001 N N 会中断正在执行的任务,并抛弃阻塞队列任务
TIDYING 010 - - 任务全执行完毕,活动线程为 0 即将进入终结
TERMINATED 011 - - 终结状态

从数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING

注意:为什么RUNNING为111确是最小的?

因为计算机都是补码来记录,所以111其实是-1

这些信息存储在一个原子变量 ctl 中,目的是将线程池状态与线程个数合二为一,这样就可以用一次 cas 原子操作进行赋值

1
2
3
4
// c 为旧值, ctlOf 返回结果为新值
ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c))));
// rs 为高 3 位代表线程池状态, wc 为低 29 位代表线程个数,ctl 是合并它们
private static int ctlOf(int rs, int wc) { return rs | wc; }
3、任务的执行

execute –> addWorker –>runworker (getTask)

线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程。 从Woker类的构造方法实现可以发现:线程工厂在创建线程thread时,将Woker实例本身this作为参数传入,当执行start方法启动线程thread时,本质是执行了Worker的runWorker方法。 firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;

1、execute()方法

ThreadPoolExecutor.execute(task)实现了Executor.execute(task)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
//workerCountOf获取线程池的当前线程数;小于corePoolSize,执行addWorker创建新线程执行command任务
if (addWorker(command, true))
return;
c = ctl.get();
}
// double check: c, recheck
// 线程池处于RUNNING状态,把提交的任务成功放入阻塞队列中
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// recheck and if necessary 回滚到入队操作前,即倘若线程池shutdown状态,就remove(command)
//如果线程池没有RUNNING,成功从阻塞队列中删除任务,执行reject方法处理任务
if (! isRunning(recheck) && remove(command))
reject(command);
//线程池处于running状态,但是没有线程,则创建线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 往线程池中创建新的线程失败,则reject任务
else if (!addWorker(command, false))
reject(command);
}

为什么需要double check线程池的状态?

在多线程环境下,线程池的状态时刻在变化,而ctl.get()是非原子操作,很有可能刚获取了线程池状态后线程池状态就改变了。判断是否将command加入workque是线程池之前的状态。倘若没有double check,万一线程池处于非running状态(在多线程环境下很有可能发生),那么command永远不会执行。

2、addWorker方法

从方法execute的实现可以看出:addWorker主要负责创建新的线程并执行任务线程池创建新线程执行任务时,需要 获取全局锁:

1
private final ReentrantLock mainLock = new ReentrantLock();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
private boolean addWorker(Runnable firstTask, boolean core) {
// CAS更新线程池数量
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 线程池重入锁
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start(); // 线程启动,执行任务(Worker.thread(firstTask).start());
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
3、Worker类的runworker方法
1
2
3
4
5
6
7
8
9
10
11
12
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this); // 创建线程
}
/** Delegates main run loop to outer runWorker */
public void run() {
runWorker(this);
}
// ...
}
  • 继承了AQS类,可以方便的实现工作线程的中止操作;
  • 实现了Runnable接口,可以将自身作为一个任务在工作线程中执行;
  • 当前提交的任务firstTask作为参数传入Worker的构造方法;

一些属性还有构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
//运行的线程,前面addWorker方法中就是直接通过启动这个线程来启动这个worker
final Thread thread;
//当一个worker刚创建的时候,就先尝试执行这个任务
Runnable firstTask;
//记录完成任务的数量
volatile long completedTasks;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
//创建一个Thread,将自己设置给他,后面这个thread启动的时候,也就是执行worker的run方法
this.thread = getThreadFactory().newThread(this);
}

runWorker方法是线程池的核心:

  • 线程启动之后,通过unlock方法释放锁,设置AQS的state为0,表示运行可中断;
  • Worker执行firstTask或从workQueue中获取任务:
    • 进行加锁操作,保证thread不被其他线程中断(除非线程池被中断)
    • 检查线程池状态,倘若线程池处于中断状态,当前线程将中断。
    • 执行beforeExecute
    • 执行任务的run方法
    • 执行afterExecute方法
    • 解锁操作

通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
// 先执行firstTask,再从workerQueue中取task(getTask())

while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
4、getTask方法

下面来看一下getTask()方法,这里面涉及到keepAliveTime的使用,从这个方法我们可以看出线程池是怎么让超过corePoolSize的那部分worker销毁的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

注意这里一段代码是keepAliveTime起作用的关键:

1
2
3
4
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();

allowCoreThreadTimeOut为false,线程即使空闲也不会被销毁;倘若为ture,在keepAliveTime内仍空闲则会被销毁。

如果线程允许空闲等待而不被销毁timed == false,workQueue.take任务:如果阻塞队列为空,当前线程会被挂起等待;当队列中有任务加入时,线程被唤醒,take方法返回任务,并执行;

如果线程不允许无休止空闲timed == true,workQueue.poll任务:如果在keepAliveTime时间内,阻塞队列还是没有任务,则返回null;

4、任务的提交
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 执行任务
void execute(Runnable command);

// 提交任务 task,用返回值 Future 获得任务执行结果
<T> Future<T> submit(Callable<T> task);

// 提交 tasks 中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;

// 提交 tasks 中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit) throws InterruptedException;

// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;

// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间
<T> T invokeAny(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;

img

  1. submit任务,等待线程池execute
  2. 执行FutureTask类的get方法时,会把主线程封装成WaitNode节点并保存在waiters链表中, 并阻塞等待运行结果;
  3. FutureTask任务执行完成后,通过UNSAFE设置waiters相应的waitNode为null,并通过LockSupport类unpark方法唤醒主线程;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Test{
public static void main(String[] args) {

ExecutorService es = Executors.newCachedThreadPool();
Future<String> future = es.submit(new Callable<String>() {
@Override
public String call() throws Exception {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "future result";
}
});
try {
String result = future.get();
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}

在实际业务场景中,Future和Callable基本是成对出现的,Callable负责产生结果,Future负责获取结果。

  1. Callable接口类似于Runnable,只是Runnable没有返回值。
  2. Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果;
  3. Future.get方法会导致主线程阻塞,直到Callable任务执行完成;
1、submit方法

AbstractExecutorService.submit()实现了ExecutorService.submit() 可以获取执行完的返回值,而ThreadPoolExecutor 是AbstractExecutorService.submit()的子类,所以submit方法也是ThreadPoolExecutor的方法。

1
2
3
4
5
6
// submit()在ExecutorService中的定义
<T> Future<T> submit(Callable<T> task);

<T> Future<T> submit(Runnable task, T result);

Future<?> submit(Runnable task);
1
2
3
4
5
6
7
8
// submit方法在AbstractExecutorService中的实现
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
// 通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}

通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。通过Executor.execute方法提交FutureTask到线程池中等待被执行,最终执行的是FutureTask的run方法;

2、FutureTask对象

public class FutureTask<V> implements RunnableFuture<V> 可以将FutureTask提交至线程池中等待被执行(通过FutureTask的run方法来执行)

  • 内部状态

    • /* The run state of this task, initially NEW. 
          * ...
          * Possible state transitions:
          * NEW -> COMPLETING -> NORMAL
          * NEW -> COMPLETING -> EXCEPTIONAL
          * NEW -> CANCELLED
          * NEW -> INTERRUPTING -> INTERRUPTED
          */
      private volatile int state;
      private static final int NEW          = 0;
      private static final int COMPLETING   = 1;
      private static final int NORMAL       = 2;
      private static final int EXCEPTIONAL  = 3;
      private static final int CANCELLED    = 4;
      private static final int INTERRUPTING = 5;
      private static final int INTERRUPTED  = 6;
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 内部状态的修改通过sun.misc.Unsafe修改

      - get方法

      ```java

      public V get() throws InterruptedException, ExecutionException {
      int s = state;
      if (s <= COMPLETING)
      s = awaitDone(false, 0L);
      return report(s);
      }
    • 内部通过awaitDone方法对主线程进行阻塞,具体实现如下:

    • private int awaitDone(boolean timed, long nanos)
          throws InterruptedException {
          final long deadline = timed ? System.nanoTime() + nanos : 0L;
          WaitNode q = null;
          boolean queued = false;
          for (;;) {
              if (Thread.interrupted()) {
                  removeWaiter(q);
                  throw new InterruptedException();
              }
      
              int s = state;
              if (s > COMPLETING) {
                  if (q != null)
                      q.thread = null;
                  return s;
              }
              else if (s == COMPLETING) // cannot time out yet
                  Thread.yield();
              else if (q == null)
                  q = new WaitNode();
              else if (!queued)
                  queued = UNSAFE.compareAndSwapObject(this, waitersOffset,q.next = waiters, q);
              else if (timed) {
                  nanos = deadline - System.nanoTime();
                  if (nanos <= 0L) {
                      removeWaiter(q);
                      return state;
                  }
                  LockSupport.parkNanos(this, nanos);
              }
              else
                  LockSupport.park(this);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44

      - 如果主线程被中断,则抛出中断异常;

      - 判断FutureTask当前的state,如果大于COMPLETING,说明任务已经执行完成,则直接返回;

      - 如果当前state等于COMPLETING,说明任务已经执行完,这时主线程只需通过yield方法让出cpu资源,等待state变成NORMAL;

      - 通过WaitNode类封装当前线程,并通过UNSAFE添加到waiters链表;

      - 最终通过LockSupport的park或parkNanos挂起线程;

      - run方法

      - ```java
      public void run() {
      if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread()))
      return;
      try {
      Callable<V> c = callable;
      if (c != null && state == NEW) {
      V result;
      boolean ran;
      try {
      result = c.call();
      ran = true;
      } catch (Throwable ex) {
      result = null;
      ran = false;
      setException(ex);
      }
      if (ran)
      set(result);
      }
      } finally {
      // runner must be non-null until state is settled to
      // prevent concurrent calls to run()
      runner = null;
      // state must be re-read after nulling runner to prevent
      // leaked interrupts
      int s = state;
      if (s >= INTERRUPTING)
      handlePossibleCancellationInterrupt(s);
      }
      }
    • FutureTask.run方法是在线程池中被执行的,而非主线程:

      1. 通过执行Callable任务的call方法;
      2. 如果call执行成功,则通过set方法保存结果;
      3. 如果call执行有异常,则通过setException保存异常;
5、任务的关闭

shutdown方法会将线程池的状态设置为SHUTDOWN,线程池进入这个状态后,就拒绝再接受任务,然后会将剩余的任务全部执行完:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
线程池状态变为 SHUTDOWN
- 不会接收新任务
- 但已提交任务会执行完
- 此方法不会阻塞调用线程的执行
*/
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//检查是否可以关闭线程
checkShutdownAccess();
//设置线程池状态
advanceRunState(SHUTDOWN);
//尝试中断worker,仅会打断空闲线程
interruptIdleWorkers();
//预留方法,留给子类实现
onShutdown(); // hook for ScheduledThreadPoolExecutor——扩展点
} finally {
mainLock.unlock();
}
// 尝试终结(没有运行的线程可以立刻终结,如果还有运行的线程也不会等)
tryTerminate();
}

private void interruptIdleWorkers() {
interruptIdleWorkers(false);
}

private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有的worker
for (Worker w : workers) {
Thread t = w.thread;
//先尝试调用w.tryLock(),如果获取到锁,就说明worker是空闲的,就可以直接中断它
//注意的是,worker自己本身实现了AQS同步框架,然后实现的类似锁的功能
//它实现的锁是不可重入的,所以如果worker在执行任务的时候,会先进行加锁,这里tryLock()就会返回false
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne)
break;
}
} finally {
mainLock.unlock();
}
}

shutdownNow做的比较绝,它先将线程池状态设置为STOP,然后拒绝所有提交的任务。最后中断左右正在运行中的worker,然后清空任务队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
线程池状态变为 STOP
- 不会接收新任务
- 会将队列中的任务返回
- 并用 interrupt 的方式中断正在执行的任务
*/
public List<Runnable> shutdownNow() {
List<Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
//检测权限,修改线程池状态
advanceRunState(STOP);
//中断所有的worker,打断所有线程
interruptWorkers();
//获取队列中剩余任务,清空任务队列
tasks = drainQueue();
} finally {
mainLock.unlock();
}
// 尝试终结(没有运行的线程可以立刻终结,如果还有运行的线程也不会等)
tryTerminate();
return tasks;
}

private void interruptWorkers() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有worker,然后调用中断方法
for (Worker w : workers)
w.interruptIfStarted();
} finally {
mainLock.unlock();
}
}
6、其他方法
1
2
3
4
5
6
7
8
// 不在 RUNNING 状态的线程池,此方法就返回 true
boolean isShutdown();

// 线程池状态是否是 TERMINATED
boolean isTerminated();

// 调用 shutdown 后,由于调用线程并不会等待所有任务运行结束,因此如果它想在线程池 TERMINATED 后做些事情,可以利用此方法等待
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;

6、异常的处理

使用线程池创建线程时,如果线程内部发生异常的话,是不会抛出或者在控制台打印异常信息的,所以需要我们对可能出现异常进行异常处理,对于异常的处理有以下几种方法:

  • 线程自己捕捉:线程在代码里对可能出现的异常进行try catch捕捉

    • ExecutorService pool = Executors.newFixedThreadPool(1);
      pool.submit(() -> {
          try {
              log.debug("task1");
              int i = 1 / 0;
          } catch (Exception e) {
              log.error("error:", e);
          }
      });
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 通过Future进行结果的返回来判断是否发生异常:

      - ```java
      ExecutorService pool = Executors.newFixedThreadPool(1);
      Future<Boolean> f = pool.submit(() -> {
      log.debug("task1");
      inti = 1/0;
      return true ;
      });
      Log. debug("result:{}", f.get();

7、更深入理解

1、为什么线程池不允许使用Executors去创建?

线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:

  • newFixedThreadPool和newSingleThreadExecutor:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
  • newCachedThreadPool和newScheduledThreadPool:主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
1、推荐方式1

首先引入:commons-lang3包

1
2
ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1,
new BasicThreadFactory.Builder().namingPattern("example-schedule-pool-%d").daemon(true).build());
2、推荐方式2

首先引入:com.google.guava包

1
2
3
4
5
6
7
8
9
10
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build();

//Common Thread Pool
ExecutorService pool = new ThreadPoolExecutor(5, 200, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());

// excute
pool.execute(()-> System.out.println(Thread.currentThread().getName()));

//gracefully shutdown
pool.shutdown();
3、推荐方式3

spring配置线程池方式:自定义线程工厂bean需要实现ThreadFactory,可参考该接口的其它默认实现类,使用方式直接注入bean调用execute(Runnable task)方法即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
<bean id="userThreadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
<property name="corePoolSize" value="10" />
<property name="maxPoolSize" value="100" />
<property name="queueCapacity" value="2000" />

<property name="threadFactory" value= threadFactory />
<property name="rejectedExecutionHandler">
<ref local="rejectedExecutionHandler" />
</property>
</bean>

//in code
userThreadPool.execute(thread);
2、配置线程池需要考虑的因素

从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且近可能地使用有界的工作队列。

性质不同的任务可用使用不同规模的线程池分开处理:

  • CPU密集型:尽可能少的线程,Ncpu+1
  • IO密集型:尽可能多的线程,Ncpu*2,比如数据库连接池
  • 混合型:CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。

具体也可以参考8、并发的多线程设计模式的7、工作线程模式查看

3、监控线程池的状态

可以使用ThreadPoolExecutor以下方法:

  • getTaskCount() Returns the approximate total number of tasks that have ever been scheduled for execution.
    • 返回计划执行的任务的大致总数。
  • getCompletedTaskCount() Returns the approximate total number of tasks that have completed execution.
    • 返回已完成执行的任务的大致总数
    • 返回结果少于getTaskCount()。
  • getLargestPoolSize() Returns the largest number of threads that have ever simultaneously been in the pool.
    • 返回池中同时存在的最大线程数。
    • 返回结果小于等于maximumPoolSize
  • getPoolSize() Returns the current number of threads in the pool.
    • 返回池中当前的线程数。
  • getActiveCount() Returns the approximate number of threads that are actively executing tasks.
    • 返回当前正在执行任务的线程的大致数目。

18、ThreadPool线程池——ScheduledThreadPoolExecutor

在『任务调度线程池』功能加入之前,可以使用 java.util.Timer 来实现定时功能,Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务

如果使用的是ScheduledThreadPoolExecutor前一个任务的延迟或异常都不会影响到之后的任务,但是异常信息不会被打印出来。需要我们对异常信息进行处理

在很多业务场景中,我们可能需要周期性的运行某项任务来获取结果,比如周期数据统计,定时发送数据等。在并发包出现之前,Java 早在1.3就提供了 Timer 类(只需要了解,目前已渐渐被 ScheduledThreadPoolExecutor 代替)来适应这些业务场景。随着业务量的不断增大,我们可能需要多个工作线程运行任务来尽可能的增加产品性能,或者是需要更高的灵活性来控制和监控这些周期业务。这些都是 ScheduledThreadPoolExecutor 诞生的必然性。

1、BAT大厂的面试问题

  • ScheduledThreadPoolExecutor要解决什么样的问题?
  • ScheduledThreadPoolExecutor相比ThreadPoolExecutor有哪些特性?
  • ScheduledThreadPoolExecutor有什么样的数据结构,核心内部类和抽象类?
  • ScheduledThreadPoolExecutor有哪两个关闭策略?区别是什么?
  • ScheduledThreadPoolExecutor中scheduleAtFixedRate 和 scheduleWithFixedDelay区别是什么?
  • 为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?
  • Executors 提供了几种方法来构造 ScheduledThreadPoolExecutor?

2、ScheduledThreadPoolExecutor简介

ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,为任务提供延迟或周期执行,属于线程池的一种。和 ThreadPoolExecutor 相比,它还具有以下几种特性:

  • 使用专门的任务类型——ScheduledFutureTask 来执行周期任务,也可以接收不需要时间调度的任务(这些任务通过 ExecutorService 来执行)。
  • 使用专门的存储队列——DelayedWorkQueue 来存储任务,DelayedWorkQueue 是无界延迟队列DelayQueue 的一种。相比ThreadPoolExecutor也简化了执行机制(delayedExecute方法,后面单独分析)。
  • 支持可选的run-after-shutdown参数,在池被关闭(shutdown)之后支持可选的逻辑来决定是否继续运行周期或延迟任务。并且当任务(重新)提交操作与 shutdown 操作重叠时,复查逻辑也不相同。

3、ScheduledThreadPoolExecutor数据结构

img

ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,ScheduledThreadPoolExecutor 内部构造了两个内部类 ScheduledFutureTaskDelayedWorkQueue:

  • ScheduledFutureTask:继承了FutureTask,说明是一个异步运算任务;最上层分别实现了Runnable、Future、Delayed接口,说明它是一个可以延迟执行的异步运算任务
  • DelayedWorkQueue:这是 ScheduledThreadPoolExecutor 为存储周期或延迟任务专门定义的一个延迟队列,继承了 AbstractQueue,为了契合 ThreadPoolExecutor 也实现了 BlockingQueue 接口。它内部只允许存储 RunnableScheduledFuture 类型的任务。与 DelayQueue 的不同之处就是它只允许存放 RunnableScheduledFuture 对象,并且自己实现了二叉堆(DelayQueue 是利用了 PriorityQueue 的二叉堆结构)。

4、ScheduledThreadPoolExecutor源码解析

1、内部类ScheduledFutureTask
1、属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//为相同延时任务提供的顺序编号
private final long sequenceNumber;

//任务可以执行的时间,纳秒级
private long time;

//重复任务的执行周期时间,纳秒级。
private final long period;

//重新入队的任务
RunnableScheduledFuture<V> outerTask = this;

//延迟队列的索引,以支持更快的取消操作
int heapIndex;
  • sequenceNumber:当两个任务有相同的延迟时间时,按照 FIFO 的顺序入队。sequenceNumber 就是为相同延时任务提供的顺序编号
  • time任务可以执行时的时间,==纳秒级==,通过triggerTime方法计算得出。
  • period:任务的执行周期时间,==纳秒级==。
    • 正数表示固定速率执行(为scheduleAtFixedRate提供服务),
    • 负数表示固定延迟执行(为scheduleWithFixedDelay提供服务),
    • 0表示不重复任务。
  • outerTask重新入队的任务,通过reExecutePeriodic方法入队重新排序。
2、核心方法run()
1
2
3
4
5
6
7
8
9
10
11
12
public void run() {
boolean periodic = isPeriodic();//是否为周期任务
if (!canRunInCurrentRunState(periodic))//当前状态是否可以执行
cancel(false);
else if (!periodic)
//不是周期任务,直接执行
ScheduledFutureTask.super.run();
else if (ScheduledFutureTask.super.runAndReset()) {
setNextRunTime();//设置下一次运行时间
reExecutePeriodic(outerTask);//重排序一个周期任务
}
}

说明:ScheduledFutureTask 的run方法重写了 FutureTask 的版本,以便执行周期任务时重置/重排序任务。任务的执行通过父类 FutureTask 的run实现。

内部有两个针对周期任务的方法:

  • setNextRunTime()用来设置下一次运行的时间,源码如下:

    • //设置下一次执行任务的时间
      private void setNextRunTime() {
          long p = period;
          if (p > 0)  //固定速率执行,scheduleAtFixedRate
              time += p;
          else
              time = triggerTime(-p);  //固定延迟执行,scheduleWithFixedDelay
      }
      //计算固定延迟任务的执行时间
      long triggerTime(long delay) {
          return now() +
              ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay));
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15

      - `reExecutePeriodic()`:**周期任务重新入队等待下一次执行**,源码如下:

      - ```java
      //重排序一个周期任务
      void reExecutePeriodic(RunnableScheduledFuture<?> task) {
      if (canRunInCurrentRunState(true)) {//池关闭后可继续执行
      super.getQueue().add(task);//任务入列
      //重新检查run-after-shutdown参数,如果不能继续运行就移除队列任务,并取消任务的执行
      if (!canRunInCurrentRunState(true) && remove(task))
      task.cancel(false);
      else
      ensurePrestart();//启动一个新的线程等待任务
      }
      }

reExecutePeriodic与delayedExecute的执行策略一致,只不过reExecutePeriodic不会执行拒绝策略而是直接丢掉任务

3、cancel方法
1
2
3
4
5
6
public boolean cancel(boolean mayInterruptIfRunning) {
boolean cancelled = super.cancel(mayInterruptIfRunning);
if (cancelled && removeOnCancel && heapIndex >= 0)
remove(this);
return cancelled;
}

ScheduledFutureTask.cancel本质上由其父类 FutureTask.cancel 实现取消任务成功后会根据removeOnCancel参数决定是否从队列中移除此任务。

2、核心属性
1
2
3
4
5
6
7
8
9
10
11
//关闭后继续执行已经存在的周期任务 
private volatile boolean continueExistingPeriodicTasksAfterShutdown;

//关闭后继续执行已经存在的延时任务
private volatile boolean executeExistingDelayedTasksAfterShutdown = true;

//取消任务后移除
private volatile boolean removeOnCancel = false;

//为相同延时的任务提供的顺序编号,保证任务之间的FIFO顺序
private static final AtomicLong sequencer = new AtomicLong();
  • continueExistingPeriodicTasksAfterShutdownexecuteExistingDelayedTasksAfterShutdownScheduledThreadPoolExecutor 定义的 run-after-shutdown 参数,用来控制池关闭之后的任务执行逻辑。
  • removeOnCancel用来控制任务取消后是否从队列中移除。当一个已经提交的周期或延迟任务在运行之前被取消,那么它之后将不会运行。默认配置下,这种已经取消的任务在届期之前不会被移除。 通过这种机制,可以方便检查和监控线程池状态,但也可能导致已经取消的任务无限滞留。为了避免这种情况的发生,我们可以通过setRemoveOnCancelPolicy方法设置移除策略,把参数removeOnCancel设为true可以在任务取消后立即从队列中移除。
  • sequencer是为相同延时的任务提供的顺序编号,保证任务之间的 FIFO 顺序。与 ScheduledFutureTask 内部的sequenceNumber参数作用一致。
3、构造函数

首先看下构造函数,ScheduledThreadPoolExecutor 内部有四个构造函数,这里我们只看这个最大构造灵活度的:

1
2
3
4
5
6
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}

构造函数都是通过super调用了ThreadPoolExecutor的构造,并且使用特定等待队列DelayedWorkQueue

4、核心方法——Schedule
1
2
3
4
5
6
7
8
9
10
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
long delay,
TimeUnit unit) {
if (callable == null || unit == null)
throw new NullPointerException();
RunnableScheduledFuture<V> t = decorateTask(callable,
new ScheduledFutureTask<V>(callable, triggerTime(delay, unit)));//构造ScheduledFutureTask任务
delayedExecute(t);//任务执行主方法
return t;
}

说明:schedule主要用于执行一次性(延迟)任务。函数执行逻辑分两步:

  • 封装 Callable/Runnable: 首先通过triggerTime计算任务的延迟执行时间,然后通过 ScheduledFutureTask 的构造函数把 Runnable/Callable 任务构造为ScheduledThreadPoolExecutor可以执行的任务类型,最后调用decorateTask方法执行用户自定义的逻辑;decorateTask是一个用户可自定义扩展的方法,默认实现下直接返回封装的RunnableScheduledFuture任务,源码如下:

    • protected <V> RunnableScheduledFuture<V> decorateTask(
          Runnable runnable, RunnableScheduledFuture<V> task) {
          return task;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17

      - `执行任务`:**通过delayedExecute实现**。下面我们来详细分析:

      - ```java
      private void delayedExecute(RunnableScheduledFuture<?> task) {
      if (isShutdown())
      reject(task);//池已关闭,执行拒绝策略
      else {
      super.getQueue().add(task);//任务入队
      if (isShutdown() &&
      !canRunInCurrentRunState(task.isPeriodic()) &&//判断run-after-shutdown参数
      remove(task))//移除任务
      task.cancel(false);
      else
      ensurePrestart();//启动一个新的线程等待任务
      }
      }

说明:delayedExecute是执行任务的主方法,方法执行逻辑如下:

  • 如果池已关闭(ctl >= SHUTDOWN),执行任务拒绝策略

  • 池正在运行,首先把任务入队排序;然后重新检查池的关闭状态,执行如下逻辑:

    1. A如果池正在运行,或者 run-after-shutdown 参数值为true,则调用父类方法ensurePrestart启动一个新的线程等待执行任务。ensurePrestart源码如下:

      • void ensurePrestart() {
            int wc = workerCountOf(ctl.get());
            if (wc < corePoolSize)
                addWorker(null, true);
            else if (wc == 0)
                addWorker(null, false);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55

        - ensurePrestart是父类 ThreadPoolExecutor 的方法,用于启动一个新的工作线程等待执行任务,即使corePoolSize为0也会安排一个新线程。

        2. `B`:**如果池已经关闭,并且 run-after-shutdown 参数值为false,则执行父类(ThreadPoolExecutor)方法remove移除队列中的指定任务,成功移除后调用ScheduledFutureTask.cancel取消任务**

        ##### 5、核心方法——scheduleAtFixedRate 和 scheduleWithFixedDelay

        ```java
        /**
        * 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
        * 之后每隔period执行一次,不等待第一次执行完成就开始计时
        */
        public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
        long initialDelay,
        long period,
        TimeUnit unit) {
        if (command == null || unit == null)
        throw new NullPointerException();
        if (period <= 0)
        throw new IllegalArgumentException();
        //构建RunnableScheduledFuture任务类型
        ScheduledFutureTask<Void> sft =
        new ScheduledFutureTask<Void>(command,
        null,
        triggerTime(initialDelay, unit),//计算任务的延迟时间
        unit.toNanos(period));//计算任务的执行周期
        RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
        sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
        delayedExecute(t);//执行任务
        return t;
        }

        /**
        * 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
        * 在第一次执行完之后延迟delay后开始下一次执行
        */
        public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
        long initialDelay,
        long delay,
        TimeUnit unit) {
        if (command == null || unit == null)
        throw new NullPointerException();
        if (delay <= 0)
        throw new IllegalArgumentException();
        //构建RunnableScheduledFuture任务类型
        ScheduledFutureTask<Void> sft =
        new ScheduledFutureTask<Void>(command,
        null,
        triggerTime(initialDelay, unit),//计算任务的延迟时间
        unit.toNanos(-delay));//计算任务的执行周期
        RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
        sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
        delayedExecute(t);//执行任务
        return t;
        }

说明:scheduleAtFixedRate和scheduleWithFixedDelay方法的逻辑与schedule类似。

注意scheduleAtFixedRate和scheduleWithFixedDelay的区别: 乍一看两个方法一模一样,其实,在unit.toNanos这一行代码中还是有区别的:

  • 没错,scheduleAtFixedRate传的是正值,而scheduleWithFixedDelay传的则是负值,这个值就是 ScheduledFutureTask 的period属性。
  • 执行效果上也有区别:
    • 对于scheduleAtFixedRate来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么将会影响到间隔的时间——间隔时间无效,会等到任务执行完毕在执行下一个任务
    • 而对于scheduleWithFixedDelay来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么间隔的时间会增加——间隔的时间(3s) = 设置的延迟时间(1s) + 代码的执行时间(2s),即:scheduleWithFixedDelay的时间间隔是从上一个任务结束时间来计算的
6、核心方法——shutdown()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void shutdown() {
super.shutdown();
}
//取消并清除由于关闭策略不应该运行的所有任务
@Override void onShutdown() {
BlockingQueue<Runnable> q = super.getQueue();
//获取run-after-shutdown参数
boolean keepDelayed =
getExecuteExistingDelayedTasksAfterShutdownPolicy();
boolean keepPeriodic =
getContinueExistingPeriodicTasksAfterShutdownPolicy();
if (!keepDelayed && !keepPeriodic) {//池关闭后不保留任务
//依次取消任务
for (Object e : q.toArray())
if (e instanceof RunnableScheduledFuture<?>)
((RunnableScheduledFuture<?>) e).cancel(false);
q.clear();//清除等待队列
}
else {//池关闭后保留任务
// Traverse snapshot to avoid iterator exceptions
//遍历快照以避免迭代器异常
for (Object e : q.toArray()) {
if (e instanceof RunnableScheduledFuture) {
RunnableScheduledFuture<?> t =
(RunnableScheduledFuture<?>)e;
if ((t.isPeriodic() ? !keepPeriodic : !keepDelayed) ||
t.isCancelled()) { // also remove if already cancelled
//如果任务已经取消,移除队列中的任务
if (q.remove(t))
t.cancel(false);
}
}
}
}
tryTerminate(); //终止线程池
}

说明:池关闭方法调用了父类ThreadPoolExecutor的shutdown,具体分析见 ThreadPoolExecutor 篇。这里主要介绍以下在shutdown方法中调用的==关闭钩子onShutdown方法==,它的主要作用是在关闭线程池后取消并清除由于关闭策略不应该运行的所有任务,这里主要是根据 run-after-shutdown 参数(continueExistingPeriodicTasksAfterShutdown和executeExistingDelayedTasksAfterShutdown)来决定线程池关闭后是否关闭已经存在的任务

5、再深入理解

1、为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?

例如:

  1. 由于 ScheduledThreadPoolExecutor 是一个固定核心线程数大小的线程池,并且使用了一个无界队列,所以调整maximumPoolSize对其没有任何影响(所以 ScheduledThreadPoolExecutor 没有提供可以调整最大线程数的构造函数,默认最大线程数固定为Integer.MAX_VALUE)。
  2. 此外,设置corePoolSize为0或者设置核心线程空闲后清除(allowCoreThreadTimeOut)同样也不是一个好的策略,因为一旦周期任务到达某一次运行周期时,可能导致线程池内没有线程去处理这些任务。
2、Executors 提供了哪几种方法来构造 ScheduledThreadPoolExecutor?
  • newScheduledThreadPool:可指定核心线程数的线程池。
  • newSingleThreadScheduledExecutor:只有一个工作线程的线程池。如果内部工作线程由于执行周期任务异常而被终止,则会新建一个线程替代它的位置。

注意:newScheduledThreadPool(1, threadFactory) 不等价于newSingleThreadScheduledExecutor。

  • newSingleThreadScheduledExecutor创建的线程池保证内部只有一个线程执行任务,并且线程数不可扩展
  • 而通过newScheduledThreadPool(1, threadFactory)创建的线程池可以通过setCorePoolSize方法来修改核心线程数

6、ScheduledThreadPoolExecutor应用

需求:让每周四 18:00:00 定时执行任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class TestSchedule {

// 如何让每周四 18:00:00 定时执行任务?
public static void main(String[] args) {
// 获取当前时间
LocalDateTime now = LocalDateTime.now();
System.out.println(now);
// 获取周四时间
LocalDateTime time = now.withHour(18).withMinute(0).withSecond(0).withNano(0).with(DayOfWeek.THURSDAY);
// 如果 当前时间 > 本周周四,必须找到下周周四
if(now.compareTo(time) > 0) {
time = time.plusWeeks(1);
}
System.out.println(time);
// initailDelay 代表当前时间和周四的时间差
// period 一周的间隔时间
long initailDelay = Duration.between(now, time).toMillis();
long period = 1000 * 60 * 60 * 24 * 7;
ScheduledExecutorService pool = Executors.newScheduledThreadPool(1);
pool.scheduleAtFixedRate(() -> {
System.out.println("running...");
}, initailDelay, period, TimeUnit.MILLISECONDS);
}
}

19、Tomcat 线程池

1、概述

Tomcat 在哪里用到了线程池呢?——tomcat的连接器部分(Connector)(tomcat还有容器部分——负责servlet规范的)

image-20210812012018371

  • LimitLatch 用来限流,可以控制最大连接个数,类似 J.U.C 中的 Semaphore
  • Acceptor 只负责【接收新的 socket 连接】
  • Poller 只负责监听 socket channel 是否有【可读的 I/O 事件】
  • 一旦可读,封装一个任务对象(socketProcessor),提交给 Executor 线程池处理
  • Executor 线程池中的工作线程最终负责【处理请求】

Tomcat 线程池扩展了 ThreadPoolExecutor,行为稍有不同

  • 如果总线程数达到 maximumPoolSize
    • 这时不会立刻抛 RejectedExecutionException 异常
    • 而是再次尝试将任务放入队列,如果还失败,才抛出 RejectedExecutionException 异常

2、源码 tomcat-7.0.42

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
// 调用父类的execute方法
super.execute(command);
} catch (RejectedExecutionException rx) { // 出现异常,进行进一步的处理
if (super.getQueue() instanceof TaskQueue) {
// 得到任务队列
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
// 再次尝试将任务放入队列
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
// 失败,抛出异常
throw new RejectedExecutionException("Queue capacity is full.");
}
} catch (InterruptedException x) {
submittedCount.decrementAndGet();
Thread.interrupted();
throw new RejectedExecutionException(x);
}
} else {
submittedCount.decrementAndGet();
throw rx;
}
}
}

TaskQueue.java

1
2
3
4
5
6
7
public boolean force(Runnable  o, long timeout, TimeUnit unit) throws InterruptedException {
if ( parent.isShutdown() )
throw new RejectedExecutionException(
"Executor not running, can't force a command into the queue"
);
return super.offer(o,timeout,unit); //forces the item onto the queue, to be used if the task is rejected
}

3、Connector 配置

配置项 默认值 说明
acceptorThreadCount 1 acceptor 线程数量
pollerThreadCount 1 poller 线程数量
minSpareThreads 10 核心线程数,即 corePoolSize
maxThreads 200 最大线程数,即 maximumPoolSize
executor - Executor 名称,用来引用下面的 Executor

4、Executor 线程配置

配置项 默认值 说明
threadPriority 5 线程优先级
daemon true 是否守护线程
minSpareThreads 25 核心线程数,即 corePoolSize
maxThreads 200 最大线程数,即 maximumPoolSize
maxIdleTime 60000 线程生存时间,单位是毫秒,默认值即 1 分钟
maxQueueSize Integer.MAX_VALUE 队列长度
prestartminSpareThreads false 核心线程是否在服务器启动时启动

20、Fork/Join分支合并框架

ForkJoinPool 是JDK 7加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。

1、BAT大厂的面试问题

  • Fork/Join主要用来解决什么样的问题?
  • Fork/Join框架是在哪个JDK版本中引入的?
  • Fork/Join框架主要包含哪三个模块?模块之间的关系是怎么样的?
  • ForkJoinPool类继承关系?
  • ForkJoinTask抽象类继承关系?
    • 在实际运用中,我们一般都会继承 RecursiveTaskRecursiveActionCountedCompleter 来实现我们的业务需求,而不会直接继承 ForkJoinTask 类
  • 整个Fork/Join 框架的执行流程/运行机制是怎么样的?
  • 具体阐述Fork/Join的分治思想和work-stealing 实现方式?
  • 有哪些JDK源码中使用了Fork/Join思想?
  • 如何使用Executors工具类创建ForkJoinPool?
  • 写一个例子:用ForkJoin方式实现1+2+3+…+100000?
  • Fork/Join在使用时有哪些注意事项?结合JDK中的斐波那契数列实例具体说明

2、Fork/Join框架简介

Fork/Join框架是Java并发工具包中的一种可以将一个大任务拆分为很多小任务来异步执行的工具,自JDK1.7引入。

1、三个模块及关系

Fork/Join框架主要包含三个模块:

  • 任务对象:ForkJoinTask (包括RecursiveTaskRecursiveActionCountedCompleter)
  • 执行Fork/Join任务的线程:ForkJoinWorkerThread
  • 线程池:ForkJoinPool

这三者的关系是:ForkJoinPool可以通过池中的ForkJoinWorkerThread来处理ForkJoinTask任务。

1
2
3
4
5
6
7
8
9
10
11
// from 《A Java Fork/Join Framework》Dong Lea
Result solve(Problem problem) {
if (problem is small)
directly solve problem
else {
split problem into independent parts
fork new subtasks to solve each part
join all subtasks
compose result from subresults
}
}

ForkJoinPool 只接收 ForkJoinTask 任务(在实际使用中,也可以接收 Runnable/Callable 任务,但在真正运行时,也会把这些任务封装成 ForkJoinTask 类型的任务),RecursiveTask 是 ForkJoinTask 的子类,是一个可以递归执行的 ForkJoinTask,RecursiveAction 是一个无返回值的 RecursiveTask,CountedCompleter 在任务完成执行后会触发执行一个自定义的钩子函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// From JDK 7 doc. Class RecursiveTask<V>
class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) { this.n = n; }
Integer compute() {
if (n <= 1)
return n;
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}

在实际运用中,我们一般都会继承 RecursiveTaskRecursiveActionCountedCompleter 来实现我们的业务需求,而不会直接继承 ForkJoinTask 类。

2、核心思想:分治算法(Divide-and-Conquer)

分治算法(Divide-and-Conquer)把任务递归的拆分为各个子任务,这样可以更好的利用系统资源,尽可能的使用所有可用的计算能力来提升应用性能。首先看一下 Fork/Join 框架的任务运行机制:

img

Fork/Join 框架要完成两件事情:

  • Fork:把一个复杂任务进行分拆,大事化小
  • Join:把分拆任务的结果进行合并

image-20210729225650517

  1. 任务分割:首先 Fork/Join 框架需要把大的任务分割成足够小的子任务,如果子任务比较大的话还要对子任务进行继续分割
  2. 执行任务并合并结果:分割的子任务分别放到双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都放在另外一个队列里,启动一个线程从队列里取数据,然后合并这些数据。
3、核心思想:work-stealing(工作窃取)算法

work-stealing(工作窃取)算法:线程池内的所有工作线程都尝试找到并执行已经提交的任务,或者是被其他活动任务创建的子任务(如果不存在就阻塞等待)。这种特性使得 ForkJoinPool 在运行多个可以产生子任务的任务,或者是提交的许多小任务时效率更高。尤其是构建异步模型的 ForkJoinPool 时,对不需要合并(join)的事件类型任务也非常适用。

在 ForkJoinPool 中,线程池中每个工作线程(ForkJoinWorkerThread)都对应一个任务队列(WorkQueue),工作线程优先处理来自自身队列的任务(LIFO或FIFO顺序,参数 mode 决定),然后以FIFO的顺序随机窃取其他队列中的任务。

具体思路如下:

  • 每个线程都有自己的一个WorkQueue,该工作队列是一个双端队列。
  • 队列支持三个功能push、pop、poll
  • push/pop只能被队列的所有者线程调用,而poll可以被其他线程调用。
  • 划分的子任务调用fork时,都会被push到自己的队列中。
  • 默认情况下,工作线程从自己的双端队列获出任务并执行。
  • 当自己的队列为空时,线程随机从另一个线程的队列末尾调用poll方法窃取任务。

img

4、Fork/Join 框架的执行流程

上图可以看出ForkJoinPool 中的任务执行分两种:

  • 直接通过 FJP 提交的外部任务(external/submissions task),存放在 workQueues 的偶数槽位;
  • 通过内部 fork 分割的子任务(Worker task),存放在 workQueues 的奇数槽位。

那Fork/Join 框架的执行流程是什么样的?

img

3、Fork/Join类关系

1、ForkJoinPool继承关系

img

内部类介绍:

  • ForkJoinWorkerThreadFactory:内部线程工厂接口,用于创建工作线程ForkJoinWorkerThread
  • DefaultForkJoinWorkerThreadFactory:ForkJoinWorkerThreadFactory 的默认实现类
  • InnocuousForkJoinWorkerThreadFactory:实现了 ForkJoinWorkerThreadFactory,无许可线程工厂,当系统变量中有系统安全管理相关属性时,默认使用这个工厂创建工作线程。
  • EmptyTask:内部占位类,用于替换队列中 join 的任务。
  • ManagedBlocker:为 ForkJoinPool 中的任务提供扩展管理并行数的接口,一般用在可能会阻塞的任务(如在 Phaser 中用于等待 phase 到下一个generation)。
  • WorkQueue:ForkJoinPool 的核心数据结构,本质上是work-stealing 模式的双端任务队列,内部存放 ForkJoinTask 对象任务,使用 @Contented 注解修饰防止伪共享
    • 工作线程在运行中产生新的任务(通常是因为调用了 fork())时,此时可以把 WorkQueue 的数据结构视为一个栈,新的任务会放入栈顶(top 位);工作线程在处理自己工作队列的任务时,按照 LIFO 的顺序。
    • 工作线程在处理自己的工作队列同时,会尝试窃取一个任务(可能是来自于刚刚提交到 pool 的任务,或是来自于其他工作线程的队列任务),此时可以把 WorkQueue 的数据结构视为一个 FIFO 的队列,窃取的任务位于其他线程的工作队列的队首(base位)。
  • 伪共享状态:缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
2、ForkJoinTask继承关系

img

ForkJoinTask 实现了 Future 接口,说明它也是一个可取消的异步运算任务,实际上ForkJoinTask 是 Future 的轻量级实现,主要用在纯粹是计算的函数式任务或者操作完全独立的对象计算任务。fork 是主运行方法,用于异步执行;而 join 方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果。 其内部类都比较简单,ExceptionNode 是用于存储任务执行期间的异常信息的单向链表;其余四个类是为 Runnable/Callable 任务提供的适配器类,用于把 Runnable/Callable 转化为 ForkJoinTask 类型的任务(因为 ForkJoinPool 只可以运行 ForkJoinTask 类型的任务)。

4、Fork/Join框架源码解析

分析思路:在对类层次结构有了解以后,我们先看下内部核心参数,然后分析上述流程图。会分4个部分:

  • 首先介绍任务的提交流程 - 外部任务(external/submissions task)提交;
  • 然后介绍任务的提交流程 - 子任务(Worker task)提交;
  • 再分析任务的执行过程(ForkJoinWorkerThread.run()到ForkJoinTask.doExec()这一部分);
  • 最后介绍任务的结果获取(ForkJoinTask.join()和ForkJoinTask.invoke())
1、ForkJoinPool
1、核心参数

在后面的源码解析中,我们会看到大量的位运算,这些位运算都是通过我们接下来介绍的一些常量参数来计算的。

例如,如果要更新活跃线程数,使用公式(UC_MASK & (c + AC_UNIT)) | (SP_MASK & c);c 代表当前 ctl,UC_MASK 和 SP_MASK 分别是高位和低位掩码,AC_UNIT 为活跃线程的增量数,使用(UC_MASK & (c + AC_UNIT))就可以计算出高32位,然后再加上低32位(SP_MASK & c),就拼接成了一个新的ctl。

这些运算的可读性很差,看起来有些复杂。在后面源码解析中有位运算的地方我都会加上注释,大家只需要了解它们的作用即可。

ForkJoinPool 与 内部类 WorkQueue 共享的一些常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Constants shared across ForkJoinPool and WorkQueue

// 限定参数
static final int SMASK = 0xffff; // 低位掩码,也是最大索引位
static final int MAX_CAP = 0x7fff; // 工作线程最大容量
static final int EVENMASK = 0xfffe; // 偶数低位掩码
static final int SQMASK = 0x007e; // workQueues 数组最多64个槽位

// ctl 子域和 WorkQueue.scanState 的掩码和标志位
static final int SCANNING = 1; // 标记是否正在运行任务
static final int INACTIVE = 1 << 31; // 失活状态 负数
static final int SS_SEQ = 1 << 16; // 版本戳,防止ABA问题

// ForkJoinPool.config 和 WorkQueue.config 的配置信息标记
static final int MODE_MASK = 0xffff << 16; // 模式掩码
static final int LIFO_QUEUE = 0; //LIFO队列
static final int FIFO_QUEUE = 1 << 16;//FIFO队列
static final int SHARED_QUEUE = 1 << 31; // 共享模式队列,负数

ForkJoinPool 中的相关常量和实例字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//  低位和高位掩码
private static final long SP_MASK = 0xffffffffL;
private static final long UC_MASK = ~SP_MASK;

// 活跃线程数
private static final int AC_SHIFT = 48;
private static final long AC_UNIT = 0x0001L << AC_SHIFT; //活跃线程数增量
private static final long AC_MASK = 0xffffL << AC_SHIFT; //活跃线程数掩码

// 工作线程数
private static final int TC_SHIFT = 32;
private static final long TC_UNIT = 0x0001L << TC_SHIFT; //工作线程数增量
private static final long TC_MASK = 0xffffL << TC_SHIFT; //掩码
private static final long ADD_WORKER = 0x0001L << (TC_SHIFT + 15); // 创建工作线程标志

// 池状态
private static final int RSLOCK = 1;
private static final int RSIGNAL = 1 << 1;
private static final int STARTED = 1 << 2;
private static final int STOP = 1 << 29;
private static final int TERMINATED = 1 << 30;
private static final int SHUTDOWN = 1 << 31;

// 实例字段
volatile long ctl; // 主控制参数
volatile int runState; // 运行状态锁
final int config; // 并行度|模式
int indexSeed; // 用于生成工作线程索引
volatile WorkQueue[] workQueues; // 主对象注册信息,workQueue
final ForkJoinWorkerThreadFactory factory;// 线程工厂
final UncaughtExceptionHandler ueh; // 每个工作线程的异常信息
final String workerNamePrefix; // 用于创建工作线程的名称
volatile AtomicLong stealCounter; // 偷取任务总数,也可作为同步监视器

/** 静态初始化字段 */
//线程工厂
public static final ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory;
//启动或杀死线程的方法调用者的权限
private static final RuntimePermission modifyThreadPermission;
// 公共静态pool
static final ForkJoinPool common;
//并行度,对应内部common池
static final int commonParallelism;
//备用线程数,在tryCompensate中使用
private static int commonMaxSpares;
//创建workerNamePrefix(工作线程名称前缀)时的序号
private static int poolNumberSequence;
//线程阻塞等待新的任务的超时值(以纳秒为单位),默认2秒
private static final long IDLE_TIMEOUT = 2000L * 1000L * 1000L; // 2sec
//空闲超时时间,防止timer未命中
private static final long TIMEOUT_SLOP = 20L * 1000L * 1000L; // 20ms
//默认备用线程数
private static final int DEFAULT_COMMON_MAX_SPARES = 256;
//阻塞前自旋的次数,用在在awaitRunStateLock和awaitWork中
private static final int SPINS = 0;
//indexSeed的增量
private static final int SEED_INCREMENT = 0x9e3779b9;

说明:ForkJoinPool 的内部状态都是通过一个64位的 long 型 变量ctl来存储,它由四个16位的子域组成:

  • AC:正在运行工作线程数减去目标并行度,高16位
  • TC:总工作线程数减去目标并行度,中高16位
  • SS:栈顶等待线程的版本计数和状态,中低16位
  • ID:栈顶 WorkQueue 在池中的索引(poolIndex),低16位

在后面的源码解析中,某些地方也提取了ctl的低32位(sp=(int)ctl)来检查工作线程状态,例如,当sp不为0时说明当前还有空闲工作线程。

2、ForkJoinPool.WoekQueue 中的相关属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//初始队列容量,2的幂
static final int INITIAL_QUEUE_CAPACITY = 1 << 13;
//最大队列容量
static final int MAXIMUM_QUEUE_CAPACITY = 1 << 26; // 64M

// 实例字段
volatile int scanState; // Woker状态, <0: inactive; odd:scanning
int stackPred; // 记录前一个栈顶的ctl
int nsteals; // 偷取任务数
int hint; // 记录偷取者索引,初始为随机索引
int config; // 池索引和模式
volatile int qlock; // 1: locked, < 0: terminate; else 0
volatile int base; // 下一个poll操作的索引(栈底/队列头)
int top; // 下一个push操作的索引(栈顶/队列尾)
ForkJoinTask<?>[] array; // 任务数组
final ForkJoinPool pool; // the containing pool (may be null)
final ForkJoinWorkerThread owner; // 当前工作队列的工作线程,共享模式下为null
volatile Thread parker; // 调用park阻塞期间为owner,其他情况为null
volatile ForkJoinTask<?> currentJoin; // 记录被join过来的任务
volatile ForkJoinTask<?> currentSteal; // 记录从其他工作队列偷取过来的任务
2、ForkJoinTask
核心参数
1
2
3
4
5
6
7
8
/** 任务运行状态 */
volatile int status; // 任务运行状态
static final int DONE_MASK = 0xf0000000; // 任务完成状态标志位
static final int NORMAL = 0xf0000000; // must be negative
static final int CANCELLED = 0xc0000000; // must be < NORMAL
static final int EXCEPTIONAL = 0x80000000; // must be < CANCELLED
static final int SIGNAL = 0x00010000; // must be >= 1 << 16 等待信号
static final int SMASK = 0x0000ffff; // 低位掩码

5、Fork/Join框架源码解析

1、构造函数
1
2
3
4
5
6
7
8
9
10
11
public ForkJoinPool(int parallelism,
ForkJoinWorkerThreadFactory factory,
UncaughtExceptionHandler handler,
boolean asyncMode) {
this(checkParallelism(parallelism),
checkFactory(factory),
handler,
asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
"ForkJoinPool-" + nextPoolId() + "-worker-");
checkPermission();
}

说明:在 ForkJoinPool 中我们可以自定义四个参数:

  • parallelism:并行度,默认为CPU数,最小为1
  • factory:工作线程工厂;
  • handler:处理工作线程运行任务时的异常情况类,默认为null;
  • asyncMode:是否为异步模式,默认为 false。如果为true,表示子任务的执行遵循 FIFO 顺序并且任务不能被合并(join),这种模式适用于工作线程只运行事件类型的异步任务

在多数场景使用时,如果没有太强的业务需求,我们一般直接使用 ForkJoinPool 中的common池,在JDK1.8之后提供了ForkJoinPool.commonPool()方法可以直接使用common池,来看一下它的构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static ForkJoinPool makeCommonPool() {
int parallelism = -1;
ForkJoinWorkerThreadFactory factory = null;
UncaughtExceptionHandler handler = null;
try { // ignore exceptions in accessing/parsing
String pp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.parallelism");//并行度
String fp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.threadFactory");//线程工厂
String hp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.exceptionHandler");//异常处理类
if (pp != null)
parallelism = Integer.parseInt(pp);
if (fp != null)
factory = ((ForkJoinWorkerThreadFactory) ClassLoader.
getSystemClassLoader().loadClass(fp).newInstance());
if (hp != null)
handler = ((UncaughtExceptionHandler) ClassLoader.
getSystemClassLoader().loadClass(hp).newInstance());
} catch (Exception ignore) {
}
if (factory == null) {
if (System.getSecurityManager() == null)
factory = defaultForkJoinWorkerThreadFactory;
else // use security-managed default
factory = new InnocuousForkJoinWorkerThreadFactory();
}
if (parallelism < 0 && // default 1 less than #cores
(parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)
parallelism = 1;//默认并行度为1
if (parallelism > MAX_CAP)
parallelism = MAX_CAP;
return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
"ForkJoinPool.commonPool-worker-");
}

使用common pool的优点就是我们可以通过指定系统参数的方式定义“并行度、线程工厂和异常处理类”;并且它使用的是同步模式,也就是说可以支持任务合并(join)。

2、执行流程——外部任务(external/submissions task)提交

向 ForkJoinPool 提交任务有三种方式:

  • invoke()会等待任务计算完毕并返回计算结果;
  • execute()是直接向池提交一个任务来异步执行,无返回结果;
  • submit()也是异步执行,但是会返回提交的任务,在适当的时候可通过task.get()获取执行结果。

这三种提交方式都都是调用externalPush()方法来完成,所以接下来我们将从externalPush()方法开始逐步分析外部任务的执行过程。

1、externalPush(ForkJoinTask<?> task)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//添加给定任务到submission队列中
final void externalPush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue q;
int m;
int r = ThreadLocalRandom.getProbe();//探针值,用于计算WorkQueue槽位索引
int rs = runState;
if ((ws = workQueues) != null && (m = (ws.length - 1)) >= 0 &&
(q = ws[m & r & SQMASK]) != null && r != 0 && rs > 0 && //获取随机偶数槽位的workQueue
U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定workQueue
ForkJoinTask<?>[] a;
int am, n, s;
if ((a = q.array) != null &&
(am = a.length - 1) > (n = (s = q.top) - q.base)) {
int j = ((am & s) << ASHIFT) + ABASE;//计算任务索引位置
U.putOrderedObject(a, j, task);//任务入列
U.putOrderedInt(q, QTOP, s + 1);//更新push slot
U.putIntVolatile(q, QLOCK, 0);//解除锁定
if (n <= 1)
signalWork(ws, q);//任务数小于1时尝试创建或激活一个工作线程
return;
}
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
externalSubmit(task);//初始化workQueues及相关属性
}

首先说明一下externalPush和externalSubmit两个方法的联系:它们的作用都是把任务放到队列中等待执行。不同的是,externalSubmit可以说是完整版的externalPush,在任务首次提交时,需要初始化workQueues及其他相关属性,这个初始化操作就是externalSubmit来完成的;而后再向池中提交的任务都是通过简化版的externalSubmit-externalPush来完成。

externalPush的执行流程很简单:

  1. 首先找到一个随机偶数槽位的 workQueue,
  2. 然后把任务放入这个 workQueue 的任务数组中,并更新top位。
  3. 如果队列的剩余任务数小于1,则尝试创建或激活一个工作线程来运行任务(防止在externalSubmit初始化时发生异常导致工作线程创建失败)。
2、externalSubmit(ForkJoinTask<?> task)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
//任务提交
private void externalSubmit(ForkJoinTask<?> task) {
//初始化调用线程的探针值,用于计算WorkQueue索引
int r; // initialize caller's probe
if ((r = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit();
r = ThreadLocalRandom.getProbe();
}
for (; ; ) {
WorkQueue[] ws;
WorkQueue q;
int rs, m, k;
boolean move = false;
if ((rs = runState) < 0) {// 池已关闭
tryTerminate(false, false); // help terminate
throw new RejectedExecutionException();
}
//初始化workQueues
else if ((rs & STARTED) == 0 || // initialize
((ws = workQueues) == null || (m = ws.length - 1) < 0)) {
int ns = 0;
rs = lockRunState();//锁定runState
try {
//初始化
if ((rs & STARTED) == 0) {
//初始化stealCounter
U.compareAndSwapObject(this, STEALCOUNTER, null,
new AtomicLong());
//创建workQueues,容量为2的幂次方
// create workQueues array with size a power of two
int p = config & SMASK; // ensure at least 2 slots
int n = (p > 1) ? p - 1 : 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
n = (n + 1) << 1;
workQueues = new WorkQueue[n];
ns = STARTED;
}
} finally {
unlockRunState(rs, (rs & ~RSLOCK) | ns);//解锁并更新runState
}
} else if ((q = ws[k = r & m & SQMASK]) != null) {//获取随机偶数槽位的workQueue
if (q.qlock == 0 && U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定 workQueue
ForkJoinTask<?>[] a = q.array;//当前workQueue的全部任务
int s = q.top;
boolean submitted = false; // initial submission or resizing
try { // locked version of push
if ((a != null && a.length > s + 1 - q.base) ||
(a = q.growArray()) != null) {//扩容
int j = (((a.length - 1) & s) << ASHIFT) + ABASE;
U.putOrderedObject(a, j, task);//放入给定任务
U.putOrderedInt(q, QTOP, s + 1);//修改push slot
submitted = true;
}
} finally {
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
if (submitted) {//任务提交成功,创建或激活工作线程
signalWork(ws, q);//创建或激活一个工作线程来运行任务
return;
}
}
move = true; // move on failure 操作失败,重新获取探针值
} else if (((rs = runState) & RSLOCK) == 0) { // create new queue
q = new WorkQueue(this, null);
q.hint = r;
q.config = k | SHARED_QUEUE;
q.scanState = INACTIVE;
rs = lockRunState(); // publish index
if (rs > 0 && (ws = workQueues) != null &&
k < ws.length && ws[k] == null)
ws[k] = q; // 更新索引k位值的workQueue
//else terminated
unlockRunState(rs, rs & ~RSLOCK);
} else
move = true; // move if busy
if (move)
r = ThreadLocalRandom.advanceProbe(r);//重新获取线程探针值
}
}

说明:externalSubmit是externalPush的完整版本,主要用于第一次提交任务时初始化workQueues及相关属性,并且提交给定任务到队列中。具体执行步骤如下:

  • 如果池为终止状态(runState<0),调用tryTerminate来终止线程池,并抛出任务拒绝异常;
  • 如果尚未初始化,就为 FJP 执行初始化操作:初始化stealCounter、创建workerQueues,然后继续自旋;
  • 初始化完成后,执行在externalPush中相同的操作:获取 workQueue,放入指定任务。任务提交成功后调用signalWork方法创建或激活线程;
  • 如果在步骤3中获取到的 workQueue 为null,会在这一步中创建一个 workQueue,创建成功继续自旋执行第三步操作;
  • 如果非上述情况,或者有线程争用资源导致获取锁失败,就重新获取线程探针值继续自旋。
3、signalWork(WorkQueue[] ws, WorkQueue q)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
final void signalWork(WorkQueue[] ws, WorkQueue q) {
long c;
int sp, i;
WorkQueue v;
Thread p;
while ((c = ctl) < 0L) { // too few active
if ((sp = (int) c) == 0) { // no idle workers
if ((c & ADD_WORKER) != 0L) // too few workers
tryAddWorker(c);//工作线程太少,添加新的工作线程
break;
}
if (ws == null) // unstarted/terminated
break;
if (ws.length <= (i = sp & SMASK)) // terminated
break;
if ((v = ws[i]) == null) // terminating
break;
//计算ctl,加上版本戳SS_SEQ避免ABA问题
int vs = (sp + SS_SEQ) & ~INACTIVE; // next scanState
int d = sp - v.scanState; // screen CAS
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & v.stackPred);
if (d == 0 && U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs; // activate v
if ((p = v.parker) != null)
U.unpark(p);//唤醒阻塞线程
break;
}
if (q != null && q.base == q.top) // no more work
break;
}
}

说明:新建或唤醒一个工作线程,在externalPushexternalSubmitworkQueue.pushscan中调用。如果还有空闲线程,则尝试唤醒索引到的 WorkQueue 的parker线程;如果工作线程过少((ctl & ADD_WORKER) != 0L),则调用tryAddWorker添加一个新的工作线程。

4、tryAddWorker(long c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void tryAddWorker(long c) {
boolean add = false;
do {
long nc = ((AC_MASK & (c + AC_UNIT)) |
(TC_MASK & (c + TC_UNIT)));
if (ctl == c) {
int rs, stop; // check if terminating
if ((stop = (rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);
unlockRunState(rs, rs & ~RSLOCK);//释放锁
if (stop != 0)
break;
if (add) {
createWorker();//创建工作线程
break;
}
}
} while (((c = ctl) & ADD_WORKER) != 0L && (int)c == 0);
}

说明:尝试添加一个新的工作线程,首先更新ctl中的工作线程数,然后调用createWorker()创建工作线程。

5、createWorker()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private boolean createWorker() {
ForkJoinWorkerThreadFactory fac = factory;
Throwable ex = null;
ForkJoinWorkerThread wt = null;
try {
if (fac != null && (wt = fac.newThread(this)) != null) {
wt.start();
return true;
}
} catch (Throwable rex) {
ex = rex;
}
deregisterWorker(wt, ex);//线程创建失败处理
return false;
}

说明:createWorker首先通过线程工厂创一个新的ForkJoinWorkerThread,然后启动这个工作线程(wt.start())。如果期间发生异常,调用deregisterWorker处理线程创建失败的逻辑(deregisterWorker在后面再详细说明)。

ForkJoinWorkerThread 的构造函数如下:

1
2
3
4
5
6
protected ForkJoinWorkerThread(ForkJoinPool pool) {
// Use a placeholder until a useful name can be set in registerWorker
super("aForkJoinWorkerThread");
this.pool = pool;
this.workQueue = pool.registerWorker(this);
}

可以看到 ForkJoinWorkerThread 在构造时首先调用父类 Thread 的方法,然后为工作线程注册pool和workQueue,而workQueue的注册任务由ForkJoinPool.registerWorker来完成。

6、registerWorker()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
final WorkQueue registerWorker(ForkJoinWorkerThread wt) {
UncaughtExceptionHandler handler;
//设置为守护线程
wt.setDaemon(true); // configure thread
if ((handler = ueh) != null)
wt.setUncaughtExceptionHandler(handler);
WorkQueue w = new WorkQueue(this, wt);//构造新的WorkQueue
int i = 0; // assign a pool index
int mode = config & MODE_MASK;
int rs = lockRunState();
try {
WorkQueue[] ws;
int n; // skip if no array
if ((ws = workQueues) != null && (n = ws.length) > 0) {
//生成新建WorkQueue的索引
int s = indexSeed += SEED_INCREMENT; // unlikely to collide
int m = n - 1;
i = ((s << 1) | 1) & m; // Worker任务放在奇数索引位 odd-numbered indices
if (ws[i] != null) { // collision 已存在,重新计算索引位
int probes = 0; // step by approx half n
int step = (n <= 4) ? 2 : ((n >>> 1) & EVENMASK) + 2;
//查找可用的索引位
while (ws[i = (i + step) & m] != null) {
if (++probes >= n) {//所有索引位都被占用,对workQueues进行扩容
workQueues = ws = Arrays.copyOf(ws, n <<= 1);//workQueues 扩容
m = n - 1;
probes = 0;
}
}
}
w.hint = s; // use as random seed
w.config = i | mode;
w.scanState = i; // publication fence
ws[i] = w;
}
} finally {
unlockRunState(rs, rs & ~RSLOCK);
}
wt.setName(workerNamePrefix.concat(Integer.toString(i >>> 1)));
return w;
}

说明:registerWorker是 ForkJoinWorkerThread 构造器的回调函数,用于创建和记录工作线程的 WorkQueue。比较简单,就不多赘述了。注意在此为工作线程创建的 WorkQueue 是放在奇数索引的(代码行: i = ((s << 1) | 1) & m;)

7、小结

OK,外部任务的提交流程就先讲到这里。在createWorker()中启动工作线程后(wt.start()),当为线程分配到CPU执行时间片之后会运行 ForkJoinWorkerThread 的run方法开启线程来执行任务。工作线程执行任务的流程我们在讲完内部任务提交之后会统一讲解。

3、执行流程:子任务(Worker task)提交

子任务的提交相对比较简单,由任务的fork()方法完成。通过上面的流程图可以看到任务被分割(fork)之后调用了ForkJoinPool.WorkQueue.push()方法直接把任务放到队列中等待被执行。

1、ForkJoinTask.fork()
1
2
3
4
5
6
7
8
public final ForkJoinTask<V> fork() {
Thread t;
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
ForkJoinPool.common.externalPush(this);
return this;
}

说明:如果当前线程是 Worker 线程,说明当前任务是fork分割的子任务,通过ForkJoinPool.workQueue.push()方法直接把任务放到自己的等待队列中;否则调用ForkJoinPool.externalPush()提交到一个随机的等待队列中(外部任务)。

2、ForkJoiPool.WorkQueue.push()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final void push(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
ForkJoinPool p;
int b = base, s = top, n;
if ((a = array) != null) { // ignore if queue removed
int m = a.length - 1; // fenced write for task visibility
U.putOrderedObject(a, ((m & s) << ASHIFT) + ABASE, task);
U.putOrderedInt(this, QTOP, s + 1);
if ((n = s - b) <= 1) {//首次提交,创建或唤醒一个工作线程
if ((p = pool) != null)
p.signalWork(p.workQueues, this);
} else if (n >= m)
growArray();
}
}

说明:首先把任务放入等待队列并更新top位;如果当前 WorkQueue 为新建的等待队列(top-base<=1),则调用signalWork方法为当前 WorkQueue 新建或唤醒一个工作线程;如果 WorkQueue 中的任务数组容量过小,则调用growArray()方法对其进行==两倍==扩容,growArray()方法源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final ForkJoinTask<?>[] growArray() {
ForkJoinTask<?>[] oldA = array;//获取内部任务列表
int size = oldA != null ? oldA.length << 1 : INITIAL_QUEUE_CAPACITY;
if (size > MAXIMUM_QUEUE_CAPACITY)
throw new RejectedExecutionException("Queue capacity exceeded");
int oldMask, t, b;
//新建一个两倍容量的任务数组
ForkJoinTask<?>[] a = array = new ForkJoinTask<?>[size];
if (oldA != null && (oldMask = oldA.length - 1) >= 0 &&
(t = top) - (b = base) > 0) {
int mask = size - 1;
//从老数组中拿出数据,放到新的数组中
do { // emulate poll from old array, push to new array
ForkJoinTask<?> x;
int oldj = ((b & oldMask) << ASHIFT) + ABASE;
int j = ((b & mask) << ASHIFT) + ABASE;
x = (ForkJoinTask<?>) U.getObjectVolatile(oldA, oldj);
if (x != null &&
U.compareAndSwapObject(oldA, oldj, x, null))
U.putObjectVolatile(a, j, x);
} while (++b != t);
}
return a;
}
3、小结

到此,两种任务的提交流程都已经解析完毕,下一节我们来一起看看任务提交之后是如何被运行的。

4、执行流程:任务执行

回到我们开始时的流程图,在ForkJoinPool .createWorker()方法中创建工作线程后,会启动工作线程,系统为工作线程分配到CPU执行时间片之后会执行 ForkJoinWorkerThread 的run()方法正式开始执行任务。

1、ForkJoinWorkerThread.run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void run() {
if (workQueue.array == null) { // only run once
Throwable exception = null;
try {
onStart();//钩子方法,可自定义扩展
pool.runWorker(workQueue);
} catch (Throwable ex) {
exception = ex;
} finally {
try {
onTermination(exception);//钩子方法,可自定义扩展
} catch (Throwable ex) {
if (exception == null)
exception = ex;
} finally {
pool.deregisterWorker(this, exception);//处理异常
}
}
}
}

说明:方法很简单,在工作线程运行前后会调用自定义钩子函数(onStartonTermination),任务的运行则是调用了ForkJoinPool.runWorker()。如果全部任务执行完毕或者期间遭遇异常,则通过ForkJoinPool.deregisterWorker关闭工作线程并处理异常信息(deregisterWorker方法我们后面会详细讲解)。

2、ForkJoinPool.runWorker(WorkerQueue w)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
final void runWorker(WorkQueue w) {
w.growArray(); // allocate queue
int seed = w.hint; // initially holds randomization hint
int r = (seed == 0) ? 1 : seed; // avoid 0 for xorShift
for (ForkJoinTask<?> t; ; ) {
if ((t = scan(w, r)) != null)//扫描任务执行
w.runTask(t);
else if (!awaitWork(w, r))
break;
r ^= r << 13;
r ^= r >>> 17;
r ^= r << 5; // xorshift
}
}

说明:runWorker是 ForkJoinWorkerThread 的主运行方法,用来依次执行当前工作线程中的任务。

函数流程很简单:调用scan方法依次获取任务,然后调用WorkQueue .runTask运行任务;如果未扫描到任务,则调用awaitWork等待,直到工作线程/线程池终止或等待超时。

3、ForkJoinPool.scan(WorkQueue w, int r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
private ForkJoinTask<?> scan(WorkQueue w, int r) {
WorkQueue[] ws;
int m;
if ((ws = workQueues) != null && (m = ws.length - 1) > 0 && w != null) {
int ss = w.scanState; // initially non-negative
//初始扫描起点,自旋扫描
for (int origin = r & m, k = origin, oldSum = 0, checkSum = 0; ; ) {
WorkQueue q;
ForkJoinTask<?>[] a;
ForkJoinTask<?> t;
int b, n;
long c;
if ((q = ws[k]) != null) {//获取workQueue
if ((n = (b = q.base) - q.top) < 0 &&
(a = q.array) != null) { // non-empty
//计算偏移量
long i = (((a.length - 1) & b) << ASHIFT) + ABASE;
if ((t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i))) != null && //取base位置任务
q.base == b) {//stable
if (ss >= 0) { //scanning
if (U.compareAndSwapObject(a, i, t, null)) {//
q.base = b + 1;//更新base位
if (n < -1) // signal others
signalWork(ws, q);//创建或唤醒工作线程来运行任务
return t;
}
} else if (oldSum == 0 && // try to activate 尝试激活工作线程
w.scanState < 0)
tryRelease(c = ctl, ws[m & (int) c], AC_UNIT);//唤醒栈顶工作线程
}
//base位置任务为空或base位置偏移,随机移位重新扫描
if (ss < 0) // refresh
ss = w.scanState;
r ^= r << 1;
r ^= r >>> 3;
r ^= r << 10;
origin = k = r & m; // move and rescan
oldSum = checkSum = 0;
continue;
}
checkSum += b;//队列任务为空,记录base位
}
//更新索引k 继续向后查找
if ((k = (k + 1) & m) == origin) { // continue until stable
//运行到这里说明已经扫描了全部的 workQueues,但并未扫描到任务

if ((ss >= 0 || (ss == (ss = w.scanState))) &&
oldSum == (oldSum = checkSum)) {
if (ss < 0 || w.qlock < 0) // already inactive
break;// 已经被灭活或终止,跳出循环

//对当前WorkQueue进行灭活操作
int ns = ss | INACTIVE; // try to inactivate
long nc = ((SP_MASK & ns) |
(UC_MASK & ((c = ctl) - AC_UNIT)));//计算ctl为INACTIVE状态并减少活跃线程数
w.stackPred = (int) c; // hold prev stack top
U.putInt(w, QSCANSTATE, ns);//修改scanState为inactive状态
if (U.compareAndSwapLong(this, CTL, c, nc))//更新scanState为灭活状态
ss = ns;
else
w.scanState = ss; // back out
}
checkSum = 0;//重置checkSum,继续循环
}
}
}
return null;
}

说明:扫描并尝试偷取一个任务。使用w.hint进行随机索引 WorkQueue,也就是说并不一定会执行当前 WorkQueue 中的任务,而是偷取别的Worker的任务来执行。

函数的大概执行流程如下:

  • 取随机位置的一个 WorkQueue;
  • 获取base位的 ForkJoinTask,成功取到后更新base位并返回任务;如果取到的 WorkQueue 中任务数大于1,则调用signalWork创建或唤醒其他工作线程;
  • 如果当前工作线程处于不活跃状态(INACTIVE),则调用tryRelease尝试唤醒栈顶工作线程来执行。

tryRelease源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private boolean tryRelease(long c, WorkQueue v, long inc) {
int sp = (int) c, vs = (sp + SS_SEQ) & ~INACTIVE;
Thread p;
//ctl低32位等于scanState,说明可以唤醒parker线程
if (v != null && v.scanState == sp) { // v is at top of stack
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + inc)) | (SP_MASK & v.stackPred);
if (U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs;
if ((p = v.parker) != null)
U.unpark(p);//唤醒线程
return true;
}
}
return false;
}
  • 如果base位任务为空或发生偏移,则对索引位进行随机移位,然后重新扫描;
  • 如果扫描整个workQueues之后没有获取到任务,则设置当前工作线程为INACTIVE状态;然后重置checkSum,再次扫描一圈之后如果还没有任务则跳出循环返回null。
4、ForkJoinPool.awaitWork(WorkQueue w, int r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
private boolean awaitWork(WorkQueue w, int r) {
if (w == null || w.qlock < 0) // w is terminating
return false;
for (int pred = w.stackPred, spins = SPINS, ss; ; ) {
if ((ss = w.scanState) >= 0)//正在扫描,跳出循环
break;
else if (spins > 0) {
r ^= r << 6;
r ^= r >>> 21;
r ^= r << 7;
if (r >= 0 && --spins == 0) { // randomize spins
WorkQueue v;
WorkQueue[] ws;
int s, j;
AtomicLong sc;
if (pred != 0 && (ws = workQueues) != null &&
(j = pred & SMASK) < ws.length &&
(v = ws[j]) != null && // see if pred parking
(v.parker == null || v.scanState >= 0))
spins = SPINS; // continue spinning
}
} else if (w.qlock < 0) // 当前workQueue已经终止,返回false recheck after spins
return false;
else if (!Thread.interrupted()) {//判断线程是否被中断,并清除中断状态
long c, prevctl, parkTime, deadline;
int ac = (int) ((c = ctl) >> AC_SHIFT) + (config & SMASK);//活跃线程数
if ((ac <= 0 && tryTerminate(false, false)) || //无active线程,尝试终止
(runState & STOP) != 0) // pool terminating
return false;
if (ac <= 0 && ss == (int) c) { // is last waiter
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
prevctl = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & pred);
int t = (short) (c >>> TC_SHIFT); // shrink excess spares
if (t > 2 && U.compareAndSwapLong(this, CTL, c, prevctl))//总线程过量
return false; // else use timed wait
//计算空闲超时时间
parkTime = IDLE_TIMEOUT * ((t >= 0) ? 1 : 1 - t);
deadline = System.nanoTime() + parkTime - TIMEOUT_SLOP;
} else
prevctl = parkTime = deadline = 0L;
Thread wt = Thread.currentThread();
U.putObject(wt, PARKBLOCKER, this); // emulate LockSupport
w.parker = wt;//设置parker,准备阻塞
if (w.scanState < 0 && ctl == c) // recheck before park
U.park(false, parkTime);//阻塞指定的时间

U.putOrderedObject(w, QPARKER, null);
U.putObject(wt, PARKBLOCKER, null);
if (w.scanState >= 0)//正在扫描,说明等到任务,跳出循环
break;
if (parkTime != 0L && ctl == c &&
deadline - System.nanoTime() <= 0L &&
U.compareAndSwapLong(this, CTL, c, prevctl))//未等到任务,更新ctl,返回false
return false; // shrink pool
}
}
return true;
}

说明:回到runWorker方法,如果scan方法未扫描到任务,会调用awaitWork等待获取任务。函数的具体执行流程大家看源码,这里简单说一下:

  • 在等待获取任务期间,如果工作线程或线程池已经终止则直接返回false。
  • 如果当前无 active 线程,尝试终止线程池并返回false,如果终止失败并且当前是最后一个等待的 Worker,就阻塞指定的时间(IDLE_TIMEOUT);
  • 等到届期或被唤醒后如果发现自己是scanning(scanState >= 0)状态,说明已经等到任务,跳出等待返回true继续 scan,否则的更新ctl并返回false。
5、WorkQueue.runTask()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
final void runTask(ForkJoinTask<?> task) {
if (task != null) {
scanState &= ~SCANNING; // mark as busy
(currentSteal = task).doExec();//更新currentSteal并执行任务
U.putOrderedObject(this, QCURRENTSTEAL, null); // release for GC
execLocalTasks();//依次执行本地任务
ForkJoinWorkerThread thread = owner;
if (++nsteals < 0) // collect on overflow
transferStealCount(pool);//增加偷取任务数
scanState |= SCANNING;
if (thread != null)
thread.afterTopLevelExec();//执行钩子函数
}
}

说明:在scan方法扫描到任务之后,调用WorkQueue.runTask()来执行获取到的任务,大概流程如下:

  • 标记scanState为正在执行状态;
  • 更新currentSteal为当前获取到的任务并执行它,任务的执行调用了ForkJoinTask.doExec()方法,

ForkJoinTask.doExec()方法的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//ForkJoinTask.doExec()
final int doExec() {
int s; boolean completed;
if ((s = status) >= 0) {
try {
completed = exec();//执行我们定义的任务
} catch (Throwable rex) {
return setExceptionalCompletion(rex);
}
if (completed)
s = setCompletion(NORMAL);
}
return s;
}

调用execLocalTasks依次执行当前WorkerQueue中的任务,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//执行并移除所有本地任务
final void execLocalTasks() {
int b = base, m, s;
ForkJoinTask<?>[] a = array;
if (b - (s = top - 1) <= 0 && a != null &&
(m = a.length - 1) >= 0) {
if ((config & FIFO_QUEUE) == 0) {//FIFO模式
for (ForkJoinTask<?> t; ; ) {
if ((t = (ForkJoinTask<?>) U.getAndSetObject
(a, ((m & s) << ASHIFT) + ABASE, null)) == null)//FIFO执行,取top任务
break;
U.putOrderedInt(this, QTOP, s);
t.doExec();//执行
if (base - (s = top - 1) > 0)
break;
}
} else
pollAndExecAll();//LIFO模式执行,取base任务
}
}
  • 更新偷取任务数;
  • 还原scanState并执行钩子函数。
6、ForkJoinPool.deregisterWorker(ForkJoinWorkerThread wt, Throwable ex)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
final void deregisterWorker(ForkJoinWorkerThread wt, Throwable ex) {
WorkQueue w = null;
//1.移除workQueue
if (wt != null && (w = wt.workQueue) != null) {//获取ForkJoinWorkerThread的等待队列
WorkQueue[] ws; // remove index from array
int idx = w.config & SMASK;//计算workQueue索引
int rs = lockRunState();//获取runState锁和当前池运行状态
if ((ws = workQueues) != null && ws.length > idx && ws[idx] == w)
ws[idx] = null;//移除workQueue
unlockRunState(rs, rs & ~RSLOCK);//解除runState锁
}
//2.减少CTL数
long c; // decrement counts
do {} while (!U.compareAndSwapLong
(this, CTL, c = ctl, ((AC_MASK & (c - AC_UNIT)) |
(TC_MASK & (c - TC_UNIT)) |
(SP_MASK & c))));
//3.处理被移除workQueue内部相关参数
if (w != null) {
w.qlock = -1; // ensure set
w.transferStealCount(this);
w.cancelAll(); // cancel remaining tasks
}
//4.如果线程未终止,替换被移除的workQueue并唤醒内部线程
for (;;) { // possibly replace
WorkQueue[] ws; int m, sp;
//尝试终止线程池
if (tryTerminate(false, false) || w == null || w.array == null ||
(runState & STOP) != 0 || (ws = workQueues) == null ||
(m = ws.length - 1) < 0) // already terminating
break;
//唤醒被替换的线程,依赖于下一步
if ((sp = (int)(c = ctl)) != 0) { // wake up replacement
if (tryRelease(c, ws[sp & m], AC_UNIT))
break;
}
//创建工作线程替换
else if (ex != null && (c & ADD_WORKER) != 0L) {
tryAddWorker(c); // create replacement
break;
}
else // don't need replacement
break;
}
//5.处理异常
if (ex == null) // help clean on way out
ForkJoinTask.helpExpungeStaleExceptions();
else // rethrow
ForkJoinTask.rethrow(ex);
}

说明:deregisterWorker方法用于工作线程运行完毕之后终止线程或处理工作线程异常,主要就是清除已关闭的工作线程或回滚创建线程之前的操作,并把传入的异常抛给 ForkJoinTask 来处理

7、小结

以上我们对任务的执行流程进行了说明,后面我们将继续介绍任务的结果获取(join/invoke)。

5、获取任务结果——ForkJoinTask.join()/ForkJoinTask.invoke()
  • join()

    • //合并任务结果
      public final V join() {
          int s;
          if ((s = doJoin() & DONE_MASK) != NORMAL)
              reportException(s);
          return getRawResult();
      }
      
      //join, get, quietlyJoin的主实现方法
      private int doJoin() {
          int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
          return (s = status) < 0 ? s :
          ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
              (w = (wt = (ForkJoinWorkerThread)t).workQueue).
              tryUnpush(this) && (s = doExec()) < 0 ? s :
          wt.pool.awaitJoin(w, this, 0L) :
          externalAwaitDone();
      }
      
      final int doExec() {
          int s; boolean completed; 
          if ((s = status) >= 0) {
              try {
                  completed = exec(); 
              } catch (Throwable rex) {
                  return setExceptionalCompletion(rex); 
              }
              if (completed) 
                  s = setCompletion(NORMAL); 
          }
          return s; 
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
          
      - 它首先调用 doJoin 方法,通过 doJoin()方法得到当前任务的状态来判断返回什么结果,任务状态有 4 种: ==已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常(EXCEPTIONAL)==

      - 如果任务状态是已完成,则直接返回任务结果。
      - 如果任务状态是被取消,则直接抛出 CancellationException
      - 如果任务状态是抛出异常,则直接抛出对应的异常

      - 在 doJoin()方法流程如下:

      1. 首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;
      2. 如果没有执行完,则从任务数组里取出任务并执行。
      3. 如果任务顺利执行完成,则设置任务状态为 NORMAL,如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL。

      - invoke()

      - ```java
      //执行任务,并等待任务完成并返回结果
      public final V invoke() {
      int s;
      if ((s = doInvoke() & DONE_MASK) != NORMAL)
      reportException(s);
      return getRawResult();
      }

      //invoke, quietlyInvoke的主实现方法
      private int doInvoke() {
      int s; Thread t; ForkJoinWorkerThread wt;
      return (s = doExec()) < 0 ? s :
      ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
      (wt = (ForkJoinWorkerThread)t).pool.
      awaitJoin(wt.workQueue, this, 0L) :
      externalAwaitDone();
      }

说明:join()方法一把是在任务fork()之后调用,用来获取(或者叫“合并”)任务的执行结果。

ForkJoinTask的join()和invoke()方法都可以用来获取任务的执行结果(另外还有get方法也是调用了doJoin来获取任务结果,但是会响应运行时异常),它们对外部提交任务的执行方式一致,都是通过externalAwaitDone方法等待执行结果。

不同的是invoke()方法会直接执行当前任务;而join()方法则是在当前任务在队列 top 位时(通过tryUnpush方法判断)才能执行,如果当前任务不在 top 位或者任务执行失败调用ForkJoinPool.awaitJoin方法帮助执行或阻塞当前 join 任务。(所以在官方文档中建议了我们对ForkJoinTask任务的调用顺序,一对 fork-join操作一般按照如下顺序调用:a.fork(); b.fork(); b.join(); a.join();。因为任务 b 是后面进入队列,也就是说它是在栈顶的(top 位),在它fork()之后直接调用join()就可以直接执行而不会调用ForkJoinPool.awaitJoin方法去等待。)

在这些方法中,join()相对比较全面,所以之后的讲解我们将从join()开始逐步向下分析,首先看一下join()的执行流程:

img

后面的源码分析中,我们首先讲解比较简单的外部 join 任务(externalAwaitDone),然后再讲解内部 join 任务(从ForkJoinPool.awaitJoin()开始)。

1、ForkJoinTask.externalAwaitDone()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private int externalAwaitDone() {
//执行任务
int s = ((this instanceof CountedCompleter) ? // try helping
ForkJoinPool.common.externalHelpComplete( // CountedCompleter任务
(CountedCompleter<?>)this, 0) :
ForkJoinPool.common.tryExternalUnpush(this) ? doExec() : 0); // ForkJoinTask任务
if (s >= 0 && (s = status) >= 0) {//执行失败,进入等待
boolean interrupted = false;
do {
if (U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) { //更新state
synchronized (this) {
if (status >= 0) {//SIGNAL 等待信号
try {
wait(0L);
} catch (InterruptedException ie) {
interrupted = true;
}
}
else
notifyAll();
}
}
} while ((s = status) >= 0);
if (interrupted)
Thread.currentThread().interrupt();
}
return s;
}

说明:如果当前join为外部调用,则调用此方法执行任务,如果任务执行失败就进入等待。方法本身是很简单的,需要注意的是对不同的任务类型分两种情况

  • 如果我们的任务为 CountedCompleter 类型的任务,则调用externalHelpComplete方法来执行任务。
  • 其他类型的 ForkJoinTask 任务调用tryExternalUnpush来执行

tryExternalUnpush的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//为外部提交者提供 tryUnpush 功能(给定任务在top位时弹出任务)
final boolean tryExternalUnpush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue w;
ForkJoinTask<?>[] a;
int m, s;
int r = ThreadLocalRandom.getProbe();
if ((ws = workQueues) != null && (m = ws.length - 1) >= 0 &&
(w = ws[m & r & SQMASK]) != null &&
(a = w.array) != null && (s = w.top) != w.base) {
long j = (((a.length - 1) & (s - 1)) << ASHIFT) + ABASE; //取top位任务
if (U.compareAndSwapInt(w, QLOCK, 0, 1)) { //加锁
if (w.top == s && w.array == a &&
U.getObject(a, j) == task &&
U.compareAndSwapObject(a, j, task, null)) { //符合条件,弹出
U.putOrderedInt(w, QTOP, s - 1); //更新top
U.putOrderedInt(w, QLOCK, 0); //解锁,返回true
return true;
}
U.compareAndSwapInt(w, QLOCK, 1, 0); //当前任务不在top位,解锁返回false
}
}
return false;
}

tryExternalUnpush的作用就是判断当前任务是否在top位,如果是则弹出任务,然后在externalAwaitDone中调用doExec()执行任务

2、ForkJoinPool.awaitJoin()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
final int awaitJoin(WorkQueue w, ForkJoinTask<?> task, long deadline) {
int s = 0;
if (task != null && w != null) {
ForkJoinTask<?> prevJoin = w.currentJoin; //获取给定Worker的join任务
U.putOrderedObject(w, QCURRENTJOIN, task); //把currentJoin替换为给定任务
//判断是否为CountedCompleter类型的任务
CountedCompleter<?> cc = (task instanceof CountedCompleter) ?
(CountedCompleter<?>) task : null;
for (; ; ) {
if ((s = task.status) < 0) //已经完成|取消|异常 跳出循环
break;

if (cc != null)//CountedCompleter任务由helpComplete来完成join
helpComplete(w, cc, 0);
else if (w.base == w.top || w.tryRemoveAndExec(task)) //尝试执行
helpStealer(w, task); //队列为空或执行失败,任务可能被偷,帮助偷取者执行该任务

if ((s = task.status) < 0) //已经完成|取消|异常,跳出循环
break;
//计算任务等待时间
long ms, ns;
if (deadline == 0L)
ms = 0L;
else if ((ns = deadline - System.nanoTime()) <= 0L)
break;
else if ((ms = TimeUnit.NANOSECONDS.toMillis(ns)) <= 0L)
ms = 1L;

if (tryCompensate(w)) {//执行补偿操作
task.internalWait(ms);//补偿执行成功,任务等待指定时间
U.getAndAddLong(this, CTL, AC_UNIT);//更新活跃线程数
}
}
U.putOrderedObject(w, QCURRENTJOIN, prevJoin);//循环结束,替换为原来的join任务
}
return s;
}

说明:如果当前 join 任务不在Worker等待队列的top位,或者任务执行失败,调用此方法来帮助执行或阻塞当前 join 的任务。

函数执行流程如下:

  • 由于每次调用awaitJoin都会优先执行当前join的任务,所以首先会更新currentJoin为当前join任务;
  • 进入自旋:
    • 首先检查任务是否已经完成(通过task.status < 0判断),如果给定任务执行完毕|取消|异常,则跳出循环返回执行状态s;
    • 如果是 CountedCompleter 任务类型,调用helpComplete方法来完成join操作(后面笔者会开新篇来专门讲解CountedCompleter,本篇暂时不做详细解析);
    • 非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务;
    • 如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务);
    • 再次判断任务是否执行完毕(task.status < 0),如果任务执行失败,计算一个等待时间准备进行补偿操作;
    • 调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。在执行补偿期间,如果发现资源争用|池处于unstable状态|当前Worker已终止,则调用ForkJoinTask.internalWait()方法等待指定的时间,任务唤醒之后继续自旋。

ForkJoinTask.internalWait()源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
final void internalWait(long timeout) {
int s;
if ((s = status) >= 0 && // force completer to issue notify
U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) {//更新任务状态为SIGNAL(等待唤醒)
synchronized (this) {
if (status >= 0)
try { wait(timeout); } catch (InterruptedException ie) { }
else
notifyAll();
}
}
}

在awaitJoin中,我们总共调用了三个比较复杂的方法:tryRemoveAndExechelpStealertryCompensate,下面我们依次讲解。

3、WorkQueue.tryRemoveAndExec(ForkJoinTask<?> task)

非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
final boolean tryRemoveAndExec(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
int m, s, b, n;
if ((a = array) != null && (m = a.length - 1) >= 0 &&
task != null) {
while ((n = (s = top) - (b = base)) > 0) {
//从top往下自旋查找
for (ForkJoinTask<?> t; ; ) { // traverse from s to b
long j = ((--s & m) << ASHIFT) + ABASE;//计算任务索引
if ((t = (ForkJoinTask<?>) U.getObject(a, j)) == null) //获取索引到的任务
return s + 1 == top; // shorter than expected
else if (t == task) { //给定任务为索引任务
boolean removed = false;
if (s + 1 == top) { // pop
if (U.compareAndSwapObject(a, j, task, null)) { //弹出任务
U.putOrderedInt(this, QTOP, s); //更新top
removed = true;
}
} else if (base == b) // replace with proxy
removed = U.compareAndSwapObject(
a, j, task, new EmptyTask()); //join任务已经被移除,替换为一个占位任务
if (removed)
task.doExec(); //执行
break;
} else if (t.status < 0 && s + 1 == top) { //给定任务不是top任务
if (U.compareAndSwapObject(a, j, t, null)) //弹出任务
U.putOrderedInt(this, QTOP, s);//更新top
break; // was cancelled
}
if (--n == 0) //遍历结束
return false;
}
if (task.status < 0) //任务执行完毕
return false;
}
}
return true;
}

说明:从top位开始自旋向下找到给定任务,如果找到把它从当前 Worker 的任务队列中移除并执行它。

注意返回的参数:如果任务队列为空或者任务未执行完毕返回true;任务执行完毕返回false。

4、ForkJoinPool.helpStealer(WorkQueue w, ForkJoinTask<?> task)

如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
private void helpStealer(WorkQueue w, ForkJoinTask<?> task) {
WorkQueue[] ws = workQueues;
int oldSum = 0, checkSum, m;
if (ws != null && (m = ws.length - 1) >= 0 && w != null &&
task != null) {
do { // restart point
checkSum = 0; // for stability check
ForkJoinTask<?> subtask;
WorkQueue j = w, v; // v is subtask stealer
descent:
for (subtask = task; subtask.status >= 0; ) {
//1. 找到给定WorkQueue的偷取者v
for (int h = j.hint | 1, k = 0, i; ; k += 2) {//跳两个索引,因为Worker在奇数索引位
if (k > m) // can't find stealer
break descent;
if ((v = ws[i = (h + k) & m]) != null) {
if (v.currentSteal == subtask) {//定位到偷取者
j.hint = i;//更新stealer索引
break;
}
checkSum += v.base;
}
}
//2. 帮助偷取者v执行任务
for (; ; ) { // help v or descend
ForkJoinTask<?>[] a; //偷取者内部的任务
int b;
checkSum += (b = v.base);
ForkJoinTask<?> next = v.currentJoin;//获取偷取者的join任务
if (subtask.status < 0 || j.currentJoin != subtask ||
v.currentSteal != subtask) // stale
break descent; // stale,跳出descent循环重来
if (b - v.top >= 0 || (a = v.array) == null) {
if ((subtask = next) == null) //偷取者的join任务为null,跳出descent循环
break descent;
j = v;
break; //偷取者内部任务为空,可能任务也被偷走了;跳出本次循环,查找偷取者的偷取者
}
int i = (((a.length - 1) & b) << ASHIFT) + ABASE;//获取base偏移地址
ForkJoinTask<?> t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i));//获取偷取者的base任务
if (v.base == b) {
if (t == null) // stale
break descent; // stale,跳出descent循环重来
if (U.compareAndSwapObject(a, i, t, null)) {//弹出任务
v.base = b + 1; //更新偷取者的base位
ForkJoinTask<?> ps = w.currentSteal;//获取调用者偷来的任务
int top = w.top;
//首先更新给定workQueue的currentSteal为偷取者的base任务,然后执行该任务
//然后通过检查top来判断给定workQueue是否有自己的任务,如果有,
// 则依次弹出任务(LIFO)->更新currentSteal->执行该任务(注意这里是自己偷自己的任务执行)
do {
U.putOrderedObject(w, QCURRENTSTEAL, t);
t.doExec(); // clear local tasks too
} while (task.status >= 0 &&
w.top != top && //内部有自己的任务,依次弹出执行
(t = w.pop()) != null);
U.putOrderedObject(w, QCURRENTSTEAL, ps);//还原给定workQueue的currentSteal
if (w.base != w.top)//给定workQueue有自己的任务了,帮助结束,返回
return; // can't further help
}
}
}
}
} while (task.status >= 0 && oldSum != (oldSum = checkSum));
}
}

说明:如果队列为空或任务执行失败,说明任务可能被偷,调用此方法来帮助偷取者执行任务。

基本思想是:偷取者帮助我执行任务,我去帮助偷取者执行它的任务。 函数执行流程如下:

  1. 循环定位偷取者,由于Worker是在奇数索引位,所以每次会跳两个索引位。
  2. 定位到偷取者之后,更新调用者 WorkQueue 的hint为偷取者的索引,方便下次定位;
  3. 定位到偷取者后,开始帮助偷取者执行任务。从偷取者的base索引开始,每次偷取一个任务执行。
  4. 在帮助偷取者执行任务后,如果调用者发现本身已经有任务(w.top != top),则依次弹出自己的任务(LIFO顺序)并执行(也就是说自己偷自己的任务执行)。
5、ForkJoinPool.tryCompensate(WorkQueue w)

调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//执行补偿操作: 尝试缩减活动线程量,可能释放或创建一个补偿线程来准备阻塞
private boolean tryCompensate(WorkQueue w) {
boolean canBlock;
WorkQueue[] ws;
long c;
int m, pc, sp;
if (w == null || w.qlock < 0 || // caller terminating
(ws = workQueues) == null || (m = ws.length - 1) <= 0 ||
(pc = config & SMASK) == 0) // parallelism disabled
canBlock = false; //调用者已终止
else if ((sp = (int) (c = ctl)) != 0) // release idle worker
canBlock = tryRelease(c, ws[sp & m], 0L);//唤醒等待的工作线程
else {//没有空闲线程
int ac = (int) (c >> AC_SHIFT) + pc; //活跃线程数
int tc = (short) (c >> TC_SHIFT) + pc;//总线程数
int nbusy = 0; // validate saturation
for (int i = 0; i <= m; ++i) { // two passes of odd indices
WorkQueue v;
if ((v = ws[((i << 1) | 1) & m]) != null) {//取奇数索引位
if ((v.scanState & SCANNING) != 0)//没有正在运行任务,跳出
break;
++nbusy;//正在运行任务,添加标记
}
}
if (nbusy != (tc << 1) || ctl != c)
canBlock = false; // unstable or stale
else if (tc >= pc && ac > 1 && w.isEmpty()) {//总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空,不需要补偿
long nc = ((AC_MASK & (c - AC_UNIT)) |
(~AC_MASK & c)); // uncompensated
canBlock = U.compareAndSwapLong(this, CTL, c, nc);//更新活跃线程数
} else if (tc >= MAX_CAP ||
(this == common && tc >= pc + commonMaxSpares))//超出最大线程数
throw new RejectedExecutionException(
"Thread limit exceeded replacing blocked worker");
else { // similar to tryAddWorker
boolean add = false;
int rs; // CAS within lock
long nc = ((AC_MASK & c) |
(TC_MASK & (c + TC_UNIT)));//计算总线程数
if (((rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);//更新总线程数
unlockRunState(rs, rs & ~RSLOCK);
//运行到这里说明活跃工作线程数不足,需要创建一个新的工作线程来补偿
canBlock = add && createWorker(); // throws on exception
}
}
return canBlock;
}

说明:具体的执行看源码及注释,这里我们简单总结一下需要和不需要补偿的几种情况:

  • 需要补偿
    • 调用者队列不为空,并且有空闲工作线程,这种情况会唤醒空闲线程(调用tryRelease方法)
    • 池尚未停止,活跃线程数不足,这时会新建一个工作线程(调用createWorker方法)
  • 不需要补偿
    • 调用者已终止或池处于不稳定状态
    • 总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空

6、Fork/Join的陷阱与注意事项

使用Fork/Join框架时,需要注意一些陷阱, 在下面 斐波那契数列例子中你将看到示例。

1、避免不必要的fork()

划分成两个子任务后,不要同时调用两个子任务的fork()方法。

表面上看上去两个子任务都fork(),然后join()两次似乎更自然。但事实证明,直接调用compute()效率更高。因为直接调用子任务的compute()方法实际上就是在当前的工作线程进行了计算(线程重用),这比“将子任务提交到工作队列,线程又从工作队列中拿任务”快得多

当一个大任务被划分成两个以上的子任务时,尽可能使用前面说到的三个衍生的invokeAll方法,因为使用它们能避免不必要的fork()。

2、注意fork()、compute()、join()的顺序

为了两个任务并行,三个方法的调用顺序需要万分注意。

1
2
3
4
right.fork(); // 计算右边的任务
long leftAns = left.compute(); // 计算左边的任务(同时右边任务也在计算)
long rightAns = right.join(); // 等待右边的结果
return leftAns + rightAns;

如果我们写成:

1
2
3
4
left.fork(); // 计算完左边的任务
long leftAns = left.join(); // 等待左边的计算结果
long rightAns = right.compute(); // 再计算右边的任务
return leftAns + rightAns;

或者:

1
2
3
4
long rightAns = right.compute(); // 计算完右边的任务
left.fork(); // 再计算左边的任务
long leftAns = left.join(); // 等待左边的计算结果
return leftAns + rightAns;
3、选择合适的子任务粒度

选择划分子任务的粒度(顺序执行的阈值)很重要,因为使用Fork/Join框架并不一定比顺序执行任务的效率高:如果任务太大,则无法提高并行的吞吐量;如果任务太小,子任务的调度开销可能会大于并行计算的性能提升,我们还要考虑创建子任务、fork()子任务、线程调度以及合并子任务处理结果的耗时以及相应的内存消耗。

官方文档给出的粗略经验是:任务应该执行100~10000个基本的计算步骤。决定子任务的粒度的最好办法是实践,通过实际测试结果来确定这个阈值才是“上上策”。

和其他Java代码一样,Fork/Join框架测试时需要“预热”或者说执行几遍才会被JIT(Just-in-time)编译器优化,所以测试性能之前跑几遍程序很重要。

4、避免重量级任务划分与结果合并

Fork/Join的很多使用场景都用到数组或者List等数据结构,子任务在某个分区中运行,最典型的例子如并行排序和并行查找。拆分子任务以及合并处理结果的时候,应该尽量避免System.arraycopy这样耗时耗空间的操作,从而最小化任务的处理开销。

7、再深入理解

1、有哪些JDK源码中使用了Fork/Join思想

我们常用的数组工具类 Arrays 在JDK 8之后新增的==并行排序方法(parallelSort)==就运用了 ForkJoinPool 的特性,还有 ConcurrentHashMap 在JDK 8之后添加的==函数式方法(如forEach等)==也有运用。

2、使用Executors工具类创建ForkJoinPool

Java8在Executors工具类中新增了两个工厂方法:

1
2
3
4
5
// parallelism定义并行级别
public static ExecutorService newWorkStealingPool(int parallelism);
// 默认并行级别为JVM可用的处理器个数
// Runtime.getRuntime().availableProcessors()
public static ExecutorService newWorkStealingPool();
3、关于Fork/Join异常处理

Java的受检异常机制一直饱受诟病,所以在ForkJoinTask的invoke()、join()方法及其衍生方法中都没有像get()方法那样抛出个ExecutionException的受检异常。

所以你可以在ForkJoinTask中看到内部把受检异常转换成了运行时异常

1
2
3
4
5
6
7
8
9
static void rethrow(Throwable ex) {
if (ex != null)
ForkJoinTask.<RuntimeException>uncheckedThrow(ex);
}

@SuppressWarnings("unchecked")
static <T extends Throwable> void uncheckedThrow(Throwable t) throws T {
throw (T)t; // rely on vacuous cast
}

==关于Java你不知道的10件事==中已经指出,JVM实际并不关心这个异常是受检异常还是运行时异常,受检异常这东西完全是给Java编译器用的:用于警告程序员这里有个异常没有处理。

但不可否认的是invoke、join()仍可能会抛出运行时异常,所以ForkJoinTask还提供了两个不提取结果和异常的方法quietlyInvoke()、quietlyJoin(),这两个方法允许你在所有任务完成后对结果和异常进行处理。

使用quitelyInvoke()quietlyJoin()时可以配合isCompletedAbnormally()isCompletedNormally()方法使用。

ForkJoinTask 在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常,所以 ForkJoinTask 提供了 isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过 ForkJoinTask 的getException 方法获取异常。

getException 方法返回 Throwable 对象,如果任务被取消了则返回 CancellationException。如果任务没有完成或者没有抛出异常则返回 null。

8、一些Fork/Join例子

1、采用Fork/Join来异步计算1+2+3+……+10000的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Test {
static final class SumTask extends RecursiveTask<Integer> {
private static final long serialVersionUID = 1L;

final int start; //开始计算的数
final int end; //最后计算的数

SumTask(int start, int end) {
this.start = start;
this.end = end;
}

@Override
protected Integer compute() {
//如果计算量小于1000,那么分配一个线程执行if中的代码块,并返回执行结果
if(end - start < 1000) {
System.out.println(Thread.currentThread().getName() + " 开始执行: " + start + "-" + end);
int sum = 0;
for(int i = start; i <= end; i++)
sum += i;
return sum;
}
//如果计算量大于1000,那么拆分为两个任务
SumTask task1 = new SumTask(start, (start + end) / 2);
SumTask task2 = new SumTask((start + end) / 2 + 1, end);
//执行任务
task1.fork();
task2.fork();
//获取任务执行的结果
return task1.join() + task2.join();
}
}

public static void main(String[] args) throws InterruptedException, ExecutionException {
ForkJoinPool pool = new ForkJoinPool();
ForkJoinTask<Integer> task = new SumTask(1, 10000);
pool.submit(task);
System.out.println(task.get());
}
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ForkJoinPool-1-worker-1 开始执行: 1-625
ForkJoinPool-1-worker-7 开始执行: 6251-6875
ForkJoinPool-1-worker-6 开始执行: 5626-6250
ForkJoinPool-1-worker-10 开始执行: 3751-4375
ForkJoinPool-1-worker-13 开始执行: 2501-3125
ForkJoinPool-1-worker-8 开始执行: 626-1250
ForkJoinPool-1-worker-11 开始执行: 5001-5625
ForkJoinPool-1-worker-3 开始执行: 7501-8125
ForkJoinPool-1-worker-14 开始执行: 1251-1875
ForkJoinPool-1-worker-4 开始执行: 9376-10000
ForkJoinPool-1-worker-8 开始执行: 8126-8750
ForkJoinPool-1-worker-0 开始执行: 1876-2500
ForkJoinPool-1-worker-12 开始执行: 4376-5000
ForkJoinPool-1-worker-5 开始执行: 8751-9375
ForkJoinPool-1-worker-7 开始执行: 6876-7500
ForkJoinPool-1-worker-1 开始执行: 3126-3750
50005000
2、实现斐波那契数列

斐波那契数列: 1、1、2、3、5、8、13、21、34、…… 公式 : F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool(4); // 最大并发数4
Fibonacci fibonacci = new Fibonacci(20);
long startTime = System.currentTimeMillis();
Integer result = forkJoinPool.invoke(fibonacci);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}
//以下为官方API文档示例
static class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}

当然你也可以两个任务都fork,要注意的是两个任务都fork的情况,必须按照f1.fork(),f2.fork(), f2.join(),f1.join()这样的顺序,不然有性能问题,详见上面注意事项中的说明。

官方API文档是这样写到的,所以平日用invokeAll就好了。invokeAll会把传入的任务的第一个交给当前线程来执行,其他的任务都fork加入工作队列,这样等于利用当前线程也执行任务了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
// ...
Fibonacci f1 = new Fibonacci(n - 1);
Fibonacci f2 = new Fibonacci(n - 2);
invokeAll(f1,f2);
return f2.join() + f1.join();
}

public static void invokeAll(ForkJoinTask<?>... tasks) {
Throwable ex = null;
int last = tasks.length - 1;
for (int i = last; i >= 0; --i) {
ForkJoinTask<?> t = tasks[i];
if (t == null) {
if (ex == null)
ex = new NullPointerException();
}
else if (i != 0) //除了第一个都fork
t.fork();
else if (t.doInvoke() < NORMAL && ex == null) //留一个自己执行
ex = t.getException();
}
for (int i = 1; i <= last; ++i) {
ForkJoinTask<?> t = tasks[i];
if (t != null) {
if (ex != null)
t.cancel(false);
else if (t.doJoin() < NORMAL)
ex = t.getException();
}
}
if (ex != null)
rethrow(ex);
}

21、CompletableFuture异步回调

1、CompletableFuture 简介

CompletableFuture 在 Java 里面被用于异步编程,异步通常意味着非阻塞, 可以使得我们的任务单独运行在与主线程分离的其他线程中,并且通过回调可以在主线程中得到异步任务的执行状态,是否完成,和是否异常等信息。

CompletableFuture 实现了 Future, CompletionStage 接口:

  • 实现了 Future 接口就可以兼容现在有线程池框架
  • 而 CompletionStage 接口才是异步编程的接口抽象,里面定义多种异步方法

通过这两者集合,从而打造出了强大的CompletableFuture 类。

2、Future 与 CompletableFuture

Futrue 在 Java 里面,通常用来表示一个异步任务的引用,比如我们将任务提交到线程池里面,然后我们会得到一个 Futrue,在 Future 里面有 isDone 方法来 判断任务是否处理结束,还有 get 方法可以一直阻塞直到任务结束然后获取结果,但整体来说这种方式,还是同步的,因为需要客户端不断阻塞等待或者不断轮询才能知道任务是否完成。

3、Future 的主要缺点

  1. 不支持手动完成
    • 我提交了一个任务,但是执行太慢了,我通过其他路径已经获取到了任务结果, 现在没法把这个任务结果通知到正在执行的线程,所以必须主动取消或者一直等待它执行完成
  2. 不支持进一步的非阻塞调用
    • 通过 Future 的 get 方法会一直阻塞到任务完成,但是想在获取任务之后执行额外的任务,因为 Future 不支持回调函数,所以无法实现这个功能
  3. 不支持链式调用
    • 对于 Future 的执行结果,我们想继续传到下一个 Future 处理使用,从而形成一个链式的 pipline 调用,这在 Future 中是没法实现的。
  4. 不支持多个 Future 合并
    • 比如我们有 10 个 Future 并行执行,我们想在所有的 Future 运行完毕之后, 执行某些函数,是没法通过 Future 实现的。
  5. 不支持异常处理
    • Future 的 API 没有任何的异常处理的 api,所以在异步运行时,如果出了问题是不好定位的。

4、CompletableFuture的使用

1、CompletableFuture 入门

场景:主线程里面创建一个 CompletableFuture,然后主线程调用 get 方法会阻塞,最后我们在一个子线程中使其终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CompletableFutureTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
CompletableFuture<String> future = new CompletableFuture<>();
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + "子线程开始干活");
//子线程睡 5 秒
Thread.sleep(5000);
//在子线程中完成主线程
future.complete("success");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
//主线程调用 get 方法阻塞
System.out.println("主线程调用 get 方法获取结果为: " + future.get());
System.out.println("主线程完成,阻塞结束!!!!!!");
}
}

结果:

1
2
3
A子线程开始干活
主线程调用 get 方法获取结果为: success
主线程完成,阻塞结束!!!!!!
2、没有返回值的同步任务
1
2
3
4
5
6
7
8
9
10
//没有返回值的同步任务
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
//同步调用
CompletableFuture<Void> completableFuture1 = CompletableFuture.runAsync(()->{
System.out.println(Thread.currentThread().getName()+" : CompletableFuture1");
});
completableFuture1.get();
}
}

同步任务调用runAsync()方法,使用get()方法获取

3、有返回值的异步任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//异步调用和同步调用
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
//异步调用
CompletableFuture<Integer> completableFuture2 = CompletableFuture.supplyAsync(()->{
System.out.println(Thread.currentThread().getName()+" : CompletableFuture2");
//模拟异常
int i = 10/0;
return 1024;
});
completableFuture2.whenComplete((t,u)->{
System.out.println("------t="+t);
System.out.println("------u="+u);
}).get();

}
}

异步调用使用supplyAsync()方法,可以通过whenComplete()获取。

其中supplyAsync()方法的参数是一个Supplier类,而Supplier类是一个函数式接口,可以使用lambda表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {
return asyncSupplyStage(asyncPool, supplier);
}

@FunctionalInterface
public interface Supplier<T> {

/**
* Gets a result.
*
* @return a result
*/
T get();
}

其中whenComplete()的两个参数:

  • t:用来接收异步调用中正常返回的结果,此时的u返回null
  • u:用来接收异步调用过程中出现的异常,此时的t返回null

关于一些函数式接口的接口类:

  • supplier:提供者,特点:无中生有 :() -> 结果
  • function:函数,特点:一个参数一个结果 :(参数) -> 结果
    • BiFunction:两个参数一个结果 :(参数1,参数2) -> 结果
  • consumer:消费者,特点:一个参数没结果:(参数) -> void
    • BiConsumer:两个参数,没有结果:(参数1,参数2) -> void
4、线程依赖

当一个线程依赖另一个线程时,可以使用 thenApply 方法来把这两个线程串行化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
try {
System.out.println("加 10 任务开始");
num += 10;
} catch (Exception e) {
e.printStackTrace();
}
return num;
}).thenApply(integer -> {
return num * num;
});
Integer integer = future.get();
System.out.println("主线程结束, 子线程的结果为:" + integer);
}
}
1
2
3
主线程开始
加 10 任务开始
主线程结束, 子线程的结果为:400
5、消费处理结果

thenAccept 消费处理结果,接收任务的处理结果,并消费处理,无返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture.supplyAsync(() -> {
try {
System.out.println("加 10 任务开始");
num += 10;
} catch (Exception e) {
e.printStackTrace();
}
return num;
}).thenApply(integer -> {
return num * num;
}).thenAccept(new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println("子线程全部处理完成,最后调用了 accept,结果为:" + integer);
}
});
}
}
1
2
3
主线程开始
加 10 任务开始
子线程全部处理完成,最后调用了 accept,结果为:400
6、异常处理

exceptionally 异常处理,出现异常时触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 1 / 0;
System.out.println("加 10 任务开始");
num += 10;
return num;
}).exceptionally(ex -> {
System.out.println(ex.getMessage());
return -1;
});
System.out.println(future.get());
}
}

结果:

1
2
3
主线程开始
java.lang.ArithmeticException: / by zero
-1

handle 类似于 thenAccept/thenRun 方法,是最后一步的处理调用,但是同时可以处理异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 1 / 0;
System.out.println("加 10 任务开始");
num += 10;
return num;
}).handle((i, ex) -> {
System.out.println("进入 handle 方法");
if (ex != null) {
System.out.println("发生了异常,内容为:" + ex.getMessage());
return -1;
} else {
System.out.println("正常完成,内容为: " + i);
return i;
}
});
System.out.println(future.get());
}
}

结果:

1
2
3
4
主线程开始
进入 handle 方法
发生了异常,内容为:java.lang.ArithmeticException: / by zero
-1
7、结果合并

thenCompose 合并两个有依赖关系的 CompletableFutures 的执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
//第一步加 10
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
//合并
CompletableFuture<Integer> future1 = future.thenCompose(i ->
//再来一个CompletableFuture
CompletableFuture.supplyAsync(() -> {
return i + 1;
}));
System.out.println(future.get());
System.out.println(future1.get());
}
}

结果:

1
2
3
4
主线程开始
加 10 任务开始
20
21

thenCombine 合并两个没有依赖关系的 CompletableFutures 任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
System.out.println("乘以 10 任务开始");
num = num * 10;
return num;
});
//合并两个结果
CompletableFuture<Object> future = job1.thenCombine(job2, (BiFunction<Integer, Integer, List<Integer>>) (a, b) -> {
List<Integer> list = new ArrayList<>();
list.add(a);
list.add(b);
return list;
});
System.out.println("合并结果为:" + future.get());
}
}

结果:

1
2
3
4
主线程开始
加 10 任务开始
乘以 10 任务开始
合并结果为:[20, 200]
8、合并多个任务的结果allOf 与anyOf

allOf:一系列独立的future 任务,等其所有的任务执行完后做一些事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class CompletableFutureTest {
private static Integer num = 10;
public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
List<CompletableFuture> list = new ArrayList<>();
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
list.add(job1);
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
System.out.println("乘以 10 任务开始");
num *= 10;
return num;
});
list.add(job2);
CompletableFuture<Integer> job3 = CompletableFuture.supplyAsync(() -> {
System.out.println("减以 10 任务开始");
num -= 10;
return num;
});
list.add(job3);
CompletableFuture<Integer> job4 = CompletableFuture.supplyAsync(() -> {
System.out.println("除以 10 任务开始");
num /= 10;
return num;
});
list.add(job4);
//多任务合并
List<Integer> collect = list.stream().map(CompletableFuture<Integer>::join).collect(Collectors.toList());
System.out.println(collect);
}
}
1
2
3
4
5
6
主线程开始
加 10 任务开始
乘以 10 任务开始
减以 10 任务开始
除以 10 任务开始
[20, 200, 190, 19]

anyOf:只要在多个future 里面有一个返回,整个任务就可以结束,而不需要等到每一个 future 结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class CompletableFutureTest {
private static Integer num = 10;
public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer>[] futures = new CompletableFuture[4];
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000);
System.out.println("加 10 任务开始");
num += 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 0;
}
});
futures[0] = job1;
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000);
System.out.println("乘以 10 任务开始");
num *= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 1;
}
});
futures[1] = job2;
CompletableFuture<Integer> job3 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(3000);
System.out.println("减以 10 任务开始");
num -= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 2;
}
});
futures[2] = job3;
CompletableFuture<Integer> job4 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(4000);
System.out.println("除以 10 任务开始");
num /= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 3;
}
});
futures[3] = job4;
CompletableFuture<Object> future = CompletableFuture.anyOf(futures);
System.out.println(future.get());
}
}

结果:

1
2
3
主线程开始
乘以 10 任务开始
100

22、Java 并发 - ThreadLocal详解

ThreadLocal是通过线程隔离的方式防止任务在共享资源上产生冲突,线程本地存储是一种自动化机制,可以为使用相同变量的每个不同线程都创建不同的存储。

1、BAT大厂的面试问题

  • 什么是ThreadLocal?用来解决什么问题的?
  • 说说你对ThreadLocal的理解
  • ThreadLocal是如何实现线程隔离的?
  • 为什么ThreadLocal会造成内存泄露?如何解决?
  • 还有哪些使用ThreadLocal的应用场景?

2、ThreadLocal简介

我们在==Java 并发 - 并发理论基础==总结过线程安全(是指广义上的共享资源访问安全性,因为线程隔离是通过副本保证本线程访问资源安全性,它不保证线程之间还存在共享关系的狭义上的安全性)的解决思路:

  • 互斥同步synchronizedReentrantLock
  • 非阻塞同步CASAtomicXXXX
  • 无同步方案栈封闭本地存储(Thread Local)可重入代码

这个章节将详细的讲讲 本地存储(Thread Local)。官网的解释是这样的:

his class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID) 该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。

总结而言:ThreadLocal是一个将在多线程中为每一个线程创建单独的变量副本的类;当使用ThreadLocal来维护变量时,ThreadLocal会为每个线程创建单独的变量副本,避免因多线程操作共享变量而导致的数据不一致的情况

3、ThreadLocal理解

提到ThreadLocal被提到应用最多的是session管理和数据库链接管理,这里以数据访问为例来理解ThreadLocal:

如下数据库管理类在单线程使用是没有任何问题的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ConnectionManager {
private static Connection connect = null;

public static Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}

public static void closeConnection() {
if (connect != null)
connect.close();
}
}

很显然,在多线程中使用会存在线程安全问题:

  1. 第一,这里面的2个方法都没有进行同步,很可能在openConnection方法中会多次创建connect;
  2. 第二,由于connect是共享变量,那么必然在调用connect的地方需要使用到同步来保障线程安全,因为很可能一个线程在使用connect进行数据库操作,而另外一个线程调用closeConnection关闭链接。

为了解决上述线程安全的问题,第一考虑:互斥同步

你可能会说,将这段代码的两个方法进行同步处理,并且在调用connect的地方需要进行同步处理,比如用Synchronized或者ReentrantLock互斥锁

这里再抛出一个问题:这地方到底需不需要将connect变量进行共享?

事实上,是不需要的。假如每个线程中都有一个connect变量,各个线程之间对connect变量的访问实际上是没有依赖关系的,即一个线程不需要关心其他线程是否对这个connect进行了修改的。即改后的代码可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ConnectionManager {
private Connection connect = null;

public Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}

public void closeConnection() {
if (connect != null)
connect.close();
}
}

class Dao {
public void insert() {
ConnectionManager connectionManager = new ConnectionManager();
Connection connection = connectionManager.openConnection();

// 使用connection进行操作

connectionManager.closeConnection();
}
}

这样处理确实也没有任何问题,由于每次都是在方法内部创建的连接,那么线程之间自然不存在线程安全问题

但是这样会有一个致命的影响:导致服务器压力非常大,并且严重影响程序执行性能。由于在方法中需要频繁地开启和关闭数据库连接,这样不仅严重影响程序执行效率,还可能导致服务器压力巨大

这时候ThreadLocal登场了

那么这种情况下使用ThreadLocal是再适合不过的了,因为ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。下面就是网上出现最多的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ConnectionManager {

private static final ThreadLocal<Connection> dbConnectionLocal = new ThreadLocal<Connection>() {
@Override
protected Connection initialValue() {
try {
return DriverManager.getConnection("", "", "");
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
};

public Connection getConnection() {
return dbConnectionLocal.get();
}
}

再注意下ThreadLocal的修饰符

ThreaLocal的JDK文档中说明:ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread。如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。

但是要注意,虽然ThreadLocal能够解决上面说的问题,但是由于在每个线程中都创建了副本,所以要考虑它对资源的消耗,比如内存的占用会比不使用ThreadLocal要大

4、ThreadLocal原理

1、如何实现线程隔离

主要是用到了Thread对象中的一个ThreadLocalMap类型的变量threadLocals,负责存储当前线程的关于Connection的对象,dbConnectionLocal(以上述例子中为例) 这个变量为Key,以新建的Connection对象为Value;这样的话,线程第一次读取的时候如果不存在就会调用ThreadLocal的initialValue方法创建一个Connection对象并且返回。

具体关于为线程分配变量副本的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
  • 首先获取当前线程对象t,然后从线程t中获取到ThreadLocalMap的成员属性threadLocals
  • 如果当前线程的threadLocals已经初始化(即不为null) 并且存在以当前ThreadLocal对象为Key的值,则直接返回当前线程要获取的对象(本例中为Connection);
  • 如果当前线程的threadLocals已经初始化(即不为null)但是不存在以当前ThreadLocal对象为Key的的对象,那么重新创建一个Connection对象,并且添加到当前线程的threadLocals Map中,并返回;
  • 如果当前线程的threadLocals属性还没有被初始化,则重新创建一个ThreadLocalMap对象,并且创建一个Connection对象并添加到ThreadLocalMap对象中并返回。

如果存在则直接返回很好理解,那么对于如何初始化的代码又是怎样的呢?

1
2
3
4
5
6
7
8
9
10
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
  • 首先调用我们上面写的重载过后的initialValue方法,产生一个Connection对象
  • 继续查看当前线程的threadLocals是不是空的,如果ThreadLocalMap已被初始化,那么直接将产生的对象添加到ThreadLocalMap中,如果没有初始化,则创建并添加对象到其中;

同时,ThreadLocal还提供了直接操作Thread对象中的threadLocals的方法:

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

这样我们也可以不实现initialValue,将初始化工作放到DBConnectionFactory的getConnection方法中:

1
2
3
4
5
6
7
8
9
10
11
12
public Connection getConnection() {
Connection connection = dbConnectionLocal.get();
if (connection == null) {
try {
connection = DriverManager.getConnection("", "", "");
dbConnectionLocal.set(connection);
} catch (SQLException e) {
e.printStackTrace();
}
}
return connection;
}

那么我们看过代码之后就很清晰的知道了为什么ThreadLocal能够实现变量的多线程隔离了;其实就是用了Map的数据结构给当前线程缓存了,要使用的时候就从本线程的threadLocals对象中获取就可以了,key就是当前线程

当然了在当前线程下获取当前线程里面的Map里面的对象并操作肯定没有线程并发问题了,当然能做到变量的线程间隔离了;

现在我们知道了ThreadLocal到底是什么了,又知道了如何使用ThreadLocal以及其基本实现原理了,是不是就可以结束了呢?其实还有一个问题就是ThreadLocalMap是个什么对象,为什么要用这个对象呢?

2、ThreadLocalMap对象是什么

本质上来讲,它就是一个Map,但是这个ThreadLocalMap与我们平时见到的Map有点不一样:

  • 它没有实现Map接口
  • 它没有public的方法,最多有一个default的构造方法,因为这个ThreadLocalMap的方法仅仅在ThreadLocal类中调用,属于静态内部类
  • ThreadLocalMap的Entry实现继承了WeakReference<ThreadLocal<?>>
  • 该方法仅仅用了一个Entry数组来存储Key、Value;Entry并不是链表形式,而是每个bucket里面仅仅放一个Entry

要了解ThreadLocalMap的实现,我们先从入口开始,就是往该Map中添加一个值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

先进行简单的分析,对该代码表层意思进行解读:

  • 看下当前threadLocal的在数组中的索引位置 比如:i = 2,看 i = 2 位置上面的元素(Entry)的Key是否等于threadLocal 这个 Key,如果等于就很好说了,直接将该位置上面的Entry的Value替换成最新的就可以了;
  • 如果当前位置上面的 Entry 的 Key为空,说明ThreadLocal对象已经被回收了,那么就调用replaceStaleEntry
  • 如果清理完无用条目(ThreadLocal被回收的条目)、并且数组中的数据大小 > 阈值的时候对当前的Table进行重新哈希,所以,该HashMap是处理冲突检测的机制是向后移位,清除过期条目 最终找到合适的位置

了解完Set方法,后面就是Get方法了:

1
2
3
4
5
6
7
8
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}

先找到ThreadLocal的索引位置,如果索引位置处的entry不为空并且键与threadLocal是同一个对象,则直接返回;否则去后面的索引位置继续查找。

5、ThreadLocal造成内存泄漏的问题

网上有这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadLocalDemo {
static class LocalVariable {
private Long[] a = new Long[1024 * 1024];
}

// (1)
final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES,
new LinkedBlockingQueue<>());
// (2)
final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();

public static void main(String[] args) throws InterruptedException {
// (3)
Thread.sleep(5000 * 4);
for (int i = 0; i < 50; ++i) {
poolExecutor.execute(new Runnable() {
public void run() {
// (4)
localVariable.set(new LocalVariable());
// (5)
System.out.println("use local varaible" + localVariable.get());
localVariable.remove();
}
});
}
// (6)
System.out.println("pool execute over");
}
}

如果用线程池来操作ThreadLocal 对象确实会造成内存泄露,因为对于线程池里面不会销毁的线程,里面总会存在着<ThreadLocal, LocalVariable>的强引用,因为final static 修饰的 ThreadLocal 并不会释放,而ThreadLocalMap 对于 Key 虽然是弱引用,但是强引用不会释放,弱引用当然也会一直有值,同时创建的LocalVariable对象也不会释放,就造成了内存泄露

如果LocalVariable对象不是一个大对象的话,其实泄露的并不严重,泄露的内存 = 核心线程数 * LocalVariable对象的大小;

所以,为了避免出现内存泄露的情况,ThreadLocal提供了一个清除线程中对象的方法,即 remove,其实内部实现就是调用 ThreadLocalMap 的remove方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

找到Key对应的Entry,并且清除Entry的Key(ThreadLocal)置空,随后清除过期的Entry即可避免内存泄露

6、再看ThreadLocal应用场景

1、每个线程维护了一个“序列号”

再回想上文说的,如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。

每个线程维护了一个“序列号”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SerialNum {
// The next serial number to be assigned
private static int nextSerialNum = 0;

private static ThreadLocal serialNum = new ThreadLocal() {
protected synchronized Object initialValue() {
return new Integer(nextSerialNum++);
}
};

public static int get() {
return ((Integer) (serialNum.get())).intValue();
}
}

2、Session的管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final ThreadLocal threadSession = new ThreadLocal();  

public static Session getSession() throws InfrastructureException {
Session s = (Session) threadSession.get();
try {
if (s == null) {
s = getSessionFactory().openSession();
threadSession.set(s);
}
} catch (HibernateException ex) {
throw new InfrastructureException(ex);
}
return s;
}
3、在线程内部创建ThreadLocal

还有一种用法是在线程类内部创建ThreadLocal,基本步骤如下:

  • 在多线程的类(如ThreadDemo类)中,创建一个ThreadLocal对象threadXxx,用来保存线程间需要隔离处理的对象xxx。
  • 在ThreadDemo类中,创建一个获取要隔离访问的数据的方法getXxx(),在方法中判断,若ThreadLocal对象为null时候,应该new()一个隔离访问类型的对象,并强制转换为要应用的类型。
  • 在ThreadDemo类的run()方法中,通过调用getXxx()方法获取要操作的数据,这样可以保证每个线程对应一个数据对象,在任何时刻都操作的是这个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class ThreadLocalTest implements Runnable{

ThreadLocal<Student> StudentThreadLocal = new ThreadLocal<Student>();

@Override
public void run() {
String currentThreadName = Thread.currentThread().getName();
System.out.println(currentThreadName + " is running...");
Random random = new Random();
int age = random.nextInt(100);
System.out.println(currentThreadName + " is set age: " + age);
Student Student = getStudentt(); //通过这个方法,为每个线程都独立的new一个Studentt对象,每个线程的的Studentt对象都可以设置不同的值
Student.setAge(age);
System.out.println(currentThreadName + " is first get age: " + Student.getAge());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println( currentThreadName + " is second get age: " + Student.getAge());

}

private Student getStudentt() {
Student Student = StudentThreadLocal.get();
if (null == Student) {
Student = new Student();
StudentThreadLocal.set(Student);
}
return Student;
}

public static void main(String[] args) {
ThreadLocalTest t = new ThreadLocalTest();
Thread t1 = new Thread(t,"Thread A");
Thread t2 = new Thread(t,"Thread B");
t1.start();
t2.start();
}

}

class Student{
int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
4、java开发手册中推荐的ThreadLocal

看看阿里巴巴 java 开发手册中推荐的 ThreadLocal 的用法:

1
2
3
4
5
6
7
8
9
10
11
import java.text.DateFormat;
import java.text.SimpleDateFormat;

public class DateUtils {
public static final ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>(){
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
}

然后我们再要用到 DateFormat 对象的地方,这样调用:

1
DateUtils.df.get().format(new Date());

23、补充:阿姆达尔定律

阿姆达尔定律可以用来计算处理器平行运算之后效率提升的能力。阿姆达尔定律因Gene Amdal 在1967年提出这个定律而得名。绝大多数使用并行或并发系统的开发者有一种并发或并行可能会带来提速的感觉,甚至不知道阿姆达尔定律。不管怎样,了解阿姆达尔定律还是有用的。

我会首先以算术的方式介绍阿姆达尔定律定律,然后再用图表演示一下。

1、阿姆达尔定律定义

一个程序(或者一个算法)可以按照是否可以被并行化分为下面两个部分:

  • 可以被并行化的部分
  • 不可以被并行化的部分

假设一个程序处理磁盘上的文件。这个程序的一小部分用来扫描路径和在内存中创建文件目录。做完这些后,每个文件交个一个单独的线程去处理。扫描路径和创建文件目录的部分不可以被并行化,不过处理文件的过程可以。

程序串行(非并行)执行的总时间我们记为T。时间T包括不可以被并行和可以被并行部分的时间不可以被并行的部分我们记为B。那么可以被并行的部分就是T-B。下面的列表总结了这些定义:

  • T = 串行执行的总时间
  • B = 不可以并行的总时间
  • T-B = 并行部分的总时间

从上面可以得出:T = B + (T – B)

首先,这个看起来可能有一点奇怪,程序的可并行部分在上面这个公式中并没有自己的标识。然而,由于这个公式中可并行可以用总时间T 和 B(不可并行部分)表示出来,这个公式实际上已经从概念上得到了简化,也即是指以这种方式减少了变量的个数。

T-B 是可并行化的部分,以并行的方式执行可以提高程序的运行速度。可以提速多少取决于有多少线程或者多少个CPU来执行。线程或者CPU的个数我们记为N。可并行化部分被执行的最快时间可以通过下面的公式计算出来:(T – B ) / N 或者通过这种方式 (1 / N) * (T – B)。维基中使用的是第二种方式。

根据阿姆达尔定律,当一个程序的可并行部分使用N个线程或CPU执行时,执行的总时间为:T(N) = B + ( T – B ) / N

T(N)指的是在并行因子为N时的总执行时间。因此,T(1)就执行在并行因子为1时程序的总执行时间。使用T(1)代替T,阿姆达尔定律定律看起来像这样:T(N) = B + (T(1) – B) / N 表达的意思都是是一样的。

2、一个计算例子

为了更好的理解阿姆达尔定律,让我们来看一个计算的例子。执行一个程序的总时间设为1,程序的不可并行化占40%,按总时间1计算,就是0.4,可并行部分就是1 – 0.4 = 0.6。

在并行因子为2的情况下,程序的执行时间将会是:

1
2
3
4
T(2) = 0.4 + ( 1 - 0.4 ) / 2
= 0.4 + 0.6 / 2
= 0.4 + 0.3
= 0.7

在并行因子为5的情况下,程序的执行时间将会是:

1
2
3
4
T(5) = 0.4 + ( 1 - 0.4 ) / 5
= 0.4 + 0.6 / 6
= 0.4 + 0.12
= 0.52

3、阿姆达尔定律图示

为了更好地理解阿姆达尔定律,我会尝试演示这个定律是如何诞生的。

首先,一个程序可以被分割为两部分,一部分为不可并行部分B,一部分为可并行部分1 – B。如下图:

image-20210814195444348

在顶部被带有分割线的那条直线代表总时间 T(1)。

下面你可以看到在并行因子为2的情况下的执行时间:

image-20210814195510264

并行因子为3的情况:

image-20210814195531931

4、优化算法

从阿姆达尔定律可以看出,程序的可并行化部分可以通过使用更多的硬件(更多的线程或CPU)运行更快。对于不可并行化的部分,只能通过优化代码来达到提速的目的。因此,你可以通过优化不可并行化部分来提高你的程序的运行速度和并行能力。你可以对不可并行化在算法上做一点改动,如果有可能,你也可以把一些移到可并行化放的部分。

优化串行分量

如果你优化一个程序的串行化部分,你也可以使用阿姆达尔定律来计算程序优化后的执行时间。如果不可并行部分通过一个因子O来优化,那么阿姆达尔定律看起来就像这样:

1
T(O, N) = B / O + (1 - B / O) / N

记住,现在程序的不可并行化部分占了B / O的时间,所以,可并行化部分就占了1 - B / O的时间。

如果B为0.1,O为2,N为5,计算看起来就像这样:

1
2
3
4
5
6
T(2,5) = 0.4 / 2 + (1 - 0.4 / 2) / 5
= 0.2 + (1 - 0.4 / 2) / 5
= 0.2 + (1 - 0.2) / 5
= 0.2 + 0.8 / 5
= 0.2 + 0.16
= 0.36

5、运行时间 vs. 加速

到目前为止,我们只用阿姆达尔定律计算了一个程序或算法在优化后或者并行化后的执行时间。我们也可以使用阿姆达尔定律计算加速比(speedup),也就是经过优化后或者串行化后的程序或算法比原来快了多少。

如果旧版本的程序或算法的执行时间为T,那么增速比就是:

1
Speedup = T / T(O , N);

为了计算执行时间,我们常常把T设为1,加速比为原来时间的一个分数。公式大致像下面这样:

1
Speedup = 1 / T(O,N)

如果我们使用阿姆达尔定律来代替T(O,N),我们可以得到下面的公式:

1
Speedup = 1 / ( B / O + (1 - B / O) / N)

如果B = 0.4, O = 2, N = 5, 计算变成下面这样:

1
2
3
4
5
6
7
Speedup = 1 / ( 0.4 / 2 + (1 - 0.4 / 2) / 5)
= 1 / ( 0.2 + (1 - 0.4 / 2) / 5)
= 1 / ( 0.2 + (1 - 0.2) / 5 )
= 1 / ( 0.2 + 0.8 / 5 )
= 1 / ( 0.2 + 0.16 )
= 1 / 0.36
= 2.77777 ...

上面的计算结果可以看出,如果你通过一个因子2来优化不可并行化部分,一个因子5来并行化可并行化部分,这个程序或算法的最新优化版本最多可以比原来的版本快2.77777倍。

6、测量,不要仅是计算

虽然阿姆达尔定律允许你并行化一个算法的理论加速比,但是不要过度依赖这样的计算。在实际场景中,当你优化或并行化一个算法时,可以有很多的因子可以被考虑进来。

内存的速度,CPU缓存,磁盘,网卡等可能都是一个限制因子。如果一个算法的最新版本是并行化的,但是导致了大量的CPU缓存浪费,你可能不会再使用x N个CPU来获得x N的期望加速。如果你的内存总线(memory bus),磁盘,网卡或者网络连接都处于高负载状态,也是一样的情况。

我们的建议是,使用阿姆达尔定律定律来指导我们优化程序,而不是用来测量优化带来的实际加速比。记住,有时候一个高度串行化的算法胜过一个并行化的算法,因为串行化版本不需要进行协调管理(上下文切换),而且一个单个的CPU在底层硬件工作(CPU管道、CPU缓存等)上的一致性可能更好。


8、并发的相关多线程设计模式

1、两阶段终止(Two Phase Termination)

在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

1、错误思路

  • 使用线程对象的 stop() 方法停止线程
    • stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁,可能会造成死锁问题
  • 使用 System.exit(int) 方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都停止

2、正常做法——两阶段终止模式(interrupt实现)

1、实现流程图

img

2、实现方法

interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行

  • 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
  • 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
3、代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestTPT{
public static void main(String[] args) {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start;
try{
Thread.sleep(3500);
} catch(InterruptedException e) {
e.printStackTrace();
}
tpt.stop();
}
}


class TwoPhaseTermination{

private Thread monitorThread;

// 启动监控线程
public void start(){
monitorThread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
// 是否被打断
if(current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(2000); // 情况1:sleep被打断
log.debug("执行监控记录"); // 情况2:正常执行被打断
} catch (InterruptedException e) {
// 因为sleep出现异常后,会清除打断标记
// 需要重置打断标记
current.interrupt();
e.printStackTrace();
}
}
}, "监控线程" );
monitorThread.start();
}

// 停止监控线程
public void stop() {
monitorThread.interrupt();
}
}

执行结果:

1
2
3
4
5
6
7
8
9
11:49:42.915 c.TwoPhaseTermination [监控线程] - 执行监控记录 
11:49:43.919 c.TwoPhaseTermination [监控线程] - 执行监控记录
11:49:44.919 c.TwoPhaseTermination [监控线程] - 执行监控记录
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at cn.itcast.test.TwoPhaseTermination.lambda$start$0(Iest3. java:30)
at java.lang.Thread.run(Thread.java: 748)
11:49:45.413 c.TestTwoPhaseTermination [main] - stop
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事

两个细节:

  1. 线程被打断时分为两种情况
    1. 情况1:线程在sleep时被打断,此时线程会抛出InterruptedException: sleep interrupted异常进入catch模块,不会清除打断标记,也就是说isInterrupted()返回false,所以需要在catch模块当中重置打断标记
    2. 情况2:线程在正常执行被打断,此时线程的打断标记不会被清除,即isInterrupted()返回true,在下一次的判断中进入if块执行break;语句退出死循环
  2. 线程使用的是isInterrupted()用来判断打断标记是否为true,即有没有被打断过。其实还有一个方法可以用来判断有没有被打断过,那就是interrupted()
    1. isInterrupted():判断当前线程是否被打断,不会清除==打断标记==
    2. interrupted():判断当前线程是否被打断,是一个静态方法,会清除==打断标记==

3、正常做法——两阶段终止模式(volatile实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class TestTPT{
public static void main(String[] args) {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start;
try{
Thread.sleep(3500);
log.debug("停止监控");
} catch(InterruptedException e) {
e.printStackTrace();
}
tpt.stop();
}
}


class TwoPhaseTermination{

// 监控线程
private Thread monitorThread;
// 打断标记
private volatile boolean stop = false;

// 启动监控线程
public void start(){
monitorThread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
// 是否被打断
if(stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(2000); // 情况1:sleep被打断
log.debug("执行监控记录"); // 情况2:正常执行被打断
} catch (InterruptedException e) {
}
}
}, "监控线程" );
monitorThread.start();
}

// 停止监控线程
public void stop() {
stop = true;
//这里依旧使用打断interrupt是为了即使监控线程在sleep当中也能马上结束,而不是等到sleep结束在停止
monitorThread.interrupt();
}
}

执行结果:

1
2
3
4
5
17:08:21.970 c.TwoPhaseTermination [监控线程] - 执行监控记录 
17:08:22.973 c.TwoPhaseTermination [监控线程] - 执行监控记录
17:08:23.974 c.TwoPhaseTermination [监控线程] - 执行监控记录
17:08:24.467 c.TwoPhaseTermination [mian] - 停止监控
17:08:24.467 c.TwoPhaseTermination [监控线程] - 料理后事

2、犹豫模式(Balking)——同步模式

1、定义

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回

2、实现

以上面两阶段终止模式的例子,当调用了多次tpt.start;就会创建多个监控线程,其实这是错误的,监控线程只需要一个就够了,在第二次创建监控线程的时候应该直接返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.TwoPhaseTermination")
public class Test13 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
tpt.start();
tpt.start();

/*Thread.sleep(3500);
log.debug("停止监控");
tpt.stop();*/
}
}

@Slf4j(topic = "c.TwoPhaseTermination")
class TwoPhaseTermination {
// 监控线程
private Thread monitorThread;
// 停止标记
private volatile boolean stop = false;
// 判断是否执行过 start 方法
private volatile boolean starting = false;

// 启动监控线程
public void start() {
synchronized (this) {
if (starting) { // false
return;
}
starting = true;
}
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (stop) {
log.debug("料理后事");
starting = false;
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}

// 停止监控线程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}

3、犹豫Balking模式还经常用来实现线程安全的单例

1
2
3
4
5
6
7
8
9
10
11
12
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}

对比一下保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。

3、保护性暂停(Guarded Suspension)——同步模式

1、定义

保护性暂停(Guarded Suspension)用在一个线程等待另一个线程的执行结果

要点:

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
  • 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
  • JDK 中,join 的实现、Future 的实现,采用的就是此模式
  • 因为要等待另一方的结果,因此归类到同步模式

image-20210805201137709

2、实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class GuardedObject {
// 结果
private Object response;

// 获取结果
public Object get(long timeout) {
synchronized (this) {
// 没有结果
while (response == null) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

3、应用

一个线程等待另一个线程的执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
// 等待结果
log.debug("等待结果");
List<String> list = (List<String>) guardedobject.get();
log.debug("结果大小: {}" list.size());
},"t1").start();

new Thread(() -> {
log.debug("执行下载");
try {
// 子线程执行下载
List<String> list = Downloader.download();
guardedObject.complete(response);
} catch (IOException e) {
e.printStackTrace();
}
},"t2").start();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Downloader {
public static List<String> download() throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL("https://www.baidu.com/").openConnection();
List<String> lines = new ArrayList<>();
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
}
return lines;
}
}

结果:

1
2
3
14:42:07.731 c.Test20 [t1] - 等待结果
14:42:07.731 c.Test20 [t2] - 执行下载
14:42:33.636 c.Test20 [t1] - 结果大小: 3

4、带超时版 GuardedObjec

如果要控制超时时间呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 增加超时效果
class GuardedObject {
// 结果
private Object response;

// 获取结果
// timeout 表示要等待多久 2000
public Object get(long timeout) {
synchronized (this) {
// 开始时间 15:00:00
long begin = System.currentTimeMillis();
// 经历的时间
long passedTime = 0;
while (response == null) {
// 这一轮循环应该等待的时间
long waitTime = timeout - passedTime;
// 经历的时间超过了最大等待时间时,退出循环
if (timeout - passedTime <= 0) {
break;
}
try {
this.wait(waitTime); // 虚假唤醒 15:00:01
} catch (InterruptedException e) {
e.printStackTrace();
}
// 求得经历时间
passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
log.debug("begin");
Object response = guardedobject.get(2000);
log.debug("结果大小: {}" response);
},"t1").start();

new Thread(() -> {
log.debug("begin");
// 睡眠1s
Sleeper.sleep(1);
guardedObject.complete(new Object());
},"t2").start();
}

如果线程t2睡眠1s,那么get没有超时,可以获得Object对象:

1
2
3
15:51:04.932 c.Test20 [Thread-1] - begin
15:51:04.932 c.Test20 [Thread-0] - begin
15:51:05.935 c.Test20 [Thread-0] - 结果是:java. lang .0bject@455b03c9

如果线程t2睡眠3s,那么get超时,不能获得Object对象:

1
2
3
15:52:07.993 c.Test20 [t2] - begin
15:52:07.993 c.Test20 [t1] - begin
15:52:09.997 c.Test20 [t1] - 结果是:null

测试虚假唤醒问题:把t2线程complete传入null(线程t2睡眠1s):(如果代码wait传入的是timeout而不是waitTime,这里的等待时间为3s(虚假唤醒1s,加设置的2s = 总共3s),而不是设置的2s)

1
2
3
15:52:11.975 c.Test20 [t2] - begin
15:52:11.975 c.Test20 [t1] - begin
15:52:13.979 c.Test20 [t1] - 结果是:null

5、扩展:多任务版 GuardedObject

代码:

图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员

如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。

注意:这里的结果等待者和结果产生者是一一对应的,所以采用的是保护性暂停模式,如果不是一一对应的话,使用的是生产者消费者模式

image-20210805215400468

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import cn.itcast.n2.util.Sleeper;
import lombok.extern.slf4j.Slf4j;

import java.util.Hashtable;
import java.util.Map;
import java.util.Set;

@Slf4j(topic = "c.Test20")
public class Test20 {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 3; i++) {
// 生成3个居民
new People().start();
}
Sleeper.sleep(1);
for (Integer id : Mailboxes.getIds()) {
// 邮递员送信
new Postman(id, "内容" + id).start();
}
}
}

@Slf4j(topic = "c.People")
class People extends Thread{
@Override
public void run() {
// 收信
GuardedObject guardedObject = Mailboxes.createGuardedObject();
log.debug("开始收信 id:{}", guardedObject.getId());
Object mail = guardedObject.get(5000);
log.debug("收到信 id:{}, 内容:{}", guardedObject.getId(), mail);
}
}

@Slf4j(topic = "c.Postman")
class Postman extends Thread {
private int id;
private String mail;

public Postman(int id, String mail) {
this.id = id;
this.mail = mail;
}

@Override
public void run() {
GuardedObject guardedObject = Mailboxes.getGuardedObject(id);
log.debug("送信 id:{}, 内容:{}", id, mail);
guardedObject.complete(mail);
}
}

/**
* Mailboxes邮箱类是之间解耦类,并不与业务挂钩,代码可以复用
*/
class Mailboxes {
private static Map<Integer, GuardedObject> boxes = new Hashtable<>();

private static int id = 1;
// 产生唯一 id
// 加上synchronized保证生成id的线程安全
private static synchronized int generateId() {
return id++;
}

public static GuardedObject getGuardedObject(int id) {
// 注意这里使用的是remove方法,而不是get方法
// 因为这里的对应关系只需要存在一次,送完信就应该取消对应关系
// 如果没有取消对应关系的话,由于boxes是static类型,是不会进入垃圾回收的,
// 造成了内存泄漏,长期以往可能会导致OOM
return boxes.remove(id);
}

public static GuardedObject createGuardedObject() {
GuardedObject go = new GuardedObject(generateId());
boxes.put(go.getId(), go);
return go;
}

public static Set<Integer> getIds() {
return boxes.keySet();
}
}

// 增加超时效果
class GuardedObject {

// 标识 Guarded Object
private int id;

public GuardedObject(int id) {
this.id = id;
}

public int getId() {
return id;
}

// 结果
private Object response;

// 获取结果
// timeout 表示要等待多久 2000
public Object get(long timeout) {
synchronized (this) {
// 开始时间 15:00:00
long begin = System.currentTimeMillis();
// 经历的时间
long passedTime = 0;
while (response == null) {
// 这一轮循环应该等待的时间
long waitTime = timeout - passedTime;
// 经历的时间超过了最大等待时间时,退出循环
if (timeout - passedTime <= 0) {
break;
}
try {
this.wait(waitTime); // 虚假唤醒 15:00:01
} catch (InterruptedException e) {
e.printStackTrace();
}
// 求得经历时间
passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

6、使用保护性暂停的好处

  1. 如果使用的是一个线程(A)使用join来等待另外一个线程(B)的结果的话,如果线程B给线程A结果,但是线程A还不能接收,线程B就不能往下运行,必须等待线程A接收结果之后才能往下运行。
  2. 如果使用的是保护性暂停模式的话,线程B在结束下载以后还能往下运行代码,没必要等待线程A接收结果
  3. 因为join是线程结束才返回,但是阻塞的线程只需要那个response有值,凭什么要去等另一个线程全部执行完
  4. 使用join的话,两线程交互的结果只能设置成全局的,而使用保护性暂停模式,可以把等待的结果设置成局部的(如示例当中的list)

4、生产者消费者模式(Producer Consumer)——异步模式

1、定义

要点:

  • 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  • JDK 中各种阻塞队列,采用的就是这种模式

image-20210805231147322

2、实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 消息类 不设置set方法,加上整个类被final修饰,保证没有其他方法去修改Message里面的值
final class Message {
// 消息对应的id,用来辨识message,用在查看消息是否发送成功等等
private int id;
// 消息的内容
private Object message;
public Message(int id, Object message) {
this.id = id;
this.message = message;
}

public int getId() {
return id;
}
public Object getMessage() {
return message;
}
}

// 消息队列 java线程间通信
@Slf4j(topic = "c.MessageQueue")
class MessageQueue {
// 消息队列集合
private LinkedList<Message> list;
// 队列容量
private int capacity;

public MessageQueue(int capacity) {
this.capacity = capacity;
list = new LinkedList<>();
}

// 获取消息
public Message take() {
synchronized (list) {
// 检查队列是否为空
while (list.isEmpty()) {
try {
log.debug("队列为空,消费者线程等待");
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
//从队列头部获取消息并返回
Message message = queue.removeFirst();
log.debug("已消费消息{}", message);
list.notifyAll();
return message;
}
}

// 存入消息
public void put(Message message) {
synchronized (list) {
// 检查队列是否已满
while (list.size() == capacity) {
try {
log.debug("队列已满,生产者线程等待");
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
// 将消息加入队列尾部
list.addLast(message);
log.debug("已生产消息{}", message);
list.notifyAll();
}
}

3、应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
MessageQueue queue = new MessageQueue(2);
// 3 个生产者线程, 下载任务
for (int i = 0; i < 3; i++) {
// lambda表达式要求里面的变量为不可变的,不能直接写i,可以先将i赋值给id,在写入id
int id = i;
new Thread(() -> {
queue.put(new Message(id,"值"+id));
},"生产者" + i).start();
}

// 1个消费者线程,消费任务
new thread(() -> {
while(true) {
// 每隔1s消费一条消息
sleep(1);
Message message = queue.take();
}
},"消费者").start();
}

结果:

1
2
3
4
5
6
7
8
11:52:21.949 c.MessageQueue [生产者2] - 已生产消息Message{id=2, value=值2}
11:52:21.953 c.MessageQueue [生产者0] - 已生产消息Message{id=0, value=值0}
11:52:21.953 c.MessageQueue [生产者1] - 队列已满,生产者线程等待
11:52:22.948 c.MessageQueue [消费者] - 已消费消息Message{id=2, value=值2}
11:52:22.948 c.MessageQueue [生产者1] - 已生产消息Message{id=1, value=值1}
11:52:23.949 c.MessageQueue [消费者] - 已消费消息Message{id=0, value=值0}
11:52:24.949 c.MessageQueue [消费者] - 已消费消息Message{id=1, value=值1}
11:52:25.949 c.MessageQueue [消费者] - 队列为空,消费者线程等待

5、顺序控制(Sequence Control)——同步模式

1、固定运行顺序

比如,必须先 2 后 1 打印

1、wait notify 版

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 用来同步的对象
static final Object lock = new Object();
// t2 运行标记, 代表 t2 是否执行过
static boolean t2runed = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
// 如果 t2 没有执行过
while (!t2runed) {
try {
// t1 先等一会
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("1");
}
},"t1").start();

Thread t2 = new Thread(() -> {
synchronized (lock) {
log.debug("1");
t2runed = true;
lock.notifyAll();
}
},"t2").start();
}

结果:

1
2
15:55:40.793 c.Test25[t2] - 2
15:55:40.796 c.Test25[t1] - 1

实际上使用ReentrantLock的await与signal方法与上面类似,这里不在展示。

2、Park Unpark 版

可以看到,实现上很麻烦:

  1. 首先,需要保证先 wait 再 notify,否则 wait 线程永远得不到唤醒。因此使用了『运行标记』来判断该不该 wait
  2. 第二,如果有些干扰线程错误地 notify 了 wait 线程,条件不满足时还要重新等待,使用了 while 循环来解决此问题(虚假唤醒问题)
  3. 最后,唤醒对象上的 wait 线程需要使用 notifyAll,因为『同步对象』上的等待线程可能不止一个

可以使用 LockSupport 类的 park 和 unpark 来简化上面的题目:代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.Test26")
public class Test26 {
public static void main(String[] args) {

Thread t1 = new Thread(() -> {
LockSupport.park();
log.debug("1");
}, "t1");
t1.start();

new Thread(() -> {
log.debug("2");
LockSupport.unpark(t1);
},"t2").start();
}
}

结果:

1
2
16:02:56.652 c.Test26[t2] - 2
16:02:56.655 c.Test26[t1] - 1

park 和 unpark 方法比较灵活,他俩谁先调用,谁后调用无所谓。并且是以线程为单位进行『暂停』和『恢复』,不需要『同步对象』和『运行标记』

2、交替输出

线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。现在要求输出 abcabcabcabcabc 怎么实现(与线程间定制化通信有区别)

1、wait notify 版

需要借助等待标记来知道下一个唤醒的线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.Test27")
public class Test27 {
public static void main(String[] args) {
WaitNotify wn = new WaitNotify(1, 5);
new Thread(() -> {
wn.print("a", 1, 2);
}).start();
new Thread(() -> {
wn.print("b", 2, 3);
}).start();
new Thread(() -> {
wn.print("c", 3, 1);
}).start();
}
}

/*
输出内容 等待标记 下一个标记
a 1 2
b 2 3
c 3 1
*/
class WaitNotify {
// 打印 a 1 2
public void print(String str, int waitFlag, int nextFlag) {
for (int i = 0; i < loopNumber; i++) {
synchronized (this) {
while(flag != waitFlag) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print(str);
flag = nextFlag;
this.notifyAll();
}
}
}

// 等待标记
private int flag; // 2
// 循环次数
private int loopNumber;

public WaitNotify(int flag, int loopNumber) {
this.flag = flag;
this.loopNumber = loopNumber;
}
}
2、Lock 条件变量版

Lock就不需要借助等待标记,但是需要主线程来启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import sun.rmi.runtime.Log;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class Test30 {
public static void main(String[] args) throws InterruptedException {
AwaitSignal awaitSignal = new AwaitSignal(5);
Condition a = awaitSignal.newCondition();
Condition b = awaitSignal.newCondition();
Condition c = awaitSignal.newCondition();
new Thread(() -> {
awaitSignal.print("a", a, b);
}).start();
new Thread(() -> {
awaitSignal.print("b", b, c);
}).start();
new Thread(() -> {
awaitSignal.print("c", c, a);
}).start();

Thread.sleep(1000);
awaitSignal.lock();
try {
System.out.println("开始...");
a.signal();
} finally {
awaitSignal.unlock();
}

}
}

class AwaitSignal extends ReentrantLock{
private int loopNumber;

public AwaitSignal(int loopNumber) {
this.loopNumber = loopNumber;
}
// 参数1 打印内容, 参数2 进入哪一间休息室, 参数3 下一间休息室
public void print(String str, Condition current, Condition next) {
for (int i = 0; i < loopNumber; i++) {
lock();
try {
current.await();
System.out.print(str);
next.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unlock();
}
}
}
}

注意:该实现没有考虑 a,b,c 线程都就绪再开始

3、Park Unpark 版

依旧需要主线程来启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.Test31")
public class Test31 {

static Thread t1;
static Thread t2;
static Thread t3;
public static void main(String[] args) {
ParkUnpark pu = new ParkUnpark(5);
t1 = new Thread(() -> {
pu.print("a", t2);
});
t2 = new Thread(() -> {
pu.print("b", t3);
});
t3 = new Thread(() -> {
pu.print("c", t1);
});
t1.start();
t2.start();
t3.start();

LockSupport.unpark(t1);
}
}

class ParkUnpark {
public void print(String str, Thread next) {
for (int i = 0; i < loopNumber; i++) {
LockSupport.park();
System.out.print(str);
LockSupport.unpark(next);
}
}

private int loopNumber;

public ParkUnpark(int loopNumber) {
this.loopNumber = loopNumber;
}
}

6、享元模式(Flyweight pattern)

1、简介

定义 英文名称:Flyweight pattern. 当需要重用数量有限的同一类对象时

比如说String,为了保证String不可变性,String在进行操作的时候经常使用的方法是:保护性拷贝。这种方式有个缺点:当拷贝的内容相当大的时候,这个时候对系统的性能以及内存的状态是非常不利的,这个时候就需要使用享元模式了

wikipedia: A flyweight is an object that minimizes memory usage by sharing as much data as possible with other similar objects

2、体现

1、包装类

在JDK中 BooleanByteShortIntegerLongCharacter 等包装类提供了 valueOf 方法,例如 Long 的valueOf **==会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象==**,大于这个范围,才会新建 Long 对象:

1
2
3
4
5
6
7
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}

LongCache的初始化:

1
2
3
4
5
6
7
8
9
10
private static class LongCache {
private LongCache(){}

static final Long cache[] = new Long[-(-128) + 127 + 1];

static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}

注意:

  • Byte, Short, Long 缓存的范围都是 -128~127
  • Character 缓存的范围是 0~127
  • Integer的默认范围是 -128~127
    • 最小值不能变
    • 但最大值可以通过调整虚拟机参数 -Djava.lang.Integer.IntegerCache.high 来改变
  • Boolean 缓存了 TRUE 和 FALSE
2、String 串池

在JVM的StringTable具体说明

3、BigDecimal BigInteger

注意:BigDecimal BigInteger的单个方法是线程安全的,但是方法之间组合组合不一定是线程安全的(有时候使用AutomicIntrger等等原子类来保证它们在组合下的线程安全)

3、DIY

例如:一个线上商城应用,QPS 达到数千,如果每次都重新创建和关闭数据库连接,性能会受到极大影响。 这时预先创建好一批连接,放入连接池。一次请求到达后,从连接池获取连接,使用完毕后再还回连接池,这样既节约了连接的创建和关闭时间,也实现了连接的重用,不至于让庞大的连接数压垮数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
import lombok.extern.slf4j.Slf4j;

import java.sql.*;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicIntegerArray;

public class Test3 {
public static void main(String[] args) {
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}

@Slf4j(topic = "c.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;

// 2. 连接对象数组
private Connection[] connections;

// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;

// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}

// 5. 借连接
public Connection borrow() {
while(true) {
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 如果没有空闲连接,当前线程进入等待
synchronized (this) {
try {
log.debug("wait...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
synchronized (this) {
log.debug("free {}", conn);
this.notifyAll();
}
break;
}
}
}
}

class MockConnection implements Connection {

private String name;

public MockConnection(String name) {
this.name = name;
}

@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}

@Override
public Statement createStatement() throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql) throws SQLException {
return null;
}

@Override
public String nativeSQL(String sql) throws SQLException {
return null;
}

@Override
public void setAutoCommit(boolean autoCommit) throws SQLException {

}

@Override
public boolean getAutoCommit() throws SQLException {
return false;
}

@Override
public void commit() throws SQLException {

}

@Override
public void rollback() throws SQLException {

}

@Override
public void close() throws SQLException {

}

@Override
public boolean isClosed() throws SQLException {
return false;
}

@Override
public DatabaseMetaData getMetaData() throws SQLException {
return null;
}

@Override
public void setReadOnly(boolean readOnly) throws SQLException {

}

@Override
public boolean isReadOnly() throws SQLException {
return false;
}

@Override
public void setCatalog(String catalog) throws SQLException {

}

@Override
public String getCatalog() throws SQLException {
return null;
}

@Override
public void setTransactionIsolation(int level) throws SQLException {

}

@Override
public int getTransactionIsolation() throws SQLException {
return 0;
}

@Override
public SQLWarning getWarnings() throws SQLException {
return null;
}

@Override
public void clearWarnings() throws SQLException {

}

@Override
public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public Map<String, Class<?>> getTypeMap() throws SQLException {
return null;
}

@Override
public void setTypeMap(Map<String, Class<?>> map) throws SQLException {

}

@Override
public void setHoldability(int holdability) throws SQLException {

}

@Override
public int getHoldability() throws SQLException {
return 0;
}

@Override
public Savepoint setSavepoint() throws SQLException {
return null;
}

@Override
public Savepoint setSavepoint(String name) throws SQLException {
return null;
}

@Override
public void rollback(Savepoint savepoint) throws SQLException {

}

@Override
public void releaseSavepoint(Savepoint savepoint) throws SQLException {

}

@Override
public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {
return null;
}

@Override
public Clob createClob() throws SQLException {
return null;
}

@Override
public Blob createBlob() throws SQLException {
return null;
}

@Override
public NClob createNClob() throws SQLException {
return null;
}

@Override
public SQLXML createSQLXML() throws SQLException {
return null;
}

@Override
public boolean isValid(int timeout) throws SQLException {
return false;
}

@Override
public void setClientInfo(String name, String value) throws SQLClientInfoException {

}

@Override
public void setClientInfo(Properties properties) throws SQLClientInfoException {

}

@Override
public String getClientInfo(String name) throws SQLException {
return null;
}

@Override
public Properties getClientInfo() throws SQLException {
return null;
}

@Override
public Array createArrayOf(String typeName, Object[] elements) throws SQLException {
return null;
}

@Override
public Struct createStruct(String typeName, Object[] attributes) throws SQLException {
return null;
}

@Override
public void setSchema(String schema) throws SQLException {

}

@Override
public String getSchema() throws SQLException {
return null;
}

@Override
public void abort(Executor executor) throws SQLException {

}

@Override
public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException {

}

@Override
public int getNetworkTimeout() throws SQLException {
return 0;
}

@Override
public <T> T unwrap(Class<T> iface) throws SQLException {
return null;
}

@Override
public boolean isWrapperFor(Class<?> iface) throws SQLException {
return false;
}
}

测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
15:26:48.211 c.Pool [THread-3] - wait...
15:26:48.211 c.Pool [Thread-0] - borrow MockConnection{name= '连接1'}
15:26:48.215 c.Pool [Thread-4] - wait...
15:26:48.215 c.Pool [Thread-2] - wait...
15:26:48.211 c.Pool [Thread-1] - borrow MockConnection{name= '连接2'}
15:26:48.397 c.Pool [Thread-0] - free MockConnection{name= '连接1'}
15:26:48.397 c.Pool [Thread-4] - wait...
15:26:48.397 c.Pool [Thread-2] - borrow MockConnection{name= '连接1'}
15:26:48.397 c.Pool [Thread-3] - wait...
15:26:48.412 c.Pool [Thread-1] - free MockConnection{name=' 连接2'}
15:26:48.412 c.Pool [Thread-3] - borrow MockConnection{name= '连接2'}
15:26:48.412 c.Pool [Thread-4] - wait...
15:26:48.796 c.Pool [Thread-3] - free MockConnection{name= '连接2'}
15:26:48.796 c.Pool [Thread-4] - borrow MockConnection{name= '连接2'}
15:26:49.340 c.Pool [Thread-2] - free MockConnection{name=' 连接1'}
15:26:49.561 c.Pool [Thread-4] - free MockConnection{name= '连接2'}

以上实现没有考虑:

  • 连接的动态增长与收缩
  • 连接保活(可用性检测)
  • 等待超时处理
  • 分布式 hash

对于关系型数据库,有比较成熟的连接池实现,例如c3p0, druid等 对于更通用的对象池,可以考虑使用apache commons pool(redis使用),例如redis连接池可以参考jedis中关于连接池的实现。

7、工作线程模式(Worker Thread)——异步模式

1、定义

让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现就是线程池,也体现了经典设计模式中的享元模式

例如,海底捞的服务员(线程),轮流处理每位客人的点餐(任务),如果为每位客人都配一名专属的服务员,那么成本就太高了(对比另一种多线程设计模式:Thread-Per-Message

注意:不同任务类型应该使用不同的线程池,这样能够避免饥饿,并能提升效率

例如,如果一个餐馆的工人既要招呼客人(任务类型A),又要到后厨做菜(任务类型B)显然效率不咋地,分成服务员(线程池A)与厨师(线程池B)更为合理,当然你能想到更细致的分工

2、饥饿

固定大小线程池会有饥饿现象:

  • 两个工人是同一个线程池中的两个线程
  • 他们要做的事情是:为客人点餐和到后厨做菜,这是两个阶段的工作
    • 客人点餐:必须先点完餐,等菜做好,上菜,在此期间处理点餐的工人必须等待
    • 后厨做菜:没啥说的,做就是了
  • 比如工人A 处理了点餐任务,接下来它要等着 工人B 把菜做好,然后上菜,他俩也配合的蛮好
  • 但现在同时来了两个客人,这个时候工人A 和工人B 都去处理点餐了,这时没人做饭了,饥饿
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Slf4j(topic = "c.TestDeadLock")
public class TestStarvation {

static final List<String> MENU = Arrays.asList("地三鲜", "宫保鸡丁", "辣子鸡丁", "烤鸡翅");
static Random RANDOM = new Random();
static String cooking() {
return MENU.get(RANDOM.nextInt(MENU.size()));
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);

pool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
pool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});

}
}

输出:

1
2
15:28:41.386 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:28:41.386 c.TestDeadLock [pool-1-thread-2] - 处理点餐...

解决方法:可以增加线程池的大小,不过不是根本解决方案,还是前面提到的,不同的任务类型,采用不同的线程池,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Slf4j(topic = "c.TestDeadLock")
public class TestStarvation {

static final List<String> MENU = Arrays.asList("地三鲜", "宫保鸡丁", "辣子鸡丁", "烤鸡翅");
static Random RANDOM = new Random();
static String cooking() {
return MENU.get(RANDOM.nextInt(MENU.size()));
}
public static void main(String[] args) {
ExecutorService waiterPool = Executors.newFixedThreadPool(1);
ExecutorService cookPool = Executors.newFixedThreadPool(1);

waiterPool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
waiterPool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});

}
}

输出:

1
2
3
4
5
6
15:33:14.925 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:33:14.928 c.TestDeadLock [pool-2-thread-1] - 做菜
15:33:14.929 c.TestDeadLock [pool-1-thread-1] - 上菜: 辣子鸡丁
15:33:14.931 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:33:14.931 c.TestDeadLock [pool-2-thread-1] - 做菜
15:33:14.931 c.TestDeadLock [pool-1-thread-1] - 上菜: 宫保鸡丁

3、创建多少线程池合适

  • 过小会导致程序不能充分地利用系统资源、容易导致饥饿
  • 过大会导致更多的线程上下文切换,占用更多内存
1、CPU 密集型运算

通常采用 cpu 核数 + 1 能够实现最优的 CPU 利用率,**+1 是保证当线程由于页缺失故障(操作系统)或其它原因导致暂停时,额外的这个线程就能顶上去,保证 CPU 时钟周期不被浪费**

2、I/O 密集型运算

CPU 不总是处于繁忙状态,例如,当你执行业务计算时,这时候会使用 CPU 资源,但当你执行 I/O 操作时、远程RPC 调用时,包括进行数据库操作时,这时候 CPU 就闲下来了,你可以利用多线程提高它的利用率。

经验公式:线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间

例如 4 核 CPU 计算时间是 50% ,其它等待时间是 50%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 50% = 8

例如 4 核 CPU 计算时间是 10% ,其它等待时间是 90%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 10% = 40

4、自定义线程池

具体参考7、JUC当中的15、ThreadPool线程池的9、自定义线程池

8、不可变(Immutability)模式

如果对象一旦被创建,状态就不会再发生任何变化,并且只允许存在只读方法,这个对象就是不可变对象。利用不可变对象解决并发问题的模式,就是不可变模式。快速实现具备不可变性的类时,将类设置成final,类内的所有属性设置成final,只暴露只读方法即可。

经常用到的String对象和各种基础类型的包装类,比如,Long,Integer都具备不可变性。更进一步,基本数据类型的包装类都用到了享元模式(Flyweight Pattern),即在JVM启动时,创建一个对象池,当创建包装类型的对象时,首先查找对象池是否存在,如果不存在,才会创建新对象,并将其放入对象池中。比如,Long对象就默认缓存了[-128,127]之间的对象。几乎所有用到了享元模式的对象,比如,包装类对象,都不适合做锁,因为看上去是私有的这些对象,其实是共用的,会导致并发问题。

但是在使用不可变模式时,一定要搞清楚特定不可变对象的边界在哪里。比如,一个final类C的final成员变量a,当a的内部存在非final的其他对象时,并且C中存在着get_a的public接口,那么C就不是线程安全的。

9、Copy-on-Write模式

Copy-on-Write模式适用于对数据的实时性不敏感,读多写少且对读性能要求极为苛刻的小数据场景。

具体的实现也很简单,当数据需要修改时,先复制一份出来,在复制的数据上进行修改,并发读还是在旧的数据上,当数据修改完成后,再将老数据替换为修改后的新数据即可。但需要注意的是,当发生并发写时,可以使用CAS的策略来完成。

10、线程本地存储

Java语言提供ThreadLocal实现避免共享,即每个线程拥有自己的一份数据,线程之间没有竞争关系。

它的具体实现原理有点反直觉,因为ThreadLocal本质上仅仅是一个代理工具类,真正的数据存储在Thread类中。即,当ThreadLocal.get()获取线程本地数据时,通过Thread.currentThread().threadLocals来获取线程内真正的本地对象进行操作。

这种设计方式,从业务上看,线程的本地数据存在线程内部显然更合理,更重要的是,这样做不容易产生内存泄漏,因为线程本地对象和线程同生命周期,当线程被gc时,其数据也同样可以被gc掉。

但需要注意的是,在线程池的场景中,因为线程池中的线程通常与进程是同生共死的,即使线程本地变量的生命周期已经结束了,但因为该线程池尚未被释放,数据也是无法被回收的。因此,在这种场景下,ThreadLocal方案要小心使用。

11、Thread-Per-Message

现实世界中,很多事情需要委托他人办理,同样的场景,在并发编程领域,就是Thread-Per_message模式,简而言之,就是由一个线程接收任务,并发的为每一个收到的任务分配一个独立线程,这是最简单的分工方法,实现起来也非常简单。

线程在Java中是成本非常高的对象,本质上并不适合高并发场景。但是,换个角度思考,语言,工具和框架本身应该是帮助我们更敏捷的实现稳定可靠的方案,Thread-Per-Message是一种最简单的分工方法,Java语言支持不了,显然是Java语言本身的问题。

在Go语言中,存在一种轻量级线程,即协程的方案。在协程的架构下,Thread-Per-Message模式就完全没有问题了。


参考文档

Juc_并发编程目录

透彻理解Java并发编程系列

Java 全栈知识体系

Java 内存模型详解

面试官:说一下公平锁和非公平锁的区别?

ReentrantLock中的公平锁可能并不是真正意义上的公平

可重入锁

死锁面试题(什么是死锁,产生死锁的原因及必要条件)

乐观锁、悲观锁

什么是乐观锁,什么是悲观锁

本文主要参考自泰迪的bagwell的https://www.jianshu.com/p/32a15ef2f1bf和https://www.jianshu.com/p/6a14d0b54b8d,在此基础上参考了如下文章

推荐阅读ForkJoinPool的作者Doug Lea的一篇文章《A Java Fork/Join Framework》英文原文地址

JUC

Java并发编程实战(三:并发设计模式)

黑马程序员全面深入学习Java并发编程,JUC并发编程全套教程

【尚硅谷】大厂必备技术之JUC并发编程2021最新版

一、五大算法

0、穷举法

穷举法简单粗暴,没有什么问题是搞不定的,只要你肯花时间。同时对于小数据量,穷举法就是最优秀的算法。

1、贪婪算法

贪婪算法可以获取到问题的局部最优解,不一定能获取到全局最优解,同时获取最优解的好坏要看贪婪策略的选择。特点就是简单,能获取到局部最优解。同样是贪婪算法,不同的贪婪策略会导致得到差异非常大的结果。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/51417211

2、动态规划算法

当最优化问题具有重复子问题和最优子结构的时候,就是动态规划出场的时候了。动态规划算法的核心就是提供了一个memory来缓存重复子问题的结果,避免了递归的过程中的大量的重复计算。动态规划算法的难点在于怎么将问题转化为能够利用动态规划算法来解决。当重复子问题的数目比较小时,动态规划的效果也会很差。如果问题存在大量的重复子问题的话,那么动态规划对于效率的提高是非常恐怖的。

具体的详细解析请参见下面的文章:

3、分治算法(divide and conquer)

分治算法的逻辑更简单了,就是一个词,分而治之。分治算法就是把一个大的问题分为若干个子问题,然后在子问题继续向下分,一直到base cases,通过base cases的解决,一步步向上,最终解决最初的大问题。分治算法是递归的典型应用。

1、基本概念

正如名字divide and conquer所言,分治算法分为两步,一步是divide,一步是conquer。

Divide:Smaller Problems are solved recursively except base cases.

Conquer:The solution to the original problem is then formed from the solutions to the sub-problem.

在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……

任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,……。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。

2、基本思想及策略

分治法的设计思想是:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之

分治策略是:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。

如果原问题可分割成k个子问题,1<k≤n,且这些子问题都可解并可利用这些子问题的解求出原问题的解,那么这种分治法就是可行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。

3、分治法适用的情况

分治法所能解决的问题一般具有以下几个特征:

  1. 该问题的规模缩小到一定的程度就可以容易地解决
  2. 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质
  3. 利用该问题分解出的子问题的解可以合并为该问题的解;
  4. 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题

第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;

第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;

第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法动态规划法

第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好

4、分治法的基本步骤

分治法在每一层递归上都有三个步骤:

  1. step1 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
  2. step2 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
  3. step3 合并:将各个子问题的解合并为原问题的解。

它的一般的算法设计模式如下: Divide-and-Conquer(P)

  1. if |P|≤n0

  2. then return(ADHOC(P))

  3. 将P分解为较小的子问题 P1 ,P2 ,…,Pk

  4. for i ← 1 to k

  5. do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi

  6. T ← MERGE(y1,y2,…,yk) △ 合并子问题

  7. return(T)

其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。ADHOC(P)是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法ADHOC(P)求解。算法MERGE(y1,y2,…,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,…,Pk的相应的解y1,y2,…,yk合并为P的解。

5、分治排序的运行时间问题及复杂度分析

img

一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阀值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有: T(n) = k*T(n/m)+f(n)

通过迭代法求得方程的解:

递归方程及其解只给出n等于m的方幂时T(n)的值,但是如果认为T(n)足够平滑,那么由n等于m的方幂时T(n)的值可以估计T(n)的增长速度。通常假定T(n)是单调上升的,从而当:mi≤n<mi+1时,T(mi)≤T(n)<T(mi+1)。

6、可使用分治法求解的一些经典问题

  1. 二分搜索
  2. 大整数乘法
  3. Strassen矩阵乘法
  4. 棋盘覆盖
  5. 合并排序
  6. 快速排序
  7. 线性时间选择
  8. 最接近点对问题
  9. 循环赛日程表
  10. 汉诺塔

7、依据分治法设计程序时的思维过程

实际上就是类似于数学归纳法,找到解决本问题的求解方程公式,然后根据方程公式设计递归程序。

  1. 一定是先找到最小问题规模时的求解方法
  2. 然后考虑随着问题规模增大时的求解方法
  3. 找到求解的递归函数式后(各种规模或因子),设计递归程序即可。

8、具体问题分析(java)

9、总结

分治算法的一个核心在于子问题的规模大小是否接近,如果接近则算法效率较高。

分治算法和动态规划都是解决子问题,然后对解进行合并;但是分治算法是寻找远小于原问题的子问题(因为对于计算机来说计算小数据的问题还是很快的),同时分治算法的效率并不一定好,而动态规划的效率取决于子问题的个数的多少,子问题的个数远小于子问题的总数的情况下(也就是重复子问题多),算法才会很高效。

10、具体的详细解析请参见下面的文章

4、回溯算法

回溯算法是深度优先策略的典型应用,回溯算法就是沿着一条路向下走,如果此路不同了,则回溯到上一个分岔路,在选一条路走,一直这样递归下去,直到遍历万所有的路径。八皇后问题是回溯算法的一个经典问题,还有一个经典的应用场景就是迷宫问题。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/17354461

5、 分支限界算法

回溯算法是深度优先,那么分支限界法就是广度优先的一个经典的例子。回溯法一般来说是遍历整个解空间,获取问题的所有解,而分支限界法则是获取一个解(一般来说要获取最优解)。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/17102037

二、排序

三、查找

1、Json的几个注解

pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.9.0</version>
</dependency>

@JsonFormat与@Date TimeFormat注解的使用

@JsonFormat

作用:从数据库获取时间传到前端进行展示的时候,我们有时候可能无法得到一个满意的时间格式的时间日期,在数据库中显示的是正确的时间格式,获取出来却变成了很丑的时间戳。@JsonFormat注解很好的解决了这个问题。

使用:

1
2
3
//设置时区为上海时区,时间格式自己据需求定。
@JsonFormat(pattern="yyyy-MM-dd",timezone = "GMT+8")
private Date testTime;

这里解释一下:@JsonFormat(pattern=”yyyy-MM-dd”,timezone = “GMT+8”)

  • pattern:是你需要转换的时间日期的格式
  • timezone:是时间设置为东八区,避免时间在转换中有误差

提示:@JsonFormat注解可以在属性的上方,同样可以在属性对应的get方法上,两种方式没有区别

完成上面两步之后,我们用对应的实体类来接收数据库查询出来的结果时就完成了时间格式的转换,再返回给前端时就是一个符合我们设置的时间格式了

@Date TimeFormat

pom.xml:

1
2
3
4
5
6
<!-- joda-time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.3</version>
</dependency>

作用:我们在使用WEB服务的时,可能会需要用到,传入时间给后台,比如注册新用户需要填入出生日期等,这个时候前台传递给后台的时间格式同样是不一致的,@DataTimeFormat便很好的解决了这个问题。

使用:在controller层我们使用spring mvc 表单自动封装映射对象时,我们在对应的接收前台数据的对象的属性上加@DateTimeFormat

1
2
3
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone="GMT+8")
private Date symstarttime;

我这里就只贴这两个属性了,这里我两个注解都同时使用了,因为我既需要取数据到前台,也需要前台数据传到后台,都需要进行时间格式的转换,可以同时使用。

总结:

  • 注解@JsonFormat主要是后台到前台的时间格式的转换
  • 注解@DataFormAT主要是前后到后台的时间格式的转换

资料来源:@JsonFormat与@DateTimeFormat注解的使用

@JsonProperty使用详解

作用:@JsonProperty注解主要用于实体类的属性上,作用可以简单的理解为在反序列化的时候给属性重命名(多一个名字来识别)

使用:

1
2
@JsonProperty(value = "fake_name", required = true)
private String fakeName;

注意:

  • 使用JSON.toJsonString的时候实体类需要有getter方法,否则会输出{}
  • @requestBody注解需要在post请求下才能正常使用.

资料来源:@JsonProperty使用详解

@JsonInclude

作用:

  • JsonJsonInclude.Include.ALWAYS 这个是默认策略,任何情况下都序列化该字段,和不写这个注解是一样的效果。
  • JsonJsonInclude.Include.NON_NULL这个最常用,即如果加该注解的字段为null,那么就不序列化这个字段了。
  • JsonJsonInclude.Include.NON_ABSENT这个包含NON_NULL,即为null的时候不序列化。

使用:

1
2
@JsonInclude(JsonInclude.Include.NON_NULL)
private String username;

资料来源:jackSon中@JsonInclude注解详解

@JsonIgnore注解

作用:在json序列化时将pojo中的一些属性忽略掉,标记在属性或者方法上,返回的json数据即不包含该属性。

使用:

1
2
@JsonIgnore
private String password;// 密码

资料来源:@JsonIgnore注解

2、ip2region——Java 根据 IP 地址来获取位置

pom.xml:

1
2
3
4
5
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>1.7.2</version>
</dependency>

然后下载 IP库 ip2region.db: https://gitee.com/lionsoul/ip2region/tree/master/data

下载解压后只需要 data 目录下的 ip2region.db 就可以了 .

把 ip2region.db 复制到 maven 项目的 resources 目录下.

然后具体实现,可以把以下代码封装成方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Ip2RegionTest {
public static void main(String[] args){
//ip
String ip="220.248.12.158";

// 判断是否为IP地址 (可用)
//boolean isIpAddress = Util.isIpAddress(ip);

//ip和long互转 (可用)
//long ipLong = Util.ip2long(ip);
//String strIp = Util.long2ip(ipLong);

//根据ip进行位置信息搜索
DbConfig config = new DbConfig();

//获取ip库的位置(放在src下)(直接通过测试类获取文件Ip2RegionTest为测试类)
String dbfile = Ip2RegionTest.class.getResource("/ip2region.db").getPath();

DbSearcher searcher = new DbSearcher(config, dbfile);

//采用Btree搜索
DataBlock block = searcher.btreeSearch(ip);

//打印位置信息(格式:国家|大区|省份|城市|运营商)
System.out.println(block.getRegion());
}
}

还有一种实现方法如下:

此内容参考了 ip2region源码的 : org.lionsoul.ip2region.test.TestSearcher.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package com.test;

import java.io.File;
import java.lang.reflect.Method;

import org.lionsoul.ip2region.DataBlock;
import org.lionsoul.ip2region.DbConfig;
import org.lionsoul.ip2region.DbSearcher;
import org.lionsoul.ip2region.Util;

public class IPUtil {

public static String getCityInfo(String ip){

//db
String dbPath = IPUtil.class.getResource("/ip2region.db").getPath();

File file = new File(dbPath);
if ( file.exists() == false ) {
System.out.println("Error: Invalid ip2region.db file");
}

//查询算法
int algorithm = DbSearcher.BTREE_ALGORITHM; //B-tree
//DbSearcher.BINARY_ALGORITHM //Binary
//DbSearcher.MEMORY_ALGORITYM //Memory
try {
DbConfig config = new DbConfig();
DbSearcher searcher = new DbSearcher(config, dbPath);

//define the method
Method method = null;
switch ( algorithm )
{
case DbSearcher.BTREE_ALGORITHM:
method = searcher.getClass().getMethod("btreeSearch", String.class);
break;
case DbSearcher.BINARY_ALGORITHM:
method = searcher.getClass().getMethod("binarySearch", String.class);
break;
case DbSearcher.MEMORY_ALGORITYM:
method = searcher.getClass().getMethod("memorySearch", String.class);
break;
}

DataBlock dataBlock = null;
if ( Util.isIpAddress(ip) == false ) {
System.out.println("Error: Invalid ip address");
}

dataBlock = (DataBlock) method.invoke(searcher, ip);

return dataBlock.getRegion();

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

return null;
}


public static void main(String[] args) throws Exception{
System.err.println(getCityInfo("220.248.12.158"));
}
}

资料来源:Java 根据 IP 地址来获取 位置 – 使用 ip2region

Joda-Time——Java 日期时间处理库

pom.xml

1
2
3
4
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>

具体API查看:https://www.oschina.net/p/joda-time?hmsr=aladdin1e1

[TOC]

第一章 操作系统引论及概述


在这里插入图片描述

1.1.1、概念、功能与目标

  1. 定义:

    操作系统(Operating System,OS)是指控制和管理整个计算机系统的硬件软件资源,并合理地组织调度计算机的工作和资源的分配;以提供给用户和其他软件方便的接口和环境;它是计算机系统中最基本的系统软件

    1. 操作系统是系统资源的管理者,负责管理协调硬件、软件等计算机资源的工作
    2. 向上层的应用程序、用户提供方便易用的服务
    3. 操作系统是最接近硬件的一层软件,是系统软件不是硬件

    image-20210406102804945

  2. 功能与目标

    1. 操作系统是系统资源的管理者

      image-20210406102859419

    2. 向上层提供方便易用的服务

      • 命令接口

        • 联机命令接口实例(Windows系统) 联机命令接口=交互式命令接口

          特点:用户说一句,系统跟着做一句

        • 脱机命令接口实例(Windows系统) 脱机命令接口=批处理命令接口

          使用windows系统的搜索功能,搜索C盘中的 *.bat文件,用记事本任意打开一个。

          特点:用户说一堆,系统跟着做一堆

      • 程序接口

        可以在程序中进行系统调用来使用程序接口。普通用户不能直接使用程序接口,只能通过程序代码间接使用。

        如C盘Windows\System32中有很多的*.dll文件。程序员在程序中调用(该调用过程即为系统调用)即可实现创建窗口等功能。

        image-20210406104140771

      • GUI:图形用户界面(Graphical User Interface)

        用户可以使用形象的图形界面进行操作,而不再需要记忆复杂的命令、参数。
        例子:在Windows 操作系统中,删除一个文件只需要把文件“拖拽”到回收站即可。

      image-20210406104309877

    3. 操作系统是最接近硬件的一层软件

      封装思想:操作系统把一些丑陋的硬件功能封装成简单易用的服务,使用户能更方便地使用计算机,用户无需关心底层硬件的原理,只需要对操作系统发出命令即可

      image-20210406104343359

  3. 脑图

    image-20210406104426461

1.1.2、操作系统的四个特征

  1. 并发

    并发与并行的区别:

    • 并发:两个或多个事件在同一时间间隔内发生。这些事件宏观上是同时发生的,但微观上是交替发生的。
    • 并行:指两个或多个事件在同一时刻同时发生

    例子:

    image-20210406111514894

    操作系统的并发性指计算机系统中“同时”运行着多个程序,这些程序宏观上看是同时运行着的,而微观上看是交替运行的。

    操作系统就是伴随着“多道程序技术”而出现的。因此,操作系统和程序并发是一起诞生的

    注意:

    • 单核CPU同一时刻只能执行一个程序,各个程序只能并发地执行
    • 多核CPU同一时刻可以同时执行多个程序,多个程序可以并行地执行
  2. 共享

    共享即资源共享,是指系统中的资源可供内存中多个并发执行的进程共同使用。

    • 互斥共享方式:

      系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源

    • 同时共享方式:

      系统中的某些资源,允许一个时间段内由多个进程“同时”对它们进行访问

    所谓的“同时”往往是宏观上的,而在微观上,这些进程可能是交替地对该资源进行访问的(即分时共享

    生活实例:

    • 互斥共享方式:使用QQ和微信视频。同一时间段内摄像头只能分配给其中一个进程。
    • 同时共享方式:使用QQ发送文件A,同时使用微信发送文件B。宏观上看,两边都在同时读取并发送文件,说明两个进程都在访问硬盘资源,从中读取数据。微观上看,两个进程是交替着访问硬盘的。

    并发与共享是操作系统最基本的两个特征,两者互为存在条件

    • 并发性指计算机系统中同时存在着多个运行着的程序。
    • 共享性是指系统中的资源可供内存中多个并发执行的进程共同使用。

    image-20210406112122162

  3. 虚拟

    虚拟是指把一个物理上的实体变为若干个逻辑上的对应物。物理实体(前者)是实际存在的,而逻辑上对应物(后者)是用户感受到的。

    虚拟技术

    • 空分复用技术(如虚拟存储器技术):实际只有4GB的内存,在用户看来似乎远远大于4GB
    • 时分复用技术(如虚拟处理器):微观上处理机在各个微小的时间段内交替着为各个进程服务

    显然,如果失去了并发性,则一个时间段内系统中只需运行一道程序,那么就失去了实现虚拟性的意义了。因此,没有并发性,就谈不上虚拟性

  4. 异步

    异步是指,在多道程序环境下,允许多个程序并发执行,但由于资源有限,进程的执行不是一贯到底的,而是走走停停,以不可预知的速度向前推进,这就是进程的异步性。

    由于并发运行的程序会争抢着使用系统资源,而系统中的资源有限,因此进程的执行不是一贯到底的,而是走走停停的,以不可预知的速度向前推进。

    如果失去了并发性,即系统只能串行地运行各个程序,那么每个程序的执行会一贯到底。只有系统拥有并发性,才有可能导致异步性。

脑图:

image-20210406112516655

1.1.3、操作系统的发展与分类

操作系统的发展:

  1. 手工操作阶段

    主要缺点:用户独占全机、人机速度矛盾导致资源利用率极低

  2. 批处理阶段

    1. 单道批处理系统

      引入脱机输入/输出技术(用外围机+磁带完成),并由**监督程序(操作系统的雏形)**负责控制作业的输入、输出

      主要优点:缓解了一定程度的人机速度矛盾,资源利用率有所提升。

      主要缺点:内存中仅能有一道程序运行,只有该程序运行结束之后才能调入下一道程序。CPU有大量的时间是在空闲等待I/O完成。资源利用率依然很低。

    2. 多道批处理系统

      操作系统正式诞生,用于支持多道程序并发运行

      主要优点:多道程序并发执行,共享计算机资源。资源利用率大幅提升,CPU和其他资源更能保持“忙碌”状态,系统吞吐量增大

      主要缺点:用户响应时间长,没有人机交互功能(用户提交自己的作业之后就只能等待计算机处理完成,中间不能控制自己的作业执行。eg:无法调试程序/无法在程序运行过程中输入一些参数)

  3. 分时操作系统

    计算机以时间片为单位轮流为各个用户/作业服务,各个用户可通过终端与计算机进行交互。

    主要优点:用户请求可以被即时响应,解决了人机交互问题。允许多个用户同时使用一台计算机,并且用户对计算机的操作相互独立,感受不到别人的存在。

    主要缺点:不能优先处理一些紧急任务。操作系统对各个用户/作业都是完全公平的,循环地为每个用户/ 作业服务一个时间片,不区分任务的紧急性

  4. 实时操作系统

    在实时操作系统的控制下,计算机系统接收到外部信号后及时进行处理,并且要在严格的时限内处理完事件。实时操作系统的主要特点是及时性可靠性

    • 硬实时系统:必须在绝对严格的规定时间内完成处理。如:导弹控制系统、自动驾驶系统
    • 软实时系统:能接受偶尔违反时间规定。如:12306火车订票系统

    主要优点:能够优先响应一些紧急任务,某些紧急任务不需时间片排队。

操作系统的分类:

  1. 网络操作系统

    伴随着计算机网络的发展而诞生的,能把网络中各个计算机有机地结合起来,实现数据传送等功能,实现网络中各种资源的共享(如文件共享)和各台计算机之间的通信。(如:Windows NT 就是一种典型的网络操作系统,网站服务器就可以使用)

  2. 分布式操作系统

    主要特点是分布性并行性。系统中的各台计算机地位相同,任何工作都可以分布在这些计算机上,由它们并行、协同完成这个任务

  3. 个人计算机操作系统

    如 Windows XP、MacOS,方便个人使用

脑图:

image-20210406115156103

1.1.4、操作系统的运行机制与体系结构

  1. 运行机制

    • 两种指令

      • 特权指令:不允许用户程序使用。如内存清零指令
      • 非特权指令:如普通的运算指令
    • 两种处理器状态

      • 用户态(目态):此时CPU只能执行非特权指令
      • 核心态(管态):特权指令、非特权指令都能执行

      两种处理器状态用程序状态字寄存器(PSW)中的某标志位来标识当前处理器处于什么状态,如0表示用户态,1表示核心态。

    • 两种程序

      • 内核程序

        操作系统的内核程序是系统的管理者,既可以执行特权指令,也可以执行非特权指令,运行在核心态

      • 应用程序

        为了保证系统的安全运行,普通应用程序只能执行非特权指令,运行在用户态

    image-20210406125651838

  2. 操作系统内核

    内核是计算机上配置的底层软件,是操作系统最基本、最核心的部分

    实现操作系统内核功能的那些程序就是内核程序

    • 时钟管理:实现计时管理
    • 中断处理:负责实现中断机制
    • 原语
      • 是一种特殊的程序
      • 处于操作系统最底层,是最接近硬件的部分
      • 这种程序的运行具有原子性,其运行只能一气呵成,不可中断
      • 运行时间短,调用频繁
    • 对系统资源进行管理的功能(有的操作系统不把这部分功能归为“内核功能”。也就是说,不同的操作系统,对内核功能的划分可能并不一样)
      • 进程管理
      • 存储器管理
      • 设备管理
  3. 体系结构

    • 大内核

      将操作系统的主要功能都作为系统内核运行在核心态

      优点:高性能

      缺点:内核代码大,结构混乱,难以维护

      典型的大内核/宏内核/单内核操作系统:Linux、UNIX

    • 微内核

      只把最基本的功能保留在内核

      优点:内核功能少,结构清晰,方便维护

      缺点:需要频繁地在核心态与用户态之间切换,性能低

      典型的微内核操作系统:Windows NT

    image-20210408192745129

    类比:

    image-20210406131034245

    image-20210406130802115

脑图:

image-20210406115337381

1.1.5、中断与异常

  1. 中断机制的诞生

    在早期的计算机没有中断机制,各个程序只能串行执行,系统资源的利用率低。

    为了解决上述问题,人们发明了操作系统(作为计算机的管理者),引入中断机制,实现了多道程序并发执行。

    本质:发生中断就意味着需要操作系统介入,开展管理工作

  2. 中断的概念与作用

    • 中断发生时,CPU立即进入核心态
    • 当中断发生后,当前运行的进程暂停运行,并有操作系统内核对中断进行处理
    • 对于不同的中断信号,会进行不同的处理

    发生中断就意味着需要操作系统介入,开展管理工作。由于操作系统的管理工作(比如进程切换、分配I/O设备等)需要使用特权指令,因此CPU要从用户态转为核心态。中断可以使CPU从用户态切换为核心态,使操作系统获得计算机的控制权。有了中断,才能实现多道程序并发执行。

    中断是实现CPU从用户态切换到核心态的唯一途径。通过执行一个特权指令,将程序状态字(PSW)对标志位设置为“核心态”。

  3. 中断(广义的中断)的分类

    • 内中断(也称“异常、例外、陷入”):与当前执行的指令有关,中断信号来源于CPU内部
      • 自愿中断:指令中断(如:系统调用时使用的访管指令(又叫陷入指令、trap指令))
      • 强迫中断
        • 硬件故障(如:缺页)
        • 软件故障(如:整数除0)
    • 外中断(也称“中断(狭义的中断)”):与当前执行的指令无关,中断信号来源于CPU外部
      • 外设请求(如:I/O操作完成发出的中断信号)
      • 人工干预(如:用户强行终止一个进程)

    image-20210406134755249

    另一种分类方式:

    • 内中断(也称“异常、例外、陷入”):与当前执行的指令有关,中断信号来源于CPU内部
      • 陷阱、陷入(trap):有意而为之的异常,如系统调用
      • 故障(fault):由错误条件引起的,可能被内核程序修复。内核程序修复故障后会把CPU使用权还给应用程序,让它继续执行下去。如:缺页故障。
      • 终止(abort):由致命错误引起,内核程序无法修复该错误,因此一般不再将CPU使用权还给引发终止的应用程序,而是直接终止该应用程序。如:整数除0、非法使用特权指令。
    • 外中断(也称“中断(狭义的中断)”):与当前执行的指令无关,中断信号来源于CPU外部
      • 外设请求(如:I/O操作完成发出的中断信号)
      • 人工干预(如:用户强行终止一个进程)

    image-20210406135217118

  4. 外中断的处理过程

    1. 检查:执行完每个指令之后,CPU都要检查当前是否有外部中断信号
    2. 保护:如果检测到外部中断信号,则需要保护被中断进程的CPU环境(如程序状态字PSW、程序计数器PC、各种通用寄存器)
    3. 处理:根据中断信号类型转入相应的中断处理程序
    4. 恢复:恢复原进程的CPU环境并退出中断,返回原进程继续往下执行
  5. 中断机制的基本原理

    不同的中断信号,需要用不同的中断处理程序来处理。当CPU检测到中断信号后,会根据中断信号的类型去查询“中断向量表”,以此来找到相应的中断处理程序在内存中的存放位置。

    显然,中断处理程序一定是内核程序,需要运行在“内核态”

    image-20210406135456448

脑图:

image-20210406135343449

1.1.6、系统调用

  1. 什么是系统调用

    操作系统作为用户和计算机硬件之间的接口,需要向上提供一些简单易用的服务。主要包括命令接口程序接口。其中,程序接口由一组系统调用组成。

    image-20210408185305701

    “系统调用”是操作系统提供给应用程序(程序员/编程人员)使用的接口,可以理解为一种可供应用程序调用的特殊函数,应用程序可以通过系统调用来请求获得操作系统内核的服务。

  2. 系统调用与库函数调用的区别

    image-20210408190637741

  3. 为什么系统调用是必须的

    生活场景:去学校打印店打印论文,你按下了WPS 的“打印”选项,打印机开始工作。
    你的论文打印到一半时,另一位同学按下了Word 的“打印”按钮,开始打印他自己的论文。

    思考:如果两个进程可以随意地、并发地共享打印机资源,会发生什么情况?

    两个进程并发运行,打印机设备交替地收到WPS 和Word 两个进程发来的打印请求,结果两篇论文的内容混杂在一起了…

    解决方法:由操作系统内核对共享资源进行统一的管理,并向上提供 “系统调用”,用户进程想要使用打印机这种共享资源,只能通过系统调用向操作系统内核发出请求。内核会对各个请求进行协调处理

  4. 什么功能要用系统调用实现

    应用程序通过系统调用请求操作系统的服务。而系统中的各种共享资源都由操作系统内核统一掌管,因此凡是与共享资源有关的操作(如存储分配、I/O操作、文件管理等),都必须通过系统调用的方式向操作系统内核提出服务请求,由操作系统内核代为完成。这样可以保证系统的稳定性和安全性,防止用户进行非法操作。

    image-20210408190445658

  5. 系统调用的过程

    image-20210408191025687

    1. 传递系统调用参数
    2. 执行陷入指令(用户态)
    3. 执行相应的内请求核程序处理系统调用(核心态)
    4. 返回应用程序

脑图:

image-20210408191123343

第一章总结

image-20210408192546560

第二章 进程与线程


在这里插入图片描述

在这里插入图片描述

2.1.1、进程的概念、组成与特征

1、定义——在计算机发展史上,”进程”是为了解决什么问题而被引入的?

1、进程的发展

在早期的计算机中,只支持单道程序。

image-20210408193824213

在引入多道程序技术之后(操作系统)

image-20210408193938895

进程与程序的区别:

  • 程序:是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。
  • 进程(Process):是动态的,是程序的一次执行过程

同一个程序多次执行会对应多个进程。

2、进程的定义

**程序段、数据段、PCB三部分组成了进程实体(进程映像)**。一般情况下,我们把进程实体就简称为进程,例如:所谓创建进程,实质上是创建进程实体中的PCB;而撤销进程,实质上是撤销进程实体中的PCB。

注意:PCB是进程存在的唯一标志!

从不同的角度,进程有不同的定义,比较传统典型的定义有:(强调“动态性”)进程的正在进行

  1. 进程是程序的一次执行过程
  2. 进程是一个程序及其数据在处理机上顺序执行时所发生的活动
  3. 进程是具有独立功能的程序在数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位

引入进程实体的概念后,可把进程定义为:

进程是进程实体的运行过程,是系统进行资源分配调度的一个独立单位。

注:严格来说,进程实体和进程并不一样,进程实体是静态的进程则是动态的。不过,除非题目专门考察二者区别,否则可以认为进程实体就是进程。因此我们也可以说“进程由程序段、数据段、PCB三部分组成

2、组成——每个进程由哪些部分组成

  1. PCB(Process Control Block):操作系统使用的。进程的管理者(操作系统)所需的数据都在PCB当中

    image-20210408195302165

    image-20210408195101713

    image-20210408195206139

  2. 程序段:进程自己使用的。程序本身的运行所需的数据

    存放要执行的代码

  3. 数据段:进程自己使用的。程序本身的运行所需的数据

    存放程序运行过程中处理的各种数据

image-20210408195916760

image-20210408194909291

3、组织方式——系统中的各个进程之间是如何被组织起来的

在一个系统中,通常有数十数百乃至数千个PCB。为了能对他们加以有效的管理,应该用适当的方式把这些PCB组织起来。

注意:进程的组成讨论的是一个进程内部的由哪些部分构成的问题,而进程的组织讨论的是多个进程之间的组织方式的问题。

  • 链接方式

    按照进程状态将PCB分为多个队列

    操作系统持有指向各个队列的指针

    image-20210408200538083

  • 索引方式

    根据进程状态的不同,建立几张索引表

    操作系统持有指向各个索引表的指针

    image-20210408200615846

4、特征——相比于程序,进程有什么特征

  • 动态性:进程是程序的一次执行过程,是动态地产生、变化和消亡的
  • 并发性:内存中有多个进程实体,各进程可以并发执行
  • 独立性:进程是能独立运行、独立获得资源、独立接收调度的基本单位
  • 异步性:各进程按各自独立的,不可预知的速度向前推进,操作系统要提供”进程同步机制“来解决异步问题
  • 结构性:每个进程都会配置一个PCB。结构上看,进程由程序段、数据段、PCB组成

image-20210408200659340

脑图

image-20210408200752313

2.1.2、进程的状态与转换

1、进程的状态

进程是程序的一次执行,在这个执行过程中,有时进程正在被CPU处理,有时又需要等待CPU服务,可见,进程的状态是会有各种变化。为了方便对各种进程的管理,操作系统需要将进程合理地划分为几种状态。

进程有五种状态,其中有三种基本状态

三种基本状态:

  • 运行态(Running):占有CPU,并在CPU上运行

    注意:在单核处理机环境下,每一时刻最多只有一个进程处于运行态。(双核环境下可以同时有两个进程处于运行态)

  • 就绪态(Ready):已经具备运行条件,但由于没有空闲的CPU,而暂时不能运行

    进程已经拥有了除处理机之外所有需要的资源,一旦获得处理机,即可立即进入运行态开始运行。即:万事俱备,只欠CPU

  • 阻塞态(Waiting/Blocked,又称:等待态):因等待某一事件而暂时不能运行

    如:等待操作系统分配打印机、等待读磁盘操作的结果,CPU是计算机中最昂贵的部件,为了提高CPU的利用率,需要先将其他进程需要的资源分配到位,才能得到CPU的服务。

剩余的两种状态:

  • 创建状态(New,又称:新建态):进程正在被创建,操作系统为进程分配资源、初始化PCB

    操作系统需要完成创建进程。操作系统为该进程分配所需的内存空间等系统资源,并为其创建、初始化PCB(如:为进程分配PID)

  • 终止状态(Terminated,又称:结束态):进程正在从系统中撤销,操作系统会回收进程拥有的资源、撤销PCB

    进程运行结束(或者由于bug导致进程无法继续执行下去,比如数组越界错误,除数为0等等),需要撤销进程。

    操作系统需要完成撤销进程的相关的工作。完成将分配给进程的资源回收,撤销进程PCB等工作。

2、进程状态间的转换

image-20210408210631200

  • 就绪态 => 运行态
  • 运行态 => 就绪态
  • 运行态 => 阻塞态
  • 阻塞态 => 就绪态

注意:不能有阻塞态直接转换为运行态,也不能由就绪态直接转换为阻塞态(因为进入阻塞态是进程主动请求的,必然需要进程在运行时才能发出这种请求)

脑图

image-20210408210724237

2.1.3、进程控制

1、基本概念

1、什么是进程控制

进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程撤销已有进程实现进程状态转换等功能。

简化理解:反正进程控制就是要实现进程状态转换

image-20210408223038360

2、如何实现进程控制——原语

原语是一种特殊的程序,它的执行具有原子性。也就是说,这段程序的运行必须一气呵成,不可中断

如果不能“一气呵成”,就有可能导致操作系统中的某些关键数据结构信息不统一的情况,这会影响操作系统进行别的管理工作。

原语的执行具有原子性,即执行过程只能一气呵成,期间不允许被中断。可以用“关中断指令”和“开中断指令”这两个特权指令实现原子性

正常情况:CPU每执行完一条指令都会例行检查是否有中断信号需要处理,如果有,则暂停运行当前这段程序,转而执行相应的中断处理程序。

CPU执行了关中断指令之后,就不再例行检查中断信号,直到执行开中断指令之后才会恢复检查。

这样,关中断、开中断之间的这些指令序列就是不可被中断的,这就实现了“原子性”。

image-20210408223258148

2、进程控制相关的原语

学习技巧:进程控制会导致进程状态的转换。无论哪个原语,要做的无非三类事情:

  1. 更新PCB中的信息(如修改进程状态标准、简化运行环境保存到PCB、从PCB恢复运行环境)
    1. 所有的进程控制原语一定都会修改进程状态标准
    2. 剥夺当前运行进程的CPU使用权必然需要保存其运行环境
    3. 某进程开始运行前必然要恢复其运行环境
  2. 将PCB插入合适的队列
  3. 分配/回收资源
  • 进程的创建

    image-20210408223606502

  • 进程的终止

    image-20210408223742379

  • 进程的阻塞与唤醒

    image-20210408224532652

  • 进程的切换

    image-20210408224604511

脑图

image-20210408223947869

2.1.4、进程通信

什么是进程通信?

顾名思义,进程通信就是指进程之间的信息交换

进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。为了保证安全,一个进程不能直接访问另一个进程的地址空间。但是进程之间的信息交换又是必须实现的。为了保证进程间的安全通信,操作系统提供了一些方法。如下:

1、共享存储

两个进程对共享空间的访问必须是互斥的(互斥访问通过操作系统提供的工具实现)。
操作系统只负责提供共享空间同步互斥工具(如P、V操作)

  • 基于数据结构的共享:

    比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式

  • 基于存储区的共享:

    在内存中画出一块共享存储区,数据的形式、存放位置都由进程控制,而不是操作系统。相比之下,这种共享方式速度更快,是一种高级通信方式。

image-20210409014400967

2、消息传递

进程间的数据交换以格式化的消息(Message)为单位。进程通过操作系统提供的“发送消息/接收消息”两个原语进行数据交换。

格式化的信息包含消息头和消息体。在消息头中包括:发送进程ID、接受进程ID、消息类型、消息长度等格式化的信息(计算机网络中发送的“报文”其实就是一种格式化的消息)

  • 直接通信方式:消息直接挂到接收进程的消息缓冲队列上

    image-20210409015008639

  • 间接通信方式:消息要先发送到中间实体(信箱)中,因此也称“信箱通信方式”。Eg:计网中的电子邮件系统。

    image-20210409015029868

3、管道通信

“管道”是指用于连接读写进程的一个共享文件,又名pipe文件。其实就是在内存中开辟一个大小固定的缓冲区

image-20210409014459852

  1. 管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道
  2. 各进程要互斥地访问管道。
  3. 数据以字符流的形式写入管道,当管道写满时,写进程的write()系统调用将被阻塞,等待读进程将数据取走。当读进程将数据全部取走后,管道变空,此时读进程的read()系统调用将被阻塞
  4. 如果没写满,就不允许读。如果没读空,就不允许写。
  5. 数据一旦被读出,就从管道中被抛弃,这就意味着读进程最多只能有一个,否则可能会有读错数据的情况。

脑图

image-20210409015059025

2.1.5、线程的概念、特点与多线程模型

1、什么是线程?为什么要引入线程?

进程是程序的一次执行。同一进程里不同的功能显然需要用不同的几段程序才能实现,并且这几段程序还要并发运行(qq当中的视频、文字聊天、传送文件)。而且,当切换进程时,需要保存/恢复进程运行环境,还需要切换内存地址空间(更新快表、更新缓存)开销很大。

image-20210409021721725

有的进程可能需要“同时”做很多事,而传统的进程只能串行地执行一系列程序。为此,引入了“线程”,来增加并发度。

image-20210409021850582

2、与进程相比,线程有什么特点?

  • 可以把线程理解为“轻量级进程”。

  • 引入线程前,进程既是资源分配的基本单位,也是调度的基本单位

  • 引入线程后,进程是资源分配的基本单位线程是调度的基本单位线程也有运行态、就绪态、阻塞态

  • 多CPU环境下,各个线程也可以分派到不同的CPU上并行地执行。

  • 线程是一个基本的CPU执行单元,也是程序执行流的最小单位

  • 引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的)。线程则作为处理机的分配单元

  • 引入线程后,进程是资源分配的基本单位。而线程几乎不拥有资源,只拥有极少量的资源(线程控制块TCB(Thread Control Block)、寄存器信息、堆栈等

  • 引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)

  • 进程间并发,开销很大。线程间并发,开销更小。进程间通信必须请求操作系统服务(CPU要切换到核心态),开销大。同进程下的线程间通信,无需操作系统干预,开销更小。引入线程机制后,并发带来的系统开销降低,系统并发性提升。

    注意:从属于不同进程的线程间切换,也必须请求操作系统服务!也会导致进程的切换!开销也大

    当切换进程时,需要保存/恢复进程运行环境,还需要切换内存地址空间(更新快表、更新缓存)

    同一进程内的各个线程间并发,不需要切换进程运行环境和内存地址空间,省时省力。

  • 从属同一进程的各个线程共享进程拥有的资源

  • 各个进程的内存地址空间相互独立,只能通过请求操作系统内核的帮助来完成进程间通信。

  • 同一进程下的各个线程间共享内存地址空间,可以直接通过读/写内存空间进行通信。

总结:

线程最小执行单位,进程最小分配资源单位

进程是可拥有资源的基本单位,频繁创建撤销进程会造成很大时空开销;而线程只是独立调度和分派的基本单位,共享进程的系统资源,线程被频繁创建和撤销也不会造成太大的时空开销。那仍然是执行的一个进程,只不过同时执行一个进程里面的多个线程。有些程序语言里还有更更轻量的协程,都是为了降低并发的代价。

3、引入线程机制后,有什么变化?

image-20210409023101820

类比:

切换进程运行环境:有一个不认识的人要用桌子,你需要你的书收走,他把自己的书放到桌上
同一进程内的线程切换=你的舍友要用这张书桌,可以不把桌子上的书收走。

4、线程有哪些重要的属性?

image-20210409023226229

5、线程的实现方式

  • 用户级线程(User-Level Thread, ULT)

    用户级线程由应用程序通过线程库实现。

    所有的线程管理工作都由应用程序负责(包括线程切换)

    用户级线程中,线程切换可以在用户态下即可完成,无需操作系统干预。

    在用户看来,是有多个线程。但是在操作系统内核看来,并意识不到线程的存在。(用户级线程对用户不透明,对操作系统透明

    可以这样理解,“用户级线程”就是“从用户视角看能看到的线程”

    image-20210409024313592

  • 内核级线程(Kernel-Level Thread, KLT, 又称“内核支持的线程”)

    内核级线程的管理工作操作系统内核完成

    线程调度、切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成。

    可以这样理解,“内核级线程”就是“从操作系统内核视角看能看到的线程”

    image-20210409024248522

线程的实现方式:

在同时支持用户级线程和内核级线程的系统中,可采用二者组合的方式:将n个用户级线程映射到m个内核级线程上(n >= m)

重点重点重点: 操作系统只“看得见”内核级线程,因此只有内核级线程才是处理机分配的单位。

例如:下边这个模型中,该进程由两个内核级线程,三个用户级线程,在用户看来,这个进程中有三个线程。但即使该进程在一个4核处理机的计算机上运行,也最多只能被分配到两个核,最多只能有两个用户线程并行执行。

image-20210409024124625

6、多线程模型

在同时支持用户级线程和内核级线程的系统中,由几个用户级线程映射到几个内核级线程的问题引出了“多线程模型”问题。

  • 多对一模型:

    多个用户及线程映射到一个内核级线程。每个用户进程只对应一个内核级线程。

    优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。

    缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。

    image-20210409024313592

  • 一对一模型:

    一个用户及线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。

    优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。

    缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。

    image-20210409024248522

  • 多对多模型

    n 用户及线程映射到m 个内核级线程(n >= m)。每个用户进程对应m 个内核级线程。

    克服了多对一模型并发度不高的缺点,又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。

    image-20210409024124625

脑图

image-20210409024100247

2.2.1、处理机调度的概念、层次

1、基本概念

当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定(如:VIP优先、短作业优先调等等)处理这些任务的顺序,这就是“调度”研究的问题。
在多道程序系统中,进程的数量往往是多于处理机的个数的,这样不可能同时并行地处理各个进程。处理机调度,就是从就绪队列中按照一定的算法选择一个进程并将处理机分配给它运行,以实现进程的并发执行。

2、三个层次

1、高级调度(作业调度)

image-20210504210138470

2、中级调度(内存调度)

image-20210504210203139

3、低级调度(进程调度)

image-20210504210230216

3、三层调度的联系、对比

image-20210504210333146

4、补充知识——进程的”挂起态”与七状态模型

image-20210504210257856

脑图

image-20210504210409703

2.2.2、进程调度的时机、切换与过程、方式

1、时机

1、什么时候需要进程调度

image-20210504211825916

2、什么时候不需要进程调度(但是进程在普通临界区中是可以进行调度、切换的。)

image-20210504211925883

3、临界区与内核程序临界区

image-20210504212056981

image-20210504212117707

如果还没退出临界区(还没解锁)就进行进程调度,但是进程调度相关的程序也需要访问就绪队列,但此时就绪队列被锁住了,因此又无法顺利进行进程调度。

内核程序临界区访问的临界资源如果不尽快释放的话,极有可能影响到操作系统内核的其他管理工作。因此在访问内核程序临界区期间不能进行调度与切换

image-20210504212504714

在打印机打印完成之前,进程一直处于临界区内,临界资源不会解锁。但打印机又是慢速设备,此时如果一直不允许进程调度的话就会导致CPU一直空闲。

普通临界区访问的临界资源不会直接影响操作系统内核的管理工作。因此在访问普通临界区时可以进行调度与切换

2、切换与过程

1、”狭义的调度”与”进程切换”的区别

image-20210504212743872

3、方式

1、非剥夺调度方式(非抢占式)

image-20210504212658968

2、剥夺调度方式(抢占式)

image-20210504212718957

脑图

image-20210504212903088

2.2.3、调度算法的评价指标

1、CPU利用率

image-20210504213732211

2、系统吞吐量

image-20210504213750111

3、周转时间

1、周转时间、平均周转时间

image-20210504213811576

2、带权周转时间、平均带权周转时间

image-20210504213904684

即:周转时间都是11s,但是作业1的运行时间是1s,等待时间是10s;而作业2的运行时间是10s,等待时间是1s。

image-20210504213926076

4、等待时间

image-20210504214350127

5、响应时间

image-20210504214421607

脑图

image-20210504214513619

2.2.4、调度算法:先来先服务、最短作业优先、最高响应比优先

Tips:各种调度算法的学习思路

  1. 算法思想
  2. 算法规则
  3. 这种调度算法是用于作业调度还是进程调度?
  4. 抢占式?非抢占式?
  5. 优点和缺点
  6. 是否会导致饥饿:
    • 某进程/作业长期得不到服务

1、先来先服务(First Come First Serve:FCFS)

image-20210504220945017

相关例题:

image-20210504221017275

2、短作业优先( Shortest Job First:SJF)

image-20210504221101352

相关例题

1、非抢占式的短作业优先

image-20210504221228030

2、抢占式的短作业优先

image-20210504221334542

image-20210504221401081

细节:

image-20210504221427980

3、对两种算法的思考

image-20210504221522717

4、高响应比优先(Highest Response Ratio Next:HRRN)

image-20210504221551173

相关例题:

image-20210504221620857

5、知识回顾与重要考点

image-20210504214906931

2.2.5、调度算法:时间片轮转、优先级、多级反馈队列

Tips:各种调度算法的学习思路

  1. 算法思想
  2. 算法规则
  3. 这种调度算法是用于作业调度还是进程调度?
  4. 抢占式?非抢占式?
  5. 优点和缺点
  6. 是否会导致饥饿:
    • 某进程/作业长期得不到服务

1、时间片轮转调度算法

image-20210504223036879

相关例题:

时间片为2

image-20210504223228420

image-20210504223311894

image-20210504223733579

时间片为5

image-20210504223829429

时间片的选取&时间片轮转调度算法与先来先服务算法的关系:

image-20210504224009814

image-20210504224059700

2、优先级调度算法

image-20210504224852371

相关例题:

非抢占式的优先级调度算法:

image-20210504224320500

抢占式的优先级调度算法:

image-20210504224457516

补充:

image-20210504224802316

3、思考

image-20210504224934560

4、多级反馈队列调度算法

image-20210504225527480

相关例题

image-20210504225342558

5、知识回顾与重要考点

image-20210504225811770

2.3.1、进程同步、进程互斥

1、进程同步

image-20210504230549925

2、进程互斥

image-20210504230709464

对临界资源的互斥访问,可以在逻辑上分为如下四个部分:

image-20210504230942497

为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:

image-20210504231106627

脑图

image-20210504231143008

2.3.2、进程互斥的软件实现方法

学习提示:

  1. 理解各个算法的思想、原理
  2. 结合上小节学习的“实现互斥的四个逻辑部分”,重点理解各算法在进入区、退出区都做了什么
  3. 分析各算法存在的缺陷(结合“实现互斥要遵循的四个原则”进行分析)

1、单标志法

image-20210504231820629

image-20210504231746318

2、双标志先检查

image-20210504232145546

3、双标志后检查

image-20210504232413700

4、Peterson算法

image-20210504232720257

image-20210504232756435

image-20210504232829595

image-20210504232858921

脑图

image-20210504232928285

2.3.3、进程互斥的硬件实现方法

学习提示:

  1. 理解各方法的原理
  2. 了解各方法的优缺点

1、中断屏蔽方法

image-20210504233615762

2、TestAndSet(TS指令/TSL指令TestAndSetLock)

image-20210504233641846

3、Swap指令(XCHG指令)

image-20210504233700709

脑图

image-20210504233729313

2.3.4、信号量机制

复习回顾+思考:之前学习的这些进程互斥的解决方案分别存在哪些问题?

  • 进程互斥的四种软件实现方式(单标志法、双标志先检查、双标志后检查、Peterson算法 )
  • 进程互斥的三种硬件实现方式(中断屏蔽方法、TS/TSL指 令、Swap/XCHG指令)
    • 在双标志先检查法中,进入区的“检查”、“ 上锁”操作无法一气呵成,从而导致了两个进程有可能同时进入临界区的问题;
    • 所有的解决方案都无法实现“让权等待”

1965年,荷兰学者Dijkstra提出了一种卓有成效的实现进程互斥、同步的方法——信号量机制

1、信号量机制

image-20210504234407772

2、整型信号量

image-20210504235542982

3、纪录型信号量

image-20210504235617935

image-20210505000153209

image-20210505000232848

image-20210505000249924

脑图

image-20210505000323832

2.3.5、用信号量实现进程互斥、同步、前驱关系

Tips:不要一头钻到代码里,要注意理解信号量背后的含义,一个信号量对应一种资源

信号量的值 = 这种资源的剩余数量(信号量的值如果小于0,说明此时有进程在等待这种资源)

P(S)——申请一个资源S,如果资源不够就阻塞等待
V(S)——释放一个资源S,如果有进程在等待该资源,则唤醒一个进程

1、实现进程互斥

image-20210505001722795

2、实现进程同步

image-20210505001748039

image-20210505001810855

3、实现进程的前驱关系

image-20210505001831897

脑图

image-20210505001903980

2.3.6、生产者-消费者问题

1、问题描述

image-20210505004917915

2、问题分析

image-20210505003435388

image-20210505004125366

image-20210505004950707

3、问题解决

image-20210505005740558

4、思考:能否改变相邻P、V操作的顺序?

image-20210505005811868

“使用产品”能不能放在”取出产品之后”(即PV操作之间):

从逻辑上来说没什么问题,取出一个产品之后马上使用。但是最好不要。因为这会导致临界区的代码量变大,消费者进程在访问临界区资源的时候就会耗费更长的时间,如果此时有别的进程也想访问临界区资源的话是会被阻塞的。

把这些非必要的代码放进临界区的话,就显然会导致进程间的并发度降低,所以最好不要把没有必要的代码放到临界区里面。

5、知识回顾与重要考点

image-20210505004859079

2.3.7、生产者-多消费者

1、问题描述

image-20210505011453970

2、问题分析

image-20210505011627140

image-20210505011725280

3、问题解决

方式一:使用互斥信号量mutex

image-20210505012011434

方式二:不使用互斥信号量mutex

image-20210505012224581

为什么只可以使用(同步)信号量plate,省略(异步)信号量mutex解决问题呢?

原因在于:本题中的缓冲区大小为1,在任何时刻,apple、 orange、 plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区。

如果把缓冲区的大小设置为2,即盘子里面可以放置两个水果

image-20210505012546283

4、知识回顾与重要考点

image-20210505011103373

image-20210505011126965

2.3.8、吸烟者问题

1、问题描述

image-20210505013010023

2、问题分析

image-20210505013811237

image-20210505013903213

3、问题解决

image-20210505013926731

4、知识回顾与重要考点

image-20210505012943618

2.3.9、读者-写者问题

1、问题描述

image-20210505014140357

2、问题分析

image-20210505014119129

3、问题解决

image-20210505014207251

image-20210505014235757

4、知识回顾与重要考点

image-20210505014049722

2.3.10、哲学家进餐问题

1、问题描述

image-20210505015934159

2、问题分析

image-20210505020031496

3、问题解决

image-20210505020244129

image-20210505020339544

第一种情况:0号进程拿起左右两支筷子进行吃饭(顺利进行)

image-20210505020554497

第二种情况:在第一种情况的基础下,0号进程正在吃饭,此时1号进程想要吃饭,但是会被阻塞在拿左边筷子的语句P(chopstick[i])上,此时2号进程想要吃饭,但是由于1号进程执行了P(mutex)但是没执行V(mutex),所以2号进程会被阻塞在语句P(mutex)上,即:2号进程虽然左右两边都有筷子,但是它吃不了饭。

image-20210505021347456

第三种情况:在第一种的情况下,4号进程想吃饭,它会拿起左边的筷子,然后就被阻塞在拿右边筷子的语句P(chopstick[(i+1)%5])上了

image-20210505021537145

4、知识回顾与重要考点

image-20210505021821006

2.3.11、管程

1、为什么要引入管程

image-20210505022123552

2、管程的定义和基本特征

image-20210505022141694

3、拓展1:用管程解决生产者消费者问题

image-20210505022231170

image-20210505022251625

4、拓展2:java中类似于管程的机制

image-20210505022319316

脑图

image-20210505022210103

2.4.1、死锁的概念

1、什么是死锁

image-20210505143529683

2、进程死锁、饥饿、死循环的区别

image-20210505143807098

3、死锁产生的必要条件

image-20210505144019761

4、什么时候会发生死锁

image-20210505144120239

5、死锁的处理策略

image-20210505144149160

脑图

image-20210505144218705

2.4.2、死锁的处理策略——预防死锁

1、死锁的处理

image-20210505144445903

2、破坏互斥条件

image-20210505144725993

3、破坏不剥夺条件

image-20210505145005522

4、破坏请求和保持条件

image-20210505145224511

5、破坏循环等待条件

image-20210505145453127

脑图

image-20210505145752465

2.4.3、死锁的处理策略——避免死锁

1、动态策略:避免死锁

image-20210505145945572

2、什么是安全序列

image-20210505150922419

image-20210505151042960

image-20210505151124895

image-20210505151244599

3、安全序列、不安全状态、死锁的联系

image-20210505151529696

4、银行家算法

image-20210505151659990

image-20210505151838002

image-20210505151927039

不安全的情况:

image-20210505152108400

代码实现:

image-20210505152424001

5、知识回顾与重要考点

image-20210505152609859

2.4.4、死锁的处理——策略检测和解除

1、死锁的检测和解除

image-20210505152730364

2、死锁的检测

image-20210505152953706

没有发生死锁:

image-20210505153349861

发生死锁:

image-20210505153629990

死锁定理:

image-20210505153748812

3、死锁的解除

image-20210505154108164

脑图

image-20210505154238217

第三章 内存管理

img

在这里插入图片描述

3.1.1、内存的基础知识

1、什么是内存?内存的作用——存储单元与内存地址

image-20210508002901851

2、进程运行的基本原理

1、指令的工作原理

image-20210508003140541

image-20210508003207444

image-20210508003230122

2、逻辑地址 VS 物理地址

image-20210508004518010

3、如何实现地址转换

image-20210508004749695

image-20210508004832543

4、从写程序到程序运行的过程:编辑——编译——链接——装入

image-20210508004555654

5、三种装入方式
1、三种装入方式——绝对装入

image-20210508005036546

2、三种装入方式——可重定位装入

image-20210508005056249

3、三种装入方式——动态运行时装入

image-20210508005114414

image-20210508005143082

6、三种链接方式
1、三种链接方式——静态链接

image-20210508005324193

2、三种链接方式——装入时动态链接

image-20210508005341688

3、三种链接方式——运行时动态链接

image-20210508005409340

3、补充知识:几个常用的数量单位

image-20210508002939003

脑图

image-20210508005509266

3.1.2、内存管理的概念

1、内存空间的分配与回收

image-20210508010417701

2、内存空间的扩充

image-20210508010447793

3、地址转换

image-20210508010631873

4、存储保护

image-20210508010712814

1、存储保护——方式1

image-20210508010856791

2、存储保护——方式2

image-20210508011111324

脑图

image-20210508005842818

3.1.3、覆盖与交换

1、内存空间的扩充

image-20210508083942705

1、覆盖技术

image-20210508012819104

image-20210508012843411

2、交换技术

image-20210508012931347

image-20210508012947320

image-20210508013003422

脑图

image-20210508011536720

3.1.4、内存空间的分配与回收——连续分配管理方式

image-20210508084011062

3.1.4.1.1、连续分配管理方式——单一连续分配

image-20210508090339927

3.1.4.1.2、连续分配管理方式——固定分区分配

image-20210508090358501

image-20210508090412868

3.1.4.1.3、连续分配管理方式——动态分区分配

image-20210508090450612

1、动态分区分配问题1——系统要用什么样的数据结构记录内存的使用情况?

image-20210508090546098

2、动态分区分配问题2——当很多个空闲分区都能满足需求时,应该选择哪个分区进行分配?

image-20210508090650871

3、动态分区分配问题3——如何进行分区的分配与回收操作?
1、分配——分配到相对大的空间中

image-20210508090911675

2、分配——分配到刚刚好的空间中

image-20210508091146057

3、回收——回收区的后面有一个相邻的空闲分区

image-20210508091905607

4、回收——回收区的前面有一个相邻的空闲分区

image-20210508091821239

5、回收——回收区的前、后面各有一个相邻的空闲分区

image-20210508092028487

6、回收——回收区的前、后都没有相邻的空闲分区

image-20210508092245786

7、内部碎片与外部碎片

image-20210508093103201

image-20210508093318950

脑图

image-20210508093401065

3.1.5、动态分区分配算法

image-20210508093523120

1、首次适应算法(First Fit)

image-20210508094125510

2、最佳适应算法(Best Fit)

image-20210508095209932

image-20210508095244859

3、最坏适应算法(Worst Fit)

image-20210508095837523

image-20210508095921779

4、邻近适应算法(Next Fit)

image-20210508100315188

image-20210508102952416

image-20210508103130003

5、知识回顾与重要考点

image-20210508100431925

3.1.4、内存空间的分配与回收——非连续分配管理方式

image-20210508105913844

image-20210508103253793

3.1.4.2.1、非连续分配管理方式——基本分页存储管理方式

把“固定分区分配”改造为“非连续分配版本”

image-20210508110554274

1、基本分页存储管理的基本概念

image-20210508111128672

2、如何实现地址的转换

进程在内存中连续存放时:

image-20210508111333219

主要思想:模块在内存中的的“起始地址”+ 目标内存单元相对于起始位置的“偏移量”

进程在内存中不连续存放时(采用分页存储):

image-20210508111929405

image-20210508112229037

image-20210508112510315

image-20210508112743184

结论:如果每个页面大小为2KB,用二进制数表示逻辑地址,则末尾K位即为页内偏移量,其余部分就是页号。
因此,如果让每个页面的大小为2的整数幂,计算机就可以很方便地得出一个逻辑地址对应的页号和页内偏移量。

3、逻辑地址结构

image-20210508113103944

4、页表

image-20210508113253320

image-20210508113414039

脑图

image-20210508113608275

3.1.6、基本地址变换机构

基本地址变换机构:用于实现逻辑地址到物理地址转换的一组硬件机构

image-20210508133337702

image-20210508133401880

image-20210508132845186

image-20210508132931955

image-20210508133601876

image-20210508133911049

脑图

image-20210508134019463

3.1.7、具有快表的地址变换机构

1、局部性原理

image-20210508202404524

2、快表(TLB)

image-20210508225440701

image-20210508225501750

3、引入快表后,地址的变换过程

image-20210508225813674

4、知识回顾与重要考点

image-20210508225906098

3.1.8、两级页表

1、单级页表存在什么问题?如何解决?

image-20210508234002478

image-20210508235354588

image-20210508235432906

2、两级页表的原理、逻辑地址结构

image-20210508235458794

image-20210508235527071

3、如何实现地址变换?

image-20210509002703906

4、如何解决单级页表的问题?

image-20210509002832170

5、两级页表问题需要注意的几个细节

image-20210509003331829

脑图

image-20210509003459726

3.1.4.2.2、非连续分配管理方式——基本分段存储管理方式

1、分段

image-20210509004013708

image-20210509004209165

2、段表

image-20210509004817652

3、地址变换

image-20210509004846961

image-20210509004926128

4、分段、分页管理的对比

image-20210509005230009

image-20210509005348731

image-20210509005415434

image-20210509005436167

脑图

image-20210509005500533

3.1.4.2.3、非连续分配管理方式——段页式管理方式

1、分页、分段的优缺点分析

image-20210509005829321

image-20210509005855867

2、分段+分页=段页式管理

image-20210509005945705

3、段页式管理的逻辑地址结构

image-20210509010126138

4、段表、页表

image-20210509010510498

image-20210509010923423

脑图

image-20210509011052374

3.2.1、虚拟内存的基本概念

image-20210509011129396

1、传统存储管理方式的特征、缺点

image-20210509011637911

2、局部性原理

image-20210509011701550

3、虚拟内存的定义和特征

image-20210509011935455

image-20210509012047548

4、如何实现虚拟内存技术

image-20210509012152298

脑图

image-20210509012242692

3.2.2、请求分页管理方式

image-20210509012615162

1、页表机制

image-20210509012717541

2、缺页中断机构

内存中存在空闲块:

image-20210509013338043

image-20210509012743561

内存中不存在空闲块:

image-20210509013627907

image-20210509012801635

image-20210509012828125

3、地址变换机构

image-20210509012911260

image-20210509013853533

image-20210509013943280

image-20210509014238955

image-20210509014011536

脑图

image-20210509012956979

3.2.3、页面置换算法

image-20210509014356859

1、页面置换算法——最佳置换算法(OPT)

image-20210509014635661

image-20210509014650392

2、页面置换算法——先进先出置换算法(FIFO)

image-20210509014718214

image-20210509014853923

3、页面置换算法——最近最久未使用置换算法(LRU)

image-20210509014927703

4、页面置换算法——时钟置换算法(CLOCK)

image-20210509015030833

5、页面置换算法——改进型的时钟置换算法

image-20210509021015176

image-20210509021320133

image-20210509021715893

image-20210509022026524

image-20210509022148622

6、知识回顾与重要考点

image-20210509015408768

3.2.4、页面分配策略

image-20210509022248848

1、页面分配、置换策略

image-20210509022843592

image-20210509023042278

image-20210509023615272

image-20210509023233829

2、何时调入页面

image-20210509022356148

预调页策略和请求调页策略一般会结合着进行使用。

image-20210509022420725

image-20210509022441309

image-20210509022505874

3、抖动(颠簸)现象

image-20210509022535523

4、工作集

image-20210509022604479

脑图

image-20210509022634991

第四章 文件管理

在这里插入图片描述

4.1.1、初识文件管理

image-20210510172015086

1、Windows操作系统的文件管理

image-20210510173929518

2、文件的属性

image-20210510172305330

image-20210510172426075

3、文件内部的数据应该怎样组织起来?

image-20210510173839817

image-20210510173958010

4、文件之间应该怎样组织起来?

image-20210510174052984

image-20210510174111553

5、操作系统应该向上提供哪些功能?

image-20210510174132287

image-20210510174152635

6、从上往下看,文件应如何存放在外存?

image-20210510174208743

image-20210510174229192

7、其他需要由操作系统实现的文件管理功能

image-20210510174244099

脑图

image-20210510174258642

4.1.2、文件的逻辑结构

image-20210510174403271

1、无结构文件

image-20210510174420173

2、有结构文件

image-20210510174420173

image-20210510174511007

image-20210510174533798

3、有结构文件的逻辑结构

image-20210510174614541

4、顺序文件

image-20210510174647781

image-20210510174712891

5、索引文件

image-20210510174742627

6、索引顺序文件

image-20210510174824341

7、索引顺序文件(检索效率分析)

image-20210510174900571

8、多级索引顺序文件

image-20210510174937291

脑图

image-20210510175000352

image-20210510175034205

4.1.3、文件目录

image-20210510180705930

image-20210510180724124

1、文件控制块

image-20210510180808309

image-20210510181628502

image-20210510181647855

2、目录结构——单级目录结构

image-20210510181719480

3、目录结构——两级目录结构

image-20210510181749558

4、目录结构——多级目录结构

image-20210510181840085

image-20210510181919035

image-20210510181942460

5、目录结构——无环图目录结构

image-20210510182039884

6、索引结点(FCB的改进)

image-20210510182124127

image-20210510182257634

脑图

image-20210510182328384

4.1.4、文件的物理结构(文件分配方式)

image-20210510184057179

image-20210510184143772

1、文件块、磁盘块

image-20210510184202356

image-20210510184234195

2、文件分配方式——连续分配

image-20210510184517016

image-20210510194304195

image-20210510194321776

image-20210510194418244

总结:

image-20210510194438017

3、文件分配方式——链接分配

链接分配采取离散分配的方式,可以为文件分配离散的磁盘块。分为隐式链接显式链接两种。

4、链接分配——隐式链接

image-20210510200010895

image-20210510200055678

image-20210510200134964

5、链接分配——显式链接

image-20210510200212233

image-20210510200226440

6、链接分配(总结)

image-20210510200242086

7、文件分配方式——索引分配

image-20210510200330776

image-20210510200346364

image-20210510200418312

1、索引分配——链接方案

image-20210510200434973

2、索引分配——多层索引

image-20210510200449834

3、索引分配——混合索引

image-20210510203950212

8、索引分配(总结)

image-20210510200553830

9、知识点回顾与重要考点

image-20210510200625936

10、易混难点:支持随机访问

image-20210510200653421

4.1.5、文件存储空间管理

image-20210510204351623

image-20210510204422297

1、存储空间的划分与初始化

image-20210510204534899

2、存储空间管理——空闲表法

分配:

image-20210510204736595

回收:

image-20210510204759316

image-20210510204857398

image-20210510204918112

3、存储空间管理——空闲链表法

image-20210510205032206

1、空闲链表法——空闲盘块链

分配与回收:

image-20210510205148716

2、空闲链表法——空闲盘区链

分配与回收:

image-20210510205259520

4、存储空间管理——位示图法

image-20210510205536697

分配与回收:

image-20210510205642666

5、存储空间管理——成组链接法

image-20210510205738193

分配:

image-20210510210218546

image-20210510210320053

image-20210510210351181

回收:

image-20210510210747340

image-20210510210839939

image-20210510210915503

脑图

image-20210510211017588

4.1.6、文件的基本操作

image-20210510211109649

1、创建文件(create系统调用)

image-20210510211530217

2、删除文件(delete系统调用)

image-20210510211600032

3、打开文件(open系统调用)

image-20210510211623264

image-20210510211657842

4、关闭文件(close系统调用)

image-20210510211854855

5、读文件(read系统调用)

image-20210510211726508

6、写文件(write系统调用)

image-20210510211835280

脑图

image-20210510211746762

4.1.7、文件共享

image-20210510213241349

1、基于索引结点的共享方式(硬链接)

image-20210510213311954

2、基于符号链的共享方式(软链接)

image-20210510213347172

image-20210510213403610

image-20210510213429991

image-20210510213445471

image-20210510213503692

脑图

image-20210510213537878

4.1.8、文件保护

image-20210510213617783

1、口令保护

image-20210510214250894

2、加密保护

image-20210510214328225

image-20210510214402046

3、访问控制

image-20210510214438312

image-20210510214452912

4、Windows的访问控制

image-20210510214521631

image-20210510214610135\

image-20210510214635252

image-20210510214651156

脑图

image-20210510214712283

4.1.9、文件系统的层次结构

1、文件系统的层次结构

image-20210510215918389

2、知识点回顾与重要考点

image-20210510220046997

4.2.1、磁盘的结构

image-20210510214754390

1、磁盘、磁道、扇区

image-20210510220120936

2、如何在磁盘中读/写数据

image-20210510220141125

3、盘面、柱面

image-20210510220307505

image-20210510220318911

4、磁盘的物理地址

image-20210510220334460

5、磁盘的分类

image-20210510220354743

image-20210510220409094

脑图

image-20210510220429976

4.2.2、磁盘调度算法

image-20210510220523176

1、一次磁盘读/写操作需要的时间

image-20210510221358877

image-20210510221414332

image-20210510221430472

image-20210510221444551

2、先来先服务算法(FCFS)

image-20210510221458167

3、最短寻找时间优先(SSTF)

image-20210510221515203

4、扫描算法(SCAN)

image-20210510221530897

5、LOOK调度算法

image-20210510221552395

6、循环扫描算法(C-SCAN)

image-20210510221622445

7、C-LOOK调度算法

image-20210510221637101

脑图

image-20210510221654417

4.2.3、减少磁盘延迟时间的方法

image-20210510221738068

1、减少延迟时间的方法:交替编号

image-20210510223254723

2、磁盘地址结构的设计

image-20210510223311665

image-20210510223329605

image-20210510223343018

3、减少延迟时间的方法:错位命名

image-20210510223402335

image-20210510223414358

脑图

image-20210510223427091

4.2.4、磁盘的管理

image-20210510224338889

1、磁盘初始化

image-20210510224546394

2、引导块

image-20210510224644253

image-20210510224801174

3、坏块的管理

image-20210510224912088

脑图

image-20210510224948374

第五章 I/O管理

img

5.1.1、I/O设备的概含和分类

image-20210511110804479

1、什么是I/O设备

image-20210511111043854

image-20210511111059679

2、I/O设备的分类——按使用特性

image-20210511111151164

3、I/O设备的分类——按传输速率分类

image-20210511111340325

4、I/O设备的分类——按信息交换的单位分类

image-20210511111357178

脑图

image-20210511111317058

5.1.2、IO控制器

image-20210511111508063

1、I/O设备的机械部件

image-20210511112240342

2、I/O设备的电子部件(I/O控制器)

image-20210511112302574

3、I/O控制器的组成

image-20210511112323795

image-20210511112339935

4、内存映像I/O VS 寄存器独立编址

image-20210511112409008

脑图

image-20210511111721235

5.1.3、IO控制方式

image-20210511112526466

1、程序直接控制方式

image-20210511114118768

image-20210511114144636

image-20210511114203481

2、中断驱动方式

image-20210511114219032

image-20210511114237657

3、DMA方式

image-20210511114254744

DMA控制器

image-20210511114311516

image-20210511114331876

4、通道控制方式

image-20210511114349325

image-20210511114404292

5、知识点回顾与重要考点

image-20210511114426856

5.1.4、IO软件层次结构

image-20210511114507050

1、用户层软件

image-20210511120145365

2、设备独立性软件

image-20210511120201000

image-20210511120214522

image-20210511120232118

image-20210511120247174

image-20210511120308807

image-20210511120347783

image-20210511120406389

image-20210511120422617

3、思考:为何不同的设备需要不同的设备驱动程序?

image-20210511120439359

image-20210511120454305

image-20210511120511147

image-20210511120524487

4、设备驱动程序

image-20210511120542492

5、中断处理程序

image-20210511120601060

1、知识点回顾与重要考点

image-20210511120620181

image-20210511120633229

2、中断处理程序

image-20210511120645686

5.1.5、I/O核心子系统

image-20210511200617750

1、这些功能要在哪个层次实现?

image-20210511201151526

2、I/O调度

image-20210511201224315

3、设备保护

image-20210511201256428

4、知识总览

image-20210511201346880

5.1.6、假脱机技术

image-20210511201414648

1、什么是假脱机技术

image-20210511201503446

image-20210511201645994

2、假脱机技术——输入井和输出井

image-20210511201850384

image-20210511201805698

image-20210511201917534

image-20210511201943528

3、假脱机技术——输入/输出缓冲区

image-20210511202055139

4、共享打印机原理分析

image-20210511202209054

image-20210511202654105

image-20210511202423159

脑图

image-20210511202456002

5.1.7、设备的分配与回收

image-20210511134320520

1、设备分配时应考虑的因素

image-20210511184505182

image-20210511184525700

image-20210511184540600

2、静态分配与动态分配

image-20210511184556436

3、设备分配管理中的数据结构

image-20210511184610941

image-20210511184623290

image-20210511184641395

image-20210511184653652

image-20210511184714960

4、设备分配的步骤

image-20210511185859000

image-20210511185912320

image-20210511185925978

image-20210511185938379

5、设备分配步骤的改进

image-20210511185955148

image-20210511190008933

image-20210511190025223

脑图

image-20210511190059629

5.1.8、缓冲区管理

image-20210511184150637

1、什么是缓冲区?有什么作用?

image-20210511190337006

2、缓冲区有什么作用?

image-20210511190349446

3、单缓冲

image-20210511190403433

image-20210511190415564

image-20210511190430654

image-20210511190447737

4、双缓冲

image-20210511190502280

image-20210511190513456

image-20210511190526272

5、使用单/双缓冲在通信时的区别

image-20210511190537821

image-20210511190551862

6、循环缓冲区

image-20210511190605054

7、缓冲池

image-20210511221515991

image-20210511221647687

image-20210511221825920

image-20210511221937054

脑图

image-20210511190720738

参考链接:

bilibili王道考研

操作系统思维导图—(零基础—思维导图详细版本及知识点)