基础篇
HashCode() 和equals() 方法的关系?
如果两个对象通过 equals() 方法比较是相等的,那么它们的 hashCode() 方法必须返回相同的哈希值。这个关系确保了哈希表的正确性和效率,因为哈希表使用哈希码来快速定位对象存储的位置
重载和重写有什么区别?
重载是方法名相同但参数不同,发生在同一个类中;重写是子类重写父类的方法,发生在子类和父类之间
什么是面向对象?
面向对象是一种编程范式,它通过将属性和操作这些属性的方法封装在对象中来组织软件设计。面向对象的核心概念包括:
- 封装:隐藏对象的属性和实现细节,仅对外公开接口(方法)。这有助于保护数据并减少耦合
- 继承:允许创建基于现有类的新类(子类),子类继承父类的属性和方法,并可添加或覆盖它们。这支持代码复用和扩展
- 多态:允许以统一的接口处理不同类型的对象。实现多态通常通过方法重写和接口实现
十大排序算法知道吗?
-
冒泡排序:通过重复遍历并交换相邻逆序元素实现排序,简单但效率低,时间复杂度最好为O(n),最坏为O(n²)
-
选择排序:通过不断选择剩余未排序部分的最小(或最大)元素,并放到已排序部分的末尾,稳定但效率低,时间复杂度为O(n²)
-
插入排序:将数组分为已排序和未排序两部分,通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入,适合小规模数据,时间复杂度最好为O(n),最坏为O(n²),但在部分有序时效率高
-
希尔排序:是插入排序的改进,通过引入间隔序列允许远距离元素比较和交换,减少初始逆序度,时间复杂度为O(n log² n)
-
归并排序:采用分治法,将数组分成两半分别排序,然后将结果合并,稳定且效率高,时间复杂度为O(n log n)
-
快速排序:通过选取基准元素,将数组分为小于和大于基准的两部分,递归排序这两部分,平均情况下效率高,时间复杂度为O(n log n),但最坏情况下退化到O(n²)
/** * 快速排序方法,用于对指定数组的指定部分进行排序。 * 通过选择一个基准元素(pivot),将数组分为两部分,其中一部分的元素都小于等于pivot,另一部分的元素都大于pivot。 * 然后对这两部分分别进行快速排序,最终实现整个数组的排序。 * * @param arr 排序的数组 * @param start 排序部分的起始索引 * @param end 排序部分的结束索引 */ public static void quickSort(int arr[], int start, int end) { // 选择数组的第一个元素作为基准元素 int pivot = arr[start]; // 初始化两个指针,i从起始位置向右移动,j从结束位置向左移动 int i = start; int j = end; while (i < j) { // i向右移动直到找到第一个大于pivot的元素 while ((i < j) && (arr[i] < pivot)) { i++; } // j向左移动直到找到第一个不大于pivot的元素 while ((i < j) && (arr[j] > pivot)) { j--; } // 如果i指向的元素与j指向的元素相等且i<j,则i右移 if ((i < j) && (arr[i] == arr[j])) { i++; } else { // 交换i和j指向的元素,将小于等于pivot的元素放到数组左边,大于pivot的元素放到数组右边 int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } } // 如果i-1大于start,则对数组的左半部分进行递归排序 if (i - 1 > start) { quickSort(arr, start, i - 1); } // 如果j+1小于end,则对数组的右半部分进行递归排序 if (j + 1 < end) { quickSort(arr, j + 1, end); } }
-
堆排序:利用堆结构进行排序,通过构建最大堆(或最小堆)并反复移除堆顶元素,再调整剩余元素为新的堆,实现排序,不稳定但效率高,时间复杂度为O(n log n)
-
计数排序:非比较型排序,通过统计每个元素的出现次数,根据元素值确定其在排序后数组中的位置,适用于一定范围内的整数排序,时间复杂度为O(n+k),其中k是整数的范围
-
桶排序:将数组元素分布到有限数量的桶里,每个桶再分别排序(可能递归使用桶排序或其他排序算法),然后将各个桶中的数据有序地合并起来,时间复杂度取决于数据的分布和桶的数量,平均情况下为O(n+k),最坏情况下为O(n²)
-
基数排序:非比较型整数排序,通过按位数切割整数并分别比较,利用多关键字排序的思想实现排序,适用于整数且位数不多的情况,时间复杂度为O(nk),其中n是排序元素个数,k是数字位数
Object有哪些方法?
- toString():返回对象的字符串表示
- equals(Object obj):比较两个对象是否相等。默认情况下,比较的是对象的内存地址
- hashCode():返回对象的哈希码值。用于哈希表中确定对象的存储位置
- getClass():返回对象的运行时类
- wait()、wait(long timeout)、wait(long timeout, int nanos):使当前线程等待,直到另一个线程调用此对象的notify()或notifyAll()方法
- notify():唤醒在此对象监视器上等待的单个线程
- notifyAll():唤醒在此对象监视器上等待的所有线程
- clone()(受保护):创建并返回此对象的一个副本。子类需要实现Cloneable接口并重写此方法
什么是反射?
Java中的反射(Reflection)是一种在运行时检查或修改类的行为和属性的能力。具体来说,它允许程序在运行时:
- 获取任意对象的类信息(包括类名、父类、接口、字段、方法等)
- 动态地创建对象的实例
- 调用对象的任意方法,即使这些方法在编译时是不可知的
- 访问和修改对象的私有字段
使用反射时需要注意性能、安全性和可读性等问题,因为它会绕过Java的编译时类型检查机制,并可能导致意外的错误或安全问题。因此,在性能要求较高的场景中应谨慎使用反射,并确保代码的安全性
JDK与CGLib动态代理的区别?
- 实现方式:
- JDK动态代理:基于Java反射机制实现,要求被代理的类必须实现一个或多个接口
- CGLib动态代理:基于ASM字节码操作框架实现,通过继承被代理类来创建代理对象,因此被代理类无需实现接口
- 性能:
- JDK动态代理:由于使用反射机制,性能上相对较慢,尤其是在频繁调用代理方法时
- CGLib动态代理:通过直接操作字节码生成新的类,避免了反射的开销,通常认为性能更好。但需要注意的是,CGLib在生成代理类时花费的时间较多,且可能破坏被代理类的封装性
常见集合篇
Java中的集合有序的集合有哪些?
- List接口的实现类:
- ArrayList
- LinkedList
- Vector
- Set接口的有序实现类:
- LinkedHashSet
- TreeSet
- Map接口的有序实现类:
- LinkedHashMap
- TreeMap
List相关面试题
为什么数组索引从0开始呢?假如从1开始不行吗?
- 在根据数组索引获取元素的时候,会用索引寻址公式来计算内存所对应的元素数据,寻址公式是:数组的首地址 + 索引乘以存储数据的类型大小
- 如果数组的索引从1开始,寻址公式中,就需要增加一次减法操作,对于CPU来说就多了一次指令,性能不高
ArrayList底层的实现原理是什么?
- ArrayList底层是用动态的数组实现的
- ArrayList初试容量为0,当第一次添加数据的时候才会初始化容量为10
- ArrayList在进行扩容的时候是原来的1.5倍,每次扩容都需要拷贝数组
- ArrayList在添加数据的时候
- 确保数组已使用长度(size)+ 1 之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度 + 1 后大于当前的数组长度,则调用 grow 方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于 size 的位置上
- 返回添加成功/失败布尔值
ArrayList list = new ArrayList(10)中的list扩容几次?
该语句只是声明和实例了一个ArrayList,指定了容量为10,未扩容
如何实现数组和List之间的转换?
- 数组转List,使用 JDK 中
java.util.Arrays
工具类的asLIst方法 - List转数组,使用List的toArray方法。无参toArray方法返回Object数组,传入初始化的数组对象,如果数组对象大小足够返回该对象数组,否则返回新数组
Arrays.asList转List后,如果修改了数组内容,list受影响吗?
Arrays.asList转List后,如果修改了数组内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
List用toArray转数组后,如果修改了List内容,数组受影响吗?
List用了toArray转数组后,如果修改了list内容,数组不会受影响,当调用了toArray以后,在底层是它进行了数组的拷贝,跟原来的元素就没有引用关系了,所以即使list修改了以后,数组也不受影响
ArrayList 和 LinkedList的区别是什么?
-
底层数据结构
-
ArrayList 是动态数组的数据结构实现
-
LinkedList 是双向链表的数据结构实现
-
-
操作数据效率
- ArrayList按照下标查询的时间复杂度O(1)(内存是连续的,根据寻址公式), LinkedList不支持下标查询
- 查找(未知索引): ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)
- 新增和删除
- ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
- LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
-
内存空间占用
-
ArrayList底层是数组,内存连续,节省内存
-
LinkedList 是双向链表需要存储数据,和两个指针,更占用内存
-
-
线程安全
- ArrayList和LinkedList都不是线程安全的
- 如果需要保证线程安全,有两种方案:
- 在方法内使用,局部变量则是线程安全的
- 使用线程安全的ArrayList和LinkedList
HashMap相关面试题
说一下HashMap的实现原理?
HashMap的数据结构: 底层使用hash表数据结构,即数组和链表或红黑树
- 往 HashMap 中 put 元素时,利用 key 的 hashCode 重新 hash 计算出当前对象的元素在数组中的下标
- 存储时,如果出现 hash 值相同的 key,此时有两种情况
- 如果 key 相同,则覆盖原始值
- 如果 key 不同(出现冲突),则将当前的 key-value 放入链表或红黑树中
- 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值
HashMap的jdk1.7和jdk1.8有什么区别?
- JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可
- JDK1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表
HashMap的put方法的具体流程
- 判断键值对数组是否为空,若为空则执行resize()进行扩容(初始化)
- 根据键值key计算hash值得到数组索引
- 判断 table[i] == null,条件成立,直接新建节点添加
- 如果 table[i] == null,条件不成立:
- 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
- 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
- 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话,判断数组长度是否大于64,如果数组长度也大于64的话把链表转换为红黑树,在红黑树中执行插入操作,遍历过程中若发现key已经存在直接覆盖value
- 插入成功后,判断实际存在的键值对数量size是否超过了最大容量 threshold (数组长度*0.75),如果超过了,进行扩容
讲一讲HashMap的扩容机制
-
在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
-
每次扩容的时候,都是扩容之前容量的2倍;
-
扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
- 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
HashMap的寻址算法
- 计算对象的 hashCode()
- 再调用 hash()(扰动函数) 方法进行二次哈希,hashCode值右移16为再异或运算,让哈希分布更为均匀
- 最后(capacity - 1) & hash 得到索引
为何HashMap的数组长度一定是2的次幂?
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
HashMap在1.7情况下的多线程死循环问题
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,
所以B->A->B,形成循环。
当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头而是保持与扩容前一样的顺序(尾插法),就避免了jdk7中死循环的问题
HashSet与HashMap的区别
-
HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对
-
HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法, 依靠HashMap来存储元素值,(利用hashMap的key键进行存储),而value值默认为Object对象。所以HashSet也不允许出现重复值,判断标准和HashMap判断标准相同,如果两个元素的hashCode相等进一步通过equals()方法判断两个元素是否相等,如果两个元素的hashCode()返回值不相等,则它们一定不相等,此时不会调用equals()方法进行判断
HashTable与HashMap的区别
主要区别:
区别 | HashTable | HashMap |
---|---|---|
数据结构 | 数组+链表 | 数组+链表+红黑树 |
是否可以为null | Key和value都不能为null | 可以为null |
hash算法 | key的hashCode() | 二次hash |
扩容方式 | 当前容量翻倍 +1 | 当前容量翻倍 |
线程安全 | 同步(synchronized)的,线程安全 | 非线程安全 |
在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类
并发编程篇
线程的基础知识
线程和进程的区别?
Java 中,线程作为调度的最小单位,进程作为资源分配的最小单位
二者对比
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
并行和并发有什么区别?
现在都是多核CPU,在多核CPU下
- 并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
- 并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程
创建线程的方式有哪些?
共有四种方式可以创建线程,分别是:
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
- 线程池创建线程
runnable 和 callable 有什么区别?
- Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程,如果不调用不会阻塞。
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
线程的 run()和 start()有什么区别?
-
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次
-
run(): 封装了要被线程执行的代码,可以被调用多次
线程包括哪些状态,状态之间是如何变化的?
新建(NEW)、可执行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、时间等待(TIMED_WALTING)、终止(TERMINATED)
- 创建线程对象是
新建状态
- 调用了 start() 方法转变为
可执行状态
- 线程获取到了CPU的执行权,执行结束是
终止状态
- 在可执行的过程中,如果没有获取CPU的执行权,可能会切换其他状态
- 如果没有获取锁(synchronized 或 lock)进入
阻塞状态
,获得锁再切换为可执行状态 - 如果线程调用了 wait() 方法进入
等待状态
,其它线程调用 notify() 唤醒后可切换为可执行状态 - 如果线程调用了 sleep(50) 方法,进入
计时等待状态
,到时间后可切换为可执行状态
- 如果没有获取锁(synchronized 或 lock)进入
新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
在多线程中有多种方法让线程按特定顺序执行,可以用线程类的 join
()方法,当一个线程调用了另一个线程的 join() 方法时,当前线程会暂停执行,直到被 join() 方法调用的那个线程执行完毕
notify()和 notifyAll()有什么区别?
-
notifyAll:唤醒所有wait的线程
-
notify:只随机唤醒一个 wait 线程
在 java 中 wait 和 sleep 方法的不同?
共同点
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
-
方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
-
醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
-
锁特性不同(重点)
- wait 方法的调用必须先获取该对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
如何停止一个正在运行的线程?
有三种方式可以停止线程
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
- 使用stop方法强行终止(不推荐,方法已作废)
- 使用interrupt方法中断线程
- 打断阻塞(sleep,wait,join)的线程,线程会抛出InterruptedExcepting异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
线程中并发锁
synchronized关键字的底层原理
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
描述 | |
---|---|
重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性 |
偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令 |
什么是JMM(Java内存模型)?
- JMM(Java Memory Model)Java内存模型,定义了
共享内存
中多线程程序读写操作
的行为规范,通过这些规则来规范对内存的读写操作从而保证了指令的正确性 - JMM 把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
- 线程跟线程之间相互隔离,线程跟线程交互需要通过主内存
CAS 了解吗?
- CAS 的全称是:Compare And Swap(比较并交换),它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性
- CAS 使用到的地方很多:AQS框架、AtomicXXX原子类
- 在操作共享变量的时候使用的自旋锁,效率上更高一些
- CAS 的底层是调用的 Unsafe 类中的方法,都是操作系统提供的,其它语言实现
乐观锁和悲观锁的区别?
- CAS 是基于乐观锁的思想:最乐观的设计,不怕别的线程来修改共享变量,就算改了也没关系,再重试下
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,当前线程上了锁别的线程都别想改,等当前线程改完了释放锁,其它线程才有机会
volatile 有什么用?
- 保证线程间的可见性: 用 volatile 修饰共享变量,能够防止编译器的优化发生,让一个线程对共享变量的修改对另一个线程可见
- 禁止进行指令重排序: 用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其它读写操作越过屏障,从而达到阻止重排序的效果
什么是AQS?
- AQS(抽象同步队列):是多线程中的队列同步器。是一种锁机制,它是做为一个
基础框架
使用的,像 ReentrantLock、Semaphore都是基于AQS实现的 - AQS内部维护了一个先进先出的双向队列,队列中存储排队的线程
- 在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中有一个线程修改成功了state为1,则当前线程就相当于获取了资源
- 在对state修改的时候使用了CAS操作,保证了多个线程修改情况下的原子性
ReentrantLock的实现原理
- ReentrantLock 表示支持重入锁,调用lock方法获取了锁之后,再次调用 lock,是不会再阻塞
- ReentrantLock 主要利用
CAS + AQS
队列来实现 - 支持公平锁和非公平锁,在提供的构造器的无参默认是非公平锁,也可以传参设置为公平锁
synchronized和Lock有什么区别?
- 语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
- 功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
- Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
- 性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
死锁产生的条件是什么?
- 互斥条件(Mutual Exclusion):资源不能被多个进程同时访问
- 保持和等待条件(Hold and Wait):一个进程至少持有一个资源,并等待获取一个当前被其他进程持有的资源
- 不可剥夺条件(No Preemption):资源只能由持有它的进程释放,不能被其他进程剥夺。
- 循环等待条件(Circular Wait):存在一个等待资源的循环,即进程集合中的一组进程 {P1, P2, …, Pn},P1 等待 P2 释放的资源,P2 等待 P3 释放的资源,…,Pn 等待 P1 释放的资源
如何进行死锁诊断?
- 当程序出现了死锁现象,可以使用JDK自带的工具:jps 和 jstack
- jps:输出JVM中运行的进程状态信息
- jstack:查看java进程内线程的堆栈信息,查看日志,检查是否有死锁,如若有死锁现象,需要查看具体代码分析后,可修复
- 可视化工具jconsole、VisualVM也可以检查死锁问题
说一下ConcurrentHashMap
- 底层数据结构:
- JDK1.7底层采用 分段的数组 + 链表 实现
- JDK1.8采用 数组 + 链表/红黑树 实现
- 加锁的方式:
- JDK1.7采用Segment分段锁,底层使用的是ReentrantLock
- JDK1.8采用CAS添加新节点,采用synchronized锁定链表/红黑树的首节点,相对Segment分段锁粒度更细,性能更好
Java程序中怎么保证多线程的执行安全?
- 原子性:一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行
- 内存可见性:让一个线程对共享变量的修改对另一个线程可见
- 有序性:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致
线程池
线程池有哪些参数?
- corePoolSize:核心线程数
- maximumPoolSize:最大线程数,最大线程数目 = (核心线程 + 救急线程的最大数目)
- keepAliveTime:生存时间,救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit:时间单位,救急线程的生存时间单位,如秒、毫秒等
- workQueue:阻塞队列,当没有空闲核心线程时,新来任务会加入到此队列排队,队列满了会创建救急线程执行任务
- threadFactory:线程工厂,可以指定线程对象的创建,例如设置线程名字、是否是守护线程等
- handler:拒绝策略,当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
- AbortPolicy:直接抛出异常,默认策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务
- DiscardPolicy:直接丢弃任务
线程池中有哪些常见的阻塞队列?
workQueue:当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO
LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO
- DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
ArrayBlockingQueue的LinkedBlockingQueue区别
LinkedBlockingQueue | ArrayBlockingQueue |
---|---|
默认无界,支持有界 | 强制有界 |
底层是链表 | 底层是数组 |
是懒惰的,创建节点的时候添加数据 | 提前初始化 Node 数组 |
入队会生成新 Node | Node需要是提前创建好的 |
两把锁(头尾) | 一把锁 |
- LinkedBlockingQueue读和写各有一把锁,性能相对较好
- ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些
如何确定核心线程数?
- IO密集型任务:文件读写、DB读写、网络请求等,
核心线程数大小设置为 2N+1
- CPU密集型任务:计算型代码、BitMap转换、Gson转换等,
核心线程数大小设置为 N+1
线程池的种类有哪些?
- FixedThreadPool(固定大小线程池):固定数量的线程,处理完一个任务后不会立即销毁,而是会处理下一个任务,适用于负载较重的服务器,限制并发线程数量
- CachedThreadPool(可缓存线程池):线程数量几乎无限制,根据任务数量动态调整线程数量,适用于执行大量短期异步任务的场景
- SingleThreadExecutor(单线程线程池):仅有一个线程执行所有任务,确保任务按顺序执行,适用于需要顺序执行任务的场景
- ScheduledThreadPool(定时线程池):支持定时及周期性执行任务,适用于需要按照一定频率执行任务的场景
- SingleThreadScheduledExecutor(单线程定时线程池):类似于ScheduledThreadPool但仅有一个线程,适用于需要顺序执行定时任务的场景
- WorkStealingPool(抢占式线程池,JDK 1.8+):基于Fork/Join框架,线程之间可以“窃取”任务以减少线程空闲时间,适用于大量并行计算的场景
为什么不建议用Executors创建线程池?
- 资源限制不足:Executors创建的线程池有时默认使用无界队列,如果任务产生速度大于处理速度,会导致队列无限增长,消耗大量内存,甚至引发内存溢出
- 灵活性差:Executors提供的工厂方法创建的线程池配置较为固定,难以根据具体需求进行灵活调整,如线程池大小、队列类型、拒绝策略等
- 异常处理简单:默认异常处理策略可能只是将异常打印到控制台或日志中,不利于问题的及时发现和解决
CountDownLatch(倒计时锁)
CountDownLatch(倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
- 其中构造参数用来初始化等待计数值
- await() 用来等待计数归零
- countDown() 用来让计数减一
如何控制某个方法允许并发访问线程的数量?
在JUC并发包下提供了一个工具类Semaphore(信号量)。在并发的情况下,可以控制方法的访问量
- 创建Semaphore对象,可以给一个容量
- acquire() 可以请求一个信号量,这时候的信号量个数 -1
- release()释放一个信号量,此时信号量个数 +1
谈谈你对ThreadLocal的理解
- ThreadLocal 可以实现 资源对象 的线程隔离,让每个线程各用各的 资源对象,避免争引发的线程安全问题
- ThreadLocal 同时实现了线程内的资源共享
- 每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
- 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
- 调用 get 方法,就是以 ThreadLocal 自己作为 key,当前线程中查找关联的资源值
- 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
ThreadLocal内存泄漏问题
- ThreadLocalMap 中的 key 是弱引用,值为强引用
- key 会被GC释放内存,关联 value 的内存并不会释放
- 建议主动 remove 释放 key,value
JVM虚拟机篇
JVM由那些部分组成?
- 类加载器
- Java堆
- 方法区
- 虚拟机栈
- 本地方法栈
- 程序计数器
- 执行引擎
什么是程序计数器?
线程私有的,每个线程一份,内部保存字节码的行号。用于记录正在执行的字节码指令地址
什么是Java堆?
线程共享的区域:
主要用来保存对象实例,数组
等,内存不够则抛出OutOfMemoryError
异常- 组成:
新生代+ 老年代
新生代
被划分为三部分,伊甸区和两个大小严格相同的幸存者区老年代
主要保存生命周期长的对象,一般是一些老的对象
- JDK 1.7 和 1.8 的区别:
- 1.7 中有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
- 1.8 移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出
什么是虚拟机栈?
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前执行的那个方法
垃圾回收是否涉及栈内存?
垃圾回收主要是回收堆内存,当栈帧弹栈以后,内存就会释放
栈内存分配越大越好吗?
未必,默认的栈内存为 1024K,栈内存过大会导致线程数变少
方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
什么情况下会导致栈内存溢出?
- 栈帧过多导致栈内存溢出,典型问题:递归调用
- 栈帧过大导致栈内存溢出
堆栈的区别是什么?
- 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的。堆会被GC垃圾回收,而栈不会
- 栈内存是线程私有的,而堆内存是线程共有的
- 两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常
- 栈空间不足:java.lang.StackOverFlowError
- 堆空间不足:java.lang.OutOfMemoryError
什么是方法区?
- 方法区(Method Area)是各个线程
共享的内存区域
- 主要存储类的信息,运行时常量池
- 虚拟机启动的时候创建,关闭虚拟机时释放
- 如果方法区中的内存无法满足分配请求,则会抛出OutOfMemoryError:Metaspace
什么是运行时常量池?
- 常量池:可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 当类被加载,它的常量池信息就会
放入运行时常量池
,并把里面的符号地址变为真实地址
什么是直接内存?
- 并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存
- 常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理
什么是类加载器?
JVM只会运行二进制文件,类加载器的作用就是将 字节码文件加载到JVM中
,从而让Java程序能够启动起来
类加载器有哪些?
- 启动类加载器(BootStrap ClassLoader):加载 JAVA_HOME/jre/lib 目录下的库
- 扩展类加载器(ExtClassLoader):主要加载 JAVA_HOME/jre/lib/ext 目录下的类
- 应用类加载器(APPClassLoader):用于加载 classPath 下的类
- 自定义类加载器(CustomizeClassLoader):自定义类继承ClassLoader,实现自定义类加载规则
什么是双亲委派模型?
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类
JVM为什么采用双亲委派机制?
- 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性
- 为了安全,保证类库API不会被修改
类加载的过程?
- 加载:查找和导入class文件
- 验证:保证加载类的准确性
- 准备:为类变量分配内存并设置类变量初始值
- 解析:把类中的符号引用转换为直接引用
- 初始化:对类的静态变量,静态代码块执行初始化
- 使用:JVM 开始从入口方法开始执行用户的程序代码
- 卸载:当用户程序代码执行完毕后,JVM 便开始销毁创建的Class对象
对象什么时候可以被垃圾器回收?
如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收
定位垃圾的方式有两种:
- 引用计数法
- 可达性分析算法
JVM 垃圾回收算法有哪些?
标记清除算法:
垃圾回收分为2个阶段,分别是标记和清除,效率高,有磁盘碎片,内存不连续标记整理算法:
将存活对象都向内存一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低复制算法:
将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收,无碎片,内存使用率低
什么是JVM中的分代回收?
- 堆的区域划分
- 堆被分为了两份:新生代和老年代 [ 1 : 2 ]
- 对于新生代,内部又被分为了三个区域。伊甸区,幸存者区(分为 from 和 to)
- 对象分代回收策略
- 新创建的对象,都会先分配到伊甸区
- 当伊甸区内存不足,标记伊甸区与 from(现阶段没有)的存活对象
- 将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸区 和 from 内存得到释放
- 经过一段时间后伊甸区的内存又出现不足,标记伊甸区 to 区存活的对象,将其复制到 from 区
- 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会提前晋升)
MinorGC、MixedGC、FullGC的区别是什么?
- MinorGC(young GC):发生在新生代的垃圾回收,暂停时间短(STW)
- MixedGC:新生代 + 老年代 部分区域的垃圾回收,G1收集器持有
- FullGC: 新生代 + 老年代 完整垃圾回收,暂停时间长(STW),应尽力避免
full gc频繁,有哪些原因?
- 堆内存分配过小
- 新生代和老年代比例不当
- 对象创建过多
- 大对象分配
- 内存泄漏
说一下JVM有哪些垃圾回收器?
- 串行垃圾回收器:Serial GC、Serial Old GC
- 并行垃圾回收器:Parallel Old GC、ParNew GC
- CMS(并发)垃圾回收器:CMS GC,作用在老年代
- G1垃圾回收器:作用在新生代和老年代
什么是CMS垃圾回收器?
CMS垃圾回收器,主要用于追求低停顿的场景。它采用标记清除算法,在垃圾回收过程中,CMS会尽量与系统工作线程并发执行,以减少Stop the World(即程序完全停止运行)的时间。CMS垃圾回收器的运行过程包括初始标记、并发标记、重新标记和并发清除四个阶段,其中并发标记和并发清除阶段是与系统工作线程同时进行的,从而减少了停顿时间。然而,CMS也存在一些缺点,如并发回收垃圾可能导致CPU资源紧张、产生大量内存碎片以及无法处理浮动垃圾等
G1垃圾回收器知道吗?
- 应用于新生代和老年代
- 划分成多个区域,每个区域都可以充当 eden,survivor,humongous,其中 humongous 专为大对象准备
- 采用复制算法
- 响应时间与吞吐量兼顾
- 分成三个阶段:新生代回收(STW),并发标记(重新标记STW),混合回收
- 如果并发失败(即回收速度赶不上创建新对象的速度),会触发 Full GC
强引用、软引用、弱引用、虚引用的区别?
- 强引用:只有所有 GC Roots 对象都不通过强引用引用该对象,该对象才能被垃圾回收
- 软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收
- 弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
- 虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由引用处理程序(Reference Handler )线程调用虚引用相关方法释放内存
JVM 调优的参数可以在哪里设置参数值?
- war 包部署在 tomcat 中设置: 修改
TOMCAT_HOME/bin/catalina.sh
文件 - jar 包部署在启动参数设置: java
-Xms512m -Xmx1024m
-jar xxx.jar
JVM 调优的参数都有哪些?
- 设置堆空间大小
- 设置虚拟机栈的大小
- 设置垃圾回收器
- 新生代中伊甸区和两个幸存者区的大小比例
- 新生代晋升老年代的阈值
JVM 调优工具有哪些?
命令工具:
- jps:进程状态信息
- jstack:查看 java 进程内线程的堆栈信息
- jmap:查看堆信息
- jhat:堆转储快照分析工具
- jstat:JVM 统计监测工具
可视化工具:
- jconsole:用于对 jvm 的内存、线程、类 的监控
- VisualVM:能够监控线程,内存情况
Java 内存泄漏怎么排查?
内存泄漏通常是指堆内存的一些大对象不被回收的情况
- 通过 jmap 或设置 jvm 参数获取堆内存快照 dump
- 通过工具,VisualVM 去分析 dump 文件,VisualVM 可以加载离线的 dump 文件
- 通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
- 找到对应的代码,通过阅读上下文的情况,进行修复即可
CPU飙高怎么排查?
- 使用 top 命令查看 CPU 占用情况
- 通过 top 命令查看后,可以查看是哪一个进程 CPU 占用较高
- 使用 jps 命令查看进程中的线程信息
- 使用 jstack 命令查看进程中哪些线程出现了问题,最终定位问题
Redis篇
什么是缓存穿透 ? 怎么解决 ?
缓存穿透是指查询一个不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。
解决方案有两种:
- 缓存空数据
- 布隆过滤器:我们通常都会用布隆过滤器来解决它。
你能介绍一下布隆过滤器吗?
布隆过滤器主要是用于检索一个元素是否在一个集合中。
它的底层主要是先去初始化一个比较大数组,里面存放的二进制0或1。在一开始都是0,当一个key来了之后经过3次hash计算,模于数组长度找到数据的下标然后把数组中原来的0改为1,这样的话,三个数组的位置就能标明一个key的存在。查找的过程也是一样的。
当然,布隆过滤器是有缺点的,其中之一就是可能会产生一定的误判率。我们一般可以设置这个误判率,通常不会超过5%。实际上,这个误判率是难以避免的,除非我们增加布隆过滤器使用的位数组的长度。但即便如此,5%以内的误判率对于一般的项目来说已经是可以接受的,不至于在高并发下导致数据库被压垮。
什么是缓存击穿 ? 怎么解决 ?
缓存击穿的意思是对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把 DB 压垮。
解决方案有两种方式:
解决方案一: 互斥锁,强一致,性能差
解决方案二: 逻辑过期,高可用,性能优,不能保证数据绝对一致
- 第一可以使用互斥锁:当缓存失效时,不立即去load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时再进行 load db的操作并回设缓存,否则重试get缓存的方法
- 第二种方案可以设置当前key逻辑过期,大概思路如下:
- 在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间
- 当查询的时候,从redis取出数据后判断时间是否过期
- 如果过期则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据不是最新的
当然两种方案各有利弊:
- 如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题
- 如果选择key的逻辑删除,则优先考虑的是高可用性,性能比较高,但是数据同步这块做不到强一致。
什么是缓存雪崩 ? 怎么解决 ?
缓存雪崩意思是设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB 瞬时压力过重。雪崩与缓存击穿的区别:雪崩是很多key,击穿是某一个key
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性:
哨兵模式、集群模式
- 给缓存业务添加降级限流策略:
Nginx、Spring Cloud Gateway
- 给业务添加多级缓存:
Guava或Caffeine
解决方案主要是可以将缓存失效时间分散开,比如可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
缓存三兄弟总结
缓存无中生有Key,布隆过滤null隔离。
缓存击穿过期Key,锁与非期解难题。
雪崩大量过期Key,过期时间要随机。
面试必考三兄弟,可用限流来保底。
redis做为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)
双写一致性: 当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致。
允许延时一致的业务(采用异步通知)
- 使用
MQ
中间件,更新数据之后,通知缓存删除 - 利用
Canal
中间件,不需要修改业务代码,伪装为MySQL的一个从节点,Canal
通过读取binlong
数据更新缓存
强一致性(采用Redisson提供的读写锁)
- 共享锁:读锁
readLock
,加锁之后其它线程可以共享读操作 - 排他锁:也叫独占锁
writeLock
,加锁之后,阻塞其它线程读写操作
延时双删
- 读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存
- 写操作:
延迟双删
(删除缓存—》修改数据库—》延时
删除缓存)
延迟双删,如果是写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据,其中这个延时多久不太好确定,在延时的过程中可能会出现脏数据,并不能保证强一致性
Redis持久化
RDB
RDB全称 Redis DataBase Backup File(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据
-
主动备份
save #由Redis主进程来执行RDB,会阻塞所有命令 bgsave #开启子进程执行RDB,避免主进程收到影响
-
Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:
# 900秒内,如果至少有一个key被修改,则执行bgsave save 900 1 # 300秒内,有10个key被修改,也会执行bgsave save 300 10 # 60秒内,有一万个key被修改,也会执行bgsave save 60 10000
RDB的执行原理: bgsave开始时会fork主进程得到子进程 共享
主进程的内存数据。完成fork后读取内存数据并写入RDB文件。
fork采用的是copy-on-write技术:
- 当主进程执行读操作时,访问共享内存
- 当主进程执行写操作时,则会拷贝一份数据,执行写操作
AOF
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。
-
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:
# 是否开启AOF功能,默认是no appendonly yes # AOf文件的名称 appendfilename "appendonly.aof"
-
AOF的命令记录的频率也可以通过redis.conf文件来配:
# 表示每执行一次写命令,立即记录到AOF文件 appendfsync always # 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区写到AOF文件,是默认方案 appendfsync everysec # 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写会磁盘 appendfsync no
配置项 刷盘时机 优点 缺点 always 同步刷盘 可靠性高,几乎不丢数据 性能差 everysec 每秒刷盘 性能适中 最多丢失1秒数据 no 操作系统控制 性能最好 可靠性差,可能丢失大量数据
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行 bgrewriteaof
命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上
auto-aof-rewrite-min-size 64mb
RDB与AOF对比
RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会 结合
两者来使用
RDB | AOF | |
---|---|---|
持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
数据完整性 | 不完整,两次备份之前会丢失 | 相对完整,取决于刷盘策略 |
文件大小 | 会有压缩,文件体积小 | 记录命令,文件体积很大 |
宕机恢复速度 | 快 | 慢 |
数据恢复优先级 | 低,因为完整性不如AOF | 高,因为数据完整更高 |
系统资源占用 | 高,大量CPU和内存消耗 | 低,主要是磁盘IO资源但AOF重写时会占用大量CPU和内存资源 |
使用场景 | 可以容忍数分钟的数据丢失,追求更快的启动速度 | 对数据安全性要求较高 |
redis做为缓存,数据的持久化是怎么做的?
在Redis中提供了两种数据持久化的方式:1、RDB 2、AOF
这两种持久化方式有什么区别呢?
RDB是一个快照文件,它是把redis内存存储的数据写到磁盘上,当redis实例宕机恢复数据的时候,从RDB的快照文件中恢复数据。
AOF的含义是追加文件,当redis操作写命令的时候,都会存储在这个文件中,当redis实例宕机恢复数据的时候,会从这个文件中再次执行一遍命令来恢复数据
这两种方式,哪种恢复的比较快呢?
RDB因为是二进制文件,在保存的时候体积也是比较小的,它恢复的比较快,但是它有可能会丢数据,我们通常在项目中也会使用AOF来恢复数据,虽然AOF恢复的速度慢一些,但是它丢数据的风险要小很多,在AOF文件中可以设置刷盘策略
数据过期策略
Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就叫做数据过期策略。
惰性删除: 设置该key过期时间后,我们不去管它,当需要该key时,我们再检查其是否过期,若过期我们就删掉它,反之返回该key。
-
优点: 对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查
-
缺点: 对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放
set name zhangsan 10 get name # 发现name过期了,直接删除key
定期删除: 每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中过期的key)
- 优点: 可以通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响。另外定期删除,也能有效释放过期占用的内存。
- 缺点: 难以确定删除操作执行的时长和频率
定时删除两种模式:
- SLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,可以通过修改配置文件 redis.conf 的
hz
选项来调整这个次数 - FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
Redis的过期删除策略: 惰性删除 + 定期删除
两种策略进行配合使用。
数据淘汰策略
数据淘汰策略: 当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则叫做内存淘汰策略。
Redis支持8种不同策略来选择要删除的key:
- noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,
默认是该策略
- volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key,随机进行淘汰
- volatile-random:对设置了TTL的key,随机进行淘汰
- allkeys-lru: 对全体key,基于LRU算法(最近最少使用)进行淘汰
- volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu: 对全体key,基于LFU算法(最少频率使用)进行淘汰
- volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰
使用建议
- 优先使用 allkeys-lru 策略。充分利用 LRU 算法的优势,把最近常访问的数据保留在缓存中。 如果业务有明显的冷热数据区分,建议使用。
- 如果业务中数据访问频率差别不大,没有明显的冷热数据区分,建议使用 allkeys-random,随机选择淘汰
- 如果业务中有置顶的需求,可以使用 volatile-lru 策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其它设置过期时间的数据
- 如果业务中有短时高频访问的数据,可以使用 allkeys-lfu 或 volatile-lfu 策略
数据库有1000万数据 ,Redis只能缓存20w数据, 如何保证Redis中的数据都是热点数据 ?
可以使用 allkeys-lru (挑选最近最少使用的数据淘汰)淘汰策略,那留下来的都是经常访问的热点数据
Redis的内存用完了会发生什么?
这个要看redis的数据淘汰策略是什么,如果是默认的配置,redis内存用完以后则直接报错
分布式锁
分布式锁使用的场景:集群情况下的定时任务、抢单、幂等性场景
Redis实现分布式锁主要利用Redis的 setnx
命令。setnx是 SET is not exists
(如果不存在,则SET)的简写
-
获取锁:
# 添加锁,NX是互斥,EX是设置超时时间 SET lock value NX EX 10
-
释放锁:
# 释放锁,删除即可 DEL key
为什么要使用分布式锁?
在分布式系统里,因为多个服务或应用实例可能同时运行在不同的服务器上,它们都有可能去操作同一个共享资源,比如数据库中的某条记录或某个缓存项。如果不对这些操作加以控制,就可能会遇到数据冲突、数据不一致或者重复处理的问题。这就像是一群人在没有协调的情况下同时去修改同一份文件,结果可想而知,文件内容会变得乱七八糟。
为了解决这个问题,我们就需要用到分布式锁。分布式锁就像是一个看门人,它确保在同一时间内,只有一个服务或应用实例能够进入并操作这个共享资源。这样,我们就可以避免数据冲突和不一致,确保操作的正确性和一致性。
Redis分布式锁如何实现?
在redis中提供了一个命令setnx(SET if not exists)
由于redis单线程的,用了命令之后,只能有一个客户端对某一个key设置值,在没有过期或删除key的时候是其他客户端是不能设置这个key的
如何控制Redis实现分布式锁有效时长呢?
redis的setnx指令不好控制这个问题,我们可以采用redis的一个框架redisson实现
在redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间,当锁住的一个业务还没有执行完成的时候,在redisson中引入了一个看门狗机制,就是每隔一段时间就检查当前业务是否还持有锁,如果持有就增加加锁的持有时间,当业务执行完成之后释放锁就可以了
还有一个好处就是,在高并发下,一个业务有可能会执行很快,在客户1持有锁的时候,客户2来了以后并不会马上拒绝,它会自旋不断尝试获取锁,如果客户1释放之后,客户2就可以马上持有锁,性能也得到了提升。
redisson实现的分布式锁是可重入的吗?
是可以重入的。这样做是为了避免死锁的产生。这个重入其实在内部就是判断是否是当前线程持有的锁,如果是当前线程持有的锁就会计数,如果释放锁就会在计数上减一。在存储数据的时候采用的hash结构,大key可以按照自己的业务进行定制,其中小key是当前线程的唯一标识,value是当前线程重入的次数
redisson实现的分布式锁能解决主从一致性的问题吗?
这个是不能的,比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。
我们可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。
但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁
如果业务非要保证数据的强一致性,这个该怎么解决呢?
redis本身就是支持高可用的,想要做到强一致性,就非常影响性能,所以,如果有强一致性要求高的业务,建议使用zookeeper实现的分布式锁,它是可以保证强一致性的
Redis集群有哪些方案, 知道嘛?
在Redis中提供的集群方案总共有三种:主从复制、哨兵模式、Redis分片集群
- 主从和哨兵可以解决高可用、高并发读的问题
- 分片集群可以解决海量数据存储问题、高并发写的问题
那你能介绍一下主从同步吗?
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,可以搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中
能说一下,主从同步数据的流程?
主从同步分为了两个阶段,一个是全量同步,一个是增量同步
全量同步是指从节点第一次与主节点建立连接的时候使用全量同步,流程是这样的:
- 从节点请求主节点同步数据,其中从节点会携带自己的拷贝(replication) id和偏移量(offset)
- 主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致
- 在同时主节点会执行bgsave,生成rdb文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致
- 如果在rdb生成执行期间,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区,缓冲区是一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件,这个就是全量同步
增量同步指的是,当从节点服务重启之后,数据就不一致了,所以这个时候,从节点会请求主节点同步数据,主节点还是判断是不是第一次请求,不是第一次就获取从节点的offset值,然后主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步
怎么保证Redis的高并发高可用?
首先可以搭建主从集群,再加上使用redis中的哨兵模式,哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障恢复、通知;如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主;同时Sentinel也充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新消息推送给Redis的客户端,所以一般项目都会采用哨兵模式来保证redis的高并发高可用
redis集群脑裂,该怎么解决呢?
有的时候由于网络等原因可能会出现脑裂的情况,就是说,由于redis master节点和redis salve节点和sentinel处于不同的网络分区,使得sentinel没有能够心跳感知到master,所以通过选举的方式提升了一个salve为master,这样就存在了两个master,就像大脑分裂了一样,这样会导致客户端还在old master那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将old master降为salve,这时再从新master同步数据,这会导致old master中的大量数据丢失。
关于解决的话,在redis的配置中可以设置:第一可以设置最少的salve节点个数,比如设置至少要有一个从节点才能同步数据,第二个可以设置主从数据复制和同步的延迟时间,达不到要求就拒绝请求,就可以避免大量的数据丢失
redis的分片集群有什么作用?
分片集群主要解决的是,海量数据存储的问题,集群中有多个master,每个master保存不同数据,并且还可以给每个master设置多个slave节点,就可以继续增大集群的高并发能力。同时每个master之间通过ping监测彼此健康状态,就类似于哨兵模式了。当客户端请求可以访问集群任意节点,最终都会被转发到正确节点
Redis分片集群中数据是怎么存储和读取的?
Redis 集群引入了哈希槽的概念,有 16384 个哈希槽,集群中每个主节点绑定了一定范围的哈希槽范围, key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,通过槽找到对应的节点进行存储
取值的逻辑是一样的
Redis是单线程的,但是为什么还那么快?
- 完全基于内存的,C语言编写
- 采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题
- 使用多路I/O复用模型,非阻塞IO
例如:bgsave 和 bgrewriteaof 都是在后台执行操作,不影响主线程的正常使用,不会产生阻塞
能解释一下I/O多路复用模型?
I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源
Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器
在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程
MySQL篇
SQL语句的执行顺序是什么?
- FROM子句:确定数据来源
- JOIN子句(如果有):连接FROM子句中的表与其他表
- WHERE子句:对FROM和JOIN操作后的结果进行过滤
- GROUP BY子句(如果有):将WHERE子句过滤后的结果按指定列分组
- 聚合函数(如果有):对GROUP BY分组后的数据进行聚合计算
- HAVING子句(如果有):对GROUP BY分组且聚合计算后的结果进行条件过滤
- SELECT子句:选择需要展示的列,包括聚合函数的结果
- DISTINCT关键字(如果有):去除查询结果中的重复行
- ORDER BY子句(如果有):对SELECT子句选定的列进行排序
- LIMIT子句(如果有):限制查询结果返回的行数
客户端的一条 SQL 语句在 MySQL 内部是如何执行的?
- 连接器:处理连接请求,验证用户身份和权限,并创建会话
- 查询缓存(MySQL 8.0之前):检查并可能直接返回缓存中的查询结果
- 解析器和预处理器:解析SQL语句,检查语法并预处理查询
- 优化器:生成并选择最优的查询执行计划
- 执行器:执行查询计划,调用存储引擎获取数据并返回结果
- 存储引擎:负责数据的实际存储和检索
- 日志记录:记录数据修改操作到日志,保障数据持久性和可恢复性
MySQL中有哪些锁?
- 全局锁:对整个数据库实例加锁,限制所有查询和修改操作,常用于备份和恢复等场景
- 表级锁:作用于整个表,限制其他事务对表的访问
- 行级锁:作用于表中的单个行,支持高并发处理,但管理较复杂
- 间隙锁:锁定一个范围内的键,但不包括这些键的实际值,用于防止幻读
- 临键锁:是记录锁与间隙锁的组合,用于防止幻读并确保范围查询的一致性
MySQL中,如何定位慢查询?
在MySQL中提供了慢日志查询的功能,可以在MySQL的系统配置文件中开启这个慢日志的功能,并且也可以设置SQL执行超过多少时间来记录到一个日志文件中
SQL语句执行很慢, 如何分析呢?
如果一条sql执行很慢的话,我们通常会使用mysql自带的执行计划explain来去查看这条sql的执行情况,比如在这里面可以通过key和key_len检查是否命中了索引,如果本身已经添加了索引,也可以判断索引是否有失效的情况,第二个,可以通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描,第三个可以通过extra建议来判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复
什么是索引?
它是帮助MySQL高效获取数据的数据结构,主要是用来提高数据检索的效率,降低数据库的IO成本,同时通过索引列对数据进行排序,降低数据排序的成本,也能降低了CPU的消耗
索引的底层数据结构了解过嘛 ?
MySQL的默认的存储引擎InnoDB采用的B+树的数据结构来存储索引,选择B+树的主要的原因是:第一阶数更多,路径更短,第二磁盘读写代价比B树更低,非叶子节点只存储指针,叶子节点存储数据,第三B+树便于扫库和区间查询,叶子节点是一个双向链表
B树和B+树的区别是什么呢?
- 第一:在B树中,非叶子节点和叶子节点都会存放数据,而B+树的所有的数据都存储在叶子节点,在查询的时候,B+树查找效率更加稳定
- 第二:在进行范围查询的时候,B+树效率更高,因为B+树的数据都在叶子节点存储,并且叶子节点是一个双向链表
什么是聚簇索引什么是非聚簇索引 ?
-
聚簇索引主要是指数据与索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个,一般情况下主键作为聚簇索引
-
非聚簇索引指的是数据与索引分开存储,B+树的叶子节点保存对应的主键,可以有多个,一般我们自己定义的索引都是非聚簇索引
什么是回表查询?
回表的意思就是通过二级索引找到对应的主键值,然后再通过主键值找到聚集索引中所对应的整行数据,这个过程就是回表(如果面试官直接问回表,则需要先介绍聚簇索引和非聚簇索引)
什么叫覆盖索引?
覆盖索引是指select查询语句使用了索引,在返回的列,必须在索引中全部能够找到,如果我们使用id查询,它会直接走聚集索引查询,一次索引扫描,直接返回数据,性能高
如果按照二级索引查询数据的时候,返回的列中没有创建索引,有可能会触发回表查询,尽量避免使用select *,尽量在返回的列中都包含添加索引的字段
MYSQL超大分页怎么处理?
超大分页一般都是在数据量比较大时,我们使用了limit分页查询,并且需要对数据进行排序,这个时候效率就很低,我们可以采用覆盖索引和子查询来解决
先分页查询数据的id字段,确定了id之后,再用子查询来过滤,只查询这个id列表中的数据就可以了
因为查询id的时候,走的覆盖索引,所以效率可以提升很多
索引创建原则有哪些?
添加索引的字段是查询比较频繁的字段,一般也是作为查询条件,排序字段或分组的字段
通常创建索引的时候都是使用复合索引来创建,一条sql的返回值,尽量使用覆盖索引,如果字段的区分度不高的话,我们也会把它放在组合索引后面的字段
如果某一个字段的内容较长,我们会考虑使用前缀索引,当然并不是所有的字段都要添加索引,这个索引的数量也要控制,因为添加索引也会导致增删改的速度变慢
什么情况下索引会失效 ?
- 违反最左前缀法则
- 范围查询右边的列,右边的列索引失效
- 在索引列上进行运算操作,索引失效
- 字符串不加单引号,索引可能失效(类型转换)
- 以%开头的like模糊查询,索引失效
sql的优化的经验
sql优化的话,我们会从这几方面考虑,比如:建表的时候、使用索引、sql语句的编写、主从复制,读写分离,还有一个是如果数据量比较大的话,可以考虑分库分表
创建表的时候,如何优化的呢?
在定义字段的时候需要结合字段的内容来选择合适的类型,如果是数值的话,像tinyint、int 、bigint这些类型,要根据实际情况选择。如果是字符串类型,也是结合存储的内容来选择char和varchar或者text类型
平时对sql语句做了哪些优化呢?
SELECT语句务必指明字段名称,不要直接使用select * ,还有就是要注意SQL语句避免造成索引失效的写法;如果是联合查询,尽量用union all代替union ,union会多一次过滤,效率比较低;如果是表关联的话,尽量使用inner join ,不要使用 left join 和 right join,如必须使用 一定要以小表为驱动
事务的特性是什么?可以详细说一下吗?
事务ACID,分别指的是:
- 原子性: 事务是不可分割的最小操作单元,要么全部成功,要么全部失败
- 一致性:事务完成时,必须使所有的数据都保持一致状态
- 隔离性:数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境运行
- 持久性:事务一但提交或回滚,它对数据库中数据的改变就是永久的
我举个例子:
A向B转账500,转账成功,A扣除500元,B增加500元,原子操作体现在要么都成功,要么都失败
在转账的过程中,数据要一致,A扣除了500,B必须增加500
在转账的过程中,隔离性体现在A像B转账,不能受其他事务干扰
在转账的过程中,持久性体现在事务提交后,要把数据持久化(可以说是落盘操作)
并发事务带来哪些问题?
- 第一是脏读: 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的
- 第二是不可重复读:比如在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读
- 第三是幻读:幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读
怎么解决这些问题呢?MySQL的默认隔离级别是?
解决方案是对事务进行隔离
- 第一个是未提交读(read uncommitted):它解决不了刚才提出的所有问题
- 第二个是读已提交(read committed):它能解决脏读的问题的,但是解决不了不可重复读和幻读
- 第三个是可重复读(repeatable read):它能解决脏读和不可重复读,但是解决不了幻读,这个也是mysql默认的隔离级别
- 第四个是串行化(serializable):它可以解决刚才提出来的所有问题,但是由于让是事务串行执行的,性能比较低
undo log和redo log的区别
其中redo log日志记录的是数据页的物理变化,服务宕机可用来同步数据,而undo log 不同,它主要记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据,比如我们删除一条数据的时候,那么undo log中会记录一个与原始删除操作相反的插入操作,如果发生回滚就执行逆操作
redo log保证了事务的持久性,undo log保证了事务的原子性和一致性
事务中的隔离性是如何保证的呢?(你解释一下MVCC)
事务的隔离性是由锁和MVCC实现的
其中MVCC的意思是多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突,它的底层实现主要是分为了三个部分,第一个是隐藏字段,第二个是undo log日志,第三个是readView读视图
隐藏字段是指:在mysql中给每个表都设置了隐藏字段,有一个是trx_id(事务id),记录每一次操作的事务id,是自增的;另一个字段是roll_pointer(回滚指针),指向上一个版本的事务版本记录地址
undo log主要的作用是记录回滚日志,存储老版本数据,在内部会形成一个版本链,在多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表
readView解决的是一个事务查询选择版本的问题,在内部定义了一些匹配规则和当前的一些事务id判断该访问那个版本的数据
MySQL主从同步原理
MySQL主从复制的核心就是二进制日志(DDL(数据定义语言)语句和 DML(数据操纵语言)语句),它的步骤是这样的:
- 第一:主库在事务提交时,会把数据变更记录在二进制日志文件 Binlog 中
- 第二:从库读取主库的二进制日志文件 Binlog ,写入到从库的中继日志 Relay Log
- 第三:从库重做中继日志中的事件,将改变反映它自己的数据
框架篇
Spring框架中的单例bean是线程安全的吗?
不是线程安全的
当多用户同时请求一个服务时,容器会给每一个请求分配一个线程,这时多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单例状态的修改(体现为该单例的成员属性),则必须考虑线程同步问题。
Spring框架并没有对单例bean进行任何多线程的封装处理。关于单例bean的线程安全和并发问题需要开发者自行去搞定。
比如:我们通常在项目中使用的Spring bean都是不可变的状态(比如Service类和DAO类),所以在某种程度上说Spring的单例bean是线程安全的。
如果你的bean有多种状态的话(比如 View Model对象),就需要自行保证线程安全。最简单的解决办法就是将多态bean的作用域由“singleton”变更为“prototype”。
什么是IOC?
IOC(Inversion of Control,控制反转) 是一种设计思想,用于减少代码间的耦合。在传统的程序设计中,对象的创建和依赖关系的管理通常是由程序本身(即程序代码)来控制的。而在使用IOC思想的情况下,这种控制权被反转了,即这些工作不再由程序代码直接完成,而是由外部容器或框架来负责
具体来说,IOC容器会负责对象的创建、配置、组装和管理。当需要某个对象时,程序不再直接通过new关键字来创建,而是向容器请求获取
什么是AOP?
AOP是面向切面编程,在Spring中用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取公共模块复用,降低耦合,比如可以做为公共日志保存,事务处理等
Spring中的事务是如何实现的?
Spring中的事务实现主要依赖于AOP(面向切面编程)机制,对方法前后进行拦截,在执行方法之前开启事务,在执行完目标方法之后根据执行情况提交或者回滚事务。
Spring中事务失效的场景有哪些?
- 数据库不支持事务
- 如果方法上异常捕获处理,自己处理了异常,没有抛出,就会导致事务失效,所以一般处理了异常以后,别忘了抛出去
- 如果方法抛出检查异常,如果报错也会导致事务失效,可以在spring事务的注解上,就是@Transactional上配置rollbackFor属性为Exception,这样不管是什么异常,都会回滚事务
- 如果方法上不是public修饰的,也会导致事务失效
- 如果事务方法被final或static修饰,由于Spring的AOP代理机制无法对final或static方法进行代理,因此这些事务方法将不会生效
- 事务传播性配置错误
spring事务的传播性是什么?
Spring事务的传播性定义了当一个事务方法被另一个事务方法调用时,事务属性(如是否开启新事务、事务的提交与回滚等)如何从一个方法传递到另一个方法
- REQUIRED(必需):如果当前有事务则加入,否则新建事务
- SUPPORTS(支持):如果当前有事务则加入,无事务则非事务执行
- MANDATORY(强制):必须有事务,无事务则抛出异常
- REQUIRES_NEW(新建):总是新建事务,若当前有事务则挂起
- NOT_SUPPORTED(不支持):总是非事务执行,若当前有事务则挂起
- NEVER(禁止):不能有事务,若当前有事务则抛出异常
- NESTED(嵌套):若当前有事务则为当前事务创建嵌套事务,否则同REQUIRED
Spring的bean的生命周期?
- 实例化阶段:Spring容器根据配置创建Bean的实例
- 属性注入阶段:Spring将配置的属性值或依赖的Bean注入到新创建的Bean实例中
- Aware接口注入阶段:如果Bean实现了特定的Aware接口(如BeanNameAware),Spring会注入相关的上下文信息
- BeanPostProcessor的前置处理阶段:BeanPostProcessor允许在Bean初始化之前进行自定义逻辑处理
- 初始化阶段:Bean执行自定义的初始化逻辑,如通过@PostConstruct注解的方法或InitializingBean接口定义的方法
- BeanPostProcessor的后置处理阶段:BeanPostProcessor允许在Bean初始化之后进行额外的自定义逻辑处理
- 就绪阶段:Bean已完成初始化,可供应用程序使用
- 使用阶段:Bean在应用程序中执行其业务逻辑
- 销毁阶段:当Spring容器关闭时,Bean执行自定义的销毁逻辑,如通过@PreDestroy注解的方法或DisposableBean接口定义的方法
Spring中的循环引用?
循环依赖:循环依赖其实就是循环引用,也就是两个或两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于A
循环依赖在Spring中是允许存在,Spring框架依据三级缓存已经解决了大部分的循环依赖
- 一级缓存:单例池,缓存已经经历了完整的生命周期,已经初始化完成的Bean对象
- 二级缓存:缓存早期的Bean对象(生命周期还没走完)
- 三级缓存:缓存的是ObjectFactory,表示对象工厂,用来创建某个对象
构造方法出现了循环依赖怎么解决?
由于bean的生命周期中构造函数是第一个执行的,spring框架并不能解决构造函数的的依赖注入,可以使用@Lazy懒加载,什么时候需要对象再进行Bean对象的创建
SpringMVC的执行流程知道吗?
- 用户发送请求到前端控制器(DispatcherServlet),这是一个调度中心
- 前端控制器(DispatcherServlet)收到请求调用处理器映射器(HandlerMapping)
- 处理器映射器找到具体的处理器(可查找xml配置或注解配置),生成处理器对象及处理器拦截器(如果有),再一起返回给前端控制器
- 前端控制器调用处理器适配器(HandlerAdapter)
- 处理器适配器经过适配调用具体的处理器(Handler/Controller)
- Controller执行完成返回模型视图(ModelAndView)对象
- 处理器适配器将Controller执行结果模型视图返回给前端控制器
- 前端控制器将模型视图传给视图解析器(ViewReslover)
- 视图解析器解析后返回具体视图(View)
- 前端控制器根据视图进行渲染,即将模型数据填充至视图中
- 前端控制器响应用户
Springboot自动配置原理?
在Spring Boot项目中的引导类上有一个注解@SpringBootApplication,这个注解是对三个注解进行了封装,分别是:
- @SpringBootConfiguration(SpringBoot配置注解)
- @EnableAutoConfiguration(启用自动配置注解)
- @ComponentScan(组件扫描注解)
其中@EnableAutoConfiguration
是实现自动配置的核心注解
该注解通过@Import
注解导入对应的配置选择器。关键的是内部就是读取了该项目和该项目引用的Jar包的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名
在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中
一般条件判断会有像@ConditionalOnClass
这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用
SpringBoot的优点有哪些?
- 简化配置:通过默认配置和自动配置减少繁琐的配置工作,采用“约定优于配置”的原则
- 快速开发:提供快速开发工具和功能,如热部署,起步依赖减少集成工作,提高开发效率
- 内嵌服务器:内置Tomcat、Jetty等Web服务器,无需单独部署即可运行,简化部署流程
- 微服务支持:支持微服务架构,提供发现、负载均衡等支持,易于构建微服务应用
- 生态系统丰富:与Spring生态无缝集成,享受丰富的功能和社区支持
- 易于扩展和集成:设计考虑可扩展性和集成性,支持轻松添加新功能和集成其他系统
- 生产就绪:提供健康监测、性能监控等生产级功能,确保应用程序的稳定性和可靠性
SpringBoot启动流程知道吗?
- 查找并运行main方法:SpringBoot应用的启动始于包含main方法的类,该类调用SpringApplication.run()方法
- 初始化SpringApplication:SpringApplication类负责启动Spring应用上下文。通过调用其run()方法,开始应用的启动流程
- 准备环境:创建并配置可配置环境(ConfigurableEnvironment)实例,用于存储应用的配置信息(如属性、配置文件等)
- 加载自动配置:通过SpringFactoriesLoader加载META-INF/spring.factories中定义的自动配置类(@Configuration类),这些类基于类路径、环境和用户配置来自动配置应用
- 创建并配置ApplicationContext:根据应用类型(如Servlet Web应用)创建并配置Spring应用上下文
- 刷新上下文:调用ApplicationContext的refresh()方法,开始上下文的刷新过程,包括解析配置文件、注册Bean定义、实例化Bean等
- 启动完成:应用程序现在已完全启动并准备好接受请求或执行其他任务
Spring 的常见注解有哪些?
- 第一类是:声明bean,有@Component、@Service、@Repository、@Controller
- 第二类是:依赖注入相关的,有@Autowired、@Qualifier、@Resourse
- 第三类是:设置作用域 @Scope
- 第四类是:spring配置相关的,比如@Configuration,@ComponentScan 和 @Bean
- 第五类是:跟aop相关做增强的注解 @Aspect,@Before,@After,@Around,@Pointcut
SpringMVC常见的注解有哪些?
- @RequestMapping:用于映射请求路径
- @RequestBody:注解实现接收http请求的json数据,将json转换为java对象
- @RequestParam:指定请求参数的名称
- @PathViriable:从请求路径下中获取请求参数(/user/{id}),传递给方法形参
- @ResponseBody:注解实现将controller方法返回对象转化为json对象响应给客户端
- @RequestHeader:获取指定的请求头数据,还有像@PostMapping、@GetMapping这些
Springboot常见注解有哪些?
Spring Boot的核心注解是@SpringBootApplication , 他由几个注解组成 :
- @SpringBootConfiguration(SpringBoot配置注解): 组合了- @Configuration注解,实现配置文件的功能;
- @EnableAutoConfiguration(启用自动配置注解):打开自动配置的功能,也可以关闭某个自动配置的选项
- @ComponentScan(组件扫描注解):Spring组件扫描
MyBatis执行流程
- 读取配置文件:加载MyBatis的核心配置文件,获取数据库连接等信息
- 构建会话工厂(SqlSessionFactory):基于配置文件构建SqlSessionFactory,用于生成SqlSession
- 创建SqlSession:通过SqlSessionFactory获取SqlSession,SqlSession是执行SQL操作的主要接口
- 获取Mapper接口代理:通过SqlSession获取Mapper接口的代理对象,用于执行具体的SQL操作
- 执行SQL语句:通过Mapper接口代理对象调用方法,MyBatis执行对应的SQL语句
- 处理结果:MyBatis将SQL执行结果转换为Java对象或集合
- 返回结果:将转换后的结果返回给调用者
#{}和${}的区别是什么?
#{}是预编译处理,${}是字符串替换。
Mybatis 在处理#{}时,会将 sql 中的#{}替换为?号,调用 参数化查询语句(PreparedStatement)的set 方法来赋值
Mybatis 在处理${}时,就是把${}替换成变量的值
使用#{}可以有效的防止 SQL 注入,提高系统安全性
Mybatis是否支持延迟加载?
支持
延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据
Mybatis支持一对一关联对象和一对多关联集合对象的延迟加载
在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled = true | false,默认是关闭的
延迟加载的底层原理
延迟加载在底层主要使用的CGLIB动态代理完成的
-
第一是使用CGLIB创建目标对象的代理对象,这里的目标对象就是开启了延迟加载的mapper
-
第二是当调用目标方法时,进入拦截器invoke方法,发现目标方法是null值,再执行sql查询
-
第三是获取数据以后,调用set方法设置属性值,再继续查询目标方法,就有值了
Mybatis的一级、二级缓存了解吗?
mybatis的一级缓存: 基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 SQLSession,当Session进行flush或close之后,该SQLSession中的所有Cache就将清空,默认打开一级缓存
关于二级缓存需要单独开启
二级缓存是基于namespace和mapper的作用域起作用的,不是依赖于SQLSession,默认也是采用 PerpetualCache 的 HashMap 本地存储
如果想要开启二级缓存需要在全局配置文件和映射文件中开启配置才行
Mybatis的二级缓存什么时候会清理缓存中的数据?
当某一个作用域(一级缓存 Session/二级缓存Namespaces)进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear
微服务篇
Spring Cloud 组件有哪些?
早期我们一般认为的Spring Cloud五大组件是
- Eureka : 注册中心
- Ribbon : 负载均衡
- Feign : 远程调用
- Hystrix : 服务熔断
- Zuul/Gateway : 网关
随着SpringCloudAlibba在国内兴起 , 我们项目中使用了一些阿里巴巴的组件
-
注册中心/配置中心 Nacos
-
负载均衡 Ribbon
-
服务调用 Feign
-
服务保护 sentinel
-
服务网关 Gateway
注册中心有什么用?
注册中心主要三块大功能,分别是服务注册 、服务发现、服务状态监控
-
服务注册:服务注册是将服务实例的信息添加到注册中心,使其他服务能够发现并使用。
-
服务发现:服务发现是从注册中心获取可用的服务实例信息,以便服务消费者能够调用它们
-
服务监控:服务监控是对注册服务进行健康检查和状态监控,确保服务的高可用性和动态性
能说下nacos与eureka的区别吗?
共同点:
- Nacos与eureka都支持服务注册和服务拉取,都支持服务提供者心跳方式做健康检测
Nacos与Eureka的区别:
- Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
- 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
- Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
- Nacos集群默认采用AP方式,当集群中存在非临时实例时,采用CP模式;Eureka采用AP方式
Nacos还支持配置中心,Eureka则只有注册中心
负载均衡如何实现的 ?
在服务调用过程中的负载均衡一般使用SpringCloud的Ribbon 组件实现 , Feign的底层已经集成了Ribbon , 使用起来非常简单
当发起远程调用时,ribbon先从注册中心拉取服务地址列表,然后按照一定的路由策略选择一个发起远程调用,一般的调用策略是轮询
Ribbon负载均衡策略有哪些 ?
-
RoundRobinRule:简单轮询服务列表来选择服务器
-
WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小
-
RandomRule:随机选择一个可用的服务器
-
ZoneAvoidanceRule:区域敏感策略,以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询(默认)
如果想自定义负载均衡策略如何实现 ?
提供了两种方式:
- 创建类实现IRule接口,可以指定负载均衡策略,这个是全局的,对所有的远程调用都起作用
- 在客户端的配置文件中,可以配置某一个服务调用的负载均衡策略,只是对配置的这个服务的远程调用生效
什么是服务雪崩,怎么解决这个问题?
服务雪崩是指一个服务失败,导致整条链路的服务都失败的情形,一般解决的方案有两种,第一个是服务降级,第二个是服务熔断,如果流量太大的话,可以考虑限流
服务降级: 服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与feign接口整合,编写降级逻辑
服务熔断: 默认关闭,需要手动打开,如果检测到 10 秒内请求的失败率超过 50%,就触发熔断机制。之后每隔 5 秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求
限流有哪些实现方式?
- nginx限流操作,nginx使用的漏桶算法来实现过滤,让请求以固定的速率处理请求,可以应对突发流量
- spring cloud gateway中支持局部过滤器RequestRateLimiter来做限流,使用的是令牌桶算法,可以根据ip或路径进行限流,可以设置每秒填充平均速率和令牌桶总容量
限流常见的算法有哪些呢?
比较常见的限流算法有漏桶算法和令牌桶算法:
- 漏桶算法是把请求存入到桶中,以固定速率从桶中流出,可以让我们的服务做到绝对的平滑,起到很好的限流效果
- 令牌桶算法在桶中存储的是令牌,按照一定的速率生成令牌,每个请求都要先申请令牌,申请到令牌以后才能正常请求,也可以起到很好的限流作用
- 它们的区别是,漏桶和令牌桶都可以处理突发流量,其中漏桶可以做到绝对的平滑,令牌桶有可能会产生突发大量请求的情况,一般nginx限流采用的漏桶,spring cloud gateway中可以支持令牌桶算法
什么是CAP理论?
CAP主要是在分布式项目下的一个理论。包含了三项,一致性、可用性、分区容错性
-
一致性(Consistency):所有节点在同一时间具有相同的数据
-
可用性(Availability):每个请求都能得到(非超时)响应,但不保证返回的是最新写入的数据
-
分区容错性(Partition Tolerance):系统在网络分区发生时仍能继续提供服务
为什么分布式系统中无法同时保证一致性和可用性?
首先一个前提,对于分布式系统而言,分区容错性是一个最基本的要求,因此基本上我们在设计分布式系统的时候只能从一致性(C)和可用性(A)之间进行取舍。
如果保证了一致性(C):对于节点N1和N2,当往N1里写数据时,N2上的操作必须被暂停,只有当N1同步数据到N2时才能对N2进行读写请求,在N2被暂停操作期间客户端提交的请求会失败或超时,这就不符合可用性的要求
如果保证了可用性(A):那就不能暂停N2的读写操作,但同时N1在写数据的话,这就违背了一致性的要求
什么是BASE理论?
BASE是CAP理论中AP方案的延伸,核心思想是即使无法做到强一致性(StrongConsistency,CAP的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性(Eventual Consitency)。它的思想包含三方面:
-
Basically Available(基本可用):基本可用是指分布式系统在出现不可预知的故障的时候,允许损失部分可用性,但不等于系统不可用。
-
Soft state(软状态):即是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
-
Eventually consistent(最终一致性):强调系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。其本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
分布式事务有哪些解决方案?
- seata的XA模式,CP,需要互相等待各个分支事务提交,可以保证强一致性,性能差
- seata的AT模式,AP,底层使用undo log实现,性能好
- seata的TCC模式,AP,性能较好,不过需要人工编码实现
- MQ模式实现分布式事务,在A服务写数据的时候,需要在同一个事务内发送消息到另外一个事务,异步,能最好
分布式服务的接口幂等性如何设计?
- 幂等:多次调用方法或者不会改变业务状态,可以
保证重复调用的结果和单次调用的结果一致
- 如果是新增数据,可以使用数据库的唯一索引
- 如果是新增或修改数据
- 分布式锁,性能较低
- 使用 token + redis 来实现,性能较好
- 第一次请求,生成一个唯一token存储redis,返回给前端
- 第二次请求,业务处理,携带之前的token,到redis进行验证,如果存在,可以执行业务,删除token;如果不存在,则直接返回,不处理业务
xxl-job篇
xxl-job路由策略有哪些?
xxl-job提供了很多的路由策略,比较常用的有:轮询、故障转移、分片广播、随机…
xxl-job任务执行失败怎么解决?
- 第一:路由策略选择故障转移,优先使用健康的实例来执行任务
- 第二,如果还有失败的,我们在创建任务时,可以设置重试次数
- 第三,如果还有失败的,就可以查看日志或者配置邮件告警来通知相关负责人解决
如果有大数据量的任务同时都需要执行,怎么解决?
部署多个实例,共同去执行这些批量的任务,其中任务的路由策略选择分片广播
在任务执行的代码中可以获取分片总数和当前分片,按照取模的方式分摊到各个实例执行就可以了
消息中间件
RabbitMQ
RabbitMQ 如何保证消息不丢失?
要保证消息的不丢失,主要从三个层面考虑:
- 第一个是开启生产者确认机制,确保生产者的消息能到达队列,如果报错可以先记录到日志中,再去修复数据
- 第二个是开启持久化功能,确保消息未消费前在队列中不会丢失,其中的交换机、队列、和消息都要做持久化
- 第三个是开启消费者确认机制为auto,由spring确认消息处理成功后完成ack,当然也需要设置一定的重试次数,假如设置了3次,如果重试3次还没有收到消息,就将失败后的消息投递到异常交换机,交由人工处理
RabbitMQ消息的重复消费问题如何解决的?
- 每条消息设置一个唯一的标识id
- 幂等性方案:分布式锁、数据库
RabbitMQ中死信交换机知道吗?
当一个队列中的消息满足下列情况之一时,可以称为 死信(dead letter):
- 消费者使用
basic.reject
或basic.nack
声明消费失败,并且消息的requeue参数设置为false - 消息是一个过期消息,超时无人消费
- 要投递的队列消费堆积满了,最早的消息可能成为死信
如果该队列配置了 dead-letter-exchange
属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为 死信交换机
(Dead Letter Exchange,简称DLX)
RabbitMQ延迟队列有了解过吗?
延迟队列插件实现延迟队列 DelayExchange
- 声明一个交换机,添加
delayed
属性为true
- 发送消息时,添加
x-delay
头,值为超时时间
如果有100万消息堆积在MQ , 如何解决 ?
解决消息堆积有三种思路:
- 增加更多消费者,提高消费速度
- 在消费者内开启线程池加快消息处理速度
- 扩大队列容积,提高堆积上限,采用惰性队列
- 在声明队列的时候可以设置属性
x-queue-mode
为lazy
,即为惰性队列 - 基于磁盘存储,消息上限高
- 性能比较稳定,但基于磁盘存储,受限于IO,时效性会降低
- 在声明队列的时候可以设置属性
RabbitMQ的高可用机制有了解过吗?
-
普通集群,或者叫标准集群(classic cluster),具备下列特征:
- 会在集群的各个节点间共享部分数据,包括:交换机、队列元信息不包含队列中的消息
- 当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回
- 队列所在节点宕机,队列中的消息就会丢失
-
镜像集群:本质是主从模式,具备下面的特征:
- 交换机、队列、队列中的消息会在各个mq的镜像节点之间同步备份。
- 创建队列的节点被称为该队列的主节点,备份到的其它节点叫做该队列的镜像节点。
- 一个队列的主节点可能是另一个队列的镜像节点
- 所有操作都是主节点完成,然后同步给镜像节点
- 主节点宕机后,镜像节点会替代成新的主节点
-
仲裁队列:仲裁队列是3.8版本以后才有的新功能,用来替代镜像队列,具备下列特征:
- 与镜像队列一样,都是主从模式,支持主从数据同步
- 使用非常简单,没有复杂的配置
- 主从同步基于Raft协议,强一致
RabbitMQ有哪些组件?
- 生产者(Producer):负责创建并发送消息到RabbitMQ服务器
- 消费者(Consumer):负责从RabbitMQ服务器接收消息并进行处理
- 交换器(Exchange):负责接收生产者发送的消息,并根据路由规则将消息路由到一个或多个队列中。RabbitMQ提供了多种类型的交换器,如Direct、Topic、Headers和Fanout等,以适应不同的消息路由需求
- 队列(Queue):用于存储消息,等待消费者进行消费。队列是RabbitMQ中的核心组件之一,它实现了消息的存储和转发功能。
- 绑定(Binding):用于建立交换器与队列之间的路由关系。通过绑定,RabbitMQ能够知道如何将交换器接收到的消息路由到指定的队列中
- 路由键(Routing Key):在消息发送过程中,生产者会指定一个路由键,用于指定消息的路由规则。路由键需要与交换器类型和绑定键(Binding Key)联合使用,才能最终确定消息的路由目标。
- 连接(Connection):客户端与RabbitMQ服务器之间的TCP连接。在RabbitMQ中,一个连接可以包含多个通道(Channel)
- 信道(Channel):在RabbitMQ中,连接(Connection)是一个重量级的对象,而信道(Channel)则是轻量级的。客户端可以通过信道与RabbitMQ进行交互,包括声明队列、交换器和绑定关系,以及发送和接收消息等
RabbitMQ有哪些交换机?
- 直连交换机(Direct Exchange):根据消息的Routing Key将消息路由到完全匹配的队列
- 扇出交换机(Fanout Exchange):将消息广播到所有绑定到该交换机的队列,忽略Routing Key
- 主题交换机(Topic Exchange):根据消息的Routing Key和通配符模式进行匹配,将消息路由到一个或多个队列
- 头部交换机(Headers Exchange):根据消息的Headers属性进行匹配,将消息路由到匹配的队列
Kafka
Kafka是如何保证消息不丢失?
需要从三个层面去解决这个问题:
- 生产者发送消息到
Brocker
丢失- 设置异步发送,发送失败使用回调进行记录或重发
- 失败重试,参数配置,可以设置重试次数
- 消息在
Brocker
中存储丢失- 发送确认
acks
,选择all
,让所有的副本都参与保存数据后确认
- 发送确认
- 消费者从
Brocker
接收消息丢失- 关闭自动提交偏移量,开启手动提交偏移量
- 提交方式,最好是同步 + 异步提交
Kafka中消息的重复消费问题如何解决的?
- 关闭自动提交偏移量,开启手动提交偏移量
- 提交凡是,最好是同步 + 异步提交
- 幂等性方案:分布式锁、数据库
Kafka是如何保证消费的顺序性?
问题原因: 一个 topic
的数据可能存储在不同的分区中,每个分区都有一个按照顺序的存储的偏移量,如果消费者关联了多个分区不能保证顺序性
解决方案:
- 发送消息时指定分区号
- 发送消息时按照相同的业务设置相同的
key
Kafka的高可用机制有了解过吗?
集群: 一个Kafka集群由多个 broker
实例组成,即使某一台宕机,也不耽误其它 broker
继续对外提供服务
复制机制:
- 一个
topic
有多个分区,每个分区有多个副本,有一个leader
,其余的是follower
,副本存储在不同的borker
中 - 所有的分区副本的内容都是相同的,如果
leader
发生故障时,会自动将某一个follower
提升为leader
,保证了系统的容错、高可用性
解释一下复制机制中的ISR
ISR(in-sync replica)需要同步复制保存的 follower
分区副本分为两类,一类是 ISR
,与 leader
副本同步保存数据,另外一个普通的副本,是异步同步数据,当 leader
挂掉之后,会优先从 ISR
副本列表中选取一个作为 leader
Kafka数据清理机制了解过吗?
Kafka存储结构:
- Kafka 中 topic 的数据存储在分区上,分区如果文件过大会分段存储 segment
- 每个分段都在磁盘上以索引(xxx.index)和日志文件(xxx.log)的形式存储
- 分段的好处是,第一能够减少单个文件内容的大小,查找数据方便,第二方便 kafka 进行日志清理
日志的清理策略有两个:
- 根据消息的保留时间,当消息保存的时间超过了指定的时间,就会触发清理,默认是168小时(7天)
- 根据 topic 存储的数据大小,当 topic 所占的日志文件大小大于一定的阈值,则开始删除最久的消息(默认关闭)
Kafka中实现高性能的设计有了解过吗?
Kafka 高性能,是多方面协同的结果,包括宏观架构、分布式存储、ISR 数据同步、以及高效的利用磁盘、操作系统特性等。主要体现有这么几点:
- 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
- 顺序读写:磁盘顺序读写,提升读写效率
- 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
- 零拷贝:减少上下文切换及数据拷贝
- 消息压缩:减少磁盘IO和网络IO
- 分批发送:将消息打包批量发送,减少网络开销
设计模式篇
对象的设计原则有哪些?
- 单一职责原则:一个类应该只负责一项职责,即一个类应该只有一个引起它变化的原因
- 开闭原则:应该对扩展开放,对修改关闭
- 里氏替换原则:子类型必须能够替换掉它们的基类型,且替换后程序的行为不会发生变化。这确保了继承的正确使用
- 依赖倒置原则:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象
- 接口隔离原则:不应该强迫客户依赖于它们不使用的方法。接口应该小而专,避免胖接口
- 合成复用原则:尽量使用组合或聚合的方式来实现代码复用,而不是通过继承。这有助于保持类的独立性,降低耦合度
- 迪米特法则:一个对象应该对其他对象有尽可能少的了解,即降低类之间的耦合度,提高系统的模块化
什么是设计模式?
设计模式是一套经过实践验证的、可复用的代码设计经验总结,它提供了针对特定问题或场景的解决方案,旨在提高代码的灵活性、可重用性、可维护性和可扩展性。设计模式不是语言特性或库,而是一种思想、一种方法论,它可以被应用于各种编程语言和框架中
有哪些设计模式?
设计模式主要分为三大类:创建型模式、结构型模式和行为型模式
-
创建型模式:创建型模式主要用于处理对象的创建过程,确保对象的创建与使用过程分离,以增加系统的灵活性和可扩展性
- 工厂方法模式:定义一个创建对象的接口,但让子类决定要实例化的类是哪一个。工厂方法让类的实例化推迟到子类中进行
- 抽象工厂模式:提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类
- 单例模式:确保一个类仅有一个实例,并提供一个全局访问点
- 建造者模式:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示
- 原型模式:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象
-
结构型模式:结构型模式主要关注类和对象的组合,通过组合的方式实现更复杂的结构,以实现新的功能
- 适配器模式:将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的类可以一起工作
- 代理模式:为其他对象提供一种代理以控制对这个对象的访问
- 装饰者模式:动态地给一个对象添加一些额外的职责,就增加功能来说,装饰者模式比生成子类更为灵活
- 外观模式:子系统中的一组接口提供一个一致的界面,此模式定义了一个高层接口,这个接口使得这一子系统更加容易使用
- 桥接模式:将抽象部分与实际部分分离,使它们都可以独立地变化
- 组合模式:将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性
- 享元模式:以共享的方式高效的支持大量的细粒度的对象
-
行为型模式:行为型模式主要关注类和对象之间的交互和职责分配,以完成特定的任务
- 模板方法模式:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤
- 命令模式:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作
- 责任链模式:为请求创建一个接收者对象的链。这个模式对请求的发送者和接收者进行解耦
- 策略模式:定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。本模式让算法的变化独立于使用算法的客户
- 中介者模式:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互
- 观察者模式:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新
- 备忘录模式:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态
- 访问者模式:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作
- 状态模式:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类
- 解释器模式:给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子
- 迭代器模式:提供了一种方法顺序访问一个聚合对象中各个元素,而又不暴露该对象的内部表示
写一个单例类
-
饿汉式单例:
public class Singleton { // 1.私有构造方法 private Singleton() {} // 2.在本类中创建本类对象 private static Singleton instance = new Singleton(); // 3.提供一个公共的访问方式,让外界获取该对象 public static Singleton getInstance(){ return instance; } }
-
懒汉式单例:
public class Singleton { // 私有构造方法 private Singleton() { } // 定义一个静态内部类 private static class SingletonHolder { // 在内部类中声明并初始化外部类对象 private static final Singleton INSTANCE = new Singleton(); } // 提供公共的访问方式 public static Singleton getInstance(){ return SingletonHolder.INSTANCE; } }
计网篇
TCP和UDP有什么区别?
- 连接性:TCP面向连接,需先建立连接;UDP无连接,直接发送
- 可靠性:TCP可靠,确保数据完整;UDP不可靠,不保证数据完整
- 速度和效率:TCP慢,有连接和确认开销;UDP快,无额外开销
- 适用场景:TCP适合文件传输等需可靠性的应用;UDP适合实时音视频等不需严格可靠性的应用
什么是TCP三次握手?
三次握手是指TCP协议中,通信双方建立连接时,通过三个TCP报文段的交换过程,来确保双方都能正确地接收和发送数据,从而建立可靠的连接。具体来说,三次握手的过程如下:
- 第一次握手:客户端发送一个带有SYN(同步)标志的数据包给服务端,表示请求建立连接,并指定客户端的初始化序列号ISN(Initial Sequence Number)
- 第二次握手:服务端收到客户端的SYN包后,回复一个SYN+ACK(同步确认)包给客户端,表示同意建立连接,并指定自己的初始化序列号ISN,同时将客户端的ISN+1作为ACK(确认)的值,表示已经收到了客户端的SYN包
- 第三次握手:客户端收到服务端的SYN+ACK包后,再回复一个ACK包给服务端,表示已经收到了服务端的SYN+ACK包,并确认自己的序列号。此时,双方都已准备好发送和接收数据,连接建立成功
这个过程就像两个人在握手确认身份和意图一样,所以被称为“三次握手”。它确保了TCP连接的可靠性和稳定性,是TCP协议中非常重要的一个部分
什么是TCP四次挥手?
TCP四次挥手的过程可以概括为:
- 客户端首先发送一个FIN报文段给服务器,表示客户端想要关闭连接
- 服务器收到FIN报文段后,回复一个ACK报文段给客户端,表示服务器已经收到了客户端的关闭请求
- 接着,服务器也会发送一个FIN报文段给客户端,表示服务器也想要关闭连接
- 客户端收到服务器的FIN报文段后,回复一个ACK报文段给服务器,表示客户端已经收到了服务器的关闭请求。至此,TCP连接被完全关闭
这个过程确保了双方都能够正确地关闭连接,并清理了相关的资源。同时,也避免了在关闭连接过程中可能出现的数据丢失或连接混乱等问题
TCP为什么可靠?
TCP可靠是因为它内置了序列号、确认应答、超时重传、数据校验和、流量控制、拥塞控制等机制,确保数据有序、完整、无误地传输
从输入URL到页面展示发生了什么?
- URL解析:识别URL的协议、服务器地址等关键信息
- DNS查询:将服务器地址(主机名)转换为IP地址
- 建立连接:与服务器建立TCP/IP连接(HTTPS则还需SSL/TLS加密)
- 发送请求:浏览器发送HTTP请求到服务器
- 服务器响应:服务器处理请求,返回HTTP响应(含HTML等)
- 页面渲染:浏览器解析HTML等响应内容,渲染页面并展示给用户
常见http状态码有哪些?
- 2xx 成功:
- 200 OK:请求成功
- 204 No Content:请求成功,但没有返回内容
- 3xx 重定向:
- 301 Moved Permanently:资源永久移动到新URL。
- 302 Found:资源临时移动到新URL。
- 304 Not Modified:资源未更改,使用缓存版本。
- 4xx 客户端错误:
- 400 Bad Request:请求格式错误。
- 401 Unauthorized:需要认证。
- 403 Forbidden:没有访问权限。
- 404 Not Found:资源未找到。
- 405 Method Not Allowed:请求方法不被允许。
- 5xx 服务器错误:
- 500 Internal Server Error:服务器内部错误。
- 502 Bad Gateway:网关错误。
- 503 Service Unavailable:服务不可用。
- 504 Gateway Timeout:网关超时。