文章目录
- 前言
- 1、JDK1.8 的新特性有哪些?
- 2、JDK 和 JRE 有什么区别?
- 3、String,StringBuilder,StringBuffer 三者的区别?
- 4、为什么 String 拼接的效率低?
- 5、ArrayList 和 LinkedList 有哪些区别?
- 6、CopyOnWriteArrayList 的底层原理
- 7、ArrayList 的扩容机制原理
- 8、Map 集合的遍历方式有哪些?
- 9、Map 集合的特点
- 10、HashMap和Hashtable的区别是?
- 11、HashMap 的扩容机制原理
- 12、ConcurrentHashMap 的扩容机制原理
- 13、ConcurrentHashMap 是如何保证线程安全的
- 14、HashCode()、equals() 的区别
- 15、HashSet 保证元素唯一性的原理?
- 16、ThreadLoacl 的底层原理
- 17、如何理解 volatile 关键字
- 18、什么是 CAS?
- 19、什么是 AQS?
- 20、ReentrantLock 中的公平锁和非公平锁的底层实现
- 21、ReentrantLock 中的 tryLock() 和 lock() 方法的区别
- 22、CountDownLatch 和 Semaphore 的区别和底层原理
- 23、Synchronized 的偏向锁、轻量级锁、重量级锁
- 24、Synchronized 和 ReentrantLock 的区别
- 25、Lock 接口比 synchronized 的优势是什么?
- 26、创建线程的方式有哪些?
- 27、线程池的底层工作原理
- 28、JVM 中哪些是线程共享区
- 29、JVM 中哪些可以作为 gc root ?
- 30、JVM 的内存模型是怎样的?
- 31、JVM 为什么使用元空间替换了永久代?
- 32、项目如何排查 JVM 问题
- 33、说说类加载器双亲委派模型
- 34、Tomcat 中为什么要使用自定义类加载器
- 35、Tomcat 如何进行优化?
- 36、游览器发出一个请求到收到响应经历了哪些步骤?
- 37、跨域请求是什么?有什么问题?怎么解决?
- 38、谈谈你对 Spring 的理解
- 39、Spring 中的 Bean 的生命周期
- 40、IOC 和 DI 是什么?
- 41、Spring IOC 的工作流程
- 42、Spring AOP 的实现原理
- 43、谈谈你对 Spring MVC 的理解
- 44、Spring MVC 的工作流程
- 45、Spring 中 Bean 是线程安全的吗?
- 46、Spring 中 Bean 的作用域有哪些?
- 47、ApplicationContext 和 BeanFactory 有什么区别
- 48、Spring 中的事务是如何实现的
- 49、Spring 中什么时候 @Transactional 会失效
- 50、Spring 容器启动流程是怎样的
- 51、Spring 用到了哪些设计模式?
- 52、Spring 如何解决循环依赖问题
- 53、Spring 里面的事务和分布式事务的使用如何区分
- 54、Spring Boot 中常用注解以及其底层实现
- 55、Spring Boot 自动装配机制的原理
- 56、Spring Boot 是如何启动 Tomcat 的
- 57、SpringBoot 中配置文件的加载顺序是怎样的?
- 58、什么是 CAP 理论
- 59、什么是 BASE 理论
- 60、什么时 RPC
- 61、分布式 ID 是什么?有哪些解决方案?
- 62、分布式锁的使用场景是什么?有哪些实现方案?
- 63、什么是分布式事务?有哪些实现方案?
- 64、雪花算法的实现原理
- 65、什么是 ZAB 协议
- 66、为什么 Zookeeper 可以用来作为注册中心
- 67、Zookeeper 中的领导者选举的流程是怎样的?
- 68、Zookeeper 集群中节点之间数据是如何同步的
- 69、Dubbo 支持哪些负载均衡策略
- 70、Dubbo 是如何完成服务导出的?
- 71、Dubbo 是如何完成服务引入的?
- 72、Dubbo 的架构设计是怎样的?
- 73、谈谈你对 Spring Cloud 的理解
- 74、Spring Cloud 有哪些常用组件,作用是什么?
- 75、Spring Cloud 和 Dubbo 有哪些区别?
- 76、什么是服务雪崩?什么是服务限流?
- 77、什么是服务熔断?什么是服务降级?区别是什么?
- 78、SOA、分布式、微服务之间有什么关系和区别?
- 79、BIO、NIO、AIO 分别是什么
- 80、零拷贝是什么?
- 81、Netty 是什么?和 Tomcat 有什么区别?特点是什么?
- 82、Netty 的线程模型是怎么样的
- 83、Netty 的高性能体现在哪些方面
- 84、Redis 有哪些数据结构?分别有哪些典型的应用场景?
- 85、Redis 分布式锁底层是如何实现的?
- 86、Redis 主从复制的核心原理
- 87、缓存穿透、缓存击穿、缓存雪崩分别是什么?
- 88、Redis 和 MySql 如何保证数据一致
- 89、Explain 语句结果中各个字段分别表示什么
- 90、索引覆盖是什么
- 91、最左前缀原则是什么
- 92、Innodb 是如何实现事务的
- 93、B树和B+树的区别,为什么 MySql 使用B+树
- 94、MySql 锁有哪些?如何理解?
- 94、MySql 慢查询该如何优化?
- 95、消息队列有哪些作用?
- 96、死信队列是什么?延时队列是什么?
- 97、Kafka 为什么比 RocketMQ 的吞吐量要高
- 98、Kafka 的 Pull 和 Push 分别有什么优缺点
- 99、RocketMQ 的底层实现原理
- 100、消息队列如何保证消息可靠传输
- 101、TCP 的三次握手和四次挥手
前言
以下面试题是我之前面试时所遇到的问题,以及以下三个B站UP主分享的面试题总结而来:
【Java最全面试攻略】-- 周瑜
【面试突击班】-- 左神
【Java面试】-- Mic
有时间的话可以去看看以上 UP 主分享的原视频,我这里只是做了一点点搬运的工作。
1、JDK1.8 的新特性有哪些?
Lambda 表达式
函数式接口
- 方法引用和构造器调用
Stream API
- 接口中的默认方法和静态方法
- 新时间日期 API
2、JDK 和 JRE 有什么区别?
JDK
是 Java 的开发工具包
,而 JRE
是 Java 的运行环境
,JDK 中包含
JRE,JDK 中有一个名为 jre
的目录,里面包含两个文件夹 bin
和 lib
,bin 就是 JVM
,lib 就是 JVM 工作所需要的类库
。
3、String,StringBuilder,StringBuffer 三者的区别?
- String 和 StringBuilder、StringBuffer 的本质区别是:
String
是一个不可改变的字符序列,而StringBuilder
和StringBuffer
是一个可以改变的字符序列 - StringBuilder 和 StringBuffer 的功能完全一致,不同点在于
StringBuffer
是 JDK1.0 出现的,线程安全(同步),但是效率低
,而StringBuilder
是 JDK1.5 出现的,线程不安全(不同步),但是效率高
4、为什么 String 拼接的效率低?
因为字符串在用 “+” 号做拼接的时候,每一次都会产生新的字符串
。字符串串联是通过 StringBuilder(或 StringBuffer)类及其 append() 方法实现的,字符串转换是通过 toString() 方法实现的。
5、ArrayList 和 LinkedList 有哪些区别?
- 底层数据结构不同,
ArrayList
底层是基于数组
实现的,LinkedList
底层是基于链表
实现的 - 查询、添加、删除的时间复杂度不同,
ArrayList
更适合随机查找
,LinkedList
更适合删除和添加
- ArrayList 和 LinkedList 都实现了 List 接口,但是
LinkedList
还额外
实现了Deque
接口,所以 LinkedList 还可以当作队列来使用
6、CopyOnWriteArrayList 的底层原理
相比 ArrayList ,CopyOnWriteArrayList 是线程安全的,以下是 CopyOnWriteArrayList 的 add 方法源码:
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
- 首先 CopyOnWriteArrayList 内部也是用数组来实现的,在向 CopyOnWriteArrayList 添加元素时,会复制一个新的数组,写操作在新数组上进行,读操作在原数组上进行
- 并且,写操作会加锁,防止出现并发写入丢失数据的问题
- 写操作结束之后会把原数组指向新数组
- CopyOnWriteArrayList 允许在写操作时来读取数据,大大提高了读的性能,因此适合读多写少的应用场景,但是 CopyOnWriteArrayList 会比较占内存,同时可能读到的数据不是实时最新的数据,所以不适合实时性要求很高的场景
7、ArrayList 的扩容机制原理
源码:
/**
* Increases the capacity to ensure that it can hold at least the
* number of elements specified by the minimum capacity argument.
*
* @param minCapacity the desired minimum capacity
*/
private void grow(int minCapacity) {
// 获取到原本的长度
int oldCapacity = elementData.length;
// 新的长度 = 原本的长度 + 原本的长度右移一位(就是除以2)
// 所以新的长度就相当于原有长度的 1.5 倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果新的长度 < 集合的长度
if (newCapacity - minCapacity < 0)
// 那新的长度就赋值为传进来的
newCapacity = minCapacity;
// 判断数据大小是否超过默认的最大值
if (newCapacity - MAX_ARRAY_SIZE > 0)
// 如果超过就会做一个处理
newCapacity = hugeCapacity(minCapacity);
// 将原本的数据拷贝到新的数组里面
elementData = Arrays.copyOf(elementData, newCapacity);
}
ArrayList 是一个数组结构的存储容器,默认情况下数组的长度是10
个,当然我们可以在构建 ArrayList 的时候指定初始长度,随着在程序里面不断的往 ArrayList 里面添加数据,当添加的数据达到10个的时候,ArrayList 里面就没有足够的容量去存储后续的数据,那么这个时候 ArrayList 就会触发自动扩容,扩容的流程也很简单,首先创建一个新的数据
,这个新数组的长度是原数组长度的 1.5
倍,然后使用 Arrays.copyOf
方法把老数组里面的数据拷贝
到新数组里面,扩容完成以后再把当前需要添加的元素加入到新的数组里面,从而去完成动态扩容这样一个过程。
8、Map 集合的遍历方式有哪些?
@Test
public void fun() {
Map<String,String> map = new HashMap<>();
map.put("A001","米大傻");
map.put("A002","曹大力");
map.put("A003","张大仙");
map.put("A004","杨大壮");
/**
* 方式一:根据键找值的方式遍历集合
* public Set<K> keySet():将 Map 所有的 key 封装到一个 Set 的集合
* public V get(Object key):根据 key(键) 获取 Map 中对应的 value(值)
*/
Set<String> set = map.keySet();
for (String key : set) {
String value = map.get(key);
System.out.println("key:" + key + " value:" + value);
}
/**
* 方式二:获取键值对对象集合,迭代器遍历集合获取键和值
* public Set<Map.Entry<K,V>> entrySet():获取所有的键值对对象集合
* Iterator<E> iterator():获取迭代器
*/
Set<Map.Entry<String, String>> entrySet = map.entrySet();
Iterator<Map.Entry<String, String>> iterator = entrySet.iterator();
while (iterator.hasNext()) {
Map.Entry<String, String> entry = iterator.next();
String key = entry.getKey();
String value = entry.getValue();
System.out.println("key:" + key + " value:" + value);
}
/**
* 方式三:获取键值对对象集合,增强 for 遍历集合获取键和值
* public Set<Map.Entry<K,V>> entrySet():获取所有的键值对对象集合
*/
Set<Map.Entry<String, String>> entries = map.entrySet();
for (Map.Entry<String, String> entry : entries) {
String key = entry.getKey();
String value = entry.getValue();
System.out.println("key:" + key + " value:" + value);
}
/**
* 方式四:拿到 Map 集合中所有的值
* public Collection<v> values():将 Map 中所有的 value 封装到一个 Collection 体系的集合
*/
Collection<String> values = map.values();
for (String value : values) {
System.out.println("value:" + value);
}
}
总结:
- 方式一:根据键找值方式遍历
- 方式二:获取所有的键值对对象集合,通过迭代器遍历
- 方式三:获取所有的键值对对象集合,通过增强for遍历
- 方式四:通过Map集合中values方法拿到所有的值
9、Map 集合的特点
- Map 是一个双列集合,将键映射到值的对象
- Map 集合的数据结构,只针对健有效跟值没有关系
- 一个映射不能包含重复的键,每个键最多只能映射到一个值
10、HashMap和Hashtable的区别是?
- HashMap 是 JDK1.2 版本出现的,
允许
存储 null 键和 null 值- 不同步(
线程不安全
):效率高
- 不同步(
- Hashtable 是 JDK1 .0版本出现的,
不允许
存储 null 键和 null 值- 同步(
线程安全的
):效率低
- 同步(
11、HashMap 的扩容机制原理
HashMap 上面的数据过多时,查询的效率就会变低,这时就需要进行扩容,以便在存储更多数据的同时,也能保证较高的运行效率,衡量 HashMap 扩容的标准是负载因子,当数据大于容量与负载因子(0.75f)的乘积时,HashMap 就会进行扩容,它的扩容机制跟 JDK 的版本有关
- JDK 1.7 版本(该版本 HashMap 的底层是数组+链表)
- 先生成新数组
- 遍历老数组中的每个位置上的链表上的每个元素
- 取每个元素的 key,并基于新数组长度,计算出每个元素在新数组中的下标
- 将元素添加到新数组中去
- 所有元素转移完之后,将新数组赋值给 HashMap 对象的 table 属性
- JDK 1.8 版本(该版本 HashMap 的底层是数组+链表+红黑树)
- 先生成新数组
- 遍历老数组中的每个位置上的链表或红黑树
- 如果是链表,则直接将链表中的每个元素重新计算下标,并添加到新数组中去
- 如果是红黑树,则先遍历红黑树,先计算出红黑树中每个元素对应在新数组中的下标位置
- 统计每个下标位置的元素个数
- 如果该位置下的元素个数超过了 8 ,则生成一个新得到红黑树,并将根节点添加到新数组对应的位置
- 如果该位置下的元素个数没有超过 8,那么则生成一个链表,并将链表的头节点添加到新数组的对应位置
- 所有元素转移完了之后,将新数组赋值给 HashMap 对象的 table 属性
12、ConcurrentHashMap 的扩容机制原理
- JDK 1.7 版本
- 该版本的 ConcurrentHashMap 是基于 Segment 分段实现的
- 每个 Segment 相当于一个小型的 HashMap
- 每个 Segment 内部会进行扩容,和 HashMap 的扩容逻辑类似
- 先生成新的数组,然后转移元素到新数组中
- JDK 1.8 版本
- 该版本的 ConcurrentHashMap 不再基于 Segment 实现
- 当每个线程进行 put 时,如果发现 ConcurrentHashMap 正在进行扩容,那么该线程一起进行扩容
- 如果某个线程 put 时,发现没有正在进行扩容,则将 key-value 添加到 ConcurrentHashMap 中,然后判断是否超过阈值,超过了则进行扩容
- ConcurrentHashMap 是支持多个线程同时扩容的
- 扩容之前先生成一个新的数组
- 在转移元素时,先将原数组分组,将每组分给不同的线程来进行元素的转移,每个线程负责一组或多组的元素转移工作
13、ConcurrentHashMap 是如何保证线程安全的
- JDK 1.7 版本
ConcurrentHashMap 在 JDK 1.7 版本中使用的是数组+链表结构,其中数组分为两大类,大数组是 Segment,小数组是 HashEntry ,而加锁是通过 Segment 添加 ReentrantLock 重入锁来保证线程安全的 - JDK 1.8 版本
ConcurrentHashMap 在 JDK 1.8 版本中使用的是数组+链表+红黑树的方式实现的,它是通过 CAS 或者是 synchronized 来保证线程安全的,并且缩小了锁的粒度,查询性能也得到了进一步的提升
14、HashCode()、equals() 的区别
- hashCode() 和 equals() 都是 Object 类中的方法
- 如果类中不重写此方法
- hashCode():属于是本地方法,返回的是对象的地址值
- equals():比较的是两个对象中成员信息是否相同
- 如果类中重写此方法
- hashCode():返回的是根据对象的成员变量,计算出的一个整数
- equals():比较的是两个对象中成员信息是否相同
- 如果类中不重写此方法
- 类中重写 hashCode() 和 equals() 比较两个对象是否相等
- 两个对象通过 equals() 比较是相等的,那么 hashCode() 肯定相等,也就是 equals() 是绝对可靠的
- 两个对象通过 hashCode() 比较相等,但是 equals() 去做比较不一定相等,也就是 hashCode() 不是绝对可靠的
15、HashSet 保证元素唯一性的原理?
HashSet 内部其实利用了 HashMap 来实现的,内部持有一个 HashMap<E,Object> map 的引用,操作 HashSet 实际上底层就是操作这个 map,往 HashSet 的集合中添加元素,底层就调用了 HashMap 的 put 方法,源码如下:
这个判断流程是:
- 首先比较对象的哈希值是否相同,这个哈希值是根据对象 hashCode() 计算出来的
- 如果哈希值不同,就直接添加到集合中
- 如果哈希值相同,继续执行equals() 进行比较
- 返回的是 true,说明元素重复,不添加
- 返回的是 false,说明元素不重复,就添加
如果我们使用 HashSet 集合存储对象,想要保证元素的唯一性,就必须重写 hashCode() 和 equals() 方法
16、ThreadLoacl 的底层原理
- ThreadLocal 是 Java 中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据
- ThreadLocal 底层是通过 ThreadLocalMap 来实现的,每个 Thread 对象中都存在一个 ThreadLocalMap,Map 的 key 为 ThreadLocal 对象,Map 的 value 为需要缓存的值
- 如果在线程池中使用 ThreadLocal 会造成内存泄漏,因为当 ThreadLocal 对象使用完之后,应该要把设置的 key,value,也就是 Entry 对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向 ThreadLocalMap,ThreadLocalMap 也是通过强引用指向 Entry 对象,线程不被回收,Entry 对象也不会被回收,从而出现内存泄漏,解决办法是,在使用了 ThreadLocal 对象之后,手动调用 ThreadLocal 的 remove 方法,收到清除 Entry 对象
- ThreadLocal 经典的应用场景就是连接管理(一个线程持有一个连接,该连接对象可以在不同的方法之间进行传递,线程之间不共享同一个连接)
17、如何理解 volatile 关键字
在并发领域中,存在三大特性:原子性、有序性、可见性。volatile 关键字用来修饰对象的属性,在并发环境下可以保证这个属性的可见性,对于加了 volatile 关键字的属性,在对这个属性进行修改时,会直接将 CPU 高级缓存中的数据写回到主内存,对这个变量的读取也会直接从主内存中读取,从而保证了可见性,底层是通过操作系统的内存屏障来实现的,由于使用了内存屏障,所以会禁止重排,所以同时也就保证了有序性,在很多并发场景下,如果用好 volatile 关键字可以很好的提高执行效率。
18、什么是 CAS?
CAS 是 Java 中 Unsafe 类里面的一个方法,它的全称是叫 CompareAndSwap,比较并交换的一个意思,它的主要功能是能够去保证在多线程的环境下,对于共享变量修改的一个原子性。比方说像这样一个场景:
public class Example {
private int state = 0;
public void doSome() {
if(state == 0) { // 多线程环境中,存在原子性问题
state = 1;
// TODO
}
}
}
有一个成员变量 state
,它的默认值是 0 ,其中定义了一个方法 doSome()
,这个方法的逻辑是先判断 state 是否为 0,如果为 0 就修改成 1 ,这个逻辑在单线程的情况下,看起来没有任何的问题,但是在多线程的环境下,会存在原子性的问题,因为这里是一个典型的 Read-Write 的一个操作,一般情况下会在 doSome 这个方法中去加一个 synchornized 的同步锁来解决这样一个原子性问题,但是加同步锁一定会带来性能上的损耗,所以对于这一类的场景,我们可以使用 CAS 机制来进行优化
public class Example {
private volatile int state = 0;
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long stateOffset;
static {
try {
stateOffset = unsafe.ObjectFieldOffset(Example.class.getDeclaredField("state"));
} catch (Exception e) {
throw new Error(e);
}
}
public void doSome() {
if(unsafe.compareAndSwapInt(this,stateOffset,0,1)) {
// TODO
}
}
}
以上是优化之后的代码,在 doSome() 这个方法中,调用了 Unsafe
类里面的 compareAndSwapInt()
方法,来达到同样的目的,这个方法有四个参数,分别是当前对象实例、成员变量、state、在内存地址中的一个偏移量,预期值 0 和期望更改之后的值 1,CAS 机制会比较 state 内存地址偏移量对于的值,和传入的预期值 0 是否相等,如果相等,就直接修改内存地址中 state 的值等于 1,否则返回 false,表示修改失败,而这个过程它是一个原子的,不会存在任何线程安全的问题,CompareAndSwap 是一个 native
方法,实际上,它最终还是会面临同样的问题,就是先从内存地址中读取 state 值,然后再去比较,最后再去修改,这过程不管在什么层面去实现都会存在原子性问题,所以在 CompareAndSwap 的底层实现里面,如果是在多核的 CPU 环境下,会增加一个 lock 指令,来对缓存或者总线去加锁,从而去保证比较并替换这两个操作的原子性。
CAS 主要是应用在一些并发场景里面,比较典型的使用场景,有两个:① JUC 里面 Atomic 的原子实现,比如:AtomicInteger 和 AtomicLong 等原子类;② 实现多线程对共享资源竞争的互斥特性,比如:AQS、ConcurrentHashMap 以及 ConcurrentLinkedQueue 等。
19、什么是 AQS?
AQS 是 AbstractQueuedSynchronizer 类,是多线程同步器
,它是 JUC
包中多个组件的底层实现,比如:lock、CountDownLatch、Semaphore
都用到了 AQS。从本质上来说,AQS 提供了两种锁的机制,分别是排他锁
和共享锁
。所谓排他锁,就是存在多个线程去竞争同一共享资源的时候,同一个时刻只允许一个线程去访问这样一个共享资源,也就是说,多个线程中只能有一个线程去获得这样一个锁的资源,比如 lock 中的 ReentrantLock 重入锁它的实现就是用到了 AQS 中的一个排它锁的功能。共享锁也称为读锁,就是在同一个时刻运行多个线程同时获得这样一个锁的资源,比如 CountDownLatch 以及 Semaphore 都用到了 AQS 中的共享锁的功能。
AQS 作为互斥锁来说它的整个设计体系中需要解决三个核心的问题:① 互斥变量的设计以及如何保证多线程同时更新互斥变量的时候线程的安全性;② 未竞争到锁资源的线程的等待以及竞争到锁的资源释放锁之后的唤醒;③ 锁竞争的公平性和非公平性。
AQS 采用了一个 int 类型的互斥变量 state
,用来记录锁竞争的一个状态,0
表示当前没有任何线程竞争锁资源,而大于 0 表示已经有线程正在持有锁资源,一个线程获得锁资源时候,首先会判断 state
是否等于 0
,也就是说它是无锁状态,如果是,则把这个 state
更新为 1
,表示占用到锁,而这个过程中,如果多个线程同时去做这样一个操作,就会导致线程安全性问题,因此 AQS 采用 CAS
机制,去保证 state
互斥变量更新的一个原子性,未获取到锁的线程,通过 Unsafe
类中的 park
方法去进行阻塞,把阻塞的线程按照先进先出的原则,去加入到一个双向链表的一个结构中,当获得锁资源的线程释在放锁之后,会从这样一个双向链表的头部去唤醒下一个等待的线程,再去竞争锁。
关于锁竞争的公平性和非公平性的问题,AQS 的处理方式是:在竞争锁资源时候,公平锁需要去判断双向链表中是否有阻塞的线程,如果有,则需要去排队等待;而非公平锁的处理方式是不管双向链表中是否存在等待竞争锁的线程,它都会直接去尝试更改互斥变量 state
去竞争锁,假设在一个临界点,获得锁的线程释放锁,此时 state
等于 0
,而当前的这个线程去抢占锁的时候,正好可以把 state
修改为 `1`` ,那么这个时候就表示它能拿到锁,而这个过程是非公平的。
20、ReentrantLock 中的公平锁和非公平锁的底层实现
- 首先不管是公平锁还是非公平锁,它们的底层实现都会使用 AQS 来进行排队,它们的区别在于:线程在使用 lock() 方法加锁时,如果是公平锁,会先检查 AQS 队列是否存在线程在排队,如果有线程在排队,则当前线程也进行排队;如果是非公平锁,则不会去检查是否有线程在排队,而是直接竞争锁
- 不管是公平锁还是非公平锁,一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排队在最前面的线程,所以非公平锁只是体现了线程加锁阶段,而没有体现在线程被唤醒阶段
- 另外,ReentrantLock 是可重入锁,不管是公平锁还是非公平锁都是可重入的
21、ReentrantLock 中的 tryLock() 和 lock() 方法的区别
- tryLock() 表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回 true,没有加到则返回 false
- lock() 表示阻塞加锁,线程会阻塞直到加到锁,方法也没有返回值
22、CountDownLatch 和 Semaphore 的区别和底层原理
CountDownLatch 表示计数器,可以给 CountDownLatch 设置一个数字,一个线程调用 CountDownLatch 的 await() 将会阻塞,其他线程可以调用 CountDownLatch 的 countDown() 方法来对 CountDownLatch 中的数字减一,当数字被减成 0 后,所有 await 的线程都会被唤醒,对于的底层原理就是:调用 await() 方法的线程会利用 AQS 排队,一旦数字被减为 0 ,则会将 AQS 中排队的线程依次唤醒。
Semaphore 表示信号量,可以设置许可的个数,表示同时允许最多多少个线程使用该信号量,通过 acquire() 来获取许可,如果没有许可可以则线程阻塞,并通过 AQS 来排队,可以通过 release() 方法来释放许可,当某个线程释放了某个许可后,会从 AQS 中正在排队的第一个线程开始依次唤醒,直到没有空闲许可。
23、Synchronized 的偏向锁、轻量级锁、重量级锁
- 偏向锁:在锁对象的对象头中记录一下当前获取到该锁的线程 ID,该线程下次如果又来获取该锁就可以直接获取到了
- 轻量级锁:由偏向锁升级而来,当一个线程获取到锁后,此时这把锁是偏向锁,此时如果有第二个线程来竞争锁,偏向锁就会升级为轻量级锁,之所以叫轻量级锁是为了和重量级锁区分开来,轻量级锁底层通过自旋来实现的,并不会阻塞线程
- 重量级锁:如果自旋次数过多仍然没有获取到锁,则会升级为重量级锁,重量级锁会导致线程阻塞
- 自旋锁:自旋锁就是线程在获取锁的过程中,不会去阻塞线程,也就无所谓唤醒线程,阻塞和唤醒这两个步骤都是需要操作系统去进行的,比较消耗时间,自旋锁是线程通过 CAS 获取预期的一个标记,如果没有获取到,则继续循环获取,如果获取到了则表示获取到了锁,这个过程线程一直在运行中,相对而言没有使用太多的操作系统资源,比较轻量
24、Synchronized 和 ReentrantLock 的区别
- synchronized 是一个关键字,ReentrantLock 是一个类
- synchronized 会自动的加锁和释放锁,ReentrantLock 需要程序员手动加锁与释放锁
- synchronized 的底层是 JVM 层面的锁,ReentrantLock 是 API 层面的锁
- synchronized 是非公平锁,ReentrantLock 可以选择公平锁和非公平锁
- synchronized 锁的是对象,锁信息保存在对象头中,ReentrantLock 通过代码中 int 类型的 state 标识来标识锁的状态
- synchronized 底层有一个锁升级的过程
25、Lock 接口比 synchronized 的优势是什么?
- 能够显示获取和释放锁,锁的运用更灵活
- Lock 中的方法
- lock() 方法:加锁
- unlock() 方法:释放锁
- Lock 中的方法
- 可以方便地实现公平锁
- 公平锁:表示线程获取锁的顺序是按照线程加锁的顺序来进行分配的,即先来先得,先进先出顺序
- 非公平锁:一种获取锁的抢占式机制,是随机拿到锁的,和公平锁不一样的是先来的不一定先拿到锁,这个方式可能造成某一些线程一直拿不到锁,这个结果是不公平的
26、创建线程的方式有哪些?
- 继承 Thread 类
- Thread 类本质上是实现了 Runnable 接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过 Thread 类的 start() 实例方法。start() 方法是一个 native 方法,它将启动一个新线程,并执行 run() 方法。这种方式实现多线程很简单,通过自己创建的类直接 extend Thread,并重写 run() 方法,就可以启动新线程并执行自己定义的 run() 方法
- 优点:代码简单
- 缺点:该类无法继承别的类
- 实现 Runnable 接口
- Java 中的类属于单继承,如果自己的类已经 extend 另一个类,就无法直接 extend Thread ,但是一个类继承一个类的同时,是可以实现多个接口的
- 优点:继承其他类,统一实现该接口的实例可以共享资源
- 缺点:代码复杂
- 实现 Callable 接口
- 实现 Runnable 和实现 Callable 接口的方式基本相同,不过 Callable 接口中的 call() 方法有返回值,Runnable 接口中的 run() 方法无返回值
- 优点:可以获得异步任务的返回值
- 线程池方式
- 线程池其实就是一个容纳多个线程的容器,其中线程可以重复使用,省去了繁琐的创建线程对象操作,因为反复创建线程是非常消耗资源的
- 优点:实现自动化装配,易于管理,循环利用资源
27、线程池的底层工作原理
线程池内部是通过队列+线程
实现的,当我们利用线程池执行任务时:
- 如果此时线程池中的数量小于 corePoolSize(核心线程数),即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务
- 如果此时线程池中的数量等于 corePoolSize,但是缓冲队列 workQueue 未满,那么任务会被放入缓冲队列
- 如果此时线程池中的数量大于等于 corePoolSize,缓冲队列 workQueue 已满,并且线程池中的数量小于 maximumPoolSize(最大线程数),新建的线程来处理被添加的任务
- 如果此时线程池中的数量大于 corePoolSize,缓冲队列 workQueue 已满,并且线程池中的数量等于 maximumPoolSize,那么通过 handler 所指定的策略来处理此任务
- 当线程池中的线程数量大于 corePoolSize 时,如果某线程空闲时间超过 keepAliveTime(过期时间),线程将被终止。这样,线程池可以动态的调整池中的线程数
28、JVM 中哪些是线程共享区
堆区和方法区是所有线程共享的,栈、本地方法栈、程序计数器是每个线程独有的
29、JVM 中哪些可以作为 gc root ?
JVM 在进行垃圾回收时,需要找到 “垃圾” 对象,也就是没有被引用的对象,但是直接找 “垃圾” 对象是比较耗时的,所以反过来先找 “非垃圾” 对象,也就是正常对象,那么就需要从某些 “根” 开始去找,根据这些 “根” 的引用路径找到正常对象,而这些 “根” 有一个特征,就是它指挥引用其他对象,而不会被其他对象引用,例如:栈中的本地变量、方法区中的静态变量、本地方法栈中的变量,正在运行的线程等可以作为 gc root
。
30、JVM 的内存模型是怎样的?
JDK 1.7 的堆内存模型
- Young 年轻区(代)
- Young 区被划分为三部分,Eden 区和两个大小严格相同的 Survivor 区,其中,Survivor 区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用,在 Eden 区间变满的时候,GC 就会将存活的对象移到空闲的 Survivor 区间中,根据 JVM 的策略,在经过几次垃圾收集后,仍然存活于 Survivor 的对象将被移动到 Tenured 区间。
- Tenured 年老区(代)
- Tenured 区主要保存生命周期长的对象,一般是一些老的对象, 当一些对象在 Young 复制转移一定的次数以后,对象就会被转移到 Tenured 区,一般如果系统中用了 application 级别的缓存,缓存中的对象往往会被转移到这一区间。
- Perm 永久区(代)
- Perm代主要保存 class,method,filed 对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到 java.lang.OutOfMemoryError : PermGen space 的错误,造成这个错误的很大原因就有可能是每次都重新部署,但是重新部署后,类的 class 没有被卸载掉,这样就造成了大量的 class 对象保存在了 perm 中,这种情况下,一般重新启动应 用服务器可以解决问题。
- Virtual 区
- 最大内存和初始内存的差值,就是Virtual区
JDK 1.8 的堆内存模型
由上图可以看出,jdk1.8的内存模型是由2部分组成,年轻代+年老代
年轻代:Eden + 2*Survivor
年老代:OldGen
在 JDK1.8中变化最大的 Perm 区,用 Metaspace (元数据空间) 进行了替换
需要特别说明的是:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中,这也是与1.7的永久代最大的区别所在。
31、JVM 为什么使用元空间替换了永久代?
在 JDK 1.7 中,方法区的实现是在永久代里面,它里面主要存储运行时常量池,class 类元信息等等,永久代属于 JVM 运行时数据区内的一块内存空间,可以通过 -XX:PermSize
参数去设置永久代的大小,当内存不够的时候就会触发垃圾回收;在 JDK 1.8 中,取消了永久代,由元空间来实现方法区的数据存储,元空间不属于 JVM 内存,而是直接使用本地内存,因此不需要考虑 GC 的一个问题,默认情况下,元空间是可以无限制的使用本地内存的,但是也可以使用 JVM 参数来限制内存的一个使用大小。
至于为什么使用元空间替换了永久代
- 在 JDK 1.7 版本里面的永久代内存是有上限的,虽然可以通过参数来设置,但是 JVM 加载 class 总数大小是很难去确定的,所以很容易出现
OOM
的问题,但是元空间是存储在本地内存里面,内存的上限是比较大的,可以很好的去避免这个问题 - 永久代的对象是通过
FullGC
进行垃圾回收的,也就是和老年代同时实现垃圾回收,替换成元空间以后,简化了 FullGC 的过程,可以在不进行暂停的情况下,去并发的释放类的数据,同时也提升了 GC 的性能 - Oracle 要合并 Hotspot 和 JRockit 的一个代码,而 JRockit 里面没有永久代
32、项目如何排查 JVM 问题
对于还在正常运行的系统:
- 可以使用
jmap
来查看 JVM 中各个区域的使用情况 - 可以通过
jstack
来查看线程的运行情况,比如哪些线程阻塞、是否出现了死锁 - 可以通过
jstat
命令来查看垃圾回收的情况,特别是FllGC
,如果发现FallGC
比较频繁,那么就得进行调优了 - 通过各个命令的结果,或者
jvisualvm
等工具来进行分析 - 首先,初步猜测频繁发送
FullGC
的原因,如果频繁发生FullGC
但是又一直没有出现内存溢出,那么表示FullGC
实际上是回收了很多对象了,所以这些对象最好能在YoungGC
过程中就直接回收掉,避免这些对象进入到老年代,对于这种情况,就要考虑这些存活时间不长的对象是不是比较大,导致年轻代放不下,直接进入到了老年代,尝试加大年轻代的大小,如果改完之后,FullGC
减少,则证明修改有效 - 同时,还可以找到占用
CPU
最多的线程,定位到具体的方法,优化这个方法的执行,看是否能避免某些对象的创建,从而节省内存
对于已经发生了 OOM 的系统:
- 一般生产系统中都会设置当系统发生了
OOM
时,生成当时的 dump 文件(-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base) - 我们可以利用
jsisualvm
等工具来分析dump
文件 - 根据 dump 文件找到异常的实例对象和异常的线程(占用 CPU 高),定位到具体的代码
- 然后再进行详细的分析和调试
33、说说类加载器双亲委派模型
JVM 中存在三个默认的类加载器:① BootstrapClassLoader
② ExtClassLoader
③ AppClassLoader
AppClassLoader
的父加载器是 ExtClassLoader
,ExtClassLoader
的父加载器是 BootstrapClassLoader
JVM 在加载一个类时,会调用 AppClassLoader
的 loadClass
方法来加载这个类,不过在这个方法中,会先使用 ExtClassLoader
的 loadClass
方法来加载类,同样 ExtClassLoader
的 loadClass
方法中会先使用 BootstrapClassLoader
来加载类,如果 BootstrapClassLoader
加载到了就直接成功,如果 BootstrapClassLoader
没有加载到,那么 ExtClassLoader
就会自己尝试加载该类,如果没有加载到,那么则会由 AppClassLoader
来加载这个类。
所以,双亲委派指的是,JVM 在加载类时,会委派给 Ext 和 Bootstrap 进行加载,如果没有加载到才由自己进行加载。
34、Tomcat 中为什么要使用自定义类加载器
一个 Tomcat 中可以部署多个应用,而每个应用中都存在很多类,并且各个应用中的类是独立的,全类名是可以相同的,比如说一个订单系统中的某个类和库存系统中的某个类全类名完全相同,一个 Tomcat,不管内部部署了多少应用,Tomcat 启动之后就是一个 Java 进程,也就是一个 JVM,所以如果 Tomcat 中只存在一个类加载器,比如默认的 AppClassLoader
,那么全类名相同的类就只能加载一个,这是有问题的,而在 Tomcat 中,会为部署的每个应用都生成一个类加载器,名字叫做 WebAppClassLoader
,这样 Tomcat 中每个应用就可以使用自己的类加载器去加载自己的类,从而达到应用之间的类隔离,不出现冲突。另外,Tomcat 还利用自定义加载器实现了热加载功能。
35、Tomcat 如何进行优化?
对于 Tomcat 调优,可以从两个方面进行调整:内存和线程。
- 首先启动 Tomcat,实际上就是启动了一个 JVM,所以可以按 JVM 调优的方式进行调整,从而达到 Tomcat 优化的目的
- 另外 Tomcat 中设计了一些缓存区,比如
appReadBufSize
、bufferPoolSize
等缓存区来提高吞吐量 - 还可以调整 Tomcat 的线程,比如调整
minSpareThreads
参数来改变 Tomcat 空闲时的线程数,调整maxThreads
参数来设置 Tomcat 处理连接的最大线程数 - 并且还可以调整 IO 模型,比如使用 NIO、APR 这种相比于 BIO 更加高效的 IO 模型
36、游览器发出一个请求到收到响应经历了哪些步骤?
- 游览器解析用户输入的 URL,生成一个 HTTP 格式的请求
- 先根据 URL 域名从本地 hosts 文件查找是否有映射 IP,如果没有就将域名发送给电脑所配置的 DNS 进行域名解析,得到 IP 地址
- 游览器通过操作系统将请求通过四层网络协议发送出去
- 途中可能会经过各种路由器、交换机、最终达到服务器
- 服务器搜到请求后,根据请求所指定的端口,将请求传递给绑定了该端口的应用程序
- 应用程序接收到请求数据后,按照 http 协议的格式进行解析,解析得到所要访问的 servlet
- 然后 servlet 来处理这个请求,如果是 SpringMVC 中的 DispathServlet,那么则会找到对应的 Controller 中的方法,并执行该方法得到结果
- 应用程序得到响应后封装成 HTTP 响应的格式,并再次通过网络发送给游览器所在的服务器
- 游览器所在的服务器拿到结果后再传递给游览器,游览器则负责解析并渲染
37、跨域请求是什么?有什么问题?怎么解决?
游览器有一个安全机制,叫做同源策略,同源是指协议、域名、端口都一致,如果任意一项不一致就是不同源
跨域是指游览器在发起网络请求时,会检查该请求所对应得协议、域名、端口和当前网页是否一致,如果不一致则游览器会进行限制。
解决:
- response 添加 header,比如:
resp.setHeader("Access-Control-Allow-Origin","*");
表示可以访问所有网站,不受是否同源的限制 jsonp
的方式,该技术底层就是基于script
标签来实现的,因为script
标签是可以跨域的- 后台自己控制,先访问同域名下的接口,然后在接口中再去使用
HTTPClient
等工具去调用目标接口 - 网关,和第三种方式类似,都是交给后台服务来进行跨域访问
38、谈谈你对 Spring 的理解
Spring 的发展历程:
2004 年发布了 Spring 的第一个版本,从全配置文件的形式发展到现在 6.0 的一个版本,支持全注解应用开发,对于应用开发效率的提升有着很大的帮助
Spring 的组成
Spring 是一个轻量级的 IOC 和 AOP 容器框架,是为 java 应用程序提供基础性服务的一套框架,目的是用于简化企业应用程序的开发,它使得开发者只需要关系业务需求。常见的配置方式有三种:基于 XML 的配置、基于注解的配置、基于 java 的配置。
主要由一下几个模块组成:
- Spring Core:核心类库,提供 IOC 服务
- Spring Context:提供框架式的 Bean 访问方式,以及企业级功能
- Spring AOP:AOP 服务
- Spring DAO:对 JDBC 的抽象,简化了数据访问异常的处理
- Spring ORM:对现有的 ORM 框架的支持
- Spring Web:提供了基本的面向 Web 的综合特性,例如多方文件上传
- Spring MVC:提供面向 Web 应用的 Model-View-Controller 实现
Spring 的优点
- 轻量:Spring 是轻量级的,基本的版本大约 2MB
- 控制反转:Spring 通过控制反转实现了松散耦合,对象们给出它们的依赖,而不是创建或查找依赖的对象们
- 面向切面编程(AOP):Spring 支持面向切面的编程,并且把应用业务逻辑和系统服务分开
- 容器:Spring 包含并管理应用中对象的生命周期和配置
- MVC 框架:Spring 的 Web 框架是个精心设计的框架,是 Web 框架的一个很好的替代品
- 事务管理:Spring 提供一个持续的事务管理接口
- 异常处理:Spring 提供方便的 API 把具体技术相关的异常转化为一致的 unchecked 异常
- 使用的人多
39、Spring 中的 Bean 的生命周期
Spring Bean 的生命周期大致可以分为五个阶段:
- 创建前准备
- 这个阶段的主要作用是 Bean 在开始加载之前要从上下文和一些配置中去解析并且查找 Bean 有关的扩展实现,比如像
init-method
容器初始化 Bean 的时候调用的方法,destory-method
容器在销毁 Bean 的时候会调用的一些方法,以及beanFactoryPostProcessor
这一类的 Bean 加载过程中的一些前置和后置的一些处理扩展实现,这些类或者配置其实是 Spring 提供给开发者用来实现 Bean 并加载过程中的一些扩展,在很多的和 Spring 集成的中间件也比较常见,比如说 Dubbo
- 这个阶段的主要作用是 Bean 在开始加载之前要从上下文和一些配置中去解析并且查找 Bean 有关的扩展实现,比如像
- 创建实例化
- 这个阶段的主要作用是通过反射区创建 Bean 的实例对象,并且会扫描和解析 Bean 声明的一些属性
- 依赖注入
- 如果被实例化的 Bean 存在依赖其它 Bean 对象的情况,则需要对这些依赖的 Bean 进行对象注入,比如常见的
@Autowired
以及setter
注入等这样一些配置形式,同时在这个阶段会触发一些扩展的调用,比如说常见的扩展类BeanPostProcessors
用来去实现 Bean 初始化前后的扩展回调,以及像BeanFactoryAware
等等
- 如果被实例化的 Bean 存在依赖其它 Bean 对象的情况,则需要对这些依赖的 Bean 进行对象注入,比如常见的
- 容器缓存
- 该阶段的主要作用是吧 Bean 保存到容器以及 Spring 的缓存中,到这个阶段 Bean 就可以被开发者去使用了,这个阶段涉及到一些常见的操作,像
init-method
属性配置的方法会在这个阶段被调用,以及像BeanPostProcessor
的后置处理器方法也会在这个阶段被触发
- 该阶段的主要作用是吧 Bean 保存到容器以及 Spring 的缓存中,到这个阶段 Bean 就可以被开发者去使用了,这个阶段涉及到一些常见的操作,像
- 销毁实例
- 当 Spring 的应用上下文被关闭的时候,那么这个上下文中所有的 Bean 都会被销毁,如果存在 Bean 实现了像
DisposableBean
接口或者配置了destory-method
属性的一些方法会在这个阶段被调用
- 当 Spring 的应用上下文被关闭的时候,那么这个上下文中所有的 Bean 都会被销毁,如果存在 Bean 实现了像
40、IOC 和 DI 是什么?
- IOC(控制反转)
- 全称是 Inversion Of Control,也就是控制反转,将对在自身对象中的一个内置对象的控制反转,反转后不再由自己本身的对象进行控制这个内置对象的创建,而是由第三方系统去控制这个内置对象的创建。简单来说就是把本来在类内部控制的对象,反转到类外部进行创建后注入,不再由类本身进行控制,这就是IOC的本质。
- DI(依赖注入)
- 全称为 Dependency Injection, 意思是自身对象中的内置对象是通过注入的方式进行创建。
- OC 和 DI 的关系
- IOC 就是容器,DI 就是注入这一行为,那么 DI 确实就是 IOC 的具体功能的实现。而 IOC 则是 DI 发挥的平台和空间。所以说,IOC 和 DI 即是相辅相成的搭档。最重要的是,他们都是为了实现解耦而服务的。
- DI 是如何实现的?
- 赖注入可以通过 setter 方法注入(设值注入)、构造器注入和接口注入三种方式来实现,Spring 支持 setter 注入和构造器注入,通常使用构造器注入来注入必须的依赖关系,对于可选的依赖关系,则 setter 注入是更好的选择,setter 注入需要类提供无参构造器或者无参的静态工厂方法来创建对象。
41、Spring IOC 的工作流程
IOC 的全称是 Inversion Of Control ,也就是控制反转,它的核心思想就是把对象的管理权限交给了容器,应用程序如果需要使用某个对象的实例,直接从 IOC 容器里面去获取,这种设计的好处在于降低了程序里面对象与对象之间的耦合性,使得程序的整个体系结构变得更加灵活。
Spring 中提供了很多方式去声明 Bean,比如说在 XML
配置文件里面,通过 <bean>
的标签,或者通过 @Service
注解,或者通过 @Configuration
配置类里面的 @Bean
注解去声明等等,Spring 在启动的时候回去解析这些 Bean,然后保存到 IOC 容器里面
Spring IOC 的工作流程大致可以分为两个阶段:
- 第一个阶段是 IOC 容器初始化阶段,这个阶段主要是根据程序里面定义的
XML
或者 注解等 Bean 的声明方式,通过解析和加载后生成BeanDefinition
,然后把BeanDefinition
注册到 IOC 容器里面,通过注解或者xml
声明的 Bean 都会解析得到一个BeanDefinition
实体,这个实例里面会包含 bean 的定义和基本的属性,最后把这个BeanDefinition
保存到一个Map
集合里面,从而去完成 IOC 的一个初始化,IOC 容器的作用就是对这些注册的 Bean 的定义信息进行处理和维护,它是 IOC 容器控制反转的一个核心 - 第二个阶段是完成 Bean 的初始化和依赖注入,进入第二个阶段以后,这个阶段会做两个事情,第一个是通过反射去针对没有设置
lazy-init
属性的单例 bean 进行初始化;第二个是完成 Bean 的依赖注入 - 最后一个阶段就是 Bean 的使用,我们可以通过
@Autowired
这样一个注解,或者通过BeanFactory.getBean()
从 IOC 容器里面去获取一个指定 Bean 的实例,另外针对设置了lazy-init
属性以及非单例 Bean 的一个实例化,是在每一次获取 Bean 对象的时候,调用 Bean 的初始化方法来完成实例化的,并且 Spring IOC 容器不会去管理这些 Bean
42、Spring AOP 的实现原理
Spring AOP 的面向切面编程,是面向对象编程的一种补充,用于处理系统中分布的各个模块的橫切关注点,比如说事务管理、日志、缓存等。它是使用动态代理实现的,在内存中临时为增强某个方法生成一个AOP对象,这个对象包含目标对象的所有方法,在特定的切入点做了增强处理,并回调原来的方法。
Spring AOP 的动态代理主要有两种方式实现,JDK 动态代理
和 cglib 动态代理
。JDK 动态代理
通过反射来接收被代理的类,但是被代理的类必须实现接口,核心是 InvocationHandler
和 Proxy
类。cglib 动态代理
的类一般是没有实现接口的类,cglib 是一个代码生成的类库,可以在运行时动态生成某个类的子类,所以,CGLIB 是通过继承的方式做的动态代理,因此如果某个类被标记为 final,那么它是无法使用 CGLIB 做动态代理的。
AOP 能做什么?
- 可以降低模块之间的耦合度
- 使系统容易扩展
- 避免修改业务代码,避免引入重复代码,更好的代码复用
AOP 怎么用?
- 前置通知:某方法调用之前发出通知
- 后置通知:某方法完成之后发出通知
- 返回后通知:方法正常返回后,调用通知。在方法调用后,正常退出发出通知
- 异常通知:在方法调用时,异常退出发出通知
- 抛出异常后通知(After throwing advice):在方法抛出异常退出时执行的通知
- 环绕通知:通知包裹在被通知的方法的周围
43、谈谈你对 Spring MVC 的理解
首先 Spring MVC 是属于 Spring Framework 生态里面的一个模块,它是在 Servlet 的基础上构建并且使用了 MVC 模式设计的一个 Web 框架,它的主要目的是为了简化传统的 Servlet + JSP 模式下的 Web 开发方式,其次 Spring MVC 的整个架构设计是对 Java Web 里面的 MVC 框架模式做了一些增强和扩展,主要体现在几个方面:① 把传统 MVC 框架里面的 Controller 控制器做了拆分,分成了前端控制器 DispatcherServlet 和后端控制器 Controller;② 把 Model 模型拆分成业务层 Service 和数据访问层 Repository;③ 在视图层,可以支持不同的视图,比如:Freemark、velocity、JSP 等等。所以 Spring MVC 天生就是为了 MVC 模式而设计的,因此在开发 MVC 应用的时候会更加方便和灵活。
Spring MVC 的整体工作流程: 游览器的请求首先会经过 Spring MVC 里面的核心控制器 DispatcherServlet,它主要是把请求分发到对应的 Controller 里面,而 Controller 里面处理业务逻辑之后,会返回一个 ModeAndView,然后 DispatcherServlet 会去寻找一个或者多个 ViewResolver 的视图解析器,找到 ModeAndView 指定的视图,并且把数据展示到客户端。
Spring MVC 的优点:
- 它是基于组件技术的。全部的应用对象,无论控制器和视图,还是业务对象之类的都是 java 组件。并且和 Spring 提供的其他基础结构紧密集成
- 不依赖于 ServletAPI (目标虽是如此,但是在实现的时候确实是依赖于 Servlet 的)
- 可以任意使用各种视图技术,而不仅仅局限于JSP。比如:PDF, Excel
- 支持各种请求资源的映射策略
- 它是易于扩展的
44、Spring MVC 的工作流程
- 用户向服务器发送请求,请求被 Spring 前端控制器
DispatcherServlet
捕获 DispatcherServlet
对请求URL
进行解析,得到请求资源标识符(URI)。然后根据该 URI,调用HandlerMapping
获得该Handler
配置的所有相关的对象(包括 Handler 对象以及 Handler 对象对应的拦截器),最后以HandlerExecutionChain
对象的形式返回DispatcherServlet
根据获得的Handler
,选择一个合适的HandlerAdapter
(适配器)。( 附注:如果成功获得HandlerAdapter后,此时将开始执行拦截器的 preHandler(…)方法)- 提取
Request
中的模型数据,填充Handler
入参,开始执行Handle
r (也就是我们自己写的Controller
)。在填充 Handler的入参过程中,根据你的配置,Spring 将帮你做一些额外的工作HttpMessageConveter
;将请求消息 (如 Json、xml 等数据)转换成一个对象,将对象转换为指定的响应信息- 数据转换:对请求消息进行数据转换。如 String 转换成 Integer、Double等
- 数据格式化:对请求消息进行数据格式化。如将字符串转换成格式化数字或格式化日期等
- 数据验证:验证数据的有效性(长度、格式等),验证结果存储到
BindingResult
或 Error 中
- Handler 执行完成后,向
DispatcherServlet
返回一个ModelAndView
对象 - 根据返回的
ModelAndView
,选择一个适合的ViewResolver
( 必须是已经注册到Spring容器中的ViewResolver)返回给DispatcherServlet
- ViewResolver 结合 Model和View, 来渲染视图
- 将渲染结果返回给客户端
45、Spring 中 Bean 是线程安全的吗?
Spring 本身并没有针对 Bean 做线程安全的处理,所以:
- 如果 Bean 是无状态的,那么 Bean 则是线程安全的
- 如果 Bean 是有状态的,那么 Bean 则不是线程安全的
- 另外,Bean 是不是线程安全的,跟 Bean 的作用域没有关系,Bean 的作用域只是表示 Bean 的生命周期范围,对于任何生命周期的 Bean 都是一个对象,这个对象是不是线程安全的,还是得看这个 Bean 对象本身。
46、Spring 中 Bean 的作用域有哪些?
- singleton (单例模式):使用该属性定义 Bean 时,IOC 容器仅创建一个 Bean 实例,IOC 容器每次返回的是同一个 Bean 实例
- prototype ( 原型模式):使用该属性定义 Bean 时,IOC 容器可以创建多个 Bean 实例,每次返回的都是一个新的实例
- request (HTTP 请求):该属性仅对 HTTP 请求产生作用,使用该属性定义 Bean 时,每次 HTTP 请求都会创建一个新的 Bean,适用于
WebApplicationContext
环境 - session(会话):该属性仅用于 HTTP Session,同一个 Session 共享一个 Bean 实例。不同 Session 使用不同的实例
- global-session (全局会话,在spring5. x版本中已经移除了):该属性仅用于 HTTP Session, 同 session 作用域不同的是,所有的 Session 共享一个 Bean 实例
47、ApplicationContext 和 BeanFactory 有什么区别
BeanFactory 是 Spring 中非常核心的组件,表示 Bean 工厂,可以生成 Bean,维护 Bean,而 ApplicationContext 继承 BeanFactory,所以 ApplicationContext 拥有 BeanFactory 所有的特点,也是一个 Bean 工厂,但是 ApplicationContext 除开继承了 BeanFactory 之外,还继承了诸如 EnvironmentCapable、MessageSource、ApplicationEventPublisher 等接口,从而 ApplicationContext 还有获取系统环境变量、国际化、事件发布等功能,这是 BeanFactory 所不具备的。
48、Spring 中的事务是如何实现的
- Spring 事务底层是基于数据库事务和 AOP 机制的
- 首先对于使用了
@Transaction
注解的 Bean,Spring 会创建一个代理对象作为 Bean - 当调用代理对象的方法时,会先判断该方法上是否加了
@Transaction
注解 - 如果加了,那么则利用事务管理器创建一个数据库连接
- 并且修改数据库连接的
autocommit
属性为 false,禁止此连接的自动提交,这是实现 Spring 事务非常重要的一步 - 然后执行当前方法,方法中会执行 sql
- 执行完当前方法后,如果没有出现异常就直接提交事务
- 如果出现了异常,并且这个异常是需要回滚的,就会回滚事务,否则仍然提交事务
- Spring 事务的隔离级别对应的就算数据库的隔离级别
- Spring 事务的传播机制是 Spring 事务自己实现的,也是 Spring 事务中最复杂的
- Spring 事务的传播机制是基于数据库连接来做的,一个数据库连接一个事务,如果传播机制配置为需要新开一个事务,那么实际上就是先建立一个数据连接,在此新数据库连接上执行 sql
49、Spring 中什么时候 @Transactional 会失效
因为 Spring 事务是基于代理来实现的,所以某个加了 @Transaction
的方法只有是被代理对象调用时,那么这个注解才会生效,所以如果是被代理对象来调用这个方法,那么 @Transaction
是不会失效的。同时,如果某个方法是 private 的,那么 @Transactional
也会失效,因为底层 cglib
是基于父子类来实现的,子类是不能重载父类的 private 方法的,所以无法很好的利用代理,也会导致 @Transactional
失效。
50、Spring 容器启动流程是怎样的
- 在创建 Spring 容器,也就是启动 Spring 时
- 首先会进行扫描,扫描得到所有的 BeanDefinition 对象,并存在一个 Map 中
- 然后筛选出非懒加载的单例 BeanDefinition 进行创建 Bean,对于多例 Bean 不需要在启动过程中去进行创建,对于多例 Bean 会在每次获取 Bean 时利用 BeanDefinition 去创建
- 利用 BeanDefinition 创建 Bean 就是 Bean 的创建生命周期,这期间包括了合并 BeanDefinition、推断构造方法、实例化、属性填充、初始化前、初始化、初始化后等步骤,其中AOP 就是发生在初始化后这一步骤中
- 单例 Bean 创建完之后,Spring 会发布一个容器启动事件
- Spring 启动结束
- 在源码中会更复杂,比如源码中会提供一些模板方法,让子类来实现,比如源码中还涉及到一些 BeanFactoryPostProcessor 和 BeanPostProcessor 的注册,Spring 的扫描就是通过 BeanFactoryPostProcessor 来实现的,依赖注入就是通过 BeanPostProcessor 来实现的
- 在 Spring 启动过程中还回去处理
@Import
等注解
51、Spring 用到了哪些设计模式?
- 工厂模式
- 这个很明显,在各种
BeanFactory
以及ApplicationContext
创建中都用到了
- 这个很明显,在各种
- 模板模式
- 这个也很明显,在各种
BeanFactory
以及ApplicationContext
实现中也都用到了
- 这个也很明显,在各种
- 代理模式
- 在 AOP 实现中用到了 JDK 的动态代理
- 单例模式
- 这个比如在创建 Bean 的时候
- 策略模式
- 在 spring 中,我们可以使用 JdbcTemplate 实现对数据库的 CRUD 操作,而在查询时我们可能会用到 RowMapper 接口以及 spring 提供的一个 BeanPropertyRowMappe r的实现类。RowMapper 接口就是规范,而我们根据实际业务需求编写的每个实现类,都是一个达成目标的策略
- 观察者模式
- spring 在 java EE 应用中创建的 WebApplicationContext 时,是通过一个 ContextLoaderListener 监听器实现的。监听器就是观察者模式的具体体现
- 适配器模式
- 在 spring- framework 中提供了 spring mvc 的开发包。我们在用spring mvc中,它实现控制器方式有很多种。例如我们常用的使用 @Controller 注解,还有实现 Controller 接口或者实现 HttpRequestHandler 接口等等。而在 DispatcherServlet 中如何处理这三种不同的控制器呢,它用到了适配器,用于对不同的实现方式适配
- 装饰者模式
- BeanWrapper
- 委派模式
- BeanDefinitionParserDelegate
- 责任链模式
- BeanPostProcessor
52、Spring 如何解决循环依赖问题
循环依赖是指一个或者多个 Bean 实例之间会存在直接或者间接的一个依赖关系,构成一个循环调用,通常表现为三种形态:① 互相依赖,也就是 A 依赖 B,B 依赖 A;② 间接依赖,两个或者两个以上的 Bean 存在间接依赖关系,造成一个循环调用;③ 自我依赖,也就是自己依赖自己而造成一个循环依赖。Spring 框架本身也考虑了这方面的问题,所以它设计了三级缓存来解决部分循环依赖的问题。
所谓三级缓存,其实就是用来存放不同类型的 Bean
- 第一级缓存存放的是完全初始化好的 Bean,这个 Bean 可以直接被使用
- 第二级缓存存放的是原始的 Bean 对象,也就是说,这个 Bean 里面的属性还没有被进行赋值,或者没有被依赖注入
- 第三级缓存存放的是 Bean 工厂的一个对象,用来生成原始 Bean 对象,并且放入到二级缓存里面,
比如:Bean A 和 Bean B 之间存在一个循环依赖,那么在三级缓存的设计里面,首先会初始化 Bean A,先把 Bean A 实例化,然后把 Bean 包装成 ObjectFactory 对象,保存到三级缓存里面,接下来 Bean A 开始对它的成员属性 Bean B 进行依赖注入,于是开始了初始化 Bean B,同样它也会做两件事情,创建 Bean B 的实例,以及加入到 三级缓存里面,然后 Bean B 也开始进行一个依赖注入,在三级缓存里面去找 Bean A 的这样一个实例,于是完成的 Bean A 的一个依赖注入,Bean B 初始化成功以后就会保存到一级缓存里面,于是,Bean A 同样可以成功拿到 Bean B 的一个实例,从而去完成正常的依赖注入。
整个流程看起来有些复杂,但是它的核心思想就是把 Bean 的实例化和 Bean 中的属性依赖注入这两个过程分离出来,不过需要注意的是 Spring 本身只能解决单实例存在的循环引用问题,如果存在以下四种情况,则需要人为去干预:
- 多实例的 Setter 注入导致的循环依赖,需要把 Bean 改成单例
- 构造器注入导致的循环依赖,可以通过
@Lazy
注解 - DependsOn 导致的循环依赖,找到注解循环依赖的地方,迫使它不循环依赖
- 单例的代理对象 Setter 注入导致的循环依赖
- 可以使用
@Lazy
- 或者使用
@DependsOn
注解指定加载先后关系
- 可以使用
在实际开发过程中出现循环依赖的根本原因其实还是在代码的一个设计上,因为模块的耦合度较高的情况下,依赖关系的复杂度一定会增加,我们应该尽可能的去从系统设计的角度去考虑模块之间的依赖关系,避免循环依赖的问题
简短版:
Spring 设计了三级缓存来解决循环依赖的问题,第一级缓存里面存储完整的 Bean 实例,这些实例是可以直接被使用的,第二级缓存里面存储的实例化,但是还没有设置属性值 Bean 实例,也就是 Bean 里面的依赖注入还没有做,第三级缓存是用来存放 Bean 工厂,它主要用来生成原始 Bean 对象,并且放到第二级缓存里面。三级缓存的核心思想就是把 Bean 的实例化和 Bean 里面的依赖注入进行分离,采用一级缓存存储完整的 Bean 实例,采用二级缓存来存储不完整的 Bean 实例,通过不完整的 Bean 实例作为突破口解决循环依赖问题,至于第三级缓存主要是解决代理对象的循环依赖问题。
53、Spring 里面的事务和分布式事务的使用如何区分
Spring 里面并没有提供事务,它只是提供了对数据库事务的一个管理的封装,我们可以通过声明式事务的配置使得开发人员可以从一些复杂的事务处理里面去脱离出来,不需要再去关心连接的获取、连接的关闭、事务的提交、事务的回滚等这样一些操作,我们可以更加聚焦在业务的开发层面,所以 Spring 里面的事务本质上是数据库层面的一个事务,而这种事务管理主要是针对于单个数据库里面的多个数据表的操作,它去满足一个事务的 ACID 特性。
而分布式事务是解决多个数据库事务操作的一个数据一致性问题,传统的关系数据库不支持跨库的事务操作,所以需要引用分布式事务的解决方案,而 Spring 里面并没有提供分布式事务的场景支持,所以,Spring 里面的事务和分布式事务在使用上并没有直接的关联关系,但是可以使用一些主流的分布式事务解决框架,比如shuo像 Seate 集成到 Spring 生态里面去解决分布式事务的一个问题。
54、Spring Boot 中常用注解以及其底层实现
@SpringBootApplication
注解:这个注解标识了一个SpringBoot
工程,它实际上是另外三个注解的组合,这三个注解是:
①@SpringBootConfiguration
:这个注解实际就是一个@Configuration
,表示启动类也是一个配置类
②@EnableAutoConfiguration
:开启自动配置,向 Spring 容器中导入一个 Selector,用来加载 ClassPath 下 SpringFactories 中所定义的自动配置类,将这些自动加载为配置 Bean
③ComponentScan
:表示扫描路径,因为默认是没有配置实际扫描路径,所以 SpringBoot 扫描的路径是启动类所在的当前目录@Bean
注解:用来定义 Bean,类似于 XML 中的<bean>
标签,Spring 在启动时,会对@Bean
注解的方法进行解析,将方法的名字作为 beanName,并通过执行方法得到 Bean 对象
55、Spring Boot 自动装配机制的原理
自动装配简单来说就是自动去把第三方组件的 Bean 装载到 IOC 容器里面,不需要开发人员再去写 Bean 相关的一个配置,在 Spring boot 应用里面只需要在启动类上加上 @SpringBootApplication
注解就可以去实现自动装配,@SpringBootApplication
是一个复合型注解,真正去实现自动装配的注解是 @EnableAutoConfiguration
,自动装配的实现主要依靠三个核心的关键技术:① 引入 Starter 启动依赖组件的时候,这个组件里面必须要包含一个 @Configuration
配置类,而在这个配置类里面,需要通过 @Bean
这个注解去声明需要装配到 IOC 容器里面的 Bean 对象;② 这个配置类是放在第三方的 jar 包里面,然后通过 Spring Boot 中约定优于配置的这样一个理念,去把这个配置类的全路径放在 classPath:/META-INF/spring,factories
文件里面,这样的话,Spring Boot 就可以知道第三方 jar 包里面这个配置类的位置,这个步骤主要使用到了 Spring 里面的 SpringFactoriesLoader
来完成的;③ Spring Boot 拿到所有第三方 jar 包里面声明的配置类以后,再通过 Spring 提供的 ImportSelector
这样一个接口来实现对这些配置类的动态加载,从而去完成自动装配这样一个动作。
56、Spring Boot 是如何启动 Tomcat 的
- 首先,Spring 在启动时会先创建一个 Spring 容器
- 在创建 Spring 容器过程中,会利用
@ConditionalOnClass
技术来判断当前calsspath
中是否存在 Tomcat 依赖,如果存在则会生成一个启动 Tomcat 的 Bean - Spring 容器创建完之后,就会获取启动 Tomcat 的 Bean,并创建 Tomcat 对象,绑定端口等,然后启动 Tomcat
57、SpringBoot 中配置文件的加载顺序是怎样的?
优先级从高到低,高优先级的配置赋值低优先级的配置,所有配置会形成互补配置
- 命令行参数,所有的配置都可以在命令行上进行指定
- 来自
java:comp/env
的 JNDI 属性 - Java 系统属性(System.getProperties())
- 操作系统环境变量
- jar 包外部的
application-{profile}.properties
或application.yml
(带spring.profile
)配置文件 - jar 包内部的
application-{profile}.properties
或application.yml
(带spring.profile
)配置文件 - jar 包外部的
application.properties
或application.yml
(不带spring.profile
)配置文件 - jar 包内部的
application.properties
或application.yml
(不带spring.profile
)配置文件 @Configuration
注解类上的@PropertySource
58、什么是 CAP 理论
CAP 理论是分布式领域中非常重要的一个指导理论,C(Consistency)表示强一致性,A(Availability)表示可用性,P(Partition Tolerance)表示分区容错性,CAP 理论指出在目前的硬件条件下,一个分布式系统是必须要保证分区容错性,而在这个前提下,分布式系统要么保证 CP,要么保证 AP,无法同时保证 CAP。
分区容错性
表示一个系统虽然是分布式的,但是对外看上去应该是一个整体,不能由于分布式系统内部的某个结点挂点,或网络出现了故障,而导致系统对外出现异常。所以,对于分布式系统而言是一定要保证分区容错性的强一致性
表示一个分布式系统中各个结点之间能及时的同步数据,在数据同步过程中,是不能对外提供服务的,不然就会造成数据不一致,所以强一致性和可用性是不能同时满足的可用性
表示一个分布式系统对外要保证可用
59、什么是 BASE 理论
由于不能同时满足 CAP,所以出现了 BASE 理论:
- BA:Basically Available,表示基本可用,表示可以允许一定程度的不可用,比如由于系统故障,请求时间变长,或者由于系统故障导致部分非核心功能不可用,都是允许的
- S:Soft state,表示分布式系统可以处于一种中间状态,比如数据正在同步
- E:Eventually consistent,表示最终一致性,不要求分布式系统数据实时达到一致,允许在经过一段时间后再达到一致,在达到一致过程中,系统是可用的
60、什么时 RPC
RPC,表示远程过程调用,对于 Java 这种面向对象语言,也可以理解为远程方法调用,RPC 调用和 HTTP 调用时有区别的,RPC 表示的是一种调用远程方法的方式,可以使用 HTTP 协议、或直接基于 TCP 协议来实现 RPC,在 Java 中,我们可以通过直接使用某个服务接口的代理对象来执行方法,而底层则通过构造 HTTP 请求来调用远端的方法,所以,有一种说法是 RPC 协议 HTTP 协议之上的一种协议,也是可以理解的。
61、分布式 ID 是什么?有哪些解决方案?
在开发中,通常会需要一个唯一 ID 来标识数据,如果是单体架构,我们可以通过数据库的主键,或者直接在内存中维护一个自增数字来作为 ID 都是可以的,但对于一个分布式系统,就会有可能出现 ID 冲突。
解决方案:
- UUID,这种方案复杂度最低,但是会影响存储空间和性能
- 利用单机数据库的自增主键,作为分布式 ID 的生成器,复杂度适中,ID 长度较 UUID 更短,但是受到单机数据库性能的限制,并发量大的时候,此方案也不是最优方案
- 利用 redis、zookeeper 的特性来生成 ID,比如 redis 的自增命令、zookeeper 的顺序节点,这种方案和单机数据相比,性能有所提高,可以适当选用
- 雪花算法,一切问题如果能直接用算法解决,那就是最合适的,利用雪花算法可以生成分布式 ID,底层原理就是通过某台机器在某一毫秒内对某一个数字自增,这种方案也能保证分布式架构中的系统 ID 唯一,但是只能保证趋势递增。业界存在
tinyid
、leaf
等开源中间件实现了雪花算法
62、分布式锁的使用场景是什么?有哪些实现方案?
在单体架构中,多个线程都是属于同一个进程的,所以在线程并发执行时,遇到资源竞争时,可以利用 ReentrantLock
、synchronized
等技术作为锁,来控制共享资源的使用。
而在分布式架构中,多个线程是可能处于不同进程中的,而这些线程并发执行遇到资源竞争时,利用 ReentrantLock
、synchronized
等技术是没办法来控制多个进程中的线程的,所以需要分布式锁,意思就是,需要一个分布式锁生成器,分布式系统中的应用程序可以来使用这个生成器所提供的锁,从而达到多个进程中的线程使用同一把锁。
目前主流的分布式锁的实现方式有两种:
- zookeeper:利用的是 zookeeper 的临时节点、顺序节点、watch 机制来实现的,zookeeper 分布式锁的特点是高一致性,因为 zookeeper 保证的是 CP,所以由它实现的分布式锁更可靠,不会出现混乱
- redis:利用 redis 的
setnx
、lua 脚本
、消费订阅
等机制来实现的,redis 分布式锁的特点是高可用,因为 redis 保证的是 AP ,所以由它实现的分布式锁可能不可靠,不稳定,可能会出现多个客户端同时加锁的情况
63、什么是分布式事务?有哪些实现方案?
在分布式系统中,一次业务处理可能需要多个应用来实现,比如用户发送一次下单请求,就涉及到订单系统创建订单、库存系统减库存,而对于一次下单,订单创建与库存应该是要同时成功或同时失败的,但在分布式系统中,如果不做处理,就很可能出现订单创建成功,但是减库存失败,那么解决这类问题,就需要用到分布式事务。常用的解决方案有:
- 本地消息表:创建订单时,减库存消息加入本地事务中,一起提交到数据存入本地消息表,然后调用库存系统,如果调用成功则修改本地消息状态为成功,如果调用库存系统失败,则由后台定时任务从本地消息表中取出未成功的消息,重试调用库存系统
- 消息队列:目前
RocketMQ
中支持事务消息,它的工作原理是:- 生产者订单系统先发送一条
half
消息到Broker
,half
消息对消费者而言是不可见的 - 再创建订单,根据创建订单成功与否,向
Broker
发送commit
或rollback
- 并且生产者订单系统还可以提供
Broker
回调接口,当Broker
发现一段时间的half
消息没有收到任何操作命令,则会主动调此来查询接口订单是否创建成功 - 一旦
half
消息commit
了,消费者库存系统就会来消费,如果消费成功,则消息销毁,分布式事务成功结束 - 如果消费失败,则根据重试策略进行重试,最后还失败则进入死信队列,等待进一步处理
- 生产者订单系统先发送一条
- Seate:阿里开源的分布式事务框架,支持 AT、TCC 等多种模式,底层都是基于两阶段提交理论来实现的
64、雪花算法的实现原理
雪花算法是一种生成分布式全局唯一 ID 的一个算法,它会等到一个 64 位长度的 long 类型的数据,其中这 64 位数据由四个部分组成:
- 第一个 bit 位是一个符号位,因为 id 不会是负数,所以它一般为 0
- 接着用 41 个 bit 位来表示毫秒单位的时间戳
- 再用 10 个 bit 位来表示工作机器的 id
- 最后用 12 个 bit 位来表示递增的序列号
然后把这 64 个 bit 位拼接成一个 long 类型的数字,这就是雪花算法的一个实现
65、什么是 ZAB 协议
ZAB 协议是 Zookeeper 用来实现一致性的原子广播协议,该协议描述了 Zookeeper 是如何实现一致性的,分为三个阶段:
- 领导者选举阶段:从 Zookeeper 集群中选出一个节点作为 Leader,所有的写请求都会由 Leader 节点来处理
- 数据同步阶段:集群中所有节点中的数据要和 Leader 节点保持一致,如果不一致则要进行同步
- 请求广播阶段:当 Leader 节点接收到写请求时,会利用两阶段提交来广播该写请求,使得写请求像事务一样在其他节点上执行,达到节点上的数据实时一致
值得注意的是,Zookeeper 只是尽量的达到强一致性,实际上仍然只是最终一致性的。
66、为什么 Zookeeper 可以用来作为注册中心
可以利用 Zookeeper 的临时节点和 watch 机制来实现注册中心的自动注册和发现,另外 Zookeeper 中的数据都是存在在内存中的,并且 Zookeeper 底层采用了 NIO,多线程模型,所以 Zookeeper 的性能也是比较高的,所以可以用来作为注册中心,但是如果考虑到注册中心应该是注册可用性的话,那么 Zookeeper 则不太合适,因为 Zookeeper 是 CP 的,它注重的是一致性,所以集群数据不一致时,集群将不可用,所以用 Redis、Eureka、Nacos 来作为注册中心将更合适。
67、Zookeeper 中的领导者选举的流程是怎样的?
对于 Zookeeper 集群,整个集群需要从集群节点中选出一个节点作为 Leader,大体流程如下:
- 集群中各个节点首先都是观望状态,一开始都会投票给自己,认为自己比较适合作为 Leader
- 然后相互交互投票,每个节点会收到其他节点发过来的选票,然后 PK,先比较 zxid,zxid 大者获胜,zxid 如果相等则比较 myid,myid 大者获胜
- 一个节点收到其他节点发过来的选票,经过 PK 后,如果 PK 输了,则改票,此节点就会投给 zxid 或 myid 更大的节点,并将选票放入自己的投票箱中,并将新的选票发送给其他节点
- 如果 PK 是平局则将接收到的选票放入自己的投票箱中
- 如果 PK 赢了,则忽略所有接收到的选票
- 当然一个节点将一张选票放入到自己的投票箱之后,就会从投票箱中统计票数,看是否超过一半的节点都和自己所投的节点是一样的,如果超过半数,那么则认为当前自己所投的节点是 Leader
- 集群中每个节点都会经过同样的流程,PK 的规则也是一样的,一旦改票就会告诉给其他服务器,所有最终各个节点中的投票箱中的选票也将是一样的,所以各个节点最终选出来的 Leader 也是一样的,这样集群的 Leader 就选举出来了
68、Zookeeper 集群中节点之间数据是如何同步的
- 首先集群启动时,会先进行领导者选举,确定哪个节点时 Leader,哪些节点是 Follower 和 Observer
- 然后 Leader 会和其他节点进行数据同步,采用发送快照和发送
Diff
日志的方式 - 集群在工作过程中,所有的写请求都会交给 Leader 节点来进行处理,从节点只能处理读请求
- Leader 节点收到一个写请求时,会通过两阶段机制来处理
- Leader 节点会将该写请求对应的日志发送给其他 Follower 节点,并等待 Follower 节点持久化日志成功
- Follower 节点收到日志后会进行持久化,如果持久化成功则发送一个 Ack 给 Leader 节点
- 当 Leader 节点收到半数以上的 Ack 后,就会开始提交,先更新 Leader 节点本地的内存数据
- 然后发送 commit 命令给 Follower 节点,Follower 节点收到 commit 命令后就会更新各自本地内存数据
- 同时 Leader 节点还是将当前写请求直接发送给 Observer 节点,Observer 节点收到 Leader 发过来的写请求后直接执行更新本地内存数据
- 最后 Leader 节点返回客户端写请求响应成功
- 通过同步机制和两阶段提交机制来达到集群中节点数据一致
69、Dubbo 支持哪些负载均衡策略
- 随机:从多个服务提供者随机选择一个来处理本次请求,调用量越大则分布越均匀,并支持按权重设置随机概率
- 轮询:一次选择服务提供者来处理请求,并支持按权重进行轮询,底层采用的时平滑加权轮询算法
- 最小活跃调用数:统计服务提供者当前正在处理的请求,下次请求过来则交给活跃数最小的服务器来处理
- 一致性哈希:相同参数的请求总是发到同一个服务提供者
70、Dubbo 是如何完成服务导出的?
- 首先 Dubbo 会将程序员所使用的
@DubboService
注解或@Service
注解进行解析得到程序员所定义的服务参数,包括定义的服务名、服务接口、服务超时时间、服务协议等等,得到一个ServiceBean
- 然后调用
ServiceBean
的export
方法进行服务导出 - 然后将服务信息注册到注册中心,如果有多个协议,多个注册中心,那就将服务按单个协议,单个注册中心进行注册
- 将服务信息注册到注册中心后,还会绑定一些监听器,监听动态配置中心的变更
- 还会根据服务协议启动对应的 Web 服务器或网络框架,比如
Tomcat
、Netty
等
71、Dubbo 是如何完成服务引入的?
- 当程序员使用
@Reference
注解来引入一个服务时,Dubbo 会将注解和服务的信息解析出来,得到当前所引用的服务名、服务接口是什么 - 然后从注册中心进行查询服务信息,得到服务的提供者信息,并存在消费端的服务目录中
- 并绑定一些监听来监听动态配置中心的变更
- 然后根据查询得到的服务提供者信息生成一个服务接口的代理对象,并放入 Spring 容器中作为 Bean
72、Dubbo 的架构设计是怎样的?
Dubbo 中的架构设计是非常优秀的,分为了很多层次,并且每层都是可以扩展的,比如:
- Proxy 服务代理层,支持 JDK 动态代理,javassist 等代理机制
- Registry 注册中心层,支持 Zookeeper、Redis 等作为注册中心
- Protocol 远程调用层,支持 Dubbo、HTTP 等调用协议
- Transport 网络传输层,支持 netty、mina 等网络传输框架
- Serialize 数据序列化层,支持 JSON、Hessian 等序列化机制
- …
73、谈谈你对 Spring Cloud 的理解
Spring Cloud 是 Spring 官方推出来的一套微服务的解决方案,准确来说,我认为 Spring Cloud 其实是对微服务架构里面出现的各种技术场景定义的一套标准规范,然后在这个标准规范里面 Spring 集成了 Netflix 公司里面的 OSS 开源套件,比如说:① Zuul
去实现应用网关;② Eureka
实现服务注册与发现;③ Ribbon
实现负载均衡;④ Hystrix
实现服务熔断。我们可以去使用 Spring Cloud Netflix 这样一套组件去快速落地微服务架构以及去解决微服务治理的一系列的问题,但是,随着 Netflix OSS 相关的一些技术组件的闭源和停止维护,所有 Spring 官方也自研了一些组件,比如说像 Gateway
来实现网关、LoadBalancer
去实现负载均衡,另外 Alibaba
里面的开源组件也实现了 Spring Cloud 这样一套标准,成为了 Spring Cloud 里面的另外一套微服务解决方案,包括 Dubbo 来实现 RPC
通信,Nacos
去实现服务注册于发现以及动态配置中心,三种去实现服务的限流和服务的降级等等
Spring Cloud 生态出现的出现有两个很重要的意义:
- 在 Spring Cloud 出现之前为了解决微服务架构里面的各种技术问题,需要去集成各种开源框架,因为标准和兼容性问题,所以在实践的时候很麻烦,而 Spring Cloud 统一了这样一个标准
- 降低了微服务架构的开发难度,只需要在 Spring Boot 的项目基础上通过 starter 启动依赖集成相关组件就能轻松解决各种问题
74、Spring Cloud 有哪些常用组件,作用是什么?
- Eureka:注册中心
- Nacos:注册中心、配置中心
- Consul:注册中心、配置中心
- Spring Cloud Config:配置中心
- Feign / OpenFeign:RPC 调用
- Kong:服务网关
- Zuul:服务网关
- Spring Cloud Gateway:服务网关
- Ribbon:负载均衡
- Spring Cloud Sleuth:链路追踪
- Zipkin:链路追踪
- Seate:分布式事务
- Dubbo:RPC 调用
- Sentinel:服务熔断
- Hystrix:服务熔断
75、Spring Cloud 和 Dubbo 有哪些区别?
Spring Cloud 是一个微服务框架,提供了微服务领域中很多功能组件,Dubbo 一开始是一个 RPC 调用框架,核心是解决服务调用间的问题,Spring Cloud 是一个大而全的框架,Dubbo 则更侧重于服务调用,所以 Dubbo 所提供的功能没有 Spring Cloud 全面,但是 Dubbo 的服务调用性能比 Spring Cloud 高,不过 Spring Cloud 和 Dubbo 并不是对立的,是可以结合起来一起使用的。
76、什么是服务雪崩?什么是服务限流?
- 当服务A调用服务B,服务B调用服务C,此时大量请求突然请求服务A,假如服务A本身能抗住这些请求,但是如果服务C扛不住,导致服务C请求堆积,从而服务B请求堆积,从而服务A不可用,这就是服务雪崩,解决方式就是服务降级和服务熔断
- 服务限流是指在高并发请求下,为了保护系统,可以对访问服务的请求进行数量上的限制,从而防止系统不被大量请求压垮,在秒杀中,限流是非常重要的
77、什么是服务熔断?什么是服务降级?区别是什么?
- 服务熔断:是指当服务A调用的某个服务B不可用时,上游服务A为了保证自己不受影响,从而不再调用服务B,直接返回一个结果,减轻服务A和服务B的压力,直到服务B恢复
- 服务降级:是指当发现系统压力过载时,可以通过关闭某个服务,或限流某个服务来减轻系统压力,这就是服务降级
相同点:
- 都是为了防止系统崩溃
- 都让用户体验到某些功能暂时不可用
不同点:熔断时下游服务故障触发的,降级是为了降低系统负载
78、SOA、分布式、微服务之间有什么关系和区别?
- 分布式架构是指将单体架构中的各个部分拆分,然后部署不同的机器或者进程中去,SOA 和微服务基本上都是分布式架构
- SOA 是一种面向服务的架构,系统的所有服务都注册在总线上,当调用服务时,从总线上查找服务信息,然后调用
- 微服务是一种更彻底的面向服务的架构,将系统中各个功能抽成一个个小的应用程序,基本保持一个应用对应的一个服务的架构
79、BIO、NIO、AIO 分别是什么
- BIO:同步阻塞 IO,使用 BIO 读取数据时,线程会阻塞住,并且需要线程主动去查询是否有数据可读,并且需要处理完一个 Socket 之后才能处理下一个 Socket
- NIO:同步非阻塞 IO,使用 NIO 读取数据时,线程不会阻塞,但需要线程主动的去查询是否有 IO 事件
- AIO:异步非阻塞 IO,使用 AIO 读取数据时,线程不会阻塞,并且当有数据可读时会通知给线程,不需要线程主动去查询
80、零拷贝是什么?
零拷贝指的是,应用程序在需要把内核中的一块区域数据转移到另外一块内核区域去时,不需要经过先复制到用户空间,再转移到目标内核区域去了,而直接实现转移。
81、Netty 是什么?和 Tomcat 有什么区别?特点是什么?
Netty 是一个基于 NIO 的异步网络通信框架,性能高,封装了原生 NIO 编码的复杂度,开发者可以直接使用 Netty 来开发高效率的各种网络服务器,并且编码简单。
Tomcat 是一个 Web 服务器,是一个 Servlet 容器,基本上 Tomcat 内部只会运行 Servlet 程序,并处理 HTTP 请求,而 Netty 封装的是底层 IO 模型,关注的是网络数据的传输,而不关心具体的协议,可定制性更高
Netty 的特点:
- 异步、NIO 的网络通信框架
- 高性能
- 高扩展,高定制性
- 易用性
82、Netty 的线程模型是怎么样的
Netty 同时支持 Reactor 单线程模型、Reactor 多线程模型和 Reactor 主从多线程模型,用户可根据启动参数配置在这三种模型之间切换。
服务器启动时,通常会创建两个 NioEventLoopGroup
实例,对应了两个独立的 Reactor 线程池,bossGroup 负责处理客户端的连接请求,workerGroup
负责处理 I/O
相关的操作,执行系统 Task
、定时任务 Task
等。用户可根据服务端引导类 ServerBootstrap
配置参数选择 Reactor 线程模型,进而最大限度地满足用户的定制化需求。
83、Netty 的高性能体现在哪些方面
- NIO 模型,用最少的资源做更多的事情
- 内存零拷贝,尽量减少不必要的内存拷贝,实现了更高效率的传输
- 内存池设计,申请的内存可以重用,主要指直接内存。内部实现是一颗二叉查找树管理内存分配情况
- 串行化处理读写:避免使用锁带来的性能开销。即消息的处理尽可能再同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整
NIO
线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队里–多个工作线程模型性能更优。 - 高性能序列化协议:支持
protobuf
等高性能序列化协议 - 高效并发编程的体现:volatile 的大量、正确的使用;
CAS
和原子类的广泛使用;线程安全容器的使用;通过读写锁提升并发性能。
84、Redis 有哪些数据结构?分别有哪些典型的应用场景?
- 字符串:可以用来做最简单的数据,可以缓存某个简单的字符串,也可以缓存某个
json
格式的字符串,Redis 分布式锁的实现就利用了这种数据结构,还包括可以实现计数器、Session
共享、分布式 ID - 哈希表:可以用来存储一些
key-value
对,更适合用来存储对象 - 列表:Redis 的列表通过命令的组合,既可以当做栈,也可以当做队列来使用,可以用来缓存类似微信公众号、微博等消息流数据
- 集合:和列表类似,也可以存储多个元素,但是不能重复,集合可以进行交集、并集、差集操作,从而可以实现类似:我和某人共同关注的人、朋友圈点赞等功能
- 有序集合:集合是有序的,有序集合可以设置顺序,可以用来实现排行榜功能
85、Redis 分布式锁底层是如何实现的?
- 首先利用
setnx
来保证:如果 key 不存在才能获取到锁,如果 key 存在,则获取不到锁 - 然后还要利用
lua
脚本来保证多个redis
操作的原子性 - 同时还要考虑锁过期,所以需要额外的一个定时任务来监听锁是否需要续约
- 同时还要考虑到
redis
节点挂掉后的情况,所以需要采用红锁的方式来同时向N/2 +1
个节点申请锁,都申请到了才证明获取锁成功,这样就算其中某个redis
节点挂掉了,锁也不能被其他客户端获取到
86、Redis 主从复制的核心原理
Redis 的主从复制是提高 Redis 的可靠性的有效措施,主从复制的流程如下:
- 集群启动时,主从库间会先建立连接,为全量复制做准备
- 主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载,这个过程依赖于内存快照 RDB
- 在主库将数据同步给从库的过程中,主库不会阻塞,仍然可以正常接收请求。否则,redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中,为了保证主从库的数据一致性,主库会在内存中专门的
replication buffer
,记录 RDB 文件生成收到的所有写操作 - 最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库,具体的操作是,当主库完成
RDB
文件发送后,就会把此时replocation buffer
中修改操作发送给从库,从库再执行这些操作。这样一来,主从库就实现同步了 - 后续主库和从库都可以处理客户端读操作,写操作只能交给主库处理,主库接收到写操作后,还会将写操作发送给从库,实现增量同步
87、缓存穿透、缓存击穿、缓存雪崩分别是什么?
缓存中存放的大多都是热点数据,目的就算防止请求可以直接从缓存中获取到数据,而不用访问 MySql。
- 缓存雪崩:如果缓存中某一时刻大批热点数据同时过期,那么就可能导致大量请求直接访问 MySql 了,解决办法就是在过期时间上增加一点随机值,另外如果搭建一个高可用的 Redis 集群也是防止缓存雪崩的有效手段
- 缓存击穿:和缓存雪崩类似,缓存雪崩是大批热点数据失效,而缓存击穿是指某一个热点 key 突然失效,也导致了大量请求直接访问 MySql 数据库,这就是缓存击穿,解决方案就算考虑这个热点 key 不设置过期时间
- 缓存穿透:假如某一时刻访问 Redis 的大量 key 都在 Redis 中不存在,那么也会给数据造成压力,这就是缓存穿透,解决方案是使用布隆过滤器,它的作用就是如果它认为一个 key 不存在,那么这个 key 就肯定不存在,所以可以在缓存之前加一层布隆过滤器来拦截不存在的 key
88、Redis 和 MySql 如何保证数据一致
- 方案一:先更新 MySql,再更新 Redis,如果更新 Redis 失败,可能仍然不一致
- 方案二:先删除 Redis 缓存数据,再更新 MySql,再次查询的时候再将数据添加到缓存中,这种方案能解决方案一的问题,但是在高并发下性能较低,而且仍然会出现数据不一致的问题,比如线程1删除了 Redis 缓存数据,正在更新 MySql,此时另外一个查询再查询,那么就会把 MySql 中老数据又查到 Redis 中
- 方案三:延时双删,步骤是:先删除 Redis 缓存数据,再更新 MySql,延迟几百毫秒再删除 Redis 缓存数据,这样就算在更新 MySql 时,有其他线程读了 MySql、把老数据读到了 Redis 中,那么也会被删除掉,从而把数据保持一致
89、Explain 语句结果中各个字段分别表示什么
列名 | 描述 |
---|---|
id | 查询语句中每出现一个 SELECT 关键字,MySQL 就会为它分配一个唯一的 id 值,某些子查询会被优化为 join 查询,那么出现的 id 会一样 |
select_type | SELECT 关键字应对的那个查询的类型 |
table | 表名 |
partitions | 匹配的分区信息 |
type | 针对单表的查询方式(全表扫描、索引) |
possible_keys | 可能用到的索引 |
key | 实际上使用的索引 |
key_len | 实际使用到的索引长度 |
ref | 当使用索引列等值查询时,与索引列进行等值匹配的对象信息 |
rows | 预估的需要读取的记录条数 |
filtered | 某个表经过搜索条件过滤后剩余记录条数的百分比 |
Extra | 一些额外的信息,比如排序等 |
90、索引覆盖是什么
索引覆盖就是一个 SQL 在执行时,可以利用索引来快速查找,并且此 SQL 所要查询的字段在当前索引对应的字段中都包含了,那么就表示此 SQL 走完索引后不用回表了,所需要的字段都在当前索引的叶子节点上存在,可以直接作为结果返回了
91、最左前缀原则是什么
当一个 SQL 想要利用索引是,就一定要提供该索引所对应的字段中最左边的字段,也就是排在最前面的字段,比如针对 a,b,c 三个字段建立了一个联合索引,那么在写一个 SQL 时就一定要提供 a 字段的条件,这样才能用到联合索引,这是由于在建立 a,b,c 三个字段的联合索引时,底层的 B+树 是按照 a,b,c 三个字段从左往右去比较大小进行排序的,所以如果想要利用 B+树 进行快速查找也得符合这个规则
92、Innodb 是如何实现事务的
Innodb 通过 Buffer Pool
,LogBuffer
,Redo Log
,Undo Log
来实现事务,以一个 update 语句为例:
- Innodb 在收到一个 update 语句后,会先根据条件找到数据所在的页,并将该页缓存在
Buffer Pool
中 - 执行 update 语句,修改
Buffer Pool
中的数据,也就是内存中的数据 - 针对 update 语句生成一个
RedoLog
对象,并存在LogBuffer
中 - 针对 update 语句生成
undolog
日志,用于事务回滚 - 如果事务提交,那么则把
RedoLog
对象进行持久化,后续还有其他机制将 Buffer Pool 中所修改的数据页持久化到磁盘中 - 如果事务回滚,则利用
undolog
日志进行回滚
93、B树和B+树的区别,为什么 MySql 使用B+树
B树的特点:
- 节点排序
- 一个节点可以存多个元素,多个元素也排序了
B+树的特点:
- 拥有B树的特点
- 叶子节点之间有指针
- 非叶子节点上的元素在叶子节点上都冗余了,也就是叶子节点中存储了所有的元素,并且排好顺序
MySql 索引使用的是 B+树,因为索引是用来加快查询的,而 B+树通过对数据进行排序所以是可以提高查询速度的,然后通过一个节点中可以存储多个元素,从而可以使得 B+树的高度不会太高,在 MySql
中一个 Innodb 页就是一个 B+树节点,一个 Innodb
页默认 16kb
,所以一般情况下一棵两层的 B+树 可以存 2000 万行左右的数据,然后通过利用 B+树 叶子节点存储了所有数据并进行排序,并且叶子节点之间有指针,可以很好的支持全表扫描,范围查找等 SQL 语句。
94、MySql 锁有哪些?如何理解?
锁机制:数据库为了保证数据的一致性,而使用各种共享的资源在被并发访问时变得有序所设计的一种规则。
分类:
- 按操作分类
- 共享锁:也叫读锁。针对同一份数据,多个事务读取操作可以同时加锁而不互相影响 ,但是不能修改数据记录。
- 排他锁:也叫写锁。当前的操作没有完成前,会阻断其他操作的读取和写入。
- 按粒度分类
- 行级锁:操作时,会锁定当前操作行。开销大,加锁慢。会出现死锁。锁定粒度小,发生锁冲突概率低,并发度高。偏向于
InnoDB
存储引擎! - 表级锁:操作时,会锁定整个表。开销小,加锁快。不会出现死锁。锁定力度大,发生锁冲突概率高,并发度最低。偏向于
MyISAM
存储引擎! - 页级锁:锁的粒度、发生冲突的概率和加锁的开销介于表锁和行锁之间,会出现死锁,并发性能一般。
- 行级锁:操作时,会锁定当前操作行。开销大,加锁慢。会出现死锁。锁定粒度小,发生锁冲突概率低,并发度高。偏向于
- 按使用方式分类
- 悲观锁:每次查询数据时都认为别人会修改,很悲观,所以查询时加锁。
- 乐观锁:每次查询数据时都认为别人不会修改,很乐观,但是更新时会判断一下在此期间别人有没有去更新这个数据。
94、MySql 慢查询该如何优化?
- 检查是否走了索引,如果没有则优化 SQL 利用索引
- 检查所利用的索引,是否是最优索引
- 检查所查字段是否都是必须的,是否查询了过多字段,查出了多余数据
- 检查表中数据是否过多,是否应该进行分库分表了
- 检查数据库实例所在机器的性能配置,是否太低,是否可以适当增加资源
95、消息队列有哪些作用?
- 解耦:使用消息队列来作为两个系统之间的通讯方式,两个系统不需要相互依赖
- 异步:系统 A 给消费队列发送完消息之后,就可以继续做其他事情了
- 流量削峰:如果使用消息队列的方式来调用某个系统,那么消息将在队列中排队,由消费者自己控制消费速度
96、死信队列是什么?延时队列是什么?
- 死信队列也是一个消息队列,它是用来存放那些没有成功消费的消息的,通常可以用来作为消息重试
- 延时队列就是用来存放需要在指定时间被处理的元素的队列,通常可以用来处理一些具有过期性操作的业务,比如十分钟内未支付则取消订单。
97、Kafka 为什么比 RocketMQ 的吞吐量要高
Kafka 的生成者采用的是异步发送消息机制,当发送一条消息时,消息并没有发送到 Broker 而是缓存起来,然后直接向业务返回成功,当缓存的消息达到一定数量时再批量发送给 Broker。这种做法减少了网络 IO,从而提高了消息发送的吞吐量,但是如果消息生产者宕机,会导致消息丢失,业务出错,所以理论上 Kafka 利用此机制提高了性能却降低了可靠性。
98、Kafka 的 Pull 和 Push 分别有什么优缺点
- pull 表示消费者主动拉取,可以批量拉取,也可以单条拉取,所以 pull 可以由消费者自己控制,根据自己的消息处理能力来进行控制,但是消费者不能及时知道是否有消息,可能会拉到消息为空
- push 表示 Broker 主动给消费者推送消息,所以肯定是有消息时才会推送,但是消费者不能按自己的能力来消费消息,推过来多少消息,消费者就得消费多少消息,所以可能会造成网络堵塞,消费者压力大等问题
99、RocketMQ 的底层实现原理
RocketMQ 由 NameServer
集群、Producer
集群、Consumer
集群、Broker
集群组成,消息生产和消费的大致原理如下:
Broker
在启动的时候向所有的NameServer
注册,并保持长连接,每30s
发送一次心跳Producer
在发送消息的时候从NameServer
获取Broker
服务器地址,根据负载均衡算法选择一台服务器来发送消息Conusmer
消费消息的时候同样从NameServer
获取Broker
地址,然后主动拉取消息来消费
100、消息队列如何保证消息可靠传输
- 为了保证消息不多,也就是消息不能重复,也就是生产者不能重复生产消息,或者消费者不能重复消费消息
- 首先要确保消息不多发,这个不常出现,也比较难控制,因为如果出现了多发,很大的原因是生产者自己的原因,如果要避免出现问题,就需要在消费端做控制
- 要避免不重复消费,最保险的机制是消费者实现幂等性,保证就算重复消费,也不会有问题,通过幂等性,也能解决生产者重复发送消息的问题
- 消息不能少,意思就是消息不能丢失,生产者发送的消息,消费者一定要能消费到,对于这个问题,就要考虑两个方面
- 生产者发送消息时,要确认 Broker 确实收到并持久化了这条消息,比如 RabbitMQ 的
comfirm
机制,Kafka 的ack
机制都可以保证生产者能正确的将消息发送给 Broker - Broker 要等待消费者真正确认消费到了消息时才删除掉消息,这里通常就是消费端 ack 机制,消费者接收到一条消息后,如果确认没问题了,就可以给 Broker 发送一个
ack
,Broker 接收到 ack 后才会删除
101、TCP 的三次握手和四次挥手
TCP 协议是 7 层网络协议中的传输层协议,负责数据的可靠传输,在建立 TCP 连接时,需要通过三次握手来建立,过程是:
- 客户端向服务端发送一个
SYN
- 服务端接收到
SYN
后,给客户端发送一个SYN_ACK
- 客户端接收到
SYN_ACK
后,再给服务端发送一个ACK
再断开 TCP 连接时,需要通过四次挥手来断开,过程是:
- 客户端向服务端发送
FIN
- 服务端接收
FIN
后,向客户端发送ACK
,表示我接收到了断开连接的请求,客户端你可以不发数据了,不过服务端这边可能还有数据正在处理 - 服务端处理完所有数据后,向客户端发送
FIN
,表示服务端限制可以断开连接 - 客户端收到服务端
FIN
,向服务端发送ACK
,表示客户端也会断开连接了