面试最常被问的 Java 后端题
一、Java 基础篇
1. Object 有哪些常用方法?大致说一下每个方法的含义
java.lang.Object
下面是对应方法的含义。
clone 方法
保护方法,实现对象的浅复制,只有实现了 Cloneable 接口才可以调用该方法,否则抛出 CloneNotSupportedException 异常,深拷贝也需要实现 Cloneable,同时其成员变量为引用类型的也需要实现 Cloneable,然后重写 clone 方法。
finalize 方法
该方法和垃圾收集器有关系,判断一个对象是否可以被回收的最后一步就是判断是否重写了此方法。
equals 方法
该方法使用频率非常高。一般 equals 和 == 是不一样的,但是在 Object 中两者是一样的。子类一般都要重写这个方法。
hashCode 方法
该方法用于哈希查找,重写了 equals 方法一般都要重写 hashCode 方法,这个方法在一些具有哈希功能的 Collection 中用到。
一般必须满足 obj1.equals(obj2)==true
。可以推出 obj1.hashCode()==obj2.hashCode()
,但是 hashCode 相等不一定就满足 equals。不过为了提高效率,应该尽量使上面两个条件接近等价。
- JDK 1.6、1.7 默认是返回随机数;
- JDK 1.8 默认是通过和当前线程有关的一个随机数 + 三个确定值,运用 Marsaglia’s xorshift scheme 随机数算法得到的一个随机数。
wait 方法
配合 synchronized 使用,wait 方法就是使当前线程等待该对象的锁,当前线程必须是该对象的拥有者,也就是具有该对象的锁。wait() 方法一直等待,直到获得锁或者被中断。wait(long timeout) 设定一个超时间隔,如果在规定时间内没有获得锁就返回。
调用该方法后当前线程进入睡眠状态,直到以下事件发生。
- 其他线程调用了该对象的 notify 方法;
- 其他线程调用了该对象的 notifyAll 方法;
- 其他线程调用了 interrupt 中断该线程;
- 时间间隔到了。
此时该线程就可以被调度了,如果是被中断的话就抛出一个 InterruptedException 异常。
notify 方法
配合 synchronized 使用,该方法唤醒在该对象上等待队列中的某个线程(同步队列中的线程是给抢占 CPU 的线程,等待队列中的线程指的是等待唤醒的线程)。
notifyAll 方法
配合 synchronized 使用,该方法唤醒在该对象上等待队列中的所有线程。
总结
只要把上面几个方法熟悉就可以了,toString 和 getClass 方法可以不用去讨论它们。该题目考察的是对 Object 的熟悉程度,平时用的很多方法并没看其定义但是也在用,比如说:wait() 方法,equals() 方法等。
Class Object is the root of the class hierarchy.Every class has Object as a superclass. All objects, including arrays, implement the methods of this class.
大致意思:Object 是所有类的根,是所有类的父类,所有对象包括数组都实现了 Object 的方法。
面试扩散
上面提到了 wait、notify、notifyAll 方法,或许面试官会问你为什么 sleep 方法不属于 Object 的方法呢?因为提到 wait 等方法,所以最好把 synchronized 都说清楚,把线程状态也都说清楚,尝试让面试官跟着你的节奏走。
2. Java 创建对象有几种方式?
这题目看似简单,要好好回答起来还是有点小复杂的,我们来看看,到底有哪些方式可以创建对象?
\1. 使用 new 关键字,这也是我们平时使用的最多的创建对象的方式,示例:
User user=new User();
\2. 反射方式创建对象,使用 newInstance(),但是得处理两个异常 InstantiationException、IllegalAccessException:
User user=User.class.newInstance();
Object object=(Object)Class.forName("java.lang.Object").newInstance()
3.使用 clone 方法,前面题目中 clone 是 Object 的方法,所以所有对象都有这个方法。
4.使用反序列化创建对象,调用 ObjectInputStream 类的 readObject() 方法。
我们反序列化一个对象,JVM 会给我们创建一个单独的对象。JVM 创建对象并不会调用任何构造函数。一个对象实现了 Serializable 接口,就可以把对象写入到文件中,并通过读取文件来创建对象。
总结
创建对象的方式关键字:new、反射、clone 拷贝、反序列化。
3. 获取一个类对象的方式有哪些?
搞清楚类对象和实例对象,但都是对象。
第一种:通过类对象的 getClass() 方法获取,细心点的都知道,这个 getClass 是 Object 类里面的方法。
User user=new User();
//clazz就是一个User的类对象
Class<?> clazz=user.getClass();
第二种:通过类的静态成员表示,每个类都有隐含的静态成员 class。
//clazz就是一个User的类对象
Class<?> clazz=User.class;
第三种:通过 Class 类的静态方法 forName() 方法获取。
Class<?> clazz = Class.forName("com.tian.User");
面试扩散
可能面试官会问相关的题目,比如:
Class.forName 和 ClassLoader.loadClass 的区别是什么?
参考:
4. ArrayList 和 LinkedList 的区别有哪些?
ArrayList
- 优点:ArrayList 是实现了基于动态数组的数据结构,因为地址连续,一旦数据存储好了,查询操作效率会比较高(在内存里是连着放的)。
- 缺点:因为地址连续,ArrayList 要移动数据,所以插入和删除操作效率比较低。
LinkedList
- 优点:LinkedList 基于链表的数据结构,地址是任意的,所以在开辟内存空间的时候不需要等一个连续的地址。对于新增和删除操作,LinkedList 比较占优势。LinkedList 适用于要头尾操作或插入指定位置的场景。
- 缺点:因为 LinkedList 要移动指针,所以查询操作性能比较低。
适用场景分析
- 当需要对数据进行对随机访问的时候,选用 ArrayList。
- 当需要对数据进行多次增加删除修改时,采用 LinkedList。
如果容量固定,并且只会添加到尾部,不会引起扩容,优先采用 ArrayList。
当然,绝大数业务的场景下,使用 ArrayList 就够了,但需要注意避免 ArrayList 的扩容,以及非顺序的插入。
5. 用过 ArrayList 吗?说一下它有什么特点?
只要是搞 Java 的肯定都会回答“用过”。所以,回答题目的后半部分——ArrayList 的特点。可以从这几个方面去回答:
Java 集合框架中的一种存放相同类型的元素数据,是一种变长的集合类,基于定长数组实现,当加入数据达到一定程度后,会实行自动扩容,即扩大数组大小。
底层是使用数组实现,添加元素。
- 如果 add(o),添加到的是数组的尾部,如果要增加的数据量很大,应该使用 ensureCapacity() 方法,该方法的作用是预先设置 ArrayList 的大小,这样可以大大提高初始化速度。
- 如果使用 add(int,o),添加到某个位置,那么可能会挪动大量的数组元素,并且可能会触发扩容机制。
高并发的情况下,线程不安全。多个线程同时操作 ArrayList,会引发不可预知的异常或错误。
ArrayList 实现了 Cloneable 接口,标识着它可以被复制。注意:ArrayList 里面的 clone() 复制其实是浅复制。
6. 有数组了为什么还要搞个 ArrayList 呢?
通常我们在使用的时候,如果在不明确要插入多少数据的情况下,普通数组就很尴尬了,因为你不知道需要初始化数组大小为多少,而 ArrayList 可以使用默认的大小,当元素个数到达一定程度后,会自动扩容。
可以这么来理解:我们常说的数组是定死的数组,ArrayList 却是动态数组。
7. 说说什么是 fail-fast?
fail-fast 机制是 Java 集合(Collection)中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生 fail-fast 事件。
例如:当某一个线程 A 通过 iterator 去遍历某集合的过程中,若该集合的内容被其他线程所改变了,那么线程 A 访问集合时,就会抛出 ConcurrentModificationException 异常,产生 fail-fast 事件。这里的操作主要是指 add、remove 和 clear,对集合元素个数进行修改。
解决办法:建议使用“java.util.concurrent 包下的类”去取代“java.util 包下的类”。
可以这么理解:在遍历之前,把 modCount 记下来 expectModCount,后面 expectModCount 去和 modCount 进行比较,如果不相等了,证明已并发了,被修改了,于是抛出 ConcurrentModificationException 异常。
8. Hashtable 与 HashMap 的区别
本来不想这么写标题的,但是无奈,面试官都喜欢这么问 HashMap。
- 出生的版本不一样,Hashtable 出生于 Java 发布的第一版本 JDK 1.0,HashMap 出生于 JDK 1.2。
- 都实现了 Map、Cloneable、Serializable(当前 JDK 版本 1.8)。
- HashMap 继承的是 AbstractMap,并且 AbstractMap 也实现了 Map 接口。Hashtable 继承 Dictionary。
- Hashtable 中大部分 public 修饰普通方法都是 synchronized 字段修饰的,是线程安全的,HashMap 是非线程安全的。
- Hashtable 的 key 不能为 null,value 也不能为 null,这个可以从 Hashtable 源码中的 put 方法看到,判断如果 value 为 null 就直接抛出空指针异常,在 put 方法中计算 key 的 hash 值之前并没有判断 key 为 null 的情况,那说明,这时候如果 key 为空,照样会抛出空指针异常。
- HashMap 的 key 和 value 都可以为 null。在计算 hash 值的时候,有判断,如果
key==null
,则其hash=0
;至于 value 是否为 null,根本没有判断过。 - Hashtable 直接使用对象的 hash 值。hash 值是 JDK 根据对象的地址或者字符串或者数字算出来的 int 类型的数值。然后再使用除留余数法来获得最终的位置。然而除法运算是非常耗费时间的,效率很低。HashMap 为了提高计算效率,将哈希表的大小固定为了 2 的幂,这样在取模预算时,不需要做除法,只需要做位运算。位运算比除法的效率要高很多。
- Hashtable、HashMap 都使用了 Iterator。而由于历史原因,Hashtable 还使用了 Enumeration 的方式。
- 默认情况下,初始容量不同,Hashtable 的初始长度是 11,之后每次扩充容量变为之前的 2n+1(n 为上一次的长度)而 HashMap 的初始长度为 16,之后每次扩充变为原来的两倍。
另外在 Hashtable 源码注释中有这么一句话:
Hashtable is synchronized. If a thread-safe implementation is not needed, it is recommended to use HashMap in place of Hashtable . If a thread-safe highly-concurrent implementation is desired, then it is recommended to use ConcurrentHashMap in place of Hashtable.
大致意思:Hashtable 是线程安全,推荐使用 HashMap 代替 Hashtable;如果需要线程安全高并发的话,推荐使用 ConcurrentHashMap 代替 Hashtable。
这个回答完了,面试官可能会继续问:HashMap 是线程不安全的,那么在需要线程安全的情况下还要考虑性能,有什么解决方式?
这里最好的选择就是 ConcurrentHashMap 了,但面试官肯定会叫你继续说一下 ConcurrentHashMap 数据结构以及底层原理等。
9. HashMap 中的 key 我们可以使用任何类作为 key 吗?
平时可能大家使用的最多的就是使用 String 作为 HashMap 的 key,但是现在我们想使用某个自定义类作为 HashMap 的 key,那就需要注意以下几点:
- 如果类重写了 equals 方法,它也应该重写 hashCode 方法。
- 类的所有实例需要遵循与 equals 和 hashCode 相关的规则。
- 如果一个类没有使用 equals,你不应该在 hashCode 中使用它。
- 咱们自定义 key 类的最佳实践是使之为不可变的,这样,hashCode 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode 和 equals 在未来不会改变,这样就会解决与可变相关的问题了。
10. HashMap 的长度为什么是 2 的 N 次方呢?
为了能让 HashMap 存数据和取数据的效率高,尽可能地减少 hash 值的碰撞,也就是说尽量把数据能均匀的分配,每个链表或者红黑树长度尽量相等。
我们首先可能会想到 %
取模的操作来实现。
下面是回答的重点哟:
取余(%)操作中如果除数是 2 的幂次,则等价于与其除数减一的与(&)操作(也就是说
hash % length == hash &(length - 1)
的前提是 length 是 2 的 n 次方)。并且,采用二进制位操作&
,相对于%
能够提高运算效率。
这就是为什么 HashMap 的长度需要 2 的 N 次方了。
11. HashMap 与 ConcurrentHashMap 的异同
- 都是 key-value 形式的存储数据;
- HashMap 是线程不安全的,ConcurrentHashMap 是 JUC 下的线程安全的;
- HashMap 底层数据结构是数组 + 链表(JDK 1.8 之前)。JDK 1.8 之后是数组 + 链表 + 红黑树。当链表中元素个数达到 8 的时候,链表的查询速度不如红黑树快,链表会转为红黑树,红黑树查询速度快;
- HashMap 初始数组大小为 16(默认),当出现扩容的时候,以 0.75 * 数组大小的方式进行扩容;
- ConcurrentHashMap 在 JDK 1.8 之前是采用分段锁来现实的 Segment + HashEntry,Segment 数组大小默认是 16,2 的 n 次方;JDK 1.8 之后,采用 Node + CAS + Synchronized 来保证并发安全进行实现。
13. 红黑树有哪几个特征?
紧接上个问题,面试官很有可能会问红黑树,下面把红黑树的几个特征列出来:
如果面试官还要继续问红黑树具体是怎么添加节点和删除节点的,推荐看:
14. 说说你平时是怎么处理 Java 异常的
try-catch-finally
- try 块负责监控可能出现异常的代码
- catch 块负责捕获可能出现的异常,并进行处理
- finally 块负责清理各种资源,不管是否出现异常都会执行
- 其中 try 块是必须的,catch 和 finally 至少存在一个标准异常处理流程
抛出异常→捕获异常→捕获成功(当 catch 的异常类型与抛出的异常类型匹配时,捕获成功)→异常被处理,程序继续运行 抛出异常→捕获异常→捕获失败(当 catch 的异常类型与抛出异常类型不匹配时,捕获失败)→异常未被处理,程序中断运行
在开发过程中会使用到自定义异常,在通常情况下,程序很少会自己抛出异常,因为异常的类名通常也包含了该异常的有用信息,所以在选择抛出异常的时候,应该选择合适的异常类,从而可以明确地描述该异常情况,所以这时候往往都是自定义异常。
自定义异常通常是通过继承 java.lang.Exception 类,如果想自定义 Runtime 异常的话,可以继承 java.lang.RuntimeException 类,实现一个无参构造和一个带字符串参数的有参构造方法。
在业务代码里,可以针对性的使用自定义异常。比如说:该用户不具备某某权限、余额不足等。
15. finally 模块执行了吗?是先执行 return 还是先执行 finally 模块?返回什么?
public class FinallyDemo {
public String method111() {
String ret = "hello";
try {
return ret;
} finally {
ret = "world";
}
}
}
把 FinallyDemo.java 编译成 class 文件后,找到该 class 文件的当前目录,执行 cmd 命令:
javap -verbose FinallyDemo.class >>test.txt
然后打开 test.txt,关键部分内容如下:
发现在字节码指令中,将 hello 保存在本地变量 2 中,然后直到把本地变量 2 加载到操作数栈中,然后就直接出栈,return 回去了,所以本题的返回去的是 hello,但是 finally 代码块也执行了,执行完 finally 模块后再返回一个临时变量 2。
二、JVM 篇
16. Java 类加载器有几种?
17. 说一下有哪些类加载场景?
18. 说说 Java 类加载机制是什么?说说 new 创建一个普通对象的过程?
类加载的过程包括了:
加载、验证、准备、解析、初始化。
new 创建一个普通对象的过程如下:
- 检测类是否被加载过
- 为对象分配内存
- 为分配的内存空间初始化为 0
- 对对象进行其他相关设置
- 执行 init 方法
下面用一张图来描述:
19. 说说类的生命周期?
注意类生命周期和对象声明周期,类生命周期主要有以下几个阶段:
20. 什么是双亲委派模型?
21. 如何破坏双亲委派模型?
22. 能不能自己也写一个 java.lang.String 类?
可以写,能编译,但是不能 run。禁止使用包名:java.开头的包名
。
定义一个普通类:
package java.lang;
public class MyTest {
public MyTest() {
}
public MyTest(String str, int a) {
}
public int length(){
return 10;
}
public static void main(String[] args) {
MyTest myTest =new MyTest("lang",1);
myTest.length();
}
}
运行:
具体校验的源码地方:
结论就是定义包目录的时候,不能以 java. 开头。
23. 说一下 JVM 运行时数据区有哪些?分别说一下它们的功能
此题我想用我的方法说,不像网上一堆一堆抄书上的,希望能对大家有所帮助,如果没多大帮助,那可以网上找个看看,只能说抱歉了。
下面我们直入主题:
- 每个线程单独拥有的:程序计数器、Java 虚拟机栈、本地方法栈
- 所有线程都共同有的:方法区、堆
如果你在 Java 代码里创建一个线程,相应 JVM 虚拟机中就创建与之对应的程序计数器、Java 虚拟机栈、本地方法栈,同时方法区和堆是在虚拟机启动就已经有了。
程序计数器
可以简单理解为:程序计数器是记录执行到你代码的的第几行了,每个线程各自对应自己的程序计数器。
Java 虚拟机栈
虚拟机会为每个线程分配一个虚拟机栈,每个虚拟机栈中都有若干个栈帧,每个栈帧中存储了局部变量表、操作数栈、动态链接、返回地址等。一个栈帧就对应 Java 代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈帧已经进入虚拟机栈并且处于栈顶的位置,每一个 Java 方法从被调用到执行结束,就对应了一个栈帧从入栈到出栈的过程。一个线程的生命周期和与之对应的 Java 虚拟机栈的生命周期相同。
一个线程进来就创建虚拟机栈,该线程调用的方法就是栈帧,进入方法,栈帧就入栈(虚拟机栈),出方法就是出虚拟机栈。可以通过下面两张图进行理解:
本地方法栈
和 Java 虚拟机栈类似,Java 虚拟机栈针对的是 Java 方法,而本地方法栈针对的 native 修饰的方法。
堆
JVM 几乎所有的对象的内存分配都在堆里。由于对象是有生命周期的,所以把堆又分成了新生代和老年代。
新生代和老年代大小比例 = 1:2(默认)。新生代又分为 Eden、S0、S1 区域,Ede:S0:S1=8:1:1。
大多数对象在 Eden 区出生和死亡。Eden 区存活下来的对象进入 S0 区,S0 区活下来的对象放到 S1,S1 区活下来的对象放到 S0 区,这过程中 S0 和 S1 至少有一个区域是空着的。并且对象每次倒腾一次自己的年龄就加 1,直达加到 15 岁的时候,就直接入老年代了。有的大对象可以直接进入老年代,条件是把该对象的大小以及达到了能直接进入老年代的条件了(阈值可以设置)。
方法区
先按照图中的关键字回答。但是方法区由于 JDK 版本有所变动。
回答的时候,一定要说一下方法区由于 JDK 版本有所变动。版本变动情况如下:
24. 方法区和永久代有什么区别?
之前有小伙伴也问过我,方法区和永久代到底是什么区别?
这么说吧:永久代又叫 Perm 区,只存在于 HotSpot JVM 中,并且只存在于 JDK 1.7 和之前的版本中,JDK 1.8 中已经彻底移除了永久代,JDK 1.8 中引入了一个新的内存区域叫 metaspace。
- 并不是所有的 JVM 中都有永久代,IBM 的 9,Oracle 的 JRocket 都没有永久代。
- 永久代是实现层面的东西。
- 永久代里面存的东西基本上就是方法区规定的那些东西。
因此,我们可以说,在 JDK 1.7 中永久代是方法区的一种实现,当然,在 HotSpot JDK 1.8 中 metaspace 可以看成是方法区的一种实现。
24. JVM 运行时数据区哪些地方会产生内存溢出?
简单回答就行:
Java 虚拟机栈、本地方法栈、Java 堆、方法区,其实就是除了程序计数器以外的几个都会发生 OOM。
建议把阈值对应的几个区也简要的说一下:
25. 为什么要用 metaspace 替换 permspace 呢?
在 JDK 1.8 之前的 HotSpot 实现中,类的元数据如方法数据、方法信息(字节码、栈和变量大小)、运行时常量池、已确定的符号引用和虚方法表等被保存在永久代中,32 位默认永久代的大小为 64M,64 位默认为 85M,可以通过参数 -XX:MaxPermSize
进行设置,一旦类的元数据超过了永久代大小,就会抛出 OOM 异常。
虚拟机团队在 JDK 1.8 的 HotSpot 中,把永久代从 Java 堆中移除了,并把类的元数据直接保存在本地内存区域(堆外内存),称之为元空间 metaspace(本地内存)。即就是说 JDK 1.8 之前,永久代是在虚拟机中的,而 JDK 1.8 引入的元空间是系统的内存的一部分。理论上取决于 32 位/64 位系统可虚拟的内存大小,可见也不是无限制的,需要配置参数。
另外一方面,咱们在对永久代进行调优的时候是相当费劲,因为永久代的大小不好控制,涉及到很多因素,比如:类的总数、常量池的大小、方法数量等,最无语的是永久代的内存不够了可能会伴随着一次 Full GC。
下面是 JDK 几个版本中方法区和堆存储的信息的关系:
总结
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- 移除永久代是为融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,不需要配置永久代。
26. 熟悉哪些 JVM 调优参数?
整个堆内存大小
-Xms
(初始堆大小)、-Xmx
(最大堆大小),为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值。
新生代空间大小
NewRadio:年轻代和年老代将根据默认的比例(1:2)分配堆内存,建议设置为 2 到 4,可以通过调整二者之间的比率 NewRadio 来调整二者之间的大小。也可以针对回收代,比如年轻代,通过 -XX:newSize -XX:MaxNewSize
来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把 -XX:newSize -XX:MaxNewSize
设置一样大小。
方法区(元空间)
JDK 1.8:-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
,根据实际情况调整, 可以使用命令 jstat -gcutil pid
查看当前使用率,M 对应的列,根据使用率来定制一个具体的值,建议两个值设置成同样大小。
JDK 1.7:-XX:MaxPermSize=256m -XX:MaxPermSize=256m
永久带。
GC 日志
-Xloggc:$CATALINA_BASE/logs/gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
记录 GC 日志并不会特别地影响 Java 程序性能,推荐你尽可能记录日志。
GC 算法
-XX:+UseParNewGC
-XX:+CMSParallelRemarkEnabled
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75
一般来说推荐使用这些配置,但是根据程序不同的特性,其他的也有可能更好。
任何一个 JVM 参数的默认值可以通过
java -XX:+PrintFlagsFinal -version |grep JVMParamName
获取,例如:
java -XX:+PrintFlagsFinal -version |grep MetaspaceSize
27. Java 对象的引用类型有哪些?
对象引用类型有四类:强引用、软引用、弱引用、虚引用。
28. JVM 垃圾回收算法有哪些?
垃圾回收算法共四种:其实我更愿意说成三种,因为分代回收其实不是算法。
29. 垃圾收集器有哪些?
目前常见的有如下几种:
Serial 收集器
ParNew 收集器
Parallel scavenge 收集器
Serial Old 收集器
CMS=Concurrent Mark Sweep 收集器
Parallel Old 收集器
G1=Garbage-First 收集器
垃圾收集器整合
- G1 是新生代和老年代一起搞,不和别人合伙。
- Serial:CMS 或者 Serial Old
- ParNew:CMS 或者 Serial Old
- Parallel Scavenge:Parallel Old 或者 Serial Old
分代收集器对应
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:Serial Old、Parallel Old、CMS
- 整堆收集器:G1
30. 说说 JVM 中内存的分配与回收策略
三、Dubbo 篇
其实关于 Dubbo 的面试题,我觉得最好的文档应该还是官网,因为官网有中文版,照顾了很多阅读英文文档吃力的小伙伴。但是官网内容挺多的,于是这里就结合官网和平时面试被问的相对较多的题目整理了一下。
31. 说说一次 Dubbo 服务请求流程?
基本工作流程:
上图中角色说明:
32. 说说 Dubbo 工作原理
工作原理分 10 层:
- 第一层:service 层,接口层,给服务提供者和消费者来实现的(留给开发人员来实现);
- 第二层:config 层,配置层,主要是对 Dubbo 进行各种配置的,Dubbo 相关配置;
- 第三层:proxy 层,服务代理层,透明生成客户端的 stub 和服务单的 skeleton,调用的是接口,实现类没有,所以得生成代理,代理之间再进行网络通讯、负责均衡等;
- 第四层:registry 层,服务注册层,负责服务的注册与发现;
- 第五层:cluster 层,集群层,封装多个服务提供者的路由以及负载均衡,将多个实例组合成一个服务;
- 第六层:monitor 层,监控层,对 rpc 接口的调用次数和调用时间进行监控;
- 第七层:protocol 层,远程调用层,封装 rpc 调用;
- 第八层:exchange 层,信息交换层,封装请求响应模式,同步转异步;
- 第九层:transport 层,网络传输层,抽象 mina 和 netty 为统一接口;
- 第十层:serialize 层,数据序列化层。
这是个很坑爹的面试题,但是很多面试官又喜欢问,你真的要背么?你能背那还是不错的,我建议不要背,你就想想 Dubbo 服务调用过程中应该会涉及到哪些技术,把这些技术串起来就 OK 了。
面试扩散
如果让你设计一个 RPC 框架,你会怎么做?其实你就把上面这个工作原理中涉及的到技术点总结一下就行了。
33. Dubbo 支持哪些协议?
还有三种,混个眼熟就行:Memcached 协议、Redis 协议、Rest 协议。
上图基本上把序列化的方式也罗列出来了。
详细请参考:Dubbo 官网。
34. 注册中心挂了,consumer 还能不能调用 provider?
可以。因为刚开始初始化的时候,consumer 会将需要的所有提供者的地址等信息拉取到本地缓存,所以注册中心挂了可以继续通信。但是 provider 挂了,那就没法调用了。
关键字:consumer 本地缓存服务列表。
35. 怎么实现动态感知服务下线的呢?
服务订阅通常有 pull 和 push 两种方式:
- pull 模式需要客户端定时向注册中心拉取配置;
- push 模式采用注册中心主动推送数据给客户端。
Dubbo ZooKeeper 注册中心采用是事件通知与客户端拉取方式。服务第一次订阅的时候将会拉取对应目录下全量数据,然后在订阅的节点注册一个 watcher。一旦目录节点下发生任何数据变化,ZooKeeper 将会通过 watcher 通知客户端。客户端接到通知,将会重新拉取该目录下全量数据,并重新注册 watcher。利用这个模式,Dubbo 服务就可以做到服务的动态发现。
注意:ZooKeeper 提供了“心跳检测”功能,它会定时向各个服务提供者发送一个请求(实际上建立的是一个 socket 长连接),如果长期没有响应,服务中心就认为该服务提供者已经“挂了”,并将其剔除。
36. Dubbo 负载均衡策略?
- 随机(默认):随机来
- 轮训:一个一个来
- 活跃度:机器活跃度来负载
- 一致性 hash:落到同一台机器上
37. Dubbo 容错策略
failover cluster 模式
provider 宕机重试以后,请求会分到其他的 provider 上,默认两次,可以手动设置重试次数,建议把写操作重试次数设置成 0。
failback 模式
失败自动恢复会在调用失败后,返回一个空结果给服务消费者。并通过定时任务对失败的调用进行重试,适合执行消息通知等操作。
failfast cluster 模式
快速失败只会进行一次调用,失败后立即抛出异常。适用于幂等操作、写操作,类似于 failover cluster 模式中重试次数设置为 0 的情况。
failsafe cluster 模式
失败安全是指,当调用过程中出现异常时,仅会打印异常,而不会抛出异常。适用于写入审计日志等操作。
forking cluster 模式
并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks="2"
来设置最大并行数。
broadcacst cluster 模式
广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。
38. Dubbo 动态代理策略有哪些?
默认使用 javassist 动态字节码生成,创建代理类,但是可以通过 SPI 扩展机制配置自己的动态代理策略。
39. 说说 Dubbo 与 Spring Cloud 的区别?
这是很多面试官喜欢问的问题,本人认为其实他们没什么关联之处,但是硬是要问区别,那就说说吧。
回答的时候主要围绕着四个关键点来说:通信方式、注册中心、监控、断路器,其余像 Spring 分布式配置、服务网关肯定得知道。
通信方式
Dubbo 使用的是 RPC 通信;Spring Cloud 使用的是 HTTP RestFul 方式。
注册中心
Dubbo 使用 ZooKeeper(官方推荐),还有 Redis、Multicast、Simple 注册中心,但不推荐。;
Spring Cloud 使用的是 Spring Cloud Netflix Eureka。
监控
Dubbo 使用的是 Dubbo-monitor;Spring Cloud 使用的是 Spring Boot admin。
断路器
Dubbo 在断路器这方面还不完善,Spring Cloud 使用的是 Spring Cloud Netflix Hystrix。
分布式配置、网关服务、服务跟踪、消息总线、批量任务等。
Dubbo 目前可以说还是空白,而 Spring Cloud 都有相应的组件来支撑。
40. 说说 TCP 与 UDP 的区别,以及各自的优缺点
41. 说一下 HTTP 和 HTTPS 的区别
- 端口不同:HTTP 和 HTTPS 的连接方式不同没用的端口也不一样,HTTP 是 80,HTTPS 用的是 443;
- 消耗资源:和 HTTP 相比,HTTPS 通信会因为加解密的处理消耗更多的 CPU 和内存资源;
- 开销:HTTPS 通信需要证书,这类证书通常需要向认证机构申请或者付费购买。
42. 说说 HTTP、TCP、Socket 的关系是什么?
- TCP/IP 代表传输控制协议/网际协议,指的是一系列协议族。
- HTTP 本身就是一个协议,是从 Web 服务器传输超文本到本地浏览器的传送协议。
- Socket 是 TCP/IP 网络的 API,其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面。对用户来说,一组简单的接口就是全部,让 Socket 去组织数据,以符合指定的协议。
综上所述:
- 需要 IP 协议来连接网络。
- TCP 是一种允许我们安全传输数据的机制,使用 TCP 协议来传输数据的 HTTP 是 Web 服务器和客户端使用的特殊协议。
- HTTP 基于 TCP 协议,所以可以使用 Socket 去建立一个 TCP 连接。
43. 说一下 HTTP 的长连接与短连接的区别
HTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接。
短连接
在 HTTP/1.0 中默认使用短链接,也就是说,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,但任务结束就中断连接。如果客户端访问的某个 HTML 或其他类型的 Web 资源,如 JavaScript 文件、图像文件、CSS 文件等。当浏览器每遇到这样一个 Web 资源,就会建立一个 HTTP 会话。
长连接
从 HTTP/1.1 起,默认使用长连接,用以保持连接特性。在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输 HTTP 数据的 TCP 连接不会关闭。如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。Keep-Alive 不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如 Apache)中设定这个时间。
四、MyBatis 篇
44. 说说 MyBatis 的缓存
一级缓存
在应用运行过程中,我们有可能在一次数据库会话中,执行多次查询条件完全相同的 SQL,MyBatis 提供了一级缓存的方案优化这部分场景,如果是相同的 SQL 语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。
每个 SqlSession 中持有了 Executor,每个 Executor 中有一个 LocalCache。当用户发起查询时,MyBatis 根据当前执行的语句生成 MappedStatement,在 Local Cache 进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入 Local Cache,最后返回结果给用户。具体实现类的类关系图如下图所示:
- MyBatis 一级缓存的生命周期和 SqlSession 一致。
- MyBatis 一级缓存内部设计简单,只是一个没有容量限定的 HashMap,在缓存的功能性上有所欠缺。
- MyBatis 的一级缓存最大范围是 SqlSession 内部,有多个 SqlSession 或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为 Statement。
二级缓存
在上文中提到的一级缓存中,其最大的共享范围就是一个 SqlSession 内部,如果多个 SqlSession 之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用 CachingExecutor 装饰 Executor,进入一级缓存的查询流程前,先在 CachingExecutor 进行二级缓存的查询,具体的工作流程如下所示。
二级缓存开启后,同一个 namespace 下的所有操作语句,都影响着同一个 Cache,即二级缓存被多个 SqlSession 共享,是一个全局的变量。
当开启缓存后,数据的查询执行的流程为:
二级缓存 -> 一级缓存 -> 数据库
- MyBatis 的二级缓存相对于一级缓存来说,实现了 SqlSession 之间缓存数据的共享,同时粒度更加细,能够到 namespace 级别,通过 Cache 接口实现类不同的组合,对 Cache 的可控性也更强。
- MyBatis 在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
- 在分布式环境下,由于默认的 MyBatis Cache 实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将 MyBatis 的 Cache 接口实现,有一定的开发成本,直接使用 Redis、Memcached 等分布式缓存可能成本更低,安全性也更高。
45. JDBC 编程有哪些步骤?
\1. 装载相应的数据库的 JDBC 驱动并进行初始化:
Class.forName("com.mysql.jdbc.Driver");
\2. 建立 JDBC 和数据库之间的 Connection 连接:
Connection c = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test?characterEncoding=UTF-8", "root", "123456");
\3. 创建 Statement 或者 PreparedStatement 接口,执行 SQL 语句:
//查询用户信息
public List<User> findUserList(){
String sql = "select * from t_user order by user_id";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
//创建一个List用于存放查询到的User对象
List<User> userList = new ArrayList<>();
try {
conn = DbUtil.getConnection();
pstmt =(PreparedStatement) conn.prepareStatement(sql);
rs =(ResultSet) pstmt.executeQuery();
while(rs.next()){
int courseId = rs.getInt("user_id");
String courseName = rs.getString("user_name");
//每个记录对应一个User对象
User user = new User();
user.setUserId(courseId);
user.setUserName(courseName);
//将对象放到集合中
userList.add(course);
}
} catch(SQLException e) {
e.printStackTrace();
}finally{
//资源关闭
DbUtil.close(pstmt);
DbUtil.close(conn);
}
return userList;
}
\4. 处理和显示结果。
\5. 释放资源。
46. 说一下 MyBatis 中使用的 #和 $ 有什么区别
动态 SQL 是 MyBatis 的主要特性之一,在 mapper 中定义的参数传到 xml 中之后,在查询之前 MyBatis 会对其进行动态解析。
MyBatis 为我们提供了两种支持动态 SQL 的语法:#{}
以及 ${}
。
#{}
是预编译处理,${}
是字符替换。在使用 #{} 时,MyBatis 会将 SQL 中的 #{}
替换成 ?
,配合 PreparedStatement 的 set 方法赋值,这样可以有效的防止 SQL 注入,保证程序的运行安全。
建议能不要用就不要用,“常在河边走哪能不湿鞋”。
47. MyBatis 中比如 UserMapper.java 是接口,为什么没有实现类还能调用?
UserMapper.xml 中:
<mapper namespace="com.tian.UserMapper">
反射生成 namespace 的对象:
boundType = Resources.classForName(namespace);
JDK 动态代理:
Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[]{mapperInterface}, mapperProxy);
总结:XML 中的 namespace="com.user.UserMapper"
接口 com.user.UserMapper 本身反射 JDK 动态代理实现接口。
48. MyBatis 中见过什么设计模式?
五、MySQL 篇
49. 简单说说在 MySQL 中执行依据查询 SQL 是如何执行的?
select name from t_user where id=1
- 取得链接,使用使用到 MySQL 中的连接器。
- 查询缓存,key 为 SQL 语句,value 为查询结果,如果查到就直接返回。不建议使用次缓存,在 MySQL 8.0 版本已经将查询缓存删除,也就是说 MySQL 8.0 版本后不存在此功能。
- 分析器,分为词法分析和语法分析。此阶段只是做一些 SQL 解析,语法校验。所以一般语法错误在此阶段。
- 优化器,是在表里有多个索引的时候,决定使用哪个索引;或者一个语句中存在多表关联的时候(join),决定各个表的连接顺序。
- 执行器,通过分析器让 SQL 知道你要干啥,通过优化器知道该怎么做,于是开始执行语句。执行语句的时候还要判断是否具备此权限,没有权限就直接返回提示没有权限的错误;有权限则打开表,根据表的引擎定义,去使用这个引擎提供的接口,获取这个表的第一行,判断 id 是都等于 1。如果是,直接返回;如果不是继续调用引擎接口去下一行,重复相同的判断,直到取到这个表的最后一行,最后返回。
MySQL 的典型的三层结构(连接器 + Server + 执行器):
50. MySQL 有哪些存储引擎?
51. MySQL 中 varchar 与 char 的区别?varchar(30) 中的 30 代表的涵义?
- varchar 与 char 的区别,char 是一种固定长度的类型,varchar 则是一种可变长度的类型。
- varchar(30) 中 30 的涵义最多存放 30 个字符。varchar(30) 和 (130) 存储 hello 所占空间一样,但后者在排序时会消耗更多内存,因为 ORDER BY col 采用 fixed_length 计算 col 长度(memory 引擎也一样)。
- 对效率要求高用 char,对空间使用要求高用 varchar。
52. int(11) 中的 11 代表什么涵义?
int(11) 中的 11,不影响字段存储的范围,只影响展示效果。
53. 为什么 SELECT COUNT(*) FROM table 在 InnoDB 比 MyISAM 慢?
对于 SELECT COUNT(*) FROM table 语句,在没有 WHERE 条件的情况下,InnoDB 比 MyISAM 可能会慢很多,尤其在大表的情况下。因为,InnoDB 是去实时统计结果,会全表扫描;而 MyISAM 内部维持了一个计数器,预存了结果,所以直接返回即可。
面试扩散
此题还有另外一种问法:SELECT COUNT(*) FROM table
在使用存储引擎 InnoDB 和 MyISAM,谁更快,为什么?
54. 说说数据库的三范式和反模式
55. 在设计数据库表的时候,字段用于存储金额、余额时,选择什么类型比较好?
- 直接选择 int 或者 bigint 类型,但是得对金额进行乘 100,或者 1000;
- 使用 decimal 类型,避免精度丢失。如果使用 Java 语言时,需要使用 BigDecimal 进行对应,但是使用 BigDecimal 的时候也是容易出问题的,这是 Java 层面的,没遇到坑的,以后要留意点。
56. 大概说说 InnoDB 与 MyISAM 有什么区别?
- 在 MySQL 5.1 及之前的版本中,MyISAM 是默认的存储引擎,而在 MySQL 5.5 版本以后,默认使用 InnoDB 存储引擎。
- MyISAM 不支持行级锁,换句话说,MyISAM 会对整张表加锁,而不是针对行。同时,MyISAM 不支持事务和外键。MyISAM 可被压缩,存储空间较小,而且 MyISAM 在筛选大量数据时非常快。
- InnoDB 是事务型引擎,当事务异常提交时,会被回滚。同时,InnoDB 支持行锁。此外,InnoDB 需要更多存储空间,会在内存中建立其专用的缓冲池用于高速缓冲数据和索引。InnoDB 支持自动奔溃恢复特性。
建议:一般情况下,个人建议优先选择 InnoDB 存储引擎,并且尽量不要将 InnoDB 与 MyISAM 混合使用。
57. 什么是索引?
索引,类似于书籍的目录,想找到一本书的某个特定的主题,需要先找到书的目录,定位对应的页码。
MySQL 中存储引擎使用类似的方式进行查询,先去索引中查找对应的值,然后根据匹配的索引找到对应的数据行。
58. 索引有什么优缺点?
59. MySQL 索引类型有哪些?
60. 什么时候不要使用索引?
- 经常增删改的列不要建立索引;
- 有大量重复的列不建立索引;
- 表记录太少不要建立索引。
61. 使用 MySQL 的索引应该注意些什么?
62. 怎么知道一条查询语句是否用到了索引,用了什么类型的索引?
使用方法,在 SELECT 语句前加上 EXPLAIN 执行,查看其执行计划, 可以帮助选择更好的索引和写出更优化的查询语句,explain 执行计划对应每一列的详情,这里就不用再提了,网上一堆资料,但还是推荐看官网:
英语好的就直接看官网的说明,英语不好的可以使用浏览器搞个自动翻译的小插件,结合英文同步来看。
63. 说说什么是 MVCC?
多版本并发控制(MVCC=Multi-Version Concurrency Control),是一种用来解决读 - 写冲突的无锁并发控制。也就是为事务分配单向增长的时间戳,为每个修改保存一个版本。版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照(复制了一份数据)。这样在读操作不用阻塞写操作,写操作不用阻塞读操作的同时,避免了脏读和不可重复读。
64. MVCC 可以为数据库解决什么问题?
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。同时还可以解决脏读、幻读、不可重复读等事务隔离问题,但不能解决更新丢失问题。
65. 说说 MVCC 的实现原理
MVCC 的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3 个隐式字段、undo 日志、Read View 来实现的。
66. 什么是死锁?
生活中:吃饭使用一双筷子,但是如果你我各一支,你想吃饭我也想吃饭,你的那支不愿意给我,我的那支也不愿给你,这会就死锁了。
虽然进程在运行过程中,可能发生死锁,但死锁的发生也必须具备一定的条件。
死锁的发生必须具备以下四个必要条件:
- 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用完释放。
- 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
- 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 环路等待条件:指在发生死锁时,必然存在一个进程 —— 资源的环形链,即进程集合 {P0,P1,P2,…,Pn} 中的 P0 正在等待一个 P1 占用的资源,P1 正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。
67. MySQL 事务隔离级别?
- READ UNCOMMITTED(未提交读):事务中的修改,即使没有提交,对其他事务也都是可见的。会导致脏读。
- READ COMMITTED(提交读):事务从开始直到提交之前,所做的任何修改对其他事务都是不可见的。会导致不可重复读。这个隔离级别,也可以叫做“不可重复读”。
- REPEATABLE READ(可重复读):一个事务按相同的查询条件读取以前检索过的数据,其他事务插入了满足其查询条件的新数据。产生幻行,会导致幻读。(MySQL 默认隔离级别)
- SERIALIZABLE(可串行化):强制事务串行执行。
69. 请说说 MySQL 数据库的锁?
关于 MySQL 的锁机制,可能会问很多问题,不过这也得看面试官在这方面的知识储备。
MySQL 中有共享锁和排它锁,也就是读锁和写锁。
- 共享锁:不堵塞,多个用户可以同一时刻读取同一个资源,相互之间没有影响。
- 排它锁:一个写操作阻塞其他的读锁和写锁,这样可以只允许一个用户进行写入,防止其他用户读取正在写入的资源。
- 表锁:系统开销最小,会锁定整张表,MyISAM 使用表锁。
- 行锁:容易出现死锁,发生冲突概率低,并发高,InnoDB 支持行锁(必须有索引才能实现,否则会自动锁全表,那么就不是行锁了)。
70. 说说什么是锁升级?
- MySQL 行锁只能加在索引上,如果操作不走索引,就会升级为表锁。因为 InnoDB 的行锁是加在索引上的,如果不走索引,自然就没法使用行锁了,原因是 InnoDB 是将 primary key index 和相关的行数据共同放在 B+ 树的叶节点。InnoDB 一定会有一个 primary key,secondary index 查找的时候,也是通过找到对应的 primary,再找对应的数据行。
- 当非唯一索引上记录数超过一定数量时,行锁也会升级为表锁。测试发现当非唯一索引相同的内容不少于整个表记录的二分之一时会升级为表锁。因为当非唯一索引相同的内容达到整个记录的二分之一时,索引需要的性能比全文检索还要大,查询语句优化时会选择不走索引,造成索引失效,行锁自然就会升级为表锁。
71. 说说悲观锁和乐观锁
悲观锁
说的是数据库被外界(包括本系统当前的其他事物以及来自外部系统的事务处理)修改保持着保守态度,因此在整个数据修改过程中,将数据处于锁状态。悲观的实现往往是依靠数据库提供的锁机制,也只有数据库层面提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统汇总实现了加锁机制,也是没有办法保证系统不会修改数据。
在悲观锁的情况下,为了保证事务的隔离性,就需要一致性锁定读。读取数据时给加锁,其它事务无法修改这些数据。修改删除数据时也要加锁,其它事务无法读取这些数据。
乐观锁
相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。
而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本(Version)记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个“version”字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
72. 怎样尽量避免死锁的出现?
- 设置获取锁的超时时间,至少能保证最差情况下,可以退出程序,不至于一直等待导致死锁;
- 设置按照同一顺序访问资源,类似于串行执行;
- 避免事务中的用户交叉;
- 保持事务简短并在一个批处理中;
- 使用低隔离级别;
- 使用绑定链接。
六、RabbitMQ 篇
73. 看你简历上写了 RabbitMQ,通常会问:为什么要用 RabbitMQ?
异步
场景:需要把 A 系统信息发给 B、C、D 系统。
用户发起请求总耗时 380,调用 A 系统耗时 80ms,A 系统调用 B 系统 100ms,A 系统调用 C 系统耗时 110ms,A 系统调用 D 系统耗时 90ms。其中 A 系统只管把数据推送出去就行。
如果引入 RabbitMQ 后,该请求总耗时 = A 系统耗时 + 发送 RabbitMQ 耗时,总耗时从 380ms 到 120ms 了。
可以看到通过 RabbitMQ 的异步功能,可以大大提高接口的性能。
解耦
场景:业务需要把用户信息从 A 系统推送到 B 系统和 C 系统,流程如下:
但是这回业务变化了,还需要把 A 系统把用户信息推送给 D 系统和 E 系统。如果还按照上面系统那种模式,那么 A 系统得又得新开发。就变成下面这样:
如果引入 RabbitMQ 的话:
往后不管有多少个系统需要 A 系统推送用户信息,A 系统就不用变,只是对应系统去 RabbitMQ 取数据就可以搞定了。
削峰
场景:A 系统常规情况下,每秒并发 200,但是高峰时间可能会到几千或者上万,但是数据库每秒只能处理 1000 左右,多了会把数据库搞死。正常情况下:
如果引入 RabbitMQ 的话,就是先把消息给 RabbitMQ,然后慢慢入库,这样数据库就不会有太大压力了。
削峰就类似于银行办理业务,正常情况下,几个窗口够用,但是在节假日去银行办理业务的人太多了,几个窗口就支持不了,窗口边会站满了人。于是就可以采取取号制度,先拿号码去大厅坐着,我们一个一个来。(如有觉得场景不妥的,望谅解)
上面三种场景能回答上来,基本上就可以避免“我们领导叫我们这么用的”尴尬场面。
真实场景:
- 用户付款成功,发一条站内信,付款成功;
- 用户注册成功,发一个短信或者邮件,提示注册成功;
- 用户每次操作日志,记录好用户行为。
到这里相信大家面试的时候,就算你没用过,编也能编出一个你项目中的场景了吧。
74. 可能你讲了上面三个 RabbitMQ 的优点后,会继续问:使用 RabbitMQ 容易带来什么问题?
75. 那么多消息队列,为什么选 RabbitMQ 呢?
这里就得把常见的消息队列都列出来进行对比,如果是做技术选型的话,这也是必须要考察的。短期为了面试,长期为了在做架构时的技术选型。
75. RabbitMQ 中什么是死信队列?
DLX=Dead-Letter-Exchange。利用 DLX,当消息在一个队列中变成死信(dead message)之后,它能被重新 publish 到另一个 Exchange,这个 Exchange 就是 DLX。
从字面上就看得出,死信就是无法被消费的消息。producer 将消息传给 broker 或者 queue 中,consumer 从 queue 取出消息进行消费。但是在某些特殊情况下会导致 queue 中部分消息无法被 consumer 消费,这样一直没有被消费的消息就变成了私信,既然有私信了,相应也有了死信队列。
消息变成死信一般有以下几种情况:
- 消息被拒绝(basic.reject/basic.nack)并且 requeue=false。
- 消息 TTL 过期(消息超时进入死信队列)。
- 队列达到最大长度。
76. 如何处理死信队列?
关于死信的出现既然不可避免,那么就需要从实际业务场景去考虑这个问题。对这些私信怎么处理,常见的处理办法有以下几种:
- 简单粗暴的丢弃,这是针对那些不是很重要的消息,可以有选择地丢弃;
- 把死信记录在数据里,然后针对业务进行分析怎么处理这些死信;
- 通过死信队列,由负责监听死信队列的应用程序进行处理(推荐)。
77. 怎么保证消息不会被丢失?
有三种场景会导致消息丢失:
总结为:生产者搞丢数据、RabbitMQ 搞丢数据、消费者搞丢数据。
生产者搞丢数据
事务功能机制
使用 RabbitMQ 的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务 channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务 channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务 channel.txCommit。
但是这种方案是存在问题的,即 RabbitMQ 事务机制(同步)一搞,基本上吞吐量会下来,因为太耗性能。
confirm 机制
在生产者那里设置开启 confirm 模式后,你每次写的消息都会分配一个唯一的 ID。如果写入 RabbitMQ 成功后会回传一个 ack 消息,告诉你这个消息已经到达 RabbitMQ 了;如果没收到你的消息或者失败了,则会回调你的一个 nack,告诉你这个消息 RabbitMQ 接受失败,然后你就可以继续重试发送,而且你可以结合这个机制在内存里维护一个 ID 的状态。如果超过一定时间没收到回调,那么就可以再次发送消息。所以一般在生产者这方避免数据丢失,都是使用 confirm 机制。
事务机制 PK confirm 模式
事务机制是同步的,提交事务后会阻塞在那里等待。
confirm 是异步的,发送这个消息后就可以发送下一个消息了。消息被 RabbitMQ 接收之后会异步回调一个通知,告知你这个消息已接收到了。
RabbitMQ 搞丢数据
RabbitMQ 接收到消息,默认是放在内存里,如果系统挂了或者重启,那对应的消息就会丢失。所以选择开启持久化,把消息写入磁盘中,这样就算系统挂了或者重启都不会丢失消息。
持久化的两个步骤:
- 创建 queue 的时候将其设置为持久化,这样就可以保证 RabbitMQ 持久化 queue 的元数据,但是它不会持久化 queue 里的数据。
- 将消息 deliveryMode=2,即将消息设置为持久化,此时 RabbitMQ 会将消息持久化到磁盘中去。
上面两个持久化必须同时设置才行。这个 RabbitMQ 就算挂了,再次重启的时候也会从磁盘上重启恢复 queue 和 queue 里的数据。
持久化和 confirm 模式一起使用,就算在消息持久化之前,RabbitMQ 挂了、数据丢了、生产者收不到 ack 的通知时,咱们也可以选择重新发送数据。
消费端搞丢消息数据
消费端代码中可能有 bug,异常没有处理导致消费失败,或者系统重启、挂了等,那么 RabbitMQ 认为咱们已经消费了,所以对应消息数据就会丢失了。
这时候我们就得使用 RabbitMQ 的 ack 机制。得把自动 ack 关闭,有个 api 直接调用,然后在自己代码里,确保消费者真的成功消费完成后,再进行一个手动 ack。
**总结 **
78. RabbitMQ 怎么高可用呢?
单机模式
不属于高可用,单机模式就是启动单个 RabbitMQ 节点,一般用于本地开发或者测试环境。实际生产上,基本不会使用这种单节点模式。
普通集群模式
不属于高可用,普通集群模式就是在多台机器上启动多个 RabbitMQ 实例,每个机器各自启动一个。
你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都是同步 queue 的元数据(元数据可以认为是 queue 的一些配置基本信息,通过元数据,可以找到 queue 所在的实例)。
你消费的时候,如果连接到另外一个实例,那个实例就会从 queue 所在的实例上把数据拉取过来。
上面这种普通集群方式确实很麻烦,给人的感觉不是很好,没有做到真正的分布式,就是一个普通的集群。
因为这导致要么消费者每次都链接一个实例然后拉取消息数据,要么固定连接那个 queue 所在实例消费数据。前者有数据拉取的开销,后者导致实例性能瓶颈。
如果消息放的 queue 挂了,会导致接下来其他实例无法从该实例上拉取消息数据。如果开启了消息持久化,让 RabbitMQ 本地持久化,消息不一定会丢。得等到这个实例重启恢复后,才可以继续从这个 queue 上拉取消息数据。
所以上面这种模式,就没有所谓的高可用。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务 queue 的读写操作。
镜像集群模式
高可用模式,镜像集群模式才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你所创建的 queue,无论元数据还是 queue 里的消息数据都会存在于多个实例上。也就是说,每个 RabbitMQ 都有 queue 的一个完整镜像,包含 queue 的全部数据。每次写消息到 queue 的时候,都会自动把消息数据同步到多个实例的 queue 上。
那么,要如何才能开启这个镜像集群模式呢?其实很简单,RabbitMQ 有很好的管理控制台,在后台新增一个策略,这个策略就是镜像集群模式的策略。可以要求数据同步到所有节点,也可以要求同步到n个节点上。再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的结点上了。
可以看到,不管任何一个机器挂了、宕了,都没影响。其他机器或者节点上还包括这个 queue 的完整数据,其他消费者都可从其他节点上去拉取消息数据进行消费。但有坏处:
- 第一,性能开销太大,消息需要同步到所有机器或者很多机器上,会导致网络带宽压力和消耗很重;
- 第二,这样不是分布式,没有扩展性可言。如果每个 queue 负载很重,添加机器或新增的机器也包含这个 queue 的所有数据,并没办法做线性扩展。如果这个 queue 的数据量很大,大到这个机器上的容量无法容纳,此时问题就更多了。
所以,以上三种模式都不是绝对的高可用模式,只是相对的。
79. RabbitMq 怎么保证消息的顺序性?
与 Kafka 和 RocketMQ 不同,Kafka 不存在类似 Topic 的概念,而是真正的一条一条队列,并且每个队列可以被多个消费者拉取消息。这是一个非常大的差异。
在 RabbitMQ 中,一个 queue,多个 consumer。比如:生产者向 RabbitMQ 里发送了三条数据,顺序依次为是 data1/data2/data3,压入的是 RabbitMQ 的内存队列里。有三个消费者分别从 RabbitMQ 中消费三条数据中的一条,结果消费者 2 先执行完操作,把 data2 存入数据库,然后是 data1/data3。这不明显乱了,没有按照顺序消费消息。
应对上面的顺序消费问题有两种方案:
方案一
拆分多个 queue,每个 queue 对应一个 consumer,就是多一些 queue 而已,确实也麻烦了些。
这种方式,有点类似于 Kafka 和 RocketMQ 中 Topic 的概念。比如说,原先一个 queue 叫 aaa,那么多个 queue,我们就可以搞成 aaa-01,aaa-02,aaa-03 等,相同前缀,不同后缀。
方法二
就一个 queue,但对应一个 consumer,consumer 内部用内存做队列、做排队,然后分发给底层不同的 worker 来处理。这种方式就是将一个 queue 里的,相同 key 交给同一个 worker 来执行。因为 RabbitMQ 是可以单条消息来 ack,所以较为方便。
从上面两个方案可以看出,前提都是一个 queue 只能启动一个 consumer 对应。
80. 如果有大量消息持续积压在队列了,怎么处理?
通常遇到这种情况,只能是搞个临时扩容,具体步骤和思路如下:
- 先修复消费者的 bug,确保其回复消费速度,然后将现有的消费者都停了;
- 临时建好原先 10 倍或者更多的 queue;
- 写一个临时分发数据的消费者,这个程序部署消费积压的数据,消费后不做任何耗时的处理,直接均匀轮训写入临时建好的 queue 里;
- 临时启动对应 queue 数量的消费者,对新建的 queue 里的消息进行消费。比如说以前只有一台消费者,那么此时的消费者可以是 10 个或者更多,这样处理积压数据的速度会快很多;
- 等快速消费完积压消息数据后,把修复好的消费者继续使用(继续原来的架构),归还相应的服务器资源。
下面大致搞一个图来表示。
解决之前的架构一:
解决消息堆积的临时架构二:
问题处理完了,还得把架构改成架构一。
七、Redis 篇
81. 为什么要用缓存
使用缓存的目的就是提升读写性能。而实际业务场景下,更多的是为了带来更好的性能、更高的并发量。Redis 的读写性能比 MySQL 好的多,我们就可以把 MySQL 中的热点数据缓存到 Redis 中,提升读取性能,同时也减轻了 MySQL 的读取压力。
82. 为什么 使用 Redis 而不是用 Memcached 呢?
这时候可以回答 Memcached 与 Redis 区别:
- Redis 和 Memcached 都是将数据存放在内存中,都是内存数据库。不过 Memcached 还可用于缓存其他东西,例如图片、视频等等。
- Memcached 仅支持 key-value 结构的数据类型,Redis 不仅仅支持简单的 key-value 类型的数据,同时还提供 list、set、hash 等数据结构的存储。
- 虚拟内存:Redis 当物理内存用完时,可以将一些很久没用到的 value 交换到磁盘。
- 分布式:设定 Memcached 集群,利用 magent 做一主多从;Redis 可以做一主多从。都可以一主一从
- 存储数据安全:Memcached 挂掉后,数据没了;Redis 可以定期保存到磁盘(持久化)
- Memcached 的单个 value 最大 1m,Redis 的单个 value 最大 512m。
- 灾难恢复:Memcached 挂掉后,数据不可恢复; Redis 数据丢失后可以通过 aof 恢复。
- Redis 原生就支持集群模式,Redis 3.0 版本中,官方便能支持 Cluster 模式了,Memcached 没有原生的集群模式,需要依赖客户端来实现,然后往集群中分片写入数据。
- Memcached 网络 IO 模型是多线程,非阻塞 IO 复用的网络模型,原型上接近于 nignx。而 Redis 使用单线程的 IO 复用模型,自己封装了一个简单的 AeEvent 事件处理框架,主要实现类 epoll、kqueue 和 select,更接近于 Apache 早期的模式。
83. 为什么 Redis 单线程模型效率也能那么高?
- C 语言实现,效率高
- 纯内存操作
- 基于非阻塞的 IO 复用模型机制
- 单线程的话就能避免多线程的频繁上下文切换问题
- 丰富的数据结构(全称采用 hash 结构,读取速度非常快。对数据存储进行了一些优化,比如亚索表、跳表等)
84. 说说 Redis 的线程模型
问这问题是因为前面回答的时候提到了“Redis 是基于非阻塞的 IO 复用模型”。如果这个问题回答不上来,就相当于前面的回答是给自己挖坑,面试官对你的印象可能也会打点折扣。
Redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,根据 socket 上的事件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:
- 多个 socket
- IO 多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件。但是 IO 多路复用程序会监听多个 socket,会将 socket 产生的事件放入队列中排队。事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。
来看客户端与 Redis 的一次通信过程:
下面来大致说一下这个图:
- 客户端 Socket01 向 Redis 的 Server Socket 请求建立连接,此时 Server Socket 会产生一个 AEREADABLE 事件。IO 多路复用程序监听到 server socket 产生的事件后,将该事件压入队列中。文件事件分派器从队列中获取该事件,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 Socket01,并将该 Socket01 的 AEREADABLE 事件与命令请求处理器关联。
- 假设客户端发送了一个 set key value 请求,此时 Redis 中的 Socket01 会产生 AEREADABLE 事件,IO 多路复用程序将事件压入队列,事件分派器从队列中获取到该事件。由于前面 Socket01 的 AEREADABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 Socket01 的 set key value 并在自己内存中完成 set key value 的设置。操作完成后,它会将 Socket01 的 AE_WRITABLE 事件与令回复处理器关联。
- 如果此时客户端准备好接收返回结果了,那么 Redis 中的 Socket01 会产生一个 AEWRITABLE 事件,同样压入队列中。事件分派器找到相关联的命令回复处理器,由命令回复处理器对 Socket01 输入本次操作的一个结果(比如 ok)之后解除 Socket01 的 AEWRITABLE 事件与命令回复处理器的关联。
这样便完成了一次通信。不要怕这段文字,结合图看,一遍不行两遍,实在不行可以网上查点资料结合着看。一定要搞清楚,否则前面吹的牛逼就白费了。
85. 说一下 Redis 有什么优点和缺点
优点
- 速度快:因为数据存在内存中,类似于 HashMap,HashMap 的优势就是查找和操作的时间复杂度都是 O(1)。
- 支持丰富的数据结构:支持 String 、List、Set、Sorted Set、Hash 五种基础的数据结构。
- 持久化存储:Redis 提供 RDB 和 AOF 两种数据的持久化存储方案,解决内存数据库最担心的“万一 Redis 挂掉,数据会消失掉”的问题。
- 高可用:内置 Redis Sentinel,提供高可用方案,实现主从故障自动转移。内置 Redis Cluster,提供集群方案,实现基于槽的分片方案,从而支持更大的 Redis 规模。
- 丰富的特性:Key 过期、计数、分布式锁、消息队列等。
缺点
- 由于 Redis 是内存数据库,所以,单台机器存储的数据量,跟机器本身的内存大小有关。虽然 Redis 本身有 Key 过期策略,但还是需要提前预估和节约内存。如果内存增长过快,需要定期删除数据。
- 如果进行完整重同步,由于需要生成 RDB 文件,并进行传输。这会占用主机的 CPU,并会消耗现网的带宽。不过 Redis2.8 版本,已经有部分重同步的功能,但还是有可能有完整重同步的,比如:新上线的备机。
- 修改配置文件,进行重启,将硬盘中的数据加载进内存,时间比较久。在这个过程中,Redis 不能提供服务。
86. Redis 缓存刷新策略有哪些?
87. Redis 持久化方式有哪些?以及有什么区别?
Redis 提供两种持久化机制 RDB 和 AOF 机制:
RDB 持久化方式
指用数据集快照的方式半持久化模式记录 redis 数据库的所有键值对,在某个时间点将数据写入一个临时文件。持久化结束后,用这个临时文件替换上次持久化的文件,达到数据恢复。
优点:
- 只有一个文件 dump.rdb,方便持久化
- 容灾性好,一个文件可以保存到安全的磁盘
- 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以使 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis 的高性能
- 相对于数据集大时,比 AOF 的启动效率更高
缺点:
数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化期间 Redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。
AOF=Append-only file 持久化方式
是指所有的命令行记录,以 Redis 命令请求协议的格式完全持久化存储,保存为 AOF 文件。
优点:
- 数据安全。AOF 持久化可以配置 appendfsync 属性,有 always,每进行一次命令操作就记录到 AOF 文件中一次。
- 通过 append 模式写文件。即使中途服务器宕机,也可以通过 redis-check-aof 工具解决数据一致性问题。
- AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令进行合并重写),可以删除其中的某些命令(比如误操作的 flushall)。
缺点:
- AOF 文件比 RDB 文件大,且恢复速度慢。
- 数据集大的时候,比 RDB 启动效率低。
88. 持久化有两种,那应该怎么选择呢?
- 不要仅仅使用 RDB,因为那样会导致丢失很多数据。
- 也不要仅仅使用 AOF,因为那样有两个问题:第一,通过 AOF 做冷备,没有 RDB 做冷备,来的恢复速度更快; 第二,RDB 每次简单粗暴生成数据快照,更加健壮,可以避免 AOF 这种复杂的备份和恢复机制的 bug。
- Redis 支持同时开启两种持久化方式。我们可以综合使用 AOF 和 RDB 两种持久化机制,用 AOF 来保证数据不丢失,作为数据恢复的第一选择;用 RDB 来做不同程度的冷备,在 AOF 文件都丢失或损坏不可用的时候,还可以使用 RDB 来进行快速的数据恢复。
- 如果同时使用 RDB 和 AOF 两种持久化机制,那在 Redis 重启的时候,会使用 AOF 来重新构建数据,因为 AOF 中的数据更加完整。
89. 怎么使用 Redis 实现消息队列?
一般使用 list 结构作为队列,rpush 生产消息,lpop 消费消息。当 lpop 没有消息的时候,要适当 sleep 一会再重试。
- 面试官可能会问可不可以不用 sleep 呢?list 还有个指令叫 blpop,在没有消息的时候,它会阻塞住直到消息到来。
- 面试官可能还问能不能生产一次消费多次呢?使用 pub /sub 主题订阅者模式,可以实现 1:N 的消息队列。
- 面试官可能还问 pub /sub 有什么缺点?在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列,如 RabbitMQ 等。
- 面试官可能还问 Redis 如何实现延时队列?我估计现在你很想把面试官一棒打死,怎么问的这么详细。但是你会很克制,然后神态自若地回答道:使用 sortedset,拿时间戳作为 score,消息内容作为 key 调用 zadd 来生产消息,消费者用 zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。
面试扩散:很多面试官上来就直接这么问: Redis 如何实现延时队列?
90. 熟悉哪些 Redis 集群模式?
\1. Redis Sentinel
体量较小时,选择 Redis Sentinel,单主 Redis 足以支撑业务。
\2. Redis Cluster
Redis 官方提供的集群化方案,体量较大时,选择 Redis Cluster,通过分片,使用更多内存。
\3. Twemprox
Twemprox 是 Twtter 开源的一个 Redis 和 Memcached 代理服务器,主要用于管理 Redis 和 Memcached 集群,减少与 Cache 服务器直接连接的数量。
\4. Codis
Codis 是一个代理中间件,当客户端向 Codis 发送指令时,Codis 负责将指令转发到后面的 Redis 来执行,并将结果返回给客户端。一个 Codis 实例可以连接多个 Redis 实例,也可以启动多个 Codis 实例来支撑,每个 Codis 节点都是对等的,这样可以增加整体的 QPS 需求,还能起到容灾功能。
\5. 客户端分片
在 Redis Cluster 还没出现之前使用较多,现在基本很少人使用了。在业务代码层实现,启动几个毫无关联的 Redis 实例,在代码层,对 Key 进行 hash 计算,然后去对应的 Redis 实例操作数据。这种方式对 hash 层代码要求比较高,考虑部分包括节点、失效后的替代算法方案、数据震荡后的自动脚本恢复、实例的监控等等。
91. 缓存和数据库谁先更新呢?
解决方案
- 写请求过来,将写请求缓存到缓存队列中,并且开始执行写请求的具体操作(删除缓存中的数据、更新数据库、更新缓存)。
- 如果在更新数据库过程中,又来了个读请求,将读请求再次存入到缓存队列(可以搞 n 个队列,采用 key 的 hash 值进行队列个数取模 hash% n,落到对应的队列中,队列需要保证顺序性)中。顺序性保证等待队列前的写请求执行完成,才会执行读请求之前的写请求。删除缓存失败,直接返回。此时数据库中的数据是旧值,并且与缓存中的数据是一致的,不会出现缓存一致性的问题。
- 写请求删除缓存成功,则更新数据库,如果更新数据库失败,则直接返回,写请求结束,此时数据库中的值依旧是旧值,读请求过来后,发现缓存中没有数据, 则会直接向数据库中请求,同时将数据写入到缓存中,此时也不会出现数据一致性的问题。
- 更新数据成功之后,再更新缓存。如果此时更新缓存失败,则缓存中没有数据,数据库中是新值,写请求结束,此时读请求还是一样。发现缓存中没有数据,同样会从数据库中读取数据,并且存入到缓存中。其实这里不管更新缓存成功还是失败, 都不会出现数据一致性的问题。
上面这方案解决了数据不一致的问题,主要是使用了串行化,每次操作进来必须按照顺序进行。如果某个队列元素积压太多,可以针对读请求进行过滤,提示用户刷新页面,重新请求。
潜在的问题,留给大家自己去想了,因为这属于发散性问题。如下给出两点思考提示:
- 请求时间过长,大量的写请求堆压在队列中,一个读请求过来,得等都写完了才可以获取到数据。
- 热点数据路由问题,导致请求倾斜。
八、Spring Boot 篇
92. Spring Boot 提供了哪些核心功能?
独立运行 Spring 项目
Spring Boot 可以以 jar 包形式独立运行,运行一个 Spring Boot 项目只需要通过 java -jar xx.jar 来运行。
内嵌 Servlet 容器
Spring Boot 可以选择内嵌 Tomcat、Jetty 或者 Undertow,这样我们无须以 war 包形式部署项目。
提供 Starter 简化 Maven 配置
Spring 提供了一系列的 starter pom 来简化 Maven 的依赖加载,比如:spring-boot-starter-web。
自动配置 Spring Bean
Spring Boot 检测到特定类的存在,就会针对这个应用做一定的配置,进行自动配置 Bean,这样会极大地减少我们要使用的配置。当然,Spring Boot 只考虑大多数的开发场景,并不是所有的场景,若在实际开发中我们需要配置 Bean,而 Spring Boot 没有提供支持,则可以自定义自动配置进行解决。
准生产的应用监控
Spring Boot 提供基于 HTTP、JMX、SSH 对运行时的项目进行监控。
无代码生成和 XML 配置
Spring Boot 没有引入任何形式的代码生成,它使用的是 Spring 4.0 的条件 @Condition 注解以实现根据条件进行配置。同时使用了 Maven /Gradle 的依赖传递解析机制来实现 Spring 应用里面的自动配置。
93. Spring Boot 核心注解是什么?
package cn.tian.spring.boot.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
@SpringBootApplication 注解,就是 Spring Boot 的核心注解。
@SpringBootApplication 源码:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan( excludeFilters = {@Filter( type = FilterType.CUSTOM, classes = TypeExcludeFilter.class}),
@Filter( type = FilterType.CUSTOM,classes = {AutoConfigurationExcludeFilter.class})})
public @interface SpringBootApplication {
@AliasFor( annotation = EnableAutoConfiguration.class)
Class<?>[] exclude() default {};
@AliasFor(annotation = EnableAutoConfiguration.class)
String[] excludeName() default {};
@AliasFor(annotation = ComponentScan.class,attribute = "basePackages")
String[] scanBasePackages() default {};
@AliasFor(annotation = ComponentScan.class,attribute = "basePackageClasses")
Class<?>[] scanBasePackageClasses() default {};
}
这个注解主要由三个注解组合:
- @Configuration 注解,指定类是 Bean 定义的配置类。来自 spring-context 项目,用于 Java Config,不是 Spring Boot 新带来的。
- @ComponentScan 注解,扫描指定包下的 Beans。来自 spring-context 项目,用于 Java Config,不是 Spring Boot 新带来的。
- @EnableAutoConfiguration 注解,打开自动配置的功能。如果我们想要关闭某个类的自动配置,可以设置注解的 exclude 或 excludeName 属性。来自 spring-boot-autoconfigure 项目,它才是 Spring Boot 新带来的。
94. 说说 Spring Boot 的自动装配原理
- 使用 @EnableAutoConfiguration 注解,打开 Spring Boot 自动配置的功能。
- Spring Boot 在启动时扫描项目所依赖的 jar 包,寻找包含 spring.factories 文件的 jar 包。
- 根据 spring.factories 配置加载 AutoConfigure 类。
- 根据 @Conditional 等条件注解 的条件,进行自动配置并将 Bean 注入 Spring IoC 中。
95. Spring Boot 常用 starter 有哪些?
- spring-boot-starter-web:嵌入 Tomcat 和 web 开发需要的相关 jar 包
- spring-boot-starter-data-redis:redis 数据库支持
- mybatis-spring-boot-starter:第三方的 MyBatis 集成的 starter
- spring-boot -starter-test:用于测试 Spring 引导应用程序
- spring-boot-starter-AOP:这个 starter 用于使用 AspectJ 和 Spring AOP 进行面向方面的编程
太多了 starter 了,这里只是例举几个。
96. Spring 中的 starter 是什么?
依赖管理是任何复杂项目的关键部分。以手动的方式来实现依赖管理不太现实,你得花更多时间,这样你在项目其他重要的方面能付出的时间就会变得越少。Starter 主要用来简化依赖用的。
比如我们之前做 MVC 时要引入日志组件,那么需要去找到 log4j 的版本,然后引入。现在有了 Starter 之后,直接用这个之后,log4j 就自动引入了,也不用关心版本这些问题。
比如我们要在 Spring Boot 中引入 Web MVC 的支持时,我们通常会引入这个模块 spring-boot-starter-web,而这个模块如果解压出来会发现里面什么都没有,只定义了一些 POM 依赖。
97. Spring Boot 有什么优缺点?
98. 读取配置文件中配置项的有哪些方法?
九、Spring 篇
99. Spring 中 ApplicationContext 和 BeanFactory 的区别
类图
包目录不同
- spring-beans.jar 中 org.springframework.beans.factory.BeanFactory
- spring-context.jar 中 org.springframework.context.ApplicationContext
国际化
BeanFactory 是不支持国际化功能的,因为 BeanFactory 没有扩展 Spring 中 MessageResource 接口。相反,由于 ApplicationContext 扩展了 MessageResource 接口,因而具有消息处理的能力(i18N)。
强大的事件机制(Event)
基本上牵涉到事件(Event)方面的设计,就离不开观察者模式,ApplicationContext 的事件机制主要通过 ApplicationEvent 和 ApplicationListener 这两个接口来提供的,和 Java swing 中的事件机制一样。即当 ApplicationContext 中发布一个事件时,所有扩展了 ApplicationListener 的 Bean 都将接受到这个事件,并进行相应的处理。
底层资源的访问
ApplicationContext 扩展了 ResourceLoader(资源加载器)接口,从而可以用来加载多个 Resource,而 BeanFactory 是没有扩展 ResourceLoader。
对 Web 应用的支持
与 BeanFactory 通常以编程的方式被创建,ApplicationContext 能以声明的方式创建,如使用 ContextLoader。
当然你也可以使用 ApplicationContext 的实现方式之一,以编程的方式创建 ApplicationContext 实例。
延迟加载
- BeanFactroy 采用的是延迟加载形式来注入 Bean 的,即只有在使用到某个 Bean 时(调用 getBean()),才对该 Bean 进行加载实例化。这样,我们就不能发现一些存在的 spring 的配置问题。而 ApplicationContext 则相反,它是在容器启动时,一次性创建了所有的 Bean。这样,在容器启动时,我们就可以发现 Spring 中存在的配置错误。
- BeanFactory 和 ApplicationContext 都支持 BeanPostProcessor、BeanFactoryPostProcessor 的使用。两者之间的区别是:BeanFactory 需要手动注册,而 ApplicationContext 则是自动注册。
可以看到,ApplicationContext 继承了 BeanFactory,BeanFactory 是 Spring 中比较原始的 Factory,它不支持 AOP、Web 等 Spring 插件。而 ApplicationContext 不仅包含了 BeanFactory 的所有功能,还支持 Spring 的各种插件,还以一种面向框架的方式工作以及对上下文进行分层和实现继承。
BeanFactory 是 Spring 框架的基础设施,面向 Spring 本身;而 ApplicationContext 面向使用 Spring 的开发者,相比 BeanFactory 提供了更多面向实际应用的功能,几乎所有场合都可以直接使用 ApplicationContext,而不是底层的 BeanFactory。
常用容器
BeanFactory 类型的有 XmlBeanFactory,它可以根据 XML 文件中定义的内容,创建相应的 Bean。
ApplicationContext 类型的常用容器有:
- ClassPathXmlApplicationContext:从 ClassPath 的 XML 配置文件中读取上下文,并生成上下文定义。应用程序上下文从程序环境变量中取得。
- FileSystemXmlApplicationContext:由文件系统中的 XML 配置文件读取上下文。
- XmlWebApplicationContext:由 Web 应用的 XML 文件读取上下文。例如我们在 Spring MVC 使用的情况。
100. 说一下你对 Spring IOC 的理解
101. Spring IOC 有什么优点?
102. Bean 的生命周期
这个题目在面试的时候被问到的概率很大,主要考察咱们对 bean 的整个生命周期的了解程度,其实平时工作中很少需要你关注 bean 的生命周期。
Bean 的声明周期为 bean 的创建、应用、销毁。
创建过程
- 实例化 Bean 对象;
- 设置 Bean 属性;
- 如果我们通过各种 Aware 接口声明了依赖关系,则会注入 Bean 对容器基础设施层面的依赖。具体包括 BeanNameAware、BeanFactoryAware 和 ApplicationContextAware,分别会注入 Bean ID、Bean Factory 或者 ApplicationContext;
- 调用 BeanPostProcessor 的前置初始化方法 postProcessBeforeInitialization;
- 如果实现了 InitializingBean 接口,则会调用 afterPropertiesSet 方法;
- 调用 Bean 自身定义的 init 方法;
- 调用 BeanPostProcessor 的后置初始化方法 postProcessAfterInitialization;
- 创建过程完毕。
应用
销毁过程
销毁过程会依次调用 DisposableBean 的 destroy 方法和 Bean 自身定制的 destroy 方法。
网上找到一张图:
103. Spring Bean 的作用域有哪些?
注:网络上很多文章说有 Global-session 级别,它是 Portlet 模块独有,目前已经废弃,在 Spring 5 中是找不到的。
104. Spring 是怎么管理事务的?
105. 说说你对 Spring AOP 的理解
AOP 的设计:
- 每个 Bean 都会被 JDK 或者 CGlib 代理,取决于是否有接口;
- 每个 Bean 会有很多方法拦截器,注意这里的拦截器分为两层,外层有 Spring 内核控制流程,内层拦截器是用户设置,也就是 AOP;
- 当代理方法被调用时候,先经过外层拦截器,外层拦截器根据方法的各种信息判断该方法应该执行哪些“内层拦截器”。内层拦截器的设计就是职责链的设计。
整体分析
\1. 代理的创建
- 首先,需要创建代理工厂,代理工厂需要 3 个重要的信息:拦截器数组、模板对象接口数组、目标对象。
- 创建代理工厂时,默认会在拦截器数组尾部再增加一个默认拦截器,用于最终的调用目标方法。
- 当调用 getProxy 方法的时候,会根据接口数量大于 0 条件,返回一个代理对象(JDK 或者 CGlib)。
注意:创建代理对象时,同时会创建一个外层拦截器,这个拦截器就是 spring 内核的拦截器,用于控制整个 AOP 流程。
\2. 代理的调用
- 当对代理对象进行调用时,就会触发外层拦截器。
- 外层拦截器根据代理配置信息,创建内层拦截器,创建的过程中,会根据表达式判断当前拦截是否匹配这个拦截器,而这个拦截器链设计模式就是职责链模式。
- 当整个链条执行到最后时,就会触发创建代理时那个尾部的默认拦截器,从而调用目标方法,最后返回。
咋一看觉得动态代理和装饰器模式很像,都是功能增加,但是,这两个模式的本质是不一样的,代理重在访问权限的控制,装饰器模式重在动态地对原本的功能进行改变。
106. Spring 中用到了哪些设计模式?
简单工厂模式:Spring 中的 BeanFactory 就是简单工厂模式的体现。根据传入一个唯一的标识来获得 Bean 对象,但是在传入参数后创建还是传入参数前创建,要根据具体情况来定。
工厂模式:Spring 中的 FactoryBean 就是典型的工厂方法模式,实现了 FactoryBean 接口的 bean 是一类叫做 factory 的 bean。其特点是,spring 在使用 getBean() 调用获得该 bean 时,会自动调用该 bean 的 getObject() 方法,所以返回的不是 factory 这个 bean,而是这个 bean.getOjbect() 方法的返回值。
单例模式:在 spring 中用到的单例模式有:scope="singleton"
,注册式单例模式,bean 存放于 Map 中。bean name 当做 key,bean 当做 value。
原型模式:在 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() 方法的处理使用到了责任链模式。
107. Spring 框架中的单例 Bean 是线程安全的么?
Spring 框架并没有对单例 Bean 进行任何多线程的封装处理。
- 关于单例 Bean 的线程安全和并发问题,需要开发者自行去搞定。
- 单例的线程安全问题,并不是 Spring 应该去关心的。Spring 应该做的是,提供根据配置,创建单例 Bean 或多例 Bean 的功能。
当然,但实际上,大部分的 Spring Bean 并没有可变的状态,所以在某种程度上说 Spring 的单例 Bean 是线程安全的。如果你的 Bean 有多种状态的话,就需要自行保证线程安全。最浅显的解决办法,就是将多态 Bean 的作用域(Scope)由 Singleton 变更为 Prototype。
108. Spring 是怎么解决循环依赖的?
整个流程大致如下:
- 首先 A 完成初始化第一步并将自己提前曝光出来(通过 ObjectFactory 将自己提前曝光),在初始化的时候,发现自己依赖对象 B,此时就会去尝试 get(B),这个时候发现 B 还没有被创建出来;
- 然后 B 就走创建流程,在 B 初始化的时候,同样发现自己依赖 C,C 也没有被创建出来;
- 这个时候 C 又开始初始化进程,但是在初始化的过程中发现自己依赖 A,于是尝试 get(A)。这个时候由于 A 已经添加至缓存中(一般都是添加至三级缓存 singletonFactories),通过 ObjectFactory 提前曝光,所以可以通过 ObjectFactory#getObject() 方法来拿到 A 对象。C 拿到 A 对象后顺利完成初始化,然后将自己添加到一级缓存中;
- 回到 B,B 也可以拿到 C 对象,完成初始化,A 可以顺利拿到 B 完成初始化。到这里整个链路就已经完成了初始化过程了。
十、ZooKeeper 篇
109. 说说 ZooKeeper 是什么?
ZooKeeper 是一个开放源码的分布式协调服务,它是集群的管理者,监视着集群中各个节点的状态。根据节点提交的反馈进行下一步合理操作,最终将简单易用的接口和性能高效、功能稳定的系统提供给用户。ZooKeeper 是 Chubby 的开源实现,使用 ZAB 协议(Paxos 算法的变种)。
分布式应用程序可以基于 ZooKeeper 实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。
110. ZooKeeper 有哪些应用场景?
统一命名服务
命名服务是指通过指定的名字来获取资源或服务的地址,利用 ZooKeeper 创建一个全局的路径,即时唯一的路径。这个路径就可以作为一个名字,指向集群中机器或者提供服务的地址,又或者一个远程的对象等。
分布式服务
相对来说,这个功能使用还是比较广泛的。ZooKeeper 实现的分布式锁的可靠性比 Redis 实现的高,当然相对性能来说,ZooKeeper 性能稍弱,但其实已经很牛了。
配置管理
Spring Cloud Config ZooKeeper 就是基于 ZooKeeper 来实现的,提供配置中心的服务。
注册与发现
是否有新的机器加入或者是有机器退出(挂了)。所有机器约定在父目录下创建临时节点,然后监听父节点下的子节点变化。一旦有机器挂机,该机器与 ZooKeeper 的链接断开,其所创建的临时目录节点也被删除,所有其他机器都收到对应的通知:某个结点被删除了。Dubbo 就是典型应用案例。
Master 选举
基于 ZooKeeper 实现分布式协调,从而实现主从的选举。比如:Kafka、Elastic-job 等中间件都有使用到。
队列管理
ZooKeeper 有两种类型的队列:
同步队列:当一个队列的成员都聚齐时,这个队列才可用,否则一直等待。在约定的目录下创建临时目录节点,再监听节点数目是否是我们要求的数据。
队列按照先进先出 FIFO 方式进行入队和出队操作。和分布式锁服务中心的控制时序的场景基本原理相同,入列和出列都有编号。创建 PERSISTENT_SEQUENTIAL 节点,创建成功时 Watcher 通知等待的队列,队列删除序列号最小的节点以消费。此场景下,znode 用于消息存储,znode 存储的数据就是消息队列中的消息内容,SEQUENTIAL 序列号就是消息的编号,按序取出即可。由于创建的节点是持久化的,所以不必担心队列消息丢失的问题。
分布式锁
有了 ZooKeeper 的一致性文件系统,锁的问题就变得简单多了。锁服务可以分为保持独占和控制时序。
- 保持独占。我们把 znode 看作是一把锁,通过 createZnode 的方式来实现。所有客户端都去创建 /distribute_lock 节点,最终成功创建的那个客户端也即拥有了这把锁,用完删除掉自己创建的 /distribute_lock 节点就释放出锁。
- 控制时序。/distribute_lock 已经预先存在,所有客户端在它下面创建临时顺序编号目录节点,和 Master 一样,编号最小的获得锁,用完删除,依次方便。
111. ZooKeeper 有哪些节点类型?
112. 请描述一下 ZooKeeper 的通知机制是什么?
ZooKeeper 允许客户端向服务端的某个 znode 注册一个 Watcher 监听,当服务端的一些指定事件触发了这个 Watcher,服务端会向指定客户端发送一个事件通知来实现分布式的通知功能,然后客户端根据 Watcher 通知状态和事件类型做出业务上的改变。
大致分为三个步骤:
客户端注册 Watcher
- 调用 getData、getChildren、exist 三个 API,传入 Watcher 对象;
- 标记请求 request,封装 Watcher 到 WatchRegistration;
- 封装成 Packet 对象,发服务端发送 request;
- 收到服务端响应后,将 Watcher 注册到 ZKWatcherManager 中进行管理;
- 请求返回,完成注册。
服务端处理 Watcher
- 务端接收 Watcher 并存储;
- Watcher 触发;
- 调用 process 方法来触发 Watcher。
客户端回调 Watcher
- 客户端 SendThread 线程接收事件通知,交由 EventThread 线程回调 Watcher。
- 客户端的 Watcher 机制同样是一次性的,一旦被触发后,该 Watcher 就失效了。
113. ZooKeeper 对节点的 watch 监听通知是永久的吗?
不是,一次性的。无论是服务端还是客户端,一旦一个 Watcher 被触发, ZooKeeper 都会将其从相应的存储中移除。这样的设计有效地减轻了服务端的压力,不然对于更新非常频繁的节点,服务端会不断地向客户端发送事件通知,无论对于网络还是服务端的压力都非常大。
114. ZooKeeper 集群中有哪些角色?
在一个集群中,最少需要 3 台。或者保证 2N+1 台,即奇数。为什么保证奇数?主要是为了选举算法。
115. ZooKeeper 集群中 Server 有哪些工作状态?
LOOKING
寻找 Leader 状态。当服务器处于该状态时,它会认为当前集群中没有 Leader,因此需要进入 Leader 选举状态。
FOLLOWING
跟随者状态。表明当前服务器角色是 Follower。
LEADING
领导者状态。表明当前服务器角色是 Leader。
OBSERVING
观察者状态。表明当前服务器角色是 Observer。
116. ZooKeeper 集群中是怎样选举 leader 的?
当 Leader 崩溃了,或者失去了大多数的 Follower,这时候 ZooKeeper 就进入恢复模式,恢复模式需要重新选举一个新的 Leader,让所有的 Server 都恢复到一个状态 LOOKING。
ZooKeeper 有两种选举算法:基于 basic paxos 实现和基于 fast paxos 实现。默认为 fast paxos。
由于篇幅问题,这里推荐:选举流程。
117. ZooKeeper 是如何保证事务的顺序一致性的呢?
- ZooKeeper 采用了递增的事务 id 来识别,所有的 proposal(提议)都在被提出的时候加上了 zxid 。zxid 实际上是一个 64 位数字。
- 高 32 位是 epoch 用来标识 Leader 是否发生了改变,如果有新的 Leader 产生出来,epoch 会自增。
- 低 32 位用来递增计数。
- 当新产生的 proposal 的时候,会依据数据库的两阶段过程,首先会向其他的 Server。
发出事务执行请求,如果超过半数的机器都能执行并且能够成功,那么就会开始执行。
118. ZooKeeper 集群中各服务器之间是怎样通信的?
Leader 服务器会和每一个 Follower/Observer 服务器都建立 TCP 连接,同时为每个 Follower/Observer 都创建一个叫做 LearnerHandler 的实体。
- LearnerHandler 主要负责 Leader 和 Follower/Observer 之间的网络通讯,包括数据同步,请求转发和 proposal 提议的投票等。
- Leader 服务器保存了所有 Follower/Observer 的 LearnerHandler。
119. ZooKeeper 分布式锁怎么实现的?
如果有客户端 1、客户端 2 等 N 个客户端争抢一个 ZooKeeper 分布式锁。大致如下:
- 大家都是上来直接创建一个锁节点下的一个接一个的临时有序节点;
- 如果自己不是第一个节点,就对自己上一个节点加监听器;
- 只要上一个节点释放锁,自己就排到前面去了,相当于是一个排队机制。
而且用临时顺序节点的另外一个用意就是,如果某个客户端创建临时顺序节点之后,不小心自己宕机了也没关系。ZooKeeper 感知到那个客户端宕机,会自动删除对应的临时顺序节点,相当于自动释放锁,或者是自动取消自己的排队。
十一、并发编程篇
120. 通常创建线程有几种方式?
创建线程的常用四种方式:
- 继承 Thread 类
- 实现 Runnable 接口
- 实现 Callable 接口(JDK1.5>=)
- 线程池方式创建
通过继承 Thread 类或者实现 Runnable 接口、Callable 接口都可以实现多线程,不过实现 Runnable 接口与实现 Callable 接口的方式基本相同,只是 Callable 接口里定义的方法返回值,可以声明抛出异常而已。因此将实现 Runnable 接口和实现 Callable 接口归为一种方式。这种方式与继承 Thread 方式之间的主要差别如下:
采用实现 Runnable、Callable 接口的方式创建线程的优缺点
- 优点:线程类只是实现了 Runnable 或者 Callable 接口,还可以继承其他类。这种方式下,多个线程可以共享一个 target 对象。所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
- 缺点:编程稍微复杂一些,如果需要访问当前线程,则必须使用 Thread.currentThread() 方法
采用继承 Thread 类的方式创建线程的优缺点:
- 优点:编写简单,如果需要访问当前线程,则无需使用 Thread.currentThread() 方法,直接使用 this 即可获取当前线程
- 缺点:因为线程类已经继承了 Thread 类,Java 语言是单继承的,所以就不能再继承其他父类了。
121. 说说线程的生命周期
先来看一张图:
这六个状态就对应线程的生命周期。下图为线程对应状态以及状态出发条件:
122. 说说 synchronized 的使用和原理
- 对于普通同步方法,锁是当前实例对象
- 对于静态同步方法,锁是当前类 Class 对象
- 对于同步方法块,锁是 Synchronized 括号里配置的对象(对象为普通对象则锁定的是该对象,对象为 Class 对象则锁定的是 Class 对象)
JVM 基于进入和退出 Monitor 对象来显示方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,而方法同步是使用另外一种方式实现的。
monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法的结束处和异常处,JVM 保证每个 monitorenter 必须有对应的 monitorexit 与之配对。
123. synchronized 和 ReentrantLock 区别
- synchronized 是关键字,ReentrantLock 是 JUC 下面的一个类。
- JDK 1.5 之前同步锁只有 synchronized。
- 都是可重入的同步锁。
- synchronized 只有非公平锁,ReentrantLock 默认为非公平锁,但是可以手动设置为公平锁。
- ReentrantLock 需要手动释放锁
try{--objectLock.lock();}---finally--{objectLock.unlock();}
,synchronized 隐形释放(方法或者代码块执行完、异常)。 - ReentrantLock 可中断,synchronized 不可中断,一个线程引用锁的时候,别的线程只能阻塞等待。
- ReentrantLock 和 synchronized 持有的对象监视器不同。
- ReentrantLock 能够将 wait/notify/notifyAll 对象化。synchronized 中,锁对象的 wait 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件。
- synchronized 和 ReentrantLock 的性能不能一概而论,早期版本 synchronized 在很多场景下性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于 ReentrantLock。
- ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制,比如可以控制 fairness,也就是公平性,或者利用定义条件等。但是,编码中也需要注意,必须要明确调用 unlock() 方法释放,不然就会一直持有该锁。
124. 什么是线程安全?
按照《Java 并发编程实战》(Java Concurrency in Practice) 的定义就是:线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性,这里的状态反映在程序中其实可以看作是数据。
通俗易懂的说法:当多个线程访问某个方法时,不管你通过怎样的调用方式或者说这些线程如何交替执行,我们在主程序中不需要做任何同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类是线程安全的。
125. 线程安全需要保证几个基本特征
- 原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
- 可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
- 有序性,是保证线程内串行语义,避免指令重排等。
126. 说一下线程之间是如何通信的?
线程之间的通信有两种方式:共享内存和消息传递。
共享内存
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信。
例如上图线程 A 与 线程 B 之间如果要通信的话,那么就必须经历下面两个步骤:
- 线程 A 把本地内存 A 更新过的共享变量刷新到主内存中去;
- 线程 B 到主内存中去读取线程 A 之前更新过的共享变量。
消息传递
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。在 Java 中典型的消息传递方式,就是 wait() 和 notify(),或者 BlockingQueue。
127. 说说你对 volatile 的理解
128. 说一下 volatile 和 synchronized 的区别?
- volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取。synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile 仅能使用在变量级别。synchronized 则可以使用在变量、方法、和类级别的。
- volatile 仅能实现变量的修改可见性,不能保证原子性。而 synchronized 则可以保证变量的修改可见性和原子性。
- volatile 不会造成线程的阻塞。synchronized 可能会造成线程的阻塞。
- volatile 标记的变量不会被编译器优化。synchronized 标记的变量可以被编译器优化。
129. Thread 调用 run 方法和调 start 方法的区别?
调用 run 方法不会再启一个线程,跟着主线程继续执行,和调普通类的方法一样;
调用 start 方法表示启动一个线程。
面试扩散
下面代码将输出什么内容?不清楚的建议自己去试试。
public class ThreadDemo {
public static void main(String[] args) {
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
System.out.println("test start");
}
});
thread.start();
thread.start();
}
}
130. 说一下 Java 创建线程池有哪些方式?
通过 java.util.concurrent.Executors 来创建以下常见线程池:
也可以通过 java.util.concurrent.ThreadPoolExecutor 来创建自定义线程池,其中核心的几个参数:
int corePoolSize, //核心线程数量
int maximumPoolSize, //最大线程数
long keepAliveTime, //超时时间,超出核心线程数量以外的线程空余存活时间
TimeUnit unit, //存活时间单位
BlockingQueue<Runnable> workQueue, //保存执行任务的队列
ThreadFactory threadFactory,//创建新线程使用的工厂
RejectedExecutionHandler handler //当任务无法执行的时候的处理方式
线程池原理:
131. 说说 ThreadLocal 底层原理是什么,怎么避免内存泄漏?
推荐阅读:
132. 说说你对 JUC 下并发工具类
Semaphore
是一种新的同步类,它是一个计数信号。从概念上讲,从概念上讲,信号量维护了一个许可集合。
- 如有必要,在许可可用前会阻塞每一个 acquire() 方法,然后再获取该许可。
- 每个 release() 方法,添加一个许可,从而可能释放一个正在阻塞的获取者。
- 但是,不使用实际的许可对象,Semaphore 只对可用许可的数量进行计数,并采取相应的行动。
信号量常常用于多线程的代码中,比如数据库连接池。
CountDownLatch
字面意思是减小计数(CountDown)的门闩(Latch)。它要做的事情是,等待指定数量的计数被减少,意味着门闩被打开,然后进行执行。CountDownLatch 默认的构造方法是 CountDownLatch(int count),其参数表示需要减少的计数,主线程调用 await() 方法告诉 CountDownLatch 阻塞等待指定数量的计数被减少,然后其它线程调用 CountDownLatch 的 CountDown() 方法,减小计数(不会阻塞)。等待计数被减少到零,主线程结束阻塞等待,继续往下执行。
CyclicBarrier
字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞,直到 parties 个线程到达,结束阻塞。
133. CyclicBarrier 和 CountdownLatch 有什么区别?
CyclicBarrier 可以重复使用,而 CountdownLatch 不能重复使用。
CountdownLatch 其实可以把它看作一个计数器,只不过这个计数器的操作是原子操作。可以向 CountdownLatch 对象设置一个初始的数字作为计数值,任何调用这个对象上的 await() 方法都会阻塞,直到这个计数器的计数值被其他的线程减为 0 为止。所以在当前计数到达零之前,await 方法会一直受阻塞。之后,会释放所有等待的线程,await 的所有后续调用都将立即返回。这种现象只出现一次 —— 计数无法被重置。如果需要重置计数,请考虑使用 CyclicBarrier。
CountdownLatch 的一个非常典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个 CountdownLatch 对象的 #await() 方法,其他的任务执行完自己的任务后调用同一个 CountdownLatch 对象上的 countDown() 方法,这个调用 #await() 方法的任务将一直阻塞等待,直到这个 CountdownLatch 对象的计数值减到 0 为止。 CyclicBarrier 一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点(common barrier point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环的 barrier。
十二、设计模式篇
134. 你都熟悉哪些设计模式
这里肯定是巴拉巴拉一堆设计模式,但是记住最好说清楚,自己最熟悉那几个设计模式,这样面试官就可以问你最熟悉的了。
比如:
- 单例模式:唯一性、安全性、性能、是否懒加载。
- 工厂模式:根据输入的条件生产对象。
- 模板模式:指定一系列算法,算法的骨架是不变的。
- 装饰器模式:动态地给某个方法的功能进行变更。
- 代理模式:房屋中介,访问权限的控制。
- 监听者模式:监听与被监听,只要被监听对象发生变动,监听就立马收到其变动的消息。
- 策略模式:条条大路通北京,每一条大路都是一个策略。
- 享元模式……。
模板模式推荐:
代理模式详解推荐:
代理模式 VS 装饰模式:
工厂方法模式 VS 建造者模式:
初中级应对的方法就是搞几个自己很熟悉的,然后面试的时候强调一下自己最熟悉的,然后结合 Spring 中或者 MyBatis 中的来说一下。高级可能就没那么简单的,至少得知道的多点,尤其是要学会对比,比如说:工厂方法模式 VS 建造者模式有什么区别。
建议熟悉一下常用设计模式实际使用案例,例如 Spring 篇、MyBatis 篇中。
推荐在线阅读:
十三、其他篇
135. 有 8 个球(大小颜色都一模一样),其中一个球比其他 7 个球中的任何一个都重,使用天平秤最多几次能找到最重的那个球?
- 拿出两个球,剩下 6 个进行称,每一边 3 个。
- 如果天平秤平衡,那么拿出去的两个球中肯定有个是最重的。再对两个球称重,就能找到最大的。
- 如果天平秤不平衡,那么最重的球就在重的那边三个中的一个。然后再从三个中拿出两个来称,如果平衡,则最重的球就另外一个,否则就是重的那边那个。
所以一共称了两次。
136. 分布式幂等性如何设计?
在高并发场景的架构里,幂等性是必须得保证的。比如说支付功能,用户发起支付,如果后台没有做幂等校验,刚好用户手抖多点了几下,于是后台就可能多次收到同一个订单请求。不做幂等很容易就让用户重复支付了,这样用户是肯定不能忍的。
解决方案
\1. 查询和删除不在幂等讨论范围,查询肯定没有幂等的说法,删除:第一次删除成功后,后面来删除直接返回 0,也是返回成功。
\2. 建唯一索引:唯一索引或唯一组合索引来防止新增数据存在脏数据 (当表存在唯一索引,并发时新增异常时,再查询一次就可以了,数据应该已经存在了,返回结果即可)。
\3. token 机制:由于重复点击或者网络重发,或者 nginx 重发等情况会导致数据被重复提交。前端在数据提交前要向后端服务的申请 token,token 放到 Redis 或 JVM 内存,token 有效时间。提交后后台校验 token,同时删除 token,生成新的 token 返回。Redis 要用删除操作来判断 token,删除成功代表 token 校验通过,如果用 select+delete 来校验 token,存在并发问题,不建议使用。
\4. 悲观锁:
select id ,name from table_# where id='##' for update;
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用(另外还要考虑 id 是否为主键,如果 id 不是主键或者不是 InnoDB 存储引擎,那么就会出现锁全表)。
\5. 乐观锁,给数据库表增加一个 version 字段,可以通过这个字段来判断是否已经被修改了。
update table_xxx set name=#name#,version=version+1 where version=#version#
\6. 分布式锁,比如 Redis、ZooKeeper 的分布式锁。单号为 key,然后给 Key 设置有效期(防止支付失败后,锁一直不释放),来一个请求使用订单号生成一把锁,业务代码执行完成后再释放锁。
\7. 保底方案:先查询是否存在此单,不存在此支付单据(上锁),存在就直接返回支付结果。
137. 简单一次完整的 HTTP 请求所经历的步骤?
\1. DNS 解析(通过访问的域名找出其 IP 地址,递归搜索)。
\2. HTTP 请求,当输入一个请求时,建立一个 Socket 连接发起 TCP 的 3 次握手。
如果是 HTTPS 请求,会略微有不同。等到 HTTPS 小节,我们再来讲。
\3. 客户端向服务器发送请求命令(一般是 GET 或 POST 请求),客户端发送请求头信息和数据。
这个是补充内容,面试一般不用回答。
客户端的网络层不用关心应用层或者传输层的东西,主要做的是通过查找路由表确定如何到达服务器,期间可能经过多个路由器,这些都是由路由器来完成的工作,我不做过多的描述,无非就是通过查找路由表决定通过哪个路径到达服务器。
客户端的链路层,包通过链路层发送到路由器,通过邻居协议查找给定 IP 地址的 MAC 地址,然后发送 ARP 请求查找目的地址。如果得到回应后就可以使用 ARP 的请求应答交换的 IP 数据包,然后发送 IP 数据包到达服务器的地址。
\4. 服务器发送应答头信息,服务器向客户端发送数据。
\5. 服务器关闭 TCP 连接(4 次挥手)。
这里是否关闭 TCP 连接,也根据 HTTP Keep-Alive 机制有关。
同时,客户端也可以主动发起关闭 TCP 连接。
\6. 客户端根据返回的 HTML、CSS、JS 进行渲染。
下面使用《图解 HTTP》里的一张图:
138. 说说分布式事务解决方案有哪些?
更多方案详情,推荐阅读:
139. 说说常用的 JVM 调优命令和工具有哪些?
常用 JVM 调优工具分为两类:
JDK 自带监控工具:jconsole 和 jvisualvm 2、第三方有:MAT(Memory Analyzer Tool)、GChisto。
- jconsole,Java Monitoring and Management Console 是从 JDK 1.5 开始,在 JDK 中自带的 Java 监控和管理控制台,用于对 JVM 中内存,线程和类等的监控。
- jvisualvm,JDK 自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC 变化等。
- MAT,Memory Analyzer Tool,一个基于 Eclipse 的内存分析工具,是一个快速、功能丰富的 Java heap 分析工具,可以帮助我们查找内存泄漏和减少内存消耗。
- GChisto,一款专业分析 GC 日志的工具。
140. 说说你对 JVM 内存溢出和内存泄漏的理解
内存溢出
即 OutOfMemoryError,当没有足够的空闲内存可供程序使用时出现。
内存泄漏
内存使用后未得到及时释放,又不能被 GC 回收,导致虚拟机不能再次使用该内存,此时这段内存就泄露了。
常见避免方法:
- 尽早释放无用对象
- 尽量少用静态变量。因为静态变量是全局的,GC 不会回收
- 尽量运用对象池技术以提高系统性能
- 不要在经常调用的方法中创建对象,尤其是忌讳在循环中创建对象
141. 说说 JVM 中有哪些常用参数?
JVM 面试的 30 个知识点
1.什么是 Java 虚拟机?为什么 Java 被称作是“平台无关的编程语言”?
Java 虚拟机是一个可以执行 Java 字节码的虚拟机进程。Java 源文件被编译成能被 Java 虚拟机执行的字节码文件。Java 被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java 虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。
2.Java 内存结构?
方法区和对是所有线程共享的内存区域;而 java 栈、本地方法栈和程序员计数器是运行是线程私有的内存区域。
3.内存模型以及分区,需要详细到每个区放什么?
JVM 分为堆区和栈区,还有方法区,初始化的对象放在堆里面,引用放在栈里面,class 类信息常量池(static 常量和 static 变量)等放在方法区。 new:
- 方法区:主要是存储类信息,常量池(static 常量和 static 变量),编译后的代码(字节码)等数据
- 堆:初始化的对象,成员变量 (那种非 static 的变量),所有的对象实例和数组都要在堆上分配
- 栈:栈的结构是栈帧组成的,调用一个方法就压入一帧,帧上面存储局部变量表,操作数栈,方法出口等信息,局部变量表存放的是 8 大基础类型加上一个应用类型,所以还是一个指向地址的指针
- 本地方法栈:主要为 Native 方法服务
- 程序计数器:记录当前线程执行的行号
4.堆里面的分区:Eden,survival (from+ to),老年代,各自的特点?
堆里面分为新生代和老生代(java8 取消了永久代,采用了 Metaspace),新生代包含 Eden+Survivor 区,survivor 区里面分为 from 和 to 区,内存回收时,如果用的是复制算法,从 from 复制到 to,当经过一次或者多次 GC 之后,存活下来的对象会被移动到老年区,当 JVM 内存不够用的时候,会触发 Full GC,清理 JVM 老年区当新生区满了之后会触发 YGC,先把存活的对象放到其中一个 Survice 区,然后进行垃圾清理。
因为如果仅仅清理需要删除的对象,这样会导致内存碎片,因此一般会把 Eden 进行完全的清理,然后整理内存。
那么下次 GC 的时候,就会使用下一个 Survive,这样循环使用。
如果有特别大的对象,新生代放不下,就会使用老年代的担保,直接放到老年代里面。 因为 JVM 认为,一般大对象的存活时间一般比较久远。
5 .解释内存中的栈(stack)、堆(heap)和方法区(method area)的用法
通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用 JVM 中的栈空间;而通过 new 关键字和构造器创建的对象则放在堆空间,堆是垃圾收集器管理的主要区域,由于现在的垃圾收集器都采用分代收集算法,所以堆空间还可以细分为新生代和老生代,再具体一点可以分为 Eden、Survivor(又可分为 From Survivor 和 To Survivor)、Tenured;方法区和堆都是各个线程共享的内存区域,用于存储已经被 JVM 加载的类信息、常量、静态变量、JIT 编译器编译后的代码等数据;程序中的字面量(literal)如直接书写的 100、”hello”和常量都是放在常量池中,常量池是方法区的一部分,。栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,栈和堆的大小都可以通过 JVM 的启动参数来进行调整,栈空间用光了会引发 StackOverflowError,而堆和常量池空间不足则会引发 OutOfMemoryError。
String str = new String("hello");
上面的语句中变量 str 放在栈上,用 new 创建出来的字符串对象放在堆上,而”hello”这个字面量是放在方法区的。
补充 1:较新版本的 Java(从 Java 6 的某个更新开始)中,由于 JIT 编译器的发展和”逃逸分析”技术的逐渐成熟,栈上分配、标量替换等优化技术使得对象一定分配在堆上这件事情已经变得不那么绝对了。
补充 2:运行时常量池相当于 Class 文件常量池具有动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的 intern()方法就是这样的。看看下面代码的执行结果是什么并且比较一下 Java 7 以前和以后的运行结果是否一致。
String s1 = new StringBuilder("go")
.append("od").toString();
System.out.println(s1.intern() == s1);
String s2 = new StringBuilder("ja")
.append("va").toString();
System.out.println(s2.intern() == s2);
6.GC 的两种判定方法?
引用计数法:指的是如果某个地方引用了这个对象就+1,如果失效了就-1,当为 0 就 会回收但是 JVM 没有用这种方式,因为无法判定相互循环引用(A 引用 B,B 引用 A) 的情况。 引用链法:通过一种 GC ROOT 的对象(方法区中静态变量引用的对象等-static 变 量)来判断,如果有一条链能够到达 GC ROOT 就说明,不能到达 GC ROOT 就说明 可以回收
7.SafePoint 是什么?
比如 GC 的时候必须要等到 Java 线程都进入到 safepoint 的时候 VMThread 才能开始执行 GC 1.循环的末尾 (防止大循环的时候一直不进入 safepoint,而其他线程在等待它进入 safepoint) 2.方法返回前 3.调用方法的 call 之后 4.抛出异常的位置
8.GC 的三种收集方法:标记清除、标记整理、复制算法的原理与特点,分别用在什么地方,如果让你优化收集方法,有什么思路?
先标记,标记完毕之后再清除,效率不高,会产生碎片 复制算法:分为 8:1 的 Eden 区和 survivor 区,就是上面谈到的 YGC 标记整理:标记完毕之后,让所有存活的对象向一端移动
9.GC 收集器有哪些?CMS 收集器与 G1 收集器的特点?
并行收集器:串行收集器使用一个单独的线程进行收集,GC 时服务有停顿时间 串行收集器:次要回收中使用多线程来执行 CMS 收集器是基于“标记—清除”算法实现的,经过多次标记才会被清除 G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的
10.Minor GC 与 Full GC 分别在什么时候发生?
新生代内存不够用时候发生 MGC 也叫 YGC,JVM 内存不够的时候发生 FGC
11. 几种常用的内存调试工具:jmap、jstack、jconsole、jhat?
jstack 可以看当前栈的情况,jmap 查看内存,jhat 进行 dump 堆的信息 mat(eclipse 的也要了解一下)
12.什么是类的加载
类的加载指的是将类的.class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供了访问方法区内的数据结构的接口。
13.类加载器
- 启动类加载器:Bootstrap ClassLoader,负责加载存放在 JDK\jre\lib(JDK 代表 JDK 的安装目录,下同)下,或被-Xbootclasspath 参数指定的路径中的,并且能被虚拟机识别的类库
- 扩展类加载:Extension ClassLoader,该加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 DK\jre\lib\ext 目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如 javax.*开头的类),开发者可以直接使用扩展类加载器。
- 应用程序类加载器:Application ClassLoader,该类加载器由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器
14.描述一下 JVM 加载 class 文件的原理机制?
JVM 中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。
由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件,然后产生与所加载类对应的 Class 对象。加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后 JVM 对类进行初始化,包括:
- 1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
- 2)如果类中存在初始化语句,就依次执行这些初始化语句。
类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader 的子类)。
从 Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM)。PDM 更好的保证了 Java 平台的安全性,在该机制中,JVM 自带的 Bootstrap 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 Bootstrap 的引用。下面是关于几个类加载器的说明:
- Bootstrap:一般用本地代码实现,负责加载 JVM 基础核心类库(rt.jar);
- Extension:从 java.ext.dirs 系统属性所指定的目录中加载类库,它的父加载器是 Bootstrap;
- System:又叫应用类加载器,其父类是 Extension。它是应用最广泛的类加载器。它从环境变量 classpath 或者系统属性 java.class.path 所指定的目录中记载类,是用户自定义加载器的默认父加载器。
15.Java 对象创建过程
1.JVM 遇到一条新建对象的指令时首先去检查这个指令的参数是否能在常量池中定义到一个类的符号引用。然后加载这个类(类加载过程在后边讲)
2.为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,最终常用的办法“本地线程缓冲分配(TLAB)”
3.将除对象头外的对象内存空间初始化为 0
4.对对象头进行必要设置
16.类的生命周期
类的生命周期包括这几个部分,加载、连接、初始化、使用和卸载,其中前三部是类的加载的过程,如下图;
java 类加载需要经历以下 几个过程:
- 加载
加载时类加载的第一个过程,在这个阶段,将完成以下三件事情:
1.通过一个类的全限定名获取该类的二进制流。
2.将该二进制流中的静态存储结构转化为方法去运行时数据结构。
3.在内存中生成该类的 Class 对象,作为该类的数据访问入口。
- 验证
验证的目的是为了确保 Class 文件的字节流中的信息不回危害到虚拟机.在该阶段主要完成以下四钟验证:
文件格式验证:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型.
元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。
字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。 如: 方法中的类型转换是否正确,跳转指令是否正确等。
符号引用验证: 这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。
- 准备
准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
public static int value=123;
//在准备阶段 value 初始值为 0 。
在初始化阶段才会变为 123。
- 解析
该阶段主要完成符号引用到直接引用的转换动作。解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。
- 初始化
初始化时类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。 到了初始化阶段,才真正开始执行类中定义的 Java 程序。
17.简述 java 类加载机制?
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的 java 类型。
18.Java 对象结构
Java 对象由三个部分组成:对象头、实例数据、对齐填充。
对象头由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC 分代年龄、锁标识状态、线程持有的锁、偏向线程 ID(一般占 32/64 bit)。
第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。
实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)
对齐填充:JVM 要求对象起始地址必须是 8 字节的整数倍(8 字节对齐)
19.Java 对象的定位方式
句柄池、直接指针。
20.如和判断一个对象是否存活?(或者 GC 对象的判定方法)
判断一个对象是否存活有两种方法:
1.引用计数法
所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收。 引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象 A 引用对象 B,对象 B 又引用者对象 A,那么此时 A,B 对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。
2.可达性算法(引用链法)
该算法的思想是:从一个被称为 GC Roots 的对象开始向下搜索,如果一个对象到 GC Roots 没有任何引用链相连时,则说明此对象不可用。 在 java 中可以作为 GC Roots 的对象有以下几种: 虚拟机栈中引用的对象
方法区类静态属性引用的对象
方法区常量池引用的对象
本地方法栈 JNI 引用的对象
虽然这些算法可以判定一个对象是否能被回收,但是当满足上述条件时,一个对象比不一定会被回收。当一个对象不可达 GC Root 时,这个对象并不会立马被回收,而是出于一个死缓的阶段,若要被真正的回收需要经历两次标记如果对象在可达性分析中没有与 GC Root 的引用链,那么此时就会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法或者已被虚拟机调用过,那么就认为是没必要的。
如果该对象有必要执行 finalize()方法,那么这个对象将会放在一个称为 F-Queue 的对队列中,虚拟机会触发一个 Finalize()线程去执行,此线程是低优先级的,并且虚拟机不会承诺一直等待它运行完,这是因为如果 finalize()执行缓慢或者发生了死锁,那么就会造成 FQueue 队列一直等待,造成了内存回收系统的崩溃。 GC 对处于 F-Queue 中的对象进行第二次被标记,这时,该对象将被移除”即将回收”集合,等待回收。
21.JVM 的永久代中会发生垃圾回收么?
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免 Full GC 是非常重要的原因。请参考下 Java8:从永久代到元数据区 (注:Java8 中已经移除了永久代,新加了一个叫做元数据区的 native 内存区)
22.简述 java 内存分配与回收策率以及 Minor GC 和 Major GC?
1.对象优先在堆的 Eden 区分配。
2.大对象直接进入老年代.
3.长期存活的对象将直接进入老年代.,当 Eden 区没有足够的空间进行分配时,虚拟机会执行一次 Minor GC.Minor Gc 通常发生在新生代的 Eden 区,在这个区的对象生存期短,往往发生 Gc 的频率较高, 回收速度比较快;Full Gc/Major GC 发生在老年代,一般情况下,触发老年代 GC 的时候不会触发 Minor GC,但是通过配置,可以在 Full GC 之前进行一次 MinorGC 这样可以加快老年代的回收速度。
23.判断一个对象应该被回收
该对象没有与 GC Roots 相连
该对象没有重写 finalize()方法或 finalize()已经被执行过则直接回收(第一次标记)、否则将对象加入到 F-Queue 队列中(优先级很低的队列)在这里 finalize()方法被执行,之后进行第二次标记,如果对象仍然应该被 GC 则 GC,否则移除队列。(在 finalize 方法中,对象很可能和其他 GC Roots 中的某一个对象建立了关联,finalize 方法只会被调用一次,且不推荐使用 finalize 方法)
24.回收方法区
方法区回收价值很低,主要回收废弃的常量和无用的类。
如何判断无用的类:
该类所有实例都被回收(Java 堆中没有该类的对象)
加载该类的 ClassLoader 已经被回收
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方利用反射访问该类
25.垃圾收集算法
GC 最基础的算法有三种:标记 -清除算法、复制算法、标记-压缩算法,我们常用的垃圾回收器一般都采用分代收集算法。
- 标记 -清除算法,“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
- 复制算法,“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 标记-压缩算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
- 分代收集算法,“分代收集”(Generational Collection)算法,把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
26.垃圾回收器
Serial 收集器,串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。
ParNew 收集器,ParNew 收集器其实就是 Serial 收集器的多线程版本。
Parallel 收集器,Parallel Scavenge 收集器类似 ParNew 收集器,Parallel 收集器更关注系统的吞吐量。
Parallel Old 收集器,Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法
CMS 收集器,CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
G1 收集器,G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征
27.GC 日志分析
摘录 GC 日志一部分(前部分为年轻代 gc 回收;后部分为 full gc 回收):
2016-07-05T10:43:18.093+0800: 25.395: [GC [PSYoungGen: 274931K->10738K(274944K)] 371093K->147186K(450048K), 0.0668480 secs] [Times: user=0.17 sys=0.08, real=0.07 secs]
2016-07-05T10:43:18.160+0800: 25.462: [Full GC [PSYoungGen: 10738K->0K(274944K)] [ParOldGen: 136447K->140379K(302592K)] 147186K->140379K(577536K) [PSPermGen: 85411K->85376K(171008K)], 0.6763541 secs] [Times: user=1.75 sys=0.02, real=0.68 secs]
通过上面日志分析得出,PSYoungGen、ParOldGen、PSPermGen 属于 Parallel 收集器。其中 PSYoungGen 表示 gc 回收前后年轻代的内存变化;ParOldGen 表示 gc 回收前后老年代的内存变化;PSPermGen 表示 gc 回收前后永久区的内存变化。young gc 主要是针对年轻代进行内存回收比较频繁,耗时短;full gc 会对整个堆内存进行回城,耗时长,因此一般尽量减少 full gc 的次数
28.调优命令
Sun JDK 监控和故障处理命令有 jps jstat jmap jhat jstack jinfo
- jps,JVM Process Status Tool,显示指定系统内所有的 HotSpot 虚拟机进程。
- jstat,JVM statistics Monitoring 是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据。
- jmap,JVM Memory Map 命令用于生成 heap dump 文件
- jhat,JVM Heap Analysis Tool 命令是与 jmap 搭配使用,用来分析 jmap 生成的 dump,jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 的分析结果后,可以在浏览器中查看
- jstack,用于生成 java 虚拟机当前时刻的线程快照。
- jinfo,JVM Configuration info 这个命令作用是实时查看和调整虚拟机运行参数。
29.调优工具
常用调优工具分为两类,jdk 自带监控工具:jconsole 和 jvisualvm,第三方有:MAT(Memory Analyzer Tool)、GChisto。
jconsole,Java Monitoring and Management Console 是从 java5 开始,在 JDK 中自带的 java 监控和管理控制台,用于对 JVM 中内存,线程和类等的监控
jvisualvm,jdk 自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC 变化等。
MAT,Memory Analyzer Tool,一个基于 Eclipse 的内存分析工具,是一个快速、功能丰富的 Java heap 分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
GChisto,一款专业分析 gc 日志的工具
30.Minor GC 与 Full GC 分别在什么时候发生?
新生代内存不够用时候发生 MGC 也叫 YGC,JVM 内存不够的时候发生 FGC
最全的 116 道 Redis 面试题解答
一、Redis 基础(27 题)
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)。
2. Redis 的数据类型有哪些?
Redis 支持的数据类型有:
类型 | 简介 | 特性 | 场景 |
---|---|---|---|
String(字符串) | 二进制安全 | 可以包含任何数据,比如 JPG 图片或者序列化的对象,一个键最大能存储 512M | 简短的字符场景 |
Hash(哈希) | 键值对集合,即编程语言中的 Map 类型 | 适合存储对象,并且可以像数据库中 update 一个属性一样只修改某一项属性值(Memcached 中需要取出整个字符串反序列化成对象修改完再序列化存回去) | 存储、读取、修改用户属性 |
List(列表) | 链表(双向链表) | 增删快,提供了操作某一段元素的 API | ①最新消息排行等功能(比如朋友圈的时间线); ②消息队列 |
Set(集合) | 哈希表实现,元素不重复 | ①添加、删除,查找的复杂度都是 O(1) ②为集合提供了求交集、并集、差集等操作 | ①共同好友; ②利用唯一性,统计访问网站的所有独立 IP; ③好友推荐时,根据 tag 求交集,大于某个阈值就可以推荐 |
Sorted Set(有序集合) | 将 Set 中的元素增加一个权重参数 score,元素按 score 有序排列 | 数据插入集合时,已经进行天然排序 | ①排行榜; ②带权重的消息队列 |
3. 使用 Redis 有哪些好处?
具有以下好处:
- 读取速度快,因为数据存在内存中,所以数据获取快;
- 支持多种数据结构,包括字符串、列表、集合、有序集合、哈希等;
- 支持事务,且操作遵守原子性,即对数据的操作要么都执行,要么都不支持;
- 还拥有其他丰富的功能,队列、主从复制、集群、数据持久化等功能。
4. Redis 相比 Memcached 有哪些优势?
Redis 相比 Memcache 有以下的优势:
- 数据结构:Memcache 只支持 key value 存储方式,Redis 支持更多的数据类型,比如 Key value、hash、list、set、zset;
- 多线程:Memcache 支持多线程,Redis 支持单线程;CPU 利用方面 Memcache 优于 Redis;
- 持久化:Memcache 不支持持久化,Redis 支持持久化;
- 内存利用率:Memcache 高,Redis 低(采用压缩的情况下比 Memcache 高);
- 过期策略:Memcache 过期后,不删除缓存,会导致下次取数据数据的问题,Redis 有专门线程,清除缓存数据;
- 适用场景:Redis 适用于对读写效率要求都很高,数据处理业务复杂和对安全性要求较高的系统。Redis 只使用单核,而 Memcached 可以使用多核,所以平均每一个核上 Redis 在存储小数据时比 Memcached 性能更高。Memcached 适用于在动态系统中减少数据库负载,提升性能,做缓存,提高性能(适合读多写少,对于数据量比较大,可以采用 sharding)。
5. Memcache 与 Redis 的区别都有哪些?
它们的主要区别有:
- 支持的数据类型不同,Memcache 支持的数据类型简单,只有 key-value,而 Redis 支持多种数据结构,包括字符串、列表、集合、有序集合、哈希等;
- Redis 支持数据持久化,Memcache 不支持数据持久化。如果服务器重启,Memcache 中的数据会被清空,而 Redis 开启持久化的话,重启后可以自动加载数据恢复到内存中;
- 它们的底层结构、实现方式的不同,服务端和客户端通信的协议也不同。
6. Redis 是单进程单线程的吗?
Redis 是单进程单线程的,它可以通过队列技术将并发访问变为串行访问,避免了传统数据库串行控制的开销。
7. 为什么 Redis 需要把所有数据放到内存中?
Redis 将数据放在内存中有一个好处,那就是可以实现最快的对数据读取,如果数据存储在硬盘中,磁盘 I/O 会严重影响 Redis 的性能。而且 Redis 还提供了数据持久化功能,不用担心服务器重启对内存中数据的影响。其次现在硬件越来越便宜的情况下,Redis 的使用也被应用得越来越多,使得它拥有很大的优势。
8. Redis 的同步机制了解是什么?
Redis 支持主从同步、从从同步。如果是第一次进行主从同步,主节点需要使用 bgsave 命令,再将后续修改操作记录到内存的缓冲区,等 RDB 文件全部同步到复制节点,复制节点接受完成后将 RDB 镜像记载到内存中。等加载完成后,复制节点通知主节点将复制期间修改的操作记录同步到复制节点,即可完成同步过程。
9. pipeline 有什么好处,为什么要用 pipeline?
使用 pipeline(管道)的好处在于可以将多次 I/O 往返的时间缩短为一次,但是要求管道中执行的指令间没有因果关系。
用 pipeline 的原因在于可以实现请求/响应服务器的功能,当客户端尚未读取旧响应时,它也可以处理新的请求。如果客户端存在多个命令发送到服务器时,那么客户端无需等待服务端的每次响应才能执行下个命令,只需最后一步从服务端读取回复即可。
10. Redis 最适合的场景?
Redis 是一个开源(BSD 许可),基于内存,支持多种数据结构的存储系统。可以作为数据库、缓存和消息中间件。它支持的数据结构有字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等,除此之外还支持 bitmaps、hyperloglogs 和地理空间( geospatial )索引半径查询等功能。根据它的特性,它适用的场景有:
1. 会话缓存
会话(Session)是存储在服务端的,但是可以设置存储的时候不以文件的方式存储,而是存到 Redis 中,而且 Redis 支持数据持久化,不用担心数据因为服务器重启导致 Session 数据丢失的问题。这样做的好处不只是提高获取会话的速度,也对网站的整体性能有很大的提升。
2. 数据缓存
Redis 支持多种数据结构,经常被用来做缓存中间件使用。缓存的数据不只是包括数据库中的数据,也可以缓存一些需要临时存储的数据,例如 token、会话数据等。
3. 队列
Redis 是支持列表(lists)功能的,可以简单实现一个队列的功能,对数据进行入队、出队操作。实现的队列可以应用到电商的秒杀场景中。
4. 排行榜、计数器
Redis 提供了有序集合,可以对数据进行排名,实现排行榜功能。 其次 Redis 中提供了 incr 对数字加 1 命令,也提供了 decr 对数字减 1 命令,所以可以实现一个简单的计数器功能。
5. 发布、订阅功能
Redis 中提供了发布订阅相关的命令,可以用来做一些跟发布订阅相关的场景应用等。例如简单的消息队列功能等。
11. 什么是 Redis 事务?原理是什么?
Redis 中的事务是一组命令的集合,是 Redis 的最小执行单位。它可以保证一次执行多个命令,每个事务是一个单独的隔离操作,事务中的所有命令都会序列化、按顺序地执行。服务端在执行事务的过程中,不会被其他客户端发送来的命令请求打断。
它的原理是先将属于一个事务的命令发送给 Redis,然后依次执行这些命令。
12. Redis 事务的注意点有哪些?
需要注意的点有:
- Redis 事务是不支持回滚的,不像 MySQL 的事务一样,要么都执行要么都不执行;
- Redis 服务端在执行事务的过程中,不会被其他客户端发送来的命令请求打断。直到事务命令全部执行完毕才会执行其他客户端的命令。
13. Redis 为什么不支持回滚?
Redis 的事务不支持回滚,但是执行的命令有语法错误,Redis 会执行失败,这些问题可以从程序层面捕获并解决。但是如果出现其他问题,则依然会继续执行余下的命令。这样做的原因是因为回滚需要增加很多工作,而不支持回滚则可以保持简单、快速的特性。
14. Redis 如何设置密码及验证密码?
Redis 密码设置有两种方式:
- 修改配置文件,需要重启 Redis。在 redis.conf 中可以找到 requirepass 参数,设置 Redis 的访问密码。配置方法为:
requirepass 访问密码
。 - 使用命令设置,不需要重启 Redis。使用命令设置的方法为:
config set requirepass 访问密码
。如果需要查询密码,可以使用config get requirepass
命令。如果需要验证密码,可以使用 auth 访问密码,再执行config get requirepass
获取。
需要注意的是,通过这种方式设置访问密码,如果 redis.conf 配置文件中没有设置对应的访问密码,那么服务器重启后访问密码会失效。
15. Redis 为什么设计成单线程的?
多线程处理会涉及到锁,并且多线程处理会涉及到线程切换而消耗 CPU。采用单线程,避免了不必要的上下文切换和竞争条件。其次 CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是机器内存或者网络带宽。
16. 怎么测试 Redis 的连通性?
通过命令 ping 执行后,会得到回复结果 pong。如果没有得到 pong 的结果说明 Redis 没有正常连通。
17. 怎么理解 Redis 事务?
在 MySQL 中,事务是指一组操作中,要么全部执行,要么全部不执行。而在 Redis 也存在事务的概念。Redis 的事务可以保证一次执行多个命令,每个事务是一个单独的隔离操作,事务中的所有命令都会序列化、按顺序地执行。服务端在执行事务的过程中,不会被其他客户端发送来的命令请求打断。
18. Redis 通讯协议是什么?有什么特点?
Redis 客户端和 Redis 服务器通信时使用的是 RESP(Redis 序列化协议)通讯协议,该协议是专门为 Redis 设计的,但是也可以用于其他客户端和服务器软件项目中。
RESP 的特点为:实现简单、快速解析、可读性好。
19. Redis 单点吞吐量有多少?
单点 TPS 达到 8 万/秒,QPS 达到 10 万/秒。TPS 和 QPS 的意思:
- QPS:应用系统每秒钟最大能接受的用户访问量。每秒钟处理完请求的次数,注意这里是处理完,具体是指发出请求到服务器处理完成功返回结果。可以理解在 Server 中有个 counter,每处理一个请求加 1,1s 后 counter=QPS。
- TPS:每秒钟最大能处理的请求数。每秒钟处理完的事务次数,一个应用系统 1s 能完成多少事务处理,一个事务在分布式处理中,可能会对应多个请求,对于衡量单个接口服务的处理能力,用 QPS 比较合理。
20. Redis 中管道有什么用?
使用 pipeline(管道)的好处在于可以将多次 I/O 往返的时间缩短为一次,但是要求管道中执行的指令间没有因果关系。
21. 修改配置不重启 Redis 会实时生效吗?
因为 Redis 在重启才能加载配置项中的配置,所以需要重启才能生效。针对运行实例,有许多配置选项可以通过 CONFIG SET 命令进行修改,而无需执行任何形式的重启。
从 Redis 2.2 开始,可以从 AOF 切换到 RDB 的快照持久性或其他方式而不需要重启 Redis。检索 'CONFIG GET *'
命令获取更多信息。
但偶尔重新启动是必须的,如为升级 Redis 程序到新的版本,或者当你需要修改某些目前 CONFIG 命令还不支持的配置参数的时候。
22. 可以介绍一些 Redis 常用的安全设置吗?
常用的设置有:
1. 端口设置
只允许信任的客户端发送过来的请求,对其他所有请求都拒绝。如果存在暴露在外网的服务,那么需要使用防火墙阻止外部访问 Redis 端口。
2. 身份验证
使用 Redis 提供的身份验证功能,在 redis.conf 文件中进行配置生效,客户端可以发送(AUTH 密码)命令进行身份认证。
3. 禁用特定的命令集
可以考虑禁止一些容易产生安全问题的命令,预防被人恶意操作。
23. 请介绍一下 Redis 的发布订阅功能
发布订阅是一种消息通信模式,发送者发送消息到某个频道,订阅了该频道的用户都可以接收到消息。该模式由发布者、接收者和频道组成。
24. 请说明一下 Redis 的批量命令与 Pipeline 有什么不同?
它们主要的不同有:
- 原子性不同;批量命令操作需要保证原子性的,Pipeline 执行是非原子性的;
- 支持的命令不同;批量命令操作是一个命令对应多个 key,Pipeline 支持多个命令的执行;
- 实现方式的不同;批量命令操作是由 Redis 服务端实现的,而 Pipeline 是需要服务端和客户端共同实现的。
25. 请介绍一下 Redis 的 Pipeline(管道),以及使用场景
因为 Redis 是基于 TCP 协议的请求/响应服务器,每次通信都需要经过 TCP 协议的三次握手,所以当需要执行的命令足够大时,会产生很大的网络延迟。并且网络的传输时间成本和服务器开销没有计入其中,总的延迟可能更大。Pipeline 主要就是为了解决存在这种情况的场景,对此存在类似的场景都可以考虑使用 Pipeline。
可以适用场景有:如果存在批量数据需要写入 Redis,并且这些数据允许一定比例的写入失败,那么可以使用 Pipeline,后期再对失败的数据进行补偿即可。
26. Redis 常用的业务场景有哪些?
主要常用的业务场景有:
- 对热点数据的缓存;因为 Redis 支持多种数据类型,数据存储在内存中,访问速度块,所以 Redis 很适合用来存储热点数据;
- 限时类业务的实现;可以使用 expire 命令设置 key 的生存时间,到时间后自动删除 key。例如使用在验证码验证、优惠活动等业务场景;
- 计数器的实现;因为 incrby 命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式序列号的生成。例如限制一个手机号发多少条短信、一个接口一分钟限制多少请求、一个接口一天限制调用多少次等业务场景。
- 排行榜的实现;借助 Sorted Set 进行热点数据的排序。例如:下单量最多的用户排行榜,最热门的帖子(回复最多)等业务场景;
- 分布式锁实现;可以利用 Redis 的 setnx 命令进行。
- 队列机制实现;Redis 提供了
list push
和list pop
这样的命令,所以能够很方便的执行队列操作。
27. Redis 适用场景有哪些?
适用的场景有:
- 可以应用于高并发读写的热数据中
- 海量数据的读写
- 数据处理业务复杂和对安全性要求较高的系统
- 扩展性要求高的数据中
二、Redis 数据结构指令(16 题)
1. Redis 的一个字符串类型的值能存储最大容量是多少?
字符串类型是最基本的数据类型,是二进制安全的字符串,最大 512M。
2. Redis 常用的哈希命令有哪些?
哈希类型操作命令和方法为:
命令 | 说明 | Cli 命令写法 | PHP 写法 |
---|---|---|---|
hset | 赋值 | hset key field value |
$redis->hSet(key,field,value); |
hmset | 赋值多个字段 | hmset key field1 value1 [field2 values] |
$redis->hMset(key,array('field1'=>'value1','field2'=>'value2')); |
hget | 取值 | hget key field |
$redis->hGet(key,field); |
hmget | 取多个字段的值 | hmget key field1[field2] |
$redis->hmGet(key,array('field1','field2')); |
hgetall | 取所有字段的值 | hgetall key |
$redis->hGet(key,array('field1','field2')); |
hlen | 获取字段的数量 | hlen key |
$redis->hLen(key); |
3. Redis 各个数据类型最大存储量分别是多少?
它们最大存储量分别为:
- Strings 类型:一个 String 类型的 value 最大可以存储 512M;
- List 类型:list 的元素个数最多为 2^32-1 个,也就是 4294967295 个;
- Sets 类型:元素个数最多为 2^32-1 个,也就是 4294967295 个;
- Hashes 类型:键值对个数最多为 2^32-1 个,也就是 4294967295 个;
- Sorted sets 类型:跟 Sets 类型相似。元素个数最多为 2^32-1 个,也就是 4294967295 个。
4. 请介绍一下 Redis 的数据类型 Sorted Set(zset)以及底层实现机制?
zset 的功能和 sets 类似,但是它在集合内的元素是有顺序,不能重复的。所以适合做排行榜之类的功能。
它底层实现机制的实现方式有两种,分别为 ziplist(压缩列表) 或者 skiplist(跳跃表)。它们的区别为:
- 底层使用的数据结构实现不同:ziplist 编码的有序集合对象使用压缩列表作为底层实现,而 skiplist 编码的有序集合对象使用 zset 结构作为底层实现。
- 底层集合元素保存的方式不同;ziplist 中的每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。并且压缩列表内的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。skiplist 的一个 zset 结构同时包含一个字典和一个跳跃表。字典的键保存元素的值,字典的值则保存元素的分值;跳跃表节点的 object 属性保存元素的成员,跳跃表节点的 score 属性保存元素的分值。
- 当有序集合对象保存的元素数量小于 128 个,并且保存的所有元素长度都小于 64 字节时,对象使用 ziplist 编码。否则使用 skiplist 编码。
5. Redis 事务相关的命令有哪几个?
事务从开始到执行会经历的三个阶段:开始事务、命令入队、 执行事务。它以 MULTI 开始一个事务,然后让多个命令入队到事务中,最后通过命令 EXEC 触发执行事务。它们的执行命令有:
命令 | 作用 | 使用方法 |
---|---|---|
MULTI | 标记一个事务块的开始 | Multi |
DISCARD | 取消事务、放弃执行事务块的所有命令 | Discard |
WATCH key [key …] | 监视一个 (或多个) key,如果在事务执行之前这个 (或这些) key 被其他命令所改动,那么事务将被打断 | Watch key |
EXEC | 执行所有事务块内的命令 | Exec |
UNWATCH | 取消 WATCH 命令对所有 key 的监视 | Unwatch |
6. Redis key 的过期时间和永久有效分别怎么设置?
可以使用 EXPIRE 和 PERSIST 命令。对一个已经带有生存时间的 key 执行 EXPIRE 命令,新指定的生存时间会取代旧的生存时间。PERSIST 命令可以移除给定 key 的生存时间,将这个 key 从带生存时间转换成持久的。
7. 一个 Redis 实例最多能存放多少的 keys?List、Set、Sorted Set 他们最多能存放多少元素?
Redis 实例最多可以存放 2 的 32 次方 -1 个 keys,只要 Redis 的内存空间足够可以支持,任何的 list、set、sorted set 都可以放 2^32 -1 个元素。
8. Redis 支持哪几种数据结构?
Redis 支持的数据结构主要有:字符串(string)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted set)等五种数据结构类型。
9. 如何设置 Redis 的最大连接数?查看 Redis 的最大连接数?查看 Redis 的当前连接数?
设置 Redis 的最大连接数的命令为:
redis-server -maxclients 100000(连接数);
查看 Redis 最大连接数的命令为:
config get maxclients
查看 Redis 连接数的命令为:
info 命令
在 redis-cli 中输入 info 命令即可查看。
10. Redis 设置键的生存时间和过期时间有哪些命令?
Redis 提供了过期时间处理函数,可以对指定的键名设置过期时间。如果对键名不设置过期时间也可以使用 DEL 函数对数据进行删除。当用户对一个键名设置了生效时间,我们可以说这个键名存在“生存时间”或“在指定时间后过期”。对键名设置过期时间可以有效地释放键名占用的内存空间,在实际的开发过程中是非常提倡的一种做法。相关命令如下:
命令 | 说明 | Cli 命令写法 |
---|---|---|
PERSIST | 移除键的过期时间 | PERSIST key-name |
EXPIRE | 让给定键在指定的秒数之后过期 | EXPIRE key-name seconds |
EXPIREAT | 将给定键的过期时间设置为给定的 UNIX 时间戳,以秒为单位 | EXPIREAT key-name timestamp |
PEXPIRE | 让给定键在指定的毫秒数之后过期 | PEXPIRE key-name milliseconds |
PEXPIREAT | 将一个毫秒级精度的 UNIX 时间戳设置为给定键过期时间 | PEXPIREAT key-name timestamp-milliseconds |
PTTL | 查看给定键距离过期时间还有多少毫秒 | PTTL key-name |
TTL | 查看给定键距离过期还有多少秒 | TTL key-name |
11. Redis 的 String 类型使用 SSD 方式实现的好处?
SDS 实现方式相对 C 语言的 String 的好处有:
- 避免缓冲区溢出;对字符修改时,可以根据 len 属性检查空间是否满足要求。
- 常数复杂度获取字符串长度;获取字符串的长度直接读取 len 属性就可以获取。而 C 语言中,因为字符串是简单的字符数组,求长度时内部其实是直接顺序遍历数组内容,找到 ‘\0’ 对应的字符,计算出字符串的长度。复杂度即 O(N)。
- 减少内存分配次数;通过结构中的 len 和 free 两个属性,更好的协助空间预分配以及惰性空间释放。
- 二进制安全;SSD 不是以空字符串来判断是否结束,而是以 len 属性来判断字符串是否结束。而在 C 语言中,字符串要求除了末尾之外不能出现空字符,否则会被程序认为是字符串的结尾。这就使得 C 字符串只能存储文本数据,而不能保存图像,音频等二进制数据。
- 兼容 C 字符串函数;可以重用 C 语言库的 的一部分函数。
以上几点好处可以概括如下:
C 字符串 | SDS |
---|---|
获取字符串长度的复杂度为 O(N) | 获取字符串长度的复杂度为 O(1) |
API 是不安全的,可能会造成缓冲区溢出 | API 是安全的,不会造成缓冲区溢出 |
修改字符串长度 N 次必然需要执行 N 次内存重分配 | 修改字符串长度 N 次最多需要执行 N 次内存重分配 |
只能保存文本数据 | 可以保存文本或者二进制数据 |
可以使用所有库中的函数 | 可以使用一部分库的函数 |
12. 请介绍一下 Redis 的 String 类型底层实现?
Redis 底层实现了简单动态字符串的类型(SDS),来表示 String 类型。没有直接使用 C 语言定义的字符串类型。
Redis 底层使用简单动态字符串(simple dynamic string,SDS)的抽象类型实现的。默认以 SDS 作为自己的字符串表示。而没有直接使用 C 语言定义的字符串类型。
SDS 的定义格式如下:
struct sdshdr{
//记录 buf 数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[]; //buf的大小等于len+free+1,其中多余的1个字节是用来存储‘\0’的。
}
SDS 的存储示例如下:
13. Redis 的链表数据结构的特征有哪些?
具有以下特征:
- 通过 len 属性来记录链表长度;
- 链表的最前和最后节点都有引用,获取前后节点的复杂度都为 O(1);
- 对链表的访问都是以 null 结束。
14. Redis 的内存消耗分类有哪些?内存统计使用什么命令?
内存消耗可以分类为:
- 对象内存:该内存占用最大,存储用户的所有数据。包括所有 key 的大小和 value 的大小。
- 缓冲内存:主要有客户端缓存、复制积压缓存、AOF 缓存等;
- 内存碎片:对 key 数据更新,数据过期等都可能产生内存碎片。
可以使用 info 命令来获取内存统计使用信息。
15. 怎么发现 bigkey?
可以使用 redis-cli –bigkeys
命令统计 bigkey 的分布。也可以在生产环节下执行 debug object key
命令查看 serializedlength 属性,获得 key 对应的 value 序列化之后的字节数。
16. 什么是 bigkey?有什么影响?
bigkey 是指键值占用内存空间非常大的 key。例如一个字符串 a 存储了 200M 的数据。
bigkey 的主要影响有:
- 网络阻塞;获取 bigkey 时,传输的数据量比较大,会增加带宽的压力。
- 超时阻塞;因为 bigkey 占用的空间比较大,所以操作起来效率会比较低,导致出现阻塞的可能性增加。
- 导致内存空间不平衡;一个 bigkey 存储数据量比较大,同一个 key 在同一个节点或服务器中存储,会造成一定影响。
三、高并发处理(21 题)
1. Redis 常见性能问题和解决方案有哪些?
Redis 常见性能问题和解决方案如下:
- Master 最好不要做任何持久化工作,如 RDB 内存快照和 AOF 日志文件;
- 如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次;
- 为了主从复制的速度和连接的稳定性,Master 和 Slave 最好在同一个局域网内;
- 尽量避免在压力很大的主库上增加从库;
- 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3….;这样的结构方便解决单点故障问题,实现 Slave 对 Master 的替换。如果 Master 挂了,可以立刻启用 Slave1 做 Master,其他不变。
2. MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?
因为内存的空间是有限的,所以 Redis 淘汰机制主要为了解决在某个时间点,Redis 中存在很多过期键,定期删除策略随机抽查时没有抽查到,并且也没有走惰性删除策略时,大量过期键占用内存的问题。如果内存只能存 20w 数据,而我们需要存储 2000w 的数据时,自然就需要对多出来的数据进行删除或覆盖,保证内存中存储的数据都是热数据。所以当 Redis 内存数据集的大小上升到一定数量时,就会执行数据淘汰策略。
3. 假如 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 指令长。
4. 什么是缓存穿透?怎么解决?
大量的请求瞬时涌入系统,而这个数据在 Redis 中不存在,所有的请求都落到了数据库上把数据库打死。造成这种情况的原因有系统设计不合理、缓存数据更新不及时,或爬虫等恶意攻击。 解决办法有:
1. 使用布隆过滤器
将查询的参数都存储到一个 bitmap 中,在查询缓存前,再找个新的 bitmap,在里面对参数进行验证。如果验证的 bitmap 中存在,则进行底层缓存的数据查询,如果 bitmap 中不存在查询参数,则进行拦截,不再进行缓存的数据查询。
2. 缓存空对象
如果从数据库查询的结果为空,依然把这个结果进行缓存,那么当用 key 获取数据时,即使数据不存在,Redis 也可以直接返回结果,避免多次访问数据库。
但是缓存空值的缺点是:
- 如果存在黑客恶意的随机访问,造成缓存过多的空值,那么可能造成很多内存空间的浪费。但是也可以对这些数据设置很短的过期时间来控制;
- 如果查询的 key 对应的 Redis 缓存空值没有过期,数据库这时有了新数据,那么会出现数据库和缓存数据不一致的问题。但是可以保证当数据库有数据后更新缓存进行解决。
5. 什么是缓存雪崩?怎么解决?
缓存雪崩是指当大量缓存失效时,大量的请求访问直接请求数据库,导致数据库服务器无法抗住请求或挂掉的情况。这时网站常常会出现 502 错误,导致网站不可用问题。
在预防缓存雪崩时,建议遵守以下几个原则:
- 合理规划缓存的失效时间,可以给缓存时间加一个随机数,防止统一时间过期;
- 合理评估数据库的负载压力,这有利于在合理范围内部分缓存失,数据库也可以正常访问;
- 对数据库进行过载保护或应用层限流,这种情况下一般是在网站处于大流量、高并发时,服务器整体不能承受时,可以采用的一种限流保护措施;
- 最后还可以考虑多级缓存设计,实现缓存的高可用。
6. 如果有大量的 key 需要设置同一时间过期,一般需要注意什么?
如果有大量的 key 在同一时间过期,那么可能同一秒都从数据库获取数据,给数据库造成很大的压力,导致数据库崩溃,系统出现 502 问题。也有可能同时失效,那一刻不用都访问数据库,压力不够大的话,那么 Redis 会出现短暂的卡顿问题。所以为了预防这种问题的发生,最好给数据的过期时间加一个随机值,让过期时间更加分散。
7. Redis 哨兵和集群的区别是什么?
Redis 的哨兵作用是管理多个 Redis 服务器,提供了监控、提醒以及自动的故障转移的功能。哨兵可以保证当主服务器挂了后,可以从从服务器选择一台当主服务器,把别的从服务器转移到读新的主机。Redis 哨兵的主要功能有:
- 集群监控:对 Redis 集群的主从进程进行监控,判断是否正常工作。
- 消息通知:如果存在 Redis 实例有故障,那么哨兵可以发送报警消息通知管理员。
- 故障转移:如果主机(master)节点挂了,那么可以自动转移到从(slave)节点上。
- 配置中心:当存在故障时,对故障进行转移后,配置中心会通知客户端新的主机(master)地址。
Redis 的集群的功能是为了解决单机 Redis 容量有限的问题,将数据按一定的规则分配到多台机器,对内存的每秒访问不受限于单台服务器,可受益于分布式集群高扩展性。
8. Redis 的哨兵有什么功能?
哨兵是 Redis 集群架构中非常重要的一个组件,主要功能如下:
- 集群监控,负责监控 Redis Master 和 Slave 进程是否正常工作;
- 消息通知,如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员;
- 故障转移,如果 Master node 挂掉了,会自动转移到 Slave node 上;
- 配置中心,如果故障转移发生了,通知 Client 客户端新的 Master 地址。
9. 请介绍几个可能导致 Redis 阻塞的原因
Redis 产生阻塞的原因主要有内部和外部两个原因导致:
1. 内部原因
- 如果 Redis 主机的 CPU 负载过高,也会导致系统崩溃;
- 数据持久化占用资源过多;
- 对 Redis 的 API 或指令使用不合理,导致 Redis 出现问题。
2. 外部原因
外部原因主要是服务器的原因,例如服务器的 CPU 线程在切换过程中竞争过大,内存出现问题、网络问题等。
10. Redis 的同步机制了解么?
Redis 可以使用主从同步、从从同步。第一次同步时,主节点做一次 bgsave,并同时将后续修改操作记录到内存 buffer,待完成后将 RDB 文件全量同步到复制节点,复制节点接受完成后将 RDB 镜像加载到内存。
加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。
11. 如何处理 Redis 集群中 big key 和 hot key?
对于 big key 先分析业务存在大键值是否合理,如果不合理我们可以把它们拆分为多个小的存储。或者看是否可以使用别的存储介质存储这种 big key 解决占用内存空间大的问题。
对于 hot key 我们可以在其他机器上复制这个 key 的备份,让请求进来的时候,去随机的到各台机器上访问缓存。所以剩下的问题就是如何做到让请求平均的分布到各台机器上。
12. 怎么去发现 Redis 阻塞异常情况?
可以从以下两方面准备:
1. 使用 Redis 自身监控系统
使用 Redis 自身监控系统,可以对 CPU、内存、磁盘、命令耗时等阻塞问题进行监控,当监控系统发现各个监控指标存在异常的时候,发送报警。
2. 使用应用服务监控
当 Redis 存在阻塞时,应用响应时间就会延长,应用可以感知发现问题,并发送报警给管理人员。
13. 你知道有哪些 Redis 分区实现方案?
客户端分区就是在客户端就已经决定数据会被存储到哪个 Redis 节点或者从哪个 Redis 节点读取。大多数客户端已经实现了客户端分区。
代理分区意味着客户端将请求发送给代理,然后代理决定去哪个节点写数据或者读数据。代理根据分区规则决定请求哪些 Redis 实例,然后根据 Redis 的响应结果返回给客户端。Redis 和 Memcached 的一种代理实现就是 Twemproxy。
查询路由(Query routing)的意思是客户端随机地请求任意一个 Redis 实例,然后由 Redis 将请求转发给正确的 Redis 节点。
Redis Cluster 实现了一种混合形式的查询路由,但并不是直接将请求从一个 Redis 节点转发到另一个 Redis 节点,而是在客户端的帮助下直接 redirected 到正确的 Redis 节点。
14. Redis 集群之间的复制方式是?
需要知道 Redis 的复制方式前,需要知道主从复制(Master-Slave Replication)的工作原理,具体为:
- Slave 从节点服务启动并连接到 Master 之后,它将主动发送一个 SYNC 命令;
- Master 服务主节点收到同步命令后将启动后台存盘进程,同时收集所有接收到的用于修改数据集的命令,在后台进程执行完毕后,Master 将传送整个数据库文件到 Slave,以完成一次完全同步;
- Slave 从节点服务在接收到数据库文件数据之后将其存盘并加载到内存中;
- 此后,Master 主节点继续将所有已经收集到的修改命令,和新的修改命令依次传送给 Slaves,Slave 将在本次执行这些数据修改命令,从而达到最终的数据同步。
整个执行的过程都是使用异步复制的方式进行复制。
15. 什么是数据库缓存双写一致性?
当一个数据需要更新时,因为不可能做到同时更新数据库和缓存,那么此时读取数据的时候就一定会发生数据不一致问题,而数据不一致问题在金融交易领域的系统中是肯定不允许的。
解决办法:读的时候先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。更新的时候,先更新数据库,然后再删除缓存。
16. 为什么要做 Redis 分区?
分区可以让 Redis 管理更大的内存,Redis 将可以使用所有机器的内存。如果没有分区,你最多只能使用一台机器的内存。
分区使 Redis 的计算能力通过简单地增加计算机得到成倍提升,Redis 的网络带宽也会随着计算机和网卡的增加而成倍增长。
17. 分布式 Redis 是前期做还是后期规模上来了再做好,为什么?
为防止以后扩容增加难度,最好的办法就是一开始就使用分布式。即便只有一台服务器,也可以一开始就让 Redis 以分布式的方式运行,使用分区,在同一台服务器上启动多个实例。
刚开始操作时比较麻烦,但是当数据不断增长,需要更多的 Redis 服务器时,只需要将 Redis 实例从一台服务迁移到另外一台服务器即可,而不用考虑重新分区的问题。一旦添加了另一台服务器,只需要将一半的 Redis 实例从第一台机器迁移到第二台机器。
18. 如果有大量的 key 需要设置同一时间过期,一般需要注意什么?
如果大量的 key 过期时间设置的过于集中,到过期的那个时间点,redis 可能会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一些。
19. 什么是分布式锁?有什么作用?
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在单机或者单进程环境下,多线程并发的情况下,使用锁来保证一个代码块在同一时间内只能由一个线程执行。比如 Java 的 Synchronized 关键字和 Reentrantlock 类。
分布式锁的作用是当多个进程不在同一个系统中,用分布式锁可以控制多个进程对资源的访问。
20. 分布式锁可以通过什么来实现?
- 可以使用 Memcached 实现分布式锁:Memcached 提供了原子性操作命令 add,线程获取到锁。key 已存在的情况下,则 add 失败,获取锁也失败。
- 也可以使用 Redis 实现分布式锁:Redis 的 setnx 命令为原子性操作命令。只有在 key 不存在的情况下,才能 set 成功。和 Memcached 的 add 方法比较类似。
- 还可以使用 ZooKeeper 分布式锁:利用 ZooKeeper 的顺序临时节点,来实现分布式锁和等待队列。
- 还有 Chubby 实现分布式锁:Chubby 底层利用了 Paxos 一致性算法,实现粗粒度分布式锁服务。
21. 介绍一下分布式锁实现需要注意的事项?
分布式锁实现需要保证以下几点:
- 互斥性:任意时刻,只有一个资源能够获取到锁;
- 容灾性:在未成功释放锁的的情况下,一定时限内能够恢复锁的正常功能;
- 统一性:加锁和解锁保证同一资源来进行操作。
四、持久化(8 题)
1. Redis 中的 bgsave 的原理是什么?
你给出两个词汇就可以了,fork 和 cow。fork 是指 Redis 通过创建子进程来进行 bgsave 操作,cow 指的是 copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。
2. 如何选择合适的持久化方式?
在实际的生产环境当中对于选择 RDB 持久化方式还是 AOF 持久化方式都需要根据数据量、应用对数据的安全要求、硬件相关预算等情况综合考虑,同时也可以考虑主从复制的策略,可以设置主机(master)和从机(slave)使用不同的持久化方案。对此以下只是简单的进行一个选择的介绍,具体在实际的开发应用过程中还需要根据实际情况综合选择。
- 如果在一段时间内对 Redis 里的数据完全丢弃也不会有什么影响的情况,那么选择 RDB 持久化方式比选择 AOF 持久化更有利;但是如果需要预防数据秒级别的丢失时,就只能选择 AOF 持久化方式了。
- 当 Redis 的结构是主从架构的情况下,可以考虑使用读写分离来分担 Redis 的读请求压力,也预防主机(master)宕机后继续提供服务。这时候可以采用这样一种策略:关闭主机(master)的持久化功能,这样保持主机(master)的高效性,然后关闭从机(slave)的 RDB 持久化,开启 AOF 持久化,同时建议关闭 AOF 的自动重写功能,通过设置一个定时任务,在应用访问次数比较少的时间点再通过调用 bgrewriteaof 命令进行自动重写,为了防止持久化数据的丢失,建议设置一个定时器在某个时间点对持久化文件进行备份并标记好备份时间点。
3. Redis 持久化数据和缓存怎么做扩容?
如果 Redis 被当做缓存使用,使用一致性哈希实现动态扩容缩容。
如果 Redis 被当做一个持久化存储使用,必须使用固定的 keys-to-nodes 映射关系,节点的数量一旦确定不能变化。
否则的话(即 Redis 节点需要动态变化的情况),必须使用可以在运行时进行数据再平衡的一套系统,而当前只有 Redis 集群可以做到这样。
4. Redis 持久化机制有哪些?
Redis 主要支持的持久化机制为 RDB(快照)和 AOF(追加文件)。
RDB 持久化是在指定时间间隔内保存数据快照到硬盘中。但 RDB 的持久化方式没有办法实现实时性的持久化。当应用使用 RDB 持久化后,如果 Redis 系统发生崩溃,那么使用 RDB 恢复数据时,恢复后的数据中,存在丢失最近一次生成快照之后更改的所有数据。所以 RDB 持久化不适用于丢失一部分数据也会对应用造成很大影响的备份中。
AOF 持久化是把命令追加到操作日志的尾部,然后保存所有历史操作。AOF 主要是解决数据持久化的实时性。Redis 服务器默认开启 RDB,关闭 AOF;要开启 AOF,需要在配置文件中配置:appendonly yes。AOF 持久化相对于 RDB 持久化的优点在于可以实时的对 Redis 缓存进行写入记录,保证快速恢复缓存时的完整性。
5. Redis 持久化机制 AOF 和 RDB 有哪些不同之处?
RDB 和 AOF 的区别:
- 持久化的方式不同:RDB 持久化是在指定时间间隔内保存数据快照到硬盘中(快照的方式)。AOF 持久化是把命令追加到操作日志的尾部,然后保存所有历史操作(追加文件)。
- 恢复的数据安全性不同:RDB 恢复数据时,恢复后的数据中,存在丢失最近一次生成快照之后更改的所有数据;AOF 持久化的数据实时性和安全性更高。
- 缓存恢复的速度不同:RDB 产生的文件紧凑压缩高,读取 RDB 文件恢复时速度比 AOF 快;由于 AOF 实时追加写命令,所以 AOF 的缓存文件体积比较大,但是可以通过重写(rewrite)压缩 AOF 持久化文件体积。
6. 请介绍一下 RDB 持久化机制的优缺点
它的优点有:
- RDB 是在某个时间点上的数据快照,非常适合使用在全量备份的情况,生成的 RDB 是一个紧凑压缩的二进制文件。快照产生的 RDB 文件可以拷贝到远程机器或文件系统中,用于灾难恢复;
- RDB 的加载恢复数据速度快于 AOF 的恢复方式。
它的缺点有:
- 因为 bgsave 每次运行都要执行 fork 操作创建子进程,操作比较繁琐,如果实时存储快照会导致成本过高,所以 RDB 只能在特定条件下进行一次持久化,从而容易出现数据丢失的情况;
- RDB 文件是一个特定二进制格式保存的文件,Redis 的版本更新过程中,有对 RDB 的版本格式修改,会出现老版本的 RDB 文件无法兼容新版本的 RDB 格式问题。
7. 请介绍一下 AOF 持久化机制的优缺点
它的优点有:
- AOF 持久化可以保证数据非常完整,故障恢复时相对 RDB 持久化丢失的数据最少;
- 由于 AOF 是可以实时对缓存命令追加到 AOF 文件的末尾,所以可以对历史操作的缓存命令进行处理。
它的缺点有:
- 由于 AOF 持久化是不断对 AOF 文件进行追加记录的,会导致 AOF 文件体积很大,极端的情况下可能会出现 AOF 文件用完硬盘的可用空间;但是 Redis 2.4 版本以后支持 AOF 自动重写,有效的解决 AOF 文件过大的问题;
- 当 AOF 文件体积很大时,会出现恢复速度慢,对性能影响大的问题;
- 当开启 AOF 后,对 QPS 会有一定影响,相对 RDB 来说,写 QPS 会下降。
8. 如果 AOF 文件的数据出现异常,Redis 服务怎么处理?
如果 AOF 文件数据出现异常,为了保证数据的一致性,Redis 服务器会拒绝加载 AOF 文件。可以尝试使用 redis-check-aof -fix 命令修复。
五、集群、复制(12 题)
1. 是否使用过 Redis 集群,集群的原理是什么?
使用过 Redis 集群,它的原理是:
- 所有的节点相互连接
- 集群消息通信通过集群总线通信,集群总线端口大小为客户端服务端口 + 10000(固定值)
- 节点与节点之间通过二进制协议进行通信
- 客户端和集群节点之间通信和通常一样,通过文本协议进行
- 集群节点不会代理查询
- 数据按照 Slot 存储分布在多个 Redis 实例上
- 集群节点挂掉会自动故障转移
- 可以相对平滑扩/缩容节点
Redis 集群中内置了 16384 个哈希槽,当需要在 Redis 集群中放置一个 key-value 时,redis 先对 key 使用 crc16 算法算出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0~16383 之间的哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点。
2. Redis 集群方案什么情况下会导致整个集群不可用?
Redis 没有使用哈希一致性算法,而是使用哈希槽。Redis 中的哈希槽一共有 16384 个,计算给定密钥的哈希槽,我们只需要对密钥的 CRC16 取摸 16384。假设集群中有 A、B、C 三个集群节点,不存在复制模式下,每个集群的节点包含的哈希槽如下:
- 节点 A 包含从 0 到 5500 的哈希槽;
- 节点 B 包含从 5501 到 11000 的哈希槽;
- 节点 C 包含从 11001 到 16383 的哈希槽;
- 这时,如果节点 B 出现故障,整个集群就会出现缺少 5501 到 11000 的哈希槽范围而不可用。
3. Redis 集群架构模式有哪几种?
Redis 集群架构是支持单节点单机模式的,也支持一主多从的主从结构,还支持带有哨兵的集群部署模式。
4. 说说 Redis 哈希槽的概念?
Redis 集群并没有使用一致性 hash,而是引入了哈希槽的概念。Redis 集群有 16384(2^14)个哈希槽,每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash 槽。
5. Redis 集群的主从复制模型是怎样的?
Redis 集群支持的主从复制,数据同步主要有两种方法:一种是全量同步,一种是增量同步。
1. 全量同步
刚开始搭建主从模式时,从机需要从主机上获取所有数据,这时就需要 Slave 将 Master 上所有的数据进行同步复制。复制的步骤为:
- 从服务器发送 SYNC 命令,链接主服务器;
- 主服务器收到 SYNC 命令后,进行存盘的操作,并继续收集后续的写命令,存储缓冲区;
- 存盘结束后,将对应的数据文件发送到 Slave 中,完成一次全量同步;
- 主服务数据发送完毕后,将进行增量的缓冲区数据同步;
- Slave 加载数据文件和缓冲区数据,开始接受命令请求,提供操作。
2. 增量同步
从节点完成了全量同步后,就可以正式的开启增量备份。当 Master 节点有写操作时,都会自动同步到 Slave 节点上。Master 节点每执行一个命令,都会同步向 Slave 服务器发送相同的写命令,当从服务器接收到命令,会同步执行。
6. Redis 集群会有写操作丢失吗?为什么?
Redis 集群中有可能存在写操作丢失的问题,但是丢失概率一般可以忽略不计。主要是 Redis 并没有一个机制保证数据一定写不丢失。在以下问题中可能出现键值丢失的问题:
1. 超过内存的最大值,键值被清理
Redis 中可以设置缓存的最大内存值,当内存中的数据总量大于设置的最大内存值,会导致 Redis 对部分数据进行清理,导致键值丢失的问题。
2. 大量的 key 过期,被清理
这种情况比较正常,只是因为键值设置的时间过期了,被自动清理了。
3. Redis 主库服务器故障重启
由于 Redis 的数据是缓存在内存中的,如果 Redis 主库服务器出现故障重启,会出现数据被清空的问题。这时可能导致从库的数据同步被清空。如果有使用数据持久化,那么故障重启后数据是可以自动恢复的。
4. 网络问题
可能出现网络故障,导致短时间内数据写入失败。
7. Redis 集群之间是如何复制的?
使用异步复制。
8. Redis 集群最大节点个数是多少?
Redis 集群有 16384 个哈希槽。
9. Redis 集群如何选择数据库?
Redis 集群不支持选择数据库操作,默认在 0 数据库。
10. Redis 集群方案应该怎么做,有哪些方案?
Redis 可以使用的集群方法有:
- Redis cluster 3.0:这是 Redis 自带的集群功能,它采用的分布式算法是哈希槽,而不是一致性 hash。支持主从结构,可以扩展多个从服务器,当主节点挂了可以很快的切换到一个从节点做主节点,然后从节点都读取到新的主节点。
- Twemproxy,它是 Twitter 开源的一个轻量级后端代理,可以管理 Redis 或 Memcache 集群。它相对于 Redis 集群来说,易于管理。它的使用方法和 Redis 集群没有任何区别,只需要设置好多个 Redis 实例后,在本需要连接 redis 的地方改为连接 Twemproxy,它会以一个代理的身份接收请求并使用一致性 hash 算法,将请求转接到具体 Redis 节点,将结果再返回 Twemproxy。对于客户端来说,Twemproxy 相当于是缓存数据库的入口,它不需要知道后端如何部署的。Twemproxy 会检测与每个节点的连接是否正常,如果存在异常节点,则会被剔除,等一段时间后,Twemproxy 还会再次尝试连接被剔除的节点。
- Codis,它是一个 Redis 分布式的解决方式,对于应用使用 Codis Proxy 的连接和使用 Redis 服务的没有明显差别,应用能够像使用单机 Redis 一样,让 Codis 底层处理请求转发,不停机的数据迁移等工作。
11. Redis sentinel(哨兵)模式优缺点有哪些?
Redis 哨兵的好处在于可以保证系统的高可用,各个节点可以对故障自动转移。但缺点是使用的主从模式,主节点单点风险高,主从切换过程可能会出现丢失数据的问题。
12. Redis 的主从复制模式有什么优缺点?
主从复制的模式相对于单节点的好处在于,实行读写分离提高了系统的读写效率,提高了网站数据的读取加载速度。但是缺点是由于写数据主要在主节点上操作,主节点内存空间有限,并且主节点存在单点风险。
六、性能优化(20 题)
1. Redis 常见性能问题和解决方案有哪些?
常见问题及解决办法有:
- Master 最好不要写内存快照,如果 Master 写内存快照,save 命令调度 rdbSave 函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性暂停服务
- 如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一
- 为了主从复制的速度和连接的稳定性,Master 和 Slave 最好在同一个局域网
- 尽量避免在压力很大的主库上增加从
- 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1<- Slave2 <- Slave3… 这样的结构方便解决单点故障问题,实现 Slave 对 Master 的替换。如果 Master 挂了,可以立刻启用 Slave1 做 Master,其他不变。
2. Redis 过期键的删除策略?
过期键的删除策略是将惰性删除策略和定期删除策略组合使用。
1. 定时删除策略
该策略的作用是给 key 设置过期时间的同时,给 key 创建一个定时器,定时器在 key 的过期时间来临时,对这些 key 进行删除。 这样做的好处是保证内存空间得以释放。但是缺点是给 key 创建一个定时器会有一定的性能损失。如果 key 很多,删除这些 key 占用的内存空间也会占用 CPU 很多时间。
2. 惰性删除策略
每次从数据库取 key 的时候检查 key 是否过期,如果过期则删除,并返回 null,如果 key 没有过期,则直接返回数据。
这样做的好处是占用 CPU 的时间比较少。但是缺点是如果 key 很长时间没有被获取,将不会被删除,容易造成内存泄露。
3. 定期删除策略
该策略的作用是每隔一段时间执行一次删除过期 key 的操作,该删除频率可以在 redis.conf 配置文件中设置。
这样做的好处是可以避免惰性删除时出现内存泄露的问题,通过设置删除操作的时长频率,可以减少 CPU 时间的占用。但是缺点是相对内存性能友好来说,该策略不如定时删除策略,相对 CPU 性能友好来说,该策略不如惰性删除策略。
Redis 采用的删除策略是将惰性删除策略和定期删除策略组合使用。
3. Redis 的回收策略(淘汰策略)?
Redis 提供了 6 种淘汰策略:
- volatile-lru:该淘汰策略是被使用最多的一种,从已设置过期时间的数据集(server.db [i].expires)中挑选最近最少使用的数据淘汰;而没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失;
- volatile-ttl:从已设置过期时间的数据集(server.db [i].expires)中挑选将要过期的数据淘汰,它和 volatile-lru 的区别在于 key 的剩余生存时间 ttl 的值越小越优先被淘汰,而 volatile-lru 是根据使用程度越小越被淘汰;
- volatile-random:从已设置过期时间的数据集(server.db [i].expires)中随机选择数据淘汰;
- allkeys-lru:从数据集(server.db [i].dict)中挑选最近最少使用的数据淘汰;它和 volatile-lru 淘汰机制的区别在于 allkeys-lru 针对的是全体 key 对象,淘汰的 key 不只是包括设置了过期时间的 key,也包括了没有设置过期时间的 key。而 volatile-lru 主要是针对设置了过期时间的 key 进行淘汰;
- allkeys-random:从数据集(server.db [i].dict)中随机选择数据淘汰;
- no-enviction:Redis 默认使用这种淘汰策略,当内存达到限制的时候,不淘汰任何数据,不可写入任何数据集,所有引起申请内存的命令会报错。它的优点在于可以保证数据不被丢失,但是它的缺点是会导致线上的业务不能持续进行的问题。这个淘汰策略下除了 DEL 请求和读请求服务可以继续被执行外,不能继续执行写请求。
4. 如何优化 Redis 服务的性能?
主要可以从以下几点进行优化:
- 对 Master 节点不进行持久化工作,让 Slave 节点进行 AOF 备份,对数据做持久化操作;
- 可以把主从节点都部署在同一个局域网内,从而保证复制速度与稳定性;
- 根据主库的压力情况来设置或者增加从库;
主从复制的结构一定要使用单向结构,避免使用图状结构。
5. Redis 回收进程如何工作的?
当客户端运行了新命令添加新数据,Redis 都会检查内存使用情况,如果内存使用情况大于 maxmemory 的限制,那么回根据设置的淘汰策略进行回收。所以会出现内存的使用限制不断的超过边界又被回收到边界以下。
6. 都有哪些办法可以降低 Redis 的内存使用情况呢?
可以使用 Hash、list、sorted set、set 等集合类型数据,更有利于将数据存储得更紧凑,更有利于内存空间的使用。
7. Redis 的慢查询修复经验有哪些?怎么修复的?
可以对大对象数据进行拆分,防止执行一次命令操作过多的数据。也可以将一些算法复杂度高的命令或执行效率低的命令,禁用或者替换成高的指令。
8. Redis 慢查询是什么?通过什么配置?
慢查询是指系统执行命令之后,当计算系统执行的指令时间超过设置的阀值,该命令就会被记录到慢查询中,该命令叫做慢指令。
Redis 慢查询的参数配置有:
1. slowlog-log-slower-than
该参数的作用为设置慢查询的阈值,当命令执行时间超过这个阈值就认为是慢查询。单位为微妙,默认为 10000。
可以根据自己线上的并发量进行调整这个值。如果存在高流量的场景,那么建议设置这个值为 1 毫秒,因为每个命令执行的时间如果超过 1 毫秒,那么 Redis 的每秒操作数最多只能到 1000。
2. slowlog-max-len
该参数的作用为设置慢查询日志列表的最大长度,当慢查询日志列表处于最大长度时,最早插入的一个命令将会被从列表中移除。
9. Redis 如何做内存优化?
内存优化可以从以下几点进行:
- Redis 对所有数据都进行 redisObject 封装;
- 对键、值对象的长度进行缩减,简化键名,提高内存使用效率;
- 减少 key 的数量,尽量使用哈希数据类型;
- 共享对象池,并设置空间极限值。
10. Redis 的内存用完了会发生什么?
如果 Redis 的内存使用达到了 redis.conf 配置文件中的设置上限,执行 Redis 的写命令会返回错误信息,但是还是支持读操作。解决这个问题的办法是,可以开启 Redis 的淘汰机制,当 Redis 内存达到上限时可以根据配置的策略删除一些数据,防止内存用完。
11. 缓存的更新策略有几种?分别有什么注意事项?
缓存的更新策略主要有以下几种:
- 先更新数据库,再更新缓存;
- 先删除缓存,再更新数据库;
- 先更新数据库,再删除缓存;
对应的策略分别需要注意的事项为:
1. 先更新数据库,再更新缓存
使用这种策略可能会存在以下两种问题:
- 缓存复杂度变高:写入缓存的数据如果需要经过特殊的计算,那么这时候更新缓存操作比删除缓存要复杂得多。因为更新完数据库后,得到的数据需要执行一次加工,最后得到的值才能更新缓存。
- 线程安全存在问题:如果存在线程 1 和线程 2 同时操作数据库和缓存,线程 1 先更新了数据库,线程 2 再更新数据库,这时候由于某种原因,线程 2 首先更新了缓存,线程 1 再更新。那么这样会导致产生脏数据的问题。因为数据库存储的是线程 2 更新后的数据,而缓存存储的是线程 1 更新的老数据。
2. 先删除缓存,再更新数据库
这种策略可能导致数据库数据和缓存数据不一致的问题。如果存在线程 1 和线程 2,线程 1 写数据先删除缓存,有一个线程 2 正好需要查询该缓存,发现缓存不存在,去访问数据库,并得到旧值放入缓存重,线程 1 再更新数据库。那么这时就出现了数据不一致的问题。如果缓存没有过期时间,那么这个脏数据一直存在。如果要解决这个问题,那么可以在更新完数据库后,对缓存再淘汰一次。
3. 先更新数据库,再删除缓存
这种策略可能导致数据库数据和缓存数据不一致的问题。如果在更新完数据库还没来得及删除缓存的时候,有请求过来从缓存中获取数据,那么可能会造成缓存和数据库数据不一致的问题。但是正常情况下,机器不出现故障或其他影响的情况下,不一致性的可能性相对较低。
12. Redis 单机会有瓶颈,那么怎么解决这个瓶颈?
可以对 Redis 进行集群部署,实行主从同步读写分离,可以方便的对 Redis 进行横向扩容,可以支撑系统更大数据量的缓存和提高系统的可用性。
13. 惰性删除有什么优点和缺点?
惰性删除的优点为:对 CPU 比较友好,因为每次键空间获取键时,检查获取到的键是否过期删除,删除的键只在选中中饿键,不用花费过多的时间和资源在其它任务上,不会增加 CPU 的负担。所以对 CPU 比较友好。
惰性删除的缺点为:但是对内存并不友好,没有被选中的键也有可能存在过期的,但是未被选中删除一直存在内存中。当存在很多这些过期键一直未被获取和未被选中删除,就会一直在内存中。所以对内存并不友好。
14. Redis 是单线程的,如何提高多核 CPU 的利用率?
可以在同一个服务器部署多个 Redis 的实例,并把他们当作不同的服务器来使用,在某些时候,无论如何一个服务器是不够的,所以,如果你想使用多个 CPU,你可以考虑一下分片(shard)。
15. 什么是缓存淘汰?
因为内存的空间是有限的,所以 Redis 引入了淘汰机制,主要为了解决在某个时间点,Redis 中存在很多过期键,定期删除策略随机抽查时没有抽查到,并且也没有走惰性删除策略时,大量过期键占用内存的问题。
16. Redis 报内存不足怎么处理?
Redis 内存不足可以这样处理:
- 修改配置文件 redis.conf 的 maxmemory 参数,增加 Redis 可用内存;
- 设置缓存淘汰策略,提高内存的使用效率;
- 使用 Redis 集群模式,提高存储量。
17. 如何设置 Redis 的内存上限?有什么作用?
设置方法为:修改配置文件 redis.conf 的 maxmemory 参数,该参数可以限制最大可用内存。
18. 简单介绍一下 Redis 的内存管理方式有哪些?
内存管理方式主要有两个,一是控制内存上限;二是优化回收策略,对内存回收。
19. 常见的淘汰算法有哪些?
主要常见的淘汰算法有:
1. 最近最少使用算法(LRU)
LRU 是 Least Recently Used 的缩写,中文意思是最近最少使用,它是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。
2. 先进先出算法(FIFO)
FIFO 是 First Input First Output 的缩写,即先入先出队列,这是一种传统的按序执行方法,先进入的指令先完成并引退,跟着才执行第二条指令。
3. 最不经常使用算法(LFU)
LFU 是 Least Frequently Used 的缩写,中文意思是最不经常使用。在一段时间内,数据被使用次数最少的,优先被淘汰。
20. 以下关于 NoSQL 的说法,不对的是:
A. Redis 支持字符串、哈希、列表、集合、有序集合等数据结构,目前 Redis 不支持事务。 B. MongoDB 支持 CAP 定理中的 AP,MySQL 支持 CAP 中的 CA,全部都支持不可能存在。 C. MongoDB 不用先创建 Collection 的结构就可以直接插入数据,目前 MongoDB 不支持事务。 D. Memcache 既支持 TCP 协议,也支持UDP协议,我们可以把 PHP的 Session 存放到 Memcache 中。
答案:A
本题解析:Redis 是支持事务的。选项 A 说法错误,所以选项 A 正确。
七、Redis 应用(12 题)
1. Redis 支持的 Java 客户端都有哪些?官方推荐用哪个?
主要支持的 Java 客户端有:Redisson、Jedis、lettuce 等,官方推荐使用 Redisson。
2. Twemproxy 有什么特点?
Twemproxy 具有以下特点:
- 软件快、轻量,配置简单。
- 可以维护持久连接,使缓存服务器的连接数很低。
- 支持一致性 Hash 等多种 hash 模式。
- 跨服务器自动分片。
- 命令请求与响应的流水线化,提升性能。
- 可以禁用失败节点,或监控端口对其进行状态监测。
3. 使用 Redis,设计一下在交易网站首页展示当天最热门售卖商品的前五十名商品列表?
我们从题目中分析知道,首页的热门售卖商品知道,访问的数据是热数据,且访问量很大,还需要对当天的热门售卖商品进行排行。
可以使用 zset 数据类型对热数据进行缓存,zset 可以对数据进行排行,把 key 作为商品的 ID,value 作为商品当天销售的数量,可以根据 value 对商品排行。
4. Redis 和 Redisson 有什么关系?
Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格,它充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。
Redisson 采用了基于 NIO 的 Netty 框架,不仅能作为 Redis 底层驱动客户端,具备提供对 Redis 各种组态形式的连接功能,对 Redis 命令能以同步发送、异步形式发送、异步流形式发送或管道形式发送的功能,LUA 脚本执行处理,以及处理返回结果的功能,还在此基础上融入了更高级的应用方案。
5. Jedis 与 Redisson 对比有什么优缺点?
它们的主要优缺点有:
- Jedis 相对 Redisson 来说,其 API 提供了比较全面的 Redis 命令支持;
- 因为 Jedis 中的 Java 方法基本和 Redis 的 API 保持一致,所以了解了 Redis 的 API,也能熟练的使用 Jedis;
- Jedis 只支持基本的数据类型,例如 String、Hash、List、Set、Sorted Set 等;
- Jedis 使用的是阻塞的 I/O,且其方法调用都是同步的,不支持异步,程序流需要等到 sockets 处理完 I/O 才能执行。Jedis 客户端实例不是线程安全的,所以需要通过连接池来使用 Jedis;
- Redisson 实现了分布式和可扩展的 Java 数据结构,童工了很多分布式相关的操作服务,例如分布式集合、分布式锁,可通过 Redis 支持延迟队列。它和 Jedis 相比,功能较为简单,不支持字符串操作,不支持排序、事务、管道、分区等 Redis 特性。这样做的原因是,Redisson 可以让使用者将精力集中放在处理业务逻辑上。
- Redisson 是基于 Netty 框架的事件驱动的通信层,方法调用是异步的。它的 API 是线程安全的,可以操作单个 Redisson 连接来完成各种操作。
6. Redis 主要消耗什么物理资源?
Redis 主要将数据存储在内存中,它是单进程单线程的,所以主要消耗的资源和瓶颈是内存而不是 CPU。
7. Redis 客户端使用的通讯协议是 RESP(Redis Serialization Protocol),以下是它的特点的是
A. 二进制安全 B. 在 TCP 层 C. 基于请求—响应模式 D. 不可以序列化不同的数据类型
答案:ABC
分析:Redis 客户端和 Redis 服务器通信时使用的是 RESP(REdis 序列化协议)通讯协议,该协议是专门为 Redis 设计的,但是也可以用于其他客户端和服务器软件项目中。
对于选项 A,RESP 是二进制安全的,并且不需要处理从一个进程传输到另一个进程的批量数据,因为它使用前缀长度来传输批量数据。所以选项 A 正确。
对于选项 B,Redis 客户端连接到 Redis 服务器,并创建与端口 6379 的 TCP 连接。尽管 RESP 在技术上不是特定于 TCP 的,但在 Redis 的上下文中,该协议仅用于 TCP 连接(或等效的面向流的连接,如 Unix 套接字)。所以选项 B 正确。
对于选项 C,Redis 接受由不同参数组成的命令。收到命令后,将对其进行处理并将答复发送回客户端。所以选项 C 正确。
对于选项 D,RESP 可以序列化不同的数据类型,例如整数,字符串,数组。还有一种特定的错误类型。请求以字符串数组的形式从客户端发送到 Redis 服务器,这些字符串表示要执行的命令的参数。Redis 使用特定于命令的数据类型进行回复。所以选项 D 错误。
所以本题的答案为 ABC。
8. 怎么提高缓存命中率?
主要常用的手段有:
- 提前加载数据到缓存中;
- 增加缓存的存储空间,提高缓存的数据;
- 调整缓存的存储数据类型;
- 提升缓存的更新频率。
9. 缓存命中率表示什么?
缓存命中率表示从缓存中读取数据时可以获取到数据的次数,命中率越高代表缓存的使用效率越高,应用的性能越好。
它的公式为:
缓存命中率 = 缓存中获取数据次数/获取数据总次数
- 缓存命中的意思是:从缓存中获取到需要的数据的次数;
- 缓存不命中的意思是:从缓存中无法获取所需数据,需要再次查询数据库或者其他数据存储载体的次数。
10. Redis 是一个基于内存且支持持久化的高性能 key-value 数据库,它支持的功能有哪些?
Redis(即 Remote Dictionary Server 的缩写,中文意思为远程字典服务)是一个使用 ANSI C 语言编写,开源的、支持网络、可基于内存亦可持久化的日志型、Key-Value 数据库,它提供了多种语言的 API。
它内部支持多种数据结构、数据持久化机制、主从结构、Redis 集群等功能。
11. 请简单描述一下 Jedis 的基本使用方法
Jedis 的基本使用方法:使用 Jedis 存储数据,Jedis 读取数据的操作命令和 Redis 读取数据的操作命令基本相同。
字符串类型:
- 存储数据:
jedis.set(String key,String value);
- 读取数据:
String value = jedis.gett(String key);
- 存储数据并在指定时间后删除:
jedis.setex(String key,int seconds,String value);
- 删除数据:
jedis.dle(String key);
hash 类型:map
- 存储数据:
jedis.hset(String key,String field,String value);
- 读取某个数据:
String value = jedis.hget(String key,String field);
- 读取所有数据:
Map<String,String> map = jedis.hgetall(String key);
- 删除数据:
jedis.hdel(String key,String field);
list类型,可以重复:
- 向列表最左边添加数据:
jedis.lpush(String key,String...strings);
可以存储多个数据,逗号隔开。 - 向列表最右边添加数据:
jedis.rpush(String key,String...strings);
- 按照范围查询数据:
jedis.lrange(String key,long start,long end);
- 删除列表最左边的数据:
jedis.lpop(String key);
- 删除列表最右边的数据:
jedis.rpop(String key);
set类型,不可重复:
- 存储数据:
jedis.sadd(String key,String...strings);
可以存储多个数据,逗号隔开。 - 获取数据:
jedis.smembers(String key);
- 删除数据:
jedis.srem(String key,String strings);
可以删除多个数据,逗号隔开。
sorted类型,不可重复,有序:
- 存储数据:
jedis.zadd(String key,double score,String value);
- 获取数据:
jedis.zrange(String key,long start,long end);
- 删除数据:
jedis.zrem(String key,String value);
原文链接:
12. Redis 如何解决 key 冲突?
Redis 如果 key 相同,后一个 key 会覆盖前一个 key。如果要解决 key 冲突,最好给 key 取好名区分开,可以按业务名和参数区分开取名,避免重复 key 导致的冲突。
Java 面试题集锦(网络篇)
前言
网络编程是程序员比较少接触的技能,但是网络协议的应用却是非常常见的,比如 RPC 框架如 Dubbo 使用 Netty 进行通信,而 Netty 是一个网络编程框架,服务提供端和服务消费端正是通过 Netty 这个网络框架提供的机制进行通信的,再比如 Spring Clound 使用 HTTP 协议作为服务提供端和服务消费端的通信协议,甚至前端同学和后端同学进行联调的时候也需要熟悉 HTTP 协议。
1. HTTP 响应码有哪些?分别代表什么含义?
HTTP 响应码是 Web 服务器告诉客户端当前服务器的运行状况的标识。
- 200:成功,Web 服务器成功处理了客户端的请求。
- 301:永久重定向,当客户端请求一个网址的时候,Web 服务器会将当前请求重定向到另一个网址,搜索引擎会抓取重定向后网页的内容并且将旧的网址替换为重定向后的网址。
- 302:临时重定向,搜索引擎会抓取重定向后网页的内容而保留旧的网址,因为搜索引擎认为重定向后的网址是暂时的。
- 400:客户端请求错误,多为参数不合法导致 Web 服务器验参失败。
- 404:未找到,Web 服务器找不到资源。
- 500:Web 服务器错误,服务器处理客户端请求的时候发生错误。
- 503:服务不可用,服务器停机。
- 504:网关超时。
2. Forward 和 Redirect 的区别?
- 浏览器 URL 地址:Forward 是服务器内部的重定向,服务器内部请求某个 servlet,然后获取响应的内容,浏览器的 URL 地址是不会变化的;Redirect 是客户端请求服务器,然后服务器给客户端返回了一个 302 状态码和新的 location,客户端重新发起 HTTP 请求,服务器给客户端响应 location 对应的 URL 地址,浏览器的 URL 地址发生了变化。
- 数据的共享:Forward 是服务器内部的重定向,request 在整个重定向过程中是不变的,request 中的信息在 servlet 间是共享的。Redirect 发起了两次 HTTP 请求分别使用不同的 request。
- 请求的次数:Forward 只有一次请求;Redirect 有两次请求。
3. 如何实现跨域?
当浏览器执行 JS 脚本的时候,会检测脚本要访问的协议、域名、端口号是不是和当前网址一致,如果不一致就是跨域。跨域是不允许的,这种限制叫做浏览器的同源策略,简单点的说法就是浏览器不允许一个源中加载脚本与其他源中的资源进行交互。那么如何实现跨域呢?
3.1 JSONP 方式
script、img、iframe、link、video、audio 等带有 src 属性的标签可以跨域请求和执行资源,JSONP 利用这一点“漏洞”实现跨域。
<script>
var scriptTag = document.createElement('script');
scriptTag.type = "text/javascript";
scriptTag.src = "http://10.10.0.101:8899/jsonp?callback=f";
document.head.appendChild(scriptTag);
</script>
再看下 jQuery 的写法。
$.ajax({
// 请求域名
url:'http://10.10.0.101:8899/login',
// 请求方式
type:'GET',
// 数据类型选择 jsonp
dataType:'jsonp',
// 回调方法名
jsonpCallback:'callback',
});
// 回调方法
function callback(response) {
console.log(response);
}
JSONP 实现跨域很简单但是只支持 GET 请求方式。而且在服务器端接受到 JSONP 请求后需要设置请求头,添加 Access-Control-Allow-Origin 属性,属性值为 *
,表示允许所有域名访问,这样浏览器才会正常解析,否则会报 406 错误。
response.setHeader("Access-Control-Allow-Origin", "*");
3.2 CORS 方式
CORS(Cross-Origin Resource Sharing)即跨域资源共享,需要浏览器和服务器同时支持,这种请求方式分为简单请求和非简单请求。
当浏览器发出的 XMLHttpRequest 请求的请求方式是 POST 或者 GET,请求头中只包含 Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type(application/x-www-form-urlencoded、multipart/form-data、text/plain)时那么这个请求就是一个简单请求。
对于简单的请求,浏览器会在请求头中添加 Origin 属性,标明本次请求来自哪个源(协议 + 域名 + 端口)。
GET
// 标明本次请求来自哪个源(协议+域名+端口)
Origin: http://127.0.0.1:8080
// IP
Host: 127.0.0.1:8080
// 长连接
Connection: keep-alive
Content-Type: text/plain
如果 Origin 标明的域名在服务器许可范围内,那么服务器就会给出响应:
// 该值上文提到过,表示允许浏览器指定的域名访问,要么为浏览器传入的 origin,要么为 * 表示所有域名都可以访问
Access-Control-Allow-Origin: http://127.0.0.1:8080
// 表示服务器是否同意浏览器发送 cookie
Access-Control-Allow-Credentials: true
// 指定 XMLHttpRequest#getResponseHeader() 方法可以获取到的字段
Access-Control-Expose-Headers: xxx
Content-Type: text/html; charset=utf-8
Access-Control-Allow-Credentials: true
表示服务器同意浏览器发送 cookie,另外浏览器也需要设置支持发送 cookie,否则就算服务器支持浏览器也不会发送。
var xhr = new XMLHttpRequest();
// 设置发送的请求是否带 cookie
xhr.withCredentials = true;
xhr.open('post', 'http://10.10.0.101:8899/login', true);
xhr.setRequestHeader('Content-Type', 'text/plain');
另外一种是非简单请求,请求方式是 PUT 或 DELETE,或者请求头中添加了 Content-Type:application/json 属性和属性值的请求。
这种请求在浏览器正式发出 XMLHttpRequest 请求前会先发送一个预检 HTTP 请求,询问服务器当前网页的域名是否在服务器的许可名单之中,只有得到服务器的肯定后才会正式发出通信请求。
预检请求的头信息:
// 预检请求的请求方式是 OPTIONS
OPTIONS
// 标明本次请求来自哪个源(协议+域名+端口)
Origin: http://127.0.0.1:8080
// 标明接下来的 CORS 请求要使用的请求方式
Access-Control-Request-Method: PUT
// 标明接下来的 CORS 请求要附加发送的头信息属性
Access-Control-Request-Headers: X-Custom-Header
// IP
Host: 127.0.0.1:8080
// 长连接
Connection: keep-alive
如果服务器回应预检请求的响应头中没有任何 CORS 相关的头信息的话表示不支持跨域,如果允许跨域就会做出响应,响应头信息如下:
HTTP/1.1 200 OK
// 该值上文提到过,表示允许浏览器指定的域名访问,要么为浏览器传入的 origin,要么为 * 表示所有域名都可以访问
Access-Control-Allow-Origin:http://127.0.0.1:8080
// 服务器支持的所有跨域请求方式,为了防止浏览器发起多次预检请求把所有的请求方式返回给浏览器
Access-Control-Allow-Methods: GET, POST, PUT
// 服务器支持预检请求头信息中的 Access-Control-Request-Headers 属性值
Access-Control-Allow-Headers: X-Custom-Header
// 服务器同意浏览器发送 cookie
Access-Control-Allow-Credentials: true
// 指定预检请求的有效期是 20 天,期间不必再次发送另一个预检请求
Access-Control-Max-Age:1728000
Content-Type: text/html; charset=utf-8
Keep-Alive: timeout=2, max=100
// 长连接
Connection: Keep-Alive
Content-Type: text/plain
接着浏览器会像简单请求一样,发送一个 CORS 请求,请求头中一定包含 Origin 属性,服务器的响应头中也一定得包含 Access-Control-Allow-Origin 属性。
3.3 代理方式
跨域限制是浏览器的同源策略导致的,使用 nginx 当做服务器访问别的服务的 HTTP 接口是不需要执行 JS 脚步不存在同源策略限制的,所以可以利用 Nginx 创建一个代理服务器,这个代理服务器的域名跟浏览器要访问的域名一致,然后通过这个代理服务器修改 cookie 中的域名为要访问的 HTTP 接口的域名,通过反向代理实现跨域。
Nginx 的配置信息:
server {
# 代理服务器的端口
listen 88;
# 代理服务器的域名
server_name http://127.0.0.1;
location / {
# 反向代理服务器的域名+端口
proxy_pass http://127.0.0.2:89;
# 修改cookie里域名
proxy_cookie_domain http://127.0.0.2 http://127.0.0.1;
index index.html index.htm;
# 设置当前代理服务器允许浏览器跨域
add_header Access-Control-Allow-Origin http://127.0.0.1;
# 设置当前代理服务器允许浏览器发送 cookie
add_header Access-Control-Allow-Credentials true;
}
}
前端代码:
var xhr = new XMLHttpRequest();
// 设置浏览器允许发送 cookie
xhr.withCredentials = true;
// 访问 nginx 代理服务器
xhr.open('get', 'http://127.0.0.1:88', true);
xhr.send();
5. get 和 post 请求有哪些区别?
用途:
- get 请求用来从服务器获取资源
- post 请求用来向服务器提交数据
表单的提交方式:
- get 请求直接将表单数据以
name1=value1&name2=value2
的形式拼接到 URL 上(http://www.baidu.com/action?name1=value1&name2=value2),多个参数参数值需要用 & 连接起来并且用?
拼接到 action 后面; - post 请求将表单数据放到请求头或者请求的消息体中。
传输数据的大小限制:
- get 请求传输的数据受到 URL 长度的限制,而 URL 长度是由浏览器决定的;
- post 请求传输数据的大小理论上来说是没有限制的。
参数的编码:
- get 请求的参数会在地址栏明文显示,使用 URL 编码的文本格式传递参数;
- post 请求使用二进制数据多重编码传递参数。
缓存:
- get 请求可以被浏览器缓存被收藏为标签;
- post 请求不会被缓存也不能被收藏为标签。
6. 简述 TCP 和 UDP 的区别?
TCP 和 UDP 都是传输层的网络协议,主要用途在于不同服务器的应用进程间的通信。
连接维护:
- TCP 是面向连接的,在不同服务器的应用进程建立通信连接的时候需要三次握手,断开连接的时候需要四次挥手,而且连接提供的是点对点的全双工服务;
- UDP 是无连接通信协议,以广播的形式向外发送数据,因为不需要建立连接所以不需要维护连接状态需要的资源少,可以做到快速响应时延低。
可靠性:
- TCP 提供了可靠的数据传输,支持流量控制和拥塞控制,为不丢包提供了可靠性的技术,如果发送方收到了数据发送失败的通知会再次进行发送,直到数据被接收到,同时保证了发送的数据包的顺序性和不重复的特性;
- UDP 提供了不可靠的数据传输,不支持流量控制和拥塞控制,发送的数据包发送失败不会收到通知,而是在一段固定的时间等待接受方是否回复信息判断上次的数据包是否发送成功,如果这段时间没有收到接受方的回复就会重新发送数据包,但是仍然无法解决丢包的问题和发送包的顺序性问题。
数据传输方式:
- TCP 是基于字节流进行传输的,传输时会将数据进行分组,然后在接收端对分组后的信息重组,这种传输方式会带来粘包问题;
- UDP 是基于数据报进行传输的。
接下来通过执行 UDP 服务端和客户端通信的测试用例和 TCP 服务端和客户端通信的测试用例,更直观的了解下这两种通信方式的区别。
UDP 服务端代码:
// UDPServerTest.java
public class UDPServerTest {
public static void main(String[] args) throws Exception {
DatagramSocket serverSocket = new DatagramSocket(8888);
byte[] readyToSendData;
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
while (true) {
byte[] receiveData = new byte[1024];
// 创建接收数据报,接受来自客户端的数据
DatagramPacket fromClientDataPacket = new DatagramPacket(receiveData, receiveData.length);
// 监听客户端是否发送了数据包
serverSocket.receive(fromClientDataPacket);
// 获取客户端数据包的内容
String data = new String(fromClientDataPacket.getData());
// 获取客户端 IP 地址
InetAddress address = fromClientDataPacket.getAddress();
if (data != null) {
System.out.println("【"+formatter.format(new Date()) + "】 receive data from client[" + address + "]: " + data );
}
// 获得客户端端口号
int port = fromClientDataPacket.getPort();
// 将获取到的数据包的内容转为大写
String upperData = data.toUpperCase();
readyToSendData = upperData.getBytes();
// 创建发送数据报,用来向客户端发送数据
DatagramPacket readyToSendPacket = new DatagramPacket(readyToSendData, readyToSendData.length, address, port);
//向客户端发送数据报包
serverSocket.send(readyToSendPacket);
}
}
}
UDP 客户端代码:
// UDPClientTest.java
public class UDPClientTest {
public static void main(String[] args) throws Exception {
DatagramSocket clientSocket = new DatagramSocket();
// 监听 console 的文字输入
BufferedReader inputFromConsole = new BufferedReader(new InputStreamReader(System.in));
// 获取 client 端的 IP 地址
InetAddress adress = InetAddress.getLocalHost();
byte[] readyToSendData;
byte[] receiveData = new byte[1024];
while (true) {
String input = inputFromConsole.readLine();
if (input.equals("exit")) break;
readyToSendData = input.getBytes();
// 创建发送数据报,用来向服务端发送数据
DatagramPacket readyToSendPacket = new DatagramPacket(readyToSendData, readyToSendData.length, adress, 8888);
//发送数据报包
clientSocket.send(readyToSendPacket);
// 创建接收数据报,接收来自服务端的数据
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
// 监听服务端是否发来了数据包
clientSocket.receive(receivePacket);
String data = new String(receivePacket.getData());
System.out.println("Server reply: " + data);
}
clientSocket.close();
}
}
UDP 服务端执行结果:
UDP 客户端执行结果:
TCP 服务端代码:
// TCPServerTest.java
public class TCPServerTest {
public static void main(String[] args) throws Exception {
String data;
String upperData;
SimpleDateFormat dataFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
ServerSocket serverSocket = new ServerSocket(8888);
while(true) {
// 接受客户端的连接
Socket socket = serverSocket.accept();
// 输入流,保存接收到的数据
BufferedReader isFromClient = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 输出流,用于向外发送数据
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
// 获取客户端数据
data = isFromClient.readLine();
if (data != null) {
System.out.println("【" + dataFormatter.format(new Date()) + "】 receive data from client[" + socket.getInetAddress() + "]: " + data );
}
upperData = data.toUpperCase() + '\n';
//向客户端发送修改后的字符串
dos.writeBytes(upperData);
}
}
}
TCP 客户端代码:
// TCPClientTest.java
public class TCPClientTest {
public static void main(String[] args) throws Exception {
String input;
String data;
while (true) {
// 监听 console 的文字输入
BufferedReader inputFromConsole = new BufferedReader(new InputStreamReader(System.in));
Socket clientSocket = new Socket("127.0.0.1", 8888);
// 输出流,用于向外发送数据
DataOutputStream readyToSendDos = new DataOutputStream(clientSocket.getOutputStream());
// 输入流,保存接收到的数据
BufferedReader receiveFromServer = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
input = inputFromConsole.readLine();
if (input.equals("exit")) break;
// 向服务器发送数据
readyToSendDos.writeBytes(input + '\n');
// IO 阻塞等待服务端的响应
data = receiveFromServer.readLine();
System.out.println("Server reply: " + data);
clientSocket.close();
}
}
}
TCP 服务端执行结果:
TCP 客户端执行结果:
7. TCP 为什么要三次握手,两次不行吗?为什么?
- TCP 客户端和服务端建立连接需要三次握手,首先服务端需要开启监听,等待客户端的连接请求,这个时候服务端处于“收听”状态;
- 客户端向服务端发起连接,选择
seq=x
的初始序列号,此时客户端处于“同步已发送”的状态; - 服务端收到客户端的连接请求,同意连接并向客户端发送确认,确认号是
ack=x+1
表示客户端可以发送下一个数据包序号从x+1
开始,同时选择seq=y
的初始序列号,此时服务端处于“同步收到”状态; - 客户端收到服务端的确认后,向服务端发送确认信息,确认号是
ack=y+1
表示服务端可以发送下一个数据包序号从y+1
开始,此时客户端处于“已建立连接”的状态; - 服务端收到客户端的确认后,也进入“已建立连接”的状态。
从三次握手的过程可以看出如果只有两次握手,那么客户端的起始序列号可以确认,服务端的起始序列号将得不到确认。
8. 说一下 TCP 粘包是怎么产生的?怎么解决粘包问题的?
上文中讲 TCP 和 UDP 区别的时候提到 TCP 传输数据基于字节流,从应用层到 TCP 传输层的多个数据包是一连串的字节流是没有边界的,而且 TCP 首部并没有记录数据包的长度,所以 TCP 传输数据的时候可能会发送粘包和拆包的问题;而 UDP 是基于数据报传输数据的,UDP 首部也记录了数据报的长度,可以轻易的区分出不同的数据包的边界。
接下来看下 TCP 传输数据的几种情况,首先第一种情况是正常的,既没有发送粘包也没有发生拆包。
第二种情况发生了明显的粘包现象,这种情况对于数据接收方来说很难处理。
接下来的两种情况发生了粘包和拆包的现象,接收端收到的数据包要么是不完整的要么是多出来一块儿。
造成粘包和拆包现象的原因:
- TCP 发送缓冲区剩余空间不足以发送一个完整的数据包,将发生拆包;
- 要发送的数据超过了最大报文长度的限制,TCP 传输数据时进行拆包;
- 要发送的数据包小于 TCP 发送缓冲区剩余空间,TCP 将多个数据包写满发送缓冲区一次发送出去,将发生粘包;
- 接收端没有及时读取 TCP 发送缓冲区中的数据包,将会发生粘包。
粘包拆包的解决方法:
- 发送端给数据包添加首部,首部中添加数据包的长度属性,这样接收端通过首部中的长度字段就可以知道数据包的实际长度啦;
- 针对发送的数据包小于缓冲区大小的情况,发送端可以将不同的数据包规定成同样的长度,不足这个长度的补充 0,接收端从缓冲区读取固定的长度数据这样就可以区分不同的数据包;
- 发送端通过给不同的数据包添加间隔符合确定边界,接收端通过这个间隔符合就可以区分不同的数据包。
9. TCP 如何保证可靠性
序列号和确认号机制:
TCP 发送端发送数据包的时候会选择一个 seq 序列号,接收端收到数据包后会检测数据包的完整性,如果检测通过会响应一个 ack 确认号表示收到了数据包。
超时重发机制:
TCP 发送端发送了数据包后会启动一个定时器,如果一定时间没有收到接受端的确认后,将会重新发送该数据包。
对乱序数据包重新排序:
从 IP 网络层传输到 TCP 层的数据包可能会乱序,TCP 层会对数据包重新排序再发给应用层。
丢弃重复数据:
从 IP 网络层传输到 TCP 层的数据包可能会重复,TCP 层会丢弃重复的数据包。
流量控制:
TCP 发送端和接收端都有一个固定大小的缓冲空间,为了防止发送端发送数据的速度太快导致接收端缓冲区溢出,发送端只能发送接收端可以接纳的数据,为了达到这种控制效果,TCP 用了流量控制协议(可变大小的滑动窗口协议)来实现。
10. 拥塞控制与流量控制的区别?
- 流量控制是为了防止发送端发送数据过快把接收端的缓存填满,引入滑动窗口来控制流量;
- 拥塞控制是为了防止当网络发生拥塞的时候控制发送的速率,引入拥塞窗口来来反映网络传输能力,窗口变量记做 cwnd。
先来看下流量控制的原理,下图为 TCP 发送端的发送情况:
- 红色的数据包表示发送已确认的包;
- 绿色的数据包表示发送未确认的包;
- 蓝色的数据包表示已经准备好但是未发送的包;
- 紫色的数据包表示没有准备好不可以发送的包;
可以看到滑动窗口的固定大小是 5,滑动窗口的大小等于未确认的包 + 已经准备好但是未发送的包,每次收到 ACK 就会增加一个红色的数据包,然后滑动窗口向右移动一位,即最左边的绿色数据包移出滑动窗口变成红色,下一个紫色的数据包进入滑动窗口变成蓝色准备发送。
再看下接收端的接收情况:
- 红色的数据包表示接收已确认的包;
- 绿色的数据包表示等待接收和未确认的包;
- 紫色的数据包表示不能接收的包;
在接收端这边可以看到滑动窗口内有一个虚线的数据包,表示该数据包还没有收到,该数据包可能发生了丢包的情况或者乱序了,这个时候接收端会先将该数据包缓存着,等待发送端超时重发。
当发送端发送数据包速度过快,导致接收端的缓存空间不够用,或者接收端一直不读取缓存空间中的数据包,接收端可以改变滑动窗口的大小甚至变为 0,让发送端停止发送数据包,从下图可以看出滑动窗口并没有向右移动,而是滑动窗口左边界向右移动了一格。
这种情况下接收端的滑动窗口的大小变成了 0,发送端也不再发送新的数据包,但是发送端会定时发送窗口探测数据包,和接收端商量是否可以改变滑动窗口的大小,接收端在窗口大小比较小的情况下不会立马回复发送端可以改变窗口大小,而是等到接收端的缓存空间为空的时候再改变窗口大小。
接下来再分析下拥塞控制的原理,先看下图这种极端情况,管道中能容纳 6 个数据包,有 3 个未接收的数据包和 3 个 ack 数据包,正好把管道撑满,如果再往管道中发送数据,那么数据包会被缓存在设备中增加时延或者丢弃多余的包,而 TCP 面对丢包的问题只能重发数据,重发数据又导致了更严重的延迟和更多的丢包,从而造成恶心循环,而拥塞控制就是为了处理时延带来的超时重传和丢包问题的。
先来看下拥塞窗口和发送端实际可用的窗口大小的联系:
- 发送端实际可用窗口大小 = min{awnd, cwnd};
- 其中 awnd 表示接收端的窗口大小,cwnd 表示拥塞窗口的大小,当发生超时重传的时候拥塞窗口的值会急剧的变小,发送端窗口的大小也跟着急剧变小,从而控制了发送端发出的数据量。
如下图所示,拥塞控制一共有 4 个算法。
慢开始:
- 当一个 TCP 连接刚启动的时候,或者是超时重传导致的丢包时,需要执行慢开始,慢开始会初始化 cwnd = 1 MSS(初始化窗口值);
- 然后一点点提速,每当发送端收到一个 ack,cwnd 就会扩大一倍,cwnd = 2^k(k 代表轮次)呈现出指数上升。当遇到 ssthresh(slow start threshold)阈值的时候,就会进入拥塞避免状态,之后呈现线性分布。
拥塞避免:
当 cwnd 增长过快并且超过了 ssthresh 阈值的话,为了防止网络拥塞,将会进入拥塞避免状态,cwnd 呈现线性增长。
超时重传:
当遇到超时重传导致的丢包的时候,ssthresh = 当前 cwnd/2,cwnd 值重置为 1,然后重新进入慢开始状态。
快速恢复:
- 当执行慢开始的时候,收到了 3 个重复的 ack 包,表示现在的网络环境没有那么拥塞了,可以进入快速恢复的状态啦;
- 这个时候 ssthresh = 当前 cwnd/2,cwnd 重置为新的 ssthresh,即 cwnd = ssthresh ,然后进入拥塞避免状态。
11. OSI 的七层模型都有哪些?
- 应用层:各种应用程序协议,比如 HTTP、HTTPS、FTP、SOCKS 安全套接字协议、DNS 域名系统、GDP 网关发现协议等等。
- 表示层:加密解密、转换翻译、压缩解压缩,比如 LPP 轻量级表示协议。
- 会话层:不同机器上的用户建立和管理会话,比如 SSL 安全套接字层协议、TLS 传输层安全协议、RPC 远程过程调用协议等等。
- 传输层:接受上一层的数据,在必要的时候对数据进行分割,并将这些数据交给网络层,保证这些数据段有效到达对端,比如 TCP 传输控制协议、UDP 数据报协议。
- 网络层:控制子网的运行:逻辑编址、分组传输、路由选择,比如 IP、IPV6、SLIP 等等。
- 数据链路层:物理寻址,同时将原始比特流转变为逻辑传输路线,比如 XTP 压缩传输协议、PPTP 点对点隧道协议等等。
- 物理层:机械、电子、定时接口通信信道上的原始比特流传输,比如 IEEE802.2 等等。
12. 网络浏览器访问一个网址的整个过程?
\1. 应用层的 DNS 域名解析协议将网址解析为 IP 地址。
浏览器会先查看缓存中有没有 DNS 解析过的这个 IP 地址,如果有的话就不会查询 DNS 服务;如果没有就查看操作系统的 hosts 文件是 否有 DNS 解析过的这个 IP 地址;如果还是没有的话就会向 DNS 服务器发送数据包对域名进行解析,找到解析后的 IP 地址后返回客户端。
\2. 浏览器得到网址对应的服务器的 IP 地址后,通过与服务器三次握手建立 TCP 连接。
\3. 浏览器与服务器建立好 TCP 连接后,就会发送 HTTP 请求。
- 首先在应用层会封装一个 HTTP 请求,包括通用头、请求头和报文主体等;
- 然后在传输层会将 HTTP 数据包嵌入到 TCP 报文段中;
- 然后在网络层将 TCP 数据包嵌入到 IP 报文段中;
- 最后将 IP 数据包嵌入到以太网数据包中在网络中传输。
\4. 服务器处理浏览器发送的 HTTP 请求。
浏览器向 Web 服务器如 Nginx 发送 HTTP 请求,Nginx 将请求转发给 Tomcat 服务器,Tomcat 服务器请求 MySQL、Redis 等 DB 服务器,得到结果后将 Velocity 模板引擎和数据整合,将生成的静态页面文件通过 Nginx 返回给浏览器。
\5. 服务器返回响应结果。
在响应头中返回了 HTTP 状态码、HTTP 协议版本、是否为长连接、文本编码类型、日期等等。
\6. 浏览器和者服务器通过四次挥手关闭 TCP。
\7. 浏览器解析 HTML、CSS、JS 等进行页面渲染。
14. 域名解析详细过程
- 浏览器访问 www.baidu.com,询问本地 DNS 服务器是否缓存了该网址解析后的 IP 地址。
- 如果本地 DNS 服务器没有缓存的话,就去 root-servers.net 根服务器查询该网址对应的 IP 地址。
- 根服务器返回顶级域名服务器的网址 gtld-servers.net,然后本地 DNS 服务器去顶级域名服务器查询该网址对应的 IP 地址。
- 顶级域名服务器返回 www.baidu.com 主区域服务器的地址,然后本地 DNS 服务器去 www.baidu.com 主区域服务器查询此域名对应的 IP 地址。
- 本地 DNS 服务器拿到 www.baidu.com 解析后的 IP 地址后,缓存起来以便备查,然后把解析后的 IP 地址返回给浏览器。
15. IP 地址分为几类,每类都代表什么,私网是哪些?
大致上分为公共地址和私有地址两大类,公共地址可以在外网中随意访问,私有地址只能在内网访问只有通过代理服务器才可以和外网通信。
公共地址:
1.0.0.1~126.255.255.254
128.0.0.1~191.255.255.254
192.0.0.1~223.255.255.254
224.0.0.1~239.255.255.254
240.0.0.1~255.255.255.254
私有地址:
10.0.0.0~10.255.255.255
172.16.0.0~172.31.255.255
192.168.0.0~192.168.255.255
- 0.0.0.0 路由器转发使用
- 127.x.x.x 保留
- 255.255.255.255 局域网下的广播地址
16. 计算机网络中的同步和异步
- 异步传输是面向字符的传输,同步传输是面向数据帧(比特组)的传输;
- 异步传输中每个字符都有起始位和停止位,由于每个信息都加了“同步”信息,导致传输负载加大,所以异步传输常用于低速的设备;
- 同步通过从数据帧中抽取同步信息,通过特定的时钟线路协调时序,由于同步信息占用数据帧的百分比比较小,所以开销比异步小效率比异步高。
总结
网络编程在最近几年的面试题中出现的频率越来越频繁,深入理解网络通信协议也将变得刻不容缓。
RocketMQ 面试题集锦
前言
最近后端面试越来越难,不仅需要熟练使用算法、数据结构,懂各种开发框架的原理和源码,还需要懂各种中间件的原理和源码;不管在 BATJ 互联网大厂还是小公司都在使用 MQ 中间件,而且有着丰富的应用场景,所以关于 MQ 的原理和源代码的考察也成了资深程序员和架构师必须要掌握的重要技能,本场 Chat 罗列了 RocketMQ 的一些面试题帮助大家更加深入的了解 MQ 的底层设计原理。
1. 什么是解耦?
A 系统与 B、C、D 等其他系统严重耦合,不仅需要给其他系统发送数据维护发送数据的代码,还需要考虑其他系统调用自己的接口是否成功/失败/超时等异常情况;但是有了 MQ 之后,A 系统只需要将关键数据发送到 MQ 里,其他系统想要这个关键数据去消费 MQ ,如果不需要就取消消费就可以了。
2. 什么是异步?
异步是一种通讯方式,消息接收方不需要知道消息发送方什么时候发送,比如还款处理,用户点击还款后,可以把还款的消息放到消息队列里面,然后马上给用户返回还款中的状态;然后后台有一个还款消息消费的监听程序,最后借款状态更新为还款成功,当用户再次刷新页面会看到借款状态已经更新为还款成功啦。
3. 什么是削峰填谷?
如果一个系统的处理能力是每秒 200 个请求,在某一秒突然来了 350 个请求,需要把多余的 150 个请求均摊到之后的时间保证系统负载在系统的处理能力之内,同时让系统尽可能多地处理更多请求,最终起到“削峰填谷”的作用。如果一个系统的处理能力是每秒 200 个请求,在某一秒突然来了 350 个请求,需要把多余的 150 个请求均摊到之后的时间保证系统负载在系统的处理能力之内,同时让系统尽可能多地处理更多请求,最终起到“削峰填谷”的作用。
4. RocketMQ 执行流程
- 先启动 NameServer 服务,起来后监听端口等待 Broker、Producer 和 Consumer,NameServer 是一个路由控制中心类似 ZooKeeper 之于 Kafka,提供服务注册、路由管理等服务。
- 启动 Broker 跟所有的 NameServer 保持长连接并且定期发送心跳包。
- 创建 Topic 并且指定其保存在哪些 Broker 里。
- Producer 向 Master Broker 发送消息并且给消息打 Tag 标签进行分组。
- Consumer 监听所有的 Master Broker 和 Slave Broker 里面的消息。
5. 怎么理解 Producer 的?
- Producer 启动的时候会从 NameServer 服务集群找一台建立长连接并且定期发送心跳包,然后每隔 30s 从 NameServer 拉取 Topic-Broker 映射关系,缓存到本地内存,然后跟所有的 Master Broker 建立长连接并且每隔 30s 发送一次心跳包。
- Producer 发送消息的时候有一个负载均衡的策略,当成功发送一次消息后,下一次消息将会换一个 Master Broker,使得消息可以均衡的分布在所有的 Broker 上。
6. 怎么理解 Consumer 的?
- 跟 Producer 类似。
- 消费端的负载均衡是由消费者的消费模式决定的。
7. 消费者消费模式有哪几种?
集群消费和广播消费。
8. 消费者获取消息有几种模式?
拉模式
推模式:是一种特殊的拉模式,每隔 15s 向 Broker 发起长轮询,Broker 的 Topic 下如果没有消息那么线程阻塞,超过 5s 就再次发起查询,如果有消息线程被唤醒并且立马把消息返回给消费端。
9. 定时消息是什么?怎样实现的?
Producer 在发送消息的时候指定什么时刻发送,然后消息被发送到 Master Broker 并保存起来,在指定时刻的时候 Broker 会把消息推送给消费端。
10. RocketMQ 如何保证高可用的?
- 多 Master 模式:该模式下必须设置为异步复制,因为没有 Slave,异步刷盘会丢失部分数据,同步刷盘不会丢失数据;如果有一台 Master Broker 宕机,那么其上的消息在服务恢复前不会推送给消费端,会对消息实时性造成影响。
- 多 Master 多 Slave 模式(异步复制):异步复制的模式下在 Master 宕机、磁盘损坏等情况下会造成数据部分丢失;但是消费端仍然可以从 Slave 拉取消息,所以消息的实时性不受影响。
- 多 Master 多 Slave 模式(同步双写):同步复制同步刷盘会导致整体性能比异步模式低一些,发送单个消息的 RT 也会变高,但是服务可用性和数据可用性都很高而且消息实时性不受影响。
11. 如何保证消息不被重复消费?或者说如何保证消息消费时的幂等性?
数据库创建一个业务幂等表,第一次消费的时候插入一条幂等数据,把相关的业务 bizId 作为幂等 id,第二次消费的时候如何数据库中已经有幂等记录的话就抛出幂等异常。
12. 如何保证消息的可靠性传输?要是消息丢失了怎么办?
- Producer:生产者端默认采用同步阻塞式发送消息,如果状态返回 OK 表示消息一定发送到了 Broker,如果状态返回失败或者超时,会重试两次,重试次数用完后可能发送成功也可能继续失败。
- Broker:发送到 Broker 的消息会通过同步刷盘或者异步刷盘的方式持久化到 CommitLog 里面,即时 Broker 服务宕机,服务恢复后仍然可以找到消息,除非磁盘损坏才会导致消息丢失;另外在多主多从的 Broker 集群模式下,采用同步复制将 Master Broker 中的消息同步到 Slave Broker,就是 Master Broker 宕机甚至磁盘损坏也可以找到该消息。
- Consumer:消费端维护了一个 MessageQueue 的队列,不管消息消费成功或者失败都会将当前消息的消费进度 offset 持久化到 MessageQueue 里面;如果消息消费失败会把消息重新发回到 Broker,如果操作失败或者 Broker 挂掉了会定时重试,成功后更新本地的消费进度 offset;就算 Broker 和 Consumer 都挂掉了由于 Consumer 会定期持久化消费进度,等服务恢复后重新拉取 offset 之前的消息到消费端本地也可以保证消息不丢失;消费端消费失败后会把消息重新发回到 Broker 中的重试队列中,如果消费重试次数超过了最大消费重试次数的话 Broker 会把消息移到死信队列中,然后人工干预处理。
13. 如何保证消息的顺序性?
顺序消息在日常的功能场景中很常见,比如点外卖生成外卖订单、付款、送餐的消息需要保证严格的顺序。
全局顺序消息:
RocketMQ 的一个 Topic 下默认有八个读队列和八个写队列,如果要保证全局顺序消息的话需要在生产端只保留一个读写队列,然后消费端只有一个消费线程,这样会降低 RocketMQ 的高可用和高吞吐量。
分区顺序消息:
分区顺序消息同样需要生产端和消费端配合,生产端根据同一个订单 ID 把消息路由到同一个 MessageQueue,消费端控制从同一个 MessageQueue 取出的消息不被并发处理。
生成端发送分区顺序消息:
SendResult sendResult = Producer.send(msg , new MessageQueueSelector() {
Override
public MessageQueue select(List <MessageQueue> mqs , Message msg ,Object arg) {
// 生成外卖订单、付款、送餐使用同一个 bizId
Integer bizId = (Integer) arg ;
// 根据 bizId 路由这三种消息到同一个分区
int index = bizId%mqs.size();
return mqs.get(index) ;
}
}, orderid);
消费端消费分区消息:
consumer.registerMessageListener(new MessageListenerOrderly() {
Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs ,ConsumeOrderlyContext context) {
System.out.printf("Receive order msg:" + new String(msgs.get(0).getBody()));
return ConsumeOrderlyStatus.SUCCESS ;
}
});
MessageListenerOrderly 底层会将 MessageQueue 锁住保证了同一个 MessageQueue 的消息不会并发消费。
14. 如何解决消息队列的延时以及过期失效问题?
当消费端宕机或者消费速度很慢导致 Broker 中消息大量积压,如有积压时长超过阈值就会被清理掉导致数据丢失。
RocketMQ 会把消息持久化到 CommitLog 文件里面,并且在以下的几种情况下会定期清理 CommitLog 文件:
- CommitLog 文件里面的消息积压超过了 72 小时,并且到了凌晨 4 点,清理线程会删除该文件;
- CommitLog 文件里面的消息积压超过了 72 小时,并且磁盘占用率达到了 75%,清理线程也会删除该文件;
- 磁盘占用率达到了 85%,不管积压的消息是否过期都会触发线程批量清理文件,直到内存充足;
- 磁盘占用率达到了 90%,Broker 出于保护自身的目的将会拒绝新发送的消息。
出现消息积压并且过期失效问题,会导致这部分消息丢失,可以写一段程序找到丢失的消息,然后等到凌晨用户都休息的时候,这个时候系统负载比较低,再把丢失的这部分消息重新发送到 Broker 中。
15. 消息队列满了以后该怎么处理?有几百万消息持续积压几小时,说说怎么解决?
- 创建一个 Topic 并且把积压消息的 Topic 下的分区中的消息分发到新建的那个 Topic 中的更多分区中,新建 Topic 的分区数是积压消息的 Topic 的分区的 10 倍(具体多少分区根据具体业务场景决定)。
- 积压的消息分发完以后停掉旧的消费端实例。
- 每一个新建 Topic 的分区都对应启动一个消费端实例。
- 积压的消息都消费完后,再把旧的消费端实例恢复。
16. 如何解决高性能读写数据的问题?
普通 IO 和 NIO
- 普通 IO 是基于字节的单向阻塞式传输;
- NIO 引入 FileChannel 全双工通道和 ByteBuffer 数据容器,ByteBuffer 可以精准的控制写入磁盘的数据大小。
如下图所示 FileChannel#write() 方法会将 ByteBuffer 里面的数据写到 PageCache 中,然后操作系统会定期将 PageCache 中的数据进行刷盘,如果想要立马通知操作系统进行刷盘可以调用 FileChannel#force() 方法。
不管是普通 IO 还是没有使用 MMAP 的 NIO 都会有多次数据拷贝的问题。
- 读磁盘的过程:先把磁盘里面的数据 DMA 拷贝到内核态 IO 缓冲区,然后再把内核态的数据 CPU 拷贝到用户态内存空间;
- 写磁盘的过程:把用户态内存空间的数据 CPU 拷贝到内存态 IO 缓冲区,然后再把内核态的数据 DMA 拷贝到磁盘;
- 不管是读还是写都需要两次数据拷贝,如果采用 MMAP 技术就可以把磁盘里面的文件映射到用户态空间的虚拟内存中,省去了内核态到用户态的 CPU 拷贝。
内存映射 MMAP(零拷贝技术)
NIO 可以采用 MappedByteBuffer 数据容器进行读写数据,而 MappedByteBuffer#map() 底层采用了 MMAP 技术进行了文件映射。
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, position, fileSize)
ize);
上文提到了 MMAP 技术就是进行文件映射和内存映射,把磁盘里面的文件映射到用户态的虚拟内存,还有将 PageCache 映射到用户态的虚拟内存,从而减少内核态到用户态的 CPU 拷贝。
MappedByteBuffer#write() 方法会将数据直接写入到 PageCache 中,然后操作系统会异步刷盘将 PageCache 中的数据写入到磁盘。
从磁盘读取数据的时候,首先会从 PageCache 中查看是否有数据,如果有的话就直接从 PageCache 加载数据,如果没有的话就去磁盘加载数据到 PageCache 中,PageCache 技术还会将要加载的数据的附近的其他数据块也一并加载进 PageCache,下文继续分析 PageCache 技术。
MMAP 虽然可以提高磁盘读写的性能,但是仍然有诸多缺陷和限制,比如:
- MMAP 进行文件映射的时候对文件大小有限制,在 1.5GB~2GB 之间,所以 RocketMQ 设计 CommitLog 单个文件 1GB,ConsumeQueue 单个文件 5.7MB;
- 当不再需要使用 MappedByteBuffer 的时候,需要手动释放占用的虚拟内存。
PageCache 技术
为了优化磁盘中数据文件的读写性能,PageCache 技术对数据文件进行了缓存。“对磁盘中数据文件的顺序读写性能接近于对内存的读写性能”,其主要原因就是 PageCache 对磁盘中数据文件的读写进行了优化。
- PageCache 对数据文件的读优化:由于读数据文件的时候会先从 PageCache 加载数据,如果 PageCache 没有数据的话,会从磁盘的数据文件中加载数据并且顺序把相邻的数据文件页面预加载到 PageCache 中,这样子如果之后读取的数据文件在 PageCache 中能找到的话就省去了去磁盘加载数据的操作相当于直接读内存。
- PageCache 对数据文件的写优化:往数据文件中写数据的时候同样先写到 PageCache 中,然后操作系统会定期刷盘把 PageCache 中的数据持久化到磁盘中。
那么 RocketMQ 具体是怎么使用 PageCache + Mmap 技术的呢?又做了哪些优化呢?
- 首先将磁盘中的 CommitLog 数据文件映射到虚拟内存中;
- 生成端将消息顺序写到 PageCache 中,然后操作系统定期进行异步刷盘,将 PageCache 中的数据批量持久化到磁盘;
- 消费端随机读取 CommitLog 数据文件,由于程序的局部性热点原理,未来将要加载的数据和正在使用的数据在存储空间上是邻近的,所以要加载的数据基本可以在 PageCache 中找到,避免了缺页中断而去磁盘中加载数据。
虽然 PageCache 技术优化了数据文件的读写性能,但是仍然有一些影响其性能的问题:
操作系统的脏页回写(当空闲内存不足的时候操作系统会将脏页刷到磁盘释放内存空间)、内存回收、内存 SWAP(当内存不足的时候把一部分磁盘空间虚拟成内存使用) 等都会造成消息读写的延迟。
为了解决消息读写的延迟问题,RocketMQ 还引入了其他优化方案,下文继续分析。
预分配 MappedFile(内存预分配)
为了解决 PageCache 技术带来的消息读写延迟问题,RocketMQ 进行了内存预分配处理。
当往 CommitLog 写入消息的时候,会先判断 MappedFileQueue 队列中是否有对应的 MappedFile,如果没有的话会封装一个 AllocateRequest 请求,参数有:文件路径、下下个文件路径、文件大小等,并把请求放到一个 AllocateRequestQueue 队列里面;
在 Broker 启动的时候会自动创建一个 AllocateMappedFileService 服务线程,该线程不停的运行并从 AllocateRequestQueue 队列消费请求,执行 MappedFile 映射文件的创建和下下个 MappedFile 的预分配逻辑,创建 MappedFile 映射文件的方式有两个:一个是在虚拟内存中通过 MapperByteBuffer 即 Mmap 创建一个 MappedFile 实例,另一个是在堆外内存中通过 DirectByteBuffer 创建一个 MappedFile 实例。创建完当前 MappedFile 实例后还会将下一个 MappedFile 实例创建好并且添加到 MappedFileQueue 队列里面,即预分配 MappedFile。
mlock 系统调用
当内存不足的时候可能发生内存 SWAP,读写消息的进程所使用的内存可能会被交换到 SWAP 空间,为了保证 RocketMQ 的吞吐量和读写消息低延迟,肯定希望尽可能使用物理内存,所以 RocketMQ 采用了 mlock 系统调用将读写消息的进程所使用的部分或者全部内存锁在了物理内存中,防止被交换到 SWAP 空间。
文件预热(内存预热)
mlock 系统调用未必会锁住读写消息进程所使用的物理内存,因为可能会有一些内存分页是写时复制的,所以 RocketMQ 在创建 MapperFile 的过程中,会将 Mmap 映射出的虚拟内存中随机写入一些值,防止内存被交换到 SWAP 空间。
由于 Mmap 技术只是创建了虚拟内存地址至物理内存地址的映射表,并没有将磁盘中的数据文件加载到内存中来,如果内存中没有找到数据就会发生缺页中断,而去磁盘读取数据,所以 RocketMQ 采用了 madvise 系统调用将数据文件尽可能多的加载到内存中达到内存预热的效果。
总结
RocketMQ 的底层设计还是很有趣的,大家有空还是要看下其源代码,将其巧妙的设计转化为自己的技能。
转载请注明来源