学习安卓所需的基本知识

239 阅读1小时+

1.集合

HashMap 的底层实现?

HashMap 的长度为什么是 2 的幂次方?

HashMap的put() 添加操作 和 resize()扩容操作?

HashMap 线程不安全的体现?

HashMap 和 Hashtable 的区别?

HashMap 和 TreeMap 区别?

HashMap 常见的遍历方式?

1.使用迭代器(Iterator) EntrySet 的方式进行遍历;

2.使用迭代器(Iterator)KeySet 的方式进行遍历; 3.使用 For Each EntrySet 的方式进行遍历; 4.使用 For Each KeySet 的方式进行遍历;

5.使用 Lambda 表达式的方式进行遍历;map.forEach((key;value) -> {代码体})

6:使用 Streams API 单线程的方式进行遍历;map.entrySet().stream.forEach((entry)->{代码体}) 7.使用 Streams API 多线程的方式进行遍历。map.entrySet().parallelStream.forEach((entry) ->{代码体})

性能分析:Stream(多)> EntrySet > Stream(单)> KeySet > lambda

Q:为什么EntrySet 比 KeySet 速度快呢? A:EntrySet 只遍历了一遍 Map 集合就可以获取到 key 和 value;KeySet 在循环时使用了map.get(key),相当于又遍历了一遍 Map 集合,总共遍历了两遍 Map 集合。

注意⚠️:我们不能在遍历中使用集合 map.remove() 来删除数据,这是非安全的操作方式,可以使用迭代器 iterator.remove()来删除数据。


ConcurrentHashMap 和 Hashtable 的区别?

相同点:

  • 都是线程安全的;
  • 都不能使用 null 作为 key 和 value;

底层数据结构:

  • ConcurrentHashMap 在 JDK1.7 采用 分段的数组+链表 实现,JDK1.8 采用的数据结底层构跟 HashMap1.8 的结构一样 数组+链表/红黑二叉树;
  • Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式;

初始化容量和扩容机制:

  • HashTable 的默认初始容量为 11,扩容时新容量是 2n+1
  • ConcurrentHashMap的 segment 分段数组默认是 16,并且他不能动态扩容, hashEntery 数组可以扩容

实现线程安全的方式(重要):

  • 在JDK1.7 的时候,ConcurrentHashMap 采用 分段锁 的方式,多线程访问容器里不同数据段的数据,就不会存在竞争,提高并发访问率;JDK1.8 的时候, ConcurrentHashMap 已经摒弃了 Segment 的概念,采用 Synchronize+CAS 的方式来保证线程安全,如果没有发生锁冲突的话,采用 CAS 的方式来完成元素的新增,如果发生了锁冲突的话采用 Synchronize 来锁住头结点。
  • Hashtable(同一把锁):使用 synchronized 来保证线程安全,效率非常低下。如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用get,竞争会越来越激烈效率越低。

ConcurrentHashMap 线程安全的具体实现方式/底层具体实现?

在 JDK1.7 采用 Segment 数组,Segment 数组的大小一旦确定就不能改变了,默认大小为 16,也就是说默认可以同时支持16个线程并发写

一个 Segment 包含一个 HashEntry 数组, 每个 HashEntry是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁 也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的

JDK1.8 的时候, ConcurrentHashMap 已经摒弃了 Segment 的概念,采用 Synchronize+CAS 的方式来保证线程安全,如果没有发生锁冲突的话,采用 CAS 的方式来完成元素的新增,如果发生了锁冲突的话采用 Synchronize 来锁住头结点。


ConcurrentHashMap在JDK1.8中为什么要使用内置锁Synchronized来替换ReentractLock重入锁(Segment 分段锁)?

  • 锁粒度降低了;
  • 官方对synchronized进行了优化和升级,使得synchronized不那么“重”了;
  • 在大数据量的操作下,对基于API的ReentractLock进行操作会有更大的内存开销

ConcurrentHashMap的get()方法需要加锁吗?

不需要,get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。


ConcurrentHashMap 为什么 key 和 value 不能为 null?

主要是为了避免二义性,在多线程的环境下,ConCurrentHashMap.get(key) 如果结果为 null 的话,没有办法确定是这个 key 是不存在;还是说这个 key 的 value 为 null


ConcurrentHashMap 能保证复合操作的原子性吗?

ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况

对于 get、put、remove、containsKey 等的单个 API 操作他可以保证线程安全,但是对于复合操作(比如先判断key是否存在,在添加)他无法保证原子性。


CopyOnWriteArrayList 的基本知识?

CopyOnWriteArrayList 采用了写入时拷贝的思想,增删改操作会将底层数组拷贝一份,在新数组上执行操作,之后在覆盖原先的数组,不影响其它线程的并发读,读写分离

使用场景:适合读多写少的应用场景,因为写操作需要拷贝数组,比较耗时

迭代器:CopyOnWriteArrayList 在返回迭代器时,创建一个内部数组当前的快照(引用) ,即使其他线程替换了原始数组,迭代器遍历的快照依然引用的是创建快照时的数组,所以这种实现方式也存在一定的数据延迟性,对其他线程并行添加的数据不可见


Collections 怎么保证集合线程安全的?

底层也是对原集合方法进行加Synchronize锁

Q:增的时候可以 get 嘛?可以并发吗?

A:

2.Java 基础

jdk8 的新特性

1.新 interface 的方法可以用defaultstatic修饰,这样就可以有方法体,实现类也不必重写此方法。

注意⚠️:如果有一个类既实现了 InterfaceNew1 接口又实现了 InterfaceNew2接口,它们都有default 类型的def()方法,并且 InterfaceNew1 接口和 InterfaceNew2接口没有继承关系的话,这时就必须重写def()。不然的话,编译的时候就会报错。

2.functional interface 函数接口,即接口中有且只有一个抽象方法,但可以有多个非抽象方法的接口。

3.Lambda 表达式

  • 代替匿名内部类

    • 比如创建线程的时候需要传递一个 Runnable 参数;
    • 使用 comparator 比较器,进行排序的时候
    • 注意📢:前提是接口需要是函数式接口,即接口中只有一个抽象方法
  • 集合foreach迭代的时候集合对象.forEach((s) -> System.out.println(s));

4.Stream 流:Stream API 提供了一种流式操作集合的方式,可以进行过滤、映射、排序、聚合等操作。它使得对集合的处理更加简洁和高效,并且可以支持并行处理,提高了程序的性能。

注意📢:一个 Stream 只能操作一次,操作完就关闭了,继续使用这个 stream 会报错。

5.新的 Date 处理类:LocalDateTime/LocalDate/LocalTime Java 8 引入了全新的日期和时间 API(java.time 包),提供了更加简洁易用线程安全[不可变的]的日期和时间处理方式,解决了旧的 Date 和 Calendar 类的一些问题。


创建对象的几种方式?

1.使用new关键字 2.使用Class类的newInstance方法 3.使用Constructor类的newInstance方法 4.使用clone方法 5.使用反序列化,需要实现 Serializable 接口


面向对象三大特征?

1.封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性,对外隐藏对象内部的实现细节,提高程序的安全性

2.继承:

  • 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是 无法访问,只是拥有;
  • 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。

3.多态:表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。

  • 调用方法时,只有在运行期间才可以确定到底是调用的父类还是子类的方法;
  • 只能调用父类中存在的方法。
  • 提高程序的灵活性和拓展性。在不同的情况下表现出不同的状态。

接口和抽象类有什么共同点和区别?

相同点:

  • 都不能被实例化。
  • 都可以包含抽象方法。
  • 都可以有默认实现的方法(Java 8 可以用default或static关键字在接口中定义默认方法)

不同点:

  • 接口主要是为了对类的行为的约束,抽象类主要是为了代码复用;
  • 单继承,多实现;
  • 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。

深拷贝和浅拷贝区别了解吗?什么是引用拷贝?

浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。

深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。

引用拷贝:引用拷贝就是两个不同的引用指向同一个对象。


class对象包含什么信息,存在什么位置?

在Java中,每个类(Class)都有一个对应的 Class 对象,它包含了该类的结构信息以及一些元数据,通常被存储在方法区中。


一个类在什么时机会被加载进虚拟机?

除了使用 new 关键字创建类的实例外,类还会在以下几种情况下被加载进Java虚拟机:

  1. new 对象
  2. 静态访问:当一个类中包含静态成员(静态字段或静态方法)时,如果程序通过类名直接访问了这些静态成员,那么该类会被加载。
  3. 反射:使用Java的反射机制,可以在运行时通过类的全限定名来动态加载类。例如,通过Class.forName("ClassName")方法加载类时,会触发类的加载过程。
  4. 初始化子类:如果一个类的子类在被加载时,其父类也会被自动加载。这是因为子类的初始化需要先初始化父类。
  5. 启动类的 main 方法:当一个JVM启动并执行某个类的 main 方法时,这个类会被加载。

Object 类的常见方法有哪些?(11个)

 /**
  * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
  */
 public final native Class<?> getClass()
 /**
  * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
  */
 public native int hashCode()
 /**
  * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
  */
 public boolean equals(Object obj)
 /**
  * native 方法,用于创建并返回当前对象的一份拷贝。
  */
 protected native Object clone() throws CloneNotSupportedException
 /**
  * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
  */
 public String toString()
 /**
  * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
  */
 public final native void notify()
 /**
  * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
  */
 public final native void notifyAll()
 /**
  * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
  */
 public final native void wait(long timeout) throws InterruptedException
 /**
  * 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
  */
 public final void wait(long timeout, int nanos) throws InterruptedException
 /**
  * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
  * 让占有当前对象锁的线程进入无限等待状态,直到被唤醒
  */
 public final void wait() throws InterruptedException
 /**
  * 实例被垃圾回收器回收的时候触发的操作
  */
 protected void finalize() throws Throwable { }

== 和 equals() 的区别?

==对于基本数据类型来说比较的是值;对于引用数据类型来说比较的是对象的内存地址。

equals 只有对象才有此方法,默认 equals 比较的地址是否相等,不过一般会重写 equals 来判断内容是否相等。


equals 和 hashCode 怎么配合?

hashCode() 的作用是获取哈希码(int 整数),确定该对象在哈希表中的索引位置。

在HashMap、HashSet中先用 hashCode() 来获取元素的哈希码,从而确定元素在桶的中的下标,只有在 hash 冲突的时候,才会使用 equals() 判断元素是否真的相等。

追 Q:为什么重写 equals() 时必须重写 hashCode() 方法?

A:如果重写equals()时没有重写hashCode()方法的话就可能会导致equals方法判断是相等的两个对象,hashCode 值却不相等。也就是说 HashSet 的不可重复性得不到保证。


String、StringBuffer、StringBuilder 的区别?

String、StringBuffer 是线程安全的,StringBuilder 不是线程安全的;

使用场景:

  • 操作少量的数据: 适用 String
  • 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  • 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

追 Q:String 为什么是不可变的?

A:关于不可变的一个粗略的定义:对象一旦创建后,其状态不可修改,则该对象为不可变对象。

也就是每次对 String 类型进行改变的时候都会重新生成一个新 String 对象,然后将指针指向新的 String 对象;

  • 保存字符串的char数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。
  • String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。

追 Q:不可变类型还有哪些?

  • A:String:每次对 String 类型进行改变的时候都会重新生成一个新 String 对象,然后将指针指向新的 String 对象;
  • Integer、Long、Double 等包装类:这些包装类也是不可变对象,一旦创建就不能被修改。如果需要修改其值,只能创建一个新的对象。
  • Biglnteger、BigDecimal:这些类是用来表示任意精度的整数和小数的,它们也是不可变对象。每次进行运算操作时,都会创建一个新的对象。
  • LocalDate、LocalTime、LocalDateTime:这些类用来表示日期和时间,它们也是不可变对象。每次进行修改操作时,都会创建一个新的对象。 Enum:Java 中的枚举类型也是不可变对象,一旦创建就不能被修改。
  • Collections.unmodifiablexxX()方法返回的集合:这些集合是只读的,不能进行添加、删除、修改操作。如果需要修改集合,只能创建一个新的集合对象。

追 Q:使用 “+” 拼接字符串的底层逻辑?

A:

  • jdk9 之前字符串对象通过“+ 或者+=”的拼接方式时,实际上是通过新建一个 StringBuilder 对象调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

    • 注意:循环内使用 “+=” 来进行字符串的拼接的话,会导致频繁的创建 StringBuilder 对象,消耗资源。
  • jdk9 之后相加“+”改为了用动态方法 makeConcatWithConstants()来实现,而不是大量的 StringBuilder 了。

追 Q:String s1 = new String("abc");这句话创建了几个字符串对象?

A:会创建 1 个或者 2 个字符串对象。

  • 第一种情况,new String(“abc”)会直接在堆中创建一个对象,然后他会检查字符串常量池中是否有字符串“abc”的引用,如果没有,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中;
  • 第二种情况,此时字符串常量池中已经存在了字符串”abc“的的引用,那么只会在堆中创建一个字符串对象。
  • 提醒📢:字符串常量池中保存的也是堆中对象的引用;

介绍一下 Throwable 这个异常类

image-20230930081105563

Throwable 有两个子类,分别是 Exception 和 Error。

Exception:程序本身可以处理的异常,可以通过catch 来进行捕获。Exception 又可以分为 Checked Exception(受检查异常,必须处理)和 Unchecked Exception(不受检查异常,可以不处理)。

  • 检查时异常如果没有被 catch 或者 throws 关键字处理的话,就没办法通过编译。包括:IOException、ClassNotFoundException、SQLException、FileNotFoundException
  • 不受检查异常在编译过程中,即使没有处理也可以正常通过编译。RuntimeException 及其子类统称为非受检查异常,包括:NullPointException、IllegalArgumentException(参数错误)、ArrayIndexOutOfBoundsException(数组越界)、ClassCastException(类型转换)、ArithmeticException(算术错误)

Throwable 类常用方法有哪些?

String getMessage(): 返回异常发生时的简要描述 String tostring(): 返回异常发生时的详细信息 String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()返回的结果相同 void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息


try-catch-finally

try 块:用于捕获异常。其后可接零个或多个catch块,如果没有catch 块,则必须跟一个finally块。

注意⚠️:

  • 如果 try 中有 return,那么返回的结果会被先缓存起来,然后在执行 finally 中的代码,执行完返回缓存起来的结果。
  • 不要在 finally语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的return 语句会被忽略。这是因为try 语句中的 return返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值

追 Q:finally 中的代码一定会执行吗?

A:不一定的!在某些情况下,finally 中的代码不会被执行。

  • 就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。
  • 程序所在的线程死亡。
  • 关闭 CPU。

追 Q:try-with-resources

A:适用范围(资源的定义):任何实现java.lang.AutoCloseable 或者iava.io.CLoseable的对象;

资源会在 catch 块和 finally 块执行之前就关闭资源;

介绍一下泛型?

使用泛型的好处:

  • 增强代码的可读性;
  • 编译时检查;
  • 增强代码的灵活性。

泛型的使用场景:

  • 泛型类;
  • 泛型接口;
  • 泛型方法。

追 Q:泛型擦除是什么意思呢?

A:在Java中,泛型是在编译期间进行类型检查的,而在运行时将会被擦除掉,例如无法通过泛型类型参数获取它的实际类型。


反射的优缺点?

优点:

  • 运行时分析类的能力,获取类中的所有属性和方法;
  • 增强了代码的灵活性,为各种框架提供了开箱即用的功能。

缺点:

  • 增加了安全问题,比如越过泛型的限制给集合中添加任意类型的元素。

追 Q:什么框架中使用了反射呢?

A:动态代理中,需要使用反射类 Method 来调用指定的方法。

。。。。。


序列化和反序列化?

序列化:将数据结构或对象转换成二进制字节流的过程 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

适用场景:通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。

追 Q:序列化协议对应于 TCP/IP 4 层模型的哪一层?

A:在应用层的表示层,表示层负责数据处理(编解码、加密解密、压缩解压缩)。

追 Q:有些字段不想进行序列化怎么办?

A:使用 transient 关键字修饰,在反序列化后变量值将会被置成类型的默认值。并且 transient 只能修饰变量,不能修饰类和方法。

提醒🔔:static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。

追 Q:常见序列化协议有哪些?

A:JDK 自带的序列化方式一般不会用,因为序列化效率低并且存在安全问题。比较常用的序列化协议有Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。像JSON 和XML这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。

追 Q:为什么不推荐使用 JDK 自带的序列化?

A:待补充

I/O

InputStream / Reader:所有的输入流的基类,前者是字节输入流,后者是字符输入流。 OutputStream / Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。

Q:I/O 流为什么要分为字节流和字符流呢?

A:如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

补 Q:常用字符编码所占字节数?(根据字母个数记忆)

A:字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。

utf8:英文占1字节,中文占3字节; unicode:任何字符都占 2个字节;

gbk:英文占1字节,中文占2字节。

追 Q:IO 中的设计模式?

A:

image.png

  1. 装饰器模式:在不改变原有对象的情况下拓展其功能。

通过组合替代继承来拓展原始类的功能;

举个例子,我们可以通过 BufferedInputStream (字节缓冲输入流)来增强 FileInputStream 的功能,可以在 BufferedInputStream 的构造器来传入一个 InputStream。

 public BufferedInputStream(InputStream in){
   this(in, DEFAULT_BUFFER_SIZE);
 }
  1. 适配器模式:主要用于接口互不兼容的类的协调工作.

字符流是通过字节流转换得来的,也就是字节流通过适配器适配成了字符流! 采用的是对象适配器!

适配者 InputStream 和 OutPutStream;

适配器 InputStreamReader (中有流解码器StreamDecoder 实现字节流到字符流的转换)和 OutPutStreamWriter(中有流编码器 实现字符流到字节流的转换);

目标类 Reader 和 Writer;

当我们使用 FileReader(String fileName) 创建对象的时候实际上调用了 适配器 InputStreamReader(new FileInputStream(fileName))

3.多线程

进程的介绍?

进程(Process):是动态的,是程序的一次执行过程,是系统进行资源分配调度的一个独立单位。

程序:是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。eg:腾讯QQ.exe文件

两者联系:同一个程序执行多次会对应多个进程。操作系统会给该进程分配一个唯一的不重复的 PID(Process ID,进程ID)

进程的组成:PCB(Process Control Block)、程序段、数据段。

  • PCB:进程描述信息、控制和管理信息、资源分配清单、处理机相关信息。
  • 程序段:程序的代码(指令序列)
  • 数据段:运行过程中产生的各种数据(如:程序中定义的变量)

进程的状态转换?

创建状态:创建PCB,程序段,数据段

就绪状态:已经具备运行条件,但是没有空闲的CPU,暂时不能运行(CPUX,其它资源√)

运行状态:占有CPU,并在CPU上运行,单核只能一个进程(双核两个)(CPU√,其它资源√)

阻塞状态:等在某个事件的发生,暂时不能运行(CPUX,其它资源X)

终止状态:回收内存,程序段,数据段,撤销PCB

image-20240103093109398


进程间的通信方式?-工作量有点大,稍后补充!

进程间通信的必要性:进程间通信(简称IPC,Interprocess Communication)是指在多个进程之间传输数据或共享信息的机制。在操作系统中,每个进程都具有独立的地址空间和资源,为了实现进程之间的数据交换和协作,需要使用IPC机制。

通信的重点:进程间通信的重点是不同的进程要可以访问同一块内存区域

  • 管道

    • 无名管道:亲缘关系进程间通信。
    • 命名管道:进程间通信。
  • System V IPC

    • 消息队列:IPC命名空间实现,通过消息传递数据。
    • 信号量:IPC命名空间实现,进程间同步。
    • 共享内存:IPC命名空间和内存映射实现,共享内存传递数据。
  • POSIX IPC

    • 消息队列:文件实现,通过消息传递数据。
    • 信号量:文件实现,进程间同步。
    • 共享内存:文件和内存映射实现,共享内存传递数据。
  • 套接字


线程间的通信方式?-工作量大,稍后补充!


yield 和 join 的区别?

"join"用于等待一个线程执行完成,实现线程之间的协同工作。

“yield”用于提示线程调度器让出当前线程的执行权限,让其他线程有机会抢到 CPU 的执行权,但是并不能保证其他线程会立即执行。


介绍直接内存?

Direct Memory 优点:

  • Java 的 NIO 库允许 Java 程序使用直接内存,使用 native 函数直接分配堆外内存
  • 读写性能高,读写频繁的场合可能会考虑使用直接内存
  • 大大提高 IO 性能,避免了在 Java 堆和 native 堆来回复制数据

直接内存缺点:

  • 不能使用内核缓冲区 Page Cache 的缓存优势,无法缓存最近被访问的数据和使用预读功能
  • 分配回收成本较高,不受 JVM 内存回收管理
  • 可能导致 OutOfMemoryError 异常:OutOfMemoryError: Direct buffer memory
  • 回收依赖 System.gc() 的调用,但这个调用 JVM 不保证执行、也不保证何时执行,行为是不可控的。程序一般需要自行管理,成对去调用unsafe类下的 allocateMemory、freeMemory

应用场景:

  • 传输很大的数据文件,数据的生命周期很长,导致 Page Cache 没有起到缓存的作用,一般采用直接 IO 的方式
  • 适合频繁的 IO 操作,比如网络并发场景

数据流的角度:

  • 非直接内存的作用链:本地 IO → 内核缓冲区→ 用户(JVM)缓冲区 →内核缓冲区 → 本地 IO
  • 直接内存是:本地 IO → 直接内存 → 本地 IO

线程与进程的关系,区别及优缺点?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。

一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈

线程执行开销小,但不利于资源的管理和保护;而进程正相反

进程适合用于多任务处理和资源独立的场景,而线程适合用于并行计算和资源共享的场景。


并发、并行、同步、异步的定义?

并发:线程轮流使用CPU的做法称为并发(concurrent)

并行:多核CPU,每个核(core)都可以调度运行线程,这时候线程可以是并行的。

同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。

异步:调用在发出之后,不用等待返回结果,该调用直接返回。


多线程的线程同步和互斥的理解?

线程互斥,就是多个线程在同一时间只能有一个线程去访问临界区的资源;

线程同步,是多个线程彼此合作,通过一定的逻辑关系来共同完成一个任务。一般来说,同步关系中往往包含互斥,同时对临界区的资源会按照某种逻辑顺序进行访问。如先生产后使用


使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏死锁线程不安全等等。


单核 CPU 上运行多个线程效率一定会高吗?

在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待CPU 的时间片分配。

如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。

如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待IO时的空闲时间,提高了效率。


如何理解线程安全和不安全?

线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性一致性的描述。

线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。

线程的生命周期和状态?

image-20230503203629212


什么是线程上下文切换?

导致线程上下文切换的情况:

  • 主动让出 CPU,比如调用了 sleep(),wait()等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求IO,线程被阻塞。
  • 被终止或结束运行

上下文切换要做的事情:线程切换意味着需要保存当前线程的上下文(比如程序计数器、栈信息等),待线程下次占用CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。


sleep() 方法和 wait() 方法对比?

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同

    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同

    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)

    • wait() 方法只能在同步代码块或同步方法中调用,并且调用 wait() 方法的线程必须是锁对象的持有者。如果调用 wait() 方法的线程不是锁对象的持有者,会抛出 IllegalMonitorStateException 异常。,而 sleep 则无此限制
  • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)

  • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)


sleep(0)有什么用?

在 Java 中,Thread.sleep(0) 的作用是让当前线程主动放弃 CPU 时间片,将执行机会让给其他可运行的线程。它的效果类似于 yield() 方法。


JMM(Java 内存模型)的了解?

JMM(Java Memory Model)是Java内存模型的缩写,它定义了Java程序中多线程并发访问共享内存时的行为规范。JMM的设计目标是提供一种可见性原子性有序性的保证,以确保多线程程序的正确性。

  1. 主内存(Main Memory):主内存是所有线程共享的内存区域,用于存储变量的值。
  2. 工作内存(Working Memory):每个线程都有自己的工作内存,工作内存是线程的私有内存区域,用于存储变量的副本。线程对变量的读写操作都是在工作内存中进行的。
  1. happens-before关系:happens-before关系是JMM定义的一种偏序关系,用于确保指令执行的有序性。如果一个操作happens-before另一个操作,那么第一个操作的结果对于第二个操作可见,且第一个操作在时间上发生在第二个操作之前。

可以使用 volatile来保证有序性和可见性,使用锁机制来保证原子性。


介绍 volatile 关键字?

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

指令重排序:指令重排序是现代处理器为了提高指令执行效率而采取的一项技术。在处理器中,为了提高性能,会对指令进行乱序执行,即不按照程序编写的先后顺序执行指令,而是根据指令之间的依赖关系和可并行性进行重排序。

但是在多线程并发执行的场景下,指令重排序可能会引发线程安全性问题和并发错误,有时我们需要禁止指令重排序,比如在 双重校验锁实现对象单例(线程安全) 当中会使用 volatile 来修饰单例属性

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

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

  • 为 uniqueInstance 分配内存空间
  • 初始化 uniqueInstance
  • 将 uniqueInstance 指向分配的内存地址

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

Q:底层原理?

A:原理:「内存屏障」

谐音:上吐(读)下泻(写)

volatile 的底层实现原理是内存屏障,Memory Barrier (Memory Fence)

  • 对 volatile 变量的读指令前会加入读屈障
  • 对 volatile 变量的写指令后会加入写屏障

保证可见性

  • 读屏障(lfence,Load Barrier)保证在该屏障之后的,对共享变量的读取,从主存刷新变量值,加载的是主存中最新数据
  • 写屏障(sfence,Store Barrier)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

保证有序性

  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

追 Q:volatile 可以保证有序性,不能保证原子性,为什么呢?

A:因为有序性是基于本线程的,他会保证本线程中相关代码不会被指令重排序,但是他不能解决多个线程间的指令交错。

想要保证原子性,可以采用锁机制来保证原子性。

image-20231102232200907


什么是线程死锁?如何预防、避免死锁?

线程都各自占有了一部分资源,并且都在等别的线程手里的资源,导致形成了一个首尾相连的循环等待资源关系。

上面的例子符合产生死锁的四个必要条件:

  • 互斥条件:该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何预防死锁?破坏死锁的产生的必要条件即可:

  • 破坏请求与保持条件:一次性申请所有的资源。

  • 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

  • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条 件。

    • 原理分析:一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源 按此规则,已持有大编号资源的进程不可能逆向地回来申请小编号的资源,从而就不会产生循环等待的现象

如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,找出一个安全序列。

银行家算法步骤:

  • 检查此次申请是否超过了之前声明的最大需求数
  • 检查此时系统剩余的可用资源是否还能满足这次请求
  • 试探着分配,更改各数据结构
  • 安全性算法检查此次分配是否会导致系统进入不安全状态
  • 如果可以找到一个安全序列,那么同意给此进程分配资源,否则拒绝给此进程分配资源

安全性算法步骤:

  • 检查当前的剩余可用资源是否能满足某个进程的最大需求,如果可以,就把该进程加入安全序列, 并把该进程持有的资源全部回收
  • 不断重复上述过程,看最终是否能让所有进程都加入安全序列

Q:如何定位死锁?

A:

  1. 使用 jps 定位进程 id,再用 jstack 进程id 定位死锁,找到死锁的线程去查看源码,解决优化
  • jps:是Java虚拟机(JVM)工具的一部分,用于列出正在运行的Java进程的相关信息。这些信息包括Java进程的进程ID(PID)和主类名。
  • jstack:是Java虚拟机(JVM)工具的一部分,用于生成Java线程转储(Java thread dump)。Java线程转储是一种记录Java应用程序中所有线程状态和执行堆栈的快照。
  1. 可以使用 jconsole 工具,在 jdk\bin 目录下,选择要连接的进程,点击死锁检测按钮
  2. Linux 下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 的输出来看各个线程栈

乐观锁和悲观锁的了解?

悲观锁总是假设最坏的情况,认为总是有人来和他抢锁,所以每次在操作资源的时候都会先上锁;

缺点:高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

乐观锁总是假设最好的情况,认为没人和他抢锁,所以只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(版本号或者 CAS 算法);

实现方式:

  • 版本号:当多个事务同时操作同一条记录时,先读取记录的当前版本号,然后在更新时比较当前版本号是否与之前读取的版本号相同。如果相同,则继续执行更新操作并让版本号加1;如果不同,则表示该记录已经被其他事务修改,需要进行冲突处理(如重试或回滚)。
  • CAS 的全称是 Compare And Swap(比较与交换) :用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

缺点

  • 在高并发情况下,CAS操作可能会出现大量重试,从而占用过多的CPU资源,导致性能下降。
  • ABA问题是指在执行CAS操作时,共享变量的值经过一系列修改后再回到原来的值,导致CAS操作成功但实际上已经发生了变化。为避免这种情况,可以使用带有版本号的CAS操作(如Java中的AtomicStampedReference),在比较和更新值时同时比较和更新版本号,从而确保数据一致性。
  • CAS操作只能用于对单个共享变量进行原子操作。如果需要对多个共享变量进行原子操作,就需要使用其他并发控制算法,如锁机制或事务机制等。
  • 无法保证公平性 CAS操作是一种非阻塞式算法,不会对线程进行阻塞或唤醒操作,因此无法保证公平性。在高并发情况下,一些线程可能会长时间处于忙等待状态,没有任何机制来保证哪个线程能够获得更优先的执行机会,因此无法保证公平性。

追 Q:高并发下使用乐观锁还是悲观锁呢?

A:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁和大量的上下文切换影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

synchronize 关键字 -这个也得补一下!!!


ReentrantLock

Q:synchronized 和 ReentrantLock 有什么区别?

A:

相同点:两者都是可重入锁

不同点:

  • 锁的实现:synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的

  • 性能:新版本 Java 对 synchronized 进行了很多优化,synchronized 与 ReentrantLock 大致相同

  • 使用:ReentrantLock 需要手动解锁,synchronized 执行完代码块自动解锁

  • 可中断[被动]:ReentrantLock 可中断[调用new ReentrantLock()#lockInterruptibly()获取锁,使用线程#interrupt()来打断锁,回头吧少年别等了],而 synchronized 不行

  • 锁超时[主动]:尝试获取锁,超时获取不到直接放弃,不进入阻塞队列

    • ReentrantLock 可以设置超时时间[ReentrantLock#trylock(超时时间,单位),他也是可打断的],synchronized 会一直等待
  • 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁

    • ReentrantLock 可以设置公平锁,synchronized 中的锁是非公平的
    • 不公平锁的含义是阻塞队列内公平,队列外非公平
  • 锁绑定多个条件:一个 ReentrantLock 可以同时绑定多个 Condition 对象,更细粒度的唤醒线程

补Q:可中断锁和不可中断锁有什么区别?

A:

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。synchronized 就属于是不可中断锁。

Q:原理!!!!

公平锁

  • 在获锁之前,通过 !hasQueuedPredecessors() 先看下是否有人排队
  • 如果没有排队则尝试获锁;如果有排队,则进入排队队列。
  • 公平锁是先等待的,先获得锁,后来的后获得锁。

非公平锁

  • 非公平锁的情况下,相对较晚来的线程,在尝试上锁的时候,即使之前已经有等待锁的线程存在,它也是有可能上锁成功

加锁

  • 没有竞争,当前线程会将 ExclusiveOwnerThread 指定为自己,并且将 state 设置为 1; (第一次)
  • 如果竞争出现,当前线程会调用tryAcquire在尝试获取一次锁 (第二次)
  • 如果仍然失败,会调用 addWaiter ,将当前线程封装为一个 Node 节点,放入 AQS 等待队列中;
  • 进入到等待队列之后,如果当前线程是在 head 节点后,会再次 tryAcquire 尝试获取锁 (第三次)
  • 然后会把当前线程的前一个结点的 waitStatus 设置为 -1,再次 tryAcquire 尝试获取锁 (第四次)
  • 最后当前节点会被 park

解锁

  • 当调用 unlock() 进行解锁的时候,会把 state 减一,如果 state为 0 的话,会将 exclusiveOwnerThread 属性置为 null;
  • 然后查看 AQS 队列中是否有等待的线程,如果有的话,他会把第二个结点的线程 unpark,让他去竞争锁,如果竞争成功,那么表示当前线程获得锁;AQS 队列中会把此节点当做头结点,并且清空里面的线程,如果获取锁失败,则会重新进入 park 阻塞;

可重入

  • 可重入就是获取锁的时候如果 state 不为 0 的话,他会去检查获取锁的线程是否是当前线程,如果是的话,让state加一,表示发生了锁重入
  • 解锁的时候每次让 state减一,如果 state 为 0,才表示释放锁成功

可打断

  • public void lockInterruptibly():获得可打断的锁
  • 不可打断模式:即使它被打断,仍会驻留在 AQS 阻塞队列中,一直要等到获得锁后才能得知自己被打断了
  • 可打断模式:AbstractQueuedSynchronizer#acquireInterruptibly,被打断后会直接抛出异常InterruptedException

条件变量

  • await()

    • 当某个线程获取锁之后,调用了 await()方法,此线程就会添加到ConditionObject的等待队列中;
    • 并且会调用fullyRelease() 释放同步器上的锁
    • unpark唤醒AQS 队列中的下一个等待结点去竞争锁
  • signal()

    • 当有线程调用 signal()时,会唤醒ConditionObject的等待队列中的第一个节点,将其转移到 AQS 的等待队列中,等待获取锁
    • 先将当前节点的 waitStatus 改为 0,然后加入 AQS 阻塞队列尾部,将他的上一个结点的 waitState设置为 -1

ThreadLocal

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的共享数据的副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享

ThreadLocal 有三个重要的方法:get()、set(value)、remove()

Q:原理?

A:

  • 每个 Thread 对象中有一个 ThreadLocalMap 属性
  • 在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap
  • ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置
  • ThreadLocal 执行 get()、set(value)、remove() 方法时,其实都是在操作当前线程的 ThreadLocalMap 属性,存(set)的时候是 ThreadLocal 对象为 key 存的, 取(get)的时候也是以 ThreadLocal 为 key 来取的。

Q:内存泄漏的问题?

A:

线程池

Q:使用线程池的好处?

A:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

Q:如何创建线程池?

A:

  • 方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。
  • 方式二:通过 Executor 框架的工具类 Executors 来创建。

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

A:

  • 固定线程数的线程池 newFixedThreadPool(int threads),核心线程数与最大线程数一样,没有救急线程,阻塞队列是 LinkedBlockingQueue 无界的,可能会堆积大量的任务,容易 OOM
  • 单线程化的线程池 newSingleThreadExecutor(),核心线程数和最大线程数都是1,阻塞队列是 LinkedBlockingQueue 无界的,可能会堆积大量的任务,容易 OOM
  • 可缓存线程池 newCachedThreadPool(),核心线程数为 0,最大线程数是Integer.MAX_VALUE,使用的是同步队列 SynchronousQueue,可能会创建大量临时线程,容易OOM

Q:ThreadPoolExecutor 的构造器常见参数有哪些?如何解释?

A:

  • corePoolSize 核心线程数目
  • maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
  • keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
  • unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  • workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  • threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  • handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

Q:线程池处理任务的流程了解吗?

A:

  1. 任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
  2. 如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
  3. 如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
  4. 如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的任务,如果有,则使用非核心线程执行任务
  5. 如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略

Q:线程池的饱和策略有哪些?-有一个不确定

A:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
  • ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行

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

A:比较常见的有4个,用的最多是ArrayBlockingQueueLinkedBlockingQueue

1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。

2.LinkedBlockingQueue:基于链表结构的支持有界阻塞队列,FIFO。

3.PriorityBlockingQueue :基于优先级堆实现的无界阻塞队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的

4.SynchronousQueue:没有存储空间的阻塞队列,每个插入操作必须等待一个相应的删除操作。

追 Q:ArrayBlockingQueue的LinkedBlockingQueue区别?

A:

  • 数据结构不同:ArrayBlockingQueue是一个基于数组实现的有界阻塞队列, LinkedBlockingQueue是一个基于链表实现的阻塞队列,其容量可以是有限或无限的。
  • 插入和删除的方式不同:ArrayBlockingQueue在插入和删除元素时,使用的是独占锁,即同一时间只能有一个线程进行插入或删除操作;而LinkedBlockingQueue在插入和删除元素时,使用的是两个独立的锁,即插入操作和删除操作可以同时进行。
  • 性能不同:由于数据结构和阻塞策略的不同,两者的性能也有所差异。在多线程环境下,LinkedBlockingQueue的吞吐量通常比ArrayBlockingQueue更高,但是在单线程环境下,ArrayBlockingQueue的性能可能更好。

Q:如何设定线程池的大小?

A:

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

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

CPU 密集型:科学计算、图像处理、加密解密、大数据处理等

IO 密集型:网络通信、文件读写、数据库操作、图形界面


AQS

AQS(AbstractQueuedSynchronizer)是阻塞式锁和相关的同步器工具的框架,许多同步类实现都依赖于该同步器

AQS 用状态属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁

  • 独占模式是只有一个线程能够访问资源,如 ReentrantLock
  • 共享模式允许多个线程访问资源,如 Semaphore,ReentrantReadWriteLock 是组合式

AQS 核心思想:

  • 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置锁定状态

  • 请求的共享资源被占用,AQS 用队列实现线程阻塞等待以及被唤醒时锁分配的机制,将暂时获取不到锁的线程加入到队列中

    CLH 是一种基于双向链表的高性能、公平的自旋锁,AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配

image-20231108103836836


Semaphore

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。

使用实例:

 // 初始共享资源数量
 final Semaphore semaphore = new Semaphore(5);
 ​
 // 获取1个许可
 semaphore.acquire();
 ​
 //释放1个许可
 semaphore.release();

Semaphore 有两种模式:

  • 公平模式:调用 acquire()方法的顺序就是获取许可证的顺序,遵循FIFO;
  • 非公平模式: 抢占式的。
 public Semaphore(int permits) { 
   sync = new NonfairSync(permits);
 }
 ​
 public Semaphore(int permits, boolean fair) {
   sync = fair ? new FairSync(permits) : new NonfairSync(permits);
 }

这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。


CountDownLatch

CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。

CountDownLatch一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch 使用完毕后,它不能再次被使用。

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。

当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行。

提醒📢:为了防止无限等待

  • await(时间,时间单位),到达超时时间还没有处理完成则结束任务
  • 把 countDown()放到 finally 中

CyclicBarrier

4.MySQL

存储引擎

引擎对比?

MyISAM 存储引擎:

  • 特点:不支持事务和外键,支持表级索,读取速度快,节约资源

  • 应用场景:适用于读多写少的场景,对事务的完整性要求不高,比如一些数仓、离线数据、支付宝的年度总结之类的场景,业务进行只读操作,查询起来会更快

  • 存储方式:

    • 每个 MyISAM 在磁盘上存储成 3 个文件,其文件名都和表名相同,拓展名不同
    • 表的定义保存在 .frm/.sdi 文件,表数据保存在 .MYD (MYData) 文件中,索引保存在 .MYI (MYIndex) 文件中

为什么 MyISM 适合读多写少的场景?

  • 表级锁定:MyISAM 使用表级锁定,这意味着当一个用户对表执行写操作时,其他用户无法同时对该表进行写操作。这适用于读多写少的场景,因为写操作的并发性较低,不容易出现大量的写冲突。
  • 较低的资源消耗:相对于其他存储引擎如 InnoDB,MyISAM 在内存和磁盘使用方面相对较少。这使得它在读取大量数据时具有较高的效率,并且在硬件资源有限的情况下表现良好。

InnoDB 存储引擎:(MySQL5.5 版本后默认的存储引擎)

  • 特点:支持事务、外键和行级锁操作,支持并发控制。对比 MyISAM 的存储引擎,InnoDB 写的处理效率差一些,并且会占用更多的磁盘空间以保留数据和索引

  • 应用场景:对事务的完整性有比较高的要求,在并发条件下要求数据的一致性,读写频繁的操作

  • 存储方式:

    • xxx.ibd:xxx代表的是表名,innoDB引擎的每张表都会对应这样一个表空间文件,存储该表的表结构(frm、sdi[8.0之后表结构存储在 .sdi文件中])、数据和索引。

MEMORY 存储引擎:

  • 特点:每个 MEMORY 表实际对应一个磁盘文件 .sdi,该文件中只存储表的结构,表数据保存在内存中,且默认使用 HASH 索引,所以数据默认就是无序的,但是在需要快速定位记录可以提供更快的访问,服务一旦关闭,表中的数据就会丢失,存储不安全
  • 应用场景:缓存型存储引擎,通常用于更新不太频繁的小表,用以快速得到访问结果
  • 存储方式:表结构保存在 .frm(.sdi) 中

特性MyISAMInnoDBMEMORY
存储限制有(平台对文件系统大小的限制)64TB有(平台的内存限制)
事务安全不支持支持不支持
锁机制表锁表锁/行锁表锁
B+Tree 索引支持支持支持
哈希索引不支持不支持支持
全文索引支持支持不支持
集群索引不支持支持不支持
数据索引不支持支持支持
数据缓存不支持支持N/A
索引缓存支持支持N/A
数据可压缩支持不支持不支持
空间使用N/A
内存使用中等
批量插入速度
外键不支持支持不支持

只读场景 MyISAM 比 InnoDB 更快:

  • 底层存储结构有差别,MyISAM 是非聚簇索引,叶子节点保存的是数据的具体地址,不用回表查询
  • InnoDB 每次查询需要维护 MVCC 版本状态,保证并发状态下的读写冲突问题

InnoDB

逻辑存储结构-了解

image-20240122011826426

  • 表空间(ibd文件),一个mysql实例可以对应多个表空间,用于存储记录索引等数据。

  • 段,分为数据段(Leaf node segment)、索引段(Non-leaf node segment)、回滚段(Rollback segment),InnoDB是索引组织表,数据段就是B+树的叶子节点,索引段即为B+树的非叶子节点。段用来管理多个Extent(区)。

  • 区,表空间的单元结构,每个区的大小为1M。默认情况下,InnoDB存储引擎页大小为16K,即一个区中一共有64个连续的页。

  • 页,是InnoDB 存储引擎磁盘管理的最小单元,每个页的大小默认为 16KB。为了保证页的连续性,InnoDB 存储引擎每次从磁盘申请 4-5个区。

  • 行,InnoDB 存储引擎数据是按行进行存放的。

    • Trx_id:每次对某条记录进行改动时,都会把对应的事务id赋值给trx_id隐藏列。
    • Roll_pointer:每次对某条引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

事务原理

事务原理

Q:事务特性ACID?

A:

  • 原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
  • 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
  • 隔离性(Isolatipg):多个用户的并发事务访问同一个数据库时,一个用户的事务不应该被其他用户的事务干扰,多个并发事务之间要相互隔离。
  • 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。

Q:redo log?

A:重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性。 该日志文件由两部分组成:重做日志缓冲(redo log buffer)以及重做日志文件(redo log file),前者是在内存中,后者在磁盘中。当事务提交之后会把所有修改信息都存到该日志文件中,用于在刷新脏页到磁盘,发生错误时,进行数据恢复使用。


Q:undo log?

A:回滚日志,用于记录数据被修改前的信息,作用包含两个:提供回滚MVCC(多版本并发控制)。实现了事务的原子性。 undo log和redo log记录物理日志不一样,它是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。 Undo log销毁:undo log在事务执行时产生,事务提交时,并不会立即删除undo log,因为这些日志可能还用于MVCC。 Undo log存储:undo log采用段的方式进行管理和记录,存放在前面介绍的 rollback segment 回滚段中,内部包含1024个undo log segment。

单从回滚这个角度看,事务在提交成功之后,那么 undo log 日志中记录的逻辑日志就没用了,因为不需要回滚了;但是对于 MVCC 来说他还有用,不能立即删除。


Q:并发事务带来哪些问题?

A:

  • 脏读:一个事务会读到另一个事务还没有提交的数据;
  • 不可重复读:一个事务两次读取的数据可能不太一样;
  • 幻读(Phantom read):幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。

Q:MySQL的隔离级别?

A:

  • 读未提交(read uncommitted)它解决不了刚才提出的所有问题,一般项目中也不用这个;
  • 读已提交(read committed)它能解决脏读的问题的,但是解决不了不可重复读和幻读;
  • 可重复读(repeatable read)它能解决脏读不可重复读,但是解决不了幻读,这个也是mysql默认的隔离级别;
  • 串行化(serializable)它可以解决刚才提出来的所有问题,但是由于让是事务串行执行的,性能比较低。

Q:隔离级别的原理?MVCC?

A:mvcc的意思是多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突,它的底层实现主要是分为了三个部分,第一个是隐藏字段,第二个是undo log日志,第三个是readView读视图。

隐藏字段是指:在mysql中给每个表都设置了隐藏字段,有一个是trx_id(事务id),记录每一次操作的事务id,是自增的;另一个字段是roll_pointer(回滚指针),指向上一个版本的事务版本记录地址;

undo log主要的作用是记录回滚日志,存储老版本数据,在内部会形成一个版本链,在多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表;

ReadView(读视图)是 快照读「没加锁的普通 select 语句」 SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃但未提交的事务id,Read View 中 4 个核心字段:

  • creator_trx_id:创建该 Read View 的事务的事务 id
  • m_ids:创建 Read View 时,当前数据库中「活跃且未提交」的事务 id 列表
  • min_trx_id:活跃且未提交的事务中最小事务的事务id
  • max_trx_id:创建 Read View 时当前数据库中应该给下一个事务的id 值

规则:

一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:

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

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

  • 如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:

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

image-20230905152622943



Q:读已提交是如何工作的?

A:读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View

他可以解决脏读的问题,这个是靠 Read View 来保证的,因为未提交的事务,当前事务是读不到的;

他解决不了不可重复读的问题,因为他每次读的时候都会生成一个 Read View,有可能第二次读之前有其他事务修改了数据并提交了事务,这样就会出现不可重复读的问题。


Q:可重复读是如何工作的?

A:可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View

他可以解决脏读的问题,这个是靠 Read View 来保证的,因为未提交的事务,当前事务是读不到的;

他也可以解决不可重复读的问题,因为他会在事务启动时创建一个 Read View,后续复用这个 Read View,在当前事务开启之后新建的事务堆数据的影响 和 当前事务开启之前创建但是没提交的事务对数据的修改 对于当前事务来说都是不可见的。

视图?

视图概念:视图是一种虚拟存在的数据表,这个虚拟的表并不在数据库中实际存在

本质:将一条 SELECT 查询语句的结果封装到了一个虚拟表中,所以在创建视图的时候,工作重心要放在这条 SELECT 查询语句上

作用:将一些比较复杂的查询语句的结果,封装到一个虚拟表中,再有相同查询需求时,直接查询该虚拟表

优点:

  • 简单:使用视图的用户不需要关心表的结构、关联条件和筛选条件,因为虚拟表中已经是过滤好的结果集
  • 安全:视图可以做到使用视图的用户只能访问查询的结果集,因为数据库对表的权限管理并不能限制到某个行某个列
  • 数据独立,一旦视图的结构确定,可以屏蔽表结构变化对用户的影响,源表增加列对视图没有影响;源表修改列名,则可以通过修改视图来解决,不会造成对访问者的影响

数据库的三大范式?

  • 第一范式:数据表中的每一列(每个字段)都不可以再拆分
  • 第二范式:在第一范式的基础上,非主键列完全依赖于主键,而不能是依赖于主键的一部分
  • 第三范式:在满足第二范式的基础上,表中的非主键只依赖于主键,而不依赖于其他非主键

三大范式是用来设计关系型数据库的规范,目的是为了减少数据冗余,提高数据的一致性和完整性。在实际应用中,可以根据具体的业务需求来适当地放宽范式的要求,以达到更好的性能和扩展性。


一条SQL发送到MySQL服务器后,是如何执行的?

一条 SQL 在发送到 MySQL 服务器后,会经过以下步骤执行:

  1. 语法解析:MySQL服务器首先对接收到的 SQL 进行语法解析,确保语句的正确性。如果 SQL 语句存在语法错误,服务器将返回相应的错误信息。
  2. 查询优化器:MySQL服务器使用查询优化器对SQL进行优化,以确定最佳的执行计划。查询优化器会考虑查询的各种可能执行方式,并选择最有效的方式来执行查询。这包括选择合适的索引、决定连接顺序等。
  1. 访问权限检查 MySQL 服务器会检查当前用户对执行该 SQL 的权限。如果用户没有足够的权限,服务器将返回相应的权限错误。
  2. 查询执行引擎:MySQL服务器根据查询优化器生成的执行计划,调用相应的查询执行引擎执行SQL。
  3. 数据返回:执行引擎将查询结果返回给客户端。如果查询涉及到大量数据,MySQL服务器可能会使用分页等机制,将数据分批返回给客户端。

索引

Q:索引的优缺点?

A:

优点

  • 使用索引可以大大加快数据的检索速度(大大减少检索的数据量),这也是创建索引的最主要的原因。
  • 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。

缺点:

  • 创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL执行效率。
  • 索引需要使用物理文件存储,也会耗费一定空间。

Q:索引下推是什么?

A:索引下推(Index Condition Pushdown) 是 MySQL 5.6 版本中提供的一项索引优化功能,可以在非聚簇索引遍历过程中,对索引中包含的字段先做判断,过滤掉不符合条件的记录,减少回表次数。


Q:索引的分类?

A:

  • 数据结构分类:B+tree索引、Hash索引、Full-text索引。
  • 物理存储分类:聚簇索引(主键索引)、二级索引(辅助索引)。
  • 字段特性分类:主键索引、唯一索引、普通索引、前缀索引。
  • 字段个数分类:单列索引、联合索引。

Q:数据库索引数据结构的选择?

A:两个判断标准:

  • 树的高度决定着它的IO操作次数
  • 范围查找

选择范围:Hash 表、二叉树、红黑树、B树、B+树

Hash表

  • 查询单个 key,时间复杂度可以达到 O(1)
  • 但是他不适合范围查询

二叉树

  • 一个父节点只有两个子节点,当数据量很大的时候树的高度变得很高,导致查询时的磁盘IO 成本比较高
  • 并且在极端情况下他还有可能退化为单链表

红黑树

  • 红黑树虽然不会出现退化成单链表的情况,但是他在数据量很大的时候树的高度变得很高,导致查询时的磁盘IO 成本也会比较高

B树

  • B 树在解决了每个磁盘块上只能存储一个数据的问题,降低的树的高度,减少了磁盘 IO 的成本
  • 但是他在叶子结点和非叶子结点上都存储了索引数据和行记录。但是我们在查找 A 记录的时候,我们并不关心磁盘块(数据页)中B 记录的内容,我们只关心她的索引数据,比较的时候用一下就可以了!
  • 并且他对于范围查询不太友好

B+树

  • 非叶子结点存储索引数据和指针,叶子结点存储索引数据和行记录,可以让非叶子结点的数据页存储更多的索引数据
  • 并且叶子结点用双向链表链接,便于范围查询

Q:B+ 树的底层物理结构(InnoDB 是如何存储数据的?)

A:InnoDB 的数据是按「数据页」为单位来读写的,数据库的 I/O 操作的最小单位是页,InnoDB 数据页的默认大小是 16KB,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的16K内容刷新到磁盘中。

image-20230913105200111

  • 数据页中的 「File Header」 中有两个指针,分别指向上一个数据页和下一个数据页,连接起来的页相当于一个双向的链表;
  • 数据主要是存储在 「用户记录」 中,数据页中的记录按照「主键」顺序组成单向链表,为了方便查找记录,将「用户记录」中的数据进行分组,且用 「页目录」 来指向每个分组的最大记录,查找记录时用二分法来找到记录所对应的分组,然后找到他的上一个分组的最大记录,然后从头遍历本组的记录;

Q:B+ 树是如何进行查询的?

A:

  • 首先定位记录在哪个页上,在定位记录所在哪一个页时,也是通过二分法快速定位到包含该记录的页。
  • 定位到该页后,又会在该页内进行二分法快速定位记录所在的分组 (槽号),最后在分组内进行遍历查找。

Q:什么情况下索引会失效?

A:

  1. 索引在使用的时候没有遵循最左匹配法则

    • 补充个例:如果表中只有主键和联合索引的列,查询时没有走最左匹配原则,他也会走联合索引的,因为 mysql 优化器一看扫描整颗联合索引树就可以获取所有数据,也没必要回表查询了!!!explain 查看 type 为 index
  2. 模糊查询,如果 %号在前面也会导致索引失效;

    • 补充个例:如果表中只有主键和二级索引时,使用 like %name(name 为二级索引列) 也是会走二级索引的,使用 explain 查看他的 type 为 index,代表走的全索引扫描,因为 mysql 一看他只有主键和 name 二级索引列,不用回表查询也可以获取想要的数据,所以他会走全扫描二级索引树
  3. 如果在添加索引的字段上进行了运算操作或者类型转换也都会导致索引失效;

    • 因为索引保存的是索引字段的原始值,而不是经过函数计算、计算操作后的值,自然就没办法走索引了;
    • MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。
  4. 如果使用了复合索引,中间使用了范围查询(< 和 >) ,右边的条件索引也会失效;

  5. where 子句中的 or 左右两边的条件都需要是索引列,否则会全表扫描


查询

Q:如何定位慢查询?

A:通过 MySQL 提供的慢日志查询,可以在MySQL的系统配置文件中开启这个慢日志的功能。

Q:如何分析慢查询?

A:通过 explain 关键字

通过 key和key_len 检查是否命中了索引 和 索引是否失效;

通过 type 字段查看sql是否有进一步的优化空间;

  • const
  • eq_ref
  • ref
  • range
  • index
  • all

通过 extra 建议来更准确的理解 MySQL 到底是如何执行查询的;

  • Using index:表明查询使用了覆盖索引,不用回表,查询效率非常高。
  • Using index condition:表示查询优化器选择使用了索引条件下推这个特性。
  • Using where: 表明查询使用了 WHERE 子句进行条件过滤。一般在没有使用到索引的时候会出现。

优化

Q:

A:

数据库命名规范

1.数据库名称用小写字母并用下划线分割

2.禁止使用 Mysql 关键字

3.临时表必须以 tmp为前缀并以日期为后缀,备份表必须以 bak为前缀并以日期(时间戳)为后缀

数据库基本设计规范

1.没有特殊要求的话,默认使用 Innodb 存储引擎;「行级锁、事务、索引有序存储、有外键、有MVCC」

2.数据库的表的字符集统一使用 UTF-8,兼容性更好,不同的字符集进行比较前需要进行转换会造成索引失效

3.表和字段都要添加注释;

4.尽量控制单表的数据量在 500W 以内,太大会造成修改表结构、备份、恢复都有很大的问题

5.禁止在数据库中存储文件(图片)这类大的二进制数据,正确做法是存储到文件系统中(MinIO),数据库中保存其地址信息即可

6.禁止在线上做数据库压力测试

数据库字段的设计

1.优先选择符合存储需要的最小的数据类型,存储空间小,性能好;比如状态字段可以使用 TINYINT 来存储

2.禁止在表中建立预留字段,因为其命名、字段类型不好确定,日后修改表的字段类型会对表进行锁定「元数据锁」

3.经常使用的列放在一个表中,避免大量的关联操作

4.-避免使用 TEXT,BLOB 数据类型,最常见的 TEXT 类型可以存储 64k 的数据,建议把大字段分离到单独的扩展表中,大数据字段使用前缀索引

5.尽可能的把所有列都设置为 NOT NULL,NULL 没有意义,而且对于 count()和比较来说都不太方便

6.-同财务相关的金额类型必须使用 decimal 类型,计算时不会丢失精度

7.-单表不要包含太多字段,字段过多的话可以将其分成多个表,必要时添加中间表进行关联

索引设计规范

1.单表上索引的数量不要过多。索引会增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况会降低查询效率;因为 MySQL 优化器在选择优化时,会分析每一个可以用到的索引,从而选择一个查询成本低的索引,索引过多的话会增加 MySQL 优化器生成执行计划的时间。

2.索引过多考虑使用联合索引,联合索引中尽量把区分度高的字段放到前面,使用区分度高的字段进行更多的过滤,尽量走覆盖索引。

3.每个 Innodb 表必须有个主键,Innodb 是根据主键索引的顺序来组织表的,并且主键的值需要是趋势递增的且数据长度不能太长;

  • 趋势递增的主键:在添加数据的时候是追加数据,不会进行数据的移动,但是如果不是趋势递增的话会导致数据的移动,有可能还会造成页分裂,影响性能。

  • 长度不能太长:二级索引的叶子结点上存放的是主键值,主键太长的话占用内存多。

4.查询频繁、分组、排序的字段适合添加索引(多表 join 的关联字段也适合),更改频繁的字段不适合添加索引。

5.–不建议使用外键约束,但是一定要在表与表之间的关联键上建立索引,外键会影响父表和子表的写操作从而降低性能

数据库 SQL 开发规范

查询

1.explain 关键字分析慢查询语句「type、key、key_len、Extra」

2.禁止使用 select * 必须使用 select 字段列表 来进行查询

3.避免索引失效「联合查询的最左前缀法则和中间字段范围查询、模糊查询%、类型转换/函数运算、or 的前后」

插入

4.禁止使用不含字段列表的 INSERT 语句 insert student(id,name,age) values(1,1,1)

5.数据库更适合处理批量操作,合并多个相同的操作到一起,可以提高处理效率,避免程序和数据库建立多次连接,从而增加服务器负荷。

计算

6.尽量不在数据库做运算,复杂的运算移动到业务应用里完成;避免数据库负担过大和索引失效

7.拆分复杂的大 SQL 为多个小 SQL,复杂 SQL 需要占用大量 CPU 进行计算,但是在 MySQL 中一个 SQL 语句只能使用一个 CPU 进行计算,拆分复杂语句达到并行的效果,提高效率

内存

8.在明显不会有重复值时使用 UNION ALL 而不是 UNION;UNION 会把两个结果集中的所有数据放到临时表中在进行去重操作,UNION ALL 不会对结果集进行去重操作

9.禁止使用 order by rand() 进行随机排序;会把所有符合条件的语句加载到内存中,在内存中对所有的数据生成随机值并排序,结果集如果太大会消耗大量的 CPU 和 IO 及内存资源

10.避免使用 JOIN 关联太多的表,MySQL 中存在关联缓存(join_buffer_size参数设置),对于同一个 SQL 关联一个表就会分配一个关联缓存,如果关联的表太多,占用的内存就更多,有可能会导致服务器的内存溢出,进而影响服务器数据库性能的稳定性

关键字

合理使用 exist 和 in 关键字,核心是小表驱动大表,exist 是对外表做 loop 循环,每次 loop 循环在对内表进行查询。如果子查询表大的话用 exist,如果子查询表小的话用 in


Q:数据库查询1000万量级的数据比较慢,应该怎么优化?

A:

  • 使用索引;
  • 优化 sql 语句,使用 explain 关键字
  • 使用 redis 缓存:缓存热点数据;
  • 尽量不在数据库做运算,复杂的运算移动到业务应用里完成;避免数据库负担过大和索引失效
  • 复杂SQL语句可以拆分成几个小的sql语句;
  • 分库分表;——一般不说,因为我也没实践过

Q:如果数据库优化后,查询速度很快了,但页面上显示很慢,应该怎么优化呢?

A:

  • 页面资源压缩和缓存:确保对页面上的静态资源(如CSS、JavaScript、图片等)进行压缩和合并,以减少资源的加载时间。同时,利用浏览器缓存机制,设置适当的Cache-Control头,使浏览器能够缓存这些资源,从而减少后续请求的响应时间。
  • 异步加载和延迟加载:将页面上的一些非关键内容或耗时的操作使用异步加载或延迟加载的方式,在页面加载完成后再进行加载,以提高初次渲染速度。可以使用技术如Ajax或懒加载来实现。
  • CDN加速:使用内容分发网络(CDN)来加速页面的加载,将页面的静态资源部署在离用户较近的服务器上,减少网络延迟。

Q:MYSQL超大分页怎么处理?

A:


Q:mysql中 in 和 exists 的区别?

A:


Q:count(*) 和 count(1)那个性能好?

A:

5.Redis

Redission 分布式锁

Q:原理

A:

image-20231114160147807

注意⚠️:尝试获取锁成功的话 ttl 为 null; 获取锁失败会返回锁的剩余有效期;

Redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间,releaseTime默认为 30s

Redis 的特性

Q:Redis为什么这么快?

A:

  1. Redis 基于内存,内存的访问速度是磁盘的上千倍;
  2. Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环IO 多路复用(Redis 线程模式后面会详细介绍到);
  3. Redis 内置了多种优化过后的数据结构实现,性能非常高。

Q:Redis 的优缺点?

A:

优点:

  • 高性能:Redis将数据存储在内存中,读写速度非常快,可以达到每秒数十万次的操作。
  • 丰富的数据结构:除了常见的字符串、列表、哈希、集合、有序集合之外,Redis还支持更复杂的数据结构如地理位置索引、位图等,方便开发者根据需求选择最合适的数据结构;
  • 支持持久化:Redis可以将内存中的数据定期写入磁盘,保证数据的持久性,同时支持快速的数据恢复。
  • 容易部署和使用
  • 支持主从复制和分布式

缺点:

  • 数据量受限:由于Redis将数据存储在内存中,受限于服务器内存大小;
  • 无法替代传统数据库:Redis是一个键值型数据库,不具备关系型数据库的复杂查询和事务支持能力;
  • 数据一致性问题:Redis的主从复制是异步的,存在一定的数据延迟,当主节点故障时,从节点可能会丢失一部分数据。

Redis 缓存三兄弟

Q:什么是缓存穿透? 怎么解决?

A:缓存穿透是指查询一个一定不存在的数据,由于 Redis 中没有,此请求会直接打到数据库上。

解决方案:

  • 对于非法请求,直接返回错误;
  • 缓存空值;
  • 使用布隆过滤器;

Q:布隆过滤器?

A:它的底层主要是先去初始化一个比较大数组,里面存放的二进制0或1。在一开始都是0,当一个key来了之后经过3次hash计算,模于数组长度找到数据的下标然后把数组中原来的0改为1,这样的话,三个数组的位置就能标明一个key的存在。查找的过程也是一样的。不过存在一定的误判情况。


Q:布隆过滤器 和 缓存空值的区别?

A:

区别:

  • 布隆过滤器会增加计算时间,并且有误判的情况发生;
  • 缓存空值的话会占用缓存空间,有可能会导致缓存命中率的下降(因为缓存空值也占用了缓存空间,如果缓存空值过多,就会导致其他有用的数据被淘汰出缓存,从而降低了缓存命中率)

使用场景:

  • 大数据量高并发 / 可以容忍误判的存在的话推荐使用布隆过滤器
  • 数据量小 / 不能容忍误判的存在推荐使用缓存空值

Q:什么是缓存击穿? 怎么解决?

A:缓存击穿的意思是对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这时间点对这个Key有大量的并发请求过来,直接打到数据库上。

解决方案:

  • 使用互斥锁:只允许一个线程成功获取锁,并且设置成功获取锁之后需要检查一下 redis 中是否有数据,如果没有则去数据库中加载数据。

  • 设置逻辑过期:

    • 在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间;
    • 当查询的时候,从redis取出数据后判断时间是否过期
    • 如果过期则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据不是最新;

两种方案使用情况:

  • 如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题
  • 如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致。

Q:什么是缓存雪崩?怎么解决?

A:设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB 瞬时压力过重雪崩。与缓存击穿的区别:雪崩是很多key同时过期,击穿是某一个key过期,导致大量请求直击数据库。

解决方案:

  • 在原有的失效时间基础上增加一个随机值,避免 key 集体失效;
  • 使用互斥锁:只允许一个线程成功获取锁,并且设置成功获取锁之后需要检查一下 redis 中是否有数据,如果没有则去数据库中加载数据。

Redis 的数据同步

Q:redis做为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)

A:

强一致:

需要让数据库与redis高度保持一致,因为要求时效性比较高,我们当时采用的Redission的读写锁保证的强一致性。

我们采用的是redisson实现的读写锁,在读的时候添加共享锁,可以保证读读不互斥,读写互斥。当我们更新数据的时候,添加排他锁,它是读写,读读都互斥,这样就能保证在写数据的同时是不会让其他线程读数据的,避免了脏数据。这里面需要注意的是读方法和写方法上需要使用同一把锁才行。

延迟一致:

更新数据库,更新缓存,不管这两个谁先谁后,都会在高并发的时候产生脏数据的问题。

采用的阿里的canal组件实现数据同步:不需要更改业务代码,部署一个canal服务。canal服务把自己伪装成mysql的一个从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,当mysql数据更新以后,canal会读取binlog数据,然后在通过canal的客户端获取到数据,更新缓存即可。


Q:为什么不用延时双删呢?

A:延迟双删,如果是写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据,其中这个延时多久不太好确定,在延时的过程中可能会出现脏数据,并不能保证强一致性,所以没有采用它。


Redis 的数据持久化

Q:Redis 的持久化方式有什么?

A:在Redis中提供了两种数据持久化的方式:1、RDB 2、AOF,默认开启 RDB


Q:AOF? A:采用 AOF 持久化,文件体积大,恢复慢,但是丢失的数据少。

Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这里如果 redis 宕机,那么就会有丢失数据的风险。

并且 Redis 是先把操作命令写到缓冲区中,然后在刷盘到 aof 文件中去,Redis 提供了 3种写回硬盘的策略:

  • always:总是,也就是只要缓冲区中有内容就会把他写到 aof 文件中;
  • everysec:每秒,也就是每秒进行缓冲区的刷盘;
  • no:也就是将刷盘的管理权交给操作系统,有操作系统来控制什么时候将缓冲区的内容刷新到 aof 文件中;

综合考虑选择 everysec,性能好,丢失的数据也少。

AOF 日志文件越来越大时,恢复数据就会很慢。当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。最后在重写工作完成后,将新的 AOF 文件覆盖现有的 AOF 文件

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

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

重写 AOF 的过程很耗时,不能交给主进程去执行,避免阻塞主进程。

Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的,好处:

  • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;

流程:

  • 主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,此时子进程就共享了父进程的物理内存数据了

为了解决子进程在重写 AOF的过程中,主进程执行了修改数据的操作,导致数据不一致的问题,Redis 设置了一个 AOF 重写缓冲区,在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。最后当子进程重写 AOF 完成后,再把 AOF 重写缓冲区中的数据写入到 AOF 文件中,然后用新的 AOF 文件覆盖原有的 AOF 文件。

为什么使用子进程而不是线程?

  • 因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。
  • 而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

Q:RDB? A:采用 RDB 持久化,文件体积小,恢复快,但是容易丢失数据。

RDB 持久化通过创建 Redis 数据库的快照来实现数据持久化。快照是一个二进制文件,它包含了 Redis 数据库在某个时间点上的所有键值数据。

RDB 持久化可以手动触发,也可以根据配置文件中设定的条件自动触发。例如,可以配置 Redis 每隔一定时间或在指定的操作次数后自动创建快照。

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

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

执行 bgsave 过程中,由于是交给子进程来构建 RDB 文件,主线程还是可以继续工作的,如果此时主进程修改了某些数据,会发生「写时复制」的操作,会把要操作的键值对单独复制一个副本出来,然后进行修改操作,但是修改过的键值对只能交由下一次的 bgsave 快照。如果还没来得及下一次 bgsave,redis 发生了宕机,那么修改过的数据就丢失了 !!!


Q:RDB 和 AOF 合体?

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

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

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

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


Q:RDB 为什么比 AOF 恢复的快呢?

A:

RDB 采用二进制格式存储数据,相比 AOF 的文本格式更加紧凑和简单,RDB 快照是一个完整的 Redis 数据库状态的副本,Redis 只需将一个二进制文件加载到内存中即可完成数据库的恢复。

AOF 持久化是通过记录操作日志来实现的,AOF 恢复过程中,Redis 需要逐行解析并执行操作日志,这需要更多的 CPU 和内存资源,并且可能需要很长时间才能完成。


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

A:

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

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

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

Redis 的数据过期策略

Q:Redis 的数据过期策略有哪些?

A:有定时删除、惰性删除、定期删除。

定时删除策略的做法是,在设置key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。

优点:可以保证过期 key 会被尽快删除,对内存友好。

缺点:在过期key 比较多的情况下,删除过期key 可能会占用相当一部分CPU时间,对 CPU 不友好。

惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则返回 null 并 删除该 key。

优点:只有在使用到此 key 时,才会检查是否过期,所以惰性删除只会使用很少的系统资源,对 CPU 比较友好;

缺点:过期的 key 不能及时的删除,对内存不友好;

定期删除:每隔一段时间「随机」从数据库中取出一定数量的key 进行检查,并删除其中的过期的key;

优点:通过限制删除操作执行的时长和频率,来减少删除操作对CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

缺点:难以确定删除操作执行的时长和频率。

原理:

频率:在Redis 中,默认每秒进行 10 次过期检查;

数量:随机抽查的数量是 20 个,这个是写死在代码中的;

时长:保证每轮定期删除的时间上限为 25ms

  • 从过期字典中随机抽取 20个 key;
  • 检查这 20个key 是否过期,并删除已过期的key;
  • 如果本轮检查的已过期key 的数量,超过5个(20/4),也就是「已过期key 的数量」占比「随机抽取key 的数量」大于25%,则继续重复步骤1;如果已过期的key 比例小于 25%,则停止继续删除过期key,然后等待下一轮再检查。

Redis 的数据淘汰策略

Q:淘汰策略?

A:默认是 noeviction,不删除任何数据,内部不足直接报错

在设置了过期时间的数据中进行淘汰:

  • volatile-random:随机淘汰设置了过期时间的任意键值;
  • volatile-ttl:优先淘汰更早过期的键值。
  • volatile-Iru(Redis3.0之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
  • volatile-Ifu(Redis 4.0后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

在所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值;
  • allkeys-Iru: 淘汰整个键值中最久未使用的键值;
  • allkeys-Ifu(Redis 4.0后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

Q:LRU 和 LFU ?

A:

  • LRU(Least Recently Used)的意思就是最少最近使用淘汰最久未使用的」,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
  • LFU(Least Frequently Used)意思是最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高

我们在项目设置的allkeys-lru,挑选最近最少使用的数据淘汰,把一些经常访问的key留在redis中


Q:LRU 算法在 Redis 中怎么实现的?

A:Redis 实现的是一种近似LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据, 它是「随机」取5个值(此值可配置),然后淘汰最久没有使用的那个。


Q:LFU 算法在 Redis 中怎么实现的?

A:

在LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key;

在LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time)最后一次访问的时间戳,低 8bit 存储logc(Logistic Counter)访问频次


Redis 的主从模式

Q:介绍主从同步?

A:一般都是一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中。


Q:主从同步的流程?

A:主从同步分为了两个阶段,一个是全量同步,一个是增量同步

🔺全量同步

是指从节点第一次与主节点建立连接的时候使用全量同步,流程是这样的:

第一:从节点请求主节点同步数据,其中从节点会携带自己的 replication id (复制id)和 offset 偏移量。

第二:主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication idoffset 发送给从节点,让从节点与主节点的信息保持一致。

第三:在同时主节点会执行bgsave,生成rdb文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致

当然,如果在rdb生成期间、发送期间、从服务器加载 rdb 文件期间 ,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区(replication buffer),缓冲区是一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件。

🔺命令传播

主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接,而且这个连接是长连接的,目的是避免频繁的 TCP 连接和断开带来的性能开销。

后续主节点可以通过这个连接继续将写操作命令传播给从节点,然后从节点执行该命令,使得与主节点的数据库状态相同。

🔺增量同步

Redis 的哨兵模式

6.Spring

7.计网

TCP三次握手+四次挥手

Q:三次握手🤝

A:

image-20240113180732339

  • 起初客户端和服务端都处于 close 状态
  • 服务端调用listen系统命令,进入监听状态,等待客户端的连接。
  • 客户端向服务端发送连接请求报文,其中TCP标志位里SYN=1,ACK=0,初始序号seq=x,之后客户端变为SYN_SENT状态。
  • 服务端收到请求报文,向 客户端 发送连接确认报文,SYN=1,ACK=1,确认号ack=x+1,初始的序号seq=y,之后服务端变为SYN_RCVD状态。
  • 客户端 收到 服务端的连接确认报文后,还要向 服务端 发出确认,ACK=1,确认号ack=y+1,序号为seq=x+1(初始为seq=x,第二个报文段所以要+1),之后客户端为ESTABLISHED状态。
  • 服务端 收到 客户端 的确认后,变为ESTABLISHED状态,至此连接建立

追 Q:为什么需要三次握手?两次不可以吗?

A:

TCP 建立连接时,通过三次握手能防止历史连接的建立能减少双方不必要的资源开销能帮助双方同步初始化序列号。序列号能够保证数据包不重复不丢弃按序传输。 不使用「两次握手」和「四次握手」的原因:

  • 「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
  • 「四次握手」:三次握手就可以做到安全可靠了,不需要四次握手了!

第一次:服务端知道自己接受正常,客户端的发送正常

第二次:客户端知道自己发送,接受正常,服务端的发送接受正常

第三次:


追Q:SYN攻击是什么?

A:服务器端的资源分配是在二次握手时分配的,而客户端的资源是在完成三次握手时分配的,所以服务器容易受到SYN洪泛攻击。SYN攻击就是Client在短时间内伪造大量不存在的IP地址,并向Server不断地发送SYN包,Server则回复确认包,并等待Client确认63s,由于源地址不存在,因此Server需要不断重发直至超时,这些伪造的SYN包将长时间占用半连接队列,导致正常的SYN请求因为队列满而被丢弃,从而引起网络拥塞甚至系统瘫痪。SYN 攻击是一种典型的 DoS/DDoS 攻击。

解决方法:

  • 缩短超时(SYN Timeout)时间
  • 增加最大半连接数
  • 过滤网关防护
  • SYN cookies技术

追 Q:什么是半连接队列?

A:服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列

当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。


追 Q:ISN(Initial Sequence[序列] Number)是固定的吗?

A:当一端为建立连接而发送它的SYN时,它为连接选择一个初始序号。ISN随时间而变化,因此每个连接都将具有不同的ISN。

ISN 固定的缺点:

  • 安全性问题:固定的ISN使得攻击者可以很容易地猜测到下一个序列号,并发起恶意的连接。。
  • 连接复用问题:当一个TCP连接关闭后,下一个新建立的连接可能会使用相同的IP地址和端口号。如果ISN是固定的,那么在新连接中使用相同的ISN,就有可能导致已经关闭的旧连接的数据包被错误地接收并处理。

ISN 的作用:

  • 序列号能够保证数据包不重复不丢弃按序传输

追 Q:三次握手过程中可以携带数据吗?

A:三次握手过程中是不允许携带数据的,因为在三次握手过程中,客户端和服务器只是建立连接的过程,目的是确认双方的通信能力和同步初始序列号,而不是传输数据。

但是某些情况下,第三次握手的时候,是可以携带少量数据的。但是,第一次、第二次握手不可以携带数据

解释:

第一次为什么不能携带数据?

假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据。因为攻击者根本就不理服务器的接收、发送能力是否正常,然后疯狂着重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。

为什么第三次可以携带数据?

对于第三次的话,此时客户端已经处于 ESTABLISHED 状态。对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据也没啥毛病。


追 Q:第一次握手失败怎么办?

A:客户端发送 SYN 开启了三次握手,之后客户端连接的状态是 SYN_SENT,然后等待服务器回复 ACK 报文。正常情况下,服务器会在几毫秒内返回 ACK,但如果客户端迟迟没有收到 ACK 会怎么样呢?

客户端会重发 SYN,重试的次数由 tcp_syn_retries 参数控制,默认是 6 次

第 1 次重试发生在 1 秒钟后,接着会以翻倍的方式在第 2、4、8、16、32 秒共做 6 次重试,最后一次重试会等待 64 秒,如果仍然没有返回 ACK,才会终止三次握手。所以,总耗时是 1+2+4+8+16+32+64=127 秒,超过 2 分钟。


追 Q:第三次握手失败怎么办?

A:当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 去通知服务器,同时己方连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。服务器端连接成功建立的时间还要再往后,到它收到 ACK 后状态才变为 ESTABLISHED。如果服务器没有收到 ACK,就会一直重发 SYN+ACK 报文。当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。

tcp_synack_retries[重试] 的默认重试次数是5 次,与客户端重发 SYN 类似,它的重试会经历 1、2、4、8、16 秒,最后一次重试后等待 32 秒,若仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒

服务器收到 ACK 后连接建立成功,此时,内核会把连接从 SYN 半连接队列中移出,再移入 accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。

实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1。


Q:四次挥手👋🏻

A:

image-20240121153041212

  • 当主动方关闭连接时,会发送 FIN 报文,此时主动方的连接状态由 ESTABLISHED 变为 FIN_WAIT1。当被动方收到 FIN 报文后,内核自动回复 ACK 报文,连接状态由 ESTABLISHED 变为 CLOSE_WAIT,顾名思义,它在等待进程调用 close 函数关闭连接。当主动方接收到这个 ACK 报文后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,主动方的发送通道就关闭了
  • 再来看被动方的发送通道是如何关闭的。当被动方进入 CLOSE_WAIT 状态时,进程的 read 函数会返回 0,这样开发人员就会有针对性地调用 close 函数,进而触发内核发送 FIN 报文,此时被动方连接的状态变为 LAST_ACK。当主动方收到这个 FIN 报文时,内核会自动回复 ACK,同时连接的状态由 FIN_WAIT2 变为 TIME_WAIT,Linux 系统下大约 1 分钟后 TIME_WAIT 状态的连接才会彻底关闭。而被动方收到 ACK 报文后,连接就会关闭。

追 Q:挥手为什么需要四次?

A:


追 Q:四次挥手释放连接时,等待2MSL的意义?

A:MSL是Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。

  • 为了保证客户端发送的最后一个ACK报文段能够到达服务器。因为这个ACK有可能丢失,从而导致处在LAST-ACK状态的服务器收不到对FIN的确认报文。服务器会超时重传这个FIN,接着客户端再重传一次确认,重新启动时间等待计时器

    最后客户端和服务器都能正常的关闭。假设客户端不等待2MSL,而是在发送完ACK之后直接释放关闭,一但这个ACK丢失的话,服务器就无法正常的进入关闭连接状态。

  • 防止“已失效的连接请求报文段”出现在本连接中

    客户端在发送完最后一个ACK报文段后,再经过2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失,使下一个新的连接中不会出现这种旧的连接请求报文段。


TCP 中的粘包和拆包情况?

因为TCP是面向流,没有边界,而操作系统在发送TCP数据时,会通过缓冲区来进行优化,例如缓冲区为1024个字节大小。

如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题。

如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包。

可以采取以下措施:

  • 消息边界标识:在消息传输中,在每个消息的结束处添加特定的边界标识符,接收方根据该标识符将接收到的数据分割成单独的消息。
  • 使用固定长度的数据包:将每个数据包的长度固定为一个固定值,接收方按照固定长度来解析和处理数据。
  • 消息头和消息体:在接收方根据消息头的信息来解析和处理数据。

TCP 协议通过什么手段保证可靠传输?

TCP协议通过以下手段来保证可靠传输:

  1. 序列号与确认应答:TCP将每个数据段进行编号,并要求接收方发送确认应答,以确保数据的顺序与完整性。发送方使用序列号对数据进行标识,接收方在收到数据后发送确认应答,确认接收到的数据段的序列号,如果发送方没有收到确认应答,会重新发送数据段。
  2. 窗口机制:TCP使用滑动窗口机制来控制发送方的发送速率和接收方的接收速率。发送方根据接收方的窗口大小调整发送数据的数量,接收方根据自身的处理能力和缓冲区情况来告知发送方窗口的大小。这样可以避免发送方发送过多数据导致接收方无法及时处理。
  3. 超时重传: 发送方设置一个定时器,在规定的时间内如果没有收到确认应答,则认为数据丢失或损坏,需要重新发送数据。
  4. 拥塞控制:TCP使用拥塞控制算法来避免网络中的拥塞情况。通过动态调整发送速率,使用拥塞窗口、慢启动和拥塞避免等机制来控制网络中的数据流量,以避免网络拥塞。

这些机制使得TCP协议具备了可靠传输的特性,在不可靠的IP网络上实现了可靠的数据传输。它能够保证数据的完整性、顺序性和可靠性,并且适应了不同网络环境和拥塞情况的变化。


Q:滑动窗口?

A:利用滑动窗口机制可以很方便地在TCP连接上实现对发送方的流量控制

所谓流量控制(flow control)就是让发送方的发送速率不要太快,要让接收方来得及接收

  • 发送方和接收方各自维护一个窗口大小的缓冲区,用来存储已发送但尚未得到确认的数据。
  • TCP接收方利用自己的接收窗口的大小来限制发送方发送窗口的大小。
  • TCP发送方收到接收方的零窗口通知后,应启动持续计时器。持续计时器超时后,向接收方发送零窗口探测报文

Q:TCP 的拥塞控制?

A:TCP拥塞控制的主要目标是在网络拥塞时避免丢包,并通过调整数据传输速率来维持网络的稳定性。以下是TCP拥塞控制的一些关键机制:

  1. 慢启动(Slow Start):在连接刚建立时,拥塞窗口(congestion window)以指数增长的方式增加,以便快速填满网络的带宽。
  2. 拥塞避免(Congestion Avoidance):一旦网络开始出现拥塞,拥塞窗口的增长速率就会减慢,并且以线性增长的方式逐渐增加,以便更加谨慎地利用网络资源。
  3. 快重传(Fast Retransmit):如果发送方连续收到相同的确认应答(acknowledgment),则会认为有数据包丢失,并快速重传这些丢失的数据包,而不必等待超时时间。
  4. 快恢复(Fast Recovery):在发生丢包时,TCP可以通过降低拥塞窗口的大小来快速适应网络情况,然后尝试恢复到较高的传输速率。

通过这些机制,TCP拥塞控制能够实现对网络拥塞的敏感性,并在一定程度上避免了网络拥塞导致的数据丢失和传输延迟,保证了网络中数据的可靠传输。


TCP/IP四层模型?

1)物理层:解决在连接各种计算机的传输媒体上传输数据比特流的问题。

任务:物理层的主要功能是利用传输介质为数据链路层提供物理联接,负责数据流的物理传输工作,基本单位是比特流,即0和1

传输单位:比特

2)数据链路层:解决分组(数据)在一段链路或网络上传输的问题。

任务:物理层只是简单的把计算机连接起来并在上面传输比特流,仅仅靠物理层是无法保证数据传输的正确性的,对于发送端来说,数据链路层会把网络层传下来的IP 数据报封装成帧(添加一些控制信息),这样,接收端接收到这个帧的时候,就可以根据其中的控制信息来判断是否出现了差错,另外,还可以根据这些控制信息知道这个顿从哪个比特开始从哪个比特结束

传输单位:帧

3)网络层:解决分组在多个网络之间传输(路由)的问题,提供主机之间的逻辑通信。

任务:对于发送端来说,网络层会将传输层传下来的 TCP 报文段或 UDP 用户数据报封装成IP数据报进行传输;通过路由选择协议选中合适的路由,使得源主机运输层所传下来的分组能够通过网络中的路由器找到目的主机

传输单位:分组(也叫 IP 数据报、数据报)。

4)传输层:解决进程之间基于网络的通信问题,提供端到端的逻辑通信。

任务:负责为两个主机中进程之间的通信提供服务。对于发送端来说,传输层会将应用层传下来的报文封装成 TCP 报文段或者 UDP用户数据报进行传输。由于一个主机可同时运行多个进程,因此运输层有复用和分用的功能。

  • 复用,就是多个应用层进程可同时使用下面传输层的服务
  • 分用,就是传输层把收到的信息分别交付给上面应用层中相应的进程

传输单位:报文段(TCP)或用户数据报(UDP)

5)应用层:直接为应用程序提供服务的层

任务:直接为用户的应用进程提供服务

传输单位:报文。报文包含了将要发送的完整的数据信息,其长短不需一致。

补充 OSI 七层模型:

  • 应用层(Application layer):提供各种服务,如电子邮件、文件传输、远程登录等。代表协议有:HTTP(HyperText Transfer Protocol)、FTP(File Transfer Protocol)、SMTP(Simple Mail Transfer Protocol)等
  • 表示层(Presentation layer):负责数据的加密、压缩、格式转换等。代表协议有:JPEG(Joint Photographic Experts Group)、MPEG(Moving Picture Experts Group)等。
  • 会话层(Session layer):负责建立、管理和终止会话。代表协议有:RPC(Remote Procedure Call)、NFS(Network File System)等。

Q:每层使用的协议?

A:

物理层:以太网

数据链路层:ARP[ip->mac地址]、MAC

网络层:IP

运输层:TCP、UDP

应用层:HTTP、DNS[域名->ip]、


Q:网络分层的原因?

A:

  • 相互独立:各层之间相互独立,各层之间不需要关心其他层是如何实现的,只需要知道自己如何调用下层提供好的功能就可以了(可以简单理解內接口调用)。这个和我们对开发时系统进行分层是一个道理。 例如 dao层、service层、controller层
  • 提高整体灵活性:每一层都可以使用最适合的技术来实现,你只需要保证你提供的功能以及暴露的接口的规则没有改变就行了。这个和我们平时开发系统的时候要求的高内聚、低耦合的原则也是可以对应上的。
  • 大问题化小:分层可以将复杂的网络问题分解为许多比较小的、界线比较清晰简单的小问题来处理和解决。这样使得复杂的计算机网络系统变得易于设计,实现和标准化。这个和我们平时开发的时候,一般会将系统功能分解,然后将复杂的问题分解为容易理解的更小的问题是相对应的,这些较小的问题具有更好的边界(目标和接口)定义。

TCP 与 UDP 的区别(重要)?

  • 是否面向连接:UDP 在传送数据之前不需要先建立连接。而 TCP 提供面向连接的服务,在传送数据之前必须先建立连接,数据传送结束后要释放连接。
  • 是否是可靠传输:远地主机在收到 UDP 报文后,不需要给出任何确认,并且不保证数据不丢失,不保证是否顺序到达。TCP 提供可靠的传输服务,TCP 在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。
  • 是否有状态:这个和上面的“是否可靠传输"相对应。TCP 传输是有状态的,这个有状态说的是 TCP 会去记录自己发送消息的状态比如消息是否发送了、是否被接收了等等。为此,TCP 需要维持复杂的连接状态表。而 UDP 是无状态服务,简单来说就是不管发出去之后的事情了(这很渣男!)。
  • 传输效率:由于使用 TCP 进行传输的时候多了连接、确认、重传等机制,所以TCP 的传输效率要比UDP 低很多。
  • 传输形式:TCP 是面向字节流的,UDP 是面向报文的。
  • 首部开销:TCP 首部开销(20~60字节)比 UDP 首部开销(8字节)要大。
  • 是否提供广播或多播服务:TCP 只支持点对点通信,UDP 支持一对一、一对多、多对一、多对多:
TCPUDP
是否面向连接
是否可靠
是否有状态
传输效率较慢较快
传输形式字节流数据报文段
首部开销20 ~ 60 bytes8 bytes
是否提供广播或多播服务

什么时候选择 TCP,什么时候选 UDP?

  • UDP 一般用于即时通信,比如:语音、视频、直播等等。这些场景对传输数据的准确性要求不是特别高, 比如你看视频即使少个一两帧,实际给人的感觉区别也不大。
  • TCP 用于对传输准确性要求特别高的场景,比如文件传输、发送和接收邮件、远程登录等等。

键入网址到网页显示,期间发生了什么?

  1. 浏览器做的第一步工作是解析 URL

  2. 真实地址查询 —— DNS,查找域名对应的IP地址

    • 浏览器域名缓存->操作系统的域名缓存->本地hosts文件->本地DNS服务器->根DNS服务器
  3. TCP 连接,三次握手🤝

  4. 发送 HTTP 请求

  5. 服务器处理请求并返回 HTTP 报文

  6. 浏览器解析渲染页面

  7. 连接结束

Http 常见的状态码?

状态码具体含义常见的状态码
1xx提示信息,表示目前是协议处理的中间状态,还需要后续的操作;
2xx成功,报文已经收到井被正确处理;200、204、206
3xx重定向,资源位置发生变动,需要客户端重新发送请求;301、302、304
4xx客户端错误,请求报文有误,服务器无法处理;400、403、404
5xx服务器错误,服务器在处理请求时内部发生了错误。500、501、502
  • 301 Moved Permanently:资源被永久重定向了。比如你的网站的网址更换了。
  • 302 Found:资源被临时重定向了。比如你的网站的某些资源被暂时转移到另外一个网址。
  • 400 Bad Request:发送的 HTTP 请求存在问题。比如请求参数不合法、请求方法错误。
  • 403 Forbidden:直接拒绝 HTTP 请求,不处理。一般用来针对非法请求。
  • 404 Not Found:你请求的资源未在服务端找到。比如你请求某个用户的信息,服务端并没有找到指定的用户。
  • 500 Internal Server Error:服务端出问题了(通常是服务端出 Bug 了)。比如你服务端处理请求的时候突然抛出异常,但是异常并未在服务端被正确处理。
  • 502 Bad Gateway:我们的网关将请求转发到服务端,但是服务端返回的却是一个错误的响应。
  • 503 :服务器暂时无法处理请求时返回的一般错误响应,需要客户端稍后重试。

HTTP 与 HTTPS 有哪些区别?

  • 安全性和资源消耗:HTTP 是超文本传输协议,连接建立也相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。并且信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。所以说,HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源。
  • 端口:两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
  • URL 前缀:HTTP 的 URL 前缀是 http://,HTTPS 的 URL 前缀是 https://
  • HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的;
  • SEO(搜索引擎优化) :搜索引擎通常会更青睐使用 HTTPS 协议的网站,因为 HTTPS 能够提供更高的安全性和用户隐私保护。使用 HTTPS 协议的网站在搜索结果中可能会被优先显示,从而对 SEO 产生影响。

HTTP/1.0 和 HTTP/1.1 有什么区别?

  • 连接方式 :HTTP/1.0 为短连接,HTTP/1.1 支持长连接。
  • 状态响应码:HTTP/1.1中新加入了大量的状态码,光是错误响应状态码就新增了 24种。比如说, (Continue) —在请求大资源前的预热请求, 206 (Partial Content)范围请求的标识码,409 (Conflict)—请求与当前资源的规定冲突,410(Gone)——资源已被永久转移,而且没有任何已知的转发地址。
  • 缓存机制」:在 HTTP/1.0 中主要使用 Header 里的 If-Modified-Since,Expires 来做为缓存判断的标准, HTTP/1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。
  • 带宽 HTTP/1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP/1.1则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
  • Host 头(Host Header)处理:HTTP/1.1引入了 Host 头字段,允许在同一IP 地址上托管多个域名,从而支持虚拟主机的功能。而 HTTP/1.0没有 Host头字段,无法实现虚拟主机。

HTTP/1.1 和 HTTP/2.0 有什么区别?

  • 多路复用 (Multiplexing):HTTP/2.0 在同一连接上可以同时传输多个请求和响应(可以看作是 HTTP/1.1 中长链接的升级版本),互不干扰。HTTP/1.1则使用串行方式,每个请求和响应都需要独立的连接,而浏览器为了控制资源会有6-8个 TCP 连接都限制。。这使得 HTTP/2.0 在处理多个请求时更加高效,减少了网络延迟和提高了性能。
  • 二进制帧 (Binary Frames):HTTP/2.0使用二进制帧进行数据传输,而HTTP/1.1则使用文本格式的报文。二进制帧更加紧凑和高效,减少了传输的数据量和带宽消耗。
  • 头部压缩(Header Compression):HTTP/1.1 支持 Body 压缩, Header 不支持压缩。HTTP/2.0 支持对 Header 压缩,使用了专门为 Header 压缩而设计的 HPACK 算法,减少了网络开销。
  • 服务器推送 (Server Push):HTTP/2.0 支持服务器推送,可以在客户端请求一个资源时,将其他相关资源一并推送给客户端,从而减少了客户端的请求次数和延迟。而 HTTP/1.1 需要客户端自己发送请求来获取相关资源。

HTTP/2.0 和 HTTP/3.0 有什么区别?

  • 传输协议:HTTP/2.0是基于 TCP 协议实现的,HTTP/3.0 新增了 QUIC (Quick UDP Internet Connections)协议来实现可靠的传输,提供与 TLS/SSL 相当的安全性,具有较低的连接和传输延迟。你可以将 QUIC看作是UDP 的升级版本,在其基础上新增了很多功能比如加密、重传等等。HTTP/3.0之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC。
  • 连接建立:HTTP/2.0需要经过经典的 TCP 三次握手过程(由于安全的 HTTPS 连接建立还需要 TLS 握手,共需要大约3个 RTT)。由于 QUIC 协议的特性(TLS 1.3,TLS 1.3除了支持1个 RTT 的握手,还支持0个RTT 的握手)连接建立仅需O-RTT 或者1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。
  • 队头阻塞 HTTP/2.0 多请求复用一个 TCP 连接,一旦发生丢包,就会阻塞住所有的 HTTP 请求。由于 QUIC 协议的特性,HTTP/3.0 在一定程度上解决了队头阻塞(Head-of-Line blocking, 简写:HOL blocking)问题,一个连接建立多个不同的数据流,这些数据流之间独立互不影响,某个数据流发生丢包了,其数据流不受影响(本质上是多路复用+轮询)。
  • 错误恢复: HTTP/3.0具有更好的错误恢复机制,当出现丢包、延迟等网络问题时,可以更快地进行恢复和重传。而 HTTP/2.0 则需要依赖于 TCP 的错误恢复机制。
  • 安全性 HTTP/2.0 和 HTTP/3.0 在安全性上都有较高的要求,支持加密通信,但在实现上有所不同。HTTP/2.0使用 TLS 协议进行加密,而HTTP/3.0 基于 QUIC 协议,包含了内置的加密和身份验证机制, 可以提供更强的安全性。

Get 与 Post 有什么区别?

  • 语义(主要区别):GET 通常用于获取或查询资源,而 POST 通常用于创建或修改资源。
  • 幂等:GET 请求是幂等的,即多次重复执行不会改变资源的状态,而POST 请求是不幂等的,即每次执行可能会产生不同的结果或影响资源的状态。
  • 格式:GET 请求的参数通常放在 URL 中,形成查询字符串(querystring),而POST 请求的参数通常放在请求体(body)中,可以有多种编码格式,如 application/x-www-form-urlencoded、multipart/formdata、application/ison 等。GET 请求的 URL 长度受到浏览器和服务器的限制,而 POST 请求的 body 大小则没有明确的限制。不过,实际上GET 请求也可以用body 传输数据,只是并不推荐这样做,因为这样可能会导致一些兼容性或者语义上的问题。
  • 缓存:由于 GET 请求是幂等的,它可以被浏览器或其他中间节点(如代理、网关)缓存起来,以提高性能和效率。而 POST 请求则不适合被缓存,因为它可能有副作用,每次执行可能需要实时的响应。
  • 安全性:GET 请求和 POST 请求如果使用 HTTP 协议的话,那都不安全,因为 HTTP 协议本身是明文传输的,必须使用 HTTPS 协议来加密传输数据。另外,GET 请求相比 POST 请求更容易泄露敏感数据,因 GET 请求的参数诵堂放在 URL中。

STAR法则是一种常用的面试技巧,用于回答行为面试题。它是一个缩写,代表情景(Situation)、任务(Task)、行动(Action)和结果(Result)。

  • 情景(Situation):首先,你需要描述一个具体的情景或背景,让面试官了解你所面临的具体情况。
  • 任务(Task):接下来,说明你在该情景下的具体任务或挑战是什么,以便面试官了解你所面对的目标或责任。
  • 行动(Action):然后,详细描述你采取的具体行动步骤,包括你所做的决策、采取的措施和运用的技能。
  • 结果(Result):最后,说明你的行动带来了什么具体的结果或成果,并强调你的贡献和学到的教训。

点赞系统

Q:点赞系统是如何设计的?

A:首先在设计之初我们分析了一下点赞业务可能需要的一些要求。

例如,在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。

再比如,点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。

所以呢,我们在设计的时候,就将点赞功能抽离出来作为独立服务。当然这个服务中除了点赞功能以外,还有与之关联的评价功能,不过这部分我就没有参与了。在数据层面也会用业务类型对不同点赞数据做隔离。

从具体实现上来说,为了减少数据库压力,我们会利用Redis来保存点赞记录、点赞数量信息。然后利用定时任务定期的将点赞数量通过 mq 同步给业务方,持久化到数据库中。


Q:点赞系统的流程是什么?

A:采用数据库来实现点赞系统会导致多次数据库的读写操作:

image-20240313231739125

点赞操作波动较大,有可能会在短时间内访问量激增。例如有人非常频繁的点赞、取消点赞。这样就会给数据库带来非常大的压力。

采用 Redis 之后流程图:

image-20240313232410868

❗️❗️❗️有同学会担心,如果点赞数据非常庞大,达到数百亿,那么该怎办呢?

大多数企业根本达不到这样的规模,如果真的达到也没有关系。这个时候我们可以将Redis 与 数据库结合

  • 先利用Redis来记录点赞状态
  • 并且定期的将Redis中的点赞状态持久化到数据库
  • 对于历史点赞记录,比如下架的课程、或者超过2年以上的访问量较低的数据都可以从redis移除,只保留在数据库中
  • 当某个记录点赞时,优先去Redis查询并判断,如果Redis中不存在,再去查询数据库数据并缓存到Redis

Q:mq 是怎么做到低耦合的?具体用 mq 是怎么做的消息同步?

A:每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可。利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey


Q:Redis中具体使用了哪种数据结构呢?

A:我们使用了两种数据结构,setzset

首先保存点赞记录,使用了set结构,key是业务类型+业务id,值是点赞过的用户id。当用户点赞时就SADD用户id进去,当用户取消点赞时就SREM删除用户id。当判断是否点赞时使用SISMEMBER即可。当要统计点赞数量时,只需要SCARD就行,而Redis的SET结构会在头信息中保存元素数量,因此SCARD直接读取该值,时间复杂度为O(1) ,性能非常好。

存储点赞记录的 set 结构如下:

KEY(bizId)VALUE(userId)
bizId:1userId:1
userId:2
userId:3
bizId:2userId:8
userId:9

存储点赞总数的 zset 结构如下:

KEY(bizType)Member(bizId)Score(likedTimes)
likes:qa(问答业务)bizId:100110
bizId:1002100

提醒⏰:之所以采用业务类型作为 key ,是因为可以通过业务类型来获取对应业务的 RoutingKey,进而通过 mq 来异步通知该业务中具体业务的总获赞数,

 <table>
   <tr>
     <td>KEY(bizType)</td>
     <td>Member(bizId)</td>
     <td>Score(likedTimes)</td>
   </tr>
   <tr>
     <td rowspan="2">likes:qa</td>
     <td>bizId:1001</td>
     <td>10</td>
   </tr>
   <tr>
     <td>bizId:1002</td>
     <td>100</td>
   </tr>
 ​
   <tr>
     <td rowspan="2">likes:video</td>
     <td>bizId:2001</td>
     <td>5</td>
   </tr>
   <tr>
     <td>bizId:2002</td>
     <td>88</td>
   </tr>
 </table>

追Q:为什么不用用户id为key,业务id为值呢?你当前这个方案如果用户量很大,可能出现BigKey?

A:您说的这个方案也是可以的,不过呢,考虑到我们的项目数据量并不会很大,我们不会有大V,因此点赞数量通常不会超过1000,因此不会出现BigKey。并且,由于我们采用了业务id为KEY,当我们要统计点赞数量时,可以直接使用SCARD来获取元素数量,无需额外保存,这是一个很大的优势。但如果是考虑到有大V的场景,有两种选择,一种还是应该选择您说的这种方案,另一种则是对用户id做hash分片,将大V的key拆分到多个KEY中,结构为 [bizType:bizId:userId高8位]

不过这里存在一个问题,就是页面需要判断当前用户有没有对某些业务点赞。这个时候会传来多个业务id的集合,而SISMEMBER只能一次判断一个业务的点赞状态,要判断多个业务的点赞状态,就必须多次调用SISMEMBER命令,与Redis多次交互,这显然是不合适的。(此处略停顿,等待面试官追问,面试官可能会问“那你们怎么解决的”。如果没追问,自己接着说),所以呢我们就采用了Pipeline管道方式,这样就可以一次请求实现多个业务点赞状态的判断了。


追Q:ZSET干什么用的?

A:严格来说ZSET并不是用来实现点赞业务的,因为点赞只靠SET就能实现了。但是这里有一个问题,我们要定期将业务方的点赞总数通过MQ同步给业务方,并持久化到数据库。但是如果只有SET,我没办法知道哪些业务的点赞数发生了变化,需要同步到业务方。

因此,我们又添加了一个ZSET结构,用来记录点赞数变化的业务及对应的点赞总数。可以理解为一个待持久化的点赞任务队列。

每当业务被点赞,除了要缓存点赞记录,还要把业务id及点赞总数写入ZSET。这样定时任务开启时,只需要从ZSET中获取并移除数据,然后发送MQ给业务方,并持久化到数据库即可。


Q:一定要用ZSET结构,把更新过的业务扔到一个List中不行吗?

A:扔到List结构中虽然也能实现,但是存在一些问题:

首先,假设定时任务每隔2分钟执行一次,一个业务如果在2分钟内多次被点赞,那就会多次向List中添加同一个业务及对应的点赞总数,数据库也要持久化多次。这显然是多余的,因为只有最后一次才是有效的。而使用ZSET则因为member的唯一性,多次添加会覆盖旧的点赞数量,最终也只会持久化一次。

(面试官可能说:“那就改为SET结构,SET中只放业务id,业务方收到MQ通知后再次查询不就行了。”如果没问就自己往下说)

当然要解决这个问题,也可以用SET结构代替List,然后当业务被点赞时,只存业务id到SET并通知业务方。业务方接收到MQ通知后,根据id再次查询点赞总数从而避免多次更新的问题。但是这种做法会导致多次网络通信,增加系统网络负担。而ZSET则可以同时保存业务id及最新点赞数量,避免多次网络查询。

不过,并不是说ZSET方案就是完全没问题的,毕竟ZSET底层是哈希结构+跳表,对内存会有额外的占用。但是考虑到我们的定时任务每次会查询并删除ZSET数据,ZSET中的数据量始终会维持在一个较低级别,内存占用也是可以接受的。

自定义 Redission 分布式锁

面试官:能详细聊聊你的分布式锁设计吗,你是如何实现锁类型切换、锁策略切换基于限流的?

好的。

首先我的分布式锁是基于自定义注解结合AOP来实现的。在自定义注解中可以指定锁名称、锁重试等待时长、锁超时释放时长等属性。当然最重要的,在注解中也支持锁类型属性、加锁策略属性。

我们先说锁类型切换,Redisson支持的分布式锁类型是固定的,比如普通的可重入锁Lock、公平锁FairLock、读锁、写锁等。因此我设计了一个枚举,与Redisson锁的类型一一对应,然后我还写了一个简单工厂,提供一个方法,可以便捷的根据枚举来创建锁对象。这样用户就可以在自定义注解中通过设置锁类型枚举来选择想要使用的锁类型。而我的AOP切面代码就可以根据用户设置的锁类型来创建对应锁对象了。

然后再说加锁策略切换,线程获取锁时如果成功没什么好说的,但如果失败则可以选择多种策略:例如获取锁失败不重试,直接结束;获取锁失败不重试直接抛异常;获取锁失败重试一段时间,依然失败则结束;获取锁失败重试一段时间,依然失败则抛异常;获取锁失败一直重试等。每种策略的代码逻辑不同,因此我就基于策略模式,先定义了加锁策略接口,然后提供了5种不同的策略实现,然后为各种策略定义了枚举。接下来就与锁类型切换类似了,在自定义注解中允许用户选择锁策略枚举,在AOP切面中根据用户选择的策略选择不同的策略实现类,尝试加锁。

至于限流功能,这里实现的就比较简单,就是在自定义注解中加了一个autoLock的标识,默认是true,在AOP切面中会在释放锁之前对这个autoLock做判断,如果为true才会执行unlock释放锁的动作,如果为false则不会执行;所不释放就只能等待Redis自动释放,假如锁自动释放时长设置为1秒,那就类似于限流QPS为1

setnx 命令的缺点有哪些?

setnx 指令

 # 给key赋值为value
 SETNX key value
 # 删除指定key,用来释放锁
 DEL key
 # 添加锁的同时设置过期时间
 SETNX key value NX EX expireTime
 ​
 # NX 等同于SETNX lock thread1效果;
 # EX 20 等同于 EXPIRE lock 20效果
 SET lock thread1 NX EX 20

代码:

 // 创建锁对象
 RedisLock lock = new RedisLock(key, redisTemplate) ;
 // 尝试获取锁,设置超时时间为 5s,注意超时时间一般都远大于业务的执行时间
 boolean isLock = lock. tryLock(5, TimeUnit.SECONDS);
 // 判断是否成功
 if (!isLock){
   throw new BizIllegalException("请求太频繁");
 }
 try{
   // 执行业务代码
    ....
 }finally{
   // 释放锁
   lock.unlock();
 }
  1. 为了避免某个线程获取锁成功之后,redis 服务器发生宕机,导致锁无法释放的问题,采用添加锁超时时间来兜底;

  2. 由于添加了超时时间而引发了锁误删问题

    • 线程 A 在成功获取了锁并处理完业务之后,准备执行 finally 代码块中的释放锁操作时,线程 A 被阻塞,但是在阻塞期间由于锁超时而释放了锁,在线程 A 被唤醒之后他会接着执行释放锁的操作,此时就会发生锁的误删问题;
    • 为了避免上述的问题,在 finally 中先通过 if 判断是否是自己的锁,如果是的话才会进行删除,由于 判断-删除 不是原子性的,所以在极端情况下还是会发生锁误删问题,比如线程 A 在处理完业务并且进入 finally 代码块判断了当前是自己占有着锁,就在释放锁之前又被阻塞了,在线程 A 被唤醒之后由于已经判断过自己是锁的主人了,所以还是会直接删除锁。

提醒🔔:锁的超时时间一般都远大于业务的执行时间。


Redission 的好处


Redission 的原理

image-20231114160147807

Redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间

接着看视频一致性的问题!!!

Q:如果锁没有等待超时,那么他不是会一直去尝试获取锁吗?所以 ttl 的作用是什么捏?

A:


Redisson的分布式锁使用并不复杂,基本步骤包括:

1)创建锁对象

2)尝试获取锁

3)处理业务

4)释放锁

但是,除了第3步以外,其它都是非业务代码,对业务的侵入较多,由于非业务代码格式固定,每次获取锁总是在重复编码。我们可以对这部分代码进行抽取和简化。

首先我的分布式锁是基于自定义注解结合AOP来实现的。基于AOP的思想,将业务部分作为切入点,将业务前后的锁操作作为环绕增强;在自定义注解中可以指定锁名称、锁重试等待时长、锁超时释放时长等属性。当然最重要的,在注解中也支持锁类型属性、加锁策略属性。

对于锁类型的切换来说:

我们先说锁类型切换,Redisson支持的分布式锁类型是固定的,比如普通的可重入锁Lock、公平锁FairLock、读锁、写锁等。因此我设计了一个枚举,与Redisson锁的类型一一对应,然后我还写了一个简单工厂,提供一个方法,可以便捷的根据枚举来创建锁对象。这样用户就可以在自定义注解中通过设置锁类型枚举来选择想要使用的锁类型。而我的AOP切面代码就可以根据用户设置的锁类型来创建对应锁对象了。

对于锁失败策略的选择来说:

然后再说加锁策略切换,线程获取锁时如果成功没什么好说的,但如果失败则可以选择多种策略,首先可以分为 需要重试不需要重试 两大类

不重试:

  • 取锁失败不重试,直接结束;
  • 获取锁失败不重试,直接抛异常;

重试:

  • 获取锁失败重试一段时间,依然失败则结束;
  • 获取锁失败重试一段时间,依然失败则抛异常;
  • 获取锁失败一直重试等。

每种策略的代码逻辑不同,因此我就基于策略模式,先定义了加锁策略接口,然后提供了 5 种不同的策略实现,然后为各种策略定义了枚举。接下来就与锁类型切换类似了,在自定义注解中允许用户选择锁策略枚举, 在AOP场面中根据用户选择的策略选择不同的策略实现类,尝试加锁。

至于限流功能,这里实现的就比较简单,就是在自定义注解中加了一个autoLock的标识,默认是true,在AOP切面中会在释放锁之前对这个autoLock做判断,如果为true才会执行unlock释放锁的动作,如果为false则不会执行;所不释放就只能等待Redis自动释放,假如锁自动释放时长设置为1秒,那就类似于限流QPS为1

注意⚠️:

  • Spring中的AOP切面有很多,会按照Order排序,按照Order值从小到大依次执行。Spring事务AOP的order值是Integer.MAX_VALUE,优先级最低。

    我们的分布式锁一定要先于事务执行,因此,我们的切面一定要实现Ordered接口,指定order值小于Integer.MAX_VALUE即可。

  • MyLockFactory内部持有了一个Map,key是锁类型枚举,值是创建锁对象的Function。注意这里不是存锁对象,因为锁对象必须是多例的,不同业务用不同锁对象;同一个业务用相同锁对象。

  • MyLockFactory内部的Map采用了EnumMap。只有当Key是枚举类型时可以使用EnumMap,其底层不是hash表,而是简单的数组。由于枚举项数量固定,因此这个数组长度就等于枚举项个数,然后按照枚举项序号作为角标依次存入数组。这样就能根据枚举项序号作为角标快速定位到数组中的数据。

自定义注解

 ​

AOP 切面

 ​

锁类型枚举

 public enum MyLockType {
     RE_ENTRANT_LOCK, // 可重入锁
     FAIR_LOCK, // 公平锁
     READ_LOCK, // 读锁
     WRITE_LOCK, // 写锁
     ;
 }

简单工程:获取对应类型的锁

 package com.tianji.promotion.utils;
 ​
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
 import org.springframework.stereotype.Component;
 ​
 import java.util.EnumMap;
 import java.util.Map;
 import java.util.function.Function;
 ​
 import static com.tianji.promotion.utils.MyLockType.*;
 ​
 @Component
 public class MyLockFactory {
 ​
     private final Map<MyLockType, Function<String, RLock>> lockHandlers;
 ​
     public MyLockFactory(RedissonClient redissonClient) {
         this.lockHandlers = new EnumMap<>(MyLockType.class);
         this.lockHandlers.put(RE_ENTRANT_LOCK, redissonClient::getLock);
         this.lockHandlers.put(FAIR_LOCK, redissonClient::getFairLock);
         this.lockHandlers.put(READ_LOCK, name -> redissonClient.getReadWriteLock(name).readLock());
         this.lockHandlers.put(WRITE_LOCK, name -> redissonClient.getReadWriteLock(name).writeLock());
     }
 ​
     public RLock getLock(MyLockType lockType, String name){
         return lockHandlers.get(lockType).apply(name);
     }
 }

策略类

 package com.tianji.promotion.utils;
 ​
 import com.tianji.common.exceptions.BizIllegalException;
 import org.redisson.api.RLock;
 ​
 public enum MyLockStrategy {
     SKIP_FAST(){
         @Override
         public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
             return lock.tryLock(0, prop.leaseTime(), prop.unit());
         }
     },
     FAIL_FAST(){
         @Override
         public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
             boolean isLock = lock.tryLock(0, prop.leaseTime(), prop.unit());
             if (!isLock) {
                 throw new BizIllegalException("请求太频繁");
             }
             return true;
         }
     },
     KEEP_TRYING(){
         @Override
         public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
             lock.lock( prop.leaseTime(), prop.unit());
             return true;
         }
     },
     SKIP_AFTER_RETRY_TIMEOUT(){
         @Override
         public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
             return lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
         }
     },
     FAIL_AFTER_RETRY_TIMEOUT(){
         @Override
         public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
             boolean isLock = lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
             if (!isLock) {
                 throw new BizIllegalException("请求太频繁");
             }
             return true;
         }
     },
     ;
 ​
     public abstract boolean tryLock(RLock lock, MyLock prop) throws InterruptedException;
 }

结束

结束