Java八股学习
Java基础知识
JDK,JVM,JRE三者的关系
- JVM是程序运行的环境,将Java字节码解释或编译成机器码,并执行程序。JVM提供了内存管理,gc,安全性等功能,使Java程序具备跨平台性。
- JDK是开发Java程序所需的工具集合,包含jvm,编译器(javac),调试器(jdb)等开发工具,以及一系列的类库。JDK提供了开发,编译,调试和运行程序所需的工具和环境
- JRE是Java程序运行所需的最小环境。包含了JVM和一组JAVA类库,用于支持Java程序的执行。JRE不包含开发工具,只提供运行所需环境。
为什么使用bigDecimal而不用double
double会出现精度丢失的问题,double执行的是二进制浮点运算,二进制有些情况下不能准确的表示一个小数,就像十进制不能准确的表示1/3(1/3=0.3333...),也就是说二进制表示小数的时候只能够表示能够用1/(2n)的和的任意组合,但是0.1不能够精确表示,因为它不能够表示成为1/(2n)的和的形式。
如果在进行商品价格计算的时候,就会出现问题。很有可能造成我们手中有0.06元,却无法购买一个0.05元和一个0.01元的商品。因为如上所示,他们两个的总和为0.060000000000000005。这无疑是一个很严重的问题,尤其是当电商网站的并发量上去的时候,出现的问题将是巨大的。可能会导致无法下单,或者对账出现问题。
而Decimal是精确计算,一般牵扯到金钱计算,都使用Decimal(java.math.BigDecimal)
装箱和拆箱是什么
装箱和拆箱是将基本数据类型和对应的包装类之间进行转换的过程,自动装箱主要发生在两种情况,一种是赋值时,一种是在方法调用时。
自动装箱有一个问题,那就是在一个循环中进行自动装箱操作的情况,会创建多余的对象,影响程序性能。
为什么需要Integer
Integer对应是int类型的包装类,就是把int类型包装成Object对象,对象封装有很多好处,可以把属性也就是数据跟处理这些数据的方法结合在一起,比如Integer就有parseInt()等方法来专门处理int型相关的数据。
另一个非常重要的原因就是在Java中绝大部分方法或类都是用来处理类类型对象的,如ArrayList集合类就只能以类作为他的存储对象,而这时如果想把一个int型的数据存入list是不可能的,必须把它包装成类,也就是Integer才能被List所接受。所以Integer的存在是很必要的。
面向对象的六大设计原则
- 单一职责原则(SRP):一个类应该只有一个引起它变化的原因,即一个类应该只负责一项职责。例子:考虑一个员工类,它应该只负责管理员工信息,而不应负责其他无关工作。
- 开放封闭原则(OCP):软件实体应该对扩展开放,对修改封闭。例子:通过制定接口来实现这一原则,比如定义一个图形类,然后让不同类型的图形继承这个类,而不需要修改图形类本身。
- 里氏替换原则(LSP):子类对象应该能够替换掉所有父类对象。例子:一个正方形是一个矩形,但如果修改一个矩形的高度和宽度时,正方形的行为应该如何改变就是一个违反里氏替换原则的例子。
- 接口隔离原则(ISP):客户端不应该依赖那些它不需要的接口,即接口应该小而专。例子:通过接口抽象层来实现底层和高层模块之间的解耦,比如使用依赖注入。
- 依赖倒置原则(DIP):高层模块不应该依赖低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。例子:如果一个公司类包含部门类,应该考虑使用合成/聚合关系,而不是将公司类继承自部门类。
- 最少知识原则(Law of Demeter):一个对象应当对其他对象有最少的了解,只与其直接的朋友交互。
非静态内部类和静态内部类的区别
- 非静态内部类依赖于外部类的实例,而静态内部类不依赖于外部类的实例。
- 非静态内部类可以访问外部类的实例变量和方法,而静态内部类只能访问外部类的静态成员。
- 非静态内部类不能定义静态成员,而静态内部类可以定义静态成员。
- 非静态内部类在外部类实例化后才能实例化,而静态内部类可以独立实例化。
- 非静态内部类可以访问外部类的私有成员,而静态内部类不能直接访问外部类的私有成员,需要通过实例化外部类来访问。
有一个父类和子类-都有静态的成员变量、静态构造方法和静态方法-在我new一个子类对象的时候-加载顺序是怎么样的
- 在创建子类对象之前,首先会加载父类的静态成员变量和静态代码块(构造方法无法被 static 修饰,因此这里是静态代码块)。这个加载是在类首次被加载时进行的,且只会发生一次。
- 接下来,加载子类的静态成员变量和静态代码块。这一过程也只发生一次,即当首次使用子类的相关代码时。
- 之后,执行实例化子类对象的过程。这时会呼叫父类构造方法,然后是子类的构造方法。
实现深拷贝的三种方式
-
实现Cloneable接口并重写clone()方法
//深拷贝的实现方式I public class MyTest1 implements Cloneable{ private String field1; private NestedClass nestedClass; @Override protected Object clone() throws CloneNotSupportedException { MyTest1 cloned = (MyTest1)super.clone(); cloned.nestedClass = (NestedClass) nestedClass.clone(); return cloned; } } class NestedClass implements Cloneable{ private int nestedField; @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }
-
使用序列化和反序列化
-
手动递归复制
创建对象的方式
-
使用new关键字
-
通过反射机制的newInstance()创建
Bean bean = (Bean) Class.forName("com.jermaine.Bean").newInstance();
测试发现这种创建方式调用了构造器
-
通过反射机制,使用Constructor类的newInstance方式创建
Constructor<Bean> constructor = Bean.class.getConstructor(); Bean obj = constructor.newInstance();
-
使用clone()方法复制对象
MyTest1 obj1 = new MyTest1(); MyTest1 obj2 = (MyTest1) obj1.clone();
-
将对象序列化到文件或者流中,然后再反序列化创建对象
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("object.ser")); out.writeObject(obj); out.close(); ObjectInputStream in = new ObjectInputStream(new FileInputStream("object.ser")); Bean bean2 = (Bean) in.readObject(); in.close();
通过new创建的对象何时回收
由GC自动回收,它会周期性地检测不再被引用的对象,并将其回收释放内存,对象的回收主要有几种情况:
- 引用计数法:某个对象的引用计数为0时,表示该对象不再被引用,可以被回收。
- 可达性分析算法:从根对象(如方法区中的类静态属性、方法中的局部变量等)出发,通过对象之间的引用链进行遍历,如果存在一条引用链到达某个对象,则说明该对象是可达的,反之不可达,不可达的对象将被回收。
- 终结器(Finalizer):如果对象重写了finalize()方法,垃圾回收器会在回收该对象之前调用finalize()方法,对象可以在finalize()方法中进行一些清理操作。然而,终结器机制的使用不被推荐,因为它的执行时间是不确定的,可能会导致不可预测的性能问题。
什么是反射
反射机制是在运行状态中,可以获取类的所有属性和方法,对于任意一个对象,可以调用它的任意一个方法和属性。
反射特性:
- 运行时类信息访问
- 动态对象创建:可以使用反射API动态地创建对象实例,在前面创建对象的地方可以体现
- 动态方法调用:可以在运行时动态地调用对象的方法,包括私有方法。
- 访问和修改字段值:还允许在运行时访问和修改对象的字段值,即使是私有的。
反射的应用场景
Spring IOC,可以通过配置文件或者配置类装载bean,在配置文件中只需要填写对应实体类的相关属性信息,使用反射机制获取到这个类的Class实例。
反射示例:
//反射实体类
public class TestInvoke {
private void printInstance(){
System.out.println("I am TestInvoke");
}
}
public class TestReflect {
public static String getName(String key) throws IOException {
Properties properties = new Properties();
FileInputStream in = new FileInputStream("C:\\java开发学习\\Java基础知识\\application.properties");
properties.load(in);
in.close();
return properties.getProperty(key);
}
public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException {
//使用反射机制获取Class对象
Class<?> c = Class.forName(getName("className"));
System.out.println(c.getSimpleName());
//使用反射机制获取方法
Method method = c.getDeclaredMethod(getName("methodName"));
//绕过安全检查
method.setAccessible(true);
//创建实例对象
TestInvoke testInvoke = (TestInvoke)c.newInstance();
//调用方法
method.invoke(testInvoke);
}
}
配置文件
className = com.jermaine.reflectDemo.TestInvoke
methodName = printInstance
注解的原理
注解本质是一个继承了Annotation的特殊接口,其具体实现类是Java运行时生成的动态代理类。
我们通过反射获取注解时,返回的是Java运行时生成的动态代理对象。通过代理对象调用自定义注解的方法,会最终调用AnnotationInvocationHandler的invoke方法。该方法会从memberValues这个Map中索引出对应的值。而memberValues的来源是Java常量池。
Java异常
Java异常主要为Error和Exception这两个大类,均继承了Throwable。
Error:表示运行时环境的错误。错误是程序无法处理的严重问题,如系统崩溃、虚拟机错误、动态链接失败等。通常,程序不应该尝试捕获这类错误。例如,OutOfMemoryError、StackOverflowError等。
Exception:分为两类
- 非运行时异常::这类异常在编译时期就必须被捕获或者声明抛出。它们通常是外部错误,如文件不存在(FileNotFoundException)、类未找到(ClassNotFoundException)等。非运行时异常强制程序员处理这些可能出现的问题,增强了程序的健壮性。
- 运行时异常::这类异常包括运行时异常(RuntimeException)和错误(Error)。运行时异常由程序错误导致,如空指针访问(NullPointerException)、数组越界(ArrayIndexOutOfBoundsException)等。运行时异常是不需要在编译时强制捕获或声明的。
抛出异常的方式:
- try catch:用于捕获并处理特定的异常,可以有多个catch块来处理不同类型的异常,finally用于定义无论是否发生异常都会执行的代码块
- throw:手动抛出异常
- throws:一个方法可能抛出异常,但不想在方法内部处理,将异常传递给调用者处理
==与equals的区别
- '==':比较的是两个字符串内存地址(堆内存)的数值是否相同
- 'equals':比较的是字符串内的内容是否相同
StringBuffer和StringBuilder的区别
- String类是final class,不可变字符串,因此在拼接时会产生许多无用中间对象,对性能产生影响
- StringBuffer()是为了解决字符串拼接的问题,本质是一个线程安全的可修改字符序列。
- StringBuilder()本质上与buffer没有区别,只是线程不安全,减少了开销。
Java 8/1.8新特性
stream API
它提供了一种高效且易于使用的数据处理方式,特别适合集合对象的操作,如过滤、映射、排序等。Stream API不仅可以提高代码的可读性和简洁性,还能利用多核处理器的优势进行并行处理。
stream流的并行API是什么
并行流(ParallelStream)就是将源数据分为多个子流对象进行多线程操作,然后将处理的结果再汇总为一个流对象,底层是使用通用的 fork/join 池来实现,即将一个任务拆分成多个“小任务”并行计算,再把多个“小任务”的结果合并成总的计算结果
CPU密集型(涉及大量计算,循环和逻辑操作等任务)任务适合使用并行流来解决,而任务如果是IO密集型(涉及频繁的输入输出,例如频繁的文件读写,网络通信等任务)的,并且相对线程数较大,直接使用并行流并不合适
completableFutrue怎么用的
CompletableFuture是由Java 8引入的,在Java8之前我们一般通过Future实现异步。
- Future用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法,Java 8之前若要设置回调一般会使用guava的ListenableFuture,回调的引入又会导致臭名昭著的回调地狱(下面的例子会通过ListenableFuture的使用来具体进行展示)。
- CompletableFuture对Future进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,同时一定程度解决了回调地狱的问题。
序列化
怎么把一个对象从一个jvm转移到另一个jvm
- 使用序列化和反序列化:将对象序列化为字节流,并将其发送到另一个 JVM,然后在另一个 JVM 中反序列化字节流恢复对象。这可以通过 Java 的 ObjectOutputStream 和 ObjectInputStream 来实现。
- 使用消息传递机制:利用消息传递机制,比如使用消息队列(如 RabbitMQ、Kafka)或者通过网络套接字进行通信,将对象从一个 JVM 发送到另一个。这需要自定义协议来序列化对象并在另一个 JVM 中反序列化。
- 使用远程方法调用(RPC):可以使用远程方法调用框架,如 gRPC,来实现对象在不同 JVM 之间的传输。远程方法调用可以让你在分布式系统中调用远程 JVM 上的对象的方法。
- 使用共享数据库或缓存::将对象存储在共享数据库(如 MySQL、PostgreSQL)或共享缓存(如 Redis)中,让不同的 JVM 可以访问这些共享数据。这种方法适用于需要共享数据但不需要直接传输对象的场景。
如何自己实现序列化和反序列化
Java 默认的序列化虽然实现方便,但却存在安全漏洞、不跨语言以及性能差等缺陷。
- 无法跨语言: Java 序列化目前只适用基于 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 Java 序列化这套协议。因此,如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化。
- 容易被攻击:Java 序列化是不安全的,我们知道对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。
- 序列化后的流太大:序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。
我会考虑用主流序列化框架,比如FastJson、Protobuf来替代Java 序列化。
如果追求性能的话,Protobuf 序列化框架会比较合适,Protobuf 的这种数据存储格式,不仅压缩存储数据的效果好, 在编码和解码的性能方面也很高效。Protobuf 的编码和解码过程结合.proto 文件格式,加上 Protocol Buffer 独特的编码格式,只需要简单的数据运算以及位移等操作就可以完成编码与解码。可以说 Protobuf 的整体性能非常优秀。
将对象转为二进制字节流如何实现
其实,像序列化和反序列化,无论这些可逆操作是什么机制,都会有对应的处理和解析协议,例如加密和解密,TCP的粘包和拆包,序列化机制是通过序列化协议来进行处理的,和 class 文件类似,它其实是定义了序列化后的字节流格式,然后对此格式进行操作,生成符合格式的字节流或者将字节流解析成对象。
在Java中通过序列化对象流来完成序列化和反序列化:
- ObjectOutputStream:通过writeObject()方法做序列化操作。
- ObjectInputStrean:通过readObject()方法做反序列化操作。
只有实现了Serializable或Externalizable接口的类的对象才能被序列化,否则抛出异常!
实现序列化步骤:
-
让类实现Serializable接口
-
创建输出流并写入对象
-
创建输入流并读取对象
通过以上步骤,对象obj会被序列化并写入到文件"object.ser"中,然后通过反序列化操作,从文件中读取字节流并恢复为对象newObj。这种方式可以方便地将对象转换为字节流用于持久化存储、网络传输等操作。需要注意的是,要确保类实现了Serializable接口,并且所有成员变量都是Serializable的才能被正确序列化。
设计模式
代理模式和适配器模式的区别
- 目的不同:代理模式主要关注控制对对象的访问,而适配器模式则用于接口转换,使不兼容的类能够一起工作。
- 结构不同:代理模式一般包含抽象主题、真实主题和代理三个角色,适配器模式包含目标接口、适配器和被适配者三个角色。
- 应用场景不同:代理模式常用于添加额外功能或控制对对象的访问,适配器模式常用于让不兼容的接口协同工作。
I/O
BIO、NIO、AIO
- BIO(blocking IO):就是传统的 java.io 包,它是基于流模型实现的,交互的方式是同步、阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。优点是代码比较简单、直观;缺点是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。
- NIO(non-blocking IO) :Java 1.4 引入的 java.nio 包,提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层高性能的数据操作方式。
- AIO(Asynchronous IO) :是 Java 1.7 之后引入的包,是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,所以人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
有一个学生类-想按照分数排序-再按学号排序-应该怎么做
可以使用Comparable接口来实现按照分数排序,再按照学号排序。首先在学生类中实现Comparable接口,并重写compareTo方法,然后在compareTo方法中实现按照分数排序和按照学号排序的逻辑。
树
B树
B树就是一颗多路平衡查找树,适合高效的存储和查找大量数据,在定义B树时,需要指定它的阶数(每个节点最多有阶数-1个孩子节点),当阶数为3时,就是一颗AVL。
一颗m阶B树的定义如下:
- 每个结点最多有m-1个关键字。
- 根结点最少可以只有1个关键字。
- 非根结点至少有Math.ceil(m/2)-1个关键字。
- 每个结点中的关键字都按照从小到大的顺序排列,每个关键字的左子树中的所有关键字都小于它,而右子树中的所有关键字都大于它。
- 所有叶子结点都位于同一层,或者说根结点到每个叶子结点的长度都相同。
B树的插入操作(以5阶为例)
B树的删除操作:
删除操作是指,根据key删除记录,如果B树中的记录中不存对应key的记录,则删除失败。
- 如果当前需要删除的key位于非叶子结点上,则用后继key(这里的后继key均指后继记录的意思)覆盖要删除的key,然后在后继key所在的子支中删除该后继key。此时后继key一定位于叶子结点上,这个过程和二叉搜索树删除结点的方式类似。删除这个记录后执行第2步
- 该结点key个数大于等于Math.ceil(m/2)-1,结束删除操作,否则执行第3步。
- 如果兄弟结点key个数大于Math.ceil(m/2)-1,则父结点中的key下移到该结点,兄弟结点中的一个key上移,删除操作结束。
- 否则,将父结点中的key下移与当前结点及它的兄弟结点中的key合并,形成一个新的结点。原父结点中的key的两个孩子指针就变成了一个孩子指针,指向这个新结点。然后当前结点的指针指向父结点,重复上第2步。
有些结点它可能即有左兄弟,又有右兄弟,那么我们任意选择一个兄弟结点进行操作即可。
B+树
关键字个数比孩子结点个数小1,这种方式是和B树基本等价的。下图就是一颗阶数为4的B+树:
除此之外B+树还有以下的要求:
- B+树包含2种类型的结点:内部结点(也称索引结点)和叶子结点。根结点本身即可以是内部结点,也可以是叶子结点。根结点的关键字个数最少可以只有1个。
- B+树与B树最大的不同是内部结点不保存数据,只用于索引,所有数据(或者说记录)都保存在叶子结点中。
m阶B+树表示了内部结点最多有m-1个关键字(或者说内部结点最多有m个子树),阶数m同时限制了叶子结点最多存储m-1个记录。 - 内部结点中的key都按照从小到大的顺序排列,对于内部结点中的一个key,左树中的所有key都小于它,右子树中的key都大于等于它。叶子结点中的记录也按照key的大小排列。
- 每个叶子结点都存有相邻叶子结点的指针,叶子结点本身依关键字的大小自小而大顺序链接。
B+树与B树的区别:
- 由于B+树在内部节点上不包含数据信息,因此在内存页中能够存放更多的key。数据存放的更加紧密,具有更好的空间局部性。因此访问叶子节点上关联的数据也具有更好的缓存命中率
- B+树的叶子节点都是相连的,因此对整棵树的遍历只需要一次线性遍历叶子节点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。相邻的元素可能在内存中不相邻,所以缓存命中性没有B+树好。
- B+树的数据只存储在叶子节点中,因此插入和删除操作只需要修改叶子节点和对应的索引节点,不需要进行节点的分裂和合并,因此B+树的插入和删除操作更加高效。而B树的每个节点都可以存储数据,因此在进行插入和删除操作时需要进行节点分裂和合并。
红黑树
Java集合
集合
介绍一下Java中的集合
List是有序的Collection,可以通过下标访问元素,插入元素,常用的实现List类的有LinkedList,ArrayList,Stack,Vector
- LinkedList(双向链表):插入与删除元素速度比ArrayList更快,但访问速度慢
- ArrayList:容量可变的线程不安全列表,使用数组实现,扩容通过创建新数组,将原数组复制到新数组中,访问速度更快,但插入与删除较慢
Set不允许存在重复的元素,并且元素是无序的
- HashSet:使用HashMap实现,HashMap的key就是HashSet存储的元素,value均为一个名为PRESENT的object常量,HashSet线程不安全
- LinkedHashSet:继承自HashSet,使用双向链表维护元素插入顺序
- TreeSet:通过TreeMap实现,按照比较规则将元素插入到合适的位置,插入后保证集合仍然有序
Map是一个键值对集合,存储键,值和之间的映射,key唯一,但无序,value可以重复,且不要求有序
- HashMap:JDK1.8之前由数组+链表组成,数组是HashMap的主体,链表通过拉链法解决哈希冲突,JDK1.8后当链表长度大于阈值(默认为8)时,将链表转换为红黑树,减少检索时间。
- TreeMap:红黑树(自平衡的排序二叉树)
- LinkedHashMap:继承自HashMap,底层结构与HashMap相同,同时增加了一条双向链表,控制键值对的插入顺序。
- HashTable:数组+链表组成,数组是HashTable的主体,链表解决哈希冲突
- ConcurrentHashMap:Node+链表+红黑树实现,线程安全的(jdk1.8之前Segment锁,1.8之后volatile+CAS或者synchronized)
线程安全的集合有哪些
java.util包下的线程安全类主要有两个:
- Vector:线程安全的动态数组,其内部方法均使用了synchronized修饰
- HashTable:线程安全的哈希表,给每个方法加上了synchronized关键字
JUC提供的都是线程安全的集合
并发Map
- ConcurrentHashMap: 它取消了Segment字段,直接在table元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率。对于put操作,如果Key对应的数组元素为null,则通过CAS操作(Compare and Swap)将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为红黑树,从而提高寻址效率。
- ConcurrentSkipListMap:实现了一个基于SkipList(跳表)算法的可排序的并发集合,SkipList是一种可以在对数预期时间内完成搜索、插入、删除等操作的数据结构,通过维护多个指向其他元素的“跳跃”链接来实现高效查找。
并发Set
- ConcurrentSkipListSet:底层使用ConcurrentSkipListMap实现
- CopyOnWriteArraySet:是线程安全的Set实现,它是线程安全的无序的集合,可以将它理解成线程安全的HashSet。有意思的是,CopyOnWriteArraySet和HashSet虽然都继承于共同的父类AbstractSet;但是,HashSet是通过“散列表”实现的,而CopyOnWriteArraySet则是通过“动态数组(CopyOnWriteArrayList)”实现的,并不是散列表。
并发List
- CopyOnWriteArrayList:它是 ArrayList 的线程安全的变体,其中所有写操作(add,set等)都通过对底层数组进行全新复制来实现,允许存储 null 元素。即当对象进行写操作时,使用了Lock锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组;若进行的读操作,则直接返回结果,操作过程中不需要进行同步。
并发Queue
- ConcurrentLinkedQueue:是一个适用于高并发场景下的队列,它通过无锁的方式(CAS),实现了高并发状态下的高性能。通常,ConcurrentLinkedQueue 的性能要好于 BlockingQueue 。
- BlockingQueue:与 ConcurrentLinkedQueue 的使用场景不同,BlockingQueue 的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享。BlockingQueue 提供一种读写阻塞等待的机制,即如果消费者速度较快,则 BlockingQueue 则可能被清空,此时消费线程再试图从 BlockingQueue 读取数据时就会被阻塞。反之,如果生产线程较快,则 BlockingQueue 可能会被装满,此时,生产线程再试图向 BlockingQueue 队列装入数据时,便会被阻塞等待。
并发Deque
- LinkedBlockingDeque:是一个线程安全的双端队列实现。它的内部使用链表结构,每一个节点都维护了一个前驱节点和一个后驱节点。LinkedBlockingDeque 没有进行读写锁的分离,因此同一时间只能有一个线程对其进行操作。
- ConcurrentLinkedDeque:ConcurrentLinkedDeque是一种基于链接节点的无限并发链表。可以安全地并发执行插入、删除和访问操作。当许多线程同时访问一个公共集合时,ConcurrentLinkedDeque是一个合适的选择。
Collections和Collection的区别
- Collection是所有集合类的基础接口,定义了一组通用的操作方法:如插入,删除,查找等
- Collections是java提供的一个工具类,提供了一系列静态方法,对集合进行操作和算法。包括排序,查找,替换,反转,随机化等。
集合遍历的方法有哪些
-
for循环索引遍历
-
for-each
-
Iterator迭代器
private static List<Character> list = new ArrayList<>(); public static void main(String[] args) { list.add('a'); list.add('b'); list.add('c'); Iterator<Character> iterator = list.iterator(); while (iterator.hasNext()){ System.out.println(iterator.next()); } }
-
ListIterator迭代器:可以双向访问列表并在迭代过程中修改元素
private static List<Character> list = new ArrayList<>(); public static void main(String[] args) { list.add('a'); list.add('b'); list.add('c'); ListIterator<Character> listIterator = list.listIterator(); while (listIterator.hasNext()){ System.out.println(listIterator.next()); }
-
使用forEach:Java8新特性,使用forEach方法并结合lamda表达式对集合快速遍历
private static List<Character> list = new ArrayList<>(); public static void main(String[] args) { list.add('a'); list.add('b'); list.add('c'); list.forEach(System.out::println); }
-
stream API
private static List<Character> list = new ArrayList<>(); public static void main(String[] args) { list.add('a'); list.add('b'); list.add('c'); list.stream.forEach(System.out::println); }
List
把ArrayList变成线程安全的方法有哪些
-
使用Collections类的synchronizedList()方法将其包装成线程安全的List
List<Character> list = Collections.synchronizedList(TestList.list);
-
使用CopyOnWriteArrayList替代(JUC)
-
使用Vector类替代
ArrayList为何线程不安全,体现在哪些方面?
在高并发量的数据下,ArrayList可能会出现这些问题:
- add的值为null(我们并没有添加null)
- 索引越界异常
- size与add的数量不符合
ArrayList的扩容机制
在添加元素时,如果当前元素个数已经到达内部数组的容量上限时,就会触发扩容操作。
主要包括以下步骤:
- 计算新的容量:一般情况下,将新的容量扩大到原容量的1.5倍,然后检查是否超过最大容量限制
- 创建新的数组
- 将元素从原来数组逐个复制到新数组中
- 更新引用:将ArrayList的引用指向新的数组
JUC中的CopyOnWriteArrayList如何实现线程安全
- 使用volatile关键字修饰数组,这使得对数组进行修改时对其他线程是可见的
- 在写入操作时,使用ReentrantLock以保证线程安全
注意:读操作并不加锁
Map
HashMap实现原理是怎样的
JDK1.7版本之前,HashMap通过哈希函数将元素的键映射到数组上,如果多个键映射到同一个位置上(哈希碰撞/哈希冲突),就会以链表的形式存储到同一个位置上(拉链法),但是链表的查询效率为O(n),链表长度太长就不利于查询。
JDK1.8实现方式:当链表长度超过8时,就会替换成红黑树,而红黑树的查询效率为O(logn),提高了查询性能,但当数量小于6时,又会替换成链表。
哈希冲突的解决方式有哪些
- 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
- 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
- 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
- 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。
HashMap为什么线程不安全
- JDK 1.7 HashMap 采用数组 + 链表的数据结构,多线程背景下,在数组扩容的时候,存在 Entry 链死循环和数据丢失问题。
- JDK 1.8 HashMap 采用数组 + 链表 + 红黑二叉树的数据结构,优化了 1.7 中数组扩容的方案,解决了 Entry 链死循环和数据丢失问题。但是多线程背景下,put 方法存在数据覆盖的问题。
那么如何解决线程不安全
多线程环境可以使用Collections.synchronizedMap同步加锁的方式,还可以使用HashTable,但是同步的方式显然性能不达标,而ConurrentHashMap更适合高并发场景使用。ConurrentHashMap在JDK1.8中使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过度增长。
HashMap的put操作
HashMap的get方法一定安全吗?
不一定,可能会遇到如下问题
- 空指针异常:当HashMap没有初始化时,key为null会抛出空指针异常
- 线程不安全
HashMap为什么适合使用String做key
因为String对象是不可变的,确保了key的稳定性,如果key可变,可能导致hashcode不一致,影响HashMap的正确性
HashMap的扩容机制是怎样的
HashMap默认的负载因子是0.75,如果HashMap中的元素量超过了总容量的75%,就会触发扩容,扩容分为两个步骤:
- 创建一个容量为原来两倍的新哈希表
- 将旧哈希表中的数据放到新的哈希表中
往HashMap中存入20个元素,会扩容几次
HashMap的初始容量是16,当填入到第13个元素时,超过了总容量的75%,扩容一次到32,足够插入到第二十个元素,因此只需要扩容一次。
HashMap与HashTable的区别
- HashMap是线程不安全的,HashTable是线程安全的
- HashMap效率更高,因为HashTable内部方法基本都有synchronized修饰,有额外的内存开销
- HashMap可以有为null的key和value,但HashTable不可以
- HashMap的默认容量为16,若给定了初始容量,会扩充为2的幂次方,而HashTable默认容量为11,若给定了初始容量,直接使用该初始容量,并且HashTable的每次扩容是变为原来的2n+1,HashTable基本上被淘汰了,保证线程安全可以使用ConcurrentHashMap
ConcurrentHashMap是怎么实现的
JDK 1.8 ConcurrentHashMap使用了数组+链表/红黑树实现,主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:
- 若为空则使用volatile加CAS来初始化
- 如果容器不为空,则根据存储的元素计算该位置是否为空
- 如果根据存储的元素计算结果为空,则利用CAS设置该节点
- 如果存储的元素计算结果不为空,则使用synchronized,然后遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否转为红黑树,这样就能保证并发访问时的线程安全了
已经用了synchronized,为什么还要用CAS?
使用synchronized还是CAS主要看线程竞争锁的激烈程度
比如:在putVal中,如果计算出来的hash槽没有存放元素,那么就可以直接使用CAS来进行设置值,这是因为在设置元素的时候,因为hash值经过了各种扰动后,造成hash碰撞的几率较低,那么我们可以预测使用较少的自旋来完成具体的hash落槽操作。
当发生了hash碰撞的时候说明容量不够用了或者已经有大量线程访问了,因此这时候使用synchronized来处理hash碰撞比CAS效率要高,因为发生了hash碰撞大概率来说是线程竞争比较强烈。
ConcurrentHashMap用了悲观锁还是乐观锁
都有用到,添加元素时首先会判断容器是否为空:
- 如果为空,就会使用volatile和乐观锁(CAS)来初始化
- 如果不为空,则根据存储的元素计算该位置是否为空
- 如果根据存储的元素计算结果为空,使用CAS设置该节点
- 如果不为空,则使用悲观锁(synchronized) ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
HashTable底层实现原理是什么
- HashTable的底层数据结构主要是数组加上链表
- HashTable使用synchronized关键字修饰所有公共方法,当一个线程持有锁的时候,另一个线程也访问的时候,就会陷入阻塞或者轮询状态。
HashTable如何保证线程安全
将put,get做成了同步方法,但因为synchronized不释放锁的情况下其他线程会陷入阻塞状态,在高并发场景下效率并不高,还可能会遇到死锁的情况。
Set
有序的set是什么,记录插入顺序的集合是什么
- 有序的set是TreeSet和LinkedHashSet:TreeSet基于红黑树实现,保证元素的自然顺序。LinkedHashSet基于双向链表和哈希表的结合来实现元素的有序存储
- 记录插入顺序的是LinkedHashSet:它不仅保证元素的唯一性,还可以保持元素的插入顺序。
Java并发编程
多线程
使用多线程需要注意哪些问题
要保证多线程的线程安全,不要造成线程之间竞争导致出现数据不一致的情况
线程安全主要需要保证以下方面:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用atomic和synchronized关键字来保证原子性
- 可见性:一个线程对主内存的修改可以及时被其他线程看到,在java中使用volatile和synchronized关键字来保证可见性
- 有序性:一个线程观察其他线程中的指令执行顺序,因为指令重排,观察结果一般是杂乱无序的,在java中使用happens-before原则来保证有序性
保证数据一致性的方案有哪些
- 事务管理:使用数据库事务来确保一组数据库操作要么全部成功,要么全部失败回滚,通过ACID特性,数据库事务可以保证数据的一致性
- 锁机制:使用锁来实现对线程共享资源的互斥访问,在java中,可以使用synchronized关键字,ReentrantLock或其他锁机制来控制并发访问
- 版本控制:通过乐观锁的机制,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性
线程创建的方式有哪些
-
继承Thread类
继承Thread类并重写run方法,run方法体内编写线程具体要执行的任务,创建给类的实例后,通过start方法来启动线程
//线程池的创建方式1 public class MyThread extends Thread{ @Override public void run() { System.out.println("我是线程"+this); } } class TestThread{ public static void main(String[] args) { MyThread myThread = new MyThread(); myThread.start(); } }
-
实现Runnable接口
因为一个类如果已经继承了其他类的情况下,不能再通过继承Thread类的方式来创建线程,这时候就可以通过Runnable接口来创建线程
public class RunnableThread implements Runnable { @Override public void run() { System.out.println("我是线程"+Thread.currentThread().getName()); } } class Test{ public static void main(String[] args) { Thread thread = new Thread(new RunnableThread()); thread.start(); } }
使用Runnable创建线程的优缺点:
- 优点:线程类只是实现了Runnable接口,还可以继承其他类。在这种方式下,多个线程共享同一目标对象,非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好的体现了面向对象思想
- 缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法
-
实现Callable接口和FutureTask
JUC下的callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常,要执行Callable任务,需要先将其包装成一个FutureTask。
public class CallableThread implements Callable<Integer> { @Override public Integer call() throws Exception { System.out.println("我是Callable创建的线程"+Thread.currentThread().getName()); return 1; } } class TestCallable{ public static void main(String[] args) { CallableThread task = new CallableThread(); FutureTask<Integer> futureTask = new FutureTask<>(task); Thread thread = new Thread(futureTask); thread.start(); try { Integer res = futureTask.get(); System.out.println("Res:"+res); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } }
-
使用线程池(Executor框架)
使用JUC的ExecutorService和相关类,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销,可以通过Executors类的静态方法创建不同类型的线程池。
public class Task implements Runnable { @Override public void run() { System.out.println("我是线程"+Thread.currentThread().getName()); } } class TestExecutor{ public static void main(String[] args) { ExecutorService excutor = Executors.newFixedThreadPool(10); for (int i = 0; i < 100; i++) { excutor.submit(new Task()); } excutor.shutdown(); } }
采用线程池的方式增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查的时候,错误的配置可能导致死锁,资源耗尽等问题,这些问题的诊断和修复比较复杂。但线程池可以重用预先创建的线程,避免了频繁的创建与销毁线程,减少了内存的开销,性能较好,对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。
如何停止一个线程的运行
- 异常法停止:线程调用interrupt方法后,在线程run方法中判断当前对象的interrupt状态,如果是中断状态则抛出异常,达到中断线程的效果
- 在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果
- stop()暴力停止:线程调用stop方法会被暴力停止,该方法已经弃用,因为强制让线程停止有可能使一些清理性的工作得不到完成。
- 使用return停止线程:调用interrupt标记为中断状态之后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。
调用interrupt是如何让线程抛出异常的
每个线程都有一个与之关联的boolean属性来表示其中断状态,中断状态的初始值为false,当一个线程被其他线程调用Thread.interrupt方法中断时,会根据实际情况做出响应。
- 如果该线程正在执行低级别的可中断方法(如Thread.sleep()、Thread.join()或Object.wait()),则会解除阻塞并抛出InterruptedException异常
- 否则Thread.interrupt仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务。
线程的状态有哪些
- NEW:创建了线程但线程尚未启动
- RUNNABLE:就绪状态(调用start,等待调度)+正在运行
- BLOCKED:等待监视器锁时,陷入阻塞状态
- WAITING:线程正在等待另一个正在执行的线程执行特定的操作(如notify)
- TIMED_WAITING:具有指定等待时间的等待状态
- TERMINATED:线程完成执行,终止状态
阻塞和等待状态有何不同
- 触发条件:阻塞状态一般是因为试图获取一个对象的锁时,该锁已经被另一个线程占用,这通常发生在synchronized块或方法中,如果锁被占用,则需要一直维持阻塞状态直到锁被释放。而等待状态是因为该线程在等待另一个线程执行某些操作,例如调用Object.wait(),Thread.join()。在这种状态下,线程将不会消耗CPU资源,并且不会参与锁的竞争
- 唤醒方式:当一个线程阻塞时,一旦锁被释放,线程将有机会获取该锁,如果锁此时未被其他线程获取,那么线程就可以从阻塞状态变为RUNNABLE状态。线程在等待状态时需要被显式唤醒。例如,一个线程调用了Object.wait()方法,那么就必须等待另一个线程调用同一个对象上的Object.notify()方法或Object.notifyAll()方法才能被唤醒。
notify选择那个线程唤醒
notify唤醒的线程是任意的,但依赖于具体实现的jvm。而JVM有很多实现,比较流行的就是hotspot,hotspot的实现是“先进先出”的顺序唤醒
如何保证多线程安全
- synchronized关键字:使用synchronized来同步代码块或方法,确保同一时刻只有一个线程能访问这些代码,对象锁是通过synchronized关键字锁定对象的监视器来实现的
- volatile:确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器的副本
- ReentrantLock:java.util.concurrent.locks.Lock接口提供了比synchronized更强大的锁定机制,ReentrantLock是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能
- 原子类:JUC提供了一些原子类,如AtomicInteger,AtomicLong等,这些类提供了原子性操作,可以用于更新基本类型的变量而无需额外的同步
- 线程局部变量:ThreadLocal类可以为每个线程提供独立的变量副本,这样每个线程都拥有自己的变量,消除了竞争条件。
- 并发集合:juc提供的一些线程安全集合例如ConcurrentHashMap,ConcurrentLinkedQueue
- JUC工具类:使用juc提供的一些工具类可以控制线程之间的同步和协作,例如Semaphore,CyclicBarrier等
Java中有哪些常用的锁,在什么场景下使用
- synchronized:syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。
- ReentrantLock:公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。
- 读写锁:允许多个读取者同时访问共享资源,但只允许有一个写入者,通常用于读远多于写的场景,提高并发性
- 乐观锁和悲观锁:悲观锁通常在访问资源前就会锁定,假设数据已经被其他线程修改了,synchronized和ReentrantLock都是悲观锁。而乐观锁并不会锁定资源,而是在更新数据时检查数据是否已经被修改,乐观锁通常使用版本号或时间戳来实现。
- 自旋锁:是一种锁机制,线程会在等待锁的时候持续循环检查锁是否可用,而不是放弃CPU并阻塞,通常可以使用CAS实现。适用于锁等待时间很短的情况,可以提高性能,但过度自旋会浪费CPU资源。
synchronized和ReentrantLock的工作原理
-
synchronized:
synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁。
使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,它依赖操作系统底层互斥锁实现。它的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一 一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。
从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。
-
ReentrantLock:
ReentrantLock 的底层实现主要依赖于 AbstractQueuedSynchronizer(AQS)这个抽象类。AQS 是一个提供了基本同步机制的框架,其中包括了队列、状态值等。
ReentrantLock 在 AQS 的基础上通过内部类 Sync 来实现具体的锁操作。不同的 Sync 子类实现了公平锁和非公平锁的不同逻辑:
- 可中断性: ReentrantLock 实现了可中断性,这意味着线程在等待锁的过程中,可以被其他线程中断而提前结束等待。在底层,ReentrantLock 使用了与 LockSupport.park() 和 LockSupport.unpark() 相关的机制来实现可中断性。
- 设置超时时间: ReentrantLock 支持在尝试获取锁时设置超时时间,即等待一定时间后如果还未获得锁,则放弃锁的获取。这是通过内部的 tryAcquireNanos 方法来实现的。
- 公平锁和非公平锁: 在直接创建 ReentrantLock 对象时,默认情况下是非公平锁。公平锁是按照线程等待的顺序来获取锁,而非公平锁则允许多个线程在同一时刻竞争锁,不考虑它们申请锁的顺序。公平锁可以通过在创建 ReentrantLock 时传入 true 来设置。
多个条件变量: ReentrantLock 支持多个条件变量,每个条件变量可以与一个 ReentrantLock 关联。这使得线程可以更灵活地进行等待和唤醒操作,而不仅仅是基于对象监视器的 wait() 和 notify()。多个条件变量的实现依赖于 Condition 接口。
可重入性: ReentrantLock 支持可重入性,即同一个线程可以多次获得同一把锁,而不会造成死锁。这是通过内部的 holdCount 计数来实现的。当一个线程多次获取锁时,holdCount 递增,释放锁时递减,只有当 holdCount 为零时,其他线程才有机会获取锁。
什么是可重入锁
可重入锁是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁的时候,如果再次尝试会成功获取而不会被阻塞
ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。
- 当一个线程第一次获取锁时,计数器会加一,表示该线程持有锁。之后如果这个线程又获取到了锁,计数器会再次加一。每次线程成功获取到锁,都会将计数器加一。
- 当线程释放锁的时候,计数器会减一,只有当计数器减少到0时,锁才会完全释放,其他线程才有机会获取锁。
synchronized支持重入吗?如何实现?
synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁是被允许的,这就是synchronized可重入性
synchronized底层是利用计算机系统的mutex Lock实现的,每一个可重入锁都会关联一个线程ID和一个锁状态status
当一个线程请求方法时,会检查锁的状态
- 如果锁的状态是0,代表该锁没有被占用,使用CAS操作获取锁,将线程ID换成自己的线程ID
- 如果锁状态不是0,代表有线程在访问该方法。此时如果线程ID是自己的线程ID且是可重入锁,那么status会自增1,然后获取到该锁 ,进而执行相应的方法;如果是非重入锁,就会进入阻塞队列等待
在释放锁的时候
- 如果是可重入锁,每一次退出方法,status就会减一,直至为0,最后释放该锁
- 如果是非重入锁,线程退出方法就会直接释放锁。
synchronized锁升级的过程是怎样的
具体过程是:无锁->偏向锁->轻量级锁->重量级锁
- 无锁:这是没有开启偏向锁时候的状态,在jdk1.6之后偏向锁是默认开启的,但有一个偏向延迟,即:在JVM启动后的多少秒之后才能开启,可以通过jvm的参数设置,同时是否开启偏向锁也可以通过jvm参数设置
- 偏向锁:这个是在偏向锁开启之后锁的状态,如果此时没有一个线程获取到该锁,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID和MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
- 轻量级锁:在这个状态下线程主要是通过CAS操作实现的,将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
- 重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没成功的话始终在自旋,进行while循环操作,这是非常消耗cpu资源的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节省cpu资源。
那么升级过程就是:线程进入synchronized开始竞争锁,JVM会判断当前是否是偏向锁的状态,如果是就会根据Mark Word中存储的线程ID来判断,当前线程是否为持有偏向锁的状态,如果是,则忽略check,线程直接执行临界区内的代码。
但如果MarkWord里不是该线程,就会通过自旋重新尝试获取锁,如果获取到了,就将MarkWord中的线程ID改为自己的。如果竞争失败,就会立马撤销偏向锁,升级为轻量级锁
后续的竞争线程都会通过自旋锁来尝试获取锁,如果自旋成功那么锁的状态仍为轻量级锁。如果竞争仍然失败,就会膨胀为重量级锁,后续等待的竞争线程都会被阻塞。
JVM对synchronized的优化
- 锁升级
- 锁消除:jvm如果检测不到某段代码被共享和竞争的可能,就会将这段代码的同步锁给消除掉,提高程序性能
- 锁粗化:将多个连续加锁,解锁的操作连接到一起,扩展成一个范围更大的锁。
- 自适应自旋锁:通过自身循环,尝试获取锁的一种方式,优点在于它避免了一些线程的挂起和恢复操作,因为挂起和恢复线程都需要从用户态转化为内核态,这个过程较慢,所以通过自旋的方式可以在一定程度上避免线程挂起和恢复所造成的性能开销。
什么是AQS
AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)。
AQS的核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源锁定,如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁的分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
AQS通过使用一个volatile的int类型成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对state值的修改
AQS主要完成的任务:
- 同步状态(比如说计数器)的原子性管理
- 线程的阻塞和解除阻塞
- 队列的管理
AQS的原理
AQS最核心的就是三大部分
- 状态state
- 这里state的具体含义,会根据具体实现类的不同而不同:例如在Semaphore中,它表示剩余许可证的数量;在CountDownLatch中,它表示还需要倒数的数量;在ReentrantLock中,state用来表示锁的占有情况,包括可重入计数,当state为0时,标识该lock不被任何线程所占有。
- state是volatile修饰的,并被并发修改,所以修改state的方法都需要保证线程安全,例如getState方法和setState方法以及compareAndSetState操作来读取和更新这个状态,这些方法都依赖于Unsafe类
- 控制线程竞争锁和配合的FIFO队列
- FIFO队列用来存放等待的线程,AQS就是”排队管理器“,当多个线程竞争同一把锁的时候,必须要有排队机制将那些没能拿到锁的线程串到一起。当锁释放的时候,锁管理器就会挑选一个适合的线程来持有这把锁。
- AQS会维护这个等待的线程队列,把线程都放到这个队列里,这个队列的形式是双向链表。
- 期望协作工具类去实现的获取/释放等重要方法(重写)
- 获取和释放方法是利用AQS的协作工具类里最重要的方法,是由协作类自己去实现的,并且含义各不相同
- 获取方法:获取操作会以state为变量,经常会阻塞。在semaphore中,获取就是acquire方法,作用是获取一个许可证。在CountDownLatch中,获取就是await方法,作用是等待直至倒数结束
- 释放方法:在semaphore中,使用release释放一个许可证,在countdownlatch中,使用countDown方法实现倒数的减一
- 需要每个实现类重写tryAcquire和tryRelease方法
ThreadLocal作用,原理,里面的key,value具体存的什么,会有什么问题,如何解决?
ThreadLocal是java中解决线程安全问题的一种机制,它允许创建线程局部变量,即每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题
- Thread类中,有个ThreadLocal.ThreadLocalMap的成员变量
- ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值
ThreadLocal的作用
- 线程隔离:ThreadLocal为每个线程提供了独立的变量副本,这意味着线程之间不会相互影响,可以安全地在多线程环境中使用这些变量而不必担心数据竞争或同步问题。
- 降低耦合度:在同一个线程内的多个函数或组件之间,使用ThreadLocal可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。
- 性能优势:由于ThreadLocal避免了线程间的同步开销,所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。
ThreaLocal的原理
ThreadLocal的实现依赖于Thread类中的一个ThreadLocalMap字段,这是一个存储ThreadLocal变量本身和对应值的映射。每个线程都有自己的ThreadLocalMap实例,用于存储该线程所持有的所有ThreadLocal变量的值。
当创建一个ThreadLocal变量时,它实际上就是一个ThreadLocal对象的实例。每个ThreadLocal对象都可以存储任意类型的值,这个值对每个线程来说是独立的。
- 当调用ThreadLocal的get()方法时,ThreadLocal会检查当前线程的ThreadLocalMap中是否有与之关联的值。
- 如果有,返回该值;
- 如果没有,会调用initialValue()方法(如果重写了的话)来初始化该值,然后将其放入ThreadLocalMap中并返回。
- 当调用set()方法时,ThreadLocal会将给定的值与当前线程关联起来,即在当前线程的ThreadLocalMap中存储一个键值对,键是ThreadLocal对象自身,值是传入的值。
- 当调用remove()方法时,会从当前线程的ThreadLocalMap中移除与该ThreadLocal对象关联的条目。
可能存在的问题
当一个线程结束时,其ThreadLocalMap会随之销毁,但是ThreadLocal对象本身不会被垃圾回收,直到没有其他引用指向它为止。
因此在使用ThreadLocal变量时需要注意,如果不显式地调用remove方法,或者线程结束时为正确清理ThreadLocal变量,可能会导致内存泄漏,因为ThreadLocalMap会持续持有ThreadLocal变量的引用,即使这些变量不再被其他地方引用。
因此在使用完ThreadLocal后需要调用remove方法释放资源
CAS有什么缺点
- ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。Java中有AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。
- 循环时间长开销大:自旋CAS的操作如果长时间不成功,会给CPU带来很大的开销
- 只能保证一个共享变量的原子性操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。
指令重排的原理
在执行程序时,为了提高性能,处理器和编译器通常会对指令进行重排序,但是重排序必须遵循以下两个条件才能执行
- 在单线程情况下不能改变程序的运行结果
- 存在数据依赖关系的不允许重排序
所以重排不会对单线程造成影响,只会破坏多线程的执行语义
volatile可以保证线程安全吗
不能完全保证,因为volatile只能保证可见性,不能保证原子性。如果遇到多线程并发下的复合操作问题,比如i++这种操作并不是原子性操作,如果多线程同时进行这个操作,volatile不能保证线程安全,需要使用synchronized或Lock类来保证原子性和线程安全。
非公平锁的吞吐量为什么比公平锁大
- 公平锁执行流程:获取锁时,先将线程自己添加到队尾并且休眠,当某个线程用完锁后,会去唤醒等待队列队首的线程尝试去获取锁,使用顺序也就是队列顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态切换到运行状态,但每次休眠与唤醒都需呀从用户态转换到内核态,而这个过程比较慢。
- 非公平锁执行流程:当线程获取锁时,会先通过CAS获取锁,如果成功就直接拥有锁,如果失败才会进入等待队列,等待下次尝试获取锁。这样在获取锁时不用遵循先到先得的规则,从而避免了从休眠状态与运行状态的切换,加速了执行效率。
ReentrantLock是如何实现公平锁的
从源码中得出,公平锁与非公平锁最大的区别在于,一个线程尝试去获取锁的时候,会先看一下等待队列是否已经有线程在排队了,如果有,那就不会去尝试获取锁了,而是直接进入队列排队,而非公平锁在获取锁时不会先看是否有线程在排队,而是直接尝试获取锁,如果获取失败了,才会进入队列排队。
而如果有线程执行tryLock方法的时候,一旦有线程释放了锁,那么这个正在trylock的线程就会去尝试获取锁,即使设置的是公平锁的模式,简单来说就是tryLock可以插队。
线程池
线程池的工作原理是怎样的
线程池是为了减少频繁创建和销毁线程所带来的性能损耗,线程池的工作原理如图:
线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程数,如果增加后的线程数大于最大的线程数,就会按照一些丢弃的策略进行处理。
线程池的七大参数
- corePoolSize:核心线程池大小,默认情况下,如果线程池中的线程数量<=corePoolSize,那么即使这些线程处于空闲状态也不会被销毁
- maximumPoolSize:线程池中可容纳的最大线程数,当一个新任务交给线程池时,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程且当前线程池的线程数量小于corePoolSize,就会创建新的线程来执行任务,否则就会放入阻塞队列中,如果阻塞队列满了,就会创建一个新的线程,从阻塞队列头部取出一个任务来执行,并将新任务添加到队尾,如果当前线程池中的线程数已经等于maximumPoolSize,就不会执行该任务,而是执行拒绝策略。
- keepAliveTime:当线程池中线程的数量大于核心线程池大小,并且某个线程的空闲时间超过了keepAliveTime,就会被销毁
- unit:就是keepAliveTime的时间单位
- workQueue:工作队列,当没有空闲的线程执行新任务时,该线程就会被放到工作队列中,等待执行
- threadFactory:线程工厂,可以用来给线程起名字
- handler:拒绝策略
线程池的拒绝策略有哪些
- AbortPolicy(默认拒绝策略):直接抛出一个任务被线程池拒绝的异常
- CallerRunsPolicy:使用线程池的调用者所在的线程去执行被拒绝的任务,除非线程池被停止或者线程池的任务队列已经有空缺。
- DiscardPolicy:不做任何处理,静默拒绝提交的任务
- DiscardOldestPolicy:抛弃最老的任务,然后执行该任务
此外,也可以通过实现接口自定义拒绝策略
线程池参数如何设置呢
- cpu密集型:corePoolSize = cpu核数 + 1
- IO密集型:corePoolSize = cpu核数*2
核心线程数可以设置为0吗
可以,即使核心线程数设置为0,在一个任务提交到线程池中时,先会添加到任务队列,同时判断当前工作的线程数是否为0,如果为0,就会创建线程来执行线程池的任务。
线程池的种类有哪些
- ScheduledThreadPool:可以设置定期的执行任务,比如每隔10s执行一次任务,可以通过这个实现类设置定期执行任务的策略。
- FixedThreadPool:核心线程数和最大线程数是相等的,因此可以看成是固定大小的线程池,它的特点是线程池中的线程数除了初始阶段需要从0开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会创建更多的线程来执行任务,而是会把超出线程能力的任务放到任务队列中等待,而且就算任务队列满了,到了本该增加线程的时候,但是核心线程数和最大线程数是相同的,也无法增加新的线程
- CachedThreadPool:可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。
- SingleThreadExecutor:它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
- SingleThreadScheduledExecutor:它实际和 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程。
线程池一般是怎么用的
Java中Executor类定义了一些快捷的工具方法,帮助我们快速创建线程池,但在《阿里巴巴Java开发手册中》提到,禁止用这些方法创建线程池,而是应该手动new ThreadPoolExecutor来创建线程池。因为会产生生产事故,最典型的就是newFixedThreadPool和newCacheThreadPool,可能因为资源耗尽导致OOM问题
因此需要手动创建线程池:
- 根据自己的场景,并发情况来评估线程池的核心参数,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数
- 任何时候,都应该为自定义线程池指定有意义的名称,以方便排查问题。当线程池出现线程数量暴增,线程死锁,线程占用大量CPU,线程执行出现异常等问题时,往往会抓取线程栈。此时有意义的名称就可以方便我们定位问题。
除了建议手动声明线程池以外,我还建议用一些监控手段来观察线程池的状态。线程池这个组件往往会表现得任劳任怨、默默无闻,除非是出现了拒绝策略,否则压力再大都不会抛出一个异常。如果我们能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。
线程池中shutdown和shutdownNow方法有什么作用
- shutdown使用了以后会置状态为SHUTDOWN,正在执行的任务会继续执行下去,没有被执行的则中断。此时,则不能再往线程池中添加任何任务,否则将会抛出 RejectedExecutionException 异常
- 而 shutdownNow 为STOP,并试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,当然,它会返回那些未执行的任务。 它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,但是这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt()方法是无法中断当前的线程的。所以,ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出。
提交给线程池中的任务可以被撤回吗
可以,当向线程池提交任务时,会得到一个Future对象。这个Future对象提供了几种方法来管理任务的执行,包括取消任务。取消任务的主要方法是Future接口中的cancel(boolean mayInterruptIfRunning)方法。这个方法尝试取消执行的任务。参数mayInterruptIfRunning指示是否允许中断正在执行的任务。如果设置为true,则表示如果任务已经开始执行,那么允许中断任务;如果设置为false,任务已经开始执行则不会被中断。
JVM
内存模型
介绍一下JVM的内存模型
JVM运行时内存共分为虚拟机、堆、栈、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的
- 元空间:元空间的本质和永久代(JDK7之前,方法区被称为永久代,是堆的一部分,用于存储类信息,方法信息,常量池信息等静态信息)类似,但元空间与永久代的最大区别在于:元空间并不在虚拟机中,而是使用本地内存。
- Java虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个线程会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
- 本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
- 程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任一确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。
- 堆内存:堆内存是JVM所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可以通过GC进行回收
- 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
JVM内存模型里的堆和栈的区别?
- 用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)。当使用new关键字创建一个对象时,对象的实例就会在堆上分配空间。
- 生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在GC检测对象不再被引用时才被回收。
- 存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出的原则,操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
- 存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
- 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的数据。
回答:
- 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快
- 堆存放的对象是实例和数组;栈存放的是局部变量,操作数栈,返回结果等
- 堆是线程共享的,栈是线程私有的。
栈中存的到底是指针还是对象
在JVM内存模型中,栈主要用于管理线程的局部变量和方法调用的上下文,而堆则是用于存储所有类的实例和数组。
当我们在栈中讨论“存储”时,实际上指的是存储基本类型和对象的引用,而不是对象的本身。
栈中存储的不是对象,而是对象的引用。也就是说,当在方法中声明一个对象,例如:MyObject obj = new MyObject();这里的obj实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据,它指向堆中分配给对象的内存区域。
堆分为哪些部分?
堆主要用于存放对象实例和数组,随着JVM的发展和不同垃圾收集器的实现,堆的具体划分可能会不同,但通常为以下几部分:
- 新生代(young generation):新生代分为Eden Space和Survivor Space。在Eden Space中,大多数新创建的对象首先存放在这里。Eden区相对较小,当Eden区满时,会触发一次Minor GC(新生代垃圾回收)。在Survivor Space中,通常分为两个大小相等的区域,称为S0(Survivor0)和S1(Survivor1)。在每次新生代垃圾回收之后,存活下来的对象会被移动到其中一个Survivor空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
- 老年代(old generation / Thread generation):存放过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Minor GC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
程序计数器的作用,为什么是私有的
Java程序是支持多线程一起运行的,多个线程一起运行的时候CPU会有一个调动器组件给它们分配时间片,比如说会分给线程1 一个时间片,它在时间片内如果它的代码没有执行完,它就会把线程1的状态执行一个暂存,切换到线程2去,执行线程2的代码,等线程2的代码执行到了一定程度,线程2的时间片用完了,再切换回来,再执行线程1剩余部分的代码。
如果在线程切换的过程中,下一条指令执行到哪里了,还是需要用到程序计数器,因此每个线程都需要有自己的程序计数器,因为它们各自执行代码的指令地址是不一样的。
方法区中方法的执行过程
- 解析方法调用:JVM会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
- 栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等。
- 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。
方法区中还有哪些东西
- 类信息:包括类的结构信息,类的访问修饰符,父类与接口等信息
- 常量池:存储类和接口中的常量,包括字面值常量,符号引用,以及运行时常量池
- 静态变量:存储类的静态变量,这些变量在类初始化的时候被复制
- 方法字节码:存储类的方法字节码,即编译后的代码
- 符号引用:存储类和方法的符号引用,是一种直接引用不同于直接引用的引用类型。
- 运行时常量池:存储着在类文件中的常量池数据,在类加载后在方法区生成该运行时常量池
- 常量池缓存:用于提升类加载的效率,将常用的常量缓存起来方便使用
引用类型有哪些?有什么区别
主要分为强软弱虚
- 强引用类型:指的就是代码中普遍存在的赋值方式,例如A a = new A();这种,强引用关联的对象,永远不会被gc回收
- 软引用:可以用softReference来描述,指的是那些有用但是不是必要的对象,会在发生内存溢出前进行回收。
- 弱引用:WeakReference,强度比软引用更低一点,弱引用的对象在下一次GC中一定会被回收,而不管内存是否足够
- 虚引用:也称作幻影引用,是最弱的引用关系,即PhantomReference,必须和ReferenceQueue一起使用,同样当GC发生的时候,虚引用也会被回收。可以用虚引用管理堆外内存。
弱引用了解吗?举例说明可以用在哪里?
弱引用是一种引用类型,它不会阻止一个对象被垃圾回收
在Java中,弱引用是通过java.lang.ref.WeakReference类实现的。弱引用的一个主要用途是创建非强制性的对象引用,这些引用可以在内存压力大时被gc清理,从而避免内存泄漏。
弱引用的使用场景:
- 缓存系统:弱引用常用于实现缓存,特别是当希望缓存项能够在内存压力下自动释放时。如果缓存的大小不受控制,可能会导致内存溢出。使用弱引用来维护缓存,可以让JVM在需要更多内存时自动清理这些缓存对象。
- 对象池:在对象池中,弱引用可以用来管理那些暂时不使用的对象。当对象不再被引用时,它们可以被垃圾回收,释放内存。
- 避免内存泄漏:当一个对象不应该被长期引用时,使用弱引用可以防止该对象被意外的保留,从而避免潜在的内存泄漏。
假设有一个缓存系统,使用弱引用来维护缓存中的对象
public class CacheExample {
private Map<String, WeakReference<MyHeavyObject>> map = new HashMap<>();
public MyHeavyObject get(String key){
WeakReference<MyHeavyObject> ref = map.get(key);
if (ref != null) return ref.get();
else {
MyHeavyObject heavyObject = new MyHeavyObject();
map.put(key,new WeakReference<>(heavyObject));
return heavyObject;
}
}
private static class MyHeavyObject{
private byte[] largeData = new byte[1024*1024*10];
}
}
在这个例子中,使用WeakReference来存储MyHeavyObject实例,当内存压力增大时,垃圾回收器可以自由地回收这些对象,而不会影响缓存的正常运行。
如果一个对象被垃圾回收,下次尝试从缓存中获取时,get()方法会返回null,这时我们可以重新创建对象并将其放入缓存中。因此,使用弱引用时要注意,一旦对象被垃圾回收,通过弱引用获取的对象可能会变为null,因此在使用前通常需要检查这一点。
内存泄漏和内存溢出的理解?
内存泄漏:指的是在程序运行过程中不再使用的对象仍然被引用,而无法被垃圾收集器回收,从而导致可用内存逐渐减少。虽然在Java中,gc会自动回收不再使用的对象,但如果有对象仍被不再使用的引用持有,gc无法回收这些内存,最终导致程序的内存使用不断增加
内存泄漏的常见原因:
- 静态集合:使用静态数据结构(如HashMap或ArrayList)存储对象,且未清理
- 事件监听:未取消对事件源的监听,导致对象持续被引用
- 线程:未停止的线程可能持有对象引用,无法被回收
内存溢出:指的是JVM在申请内存时,无法找到足够的内存,最终引发OutOfMemoryError。这通常发生在堆内存不足以存放新创建的对象时。
内存溢出常见原因:
- 大量对象创建:程序中不断创建大量对象,超出JVM堆的限制
- 持久引用:大型数据结构(如缓存,集合等)长时间持有对象引用,导致内存累积
- 递归调用:深度递归导致栈溢出
JVM内存结构有哪几种内存溢出的情况
- 堆内存溢出:当出现java.lang.OutOfMemoryError:Java heap space异常时,就是堆内存溢出了。原因是代码中可能存在大对象分配,或者发生了内存泄露,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。
- 栈溢出:如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError;当然,如果 JVM 试图去扩展栈空间的时候失败,则会抛出 OutOfMemoryError。
- 元空间溢出:元空间的溢出,系统会抛出java.lang.OutOfMemoryError: Metaspace。出现这个异常的问题的原因是系统的代码非常多或引用的第三方包非常多或者通过动态代码生成类加载等方法,导致元空间的内存占用很大。
- 直接内存溢出:在使用ByteBuffer中的allocateDirect()的时候会用到,很多javaNIO(像netty)的框架中被封装为其他的方法,出现该问题时会抛出java.lang.OutOfMemoryError: Direct buffer memory异常。
有具体的内存泄漏和内存溢出的例子么请举例以及解决方案
-
静态属性导致内存泄漏
会导致内存泄露的一种情况就是大量使用static静态变量。在Java中,静态属性的生命周期通常伴随着应用整个生命周期(除非ClassLoader符合垃圾回收的条件)。下面来看一个具体的会导致内存泄露的实例:
public class StaticTest { public static List<Double> list = new ArrayList<>(); public void populateList(){ for (int i = 0; i < 10000000; i++) { list.add(Math.random()); } System.out.println("Debug point2"); } public static void main(String[] args) { System.out.println("Debug point1"); new StaticTest().populateList(); System.out.println("Debug point3"); } }
如果监控内存堆内存的变化,会发现在打印Point1和Point2之间,堆内存会有一个明显的增长趋势图。但当执行完populateList方法之后,对堆内存并没有被垃圾回收器进行回收。
但针对上述程序,如果将定义list的变量前的static关键字去掉,再次执行程序,会发现内存发生了具体的变化。VisualVM监控信息如下图:
对比两个图可以看出,程序执行的前半部分内存使用情况都一样,但当执行完populateList方法之后,后者不再有引用指向对应的数据,垃圾回收器便进行了回收操作。因此,我们要十分留意static的变量,如果集合或大量的对象定义为static的,它们会停留在整个应用程序的生命周期当中。而它们所占用的内存空间,本可以用于其他地方。
那么如何优化?第一,减少静态变量,第二,如果使用单例,尽量采用懒加载。
-
未关闭的资源
无论什么时候当我们创建一个连接或打开一个流,JVM都会分配给内存这些资源。比如,数据库链接、输入流和session对象。
忘记关闭这些资源,会阻塞内存,从而导致GC无法进行清理。特别是当程序发生异常时,没有在finally中进行资源关闭的情况。这些未正常关闭的连接,如果不进行处理,轻则影响程序性能,重则导致OutOfMemoryError异常发生。
如果进行处理呢?第一,始终记得在finally中进行资源的关闭;第二,关闭连接的自身代码不能发生异常;第三,Java7以上版本可使用try-with-resources代码方式进行资源关闭。
-
使用ThreadLocal
ThreadLocal提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal相当于提供了一种线程隔离,将变量与线程相绑定,从而实现线程安全的特性。
ThreadLocal的实现中,每个Thread维护一个ThreadLocalMap映射表,key是ThreadLocal实例本身,value是真正需要存储的Object。
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统GC时,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。
如果当前线程迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
如何解决此问题?
-
使用ThreadLocal提供的remove()方法,可对当前线程中的value值进行移除;
-
不要使用ThreadLocal.set(null)的方式清除value,它实际上并没有清除值,而是查找与当前线程关联的Map并将键值对分别设置为当前线程和null
-
最好将ThreadLocal视为finally块中关闭的资源,以确保即使在发生异常的情况下也是种关闭该资源
try{ threadLocal.set(System.nanoTime()); }finally{ threadLocal.remove(); }
-
类初始化和类加载
创建对象的过程
在Java中创建对象的过程包括以下几个步骤:
- 类加载检查:虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
- 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在java代码中可以不赋初始就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的gc分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行init方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角看,对象创建才刚开始——构造函数,即class文件中的方法还没有执行,所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行new指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个可用的对象才算完全被构建出来。
对象的生命周期
- 创建:通过new关键字在堆内存中被实例化,构造函数被调用,对象的内存空间被分配
- 使用:对象被引用并执行相应的操作,可以通过引用访问对象的属性和方法,在程序运行过程中不断被使用。
- 销毁:当对象不再被使用时,通过gc自动回收对象所占用的内存空间。垃圾回收器会在适当的时候检测并回收不再被引用的对象,释放对象占用的内存空间,完成对象的销毁过程。
类加载器有哪些
- 启动类加载器:最顶层的类加载器,负责加载Java的核心库(如位于jre/lib/rt.jar中的类),是jvm的一部分,无法被Java程序直接调用
- 扩展类加载器:是Java语言实现的,继承自ClassLoader类,负责加载Java扩展目录(jre/lib/ext或由系统变量java.ext.dirs指定的目录)下的jar包和类库。扩展类加载器由启动类加载器加载,并且父加载器就是启动类加载器。
- 系统类加载器/应用程序类加载器:也是java实现的,负责加载用户类路径(ClassPath)上的指定类库,是平时编写java程序时默认使用的类加载器。系统类加载器的父加载器是扩展类加载器。它可以通过ClassLoader.getSystemClassLoader()方法获取到。
- 自定义类加载器:开发者可以根据需求定制类的加载方式,比如从网络加载class文件、数据库、甚至是加密的文件中加载类等。自定义类加载器可以用来扩展Java应用程序的灵活性和安全性,是Java动态性的一个重要体现。
这些类加载器之间的关系形成了双亲委派模型,其核心思想是当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。
只有父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派模型的作用
- 保证类的唯一性:通过委托机制,确保了所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也防止了用户自定义类覆盖核心类库的可能。
- 保证安全性:由于Java核心库被启动类加载器加载,而启动类加载器只加载信任的类路径中的类,这样可以防止不可信的类假冒核心类,增强了系统的安全性。例如,恶意代码无法自定义一个java.lang.System类并加载到JVM中,因为这个请求会被委托给启动类加载器,而启动类加载器只会加载标准的Java库中的类。
- 支持隔离和层次划分:双亲委派模型支持不同层次的类加载器服务于不同的类加载需求,如应用程序类加载器加载用户代码,扩展类加载器加载扩展框架,启动类加载器加载核心库。这种层次化的划分有助于实现沙箱安全机制,保证了各个层级类加载器的职责清晰,也便于维护和扩展。
- 简化了加载流程:通过委派,大部分类能够被正确的类加载器加载,减少了每个加载器需要处理的类的数量,简化了类的加载过程,提高了加载效率。
类加载过程
- 加载:通过类的包名+类名获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的java.lang.Class对象作为访问这个类的各种数据的入口。
- 连接:包括验证,准备,解析
- 验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性不会危害到虚拟机安全。验证阶段大致会完成四个阶段的校验动作:文件格式校验、元数据校验、字节码验证、符号引用验证。
- 准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值为0。被final修饰的static字段不会设置,因为final在编译时就被分配了
- 解析:虚拟机将常量池的「符号引用」替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
- 初始化:整个类加载过程的最后阶段,简单来说就是执行类的构造器方法。
- 使用:使用类或者创建对象
- 卸载:以下情况类会被卸载:
- 该类所有实例都已被回收,堆中不存在任何该类的实例
- 加载该类的ClassLoader已被回收
- 类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类方法
垃圾回收
什么是垃圾回收,如何触发?
GC是自动管理内存的一种机制,它负责自动释放不再被程序引用的对象所占用的内存,这种机制减少了内存泄漏和内存管理错误的可能性。垃圾回收可以通过多种方式触发,具体如下:
- 内存不足:当检测到堆内存不足,无法为新的对象分配内存时,会自动触发垃圾回收。
- 手动请求:虽然gc是自动的,可以通过调用System.gc()或Runtime.getRuntime.gc()建议JVM进行垃圾回收
- JVM参数:启动java应用时可以通过JVM参数来调整垃圾回收的行为,比如:-Xmx(最大堆大小),-Xms(初始堆大小)等。
- 对象数量或内存使用达到阈值:垃圾收集器内部实现了一些策略,以监控对象的创建和内存使用,达到某个阈值时触发垃圾回收。
判断为垃圾的方法有哪些?
-
引用计数法:
原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加一;引用失效时,计数器减一。当计数器为0时,表示对象不再被任何变量引用,可以被回收。
缺点:不能解决循环引用的问题,如果两个对象相互引用,但不被其他对象引用,计数器始终不能为0,导致无法被回收。
-
可达性分析算法:
原理:从一组GC Roots,即垃圾收集根(虚拟机、本地方法栈、静态属性、方法区常量引用的对象)出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。GC Roots对象包括:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、本地方法栈中JNI引用的对象、活跃的线程引用等。
垃圾回收算法是什么,是为了解决什么问题
JVM有gc的原因是为了解决内存管理的问题。例如c++,需要自己手动分配和释放内存,这可能导致内存泄漏、内存溢出等问题。而Java提供的gc可以自动管理内存,使开发过程更简单,更安全
gc的主要目标是自动检测和回收不再被使用的对象,从而释放它们所占用的内存空间。这样可以避免内存泄漏,同时,垃圾回收机制还可以防止内存溢出。
通过gc,jvm可以在程序运行时自动识别和清理不再使用的对象,使得开发人员无需手动管理内存。这样可以提高开发效率、减少错误,并且使程序更加可靠和稳定。
垃圾回收算法有哪些
- 标记-清除算法:算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象,但是这种算法有两个缺陷:一个是效率问题,标记和清除的过程效率都不高,另一个就是会造成大量的碎片空间,有可能会导致在申请大块内存的时候没有足够的连续内存空间而再次GC。
- 复制算法:为了解决碎片空间的问题,复制算法会将内存空间分为两块,每次申请内存都使用其中一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后再把已使用的内存整个都清理掉。这种策略又有新的问题:每次申请内存时,都只能使用一般的空间,内存利用率严重不足
- 标记-整理算法:复制算法在gc之后存活对象较少的情况下效率较高,但如果存活对象比较多,会执行比较多的复制操作,效率就会下降。而老年代的对象在GC之后的存活率就比较高,所以就有了标记-整理算法,标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
- 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对象还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。
垃圾回收器有哪些?
垃圾回收算法那些阶段会stop the world
标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:
- 标记阶段,即从GC Roots集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
标记阶段停顿分析:
- 初始标记阶段:是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。
- 并发标记阶段:从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。
- 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。
清理阶段停顿分析:
- 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。
复制阶段停顿分析:
复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。
四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。
MinorGC,majorGC,fullGC的区别,什么场景触发fullGc
-
MinorGC(Young GC)
作用范围:只针对年轻代进行回收,包括Eden区和两个Survivor区(S0和S1)
触发条件:当Eden区不足时,JVM会触发一次MinorGC,将Eden区和一个Survivor区中的存活对象移动到另一个Survivor区或老年代
特点:通常发生的非常频繁,因为年轻代中对象的生命周期较短,回收效率高,暂停时间相对较短。
-
MajorGC
作用范围:主要针对老年代进行回收,但不一定只回收老年代
触发条件:当老年代空间不足时,或者系统检测到年轻代晋升到老年代的速度过快,可能会触发MajorGC
特点:相比MinorGC,MajorGC发生的频率相对较低,但每次回收可能需要更长的时间,因为老年代中的对象存活率较高。
-
FullGC
作用范围:对整个堆内存(包括新生代,老年代以及永久代/元空间)进行回收
触发条件:
- 直接调用System.gc()或Runtime.getRuntime().gc()方法时,虽然不能马上执行,但是JVM会尝试进行FullGC
- MinorGC时,如果存活的对象无法全部放入老年代,或者老年代空间不足以容纳存活的对象,则会触发FullGC,对整个堆内存进行回收。
- 当永久代或元空间空间不足时
特点:FullGC是最昂贵的操作,因为它需要停止所有的工作线程(stop the world),遍历整个堆内存来查找和回收不再使用的对象,因此尽量减少FullGC操作。
垃圾回收器CMS和G1的区别?
区别一:使用的范围不一样
- CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用
- G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用
区别二:STW的时间
- CMS收集器以最小的停顿时间为目标的收集器。
- G1收集器可预测垃圾回收的停顿时间(建立可预测的停顿时间模型)
区别三:垃圾碎片
- CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片
- G1收集器使用的是“标记-整理”算法,进行了空间整合,没有内存空间碎片。
区别四:垃圾回收的过程不一样
区别五:CMS会产生浮动垃圾
- CMS产生浮动垃圾过多时会退化为serial old,效率低,因为在上图的第四阶段,CMS清除垃圾时是并发清除的,这个时候,垃圾回收线程和用户线程同时工作会产生浮动垃圾,也就意味着CMS垃圾回收器必须预留一部分内存空间用于存放浮动垃圾
- 而G1没有浮动垃圾,G1的筛选回收是多个垃圾回收线程并行gc的,没有浮动垃圾的回收,在执行‘并发清理’步骤时,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理是才会被回收。如果在清理过程中预留给用户线程的内存不足就会出现‘Concurrent Mode Failure’,一旦出现此错误时便会切换到SerialOld收集方式。
什么情况下使用CMS,什么情况使用G1
CMS适用场景:
- 低延迟需求:适用于对停顿时间要求敏感的应用程序
- 老生代收集:主要针对老年代的垃圾回收
- 碎片化管理:容易出现内存碎片,可能需要定期进行FullGC来压缩内存空间
G1适用场景:
- 大堆内存:适用于需要管理大内存堆的场景,能够有效处理数GB以上的堆内存。
- 对内存碎片敏感:G1通过紧凑整理来减少内存碎片,降低了碎片化对性能的影响。
- 比较平衡的性能:G1在提供较低停顿时间的同时,也保持了相对较高的吞吐量
G1回收器的特色是什么?
G1的特点:
- G1最大的特点是引入分区的思路,弱化了分代的概念
- 合理利用垃圾收集各个周期的资源,解决了其他收集器、甚至CMS的众多缺陷
G1相比较CMS的改进:
- 算法:G1基于标记--整理算法,不会产生空间碎片,在分配大对象时,不会因无法得到连续的空间,而提前触发一次FullGC。
- 停顿时间可控:G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间避免应用雪崩现象。
- 并行与并发:G1更能充分的利用CPU多核环境下的硬件优势,来缩短STW的停顿时间。
GC只会对堆进行GC吗?
JVM的垃圾回收器不仅仅会对堆进行垃圾回收,它还会对方法区进行垃圾回收
- 堆:堆是用于存储对象实例的内存区域。大部分的垃圾回收工作都发生在堆上,因为大多数对象都会被分配在堆上,而GC的重点通常也是回收堆中不再被引用的对象,以释放内存空间。
- 方法区:方法区是用于存储类信息,常量,静态变量等数据的区域。虽然方法区中的垃圾回收与堆有所不同,但是同样存在对不再需要的常量、无用的类信息等进行清理的过程。
Spring
Spring与SpringMVC
介绍一下Spring
Spring框架核心特性包括:
- IoC容器:Spring通过控制反转实现了对象的创建和对象间的依赖关系管理。开发者只需要定义好Bean及其依赖关系,Spring容器负责创建和组装这些对象。
- AOP:面向切面编程,允许开发者定义横切关注点,例如事务管理、安全控制等,独立于业务逻辑的代码。通过AOP,可以将这些关注点模块化,提高代码的可维护性和可重用性。
- 事务管理:Spring提供了一致性的事务管理接口,支持声明式和编程式事务。开发者可以轻松地进行事务管理,而无需关心具体的事务API。
- MVC框架:SpringMVC是一个基于Servelet API构建的Web框架,采用了MVC架构。支持灵活的url到页面控制器的映射,以及多种试图技术。
介绍一下AOP
AOP是一种编程范式,即面向切面编程,在AOP中最小的单位是“切面”,一个切面可以包含很多种类型和对象,对他们进行模块化管理,AOP能够将那些与业务无关,却为业务模块所共同调用的逻辑封装起来,降低耦合度,例如:异常处理,记录日志,事务管理。AOP是基于动态代理实现的,如果被代理的对象实现了某个接口,那么就会使用JDK动态代理去创建代理对象,如果没有实现接口,会使用CGLIB生成一个被代理对象的子类来作为代理。
简单来说AOP就是将核心业务功能与周边功能进行分别独立开发,两者不是耦合的,然后将核心业务功能与周边功能编织在一起。
AOP中主要由以下几个概念组成:
- AspectJ:切面,是Join point,Advice,Pointcut的一个统称
- Join point:连接点,指程序执行过程中的一个点,例如方法调用,异常处理等。
- Advice:通知,由“Around,Before,After”三种。在很多AOP实现的框架中,Advice通常作为一个拦截器,也可以包含许多个拦截器作为一条链路围绕着join point进行处理
- Pointcut:切点,用于匹配连接点,一个AspectJ中包含哪些Join point需要由Pointcut进行筛选。
- Introduction:引介,让一个切面可以声明被通知的对象实现任何他们没有真正实现的额外的接口。例如可以让一个代理对象代理两个目标类。
- Weaving:织入,在有了连接点、切点、通知以及切面,如何将它们应用到程序中呢?没错,就是织入,在切点的引导下,将通知逻辑插入到目标方法上,使得我们的通知逻辑在方法调用时得以执行。
- AOP proxy:AOP 代理,指在 AOP 实现框架中实现切面协议的对象。在 Spring AOP 中有两种代理,分别是 JDK 动态代理和 CGLIB 动态代理。
- Target object:目标对象,就是被代理的对象。
代理模式
静态代理模式:普通的代理模式需要一个接口,实体类与代理类都需要实现这个接口,代理模式的思想就是引入一个代理对象来控制对原对象的访问,在客户端和目标对象之间充当中介,将客户端的请求转发给原对象进行处理,同时在转发请求的前后可以进行额外的处理
-
主要解决的问题:代理模式解决的是在直接访问某些对象时可能遇到的问题,例如对象创建成本高、需要安全控制或远程访问等。
-
优点:职责分离,将访问控制与业务逻辑清晰分离开。且可以灵活的添加额外的功能或控制以及可以智能地处理访问请求,如延迟加载,缓存等。
-
缺点:增加了代理层会造成额外的性能开销,可能会影响请求的处理响应速度,并且某些类型的代理模式实现起来较为复杂
-
实现:
创建接口,定义抽象主题:
public interface Image { void display(); }
定义实体类:
public class RealImage implements Image { private String fileName; public RealImage(String fileName) { this.fileName = fileName; loadFromDisk(fileName); } @Override public void display() { System.out.println("Displaying " + fileName); } private void loadFromDisk(String fileName) { System.out.println("Loading " + fileName); } }
定义代理类:
public class ProxyImage implements Image { private RealImage realImage; private String fileName; public ProxyImage(String fileName){ this.fileName = fileName; } @Override public void display() { if (realImage == null) realImage = new RealImage(fileName); realImage.display(); } }
测试:
public class ProxyDemo { public static void main(String[] args) { Image image = new ProxyImage("test_10mb.jpg"); image.display(); System.out.println(); image.display(); } }
这个代理实现模拟了一个简单的缓存机制,在实体类中间增加了一层缓存处理,在display()的请求发送给代理类时,代理类会先查看缓存中是否存在实体对象,不存在才会创建对象,从磁盘中读取。
基于JDK的动态代理:
-
动态代理与静态代理的主要区别在于,静态代理在进行代理时,每次都需要手动的创建代理类,且可能会存在多个代理引起“类爆炸”(指在实现一个功能时,类本来可以不用这么多,但是却设计成这么多,使得维护成本过高,明显高于设计的效用。),并且静态代理是在编译时就确定了代理的对象,而动态代理在创建代理对象上更加灵活,基于java的反射机制,在运行时期,动态地为目标对象创建代理对象,无需手动地创建代理类。
-
jdk动态代理的实现:
接口:
public interface Service { void perform(); }
实体类:
public class ServiceImpl implements Service { @Override public void perform() { System.out.println("jdk代理实现类"); } }
jdk动态处理类:
public class ServiceInvocationHandler implements InvocationHandler { private final Object target; public ServiceInvocationHandler(Object target){ this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("Before method invoke"); Object result = method.invoke(target,args); System.out.println("After method invoke"); return result; } }
测试:
public class DynamicProxyDemo { public static void main(String[] args) { Service target = new ServiceImpl(); Service proxy = (Service) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new ServiceInvocationHandler(target)); proxy.perform(); } }
这里的动态代理实现在执行方法的前后都进行了输出,动态代理的灵活性就在于,不需要知道为哪些方法进行功能增强,只需要关注增强功能的实现以及增强位置。实现InvocationHandler接口得到一个切面类,利用proxy根据目标类的类加载器,接口和切面类获得一个代理类,代理类的逻辑就是把所有接口方法的调用转发到切面类的invoke()方法上,然后根据反射调用目标类的方法
基于CGLIB的动态代理:
jdk动态代理机制只能代理实现了接口的类,而不能实现接口的类就不能使用jdk的动态代理,而cglib基于ASM字节码生成工具,通过继承的方式来实现代理类,所以不需要接口,可以代理普通类,不能代理final类。
实现:
创建实体类
public class CgService {
public void perform(){
System.out.println("基于cglib的动态代理");
}
}
创建cglib动态代理处理类:
public class ServiceMethodInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
System.out.println("Before method invoke");
Object res = methodProxy.invokeSuper(o, objects);
System.out.println("After method invoke");
return res;
}
}
测试:
public class CgProxyDemo {
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(CgService.class);
enhancer.setCallback(new ServiceMethodInterceptor());
CgService service = (CgService) enhancer.create();
service.perform();
}
}
在使用AOP时,因为AOP是基于jdk的动态代理和cglib的动态代理实现的,若要为某个业务织入aop,该类如果没有实现接口,会使用cglib进行动态代理,因此该类不能使用final修饰。
依赖倒置,依赖注入,以及控制反转分别是什么
- 控制反转:控制指的是对程序执行流程的控制,而反转指的是在没有使用框架前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。
- 依赖注入:依赖注入是一种具体的编码技巧,不通过new的方式来创建对象,而是在外部创建对象,通过构造函数,函数参数等方式传递给类来使用
- 依赖倒置:与控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,而是共同依赖一个抽象。抽象不要依赖具体实现细节,具体细节实现依赖抽象。
SpringAOP主要想解决什么问题?
AOP编程范式其实像是对OOP编程的一种补充,在OOP编程中会遇到这样的问题:一些比较共性的功能例如异常处理,打印日志等。这些功能在类与类之间并不是对象的主要特征,并且这些操作都有具体实现,不能抽象出来。假如这类功能利用继承,会强行侵入到继承树中对对象与对象之间的共性造成干扰。
AOP就是一种无侵入式的一种思想,它会把这些共性的功能整理出来,通过切入点在方法粒度上进行织入。
Spring如何解决循环依赖
循环依赖主要有三种情况
- 通过构造方法依赖注入时产生的循环依赖问题
- 通过setter注入且是在多例(原型)模式下注入
- 通过setter注入且是单例模式下注入
只有第三种情况被Spring解决了,其他两种情况在遇到循环依赖问题时,都会发生异常
Spring解决循环依赖的问题主要是通过三级缓存来解决问题,三级缓存就是在bean的创建过程中,通过三级缓存来缓存正在创建的bean,以及已经创建的bean实例。
- 实例化bean:实例化bean时,会先创建一个空的bean对象,放入缓存中
- 属性赋值:对bean进行属性赋值,如果发现循环依赖,会将当前bean对象提前暴露给后续需要依赖的bean
- 初始化bean:完成属性赋值后,spring将bean初始化放入二级缓存中
- 注入依赖:Spring 继续对 Bean 进行依赖注入,如果发现循环依赖,会从二级缓存中获取已经完成初始化的 Bean 实例。
测试:
第三种情况
//两个循环依赖的bean
public class Service1 {
private Service2 service2;
public Service1(){
System.out.println("loading...");
}
public void setService2(Service2 service2) {
this.service2 = service2;
}
public void test(){
System.out.println("I am service1"+service2.toString());
}
}
public class Service2 {
public void setService1(Service1 service1) {
this.service1 = service1;
}
private Service1 service1;
public Service2(){
System.out.println("loading...");
}
public void test(){
System.out.println("I am service2"+service1.toString());
}
}
//测试是否能够使用bean
public class App {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
Service1 bean = (Service1) context.getBean("service1");
Service2 bean1 = context.getBean(Service2.class);
bean.test();
bean1.test();
}
}
在单例模式下,两个循环依赖的bean能够正确创建且调用方法,但将bean改为非单例的
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="service1" class="com.jermaine.service.Service1" scope="prototype">
<property name="service2" ref="service2"/>
</bean>
<bean id="service2" name="service2" class="com.jermaine.service.Service2" scope="prototype">
<property name="service1" ref="service1"/>
</bean>
</beans>
这种情况下出现如下异常
Cannot resolve reference to bean 'service2' while setting bean property 'service2'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'service2' defined in class path resource [application.xml]: Cannot resolve reference to bean 'service1' while setting bean property 'service1'; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'service1': Requested bean is currently in creation: Is there an unresolvable circular reference?
Requested bean is currently in creation: Is there an unresolvable circular reference?这里提出了两个bean存在循环依赖的问题
Spring三级缓存的数据结构是什么?
都是Map类型的缓存,比如Map{k:name;v:bean}。
- 一级缓存:存储的是已经完全初始化好的bean,即完全准备好可以使用的bean实例。键是bean的名称,值是bean的实例。这个缓存在DefaultSingletonBeanRegistry类中的singletonObjects属性中。
- 二级缓存:存储的是早期的bean引用,即已经实例化但还未完全初始化的bean。这些bean已经被实例化,但是可能还没有进行属性注入等操作。这个缓存在DefaultSingletonBeanRegistry类中的earlySingletonObjects属性中。
- 三级缓存:存储的是ObjectFactory对象,这些对象可以生成早期的bean引用。当一个bean正在创建过程中,如果它被其他bean依赖,那么这个正在创建的bean就会通过这个ObjectFactory来创建一个早期引用,从而解决循环依赖的问题。这个缓存在DefaultSingletonBeanRegistry类中的singletonFactories属性中。
Spring都用到了哪些设计模式?
- 工厂模式
- 代理模式
- 单例模式
- 模板方法模式:Spring中jdbcTemplate、hibernateTemplate等以Template结尾的对数据库操作的类,都使用了模板方法模式
- 包装器设计模式:项目通常需要连接多个数据库,不同的客户在每次访问中根据需要会去访问不同的数据库,这种模式可以根据需求动态切换不同的数据源
- 观察者模式:Spring事件驱动模型(一种发布订阅模型,发布者通过发布事件的方式进行通知,而订阅者通过订阅事件的方式接收通知。发布者和订阅者之间的关系是松散耦合的,发布者只需要发布事件,并由框架负责将事件传递给订阅者)就是观察者模式很经典的一个应用。
- 适配器模式:AOP的增强或通知,SpringMVC的Controller均使用到了适配器模式。
Spring的事务什么情况下会失效
SpringBoot通常是通过@Transactional注解来支持事务操作,失效的情况一般包括:
- 未捕获异常:如果一个事务方法中出现了未捕获的异常且未处理或传播到事务边界之外,那么事务就会失效,所有的操作都会回滚
- 非受检异常:Spring对非受检异常(RuntimeException或其子类)进行回滚处理,这意味着当事务方法中抛出这些异常时,事务会回滚。
- 事务传播属性设置不当:如果多个事务之间存在着事务嵌套并且事务传播属性配置不正确,可能导致事务失效。特别是在方法内部调用由@Transactional注解的方法时要特别注意。
- 多数据源的事务管理:如果在使用多数据源时,事务管理没有正确配置或存在多个@Transactional注解时,可能会导致事务失效
- 跨方法调用事务问题:如果一个事务方法内部调用另一个方法,而这个被调用的方法没有@Transactional注解,这种情况下外层事务可能会失效。
- 事务在非公开方法中失效:如果@Transactional注解标注在私有方法上或者非public方法上,事务也会失效。
Spring的事务调用this是否生效
不生效,因为Spring的事务是通过代理对象来控制的,只有代理对象的方法调用才会应用事务管理的相关规则。当使用this时,是绕过了Spring的代理机制,因此不会应用事务设置。
Bean是否单例
spring默认为单例模式,但是也可以通过配置将Bean设置为多例模式,即每次请求都会创建一个新的实例。并且,单例模式的Bean是完全交给Spring容器管理的,包括完整的生命周期,但当设置为多例的情况下,在容器创建交给使用者后,就不再管理后续的生命周期了。
那么何时使用单例,何时使用多例呢?
适合交给容器管理的单例模式bean,是一些复用的对象,例如表现层,业务层,数据层以及一些工具类的对象,它们并没有封装一些成员变量的属性值,只负责完成一些业务操作。而如果是一些实体域对象,例如在使用mybatis查询时,查询结果通常会封装在一些pojo对象中,这种情况下并不适合将其交给容器进行管理,而且在使用这些对象时,需要考虑线程安全的问题。
Bean的作用域?
- singleton(单例)
- prototype 原型(非单例)
- Request(请求):每个Http请求都会创建一个新的Bean实例。仅在SpringWeb应用程序中有效,适用于Web应用中需求局部性的Bean
- Session(会话):Session范围内只会创建一个Bean实例,在用户会话范围内共享,仅在SpringWeb应用程序中有效,适用于与用户会话相关的Bean
- Application:当前ServletContext中只存在一个Bean实例。仅在SpringWeb应用程序中有效,在整个ServletContext范围内共享,适用于应用程序范围内共享的bean
- WebSocket(web套接字):当前WebSocket中只存在一个Bean实例。仅在SpringWeb应用程序中有效,在整个WebSocket范围内共享,适用于WebSocket会话范围内共享的bean
- Custom scopes(自定义作用域):开发者可以通过实现Scope接口来创建新的作用域
Spring提供了很多扩展点,有了解吗
Spring提供了许多扩展点,使得开发者可以根据需求定制和扩展Spring功能。以下是一些常用的扩展点:
- BeanFactoryPostProcessor:允许在spring容器实例化bean之前修改bean的定义,常用于修改bean属性或改变bean的作用域
- BeanPostProcessor:可以在bean实例化、配置以及初始化后对其进行额外的处理。常用于代理bean,修改bean属性等
- PropertySource:用于定义不同的数据源,如文件,数据库等,以便在Spring应用中使用。
- ImportSelector和ImportBeanDefinationRegister:用于根据条件动态注册bean定义,实现配置类的模块化。
- SpringMVC中的HandlerInterceptor:用于拦截处理请求,可以在请求处理前、处理中和处理后执行特定逻辑
- SpringMVC中的ControllerAdvice:用于全局处理控制器的异常、数据绑定和数据校验
- SpringBoot的自动配置:通过创建自定义的自动配置类,可以实现对框架和第三方库的自动配置。
- 自定义注解:创建自定义注解,用于实现特定功能或约定,如权限控制,日志记录等。
SpringMVC执行流程
HandlerMapping和HandlerAdapter都是干嘛的
HandlerMapping:handlermapping会根据DispatcherServlet发送的url以及请求参数进行解析,将请求映射到控制器(controller),然后再根据映射关系以及拦截器串联成执行链返回给DispatcherServlet。
HandlerAdapter:HandlerAdapter是适配器模式的典型应用,在接收到DispatcherServlet的执行请求后,负责调用controller来处理请求,而controller可能有不同的接口类型(Controller接口,HttpRequestHandler接口等),HandlerAdapter根据controller的类型来选择合适的方法调用处理器,处理器完成处理业务后,将结果包装成ModelAndView对象或者JSON返回给HandlerAdapter,HandlerAdapter将结果提交给DispatcherServlet。
讲一下拦截器和Filter的区别
- 首先,拦截器Interceptor是在SpringMVC层级的,Filter则是Servlet容器控制的,而SpringMVC是运行在web容器上的一个框架,在处理请求时,是先交给Web容器做处理的,Web容器通过请求的URL模式才会将请求路由到已注册的DispatcherServlet上,SpringMVC开始处理请求。因此Filter是先于拦截器执行的,覆盖范围更广,而拦截器只在控制器处理的过程中执行
- 再说拦截范围,Filter可以拦截几乎所有类型的请求,包括Servlet,JSP页面,静态资源等,而Interceptor主要拦截的是Controller层的请求,不能直接拦截静态资源或非控制器请求。因此Filter适用于通用的请求处理,例如编码设置,权限检查以及日志记录等,而Interceptor主要用于应用业务逻辑层面的处理,比如用户身份校验,权限校验,请求参数校验等。
- 最后是二者的生命周期也不同,Filter由web容器控制,初始化和销毁都是随着应用启动和关闭而触发的,拦截器由Spring容器控制,通常是在Spring容器启动时初始化,关闭时销毁
那如何定义一个拦截器?
- 实现HandlerInterceptor接口
- 创建拦截方法
- 注册拦截器
- 配置拦截器的路径匹配
- 配置拦截器的排除路径
- 配置拦截器的顺序
- 启用MVC配置
SpringBoot
SpringBoot相比Spring有哪些优点?
- SpringBoot提供了自动化配置,遵守约定大于配置原则,很多配置可以自动完成,开发可以专注于关注业务逻辑的实现
- SpringBoot提供了很多的快速项目启动器,通过引入不同的starter,可以快速集成常用的框架和库,极大地提高了开发效率
SpringBoot的项目结构是怎样的
- 开放接口层:可直接封装 Service 接口暴露成 RPC 接口;通过 Web 封装成 http 接口;网关控制层等。
- 终端显示层:各个端的模板渲染并执行显示的层。当前主要是 velocity 渲染,JS 渲染,JSP 渲染,移动端展示等。
- Web 层:主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。
- Service 层:相对具体的业务逻辑服务层。
- Manager 层:通用业务处理层,它有如下特征
- 1)对第三方平台封装的层,预处理返回结果及转化异常信息,适配上层接口。
- 2)对 Service 层通用能力的下沉,如缓存方案、中间件通用处理。
- 3)与 DAO 层交互,对多个 DAO 的组合复用。
- DAO 层:数据访问层,与底层 MySQL、Oracle、Hbase、OceanBase 等进行数据交互。
- 第三方服务:包括其它部门 RPC 服务接口,基础平台,其它公司的 HTTP 接口,如淘宝开放平台、支付宝付款服务、高德地图服务等。
- 外部接口:外部(应用)数据存储服务提供的接口,多见于数据迁移场景中。
SpringBoot的自动配置原理?
什么是自动配置?
SpringBoot自动配置是基于Spring的条件化配置和@EnableAutoConfiguration注解实现的,这种机制允许开发者在项目中引入相关依赖,SpringBoot将根据这些依赖自动配置到应用程序的上下文和功能中
如何进行自动配置的?
SpringBoot定义了一套接口规范,这套规范规定:SpringBoot在启动时会扫描外部引用的jar包中的META-INF/Spring.factories文件,将文件中配置的类型信息加载到Spring容器中,并执行类中定义的各种操作。对于外部jar,只需要按照SpringBoot的标准就能把自己的功能装配到容器中。
SpringBoot启动类需要加上@SpringBootApplication注解,进入该注解,有一个@EnableAutoConfiguration注解,这个注解是SpringBoot自动配置的核心,能够根据项目的依赖和配置自动配置应用程序的上下文,在@EnableAutoConfiguration注解中,有@AutoConfigurationPackage以及@Import{{AutoConfigurationImportSelector.class}},@AutoConfigurationPackage负责将项目src中main包下的所有组件注册到容器中,而@Import{{AutoConfigurationImportSelector.class}}通过分析项目的类路径和条件来决定应该导入哪些自动配置类,简单概括AutoConfigurationImportSelector的主要工作:
- 扫描类路径:AutoConfigurationImportSelector在启动时会扫描外部引用的jar包中的META-INF/Spring.factories文件,在这里,它会查找所有实现了AutoConfiguration的类,具体的实现方法为getCandidateConfiguration。
- 条件判断: 对于每一个发现的自动配置类,AutoConfigurationImportSelector 会使用条件判断机制(通常是通过 @ConditionalOnXxx注解)来确定是否满足导入条件。这些条件可以是配置属性、类是否存在、Bean是否存在等等。
- 根据条件导入自动配置类:满足条件的自动配置类将被导入到应用程序的上下文中。这意味着它们会被实例化并应用于应用程序的配置。
SpringBoot如何开启事务
在SpringBoot开启事务非常简单,只需要在服务层的方法上添加@Transactional注解即可
假设有一个服务层的接口UserService:
public interface UserService{
void saveUser(User user)
}
想要在这个方法上开启事务:
public class UserServiceImpl implements UserService{
@Autowired
private UserRepository userRepository;
@Override
@Transcational
public void saveUser(User user){
userRepository.save(user);
}
}
当调用saveUser方法时,Spring就会自动为该方法开启一个事务,如果方法执行成功,事务会自动提交,否则就会回滚。
Mybatis
与传统的JDBC相比,Mybatis的优点
- 基于 SQL 语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任 何影响,SQL 写在 XML 里,解除 sql 与程序代码的耦合,便于统一管理;提供 XML 标签,支持编写动态 SQL 语句,并可重用。
- 与 JDBC 相比,减少了 50%以上的代码量,消除了 JDBC 大量冗余的代码,不 需要手动开关连接;
- 很好的与各种数据库兼容,因为 MyBatis 使用 JDBC 来连接数据库,所以只要 JDBC 支持的数据库 MyBatis 都支持。
- 能够与 Spring 很好的集成,开发效率高
- 提供映射标签,支持对象与数据库的 ORM 字段关系映射;提供对象关系映射 标签,支持对象关系组件维护。
JDBC的连接步骤?
- 加载数据库驱动:在使用JDBC连接数据库之前,需要加载相应的数据库驱动程序。可以通过 Class.forName("com.mysql.jdbc.Driver") 来加载MySQL数据库的驱动程序。不同数据库的驱动类名会有所不同。
- 建立数据库连接:使用 DriverManager 类的 getConnection(url, username, password) 方法来连接数据库,其中url是数据库的连接字符串(包括数据库类型、主机、端口等)、username是数据库用户名,password是密码。
- 创建statement对象:通过 Connection 对象的 createStatement() 方法创建一个 Statement 对象,用于执行 SQL 查询或更新操作。
- 执行SQL查询或更新操作:使用 Statement 对象的 executeQuery(sql) 方法来执行 SELECT 查询操作,或者使用 executeUpdate(sql) 方法来执行 INSERT、UPDATE 或 DELETE 操作。
- 处理查询结果:如果是 SELECT 查询操作,通过 ResultSet 对象来处理查询结果。可以使用 ResultSet 的 next() 方法遍历查询结果集,然后通过 getXXX() 方法获取各个字段的值。
- 关闭连接:在完成数据库操作后,需要逐级关闭数据库连接相关对象,即先关闭 ResultSet,再关闭 Statement,最后关闭 Connection。
Mybatis里的#和$有什么区别?
- Mybatis在处理#{}时,会创建预编译的SQL语句,将SQL语句中的#{}替换为?号,在执行SQL时会为预编译SQL中的占位符(?)赋值,调用PreparedStatement的set方法来赋值,预编译的SQL语句执行效率高,并且可以防止SQL注入,提供更高的安全性,适合传递参数值。
- Mybatis 在处理 ${} 时,只是创建普通的 SQL 语句,然后在执行 SQL 语句时 MyBatis 将参数直接拼入到 SQL 里,不能防止 SQL 注入,因为参数直接拼接到 SQL 语句中,如果参数未经过验证、过滤,可能会导致安全问题。
- 此外,防止sql注入的策略还有使用动态sql拼接进行查询
例如:使用${}查询,语句为:
select * form user where id = 1
使用#{}查询,语句为:
select * from user where id = ?
parameters: 1(Long)
Mybatis的缓存机制?
Mybatis总共有两级缓存,即查询缓存,缓存分为一级缓存和二级缓存
-
一级缓存:一级缓存的作用域在SqlSession上,在操作数据库时需要构建SqlSession对象,在对象中有一个数据结构(HashMap)用来存储缓存数据,不同的SqlSession之间的HashMap是不会相互影响的。在同一个sqlSession中两次执行相同的sql语句,第一次执行完毕会将数据库中查询的数据写到缓存(内存),第二次会从缓存中获取数据将不再从数据库查询,从而提高查询效率。当一个sqlSession结束后该sqlSession中的一级缓存也就不存在了。Mybatis默认开启一级缓存 。
一级缓存的生命周期:MyBatis在开启一个数据库会话时,会创建一个新的SqlSession对象,SqlSession对象中会有一个新的Executor对象。Executor对象中持有一个新的PerpetualCache对象;当会话结束时,SqlSession对象及其内部的Executor对象还有PerpetualCache对象也一并释放掉。如果SqlSession调用了close()方法,会释放掉一级缓存PerpetualCache对象,一级缓存将不可用。如果SqlSession调用了clearCache(),会清空PerpetualCache对象中的数据,但是该对象仍可使用。SqlSession中执行了任何一个update操作(update()、delete()、insert()) ,都会清空PerpetualCache对象的数据,但是该对象可以继续使用
-
二级缓存:二级缓存是mapper级别的缓存,多个SqlSession去操作同一个Mapper的sql语句,数据库得到数据会存在二级缓存区域,多个SqlSession可以共用二级缓存,二级缓存是跨SqlSession的。mybatis的二级缓存是需要自己手动去配置的,当开启二级缓存后,MyBatis要求返回的POJO必须是可序列化的, 也就是要求实现Serializable接口。
当开启二级缓存后:
- 映射语句文件中的所有select语句将会被缓存。
- 映射语句文件中的所欲insert、update和delete语句会刷新缓存。
- 缓存会使用默认的Least Recently Used(LRU,最近最少使用的)算法来收回。
- 根据时间表,比如No Flush Interval,(CNFI没有刷新间隔),缓存不会以任何时间顺序来刷新。
- 缓存会存储列表集合或对象(无论查询方法返回什么)的1024个引用
- 缓存会被视为是read/write(可读/可写)的缓存,意味着对象检索不是共享的,而且可以安全的被调用者修改,不干扰其他调用者或线程所做的潜在修改。
- 如果缓存中有数据就不用从数据库中获取,大大提高系统性能。
Mybatis的一对多与多对一问题了解吗
举个例子,一个班级有一个老师和多个学生,站在学生的角度来看,与老师的关系就是多对一的,站在老师的角度来看,与学生的关系就是一对多的
MP和Mybatis的区别
MP是Mybatis的一个增强工具库,可以简化Mybatis的一些操作,提高开发效率
- CRUD操作:MybatisPlus通过继承BaseMapper接口,提供了一系列内置的快捷方法,使得CRUD操作更加简单,无需编写重复的SQL语句。
- 代码生成器:MybatisPlus提供了代码生成器功能,可以根据数据库表结构自动生成实体类、Mapper接口以及XML映射文件,减少了手动编写的工作量。
- 通用方法封装:MybatisPlus封装了许多常用的方法,如条件构造器、排序、分页查询等,简化了开发过程,提高了开发效率。
- 分页插件:MybatisPlus内置了分页插件,支持各种数据库的分页查询,开发者可以轻松实现分页功能,而在传统的MyBatis中,需要开发者自己手动实现分页逻辑。
- 多租户支持:MybatisPlus提供了多租户的支持,可以轻松实现多租户数据隔离的功能。
- 注解支持:MybatisPlus引入了更多的注解支持,使得开发者可以通过注解来配置实体与数据库表之间的映射关系,减少了XML配置文件的编写。
SpringCloud
SpringCloud与SpringBoot的关系
SpringBoot是用于构建单个Spring应用的框架,SpringCloud是用于构建分布式系统中微服务架构的工具,SpringCloud提供了服务注册与发现,负载均衡,断路器,网关等功能。
两者可以结合使用,通过SpringBoot构建微服务应用,然后使用SpringCloud来实现微服务架构中的各种功能。
用过哪些微服务组件?
- 注册中心Eureka:SpringCloud中最核心的组件,负责服务的注册与发现,服务治理,微服务节点在启动时会将自己的服务名称、IP、端口等信息在注册中心登记,注册中心会通过心跳机制检查该节点的运行状态。
- 负载均衡Ribbon和Feign:解决了「如何发现服务及负载均衡如何实现的问题」,通常微服务在互相调用时,并不是直接通过IP、端口进行访问调用。而是先通过服务名在注册中心查询该服务拥有哪些节点,注册中心将该服务可用节点列表返回给服务调用者,这个过程叫服务发现,因服务高可用的要求,服务调用者会接收到多个节点,必须要从中进行选择。因此服务调用者一端必须内置负载均衡器,通过负载均衡策略选择合适的节点发起实质性的通信请求。
- 断路器Hystrix:防止服务链路依赖关系失效导致服务出现故障,Hystrix可以提供服务熔断,服务降级,服务限流等功能,避免系统崩溃。
- 路由网关Zuul:提供代理+路由+过滤三大功能,路由负责将外部请求转发到具体的微服务实例上,是实现外部访问统一入口的基础,过滤器功能负责对请求的处理过程进行干预,是实现请求校验,服务聚合等功能的基础。
- 分布式配置中心SpringCloud Config:由于微服务架构中将服务拆分成各个模块,为每个服务进行配置十分复杂,而SpringCloud Config为微服务提供集中化管理的外部配置支持,集中管理配置文件,且配置发生变动时,服务不需要重启,即可感知到配置的变化,并应用新的配置。
负载均衡算法有哪些?
RoundRobinRule 轮询(默认):将请求按照顺序分发给服务器,不关心服务器的当前状态,使用CAS+自旋锁的方式来保证线程安全
加权轮询:根据服务器自身的性能给服务器设置不同的权重,将请求按顺序和权重分发给后端服务器,可以让性能高的机器处理更多的请求
RandomRule 随机:将请求随机分发给后端服务器,请求越多,各个服务器接收到的请求越平均。
加权随机:根据服务器自身的性能给服务器设置不同的权重,将请求按各个服务器的权重随机分发给后端服务器
一致性哈希:根据请求的客户端 ip、或请求参数通过哈希算法得到一个数值,利用该数值取模映射出对应的后端服务器,这样能保证同一个客户端或相同参数的请求每次都使用同一台服务器
最小活跃数:统计每台服务器上当前正在处理的请求数,也就是请求活跃数,将请求分发给活跃数最少的后台服务器
什么是服务熔断和服务降级?
服务熔断:
熔断机制是应对雪崩效应的一种微服务链路保护机制,当扇出链路的某个微服务不可用或者响应时间太长的时候,会熔断该节点微服务的调用,快速返回错误的响应信息。当检测到该节点微服务调用响应正常后恢复调用链路,在SpringCloud中熔断机制通过Hystrix实现。Hystrix会监控微服务间调用的状况,当失败的调用到一定阈值,缺省是5s内20次调用失败就会启动熔断机制,熔断机制的注解@HystrixCommand。
服务熔断的原理是通过在服务调用的过程中设置一个熔断器,并监控服务的调用情况。当服务的错误率或失败次数超过设定的阈值时,熔断器会打开,将后续的请求快速失败,而不是继续调用具有高延迟或已经失效的服务。当熔断器打开后,可以选择返回一个预设的默认值或者执行降级逻辑,以保证系统的相应性能。
服务降级:
服务降级是一种应对系统负载过高或者服务不可用的策略,通过临时屏蔽某些非核心功能来保证系统的可用性。在Spring Cloud中,服务降级是通过在调用链路中使用备用方法或者返回默认值来处理异常情况的过程。
服务的资源是有限的,而请求是无限的。在用户使用并发高峰期,会影响整体的服务性能,严重的话甚至会宕机,因此在高并发期间,会对某些非核心功能进行降级处理,释放出服务器资源以保证核心业务的正常高效运行,可以理解为舍小保大。
什么是CAP原则,Eureka遵循的哪种原则?
CAP原则是C(Consistency)强一致性,A(Availability)可用性,P(Partition tolerance)分区容错性的缩写,在设计分布式系统时CAP这三个要素最多只能同时实现两个,不可能三者兼顾
- 强一致性:系统在执行过某项操作后,仍然处于一致的状态,适用于需要保持数据高一致性的场景
- 可用性:在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求,即不管成功与否都能得到响应
- 分区容错性:保证系统中任意信息的丢失或失败不会影响系统的继续运作。
分区容错性在分布式系统中是必须要保证的,只能在AC之间权衡,Eureka遵循的是AP原则,而Zookeeper遵循的是CP原则。
MySQL
SQL基础
SQL与NoSQL的区别
SQL:即关系型数据库,存储结构化的数据,以行列二级的表结构存储数据,常用的关系型数据库有MySQL,Oracle,PostgreSQL,SQL支持ACID特性,即原子性,一致性,隔离性,持续性,适用于需要保证ACID的场景,例如银行系统。此外,关系型数据库的表结构在设计之初就被定义好了,且数据之间存在关联性,难以水平扩展,需要解决跨服务JSON,分布式事务等问题。
NoSQL:非关系型数据,存储形式有很多种,例如Redis的kv结构,MongoDB的JSON文档存储方式。NOSQL采用更宽松的模型BASE,即基本可用,软状态以及最终一致性。NoSQL适用于不要求高一致性的场景,例如社交软件更新一条状态,对于所有用户读取先后时间有数秒不同并不影响使用。NoSQL数据库数据之间无关系,容易进行扩展,比如Redis自带主从复制模式,哨兵模式,切片集群模式。
数据库三大范式?
-
第一范式:数据表的每一列都是不可分割的原子数据项。
不满足第一范式
满足第一范式
-
第二范式:在第一范式的基础上,非码属性必须完全依赖于候选码,即第二范式需要确保数据表中的每一列都和主键相关,而不能只与主键的一部分相关。
在上图所示的情况中,同一个订单中可能包含不同的产品,因此主键必须是“订单号”和“产品号”联合组成,
但可以发现,产品数量、产品折扣、产品价格与“订单号”和“产品号”都相关,但是订单金额和订单时间仅与“订单号”相关,与“产品号”无关,这样就不满足第二范式的要求,调整如下,需分成两个表:
第三范式:在第二范式的基础上,任何非主属性不依赖于其他非主属性,第三范式需要确保数据表中的每一列数据都和主键直接相关,而不能间接相关。
上表中,所有属性都完全依赖于学号,所以满足第二范式,但是“班主任性别”和“班主任年龄”直接依赖的是“班主任姓名”,而不是主键“学号”,所以需做如下调整:
MySQL如何联表查询
mysql有以下几种联表查询类型:
- 内连接
- 左外连接
- 右外连接
- 全外连接
-
内连接
内连接返回两个表中有匹配关系的行,例如:
SELECT employees.name,departments.name FROM emoloyees INNER JOIN departments ON employees.department_id = departments.id
查询返回每个员工及其所在的部门名称。
-
左外连接
左外连接返回左表中的所有行,即使在右表中没有匹配的行。未匹配的右表列会包含null,例如:
SELECT employees.name,departments.name FROM employees LEFT JOIN departments ON employees.department_id = departments.id
这个查询返回所有员工及其部门名称,包括那些没有被分配部门的员工。
-
右外连接
返回右表中所有行,即使左表中没有匹配的行。未匹配的左列表会包含null,例如:
SELECT employees.name,departments.name FROM employees RIGHT JOIN departments ON employees.department_id = departments.id
这个查询返回所有部门及其员工,包括那些没有分配员工的部门。
-
全外连接
返回两个表中的所有行,包括非匹配行,在Mysql中,全外连接需要使用UNION来实现,因为MYSQL不支持直接使用全连接,例如:
SELECT employees.name,departments.name FROM employees LEFT JOIN departments ON employees.department_id = departments.id UNION SELECT employees.name,departments.name FROM employees RIGHT JOIN departments ON employees.department_id = departments.id
这个查询返回所有员工和所有部门,包括没有匹配行的记录。
如何避免重复插入数据?
-
使用unique约束
在表的相关列上添加unique约束,确保每个值在该列中唯一,例如:
CREATE TABLE users( id INT PRIMARY KEY AUTO_INCREMENT, email VARCHAR(255) UNIQUE, name VARCHAR(255) );
如果尝试插入重复的email,Mysql会返回错误。
-
使用INSERT ... ON DUPLICATE KEY UPDATE
这种语句允许在插入时处理键重复的情况,如果插入的记录与现有记录冲突,可以选择更新现有记录
INSERT INTO users (email,name) VALUES("2671667099@qq.com","Andy") ON DUPLICATE KEY UPDATE name = VALUES(name);
-
使用INSERT IGNORE:该语句会在插入记录时忽略那些因重复键而导致的插入错误。例如:
INSERT IGNORE INTO users(email,name) VALUES("2671667099@qq.com","Jermaine");
如果email已经存在,这条插入语句将被忽略而不会返回错误。
选择哪种方法要看具体情况:
- 如果保证全局唯一性,使用UNIQUE约束是最佳做法
- 如果需要插入和更新结合可以使用ON DUPLICATE KEY UPDATE name
- 对于快速忽略重复插入,INSERT IGNORE是最佳做法。
char和varchar的区别?
- char是固定长度的字符串类型,定义时需要固定长度,存储时会在末尾补足空格。char适合存储例如固定长度的代码,状态等。
- varchar是可变长度的字符串类型,定义的是最大长度,存储时按照字符串的实际长度存储,varchar适合存储长度可变的数据,例如用户输入的文本,备注等,节约存储空间。
Text数据类型可以无限大吗?
MySQL中有三种类型的text:
- TEXT:65,535 bytes ~64kb
- MEDIUMTEXT:16,777,215 bytes ~16Mb
- LONGTEXT:4,294,967,295 bytes ~4Gb
什么是外键约束?
外键约束的作用是维护表与表之间的关系,确保数据的完整性和一致性。让我们举一个简单的例子:
假设有两个表,一个是学生表,另一个是课程表,这两个表之间有一个关系,即一个学生可以选修多门课程,而一门课程也可以被多个学生选修。在这种情况下,我们可以在学生表中定义一个指向课程表的外键,如下所示:
CREATE TABLE students(
id INT PRIMARY KEY,
name VARCHAR(50),
course_id INT,
FOREIGN KEY (course_id) REFERENCES courses(id)
);
students表中的course_id是一个外键,指向courses表中的id,这个外键约束确保了每个学生所选的课程在courses表中都存在,从而维护了数据的完整性和一致性。
如果没有定义外键约束,那么就有可能出现学生选了不存在的课程或者删除了一个课程而忘记从学生表中删除选修该课程的学生的情况,这会破坏数据的完整性和一致性。因此,使用外键约束可以帮助我们避免这些问题。
例如:
UPDATE students SET course_id = 6 WHERE id = 2;
这里插入不存在该课程的id,因为外键约束,这个操作是不允许的
DELETE FROM courses WHERE id = 5;
同样,删除操作也不被允许,必须先删除选择了该课程的学生。
关键字in和exist?
IN和EXIST都是用来处理子查询的关键词,IN用于检查左边的表达式是否存在于右边的列表或子查询的结果集中,如果存在,则IN返回TRUE,否则false。EXIST用于判断子查询是否至少能返回一行数据。它不关心子查询返回什么数据,只关心是否有结果。如果有结果为TRUE,否则为FALSE
IN:
SELECT column_name(s)
FROM table_name
WHERE column_name IN (SELECT column_name FROM another_table WHERE condition);
EXIST:
SELECT column_name(s)
FROM table_name
WHERE EXISTS (SELECT column_name FROM another_table WHERE condition);
区别:
- 性能:EXISTS的性能由于IN,因为EXISTS一旦到匹配项就会立即停止查询,而IN可能扫描整个子查询结果集
- NULL值处理:IN能够正确处理子查询中包含NULL值的情况,而EXISTS不受子查询结果中NULL值的影响
mysql中的一些基本函数你知道哪些?
字符串函数
CONCAT(str1,str2,....):连接多个字符串,返回一个合并后的字符串
SELECT CONCAT(‘hello’,' ','World')AS Greeting
LENGTH(str):返回字符串长度
SUBSTRING(str,pos,len):从指定位置开始,截取指定长度的字符串
REPLACE(str,from_str,to_str):将字符串中的某部分替换为另一个字符串
数值函数
ABS(num):绝对值
POWER(num,exponent):返回指定数字的指定幂次方
日期和时间函数
NOW():返回当前日期和时间
CURDATE():返回当前日期
聚合函数
COUNT(column):返回指定列中的非NULL值的个数
SUM(column):返回指定列的总和
AVG(column):返回指定列的平均值
MAX(column):返回指定列的最大值
MIN(column):返回指定列的最小值
SQL查询语句的执行过程是怎样的?
所有的查询语句都是从FROM开始执行,在执行过程中,每个步骤都会生成一个虚拟表,这个虚拟表将作为下一个执行步骤的输入,最后一个步骤产生的虚拟表即为输出结果。
存储引擎
执行一条SQL请求的过程?
- 连接器:建立连接,管理连接,校验用户身份
- 查询缓存:查询语句如果命中缓存则直接返回,否则继续向下执行,8.0版本已删除该模块
- 解析SQL:通过解析器对SQL查询语句进行词法分析,语法分析,然后构建语法树,方便后续模块读取表名,字段,语句类型。
- 执行SQL:
- 预处理阶段:检查表或字段是否存在,将select *中的星号扩展为列表上的所有列
- 优化阶段:基于查询成本的考虑,选择查询成本最小的执行计划
- 执行阶段:根据执行计划执行SQL查询语句,从存储引擎读取记录,返回给客户端。
MySQL的引擎有了解吗
InnoDB:InnoDB是MySQL的默认存储引擎,具有ACID事务支持、行级锁、外键约束等特性。它适用于高并发的读写操作,支持较好的数据完整性和并发控制。
MyISAM:MyISAM是MySQL的另一种常见的存储引擎,具有较低的存储空间和内存消耗,适用于大量读操作的场景。然而,MyISAM不支持事务、行级锁和外键约束,因此在并发写入和数据完整性方面有一定的限制。
Memory:Memory引擎将数据存储在内存中,适用于对性能要求较高的读操作,但是在服务器重启或崩溃时数据会丢失。它不支持事务、行级锁和外键约束。
InnoDB与MyISAM的区别?
- 事务
- 索引结构:InnoDB 是聚簇索引,MyISAM 是非聚簇索引。聚簇索引的文件存放在主键索引的叶子节点上,因此 InnoDB 必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。而 MyISAM 是非聚簇索引,数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。
- 锁粒度:InnoDB 最小的锁粒度是行锁,MyISAM 最小的锁粒度是表锁。一个更新语句会锁住整张表,导致其他查询和更新都会被阻塞,因此并发访问受限。
- count的效率:InnoDB 不保存表的具体行数,执行 select count(*) from table 时需要全表扫描。而MyISAM 用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快。
InnoDB为什么是默认引擎
- 事务支持:InnoDB引擎提供了对事务的支持,可以进行ACID(原子性、一致性、隔离性、持久性)属性的操作。Myisam存储引擎是不支持事务的。
- 并发性能:InnoDB引擎采用了行级锁定的机制,可以提供更好的并发性能,Myisam存储引擎只支持表锁,锁的粒度比较大。
- 崩溃恢复:InnoDB引引擎通过 redolog 日志实现了崩溃恢复,可以在数据库发生异常情况(如断电)时,通过日志文件进行恢复,保证数据的持久性和一致性。Myisam是不支持崩溃恢复的。
数据管理中,数据文件大体分成哪几种数据文件
- db.opt:用来存储当前数据库的默认字符集和字符校验规则。
- t_order.frm:t_order 的表结构会保存在这个文件。在 MySQL 中建立一张表都会生成一个.frm 文件,该文件是用来保存每个表的元数据信息的,主要包含表结构定义。
- t_order.ibd:t_order 的表数据会保存在这个文件。表数据既可以存在共享表空间文件(文件名:ibdata1)里,也可以存放在独占表空间文件(文件名:表名字.ibd)。这个行为是由参数 innodb_file_per_table 控制的,若设置了参数 innodb_file_per_table 为 1,则会将存储的数据、索引等信息单独存储在一个独占表空间,从 MySQL 5.6.6 版本开始,它的默认值就是 1 了,因此从这个版本之后, MySQL 中每一张表的数据都存放在一个独立的 .ibd 文件。
索引
索引是什么?有什么好处
索引类似于书籍目录,可以减少扫描的数据量,提高查询效率
- 如果查询的时候,没有用到索引就会全表扫描,这时候查询的时间复杂度是On
- 如果用到了索引,那么查询的时候,可以基于二分查找算法,通过索引快速定位到目标数据, mysql 索引的数据结构一般是 b+树,其搜索复杂度为O(logdN),其中 d 表示节点允许的最大子节点个数为 d 个。
索引的分类是什么?
-
按「数据结构」分类:B+tree索引、Hash索引、Full-text索引
在创建表时,InnoDB存储引擎会根据不同场景选择不同的列作为索引键(key):
- 如果有主键,默认会使用主键作为聚簇索引的索引键(key)。
- 如果没有主键,就选择第一个不包含NULL值的唯一列作为聚簇索引的索引键(key)
- 在上面两个都没有的情况下,InnoDB将自动生成一个隐式自增id列作为聚簇索引的索引键(key)
其他索引都属于辅助索引,也被称为二级索引或非聚簇索引。创建的主键索引和二级索引默认使用的是B+Tree索引。
-
按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)
主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里;
二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。
所以,在查询时使用了二级索引,如果查询的数据能在二级索引里查询的到,那么就不需要回表,这个过程就是覆盖索引。如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后再检索主键索引,就能查询到数据了,这个过程就是回表。
-
按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引
主键索引(Primary key)
唯一索引(UNIQUE)
普通索引(就是建立在普通字段上的索引,既不要求字段为主键,也不要求为UNIQUE)
前缀索引:前缀索引是指对字符类型字段的前几个字符建立的索引,而不是在整个字段上建立的索引,前缀索引可以建立在字段类型为 char、 varchar、binary、varbinary 的列上。使用前缀索引的目的是为了减少索引占用的存储空间,提升查询效率。
-
按「字段个数」分类:单列索引、联合索引
建立在单列上的索引称为单列索引,比如主键索引;建立在多列上的索引(将多个字段组合成一个索引)称为联合索引。
聚簇索引和非聚簇索引的区别
- 存储方式:聚簇索引按照键值的顺序存储,索引的叶子节点包含具体的值,而非聚簇索引的叶子节点是指向数据行的指针或者主键值,数据行本身存在聚簇索引中
- 唯一性:聚簇索引通常是基于主键构建的,因此一个表中只能有一个聚簇索引,因为数据只能有一种物理排序方式,可以有多个非聚簇索引
- 效率:对于范围和排序查询,聚簇索引更快,因为避免了额外的寻址开销(回表操作)。非聚簇索引在使用覆盖索引进行查询时速度更快,因为不需要读取完整的数据行
什么字段适合作为主键
- 具有唯一性,且不为空特性的字段
- 字段最好有递增的趋势,如果字段的值是无序的,可能会引发页分裂的问题,造成性能影响
- 业务数据不适合做主键,例如订单号,学生号等,因为无法预测未来会不会存在复用的情况。
- 分布式系统下,自增字段就不适合做主键了,如果每台机器产生的数据需要合并时,会存在主键重复的情况,这时候就要考虑分布式id的方案了。
性别字段能加索引吗?为什么?
不建议,假如有100w条数据,其中各50w分别为男和女,区分度几乎为0,不适合的原因是因为select *操作,得进行50w次回表操作,根据主键从聚簇索引中找打其他字段,开销十分大,索引的作用是为了加快查询,但这里并没有起到作用,相比于全表扫描来说,还因为创建索引增加了空间。
B+树的叶子节点为什么使用双向链表?
使用双向链表连接,既能向右遍历,也能向左遍历,有利于范围查询,相比于B树,B树的叶子节点并没有链表连接,范围查询时可能会消耗大量的IO资源
MySQL为什么使用B+树结构,相比于其他结构的优点?
- B+ Tree vs B Tree:B+树相比于B树,高度会很小,且数据均存在叶子节点中,在进行查询时,B树在高度非常高的情况下,需要一直进行递归寻找符合的值为止,最坏的情况一直要查到叶子节点,而B+树所有的查询操作最终都会查询到叶子节点,性能也更加稳定。并且,B树在进行插入删除操作时,为了维护树的平衡,可能还需要重构树,而B+树有大量的冗余节点,基本不需要重构树的结构。
- B+ Tree vs 红黑树:红黑树有良好的平衡性,查询效率很高,但红黑树每个节点只能存储一个关键字和一个数据,在数据量非常大的情况下,树的高度同样会非常高,而且在进行插入删除操作的时候,维护红黑树的平衡会涉及到节点的旋转操作,这些都需要大量的I/O操作
- B+ Tree vs Hash:Hash表的查询速度十分快,时间复杂度仅为O(1),但Hash表不适合做范围查询,更适合做等值查询。
- B+ Tree vs SkipList :B+树高度在3层时,存储的数据可能已经达到千万级别,但对于跳表如果维护千万级别的数据会导致跳表层数过高而大大增加IO次数。
创建联合索引时需要注意什么?
建立联合索引时的字段顺序,对索引效率也有很大影响。越靠前的字段被用于索引过滤的概率越高,即要把区分度(某个字段column不同值的个数/表的总行数)大的字段排在前面,这样区分度大的字段越有可能被更多的 SQL 使用到。
索引什么情况会失效
- 使用左或者左右模糊匹配的时候,例如%xxx,%xxx%。
- 在查询条件中对索引列使用函数,就会导致索引失效
- 在查询条件中对索引列进行表达式计算,也会失效
- 字符串与数字比较时,会自动把字符串转化为数字进行比较,如果字符串为索引时,当查询条件输入为数字时,mysql会使用CAST函数进行隐式转换,对索引列使用函数,导致失效
- 联合索引要能遵循最左匹配原则,如果不满足也会失效
- 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。
何为覆盖索引
覆盖索引就是指一个索引包含了查询所需要的所有列,因此不需要访问表中的数据行就能完成查询,例如:
CREATE INDEX idx_name_age_salary ON users(name,age,salary);
创建了该索引后执行以下sql语句:
SELECT name,age,salary FROM users WHERE name = 'Andy';
索引的优缺点?
优点就是加快查询速度,主要说下缺点:
- 需要占用物理空间,数量越大,占的空间越大
- 创建索引和维护索引要耗费时间,这种时间随着数据量的增大而增大;
- 会降低表的增删改效率,每次增删改索引,B+树都需要进行维护。
那什么时候建立索引呢?
- 字段有唯一性限制,比如商品编码
- 经常用于where查询条件的字段,这样能够提高整个表的查询速度,如果查询条件不是一个字段,可以建立联合索引
- 经常用于group by和order by的字段,这样在查询时就不需要另外做排序了。
此外这些场景不适合建立索引
- 字段中存在大量重复数据的,区分度不高,mysql在遇到这种索引时,用到查询优化器,不会通过索引去查询了,而是直接进行全表扫描
- 表数据太少的时候
- 经常更新的字段
如何进行索引优化?
常见的有以下方法:
- 前缀索引优化:使用前缀索引是为了减小索引字段大小,可以增加一个索引页中存储的索引值,有效提高索引的查询速度。在一些大字符串的字段作为索引时,使用前缀索引可以帮助我们减小索引项的大小。
- 覆盖索引优化:覆盖索引是指 SQL 中 query 的所有字段,在索引 B+Tree 的叶子节点上都能找得到的那些索引,从二级索引中查询得到记录,而不需要通过聚簇索引查询获得,可以避免回表的操作。
- 主键索引最好是自增的:如果我们使用自增主键,那么每次插入的新数据就会按顺序添加到当前索引节点的位置,不需要移动已有的数据,当页面写满,就会自动开辟一个新页面。因为每次插入一条新记录,都是追加操作,不需要重新移动数据,因此这种插入数据的方法效率非常高。
- 防止索引失效
事务
事务的四大特性?如何实现
- Atomic原子性:一个事务中的操作要么全部成功,要么全部失败回滚
- Consistency一致性:在事务操作的前后,数据满足完整性约束,数据库保持一致性状态
- Isolation隔离性:每个事务的操作细节对于其他事务是不可见的,多个事务并发执行时不会交叉执行导致数据一致性被破坏
- Durability持久性:事务处理结束后,对数据的修改是永久性的,即便系统故障也不会丢失
实现:
- 持久性:redo log(重做日志)
- 原子性:undo log(回滚日志)
- 隔离性:MVCC(多版本并发控制)或锁机制
- 一致性:通过持久性+原子性+隔离性保证
MySQL事务可能出现什么并发问题?
- 脏读:一个事务读取到了另一个事务修改但未提交的数据,加入事务A还未提交数据,发生了回滚操作,事务B读取到的数据就是过期数据。
- 幻读:在一个事务内多次进行了读取操作,如果前后两次查询到的记录数量不一样,就是幻读。假如有两个事务AB对余额这条数据进行操作,B第一次读取到余额大于100w的数据有五条,这时A事务添加了一条大于100w的余额数据并且提交了事务,B再次读取会发现大于100w的数据变成了6条。
- 不可重复读:在一个事务内多次读取同一条数据,如果出现前后两次读到的数据不一致,就是不可重复读。假如事务A第一次读取了自己的余额,这个时候事务B给他进行了一次转账并且提交了事务,事务A再次读取自己的余额发现余额值和之前不一样了。
哪些场景不适合脏读?
- 银行系统:在银行系统中,如果一个账户的余额正在被调整但尚未提交,另一个事务读取了这个临时的余额,可能会导致客户看到不正确的余额。
- 库存系统:在一个库存管理系统中,如果一个商品的数量正在被更新但尚未提交,另一个事务读取了这个临时的数量,可能会导致库存管理错误。
- 在线订单系统:在一个在线订单系统中,如果一个订单正在被修改但尚未提交,另一个事务读取了这个临时的订单状态,可能导致订单状态显示错误,客户收到不准确的信息。
简单来说,这些场景需要严格保证数据库的高一致性,如果出现不一致的情况会造成严重的损失
MySQL如何解决并发问题?
- 锁机制:MySQL提供了多种锁机制包括行级锁,表级锁,页级锁的机制来保证数据一致性,可以在读写操作时对数据进行加锁
- 事务隔离:MySQL提供了四种隔离级别:读未提交,读已提交,可重复读和串行化来控制事务之间的隔离程度。
- MVCC(多版本并发控制):Mysql使用MVCC来管理并发访问,它通过在数据库中保存不同版本的数据来实现不同事务之间的隔离。在读取数据时,Mysql会根据事务的隔离级别来选择合适的数据版本,从而保证数据的一致性。
事务的隔离级别?
- 读未提交:一个事务还没提交,另一个事务就能看到这个事务的变更。可能发生脏读,幻读,不可重复读的现象。
- 读已提交:一个事务提交之后,它的变更就能被其他事务看到。可能发生幻读,不可重复读的现象。
- 可重复读(默认隔离级别):一个事务在执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的。可能发生幻读的现象。
- 串行化:会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;
再说说这四种隔离级别的实现
- 读未提交:直接读取最新的数据,即使事务未提交
- 串行化:加读写锁(读的操作是不互斥的;在写入操作时,其他事务不能读也不能写)
- 读已提交和可重复读:通过Read View实现的,区别在于创建ReadView的时机不同,读已提交是在每个语句执行之前都会重新生成一个ReadView,而可重复读是在事务开启的时候生成一个ReadView,然后整个事务期间都使用这个ReadView。
串行化隔离级别是怎么实现的
是通过行级锁实现的,序列化隔离级别下,普通的select查询是会对记录加S型的next-key锁,其他事务就没办法对这些已经加锁的记录进行增删改操作了,从而避免了脏读,幻读,不可重复读。
MVCC实现原理?
MVCC允许多个事务同时读取同一行的数据,而不会彼此阻塞,每个事务看到的数据版本是该事务开始时的数据版本。
MVCC重点在于生成的ReadView,ReadView有四个字段:
- creator_trx_id:创建该ReadView的事务的事务id
- m_ids:创建ReadView时,当前数据库中活跃且未提交的事务id列表
- min_trx_ids:创建ReadView时当前数据库中活跃且未提交的事务中最小事务的事务id
- max_trx_ids:创建ReadView时当前数据库中应该给下一个事务的id值
创建ReadView之后,可以将记录中的trx_id划分为以下三种情况
这种通过版本链来控制并发事务访问同一个记录时的行为就叫MVCC。
一条update是不是原子性的?为什么?
是原子性的,主要通过锁+undolog日志保证原子性的
- 执行update时,会加行级锁,保证一个事务更新一条记录时,不会被其他事务干扰。
- 事务执行过程中,会生成undolog,如果事务执行失败,可以通过undolog日志进行回滚。
滥用事务,或者一个事务里有特别多SQL的弊端
事务的资源在事务提交之后才会释放,比如存储资源、锁
如果一个事务里有特别多的sql:
- 锁定的数据太多,容易造成大量的锁超时和死锁
- mysql在每条记录更新的时候都会同时记录一条回滚操作,回滚记录会占用大量的空间,事务回滚时间长。
- 执行时间长容易造成主从延迟,主库必须等事务执行完才会写入binlog,再传给备库。
锁
mysql有哪些锁?
- 全局锁:通过flush tables with read lock 语句会将整个数据库就处于只读状态了,这时其他线程执行以下操作,增删改或者表结构修改都会阻塞。全局锁主要应用于做全库逻辑备份,这样在备份数据期间,不会因为数据或者表结构的更新,而出现备份文件的数据与预期的不一样。
- 表级锁:
- 表锁:通过lock tables语句可以对表加锁,表锁除了会限制别的线程读写外,也会限制本线程接下来的读取操作。
- 元数据锁:当我们对数据库进行操作时,会自动给这个表加上MDL,对一张表进行CRUD操作时,加的是MDL读锁;对一张表做结构变更操作时,加的是MDL写锁;MDL是为了保证当用户对表进行CRUD操作时,防止其他线程对这个表结构做了变更。
- 意向锁:当执行插入、更新、删除操作,需要先对表加上意向独占锁,然后该记录加独占锁。意向锁的目的是为了快速判断表里是否有记录被加锁。
- 行级锁:InnoDB支持,MyISAM不支持
- 记录锁:锁住的是一条记录,而且记录锁是由S锁和X锁之分的,满足读写互斥,写写互斥。
- 间隙锁:只存在于可重复读隔离级别,目的是为了解决幻读问题
- next-key-lock:是记录锁和间隙锁的组合,锁定一个范围并且锁定记录本身。
数据库的表锁和行锁有什么作用
表锁的作用:
- 整体控制:表锁可以用来控制整张表的并发访问,当一个事务获取到了表锁时,其他事务无法对该表进行任何读写操作,从而确保数据的完整性和一致性。
- 粒度大:表锁的粒度比较大,在锁定表的情况下,可能会影响到整个表的其他操作,可能会引起锁竞争和性能问题
- 适用于大批量操作:表锁适用于需要大批量操作数据的场景,例如表的重建、大量数据的加载等。
行锁的作用:
- 细粒度控制:行锁可以精确控制对表中某行数据的访问,使得其他事务可以同时访问表中其他行的数据,在并发量大的系统中能够提高并发性能
- 减少锁冲突:行锁不会像表锁那样造成整个表的锁冲突,减少了锁竞争的可能性,提高了并发访问的效率
- 适用于频繁单行操作:适用于需要频繁对表中单独行进行操作的场景。
MySQL两个线程的update语句同时处理一条数据会不会阻塞?
如果是两个事务同时进行修改会阻塞(这时候会加类型为X的记录锁),InnoDB实现了行级锁。
如果两个范围不是主键或者索引,会阻塞吗?
会,如果两个范围查询的字段不是索引或者主键,会触发全表扫描,这时候会给全部索引加行级锁,相当于加了表锁,这时候第二条update执行的时候就会阻塞了。
日志
日志文件分为哪几种?
- redo log:是InnoDB层生成的日志,实现了事务中的持久性,主要用于掉电恢复
- undo log:InnoDB层生成的日志,实现了事务中的原子性,主要用于事务回滚和MVCC
- bin log:二进制日志,是Server层生成的日志,用于数据备份和主从复制
- ready log:中继日志,用于主从复制的场景,slave通过IO线程拷贝master的bin log后本地生成的日志
- 慢查询日志:用于记录执行时间过长的sql,需要设置阈值后手动开启。
讲一下binlog
MySQL 在完成一条更新操作后,Server 层还会生成一条 binlog,等之后事务提交的时候,会将该事物执行过程中产生的所有 binlog 统一写 入 binlog 文件,binlog 是 MySQL 的 Server 层实现的日志,所有存储引擎都可以使用。
binlog 是追加写,写满一个文件,就创建一个新的文件继续写,不会覆盖以前的日志,保存的是全量的日志,用于备份恢复、主从复制;
binlog 文件是记录了所有数据库表结构变更和表数据修改的日志,不会记录查询类的操作,比如 SELECT 和 SHOW 操作。
binlog 有 3 种格式类型,分别是 STATEMENT(默认格式)、ROW、 MIXED,区别如下:
- STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中(相当于记录了逻辑操作,所以针对这种格式, binlog 可以称为逻辑日志),主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题,比如你用了 uuid 或者 now 这些函数,你在主库上执行的结果并不是你在从库执行的结果,这种随时在变的函数会导致复制的数据不一致;
- ROW:记录行数据最终被修改成什么样了(这种格式的日志,就不能称为逻辑日志了),不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录,比如执行批量 update 语句,更新多少行数据就会产生多少条记录,使 binlog 文件过大,而在 STATEMENT 格式下只会记录一个 update 语句而已;
- MIXED:包含了 STATEMENT 和 ROW 模式,它会根据不同的情况自动使用 ROW 模式和 STATEMENT 模式;
有了undolog为什么还需要redolog呢
防止断电重启导致还没来得及落盘的脏页数据就会丢失,当有一条记录需要更新的时候,InnoDB 引擎就会先更新内存(同时标记为脏页),然后将本次对这个页的修改以 redo log 的形式记录下来,这个时候更新就算完成了。后续,InnoDB 引擎会在适当的时候,由后台线程将缓存在 Buffer Pool 的脏页刷新到磁盘里,这就是 WAL (Write-Ahead Logging)技术。
WAL技术指的是, MySQL 的写操作并不是立刻写到磁盘上,而是先写日志,然后在合适的时间再写到磁盘上。
undolog和redolog的区别在于
- undolog记录了事务开始前的数据状态,记录的是更新之前的值
- redolog记录的是事务完成后的数据状态,记录的是更新后的值
事务提交之前系统崩溃,重启后会通过undolog回滚事务,事务提交之后发生了崩溃,重启后会根据redolog恢复事务。
其次,redolog是追加写的操作,所以磁盘操作是顺序写,比随机写高效很多。
redolog怎么保证持久性
- WAL技术
- 顺序写
- Checkpoint机制:MySQL会定期将内存中的数据刷新到磁盘,同时将最新的LSN(Log Sequence Number)记录到磁盘中,这个LSN可以确保redo log中的操作是按顺序执行的。在恢复数据时,系统会根据LSN来确定从哪个位置开始应用redo log。
binlog两阶段提交过程是怎样的?
- prepare阶段:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit = 1 的作用);
- commit阶段:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘(sync_binlog = 1 的作用),接着调用引擎的提交事务接口,将 redo log 状态设置为 commit,此时该状态并不需要持久化到磁盘,只需要 write 到文件系统的 page cache 中就够了,因为只要 binlog 写磁盘成功,就算 redo log 的状态还是 prepare 也没有关系,一样会被认为事务已经执行成功;
性能调优
mysql的explain有什么作用
查看sql的执行计划,主要用来分析sql语句的执行过程,比如有没有走索引,有没有外部排序,有没有索引覆盖等。
- possible_keys:表示可能用到的索引
- keys:实际用到的索引
- key_len:表示索引的长度
- rows:表示扫描的行数
- type:表示数据扫描的类型
type字段就是描述了找到所需数据时使用的扫描方式是什么,常见的类型执行效率从低到高为:
- All(全表扫描)
- index(全索引扫描)
- range(索引范围扫描):一般在where子句中使用<,>,in,between等关键词,只检索给定范围的行,属于范围查找。
- ref(非唯一索引扫描):表示采用了非唯一索引,或者是唯一索引的非唯一性前缀,返回数据可能是多条。因为虽然使用了索引,但该索引列的值并不唯一,有重复。这样即使用索引快速查找到了第一条数据,仍然不能停止,要进行目标值附近的小范围扫描。但它的好处是它并不需要扫全表,因为索引是有序的,即便有重复值,也是在一个非常小的范围内扫描。
- eq_ref(唯一索引扫描):使用主键或唯一索引时产生的访问方式,通常使用在多表联查中。比如,对两张表进行联查,关联条件是两张表的 user_id 相等,且 user_id 是唯一索引,那么使用 EXPLAIN 进行执行计划查看的时候,type 就会显示 eq_ref。
- const(结果只有一条的主键或唯一索引扫描):const 类型表示使用了主键或者唯一索引与常量值进行比较,比如 select name from product where id=1。需要说明的是 const 类型和 eq_ref 都使用了主键或唯一索引,不过这两个类型有所区别,const 是与常量进行比较,查询效率会更快,而 eq_ref 通常用于多表联查中
如果Explain用到的索引不正确,有什么办法干预?
可以使用force index,强制走索引。
例如:
EXPLAIN SELECT productName,buyPrice
FROM products
FORCE INDEX(idx_buyPrice)
WHERE buyPrice BETWEEN 10 AND 80
ORDER BY buyPrice
架构
MySQL如何进行主从复制?
MySQL主从复制依赖于binlog,也就是记录MySQL的所有变化并以二进制形式保存在磁盘上。复制的过程就是将binlog中的数据从主库复制到从库上。
这个过程一般是异步的,也就是主库上执行事务操作的线程不会等待复制binlog的线程同步完成。
MySQL的集群复制简单来说可以分为三个阶段:
- 写入binlog:主库写binlog日志,提交事务,并更新本地存储数据
- 同步binlog:把binlog复制到所有从库上,每个从库把binlog写道暂存日志上
- 回放binlog:更新存储引擎中的数据
完成主从复制后,就可以在写数据时只写主库,读数据只读从库,这样即使写请求会锁表或者锁记录也不会影响读请求的执行。
分表和分库是什么?有什么区别
- 分库是一种水平扩展数据库的技术,将数据按照一定的规则划分到多个独立的数据库中。每个数据库只负责存储部分数据,实现了数据的拆分和分布式存储。分库主要是为了解决并发连接过多,单机mysql扛不住的时候
- 分表是将单个数据库中的表拆分成多个表,每个表只负责存储一部分数据,这种数据的垂直划分能够提升查询效率,减轻单个表的压力。分表主要是为了解决单表数据量太大,导致查询性能下降的问题。
分库和分表又分为水平拆分和垂直拆分:
- 垂直分库:一般来说按照业务和功能的维度进行拆分,将不同业务数据分别放到不同的数据库中,核心理念 专库专用。按业务类型对数据分离,剥离为多个数据库,像订单、支付、会员、积分相关等表放在对应的订单库、支付库、会员库、积分库。垂直分库把一个库的压力分摊到多个库,提升了一些数据库性能,但并没有解决由于单表数据量过大导致的性能问题,所以就需要配合后边的分表来解决。
- 垂直分表:针对业务上字段比较多的大表进行的,一般是把业务宽表中比较独立的字段,或者不常用的字段拆分到单独的数据表中,是一种大表拆小表的模式。数据库它是以行为单位将数据加载到内存中,这样拆分以后核心表大多是访问频率较高的字段,而且字段长度也都较短,因而可以加载更多数据到内存中,减少磁盘IO,增加索引查询的命中率,进一步提升数据库性能。
- 水平分库:是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,以此实现水平扩展,是一种常见的提升数据库性能的方式。这种方案往往能解决单库存储量及性能瓶颈问题,但由于同一个表被分配在不同的数据库中,数据的访问需要额外的路由工作,因此系统的复杂度也被提升了。
- 水平分表:是在同一个数据库内,把一张大数据量的表按一定规则,切分成多个结构完全相同表,而每个表只存原表的一部分数据。水平分表尽管拆分了表,但子表都还是在同一个数据库实例中,只是解决了单一表数据量过大的问题,并没有将拆分后的表分散到不同的机器上,还在竞争同一个物理机的CPU、内存、网络IO等。要想进一步提升性能,就需要将拆分后的表分散到不同的数据库中,达到分布式的效果。
Redis
Redis的数据结构有哪些?
五大基本数据类型:String,Hash(类似于哈希表),List(类似于双向链表),Set,ZSet(有序集合,使用score排序),新增数据类型:GEO,BitMap,HyperLoglog,Stream。
应用场景:
- 字符串:缓存对象,常规技术,分布式锁,共享session信息
- List:消息队列(注意:1、生产者需要自己实现全局唯一ID。2、不能以消费组形式消费数据)
- Hash:缓存对象,购物车等
- Set:聚合计算(并,差,交集),例如点赞,共同关注,抽奖活动等
- ZSet:排序场景,例如排行榜,电话和姓名排序等。
- BitMap:二值统计场景,比如签到,判断用户登录状态,连续签到用户总数等
- HyperLogLog:海量数据基数统计场景,比如百万级网页UV计数等
- GEO:存储地理位置信息
- Stream:消息队列,相比基于List类型实现的消息队列,有两个特有的特性:自动生成全局唯一消息ID,支持以消费组的形式消费数据
ZSet底层如何实现的?
底层数据结构由压缩列表或跳表实现的
- 如果有序集合的元素个数小于128个,每个元素的值小于64字节时,使用压缩列表
- 否则使用跳表
跳表是怎么实现的
跳表的结构由多层的链表构成,随着层级的递增,每一层的链表元素是下层元素的子集,查找元素时,会在多个层级上跳来跳去,查询的时间复杂度为O(logn)。
Redis的ZSet要同时存储元素和权重,对应跳表结构中sds类型的ele变量和double类型的score变量。
跳表如何设置层高?
跳表在创建节点时,会随机生成一个0-1范围的数,如果这个数小于0.25,那么层数就增加一层,然后继续生成下一个随机数,直到随机数的结果大于0.25结束,最终确定该节点的层数。
Redis为什么使用跳表而不是B+树
- 从内存占用上看,跳表更灵活,平衡树每个节点包含两个指针,而跳表平均每个节点包含1.33个指针。
- 做范围查找时,跳表比平衡树操作简单。跳表进行范围查找时,只需要找到范围的小值,然后对第一层链表进行若干步遍历即可,而平衡树在找到范围小值后,还需要进行中序遍历继续寻找不超过范围大值的节点。
- 实现难度:对平衡树进行增删改时,可能需要调整树的结构,逻辑复杂,而跳表的插入与删除只需要修改相邻节点的指针,操作简单又快速。
压缩列表怎么实现的
压缩列表是由连续内存块组成的顺序型数据结构,有点类似于数组。
表头有三个字段:
- zlbytes:记录整个压缩列表占用内存的字节数
- zltail:记录列表尾部节点距离起始地址有多少字节
- zllen:记录压缩列表包含多少节点。
末尾有一个字段:
- zlend:标记压缩列表结束点,固定值为0xFF(十进制255)
压缩列表查找第一个元素和最后一个元素可以通过表头字段直接定位,但查找其他元素就需要逐个查找了,因此压缩列表不适合存储过大的数据。
当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的
压缩列表的缺点是会发生连锁更新的问题,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能
介绍一下Redis的listpack
redis5.0引入的新数据结构,目的是替代压缩列表,最大的特点是listpack中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。
listpack同样采用了压缩列表中的一些优秀设计:还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存开销,listpack节点会采用不同的编码方式保存不同大小的数据。
底层结构:
- encoding:定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
- data:实际存放数据
- len:encoding+data长度
Redis哈希表怎么扩容的
类似于Java集合中HashMap的扩容机制,区别在于:redis哈希表创建的时候会创建两张哈希表,一张哈希表存储数据,另一张等待下一次rehash(没有分配空间),在redis哈希表扩容的时候有一处重要的地方,如果哈希表1在数据量非常大的情况下,将数据迁移至哈希表2的时候会涉及大量的数据拷贝,此时可能会对Redis造成阻塞,无法服务其他请求。
为了避免rehash迁移阻塞,redis采用了渐进式rehash,也就是分多次迁移数据,步骤如下:
- 给哈希表2分配空间
- 在rehash期间,每次哈希表元素进行新增,删除,查找或者更新操作时,Redis除了会执行对应的操作,还会顺序将哈希表1中索引位置上的所有key-value迁移到哈希表2上。
- 随着处理客户端发起的哈希表操作请求数量越多,最终某个时间点会把哈希表1的所有key-value迁移到哈希表2,从而完成rehash操作。
也就是说,在此期间如果要去哈希表中查找一条数据,先会在哈希表1查找,如果没有,再去哈希表2查找。
String是使用什么存储的?为什么不用C语言中的字符串
使用SDS数据结构存储,结构如下:
结构中的每个成员变量分别为:
- len,记录了字符串长度,获取字符串长度时,时间复杂度为O(1)
- alloc:分配给字符数组的空间长度,这样在修改字符串的时候,可以通过alloc-len计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出的问题。
- flags:用来表示不同类型的SDS,分别为
- sdshdr5
- sdshdr8
- sdshdr16
- sdshdr32
- sdshdr64
- buf[]:字符数组,用来保存实际数据,不仅可以保存字符串,也可以保存二进制数据。
总的来说,Redis的SDS结构在原本字符数组上增加了三个元数据:len,alloc,flags,用来解决C语言字符串的缺陷。
线程模型
Redis为什么快?
首先,单线程Redis的吞吐量高达10W/秒。
- Redis为内存型数据库,大部分操作都在内存中完成,并且采用高效的数据结构,Redis的瓶颈可能是机器的内存或者网络带宽,而并非CPU
- Redis采用单线程模型避免了多线程之间的竞争,省去了性能开销,还不会遇到死锁问题
- Redis采用了I/O多路复用模型处理大量的客户端Socket请求,I/O多路复用机制是指一个线程处理多个I/O流,也就是select/epoll机制。简单来说,在Redis只运行单线程情况下,该机制允许内核中同时存在多个监听socket和已连接socket,内核会一直监听这些socket上的连接请求或数据请求。一旦有请求到达,就会交给Redis线程处理,这就实现了一个redis线程处理多个IO流的效果。
Redis哪些地方使用了多线程
Redis单线程指的是接收客户端请求->解析请求->进行数据读写等操作->发送数据给客户端这个过程是由一个单线程(主线程)完成的,但Redis并不是单线程的,在Redis启动的时候,会启动后台线程(BIO):
- Redis在2.6版本,会启动2个后台线程,分别处理关闭文件、AOF刷盘这两个任务
- 4.0版本之后,新增了一个新的后台线程,用来异步释放Redis内存,也就是Lazyfree线程。例如执行unlink key/flushall async等命令,会把这些删除操作交给后台线程来执行,好处是不会导致Redis主线程卡顿。因此在删除一个大key的时候,不要使用DEL命令,会导致主线程卡顿,因此使用unlink更合适。
Redis为关闭文件,AOF刷盘,释放内存这些操作创建单独的线程来处理,是因为这些任务的操作都是很耗时的,把这些任务交给主线程处理很容易出现阻塞。
在6.0版本之后,也采用了多个I/O线程来处理网络请求,这是因为随着网络硬件性能的提升,Redis的瓶颈可能会出在网络I/O的处理上。但命令的执行Redis仍然采用单线程处理
Redis怎么实现IO多路复用
Redis是单线程执行的,所有操作都是按照线性顺序处理的,但由于读写操作等待用户输入和输出都是阻塞的,为了解决这个问题,Redis采用了多路复用机制
多路指的是多个网络连接客户端,复用指的是复用同一个线程,来检查多个socket的就绪状态,在单个线程通过记录跟踪每一个socket(I/O流)的状态来管理处理多个I/O流。模型如下:
- 一个socket客户端与服务端连接时,会生成对应一个套接字描述符(文件描述符的一种),每一个socket网络连接其实都对应一个文件描述符
- 多个客户端与服务端连接时,Redis使用I/O多路复用程序将客户端socket对应的FD注册到监听列表(一个队列)中。当客户端执行read,write等操作命令时,I/O多路复用程序会将命令封装成一个事件,并绑定到对应的FD上
- 文件事件处理器使用I/O多路复用模块同时监控多个文件描述符(FD)的读写情况,当accept、read、write和close文件事件产生时,文件事件处理器就会回调FD绑定的事件处理器进行处理相关命令操作。
Redis的网络模型是怎样的
6.0版本之前,使用的是单Reactor单线程模型,所有工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多线程竞争。但这种方式不能充分利用多核CPU的性能,而且在Handler对象处理业务的时候,整个进程是无法处理其他连接事件的。
因此在6.0版本之后,Redis就将网络IO的处理改为多线程的方式了,但只有网络IO是多线程的,命令的执行还是采取单线程来处理。
事务
如何实现Redis原子性
Redis执行命令的时候是单线程来处理的,执行一条命令的时候是具备原子性的,如果要保证两条指令的原子性的话,可以考虑使用lua脚本,将多个操作写入一个lua脚本中,Redis会把整个lua脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了Lua脚本中操作的原子性。
例如在分布式锁场景下,解锁期间涉及两个操作,先判断锁是不是自己的,是自己的才能删除锁,会通过lua脚本保证原子性。
除了lua有没有其他操作也能保证原子性
事务,如果Redis事务正常执行,没有发生错误,将MULTI和EXEC配合使用,就可以保证多个操作都完成。
如果事务发生错误了,就没办法保证原子性了。假设由两条指令,第一条指令出错了,而第二条指令正常执行,那事务并不会回滚,因为Redis中并没有提供回滚机制。
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> LPOP mc
QUEUED
127.0.0.1:6379(TX)> DECR key2
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) (integer) 2023
因此Redis的原子性只能在事务正常执行时保证,失败并不能保证原子性。
持久化
Redis有哪两种持久化方法,有什么优缺点
-
RDB:类似于快照,将某一时刻的内存数据,以二进制的方式写入磁盘。AOF是需要把所有日志都执行一遍,一旦AOF日志非常多,势必会造成Redis恢复操作缓慢。
RDB提供了两个命令来生成RDB文件,分别是save和bgsave,区别就在于是否在主线程里执行。save在主线程,写入RDB文件时间太长,会阻塞主线程;bgsave是创建一个子线程来生成RDB文件。
-
AOF:类似于日志,每执行一次写操作,就把该命令追加到文件中。在Redis重启时,会读取该文件记录的命令,然后逐一执行命令恢复数据,Redis提供了三种写回硬盘的策略,在Redis.conf配置文件中的appendfsync配置项可以有以下3种参数可填:
- Always:每次执行完写操作命令,同步将AOF日志数据写回硬盘。优点是可靠性高,最大程度保证数据不丢失,但缺点也很明显,每个命令都要写回硬盘开销很大。
- Everysec:每次执行完写操作命令,先将命令写入AOF文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到磁盘。缺点就是如果宕机就会丢失1秒内的数据
- No:不由Redis控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到AOF文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。优点是性能好,但是宕机会丢失很多数据。
总的来说,RDB的优点就是文件体积小,备份和恢复的速度非常快,但在两次快照之间Redis服务器发生故障,会丢失这期间的数据。而AOF的数据完整性与可靠性保证的是最好的,但是频繁的为文件追加写入命令需要消耗更多的资源,而且AOF文件占用的磁盘空间也会很大,重新恢复数据时速度也会比较慢。
缓存淘汰和过期删除
过期删除策略和内存淘汰策略有什么区别
- 内存淘汰策略是在内存满了的时候,redis会触发内存淘汰策略,淘汰一些不必要的内存资源,以腾出空间,来保存新的内容。
- 过期键删除策略是将已过期的键值删除,Redis采用的删除策略是惰性删除+定期删除
介绍一下内存淘汰策略
Redis内存淘汰策略共有八种,大体分为不进行数据淘汰和进行数据淘汰两类策略
不进行:
- noeviction(Redis3.0之后默认的淘汰策略):当运行内存超过最大内存的时候,不淘汰任何数据,如果有新的数据写入,会报错通知禁止写入。
进行淘汰:
- volatile-random:随机淘汰设置了过期时间的任意键值
- volatile-ttl:优先淘汰更早过期的键值
- volatile-lru:淘汰所有设置了过期时间的键值中,最久未使用的键值
- volatile-lfu:淘汰所有设置了过期时间的键值中,最少使用的键值
- allkeys-random:随意淘汰任意键值
- allkeys-lru:淘汰整个键值中最久未使用的键值
- allkeys-lfu:淘汰整个键值中最少使用的键值
介绍一下过期删除策略
Redis选择了惰性删除+定期删除这两种策略配合使用,以求在合理使用cpu时间和避免内存浪费之间取得平衡
惰性删除策略是Redis在访问或修改key之前,都会调用db.c文件中的expireIfNeeded函数进行过期检查,检查是否过期,如果过期,则删除该key,可以通过lazyfree_lazy_expire参数配置决定同步还是异步删除,然后返回null给客户端。没有过期就会返回正常的键值对给客户端
定期策略是每隔一段时间随机从数据库中取出一定数量的key(配置是20个)进行检查,并删除其中的过期key。Redis默认是每秒进行10次定期检查一次,同样也可以通过redis.conf对定期策略进行配置。
定期删除的流程:
- 从过期字典中随机抽取20个key
- 删除已过期的key
- 如果过期的数量超过5个,也就是25%,则继续重复步骤一,直到比例小于25%,则停止继续删除等待下一轮再检查
为什么Redis过期不立即删除key
在过期key比较多的情况下,删除过期key可能会占用相当一部分cpu时间,将这部分时间用于删除和当前任务无关的过期键上,会对服务器的响应时间和吞吐量造成影响。
集群
主从同步中的增量和完全同步怎么实现
完全同步
- 从服务器向主服务器发送SYNC命令请求开始同步
- 接收到SYNC命令后,主服务器会保存当前数据集的状态到临时的RDB文件中
- 主服务器将RDB文件发送给从服务器
- 从服务器接收到文件后,清空当前数据集,并下载RDB文件中的数据
- 在RDB文件生成和传输期间,主服务器会记录所有接收到的写命令到replication backlog buffer。
- 一旦RDB文件传输完成,主服务器会将replication backlog buffer中的命令发送给从服务器,从服务器会执行这些指令以保证数据一致性。
增量同步
增量同步允许从服务器从断点处继续同步,而不是每次进行完全同步,它基于PSYNC命令,使用了运行ID(run ID)和复制偏移量(offset)
- 从服务器恢复网络时,会发送psync命令给主服务器,此时psync命令里的offset参数不是-1;
- 主服务器收到该命令后,然后用CONTINUE响应命令告诉从服务器接下来采用增量复制的方式同步数据
- 然后主服务器将主从服务器断线,所执行的写命令发送给从服务器,然后从服务器执行这些命令。
主服务器还需要知道将哪些增量数据发送给从服务器
- repl_backlog_buffer:是一个环形缓冲区,用于主从服务器断连后,从中找到差异的数据;
- replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用master_repl_offset来记录自己写到的位置,从服务器使用slave_repl_offset来记录到自己读的位置。
哨兵机制的原理?
在Redis主从读写架构分离中,如果主节点挂了,将没有从节点来服务客户端的写操作请求,也没有主节点给从节点进行数据同步了。
这时就出现了哨兵机制,它的作用是实现主从节点故障转移,会监测主节点是否存活,如果发现主节点挂掉了,它就会选举出一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。
哨兵机制的选主节点的算法介绍一下
-
故障节点主观下线
哨兵集群的每一个哨兵节点会定时对redis集群的所有节点发心跳包检测节点是否正常,如果监测不到节点回复心跳包,那么这个节点就会被哨兵主观下线
-
故障节点客观下线
该节点被一个sentinel节点标记为主观下线后并不会立即下线,还需要其他sentinel节点共同判断为主观下线才行,sentinel集群中超过quorum数量的sentinel节点认为该节点主观下线,这时就会客观下线。如果客观下线的是从节点,就没有任何操作了,如果是主节点,就需要重新选举一个节点升级为主节点。
-
哨兵集群选举Leader
每一个sentinel节点都可以成为leader,这时会请求其他sentinel节点选举自己为leader。被请求的sentinel节点如果没有同意过其他sentinel节点的选举请求,则同意该请求(选举票数+1)。
如果一个sentinel节点获得的选举票数达到Leader最低票数(quorum和节点数/2+1的最大值),该节点就会被选举为leader,否则重新进行选举。
-
哨兵leader决定新主节点
当选出sentinel leader后,由leader从redis从节点中选择一个作为新的主节点
- 过滤故障的节点
- 选择优先级slave-priority最大的从节点作为主节点,如不存在则继续
- 选择复制偏移量(数据写入量的字节,记录写了多少数据,主服务器会把偏移量同步给从服务器,当主从的偏移量一致,则数据是完全同步)最大的从节点作为主节点,如不存在则继续
- 选择runid最小的从节点作为主节点。
redis的集群模式了解吗,有哪些优缺点?
Redis集群方案采用哈希槽,来处理数据和节点之间的映射关系。在redis集群方案中,一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key,被映射到一个哈希槽中,具体步骤分为以下两步:
- 根据键值对的key,按照CR16算法计算出一个16bit的值。
- 再用16bit值对16384取模,得到0~16383范围内的模数,每个模数代表一个相应编号的哈希槽。
然后把哈希槽映射到具体的redis节点上,有以下两种方案:
- 平均分配:在使用cluster create命令创建Redis集群时,Redis会自动把所有哈希槽平均分布到集群节点上。比如集群有9个节点,则每个节点上槽的个数为16384/9个
- 手动分配:可以使用cluster meet命令手动建立节点间的连接,组成集群,再使用cluster addslots命令,指定每个节点上的哈希槽个数。
优点:
- 高可用性:节点之间采用主从复制机制,可以保证数据的持久性和容错能力,哪怕一个节点挂掉,整个集群还可以继续工作
- 高性能:集群采用分片技术,将数据分散到多个节点,从而提高读写性能。当业务访问量大到Redis单机无法满足时,可以通过添加节点来增加集群的吞吐量。
- 扩展性好:可以根据实际需求动态增加或减少节点,从而实现可扩展性。集群模式中某些节点还可以作为代理节点,自动转发请求,增加数据模式的灵活度和可定制性。
缺点:
- 部署和维护较复杂:Redis集群的部署和维护需要考虑到分片规则、节点的布置、主从配置以及故障处理等多个方面,需要较强的技术支持,增加了节点异常处理的复杂性和成本。
- 集群同步问题:当某些节点失败或者网络出故障,集群中数据同步的问题也会出现。数据同步的复杂度和工作量随着节点的增加而增加,同步时间也较长,导致一定的读写延迟。
- 数据分片限制:Redis集群的数据分片也限制了一些功能的实现,如在一个key上修改多次,可能会因为该key所在的节点位置变化而失败。此外,由于将数据分散存储到各个节点,某些操作不能跨节点实现,不同节点之间的一些操作需要额外注意。
场景
为什么使用Redis
- 高性能:假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。
- 高并发:单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,所以直接访问Redis能够承受的请求是远远大于访问mysql的,可以考虑把数据库中的部分数据转移到缓存中去,在缓存命中的情况下可以直接请求Redis。
为什么Redis比MySQL快
- Redis是基于内存存储结构的NOSQL数据库,由于内存存储速度快,Redis能够更快地读取和写入数据,而MySQL需要频繁的磁盘IO操作。
- Redis是基于键值对存储数据的,支持简单的数据结构,而MySQL需要定义表结构,索引等复杂的关系型数据结构,Redis采用的是哈希表结构,查询时间复杂度是O(1),而mysql是b+树,复杂度为O(logn)。
- 线程模型:Redis采用的是单线程模型,避免了多线程的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁的问题
高并发场景下,Redis单节点+MySQL单节点能有多大的并发量
- 如果缓存命中,4核8gb内存的配置,redis可以支撑10w的QPS
- 如果没有命中,mysql只能支持5000左右的QPS
Redis的应用场景有哪些?
- 缓存:最常用的场景,将热门数据存储到缓存中,可以极大地提高访问速度,减轻数据库负载。
- 排行榜:使用zset用于实现排行榜和排名系统,可以方便地进行数据排序和排名
- 分布式锁:使用Redis实现分布式锁,确保多个进程或服务之间的数据操作的原子性和一致性。
- 计数器:由于Redis的原子性和高性能,它非常适合用于实现计数器和统计数据的存储,如网站访问量统计、点赞统计等。
- 消息队列:Redis的发布订阅功能使其成为一个轻量级的消息队列,它可以用来实现发布和订阅模式,以便实时处理消息。
Redis支持并发操作吗
Redis单个命令能够保证原子性,这对于并发操作非常重要。Redis可以将一系列的操作放入一个事务中执行,使用MULTI,EXEC,DISCARD以及WATCH等命令管理事务。这样可以确保一系列操作的原子性。
Redis分布式锁怎么实现的
分布式锁是在分布式环境下控制并发访问的一种机制,控制某个资源在同一时刻只能被一个应用所使用。
Redis本身可以被多个客户端共享访问,可以用来保存分布式锁。Redis的SETNX命令可以用来实现分布式锁:如果key不存在,则显示插入成功,可以用来表示加锁成功。如果key存在,表示加锁失败。
在设置分布式锁时,如果一个客户端拿到锁后发生异常,导致锁一直无法被释放,因此还需要设置过期时间。并且在设置锁的变量值时,每个客户端设置的是一个唯一值,用于标识客户端,防止在释放锁时出现误释放操作。
在释放锁涉及两个操作,一个是检查锁的变量值也就是唯一标识符是否与自己的值相等,如果相等就会删除该键值对,也就是释放锁。要保证两个操作的原子性,还需要用到Lua脚本。
Redis的大key问题是什么?
指的是某个Key对应的value值所占的内存空间比较大,导致Redis的性能下降,内存不足,数据不均衡以及主从同步延迟等问题。
通常认为字符串类型的Key对应的value值占用空间大于1M,或者集合类型的k元素数量超过1w个,就算是大key。但评判为大key的标准并不是一成不变的,在高并发低延迟的场景下,仅10kb就已经算是大key了,因此要根据实际的业务场景来确立合理的大key阈值。
大key会造成什么问题?
- 内存占用过高:大key内存占用过高,可能导致可用内存不足,从而触发内存淘汰策略。极端情况下可能会导致内存耗尽,Redis实例崩溃
- 性能下降:大Key会占用大量内存空间,导致内存碎片增加,进而影响Redis的性能。对于大key的增删改查操作都会消耗更多的CPU时间和内存资源,进一步降低系统性能。
- 阻塞其他操作:某些对大Key的操作可能会导致Redis实例阻塞。例如,使用DEL命令删除一个大Key时,可能会导致Redis实例在一段时间内无法响应其他客户端请求,从而影响系统的响应时间和吞吐量。
- 网络拥塞:每次获取大key产生的网络流量较大,可能造成机器或局域网的带宽被打满,同时波及其他服务。例如:一个大key占用空间是1MB,每秒访问1000次,就有1000MB的流量。
- 主从同步延迟:当Redis实例配置了主从同步时,大Key可能导致主从同步延迟。由于大Key占用较多内存,同步过程中需要传输大量数据,这会导致主从之间的网络传输延迟增加,进而影响数据一致性。
- 数据倾斜:在Redis集群模式中,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。另外也可能造成Redis内存达到maxmemory参数定义的上限导致重要的key被逐出,甚至引发内存溢出。
如何解决大key问题
- 对大key进行拆分
- 对大key进行删除:某些大key并不适合使用redis进行存储,应将其转移到其他存储位置,并在Redis中异步删除此数据
- 监控Redis的内存水位:可以通过监控系统设置合理的Redis内存报警阈值进行提醒,例如Redis内存使用率超过70%、Redis的内存在一小时内增长率超过20%
- 对过期数据进行定期清理,堆积大量过期数据会造成大key的产生,例如在对hash数据类型中以增量的形式不断写入大量数据而忽略了数据的时效性。可以通过定时任务的方式对失效数据进行处理。
什么是热Key,如何解决热key问题
一个key被请求频率很高,例如:
- QPS集中在特定的key:redis实例的总QPS为10000,而其中一个key的每秒访问率达到了7000
- 带宽使用率集中在特定的key:对一个拥有上千个成员且总大小为1MB的Hash key每秒发送大量的Hash get请求
- CPU使用时间占比集中在特定的key:对一个拥有数万个成员(ZSET类型)每秒发送大量的ZRANGE请求。
解决热key问题有以下思路:
- 在Redis集群架构中对热key进行复制,例如将热key foo复制出三个内容完全一样的key并命名为foo2、foo3、foo4,将这三个key迁移到其他数据分片来解决单个数据分片的热key压力。
- 使用读写分离架构,如果热key的产生来自于读请求,可以将实例改造为读写分离架构来降低每个数据分片的读请求压力。
Redis和MySQL如何保证数据一致性
读数据时,采取旁路缓存策略,如果缓存未命中,会从数据库中加载数据到缓存中,对于写数据,更新数据库后,再删除缓存。
缓存是通过牺牲强一致性来提高性能的,如果遇到需要数据库和缓存数据保持强一致性时(如银行系统),就不适合使用缓存。因为使用缓存虽然提升了性能,但是会有数据更新的延迟,且设置缓存还需要设置过期时间,过短或者过长都不好。
可以通过一些方案来保证最终一致性的,比如消息队列方案,以及canal+消息队列方案
消息队列方案
将删除缓存的操作加入到消息队列中,由消费者来操作数据。
- 如果应用删除缓存失败:可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过一定次数仍未成功,就需要向业务层发送错误信息了
- 删除缓存成功后,就需要把数据从消息队列中移除,避免重复操作。
这种方案对于原先已经架构成熟的系统来说十分麻烦,因为会造成许多业务代码的入侵。
订阅mysql binlog,再操作缓存
先更新数据库,再删除缓存,第一步操作就是更新数据库,那么更新数据库成功就会产生一条变更日志在binlog中。此时就可以通过订阅binlog日志拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的Canal中间件就是基于这个实现的。
Canal模拟MySQL的主从复制的交互协议,把自己伪装成一个MySQL从节点,向MySQL主节点发送dump请求,MySQL收到请求后就会开始推送binlog给Canal,Canal解析binlog字节流之后,转换为便于读取的数据化结构,供下游程序订阅使用。
将binlog日志采集发送到MQ队列中,然后编写一个简单的缓存删除消息者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据一致性。
缓存雪崩、击穿、穿透是什么?如何解决?
-
雪崩:当大量缓存在同一时间过期或者Redis故障宕机时,如果此时由大量的请求无法在Redis中处理,就会去访问数据库,导致数据库的压力骤增,严重的情况甚至会造成数据库宕机,从而形成一系列连锁反应造成整个系统崩溃。
解决方案:
- 均匀设置过期时间,在对缓存设置过期时间时加上一个随机数,这样就保证数据不会在同一时间过期
- 互斥锁:当业务线程在处理用户请求时,如果发现访问的数据不在Redis中,就加个互斥锁,保证同一时间内只有一个请求来构建缓存,当缓存构建完毕时再释放锁,在设置互斥锁的时候同样要加过期时间防止长时间阻塞导致死锁。
- 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存永久有效,并将更新缓存的工作交给后台线程定时更新。
-
击穿:某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易被高并发的请求冲垮。
解决方案:
- 互斥锁:保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或默认值。
- 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间。
-
穿透:当用户访问的数据既不在缓存中也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。如果有大量这样的请求到来时,数据库的压力骤增。
- 非法请求的限制:当有大量恶意请求访问不存在的数据时,也会发生缓存穿透,因此在API入口就需要判断请求参数是否合理,是否含有非法值,请求字段是否存在,避免进一步访问缓存和数据库。
- 缓存空值和默认值:当线上业务发生缓存穿透现象时,可以针对查询的数据在缓存中设置一个空值或者默认值,这样后续请求就可以在缓存中获取到结果,返回给用户,而不会继续查数据库。
- 布隆过滤器:在写入数据库时,使用布隆过滤器做个标记,在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在就不用查询数据库来判断数据是否存在。即使发生了穿透大量请求只会查询Redis和布隆过滤器,此外Redis自己也是支持布隆过滤器的。
布隆过滤器的原理?
布隆过滤器在写入数据库数据时会在过滤器中做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询数据没有被标记,说明不在数据库中,过滤器通过以下操作完成标记:
- 使用N个哈希函数分别对数据做哈希计算,得到N个哈希值
- 将第一步得到的N个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
- 将每个哈希值在位图数组的对应位置的值设置为1。
例如:
在数据库写入数据x后,会被三个哈希函数分别计算出三个哈希值,然后取模结果为1,4,6并设置值为1,下一次要查询数据是否在数据库时,通过检查布隆过滤器的1,4,6是否都为1,只要有一个不为1,那么就认为数据x不在数据库中。
布隆过滤器是基于哈希函数实现查找的,会存在哈希冲突的情况,也就是会误判,不过布隆过滤器查询到数据存在,数据库中不一定存在,如果过滤器不存在,数据库中一定不存在。
如何设计并发场景处理高并发以及超卖问题
数据库层面解决:
使用select ... for update给数据行加锁,此时其他线程可以使用select语句,但如果也是用for update加锁,或者使用delete都会被阻塞,直到前面的线程提交事务,其他排在后面的线程才能获取到锁
这种方案性能比较差,高并发情况下可能还因为获取不到数据库的连接或者因为超时等待报错。
分布式锁+分段缓存
把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。假设场景:假如现在商品有100个库存,在redis存放5个库存key,用户下单时对用户id进行%5计算,看落在哪个redis的key上,就去取哪个,这样每次就能够处理5个进程请求。
这种方案可以解决同一个商品在多用户同时下单的情况,但有个坑需要解决:当某段锁的库存不足,一定要实现自动释放锁然后换下一个分段库存再次尝试加锁处理,此种方案复杂比较高。
计算机网络
网络模型
网络OSI模型和TCP/IP模型分别介绍一下
OSI七层模型
为了使得多种设备能通过网络相互通信,和为了解决各种不同设备在网络互联中的兼容性问题,国际标准化组织制定了开放式系统互联通信参考模型,也就是OSI模型。
每一层职能都不同,如下:
- 应用层:负责给应用程序提供统一的接口
- 表示层:负责把数据转换成兼容另一个系统能识别的格式
- 会话层:负责建立、管理和终止表示层实体之间的通信会话;
- 传输层:负责端到端的数据传输
- 网络层:负责数据的路由、转发、分片
- 数据链路层:负责数据的封帧和差错检测,以及MAC寻址
- 物理层:负责在物理网络中传输数据帧
OSI模型实在太复杂,提出的也只是概念理论上的分层,并没有提供具体的实现方案。而比较常见也比较实用的是四层模型,即TCP/IP网络模型。
TCP/IP模型
TCP/IP协议被组织成四个概念层,其中有三层对应于ISO参考模型中的相应层。TCP/IP协议族并不包含物理层和数据链路层,因此它不能独立完成整个计算机网络系统的功能,必须与许多其他的协议协同工作。TCP/IP 网络通常是由上到下分成 4 层,分别是应用层,传输层,网络层,网络接口层。
- 应用层:支持HTTP、SMTP等最终用户进程
- 传输层:处理主机到主机的通信(TCP、UDP)
- 网络层:寻址和路由数据包(IP协议)
- 链路层:通过网络的物理电线、电缆或无线信道移动比特
网络为什么要分层?
- 分层的目的是为了降低耦合,各层相互独立之后,上层可以不关心下层的实现,只关心下层提供的接口服务,有利于排查网络问题,能更精细定位问题所在哪一层。
- 而且分层之后层一层之间不会产生关联性,不会因为某个层的改动,影响了其他层,比如我们应用层的 HTTP 协议,从 HTTP1.1 升级到 HTTP2.0 的时候,并不会对传输层、网络层等有影响,或者网络层的IPv4协议升级为 IPv6协议的时候,也不会影响应用层、传输层。
输入网址后,期间发生了什么
- 解析URL:解析出域名,资源路径,端口等信息,然后构造HTTP请求报文
- 域名解析:将域名解析为IP地址,会先查系统缓存是否有域名信息,如果有就返回IP地址,没有就会查看本地系统host文件有没有域名信息,如果有就返回IP地址,如果没有就去查本地DNS服务器,如果本地DNS服务器缓存中有域名信息,就返回IP地址,否则本地DNS服务器分别去根域名服务器->顶级域名服务器->权威域名服务器询问,最后拿着返回的IP交给浏览器。
- 由于HTTP是基于TCP传输的,所以在发送HTTP请求之前,需要进行三次握手,在客户端发送第一次握手的时候,TCP头部会填上SYN标记位,同时填上目标端口和源端口信息。源端口是浏览器随机生成的,目标端口会看是HTTP还是HTTPS的,http默认端口为80,https默认端口位443。
- 然后到网络层,会加上IP头,同时填上目标IP地址和源IP地址。
- 然后到数据链路层,会通过ARP协议,获取路由器的MAC地址,然后会加上MAC头,填上目标MAC地址和源MAC地址。
- 然后到物理层之后,直接把数据包转发给路由器,路由器再通过下一跳,最终找到目标服务器,然后目标服务器收到客户的SYN报文后,会响应第二次握手
- 当双方都完成三次握手后,如果是HTTP协议,客户端就会将HTTP请求发送给目标服务器;如果是HTTPS协议,客户端还要和服务端进行TLS四次握手。
- 目标服务器收到 HTTP 请求消息后,就返回 HTTP 响应消息,浏览器会对响应消息进行解析渲染,呈现给用户。
DNS域名解析使用了什么协议?
在DNS中,域名解析请求和响应都是基于UDP进行传输的
UDP是一种无连接的传输层协议,它提供了一种简单的传输机制,适用于对实时性要求较高的应用场景。DNS使用UDP协议进行域名解析是因为域名解析通常是短小而频繁的请求,UDP的无连接特性可以减少建立和断开连接的开销,并提高解析的效率。
UDP对于TCP的缺点是没办法保证数据的可靠传输,针对这个缺陷,可以在应用层实现一个超时重传机制,如果域名解析请求在一定时间内没收到响应,那么就重发域名解析请求。
应用层
应用层有哪些协议?
HTTP、HTTPS、CDN、DNS、FTP。
HTTP协议的特点有哪些?
- 基于文本:http的消息是基于文本形式传输,易于阅读和调试
- 可扩展性:http协议本身不限制数据的内容和格式,可以通过扩展头部、方法等来支持新的功能
- 灵活性:HTTP支持不同的数据格式(如HTML、JSON、XML等),适用于多种场景。
- 请求应答模式:HTTP是请求应答模式,请求方先发起连接和请求,是主动的,而应答方只有在收到请求的时候才会应答,否则没有任何动作。
- 无状态:HTTP每个请求是独立的,服务器不会保留当前HTTP请求的状态信息,需要通过其他手段(session、cookie)来维护状态。
HTTP报文有哪些部分
请求报文:
- 请求行:包含请求方法、请求目标(URL或URI)和HTTP协议版本
- 请求头部:包含关于请求的附加信息,如Host、user-agent、Content-Type等
- 空行:请求头部和请求体之间用空行分隔
- 请求体:可选,包含请求数据,通常用于POST请求等需要传输数据的情况
响应报文:
- 状态行:包含HTTP协议版本、状态码和状态信息
- 响应头部:包含关于响应的附加信息,如Content-type、Content-Length等
- 空行:响应头部和响应体之间用空行分隔
- 响应体:包含响应的数据,通常是服务器返回的HTML、JSON等内容。
哪些HTTP方法是安全或者幂等的?
幂等:指执行一次和执行多次的影响是相同的,这些执行操作不会影响系统状态。
- GET和HEAD是安全且幂等的,因为它们是只读操作,只要开发者遵循规范要求的去处理请求,无论GET和HEAD多少次,服务器上的数据都是安全的
- POST/DELETE/PUT这些方法都会增删改服务器上的资源,因此不安全
- DELETE/PUT方法都是幂等的,因为DELETE多次删除资源,效果都是资源不存在,PUT多次更新数据,结果都是一样的。
- POST不是幂等的,因为多次POST请求,会创建多个资源,所以不是幂等的。
HTTP常用的状态码?
分为五大类:
- 1xx类状态码属于提示信息,是协议处理中的一种中间状态,实际用到的比较少
- 2xx类状态码表示服务器成功处理了客户端的请求,也是我们最愿意看到的状态。
- 3xx类状态码表示客户端请求的资源发生了变动,需要客户端用新的URL重新发送请求获取资源,也就是重定向
- 4xx类状态码表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。
- 5xx类状态码表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。
常见的有:
- 200:请求成功
- 404:无法找到页面,405:请求的方法类型不支持
- 301:永久重定向,302:临时重定向
- 500:服务器内部出错。
什么时候会出现502错误码?
如果客户端访问服务器是通过nginx来反向代理到应用服务器,那么如果应用服务器出现了故障,导致nginx无法从应用服务器获取到响应,就会返回502给客户端
有个服务出现了504,这个服务出现了什么问题?
504是网关超时错误,通常是nginx将请求代理到后端应用时,后端应用没有在规定时间内返回数据,需要开发检查下应用那块有什么耗时的操作,比如是否出现了sql慢查询,接口是否发生死循环,死锁等问题,然后后端服务器系统负载高不高。
HTTP返回状态301 302分别是什么?
- 301表示永久重定向,说明请求的资源已经不在了,需要改用新的URL再次访问。
- 302代表临时跳转,例如URL地址A向URL地址B上跳转,但这并不是永久性的,在经过一段时间后,URL地址A还可能向URL地址C上跳转。
HTTP是长连接还是短连接?
HTTP1.0虽然支持长连接,但是默认是短连接,从HTTP1.1之后都是默认长连接了
长连接和短连接的区别?
短连接每次通信请求都需要建立新的连接,请求完成后立即关闭连接。这样每次请求都需要建立连接和释放连接,会增加通信开销和延迟
长连接在通信过程中保持连接的持续性,多次请求可以共享同一个连接。在长连接中,客户端和服务端建立连接后可以进行多次请求和响应,减少了连接建立和释放的开销,提高了通信效率。
HTTP1.0和1.1的区别?
长连接:HTTP1.1默认的连接是长连接,而1.0默认的是短连接
请求管道化:HTTP/1.1 支持请求管道传输(pipline) 的方式,HTTP/1.0 不支持这个模式,HTTP/1.0 请求和响应必须是串行的,当一个请求和它响应完成之后,才能发送下一个请求,而 HTTP/1.1 由于支持管道传输方式,因此可以并发发送HTTP请求,能够提高HTTP请求的效率,但是HTTP响应还是得按顺序响应,只有HTTP/2.0 实现了 HTTP 请求和响应的并发传输的能力。
host字段:HTTP1.1新增了 host 字段, 通过Host头部字段,一个物理服务器可以承载多个域名或站点。
1.1和2.0的区别?
我认为 HTTP/1.1 和 HTTP/2.0 最大的区别在于,HTTP/1.1无法实现请求和响应的并发传输,而 HTTP/2.0 能够实现请求和响应的并发传输,HTTP/1.1 虽然支持了管道化请求模式,能够并发传输HTTP请求,但是HTTP响应还是需要按顺序返回,无法做到HTTP响应并发传输,HTTP/2 引入了 stream 的概念,不同的HTTP请求和响用不同的 stream 来区分,多个 stream 复用一条 TCP 连接,只需要一条连接就可以达到了并发传输的效果。
HTTP2.0在HTTP报文格式上也做了改进,HTTP2.0用了HPACK算法压缩了HTTP头部,同时将HTTP/1.1纯文本的格式改进成了二进制格式,提高了数据传输的效率。
HTTP2.0还支持服务器主动推送资源,比如客户端在从服务器获取HTML文件时,可能这个页面渲染还需要其他CSS,这时候服务器可以主动推送CSS文件,减少了消息传递的次数。
2.0和3.0的区别?
最大区别在于传输层使用的协议不同,以往 HTTP 都是基于 TCP 协议进行传输,这次 HTTP3.0 改用 UDP 协议。这个变化是有原因的,HTTP/2.0 并发传输的特性,是在一条 TCP 连接上实现的,这里会有 TCP 队头阻塞的问题,在传输过程中,假设某个stream发生了丢包,服务端不仅不能处理这个 stream,也不能处理其他 stream,必须等这个包重传了,才能继续处理其他 stream。为了解决这个问题,HTTP/3.0 将传输层改用 UDP 协议,并在 UDP 基础上实现了一个可靠传输的QUIC协议,当某个流发生丢包时,只会阻塞这个stream,其他stream不会受到影响,因此不存在队头阻塞问题。
除此之外:
- HTTP/3.0 连接建立方面比 HTTP/2.0 更高效,HTTP/2.0建立连接的时候需要 3 次 TCP 握手+TLS 四次握手,而 HTTP3.0的协议只需要 3 次握手就能完成连接建立+TLS握手建立。
- 最后还有一点,HTTP/3.0 在网络环境切换的过程,可以不需要重新建立连接。以往 HTTP 都是基于 TCP 协议进行传输,TCP 是根据四元组信息唯一确认一条连接,如果四元组信息某一个信息发生变化了,这时候就需要断开连接,重新建立连接,比如4G网络切换到 WIFI 网络的时候,所有的 TCP 连接都需要重新建立,而 HTTP/3.0 是在应用层通过连接 ID 来唯一确认连接的,即使网络发生切换,也不会影响原本的连接,消除重连的成本。
HTTP用户后续的操作,服务端如何知道属于同一个用户?追问:如果服务端是一个集群机器?
可以使用session cookie机制,达到身份识别的效果。
比如,我用账号密码登录某电商,登录成功后,网站服务器会校验账号和密码,校验成功后,会生成一个唯一的session id来标识,然后将该session id设置到cookie中发送给客户端,客户端再次访问网站时,会将该Cookie发送给服务器,服务器通过Cookie中的session id可以获取用户之前的状态信息,免去了重复登录的麻烦,实现了状态保持。
追问:如果服务端是一个集群机器的话, 可以用一台 Redis 来保存 session 数据,达到共享 session 的效果,不过这种方案存在单点的问题,如果 redis 挂了,可能所有用户登陆状态都会消失了,虽然可以通过搭建 redis 集群来保证避免单点问题,但是我觉得这个方案成本太高了。
一种比较低的解决成本方案是:不用session而是改用JWT来实现用户凭证比较好,因为JWT的状态信息是保存在客户端的,服务端不再保存状态信息,天然适合分布式系统。
Cookie、Session、Token的区别
- 存储位置不同:cookie存储在客户端,即浏览器中的文本文件,通过在HTTP头中传递给服务器来进行通信;session存储在服务端,通常是存储在服务器的内存或数据库中;token也是存储在客户端,但是通常以加密的方式存储在localStorage或sessionStorage中
- 安全性不同:cookie存储在客户端,可能会被窃取或篡改,因此存储敏感信息时需要进行加密处理;session存储在服务端,通过session ID将客户端与服务端关联起来,可以避免敏感数据直接暴露;token通常使用加密算法生成,有效期短且单向不可逆,可以提供较高的安全性。
- 跨域支持不同:为了防止安全事故,cookie不支持跨域传输,而session机制通常是通过Cookie存储session id的,因此session id默认情况下也是不支持跨域的;但token可以轻松实现跨域,因为token是存储在客户端的LocalStorage或者作为请求头的一部分发送到服务器的,所以不同的域名token信息传输通常是不受影响的。
- 状态管理不同:cookie是应用程序通过在客户端存储临时数据,用于实现状态管理的一种机制;Session 是服务器端记录用户状态的方式,服务器会为每个会话分配一个唯一的 Session ID,并将其与用户状态相关联;Token 是一种用于认证和授权的一种机制,通常表示用户的身份信息和权限信息。
JWT的原理以及校验机制?
JWT由3部分组成,分别是头部,负载,签名。头部描述令牌使用的签名算法,负载描述的是用户信息,比如用户名称、过期时间等,头部和负载都不会加密的,只是使用Base64编码。最后一部分签名是对头部和负载两部分数据进行签名,签名的过程是:使用头部的签名算法,通过服务器的密钥对前面两部分内容进行加密计算。
校验JWT令牌的过程是服务端收到客户端发过来的JWT令牌后,服务端会取出头部和负载数据,然后用自己的密钥对这两部分数据进行加密计算,将得到的加密结果和客户端发过来的JWT的签名进行对比,如果相同,就表示前面两部分没有被人篡改,这时候服务器可以进行其他验证,比如检查JWT是否过期,如果没问题,就会执行业务逻辑。
JWT令牌为什么能解决集群部署
JWT包含身份验证和会话信息,可以让服务器无需存储会话信息,就让服务器成为无状态的了,从而比较容易实现扩展。
JWT的缺点?
JWT的最大缺点是令牌难以主动失效,一旦JWT签发了,在到期之前就会始终有效,除非服务器搞了额外的逻辑,比如设计一个“黑名单”的额外逻辑,用来把要主动失效的令牌集中存储起来,然后,每次使用JWT进行请求的话都会先判断这个JWT是否存在于黑名单中。
还有,JWT要防止盗用的问题,因为JWT包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限,为了减少盗用,JWT的有效期设计的比较短,而且不应该使用HTTP协议明文传输,而是使用HTTPS协议传输。
什么是跨域?什么情况下会发生跨域请求?
当一个网页去尝试访问不同源的资源的时候,就意味着发生了跨域请求,只要域名、协议、端口这三个信息任意一个都不同,都认为是不同源的URL。
跨域请求在浏览器上是不被允许的,只要在浏览器上发生跨域请求操作时,浏览器就会自动抛出错误。
如果想绕过这个限制,可以用跨域资源共享技术,实现的方式是服务器需要在响应头上添加Access-Control-Allow-Origin的字段,这个字段是设置为需要放行的域名,浏览器识别到了才能放行该请求。
HTTP和HTTPS有什么区别?
- 安全性:HTTP是明文传输协议,数据在传输过程中不加密,容易被窃听和篡改。而HTTPS通过使用SSL/TLS协议对数据进行加密,提供了更高的安全性和数据保护。
- 建立连接:HTTP连接建立相对简单,TCP三次握手之后便可进行HTTP报文传输。而HTTPS在三次握手后,还需进行SSL/TLS的握手过程,才可进入加密报文传输。
- 端口号:HTTP默认使用端口号80进行通信,而HTTPS默认使用443端口号
- 证书:HTTPS需要使用数字证书来验证服务器的身份,并确保数据传输的安全性。证书由受信任的第三方机构颁发,用于证明服务器的身份和所有权。而HTTP没有使用证书进行身份验证和加密。
了解过哪些加密算法?
对称以及非对称加密算法,哈希算法这三种算法
- 在 HTTPS 协议里,对称加密算法和非对称加密算法这两种算法都会用到,对称加密算法就是用一个密钥进行加解密,比如 AES 算法,非对称加密则是有 2 个密钥,分别是公钥和私钥,比如RSA算法。对称加密算法适用于大量数据的加密和解密,而非对称加密算法适用于密钥交换和数字签名等场景。
- 哈希算法主要用过 MD5 算法,哈希算法是一种单向算法,用户可以通过哈希算法对目标信息生成一段特定长度的唯一的哈希值,却不能通过这个哈希值重新获得目标信息,所以用于数据完整性校验方面。
对称和非对称加密是什么?各自有哪些算法?
对称加密和解密都是用同一个密钥进行操作,加密和解密过程速度较快,适合对大量数据进行加密,对称密钥必须保密,不能明文传输,常见的对称加密算法有AES、DES等。
非对称加密使用两个密钥,分别是公钥和私钥,加密和解密过程相对较慢,适合对少量数据进行加密。公钥可以任意分发,而私钥必须保密,可以通过公钥加密对称密钥,私钥解密的方式,保证对称密钥的安全传输,常见的非对称加密算法有RSA、ECC等。
HTTPS的建立过程?
- 第一次TLS握手:客户端首先会发一个 Client Hello 消息,消息里面有客户端使用的 TLS 版本号、支持的密码套件列表、客户端生成的随机数,这个随机数是用来后面生成对称密钥元素之一。
- 第二次TLS握手:当服务端收到客户端的消息后,会返回 Server Hello 消息给的客户端,消息里面有服务器确认的 TLS 版本号、密码套件、服务端生成的随机数。接着服务端为了证明自己的身份,会发送 Server Certificate 给客户端,这个消息里含有数字证书。随后,服务端发了 Server Hello Done 消息,目的是告诉客户端,我已经把该给你的东西都给你了,本次握手完毕。
- 校验证书:客户端收到服务端的数字证书的时候,会对校验服务端的证书,如果证书是合法的,客户端会用 CA 机构的公钥解密数字证书拿到服务端的公钥。
- 第三次TLS握手:客户端再次生成一个随机数,用服务端的公钥加密后,通过 Client Key Exchange 消息传给服务端。服务端收到后,用服务端的私钥解密得到客户端的第二个随机数。到这里,服务端和客户端双方都有 3 个随机数,双方根据已经得到的三个随机数,会根据算法生成对称密钥。生成完对称密钥后,客户端会发一个消息告诉服务端开始使用对称加密方式发送消息,并且还会对之前所有发送的数据做个摘要,再用对称加密加密一下,让服务器做个验证,验证对称密钥是否可用,以及之前握手信息是否有被中途篡改。
- 第四次TLS握手:服务器也是同样的操作,发送消息告诉客户端开始用对称加密方式发送消息,并且也会对数据做个摘要,并用对称密钥加密一下,让客户端做个校验,如果双方都验证加密和解密没问题,那么 TLS 四次握手正式完成了。
为什么需要三个随机数
因为计算机生成一个随机数是伪随机,那么只用一个随机数来做密钥容易被破解,但如果使用三个伪随机数就十分接近随机了,这样密钥破解的难度就变高了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!