面试总结最终版

JAVA基础

面向过程和面向对象的区别

面向过程

面向过程是以过程为中心的编程思想,是把解决问题的步骤通过函数去实现。

优点:性能比面向对象高,因为类调用时需要实例化,开销比较大。

缺点:没有面向对象容易维护、易复用、易扩展

面向对象

面向对象是将事物高度抽象化为对象,不同对象进行调用、组合去解决问题。

优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态的特点,可以设计出低耦合的系统,使系统更加灵活、易于维护

缺点:性能比面向对象低

java语言有哪些特点

  1. 简单易学
  2. 面向对象(封装、继承、多态)
  3. 平台无关性(java虚拟机实现平台无关性)

JVM、JDK、JRE的解答

JVM

java虚拟机是运行java字节码的虚拟机。JVM有针对不同系统的特定实现(windows、Linux、macOS),目的是使用相同的字节码,在不同系统中它们都会给出相同的结果。字节码和不同系统的jvm实现是java语言”一次编译、随处可以运行”的关键所在。

JRE

java运行时环境,主要由JVM+基础类库组成(IO、日期、线程、集合类等)

JDk

Java开发工具包,主要由JVM+基础类库+编译工具,编译器(javac)和工具(javadoc和jdb)。它能创建和编译程序

Java和C++的区别

  • 都是面向对象语言,都支持封装、继承和多态
  • java不提供指针来直接访问内存,程序内存更加安全
  • java的类是单继承,C++支持多重继承,虽然java的类不可以多继承,但是接口可以多继承
  • java有自动内存管理机制,不需要程序员手动释放无用内存

字符型常量和字符串常量的区别

  • 形式上:字符型常量是单引号引起的一个字符,字符串常量是双引号引起的若干个字符
  • 含义上:字符常量相当于一个整型值(ASCII值),可以参加表达式运算,字符串常量代表一个地址值(该字符串在内存中存放的位置)
  • 占内存大小,字符常量只占2个字节,字符串常量占若干个字节(至少一个字符结束标志)

构造器Constructor是否可被override

父类的私有属性和构造方法并不能被继承,所以Constructor也就不能被override(重写)。但是可以overload(重载),所以你可以看到一个类中有多个构造函数的情况

重载和重写的区别

重载

发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同、方法返回值和访问修饰符可以不同,发生在编译时

重写

发生在父子类中,方法名、参数列表必须相同、返回值范围小于等于父类,抛出异常范围小于等于父类,访问修饰符范围大于父类,如果父类方法访问修饰符为private,则子类就不能重写该方法。

封装、继承、多态

封装

封装就是把一个对象的属性私有化,同时提供一些可以被外界访问属性的方法,如果属性不想被外界访问,我们也可以不提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。

继承

是java对功能进行扩展的一种方式,通过继承创建的类被称为子类,被继承的类称为父类。

  1. 子类拥有父类非private的属性和方法
  2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展
  3. 子类可以用自己的方式实现父类的方法

多态

就是同一操作,作用于不同的对象,可以有不同的解释,并且产生不同的执行结果。

在java中有两种形式可以实现多态:继承(多个子类对同一方法的重写)和接口(实现接口并覆盖接口中同一方法)

必要条件

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

String为什么是不可变的

因为String类中使用final关键字字符数组保存字符串,private final char value[],所以String对象是不可变的。

String、StringBuffer和StringBuilder的区别

可变性

String是不可变的,StringBuffer和StringBuilder都继承字AbstractStringBuilder,没有使用final关键字修饰,所以这两种对象是可变的。

线程安全性

String中的对象是不可变的,也就可以理解为常量,线程安全。

StringBuffer对方法加了同步锁,所以是线程安全的。

StringBuilder并没有对方法进行加同步锁,所以是线程不安全的。

性能

每次对String类型进行改变时,都会生成一个新的String对象,然后将地址指向新的String对象。

StringBuffer和StringBuilder每次都会对对象本身进行操作,而不会生成新的对象并改变对象的引用。

在相同的情况下,StringBuilder比StringBuffer仅能获得10%~15%左右的性能提升,但却要冒多线程不安全的风险。

总结

  1. 操作少量的数据使用String
  2. 单线程操作字符串缓冲区下操作大量数据使用StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据使用StringBuffer

自动拆装箱

包装类

Java语言是一个面向对象的语言,但是Java中的基本数据类型却是不面向对象的,在使用中存在很多不便,在八个类名中,除Integer和Character类,其他六个类的类名和基本数据类型一致,只是类名的第一个字母大写即可。

自动拆装箱

自动装箱:就是将基本数据类型自动转换成对应的包装类

自动拆箱:就是将包装类自动转换成对应的基本数据类型。

自动拆装箱原理

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

那些地方会自动拆装箱

  1. 将基本数据类型放入集合
  2. 包装类型和基本类型的大小比较
  3. 包装类型的运算
  4. 三目运算符的使用

自动拆装箱带来的问题

  1. 包装对象的数值比较,不能简单的使用==,虽然-128到127之间的数字可以,但是这个范围之外还是需要使用equals比较
  2. 由于自动拆箱,如果包装类对象为null,那么自动拆箱是就有可能抛异常
  3. 如果一个for循环中有大量的拆装箱操作,会浪费很多资源

在一个静态方法内调用一个非静态成员为什么是非法的

由于静态方法可以不通过对象进行调用,因此在静态方法里,不能调用其他非静态变量,也不可以访问非静态变量成员。

接口和抽象类的区别

  1. 抽象类要被子类继承,接口要被子类实现
  2. 抽象类可以有构造方法,接口中不能有构造方法
  3. 抽象类中可以有普通成员变量,接口中没有普通成员变量,它的变量只能是公共的静态的常量
  4. 一个类可以实现多个接口,但是只能继承一个父类,这个父类可以是抽象类
  5. 接口只能做方法声明,抽象类中可以做方法声明,也可以做方法实现
  6. 抽象级别(从高到低):接口>抽象类>实现类
  7. 抽象类主要是用来抽象类别,接口主要是用来抽象方法功能
  8. 抽象类的关键字是abstract,接口关键字是interface

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

  1. 从语法形式上,成员变量是属于类的,而局部变量是在方法中定义的变量或者是方法的参数,成员变量可以被public,private、static等修饰符所修饰,而局部变量不能被访问控制修饰符及static所修饰,但是成员变量和局部变量都能被final所修饰
  2. 成员变量是对象的一部分,存储在堆内存,局部变量在方法中,存在于栈内存
  3. 从生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失
  4. 成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值,而局部变量则不会自动赋值。

创建一个对象用什么运算符,对象实体与对象引用有何不同

new运算符,new创建对象实例存放在堆内存中,对象引用指向对象实例,存放在栈内存中。一个对象引用可以执行0个或1个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有n个引用指向它(可以用n条绳子系住一个气球)

什么是方法返回值?返回值的作用

方法的返回值是指我们获取到某个方法体中的代码执行后产生的结果。返回值的作用:接收出结果,使得它可以用于其他操作

一个类的构造方法的作用,若一个类没有声明构造方法,该程序能正常执行吗

主要作用是完成对类对象的初始化工作。

可以执行,因为一个类即使没有声明构造方法也会有默认的不带参的构造方法。

静态方法和实例方法有何不同

  1. 使用静态方法时,可以使用类名.方法名的方式,也可以使用对象名.方法名的方式,而实例方法只有后面这种方法,也就是说调用静态方法可以无需创建对象
  2. 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法,实例方法则无此限制

==与equals

==

它的作用是判断两个对象的地址是不是相等,即判断两个对象是不是同一个对象(基本数据类型= =比较的是值,引用数据类型= =比较的是内存地址)

equals()

它的作用也是判断两个对象的是否相等,但是它一般有两种情况:

  • 情况1:类没有覆盖equals()方法。则通过equals()比较该类的两个对象时,等价于通过”==”比较这两个对象
  • 情况2:类覆盖了equals()方法,一般,我们都覆盖equals方法来判断两个对象的内容相等,若它们的内容相等,则返回true

hashCode()与equals()

hashCode()的作用是获取哈希值,也称为散列码;它实际上是返回一个int整数,这个哈希值的作用是确定该对象在哈希表中的索引位置。hashCode定义在JDK的Object类中,这就意味着java中的任何类都包含有hashCode方法。

hashCode()与equals()相关规定:

  1. 如果两个对象相等,则hashcode一定也是相同的
  2. 两个对象相等,对两个对象分别调用equals方法都返回true(a.equals(b)返回true)
  3. 两个对象有相同的hashcode值,它们也不一定是相等的
  4. 因此,equals方法被覆盖过,则hashcode方法也必须被覆盖

final关键字

  1. 作用于变量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改,如果是引用类型的变量,则在对其初始化后便不能让其指向另一个对象
  2. 作用于方法,表示该该方法不能被重写
  3. 作用于类上,表示该类不能被继承

使用原因

  • 把方法锁定,以防任何继承类修改它的含义
  • 第二个原因是为了效率

java异常处理

所有异常都有一个共同的类:Throwable类,该类有两个重要的子类:Exception(异常)和Error(错误)

Error(错误)

是程序无法处理的错误,表示运行应用程序中较严重的问题,大多数错误与代码编写者执行的操作无关,而表示代码运行时JVM出现的问题

Exception(异常)

是程序本身可以处理的异常,Exception类有一个重要的子类RuntimeException,该异常由java虚拟机抛出

Throwable类常用方法

  • public string getMessage()返回异常发生时的详细信息
  • public stirng toString() 返回异常发生时的简要描述
  • public String getLocalizedMessage() 返回异常对象的本地化信息
  • public void printStackTrace()在控制台上打印Throwable对象封装的异常信息

异常处理

  • try块:用于捕获异常。其后可以接零个或多个catch块,如果没有catch块,则必须跟一个finally块
  • catch块:用于处理try捕获到的异常
  • finally块:无论是否捕获到异常处理,finally块的语句都会被执行。
  • 当try块或catch块中遇到return语句时,finally语句块将在方法返回之前被执行
  • 以下四种情况,finally块不会被执行
    • 在finally语句块发生了异常
    • 在前面的代码中使用了System.exit()退出程序
    • 程序所在的线程死亡
    • 关闭CPU

java序列化中如果那些字段不想进行序列化,怎么办

对不想进行序列化的变量,使用transient关键字修饰。

transient关键字作用:阻止实例中那些用此关键字修饰的变量序列化,当对象被反序列化时,被transient修饰的变量值不会被持久化和恢复。transient只能修饰变量,不能修饰类和方法。

获取键盘输入常用的类两种方法

  1. 通过Scanner

    1
    2
    3
    Scanner input = new Scanner(System.in);
    String s = input.nextLine();
    input.close();
  2. 通过BufferedReader

    1
    2
    BufferedReader input = new BufferedReader(new InputStreamReader(System.in));
    String s = input.readLine();

线程和锁

https://www.processon.com/mindmap/629c72761e08531a4012cfd7

线程基础知识

并行和并发的区别

  • 在多核CPU下,并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU

  • 并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程

线程和进程的区别

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

Java中创建线程的方式

在java中一共有四种常见的创建方式,分别是:

  • 继承Thread类
  • 实现runnable接口
  • 实现Callable接口
  • 线程池创建线程

通常情况下,我们项目中都会采用线程池的方式创建线程。

runnable和callable的区别

最主要的两个线程一个是有返回值,一个是没有返回值的。

  • Runnable 接口run方法无返回值
  • Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果

还有一个就是他们异常处理也不一样

  • Runnable接口run方法只能抛出运行时异常,也无法捕获处理
  • Callable接口call方法允许抛出异常,可以获取异常信息

​ 在实际开发中,如果需要拿到执行的结果,需要使用Callalbe接口创建线程,调用FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

线程状态和状态之间的变化

​ 在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。

  • 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
  • 调用了 start 方法,就会由新建进入可运行状态
  • 如果线程内代码已经执行完毕,由可运行进入终结状态

当然这些是一个线程正常执行的情况。

  • 如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
  • 如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态进入释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态
  • 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态

wait和sleep方法的区别

​ 它们两个的相同点是都可以让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。

不同点主要有三个方面:

  1. 方法归属不同

    sleep(long) 是 Thread 的静态方法。而 wait(),是 Object 的成员方法,每个对象都有。

  2. 线程醒来时机不同

    线程执行 sleep(long) 会在等待相应毫秒后醒来,而 wait() 需要被 notify 唤醒,wait() 如果不唤醒就一直等下去

  3. 锁特性不同

    wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制

    wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃 cpu,但你们还可以用)

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

新建T1、T2、T3三个线程,如何保证顺序执行

​ 在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

​ 使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成

start方法和run方法的区别

  • start方法用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
  • run方法封装了要被线程执行的代码,可以被调用多次。

如何停止一个正在运行的线程

有三种方式可以停止线程

  1. 可以使用退出标志,使线程正常退出,也就是当run方法完成后线程终止,一般我们加一个标记
  2. 可以使用线程的stop方法强行终止,不过一般不推荐,这个方法已作废
  3. 可以使用线程的interrupt方法中断线程,内部其实也是使用中断标志来中断线程

我们项目中使用的话,建议使用第一种或第三种方式中断线程

notify()和notifyAll()有什么区别

  • notifyAll:唤醒所有wait的线程
  • notify:只随机唤醒一个wait线程

线程中并发锁

synchronized关键字的底层原理

​ synchronized 底层使用的JVM提供的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

​ synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。

具体说下Monitor

​ monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

monitor内部维护了三个变量

  • WaitSet:保存处于Waiting状态的线程

  • EntryList:保存处于Blocked状态的线程

  • Owner:持有锁的线程

​ 只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

​ 在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。如果代码块中调用了wait()方法,则会进入WaitSet中进行等待

synchronized 的锁升级

​ Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

重量级锁

​ 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

轻量级锁

​ 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性

偏向性

​ 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

轻量级锁和偏向锁一旦锁发生了竞争,都会升级为重量级锁

synchronized它在高并发量的情况下,性能不高,在项目该如何控制使用锁呢?

在高并发下,我们可以采用ReentrantLock来加锁。

ReentrantLock使用方式和底层原理

使用方式

​ ReentrantLock是一个可重入锁,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。

​ ReentrantLock是属于juc包下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。

底层原理

​ 主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

​ 构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。

CAS

​ CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。

  • CAS使用到的地方很多:AQS框架、AtomicXXX类

  • 在操作共享变量的时候使用的自旋锁,效率上更高一些

  • CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现

AQS

​ 其实就一个jdk提供的类。AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。

​ 内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态

​ 在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中

  • tail 指向队列最后一个元素

  • head 指向队列中最久的一个元素

synchronized和Lock有什么区别

  1. 语法层面
    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
  2. 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
  3. 性能层面
    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈时,Lock 的实现通常会提供更好的性能

死锁产生的条件

一个线程需要同时获取多把锁,这时就容易发生死锁

  • t1 线程获得A对象锁,接下来想获取B对象的锁
  • t2 线程获得B对象锁,接下来想获取A对象的锁

这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁

死锁诊断

我们只需要通过jdk自动的工具就能搞定

  1. 可以先通过jps来查看当前java程序运行的进程id
  2. 然后通过jstack来查看这个进程id,就能展示出来死锁的问题
  3. 可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

volatile理解

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存
  2. 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

使用注意:

  • 写变量让volatile修饰的变量的在代码最后位置
  • 读变量让volatile修饰的变量的在代码最开始位置

线程池

线程池核心参数

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数

  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目

  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放

  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等

  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等

  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

拒绝策略

当线程数过多以后,可以选择一下拒绝策略

  1. 抛异常(默认值)
  2. 由调用者执行任务
  3. 丢弃当前任务
  4. 丢弃最早排队的任务

线程池种类

  1. newCachedThreadPool创建一个可缓存线程池

    如果线程池长度超过处理需要,可灵活回 收空闲线程,若无可回收,则新建线程。

  2. newFixedThreadPool 创建一个固定线程数的线程池

    可控制线程最大并发数,超出的线程会在队列 中等待。

  3. newScheduledThreadPool 创建一个定长线程池

    支持定时及周期性任务执行

  4. newSingleThreadExecutor 创建一个单线程化的线程池

    它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行

如何确定核心线程池

​ 我们公司当时有一些规范,为了减少线程上下文的切换,要根据当时部署的服务器的CPU核数来决定。

​ 我们规则是:CPU核数+1就是最终的核心线程数。

线程池执行原理

  1. 首先判断线程池里的核心线程是否都在执行任务,如果不是则创建一个新的工作线程来执行任务
  2. 如果核心线程都在执行任务,则线程池判断工作队列是否已满
    • 如果工作队列没有满,则将新提交的任务存储在这个工作队 列里
    • 如果工作队列满了,则判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任 务。如果已经满了,则交给拒绝策略来处理这个任务。

为什么不建议使用Executors创建线程池

​ 其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了

​ 主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。

​ 所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。

线程使用场景问题

如何控制某一个方法允许并发访问线程的数量

​ 在jdk中提供了一个Semaphore[seməfɔːr]类(信号量),它提供了两个方法

  • semaphore.acquire()

    请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了

  • semaphore.release()

    代表是释放一个信号量,此时信号量的个数+1

如何保证Java程序在多线程的情况下执行安全

​ jdk中也提供了很多的类帮助我们解决多线程安全的问题,比如:

  • JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
  • synchronized、volatile、LOCK,可以解决可见性问题
  • Happens-Before 规则可以解决有序性问题

项目中哪里使用了多线程

es数据批量导入

​ 在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch(倒计时锁)来控制,就能大大提升导入的时间。

思路:

​ 百智汇同步表数据

  1. 获取当前表的总条数,固定分页条数,可以得出分页数
  2. 创建对应的倒计时锁,参数为分页数
  3. 遍历页数,每次都能得到对应limit开始和结束的下标,获取对应的数据,创建线程,在用线程池去执行
  4. 在创建线程的逻辑方法中,去插入数据和将CountDownLatch锁计数减1
  5. 在循环外调用CountDownLatch.await方法等待计数归零
数据汇总

​ 在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行

异步调用

​ 我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用

其他

ThreadLocal的理解

ThreadLocal 主要功能有两个。

  1. 可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题
  2. 是实现了线程内的资源共享

ThreadLocal的底层原理实现

  • 在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
  • 当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
  • 当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
  • 当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

ThreadLocal会导致内存溢原因

​ 是因为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。

​ 在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。

IO

IO流分为几种

字符流、字节流

  • 字节流

    操作byte类型数据,主要操作类是InputStream,OutputStream的子类;不用缓冲区,直接对文件本身操作

  • 字符流

    操作字符类型数据,主要操作类是Reader、Writer的子类;使用缓冲区缓冲字符,不关闭流就不会输出任何内容

  • 互相转换

    • OutputStreamWriter:是Writer的子类,将输出的字符流变为字节流,即 将一个字符流的输出对象变为字节流输出对象
    • InputStreamReader:是Reader的子类,将输入的字节流变为字符流,即 将一个字节流的输入对象变为字符流的输入对象

输入流、输出流

输入、输出,有一个参照物,参照物就是存储数据的介质。如果·是把对象读入到介质中,这就是输入。从介质中向外读数据,这就是输出、所以,输入流是把数据写入存储介质的,输出流是从存储介质中把数据读取出来

同步、异步

同步与异步描述的是被调用者的

如A调用B:

如果是同步,B在接到A的调用后,会立即执行要做的事。A的本次调用可以得到结果

如果是异步,B在接到A的调用后,不保证会立即执行要做的事,但是保证会去做,B在做好了之后会通知A。A的本次调用得不到结果,但是B执行完之后会通知A

阻塞、非阻塞

阻塞与非阻塞描述的是调用者的

如A调用B

如果是阻塞,A在发出调用后,要一直等待,等着B返回结果

如果是非阻塞,A在发出调用后,不需要等待,可以去做自己的事情

同步,异步和阻塞。非阻塞之间的区别

IO模型
阻塞IO模型同步阻塞
非阻塞IO模型同步非阻塞
IO多路复用模型同步阻塞
信号驱动IO模型同步非阻塞
异步IO模型(AIO)异步非阻塞
  • 同步,异步,是描述被调用方的
  • 阻塞、非阻塞,是描述调用方的
  • 同步不一定阻塞,异步也不一定非阻塞。没有必然关系

一个例子读懂BIO、NIO、AIO

  • 同步阻塞(blocking-io)简称BIO:线程发起IO请求,不管内核是否准备好IO操作,从发起请求,线程一直阻塞,直到操作完成
  • 同步非阻塞(non-blocking-IO)简称NIO:线程发起IO请求,立即返回,内核在做好IO操作的准备之后,通过调用注册的回调函数通知线程做IO操作,线程开始阻塞,直到操作完成
  • 异步非阻塞(asynchronous-non-blocking-io)简称AIO:线程发起IO请求,立即返回,内存做好IO操作的准备之后,做IO操作,直到操作完成或者是失败,通过调用注册的回调函数通知线程做IO操作完成或失败

举例:

  • 小明去吃椰子鸡,就这样在那里排队,等了一个小时,然后才开始吃(BIO)
  • 小红去吃椰子鸡,她一看要等很久,于是去逛商场,每次逛一下,就跑回来看看,是不是轮到她了,最后她既购物了,又吃上了椰子鸡(NIO)
  • 小华,去吃椰子鸡,由于他是会员,所以店长说,你去商场随便逛逛,等下有位置,我立马打电话给你,于是不需要干巴巴的等着,也不用一会跑过来看看有没有等到,最后也吃上了椰子鸡(AIO)

五种IO模型

阻塞IO模型

假设程序的进程发起IO调用,但是内核的数据还没准备好,那么应用程序进程一直在阻塞等待,一直等到内核数据准备好了,从内核拷贝到用户空间,才返回成功提示,此次IO操作,称之为阻塞IO

  • 阻塞IO比较经典的应用就是阻塞sockert,javaBIO
  • 阻塞IO的缺点:如果内核数据一直没准备好,那用户进程一直阻塞,浪费性能,可以用非阻塞IO优化
非阻塞IO模型

如果内核数据还没准备好,可以先返回错误信息给用户进程,让它不需要等待,而是通过轮询的方式再来请求,这就是非阻塞IO。

非阻塞IO流程:

  • 应用进程向操作系统内核,发起recvfrom读取数据
  • 操作系统内核数据没准备好,立即返回ewouldblock错误码
  • 应用程序进程轮询调用,继续向操作系统内核发起recvfrom读取数据、
  • 操作系统内核数据准备好了,从内核缓冲区拷贝到用户空间
  • 完成调用,返回成功提示

非阻塞IO模型,简称NIO,相对于阻塞IO,虽然大幅提升性能,但是它依然存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的cpu资源,可以考虑使用IO复用模型,去解决这个问题

IO多路复用模型

既然NIO无效的轮询会导致cpu资源消耗,我们等到内核数据准备好了,主动通知应用程序进程再去进行调用,不就好了吗

==IO复用模型核心思路:系统给我们提供一类函数(比如select、poll、epoll函数),它们可以同时监视多个fd(文件描述符)操作,任何一个返回内核数据就绪,应用进程再发起recvfrom系统调用==

  • IO多路复用之select

    应用程序通过调用select函数,可以同时监控多个fd,在select函数监控的fd中,只要有任何一个数据状态准备好了,select函数就会返回可读状态,这时应用程序再发起recvfrom请求去读取数据

    select的IO多路复用模型,只需要发起一次询问就够了,大大优化了性能。但是几个缺点

    • 监听的IO最大连接数有限,在linux一般为1024
    • select函数返回后,是通过遍历fdset,找到就绪的描述符fd

    因为存在连接数限制,所以后来提出poll,与select相比,poll解决了连接数限制问题,但是还是一样需要通过遍历文件描述符来获取已就绪的socket

  • IO多路复用之epoll

    为了解决select/poll存在的问题,多路复用模型epoll诞生,它采用事件驱动来实现

    epoll先通过epoll_ctl()来注册一个fd(文件描述符),一旦基于某个fd就绪时,内核会采用回调机制,迅速激活这个fd,当进程调用epoll_wait()时便得到通知。这里去掉了遍历文件描述符的坑爹操作,而是采用监听事件回调的机制。这就是epoll的亮点。

IO模型之信号驱动模型

信号驱动IO不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号(调用sigaction的时候建立一个SIGIO的信号),然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过SIGIO信号通知应用进程,数据准备好后的可读状态。应用用户进程收到信号之后,立即调用recvfrom,去读取数据。

IO模型之异步IO(AIO)

前面讲的BIO,NIO和信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的,因此都不算是真正的异步。AIO实现了IO全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是立即返回的不是处理结果,而是表示提交成功类似的意思。等内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程IO操作执行完毕

异步IO的优化思路很简单,只需要向内核发送一次请求,就可以完成数据状态询问和数据拷贝的所有操作,并且不用阻塞等待结果。

集合

Java常见的集合类

​ 在java中提供的集合类,主要分为两类

  1. Collection单列集合
    • List
      • ArrayList
      • LinkedList
    • Set
      • HashSet
      • TreeSet
  2. Map双列集合
    • HashMap
    • TreeMap
    • 线程安全map:ConcurrentHashMap

image-20230427162524322

image-20220418142353567

List

Java的List是非常常用的数据类型。List是有序的Collection。Java List一共三个实现类:分别是ArrayList、Vector和LinkedList

ArrayList(动态数组)

底层实现

​ 底层是通过一个动态数组去实现的,当new ArrayList,如果是调用空参构造的,默认的初始容量为0,当第一次添加数据的时候才会初始化容量为10,如果调用带参构造的话,就直接按照传递的参数,初始化容量,如果这个参数大于10就需要按照10的1.5倍扩容,以此类推直到达到能存下参数的值。

​ 当调用add方法往数组里添加数据的时候:

  1. 判断数组已使用长度+1之后是否能足够存下下一个数据
  2. 如果当前数组已使用长度+1后的值大于当前数组长度,则调用grow方法扩容(原来的1.5倍)
  3. 确保新增的数据有地方存储之后,则将新元素添加到对应的位置上
  4. 返回成功的布尔值
数组的的寻址公式

在数组的内存中查找元素的时候,是有一个寻址公式的:

1
arr[i] = baseAddress + i*dataTypeSize
  • baseAddress

    数组的首地址

  • dataTypeSize

    代表数组中元素类型的大小,目前数组中存储的是int类型,所以dataTypeSize=4字节

  • i

    指的是数组的下标

代入到寻址公式,获取下标为1的元素,首地址为10,存入的是int类型,占4个字节

1
array[1] = 10+i*4 =14

获取到14这个地址,就能获取到下标为1的这个元素了。

总结

​ 因为ArrayList底层结构是数组,数组可以通过索引根据寻址公式直接找到元素,时间复杂度为O(1),所以它的随机查询和遍历是很快的。

​ 但是当它插入和删除的时候,因为数组是一段连续的内存空间,当插入和删除的位置在中间范围的时候,需要将插入或删除之后的数据进行复制和移动,时间复杂度为O(n),如果直接插入在尾部则直接插入或删除,时间复杂度为O(1),所以ArrayList插入和删除是比较慢的。

如何实现数组和List之间的转换
  • 数组转list,可以使用jdk自动的一个工具类Arrars,里面有一个asList方法可以转换为数组
1
List<String> list = Arrays.asList(strs);
  • List 转数组,可以直接调用list中的toArray方法,需要给一个参数,指定数组的类型,需要指定数组的长度。

    1
    String[] array = list.toArray(new String[list.size()]);
  • 用Arrays.asList转List后,如果修改了数组内容,list受影响吗

    数组转List受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址。

    image-20240312100255677

  • List用toArray转数组后,如果修了List内容,数组受影响吗

    List转数组不受影响,当调用了toArray以后,在底层时它是进行了数组的拷贝,更原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响。

    image-20240312101711200

Vector(数组实现、线程同步)

Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此访问它比访问ArrayList慢。

LinkedList(双向链表)

==LikedList是用双向链表结构存数据的,比较数据的动态插入和删除,随机访问和遍历速度比较慢。==另外,他还提供了List接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆、栈、队列和双向队列使用。

ArrayList和LinkedList

  • 底层数据结构

    • ArrayList是动态数组的数据结构实现
    • LinkedList是双向链表的数据结构实现
  • 操作数据效率

    • ArrayList根据下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】,Linked不支持下标查询
    • 查找(未知索引):ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)
    • 新增和删除
      • ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
      • LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
  • 内存空间占用

    • ArrayList底层是数组,内存连续,节省内存

    • LinkedList 是双向链表需要存储数据,和两个指针,更占用内存

  • 线程安全

    • ArrayList和LinkedList都不是线程安全的

    • 如果需要保证线程安全,有两种方案:

      • 在方法内使用,局部变量则是线程安全的
      • 使用线程安全的ArrayList和LinkedList,ArrayList可以通过Collections 的 synchronizedList 方法将 ArrayList 转换成线程安全的容器后再使用。LinkedList 换成ConcurrentLinkedQueue来使用

Set

set注重独一无二的性质,该体系集合用于存储无序(存入和取出的顺序不一定相同)元素,值不能重复。对象的相等性本质是对象hashCode值(java是依据对象的内存地址计算出的此序号)判断的,如果想让两个不同的对象视为相等,就必须覆盖Object的hashCode方法和equals方法

HashSet(Hash表)

哈希表存放的是hash值。HashSet存储元素的顺序并不是按照存入的顺序(和List显然不同)而是按照哈希值来存的,所以取数据也是按照哈希值取的。元素的哈希值是通过元素hashcode方法来获取的,HashSet首先判断两个元素的哈希值,如果哈希值一样,接着会比较equals方法,如果equals结果为true,HashSet就视为同一个元素。如果equals为false就不是同一个元素。

哈希值相同equals为false的元素是怎么存储的呢,就是在相同的哈希值顺延(可以认为哈希值相同的元素放在一个哈希桶中)。也就是哈希一样的存一列。如图1表示hashCode值不相同的情况;图2表示hashCode值相同,但equals不相同的情况。

image-20220418154634193

HashSet通过hashCode值来确定元素在内存中的位置。一个hashCode位置上可以存放多个元素。

TreeSet(二叉树)

  1. TreeSet()是使用二叉树原理对新add()的对象按照指定顺序排序(升序、降序),每增加一个对象都会进行排序,将对象插入的二叉树指定的位置。
  2. Integer和String对象都可以进行默认的TreeSet排序,而自定义类的对象时不可以的,自己定义的类必须实现Comparable接口,并且覆盖相应的compareTo()函数,才可以正常使用。
  3. 在覆写compare()函数时,要返回相应的值才能使TreeSet按照一定的规则来排序
  4. 比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数、零和正整数。

LikedHashSet(HashSet+LinkedHashMap)

对于LikedHashSet而言,它继承于HashSet、又基于LikedHashMap来实现的。LinkedHashSet底层使用LinkedHashMap来保存所有元素,它继承于HashSet,其所有的方法操作又与HashSet相同,因此LikedHashSet的实现上非常简单,只提供四个构造方法,并通过传递一个标识参数,调用父类的构造器,底层构造一个LikedHashMap来实现,在相关操作上与父类HashSet的操作相同,直接调用父类HashSet方法即可。

Map

HashMap(数组+链表+红黑树)

HashMap根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。我们用下面这张图来介绍HashMap的结构。

JAVA7实现HashMap结构

image-20220418172825531

大方向上,HashMap里面是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类Entry的实例,Entry包含四个属性:key,value,hash值和用于单向链表的next。

  1. capacity:当前数组容量,始终保持2^n,可以扩容,扩容后数组大小为当前的两倍。
  2. loadFactor:负载因子,默认为0.75
  3. threshold:扩容的阈值,等于capacity*loadFactor
JAVA8实现

java8对HashMap进行了一些修改,最大的不同就是利用了红黑树,所以其由数组+链表+红黑树组成。

根据Java7 HashMap的介绍,我们知道,查找的时候,根据hash值我们可以快速定位到数字的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为O(n)。为了降低这部分开销,在Java8中,当链表中的元素超过了8个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为O(logN)

image-20220418173933245

HashMap的put方法具体流程
  1. 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
  2. 根据键值key计算hash值得到数组索引
  3. 判断table[i] == null,条件成立,直接新建节点添加
  4. 如果table[i] == null不成立
    • 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
    • 判断table[i]是否为treeNode,即table[i]是否为红黑树,如果是红黑树,则直接在树中插入键值对
    • 遍历rable[i],链表的尾部插入数据,然后判断链表长度是否大于8,如果大于8的话把链表转换为红黑树,在红黑树中执行插入操作,遍历过程中,若发现key已经存在则直接覆盖value
  5. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容
hashMap扩容机制
  1. 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
  2. 每次扩容的时候,都是扩容之前容量的2倍;
  3. 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
  4. 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
  5. 如果是红黑树,走红黑树的添加
  6. 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
HashMap的寻址算法

​ 这个哈希方法首先计算出key的hashCode值,然后通过这个hash值右移16位后的二进制进行按位异或运算得到最后的hash值。

​ 在putValue的方法中,计算数组下标的时候使用hash值与数组长度取模得到存储数据下标的位置,hashmap为了性能更好,并没有直接采用取模的方式,而是使用了数组长度-1 得到一个值,用这个值按位与运算hash值,最终得到数组的位置。

为何HashMap的数组长度一定是2的次幂?
  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模

  2. 扩容时重新计算索引效率更高:在进行扩容是会进行判断 hash值按位与运算的旧数组长度是否 == 0

    如果等于0,则把元素留在原来位置 ,否则新位置是等于旧位置的下标+旧数组长度

hashmap在1.7情况下的多线程死循环问题

​ jdk7的的数据结构是:数组+链表

​ 在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环

比如说,现在有两个线程

线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入

线程二也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。

当线程一再继续执行的时候就会出现死循环的问题。

线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。

当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题

ConcurrentHashMap

Segment段

ConcurrentHashMap和HashMap思路是差不多的,但因为它支持并发操作,所以要复杂一些。整个ConcurrentHashMap由一个个Segment组成,Segment代表“部分”或“一段”的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用来“槽”来代表一个segment。

线程安全(Segment继承ReentrantLock加锁)

简单理解就是,ConcurrentHashMap是一个Segment数组,Segment通过继承ReentrantLock来进行加锁,所以每次需要加锁的操作锁住的是一个segment,这样只要保证每个Segment是线程安全的,也就实现了全局的线程安全。

Java 7 ConcurrentHashMap结构

image-20220418181617958

并行度(默认16)

concurrencyLevel:并行级别、并发数、Segment数,怎么翻译不重要,理解它。,默认是16,也就是说ConcurrentHashMap有16个Segments,所以理论上,这个时候,最多可以同时支持16个线程并发写,只要它们的操作分别分布在不同的Segment上。这个值可以在初始化的时候设置为其他的值,但是一旦初始化之后。它是不可扩容的。在具体到每个Segment内部,其实每个Segment很想之前介绍的hashMap,不过它要保证线程安全,所以处理起来要麻烦些。

JAVA8实现(引入红黑树)

Java8对ConcurrentHashMap进行了比较大的改动,java8也引入了红黑树

image-20220418182643886

HashTable(线程安全)

Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它继承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写hashtable。并发性不如ConcurrentHashMap。因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。

TreeMap(可排序)

TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当Iterator遍历TreeMap时,得到的记录是排过序的。

如果使用排序的映射,建议使用TreeMap。

在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。

LinkedHashMap(记录插入顺序)

LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问顺序排序。

HashSet与HashMap的区别

​ HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法,依靠HashMap来存储元素值,(利用hashMap的key键进行存储)

​ 而value值默认为Object对象. 所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true.

HashTable与HashMap的区别

  1. 数据结构不一样,hashtable是数组+链表,hashmap在1.8之后改为了数组+链表+红黑树
  2. hashtable存储数据的时候都不能为null,而hashmap是可以的
  3. hash算法不同,hashtable是用本地修饰的hashcode值,而hashmap经常了二次hash
  4. 扩容方式不同,hashtable是当前容量翻倍+1,hashmap是当前容量翻倍
  5. hashtable是线程安全的,操作数据的时候加了锁synchronized,hashmap不是线程安全的,效率更高一些
  6. 在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类

SpringMVC

什么是SpringMvc

SpringMvc是spring的一个模块,基于MVC的一个框架,无需中间整合层来整合。

SpringMvc的优点

  1. 是spring框架的一部分,可以方便的利用spring所提供的的其他功能
  2. 提供了一个前端控制器DispatchServlet,使开发人员无需额外开发控制器对象
  3. 可以自动绑定用户输入,并能正确的转换数据类型
  4. 内置了常见的校验器,可以校验用户输入
  5. 支持国际化,可以根据用户区域显示多国语言
  6. 支持多种视图技术,它支持JSP、FreeMarker等视图技术
  7. 使用基于xml的配置文件,在编辑后,不需要重新编译应用程序

SpringMvc工作原理

  1. 客户端发送请求到DispatchServlet
  2. DispatchServlet查询handlerMapping找到处理请求的Controller
  3. Controller调用业务逻辑后,返回ModelAndView
  4. DispatchServlet查询ModelAndView,找到指定视图
  5. 视图将结果返回客户端

SpringMVC流程

  1. 用户发送请求到前端控制器DispatchServlet
  2. DispatchServlet收到请求调用HandlerMapping处理器映射器
  3. 处理器映射器找到具体的处理器(可以根据xml配置,注解进行查找),生成处理器对象及处理器拦截器(如果有则生成)一并返回给DispatchServlet
  4. DispatchServlet调用HandlerAdapter处理器适配器
  5. HandlerAdapter经过适配调用具体的处理器(controller,也叫后端控制器)
  6. Controller执行完成返回ModelAndView
  7. HandlerAdapter将controller执行结果返回给DispatchServlet
  8. DispatchServlet将ModelAndView传给ViewReslover视图解析器
  9. ViewReslover解析后返回具体View
  10. DispatchServlet根据View进行渲染视图(即将模型数据填充至视图中)
  11. DispatchServlet响应用户

SpringMVc的控制器是不是单例模式,如果是,有什么问题,怎么解决

是单例模式,所以在多线程访问的时候有线程安全问题,不要用同步,会影响性能的,解决方法是在控制器里面不能写成员变量。

SpringMVC控制器的注解一般用哪个,有没别的注解可以替代

一般用@Controller注解,表示是表现层,不能用别的注解替代

@RequestMapping注解用在类上面有什么作用

是一个用来处理请求地址映射的注解,可以用于类或者方法上。用于类上,表示类中的所有响应请求的方法都是以该地址作为父路径

怎样把某个请求方法映射到特定的方法上面

直接在方法上加上注解@RequestMapping,并且在这个注解里面写上要拦截的路径

如果在拦截请求中,我想拦截get方式提交的方法,怎么配置

可以在@RequestMapping中加上method=RequestMethod.GET

怎么样在方法里得到Request或者Session

直接在方法的形参中声明request,springmvc就自动把request对象传入

我想在拦截的方法里面得到从前台传入的参数,怎么得到

直接在形参里面声明这个参数就可以,但名字必须要和传过来的参数一样

如果前台有很多个参数传入,并且这些参数都是一个对象

直接在方法中声明这个对象,springmvc就自动会把属性到这个对象里面。

SpringMvc怎样设定重定向和转发的

在返回值加forward,比如forward:user.do?name=method4

再返回值加redirect,比如redirect:http://www.baidu.com

当以一个方法向ajax返回List,Object,Map等,要怎么做

在类上加上@ResponseBody注解

sprinmvc拦截器怎么写的

  • 实现接口HandlerInterceptor,重写preHandle方法(处理前)、postHandle方法(处理后)、afterCompletion方法(清理操作)
  • 继承适配器类,然后在springmvc的配置文件配置拦截器即可
1
2
3
4
5
6
7
8
9
10
<!-- 配置 SpringMvc 的拦截器 -->
<mvc:interceptors>
<!-- 配置一个拦截器的 Bean 就可以了 默认是对所有请求都拦截 -->
<bean id="myInterceptor" class="com.et.action.MyHandlerInterceptor"></bean>
<!-- 只针对部分请求拦截 -->
<mvc:interceptor>
<mvc:mapping path="/modelMap.do" />
<bean class="com.et.action.MyHandlerInterceptorAdapter" />
</mvc:interceptor>
</mvc:interceptors>

拦截器和过滤器的区别

过滤器

  1. servlet规范中的一部分,在任何javaweb工程中都可以使用
  2. 中配置/*.可以对所有要访问的资源进行拦截

拦截器

  1. 是springmvc框架自己的,只有使用了springmvc框架的工程才能使用
  2. 拦截器只会拦截访问控制器的方法,对于静态资源则不会进行拦截
  3. 拦截器是AOP思想的具体应用

如何解决POST请求中文乱码,GET的呢

解决post中文乱码

在web.xml中加入过滤器

1
2
3
4
5
6
7
8
9
10
11
12
<filter>
<filter-name>CharacterEncodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>utf-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CharacterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

解决Get请求中文参数乱码有两个方法

  • 修改tomcat配置文件添加编码URIEncoding="utf-8"与工程编码一致

    1
    <Connector URIEncoding="utf-8" connectionTimeout="20000" port="8080" protocol="HTTP/1.1" redirectPort="8443"/>
  • 方法对参数进行重新编码:ISO8859-1是tomcat默认编码,需要将tomcat编码后的内容按utf-8编码

    1
    String userName = new String(request.getParamter("userName").getBytes("ISO8859-1"),"utf-8")

Springmvc异常如何处理

定义一个异常处理器,就是自己写一个类,实现SpringMVC的一个异常处理器HandlerExceptionResolver(接口),并在springmvc配置中配置

1
<bean class="com.exception.MyExceptionHandler" id="exceptionHandler"></bean>

Spring

什么是Spring框架

Spring就是一个轻量级的控制反转(IOC)和面向切面编程(AOP)的框架,主要是针对javaBean的生命周期进行管理的轻量级容器,最大的优点就是解耦,减少程序的耦合性。

什么是控制反转,什么是依赖注入

IOC:把对象的创建、初始化、销毁交给spring来管理,而不是由开发者控制,实现控制反转。

DI:依赖注入是控制反转的基础,所谓依赖注入就是上层控制底层,底层类作为参数传入上层类

SpringAOP的理解

理解

aop面向切面编程概念

  1. 可以在程序运行期间,不修改源码,对已有方法进行增强。

  2. 可以把与业务无关,却为业务模块共同调用的逻辑封装起来,减少系统的重复代码,降低模块之间的耦合度

  3. 常用于权限认证、事务、日志的功能

    ​ 主要思路是这样的,使用aop中的环绕通知+切点表达式,这个表达式就是要找到要记录日志的方法,然后通过环绕通知的参数获取请求方法的参数,比如类信息、方法信息、注解、请求方式等,获取到这些参数以后,保存到数据库

核心概念

  1. 切面:类是对物体特征的抽象,切面就是对横切关注点的抽象
  2. 横切关注点:对哪些方法进行拦截,拦截后怎么处理,这些关注点称为横切关注点
  3. 连接点:被拦截到的点,在spring中指的是被拦截到的方法,实际上连接点还可以是字段或者构造器
  4. 切入点:对连接点进行拦截的定义
  5. 通知:指拦截到连接点之后要执行的代码,分为前置、后置、异常、最终、环绕通知五类
  6. 目标对象:代理的目标对象
  7. 织入:将切面应用到目标对象并导致代理对象创建的过程
  8. 引入:在不修改代码的前提下,引入可以在运行期为类动态地添加一些方法或字段

AOP代理的两种方式

  1. JDK动态代理

    主要涉及到两个类:Proxy和invocationHandeler。invocationHandler是一个接口,通过实现该接口定义横切逻辑,并通过反射机制调用目标类的代码,动态将横切逻辑与业务逻辑编制在一起。Proxy利用InvocationHandler动态创建一个符合某一接口的实例,生成目标类的代理对象。

  2. cglib

    是一个强大的高性能,高质量的代码生成类库,可以在运行期扩展 Java 类与实现 Java 接口。和 JDK 动态代理相比较:JDK 创建代理有一个限制,就是只能为接口创建代理实例,而对于没有通过接口定义业务方法的类,则可以通过 CGLib 创建动态代理

BeanFactory和ApplicationContext有什么区别

BeanFactory

BeanFactory可以理解为含有bean集合的工厂类。BeanFactory包含了各种bean的定义,以便在接收到客户端请求时将对应的bean实例化。

BeanFactory还能在实例化对象时生成协作类之间的关系。还包含了bean生命周期的控制,bean初始化方法(initialization methods)和销毁方法(destruction methods)

ApplicationContext

ApplicationContext是spring提供的高级的IOC容器,面向使用spring框架的开发者,由BeanFactory派生而来,并且提供了很多面向实际应用的功能,比如

  1. 提供了支持国际化的文本消息
  2. 统一的资源文件读取方式
  3. 在监听器中注册的bean的事件

以下是三种较常见的ApplicationContext实现方式

  1. ClassPathXMLApplicationContext:默认从类路径加载配置文件
  2. FileSystemXmlApplicationContext:默认从文件系统中加载配置文件
  3. XmlWebApplicationContext:从web应用的文件中读取上下文

Spring有几种配置方式

  1. 基于XML的配置

    在spring框架中,依赖和服务需要在专门的配置文件来实现,这些配置文件的格式通常用开头,然后一系列的bean定义和专门的应用配置选项组成。

    Spring的xml配置方式是使用被Spring命名空间的所支持的一些系列的xml标签来实现的,主要有context、beans、jdbc、tx、aop、mvc等

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <beans> 
    <!-- JSON Support -->
    <bean name="viewResolver"
    class="org.springframework.web.servlet.view.BeanNameViewResolver"/>
    <bean name="jsonTemplate"
    class="org.springframework.web.servlet.view.json.MappingJackson2JsonV
    iew"/>
    <bean id="restTemplate"
    class="org.springframework.web.client.RestTemplate"/>
    </beans>
  2. 基于注解的配置

    可以用注解的方式来替代xml方式,可以将bean描述转移到类的内部,只需要在相关类上、方法上或者字段声明明上使用注解即可,注解注入将会在容器注入xml之前被处理,所以后者会覆盖掉前者对同一个属性的处理结果。

    注解装配时默认关闭的,需要手动配置

    1
    2
    3
    <beans>
    <context:annotation-config/>
    </beans>

    有几种比较重要的注解类型

    • @Required:该注解应用于设值方法
    • @Autowired:该注解用于有值设值方法、非设值值方法,构造方法和变量
    • @Qualifier:该注解和@Autowired注解搭配使用,用于消除特定bean自动装配的歧义
  3. 基于java的配置

    Spring对java配置的支持是由@Configuration注解和@bean注解来实现的,由@Bean注解的方法将会实例化、配置和初始化一个新对象,这个对象将由spring的ioc容器来管理。@bean声明所起到的作用与类似

    被@Configuration所注解的类则表示这个类的主要目的是作为bean定义的资源,被@Configuration声明的类可以通过在同一个类的内部调用@bean方法来设置嵌入bean的依赖关系。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Configuration//这个也会被Spring容器托管,注册到容器中,因为他本来就是一个@Component
    //@Configuration代表这是一个配置类就和我们之前看的beans.xml一样
    public class AppConfig{
    @Bean
    // @Bean—注册一个bean,就相当于我们之前写的一个bean标签,这个方法的名字就相当于bean标签中的id属性,这个方法的返回值就相当于bean标签中的class属性
    public MyService myService(){
    return new MyServiceImpl()
    }
    }

    对于上面的@Beans配置文件相同的xml配置如下

    1
    2
    3
    <beans>
    <bean id="myService" class="com.xxx.xxx.MyserviceImpl"></bean>
    </beans>

    上述配置方式的实例化方法如下:利用AnnotationConfigApplication类进行实例化

    1
    2
    3
    4
    5
    public static void main(String[] args){
    ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class);
    MyService myService = ctx.getBean(MyService.class);
    myService.doSomeThing();
    }

简述Spring Bean的生命周期

image-20240228103437356

​ 首先会通过一个非常重要的类,叫做BeanDefinition获取bean的定义信息,这里面就封装了bean的所有信息,比如,类的全路径,是否是延迟加载,是否是单例等等这些信息

  1. 在创建bean的时候,第一步是调用构造函数实例化bean
  2. bean的依赖注入,比如一些set方法注入,像平时开发用的@Autowire都是这一步完成的
  3. 处理Aware接口,如果某一个bean实现了Aware接口,就会重写方法执行
  4. bean的后置处理器BeanPostProcessor,这个是前置处理器
  5. 初始化方法,比如实现接口InitializingBean或者自定义了方法init-method标签或@PostContruct
  6. 执行了bean的后置处理器BeanPostProcessor,主要是对bean进行增强,有可能在这里产生代理对象
  7. 最后一步是调用destory方法销毁bean

Spring Bean的作用域

singleton(单列)

​ 这种bean范围是默认的,每个容器中只有一个bean实例,只有一个就避免对象的频繁创建与销毁,达到了bean对象的复用,性能高。

单例bean是线程安全的吗

​ 不是线程安全的,当多用户同时请求一个服务时,容器会给每一个请求分配一个线程,这时多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单例状态的修改(体现为该单例的成员属性),则必须考虑同步问题。

​ Spring框架并没有对单例bean进行任何多线程的封装处理,关于单例bean的线程安全和并发问题需要开发者自行去搞定

​ 比如:我们通常在项目中使用的Spring bean都是不可变的状态(比如Service类和Dao类),所以在某种程度上说Spring的单例bean是线程安全的

​ 如果你的bean有多种状态的话(比如View Model对象,可以理解为实体类),就需要自行保证线程安全。最浅显的解决办法就是将多态bean的范围由singleton变更为prototype。

prototype(多例)

为每一个bean请求提供一个实例

request

为每一个来自客户端的网络请求创建一个实例,在请求完成以后,bean会失效并被垃圾回收器回收

Session

确保每个session中有一个bean的实例,在session过期后,bean会随之失效

global-session

global-session和portlet应用相关,当你的应用部署在portlet容器中工作时,它包含许多portlet,如果你想要声明让所有的portlet公用全局的存储变量的话,那么这全局变量需要存储在global-session中。

举例说明如何在spring中注入一个java collection

  • :该标签用来装配可重复的list的值

  • :该标签用来装配没有重复的set值

  • map :该标签可用来注入键和值可以为任何类型的键值对

  • : 该标签支持注入键和值都是字符串类型的键值对

请解释Spring Bean的自动装配

spring可以通过向bean factory中注入的方式自动搞定bean之间的依赖关系,自动装配可以配置在每一个bean上,也可以设定在特定的bean上

  • xml配置如何根据名称将一个bean设置为自动装配

    1
    <bean id="employDao" class="com.xxx.EmployDaoImp" autowire="byName"></bean>
  • 还可以使用@Autowired注解来自动装配指定的bean,但是需要spring配置中加上以下配置

    1
    <context:annotation-config />

spring框架中共有5中自动装配

  1. no:默认的设置,在该设置下自动装配是关闭的,开发者需要自行在bean定义中用标签明确的设置依赖关系
  2. byName:可以根据bean名称设置依赖关系
  3. byType:根据bean类型设置依赖关系
  4. constructor:仅适用于有构造器相同参数的bean
  5. autodetect:该模式自动探测使用构造器自动装配或者byType自动装配

Spring都用到了那些设计模式

  • 代理模式—-Spring的Aop用到了JDK动态代理和CGLIB字节码生成技术
  • 单例模式—bean默认为单例模式
  • 模板方法—-用来解决代码重复的问题,比如restTemplate,jdbcTempalte
  • 工厂模式—–BeanFactory用来创建对象的实例
  • 观察者模式—-定义对象一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知被自动更新,如Spring中listener的实现ApplicationListener。

开发中主要使用Spring什么技术

  1. IOC容器管理各层的组件
  2. 使用AOP配置声明式事务
  3. 整合其他框架

spring中如何配置bean

  1. 通过全类名(反射)
  2. 通过工厂方法(静态工厂访问和实例工厂方法)
  3. FactoryBean

IOC容器对Bean的生命周期

  1. 通过构造器或工厂方法创建bean实例
  2. 为bean的属性设置值和对其他bean的引用
  3. 将bean实例传递给bean前置处理器的postProcessBeforeIniiialization方法
  4. 调用bean的初始化方法(init-method)
  5. 将bean实例传递给bean后置处理器的postProcessAfterInitialization方法
  6. bean可以使用了
  7. 当容器关闭时,调用bean的销毁方法(destory-method)

Spring的事务隔离级别,实现原理

spring事务的隔离级别就是数据库的隔离级别:

  1. 读未提交 read uncommitted(允许另外一个事务可以看到这个事务未提交的数据)
  2. 读提交 read committed(保证一个事务修的数据提交后才能被另一事务读取,而且能看到该事务对已有记录的更新)
  3. 可重复读 repeatable read(保证每一个是事务修改的数据提交后才能被另一事务读取,但是不能看到该事务对已有记录的更新)
  4. 串行化 serializable(一个事务在执行的过程中完全看不到其他事务对数据库所做的更新)

实现原理:

​ spring实现的事务本质就是aop完成,对方法前后进行拦截,在执行方法之前开启事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

spring支持编程式事务管理和声明式事务管理

  1. 编程式事务管理使用的TransactionTemplate

  2. 声明式事务管理其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行的情况提交或者回滚事务。

  3. 具体操作

    在某个方法上增加@Transaction注解,就可以开始事务,这个方法所有的sql都会在一个事务中执行,统一成功或失败。

  4. 流程

    在一个方法上加了@Transaction注解后,spring会基于这个类生成一个代理对象,会将这个代理对象作为bean,当在使用这个代理对象的方式时,如果这个方法上存在@Transaction注解,那么代理逻辑会先把事务的自动提交设置为false,然后再去执行原本的业务逻辑方法,如果执行业务逻辑方法没有出现异常,那么代理逻辑中就会将事务进行提交,如果执行业务逻辑方法出现异常,那么则会将事务进行回滚。

Spring中事务失效场景

  1. 如果方法上异常捕获处理,自己处理了异常,没有抛出,就会导致事务失效,所以一般处理了异常以后,别忘了跑出去就行了
  2. 如果方法抛出检查异常,也会导致事务失效,最后在spring事务的注解上,就是@Transactional上配置rollbackFor属性为Exception,这样别管是什么异常,都会回滚事务,Spring默认只回滚非检查异常(只会回滚runtimeException)
  3. 我之前还遇到过一个,如果方法上不是public修饰的,也会导致事务失效,因为spring为方法创建代理、添加事务通知、前提条件都是该方法是public的

循环依赖

​ 就是两个或两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于A

​ 循环依赖在spring中是允许存在,spring框架依据三级缓存已经解决了大部分的循环依赖

  1. 一级缓存:单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
  2. 缓存早期的bean对象(生命周期还没走完)
  3. 缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

解决流程

如果想要打破循环依赖,就需要一个中间人的参与,这个中间人就是二级缓存或三级缓存

image-20240228135337218

  1. 先实例A对象,同时会创建ObjectFactory对象存入三级缓存(singletonFactories)
  2. A在初始化的时候需要B对象,这个走B的创建的逻辑
  3. B实例化完成,也会创建ObjectFactory对象存入三级缓存(singletonFactories)
  4. B需要注入A,通过三级缓存中获取ObjectFactory来生成一个A的对象同时存入二级缓存,这个是有两种情况,一个是可能是A的普通对象,另外一个是A的代理对象,都可以让ObjectFactory来生产对应的对象,这也是三级缓存的关键
  5. B通过从通过二级缓存(earlySingletonObjects )获得到A的对象后可以正常注入,B创建成功,存入一级缓存(singletonObjects)
  6. 回到A对象初始化,因为B对象已经创建完成,则可以直接注入B,A创建成功存入一次缓存(singletonObjects)
  7. 二级缓存中的临时对象A清除

构造方法出现了循环依赖怎么解决

​ 由于bean的生命周期中构造函数是第一个执行的,spring框架并不能解决构造函数的的依赖注入,可以使用@Lazy懒加载,什么时候需要对象再进行bean对象的创建

常用注解

Spring注解

声明bean
  • @Component
  • @Service
  • @Repository
  • @Controller
依赖注入
  • @Autowired
  • @Qualifier
  • @Resourse
设置作用域
  • @Scope
Spring配置相关
  • @Configuration
  • @ComponentScan
  • @Bean
AOP相关增强
  • @Aspect
  • @Before
  • @After
  • @Around
  • @Pointcut

SpringMVC注解

  • @RequestMapping

    用于映射请求路径;

  • @RequestBody

    注解实现接收http请求的json数据,将json转换为java对象;

  • @RequestParam

    指定请求参数的名称;

  • @PathViriable

    从请求路径下中获取请求参数(/user/{id}),传递给方法的形式参数

  • @ResponseBody

    注解实现将controller方法返回对象转化为json对象响应给客户端

  • @RequestHeader

    获取指定的请求头数据

  • @PostMapping、@GetMapping

SpringBoot注解

@SpringBootApplication核心注解
  • @SpringBootConfiguration

    组合了- @Configuration注解,实现配置文件的功能;

  • @EnableAutoConfiguration

    打开自动配置的功能,也可以关闭某个自动配置的选项

  • @ComponentScan

    Spring组件扫描

SpringBoot

SpringBoot的优点

  1. 减少开发,测试时间
  2. 使用javaConfig有助于避免使用XML
  3. 避免大量Maven导入和各版本的冲突
  4. 通过提供默认值快速的开始开发

什么是JavaConfig

它提供了配置spring ioc容器的纯java方法,有助于避免使用xml配置,

优点:

  1. 面向对象的配置,由于配置被定义为javaConfig类,因此用户可以充分利用java的面向对象功能,一个配置可以继承另一个,重写它的@bean方法等
  2. 减少或消除xml配置。

如何在定义定端口上运行springboot程序

在application.properties中指定端口:server.port=8090

什么是YAML

yaml是一种人类可读的数据序列化语言,它通常用于配置文件,与属性文件相比,如果我们想在配置文件中添加复杂的属性,yaml文件就更加结构化,而且更少混淆,可以看出yaml具有分层配置的数据。

springboot实现异常处理

我们通过实现一个ControlerAdvice,来处理控制器类抛出的所有异常

什么是CSRF攻击

CSRF代表跨站请求伪造,这是一种攻击,迫使最终用户在当前通过身份验证的web应用程序执行不需要的操作,CSRF攻击专门针对状态改变请求,而不是数据窃取,因为攻击者无法查看对伪造请求的响应。

SpringBoot运行原理

主要从四个方面来说

  1. pom文件:会依赖一个父工程,在这个父工程中会关联所需要的导入模块的版本

  2. 会自动导入springboot-starter-web包,去集成web开发

  3. xxxApplication启动类有@springbootApplication注解标注它是一个springboot启动类。点进去有三个注解

    1. @Component:会扫描指定包下的bean或者组件,把他们添加到spring容器中

    2. @configuration:标注它是一个springboot配置类

    3. @EnableAutoConfiguration:实现自动化配置的核心注解。 该注解通过@Import注解导入对应的配置选择器。关键的是内部就是读取了该项目和该项目引用的Jar包的的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名。

      在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中。

      一般条件判断会有像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用。

  4. SpringApplication所做的四件事情

    1. 推断应用的类型是普通的项目还是web项目
    2. 查找并加载所有可用初始化容器,设置到initializaers属性中
    3. 找出所有的应用程序监听器,设置到listeners属性中
    4. 推断并设置main方法的定义类,找到运行的主类

Mybatis

什么是mybatis

Mybatis是一个半ORM(对象关系映射)框架,它内部封装了JDBC,开发时只需要关注SQL语句本身,不需要花费精力去处理加载驱动、创建连接、创建statement等繁杂的过程。可以让开发者直接编写原生态的sql,可以严格控制sql执行性能,灵活度高

Mybatis的执行流程

  1. 读取Mybatis配置文件:Mybatis-config.xml加载运行环境和映射文件
  2. 构造会话工厂sqlSessionFactory,一个项目只需要一个,单例的,一般由spring进行管理
  3. 会话工厂创建sqlSession对象,这里面就含了执行SQL语句的所有方法
  4. 操作数据库的接口,Executor执行器,同时负责查询缓存的维护
  5. Executor接口的执行方法中有一个MappedStatement类型的参数,封装了映射信息
  6. 输入参数映射
  7. 输出结果映射

Mybatis与Hibernate有哪些不同

  1. mybatis不完全是一个ORM框架,因为mybatis需要程序员自己编写sql语句
  2. Hibernate对象/关系映射能力强,数据库无关性好,可以节省很多代码,提高效率

#{}和${}的区别

#{}是预编译处理,${}是字符串替换.

mybatis在处理#{}时,会将sql中的#{}替换为?号,调用PreparedStatement的set方法来赋值。可以有效的防止sql注入,提高系统的安全性

mybatis在处理${}时,就是把 它替换成变量的值

Mybatis是如何进行分页的,分页插件原理是什么?

Mybatis使用RowBounds对象进行分页,它是针对ResultSet结果集执行的内存分页,而非物理分页。可以在sql内直接书写带有物理分页的参数来完成物理分页,也可以使用分页插件来完成物理分页。

分页插件的基本原理是使用Mybatis提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的sql,然后重写sql,根据dialect方言,添加对应的物理分页语句和物理分页参数。

Mybatis是如何将sql执行结果封装为目标对象并返回

  1. 使用标签,逐一定义数据库列名和对象属性名之间的映射关系
  2. 使用sql列的别名功能,将类的别名书写为对象的属性名。有了列名和属性名的映射关系后,mybatis通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。

Mybatis动态sql用什么?执行原理

Mybatis动态sql可以在XMl映射文件中,以标签的形式编写动态sql,执行原理是根据表达式的值完成逻辑判断并动态拼接sql的功能。提供了9种动态标签:trim、where、set、foreach、if、choose、when、otherwise、bind

xml映射文件中,除了select、insert、update、delete标签,还有那些标签

加上动态sql的9个标签,其中为sql片段标签,通过标签引入sql片段、为不支持自增的主键生成策略标签

mybatis的xml映射文件中,不同的xml映射文件,id是否可以重复

不同的xml映射文件,如果配置了namespace,那么id可以重复,如果没有配置namespace,那么id不能重复、

原因就是namespace+id是做为Map<String,MapperStatement>的key使用的,如果没有namespace,就剩下id,那么id重复会导致数据互相覆盖。有了namespace,自然id就可以重复,namespace不同,namespace+id自然也就不同。

Mybatis是否支持延迟加载,如果支持,它的实现原理是什么

mybatis仅支持association关联对象和collection关联集合对象的延迟加载,association指的是一对一,collection指的就是一对多查询。在mybatis配置文件中可以配置是否启用延迟加载lazyLoadingEnabled=true|false。

底层原理

​ 延迟加载在底层主要使用的CGLIB的动态代理完成的

  1. 使用CGLIB创建目标对象的代理对象,这里的目标对象就是开启了延迟加载的mapper
  2. 当调用目标方法时,进入拦截器invoke方法,发现目标方法时null值,再执行sql查询
  3. 获取数据以后,调用set方法设置属性值,在继续查询目标方法,就有值了

Mybatis缓存

一级缓存是默认开启的,又叫本地缓存,只在一次sqlsession有效,在与数据库一次会话期间查询的数据都会放在一级缓存中,之后在查询相同的数据,将会直接从缓存中来,不需要再去查询数据库,如果去查询不同的东西或者对数据的增删改、查询不同的mapper都会让一级缓存失效,也可以在查询数据后手动的去清理缓存

二级缓存又叫全局缓存,是基于namespace级别的缓存(相当于在一个xml都有效),二级缓存的工作机制是,当一个连接查询到一条数据,这个数据会被放在一级缓存,如果这个连接被关闭了,一级缓存的数据就会保存到二级缓存中,新的连接如果查询相同的数据就会直接从二级缓存中获取

缓存的读取顺序:先会读取二级缓存,如果二级缓存没有,则会去一级缓存找,如果一级缓存也没有,在连接数据库去查找

使用Mabatis的mapper接口调用时有哪些要求

  1. Mapper接口方法名和mapper.xm中定义的每个sql的id相同
  2. Mapper接口方法的输入参数类型和mapper.xml中定义每个sql的parameterType的类型相同
  3. Mapper接口方法的输出参数类型和mapper.xml中定义的每个sql的resultType的类型相同
  4. Mapper.xml文件中的namespace即是mapper接口的类路径

SpringCloud

微服务架构

微服务架构主要是将整个项目功能化,模块化。

如果人过于多的访问一个模块,一台服务器解决不了,在增加服务器(属于横向解决)。

假设A服务器占用98%的资源,而B服务器只占用10%(负载均衡解决)

微服务架构会遇到的问题

  1. 这么多服务,客户端该如何去访问(需要一个共同的东西去处理,在进行分发)
  2. 这么多服务,如何进行统一管理
  3. 这么多服务,服务之间如何进行通信
  4. 服务挂了,怎么办

springcloud是一套生态,用来解决以上分布式的四个问题

  1. spring cloud netflix(一站式解决方案)
    1. API网关:zuul组件,解决服务问题
    2. Fegin:基于HttpClient,http的通信方式,同步并阻塞
    3. 服务注册与发现:Eureka
    4. 熔断机制:Hystrix
  2. Apache Dubbo Zookeeper(第二套解决方案)
    1. API:没有,要么找第三方组件,要么自己解决
    2. Doubbo:(RPC框架)高性能的基于java实现的RPC通信框架
    3. 服务注册与发现:(交给第三方管理)Zookeeper
    4. 熔断机制:没有,借助了Hystrix
  3. Springcloud Alibaba(一站式解决方案)
    • 注册中心/配置中心 Nacos

    • 负载均衡 Ribbon

    • 服务调用 Feign

    • 服务保护 sentinel

    • 服务网关 Gateway

SpringBoot和springcloud谈谈你的理解

  • springboot可以用来开发单个应用。springcloud是用于多个个体的开发,也就是分布式开发
  • Springcloud是基于springboot的一套实现微服务的框架,springboot可以独立开发项目,但是springcloud离不开springboot,属于依赖关系
  • springboot专注于快速、方便的开发单个微服务个体,springcloud关注全局的服务治理框架

注册中心

微服务中必须要使用的组件,核心作用是:服务注册和发现。

常见的注册中心:Eureka、nocos、zookeeper

Eureka

image-20240229142056229

  • 服务注册

    服务提供者需要把自己的信息注册到eureka,由Eureka来保存这些信息,比如服务名称、ip、端口等等

  • 服务发现

    消费者向Eureka拉取服务列表信息,如果服务提供者有集群、则消费者会利用负载均衡算法,选择一个发起调用

  • 服务监控

    服务提供者会每隔30秒向eureka发送心跳,报告健康状态,如果eureka服务90秒没有接受到心跳,从Eureka中剔除。

Nacos

image-20240229143444531

  • 注册到nacos的服务默认是创建临时实例,它的服务注册、发现、监控都是和Eureka一致

  • 当在服务的提供者中设置以下属性,将设置为非临时实例

    image-20240229144032456

    • nacos就会主动询问,临时实例心跳不正常会被剔除,非临时实例则不会被剔除
    • nacos还会将服务列表变更的消息推送模式,让服务列表更新更及时

Eureka与Nacos对比

相同点
  • 都可以作为注册中心
  • 都支持服务注册和服务拉取
  • 都支持服务提供者心跳方式做健康检测
不同点
  • Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
  • 临时实例心跳不正常会被剔除、非临时实例则不会被剔除
  • Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
  • Nacos集群默认采用AP(高可用)方式,当集群中存在非临时实例时,采用CP模式(强一致性);eureka采用AP方式
  • Nacos还支持了配置中心,eureka则只有注册中心,也是选择使用Nacos的一个重要原因

Ribbon负载均衡

当服务之间发起远程调用Feign就会使用到Ribbon实现请求的负载均衡

负载均衡的流程

image-20240229155400024

  1. 服务消费者发起请求去调用服务提供者的过程会经过Ribbon负载均衡
  2. Ribbon会去注册中心拉取对应需要的服务
  3. 注册中心返回需要的服务列表
  4. ribbon根据设置的负载均衡策略请求到对应的服务

负载均衡策略

  • RoundRobinRule

    简单轮询服务列表来选择服务器

  • WeightedResponseTimeRule

    按照权重来选择服务器,响应时间越长,权重越小

  • RandomRule

    随机选择一个可用的服务器

  • ZoneAvoidanceRule

    以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为第一个机房、一个机架等。而后在对Zone内的多个服务做轮询。

  • BestAvailableRule

    忽略那些短路的服务器,并选择并发数较低的服务器

  • RetryRule

    重试机制的选择逻辑

  • AvailabilityFilteringRule

    可用性敏感策略,先过滤非健康的,再选择连接数较小的实例

自定义负载均衡策略

​ 可以自己创建类实现IRule接口,然后通过配置类或者配置文件配置即可,通过定义IRule实现可以修改负载均衡规则,有以下规则

  1. 全局生效(创建类实现IRule接口,将自己的负载均衡策略交给Spring容器管理)

    1
    2
    3
    4
    @Bean
    public IRule randomRule(){
    return new RandomRule();
    }
  2. 局部生效(在服务消费者的配置文件中,配置需要消费的服务设置对应的负载均衡规则)

    1
    2
    3
    4
    userservice:
    ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule# 负载均衡规则

服务雪崩

一个服务失败,导致整条链路的服务都失败的情形。可以通过服务的熔断降级去解决或者限流去预防

Hystix熔断、降级(解决)

服务降级

​ 服务降级是服务自我保护的一种方式,或者保护下游服务的一种方式,用与确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与Feign接口整合,编写降级逻辑

image-20240229163730968

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@FeignClient(value="leadnews-article",fallback=ArticleClientFallback.class)
public interface ArticleClient{

@PostMapping("/api/v1/article/save")
public ResponseReuslt saveArticle(@RequestBody ArticleDto dto);
}


@Compoent
public class ArticleClientFallback implements ArticleClient{
@Override
public ResponseResult saveArticle(ArticleDto dto){
return ResponseResult.errorResult(AppHttpCodeEnum.SERVER_ERROR,"获取数据失败");
}
}
服务熔断

​ 用于监控微服务调用情况,默认是关闭的,如果需要开启,需要再引导类上添加注解@EnableCircuitBreaker。

​ 如果检测到10秒内请求失败率超过50%,就触发熔断机制。

​ 之后每隔5秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求。

​ 因为发起请求是通过Hystrix的线程池来走的,不同的服务走不同的线程池,实现了不同服务调用的隔离,避免了服务雪崩的问题

image-20240229165338734

服务熔断和降级的区别
  • 触发原因不太一样,服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑
  • 管理目标的层次不太一样,熔断其实是一个框架级的处理,每个微服务都需要(无层级区分),而降级一般需要对业务有层级之分(比如降级一般都是从最外围服务开始的)
  • 实现方式不太一样,服务降级具有代码侵入性(由控制器完成/或自动降级),熔断一般称为自我熔断。

服务监控

有以下工具可以进行微服务的监控

  • Springboot-admin
  • Prometheus+Grafana
  • zipkin(链路追踪工具)
  • skywalking(链路追踪工具)

我们在项目中使用的是skywalking

skywalking

一个分布式系统的应用程序性能监控工具(ApplicationContext Performance Managment),提供了完善的链路追踪能力,Apache的顶级项目

  1. skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中那些服务和接口比较慢,我们可以针对性的分析和优化
  2. 我们还在skywalking设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发送短信和邮件,第一时间知道项目的bug情况,第一时间修复。

微服务的优缺点,说下你在项目中遇到的坑

优点:

  1. 每个微服务都很小,这样可以专一的实现指定的功能
  2. 微服务是松耦合的,微服务无论在开发阶段或者部署阶段都是独立的
  3. 微服务只用来处理业务逻辑代码,不会和其他界面组件混合

缺点:

  1. 微服务架构可能会带来更多的操作
  2. 分布式系统可能复杂难以管理

限流

限流原因:

  1. 并发的确大(突发流量)
  2. 防止用户恶意刷接口

限流方式:

  • Tomcat:可以设置最大连接数(maxThreads)

    1
    <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" maxThreads="150" redirectPort="8443"></Connector>
  • Nginx:漏桶算法

  • 网关:令牌桶算法

  • 自定义拦截器

Nginx限流

image-20240301095738092

​ nginx使用的漏桶算法来实现过滤,让请求以固定的速率处理请求,可以应对突发流量,我们控制的速率是按照ip进行限流,限制的流量是每秒20

网关限流

​ 采用的是spring cloud gateway中支持局部过滤器RequestRateLimiter来做限流,使用的是令牌桶算法,可以根据ip或路径进行限流,可以设置每秒填充平均速率,和令牌桶总容量

漏桶算法和令牌桶算法

​ 它们的区别是,漏桶和令牌桶都可以处理突发流量,其中漏桶可以做到绝对的平滑,令牌桶有可能会产生突发大量请求的情况,一般nginx限流采用的漏桶,spring cloud gateway中可以支持令牌桶算法

漏桶算法

​ 漏桶算法是把请求存入到桶中,以固定速率从桶中流出,可以让我们的服务做到绝对的平均,起到很好的限流效果

令牌桶算法

​ 令牌桶算法在桶中存储的是令牌,按照一定的速率生成令牌,每个请求都要先申请令牌,申请到令牌以后才能正常请求,也可以起到很好的限流作用

CAP理论

CAP主要是在分布式项目下的一个理论。包含了三项,一致性、可用性、分区容错性

一致性(Consistency)

​ 是指更新操作成功并返回客户端完成后,所有节点在同一时间的数据完全一致(强一致性),不能存在中间状态。

可用性(Availability)

​ 是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。

分区容错性(Partition tolerance)

​ 是指分布式系统在遇到任何网络分区故障时,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。

为什么分布式系统中无法同时保证一致性和可用性

​ 首先一个前提,对于分布式系统而言,分区容错性是一个最基本的要求,因此基本上我们在设计分布式系统的时候只能从一致性(C)和可用性(A)之间进行取舍。

​ 如果保证了一致性(C):对于节点N1和N2,当往N1里写数据时,N2上的操作必须被暂停,只有当N1同步数据到N2时才能对N2进行读写请求,在N2被暂停操作期间客户端提交的请求会收到失败或超时。显然,这与可用性是相悖的。

​ 如果保证了可用性(A):那就不能暂停N2的读写操作,但同时N1在写数据的话,这就违背了一致性的要求。

BASE理论

​ 这个也是CAP分布式系统设计理论,BASE是CAP理论中AP方案的延伸,核心思想是即使无法做到强一致性(StrongConsistency,CAP的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性(Eventual Consitency)。它的思想包含三方面:

Basically Available(基本可用)

​ 基本可用是指分布式系统在出现不可预知的故障的时候,允许损失部分可用性,但不等于系统不可用。

Soft state(软状态)

​ 即是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。

Eventually consistent(最终一致性)

​ 强调系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。其本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

Ribbon与Feign的区别

Ribbon添加maven依赖spring-starter-ribbon,使用@RibbonClient(value=”服务名称”),使用RestTemplate调用远程服务对应的方法。

Feign添加maven依赖spring-starter-feign,服务提供方提供对外接口,调用方使用在接口上使用@FeignClient(“指定服务名”)

Ribbon和Feign的区别

  • Ribbon和Feign都是用于调用其他服务的,不过方式不同
  • 启动类使用的注解不同,Ribbon用的是@RibbonClient,Feign用的是@EnableFeighClients
  • 服务的指定位置不同,Ribbon是在@RibbonClient注解上声明,Feign则是在定义抽象方法的接口中使用FeignClient声明
  • 调用方式不同,Ribbon需要自己构建http请求,模拟http请求然后使用RestTemplate发送给其他服务,步骤繁琐。Feign则是在Ribbon的基础上进行了一次改进,采用接口的方式,将需要调用的其他服务的方法定义成抽象方法即可,不需要自己构建http请求。不过要注意的是抽象方法的注解、方法签名要和提供服务的方法完全一致。

Eureka和Zookeeper的区别

  • Zookeeper保证了CP(C:一致性,P:分区容错性),但是不保证可用性,ZK的leader选举期间,是不可用的
  • Eureka保证了AP(A:高可用),它优先保证了可用性,几个节点挂掉不会影响正常节点的工作。

分布式事务解决方案

  • Seata框架(XA、AT、TCC)
  • MQ

Seata架构

Seata事务管理中有三个重要的角色:

  • TC(Transaction Coordinator)

    事务协调者,维护全局和分支事务的状态,协调全局事务提交或回滚

  • TM(Transaction Manager)

    事务管理器,定义全局事务的范围,开始全局事务、提交或回滚全局事务

  • RM(Resource Manager)

    资源管理器,管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

image-20240301134809160

XA模式

image-20240301134809160

具体的流程步骤:

  1. 开启全局事务
  2. 调用分支

RM一阶段工作

  1. 注册分支事务到TC
  2. 执行分支业务sql但不提交
  3. 报告执行状态到TC

TC二阶段工作

  • TC检测各分支事务执行状态
    • 如果都成功,通知所有RM提交事务
    • 如果有失败,通知所有RM回滚事务

RM二阶段的工作

  • 接收TC指令,提交或回滚事务
AT模式

​ AT模式同样是分阶段提交的事务模式,不过弥补了XA模型中资源锁定周期过长的缺陷。

image-20240301140400703

具体的流程步骤:

  1. 开启全局事务
  2. 调用分支

RM一阶段工作

  1. 注册分支事务到TC
  2. 记录undo-log(记录数据更新前后快照)
  3. 执行业务sql并提交
  4. 报告事务状态

TC二阶段工作

  • TC检测各分支事务执行状态

    • 如果都成功,通知所有RM提交事务

    • 如果有失败,通知所有RM回滚事务

RM二阶段工作

  • 提交时,删除undo-log即可
  • 回滚时,根据undo-log恢复数据到更新前
TCC模式
  1. Try:资源的检测和预留
  2. Confirm:完成资源操作业务,要求Try成功Confirm一定要成功
  3. Cancel:预留资源释放,可以理解为try的反向操作。

image-20240301141510530

总结
  1. seata的XA模式,需要互相等待各个分支事务提交,可以保证强一致性,性能差(CP,适用于银行业务)
  2. seata的AT模式,底层使用undo-log实现,性能好(AP,适用于互联网业务)
  3. seata的TCC模式,性能较好,不过需要人工编码实现(AP,适用于银行业务)

接口幂等性

​ 多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

需要幂等场景(一般只有POST和PUT请求产生问题)

  • 用户重复点击(网络波动)
  • MQ消息重复
  • 应用使用失败或超时重试机制

存在以下几种方式解决接口幂等问题

  1. 数据库唯一索引(解决新增)

  2. token+redis (解决新增、修改)

    适用于创建商品、提交订单、转账、支付等操作

    • 第一次请求,生成一个唯一token存入redis,返回给前端
    • 第二次请求,业务处理,携带之前的token,到redis进行验证,如果存在,可以执行业务,删除token,如果不存在,则直接返回,不处理业务。
  3. 分布式锁(解决新增、修改)

    每次都先获取锁,再去执行对应的逻辑,成功后在释放锁,保证了原子性,但是使用分布式锁性能较低。有以下注意:

    • 快速失败(对于抢不到锁的线程快速返回失败的结果)
    • 控制锁的粒度(锁用完需要及时释放锁)

XXl-JOB

路由策略

xxl-job提供了很多的路由策略,我们平时用的较多就是:轮询、故障转移、分片广播…

任务执行失败怎么解决

  1. 路由策略选择故障转移,优先使用健康的实例来执行任务
  2. 如果还有失败的,我们在创建任务时,可以设置重试次数
  3. 如果还有失败的,就可以查看日志或者配置邮件告警来通知相关负责人解决

大数据量的任务同时都需要执行,怎么解决

​ 我们会让部署多个实例,共同去执行这些批量的任务,其中任务的路由策略是分片广播

​ 在任务执行的代码中可以获取分片总数和当前分片,按照取模的方式分摊到各个实例执行就可以了

Redis

什么是Redis?主要用来干什么的

Redis,由C语言编写的远程字典服务,可基于内存亦可持久化的日志型key-value数据库。

与mysql数据库不同的是,Redis是单线程的,因为数据是存在内存中的,它的读写速度非常快,因此redis被广泛应用于缓存,另外redis也经常用来做分布式锁。除此之外,Redis支持事务、持久化等。

Redis的数据结构类型

五大基本类型

image-20220624150815865

String(字符串)
  • String是Redis最基础的数据结构类型,它是二进制安全的,可以存储图片或者序列化对象,值最大存储为512M
  • 有以下方法set、get、exist(是否存在)、incr(获取值自增)、decr(自减少)、setex(设置过期时间)、setnx(不存在在设置,在分布式锁会常用)
  • 应用场景:共享session、分布式锁、计数器、限流
Hash(哈希)
  • 简介:在redis中,哈希类型是指key-map集合,这时候的值就是一个map
  • 命令是h开头,其他和string类型基本一致
  • 应用场景:比较适合存储对象。比如:缓存用户信息等
List(列表)
  • 简介:列表类型是用来存储多个有序的字符串

  • 实例:命令都以l开头,比如lpush、lrange

  • 应用场景:消息队列,文章列表等

    1
    2
    3
    4
    lpush+lpop=stack(栈)
    lpush+rpop=Queue(队列)
    lpush+ltrim=Capped Collection(有限集合)
    lpush+brpop=message Queue(消息队列)

    brpop:命令移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止

Set(集合)
  • 集合类型,命令s开头,里面的值是不能重复的
  • sdiff:差集、sinter:交集、sunion:并集
  • 运用场景:社交需求,比如sinter实现共同关注等
zset(有序集合)
  • 简介:有序集合,主要是在set上增加了一个值用于排序set k1 v1,zset k1 score1 v1
  • 可以用来做一些数据排序的工作

三种特殊数据结构类型

Geospatial
  • 地理位置定位,用于存储地理位置信息,并对存储的信息进行操作
  • 可以用于附近的人,两点距离计算等
Hyperloglog
  • 用来做基数统计算法的数据结构,优点是,在输入元素的数量或者体积非常大时,计算基数所需的空间总是固定的,并且是很小的。
  • 应用场景,比如一个人访问网站多次,但是还是算作一个人
Bitmap
  • 位存储,操作二进制位来进行记录,就只有0和1两个状态
  • 统计用户信息:比如活跃,不活跃、登录、未登录等

什么是缓存穿透、缓存雪崩、缓存击穿

缓存穿透

​ 缓存穿透是指查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。。

缓存穿透产生情况:

  1. 业务不合理的设计
  2. 业务/运维/开发失误的操作:比如缓存和数据库的数据都被误删除了
  3. 黑客非法请求攻击:比如故意捏造大量非法请求,以读取不存在的业务数据

如何避免缓存穿透:

  1. 如果是非法请求,我们在api入口,对参数进行校验,过滤非法值
  2. 如果查询数据库为空,我们可以给缓存设置一个空值或者默认值。但是如果有请求进来的话,需要更新缓存,以保证缓存一致性,同时,最后给缓存设置适当的过期时间。(业务上比较常用,简单有效)
  3. 布隆过滤器
布隆过滤器

​ 布隆过滤器主要是用于检索一个元素是否在一个集合中。我们当时使用的是redisson实现的布隆过滤器。

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

​ 当然是有缺点的,布隆过滤器有可能会产生一定的误判,我们一般可以设置这个误判率,大概不会超过5%,其实这个误判是必然存在的,要不就得增加数组的长度,其实已经算是很划分了,5%以内的误判率一般的项目也能接受,不至于高并发下压倒数据库。

缓存雪崩问题

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

  • 缓存雪崩一般是由于大量数据同时过期造成的,对于这个原因,可以通过均匀设置过期时间解决。如采用一个较大固定值+一个较小的随机值。(5小时+0到18000秒)
  • Redis故障宕机也可能引起缓存雪崩,这就需要构造Redis高可用集群

缓存击穿问题

​ 指热点key在某个时间点过期的时候,而恰好在这个时间点对这个key有大量并发请求,从而大量的请求到数据库。

缓存击穿看着有点像:其实两者区别是:缓存雪崩是指数据库压力过大甚至down机,缓存击穿只是大量并发请求到了数据库层面。可以认为击穿是缓存雪崩的子集

解决方法:
  • 使用互斥锁方案:缓存失效时,不是立即加载数据库数据,而是先使用某些带成功返回的原子操作,如(redis的setnx)去操作,成功的时候,再去加载db数据库数据和设置缓存。否则就去重试获取缓存
  • 设置当前key逻辑过期
    • 在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间
    • 当查询的时候,从redis取出数据后判断时间是否过期
    • 如果过期则开通另外一个线程进行数据同步,当前线程正常返回数据,但是这个数据不是最新的数据

​ 如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题

​ 如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致。

什么是热key问题,如何解决

在redis中,我们把访问频率高的key,称为热点key,如果某一热点key的请求到服务器主机时,由于请求量特别大,可能会导致主机资源不足,甚至宕机,从而影响正常的服务。

如何产生的:

  1. 用户消费的数据远大于生成产的数据,如秒杀,热点新闻等读多写少的场景
  2. 请求分片集中,超过redis服务器性能,比如固定名称key。hash落入同一台服务器,瞬间访问量极大,超过机器瓶颈,产生热点key问题

如何解决:

  • redis集群扩容,增加分片副本,均衡读流量
  • 将热key分散到不同的服务器中
  • 使用二级缓存,即jvm本地缓存,减少redis的读请求

Mysql的数据如何与redis进行同步的

场景要求强一致性

​ 可以采用redisson实现的读写锁,在读的时候添加共享锁,可以保证读读不互斥,读写互斥。

​ 当我们更新数据的时候,添加排他锁,它是读写,读读都互斥,这样就能保证在写数据的同时是不会让其他线程读数据的,避免了脏数据。

​ 这里面需要注意的是读方法和写方法上需要使用同一把锁才行。

排他锁是如何保证读写、读读互斥的呢

​ 其实排他锁底层使用也是setnx,保证了同时只能有一个线程操作锁住的方法

延迟双删

​ 造成原因是:线程是交替执行的

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

场景要求最终一致性

MQ异步通知

​ 当去修改数据库数据时,会发送一个异步通知到消息队列,在根据消息队列的数据去更新缓存中的数据,这样去保证数据的最终一致性。

Canal异步通知

​ 我们当时采用的阿里的canal组件实现数据同步:不需要更改业务代码,部署一个canal服务。canal服务把自己伪装成mysql的一个从节点,当mysql数据更新以后,canal会读取binlog数据,然后在通过canal的客户端获取到数据,更新缓存即可。

Redis过期策略和内存淘汰策略

过期策略

在redis中提供了两种数据过期删除策略

定期删除

​ 就是说每隔一段时间,我们就对一些key进行检查,删除里面过期的key

定期清理的两种模式:

  • SLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf 的 hz 选项来调整这个次数
  • FAST模式执行频率不固定,每次事件循环会尝试执行,但两次间隔不低于2ms,每次耗时不超过1ms
惰性删除

​ 在设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。

==Redis中同时是用来惰性过期和定时过期两种过期策略==

  • 假设redis存放30万key,并都设置了过期时间,如果你每隔100ms去检查全部的key,cpu负载会很高
  • 因此采取定期过期,每隔100ms随机抽取一定数量的key来检查和删除
  • 但是最后可能有很多过期的key没被删除,redis采用惰性删除,在你获取某个key时,redis会检查是否key已经过了过期时间,过期了就会删除

内存淘汰策略

​ 这个在redis中提供了很多种,默认是noeviction,不删除任何数据,内部不足直接报错

​ 是可以在redis的配置文件中进行设置的,里面有两个非常重要的概念,一个是LRU,另外一个是LFU

​ LRU的意思就是最近最少使用,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。

​ LFU的意思是最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高

​ 我们在项目设置的allkeys-lru,挑选最近最少使用的数据淘汰,把一些经常访问的key留在redis中(这种留下来的数据都是经常访问的热点数据)

redis内存用完了会发生什么

​ 这个要看redis的数据淘汰策略是什么,如果是默认的配置,redis内存用完以后则直接报错。我们当时设置的 allkeys-lru 策略。把最近最常访问的数据留在缓存中。

redis应用场景

我这边记录几个上面不太理解的

共享session

如果一个分布式web服务将用户的session信息保存在各自服务器,用户刷新一次可能就需要重新登录了,可以使用redis将用户的session进行集中管理,每次用户更新或者查询登录信息都直接从redis中获取

分布式锁

分布式服务下,就会遇到对同一个资源的并发访问的技术难题,如秒杀,下单减库存等场景

  • 用sychronize或者ReentrantLock本地锁肯定不行
  • 如果并发量不大的话,使用数据库的悲观锁、乐观锁来实现没啥问题
  • 但是在并发量高的场合中,利用数据库锁来控制资源的并发访问,会影响数据库的性能
  • 实际上可以使用redis的setnx来实现分布式锁

消息队列

消息对列是大型网站的必用中间件,主要用于业务解耦,流量削峰以及异步处理实时性低的业务。redis提供了发布/订阅及阻塞队列功能,能实现一个简单的消息队列系统,另外,这个不能和专业的消息中间件相比

Redis持久化

AOF

采用日志的形式来记录每一个写操作,追加到AOF文件的末尾。Redis默认是不开启AOF的,重启时再重新执行AOF文件中的命令来恢复数据。它主要解决数据持久化的实时性的问题。

AOF是执行命令完后才记录的日志,这是因为redis在向AOF记录日志时,不会先对这些命令进行检查,如果先记录日志在执行命令,日志中可能记录错误命令,redis使用日志恢复数据时,可能出错。

那么可能出现以下两个风险

  • 刚执行完命令还没记录日志时,宕机了会导致数据丢失
  • AOF不会阻塞当前命令,但是可能会阻塞下一个操作。

解决方案:AOF机制的三种写回策略

  • always:同步写回,每个子命令执行完,都立即将日志写回磁盘
  • everysec:每个命令执行完,只是先把日志写到AOF内存缓存区,每隔一秒同步到磁盘
  • no:只是先把日志写到AOF内存缓存区,由操作系统去决定何时写入磁盘

==always同步写回,基本保证数据不丢失,no则性能高但是数据可能会丢失,一般可以考虑这种选择everysec==

优点:
  1. 每一次修改都同步,文件的完整性会更加好
  2. 每秒同步一次,可能会丢一秒的数据
  3. 从不同步,效率最高的
缺点:
  1. 相对于数据文件来说,aof远远大于rdb,恢复的速度也比rdb慢
  2. Aof运行效率也要比rdb慢,所以我们redis默认的配置就是rdb持久化

RDB

​ RDB是一个快照文件,它是把redis内存存储的数据写到磁盘上,当redis实例宕机恢复数据的时候,方便从RDB的快照文件中恢复数据。

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

优点:

  1. 适合大规模的数据恢复!dump.rdb
  2. 对数据的完整性要求不高

缺点:

  1. 需要一定的时间间隔进行操作。如果在这期间,redis意外宕机了,这个最后一次修改数据就没有了
  2. fork进程的时候会占用一定的内存空间

如何选择RDB和AOF

  • 如果数据不能丢失,RDB和AOF混用
  • 如果只作为缓存使用,可以承受几分钟的数据丢失的话,可以只使用RDB
  • 如果只使用AOF,优先使用everysec的写回策略

Redis集群方案

为了实现高可用,通常的做法是,将数据库复制多个副本以部署在不同的服务器上,其中一台挂了也可以继续提供服务。redis实现高可用有三种部署模式:主从模式,哨兵模式、分片集群模式

redis高可用回答包括两个层面:一个就是数据不能丢失,或者是说尽量减少丢失,另一个就是保证redis服务不中断

  • 对于尽量减少数据丢失,可以通过AOF和RDB保证
  • 服务不中断的话,redis就不能单点部署了

Redis主从

  • 就是部署多台redis服务器,有主库和从库,它们之间通过主从复制,以保证数据的一致
  • 主从库之间采用的是读写分离的方式,其中主库负责读操作和写操作,从库负责读操作
  • 如果Redis主库挂了,切换其中的从库成为主库
主从同步原理
  • Replication Id

    简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid。

  • offset

    偏移量,随着记录在repl_backlog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。

主从同步数据的流程

​ 主从同步分为了两个阶段,

  • 全量同步

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

    1. 从节点请求主节点同步数据,其中从节点会携带自己的replication id和offset偏移量。
    2. 主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致
    3. 在同时主节点会执行bgsave,生成rdb文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致
    4. 当然,如果在rdb生成执行期间,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区,缓冲区是一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件
  • 增量同步

​ 当从节点服务重启之后,数据就不一致了,所以这个时候,从节点会请求主节点同步数据,主节点还是判断不是第一次请求,不是第一次就获取从节点的offset值,然后主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步

Redis哨兵

哨兵作用

哨兵其实是一个运行在特殊模式下的redis进程,它有三个作用,分别是:监控、自动选主切换、通知.

哨兵模式

​ 就是由一个或多个哨兵实例组成的哨兵系统,它可以监视所有的Redis主节点和从节点,并在监视主节点进入下线状态时,自动将下线主服务器下的某个从节点升为新的主节点。一个哨兵进程对redis节点进行监控,就可能出现单点问题,因此,一般使用多个哨兵来进行监控Redis节点,并且各个哨兵之间还会进行监控。

其实哨兵之间是通过发布订阅机制组成集群的,同时哨兵又通过info命令,获得了从库连接信息,也能和从库建立连接,从而进行监控

哨兵如何判定主库下线
  • 主观下线

    哨兵进程向主库、从库发送ping命令,如果主库或者从库没有在规定的时间内响应ping命令,哨兵就把它标记为主观下线

  • 客观下线

    如果主库被标记为主观下线,则正在监视这个主库的所有哨兵要以每秒一次的频率,以确认主库是否真的进入主观下线,当有多数哨兵在指定时间范围内确认主库的确进入了主观下线状态,则主库会标记为客观下线。这样做的目的就是避免对主库的误判,减少没必要的主从切换,减少不必要的开销。

==假设我们有 N 个哨兵实例,如果有 N/2+1 个实例判断主库主观下线,此时就可 以把节点标记为客观下线,就可以做主从切换了==

哨兵的工作模式
  1. 每个哨兵以每秒钟一次的频率向它所知的主库、从库以及其他哨兵实例发送一个PING 命令。

  2. 如果一个实例节点距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被哨兵标记为主观下线。

  3. 如果主库被标记为主观下线,则正在监视这个主库的所有哨兵要以每秒一次的频率 确认主库的确进入了主观下线状态。

  4. 当有足够数量的哨兵(大于等于配置文件指定的值)在指定的时间范围内确认主库的确进入了主观下线状态, 则主库会被标记为客观下线

  5. 当主库被哨兵标记为客观下线时,就会进入选主模式

  6. 若没有足够数量的哨兵同意主库已经进入主观下线, 主库的主观下线状态就会被 移除;若主库重新向哨兵的 PING 命令返回有效回复,主库的主观下线状态就会被 移除。

哨兵是如何选主的

哨兵选主包括两大过程,分别是:过滤和打分。

  • 首先判断主节点与从节点断开时间次数,如果超过指定阈值就排除该从节点(筛选)
  • 然后判断从节点的slave-priority值,值越小优先级越高(打分)
  • 如果slave-priority一样,则判断slave节点的offset值,offset值越大优先级越高
  • 最后是判断slave节点的运行id大小,id值越小优先级越高
由那个哨兵执行主从切换

一个哨兵标记主库为主观下线后,会征求其他哨兵的意见,确认主库是否的确进入主观下线状态,它向其他实例哨兵发送is-master-down-by-addr命令。其他哨兵会根据自己和主库的连接情况回应y或者n,如果这个哨兵获得足够多的赞成票数(quorum配置),主库就会被标记客观下线。标记主库客观下线的这个哨兵,紧接着向其他哨兵发送命令,再发起投票,希望它可以来执行主从切换。这个投票过程称为Leader选举。一个哨兵想要称为Leader需要满足两个条件

  • 需要拿到num(sentinels)/2+1的赞成票
  • 并且拿到的票数需要大于等于哨兵配置文件中的quorum值。

假设如果因为网络故障等原因,导致这轮投票就不会产生Leader,哨兵集群会等待一端时间(一般是哨兵故障转移超时时间的2倍),再进行重新选举。

故障转移

当哨兵检测到redis主库出现故障,那么哨兵需要对集群进行故障转移。流程如下

  1. 通过选主模式的从库解除从节点身份,升级为新主库
  2. 另外的从库成为新主库的从库
  3. 原来的主库也变成新主库的从库
  4. 通知客户端应用程序新主库的地址

分片集群

主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决

  • 海量数据存储问题
  • 高并发写的问题

使用分片集群可以解决上述问题,分片集群的特征

  • 集群中有多个master,每个master保存不同数据(解决海量数据存储问题,有过个master也能解决高并发写的问题)
  • 每个master都可以有多个slave节点(解决高并发读)
  • master之间通过ping监测彼此健康状态(监控状态,类似哨兵模式)
  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点。(因为有路由规则,引入哈希槽概念。)

image-20240226113635750

分片集群中数据是怎么存储和读取的

​ Redis 集群引入了哈希槽的概念,有 16384 个哈希槽,集群中每个主节点绑定了一定范围的哈希槽范围, key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,通过槽找到对应的节点进行存储。

​ 取值的逻辑是一样的

项目中使用redis是单点还是集群

​ 我们当时使用的是主从(1主1从)加哨兵。

​ 一般单节点不超过10G内存,如果Redis内存不足则可以给不同服务分配独立的Redis主从节点。

​ 尽量不做分片集群。因为集群维护起来比较麻烦,并且集群之间的心跳检测和数据通信会消耗大量的网络带宽,也没有办法使用lua脚本和事务

Redis脑裂该如何解决

​ 脑裂的问题是这样的,出现在redis的哨兵模式集群,有的时候由于网络等原因可能会出现脑裂的情况

​ 就是说,由于redis master节点和redis salve节点和sentinel处于不同的网络分区,使得sentinel没有能够心跳感知到master,所以通过选举的方式提升了一个salve为master,这样就存在了两个master,就像大脑分裂了一样

​ 这样会导致客户端还在old master那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将old master降为salve,这时再从新master同步数据,这会导致old master中的大量数据丢失。

​ 关于解决的话,我记得在redis的配置中可以设置:

  1. 可以设置最少的salve节点个数,比如设置至少要有一个从节点才能同步数据
  2. 可以设置主从数据复制和同步的延迟时间,达不到要求就拒绝请求,就可以避免大量的数据丢失

使用过redis分布式锁吗。有哪些注意点

分布式锁是控制分布式系统不同进程共同访问共享资源的一种锁的实现。redis分布式锁的几种实现方法

  1. 命令setnx+expire分开写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    if(jedis.setnx(key.lockValue)==1){
    //加锁
    expire(key,100);//设置过期时间
    try{
    do something;
    }catch(){

    }finally{
    jedis.del(key);//释放锁
    }
    }

    如果执行完setnx加锁,正要执行expire设置过期时间,进程crash或者服务器重启,那么这个锁就一直存在,别的线程永远获取不到锁,所以分布式锁不能这样实现。

  2. setnx+value值是过期时间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    long expires = System.currentTimeMillis()+expireTime;//系统时间+设置过期时间
    String expiresStr = String.valueOf(expires);
    //如果当前锁不存在就返回加锁成功
    if(jedis.setnx(key,expiresStr)==1){
    return true;
    }
    //如果所已经存在,获取锁的过期日期
    String currentValueStr = jedis.get(key);
    //如果获取到的过期时间,小于系统当前时间,表示已经过期
    if(currentValueStr!=null&&Long.parseLong(currentValueStr)<System.currentTImeMillis()){
    //锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
    String oldValue = jedis.getSet(key_resource_id.expiresStr);
    if(oldValueStr!=null&&oldValueStr.equals(currentValueStr)){
    //考虑多线程并发的情况,只有一个线程的设置值和当前值相同,他才可以加锁
    return true;
    }
    }
    //其他情况,均返回加锁失败
    return false;
    }

    缺点:

    1. 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步
    2. 没有保存持有者的唯一标识,可能被别的客户端释放锁
    3. 锁过期的时候,并发多个客户端同时请求过来,都执行了jedis.getSet(),最终只有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。
  3. set ex px nx +校验唯一随机值,再删除

    key不存在的时候才进行设置选用NX,过期时间设置为10s,将SETNX和EXPIRE合二为一

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    if(jedis.set(key,uni_request_id,"NX","EX",100s)==1){
    //加锁
    try{
    do something;
    }catch(){

    }finally{
    //判断是不是当前线程假的锁,是才释放
    if(uni_request_id.equals(jedis.get(key))){
    jedis.del(key);//释放锁
    }
    }
    }

    在这里,判断当前线程加的锁和释放锁是不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁

使用过Redisson嘛。说说它的原理

​ redis的setnx指令不好控制这个问题,我们当时采用的redis的一个框架redisson实现的。

​ 在redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间,当锁住的一个业务还没有执行完成的时候,在redisson中引入了一个看门狗机制,就是说每隔一段时间就检查当前业务是否还持有锁,如果持有就增加加锁的持有时间,当业务执行完成之后需要使用释放锁就可以了

​ 还有一个好处就是,在高并发下,一个业务有可能会执行很快,先客户1持有锁的时候,客户2来了以后并不会马上拒绝,它会自旋不断尝试获取锁,如果客户1释放之后,客户2就可以马上持有锁,性能也得到了提升

redisson实现的分布式锁是可重入的吗

​ 是可以重入的。这样做是为了避免死锁的产生。这个重入其实在内部就是判断是否是当前线程持有的锁,如果是当前线程持有的锁就会计数,如果释放锁就会在计算上减一。

​ 在存储数据的时候采用的hash结构,大key可以按照自己的业务进行定制,其中小key是当前线程的唯一标识,value是当前线程重入的次数

redisson实现的分布式锁能解决主从一致性的问题吗

​ 这个是不能的,比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。

​ 我们可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。

​ 但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁

如果业务非要保证数据的强一致性,这个该怎么解决呢?

​ redis本身就是支持高可用的,做到强一致性,就非常影响性能,所以,如果有强一致性要求高的业务,建议使用zookeeper实现的分布式锁,它是可以保证强一致性的。

Redis是单线程,为什么那么快

  1. 完全基于内存的,C语言编写

  2. 采用单线程,避免不必要的上下文切换可竞争条件

  3. 使用多路I/O复用模型,非阻塞IO

I/O多路复用模型

​ I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源

​ 目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。

​ 其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;

为什么Redis6.0之后改为多线程呢

​ redis 使用多线程并非是完全摒弃单线程,redis 还是使用单线程模型来处理客 户端的请求

​ 只是在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程

Redis事务机制

redis通过multi、exec、watch等一组命令集合,来实现事务机制。简言之,redis就是顺序性,一次性、排他性的执行一个队列中的一系列命令

执行事务的流程如下:

  • 开始事务(multi)
  • 命令入队
  • 执行事务(exec)、撤销事务(discard)

还存在一个watch,监视key,如果在事务执行之前,该key被其他命令所改动,那么事务将被打断

消息队列

什么是消息队列

你可以把消息队列理解为一个使用队列来通信的组件。它的本质,就是个转发器,包含发消息、存消息、消费消息的过程。最简单的消息队列模型如下:

image-20220624222145573

我们通常说的消息队列,简称MQ(Message Queue),它其实就指消息中间件,当前业界比较流行的开源消息中间件包括:RabbitMQ、RocketMQ、Kafka

消息队列有哪些使用场景(或者为什么使用消息队列)

  1. 应用解耦
  2. 流量削峰
  3. 异步处理
  4. 消息通讯
  5. 远程调用

应用解耦

常见业务场景:下单扣库存,用户下单后,订单系统去通知库存系统扣减。传统做法就是订单系统直接调用库存系统:

  • 如果库存系统无法访问,下单就会失败,订单和库存系统存在耦合关系
  • 如果业务又接入一个营销积分服务,那订单下游系统要扩充,如果未来接入越来越多下游系统,那订单系统代码需要经常修改

image-20220624222909398

  1. 订单系统:用户下单后,消息写入到消息队列,返回下单成功
  2. 库存系统:订阅下单消息,获取下单消息,进行库存操作。

流量削峰

流量削峰也是消息队列的常用场景。我们做秒杀实现的时候,需要避免流量暴涨,打垮应用系统的风险。可以在应用前面加入消息队列。

image-20220624223225387

假设秒杀系统每秒最多可以处理2k个请求,每秒却有5k的请求,可以引入消息队列,秒杀系统每秒从消息队列拉2k请求处理得了。

有些伙伴会担心出现消息积压的问题:

  • 首先秒杀活动不会每时每刻都那么多请求过来,高峰期过后,积压的请求可以慢慢处理
  • 其次,如果消息队列长度超过最大数量,可以直接抛弃用户请求或跳转到错误页面

异步提速

比如:用户注册成功后,给它发个短信和发个邮件,如果注册信息入库是30ms,发短信、邮件也是30ms,三个动作串行执行的话,会比较耗时,响应90ms。如果采取并行执行的方式,可以减少响应时间。注册信息入库后,同时异步发短信和邮件。通过消息队列即可,就是说,注册信息入库成功后,写入到消息队列,然后异步读取发邮件和短信

image-20220624223826587

消息通讯

消息队列内置了高效的通信机制,可用于消息通讯,如实现点对点消息队列、聊天室等

远程调用

MQ的劣势

  • 系统的可用性降低

    系统引入外部依赖越多,系统稳定性就越差,一旦MQ宕机,就会对业务造成影响,如何保证MQ的高可用(集群部署

  • 系统复杂度提高

    MQ的加入大大增加了系统的复杂度,以前系统间是同步的远程调用,现在是通过MQ进行异步调用,会出现如何保证消息不被丢失的情况。

消息队列如何解决消息丢失问题

如果是RocketMQ消息中间件,Producer生产者提供了三种发送消息的方式,分别是:

  • 同步发送
  • 异步发送
  • 单向发送

生产者要想发消息时保证消息不丢失,可以:

  • 采用同步方式发送,send消息方法返回成功状态,就表示消息正常到达了存储端Broker
  • 如果send消息异常或者返回非成功状态,可以重试
  • 可以使用事务消息,RocketMQ的事务消息机制就是为了保证零丢失来设计的

如果是RabbitMQ消息中间件,有以下几种方式

  1. 开启生产者确认机制,确保生产者的消息能到达队列,如果报错可以先记录到日志中,再去修复数据
  2. 开启持久化功能,确保消息未消费前在队列中不会丢失,其中的交换机、队列、和消息都要做持久化
  3. 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack,当然也需要设置一定的重试次数,我们当时设置了3次,如果重试3次还没有收到消息,就将失败后的消息投递到异常交换机,交由人工处理

如果是Kafka消息中间件,有以下几种方式

​ 在发送消息到消费者接收消息,在每个阶段都有可能会丢失消息,所以我们解决的话也是从多个方面考虑

  1. 生产者发送消息的时候,可以使用异步回调发送,如果消息发送失败,我们可以通过回调获取失败后的消息信息,可以考虑重试或记录日志,后边再做补偿都是可以的。同时在生产者这边还可以设置消息重试,有的时候是由于网络抖动的原因导致发送不成功,就可以使用重试机制来解决
  2. 在broker中消息有可能会丢失,我们可以通过kafka的复制机制来确保消息不丢失,在生产者发送消息的时候,可以设置一个acks,就是确认机制。我们可以设置参数为all,这样的话,当生产者发送消息到了分区之后,不仅仅只在leader分区保存确认,在follwer分区也会保存确认,只有当所有的副本都保存确认以后才算是成功发送了消息,所以,这样设置就很大程度了保证了消息不会在broker丢失
  3. 有可能是在消费者端丢失消息,kafka消费消息都是按照offset进行标记消费的,消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次,如果出现重平衡的情况,可能会重复消费或丢失数据。我们一般都会禁用掉自动提价偏移量,改为手动提交,当消费成功以后再报告给broker消费的位置,这样就可以避免消息丢失和重复消费了

消息队列如何保证消息的顺序性

消息的有序性,就是指可以按照消息的发送顺序来消费,比如先下单在付款,最后再完成订单。

为了保证消息的顺序性,可以将将M1、M2发送到一个Server上,当M1发送完收到ack后,M2在发送。如图:

image-20220624230641703

但是可能存在网络延迟,虽然M1先发送,但是它比M2晚到。那可以这样处理

image-20220624230916237

Kafka保证消费的顺序性

​ kafka如果有这样的需求的话,我们是可以解决的,把消息都存储同一个分区下就行了,有两种方式都可以进行设置,第一个是发送消息时指定分区号,第二个是发送消息时按照相同的业务设置相同的key,因为默认情况下分区也是通过key的hashcode值来选择分区的,hash值如果一样的话,分区肯定也是一样的

消息队列有可能发生重复消费,如何避免,如何做到幂等

消息队列是可能发生重复消费的。

​ 我们当时消费者是设置了自动确认机制,当服务还没来得及给MQ确认的时候,服务宕机了,导致服务重启之后,又消费了一次消息。这样就重复消费了

如何幂等处理重复消息

  • 幂等处理重复消息,简单来说,就是搞个本地表,带唯一业务标记的,利用主键或者唯一性索引,每次处理业务,先校验一下就好了。
  • redis分布式锁
  • 数据库的锁都是可以的
Kafka消息的重复消费问题解决

​ kafka消费消息都是按照offset进行标记消费的,消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次,如果出现重平衡的情况,可能会重复消费或丢失数据。

​ 我们一般都会禁用掉自动提价偏移量,改为手动提交,当消费成功以后再报告给broker消费的位置,这样就可以避免消息丢失和重复消费了

如何处理消息队列的消息积压问题

消息积压是因为生产者的生产速度,大于消费者的消费者速度,首先需要先排查,是不是有bug。

  • 不是bug,优化消费逻辑

    • 提高消费者的消费能力 ,可以使用多线程消费任务
    • 增加更多消费者,提高消费速度,使用工作队列模式, 设置多个消费者消费消费同一个队列中的消息
    • 扩大队列容积,提高堆积上限
  • 如果是bug导致百万消息积压几个小时,大概思路:

    • 先修复consumer消费者的问题,以确保其恢复消费速度,然后将现用consumer都停掉
    • 新建一个topic,partition是原来的10,临时建立好原来10倍的queue数量
    • 然后写一个临时的分发数据的consumer程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的10倍数量的queue
    • 接着临时征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的数据,这种做法相当于临时将queue资源和consumer资源扩大10倍,以正常的10倍速度来消费数据
    • 等快速消费玩积压数据之后,得恢复原来先部署的架构,重新用原来的consumer机器来消费

可以使用RabbitMQ惰性队列,惰性队列的好处主要是

  1. 接收到消息后直接存入磁盘而非内存

  2. 消费者要消费消息时才会从磁盘中读取并加载到内存

  3. 支持数百万条的消息存储

消息队列技术选型

KafkaRocketMQRabbitMQ
单机吞吐量17.3w/s11.6w/s2.62/s(消息做持久化)
开发语言Scala/javajavaErlang
主要维护者ApacheAlibabaMozilla/Spring
订阅形式基于topic,按照topic进行正则匹配的发布订阅模式基于topic/messageTag,按照消息类型、属性进行正则匹配的发布订阅模式提供了4种:direct,topic,Headers和fanout。fanout就是广播模式
持久化支持大量堆积支持大量堆积支持少量堆积
顺序消息支持支持不支持
集群方式天然的Leader-Slave,无状态集群,每台服务器即是Master也是Slave常用多对Master-Slave模式,开源版本需要手动切换Slave变成Master支持简单集群,复制模式,对高级集群模式支持不好
性能稳定性较差一般
  • RabbitMQ是开源的,比较稳定的支持,活跃度也高,但是不是java语言开发的
  • 很过公司用RocketMQ,是阿里出品
  • 如果是大数据领域的实时计算、日志采集等场景,用Kafka是业内标准的。

如何保证数据一致性,事务消息如何实现

image-20220624234114650

  1. 生产者产生消息,发送一条半事务消息到MQ服务器
  2. MQ收到消息后,将消息持久化到存储系统,这条消息的状态就是待发送状态
  3. MQ服务器返回ACK,确认到生产者,此时MQ不会触发消息推送事件
  4. 生产者执行本地事务
  5. 如果本地事务执行成功,即commit执行结果到MQ服务器;如果执行失败,发送rollback
  6. 如果是正常commit,MQ服务器更新消息状态为可发送,如果是rollback,即删除消息
  7. 如果消息状态更新为可发送,则MQ服务器会push消息给消费者,消费者消费完就返回ACK
  8. 如果MQ服务器长时间没有收到生产者的commit或者rollback,它会反查生成者,然后根据查询到的结果执行最终状态。

RabbitMQ高可用机制

​ 我们当时项目在生产环境下,使用的集群,当时搭建是镜像模式集群,使用了3台机器。

​ 镜像队列结构是一主多从,所有操作都是主节点完成,然后同步给镜像节点,如果主节点宕机后,镜像节点会替代成新的主节点,不过在主从同步完成前,主节点就已经宕机,可能出现数据丢失

RabbitMQ出现丢数据如何解决

​ 我们可以采用仲裁队列,与镜像队列一样,都是主从模式,支持主从数据同步,主从同步基于Raft协议,强一致。

并且使用起来也非常简单,不需要额外的配置,在声明队列的时候只要指定这个是仲裁队列即可

Kafka高可用机制

​ 主要是有两个层面,第一个是集群,第二个是提供了复制机制

  • kafka集群指的是由多个broker实例组成,即使某一台宕机,也不耽误其他broker继续对外提供服务
  • 复制机制是可以保证kafka的高可用的,一个topic有多个分区,每个分区有多个副本,有一个leader,其余的是follower,副本存储在不同的broker中;所有的分区副本的内容是都是相同的,如果leader发生故障时,会自动将其中一个follower提升为leader,保证了系统的容错性、高可用性

复制机制中的ISR

​ ISR的意思是in-sync replica,就是需要同步复制保存的follower

​ 其中分区副本有很多的follower,分为了两类,一个是ISR,与leader副本同步保存数据,另外一个普通的副本,是异步同步数据,当leader挂掉之后,会优先从ISR副本列表中选取一个作为leader,因为ISR是同步保存数据,数据更加的完整一些,所以优先选择ISR副本列表

Kafka数据清理机制

Kafka中topic的数据存储在分区上,分区如果文件过大会分段存储segment

每个分段都在磁盘上以索引(xxxx.index)和日志文件(xxxx.log)的形式存储,这样分段的好处是,第一能够减少单个文件内容的大小,查找数据方便,第二方便kafka进行日志清理。

在kafka中提供了两个日志的清理策略:

  1. 根据消息的保留时间,当消息保存的时间超过了指定的时间,就会触发清理,默认是168小时( 7天)
  2. 根据topic存储的数据大小,当topic所占的日志文件大小大于一定的阈值,则开始删除最久的消息。这个默认是关闭的

这两个策略都可以通过kafka的broker中的配置文件进行设置

Kafka中实现高性能的设计有了解过嘛

​ Kafka 高性能,是多方面协同的结果,包括宏观架构、分布式存储、ISR 数据同步、以及高效的利用磁盘、操作系统特性等。主要体现有这么几点:

  1. 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
  2. 顺序读写:磁盘顺序读写,提升读写效率
  3. 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
  4. 零拷贝:减少上下文切换及数据拷贝
  5. 消息压缩:减少磁盘IO和网络IO
  6. 分批发送:将消息打包批量发送,减少网络开销

RabbitMQ

image-20220707190250212

相关概念

  • Broker

    接收和分发消息的应用,RabbitMQ Server就是Message Broker

  • Virtual host

    当多个不同的用户使用同一个RabbitMQ Server提供的服务时,可以划分出多个vhost,每个用户在自己的vhost创建exchange/queue等

  • Connection

    创建者/消费者和borker之间的tcp连接

  • Channel

    Channel作为轻量级的Connection极大减少了操作系统建立TCP connection的开销

  • Exchange

    message到达broker的第一站,根据分发规则,匹配查询表中的routing key,分发消息到queue中去

    1. Direct Exchange

      直连交换机,根据路由键完全匹配进行路由消息队列

    2. Topic Exchange

      通过模糊匹配的方式匹配消息对列,如果是个#是匹配多个单词,*匹配一个单词,用.隔开的为一个单词

    3. Fanout Exchange

      广播模式,投递到所有绑定的队列,不需要规则去匹配的

    4. Headers Exchange

      基于消息内容中的headers属性进行匹配

  • Queue

    消息最终被送到这里等待consumer取走

  • Binding

    exchange和queue之间的虚拟连接,binding中可以包含routing key。Binding信息被保存到exchange中的查询表中,用于message的分发依据

工作模式

简单模式
  1. 生产者产生消息,将消息放入队列
  2. 消息的消费者监听消息队列,如果队列中有消息,就消费掉,消息被拿走后,自动从队列中删除,这样可能造成消息处理失败后,造成消息的丢失,可以设置手动的ack,但是如果设置为手动的ack,处理完后要即时ack消息给队列,否则会造成内存溢出
工作队列模式(work queues)

image-20220707193134618

存在一个生产者,一个消息队列,多个消费者

Publish/Subscribe(发布与订阅模式)

image-20220707194014345

  1. 每个消费者监听自己的队列
  2. 生产者将消息发给broke,由交换机将消息转发并绑定此交换机的每一个队列,每个绑定交换机的队列都将接收到消息
Routing路由模式

image-20220707194209640

  1. 消息生产者将消息发送给交换机,交换机根据路由的key去匹配对应的消息队列
  2. 消费者也根据路由的key消费消息
Topics主题模式

可以里理解为路由模式的一种,不是有路由的key去精确匹配,而是根据key去模糊匹配,星号和井号代表通配符,星号代表多个单词,井号代表一个单词

消息确认机制(确保消息的发送和接收)

事务机制

RabbitMQ中与事务机制有关的方法有三个:

  1. txSelect():用于将当前channel设置成Transaction模式
  2. txCommit():用于提交事务
  3. txRollback():用于回滚事务

在通过txselect开始事务之后,我们便可以发布消息给broker代理服务器了,如果txCommit提交成功了,则消息一定到达了broke,如果在txcommit执行之前broke异常,这个时候便可以捕获异常通过txRollback回滚事务。

confirm模式

生产者将信道设置为confirm模式,进入该模式,所有在信道上面发布的消息都会被指派一个唯一的ID,当消息被投递到所匹配的队列之后,broke就会发送一个确认给生产者(包含消息的唯一ID),如果发生错误导致消息丢失,会发送一条nack(未确认)消息给生产者。

发送方确认模式是异步的,生产者在等待确认的同时,可以继续发送消息,当确认消息到达生产者,生产者的回调方法会被触发

ConfirmCallback接口:只确认是否正确到达Exchange中,成功到达时回调

ReturnCallback接口:消息失效返回时回调

消息延时发送机制(死信交换机)

​ 延迟队列就是用到了死信交换机和TTL(消息存活时间)实现的。

​ 如果消息超时未消费就会变成死信,在RabbitMQ中如果消息成为死信,队列可以绑定一个死信交换机,在死信交换机上可以绑定其他队列,在我们发消息的时候可以按照需求指定TTL的时间,这样就实现了延迟队列的功能了。

​ 我记得RabbitMQ还有一种方式可以实现延迟队列,在RabbitMQ中安装一个死信插件,这样更方便一些,我们只需要在声明交互机的时候,指定这个就是死信交换机,然后在发送消息的时候直接指定超时时间就行了,相对于死信交换机+TTL要省略了一些步骤

​ 可以应用到订单下单后,30分钟未支付则取消订单。

Zookeeper

Zookeeper有什么用途

  • 当做dubbo的注册中心
  • 它是一个开放源码的分布式协调服务,它是一个集群的管理者,它将简单易用的接口提供给用户
  • 可以基于Zookeeper实现诸如数据发布/订阅、负载均衡,命名服务,分布式协调、集群管理、Master选举、分布式锁和分布式队列等功能
  • Zookeeper用途:命名服务、配置管理、集群管理等。

什么是命名服务、配置管理、集群管理

  • 命名服务

    是指通过指定的名字来获取资源或者服务地址,Zookeeper可以创建一个全局唯一的路径,这个路径就可以作为一个名字。被命名的实体可以是集群中的机器,服务的地址,或者是远程的对象等

  • 配置管理

    实际项目开发中,经常使用xml配置信息,因为程序分布式部署在不同的机器上,如果把这些配置保存在zk的znode节点下,当你要修改配置,即znode会发生变化时,可以通过改变zk中某个目录节点的内容,利用watcher通知给各个客户端,从而更改配置。

  • 集群管理

    集群管理包括集群监控和集群控制,其实就是监控集群机器状态,Zookeeper可以实时监控znode节点的变化,一旦发现有机器挂了,该机器就会与zk断开连接,对应使用的临时目录节点会被删除,其他所有机器都收到通知,新机器加入也是类似流程。

znode有几种类型,Zookeeper数据模型是怎样的

Zookeeper的视图数据结构,很像unix文件系统,也是树状的,这样可以确定每个路径都是唯一的。Zookeeper的节点统一叫做znode,它是可以通过路径来标识的

image-20220626164315979

znode的4中类型

根据节点的生命周期,znode可以分为4中类型,分别是持久节点、持久顺序节点、临时节点、临时顺序节点

  • 持久节点(这类节点被创建后,就会一直存在于zk服务器上,直到手动删除)
  • 持久顺序节点(它的基本特性和持久节点差不多,不同的是增加了顺序性。父节点会维护一个自增整型数字,用于子节点的创建的先后顺序)
  • 临时节点(临时节点的生命周期与客户端的会话绑定,一旦客户端会话失效,那么这个节点就会被自动清理掉。zk规定临时节点只能作为叶子节点)
  • 临时顺序节点(基本特性同临时节点,添加了顺序的特性)

znode节点存储什么,节点数据最大是多少

znode节点存储

1
2
3
4
5
6
public class DataNode implements Record{
byte data[];
Long acl;
public StatPersister stat;
private Set<Strng> children children = null;
}

所以znode包含了:存储数据,访问权限,子节点的引用、节点状态信息。

  • data:znode存储的业务数据信息
  • acl:记录客户端对znode节点的访问权限,如IP等
  • child:当前节点的子节点引用
  • stat:包含znode节点的状态信息,比如事务id、版本号、时间戳等

节点数据最大范围

为了保证高吞吐和低延迟,以及数据的一致性,znode只适合存储非常小的数据,不能超过1M,最好都小于1k。

你知道Znode节点的监听机制吗?讲下zk的watch机制

watcher监听机制

可以把watcher理解成客户端注册在某个znode上的触发器,当这个znode节点发生变化时(增删改查),就会触发znode对应的注册事件,注册的客户端就会收到异步通知,然后做出业务的改变

watch监听机制工作原理

image-20220626165711234

  • Zookeeper的watcher机制主要包括客户端线程,客户端watcherManager、Zookeeper服务器三部分
  • 客户端向Zookeeper服务器注册watcher的同时,会将watcher对象存储在客户端的watchManager中
  • 当Zookeeper服务器触发watcher事件后,会向客户端发送通知,客户端线程从watchManager中取出对应的watcher对象来执行回调逻辑。

Zookeeper的特性

  • 顺序一致性:从同一客户端发起的事务请求,最终将会严格的按照顺序被应用到Zookeeper中去
  • 原子性:要么整个集群中所有机器都成功应用了某一个服务,要么都没有应用
  • 单一视图:无论客户端连到哪一个Zookeeper服务器上,其看到的服务端数据模型都是一致的
  • 可靠性:一旦服务端成功应用了一个事务,并完成对客户端的响应,那么该事务所引起的服务端状态变更将会被一直保留下来
  • 实时性(最终一致性):Zookeeper仅仅能保证在一定时间段内,客户端最终一定能够从服务端上读取到最新的数据状态

Zookeeper如何保证事务的顺序一致性

Zookeeper在选举时通过比较各节点的zxid和机器id选出新的主节点。zxid由Leader节点生成,有新写入事件时,Leader生成新的zxid并随提案一起广播,每个节点本地都保存了当前最近一次事务的zxid,zxid是自增的,所以谁的zxid越大,就表示谁的数据是最新的。

zxid有两部分组成

  • 任期(前32位):完成本次选取后,直到下次选举前,由统一个Leader负责写入,相当于一个王朝的概念,谁是Leader谁就是这个王朝
  • 事务计数器(后32位):单调递增,每生效一次写入,计数器加1

zxid的低32位是计数器,所以统一任期内,zxid是连续的,每个节点又都保存着自身最新生效的zxid,通过对比新提案的zxid与自身最新的zxid是否相差1,来保证事务严格按照顺序生效的。

Zookeeper服务器的角色,server工作的状态

服务器角色

  • Leader

    Leader服务器是整个Zookeeper集群工作机制中的核心,其主要工作是:事务请求的唯一调度和处理者,保证集群事务处理的顺序性。集群内部各服务的调度者

  • Follower

    Follower服务器是Zookeeper集群状态的跟随者,其最要工作:

    • 处理客户端非事务请求,转发事务请求给Leader服务器
    • 参与事务请求Proposal的投票
    • 参与Leader选举投票
  • Observer

    充当一个观察者角色,观察Zookeeper集群的最新状态变化并将这些状态变更同步过来,其工作:处理客户端的非事务请求,转发事务请求给Leader服务器,不参与任何形式的投票

Server工作状态

  1. Looking:寻找Leader状态,当服务器处于该状态时,它会认为当前急群众没有Leader,因此需要进入Leader选举状态
  2. Following:跟随者状态,表明当前服务器角色是Follower
  3. Leading:领导者状态:表明当前服务器角色是Leader
  4. Observing:观察者状态:表明当前服务器角色是Observer

Zookeeper集群部署图

image-20220626172642969

Zookeeper集群是一主多从的结构:

  • 如果是写入数据,先写入主服务器(主节点),再通知从服务器
  • 如果是读取数据,即读主服务器的,也可以读从服务的

Zookeeper如何保证主从节点数据一致性

要保证主次节点一致性,无非就是考虑两个问题:

  1. 主服务器挂了,或者重启了
  2. 主从服务器之间同步数据

Zookeeper是采用ZAB(原子广播协议)来保证主从节点数据一致性的,ZAB协议支持崩溃恢复和消息广播两种模式

  1. 崩溃恢复:Leader挂了,进入该模式,选举一个新的leader出来
  2. 消息广播:把更新的数据,从Leader同步到所有的Follower

简述Zookeeper的选举机制

就是半数机制(集群中半数以上机器存活,集群可用,所以Zookeeper适合安装奇数台服务器),只要达到半数,它就会选择myid当中id号最大的那个。选举其实细分两类

启动时选举

  • 服务器启动时选举,在未选取完leader的情况下,每台服务器启动时都会发起leader的选取,当服务器1发起选举时,会投给自己一票,但是服务器得票未超过半数,服务器1状态将会保持为loking状态
  • 服务2启动时发起选举,首先会投自己一票,当服务器1和服务器2比较myid,发现服务器2的id大于服务器1,则服务器1将把自己的票投给服务器2,票未够半数,服务器2继续保持looking状态
  • 服务器3启动,发起选举,按上述投票规则投完票,发现得票数大于半数,服务器1和2的状态就改为following,服务器3状态变为leading
  • 服务器4启动发现服务器1和2状态为following,服务器1和2不会再次投票,而服务器4不管myid是否比服务器3大,而是遵从半数原则,直接将自己的票投给服务器3,依次类推,直到所有的服务起来

崩溃恢复时选举

  • 当leader挂掉后,剩余的follower将会把自己的状态从following变为looking重新进行leader选举
  • 选举过程和启动时选取过程一致

Zookeeper常用命令

  • ls 查看文件下所有文件
  • create 创建节点
  • get 获取节点下的值
  • delete 删除节点
  • set 设置节点的值

Zookeeper的部署方式有几种,角色有哪些,集群最少需要几台服务器

  1. 部署方式有单机模式、集群模式
  2. 角色:Leader和Follower
  3. 集群最少需要机器数:3台,一般为奇数2n+1

Dubbo

什么是dubbo

Apache dubbo是一款高性能、轻量级的开源java RPC框架,主要有三大能力

  • 面向接口的远程方法调用
  • 智能容错和负载均衡
  • 服务自动注册与发现

dubbo基本概念

有以下角色:

  • 服务提供者(Provider)

    暴露服务的服务提供方,服务提供者在启动时,向注册中心注册自己提供的服务

  • 服务消费者(Consumer)

    调用远程服务的服务消费方,服务消费者在启动时,向注册中心订阅自己所需要的服务,服务消费者从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另外一台调用

  • 注册中心(Registry)

    注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者

  • 监控中心(Monitor)

    服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心

调用关系说明

  • 服务容器启动负责启动、加载、运行服务提供者
  • 服务提供者在启动时,向注册中心注册自己提供的服务
  • 服务消费者在启动时,向注册中心订阅自己所需要的服务
  • 注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接(NIO异步通讯,适用于小数据量大并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况)推送变更数据给消费者
  • 服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,在选另一台调用
  • 服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心

dubbo如何做负载均衡

在提供者和消费者的配置文件中设置loadbalance属性的值,有以下四种内置策略

  • RandomLoadBalance:随机负载均衡,按权重设置随机概率,是dubbo的默认负载均衡策略
  • RoundRobinLoadBalance:轮询负载均衡,轮询依次,根据公约后的权重(几分之几,比如一个是七分之一,表示在七次访问中会有一次访问该服务器)进行轮询访问
  • LeastActiveLoadBalance:最少活跃调用数,相同活跃数的随机
  • ConsistentHashLoadBalance:一致性哈希负载均衡:相同参数的请求总是落在同一台机器上

dubbo如何做服务降级

dubbo提供了mock配置,可以很好的实现了dubbo服务降级

1
<dubbo:referene id="xxxServie" interface="com.x.service.xxxServie" check="false" mock="return fail">

dubbo如何集群容错

集群容错模式

  • failover cluster

    失败自动切换,当出现失败时,重试其他服务器,通常用于读操作,但重试会带来更长的延迟,可通过设置retries=“2”来设置重试次数(不含第一次)

  • failfast cluster

    快速失败,只发起一次调用,失败立即报错,通常用于非幂等性的写操作,如新增记录等

  • failsafe cluster

    失败安全,出现异常时,直接忽略,通常用于写入审计日志等操作

  • failback cluster

    失败自动恢复,后台记录失败请求,定时重发,通常用于消息通知操作

  • forking cluster

    并行调用多个服务器,只要一个成功即返回,通常用于实时性要求较高的读操作,但需要浪费更多资源,可通 过fork=“2”来设置最大并行数

  • broadcast cluster

    广播调用所有提供者,逐个调用,任意一台报错则报错,通常用于通知所有提供者更新缓存或日志等本地资源信息

Dubbo如何实现异步调用的

  1. api注入是添加异步调用标识@Reference(interfaceClass=xxx.class,async-true)
  2. 启动类开启异步调用 @EnableAsycn
  3. 异步调用的接口添加异步调用代码
  4. RPCContext.getContext.future()

在provide上可配置的consumer端的属性有哪些

  • timeout:调用超时
  • retries:失败重试次数(默认重试2次)
  • loadbalance:负载均衡算法。默认随机
  • actives:消费者端,最大并发调用限制

dubbo默认使用的是什么通信框架

Dubbo默认使用Netty框架(推荐),另外内容还集成Mina、Grizzly

Doubbo服务之间的调用时阻塞的吗

默认是阻塞的,可以异步调用

Dubbo的管理控制台可以做什么

  • 路由规则
  • 动态配置
  • 服务降级
  • 访问控制
  • 权重调整
  • 负载均衡等管理功能

说说dubbo服务暴露的过程

Dubbo负责spring实例化完bean之后,在刷新容器最后一步发布ContextRefreshEvent事件的时候,通知实现类ApplicationListener的serviceBean类进行回调onApplicationEvent事件方法,Dubbo会在这个方法中调用serviceBean父类ServiceConfig的export方法,而该方法真正实现了服务的发布

Nignx

概述

是一款高性能的HTTP和反向代理服务器,同时也提供了IMAP/POP3/SMTP服务。其特点是占有内存少,并发能力强。

Nginx是一个安装非常容易,配置文件非常简洁,Bug非常少的服务。Nginx启动特别容易,并且几乎可以做到7*24不间断运行,即使运行数个月也不需要重新启动,还能够不间断服务的情况下进行软件版本的升级

Nginx代码完全用C语言从头写成,官方数据测试表明能够支持高达50000个并发连接数响应。

Nginx作用(下列图中云可以不需要考虑是什么意思)

  • 正向代理

    • 访问原来无法访问的资源,如谷歌
    • 可以做缓存,加速访问资源

  • 反向代理

    反向代理的作用

    • 保证内网安全,隐藏后端服务器的信息,屏蔽黑名单中的IP,限制每个客户端的连接数
    • 提高可扩展性和灵活性,客户端只能看到反向代理服务器的IP,这使你可以增减服务器或者修改它们的配置
    • 缓存,直接返回选中的缓存结果
    • 静态内容直接返回
    • 负载均衡,通过反向代理服务器来优化网站的负载

Nginx负载均衡策略

  • 内置策略

    • 轮询

    • 加权轮询

    • Ip hash

      ip hash对客户端请求的ip进行hash操作,然后根据hash结果将同一个客户端ip请求分发给同一台服务器进行处理,可以解决session不共享问题

    • 动静分离

      在我们软件开发中,有些请求是需要后台处理的,有些请求是不需要经过后台处理的(如:css、html、jpg、js等文件),这些不需要经过后台处理的文件称为静态文件,让动态网站里的动态网页根据一定规则把不变的资源和经常变的资源分开,动静资源做好拆分后,我们可以根据静态资源的特点将其做缓存操作。提高资源响应的速度

Nginx常用命令

1
2
3
4
5
6
cd /usr/local/nginx/sbin/
./nginx 启动
./nginx -s stop 停止
./nginx -s quit 安全退出
./nginx -s reload 重新加载配置文件
ps aux|grep nginx 查看nginx进程

Nginx安装一些注意的问题

  • nginx的配置文件是conf目录下的Nginx.cong,默认配置的Nginx监听的端口为80,如果80端口被占用就可以修改为未被占用的端口即可

Mysql

Mysql中如何定位慢查询

  1. 如果部署了运维监控系统Skywalking,在展示的报表中可以看到哪一个接口比较慢,并且分析这个接口那部分比较慢,可以看到sql的具体的执行时间,所以可以定位是那个sql出了问题
  2. MySQL中也提供了慢日志查询的功能,可以在MySQL的系统配置文件中开启这个慢日志的功能,并且也可以设置SQL执行超过多少时间来记录到一个日志文件中

索引的优缺点以及索引的类型

优缺点

优点:
  • 唯一索引可以保证数据库表中每一行数据的唯一性
  • 索引可以加快数据查询速度,减少查询时间
缺点
  • 创建索引和维护索引需要耗费时间
  • 索引需要占物理空间
  • 表中数据进行增、删、改的时候,索引也要动态维护

索引类型

  • 主键索引:数据列不允许重复,不允许为Null,一个表只能有一个主键
  • 唯一索引:数据列不允许重复,允许为null值,一个表允许多个列创建唯一索引
  • 普通索引:基本的索引类型,没有唯一性的限制,允许为null值
  • 全文索引:是目前搜索引擎使用的一种关键技术,对文本的内容进行分词、搜索
  • 覆盖索引:查询列要被所建的索引覆盖,不必取数据行
  • 组合索引:多列值组成一个索引,用于组合搜索,效率大于索引合并

创建索引有什么原则

​ 这个情况有很多,不过都有一个大前提,就是表中的数据要超过10万以上,我们才会创建索引,并且添加索引的字段是查询比较频繁的字段,一般也是像作为查询条件,排序字段或分组的字段这些。

​ 还有就是,我们通常创建索引的时候都是使用复合索引来创建,一条sql的返回值,尽量使用覆盖索引,如果字段的区分度不高的话,我们也会把它放在组合索引后面的字段。

​ 如果某一个字段的内容较长,我们会考虑使用前缀索引来使用,当然并不是所有的字段都要添加索引,这个索引的数量也要控制,因为添加索引也会导致新增改的速度变慢。

  • 最左前缀匹配原则
  • 频繁作为查询条件的字段才去创建索引
  • 索引列不能参与计算,不能有函数操作
  • 频繁更新的字段不适合创建索引
  • 优先考虑扩展索引,而不是新建索引,避免不必要的索引
  • 在order by或者group by子句中,创建索引需要注意顺序
  • 区分度低的数据列不适合做索引列(比如性别)
  • 定义有外键的数据列一定要建立索引
  • 对于定义为text、image数据类型的类不要建立索引
  • 删除不在使用或者很少使用的索引

Mysql索引有哪些注意事项

索引那些情况会失效

  • 查询条件包含or,可能导致索引失效
  • 如果字段类型是字符串,where时一定要用引号括起来,否则索引失效
  • 模糊查询,如果%号在前面也会导致索引失效
  • 联合索引,查询时的条件列不是联合索引中的第一个列,索引失效(没遵循最左匹配法则)
  • 在索引列上使用mysql内置函数,索引失效
  • 对索引列运算(如+、-、*),索引失效
  • 索引字段上使用(!=或者<>,not in)时,可能会导致索引失效
  • 索引字段上使用is null,in not null,可能导致索引失效
  • 左连接查询或者右连接查询关联字段编码格式不一样,可能导致索引失效
  • mysql估计使用全表扫描要比索引快,则不使用索引

索引不适合那些场景

  • 数据量少的不适合加索引
  • 更新比较频繁的也不适合加索引
  • 区分度低的字段不适合加索引(比如性别)

索引的一些潜规则

  • 覆盖索引
  • 回表
  • 索引数据结构(B+树)
  • 最左前缀原则
  • 索引下推

Mysql遇到死锁问题吗,如何解决

  • 查看死锁日志show engine innodb status
  • 找出死锁sql
  • 分析sql加锁情况
  • 模拟死锁案发
  • 分析死锁日志
  • 分析死锁结果

如何优化sql

可以从如下几个维度回答

  1. 加索引
  2. 避免返回不必要的数据(SELECT语句务必指明字段名称,不要直接使用select * )
  3. 适当分批量进行
  4. 优化sql结构
    • 如果是聚合查询,尽量用union all代替union ,union会多一次过滤,效率比较低;
    • 如果是表关联的话,尽量使用innerjoin ,不要使用用left join right join,如必须使用 一定要以小表为驱动
  5. 分库分表
  6. 读写分离

说说分库与分表的设计

分库分表方案

  • 水平分库

    以字段为依据,按照一定策略(hash、range等),将一个库中的数据拆分到多个库中(适用于数据量太多)

  • 水平分表

    以字段为依据,按照一定策略(hash、range等),将一个表中的数据拆分到多个表中

  • 垂直分库

    以表为依据,按照业务归属不同,将不同的表拆分到不同的库中

  • 垂直分表

    以字段为依据,按照字段的活跃性,将表中字段拆到不同的表中(主表和扩展表)

常用分库分表中间件

  • sharding-jdbc(当当)
  • mycat
  • TDDL(淘宝)
  • Oceanus(58同城数据库中间件)
  • vitess(谷歌开发的数据库中间件)
  • Atlas(360)

分库分表可能遇到的问题

  • 事务问题:需要用分布式事务

  • 跨节点join问题:解决这一问题可以分两次查询实现

  • 跨节点的count,order by,group by以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并

  • 数据迁移,容量规划,扩容等问题

  • ID问题:数据库被切分后,不能在依赖数据库自身的主键生成机制,最简单的可以考虑UUID

  • 跨分片的排序分页问题(后台加大pagesize处理?)

InnoDB和MyISAM区别

  • InnoDB支持事务,MyISAM不支持事务
  • InnoDB支持外键,MyISAM不支持外键
  • InnoDB支持MVCC(多版本并发控制),MyISAM不支持
  • InnoDB支持表、行级锁,而MyISAM支持表级锁
  • InnoDB必须有主键,而MyISAM可以没有主键
  • InnoDB按主键大小有序插入,MyISAM按记录插入顺序保存

limit 1000000加载很慢,你怎么解决

  • 如果id是连续的,可以返回上次查询的最大记录(偏移量),在往下limit

    1
    select id,name from employee where id>1000000 limit 10
  • 在业务允许的情况下限制页数,因为绝大多数用户都不会往后翻太多页

  • order by +索引(id为索引)

    1
    select id,name from employee order by id limit 1000000,10
  • 利用延迟关联或者子查询优化超多分页场景(先快速定位需要获取的id段,然后在关联)

    1
    select a.* from employee a,(select id from employee where 条件 limit 1000000,10)b where a.id=b.id

如何选择合适的分布式主键方案

  • 数据库自增长序列或者字段
  • UUID
  • redis生成ID
  • Twitter的snowflake算法
  • 利用Zookeeper生成唯一ID
  • MOngoDB的ObjectId

事务的隔离级别有哪些,Mysql默认隔离级别是什么

  • 读未提交
  • 读提交
  • 可重复读
  • 串行化

Mysql的默认事务隔离级别是可重复读

什么是脏读、不可重复读、幻读

  • 事务A,B交替执行,事务A读取到了事务B未提交的数据,这就是脏读
  • 在一个事务范围内,两个相同的查询,读取同一条数据,却返回了不同的数据,这就是不可重复读
  • 事务A查询一个范围的结果集,另一个并发事务B往这个范围中插入/删除了数据,并提交了,然后事务A再次查询相同的范围,两次读取的到的结果集不一样了,这就是幻读

在高并发的情况下,如何做到安全的修改同一行数据

要安全的修改同一行数据,就要保证一个线程再修改时其他线程无法更新这行记录,一般有悲观锁和乐观锁两种方案

使用悲观锁

就是当前线程要进来修改数据时,别的线程都得拒之门外,比如使用select…..for update

1
select * from user where name='jay' fro update

以上这条sql语句会锁定user表中所有符合检索条件name=’jay’的记录,本次事务提交之前,别的线程都无法修改这些记录

使用乐观锁

有线程过来,先放过去修改,如果看到别的线程没修改过,就可以修改成功,如果别的线程修改过,就修改失败或者重试。实现方式:乐观锁一般会使用版本号机制或CAS算法实现。

SQL优化的一般步骤是什么,怎么看执行计划(explain)

  • show status命令了解各种sql的执行频率
  • 通过慢查询日志定位那些执行效率较低的sql语句
  • explain分析低效sql的执行计划

​ 背这个回答:如果一条sql执行很慢的话,我们通常会使用mysql自动的执行计划explain来去查看这条sql的执行情况。

​ 比如在这里面可以通过key和key_len检查是否命中了索引,如果本身已经添加了索引,也可以判断索引是否有失效的情况。

​ 第二个,可以通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描

​ 第三个可以通过extra建议来判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复

explain解释了它将如何处理该语句

  1. 表的加载顺序
  2. sql的查询类型
  3. 可能用到那些索引,那些索引又被实际使用
  4. 表与表之间的引用关系
  5. 一个表中有多少行被优化器查询

explain执行计划包含字段信息如下“

  1. id(表示查询中执行select子句或者操作表的顺序,id的值越大,代表优先级越高,越先执行)
    • id相同:可以理解成具有同样的优先级,执行顺序由上而下,具体顺序由优化器决定
    • id不同:如果sql中存在子查询,那么id序号会递增,id值越大优先级越高,越先被执行。当三个表依次嵌套,发现最里层的子查询id最大,最先执行
    • 以上两种同时存在,增加一个子查询,发现id以上两种同时存在。相同的id划分为一组,这样就有三个组,同组的从上往下顺序执行。不同组id值越大,优先级越高,越先执行
  2. select_type:表示select查询类型,主要用于区分各种复杂的查询,例如普通查询,联合查询,子查询等
    • simple:表示最简单的select查询语句
    • primary:当查询语句中包含任何复杂的子部分,最外层查询则被标记为primary
    • subquery:当select或where列表中包含子查询
    • derived:表示包含在from子句中的子查询的select
    • union:如果union后边又出现的select语句
    • union result:代表从union的临时表中读取数据
  3. table:查询的表明,并不一定是真实存在的表,也可能是临时表
  4. partitions:查询时匹配到的分区信息
  5. type:查询使用了何种类型,它在sql优化中是一个非常重要的指标,从好到坏的顺序==system > const > eq_ref > ref > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > all==
    • system:当表仅有一行记录时(系统表),数据量很少,往往不需要进行磁盘IO,速度非常快
    • const:表示查询时命中primary key主键或者unique唯一索引,或者被连接的部分是一个常量值(const)。这类扫描效率极高,返回数据量少,速度非常快
    • eq_ref:查询时命中主键primary_key或者unique_key索引
    • ref:区别eq_ref,ref表示使用非唯一性索引,会找到很多符合条件的行
    • ref_or_null:这种连接类型类似于ref,区别在于mysql会额外搜索包含null值的行
    • index_merge:使用了索引合并优化方法,查询使用了两个以上的索引
    • unique_subquery:替换下面的in子查询,子查询返回不重复的集合
    • index_subquery:区别unique_subquery,用于非唯一索引,可以返回重复值
    • range:使用索引选择行,仅检索给定范围内的行,简单的说就是针对一个有索引的字段,给定范围检索数据。在where语句中使用bettween..and、<、>、<=、in等条件查询type都是range
    • index:index与ALL其实都是读全表,区别在于index是遍历索引树读取,而all是从硬盘中读取
    • all:将遍历全表以找到匹配的行,性能最差
  6. possible_keys:表示在mysql中通过那些索引,能让我们在表中找到想要的记录,一旦查询涉及到的某个字段上存在索引,则索引将被列出。
  7. key:区别于possible_keys,key是查询中实际使用的索引,若没有使用索引,显示为NULL
  8. key_len:表示查询用到的索引长度,原则上长度越短越好
  9. ref
    • 当使用常量等值查询,显示const
    • 当关联查询时,会显示相应关联表的关联字段
    • 如果查询条件使用了表达式、函数、或者条件列发生内部隐式转换,可能显示为func
    • 其他情况null
  10. rows:以表的统计信息和索引使用情况,估计要找到它们所需的记录,需要读取的行数,这是评估sql想能的一个比较重要的数据,mysql需要扫描的行数,很直观的显示sql性能的好坏,一般rows值越小越好
  11. filtered:这个字段表示存储引擎返回的数据在经过过滤后,剩下满足条件的记录数量的比例
  12. Extra:不适合在其他列中显示的信息,很多额外的信息会在exrea字段显示

select for update有什么含义,会锁表还是锁行还是其他

select for update含义

select查询语句是不会加锁的,但是select for update除了有查询的作用外,还会加锁,而且还是悲观锁,至于加了是行锁还是表锁。这就要是看是不是用了索引/主键,没用索引/主键的话就是表锁,否则就是行锁

Mysql事务的四大特性以及实现原理

  • 原子性:事务作为一个整体被执行,包含在其中的对数据库的操作要么全部执行,要么都不执行
  • 一致性:指在事务开始之前和事务结束以后,数据不会被破坏,假如A账户给B账户转10块钱,不管成功与否,A和B的总金额是不变的
  • 隔离性:多个事务并发访问,事务之间是相互隔离的,即一个事务不影响其他事务运行效果,简而言之,就是事务之间是井水不犯河水
  • 持久性:表示事务完成以后,该事务对数据库所作的操作更改,将持久的保存在数据库之中

事务ACID特性的实现思想

  • 原子性:是使用undo log来实现的,如果事务执行过程中出错或者用户执行了rollback,系统通过undo log日志返回事务开始的状态
  • 持久性:使用redo log 来实现,只要redo log日志持久化了,当系统崩溃,即可通过redo log把数据恢复
  • 隔离性:通过锁以及mvvc,使事务相互隔离开
  • 一致性:通过回滚、恢复、以及并发情况下的隔离性,从而实现一致性。

如果某个表有仅千万数据,curd比较慢,如何优化

  • 分库分表
  • 索引优化

如何写sql能够有效的使用到复合索引

复合索引,也叫联合索引,用户可以在多个列上建立索引,当我们创建一个组合索引,如(k1,k2,k3),相当于创建了(k1)、(k1、k2)、(k1,k2,k3)三个索引,这就是最左匹配原则。我们需要关注查询sql条件的顺序,确保最左匹配原则有效,同时可以删除不必要的冗余索引

数据库自增可能遇到什么问题

  • 使用自增主键对数据库做分库分表,可能出现主键重复等问题,简单点可以考虑使用uuid
  • 自增主键会产生表锁,从而引发问题
  • 自增主键可能用完的问题

MVVC的底层原理

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

  • 表的隐藏列

    对于InnoDB存储引擎,每一行记录都有两个隐藏列trx_id,roll_point,如果表中没有主键和非null唯一键时,则还会有第三个隐藏的主键列row_id

    列名是否必须描述
    row_id单调递增的行id,不是必须的,占用6字节
    trx_id记录操作该数据事务的事务id
    roll_pointer这个隐藏列相当于一个指针,指向回滚段的undo日志
  • undo log

    回滚日志,用于记录数据被修改前的信息。在表记录修改前,会把数据拷贝到undo log里,在内部会形成一个版本链,在多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表

    undo用途

    1. 事务回滚时,保证原子性和一致性
    2. 用于MVCC快照读

    image-20220627174053669

  • read view

    ​ 它就是事务执行sql语句时,产生的读视图,在InnoDB中,每个sql语句执行前都会得到一个read view,用来判断当前事务可见那个版本的数据。

    ​ 不同的隔离级别快照读是不一样的,最终的访问的结果不一样。如果是rc隔离级别,每一次执行快照读时生成ReadView,如果是rr隔离级别仅在事务中第一次执行快照读时生成ReadView,后续复用

查询一条sql,基于MVVC的流程

  1. 获取事务自己的版本号,即事务id
  2. 获取read view
  3. 查询得到的数据,然后和read view中的事务版本号进行比较
  4. 如果不符合read view的可见性规则,即就需要undo log中历史快照
  5. 最后返回符合规则的数据

==InnoDB 实现MVCC,是通过Read View+ Undo Log 实现的,Undo Log 保存了历史快照,Read View可见性规则帮助判断当前版本的数据是否可见。==

MySql的主从延迟,怎么解决

主从复制的步骤

  1. 主库的更新操作(update、insert、delete)被写到binlog
  2. 从库发起连接,连接到主库
  3. 此时主库创建一个binlog dump thread,把binlog的内容发送到从库
  4. 从库启动后,创建一个IO线程。读取主库传过来的binlog内容并写入到relay log
  5. 还会创建一个sql线程,从relay log里面读取内容,从Exec_Master_Log_Pos位置开始执行读取到的更新事件,将更新内容写入到slave的db

主从同步延迟的原因

一个服务器开放N个链接给客户端来连接的,这样会有大并发的更新操作,但是从服务器的里面读取binlog的线程仅有一个,当某个sql在从服务器上执行的时间稍长,就会导致主服务器的sql大量积压,未被同步到从服务器,这样就到导致了主从不一致。也就是主从延迟

主从同步延迟的解决办法

  • 主服务器要负责更新操作,对安全性的要求比从服务器高,所以有些设置参数可以修改,比如sync_binlog=1等
  • 选择更好的硬件设备作为备份服务器
  • 把一台从服务器当做为备份使用,而不提供查询,那他的负载就下来了,执行relay log里面的sql效率就高了起来
  • 增加从服务器,这个目的还是分散读的压力,从而降低服务器负载

说说大表查询的优化方案

  • sql+索引
  • 可以考加缓存、redis或memcached
  • 主从复制,读写分离
  • 分库分表

InnoDB索引策略

  • 覆盖索引

    只需要在一棵索引树上就能获取sql所需的所有列数据,无需回表,速度更快

  • 最左前缀原则

  • 索引下推

    可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数

一条sql执行时间过长,如何优化

  • 查看是否涉及多表和子查询,优化sql结构,比如去除冗余字段,是否可拆表等
  • 优化索引结构,看是否可以适当的添加索引
  • 数据量大的表,可以考虑进行分表
  • 数据库主从分离,读写分离
  • explain分析sql语句,查看执行计划,优化sql

Blob和text的区别

  • Blob用于存储二进制数据,而Text用于存储大字符串
  • Blob值被视为二进制字符串(字节字符串),它们没有字符集,并且排序和比较基于列值中的字节的数值
  • text值被视为非二进制字符串(字符字符串)。它们有一个字符集,并根据字符集的排序规则对值进行排序和比较

Mysql锁

image-20220627181319097

按锁的粒度分:

  1. 表锁:开销小,锁定粒度大,发生锁冲突概率高,并发度最低;不会出现死锁
  2. 行锁:开销大,加锁慢,会出现死锁,锁定粒度小,发生锁冲突的概率低,并发读高
  3. 页锁:开销和加锁速度介于表锁和行锁之间,会出现死锁,锁定粒度介于表锁和行锁之间,并发度一般

Hash索引和B+树区别是什么?如何选择

  • B+树可以进行范围查询,Hash索引不能
  • B+树支持联合索引的最左侧原则,Hash索引不支持
  • B+树支持order by排序,Hash索引不支持
  • Hash索引在等值查询上比B+树效率更高
  • B+树使用like进行模糊查询的时候,like后面(比如%开头)的话可以起到优化作用,而Hash索引根本无法进行模糊查询

Mysql的内连接、做连接、右连接

  • inner join:在两张表进行连接查询时,只保留两张表中完全匹配的结果集
  • left join:在两张表进行连接查询时,会返回左表所有的行,即使在右表中没有匹配的记录
  • right join:在两张表进行连接查询时,会返回右表所有的行,即使在坐标中没有匹配的记录

数据库三大范式

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

Mysql的binlog有几种录入格式

  • statement:记录的是sql的原文。不需要记录每一行的变化,减少了binlog日志量,太高了性能
  • row,不记录sql语句上下文相关关系,仅保存那条记录被修改。记录单元为每一行的改动,基本是可以全部记下来,但是由于很多批量操作会导致当量行的改动,因此这种模式的文件保存的信息太多。
  • mixed:一种折中的方案,普通操作使用statement记录,当无法使用statement的时候使用row

百万级别或以上数据如何删除

  • 可以先删除索引
  • 然后批量删除其中无用数据
  • 删除完成后重新创建索引

覆盖索引、回表是什么

  • 覆盖索引:查询列要被所建的索引覆盖,不必从数据表中读取,换取话说查询列要被所使用的的索引覆盖
  • 回表:二级索引无法直接查询所有列的数据,所以通过二级索引查询到聚簇索引后,再查询到想到的数据,这种通过二级索引查询出来的过程,叫做回表

InnoDB锁

什么是死锁,怎么解决

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方的资源,从而导致恶性循环的现象

死锁有四个必要条件:互斥条件、请求和保持条件、环路等待条件、不剥夺条件

解决死锁思路:一般就是切断环路,尽量避免并发形成环路

  • 如果不同程序会并发存取多个表,尽量约定以相同的顺序访问表,可以降低死锁机会
  • 在同一个事务中,尽可能做到一次锁定所需要的的所有资源,减少死锁产生的概率
  • 对于容易产生死锁的业务部分,可以尝试使用升级锁定颗粒度,通过表级锁定来减少死锁产生的概率
  • 如果业务处理不好可以用分布式事务锁或者乐观锁
  • 死锁与索引密不可分,解决索引问题,需要合理优化索引

什么是存储过程,优缺点是什么

存储过程

就是一些编译好了的sql语句,这些sql语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后给这些代码块取一个名字,在用到这个功能的时候调用即可

优点

  • 存储过程是一个预编译的代码块,执行效率比较高
  • 存储过程在服务器端运行,减少客户端的压力
  • 可以一定程度确保数据安全性
  • 允许模块化程序设计,只需要创建一次过程,以后在程序中就可以调用该过程任意次,类似方法的复用

缺点

  • 调试麻烦
  • 可移植性不灵活
  • 重新编译问题

varchar(50)中50的含义

  • 字段最多存放50个字符
  • 如varchar(50)和varchar(200)存储“jay”字符串所占空间是一样的,后者在排序时会消耗更多内存

mysql中int(20)、char(20)、varchar(20)的区别

  • int(20)表示字段是int类型,显示长度是20
  • char(20)表示字段是固定长度字符串,长度为20
  • varchar(20)表示字段是可变长度字符串,长度为20

union与union all的区别

  • union:对两个结果集进行并集操作,不包括重复行,同时进行默认规则的排序
  • union all:对两个结果集进行并集操作,包括重复行,不进行排序
  • union的效率高于union all

undo log 和redo log的区别

  • redo log日志记录的是数据页的物理变化,服务宕机可用来同步数据,

  • undo log ,它主要记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据,比如我们删除一条数据的时候,就会在undo log日志文件中新增一条delete语句,如果发生回滚就执行逆操作;

  • redo log保证了事务的持久性,undo log保证了事务的原子性和一致性

Tomcat

Tomcat的缺省端口是多少,这么去修改?

  1. 找到Tomcat目录下的config文件夹

  2. 进入conf文件夹里面找到server.xml文件

  3. 打开server.xml文件

  4. 在文件找到下列信息

    1
    <Connector connectionTimeout="20000" port="8080" protocol="HTTP/1/1" redirectPort="8443" uriEncoding="utf-8"></Connector>

    port改成你想要的端口

tomcat有哪几种Connector运行模式(优化)

  1. bio:传统的java i/o操作,同步且阻塞io

  2. maxThreads=“150”

    tomcat使用线程来处理接收的每个请求,这个值表示tomcat可创建的最大线程数,默认值200,可以根据机器的性能和大小调整,一般可以在400-500.最大可以在800左右

  3. minSpareThreads=”25”

    tomcat初始化时创建的线程数,默认值4.如果当前没有空闲线程,且没有超过maxThreads,一次性创建的空闲线程数量,tomcat初始化创建的线程数量也由此值设置

  4. maxSpareThreads=“75”

    一旦创建的线程超过这个值,tomcat就会关闭不在需要的socket线程,默认值50.线程数大致上用同时在线人数每秒用户操作次数系统平均操作时间来计算

  5. acceptCount=”100”

    指当前可以使用的处理请求的线程都被使用时,可以放到处理队列的请求数,超过这个数的请求不予处理,默认值10.

  6. connectionTimeout=”20000”

    网络连接超时,默认值20000,单位毫秒,设置为0时表示永不超时,这样设置有隐患的,通常可设置为30000毫秒

  7. nio

    指定使用NIO模型来接受HTTP请求,同步阻塞或同步非阻塞IO

  8. protocol=”org.apache.aoyote.http11.Http11NioProtocol”

    指定使用NIO模型来接受HTTP请求,默认是BlockingIO,配置为protocol=”HTTP/1.1”

  9. acceptorThreadCount=”2”

    使用NIO模型接受线程的数目

  10. aio

    jdk7开始支持,异步非阻塞IO

  11. apr

    tomcat将以JNI形式调用Apache,htto服务器的核心动态链接库来处理文件读取或网络传输操作,从而大大地提高tomcat对静态文件的处理性能

Tomcat有几种部署方式

  1. 直接把web项目放在webapps下,Tomcat会自动将其部署
  2. 在server.xml文件上配置节点,设置相关属性即可
  3. 通过Catalina来进行配置,进入到config\Catalina\localhost文件下,创建一个xml文件,该文件的名字就是站点的名字。

Tomcat如何优化

  1. 优化连接配置,需要修改conf/server.xml文件,修改连接数,关闭客户端dns查询

    参数解释

    • URIEncoding=”utf-8”:使得tomcat可以解析含有中文名的url
    • maxSpareThreads:如果空闲状态的线程数多于设置的数目,则将这些线程终止,减少这个池中的线程总数
    • minSpareThreads:最小备用线程数,tomcat启动时的初始化的线程数
    • enableLookups:设置为关闭
    • connectionTimeout:connectionTimeout为网络连接超时时间毫秒数
    • maxThreads:tomcat使用线程来处理接收的每个请求,这个值表示Tomcat可创建的最大的线程数,即最大并发数
    • acceptCount:当线程数达到maxThreads后,后续请求会被放入一个等待队列,这个acceptCount是这个对列的大小,如果这个对列也满了,就直接refuse connection
    • maxProcessors 与 minProcessors :在java中线程是程序运行时的路径,是在一个程序中与其他控制线程无关的,能够独立运行的代码段,它们共享相同的地址空间。多线程帮助程序员写出cpu最大利用效率的高效程序,使空闲时间保持最低,从而接受更多的请求。通常windows是1000个左右,Linux是2000个左右
    • userURIValidationHack:如果把 useURIValidationHack 设成”false”,可以减少它对一些 url的不必要的检查从而减省开销。
    • enableLookups=”false”:为了消除DNS查询对性能的影响我们可以关闭DNS查询,把值改为false
    • disableUploadTimeout:类似于Apache中的keeyalive一样
    • compression=”on”:打开压缩功能

内存调优

内存方式的设置是在catalina.sh中,调整JAVA_OPTS变量既可,因为后面的启动参数会把JAVA_OPTS作为JVM的启动参数来处理

具体设置如下:

JAVA_OPTS=”$JAVA_OPTS -Xmx3500m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4”

  1. -Xmx3550m:设置JVM最大可用内存为3550M

  2. -Xms3550m:设置JVM初始内存为3550M,此值可以设置与-Xmx相同,以避免每次垃圾回收后JVM重新分配内存

  3. -Xmn2g:设置年轻代大小为2G。整个堆大小=年轻代大小+老年代大小+持久代大小。持久代大小一般固定位64m,所以增大年轻代后,将会减少老年代大小,推荐配置为整个堆的3/8

  4. -Xss128k:设置每个线程的堆栈大小。

  5. -XX:NewRatio=4:设置年轻代与老年代的比值,则年轻代与老年代比值为1:4,年轻代占整个堆的1/5

  6. -XX:SurvivorRatio=4:设置年轻代中Eden区与Surivior区的大小比值。设置为4,则两个survive区与一个eden区比值为2:4,一个survivor区占整个年轻代的1/6

  7. -XX:MaxPermSize=16:设置持久代大小为16m

  8. -XX:MaxTenuringThreshold=0;设置垃圾最大年龄,如果设置为0的话,则年轻代对象不经过Survivor去,直接进入老年代

垃圾回收策略调优

垃圾回收的设置也是在 catalina.sh 中,调整 JAVA_OPTS 变量。

  • 串行收集器(JDk5以前的主要回收方式)

    -XX:+UseSerialGC:设置串行收集器

  • 并行收集器(吞吐量优先)

  • 还有许多,相当于配置jvm垃圾回收器

共享Session处理

  1. 使用Tomcat本身的session复制功能

    缺点是:当集群数量较多时,session复制的时间会比较长,影响响应的效率

  2. 使用第三方来存放共享的session

    目前用的比较多的是memcached,借助于memcached-session-manager来进行Tomcat的session管理

  3. 使用黏性session的策略

    对于会话要求不太强(不涉及到计费,失败了允许重新请求)的场合,同一个session可以用Nginx或者apache交个同一个Tomcat处理。

    优点是处理效率高很多,缺点是强会话要求的场合不合适

Tomcat工作模式

Tomcat是一个jsp/servlet容器。其作为Servlet容器,有三种工作模式:独立的Servlet容器、进程内的Servlet容器和进程外的Servlet容器

进入Tomcat的请求可以根据Tomcat的工作模式分为如下两类:

Tomca作为应用程序服务器:请求来自前端的web服务器,这可能是Apache。Nginx等

Tomcat作为独立服务器:请求来自web浏览器

JVM

https://www.processon.com/mindmap/629b77a17d9c08070f9854e7a

JVM组成

JVM由那些部分组成,运行流程是什么

在JVM中共有四大部分,分别是

  1. ClassLoader(类加载器)
  2. Runtime Data Area(运行时数据区,内存分区)
  3. Execution Engine(执行引擎)
  4. Native Method Library(本地库接口)

它的运行流程是:

  1. 类加载器(ClassLoader)把Java代码转换为字节码
  2. 运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行
  3. 执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能

JVM运行时数据区

​ 运行时数据区包含了堆、方法区、栈、本地方法栈、程序计数器这几部分,每个功能作用不一样。

  • 堆解决的是对象实例存储的问题,垃圾回收器管理的主要区域。
  • 方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。
  • 栈解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息。
  • 本地方法栈与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码的接口。
  • 程序计数器(PC寄存器)程序计数器中存放的是当前线程所执行的字节码的行数。JVM工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。

程序计数器作用

​ java虚拟机对于多线程是通过线程轮流切换并且分配线程执行时间。在任何的一个时间点上,一个处理器只会处理执行一个线程。

​ 如果当前被执行的这个线程它所分配的执行时间用完了【挂起】。处理器会切换到另外的一个线程上来进行执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。

​ 这时候程序计数器就起到了关键作用,程序计数器在来回切换的线程中记录他上一次执行的行号,然后接着继续向下执行。

JAVA堆

​ Java中的堆术语线程共享的区域。主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

在JAVA8中堆内会存在年轻代、老年代

  1. Young区被划分为三部分,Eden区(伊甸园区)和两个大小严格相同的Survivor区(幸存区),其中,Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用。在Eden区变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到Tenured区间(老年代)。
  2. Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区。

解释下方法区

​ 与虚拟机栈类似。本地方法栈是为虚拟机执行本地方法时提供服务的。不需要进行GC。本地方法一般是由其他语言编写。

你听过直接内存吗

​ 它又叫做堆外内存线程共享的区域,在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果大量动态生成类(将类信息放入永久代),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。

​ 所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能。

什么是虚拟机栈

​ 虚拟机栈是描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢。保存执行方法时的局部变量、动态连接信息、方法返回地址信息等等。方法开始执行的时候会进栈,方法执行完会出栈【相当于清空了数据】,所以这块区域不需要进行 GC

堆栈的区别

  1. 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
  2. 栈内存是线程私有的,而堆内存是线程共有的。
  3. 两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
    • 栈空间不足:java.lang.StackOverFlowError。
    • 堆空间不足:java.lang.OutOfMemoryError。

类加载器

什么是类加载器,类加载器有哪些

​ JVM只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。

常见的类加载器有4个

  1. 启动类加载器(BootStrap ClassLoader):其是由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库
  2. 扩展类加载器(ExtClassLoader):该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。
  3. 应用类加载器(AppClassLoader):该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类。
  4. 自定义类加载器:开发者自定义类继承ClassLoader,实现自定义类加载规则。

类装载的执行过程

​ 类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)

  1. 加载:查找和导入class文件

  2. 验证:保证加载类的准确性

  3. 准备:为类变量分配内存并设置类变量初始值

  4. 解析:把类中的符号引用转换为直接引用

  5. 初始化:对类的静态变量,静态代码块执行初始化操作

  6. 使用:JVM 开始从入口方法开始执行用户的程序代码

  7. 卸载:当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存

双亲委派机制

​ 如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传说到顶层的启动类加载器中,只有当父类加载器返回自己无法完成这个加载请求(它的搜索返回中没有找到所需的类)时,子类加载器才会尝试自己去加载

采用原因
  1. 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
  2. 为了安全,保证类库API不会被修改

垃圾回收

Java垃圾回收机制

​ 为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC(Garbage Collection)。

​ 有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。

​ 在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机

强引用、软引用、弱引用、虚引用的区别

强引用

​ 最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收

软引用

​ 表示一个对象处于有用且非必须状态,如果一个对象处于软引用,在内存空间足够的情况下,GC机制并不会回收它,而在内存空间不足时,则会在OOM异常出现之间对其进行回收。但值得注意的是,因为GC线程优先级较低,软引用并不会立即被回收。

弱引用

​ 表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。同样的,因为GC线程优先级较低,所以弱引用也并不是会被立刻回收。

虚引用

​ 表示一个对象处于无用的状态。在任何时候都有可能被垃圾回收。虚引用的使用必须和引用队列Reference Queue联合使用

对象什么时候可以被垃圾器回收

​ 如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。

​ 如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法

​ 通常都使用可达性分析算法来确定是不是垃圾

引用计数法

​ 一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收,当对象间出现了循环引用的话,则引用计数法就会失效。

可达性分析法

​ 会存在一个根节点【GC Roots】,引出它下面指向的下一个节点,再以下一个节点节点开始找出它下面的节点,依次往下类推。直到所有的节点全部遍历完毕。

​ 判断某对象是否与根对象有直接或间接的引用,如果没有被引用,则可以当做垃圾回收

​ 可以当做根对象的有以下几种(肯定不能当做垃圾回收的对象)

  • 局部变量
  • 静态方法
  • 静态变量
  • 类信息

JVM 垃圾回收算法

​ 一共有四种,分别是标记清除算法、复制算法、标记整理算法、分代回收

标记清除算法

标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除

  1. 根据可达性分析算法得出的垃圾,进行标记
  2. 对这些标记为可回收的内容进行垃圾回收

缺点:

  • 效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序
  • 清理出来的内存碎片化严重
复制算法

​ 复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾回收。

​ 如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。

优点:

  • 在垃圾对象多的情况下,效率较高
  • 清理后,内存无碎片

缺点:

​ 分配的2块内存空间,在同一时刻,只能使用一半,内存使用率较低

标记整理算法

​ 标记整理算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题。

  1. 标记垃圾
  2. 需要清除向右边走,不需要清除向左边走
  3. 清除边界以外的垃圾

优点:

  1. 解决标记清除算法的碎片化严重问题

缺点:

  1. 标记整理算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。

分代收集算法

​ 在java8时,堆被分为了两份:新生代和老年代,它们默认空间占用比例是1:2

​ 对于新生代,内部又被分为了三个区域。Eden区,S0区,S1区默认空间占用比例是8:1:1

具体的工作机制是有些情况:

  1. 当创建一个对象的时候,那么这个对象会被分配在新生代的Eden区。当Eden区要满了时候,触发YoungGC。
  2. 当进行YoungGC后,此时在Eden区存活的对象被移动到S0区,并且当前对象的年龄会加1,清空Eden区。
  3. 当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S0中的对象,移动到S1区中,这些对象的年龄会加1,清空Eden区和S0区。
  4. 当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S1中的对象,移动到S0区中,这些对象的年龄会加1,清空Eden区和S1区。
  5. 对象的年龄达到了某一个限定的值(默认15岁 ),那么这个对象就会进入到老年代中。
  6. 当然也有特殊情况,如果进入Eden区的是一个大对象,在触发YoungGC的时候,会直接存放到老年代
  7. 当老年代满了之后,触发FullGCFullGC同时回收新生代和老年代,当前只会存在一个FullGC的线程进行执行,其他的线程全部会被挂起。 我们在程序中要尽量避免FullGC的出现。

新生代、老年代、永久代的区别

  1. 新生代主要用来存放新生的对象。

  2. 老年代主要存放应用中生命周期长的内存对象。

  3. 永久代指的是永久保存区域。主要存放Class和Meta(元数据)的信息。在Java8中,永久代已经被移除,取而代之的是一个称之为“元数据区”(元空间)的区域。元空间和永久代类似,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存的限制。

JVM垃圾回收器

​ 在jvm中,实现了多种垃圾收集器,包括:串行垃圾收集器、并行垃圾收集器(JDK8默认)、CMS(并发)垃圾收集器、G1垃圾收集器(JDK9默认)

串行垃圾收集器

Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑

  • Serial作用于新生代,采用复制算法
  • Serial Old作用于老年代,采用标记-整理算法

垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成

image-20230506154006266

并行垃圾收集器

Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器

  • Parallel New作用于新生代,采用复制算法
  • Parallel Old作用于老年代,采用标记整理算法

垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成

image-20230506154042673

CMS(并发)垃圾收集器

MS全称Concurrent Mark Sweep。是一款并发的、使用标记清除算法的垃圾回收器。

该回收器是针对老年代垃圾进行回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。

其最大的特点就是在进行垃圾回收时,应用仍然能正常运行。

image-20230506154117857

最后需要重新标记的原因是:有可能会出现新的关联节点,所以需要重新标记

G1垃圾回收器
  • 应用于新生代和老年代,在jdk9之后默认使用G1
  • 划分成多个区域,每个区域都可以充当eden,survivor,old,humongous,其中humongous专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段
    • 新生代回收
    • 并发标记
    • 混合收集
  • 如果并发失败(即回收速度赶不上创建新对象的速度),就会出发Full GC
Young Collection(年轻代垃圾回收)
  • 初始时,所有区域都处于空闲状态

    image-20230506154542687

  • 创建了一些对象,挑出一些空闲区作为伊甸园区存储这些对象

    image-20230506154607558

  • 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程

    image-20230506154633118

    image-20230506154705088

  • 随着时间流逝,伊甸园的内存又有不足

  • 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老的对象晋升为老年代

    image-20230506154759809

    image-20230506154826981

    image-20230506154859985

Young Collection + Concurrent Mark(年轻代垃圾回收+并发标记)
  • 当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程

    image-20230506155000503

  • 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程

  • 这些都完成后就知道了老年代有哪些存活对象,随后进行混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是Gabage First名称的由来)

    image-20230506155047765

Mixed Collection (混合垃圾回收)

复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集

image-20230506155116267

其中H叫做巨型对象,如果对象非常大,会开辟一块连续的空间存储巨型对象

image-20230506155146370

Minor GC、Major GC、Full GC是什么

​ 它们指的是不同代之间的垃圾回收

  1. Minor GC 发生在新生代的垃圾回收,暂停时间短

  2. Major GC 老年代区域的垃圾回收,老年代空间不足时,会先尝试触发Minor GC。Minor GC之后空间还不足,则会触发Major GC,Major GC速度比较慢,暂停时间长

  3. Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免

JVM调优

JVM 调优的参数可以在哪里设置参数值

​ 我们当时的项目是springboot项目,可以在项目启动的时候,java -jar中加入参数就行了

JVM 调优的参数都有哪些

  1. 当时我们设置过堆的大小,像-Xms和-Xmx

  2. 还有就是可以设置年轻代中Eden区和两个Survivor区的大小比例

  3. 还有就是可以设置使用哪种垃圾回收器等等。具体的指令还真记不太清楚。

调试 JVM都用了哪些工具

一般都是使用jdk自带的一些工具,比如

  1. jps 输出JVM中运行的进程状态信息

  2. jstack查看java进程内线程的堆栈信息。

  3. jmap 用于生成堆转存快照

  4. jstat用于JVM统计监测工具

  5. 还有一些可视化工具,像jconsole和VisualVM等

产生了java内存泄露,你说一下你的排查思路

  1. 可以通过jmap指定打印他的内存快照 dump文件,不过有的情况打印不了,我们会设置vm参数让程序自动生成dump文件
  2. 可以通过工具去分析 dump文件,jdk自带的VisualVM就可以分析
  3. 通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
  4. 找到对应的代码,通过阅读上下文的情况,进行修复即可

服务器CPU持续飙高,你的排查方案与思路?

  1. 可以使用使用top命令查看占用cpu的情况
  2. 通过top命令查看后,可以查看是哪一个进程占用cpu较高,记录这个进程id
  3. 可以通过ps 查看当前进程中的线程信息,看看哪个线程的cpu占用较高
  4. 可以jstack命令打印进行的id,找到这个线程,就可以进一步定位问题代码的行号

设计模式

工厂模式

需求:设计一个咖啡店点餐系统。

​ 设计一个咖啡类(Coffee),并定义两个子类美式咖啡和拿铁咖啡,在设计一个咖啡店类,咖啡店具有点咖啡的功能。

具体类设计如下:

简单工厂

上图中的符号的含义:

  • +:表示public

  • -:表示private

  • #:表示protected

  • 泛化关系(继承extends)用带空心三角箭头的实线来表示

  • 实现关系(实现implement)用带空心三角箭头的虚线表示

  • 依赖关系使用带箭头的虚线来表示

根据上图含义可以得出以下代码:

  1. 一个Coffee接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * @Title: Coffee
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 9:54
    * @description: 咖啡接口
    */
    public interface Coffee {

    //获取咖啡名称
    String getName();

    //添加牛奶方法
    void addMilk();

    //添加糖
    void addSugar();
    }
  2. 美式咖啡和拿铁咖啡类去实现咖啡接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    package com.zhuixun.test.designPatterns;

    /**
    * @Title: LatteCoffee
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 10:28
    * @description: 拿铁咖啡类
    */
    public class LatteCoffee implements Coffee{
    @Override
    public String getName() {
    return "拿铁咖啡";
    }

    @Override
    public void addMilk() {
    System.out.println("拿铁咖啡添加牛奶");
    }

    @Override
    public void addSugar() {
    System.out.println("拿铁咖啡添加糖");
    }
    }


    package com.zhuixun.test.designPatterns;

    /**
    * @Title: AmericanCoffee
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 9:53
    * @description: 美式咖啡类
    */
    public class AmericanCoffee implements Coffee{

    @Override
    public String getName() {
    return "美式咖啡";
    }

    @Override
    public void addMilk() {
    System.out.println("美式咖啡添加牛奶");
    }

    @Override
    public void addSugar() {
    System.out.println("美式咖啡添加糖");
    }

    }

  3. 咖啡店类去点咖啡

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    package com.zhuixun.test.designPatterns;

    /**
    * @Title: CoffeeStore
    * @Author huan
    * @Package com.zhuixun.test.designPatterns
    * @Date 2024/3/11 10:29
    * @description: 咖啡店类
    */
    public class CoffeeStore {

    public static void main(String[] args) {
    createCoffee("拿铁");
    }

    public static Coffee createCoffee(String coffeeType){
    Coffee coffee = null;
    if (coffeeType.equals("拿铁")){
    coffee = new LatteCoffee();
    String name = coffee.getName();
    System.out.println("创建"+name+"成功");
    }else if(coffeeType.equals("美式")){
    coffee = new AmericanCoffee();
    String name = coffee.getName();
    System.out.println("创建"+name+"成功");
    }
    //添加配料
    coffee.addMilk();
    coffee.addSugar();
    return coffee;
    }

    }

​ 在java中,万物皆对象,这些对象都需要创建,如果创建的时候,直接new该对象,就会对该对象耦合严重,假如我们要更换对象,所有new对象的地方都需要修改一遍,这显然违背了软件设计的开闭原则

[^开闭原则:对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修原有代码,实现一个热插拔的效果,简而言之,是为了使程序的扩展性好,易于维护和升级。]:

​ 如果我们使用工厂来生产创建对象,我们就只和工厂打交道就可以了,彻底和对象解耦,如果需要更换对象,直接在工厂里面更换该对象即可,达到了与对象解耦的目的,所以说工厂模式最大的优点是解耦

存在以下三种工厂:

  • 简单工厂模式
  • 工厂方法模式
  • 抽象工厂模式

简单工厂模式

简单工厂不是一种设计模式,反而比较像一种编程习惯

结构

简单工厂包含如下角色:

  • 抽象产品

    定义了产品的规范,描述了产品的主要特征和功能

  • 具体产品

    实现或者继承抽象产品的子类

  • 具体工厂

    提供了创建产品的方法,调用这通过该方法来获取产品

实现

现在使用简单工厂对上面案例进行改进:

image-20230521102022928

工厂代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @Title: CoffeeFactory
* @Author huan
* @Package com.zhuixun.test.designPatterns
* @Date 2024/3/11 11:06
* @description: 咖啡工厂类
*/
public class CoffeeFactory {
public static Coffee createCoffee(String coffeeType){
Coffee coffee = null;
if (coffeeType.equals("拿铁")){
coffee = new LatteCoffee();
String name = coffee.getName();
System.out.println("创建"+name+"成功");
}else if(coffeeType.equals("美式")){
coffee = new AmericanCoffee();
String name = coffee.getName();
System.out.println("创建"+name+"成功");
}
return coffee;
}
}

咖啡店代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.zhuixun.test.designPatterns;

/**
* @Title: CoffeeStore
* @Author huan
* @Package com.zhuixun.test.designPatterns
* @Date 2024/3/11 10:29
* @description: 咖啡店类
*/
public class CoffeeStore {

public static void main(String[] args) {
orderCoffee("美式");
}


//订单咖啡
public static void orderCoffee(String type){
//通过工厂获得对象,不需要知道对象实现的细节
CoffeeFactory coffeeFactory = new CoffeeFactory();
Coffee coffee = coffeeFactory.createCoffee(type);
coffee.addMilk();
coffee.addSugar();
}
}

​ 工厂处理创建对象的细节,一旦有了coffeeFactoy,CoffeeStore类中的orderCoffee()就变成此对象的客户,后期如果需要Coffee对象直接从工厂中获取即可,这样也就解除了和Coffee实现类的耦合,同时又产生了新的耦合CoffeeStore和CoffeeFactory工厂对象的耦合,工厂对象和商品对象的耦合。

​ 后期如果再加新品种的咖啡,我们势必要修改CoffeeFactory的代码,违反了开闭原则。

优点

​ 封装了创建对象的过程,可以通过参数直接获取对象,把对象的创建和业务逻辑层分开,这样以后就避免了修改客户代码,如果要实现新产品直接修改工厂类,而不需要在原代码中修改,这样就降低了客户代码修改的可能性,更加容易扩展

缺点

​ 增加新产品时还是需要修改工厂类的代码,违反了开闭原则

工厂方法模式

定义一个用于创建对象的接口,让子类决定实例化哪个产品类对象。工厂方法使一个产品类的实例化延迟到其他工厂的子类

结构

工厂方法模式的主要角色:

  • 抽象工厂(Abstract Factory)

    提供了创建产品的接口,调用者通过它访问具体工厂的工厂方法来创建产品

  • 具体工厂(ConcreteFactory)

    主要是实现抽象工厂中的抽象方法,完成具体产品的创建

  • 抽象产品(Product)

    定义了产品的规范,描述了产品的主要特性和功能

  • 具体产品(ConcreteProduct)

    实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间一一对应

实现

使用工厂方法模式对上例进行改进,类图如下:

image-20230521102122950

流程:

image-20230521102156863

代码如下:

抽象工厂:

1
2
3
public interface CoffeeFactory {
Coffee createCoffee();
}

具体工厂:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class LatteCoffeeFactory implements CoffeeFactory {

public Coffee createCoffee() {
return new LatteCoffee();
}
}

public class AmericanCoffeeFactory implements CoffeeFactory {

public Coffee createCoffee() {
return new AmericanCoffee();
}
}

咖啡店类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class CoffeeStore{
//成员变量
private CoffeeFactory coffeeFactory;
//构造方法
public CoffeeStore(coffeeFactory){
this.coffeeFactory = coffeeFactory;
}


//咖啡订单
public Coffee orderCoffee(){
Coffee coffee = coffeeFactory.createCoffee();
//添加配料
coffee.addMilk();
coffee.addSugar();
retutn coffee;
}

public static void main(String[] args){
//可以根据不同的工厂,创建不同的产品
CoffeeStore coffeeStore = new CoffeeStore(new LatteCoffeeFactory());
Coffee latte = coffeeStore.orderCoffee();
System.out.println(latte,getName());
}
}

从以上的编写的代码可以看到,要增加产品类时也要相应地增加工厂类,不需要修改工厂类的代码了,这样就解决了简单工厂模式的缺点。

工厂方法模式是简单工厂模式的进一步抽象。由于使用了多态性,工厂方法模式保持了简单工厂模式的优点,而且克服了它的缺点。

优点
  • 用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程;
  • 在系统增加新的产品时只需要添加具体产品类和对应的具体工厂类,无须对原工厂进行任何修改,满足开闭原则;
缺点
  • 每增加一个产品就要增加一个具体产品类和一个对应的具体工厂类,这增加了系统的复杂度。

抽象工厂模式

前面介绍的工厂方法模式中考虑的是一类产品的生产,如畜牧场只养动物、电视机厂只生产电视机等

​ 同种类产品称为同等级产品,也就是说:工厂方法模式只考虑生产同等级的产品。

​ 在现实生活中许多工厂是综合型的工厂,能生产多等级(种类) 的产品,如电器厂既生产电视机又生产洗衣机或空调,大学既有软件专业又有生物专业等。

​ 抽象工厂模式将考虑多等级产品的生产,将同一个具体工厂所生产的位于不同等级的一组产品称为一个产品族,下图所示

  • 产品族:一个品牌下面的所有产品;例如华为下面的电脑、手机称为华为的产品族;
  • 产品等级:多个品牌下面的同种产品;例如华为和小米都有手机电脑为一个产品等级;

image-20220913115948157

概念

​ 是一种为访问类提供一个创建一组组或相关依赖对象的接口,且访问类无需指定所要产品的具体类就能得到同族的不同等级的产品的模式结构

​ 抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产一个等级的产品,而抽象工厂模式可以生产多个等级的产品

一个超级工厂创建其他工厂。该超级工厂又被称为其他工厂的工厂

结构

抽象工厂模式的主要角色如下:

  • 抽象工厂(Abstract Factory)

    提供了创建产品的接口,它包含多个创建产品的方法,可以创建多个不同等级的产品

  • 具体工厂(Concrete Factory)

    主要是实现抽象工厂中的多个抽象方法,完成具体产品的创建

  • 抽象产品(Product)

    定义了产品的规范,描述了产品的主要特性和功能,抽象工厂模式有多个抽象产品

  • 具体产品(ConcreteProduct)

    实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间是多对一的关系

实现

现在咖啡店业务发生改变,不仅要生产咖啡还要生产甜点

  • 同一个产品等级(产品分类)
    • 咖啡:拿铁咖啡、美式咖啡
    • 甜点:提拉米苏、抹茶慕斯
  • 同一个风味,就是同一个产品族(相当于同一个品牌)
    • 美式风味:美式咖啡、抹茶慕斯
    • 意大利风味:拿铁咖啡、提拉米苏

要是按照工厂方法模式,需要定义提拉米苏类、抹茶慕斯类、提拉米苏工厂、抹茶慕斯工厂、甜点工厂类,很容易发生爆炸情况。

所以这个案例可以使用抽象工厂模式实现,类图如下:

image-20230521102319997

整体调整思路:

image-20220913124542154

优点

当一个产品族中的多个对象被设计成一起工作时,它能保证客户端始终只使用同一个产品族中的对象

缺点

当产品族中需要增加一个新的产品时,所有的工厂类都需要进行修改。

使用场景
  • 当需要创建的对象是一系列相互关联或相互依赖的产品族时,如电器工厂中的电视机、洗衣机、空调等
  • 系统中有多个产品族,但每次只使用其中的某一族产品。如有人只喜欢穿某一个品牌的衣服和鞋。
  • 系统中提供了产品的类库,且所有产品的接口相同,客户端不依赖产品实例的创建细节和内部结构。
  • 输入法换皮肤,一整套一起换。生成不同操作系统的程序。

策略模式

​ 我们去旅游选择出行模式有很多种,可以骑自行车、可以坐汽车、可以坐火车、可以坐飞机。

​ 该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户。策略模式属于对象行为模式,它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委派给不同的对象对这些算法进行管理。

结构

策略模式的主要角色如下:

  • 抽象策略(Strategy)类:这是一个抽象角色,通常由一个接口或抽象类实现。此角色给出所有的具体策略类所需的接口。
  • 具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现或行为。
  • 环境(Context)类:持有一个策略类的引用,最终给客户端调用。

案例实现

【例】促销活动

一家百货公司在定年度的促销活动。针对不同的节日(春节、中秋节、圣诞节)推出不同的促销活动,由促销员将促销活动展示给客户。类图如下

image-20220913125209804

  • 聚合关系可以用带空心菱形的实线来表示

代码如下:

定义百货公司所有促销活动的共同接口

1
2
3
public interface Strategy {   
void show();
}

定义具体策略角色(Concrete Strategy):每个节日具体的促销活动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//为春节准备的促销活动A
public class StrategyA implements Strategy {

public void show() {
System.out.println("买一送一");
}
}

//为中秋准备的促销活动B
public class StrategyB implements Strategy {

public void show() {
System.out.println("满200元减50元");
}
}

//为圣诞准备的促销活动C
public class StrategyC implements Strategy {

public void show() {
System.out.println("满1000元加一元换购任意200元以下商品");
}
}

定义环境角色(Context):用于连接上下文,即把促销活动推销给客户,这里可以理解为销售员

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SalesMan {                        
//持有抽象策略角色的引用
private Strategy strategy;

public SalesMan(Strategy strategy) {
this.strategy = strategy;
}

//向客户展示促销活动
public void salesManShow(){
strategy.show();
}
}

综合案例

系统存在多种方式可以进行登录

  • 用户名密码登录

  • 短信验证码登录

  • 微信登录

  • QQ登录

  • ….

一般自己的代码都是使用前端传递过来的登录类型去if else判断去走对应的登录方式,这样会出现一个问题,如果业务发生变更,需要新增一个登录方式,这个时候就需要修改业务层代码,违反了开闭原则

解决:

使用工厂方法设计模式+策略模式

  1. 整体思路

    改造之后,不在service中写业务逻辑,让service调用工厂,然后通过service传递不同的参数来获取不同的登录策略(登录方式)

  2. 具体实现

    • 抽象策略类:UserGranter

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      /**
      * 抽象策略类
      */
      public interface UserGranter{

      /**
      * 获取数据
      * @param loginReq 传入的参数
      * @return map值
      */
      LoginResp login(LoginReq loginReq);
      }
    • 具体的策略:AccountGranter、SmsGranter、WeChatGranter

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      /**
      * 策略:账号登录
      **/
      @Component
      public class AccountGranter implements UserGranter{

      @Override
      public LoginResp login(LoginReq loginReq) {

      System.out.println("登录方式为账号登录" + loginReq);
      // TODO
      // 执行业务操作

      return new LoginResp();
      }
      }
      /**
      * 策略:短信登录
      */
      @Component
      public class SmsGranter implements UserGranter{

      @Override
      public LoginResp login(LoginReq loginReq) {

      System.out.println("登录方式为短信登录" + loginReq);
      // TODO
      // 执行业务操作

      return new LoginResp();
      }
      }
      /**
      * 策略:微信登录
      */
      @Component
      public class WeChatGranter implements UserGranter{

      @Override
      public LoginResp login(LoginReq loginReq) {

      System.out.println("登录方式为微信登录" + loginReq);
      // TODO
      // 执行业务操作

      return new LoginResp();
      }
      }
    • 工程类UserLoginFactory

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      /**
      * 操作策略的上下文环境类 工具类
      * 将策略整合起来 方便管理
      */
      @Component
      public class UserLoginFactory implements ApplicationContextAware {

      private static Map<String, UserGranter> granterPool = new ConcurrentHashMap<>();

      @Autowired
      private LoginTypeConfig loginTypeConfig;

      /**
      * 从配置文件中读取策略信息存储到map中
      * {
      * account:accountGranter,
      * sms:smsGranter,
      * we_chat:weChatGranter
      * }
      *
      * @param applicationContext
      * @throws BeansException
      */
      @Override
      public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
      loginTypeConfig.getTypes().forEach((k, y) -> {
      granterPool.put(k, (UserGranter) applicationContext.getBean(y));
      });
      }

      /**
      * 对外提供获取具体策略
      *
      * @param grantType 用户的登录方式,需要跟配置文件中匹配
      * @return 具体策略
      */
      public UserGranter getGranter(String grantType) {
      UserGranter tokenGranter = granterPool.get(grantType);
      return tokenGranter;
      }

      }
    • 在application.yml文件中新增自定义配置

      1
      2
      3
      4
      5
      login:
      types:
      account: accountGranter
      sms: smsGranter
      we_chat: weChatGranter
    • 新增读取数据配置类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      Getter
      @Setter
      @Configuration
      @ConfigurationProperties(prefix = "login")
      public class LoginTypeConfig {

      private Map<String,String> types;

      }

    • 改造service

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      @Service
      public class UserService {

      @Autowired
      private UserLoginFactory factory;

      public LoginResp login(LoginReq loginReq){

      UserGranter granter = factory.getGranter(loginReq.getType());
      if(granter == null){
      LoginResp loginResp = new LoginResp();
      loginResp.setSuccess(false);
      return loginResp;
      }
      LoginResp loginResp = granter.login(loginReq);
      return loginResp;
      }
      }

举一反三

其实像这样的需求,在日常开发中非常常见,场景有很多,以下的情景都可以使用工厂模式+策略模式解决比如:

  • 订单的支付策略
    • 支付宝支付
    • 微信支付
    • 银行卡支付
    • 现金支付
  • 解析不同类型excel
    • xls格式
    • xlsx格式
  • 打折促销
    • 满300元9折
    • 满500元8折
    • 满1000元7折
  • 物流运费阶梯计算
    • 5kg以下
    • 5kg-10kg
    • 10kg-20kg
    • 20kg以上

一句话总结:只要代码中有冗长的 if-else 或 switch 分支判断都可以采用策略模式优化

责任链模式

概述

​ 在现实生活中,常常会出现这样的事例:一个请求有多个对象可以处理,但每个对象的处理条件或权限不同。

​ 例如,公司员工请假,可批假的领导有部门负责人、副总经理、总经理等,但每个领导能批准的天数不同,员工必须根据自己要请假的天数去找不同的领导签名,也就是说员工必须记住每个领导的姓名、电话和地址等信息,这增加了难度。

​ 这样的例子还有很多,如找领导出差报销、生活中的“击鼓传花”游戏等。

定义

​ 又名职责链模式,为了避免请求发送者与多个请求处理者耦合在一起,将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。

比较常见的springmvc中的拦截器,web开发中的filter过滤器

image-20230521111455359

结构

职责链模式主要包含以下角色:

  • 抽象处理者(Handler)角色:定义一个处理请求的接口,包含抽象处理方法和一个后继连接。
  • 具体处理者(Concrete Handler)角色:实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。
  • 客户类(Client)角色:创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。

案例实现

处理订单的操作

image-20230521111943231

类图:

image-20230521112108550

代码:

  • 抽象处理者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /**
    * 抽象处理者
    */
    public abstract class Handler {

    protected Handler handler;

    public void setNext(Handler handler) {
    this.handler = handler;
    }

    /**
    * 处理过程
    * 需要子类进行实现
    */
    public abstract void process(OrderInfo order);
    }
  • 订单信息类:

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

    public class OrderInfo {

    private String productId;
    private String userId;

    private BigDecimal amount;

    public String getProductId() {
    return productId;
    }

    public void setProductId(String productId) {
    this.productId = productId;
    }

    public String getUserId() {
    return userId;
    }

    public void setUserId(String userId) {
    this.userId = userId;
    }

    public BigDecimal getAmount() {
    return amount;
    }

    public void setAmount(BigDecimal amount) {
    this.amount = amount;
    }
    }
  • 具体处理者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    /**
    * 订单校验
    */
    public class OrderValidition extends Handler {

    @Override
    public void process(OrderInfo order) {
    System.out.println("校验订单基本信息");
    //校验
    handler.process(order);
    }

    }

    /**
    * 补充订单信息
    */
    public class OrderFill extends Handler {
    @Override
    public void process(OrderInfo order) {
    System.out.println("补充订单信息");
    handler.process(order);
    }

    }

    /**
    * 计算金额
    */
    public class OrderAmountCalcuate extends Handler {
    @Override
    public void process(OrderInfo order) {
    System.out.println("计算金额-优惠券、VIP、活动打折");
    handler.process(order);
    }

    }

    /**
    * 订单入库
    */
    public class OrderCreate extends Handler {
    @Override
    public void process(OrderInfo order) {
    System.out.println("订单入库");
    }
    }

  • 客户类

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

    public static void main(String[] args) {
    //检验订单
    Handler orderValidition = new OrderValidition();
    //补充订单信息
    Handler orderFill = new OrderFill();
    //订单算价
    Handler orderAmountCalcuate = new OrderAmountCalcuate();
    //订单落库
    Handler orderCreate = new OrderCreate();

    //设置责任链路
    orderValidition.setNext(orderFill);
    orderFill.setNext(orderAmountCalcuate);
    orderAmountCalcuate.setNext(orderCreate);

    //开始执行
    orderValidition.process(new OrderInfo());
    }

    }

优点

  • 降低了对象之间的耦合度

    该模式降低了请求发送者和接收者的耦合度。

  • 增强了系统的可扩展性

    可以根据需要增加新的请求处理类,满足开闭原则。

  • 增强了给对象指派职责的灵活性

    当工作流程发生变化,可以动态地改变链内的成员或者修改它们的次序,也可动态地新增或者删除责任。

  • 责任链简化了对象之间的连接

    一个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句。

  • 责任分担

    每个类只需要处理自己该处理的工作,不能处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。

缺点

  • 不能保证每个请求一定被处理。由于一个请求没有明确的接收者,所以不能保证它一定会被处理,该请求可能一直传到链的末端都得不到处理。
  • 对比较长的职责链,请求的处理可能涉及多个处理对象,系统性能将受到一定影响。
  • 职责链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于职责链的错误设置而导致系统出错,如可能会造成循环调用。

举一反三

  • 内容审核(视频、文章、课程….)

    image-20230521112731617

  • 订单创建

    image-20230521112802886

  • 简易流程审批

    image-20230521112831065

常见技术场景

单点登录

概述

​ 单点登录的英文名叫做:Single Sign On(简称SSO),只需要登录一次,就可以访问所有信任的应用系统

​ 在以前的时候,一般我们就单系统,所有的功能都在同一个系统上。

单体系统的session共享

  • 登录:将用户信息保存在Session对象中

    • 如果在Session对象中能查到,说明已经登录
    • 如果在Session对象中查不到,说明没登录(或者已经退出了登录)
  • 注销(退出登录):从Session中删除用户的信息

后来,我们为了合理利用资源和降低耦合性,于是把单系统拆分成多个子系统。

多系统即可能有多个Tomcat,而Session是依赖当前系统的Tomcat,所以系统A的Session和系统B的Session是不共享的。

解决系统之间Session不共享问题有一下几种方案:

  • Tomcat集群Session全局复制(最多支持5台tomcat,不推荐使用)
  • JWT(常见)
  • Oauth2
  • CAS
  • 自己实现(redis+token)

JWT解决单点登录

使用jwt解决单点登录的流程如下:

image-20230521113941467

回答要点
  1. 先解释什么是单点登录

    单点登录的英文名叫做:Single Sign On(简称SSO

  2. 介绍自己项目中涉及到的单点登录(即使没涉及过,也可以说实现的思路)

  3. 介绍单点登录的解决方案,以JWT为例

    • 用户访问其他系统,会在网关判断token是否有效
    • 如果token无效则会返回401(认证失败)前端跳转到登录页面
    • 用户发送登录请求,返回浏览器一个token,浏览器把token保存到cookie
    • 再去访问其他服务的时候,都需要携带token,由网关统一验证后路由到目标服务

权限认证

概述

后台的管理系统,更注重权限控制,最常见的就是RBAC模型来指导实现权限

RBAC(Role-Based Access Control)基于角色的访问控制

  • 3个基础部分组成:用户、角色、权限

  • 具体实现

    • 5张表(用户表、角色表、权限表、用户角色中间表、角色权限中间表)
    • 7张表(用户表、角色表、权限表、菜单表、用户角色中间表、角色权限中间表、权限菜单中间表)

RBAC权限模型

最常见的5张表的关系

image-20230521114305463

数据流转

张三具有什么权限呢?

流程:张三登录系统—> 查询张三拥有的角色列表—>再根据角色查询拥有的权限

image-20230521114432028

在实际的开发中,也会使用权限框架完成权限功能的实现,并且设置多种粒度,常见的框架有:

  • Apache shiro
  • Spring security(推荐)

上传数据安全如何控制

这里的安全性,主要说的是,浏览器访问后台,需要经过网络传输,有可能会出现安全的问题

解决方案:使用非对称加密(或对称加密),给前端一个公钥让他把数据加密后传到后台,后台负责解密后处理数据

对称加密

文件加密和解密使用相同的密钥,即加密密钥也可以用作解密密钥

一般用于保存用户手机号、身份证等敏感但能解密的信息。

常见的对称加密算法有: AES、DES、3DES、Blowfish、IDEA、RC4、RC5、RC6、HS256

image-20230521125012727

  • 数据发信方将明文和加密密钥一起经过特殊的加密算法处理后,使其变成复杂的加密密文发送出去,

  • 收信方收到密文后,若想解读出原文,则需要使用加密时用的密钥以及相同加密算法的逆算法对密文进行解密,才能使其回复成可读明文。

  • 在对称加密算法中,使用的密钥只有一个,收发双方都使用这个密钥,这就需要解密方事先知道加密密钥。

优点

对称加密算法的优点是算法公开、计算量小、加密速度快、加密效率高。

缺点

没有非对称加密安全

非对称加密

两个密钥:公开密钥(publickey)和私有密钥,公有密钥加密,私有密钥解密

一般用于签名和认证。私钥服务器保存, 用来加密, 公钥客户拿着用于对于令牌或者签名的解密或者校验使用.

常见的非对称加密算法有: RSA、DSA(数字签名用)、ECC(移动设备用)、RS256 (采用SHA-256 的 RSA 签名

image-20230521125136717

解释: 同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端.

加密与解密:

  • 私钥加密,持有公钥才可以解密
  • 公钥加密,持有私钥才可解密

签名:

  • 私钥签名, 持有公钥进行验证是否被篡改过.
优点

非对称加密与对称加密相比,其安全性更好

缺点

非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。

回答要点

使用非对称加密(或对称加密),给前端一个公钥让他把数据加密后传到后台,后台解密后处理数据

  • 传输的数据很大建议使用对称加密,不过不能保存敏感信息
  • 传输的数据较小,要求安全性高,建议采用非对称加密

项目遇到那些棘手问题

有4个方面可以回答,只要挑出一个回答就行了

设计模式

  • 工厂模式+策略
  • 责任链模式

举例:

  1. 介绍登录业务(一开始没有用设计模式,所有的登录方式都柔和在一个业务类中,不过,发现需求经常改)

  2. 登录方式经常会增加或更换,每次都要修改业务层代码,所以,经过我的设计,使用了工厂设计模式和策略模式,解决了,经常修改业务层代码的问题

  3. 详细介绍一下工厂模式和策略模式(参考前面设计模式的课程)

线上bug

  • CPU飙高
  • 内存泄漏
  • 线程死锁

调优

  • 慢接口
  • 慢SQL
  • 缓存方案

组件封装

  • 分布式锁
  • 接口幂等
  • 分布式事务
  • 支付通用

项目中日志怎么采集

日志是定位系统问题的重要手段,可以根据日志信息快速定位系统中的问题

日志采集方式

  • ELK:即Elasticsearch、Logstash和Kibana三个软件的首字母

  • 常规采集:按天保存到一个日志文件

ELK基本架构

ELK即Elasticsearch、Logstash和Kibana三个开源软件的缩写

image-20230521232913086

  • Elasticsearch
    Elasticsearch 全文搜索和分析引擎,对大容量的数据进行接近实时的存储、搜索和分析操作。

  • Logstash
    Logstash是一个数据收集引擎,它可以动态的从各种数据源搜集数据,并对数据进行过滤、分析和统一格式等操作,并将输出结果存储到指定位置上

  • Kibana
    Kibana是一个数据分析和可视化平台,通常与Elasticsearch配合使用,用于对其中的数据进行搜索、分析,并且以统计图标的形式展示。

参考回答

  • 我们搭建了ELK日志采集系统

  • 介绍ELK的三个组件:

    • Elasticsearch是全文搜索分析引擎,可以对数据存储、搜索、分析
    • Logstash是一个数据收集引擎,可以动态收集数据,可以对数据进行过滤、分析,将数据存储到指定的位置
    • Kibana是一个数据分析和可视化平台,配合Elasticsearch对数据进行搜索,分析,图表化展示

linux查看日志的命令

目前采集日志的方式:按天保存到一个日志文件

也可以在logback配置文件中设置日志的目录和名字

image-20230521233220905

需要掌握的Linux中的日志:

  • 实时监控日志的变化

    实时监控某一个日志文件的变化:tail -f xx.log;实时监控日志最后100行日志: tail –n 100 -f xx.log

  • 按照行号查询

    • 查询日志尾部最后100行日志:tail – n 100 xx.log

    • 查询日志头部开始100行日志:head –n 100 xx.log

    • 查询某一个日志行号区间:cat -n xx.log | tail -n +100 | head -n 100 (查询100行至200行的日志)

  • 按照关键字找日志的信息

    查询日志文件中包含debug的日志行号:cat -n xx.log | grep “debug”

  • 按照日期查询

    sed -n ‘/2023-05-18 14:22:31.070/,/ 2023-05-18 14:27:14.158/p’xx.log

  • 日志太多,处理方式

    • 分页查询日志信息:cat -n xx.log |grep “debug” | more

    • 筛选过滤以后,输出到一个文件:cat -n xx.log | grep “debug” >debug.txt

生产问题怎么排查

已经上线的bug排查的思路:

  1. 先分析日志,通常在业务中都会有日志的记录,或者查看系统日志,或者查看日志文件,然后定位问题
  2. 远程debug(通常公司的正式环境(生产环境)是不允许远程debug的。一般远程debug都是公司的测试环境,方便调试代码)

远程debug配置

前提条件:远程的代码和本地的代码要保持一致

  1. 远程代码需要配置启动参数,把项目打包放到服务器后启动项目的参数

    1
    java -jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 project-1.0-SNAPSHOT.jar
    • -agentlib:jdwp 是通知JVM使用(java debug wire protocol)来运行调试环境

    • transport=dt_socket 调试数据的传送方式

    • server=y 参数是指是否支持在server模式

    • suspend=n 是否在调试客户端建立起来后,再执行JVM。

    • address=5005 调试端口设置为5005,其它端口也可以

  2. idea中设置远程debug,找到idea中的 Edit Configurations…

    image-20230521233554657

    image-20230521233600556

  3. idea中启动远程debug

  4. 访问远程服务器,在本地代码中打断点即可调试远程

快速定位系统瓶颈

  • 压测(性能测试),项目上线之前测评系统的压力

    • 压测目的:给出系统当前的性能状况;定位系统性能瓶颈或潜在性能瓶颈
    • 指标:响应时间、 QPS、并发数、吞吐量、 CPU利用率、内存使用率、磁盘IO、错误率
    • 压测工具:LoadRunner、Apache Jmeter …
    • 后端工程师:根据压测的结果进行解决或调优(接口慢、代码报错、并发达不到要求…)
  • 监控工具、链路追踪工具,项目上线之后监控

    • 监控工具:Prometheus+Grafana
    • 链路追踪工具:skywalking、Zipkin
  • 线上诊断工具Arthas(阿尔萨斯),项目上线之后监控、排查


面试总结最终版
http://example.com/2024/03/14/面试总结/面试题总结最终版/
作者
zhuixun
发布于
2024年3月14日
许可协议