八股文
typora-root-url: ./..\images
一、基础
1. 面向对象和面向过程的区别
面向对象:拆分对象
面向过程:拆分方法 洗衣机
面向过程编程(Procedural-Oriented Programming,POP)和面向对象编程(Object-Oriented Programming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同:
- 面向过程编程(POP):面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
- 面向对象编程(OOP):面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
洗衣机洗衣服:
面向过程会将任务拆解成一系列的方法,1、打开洗衣机----->2、放衣服----->3、放洗衣粉----->4、清洗----->5、烘干 面向对象会拆出人和洗衣机两个对象:人:打开洗衣机 放衣服 放洗衣粉 洗衣机:清洗 烘干
OOP 优点:
- 易维护:由于良好的结构和封装性,OOP 程序通常更容易维护。
- 易复用:通过继承和多态,OOP 设计使得代码更具复用性,方便扩展功能。
- 易扩展:模块化设计使得系统扩展变得更加容易和灵活。
POP 的编程方式通常更为简单和直接,适合处理一些较简单的任务。
2. 面向对象三大特性
封装:属性对内 方法对外 空调
继承:父类 属性 方法
多态:对象 属类相同 方法不同 父引用->子对象
封装:是指把一个对象的状态信息(属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。
就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了
继承:继承父类的方法,并做出自己的改变和/或扩展子类共性的方法或者属性直接使用父类的,而不需要自己再定义,只需扩展自己个性化的
多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。
继承,方法重写,父类引用指向子类对象,无法调用子类特有的功能
父类类型 变量名 = new 子类对象 ;
变量名.方法名();
3. String/StringBuffer/StringBuilder 区别
String 是不可变的字符串,每次修改都会创建新的对象,适用于不经常修改的字符串操作;
StringBuffer 是可变的字符串,线程安全,适用于多线程环境; StringBuilder 也是可变的字符串,但不是线程安全的,适用于单线程频繁修改字符串的场景。
4. == 和 equals 的区别
"=="用于比较基本类型的值或引用对象的地址值是否相等
equals 方法用于比较对象的内容是否相等
简而言之,"=="比较的是值,equals 比较的是内容
5. 接口和抽象类的区别
接口主要是用于制定规范
抽象类主要为了复用,比较典型的就是模板方法模式
⼀个类可以实现多个接口,但只能继承⼀个抽象类
接口中的成员变量只能由 public static final 修饰,而抽象类中的成员变量可以是各种类型的
简而言之,接口关注的是行为的规范,而抽象类关注的是共享的属性。
6. 重写和重载的区别
重写(override)指的是子类重新实现了父类中已有的方法,子类的方法具有相同的名称、参数列表和返回类型。
重载(overload)指的是在同一个类中定义了多个方法,它们的方法名称相同但参数列表不同。
7. 浅拷贝和深拷贝的区别
浅拷贝:对象指针 地址相同
深拷贝:对象本身 地址不同
- 浅拷贝:浅拷贝只复制对象的指针而不复制对象本身,两个引用指针指向被复制对象(CloneObject1)的同一块引用地址
- 深拷贝:深拷贝会完全复制整个对象,新老对象(CloneObject1,2)不共享内存
8. 值传递和引用传递的区别
值传递是对基本型变量而言的,传递的是该变量的一个副本,改变副本不影响原变量。
引用传递一般是对于对象型变量而言的,传递的是该对象地址的一个副本, 并不是原对象本身 。
一般认为,java 内的传递都是值传递. java 中实例对象的传递是引用传递。
9. IO 流
IO 即 Input/Output
,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream
/Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream
/Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
10. 反射
Java 的反射机制是指在运行时动态地获取类的信息并操作类或对象的能力。
通过反射,我们可以在编译时无法确定的情况下,通过类名获取类的实例、获取类的字段、方法、构造函数等信息,并且可以在运行时调用这些方法或访问这些字段。
反射方法:
Class.forName(String className)
:根据类名获取对应的 Class 对象。getClass()
:获取对象的运行时类型。getMethod(String name, Class<?>... parameterTypes)
:获取指定方法名和参数类型的方法。getField(String name)
:获取指定名称的字段。newInstance()
:使用默认的构造函数创建实例。newInstance(Object... initargs)
:使用指定参数类型和值的构造函数创建实例。invoke(Object obj, Object... args)
:调用指定对象的方法。
11. sleep 和 wait 的区别
sleep:Thread类方法 不释放对象锁
wait:Object类方法 释放对象锁
sleep 是线程类(Thread)的方法,导致此线程暂停执行指定时间,给执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复。调用 sleep 不会释放对象锁。
wait 是 Object 类的方法,对此对象调用 wait 方法导致本线程释放对象锁,进入等待此对象的等待锁定池,只有针对此对象发出 notify 方法(或 notifyAll)后本线程才进入对象锁定池准备获得对象锁进入运行状态。
12. JVM 内存模型
Java 的内存结构主要包括方法区、堆、虚拟机栈、本地方法栈和程序计数器。方法区用于存储类信息,堆用于存储对象实例,虚拟机栈用于存储方法调用和局部变量,本地方法栈用于存储非 Java 方法信息,程序计数器用于记录当前线程执行的指令地址。
堆:
- 主要存放对象
- 新创建的对象都会放在堆上面
- 线程共享的
- 需要进行垃圾回收
- 如果内存不够、没有空间去分配给新创建的对象 =》OutOfMemoryError
堆的组成:
- 年轻代(eden + s0 + s1)
- 老年代
栈:
- 虚拟机栈
- 线程私有的
- 栈里是一个个的栈帧(frame),每个栈帧对应一个被调用的方法
public static int get(int n){
if(n==1||n==0){
return 1;
}
return get(n-1) + get(n-2);
}
(1)局部变量表(local variables)
(2)操作数栈
(3)动态链接(调用的别的方法)
(4)返回地址和一些额外信息
- 如果一个栈里的栈帧过多,就有可能出现stackoverflowError
public static int get( ){
return get();
}
本地方法栈:
- 专门为了native本地方法使用
- Java语言不能直接对操作系统底层进行访问和操作,所以需要通过JNI(Java Native Interface)去调用别的语言(C、C++、汇编)来实现对底层的访问
- 线程私有的
程序计数器:
- 线程私有的
- 存储当前线程执行的位置、行号
- 当线程切换,通过程序计数器知道从哪里重新运行
**元空间(Metaspace):**JDK8以后对JVM最大的一个调整
- 保存类相关的信息:类的名字、方法名字、字段信息、静态变量
- 放在JVM外面
引起OOM的常见原因:
- Java Heap Space: 堆没有足够空间存放新创建的对象: (1)创建了超大对象,一般都是大数组。 调大堆的空间大小 (2)特殊场景:双11,超出预期的访问量、数据量。 双11 =》限流 (3)内存泄露:例子threadlocal,本来应该把它回收,但是没有回收成功,内存空间无法使用。 找bug
- GC Overhead limit exceeded:** **Java垃圾回收花费了非常多的时间进行垃圾回收,但是回收了一点点内存,并且这种情况已经连续重复了5次 说明可用的堆空间已经非常非常少了,GC也无能为力
- Metaspace:** **说明元空间满了,因为加载的类太多了
- Unable to create new native thread:** **因为创建的Java线程太多了,没有足够的资源进行分配了
13. JVM 类加载器
加载过程:
- 加载:通过类的全限定名获取字节码文件,并将其转换为方法区内的运行时数据结 构。
- 验证:对.class文件 里的字节码进行校验,确保符合 Java 虚拟机规范,能被执行。
- 准备:为类的静态变量分配内存,并设置默认初始值。
- 解析:将符号引用转换为直接引用,即将类、方法、字段等解析为具体的内存地址。
- 初始化:执行类的初始化代码,包括静态变量赋值和静态代码块的执行。
分类:
一、系统本身自带类加载器
(1)启动类加载器(Bootstrap classloader)
主要负责Java目录下核心类(lib目录,java中最核心的类库,支撑Java系统的运行)
(2)扩展类加载器(Extensions classloader)
lib/ext目录下的内容
(3)应用类加载器(Application classloader)
简单理解为加载程序员自己写好的代码
二、自定义类加载器
满足一些特殊需求,需要继承 java.lang.ClassLoader 来实现
14. JVM 双亲委派机制
双亲委派机制是指类加载器在加载类时,首先将加载请求委托给父类加载器,只有当父类加载器无法加载时,才自己尝试加载。从而确保类的加载安全和防止类的重复加载。
java.lang.Object,它是放在rt.jar之中,它永远都会被最上面的加载器进行加载,因此无论在任何环境下,都能找到这个类
不同classloader加载的类肯定是不相同的,相当于在JVM当中创建了一个个互相隔离的空间,这种技术在框架中经常被使用
15. JVM 垃圾回收算法
(1)**标记 - 清理:**导致内存碎片
(2)**标记 - 整理:**释放完空间后,会对整个内存空间进行整理,去除内存碎片
缺点:整理的过程非常慢,非常耗资源
(3)**复制 - 清理:**先标记哪些需要被删除,哪些需要被复制
缺点:空间只能用一半
二、集合
1. Java 常用的集合、分类、接口
Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection
接口,主要用于存放单一元素;另一个是 Map
接口,主要用于存放键值对。对于Collection
接口,下面又有三个主要的子接口:List
、Set
、 Queue
。
Java 集合框架如下图所示:
实现类:
- List:ArrayList 是基于数组的动态列表,LinkedList 是基于链表的列表,Vector 是线程安全的列表。
- Set:HashSet 是基于哈希表的集合,LinkedHashSet 是基于哈希表和链表的集合,TreeSet 是基于红黑树的有序集合。
- Map:HashMap 是基于哈希表的映射,LinkedHashMap 是基于哈希表和链表的映射,TreeMap 是基于红黑树的有序映射。
- Queue:LinkedList 可以作为队列使用,PriorityQueue 是基于优先级的队列。
2. ArrayList 和 Array(数组)的区别
ArrayList
内部基于动态数组实现,比 Array
(静态数组) 使用起来更加灵活:
ArrayList
会根据实际存储的元素动态地扩容或缩容,而Array
被创建之后就不能改变它的长度了。ArrayList
允许你使用泛型来确保类型安全,Array
则不可以。ArrayList
中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array
可以直接存储基本类型数据,也可以存储对象。ArrayList
支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如add()
、remove()
等。Array
只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。ArrayList
创建时不需要指定大小,而Array
创建时必须指定大小。
3. ArrayList 与 LinkedList 区别
ArrayList 基于动态数组实现,查询操作快。
LinkedList基于双向链表实现,增删操作快。
array 用的比 linked 更多
我们在项目中一般是不会使用到 LinkedList
的,需要用到 LinkedList
的场景几乎都可以使用 ArrayList
来代替,并且,性能通常会更好!就连 LinkedList
的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList
。
另外,不要下意识地认为 LinkedList
作为链表就最适合元素增删的场景。我在上面也说了,LinkedList
仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。
4. HashSet、LinkedHashSet 和 TreeSet 异同
HashSet
、LinkedHashSet
和TreeSet
都是Set
接口的实现类,都能保证元素唯一,并且都是线程不安全的。HashSet
、LinkedHashSet
和TreeSet
的主要区别在于底层数据结构不同。
HashSet
的底层数据结构是哈希表(基于 HashMap
实现)。LinkedHashSet
的底层数 据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑 树,元素是有序的,排序的方式有自然排序和定制排序。
- 底层数据结构不同又导致这三者的应用场景不同。
HashSet
用于不需要保证元素插入和取出顺序的场景,LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet
用于支持对元素自定义排序规则的场景。
5. HashMap 底层原理
关键属性:
第一个属性 loadFactor,它是负载因子,默认值是0.75,表示扩容前 。
第二个属性 threshold 它是记录HashMap所能容纳的键值对的临界值,它的计算规则是负载因子 × 数组长度,扩容后的容量为当前容量的 2 倍。例如,初始容量为16,当元素数量达到 12 时会触发扩容,扩容后的容量为 32。
第三个属性size,它用来记录HashMap实际存在的键值对的数量。
第四个属性modCount,它用来记录HashMap内部结构修改的次数。
第五个是常量属性DEFAULT_INITIAL_CAPACITY ,它规定的默认容量是16。
存储结构:
HashMap采用的是哈希表(散列表)的存储结构。HashMap的数组部分称为Hash桶,数组元素保存在一个叫做table的属性中。当链表长度大于等于8时,链表数据将会以红黑树的形式进行存储,当长度降到6时,又会转成链表形式存储。
每个Node节点,保存了用来定位数组索引位置的hash值、Key、Value和链表指向的下一个Node节点。而Node类是HashMap的内部类,它实现了Map.Entry接口,它的本质其实可以简单的理解成就是一个键值对。
6. ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
ConcurrentHashMap:
• 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
• 实现线程安全的方式(重要):
◦ 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
◦ 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
Hashtable(同一把锁) :
使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
下面,我们再来看看两者底层数据结构的对比图。
Hashtable :
JDK1.7 的 ConcurrentHashMap:
Java7 ConcurrentHashMap 存储结构:
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构。
JDK1.8 的 ConcurrentHashMap:
Java8 ConcurrentHashMap 存储结构:
JDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。
TreeNode是存储红黑树节点,被TreeBin包装。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
...
}
7. ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
JDK1.8 之前:
Java7 ConcurrentHashMap 存储结构:
首先将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。 Segment数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。
JDK1.8 之后:
Java8 ConcurrentHashMap 存储结构:
Java 8 几乎完全重写了 ConcurrentHashMap
,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。
ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。数据结构跟 HashMap
1.8 的结构类似,Node 数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
8. JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- 线程安全实现方式:JDK 1.7 采用
Segment
分段锁来保证安全,Segment
是继承自ReentrantLock
。JDK1.8 放弃了Segment
分段锁的设计,采用Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点。 - Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值(8)时,将链表转换为红黑树)。
- 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
三、并发
1. 线程和进程的区别
进程:
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
如下图所示,在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe
文件的运行)。
线程:
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
Java 程序天生就是多线程程序,我们可以通过 JMX 来看看一个普通的 Java 程序有哪些线程,代码如下。
public class MultiThread {
public static void main(String[] args) {
// 获取 Java 线程管理 MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历线程信息,仅打印线程 ID 和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
}
}
}
上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):
[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序入口
从上面的输出内容可以看出:一个 Java 程序的运行是 main 线程和多个其他线程同时运行。
下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。
Java 运行时数据区域(JDK1.8 之后)从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
总结:线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
2. 创建线程的方式
- 继承 Thread 类:
// 定义一个继承自 Thread 类的线程类
class MyThread extends Thread {
public void run() {
// 线程执行的代码
System.out.println("Thread running");
}
}
// 创建线程实例,并启动线程
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
- 实现 Runnable 接口:
// 定义一个实现 Runnable 接口的线程类
class MyRunnable implements Runnable {
public void run() {
// 线程执行的代码
System.out.println("Thread running");
}
}
// 创建线程实例,并启动线程
public class Main {
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
}
}
- 使用匿名内部类:
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
public void run() {
// 线程执行的代码
System.out.println("Thread running");
}
});
thread.start();
}
}
- 使用 Lambda 表达式:
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(() -> System.out.println("Thread running"));
thread.start();
}
}
3. 线程的生命周期和状态
• NEW: 初始状态,线程被创建出来但没有被调用 start() 。
• RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
• BLOCKED:阻塞状态,需要等待锁释放。
• WAITING:等待状态,线程进入等待状态,直到其他线程显式地唤醒它。线程可以调用 Object 类的 wait()方法、join()方法或 Lock 类的条件等待方法进入此状态。
• TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
• TERMINATED:终止状态,表示该线程已经运行完毕。
4. 线程上下文切换
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
• 主动让出 CPU,比如调用了 sleep(), wait() 等。
• 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
• 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
• 被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,等待线程下次占用 CPU 的时候恢复现场,并加载下一个将要占用 CPU 的线程上下文。这就是所谓的上下文切换。
5. sleep() 和 wait() 方法对比
共同点:两者都可以暂停线程的执行。
区别:
• sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
• wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
• wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。| sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
• sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。
6. 并发与并行的区别
- 并发:两个及两个以上的作业在同一 时间段 内执行。
- 并行:两个及两个以上的作业在同一 时刻 执行。
最关键的点是:是否是 同时 执行。
7. 同步和异步的区别
- 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回。
8. 线程调度方式
- 抢占式调度(Preemptive Scheduling):操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。
- 协同式调度(Cooperative Scheduling):线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。
Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。
9. 死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
10. JMM
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
JMM描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。它描述了主内存与工作内存的交互操作,如:lock、unlock、read、load、use、assign、store和write,并设定了这些操作的执行规则,以保证并发程序的正确性。
11. 可见性
一个线程修改了共享变量的值,其他线程能够看到修改的值。
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。
如何保证可见性
通过 volatile 关键字保证可见性。
通过 内存屏障 保证可见性。
通过 synchronized 关键字保证可见性。
通过 Lock 保证可见性。
通过 final 关键字保证可见性
12. 有序性
程序执行的顺序按照代码的先后顺序执行。JVM 存在指令重排,所以存在有序性问题。
如何保证有序性
通过 volatile 关键字保证有序性。
通过 内存屏障保证有序性。
通过 synchronized 关键字保证有序性。
通过 Lock 保证有序性。
13. 原子性
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。
在 Java中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。
如何保证原子性
通过 synchronized 关键字保证原子性。
通过 Lock 保证原子性。
通过 CAS 保证原子性。
14. CAS
CAS(Compare and Swap)比较与交换是一种并发编程中常用的技术,用于解决多线程环境下的并发访问问题。CAS操作是一种原子操作,它可以提供线程安全,避免了使用传统锁机制所带来的性能开销。
实现线程安全的并发控制:CAS操作可以保证在多线程环境中对共享数据进行原子性的读写操作,从而避免了多线程并发访问时可能引发的数据不一致问题。它提供了一种基于硬件层面的并发控制机制。
提高性能和可伸缩性:相比于传统的锁机制,CAS操作不需要阻塞线程或切换上下文,因为它是一种乐观锁机制。这使得CAS在高并发场景下具有更好的性能和可伸缩性,尤其适用于细粒度的并发控制。
解决ABA问题:CAS操作使用期望值来判断共享数据是否被修改过,但它无法检测到共享数据在操作过程中经历了多次修改,然后又回到了期望值的情况,即ABA问题。为了解决ABA问题,可以使用版本号、引用更新等技术。
支持无锁算法:CAS操作可以用于实现一些无锁算法,如非阻塞数据结构和并发容器。无锁算法可以避免线程间的竞争和阻塞,提高程序的吞吐量和效率。
并发数据结构中的应用:CAS操作在并发数据结构中得到广泛应用,如高性能队列、计数器、散列表等。它可以保证多个线程同时对共享数据进行读写时的一致性和正确性。
15. volatile 关键字
- volatile保证了可见性、有序性
- volatile不支持原子性,适用于读多写少的场景
实现可见性:
实现有序性:
使用场景
16. synchronized 关键字
synchronized
是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
保证原子性:
底层原理:
public class StockSample{
// 商品的库存一共十万件
private static int stockCount =100000;
public void deduct() {
//模拟一直在进行卖出,每卖出一件将对商品的库存减1
for (int i = 0; i < 10000; i++){
stockCount--;
}
}
public static void main (String[]args){
StockSample sample = new StockSample();
// 线程1
Thread t1 = new Thread(() -> {
sample.deduct();
});
// 线程2
Thread t2 = new Thread(() -> {
sample.deduct();
});
t1.start();
t2.start();
try {
// 等待两个线程执行结束
t1.join();
t2.join();
System.out.println(stockCount);
} catch (
InterruptedException e) {
e.printStackTrace();
}
}
}
使用方法:
- 修饰一个实例的方法
public class StockSample{
// 商品的库存一共十万件
private static int stockCount =100000;
public synchronized void deduct() {
//模拟一直在进行卖出,每卖出一件将对商品的库存减1
for (int i = 0; i < 10000; i++){
stockCount--;
}
}
public static void main (String[]args){
StockSample sample = new StockSample();
// 线程1
Thread t1 = new Thread(() -> {
sample.deduct();
});
// 线程2
Thread t2 = new Thread(() -> {
sample.deduct();
});
t1.start();
t2.start();
try {
// 等待两个线程执行结束
t1.join();
t2.join();
System.out.println(stockCount);
} catch (
InterruptedException e) {
e.printStackTrace();
}
}
}
- 修饰一个类方法
public class StockSample{
// 商品的库存一共十万件
private static int stockCount =100000;
public synchronized static void deduct() {
//模拟一直在进行卖出,每卖出一件将对商品的库存减1
for (int i = 0; i < 10000; i++){
stockCount--;
}
}
public static void main (String[]args){
// StockSample sample = new StockSample();
// 线程1
Thread t1 = new Thread(() -> {
StockSample.deduct();
});
// 线程2
Thread t2 = new Thread(() -> {
StockSample.deduct();
});
t1.start();
t2.start();
try {
// 等待两个线程执行结束
t1.join();
t2.join();
System.out.println(stockCount);
} catch (
InterruptedException e) {
e.printStackTrace();
}
}
}
- 修饰一个代码块
public class StockSample {
// 商品的库存一共十万件
private static int stockCount = 100000;
// private static volatile int value;
public void deduct() {
// get value
synchronized (this) {
// 模拟一直在进行卖出,每卖出一件将对商品的库存减1
for (int i = 0; i < 10000; i++) {
stockCount--;
}
}
}
public static void main(String[] args) {
// StockSample sample = new StockSample();
// 线程1
Thread t1 = new Thread(() -> {
sample.deduct();
});
// 线程2
Thread t2 = new Thread(() -> {
sample.deduct();
});
t1.start();
t2.start();
try {
// 等待两个线程执行结束
t1.join();
t2.join();
System.out.println(stockCount);
} catch (
InterruptedException e) {
e.printStackTrace();
}
}
}
优化:
在 Java 6 之后, synchronized
引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized
锁的效率提升了很多。因此, synchronized
还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized
。
关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking
启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。
17. synchronized 和 volatile 区别
volatile
关键字是线程同步的轻量级实现,synchronized
关键字是线程同步的重量级实现。volatile
关键字只能用于变量而,synchronized
关键字可以修饰方法以及代码块 。volatile
关键字能保证数据的可见性,synchronized
关键字能保证数据的可见性和原子性。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性。
18. AQS
竞争锁:
可重入锁:
释放锁:
19. ReentrantLock
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
public class ReentrantLock implements Lock, java.io.Serializable {}
ReentrantLock
里面有一个内部类 Sync
,Sync
继承 AQS(AbstractQueuedSynchronizer
),添加锁和释放锁的大部分操作实际上都是在 Sync
中实现的。Sync
有公平锁 FairSync
和非公平锁 NonfairSync
两个子类。
ReentrantLock
默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
20. 公平锁和非公平锁的区别
- 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
21. synchronized 和 lock 的区别
synchronized 是 Java 关键字,是内置的同步机制,能修饰方法或代码块,锁的获取是隐式的,底层实现原理是基于 JVM内置监视器锁;
Lock 是一个接口,提供更灵活的同步机制,可以手动控制锁的获取和释放,底层实现可以是 ReentrantLock 等,性能在高竞争环境下通常较好。
22. synchronized 和 ReentrantLock 对比
两者都是可重入锁:
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
JDK 提供的所有现成的 Lock
实现类,包括 synchronized
关键字锁都是可重入的。
在下面的代码中,method1()
和 method2()
都被 synchronized
关键字修饰,method1()
调用了method2()
。
public class SynchronizedDemo {
public synchronized void method1() {
System.out.println("方法1");
method2();
}
public synchronized void method2() {
System.out.println("方法2");
}
}
由于 synchronized
锁是可重入的,同一个线程在调用method1()
时可以直接获得当前对象的锁,执行 method2()
的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized
是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()
时获取锁失败,会出现死锁问题。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API:
synchronized
是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReentrantLock
是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock 比 synchronized 增加了一些高级功能:
- 等待可中断 :
ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 - 可实现公平锁 :
ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock
默认情况是非公平的,可以通过ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来指定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
23. 可中断锁和不可中断锁的区别
- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
ReentrantLock
就属于是可中断锁。 - 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized
就属于是不可中断锁。
24. Atomic 原子类
原子类简单来说就是具有原子性操作特征的类。
java.util.concurrent.atomic
包中的 Atomic
原子类提供了一种线程安全的方式来操作单个变量。
Atomic
类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 synchronized
块或 ReentrantLock
)。
25. ThreadLocal 原理
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
JDK 中自带的ThreadLocal
类正是为了解决这样的问题。 **ThreadLocal**
类主要解决的就是让每个线程绑定自己的值,可以将**ThreadLocal**
类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据**。**
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal
变量名的由来。他们可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
再举个简单的例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。
26. ThreadLocal 内存泄露
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 是强引用。
ThreadLocal
在没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap
实现中已经考虑了这种情况,在调用 set()
、get()
、remove()
方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal
方法后最好手动调用remove()
方法。
27. 线程池的核心参数
public ThreadPoolExecutor(int corePoolSize, // 线程池的核心线程数量
int maximumPoolSize, // 线程池的最大线程数
long keepAliveTime, // 当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory, // 线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handle) // 拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
28. 线程池的创建
方式一:**ThreadPoolExecutor**
构造函数(推荐)
方式二:spring 的 **ThreadPoolTaskExecutor**
创建线程池需要合理配置线程池的大小、选择适当的任务队列和拒绝策略,正确管理线程池的生命周期,并考虑线程安全性。
29. 线程池工作原理
线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
当调用 execute() 方法添加一个任务时,线程池会做如下判断:
如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
当一个线程完成任务时,它会从队列中取下一个任务来执行。
当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
30. 线程池的拒绝策略
线程池的拒绝策略有四种:AbortPolicy(默认方式,中止并抛出RejectedExecutionException 异常)、CallerRunsPolicy(使用调用线程来执行被拒绝的任务)、DiscardPolicy(默默地丢弃拒绝的任务)以及 DiscardOldestPolicy(丢弃最早被添加到队列的任务,然后尝试重新提交新任务)。
如果希望快速失败并将异常传递给调用者,则选择 AbortPolicy。如果希望尽可能保证任务的执行
而不堆积在队列中,则选择 CallerRunsPolicy。如果对任务的丢失情况不敏感,则选择DiscardPolicy。而如果希望尽可能保留最新的任务而不是旧的任务,则选择DiscardOldestPolicy。
31. CountDownLatch
32. CyclicBarrier
33. CountDownLatch 和 CyclicBarrier 区别
34. Semaphore
Semaphore 模型:
35. ArrayBlockingQueue
36. LinkedBloackingQueue
37. 悲观锁和乐观锁的区别
悲观锁:认为自己在使用数据的时候一定有别的线程来修改数据,在获取数据的时候会先加锁,确保数据不会被别的线程修改。
实现:关键字 synchronized、接口 Lock 的实现类
适用场景:写操作较多,先加锁可以保证写操作时数据正确
乐观锁:认为自己使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
实现 :
- CAS 算法, CAS 即 Compare And Swap,是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则返回 false,不进行任何操作;例如:ActomicInteger 类的原子自增是通过 CAS 自选实现。
- 版本号控制:数据表中加上版本号字段 version,表示数据被修改的次数。当数据被修改时,这个字段值会加 1,提交必 须满足“ 提交版本必须大于记录当前版本才能执行更新“的乐观锁策略。
适用场景:读操作较多,不加锁的特点能够使其读操作的性能大幅提升
四、设计模式
1. 分类
创建型: 在创建对象的同时隐藏创建逻辑,不使⽤ new 直接实例化对象,程序在判断需要创建哪些对象时更灵活。包括⼯⼚/抽象⼯⼚/单例/建造者/原型模式。
结构型: 通过类和接⼝间的继承和引⽤实现创建复杂结构的对象。包括适配器/桥接模式/过滤器/组合/装饰器/外观/享元/代理模式。
⾏为型: 通过类之间不同通信⽅式实现不同⾏为。包括责任链/命名/解释器/迭代器/中介者/备忘录/观察者/状态/策略/模板/访问者模式。
2. 工厂模式
和简单⼯⼚模式中⼯⼚负责⽣产所有产品相⽐,⼯⼚⽅法模式将⽣成具体产品的任务分发给具体的产品⼯⼚。
UML 类图如下:
也就是定义⼀个抽象⼯⼚,其定义了产品的⽣产接⼝,但不负责具体的产品,将⽣产任务交给不同的派⽣类⼯⼚。这样不⽤通过指定类型来创建对象了。
3. 单例模式
单例模式属于创建型模式,⼀个单例类在任何情况下都只存在⼀个实例,构造⽅法必须是私有的、由⾃⼰创建⼀个静态变量存储实例,对外提供⼀个静态公有⽅法获取实例。 优点是内存中只有⼀个实例,减少了开销,尤其是频繁创建和销毁实例的情况下并且可以避免对资源的多重占⽤。缺点是没有抽象层,难以扩展,与单⼀职责原则冲突。
4. 代理模式
代理模式的本质是⼀个中间件,主要⽬的是解耦合服务提供者和使⽤者。使⽤者通过代理间接的访问服务提供者,便于后者的封装和控制。是⼀种结构性模式。
下⾯是 GoF 介绍典型的代理模式 UML 类图
Subject: 定义 RealSubject 对外的接⼝,且这些接⼝必须被 Proxy 实现,这样外部调⽤ proxy 的接⼝最终都被转化为对 realsubject 的调⽤。
RealSubject: 真正的⽬标对象。
Proxy: ⽬标对象的代理,负责控制和管理⽬标对象,并间接地传递外部对⽬标对象的访问。
Remote Proxy: 对本地的请求以及参数进⾏序列化,向远程对象发送请求,并对响应结果进⾏反序列化,将最终结果反馈给调⽤者;
Virtual Proxy: 当⽬标对象的创建开销⽐较⼤的时候,可以使⽤延迟或者异步的⽅式创建⽬标对象;
Protection Proxy: 细化对⽬标对象访问权限的控制;
5. 策略模式
策略模式(Strategy Pattern)属于对象的⾏为模式。其⽤意是针对⼀组算法,将每⼀个算法封装到具有共同接⼝的独⽴的类中,从⽽使得它们可以相互替换。策略模式使得算法可以在不影响到客户端的情况下发⽣变化。其主要⽬的是通过定义相似的算法,替换 if else 语句写法,并且可以随时相互替换。
6. Spring 框架中用到了哪些设计模式?
- 工厂****设计模式 : Spring 使用工厂模式通过
BeanFactory
、ApplicationContext
创建 bean 对象。 - 代理****设计模式 : Spring AOP 功能的实现。
- 单例****设计模式 : Spring 中的 Bean 默认都是单例的。
- 模板****方法模式 : Spring 中
jdbcTemplate
、hibernateTemplate
等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。 - 包装器****设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
- 观察者****模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
- 适配器****模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配
Controller
。
7. MyBatis 中有哪些设计模式
工厂模式,例如 SqlSessionFactory、ObjectFactory、MapperProxyFactory;
单例模式,例如 ErrorContext 和 LogFactory;
代理模式,Mybatis 实现的核心,比如 MapperProxy、ConnectionLogger,
用的 jdk 的动态代理;还有 executor.loader 包使用了 cglib 或者 javassist 达
到延迟加载的效果;
五、MySQL
1. 事务
数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行 。
# 开启一个事务
START TRANSACTION;
# 多条 SQL 语句
SQL1,SQL2...
## 提交事务
COMMIT;
2. ACID 特性
- 原子性(Atomicity):事务中的操作要么全部成功,要么全部失败。
事务是一个不可分割的单元,要么全部执行,要么全部回滚。如果事务中的任何操作失败,所有操作都将被回滚到事务开始之前的状态,以保证数据的一致性。
- 一致性(Consistency):事务的执行应使数据库从一个一致性状态转移到另一个一致性状态。
在事务开始和结束时,数据库的完整性约束应得到满足,确保数据的正确性和一致性。
- 隔离性(Isolation):每个事务在执行过程中都应该与其他事务隔离。
并发事务的执行应当互不干扰,每个事务应该感知不到其他事务的存在或并发执行。隔离级别定义了不同事务之间的可见性和互相影响的程度。
- 持久性(Durability):一旦事务提交成功,其对数据库的修改应该永久保存。
即使系统发生故障或重启,也应该能够保持数据的持久性。
3. 隔离级别
- READ-UNCOMMITTED(读未提交) :允许读取尚未提交的数据变更,
最低的隔离级别,可能会导致脏读、幻读或不可重复读。
- READ-COMMITTED(读已提交) :允许读取并发事务已经提交的数据,
可以阻止脏读,但是幻读或不可重复读仍有可能发生。
- REPEATABLE-READ(可重复读) :对同一字段的多次读取结果都是一致的,
除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
- SERIALIZABLE(串行化) :所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,
最高的隔离级别,完全服从 ACID 的隔离级别。也就是说,该级别可以防止脏读、不可重复读 以及幻读。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ-UNCOMMITTED**(读未提交)** | √ | √ | √ |
READ-COMMITTED**(读已提交)** | × | √ | √ |
REPEATABLE-READ**(可重复读)** | × | × | √ |
SERIALIZABLE**(串行化)** | × | × | × |
4. 锁
- 属性分类:共享锁、排他锁。
- 粒度分类:表锁(INNODB、MYISAM)、行锁(INNODB)、页级锁(BDB引擎)、记录锁、间隙锁、临键锁。
- 状态分类:意向共享锁、意向排它锁。
- 共享锁(Share Lock):
共享锁又称读锁,简称s锁;当一个事务为数据加上读锁之后,其他事务只能对该数据加读锁,而不能对数据加写锁,直到所有的读锁释放之后其他事务才能对其进行加持写锁。共享锁的特性主要是为了支持并发的读取数据,读取数据的时候不支持修改,避免出现重复读的问题。
- 排他锁(Exclusive Lock):
排他锁又称写锁,简称X锁:当一个事务为数据加上写锁时,其他请求将不能再为数据加任何锁,直到该锁释放之后其他事务才能对数据进行加锁。排他锁的目的是在数据修改时候,不允许其他人同时修改,也不允许其他人读取。避免了出现脏数据和脏读的问题
- 表锁:
表锁是指上锁的时候锁住的是整个表,当下一个事务访问该表的时候,必须等前一个事务释放了锁才能进行对表进行访问;
- 行锁:
行锁是指上锁的时候锁住的是表的某一行或多行记录,其他事务访问同一张表时,只有被锁住的记录不能访问,其他的记录可正常访问;
特点:粒度小,加锁麻烦,不容易冲突,相比表锁支持的并发高;
- 页锁:
页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。
所以取了折中的页级,一次锁定相邻的一组记录。
特点:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般
- 记录锁(Record Lock):
记录锁也属于行锁中的一种,只不过记录锁的范围只是表中的某一条记录,记录锁是说事务在加锁后锁住的只是表的某一条记录。
精准条件命中,并且命中的条件字段是唯一索引
加了记录锁之后数据可以避免数据在査询的时候被修改的重复读问题,也避免了在修改的事务未提交前被其他事务读取的脏读问题。
- 间隙锁(Gap Lock):
属于行锁中的一种,间隙锁是在事务加锁后其锁住的是表记录的某一个区间,当表的相邻ID之间出现空隙则会形成一个区间,遵循左开右闭原则。
范围查询并且查询未命中记录,查询条件必须命中索引、间隙锁只会出现REPEATABLE_READ(可重复读)的事务级别中。
触发条件:防止幻读问题,事务并发的时候,如果没有间隙锁,就会发生如下图的问题,在同一个事务里,A事务的两次查询出的结果会不一样。
比如表里面的数据ID 为 1,4,5,7,10,那么会形成以下几个间隙区间,-n-1区间,1-4区间,7-10区间,10-n区间(-n代表负无穷大,n代表正无穷大)
- 临建锁(Next-Key Lock):
也属于行锁的一种,并且它是INNODB的行锁默认算法,总结来说它就是记录锁和间隙锁的组合,临键锁会把查询出来的记录锁住,同时也会把该范围查询内的所有间隙空间也会锁住,再之它会把相邻的下一个区间也会锁住触发条件:范围查询并命中,查询命中了索引。结合记录锁和间隙锁的特性,临键锁避免了在范围査询时出现脏读、重复读、幻读问题。加了临键锁之后,在范围区间内数据不允许被修改和插入。
- 意向锁:
如果当事务A加锁成功之后就设置一个状态告诉后面的人,已经有人对表里的行加了一个排他锁了,你们不能对整个表加共享锁或排它锁了,那么后面需要对整个表加锁的人只需要获取这个状态就知道自己是不是可以对表加锁,避免了对整个索引树的每个节点扫描是否加锁,而这个状态就是意向锁。
- 意向共享锁:
当一个事务试图对整个表进行加共享锁之前,首先需要获得这个表的意向共享锁,
- 意向排他锁:
当一个事务试图对整个表进行加排它锁之前,首先需要获得这个表的意向排它锁。
5. MVCC
读取数据时通过一种类似快照的方式将数据保存下来,这样读锁和写锁就不冲突了,
不同的事务session会看到自己特定版本的数据,版本链MVCC只在读已提交和可重复读两个隔离级别下工作。其他两个隔离级别够和MVCC不兼容, 因为读未提交总是读取最新的数据行, 而不是符合当前事务版本的数据行。而串行化则会对所有读取的行都加锁。
聚簇索引记录中有两个必要的隐藏列:
trx_id:用来存储每次对某条聚簇索引记录进行修改的时候的事务id。
roll_pointer:每次对哪条聚簇索引记录有修改的时候,都会把老版本写入undo日志中。这个roll_pointer就是存了一个指针,它指向这条聚簇索引记录的上一个版本的位置,通过它来获得上一个版本的记录信息。(注意插入操作的undo日志没有这个属性,因为它没有老版本)
读已提交和可重复读的区别就在于它们生成ReadView的策略不同**:**
开始事务时创建readview,readView维护当前活动的事务id,即未提交的事务id,排序生成一个数组访问数据,获取数据中的事务id(获取的是事务id最大的记录)。
对比readview:如果在readview的左边(比readview都小),可以访问(在左边意味着该事务已经提交)如果在readview的右边(比readview都大)或者就在readview中,不可以访问,获取roll_pointer,取上一版本重新对比(在右边意味着,该事务在readview生成之后出现,在readview中意味着该事务还未提交)。
读已提交隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,
可重复读隔离级别则在第一次读的时候生成一个ReadView,之后的读都复用之前的ReadView。
这就是Mysql的MVCC,通过版本链,实现多版本,可并发读-写,写-读。通过ReadView生成策略的不同实现不同的隔离级别。
6. 索引
底层原理:
MySQL 索引底层原理使用了B+树数据结构,它是一种平衡树,能快速定位和检索数据;B+树的叶子节点存储实际数据,中间节点存储索引,通过减少磁盘 IO 来提高查询效率;索引按照值的大小顺序排列,使得范围查询效率更高。
索引类型:
按照底层存储方式角度划分:
- 聚簇索引(聚集索引):索引结构和数据一起存放的索引,InnoDB 中的主键索引就属于聚簇索引。
- 非聚簇索引(非聚集索引):索引结构和数据分开存放的索引,二级索引(辅助索引)就属于非聚簇索引。MyISAM 中,不管主键还是非主键,使用的都是非聚簇索引。
按照应用维度划分:
- 主键索引:加速查询 + 列值唯一(不可以有 NULL)+ 表中只有一个。
- 普通索引:仅加速查询。
- 唯一索引:加速查询 + 列值唯一(可以有 NULL)。
- 覆盖索引:一个索引包含(覆盖)所有需要查询的字段的值。
- 联合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。
- 全文索引:对文本的内容进行分词,进行搜索。目前只有
CHAR
、VARCHAR
,TEXT
列上可以创建全文索引。一般不会使用,效率较低,通常使用搜索引擎如 ElasticSearch 代替。
7. 三大日志
MySQL 日志 主要包括二进制日志、事务日志、错误日志、查询日志、慢查询日志几大类。其中,比较重要的还要属二进制日志: binlog(归档日志),事务日志:redo log(重做日志)和 undo log(回滚日志)。
binlog:
binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于MySQL Server
层。
不管用什么存储引擎,只要发生了表数据更新,都会产生 binlog 日志。
MySQL 数据库的数据备份、主备、主主、主从都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。
binlog 会记录所有涉及更新数据的逻辑操作,并且是顺序写。
redo log:
redo log(重做日志)是 InnoDB 存储引擎独有的物理日志,让 MySQL 拥有了崩溃恢复能力。
比如 MySQL 实例挂了或宕机了,重启时,InnoDB 存储引擎会使用 redo log 恢复数据,保证数据的持久性与完整性。
- MySQL 中数据是以页为单位,你查询一条记录,会从硬盘中把一页的数据加载出来,加载出来的数据叫数据页,会放入到
Buffer Pool
中。后续的查询都是先从Buffer Pool
中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。 - 更新表数据的时候,也是如此,发现
Buffer Pool
里存在要更新的数据,就直接在Buffer Pool
里更新。 - 然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(
redo log buffer
)里 - 接着清空 redolog buffer, 刷盘到 redo log 文件里。
理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略来进行的。
小贴士:每条 redo 记录由“表空间号+数据页号+偏移量+修改数据长度+具体修改的数据”组成
undo log:
每一个事务对数据的修改都会被记录到 undo log,当执行事务过程中出现错误或者需要执行回滚操作的话,MySQL可以利用 undo log 将数据恢复到事务开始之前的状态。
undo log属于逻辑日志,记录的是 SOL语句,比如说事务执行一条 DELETE 语句,那 undo log 就会记录条相对应的 INSERT 语句。同时,undo log的信息也会被记录到 redo log 中,因为 undo log也要实现持久性保护。并且,undo log本身是会被删除清理的,例如 INSERT操作,在事务提交之后就可以清除掉了;UPDATE/DELETE 操作在事务提交不会立即删除,会加入 history list,由后台线程 purge 进行清理。
undo log 是采用 segment(段)的方式来记录的,每个 undo 操作在记录的时候占用一个 undo log segment(undo日志段),umdo log segment包含在roll back segment(回滚段)中。事务开始时,需要为其分配一个roll back segment。每个roll back segment有1024个undo log segment,这有助于管理多个并发事务的回滚需求。
通常情况下,rollback segment header(回滚段的第一个页)负责管理 rollback segment。rolback segment header 是 rollback segment 的一部分,通常在回滚段的第一个页。history list 是rollback segment header 的一部分,它的主要作用是记录所有已经提交但还没有被清理(purge)的事务的undo log。这个列表使得 purge 线程能够找到并清理那些不再需要的 undo log记录。
另外,MVCC 的实现依赖于:隐藏字段、ReadView、undolog。在内部实现中,ImnnoDB通过数据行的DB TRX ID 和 Read view 来判断数据的可见性,如不可见,则通过数据行的 DB ROLL PTR 找到 undo log中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建Read view 之前已经提交的修改和该事务本身做的修改
8. 存储引擎
InnoDB(MySQL 的默认存储引擎),支持事务处理、行级锁和外键;
MyISAM 不支持事务、只有表级锁,并且不支持外键。
9. 优化方案
- 服务器优化(增加 CPU、内存、网络、更换高性能磁盘)
- 表设计优化(字段长度控制、添加必要的索引)
- SQL 优化(避免 SQL 命中不到索引的情况)
- 架构部署优化(一主多从集群部署)
- 分库分表(垂直分库、水平分表)
- 读写分离
六、Redis
1. 什么是 Redis
Redis (REmote DIctionary Server)是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。
2. Redis 优点
Redis 之所以快是因为它采用了内存存储和非阻塞的 I/O 模型,避免了磁盘 IO 的延迟;
Redis 使用了IO 多路复用技术,通过一个线程同时处理多个客户端请求,减少了线程切换的开销,提高了并发处理能力。
Redis速度快主要有以下原因:
- 基于内存存储:数据存于内存,内存读写速度远快于磁盘,避免了磁盘I/O的延迟,极大减少了数据访问时间。
- I/O多路复用技术:采用单线程(主要是网络I/O和键值对读写)结合I/O多路复用,一个线程可以同时处理多个客户端请求。就像交通警察管理交通一样,多路复用器可以同时监听多个客户端连接事件,只有当事件发生时才进行操作,避免了为每个客户端创建线程所带来的大量线程切换开销,有效提高并发处理能力。
- 高效的数据结构:Redis拥有多种简单高效的数据结构,如哈希表(查找元素平均时间复杂度为O(1))、跳表等,这些数据结构的时间复杂度较低,能快速地操作数据。
3. Redis 应用场景
- 缓存
- 排行榜
- 分布式计数器
- 分布式锁
- 消息队列
- 分布式token
- 限流
4. Redis 数据类型
Redis 5 种基本数据类型详解 | JavaGuide | Redis 3 种特殊数据类型详解 | JavaGuide
5 种基础数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
3 种特殊数据类型:HyperLogLog(基数统计)、Bitmap (位图)、Geospatial (地理位置)。
其他的比如: Bloom filter(布隆过滤器)、Bitfield(位域)。
数据类型 | 使用场景 |
---|---|
String | 需要存储常规数据的场景举例:缓存 Session、Token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)。相关命令:SET 、GET 。需要计数的场景举例:用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。相关命令:SET 、GET 、 INCR 、DECR 。分布式锁利用 SETNX key value 命令可以实现一个最简易的分布式锁(存在一些缺陷,通常不建议这样实现分布式锁)。 |
List | 信息流展示****举例:最新文章、最新动态。****相关命令:**LPUSH** 、**LRANGE** **。**消息队列**List** **可以用来做消息队列,只是功能过于简单且存在很多缺陷,不建议这样做。**相对来说,Redis 5.0 新增加的一个数据结构 **Stream** 更适合做消息队列一些,只是功能依然非常简陋。和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。 |
5. Redis 持久化机制
RDB(Redis Database Backup):
RDB 是 Redis 的一种快照方式的持久化方法。它定期将 Redis 内存中的数据保存到磁盘上的一个二进制文件。当 Redis 重新启动时,可以加载这个 RDB 文件来恢复之前保存的数据状态。
Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。
AOF(Append Only File):
AOF 是一种追加日志方式的持久化方法。它记录每次写操作(如 SET、INCR 等)到一个日志文件中。当 Redis 重新启动时,会重新执行这些写操作来恢复数据集的原始状态。
与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化(Redis 6.0 之后已经默认是开启了),可以通过 appendonly
参数开启:
appendonly yes
6. Redis 的缓存穿透、缓存击穿、缓存雪崩(缓存三兄弟)
缓存穿透:缓存、数据库中都没有所查询的数据。
举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。
解决:可以通过使用布隆过滤器来快速判断请求的 Key 是否合法,避免查询不存在的数据。
缓存击穿:请求的 key 对应的是热点数据,该数据存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
举个例子:秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。
解决:
- 永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。
- 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
- 加锁(看情况):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。
缓存雪崩:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。
举个例子:数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。
解决:
- 设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。
- 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
- 持久缓存策略(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略。
7. Redis 的哨兵集群(Redis Sentinel)
Redis 的哨兵集群主要用于实现高可用性,监控主、从节点的状态变化,并在主节点失效时自动将从节点升级为主节点。
哨兵集群由多个哨兵节点组成,工作原理是哨兵节点通过相互通信,监测主节点的健康状态。
当主节点失效时,选举新的主节点,并通知其他从节点进行切换,确保系统的可用性。
8. Redis 的分片集群(Redis Cluster)
多个主从节点 一致性哈希算法
Redis 的分片集群主要用于实现数据的横向扩展,将数据分散存储在多个节点上,
提高系统的并发能力和存储能力。分片集群由多个主从节点组成,根据 Key 经过哈希算
法映射到不同的节点上,每个节点负责存储和处理一部分数据,工作原理是通过一
致性哈希算法将数据按照一定规则分配到不同的节点上,实现数据的均衡存储和查
询。
9. Redis(或 ElasticSearch)和 MySQL 如何保持数据一致性
- 双写 | 异步队列 | Canal 方案
- 双写:每次写入操作同时将数据写入 Elasticsearch 和 MySQL,确保数据一致性,但可能增加写延迟和复杂性。
- 异步队列:将写入操作请求放入队列中,后台任务异步地将数据写入Elasticsearch 和 MySQL,提高写入性能,但可能导致一定的数据不一致性。
- Canal 方案:使用 Canal 工具订阅 MySQL 的 binlog 日志,实时将数据同步到 Elasticsearch,实现数据的实时增量同步,但需要额外的工具和配置。
10. Redis 分布式锁
- 分布式锁的用途:分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。
- 分布式锁的应该具备的条件:互斥、高可用、可重入、高性能、非阻塞。
- 分布式锁的常见实现方式:关系型数据库比如 MySQL、分布式协调服务 ZooKeeper、分布式键值存储系统比如 Redis 、Etcd 。
11. Redisson 是什么,怎么用
- redis 客户端 api 依赖 配置 注解/对象
最简回答:Redisson 是一个 Java 的 Redis 客户端,提供丰富的 API 和功能,用于
封装分布式操作和并发控制。在 Spring Boot 中使用 Redisson,首先添加 Redisson
的依赖,然后在配置文件中配置 Redisson 连接信息,接着通过@Autowired 注解
或手动创建 RedissonClient 对象。最后,利用 RedissonClient 对象可以使用各种
功能,如分布式锁、分布式集合等,与 Redis 进行交互。
12. Redisson 看门狗机制的原理
- 定时续期锁的过期时间
最简回答:Redisson 的看门狗机制通过定时续期锁的过期时间,保证在业务执行期
间锁不会被自动释放。它解决了分布式环境下锁过期导致的资源竞争问题,确保业
务能够完成。续期是看门狗机制的核心,它通过定时更新锁的过期时间来实现锁的
持久性,以防止锁过期并被其他实例获得。
13. Redis bigkey(大key)
- 大量 key 同时过期 | 随机过期 lazy free
什么是 bigkey?
当 Redis 中存在大量 key 在同一时间点集中过期时,可能会导致以下问题:
- 请求延迟增加: Redis 在处理过期 key 时需要消耗 CPU 资源,如果过期 key 数量庞大,会导致 Redis 实例的 CPU 占用率升高,进而影响其他请求的处理速度,造成延迟增加。
- 内存占用过高: 过期的 key 虽然已经失效,但在 Redis 真正删除它们之前,仍然会占用内存空间。如果过期 key 没有及时清理,可能会导致内存占用过高,甚至引发内存溢出。
如何解决 bigkey?
- 尽量避免 key 集中过期: 在设置键的过期时间时尽量随机一点。
- 开启 lazy free 机制:修改
redis.conf
配置文件,将lazyfree-lazy-expire
参数设置为yes
,即可开启 lazy free 机制。开启 lazy free 机制后,Redis 会在后台异步删除过期的 key,不会阻塞主线程的运行,从而降低对 Redis 性能的影响。
14. Redis hotkey(热Key)
- key 的访问次数多 | 主从读写分离 集群 二级缓存
什么是 hotkey?
如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。
hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。
如何解决 hotkey?
hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 读写分离:主节点处理写请求,从节点处理读请求。
- Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
- 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。
七、微服务
1. Spring
Spring 是一款开源的轻量级 JavaEE 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。
我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发,比如说 Spring 支持 IoC(Inversion of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程)、可以很方便地对数据库进行访问、可以很方便地集成第三方组件(电子邮件,任务,调度,缓存等等)、对单元测试支持比较好、支持 RESTful Java 应用程序的开发。
2. Spring IOC
- 控制反转 | 容器控制对象的创建和依赖关系
IOC (Inversion of Control,控制反转) 是一种设计思想,通过它,容器控制对象的创建和依赖关系,从而实现对象之间的解耦和灵活性的提升。 在实际项目中一个 Service 类可能依赖了很多其他的类,假如我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IoC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。
3. Spring AOP
- 面向切面编程 | 动态代理: JDK 实现接口 CGLIB 没有实现接口 | AOP 术语
AOP(Aspect-Oriented Programming,面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。
AOP的底层机制是动态代理,实现方式有两种:JDK动态代理和CGLIB动态代理
如果想要代理的目标对象实现了接口,使用JDK动态代理(Spring在运行时创建了一个实现了目标
对象所有接口的新对象)。
如果想要代理的目标对象没有实现接口,使用CGLIB动态代理(Spring在运行时使用CGLIB库生成
目标类的子类对象)。
AOP 术语 | 含义 |
---|---|
目标(Target) | 被通知的对象 |
代理(Proxy) | 向目标对象应用通知之后创建的代理对象 |
连接点(JoinPoint) | 目标对象的所属类中,定义的所有方法均为连接点 |
切入点(Pointcut) | 被切面拦截 / 增强的连接点(切入点一定是连接点,连接点不一定是切入点) |
通知(Advice) | 增强的逻辑 / 代码,即拦截到目标对象的连接点之后要做的事情 |
切面(Aspect) | 切入点(Pointcut)+通知(Advice) |
Weaving(织入) | 将通知应用到目标对象,进而生成代理对象的过程动作 |
@Component
@Aspect
public class UserServiceAspect {
@Pointcut("execution(* com.example.aop.service.UserServiceImpl.*(..))")
public void pointcut() {
}
@Before("pointcut()")
public void before(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
System.out.println("开始执行" + methodName + "方法");
}
@AfterReturning(pointcut = "pointcut()", returning = "result")
public void afterReturning(JoinPoint joinPoint, Object result) {
System.out.println("方法返回结果:" + result);
}
@After("pointcut()")
public void after(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
System.out.println(methodName + "方法执行完毕");
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
System.out.println("开始执行" + methodName + "方法");
Object result = joinPoint.proceed();
System.out.println("方法返回结果:" + result);
System.out.println(methodName + "方法执行完毕");
return result;
}
}
**切点表达式(Pointcut Expression):**用于描述哪些方法执行时应该被增强。
execution(* com.example.aop.service.UserServiceImpl.*(..))
*
,表示匹配任意返回类型的方法com.example.aop.service.UserServiceImpl
,表示匹配UserServiceImpl类里的方法*
,表示匹配类里的所有方法(..)
,表示匹配任意输入参数的方法
**通知(Advice):**增强的逻辑 / 代码
- 前置通知(@Before):在目标方法执行之前执行。 Before):在目标方法执行之前执行。
- 后置通知(@After):在目标方法执行之后执行。
- 返回后通知(@AfterReturning):在目标方法正常返回后执行,可以获取方法返回值等信息。
- 环绕通知(@Around):在目标方法执行前后执行自定义逻辑。
- 异常通知(@AfterThrowing):在目标方法抛出异常后执行,可以获取异常信息。
4. 声明成 Bean 注解的方式
注解分层讨论
@Component
:通用的注解,可标注任意类为Spring
组件。如果一个 Bean 不知道属于哪个层,可以使用@Component
注解标注。@Repository
: 对应持久层即 Dao 层,主要用于数据库相关操作。@Service
: 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。@Controller
: 对应 Spring MVC 控制层,主要用于接受用户请求并调用Service
层返回数据给前端页面。@Configuration
和@Bean
组合使用 :@Configuration
标记一个类为配置类,然后在该类中使用@Bean
方法来定义 Bean。
5. @Component 和 @Bean 区别
@Component
作用于类 |@Bean
作用于方法,自定义性更强,三方库中的类@Component
注解作用于类,而@Bean
注解作用于方法。@Component
通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用@ComponentScan
注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。|@Bean
注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean
告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。@Bean
注解比@Component
注解的自定义性更强,而且很多地方我们只能通过@Bean
注解来注册 bean。比如当我们引用第三方库中的类需要装配到Spring
容器时,则只能通过@Bean
来实现。
6. @Autowired 和 @Resource 的区别
**@Autowired**
Spring 注解 by 类型 作用范围大 |**@Resource**
JDK 注解 by 名称**@Autowired**
是 Spring 提供的注解,**@Resource**
是 JDK 提供的注解。**Autowired**
默认的注入方式为**byType**
(根据类型进行匹配),**@Resource**
默认注入方式为**byName**
(根据名称进行匹配)。当一个接口存在多个实现类的情况下,
**@Autowired**
和**@Resource**
都需要通过名称才能正确匹配到对应的 Bean。**Autowired**
可以通过**@Qualifier**
注解来显式指定名称,**@Resource**
可以通过**name**
属性****来显式指定名称。**@Autowired**
支持在构造函数、参数、方法、字段上使用。**@Resource**
主要用于字段和方法上的注入,不支持在构造函数或参数上使用。
7. 注入 Bean 的方式
- 构造函数 setter 注解
依赖注入 (Dependency Injection, DI) 的常见方式:
- 构造函数注入:通过类的构造函数来注入依赖项。
- Setter 注入:通过类的 Setter 方法来注入依赖项。
- Field(字段) 注入:直接在类的字段上使用注解(如
@Autowired
或@Resource
)来注入依赖项。
8. Spring MVC 原理
• DispatcherServlet:前端控制器,负责接收请求、分发,并给予客户端响应。 • HandlerMapping:处理器映射器,根据 URL 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。 • HandlerAdapter:处理器适配器,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler。 • Handler:请求处理器,处理实际请求的处理器。 • ViewResolver:视图解析器,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端。
- 客户端(浏览器)发送请求, DispatcherServlet 前端控制器 拦截请求。
- DispatcherServlet 根据请求信息调用 HandlerMapping 处理器映射器。
- HandlerMapping 根据 URL 去匹配查找能处理的 Handler 处理器(Controller 控制器) ,并会将请求涉及到的拦截器和 Handler一起封装。
- DispatcherServlet 调用 HandlerAdapter 处理器适配器。
- HandlerAdapter 请求执行 Handler。
- Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象 给 HandlerAdapter,ModelAndView 顾名思义,包含了数据模型以及相应的视图信息。Model 是返回的数据对象,View是个逻辑上的 View。
- HandlerAdapter 会返回 ModelAndView 对象给 DispatcherServlet。
- HandlerAdapter 请求 ViewResolver 视图解析器,根据逻辑 View 查找实际的 View。
- ViewResolver 返回 View 对象给 DispatcherServlet。
- DispaterServlet 把返回的 Model 传给 View(渲染视图)。
- View 被返回给 请求者(浏览器)。
9. SpringBoot 优点
内嵌服务器 简化配置 自动配置 监控管理
独立运行 Spring Boot ,内嵌了 servlet 容器,如 Tomcat、Jetty 等,现在不再需要打成 war 包部署到容器中,Spring Boot 只要打成一个可执行的 jar 包就能独立运行,所有的依赖包都在一个 jar 包内。
简化配置 spring-boot-starter-web 启动器自动依赖其他组件,简少了maven 的配置。
自动配置 Spring Boot 能根据当前类路径下的类、jar 包来自动配置 bean,如添加一个 spring-boot-starter-web 启动器就能拥有 web 的功能,无需其他配置。
无代码生成和 XML 配置, Spring Boot 配置过程中无代码生成,也无需 XML配置文件就能完成所有配置工作,这一切都是借助于条件注解完成的,这也是Spring4.x 的核心功能之一。
避免大量的 Maven 导入和各种版本冲突
应用监控 Spring Boot 提供一系列端点可以监控服务及应用,做健康检测。
10. SpringBoot 自动配置原理
Spring Boot 通过@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载META-INF/spring.factories中的自动配置类实现自动装配,自动配置类其实就是通过@Conditional按需加载的配置类,想要其生效必须引入spring-boot-starter-xxx包实现起步依赖
1. 启动类与注解:
Spring Boot应用通常以一个带有
@SpringBootApplication
注解的主启动类开始。这个注解实际上是一个组合注解,它包含了以下几个重要注解:@Configuration
:表明该类是一个配置类,用于定义各种Bean以及配置信息,相当于传统Spring项目中的XML配置文件的作用。@EnableAutoConfiguration
:这是实现自动配置的关键注解。它会触发Spring Boot去查找并应用所有符合条件的自动配置类。@ComponentScan
:用于扫描指定包及其子包下的所有带有@Component
注解(以及其衍生注解,如@Service
、@Repository
、@Controller
等)的类,将它们作为Spring容器中的组件进行管理。
2. 自动配置类查找:
- 当应用启动时,在
@EnableAutoConfiguration
注解的作用下,Spring Boot会去查找所有的自动配置类。这些自动配置类通常位于spring-boot-autoconfigure
项目的META-INF/spring-boot-autoconfigure.properties
文件中定义的包路径下,例如org.springframework.boot.autoconfigure
及其子包。 - 自动配置类的命名规范一般是
XXXAutoConfigure
,比如DataSourceAutoConfigure
用于自动配置数据源。
3. 条件注解判断:
每个自动配置类上通常会带有一些条件注解(Conditional Annotation),这些条件注解用于判断当前项目的实际情况是否满足该自动配置类生效的条件。常见的条件注解有:
@ConditionalOnClass
:当项目中存在指定的类时,该自动配置类才生效。例如,对于DataSourceAutoConfigure
配置类,可能会有@ConditionalOnClass({DataSource.class})
注解,这意味着只有当项目中引入了DataSource
类(通常是引入了数据库驱动依赖后才会有这个类)时,该自动配置类才会对数据源进行自动配置。@ConditionalOnMissingClass
:与@ConditionalOnClass
相反,当项目中不存在指定的类时,该自动配置类才生效。@ConditionalOnProperty
:根据项目中配置属性的值来判断自动配置类是否生效。比如,如果配置属性spring.datasource.url
有特定的值,那么相关的数据源自动配置类可能会根据这个值来进一步调整配置。@ConditionalOnBean
:当Spring容器中已经存在指定的Bean时,该自动配置类才生效。@ConditionalOnMissingBean
:当Spring容器中不存在指定的Bean时,该自动配置类才生效。
4. 自动配置逻辑执行:
当一个自动配置类通过条件注解判断满足生效条件后,它就会执行内部的配置逻辑。这通常包括以下几个方面:
- **定义Bean:**自动配置类会在Spring容器中定义各种所需的Bean。例如,在
DataSourceAutoConfigure
中,会定义数据源相关的Bean,如DataSource
本身、连接池相关的Bean等。 - 设置属性:会根据项目中现有的配置信息(如配置文件中的属性值、环境变量等)来设置这些Bean的属性。比如,根据
spring.datasource.url
、spring.datasource.username
、spring.datasource.password
等属性值来设置数据源Bean的连接参数。 - 依赖注入:将定义好的Bean进行相互之间的依赖注入,使得它们能够协同工作。例如,将数据源Bean注入到需要使用数据源的其他组件(如数据访问层的
Repository
类)中。
- **定义Bean:**自动配置类会在Spring容器中定义各种所需的Bean。例如,在
5. 自定义配置覆盖:
- 虽然Spring Boot有强大的自动配置功能,但开发人员仍然可以根据项目的特殊需求对自动配置进行定制。
- 如果开发人员在项目中添加了自定义的配置类或者在配置文件中设置了与自动配置相关的属性值,那么这些自定义的配置会覆盖自动配置类中的相应部分。例如,如果开发人员在配置文件中明确设置了
spring.datasource.url
的值,那么自动配置类就会按照这个值来配置数据源,而不是按照默认的推断方式。
11. SpringBoot 启动事项
- 加载配置:Spring Boot 启动时首先加载
META-INF/spring.factories
文件,这个 文件指定了各个自动配置类的路径。 - 扫描自动配置类:根据
spring.factories
中配置的自动配置类路径,Spring Boot 会扫描这些自动配置类,并将它们实例化。 - 条件判断:通过条件注解,如
@ConditionalOnClass
、@ConditionalOnProperty
等,决定是否应用该自动配置类。 - 自动配置:根据自动配置类中的代码逻辑,自动配置相应的组件,例如:数据库连接池、Web 服务器、日志等。
- 注册组件:将自动配置的组件注册到 Spring 容器中,使其可以被应用程序使用。
- 启动应用:执行应用程序的启动逻辑,包括:初始化、加载数据、启动定时任务等。
12. Spring Security
控制请求权限的方法:
permitAll()
:无条件允许任何形式访问,不管你登录还是没有登录。anonymous()
:允许匿名访问,也就是没有登录才可以访问。denyAll()
:无条件拒绝任何形式的访问。authenticated()
:只允许已认证的用户访问。fullyAuthenticated()
:只允许已经登录或者通过 remember-me 登录的用户访问。hasRole(String)
: 只允许指定的角色访问。hasAnyRole(String)
: 指定一个或者多个角色,满足其一的用户即可访问。hasAuthority(String)
:只允许具有指定权限的用户访问。hasAnyAuthority(String)
:指定一个或者多个权限,满足其一的用户即可访问。hasIpAddress(String)
: 只允许指定 ip 的用户访问。
13. MyBatis #{} 和 ${} 区别
# 用于参数的替换,并会进行预编译处理,以防止 SQL 注入。
$ 将变量直接替换进 SQL 语句,有 SQL 注入风险。
14. MyBatis 动态 SQL
MyBatis 动态 sql 可以让我们在 xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。其执行原理为,使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。
MyBatis 提供了 9 种动态 sql 标签:
<if></if>
<where></where>(trim,set)
<choose></choose>(when, otherwise)
<foreach></foreach>
<bind/>
15. ResultType 和 ResultMap 的区别
¢ 如果数据库结果集中的列名和要封装实体的属性名完全一致的话用resultType 属性
¢ 如果数据库结果集中的列名和要封装实体的属性名有不一致的情况用resultMap 属性,通过 resultMap 手动建立对象关系映射,resultMap 要配置一下表和类的一一对应关系,所以说就算你的字段名和你的实体类的属性名不一样也没关系,都会给你映射出来
16. Mybatis 和 Hibernate 区别
Hibernate 属于全自动 ORM 映射工具,使用 Hibernate 查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取,所以它是全自动的。
MyBatis 在查询关联对象或关联集合对象时,需要手动编写 sql 来完成,所以,称之为半自动 ORM 映射工具。
17. MyBatisPlus 常用注解
- @TableName:用于指定实体类对应的数据库表名。该注解可以在实体类上添加,表示该类映射到指定的数据库表。例如:@TableName("user")。
- @TableField:用于指定实体类的字段对应的数据库表的字段名。该注解可以在实体类的字段上添加,表示该字段映射到指定的数据库表字段。例如:@TableField("name")。
- @TableId:用于指定实体类的字段作为数据库表的主键。该注解可以在实体类的字段上添加,表示该字段作为数据库表的主键。例如:@TableId(value = "id", type= IdType.AUTO)。
- @Version:用于指定实体类的字段作为乐观锁的版本字段。乐观锁是一种并发控制机制,通过版本号的变化来判断数据是否被修改。该注解可以在实体类的字段上添加,表示该字段作为乐观锁的版本字段。例如:@Version。
18. SpringCloud 组件
- 服务注册与发现(Eureka/Nacos):Eureka 是一个用于实现服务注册与发现的组件,提 供了服务注册中心来管理服务实例的注册和发现,使得服务之间可以方便地进行通信和 调用。
- 客户端负载均衡(Ribbon/Nginx):Ribbon 是一个用于在客户端实现负载均衡的组件, 它可以根据一定的策略选择合适的服务实例进行负载均衡,提高系统的可用性和性能。
- 服务调用(Feign):Feign 是一个声明式的服务调用组件,它基于注解和动态代理, 可以让开发者使用简单的接口定义服务调用,而无需关注底层的具体实现。
- 熔断器(Hystrix/Sentinel):Hystrix 是一个用于实现服务容错和熔断的组件,它可以保护 系统免受服务故障的影响,通过实现服务降级、熔断和隔离等机制,提高系统的稳定性 和可靠性。
- 网关(Gateway):Zuul 或 Gateway 是用于构建统一 API 网关的组件,它可以 实现请求的路由、过滤和转发等功能,提供了对外的统一的接入点,并可以对请求进行 安全验证、限流和监控等。
19. Eureka 工作原理
Eureka 是一个用于实现服务注册与发现的组件。
通过服务注册,在启动时将服务实例信息注册到 Eureka 注册中心;
通过心跳检测与续约,实现服务实例的健康状态监测;
通过服务发现,让消费者能够从注册中心获取可用的服务列表;
通过负载均衡和容错处理,实现服务调用的负载均衡和容错能力。
20. Nacos 工作原理
Nacos 实现了服务发现、服务配置、服务元数据及流量管理。
它提供了服务提供者将服务注册到注册中心,并让让从注册中心发现服务实例的能力。
注册中心通过两种类型的实例进行管理,包括临时实例和非临时实例。临时实例会定期发送
心跳信号以保持连接,而非临时实例则长时间提供服务。注册中心通过定期的心跳检查来监测实例的可用性,并在长时间没有心跳信号时将失效实例剔除。
21. Nacos 和 Eureka 的区别
Nacos 和 Eureka 都是服务注册与发现组件,但 Nacos 比 Eureka 功能更丰富,除了服务注册与发现外,还提供了服务配置和管理功能。
Nacos 支持多种服务发现协议,如 DNS、HTTP 和 gRPC,而 Eureka 仅支持自身的协议。
Nacos 还具备更好的可扩展性和容错性,能够应对更复杂的场景。
22. Ribbon 工作原理
Ribbon是基于 Netflix Ribbon 实现的一套客户端的负载均衡工具,Ribbon客户端组件提供一系列的完善的配置,如超时,重试等。
目前主流的负载方案分为以下两种:
• 集中式负载均衡:在消费者和服务提供方中间使用独立的代理方式进行负载,有硬件的(比如 F5),也有软件的(比如 Nginx)。
• 客户端根据自己的请求情况做负载均衡:Ribbon 就属于客户端自己做负载均衡。
常见负载均衡算法:
• 随机:通过随机选择服务进行执行,一般这种方式使用较少;
• 轮询:负载均衡默认实现方式,请求来之后排队处理;
• 加权轮询:通过对服务器性能的分型,给高配置,低负载的服务器分配更高的权重,均衡各个服务器的压力;
• 地址哈希(IP hash):通过客户端请求的地址的HASH值取模映射进行服务器调度。
• 最小链接数:即使请求均衡了,压力不一定会均衡,最小连接数法就是根据服务器的连接情况,比如请求积压数等参数,将请求分配到当前压力最小的服务器上。
23. Feign 工作原理
Feign 是一个声明式的 Web 服务客户端,用于简化和优化服务之间的 HTTP 通信,可以做到使用 HTTP 请求远程服务时就像调用本地方法一样的体验。
- 接口定义:开发者通过定义 Java 接口来描述服务间的通信协议,包括 URL、请求方法、请求参数等。
- 代理生成:在应用启动时,Feign 会根据接口定义生成代理对象。
- 请求发送:当调用代理对象的方法时,Feign 会根据方法的注解和参数生成 HTTP请求,并发送给目标服务。
- 负载均衡:Feign 集成了负载均衡组件(如 Ribbon),可以自动将请求分发到不同的服务实例。
- 响应处理:目标服务处理完请求后,将响应返回给 Feign 客户端。
- 结果解析:Feign 会根据接口定义和注解,将 HTTP 响应解析为 Java 对象,并返回给调用者。
总结起来,Feign 的工作原理就是根据接口定义生成代理对象,通过代理对象发送 HTTP请求给目标服务,并将响应解析为 Java 对象返回给调用者。这样,开发者可以使用简洁的接口定义来实现服务之间的通信。
24. Hystrix 工作原理
Hystrix 是一个用于实现服务容错和熔断的库,在分布式系统中起到了保护系统免受服务故障的影响的作用。
- 熔断条件:
- Hystrix 根据一定的规则监控执行的服务请求,当错误率超过一定阈值时,触发熔断。
- 默认情况下,如果在 10 秒内的请求错误率超过 50%,则触发熔断。
- 熔断后的处理:
- 当熔断触发后,Hystrix 会停止请求该服务的发送,而是快速返回一个预设的fallback(降级)响应或空响应。
- 在熔断打开期间,Hystrix 还会定时尝试发起一些请求观察该服务是否已经恢复,如果请求成功率较高,熔断器将逐渐关闭。
- 熔断的好处:
- 减少联动效应:当一个服务发生故障或响应时间过长时,传统的做法是等待它超 时并再次尝试。这会浪费系统资源,而 Hystrix 的熔断机制可以快速失败并返回降级的 响应,减少了等待和资源浪费。
- 提高系统稳定性:通过熔断机制,当某个服务出现故障或不可用时,可以快速切 换到备用的降级逻辑,保护整个系统免受服务故障的影响。
- 预防雪崩效应:当一个服务不可用或响应缓慢时,传统的做法是继续发送请求,导致失败的请求堆积并最终耗尽系统资源。而 Hystrix 的熔断机制能够避免这种情况的发生,减少了对失败服务的依赖,提高了系统的稳定性。
总而言之,Hystrix 的熔断机制通过监控服务请求的错误率,当错误率超过阈值时,快速切换到备用逻辑,避免了对失败服务的不必要请求,提高了系统的稳定性和可靠性。熔断可以在故障发生时快速失败并返回降级响应,避免资源浪费和雪崩效应,保护系统免受故障服务的影响。
25. Sentinel 工作原理
26. Gateway 工作原理
Gateway(网关)是一种在微服务架构中起到请求转发、路由和过滤作用的组件。它作为系统入口,接收所有的客户端请求,并将它们转发到相应的服务上进行处理。
- 请求路由:可以根据请求的 URL 路径将请求动态路由到不同的服务实例上,实现动态路由的功能。
- 负载均衡:可以根据负载均衡策略将请求分发到多个服务实例中,平衡负载,提高系统的可用性和性能。
- 安全控制:可以集成认证和授权机制,对请求进行鉴权,保护系统的安全性。
- 请求过滤:可以对请求进行过滤和校验,例如对请求进行验证、请求参数转换、请求日志记录等。
- 降级和熔断:可以对服务进行降级和熔断处理,当服务出现故障或超时时,返回预设的响应或转发到备用服务。
- 监控和统计:可以对请求进行监控和统计,记录请求的响应时间、流量等指标,方便分析系统性能和问题排查。
27. Skywalking 工作原理
探针:基于不同的来源可能是不一样的, 但作用都是收集数据, 将数据格式化为SkyWalking 适用的格式.
平台后端:支持数据聚合, 数据分析以及驱动数据流从探针到用户界面的流程。分析包括Skywalking 原生追踪和性能指标以及第三方来源,包括 Istio 及 Envoy telemetry , Zipkin 追踪格式化等。
存储:通过开放的插件化的接口存放 SkyWalking 数据. 你可以选择一个既有的存储系统, 如ElasticSearch, H2 或 MySQL 集群(Sharding-Sphere 管理),也可以选择自己实现一个存储系统. 当然, 我们非常欢迎你贡献新的存储系统实现。
UI:一个基于接口高度定制化的Web系统,用户可以可视化查看和管理 SkyWalking 数据
八、分布式
1. RabbitMQ 工作模式
RabbitMQ 是一个广泛使用的开源消息代理,它支持多种工作模式。
1**. 简单模式(Simple Mode)**
- **特点:**使用单个生产者将消息发送到单个消费者。
- **应用场景:**适用于简单的任务分发,消息的顺序不重要。
2. 工作队列模式(Work Queue Mode)
- **特点:**多个生产者将消息发送到一个或多个消费者。
- **应用场景:**适用于任务分发,提高系统的并发处理能力。
3. 发布/订阅模式(Publish/Subscribe Mode)
- **特点:**消息发送者将消息发布到交换机,多个消费者通过绑定到交换机的队列接收消息。
- **应用场景:**适用于消息广播,例如:日志记录、实时聊天、新闻发布等。
4. 路由模式(Routing Mode)
- **特点:**消息发送者通过指定不同的路由键将消息发送到交换机,交换机根据路由键将消息发送到对应的队列。
- **应用场景:**适用于消息的有选择性地路由,例如根据消息内容进行过滤。
5. 主题模式(Topic Mode)
- **特点:**消息发送者通过指定主题(可以使用通配符)将消息发送到交换机,交换机根据主题将消息发送到对应的队列。
- **应用场景:**适用于消息的多样性路由,例如根据不同的主题进行过滤和选择。
2. RabbitMQ 消息可靠性
生产端(Producer)的可靠性保证措施:
发布者确认(Publisher Confirms):生产者可以通过启用发布者确认机制,在消息成功发送给 RabbitMQ后接收确认回执,确保消息已被正确接收。
服务端(Broker)的可靠性保证措施:
- 持久化(Durability):队列和交换机可设置为持久化,使其在 RabbitMQ 重新启动后不会丢失。
- 持久化消息:被标记为持久化的消息,会写入磁盘,确保消息在服务器故障时不会丢失。
- 事务机制(Transactions):可通过启用事务机制将一组操作包装在事务中,要么全部成功执行,要么全部回滚,保证消息的原子性和一致性处理。
消费端(Consumer)的可靠性保证措施:
手动消息确认(Manual Message Acknowledgement):消费者在处理完消息后,发送确认回执给 RabbitMQ,告知消息已被成功处理,RabbitMQ 可以删除该消息。
3. RabbitMQ 死信队列
RabbitMQ 的死信队列(Dead Letter Queue)是用来处理无法被正常消费或处理的消息的特殊队列。
- **消息被拒绝(Rejected):**当消费者拒绝消费消息或者消息超过消费者的最大重试次数时,消息会被发送到死信队列。
- **消息过期(Expired):**如果消息在一定时间内没有被消费者处理,即超过了消息的过期时间,该消息也会被发送到死信队列。
- 队列达到最大长度(Queue Length Limit):当队列达到了定义的最大长度限制,新的消息无法进入队列,会将旧的消息发送到死信队列。
通过配置死信交换机(Dead Letter Exchange)和死信队列(Dead Letter Queue)的绑定关系,可以将满足上述条件的消息发送到指定的死信队列中,以便进行后续的处理或分析。
4. RabbitMQ 消息重复消费
- 消费者应用程序在处理消息时发生了错误,导致消息确认(ack)没有发送给 RabbitMQ,从而导致 RabbitMQ 将消息重新分发给其他消费者进行消费。
- 网络问题或消费者应用程序重启时,RabbitMQ 无法收到消息确认,也会导致消息重新分发。
解决措施:
- 消费端幂等性:消费者应用程序在处理消息时需要保证幂等性,即无论接收到相同的消息多少次,处理结果都保持一致。这可以通过使用唯一标识符、幂等存储等方式实现。
- 消息去重:消费者应用程序在处理消息之前,可以在自己的系统中维护一个消息记录表,记录已经处理过的消息的唯一标识符。在接收到新消息时,先检查该消息是否已经处理过,如果已经处理过,则忽略重复消息。
- 消息确认机制:消费者应及时地发送消息确认(ack)给 RabbitMQ,表示已经成功处理了消息。这样 RabbitMQ 就不会将消息重新分发给其他消费者。
保证消费端的幂等性是解决消息重复消费问题的关键。通过在消费者应用程序中实现幂等性逻辑和消息去重措施,可以保证即使同一条消息被重复消费,也不会对系统产生重复、不一致的影响。
最简回答:RabbitMQ 消息重复消费问题是由消费者应用程序错误或网络问题导致的,造成消息未得到确认,从而重新分发。为了解决问题,可以实现消费端的幂等性来保证消息处理结果一致,同时使用消息去重和及时发送消息确认,避免重复消费。确保消费幂等性是解决消息重复消费问题的重要措施之一。
5. RocketMQ 特点
RocketMQ作为一款纯java、分布式、队列模型的消息中间件,支持事务消息、顺序消息、批量消息、定时消息、消息回溯等。
特点:
• 支持发布/订阅(Pub/Sub)和点对点(P2P)消息模型
• 在一个队列中可靠的先进先出(FIFO)和严格的顺序传递 (RocketMQ可以保证严格的消息顺序,而ActiveMQ无法保证)
• 支持拉(pull)和推(push)两种消息模式。pull其实就是消费者主动从MQ中去拉消息,而push则像rabbit MQ一样,是MQ给消费者推送消息
• 支持多种消息协议,如 JMS、MQTT 等
• 分布式高可用的部署架构,满足至少一次消息传递语义(RocketMQ原生就是支持分布式的,而ActiveMQ原生存在单点性)
• 提供 docker 镜像用于隔离测试和云集群部署
• 提供配置、指标和监控等功能丰富的 Dashboard
6. RocketMQ 概念
7. RocketMQ 组件
8. RocketMQ 交互过程
9. 常用中间件比较
10. XXL-JOB 概念
xxl-job 是一个分布式任务调度框架,主要用于解决大规模分布式系统中的定时调度和任务管理问题。它提供了可视化的任务管理界面和强大的调度功能,可以方便地实现定时任务的配置、监控和执行。
11. XXL-JOB 使用
12. SEATA 使用
- 在服务中引入 Seata 依赖。
- 配置 Seata 的全局事务切面和数据源代理。
- 在需要进行分布式事务管理的方法上添加**@GlobalTransactional 注解**。
- 在代码中进行正常的业务操作,Seata 会自动进行事务管理和协调。
九、部署
1. Linux
2. Docker
Docker是基于Go语言实现的云开源项目。Docker的主要目标是“Build,Ship and Run Any App,Anywhere”,也就是通过对应用组件的封装、分发、部署、运行等生命周期的管理,使用户的APP(可以是一个WEB应用或数据库应用等等)及其运行环境能够做到“一次镜像,处处运行”。
3. Nginx
- **反向代理:**将多台服务器代理成一台服务器。
- 负载均衡:将多个请求均匀的分配到多台服务器上,减轻每台服务器的压力,提高服务的吞吐量。
- 动静分离:nginx 可以用作静态文件的缓存服务器,提高访问速度