八股必会内容

JAVA

基础问题

JDK、JRE、JVM

image-20230613143956016

重写与重载

重载:发生在本类方法中,是类中多态性的表现,要求同名方法参数列表不同(参数类型,参数个数和参数顺序),返回值类型可以相同也可以不同。无法以返回值类型作为区分重载的标准

重写:发生在继承关系中,子类继承了父类的原有方法,但在某些情况下,子类并不想继承原有的方法,对继承的方法(方法名,参数列表,返回类型和父类一致,并且子类函数的访问修饰权限不能少于父类的,若父类修饰符是public,则子类不能用protected,若父类修饰符是private,则子类无法继承)进行方法体重写。

访问控制权限

1.控制的范围是什么
private 私有的,只能在本类中访问
public 公开的,在任何位置可以访问
protected 受保护的,可以在本类中、在同包中、在子类中可以访问,在任意位置不能访问
默认:可以在本类中、同包下可以访问,在子类、任意位置不能访问。

2.访问修饰符可以修是什么
属性(4个都能用)
方法(4个都能用)
类(public和默认能用,其他不行)
接口(public和默认能用,其他不行)

equals()和==区别

  1. == 是运算符 equals 来自于 object 类定义的一个方法
  2. == 可以用于基本数据类型和引用类型 equals 只能用于引用类型
  3. == 两端如果是基本数据类型,就是判断值是否相同否则是判断地址 equals 在重写之后,判断两个对象的属性值是否相同
  4. equals 如果不重写,其实就是 ==

为什么重写 equals就要重写 hashcode

object 中定义的 hashcode 方法生成的哈希码能保证同一个类的对象的哈希码一定是不同的

当 equals 返回为 true,我们在逻辑上可以认为是同一个对象,但是查看哈希码,发现哈希码不同,和 equals 方法的返回结果违背

object 中定义的 hashcode 方法生成的哈希码跟对象的本身属性值是无关的
重写 hashcode 之后,我们可以自定义哈希码的生成规则,可以通过对象的属性值计算出哈希码

HashMap 中,借助 equals 和 hashcode 方法来完成数据的存储:

  1. 计算hash值,根据这个计算要放入的位置
  2. 如果没有相同hash值的就放入,有的话调用equals()函数

HashMap在jdk1.8中的优化

  1. 加入红黑树
  2. hash扰动
  3. 扩容优化

具体内容在集合里看

Tomcat为什么要自定义类加载器

image-20230613143653754

image-20230613143646338

集合

HashMap

结构:数组+链表/红黑树

初始化大小默认16,负载因子0.75,链表长度超过6就会转换成红黑树

初始化大小计算

找到大于或等于 cap 的最小2的幂

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
在桶数组中的位置

通过(n - 1)& hash即可算出桶的在桶数组中的位置

HashMap 中桶数组的大小 length 总是2的幂,此时,(n - 1) & hash 等价于对 length 取余。但取余的计算效率没有位运算高,所以(n - 1) & hash也是一个小的优化

hash扰动

hash ^ (hash >>> 16)为什么高位右移并与低位异或

  1. 计算余数时,由于 n 比较小,hash 只有低4位参与了计算,高位的计算可以认为是无效的。这样导致了计算结果只与低位信息有关,高位数据没发挥作用。为了处理这个缺陷,我们可以上图中的 hash 高16位数据与低16位数据进行异或运算,即 hash ^ (hash >>> 16)
  2. 当我们覆写 hashCode 方法时,可能会写出分布性不佳的 hashCode 方法,进而导致 hash 的冲突率比较高。通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性
如何检查重复
  1. 计算hash值,根据这个计算要放入的位置
  2. 如果没有相同hash值的就放入,有的话调用equals()函数
扩容为什么是2倍

1.得到的新的数组索引和老数组索引只有最高位区别,更快地得到新索引

JDK 1.8在扩容时通过高位运算 (e.hash & oldCap)来确定元素是否需要移动:
高位为0时不动,高位为1时移动:桶数组位置为当前位置加上oldCap长度
举例 :
key1.hash为10 二进制为: 0000 1010
oldCap 为 16 二进制为:0001 0000
e.hash & oldCap 后高位为0000 因此位置不变

image-20230402212029140

2.rehash时的取余操作,hash % length == hash & (length - 1)这个关系只有在length等于二的幂次方时成立,位运算能比%高效得多

扩容过程中,树化要满足两个条件
  1. 链表长度大于等于8

  2. 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。

    当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容。容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。

Hashmap安全吗

正常流程:

img

do {
    Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

扩容的时候的迁移数据代码。

线程1和线程2并发执行:

img

注意,因为Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表

线程一被调度回来执行。

  • 先是执行 newTalbe[i] = e;
  • 然后是e = next,导致了e指向了key(7),
  • 而下一次循环的next = e.next导致了next指向了key(3)

img

线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移

img

环形链接出现。

e.next = newTable[i] 导致 key(3).next 指向了 key(7)

注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

img

ConcurrentHashMap1.7

存储结构

Java 7 ConcurrentHashMap 存储结构

Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,默认支持最多 16 个线程并发。

初始化Map

  1. 必要参数校验。
  2. 校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无参构造默认值是 16.
  3. 寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16
  4. 记录 segmentShift 偏移量,这个值为【容量 = 2 的N次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28. 因为默认是16即2的4次方 所以是32-4
  5. 记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15.
  6. 初始化 segments[0]默认大小为 2负载因子 0.75扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容

put

  1. 计算要 put 的 key 的位置,获取指定位置的 Segment

  2. 如果指定位置的 Segment 为空,则初始化这个 Segment.

  3. 初始化 Segment 流程:

    1. 检查计算得到的位置的 Segment 是否为null.

    2. 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。

    3. 再次检查计算得到的指定位置的 Segment 是否为null.

    4. 使用创建的 HashEntry 数组初始化这个 Segment.

    5. 自旋判断计算得到的指定位置的 Segment 是否为null,使用 CAS 在这个位置赋值为 Segment.

              // 2.创建一个 cap 容量的 HashEntry 数组
              HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
              if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // 3.recheck
                  // 4.再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作
                  Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
                  // 5.自旋检查 u 位置的 Segment 是否为null
                  while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                         == null) {
                      // 使用CAS 赋值,只会成功一次
                      if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                          break;
                  }
              }
      
  4. Segment.put 插入 key,value 值。
    由于 Segment 继承了 ReentrantLock,所以 Segment 内部可以很方便的获取锁,put 流程就用到了这个功能。

    1. tryLock() 获取锁,获取不到使用 scanAndLockForPut 方法继续获取。
    2. 计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry
    3. 遍历 put 新元素

    scanAndLockForPut操作这里没有介绍,这个方法做的操作就是不断的自旋 tryLock() 获取锁。当自旋次数大于指定次数时,使用 lock() 阻塞获取锁。在自旋时顺表获取下 hash 位置的 HashEntry

扩容和get与HashMap差别不大 不记录了

ConcurrentHashMap1.8

存储结构

Java8 ConcurrentHashMap 存储结构(图片来自 javadoop)

不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。

初始化 数组

初始化数组桶,自旋+CAS

/**
 * Initializes table, using the size recorded in sizeCtl.
 */
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 如果 sizeCtl < 0 ,说明另外的线程执行CAS 成功,正在进行初始化。
        if ((sc = sizeCtl) < 0)
            // 让出 CPU 使用权
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

从源码中可以发现 ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl ,它的值决定着当前的初始化状态。

  1. -1 说明正在初始化
  2. -N 说明有N-1个线程正在进行扩容
  3. 0 表示 table 初始化大小,没有初始化
  4. >0 表示 table 扩容的阈值,已经初始化。

put

先CAS不行再synchronized

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容
  5. 如果都不满足,则利用 synchronized 锁写入数据。
    Synchronized 锁自从引入锁升级策略后,性能不再是问题。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度≥64时才会将链表转换为红黑树。

get

  1. 根据 hash 值计算位置。
  2. 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
  3. 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
  4. 如果是链表,遍历查找之。

生产者消费者

Synchronized 版本

重点是一定要用while来判断,否则会出现虚假唤醒(那什么虚假唤醒呢,虚假唤醒就是由于把所有线程都唤醒了,但是只有其中一部分是有用的唤醒操作,其余的唤醒都是无用功,对于不应该被唤醒的线程而言,便是虚假唤醒。)

对于这个例子中,假设A完成了+1的任务,那么会notifyAll(),而C也是+1的线程,应当继续去wait,如果是if的话直接继续执行+1就出问题了。因此应当用while,当不该被唤醒是自己继续去睡。

package com.ldl.test;
//-Xms10m -Xms10m -XX:+PrintGCDetails

public class Main{
    public static void main(String[] args) throws Exception {
//        Main main = new Main();
        Data data = new Data();
        new Thread(()->{ try { for (int i = 0; i < 10; i++) data.increasement(); } catch (InterruptedException e) { e.printStackTrace(); } },"A").start();
        new Thread(()->{ try { for (int i = 0; i < 10; i++) data.decreasement(); } catch (InterruptedException e) { e.printStackTrace(); } },"B").start();
        new Thread(()->{ try { for (int i = 0; i < 10; i++) data.increasement(); } catch (InterruptedException e) { e.printStackTrace(); } },"C").start();
        new Thread(()->{ try { for (int i = 0; i < 10; i++) data.decreasement(); } catch (InterruptedException e) { e.printStackTrace(); } },"D").start();
    }

}

class Data{
    private int  num =0;
    public  synchronized void increasement() throws InterruptedException {
        while (num!=0){ //num为0的时候才去操作
            this.wait();
        }
        num++;
        System.out.println(Thread.currentThread().getName()+"=>"+num);
        this.notifyAll();
    }
    public  synchronized void decreasement() throws InterruptedException {
        while(num==0){//num不为0的时候才去操作
            this.wait();
        }
        num--;
        System.out.println(Thread.currentThread().getName()+"=>"+num);
        this.notifyAll();
    }
}

Lock版本

流程: 判断等待业务通知

不指定下一个线程

package com.ldl.test;
//-Xms10m -Xms10m -XX:+PrintGCDetails

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static void main(String[] args) throws Exception {
//        Main main = new Main();
        Data data = new Data();
        new Thread(() -> { try { for (int i = 0; i < 10; i++) data.increasement(); } catch (InterruptedException e) { e.printStackTrace(); } }, "A").start();
        new Thread(() -> { try { for (int i = 0; i < 10; i++) data.decreasement(); } catch (InterruptedException e) { e.printStackTrace(); } }, "B").start();
        new Thread(() -> { try { for (int i = 0; i < 10; i++) data.increasement(); } catch (InterruptedException e) { e.printStackTrace(); } }, "C").start();
        new Thread(() -> { try { for (int i = 0; i < 10; i++) data.decreasement(); } catch (InterruptedException e) { e.printStackTrace(); } }, "D").start();
    }

}

class Data {
    private int num = 0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();
    public  void increasement() throws InterruptedException {
        lock.lock();
        try {
            while (num != 0) { //num为0的时候才去操作
                condition.await();
            }
            num++;
            System.out.println(Thread.currentThread().getName() + "=>" + num);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public synchronized void decreasement() throws InterruptedException {
        lock.lock();
        try {
            while (num == 0) {//num不为0的时候才去操作
                condition.await();
            }
            num--;
            System.out.println(Thread.currentThread().getName() + "=>" + num);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }
}

指定下一个线程

  1. 需要一个标志位去记录应该到哪个执行了
  2. 需要Condition唤醒对应的线程
package com.ldl.test;
//-Xms10m -Xms10m -XX:+PrintGCDetails

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static void main(String[] args) throws Exception {
//        Main main = new Main();
        Data data = new Data();
        new Thread(() -> { try { for (int i = 0; i < 10; i++) data.printA(); } catch (InterruptedException e) { e.printStackTrace(); } }, "A").start();
        new Thread(() -> { try { for (int i = 0; i < 10; i++) data.printB(); } catch (InterruptedException e) { e.printStackTrace(); } }, "B").start();
        new Thread(() -> { try { for (int i = 0; i < 10; i++) data.printC(); } catch (InterruptedException e) { e.printStackTrace(); } }, "C").start();
    }

}

class Data {
    private int num = 1; //1A 2B 3C
    private Lock lock = new ReentrantLock();
    private Condition condition1 = lock.newCondition();
    private Condition condition2 = lock.newCondition();
    private Condition condition3 = lock.newCondition();

    public void printA() throws InterruptedException {
        lock.lock();
        try {
            while (num!=1){
                condition1.await();
            }
            System.out.println(Thread.currentThread().getName()+"AAAAA");
            num=2;
            condition2.signal();
        } finally {
            lock.unlock();
        }
    }
    public void printB() throws InterruptedException {
        lock.lock();
        try {
            while (num!=2){
                condition2.await();
            }
            System.out.println(Thread.currentThread().getName()+"BBBBB");
            num=3;
            condition3.signal();
        } finally {
            lock.unlock();
        }
    }
    public void printC() throws InterruptedException {
        lock.lock();
        try {
            while (num!=3){
                condition3.await();
            }
            System.out.println(Thread.currentThread().getName()+"CCCCC");
            num=1;
            condition1.signal();
        } finally {
            lock.unlock();
        }
    }

}

线程并发知识点

线程的状态

内核线程3

volatile 关键字

如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

JMM(Java 内存模型)强制在主存中进行读取

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

如何禁止指令重排序?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些。

下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化.

volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

synchronized 关键字

synchronized 是什么?

synchronized 是 Java 中的一个关键字,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

如何使用 synchronized?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {
    //业务代码
}

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {
    //业务代码
}

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {
    //业务代码
}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

构造方法可以用 synchronized 修饰么?

先说结论:构造方法不能使用 synchronized 关键字修饰。

构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized 底层原理了解吗?

synchronized 关键字底层原理属于 JVM 层面的东西。

synchronized 同步语句块的情况
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class

synchronized关键字原理

从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitoropen in new window实现的。每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在执行monitorenter,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1

执行 monitorenter 获取锁

synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

总结

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

相关推荐:Java 锁与线程的那些事 - 有赞技术团队open in new window

🧗🏻 进阶一下:学有余力的小伙伴可以抽时间详细研究一下对象监视器 monitor

JDK1.6 之后的 synchronized 底层做了哪些优化?

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

关于这几种优化的详细信息可以查看下面这篇文章:Java6 及以上版本对 synchronized 的优化open in new window

锁的升级

image-20230613143337109

重量级锁

这里是对这篇文章的摘抄。

Java对象头(存储锁类型)

在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充

对象头中包含两部分:MarkWord 和 类型指针。如果是数组对象的话,对象头还有一部分是存储数组的长度

多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作。

1、MarkWord

Mark Word 用于存储对象自身的运行时数据,如 HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等。
占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位,64位JVM -> MarkWord是64位)。

2、类型指针

虚拟机通过这个指针确定该对象是哪个类的实例。

3、对象头的长度
长度 内容 说明
32/64bit MarkWord 存储对象的hashCode或锁信息等
32/64bit Class Metadada Address 存储对象类型数据的指针
32/64bit Array Length 数组的长度(如果当前对象是数组)

如果是数组对象的话,虚拟机用3个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头,如果是普通对象的话,虚拟机用2字宽存储对象头(32/64bit + 32/64bit)。

优化后synchronized锁的分类

级别从低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

锁可以升级,但不能降级。即:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的。

下面看一下每个锁状态时,对象头中的 MarkWord 这一个字节中的内容是什么。

以32位系统为例:

无锁状态
25bit 4bit 1bit(是否是偏向锁) 2bit(锁标志位)
对象的hashCode 对象分代年龄 0 01

这里的 hashCode 是 Object#hashCode 或者 System#identityHashCode 计算出来的值,不是用户覆盖产生的 hashCode。

偏向锁状态
23bit 2bit 4bit 1bit 2bit
线程ID epoch 对象分代年龄 1 01

这里 线程ID 和 epoch 占用了 hashCode 的位置,所以,如果对象如果计算过 identityHashCode 后,便无法进入偏向锁状态,反过来,如果对象处于偏向锁状态,并且需要计算其 identityHashCode 的话,则偏向锁会被撤销,升级为重量级锁。

epoch:

对于偏向锁,如果 线程ID = 0 表示未加锁。

什么时候会计算 HashCode 呢?比如:将对象作为 Map 的 Key 时会自动触发计算,List 就不会计算,日常创建一个对象,持久化到库里,进行 json 序列化,或者作为临时对象等,这些情况下,并不会触发计算 hashCode,所以大部分情况不会触发计算 hashCode。

Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。

轻量级锁状态
30bit 2bit
指向线程栈锁记录的指针 00

这里指向栈帧中的 Lock Record 记录,里面当然可以记录对象的 identityHashCode。

重量级锁状态
30bit 2bit
指向锁监视器的指针 10

这里指向了内存中对象的 ObjectMonitor 对象,而 ObectMontitor 对象可以存储对象的 identityHashCode 的值。

锁的升级

偏向锁

偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。

为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。

如果支持偏向锁(没有计算 hashCode),那么在分配对象时,分配一个可偏向而未偏向的对象(MarkWord的最后 3 位为 101,并且 Thread Id 字段的值为 0)。

a、偏向锁的加锁
  1. 偏向锁标志是未偏向状态,使用 CAS 将 MarkWord 中的线程ID设置为自己的线程ID,
    1. 如果成功,则获取偏向锁成功。
    2. 如果失败,则进行锁升级。
  2. 偏向锁标志是已偏向状态
    1. MarkWord 中的线程 ID 是自己的线程 ID,成功获取锁
    2. MarkWord 中的线程 ID 不是自己的线程 ID,需要进行锁升级

偏向锁的锁升级需要进行偏向锁的撤销

b、偏向锁的撤销
  1. 对象是不可偏向状态
    1. 不需要撤销
  2. 对象是可偏向状态
    1. MarkWord 中指向的线程不存活
      1. 允许重偏向:退回到可偏向但未偏向的状态
      2. 不允许重偏向:变为无锁状态
    2. MarkWord 中的线程存活
      1. 线程ID指向的线程仍然拥有锁
        1. 升级为轻量级锁,将 mark word 复制到线程栈中
      2. 不再拥有锁
        1. 允许重偏向:退回到可偏向但未偏向的状态
        2. 不允许重偏向:变为无锁状态

小结: 撤销偏向的操作需要在全局检查点执行。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不在拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否 允许重偏向(rebiasing),获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁 升级为轻量级锁,线程B自旋请求获得锁。

偏向锁的撤销流程

img

轻量级锁

之所以是轻量级,是因为它仅仅使用 CAS 进行操作,实现获取锁。

a、加锁流程

如果线程发现对象头中Mark Word已经存在指向自己栈帧的指针,即线程已经获得轻量级锁,那么只需要将0存储在自己的栈帧中(此过程称为递归加锁);在解锁的时候,如果发现锁记录的内容为0, 那么只需要移除栈帧中的锁记录即可,而不需要更新Mark Word。

加锁前:
img

加锁后:
img

线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录(Lock Record)的指针, 如上图所示。如果成功,当前线程获得轻量级锁,如果失败,虚拟机先检查当前对象头的 Mark Word 是否指向当前线程的栈帧,如果指向,则说明当前线程已经拥有这个对象的锁,则可以直接进入同步块 执行操作,否则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数 达到界限值(threshold),轻量级锁将会膨胀为重量级锁

b、撤销流程

轻量级锁解锁时,如果对象的Mark Word仍然指向着线程的锁记录,会使用CAS操作, 将Dispalced Mark Word替换到对象头,如果成功,则表示没有竞争发生。如果失败, 表示当前锁存在锁竞争,锁就会膨胀为重量级锁。

重量级锁

重量级锁(heavy weight lock),是使用操作系统互斥量(mutex来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁.

总结

首先要明确一点是引入这些锁是为了提高获取锁的效率, 要明白每种锁的使用场景, 比如偏向锁适合一个线程对一个锁的多次获取的情况; 轻量级锁适合锁执行体比较简单(即减少锁粒度或时间), 自旋一会儿就可以成功获取锁的情况.

要明白MarkWord中的内容表示的含义.

创建线程的5种方式

img

  1. 继承Thread类创建线程;
  2. 实现Runnable接口创建线程;
  3. 实现Callable接口,通过FutureTask包装器来创建Thread线程;
  4. 使用ExecutorService、Callable(或者Runnable)、Future实现由返回结果的线程。
  5. 使用CompletableFuture类创建异步线程,且是据有返回结果的线程。 JDK8新支持

继承Thread类创建线程

就是继承Thread类然后重写run方法,我们在run方法内部进行线程逻辑的编写。

java的start方法会调用start0方法,start0是一个native方法,底层会调用run方法进行执行线程,所以我们是重写run方法。那run方法是怎么来的呢?
在这里插入图片描述

上面是run方法在Thread中的实现,我们可以看到他被注解Override修饰了,说明这个方法是父类的方法,我们点击左侧红色向上的箭头,就会发现跳到了Runnable接口中,如下

在这里插入图片描述

实现Runnable接口

这种方式也是需要依赖Thread才能去创建线程,如下所示

public class TestThread {
	    public static void main(String[] args) {
	        new Thread(() -> {
	            while(true)
	                System.out.println("Runnable多线程1");
	        }).start();
	        
	        new Thread(() -> {
	            while(true)
	                System.out.println("Runnable多线程2");
	        }).start();
	    }
	}

那我们来看下这个实现方式到底是如何进行多线程创建的吧,我们还是从run方法开始,因为Thread启动线程时调用的是自己的run方法,那我们就先看下他自己的run方法实现:

在这里插入图片描述

可以看到Thread的run方法调用的是target.run方法,那target是什么呢?ctrl+左会发现,他就是一个Runnable,那这个Runnable怎么来的呢?我们创建过程中只在Thread的构造方法中传入了Runnable,那是不是在这里咱们一起看下:

在这里插入图片描述

这里没有做什么实质的操作,所以继续看,最后调用到了这里:

private void init(ThreadGroup g, Runnable target, String name,long stackSize, AccessControlContext acc) {
    .....
    this.target = target; // 这里是关键点
    .....
}

我们会发现这个方法内部将我们从构造器中传入的Runnable对象放到了this.target中,所以可以看到Thread中的run方法调用的就是我们重写的run方法。所以这个也证明了java中是通过Thread+Runnable实现的多线程方式。

实现Callable接口

这种方式我们需要依赖FutrureTask,其实我们可以接受返回值也得归功FutureTask,下面是常见的实现代码:

public class TestThread {
	    public static void main(String[] args) throws Exception{
	        FutureTask<String> futureTask = new FutureTask<>(()->{
	            int i =0 ;
	            while(i<100)
	                System.out.println("Callable线程1在执行:"+i++);
	            return "线程1执行完了";
	        });
	
	        FutureTask<String> futureTask2 = new FutureTask<>(()->{
	            int i =0 ;
	            while(i<100)
	                System.out.println("Callable线程2在执行:"+i++);
	            return "线程2执行完了";
	        });
	
	        new Thread(futureTask).start();
	        new Thread(futureTask2).start();
	        System.out.println(futureTask.get());
	        System.out.println(futureTask2.get());
	    }
	}

run方法才是多线程的执行地方,还是一样我们还是从run方法开始看,我们点击下new Thread会进入Thread的构造器,发现进入的构造器和第二种是一样的:

在这里插入图片描述

这就说明FutureTask一定实现了或者继承了Runnable接口,其实FutureTask实现了RunnableFuture接口,而RunnableFuture继承了Runnable接口,因为继承和实现的特性,相当于FutureTask实现了Runnable接口。那走到这个构造器就是理所当然那的了。所以与第二种方式相同的是Thread调用的target.run就是FutureTask的run了。我们来看下FutureTask的run方法吧:

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方法,里面真正调用的是c.call方法,看到call方法应该就明白了,不错这个call就是我们传入到FutureTask中的Callable实例的call方法,所以FutureTask的调用路线也就清晰了:Thread.start–>Thread.run–>FutureTask.run–>Callable.call。而FutureTask则是Runnable的子类,所以也证明了我们一开始说的java是通过Thread+Runnable来实现的多线程。此外通过这里我们还可以清晰的看到FutureTask是怎么实现参数返回接收的,就是因为call方法有返回,然后FutureTask的run方法接收到返回后将他存放到自身的泛型V中,然后我们就可以直接通过FutureTask.get方法进行获取了。其实这种思想java里有很多,比如常见的http请求的包装类也是通过这种思想来处理流数据的。

通过线程池创建

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,1, 60,TimeUnit.SECONDS, new SynchronousQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.DiscardPolicy() );
        threadPoolExecutor.execute(()->{
            while(true){
                System.out.println("线程4执行中");
            }
        });

关于各个参数的意思,这里就不解释了,需要的看这里:4万字爆肝总结java多线程知识点,言归正传,我们看下线程池是怎么创建线程的,线程池提交任务有submit和execute还有一个定时的schedule,不过schedule他的线程池类不是这个,不过原理一样这里只介绍ThreadPoolExecutor的。我们看下execute的实现方法

public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        // 此处省略原文注释
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {//判断线程是否小于核心线程数,很明显第一次会进入这里
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }

很明显第一次执行会执行:addWorker(command, true)这个方法,我们再看下这个方法做了什么

private boolean addWorker(Runnable firstTask, boolean core) {
        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);//这个firstTask实际就是我们从execute传入的Runnable实现类
            final Thread t = w.thread;//获取thread,因为线程开启必须利用Thread的start方法,这两步就是最关键的地方
            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();//这里真正调用了线程启动
                    workerStarted = true;
                }
            }
        } finally {
            if (! workerStarted)
                addWorkerFailed(w);
        }
        return workerStarted;
    }

最关键的两步,笔者在代码中加了注释了,可以看到会将我们传入的Runnable的实现类交给Worker的构造器,那我们看看这个构造器又干了什么:

在这里插入图片描述

可以看到在这个构造器里面,将我们传入的Runnable给到了自己的Runnable,对他的私有变量进行了初始化,然后又对thread进行了初始化,而初始化Thread时传入了this,注意传入的是this。因为Worker实现了Runable,所以当我们真正执行start方法时,调用的应该是Worker的run方法,所以我们到这里应该去看run方法了,run方法很简单,他直接调用了runWorer方法,那看下runWorker的实现:

final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask;//将Runnable的对象指向一个新的引用
        w.firstTask = null;//失效原引用
        w.unlock(); // allow interrupts//加锁,worker实现了AQS,支持锁
        boolean completedAbruptly = true;
        try {
            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();//这里相当于运行了我们在execute中传入的Runnable对象的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);
        }
    }

看笔者在代码中加的注释很清晰就看到最终调用的是我们从execute中传入的Runnable的对象的run方法。所以我们看到这里也是可以得出一个解决线程池还是利用Thread+Runnable接口一起实现的多线程。

那么来重新梳理下线程池的调用过程:
execute(Runnable)–>addWorker(command, true)–>内部对Worker(Runnale)进行初始化,Worker该类实现了Runnable,初始化Worker时生产线程Thread传入Worker自己,之后获取上一步生产的thread调用start方法。

执行start方法,实际执行的是Worker的run方法,worker.run方法调用了runWorker方法,该方法执行时是将Worker的Runnable的对象的run方法进行调用执行。而Worker的Runnable的对象就是在addWorker中由execute方法传入的Runnable实现类。这样整个流程就很清晰了,我们也是可以佐证一开始说的观点了。

CompletableFuture类创建异步线程

package com.xiaoxuzhu;
 
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
 
import org.junit.Test;
 
/**
 * Description:  使用CompletableFuture类创建异步线程,且是据有返回结果的线程。
 *
 * @author xiaoxuzhu
 * @version 1.0
 *
 * <pre>
 * 修改记录:
 * 修改后版本	        修改人		修改日期			修改内容
 * 2022/5/15.1	    xiaoxuzhu		2022/5/15		    Create
 * </pre>
 * @date 2022/5/15
 */
public class ThreadDemo5 {
 
    /**
     * A任务B任务完成后,才执行C任务
     * 返回值的处理
     * @param
     *@return void
     **/
    @Test
    public void completableFuture1(){
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("future1 finished!");
            return "future1 finished!";
        });
 
        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
            System.out.println("future2 finished!");
            return "future2 finished!";
        });
 
        CompletableFuture<Void> future3 = CompletableFuture.allOf(future1, future2);
        try {
            future3.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        System.out.println("future1: " + future1.isDone() + " future2: " + future2.isDone());
 
    }
 
    /**
     * 在Java8中,CompletableFuture提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,
     * 并且提供了函数式编程的能力,可以通过回调的方式处理计算结果,也提供了转换和组合 CompletableFuture 的方法
     *
     *  注意: 方法中有Async一般表示另起一个线程,没有表示用当前线程
     */
    @Test
    public void test01() throws Exception {
        ExecutorService service = Executors.newFixedThreadPool(5);
        /**
         *  supplyAsync用于有返回值的任务,
         *  runAsync则用于没有返回值的任务
         *  Executor参数可以手动指定线程池,否则默认ForkJoinPool.commonPool()系统级公共线程池
         */
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "xiaoxuzhu";
        }, service);
        CompletableFuture<Void> data = CompletableFuture.runAsync(() -> System.out.println("xiaoxuzhu"));
        /**
         * 计算结果完成回调
         */
        future.whenComplete((x,y)-> System.out.println("有延迟3秒:执行当前任务的线程继续执行:"+x+","+y)); //执行当前任务的线程继续执行
        data.whenCompleteAsync((x,y)-> System.out.println("交给线程池另起线程执行:"+x+","+y)); // 交给线程池另起线程执行
        future.exceptionally(Throwable::toString);
        //System.out.println(future.get());
        /**
         * thenApply,一个线程依赖另一个线程可以使用,出现异常不执行
         */
        //第二个线程依赖第一个的结果
        CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> 5).thenApply(x -> x);
 
        /**
         * handle 是执行任务完成时对结果的处理,第一个出现异常继续执行
         */
        CompletableFuture<Integer> future2 = future1.handleAsync((x, y) -> x + 2);
        System.out.println(future2.get());//7
        /**
         * thenAccept 消费处理结果,不返回
         */
        future2.thenAccept(System.out::println);
        /**
         * thenRun  不关心任务的处理结果。只要上面的任务执行完成,就开始执行
         */
        future2.thenRunAsync(()-> System.out.println("继续下一个任务"));
        /**
         * thenCombine 会把 两个 CompletionStage 的任务都执行完成后,两个任务的结果交给 thenCombine 来处理
         */
        CompletableFuture<Integer> future3 = future1.thenCombine(future2, Integer::sum);
        System.out.println(future3.get()); // 5+7=12
        /**
         * thenAcceptBoth : 当两个CompletionStage都执行完成后,把结果一块交给thenAcceptBoth来进行消耗
         */
        future1.thenAcceptBothAsync(future2,(x,y)-> System.out.println(x+","+y)); //5,7
        /**
         * applyToEither
         * 两个CompletionStage,谁执行返回的结果快,我就用那个CompletionStage的结果进行下一步的转化操作
         */
        CompletableFuture<Integer> future4 = future1.applyToEither(future2, x -> x);
        System.out.println(future4.get()); //5
        /**
         * acceptEither
         * 两个CompletionStage,谁执行返回的结果快,我就用那个CompletionStage的结果进行下一步的消耗操作
         */
        future1.acceptEither(future2, System.out::println);
        /**
         * runAfterEither
         * 两个CompletionStage,任何一个完成了都会执行下一步的操作(Runnable
         */
        future1.runAfterEither(future,()-> System.out.println("有一个完成了,我继续"));
        /**
         * runAfterBoth
         * 两个CompletionStage,都完成了计算才会执行下一步的操作(Runnable)
         */
        future1.runAfterBoth(future,()-> System.out.println("都完成了,我继续"));
        /**
         * thenCompose 方法
         * thenCompose 方法允许你对多个 CompletionStage 进行流水线操作,第一个操作完成时,将其结果作为参数传递给第二个操作
         * thenApply是接受一个函数,thenCompose是接受一个future实例,更适合处理流操作
         */
        future1.thenComposeAsync(x->CompletableFuture.supplyAsync(()->x+1))
                .thenComposeAsync(x->CompletableFuture.supplyAsync(()->x+2))
                .thenCompose(x->CompletableFuture.runAsync(()-> System.out.println("流操作结果:"+x)));
        TimeUnit.SECONDS.sleep(5);//主线程sleep,等待其他线程执行
    }
}

线程池

线程池的几种状态

  1. RUNNING
    状态说明:在RUNNING状态下,线程池可以接收新的任务和执行已添加的任务。
    线程池的初始化状态是RUNNING。换句话说,线程池被一旦被创建(比如调用Executors.newFixedThreadPool()或者使用ThreadPoolExecutor进行创建),就处于RUNNING状态,并且线程池中的任务数为0!线程池处在RUNNING状态时,能够接收新任务,以及对已添加的任务进行处理。
  2. SHUTDOWN
    状态说明:线程池处在SHUTDOWN状态时,不接收新任务,但能处理已添加的任务
    当一个线程池调用shutdown()方法时,线程池由RUNNING -> SHUTDOWN。
  3. STOP
    状态说明:线程池处在STOP状态时,不接收新任务,不处理已添加的任务,并且会中断正在执行的任务
    调用线程池的shutdownNow()方法的时候,线程池由(RUNNING或者SHUTDOWN ) -> STOP
  4. TIDYING
    状态说明:当所有的任务已终止,记录的”任务数量”为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。
    terminated()方法在ThreadPoolExecutor类中是空的,没有任何实现。
    若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重写terminated()函数来实现。 当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。 当线程池在STOP状态下,线程池中执行的任务为空时,会由STOP -> TIDYING。
  5. TERMINATED
    状态说明:当钩子函数terminated()被执行完成之后,线程池彻底终止,就变成TERMINATED状态。
    线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。

img
状态之间状态,和各个状态之间的的切换总结

为什么不推荐使用内置线程池?

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

Executors 返回线程池对象的弊端如下(后文会详细介绍到):

  • FixedThreadPoolSingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
// 无界队列 LinkedBlockingQueue
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
}
// 无界队列 LinkedBlockingQueue
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}
// 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE`
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
// DelayedWorkQueue(延迟阻塞队列)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

线程池常见参数有哪些?

/**
 * 用给定的初始参数创建一个新的ThreadPoolExecutor。
 */
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
                          int maximumPoolSize,//线程池的最大线程数
                          long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                          TimeUnit unit,//时间单位
                          BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
                          ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
                          RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
                           ) {
	...
}

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数 :

  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :饱和策略。
    1. 默认策略是AbortPolicy 队列满了,ThreadPoolExecutor 将抛出 RejectedExecutionException 异常来拒绝新来的任务 ;
    2. 如果不想丢弃任务的话,可以使用CallerRunsPolicyCallerRunsPolicy 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。
public static class CallerRunsPolicy implements RejectedExecutionHandler {

        public CallerRunsPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                // 直接主线程执行,而不是线程池中的线程执行
                r.run();
            }
        }
    }

线程池常用的阻塞队列有哪些?

新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。

  • 容量为 Integer.MAX_VALUELinkedBlockingQueue(无界队列)FixedThreadPoolSingleThreadExector 。由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。
  • SynchronousQueue(同步队列)CachedThreadPoolSynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
  • DelayedWorkQueue(延迟阻塞队列)ScheduledThreadPoolSingleThreadScheduledExecutorDelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

线程池处理任务的流程

图解线程池实现原理

如何设定线程池的大小?

CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

Future

Future 类有什么用?

Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。

在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {
    // 取消任务执行
    // 成功取消返回 true,否则返回 false
    boolean cancel(boolean mayInterruptIfRunning);
    // 判断任务是否被取消
    boolean isCancelled();
    // 判断任务是否已经执行完成
    boolean isDone();
    // 获取任务执行结果
    V get() throws InterruptedException, ExecutionException;
    // 指定时间内没有返回计算结果就抛出 TimeOutException 异常
    V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutExceptio
}

简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果.

Callable 和 Future 有什么关系?

我们可以通过 FutureTask 来理解 CallableFuture 之间的关系。

FutureTask 提供了 Future 接口的基本实现,常用来封装 CallableRunnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask

<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);

FutureTask 不光实现了 Future接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行。

img

FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。

public FutureTask(Callable<V> callable) {
    if (callable == null)
        throw new NullPointerException();
    this.callable = callable;
    this.state = NEW;
}
public FutureTask(Runnable runnable, V result) {
    // 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象
    this.callable = Executors.callable(runnable, result);
    this.state = NEW;
}

FutureTask相当于对Callable 进行了封装,管理着任务执行的情况,存储了 Callablecall 方法的任务执行结果。

CompletableFuture 类有什么用?

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用

Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力

CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。CompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。

img

ThreadLocal

ThreadLocal 有什么用?

ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get()set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

image-20230612111552229

ThreadLocal 原理了解吗?

Thread类源代码入手。

public class Thread implements Runnable {
    //......
    //与此线程有关的ThreadLocal值。由ThreadLocal类维护
    ThreadLocal.ThreadLocalMap threadLocals = null;
    //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}

从上面Thread类 源代码可以看出Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 setget方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()set()方法。

ThreadLocal类的set()方法

inheritableThreadLocals

public void set(T value) {
    //获取当前请求的线程
    Thread t = Thread.currentThread();
    //取出 Thread 类内部的 threadLocals 变量(哈希表结构)
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 将需要存储的值放入到这个哈希表中
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //......
}

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。

ThreadLocal 数据结构如下图所示:

ThreadLocal 数据结构

ThreadLocalMapThreadLocal的静态内部类。

ThreadLocal内部类

ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法

强引用:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候

软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收

弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收

虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知

ReentrantLock

基于经典的 AQS(AbstractQueueSyncronized) 实现的, AQS 是基于 volitale 和 CAS 实现的,其中 AQS 中维护一个 valitale 类型的变量 state 来做一个可重入锁的重入次数,加锁和释放锁也是围绕这个变量来进行的。 ReentrantLock 也提供了一些 synchronized 没有的特点,因此比 synchronized 好用。

AQS模型如下图:

图片

reentrantLock 有如下特点:

1、可重入

ReentrantLock 和 syncronized 关键字一样,都是可重入锁,不过两者实现原理稍有差别, RetrantLock 利用 AQS 的的 state 状态来判断资源是否已锁,同一线程重入加锁, state 的状态 +1 ; 同一线程重入解锁, state 状态 -1 (解锁必须为当前独占线程,否则异常); 当 state 为 0 时解锁成功。

2、需要手动加锁、解锁

synchronized 关键字是自动进行加锁、解锁的,而 ReentrantLock 需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成,来手动加锁、解锁。

3、支持设置锁的超时时间

synchronized 关键字无法设置锁的超时时间,如果一个获得锁的线程内部发生死锁,那么其他线程就会一直进入阻塞状态,而 ReentrantLock 提供 tryLock 方法,允许设置线程获取锁的超时时间,如果超时,则跳过,不进行任何操作,避免死锁的发生。

4、支持公平/非公平锁

synchronized 关键字是一种非公平锁,先抢到锁的线程先执行。而 ReentrantLock 的构造方法中允许设置 true/false 来实现公平、非公平锁,如果设置为 true ,则线程获取锁要遵循"先来后到"的规则,每次都会构造一个线程 Node ,然后到双向链表的"尾巴"后面排队,等待前面的 Node 释放锁资源。

5、可中断锁

ReentrantLock 中的 lockInterruptibly() 方法使得线程可以在被阻塞时响应中断,比如一个线程 t1 通过 lockInterruptibly() 方法获取到一个可重入锁,并执行一个长时间的任务,另一个线程通过 interrupt() 方法就可以立刻打断 t1 线程的执行,来获取t1持有的那个可重入锁。而通过 ReentrantLock 的 lock() 方法或者 Synchronized 持有锁的线程是不会响应其他线程的 interrupt() 方法的,直到该方法主动释放锁之后才会响应 interrupt() 方法。

AQS

AQS 是什么?

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面。

image-20230611151851801

AQS 就是一个抽象类,主要用来构建锁和同步器。

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
}

AQS 的原理是什么?

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

img

img

AQS的state

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。

state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

// 共享变量,使用volatile修饰保证线程可见性
private volatile int state;

另外,状态信息 state 可以通过 protected 类型的getState()setState()compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

//返回同步状态的当前值
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);
}

ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock()state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。

再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),unpark() 主调用线程,然后主调用线程就会await() 函数返回,继续后余动作

Java线程VS操作系统线程

之前有被问过CPU到底是执行的进程还是线程,其实得看具体操作系统实现,感觉就Linux而言还是进程,线程差不多是一种轻量级的进程,字段结构啥的没有什么很大的变化。

浅谈java中线程和操作系统线程这篇文章有差不多讲了JVM中的线程是怎么做到的,Java中的线程差不多就是和Linux里面的内核线程一一对应。

Java线程VS操作系统线程这篇文章讲了JVM通过JavaThread来和Linux的线程进行对应,相当于对Linux线程进行了封装,然后再提供给Thread。

img

操作系统进程相关知识点

JUC

就目前对上面的Condition、synchronized等底层还是不太理解,JUC部分内容过于琐碎,因此总结一下。

JUC是什么?

JUC是java.util.concurrent包的简称,在Java5.0添加,目的就是为了更好的支持高并发任务。让开发者进行多线程编程时减少竞争条件和死锁的问题!

Synchronized与锁升级

ReentrantLock

image-20230519183830732

Spring

Spring 中创建 Bean 的步骤

在 Spring 中创建 Bean 分三步:

  1. 实例化,createBeanInstance,就是 new 了个对象。
  2. 属性注入,populateBean, 就是 set 一些属性值。
  3. 初始化,initializeBean,执行一些 aware 接口中的方法,initMethod,AOP代理等

循环依赖

循环依赖的定义

A对象创建的时候里面包含B,B对象创建的时候里面包含A

  1. 构造注入没办法解决循环依赖
  2. set设值注入的话,可以通过提前暴露出半成品对象的引用,让另一个对象先使用这个引用初始化好自己来解决循环依赖。

image-20230331190738192

构造注入都是无解的,需要字典序靠前的是设值注入。

Spring如何检测构造注入发生了循环依赖

image-20230331195918224

Bean在实例化后会被放入到Set中,这个Set用于保存所有正在创建中的实例。

如果创建单例A的时候,发现依赖的对象B在这个Set中,说明B也正在创建,发生了循环依赖。

如何解决循环依赖

解决循环依赖的条件

在 Spring 中,只有同时满足以下两点才能解决循环依赖的问题:

  1. 依赖的 Bean 必须都是单例。
  2. 依赖注入的方式,必须不全是构造器注入,且 beanName 字母序在前的不能是构造器注入。

接下来要逐一解释这两点:

原型模式不支持循环依赖的原因

根本原因:在原型模式中,每个对象都会调用自己的 clone() 方法来创建新的对象,如果对象之间存在循环依赖,那么可能会出现无限递归的情况,进而导致内存溢出等问题。
举例:按照理解,如果两个 Bean 都是原型模式的话。那么创建 A1 需要创建一个 B1。创建 B1 的时候要创建一个 A2。创建 A2 又要创建一个 B2。创建 B2 又要创建一个 A3。创建 A3 又要创建一个 B3.....

如果是单例的话,创建 A 需要创建 B,而创建的 B 需要的是之前的个 A, 不然就不叫单例了

为什么不能全是构造器注入

在 Spring 中创建 Bean 分三步:

  1. 实例化,createBeanInstance,就是 new 了个对象。
  2. 属性注入,populateBean, 就是 set 一些属性值。
  3. 初始化,initializeBean,执行一些 aware 接口中的方法,initMethod,AOP代理等

因为解决循环依赖需要提前暴露,而构造器创建对象无法暴露出半成品对象A,这会导致B类无法完成构建。

img

设值注入顺序的影响

img

A先暴露出半成品对象,等B创建完成再通过设值注入,这种是解决了的。

img

虽然可以先实例化B来解决,但是Spring容器是按照字母序创建 Bean 的,A 的创建永远排在 B 前面。

Spring 解决循环依赖全流程

image-20230331201650421

image-20230331201625087

三级缓存

为了解决单例的循环依赖,Spring搞了个三级缓存:

  1. 一级缓存singletonObjects,存储所有已创建完毕的单例 Bean (完整的 Bean)。
  2. 二级缓存earlySingletonObjects,存储所有仅完成实例化,但还未进行属性注入和初始化的 Bean。
  3. 三级缓存singletonFactories,存储能建立这个 Bean 的一个工厂,通过工厂能获取这个 Bean,延迟化 Bean 的生成,工厂生成的 Bean 会塞入二级缓存。

三级缓存如何运行

  1. 首先,获取单例 Bean 的时候会通过 BeanName 先去 singletonObjects(一级缓存) 查找完整的 Bean,如果找到则直接返回,否则进行步骤 2。
  2. 看对应的 Bean 是否在创建中,如果不在直接返回找不到,如果是,则会去 earlySingletonObjects (二级缓存)查找 Bean,如果找到则返回,否则进行步骤 3
  3. 去 singletonFactories (三级缓存)通过 BeanName 查找到对应的工厂,如果存着工厂则通过工厂创建 Bean ,并且放置到 earlySingletonObjects 中。
  4. 如果三个缓存都没找到,则返回 null。

第二步很关键,如果没有在创建中直接返回null并标记这个 Bean 正在创建中,然后调用createBean 方法中的doCreateBean 方法来创建实例。

doCreateBean 这个方法就会执行上面我们说的三步骤:

  1. 实例化
  2. 属性注入
  3. 初始化

实例化 Bean 之后,会往 singletonFactories 塞入一个工厂,而调用这个工厂的 getObject 方法,就能得到这个 Bean
目的是通过这个工厂提前暴露出来代理对象

addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));

Spring 解决依赖循环步骤:

  1. 创建A,实例化A放入三级缓存中
  2. A属性注入发现需要B,那么B也去创建,实例化放入三级缓存中
  3. B属性注入发现需要A,调用getBean(A),发现A正在创建中,去二级缓存中找,没找到,去三级找,找到了
  4. 通过三级缓存里的工厂得到 A,然后将这个工厂从三级缓存里删除,并将 A 加入到二级缓存中
  5. B属性注入成功
  6. B 调用 initializeBean 初始化,最终返回,此时 B 已经被加到了一级缓存里
  7. 回到了 A 的属性注入,此时注入了 B,接着执行初始化,最后 A 也会被加到一级缓存里,且从二级缓存中删除 A

为什么循环依赖需要三级缓存,二级不够吗

三级缓存中的工厂会调用getObject()方法返回代理Bean对象

protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
        for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {
            exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
        }
    }
    return exposedObject;
}

如果要返回的Bean对象有声明BeanPostProcessor,说明 Bean 需要被 aop 代理。

尽管二级缓存也可以在放入半成品对象的时候直接进行代理,但是对于Bean的生命周期来看不合理。

正常代理对象的生成是基于后置处理器,是在被代理的对象初始化后期调用生成的,所以如果你提早代理了其实是违背了 Bean 定义的生命周期。

网络

img

零散知识点

ARP协议

​ 地址解析协议,即ARP(Address Resolution Protocol),是根据IP地址获取物理地址(MAC地址)的一个TCP/IP协议主机发送信息时将包含目标IP地址的ARP请求广播到局域网络上的所有主机,并接收返回消息,以此确定目标的物理地址;收到返回消息后将该IP地址和物理地址存入本机ARP缓存中并保留一定时间,下次请求时直接查询ARP缓存以节约资源。

​ 地址解析协议是建立在网络中各个主机互相信任的基础上的,局域网络上的主机可以自主发送ARP应答消息,其他主机收到应答报文时不会检测该报文的真实性就会将其记入本机ARP缓存;由此攻击者就可以向某一主机发送伪ARP应答报文,使其发送的信息无法到达预期的主机或到达错误的主机,这就构成了一个ARP欺骗

ICMP协议

ICMP协议是一种面向无连接的协议,用于传输出错报告控制信息。它是一个非常重要的协议,它对于网络安全具有极其重要的意义。 [3] 它属于网络层协议,主要用于在主机与路由器之间传递控制信息,包括报告错误、交换受限控制和状态信息等。当遇到IP数据无法访问目标、IP路由器无法按当前的传输速率转发数据包等情况时,会自动发送ICMP消息。

SYN

SYN:同步序列编号(*Synchronize Sequence Numbers*)。是TCP/IP建立连接时使用的握手信号。在客户机和服务器之间建立正常的TCP网络连接时,客户机首先发出一个SYN消息,服务器使用SYN+ACK应答表示接收到了这个消息,最后客户机再以ACK消息响应。这样在客户机和服务器之间才能建立起可靠的TCP连接,数据才可以在客户机和服务器之间传递。

TCP连接的第一个包,非常小的一种数据包。SYN 攻击包括大量此类的包,由于这些包看上去来自实际不存在的站点,因此无法有效进行处理。每个机器的欺骗包都要花几秒钟进行尝试方可放弃提供正常响应。

MSS

最大报文段长度(MSS)是TCP协议的一个选项,用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度(不包括文段头)。

OSI模型

对于IPv4,为了避免IP分片,主机一般默认MSS为536字节 (576IP最大字节数-20字节TCP协议头-20字节IP协议头=536字节)。同理,IPv6的主机默认MSS为1220字节(1280IP最大字节数-20字节TCP协议头-40字节IP协议头=1220字节)。 [1] 当发送方主机想要调整MSS时,应注意以下几点:

  1. MSS不包含TCP及IP的协议头长度。 [1]
  2. MSS选项只能在初始化连接请求(SYN=1)使用。 [1]
  3. 发送方与接收方的MSS不一定相等。 [1]

MTU

当网络包超过 MTU 的大小,就会在网络层分片,以确保分片后的 IP 包不会超过 MTU 大小

状态码

 五大类 HTTP 状态码

  • 200 OK」是最常见的成功状态码,表示一切正常。如果是非 HEAD 请求,服务器返回的响应头都会有 body 数据。

  • 204 No Content」也是常见的成功状态码,与 200 OK 基本相同,但响应头没有 body 数据。

  • 206 Partial Content」是应用于 HTTP 分块下载或断点续传,表示响应返回的 body 数据并不是资源的全部,而是其中的一部分,也是服务器处理成功的状态。

  • 301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。

  • 302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。
    301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。

  • 304 Not Modified」不具有跳转的含义,表示资源未修改,重定向已存在的缓冲文件,也称缓存重定向,也就是告诉客户端可以继续使用缓存资源,用于缓存控制。

  • 400 Bad Request」表示客户端请求的报文有错误,但只是个笼统的错误。

  • 403 Forbidden」表示服务器禁止访问资源,并不是客户端的请求出错。

  • 404 Not Found」表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端。

  • 500 Internal Server Error」与 400 类型,是个笼统通用的错误码,服务器发生了什么错误,我们并不知道。

  • 501 Not Implemented」表示客户端请求的功能还不支持,类似“即将开业,敬请期待”的意思。

  • 502 Bad Gateway」通常是服务器作为网关或代理时返回的错误码,表示服务器自身工作正常,访问后端服务器发生了错误。

  • 503 Service Unavailable」表示服务器当前很忙,暂时无法响应客户端,类似“网络服务正忙,请稍后重试”的意思。

常见字段

Host 字段:客户端发送请求时,用来指定服务器的域名。
Content-Length 字段:服务器在返回数据时,会有 Content-Length 字段,表明本次回应的数据长度
Connection 字段:Connection: Keep-Alive 最常用于客户端要求服务器使用「HTTP 长连接」机制,以便其他请求复用。
Content-Type 字段:Content-Type 字段用于服务器回应时,告诉客户端,本次数据是什么格式。
Content-Encoding字段:Content-Encoding 字段说明数据的压缩方法。表示服务器返回的数据使用了什么压缩格式

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

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

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

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

HTTP

TCP 粘包

定义当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。
字节流:UDP不会被拆分,TCP会被拆分成多个TCP包,至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件image-20230324093356167

UDP为啥没有:UDP发送一个就是一个用户消息,接受直接放到队列里自己是一个元素,所以不用区分

解决方式:一般有三种方式分包的方式:

  • 固定长度的消息;这种方式灵活性不高,实际中很少用。

  • 特殊字符作为边界;

    HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。

    有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。

  • 自定义消息结构。
    自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。

长连接 HTTP1.1

HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。

TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接。

队头阻塞

HTTP 流水线(HTTP1.1 管道网络传输)客户端可以先一次性发送多个请求,而在发送过程中不需先等待服务器的回应,可以减少整体的响应时间
服务器还是按照顺序响应:而且要等服务器响应完客户端第一批发送的请求后,客户端才能发出下一批的请求,也就说如果服务器响应的过程发生了阻塞,那么客户端就无法发出下一批的请求,此时就造成了「队头阻塞」的问题。

HTTP缓存技术

强制缓存:只要浏览器判断缓存没有过期,则直接使用浏览器的本地缓存**,决定是否使用缓存的主动性在于浏览器这边。

  • 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 Cache-Control,Cache-Control 中设置了过期时间大小;
  • 浏览器再次请求访问服务器中的该资源时,会先通过请求资源的时间与 Cache-Control 中设置的过期时间大小,来计算出该资源是否过期,如果没有,则使用该缓存,否则重新请求服务器;
  • 服务器再次收到请求后,会再次更新 Response 头部的 Cache-Control。

协商缓存:某些请求的响应码是 304 No Modified,协商缓存就是与服务端协商之后,通过协商结果来判断是否使用本地缓存
img

HTTPS和HTTP的对比

  1. 区别:

    1. HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
    2. HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
    3. 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
    4. HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的
  2. http的问题:

    1. 窃听风险,比如通信链路上可以获取通信内容,用户号容易没。
    2. 篡改风险,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。
    3. 冒充风险,比如冒充淘宝网站,用户钱容易没。
  3. 如何解决:

    1. 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
    2. 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
    3. 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。
  4. 如何做到的:

    1. 混合加密的方式实现信息的机密性,解决了窃听的风险。

      采用「混合加密」的方式的原因:

      1. 对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。 用于建立连接后
      2. 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。用于建立连接
    2. 摘要算法的方式来实现完整性,它能够为数据生成独一无二的「指纹」,指纹用于校验数据的完整性,解决了篡改的风险。
      摘要算法 + 数字签名
      img

    3. 将服务器公钥放入到数字证书中,解决了冒充的风险。
      万一公钥是被伪造的呢?因此需要数字证书进行身份鉴权
      数子证书工作流程

      用CA的公钥去解密CA的数字签名,然后比对和服务器公钥的结果,一致就是认证过的。

HTTS加密过程 RSA

HTTPS 连接建立过程

基于 RSA 算法的 HTTPS 存在「前向安全」的问题:如果服务端的私钥泄漏了,过去被第三方截获的所有 TLS 通讯密文都会被破解。

数字证书签发和验证流程

img

证书链的作用:这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。

HTTP/1.1、HTTP/2、HTTP/3 演变

HTTP/1 ~ HTTP/3

HTTP/1.1 相比 HTTP/1.0 性能上的改进:

  • 使用长连接的方式改善了 HTTP/1.0 短连接造成的性能开销。
  • 支持管道(pipeline)网络传输,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。

但 HTTP/1.1 还是有性能瓶颈以及后面跟着HTTP2的改进:

  • 请求 / 响应头部(Header)未经压缩就发送,首部信息越多延迟越大。只能压缩 Body 的部分;(二进制传输,200从3字节变1个)
  • 发送冗长的首部。每次互相发送相同的首部造成的浪费较多; (HPACK算法,压缩头,维护头信息表,只发送索引号即可)
  • 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端一直请求不到数据,也就是队头阻塞;(并发传输,一个TCP可以有多个Stream 根据独特的StreamID可以并发交错的发送数据)
  • 没有请求优先级控制;
  • 请求只能从客户端开始,服务器只能被动响应。(服务器推送,StreamID为偶数的是服务器创建的Stream流,当用户请求html的时候,服务器自动的在返回的流里开始推css和js这种)

HTTP2的缺陷:
“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层:
TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。
一旦发生了丢包现象,就会触发 TCP 的重传机制,这样在一个 TCP 连接中的所有的 HTTP 请求都必须等待这个丢了的包被重传回来
即Stream之间是相互依赖的。

HTTP/3 做了哪些优化?

基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠性传输。

QUIC 有以下 3 个特点。

  • 无队头阻塞 QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。
  • 更快的连接建立 QUIC 使用的是 TLS/1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商
  • 连接迁移 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己

Mysql

SQL执行过程

查询语句执行流程

索引

创建表时聚簇索引的选择

InnoDB 存储引擎会根据不同的场景选择不同的列作为索引:

  • 如果有主键,默认会使用主键作为聚簇索引的索引键(key);
  • 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键(key);
  • 在上面两个都没有的情况下,InnoDB 将自动生成一个隐式自增 id 列作为聚簇索引的索引键(key);

其它索引都属于辅助索引(Secondary Index),也被称为二级索引或非聚簇索引。创建的主键索引和二级索引默认使用的是 B+Tree 索引

主键索引的 B+Tree 和二级索引的 B+Tree 区别如下

  • 主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里;
  • 二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。

B+树 前几层都是索引范围,最下面一层放数据,且用双向链表链接。

回表

(叶子节点应该为双向链表)
回表

select id from product where product_no = '0002';

覆盖索引

这种在二级索引的 B+Tree 就能查询到结果的过程就叫作「覆盖索引」,也就是只需要查一个 B+Tree 就能找到数据

覆盖索引是直接在二级索引里查到了结果

索引下推

我们知道,对于联合索引(a, b),在执行 select * from table where a > 1 and b = 2 语句的时候,只有 a 字段能用到索引,那在联合索引的 B+Tree 找到第一个满足条件的主键值(ID 为 2)后,还需要判断其他条件是否满足(看 b 是否等于 2),那是在联合索引里判断?还是回主键索引去判断呢?

  • 在 MySQL 5.6 之前,只能从 ID2 (主键值)开始一个个回表,到「主键索引」上找出数据行,再对比 b 字段值。
  • 而 MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在联合索引遍历过程中,对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数

当你的查询语句的执行计划里,出现了 Extra 为 Using index condition,那么说明使用了索引下推的优化。

索引下推是通过二级索引过滤了不满足条件的记录,减少回表次数

索引的分类

  • 按「数据结构」分类:B+tree索引、Hash索引、Full-text索引(这个Mysql不支持)
  • 按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)
  • 按「字段特性」分类:主键索引、唯一索引(没有主键时选择它作为聚簇索引)、普通索引、前缀索引(对字符类型字段的前几个字符建立的索引)
  • 按「字段个数」分类:单列索引、联合索引(建立在多列上的索引)

联合索引

联合索引联合索引查询的 B+Tree 是先按 product_no 进行排序,然后再 product_no 相同的情况再按 name 字段排序。

联合索引最左匹配原则

比如,如果创建了一个 (a, b, c) 联合索引,如果查询条件是以下这几种,就可以匹配上联合索引:

  • where a=1;
  • where a=1 and b=2 and c=3;
  • where a=1 and b=2;

需要注意的是,因为有查询优化器,所以 a 字段在 where 子句的顺序并不重要。

但是,如果查询条件是以下这几种,因为不符合最左匹配原则,所以就无法匹配上联合索引,联合索引就会失效:

  • where b=2;
  • where c=3;
  • where b=2 and c=3;

上面这些查询条件之所以会失效,是因为(a, b, c) 联合索引,是先按 a 排序,在 a 相同的情况再按 b 排序,在 b 相同的情况再按 c 排序。所以,b 和 c 是全局无序,局部相对有序的,这样在没有遵循最左匹配原则的情况下,是无法利用到索引的。

联合索引范围查询

  1. Q1: select * from t_table where a > 1 and b = 2,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?
    在符合 a > 1 条件的二级索引记录的范围里,b 字段的值是无序的
    因此,Q1 这条查询语句只有 a 字段用到了联合索引进行索引查询,而 b 字段并没有使用到联合索引
    img

  2. Q2: select * from t_table where a >= 1 and b = 2,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?
    唯一的区别就是 a 字段的查询条件「大于等于」但是对于符合 a = 1 的二级索引记录的范围里,b 字段的值是「有序」的。从符合 a = 1 and b = 2 条件的第一条记录开始扫描,而不需要从第一个 a 字段值为 1 的记录开始扫描
    所以,Q2 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询
    img

  3. Q3: SELECT * FROM t_table WHERE a BETWEEN 2 AND 8 AND b = 2,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?
    MySQL 中,BETWEEN 包含了 value1 和 value2 边界值,类似于 >= and =<
    因此 Q3 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询

  4. Q4: SELECT * FROM t_user WHERE name like 'j%' and age = 22,联合索引(name, age)哪一个字段用到了联合索引的 B+Tree?
    img

    对于符合 name = j 的二级索引记录的范围里,age字段的值是「有序」的
    所以,Q4 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询

    也可以在执行计划中的 key_len 知道这一点。本次例子中:

    • name 字段的类型是 varchar(30) 且不为 NULL,数据库表使用了 utf8mb4 字符集,一个字符集为 utf8mb4 的字符是 4 个字节,因此 name 字段的实际数据最多占用的存储空间长度是 120 字节(30 x 4),然后因为 name 是变长类型的字段,需要再加 2 字节(用于存储该字段实际数据的长度值),也就是 name 的 key_len 为 122。
    • age 字段的类型是 int 且不为 NULL,key_len 为 4。
      img

综上所示,联合索引的最左匹配原则,在遇到范围查询(如 >、<)的时候,就会停止匹配,也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引。注意,对于 >=、<=、BETWEEN、like 前缀匹配的范围查询,并不会停止匹配

优化索引

  1. 前缀索引优化
    使用某个字段中字符串的前几个字符建立索引,减小索引字段大小

    局限性:

    • order by 就无法使用前缀索引;
    • 无法把前缀索引用作覆盖索引;
  2. 覆盖索引优化
    建立一个联合索引,即「商品ID、名称、价格」作为一个联合索引。如果索引中存在这些数据,查询将不会再次检索主键索引,从而避免回表。
    使用覆盖索引的好处就是,不需要查询出包含整行记录的所有信息,也就减少了大量的 I/O 操作

  3. 主键索引最好是自增的

    • 如果我们使用自增主键插入一条新记录,都是追加操作,不需要重新移动数据,因此这种插入数据的方法效率非常高
    • 如果我们使用非自增主键,需要从一个页面复制数据到另外一个页面,我们通常将这种情况称为页分裂页分裂还有可能会造成大量的内存碎片,导致索引结构不紧凑,从而影响查询效率
  4. 索引最好设置为 NOT NULL

    • 索引列存在 NULL 就会导致优化器在做索引选择的时候更加复杂。比如进行索引统计时,count 会省略值为NULL 的行。
    • NULL 值是一个没意义的值,但是它会占用物理空间。如果表中存在允许为 NULL 的字段,那么行格式 (opens new window)至少会用 1 字节空间存储 NULL 值列表
  5. 防止索引失效

    1. 当我们使用左或者左右模糊匹配的时候,也就是 like %xx 或者 like %xx%这两种方式都会造成索引失效;
    2. 当我们在查询条件中对索引列使用函数,就会导致索引失效。
    3. 当我们在查询条件中对索引列进行表达式计算,也是无法走索引的。
    4. MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。如果字符串是索引列,而条件语句中的输入参数是数字的话,那么索引列会发生隐式类型转换,由于隐式类型转换是通过 CAST 函数实现的,等同于对索引列使用了函数,所以就会导致索引失效。
    5. 联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。
    6. 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。

执行计划的参数解释

img

  • possible_keys 字段表示可能用到的索引;
  • key 字段表示实际用的索引,如果这一项为 NULL,说明没有使用索引;
  • key_len 表示索引的长度;
  • rows 表示扫描的数据行数。
  • type 表示数据扫描类型,我们需要重点看这个。

type 字段就是描述了找到所需数据时使用的扫描方式是什么,常见扫描类型的执行效率从低到高的顺序为

  • All(全表扫描);
  • index(全索引扫描);
  • range(索引范围扫描);
  • ref(非唯一索引扫描);
  • eq_ref(唯一索引扫描);
  • const(结果只有一条的主键或唯一索引扫描)。

除了关注 type,我们也要关注 extra 显示的结果

  • Using filesort :当查询语句中包含 group by 操作,而且无法利用索引完成排序操作的时候, 这时不得不选择相应的排序算法进行,甚至可能会通过文件排序,效率是很低的,所以要避免这种问题的出现。
  • Using temporary:使了用临时表保存中间结果,MySQL 在对查询结果排序时使用临时表,常见于排序 order by 和分组查询 group by。效率低
  • Using index:所需数据只需在索引即可全部获得,不须要再到表中取数据,也就是使用了覆盖索引,避免了回表操作,效率不错。

索引区分度

实际开发工作中建立联合索引时,要把区分度大的字段排在前面,这样区分度大的字段越有可能被更多的 SQL 使用到
区分度计算公式

比如,性别的区分度就很小,不适合建立索引或不适合排在联合索引列的靠前的位置,而 UUID 这类字段就比较适合做索引或排在联合索引列的靠前的位置。

MySQL 还有一个查询优化器,查询优化器发现某个值出现在表的数据行中的百分比(惯用的百分比界线是"30%")很高的时候,它一般会忽略索引,进行全表扫描。

为什么 采用 B+ 树作为索引?

设计一个适合 MySQL 索引的数据结构,至少满足以下要求:

  • 能在尽可能少的磁盘的 I/O 操作中完成查询工作;
  • 要能高效地查询某一个记录,也要能高效地执行范围查找;
  1. 二分查找
    优点:二分查找法每次都把查询的范围减半,这样时间复杂度就降到了 O(logn)。

    缺点:

    • 每次查找都需要不断计算中间位置。
    • 插入新元素需要将这个元素之后的所有元素后移一位。灾难
  2. 二叉查找树
    图片
    二叉查找树的特点是一个节点的左子树的所有节点都小于这个节点,右子树的所有节点都大于这个节点
    优点:二叉查找树解决了连续结构插入新元素开销很大的问题,同时又保持着天然的二分结构。
    缺点:

    • 存在极端情况:当每次插入的元素都是二叉查找树中最大的元素,二叉查找树就会退化成了一条链表,查找数据的时间复杂度变成了 O(n)
    • 不能范围查询
  3. 平衡二叉查找树(AVL 树)
    在二叉查找树的基础上增加了一些条件约束:每个节点的左子树和右子树的高度差不能超过 1
    不管平衡二叉查找树还是红黑树,都会随着插入的元素增多,而导致树的高度变高,这就意味着磁盘 I/O 操作次数多,会影响整体数据查询的效率
    根本原因是因为它们都是二叉树,也就是每个节点只能保存 2 个子节点 ,如果我们把二叉树改成 M 叉树(M>2)呢?

  4. B 树
    它不再限制一个节点就只能有 2 个子节点,而是允许 M 个子节点 (M>2),从而降低树的高度
    优点:如果同样的节点数量在平衡二叉树的场景下,树的高度就会很高,意味着磁盘 I/O 操作会更多。所以,B 树在数据查询中比平衡二叉树效率要高。
    缺点:

    • 每个节点都包含数据(索引+记录),而用户的记录数据的大小很有可能远远超过了索引数据,这就需要花费更多的磁盘 I/O 操作次数来读到「有用的索引数据」
    • 使用 B 树来做范围查询的话,需要使用中序遍历,这会涉及多个节点的磁盘 I/O 问题
  5. B+树

    图片B+ B+树与 B 树差异的点,主要是以下这几点:

    • 叶子节点(最底部的节点)才会存放实际数据(索引+记录),非叶子节点只会存放索引;
    • 所有索引都会在叶子节点出现,叶子节点之间构成一个有序链表;(InnoDB中实现是双向链表)
    • 非叶子节点的索引也会同时存在在子节点中,并且是在子节点中所有索引的最大(或最小)(根节点1,10,19在子节点中再次出现)。
    • 非叶子节点中有多少个子节点,就有多少个索引;

    B+树与 B 树性能比较

    1. 单点查询
      B树的查询波动会比较大,因为每个节点即存索引又存记录
      B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O次数会更少

    2. 插入和删除

      B+ 树的插入也是一样,有冗余节点,插入可能存在节点的分裂(如果节点饱和),但是最多只涉及树的一条路径。而且 B+ 树会自动平衡,不需要像更多复杂的算法,类似红黑树的旋转操作等。

      因此,B+ 树的插入和删除效率更高

    3. 范围查询

      B+ 树所有叶子节点间还有一个链表进行连接,这种设计对范围查找非常有帮助
      存在大量范围检索的场景,适合使用 B+树,比如数据库。而对于大量的单个索引查询的场景,可以考虑 B 树,比如 nosql 的MongoDB

    MySQL的B+树
    图片

    • B+ 树的叶子节点之间是用「双向链表」进行连接,这样的好处是既能向右遍历,也能向左遍历。
    • InnoDB 的数据是按「数据页」为单位来读写的,默认数据页大小为 16 KB。每个数据页之间通过双向链表的形式组织起来,物理上不连续,但是逻辑上连续。

事务

保证转账业务里的所有数据库的操作是不可分割的,要么全部执行成功 ,要么全部失败,不允许出现中间状态的数据。

事务的特性

ACID:

  • 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样,就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品在商家手中,消费者的钱也没花出去。通过 undo log(回滚日志) 来保证的;
  • 一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。比如,用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元,用户 A 给用户 B 转账 200 元,分为两个步骤,从 A 的账户扣除 200 元和对 B 的账户增加 200 元。一致性就是要求上述步骤操作后,最后的结果是用户 A 还有 600 元,用户 B 有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200 元,但用户 B 未增加的情况(该情况,用户 A 和 B 均为 600 元,总共 1200 元)。通过持久性+原子性+隔离性来保证;
  • 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。通过 MVCC(多版本并发控制) 或锁机制来保证的;
  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。通过 redo log (重做日志)来保证的

并行事务会引发什么问题?

脏读

一个事务「读到」了另一个「未提交事务修改过的数据」

因为回滚导致

图片

在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读

不可重复读

在一个事务内多次读取同一个数据,出现前后两次读到的数据不一样的情况

重点是查询的数据不一致了

图片

幻读

在一个事务内多次查询某个符合查询条件的「记录数量」,出现前后两次查询到的记录数量不一样的情况

重点是数量不一致了

图片

严重程度排序

图片

事务的隔离级别有哪些?

  • 读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到;

  • 读提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到;
    在「每个读取语句执行前」都会重新生成一个 Read View

  • 可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别很大程度上避免幻读现象
    「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View

    解决方式有两种

    1. 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
    2. 针对当前读(select ... for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
  • 串行化(serializable );会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

隔离水平高低排序如下:

图片

图片

Read View 和 MVCC

ReadView定义

img

聚簇索引记录中的两个隐藏列

图片

  • trx_id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里
  • roll_pointer,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。

MVCC定义

img

  • 如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 已经提交的事务生成的,所以该版本的记录对当前事务可见

  • 如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 才启动的事务生成的,所以该版本的记录对当前事务不可见

  • 如果记录的 trx_id 值在 Read View 的min_trx_id和max_trx_id

    之间,需要判断 trx_id 是否在 m_ids 列表中:

    • 如果记录的 trx_id m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见
    • 如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见

这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。

主要是通过MVCC即版本链可以知道该事务能否看到当前记录

幻读触发情况

如何解决的?

快照读靠着MVCC解决,当前读靠着间隙锁

MVCC就是版本控制解决,当前度读:

img

事务 A 执行了这面这条锁定读语句后,就在对表中的记录加上 id 范围为 (2, +∞] 的 next-key lock(next-key lock 是间隙锁+记录锁的组合)

然后,事务 B 在执行插入语句的时候,判断到插入的位置被事务 A 加了 next-key lock,于是事物 B 会生成一个插入意向锁,同时进入等待状态,直到事务 A 提交了事务。这就避免了由于事务 B 插入新记录而导致事务 A 发生幻读的现象。

如何触发?

  1. img

    直接更新别人提交的事务的数据,这时MVCC上的id链会更新成包含事务A的范围,因此产生了幻读。
    MySQL Innodb 中的 MVCC 并不能完全避免幻读现象

  2. 先快照读,然后当前读

    • T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。
    • T2 时刻:事务 B 往插入一个 id= 200 的记录并提交;
    • T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。

锁篇

img

全局锁

定义及命令

flush tables with read lock

执行后,整个数据库就处于只读状态了

释放全局锁,则要执行这条命令:

unlock tables

应用场景

全库逻辑备份,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。

有可能出现这样的顺序:

  1. 先备份了用户表的数据;
  2. 然后有用户发起了购买商品的操作;
  3. 接着再备份商品表的数据。

也就是在备份用户表和商品表之间,有用户购买了商品。用户钱没少,而库存少了

缺点

意味着整个数据库都是只读状态。会造成业务停滞。

避免备份影响业务

备份数据库的工具是 mysqldump,在使用 mysqldump 时加上 –single-transaction 参数的时候,就会在备份数据库之前先开启事务。这种方法只适用于支持「可重复读隔离级别的事务」的存储引擎。

InnoDB 存储引擎默认的事务隔离级别正是可重复读,因此可以采用这种方式来备份数据库。

但是,对于 MyISAM 这种不支持事务的引擎,在备份数据库时就要使用全局锁的方法。

表级锁

MySQL 里面表级别的锁有这几种:

  • 表锁;
  • 元数据锁(MDL);
  • 意向锁;
  • AUTO-INC 锁;

表锁

想对学生表(t_student)加表锁,可以使用下面的命令:

//表级别的共享锁,也就是读锁;
lock tables t_student read;

//表级别的独占锁,也就是写锁;
lock tables t_stuent write;

需要注意的是,表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作

释放当前会话的所有表锁:

unlock tables

表锁的颗粒度太大,会影响并发性能,InnoDB 牛逼的地方在于实现了颗粒度更细的行级锁

元数据锁

元数据锁(MDL):MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更

不需要显示的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL:

  • 对一张表进行 CRUD 操作时,加的是 MDL 读锁
  • 对一张表做结构变更操作的时候,加的是 MDL 写锁

阻塞问题

MDL 是在事务提交后才会释放,这意味着事务执行期间,MDL 是一直持有的

如果有长事务一直没提交,那么对表结构做改变的时候可以会出现阻塞问题,下面是可能步骤:

  1. 首先,线程 A 先启用了事务(但是一直不提交),然后执行一条 select 语句,此时就先对该表加上 MDL 读锁;
  2. 接着,线程 C 修改了表字段,此时由于线程 A 的事务并没有提交,也就是 MDL 读锁还在占用着,这时线程 C 就无法申请到 MDL 写锁,就会被阻塞
  3. 那么接下来的线程D、E、F的读请求(select语句)都无法执行,因为在队列中写锁获取优先级高于读锁,会有大量的线程被阻塞住,这时数据库的线程很快就会爆满了。

解决方式

在对表结构变更前,先要看看数据库中的长事务,是否有事务已经对表加上了 MDL 读锁,如果可以考虑 kill 掉这个长事务,然后再做表结构的变更。

意向锁

意向锁的目的是为了快速判断表里是否有记录被加锁

  • 在对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
  • 在对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;

意向共享锁和意向独占锁是表级锁不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(lock tables ... read)和独占表锁(lock tables ... write)发生冲突。

AUTO-INC 锁

定义

AUTO-INC 锁是特殊的表锁机制,锁不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放

在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。

插入性能问题

AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。

选择锁的模式

InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择用 AUTO-INC 锁,还是轻量级的锁

  • 当 innodb_autoinc_lock_mode = 0,就采用 AUTO-INC 锁,语句执行结束后才释放锁;
  • 当 innodb_autoinc_lock_mode = 2,就采用轻量级锁,申请自增主键后就释放锁,并不需要等语句执行后才释放。
  • 当 innodb_autoinc_lock_mode = 1:
    • 普通 insert 语句,自增锁在申请之后就马上释放;
    • 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;

数据不一致的问题

innodb_autoinc_lock_mode = 2 是性能最高的方式,但是当搭配 binlog 的日志格式是 statement 一起使用的时候,在「主从复制的场景」中会发生数据不一致的问题

img

  1. session B 先插入了两个记录,(1,1,1)、(2,2,2);
  2. 然后,session A 来申请自增 id 得到 id=3,插入了(3,5,5);
  3. 之后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。

session B 的 insert 语句,生成的 id 不连续,因此主库id是不连续的

从库是按「顺序」执行语句的,只有当执行完一条 SQL 语句后,才会执行下一条 SQL,从库生成的结果里面,id 都是连续的。

主从库就发生了数据不一致

解决数据不一致性

binlog 日志格式要设置为 row,这样在 binlog 里面记录的是主库分配的自增值,到备库执行的时候,主库的自增值是什么,从库的自增值就是什么

所以,当 innodb_autoinc_lock_mode = 2 时,并且 binlog_format = row,既能提升并发性,又不会出现数据一致性问题

行级锁

InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁。

普通的 select 语句是不会对记录加锁的,因为它属于快照读。如果要在查询时对记录加行锁,可以使用下面这两个方式,这种查询会加锁的语句称为锁定读

//对读取的记录加共享锁
select ... lock in share mode;

//对读取的记录加独占锁
select ... for update;

当事务提交了,锁就会被释放

共享锁(S锁)满足读读共享,读写互斥。独占锁(X锁)满足写写互斥、读写互斥。

img

行级锁的类型主要有三类:

  • Record Lock,记录锁,也就是仅仅把一条记录锁上,有X和S锁
  • Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身,只是不让插入
  • Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
  • 插入意向锁 插入的时候发现这个区间被Gap Lock锁住了,需要生成一个等待状态的插入意向锁

Record Lock

Record Lock 称为记录锁,锁住的是一条记录。而且记录锁是有 S 锁和 X 锁之分的。

举个例子,当一个事务执行了下面这条语句:

mysql > begin;
mysql > select * from t_test where id = 1 for update;

就是对 t_test 表中主键 id 为 1 的这条记录加上 X 型的记录锁,这样其他事务就无法对这条记录进行修改了。

img

当事务执行 commit 后,事务过程中生成的锁都会被释放

Gap Lock

Gap Lock 称为间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。

假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。

img

间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的

Next-Key Lock

Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。

假设,表中有一个范围 id 为(3,5] 的 next-key lock,那么其他事务即不能插入 id = 4 记录,也不能修改 id = 5 这条记录。

img

所以,next-key lock 即能保护该记录,又能阻止其他事务将新纪录插入到被保护记录前面的间隙中。

next-key lock 是包含间隙锁+记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的

比如,一个事务持有了范围为 (1, 10] 的 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,就会被阻塞

虽然相同范围的间隙锁是多个事务相互兼容的,但对于记录锁,我们是要考虑 X 型与 S 型关系,X 型的记录锁与 X 型的记录锁是冲突的。

插入意向锁

一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。

如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态。

举个例子,假设事务 A 已经对表加了一个范围 id 为(3,5)间隙锁。

img

当事务 A 还没提交的时候,事务 B 向该表插入一条 id = 4 的新记录,这时会判断到插入的位置已经被事务 A 加了间隙锁,于是事物 B 会生成一个插入意向锁,然后将锁的状态设置为等待状态PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁),此时事务 B 就会发生阻塞,直到事务 A 提交了事务。

插入意向锁名字虽然有意向锁,但是它并不是意向锁,它是一种特殊的间隙锁,属于行级别锁

如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。

插入意向锁与间隙锁的另一个非常重要的差别是:尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)。

MySQL 是怎么加行级锁的?

设计模式

原型模式

原型模式:使用原型实例指定待创建对象的类型,并且通过复制这个原型来创建新的对象!

image-20230331190445911

原型模式分三个角色,抽象原型类,具体原型类,客户类。

抽象原型类(prototype):它是声明克隆方法的接口,是所有具体原型类的公共父类,它可以是接口,抽象类甚至是一个具体的实现类。

具体原型类(concretePrototype):它实现了抽象原型类中声明的克隆方法,在克隆方法中返回一个自己的克隆对象。

客户类(Client):在客户类中,使用原型对象只需要通过工厂方式创建或者直接NEW(实例化一个)原型对象,然后通过原型对象的克隆方法就能获得多个相同的对象。由于客户端是针对抽象原型对象编程的所以还可以可以很方便的换成不同类型的原型对象!

在原型模式中有两个概念我们需要了解一下,就是浅克隆和深克隆的概念。按照我的理解,浅克隆只是复制了基础属性,列如八大基本类型以及String类型,然而引用类型实际上没有复制,只是将对应的引用给复制了地址。

附件类:

package prototypePattern;
/**
 * 
* <p>Title: Attachment</p>  
* <p>Description:附件类 </p>  
* @author HAND_WEILI  
* @date 2018年9月2日
 */
 
public class Attachment {
 private String name;	//附件名
 
public String getName() {
	return name;
}
 
public void setName(String name) {
	this.name = name;
}
 public void download() {
	 System.out.println("下载附件"+name);
 }
}

周报类:关键点在于,实现cloneable接口以及用object的clone方法。

package prototypePattern;
/**
 * 
* <p>Title: WeeklyLog</p>  
* <p>Description:周报类充当具体的原型类 </p>  
* @author HAND_WEILI  
* @date 2018年9月2日
 */
public class WeeklyLog implements Cloneable{

	private Attachment attachment;
	private String date;
	private String name;
	private String content;
	public Attachment getAttachment() {
		return attachment;
	}
	public void setAttachment(Attachment attachment) {
		this.attachment = attachment;
	}
	public String getDate() {
		return date;
	}
	public void setDate(String date) {
		this.date = date;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getContent() {
		return content;
	}
	public void setContent(String content) {
		this.content = content;
	}
	//实现clone()方法实现浅克隆
	public WeeklyLog clone() {
		//需要实现cloneable的接口,直接继承object就好,它里面自带一个clone方法!
		Object obj = null;
		try {
			obj = super.clone();
			return (WeeklyLog)obj;
		} catch (CloneNotSupportedException e) {
			// TODO Auto-generated catch block
			System.out.println("不支持克隆方法!");
			return null;
		}
		
		
	}
}

客户端:

package prototypePattern;                                                                                  
                                                                                                           
public class Client {                                                                                      
    //测试类,客户端                                                                                              
	public static void main(String[] args) {                                                               
		WeeklyLog log_1,log_2;                                                                             
		log_1 = new WeeklyLog();	//创建原型对象                                                               
		Attachment attachment = new Attachment(); //创建附件对象                                                 
		log_1.setAttachment(attachment);	//将附件添加到周报种去                                                   
		log_2=log_1.clone();	//克隆周报                                                                     
		System.out.println("周报是否相同"+(log_1==log_2));                                                       
		System.out.println("附件是否相同"+(log_1.getAttachment()==log_2.getAttachment()));                       
	}                                                                                                      
}                                                                                                          

浅克隆的结果肯定是周报不同但是附件相同。

深克隆

在JAVA怎么做到深度克隆了?第一种方式是通过再次递归克隆子引用一直到null为止,这种很麻烦不推荐,第二种就是序列化以及反序列化。

通过序列化(Serialization)等方式来进行深度克隆。这个时候要聊一聊什么是序列化了。简单的讲就是序列化就将对象写到流的一个过程,写到流里面去(就是字节流)就等于复制了对象,但是原来的对象并没有动,只是复制将类型通过流的方式进行读取,然后写到另个内存地址中去!

序列化的实现:

首先将附件类修改。将其序列化

package prototypePattern;
 
import java.io.Serializable;
 
/**
* <p>Description:附件类 </p>  
 */
 
public class Attachment_2 implements Serializable {//这里实现了序列化接口Serializable
 private String name;	//附件名
 
public String getName() {
	return name;
}
 
public void setName(String name) {
	this.name = name;
}
 public void download() {
	 System.out.println("下载附件"+name);
 }
}

周报类

package prototypePattern;
 
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
 
/**
 * 
* <p>Title: WeeklyLog</p>  
* <p>Description:周报类充当具体的原型类 </p>  
* @author HAND_WEILI  
* @date 2018年9月2日
 */
public class WeeklyLog_2 implements Serializable{
	
	private Attachment_2 attachment;
	private String date;
	private String name;
	private String content;
	public Attachment_2 getAttachment() {
		return attachment;
	}
	public void setAttachment(Attachment_2 attachment) {
		this.attachment = attachment;
	}
	public String getDate() {
		return date;
	}
	public void setDate(String date) {
		this.date = date;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getContent() {
		return content;
	}
	public void setContent(String content) {
		this.content = content;
	}
	//通过序列化进行深克隆
	public WeeklyLog_2 deepclone() throws Exception {
		//将对象写入流中,
		ByteArrayOutputStream bao = new ByteArrayOutputStream();
		ObjectOutputStream oos = new ObjectOutputStream(bao);
		oos.writeObject(this);
		//将对象取出来
		ByteArrayInputStream bi = new ByteArrayInputStream(bao.toByteArray());
		ObjectInputStream ois = new ObjectInputStream(bi);
		return (WeeklyLog_2)ois.readObject();
	}
}

客户类

package prototypePattern;                                                                                  
                                                                                                           
public class Client_2 {                                                                                      
    //测试类,客户端                                                                                              
	public static void main(String[] args) {                                                               
		WeeklyLog_2 log_1,log_2=null;                                                                             
		log_1 = new WeeklyLog_2();	//创建原型对象                                                               
		Attachment_2 attachment = new Attachment_2(); //创建附件对象                                                 
		log_1.setAttachment(attachment);	//将附件添加到周报种去                                                   
		try {
			log_2=log_1.deepclone();
		} catch (Exception e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}	//克隆周报                                                                     
		System.out.println("周报对象是否相同"+(log_1==log_2));                                                     
		System.out.println("附件对象是否相同"+(log_1.getAttachment()==log_2.getAttachment()));             
	}                                                                                                      
}                                                                                                          

原型模式的优缺点
原型模式作为一种快速创建大量相同或相似的对象方式,在软件开发种的应用较为广泛,很多软件提供的CTRL+C和CTRL+V操作的就是原型模式的典型应用!

优点

当创建的对象实例较为复杂的时候,使用原型模式可以简化对象的创建过程!
扩展性好,由于写原型模式的时候使用了抽象原型类,在客户端进行编程的时候可以将具体的原型类通过配置进行读取。
可以使用深度克隆来保存对象的状态,使用原型模式进行复制。当你需要恢复到某一时刻就直接跳到。比如我们的idea种就有历史版本,或则SVN中也有这样的操作。非常好用!

缺点

需要为每一个类配备一个克隆方法,而且该克隆方法位于一个类的里面,当对已有的类经行改造时需要修改源代码,违背了开闭原则。
在实现深克隆的时需要编写较为复杂的代码,而且当对象之间存在多重嵌套引用的时候,为了实现深克隆,每一层对象对应的类都必须支持深克隆,实现相对麻烦。

原型模式适用场景

在以下情况可以考虑使用。

1创建对象成本比较大,比如初始化要很长时间的,占用太多CPU的,新对象可以通过复制已有的对象获得的,如果是相似的对象,则可以对其成员变量稍作修改。

2系统要保存对象状态的,而对象的状态改变很小。

3需要避免使用分层次的工厂类来创建分层次的对象,并且类的对象就只用一个或很少的组合状态!

参考文章:JAVA原型模式

高并发

高可用

Netty

什么是netty--通俗易懂

分布式锁

代码实现:https://gitee.com/dong-liang-li/DistributedLock.git

压测

Apache JMeter

配置线程情况,右键Test Plan->Add->Threads(Users)->Thread Group:线程数100,间隔时间1s,每个线程请求50次

image-20230411152444317

配置访问接口,右键Thread Group->Add->Sampler->Http Request

image-20230411152828560

配置报表,右键Thread Group->Add->Listener->Aggregate Report

JVM锁

商品超卖

超卖现象指的是超出自身设置的售出数。

当出现商品的销售量实际的库存量的现象,成为“超卖”现象。

假设数据库中是5000,那么100个线程跑50次,最极限的情况就是100个线程同时更新,那么只把5000-1,即更新为4999,50次循环都是这个情况的话,那么最惨会只减到4950.

解决方式

给函数里面加锁

  1. synchronized
  2. reentrantlock

加锁失败的情况

多例模式

当Spring使用多例模式时,每个线程进入后获取的不是同一把锁,自然会失败。

只用一个sql语句更新就能解决这个个问题,因为update等更新自带悲观锁。

image-20230406203129936

只加prototype不行,需要将代理模式修改为TARGET_CLASS,此时是CGLib的动态代理。
如果是原生的Spring,是JDK代理;但是SpringBoot2.X后,是CGLib代理。

事务RR、RC

image-20230406204715447

线程A 线程B
begin;开启事务 begin;开启事务
获取锁成功
查询库存:21
扣减库存:20
释放锁
获取锁成功
查询库存:21
提交事务

image-20230406210521463

此时A扣完后是20,而B扣完后提交事务也是20,少扣了一次,即超卖了

原因:数据库隔离级别默认为RR或RC,即可重复读和读已提交。因此A未提交事务时,B只能读到A修改之前的数据。
根本原因还是因为获取锁和开启事务的过程不是原子的

集群部署

  1. 复制一份项目 用--server.port=10086改一下端口号

  2. 使用nginx,进行配置。这里是用到了反向代理和负载均衡,来实现集群。
    image-20230410150555653
    双击nginx.exe启动后,任务管理器里有两个nginx进程说明启动成功 一个主线程 一个子线程
    image-20230410150821301

  3. 修改配置文件后重新加载的命令:

    nginx -s reload
    

此时压测会出现问题,原因是两台服务器导致的而不是nginx

超卖现象:即使加了锁依旧会超卖,原因是两个服务都是用两条SQL语句进行更新,如果只用一个sql语句更新就能解决这个个问题,因为update等更新自带悲观锁。

MySql锁演示

JVM锁

同上

一个SQL语句

优点

只用一个sql语句更新就能解决上述三个问题,因为update等更新自带悲观锁,触发当前读操作。

缺点

  1. 锁范围不好掌握 毕竟是只能通过间隙锁来控制 表级锁还是行级锁? 默认是表级锁 同一个表的更新和插入都被禁止,正常应该是行级锁
  2. 同一个商品有多条库存
  3. 无法记录库存前后的变化状态 也就是更新过程不好打日志

悲观锁: select ... for update

MySql中使用悲观锁行级锁select ... for update

条件

  1. 锁的查询或者更新条件必须是索引字段
  2. 查询或者更新条件必须是具体值 类似于like "%01" 左模糊匹配导致索引失效,会升级成表锁

使用示例

image-20230411202313124

image-20230411202600465

事务与锁

注意这里必须加上事务,不然和加锁失败的事务RR、RC情况一样,会因为A查询后的行锁释放后,另一个线程B去查询结果,B查询行锁释放后,A修改结果,而B再次修改结果,这会导致超卖即减库存覆盖,多个线程只减了一个真正库存。

加上事务之后,线程A查询完释放行锁后,开始扣库存,扣库存的时候,线程B查询该记录,会被阻塞
即加上事务之后,使用的行锁会继续保留,如果直接释放的话会破坏事务的一致性

存在问题

  1. 性能问题 不如一个SQL语句快,和JVM锁差不多
  2. 死锁问题 加锁顺序要一致
  3. 库存查询操作要统一,select ... for update和select ... 否则会产生幻读 先select再select for update会幻读

死锁问题

线程1开启事务先锁记录1,线程2开启事务锁记录2,然后线程1等待记录2,线程2等待记录1 触发死锁

image-20230412085809316

image-20230412085740851

乐观锁

实现方式:时间戳、版本号、CAS机制

乐观锁随着线程并发提升而吞吐量下降,因为线程越多会更容易自旋

CAS:CompareAndSwap 会有ABA问题,即其他人改成了别的又改回来感知不到,通过版本号或者时间戳解决。
是指对于变量X,有旧值A有新值B,如果X==A就给X换成B,否则就放弃

实现

直接的写法:

image-20230412095146048

压测报错:

image-20230412095504166

image-20230412095508592

第一个错误是因为反复的递归调用会导致栈溢出
解决方式:让该线程sleep20毫秒后再去递归,防止无效的反复递归

第二个错误是因为我们开启了手动事务,然后DML(Data Manipulation Language)操作会自动加悲观锁,且递归加锁,导致一直不释放那个锁。卡住了后续的请求,因此超时报错。
解决方式:去掉手动事务,因为我们通过CAS操作来保证了数据执行的正确性,去掉@Transactional注解后,虽然update还是会加悲观锁,但是粒度变小了,在update失败后会立即释放,而开启手动事务的情况下,加锁后只有事务提交后才会释放。

问题

  1. 高并发情况下,性能极低 浪费CPU资源不停自旋

  2. ABA问题需要额外添加版本号或者时间戳解决 且版本号可能走完一圈又回来。。。

  3. 读写分离情况下导致乐观锁不可靠
    image-20230412101606820

    这里说的是多库的情况,一般是主从库,从主库中插入,从库中读取,但是如果ABA读了从库的,因为有IO的时间,很大概率读到未更新的旧值,因此很难用。

Mysql锁总结

性能: 一个sql > 悲观锁>jvm锁>乐观锁

  • 如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下.
    优先选择: 一个sql如果写并发量较低(多读)
  • 争抢不是很激烈的情况下
    优先选择: 乐观锁
  • 如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试.
    优先选择: mysql悲观锁
  • 不推荐jvm本地锁。

Redis锁演示

这里应该使用StringRedisTemplate,这样序列化的时候还是String,否则序列化为二进制不好在命令行观察

JVM锁

同上

乐观锁

image-20230412113845165

这里watch可以监控一个或者多个变量,multi开启事务,然后进行的操作会进入队列,使用exec后才会依次执行,如果muti期间有其他线程操作了这个变量,那么exec结果为nil,因此可以通过这三个指令实现乐观锁。watch、multi、exec。

watch:可以监控一个或者多个key的值,如果在事务(exec)执行之前,key的值发生变化则取消事务执行
multi: 开启事务
exec:执行事务

实现:

image-20230514152322453

这里需要通过execute方法里面实现匿名内部类才能实现,记得执行失败的时候停一下,不然一直递归调用顶不住,而且连接数可能会不够。

缺点:

性能很差,好像乐观锁都会这样,高并发只能说是一种实现方式,但是性能差的一。

手写分布式锁

跨进程、跨服务、跨服务器

场景:超卖现象、缓存击穿

分布式锁的实现方式:

  1. 基于redis实现
  2. 基于zookeeper/etcd实现
  3. 基于mysql实现

手写分布式锁特征:

独占排他

使用 setnx

防死锁

  • Redis客户端从Redis服务中获取到锁,然后这个机器宕机了,即使重启了这些锁也不会释放。
    解决方式:通过给锁 即key设置过期时间解决
    image-20230519140758522
  • 可重入问题,假设线程1的方法A获取锁,调用了方法B,而B也要锁,而锁被A拿着,直接寄!

原子性问题

  • 获取锁和设置锁过期时间之间
    而设置锁的操作如果不是原子性的,那么先设置锁 立刻宕机,等不到设置锁过期时间就寄了,会有安全隐患
    因此应该使用set来原子的设置过期时间,set里面nx代表不存在就设置,xx代表存在才设置,ex代表秒的过期时间,px代表毫秒级过期时间,ttl代表获取该锁还有多久过期
    image-20230519141444253

  • 判断锁和删除锁之间

    这里会有原子性问题,假如判断完之后,第二个线程开始尝试获取锁,第二个线程设置锁后,被第一个线程误删了
    只有即判断又set的 没有即判断又删除的 因此需要Lua脚本
    image-20230519153947200

防误删

第二个请求获取到锁,第一个请求执行完了,给第二个请求的锁(因为过期获取到锁)给删了,出现了误删的操作

  • 加UUID

  • 通过Lua解决删除时的原子性问题

    if redis.call('get',KEYS[1]) == ARGV[1] 
    then 
    return redis.call('del',KEYS[1]) 
    else 
    return 0 
    end
    
    eval "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock 123
    

    image-20230519163201223

可重入性

  • 讲解Reentrantlock
    可重入锁的加锁流程:ReentrantLock.lock->NonfairSync.lock()[检查第一次]->NonfairSync.acquire(1)->AQS.tryAcquire(1)->ReentrantLock.nonfairTryAcquire(1)[检查第二次->如果已经state不为0,判断当前线程如果是执行线程那么state+1]->如果当前线程不是执行线程加锁失败,进入队列

    可重入锁的解锁流程:ReentrantLock.unlock->AQS.release(1)->Sync.tryRelease(1)[判断当前线程是否是持有线程->state-1->如果state为0返回true,不为0返回false]

  • 通过hash(hset "lock " uuid 1)+lua脚本可以实现分布式可重入锁仿照ReentrantLock

    • 加锁:

      1. 判断当前锁是否存在(exists),如果锁不存在,则直接获取锁:hset key field value(可以通过hincrby替换)
      2. 如果锁存在则判断是否是自己的锁(hexists),如果是自己的则重入:hincrby key field increment
      3. (AQS用的是FIFO的CLH等待队列),这里用重试:递归/循环
      eval "if redis.call('exists',KEYS[1])==0 or redis.call('hexists',KEYS[1],ARGV[1])==1 then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end" 1 lock 123-123  30
      

      key:lock arg:uuid 30(过期时间)

    • 解锁:

      1. 如果之前没有锁(hexists) 那么返回nil(恶意释放锁)抛出异常
      2. 如果锁的重入次数-1(hincrby -1)后为0,那么删掉锁(del) 返回1
      3. 如果-1后不为0,那么返回0
      eval "if redis.call('hexists',KEYS[1],ARGV[1])==0 then return nil elseif redis.call('hincrby',KEYS[1],ARGV[1],-1)==0 then return redis.call('del',KEYS[1]) else return 0 end" 1 lock 123-123
      

自动续期

由于设置了过期时间,有可能会导致第一个请求获取了锁,而后第一个请求还没执行完过期了(自动续期)

需要搞一个子线程while循环每过三分之一时间起来检查自己线程还是不是执行线程

而Spring、quarlz、xxl-job、elastic-job这几个都不满足条件,因为我们的缓存锁他们无法感知,自己设置的锁毕竟是,因此得用JUC来搞这个线程。

定时任务(线程池不行,无法取消任务只能销毁线程池,所以时间驱动选取Timer定时器)+ lua脚本(判断是否还持有锁 hexists,如果存在则重置过期时间)

eval "if redis.call('hexists',KEYS[1],ARGV[1])==1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end" 1 lock 123-123 30

在上锁之后启动一个子线程Timer来延长期限

private void renewExpire() {
    String script = "if redis.call('hexists',KEYS[1],ARGV[1])==1 " +
        "then " +
        "   return redis.call('expire',KEYS[1],ARGV[2]) " +
        "else " +
        "   return 0 " +
        "end";
    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            boolean flag = redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expireTime));
            if (flag) {
                renewExpire();
            }
        }
    }, this.expireTime * 1000 / 3);
}

小结

锁操作:

  • 加锁:

    1. setnx 独占排他、死锁、不可重入、原子性
    2. set k v ex 30 nx:独占排他、死锁、不可重入
    3. hash+lua脚本:可重入锁
      1. 判断当前锁是否存在(exists),如果锁不存在,则直接获取锁:hset key field value(可以通过hincrby替换)
      2. 如果锁存在则判断是否是自己的锁(hexists),如果是自己的则重入:hincrby key field increment
      3. (AQS用的是FIFO的CLH等待队列),这里用重试:递归/循环
    4. Timer定时器+lua脚本:实现自动续期
  • 解锁:

    1. del:导致误删
    2. 先判断(服务+线程ID)再删除同时保证原子性:lua脚本
    3. hash+lua脚本:可重入
      1. 如果之前没有锁(hexists) 那么返回nil(恶意释放锁)抛出异常
      2. 如果锁的重入次数-1(hincrby -1)后为0,那么删掉锁(del) 返回1
      3. 如果-1后不为0,那么返回0

集群情况下锁机制失效

这个可以通过Redission看门狗机制解决 错了 是通过设置超时时间解决 看门狗是续时间的Timer定时器

  1. 客户端程序10010,从主中获取锁
  2. 从还没来得及同步数据,主挂了
  3. 于是从升级为主
  4. 客户端程序10086就从新主中获取到锁,导致锁机制失效

RedLock算法

  1. 应用程序获取系统当前时间
  2. 应用程序使用相同的kv值依次从多个redis实例中获取锁。如果某一个节点超过给定时间依然没有获取到锁则直接放弃,尽快尝试从下一个健康的redis节点获取锁,以避免被一个宕机了的节点阻塞
  3. 计算获取锁的消耗时间=客户端程序的系统当前时间-step1中的时间。获取锁的消耗时间小于总的锁定时间 (30s)并且半数以上节点获取锁成功,认为获取锁成功
  4. 计算剩余锁定时间 = 总的锁定时间 - step3中的消耗时间
  5. 如果获取锁失败了,对所有的redis节点释放锁。

Redission

Redission底层原理

ReentrantentLock是根据AQS封装的锁,Redission是根据Redis封装的,底层其实也是Lua脚本,不过自动续期之前是用的JUC的Timer,版本升级后是根据Netty的时间轮(HashedWheelTimer)来封装的TimerTask。里面用了CompletationFuture所以效率高。

Redission使用方法

image-20230523215605826

image-20230520223017557

image-20230520222952658

代码实现:见Gitee

image-20230523220714006

image-20230523220836815

Zookeeper分布式锁

zookeeper分布式锁:

  1. 介绍了zk

  2. zk下载及安装

  3. 指令:
    ls
    get /zookeeper
    create /aa "testn
    delete /aa
    set /aa "test1"

  4. znode节点类型:
    永久节点: create /path content
    临时节点: create -e /path content 。只要客户端程序断开链接自动删除
    永久序列化节点: create -s /path content
    临时序列化节点: create -s -e /path content

  5. 节点的事件监听:一次性
    1.节点创建: NodeCreated
    stat -W /xX
    2.节点删除: NodeDeleted
    stat -w /xx
    3.节点数据变化: NodeDataChanged
    get -w /xx

    4.子节点变化
    ls -w /xx

  6. java客户端:官方提供(比较基础) zkclient(封装过优化过 dubbo、kafuka等都用) curator(类似redission 分布式锁直接整好了)

  7. 分布式锁:

    1. 独占排他锁: 通过create /xx 一个节点作为lock 其他线程无法再次创建因此阻塞且自旋 自旋锁会浪费性能
    2. 阻塞锁:临时序列化节点 让最小的那个节点执行,第二小的去监听这个最小的,再后来到的继续监听上一个,公平锁
      1. 所有请求要求获取锁时,给每一个请求创建临时序列化节点
      2. 获取当前节点的前置节点,如果前置节点为空,则获取锁成功,否则监听前置节点
      3. 获取锁成功之后执行业务操作,然后释放当前节点的锁
    3. 可重入:同一线程已经获取锁的情况下,释放当前锁的节点
      1. 在节点内容中记录服务器、线程以及重入信息
      2. ThreadLocal:线程的局部变量,线程私有
  8. 特征总结:

    1. 独占排他互斥使用:节点不重复
    2. 防死锁:客户端程序获取到锁之后服务器立马宕机。临时节点:一旦客户端服务器宕机,链接就会关闭,此时2k心跳检测不到客户端程序,删除该节点
      不可重入: 可重入锁
    3. 防误删: 给每一个请求线程创建一个唯一的序列化节点。
    4. 原子性:创建节点 删除节点 查询及监听 具备原子性
    5. 可重入: ThreadLocal实现 节点数据 ConcurrentHashMap
    6. 自动续期:没有过期时间 也就不需要自动续期
    7. 单点故障: zk一般都是集群部署
    8. zk集群: 偏向于一致性集群
  9. 框架Curator: Netflix贡献给Apache
    Curator-framework: zk的底层做了一些封装。Curator-recipes: 典型应用场景做了一些封装,分布式锁

    image-20230526160235619

    image-20230526162610556

image-20230526163934164

Mysql分布式锁

image-20230526164639959

总结

image-20230526165550689

Redis

数据类型篇

数据结构

左边是 Redis 3.0版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Github 最新的 Redis 代码的(还未发布正式版本)。

img

共有 9 种数据结构:SDS、双向链表、压缩列表、哈希表、跳表、整数集合、quicklist、listpack。

img

键值对数据库如何实现?

img

  • redisDb 结构,表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针;
  • dict 结构,结构体里存放了 2 个哈希表,正常情况下都是用「哈希表1」,「哈希表2」只有在 rehash 的时候才用
  • ditctht 结构,表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个哈希表节点结构(dictEntry)的指针;
  • dictEntry 结构,表示哈希表节点的结构,结构里存放了 void * key 和 void * value 指针, *key 指向的是 String 对象,而 *value 则可以指向 String 对象,也可以指向集合类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象

特别说明下,void * key 和 void * value 指针指向的是 Redis 对象,Redis 中的每个对象都由 redisObject 结构表示,如下图:

img

对象结构里包含的成员变量:

  • type,标识该对象是什么数据类型的对象(String 对象、 List 对象、Hash 对象、Set 对象和 Zset 对象);
  • encoding,标识该对象使用了哪种底层的数据结构
  • ptr,指向底层数据结构的指针

SDS

简单动态字符串(simple dynamic string,SDS) 数据结构来表示字符串

C 语言字符串的缺陷

C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。

img

C 语言标准库中的字符串操作函数就通过判断字符是不是 “\0” 来决定要不要停止操作,如果当前字符不是 “\0” ,说明字符串还没结束。

很明显,C 语言获取字符串长度的时间复杂度是 O(N)(*这是一个可以改进的地方*

除了字符串的末尾之外,字符串里面不能含有 “\0” 字符,否则最先被程序读入的 “\0” 字符将被误认为是字符串结尾,这个限制使得 C 语言的字符串只能保存文本数据,不能保存像图片、音频、视频文化这样的二进制数据(*这也是一个可以改进的地方*)

举个例子,strcat 函数是可以将两个字符串拼接在一起。

//将 src 字符串拼接到 dest 字符串后面
char *strcat(char *dest, const char* src);

C 语言的字符串是不会记录自身的缓冲区大小的,所以 strcat 函数假定程序员在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,(*这是一个可以改进的地方*)。

C 语言的字符串不足之处以及可以改进的地方:

  • 获取字符串长度的时间复杂度为 O(N);
  • 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;
SDS 结构设计

img

结构中的每个成员变量分别介绍下:

  • len( 二进制安全),记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据
  • alloc(不会发生缓冲区溢出),分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题
    • 如果所需的 sds 长度小于 1 MB,那么最后的扩容是按照翻倍扩容来执行的,即 2 倍的newlen
    • 如果所需的 sds 长度超过 1 MB,那么最后的扩容长度应该是 newlen + 1MB
  • flags(节省内存空间),用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间
    除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐
    img
    img
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据

总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。

链表

链表的结构

img

typedef struct list {
    //链表头节点
    listNode *head;
    //链表尾节点
    listNode *tail;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值比较函数
    int (*match)(void *ptr, void *key);
    //链表节点数量
    unsigned long len;
} list;
链表的优势与缺陷

Redis 的链表实现优点如下:

  • listNode 链表节点的结构里带有 prev 和 next 指针,获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表
  • list 结构因为提供了表头指针 head 和表尾节点 tail,所以获取链表的表头节点和表尾节点的时间复杂度只需O(1)
  • list 结构因为提供了链表节点数量 len,所以获取链表中的节点数量的时间复杂度只需O(1)
  • listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值

链表的缺陷也是有的:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
  • 还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。

不过,压缩列表存在性能问题(具体什么问题,下面会说),所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现。

然后在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。

Redis 常见数据类型和应用场景

String

介绍

String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M

img

持久化篇

AOF 持久化

AOF 日志

img

这种保存写操作命令到日志的持久化方式,就是 Redis 里的 AOF(Append Only File) 持久化功能,注意只会记录写操作命令,读操作命令是不会被记录的,因为没意义。

在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:
img

AOF 日志文件其实就是普通的文本,我们可以通过 cat 命令查看里面的内容,不过里面的内容如果不知道一定的规则的话,可能会看不懂。

我这里以「set name xiaolin」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图:
img

*3」表示当前命令有三个部分,每部分都是以「$+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「$3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。

Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处

  1. 避免额外的检查开销。
    如果先执行写操作命令再记录日志的话,只有在该命令执行成功后,才将命令记录到 AOF 日志里,这样就不用额外的检查开销,保证记录在 AOF 日志里的命令都是可执行并且正确的。
  2. 不会阻塞当前写操作命令的执行
    因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。

AOF 持久化功能也不是没有潜在风险:

  1. 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险
  2. 前面说道,由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前写操作命令的执行,但是可能会给「下一个」命令带来阻塞风险

因为将命令写入到日志的这个操作也是在主进程完成的(执行命令也是在主进程),也就是说这两个操作是同步的。
img

认真分析一下,其实这两个风险都有一个共性,都跟「 AOF 日志写回硬盘的时机」有关。

三种写回策略

img

具体说说:

  1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区
  2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
  3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定

redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

  • Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
  • Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

img

这三种策略只是在控制 fsync() 函数的调用时机。

img

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是永不执行 fsync() 函数;

AOF 重写机制

为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

img

重写 AOF 的时候,不直接复用现有的 AOF 文件,而是先写到新的 AOF 文件再覆盖过去。

因为如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染,可能无法用于恢复使用。

AOF 后台重写

Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的,这么做可以达到两个好处:

  • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
  • 子进程带有主进程的数据副本(数据副本怎么产生的后面会说),这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。

img

当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」。

img

写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

有两个阶段会导致阻塞父进程:

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长
  • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长
  • 信号处理函数执行时也会对主进程造成阻塞:
    • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
    • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

如果主进程修改了已经存在 key-value,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?

为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

在这里插入图片描述

RDB 快照

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。

快照怎么用?

Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

RDB 文件的加载工作是在服务器启动时自动执行的,Redis 并没有提供专门用于加载 RDB 文件的命令。

Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:

save 900 1
save 300 10
save 60 10000

别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。

只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:

  • 900 秒之内,对数据库进行了至少 1 次修改;
  • 300 秒之内,对数据库进行了至少 10 次修改;
  • 60 秒之内,对数据库进行了至少 10000 次修改。

这里提一点,Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。

执行快照时,数据能被修改吗?

依旧是写时复制技术(Copy-On-Write, COW)

Redis 在使用 bgsave 快照过程中,如果主线程修改了内存数据,不管是否是共享的内存数据,RDB 快照都无法写入主线程刚修改的数据,因为此时主线程(父进程)的内存数据和子进程的内存数据已经分离了,子进程写入到 RDB 文件的内存数据只能是原本的内存数据。

如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。

写时复制的时候会出现这么个极端的情况。

在 Redis 执行 RDB 持久化期间,刚 fork 时,主进程和子进程共享同一物理内存,但是途中主进程处理了写操作,修改了共享内存,于是当前被修改的数据的物理内存就会被复制一份。

那么极端情况下,如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。

所以,针对写操作多的场景,我们要留意下快照过程中内存的变化,防止内存被占满了。

RDB 和 AOF 合体

这个方法是在 Redis 4.0 提出的,该方法叫混合使用 AOF 日志和内存快照,也叫混合持久化。

如果想要开启混合持久化功能,可以在 Redis 配置文件将下面这个配置项设置成 yes:

aof-use-rdb-preamble yes

混合持久化工作在 AOF 日志重写过程

当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

图片

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失

Redis 大 Key 对持久化有什么影响?

大 Key 对 AOF 日志的影响

分别说说这三种策略,在持久化大 Key 的时候,会影响什么?

在使用 Always 策略的时候,主线程在执行完命令后,会把数据写入到 AOF 日志文件,然后会调用 fsync() 函数,将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。

当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的

当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。

当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程。

大 Key 对 AOF 重写和 RDB 的影响

当 AOF 日志写入了很多的大 Key,AOF 日志文件的大小会很大,那么很快就会触发 AOF 重写机制

img

着 Redis 存在越来越多的大 Key,那么 Redis 就会占用很多内存,对应的页表就会越大。

在通过 fork() 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是内核会把父进程的页表复制一份给子进程,如果页表很大,那么这个复制过程是会很耗时的,那么在执行 fork 函数的时候就会发生阻塞现象

可以执行 info 命令获取到 latest_fork_usec 指标,表示 Redis 最近一次 fork 操作耗时。

# 最近一次 fork 操作耗时
latest_fork_usec:315

如果 fork 耗时很大,比如超过1秒,则需要做出优化调整:

  • 单个实例的内存占用控制在 10 GB 以下,这样 fork 函数就能很快返回。
  • 如果 Redis 只是当作纯缓存使用,不关心 Redis 数据安全性问题,可以考虑关闭 AOF 和 AOF 重写,这样就不会调用 fork 函数了。
  • 在主从架构中,要适当调大 repl-backlog-size,避免因为 repl_backlog_buffer 不够大,导致主节点频繁地使用全量同步的方式,全量同步的时候,是会创建 RDB 文件的,也就是会调用 fork 函数。

总结

AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程)

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
  • 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。

大 key 除了会影响持久化之外,还会有以下的影响。

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。

功能篇

Redis 过期删除策略和内存淘汰策略有什么区别?

img

过期删除策略

Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。

如何设置过期时间?

缓存篇

图片

缓存雪崩

大量缓存数据同时过期或redis宕机,给数据库干崩了,所以叫缓存雪崩,思考怎么解决冲向数据库的大批流量。

图片

大量缓存同时过期

  1. 均匀设置过期时间
    给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。

  2. 互斥锁
    如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(多个请求请求同一个数据的时候,只有一个请求去访问数据库,其余的原地等着)
    记得设置超时时间,否则这个请求的寄了都寄

  3. 双 key 策略

    我们对缓存数据可以使用两个 key,一个是主 key,会设置过期时间,一个是备 key,不会设置过期,它们只是 key 不一样,但是 value 值是一样的,相当于给缓存数据做了个副本。

    当业务线程访问不到「主 key 」的缓存数据时,就直接返回「备 key 」的缓存数据,然后在更新缓存的时候,同时更新「主 key 」和「备 key 」的数据。

    主key找不到先返回备用key的值,且用互斥锁叫一个线程开始重建缓存。

  4. 后台更新缓存

    业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新

    当系统内存紧张的时候,有些缓存数据会被“淘汰” 解决这个问题的方式有两种。

    第一种方式,后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效

    这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般

    第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰)通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。

    在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。

Redis 故障宕机

  1. 服务熔断或请求限流机制

    • 启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力
    • 启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制
  2. 构建 Redis 缓存高可靠集群

    服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群

    如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

缓存击穿

大量请求冲向redis某几个热点数据,缓存击穿是缓存雪崩的一个子集(大量数据同时过期)

我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

图片

可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。

应对缓存击穿可以采取前面说到两种方案:

  • 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
  • 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

缓存穿透

用户访问的数据,既不在缓存中,也不在数据库中

图片

缓存穿透的发生一般有这两种情况:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

常见的方案有三种。

  • 第一种方案,非法请求的限制;
  • 第二种方案,缓存空值或者默认值;
  • 第三种方案,使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在;
  1. 非法请求的限制
    在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误

  2. 缓存空值或者默认值
    线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值

  3. 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在
    图片
    在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中

    布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

    所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据

Nginx

动静分离

反向代理

负载均衡

  1. 复制一份项目 用--server.port=10086改一下端口号

  2. 使用nginx,进行配置。这里是用到了反向代理和负载均衡,来实现集群。
    image-20230410150555653
    双击nginx.exe启动后,任务管理器里有两个nginx进程说明启动成功 一个主线程 一个子线程
    image-20230410150821301

  3. 修改配置文件后重新加载的命令:

    nginx -s reload
    

身边的面经

偶遇

考虑一个包含Redis和MvSQL的多级缓存系统 请设计一套存读与更新策略,尝解决高并发读写、存一致性透、存穿透、缓存雪崩等问题

滕涛美团一面

20230322 美团一面面经

面试官自我介绍、部门介绍

自我介绍

简单说了一下项目

遇见一个新技术怎么学,遇见自己无法解决的问题怎么解决

==与equals

Hashmap的底层实现

Hashmap的核心性质

负载因子

为什么扩容是两倍的原长

Hashmap安全吗,为什么,存在什么问题,什么map是安全

Concurrenthashmap底层原理

应该还有 但是我忘了

CAS讲下,存在什么问题,怎么解决的

索引底层原理讲一下,hash、链表、b、b+树对比一下

事务的四个属性,mysql是怎么保证四个属性的

回表,怎么避免

进程与线程

锁、偏向锁、轻量锁、重量锁、锁升级

AQS底线实现

Tcp与udp

Tcp怎么保证可靠性

IOC底层实现

怎么解决循依赖,为什么是三级缓存,两级不行吗

其他的没想起来,还有一堆

勇哥美团一面

美团 (45mins):
自我介绍
根据项目提问 (10mins): 项目中用到的技术,项目中遇到的难点又是如何解决的。

八股:

堆排序与快排序的实现

上下文切换是如何实现的

Java 锁的底层介绍 (synchronized 的底层实现,lock:ReentrantLock/CountdownLatch/信号量 synchronize 和锁的区别)

数据库隔离级别能详细介绍一下吗?

MySOL 中默认的隔离级别是什么? 是如何保证实现的? (RR和 RC下mvcc保证幻读的不同、锁是如何保证一致性的)

聚簇索引和非聚簇索引你了解吗? 有什么不同? (B+树,回表InnoDB和MyISAM 在索引上的不同实现方式)

你开发中用的 jdk 版本是什么,企业目前使用的 jdk 版本和最新的 jdk版本有关注吗? 他们之间的区别?

Jdk8 中的垃圾回收器详细介绍一下,G1 和 ZGC 与它有什么不同?

项目中使用的中间件是什么? mg和 kafka的框架了解吗?他们是如何保证消息的不丢失?

手撕算法:判断链表是否有环?

粤粤同门阿里一面

面经
做题四个
自我介绍
1、快速排序的思路(写题思路)
2、操作系统有没有了解过,线程的上下文切换,进程的时间片轮转效率和时间的大小的关系
3、进程间通信有了解吗?说一下管道是什么、线程和进程有什么区别
4、网络的5层结构,tcp三次握手,第三次握手失败了会发生什么,三次握手过程中可以携带数据吗
5、tcp和udp的区别
6、索引的数据结构,hash索引和b+索引区别,索引为什么可以加快查询,索引是不是越多越好
7、数据库的事务有了解吗
8、jvm有了解过吗
9、对象创建的过程了解吗
10、内存溢出和泄露区别
11、young gc 和full gc的区别什么时候进入老年代
12、如何避免频繁full gc
13、双亲委派机制有了解吗
14、spring一块有了解吗?依赖注入有哪些方法
15、工厂方法有了解吗,静态和动态工厂了解吗
16、spring事务了解吗
17、项目
18、rabbitmq一定是保持先进先出吗?更适合在什么应用场景下使用有了解吗
19、职业规划说一下
20、反问

滕涛同门美团一面

自我介绍

介绍一下比较有亮点的项目

索引limit几千万条数据怎么查

sql语句执行流程

redis用途

redis跳表

粤粤美团一面

  1. 浏览器输入url到显示的全过程
  2. 线程进程的区别,线程通信方式有哪些
  3. 对象创建的整个流程
  4. 实现线程安全有哪些方式
  5. 你知道的锁都有哪些
  6. CAS说一下,底层原理
  7. 线程池用过吗
  8. 说一下原理innodb和myisam的区别
  9. 为什么一般不使用外键索引
  10. 为什么使用B+树来实现
  11. 导致慢查询的因素
  12. 索引失效的场景有哪些
  13. 依赖冲突了解吗
  14. zookeeper怎么保证高可用
  15. zookeeper的节点类型说一下
  16. NIO和BIO的区别
  17. 写题:版本号的比较

松快手一面

自我介绍
看简历死扒项目
2.1 项目大概运行过程,为啥用多进程,多线程,有什么优点。答:大概回答了一下进程线程定义,优点什么的,说了一下整个程序服务的先后过程。
2.2 日志服务器怎么实现的。
2.3线程池怎么实现的,大概写一下你这个threadpool类,指出我这个线程池有不足
八股
3.1 c++对比c的优点
3.2 进程通信方式
3.3 死锁条件,怎么避免死锁
3.4 智能指针
3.5 想问mysql数据库相关,我说懂得不多就没咋问
还问了几个基础八股吧,都答出来了,忘了是啥了
code
4.1 棋盘被棋子围住的面积。憋了半天想了个回溯,还没写出来,面试官讲了思路,最后时间快到了,也没实现就过去了。
反问
5.1 实习生会干什么工作
5.2 我之后应该怎么继续学习,提个建议。
整体感觉面试官人很好,不会的地方一直引导我,项目扒的比较多,最后八股问的比较少。可惜自己太垃圾,没code出来。

RPC笔记

代理模式

代理模式是一种比较好理解的设计模式。代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
Understanding the Proxy Design Pattern | by Mithun Sasidharan | Medium

姨妈在这里就可以看作是代理你的代理对象,代理的行为(方法)是接收和回复新郎的提问。

代理模式有静态代理和动态代理两种实现方式,我们 先来看一下静态代理模式的实现

静态代理

静态代理中,我们对目标对象的每个方法的增强都是手动完成的,非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改)且麻烦(需要对每个目标类都单独写一个代理类)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。

实现步骤

  1. 定义一个接口及其实现类;
  2. 创建一个代理类同样实现这个接口
  3. 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情

实现案例

  1. 定义发送短信的接口

    public interface SmsService {
        String send(String message);
    }
    
  2. 实现发送短信的接口

    public class SmsServiceImpl implements SmsService {
        public String send(String message) {
            System.out.println("send message:" + message);
            return message;
        }
    }
    
  3. 创建代理类并同样实现发送短信的接口

    public class SmsProxy implements SmsService {
    
        private final SmsService smsService;
    
        public SmsProxy(SmsService smsService) {
            this.smsService = smsService;
        }
    
        @Override
        public String send(String message) {
            //调用方法之前,我们可以添加自己的操作
            System.out.println("before method send()");
            smsService.send(message);
            //调用方法之后,我们同样可以添加自己的操作
            System.out.println("after method send()");
            return null;
        }
    }
    
  4. 实际使用

    public class Main {
        public static void main(String[] args) {
            SmsService smsService = new SmsServiceImpl();
            SmsProxy smsProxy = new SmsProxy(smsService);
            smsProxy.send("java");
        }
    }
    

运行上述代码之后,控制台打印出:

before method send()
send message:java
after method send()

可以输出结果看出,我们已经增加了 SmsServiceImplsend()方法。

从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。

动态代理

从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。

JDK 动态代理机制

介绍

在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心。

Proxy 类中使用频率最高的方法是:newProxyInstance() ,这个方法主要用来生成一个代理对象

public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h)
    throws IllegalArgumentException
{
    ......
}

这个方法一共有 3 个参数:

  1. loader :类加载器,用于加载代理对象。
  2. interfaces : 被代理类实现的一些接口;
  3. h : 实现了 InvocationHandler 接口的对象;

要实现动态代理的话,还必须需要实现InvocationHandler 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现InvocationHandler 接口类的 invoke 方法来调用

public interface InvocationHandler {

    /**
     * 当你使用代理对象调用方法的时候实际会调用到这个方法
     */
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

invoke() 方法有下面三个参数:

  1. proxy :动态生成的代理类
  2. method : 与代理类对象调用的方法相对应
  3. args : 当前 method 方法的参数

也就是说:你通过Proxy 类的 newProxyInstance() 创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler 接口的类的 invoke()方法。 你可以在 invoke() 方法中自定义处理逻辑,比如在方法执行前后做什么事情。

JDK 动态代理类使用步骤
  1. 定义一个接口及其实现类;
  2. 自定义 InvocationHandler 并重写invoke方法,在 invoke 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑;
  3. 通过 Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h) 方法创建代理对象;
代码示例
  1. 定义发送短信的接口

    public interface SmsService {
        String send(String message);
    }
    
  2. 实现发送短信的接口

    public class SmsServiceImpl implements SmsService {
        public String send(String message) {
            System.out.println("send message:" + message);
            return message;
        }
    }
    
  3. 定义一个 JDK 动态代理类

    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.InvocationTargetException;
    import java.lang.reflect.Method;
    
    /**
     * @author shuang.kou
     * @createTime 2020年05月11日 11:23:00
     */
    public class DebugInvocationHandler implements InvocationHandler {
        /**
         * 代理类中的真实对象
         */
        private final Object target;
    
        public DebugInvocationHandler(Object target) {
            this.target = target;
        }
    
    
        public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
            //调用方法之前,我们可以添加自己的操作
            System.out.println("before method " + method.getName());
            Object result = method.invoke(target, args);
            //调用方法之后,我们同样可以添加自己的操作
            System.out.println("after method " + method.getName());
            return result;
        }
    }
    

    invoke() 方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 invoke() 方法,然后 invoke() 方法代替我们去调用了被代理对象的原生方法。

  4. 获取代理对象的工厂类

    public class JdkProxyFactory {
        public static Object getProxy(Object target) {
            return Proxy.newProxyInstance(
                    target.getClass().getClassLoader(), // 目标类的类加载
                    target.getClass().getInterfaces(),  // 代理需要实现的接口,可指定多个
                    new DebugInvocationHandler(target)   // 代理对象对应的自定义 InvocationHandler
            );
        }
    }
    

    getProxy():主要通过Proxy.newProxyInstance()方法获取某个类的代理对象

  5. 实际使用

    SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl());
    smsService.send("java");
    

运行上述代码之后,控制台打印出:

before method send
send message:java
after method send

CGLIB 动态代理机制

介绍

JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。

为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。

CGLIBopen in new window(Code Generation Library)是一个基于ASMopen in new window的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIBopen in new window, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理

在 CGLIB 动态代理机制中 MethodInterceptor 接口和 Enhancer 类是核心。

你需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。

public interface MethodInterceptor  extends Callback{
    // 拦截被代理类中的方法
    public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable;
}
  1. obj : 被代理的对象(需要增强的对象)
  2. method : 被拦截的方法(需要增强的方法)
  3. args : 方法入参
  4. proxy : 用于调用原始方法

你可以通过 Enhancer类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor 中的 intercept 方法.

CGLIB 动态代理类使用步骤
  1. 定义一个类;
  2. 自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法,和 JDK 动态代理中的 invoke 方法类似;
  3. 通过 Enhancer 类的 create()创建代理类;
代码示例

不同于 JDK 动态代理不需要额外的依赖。CGLIBopen in new window(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖.

<dependency>
  <groupId>cglib</groupId>
  <artifactId>cglib</artifactId>
  <version>3.3.0</version>
</dependency>
  1. 实现一个使用阿里云发送短信的类

    public class AliSmsService {
        public String send(String message) {
            System.out.println("send message:" + message);
            return message;
        }
    }
    
  2. 自定义 MethodInterceptor(方法拦截器)

    import net.sf.cglib.proxy.MethodInterceptor;
    import net.sf.cglib.proxy.MethodProxy;
    
    import java.lang.reflect.Method;
    
    /**
     * 自定义MethodInterceptor
     */
    public class DebugMethodInterceptor implements MethodInterceptor {
    
    
        /**
         * @param o           被代理的对象(需要增强的对象)
         * @param method      被拦截的方法(需要增强的方法)
         * @param args        方法入参
         * @param methodProxy 用于调用原始方法
         */
        @Override
        public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
            //调用方法之前,我们可以添加自己的操作
            System.out.println("before method " + method.getName());
            Object object = methodProxy.invokeSuper(o, args);
            //调用方法之后,我们同样可以添加自己的操作
            System.out.println("after method " + method.getName());
            return object;
        }
    
    }
    
  3. 获取代理类

    import net.sf.cglib.proxy.Enhancer;
    
    public class CglibProxyFactory {
    
        public static Object getProxy(Class<?> clazz) {
            // 创建动态代理增强类
            Enhancer enhancer = new Enhancer();
            // 设置类加载器
            enhancer.setClassLoader(clazz.getClassLoader());
            // 设置被代理类
            enhancer.setSuperclass(clazz);
            // 设置方法拦截器
            enhancer.setCallback(new DebugMethodInterceptor());
            // 创建代理类
            return enhancer.create();
        }
    }
    
  4. 实际使用

    AliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class);
    aliSmsService.send("java");
    

运行上述代码之后,控制台打印出:

before method send
send message:java
after method send

JDK 动态代理和 CGLIB 动态代理对比

  1. JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
  2. 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。

静态代理和动态代理的对比

  1. 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
  2. JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。

Java 序列化与反序列化

Java 序列化是指把 Java 对象转换为字节序列的过程便于保存在内存、文件、数据库中,ObjectOutputStream类的 writeObject() 方法可以实现序列化。
Java 反序列化是指把字节序列恢复为 Java 对象的过程,ObjectInputStream 类的 readObject() 方法用于反序列化。

addShutdownHook

ava程序运行时,有时会因为一些原因会导致程序死掉。也有些时候需要将程序对应的进程kill掉。

这些情况发生时,可能会导致有些需要保存的信息没能够保存下来,还有可能我们需要进程交代一些后事再被销毁。那要怎么办呢?

这就该ShutdownHook登场了。他是怎么完成我们上面描述的需要完成的事情呢?看看下面的例子吧。

  1. 正常运行结束
    img
    上面代码中“Runtime.getRuntime().addShutdownHook”方法就是添加了一个ShutdownHook。这个方法的参数是一个Thread对象,在程序退出时就会执行这个Thread对象了。
    img

  2. 因异常结束运行
    img

    比之前多添加了一行会抛出异常的代码。因为抛出异常,“结束运行”这个内容是肯定不会输出了。那还会输出“交代后事”吗?我们来看看运行的结果是什么吧。
    img

    看来有异常时,进程也可以完成“交代后事”。

  3. 程序通过System.exit()自行结束
    img
    img
    说明通过System.exit()结束程序时,也可以“交代后事”。

  4. 使用kill命令结束程序

    其实这个“-9”就是个必杀令,如果是执行“kill -9 pid”这样的命令,程序就不能“交代后事”了。我觉得就是为了防止程序的ShutdownHook磨磨蹭蹭的交代不完后事,甚至是通过一直“交代后事”而达到不被“kill”的目的。

RPC架构

image-20230629143130180

image-20230629143320014

整个架构就变成了一个微内核架构,我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。

服务发现

image-20230629144038018

  1. 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。
  2. 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用

健康检测机制

image-20230629150442040

路由策略

image-20230629151719099

image-20230629152012743

负载均衡

image-20230629161335408

image-20230629161643716

image-20230629162235872

重试机制

image-20230629162653319

image-20230629163331609

优雅关闭

image-20230629163845468

image-20230629165316359

优雅启动

image-20230629165943443

image-20230629170600258

熔断限流

image-20230629171024894

image-20230629171856550

image-20230629171837689

业务分组

image-20230629173719703

谷粒商城笔记

视频链接:https://www.bilibili.com/video/BV1np4y1C7Yf/

参考笔记:https://blog.csdn.net/unique_perfect/article/details/111392634

image-20230529200307822

image-20230530135745917

环境准备

环境配置的笔记1-15在Linux笔记中有,主要是搞虚拟机Linux环境和Mysql、Redis、Maven等

微服务代码搭建

创建微服务

在项目下右键新建Module,选中Spring Initializr初始化模块,一开始只需要选中必须的Spring Web和Open Feign:
image-20230530163912577

image-20230530164526563

image-20230530164548283

设置.gitignore

**/mvnw
**/mvnw.cmd
**/.mvn
**/target
.idea
**/.gitignore
**/README.md

在最外层设置聚合服务

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.ldl.gulimall</groupId>
    <artifactId>gulimall</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gulimall</name>
    <description>聚合服务</description>
    <packaging>pom</packaging>
    <modules>
        <module>gulimall-coupon</module>
        <module>gulimall-member</module>
        <module>gulimall-order</module>
        <module>gulimall-product</module>
        <module>gulimall-ware</module>
        <module>renren-fast</module>
        <module>renren-generator</module>
        <module>gulimall-common</module>
    </modules>
</project>

创建数据库

image-20230530172049955

SQL在参考笔记中有

快速开发-人人开源

image-20230530175106838

在Gitee上搜索人人开源,下载这两个项目代码,根据SQL生成代码。

  1. 下载好renren-fast删除git后,拖入到项目中,在聚合模块聚合这个模块,这个是我们的后端
  2. 将renren-fast/db/mysql.sql执行,创建renren-fast需要的表,例如定时任务、权限等。
  3. 在renren-fast下配置pom中的数据库账号密码,启动后台项目
  4. 下载node,在VsCode启动renren-fast-vue,这个是我们的前端
  5. 下载renren-generator,也是重复renren-fast的步骤启动它。
    这是一个代码生成器,其中的配置文件可以进行设置我们生成的代码形式,可以选择根据哪个数据库来生成代码
    image-20230530180513325
  6. 生成代码后复制到对应的模块下,会发现有很多共同依赖的包,因此需要创建一个新的模块(直接新建Maven模块即可):gulimall-commen用来存放所有需要的包,除了root模块其他的都可以依赖导入公共模块。

导入MyBatisPlus

image-20230530194037689

image-20230530194044786

即可测试成功:

image-20230530194352394

设置端口号

server:
port: 10000

SpringCloud Alibaba

Spring Cloud Alibaba Github链接:https://github.com/alibaba/spring-cloud-alibaba/blob/2022.x/README-zh.md

image-20230530195230172

image-20230530203533214

    <!--下面是依赖管理,相当于以后再dependencies里引spring cloud alibaba就不用写版本号, 全用dependencyManagement进行管理-->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>2.1.0.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

版本对应:SpringBoot 2.1.8.RELEASE spring-cloud :Greenwich.SR3 spring-cloud-alibaba:2.1.0.RELEASE

Nacos注册中心

Nacos通过startup.cmd启动后网址:http://localhost:8848/nacos/

github:https://github.com/alibaba/spring-cloud-alibaba/blob/2022.x/spring-cloud-alibaba-examples/nacos-example/nacos-discovery-example/readme-zh.md

image-20230530204606791

image-20230531103452829

image-20230531104028068

image-20230531105905306

OpenFeign微服务远程调用

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

image-20230531110615825

image-20230531110630337

image-20230531111036582

Nacos配置中心

github:https://github.com/alibaba/spring-cloud-alibaba/blob/2022.x/spring-cloud-alibaba-examples/nacos-example/nacos-config-example/readme-zh.md

获取配置文件中的内容(本地):

image-20230531112839002

image-20230531112716072

配置文件通过Nacos管理:

image-20230531112752755

image-20230531112602221

image-20230531112808097

配置文件顺序:bootstrap->application.yml->appliacton.properties->nacos
image-20230531120902675

image-20230531113139167

image-20230531121249090

image-20230531121324706

spring.application.name=gulimall-coupon
spring.cloud.nacos.config.server-addr=127.0.0.1:8848

#管理是哪个空间的
# 1.开发/测试/生产
# 2.根据模块来  coupon/member
spring.cloud.nacos.config.namespace=855e8029-14a1-45e4-961b-b07d9967bb4e
#同一空间下可以有不同的分组
# 1.根据活动来双11/618/平时的组  不配置是默认的
# 2.开发/测试/生产
spring.cloud.nacos.config.group=dev
spring.cloud.nacos.config.ext-config[0].data-id=datasource.yml
spring.cloud.nacos.config.ext-config[0].group=dev
spring.cloud.nacos.config.ext-config[0].refresh=true
spring.cloud.nacos.config.ext-config[1].data-id=mybatis.yml
spring.cloud.nacos.config.ext-config[1].group=dev
spring.cloud.nacos.config.ext-config[1].refresh=true
spring.cloud.nacos.config.ext-config[2].data-id=other.yml
spring.cloud.nacos.config.ext-config[2].group=dev
spring.cloud.nacos.config.ext-config[2].refresh=true

image-20230531115914670

SpringCloud

Gateway

官方文档:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/

image-20230531121526839

Spring Cloud Gateway Diagram

术语表:

route:网关的基本构建块。它由一个ID、一个目标URI、一组谓词和一组筛选器定义。如果聚合谓词为true,则匹配路由。

predicate:这是一个Java 8函数谓词。输入类型是Spring Framework Server Web Exchange。这允许您匹配HTTP请求中的任何内容,例如标头或参数。

filter:这些是使用特定工厂构建的网关过滤器实例。在这里,您可以在发送下游请求之前或之后修改请求和响应。

过滤器请求和响应都可以被修改。客户端发请求给服务端。中间有网关。先交给映射器,如果能处理就交给handler处理,然后交给一系列filer,然后给指定的服务,再返回回来给客户端。

先通过断言判断,判断成功了,才路由到指定地址

新建子模块,导入,配置nacos服务发现和配置中心。排除掉commen中的数据源信息.是用Netty做的而不是Tomcat

image-20230531135709882

image-20230531135718941

前端Vue搭建

全局安装webpack

npm install webpack 

全局安装vue脚手架

npm install  @vue/cli-init

初始化vue项目

vue init webpack appname

vue脚手架使用webpack模板初始化一个appname项目
启动vue项目
项目的package.json中有scripts,代表我们能运行的命令
npm run dev: 启动项目
npm run build:将项目打包

这里我直接用就行了 用nvm控制不知道有啥问题了。。。

使用element-ui:

npm i element-ui -S

在 main.js 中写入以下内容:

import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);

实现三级分类

插入数据

https://blog.csdn.net/unique_perfect/article/details/113824202

Controller

/**
     * 查出所有分类以及子分类,以树形结构组装起来
     */
    @RequestMapping("/list/tree")
    public R list(@RequestParam Map<String, Object> params){
        List<CategoryEntity> categoryEntities = categoryService.listWithTree();
        return R.ok().put("data", categoryEntities);
    }

Service

    @Override
    public List<CategoryEntity> listWithTree() {
        //1、查出所有分类
        List<CategoryEntity> categoryEntities = baseMapper.selectList(null);
        //2、将分类组装成父子的树形结构
        //2.1、找到所有的一级分类
        List<CategoryEntity> level1Menus = categoryEntities.stream().filter((categoryEntity ->
                categoryEntity.getParentCid() == 0
        )).map((menu) -> {
            menu.setChildren(getChildren(menu,categoryEntities));
            return menu;
        }).sorted((menu1,menu2)->{return menu1.getSort()==null?0:menu1.getSort()-(menu2.getSort()==null?0:menu2.getSort());}).collect(Collectors.toList());
        return level1Menus;
    }

    private List<CategoryEntity> getChildren(CategoryEntity root, List<CategoryEntity> all) {
        List<CategoryEntity> collect = all.stream().filter(categoryEntity -> categoryEntity.getParentCid().equals(root.getCatId())).map(categoryEntity -> {
            categoryEntity.setChildren(getChildren(categoryEntity, all));
            return categoryEntity;
        }).sorted((menu1,menu2)->{return menu1.getSort()==null?0:menu1.getSort()-(menu2.getSort()==null?0:menu2.getSort());}).collect(Collectors.toList());
        return collect;
    }

效果

image-20230601211620341

配置网关路由和路径重写

在renren-fast菜单管理新增路由,新增商品管理以及分类维护

image-20230601211728504

获取数据:

image-20230602150728534

发现会有后端路径问题:

image-20230601214641500

应当将localhost:8080/renren-fast/替换为我们的网关的地址 即localhost:88 也就是gulimall-gateway的地址

修改后:
image-20230601221332471

发现需要重新登录且验证码不对了,因为验证码是发送到后端的renren-fast的项目中,因此默认将发往网关的数据发送到renren-fast后端中。

先在renren-fast中引入Nacos,能让网关发现我们的renren-fast
image-20230601215121300

image-20230601215919628

image-20230601220233017

启动后发现Nacos服务列表中已经存在renren-fast中了,因此已经被服务发现,只需要在gateway中全部转发到renren-fast即可。

image-20230601220538209

通过filter重写功能,将/api替换为/renren-fast,注意要在同一空间下服务才能发现。

这样我们的验证码功能就出来了。会遇到跨域问题:
image-20230601221602443

网关统一配置跨域

跨域情况

image-20230602120025872

跨域流程

image-20230602141649015

先发OPTIONS请求后,返回允许,再发真实请求。

跨域解决方式

image-20230602141833523

image-20230602141908092

在网关处配置跨域

  1. 在网关项目中新建配置类,并设置返回的内容:
    image-20230602143138981
  2. 重启项目后会遇到错误:
    image-20230602143203226
  3. 因为重复配置了跨域,在renren-fast中也有跨域内容,需要注释掉:
    image-20230602143245643

树形展示三级分类

image-20230602143446653

目前所有请求转发到了renren-fast中,而商品相关应该转发到商品项目中,需要配置网关将http://localhost:88/api/product/category/list/tree/role/list中的/api/product所有请求转发到商品项目中。

在网关中添加:
image-20230602145148496

为商品服务添加Nacos服务注册和配置中心

image-20230602144506080

image-20230602144755713

image-20230602144944178

现在已经可以在前端获取到数据了:
image-20230602150834901

中间出了个小插曲我以为是我网关写错了,其实是我的前端网址写错了,笑死。

然后前端增加el-tree组件和数据:
image-20230602151703341

image-20230602151709274

image-20230602151721394

修改页面样式:
image-20230602153100600

image-20230602153115201

逻辑删除

  1. MybatisPlus提供了逻辑删除
    image-20230602154931585

  2. 虽然设置了全局的逻辑删除值的定义 但是可以在单表上进行修改
    image-20230602155154695

  3. 新建方法,使用逻辑删除
    image-20230602155513091

    image-20230602155528101

  4. PostMan验证:
    image-20230602155945395
    image-20230602160020862

  5. 添加打印日志,观察SQL
    image-20230602160317697

    image-20230602160311244

  6. 前端优化效果:

    这块儿可有可无感觉 不记了

一直到61 感觉内容都是前端 明天继续

posted @ 2022-11-03 19:00  杀戒之声  阅读(18)  评论(0编辑  收藏  举报