【基础、JVM、集合、线程、MySQL、Redis、Kafka、Zookeeper】

基础、JVM、集合、线程、MySQL、Redis、Kafka、Zookeeper

--------------------------------------------------基础--------------------------------------------------

1、Java 中几种基本数据类型什么,各自占用多少字节

基本数据类型字节数范围
byte1个字节(8位)-2^ 7~2^7-1
short2个字节(16位)-2^ 15~2^15-1
int4个字节(32位)-2^ 31~2^31-1
long8个字节(64位)-2^ 63~2^63-1
float4个字节(32位)-2^ 31~2^31-1
double8个字节(64位)-2^ 63~2^63-1
char2个字节(16位)-2^ 15~2^15-1
boolean1个字节(8位)-2^ 7~2^7-1

2、基本数据同包装类的区别

  1. 初始值不同:基本类型的初始值如int为0,boolean为false,而包装类型的初始值为null。
  2. 是否可以用于泛型:包装类型可用于泛型,而基本类型不可以,泛型不能使用基本类型,因为使用基本类型时会编译出错。
  3. 在栈中基本类型比包装类型更高效:基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。
  4. 是否适用new关键字:基本类型不适用new关键字,而包装类型需要使用new关键字来在堆中分配存储空间。

3、Java 中有了基本类型为什么还需要包装类

Java 是一个面向对象的语言,而基本类型不具备面向对象的特性。例如:integer 有 null 值,而int 只有 0。可见基本类型不能赋 null 值,但是某些场合我们需要给一个字段赋值为 null。

而且学习到集合时,你会发现,集合中是不可以直接存储基本数据类型的。所以我们要把基本数据类型转换一下,变成包装类。

4、Java 基本类型的参数传递和引用类型的参数传递有啥区别

  1. 当使用​基本数据类型​作为方法的形参时,在方法体中对形参的修改​不会​影响到实参的数值。
public static void main(String[] args) {
    int yang = 1;
    System.out.println("yang调用前" + yang);
    yang(yang);
    System.out.println("yang调用后" + yang);
}

public static void yang(int x) {
    System.out.println("yang中 赋值前" + x);
    x = 2;
    System.out.println("yang中 赋值后" + x);
}

结果

yang调用前1
yang中 赋值前1
yang中 赋值后2
yang调用后1
  1. 当使用​引用数据类型​作为方法的形参时,若在方法体中修改形参指向的数据内容,则​会​对实参变量的数值产生影响,因为形参变量和实参变量共享同一块堆区。
public static void main(String[] args) {
    int[] yang = new int[] {1, 2};
    System.out.println("yang调用前" + Arrays.toString(yang));
    yang(yang);
    System.out.println("yang调用后" + Arrays.toString(yang));
}

public static void yang(int[] x) {
    System.out.println("yang中 赋值前" + Arrays.toString(x));
    int temp = x[0];
    x[0] = x[x.length - 1];
    x[x.length - 1] = temp;
    System.out.println("yang中 赋值后" + Arrays.toString(x));
}

结果
yang调用前[1, 2]
yang中 赋值前[1, 2]
yang中 赋值后[2, 1]
yang调用后[2, 1]
  1. 当使用​引用数据类型​作为方法的​形参​时,​若在方法体中修改形参变量的指向,此时不会​对实参变量的数值产生影响,因此形参变量和实参变量分别指向不同的堆区。
public static void main(String[] args) {
    int[] yang = new int[] {1, 2};
    System.out.println("yang调用前" + Arrays.toString(yang));
    yang(yang);
    System.out.println("yang调用后" + Arrays.toString(yang));
}

public static void yang(int[] x) {
    x = new int[] {7, 8};
    System.out.println("yang中 赋值前" + Arrays.toString(x));
    int temp = x[0];
    x[0] = x[x.length - 1];
    x[x.length - 1] = temp;
    System.out.println("yang中 赋值后" + Arrays.toString(x));
}

结果
yang调用前[1, 2]
yang中 赋值前[7, 8]
yang中 赋值后[8, 7]
yang调用后[1, 2]

5、隐式类型转换和显式类型转换

(1)隐式类型转换

隐式转换也叫作自动类型转换,由系统自动完成,从存储范围小的类型到存储范围大的类型。

byte > short(char) > int > long > float > double

(2)显式类型转换(+= 内部含强制转换)

显示类型转换也叫作强制类型转换,是从存储范围大的类型到存储范围小的类型。我们需要将数值范围较大的数值类型赋给数值范围较小的数值类型变量时,由于此时可能会丢失精度。

在这里插入图片描述

byte b1 = 20;
byte b2 = 30;
int result1 = b1;// 50 隐式类型转换 byte -> int
byte result2 = 30 + 20;// 50 无转换
// byte result3 = b1 + b2;// 编译报错,整型变量之间运算结果最小是int类型
// byte result4 = b1 + 30;// 编译报错,整型变量之间运算结果最小是int类型
byte result5 = (byte)(b1 + b2);// 50 显式类型转换也叫强转,int -> byte

6、switch 要注意的地方

  • case后常量值不能重复。
  • case后常量值的类型与表达式结果的类型一致。
  • 表达式结果的类型只能是byte,short,int,char,String,enum。switch 可作用于 char byte short int 对应的包装类。
  • 根据需求,选择是否使用break。
  • default可以在任意位置。
  • 可以有多个case常量值对应一组语句。
int num = 2;
switch (num + 1) {
    case 1:
        System.out.println(num * num);
        break;
    case 2:
        System.out.println(num * num * num);
        break;
    case 3:
    case 5:
    case 6:
        System.out.println(num / 2);
        break;
    case 4:
        System.out.println(num / 4);
        break;
    default:
        System.out.println("over!");
}

// 结果 1

7、数组的扩容方式

  1. Arrays.copyOf(ages, ages.length+1)
  2. System.arraycopy(ages, 0, ages2, 0, ages.length)
@Test
public void test02() {
    int a[] = {1, 2, 3};
    int b[] = new int[5];
    System.arraycopy(a, 0, b, 0, a.length);
    System.out.println(Arrays.toString(b));
    int[] c = Arrays.copyOf(a, a.length);
    System.out.println(Arrays.toString(c));
}

结果
[1, 2, 3, 0, 0]
[1, 2, 3]

8、成员变量与局部变量的区别

  1. 生命周期:成员变量当创建对象new Cell(12,12)堆内存中为成员变量分配空间,当对象被垃圾回收,成员变量从堆内存消失。局部变量即为方法中定义的变量当方法被调用,局部变量进栈,当方法调用结束,栈区变量即出栈。
  2. 初始化:成员变量是定义在类中的,在使用之前可以不初始化,可以自动初始化值,局部变量定义在方法中不会自动初始化,成员变量在使用之前要定义赋值。
  3. 使用范围:类创建对象之后,成员变量自动进入堆内存中,成员变量在类内部都可以访问,局部变量会出现在栈内存中,局部变量只能在定义的方法内使用当方法执行结束,局部变量自动被清除。

9、静态变量和成员变量的区别

  1. 静态变量属于类,所以称为类变量,成员变量属于对象,所以称为对象变量。
  2. 静态变量储存于方法区的静态区,成员变量储存于堆内存中。
  3. 静态变量随着类的加载而加载随着类的消失而消失,成员变量随着对象的创建而存在随着对象的消失而消失。
  4. 静态变量可以通过类名调用,也可以通过对象调用,成员变量只能通过对象名调用。

10、面向对象三大特征

Java也支持面向对象的三大特征:封装、继承和多态。

封装

封装就是把现实世界中的客观事物抽象成一个java类,然后在类中存放属性和方法。如封装一个汽车类,其中包含了发动机、轮胎、底盘等属性,并且有启动、前进等方法。

java中提供了不同的封装级别:public、protected、默认的、private。

  1. public:公共的,可以修饰类,成员变量,成员方法,修饰的成员在任何场景中都可以访问。
  2. protected:受保护的,可以修饰成员变量和方法,修饰的成员在子类和同一个包中可以访问。
  3. 默认:不加任何修饰符,类,变量,方法。
  4. private:私有的,可以修饰成员变量和方法。

在这里插入图片描述
继承

象现实世界中儿子可以继承父亲的财产、样貌、行为等一样,编程世界中也有继承,继承的主要目的就是为了复用。子类可以继承父类,这样就可以把父类的属性和方法继承过来。

如Dog类可以继承Animal类,继过来嘴巴、颜色等属性,吃东西、奔跑等行为。

多态

多态是指在父类中定义的属性和方法被子类继承之后,可以通过重写,使得父类和子类具有不同的实现,这使得同一个属性或方法在父类及其各个子类中具有不同含义。

多态的概念比较简单,就是同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。如果按照这个概念来定义的话,那么多态应该是一种运行期的状态。为了实现运行期的多态,或者说是动态绑定,需要满足三个条件:

  • 有类继承或者接口实现。
  • 子类要重写父类的方法。
  • 父类的引用指向子类的对象。

继承和实现

在java中,接口可以继承接口,抽象类可以实现接口,抽象类也可以继承具体类。普通类可以实现接口,普通类也可以继承抽象类和普通类。

java支持多实现,但是只支持单继承。即一个类可以实现多个接口,但是不能继承多个类。

11、为什么 Java 不支持多继承

存在菱形继承问题,假设我们有类B和类C,它们都继承了相同的类A。另外我们还有类D,类D通过多重继承机制继承了类B和类C。

在这里插入图片描述

这时候,因为D同时继承了B和C,并且B和C又同时继承了A,那么,D中就会因为多重继承,继承到两份来自A中的属性和方法。这时候,在使用D的时候,如果想要调用一个定义在A中的方法时,就会出现歧义。

12、& 和 && 的区别

短路与 && 第一个是 false 就是 false ,不会继续判断第二个条件,如果第一个 true 就会继续判断。与运算 & 两个条件都判断。

13、讲讲类实例化顺序

  1. 父类静态代码块,静态代码块之间按代码顺序执行。
  2. 子类静态代码块,静态代码块之间按代码顺序执行。
  3. 父类实例代码块,实例代码块之间按代码顺序执行。
  4. 父类的构造函数。
  5. 子类实例代码块,实例代码块之间按代码顺序执行。
  6. 子类的构造函数。

14、抽象类和接口的区别,如何选择

  1. 抽象类的定义 abstract class A{},接口的定义 interface A{}。
  2. 抽象类中可以有成员变量,接口只能有静态常量。
  3. 抽象类只支持单继承,一个类可以实现多个接口,接口之间支持多继承。
  4. 抽象类中的抽象方法可以有public、protected和default这些修饰符,而接口中默认修饰符是public。不可以使用其它修饰符。
  5. 接口和抽象类,最明显的区别就是接口只是定义了一些方法而已,在不考虑Jva8中default方法情况下,接口中是没有实现的代码的。

在这里插入图片描述

15、成员内部类、局部内部类、匿名的内部类

定义在类中的类,叫成员内部类。举例:

class Outer {
    private int time;

    class Inner {
        public void timeIn() {
            time++;
        }
    }

    public int getTime() {
        return time;
    }
}

如果想创建内部类对象,必须先创建外部类对象,因为在内部类中,有一个隐式的引用指向了创建它的外部类对象

// 创建内部类对象
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
inner.timeIn();
System.out.println(outer.getTime());

结果
1

定义在方法中的内部类,叫局部内部类。局部内部类在使用外部成员的时候会报错,需要将外部成员使用final修饰。

class Outer2 {

    private int time;

    public void f(final int innerTime) {

        class Inner2 {
            public void fun() {
                // 局部内部类中访问局部变量,该变量必须是final的
                System.out.println(innerTime);
                time++;
            }
        }
        Inner2 inner = new Inner2();
        inner.fun();
    }

    public int getTime() {
        return time;
    }

    public void setTime(int time) {
        this.time = time;
    }
}

测试

Outer2 outer = new Outer2();
outer.f(100);
System.out.println(outer.getTime());

结果
100
1

匿名内部类中必须存在继承或实现。演示匿名内部类

/**
 * 匿名内部类
 */
public class InnerClass_Ni {
    public static void main(String[] args) {
        new MyRunnable3() {
            @Override
            public void run() {
                System.out.println("匿名内部类:Run...");
            }
        }.run();;
    }
}

// 接口
abstract interface MyRunnable1 {

    public void run();
}

// 接口
interface MyRunnable2 {

    public void run();
}

// 抽象类
abstract class MyRunnable3 {

    public abstract void run();
}
/*
class MyRunnableImpl implements MyRunnable{
	@Override
	public void run() {
		System.out.println("Run...");
	}
}
*/
  • 没有名字。
  • 匿名内部类必须继承一个抽象类或者实现一个接口。
  • 匿名内部类不能定义任何静态成员和静态方法。
  • 当所在方法形参需要被匿名内部类使用时,必须声明为 final。
  • 匿名内部类不能抽象,它必须要实现继承类或者实现接口所有抽象方法。
  • 匿名内部类不能访问外部类方法中局部变量,除非该变量被声明为 final 类型

16、静态内部类与非静态内部类有什么区别

  1. 静态内部类不依赖于外部类的实例,而非静态内部类需要外部类的实例才能创建。
  2. 静态内部类无法访问外部类的非静态成员变量和方法,只能访问外部类的静态成员变量和方法;而非静态内部类可以访问外部类的所有成员变量和方法。
  3. 静态内部类可以有静态成员变量和方法,而非静态内部类不能有静态成员变量和方法。

总之,静态内部类与非静态内部类的最大区别在于:是否依赖于外部类的实例。如果内部类需要直接访问外部类的非静态成员变量和方法,或者需要与外部类的实例紧密关联,那么应该使用非静态内部类;如果不需要访问外部类的实例或成员变量,或者需要独立存在,那么可以使用静态内部类。

17、equals 与 == 区别

  • == 对于基本类型和引用类型的作用效果是不同的:
    • 对于基本数据类型来说,== 比较的是值。
    • 对于引用数据类型来说,== 比较的是对象的内存地址。

因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。

equals() 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。

equals() 方法存在两种使用情况:

  • 类没有重写 equals()方法 :通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。
  • 类重写了 equals()方法 :一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。
String a = new String("ab"); // a 为一个引用
String b = new String("ab"); // b为另一个引用,,对象的内容一样
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
System.out.println(aa == bb);// true
System.out.println(a == b);// false
System.out.println(a.equals(b));// true
System.out.println(42 == 42.0);// true

18、谈谈 Java 异常层次结构

在这里插入图片描述
在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类

  • Exception:程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
  • Error :Error 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获不建议通过catch捕获 。Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。

19、请列出检查异常和运行异常

检查异常

  1. IOException:表示输入输出异常,例如文件读写、网络连接等操作时可能出现的异常。
  2. SQLException:表示SQL数据库访问异常,例如数据库连接错误、执行SQL语句错误等情况
  3. ParseException:表示解析异常,例如日期、时间等格式化解析错误。

运行异常

  1. NullPointerException:表示空指针异常,当尝试对一个空引用调用方法或属性时会抛出此异常。
  2. ArrayIndexOutOfBoundsException:表示数组下标越界异常,当访问超出数组范围的索引时会抛出此异常。
  3. IllegalArgumentException:表示非法参数异常,当传入的参数不符合预期时会抛出此异常。
  4. ClassCastException:表示类转换异常,当试图将一个对象强制转换成另一个不兼容的类型时会抛出此异常。
  5. ArithmeticException:表示算术运算异常,例如除以零或取模运算时分母为零时会抛出此异常。

20、try - catch - finally - return 执行顺序

  • 如果不发生异常,不会执行 catch 部分。
  • 不管有没有发生异常,finally 都会执行到。
  • 即使 try 和 catch 中有 return 时,finally 仍然会执行。
  • finally 部分就不要 return 了,要不然,就回不去 try 或者catch 的 return 了。

21、throw 和 throws 的区别

throw 作用在方法内,表示抛出具体异常,throws 作用在方法的声明上,表示方法抛出异常,由调用者来进行异常处理。

22、final、finally、finalize 区别

final可以修饰类,变量,方法,修饰的类不能被继承,修饰的变量不能重新赋值,修饰的方法不能被重写。

finally用于抛异常,finally代码块内语句无论是否发生异常,都会在执行finally,常用于一些流的关闭。

finalize是Object中的方法,当垃圾回收器将要回收对象所占内存之前被调用,即当一个对象被虚拟机宣告死亡时会先调用它finalize方法,让此对象处理它生前的最后事情,这个对象可以趁这个时机挣脱被回收的命运。在可达性分析算法前提下判断是否死亡,但被判断死亡后,还有生还的机会。如何自我救赎:

  1. 对象覆写了finalize方法,这样在被判死后才会调用此方法,才有机会做最后的救赎。
  2. 回收前被调用一次finalize的调用具有不确定性,只保证方法会调用,但不保证方法里的任务会被执行完(比如一个对象手脚不够利索,磨磨叽叽,还在自救的过程中,被杀死回收了)。

23、String 为什么设计成不可变的

字符串常量池的需要

字符串是使用最广泛的数据结构。大量的字符串的创建是非常耗费资源的,所以,java提供了对字符串的缓存功能,可以大大的节省堆空间。

JVM中专门开辟了一部分空间来存储java字符串,那就是字符串池。

通过字符串池,两个内容相同的字符串变量,可以从池中指向同一个字符串对象,从而节省了关键的内存资源。

String s = "abcd";
String s2 = s;

对于这个例子,s和s2都表示"abcd",所以他们会指向字符串池中的同一个字符串对象:

在这里插入图片描述

但是,之所以可以这么做,主要是因为字符串的不变性。试想一下,如果字符串是可变的,我们一旦修改了s的内容,那必然导致s2的内容也被动的改变了,这显然不是我们想看到的。

安全性

字符串在java应用程序中广泛用于存储每敏感信息,如用户名、密码、连接url、网络连接等。JVM类加载器在加载类的时也广泛地使用它。

因此,保护String类对于提升整个应用程序的安全性至关重要。当我们在程序中传递一个字符串的时候,如果这个字符串的内容是不可变的,那么我们就可以相信这个字符串中的内容。但是,如果是可变的,那么这个字符串内容就可能随时都被修改。那么这个字符串内容就完全不可信了。这样整个系统就没有安全性可言了。

线程安全

不可变会自动使字符串成为线程安全的,因为当从多个线程访问它们时,它们不会被更改。

因此,一般来说,不可变对象可以在同时运行的多个线程之间共享。它们也是线程安全的,因为如果线程更改了值,那么将在字符串池中创建一个新的字符串,而不是修改相同的值。因此,字符串对于多线程来说是安全的。

hashcodes缓存

由于字符串对象被广泛地用作数据结构,它们也被广泛地用于哈希实现,如HashMap、HashTable、HashSet 等。在对这些散列实现进行操作时,经常调用nashCode()方法。

不可变性保证了字符串的值不会改变。因比,hashCode 方法在String类中被重写,以方便缓存,这样在第一次 hashCode 调用间计算和缓存散列,并从那时起返回相同的值。

性能

前面提到了的字符串池、hashcode缓存等,都是提升性能的提现。因为字符串不可变,所以可以用字符串池缓存,可以大大节省堆内存。而且还可以提前对hashcode进行缓存,更加高效,由于字符串是应用最广泛的数据结构,提高字符串的性能对提高整个应用程序的总体性能有相当大的影响。

24、字符串拼接用 + 还是 StringBuilder

Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。

String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3;

上面的代码对应的字节码如下:

在这里插入图片描述
可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。

String[] arr = {"he", "llo", "world"};
String s = "";
for (int i = 0; i < arr.length; i++) {
    s += arr[i];
}
System.out.println(s);

StringBuilder 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。

在这里插入图片描述
如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。

String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
    s.append(value);
}
System.out.println(s);

25、String s 与 new String 有什么区别

直接使用形如"hello"的字符串直接量时,JVM将会使用常量池来管理这些字符串,当使用new String(“hello”)时,JAVA虚拟机首先在字符串池中查找是否已经存在了值为“hello”的这么一个对象,它的判断依据是String类equals()方法的返回值。如果有,则不再创建新的对象,直接返回已存在对象的引用,如果没有,则先创建这个对象,然后把它加入到字符串池中,再将它的引用返回。然后把常量池中的地址放到堆内存中,然后在栈中创建一个引用指向其堆内存块对象(此过程中可能会创建两个对象,也可能就一个)。

26、如何将 GB2312 编码字符串转换为 ISO-8859-1 编码字符串呢

public class Test {
	public static void main(String[] args) throws UnsupportedEncodingException {
		String str = "小男孩";
		String strIso = new String(str.getBytes("GB2312"), "ISO-8859-1");
		System.out.println(strIso);
	}
}

27、String有长度限制吗

有,编译期和运行期不一样。

  1. 编译期需要用CONSTANT_Utf8结构用于表示字符串常量的值,而这个结构是有长度限制,理论上允许的的最大长度是2^16-1=65535。但是由于 JVM 需要 1 个字节表示结束指令,所以编译时 String 最大长度不能超过65534。

  2. 字符串的内容是由一个字符数组 char[] 来存储的,由于数组的长度及索引是整数,且 String 类中返回字符串长度的方法 length() 的返回值也是 int,所以通过查看 java 源码中的类 Integer 我们可以看到 Integer 的最大长度不能超过2^31 -1。

28、Java 创建对象有几种方式

  • 用 new 语句创对象。
  • 使用反射,使用 Class.newInstance 创类对象,调用类对象构造方法 Constructor。
    • 第一种,使用 Class.forName 静态方法。
    • 第二种,使用 类.class 方法。
    • 第三种,使用实例对象 getClass() 方法。
  • 调用对象 clone 方法。
  • 运用反序列化手段,调用 java.io.ObjectInputStream 对象 readObject()方法。
  • 使用 Unsafe。

29、深拷贝和浅拷贝区别了解吗,什么是引用拷贝

深拷贝:所有元素或属性均完全复制,与原对象完全脱离,也就是说所有对于新对象的修改都不会反映到原对象中。在Java中,实现深拷贝的方式比较多,可以使用对象的序列化、手动编写clone()方法等。下面是一个使用对象序列化来实现深拷贝的例子:

package com.example.test.other.base;

import java.io.*;

public class Yang {

    public static void main(String[] args) throws Exception {
        Address address = new Address("Beijing");
        Person person1 = new Person("Tom", address);
        Person person2 = (Person)deepCopy(person1);
        System.out.println(person1 == person2); // false
        System.out.println(person1.getAddress() == person2.getAddress()); // false
    }

    private static Object deepCopy(Object obj) throws Exception {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(obj);
        ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bis);
        return ois.readObject();
    }

}

class Person implements Serializable {
    private String name;
    private Address address;

    public Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public Address getAddress() {
        return address;
    }
}

class Address implements Serializable {
    private String city;

    public Address(String city) {
        this.city = city;
    }
}

在上述代码中,我们使用了一个deepCopy()方法来实现对象的深拷贝。该方法使用对象的序列化和反序列化来实现深拷贝。首先,将原始对象序列化成字节数组,然后再将字节数组反序列化成新的对象。这样可以保证复制出的新对象与原始对象完全独立,不会相互影响。

浅拷贝:克隆出来的数据并不能完全脱离原数据,克隆前与克隆后的变量各自的变化会相互影响。这是因为引用变量存储在栈中,而实际的对象存储在堆中。每一个引用变量都有一根指针指向其堆中的实际对象。即当一个变量值改变时,另一个变量也会跟着发生变化。

package com.example.test.other.base;

public class Yang {
    public static void main(String[] args) throws Exception {
        Address address = new Address("Beijing");
        Person person1 = new Person("Tom", address);
        Person person2 = (Person)person1.clone();
        System.out.println(person1 == person2); // false
        System.out.println(person1.getAddress() == person2.getAddress()); // true
    }
}

class Person implements Cloneable {
    private String name;
    private Address address;

    public Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    public Address getAddress() {
        return address;
    }
}

class Address {
    private String city;

    public Address(String city) {
        this.city = city;
    }
}

那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。

一张图来描述浅拷贝、深拷贝、引用拷贝:

在这里插入图片描述

30、写出几种单例模式实现,懒汉模式和饿汉模式区别

单例模式分为饿汉模式和懒汉模式,单例模式的效果就是保证某个类只有唯一实例。

(1)饿汉单例模式

public class Hungry_Person {

    private static Hungry_Person single = new Hungry_Person();

    private Hungry_Person() {}

    public static Hungry_Person getInstance() {

        return single;
    }
}

测试结果,两个对象相等

Hungry_Person obj1 = Hungry_Person.getInstance();
Hungry_Person obj2 = Hungry_Person.getInstance();
System.out.println(obj1 == obj2);

结果
true

(2)懒汉单例模式

public class Lazy_Person {
    private static Lazy_Person single;

    private Lazy_Person() {
        System.out.println("对象被创建了!");
    }

    public static Lazy_Person getInstance() {
        if (single == null) {
            single = new Lazy_Person();
        }
        return single;
    }
}

测试结果,两个对象相等

Lazy_Person obj1 = Lazy_Person.getInstance();
Lazy_Person obj2 = Lazy_Person.getInstance();
System.out.println(obj1 == obj2);

结果
对象被创建了!
true

但是懒汉单例模式可能出现的线程安全问题

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        Lazy_Person.getInstance();
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});
Thread t2 = new Thread(new Runnable() {
    @Override
    public void run() {
        Lazy_Person.getInstance();
    }
});
t1.start();
t2.start();

结果
对象被创建了!
对象被创建了!

在多线程种会出现创建两个不同对象的情况,这就是线程安全问题,可以给懒汉模式在创建对象的时候加 synchronized


public class Lazy_Person {
    private static Lazy_Person single;

    private Lazy_Person() {
        System.out.println("对象被创建了!");
    }

    public static Lazy_Person getInstance() {
        if (single == null) {
            synchronized (Lazy_Person.class) {
                if (single == null) {
                    single = new Lazy_Person();
                }
            }
        }
        return single;
    }
}


结果
对象被创建了!

第一个判定条件是否需要加锁,第二个判定条件是否需要对像实例化,这就解决了懒汉模式下的线程安全问题。

31、java 中 Math.round(-1.5) 等于多少呢

  • round() :返回四舍五入,负5小数返回较大整数,如 -1.5 返回 -1。
  • ceil() :返回小数所在两整数间较大值,如 -1.5 返回 -1.0。
  • floor() :返回小数所在两整数间较小值,如 -1.5 返回 -2.0。

32、什么是泛型,有什么好处

Java 泛型是 JDK 1.5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。

泛型的本质是参数化类型,即给类型指定一个参数,然后在使用时再指定此参数具体的值,那样这个类型就可以在使用时决定了。这种参数类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。

为什么使用泛型

(1)保证了类型的安全性。

在没有泛型之前,从集合中读取到的每一个对象都必须进行类型转换,如果不小心插入了错误的类型对象,在运行时的转换处理就会出错。

比如:没有泛型的情况下使用集合:

ArrayList list = new ArrayList();
list0.add("abc");
list0.add(10);

比如:没有泛型的情况下使用集合:

ArrayList<String> list = new ArrayList<String>();
list.add("abc");
list.add(10);// 编译报错

相当于告诉编译器每个集合接收的对象类型是什么,编译器在编译期就会做类型检查,告知是否插入了错误类型的对象,使得程序更加安全,增强了程序的健壮性。

(2) 消除强制转换

泛型的一个附带好处是,消除源代码中的许多强制类型转换,这使得代码更加可读,并且减少了出错机会。

没有泛型的代码段需要强制转换

ArrayList list = new ArrayList();
list.add("a");
String s = (String)list.get(0);

当使用泛型时,代码不需要强制转换

ArrayList<String> list = new ArrayList<String>();
list.add("a");
String s = list.get(0);

33、泛型擦除是什么

Java 中的泛型基本上都是在编译器这个层次来实现的。在生成的 Java 字节代码中是不包含泛型中的类型信息的。使用泛型的时候加上的类型参数,会被编译器在编译的时候去掉。如在代码中定义的 List< Object > 和 List< String >等类型,在编译之后都会变成 List。 这个过程就称为类型擦除。

测试泛型擦除

ArrayList<String> list1 = new ArrayList<String>();
list1.add("abc");
ArrayList<Integer> list2 = new ArrayList<Integer>();
list2.add(123);
// 泛型类型String和Integer都被擦除掉了,只剩下原始类型
System.out.println(list1.getClass() == list2.getClass());

结果
true

34、面向对象的五大基本原则

  • 单一职责原则:一个类最好只做一件事
  • 开放封闭原则:对扩展开放、对修改封闭
  • 里氏替换原则:子类必须能够替换其基类
  • 依赖倒置原则:程序要依赖于抽象接口,而不是具体的实现
  • 接口隔离原则:使用多个小的专门的接口,而不要使用一个大的总接口

35、自动装箱与拆箱了解吗,原理是什么

拆箱和装箱

包装类是对基本类型的包装,所以,把基本数据类型转换成包装类的过程就是装箱;反之,把包装类转换成基本数据类型的过程就是拆箱。

自动拆装箱

在Java 5中,为了减少开发人员的工作,Java提供了自动拆箱与自动装箱功能。

  • 自动装箱:就是将基本数据类型自动转换成对应的包装类。
  • 自动拆箱:就是将包装类自动转换成对应的基本数据类型。
Integer i=10;//自动装箱
int b=i;//自动拆箱

自动拆装箱原理

自动装箱都是通过包装类的value0f()方法来实现的,自动拆箱都是通过包装类对象的xxxValue()来实现的。

哪些地方会自动拆装箱

1、场景一、将基本数据类型放入集合类

我们知道,Java中的集合类只能接收对象类型,那么以下代码为什么会不报错呢?

List<Integer> li = new ArrayList<>();
for (int i = 1; i < 50; i++) {
    li.add(i);
}

将上面代码进行反编译,可以得到以下代码:

List<Integer> li = new ArrayList<>();
for (int i = 1; i < 50; i += 2) {
    li.add(Integer.valueof(i));
}

以上,我们可以得出结论,当我们把基本数据类型放入集合类中的时候,会进行自动装箱。

2、场景二、包装类型和基本类型的大小比较

Integer a = 1;
System.out.println(a == 1 ? "等于" : "不等于");
Boolean bool = false;
System.out.println(bool ? "真" : "假");

对以上代码进行反编译,得到以下代码:

Integer a = 1;
System.out.println(a.intValue() == 1 ? "等于" : "不等于");
Boolean bool = false;
System.out.println(bool.booleanValue() ? "真" : "假");

可以看到,包装类与基本数据类型进行比较运算,是先将包装类进行拆箱成基本数据类型,然后进行比较的。

3、场景三、包装类型的运算

有没有人想过,当我们对Integer对象进行四则运算的时候,是如何进行的呢?看以下代码:

Integer i = 10;
Integer j = 20;
System.out.println(i + j);

反编译后代码如下:

Integer i = Integer.valueOf(10);
Integer j = Integer.valueOf(20);
System.out.println(i.intValue() + j.intValue());

我们发现,两个包装类型之间的运算,会被自动拆箱成基本类型进行。

4、场景四、三元运算符的使用

boolean flag = true;
Integer i = 0;
int j = 1;
int k = flag ? i : j;

很多人不知道,其实在 int k = flag ? i : j 这一行,会发生自动拆箱。反编译后代码如下:

boolean flag = true;
Integer i = Integer.valueOf(0);
int j = 1;
int k = flag ? i.intValue() : j;
System.out.println(k);

这其实是三目运算符的语法规范。当第二,第三位操作数分别为基本类型和对象时,其中的对象就会拆箱为基本类型进行操作。

因为例子中,flag ? i : j 片段中,第二段的i是一个包装类型的对象,而第三段的j是一个基本类型,所以会对包装类进行自动拆箱。如果这个时候的值为null,那么就会发生自动拆箱导致空指针异常。

5、场景五、函数参数与返回值

public int getNum1(Integer num){
	return num;
}
public Integer getNum2(int num){
	return num;
}

36、包装类型的缓存机制了解么

jdk1.5以后不再需要通过valueOf的方式手动装箱,采用自动装箱的方式,其实底层用的还是valueOf方法,只是现在不用要手动执行了,是通过编译器调用,执行时会自动生成一个静态数组作为缓存,例如Integer默认对应的缓存数组范围在[-128,127],只要数据在这个范围内,就可以从缓存中拿到相应的对象。超出范围就新建对象,这个就是缓存机制。

源码

valueOf 方法

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

Integer 的内部类 IntegerCache

private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];

    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;

        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);

        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}

IntegerCache是Integer的静态内部类,valueOf调用的IntegerCache.cache就是一个数组对象,数组的大小取决于范围内的最大值和最小值,例如上面的Integer是[-128,127]。然后数组内的元素都会被赋一个Integer对象,缓存也就形成了。

存在数组缓存,也就意味着,如果取值在[-128,127],使用valueOf()或者自动装箱创建的Integer对象都是在数组中取出,因此对象指向的内存地址是完全一样的。而如果用new或者是超出这个范围都要重新创建对象。

其它类型缓存范围

Byte:(全部缓存)
Short:(-128 — 127缓存)
Integer:(-128 — 127缓存)
Long:(-128 — 127缓存)

Float:(没有缓存)
Double:(没有缓存)

Boolean:(全部缓存)
Character:(0 — 127缓存)

测试

Integer a = new Integer(1);
Integer b = new Integer(1);
System.out.println(a == b); //new创建的两个对象,即使值相同,指向的内存地址也是不同的,使用==进行比较,比较的是地址,返回结果为false
Integer c = 1;
Integer d = 1;
System.out.println(c == d); //自动装箱和缓存机制,两个对象实际上是相同的,返回结果为true
Integer e = 128;
Integer f = 128;
System.out.println(e == f); //超出缓存范围,执行时会new新对象,两个对象不同,返回结果为false

结果
false
true
false

37、为什么浮点数运算的时候会有精度丢失的风险

浮点数运算精度丢失代码演示:

float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.println(a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false

为什么会出现这个问题呢?

这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。

就比如说十进制下的 0.2 就没办法精确转换成二进制小数:

// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...

如何解决浮点数运算的精度丢失问题

BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");

BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);

System.out.println(x); /* 0.1 */
System.out.println(y); /* 0.1 */
System.out.println(Objects.equals(x, y)); /* true */

BigDecimal 的 equals 原理

BigDecimal bigDecimal = new BigDecimal(1);
BigDecimal bigDecimal1 = new BigDecimal(1);
System.out.println(bigDecimal.equals(bigDecimal1));// true

BigDecimal bigDecimal2 = new BigDecimal(1);
BigDecimal bigDecimal3 = new BigDecimal(1.0);
System.out.println(bigDecimal2.equals(bigDecimal3));// true

BigDecimal bigDecimal4 = new BigDecimal("1");
BigDecimal bigDecimal5 = new BigDecimal("1.0");
System.out.println(bigDecimal4.equals(bigDecimal5));// false

通过以上代码示例,我们发现,在使用BigDecimal的equals方法对1和1.0进行比较的时候,当使用int、double定义时是true,当使用String定义时是false。

原因是,equals方法和compareTo并不一样,equals方法会比较两部分内容,分别是值和标度

所以,我们以上代码定义出来的两个BigDecimal对象(bigDecimal4和bigDecimal5)的标度是不一样的,所以使用equals比较的结果就是false了。

为什么标度不同

首先,BigDecima一共有以下4个构造方法:

BigDecimal(int)
BigDecimal(double)
BigDecimal(long)
BigDecimal(String)

以上四个方法,创建出来的的BigDecimal的标度是不同的。

BigDecimal(long)BigDecimal(int)

首先,最简单的就是BigDecimal(long)和Big Decimal(int),因为是整数,所以标度就是0

BigDecimal(double)

而对于BigDecimal(double),当我们使用new BigDecimal(0.1)创建一个BigDecimal的时候,实创建出来的值并不是整好等于0.1,是0.1000000000000000055511151231257827021181583404541015625。这是因为doule自身表示的只是一个近以值。

那么,无论我们使用new BigDecimal(0.1)还是new BigDecimal(0.10)定义,他的近似值都是
0.1000000000000000055511151231257827021181583404541015625这个,那么他的标度就是这个数字的位数,即55。

其他的浮点数也同样的道理。对于new BigDecimal(1.0)这样的形式来说,因为他本质上也是个整数,所以他创建出来的数字的标度就是0。所以,因为Big Decimal(1.0)和BigDecimal(1.00)的标度是一样的,所以在使用equals?方法比较的时候,得到的结果就是true。

BigDecimal(string)

而对于BigDecimal(String),,当我们使用new BigDecimal(“0.1”)创建一个BigDecimal的时候,其实创建出来的值正好就是等于0.1的。那么他的标度也就是1。如果使用new BigDecimal(“0.10000”),那么创建出来的数就是0.10000,标度也就是5。

所以,因为BigDecimal(“1.0”)和BigDecimal(“1.00”)的标度不一样,所以在使用equals方法比较的时候,得到的结果就是false。

如何比较Big Decimal

前面,我们解释了BigDecimal的equals方法,其实不只是会比较数字的值,还会对其标度进行比较。

所以,当我们使用equals方法判断判断两个数是否相等的时候,是极其严格的。

那么,如果我们只想判断两个BigDecimal的值是否相等,那么该如何判断呢?

BigDecimalr中提供了compareTo方法,这个方法就可以只比较两个数字的值,如果两个数相等,则返回0。

BigDecimal bigDecimal4 new BigDecimal("1");
BigDecimal bigDecimal5 new BigDecimal("1.0000");
System.out.println(bigDecimal4.compareTo(bigDecimal5));

38、如果有些字段不想进行序列化怎么办

对于不想进行序列化的变量,使用 transient 关键字修饰。transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。

关于 transient 还有几点注意:

  • transient 只能修饰变量,不能修饰类和方法。
  • transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。
  • static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。

39、BIO、NIO 和 AIO 的区别

网络IO模型有BIO、NIO、AIO

  • BIO:同步阻塞IO。
  • NIO:同步非阻塞IO。
  • AIO:异步非阻塞IO。

首先在网络编程中,客户端给服务端发送消息大约分为两个个步骤。

  1. 发起连接。
  2. 发送数据。

在BIO中每一个连接都需要分配一个线程来执行,假如A客户端连接了服务器,但是还没有发送消息,这个时候B客户端向服务器发送连接请求,这个时候服务器是没有办法处理B客户端的连接请求的。

在这里插入图片描述
因为一个线程处理了一个客户端的连接后就阻塞住,并等待处理该客户端发送过来的数据。处理完该客户端的数据后才能处理其他客户端的连接请求。

在这里插入图片描述
那你这个是只有一个线程的时候,那我弄多个线程不就好了,来一个请求连接我弄一个线程?那假如有一万个连接请求同时过来,那你开启一万个线程服务端不就崩了嘛!那我弄一个线程池呢,我最大线程数最多弄500呢?那假如有500线程只请求连接,并不发送数据呢,那你这个线程池不也一样废了吗。这500个请求连接上了还没有发送数据,那么线程池的500个线程就没办法去处理别的请求,这样照样废废了。

那咋办呢?

可以使用NIO同步非阻塞,这样就不需要很多线程,一个线程也能处理很多的请求连接和请求数据。NIO他是怎么实现一个线程处理多个连接请求和多个请求数据的呢?NIO会将获取的请求连接放入到一个数组中,然后再遍历这个数据查看这些连接有没有数据发送过来。

在这里插入图片描述

但是有个问题啊,如果B和C只连接了,但是一直没有发送数据,那每次还循环判断他俩有没有发送数据的请求是不是有点多余了,能不能在我知道B和C肯定发送了数据的情况下再去遍历他呢?可以引入Epoll,在JDK1.5开始引入了epoll通过事件响应来优化NIO,原理是客户端的每一次连接和每一次发送数据都看作是一个事件,每次发生事件会注册到服务端的一个集合中去,然后客户端只需要遍历这个集合就可以了。

那AIO有什么特点呢?

AIO是异步非阻塞,他对于客户端的连接请求和发送数据请求是用不同的线程来处理的,他是通过回调来通知服务端程序去启动线程处理,适用于长连接的场景。

40、Java 的 instanceof 有什么用处

instanceof 是 Java 的保留关键字。它的作用是测试它左边的对象是否是它右边的类的实例,返回 boolean 的数据类型。

boolean result = obj instanceof class 

其中 obj 为一个对象,Class 表示一个类或者一个接口。

当 obj 为 Class 的对象,或者是其直接或间接子类,或者是其接口的实现类,结果result 都返回 true,否则返回false。

注意:编译器会检查 obj 是否能转换成右边的class类型,如果不能转换则直接报错,如果不能确定类型,则通过编译,具体看运行时定。

41、使用实体类作为入参的优缺点

使用实体类作为入参的优点

  1. 实体类作为入参时,在属性方面和函数两方面都可以良好的体现面向对象和封装的概念。
    JavaBean的属性方面:JavaBean的属性明确即使多个人同时使用同一个JavaBean,也可以避免本质上相同属性出现多个不同命名的情况。
    JavaBean的函数方面:当属性需要特殊加工处理时,可以在实体类内封装函数处理需要加工的属性,体现了对特定逻辑的封装、包装,方便团队间理解和后期维护。
  2. 实体类与IBatis或MyBatis进行交互,作为返回结果时,通过配置属性与字段映射关系可以降低实体类与sql之间的耦合关系,当sql改动时修改映射关系就可以无需改动对应的实体类属性。
  3. 实体类的一些问题可以在代码编译期排除,而不需要等到运行时才发现错误,可以减少在运行时需要排查的问题点。

使用实体类作为入参的缺点

  1. 增加大量代码量,需要更多的时间需考虑对实体类属性与业务函数的实际封装。
  2. 会降低业务程序的开发进度。
  3. 当实体类属性需要增加时,改动地方要比使用Map的形式时要多一些,要增加实体类属性、get/set方法、封装的业务逻辑函数以及实体类与sql字段的映射关系等。

42、使用 Map 作为入参的优缺点

使用Map作为入参的优点

  1. 使用Map不需要定义属性以及初始化get、set方法和构造函数,可以简化这些操作部分。
  2. Map的灵活性要高于实体类,可以根据不同的封装内容,使用于不用的场景,而这样也就意味着Map的扩展性很强。
  3. Map可以直接作为IBatis、MyBatis的返回结果,省略了实体类作为返回结果时需要将返回字段与实体属性进行关系映射的步骤,可以简化操作。

使用Map作为入参的缺点

  1. 使用Map作为入参需要明确维护好每个key参数对应的使用场景及明确描述key的作用等,否则后期需要维护的时候这就是个让你头疼的坑。
  2. 团队开发时,如果多个人同时需要使用同一个Map时,会出现Map中出现多个不同的key,但是保存的值是相同的,这样会导致代码冗乱,多个业务之间衔接时不方便理解。
  3. 如果与IBatis、或MyBatis交互作为返回结果时,编译期无法确认参数是否有误,若参数有误需要到sql交互层进行确认。

43、枚举类与普通类有什么区别

  • 当取值为有限固定的值,可以使用枚举类型,枚举是一个数据类型。枚举类默认继承了java.lang.Enum类,而不是继承Object类。

  • 枚举也可以有方法和属性和构造函数,但是构造方法必须是私有的。如果省略了构造器的访问控制符,则默认使用private修饰。

  • 枚举还可以实现接口,不能进行继承,枚举也可以包含抽象方法。

现在说下最简单的枚举类型。每个枚举值只有一个字符串,如一个星期的枚举类:

public enum Week {
    Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday;
}

但是在实际使用中,可能想给每个枚举值赋予更多的含义,例如,给每个星期中文说明和编号等。修改后的星期枚举类如下:

public enum Week {
    Monday("星期一", "1"), 
    Tuesday("星期二", "2"),
    Wednesday("星期三", "3"),
    Thursday("星期四", "4"), 
    Friday("星期五", "5"),
    Saturday("星期六", "6"), 
    Sunday("星期日", "7");

    private String name;
    private String number;

    Week(String name, String number) {
        this.name = name;
        this.number = number;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getNumber() {
        return number;
    }

    public void setNumber(String number) {
        this.number = number;
    }
}

可以在枚举类中增加了 name/number 两个属性,并重新编写了构造方法。实现了要求。

测试得到结果

System.out.println(Week.Monday); //Monday
System.out.println(Week.Monday.getName()); //星期一
System.out.println(Week.Monday.getNumber()); //1

还有就是可以在枚举类中增加自定义抽象方法,再次修改星期枚举类如下:

public enum Week {
    Monday("星期一", "1") {
        @Override
        void helloYang() {
            System.out.println("hello Monday");
        }
    },
    Tuesday("星期二", "2") {
        @Override
        void helloYang() {
            System.out.println("hello Monday");
        }
    },
    Wednesday("星期三", "3") {
        @Override
        void helloYang() {
            System.out.println("hello Monday");
        }
    },
    Thursday("星期四", "4") {
        @Override
        void helloYang() {
            System.out.println("hello Monday");
        }
    },
    Friday("星期五", "5") {
        @Override
        void helloYang() {
            System.out.println("hello Monday");
        }
    },
    Saturday("星期六", "6") {
        @Override
        void helloYang() {
            System.out.println("hello Monday");
        }
    },
    Sunday("星期日", "7") {
        @Override
        void helloYang() {
            System.out.println("hello Monday");
        }
    };

    private String name;
    private String number;

    Week(String name, String number) {
        this.name = name;
        this.number = number;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getNumber() {
        return number;
    }

    public void setNumber(String number) {
        this.number = number;
    }

    abstract void helloYang();
}

测试得到结果

Week.Monday.helloYang(); //hello Monday

44、serialVersionUID 有何用途

简单概括而言, serialVersionUID 是用于在序列化和反序列化过程中进行核验的一个版本号。

如果我们不设置serialVersionUID,系统就会自动生成,自动生成有风险,就是我们的字段类型或者长度改变(新增或者删除的时候),自动生成的serialVersionUID会发生变化,那么以前序列化出来的对象,反序列化的时候就会失败。

--------------------------------------------------JVM--------------------------------------------------

1、JVM 的运行时内存区域是怎样的

根据Java虚拟机规范的定义,JVM的运行时内存区域注要由虚拟机栈本地方法栈方法区程序计数器以及运行时常量池组成。其中堆、方法区以及运行时常量池是线程之间共享的区域,而栈(本地方法栈+虚拟机栈)、程序计数器都是线程独享的。

演变过程
在这里插入图片描述

JVM 程序计数器

用于记录虚以机正在执行的字节码指令的地址。它是线程私有的,为每个线程维护一个独立的程序计数器,用于指示下一条将要被执行的字节码指令的位置。它保证线程执行一个字节码指令以后,才会去执行下一个字节码指令。

在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。在线程中程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。所以程序计数器一定是线程私有的。

JVM 虚拟机栈
在这里插入图片描述

JVM中的方法栈是线程私有的,每一个方法的调用会在方法栈中加入一个栈帧,比如这样启动 main 方法

public static void main(String[] args) {
    methodA();
}

public static void methodA() {
	int a = 0;
	int b = a + 3;
	methodB();
}

public static void methodB() {

}

栈中压入 main 方法的栈帧,执行到 methodA 方法,栈中压入 methodA 方法的栈帧,执行到 methodB 方法,栈中压入 methodB 方法的栈帧,每个方法执行完成之后,这个方法所对应的栈帧就会出栈,每个栈帧中大概存储这五个内容:局部变量表(存储局部变量的空间)、操作数栈(线程执行时使用到的数据存储空间)、动态链接(方法区的引用,例如类信息,常量、静态变量)、返回地址(存储这个方法被调用的位置,因为方法执行后还需要到方法被调用的位置)、附加信息(增加的一些规范里面没有的信息,可以添加自己的附加信息),这就是栈和栈帧。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

堆是存储对象实例的运行时内存区域。它是虚拟机运行时的内存总体的最大的一块,也一直占据着虚拟机内存总量的一大部分。Java堆由Java虚拟机管理,用于存放对象实例,几乎所有的对象实例都要在上面分配内存。此外,Java堆还用于垃圾回收,虚拟机发现没有被引用的对象时,就会对堆中对象进行垃圾回收,以释放内存空间。

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

Java 7及之前堆内存逻辑上分为三部分:新生代+老年代+永久代,Java 8及之后堆内存逻辑上分为三部分:新生代+老年代+元空间

在这里插入图片描述
设置堆大小与OOM

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项"-Xmx"和"-Xms"来进行设置

  • "-Xms"用于表示堆区的起始内存,等价于-XX: InitialHeapsize。
  • "-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapsize。

一旦堆区中的内存大小超过"-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。

通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认情况下,初始内存大小:物理电脑内存大小/ 64 最大内存大小:物理电脑内存大小/4。

注意:设置的堆大小不包含元空间(或永久代)

TLAB

先来看对象的创建过程,为对象分配空间的任务等同于在java堆中划分一块大小确定的内存出来,假设java堆空间内存是绝对规整的,所有使用过的内存都放在一边,没有使用过的内存放在另一半,中间放着一个指针作为分界点的指示器,当分配内存就仅仅是把指针想空闲的空间移动一段与对象大小相等的距离,这种分配方法称为指针碰撞(Bump The Pointer)。

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又使用了原来的指针来分配内存的情况。

解决方案其一就是使用本地线程分配缓冲(TLAB),对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden区内,即每个线程在java堆中预先分配一小块内存,哪个线程需要分配内存,就在哪个线程的本地缓冲区中分配,当本地缓冲使用完,分配新的缓存区时才需要同步锁定。

在这里插入图片描述
尽管不是所有对象实例都能在TLAB中成功分配内存,但是JVM确定是将TLAB作为内存分配的首选,可以通过选项 -XX:UseTLAB 设置是否开启TLAB空间,默认的情况下TLAB占用的内存非常小,仅占用Eden空间的1%。

堆的内存分配过程

  1. 最开始应该使用线程分配缓冲区(tlab)来给对象分配空间,每个线程都有一个tlab,它可以保证线程的安全。
  2. 使用tlab分配空间失败时考虑通过加锁的方式(多线程),在eden区分配空间,如果eden区满了,就会触发一次minor gc,它会清除掉没有用的对象,判断一个对象是否能被搜集通常有两种算法:引用计数器法、可达性分析法;存活下来的对象将会进入eden的from区,然后清空eden区。
  3. 当eden区满了,会第二次触发minor gc,他会将eden存活下来的对象放入to区,from存活下来的对象年龄+1后也进入to区,然后清空eden区和from区。
  4. 当eden区再次满时,第三次执行minor gc,这一次eden区存活下来的对象进入from区,to区存活下来的对象年龄+1也会进入from区,然后清空eden区和to区。
  5. 随着对象的数量增加,不停的做上面两次操作,到对象的年龄到达老年带所规定的年龄阈值的时候,对象从新生代进入老年代。
  6. 随着对象增加,老年代满时会执行major gc操作,gc后对象仍然无法保存报内存溢出。

方法区

用于存诸已被加载的类信息、常量、静态变量、即时编译后的代码等数据的内存区域。每加载一个类,方法区就会分配一定的内存空间,用于存储该类的相关信息,这部分空间随着需要而动态变化。方法区的具体实现形式可以有多种,比如堆、永久代、元空间等。

在这里插入图片描述

运行时常量池

是方法区的一部分。用于存储编译阶段生成的信息,主要有字面量和符号引用常量两类。其中字面量包括了文本字符串、被声明final的常量值、基本数据类型的值和其他。其中符号引用常量包括了类的全限定名称、字段的名称和描述符、方法的名称和描述符。

堆和栈的区别

  1. 存储位置不同,堆是在堆内存中分配空间,而栈是在的栈内存中分配空间。
  2. 存储的内容不同,堆中主要存储对象,栈中主要存储本地变量。
  3. 堆是线程共享的,栈是线程独享的。
  4. 堆是垃圾回收的主要区域,不再引用这个对象,会被垃圾回收机制会自动回收。栈的内存使用是一种先进后出的机制,栈中的变量会在程序执行完毕后自动释放。
  5. 栈的大小比堆要小的多,一般是几百到几干字节。
  6. 栈的存储速度比堆快,代码执行效率高。
  7. 堆上会发生OutofMemoryError,栈上会发生StackOverflowError。

2、Java的堆是如何分代的,为什么分代

Java的堆内存分代是指将不同生命周期的堆内存对象存储在不同的堆内存区域中,这里的不同的堆内存区域被定义为"代”。这样做有助于提升垃圾回收的效率,因为这样的话就可以为不同的"代"设置不同的回收策略。

一般来说,Java中的大部分对象都是朝生夕死的,同时也有一部分对象会持久存在。因为如果把这两部分对象放
到一起分析和回收,这样效率实在是太低了。通过将不同时期的对象存储在不同的内存池中,就可以节省宝贵的时
间和空间,从而改善系统的性能。

Java的堆由新生代(Young Generation)和老年代(Old Generation)组成。新生代存放新分配的对象,老年代存放长期存在的对象。新生代(Young)由年轻区(Eden)、Survivor区组成(From Survivor、To Survivor)。默认情况下,新生代的Eden区和Survivorl区的空间大小比例是8:2,可以通过-X:SurvivorRatio参数调整。

在这里插入图片描述
对象的分代晋升

一般情况下,对象将在新生代进行分配,首先会尝试在Eden区分配对象,当Eden内存耗尽,无法满足新的对象分
配请求时,将触发新生代的GC(Young GC、MinorGC),在新生代的GC过程中,没有被回收的对象会从Eden区被
般运到Survivo区,这个过程通常被称为"晋升"。

同样的,对象也可能会晋升到老年代,触发条件主要看对象的大小和年龄。对象进入老年代的条件有三个,满足一
个就会进入到老年代:

  1. 躲过15次GC。每次垃圾回收后,存活的对象的年龄就会加1,累计加到15次(Gdk8默认的),也就是某个对象躲过了1次垃圾回收,那么J小M就认为这个是经常被使用的对象,就没必要再待在年轻代中了。具体的次数可以通过-XX:MaxTenuringThreshold来设置在躲过多少次垃圾收集后进去老年代。
  2. 动态对象年龄判断。规则:在某个Survivor中,如果有一批对象的大小总是大于该Survivor的50%,那么此时大于等于该批对象年龄的对象就会直接到老年代中。
  3. 大对象直接进入老年代。-XX:PretenureSizeThreshold来设置大对象的l临界值,大于该值的就被认为是大对象,就会直接进入老年代。

什么是永久代

永久代(Permanent Generation)是HotSpot)虚拟机在以前版本中使用的一个永久内存区域,是VM中垃圾收集堆之外的另一个内存区域,它主要用来实现方法区的,其中存储了Class类信息、常量池以及静态变量等数据。

Java8以后,永久代被重构为元空间(MetaSpace)。但是,和新生代、老年代一样,永久代也是可能会发生GC的。而且,永久代也是有可能导致内存益出。只要永久代的内存分配超过限制指定的最大值,就会出现内存溢出。

3、如果YoungGC存活的对象所需要的空间比Survivor区域的空间大怎么办

毕竟一块Survivor区域的比例只是年轻的10%而已。这时候就需要把对象移动到老年代。

空间分配担保机制

如果Survivor区域的空间不够,就要分配给老年代,也就是说,老年代起到了一个兜底的作用。但是,老年代也是
可能空间不足的。所以,在这个过程中就需要做一次空间分配担保(CMS):

  • 剩余的存活对象大小,小于Survivorl区,那就直接进入Survivor区。
  • 剩余的存活对象大小,大于Survivor区,小于老年代可用内存,那就直接去老年代。
  • 剩余的存活对象大小,大于Survivor+老年代,触发"FullGC"。

4、YoungGC和FullGC的触发条件是什么

YoungGC的触发条件比较简单,那就是当年轻代中的eden区分配满的时候就会触发。

FullGC的触发条件比较复杂也比较多,主要以下几种:

  • 老年代空间不足
    • 创建一个大对象,超过指定阈值会直接保存在老年代当中,如果老年代空间也不足,会触发FullGC。
    • YoungGC之后,发现要移到老年代的对象,老年代存不下的时候,会触发一次FullGC。
  • 空间分配担保失败(空间分配担保)
  • 永久代空间不足
    • 如果有永久代的话,当在永久代分配空间时没有足够空间的似乎还,会触发FullGC。
  • 代码中执行System.gc
    • 代码中执行System.gc的时候,会触发FullGC,但是并不保证一定会立即触发。

5、什么是 Stop The World

Java中Stop-The-Vorld机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起。这是
Java中一种全局暂停现象,全局停顿,所有java代码停止,native代码可以执行,但不能与JVM交互。

不管选择哪种GC算法,stop-the-world都是不能彻底避免的,只能尽量降低STW的时长。

为什么需要STW呢

首先,如果不暂停用户线程,就意味着期间会不断有垃圾产生,永远也清理不干净。其次,用户线程的运行必然会导致对象的引用关系发生改变,这就会导致两种情况:漏标和错标。

  • 多标:原本不是垃圾,但是GC的过程中,用户线程将其引用关系修改,导致GC Roots不可达,成为了垃圾。这种情况还好一点,无非就是产生了一些浮动垃圾,下次GC再清理就好了。
  • 漏标:原本是垃圾,但是GC的过程中,用户线程将引用重新指向了它,这时如果GC一旦将其回收,将会导致程序运行错误。

6、JVM 如何判断对象是否存活

JVM有两种算法来判断对象是否存活,分别是引用计数法和可达性分析算法

  • 引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
  • 可达性分析算法:这个算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。

但是,并不是说当进行完可达性分析算法后,即可证明某对象可以被GC。对象是否存活,需要两次标记:

  1. 第一次标记通过可达性分析算法。如果没有GC Roots相连接的引用链,那么将第一次标记。
  2. 如果对象的finalize()方法被覆盖并且没有执行过,则放在F-Queuel队列中等待执行不一定会执行,如果一段时间后该队列的finalize()方法被执行且和GC Roots关联,则移出“即将回收”集合。如果仍然没有关联,则进行第二次标记,才会对该对象进行回收不过现在都不提倡覆盖finalize方法,它的本意是像Cpp一样在对象销毁前执行,但是它影响了JAVA的安全和GC的性能,所以第二种判断会越来越少。

7、JVM 有哪些垃圾回收算法

① 标记 - 清除算法(Tracing Collector)

标记-清除 算法是最基础的收集算法,它是由 标记 和 清除 两个步骤组成的。第一步是标记存活的对象,第二步是清除没有被标记的垃圾对象。

在这里插入图片描述

该算法的优点是当存活对象比较多的时候,性能比较高,因为该算法只需要处理待回收的对象,而不需要处理存活的对象。但是缺点也很明显,清除之后会产生大量不连续的内存碎片。导致之后程序在运行时需要分配较大的对象时,无法找到足够的连续内存。

② 标记 - 整理算法(Compacting Collector)

上述的 标记-清除 算法会产生内存区域使用的间断,所以为了将内存区域尽可能地连续使用, 标记-整理 算法应运而生。标记-整理 算法也是由两步组成,标记 和 整理。

和标记清除算法一样,先进行对象的标记,通过GC Roots节点扫描存活对象进行标记,将所有存活对象往一端空闲空间移动,按照内存地址依次排序,并更新对应引用的指针,然后清理末端内存地址以外的全部内存空间。

在这里插入图片描述

但是同样,标记整理算法也有它的缺点,一方面它要标记所有存活对象,另一方面还添加了对象的移动操作以及更新引用地址的操作,因此标记整理算法具有更高的使用成本。

③ 复制算法(Copying Collector)

无论是标记-清除算法还是垃圾-整理算法,都会涉及句柄的开销或是面对碎片化的内存回收,所以,复制算法 出现了。

复制算法将内存区域均分为了两块(记为S0和S1),而每次在创建对象的时候,只使用其中的一块区域(例如S0),当S0使用完之后,便将S0上面存活的对象全部复制到S1上面去,然后将S0全部清理掉。复制算法主要被应用于新生代,它将内存分为大小相同的两块,每次只使用其中的一块。在任意时间点,所有动态分配的对象都只能分配在其中一个内存空间,而另外一个内存空间则是空闲的。

在这里插入图片描述

但是缺点也是很明显的,可用的内存减小了一半,存在内存浪费的情况。所以 复制算法 一般会用于对象存活时间比较短的区域,例如 年轻代,而存活时间比较长的 老年代 是不适合的,因为老年代存在大量存活时间长的对象,采用复制算法的时候会要求复制的对象较多,效率也就急剧下降,所以老年代一般会使用上文提到的 标记-整理算法。

单纯的从时间长短上面来看:标记-清除 < 标记-复制 < 标记-整理。单纯从结果来看:标记-整理 > 标记-复制 >= 标记-清除

8、什么是三色标记算法

三色标记算法是一种垃圾回收的标记算法,它可以让JVM不发生或仅短时间发生STW(Stop The World),从而达到清除JVM内存垃圾的目的。 JVM中的CMS、G1垃圾回收器所使用垃圾回收算法即为三色标记法。

三色标记法将对象分为三种状态:白色、灰色和黑色。

  • 白色:该对象没有被标记过。
  • 灰色:该对象已经被标记过了,但该对象的引用对象还没标记完。
  • 黑色:该对象已经被标记过了,并且他的全部引用对象也都标记完了。
    在这里插入图片描述

三色标记法的标记过程可以分为三个阶段:初始标记(Initial Marking)、并发标记(Concurrent Marking)和
重新标记(Remark)。

  • 初始标记:遍历所有的根对象,将根对象和直接引用的对象标记为灰色。在这个阶段中,垃圾回收器只会扫描
    被直接或者间接引用的对象,而不会扫描整个堆。因此,初始标记阶段的时间比较短。(Stop The World)
  • 并发标记:在这个过程中,垃圾回收器会从灰色对象开始遍历整个对象图,将被引用的对象标记为灰色,并将
    已经遍历过的对象标记为黑色。并发标记过程中,应用程序线程可能会修改对象图,因此垃圾回收器需要使用 写屏障(Vrite Barrier)技术来保证并发标记的正确性。(不需要STW)
  • 重新标记:重新标记的主要作用是标记在并发标记阶段中被修改的对象以及未被遍历到的对象。这个过程中,
    垃圾回收器会从灰色对象重新开始遍历对象图,将被引用的对象标记为灰色,并将已经遍历过的对象标记为黑 色。(Stop The World)

在重新标记阶段结束之后,垃圾回收器会执行清除操作,将未被标记为可达对象的对象进行回收,从而释放内存空
间。这个过程中,垃圾回收器会将所有未被标记的对象标记为白色(White)。

以上三个标记阶段中,初始标记和重新标记是需要STW的,而并发标记是不需要STW的。其中最耗时的其实就是
并发标记的这个阶段,因为这个阶段需要遍历整个对象树,而三色标记把这个阶段做到了和应用线程并发执行,大
大降低了GC的停顿时长。

9、新生代和老年代的垃圾回收器有何区别

常见的垃圾回收器如下:

  1. 串行垃圾回收器(Serial Garbage Collector)如:Serial GC,Serial Old
  2. 并行垃圾回收器(Parallel Garbage Collector)如:Parallel Scavenge,Parallel Old,ParNew
  3. 并发标记扫描垃圾回收器(CMS Garbage Collector)
  4. G1垃圾回收器(G1 Garbage Collector,JDK7中推出,JDK9中设置为默认)
  5. ZGC垃圾回收器(The Z Garbage Collector,JDK11推出)
垃圾收集器分类作用位置使用算法特点适用场景
Serial串行新生代复制算法响应速度优先适用于单CPU环境下的client模式
ParNew并行新生代复制算法响应速度优先多CPU环境Server模式下与CMS配合使用
Parallel并行新生代复制算法吞吐量优先适用于后台运算而不需要太多交互的场景
Serial Old串行老年代标记-整理(压缩)算法响应速度优先适用于单CPU环境下的Client模式
Paraller Old并行老年代标记-整理(压缩)算法吞吐量优先适用于后台运算而不需要太多交互的场景
CMS并发老年代标记-清除算法响应速度优先适用于互联网或B/S业务
G1并发、并行新生代、老年代标记-整理(压缩)算法响应速度优先响应速度优先

新生代收集器有Serial、ParNew、Parallel Scavenge。
老年代收集器有Serial Old、Parallel Old、CMS。
整堆收集器有G1、ZGC。

在这里插入图片描述
jdk1.8默认使用ParallelGC。新生代采用的是Parallel Scavenge,老年代Parallel Old。

10、Java 中的四种引用有什么区别

  1. 强引用(Strong Reference):指向对象的引用称为强引用。如果一个对象具有强引用,那么垃圾回收器就不会回收这个对象,即使系统内存不足也不会回收它。例如,以下代码中的 obj 变量就是一个强引用。
java
Object obj = new Object();
  1. 软引用(Soft Reference):如果一个对象只被软引用所引用,则当系统内存不足时,垃圾回收器可能会回收这个对象。软引用通常用来实现缓存等功能。例如,以下代码中的 softRef 变量就是一个软引用。
SoftReference<Object> softRef = new SoftReference<>(new Object());
Object obj = softRef.get(); // 可以通过 get() 方法获取到被引用的对象
  1. 弱引用(Weak Reference):与软引用类似,如果一个对象只被弱引用所引用,则当垃圾回收器运行时,无论当前内存是否充足,都会回收这个对象。弱引用通常用来避免内存泄漏等问题。例如,以下代码中的 weakRef 变量就是一个弱引用。
WeakReference<Object> weakRef = new WeakReference<>(new Object());
Object obj = weakRef.get(); // 可以通过 get() 方法获取到被引用的对象
  1. 虚引用(Phantom Reference):如果一个对象只被虚引用所引用,则无论该对象是否有其他引用,垃圾回收器都会将其回收,并且在回收前会通过队列通知一次程序。虚引用通常用来跟踪对象被垃圾收集器回收的状态,或者在对象被回收时执行某些操作。例如,以下代码中的 phantomRef 变量就是一个虚引用。
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
Object obj = phantomRef.get(); // 返回值始终为 null
Reference<?> ref = queue.remove(); // 可以从队列中获取到被回收的虚引用

需要注意的是,软引用、弱引用和虚引用都继承自 Reference 类,并且它们在垃圾回收器运行时都可能会被回收。因此,在使用这些引用时,需要特别小心,避免由于引用被回收而导致程序出错。

11、Java 中类加载的过程是怎么样的

在这里插入图片描述

类加载主要分为三个阶段,加载,链接,初始化

  1. 加载

将类的字节码文件加载到内存中,并创建一个对应的 Class 对象来表示该类。

  1. 链接

又分为三个子阶段,验证,准备和解析

  • 验证阶段主要验证加载的class是否正确,例如,验证字节码文件的格式、方法调用是否正确。
  • 准备阶段为类的静态变量分配内存,并设置默认值。例如,如果一个类有一个静态变量 int i,则在准备阶段会为 i 分配内存,并将其初始化为 0。
  • 解析阶段会将符号引用解析为直接引用,在一个字节码文件中会用到其他类,但是字节码文件只会存用到类的类名,解析阶段就是会根据类名找到该类加载后在方法区的地址,也就是直接引用,并替换符号引用,这样运行到字节码时,就能直接找到某个类了。
  1. 初始化

在链接阶段之后,就可以开始初始化了。在初始化阶段,通常情况下,初始化代码包括静态变量的赋值、静态代码块的执行等。如果一个类有父类,则需要先初始化父类,然后再初始化子类。

Java中的类什么时候会被加载

  1. 当创建类的实例时,如果该类还没有被加载,则会触发类的加载。例如,通过关键字new创建一个类的对象
    时,JVM会检查该类是否已经加载,如果没有加载,则会调用类加载器进行加载。
  2. 当使用类的静态变量或静态方法时,如果该类还没有被加载,则会触发类的加载。例如,当调用某个类的静态方法时,JVM会检查该类是否已经加载,如果没有加载,则会调用类加载器进行加载。
  3. 当使用反射机制问类时,如果该类还没有被加载,则会触发类的加载。例如,当使用Class.forName方法加载某个类时,M会检查该类是否已经加载,如果没有加载,则会调用类加载器进行加载。
  4. 当JVM启动时,会自动加载一些基础类,例如java.lang.Object类和java.lang.Class类等。

总之,Java中的类加载其实是延迟加载的,除了一些基础的类以外,其他的类都是在需要使用类时才会进行加载。同时,Java还支持动态加载类,即在运行时通过程序来加载类,这为ava程序带来了更大的灵活性。

12、JVM中一次完整的GC流程是怎样的

在这里插入图片描述

一般来说,GC的触发是在对象分配过程中,当一个对象在创建时,他会根据他的大小决定是进入年轻代或者老年
代。如果他的大小超过-X:PretenureSizeThreshold就会被认为是大对象,直接进入老年代,否则就会在年轻代进行创建。

在年轻代创建对象,会发生在Eden区,但是这个时候有可能会因为Eden区内存不够,这时候就会尝试触发一次
YoungGC。

年轻代采用的是标记复制算法,主要分为,标记、复制、清除三个步骤,会从GC Roo开始进行存活对象的标记,
然后把Eden区和Survivor[区复制到另外一个Survivor[区。然后再把Eden和From Survivorl区的对象清理掉。

这个过程,可能会发生两件事情,第一个就是Suo有可能存不下这些存活的对象,这时候就会进行空间分配担保。如果担保成功了,那么就没什么事儿,正常进行Young GC就行了。但是如果担保失败了,说明老年代可能也不够了,这时候就会触发一次FuGC了。

还会发生第二件事情就是,在这个过程中,会进行对象的年龄判断,如果他经过一定次数的GC之后,还没有被回
收,那么这个对象就会被放到老年代当中去。

而老年代如果不够了,或者担保失败了,那么就会触发老年代的GC,一般来说,现在用的比较多的老年代的垃圾
收集器是CMS或者G1,他们采用的都是三色标记法。

也就是分为四个阶段:初始标记、并发标记、重新标记、及并发清理。

老年代在做Fu川GC之后,如果空间还是不够,那就要触发OOM了。

13、如何进行JVM调优

JVM调优是一个过程,而不是一个具体的动作,是需要不断的根据实际的业务情况,根据实际的应用情况进行不
断的调整和优化的。不同的应用之间的配置和优化手段也完全不同。

在做JVM调优的时候,首先就是需要监控、分析你的JVM的情况,然后才是真正的调整和优化,JVM的监控可以
用到以下工具:

jstack:Java虚拟机自带的命令行工具,主要用于生成线程的堆栈信息,用于诊断死锁及线程阻塞等问题。
https://www.hollischuang.com/archives/110
jmap:Java虚拟机自带的命令行工具,可以生成VM中堆内存的Dump文件,用于分析堆内存的使用情况。排
查内存泄漏等问题。https:/www.hollischuang.com/archives/303
jstat:Java虚拟机自带的命令行工具,主要用来监控VM中的类加载、GC、线程等信息。
https://www.hollischuang.com/archives/481
jhat:使用jmap可以生成Java堆的Dump文件,生成dump文件之后就可以用jhat命令,将dump文件转成html
的形式,然后通过http访问可以查看堆情况。https:/www.hollischuang.com/archives/1047

在做JVM调整和优化的时候,可以真正实践做的一些手段:

  1. 调整堆内存:JVM默认的最大堆内存大小是物理内存的1/4,可以通过在启动参数中增加-Xmx选项来增加堆
    内存大小。
  2. 调整垃圾收集器:可以通过设置不同的垃圾收集器来改善应用程序的性能,如使用并行收集器(UseParallelGC)或CMS收集器(UseConcMarkSweepGC)、G1等。
  3. 设置新生代和老年代的比例:默认情况下,新生代和老年代的比例是1:2,可以根据应用程序的需求调整这个
    比例。
  4. 调整GC线程数:可以通过设置线程数来优化应用程序的性能,如使用-XX:ParallelThreads参数来设置并行垃
    圾收集器的线程数。
  5. 使用合适的GC算法:可以根据应用程序的需求选择不同的GC算法,如G1(Garbage First)收集器,可以更
    好地处理大堆内存的应用程序。
  6. 减少对象创建:对象的创建和回收是JVM中的一个开销,因此可以通过减少对象的创建来降低JVM的开销。
  7. 使用合适的数据结构和算法:选择合适的数据结构和算法可以降低应用程序的内存和CPU使用量,从而提高
    性能。

--------------------------------------------------集合--------------------------------------------------

1、常见的集合有哪些

Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection 接口,主要用于存放单一元素,另一个是 Map 接口,主要用于存放键值对。对于 Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue。Java 集合框架如下图所示:

在这里插入图片描述

2、说说 List、Set、Queue、Map 四者的区别

  • List:存储的元素是有序的、可重复的。
  • Set:存储的元素是无序的、不可重复的。
  • Queue:按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
  • Map:使用键值对存储,保存键值对映射,映射关系可以一对一、多对一。

3、ArrayList 和 LinkedList 的区别是什么

  • 底层数据结构: ArrayList 底层使用的是 Object 数组,LinkedList 底层使用的是 双向链表 数据结构。

在这里插入图片描述

  • 随机访问性能:ArrayList基于数组,所以它可以根据下标查找,支持随机访问,当然,它也实现了RandmoAccess 接口,这个接口只是用来标识是否支持随机访问。LinkedList基于链表,所以它没法根据序号直接获取元素,它没有实现 RandmoAccess 接口,标记不支持随机访问。
  • 插入和删除是否受元素性能:LinkedList 的随机访问集合元素时性能较差,但在插入,删除操作是更快的。因为 LinkedList 不像 ArrayList 一样,不需要改变数组的大小,不需要在数组装满的时候要将所有的数据重新装入一个新的数组,对于插入和删除操作,LinkedList 优于 ArrayList。
  • 内存空间占用:LinkedList 需要更多的内存,因为 ArrayList 的每个索引的位置是实际的数据,而 LinkedList 中的每个节点中存储的是实际的数据和前后节点的位置。

4、ArrayList 和 Vector 的区别是什么

  • Vector 的方法都是同步的,线程安全;ArrayList 非线程安全,但性能比 Vector 好。
  • Vector 扩容默认扩容2倍,ArrayList 只增加1.5倍。

5、ArrayList 和 Vector 的扩容机制

ArrayList 是基于数组的集合,数组的容量是在定义的时候确定的,如果数组满了,再插入,就会数组溢出。所以在插入时候,会先检查是否需要扩容,如果当前容量+1超过数组长度,就会进行扩容

ArrayList 的扩容是创建一个1.5倍的新数组,然后把原数组的值拷贝过去。

在这里插入图片描述

底层代码:

ArrayList无参构造

// Object类型的数组 elmentData []
transient Object[] elementData;
// {}
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// ArrayList无参构造方法
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

底层用的是一个Object类型的数组elementData,当使用无参构造方法ArrayList后elementData是空的,也就是说使用无参构造方法后容量为0。

// 容量为10
private static final int DEFAULT_CAPACITY = 10;
// add添加元素方法
public boolean add(E e) {
	// 按照元素个数+1,确认数组容量是否够用,所需最小容量方法
    ensureCapacityInternal(size + 1);
    // 将数组第size位置添加为该元素
    elementData[size++] = e;
    return true;
}

// 所需最小容量方法
private void ensureCapacityInternal(int minCapacity) {
	// 空数组初始所需最小容量为10,非空数组所需最小容量是元素个数+1
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    // 是否需要扩容方法
    ensureExplicitCapacity(minCapacity);
}

// 是否需要扩容方法
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // 所需最小容量当前数组能否存下,如果现在数组存不下进行扩容
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

// 容器扩容方法
private void grow(int minCapacity) {
    // 旧容量(原数组的长度)
    int oldCapacity = elementData.length;
    // 新容量(旧容量加上旧容量右移一位,也就是1.5倍)
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 如果计算出的新容量比最小所需容量小就用最小所需容量作为新容量
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    // 如果计算出的新容量比MAX_ARRAY_SIZE大, 就调用hugeCapacity计算新容量
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // 数组扩容成新容量
    elementData = Arrays.copyOf(elementData, newCapacity);
}

由此看出只有当第一次add添加元素的时候,才初始化容量,因为是空数组所需的最小容量为10,而 elementData 大小为0,新容量算出类也是0,此时最小所需容量作为新容量为10。

例如:

ArrayList<Object> objects = new ArrayList<>();
长度:  1 容量: 10
长度:  5 容量: 10
长度:  11 容量: 15
长度:  15 容量: 15
长度:  21 容量: 22

ArrayList有参构造

//有参构造方法
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

有参构造和无参构造区别就是给数组初始化了长度initialCapacity并且数组不为空,不为空的数组最小所需容量就是集合元素长度,集合元素长度超过初始化长度initialCapacity值才扩容,扩容逻辑和无参构造一致。

例如:

ArrayList<Object> objects = new ArrayList<>(5);
长度:  3 容量: 5
长度:  5 容量: 5
长度:  7 容量: 7
长度:  11 容量: 15
长度:  15 容量: 15
长度:  19 容量: 22
ArrayList<Object> objects = new ArrayList<>(13);
长度:  15 容量: 19
长度:  17 容量: 19
长度:  21 容量: 28
长度:  25 容量: 28
长度:  29 容量: 42

Vector 扩容机制

Vector 的底层也是一个数组 elmentData,但相对于 ArrayList 来说,它是线程安全的,它的每个操作方法都是加了锁的。如果在开发中需要保证线程安全,则可以使用 Vector。扩容机制也与 ArrayList 大致相同。唯一需要注意的一点是,Vector 的扩容量是2倍。

结论

数据类型底层数据结构默认初始容量加载因子扩容增量
ArrayList数组10(jdk7)0(jdk8)加载因子1(元素满了扩容)0.5:扩容后容量为原容量的1.5倍
Vector数组10加载因子1(元素满了扩容)1:扩容后容量为原容量的2倍

LinkedList,链表结构,且是是双向链表,不涉及扩容的问题。

6、CopyOnWriteArrayList 了解多少

CopyOnWriteArrayList 就是线程安全版本的 ArrayList。

它的名字叫 CopyOnWrite —— 写时复制,已经明示了它的原理。

CopyOnWriteArrayList 采用了一种读写分离的并发策略。CopyOnWriteArrayList 容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。

在这里插入图片描述
CopyOnWriteArrayList 添加新元素是否需要扩容

CopyOnWriteArrayList 底层并非是动态扩容数组,不能动态扩容,其线程安全是通过ReentrantLock来保证的。当向其添加元素时候,线程获取锁的使用权,add方法中会新建一个容量为旧容量加一的数组,然后将旧数据拷贝到该数组,然后把新数据放在数组尾部。

7、为什么 ArrayList 不是线程安全的

ArrayList 和 Vector 线程安全区别就是add方法没有被synchronized修饰,这样的ArrayList会出现两种线程安全问题,第一种就是集合数据出现NULL的情况,线程A 是第一次add的时候,他知道他要去扩容,他自己扩容完复制成一个新的数组,然后给数组第一个下标赋值,此时size下标加1,如果线程A没有扩容完时候,线程B进入add方法时候,不巧也是以为要扩容,复制成一个新的数组,再赋值的时候,如果线程A把size加1了,那么线程B赋值时候就只能从第二个下标开始了。 [null , B的UUID值] 。

还有一种出现java.util.ConcurrentModificationException 并发冲突情况,modCount是修改记录数,expectedModCount是期望修改记录数,初始化的时候 expectedModCount=modCount,ArrayList的add函数、remove函数 操作都有对modCount++操作,当expectedModCount和modCount值不相等, 那就会报并发错误了。

实现 ArrayList 线程安全的方法

1.最简单的方式, 也是面经上经常看到的  使用 VectorList<String> resultList = new Vector<>();
2.使用 Collections里面的synchronizedList :
List<String> list1 = Collections.synchronizedList(list);
3.使用juc包下的 CopyOnWriteArrayListCopyOnWriteArrayList list2 = new CopyOnWriteArrayList();

拓展:set 因为实现类都是线程不安全的,所以解决方法有

// 方式一:使用Collections集合类
// Set<String> set = Collections.synchronizedSet(set1);
// 方式二:使用CopyOnWriteArraySet

8、Collection 和 Collections 有什么区别

首先说下 collection,collection 它是一个接口,collection 接口的意义是为各种具体的集合提供统一的操作方式,它继承 Iterable 接口,Iterable 接口中有一个最关键的方法, Iterator iterator()方法迭代器,可以迭代集合中的元素。
Collections 是操作集合的工具类,其中最出名的就是 Collections.sort(list) 方法进行排序,使用此方法排序集合元素类型必须实现Comparable接口重写compareTo()方法,否则无法实现排序。

9、Comparable 和 Comparator 的区别

对集合排序最常见的就是 Collections.sort(arrayList) 方法,但是用这个方法排序,arrayList这个集合的元素类型必须实现 Comparable 接口并实现其中的 compareTo 方法并写排序规则才能完成排序。

或者对集合排序也可使使用 sort 的一个重载方法 sort(List list, Comparator<? super T> c) 来实现排序,就是第一个参数为 arrayList 这个集合也不用再去实现 Comparable 接口了,而是在第二个参数写 Comparator 的实现 compare 方法,例如

 Collections.sort(arrayList, new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2.compareTo(o1);
    }
});

10、HashSet 的实现原理

HashSet底层其实是一个HashMap实例,数据存储结构都是数组+链表。HashSet中的元素都存放在HashMap的key上面,而value都是一个统一的对象PRESENT。

private static final Object PRESENT = new Object();

HashSet中add方法调用的是底层HashMap的put方法。如果是在HashMap中调用put方法,首先会去判断key是否已经存在,如果存在,则修改value的值,如果不存在,则插入这个k-v对。而在Set中,value是没有用的,所以也就不存在修改value的情况,故而,向HashSet中添加新的元素,首先判断元素是否存在,不存在则插入,存在则pass,这样HashSet中就不存在重复值了。

11、TreeSet 的实现原理

TreeSet底层实际是用TreeMap实现的,Treeset 底层是由红黑树实现的,内部维持了一个简化版的TreeMap,通过key来存储Set的元素。 TreeSet内部需要对存储的元素进行排序,因此,我们对应的类需要实现Comparable接口。这样,才能根据compareTo()方法比较对象之间的大小,才能进行内部排序。

12、Queue 与 Deque 的区别

Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。

Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
|

Queue 接口抛出异常返回特殊值
插入队尾add(E e)offer(E e)
删除队首remove()poll()
查询队首元素element()peek()

Deque 是双端队列,在队列的两端均可以插入或删除元素。

Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:

Deque 接口抛出异常返回特殊值
插入队首addFirst(E e)offerFirst(E e)
插入队尾addLast(E e)offerLast(E e)
删除队首removeFirst()pollFirst()
删除队尾removeLast()pollLast()
查询队首元素getFirst()peekFirst()
查询队尾元素getLast()peekLast()

事实上,Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。

/**
  * 测试队列 特殊的线性表,队列中限制了对线性表的访问只能从线性表的一端添加元素,从另一端取出,遵循先进先出(FIFO)原则
  */
 @Test
 public void test01() {
     Queue<String> que = new LinkedList<String>();
     // 添加队尾
     que.offer("3");
     que.offer("1");
     que.offer("2");

     // 获取队首
     String str = que.peek();
     System.out.println(str);// 3

     // 移除队首
     que.poll();
     System.out.println(que);// [1, 2]
 }

 /**
  * 栈是队的子接口,栈是继承队的,定义类"双端列"从队列的两端可以入队(offer)和出队(poll)
  * LinkedList实现了该接口,如果限制Deque双端入队和出队,将双端队列改为单端队列即为栈,栈遵循先进后出(FILO)的原则
  */
 @Test
 public void test02() {
     Deque<String> stack = new LinkedList<String>();
     // 压栈
     stack.push("aaa");
     stack.push("bbb");
     stack.push("ccc");
     System.out.println(stack);// [ccc, bbb, aaa]

     // 弹栈
     String lastStr = stack.pop();
     System.out.println(lastStr);// ccc
     lastStr = stack.pop();
     System.out.println(lastStr);// bbb
     lastStr = stack.pop();
     System.out.println(lastStr);// aaa
 }

13、ArrayDeque 与 LinkedList 的区别

ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。
  • ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。
  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。

从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。

14、HashMap 的数据结构

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是链表散列。HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)将链表转化为红黑树,以减少搜索时间。但将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。
在这里插入图片描述
其中,桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。

  • 数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置。
  • 如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素。
  • 如果链表长度 > 8 并且数组大小 >= 64,链表转为红黑树。
  • 如果红黑树节点个数 < 6 ,转为链表。

15、HashMap 的put流程

在这里插入图片描述

以下是具体的 put 过程(JDK1.8 版):

  • 对 Key 求 Hash 值,然后再计算下标
  • 如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的 Hash 值相同,需要放到同一个 bucket 中)
  • 如果碰撞了,以链表的方式链接到后面
  • 如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于 6,就把红黑树转回链表
  • 如果节点已经存在就替换旧值
  • 如果桶满了(容量 16*加载因子 0.75),就需要 resize(扩容 2 倍后重排)
    • initialCapacity:初始容量。指的是 HashMap 集合初始化的时候自身的容量。可以在构造方法中指定;如果不指定的话,总容量默认值是 16 。需要注意的是初始容量必须是 2 的幂次方。
    • size:当前 HashMap 中已经存储着的键值对数量,即 HashMap.size()
    • loadFactor:加载因子。所谓的加载因子就是 HashMap (当前的容量/总容量) 到达一定值的时候,HashMap 会实施扩容。加载因子也可以通过构造方法中指定,默认的值是 0.75 。
    • threshold:扩容阀值。即 扩容阀值 = HashMap 总容量 * 加载因子。当前 HashMap 的容量
      大于或等于扩容阀值的时候就会去执行扩容。扩容的容量为当前 HashMap 总容量的两倍。

举个例子,假设有一个 HashMap 的初始容量为 16 ,那么扩容的阀值就是 0.75 * 16 = 12 。也就是说,在你打算存入第 13 个值的时候,HashMap 会先执行扩容,那么扩容之后为 32 。

16、你对红黑树了解多少,为什么不用二叉树/平衡树呢

红黑树本质上是一种二叉查找树,为了保持平衡,它又在二叉查找树的基础上增加了一些规则:

在这里插入图片描述

  1. 每个节点颜色不是黑色,就是红色
  2. 根节点是黑色的
  3. 如果一个节点是红色,那么它的两个子节点就是黑色的(没有连续的红节点)
  4. 对于每个节点,从该节点到其后代叶节点的简单路径上,均包含相同数目的黑色节点
  5. NULL 节点都是黑色的

之所以不用二叉树:

红黑树是一种平衡的二叉树,插入、删除、查找的最坏时间复杂度都为 O(logn),避免了二叉树最坏情况下的O(n)时间复杂度。

平衡二叉树是比红黑树更严格的平衡树,为了保持保持平衡,需要旋转的次数更多,也就是说平衡二叉树保持平衡的效率更低,所以平衡二叉树插入和删除的效率比红黑树要低。

17、HashMap 怎么查找元素的呢

在这里插入图片描述

18、HashMap 中元素存放位置的计算

在这里插入图片描述

19、为什么 HashMap 的容量是2的倍数呢

第一是因为哈希函数的问题

hash & (n-1)

通过除留余数法方式获取桶号,因为Hash表的大小始终为2的n次幂,因此可以将取模转为位运算操作,提高效率,容量n为2的幂次方,n-1的二进制会全为1,位运算时可以充分散列,避免不必要的哈希冲突,这也就是为什么要按照2倍方式扩容的一个原因。

第二是因为是否移位的问题

是否移位,由扩容后表示的最高位是否1为所决定,并且移动的方向只有一个,即向高位移动。因此,可以根据对最高位进行检测的结果来决定是否移位,从而可以优化性能,不用每一个元素都进行移位,因为为0说明刚好在移位完之后的位置,为1说明不是需要移动oldCop,这也是其为什么要按照2倍方式扩容的第二个原因。

HashMap 初始化容量设置多少合适

return (int) ((float) expectedSize / 0.75F + 1.0F);

20、1.8 对 HashMap 主要做了哪些优化

jdk1.8 的HashMap主要有五点优化:

  1. 数据结构:数组 + 链表改成了数组 + 链表或红黑树原因:发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n)降为O(logn)。
  2. 链表插入方式:链表的插入方式从头插法改成了尾插法简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。原因:因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。
  3. 扩容rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。原因:提高扩容的效率,更快地扩容。
  4. 扩容时机:在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容。
  5. 散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次。原因:做 4 次的话,边际效用也不大,改为一次,提升效率。

21、HashMap 和 Hashtable 有什么区别

  • 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!)
  • 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它。
  • 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。
  • 初始容量大小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小。

22、HashMap 和 TreeMap 区别

TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。

实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。

实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:

TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
            @Override
            public int compare(Person person1, Person person2) {
                int num = person1.getAge() - person2.getAge();
                return Integer.compare(num, 0);
            }
        });

综上,相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。

23、为什么 HashMap 线程不安全

Java8中已经不再采用头插法,改为尾插法,即直接插入链表尾部,因此不会出现死循环和数据丢失,但是在多线程环境下仍然会有数据覆盖的问题。

首先我们看一下Java8中put操作的源码

在这里插入图片描述
注意红色框内的部分,如果插入元素没有发生hash碰撞则直接插入。

如果线程A和线程B同时进行put,刚好两条数据的hash值相同,如果线程A已经判断该位置数据为null,此时被挂起,线程B正常执行,并且正常插入数据,随后线程A继续执行就会将线程A的数据给覆盖。发生线程不安全。

Java7中头插法扩容会导致死循环和数据丢失,Java8中将头插法改为尾插法后死循环和数据丢失已经得到解决,但仍然有数据覆盖的问题。

有什么办法能解决HashMap线程不安全的问题呢

并发环境下推荐使用 ConcurrentHashMap(ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用 CAS + synchronized) 。或者

// 方式一:使用古老实现类Hashtable(是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大)
// Hashtable<String, String> table = new Hashtable<>();

// 方式二:使用Collections集合类( 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现)
// Map<String, String> map1 = Collections.synchronizedMap(map);

24、怎么确保一个集合不能被修改

final关键字可以修饰类,方法,成员变量,final修饰的类不能被继承,final修饰的方法不能被重写,final修饰的成员变量必须初始化值,如果这个成员变量是基本数据类型,表示这个变量的值是不可改变的,如果说这个成员变量是引用类型,则表示这个引用的地址值是不能改变的,但是这个引用所指向的对象里面的内容还是可以改变的。

集合(map,set,list…)都是引用类型,所以我们如果用final修饰的话,集合里面的内容还是可以修改的。我们可以采用Collections包下来让集合不能修改:

1. Collections.unmodifiableList(List)
2. Collections.unmodifiableSet(Set)
3. Collections.unmodifiableSet(map)

25、LinkedHashMap 怎么实现有序的

LinkedHashMap维护了一个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。

在这里插入图片描述
可以实现按插入的顺序或访问顺序排序。

在这里插入图片描述

26、讲讲 TreeMap 怎么实现有序的

TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,或者自定义一个实现了 Comparator 接口的比较器,传给 TreeMap 用于 key 的比较。

在这里插入图片描述

27、ConcurrentHashMap 实现原理

HashMap 在我们日常的开发中使用频率最高的一个工具类之一,然而使用 HashMap 最大的问题之一就是它是线程不安全的,如果我们想要线程安全, 这时候就可以选择使用 ConcurrentHashMap,ConcurrentHashMap 和 HashMap 的功能是基本一样的,ConcurrentHashMap 是 HashMap 的线程安全版本。

ConcurrentHashMap 原理

ConcurrentHashMap 是 HashMap 的线程安全版本,如何实现线程的安全性?

加锁。但是这个锁应该怎么加呢?在 HashTable 中,是直接在 put 和 get 方法上加上了 synchronized,理论上来说 ConcurrentHashMap 也可以这么做,但是这么做锁的粒度太大,会非常影响并发性能,所以在 ConcurrentHashMap 中并没有采用这么直接简单粗暴的方法,其内部采用了非常精妙的设计,大大减少了锁的竞争,提升了并发性能。

ConcurrentHashmap线程安全在jdk1.7版本是基于分段锁实现,在jdk1.8是基于CAS + synchronized实现。

jdk1.7 基于分段锁

在JDK1.7中,ConcurrentHashMap使用了分段锁技术,即将哈希表分成多个段,每个段拥有一个独立的锁。这样可以在多个线程同时访问哈希表时,只需要锁住需要操作的那个段,而不是整个哈希表,从而提高了并发性能。

虽然JDK1.7的这种方式可以减少锁竞争,但是在高并发场景下,仍然会出现锁竞争,从而导致性能下降。

在 JDK1.7 版本中,ConcurrentHashMap 由数组 + Segment + 分段锁实现,其内部分为一个个段(Segment)数组,Segment 通过继承 ReentrantLock 来进行加锁,通过每次锁住一个 segment 来降低锁的粒度而且保证了每个 segment 内的操作的线程安全性,从而实现线程安全。下图就是 JDK1.7 版本中 ConcurrentHashMap 的结构示意图

实际上就是相当于每个Segment都是一个HashMap,默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。

在这里插入图片描述
put流程

整个流程和HashMap非常类似,只不过是先定位到具体的Segment,然后通过ReentrantLock去操作而已,后面的流程,就和HashMap基本上是一样的。

  1. 计算hash,定位到segment,segment如果是空就先初始化
  2. 使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功
  3. 遍历HashEntry,就是和HashMap一样,数组中key和hash一样就直接替换,不存在就再插入链表,链表同样操作
    在这里插入图片描述
    get流程

get也很简单,key通过hash定位到segment,再遍历链表定位到具体的元素上,需要注意的是value是volatile的,所以get是不需要加锁的。

但是这么做的缺陷就是每次通过 hash 确认位置时需要 2 次才能定位到当前 key 应该落在哪个槽:

  • 通过 hash 值和 段数组长度-1 进行位运算确认当前 key 属于哪个段,即确认其在 segments 数组的位置。
  • 再次通过 hash 值和 table 数组(即 ConcurrentHashMap 底层存储数据的数组)长度 - 1进行位运算确认其所在。

jdk1.8 基于CAS + synchronized

在JDK1.8中,ConcurrentHashMap的实现方式进行了改进,使用CAS+Synchronized的机制来保证线程安全。实现线程安全不是在数据结构上下功夫,它得数据结构和hashMap一样,,ConcurrentHashMap会在添加或删除元素时,首先使用CAS操作来尝试修改元素,如果CAS操作失败,则使用Synchronizeds锁住当前槽,再次尝试put或者delete。这样可以避免分段锁机制下的锁粒度太大,以及在高并发场景下,由于线程数量过多导致的锁竞争问题,提高了并发性能。

put流程

  1. 首先计算hash,遍历node数组,如果node是空的话,就通过CAS+自旋的方式初始化
  2. 如果当前数组位置是空则直接通过CAS自旋写入数据
  3. 如果哈希槽处已经有节点,且hash值为MOVED,说明需要扩容,执行扩容
  4. 如果都不满足,就使用synchronized写入数据,写入数据同样判断链表、红黑树,链表写入和HashMap的方式一样,key hash一样就覆盖,反之就尾插法,链表长度超过8就转换成红黑树
    在这里插入图片描述
    get查询

get很简单,和HashMap基本相同,通过key计算位置,table该位置key相同就返回,如果是红黑树按照红黑树获取,否则就遍历链表获取。

28、ConcurrentHashMap 和 Hashtable 区别

HashTable 使用的是 Synchronized 关键字修饰,ConcurrentHashMap 是JDK1.7使用了锁分段技术来保证线程安全的。JDK1.8ConcurrentHashMap取消了Segment分段锁,采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。

synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

29、Iterator 怎么使用

/**
 * 测试Collection迭代对象的方式 迭代器的方式 Iterator接口,定义了迭代Collection 容器中对象的统一操作方式 集合对象中的迭代器是采用内部类的方式实现 这些内部类都实现了Iterator接口
 * 使用迭代器迭代集合中数据期间,不能使用集合对象 删除集合中的数据
 */
 @Test
 public void test02() {

     Collection<String> c1 = new HashSet<String>();
     c1.add("java");
     c1.add("css");
     c1.add("html");
     c1.add("javaScript");
     Iterator<String> it = c1.iterator();
     while (it.hasNext()) {
         String str = it.next();
         System.out.println(str);// css java javaScript html
         if (str.equals("css")) {
             // c1.remove(str);//会抛出异常
             it.remove();
         }
     }
     System.out.println(c1);// [java, javaScript, html]
 }

30、Java 集合使用注意事项总结

① 集合判空

判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式。

② 集合转 Map

在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。

List<Person> bookList = new ArrayList<>();
bookList.add(new Person("jack","18163138123"));
bookList.add(new Person("martin",null));
// 空指针异常
bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber));

③ 集合遍历

不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。

通过反编译你会发现 foreach 语法底层其实还是依赖 Iterator 。不过, remove/add 操作直接调用的是集合自己的方法,而不是 Iterator 的 remove/add方法这就导致 Iterator 莫名其妙地发现自己有元素被 remove/add ,然后,它就会抛出一个 ConcurrentModificationException 来提示用户发生了并发修改异常。这就是单线程状态下产生的 fail-fast 机制。

fail-fast 机制 :多个线程对 fail-fast 集合进行修改的时候,可能会抛出ConcurrentModificationException。 即使是单线程下也有可能会出现这种情况,上面已经提到过。

④ 集合去重

可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作。

⑤ 集合转数组

使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。toArray(T[] array) 方法的参数是一个泛型数组,如果 toArray 方法中没有传递任何参数的话返回的是 Object类 型数组。

String [] s= new String[]{
    "dog", "lazy", "a", "over", "jumps", "fox", "brown", "quick", "A"
};
List<String> list = Arrays.asList(s);
Collections.reverse(list);
//没有指定类型的话会报错
s=list.toArray(new String[0]);

由于 JVM 优化,new String[0]作为Collection.toArray()方法的参数现在使用更好,new String[0]就是起一个模板的作用,指定了返回数组的类型,0 是为了节省空间,因为它只是为了说明返回的类型。

⑥ 数组转集合

使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。

基本数据类型数组转集合

错误代码如下(示例):

int[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
System.out.println("集合为:" + list + " 长度为:" + list.size());
//集合为:[[I@4554617c] 长度为:1

当把基础数据类型的数组转为集合时,由于Arrays.asList参数为可变长泛型,而基本类型是无法泛型化的,所以它把int[] arr数组当成了一个泛型对象,所以集合中最终只有一个元素arr。

正确代码如下(示例):

//(1)通过for循环遍历数组将其转为集合
int a[] = {1, 2, 3};
ArrayList<Integer> aList = new ArrayList<>();
for (Integer i : a) {
    aList.add(i);
}

//(2)使用Java8的Stream实现转换(依赖boxed的装箱操作)
int [] myArray = { 1, 2, 3 };
List myList = Arrays.stream(myArray).boxed().collect(Collectors.toList());

包装数据类型数组转集合

 Integer[] arr = {1, 2, 3};
 List list = Arrays.asList(arr);
 System.out.println("集合为:" + list + " 长度为:" + list.size());
//集合为:[1, 2, 3] 长度为:3

使用集合的修改方法: add()、remove()、clear()会抛出异常。

List myList = Arrays.asList(1, 2, 3);
myList.add(4);//运行时报错:UnsupportedOperationException
myList.remove(1);//运行时报错:UnsupportedOperationException
myList.clear();//运行时报错:UnsupportedOperationException

Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。

31、如何利用List实现LRU

LRU 即最近最少使用策略,基于时空局部性原理(最近问的,未来也会被访问),往往作为缓存淘汰的策略,如Redis和GuavaMap都使用了这种淘汰策略。

我们可以基于LinkedList来实现LRU,因为LinkedList基于双向链表,每个结点都会记录上一个和下一个的节点,
具体实现方式如下:

package com.example.test.other.base;

import java.util.LinkedList;

public class LruListCache<E> {

    private final int maxSize;
    private final LinkedList<E> list = new LinkedList<>();

    public LruListCache(int maxSize) {
        this.maxSize = maxSize;
    }

    public void add(E e) {
        if (list.size() < maxSize) {
            list.addFirst(e);
        } else {
            list.removeLast();
            list.addFirst(e);
        }
    }

    public E get(int index) {
        E e = list.get(index);
        list.remove(e);
        add(e);
        return e;
    }

    @Override
    public String toString() {
        return list.toString();
    }

    public static void main(String[] args) {
        LruListCache lruListCache = new LruListCache(2);
        lruListCache.add(1);
        lruListCache.add(2);
        lruListCache.add(3);
        System.out.println(lruListCache.get(1));
        System.out.println(lruListCache.toString());

    }
}

--------------------------------------------------线程--------------------------------------------------

1、什么是上下文切换

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。

一个线程被剥夺CPU的使用权就是 “切出”,一个线程获得CPU的使用权就是 “切入”,这种切入切出过程就是上线文。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

在多线程中,上下文切换的开销比单线程大,因为在多线程中,需要保存和恢复更多的上下文信息。过多上下文切换会降低系统的运行效率,因此需要尽可能减少上下文切换的次数。

减少上下文的切换的方式

  1. 减少线程数:可以通过合理的线程池也管理来减少线程的创建和销毁,线程数不是越多诚好,合理的线程数可以避免线程过多导致上下文切换。
  2. 使用CAS算法:CAS算法可以避免线程的阻塞和唤醒操作,从而减少上下文切换。
  3. 合理地使用锁:在使用锁的过程中,需要避免过多地使用同步块或同步方法,尽量缩小同步块或同步方法的范
    围,从而减少线程的等待时间,避免上下文切换的发生。

2、能不能谈谈你对线程安全的理解

线程安全是并发环境中能够正确地处理多个线程之间的共享变量,使程序功能正确完成。得到的结果和我们预期的一样,就是线程安全。

并发与并行的区别

  • 并发:两个及两个以上的作业在同一时间段内执行。
  • 并行:两个及两个以上的作业在同一时刻执行。

3、线程有几种状态

  1. 新建状态(new):创建一个线程对象。
  2. 就绪状态(Runnable):线程对象创建之后,调用start方法,就绪状态的线程只处于等待CPU的使用权,变为可运行。
  3. 运行状态(Running): 就绪状态的线程,获取到了CPU资源,执行程序代码。
  4. 阻塞状态(Blocked): 等待阻塞(线程执行了一个对象的wait方法,进入阻塞状态,只有等到其他线程执行了该对象的notify或notifyAll方法,才可能将其唤醒)、线程阻塞(线程获取synchronized同步锁失败因为锁被其它线程锁占用,它会进入同步阻塞状态)、其它阻塞(通过调用线程的sleep或join或发出了IO请求时,线程就会进入阻塞状态。当sleep超时或join等待线程终止或超时或IO处理完毕时,线程重新转入就绪状态)。
  5. 死亡状态(Dead):线程任务执行结束,即run方法结束,该线程对象就会被垃圾回收,线程对象即为死亡状态。

线程是如何被调度的

进程是分配资源的基本单元,线程是CPU调度的基本单元。这里所说的调度指的就是给其分配CPU时间片,让其
执行任务。

run方法和start方法区别

我们创建好线程之后,想要启动这个线程,则需要调用其start方法。所以,start方法是启动一个线程的入口。如果在创建好线程之后,直接调用其run方法,那么就会在单线程中直接运行run方法,不会起到多线程的效果。

WAITING 和 TIMED WAIT 的区别

WAITING是等待状态,在Java中,调用wait方法时,线程会进入到WAITING 状态,而TIMED WAITING是超时等
待状态,当线程执行sleep方法时,线程会进入TIMED WAIT状态。

sleep和wait区别

  1. sleep方法可以在任何地方使用,而wait方法则只能在同步方法或同步块中使用。
  2. wait方法会释放对象锁,但sleep方法不会。
  3. wait、notify、notifyAll针对的目标都是对象,所以把他们定义在Object类中。而sleep不需要释放锁,所以他是Thread类中的一个方法。
  4. wait的线程会进入到WAITING状态,直到被唤醒,sleep的线程会进入到TIMED WAITING状态,等到指定时间之后会再尝试获取CPU时间片。

notity和notityAll区别

当一个线程进入wait之后,就必须等其他线程notify或者notifyAll才会从等待队列中被移出。使用notifyAll可以唤醒所有处于wait状态的线程,使其重新进入锁的争夺队列中,而notify只能唤醒一个。被notify/notifyAll唤醒的线程,只是表示他们可以竞争锁了,竞争到锁之后才有机会被CPU调度。

Thread.sleep(0)的作用是什么

sleep方法需要指定一个时间,表示sleep的毫秒数,但是有的时候我们会见到Thread.sleep(0)这种用法其实就是让当前线程释放一下CPU时间片,然后重新开始争抢。

线程优先级

虽然Java线程调度是系统自动完成的,但是我们还是可以"建议”系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点,这项操作可以通过设置线程优先级来完成。

方法描述
static Thread currentThread()获取当前线程对象(线程名称, 线程优先级, 线程所属线程组)
String getName()获取当前线程对象
viod set(String name)设置线程名称
int getId()获取线程id
int getPriority(int i)设置线程级别
static Thread currentThread()获取当前线程对象
void setDaemon(boolean bo)设置一个线程为守护(后台)线程
boolean isDaemon()获取守护线程是true还是false
static native void sleep(long millis)设置休眠(单位毫秒)
static native void yield()当前线程放弃时间片
void join()等待该线程终止
void wait()设置当前线程等待阻塞状态
void notify()唤醒正处于等待状态的线程
void notifyAll()唤醒所有处于等待状态的线程

4、什么是守护线程,和普通线程有什么区别

在Java中有两类线程:User Thread用户线程、Daemon Thread守护线程。用户线程一般用户执行用户级任务,而守护线程也就是后台线程,一般用来执行后台任务,守护线程最典型的应用就是GC垃圾回收器。

这两种线程其实是没有什么区别的,唯一的区别就是虚拟机在所有用户线程都结束后就会退出,而不会等守护线程执行完。

Thread t1 = new Thread();
t1.setDaemon(true);
System.out.println(t1.isDaemon());

5、创建线程有几种方式

(1)通过继承Thread类,重写run方法,线程的任务定义在run()方法中。

class TicketThread extends Thread {

    private int tickets = 30;

    @Override
    public void run() {
        while (true) {
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() + "正在买票" + tickets--);
            } else {
                System.out.println(Thread.currentThread().getName() + "票卖完了");
                break;
            }
        }
    }
}

创建线程对象和启动线程

// 创建一个线程对象
TicketThread t = new TicketThread();
// 启动线程
t.start();

(2)实现Runnable接口,实现run方法,线程的任务,定义在run()方法中。

class TicketThread implements Runnable {

    private int tickets = 30;

    @Override
    public void run() {
        while (true) {
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() + "正在买票" + tickets--);
            } else {
                System.out.println(Thread.currentThread().getName() + "票卖完了");
                break;
            }
        }
    }
}

创建线程对象和启动线程

// 创建一个任务对象
Runnable runnable = new TicketThread();
// 创建一个线程对象
Thread t = new Thread(runnable);
// 启动线程
t.start();

(3)实现Callable接口。

class TicketThread implements Callable<List<String>> {

    private int tickets = 30;
    List<String> list = new ArrayList<String>();

    @Override
    public List<String> call() throws Exception {

        while (true) {
            if (tickets > 0) {
                list.add(Thread.currentThread().getName() + "正在买票" + tickets--);
            } else {
                list.add(Thread.currentThread().getName() + "票卖完了");
                return list;
            }
        }
    }
}

创建线程对象和启动线程

Callable callable = new TicketThread();
FutureTask futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
// 获取返回值
List<String> list = (List<String>)futureTask.get();
for (String s : list) {
    System.out.println(s);
}

(4)通过线程池创建线程。

public class H {

    public static void main(String[] args) {

        // 1.创建一个单线程的线程池,这个线程池只有一个线程在工作,即单线程执行任务,如果这个唯一的线程因为异常结束,那么就会有一个新的线程来替代它因此线程池保证所有的任务是按照任务的提交顺序来执行。
        // ExecutorService service = Executors.newSingleThreadScheduledExecutor();
        // 2.创建一个固定大小的线程池,每次提交一个任务就创建一个线程直到达到线程池的最大的大小,线程池的大小一旦达到最大就会保持不变,如果某个线程因为执行异常而结束,那么就会补充一个新的线程。
        // ExecutorService service = Executors.newFixedThreadPool(5);
        // 3.创建一个可以缓冲的线程池,如果线程大小超过处理任务所需的线程,那么就会回收部分线程,当线程数增加的时候此线程池不会对线程池大小做限制,线程池的大小完全依赖操作系统能够创建的做大做小。
        // ExecutorService service = Executors.newCachedThreadPool();
        // 4.周期性线程池创建,此线程池支持定时以及周期性的执行任务的需求。
        // 5.手动创建线程池。
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 10, 30L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<Runnable>(5), new RejectedExecutionHandler() {
                // 回调方法
                public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                    System.out.println("线程数超过了线程池容量,拒绝执行任务-->" + r);
                }
            });

        // 执行10个任务
        for (int i = 0; i < 10; i++) {
            threadPool.execute(new Runnable() {
                public void run() {
                    System.out.println("线程名是:" + Thread.currentThread().getName());
                }
            });
        }
    }
}

Thread 和 Runnable 两种开发线程的区别

  1. 继承Thread类,不能实现多个线程共享同一个实例资源,实现Runnable接口多个线程可以共享同一个资源。
  2. 继承Thread类不能继承其他类,有单继承局限性,实现Runnable接,还可以继承其他类。

Runnable 和 Callable 区别

  1. Runnable接口和Callable接口都可以用来创建新线程,实现Runnablel的时候,需要实现run方法;实现Callable 接口的话,需要实现call方法。
  2. Runnable的run方法无返回值,Callable的call方法有返回值,类型为Object。
  3. Callable中可以够抛出checked exception,而Runnable不可以。

FutureTask 和 Callable 示例

Callable<String> callable = () -> {
    System.out.println("Entered Callable");
    Thread.sleep(2000);
    return "Hello from Callable";
};
FutureTask<String> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println("Do something else while callable is getting executed");
System.out.println("Retrieved:" + futureTask.get());

线程池和Callable的示例

ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<String> callable = () -> {
    System.out.println("Entered Callable");
    Thread.sleep(2000);
    return "Hello from Callable";
};
System.out.println("Submitting Callable");
Future<String> future = executor.submit(callable);
System.out.println("Do something else while callable is getting executed");
System.out.println("Retrieved:" + future.get());
executor.shutdown();

6、什么是线程池,如何实现的

顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。

为什么要使用线程池

  1. 减少了创建和销毁线程的次数,每个工作线程都可以被重复使用或利用,可以并发执行多个任务。
  2. 可以根据系统的承受能力,调整线程池中的工作数目,防止消耗过多的内存而使服务器宕机。

线程池的使用

(1)通过 Executor 框架的工具类 Executors 来创建

Executors的创建线程池的方法,创建出来的线程池都实现了ExecutorService:接口。常用方法有以下几个:

  • newFiexedThreadPool(int Threads)创建固定数目线程的线程池。
  • newSingleThreadExecutor()创建一个单线程化的Executor。
  • newCachedThreadPool0:创建一个可缓存的线程池,调用execute将重用以前构造的线程。如果没有可用的线程,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有60秒钟未被使用的线程。
  • newScheduledThreadPool(int corePoolSize)创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类。

(2)通过ThreadPoolExecutor构造函数来创建

ExecutorService executor = new ThreadPoolExecutor(10, 10, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(10));

(3)ThreadPoolTaskExecutor是对ThreadPoolExecutor进行了封装处理

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 最大线程数
executor.setCorePoolSize(corePoolSize);
// 核心线程数
executor.setMaxPoolSize(maxPoolSize);
// 任务队列的大小
executor.setQueueCapacity(queueCapacity);
// 线程池名的前缀
executor.setThreadNamePrefix(namePrefix);

7、线程池处理任务的流程了解吗

在这里插入图片描述
线程池的拒绝策略有那些

  • AbortPolicy(默认),直接抛出一个类型为 RejectedExecutionException 的 RuntimeException异常阻止系统的正常运行。
  • DiscardPolicy:直接丢弃任务,不给予任何处理也不抛出异常。如果允许任务丢失的话,这是最好的方案。
  • DiscardOldestPolicy,抛弃队列中等待时间最长的任务,然后把当前任务加入队列中尝试再次提交任务。
  • CallerRunsPolicy:"调用者运行"一种调节机制,该策略既不会抛弃任务也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。

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

在这里插入图片描述

8、线程数设定成多少更合适

一般情况下,需要根据你的任务情况来设置线程数,任务可能是两种类型,分别是CPU密集型和IO密集型。

  • 如果是CPU密集型应用,则线程池大小设置为N+1
  • 如果是IO密集型应用,则线程池大小设置为2N+1

CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行。CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程),而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那些。IO包括数据库交互,文件上传下载,网络传输等。

9、什么是ThreadLocal,如何实现的

ThreadLocal 是用来解决java多线程程序中并发问题的一种途径,是java中的一个线程本地变量,在多线程环境下维护每个下线程的独立数据副本,通过为每一个线程创建一份共享变量的副本来保证各个线程之间的变量的访问和修改互相不影响。

ThreadLocal有四个方法,分别为:

  • initialValue:返回此线程局部变量的初始值。
  • get:返回此线程局部变量的当前线程副本中的值。如果线程第一次调用该方法,则创建并初始化此副本。
  • set:将此线程局部变量的当前线程副本中的值设置为指定值。许多应用程序不需要这项功能,它们只依赖于
    initialValue方法来设置线程局部变量的值。
  • remove:移除此线程局部变量的值。

ThreadLocal原理

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 上,每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。

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

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

ThreadLocal 数据结构如下图所示:

在这里插入图片描述
ThreadLocal中用于保存线程的独有变量的数据结构是一个内部类:ThreadLocalMap,也是k-v结构key就是当前的ThreadLoacaly对象,而v就是我们想要保存的值。

在这里插入图片描述
上图中基本描述出了Thread、ThreadLocalMapl以及ThreadLocal三者之间的包含关系。

ThreadLocal 内存泄露问题

了解了ThreadLocal的基本原理之后,我们把上面的图补全,从堆栈的视角整体看一下他们之间的引用关系。

在这里插入图片描述

ThreadLocal对象,是有两个引用的,一个是栈上的ThreadLocal引用一个是ThreadLocalMap中的Key对他的引用。

那么,假如,栈上的ThreadLocal引用不在使用了,即方法结束后这个对象引用就不再用了,那么,ThreadLocal
对象因为还有一条引用链在,所以就会导致他无法被回收,久而久之可能就会对导致OOM。这就是我们所说的ThreadLocal的内存泄露问题。

在这里插入图片描述

原因是 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。他的生命周期是和Thread 一样的,也就是说,只要这个Thread还在,这个对象就无法被回收。

那么,什么情况下,Thread会一直在呢?那就是线程池。

在线程池中,重复利用线程的时候,就会导致这个引用一直在,而value就一直无法被回收。那么如何解决呢?

ThreadLocalMap底层使用数组来保存元素,使用"线性探测法”来解决hash冲突的,在每次调用ThreadLocal的
get、set、remove等方法的时候,内部会实际调用ThreadLocalMap的get、set、remove等操作。而ThreadLocalMap的每次get、set、remove,都会清理过期的Entry。.

所以,当我们在一个ThreadLocall用完之后,手动调用一下remove,就可以在下一次GC的时候,把Entryi清理
掉。

10、父子线程之间怎么共享数据

当我们在同一个线程中,想要共享变量的话,是可以直接使用ThreadLocal的,但是如果在父子线程之间,共享变
量,ThreadLocal就不行了。

public class TestYang {
    public static ThreadLocal<Integer> sharedData = new ThreadLocal<>();

    public static void main(String[] args) {
        sharedData.set(0);// 主线程设置 0
        MyThread thread = new MyThread(); // 定义子线程
        thread.start();// 开启子线程
        sharedData.set(sharedData.get() + 1); // 主线程设置 1
        System.out.println("sharedData in main thread:" + sharedData.get());// 获取主线程的值 1
    }

    static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("sharedData in child thread:" + sharedData.get());// null
            sharedData.set(sharedData.get() + 1);
            System.out.println("sharedData in child thread after increment:" + sharedData.get());
        }
    }
}

因为ThreadLocal变量是为每个线程提供了独立的副本,因比不同线程之间只能访问它们自己的副本。那么,想要实现数据共享,主要有两个办法,第一个是自己传递,第二个是借助InheritableThreadLocal

InheritableThreadLocal

与ThreadLocal不同,InheritableThreadLocal可以在子线程中继承父线程中的值。在创建子线程时,子线程将复制父线程中的InheritableThreadLocal变量。我们把开头的示例中ThreadLocal改成InheritableThreadLocal就可以了:

public class TestYang {
    public static InheritableThreadLocal<Integer> sharedData = new InheritableThreadLocal<>();

    public static void main(String[] args) {
        sharedData.set(0);// 主线程设置 0
        MyThread thread = new MyThread(); // 定义子线程
        thread.start();// 开启子线程
        sharedData.set(sharedData.get() + 1); // 主线程设置 1
        System.out.println("sharedData in main thread:" + sharedData.get());// 获取主线程的值 1
    }

    static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("sharedData in child thread:" + sharedData.get());// 0
            sharedData.set(sharedData.get() + 1);// 1
            System.out.println("sharedData in child thread after increment:" + sharedData.get());// 1
        }
    }
}

11、讲述并发编程中的原子性、可见性、有序性

原子性是指一个操作或者多个操作被当作一个整体来执行,这些操作要么全部执行成功,要么全部不执行,不存在部分执行的情况,也就是 “操作不可拆分、不被中断”。原子性可以保证同时访问共享数据时不会发生冲突,确保每个线程对该数据的操作都是完整的。在数据库中,事务的ACD中原子性指的是 “要么都执行要么都回滚”。

可见性是指一个线程对共享数据的修改对其他线程是可见的,在并发编程中由于多个线程同时访问共享数据可能会导致数据的不一致性,为了保证数据的正确性,需要保证一个线程对共享数据的修改对于其他线程是可见的。

有序性是指程序在执行过程中指令的执行顺序不会被重排,保证程序的逻辑正确性,在并发编程中由于多个线程同时执行指令,可能会导致指令重排的问题。

12、线程同步的方式有哪些

线程同步指的就是让多个线程之间按照顺序访问同一个共享资源,避免因为并发冲突导致的问题,主要有以下几种
方式:

  • synchronized:Java中最基本的线程同步机制,可以修饰代码块或方法,保证同一时间只有一个线程访问该代码块或方法,其他线程需要等待锁的释放。

  • ReentrantLock:与synchronized关键字类似,也可以保证同一时间只有一个线程访问共享资源,但是更灵活,支持公平锁、可中断锁、多个条件变量等功能。

  • CountDownLatch:允许一个或多个线程等待其他线程执行完毕之后再执行,用于线程之间的协调和通信。

  • Semaphore:允许多个线程同时访问共享资源,但是限制访问的线程数量。用于控制并发访问的线程数量,避免系统资源被过度占用。

  • CyclicBarrier类:允许多个线程在一个栅栏处等待,直到所有线程都到达栅栏位置之后,才会继续执行。

  • Phaser:与CyclicBarrier类似,也是一种多线程同步工具,但是支特更灵活的栅栏操作,可以动态地注册和注销参与者,并可以控制各个参与者的到达和离开。

13、synchronized 是怎么实现的

synchronized 同步语句块的情况

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

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

在这里插入图片描述
对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

在这里插入图片描述
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰方法的的情况

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

JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

synchronized 用法

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

synchronized 关键字最主要的三种使用方式:

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

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

synchronized void method() {
  //业务代码
}
  1. 修饰静态方法(锁当前类)

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

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

synchronized void staic method() {
  //业务代码
}
  1. 修饰代码块 (锁指定对象/类)

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

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

14、synchronized 是如何保证原子性、可见性、有序性的

原子性

synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在
Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

有序性

由于synchronized修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。

可见性

可见性是指当多个线程问同一个变量时,一个线程修改了这个变量的值,其他线程能的够立即看得到修改的值。

被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,有一条规则是
这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的
值,其值是具有可见性的。

15、synchronized 的锁升级过程是怎样的

在JDK1.6之后,synchronized锁的实现发生了一些变化,引入了"偏向锁”、"轻量级锁”和"重量级锁”三种不同的状态,用来适应不同场景下的锁竞争情况。

  1. 无锁状态:当对象锁被创建出来时,在线程获得该对象锁之前,对象处于无锁状态。
  2. 偏向锁:偏向锁是指在只有一个线程访问对象的情况下,该线程不需要使用同步操作就可以访问对象。在这种情况下,如果其他线程访问该对象,会先检查该对象的偏向锁标识,如果和自己的线程ID相同,则直接获取锁。如果不同,就是发生了锁竞争,则该对象的锁状态就会升级到轻量级锁状态。
  3. 轻量级锁:就是CAS,它会让线程线程在那里自旋,在OpenJDK8中,轻量级锁的自旋默认是开启的,最多自旋15次,如果15次自旋后仍然没有获取到锁,就会升级为重量级锁。
  4. 重量级锁:如果一个线程想要获取该对象的锁,则需要先进入等待队列,等待该锁被释放。当锁被释放时,JVM会从等待队列中选择一个线程唤醒,并将该线程的状态设置为“就绪”状态,然后等待该线程重新获取该对象的锁。

16、什么是锁消除和锁粗化

  1. 锁消除

比如StringBuffer的append方法,因为append方法需要判断对象是否被占用,而如果代码不存在锁竞争,那么这部分的性能消耗是无意义的。于是虚拟机在即时编译的时候就会将上面的代码进行优化,也就是锁消除。

@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}
  1. 锁粗化

当发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同
步的范围散(粗化)到整个操作序列的外部。

for(inti=0;i<100000;i++){
	synchronized(this){
		do();
	}
}

会被粗化成:

synchronized(this){
	for(inti=0;i<100000;i++){
		do();
	}
}

17、synchronized 和 reentrantLock 区别

相同点是都是可重入锁,不同点如下

  1. synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。
  2. synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁,而 ReentrantLock 需要手动加锁和释放锁。
  3. synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是通过 AQS 程序级别的 API 实现。
  4. synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。默认非公平锁。

怎么创建公平锁

new ReentrantLock()默认创建的为非公平锁,如果要创建公平锁可以使用new ReentrantLock(true)。

lock()和lockInterruptibly()的区别

lock和lockInterruptibly的区别在于获取锁的途中如果所在的线程中断,Iock会忽略异常继续等待获取锁,而lockInterruptibly则会抛出InterruptedException异常。

tryLock()

tryLock(5,TimeUnit.SECONDS)表示获取锁的最大等待时间为5秒,期间会一直尝试获取,而不是等待5秒之后再去获取锁。

reentrantLock 底层原理

ReentrantLock asd = new ReentrantLock();

ReentrantLock 锁是一个轻量级锁,底层其实就是用自旋锁实现的,当我们调用 lock 方法的时候,在内部其实调用了 Sync.lock 方法,Sync 继承了 AQS,AQS 内部有一个 volatile 类型的 state 属性,实际上多线程对锁的竞争体现在对 state 值写入的竞争。一旦 state 从 0 变为 1,代表有线程已经竞争到锁,那么其它线程则进入等待队列。通过CAS修改了 state,修改成功标志自己成功获取锁。如果CAS失败的话,调用 acquire 方法

AQS 的 lock 有两个实现方法,一个在 ReentrantLock 非公平锁,一个在公平锁,非公平锁调用 lock 方法通过 CAS 去更新 AQS 的 state 的值(锁的状态值),更新成功就是获得锁可以执行。更新不成功就将没获得锁的线程放入链表尾部,自旋等待状态被释放,释放了,用CAS获得锁

// 所以在底层调用的其实是AQS的lock()方法,
asd.lock();

18、公平锁和非公平锁有什么区别

公平锁:每个线程获取锁的顺序是按照线程访问锁的先后顺序获取的,最前面的线程总是最先获取到锁。非公平锁:每个线程获取锁的顺序是随机的,并不会遵循先来先得的规则,所有线程会竞争获取锁。

在 Java 语言中,锁 synchronized 和 ReentrantLock 默认都是非公平锁,当然我们在创建 ReentrantLock 时,可以手动指定其为公平锁,但 synchronized 只能为非公平锁。

19、CountDownLatch、CyclicBarrier、Semaphore

CountDownLatch、CyclicBarrier、Semaphore都是ava并发库中的同步辅助类,它们都可以用来协调多个线程
之间的执行。

  • CountDownLatch是一个计数器,它允许一个或多个线程等待其他线程完成操作。它通常用来实现一个线程等待其他多个线程完成操作之后再继续执行的操作。
  • CyclicBarrier是一个同步屏障,它允许多个线程相互等待,直到到达某个公共屏障点,才能继续执行。它通常用来实现多个线程在同一个屏障处等待,然后再一起继续执行的操作。
  • Semaphore是一个计数信号量,它允许多个线程同时访问共享资源,并通过计数器来控制访方问数量。它通常用来实现一个线程需要等待获取一个许可证才能访问共享资源,或者需要释放一个许可证才能完成操作的操作。

CountDownLatch和CyclicBarrier区别

  1. CountDownLatch的计数器只能使用一次,CyclicBarrier的计数器可以使用reset方法进行重置并且可以循环使用。
  2. CountDownLatch主要实现1个或n个线程需要等待其他线程完成某项操作之后才能继续往下执行,描述的是1个或n个线程等待其他线程的关系,CyclicBarrier主要实现了多个线程之间相互等待,直到所有线程都满足了条件之后才能继续执行后续的操作,描述的是各个线程内部相互等待的关系。
  3. CyclicBarrier能够处理更复杂的场景,如果计算发生错误可以重置计数器让线程重新执行一次
  4. CyclicBarrier中提供了很多有用的方法,比如可以通过getNumber Waiting方法获取阻塞的线程数量,通过isBroken方法判断阻塞的线程是否被中断

20、有三个线程T1、T2、T3如何保证顺序执行

想要让三个线程依次执行,并且严格按照T1,T2,T3的顺序的话,主要就是想办法让三个线程之间可以通信、或者可以排队。

想让多个线程之间可以通信,可以通过join方法实现,还可以通过CountDownLatch、CyclicBarrier和Semaphore来实现通信。想要让线程之间排队的话,可以通过线程池或者CompletableFuturel的方式来实现。

join

final Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "is Running.");
            }
        }, "T1");

        final Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    thread1.join();
                } catch (InterruptedException e) {
                    System.out.println("join thread1 failed");
                }
                System.out.println(Thread.currentThread().getName() + "is Running.");
            }
        }, "T2");

        Thread thread3 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    thread2.join();
                } catch (InterruptedException e) {
                    System.out.println("join thread1 failed");
                }
                System.out.println(Thread.currentThread().getName() + "is Running.");
            }
        }, "T3");
        thread3.start();
        thread2.start();
        thread1.start();

CountDownLatch

AQS中的CountDownLatch并发工具类,通过它可以阻塞当前线程,也就是说能够实现一个线程或者多个线程的一直等待,直到其他线程执行的操作完成,使用一个给定的计数器进行初始化,该技术器的操作是原子操作,即同时只能有一个线程操作该计数器,调用该类的await方法的线程会一直阻塞直到其他线程调用该类的countDown方法使当前计数器的值变为0为止,每次调用该类的countDown方法当前计数器的值都会减1,当计数器的值减为0的时候所欲因调用await方法而出处于等待状态的线程就会继续往下执行,这种操作只能出现一次,因为该类的计数器不能被重置,如果需要一个可以重置的计数次数的版本可以考虑使用CyclicBarrier类,CountDownLatch支持给定时间等待,超过一定时间不再等待,使用时只需要在CountDownLatch方法中传入需要等待的时间即可,使用场景:在程序执行需要等待某个条件完成后才能继续执行后续的操作,典型的应用为并行计算,当某个处理的运算量很大时可以将该运算拆分成多个子任务,等待所有的子任务都完成后,父任务再拿到所有子任务的运算计算结果汇总。

 // 创建CountDownLatch对象,用来做线程通信
        CountDownLatch latch = new CountDownLatch(1);
        CountDownLatch latch2 = new CountDownLatch(1);
        CountDownLatch latch3 = new CountDownLatch(1);
        // 创建并启动线程T1
        Thread t1 = new Thread(new MyThread(latch), "T1");
        t1.start();
        // 等待线程T1执行完
        latch.await();
        // 创建并启动线程T2
        Thread t2 = new Thread(new MyThread(latch2), "T2");
        t2.start();
        // 等待线程T2执行完
        latch2.await();
        // 创建并启动线程T3
        Thread t3 = new Thread(new MyThread(latch3), "T3");
        t3.start();
        // 等待线程T3执行完
        latch3.await();
    }
}

class MyThread implements Runnable {
    private CountDownLatch latch;

    public MyThread(CountDownLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
        try {
            // 模拟执行任务
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "is Running.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 完成一个线程,计数器减1
            latch.countDown();
        }
    }

CyclicBarrier

允许一组线程相互等待直到到达某个公共的屏障点,通过它可以完成多个线程之间的相互等待,只有当每个线程都准备就绪后才能各自继续往下执行后面的操作,与CountDownLatch有相似的地方都是使用计数器实现,当某个线程调用了CyclicBarrier的await方法后,该线程就进入了等待状态,而且计数器执行加1操作,当计数器的值达到了设置的初始值调用await方法进入等待状态的线程会被唤醒继续执行各自后续的操作,CyclicBarrier在释放等待线程后可以重复使用,所以,CyclicBarrier又被称为循环屏障,使用场景:CyclicBarrier可以用于多线程计算数据,最后合并计算结果的场景。

// CyclicBarrier,用来做线程通信
        CyclicBarrier barrier = new CyclicBarrier(2);
        // 创建并启动线程T1
        Thread t1 = new Thread(new MyThread(barrier), "T1");
        t1.start();
        // 等待线程T1执行完
        barrier.await();
        // 创建并启动线程T2
        Thread t2 = new Thread(new MyThread(barrier), "T2");
        t2.start();
        // 等待线程T2执行完
        barrier.await();
        // 创建并启动线程T3
        Thread t3 = new Thread(new MyThread(barrier), "T3");
        t3.start();
        // 等待线程T3执行完
        barrier.await();
    }
}

class MyThread implements Runnable {
    private CyclicBarrier barrier;

    public MyThread(CyclicBarrier barrier) {
        this.barrier = barrier;
    }

    @Override
    public void run() {
        try {
            // 模拟执行任务
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "is Running.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 等待其他线程完成
            try {
                barrier.await();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

Semaphore

控制同一时间并发线程的数量,能够完成对于信号量的控制,可以控制某个资源同时访问的个数,提供了两个核心方法acquire和release方法,acquire方法表示获取一个许可,如果没有则等待,release方法则是在操作完成后释放对应的许可,Semaphore维护了当前访问的个数,通过提供同步机制来控制同时访问的个数,Semaphore可以实现有限大小的链表,使用场景:常用于仅能提供有限访问资源的业务场景,比如数据库连接数。业务请求并发太高已经超过了系统并发处理的阈值,对超过上限的请求进行丢弃处理。

 // Semaphore,用来做线程通信
        Semaphore semaphore = new Semaphore(1);
        // 创建并启动线程T1
        Thread t1 = new Thread(new MyThread(semaphore), "T1");
        t1.start();
        // 等待线程T1执行完
        semaphore.acquire();
        // 创建并启动线程T2
        Thread t2 = new Thread(new MyThread(semaphore), "T2");
        t2.start();
        // 等待线程T2执行完
        semaphore.acquire();
        // 创建并启动线程T3
        Thread t3 = new Thread(new MyThread(semaphore), "T3");
        t3.start();
        // 等待线程T3执行完
        semaphore.acquire();
    }
}

class MyThread implements Runnable {
    private Semaphore semaphore;

    public MyThread(Semaphore semaphore) {
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            // 模拟执行任务
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "is Running.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 释放许可证,表示完成以一个线程
            semaphore.release();

        }
    }

使用线程池

    public static void main(String[] args) throws InterruptedException, BrokenBarrierException {
        // 创建线程池
        ExecutorService executor = Executors.newSingleThreadExecutor();
        // 创建并启动线程T1
        executor.submit(new MyThread("T1"));
        // 创建并启动线程T2
        executor.submit(new MyThread("T2"));
        // 创建并启动线程T3
        executor.submit(new MyThread("T3"));
        // 关闭线程池
        executor.shutdown();
    }
}

class MyThread implements Runnable {
    private String name;

    public MyThread(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        try {
            // 模拟执行任务
            Thread.sleep(1000);
            System.out.println(name + "is Running.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

CompletableFuture

public class TestYang {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //创建CompletableFuture对象
        CompletableFuture<Void> future = CompletableFuture.runAsync(new MyThread("T1")).thenRun(new MyThread("T2")).thenRun(new MyThread("T3"));
        future.get();
    }
}

    class MyThread implements Runnable {
        private String name;

        public MyThread(String name) {
            this.name = name;
        }

        @Override
        public void run() {
            try {
                // 模拟执行任务
                Thread.sleep(1000);
                System.out.println(name + "is Running.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
}

21、volatile 是如何保证可见性和有序性的

volatile和可见性

对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中
的变量回写到系统主存中。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓
存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发
编程中,其值在多个缓存中是可见的。

volatile和有序性

volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等。

普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋
值操作的顺序与程序代码中的执行顺序一致。

volatile是通过内存屏障来禁止指令重排的,这就保证了代码的程序会严格按照代码的先后顺序执行。

22、volatile 能保证原子性吗,为什么

不能,为什么volatile不能保证原子性呢?因为他不是锁,他没做任何可以保证原子性的处理。当然就不能保证原
子性了。

我们通过下面的代码即可证明:

public class VolatoleAtomicityDemo {
    public volatile static int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) throws InterruptedException {
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo();
        for (int i = 0; i < 5; i++) {
            threadPool.execute(() -> {
                for (int j = 0; j < 500; j++) {
                    volatoleAtomicityDemo.increase();
                }
            });
        }
        // 等待1.5秒,保证上面程序执行完成
        Thread.sleep(1500);
        System.out.println(inc);
        threadPool.shutdown();
    }
}

正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500。

为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!

也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  1. 读取 inc 的值。
  2. 对 inc 加 1。
  3. 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized 、Lock或者AtomicInteger都可以。

使用 synchronized 改进

public synchronized void increase() {
    inc++;
}

使用 AtomicInteger 改进

public AtomicInteger inc = new AtomicInteger();

public void increase() {
    inc.getAndIncrement();
}

使用 ReentrantLock 改进

Lock lock = new ReentrantLock();
public void increase() {
    lock.lock();
    try {
        inc++;
    } finally {
        lock.unlock();
    }
}

23、synchronized 和 volatile 有什么区别

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

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

24、什么是死锁,如何解决

死锁是指两个或两个以上的进程(或线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现
象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的
进程称为死锁进程。

产生死锁的四个必要条件

  1. 互斥条件:一个资源每次只能被一个进程使用。
  2. 占有且等待:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不可强行占有:进程已获得的资源,在末使用完之前,不能强行剥夺。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

如何解除死锁

  • 破坏不可抢占:设置优先级,使优先级高的可以抢占资源。
  • 破坏循环等待:保证多个进程(线程)的执行顺序相同即可避免循环等待。

数据库死锁的发生

在数据库中,如果有多个事务并发执行,也是可能发生死锁的。当事务1持有资源八的锁,但是尝试获取资源B的
锁,而事务2持有资源B的锁,尝试获取资源A的锁的时候,这时候就会发生死锁的情况发生死锁时,会发生如下异常:

Error updating database.Cause:ERR-CODE:[TDDL-4614][ERR_EXECUTE_ON_MYSQL]
Deadlock found when trying to get lock;

25、什么是CAS,存在什么问题

CAS是一项乐观锁技术,是Compare And Swapl的简称,顾名思义就是先比较再替换。CAS操作包含三个操作数
一内存位置(V)、预期原值(A)和新值(B)。在进行并发修改的时候,会先比较A和V的值是否相等,如果相等,则会把值替换成B,否则就不做任何操作。

当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS会导致ABA问题

CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。

比如说一个线程1从内存位置V中取出A,这时候另一个线程2也从内存中取出A,并且2进行了一些操作变成了B,然后又将V位置的数据变成A,这时候线程1进行CAS操作发现内存中仍然是A,然后1操作成功。尽管线程1的
CAS操作成功,但是不代表这个过程就是没有问题的。

部分乐观锁的实现是通过版本号(version)的方式来解决ABA问题,乐观锁每次在执行数据的修改操作时,都会
带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1操作,否则就执行失
败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加不会减少。

忙等待

因为CAS基本都是要自旋的,这种情况下,如果并发冲突比较大的话,就会导致CAS一直在不断地重复执行,就会进入忙等待。所以,一旦CAS进入忙等待状态一直执行不成功的话,会对CPU造成较大的执行开销。

Java中CAS的使用

Java中大量使用的CAS,比如java.util.concurrent.atomic包下有很多的原子类AtomicInteger、AtomicBoolean…这些类提供对int、boolean等类型的原子操作,而底层就是通过CAS机制实现的。

比如AtomicInteger类有一个实例方法,叫做incrementAndGet,这个方法就是将AtomicInteger对象记录的值+1并返回,与i++类似。但是这是一个原子操作,不会像i++一样,存在线程不一致问题,因为i++不是原子操作。比如如下代码,最终一定能够保证num的值为200:

// 声明一个AtomicInteger对象
AtomicInteger num = new AtomicInteger(0);
// 线程1
new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        // num++
        num.incrementAndGet();
    }
}).start();
// 线程2
new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        // num++
        num.incrementAndGet();
    }
}).start();

Thread.sleep(1000);
System.out.println(num);

26、如何理解AQS

AQS就是AbstractQueuedSynchronizer抽象类,AQS其实就是JUC包下的一个基类,JUC下的很多内容都是基于AQS实现了部分功能,比如ReentrantLock,ThreadPoolExecutor,阻塞队列,CountDownLatch,Semaphore,CyclicBarrier等等都是基于AQS实现。

(1)首先AQS中提供了一个由volatile修饰,并且采用CAS方式修改的int类型的state变量。

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
	implements java.io.Serializable {
    // 同步state成员变量,0表示无人占用锁,大于1表示锁被占用需要等待;
    private volatile int state;
    
    /*CLH队列
     * <pre>
     *      +------+  prev +-----+       +-----+
     * head |      | <---- |     | <---- |     |  tail
     *      +------+       +-----+       +-----+
     * </pre>
     */
    // 通过state自旋判断是否阻塞,阻塞的线程放入队列,尾部入队,头部出队
    static final class Node{}
    private transient volatile Node head;
    private transient volatile Node tail;
}

(2)其次AQS中维护了一个双向链表,有head,有tail,并且每个节点都是Node对象

static final class Node {
		// 表示线程以共享的模式等待锁
        static final Node SHARED = new Node();
        // 表示线程以独占的方式等待锁
        static final Node EXCLUSIVE = null;
        //表示线程获取锁的请求已经取消了
        static final int CANCELLED =  1;
        //表示线程准备解锁
        static final int SIGNAL    = -1;
        // 表示节点在等待队列红,节点线等待唤醒
        static final int CONDITION = -2;
        // 表当前节点线程处于共享模式,锁可以传递下去
        static final int PROPAGATE = -3;
   		// 表示节点在队列中的状态
     	volatile int waitStatus;
    	volatile Node prev;// 前序指针
    	volatile Node next;// 后序指针
    	volatile Thread thread; // 当前节点的线程
    	Node nextWaiter;// 指向下一个处于Condition状态的节点
    	final Node predecessor();//一个方法,返回前序节点prev,没有的话抛出NPE(空指针异常)
}

AQS的核心原理
在这里插入图片描述

当多线程访问共享资源(state)时,流程如下:

  1. 当线程1、2、3通过cas获取state时,如果线程1获取到了资源的使用权,令当前锁的owenerThread设置为当前线程,state+1。
  2. 线程2、3未获取到共享资源,将会被加入等待队列(CLH 双端链表队列)。
  3. 当线程1释放state时,令当前锁的ownerThread设置为null,state-1。
  4. 这里要根据是否是公平锁来竞争资源,如果是公平锁,将会按照等待队列的顺序依次获取资源。如果不是公平锁,等待队列中的第一个线程将会和新进来的线程竞争获取资源。

在这里插入图片描述

AQS唤醒节点为何从后往前找

node节点在插入整个AQS队列当中时是先把当前节点的上一个指针指向前面的节点,再把tail指向自己,这个时候会有一个CPU调度问题,如果这个时候我卡在这个位置,那么从前往后找就会造成节点丢失,就会出现找到空的节点的问题无法实现有效的线程唤醒导致出现死锁的问题

aqs中的取消节点的方法,cancelAcquire也是先去调整上一个指针的指向,next指针后续才动,所以无论是我们节点插入的过程还是某一个节点取消个更改指针的过程,都是先动上一个指针再动next的,所以prex这个节点指向相对来说优先级更高或者时效性更好。

总结因为从前往后极大可能错过某一个节点,从而造成某一个node在那边被挂起了,但是你之前的线程已经释放资源了并没有被唤醒,造成锁饥饿问题,总的来说AQS在唤醒节点时候从后往前找只要是为了找一个有效且可以被唤醒的接点,来保证并发程序的效率。

公平锁与非公平锁的区别

公平锁:多个线程按照线程调用lock()的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

27、什么是 Java 内存模型

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

在这里插入图片描述

所以,再来总结下,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

Java内存模型的实现

在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

并发编程要解决原子性、有序性和一致性的问题,我们就再来看下,在Java中,分别使用什么方式来保证。

原子性

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorentermonitorexit,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronized关键字也可以实现可见性。只不过实现方式不同。

有序性

在Java中,可以使用synchronizedvolatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

28、三个线程分别顺序打印 0-100

public class H {
    private static volatile int count = 0;

    public void yang() {
        Runnable a = () -> {
            while (count <= 100) {
                synchronized (this) {
                    String s = Thread.currentThread().getName().split("-")[1];
                    try {
                        while (count % 3 != Integer.parseInt(s)) {
                            this.wait();
                        }
                        if (count <= 100) {
                            System.out.println(Thread.currentThread().getName() + ":" + count++);
                        }
                        this.notifyAll();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        Thread thread0 = new Thread(a);
        Thread thread1 = new Thread(a);
        Thread thread2 = new Thread(a);
        thread0.start();
        thread1.start();
        thread2.start();
    }

    public static void main(String[] args) {
        H h = new H();
        h.yang();
    }
}

--------------------------------------------------MySQL--------------------------------------------------

1、什么是关系型数据库,什么是非关系型数据库

关系型数据库,是指采用了关系模型来组织数据的数据库(关系模型可以简单理解为二维表格模型),其以行和列的形式存储数据,以便于用户管理。关系型数据库中有表的概念,表中包含了行和列,多张(或1张)表可以组成数据库。

非关系型数据库的存储方式是基于键值来存诸的,对于值的类型也有不同的支特,所以没有固定的要求和
限制。

  1. 关系型数据库以表的形式进行存储数据,而非关系型数据库以Key-value的形式存储数据。
  2. 关系型数据库需要保证事务的ACID,而非关系型数据库中的事务一般无法回滚。(也有部分数据库可
    以回滚,如MongoDB在集群模式下)
  3. 关系型数据库可以通过一张表中的任意字段进行查询,非关系型数据库需要通过key进行查询。
  4. 一般来说,关系型数据库是基于硬盘存储,非关系型数据库基于内存存储。(Mongodb基于磁盘存
    储)
  5. 关系型数据库支持各种范围查询、公式计算等,非关系型数据库不一定支持。

2、什么是数据库存储引擎

数据库引擎是用于存储、处理和保护数据的核心服务。利用数据库引擎可控制访问权限并快速处理事务,从而满足
企业内大多数需要处理大量数据的应用程序的要求。

查看看某个表用了什么引擎:show create table 表名,在显示结果里参数engine后面的就表示该表当前用的存储
引擎。

MySQL的存储引擎是基于表的还是基于数据库的

MySQL的数据存储一定是基于硬盘的吗

不是的,MySQL也可以基于内存的,即MySQL的内存表技术。它允许将数据和索引存储在内存中,从而提高了检
索速度和修改数据的效率。优点包括具有快速响应的查询性能和节约硬盘存储空间。此外,使用内存表还可以实现
更高的复杂性,从而提高了MySQL的整体性能。

创建内存表与创建普通表一样,使用CREATE TABLE语句,但需要将存储擎设置为:ENGINE=MEMORY

InnoDB 与 MyISAM 的区别

MySQL 5.5以后的版本开始将InnoDB作为默认的存储引擎,之前的版本都是MyISAM。关于MyISAM和InnoDB的区别,我总结为以下5个方面:

  1. MyISAM不支持外键,InnoDB支持。
  2. MyISAM不支持事务,而InnoDB支持ACID特性的事务处理。
  3. MyISAM只支持表锁,而innoDB支持行级锁,删除插入的时候只需要锁定操作行就行。
  4. MyISAM不支持外键,而InoDB支持外键。
  5. MyISAM 不支持持数据库异常崩溃后的安全恢复,而 InnoDB 支持。使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 redo log 。

比如如果需要支持事务,那必须要选择InnoDB。如果大部分的表操作都是查询,可以选择MyISAM。

3、MySQL5.x和8.0有什么区别

  1. 窗口函数:从MySQL8.0开始,新增了一个叫窗口函数的概念,它可以用来实现若干新的查询方式。窗口函数与SUM、COUNT这种集合函数类似,但它不会将多行查询结果合并为一行,而是将结果放回多行当中。即窗口函数不需要GROUP BY。
语法 <窗口函数> over (partition by 分组字段 order by 排序字段)
  1. 隐藏索引:在MySQL8.0中,索引可以被“隐藏”和“显示”。当对索引进行隐藏时,它不会被查询优化器所使用。我们可以使用这个特性用于性能调试,例如我们先隐藏一个索引,然后观察其对数据库的影响。如果数据库性能有所下降,说明这个索引是有用的,然后将其“恢复显示”即可;如果数据库性能看不出变化,说明这个索引是多余的,可以考虑删掉。
ALTER TABLE tablename ALTER  INDEX  index_name INVISIBLE;  #隐藏索引
ALTER TABLE tablename ALTER  INDEX  index_name VISIBLE;    #取消隐藏
  1. 降序索引:MySQL8.0为索引提供按降序方式进行排序的支持,在这种索引中的值也会按降序的方式进行排序。
ALTER TABLE employee ADD INDEX idx_salary (salary DESC)
  1. UTF-8编码:从MySQL8开始,使用utf8mb4作为MySQL的默认字符集。

4、什么是数据库范式,为什么要反范式

  1. 第一范式是说,数据库表中的属性的原子性的,要求属性具有原子性,不可再被拆分。比如地址如果都细化拆分成省、市、区、街道、小区等等多个字段这就是符合第一范式的,如果地址就是一个字段,那就不符合了。
  2. 第二范式是说,数据库表中的每个实例或记录必须可以被唯一地区分,说白了就是要有主键,其他的字段都依赖于主键。
  3. 第三范式是说,任何非主属性不依赖于其它非主属性,也就是说,非主键外的所有字段必须互不依赖。

因为在遵守范式的数据库设计中,表中不能有任何冗余字段,这就使得查询的时候就会经常有多表关联查询,这无
疑是比较耗时的。于是就有了反范式化。所谓反范式化,是一种针对遵从设计范式的数据库的性能优化策略。反范式其实本质上是软件开发中一个比较典型的方案,那就是"用空间换时间",通过做一些数据冗余,来提升查询
速度。

5、MySQL 事务的四大特性以及实现原理

  • 原子性:原子性指的是:当前事务的操作要么同时成功,要么同时失败。原子性由undo log日志来保证,因为undo log记载着数据修改前的信息。

  • 隔离性:隔离性指的是:在事务「并发」执行时,他们内部的操作不能互相干扰。如果多个事务可以同时操作一个数据,那么就会产生脏读、重复读、幻读的问题。于是,事务与事务之间需要存在「一定」的隔离。在InnoDB引擎中,定义了四种隔离级别供我们使用,不同的隔离级别对事务之间的隔离性是不一样的(级别越高事务隔离性越好,但性能就越低),而隔离性是由MySQL的各种锁来实现的,只是它屏蔽了加锁的细节。

    • read uncommit(读未提交)
    • read commit (读已提交)
    • repeatable read (可重复复读)
    • serializable (串行)
  • 持久性:持久性指的就是:一旦提交了事务,它对数据库的改变就应该是永久性的。说白了就是,会将数据持久化在硬盘上。而持久性由redo log 日志来保证,当我们要修改数据时,MySQL是先把这条记录所在的「页」找到,然后把该页加载到内存中,将对应记录进行修改。为了防止内存修改完了,MySQL就挂掉了(如果内存改完,直接挂掉,那这次的修改相当于就丢失了)。MySQL引入了redo log,内存写完了,然后会写一份redo log,这份redo log记载着这次在某个页上做了什么修改。即便MySQL在中途挂了,我们还可以根据redo log来对数据进行恢复。

  • 一致性:回头再来讲一致性,「一致性」可以理解为我们使用事务的「目的」,而「隔离性」「原子性」「持久性」均是为了保障「一致性」的手段,保证一致性需要由应用程序代码来保证。比如,如果事务在发生的过程中,出现了异常情况,此时你就得回滚事务,而不是强行提交事务来导致数据不一致。

6、事务的隔离级别有哪些

  • 读未提交:事务B读取到了事务A还没提交的数据,这种用专业术语来说叫做「脏读」。对于锁的维度而言,其实就是在read uncommit隔离级别下,读不会加任何锁,而写会加排他锁。读什么锁都不加,这就让排他锁无法排它了。
    在这里插入图片描述
    而我们又知道,对于更新操作而言,InnoDB是肯定会加写锁的(数据库是不可能允许在同一时间,更新同一条记录的)。而读操作,如果不加任何锁,那就会造成上面的脏读。脏读在生产环境下肯定是无法接受的,那如果读加锁的话,那意味着:当更新数据的时,就没办法读取了,这会极大地降低数据库性能。在MySQL InnoDB引擎层面,又有新的解决方案(解决加锁后读写性能问题),叫做MVCC多版本并发控制。

在这里插入图片描述
在MVCC下,就可以做到读写不阻塞,且避免了类似脏读这样的问题。那MVCC是怎么做的呢?

MVCC通过生成数据快照(Snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取。回到事务隔离级别下,针对于 read commit (读已提交) 隔离级别,它生成的就是语句级快照,而针对于repeatable read (可重复读),它生成的就是事务级的快照

在这里插入图片描述

  • 读已提交:前面提到过read uncommit隔离级别下会产生脏读,而read commit (读已提交) 隔离级别解决了脏读。思想其实很简单:在读取的时候生成一个”版本号”,等到其他事务commit了之后,才会读取最新已commit的”版本号”数据。比如说:事务A读取了记录(生成版本号),事务B修改了记录(此时加了写锁),事务A再读取的时候,是依据最新的版本号来读取的(当事务B执行commit了之后,会生成一个新的版本号),如果事务B还没有commit,那事务A读取的还是之前版本号的数据。通过「版本」的概念,这样就解决了脏读的问题,而「版本」其实就是对应快照的数据。

  • 可重复读:read commit (读已提交) 解决了脏读,但也会有其他并发的问题。「不可重复读」:一个事务读取到另外一个事务已经提交的数据,也就是说一个事务可以看到其他事务所做的修改。不可重复读的例子:A查询数据库得到数据,B去修改数据库的数据,导致A多次查询数据库的结果都不一样【危害:A每次查询的结果都是受B的影响的】,了解MVCC基础之后,就很容易想到repeatable read (可重复复读)隔离级别是怎么避免不可重复读的问题了(前面也提到了)。repeatable read (可重复复读)隔离级别是「事务级别」的快照!每次读取的都是「当前事务的版本」,即使当前数据被其他事务修改了(commit),也只会读取当前事务版本的数据。
    在这里插入图片描述
    而repeatable read (可重复复读)隔离级别会存在幻读的问题,「幻读」指的是指在一个事务内读取到了别的事务插入的数据,导致前后读取不一致。在InnoDB引擎下的的repeatable read (可重复复读)隔离级别下,快照读MVCC影响下,已经解决了幻读的问题(因为它是读历史版本的数据)而如果是当前读(指的是 select * from table for update),则需要配合间隙锁来解决幻读的问题。

  • 串行化:剩下的就是serializable (串行)隔离级别了,它的最高的隔离级别,相当于不允许事务的并发,事务与事务之间执行是串行的,它的效率最低,但同时也是最安全的。

在Mysql里面,InnoDB引擎默认的隔离级别是RR(可重复读)。

一句话总结脏读、不可重复读、幻读

  1. 脏读:读到了其他事务还没有提交的数据。
  2. 不可重复读:对某数据进行读取过程中,有其他事务对数据进行了修改(UPDATE、DELET),导致第二次读取的结果不同。
  3. 幻读:事务在做范围查询过程中,有另外一个事务对范围内新增或删除了记录(NSERT、DELETE),导致范围查询的结果条数不一致。

7、为什么MySQL默认使用可重复读隔离级别

在这里插入图片描述
以上两个事务执行之后,数据库里面的记录会只有一条记录(10,99),这个发生在主库的数据变更大家都能理解。

以上两个事务执行之后,会在bin log中记录两条记录,因为事务2先提交,所以insert into t1 values(10,99)会被优先记录,然后再记录delete from t1 where b<100(再次提醒:statement格式的bin log记录的是SQL语句的原文)

这样bin log同步到备库之后,SQL语句回放时,会先执行insert into t1 values(16,99),再执行delete from t1 where b 100这时候,数据库中的数据就会变成EMPTY SET,即没有任何数据。这就导致主库和备库的数据不一致了,为了避免这样的问题发生。MySQL就把数据库的默认隔离级别设置成了Repetable Read。

Repetable Read这种隔离级别,会在更新数据的时候不仅对更新的行加行级锁,还会增加GAP锁和临键锁。上面的例子,在事务2执行的时候,因为事务1增加了GAP锁和临键锁,就会导致事务2执行被卡住,需要等事务1提交或者回滚后才能继续执行。

8、如何理解MVCC

MVCC的主要是通过read view和undo log来实现的

在这里插入图片描述
undo log前面也提到了,它会记录修改数据之前的信息,事务中的原子性就是通过undo log来实现的。所以,有undo log可以帮我们找到「版本」的数据,而read view 实际上就是在查询时,InnoDB会生成一个read view,read view 有几个重要的字段,分别是:trx_ids(尚未提交commit的事务版本号集合),up_limit_id(下一次要生成的事务ID值),low_limit_id(尚未提交版本号的事务ID最小值)以及creator_trx_id(当前的事务版本号)

在每行数据有两列隐藏的字段,分别是DB_TRX_ID(记录着当前ID)以及DB_ROLL_PTR(指向上一个版本数据在undo log 里的位置指针)铺垫到这了,很容易就发现,MVCC其实就是靠「比对版本」来实现读写不阻塞,而版本的数据存在于undo log中。而针对于不同的隔离级别(read commit和repeatable read),无非就是read commit隔离级别下,每次都获取一个新的read view,repeatable read隔离级别则每次事务只获取一个read view。

9、InnoDB的一次更新事务是怎么实现的

在这里插入图片描述

  1. 在Buffer Pool中读取数据:当InnoDB需要更新一条记录时,首先会在Buffer Pool中查找该记录是否在内存中。如果没有在内存中,则从磁盘读取该页到Buffer Pool中。
  2. 记录Undo Log:在修改操作前,InnoDB会在Undo Log中记录修改前的数据。Undo Log是用来保证事务原子性和一致性的一种机制,用于在发生事务回滚等情况时,将修改操作回滚到修改前的状态,以达到事务的原子性和一致性。Undo Log的写入最开始写到内存中的,然后由1个后台线程定时刷新到磁盘中的。
  3. 在Buffer Pool中更新:当执行update语句时,InnoDB会先更新已经读取到Buffer Pool中的数据,而不是直接写入磁盘。
  4. 记录Redo Log Buffer:InnoDB在Buffer Pool中记录修改操作的同时,InnoDB会先将修改操作写入到redo log buffer中。
  5. 提交事务:在执行完所有修改操作后,事务被提交。在提交事务时,InnoDB会将Redo Log:写入磁盘,以保证事务持久性。同时,InnoDB会将修改操作写入Buffer Pool中对应的页,并将页的状态设置为"脏页”(Dirty Page)状态,表示该页已经被修改但尚未写入磁盘。
  6. 写入磁盘:在提交事务后,InnoDB会将Buffer Pool中的脏页写入磁盘,以保证数据的持久性。但是这个写入过程并不是立即执行的,是有一个后台线程异步执行的,所以可能会延迟写入,总之就是SQL会选择合适的时机把数据写入磁盘做持久化。
  7. 记录Binlog:在提交事务后,InnoDB会将事务提交的信息记录到Binlog中。Binlog是MySQL用来实现主从复制的一种机制,用于将主库上的事务同步到从库上。在Binlog中记录的信息包括:事务开始的时间、数据库名、表名、事务D、SQL语句等。

10、InnoDB的锁机制

1、按锁的级别划分,可分为共享锁、排他锁

  • 共享锁就是多个事务只能读数据不能改数据。
  • 对于排他锁的理解可能就有些差别,以为排他锁锁住一行数据后,其他事务就不能读取和修改该行数据,其实不是这样。排他锁指的是一个事务在一行数据加上排他锁后,其他事务不能再在其上加其他的锁。
  • mysql InnoDB引擎默认的修改数据语句,update,delete,insert都会自动给涉及到的数据加上排他锁,select语句默认不会加任何锁类型,如果加排他锁可以使用select …for update语句,加共享锁可以使用select … lock in share mode语句。
  • 加过排他锁的数据行在其他事务种是不能修改数据的,也不能通过for update和lock in share mode锁的方式查询数据,但可以直接通过select …from…查询数据,因为普通查询没有任何锁机制。

共享锁

又称为读锁,简称S锁,顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
在这里插入图片描述
用法

在查询语句后面增加 LOCK IN SHARE MODE,Mysql会对查询结果中的每行都加共享锁。

SELECT ... LOCK IN SHARE MODE

验证

打开一个查询窗口,进行共享锁测试,给ID等于100的记录添加共享锁,SQL如下:

SELECT * FROM yang WHERE id = 100 LOCK IN SHARE MODE

在加了共享锁后,打开一个新的查询窗口,进行共享锁测试

SELECT * FROM yang WHERE id = 100 LOCK IN SHARE MODE;-- 使用共享锁 查询到数据
SELECT * FROM yang WHERE id = 100 FOR UPDATE; -- 1205 - 使用排它锁 Lock wait timeout exceeded; try restarting transaction
SELECT * FROM yang WHERE id = 100;-- 不加锁 查询到数据

排它锁

又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。
在这里插入图片描述

用法

在查询语句后面增加FOR UPDATE,Mysql会对查询结果中的每行都加排他锁

SELECT ... FOR UPDATE

验证

打开一个查询窗口,进行共享锁测试,给ID等于100的记录添加排它锁,SQL如下:

SELECT * FROM yang WHERE id = 100 FOR UPDATE

在加了排它锁后,打开一个新的查询窗口,进行排它锁测试

SELECT * FROM yang WHERE id = 100 LOCK IN SHARE MODE;-- 使用共享锁 Lock wait timeout exceeded; try restarting transaction
SELECT * FROM yang WHERE id = 100 FOR UPDATE; -- 1205 - 使用排它锁 Lock wait timeout exceeded; try restarting transaction
SELECT * FROM yang WHERE id = 100;-- 不加锁 查询到数据

2、按锁的粒度划分,可分为表级锁、行级锁

表锁

表锁是指上锁的时候锁住的是整个表,当下一个事务访问该表的时候,必须等前一个事务释放了锁才能进行对表进行访问。

特点: 粒度大,加锁简单,容易冲突
在这里插入图片描述
行锁

行锁是对所有行级别锁的一个统称,比如下面说的记录锁、间隙锁、临键锁都是属于行锁, 行锁是指加锁的时候锁住的是表的某一行或多行记录,多个事务访问同一张表时,只有被锁住的记录不能访问,其他的记录可正常访问。

特点:粒度小,加锁比表锁麻烦,不容易冲突,相比表锁支持的并发要高。
在这里插入图片描述

记录锁

Record Lock,翻译成记录锁,是加在索引记录上的锁。。 锁定的是某一行一级,比如

SELECT * FROM yang WHERE id = 1 FOR UPDATE

它会在 id=1 的记录上加上记录锁,以阻止其他事务插入,更新,删除 id=1 这一行。

  • 锁住一条记录。
  • id 列必须为唯一索引列或主键列,否则上述语句加的锁就会变成临键锁。
  • 同时查询语句必须为精准匹配(=),不能为 >、<、like等,否则也会变成成临键锁。

需要特别注意的是,记录锁锁定的是索引记录。即使表没有定义索引,InnoDB也会创建一个隐藏的聚集索引,并使用这个索来锁定记录。

间隙锁

它是行锁中的一种,它锁定的是一个范围区间的索引。锁定的是记录与记录之间的空隙,间隙锁只阻塞插入操作,解决幻读问题。

  • 锁定一个区间,左开右开。

临键锁

临键锁也属于行锁的一种,并且它是 INNODB 的行锁默认算法,临键锁记录锁与间隙锁的并集,是mysql加锁的基本单位。

  • 记录锁 + 间隙锁锁定的区间,左开右闭。

案例说明

idyangc
000
555
101010
151515

以上表,id为主键,yang为普通索引,c为普通列。表名为ep

接下来我们将场景分为唯一索引等值查询、唯一索引范围查询、普通索引等值查询以及普通索引范围查询来分析下mysql如何加锁(数据库默认隔离级别下)

① 唯一索引等值查询(如果记录存在加临建锁,然后会退化为记录锁,该记录不存在加临建锁,然后会退化为间隙锁)

update ep set c = 1 where id = 5 唯一索引等值查询记录存在加临建锁(0,5]然后退化为记录锁5
update ep set c = 1 where id = 7 唯一索引等值查询记录不存在加临建锁(5,10]然后退化为间隙锁(5,10) 

② 唯一索引范围查询(范围内的查询语句,会产生间隙锁)

update ep set c = 1 where id >= 5 and id < 7

1.先来看语句查询条件的前半部分 id >= 5,因此,这条语句最开始要找的第一行是 id = 5,结合加锁的两个核心,需要加上
临建锁(0,5]。又由于 id 是唯一索引,且 id = 5 的这行记录是存在的,因此会退化成记录锁,也就是只会对 id = 5 这一行加锁。

2.再来看语句查询条件的后半部分id < 7,由于是范围查找,就会继续往后找第一个不满足条件的记录,也就是会找到 id = 10 这一行停下来,然后加临建锁(5, 10],重点来了,但由于 id = 10不满足 id < 7,因此会退化成间隙锁,加锁范围变为(5, 10)。

所以,上述语句在主键 id 上的最终的加锁范围是 Record Lockid = 5 以及 Gap Lock (5, 10)

③ 普通索引等值查询(如果记录存在,除了会加临建锁外,还额外加间隙锁,也就是会加两把锁,如果记录不存在,只会加临建锁,然后会退化为间隙锁,也就是只会加一把锁)

update ep set c = 1 where c = 5 普通索引等值查询记录存在加临建锁 (0,5],又因为是非唯一索引等值查询,且查询的
记录 a= 5 是存在的,所以还会加上间隙锁,规则是向下遍历到第一个不符合条件的值才能停止,因此间隙锁的范围是 (5,10)。
update ep set c = 1 where c = 7 普通索引等值查询记录不存在加临建锁(5,10],但是由于查询的记录 a = 7 是不存在的,因此会退化为间隙锁,然后退化为间隙锁(5,10) 

④ 普通索引范围查询(普通索引范围查询, 不会退化为间隙锁和记录锁)

update ep set c = 1 where c < 11 普通索引上的 (0,15] 临键锁
update ep set c = 1 where c >= 10 普通索引上的 (5,10] 临键锁 (10,~] 临键锁
update ep set c = 1 where c >= 10 and c < 11 普通索引上的 (5,15] 临键锁

先来看语句查询条件的前半部分 a >= 10,因此,这条语句最开始要找的第一行是 a = 10,结合加锁的两个核心,需要加上临建锁(5,10]。

再来看语句查询条件的后半部分 a < 11,由于是范围查找,就会继续往后找第一个不满足条件的记录,也就是会找到 id = 15 这一行停下来,然后加临建锁 (10, 15]。

所以,上述语句在普通索引 a 上的最终的加锁范围是 Next-key Lock (5, 10](10, 15],也就是 (5, 15]

3、基于锁的状态分类(意向共享锁与意向排它锁)

意向共享锁

意向共享(IS)锁:事务有意向对表中的某些行加共享锁(S锁)

-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 
SELECT column FROM table ... LOCK IN SHARE MODE;

意向排它锁

意向排他(IX)锁:事务有意向对表中的某些行加排他锁(X锁)

 -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
 SELECT column FROM table ... FOR UPDATE;
  • 意向共享锁(IS)和 意向排他锁(IX)都是表锁。
  • 意向锁是一种 不与行级锁冲突的表级锁。
  • 意向锁是 InnoDB自动加的, 不需用户干预。

11、乐观锁与悲观锁如何实现

在MySQL中,悲观锁是需要依靠数据库提供的锁机制实现的,在InnoDB引擎中,要使用悲观锁,需要先关闭
MySQL数据库的自动提交属性,然后通过select…for update来进行加锁。

//0.开始事务
begin;
//1.查询出商品信息
select quantity from items where id = 1 for update;
//2.修改商品quantity为2
update items set quantity = 2 where id = 1;
//3.提交事务
commit;

MySQL中的乐观锁主要通过CAS的机制来实现,一般通过version版本号来实现。

//查询出商品信息,quantity=3
select quantity from items where id = 1
//根据商品信息生成订单
//修改商品quantity为2
update items set quantity = 2 where id = 1 and quantity = 3;

12、索引有哪些优缺点?索引有哪几种类型

优点 :

  • 使用索引可以大大加快 数据的检索速度(大大减少检索的数据量),这也是创建索引的最主要的原因。
  • 通过创建唯一性索引或者主键索引,可以保证数据库表中每一行数据的唯一性。

缺点 :

  • 创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。
  • 索引需要使用物理文件存储,也会耗费一定空间。

大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升。

在这里插入图片描述
创建索引的三种方式

  • 在执行 CREATE TABLE 时创索引
  • 使用 ALTER TABLE 命令创索引
 ALTER TABLE table_name ADD INDEX index_name (column)
  • 使用 CREATE INDEX 命令创索引
CREATE INDEX index_name ON table_name (column)

13、InnoDB为什么使用B+树实现索引

  1. B+树是一棵平衡树,每个叶子节点到根节点的路径长度相同,查找效率较高。
  2. B+的所有关键字都在叶子节点上,因此范围查询时只需要遍历一扁叶子节点即可。
  3. B+树的叶子节点都按照关键字大小顺序存放,因此可以快速地支持按照关键字大小进行排序。
  4. B+树的非叶子节点不存储实际数据,因此可以存储更多的索引数据。
  5. B+树的非叶子节点使用指针连接子节点,因此可以快速地支持范围查询和倒序查询。
  6. B+树的加叶子节点之间通过双向链表链接,方便进行范围查询。

Hash索引和B+树区别是什么

  1. B+树可以进行范围查询,Hash 索引不能。
  2. B+树支持联合索引的最左侧原则,Hash 索引不支持。
  3. B+树支持 order by 排序,Hash 索引不支持。
  4. Hash 索引在等值查询上比 B+树效率更高。
  5. B+树使用 like 进行模糊查询的时候,like 后面(比如%开头)的话可以起到优化的作用,Hash 索引根本无法进行模糊查询。

14、MySQL 是如何保证唯一性索引的唯一性的

MySQL实现唯一索引的底层原理是基于B+树索引结构。在实现唯一索引时,MySQL会在B+树上的每个节点上都添加一个指向唯一性索引值的指针(也称为“唯一性检查器”)。当在索引列上插入新值时,MySQL会先使用B+树查找该值是否存在。

如果该值已经存在,就会触发唯一性检查器,检查索引列中是否已经存在相同的值。如果唯一性检查器返回了错
误,就会抛出唯一性约束冲突的异常,否则就可以插入新值。

在更新索列时,MySQL也会先使用B+树查找目标记录,然后触发唯一性检查器,检查索列中是否已经存在相同的值。如果新值和原值相同,就直接返回。如果新值和原值不同,就会检查新值是否已经存在。如果新值已经存在,就会抛出唯一性约束冲突的异常,否则就可以更新该记录。

15、什么是聚簇索引和非聚簇索

聚簇索引,简单点理解就是将数据与索引放到了一起,找到索引也就找到了数据。也就是说,对于聚簇索引来说,他的非叶子节点上存储的是索引字段的值,而他的叶子节点上存储的是这条记录的整行数据。

在这里插入图片描述

非聚簇索引,就是将数据与索引分开存储,叶子节点包含索引字段值及指向数据页数据行的逻辑指针。

在这里插入图片描述
没有创建主键怎么办

我们知道,Innodb中的聚簇索引是按照每张表的主键构造一个B+树,那么不知道大家有没有想过这个问题,如果
我们在表结构中没有定义主键,那怎么办呢?

其实,数据库中的每行记录中,除了保存了我们自己定义的一些字段以外,还有一些重要的db row id字段,其实他就是一个数据库帮我添加的隐藏主键,如果我们没有给这个表创建主键,会选择一个不为空的唯一索引来作为
聚簇索引,但是如果没有合适的唯一索引,那么会以这个隐藏主键来创建聚簇索引。

16、什么是回表,怎么减少回表的次数

那么,当我们根据非聚簇索引查询的时候,会先通过非聚簇索引查到主键的值,之后,还需要再通过主键的值再进
行一次查询才能得到我们要查询的数据。而这个过程就叫做回表。

所以,在InnoDB中,使用主键查询的时候,是效率更高的,因为这个过程不需要回表。另外,依赖覆盖索引、索引下推等技术,我们也可以通过优化索引结构以及SQL语句减少回表的次数。

覆盖索引

覆盖索引:在查询的数据列里面,不需要回表去查,直接从索引列就能取到想要的结果。换句话说,你SQL用到的索引列数据,覆盖了查询结果的列,就算上覆盖索引了。

在这里插入图片描述

例如(覆盖索引)

select id,age from yang where age = 48;--age非聚集索引上带有主键值,不需要回表

例如(覆盖索引)

select age from yang where age = 48;--SQL用到的索引列数据,覆盖了查询结果的列,不需要回表

回到idx_age索引树,你可以发现查询选项id和age都在叶子节点上了。因此,可以直接提供查询结果啦,根本就不需要再回表了。

例如(覆盖索引失效)

select id,age,sex from yang where age = 48;--sex需要回表查询出来

索引下推是 MySQL 5.6 版本中提供的一项索引优化功能,可以在非聚簇索引遍历过程中,对索引中包含的字段先做判断,过滤掉不符合条件的记录,减少回表次数。

select * from tuser where name like '张%' and age=10;

索引下推

在MySQL 5.6之前,存储引擎根据通过联合索引找到name like ‘张%’ 的主键id(1、4),逐一进行回表扫描,去聚簇索引找到完整的行记录,server层再对数据根据age=10进行筛选。可以看到需要回表两次,把我们联合索引的另一个字段age浪费了。
在这里插入图片描述
而MySQL 5.6 以后, 存储引擎根据(name,age)联合索引,找到,由于联合索引中包含列,所以存储引擎直接再联合索引里按照age=10过滤。按照过滤后的数据再一一进行回表扫描。可以看到只回表了一次。
在这里插入图片描述

17、设计索引的时候有哪些原侧

  1. 考虑查询的频率和效率:在决定创建索引之前,需要分析查询频率和效率。对于频繁查询的列,可以创建索引
    来加速查询,但对于不经常查询或者数据量较少的列,可以不创建索引。
  2. 选择适合的索引类型:MySQL提供了多种索引类型,如B+Tree索引、哈希索引和全文索引等。不同类型的索
    引适用于不同的查询操作,需要根据实际情况选择适合的索引类型。
  3. 考虑区分度:尽量不要选择区分度不高的字段作为索引,比如性别。但是也并不绝对,对于一些数据倾斜比较
    严重的字段,虽然区分度不高,但是如果有索引,查询占比少的数据时效率也会提升。
  4. 考虑联合索引:联合索引是将多个列组合在一起创建的索引。当多个列一起被频繁查询时,可以考虑创建联合
    索引。
  5. 考虑索引覆盖:联合索引可以通过索引覆盖而避免回表查询,可以大大提升效率,对于频繁的查询,可以考虑
    将select后面的字段和where后面的条件放在一起创建联合索引。
  6. 避免创建过多的索引:创建过多的索引会占用大量的磁盘空间,影响写入性能。并且在数据新增和删除时也需
    要对索引进行维护。所以在创建索引时,需要仔细考虑需要索引的列,避免创建过多的索引。
  7. 避免使用过长的索引:索引列的长度越长,索引效率越低。在创建索引时,需要选择长度合适的列作为索引
    列。
  8. 合适的索引长度:虽然索引不建议太长,但是也要合理设置,如果设置的太短,比如身份证号,但是只把前面
    6位作为索引,那么可能会导致大量锁冲突。

18、MySQL的主键一定是自增的吗

不是的,主键是可以自己选择的,我们可以选择任意一种数据类型作为主键。

但是一般都是单独创建一个自增字段作为主键,主要能带来以下几个好处:

  1. 索引大小更小:使用自增主键可以确保主键的递增性,使得新插入的数据都会在索引的末尾处,减少了数据页
    的分裂和页分裂导致的O操作,使得索引大小更小,查询速度更快。
  2. 索引顺序和插入顺序相同:使用白增主键可以保证索引顺序和插入顺序相同,减少了插入新数据时索引的重新
    排序,提高了插入速度。
  3. 安全性:使用自增主键可以避免主键重复的情况,确保数据完整性和唯一性。
  4. 减少页分裂(合并)及内存碎片。

19、MySQL怎么做热点数据高效更新

针对于频繁更新或秒杀类业务场景,大幅度优化对于热点行数据的update操作的性能。当开启热点更新自动探测时,系统会自动探测是否有单行的热点更新,如果有,则会让大量的并发 update 排队执行,以减少大量行锁造成的并发性能下降。

20、MySQL自增主键用完了会怎么样

显示自定义的自增ID,用完以后下次插入会报主键冲突。

未定义自增ID主键,会用row_id,用完以后下一次插入会覆盖历史数据。

那么,从这个方面来看的话,我们为了避免数据被覆盖,还是需要自己设置一个自增的主键D的,毕竟异常我们
是可以感知到的,但是数据覆盖我们可能过了很久才能发现。

21、MySQL 索引使用有哪些注意事项呢

  • 被频繁更新的字段应该慎重建立索引
    • 虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了,因为数据修改索引树也要修改。
  • 限制每张表上的索引数量
    • 首先占用空间,索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。
  • 尽可能的考虑建立联合索引而不是单列索引
    • 如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。
  • 考虑索引覆盖
    • 联合索引可以通过索引覆盖而避免回表查询,可以大大提升效率,对于频繁的查询,可以考虑将select后面的字段和where后面的条件放在一起创建联合索引。
  • 注意避免冗余索引
    • 冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。
  • 索引哪些情况会失效
    • OR引起索引失效(OR导致索引是在特定情况下的,并不是所有的OR都是使索引失效,如果OR连接的字段都加上索引,索引就不会失效)。
    • LIKE模糊查询导致索引失效(但是并不是所有LIKE查询都会失效,只有在查询时字段最左侧加%和左右侧都加%才会导致索引失效)。
      • 幸运的是在MySQL5.7.6之后,新增了虚拟列功能,为一个列建立一个虚拟列,并为虚拟列建立索引,在查询时where中like条件改为虚拟列,就可以使用索引了。
      • ALTER TABLE yang ADD COLUMN v_bbb VARCHAR(50) GENERATED ALWAYS AS (REVERSE(b)) VIRTUAL;
      • ALTER TABLE yang ADD INDEX v_bbb(v_bbb);
    • 如果列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则索引可能失效。
      • 隐式类型转换规则:当索引字段是字符串和数字进行比较的时候,默认是字符串类型转换成数字类型。
      • 例如 SELECT * FROM YANG WHERE ID = '1000' 如果id是数字类型,那么类型不一致,就会变为
      • SELECT * FROM YANG WHERE ID = 1000 此时索引还是生效的,但是如果是 SELECT * FROM YANG WHERE ID = 1000 如果id是字符串类型,那么类型不一致,就会变为 SELECT * FROM YANG WHERE cast(ID unsigned int) = 1000 此时索引字段用到了函数。
    • 在索引列上使用内置函数和运算,索引不一定失效。
      • 索引查询方式有两种:① 从B+树根节点进行树搜索 ② 遍历叶子节点(双向链表)从第一个节点开始。
      • 看是否需要回表,不回表类似 SELECT COUNT(*) FROM YANG WHERE MONTH(Y) = 2 查询成本低,就会走索引。
      • 看是否需要回表,回表类似 SELECT * FROM YANG WHERE MONTH(Y) = 2 查需成本高,就会不走索引。
    • 联合索引,查询时的条件列不是联合索引中的第一个列,索引失效。
    • 索引字段上使用is null, is not null,可能导致索引失(走和不走索引是和数据量或者和其他元素有关系)。
    • 索引字段上使用(!= 或者 < >,not in)时,可能会导致索引失效(需要看条件,比如数据量,mysql在执行的时候会判断走索引的成本和全表扫描的成本,然后选择成本小的那个)。
    • mysql估计使用全表扫描要比使用索引快,则不使用索引。
    • 左连接查询或者右连接查询查询关联的字段编码格式不一样,可能导致索引失效。

22、数据库加密后怎么做模糊查询

使用明文分词加密

比如拿到一个手机号,先对手机号进行分词,例如13602021515,按4位分词就为1360,3602,6020等等对这些字符串加密后组成一个长串放入库中,但是有局限性就是 需要约定模糊搜索位数,不灵活。

23、MySQL索引一定遵循最左前缀匹配吗

MySQL一定是遵循最左前缀匹配的,这句话在以前是正确的,没有任何毛病。但是在MySQL8.0中,就不一定了。因为8.0.13中引入了索引跳跃扫描。

24、count(1)、count(*) 与 count(列名) 的区别

C0UNT(1)和COUNT(*)表示的是直接查询符合条件的数据库表的行数。而C0UNT(列名)表示的是查询符合条件的列的值不为NULL的行数。

25、drop、delete 与 truncate 区别

  • drop(丢弃数据):drop table 表名 ,直接将表都删除掉,在删除表的时候使用。
  • truncate(清空数据):truncate table 表名 ,只删除表中的数据,再插入数据的时候自增长 id 又从 1 开始,在清空表中数据的时候使用。
  • delete(删除数据):delete from 表名 where 列名=值,删除某一行的数据,如果不加 where 子句和truncate table 表名作用类似。

执行速度不同

一般来说:drop > truncate > delete

26、limit 0,100 和 limit 10000000,100 一样吗

典型的深度分页的问题。

MySQL的limit m n工作原理就是先读取前面m+n条记录,然后抛弃前m条,然后返回后面n条数据,所以m越大,偏移量越大,性能就越差。

所以,limit 10000000 100要比limit 0 100的性能差的多,因为他要先读取10000100条数据,然后再抛弃前面的
10000000条。

27、SQL语句如何实现insertOrUpdate的功能

假设有一个student表,包含id、name和age三列,其中id是主键。现在要插入一条数据,如果该数据的主键已经存在,则更新该数据的姓名和年龄,否则插入该数据。

INSERT INTO student (id,name,age) VALUES (1,'Alice',20) ON DUPLICATE KEY UPDATE name='Alice', age=20;

28、常见的日志都有什么用

  1. slow query log(慢查询日志)

慢查询日志记录了执行时间超过 long_query_time (默认是10s,通常设置为1s)的所有查询语句,在解决 SQL 慢查询(SQL执行时间过长)问题的时候经常会用到。

找到慢SQL是优化SQL语句性能的第一步,然后再用EXPLAIN命令可以对慢SQL进行分析,获取执行计划的相关信息。

你可以通过show variables like "slow_query_log"命令来查看慢查询日志是否开启,默认是关闭的。

在这里插入图片描述

  1. binlog(二进制日志)

binlog是MySQL用于记录数据库中的所有DDL语句和DML语句的一种二进制日志。它记录了所有对数据库结构和数据的修改操作,如INSERT、UPDATE和DELETES等。binlog主要用来对数据库进行数据备份、灾难恢复和数据复制等操作。binlog的格式分为基于语句的格式和基于行的格式。

你可以使用show binary logs命令查看所有二进制日志列表

在这里插入图片描述
binlog的格式有哪几种

一共有3种类型二进制记录方式:

  • Statement模式:每一条会修改数据的sql都会被记录在binlog中,如inserts,updates,deletes。
  • Row模式(推荐):每一行的具体变更事件都会被记录在binlog中。
  • Mixed模式:Statement模式和Row模式的混合。默认使用Statement模式,少数特殊具体场景自
    动切换到RoW模式。
  1. statement,记录的是 SQL 的原文。不需要记录每一行的变化,减少了binlog 日志量,节约了 IO,提高性能。由于 sql 的执行是有上下文的,因此在保存的时候需要保存相关的信息,同时还有一些使用了函数之类的语句无法被记录复制。
  2. row,不记录 sql 语句上下文相关信息,仅保存哪条记录被修改。记录单元为每一行的改动,基本是可以全部记下来但是由于很多操作,会导致大量行的改动(比如 alter table),因此这种模式的文件保存的信息太多,日志量太大。
  3. mixed,一种折中的方案,普通操作使用 statement 记录,当无法使用 statement 的时候使用 row。

binlog主要用来做什么

binlog最主要的应用场景是主从复制,主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。

在这里插入图片描述

  • 主库将数据库中数据的变化写入到binlog
  • 从库连接主库
  • 从库会创建一个I/O线程向主库请求更新的binlog
  • 主库会创建一个binlog dump线程来发送binlog,从库中的/O线程负责接收
  • 从库的I/O线程将接收的binlog写入到relay log中。
  • 从库的SQL线程读取relay log同步数据本地(也就是再执行一遍SQL)。

拓展一下:

不知道大家有没有使用过阿里开源的一个叫做 canal 的工具。这个工具可以帮助我们实现 MySQL 和其他数据源比如 Elasticsearch 或者另外一台 MySQL 数据库之间的数据同步。很显然,这个工具的底层原理肯定也是依赖 binlog。canal 的原理就是模拟 MySQL 主从复制的过程,解析 binlog 将数据同步到其他的数据源。

另外,像咱们常用的分布式缓存组件 Redis 也是通过主从复制实现的读写分离。

  1. redo log(重做日志)

Redo Log是MySQL用于实现崩溃恢复和数据持久性的一种机制。MySQL会将事务做了什么改动到Redo Log中。当系统崩溃或者发生异常情况时,MySQL会利用Redo Log中的记录信息来进行恢复操作,将事务所做的修改持久化到磁盘中。

  1. undo log(徹销日志)

Undo Log则用于在事务回滚或系统崩溃时撤销(回滚)事务所做的修改。当一个事务执行完成后,MySQL会将事务修改前的数据记录到Undo Log中。如果事务需要回滚,则会从Undo Log中找到相应的记录来撤销事务所做的修改。另外,Undo Log还支持MVCC(多版本并发控制)机制,用于在并发事务执行时提供一定的隔离性。

29、一条 SQL 语句在 MySQL 中如何执行的

select * from users where age = '18' and name = 'y';

在这里插入图片描述
① 使用连接器,通过客户端/服务器通信协议与MySQL建立连接。并查询是否有权限。
② Mysql8.0之前检查是否开启缓存,开启了Query Cache且命中完全相同的SQL语句,则将查询结果直接返回给客户端。
③ 由解析器(分析器)进行语法分析和语义分析,并生成解析树。

  • 第一步,词法分析,一条 SQL 语句有多个字符串组成,首先要提取关键字,比如 select,提出查询的表,提出字段名,提出查询条件等等。做完这些操作后,就会进入第二步。
  • 第二步,语法分析,主要就是判断你输入的 sql 是否正确,是否符合 MySQL 的语法。

④ 由优化器生成执行计划。根据索引看看是否可以优化,优化器的作用就是它认为的最优的执行方案去执行,比如多个索引的时候该如何选择索引,多表查询的时候如何选择关联顺序等。可以说,经过了优化器之后可以说这个语句具体该如何执行就已经定下来。
⑤ 执行器来执行SQL语句,这里具体的执行会操作MSQL的存储引擎来执行SQL语句,根据存储引擎类型,得到查询结果。若开启了Query Cache则缓存,否则直接返回。

30、如何进行SQL调优

首先需要定位到具体的SQL语句,这个可以通过各类监控平台或者工具来实现,通过定位到SQL语句之后,我们就知道具体是哪张表、哪个SQL慢了。

  • 首先,索引失效的问题一般是先通过执行计划分析是否走了索引,以及所走的索引是否符合预期,如果因为索引设计的不合理、或者索引失效导致的,那么就可以修改索引,或者修改SQL语句。

  • 多表join也是SQL执行的比较慢的一个常见原因,需要永远用小结果集驱动大结果集合。为被驱动表匹配的条件增加索引。

  • 查询字段太多,查询需要的列信息即可,少用 * 代替列信息。

  • 表中数据量太大,一般来说,单表超过1000万,会导致查询效率变低,即使使用索引可能也会比较慢,所以如果表中数据量太大的话,这时候可能通过建索引并不一定能完全解决了。那么具体的解决方案有几种:

    • 数据归档,把历史数据移出去,比如只保留最近半年的数据,半年前的数据做刻归档。
    • 分库分表、分区。把数据拆分开,分散到多个地方去。
    • 使用第三方的数据库,比如把数据同步到支持大数量查询的分布式数据库中,如oceanbase、tidb或者搜索引擎中,如ES等。
  • 子查询优化,执行子查询时,mysql需要为内层查询语句的查询结果建立一个临时表,然后外层查询语句从临时表中查询记录。查询完毕后,再撤销这些临时表。这样会消耗过多的CPU和IO资源,产生大量的慢查询。子查询的结果集存储的临时表不存在索引,所以查询性能会受到一定的影响。对于返回结果集比较大的子查询,其对查询性能的影响也越来越大。在mysql中,可以使用连接(join)查询来替代子查询。连接查询不需要建立临时表,其速度比子查询要快。如果使用索引的话,性能要更好。

  • 排序优化,可以在ORDER BY子句中使用索,在ORDER BY子句中避免使用FileSort排序。使用的是Index排序中,索引可以保证数据的有序性,不需要再进行排序,效率更高。

  • 分页查询优化,索引上先完成排序分页操作,然后再根据主键关联回原表来查询所需内容。

SELECT * FROM ep t, (SELECT id FROM ep ORDER BY id LIMIT 0, 2) a WHERE t.id = a.id;

31、SQL执行计划分析的时候,要关注哪些信息

mysql> explain SELECT * FROM dept_emp WHERE emp_no IN (SELECT emp_no FROM dept_emp GROUP BY emp_no HAVING COUNT(emp_no)>1);
+----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+
| id | select_type | table    | partitions | type  | possible_keys   | key     | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+
|  1 | PRIMARY     | dept_emp | NULL       | ALL   | NULL            | NULL    | NULL    | NULL | 331143 |   100.00 | Using where |
|  2 | SUBQUERY    | dept_emp | NULL       | index | PRIMARY,dept_no | PRIMARY | 16      | NULL | 331143 |   100.00 | Using index |
+----+-------------+----------+------------+-------+-----------------+---------+---------+------+--------+----------+-------------+
  1. id:SELECT 标识符,是查询中 SELECT 的序号,用来标识整个查询中 SELELCT 语句的顺序。id 如果相同,从上往下依次执行。id 不同,id 值越大,执行优先级越高,如果行引用其他行的并集结果,则该值可以为 NULL。
    • id相同,由上到下依次执行
    • id不同,值越大优先级越高,越先执行
    • id为null,最后执行
  2. select type:操作的类型。常见的类型包括SIMPLE、PRIMARY、SUBQUERY、UNION等。不同类型的操作会影响查询的执行效率。
    • SIMPLE:简单查询,不包含 UNION 或者子查询。
    • PRIMARY:查询中如果包含子查询或其他部分,外层的 SELECT 将被标记为 PRIMARY。
    • SUBQUERY:子查询中的第一个 SELECT。
    • UNION:在 UNION 语句中,UNION 之后出现的 SELECT。
    • DERIVED:在 FROM 中出现的子查询将被标记为 DERIVED。
    • UNION RESULT:UNION 查询的结果
  3. table:当前操作所涉及的表。
  4. partitions:当前操作所涉及的分区。
  5. type:表示查询时所使用的索引类型,包括ALL、index、range、ref、eq_ref、const等。
    • system:如果表使用的引擎对于表行数统计是精确的(如:MyISAM),且表中只有一行记录的情况下,访问方法是 system ,是 const 的一种特例。
    • const:表中最多只有一行匹配的记录,一次查询就可以找到,常用于使用主键或唯一索引的所有字段作为查询条件。
    • eq_ref:当连表查询时,前一张表的行在当前这张表中只有一行与之对应。是除了 system 与 const 之外最好的 join 方式,常用于使用主键或唯一索引的所有字段作为连表条件。
    • ref:使用普通索引作为查询条件,查询结果可能找到多个符合条件的行。
    • range:对索引列进行范围查询,执行计划中的 key 列表示哪个索引被使用了。
    • index:查询遍历了整棵索引树,与 ALL 类似,只不过扫描的是索引,而索引一般在内存中,速度更快。
    • ALL:全表扫描。
  6. possible keys:表示可能被查询优化器选择使用的索引。
  7. key:表示查询优化器选择使用的索引。
  8. key len:表示索引的长度。索引的长度越短,查询时的效率越高。
  • 字符串
    • char(n):n字节长度
    • varchar(n):2字节存储字符串长度,如果是utf-8,则长度 3n + 2
  • 数值类型
    • tinyint:1字节
    • smallint:2字节
    • int:4字节
    • bigint:8字节
  • 时间类型
    • date:3字节
    • timestamp:4字节
    • datetime:8字节
  • 如果字段允许为 NULL,需要1字节记录是否为NULL
  1. ref:表示连接操作所使用的索引。
  2. rows:表示此操作需要扫描的行数,即扫描表中多少行才能得到结果。
  3. filtered:表示比操作过滤掉的行数占扫描行数的百分比。该值越大,表示查询结果越准确。
  4. Extra:这列包含了 MySQL 解析查询的额外信息,通过这些信息,可以更准确的理解 MySQL 到底是如何执行查询的。常见的值如下:
  • Using filesort:在排序时使用了外部的索引排序,没有用到表内索引进行排序。
  • Using temporary:MySQL 需要创建临时表来存储查询的结果,常见于 ORDER BY 和 GROUP BY。
  • Using index:表明查询使用了覆盖索引,不用回表,查询效率非常高。
  • Using index condition:表示查询优化器选择使用了索引条件下推这个特性。
  • Using where:表明查询使用了 WHERE 子句进行条件过滤。一般在没有使用到索引的时候会出现。
  • Using join buffer (Block Nested Loop):连表查询的方式,表示当被驱动表的没有使用索引的时候,MySQL 会先将驱动表读出来放到 join buffer 中,再遍历被驱动表与驱动表进行查询。

这里提醒下,当 Extra 列包含 Using filesort 或 Using temporary 时,MySQL 的性能可能会存在问题,需要尽可能避免。

32、数据库死锁

数据库和操作系统一样,是一个多用户使用的共享资源。当多个用户并发地存取数据 时,在数据库中就会产生多个事务同时存取同一数据的情况。若对并发操作不加控制就可能会读取和存储不正确的数据,破坏数据库的一致性。加锁是实现数据库并 发控制的一个非常重要的技术。在实际应用中经常会遇到的与锁相关的异常情况,当两个事务需要一组有冲突的锁,而不能将事务继续下去的话,就会出现死锁,严重影响应用的正常执行。

① 一个用户A 访问表A(锁住了表A),然后又访问表B,另一个用户B 访问表B(锁住了表B),然后企图访问表A,这时用户A由于用户B已经锁住表B,它必须等待用户B释放表B才能继续,同样用户B要等用户A释放表A才能继续,这就死锁就产生了。

解决方法:这种死锁比较常见,是由于程序的BUG产生的,除了调整的程序的逻辑没有其它的办法。仔细分析程序的逻辑,对于数据库的多表操作时,尽量按照相同的顺序进 行处理,尽量避免同时锁定两个资源,如操作A和B两张表时,总是按先A后B的顺序处理, 必须同时锁定两个资源时,要保证在任何时刻都应该按照相同的顺序来锁定资源。

② 用户A查询一条纪录,然后修改该条纪录,这时用户B修改该条纪录,这时用户A的事务里锁的性质由查询的共享锁企图上升到独占锁,而用户B里的独占锁由于A 有共享锁存在所以必须等A释放掉共享锁,而A由于B的独占锁而无法上升的独占锁也就不可能释放共享锁,于是出现了死锁。这种死锁比较隐蔽,但在稍大点的项 目中经常发生。如在某项目中,页面上的按钮点击后,没有使按钮立刻失效,使得用户会多次快速点击同一按钮,这样同一段代码对数据库同一条记录进行多次操 作,很容易就出现这种死锁的情况。

解决方法:
1、对于按钮等控件,点击后使其立刻失效,不让用户重复点击,避免对同时对同一条记录操作。
2、使用乐观锁进行控制。乐观锁大多是基于数据版本(Version)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是 通过为数据库表增加一个“version”字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数 据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。乐观锁机制避免了长事务中的数据 库加锁开销(用户A和用户B操作过程中,都没有对数据库数据加锁),大大提升了大并发量下的系统整体性能表现。Hibernate 在其数据访问引擎中内置了乐观锁实现。需要注意的是,由于乐观锁机制是在我们的系统中实现,来自外部系统的用户更新操作不受我们系统的控制,因此可能会造 成脏数据被更新到数据库中。
3、使用悲观锁进行控制。悲观锁大多数情况下依靠数据库的锁机制实现,如Oracle的Select … for update语句,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。如一个金融系统, 当某个操作员读取用户的数据,并在读出的用户数据的基础上进行修改时(如更改用户账户余额),如果采用悲观锁机制,也就意味着整个操作过程中(从操作员读 出数据、开始修改直至提交修改结果的全过程,甚至还包括操作员中途去煮咖啡的时间),数据库记录始终处于加锁状态,可以想见,如果面对成百上千个并发,这 样的情况将导致灾难性的后果。所以,采用悲观锁进行控制时一定要考虑清楚。

③ 如果在事务中执行了一条不满足条件的update语句,则执行全表扫描,把行级锁上升为表级锁,多个这样的事务执行后,就很容易产生死锁和阻塞。类似的情 况还有当表中的数据量非常庞大而索引建的过少或不合适的时候,使得经常发生全表扫描,最终应用系统会越来越慢,最终发生阻塞或死锁。

33、介绍一下InnoDB的数据页,和B+树的关系是什么

InnoDB的数据页是InnoDB存储引擎中用于存储数据的基本单位。它是磁盘上的一个连续区域,通常大小为16KB
当然,也可以通过配置进行调整。16KB就意味着1 nnodb的每次读写都是以16KB为单位的,一次从磁盘到内存的
读取的最小是16KB,一次从内存到磁盘的持久化也是最小16KB。

B+树的每个节点都对应着一个数据页,包括根节点、非叶子节点和叶子节点。B+树通过节点之间的指针连接了不
同层级的数据页,从而构建了一个有序的索引结构。

34、什么是buffer pool

我们都知道,MySQLE的数据是存储在磁盘上面的(Memory引擎除外),但是如果每次数据的查询和修改都直接旦
和磁盘交互的话,性能是很差的。于是,为了提升读写性能,Innodb引擎就引入了一个中间层,就是ouffer pool。

buffer是在内存上的一块连续空间,他主要的用途就是用来缓存数据页的,每个数据页的大小是16KB。

在这里插入图片描述
有了buffer pool之后,当我们想要做数据查询的时候,InnoDB会首先检查Buffer Pool中是否存在该数据。如果存在,数据就可以直接从内存中获取,避免了频繁的磁盘读取,从而提高查询性能。如果不存在再去磁盘中进行读
取,磁盘中如果找到了的数据,则会把该数据所在的页直接复制一份到ouffer pool中,并返回给客户端,后续的
话再次读取就可以从ouffer pool中就近读取了。
在这里插入图片描述
当需要修改的时候也一样,需要现在ouffer pool中做修改,然后再把他写入到磁盘中。

但是因为buffer pool是基于内存的,所以空间不可能无限大,他的默认大小是128M,当然这个大小也不是完全
固定的,我们可以调整,可以通过修改小ySQL配置文件中的innodb buffer pool size参数来调整Buffer Pool的
大小。

35、什么是InnoDB的叶分裂和叶合并

我们都是知道,B+树是按照索字段建立的,并且在B+树中是有序的,假如有下面一个索引的数结构,其中的索引字段的值并不连续。

假如,现在我们插入一个新的一条记录,他的索引值是3,那么他就要按照顺序插入到页20中,在索引值为1,2的
记录的后面。而如果这个索引页已经满了,那么就需要触发一次页分裂。

在这里插入图片描述
那么,当我们向odb中添加数据的时候,如果索引是随机无序的,那么就会导致页分裂。而且分裂这个动作还可能会引起连锁反应,从叶子节点沿着树结构一路分裂到根节点。

有分裂,就会有合并。在noDB中,当索引页面中的索引记录删除后,页面可能会变得过于稀疏。这时,为了节省空间和提高性能,可能会触发叶合并操作。

在这里插入图片描述
页分裂(合并)的危害

首先,页分裂和合并是涉及大量数据移动和重组的操作。频繁进行这些操作会增加数据库的/O负担和CPU消耗,影响数据库的整体性能。分裂和合并可能导致B+树索引结构频繁调整,这个过程也会影响插入及删除操作的性能。频繁的页分裂和合并可能会导致磁盘上存在较多的空间碎片,新分出的一个页一般会有很多空闲空间,使得数据库表占用更多的磁盘空间,而导致浪费。

所以,尽量选择使用自增的字段作为索引,尤其是主键索引,这样可以很大程度的避免页分裂。如果要插入大量数据,尽量使用批量插入的方式,而不是逐条插入。这样可以减少页分裂的次数。频繁删除操作可能导致页面过于稀疏,从而触发叶合并。所以,一般建议使用逻辑删除而不是物理删除。

36、什么是最左前缀匹配

在MySQL建立联合索引时会遵守最左前缀匹配原则,即最左优先,在检索数据时从联合索引的最左边开始匹配。

CREATE INDEX index_age_name_sex ON yang (AGE,NAME,SEX)

如:(AGE, NAME,SEX)是一个联合索引,支持(AGE)(AGE, NAME)(AGE, NAME,SEX)查找。

EXPLAIN SELECT * FROM YANG WHERE AGE = 43--索引生效
EXPLAIN SELECT * FROM YANG WHERE AGE = 43 AND NAME = '1'--索引生效
EXPLAIN SELECT * FROM YANG WHERE AGE = 46 AND NAME = '1' AND SEX = 0--索引生效

那么(AGE,SEX)索引会不会生效呢,也是会生效的但是只有AGE走了索引

EXPLAIN SELECT * FROM YANG WHERE AGE = 43 AND SEX= 0--索引生效

那么(SEX,AGE,NAME)索引会不会生效呢,也是会生效的,这个属于匹配查询(where子句搜索条件顺序调换不影响索引使用,因为查询优化器会自动优化查询顺序 ),顺序可以颠倒

EXPLAIN SELECT * FROM YANG WHERE AGE = 43 AND NAME = '1' AND SEX = 0--索引生效
EXPLAIN SELECT * FROM YANG WHERE SEX = 0 AND NAME = '1' AND AGE = 43--索引生效
EXPLAIN SELECT * FROM YANG WHERE NAME = '1' AND SEX = 0 AND AGE = 43--索引生效

还有一种带有范围查询的时候,例如

select * from table where AGE = 1 and NAME > '2' and SEX = 0 这种类型的也只会有AGE与NAME走索引,SEX不会走

在这里插入图片描述
如图所示他们是按照a来进行排序,在a相等的情况下,才按b来排序。

因此,我们可以看到a是有序的1,1,2,2,3,3。而b是一种全局无序,局部相对有序状态! 什么意思呢?

从全局来看,b的值为1,2,1,4,1,2,是无序的,因此直接执行b = 2这种查询条件没有办法利用索引。

从局部来看,当a的值确定的时候,b是有序的。例如a = 1时,b值为1,2是有序的状态。当a=2时候,b的值为1,4也是有序状态。 因此,你执行a = 1 and b = 2是a,b字段能用到索引的。而你执行a > 1 and b = 2时,a字段能用到索引,b字段用不到索引。因为a的值此时是一个范围,不是固定的,在这个范围内b值不是有序的,因此b字段用不上索引。

综上所示,最左匹配原则,在遇到范围查询的时候,就会停止匹配。

所以根据联合索引的最左匹配原则,我们在构建联合索引的时候,要把区分度高的字段,放在最左侧。

MySQL一定是遵循最左前缀匹配的,这句话在以前是正确的,但是在MySQL 8.0出现了索引跳跃扫描

37、在高并发情况下,如何做到安全的修改同一行数据

要安全的修改同一行数据,就要保证一个线程在修改时其它线程无法更新这行记录。一般有悲观锁和乐观锁两种方案。

使用悲观锁

在MySQL中,悲观锁是需要依靠数据库提供的锁机制实现的,在InnoDB引擎中,要使用悲观锁,需要先关闭
MySQL数据库的自动提交属性,然后通过select…·for update来进行加锁。

悲观锁思想就是,当前线程要进来修改数据时,别的线程都得拒之门外,比如, 可以使用 select…for update

select * from User where name= 'yang' for update

以上这条 sql 语句会锁定了 User 表中所有符合检索条件(name= ‘yang’)的记录。本次事务提交之前,别的线程都无法修改这些记录。

使用乐观锁

乐观锁思想就是,有线程过来,先放过去修改,如果看到别的线程没修改过, 就可以修改成功,如果别的线程修改过,就修改失败或者重试。实现方式:乐观锁一般会使用版本号机制或 CAS 算法实现。

38、MYSQL 的主从延迟,你怎么解决

读写分离对于提升数据库的并发非常有效,但是,同时也会引来一个问题:主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟

解决方法

  1. 本地缓存标记

在这里插入图片描述
流程

1)用户A发起写请求,更新了主库,并在客户端设置标记,过期时间,如:cookies
 
2)用户A再发起读请求时,带上这个本地标记在后端
 
3)后端在处理请求时,获取请求传过来的数据,看有没有这个标记(如:cookies)
 
4)有这个业务标记,走主库;没有走从库。

这个方案就保证了用户A的读请求肯定是数据一致的,而且没有性能问题,因为标记是本地客户端传过去的。

但有写小伙伴就会问那其他用户在本地客户端是没有这个标记的,他们走的就是从库了。那其他用户不就看不到这个数据了吗?说的对,其他用户是看不到,但看不到的时间很短,过个1~10秒就能够看到。还是那句话,脱离业务的方案是耍流氓。

39、MySQL 时间类型数据存储建议

在这里插入图片描述
datetime 不分时区,不会受到时区的影响
timestamp 分时区,会受到时区的影响

40、char 和 varchar 的区别

char是一种定长的数据类型,它的长度固定且在存储时会自动在结尾添加空格来将字符串填满指定的长度。char
的长度范围是0-255。

varchar是一种可变长度的数据类型,它只会存储实际的字符串内容,不会填充空格。因此,在存储短字符串时,
varchar可以节省空间。varchar的长度范围是0-65535(MySQL5.0.3之后的版本)。

在性能方面,一般来说,char性能更好。因为char长度固定,存储空间小,占用资源少,检索也更快;而varchar
每次存储和检索都要检查字符串长度,降低性能。对于字段值经常改变的数据类型来说,CHAR相比VARCHAR也更有优势,因为CHAR的长度固定,不会产生碎片。

例如,存储身份证号(固定长度)、存诸订单号(可变长度)、存储国家编码(固定长度),这些都适合用char。

41、mysql 里记录货币用什么字段类型比较好

DECIMAL 和 NUMERIC 值作为字符串存储,而不是作为二进制浮点数,以便保存那些值的小数精度。

42、一条 Sql 的执行顺序

在这里插入图片描述

43、MySQL 查询缓存

执行查询语句的时候,会先查询缓存。不过,MySQL 8.0 版本后移除,因为这个功能不太实用

my.cnf 加入以下配置,重启 MySQL 开启查询缓存

query_cache_type=1
query_cache_size=600000

MySQL 执行以下命令也可以开启查询缓存

set global  query_cache_type=1;
set global  query_cache_size=600000;

如上,开启查询缓存后在同样的查询条件以及数据情况下,会直接在缓存中返回结果。这里的查询条件包括查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息。

查询缓存不命中的情况:

  1. 任何两个查询在任何字符上的不同都会导致缓存不命中。
  2. 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。
  3. 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。

存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,还可以通过 sql_cache 和 sql_no_cache 来控制某个查询语句是否需要缓存:

select sql_no_cache count(*) from usr;

取消查询缓存原因

  1. 频繁失效:查询缓存是以表级别为单位进行管理的,这意味着如果任何表中的数据发生变化,与该表相关的所
    有查询缓存都将被清除。这导致了缓存的频繁失效,减少了其效用。
  2. 内存开销:查询缓存需要占用大量内存来存储查询文本和结果集,这对于具有大量查询和数据的数据库来说,
    会导致内存开销问题。
  3. 不一致性:有时查询结果可能会因为数据库中的数据更改而不再与缓存的结果匹配,这可能导致不一致性的问
    题。
  4. 查询分布不均匀:在某些情况下,查询缓存可能会导致性能下降,因为它不能很好地应对不均匀的查询分布。

44、什么是事务的2阶段提交

所谓的MySQL事务的2阶段提交,其实是在更新过程中,保证binlog和redolog一致性的一种手段。

过程是:

  • Prepare阶段
    • 这个阶段SQL已经成功执行并生成redolog,处于preparel阶段。
  • BinLog持久化
    • binlog提交,通过write()将binlog内存日志数据写入文件缓冲区。
    • 通过fsync()将binlog从文件缓冲区永久写入磁盘。
  • Commit
    • 在执行引擎内部执行事务操作,更新redolog,处于Commit阶段。

那么,为什么这个过程需要用2阶段提交的方式呢?

假设我们执行一条SQL语句,修改他的name为Hollis:update user set name=‘hol1is’where id=
10。
假设先写入redo log成功,但是没来得及写入bin log,系统崩了。在MySQL重启后,可以根据redolog把记录
更新成hollis’,但是,binlog由于没写成功,所以他是没有记录下来这次变更的,那么也就意味着,主备同步的
时候,是缺了一条SQ儿的,导致主备库之间数据不一致。
那么,如果换个顺序,先写入binlog成功,但是没来及的写入redolog,系统崩了。在小ySQL重启之后,崩溃恢
复的时候由于redo log还没写,所以什么都不用做,数据库记录还是旧值。但是因为oinlog已经写入成功了,所
以在做主备同步的时候,就会把新值同步到备库,就导致了主备库之间数据不一致。
如上面的例子,如果不引入二阶段提交的话,在bin log和redo log没办法保证一致性的情况下,就会导致主备
库之间的数据不一致。
而为了解决这个问题,那就引入了2阶段提交,来整体的控制edo log和bin log的一致性写入。

2阶段如何保证一致性的?

引入2阶段提交之后,事务的提交过程就可能有以下三种情况:
情况一:一阶段提交之后崩溃了,即写入redo log,处于prepare状态的时候崩溃了
比时已经写了redolog,处于prepare*状态,binlog还没写。这时候如果崩溃恢复,直接回滚事务即可,这样主备
是一致的,就部没有执行这个事务。
情况二:一阶段提交成功,写完binlog之后崩溃了
此时,redolog:处于prepare状态,binlog已写入,这时候检查binlog中的事务是否存在并且完整,如果存在且
完整,则直接提交事务,如果不存在或者不完整,则回滚事务。
情况三:假设redolog处于commit状态的时候崩溃了,那么重启后的处理方案同情况二。
由此可见,两阶段提交能够确保数据的一致性。

如何判断binlog和redolog达成一致了?

当MySQL写完redolog并将它标记为prepare状态时,并且会在redolog中记录一个XID,它全局唯一的标识着这
个事务。而当你设置sync_binlog=1时,做完了上面第一阶段写redolog后,mysql就会对应binlog并且会直接
将其刷新到磁盘中。
下图就是磁盘上的row格式的oinlog记录。binlog结束的位置上也有一个XID。

只要这个XID和redolog中记录的XID是一致的,小ySQL就会认为binlog和redolog:逻辑上是一致的。

--------------------------------------------------Redis--------------------------------------------------

1、Redis 的数据类型有哪些

字符串 String、哈希 Hash、列表 List、集合 Set、有序集合 Zset。

其他:Bitmaps、HyperLogLogs、Geospatial。

2、Redis的Key和Value的设计原则有哪些

  1. 可读性:一个Key应该具有比较好的可读性,让人能看得懂是什么意思,而不是含湖不清。
  2. 简洁性:Key应该保持简洁,避免过长的命名,以节省内存和提高性能。
  3. 命名空间:使用命名空间来区分不同部分的Key。例如,可以为用户数据使用"user:"前缀,为缓存数据使用"cache:"前缀。

3、String类型的数据结构

Redis是用C语言写的,但是对应Redis的Sting,并不是C 语言中的字符串(即以空字符’\0’结尾的字符数组);Redis自定义了数据结构SDS(simple dynamic string)【简单动态字符串】,并将 SDS 作为 Redis的默认字符串表示。

struct sdshdr{
    //记录 buf 数组中未使用字节的数量
     int free;
    
    //记录buf数组已使用字节的数量
    //等于 SDS 保存字符串的长度
     int len;
        
     //字节数组,用于保存字符串
     char buf[];	//柔性数组
}

优点

减少修改字符串的内存重新分配次数

1、空间预分配

对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。

C++中数组在进行扩容时,往往会申请一个更大的数组,然后把数组拷贝过去。Redis同样基于这种策略提高了空间预分配机制。当执行字符串增长操作并且需要扩展内存时,程序不仅仅会给SDS分配必需的空间还会分配额外的未使用空间,其长度存到free属性中。具体如下:

  • 如果修改后len长度将小于1M,这时分配给free的大小和len一样,例如修改过后为10字节,那么给free也是10字节,buf实际长度变成了10+10+1 = 21byte(别忘记了\0的存在)。
  • 如果修改后len的长度大于等于1M,这时分配给free的长度为1M,例如修改过后为30M,那么给free是1M,buf实际长度变成了30M+1M+1byte。

在这里插入图片描述

2、惰性空间释放

对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。

在这里插入图片描述

为什么SDS的最大长度是512M?

Redis字符串使用int类型表示长度,一共有32个比特位。2^32字节=512M

SDS是如何扩容的?

空间预分配。先判断扩容长度与free的大小关系,如果够就直接拼接字符串,如果不够使用空间预分配的方式扩容

4、List类型的数据结构

  • List 的数据结构为快速链表 quickList。
  • 首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。
  • 当数据量比较多的时候才会改成 quicklist。因为普通的链表需要的附加指针空间太大,会比较浪费空间。比如这个列表里存的只是 int 类型的数据,结构上还需要两个额外的指针 prev 和 next。

在这里插入图片描述

  • Redis 将链表和 ziplist 结合起来组成了 quicklist。也就是将多个 ziplist 使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。

5、Set类型的数据结构

  • Set 数据结构是 dict 字典,字典是用哈希表实现的。
  • Java 中 HashSet 的内部实现使用的是 HashMap,只不过所有的 value 都指向同一个对象。Redis 的 set 结构也是一样,它的内部也使用 hash 结构,所有的 value 都指向同一个内部值。

6、Hash类型的数据结构

Hash 类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当 field-value 长度较短且个数较少时,使用 ziplist,否则使用 hashtable。

7、Zset类型的数据结构

Redis 中的 ZSet 在实现中,有多种结构,大类的话有两种,分别是ziplist(压缩列表)和skiplist(跳跃表),但是这只是以前,在Redis5.0中新增了一个listpack(紧凑列表)的数据结构,这种数据结构就是为了替代ziplist的,而在之后Redis7.0的发布中,在Zset的实现中,已经彻底不在使用zipList了。

在这里插入图片描述
当ZSet的元素数量比较少时,Redis会采用ZipList(ListPack)来存储ZSet的数据。ZipList(ListPack)是一种紧凑的列表结构,它通过连续存储元素来节约内存空间。当ZSet的元素数量增多时,Redis会自动将ZipList(ListPack)转换为SkipList,以保持元素的有序性和支持范围查询操作。

何时转换

总的来说就是,当元素数量少于128,每个元素的长度都小于64字节的时候,使用ZipList(ListPack),否则使用SkipList。

跳表

跳表也是一个有序链表,如下面这个数据结构:
在这里插入图片描述
在这个链表中,我们想要查找一个数,需要从头结点开始向后依次遍历和匹配,直到查到为止,这个过程是比较耗费时间的,他的时间复杂度是O(N)。

那么,怎么能提升遍历速度呢,有一个办法,那就是我们对链表进行改造,先对链表中每两个节点建立第一级索引,如下图所示:

在这里插入图片描述
有了我们创建的这个索引之后,我们查询元素12,我们先从一级索引6->9->17->26中查找,发现12介于9和17之间,然后,转移到下一层进行搜索,即9->12->17,即可找到12这个节点了。

可以看到,同样是查找12,原来的链表需要扁历5个元素(3、6、7、9、12),建立了一层索引之后,只需要遍历3个元素即可(6、9、12)。

有了上面的经验,我们可以继续创建二级索引、三级索引…

在这里插入图片描述
在这样一个链表中查找12这个元素,只需要遍历2个节点就可以了(9、12)。像上面这种带多级索引的链表,就是跳表。时间复杂度O(logN)。

8、Bitmaps介绍

  • Redis 提供了 Bitmaps 这个 “数据类型” 可以实现对位的操作。
  • Bitmaps 本身不是一种数据类型, 实际上它就是字符串(key-value) , 但是它可以对字符串的位进行操作。
  • Bitmaps 单独提供了一套命令, 所以在 Redis 中使用 Bitmaps 和使用字符串的方法不太相同。 可以把 Bitmaps 想象成一个以位为单位的数组, 数组的每个单元只能存储 0 和 1, 数组的下标在 Bitmaps 中叫做偏移量。

常用命令

setbit <key> <offset> <value>			设置Bitmaps中某个偏移量的值(01),offset偏移量从0开始
getbit <key> <offset>					获取Bitmaps中某个偏移量的值
bitcount <key> [start end]				统计字符串从stat字节到end字节比特值为1的数量
bitop and(or/not/xor) <destkey> key...	bitop是一个复合操作,它可以做多个Bitmapsand(交集)or(并集)、not
()、or(异或)操作并将结果保存在destkey中

每个独立用户是否访问过网站存放在 Bitmaps 中,将访问的用户记做 1,没有访问的用户记做 0,用偏移量作为用户的 id。设置键的第 offset 个位的值(从0算起),假设现在有 20 个用户,userid=1、6、11、15、19的用户对网站进行了访问,那么当前 Bitmaps 初始化结果如图:

在这里插入图片描述

127.0.0.1:6379> SETBIT users:20210101 1 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 6 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 11 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 15 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 19 1
(integer) 0

实例:获取 id=8 的用户是否在 2020-11-06 这天访问过, 返回0说明没有访问过

127.0.0.1:6379> GETBIT user:20210101 1
(integer) 0
127.0.0.1:6379> GETBIT users:20210101 1
(integer) 1
127.0.0.1:6379> GETBIT users:20210101 8
(integer) 0
127.0.0.1:6379> GETBIT users:20210101 100
(integer) 0

实例:计算 2022-11-06 这天的独立访问用户数量

127.0.0.1:6379> BITCOUNT users:20210101
(integer) 5

实例:

2020-11-04 日访问网站的userid=1,2,5,9。

setbit unique:users:20201104 1 1
setbit unique:users:20201104 2 1
setbit unique:users:20201104 5 1
setbit unique:users:20201104 9 1

2020-11-03 日访问网站的userid=0,1,4,9。

setbit unique:users:20201103 0 1
setbit unique:users:20201103 1 1
setbit unique:users:20201103 4 1
setbit unique:users:20201103 9 1

计算出两天都访问过网站的用户数量

bitop and unique:users:20201103 unique:users:20201104

计算出任意一天都访问过网站的用户数量(例如月活跃就是类似这种) , 可以使用or求并集

Bitmaps 与 set 对比

假设网站有 1 亿用户, 每天独立访问的用户有 5 千万, 如果每天用集合类型和 Bitmaps 分别存储活跃用户可以得到表:

set 和 Bitmaps 存储一天活跃用户对比

数据类型每个用户 id 占用空间需要存储的用户量全部内存量
集合64 位5000000064 位 * 50000000 = 400MB
数据Bitmaps1 位1000000001 位 * 100000000 = 12.5MB

很明显, 这种情况下使用 Bitmaps 能节省很多的内存空间, 尤其是随着时间推移节省的内存还是非常可观的。

但 Bitmaps 并不是万金油, 假如该网站每天的独立访问用户很少, 例如只有 10 万(大量的僵尸用户) , 那么两者的对比如下表所示, 很显然, 这时候使用 Bitmaps 就不太合适了, 因为基本上大部分位都是 0。

set 和 Bitmaps 存储一天活跃用户对比(用户比较少)

数据类型每个用户 id 占用空间需要存储的用户量全部内存量
集合64 位10000064 位 * 100000 = 800KB
数据Bitmaps1 位1000000001 位 * 100000000 = 12.5MB

9、HyperLogLog介绍

在工作当中,我们经常会遇到与统计相关的功能需求,比如统计网站 PV(PageView 页面访问量),可以使用 Redis 的 incr、incrby 轻松实现。但像 UV(UniqueVisitor 独立访客)、独立 IP 数、搜索记录数等需要去重和计数的问题如何解决?这种求集合中不重复元素个数的问题称为基数问题。

解决基数问题有很多种方案:

① 数据存储在 MySQL 表中,使用 distinct count 计算不重复个数。
② 使用 Redis 提供的 hash、set、bitmaps 等数据结构来处理。

以上的方案结果精确,但随着数据不断增加,导致占用空间越来越大,对于非常大的数据集是不切实际的。能否能够降低一定的精度来平衡存储空间?Redis 推出了 HyperLogLog。

  • Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是:在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
  • 在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
  • 但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。

什么是基数?

比如数据集 {1, 3, 5, 7, 5, 7, 8},那么这个数据集的基数集为 {1, 3, 5 ,7, 8},基数 (不重复元素) 为 5个。 基数估计就是在误差可接受的范围内,快速计算基数。

常用命令

pfadd <key> <element> [element...]				添加指定元素到HyperLogLog中
pfcount <key> [key..] 							计算HLL的近似基数,可以计算多个HLL,比如用HLL存储每天的UV,计算一周的UV可以使用7天的UV合并计算即可
pfmerge <destkey> <sourcekey> [sourcekey...]	将一个或多个sourcekey合并后的结果存储在另一个destkey中,比如每月活跃用户可以使用每天的活跃用户来合并计算可得

10、Geospatial介绍

Redis 3.2 中增加了对 GEO 类型的支持。GEO,Geographic,地理信息的缩写。该类型,就是元素的 2 维坐标,在地图上就是经纬度。redis 基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度 Hash 等常见操作。

常用命令

geoadd <key> <longitude> <latitude> <member>	加地理位置(经度,纬度,名称)
geopos <key> <member>							获得指定地区坐标值
geodist <key> <member1> <member2> m|km|ft|mi	获得两个位置之间的直线距离
单位:
m	表示单位为米[默认值]。
km	表示单位为千米。
mi	表示单位为英里。
t	表示单位为英尺。
如果用户没有显式地指定单位参数,那么GEODIST默认使用米作为单位
georadius <key> <longitude> <latitude> radius m|km|ft|mi	以给定的经纬度为中心,找出某一半径内的元素

部分实例

geoadd china:city 121.47 31.23 shanghai
geoadd china:city 106.50 29.53 chongqing 114.05 22.52 shenzhen 116.38 39.90
georadius china:city110 30 1000 km

两极无法直接添加,一般会下载城市数据,直接通过Java程序一次性导入。有效的经度从 -180 度到 180 度。有效的纬度从 -85.05112878 度到 85.05112878 度。当坐标位置超出指定范围时,该命令将会返回一个错误。已经添加的数据,是无法再次往里面添加的。·

11、Redis 为什么这么快

  1. 基于内存:Redis 是一种基于内存的数据库,数据存储在内存中,数据的读写速度非常快,因为内存访问速度比硬盘访问速度快得多。
  2. 单线程模型:Redis 使用单线程模型,这意味着它的所有操作都是在一个线程内完成的,不需要进行线程切换和上下文切换。这大大提高了Redis的运行效率和响应速度。
  3. 高效的数据结构:Redis 提供了多种高效的数据结构,如哈希表、有序集合、列表等,这些数据结构都被实现得非常高效,能够在O(1)的时间复杂度内完成数据读写操作,这也是 Redis 能够快速处理数据请求的重要因素,并且对数据存储进行了一些优化,比如跳表。
  4. 多路复用IO:Redis 采用多路复用IO模型,能够处理大量的客户端连接请求,同时保持较低的系统负载。Redis通过IO多路复用技术,实现了单个线程同时处理多个客户端连接的能力,从而提高了Redis的并发性能。
  5. 多线程的引入:在Redis6.0中,为了进一步提升IO的性能,引入了多线程的机制。采用多线程,使得网络处理的请求并发进行,就可以大大的提升性能。多线程除了可以减少由于网络I/O等待造成的影响,还可以充分利用CPU的多核优势。

12、Redis 为什么设计成单线程的

多线程的目的,就是通过并发的方式来提升IO的利用率和CPU的利用率。Redis不需要提升CPU利用率,因为Redis的操作基本都是基于内存的,CPU资源根本就不是Redis的性能瓶颈。纯粹的内存操作(非常快速),采用单线程,避免了不必要的上下文切换。

13、请说说 Redis 的线程模型

多个 socket 可能并发地产生不同的操作,每个操作对应不同的事件,IO 多路复用程序会监听多个 sokcet,会将socket 放入一个队列中排队,每次从队列中取出一个 socket 给事件分派器,其是单线程的,因此 redis 才叫做单线程的模型,文件事件分派器会根据每个 socket 当前产生的事件,来选择对应的事件处理器(命令请求处理器:写数据到 redis,命令回复处理器:客户端要从 redis 读数据,连接应答处理器:客户端要连接 redis)来处理。

14、Redis 和 Memcached 的区别有哪些

  • 数据结构不同:Memcache 仅支持 key-value 结构的数据类型,Redis 不仅仅支持简单的 key-value 类型的数据,同时还提供 list,set,hash 等数据结构的存储。
  • 持久化方式不同:Redis 支持多种持久化方式,如RDB和AOF,可以将数据持久化到磁盘上,而 Memcached 不支持持久化。
  • 数据分片方式不同:Redis使用哈希槽分片,可以实现数据的自动分片和负载均衡,而 Memcached 只能手动分片。
  • 处理数据的方式不同:Redis使用单线程处理数据请求,支持事务、Lua脚本等高级功能,而 Memcached 使用多线程处理数据请求,只支持基本的GET、SET操作。
  • 协议不同:Redis使用自己的协议,支持多个数据库,可以使用密码进行认证;而Memcached使用文本协议,只支持一个默认数据库。

15、Redis使用什么协议进行通信

Redis使用自己设计的一种文本协议进行客户端与服务端之间的通信 一 RESP(Redis Protocol specification),这种协议简单、高效,易于解析,被广泛使用。

16、缓存数据库为什么需要持久化

缓存数据库的数据是存在内存中,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证在内存中的数据不会丢失,这种机制就叫缓存数据库持久化机制。

17、Redis 有几种持久化方式

RDB

Redis 会单独创建(fork)一个子进程来进行持久化,首会将数据入到一个临时文件中,待写入过程结束了再用这个临时文件替换上次持久化好的文件。整个过程中,主进程是不进行任何 IO 操作的,这就确保了极高的性能。快照文件小节省磁盘空间、恢复速度快,适合大规模的数据恢复,对数据完整性和一致性要求不高更适合使用。

  • 快照保持策略
手动触发saveSAVE命令执行快照的过程会阻塞所有客户端的请求,应避免在生产环境使用此命令
手动触发bgsaveBGSAVE命令可以在后台异步进行快照操作,快照的同时服务器还可以继续响应客户端的请求,因此需要手动执行快照时推荐使用BGSAVE命令
自动触发save m n根据配置规则进行自动快照,如SAVE 100 10,100秒内至少有10个键被修改则进行快照

RDB的优点是:快照文件小、恢复速度快,适合做备份和灾难恢复。
RDB的缺点是:定期更新可能会丢数据。

AOF

以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录,可以打开文件看到详细的操作记录。备份机制更稳健,丢失数据概率更低(它有三种写回策略),但是比起 RDB 占用更多的磁盘空间。恢复备份速度要慢。

  • 写回策略
    • Always 同步写回:每个写命令执行完,立马同步地将日志写回磁盘。
    • Everysec 每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘。
    • No 操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

AOF的优点是:可以实现更高的数据可靠性、支持更细粒度的数据恢复,适合做数据存档和数据备份。
AOF的缺点是:文件大占用空间更多,每次写操作都需要写磁盘导致负载较高。

比较
在这里插入图片描述
Redis 4.0 混合持久化

AOF和RDB各自有优缺点,为了让用户能够同时拥有上述两种持久化的优点,推出了RDB-AOF混合持久化。

aof-use-rdb-preamble参数来开启 混合持久化,默认是yes开启的。混合持久化结合了 RDB 和 AOF 的优点,Redis 5.0 默认是开启的。

Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。

在这里插入图片描述
于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。

混合持久化的加载流程如下:

在这里插入图片描述

优点:

混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF的优点,有减低了大量数据丢失的风险。

缺点:

AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差,并且兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

18、RDB 为什么要写入临时文件

在替换持久化文件之前要写入临时文件,比如同步10个数据在第8个时候中断了,如果直接同步到持久化dump文件这样是不行的,应该先同步到临时文件,这样主要为了保证 dump 文件数据的一致性完整性,也是处于数据的完全考虑,这个过程用到的就是写时复制技术

19、Redis 中 AOF 重写

因为AOF会把每个数据更改的操作指令,追加存储到AOF文件里面。所以很容易导致AOF文件出现过大,造成IO性能问题。Redis为了解决这个问题,设计了AOF重写机制,也就是说把AOF文件里面相同的指令进行压缩,只保留最新的数据指令。

简单来说,如果AOF文件里面存储了某个key的多次变更记录,但是实际上,最终在做数据恢复的时候,只需要执行最新的指令操作就行了,历史的数据就没必要存在这个文件里面占空间。

重写原理,如何实现重写

AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写 (也是先写临时文件最后再 rename),redis4.0 版本后的重写,是指把 rdb 的快照,以二进制的形式附在新的 aof 头部,作为已有的历史数据,替换掉原来的流水账操作。

no-appendfsync-on-rewrite:

  • 如果 no-appendfsync-on-rewrite=yes ,不写入 aof 文件只写入缓存,用户请求不会阻塞,但是在这段时间如果宕机会丢失这段时间的缓存数据。(降低数据安全性,提高性能)
  • 如果 no-appendfsync-on-rewrite=no,还是会把数据往磁盘里刷,但是遇到重写操作,可能会发生阻塞。(数据安全,但是性能降低)

触发机制,何时重写

Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的一倍且文件大于 64M 时触发。

重写虽然可以节约大量磁盘空间,减少恢复时间。但是每次重写还是有一定的负担的,因此设定 Redis 要满足一定条件才会进行重写。

  • auto-aof-rewrite-percentage:设置重写的基准值,文件达到 100% 时开始重写(文件是原来重写后文件的 2
    倍时触发)。
  • auto-aof-rewrite-min-size:设置重写的基准值,最小文件 64MB。达到这个值开始重写。
  • 系统载入时或者上次重写完毕时,Redis 会记录此时 AOF 大小,设为 base_size
  • 如果 Redis 的 AOF 当前大小 >= base_size +base_size*100% (默认) 且当前大小 >=64mb (默认) 的情况下,Redis 会对 AOF 进行重写。
  • 例如:文件达到 70MB 开始重写,降到 50MB,下次什么时候开始重写?100MB

重写流程

  1. bgrewriteaof 触发重写,判断是否当前有 bgsave 或 bgrewriteaof 在运行,如果有,则等待该命令结束后再继续执行
  2. 主进程 fork 出子进程执行重写操作,保证主进程不会阻塞
  3. 子进程遍历 redis 内存中数据到临时文件,客户端的写请求同时写入 aof_buf 缓冲区和 aof_rewrite_buf 重写缓冲区,保证原 AOF 文件完整以及新 AOF 文件生成期间的新的数据修改动作不会丢失
  4. 子进程写完新的 AOF 文件后,向主进程发信号,父进程更新统计信息。主进程把 aof_rewrite_buf 中的数据写入到新的 AOF 文件
  5. 使用新的 AOF 文件覆盖旧的 AOF 文件,完成 AOF 重写。

在这里插入图片描述

20、什么是 Redis 事务

Redis 事务是一个单独的隔离操作,事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

Redis 事务的主要作用就是串联多个命令防止别的命令插队

  • MULTI:标记一个事务块的开始。
  • DISCARD:取消事务,放弃执行事务块内的所有命令。
  • EXEC:执行所有事务块内的命令。
  • UNWATCH:取消WATCH命令对所有key的监视。
  • WATCH key [key…]:监视一个或多个key,如果在事务执行之前这个或这些key被其他命令所改动,那么事务将被打断。
Jedis jedis = new Jedis("localhost", 6379);
// 开启事务:MULTI
Transaction tx = jedis.multi();
// 扣减库存
tx.decrBy("item:10001:stock", 1);
// 计算商品总价
tx.mu1(100, 2);
// 下单记录
tx.rpush("orders", "user:10001,item:10001,amount:2,total:200");
// 执行事务:EXEC
List<Object> result = tx.exec();
// 检查事务执行结果
if (result = null) {
    System.out.println("事务执行失败!");
} else {
    System.out.println("事务执行成功,订单已提交!");
}

21、Redis 事务的注意点有哪些

  • 组队中某个命令出现了报告错误,执行时整个的所有队列都会被取消。(例如 set k1 “yang”,set “jun”)
  • 如果执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。(例如set k1 “yang”, incr k1)

22、Redis 有几种数据过期策略

Redis的过期策略采用的是定期删除和惰性删除相结合的方式

定期删除:指的是 redis 默认每隔100ms就随机抽取一些设置了过期时间的key,检查其是否过期,如果其就删除。假设 redis 里放了10万个 key,都设置了过期时间,每隔几百毫秒,就检查10万个key,那 redis 基本就挂了,cpu负载会很高,消耗在检查过期key上了。因此,实际上 redis 是每隔 100ms 随机抽取一些 key 来检查和删除的。

问题:定期删除可能导致很多过期 key 到了时间并没有被删除掉。解决:惰性删除。

惰性删除:并不是key到时间就被删除掉,而是查询这个 key 的时候,redis再懒惰地检查一下

但是这实际上还有问题,如果定期删除漏掉了很多过期key,然后也没有及时去查,也就没走惰性删除,此时大量过期 key 堆积在内存里,导致 redis 内存块耗尽了,怎么办?走内存淘汰机制

23、内存淘汰机制

如果 redis 的内存占用过多,此时会进行内存淘汰,有如下一些策略:

  1. noeviction:当内存不足以容纳新写入数据时,新写入操作会报错(默认)。
  2. allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(这个是最常用的)。
  3. allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key
  4. volatile-lru:当内存不足以容纳新写入数据时,在过期时间的键空间中,移除最近最少使用的key
  5. volatile-random:当内存不足以容纳新写入数据时,在过期时间的键空间中,随机移除某个 key
  6. volatile-ttl:当内存不足以容纳新写入数据时,在设过期时间的键空间中,有更早过期时间的key优先移除

24、什么是缓存穿透、缓存击穿、缓存雪崩

缓存穿透

缓存穿透是指缓存服务器中没有缓存数据,数据库中也没有符合条件的数据,导致业务系统每次都绕过缓存服务器
查询下游的数据库,缓存服务器完全失去了其应用的作用。

解决方案

1) 缓存空对象

2

2) 布隆过滤器

布隆过滤器判定不存在的数据,那么该数据一定不存在,利用它的这一特点可以防止缓存穿透。

首先将用户可能会访问的热点数据存储在布隆过滤器中(也称缓存预热),当有一个用户请求到来时会先经过布隆过滤器,如果请求的数据,布隆过滤器中不存在,那么该请求将直接被拒绝,否则将继续执行查询。相较于第一种方法,用布隆过滤器方法更为高效、实用。其流程示意图如下:

在这里插入图片描述

缓存预热:是指系统启动时,提前将相关的数据加载到 Redis 缓存系统中。这样避免了用户请求的时再去加载数据。

缓存击穿

key 对应的数据库数据存在,但在 redis 中过期,此时若有大量并发请求过来,这些请求发现缓存过期,一般都会从后端数据库加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端数据库压垮。

解决方案

1)定时更新

在缓存处理上,同理,比如某一个热点数据的过期时间是1小时,那么每59分钟,通过定时任务去更新这个热点
key,并重新设置其过期时间。

2) 分布式锁

采用分布式锁的方法,重新设计缓存的使用方式,过程如下:

  • 上锁:当我们通过 key去查询数据时,首先查询缓存,如果没有,就通过分布式锁进行加锁,第一个获取锁的进程进入后端数据库查询,并将查询结果缓到Redis中。
  • 解锁:当其他进程发现锁被某个进程占用时,就进入等待状态,直至解锁后,其余进程再依次访问被缓存的 key。

缓存雪崩

缓存雪崩是指当大量缓存同时过期或缓存服务宕机,所有请求的都直接访问数据库,造成数据库高负载,影响性
能,甚至数据库宕机。

解决方案

1)将缓存失效时间分散开

比如可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

2)集群

类以的,在缓存雪崩问题防治上面,一个比较典型的技术就是采用集群方式部署,使用集群可以避免服务单点故
障。

25、什么是大Key问题

Big Key是Redis中存储了大量数据的Key,不要误以为big key只是表示Key的值很大,他还包括这个Key对应的 value占用空间很多的情况,通常在String、list、hash、set、Zset等类型中出现的问题比较多。其中String类型就是字符串的值比较大,而其他几个类型就是其中元素过多的情况。

  1. 占用内存:大量的big key也会占满Redis的内存,让Redis无法继续存储新的数据,而且也会导致Redis卡住。
  2. 搜索困难:由于大key可能非常大,因此搜索key内容时非常困难,可能需要花费较长的时间完成搜索任务。
  3. 影响Rdis备份和恢复:如果从RDB文件中恢复全量数据时,可能需要大量的时间,甚至无法正常恢复。
  4. 过期执行耗时:如果Big key设置了过期时间,当过期后key会被删除,而大key的删除过程也比较耗时。

多大算大

对于String类型的Value值,值超过10MB(数据值太大)。
对于Set类型的Value值,含有的成员数量为10000个(成员数量多)。
对于List类型的Value值,含有的成员数量为10000个(成员数量多)。
对于Hash格式的Value值,含有的成员数量1000个,但所有成员变量的总Value值大小为1000MB(成员总的体积过大)。

但是,这些并不是绝对的限制,而是一个经验值,具体的情况还需要根据应用场景和实际情况进行调整。

识别big key

在识别方面,Redis中的big key可以识别的程序是“redis-cli",用户可以通过在终端中输入“redis-cli bigkeys”来获取Redis中的big key。当redis-cli被调用时,它将搜索所有Redis数据库中包,含大量内存数据的 key并且会将其保存在本地标准输出文件中。

处理Big Key

想要解决Big Key的问题,根据具体的业务情况有很多不同的方案,下面简单列几个:

  • 有选择地删除Big Key,针对Big Key我们可以针对一些访问频率低的进行有选择性的删除,删除Big Key来优化内存占用。
  • 除了手动删除以外,还可以通过合理的设置缓存TTL,避免过期缓存不及时删除而增大key大小。
  • Big Key的主要问题就是Big,所以我们可以想办法解决big的问题,那就是拆分呗,把big的key拆分开:
    • a、在业务代码中,将一个big key有意的进行拆分,比如根据日期或者用户尾号之类的进行拆分。使用小键替代大键可以有效减小存储空间,从而避免影响系统性能。
    • b、使用Cluster集群模式,以将大key分散到不同服务器上,以加快响应速度。

26、什么是热Key问题,如何解决热key问题

如果在同一个时间点上,Redis中的同一个key被大量访问,就会导致流量过于集中,使得很多物理资源无法支撑,如网络带宽、物理存储空间、数据库连接等。

主要可以考虑,热点key拆分、多级缓存、热key备份、限流等方案来解决。

热点key拆分

讲一个热key拆分成多个key,在每一个Key后面加一个后缀名,然后把这些key分散到多个实例中。这样在客户端请求的时候,可以根据一定的规则计算得出一个固定的Key,这样多次请求就会被分散到不同的节点上了。

比如iphone14是个热点key,把他拆分成iphone140001、iphone140002、iphone140003、iphone140004,然后把它们分别存储在 cluster中的不同节点上,这样用户在查询iphone14的时候,先根据用户lD算出一个下标,然后就访问其中一个节点就行了。

多级缓存

多级缓存解决热ky问题最主要的方式就是加缓存。通过缓存的方式尽量减少系统交互,使得用户请求可以提前返回。这样即能提升用户体验,也能减少系统压力。缓存的方式有很多,有些数据可以缓存在客户的客户端刘览器中,有些数据可以缓存在距离用户就近的CDN中,有些数据可以通过Rdis等这类缓存框架进行缓存,还有些数据可以通过服务器本地缓存进行。这种使用多个缓存的情况,就组成了二级缓存、三级缓存等多级缓存了。总之,通过缓存的方式尽量减少用户的的访问链略的长度。

27、如何保证缓存与数据库的数据一致性

在这里插入图片描述

28、什么是主从复制

一个主机多个从机,有 master 主机可执行写命令,其他 salve 从机只能只能执行读命令,这种读写分离的模式可以大大减轻 Redis 主机的数据读取压力,Redis 主机会一直将自己的数据复制给 Redis 从机,从而实现主从同步。在这个过程中,不但提高了Redis 的效率,并同时提供了多个数据备份。在这里插入图片描述

主从复制原理

在 Redis的主从复制实现中,包含两个类似阶段:全量数据同步增量数据同步

  1. Slave 从服务器启动成功连接到 Master 后会发送一个 sync 命令主动进行数据同步, Master 主服务器接到命令后先进行数据的持久化,然后把持久化的文件发送给 Slave,拿到持久化文件后进行读取完成数据同步,此时称为全量复制,只要是重新连接 Master,全量复制将被自动执行。
  2. Master 继续将新的所有收集到的修改命令依次传给 Slave,完成同步,此时称为增量复制

为什么主从全量复制使用RDB而不是AOF

  • RDB文件存储的内容是经过压缩的二进制数据,文件很小。AOF文件存储的是每一次写命令通常会必RDB文件大很多。因此,传输RDB文件更节省带宽,速度也更快。
  • 使用RDB文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而AOF则需
    要依次执行每个写命令,速度非常慢。也就是说,与AOF相比,恢复大数据集的时候,RDB速度更快。

主从复制方案有什么痛点

  1. 主从复制方案下,master 发生宕机的话可以手动将某一台 slave 升级为master,Redis服务可用性提高。整个过程需要人工干预。人工干预大大增加了问题的处理时间以及出错的可能性。(解决方案 Redis Sentinel)
  2. 主从复制方案在高并发场景下能力有限。如果缓存的数据量太大或者并发量要求太高,主从复制就没办法满
    足我们的要求了。(解决 Redis Cluster)

29、Redis Cluster 用来解决什么问题

  1. 解决容量问题,一台服务器使用的容量是有限的,可以用集群进行扩容。
  2. 解决并发写问题,并发的请求由一台服务器压力很,可以用集群多台处理分担压力。
  3. 无中心化配置相对简单,从哪个节点都可以进入,节点不能处理会分配给其他节点进行处理。

主从复制是Redis最简单的集群模式。这个模式主要是为了解决单点故障的问题,所以将数据复制多个副本中,这样即使有一台服务器出现故障,其他服务器依然可以继续提供服务。

主从模式中,包括一个主节点(Master)和一个或多个从节点(Slave)。主节点负责处理所有写操作和读操作,而从节点则复制主节点的数据,并且只能处理读操作。当主节点发生故障时,可以将一个从节点升级为主节点,实现故障转移(需要手动实现)。

在这里插入图片描述
主从复制的优势在于简单易用,适用于读多写少的场景。它提供了数据备份功能,并且可以有很好的扩展性,只要增加更多的从节点,就能让整个集群的读的能力不断提升。但是主从模式最大的缺点,就是不具备故障自动转移的能力,没有办法做容错和恢复。主节点和从节点的宕机都会导致客户端部分读写请求失败,需要人工介入让节点恢复或者手动切换一台从节点服务器变成主节点服务器才可以。并且在主节点宕机时,如果数据没有及时复制到从节点,也会导致数据不一致。

在这里插入图片描述

30、什么是Redis的数据分片

Redis的数据分片是一种将一个Redis数据集分割成多个部分,分别存储在不同的Redis节点上的技术。它可以用于将一个单独的Redis数据库扩展到多个物理机器上,从而提高Redis集群的性能和可扩展性。

在Redis的Cluster集群模式中,使用哈希槽的方式来进行数据分片

一个 Redis 集群包含 16384 个插槽(hash slot),数据库中的每个键都属于这 16384 个插槽的其中一个。集群使用公式 CRC16 (key) % 16384 来计算键 key 属于哪个槽, 其中 CRC16 (key) 语句用于计算键 key 的 CRC16 校验和 。

集群中的每个节点负责处理一部分插槽。 举个例子, 如果一个集群可以有主节点, 其中:

  • 节点 A 负责处理 0 号至 5460 号插槽。
  • 节点 B 负责处理 5461 号至 10922 号插槽。
  • 节点 C 负责处理 10923 号至 16383 号插槽。

其实作用就是将我们 set 的 key 通过计算平均分配到不同的主机上。

做一个举例:

在集权部署下,使用 set key yang 增加 key 值,他会返回

127.0.0.1:6379>set key yang
Redirected to slot [12706]located at 192.168.44.168:6381

这个 12706 就是用来确定 这个 key 应该存在的节点,因为每个节点的范围不一样,这样这个数据就由节点 C 处理了,这也是无中心化集集群的一个特点,不管从哪里进入,如果不能处理会提交给其他节点,就像是这个例子12706 的插槽是在6379主机执行的,但是它范围是0 号至 5460 号插是不能够处理,它就会分配给到 B,B不能处理就给C。

用批量入值会有什么问题?举例:

192.168.44.168:6379>mset name lucy age 20 address china
(error)CROSSSLOT Keys in request don't hash to the same slot

但是我就想加入多个值我们应该加入组才行,让它们归属到一个组就行了:

192.168.44.168:6379>mset name{user} lucy age{user} 20

为什么是 16384 呢?

16384 是 Redis 中哈希槽数量的默认值,这个数字并没有什么特殊的含义,而是一个经验值。

31、什么是哨兵模式

为了解决主从模式的无法自动容错及恢复的问题,Redis引入了一种哨兵模式的集群架构。哨兵模式是在主从复制的基础上加入了哨兵节点。哨兵节点是一种特殊的Redis节点,用于监控主节点和从节点的状态。当主节点发生故障时,哨兵节点可以自动进行故障转移,选择一个合适的从节点升级为主节点,并通知其他从节点和应用程序进行更新。

Redis 官方推荐一种高可用方案,也就是 Redis Sentinel 哨兵模式,它弥补了主从模式的不足。Sentinel 通过监控的方式获取主机的工作状态是否正常,当主机发生故障时, Sentinel 会自动进行 Failover(即故障转移),并将其监控的从机提升主服务器(master),从而保证了系统的高可用性

在这里插入图片描述

Redis 的哨兵有什么功能

  1. 集群监控,监控所有redis节点(包括sentinel节点自身)的状态是否正常。
  2. 故障转移,如果 Master node 挂掉了,会自动转移到 Slave node 上,确保整个Redis系统的可用性。
  3. 消息通知,通知 slave 新的 master 连接信息,让它们执行 replicaof 成为新的 master 的 slave。.
  4. 配置提供,如果故障转移发生了,通知 Client 客户端新的 Master 地址。

Sentinel 如何检测节点是否下线

  1. 主观下线:sentinel节点认为某个Redis节点已经下线了(主观下线),但还不是很确定,需要其他sentinel节点的投票。
  2. 客观下线:法定数量(通常为过半)的sentinel节点认定某个Redis节点已经下线(客观下线),那它就算是真的下线了。

也就是说,主观下线当前的sentinel自己认为节点宕机,客观下线是sentinel整体达成一致认为节点宕机。

在这里插入图片描述

默认情况下,Sentinel哨兵会以每秒一次的频率向所有与它创建命令连接的实例(包括主服务器、从服务器、其他Sentinel)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。

如果对应的节点超过规定的时间没有进行有效回复的话,就会被其认定为是主观下线(SDOWN)。注意!这里的有效回复不一定是PONG,可以是 -LOADING 或者 -MASTERDOWN。

在这里插入图片描述

如果被认定为主观下线的是 slave 的话,sentinel 不会做什么事情,slave下线对Redis集群的影响不大,Redis集群对外正常提供服务。但如果是master被认定为主观下线就不一样了,sentinel 整体还要对其进行进一步核实,确保 master 是真的下线了。

所有 sentinel 节点要以每秒一次的频率确认 master 的确下线了,当法定数量(通常为过半)的 sentinel 节点认定 master 已经下线,master才被判定为客观下线(ODOWN)。这样做的目的是为了防止误判,毕竟故障转移的开销还是比较大的,这也是为什么Redis官方推荐部署多个sentinel节点(哨兵集群)。

在这里插入图片描述

随后,sentinel 中会有一个 Leader 的角色来负责故障转移,也就是自动地从 slave 中选出一个新的 master 并执行完相关的一些工作(比如通知slave新的master连接信息,让它们执行replicaof成为新的master的slave)。

如果没有足够数量的 sentinel 节点认定 master 已经下线的话,当 master 能对 sentinel 的PlNG命令进行有效回复之后,master也就不再被认定为主观下线,回归正常。

如何从 Sentinel 集群中选择出 Leader

大部分共识算法都是基于 Paxos 算法改进而来,在 sentinel 选举 leader 这个场景下使用的是 Raft 算法

Sentinel 如何选择出新的 master

故障恢复是指主机down掉需要从机来替代它工作,故障恢复选择条件依次为

  1. 选择优先级靠前的(优先级:在 redis.conf 中默认 slave-priority 100,值越小优先级越高)。
  2. 优先级相同时,选择偏移量最大的(偏移量:指从机获得主机数据最全的概率)。
  3. 偏移量也相同时候,选择runid最小的(runid:每个 redis 实例启动后都会随机生成一个 40 位的 runid)。

32、假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,全部找出来

使用 keys 指令可以扫出指定模式的 key 列表。但是Redis 的单线程的。keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan 指令,scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys 指令长。

SCAN 0 遍历返回结果,使用 MATCH 参数匹配前缀

33、Redis 什么时候需要序列化?Redis 序列化方式有哪些

Redis 在进行数据持久化(RDB 和 AOF)以及进行数据传输时都需要对数据进行序列化和反序列化。因此,一般来说,在使用 Redis 存储对象类型的数据时,需要进行序列化操作。

Redis 支持多种数据格式的序列化,包括以下几种:

  • String 序列化:Redis 使用二进制安全协议,因此可以直接将 Java 对象序列化成二进制形式存储在 Redis 的 String 数据结构中,常用的序列化方式包括 JDK 自带的序列化方式、Kryo 等。
  • JSON 序列化:JSON 是一种轻量级的数据交换格式,易于理解和生成,Redis 可以使用各种 JSON 库(如 Jackson、Gson 等)将 Java 对象序列化为 JSON 字符串后存储在 Redis 中。
  • XML 序列化:XML 也是一种通用的数据格式,Redis 可以使用 JAXP 标准库或者其他第三方库(如 XStream 等)将 Java 对象序列化为 XML 字符串后存储在 Redis 中。
  • Protobuf 序列化:Protobuf 是 Google 开发的一种高效的二进制序列化库,可以将 Java 对象序列化为紧凑的二进制格式,并支持跨语言的数据传输和兼容性。

在选择序列化方式时,需要考虑序列化效率、数据大小、可读性、兼容性等因素。同时,也需要注意在进行序列化和反序列化时可能会出现的异常和安全问题。

21、如何使用 Redis 实现消息队列

Redis 的 list (列表) 数据结构常用来作为异步消息队列使用,使用 rpush/lpush 操作入队列,使用 lpop 和 rpop 来出队列。rpush 和 lpop 结合 或者 lpush 和 rpop 结合。

在这里插入图片描述
客户端是通过队列的 pop 操作来获取消息,然后进行处理。处理完了再接着获取消息,再进行处理。如此循环往复,这便是作为队列消费者的客户端的生命周期。

20、如何使用 Redis 实现分布式限流

基于Redis的令牌桶算法

if ( redisTemplate.opsForList().leftPop("limit_list") != null) {
    System.out.println(1);
}

定时

if (redisTemplate.opsForList().size("limit_list") < 10) {
    redisTemplate.opsForList().rightPush("limit_list", UUID.randomUUID().toString());
}

Redis如何实现发布订阅

Redis如何实现延迟消息

如何基于Redis:实现滑动窗口限流

如何用SETNX实现分布式锁

Redissonl的watch dog机制是怎么样的

如何基于Redisson实现一个延迟队列

为什么需要延迟双删,两次删除的原因是什么

--------------------------------------------------Kafka--------------------------------------------------

1、为什么要使用 kafka

1. 解耦:在一个复杂的系统中,不同的模块或服务之间可能需要相互依赖,如果直接使用函数调用或者API调用的方式,会造成模块之间的耦合,当其中一个模块发生改变时,需要同时修改调用方和被调用方的代码。而使用消息队列作为中间件,不同的模块可以将消息发送到消息队列中,不需要知道具体的接收方是谁,接收方可以独立地消费消息,实现了模块之间的解耦。

2. 异步:有些操作比较耗时,例如发送邮件、生成报表等,如果使用同步的方式处理,会阻塞主线程或者进程,导致系统的性能下降。而使用消息队列,可以将这些操作封装成消息,放入消息队列中,异步地处理这些操作,不影响主流程的执行,提高了系统的性能和响应速度。

3. 削峰:削峰是一种在高并发场景下平衡系统压力的技术,在削峰的过程中,通常使用消息队列作为缓冲区,将请求放入消息队列中,然后在系统负载低的时候进行处理。这种方式可以将系统的峰值压力分散到较长的时间段内,减少瞬时压力对系统的影响,从而提高系统的稳定性和可靠性。

2、Kafka 的架构是怎么样的

在这里插入图片描述

1、Producer 生产者

生产者负责将消息发布到 kafka 中的一个或多个主题,每个主题包含一个或多个分区,消息保存在各个分区上,每一个分区都是一个顺序的,分区中的消息都被分了一个序列号,称之为偏移量,就是指消息在分区中的位置,所有分区的消息加在一起就是一个主题的所有消息。

分区策略

分区策略说明
轮询策略按顺序轮流将每条数据分配到每个分区中
随机策略每次都随机地将消息分配到每个分区
按键保存策略生产者发送数据的时候,可以指定一个key,计算这个key的hashCodet值,按照hashCodel的值对不同消息进行存储

如果 topic 有多个 partition,消费数据时就不能保证数据的顺序。严格保证消息的消费顺序的场景下,需要将分区数目设为1 或者指定消息的 key。

消息发送

public Future<RecordMetadata> send(ProducerRecord<K, V> record);
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback);

生产者架构图

消息在通过 send 方法发往 broker 的过程中,有可能需要经过拦截器、序列化器、分区器一系列之后才能被真正地发往 broker。整个生产者客户端由两个线程协调运行,这两个线程分别为主线程和Sender发送线程。

在这里插入图片描述
① 主线程

拦截器

生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。通过自定义实现 ProducerInterceptor 接口来使用。

序列化

生产者需要用序列化器把对象转换成字节数组才能通过网络发送给 Kafka。消费者需要用反序列化把从 Kafka 中收到的字节数组转换成相应的对象。自带的有StringSerializer,ByteArray、ByteBuffer、Bytes、Double、Integer、Long等,还可以自定义序列化器。

分区器

如果消息中没有指定 partition 字段,那么就需要依赖分区器,根据 key 这个字段来计算 partition 的值。也可以自定义分区器。

消息累加器

消息累加器主要用来缓存消息以便 Sender线程可以批量发送进而减少网络传输的资源消耗以提升性能。消息累加器的缓存大小可以通过buffer.memory配置。在消息累加器的内部为每个分区都维护了一个双端队列,主线程发送过来的消息都会被追加到某个双端队列中,队列中的内容就是 ProducerBatch,即Dqueue< ProducerBatch >。

当一条消息流入消息累加器,如果这条消息小于batch.size参数大小则以batch.size参数大小创建 ProducerBatch,否则以消息的实际大小创建 ProducerBatch。

② Sender发送线

程负责从消息累加器中获取消息并将其发送到 Kafka 中。后续 Sender 从缓存中获取消息,进行转换,发送到broker。在发送前还会保存到InFlightRequests中,作用是缓存已经发送出去但还没有收到响应的请求,缓存数量由max.in.flight.requests.per.connection参数确定,默认是5,表示每个连接最多缓存5个未响应的请求。

2、Consumer 消费者

消费者,消息的订阅者,可以订阅一个或多个主题,并且依据消息生产的顺序读取他们,消费者通过检查消息的偏移量来区分已经读取过的消息。消费者一定属于某一个特定的消费组。消息被消费之后,并不被马上删除,这样多个业务就可以重复使用 kafkal 的消息,我们某一个业务也可以通过修改偏移量达到重新读取消息的目的,偏移量由用户控制。消息最终还是会被删除的,默认生命周期为1周(7*24小时)。

订阅主题和分区

通过 subscribe 方法订阅主题具有消费者自动再均衡的功能,在多个消费者的情况下可以根据分区分配政策来自动分配各个消费者与分区的关系,以实现消费者负载均衡和故障自动转移。而通过 assign 方法则没有。

消息消费

Kafka 中的消息是基于推拉模式的。Kafka 中的消息消费是一个不断轮询的过程,消费者所要做的就是重复地调用poll 方法,而 poll 方法返回的是所订阅的主题(分区)上的一组消息。如果没有消息则返回空。

public ConsumerRecords<K, V> (final Duration timeout)

timeout 用于控制 poll 方法的阻塞时间,没有消息时会阻塞。

位移提交

Kafka 中的每条消息都有唯一的 offset,用来标识消息在分区中对应的位置。Kafka 默认的消费唯一的提交方式是自动提交,由enable.auto.commit配置,默认为true。自动提交不是每一条消息提交一次,而是定期提交,周期由auto.commit.interval.ms配置,默认为5秒。

自动提交可能发生消息重复或者丢失的情况,Kafka 还提供了手动提交的方式。enable.auto.commit配置为false开启手动提交。

指定位移消费

在 Kafka 中每当消费者查找不到所记录的消费位移时,就会根据消费者客户端参数auto.offset.reset的配置来决定从何处开始进行消费。默认值为 lastest,表示从分区末尾开始消费消息;earliest 表示从起始开始消费;none为不进行消费,而是抛出异常。

seek 可以从特定的位移处开始拉去消息,得以追前消费或回溯消费。

public void seek(TopicPartition partition, long offset)

再均衡

再均衡是指分区的所属权从一个消费者转移到另一个消费者的行为,它为消费组具备高可用性和伸缩性提供保障,使我们可以既方便又安全地删除消费组内的消费者或者往消费组内添加消费者。不过在再均衡发生期间,消费组内的消费者是无法读取消息的。再均衡后也可能出现重复消费的情况。所以应尽量避免不必要的再均衡发生。

3、Consumer Group 消费者群组

同一个消费者组中保证每个分区只能被一个消费者使用 ,不会出现多个消费者读取同一个分区的情况,通过这种方式,消费者可以消费包含大量消息的主题。而且如果某个消费者失效,群组里的其他消费者可以接管失效悄费者的工作。

4、Broker 服务器

一个独立的 Kafka 服务器被称为 broker, broker 接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存。

在集群中,每个分区都有一个Leader Broker和多个Follower Broker,只有Leader Broker才能处理生产者和消费者的请求,而Follower Broker只是Leader Broker的备份,用于提供数据的冗余备份和容错能力。如果Leader Broker发生故障,Kafka集群会自动将Follower Broker提升为新的Leader Broker,从而实现高可用性和容错能力。

AR、ISR、OSR

  • 分区中的所有副本统称为AR。
  • 所有与leader副本保持一定同步程度的副本组成ISR。
  • 与leader副本同步滞后过多的副本组成OSR。
  • AR = ISR +OSR。正常情况 应该AR=ISR,OSR集合为空。

5、 Log 日志存储

一个分区对应一个日志文件(Log),为了防止Log过大,Kafka又引入了日志分段(LogSegment)的概念,将Log 切分为多个 LogSegment,便于消息的维护和清理。Log在物理上只以(命名为topic-partitiom)文件夹的形式存储,而每个LogSegment对应磁盘上的一个日志文件和两个索引文件,以及可能的其他文件。

在这里插入图片描述
在这里插入图片描述

LogSegment文件由两部分组成,分别为“.index”文件和“.log”文件,分别表示为segment的索引文件和数据文件

  • partition全局的第一个segment从0开始,后续每个segment文件名为上一个segment文件最后一条消息的offset值
  • 数值大小为64位,20位数据字符长度,没有数字用0填充

消息压缩

一条消息通常不会太大,Kafka 是批量消息压缩,通过compression.type配置,默认为 producer,还可以配置为gzip、snappy、lz4,uncompressed表示不压缩。

日志索引

Kafka中的索引文件以稀疏索引的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。每当写入一定量(log.index.interval.bytes指定,默认4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引文件项和时间戳索引文件项。稀疏索引通过MappedByteBuffer将索引文件映射到内存中,以加快索引的查询速度。

日志清理

Kafka提供两种日志清理策略:

  1. 日志删除:按照一定的保留策略(基于时间、日志大小或日志起始偏移量)直接删除不符合条件的日志分段。
  2. 日志压缩:针对每个消息的key进行整合,对于有相同key的不同value值,只保留最后一个版本。

页缓存

页缓存是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问,减少对磁盘IO的操作。

零拷贝

所谓的零拷贝是将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。减少了数据拷贝的次数和内核和用户模式之间的上下文切换。对于Linux操作系统而言,底层依赖于sendfile()方法实现。

一般的数据流程:磁盘 -> 内核 -> 应用 -> Socket -> 网卡,数据复制4次,上下文切换4次。

在这里插入图片描述
流程步骤:

  1. 操作系统将数据从磁盘文件中读取到内核空间的页面缓存。
  2. 应用程序将数据从内核空间读入用户空间缓冲区。
  3. 应用程序将读到数据写回内核空间并放入socket缓冲区。
  4. 操作系统将数据从socket缓冲区复制到网卡接口,此时数据才能通过网络发送。

通过网卡直接去访问系统的内存,就可以实现现绝对的零拷贝了。这样就可以最大程度提高传输性能。通过“零拷贝”技术,我们可以去掉那些没必要的数据复制操作, 同时也会减少上下文切换次数。

在这里插入图片描述
通过上图可以看到,零拷贝技术只用将磁盘文件的数据复制到页面缓存中一次,然后将数据从页面缓存直接发送到网络中(发送给不同的订阅者时,都可以使用同一个页面缓存),避免了重复复制操作。

6、ZooKeeper

ZooKeeper是Kafka集群中使用的分布式协调服务,用于维护Kafka集群的状态和元数据信息,例如主题和分区的分配信息、消费者组和消费者偏移量等。

3、Kafka 如何保证消息不丢失

  1. 生产者 丢失消息
  2. Kafka 丢失消息
  3. 消费者 丢失消息

生产者

生产者调用send方法发送消息之后,消息可能因为网络问题并没有发送过去。所以,我们不能默认在调用send方法发送消息之后消息发送成功了。为了确定消息是发送成功,我们要判断消息发送的结果。可以采用为其添加回调函数的形式,如果消息发送失败的话,可以对失败消息做记录,我们检查失败的原因之后重新发送即可!

// 异步发送消息
producer.send(record, new Callback() {
    @Override
    public void onCompletion(RecordMetadata metadata, Exception exception) {
        if (exception == null) {
            System.out.println("发送成功");
        } else {
            System.out.println("发送失败");
        }
        if (metadata != null) {
            System.out.println("异步方式发送消息结果:" + "topic‐" + metadata.topic() + "|partition‐"
                + metadata.partition() + "|offset‐" + metadata.offset());
        }
    }
});

同时,我们也可以通过给oroducer设置一些参数来提升发送成功率:

/**
 * producer 需要 server 接收到数据之后发出的确认接收的信号,此项配置就是指 procuder需要多少个这样的确认信号。此配置实际上代表了数据备份的可用性。以下设置为常用选项:
 * (1)acks=0:生产者在成功写入消息之前不会等待任何来自服务器的响应,消息传递过程中有可能丢失,其实就是保证消息不会重复发送或者重复消费,但是速度最快。同时重试配置不会发生作用。
 * (2)acks=1:默认值,只要集群首领节点收到消息,生产者就会收到一个来自服务器的成功响应。
 * (3)acks=all:只有当所有参与赋值的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应。
 */
props.put(ProducerConfig.ACKS_CONFIG, "all");

/**
 * 如果请求失败,生产者会自动重试,如果启用重试
 */
props.put(ProducerConfig.RETRIES_CONFIG, 3);

/**
 * 消息发送超时或失败后,间隔的重试时间
 */
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 300);

Broker

  • 持久化存储:Kafka使用持久化存储来存储消息。这意味着消息在写入Kafka时将被写入磁盘,这种方式可以防止消息因为节点宕机而丢失。
  • 复制机制:Kafka使用复制机制来保证数据的可靠性。每个分区都有多个副本,副本可以分布在不同的节点上,当一个节点宕机时,其他节点上的副本仍然可以提供服务,保证消息不丢失。
  • ISR机制:Kafka使用ISR机制来确保消息不会丢失。ISR是指已经复制了数据并与主节点保持同步的节点集合,只有SR中的节点才会被认为是“可用”的节点,只有在ISR中的节点上的副本才会被认为是“可用”。

在服务端,也有一些参数配置可以调节来避免消息丢失:

replication.factor //表示分区副本的个数,replication.factor>1 当1eader副本挂了,follower副本会被选举为leader继续提供服务。
min.insync.rep1icas //表示ISR最少的副本数量,通常设置min.insync.replicas>1,这样才有可用的fol1ower副本执行替换,保证消息不丢
unclean.leader.election.enable=false //是否可以把非ISR集合中的副本选举为leader副本。

消费者

当消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。自动提交的话会有一个问题,试想一下,当消费者刚拿到这个消息准备进行真正消费的时候,突然挂掉了,消息实际上并没有被消费,但是 offset 却被自动提交了。

这种情况的解决办法也比较粗暴,我们手动关闭自动提交 offset,每次在真正消费完消息之后之后再自己手动提交 offset 。但是,细心的朋友一定会发现,这样会带来消息被重新消费的问题。比如你刚刚消费完消息之后,还没提交 offset,结果自己挂掉了,那么这个消息理论上就会被消费两次。

while (true) {
    /**
     * poll() API 是拉取消息的长轮询 比如设置了1000毫秒 并不是在这1秒钟内只拉取一次 而是当没有拉取到数据时 会多次拉取数据 直到拉取到数据 然后继续循环
     */
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
    for (ConsumerRecord<String, String> record : records) {
        System.out.printf("收到消息:partition = %d,offset = %d, key = %s, value = %s%n", record.partition(),
                record.offset(), record.key(), record.value());
    }

    if (records.count() > 0) {
        // 手动同步提交offset,当前线程会阻塞直到offset提交成功
        // 一般使用同步提交,因为提交之后一般也没有什么逻辑代码了
        // consumer.commitSync();

        // 手动异步提交offset,当前线程提交offset不会阻塞,可以继续处理后面的程序逻辑
        consumer.commitAsync(new OffsetCommitCallback() {
            @Override
            public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
                if (exception != null) {
                    System.err.println("Commit failed for " + offsets);
                    System.err.println("Commit failed exception: " + exception.getStackTrace());
                }
            }
        });

    }
}

4、Kafka 如何保证消息不重复消费

  • kafka出现消息重复消费的原因:服务端侧已经消费的数据没有成功提交 offset(根本原因)。
  • Kafka 侧 由于服务端处理业务时间长或者网络链接等等原因让 Kafka 认为服务假死,触发了分区 rebalance。

① 消费消息服务做幂等校验,比如 Redis 的 set、MySQL 的主键等天然的幂等功能。这种方法最有效。这部分主要集中在消费端的编码层面,需要我们在设计代码时以幂等性的角度进行开发设计,保证同一数据无论进行多少次消费,所造成的结果都一样。处理方式可以在消息体中添加唯一标识,在处理消息前先检查下Mysql/Redis是否已经处理过该消息了,消费端进行确认此唯一标识是否已经消费过,如果消费过,则不进行之后处理。从而尽可能的避免了重复消费。

② 提高消费端的处理性能避免触发Balance,比如可以用多线程的方式来处理消息,缩短单个消息消费的时长。或者还可以调整消息处理的超时时间,也还可以减少一次性从Broker上拉取数据的条数。

5、什么是 Kafka 的重平衡机制

Kafka的重平衡机制是指在消费者组中新增或删除消费者时,Kafka集群会重新分配主题分区给各个消费者,以保
证每个消费者消费的分区数量尽可能均衡。

重平衡机制的目的是实现消费者的负载均衡和高可用性,以确保每个消费者都能够按照预期的方式消费到消息。

在这里插入图片描述

重平衡的3个触发条件:

  • 消费者组成员数量发生变化。
  • 订阅主题数量发生变化。
  • 订阅主题的分区数发生变化。

当Kafka集群要触发重平衡机制时,大致的步骤如下:

  1. 暂停消费:在重平衡开始之前,Kāfkā会暂停所有消费者的拉取操作,以确保不会出现重平衡期间的消息丢失
    或重复消费。
  2. 计算分区分配方案:Kfk集群会根据当前消费者组的消费者数量和主题分区数量,计算出每个消费者应该分
    配的分区列表,以实现分区的负载均衡。
  3. 通知消费者:一旦分区分配方案确定,Kafk集群会将分配方案发送给每个消费者,告诉它们需要消费的分区
    列表,并请求它们重新加入消费者组。
  4. 重新分配分区:在消费者重新加入消费者组后,Kfka集群会将分区分配方案应用到实际的分区分配中,重新
    分配主题分区给各个消费者。
  5. 恢复消费:最后,Kfka会恢复所有消费者的拉取操作,允许它们消费分配给自己的分区。

Kafka的重平衡机制能够有效地实现消费者的负载均衡和高可用性,提高消息的处理能力和可靠性。但是,由于重
平衡会带来一定的性能开销和不确定性,因此在设计应用时需要考虑到重平衡的影响,并采取一些措施来降低重平
衡的频率和影响。

在重平衡过程中,所有Consumer实例都会停止消费,等待重平衡完成。但是目前并没有什么好的办法来解决重
平衡带来的STW,只能尽量迟避免它的发生。

6、Kafka 几种选举过程简单介绍一下

启动时选举

集群中第一个启动的broker会通过在zookeeper中创建临时节点/controller来让自己成为控制器,其他broker启动时
会去尝试读取/controller节点的brokerid的值,读取到的brokerid的值不为-1知道已经有其他broker节点成功竞选为控制器,就会在zookeeper中创建watch对象,便于它们收到控制器变更的通知。

leader异常选举

那么如果broker由于网络原因与zookeeper断开连接或者异常退出,那么其他broker通过watch收到控制器变更的通知,就会去尝试创建临时节点/controller,如果有一个broker创建成功,那么其他broker就会收到创建异常通知,也就意味着集群中已经有了控制器,其他broker只需创建watch对象即可。

follower异常

如果集群中有一个broker发生异常退出了,那么控制器就会检查这个broker是否有分区的副本leader,如果有那么这个分区就需要一个新的leader,此时控制器就会去遍历其他副本,决定哪一个成为新的leader,同时更新分区的ISR集合。

broker加入

如果有一个broker加入集群中,那么控制器就会通过brokerid去判断新加入的broker中是否含有现有分区的副本,如果有,就会从分区副本中去同步数据。

分区Leader的选举

controller感知到分区leader所在的broker挂了,controller会从replicas副本列表(同时在ISR列表里)中取出第一个broker作为leader。

消费组Leader的选举

GroupCoordinator需要为消费组内的消费者选举出一个消费组的leader,这个选举的算法很简单,当消费组内还没有leader,那么第一个加入消费组的消费者即为消费组的leader,如果当前leader退出消费组,则会挑选以HashMap结构保存的消费者节点数据中,第一个键值对来作为leader。

7、Kafka 为什么这么快

  1. 批量发送:Kafka发送消息时将消息缓存到本地,达到一定数量或者间隔一定时间再发送,减少了网络请求的次数。
  2. 零拷贝:Kafka使用了DMA的技术,使Socket缓冲池可以直接读取内核内存的数据,减少了数据拷贝到应用再拷贝到Socket缓冲池的过程,也减少了2次上下文切换。
  3. 磁盘顺序写入:Kafka每个分区对应一个日志文件,消息写入是追加到日志文件后面、顺序写磁盘的速度快于随机写,避免了随机读写带来的性能损耗,提高了磁盘的使用效率。
  4. 页面缓存:Kafka大量使用了页面缓存,就是将数据写入磁盘前会先写入系统缓存,然后进行刷盘;读取数据也会先读取缓存,没有再读磁盘。虽然异步刷盘会因单点故障导致数据丢失,但是多副本的机制保障了数据的持久化。
  5. 批量压缩:发送的时候对数据进行压缩。
  6. 分区和副本:Kafka采用分区和副本的机制,可以将数据分散到多个节点上进行处理,从而实现了分布式的高
    可用性和负载均衡。

8、Kafka 高水位了解过吗

高水位标识了一个特定的消息偏移量(offset),即一个分区中已提交消息的最高偏移量(offset),消费者只能拉取到这个offset之前的消息。消费者可以通过跟踪高水位来确定自己消费的位置。

在Kafka中,HW主要有两个作用:

  • 消费进度管理:消费者可以通过记录上一次消费的偏移量,然后将其与分区的高水位进行比较,来确定自己的消费进度。消费者可以在和高水位对北比之后继续消费新的消息,确保不会错过任何已提交的消息。这样,消费者可以按照自己的节奏进行消费,不受其他消费者的影响。
  • 数据的可靠性:高水位还用于确保数据的可靠性。在Kafka中,只有消息被写入主副本并被所有的同步副本ISR确认后,才被认为是已提交的消息。高水位表示已经被提交的消息的边界。只有高水位之前的消息才能被认为是已经被确认的,其他的消息可能会因为副本故障或其他原因而丢失。

还有一个概念,叫做LEO,即Log End Offset,他是日志最后消息的偏移量。它标识当前日志文件中下一条待写
入消息的offset。

在这里插入图片描述
当消费者消费消息时,它可以使用高水位作为参考点,只消费高水位之前的消息,以确保消费的是已经被确认的消
息,从而保证数据的可靠性。如上图,只消费offet为6之前的消息。

我们都知道,在Kafka中,每个分区都有一个Leader副本和多个Follower副本。当Leader副本发生故障时,Kafka会选择一个新的Leader副本。这个切换过程中,需要保证数据的一致性,即新的Leader副本必须具有和旧Leader副本一样的消息顺序。为了实现这个目标,Kafka引入了Leader Epoch的概念。

Leader Epoch的过程

  1. 每个分区都有一个初始的Leader Epoch,通常为0。
  2. 当Leader副本发生故障或需要进行切换时,Kafka会触发副本切换过程。
  3. 副本切换过程中,Kafka会从ISR同步副本)中选择一个新的Follower副本作为新的Leader副本。
  4. 新的Leader副本会增加自己的Leader Epoch,使其大于之前的Leader Epoch。这表示进入了一个新的任期。
  5. 新的Leader副本会验证旧Leader副本的状态以确保数据的一致性。它会检查旧Leader副本的Leader Epoch和高水位。
  6. 如果旧Leader副本的Leader Epoch小于等于新Leader副本的Leader Epoch,并且旧Leader副本的高水位小于等于新Leader副本的高水位,则验证通过。
  7. 验证通过后,新的Leader副本开始从旧Leader副本复制数据。它只会接受旧Leader副本的Leader Epoch和高水位之前的消息。
  8. 一旦新的Leader副本复制了旧Leader副本的所有数据,并达到了与旧Leader副本相同的高水位,副本切换过程就完成了。

--------------------------------------------------Zookeeper--------------------------------------------------

1、ZooKeeper 集群部署图

为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。

ZooKeeper 官方提供的架构图就是一个 ZooKeeper 集群整体对外提供服务。

在这里插入图片描述
上图中每一个 Server 代表一个安装 ZooKeeper 服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 ZAB 协议来保持数据的一致性。

最典型集群模式: Master/Slave 模式(主备模式)。在这种模式中,通常 Master 服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。

2、那你知道 znode 有几种类型呢

我们通常是将 znode 分为 4 大类:

  • 持久(PERSISTENT)节点 :一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。
  • 临时(EPHEMERAL)节点 :临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。
  • 持久顺序(PERSISTENT_SEQUENTIAL)节点 :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001 、/node1/app0000000002 。
  • 临时顺序(EPHEMERAL_SEQUENTIAL)节点 :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性

3、你知道 znode 节点里面存储什么吗

  • stat :状态信息。

Stat 类中包含了一个数据节点的所有状态信息的字段,包括事务 ID(cZxid)、节点创建时间(ctime) 和子节点个数(numChildren) 等等,如下:

在这里插入图片描述

  • data : znode 存储业务数据信息。
  • acl : 记录客户端对 znode 节点访问权限,如 IP 等。
    • ZooKeeper 采用 ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。对于 znode 操作的权限,ZooKeeper 提供了以下 5 种:
      • CREATE : 能创建子节点
      • READ :能获取节点数据和列出其子节点
      • WRITE : 能设置/更新节点数据
      • DELETE : 能删除子节点
      • ADMIN : 能设置节点 ACL 的权限
    • 其中尤其需要注意的是,CREATE 和 DELETE 这两种权限都是针对 子节点 的权限控制。对于身份认证,提供了以下几种方式:
      • world : 默认方式,所有用户都可无条件访问。
      • auth :不使用任何 id,代表任何已认证的用户。
      • digest :用户名:密码认证方式: username:password 。
      • ip : 对指定 ip 进行限制
  • child : 当前节点子节点引用。

4、你知道 znode 节点上监听机制嘛

Watcher 为事件监听器,是 zk 非常重要的一个特性,很多功能都依赖于它,它有点类似于订阅的方式,即客户端向服务端注册指定的 watcher ,当服务端符合了 watcher 的某些事件或要求则会向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher 然后执行相应的回调方法 。

在这里插入图片描述

可以把 Watcher 理解成客户端注册在某个 Znode 上触发器,当这个 Znode 节点发生变化时(增删改查),就会触发 Znode 对应注册事件,注册客户端就会收到异步通知,然后做出业务改变。

zookeeper 监听原理

zookeeper的监听事件有四种

  • nodedatachanged 节点数据改变
  • nodecreate 节点创建事件
  • nodedelete 节点删除事件
  • nodechildrenchanged 子节点改变事件

在这里插入图片描述

ZooKeeper Watcher 机制主要包括客户端线程、客户端WatcherManager、Zookeeper 服务器三部分。

  1. 客户端向 ZooKeeper 服务器注册 Watcher 同时,会将 Watcher 对象存储在客户端 WatchManager 中。
  2. 当 zookeeper 服务器触发 watcher 事件后,会向客户端发送通知, 客户端线程从 WatcherManager 中取出对应Watcher 对象来执行回调逻辑。

5、你讲一下 ZooKeeper 选举机制吧

当Zookeeper集群中的一台服务器出现以下两种情况之一时,需要进入Leader选举。

  1. 服务器初始化启动。
  2. 服务器运行期间Leader故障。

服务器启动 Leader 选举

假设一个Zookeeper集群中有5台服务器,id从1到5编号,并且它们都是最新启动的,没有历史数据。
在这里插入图片描述

服务器运行期间 Leader 选举

在这里插入图片描述

选举Leader规则:①EPOCH大的直接胜出 ②EPOCH相同,事务id大的胜出 ③事务id相同,服务器id大的胜出,总结:择优选取,保证leader是zk集群中数据最完整、最可靠的一台服务器

6、如何保证数据一致性

ZooKeeper通过ZAB协议实现数据一致性,确保分布式场景中的数据更新在集群中是有序、一致和可靠的。

在这里插入图片描述
在 ZooKeeper 集群中,所有客户端的请求都是写入到 Leader 进程中的,然后,由 Leader 同步到其他节点,称为 Follower。在集群数据同步的过程中,如果出现 Follower 节点崩溃或者 Leader 进程崩溃时,都会通过 Zab 协议来保证数据一致性。

ZooKeeper的ZAB协议可以被分为两个阶段来理解:消息广播阶段和崩溃恢复阶段。

消息广播阶段,ZooKeeper中的一个节点被选为leader节点,它接收来自客户端的事务提交请求,并将这些请求作为proposal广播给其他follower节点。每个follower节点收到proposal后会进行反馈,leader节点根据收集到的反馈决定是否执行commit操作。为了保证数据一致性,ZooKeeper使用了quorum选举机制来决定大多数节点上的commit结果。

在这里插入图片描述

client端发起请求,读请求由follower和observer直接返回,写请求由它们转发给leader。Leader 首先为这个事务分配一个全局单调递增的唯一事务ID (即 ZXID )。然后发起proposal给follower,Leader 会为每一个 Follower 都各自分配一个单独的队列,然后将需要广播的事务 Proposal 依次放入这些队列中去,并且根据 FIFO策略进行消息发送。每一个 Follower 在接收到这个事务 Proposal 之后,都会首先将其以事务日志的形式写入到本地磁盘中去,并且在成功写入后反馈给 Leader 服务器一个 Ack 响应。当 Leader 服务器接收到超过半数 Follower 的 Ack 响应后,就会广播一个Commit 消息给所有的 Follower 服务器以通知其进行事务提交,同时Leader 自身也会完成对事务的提交。

当leader节点崩溃或不可用时,进入崩溃恢复阶段。在这个阶段,ZooKeeper会进行leader选举,并进行数据同步操作以保持集群中的数据一致性。一旦数据同步完成,ZooKeeper重新进入消息广播阶段。

7、讲一下 zk 分布式锁实现原理吧

实现分布式锁要借助临时顺序节点和watch,首先我们要有一个持久节点,客户端获取锁就是在持久节点下创建临时顺序节点。客户端创建的临时顺序节点创建成功后会判断节点是不是最小节点,如果是最小节点那么获取锁成功,否则回去锁失败。如果获取锁失败,则说明有其他客户端已成功获得锁,这时候也不需要循环尝试去加锁,而是给前一个节点注册一个事件监听器,这个监听器作用就是当前一个节点释放后,也就是节点删除后通知自己让自己获得锁,这样的好处是不会通知到所有的节点去争夺锁(避免无效自旋)。所以使用Zookeeper实现的分布式锁是公平锁。

为什么要用临时顺序节点

临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。

使用 Redis 实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而 ZooKeeper 直接利用临时节点的特性即可。

假设不适用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。

为什么要设置对前一个节点的监听

同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器。

这个事件监听器的作用是: 当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll ),让它尝试去获取锁,然后就成功获取锁了。

原生API 分布式锁

package com.example.test.other.zk;

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;

import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;

public class ZookepeerLock1 {

    private final String connectionString = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
    private final int sessionTimeout = 2000;
    private final ZooKeeper zk;
    private CountDownLatch countDownLatch = new CountDownLatch(1);
    private CountDownLatch waitLatch = new CountDownLatch(1);
    private String waitPath;
    private String currentMode;

    public ZookepeerLock1() throws IOException, InterruptedException, KeeperException {

        // 获取连接
        zk = new ZooKeeper(connectionString, sessionTimeout, new Watcher() {
            @Override
            public void process(WatchedEvent watchedEvent) {
                // connectLatch 如果连接上zk 可以释放
                if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
                    countDownLatch.countDown();
                }
                // waitLatch 需要释放
                if (watchedEvent.getType() == Event.EventType.NodeDeleted && watchedEvent.getPath().equals(waitPath)) {
                    waitLatch.countDown();
                }
            }
        });
        // 等待zk正常连接后,往下走程序
        countDownLatch.await();

        // 判断根节点/locks是否存在
        Stat stat = zk.exists("/lockZookeeper", false);

        if (stat == null) {
            // 创建根节点,这是⼀个完全开放的ACL,持久节点
            zk.create("/lockZookeeper", "lockZookeeper".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        }

    }

    // 对zk加锁
    public void zkLock() {

        try {
            // 创建对应的临时顺序节点
            currentMode =
                    zk.create("/lockZookeeper/" + "seq-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);

            // 判断创建的节点是否是序号最小的节点,如果是获取到锁,如果不是,监听他序号前一个节点
            List<String> children = zk.getChildren("/lockZookeeper", false);
            if (children.size() == 1) {
                return;
            } else {

                //[seq-0000000016, seq-0000000017]
                Collections.sort(children);

                // 获取节点名称 /locks/seq-0000000017 -> seq-0000000017
                String thisNode = currentMode.substring("/lockZookeeper/".length());

                // 通过seq-0000000017获取该节点在children集合的位置
                int index = children.indexOf(thisNode);

                // 判断
                if (index == -1) {
                    System.out.println("数据异常");
                } else if (index == 0) {
                    // 就一个节点,可以获取锁了
                    return;
                } else {
                    // 需要监听 他前一个结点的变化
                    waitPath = "/lockZookeeper/" + children.get(index - 1);
                    zk.getData(waitPath, true, null);
                    // 等待监听
                    waitLatch.await();
                }

            }

        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    // 解锁
    public void unZkLock() {
        // 删除节点
        try {
            zk.delete(currentMode, -1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }
}

Curator 分布式锁

package com.example.test.other.zk;

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessLock;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.framework.recipes.locks.InterProcessSemaphoreMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.KeeperException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.IOException;
/**
 * Description: 1、@Bean单独注解方法时,每次调用方法都是执行方法内的逻辑并返回新创建的对象bean,而且SpringIOC并没有该bean的存在。
 *              2、@Bean + @Configuration ,在调用@Bean注解的方法时返回的实例bean是从IOC容器获取的,已经注入的,且是单例的,而不是新创建的。
 *              3、@Bean + @Component,虽然@Bean注解的方法返回的实例已经注入到SpringIOC容器中,但是每次调用@Bean注解的方法时,都会创建新的对象实例bean返回,并不会从IOC容器中获取。
 * Author: yangjj_tc
 * Date: 2023/5/18 13:25
 */
@Configuration
public class ZookeperLock1Test {

    private final String ZOOKEEPER_ADDRESS = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";

    /**
     * Description: Author:
     * yangjj_tc
     *  1、会话连接是异步的,需要自己去处理。比如使用CountDownLatch
     *  2、Watch需要重复注册,不然就不能生效
     *  3、开发的复杂性还是比较高的
     *  4、不支持多节点删除和创建。需要自己去递归
     * Date: 2023/5/18 12:48
     */
    @Bean
    public CuratorFramework getCuratorFramework(){
        // 重试策略,重试间隔时间为1秒,重试次数为3次。curator管理了zookeeper的连接,在操作zookeeper的过程中出现连接问题会自动重试。
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);

        // 初始化客户端,通过工厂创建连接
        // zk地址 会话超时时间,默认60秒 连接超时时间,默认15秒 重试策略
        CuratorFramework zkClient = CuratorFrameworkFactory.newClient(ZOOKEEPER_ADDRESS, 5000, 15000, retryPolicy);

        // 开始连接
        zkClient.start();

        return zkClient;
    }
    public static void main(String[] args) throws InterruptedException, IOException, KeeperException {
        // 获得两个客户端
        CuratorFramework client1 = new ZookeperLock1Test().getCuratorFramework();
        CuratorFramework client2 = new ZookeperLock1Test().getCuratorFramework();
        // 可重入锁, 意味着同一个客户端在拥有锁的同时,可以多次获取,不会被阻塞。如想重入,则需要使用同一个InterProcessMutex对象。
        final InterProcessLock lock1 = new InterProcessMutex(client1, "/lockCurator");
        final InterProcessLock lock2 = new InterProcessMutex(client2, "/lockCurator");
        // 不可重入锁,区别在于该锁是不可重入的,在同一个线程中不可重入
        final InterProcessSemaphoreMutex lock3 = new InterProcessSemaphoreMutex(client1, "/lockCurator");
        final InterProcessSemaphoreMutex lock4 = new InterProcessSemaphoreMutex(client2, "/lockCurator");
        // 模拟两个线程
        new Thread(() -> {
            try {
                // 线程加锁
                lock3.acquire();
                lock3.acquire();
                System.out.println("线程1获取锁");
                // 线程沉睡
                Thread.sleep(5 * 1000);
                // 线程解锁
                lock1.release();
                System.out.println("线程1释放了锁");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
        // 线程2
        new Thread(() -> {
            // 线程加锁
            try {
                lock2.acquire();
                System.out.println("线程2获取到锁");
                // 线程沉睡
                Thread.sleep(5 * 1000);
                lock2.release();
                System.out.println("线程2释放锁");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}

8、可重入锁与不可重入锁

锁:把需要的代码块,资源或数据锁上,只允许一个线程去操作,保证了并发时共享数据的一致性。锁有两种类型:可重入锁和不可重入锁。

不可重入锁

若当前线程执行中已经获取了锁,如果再次获取该锁时,就会获取不到被阻塞。我们用测试例子对使用不可重入锁类的情况做下分析

在这里插入图片描述

当线程执行methodA()方法首先获取lock,接下来执行methodB()方法,在methodB方法中,也尝试获取lock。当前线程的锁已经被methodA获取,由lock()代码可知,methodB无法获取到锁,并且自旋,产生了死锁。这种情况叫做不可重入锁。

可是我们平时又有需要重入一把锁的需求,怎么办呢?接下来我们对不可重入锁类进行改造。

可重入锁

当线程执行methodA()方法首先获取lock,接下来执行methodB()方法,在methodB方法中,也尝试获取lock。当前线程的锁已经被methodA获取,由lock()代码可知,count加1,并且返回,继续执行methodB代码,最后释放锁unlock,count减1。跳出methodB后再次执行unlock方法,这个时候count等于0,所以currLock完全释放。

这样设计后拿到锁的代码能多次以不同的方式访问临界资源并把加锁次数count加1;在解锁的时候通过count,可以确保所有加锁的过程都解锁了。这就是可重入的能力。

可重入锁也叫递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响。

在java环境下,ReentrantLock和synchronized都是可重入锁。

9、什么是脑裂,如何解决

对于一个集群,想要提高这个集群的可用性,通常会采用多机房部署,比如现在有一个由6台zkServer所组成的一个集群,部署在了两个机房:

在这里插入图片描述
正常情况下,此集群只会有一个Leader,那么如果机房之间的网络断了之后,两个机房内的zkServer还是可以相互通信的,如果不考虑过半机制,那么就会出现每个机房内部都将选出一个Leader。

在这里插入图片描述
这就相当于原本一个集群,被分成了两个集群,出现了两个“大脑”,这就是脑裂。

对于这种情况,我们也可以看出来,原本应该是统一的一个集群对外提供服务的,现在变成了两个集群同时对外提供服务,如果过了一会,断了的网络突然联通了,那么此时就会出现问题了,两个集群刚刚都对外提供服务了,数据该怎么合并,数据冲突怎么解决等等问题。

刚刚在说明脑裂场景时,有一个前提条件就是没有考虑过半机制,所以实际上Zookeeper集群中是不会出现脑裂问题的,而不会出现的原因就跟过半机制有关。

过半机制

举个简单的例子: 如果现在集群中有6台zkServer,也就是说至少要4台zkServer才能选出来一个Leader,才会符合过半机制,才能选出来一个Leader。

在这里插入图片描述
所以对于机房1来说它不能选出一个Leader,同样机房2也不能选出一个Leader,这种情况下整个集群当机房间的网络断掉后,整个集群将没有Leader。

如果假设我们现在只有5台机器,也部署在两个机房:

在这里插入图片描述
也就是至少要3台服务器才能选出一个Leader,此时机房件的网络断开了,对于机房1来说是没有影响的,Leader依然还是Leader,对于机房2来说是选不出来Leader的,此时整个集群中只有一个Leader。

所以,我们可以总结得出,有了过半机制,对于一个Zookeeper集群,要么没有Leader,要没只有1个Leader,这样就避免了脑裂问题。

综上,何必增加那一个不必要的 ZooKeeper 呢?

10、Eureka 与 Zk 有什么区别

Eureka各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务,Eureka的客户端在向某个Eureka服务端注册时如果发现链接失败,则自动切换到其他节点,只要有一台Eureka活着,就能保证服务可用,强调可用性。只不过查询到的信息可能不是最新的,不能保证一致性。

zk会出现这样一种情况,当master节点因为网络故障与其他节点失去联系时,剩余节点会重新进行leader选举,问题在于,选择leader的时间太长,且选举期间整个zk集群都是不可用的,者就导致在选举期间注册服务瘫痪,虽然服务能够最终恢复,但是漫长的选举时间导致的注册长期不可用,不能保证可用性,但是它因为只从节点的设置,从节点会从主节点同步数据,主从节点数据一致,强调一致性。

--------------------------------------------------Spring--------------------------------------------------

1、介绍一下Spring 的 IOC

所谓的 IOC,就是控制反转的意思。何为控制反转?我们可以根据字面意思理解,就是对于某个东西A,原来的控制权在使用方B,B想用就能用,不想用就不用。现在把控制权交还给了A,只有A给了才能用,这样就是控制反转了。

可能说的有点抽象,更具体一点呢,我们拿代码来说话:

class A {}

class B {
    // B需要将A的实例new出来,也就是我们说的控制
    private A a = new A();

    public void use() {
        System.out.print(a);
    }
}

当有了IOC后

@Component // 说明A自己控制自己,把自己初始化出来,注入给了容器
class A {}

class B {
    // B不需要控制a,直接使用。如果A没有把自己注入给容器,B就不能使用
    @Resource
    private A a;

    public void use() {
        System.out.print(a);
    }
}

也就是说,没有Spring的话,我们要使用的对象,需要我们自己创建,而有了Spring的IOC之后,对象由IOC容
器创建并管理,我们只需要在想要使用的时候从容器中获取就行了。

对于Spring的lOC来说,它是IOC思想的一种实现方式。在容器启动的时候,它会根据每个bean的要求,将bean注入到SpringContainer中。如果有其他bean需要使用,就直接从容器中获取即可,如下图所示:

在这里插入图片描述

IOC是如何实现的

ApplicationContext context = new AnnotationConfigApplicationContext("cn.wxxlamp.spring.ioc");
Bean bean = context.getBean(Bean.class);
  1. 从配置元数据中获取要Dl的业务对象(这里的配置元数据包括xml,注解,configuration类等)。
  2. 将业务对象形成BeanDefinition注入到Spring Container中。
  3. 使用方通过ApplicationContext从Spring Container直接获取即可。

2、将一个类声明为 Bean 的注解有哪些

  • @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。
  • @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。
  • @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。
  • @Component :通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层使用。

3、Bean 的作用域有哪些

Spring 中 Bean 的作用域通常有下面几种:

  • singleton : 只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。
  • prototype : 每次获取都会创建一个新的 bean 实例,连续 getBean() 两次,得到的是不同的 Bean 实例。
  • request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。
  • session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。
  • application/global-session (仅 Web 应用可用): 每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。
  • websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。

如何配置 bean 的作用域呢

xml 方式:

<bean id="..." class="..." scope="singleton"></bean>

注解方式:

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Person personPrototype() {
    return new Person();
}

单例和多例优缺点

  • 单例: 所有请求用同一个对象来处理,通过单例模式,可以保证系统中一个类只有一个实例。
  • 多例:每一个请求用一个新的对象来处理。
  1. 单例优点降低了实例创建和销毁所占用的资源,缺点线程共享一个实体,会发生线程安全问题。
  2. 多例线程之间数据隔离,所以不会有线程安全问题,但是频繁的实例创建和销毁会带来资源的大量浪费。

4、Spring 框架中的 Bean 是线程安全的吗

平常的bean它分为单例bean和多例bean。单例bean创建一个全局共享的实例,多例bean使用时候创建一个新的对象,线程之间不存在bean共享的问题,单例bean是所有线程共享一个实例,这样单例bean就可能会产生线程安全问题。但是也不是绝对的。单例bean分为有状态有无状态两种。

  • 有状态Bean:多线程操作中如果需要对bean中的成员变量进行数据更新操作,是非线程安全。
  • 无状态Bean:多线程操作中没有成员变量或者只会对bean成员变量进行查询操作,不会修改操作,是线程安全(比如 Controller、Dao、Service等)。

处理有状态的单例bean线程安全问题,常见的有两种解决办法:

  1. 改为多例bean
  2. 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)

5、Spring 容器对象的懒加载

意思为控制对象的创建的时机,如果Spring容器创建,对象立即创建,则称为立即加载。如果 Spring 容器创建,对象在被使用的时候创建,则称为懒加载。

注解:@Lazy 表示为懒加载

懒加载XML写法
<!-- 懒加载的局部的写法 -->
<bean id="hello" class="com.ioc.Hello" scope="singleton" lazy-init="true"></bean>
<!-- 懒加载的全局的写法 -->
<beans default-lazy-init="true"></beans>
lazy-init 是否懒加载scope 单例多例对象的创建结果
truesingleton懒加载
trueprototype懒加载
default/flasesingleton立即加载
default/flaseprototype懒加载

只要对象是多例模式 则都是懒加载! 在单例模式中控制懒加载才有效

6、Spring 容器中的 bean 生命周期

在这里插入图片描述

  1. 获取元数据信息:Spring容器首先会读取XML配置文件或注解配置,得到指定bean的元数据信息(BeanDefinition),包括bean的名称、类名、作用域等等。
  2. 创建对象:Spring容器使用反射机制创建指定bean的实例。这通常通过调用类的构造函数来完成。
  3. 属性赋值:Spring容器使用populateBean方法将创建的对象的属性赋值。这包括使用setter方法设置属性值、调用任何实现了InitializingBean接口的afterPropertiesSet方法以及自定义的init-method方法。
  4. Aware接口:如果bean实现了相应的Aware接口,Spring容器会在创建bean时自动调用相关的方法,例如BeanNameAware、BeanFactoryAware和ApplicationContextAware等接口。
  5. 前置处理:如果在Spring容器中定义了任何实现了BeanPostProcessor接口的类,它们就会在bean初始化之前或之后进行前置或后置处理。
  6. AOP代理:如果该bean已经与AOP相关联,则Spring容器将为该bean创建代理对象,以实现AOP功能。
  7. 初始化方法:在bean完成所有其他配置和属性设置后,执行任何自定义的初始化方法。
  8. 后置处理:与前置处理类似,如果在Spring容器中定义了任何实现了BeanPostProcessor接口的类,则它们将在bean初始化之前或之后进行后置处理。
  9. 销毁方法:在bean不再需要时,Spring容器将执行任何自定义的销毁方法以及实现了DisposableBean接口的destroy方法。

7、谈谈自己对于 Spring DI 的了解

从spring容器中取出容器中的对象,然后把对象注入到需要的地方。

Spring支持哪些注入方式

  1. 字段注入
@Autowired
private Bean bean;
  1. 构造器注入
@Component
class Test {
	private final Bean bean;

	@Autowired
	public Test(Bean bean){
		this.bean bean;
	}
}
  1. setteri注入
@Component
class Test {
	private Bean bean;

	@Autowired
	public void setBean(Bean bean){
		this.bean bean;
	}
}

使用构造器注入可能有哪些问题

如果我们两个bean循环依赖的话,构造器注入就会抛出异常:

@Component
public class BeanTwo implements Bean{
	Bean beanone;
	public BeanTwo(Bean beanone){
		this.beanOne beanone;
	}
}

@Component
public class Beanone implements Bean{
	Bean beanTwo;
	public BeanOne(Bean beanTwo){
		this.beanTwo beanTwo;
	}
}

如果两个类彼此循环引用,那说明代码的设计一定是有问题的。如果临时解决不了,我们可以在某一个构造器中加
入@Lazy注解,让一个类延迟初始化即可。

@Component
public class Beanone implements Bean{
	Bean beanTwo;
	@Lazy
	public Beanone(Bean beanTwo){
		this.beanTwo beanTwo;
	}
}

8、注入 Bean 的注解有哪些

Spring 内置的 @Autowired 以及 JDK 内置的 @Resource 和 @Inject 都可以用于注入 Bean。

AnnotaionPackageSource
@Autowiredorg.springframework.bean.factorySpring 2.5+
@Resourcejavax.annotationJava JSR-250
@Injectjavax.injectJava JSR-250

@Autowired 和@Resource使用的比较多一些。

@Resource( name = “value” )

1. 按照名字装配Bean,即会按照name属性的值来找到具有相同id的Bean并注入。如果没有指定name属性(@Resource 样式),则会根据这个将要被注入的变量名进行注入(value)。如果变量名在容器中也不存在,就按照变量类型注入,如果类型不存在或者存在多个实现类情况下抛出异常。

@Resource
private UserMapper userMapper;

@Autowired @Qualifier( “value” )

1.默认属性required= true(属性必须存储对象,不能为nullfalse可以),按照名字装配Bean,即会按照value值来找到具有相同id的Bean并注入。如果没有指定vaule值(@Autowired 样式),按照类型注入,类型不存在抛出异常,类型存在,如果类型只有一个实现类就按照类型注入,如果类型有多个实现类先按类型匹配再按变量名称匹配,再匹配不到抛出异常。

@Autowired @Qualifier("yang")
private UserMapper userMapper;

作用域不同

  1. Autowired可以作用在构造器,字段,setter方法上
  2. Resource只可以使用在field,setter方法上

9、Spring Boot如何让你的bean在其他bean之前加载

  1. 直接依赖某Bean
@Component
public class A {

	@Autowired
	private B b;
}

如上,在加载Bean A的时候,一定会先初始化Bean B

  1. DependsOn

对于应用之外的二方或者三方库来说,因为我们不能修改外部库的代码,如果想要二方库的Ben在初始化之前就
初始化我们内部的某个bean,就不能用第一种直接依赖的方式,可以使用@Dependst0n注解来完成。

@Configuration
public class BeanorderConfiguration {

	@Bean
	@Dependson("beanB")
	public BeanA beanA(){
		return new BeanA();
	}
}

10、介绍一下 Spring 的 AOP

首先,在面向切面编程的思想里面,把功能分为核心业务功能和周边功能。

  • 所谓的核心业务,工作中做的最多的就是增删改查,增删改查都叫核心业务。
  • 所谓的周边功能,比如性能统计,权限检验,日志打印,事务管理等等。

核心业务功能和切面功能分别独立进行开发,在程序运行期间,在不修改核心业务的情况下,然后把切面功能和核心业务功能 “编织” 在一起,这就叫AOP。

Spring AOP 有如下概念

术语翻译释义
Aspect切面切面由切入点和通知组成,它既包含了横切逻辑的定义,也包括了切入点的定义,比如说事务处理和日志处理可以理解为两个切面
PointCut切入点切入点是对连接点进行拦截的条件定义,决定通知应该作用于截哪些方法
Advice通知通知定义了通过切入点拦截后,应该在连接点做什么,是切面的具体行为
Target目标对象目标对象指将要被增强的对象,即包含主业务逻辑的类对象。或者说是被一个或者多个切面所通知的对象
JoinPoint连接点连接点是程序在运行时的执行点,这个点可以是正在执行的方法,或者是正在抛出的异常。因为Spring.只支持方法类型的连接点,所以在Spring中连接点就是运行时刻被拦截到的方法。
Weaving织入织入是将切面和业务逻辑对象连接起来,并创建通知代理的过程。在编时进行织入就是静态代理,而在运行时进行织入则是动态代理

AOP的动态代理技术

  • JDK代理:基于接口的动态代理技术(默认,有接口时用)
  • CGLIB代理:基于父类的动态代理技术(没接口时用)

在这里插入图片描述
jdk动态代理 和cglib动态代理的区别

  1. jdk动态代理目标业务类必须有接口,cglib动态代理业务类有无接口皆可。
  2. jdk动态代理必须实现InvocationHandler接口,cglib动态代理必须实现MethodInterceptor接口。
  3. jdk动态代理代理类和目标业务类是兄弟关系,因为隶属于同一个接口,cglib动态代理代理类和目标业务类是父子关系,业务类是父类,业务类不能是final类,代理类是子类。
  4. jdk动态代理创建代理类快,执行代理类慢,cglib动态代理创建代理类慢,执行代理类快。

Spring的AOP在什么场景下会失效
1、私有方法调用 2、静态方法调用 3、final方法调用 4、类内部自调用 5、内部类方法调用

11、什么是Spring的三级缓存

三级缓存是如何解决循环依赖的问题的

Springl解决循环依赖一定需要三级缓存吗

12、Spring 通知有哪些类型

  1. 前置通知(Before Advice):在连接点(Join point)之前执行的通知。
  2. 后置通知(After Advice):当连接点退出的时候执行的通知(不论是正常返回还是异常退出)。
  3. 环绕通知(Around Advice):包围一个连接点的通知,这是最强大的一种通知类型。 环绕通知可以在方法调用前后完成自定义的行为。它也可以选择是否继续执行连接点或直接返回它们自己的返回值或抛出异常来结束执行。
  4. 后置返回(AfterReturning Advice):在连接点正常完成后执行的通知(如果连接点抛出异常,则不执行)。
  5. 后置异常通知(AfterThrowing advice):在方法抛出异常退出时执行的通知。
正常情况:环绕前置 ==> 前置通知@Before ==> 目标方法执行 ==> 后置返回通知@AfterReturning ==> 后置通知@After ==> 环绕返回 ==> 环绕最终
异常情况:环绕通知 ==> 前置通知@Before ==> 目标方法执行 ==> 后置异常通知@AfterThrowing ==> 后置通知@After ==> 环绕异常 ==> 环绕最终

13、多个切面的执行顺序如何控制

  1. 通常使用@Order 注解直接定义切面顺序
// 值越小优先级越高
@Order(3)
@Component
@Aspect
public class LoggingAspect implements Ordered {
  1. 实现Ordered 接口重写 getOrder 方法。
@Component
@Aspect
public class LoggingAspect implements Ordered {

    // ....

    @Override
    public int getOrder() {
        // 返回值越小优先级越高
        return 1;
    }
}

14、Spring 事务中哪几种事务传播行为

7种传播机制的约束条件

约束条件说明
REQUIRED如果当前没有事务,则新建事务,如果当前存在事务,则加入当前事务,合并成一个事务
REQUIRES_NEW新建事务,如果当前存在事务,则把当前事务挂起,新建事务执行完后再恢复当前事务
NESTED如果当前没有事务,则新建事务,如果当前存在事务,则创建一个当前事务的子事务(嵌套事务),子事务不能单独提交,只能和父事务一起提交
SUPPORTS支持当前事务,如果当前没有事务,以非事务的方式执行
NOT_SUPPORTED以非事务方式执行,如果存在当前事务就把当前事务挂起
NEVER以非事务方式执行,如果当前存在事务就抛异常
MANDATORY使用当前事务,如果当前没有事务,就抛异常

@Transactional的几种失效场景

  1. @Transactional应用在非public修饰的方法上
public class MyService {
    @Transactional
    private void doInternal() {
        System.out.println("Doing internal work...");
    }
}

spring要求被代理方法必须得是public的,在AbstractFallbackTransactionAttributeSource类的computeTransactionAttribute方法中有个判断,如果目标方法不是public,则TransactionAttribute返回null,即不支持事务。

private方法,只会在当前对象中的其他方法中调用,也就是会进行对象的自调用,这种情况是用this调用的,并不会走到代理对象,而@Transactionaly是基于动态代理实现的,所以代理会失效。

  1. 同一个类中的方法直接内部调用,会导致事务失效。
public class MyService {
    public void doSomething() {
        doInternal();
    }

    @Transactional
    public void doInternal() {
        System.out.println("Doing internal work...");
    }
}

事务的底层是Spring AOP来实现的,这种自调用的方式是不满足AOP的动态代理的,如果你想要让这个事务生效,你在A类A方法中调用的时候不能采用 addMoney1(yang, i) 的方式,应该用A类的对象调用才行。

  1. final、static方法

有时候,某个方法不想被子类重新,这时可以将该方法定义成final的。普通方法这样定义是没问题的,但如果将事务方法定义成final会失效,spring事务底层使用了aop,也就是通过jdk动态代理或者cglib,帮我们生成了代理类,在代理类中实现的事务功能。但如果某个方法用final修饰了,那么在它的代理类中,就无法重写该方法,而添加事务功能。注意:如果某个方法是static的,同样无法通过动态代理,变成事务方法。

由于AOP是通过创建代理对象来实现的,而无法对方法进行子类化和覆盖,所以无法栏截这些方法。还有就是调用static方法,因为这类方法是属于这个类的,并不是对象的,所以无法被AOP。

  1. 类本身未被spring管理。

在我们平时开发过程中,有个细节很容易被忽略。即使用spring事务的前提是:对象要被spring管理,需要创建bean实例。

  • 多线程调用。
@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RoleService roleService;

    @Transactional
    public void add(UserModel userModel) throws Exception {
        userMapper.insertUser(userModel);
        new Thread(() -> {
            roleService.doOtherThing();
        }).start();
    }
}

@Service
public class RoleService {

    @Transactional
    public void doOtherThing() {
        System.out.println("保存role表数据");
    }
}

从上面的例子中,我们可以看到事务方法add中,调用了事务方法doOtherThing,但是事务方法doOtherThing是在另外一个线程中调用的。这样会导致两个方法不在同一个线程中,获取到的数据库连接不一样,从而是两个不同的事务。如果想doOtherThing方法中抛了异常,add方法也回滚是不可能的。

  1. 存储引擎不支持事务。

myisam好用,但有个很致命的问题是:不支持事务。如果只是单表操作还好,不会出现太大的问题。但如果需要跨多张表操作,由于其不支持事务,数据极有可能会出现不完整的情况。此外,myisam还不支持行锁和外键。所以在实际业务场景中,myisam使用的并不多。在mysql5以后,myisam已经逐渐退出了历史的舞台,取而代之的是innodb。

  1. 自己吞了异常。

spring的事务是在调用业务方法之前开始的,业务方法执行完毕之后才执行commit or rollback,事务是否执行取决于是否抛出runtime异常。如果抛出runtime exception 并在你的业务方法中没有catch到的话,事务会回滚。尽量不要写 try-catch 如果要写的同时还要保证事务回滚可以尝试在catch最后一行throw一个runtimeException或者手动回滚。

声明式事务与编程式事务

① 声明式事务(使用这种方式,对代码没有侵入性,方法内只需要写业务逻辑就可以了)

通常情况下,我们会在方法上@Transactional注解,填加事务功能,比如:

@Service
public class UserService {
    
    @Autowired 
    private RoleService roleService;
    
    @Transactional
    public void add(UserModel userModel) throws Exception {
       query1();
       query2();
       query3();
       roleService.save(userModel);
       update(userModel);
    }
}


@Service
public class RoleService {
    
    @Autowired 
    private RoleService roleService;
    
    @Transactional
    public void save(UserModel userModel) throws Exception {
       query4();
       query5();
       query6();
       saveData(userModel);
    }
}

但@Transactional注解,如果被加到方法上,有个缺点就是整个方法都包含在事务当中了。

上面的这个例子中,在UserService类中,其实只有这两行才需要事务:

roleService.save(userModel);
update(userModel);

在RoleService类中,只有这一行需要事务:

saveData(userModel);

现在的这种写法,会导致所有的query方法也被包含在同一个事务当中。如果query方法非常多,调用层级很深,而且有部分查询方法比较耗时的话,会造成整个事务非常耗时,而从造成大事务问题。

② 编程式事务

  1. @Transactional注解是通过Spring的AOP起作用的,但是如果使用不当,事务功能可能会失效。
  2. @Transactional注解一般加在某个业务方法上,会导致整个业务方法都在这个事务中,粒度太大,不好控制事务范围。

上面的这些内容都是基于@Transactional注解的,主要讲的是它的事务问题,我们把这种事务叫做:声明式事务。

其实,spring还提供了另外一种创建事务的方式,即通过手动编写代码实现的事务,我们把这种事务叫做:编程式事务。例如:

   @Autowired
   private TransactionTemplate transactionTemplate;
   
   public void save(final User user) {
         queryData1();
         queryData2();
         transactionTemplate.execute(transactionStatus -> {
            addData1();
            updateData2();
            return Boolean.TRUE;
        });
   }

在spring中为了支持编程式事务,专门提供了一个类:TransactionTemplate,在它的execute方法中,就实现了事务的功能。

相较于@Transactional注解声明式事务,更建议大家使用,基于TransactionTemplate的编程式事务。主要原因如下:

  1. 避免由于spring aop问题,导致事务失效的问题。
  2. 能够更小粒度的控制事务的范围,更直观。

建议在项目中少使用@Transactional注解开启事务。但并不是说一定不能用它,如果项目中有些业务逻辑比较简单,而且不经常变动,使用@Transactional注解开启事务开启事务也无妨,因为它更简单,开发效率更高,但是千万要小心事务失效的问题。

事务中避免远程调用

我们在接口中调用其他系统的接口是不能避免的,由于网络不稳定,这种远程调的响应时间可能比较长,如果远程调用的代码放在某个事物中,这个事物就可能是大事务。当然,远程调用不仅仅是指调用接口,还有包括:发MQ消息,或者连接redis、mongodb保存数据等。

Spring在业务中常见的使用方式

  1. 通过IOC实现策略模式

很多时候,我们需要对不同的场景进行不同的业务逻辑处理,普通的逻辑是使用if-else,如下所示:

public void query(String type) {
    if (type.equals("role")) {
        doRole();
    } else if (type.equals("party")) {
        doParty();
    } else {
        doAll();
    }
}

如果type越来越多,这种if-else显然非常不合适,就需要我们借助Spring来完成策略模式

策略模式的三大角色

在这里插入图片描述
策略模式的主要角色如下:

  • 抽象策略(Strategy)类:这是一个抽象角色,通常由一个接口或抽象类实现。此角色给出所有的具体策略类所需的接口。
  • 具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现或行为。
  • 环境(Context)类:用来操作策略的上下文环境,屏蔽高层模块(客户端)对策略、算法的直接访问,封装可能存在的变化。策略模式中的上下文环境(Context),其职责本来是隔离客户端与策略类的耦合,让客户端完全与上下文环境沟通,无需关心具体策略。
public class A {
    // 抽象策略类 Strategy
    public interface IStrategy {
        void query();
    }
    // 具体策略类 ConcreteStrategy
    class ConcreteStrategyA implements IStrategy {
        public void query() {
            System.out.println("Strategy A");
        }
    }
    // 具体策略类 ConcreteStrategy
    class ConcreteStrategyB implements IStrategy {
        public void query() {
            System.out.println("Strategy B");
        }

    }
    // 上下文环境
    class Context {
        private IStrategy mStrategy;

        public Context(IStrategy strategy) {
            this.mStrategy = strategy;
        }

        public void query() {
            this.mStrategy.query();
        }
    }

    public static void main(String[] args) {
        A outer = new A();
        // 选择一个具体策略
        A.IStrategy strategy = outer.new ConcreteStrategyA();
        // 来一个上下文环境
        A.Context context = outer.new Context(strategy);
        // 客户端直接让上下文环境执行算法
        context.query();
    }
}

用Ioc来实现策略模式

接口

// 接口
public interface Shape {
    void draw();
}

实现

// 实现1
@Service
public class Rectangle implements Shape {
    @Override
    public void draw() {
        System.out.println("Inside Rectangle::draw() method.");
    }
}
// 实现2
@Service
public class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Inside Circle::draw() method.");
    }
}
// 实现3
@Service
public class Square implements Shape {
    @Override
    public void draw() {
        System.out.println("Inside Square::draw() method.");
    }
}

枚举类型

// 枚举类型
public enum SettingTypeEnum {

    RECTANGLE("1", "rectangle", "矩形"),

    SQUARE("2", "square", "正方形"),

    CIRCLE("3", "circle", "圆形"),;

    public String code;
    // 接口的实现类名
    public String implement;
    // 备注
    public String desc;

    SettingTypeEnum(String code, String implement, String desc) {
        this.code = code;
        this.implement = implement;
        this.desc = desc;
    }
}

策略工厂

// 策略工厂
@Component
public class ShapeBeanFactory {

    // 关键在这个,原理:当一个接口有多个实现类的时候,key为实现类名,value为实现类对象
    @Autowired
    private Map<String, Shape> shapeMap;

    // 这个注入了多个实现类对象
    @Autowired
    private List<Shape> shapeList;

    public Shape getShape(String shapeType) {
        Shape bean1 = shapeMap.get(shapeType);
        return bean1;
    }
}

控制层或者业务层

@RequestMapping(value = "/drawMyShape", method = RequestMethod.POST)
public String drawMyShape(@RequestBody Map<String, Object> map) {
    shapeBeanFactoryDraw();
    return "成功";
}

private void shapeBeanFactoryDraw() {

    // 根据业务逻辑查库或者其他逻辑确定了一个类型
    String circle = SettingTypeEnum.RECTANGLE.implement;
    // 获取真正对象
    Shape shapeInterface1 = factory.getShape(circle);
    shapeInterface1.draw();
}
  1. 通过AOP实现拦截

很多时候,我们一般是通过注解和AOP相结合。大概的实现思路就是先定义一个注解,然后通过AOP去发现使用过该注解的类,对该类的方法进行代理处理,增加额外的逻辑,譬如参数校验,缓存,日志打印等等,如下代码所示:

@Component
@Aspect
@Slf4j
public class AppGrantAop {
    private ObjectMapper objectMapper = new ObjectMapper();

    @Pointcut("execution(* com.sitech.ep.appgrant.svc.*.*(..))")
    public void pointcut() {}

    @Before("pointcut()")
    public void before(JoinPoint joinPoint) throws IOException {
        // 接收到请求,RequestContextHolder来获取请求信息,Session信息
        ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        log.info("URL : " + request.getRequestURL().toString());
        log.info("HTTP_METHOD : " + request.getMethod());
        log.info("IP : " + request.getRemoteAddr());
        log.info("CLASS_METHOD : " + joinPoint.getSignature().getDeclaringTypeName() + "."
            + joinPoint.getSignature().getName());
        if ("POST".equals(request.getMethod())) {
            Object[] args = joinPoint.getArgs();
            log.info("HEADER : " + objectMapper.writeValueAsString(args[0]));
            log.info("INPUT_PARAM : " + objectMapper.writeValueAsString(args[1]));
        } else if ("GET".equals(request.getMethod())) {
            log.info("INPUT_PARAM : " + objectMapper.writeValueAsString(request.getParameterMap()));
        }
    }
}

15、BeanFactory 和 FactroyBean 的关系

从类名后缀就可以体现这两个类的作用,BeanFactory 是一个Bean工厂,FactoryBean 是一个java bean 明白了这两点我们再来详细介绍下 BeanFactory 和 FactoryBean。

BeanFactory

BeanFactory 是 IOC 容器的底层实现接口,Spring 不允许我们直接操作 BeanFactory 工厂,所以 BeanFactory 接口又衍生很多接口,其中我们经常用到的是 ApplicationContext 接口,这个接口此接口继承 BeanFactory 接口,包含 BeanFactory 的所有功能,同时还进行更多的扩展。

FileSystemXmlApplicationContextClassPathXmlApplicationContext:是用来读取xml文件创建bean对象
ClassPathXmlApplicationContext:读取类路径下xml 创建bean
FileSystemXmlApplicationContext:读取文件系统下xml创建bean
AnnotationConfigApplicationContext:主要是注解开发获取ioc中的bean实例

使用

ApplicationContext applicationContext = new AnnotationConfigApplicationContext(TestApplication.class);
applicationContext.getBean("yang");

FactoryBean

工厂 Bean 跟普通 Bean 不同,其返回的对象不是指定类的一个实例,,其返回的是该工厂 Bean 的 getObject 方法所返回的对象。

  • T getObject():返回由FactoryBean创建的bean实例,如果isSingleton()返回true,那么该实例会放到Spring容器中单实例缓存池中
  • Class getObjectType():返回FactoryBean创建的bean实例的类型
  • boolean isSingleton():返回由FactoryBean创建的bean实例的作用域是singleton还是prototype

演示

/**
 * @Description: 创建一个Spring定义的FactoryBean,T(泛型):指定我们要创建什么类型的对象
 * @Author: yangjj_tc
 * @Date: 2023/5/9 0:23
 */
public class Yang implements FactoryBean<TopCharts> {
    // 返回一个Color对象,这个对象会添加到容器中
    @Override
    public TopCharts getObject() throws Exception {
        System.out.println("TopCharts...getObject...");
        return new TopCharts();
    }

    // 返回这个对象的类型
    @Override
    public Class<?> getObjectType() {
        return TopCharts.class;
    }

    // 是单例吗?
    // 如果返回true,那么代表这个bean是单实例,在容器中只会保存一份;
    // 如果返回false,那么代表这个bean是多实例,每次获取都会创建一个新的bean
    @Override
    public boolean isSingleton() {
        return FactoryBean.super.isSingleton();
    }
}

使用 getBean 返回 getObject 中的对象

@Configuration
public class TestYang {
     @Bean
     public Yang yang() {
        return new Yang();
     }

    @Test
    public void testImport() {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(TestApplication.class);
        // 工厂bean获取的是调用getObject方法创建的对象
        System.out.println(applicationContext.getBean("yang").getClass());
        System.out.println(applicationContext.getBean("&yang"));
    }
}

TopCharts...getObject...
class com.example.test.entity.TopCharts
com.example.test.controller.Yang@6fca5907

16、什么是Spring的循环依赖问题

在Spring框架中,循环依赖是指两个或多个bean之间相互依赖,形成了一个循环引用的情况。如果不加以处理,这种情况会导致应用程序启动失败。

在这里插入图片描述
我们看一个简单的 Demo,对标“情况 2”。

@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;
}

@Service
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}

在Spring中,解决循环依赖的方式就是引入了三级缓存。

但是,Spring解决循环依赖是有一定限制的:

  • 首先就是要求互相依赖的Bean必须要是单例的Bean
  • 另外就是依赖注入的方式不能都是构造函数注入的方式

为什么只支持单例

Spring循环依赖的解决方案主要是通过对象的提前暴露来实现的。当一个对象在创建过程中需要引用到另一个正在创建的对象时,Spg会先提前暴露一个尚未完全初始化的对象实例,以解决循环依赖的问题。这个尚未完全初始化的对象实例就是半成品对象。

Spring容器中,单例对象的创建和初始化只会发生一次,并且在容器启动时就完成了。这意味着,在容器运行期间,单例对象的依赖关系不会发生变化。因此,可以通过提前暴露半成品对象的方式来解决循环依赖的问题。

相比,之下,原型对象的创建和初始化可以发生多次,并且可能在容器运行期间动态地发生变化。因此,对于原型对象,提前暴露半成品对象并不能解决循环依赖的问题,因为在后续的创建过程中,可能会涉及到不同的原型对象实例,无法像单例对象那样缓存并复用半成品对象。

因此,Spring只支持通过单例对象的提前暴露来解决循环依赖问题。

为什么不支持构造函数注入

Spring无法解决构造函数的循环依赖,是因为在对象实例化过程中,构造函数是最先被调用的,而此时对象还未完成实例化,无法注入一个尚未完全创建的对象,因比Spg容器无法在构造函数注入中实现循环依赖的解决。

在属性注入中,Spring容器可以通过先创建一个空对象或者提前暴露一个半成品对象来解决循环依赖的问题。但在构造函数注入中,对象的实例化是在构造函数中完成的,这样就无法使用类以的方式解决循环依赖问题了。

原理执行流程
在这里插入图片描述
整个执行逻辑如下:

  1. 在第一层中,先去获取 A 的 Bean,发现没有就准备去创建一个,然后将 A 的代理工厂放入“三级缓存”(这个 A 其实是一个半成品,还没有对里面的属性进行注入),但是 A 依赖 B 的创建,就必须先去创建 B;
  2. 在第二层中,准备创建 B,发现 B 又依赖 A,需要先去创建 A;
  3. 在第三层中,去创建 A,因为第一层已经创建了 A 的代理工厂,直接从“三级缓存”中拿到 A 的代理工厂,获取 A 的代理对象,放入“二级缓存”,并清除“三级缓存”;
  4. 回到第二层,现在有了 A 的代理对象,对 A 的依赖完美解决(这里的 A 仍然是个半成品),B 初始化成功;
  5. 回到第一层,现在 B 初始化成功,完成 A 对象的属性注入,然后再填充 A 的其它属性,以及 A 的其它步骤(包括 AOP),完成对 A 完整的初始化功能(这里的 A 才是完整的 Bean)。
  6. 将 A 放入“一级缓存”。

17、Spring 中的设计模式

  1. 单例模式

单例模式是Spring一个非常核心的功能,Spring中的bean默认都是单例的,这样可以尽最大程度保证对象的复用和线程安全。

  1. 工厂模式

Spring的IOC就是一个非常好的工厂模式的例子。Spring IOC容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件注解即可,完全不用考虑对象是如何被创建出来的。IOC容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。

  1. 模板方法模式

如果使用过Spring的事务管理,相信一定对TransactionTemplate这个类不陌生,而且顾名思义,这个也是用到了模板方法。它把事务操作按照3个固定步骤来写:① 执行业务逻辑 ② 如果异常则回滚事务 ③ 否则提交事务

在这里插入图片描述
4. 代理模式

Spring的AOP功能用到了JDK的动态代理和CGLIB字节码生成技术

  1. 适配器模式

适配器模式简而言之就是上游为了适应下游,而要做一些适配,承担适配工作的模块,就叫做适配器。常见的场景是甲方因为话语权很高,提供了一套交互模型,而所有对接甲方模型的乙方,就需要通过适配器模式来适配甲方的模型和自己已有的系统。

对于DispatcherServlet来说,HandlerAdapter是核心的业务逻辑处理流程,DispatcherServlet只负责调用HandlerAdapterr#handle方法即可。至于当前Http的请求该如何处理,则交给HandlerAdapter的实现方负责。换句话说,HandlerAdapter只是定义了和DispatcherServlet交互的标准,帮助不同的实现适配了 DispatcherServlet而已。

1、说说自己对于 Spring MVC 了解

MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。

MVC 是一种设计模式,Spring MVC 是一款很优秀的 MVC 框架。Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成。Spring MVC 下我们一般把后端项目分为Controller 层(控制层,返回数据给前台页面)、 Service 层(处理业务)、Dao 层(数据库操作)、Entity 层(实体类)。

2、Spring MVC 执行流程

这里插入图片描述

  1. 用户点击某个请求路径,发起一个 HTTP request 请求,该请求会被提交到 Dispatcher Servlet(前端控制器)
  2. 由 Dispatcher Servlet 调用 Handler Mapping(处理器映射器),并返回一个执行链(Handler Execution Chain)
  3. Dispatcher Servlet 将 Handler Mapping 返回的执行链中的 Handler 信息发送给 Handler Adapter(处理器适配器)
  4. HandlerAdapter 根据 Handler 信息找到并执行相应的 Handler(常称为 Controller
  5. Handler 执行完毕后会返回给 HandlerAdapter 一个 ModelAndView 对象
  6. HandlerAdapter 接收到 ModelAndView 对象后,将其返回给 DispatcherServlet
  7. DispatcherServlet 接收到 ModelAndView 对象后,会请求 ViewResolver(视图解析器)对视图进行解析
  8. ViewResolver 根据 View 信息匹配到相应的视图结果,并返回给 DispatcherServlet
  9. DispatcherServlet 接收到具体的 View 视图后,进行视图渲染,将 Model 中的模型数据填充到 View 视图中的 request 域,生成最终的 View(视图)
  10. 视图负责将结果显示到浏览器(客户端)

3、 Spring MVC 常用的注解有哪些

@RequestMapping:用于处理请求 url 映射的注解,可用于类或方法上。用于类上,则表示类中的所有响应请求的方法都是以该地址作为父路径。

@RequestBody:注解实现接收http请求的json数据,将json转换为java对象。

@ResponseBody:注解实现将conreoller方法返回对象转化为json对象响应给客户。

Sping MVC 中的控制器的注解一般用@Controller注解,也可以使用@RestController,@RestController注解相当于@ResponseBody + @Controller,表示是表现层,除此之外,一般不用别的注解代替。

4、SpringMVC是如何将不同的Request路由到不同Controller中的

4、Spring MVC 里面拦截器是怎么写的

有两种写法,一种是实现HandlerInterceptor接口,另外一种是继承适配器类,接着在接口方法当中,实现处理逻辑;然后在SpringMvc的配置文件中配置拦截器即可

https://blog.csdn.net/yy139926/article/details/127916974

Springboot是如何实现自动配置的

1、什么是 MyBatis

MyBatis是一个半ORM框架(模式是一种为了解决面向对象与关系数据库存在的互不匹配的现象的技术,将Java中的对象和数据库中的表关联对应起来,理解为:Java对象与数据库表的映射管理框架),它内部封装了JDBC,开发的时候只需要关注SQL语句本身就可以了,我们不需要花太多精力去处理原生的JDBC那一套流程,比如 加载驱动、创建connection连接、创建statement等。

优点
很明显,我们能看到使用ybatis的代码,结构更清晰,代码量也比较少,这就是ybatisi最直观的优点:
1.将数据库的操作逻辑和业务操作解耦合,使得开发人员可以专心业务逻辑的处理。
2.开发人员只写Sql就可以访问数据库,不需要关心各种数据库连接等额外的操作。各种Connection和
Statementi堵都交给了Mybatis来管理
3.可以将数据库表的字段按照业务规则直接映射到DO层,不用再像DBC一样需要业务代码来转换
除此之外,还有其他优点:
4.支持多种数据源,如POOLED,UNPOOLED,JNDl。同时,还可以整合其他数据库连接池如HikariCP,
Druid,C3p0等
5.支持动态SQL,大大减少了代码的开发量,如if/foreach等常用的动态标签
6.支持事务性的一级缓存,二级缓存和自定义缓存,其中,一级缓存是以session为生命周期,默认开启;二级
缓存则是根据配置的算法来计算过期时间(「O,LRU等),二级缓存如果操作不当容易产生脏数据,不建
议使用

2、为什么说 MyBatis 是半ORM框架,与 Hibernate 有哪些不同

Hibernate是全自动ORM框架,而Mybatis是半自动的。hibernate完全可以通过对象关系模型实现对数据库的操作,拥有完整的JavaBean对象与数据库的映射结构来自动生成sql。而mybatis仅有基本的字段映射,对象数据以及对象实际关系仍然需要通过手写sql来实现和管理。

3、#{ } 和 ${ } 的区别是什么

  1. #{ } 是预编译处理当中的一个占位符。MyBatis在处理 #{ } 的时候,会将SQL中的 #{ } 替换成 ? ,然后,再调用PreparedStatement对象的set方法进行赋值,由此来防止SQL注入的问题。底层如下:
// 实例化
PreparedStatement pstmt = con.prepareStatement(sql);

// 装载占位符
pstmt.setString(1, "6");
pstmt.setString(2, "bb");

// 执行sql语句
ResultSet rs = pstmt.executeQuery();
  1. ${ } 是单纯的字符串文本替换。MyBatis在处理 ${ } 的时候,只是简单的把 ${ } 替换为变量的值而已,由此会造成SQL注入,带来不必要的风险。
  2. 大部分情况下,我们都是使用 #{ } 来处理业务。但是,针对一些特殊的情况,比如 通过一个“变化的字段”做排序等,也可以使用 ${ } 。
${param} 传入的参数会被当成SQL语句中的一部分,举例:order by ${param},则解析成的sql为:order by id

4、MyBatis 是怎么解决实体类中的属性名和表中的字段名不一样的问题

(1)第一种是使用 <resultMap> 标签,逐一定义列名和实体类对象属性名之间的映射关系。

<resultMap id="orderResultMap" type="com.wind.OrderEntity">
     <!–用id标签来映射主键字段,用result属性来映射非主键字段–>
     <!–其中,用property为实体类属性名,column为数据表中的属性–>
     <id property="id" column="user_id">
     <result property="no" column="user_no"/>
</reslutMap>

(2)第二种是使用在SQL中定义列的别名,将列的别名与实体类对象的属性名一一对应起来

<select id="selectUserById" parameterType="java.lang.Integer" resultetype="com.wind.UserEntity">
       select user_id as id, user_no as no from Test where user_id = #{id}
</select>

5、如何在 Mapper 中传递多个参数

(1)第一种是使用 @param 注解的方式。比如:

user selectUser(@param("username") string username, @param("password") string password);

(2)第二种是使用Java对象的方式。此时,在Java对象中可以有多个属性,每一个属性其实都是一个参数,这样也可以实现在Mapper中传递多个参数。

(3)第三种是使用map集合的方式。此时,需要使Mapper接口方法的输入参数类型和mapper.xml中定义的每个SQL的parameterType的类型都相同。

6、MyBatis 的接口绑定是什么,有哪些绑定方式

接口绑定,就是在MyBatis中定义接口,然后把接口里面的方法和SQL语句绑定,我们直接调用接口中的方法就可以操作数据库了。这样比起直接使用SqlSession对象提供的原生态的方法,更加灵活与简单。

(1)第一种是通过注解绑定,也就是说 在接口的方法上面加上 @Select、@Update 等注解,注解里面包含SQL语句来进行绑定。这种方式可以省去SQL的 xml 映射文件,对于简单的SQL来说比较适用,后期维护比较困难,平时在业务中基本不怎么使用。

(2)第二种是通过在SQL的xml映射文件里面写SQL来进行绑定, 在这种情况下,要指定 xml 映射文件里面的 namespace 参数必须为接口的全类名。不管是SQL简单还是复杂,xml 文件的方式 都比较简单高效,也是最常用的。

7、在MyBatis 中使用 Mapper 接口开发时有哪些要求

一般情况下,在日常开发的时候,会遵循一个mapper.xml映射文件对应于一张表的增删改查。

  1. mapper.xml映射文件中的namespace属性,必须要定义为对应的Mapper接口的全类名,以此来标识一个mapper级别的二级缓存。
  2. Mapper接口中的方法名要和mapper.xml中定义的每个SQL语句的id属性相同。
  3. Mapper接口中的方法的输入参数类型要和mapper.xml中定义的每个SQL语句的parameterType的类型相同。
  4. Mapper接口中的方法的输出参数类型要和mapper.xml中定义的每个SQL语句的resultType的类型相同,或者使用resultMap也行。

8、MyBatis 中 Mapper 接口中的方法支持重载么

通常一个 xml 映射文件,都会写一个 Dao 接口与之对应。Dao 接口就是人们常说的 Mapper 接口,接口的全限名,就是映射文件中的 namespace 的值,接口的方法名,就是映射文件中 MappedStatement 的 id 值,接口方法内的参数,就是传递给 sql 的参数。

Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为 key 值,可唯一定位一个 MappedStatement

举例: com.mybatis3.mappers. StudentDao.findStudentById,可以唯一找到 namespace 为 com.mybatis3.mappers. StudentDao 下面 id = findStudentById 的 MappedStatement,在 MyBatis 中,每一个 <select><insert><update><delete> 标签,都会被解析为一个 MappedStatement 对象。

Dao 接口里的方法可以重载,但是 Mybatis 的 xml 里面的 ID 不允许重复。

/**
 * Mapper接口里面方法重载
 */
public interface StuMapper {

	List<Student> getAllStu();

	List<Student> getAllStu(@Param("id") Integer id);
}

然后在 StuMapper.xml 中利用 Mybatis 的动态 sql 就可以实现。

<select id="getAllStu" resultType="com.pojo.Student">
	select * from student
	<where>
		<if test="id != null">
			id = #{id}
		</if>
	</where>
</select>

能正常运行,并能得到相应的结果,这样就实现了在 Dao 接口中写重载方法。Mybatis 的 Dao 接口可以有多个重载方法,但是多个接口对应的映射必须只有一个,否则启动会报错。

9、MyBatis 的动态SQL是什么,主要标签有哪些

MyBatis的动态SQL标签,主要有以下几类:(1) if 标签,配合 test 标签用来做简单的条件判断。(2)choose 标签,配合 when、otherwise 标签,相当于Java语言中的switch…case语句实现分支选择功能。(3)where 标签,主要是用来简化SQL语句中where条件判断的,能智能的处理 and、or,不用担心有多余的 and 或者 or 导致语法错误。(4)set 标签,主要用来做数据update的时候。(5)trim 标签,对包含的内容加上前缀 prefix、或者后缀 suffix。 (6)foreach 标签,主要用在 Mybatis in 语句中。

(1)if 标签

<select id="findUsersByIf" parameterType="User" resultType="User">
<include refid="select"></include> where age=23 
<if test="name!=null"> and username like #{name}</if>
<if test="address!=null"> and address like #{address}</if>
</select>

name->Name->getName 用getName去parameterType指定的类中寻找是否有此方法,如果有就反射调用,调用完反射结果不为null就拼装sql语句 null就不拼装,address同理。

如果两个条件都不为null
select * from user where age=20 username like ? and address like ?

(2)choose when otherwise 标签

<select id="findUsersByChoose" parameterType="java.util.Map" resultType="User">
<include refid="select"></include> where age=23
<choose>
		<when test="uname !=null">and username like #{uname}</when>
		<when test="uaddress !=null">and address like #{uaddress}</when>
<otherwise>
			and username like '%a%'
			and address like '%b%'
</otherwise>
</choose>
</select>

choose when otherwise 标签 多个when条件同时成立,就取第一个条件成立的when。

(3)where 标签

<select id="findUsersByWhere" parameterType="java.util.Map" resultType="User">
	<include refid="select"></include>
	<where>
		<if test="uname != null">username like #{uname}</if>
	    <if test="uaddress !=null">and address like #{uaddress}</if>
	</where>        
</select>	

where标签是为了给sql语句添加where关键字,where标签中的条件都不成立,where关键字就不添加了,如果两个条件都成立
select * from t_user where username like ? and address like ?
如果第一个不成立,第二个条件成立
select * from t_user where and address like ?
他会自动去掉and关键字

(4)set 标签

<update id="updateUserBySet" parameterType="java.util.Map"> update user
	<set>
	    <if test="uname != null">username=#{uname},</if>
	    <if test="uaddress !=null">address=#{uaddress}</if>
	</set>
	where id=#{uid}
</update>	

set 标签只能用于更新语句,第一个条件成立,第二条件不成立,则自动取消逗号。

(5)trim 标签

trim 替换where标签

<select id="findUsersByTrim" parameterType="java.util.Map" resultType="User">
	<include refid="select"></include>
	<trim prefix="where" prefixOverrides="and|or">
		<if test="uname != null">username like #{uname}</if>
	    <if test="uaddress !=null">and address like #{uaddress}</if>
	</trim>        
</select>

trim标签替换 set标签

<update id="updateUserByTrim" parameterType="java.util.Map"> update t_user
	<trim prefix="set" suffixOverrides=",">
	    <if test="uname != null">username=#{uname},</if>
	    <if test="uaddress !=null">address=#{uaddress}</if>
	</trim>
	where id=#{uid}
</update>

可以替换where标签和set标签。

(6)foreach 标签

<select id="findUsersByForeach" parameterType="list" resultType="User">
	<include refid="select"></include>where id in 
    <foreach collection="list"
             item="id"
             index="index"
             open="("
             close=")"
             separator=",">
       #{id}
    </foreach>
</select>

10、MyBatis 映射文件中,A 标签通过 include 引用了 B 标签的内容,B 标签能否定义在 A 标签的后面

虽然 MyBatis 解析 xml 映射文件是按照顺序解析的,但是,被引用的 B 标签依然可以定义在任何地方,MyBatis 都可以正确识别。

原理是,MyBatis 解析 A 标签,发现 A 标签引用了 B 标签,但是 B 标签尚未解析到,尚不存在,此时,MyBatis 会将 A 标签标记为未解析状态,然后继续解析余下的标签,包含 B 标签,待所有标签解析完毕,MyBatis 会重新解析那些被标记为未解析的标签,此时再解析 A 标签时,B 标签已经存在,A 标签也就可以正常解析完成了。

11、MyBatis 的 Mapper 接口工作原理是什么

Mapper接口,它是没有实现类的。当调用接口方法的时候,它是采用了JDK的动态代理的方式。

在这里插入图片描述

UserMapper userMapper=session.getMapper(UserMapper.class);
  1. Mapper接口的Class对象会被包装成MapperProxyFactory对象,通过MapperProxyFactory对象调用newInstance创建Mapper接口动态代理对象MapperProxy。
  2. 执行Mapper接口方法时候,其实执行的就是用代理对象执行接口方法,本质执行的是MapperProxy代理类的invoke方法,invoke方法中使用MapperMethod对象执行execute方法。
  3. 在execute方法中根据MapperMethod对象中的操作类型选择调用的原生方法(接口名,参数)。

每次通过调用接口方法操作数据库的时候,Mybatis都会利用MapperProxyFactory创建当前Mapper接口对应的MapperProxy代理实现类,在此代理类定义的增强中,会利用sqlSession、接口、方法等信息构造MapperMethod。MapperMethod是Mybatis对Mapper的接口方法生成的对应封装类,此封装类定义了真正的操作数据库的代码实现,最终对数据库的操作就是依赖他实现的。

12、MyBatis 的工作原理是什么

  1. 系统启动时候会读取全局配置文件mybatis-config.xml和加载映射文件Mapper.xml,加载的相关信息都会保存在Configuration对象中。
  2. MyBstis的环境配置信息构建会话工厂SqlSessionFactory(单例模式)
  3. SqlSessionFactory会话工厂创建SqlSession对象,这个对象中包含执行SQL语句的所有方法
  4. MyBatis底层定义了一个Executer执行器来操作数据库,它会根据SqlSession传递的参数生成需要执行的语句,同时负责查询缓存维护。
  5. 在Executer中MappedStatement对象,该参数是对映射信息的封装,用于储存要映射的SQL语句的id,参数等信息

在这里插入图片描述

13、MyBatis 中一对一查询、一对多查询是怎么实现的

  1. 在MyBatis中,使用association标签来解决一对一的关联查询。association标签可用的属性有:(1)property:对象属性的名称(2)javaType:对象属性的类型(3) column:对应的外键字段名称(4)select:使用另一个查询封装的结果。
  2. 在MyBatis中,使用collection标签来解决一对多的关联查询。collection标签可用的属性有:(1)property:指的是集合属性的值(2)ofType:指的是集合中元素的类型(3)column:所对应的外键字段名称(4)select:使用另一个查询封装的结果。

一对一关联查询举例如下

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wind.repository.StudentRepository">
 
    <resultMap id="studentMap" type="com.wind.entity.StudentEntity">
        <result column="Id" property="id"/>
        <result column="Name" property="name"/>
        <result column="ClassId" property="classId"/>
        <result column="Status" property="status"/>
        <result column="AddTime" property="addTime"/>
        <result column="UpdateTime" property="updateTime"/>
    </resultMap>
 
    <resultMap id="studentMap2" type="com.wind.entity.StudentEntity">
        <result column="Id" property="id"/>
        <result column="Name" property="name"/>
        <result column="ClassId" property="classId"/>
        <result column="Status" property="status"/>
        <result column="AddTime" property="addTime"/>
        <result column="UpdateTime" property="updateTime"/>
        <association property="classEntity" javaType="classEntity">
            <id column="cId" property="id"/>
            <result column="cClassName" property="className"/>
            <result column="cStatus" property="status"/>
        </association>
    </resultMap>
 
    <sql id="sql_select">
        select Id, Name, ClassId, Status, AddTime, UpdateTime from RUN_Student
    </sql>
 
    <select id="queryStudent" parameterType="int" resultMap="studentMap">
        <include refid="sql_select"/>
        where id = #{id} and status = 1
    </select>
 
    <select id="queryStudentWithClass" parameterType="int" resultMap="studentMap2">
        select r.Id, r.Name, r.ClassId, r.Status, r.AddTime, r.UpdateTime, c.id as cid, c.ClassName as cClassName, c.Status as cStatus
        from RUN_Student r join RUN_Class c on r.classId = c.id
        where r.id = #{id}
    </select>
 
</mapper>

一对多关联查询举例如下

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wind.repository.ClassRepository">
 
    <resultMap id="classStudentMap" type="com.wind.entity.ClassEntity">
        <result column="Id" property="id"/>
        <result column="ClassName" property="className"/>
        <result column="Status" property="status"/>
        <result column="AddTime" property="addTime"/>
        <result column="UpdateTime" property="updateTime"/>
        <collection property="studentEntities" ofType="com.wind.entity.StudentEntity">
            <result column="sId" property="id"/>
            <result column="sName" property="name"/>
            <result column="sClassId" property="classId"/>
            <result column="sStatus" property="status"/>
            <result column="sAddTime" property="addTime"/>
            <result column="sUpdateTime" property="updateTime"/>
        </collection>
    </resultMap>
 
    <sql id="sql_select_join_student">
        select c.Id, c.ClassName, c.Status, c.AddTime, c.UpdateTime,
        s.Id as sId, s.Name as sName, s.ClassId as sClassId, s.Status as sStatus, s.AddTime as sAddTime, s.UpdateTime as sUpdateTime
        from RUN_Class c join RUN_Student s on c.Id =  s.classId
    </sql>
 
    <select id="queryClassByClassId" parameterType="int" resultMap="classStudentMap">
        <include refid="sql_select_join_student"/>
        where c.id = #{id} and c.status = 1 and s.status =1
    </select>
 
</mapper>

14、MyBatis 的分页方法有哪些

(1)在MyBatis中,是使用RowBounds对象进行分页的,它是针对ResultSet结果集执行的逻辑分页,而不是物理分页。
(2)另外,我们可以在SQL内,直接书写带有物理分页的参数来完成物理分页功能,也可以使用第三方的分页插件PageHelper来完成物理分页。

<!-- SQL物理分页 -->
<select id="queryStudentsBySql" parameterType="map" resultMap="studentmapper"> 
           select * from student limit #{start} , #{end}
</select>
int perPage=3;                 每页现实的记录数
int page=1;                    页数
int begin=(page-1)*perPage;    计算起点

(3)分页插件的原理(物理分页):就是使用MyBatis提供的插件接口,来实现自定义插件。在自定义插件的拦截方法内,拦截待执行的SQL,然后根据设置的分页参数 重写SQL ,生成带有分页语句的SQL,最终执行的是重写之后的SQL,从而实现分页。 举例:

select * from student,分页插件拦截SQL后 重写SQL为:select t.* from (select * from student)t limit 010;

15、MyBatis 中都有哪些Executor执行器,它们之间的区别是什么

在 MyBatis 配置文件中,可以指定默认的 ExecutorType 执行器类型,也可以手动给 SqlSessionFactory 的创建 SqlSession 的方法传递 ExecutorType 类型参数。

在这里插入图片描述
BaseExecutor:基础抽象类,实现了Executor接口的大部分方法,主要提供了缓存管理和事务管理的能力,使用了模板模式,doUpdate、doQuery、doQueryCursor 等方法的具体实现交给不同的子类去实现。

  1. SimpleExecutor:BaseExecutor的具体子类实现,且为默认配置,在doQuery方法中使用PrepareStatement对象访问数据库,每次访问都要创建新的PrepareStatement对象,用完立刻关闭PrepareStatement。

  2. ReuseExecutor:BaseExecutor的具体子类实现,与SimpleExecutor不同的是,在doQuery方法中,以 sql 作为 key 查找 Statement 对象,存在就使用,不存在就创建,用完后,不关闭 Statement 对象,而是放置于 Map<String, Statement>内,供下一次使用。简言之,会重用缓存中的statement对象,而不是每次都创建新的PrepareStatement。

  3. BatchExecutor:BaseExecutor的具体子类实现,在doUpdate方法中,提供批量执行多条SQL语句的能力。将所有 sql 都添加到批处理中(addBatch()),等待统一执行(executeBatch()),它缓存了多个 Statement 对象,每个 Statement 对象都是 addBatch()完毕后,等待逐一执行 executeBatch()批处理。与 JDBC 批处理相同。

  4. CachingExecutor:直接实现Executor接口,使用装饰器模式提供二级缓存能力。先从二级缓存中查询,缓存没有命中再从数据库中查询,最后将结果添加到缓存中再返回给用户。如果在xml文件中配置了节点,则会创建 CachingExecutor。

16、SpringBoot + Mybatis 一级缓存和二级缓存详解

一级缓存

一级缓存在 mybatis 中默认是开启的并且是 session 级别,它的作用域为一次 sqlSession 会话。 一个SqlSession对象中创建一个本地缓存(local cache),在同一个SqlSession中,执行相同的SQL查询时,会尝试去本地缓存中查找是否在缓存,如果在缓存中,就直接从缓存中取出,然后返回给用户,否则,从数据库读取数据,将查询结果存入缓存并返回给用户。
在这里插入图片描述

代码演示:

 @Test
 @Transactional(rollbackFor = Throwable.class)
 public void testFistCache(){
     // 第一次查询,缓存到一级缓存
     userMapper.selectById(1);
     // 第二次查询,直接读取一级缓存
     userMapper.selectById(1);
 }
console 2023-03-15 14:53:58.084 DEBUG [BaseJdbcLogger.java:137] : ==>  Preparing: select * from user where id = ?
console 2023-03-15 14:53:58.084 DEBUG [BaseJdbcLogger.java:137] : ==> Parameters: 12(Integer)
console 2023-03-15 14:53:58.103 DEBUG [BaseJdbcLogger.java:137] : <==      Total: 1
User{userId=12, userName='Endo Riku', userSex='M', userAge=238}
User{userId=12, userName='Endo Riku', userSex='M', userAge=238}

可以看到,虽然进行了两次查询,但最终只请求了一次数据库,第二次查询命中了一级缓存,直接返回了数据。

这里有两点需要说明一下:

  1. 为什么开启事务

使用了数据库连接池,默认每次查询完之后自动 commite,这就导致两次查询使用的不是同一个sqlSessioin,根据一级缓存的原理,它将永远不会生效。当我们开启了事务,两次查询都在同一个 sqlSession 中,从而让第二次查询命中了一级缓存。

  1. 两种一级缓存模式

一级缓存的作用域有两种:session(默认)和 statment,可通过设置 local-cache-scope 的值来切换,默认为 session。二者的区别在于 session 会将缓存作用于同一个 sqlSesson,而 statment 仅针对一次查询,所以,local-cache-scope: statment 可以理解为关闭一级缓存。

  1. 一级缓存总结

mybatis 默认的 session 级别一级缓存,由于 springboot 中默认使用了 hikariCP,所以基本没用,需要开启事务才有用。但一级缓存作用域仅限同一 sqlSession 内,无法感知到其他 sqlSession 的增删改,所以极易产生脏数据。

二级缓存

默认情况下,mybatis 打开了二级缓存,但它并未生效,因为二级缓存的作用域是 namespace,所以还需要在 Mapper.xml 文件中配置一下才能使二级缓存生效

<cache></cache>

测试 单表二级缓存:

/**
  * 测试二级缓存效果
  * 需要*Mapper.xml开启二级缓存
  **/
 @Test
 public void testSecondCache(){
     userMapper.selectById(1);
     userMapper.selectById(1);
 }
console 2023-03-15 14:52:54.318 DEBUG [LoggingCache.java:60] : Cache Hit Ratio [com.example.canal.mybatis.mapper.UserMapper]: 0.5
console 2023-03-15 14:52:54.319 DEBUG [LoggingCache.java:60] : Cache Hit Ratio [com.example.canal.mybatis.mapper.UserMapper]: 0.6
User{userId=12, userName='Endo Riku', userSex='M', userAge=238}
User{userId=12, userName='Endo Riku', userSex='M', userAge=238}

这里可以看到,第二次查询直接命中了缓存,日志还打印了该缓存的命中率。读者可以自行关闭二级缓存查看效果,通过注掉对应 mapper.xml 的 cache 标签,或者 cache-enabled: false 均可

第一次调用mapper下的SQL去查询用户的信息,查询到的信息会存放代该mapper对应的二级缓存区域。 第二次调用namespace下的mapper映射文件中,相同的sql去查询用户信息,会去对应的二级缓存内取结果。

在这里插入图片描述

缓存的优先级

通过 mybatis 发起的查询,作用顺序为:二级缓存 -> 一级缓存 -> 数据库 ,其中任何一个环节查到不为空的数据,都将直接返回结果。

如果多表联查的二级缓存,user 表 left join user_order 表 on user.id = user_order.user_id
我们考虑这样一种情况,该联查执行两次,第二次联查前更新 user_order 表,如果只使用 cache 配置,将会查不到更新的 user_orderxi,因为两个 mapper.xml 的作用域不同,要想合到一个作用域,就需要用到 cache-ref

userOrderMapper.xml

<cache></cache>

userMapper.xml

<cache-ref namespace="com.zhengxl.mybatiscache.mapper.UserOrderMapper"/>

二级缓存可通过 cache-ref 让多个 mapper.xml 共享同一 namespace,从而实现缓存共享,但多表联查时配置略微繁琐。所以生产环境建议将一级缓存设置为 statment 级别(即关闭一级缓存),如果有必要,可以开启二级缓存

17、MyBatis 是否支持延迟加载,如果支持,它的实现原理是什么

MyBatis 仅支持 association 关联对象和 collection 关联集合对象的延迟加载,association 指的就是一对一,collection 指的就是一对多查询。在 MyBatis 配置文件中,可以配置是否启用延迟加载 。

什么是延迟加载

就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。延迟加载也称懒加载。

局部延迟加载

在association和collection标签中都有⼀个fetchType属性,通过修改它的值,可以修改局部的加载策略。

<!-- 开启⼀对多 延迟加载 -->
<resultMap id="userMap" type="user">
    <id column="id" property="id"></id>
    <result column="username" property="username"></result>
    <result column="password" property="password"></result>
    <result column="birthday" property="birthday"></result>
<!--
fetchType="lazy" 懒加载策略
fetchType="eager" ⽴即加载策略
-->
    <collection property="orderList" ofType="order" column="id"
        select="com.lagou.dao.OrderMapper.findByUid" fetchType="lazy">
    </collection>
</resultMap>
<select id="findAll" resultMap="userMap">
    SELECT * FROM `user`
</select>

全局延迟加载

在Mybatis的核⼼配置⽂件中可以使⽤setting标签修改全局的加载策略。

<settings>
    <!--开启全局延迟加载功能-->
    <setting name="lazyLoadingEnabled" value="true"/>
</settings>

局部的加载策略的优先级高于全局的加载策略

延迟加载原理实现

它的原理是,使用 CGLIB 创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用 a.getB().getName() ,拦截器 invoke() 方法发现 a.getB() 是 null 值,那么就会单独发送事先保存好的查询关联 B 对象的 sql,把 B 查询上来,然后调用 a.setB(b),于是 a 的对象 b 属性就有值了,接着完成 a.getB().getName() 方法的调用。这就是延迟加载的基本原理。

18、MyBatis 是如何防止sql注入的

Mybatis也是用的预编译进行防护SQL注入。其实在框架底层,是JDBC中的PreparedStatement类在起作用。

@Test
public void test02() throws SQLException {

    Connection con = JdbcUtil.getConn();

    String sql = "SELECT * FROM test WHERE ID = ? AND NAME = ?";

    // 实例化
    PreparedStatement pstmt = con.prepareStatement(sql);

    // 装载占位符
    pstmt.setString(1, "6");
    pstmt.setString(2, "bb");

    // 执行sql语句
    ResultSet rs = pstmt.executeQuery();

    System.out.println(rs);
    while (rs.next()) {
        System.out.println(rs.getInt("id") + " " + rs.getString("name"));
    }

    JdbcUtil.close(con);
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值