【2022最新版】Java经典面试题(309道)

文章篇幅过长(目前有13万字),资料收集不容易呀,大家多点点赞啦!!!。文章会持续更新,文章有什么错误的地方,欢迎在评论区留言,我会第一时间处理。如果大家有新的面试题,也可以在评论区留言,合理的,我也会第一时间加上去。

目录

Java最新面试题汇总

一、Java基础篇

1.1 Java语言有哪些特点

  1. 简单易学、有丰富的资源库
  2. 面向对象(Java最重要的特性,让程序耦合度更低,内聚性更高)
  3. 跨平台(JVM是Java跨平台使用的根本)
  4. 可靠安全,支持多线程

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

面向过程: 面向过程是分析解决问题的步骤,用函数把这些步骤一步一步的实现,在使用的时候一一调用即可。性能较高,单片机、嵌入式开发等一般采用面向过程开发。

面向对象: 面向对象是把构成问题的事务分解成各个对象,建立对象的目的也不是为了完成一个个步骤,而是为了描述某个事务在解决整个问题的过程中所发生的行为。面向对象有封装、继承、多态的特性,所以易维护、易复用、易扩展。可以设计出低耦合的系统。但性能上比面向过程要低。

1.3 基本数据类型

数据类型 大小(字节) 默认值
byte 1 0
short 2 0
int 4 0
long 8 0L
float 4 0.0f
double 8 0.0d
boolean (1位) false
char 2 \u000

1.4 instanceof关键字的作用

instanceof严格来说是Java中的双目运算符,用来测试一个对象是否为一个类的实例

boolean result = obj instanceof Class;

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

编译器会检查obj是否能转换成右边的class类型,如果不能转换直接报错。

int i = 0;
System.out.println(i instanceof Integer);//编译不通过 i必须是引用类型,不能是基本类型
System.out.println(i instanceof Object);//编译不通过

Integer integer = new Integer(1);
System.out.println(integer instanceof Integer);//true

//false ,在 JavaSE规范 中对 instanceof 运算符的规定就是:如果 obj 为 null,那么将返回 false。
System.out.println(null instanceof Object);

1.5 Java自动装箱与拆箱

装箱就是自动将基本数据类型转换为包装器类型,(int—>Integer)调用Integer.valueOf()方法

拆箱就是自动将包装器类型转换为基本数据类型,(Integer—>int)调用Integer.intValue()方法

1.6 重载和重写的区别

重写:

  1. 重写发生在父类与子类之间
  2. 方法名、参数列表、返回类型必须相同
  3. 访问修饰符限制必须大于被重写方法的访问修饰符
  4. 重写方法一定不能抛出新的检查异常,或者比重写方法申明更加宽泛的异常

重载: 在同一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同、参数顺序不同)则视为重载,与返回类型无关。

1.7 equals与==的区别

==: ==比较的是变量在内存中存放的对象内存地址,用来判断两个对象的内存地址是否相同。

equals: equals用来比较两个对象的内容是否相等,由于所有的类都是继承Object类,适用于所有对象,如果没有重写该方法的话,调用的是Object类中的方法,而Object类中的方法确是==判断的。

在阿里代码规范中只使用equals

1.8 String、StringBuffer、StringBuilder的区别

String是只读字符串,它并不是基本数据类型,而是一个对象。从底层源码看是一个final修饰的字符数组,所引用的字符串不能改变,每次对String的操作都会生成新的String对象。

StringBuffer和StringBuilder他们两都继承了AbstractStringBuilder抽象类,从AbstractStringBuilder抽象类中我们可以看到它们底层都是可变字符数组。对字符串频繁操作建议使用StringBuffer和StringBuilder。StringBuffer对方法加了同步锁,所以是线程安全的。StringBuilder没有对方法加同步锁,所以是非线程安全的。

1.9 ArrayList和LinkedList的区别

Array(数组)是基于索引(index)的数据结构,它使用索引在数组搜索和读取是很快的。

Array获取数据的时间复杂度是O(1),但删除数据开销很大,因为需要重排数据中的所有数据。

LinkedList是一个双向链表,在添加和删除是比ArrayList性能更好,但在get和set弱于ArrayList。

1.10 HashMap和HashTable的区别

  1. 两者父类不同:HashMap是继承自AbstractMap类,而Hashtable是继承自Dictionary类。不过它们都实现了同时实现了map、Cloneable(可复制)、Serializable(可序列化)这三个接口。
  2. 对null的支持不同:HashTable,key和value都不能为null。HashMap,key可以为null,但这样的key只能有一个,因为必须保证key的唯一性,可以有对个key对应的value为null。
  3. 安全性不同:HashMap是线程不安全的,HashTable是线程安全的,它的每个方法都加了synchronized关键字。
  4. 初始容量大小和每次扩容量大小不同,Hashtable 的初始长度是 11,之后每次扩充容量变为之前的2n+1(n 为上一次的长度)而 HashMap 的初始长度为 16,之后每次扩充变为原来的两倍。
  5. 计算hash值的方法不同

1.11 Collection和Collections的区别

Collection是集合类的上级接口,子接口有Set、List、LinkedList、ArrayList、Vector、Stack、set。

Collections是集合的工具类,它包含有各种有关集合操作的静态多态方法,用于实现对各种集合的搜索、排序、线程安全化等操作。

1.12 Java的四种引用,强弱软虚

强引用: 强引用是平常使用最多的引用,强引用在程序内存不足时也不会被回收,使用方式:

String str = new String("str");
System.out.println(str);

软引用: 软引用在程序内存不足时会被回收,使用方式:

// 注意:wrf这个引用也是强引用,它是指向SoftReference这个对象的,
// 这里的软引用指的是指向new String("str")的引用,也就是SoftReference类中T
SoftReference<String> wrf = new SoftReference<String>(new String("str"));

弱引用: 弱引用就是JVM垃圾回收器发现它,就会被回收。

// str就是弱引用
WeakReference<String> wrf = new WeakReference<String>(str);

虚引用: 虚引用的回收机制跟弱引用差不多,但是它被回收之前,会被放入 ReferenceQueue 中。注意哦,其它引用是被JVM回收后才被传入 ReferenceQueue 中的。由于这个机制,所以虚引用大多被用于引用销毁前的处理工作。还有就是,虚引用创建的时候,必须带有 ReferenceQueue

PhantomReference<String> prf = new PhantomReference<String>(new String("str"),
new ReferenceQueue<>());

1.13 泛型常用特点

泛型是Java1.5之后的特性,《Java核心技术》中对泛型的定义:“泛型”意味着编写的代码可以被不同类型的对象所重用。

“泛型”,顾名思义,“泛指的类型”。我们提供泛指的概念,但具体执行的时候可以有具体的规则来约束,比如我们用的非常多的ArrayList就是个泛型类,ArrayList作为集合可以存放各种元素,如Integer, String,自定义的各种类型等,但在我们使用的时候通过具体的规则来约束,如我们可以约束集合中只存放Integer类型的元素。

1.14 Java创建对象的几种方式

  1. new方式创建对象
  2. 通过反射机制
  3. 通过clone机制
  4. 通过反序列化机制

1.15 深拷贝和浅拷贝

浅拷贝: 浅拷贝只会拷贝基本数据类型的值,以及实例对象的引用地址,并不会复制引用地址所指向的对象,内部的类属性指向的是同一个对象。

深拷贝: 深拷贝会拷贝基本数据类型的值,也会针对实例对象的引用地址所指向的对象进行拷贝,内部的类属性指向的不是同一个对象。

1.16 final有哪些用法

  1. 被final修饰的类不能被继承
  2. 被final修饰的方法不能被重写
  3. 被final修饰的变量不可被改变
  4. 被final修饰的方法,JVM会尝试将其内联,提高运行效率
  5. 被final修饰的常量,在编译阶段会存入常量池

除此之外,编译器对final域要遵守的两个重排序规则更好:

在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

1.17 try catch finally,try里有return,finally还执行么?

执行,并且finally的执行早于try里面的return

  1. 不管有么有异常操作,finally里的代码都会执行
  2. 当try和catch中有return时,finally仍然会执行
  3. finally是在return后面的表达式运算后执行的(此时并没有返回运算后的值,而是先把要返回的值保存起来,管finally中的代码怎么样,返回的值都不会改变,任然是之前保存的值),所以函数返回值是在finally执行前确定的
  4. finally中最好不要包含return,否则程序会提前退出,返回值不是try或catch中保存的返回值

1.18 进程与线程的区别?

进程: 进程是一个具有一定独立功能的程序在一个数据集合上依次动态执行的过程。进程一个正在执行的程序的实例,包括程序计数器、寄存器和程序变量的当前值。

进程的特征:

  1. 进程依赖于程序运行而存在,进程是动态的,程序是静态的
  2. 进程是操作系统进行资源分配和调度的一个独立单元(CPU除外,线程是处理器任务调度和执行的基本单元)
  3. 每个进程拥有独立的地址空间,地址空间包括代码区、数据区和堆栈区,进程之间的地址空间隔离的,互不影响

线程: 线程与进程相似,但线程是一个比进程更小的执行单元。一个进程在执行过程中可以产生多个线程。与进程不同的同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

进程与线程的区别总结:

  1. 本质区别: 进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。
  2. 包含关系: 一个进程至少有一个线程,线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
  3. 资源开销: 每个进程都有独立的地址空间,进程之间的切换会有较大的开销;线程可以看做轻量级的进程,同一个进程内的线程共享进程的地址空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。
  4. 影响关系: 一个进程崩溃后,在保护模式下其他进程不会被影响,但是一个线程崩溃可能导致整个进程被操作系统杀掉,所以多进程要比多线程健壮。

推荐下面的博客,讲得很透彻。
线程与进程,你真得理解了吗

1.19 Java序列化中如果有些字段不能序列化,如何处理?

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

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

1.20 Java中IO流

Java中IO流分几种:

  1. 按照流方向分,可分为输入流和输出流
  2. 按照操作单元分,可分为字节流和字符流
  3. 按照流的角色分,可分为节点流和处理流

流分类结构图:
在这里插入图片描述

1.21 Java反射

定义: 反射机制是在运行时,对于任意一个类,都能知道类的所有属性和方法;对于任意一个对象,都能调用它的任意一个方法。在Java中,只要给定类的名称,就可以通过反射机制来获取类的所有信息。这种动态获取的信息以及动态调用对象方法的功能称为Java的反射机制

反射的实现方式:

  1. Class.forName(“类的路径”)
  2. 类名.class
  3. 对象名.getClass()
  4. 基本类型的包装类,可以调用包装类的Type属性来获得该包装类的Class对象

反射机制的优缺点:
优点:
1)能够运行时动态获取类的实例,提高灵活性;
2)与动态编译结合
缺点:
1)使用反射性能较低,需要解析字节码,将内存中的对象进行解析。 解决方案: 1、通过setAccessible(true)关闭JDK的安全检查来提升反射速度; 2、多次创建一个类的实例时,有缓存会快很多 3、ReflectASM工具类,通过字节码生成的方式加快反射速度
2)相对不安全,破坏了封装性(因为通过反射可以获得私有方法和属性)

1.22 List、Set、Map三者的区别

List(对付顺序的好帮手): List接口存储一组不唯一(可以有多个元素引用相同的对象),有序的对象
Set(注重独一无二的性质): 不允许重复的集合。不会有多个元素引用相同的对象。
Map(用Key来搜索的专家): 使用键值对存储。Map会维护与Key有关联的值。两个Key可以引用相同的对象,但Key不能重复,典型的Key是String类型,但也可以是任何对象。

1.23 HashMap 中的 key 我们可以使用任何类作为 key 吗?

平时可能大家使用的最多的就是使用 String 作为 HashMap 的 key,但是现在我们想使用某个自定义类作为 HashMap 的 key,那就需要注意以下几点:

  1. 如果类重写了 equals 方法,它也应该重写 hashCode 方法。
  2. 类的所有实例需要遵循与 equals 和 hashCode 相关的规则。
  3. 如果一个类没有使用 equals,你不应该在 hashCode 中使用它。
  4. 咱们自定义 key 类的最佳实践是使之为不可变的,这样,hashCode 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode 和 equals 在未来不会改变,这样就会解决与可变相关的问题了。

1.24 HashMap 的长度为什么是 2 的 N 次方呢?

为了能让HashMap的存数据和取数据效率高,尽可能地减少hash值的碰撞,也就是说尽量把数据均匀分配,每个链表或者红黑树的长度相等。

我们首先可能会想到 % 取模的操作来实现。

取余(%)操作中如果除数是 2 的幂次,则等价于与其除数减一的与(&)操作(也就是说hash % length == hash &(length - 1) 的前提是 length 是 2 的 n 次方)。并且,采用二进制位操作 & ,相对于 % 能够提高运算效率。

1.25 HashMap 与 ConcurrentHashMap 的异同

  1. 都是 key-value 形式的存储数据;
  2. HashMap 是线程不安全的,ConcurrentHashMap 是 JUC 下的线程安全的;
  3. HashMap 底层数据结构是数组 + 链表(JDK 1.8 之前)。JDK 1.8 之后是数组 + 链表 + 红黑树。当链表中元素个数达到 8 的时候,链表的查询速度不如红黑树快,链表会转为红黑树,红黑树查询速度快;
  4. HashMap 初始数组大小为 16(默认),当出现扩容的时候,以 0.75 * 数组大小的方式进行扩容;
  5. ConcurrentHashMap 在 JDK 1.8 之前是采用分段锁来现实的 Segment + HashEntry,Segment 数组大小默认是 16,2 的 n 次方;JDK 1.8 之后,采用 Node + CAS + Synchronized来保证并发安全进行实现。

1.26 红黑树有哪些特征?

  1. 每个节点是黑色或红色
  2. 根节点是黑色
  3. 每个叶子节点都是黑色(指向空的叶子节点)
  4. 如果一个叶子节点是红色,那么其子节点必须都是黑色
  5. 从一个节点到该节点的子孙节点,每条路径上有相同数目的黑节点

1.27 面向对象的特征有哪些?

抽象: 抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面。抽象只关注对象有哪些属性和行为,并不关注这些行为的细节是什么。

继承: 继承是从已有类得到继承信息创建新类的过程。提供继承信息的类被称为父类(超类、基类);得到继承信息的类被称为子类(派生类)。继承让变化中的软件系统有了一定的延续性,同时继承也是封装程序中可变因素的重要手段(如果不能理解请阅读阎宏博士的《Java 与模式》或《设计模式精解》中关于桥梁模式的部分)。

封装: 通常认为封装是把数据和操作数据的方法绑定起来,对数据的访问只能通过已定义的接口。面向对象的本质就是将现实世界描绘成一系列完全自治、封闭的对象。我们在类中编写的方法就是对实现细节的一种封装;我们编写一个类就是对数据和数据操作的封装。可以说,封装就是隐藏一切可隐藏的东西,只向外界提供最简单的编程接口(可以想想普通洗衣机和全自动洗衣机的差别,明显全自动洗衣机封装更好因此操作起来更简单;我们现在使用的智能手机也是封装得足够好的,因为几个按键就搞定了所有的事情)。

多态: 多态性是指允许不同子类型的对象对同一消息作出不同的响应。简单的说就是用同样的对象引用调用同样的方法但是做了不同的事情。多态性分为编译时的多态性和运行时的多态性。如果将对象的方法视为对象向外界提供的服务,那么运行时多态性可以解释为:当A 系统访问B 系统提供的服务时,B系统有多种提供服务的方式,但一切对A 系统来说都是透明的(就像电动剃须刀是A 系统,它的供电系统是B 系统,B 系统可以使用电池供电或者用交流电,甚至还有可能是太阳能,A 系统只会通过B 类对象调用供电的方法,但并不知道供电系统的底层实现是什么,究竟通过何种方式获得了动力)。方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重写(override)实现的是运行时的多态性(也称为后绑定)。运行时的多态是面向对象最精髓的东西,要实现多态需要做两件事:1). 方法重写(子类继承父类并重写父类中已有的或抽象的方法);2). 对象造型(用父类型引用引用子类型对象,这样同样的引用调用同样的方法就会根据子类对象的不同而表现出不同的行为)。

1.28 访问修饰符

访问修饰符 本类 同包 子类 其他包
private
default
protected
public

1.29 float f=3.4;是否正确?

不正确。3.4 是双精度数,将双精度型(double) 赋值给浮点型(float)属于下转型( down-casting,也称为窄化)会造成精度损失,因此需要强制类型转换float f =(float)3.4; 或者写成float f =3.4F;。

1.30 Math.round(11.5) 等于多少?Math.round(-11.5)等于多少?

Math.round(11.5)的返回值是12, Math.round(-11.5)的返回值是-11。四舍五入的原理是在参数上加0.5 然后进行下取整。

1.31 switch 是否能作用在byte 上,是否能作用在long 上,是否能作用在String 上?

在Java 5 以前,switch(expr)中,expr 只能是byte、short、char、int。从Java5 开始, Java 中引入了枚举类型,expr 也可以是enum 类型,从Java 7 开始,expr 还可以是字符串( String), 但是长整型( long)在目前所有的版本中都是不可以的。

1.32 用最有效率的方法计算2 乘以8?

2 << 3(左移3 位相当于乘以2 的3 次方,右移3 位相当于除以2 的3 次方) 。

1.33 数组有没有length()方法?String 有没有length()方法?

数组没有length()方法,有length 的属性。String 有length()方法。JavaScript中, 获得字符串的长度是通过length 属性得到的, 这一点容易和Java 混淆。

1.34 抽象类(abstract class)和接口(interface)有什么异同?

抽象类和接口都不能够实例化,但可以定义抽象类和接口类型的引用。一个类如果继承了某个抽象类或者实现了某个接口都需要对其中的抽象方法全部进行实现, 否则该类仍然需要被声明为抽象类。接口比抽象类更加抽象,因为抽象类中可以定义构造器,可以有抽象方法和具体方法,而接口中不能定义构造器而且其中的方法全部都是抽象方法。抽象类中的成员可以是private、默认、protected、public 的,而接口中的成员全都是public 的。抽象类中可以定义成员变量,而接口中定义的成员变量实际上都是常量。有抽象方法的类必须被声明为抽象类, 而抽象类未必要有抽象方法。

1.35 线程有哪些状态?

创建状态: 在生成线程对象,并没有调用该对象的start方法,这是线程处于创建状态。

就绪状态: 当调用了线程对象的start方法之后,该线程就进入了就绪状态,但是此时线程调度程序还没有把该线程设置为当前线程,此时处于就绪状态。在线程运行之后,从等待或者睡眠中回来之后,也会处于就绪状态。

运行状态: 线程调度程序将处于就绪状态的线程设置为当前线程,此时线程就进入了运行状态,开始运行run函数当中的代码。

阻塞状态: 线程正在运行的时候,被暂停,通常是为了等待某个时间的发生(比如说某项资源就绪)之后再继续运行。sleep,suspend,wait等方法都可以导致线程阻塞。

死亡状态: 如果一个线程的run方法执行结束或者调用stop方法后,该线程就会死亡。对于已经死亡的线程,无法再使用start方法令其进入就绪。

1.36 sleep() 和 wait() 有什么区别?

  1. 类的不同:sleep() 来自 Thread,wait() 来自 Object。
  2. 释放锁:sleep() 不释放锁;wait() 释放锁。
  3. 用法不同:sleep() 时间到会自动恢复;wait() 可以使用 notify()/notifyAll()直接唤醒。

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

notifyAll()会唤醒所有的线程,notify()之后唤醒一个线程。notifyAll() 调用后,会将全部线程由等待池移到锁池,然后参与锁的争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而 notify()只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。

1.38 线程的 run() 和 start() 有什么区别?

start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。

1.39 创建线程池有哪些方式?

new SingleThreadExecutor(): 它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。

new CachedThreadPool(): 它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列;

new FixedThreadPool(int nThreads): 重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads;

new SingleThreadScheduledExecutor(): 创建单线程池,返回 ScheduledExecutorService,可以进行定时或周期性的工作调度;
new ScheduledThreadPool(int corePoolSize): 和newSingleThreadScheduledExecutor()类似,创建的是个ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程;

new WorkStealingPool(int parallelism): 这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序;

new ThreadPoolExecutor():是最原始的线程池创建,上面1-3创建方式都是对ThreadPoolExecutor的封装。

特别提醒:在阿里开放手册中(规约【强制),线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式。 原文下图:

在这里插入图片描述

ThreadPoolExecutor的方式创建线程池代码示例:

	//核心线程数
    private static final int CORE_POOL_SIZE = 20;
    //最大线程数
    private static final int MAX_POOL_SIZE = 40;
    //队列数
    private static final int QUEUE_CAPACITY = 100;
    private static final Long KEEP_ALIVE_TIME = 1L;

    public static void main (String [] args) {
    
    
        ThreadPoolExecutor executor = getThreadPoolExecutor();
        executor.execute(new Thread(()->{
    
    
            for (int i=0; i<10 ; i++){
    
    
                System.out.println(i);
            }
        }));
        executor.shutdown();
    }

    public static ThreadPoolExecutor getThreadPoolExecutor() {
    
    
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                CORE_POOL_SIZE,
                MAX_POOL_SIZE,
                KEEP_ALIVE_TIME,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(QUEUE_CAPACITY),
                new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }

1.40 线程池中 submit() 和 execute() 方法有什么区别?

execute():只能执行 Runnable 类型的任务。submit():可以执行 Runnable 和 Callable 类型的任务。

1.41 Java死锁如何避免?

死锁的原因:

  1. 一个资源每次只能被一个线程使用。
  2. 一个线程在阻塞等待某个资源时,不释放已占有资源。
  3. 一个线程已经获的资源,在未使用之前,不能强行剥夺。
  4. 若干线程形成头尾相接的循环等待资源关系。

当线程 A 持有独占锁a,并尝试去获取独占锁 b 的同时,线程 B 持有独占锁 b,并尝试获取独占锁 a 的情况下,就会发生 AB 两个线程由于互相持有对方需要的锁,而发生的阻塞现象,我们称为死锁。

如何避免死锁:

  1. 要注意加锁顺序,保证每个线程按同样的顺序加锁。
  2. 要注意加锁时间,可以设置一个超时时间。
  3. 要注意死锁检测,这时一种预防机制,确保在第一时间发现死锁并解决。

1.42 ThreadLocal是什么?

ThreadLocal就是为每个使用该变量的线程提供一个变量副本,每个线程都可以独立操作自己的变量副本,不会影响其它线程对应的副本。ThreadLocal 的经典使用场景是数据库连接和 session 管理等。

1.43 synchronized底层原理

synchronized底层使用了自旋锁、偏向锁、重量级锁、轻量级锁。

偏向锁: 在锁对象的对象头记录一下当前获取线程锁的线程id,该线程下次来获取就可以直接获取了。

轻量级锁: 由偏向锁升级而来,当一个线程获取到锁后,此时这把锁是偏向锁,如果第二个线程来竞争锁,偏向锁会升级为轻量级锁,之所以叫轻量级锁,是为了与重量级锁区分开,轻量级锁底层是通过自旋来实现的,并不会阻塞线程。

重量级锁: 如果自旋次数过多任然没有获取到锁,则会升级到重量级锁,重量级锁会导致线程阻塞。

自旋锁: 自旋锁就是线程在获取锁的过程中不会阻塞线程,也就不会唤醒线程,阻塞或唤醒这两个步骤都需要操作系统去进行的,比较消耗时间,自旋锁是线程通过CAS获取预期的一个标记,如果没有获取到,则继续循环获取,这个过程线程一直在运行中,相对而言没有使用过多操作系统资源,比较轻量。

1.44 synchronized 和 volatile 的区别?

  1. volatile 是变量修饰符;synchronized 是修饰类、方法、代码段。
  2. volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
  3. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

1.45 synchronized 和 Lock 的区别?

  1. synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
  2. synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
  3. 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

1.46 synchronized 和 ReentrantLock 的区别?

  1. synchronized是一个关键字,ReentrantLock是一个类。
  2. synchronized会自动加锁和释放锁,ReentrantLock需要手动加锁和释放锁。
  3. synchronized的底层是JVM层面的锁,ReentrantLock是API层面的锁。
  4. synchronized是非公平锁,ReentrantLock可选择公平锁与非公平锁。
  5. synchronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过int类型state标识控制锁状态。
  6. synchronized底层有一个锁升级过程。

1.47 什么是Callable和Future?

Callable接口类似于Runnable,从名字就可以看出来了,但是Runnable不会返回结果,并且无法抛出返回结果的异常,而Callable功能更强大一些,被线程执行后,可以返回值,这个返回值可以被Future拿到,也就是说,Future可以拿到异步执行任务的返回值。可以认为是带有回调的Runnable。

Future接口表示异步任务,是还没有完成的任务给出的未来结果。所以说Callable用于产生结果,Future用于获取结果。

二、JVM篇

2.1 JVM知识点汇总

JVM是Java运行基础,面试时一定会遇到JVM的有关问题,内容相对集中,但对只是深度要求较高。其中内存模型,类加载制,GC是重点方面。性能调优部分更偏向应用,重点突出实践能力。编译器优化和执行模式部分偏向于理论基础,重点掌握知识点。需了解 内存模型各部分作用,保存哪些数据。类加载双亲委派加载机制,常用加载器分别加载哪种类型的类。GC分代回收的思想和依据以及不同垃圾回收算法的回收思路和适合场景。性能调优常有JVM优化参数作用,参数调优的依据,常用的JVM分析工具能分析哪些问题以及使用方法。执行模式解释/编译/混合模式的优缺点,Java7提供的分层编译技术,JIT即时编译技术,OSR栈上替换,C1/C2编译器针对的场景,C2针对的是server模式,优化更激进。新技术方面Java10的graal编译器编译器优化javac的编译过程,ast抽象语法树,编译器优化和运行器优化。

在这里插入图片描述

2.2 JVM内存模型

内存模型图:
在这里插入图片描述

虚拟机栈:

虚拟机栈是线程私有的内存空间,创建一个线程就会在虚拟机栈中获取一个线程栈,线程栈有多个栈帧,一个栈帧保存方法的局部变量表、操作数栈、动态链接、方法出口等信息。

若单个线程请求的栈深度大于虚拟机允许的深度,则会抛出StackOverflowError(栈溢出错误)。

不同于StackOverflowError,OutOfMemoryError指的是当整个虚拟机栈内存耗尽,并且无法再申请到新的内存时抛出的异常。

本地方法栈:

本地方法栈与虚拟机栈类似,虚拟机栈执行的是Java方法服务,本地方法栈执行的是本地方法服务(用native关键字修饰的方法)。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

堆:

堆(Heap),一个JVM只有一个堆内存,堆内存的大小是可以调节的。该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)。年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。

程序计数器:

程序计数器(Program Counter Register)用来储存指向下一条字节码指令的地址,也就是即将要执行的字节码指令,由存储引擎读取下一条指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。在JVM规范中,每个线程都有自己私有的程序计数器,生命周期与线程的生命周期保持一致。

方法区:

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区是一种规范,不同的虚拟机厂商可以基于规范做出不同的实现,永久代和元空间就是出于不同jdk版本的实现。说白了,方法区就像是一个接口,永久代与元空间分别是两个不同的实现类而已。只不过永久代是这个接口最初的实现类,后来这个接口一直进行变更,直到最后彻底废弃这个实现类,由新实现类——元空间进行替代。

类加载子系统:

类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

2.3 类的加载与卸载

加载过程:
在这里插入图片描述
类加载其中验证、准备、解析和称链接。

加载: 通过类的完全限定名,查找类字节码文件,利用字节码文件创建Class对象。

验证: 确保Class文件符合当前虚拟机要求,不会危害到虚拟机自身安全。

准备: 进行内存分配,为static修饰的类变量分配内存,并设置初始值(0或null)。不包含final修饰的静态变量,因为final变量在编译时分配。

解析: 将常量池中的符号引用替换为直接引用的过程。直接引用直接指向目标的的指针或者相对偏移量。

初始化: 主要完成静态块执行以及静态变量的赋值。先初始化父类,再初始化当前类。只有对类主动使用时才会初始化。触发条件包括,创建类的实例时,访问类的静态方法或静态变量的时候,使用Class.forName反射类的时候,或者某个子类初始化的时候。

Java自带的加载器加载的类,在虚拟机的生命周期中是不会被卸载的,只有用户自定义的加载器加载的类才可以被卸载。

2.4 加载机制,双亲委派模型

JVM中存三个默认的类加载器,BootstrapClassLoaderExtClassLoaderAppClassLoader

AppClassLoader的父加载器是ExtClassLoader,ExtClassLoader的父加载器是BootstrapClassLoader。

JVM在加载一个类时,会调用AppClassLoader的loaderClass方法来加载类,不过在这个方法中,会先使用ExtClassLoader的loaderClass方法加载类,同样ExtClassLoader的loaderClass方法中会先使用BootstrapClassLoader来加载类,如果BootstrapClassLoader加载到了就就直接成功,如果BootstrapClassLoader没有加载到,那么就会自己尝试加载该类,若果没有加载到,那么则会有AppClassLoader来加载这个类。

所以双亲委派指的是,JVM在加载类时,会委派ExtClassLoader和BootstrapClassLoader进行加载,若果没有加载到才有自己进行加载。

沙箱安全机制:自己写的String.class类不会被加载,这样便可以防止核心API库被随意篡改避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。

2.5 JVM垃圾判断算法

引用计数法:

假设有个一对象ClassA,任何一个对象引用了ClassA,ClassA的引用计数器就会加1,引用失效引用计数器就会减1。如果ClassA引用计数器为0,那么ClassA就会被回收。

可达性分析法:

可达性分析法也被称之为根搜索法,可达性是指,如果一个对象被一个或多个在程序中的变量,通过直接或间接方式被其它可达的对象引用,那么该对象就是可达的。更准确的说,一个对象只有满足下述两个条件之一,就会被判断为可达的:

  1. 对象是属于根集中的对象
  2. 对象被一个可达的对象引用

在这里,我们引出了一个专有名词,“根集”,指正在执行的Java程序可以访问的引用变量的集合,程序可以使用引用变量访问对象的属性和调用对象的方法。在 JVM 中,会将以下对象标记为根集中的对象,具体包括:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中的常量引用的对象
  3. 方法区中的类静态属性引用的对象
  4. 本地方法栈中 JNI(Native 方法)的引用对象
  5. 活跃线程(已启动且未停止的 Java 线程)

根集中的对象称之为GC Roots,即根对象。可达性分析法的基本思路是:将一系列的根对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,如果一个对象到根对象没有任何引用链相连,那么这个对象就不是可达的,也称之为不可达对象。
在这里插入图片描述
如上图所示,形象的展示了可达对象与不可达对象,其中灰色的是不可达对象,可以被垃圾收集的对象。在可达性分析法中,对象有两种状态,要么是可达的、要么是不可达的,在判断一个对象的可达性的时候,就需要对对象进行标记。关于标记阶段,有几个关键点是值得我们注意的,分别是:

  1. 开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便 JVM 可以尽情地收拾家务的这种情况又被称之为安全点(Safe Point),这会触发一次 Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。

  2. 暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。

  3. 在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:
    如果对象在进行根搜索后发现没有与根对象相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法(可看作析构函数,类似于 OC 中的dealloc,Swift 中的deinit)。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。
    如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次),稍后 GC 将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

  4. GC 判断对象是否可达看的是强引用。

2.6 JVM垃圾回收算法

标记-清除算法:

标记-清除(Tracing Collector)算法是最基础的收集算法,为了解决引用计数法的问题而提出。它使用了根集的概念,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的可达性分析法中判定垃圾对象的标记过程。

标记-整理算法:

标记-整理(Compacting Collector)算法标记的过程与“标记-清除”算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。在基于“标记-整理”算法的收集器的实现中,一般增加句柄和句柄表。

复制算法:

复制(Copying Collector)算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。

复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如“标记-整理”算法。一种典型的基于复制算法的垃圾回收是stop-and-copy算法,它将堆分成对象区和空闲区,在对象区与空闲区的切换过程中,程序暂停执行。

分代收集算法:

分代收集(Generational Collector)算法的将堆内存划分为新生代、老年代和永久代。新生代又被进一步划分为 Eden 和Survivor 区,其中 Survivor 由 FromSpace(Survivor0)和 ToSpace(Survivor1)组成。所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。分代收集,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,可以将不同生命周期的对象分代,不同的代采取不同的回收算法进行垃圾回收,以便提高回收效率。

2.7 JVM垃圾回收器

垃圾回收(GC)线程与应用线程保持相对独立,当系统需要执行垃圾回收任务时,先停止工作线程,然后命令 GC 线程工作。以串行模式工作的收集器,称为Serial Collector,即串行收集器;与之相对的是以并行模式工作的收集器,称为Paraller Collector,即并行收集器。

Serial 收集器:

串行收集器采用单线程方式进行收集,且在 GC 线程工作时,系统不允许应用线程打扰。此时,应用程序进入暂停状态,即 Stop-the-world。Stop-the-world 暂停时间的长短,是衡量一款收集器性能高低的重要指标。Serial 是针对新生代的垃圾回收器,采用“复制”算法。

ParNew 收集器:

并行收集器充分利用了多处理器的优势,采用多个 GC 线程并行收集。可想而知,多条 GC 线程执行显然比只使用一条 GC 线程执行的效率更高。一般来说,与串行收集器相比,在多处理器环境下工作的并行收集器能够极大地缩短 Stop-the-world 时间。ParNew 是针对新生代的垃圾回收器,采用“复制”算法,可以看成是 Serial 的多线程版本

Parallel Scavenge 收集器:

Parallel Scavenge 是针对新生代的垃圾回收器,采用“复制”算法,和 ParNew 类似,但更注重吞吐率。在 ParNew 的基础上演化而来的 Parallel Scanvenge 收集器被誉为“吞吐量优先”收集器。吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。如虚拟机总运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是99%。

Serial Old 收集器:

Serial Old 是 Serial 收集器的老年代版本,单线程收集器,采用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

Parallel Old 收集器:

Parallel Old 是 Parallel Scanvenge 收集器的老年代版本,多线程收集器,采用“标记-整理”算法。

CMS收集器:

CMS(Concurrent Mark Swee)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS 收集器仅作用于老年代的收集,采用“标记-清除”算法,它的运作过程分为 4 个步骤:初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)。

其中,初始标记、重新标记这两个步骤仍然需要 Stop-the-world。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

CMS 以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需 STW 才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。

CMS 收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面已经介绍过“标记-清除”算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。

G1 收集器:

G1(Garbage First)重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成,即 G1 提供了接近实时的收集特性。G1 具备如下特点:

  1. 并行与并发: G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-the-world 停顿的时间,部分其他收集器原来需要停顿 Java 线程执行的 GC 操作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行。
  2. 分代收集: 打破了原有的分代模型,将堆划分为一个个区域。
  3. 空间整合: 与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
  4. 可预测的停顿: 这是 G1 相对于 CMS 的一个优势,降低停顿时间是 G1 和 CMS 共同的关注点。

G1 收集的运作过程大致如下:

  1. 初始标记(Initial Marking): 仅仅只是标记一下GC Roots能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。
  2. 并发标记(Concurrent Marking): 是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  3. 最终标记(Final Marking): 是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
  4. 筛选回收(Live Data Counting and Evacuation): 首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

G1 的 GC 模式可以分为两种,分别为:

  1. Young GC: 在分配一般对象(非巨型对象)时,当所有 Eden 区域使用达到最大阀值并且无法申请足够内存时,会触发一次 YoungGC。每次 Young GC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Old 区以及另一部分的 Survivor 区。
  2. Mixed GC: 当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个新生代,还会回收一部分的老年代,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old 区域进行收集,从而可以对垃圾回收的耗时时间进行控制。G1 没有 Full GC概念,需要 Full GC 时,调用 Serial Old GC 进行全堆扫描。

JAVM垃圾回收机制推荐以下博客:
深入理解 JVM 垃圾回收机制及其实现原理

2.8 什么时候会触发FullGC?

除直接调用System.gc外,触发Full GC执行的情况有如下四种。

  1. 旧生代空间不足: 旧生代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误: java.lang.OutOfMemoryError: Java heap space 为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

  2. Permanet Generation空间满: PermanetGeneration中存放的为一些class的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息: java.lang.OutOfMemoryError: PermGen space 为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。

  3. CMS GC时出现promotion failed和concurrent mode failure: 对于采用CMS进行旧生代GC的程序而言,尤其要注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当这两种状况出现时可能会触发Full GC。 promotionfailed是在进行Minor GC时,survivor space放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。 应对措施为:增大survivorspace、旧生代空间或调低触发并发GC的比率,但在JDK 5.0+、6.0+的版本中有可能会由于JDK的bug29导致CMS在remark完毕后很久才触发sweeping动作。对于这种状况,可通过设置-XX:CMSMaxAbortablePrecleanTime=5(单位为ms)来避免。

  4. 统计得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间: 这是一个较为复杂的触发情况,Hotspot为了避免由于新生代对象晋升到旧生代导致旧生代空间不足的现象,在进行MinorGC时,做了一个判断,如果之前统计所得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间,那么就直接触发Full GC。 例如程序第一次触发MinorGC后,有6MB的对象晋升到旧生代,那么当下一次Minor GC发生时,首先检查旧生代的剩余空间是否大于6MB,如果小于6MB,则执行Full GC。 当新生代采用PSGC时,方式稍有不同,PS GC是在Minor GC后也会检查,例如上面的例子中第一次Minor GC后,PS GC会检查此时旧生代的剩余空间是否大于6MB,如小于,则触发对旧生代的回收。 除了以上4种状况外,对于使用RMI来进行RPC或管理的Sun JDK应用而言,默认情况下会一小时执行一次Full GC。可通过在启动时通过- java-Dsun.rmi.dgc.client.gcInterval=3600000来设置Full GC执行的间隔时间或通过-XX:+DisableExplicitGC来禁止RMI调用System.gc。

2.9 什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?

Java虚拟机是一个可以执行Java字节码的虚拟机进程。Java源文件被编译成能被Java虚拟机执行的字节码文件。 Java被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。

2.10 说说对象分配规则

对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。

大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。

长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,知道达到阀值对象进入老年区。动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC。

2.11 对象一定分配在堆中吗?有没有了解逃逸分析技术?

不一定的,JVM通过「逃逸分析」,那些逃不出方法的对象会在栈上分配。

逃逸分析(Escape Analysis),是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。

逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。

2.12 什么是Stop The World ? 什么是OopMap?什么是安全点?

进行垃圾回收的过程中,会涉及对象的移动。为了保证对象引用更新的正确性,必须暂停所有的用户线程,像这样的停顿,虚拟机设计者形象描述为「Stop The World」。也简称为STW

在HotSpot中,有个数据结构(映射表)称为「OopMap」。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,记录到OopMap。在即时编译过程中,也会在「特定的位置」生成 OopMap,记录下栈上和寄存器里哪些位置是引用。

这些特定的位置主要在:

  1. 循环的末尾(非 counted 循环)
  2. 方法临返回前 / 调用方法的call指令后
  3. 可能抛异常的位置

这些位置就叫作「安全点(safepoint)」 用户程序执行时并非在代码指令流的任意位置都能够在停顿下来开始垃圾收集,而是必须是执行到安全点才能够暂停。

2.13 简述Java的对象结构

Java对象由三个部分组成:对象头、实例数据、对齐填充。

对象头由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。

实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)

对齐填充:JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)

2.14 什么是指针碰撞?

一般情况下,JVM的对象都放在堆内存中(发生逃逸分析除外)。当类加载检查通过后,Java虚拟机开始为新生对象分配内存。如果Java堆中内存是绝对规整的,所有被使用过的的内存都被放到一边,空闲的内存放到另外一边,中间放着一个指针作为分界点的指示器,所分配内存仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的实例,这种分配方式就是 指针碰撞。

在这里插入图片描述

2.15 什么是空闲列表?

如果Java堆内存中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,不可以进行指针碰撞啦,虚拟机必须维护一个列表,记录哪些内存是可用的,在分配的时候从列表找到一块大的空间分配给对象实例,并更新列表上的记录,这种分配方式就是空闲列表。

2.16 什么是TLAB?

可以把内存分配的动作按照线程划分在不同的空间之中进行,每个线程在Java堆中预先分配一小块内存,这就是TLAB(Thread Local Allocation Buffer,本地线程分配缓存) 。虚拟机通过-XX:UseTLAB 设定它的。

2.17 JVM里的有几种classloader,为什么会有多种?

启动类加载器: 负责加载JRE的核心类库,如jre目标下的rt.jarcharsets.jar等。
扩展类加载器: 负责加载JRE扩展目录extJAR类包。
系统类加载器: 负责加载ClassPath路径下的类包。
用户自定义加载器: 负责加载用户自定义路径下的类包。

为什么会有多种: 分工,各自负责各自的区块。为了实现委托模型。

三、多线程与并发编程篇

3.1 有三个线程T1,T2,T3,如何保证顺序执行?

在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成。

实际上先启动三个线程中哪一个都行, 因为在每个线程的run方法中用join方法限定了三个线程的执行顺序。

3.2 什么是线程安全

线程安全就是说多线程访问同一段代码,不会产生不确定的结果。又是一个理论的问题,各式各样的答案有很多,我给出一个个人认为解释地最好的:如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。这个问题有值得一提的地方,就是线程安全也是有几个级别的:

  1. 不可变: 像String、Integer、Long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用
  2. 绝对线程安全: 不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,Java中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,Java中也有,比方说CopyOnWriteArrayList、CopyOnWriteArraySet
  3. 相对线程安全: 相对线程安全也就是我们通常意义上所说的线程安全,像Vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个Vector、有个线程同时在add这个Vector,99%的情况下都会出现ConcurrentModificationException,也就是fail-fast机制。
  4. 线程非安全: 这个就没什么好说的了,ArrayList、LinkedList、HashMap等都是线程非安全的类

3.3 说一下线程之间是如何通信的?

线程之间的通信有两种方式:共享内存和消息传递。

共享内存: 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信。例如上图线程 A 与 线程 B 之间如果要通信的话,那么就必须经历下面两个步骤:

  1. 线程 A 把本地内存 A 更新过得共享变量刷新到主内存中去。
  2. 线程 B 到主内存中去读取线程 A 之前更新过的共享变量。

消息传递: 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。在 Java 中典型的消息传递方式,就是 wait() 和 notify() ,或者BlockingQueue 。

3.4 CAS的原理呢?

CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性,它包含三个操作数:

  1. 变量内存地址,V表示
  2. 旧的预期值,A表示
  3. 准备设置的新值,B表示

当执行CAS指令时,只有当V等于A时,才会用B去更新V的值,否则就不会执行更新操作。

CAS 缺点:

  1. ABA 问题: 比如说一个线程one 从内存位置V 中取出A,这时候另一个线程two 也从内存中取出A,并且two 进行了一些操作变成了B,然后two 又将V 位置的数据变成A,这时候线程one 进行CAS 操作发现内存中仍然是A,然后one 操作成功。尽管线程one 的CAS 操作成功,但可能存在潜藏的问题。从Java1.5 开始JDK 的atomic包里提供了一个类AtomicStampedReference 来解决ABA 问题。
  2. 循环时间长开销大: 对于资源竞争严重(线程冲突严重) 的情况, CAS 自旋的概率会比较大, 从而浪费更多的CPU 资源,效率低于synchronized
  3. 只能保证一个共享变量的原子操作: 当对一个共享变量执行操作时,我们可以使用循环CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环CAS 就无法保证操作的原子性, 这个时候就可以用锁。

3.5 什么是AQS?

简单说一下AQSAQS全称为AbstractQueuedSychronizer,翻译过来应该是抽象队列同步器。

如果说java.util.concurrent的基础是CAS的话,那么AQS就是整个Java并发包的核心了,ReentrantLockCountDownLatchSemaphore等等都用到了它。AQS实际上以双向队列的形式连接所有的Entry,比方说ReentrantLock,所有等待的线程都被放在一个Entry中并连成双向队列,前面一个线程使用ReentrantLock好了,则双向队列实际上的第一个Entry开始运行。

AQS定义了对双向队列所有的操作,而只开放了tryLocktryRelease方法给开发者使用,开发者可以根据自己的实现重写tryLocktryRelease方法,以实现自己的并发功能。

3.6 了解Semaphore吗?

semaphore就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果Semaphore构造函数中传入的int型整数n=1,相当于变成了一个synchronized了。

3.7 什么是阻塞队列?阻塞队列的实现原理是什么?如何使用阻塞队列来实现生产者-消费者模型?

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。

这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。

阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。JDK7提供了7个阻塞队列。分别是:

  1. ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
  2. LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
  3. PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
  4. DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  5. SynchronousQueue:一个不存储元素的阻塞队列。
  6. LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  7. LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

Java 5之前实现同步存取时,可以使用普通的一个集合,然后在使用线程的协作和线程同步可以实现生产者,消费者模式,主要的技术就是用好,waitnotifynotifyAllsychronized这些关键字。而在java 5之后,可以使用阻塞队列来实现,此方式大大简少了代码量,使得多线程编程更加容易,安全方面也有保障。

BlockingQueue接口是Queue的子接口,它的主要用途并不是作为容器,而是作为线程同步的的工具,因此他具有一个很明显的特性,当生产者线程试图向BlockingQueue放入元素时,如果队列已满,则线程被阻塞,当消费者线程试图从中取出一个元素时,如果队列为空,则该线程会被阻塞,正是因为它所具有这个特性,所以在程序中多个线程交替向BlockingQueue中放入元素,取出元素,它可以很好的控制线程之间的通信。

阻塞队列使用最经典的场景就是socket客户端数据的读取和解析,读取数据的线程不断将数据放入队列,然后解析线程不断从队列取数据解析。

3.8 什么是多线程中的上下文切换?

在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。

在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB)中的。PCB还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到CPU的内存中,直到他们被再次使用。

上下文切换是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。

3.9 什么是Daemon线程?它有什么意义?

所谓后台(daemon)线程,也叫守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。

因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说, 只要有任何非后台线程还在运行,程序就不会终止。

必须在线程启动之前调用setDaemon()方法,才能把它设置为后台线程。注意:后台进程在不执行finally子句的情况下就会终止其run()方法。

比如:JVM的垃圾回收线程就是Daemon线程,Finalizer也是守护线程。

3.10 乐观锁和悲观锁的理解及如何实现,有哪些实现方式?

悲观锁: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

乐观锁: 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。

乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。

在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

乐观锁的实现方式:

  1. 使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识, 不一致时可以采取丢弃和再次尝试的策略。
  2. java 中的Compare and SwapCAS ,当多个线程尝试使用CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败, 失败的线程并不会被挂起,而是被告知这次竞争中失败, 并可以再次尝试。CAS 操作中包含三个操作数—— 需要读写的内存位置( V)、进行比较的预期原值( A)和拟写入的新值(B)。如果内存位置V 的值与预期原值A 相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。

3.11 Java 中用到的线程调度算法是什么?

采用时间片轮转的方式。可以设置线程的优先级, 会映射到下层的系统上面的优先级上, 如非特别需要,尽量不要用,防止线程饥饿。

计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU 的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU 的使用权,分别执行各自的任务.在运行池中,会有多个处于就绪状态的线程在等待CPU,JAVA 虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配CPU 的使用权。

有两种调度模型:分时调度模型和抢占式调度模型。分时调度模型是指让所有的线程轮流获得cpu 的使用权,并且平均分配每个线程占用的CPU 的时间片这个也比较好理解。

java 虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行, 直至它不得不放弃CPU。

3.12 SynchronizedMap 和ConcurrentHashMap 有什么区别?

SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为map

ConcurrentHashMap 使用分段锁来保证在多线程下的性能。

ConcurrentHashMap 中则是一次锁住一个桶。ConcurrentHashMap 默认将hash 表分为16 个桶,诸如getputremove 等常用操作只锁当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16 个写线程执行,并发性能的提升是显而易见的。

另外ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中, 当iterator 被创建后集合再发生改变就不再是抛出。

ConcurrentModificationException,取而代之的是在改变时new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。

3.13 CopyOnWriteArrayList 可以用于什么应用场景?

CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException。在CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时, 读取操作可以安全地执行。

1、由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下, 可能导致young gc 或者full gc;

2、不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求;

CopyOnWriteArrayList 透露的思想,1、读写分离,读和写分开,2、最终一致性,3、使用另外开辟空间的思路,来解决并发冲突

3.14 你对线程优先级的理解是什么?

每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。我们可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。线程优先级是一个int 变量(从1-10),1 代表最低优先级, 10 代表最高优先级。

java 的线程优先级调度会委托给操作系统去处理, 所以与具体的操作系统优先级有关,如非特别需要,一般无需设置线程优先级。

3.15 什么是线程调度器(Thread Scheduler)和时间分片(TimeSlicing )?

线程调度器是一个操作系统服务,它负责为Runnable 状态的线程分配CPU 时间。一旦我们创建一个线程并启动它,它的执行便依赖于线程调度器的实现。同上一个问题,线程调度并不受到Java 虚拟机控制,所以由应用程序来控制它是更好的选择(也就是说不要让你的程序依赖于线程的优先级)。

时间分片是指将可用的CPU 时间分配给可用的Runnable 线程的过程。分配CPU时间可以基于线程优先级或者线程等待的时间。

3.16 并发编程三要素

原子性: 原子性指的是一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断, 要么就全部都不执行。

可见性: 可见性指多个线程操作一个共享变量时, 其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。

有序性: 有序性, 即程序的执行顺序按照代码的先后顺序来执行。

3.17 线程池原理

1)线程池的优点

1、线程是稀缺资源,使用线程池可以减少创建和销毁线程的次数,每个工作线程都可以重复使用。
2、可以根据系统的承受能力,调整线程池中工作线程的数量,防止因为消耗过多内存导致服务器崩溃。

2)线程池的创建

 public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
                              BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) 

corePoolSize: 线程池核心线程数量

maximumPoolSize: 线程池最大线程数量

keepAliverTime: 当活跃线程数大于核心线程数时,空闲的多余线程最大存活时间

unit: 存活时间的单位

workQueue: 存放任务的队列

handler: 超出线程范围和队列容量的任务的处理程序

3)线程池的实现原理

提交一个任务到线程池中,线程池的处理流程如下:

  1. 判断线程池里的核心线程是否都在执行任务,如果不是(核心线程空闲或者还有核心线程没有被创建)则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则进入下个流程。
  2. 线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
  3. 判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

在这里插入图片描述
4)RejectedExecutionHandler:饱和策略

当队列和线程池都满了,说明线程池处于饱和状态,那么必须对新提交的任务采用一种特殊的策略来进行处理。这个策略默认配置是AbortPolicy,表示无法处理新的任务而抛出异常。JAVA提供了4中策略:

1、AbortPolicy:直接抛出异常

2、CallerRunsPolicy:只用调用所在的线程运行任务

3、DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。

4、DiscardPolicy:不处理,丢弃掉。

5)Executor框架的两级调度模型

在HotSpot VM的模型中,JAVA线程被一对一映射为本地操作系统线程。JAVA线程启动时会创建一个本地操作系统线程,当JAVA线程终止时,对应的操作系统线程也被销毁回收,而操作系统会调度所有线程并将它们分配给可用的CPU。

在上层,JAVA程序会将应用分解为多个任务,然后使用应用级的调度器(Executor)将这些任务映射成固定数量的线程;在底层,操作系统内核将这些线程映射到硬件处理器上。

在这里插入图片描述
在前面介绍的JAVA线程既是工作单元,也是执行机制。而在Executor框架中,我们将工作单元与执行机制分离开来。Runnable和Callable是工作单元(也就是俗称的任务),而执行机制由Executor来提供。这样一来Executor是基于生产者消费者模式的,提交任务的操作相当于生成者,执行任务的线程相当于消费者。

3.18 如何实现线程安全?

实现线程安全的三种方式:

1)互斥同步锁:

Synchorized、ReentrantLock。互斥同步锁也叫做阻塞同步锁,特征是会对没有获取锁的线程进行阻塞。

要理解互斥同步锁,首选要明白什么是互斥什么是同步。简单的说互斥就是非你即我,同步就是顺序访问。互斥同步锁就是以互斥的手段达到顺序访问的目的。操作系统提供了很多互斥机制比如信号量,互斥量,临界区资源等来控制在某一个时刻只能有一个或者一组线程访问同一个资源。

Java里面的互斥同步锁就是Synchorized和ReentrantLock,前者是由语言级别实现的互斥同步锁,理解和写法简单但是机制笨拙,在JDK6之后性能优化大幅提升,即使在竞争激烈的情况下也能保持一个和ReentrantLock相差不多的性能,所以JDK6之后的程序选择不应该再因为性能问题而放弃synchorized。ReentrantLock是API层面的互斥同步锁,需要程序自己打开并在finally中关闭锁,和synchorized相比更加的灵活,体现在三个方面:等待可中断,公平锁以及绑定多个条件。但是如果程序猿对ReentrantLock理解不够深刻,或者忘记释放lock,那么不仅不会提升性能反而会带来额外的问题。另外synchorized是JVM实现的,可以通过监控工具来监控锁的状态,遇到异常JVM会自动释放掉锁。而ReentrantLock必须由程序主动的释放锁。

互斥同步锁都是可重入锁,好处是可以保证不会死锁。但是因为涉及到核心态和用户态的切换,因此比较消耗性能。JVM开发团队在JDK5-JDK6升级过程中采用了很多锁优化机制来优化同步无竞争情况下锁的性能。比如:自旋锁和适应性自旋锁,轻量级锁,偏向锁,锁粗化和锁消除。

2)非阻塞同步锁:

CAS(Compare And Swap)。非阻塞同步锁也叫乐观锁,相比悲观锁来说,它会先进行资源在工作内存中的更新,然后根据与主存中旧值的对比来确定在此期间是否有其他线程对共享资源进行了更新,如果旧值与期望值相同,就认为没有更新,可以把新值写回内存,否则就一直重试直到成功。它的实现方式依赖于处理器的机器指令:CAS(Compare And Swap)。

JUC中提供了几个Automic类以及每个类上的原子操作就是乐观锁机制。

不激烈情况下,性能比synchronized略逊,而激烈的时候,也能维持常态。激烈的时候,Atomic的性能会优于ReentrantLock一倍左右。但是其有一个缺点,就是只能同步一个值,一段代码中只能出现一个Atomic的变量,多于一个同步无效。因为他不能在多个Atomic之间同步。

非阻塞锁是不可重入的,否则会造成死锁。

3)无同步方案:

可重入代码:在执行的任何时刻都可以中断-重入执行而不会产生冲突。特点就是不会依赖堆上的共享资源。

ThreadLocal/Volaitile:线程本地的变量,每个线程获取一份共享变量的拷贝,单独进行处理。

线程本地存储:如果一个共享资源一定要被多线程共享,可以尽量让一个线程完成所有的处理操作,比如生产者消费者模式中,一般会让一个消费者完成对队列上资源的消费。典型的应用是基于请求-应答模式的web服务器的设计。

3.19 volatile、ThreadLocal的使用场景和原理?

volatile原理:

volatile变量进行写操作时,JVM 会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写会到系统内存。

Lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

volatile的适用场景:

  1. 状态标志,如:初始化或请求停机
  2. 一次性安全发布,如:单列模式
  3. 独立观察,如:定期更新某个值
  4. “volatile bean” 模式
  5. 开销较低的“读-写锁”策略,如:计数器

ThreadLocal原理:

ThreadLocal是用来维护本线程的变量的,并不能解决共享变量的并发问题。ThreadLocal是各线程将值存入该线程的map中,以ThreadLocal自身作为key,需要用时获得的是该线程之前存入的值。如果存入的是共享变量,那取出的也是共享变量,并发问题还是存在的。

ThreadLocal的适用场景:

数据库连接、Session管理

3.20 谈谈ConcurrentHashMap的扩容机制

1.7版本的ConcurrentHashMap是基于Segment分段实现的、每个Segment相对于⼀个小型的HashMap、每个Segment内部会进行扩容,和HashMap的扩容逻辑类似、先⽣成新的数组,然后转移元素到新数组中、扩容的判断也是每个Segment内部单独判断的,判断是否超过阈值。

1.8版本的ConcurrentHashMap不再基于Segment实现。当某个线程进行put时,如果发现ConcurrentHashMap正在进行扩容那么该线程⼀起进行扩容。如果某个线程put时,发现没有正在进行扩容,则将key-value添加到ConcurrentHashMap中,然后判断是否超过阈值,超过了则进行扩容。ConcurrentHashMap是支持多个线程同时扩容的。扩容之前也先⽣成⼀个新的数组。在转移元素时,先将原数组分组,将每组分给不同的线程来进行元素的转移,每个线程负责⼀组或多组的元素转移工作。

3.21 CopyOnWriteArrayList的底层原理

CopyOnWriteArrayList内部也是用过数组来实现的,在向CopyOnWriteArrayList添加元素时,会复制⼀个新的数组,写操作在新数组上进行,读操作在原数组上进行。并且,写操作会加锁,防止出现并发写入丢失数据的问题。写操作结束之后会把原数组指向新数组。

CopyOnWriteArrayList允许在写操作时来读取数据,大大提⾼了读的性能,因此适合读多写少的应用场景,但是CopyOnWriteArrayList会比较占内存,同时可能读到的数据不是实时最新的数据,所以不适合实时性要求很⾼的场景。

参考资料: Java 多线程安全机制

四、JMM(Java内存模型)篇

4.1 JMM概念

Java内存模型(Java Memory Model,JMM)。Java语言规范中,JVM中存在一个主存区(Main Memory或Java Heap Memory),Java中所有变量都存在主存中,对所以线程是共享的,而每个线程又存在自己的工作内存(Working Memory),工作内存中保存的是主内存中某些变量的拷贝,线程对对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间不能直接相互访问,变量在程序中的传递,是依赖主存完成的。

4.2 JSR-133(Java内存模型与线程规范)

JSR-133中文文档

Java虚拟机支持多线程执行。线程是用Thread类来表示的。用户创建 一个线程的唯一方式是创建一个该类的对象,每个线程都与这样一个对象相关联。在对应的Thread 对象上调用 start()方法将启动线程。

线程的行为,尤其是未正确同步时的行为,可能会让人困惑和违背直觉。本规范描述了用 Java语言编写的多线程程序的语义;包括多线程更新共享内存时,读操作能看到什么值的规则。因为本规范与不同的硬件架构的内存模型相似,所以,这里的语义都指的是 Java内存模型。

这些语义不会去描述多线程程序该如何执行。而是描述多线程程序允许表现出的行为。任何执行策略,只要产生的是允许的行为,那它就是一个可接受的执行策略。

4.3 JMM内存模型

Java多线程内存模型与cpu缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽底层不同计算机的区别。所有的变量都 存储在主内存中,每个线程还有自己的工作内存 ,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。
在这里插入图片描述

4.4 JMM三大特性

原子性: 指一个操作是不可中断的,即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

可见性: 指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题 是不存在。因为你在任何一个操作步骤中修改某个变量,那么在后续的步骤中,读取这个变量的值,一定是修改后的新值。但是这个问题在并行程序中就不见得了。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。

有序性: 对于一个线程的执行代码而言,我们总是习惯地认为代码的执行时从先往后,依次执行的。这样的理解也不能说完全错误,因为就一个线程而言,确实会这样。但是在并发时,程序的执行可能就会出现乱序。给人直观的感觉就是:写在前面的代码,会在后面执行。有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。

4.5 JMM数据原子操作

  1. read(读取):从主内存中读取数据
  2. load(载入):将主内存读取到的数据写入工作内存中
  3. use(使用):从工作内存读取数据来计算
  4. assign(赋值):将计算好的值重新赋值到工作内存中
  5. store(存储):将工作内存数据写入主内存中
  6. write(写入):将store过去的变量值赋值给主内存中的变量
  7. lock(锁定):将主内存变量加锁,标识为线程独占状态
  8. unlock(解锁):将主内存变量解锁

4.6 缓存一致性协议(MESI)

多个CPU从主内存读取同一个数据到各自的高速缓存,当某个CPU修改了缓存了的数据,该数据会马上同步回写到主内存,其它CPU通过总线嗅探机制可以感知到数据的变化,从而将自己缓存里的数据标记为失效。

4.7 volatile缓存可见性

底层实现主要通过汇编lock前前缀指令,它会锁定这块内存区域的缓存并回写到主内存。volatile保证可见性与有序性,保证原子性需要synchronized这样的锁机制,lock指令解释:

  1. 会将当前处理器缓存的数据立即写回到主内存。
  2. 这个写回内存的操作,会引起其它CPU缓存数据无效。
  3. 提供内存屏障功能,使lock前后指令不能重排序 。

4.8 指令重排序

在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对指令重排序优化,重排序会遵守as-if-serial语义与happens-before原则。

as-if-serial语义:

不管怎么重排序,单线程程序的执行结果不能改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。

happens-before原则:

  1. 程序顺序规则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  2. 锁原则:解锁操作必须发生在后续同一个锁加锁之前。
  3. volatile原则:volatile变量的写先发生于读,保证了volatile变量的可见性。
  4. 线程启动原则:线程的start()方法先于其它的每一个动作,如果 线程A在执行线程B的start()方法之前修改了共享变量的值,那么当线程B执行start()时,线程A对共享变量的修改对线程B可见。
  5. 传递性:A先于B,B先于C,那么A必然先于C。
  6. 线程终止原则:线程的所有操作先于线程的终止,假设在线程B终止之前,修改了共享变量,线程A从线程B的join()方法返回后,线程B对共享变量的修改对线程A可见。
  7. 线程中断原则:对线程interrupt()方法的调用,先发生于被终中断线程代码检查中断事件的发生。
  8. 对象终结原则:对象的构造函数执行,结束先于finalize()方法。

4.9 内存屏障

屏障类型 指令示例 说明
LoadLoad Load1; LoadLoad; Load2 保证load1的读取操作在load2及后续读取操作之前
StoreStore Store1; StoreStore; Store2; 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStore Load1; LoadStore; Store2 在store2及其后的写操作执行前,保证load1的读取操作已结束
StoreLoad Store1; StoreLoad; Load2 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

JVM底层简化了内存屏障硬件指令实现,在汇编代码前加lock指令,lock指令不是一种内存屏障,但它能完成内存屏障功能。

五、Spring篇

5.1 Spring是什么?

Spring是轻量级开源的J2EE框架。它是一个容器框架、用来封装Javabean(Java对象),它是一个中间层框架(万能胶)可以起一个连接作用,⽐如说把Struts和hibernate粘合在⼀起运⽤,可以让我们的企业开发更快、更简洁。Spring是一个轻量级的控制反转(IOC)和面向切面(AOP)的容器框架。

5.2 Spring有哪些特点?

  1. 轻量: Spring 是轻量的,基本的版本大约2MB。
  2. 控制反转(IOC): Spring通过控制反转实现松散耦合,对象们给出它们的依赖,而不是创建或查找依赖的对象们。
  3. 面向切面编程(AOP): Spring支持面向切面的编程,并且把应用业务逻辑和系统服务分开。
  4. 容器: Spring包含并管理应用中对象的生命周期和配置。
  5. MVC框架: Spring的WEB框架是个精心设计的框架,是Web框架的一个很好的替代品。
  6. 事务管理: Spring提供一个持续事务管理接口,可以扩展到上至本地事务下至全局事务(JTA)。
  7. 异常处理: Spring提供方便的API吧具体技术相关的异常转换为一致的unchecked 异常。

5.3 谈谈你对AOP的理解

系统是由许多不同组件组成的,每一个组件各负责一块特定功能。除了实现自身核心功能之外,这些组件还经常承担着额外的职责。例如⽇志、事务管理和安全这样的核⼼服务经常融⼊到⾃身具有核⼼业务逻辑的组件中去。这些系统服务经常被称为横切关注点,因为它们会跨越系统的多个组件。

当我们需要为分散的对象引⼊公共⾏为的时候,OOP则显得⽆能为⼒。也就是说,OOP允许你定义从上到下的关系,但并不适合定义从左到右的关系。例如⽇志功能。

⽇志代码往往⽔平地散布在所有对象层次中,⽽与它所散布到的对象的核⼼功能毫⽆关系。

在OOP设计中,它导致了⼤量代码的重复,⽽不利于各个模块的重⽤。

AOP:将程序中的交叉业务逻辑(⽐如安全,⽇志,事务等),封装成⼀个切⾯,然后注⼊到⽬标对象(具体业务逻辑)中去。AOP可以对某个对象或某些对象的功能进⾏增强,⽐如对象中的⽅法进⾏增强,可以在执⾏某个⽅法之前额外的做⼀些事情,在某个⽅法执⾏之后额外的做⼀些事情。

5.4 谈谈你对IOC的理解

IOC容器、控制反转、依赖注入

IOC容器: 实际上就是个map(key,value),⾥⾯存的是各种对象(在xml⾥配置的bean节点、@repository、@service、@controller、@component),在项⽬启动的时候会读取配置⽂件⾥⾯的bean节点,根据全限定类名使⽤反射创建对象放到map⾥、扫描到打上上述注解的类还是通过反射将对象放到map⾥。

这个时候map⾥就有各种对象了,接下来我们在代码⾥需要⽤到⾥⾯的对象时,再通过DI注⼊(autowired、resource等注解,xml⾥bean节点内的ref属性,项⽬启动的时候会读取xml节点ref属性根据id注⼊,也会扫描这些注解,根据类型或id注⼊;id就是对象名)。

控制反转: 没有引⼊IOC容器之前,对象A依赖于对象B,那么对象A在初始化或者运⾏到某⼀点的时候,⾃⼰必须主动去创建对象B或者使⽤已经创建的对象B。⽆论是创建还是使⽤对象B,控制权都在⾃⼰⼿上。

引⼊IOC容器之后,对象A与对象B之间失去了直接联系,当对象A运⾏到需要对象B的时候,IOC容会主动创建⼀个对象B注⼊到对象A需要的地⽅。

通过前后的对⽐,不难看出来:对象A获得依赖对象B的过程,由主动⾏为变为了被动⾏为,控制权颠倒过来了,这就是“控制反转”这个名称的由来。

全部对象的控制权全部上缴给“第三⽅”IOC容器,所以,IOC容器成了整个系统的关键核⼼,它起到了⼀种类似“粘合剂”的作⽤,把系统中的所有对象粘合在⼀起发挥作⽤,如果没有这个“粘合剂”,对象与对象之间会彼此失去联系,这就是有⼈把IOC容器⽐喻成“粘合剂”的由来。

依赖注入: “获得依赖对象的过程被反转了”。控制被反转之后,获得依赖对象的过程由⾃身管理变为了由IOC容器主动注⼊。依赖注⼊是实现IOC的⽅法,就是由IOC容器在运⾏期间,动态地将某种依赖关系注⼊到对象之中。

5.5 依赖注入的三种方式

  1. 构造器注入: 将被依赖对象通过构造函数的参数注入给依赖对象,并且在初始化对象的时候注入。
    优点: 对象初始化完成后便可获得可使用的对象。
    缺点: 当需要注入的对象很多时,构造器参数列表将会很长; 不够灵活。若有多种注入方式,每种方式只需注入指定几个依赖,那么就需要提供多个重载的构造函数,麻烦。

  2. setter方法注入: IoC Service Provider通过调用成员变量提供的setter函数将被依赖对象注入给
    依赖类。
    优点: 灵活。可以选择性地注入需要的对象。
    缺点: 依赖对象初始化完成后由于尚未注入被依赖对象,因此还不能使用。

  3. 接口注入: 依赖类必须要实现指定的接口,然后实现该接口中的一个函数,该函数就是用于依赖注入。该函数的参数就是要注入的对象。
    优点: 接口注入中,接口的名字、函数的名字都不重要,只要保证函数的参数是要注入的对象类型即可。
    缺点: 侵入行太强,不建议使用。
    PS:什么是侵入行? 如果类A要使用别人提供的一个功能,若为了使用这功能,需要在自己的类中增加额外的代码,这就是侵入性。

5.6 AOP核心概念

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

5.7 AOP两种代理方式

Spring 提供了两种方式来生成代理对象: JDKProxy 和 Cglib,具体使用哪种方式生成由AopProxyFactory 根据 AdvisedSupport 对象的配置来决定。默认的策略是如果目标类是接口,则使用 JDK 动态代理技术,否则使用 Cglib 来生成代理。

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

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

5.8 @Autowired和@Resource的区别?

@Resource和@Autowired都是做bean的注入时使用,其实@Resource并不是Spring的注解,它的包是javax.annotation.Resource,需要导入,但是Spring支持该注解的注入。

不同点:

  1. @Autowired为Spring提供的注解,需要导入包org.springframework.beans.factory.annotation.Autowired,只按照byType注入。@Autowired注解是按照类型(byType)装配依赖对象,默认情况下它要求依赖对象必须存在,如果允许null值,可以设置它的required属性为false。
  2. @Resource默认按照byName自动注入,由J2EE提供,需要导入包javax.annotation.Resource。@Resource有两个重要的属性:name和type,而Spring将@Resource注解的name属性解析为bean的名字,而type属性则解析为bean的类型。所以,如果使用name属性,则使用byName的自动注入策略,而使用type属性时则使用byType自动注入策略。如果既不制定name也不制定type属性,这时将通过反射机制使用byName自动注入策略。

5.9 Spring中bean的生命周期

spring中bean的生命周期可分为四个阶段,实例化 Instantiation、属性赋值 Populate、初始化 Initialization、销毁 Destruction。

bean详细生命周期:

  1. 启动Spring容器,也就是创建BeanFactory(bean工厂),通过applicationcontext加载配置文件,或者利用注解的方式扫描,将bean的配置信息加载到Spring容器中。
  2. 加载完后,Spring容器会将这些配置信息(bean的信息)封装成BeanDeFinition对象,赋予一些属性,比如是否单例、是否懒加载等。
  3. 然后将这些BeanDefinition对象以key为beanName,值为BeanDefinition对象的形式存入到一个map里面,将这个map传入到spring beanfactory去进行springBean的实例化。
  4. 传入到spring beanfactory之后,利用BeanFactoryPostProcessor接口这个扩展点去对BeanDefinition对象进行一些属性修改。
  5. 开始循环BeanDefinition对象进行springBean的实例化,springBean的实例化也就是执行bean的构造方法(单例的Bean放入单例池中,但是此刻还未初始化),在执行实例化的前后,可以通过InstantiationAwareBeanPostProcessor扩展点(作用于所有bean)进行一些修改。
  6. spring bean实例化之后,就开始注入属性,首先注入自定义的属性,比如标注@autowrite的这些属性,再调用各种Aware接口扩展方法,注入属性(spring特有的属性),比如BeanNameAware.setBeanName,设置Bean的ID或者Name。
  7. 初始化bean,对各项属性赋初始化值,初始化前后执行BeanPostProcessor(作用于所有bean)扩展点方法,对bean进行修改。初始化前后除了BeanPostProcessor扩展点还有其他的扩展点,执行顺序如下:(1). 初始化前postProcessBeforeInitialization()。(2). 执行构造方法之后,执行 @PostConstruct 的方法。(3). 所有属性赋初始化值之后afterPropertiesSet()。(4). 初始化时,配置文件中指定的 init-method 方法。(5). 初始化后postProcessAfterInitialization()。先执行BeanPostProcessor扩展点的前置方法postProcessBeforeInitialization(),再执行bean本身的构造方法,再执行@PostConstruct标注的方法,所有属性赋值完成之后执行afterPropertiesSet(),然后执行 配置文件或注解中指定的 init-method 方法,最后执行BeanPostProcessor扩展点的后置方法postProcessAfterInitialization()。
  8. 此时已完成bean的初始化,在程序中就可以通过spring容器拿到这些初始化好的bean。
  9. 随着容器销毁,springbean也会销毁,销毁前后也有一系列的扩展点。销毁bean之前,执行@PreDestroy 的方法,销毁时,执行配置文件或注解中指定的 destroy-method 方法。

参考资料:
springBean生命周期

5.10 什么是MVC?

MVC原理图:
在这里插入图片描述
M-Model 模型(完成业务逻辑:有javaBean构成,service+dao+entity)
V-View 视图(做界面的展示 jsp,html……)
C-Controller 控制器(接收请求—>调用模型—>根据结果派发页面)

5.11 SpringMVC执行流程

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

5.12 Spring支持的几种bean的作用域

(1)singleton: 默认,每个容器中只有一个bean的实例,单例的bean由BeanFactory自身来维
护。
(2)prototype: 为每一个bean请求提供一个实例。
(3)request: 为每一个网络请求创建一个实例,在请求完成以后,bean会失效并被垃圾回收器回收。
(4)session: 与request范围类似,确保每个session中有一个bean的实例,在session过期后,bean会随之失效。
(5)global-session: 全局作用域,global-session和Portlet应用相关。当你的应用部署在Portlet容器中工作时,它包含很多portlet。如果你想要声明让所有的portlet共用全局的存储变量的话,那么这全局变量需要存储在global-session中。全局作用域与Servlet中的session作用域效果相同。

5.13 Spring基于xml注入bean的几种方式?

(1)Set方法注入;
(2)构造器注入:①通过index设置参数的位置;②通过type设置参数类型;
(3)静态工厂注入;
(4)实例工厂;

5.14 Spring用到了那些设计模式

简单工厂模式: Spring中的BeanFactory就是简单工厂模式的体现。根据传入一个唯一标识获取Bean对象。

工厂模式: Spring中的FactoryBean就是典型的工厂方法模式,实现了 FactoryBean 接口的 bean是一类叫做 factory 的 bean。其特点是,spring 在使用 getBean() 调用获得该 bean 时,会自动调用该 bean 的 getObject() 方法,所以返回的不是 factory 这个 bean,而是这个 bean.getOjbect()方法的返回值。

原型模式: 在 spring 中用到的原型模式有: scope=“prototype” ,每次获取的是通过克隆生成的新实例,对其进行修改时对原有实例对象不造成任何影响。

迭代器模式: 在 Spring 中有个 CompositeIterator 实现了 Iterator,Iterable 接口和 Iterator 接口,这两个都是迭代相关的接口。可以这么认为,实现了 Iterable 接口,则表示某个对象是可被迭代的。Iterator 接口相当于是一个迭代器,实现了 Iterator 接口,等于具体定义了这个可被迭代的对象时如何进行迭代的。

代理模式: Spring 中经典的 AOP,就是使用动态代理实现的,分 JDK 和 CGlib 动态代理。

适配器模式: Spring 中的 AOP 中 AdvisorAdapter 类,它有三个实现:MethodBeforAdviceAdapter、AfterReturnningAdviceAdapter、ThrowsAdviceAdapter。Spring会根据不同的 AOP 配置来使用对应的 Advice,与策略模式不同的是,一个方法可以同时拥有多个Advice。Spring 存在很多以 Adapter 结尾的,大多数都是适配器模式。

观察者模式: Spring 中的 Event 和 Listener。spring 事件:ApplicationEvent,该抽象类继承了EventObject 类,JDK 建议所有的事件都应该继承自 EventObject。spring 事件监听器:ApplicationListener,该接口继承了 EventListener 接口,JDK 建议所有的事件监听器都应该继承EventListener。

模板模式: Spring 中的 org.springframework.jdbc.core.JdbcTemplate 就是非常经典的模板模式的应用,里面的 execute 方法,把整个算法步骤都定义好了。

责任链模式: DispatcherServlet 中的 doDispatch() 方法中获取与请求匹配的处理器HandlerExecutionChain,this.getHandler() 方法的处理使用到了责任链模式。

注意:这里只是列举了部分设计模式,其实里面用到了还有享元模式、建造者模式等。可选择性的 回答,主要是怕你回答了迭代器模式,然后继续问你,结果你一问三不知,那就尴了尬了。

5.15 Spring 中的单例 Bean 是线程安全的么?

Spring 框架并没有对单例 Bean 进行任何多线程的封装处理。

关于单例 Bean 的线程安全和并发问题,需要开发者自行去搞定。单例的线程安全问题,并不是 Spring 应该去关心的。Spring 应该做的是,提供根据配置,创建单例 Bean 或多例 Bean 的功能。

当然,但实际上,大部分的 Spring Bean 并没有可变的状态,所以在某种程度上说 Spring 的单例Bean 是线程安全的。如果你的 Bean 有多种状态的话,就需要自行保证线程安全。最浅显的解决办法,就是将多态 Bean 的作用域(Scope)由 Singleton 变更为 Prototype。

5.16 Spring中什么时候@Transactional会失效

因为Spring事务是基于代理来实现的,所以某个加了@Transactional的⽅法只有是被代理对象调⽤时,那么这个注解才会⽣效,所以如果是被代理对象来调⽤这个⽅法,那么@Transactional是不会失效的。

同时如果某个⽅法是private的,那么@Transactional也会失效,因为底层cglib是基于⽗⼦类来实现的,⼦类是不能重载⽗类的private⽅法的,所以⽆法很好的利⽤代理,也会导致@Transactianal失效

5.17 Spring容器启动流程

  1. 在创建Spring容器,也就是启动Spring时:
  2. ⾸先会进⾏扫描,扫描得到所有的BeanDefinition对象,并存在⼀个Map中
  3. 然后筛选出⾮懒加载的单例BeanDefinition进⾏创建Bean,对于多例Bean不需要在启动过程中去进⾏创建,对于多例Bean会在每次获取Bean时利⽤BeanDefinition去创建
  4. 利⽤BeanDefinition创建Bean就是Bean的创建⽣命周期,这期间包括了合并BeanDefinition、推断构造⽅法、实例化、属性填充、初始化前、初始化、初始化后等步骤,其中AOP就是发⽣在初始化后这⼀步骤中
  5. 单例Bean创建完了之后,Spring会发布⼀个容器启动事件
  6. Spring启动结束
  7. 在源码中会更复杂,⽐如源码中会提供⼀些模板⽅法,让⼦类来实现,⽐如源码中还涉及到⼀些BeanFactoryPostProcessor和BeanPostProcessor的注册,Spring的扫描就是通过BenaFactoryPostProcessor来实现的,依赖注⼊就是通过BeanPostProcessor来实现的
  8. 在Spring启动过程中还会去处理@Import等注解

5.18 单例Bean和单例模式

单例模式表示JVM中某个类的对象只会存在唯⼀⼀个。⽽单例Bean并不表示JVM中只能存在唯⼀的某个类的Bean对象。

5.19 Spring中的事务是如何实现的

  1. Spring事务底层是基于数据库事务和AOP机制的。
  2. ⾸先对于使⽤了@Transactional注解的Bean,Spring会创建⼀个代理对象作为Bean。
  3. 当调⽤代理对象的⽅法时,会先判断该⽅法上是否加了@Transactional注解。
  4. 如果加了,那么则利⽤事务管理器创建⼀个数据库连接。
  5. 并且修改数据库连接的autocommit属性为false,禁⽌此连接的⾃动提交,这是实现Spring事务⾮常重要的⼀步。
  6. 然后执⾏当前⽅法,⽅法中会执⾏sql。
  7. 执⾏完当前⽅法后,如果没有出现异常就直接提交事务。
  8. 如果出现了异常,并且这个异常是需要回滚的就会回滚事务,否则仍然提交事务。
  9. Spring事务的隔离级别对应的就是数据库的隔离级别。
  10. Spring事务的传播机制是Spring事务⾃⼰实现的,也是Spring事务中最复杂的。
  11. Spring事务的传播机制是基于数据库连接来做的,⼀个数据库连接⼀个事务,如果传播机制配置为需要新开⼀个事务,那么实际上就是先建⽴⼀个数据库连接,在此新数据库连接上执⾏sql。

5.20 Spring这么解决循环依赖的?

六、Mybatis篇

6.1 什么是Mybatis?

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

(2)MyBatis 可以使用 XML 或注解来配置和映射原生信息,将 POJO映射成数据库中的记录,避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。

(3)通过xml 文件或注解的方式将要执行的各种 statement 配置起来,并通过java对象和statement中sql的动态参数进行映射生成最终执行的sql语句,最后由mybatis框架执行sql并将结果映射为java对象并返回。(从执行sql到返回result的过程)。

6.2 MyBatis的优点和缺点

优点:

  1. 基于SQL语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL写在XML里,解除sql与程序代码的耦合,便于统一管理;提供XML标签,支持编写动态SQL语句,并可重用。
  2. 与JDBC相比,减少了50%以上的代码量,消除了JDBC大量冗余的代码,不需要手动开关连接;
  3. 很好的与各种数据库兼容(因为MyBatis使用JDBC来连接数据库,所以只要JDBC支持的数据库MyBatis都支持)。
  4. 能够与Spring很好的集成;
  5. 提供映射标签,支持对象与数据库的ORM字段关系映射;提供对象关系映射标签,支持对象关系组件维护。

缺点:

  1. SQL语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL语句的功底有一定要求。
  2. SQL语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。

6.3 #{}和${}的区别是什么?

#{}是预编译处理、是占位符, ${}是字符串替换、是拼接符。

Mybatis 在处理#{}时,会将 sql 中的#{}替换为?号,调⽤ PreparedStatement 来赋值。Mybatis 在处理{}替换成变量的值,调⽤ Statement 来赋值。

#{} 的变量替换是在DBMS 中、变量替换后,#{} 对应的变量⾃动加上单引号,{} 的变量替换是在
DBMS 外、变量替换后,{} 对应的变量不会加上单引号

使⽤#{}可以有效的防⽌ SQL 注⼊, 提⾼系统安全性。

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

Mybatis使用RowBounds对象进行分页,它是针对ResultSet结果集执行的内存分页,而非物理分页。可以在sql内直接拼写带有物理分页的参数来完成物理分页功能,也可以使用分页插件来完成物理分页,比如:MySQL数据的时候,在原有SQL后面拼写limit。

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

6.5 Mybatis是如何将sql执行结果封装为目标对象并返回的?都有哪些映射形式?

第一种是使用标签,逐一定义数据库列名和对象属性名之间的映射关系。

第二种是使用sql列的别名功能,将列的别名书写为对象属性名。

有了列名与属性名的映射关系后,Mybatis通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。

6.6 Xml映射文件中,除了常见的select|insert|updae|delete标签之外,还有哪些标签?

<resultMap><parameterMap><sql><include><selectKey>,加上动态sql的9个标签 trim | where | set | foreach | if | choose | when | otherwise | bind 等,其中 <sql> 为sql片段标签,通过<include>标签引入sql片段,<selectKey>为不支持自增的主键生成策略标签。

6.7 Mybatis的一级、二级缓存

一级缓存: 基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当 Session flush 或 close 之后,该Session 中的所有 Cache 就将清空,默认打开一级缓存。

二级缓存: 与一级缓存其机制相同,默认也是采用 PerpetualCache,HashMap 存储,不同在于其存储作用域为Mapper(Namespace),并且可自定义存储源,如 Ehcache。默认不打开二级缓存,要开启二级缓存,使用二级缓存属性类需要实现Serializable序列化接口(可用来保存对象的状态),可在它的映射文件中配置 。

对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了C/U/D 操作后,默认该作用域下所有select 中的缓存将被 clear 掉并重新更新,如果开启了二级缓存,则只根据配置判断是否刷新。

6.8 通常一个Xml 映射文件,都会写一个Dao 接口与之对应,这个Dao 接口的工作原理是什么?Dao 接口里的方法,参数不同时,方法能重载吗?

Dao 接口即Mapper 接口。接口的全限名,就是映射文件中的namespace 的值;接口的方法名,就是映射文件中Mapper 的Statement 的id 值; 接口方法内的参数,就是传递给sql 的参数。

Mapper 接口是没有实现类的,当调用接口方法时,接口全限名+方法名拼接字符串作为key 值, 可唯一定位一个MapperStatement。在Mybatis 中, 每一个<select><insert><update><delete>标签,都会被解析为一个MapperStatement 对象。

Mapper 接口里的方法,是不能重载的,因为是使用全限名+方法名的保存和寻找策略。 Mapper 接口的工作原理是JDK 动态代理,Mybatis 运行时会使用JDK动态代理为Mapper 接口生成代理对象proxy,代理对象会拦截接口方法,转而执MapperStatement 所代表的sql,然后将sql 执行结果返回。

6.9 Mybatis的缓存机制

Mybatis整体:
在这里插入图片描述

一级缓存localCache

在应用运行过程中,我们有可能在一次数据库会话中,执行多次查询条件完全相同的 SQL,MyBatis 提供了一级缓存的方案优化这部分场景,如果是相同的 SQL 语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。

每个 SqlSession 中持有了 Executor,每个 Executor 中有一个 LocalCache。当用户发起查询时,MyBatis 根据当前执行的语句生成 MappedStatement,在 Local Cache 进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入 Local Cache,最后返回结果给用户。具体实现类的类关系图如下图所示:
在这里插入图片描述

  1. MyBatis 一级缓存的生命周期和 SqlSession 一致。
  2. MyBatis 一级缓存内部设计简单,只是一个没有容量限定的 HashMap,在缓存的功能性上有所欠缺。
  3. MyBatis 的一级缓存最大范围是 SqlSession 内部,有多个 SqlSession 或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为 Statement。

二级缓存

在上文中提到的一级缓存中,其最大的共享范围就是一个 SqlSession 内部,如果多个 SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用 CachingExecutor 装饰Executor,进入一级缓存的查询流程前,先在 CachingExecutor 进行二级缓存的查询,具体的工作流程如下所示。
在这里插入图片描述
二级缓存开启后,同一个 namespace 下的所有操作语句,都影响着同一个 Cache,即二级缓存被多个 SqlSession 共享,是一个全局的变量。

当开启缓存后,数据的查询执行的流程为:二级缓存----->一级缓存----->数据库

  1. MyBatis 的二级缓存相对于一级缓存来说,实现了 SqlSession 之间缓存数据的共享,同时粒度更加细,能够到 namespace 级别,通过 Cache 接口实现类不同的组合,对 Cache 的可控性也更强。
  2. MyBatis 在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
  3. 在分布式环境下,由于默认的 MyBatis Cache 实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将 MyBatis 的 Cache 接口实现,有一定的开发成本,直接使用 Redis、Memcached 等分布式缓存可能成本更低,安全性也更高。

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

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

它的原理是,使用CGLIB创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用a.getB().getName(),拦截器invoke()方法发现a.getB()是null值,那么就会单独发送事先保存好的查询关联B对象的sql,把B查询上来,然后调用a.setB(b),于是a的对象b属性就有值了,接着完成a.getB().getName()方法的调用。这就是延迟加载的基本原理。

6.11 MyBatis与Hibernate有哪些不同?

(1)Mybatis和hibernate不同,它不完全是一个ORM框架,因为MyBatis需要程序员自己编写Sql语句。

(2)Mybatis直接编写原生态sql,可以严格控制sql执行性能,灵活度高,非常适合对关系数据模型要求不高的软件开发,因为这类软件需求变化频繁,一但需求变化要求迅速输出成果。但是灵活的前提是mybatis无法做到数据库无关性,如果需要实现支持多种数据库的软件,则需要自定义多套sql映射文件,工作量大。

(3)Hibernate对象/关系映射能力强,数据库无关性好,对于关系模型要求高的软件,如果用hibernate开发可以节省很多代码,提高效率。

七、SpringBoot篇

7.1 SpringBoot优点

1. 独立运行: Spring Boot而且内嵌了各种servlet容器,Tomcat、Jetty等,现在不再需要打成war包部署到容器中,Spring Boot只要打成一个可执行的jar包就能独立运行,所有的依赖包都在一个jar包内。

2. 简化配置: spring-boot-starter-web启动器自动依赖其他组件,简少了maven的配置。

3. 自动配置: Spring Boot能根据当前类路径下的类、jar包来自动配置bean,如添加一个spring-boot-starterweb启动器就能拥有web的功能,无需其他配置。

4. 无代码生成和XML配置: Spring Boot配置过程中无代码生成,也无需XML配置文件就能完成所有配置工作,这一切都是借助于条件注解完成的,这也是Spring4.x的核心功能之一。

5. 应用监控: Spring Boot提供一系列端点可以监控服务及应用,做健康检测。

7.2 Spring Boot 的核心注解是哪个?它主要由哪几个注解组成的?

启动类上面的注解是@SpringBootApplication,它也是 Spring Boot 的核心注解,主要组合包含了以下 3 个注解:

@SpringBootConfiguration: 组合了 @Configuration 注解,实现配置文件的功能。

@EnableAutoConfiguration: 打开自动配置的功能,也可以关闭某个自动配置的选项,如关闭数据源自动配置功能:@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class})。

@ComponentScan: Spring组件扫描。

7.3 Spring Boot中的监视器是什么?

Spring boot actuator是spring启动框架中的重要功能之一。Spring boot监视器可帮助您访问生产环境中正在运行的应用程序的当前状态。有几个指标必须在生产环境中进行检查和监控。即使一些外部应用程序可能正在使用这些服务来向相关人员触发警报消息。监视器模块公开了一组可直接作为HTTP URL访问的REST端点来检查状态。

7.4 如何使用Spring Boot实现异常处理?

Spring提供了一种使用ControllerAdvice处理异常的非常有用的方法。 我们通过实现一个ControlerAdvice类,来处理控制器类抛出的所有异常。

7.5 运行Spring Boot有哪几种方式?

1)打包用命令或者放到容器中运行
2)用 Maven/Gradle 插件运行
3)直接执行 main 方法运行

7.6 你如何理解 Spring Boot 中的 Starters?

Starters可以理解为启动器,它包含了一系列可以集成到应用里面的依赖包,你可以一站式集成Spring 及其他技术,而不需要到处找示例代码和依赖包。如你想使用 Spring JPA 访问数据库,只要加入 spring-boot-starter-data-jpa 启动器依赖就能使用了。

7.7 Spring Boot 的核心配置文件有哪几个?它们的区别是什么?

pring Boot 的核心配置文件是 application 和 bootstrap 配置文件。

application 配置文件这个容易理解,主要用于 Spring Boot 项目的自动化配置。

bootstrap 配置文件有以下几个应用场景。

  1. 使用 Spring Cloud Config 配置中心时,这时需要在 bootstrap 配置文件中添加连接到配置中心的配置属性来加载外部配置中心的配置信息;
  2. 一些固定的不能被覆盖的属性;
  3. 一些加密/解密的场景;

7.8 SpringBoot自动配置原理

Spring Boot 通过@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载META-INF/spring.factories中的自动配置类实现自动装配,自动配置类其实就是通过@Conditional按需加载的配置类,想要其生效必须引入spring-boot-starter-xxx包实现起步依赖

八、MySQL篇

8.1 概念

MySQL是一个关系型数据库管理系统,由瑞典MySQL AB 公司开发,属于 Oracle 旗下产品。MySQL 是最流行的关系型数据库管理系统之一,在 WEB 应用方面,MySQL是最好的 RDBMS (Relational Database Management System,关系数据库管理系统) 应用软件之一。

8.2 基本数据类型

数值类型: 包括:TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT,这几个为 整数类型,其中TINYINT占1字节、SMALLINT占2个字节、MEDIUMINT占3个字节、INT占4个字节、BIGINT占8个字节。这几个都可以加UNSIGNED(无符号整数)属性,都可以指定数据长度。

小数类型: FLOAT、DOUBLE、DECIMAL。

字符串类型: VARCHAR、CHAR、TEXT、BLOB。

日期类型: DATETIME、DATE 、 TIMESTAMP。

8.3 innerjoin、leftjoin、rightjoin三者之间的区别

innerjoin(等值连接) 只返回两个表中联结字段相等的行
leftjoin(左联接) 返回包括左表中的所有记录和右表中联结字段相等的记录
rightjoin(右联接) 返回包括右表中的所有记录和左表中联结字段相等的记录

8.4 SQL语句中where与having的区别

where是一个约束声明,使用where来约束来之数据库的数据,where是在结果返回之前起作用的。
having是一个过滤声明,having是对分组之后的结果筛选。

8.5 char与varchar的区别

数据长度:CHAR 是定长的,而 VARCHAR 是变长。
存储方式:CHAR 对英文字符(ASCII)占用 1 字节,对一个汉字使用用 2 字节。而 VARCHAR 对每个字符均使用 2 字节。

8.6 数据库三大范式

第一范式(1NF): 字段(或属性)是不可分割的最小单元,即不会有重复的列,体现原子性。

第二范式(2NF): 满足 1NF 前提下,存在一个候选码,非主属性全部依赖该候选码,即存在主键,体现唯一性,专业术语则是消除部分函数依赖。

第三范式(3NF): 满足 2NF 前提下,非主属性必须互不依赖,消除传递依赖。

8.7 数据库事务是什么?

事务是作为一个逻辑单元执行的一系列操作,一个逻辑工作单元必须有四个属性,称为ACID(原子性、一致性、隔离性和持久性)属性,只有这样才能成为一个事务:

原子性事务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。

一致性事务在完成时,必须使所有的数据都保持一致状态。在相关数据库中,所有规则都必须应用于事务的修改,以保持所有数据的完整性。事务结束时,所有的内部数据结构(如B树索引或双向链表)都必须是正确的。

隔离性由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。这称为可串行性,因为它能够重新装载起始数据,并且重播一系列事务,以使数据结束时的状态与原始事务执行的状态相同。

持久性事务完成之后,它对于系统的影响是永久性的。该修改即使出现系统故障也将一直保持。

8.8 事务的四大特性(ACID)

原子性(Atomicity): 事务是一个完整的操作,事务的每个操作是不可分的,要么都执行,要么都不执行。
一致性(Consistency): 当事务完成时,数据必须处于一致状态。
隔离性(Isolation): 并发事务之间彼此隔离、独立,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的。
持久性(Durability): 事务完成后,对数据库的修改永久保持。

8.9 事务并发问题

脏读、幻读和不可重复读。

8.10 什么是脏读、幻读和不可重复读?

脏读:一个事务读取到另一个事务尚未提交的数据。 对于事务A和事务B,事务A读取事务B修改后的数据,如果事务B未提交,然后事务B回滚,那么事务A读取的就是脏数据。

不可重复读:一个事务中两次读取的数据的内容不一致。 事务A多次读取同一条数据,如果事务B在事务A读取过程中,对数据修改并提交,那么事务A多次读取的数据不一致。

幻读:一个事务中两次读取的数据量不一致。 事务A读取表数据量是,如果事务B在事务A读取过程中插入了几条数据,那么事务A就会出现两次读取数据量不一致。

不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。

8.11 事务的隔离级别

隔离级别 脏读 不可重复读 幻读
ReadUncommitted(读取未提交)
ReadCommitted(读已提交)
RepeatableRead(可重复读)
Serializable(可串行化)

mysql默认的事务隔离级别为RepeatableRead(可重复读),但它解决了脏读、不可重复读、幻读。

8.12 索引数据结构

MySQL主要有两种结构:hash索引和B+Tree索引,InnoDB引擎默认是B+Tree索引。

8.13 索引分类

聚簇索引: 指索引的键值的逻辑顺序与表中相应行的物理顺序一致,即每张表只能有一个聚簇索引,也就是我们常说的主键索引。

非聚簇索引: 的逻辑顺序则与数据行的物理顺序不一致。

普通索引: MySQL 中的基本索引类型,没有什么限制,允许在定义索引的列中插入重复值和空值,纯粹为了提高查询效率。通过 ALTER TABLE table_name ADD INDEX index_name (column) 创建。

唯一索引: 索引列中的值必须是唯一的,但是允许为空值。通过 ALTER TABLE table_name ADD UNIQUE index_name (column) 创建。

主键索引: 特殊的唯一索引,也成聚簇索引,不允许有空值,并由数据库帮我们自动创建。

组合索引: 组合表中多个字段创建的索引,遵守最左前缀匹配规则。

全文索引: 只有在 MyISAM 引擎上才能使用,同时只支持 CHAR、VARCHAR、TEXT 类型字段上使用。

8.14 为什么建议InnoDB必须建主键?

对于InnoDB来说,如果不手动建主键索引,MySQL底层依然会帮我们创建一个聚集索引来维护整张表的所有数据,因为B+Tree必须依靠索引才能建立。为什么建议InnoDB必须建主键呢?因为本身数据库的资源就非常宝贵,我们尽量能手动做的就不要麻烦MySQL去帮我们维护,说白了就是降低数据库开销。

8.15 为什么推荐使用整型主键?

InnoDB引擎B+Tree索引数据结构,右边叶子节点大于父节点、左边叶子节点小于父节点,如果使用UUID字符串比较大小,效率很低,因为字符串需要遍历比较,显然整型更具有优势。

8.16 Hash索引与B+Tree索引的区别

Hash索引:
1)Hash 进行等值查询更快,但无法进行范围查询。因为经过 Hash 函数建立索引之后,索引的顺序与原顺序无法保持一致,故不能支持范围查询。同理,也不支持使用索引进行排序。

2)Hash 不支持模糊查询以及多列索引的最左前缀匹配,因为 Hash 函数的值不可预测,如 AA 和 AB 的算出的值没有相关性。

3)Hash 任何时候都避免不了回表查询数据.

4)虽然在等值上查询效率高,但性能不稳定,因为当某个键值存在大量重复时,产生 Hash 碰撞,此时查询效率反而可能降低。

B+Tree索引:
1)B+ 树本质是一棵查找树,自然支持范围查询和排序。

2)在符合某些条件(聚簇索引、覆盖索引等)时候可以只通过索引完成查询,不需要回表。

3)查询效率比较稳定,因为每次查询都是从根节点到叶子节点,且为树的高度。

8.17 为什么使用B+Tree?

B+Tree将数据的存储都放在了叶子节点,非叶子节点全部用来存放冗余索引,这样可以保证非叶子节点可以存储更多的索引,因为决定B+Tree高度的就是非叶子节点,如果非叶子节点可以存储更多的值就会使树的整体高度变少,从而降低磁盘IO次数,降低系统消耗。

8.18 什么是最左匹配原则?

顾名思义,最左优先,以最左边为起点任何连续的索引都能匹配上。同时遇到范围查询(>、<、between、like)就会停止匹配。

8.19 索引下推

索引下推(Index condition pushdown) 简称 ICP,在 Mysql 5.6 版本上推出的一项用于优化查询的技术。

在不使用索引下推的情况下,在使用非主键索引进行查询时,存储引擎通过索引检索到数据,然后返回给 MySQL 服务器,服务器判断数据是否符合条件。

而有了索引下推之后,如果存在某些被索引列的判断条件时,MySQL 服务器将这一部分判断条件传递给存储引擎,然后由存储引擎通过判断索引是否符合 MySQL 服务器传递的条件,只有当索引符合条件时才会将数据检索出来返回给 MySQL 服务器。

索引条件下推优化可以减少存储引擎查询基础表的次数,也可以减少 MySQL 服务器从存储引擎接收数据的次数。

8.20 like语句左边的’%'不会使用索引

select * from abc where title like '%XX';   #不能使用索引
select * from abc where title like 'XX%';   #非前导模糊查询,可以使用索引

8.21 范围条件右边不会使用索引

范围条件有:<、<=、>、>=、between等。

索引最多用于一个范围列,如果查询条件中有两个范围列则无法全用到索引。

select * from abc where a>0;             #可以使用索引
select * from abc where a>0 and b>0;     #不会使用索引

8.22 负向条件不会使用索引

负向条件有:!=、<>、not in、not exists、not like 等。

select * from abc where a!=0 and a!=1;             #不会使用索引
select * from abc where a in(0,1,2,3);             #优化查询可以使用索引

8.23 在索引列任何操作(函数,计算、表达式)会导致索引失效

select * from abc where year(create_time) <= '2022'   #索引失效
select * from abc where create_time <= '2022-01-01'   #可以使用索引

select * from abc where a/10=2   #索引失效
select * from abc where a=10*2   #可以使用索引

8.24 强制类型转换会导致索引失效

字符串类型不加单引号会导致索引失效,因为mysql会自己做类型转换,相当于在索引列上进行了操作。

#前提a字段是varchar类型
select * from abc where a=8393881     #索引失效
select * from abc where a='8393881'   #可以使用索引

8.25 组合索引要匹配最左前缀原则

组合索引的字段数不允许超过5个。如果在a,b,c三个字段上建立联合索引 index(a,b,c),那么他会自动建立 a、(a,b)、(a,b,c) 三组索引。

8.26 减少select*的使用

MySQL数据库是按照行的方式存储,而数据存取操作都是以一个页大小进行IO操作的,每个IO单元中存储了多行,每行都是存储了该行的所有字段。所以无论取一个字段还是多个字段,实际上数据库在表中需要访问的数据量其实是一样的。

8.27 优化Group by,使用where子句替换Having子句

避免使用having子句,having只会在检索出所有记录之后才会对结果集进行过滤,这个处理需要排序分组,如果能通过where子句提前过滤查询的目,就可以减少这方面的开销。

on、where、having这三个都可以加条件的子句,on是最先执行,where次之,having最后。

提高GROUP BY 语句的效率, 可以通过将不需要的记录在GROUP BY 之前过滤掉。

select a,b,c from abc group by a having a=0      #效率低
select a,b,c from abc where a=0 group by a       #效率高

8.28 使用union all 替换 union

当SQL语句需要union两个查询结果集合时,这两个结果集合会以union all的方式被合并,然后再输出最终结果前进行排序。如果用union all替代union,这样排序就不是不要了,效率就会因此得到提高.。需要注意的是,UNION ALL 将重复输出两个结果集合中相同记录。

8.29 优化深度分页的场景:利用延迟关联或者子查询

对于 limit m, n 的分页查询,越往后面翻页(即m越大的情况下)SQL的耗时会越来越长,对于这种应该先取出主键id,然后通过主键id跟原表进行Join关联查询。因为MySQL 并不是跳过 offset 行,而是取 offset+N 行,然后放弃前 offset 行,返回 N 行,那当 offset 特别大的时候,效率就非常的低下,要么控制返回的总页数,要么对超过特定阈值的页数进行 SQL 改写。

# 延迟关联:通过使用覆盖索引查询返回需要的主键,再根据主键关联原表获得需要的数据

# 覆盖索引:select的数据列只用从索引中就能够得到,不用回表查询

select a.* from1 a,(select id from1 where 条件 limit 100000,20) b where a.id=b.id;

8.30 锁分类

锁粒度: 表锁、页锁、行锁。

锁性质: 共享(读)锁、排他(写)锁、意向共享(读)锁、意向排他(写)锁。

锁思想: 悲观锁、乐观锁。

8.31 表锁

表级别的锁定是MySQL各存储引擎中最大颗粒度的锁定机制。该锁定机制最大的特点是实现逻辑非常简单,带来的系统负面影响最小。所以获取锁和释放锁的速度很快。由于表级锁一次会将整个表定,所以可以很好的避免困扰我们的死锁问题。

使用表级锁定的主要是MyISAM,MEMORY,CSV等一些非事务性存储引擎。

8.32 行锁

与表锁正相反,行锁最大的特点就是锁定对象的颗粒度很小,也是目前各大数据库管理软件所实现的锁定颗粒度最小的。由于锁定颗粒度很小,所以发生锁定资源争用的概率也最小,能够给予应用程序尽可能大的并发处理能力从而提高系统的整体性能。

虽然能够在并发处理能力上面有较大的优势,但是行级锁定也因此带来了不少弊端。由于锁定资源的颗粒度很小,所以每次获取锁和释放锁需要做的事情也更多,带来的消耗自然也就更大了。此外,行级锁定也最容易发生死锁。

8.33 页锁

除了表锁、行锁外,MySQL还有一种相对偏中性的页级锁,页锁是MySQL中比较独特的一种锁定级别,在其他数据库管理软件中也并不是太常见。页级锁定的特点是锁定颗粒度介于行级锁定与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力也同样是介于上面二者之间。另外,页级锁定和行级锁定一样,会发生死锁。

使用页级锁定的主要是BerkeleyDB存储引擎。

8.34 共享(读)锁(Share Lock)

共享锁,又叫读锁,是读取操作(SELECT)时创建的锁。其他用户可以并发读取数据,但在读锁未释放前,也就是查询事务结束前,任何事务都不能对数据进行修改(获取数据上的写锁),直到已释放所有读锁。

如果事务A对数据B(1024房)加上读锁后,则其他事务只能对数据B上加读锁,不能加写锁。获得读锁的事务只能读数据,不能修改数据。

SELECTLOCK IN SHARE MODE;

在查询语句后面增加LOCK IN SHARE MODE,MySQL就会对查询结果中的每行都加读锁,当没有其他线程对查询结果集中的任何一行使用写锁时,可以成功申请读锁,否则会被阻塞。其他线程也可以读取使用了读锁的表,而且这些线程读取的是同一个版本的数据。

8.35 排他(写)锁(Exclusive Lock)

排他锁又称写锁、独占锁,如果事务A对数据B加上写锁后,则其他事务不能再对数据B加任何类型的锁。获得写锁的事务既能读数据,又能修改数据。

SELECTFOR UPDATE;

在查询语句后面增加FOR UPDATE,MySQL 就会对查询结果中的每行都加写锁,当没有其他线程对查询结果集中的任何一行使用写锁时,可以成功申请写锁,否则会被阻塞。另外成功申请写锁后,也要先等待该事务前的读锁释放才能操作。

8.36 意向锁(Intention Lock)

意向锁属于表级锁,其设计目的主要是为了在一个事务中揭示下一行将要被请求锁的类型。InnoDB 中的两个表锁:

意向共享锁(IS): 表示事务准备给数据行加入共享锁,也就是说一个数据行加共享锁前必须先取得该表的IS锁;

意向排他锁(IX): 类似上面,表示事务准备给数据行加入排他锁,说明事务在一个数据行加排他锁前必须先取得该表的IX锁。
意向锁是 InnoDB 自动加的,不需要用户干预。

再强调一下,对于INSERT、UPDATE和DELETE,InnoDB 会自动给涉及的数据加排他锁;对于一般的SELECT语句,InnoDB 不会加任何锁,事务可以通过以下语句显式加共享锁或排他锁。

8.37 乐观锁

用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加1。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。

8.38 悲观锁

在进行每次操作时都要通过获取锁才能进行对相同数据的操作,这点跟java中synchronized很相似,共享锁(读锁)和排它锁(写锁)是悲观锁的不同的实现。

8.39 MVCC

MVCC 的英文全称是 Multiversion Concurrency Control,中文意思是多版本并发控制,可以做到读写互相不阻塞,主要用于解决不可重复读和幻读问题时提高并发效率。

其原理是通过数据行的多个版本管理来实现数据库的并发控制,简单来说就是保存数据的历史版本。可以通过比较版本号决定数据是否显示出来。读取数据的时候不需要加锁可以保证事务的隔离效果。

推荐博客

【MySQL笔记】正确的理解MySQL的MVCC及实现原理

8.40 隔离级别和锁关系

1)在 Read Uncommitted 级别下,读取数据不需要加共享锁,这样就不会跟被修改的数据上的排他锁冲突;

2)在 Read Committed 级别下,读操作需要加共享锁,但是在语句执行完以后释放共享锁;

3)在 Repeatable Read 级别下,读操作需要加共享锁,但是在事务提交之前并不释放共享锁,也就是必须等待事务执行完毕以后才释放共享锁;

4)在 SERIALIZABLE 级别下,限制性最强,因为该级别锁定整个范围的键,并一直持有锁,直到事务完成。

8.41 日志分类

MySQL主要包括以下7种日志

重做日志(redo log)
回滚日志(undo log)
归档日志(binlog)
错误日志(errorlog)
慢查询日志(slow query log)
一般查询日志(general log)
中继日志(relay log)

8.42 重做日志(redo log)

如果每次更新操作都需要写磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者就用了WAL 技术来提升更新效率。

WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘

具体来说,当有一条update语句要执行的时候,InnoDB 引擎就会先把记录写到 redo log里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。InnoDB 的 redo log 是固定大小的。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe

crash-safe 就是落盘处理,将数据存储到了磁盘上,断电重启也不会丢失。

8.43 归档日志(binlog)

MySQL 其实是分为 server层 和 引擎层两部分,Server 层:它主要做的是 MySQL 功能层面的事情,引擎层:负责存储相关的具体事宜。

redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为binlog(归档日志),其实就是用来恢复数据用的。

8.44 重做日志(redo log)与归档日志(binlog)的区别

1)redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎共用。

2)redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=1 这一行的 c 字段加 1 ”。

3)redo log 是循环写的,空间固定会用完然后复写;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

8.45 两阶段提交

由于存储引擎层与server层之间采用的是内部XA(保证两个事务的一致性,这里主要保证redo log和binlog的原子性),所以提交分为prepare阶段与commit阶段,也就是我们说的两阶段提交。

下面这条sql语句分析两阶段提交

update `user` set integral = integral+10 where user_id = 542;

1)执行器先从内存中找user_id = 542这条数据,如果有直接返回,否则从磁盘读取到内存,再返回。

2)数据修改,执行器拿到数据,把integral字段+10。

3)写redo log,引擎将数据写入内存中,同时将此更新操作写入redo log,此时redo log处于prepare阶段。

4)写biglog,然后告知执行器执行完成了,随时可以提交事务。执行器生成这个操作的 binlog,并把 binlog 同步到磁盘。

5)执行器调用引擎的提交事务接口执行修改操作,需要将在二级索引上做的修改,写入到change buffer page,等到下次有其他sql需要读取该二级索引时,再去与二级索引做merge,引擎把刚刚写入的 redo log 标记上(commit)状态,实际上是加上了一个与binlog对应的XID,使两个日志逻辑保持一致,到此结束,更新流程闭环。
在这里插入图片描述

8.44 MySQL数据库引擎有哪些?

在这里插入图片描述
mysql常用引擎包括:MYISAM、Innodb、Memory、MERGE。

  1. MYISAM: 全表锁,拥有较高的执行速度,不支持事务,不支持外键,并发性能差,占用空间相对较小,对事务完整性没有要求,以select、insert为主的应用基本上可以使用这引擎。
  2. Innodb: 行级锁,提供了具有提交、回滚和崩溃回复能力的事务安全,支持自动增长列,支持外键约束,并发能力强,占用空间是MYISAM的2.5倍,处理效率相对会差一些。
  3. Memory: 全表锁,存储在内容中,速度快,但会占用和数据量成正比的内存空间且数据在mysql重启时会丢失,默认使用HASH索引,检索效率非常高,但不适用于精确查找,主要用于那些内容变化不频繁的代码表。
  4. MERGE: 是一组MYISAM表的组合。

8.45 InnoDB与MyISAM的区别

  1. InnoDB支持事务,MyISAM不支持,对于InnoDB每一条SQL语言都默认封装成事务,自动提交,这样会影响速度,所以最好把多条SQL语言放在begin和commit之间,组成一个事务;
  2. InnoDB支持外键,而MyISAM不支持。对一个包含外键的InnoDB表转为MYISAM会失败;
  3. InnoDB是聚集索引,数据文件是和索引绑在一起的,必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。而MyISAM是非聚集索引,数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。
  4. InnoDB不保存表的具体行数,执行select count(*) from table时需要全表扫描。而MyISAM用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快;
  5. Innodb不支持全文索引,而MyISAM支持全文索引,查询效率上MyISAM要高;

推荐博客

MySQL数据库:SQL语句的执行过程

九、Redis篇

9.1 什么是Redis?

Redis 是一个开源(BSD 许可)、基于内存、支持多种数据结构的存储系统,可以作为数据库、缓存和消息中间件。它支持的数据结构有字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等,除此之外还支持 bitmaps、hyperloglogs 和地理空间(geospatial )索引半径查询等功能。

它内置了复制(Replication)、LUA 脚本(Lua scripting)、LRU 驱动事件(LRU eviction)、事务(Transactions)和不同级别的磁盘持久化(persistence)功能,并通过 Redis 哨兵(哨兵)和集群(Cluster)保证缓存的高可用性(High availability)。

9.2 为什么要用缓存

使用缓存的目的就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性能,带来更高的并发量。Redis 的读写性能比Mysql 好的多,我们就可以把Mysql 中的热点数据缓存到Redis 中,提升读取性能,同时也减轻了Mysql 的读取压力。

9.3 Redis优点

1、速度快,因为数据存在内存中,类似于HashMap,HashMap 的优势就是查找和操作的时间复杂度都是O(1)。

2、支持丰富数据类型,支持string,list, set, Zset, hash 等。

3、支持事务,操作都是原子性, 所谓的原子性就是对数据的更改要么全部执行,要么全部不执行。

4、丰富的特性:可用于缓存,消息,按key 设置过期时间,过期后将会自动删除。

9.4 Redis为何使用单线程

因为 Redis 是基于内存的操作,CPU 不会成为 Redis 的瓶颈,而最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。

1)不需要各种锁的性能消耗

Redis 的数据结构并不全是简单的 Key-Value,还有 List,Hash 等复杂的结构,这些结构有可能会进行很细粒度的操作,比如在很长的列表后面添加一个元素,在hash当中添加或者删除一个对象。这些操作可能就需要加非常多的锁,导致的结果是同步开销大大增加。

2)单线程多进程集群方案

单线程的威力实际上非常强大,每核心效率也非常高,多线程自然是可以比单线程有更高的性能上限,但是在今天的计算环境中,即使是单机多线程的上限也往往不能满足需要了,需要进一步摸索的是多服务器集群化的方案,这些方案中多线程的技术照样是用不上的。

9.5 为什么Redis 单线程模型效率也能那么高?

1)C语言实现,效率高
2)纯内存操作
3)基于非阻塞的IO多路复用模型
4)单线程的话就能避免多线程的频繁上下文切换问题
5)高效的数据结构(全称采用hash结构,读取速度非常快,对数据存储进行了一些优化,比如亚索表,跳表等)

9.6 Redis的线程模型

Redis 内部使用文件事件处理器 file event handler ,这个文件事件处理器是单线程的,所以Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket ,根据 socket 上的事件来选择对应的事件处理器进行处理。

文件事件处理器的结构包含 4 个部分:

  1. 多个 socket 。
  2. IO 多路复用程序。
  3. 文件事件分派器。
  4. 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。

多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

来看客户端与Redis 的一次通信过程:
在这里插入图片描述
下面来大致说一下这个图:

  1. 客户端Socket01 向 Redis 的 Server Socket 请求建立连接,此时 Server Socket 会产生一个AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该事件压入队列中。文件事件分派器从队列中获取该事件,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 Socket01,并将该 Socket01 的AE_READABLE 事件与命令请求处理器关联。
  2. 假设此时客户端发送了一个 set key value 请求,此时 Redis 中的 Socket01 会产生AE_READABLE 事件,IO 多路复用程序将事件压入队列,此时事件分派器从队列中获取到该事件,由于前面 Socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 Socket01 的 set key value 并在自己内存中完成 set key value 的设置。操作完成后,它会将 Socket01 的AE_WRITABLE 事件与令回复处理器关联。
  3. 如果此时客户端准备好接收返回结果了,那么 Redis 中的 Socket01 会产生一个AE_WRITABLE 事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 Socket01 输入本次操作的一个结果,比如 ok ,之后解除 Socket01的AE_WRITABLE 事件与命令回复处理器的关联。

9.7 Redis 的同步机制

Redis 支持主从同步、从从同步。如果是第一次进行主从同步,主节点需要使用 bgsave 命令,再将后续修改操作记录到内存的缓冲区,等 RDB 文件全部同步到复制节点,复制节点接受完成后将RDB 镜像记载到内存中。等加载完成后,复制节点通知主节点将复制期间修改的操作记录同步到复制节点,即可完成同步过程。

9.8 pipeline 有什么好处,为什么要用 pipeline?

使用 pipeline(管道)的好处在于可以将多次 I/O 往返的时间缩短为一次,但是要求管道中执行的指令间没有因果关系。

pipeline 的原因在于可以实现请求/响应服务器的功能,当客户端尚未读取旧响应时,它也可以处理新的请求。如果客户端存在多个命令发送到服务器时,那么客户端无需等待服务端的每次响应才能执行下个命令,只需最后一步从服务端读取回复即可。

9.9 Redis单线程为什么这么快

Redis基于Reactor模式开发了网络事件处理器、文件事件处理器 fileeventhandler。它是单线程的, 所以 Redis才叫做单线程的模型,它采用IO多路复用机制来同时监听多个Socket,根据Socket上的事件类型来选择对应的事件处理器来处理这个事件。可以实现高性能的网络通信模型,又可以跟内部其他单线程的模块进行对接,保证了 Redis内部的线程模型的简单性。

文件事件处理器的结构包含4个部分:多个Socket、IO多路复用程序、文件事件分派器以及事件处理器(命令请求处理器、命令回复处理器、连接应答处理器等)。

多个 Socket 可能并发的产生不同的事件,IO多路复用程序会监听多个 Socket,会将 Socket 放入一个队列中排队,每次从队列中有序、同步取出一个 Socket 给事件分派器,事件分派器把 Socket 给对应的事件处理器。

然后一个 Socket 的事件处理完之后,IO多路复用程序才会将队列中的下一个 Socket 给事件分派器。文件事件分派器会根据每个 Socket 当前产生的事件,来选择对应的事件处理器来处理。

  1. Redis启动初始化时,将连接应答处理器跟AE_READABLE事件关联。
  2. 若一个客户端发起连接,会产生一个AE_READABLE事件,然后由连接应答处理器负责和客户端建立 连接,创建客户端对应的socket,同时将这个socket的AE_READABLE事件和命令请求处理器关联,使 得客户端可以向主服务器发送命令请求。
  3. 当客户端向Redis发请求时(不管读还是写请求),客户端socket都会产生一个AE_READABLE事件,触发命令请求处理器。处理器读取客户端的命令内容, 然后传给相关程序执行。
  4. 当Redis服务器准备好给客户端的响应数据后,会将socket的AE_WRITABLE事件和命令回复处理器关联,当客户端准备好读取响应数据时,会在socket产生一个AE_WRITABLE事件,由对应命令回复处 理器处理,即将准备好的响应数据写入socket,供客户端读取。
  5. 命令回复处理器全部写完到 socket 后,就会删除该socket的AE_WRITABLE事件和命令回复处理器的映射。

单线程快的原因:

  1. 纯内存操作
  2. 核心是基于非阻塞的IO多路复用机制
  3. 单线程反而避免了多线程的频繁上下文切换带来的性能问题

9.10 简述Redis事务实现

事务开始: MULTI命令的执行,标识着一个事务的开始。MULTI命令会将客户端状态的 flags属性中打开REDIS_MULTI标识来完成的。

命令入队: 当一个客户端切换到事务状态之后,服务器会根据这个客户端发送来的命令来执行不同的操作。如果客 户端发送的命令为MULTI、EXEC、WATCH、DISCARD中的一个,立即执行这个命令,否则将命令放入一 个事务队列里面,然后向客户端返回QUEUED回复。

  1. 如果客户端发送的命令为 EXEC、DISCARD、WATCH、MULTI 四个命令的其中一个,那么服务器立即执行这个命令。
  2. 如果客户端发送的是四个命令以外的其他命令,那么服务器并不立即执行这个命令。首先检查此命令的格式是否正确,如果不正确,服务器会在客户端状态(redisClient)的 flags 属性关闭REDIS_MULTI 标识,并且返回错误信息给客户端。如果正确,将这个命令放入一个事务队列里面,然后向客户端返回 QUEUED 回复事务队列是按照FIFO的方式保存入队的命令。

事务执行: 客户端发送 EXEC 命令,服务器执行 EXEC 命令逻辑。

  1. 如果客户端状态的 flags 属性不包含 REDIS_MULTI 标识,或者包含 REDIS_DIRTY_CAS 或者REDIS_DIRTY_EXEC 标识,那么就直接取消事务的执行。
  2. 否则客户端处于事务状态(flags有 REDIS_MULTI 标识),服务器会遍历客户端的事务队列,然后执行事务队列中的所有命令,最后将返回结果全部返回给客户端;

Redis不支持事务回滚机制,但是它会检查每一个事务中的命令是否错误。Redis事务不支持检查那些程序员自己逻辑错误。例如对 String 类型的数据库键执行对 HashMap 类型的操作!

1)WATCH 命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。
2)MULTI命令用于开启一个事务,它总是返回OK。MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
3)EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。当操作被打断时,返回空值 nil 。
4)通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。
5)UNWATCH命令可以取消watch对所有key的监控。

9.11 Redis 持久化方式

Redis 提供两种持久化机制RDB 和 AOF 机制:

RDB 持久化方式

是指用数据集快照的方式半持久化模式)记录 redis 数据库的所有键值对,在某个时间点将数据写入一个临时文件,持久化结束后,用这个临时文件替换上次持久化的文件,达到数据恢复。

优点:

  1. 只有一个文件dump.rdb ,方便持久化。
  2. 容灾性好,一个文件可以保存到安全的磁盘。
  3. 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单
  4. 独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis 的高性能)
  5. 相对于数据集大时,比 AOF 的启动效率更高。

缺点:

  1. 数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 Redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。

AOF(Append-only file) 持久化方式

是指所有的命令行记录以Redis 命令请求协议的格式完全持久化存储,保存为 AOF 文件。

优点:

  1. 数据安全, AOF 持久化可以配置appendfsync 属性,有 always,每进行一次命令操作就记录到 AOF 文件中一次。
  2. 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
  3. AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令进行合并重写),可以删除其中的某些命令(比如误操作的flushall )

缺点:

  1. AOF 文件比 RDB 文件大,且恢复速度慢。
  2. 数据集大的时候,比 RDB 启动效率低。.

9.12 持久化有两种,那应该怎么选择呢?

  1. 不要仅仅使用 RDB ,因为那样会导致你丢失很多数据。
  2. 也不要仅仅使用 AOF ,因为那样有两个问题,第一,你通过 AOF 做冷备没有 RDB 做冷备的恢复速度更快; 第二, RDB 每次简单粗暴生成数据快照,更加健壮,可以避免 AOF 这种复杂的备份和恢复机制的 bug 。
  3. Redis 支持同时开启开启两种持久化方式,我们可以综合使用 AOF 和 RDB 两种持久化机制,用 AOF 来保证数据不丢失,作为数据恢复的第一选择; 用 RDB 来做不同程度的冷备,在 AOF文件都丢失或损坏不可用的时候,还可以使用 RDB 来进行快速的数据恢复。
  4. 如果同时使用 RDB 和 AOF 两种持久化机制,那么在 Redis 重启的时候,会使用 AOF 来重新构建数据,因为 AOF 中的数据更加完整。

9.13 Redis的过期键的删除策略

Redis是key-value数据库,我们可以设置Redis中缓存的key的过期时间。Redis的过期策略就是指当Redis中缓存的key过期了,Redis如何处理。

惰性过期: 只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

定期过期: 每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

(expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。)

Redis中同时使用了惰性过期和定期过期两种过期策略。

9.14 Redis 主从复制的核心原理

通过执行slaveof命令或设置slaveof选项,让一个服务器去复制另一个服务器的数据。主数据库可以进行读写操作,当写操作导致数据变化时会自动将数据同步给从数据库。而从数据库一般是只读的,并接受主数据库同步过来的数据。一个主数据库可以拥有多个从数据库,而一个从数据库只能拥有一个主数据库。

全量复制:

  1. 主节点通过bgsave命令fork子进程进行RDB持久化,该过程是非常消耗CPU、内存(页表复制)、硬盘IO的
  2. 主节点通过网络将RDB文件发送给从节点,对主从节点的带宽都会带来很大的消耗
  3. 从节点清空老数据、载入新RDB文件的过程是阻塞的,无法响应客户端的命令;如果从节点执行bgrewriteaof,也会带来额外的消耗

部分复制:

  1. 复制偏移量:执行复制的双方,主从节点,分别会维护一个复制偏移量offset
  2. 复制积压缓冲区:主节点内部维护了一个固定长度的、先进先出(FIFO)队列 作为复制积压缓冲区,当主从节点offset的差距过大超过缓冲区长度时,将无法执行部分复制,只能执行全量复制。
  3. 服务器运行ID(runid):每个Redis节点,都有其运行ID,运行ID由节点在启动时自动生成,主节点会将自己的运行ID发送给从节点,从节点会将主节点的运行ID存起来。 从节点Redis断开重连的时候,就是根据运行ID来判断同步的进度:1)如果从节点保存的runid与主节点现在的runid相同,说明主从节点之前同步过,主节点会继续尝试使用部分复制(到底能不能部分复制还要看offset和复制积压缓冲区的情况);2)如果从节点保存的runid与主节点现在的runid不同,说明从节点在断线前同步的Redis节点并不是当前的主节点,只能进行全量复制。

9.15 Redis有哪些数据结构?分别有哪些典型的应用场景?

  1. 字符串:可以用来做最简单的数据,可以缓存某个简单的字符串,也可以缓存某个json格式的字符串,Redis分布式锁的实现就利用了这种数据结构,还包括可以实现计数器、Session共享、分布式ID
  2. 哈希表:可以用来存储一些key-value对,更适合用来存储对象
  3. 列表:Redis的列表通过命令的组合,既可以当做栈,也可以当做队列来使用,可以用来缓存类似微信公众号、微博等消息流数据
  4. 集合:和列表类似,也可以存储多个元素,但是不能重复,集合可以进行交集、并集、差集操作,从而可以实现类似,我和某人共同关注的人、朋友圈点赞等功能
  5. 有序集合:集合是无序的,有序集合可以设置顺序,可以用来实现排行榜功能

9.16 Redis分布式锁底层是如何实现的?

  1. 首先利用setnx来保证:如果key不存在才能获取到锁,如果key存在,则获取不到锁
  2. 然后还要利用lua脚本来保证多个redis操作的原子性
  3. 同时还要考虑到锁过期,所以需要额外的一个看门狗定时任务来监听锁是否需要续约
  4. 同时还要考虑到redis节点挂掉后的情况,所以需要采用红锁的方式来同时向N/2+1个节点申请锁,都申请到了才证明获取锁成功,这样就算其中某个redis节点挂掉了,锁也不能被其他客户端获取到

9.17 Redis集群策略

Redis提供了三种集群策略:

  1. 主从模式: 这种模式比较简单,主库可以读写,并且会和从库进行数据同步,这种模式下,客户端直接连主库或某个从库,但是但主库或从库宕机后,客户端需要手动修改IP,另外,这种模式也比较难进行扩容,整个集群所能存储的数据受到某台机器的内存容量,所以不可能支持特大数据量
  2. 哨兵模式: 这种模式在主从的基础上新增了哨兵节点,但主库节点宕机后,哨兵会发现主库节点宕机,然后在从库中选择一个库作为进的主库,另外哨兵也可以做集群,从而可以保证但某一个哨兵节点宕机后,还有其他哨兵节点可以继续工作,这种模式可以比较好的保证Redis集群的高可用,但是仍然不能很好的解决Redis的容量上限问题。
  3. Cluster模式: Cluster模式是用得比较多的模式,它支持多主多从,这种模式会按照key进行槽位的分配,可以使得不同的key分散到不同的主节点上,利用这种模式可以使得整个集群支持更大的数据容量,同时每个主节点可以拥有自己的多个从节点,如果该主节点宕机,会从它的从节点中选举一个新的主节点。

对于这三种模式,如果Redis要存的数据量不大,可以选择哨兵模式,如果Redis要存的数据量大,并且需要持续的扩容,那么选择Cluster模式。

9.18 缓存雪崩、缓存穿透、缓存预热、缓存更新、缓存降级

1)缓存雪崩

我们可以简单的理解为:由于原有缓存失效,新缓存未到期间 (例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期),所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机。从而形成一系列连锁反应,造成整个系统崩溃。

解决办法: 大多数系统设计者考虑用加锁( 最多的解决方案)或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。还有一个简单方案就时讲缓存失效时间分散开。

2)缓存穿透

缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中率问题。

解决办法: 最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。 另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。通过这个直接设置的默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库,这种办法最简单粗暴。5TB的硬盘上放满了数据,请写一个算法将这些数据进行排重。如果这些数据是一些32bit大小的数据该如何解决?如果是64bit的呢?对于空间的利用到达了一种极致,那就是Bitmap和布隆过滤器(Bloom Filter)。 Bitmap: 典型的就是哈希表 缺点是,Bitmap对于每个元素只能记录1bit信息,如果还想完成额外的功能,恐怕只能靠牺牲更多的空间、时间来完成了。

布隆过滤器: 就是引入了k(k>1)k(k>1)个相互独立的哈希函数,保证在给定的空间、误判率下,完成元素判重的过程。 它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。 Bloom-Filter算法的核心思想就是利用多个不同的Hash函数来解决“冲突”。 Hash存在一个冲突(碰撞)的问题,用同一个Hash得到的两个URL的值有可能相同。为了减少冲突,我们可以多引入几个Hash,如果通过其中的一个Hash值我们得出某元素不在集合中,那么该元素肯定不在集合中。只有在所有的Hash函数告诉我们该元素在集合中时,才能确定该元素存在于集合中。这便是Bloom-Filter的基本思想。 Bloom-Filter一般用于在大数据量的集合中判定某元素是否存在。

布隆过滤器究竟是什么,这一篇给讲的明明白白的

3)缓存预热

缓存预热这个应该是一个比较常见的概念,相信很多小伙伴都应该可以很容易的理解,缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据! 解决思路: 1、直接写个缓存刷新页面,上线时手工操作下; 2、数据量不大,可以在项目启动的时候自动进行加载; 3、定时刷新缓存。

4)缓存更新

除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),我们还可以根据具体的业务需求进行自定义的缓存淘汰,常见的策略有两种: (1)定时去清理过期的缓存; (2)当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。 两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!具体用哪种方案,大家可以根据自己的应用场景来权衡。

5)缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。 降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。 以参考日志级别设置预案: (1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级; (2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警; (3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级; (4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。

9.19 Redis 缓存刷新策略有哪些?

在这里插入图片描述

9.20 什么是 bigkey?会存在什么影响?

bigkey 是指键值占用内存空间非常大的 key。例如一个字符串 a 存储了 200M 的数据。

bigkey 的主要影响有:

  1. 网络阻塞: 获取 bigkey 时,传输的数据量比较大,会增加带宽的压力。
  2. 超时阻塞: 因为 bigkey 占用的空间比较大,所以操作起来效率会比较低,导致出现阻塞的可能性增加。
  3. 导致内存空间不平衡: 一个 bigkey 存储数据量比较大,同一个 key 在同一个节点或服务器中存储,会造成一定影响。

9.20 说说 Redis 哈希槽的概念?

Redis 集群并没有使用一致性 hash,而是引入了哈希槽的概念。Redis 集群有 16384(2^14)个哈希槽,每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分hash 槽。

9.21 假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如果将它们全部找出来?

我们可以使用 keys 命令和 scan 命令,但是会发现使用 scan 更好。

1)使用 keys 命令

直接使用 keys 命令查询,但是如果是在生产环境下使用会出现一个问题,keys 命令是遍历查询的,查询的时间复杂度为 O(n),数据量越大查询时间越长。而且 Redis 是单线程,keys 指令会导致线程阻塞一段时间,会导致线上 Redis 停顿一段时间,直到 keys 执行完毕才能恢复。这在生产环境是不允许的。除此之外,需要注意的是,这个命令没有分页功能,会一次性查询出所有符合条件的 key 值,会发现查询结果非常大,输出的信息非常多。所以不推荐使用这个命令。

2)使用 scan 命令

scan 命令可以实现和 keys 一样的匹配功能,但是 scan 命令在执行的过程不会阻塞线程,并且查找的数据可能存在重复,需要客户端操作去重。因为 scan 是通过游标方式查询的,所以不会导致Redis 出现假死的问题。Redis 查询过程中会把游标返回给客户端,单次返回空值且游标不为 0,则说明遍历还没结束,客户端继续遍历查询。scan 在检索的过程中,被删除的元素是不会被查询出来的,但是如果在迭代过程中有元素被修改,scan 不能保证查询出对应元素。相对来说,scan 指令查找花费的时间会比 keys 指令长。

9.22 如果有大量的 key 需要设置同一时间过期,一般需要注意什么?

如果有大量的 key 在同一时间过期,那么可能同一秒都从数据库获取数据,给数据库造成很大的压力,导致数据库崩溃,系统出现 502 问题。也有可能同时失效,那一刻不用都访问数据库,压力不够大的话,那么 Redis 会出现短暂的卡顿问题。所以为了预防这种问题的发生,最好给数据的过期时间加一个随机值,让过期时间更加分散。

9.23 什么情况下可能会导致 Redis 阻塞?

Redis 产生阻塞的原因主要有内部和外部两个原因导致:

内部原因

如果 Redis 主机的 CPU 负载过高,也会导致系统崩溃;数据持久化占用资源过多;对 Redis 的 API 或指令使用不合理,导致Redis 出现问题。

外部原因

外部原因主要是服务器的原因,例如服务器的 CPU 线程在切换过程中竞争过大,内存出现问题、网络问题等。

9.24 Redis 如何解决 key 冲突?

Redis 如果 key 相同,后一个 key 会覆盖前一个 key。如果要解决 key 冲突,最好给 key 取好名区分开,可以按业务名和参数区分开取名,避免重复 key 导致的冲突。

9.25 Memcache与Redis的区别?

1)、存储方式 Memecache把数据全部存在内存之中,断电后会挂掉,数据不能超过内存大小。Redis有部份存在硬盘上,redis可以持久化其数据
2)、数据支持类型 memcached所有的值均是简单的字符串,redis作为其替代者,支持更为丰富的数据类型 ,提供list,set,zset,hash等数据结构的存储
3)、使用底层模型不同 它们之间底层实现方式 以及与客户端之间通信的应用协议不一样。 Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
4)、value 值大小不同:Redis 最大可以达到 1gb;memcache 只有 1mb。
5)、redis的速度比memcached快很多
6)、Redis支持数据的备份,即master-slave模式的数据备份。

9.26 为什么Redis的操作是原子性的,怎么保证原子性的?

对于Redis而言,命令的原子性指的是:一个操作的不可以再分,操作要么执行,要么不执行。Redis的操作之所以是原子性的,是因为Redis是单线程的。 Redis本身提供的所有API都是原子操作,Redis中的事务其实是要保证批量操作的原子性。 多个命令在并发中也是原子性的吗? 不一定, 将get和set改成单命令操作,incr 。使用Redis的事务,或者使用Redis+Lua==的方式实现。

十、Nginx篇

10.1 什么是Nginx,它有什么优势和功能?

Nginx是一个web服务器和方向代理服务器,用于HTTP、HTTPS、SMTP、POP3和IMAP协议。因它的稳定性、丰富的功能集、示例配置文件和低系统资源的消耗而闻名。

也就是说Nginx本身就可以托管网站(类似于Tomcat一样),进行Http服务处理,也可以作为反向代理服务器 、负载均衡器和HTTP缓存。

Nginx 解决了服务器的C10K(就是在一秒之内连接客户端的数目为10k即1万)问题。它的设计不像传统的服务器那样使用线程处理请求,而是一个更加高级的机制—事件驱动机制,是一种异步事件驱动结构。

优点: 更快、高扩展性,跨平台、高可靠性:用于反向代理,宕机的概率微乎其微、低内存消耗、单机支持10万以上的并发连接、热部署、最自由的BSD许可协议

10.2 Nginx是如何处理一个HTTP请求的呢?

Nginx 是一个高性能的 Web 服务器,能够同时处理大量的并发请求。它结合多进程机制和异步机制,异步机制使用的是异步非阻塞方式 ,接下来就给大家介绍一下 Nginx 的多线程机制和异步非阻塞机制 。

1)多进程机制

服务器每当收到一个客户端时,就有 服务器主进程 ( master process )生成一个 子进程(worker process )出来和客户端建立连接进行交互,直到连接断开,该子进程就结束了。

使用进程的好处是各个进程之间相互独立,不需要加锁,减少了使用锁对性能造成影响,同时降低编程的复杂度,降低开发成本。其次,采用独立的进程,可以让进程互相之间不会影响 ,如果一个进程发生异常退出时,其它进程正常工作, master 进程则很快启动新的 worker 进程,确保服务不会中断,从而将风险降到最低。

缺点是操作系统生成一个子进程需要进行 内存复制等操作,在资源和时间上会产生一定的开销。当有大量请求时,会导致系统性能下降 。

2)异步非阻塞机制

每个工作进程 使用 异步非阻塞方式 ,可以处理 多个客户端请求 。

当某个 工作进程 接收到客户端的请求以后,调用 IO 进行处理,如果不能立即得到结果,就去 处理其他请求 (即为 非阻塞 );而 客户端 在此期间也 无需等待响应 ,可以去处理其他事情(即为 异步 )。

当 IO 返回时,就会通知此 工作进程 ;该进程得到通知,暂时 挂起 当前处理的事务去 响应客户端请求 。

10.3 Nginx服务器上的Master和Worker进程分别是什么?

主程序 Master process 启动后,通过一个 for 循环来 接收 和 处理外部信号 ;

主进程通过 fork() 函数产生 worker 子进程 ,每个子进程执行一个 for循环来实现Nginx服务器对事件的接收和处理 。

10.4 在Nginx中,如何使用未定义的服务器名称来阻止处理请求?

只需将请求删除的服务器就可以定义为:

Server{
    
    
	listen 80;
	server_name "";
	return 444;
}

这里,服务器名被保留为一个空字符串,它将在没有“主机”头字段的情况下匹配请求,而一个特殊的Nginx的非标准代码444被返回,从而终止连接。

10.5 worker 进程

一般推荐 worker 进程数与CPU内核数一致,这样一来不存在大量的子进程生成和管理任务,避免了进程之间竞争CPU 资源和进程切换的开销。而且 Nginx 为了更好的利用 多核特性 ,提供了 CPU亲缘性的绑定选项,我们可以将某一个进程绑定在某一个核上,这样就不会因为进程的切换带来Cache 的失效。

所有 worker 进程的 listenfd 会在新连接到来时变得可读 ,为保证只有一个进程处理该连接,所有worker 进程在注册 listenfd 读事件前抢占 accept_mutex ,抢到互斥锁的那个进程注册 listenfd 读事件 ,在读事件里调用 accept 接受该连接。

当一个 worker 进程在 accept 这个连接之后,就开始读取请求、解析请求、处理请求,产生数据后,再返回给客户端 ,最后才断开连接。这样一个完整的请求就是这样的了。我们可以看到,一个请求,完全由 worker 进程来处理,而且只在一个 worker 进程中处理。

在 Nginx 服务器的运行过程中, 主进程和工作进程 需要进程交互。交互依赖于 Socket 实现的管道来实现。

10.6 正向代理和反向代理

正向代理: 正向代理就是一个人发送一个请求直接就到达了目标的服务器。

反向代理: 反方代理就是请求统一被Nginx接收,nginx反向代理服务器接收到之后,按照一定的规 则分发给了后端的业务理服务器进行处理了。

10.7 Nginx应用场景

  1. http服务器。Nginx是一个http服务可以独立提供http服务。可以做网页静态服务器。
  2. 虚拟主机。可以实现在一台服务器虚拟出多个网站,例如个人网站使用的虚拟机。
  3. 反向代理,负载均衡。当网站的访问量达到一定程度后,单台服务器不能满足用户的请求时,需要用多台服务器集群可以使用nginx做反向代理。并且多台服务器可以平均分担负载,不会应为某台服务器负载高宕机而某台服务器闲置的情况。
  4. nginz 中也可以配置安全管理、比如可以使用Nginx搭建API接口网关,对每个接口服务进行拦截。

10.8 Nginx限流怎么做的?

Nginx限流就是限制用户请求速度,限流有3种,1)正常限制访问频率(正常流量)、2)突发限制访问频率(突发流量)、3)限制并发连接数。

实现三种限流算法

1)正常限制访问频率(正常流量):

限制一个用户发送的请求,我Nginx多久接收一个请求。

Nginx中使用ngx_http_limit_req_module模块来限制的访问频率,限制的原理实质是基于漏桶算法原理来实现的。在nginx.conf配置文件中可以使用limit_req_zone命令及limit_req命令限制单个IP的请求处理频率。

	#定义限流维度,一个用户一分钟一个请求进来,多余的全部漏掉
	limit_req_zone $binary_remote_addr zone=one:10m rate=1r/m;
	#绑定限流维度
	server{
    
    
		location /seckill.html{
    
    
			limit_req zone=zone;	
			proxy_pass http://lj_seckill;
		}
	}

1r/s代表1秒一个请求,1r/m一分钟接收一个请求, 如果Nginx这时还有别人的请求没有处理完,Nginx就会拒绝处理该用户请求。

2)突发限制访问频率(突发流量):

上面的配置一定程度可以限制访问频率,但是也存在着一个问题:如果突发流量超出请求被拒绝处理,无法处理活动时候的突发流量,这时候应该如何进一步处理呢?Nginx提供burst参数结合nodelay参数可以解决流量突发的问题,可以设置能处理的超过设置的请求数外能额外处理的请求数。我们可以将之前的例子添加burst参数以及nodelay参数:

	#定义限流维度,一个用户一分钟一个请求进来,多余的全部漏掉
	limit_req_zone $binary_remote_addr zone=one:10m rate=1r/m;
	#绑定限流维度
	server{
    
    
		location/seckill.html{
    
    
			limit_req zone=zone burst=5 nodelay;
			proxy_pass http://lj_seckill;
		}
	}

为什么就多了一个 burst=5 nodelay; 呢,多了这个可以代表Nginx对于一个用户的请求会立即处理前五个,多余的就慢慢来落,没有其他用户的请求我就处理你的,有其他的请求的话我Nginx就漏掉不接受你的请求

3)限制并发连接数:

Nginx中的ngx_http_limit_conn_module模块提供了限制并发连接数的功能,可以使用limit_conn_zone指令以及limit_conn执行进行配置。接下来我们可以通过一个简单的例子来看下:

	http {
    
    
		limit_conn_zone $binary_remote_addr zone=myip:10m;
		limit_conn_zone $server_name zone=myServerName:10m;
	}
    server {
    
    
        location / {
    
    
            limit_conn myip 10;
            limit_conn myServerName 100;
            rewrite / http://www.lijie.net permanent;
        }
    }

上面配置了单个IP同时并发连接数最多只能10个连接,并且设置了整个虚拟服务器同时最大并发数最多只能100个链接。当然,只有当请求的header被服务器处理后,虚拟服务器的连接数才会计数。刚才有提到过Nginx是基于漏桶算法原理实现的,实际上限流一般都是基于漏桶算法和令牌桶算法实现的。

10.9 漏桶流算法和令牌桶算法

1)漏桶算法

漏桶算法是网络世界中流量整形或速率限制时经常使用的一种算法,它的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。漏桶算法提供了一种机制,通过它,突发流量可以被整形以便为网络提供一个稳定的流量。也就是我们刚才所讲的情况。漏桶算法提供的机制实际上就是刚才的案例:突发流量会进入到一个漏桶,漏桶会按照我们定义的速率依次处理请求,如果水流过大也就是突发流量过大就会直接溢出,则多余的请求会被拒绝。所以漏桶算法能控制数据的传输速率。

在这里插入图片描述

2)令牌桶算法

令牌桶算法是网络流量整形和速率限制中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。Google开源项目Guava中的RateLimiter使用的就是令牌桶控制算法。令牌桶算法的机制如下:存在一个大小固定的令牌桶,会以恒定的速率源源不断产生令牌。如果令牌消耗速率小于生产令牌的速度,令牌就会一直产生直至装满整个令牌桶。

在这里插入图片描述

10.10 Nginx负载均衡策略

为了避免服务器崩溃,大家会通过负载均衡的方式来分担服务器压力。将对台服务器组成一个集群,当用户访问时,先访问到一个转发服务器,再由转发服务器将访问分发到压力更小的服务器。

Nginx负载均衡实现的策略有以下五种:

1)轮询(默认)

每个请求按时间顺序逐一分配到不同的后端服务器,如果后端某个服务器宕机,能自动剔除故障系统。

upstream backserver {
    
     
	server 192.168.0.12; 
	server 192.168.0.13; 
} 

2)权重 weight

weight的值越大分配到的访问概率越高,主要用于后端每台服务器性能不均衡的情况下。其次是为在主从的情况下设置不同的权值,达到合理有效的地利用主机资源。

upstream backserver {
    
     
	server 192.168.0.12 weight=2; 
	server 192.168.0.13 weight=8; 
} 

3)ip_hash( IP绑定)

每个请求按访问IP的哈希结果分配,使来自同一个IP的访客固定访问一台后端服务器,并且可以有效解决动态网页存在的session共享问题

upstream backserver {
    
     
	ip_hash; 
	server 192.168.0.12:88; 
	server 192.168.0.13:80; 
} 

4)fair(第三方插件)

必须安装upstream_fair模块。对比 weight、ip_hash更加智能的负载均衡算法,fair算法可以根据页面大小和加载时间长短智能地进行负载均衡,响应时间短的优先分配。哪个服务器的响应速度快,就将请求分配到那个服务器上。

upstream backserver {
    
     
	server server1; 
	server server2; 
	fair; 
} 

5)url_hash(第三方插件)

必须安装Nginx的hash软件包。按访问url的hash结果来分配请求,使每个url定向到同一个后端服务器,可以进一步提高后端缓存服务器的效率。

upstream backserver {
    
     
	server squid1:3128; 
	server squid2:3128; 
	hash $request_uri; 
	hash_method crc32; 
}

参考资料:
Nginx面试题(总结最全面的面试题!!!)

十一、Tomcat篇

11.1 Tomcat的缺省端口是多少,怎么修改?

默认端口为8080,可以通过在tomcat安装包conf目录下,service.xml中的Connector元素的port属性来修改端口。

11.2 tomcat 有哪几种Connector 运行模式(优化)?

三种模式的不同之处如下:

BIO : 一个线程处理一个请求。缺点:并发量高时,线程数较多,浪费资源。Tomcat7版本或更低版本中,在Linux系统中默认使用这种方式。
NIO : 利用Java的异步IO处理,可以通过少量的线程处理大量的请求。tomcat8.0.x中默认使用的是NIO。Tomcat7必须修改Connector配置来启动:

<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol" 
connectionTimeout="20000" redirectPort="8443"/>

APR : 即Apache Portable Runtime,从操作系统层面解决io阻塞问题。Tomcat7或Tomcat8在Win7或以上的系统中启动默认使用这种方式。

11.3 Tomcat有几种部署方式?

利用Tomcat的自动部署:把web应用拷贝到webapps目录(生产环境不建议放在该目录中)。Tomcat在启动时会加载目录下的应用,并将编译后的结果放入work目录下。

使用Manager App控制台部署:在tomcat主页点击“Manager App” 进入应用管理控制台,可以指定一个web应用的路径或war文件。

修改conf/server.xml 文件部署:在server.xml 文件中,增加Context节点可以部署应用。

增加自定义的Web部署文件:在conf/Catalina/localhost/ 路径下增加 xyz.xml文件,内容是Context节点,可以部署应用。

11.4 tomcat容器是如何创建servlet类实例?用到了什么原理?

当容器启动时,会读取在webapps目录下所有的web应用中的web.xml文件,然后对 xml文件进行解析,并读取servlet注册信息。然后,将每个应用中注册的servlet类都进行加载,并通过反射的方式实例化。(有时候也是在第一次请求时实例化)

在servlet注册时加上1如果为正数,则在一开始就实例化,如果不写或为负数,则第一次请求实例化。

11.5 tomcat 如何优化?

tomcat作为Web服务器,它的处理性能直接关系到用户体验,下面是几种常见的优化措施:

掉对web.xml的监视,把jsp提前编辑成Servlet。有富余物理内存的情况,加大tomcat使用的jvm的内存。服务器所能提供CPU、内存、硬盘的性能对处理能力有决定性影响。

  1. 对于高并发情况下会有大量的运算,那么CPU的速度会直接影响到处理速度。
  2. 内存在大量数据处理的情况下,将会有较大的内存容量需求,可以用-Xmx -Xms -XX:MaxPermSize等参数对内存不同功能块进行划分。我们之前就遇到过内存分配不足,导致虚拟机一直处于full GC,从而导致处理能力严重下降。
  3. 硬盘主要问题就是读写性能,当大量文件进行读写时,磁盘极容易成为性能瓶颈。最好的办法还是利用下面提到的缓存。

利用缓存和压缩

  1. 对于静态页面最好是能够缓存起来,这样就不必每次从磁盘上读。这里我们采用了Nginx作为缓存服务器,将图片、css、js文件都进行了缓存,有效的减少了后端tomcat的访问。
  2. 另外,为了能加快网络传输速度,开启gzip压缩也是必不可少的。但考虑到tomcat已经需要处理很多东西了,所以把这个压缩的工作就交给前端的Nginx来完成。
  3. 除了文本可以用gzip压缩,其实很多图片也可以用图像处理工具预先进行压缩,找到一个平衡点可以让画质损失很小而文件可以减小很多。曾经我就见过一个图片从300多kb压缩到几十kb,自己几乎看不出来区别。

采用集群

单个服务器性能总是有限的,最好的办法自然是实现横向扩展,那么组建tomcat集群是有效提升性能的手段。我们还是采用了Nginx来作为请求分流的服务器,后端多个tomcat共享session来协同工作。可以参考之前写的《利用nginx+tomcat+memcached组建web服务器负载均衡》。

优化线程数优化

找到Connector port=“8080” protocol=“HTTP/1.1”,增加maxThreads和acceptCount属性(使acceptCount大于等于maxThreads),如下:

<Connector port="8080" protocol="HTTP/1.1"connectionTimeout="20000"
redirectPort="8443"acceptCount="500" maxThreads="400" />

其中:

  1. maxThreads:tomcat可用于请求处理的最大线程数,默认是200
  2. minSpareThreads:tomcat初始线程数,即最小空闲线程数
  3. maxSpareThreads:tomcat最大空闲线程数,超过的会被关闭
  4. acceptCount:当所有可以使用的处理请求的线程数都被使用时,可以放到处理队列中的请求数,超过这个数的请求将不予处理.默认100

使用线程池优化

在server.xml中增加executor节点,然后配置connector的executor属性,如下:

<Executor name="tomcatThreadPool" namePrefix="req-exec-"maxThreads="1000"minSpareThreads="50"maxIdleTime="60000"/>
<Connector port="8080" protocol="HTTP/1.1"executor="tomcatThreadPool"/>

其中:

  1. namePrefix:线程池中线程的命名前缀
  2. maxThreads:线程池的最大线程数
  3. minSpareThreads:线程池的最小空闲线程数
  4. maxIdleTime:超过最小空闲线程数时,多的线程会等待这个时间长度,然后关闭
  5. threadPriority:线程优先级

注:当tomcat并发用户量大的时候,单个jvm进程确实可能打开过多的文件句柄,这时会报java.net.SocketException:Too many open files错误。可使用下面步骤检查:

  1. ps -ef |grep tomcat 查看tomcat的进程ID,记录ID号,假设进程ID为10001
  2. lsof -p 10001|wc -l 查看当前进程id为10001的 文件操作数
  3. 使用命令:ulimit -a 查看每个用户允许打开的最大文件数

启动速度优化

  1. 删除没用的web应用:因为tomcat启动每次都会部署这些应用。
  2. 关闭WebSocket: websocket-api.jar和tomcat-websocket.jar 。
  3. 随机数优化:设置JVM参数: -Djava.security.egd=file:/dev/./urandom 。

内存优化

因为tomcat启动起来后就是一个java进程,所以这块可以参照JVM部分的优化思路。堆内存相关参数,比如说:
• -Xms:虚拟机初始化时的最小堆内存。
• -Xmx:虚拟机可使用的最大堆内存。-Xms与-Xmx设成一样的值,避免JVM因为频繁的GC导致性能大起大落
• -XX:MaxNewSize:新生代占整个堆内存的最大值。
另外还有方法区参数调整(注意:JDK版本)、垃圾收集器等优化。JVM相关参数请看:手把手教你设置JVM调优参数

11.6 熟悉tomcat的哪些配置?

Context: (表示一个web应用程序,通常为WAR文件,关于WAR的具体信息见servlet规范)标签。

docBase : 该web应用的文档基准目录(Document Base,也称为Context Root),或者是WAR文件的路径。可以使绝对路径,也可以使用相对于context所属的Host的appBase路径。path :表示此web应用程序的url的前缀,这样请求的url为http://localhost:8080/path/**** 。

reloadable : 这个属性非常重要,如果为true,则tomcat会自动检测应用程序的/WEB-INF/lib和/WEB-INF/classes目录的变化,自动装载新的应用程序,我们可以在不重启tomcat的情况下改变应用程序。

useNaming : 如果希望Catalina为该web应用使能一个JNDI InitialContext对象,设为true。该InitialialContext符合J2EE平台的约定,缺省值为true。
workDir : Context提供的临时目录的路径,用于servlet的临时读/写。利用javax.servlet.context.tempdir属性,servlet可以访问该目录。如果没有指定,使用$CATALINA_HOME/work下一个合适的目录。

swallowOutput : 如果该值为true,System.out和System.err的输出被重定向到web应用的logger。如果没有指定,缺省值为false
debug : 与这个Engine关联的Logger记录的调试信息的详细程度。数字越大,输出越详细。如果没有指定,缺省为0。

host: (表示一个虚拟主机)标签。

name : 指定主机名。

appBase : 应用程序基本目录,即存放应用程序的目录。

unpackWARs : 如果为true,则tomcat会自动将WAR文件解压,否则不解压,直接从WAR文件中运行应用程序。

Logger: (表示日志,调试和错误信息)标签。

className : 指定logger使用的类名,此类必须实现org.apache.catalina.Logger接口。

prefix : 指定log文件的前缀。

suffix : 指定log文件的后缀。

timestamp : 如果为true,则log文件名中要加入时间,如下例:localhost_log.2001-10-04.txt。

11.7 Tomcat是什么?

Tomcat 服务器Apache软件基金会项目中的一个核心项目,是一个免费的开放源代码的Web 应用服务器(Servlet容器),属于轻量级应用服务器,在中小型系统和并发访问用户不是很多的场合下被普遍使用,是开发和调试JSP 程序的首选。

11.8 什么是Servlet呢?

Servlet是JavaEE规范的一种,主要是为了扩展Java作为Web服务的功能,统一接口。由其他内部厂商如tomcat,jetty内部实现web的功能。如一个http请求到来:容器将请求封装为servlet中的HttpServletRequest对象,调用init(),service()等方法输出response,由容器包装为httpresponse返回给客户端的过程。

在这里插入图片描述

11.9 什么是Servlet规范?

从 Jar 包上来说,Servlet 规范就是两个 Jar 文件。servlet-api.jar 和 jsp-api.jar,Jsp 也是一种Servlet。

从package上来说,就是 javax.servlet 和 javax.servlet.http 两个包。

从接口来说,就是规范了 Servlet 接口、Filter 接口、Listener 接口、ServletRequest 接口、ServletResponse 接口等。类图如下:
在这里插入图片描述

11.10 为什么我们将tomcat称为Web容器或者Servlet容器 ?

我们用一张图来表示他们之间的关系:

在这里插入图片描述
简单的理解:启动一个ServerSocket,监听8080端口。Servlet容器用来装我们开发的Servlet。

11.11 tomcat是如何处理Http请求流程的?

假设来我们在浏览器上输入 http://localhost:8080/my-web-mave/index.jsp,在tomcat中是如何处理这个请求流程的:

  1. 我们的请求被发送到本机端口8080,被在那里侦听的Coyote HTTP/1.1 Connector获得。
  2. Connector把该请求交给它所在的Service的Engine来处理,并等待来自Engine的回应 。
  3. Engine获得请求localhost/my-web-maven/index.jsp,匹配它所拥有的所有虚拟主机Host ,我们的虚拟主机在server.xml中默认配置的就是localhost。
  4. Engine匹配到name=localhost的Host(即使匹配不到也把请求交给该Host处理,因为该Host被定义为该Engine的默认主机)。
  5. localhost Host获得请求/my-web-maven/index.jsp,匹配它所拥有的所有Context。
  6. Host匹配到路径为/my-web-maven的Context(如果匹配不到就把该请求交给路径名为”"的Context去处理)。
  7. path=”/my-web-maven”的Context获得请求/index.jsp,在它的mapping table中寻找对应的servlet 。
  8. Context匹配到URL PATTERN为*.jsp的servlet,对应于JspServlet类。
  9. 构造HttpServletRequest对象和HttpServletResponse对象,作为参数调用JspServlet的doGet或doPost方法 。
  10. Context把执行完了之后的HttpServletResponse对象返回给Host 。
  11. Host把HttpServletResponse对象返回给Engine 。
  12. Engine把HttpServletResponse对象返回给Connector 。
  13. Connector把HttpServletResponse对象返回给客户browser 。

十二、Zookeeper篇

12.1 Zookeeper 是什么?

直译: 从名字上直译就是动物管理员,动物指的是 Hadoop 一类的分布式软件,管理员三个字体现了 ZooKeeper 的特点:维护、协调、管理、监控。

简述: ZooKeeper 是一个开放源码的分布式协调服务, 它是集群的管理者,监视着集群中各个节点的状态根据节点提交的反馈进行下一步合理操作。最终, 将简单易用的接口和性能高效、功能稳定的系统提供给用户。

分布式应用程序可以基于Zookeeper 实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。

特点:

  1. 最终一致性: 客户端看到的数据最终是一致的。
  2. 可靠性: 服务器保存了消息,那么它就一直都存在。
  3. 实时性: ZooKeeper 不能保证两个客户端同时得到刚更新的数据。
  4. 独立性: (等待无关):不同客户端直接互不影响。
  5. 原子性: 更新要不成功要不失败,没有第三个状态。

12.2 ZooKeeper 有哪些应用场景?

1)数据发布与订阅

发布与订阅即所谓的配置管理,顾名思义就是将数据发布到ZooKeeper节点上,供订阅者动态获取数据,实现配置信息的集中式管理和动态更新。例如全局的配置信息,地址列表等就非常适合使用。数据发布/订阅的一个常见的场景是配置中心,发布者把数据发布到 ZooKeeper 的一个或一系列的节点上,供订阅者进行数据订阅,达到动态获取数据的目的。

配置信息一般有几个特点:

  1. 数据量小的KV
  2. 数据内容在运行时会发生动态变化
  3. 集群机器共享,配置一致
    在这里插入图片描述

ZooKeeper 采用的是推拉结合的方式

  1. 推: 服务端会推给注册了监控节点的客户端 Wathcer 事件通知
  2. 拉: 客户端获得通知后,然后主动到服务端拉取最新的数据

2)命名服务

作为分布式命名服务,命名服务是指通过指定的名字来获取资源或者服务的地址,利用ZooKeeper创建一个全局的路径,这个路径就可以作为一个名字,指向集群中的集群,提供的服务的地址,或者一个远程的对象等等。

统一命名服务的命名结构图如下所示:

在这里插入图片描述
1、在分布式环境下,经常需要对应用/服务进行统一命名,便于识别不同服务。

  1. 类似于域名与IP之间对应关系,IP不容易记住,而域名容易记住。
  2. 通过名称来获取资源或服务的地址,提供者等信息。

2、按照层次结构组织服务/应用名称。

  1. 可将服务名称以及地址信息写到ZooKeeper上,客户端通过ZooKeeper获取可用服务列表类。

3)配置管理

程序分布式的部署在不同的机器上,将程序的配置信息放在ZooKeeper的znode下,当有配置发生改变时,也就是znode发生变化时,可以通过改变zk中某个目录节点的内容,利用watch通知给各个客户端 从而更改配置。

ZooKeeper配置管理结构图如下所示:

在这里插入图片描述
1、分布式环境下,配置文件管理和同步是一个常见问题。

  1. 一个集群中,所有节点的配置信息是一致的,比如 Hadoop 集群。
  2. 对配置文件修改后,希望能够快速同步到各个节点上。

2、配置管理可交由ZooKeeper实现。

  1. 可将配置信息写入ZooKeeper上的一个Znode。
  2. 各个节点监听这个Znode。
  3. 一旦Znode中的数据被修改,ZooKeeper将通知各个节点。

4)集群管理

所谓集群管理就是:是否有机器退出和加入、选举master。

集群管理主要指集群监控和集群控制两个方面。前者侧重于集群运行时的状态的收集,后者则是对集群进行操作与控制。开发和运维中,面对集群,经常有如下需求:

  1. 希望知道集群中究竟有多少机器在工作
  2. 对集群中的每台机器的运行时状态进行数据收集
  3. 对集群中机器进行上下线的操作

集群管理结构图如下所示:

在这里插入图片描述

1、分布式环境中,实时掌握每个节点的状态是必要的,可根据节点实时状态做出一些调整。

2、可交由ZooKeeper实现。

  1. 可将节点信息写入ZooKeeper上的一个Znode。
  2. 监听这个Znode可获取它的实时状态变化。

3、典型应用

  1. Hbase中Master状态监控与选举。

利用ZooKeeper的强一致性,能够保证在分布式高并发情况下节点创建的全局唯一性,即:同时有多个客户端请求创建 /currentMaster 节点,最终一定只有一个客户端请求能够创建成功。

5)分布式通知与协调

1、分布式环境中,经常存在一个服务需要知道它所管理的子服务的状态。

  1. NameNode需知道各个Datanode的状态。
  2. JobTracker需知道各个TaskTracker的状态。

2、心跳检测机制可通过ZooKeeper来实现。

3、信息推送可由ZooKeeper来实现,ZooKeeper相当于一个发布/订阅系统。

6)分布式锁

处于不同节点上不同的服务,它们可能需要顺序的访问一些资源,这里需要一把分布式的锁。分布式锁具有以下特性:写锁、读锁、时序锁。

写锁: 在zk上创建的一个临时的无编号的节点。由于是无序编号,在创建时不会自动编号,导致只能有一个客户端得到锁,然后进行写入。
读锁: 在zk上创建一个临时的有编号的节点,这样即使下次有客户端加入是同时创建相同的节点时,他也会自动编号,也可以获得锁对象,然后对其进行读取。
时序锁: 在zk上创建的一个临时的有编号的节点根据编号的大小控制锁。

7)分布式队列

分布式队列分为两种:

1、当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达,这种是同步队列。

  1. 一个job由多个task组成,只有所有任务完成后,job才运行完成。
  2. 可为job创建一个/job目录,然后在该目录下,为每个完成的task创建一个临时的Znode,一旦临时节点数目达到task总数,则表明job运行完成。

2、队列按照FIFO方式进行入队和出队操作,例如实现生产者和消费者模型。

12.3 说说Zookeeper的工作原理?

Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们 分别是恢复模式(选主)广播模式(同步)

Zab协议 的全称是 Zookeeper Atomic Broadcast (Zookeeper原子广播)。Zookeeper 是通过Zab 协议来保证分布式事务的最终一致性。Zab协议要求每个 Leader 都要经历三个阶段:发现同步广播

当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和 leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。

为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加 上了zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一 个新的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数。

epoch:可以理解为皇帝的年号,当新的皇帝leader产生后,将有一个新的epoch年号。

每个Server在工作过程中有三种状态:

  1. LOOKING: 当前Server不知道leader是谁,正在搜寻。
  2. LEADING: 当前Server即为选举出来的leader。
  3. FOLLOWING: leader已经选举出来,当前Server与之同步。

12.4 Zookeeper 提供了什么?

1、文件系统
2、通知机制

12.5 Zookeeper 文件系统

Zookeeper 提供一个多层级的节点命名空间(节点称为znode)。与文件系统不同的是, 这些节点都可以设置关联的数据,而文件系统中只有文件节点可以存放数据而目录节点不行。

Zookeeper 为了保证高吞吐和低延迟, 在内存中维护了这个树状的目录结构, 这种特性使得Zookeeper 不能用于存放大量的数据, 每个节点的存放数据上限为1M。

12.6 Zookeeper 通知机制

Zookeeper 允许客户端向服务端的某个znode 注册一个 Watcher 监听,当服务端的一些指定事件触发了这个 Watcher ,服务端会向指定客户端发送一个事件通知来实现分布式的通知功能,然后客户端根据 Watcher 通知状态和事件类型做出业务上的改变。

大致分为三个步骤:

  1. 客户端注册 Watcher: 1、调用 getData、getChildren、exist 三个 API ,传入Watcher 对象。 2、标记请求request ,封装 Watcher 到 WatchRegistration 。 3、封装成 Packet 对象,发服务端发送request 。 4、收到服务端响应后,将 Watcher 注册到 ZKWatcherManager 中进行管理。 5、请求返回,完成注册。
  2. 服务端处理 Watcher: 1、服务端接收 Watcher 并存储。 2、Watcher 触发 3、调用 process 方法来触发 Watcher 。
  3. 客户端回调 Watcher: 1,客户端 SendThread 线程接收事件通知,交由EventThread 线程回调Watcher 。 2,客户端的 Watcher 机制同样是一次性的,一旦被触发后,该 Watcher 就失效了。client 端会对某个 znode 建立一个 watcher 事件,当该 znode 发生变化时,这些 client 会收到 zk 的通知,然后 client 可以根据 znode 变化来做出业务上的改变等。

12.7 ZAB 协议?

ZAB 协议是为分布式协调服务Zookeeper 专门设计的一种支持崩溃恢复的原子广播协议。ZAB 协议包括两种基本的模式: 崩溃恢复消息广播

当整个zookeeper 集群刚刚启动或者Leader 服务器宕机、重启或者网络故障导致不存在过半的服务器与Leader 服务器保持正常通信时,所有进程(服务器)进入崩溃恢复模式,首先选举产生新的Leader 服务器, 然后集群中Follower 服务器开始与新的Leader 服务器进行数据同步, 当集群中超过半数机器与该Leader服务器完成数据同步之后,退出恢复模式进入消息广播模式,Leader 服务器开始接收客户端的事务请求生成事物提案来进行事务请求处理。

12.8 Zookeeper 对节点的 watch 监听通知是永久的吗?

不是,一次性的。 无论是服务端还是客户端,一旦一个 Watcher 被触发, Zookeeper 都会将其从相应的存储中移除。这样的设计有效的减轻了服务端的压力,不然对于更新非常频繁的节点,服务端会不断的向客户端发送事件通知,无论对于网络还是服务端的压力都非常大。

12.9 Zookeeper 集群中有哪些角色?

在这里插入图片描述

在一个集群中,最少需要 3 台。或者保证2N + 1 台,即奇数。为什么保证奇数?主要是为了选举算法。

12.10 Zookeeper 集群中Server有哪些工作状态?

LOOKING: 寻找 Leader 状态;当服务器处于该状态时,它会认为当前集群中没有 Leader ,因此需要进入Leader 选举状态
FOLLOWING: 跟随者状态;表明当前服务器角色是 Follower
LEADING: 领导者状态;表明当前服务器角色是 Leader
OBSERVING: 观察者状态;表明当前服务器角色是 Observer

12.11 Zookeeper 集群中是怎样选举leader的?

当Leader崩溃了,或者失去了大多数的Follower,这时候Zookeeper 就进入恢复模式,恢复模式需要重新选举出一个新的Leader,让所有的Server都恢复到一个状态LOOKING 。

Zookeeper 有两种选举算法:基于basic paxos 实现和基于fast paxos 实现。默认为fast paxos由于篇幅问题,这里推荐:选举流程

12.12 Zookeeper 是如何保证事务的顺序一致性的呢?

Zookeeper 采用了递增的事务 id 来识别,所有的 proposal (提议)都在被提出的时候加上了zxid 。zxid 实际上是一个 64 位数字。

高 32 位是 epoch 用来标识Leader 是否发生了改变,如果有新的 Leader 产生出来, epoch 会自增。 低 32 位用来递增计数。 当新产生的 proposal 的时候,会依据数据库的两阶段过程,首先会向其他的 Server 发出事务执行请求,如果超过半数的机器都能执行并且能够成功,那么就会开始执行。

12.13 ZooKeeper 集群中个服务器之间是怎样通信的?

Leader 服务器会和每一个Follower/Observer 服务器都建立 TCP 连接,同时为每个Follower/Observer 都创建一个叫做LearnerHandler 的实体。

  1. LearnerHandler 主要负责 Leader 和Follower/Observer 之间的网络通讯,包括数据同步,请求转发和 proposal 提议的投票等。
  2. Leader 服务器保存了所有Follower/Observer 的 LearnerHandler 。

12.14 了解Zookeeper的系统架构吗?

在这里插入图片描述
Zookeeper 的架构图中我们需要了解和掌握的主要有:

(1)Zookeeper分为服务器端(Server) 和客户端(Client),客户端可以连接到整个Zookeeper服务的任意服务器上(除非 leaderServes 参数被显式设置, leader 不允许接受客户端连接)。

(2)客户端使用并维护一个 TCP 连接,通过这个连接发送请求、接受响应、获取观察的事件以及发送心跳。如果这个 TCP 连接中断,客户端将自动尝试连接到另外的 Zookeeper服务器。客户端第一次连接到 ZooKeeper服务时,接受这个连接的 Zookeeper服务器会为这个客户端建立一个会话。当这个客户端连接到另外的服务器时,这个会话会被新的服务器重新建立。

(3)上图中每一个Server代表一个安装Zookeeper服务的机器,即是整个提供Zookeeper服务的集群(或者是由伪集群组成)。

(4)组成ZooKeeper服务的服务器必须彼此了解。它们维护一个内存中的状态图像,以及持久存储中的事务日志和快照, 只要大多数服务器可用,ZooKeeper服务就可用。

(5)ZooKeeper 启动时,将从实例中选举一个 leaderLeader 负责处理数据更新等操作,一个更新操作成功的标志是当且仅当大多数Server在内存中成功修改数据。每个Server 在内存中存储了一份数据。

(6)Zookeeper是可以集群复制的,集群间通过Zab协议(Zookeeper Atomic Broadcast)来保持数据的一致性;

(7)Zab协议包含两个阶段:leader election阶段Atomic Brodcast阶段

  1. 集群中将选举出一个leader,其他的机器则称为follower,所有的写操作都被传送给leader,并通过brodcast将所有的更新告诉给follower
  2. leader崩溃或者leader失去大多数的follower时,需要重新选举出一个新的leader,让所有的服务器都恢复到一个正确的状态。
  3. leader被选举出来,且大多数服务器完成了 和leader的状态同步后,leadder election 的过程就结束了,就将会进入到Atomic brodcast的过程。
  4. Atomic Brodcast同步leaderfollower之间的信息,保证leaderfollower具有形同的系统状态。

12.15 你知道Zookeeper中有哪些角色?

系统模型:
在这里插入图片描述

领导者(leader): Leader服务器为客户端提供读服务和写服务。负责进行投票的发起和决议,更新系统状态。

学习者(learner): 跟随者( follower ) Follower服务器为客户端提供读服务,参与Leader选举过程,参与写操作“过半写成功”策略。

观察者( observer ): Observer服务器为客户端提供读服务,不参与Leader选举过程,不参与写操作“过半写成功”策略。用于在不影响写性能的前提下提升集群的读性能。

客户端(client): 服务请求发起方。

12.16 熟悉Zookeeper节点ZNode和相关属性吗?

节点有哪些类型?Znode两种类型:

持久的(persistent): 客户端和服务器端断开连接后,创建的节点不删除(默认)。

短暂的(ephemeral): 客户端和服务器端断开连接后,创建的节点自己删除。

Znode有四种形式:

持久化目录节点(PERSISTENT): 客户端与Zookeeper断开连接后,该节点依旧存在持久化顺序编号目录节点(PERSISTENT_SEQUENTIAL)

客户端与Zookeeper断开连接后,该节点依旧存在,只是Zookeeper给该节点名称进行顺序编号:临时目录节(EPHEMERAL)

客户端与Zookeeper断开连接后,该节点被删除:临时顺序编号目录节点(EPHEMERAL_SEQUENTIAL)

客户端与Zookeeper断开连接后,该节点被删除,只是Zookeeper给该节点名称进行顺序编号

「注意」:创建ZNode时设置顺序标识,ZNode名称后会附加一个值,顺序号是一个单调递增的计数器,由父节点维护。

12.17 为什么Zookeeper集群的数目,一般为奇数个?

首先需要明确zookeeper选举的规则:leader选举,要求可用节点数量 > 总节点数量/2 。

比如:标记一个写是否成功是要在超过一半节点发送写请求成功时才认为有效。同样,Zookeeper选择领导者节点也是在超过一半节点同意时才有效。最后,Zookeeper是否正常是要根据是否超过一半的节点正常才算正常。这是基于CAP的一致性原理。

zookeeper有这样一个特性:集群中只要有过半的机器是正常工作的,那么整个集群对外就是可用的。也就是说如果有2个zookeeper,那么只要有1个死了zookeeper就不能用了,因为1没有过半,所以2个zookeeper的死亡容忍度为0;同理,要是有3个zookeeper,一个死了,还剩下2个正常的,过半了,所以3个zookeeper的容忍度为1;

同理:

  1. 2->0;两个zookeeper,最多0个zookeeper可以不可用。
  2. 3->1;三个zookeeper,最多1个zookeeper可以不可用。
  3. 4->1;四个zookeeper,最多1个zookeeper可以不可用。
  4. 5->2;五个zookeeper,最多2个zookeeper可以不可用。
  5. 6->2;两个zookeeper,最多0个zookeeper可以不可用。

会发现一个规律,2n和2n-1的容忍度是一样的,都是n-1,所以为了更加高效,何必增加那一个不必要的zookeeper呢。

zookeeper的选举策略也是需要半数以上的节点同意才能当选leader,如果是偶数节点可能导致票数相同的情况。

12.18 Zookeeper监听器原理

在这里插入图片描述

  1. 创建一个Main()线程。
  2. 在Main()线程中创建两个线程,一个负责网络连接通信(connect),一个负责监听(listener)。
  3. 通过connect线程将注册的监听事件发送给Zookeeper。
  4. 将注册的监听事件添加到Zookeeper的注册监听器列表中。
  5. Zookeeper监听到有数据或路径发生变化时,把这条消息发送给Listener线程。
  6. Listener线程内部调用process()方法

12.19 说说Zookeeper中的ACL 权限控制机制

UGO(User/Group/Others)目前在 Linux/Unix 文件系统中使用,也是使用最广泛的权限控制方式。是一种粗粒度的文件系统权限控制模式。

ACL(Access Control List)访问控制列表,包括三个方面:

1)权限模式(Scheme)

  1. IP: 从 IP 地址粒度进行权限控制
  2. Digest: 最常用,用类似于 username:password 的权限标识来进行权限配置,便于区分不同应用来进行权限控制。
  3. World: 最开放的权限控制方式,是一种特殊的 digest 模式,只有一个权限标识“world:anyone”
  4. Super: 超级用户

2)授权对象

授权对象指的是权限赋予的用户或一个指定实体,例如 IP 地址或是机器灯。

3)权限 Permission

  1. CREATE: 数据节点创建权限,允许授权对象在该 Znode 下创建子节点
  2. DELETE: 子节点删除权限,允许授权对象删除该数据节点的子节点
  3. READ: 数据节点的读取权限,允许授权对象访问该数据节点并读取其数据内容或子节点列表等
  4. WRITE: 数据节点更新权限,允许授权对象对该数据节点进行更新操作
  5. ADMIN: 数据节点管理权限,允许授权对象对该数据节点进行 ACL 相关设置操作

12.20 Zookeeper 有哪几种几种部署模式?

Zookeeper 有三种部署模式:

  1. 单机部署:一台集群上运行;
  2. 集群部署:多台集群运行;
  3. 伪集群部署:一台集群启动多个 Zookeeper 实例运行。

12.21 Zookeeper集群支持动态添加机器吗?

其实就是水平扩容了,Zookeeper 在这方面不太好。两种方式:

全部重启: 关闭所有 Zookeeper 服务,修改配置之后启动。不影响之前客户端的会话。

逐个重启: 在过半存活即可用的原则下,一台机器重启不影响整个集群对外提供服务。这是比较常用的方式。

3.5 版本开始支持动态扩容。

12.22 ZAB 和 Paxos 算法的联系与区别?

相同点:

  1. 两者都存在一个类似于 Leader 进程的角色,由其负责协调多个 Follower 进程的运行
  2. Leader 进程都会等待超过半数的 Follower 做出正确的反馈后,才会将一个提案进行提交
  3. ZAB 协议中,每个 Proposal 中都包含一个 epoch 值来代表当前的 Leader周期,Paxos 中名字为 Ballot

不同点:

ZAB 用来构建高可用的分布式数据主备系统(Zookeeper),Paxos 是用来构建分布式一致性状态机系统。

12.23 Zookeeper 宕机如何处理?

ZooKeeper 本身也是集群,推荐配置奇数个服务器。因为宕机就需要选举,选举需要半数 +1 票才能通过,为了避免打成平手。进来不用偶数个服务器。

如果是 Follower 宕机了,没关系不影响任何使用。用户无感知。如果 Leader 宕机,集群就得停止对外服务,开始选举,选举出一个 Leader 节点后,进行数据同步,保证所有节点数据和 Leader 统一,然后开始对外提供服务。

为啥投票需要半数 +1,如果半数就可以的话,网络的问题可能导致集群选举出来两个 Leader,各有一半的小弟支持,这样数据也就乱套了。

12.24 描述一下 Zookeeper 的 session 管理的思想?

分桶策略:

简单地说,就是不同的会话过期可能都有时间间隔,比如 15 秒过期、15.1 秒过期、15.8 秒过期,ZooKeeper 统一让这些session 16 秒过期。这样非常方便管理,看下面的公式,过期时间总是ExpirationInterval 的整数倍。

计算公式:

ExpirationTime = currentTime + sessionTimeout
ExpirationTime = (ExpirationTime / ExpirationInrerval + 1) * ExpirationInterval 

见图片:

在这里插入图片描述

默认配置的 session 超时时间是在 2tickTime~20tickTime。

12.25 Zookeeper 负载均衡和 Nginx 负载均衡有什么区别?

Zookeeper:

  1. 不存在单点问题,zab 机制保证单点故障可重新选举一个 Leader
  2. 只负责服务的注册与发现,不负责转发,减少一次数据交换(消费方与服务方直接通信)
  3. 需要自己实现相应的负载均衡算法

Nginx:

  1. 存在单点问题,单点负载高数据量大,需要通过 KeepAlived 辅助实现高可用
  2. 每次负载,都充当一次中间人转发角色,本身是个反向代理服务器
  3. 自带负载均衡算法

12.26 Zookeeper 的序列化

序列化:

  1. 内存数据,保存到硬盘需要序列化。
  2. 内存数据,通过网络传输到其他节点,需要序列化。

ZK 使用的序列化协议是 Jute,Jute 提供了 Record 接口。接口提供了两个方法:

  1. serialize 序列化方法
  2. deserialize 反序列化方法

要系列化的方法,在这两个方法中存入到流对象中即可。

12.27 Zookeeper 持久化机制

什么是持久化?

  1. 数据,存到磁盘或者文件当中。
  2. 机器重启后,数据不会丢失。内存 -> 磁盘的映射,和序列化有些像。

ZooKeeper 的持久化:

  1. SnapShot 快照,记录内存中的全量数据
  2. TxnLog 增量事务日志,记录每一条增删改记录(查不是事务日志,不会引起数据变化)

为什么持久化这么麻烦,一个不可用吗?

快照的缺点,文件太大,而且快照文件不会是最新的数据。 增量事务日志的缺点,运行时间长了,日志太多了,加载太慢。二者结合最好。

快照模式:

将 ZooKeeper 内存中以 DataTree 数据结构存储的数据定期存储到磁盘中。由于快照文件是定期对数据的全量备份,所以快照文件中数据通常不是最新的。
在这里插入图片描述

12.28 Zookeeper选举中投票信息的五元组是什么?

  1. Leader: 被选举的 Leader 的 SID
  2. Zxid: 被选举的 Leader 的事务 ID
  3. Sid: 当前服务器的 SID
  4. electionEpoch: 当前投票的轮次
  5. peerEpoch: 当前服务器的 Epoch

Epoch > Zxid > Sid

Epoch,Zxid 都可能一致,但是 Sid 一定不一样,这样两张选票一定会 PK 出结果。

12. 29 说说Zookeeper中的脑裂?

简单点来说,脑裂(Split-Brain) 就是比如当你的 cluster 里面有两个节点,它们都知道在这个cluster 里需要选举出一个 master。那么当它们两个之间的通信完全没有问题的时候,就会达成共识,选出其中一个作为 master。但是如果它们之间的通信出了问题,那么两个结点都会觉得现在没有 master,所以每个都把自己选举成 master,于是 cluster 里面就会有两个 master。

对于Zookeeper来说有一个很重要的问题,就是到底是根据一个什么样的情况来判断一个节点死亡down掉了?在分布式系统中这些都是有监控者来判断的,但是监控者也很难判定其他的节点的状态,唯一一个可靠的途径就是心跳,Zookeeper也是使用心跳来判断客户端是否仍然活着。

使用ZooKeeper来做Leader HA基本都是同样的方式:每个节点都尝试注册一个象征leader的临时节点,其他没有注册成功的则成为follower,并且通过watch机制监控着leader所创建的临时节点,Zookeeper通过内部心跳机制来确定leader的状态,一旦leader出现意外Zookeeper能很快获悉并且通知其他的follower,其他flower在之后作出相关反应,这样就完成了一个切换,这种模式也是比较通用的模式,基本大部分都是这样实现的。但是这里面有个很严重的问题,如果注意不到会导致短暂的时间内系统出现脑裂,因为心跳出现超时可能是leader挂了,但是也可能是zookeeper节点之间网络出现了问题,导致leader假死的情况,leader其实并未死掉,但是与ZooKeeper之间的网络出现问题导致Zookeeper认为其挂掉了然后通知其他节点进行切换,这样follower中就有一个成为了leader,但是原本的leader并未死掉,这时候client也获得leader切换的消息,但是仍然会有一些延时,zookeeper需要通讯需要一个一个通知,这时候整个系统就很混乱可能有一部分client已经通知到了连接到新的leader上去了,有的client仍然连接在老的leader上,如果同时有两个client需要对leader的同一个数据更新,并且刚好这两个client此刻分别连接在新老的leader上,就会出现很严重问题。

这里做下小总结: 假死: 由于心跳超时(网络原因导致的)认为leader死了,但其实leader还存活着。 脑裂: 由于假死会发起新的leader选举,选举出一个新的leader,但旧的leader网络又通了,导致出现了两个leader ,有的客户端连接到老的leader,而有的客户端则连接到新的leader

12.30 Zookeeper脑裂是什么原因导致的?

主要原因是Zookeeper集群和Zookeeper client判断超时并不能做到完全同步,也就是说可能一前一后,如果是集群先于client发现,那就会出现上面的情况。同时,在发现并切换后通知各个客户端也有先后快慢。一般出现这种情况的几率很小,需要leader节点与Zookeeper集群网络断开,但是与其他集群角色之间的网络没有问题,还要满足上面那些情况,但是一旦出现就会引起很严重的后果,数据不一致。

12.31 Zookeeper 是如何解决脑裂问题的?

要解决Split-Brain脑裂的问题,一般有下面几种种方法: Quorums (法定人数) 方式: 比如3个节点的集群,Quorums = 2, 也就是说集群可以容忍1个节点失效,这时候还能选举出1个lead,集群还可用。比如4个节点的集群,它的Quorums = 3,Quorums要超过3,相当于集群的容忍度还是1,如果2个节点失效,那么整个集群还是无效的。这是zookeeper防止"脑裂"默认采用的方法。

采用Redundant communications (冗余通信)方式:集群中采用多种通信方式,防止一种通信方式失效导致集群中的节点无法通信。

Fencing (共享资源) 方式:比如能看到共享资源就表示在集群中,能够获得共享资源的锁的就是Leader,看不到共享资源的,就不在集群中。

要想避免zookeeper"脑裂"情况其实也很简单,在follower节点切换的时候不在检查到老的leader节点出现问题后马上切换,而是在休眠一段足够的时间,确保老的leader已经获知变更并且做了相关的shutdown清理工作了然后再注册成为master就能避免这类问题了,这个休眠时间一般定义为与zookeeper定义的超时时间就够了,但是这段时间内系统可能是不可用的,但是相对于数据不一致的后果来说还是值得的。

zooKeeper默认采用了Quorums这种方式来防止"脑裂"现象。即只有集群中超过半数节点投票才能选举出Leader。这样的方式可以确保leader的唯一性,要么选出唯一的一个leader,要么选举失败。在zookeeper中Quorums作用如下:

  1. 集群中最少的节点数用来选举leader保证集群可用。
  2. 通知客户端数据已经安全保存前集群中最少数量的节点数已经保存了该数据。一旦这些节点保存了该数据,客户端将被通知已经安全保存了,可以继续其他任务。而集群中剩余的节点将会最终也保存了该数据。

假设某个leader假死,其余的followers选举出了一个新的leader。这时,旧的leader复活并且仍然认为自己是leader,这个时候它向其他followers发出写请求也是会被拒绝的。因为每当新leader产生时,会生成一个epoch标号(标识当前属于那个leader的统治时期),这个epoch是递增的,followers如果确认了新的leader存在,知道其epoch,就会拒绝epoch小于现任leader epoch的所有请求。那有没有follower不知道新的leader存在呢,有可能,但肯定不是大多数,否则新leader无法产生。Zookeeper的写也遵循quorum机制,因此,得不到大多数支持的写是无效的,旧leader即使各种认为自己是leader,依然没有什么作用。

zookeeper除了可以采用上面默认的Quorums方式来避免出现"脑裂",还可以可采用下面的预防措施:

  1. 添加冗余的心跳线,例如双线条线,尽量减少“裂脑”发生机会。
  2. 启用磁盘锁。正在服务一方锁住共享磁盘,“裂脑"发生时,让对方完全"抢不走"共享磁盘资源。但使用锁磁盘也会有一个不小的问题,如果占用共享盘的一方不主动"解锁”,另一方就永远得不到共享磁盘。现实中假如服务节点突然死机或崩溃,就不可能执行解锁命令。后备节点也就接管不了共享资源和应用服务。于是有人在HA中设计了"智能"锁。即正在服务的一方只在发现心跳线全部断开(察觉不到对端)时才启用磁盘锁。平时就不上锁了。
  3. 设置仲裁机制。例如设置参考IP(如网关IP),当心跳线完全断开时,2个节点都各自ping一下 参考IP,不通则表明断点就出在本端,不仅"心跳"、还兼对外"服务"的本端网络链路断了,即使启动(或继续)应用服务也没有用了,那就主动放弃竞争,让能够ping通参考IP的一端去起服务。更保险一些,ping不通参考IP的一方干脆就自我重启,以彻底释放有可能还占用着的那些共享资源。

12.32 说说 Zookeeper 的 CAP 问题上做的取舍?

一致性 C: Zookeeper 是强一致性系统,为了保证较强的可用性,“一半以上成功即成功”的数据同步方式可能会导致部分节点的数据不一致。所以 Zookeeper 还提供了 sync() 操作来做所有节点的数据同步,这就关于 C 和 A 的选择问题交给了用户,因为使用 sync()势必会延长同步时间,可用性会有一些损失。

可用性 A: Zookeeper 数据存储在内存中,且各个节点都可以相应读请求,具有好的响应性能。Zookeeper 保证了数据总是可用的,没有锁。并且有一大半的节点所拥有的数据是最新的。

分区容忍性 P: Follower 节点过多会导致增大数据同步的延时(需要半数以上 follower 写完提交)。同时选举过程的收敛速度会变慢,可用性降低。Zookeeper 通过引入 observer 节点缓解了这个问题,增加 observer 节点后集群可接受 client 请求的节点多了,而且 observer 不参与投票,可以提高可用性和扩展性,但是节点多数据同步总归是个问题,所以一致性会有所降低。

12.33 watch 监听为什么是一次性的?

如果服务端变动频繁,而监听的客户端很多情况下,每次变动都要通知到所有的客户端,给网络和服务器造成很大压力。

一般是客户端执行 getData(节点 A,true) ,如果节点 A 发生了变更或删除,客户端会得到它的watch 事件,但是在之后节点 A 又发生了变更,而客户端又没有设置 watch 事件,就不再给客户端发送。

在实际应用中,很多情况下,我们的客户端不需要知道服务端的每一次变动,我只要最新的数据即可。

十三、Dubbo篇

13.1 Dubbo是什么?

是阿里巴巴开源的基于 Java 的高性能 RPC 分布式服务框架,现已成为 Apache 基金会孵化项目。

13.2 RPC是什么?

RPC(Remote Procedure Call Protocol)远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。简言之,RPC使得程序能够像访问本地系统资源一样,去访问远端系统资源。比较关键的一些方面包括:通讯协议、序列化、资源(接口)描述、服务框架、性能、语言支持等。

简单说:RPC是远程过程调用,RPC同时也是一种计算机通信协议,他可以从A机器调用B机器的程序,调用的时候就类似于调用本地程序一样方便。

13.3 Dubbo有哪些特性?

Dubbo具有负载均衡、服务超时处理、集群容错、服务降级、本地存根、本地伪装、参数回调、异步调用等特性。

13.4 Dubbo的负载均衡是怎么实现的?

在Dubbo中,消费者调用服务者的时候会记录服务者的active,比如现在有一个消费者,有A、B两个服务者。当消费者向A服务发送一条消息的时候,消费者自身会记录A服务的active会加1,当消费者接收到服务者A的相应结果后会将A服务的active减1。而在消费者选择使用哪个服务者的时候正是根据每个服务者active的大小来判断的,首先选择active小的来调用。

13.5 Dubbo服务超时怎么设置吧,有什么要注意的吗?

Dubbo可以在消费者和服务端都设置超时时间,消费者的超时时间是消费者发出消息后到消费者接收到消息的时间。服务端的超时时间是服务端接收消息后到处理完毕后发出的时间。需要注意的是消费端的时间尽量设置的要比服务端的时间要长,因为如果消费端设置的是2秒,服务端设置的是5秒,而服务执行就需要3秒,那么消费端肯定是超时了,但是这个时候服务端并没有超时,不会发生异常。

13.6 Dubbo 服务请求流程?

在这里插入图片描述

Dubbo 基本工作流程(官网)

以下是官网上的原文。
在这里插入图片描述
Dubbo 首先是一款 RPC 框架,它定义了自己的 RPC 通信协议与编程方式。如上图所示,用户在使用 Dubbo 时首先需要定义好Dubbo 服务;其次,是在将 Dubbo 服务部署上线之后,依赖 Dubbo 的应用层通信协议实现数据交换,Dubbo 所传输的数据都要经过序列化,而这里的序列化协议是完全可扩展的。 使用 Dubbo 的第一步就是定义 Dubbo 服务,服务在 Dubbo 中的定义就是完成业务功能的一组方法的集合,可以选择使用与某种语言绑定的方式定义,如在 Java 中 Dubbo 服务就是有一组方法的 Interface 接口,也可以使用语言中立的 Protobuf Buffers IDL 定义服务。定义好服务之后,服务端(Provider)需要提供服务的具体实现,并将其声明为 Dubbo 服务,而站在服务消费方(Consumer)的视角,通过调用 Dubbo 框架提供的 API 可以获得一个服务代理(stub)对象,然后就可以像使用本地服务一样对服务方法发起调用了。 在消费端对服务方法发起调用后,Dubbo 框架负责将请求发送到部署在远端机器上的服务提供方,提供方收到请求后会调用服务的实现类,之后将处理结果返回给消费端,这样就完成了一次完整的服务调用。如图中的 Request、Response 数据流程所示。

需要注意的是,在 Dubbo 中,我们提到服务时,通常是指 RPC 粒度的、提供某个具体业务增删改功能的接口或方法,与一些微服务概念书籍中泛指的服务并不是一个概念。

在分布式系统中,尤其是随着微服务架构的发展,应用的部署、发布、扩缩容变得极为频繁,作为 RPC 消费方,如何动态的发现服务提供方地址成为 RPC 通信的前置条件。Dubbo 提供了自动的地址发现机制,用于应对分布式场景下机器实例动态迁移的问题。如下图所示,通过引入注册中心来协调提供方与消费方的地址,提供者启动之后向注册中心注册自身地址,消费方通过拉取或订阅注册中心特定节点,动态的感知提供方地址列表的变化。

在这里插入图片描述

13.7 Dubbo 核心特性(官网)

1)高性能 RPC 通信协议

跨进程或主机的服务通信是 Dubbo 的一项基本能力,Dubbo RPC 以预先定义好的协议编码方式将请求数据(Request)发送给后端服务,并接收服务端返回的计算结果(Response)。RPC 通信对用户来说是完全透明的,使用者无需关心请求是如何发出去的、发到了哪里,每次调用只需要拿到正确的调用结果就行。除了同步模式的 Request-Response 通信模型外,Dubbo3 还提供更丰富的通信模型选择:

  1. 消费端异步请求(Client Side Asynchronous Request-Response)
  2. 提供端异步执行(Server Side Asynchronous Request-Response)
  3. 消费端请求流(Request Streaming)
  4. 提供端响应流(Response Streaming)
  5. 双向流式通信(Bidirectional Streaming)

具体可参见各语言 SDK 实现的可选协议列表 或 Triple协议

2)自动服务(地址)发现

Dubbo 的服务发现机制,让微服务组件之间可以独立演进并任意部署,消费端可以在无需感知对端部署位置与 IP 地址的情况下完成通信。Dubbo 提供的是 Client-Based 的服务发现机制,使用者可以有多种方式启用服务发现:

  1. 使用独立的注册中心组件,如 Nacos、Zookeeper、Consul、Etcd 等。
  2. 将服务的组织与注册交给底层容器平台,如 Kubernetes,这被理解是一种更云原生的使用方式。

3)运行态流量管控

透明地址发现让 Dubbo 请求可以被发送到任意 IP 实例上,这个过程中流量被随机分配。当需要对流量进行更丰富、更细粒度的管控时,就可以用到 Dubbo 的流量管控策略,Dubbo 提供了包括负载均衡、流量路由、请求超时、流量降级、重试等策略,基于这些基础能力可以轻松的实现更多场景化的路由方案,包括金丝雀发布、A/B测试、权重路由、同区域优先等,更酷的是,Dubbo 支持流控策略在运行态动态生效,无需重新部署。具体可参见:流量治理示例

3)丰富的扩展组件及生态

Dubbo 强大的服务治理能力不仅体现在核心框架上,还包括其优秀的扩展能力以及周边配套设施的支持。通过 Filter、Router、Protocol 等几乎存在于每一个关键流程上的扩展点定义,我们可以丰富 Dubbo 的功能或实现与其他微服务配套系统的对接,包括 Transaction、Tracing 目前都有通过 SPI 扩展的实现方案,具体可以参见 Dubbo 扩展性的详情,也可以在 apache/dubbo-spi-extensions 项目中发现与更多的扩展实现。具体可参见:Dubbo生态Dubbo可扩展性设计

4)面向云原生设计

Dubbo 从设计上是完全遵循云原生微服务开发理念的,这体现在多个方面,首先是对云原生基础设施与部署架构的支持,包括 容器、Kubernetes 等,Dubbo Mesh 总体解决方案也在 3.1 版本正式发布;另一方面,Dubbo 众多核心组件都已面向云原生升级,包括 Triple 协议、统一路由规则、对多语言的支持。

值得一提的是,如何使用 Dubbo 支持弹性伸缩的服务如 Serverless 也在未来计划之中,这包括利用 Native Image 提高 Dubbo 的启动速度与资源消耗等。

结合当前版本,本节主要从以下两点展开 Dubbo 的云原生特性容器调度平台(Kubernetes)Dubbo Mesh

5)Kubernetes

Dubbo 微服务要支持 Kubernetes 平台调度,最基础的就是实现 dubbo 服务生命周期与容器生命周期的对齐,这包括 Dubbo 的启动、销毁、服务注册等生命周期事件。相比于以往 Dubbo 自行定义生命周期事件,并要求开发人员在运维实践过程中遵守约定,Kubernetes 底层基础设施定义了严格的组件生命周期事件(probe),转而要求 Dubbo 去按约定适配。

Kubernetes Service 是另一个层面的适配,这体现了服务定义与注册向云原生底层基础设施下沉的趋势。在这种模式下,用户不再需要搭建额外的注册中心组件,Dubbo 消费端节点能自动对接到 Kubernetes(API-Server 或 DNS),根据服务名(Kubernetes Service Name) 查询到实例列表(Kubernetes endpoints)。 此时服务是通过标准的 Kubernetes Service API 定义,并被调度到各个节点。

6)Dubbo Mesh

Service Mesh 在业界得到了广泛的传播与认可,并被认为是下一代的微服务架构,这主要是因为它解决了很多棘手的问题,包括透明升级、多语言、依赖冲突、流量治理等。Service Mesh 的典型架构是通过部署独立的 Sidecar 组件来拦截所有的出口与入口流量,并在 Sidecar 中集成丰富的流量治理策略如负载均衡、路由等,除此之外,Service Mesh 还需要一个控制面(Control Panel)来实现对 Sidecar 流量的管控,即各种策略下发。我们在这里称这种架构为经典 Mesh。

然而任何技术架构都不是完美的,经典 Mesh 在实施层面也面临成本过高的问题

  1. 需要运维控制面(Control Panel)
  2. 需要运维 Sidecar
  3. 需要考虑如何从原有 SDK 迁移到 Sidecar
  4. 需要考虑引入 Sidecar 后整个链路的性能损耗

为了解决 Sidecar 引入的相关成本问题,Dubbo 引入并实现了全新的 Proxyless Mesh 架构,顾名思义,Proxyless Mesh 就是指没有 Sidecar 的部署,转而由 Dubbo SDK 直接与控制面交互,其架构图如下:

在这里插入图片描述
可以设想,在不同的组织、不同的发展阶段,未来以 Dubbo 构建的微服务将会允许有三种部署架构:传统 SDK、基于 Sidecar 的 Service Mesh、脱离 Sidecar 的 Proxyless Mesh。基于 Sidecar 的 Service Mesh,即经典的 Mesh 架构,独立的 sidecar 运行时接管所有的流量,脱离 Sidecar 的 Proxyless Mesh,副 SDK 直接通过 xDS 与控制面通信。Dubbo 微服务允许部署在物理机、容器、Kubernetes 平台之上,能做到以 Admin 为控制面,以统一的流量治理规则进行治理。

13.8 Dubbo 负载均衡策略

随机(默认)、轮训、活跃度、一致性 hash

13.9 Dubbo 容错策略

1)failover cluster 模式

provider 宕机重试以后,请求会分到其他的 provider 上,默认两次,可以手动设置重试次数,建议把写操作重试次数设置成 0。

2)failback 模式

失败自动恢复会在调用失败后,返回一个空结果给服务消费者。并通过定时任务对失败的调用进行重试,适合执行消息通知等操作。

3)failfast cluster 模式

快速失败只会进行一次调用,失败后立即抛出异常。适用于幂等操作、写操作,类似于 failovercluster 模式中重试次数设置为 0 的情况。

4)failsafe cluster 模式

失败安全是指,当调用过程中出现异常时,仅会打印异常,而不会抛出异常。适用于写入审计日志等操作。

5)forking cluster 模式

并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks=“2” 来设置最大并行数。

6)broadcacst cluster 模式

广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。

13.10 Dubbo 动态代理策略有哪些?

默认使用 javassist 动态字节码生成,创建代理类,但是可以通过 SPI 扩展机制配置自己的动态代理策略。

13.11 Zookeeper 和 Dubbo 的关系?

Zookeeper的作用

Zookeeper用来注册服务和进行负载均衡,哪一个服务由哪一个机器来提供必需让调用者知道,简单来说就是ip地址和服务名称的对应关系。当然也可以通过硬编码的方式把这种对应关系在调用方业务代码中实现,但是如果提供服务的机器挂掉调用者无法知晓,如果不更改代码会继续请求挂掉的机器提供服务。zookeeper通过心跳机制可以检测挂掉的机器并将挂掉机器的ip和服务对应关系从列表中删除。至于支持高并发,简单来说就是横向扩展,在不更改代码的情况通过添加机器来提高运算能力。通过添加新的机器向zookeeper注册服务,服务的提供者多了能服务的客户就多了。

dubbo

是管理中间层的工具,在业务层到数据仓库间有非常多服务的接入和服务提供者需要调度,dubbo提供一个框架解决这个问题。 注意这里的dubbo只是一个框架,至于你架子上放什么是完全取决于你的,就像一个汽车骨架,你需要配你的轮子引擎。这个框架中要完成调度必须要有一个分布式的注册中心,储存所有服务的元数据,你可以用zk,也可以用别的,只是大家都用zk。

zookeeper和dubbo的关系

引入了 ZooKeeper 作为存储媒介,也就把 ZooKeeper 的特性引进来。首先是负载均衡,单注册中心的承载能力是有限的,在流量达到一定程度的时 候就需要分流,负载均衡就是为了分流而存在的,一个 ZooKeeper 群配合相应的 Web 应用就可以很容易达到负载均衡;资源同步,单单有负载均衡还不 够,节点之间的数据和资源需要同步,ZooKeeper 集群就天然具备有这样的功能;命名服务,将树状结构用于维护全局的服务地址列表,服务提供者在启动 的时候,向 ZooKeeper 上的指定节点 /dubbo/${serviceName}/providers 目录下写入自己的 URL 地址,这个操作就完成了服务的发布。 其他特性还有 Mast 选举,分布式锁等。

在这里插入图片描述

参考资料:
面试官:Dubbo是什么,他有什么特性?
Dubbo官网

十四、MQ篇

14.1 RabbitMQ是什么?

RabbitMQ是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用Erlang语言编写的,而群集和故障转移是构建在开放电信平台框架上的。所有主要的编程语言均有与代理接口通讯的客户端库。

14.2 AMQP是什么?

RabbitMQ就是 AMQP 协议的 Erlang 的实现(当然 RabbitMQ 还支持 STOMP2、 MQTT3 等协议 ) AMQP 的模型架构 和RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。

RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。

Module Layer: 协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。

Session Layer: 中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。

TransportLayer: 最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。

AMQP模型的组件

交换器 (Exchange): 消息代理服务器中用于把消息路由到队列的组件。

队列 (Queue): 用来存储消息的数据结构,位于硬盘或内存中。

绑定 (Binding): 一套规则,告知交换器消息应该将消息投递给哪个队列。

14.3 RabbitMQ如何保证高可用的?

RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。

单机模式: 就是 Demo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用单机模式。

普通集群模式: 意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。你创建的queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。

镜像集群模式: 这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。这样的话,好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。

14.4 如何保证消息的可靠传输?如果消息丢了怎么办

数据的丢失问题,可能出现在生产者、MQ、消费者中

生产者丢失消息: 生产者将数据发送到 RabbitMQ 的时候,可能数据就在半路给搞丢了,因为网络问题啥的,都有可能。此时可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。吞吐量会下来,因为太耗性能。所以一般来说,如果你要确保说写 RabbitMQ 的消息别丢,可以开启confirm模式,在生产者那里设置开启confirm模式之后,你每次写的消息都会分配一个唯一的 id,然后如果写入了 RabbitMQ 中,RabbitMQ 会给你回传一个ack消息,告诉你说这个消息 ok 了。如果 RabbitMQ 没能处理这个消息,会回调你一个nack接口,告诉你这个消息接收失败,你可以重试。而且你可以结合这个机制自己在内存里维护每个消息 id 的状态,如果超过一定时间还没接收到这个消息的回调,那么你可以重发。事务机制和cnofirm机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是confirm机制是异步的,你发送个消息之后就可以发送下一个消息,然后那个消息RabbitMQ 接收了之后会异步回调你一个接口通知你这个消息接收到了。所以一般在生产者这块避免数据丢失,都是用confirm机制的。

MQ丢失信息: 就是 RabbitMQ 自己弄丢了数据,这个你必须开启 RabbitMQ 的持久化,就是消息写入之后会持久化到磁盘,哪怕是 RabbitMQ 自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。设置持久化有两个步骤:创建 queue 的时候将其设置为持久化,这样就可以保证RabbitMQ 持久化 queue 的元数据,但是不会持久化 queue 里的数据。第二个是发送消息的时候将消息的 deliveryMode 设置为 2,就是将消息设置为持久化的,此时 RabbitMQ 就会将消息持久化到磁盘上去。必须要同时设置这两个持久化才行,RabbitMQ 哪怕是挂了,再次重启,也会从磁盘上重启恢复 queue,恢复这个 queue 里的数据。持久化可以跟生产者那边的confirm机制配合起来,只有消息被持久化到磁盘之后,才会通知生产者ack了,所以哪怕是在持久化到磁盘之前,RabbitMQ 挂了,数据丢了,生产者收不到ack,你也是可以自己重发的。注意,哪怕是你给RabbitMQ 开启了持久化机制,也有一种可能,就是这个消息写到了 RabbitMQ 中,但是还没来得及持久化到磁盘上,结果不巧,此时 RabbitMQ 挂了,就会导致内存里的一点点数据丢失。

消费者丢失信息: 你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,那么就尴尬了,RabbitMQ 认为你都消费了,这数据就丢了。这个时候得用 RabbitMQ 提供的ack机制,简单来说,就是你关闭 RabbitMQ 的自动ack,可以通过一个 api 来调用就行,然后每次你自己代码里确保处理完的时候,再在程序里ack一把。这样的话,如果你还没处理完,不就没有ack?那RabbitMQ 就认为你还没处理完,这个时候 RabbitMQ 会把这个消费分配给别的 consumer 去处理,消息是不会丢的。

在这里插入图片描述

14.5 如何保证消息的顺序性

先看看顺序会错乱的场景:RabbitMQ:一个 queue,多个 consumer,这不明显乱了。

在这里插入图片描述
如何解决,拆分多queue,每个queue一个consumer,就是多一些queue而已,确实有点麻烦;或者就一个queue对一个consumer,然后这个consumer内部用内存队列排队,分发给不同的worker来处理。

在这里插入图片描述

14.6 如何解决消息队列的延时以及过期失效问题?消息队列满了以后该怎么处理?有几百万消息持续积压几小时,说说怎么解决?

消息积压处理办法:临时紧急扩容

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

MQ中消息失效

假设你用的是 RabbitMQ,RabbtiMQ 是可以设置过期时间的,也就是 TTL。如果消息在 queue 中积压超过一定的时间就会被
RabbitMQ 给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在 mq里,而是大量的数据会直接搞丢。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上12点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把白天丢的数据给他补回来。也只能是这样了。假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。

mq消息队列块满了

如果消息积压在 mq 里,你很长时间都没有处理掉,此时导致 mq 都快写满了,咋办?这个还有别的办法吗?没有,谁让你第一个方案执行的太慢了,你临时写程序,接入数据来消费,消费一个丢弃一个,都不要了,快速消费掉所有的消息。然后走第二个方案,到了晚上再补数据吧。

14.7 消息队列有哪些作用?

  1. 解耦: 使⽤消息队列来作为两个系统之间的通讯⽅式,两个系统不需要相互依赖了
  2. 异步: 系统A给消息队列发送完消息之后,就可以继续做其他事情了
  3. 流量削峰: 如果使⽤消息队列的⽅式来调⽤某个系统,那么消息将在队列中排队,由消费者⾃⼰控制消费速度

14.8 死信队列是什么?延时队列是什么?

死信队列也是⼀个消息队列,它是⽤来存放那些没有成功消费的消息的,通常可以⽤来作为消息重试。

延时队列就是⽤来存放需要在指定时间被处理的元素的队列,通常可以⽤来处理⼀些具有过期性操作的业务,⽐如⼗分钟内未⽀付则取消订单。

14.9 如何保证消息的高效读写?

零拷贝: kafka和RocketMQ都是通过零拷贝技术来优化文件读写。

传统⽂件复制⽅式: 需要对⽂件在内存中进⾏四次拷贝。

零拷贝: 有两种⽅式, mmaptransfile,Java当中对零拷⻉进⾏了封装, Mmap⽅式通过MappedByteBuffer对象进⾏操作,⽽transfile通过FileChannel来进⾏操作。Mmap 适合⽐较⼩的⽂件,通常⽂件⼤⼩不要超过1.5G ~2G 之间。Transfile没有⽂件⼤⼩限制。RocketMQ当中使⽤Mmap⽅式来对他的⽂件进⾏读写。

在kafka当中,他的index⽇志⽂件也是通过mmap的⽅式来读写的。在其他⽇志⽂件当中,并没有使⽤零拷⻉的⽅式。Kafka使⽤transfile⽅式将硬盘数据加载到⽹卡。

14.10 消息队列如何保证消息可靠传输

消息可靠传输代表了两层意思,既不能多也不能少。

  1. 为了保证消息不多,也就是消息不能重复,也就是⽣产者不能重复⽣产消息,或者消费者不能重复消费消息
  2. ⾸先要确保消息不多发,这个不常出现,也⽐较难控制,因为如果出现了多发,很⼤的原因是⽣产者⾃⼰的原因,如果要避免出现问题,就需要在消费端做控制
  3. 要避免不重复消费,最保险的机制就是消费者实现幂等性,保证就算重复消费,也不会有问题,通过幂等性,也能解决⽣产者重复发送消息的问题
  4. 消息不能少,意思就是消息不能丢失,⽣产者发送的消息,消费者⼀定要能消费到,对于这个问题,就要考虑两个⽅⾯
  5. ⽣产者发送消息时,要确认broker确实收到并持久化了这条消息,⽐如RabbitMQ的confirm机制,Kafka的ack机制都可以保证⽣产者能正确的将消息发送给broker
  6. broker要等待消费者真正确认消费到了消息时才删除掉消息,这⾥通常就是消费端ack机制,消费者接收到⼀条消息后,如果确认没问题了,就可以给broker发送⼀个ackbroker接收到ack后才会删除消息

14.11 RabbitMQ死信队列、延时队列

消息被消费⽅否定确认,使⽤ channel.basicNackchannel.basicReject ,并且此时requeue 属性被设置为false

消息在队列的存活时间超过设置的TTL时间。

消息队列的消息数量已经超过最⼤队列⻓度。

那么该消息将成为“死信”。“死信”消息会被RabbitMQ进⾏特殊处理,如果配置了死信队列信息,那么该消息将会被丢进死信队列中,如果没有配置,则该消息将会被丢弃。

为每个需要使⽤死信的业务队列配置⼀个死信交换机,这⾥同⼀个项⽬的死信交换机可以共⽤⼀个,然后为每个业务队列分配⼀个单独的路由key,死信队列只不过是绑定在死信交换机上的队列,死信交换机也不是什么特殊的交换机,只不过是⽤来接受死信的交换机,所以可以为任何类型【Direct、Fanout、Topic】

TTL:⼀条消息或者该队列中的所有消息的最⼤存活时间

如果⼀条消息设置了TTL属性或者进⼊了设置TTL属性的队列,那么这条消息如果在TTL设置的时间内没有被消费,则会成为“死信”。如果同时配置了队列的TTL和消息的TTL,那么较⼩的那个值将会被使用。

只需要消费者⼀直消费死信队列⾥的消息

14.12 简述RabbitMQ的架构设计

Broker: rabbitmq的服务节点

Queue: 队列,是RabbitMQ的内部对象,⽤于存储消息。RabbitMQ中消息只能存储在队列中。⽣产者投递消息到队列,消费者从队列中获取消息并消费。多个消费者可以订阅同⼀个队列,这时队列中的消息会被平均分摊(轮询)给多个消费者进⾏消费,⽽不是每个消费者都收到所有的消息进⾏消费。(注意:RabbitMQ不⽀持队列层⾯的⼴播消费,如果需要⼴播消费,可以采⽤⼀个交换器通过路由Key绑定多个队列,由多个消费者来订阅这些队列的⽅式。

Exchange: 交换器。⽣产者将消息发送到Exchange,由交换器将消息路由到⼀个或多个队列中。如果路由不到,或返回给⽣产者,或直接丢弃,或做其它处理。

RoutingKey: 路由Key。⽣产者将消息发送给交换器的时候,⼀般会指定⼀个RoutingKey,⽤来指定这个消息的路由规则。这个路由Key需要与交换器类型和绑定键(BindingKey)联合使⽤才能最终⽣效。在交换器类型和绑定键固定的情况下,⽣产者可以在发送消息给交换器时通过指定RoutingKey来决定消息流向哪⾥。

Binding: 通过绑定将交换器和队列关联起来,在绑定的时候⼀般会指定⼀个绑定键,这样RabbitMQ就可以指定如何正确的路由到队列了。交换器和队列实际上是多对多关系。就像关系数据库中的两张表。他们通过BindingKey做关联(多对多关系表)。在投递消息时,可以通过ExchangeRoutingKey(对应BindingKey)就可以找到相对应的队列。

信道: 信道是建⽴在Connection 之上的虚拟连接。当应⽤程序与Rabbit Broker建⽴TCP连接的时候,客户端紧接着可以创建⼀个AMQP 信道(Channel) ,每个信道都会被指派⼀个唯⼀的D。RabbitMQ 处理的每条AMQP 指令都是通过信道完成的。信道就像电缆⾥的光纤束。⼀条电缆内含有许多光纤束,允许所有的连接通过多条光线束进⾏传输和接收。

14.13 Kafka是什么?

Kafka 是⼀种⾼吞吐量、分布式、基于发布/订阅的消息系统,最初由 LinkedIn 公司开发,使用Scala语⾔编写,⽬前是 Apache 的开源项⽬。broker:Kafka 服务器,负责消息存储和转发topic:消息类别, Kafka 按照 topic 来分类消息partition:topic 的分区,⼀个 topic 可以包含多个 partition,topic 消息保存在各个partition 上offset:消息在⽇志中的位置,可以理解是消息在 partition 上的偏移量,也是代表该消息的唯⼀序号Producer:消息⽣产者Consumer:消息消费者Consumer Group:消费者分组,每个 Consumer 必须属于⼀个 groupZookeeper:保存着集群 broker、 topic、 partition等 meta 数据;另外,还负责 broker 故障发现, partition leader 选举,负载均衡等功能。

14.14 Kafka为什么吞吐量高?

Kafka的⽣产者采⽤的是异步发送消息机制,当发送⼀条消息时,消息并没有发送到Broker而是缓存起来,然后直接向业务返回成功,当缓存的消息达到⼀定数量时再批量发送给Broker。这种做法减少了网络io,从而提⾼了消息发送的吞吐量,但是如果消息⽣产者宕机,会导致消息丢失,业务出错,所以理论上kafka利⽤此机制提⾼了性能却降低了可靠性。

14.15 Kafka的Pull和Push分别有什么优缺点?

pull表示消费者主动拉取,可以批量拉取,也可以单条拉取,所以pull可以由消费者自己控制,根据自己的消息处理能⼒来进⾏控制,但是消费者不能及时知道是否有消息,可能会拉到的消息为空。

push表示Broker主动给消费者推送消息,所以肯定是有消息时才会推送,但是消费者不能按自己的能力来消费消息,推过来多少消息,消费者就得消费多少消息,所以可能会造成⽹络堵塞,消费者压力大等问题。

14.16 Kafka中的ISR、AR又代表什么?ISR的伸缩又指什么?

ISR: In-Sync Replicas 副本同步队列

AR: Assigned Replicas 所有副本ISR是由leader维护,follower从leader同步数据有⼀些延迟(包括延迟时间replica.lag.time.max.ms和延迟条数replica.lag.max.messages两个维度, 当前最新的版本0.10.x中只⽀持replica.lag.time.max.ms这个维度),任意⼀个超过阈值都会把follower剔除出ISR, 存⼊OSR(Outof-Sync Replicas)列表,新加⼊的follower也会先存放在OSR中。AR=ISR+OSR。

14.17 Kafka高效文件存储设计特点

  1. Kafka 把 topic 中⼀个 parition 大文件分成多个小文件段,通过多个小文件段,就容易定期清除或删除已经消费完⽂件,减少磁盘占⽤。
  2. 通过索引信息可以快速定位 message 和确定 response 的最⼤⼤小。
  3. 通过 index 元数据全部映射到 memory,可以避免 segment file 的 IO 磁盘操作。
  4. 通过索引文件稀疏存储,可以大幅降低 index 文件元数据占⽤空间⼤小。

14.18 Kafka与传统消息系统之间有三个关键区别

  1. Kafka 持久化⽇志,这些⽇志可以被重复读取和⽆限期保留
  2. Kafka 是⼀个分布式系统:它以集群的方式运行,可以灵活伸缩,在内部通过复制数据提升容错能⼒和⾼可⽤性
  3. Kafka ⽀持实时的流式处理

14.19 Kafka创建 Topic 时如何将分区放置到不同的 Broker 中

  1. 副本因子不能大于 Broker 的个数;
  2. 第⼀个分区(编号为 0)的第⼀个副本放置位置是随机从 brokerList 选择的;
  3. 其他分区的第⼀个副本放置位置相对于第 0 个分区依次往后移。也就是如果我们有 5 个Broker,5 个分区,假设第⼀个分区放在第四个 Broker 上,那么第⼆个分区将会放在第五个 Broker 上;第三个分区将会放在第⼀个 Broker 上;第四个分区将会放在第⼆个Broker 上,依次类推;
  4. 剩余的副本相对于第⼀个副本放置位置其实是由 nextReplicaShift 决定的,而这个数也是随机产⽣的

14.20 Kafka的消费者如何消费数据

消费者每次消费数据的时候,消费者都会记录消费的物理偏移量( offset)的位置等到下次消费时,他会接着上次位置继续消费。

14.21 Kafka消费者负载均衡策略

⼀个消费者组中的⼀个分⽚对应⼀个消费者成员,他能保证每个消费者成员都能访问,如果组中成员太多会有空闲的成员。

14.22 kafaka⽣产数据时数据的分组策略

⽣产者决定数据产⽣到集群的哪个 partition 中每⼀条消息都是以( key, value)格式 Key是由⽣产者发送数据传⼊所以⽣产者( key)决定了数据产⽣到集群的哪个 partition

14.23 Kafka中是怎么体现消息顺序性的?

kafka每个partition中的消息在写⼊时都是有序的,消费时,每个partition只能被每⼀个group中的⼀个消费者消费,保证了消费时也是有序的。整个topic不保证有序。如果为了保证topic整个有序,那么将partition调整为1。

14.24 Kafka如何实现延迟队列?

Kafka并没有使⽤JDK自带的Timer或者DelayQueue来实现延迟的功能,而是基于时间轮⾃定义了⼀个用于实现延迟功能的定时器(SystemTimer)。JDK的TimerDelayQueue插⼊和删除操作的平均时间复杂度为O(nlog(n)),并不能满⾜Kafka的⾼性能要求,而基于时间轮可以将插⼊和删除操作的时间复杂度都降为O(1)。时间轮的应用并非Kafka独有,其应⽤场景还有很多,Netty、Akka、Quartz、Zookeeper等组件中都存在时间轮的踪影。底层使⽤数组实现,数组中的每个元素可以存放⼀个TimerTaskList对象。TimerTaskList是⼀个环形双向链表,在其中的链表项TimerTaskEntry中封装了真正的定时任务TimerTask。Kafka中到底是怎么推进时间的呢?Kafka中的定时器借助了JDK中的DelayQueue来协助推进时间轮。具体做法是对于每个使⽤到的TimerTaskList都会加⼊到DelayQueue中。Kafka中的TimingWheel专门用来执行插⼊和删除TimerTaskEntry的操作,而DelayQueue专⻔负责时间推进的任务。再试想⼀下,DelayQueue中的第⼀个超时任务列表的expiration为200ms,第⼆个超时任务为840ms,这⾥获取DelayQueue的队头只需要O(1)的时间复杂度。如果采用每秒定时推进,那么获取到第⼀个超时的任务列表时执⾏的200次推进中有199次属于“空推进”,而获取到第⼆个超时任务时有需要执行639次“空推进”,这样会⽆故空耗机器的性能资源,这⾥采⽤DelayQueue来辅助以少量空间换时间,从而做到了“精准推进”。Kafka中的定时器真可谓是“知⼈善用”,⽤TimingWheel做最擅⻓的任务添加和删除操作,而用DelayQueue做最擅长的时间推进工作,相辅相成。

14.25 RocketMQ的实现原理

RocketMQ由NameServer注册中⼼集群、Producer⽣产者集群、Consumer消费者集群和若干Broker(RocketMQ进程)组成,它的架构原理是这样的:

Broker在启动的时候去向所有的NameServer注册,并保持⻓连接,每30s发送⼀次心跳
Producer在发送消息的时候从NameServer获取Broker服务器地址,根据负载均衡算法选择⼀台服务器来发送消息

Conusmer消费消息的时候同样从NameServer获取Broker地址,然后主动拉取消息来消费

十五、分布式与微服务篇

15.1 什么是微服务

微服务简介

在介绍微服务时,首先得先理解什么是微服务,顾名思义,微服务得从两个方面去理解,什么是"微"、什么是"服务", 微 狭义来讲就是体积小、著名的"2 pizza 团队"很好的诠释了这一解释(2 pizza 团队最早是亚马逊 CEO Bezos提出来的,意思是说单个服务的设计,所有参与人从设计、开发、测试、运维所有人加起来 只需要2个披萨就够了 )。 而所谓服务,一定要区别于系统,服务一个或者一组相对较小且独立的功能单元,是用户可以感知最小功能集。

微服务由来

微服务最早由Martin Fowler与James Lewis于2014年共同提出,微服务架构风格是一种使用一套小服务来开发单个应用的方式途径,每个服务运行在自己的进程中,并使用轻量级机制通信,通常是HTTP API,这些服务基于业务能力构建,并能够通过自动化部署机制来独立部署,这些服务使用不同的编程语言实现,以及不同数据存储技术,并保持最低限度的集中式管理。

15.2 为什么需要微服务?

在传统的IT行业软件大多都是各种独立系统的堆砌,这些系统的问题总结来说就是扩展性差,可靠性不高,维护成本高。到后面引入了SOA服务化,但是,由于 SOA 早期均使用了总线模式,这种总线模式是与某种技术栈强绑定的,比如:J2EE。这导致很多企业的遗留系统很难对接,切换时间太长,成本太高,新系统稳定性的收敛也需要一些时间。最终 SOA 看起来很美,但却成为了企业级奢侈品,中小公司都望而生畏。

早期单体架构带来的问题

单体架构在规模比较小的情况下工作情况良好,但是随着系统规模的扩大,它暴露出来的问题也越来越多,主要有以下几点:

  1. 复杂性逐渐变高: 比如有的项目有几十万行代码,各个模块之间区别比较模糊,逻辑比较混乱,代码越多复杂性越高,越难解决遇到的问题。
  2. 技术债务逐渐上升: 公司的人员流动是再正常不过的事情,有的员工在离职之前,疏于代码质量的自我管束,导致留下来很多坑,由于单体项目代码量庞大的惊人,留下的坑很难被发觉,这就给新来的员工带来很大的烦恼,人员流动越大所留下的坑越多,也就是所谓的技术债务越来越多。
  3. 部署速度逐渐变慢: 这个就很好理解了,单体架构模块非常多,代码量非常庞大,导致部署项目所花费的时间越来越多,曾经有的项目启动就要一二十分钟,这是多么恐怖的事情啊,启动几次项目一天的时间就过去了,留给开发者开发的时间就非常少了。
  4. 阻碍技术创新: 比如以前的某个项目使用struts2写的,由于各个模块之间有着千丝万缕的联系,代码量大,逻辑不够清楚,如果现在想用spring mvc来重构这个项目将是非常困难的,付出的成本将非常大,所以更多的时候公司不得不硬着头皮继续使用老的struts架构,这就阻碍了技术的创新。
  5. 无法按需伸缩: 比如说电影模块是CPU密集型的模块,而订单模块是IO密集型的模块,假如我们要提升订单模块的性能,比如加大内存、增加硬盘,但是由于所有的模块都在一个架构下,因此我们在扩展订单模块的性能时不得不考虑其它模块的因素,因为我们不能因为扩展某个模块的性能而损害其它模块的性能,从而无法按需进行伸缩。

15.3 微服务与单体架构的区别

  1. 单体架构所有的模块全都耦合在一块,代码量大,维护困难,微服务每个模块就相当于一个单独的项目,代码量明显减少,遇到问题也相对来说比较好解决。
  2. 单体架构所有的模块都共用一个数据库,存储方式比较单一,微服务每个模块都可以使用不同的存储方式(比如有的用redis,有的用mysql等),数据库也是单个模块对应自己的数据库。
  3. 单体架构所有的模块开发所使用的技术一样,微服务每个模块都可以使用不同的开发技术,开发模式更灵活。

15.4 微服务与SOA的区别

微服务,从本质意义上看,还是 SOA 架构。但内涵有所不同,微服务并不绑定某种特殊的技术,在一个微服务的系统中,可以有 Java 编写的服务,也可以有 Python编写的服务,他们是靠Restful架构风格统一成一个系统的。所以微服务本身与具体技术实现无关,扩展性强。

15.5 微服务本质

微服务,关键其实不仅仅是微服务本身,而是系统要提供一套基础的架构,这种架构使得微服务可以独立的部署、运行、升级,不仅如此,这个系统架构还让微服务与微服务之间在结构上“松耦合”,而在功能上则表现为一个统一的整体。这种所谓的“统一的整体”表现出来的是统一风格的界面,统一的权限管理,统一的安全策略,统一的上线过程,统一的日志和审计方法,统一的调度方式,统一的访问入口等等。

微服务的目的是有效的拆分应用,实现敏捷开发和部署 。

微服务提倡的理念团队间应该是 inter-operate, not integrate 。inter-operate是定义好系统的边界和接口,在一个团队内全栈,让团队自治,原因就是因为如果团队按照这样的方式组建,将沟通的成本维持在系统内部,每个子系统就会更加内聚,彼此的依赖耦合能变弱,跨系统的沟通成本也就能降低。

15.6 微服务设计原则

单一职责原则: 意思是每个微服务只需要实现自己的业务逻辑就可以了,比如订单管理模块,它只需要处理订单的业务逻辑就可以了,其它的不必考虑。

服务自治原则: 意思是每个微服务从开发、测试、运维等都是独立的,包括存储的数据库也都是独立的,自己就有一套完整的流程,我们完全可以把它当成一个项目来对待。不必依赖于其它模块。

轻量级通信原则: 首先是通信的语言非常的轻量,第二,该通信方式需要是跨语言、跨平台的,之所以要跨平台、跨语言就是为了让每个微服务都有足够的独立性,可以不受技术的钳制。

接口明确原则: 由于微服务之间可能存在着调用关系,为了尽量避免以后由于某个微服务的接口变化而导致其它微服务都做调整,在设计之初就要考虑到所有情况,让接口尽量做的更通用,更灵活,从而尽量避免其它模块也做调整。

15.7 微服务架构的常见概念

1)服务治理

服务治理就是进行服务的自动化管理,其核心是服务的自动注册与发现。

  1. 服务注册: 服务实例将自身服务信息注册到注册中心。
  2. 服务发现: 服务实例通过注册中心,获取到注册到其中的服务实例的信息,通过这些信息去请求它们提供的服务。
  3. 服务剔除: 服务注册中心将出问题的服务自动剔除到可用列表之外,使其不会被调用到。

2)服务调用

在微服务架构中,通常存在多个服务之间的远程调用的需求。目前主流的远程调用技术有基于HTTP的RESTfu接口以及基于TCP的RPC协议。

  1. REST(Representational State Transfer): 这是一种HTTP调用的格式,更标准,更通用,无论哪种语言都支持http协议。

  2. RPC(Remote Promote Call): 一种进程间通信方式。允许像调用本地服务一样调用远程服务RPC框架的主要目标就是让远程服务调用更简单、透明。RPC框架负责屏蔽底层的传输方式、序列化方式和通信细节。开发人员在使用的时候只需要了解谁在什么位置提供了什么样的远程服务接口即可,并不需要关心底层通信细节和调用过程。

区别与联系

比较项 RESTful RPC
通信协议 HTTP 一般使用TCP
性能 略低 较高
灵活度 较高
应用 微服务架构 SOA架构

3)服务网关

随着微服务的不断增多,不同的微服务一般会有不同的网络地址,而外部客户端可能需要调用多个服务的接口才能完成一个业务需求,如果让客户端直接与各个微服务通信可能出现:

  1. 客户端需要调用不同的url地址,增加难度。
  2. 在一定的场景下,存在跨域请求的问题。
  3. 每个微服务都需要进行单独的身份认证。

针对这些问题,API网关顺势而生。API网关直面意思是将所有API调用统一接入到API网关层,由网关层统一接入和输出。一个网关的基本功能有:统一接入、安全防护、协议适配、流量管控、长短链接支持、容错能力。有了网关之后,各个API服务提供团队可以专注于自己的的业务逻辑处理,而API网关更专注于安全、流量、路由等问题。

在这里插入图片描述

4)服务容错

在微服务当中,一个请求经常会涉及到调用几个服务,如果其中某个服务不可用,没有做服务容错的话,极有可能会造成一连串的服务不可用,这就是雪崩效应。我们没法预防雪崩效应的发生,只能尽可能去做好容错。服务容错的三个核心思想是:不被外界环境影响不被上游请求压垮不被下游响应拖垮

在这里插入图片描述
5)链路追踪

随着微服务架构的流行,服务按照不同的维度进行拆分,一次请求往往需要涉及到多个服务。互联网应用构建在不同的软件模块集上,这些软件模块,有可能是由不同的团队开发、可能使用不同的编程语言来实现、有可能布在了几千台服务器,横跨多个不同的数据中心。因此,就需要对一次请求涉及的多个服务链路进行日志记录,性能监控即链路追踪。

15.8 什么是CAP理论

CAP理论是分布式领域中⾮常重要的⼀个指导理论,C(Consistency)表示强⼀致性,A(Availability)表示可⽤性,P(Partition Tolerance)表示分区容错性,CAP理论指出在目前的硬件条件下,⼀个分布式系统是必须要保证分区容错性的,而在这个前提下,分布式系统要么保证CP,要么保证AP,⽆法同时保证CAP。

分区容错性表示: ⼀个系统虽然是分布式的,但是对外看上去应该是⼀个整体,不能由于分布式系统内部的某个结点挂点,或⽹络出现了故障,而导致系统对外出现异常。所以,对于分布式系统而言是⼀定要保证分区容错性的。

强⼀致性表示: ⼀个分布式系统中各个结点之间能及时的同步数据,在数据同步过程中,是不能对外提供服务的,不然就会造成数据不⼀致,所以强⼀致性和可⽤性是不能同时满足的。

可用性表示: ⼀个分布式系统对外要保证可用。

15.9 什么是BASE理论

由于不能同时满足CAP,所以出现了BASE理论:

BA: Basically Available,表示基本可用,表示可以允许⼀定程度的不可用,比如由于系统故障,请求时间变长,或者由于系统故障导致部分非核心功能不可用,都是允许的。

S: Soft state:表示分布式系统可以处于⼀种中间状态,比如数据正在同步。

E: Eventually consistent,表示最终⼀致性,不要求分布式系统数据实时达到⼀致,允许在经过⼀段时间后再达到⼀致,在达到⼀致过程中,系统也是可用的。

15.10 数据一致性模型

强⼀致性: 当更新操作完成之后,任何多个后续进程的访问都会返回最新的更新过的值,这种是对用户最友好的,就是用户上⼀次写什么,下⼀次就保证能读到什么。根据 CAP理论,这种实现需要牺牲可用性。

弱⼀致性: 系统在数据写⼊成功之后,不承诺⽴即可以读到最新写⼊的值,也不会具体的承诺多久之后可以读到。用户读到某⼀操作对系统数据的更新需要⼀段时间,我们称这段时间为“不⼀致性窗口”。

最终⼀致性: 最终⼀致性是弱⼀致性的特例,强调的是所有的数据副本,在经过⼀段时间的同步之后, 最终都能够达到⼀个⼀致的状态。因此,最终⼀致性的本质是需要系统保证最终数据能够达到⼀致,而不需要实时保证系统数据的强⼀致性。到达最终⼀致性的时间 ,就是不⼀致窗口时间,在没有故障发⽣的前提下,不⼀致窗⼝的时间主要受通信延迟,系统负载和复制副本的个数影响。最终⼀致性模型根据其提供的不同保证可以划分为更多的模型,包括因果⼀致性和会话⼀致性等。

15.11 分布式ID是什么?有哪些解决方案?

在开发中,我们通常会需要⼀个唯⼀ID来标识数据,如果是单体架构,我们可以通过数据库的主键,或直接在内存中维护⼀个⾃增数字来作为ID都是可以的,但对于⼀个分布式系统,就会有可能会出现ID冲突,此时有以下解决方案:

  1. uuid,这种方案复杂度最低,但是会影响存储空间和性能
  2. 利用单机数据库的自增主键,作为分布式ID的⽣成器,复杂度适中,ID长度较之uuid更短,但是受到单机数据库性能的限制,并发量大的时候,此方案也不是最优方案
  3. 利用redis、zookeeper的特性来⽣成id,比如redis的自增命令、zookeeper的顺序节点,这种方案和单机数据库(mysql)相比,性能有所提⾼,可以适当选用。
  4. 雪花算法,⼀切问题如果能直接用算法解决,那就是最合适的,利用雪花算法也可以⽣成分布式ID,底层原理就是通过某台机器在某⼀毫秒内对某⼀个数字自增,这种方案也能保证分布式架构中的系统id唯⼀,但是只能保证趋势递增。业界存在tinyid、leaf等开源中间件实现了雪花算法。

15.12 分布式锁的使用场景是什么?有哪些实现方案?

在单体架构中,多个线程都是属于同⼀个进程的,所以在线程并发执行时,遇到资源竞争时,可以利用ReentrantLock、synchronized等技术来作为锁,来控制共享资源的使用。

而在分布式架构中,多个线程是可能处于不同进程中的,而这些线程并发执行遇到资源竞争时,利用ReentrantLock、synchronized等技术是没办法来控制多个进程中的线程的,所以需要分布式锁,意思就是,需要⼀个分布式锁生成器,分布式系统中的应⽤程序都可以来使用这个生成器所提供的锁,从而达到多个进程中的线程使⽤同⼀把锁。

⽬前主流的分布式锁的实现方案有两种:

  1. zookeeper:利⽤的是zookeeper的临时节点、顺序节点、watch机制来实现的,zookeeper分布式锁的特点是⾼⼀致性,因为zookeeper保证的是CP,所以由它实现的分布式锁更可靠,不会出现混乱。
  2. redis:利⽤redis的setnx、lua脚本、消费订阅等机制来实现的,redis分布式锁的特点是高可用,因为redis保证的是AP,所以由它实现的分布式锁可能不可靠,不稳定(⼀旦redis中的数据出现了不⼀致),可能会出现多个客户端同时加到锁的情况。

15.13 什么是分布式事务?有哪些实现方案?

在分布式系统中,⼀次业务处理可能需要多个应用来实现,比如用户发送⼀次下单请求,就涉及到订单系统创建订单、库存系统减库存,而对于⼀次下单,订单创建与减库存应该是要同时成功或同时失败的,但在分布式系统中,如果不做处理,就很有可能出现订单创建成功,但是减库存失败,那么解决这类问题,就需要用到分布式事务。常用解决方案有:

  1. 本地消息表:创建订单时,将减库存消息加入在本地事务中,⼀起提交到数据库存入本地消息表,然后调⽤库存系统,如果调⽤成功则修改本地消息状态为成功,如果调⽤库存系统失败,则由后台定时任务从本地消息表中取出未成功的消息,重试调用库存系统
  2. 消息队列:目前RocketMQ中⽀持事务消息,它的⼯作原理是:
    a. 生产者订单系统先发送⼀条half消息到Broker,half消息对消费者而言是不可见的
    b. 再创建订单,根据创建订单成功与否,向Broker发送commit或rollback
    c. 并且生产者订单系统还可以提供Broker回调接⼝,当Broker发现⼀段时间half消息没有收到任何操作命令,则会主动调此接⼝来查询订单是否创建成功
    d. ⼀旦half消息commit了,消费者库存系统就会来消费,如果消费成功,则消息销毁,分布式事务成功结束
    e. 如果消费失败,则根据重试策略进⾏重试,最后还失败则进入死信队列,等待进⼀步处理
  3. Seata:阿⾥开源的分布式事务框架,⽀持AT、TCC等多种模式,底层都是基于两阶段提交理论来实现的。

15.14 简述paxos算法

Paxos算法解决的是⼀个分布式系统如何就某个值(决议)达成⼀致。⼀个典型的场景是,在⼀个分布式数据库系统中,如果各个节点的初始状态⼀致,每个节点执行相同的操作序列,那么他们最后能够得到⼀个⼀致的状态。为了保证每个节点执行相同的操作序列,需要在每⼀条指令上执行⼀个“⼀致性算法”以保证每个节点看到的指令⼀致。在Paxos算法中,有三种角色:Proposer (提议者),Acceptor(接受者),Learners(记录员) 。

Proposer提议者: 只要Proposer发的提案Propose被半数以上的Acceptor接受,Proposer就认为该提案例的value被选定了。

Acceptor接受者: 只要Acceptor接受了某个提案,Acceptor就认为该提案例的value被选定了Learner记录员:Acceptor告诉Learner哪个value被选定,Learner就认为哪个value被选定。Paxos算法分为两个阶段,具体如下:

  1. 阶段⼀ (preprae ):(a) Proposer收到client请求或者发现本地有未提交的值,选择⼀个提案编号N,然后向半数以上的Acceptor发送编号为 N的Prepare请求。(b) Acceptor收到⼀个编号为 N的 Prepare请求,如果该轮paxos本节点已经有已提交的value记录,对比记录的编号和N,⼤于N则拒绝回应,否则返回该记录value及编号,没有已提交记录,判断本地是否有编号N1,N1>N、则拒绝响应,否则将N1改为N(如果没有N1,则记录N),并响应prepare。
  2. 阶段⼆ (accept):(a) 如果 Proposer收到半数以上 Acceptor对其发出的编号为 N的 Prepare请求的响应,那么它就会发送⼀个针对[N,V]提案的Accept请求给半数以上的 Acceptor。V就是收到的响应中编号最⼤的value,如果响应中不包含任何value,那么V 就由 Proposer ⾃⼰决定。(b) 如果Acceptor收到⼀个针对编号为 N的提案的 Accept请求,Acceptor对⽐本地的记录编号,如果小于等于N,则接受该值,并提交记录value。否则拒绝请求。Proposer 如果收到的⼤多数Acceptor响应,则选定该value值,并同步给leaner,使未响应的Acceptor达成⼀致。

活锁:accept时被拒绝,加⼤N,重新accept,此时另外⼀个proposer也进⾏相同操作,导致accep⼀致失败,⽆法完成算法。

multi-paxos:区别于paxos只是确定⼀个值,multi-paxos可以确定多个值,收到accept请求后,则⼀定时间内不再accept其他节点的请求,以此保证后续的编号不需要在经过preprae确认,直接进行accept操作。此时该节点成为了leader,直到accept被拒绝,重新发起prepare请求竞争leader资格。

15.15 负载均衡算法有哪些

1)轮询法: 将请求按顺序轮流地分配到后端服务器上,它均衡地对待后端的每⼀台服务器,而不关心服务器实际的连接数和当前的系统负载。

2)随机法: 通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的⼀台服务器进行访问。由概率统计理论可以得知,随着客户端调用服务端的次数增多,其实际效果越来越接近于平均分配调⽤量到后端的每⼀台服务器,也就是轮询的结果。

3)源地址哈希法: 源地址哈希的思想是根据获取客户端的IP地址,通过哈希函数计算得到的⼀个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同⼀IP地址的客户端,当后端服务器列表不变时,它每次都会映射到同⼀台后端服务器进行访问。

4)加权轮询法: 不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更⾼的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这⼀问题,并将请求顺序且按照权重分配到后端。

5)加权随机法: 与加权轮询法⼀样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。

6)最小连接数法: 最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它是根据后端服务器当前的连接情况,动态地选取其中当前积压连接数最少的⼀台服务器来处理当前的请求,尽可能地提⾼后端服务的利⽤效率,将负责合理地分流到每⼀台服务器。

15.16 如何实现接口的幂等性

1)唯⼀id。每次操作,都根据操作和内容⽣成唯⼀的id,在执行之前先判断id是否存在,如果不存在则执行后续操作,并且保存到数据库或者redis等。

2)服务端提供发送token的接口,业务调用接口前先获取token,然后调用业务接口请求时,把token携带过去,务器判断token是否存在redis中,存在表示第⼀次请求,可以继续执行业务,执行业务完成后,最后需要把redis中的token删除。

3)建去重表。将业务中有唯⼀标识的字段保存到去重表,如果表中存在,则表示已经处理过了。

4)版本控制。增加版本号,当版本号符合时,才能更新数据。

5)状态控制。例如订单有状态已⽀付 未⽀付 ⽀付中 ⽀付失败,当处于未⽀付的时候才允许修改为⽀付中等。

15.17 雪花算法原理

SnowFlake 中文意思为雪花,故称为雪花算法。最早是 Twitter 公司在其内部用于分布式环境下生成唯一 ID。在2014年开源 scala 语言版本。
在这里插入图片描述
第⼀位符号位固定为0,41位时间戳,10位workId,12位序列号,位数可以有不同实现。

雪花算法的原理就是生成一个的 64 位比特位的 long 类型的唯一 id。

  • 最高 1 位固定值 0,因为生成的 id 是正整数,如果是 1 就是负数了。
  • 接下来 41 位存储毫秒级时间戳,2^41/(1000606024365)=69,大概可以使用 69 年。
  • 再接下 10 位存储机器码,包括 5 位 datacenterId 和 5 位 workerId。最多可以部署 2^10=1024 台机器。
  • 最后 12 位存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成 2^12=4096 个不重复 id。

可以将雪花算法作为一个单独的服务进行部署,然后需要全局唯一 id 的系统,请求雪花算法服务获取 id 即可。

对于每一个雪花算法服务,需要先指定 10 位的机器码,这个根据自身业务进行设定即可。例如机房号+机器号,机器号+服务号,或者是其他可区别标识的 10 位比特位的整数值都行。

雪花算法优点:

  1. 高并发分布式环境下生成不重复 id,每秒可生成百万个不重复 id。
  2. 基于时间戳,以及同一时间戳下序列号自增,基本保证 id 有序递增。
  3. 不依赖第三方库或者中间件。
  4. 算法简单,在内存中进行,效率高。

雪花算法缺点:

依赖服务器时间,服务器时钟回拨时可能会生成重复 id。算法中可通过记录最后一个生成 id 时的时间戳来解决,每次生成 id 之前比较当前服务器时钟是否被回拨,避免生成重复 id。

15.18 如何解决不使用分区键的查询问题

映射: 将查询条件的字段与分区键进行映射,建⼀张单独的表维护(使⽤覆盖索引)或者在缓存中维护。

基因法: 分区键的后x个bit位由查询字段进行hash后占用,分区键直接取x个bit位获取分区,查询
字段进行hash获取分区,适合非分区键查询字段只有⼀个的情况。

冗余: 查询字段冗余存储

15.19 Spring Cloud Alibaba

Spring Cloud Alibaba 致力于提供微服务开发的一站式解决方案。此项目包含开发分布式应用微服务的必需组件,方便开发者通过 Spring Cloud 编程模型轻松使用这些组件来开发分布式应用服务。

依托 Spring Cloud Alibaba,您只需要添加一些注解和少量配置,就可以将 Spring Cloud 应用接入阿里微服务解决方案,通过阿里中间件来迅速搭建分布式应用系统。

1)主要功能

服务限流降级:默认支持 WebServlet、WebFlux、OpenFeign、RestTemplate、Spring Cloud Gateway、Dubbo 和 RocketMQ 限流降级功能的接入,可以在运行时通过控制台实时修改限流降级规则,还支持查看限流降级 Metrics 监控。

服务注册与发现:适配 Spring Cloud 服务注册与发现标准,默认集成了 Ribbon 的支持。

分布式配置管理:支持分布式系统中的外部化配置,配置更改时自动刷新。

消息驱动能力:基于 Spring Cloud Stream 为微服务应用构建消息驱动能力。

分布式事务:使用 @GlobalTransactional 注解, 高效并且对业务零侵入地解决分布式事务问题。

阿里云对象存储:阿里云提供的海量、安全、低成本、高可靠的云存储服务。支持在任何应用、任何时间、任何地点存储和访问任意类型的数据。

分布式任务调度:提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。同时提供分布式的任务执行模型,如网格任务。网格任务支持海量子任务均匀分配到所有 Worker(schedulerx-client)上执行。

阿里云短信服务:覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。

2)组件

Sentinel:把流量作为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。

Nacos:一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。

RocketMQ:一款开源的分布式消息系统,基于高可用分布式集群技术,提供低延时的、高可靠的消息发布与订阅服务。

Seata:阿里巴巴开源产品,一个易于使用的高性能微服务分布式事务解决方案。

Alibaba Cloud OSS:阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。您可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。

Alibaba Cloud SchedulerX:阿里中间件团队开发的一款分布式任务调度产品,提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。

Alibaba Cloud SMS:覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。

参考资料:
什么是微服务
雪花算法(SnowFlake)

猜你喜欢

转载自blog.csdn.net/qq_40042416/article/details/127461211