八股文

[toc]

Java

JDK、JRE、JVM 之间的关系

  • JDK (Java Development Kit)----Java开发工具包,用于Java程序的开发。
  • JRE (Java Runtime Environment)----Java运行时环境,只能运行.class文件,不能编译。
  • JVM (Java Virtual Machine)----Java虚拟机,Java运行时环境。

Java的三大特性

面向对象是利于语言对现实事物进行抽象。面向对象具有以下特征:

继承:继承是从已有类得到继承信息创建新类的过程

封装:封装是把数据和操作数据的方法绑定起来,对数据的访问只能通过已定义的接口

多态性:多态性是指允许不同子类型的对象对同一消息作出不同的响应

多态的表现形式

重载:在同一个类中,同名方法可以根据不同的参数类型或个数提供不同的实现

重写:子类可以重写父类的方法,以提供特定于子类类型的行为。

接口实现:类可以通过实现接口中定义的方法来支持多态。

重载与重写区别

  1. 重载发生在本类,重写发生在父类与子类之间

  2. 重载的方法名必须相同,重写的方法名相同且返回值类型必须相同

  3. 重载的参数列表不同,重写的参数列表必须相同

  4. 重写的访问权限不能比父类中被重写的方法的访问权限更低

  5. 构造方法不能被重写

接口与抽象类的区别

抽象类要被子类继承,接口要被类实现

接口可多继承接口,但类只能单继承

抽象类可以有构造器、接口不能有构造器

抽象类:除了不能实例化抽象类之外,它和普通Java类没有任何区别

抽象类:抽象方法可以有public、protected和default这些修饰符、接口:只能是public

抽象类:可以有成员变量;接口:只能声明常量

深拷贝与浅拷贝的理解

深拷贝和浅拷贝就是指对象的拷贝,一个对象中存在两种类型的属性,一种是基本数据类型,一种是实例对象的引用。

浅拷贝是指,只会拷贝基本数据类型的值,以及实例对象的引用地址,并不会复制一份引用地址所指向的对象,也就是浅拷贝出来的对象,内部的类属性指向的是同一个对象

深拷贝是指,既会拷贝基本数据类型的值,也会针对实例对象的引用地址所指向的对象进行复制,深拷贝出来的对象,内部的类执行指向的不是同一个对象

强引用、软引用、弱引用、虚引用

  1. 强引用(Strong Reference):是最常见的引用方式,例如Object obj = new Object()创建的引用,只要强引用存在,垃圾收集器永远不会回收掉被引用的对象。
  2. 软引用(Soft Reference):在系统将要OutOfMemory之前,会回收软引用所引用的对象。软引用可以用来实现内存敏感的缓存。
  3. 弱引用(Weak Reference):弱引用的对象生命周期更短,在垃圾收集器工作时,只要发现弱引用的对象,不管JVM的堆空间是否足够,都会将这些对象进行回收。
  4. 虚引用(Phantom Reference):也称为幽灵引用或幽灵引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生命周期构成影响,也无法通过虚引用获取对象的实例。虚引用的目的是在对象被收集器回收时收到一个通知。

sleep和wait区别

sleep方法

​ 属于Thread类中的方法

​ 释放cpu给其它线程 不释放锁资源

​ sleep(1000) 等待超过1s被唤醒

wait方法

​ 属于Object类中的方法

​ 释放cpu给其它线程,同时释放锁资源

​ wait(1000) 等待超过1s被唤醒

​ wait() 一直等待需要通过notify或者notifyAll进行唤醒

​ wait 方法必须配合 synchronized 一起使用,不然在运行时就会抛出IllegalMonitorStateException异常

什么是自动拆装箱 int和Integer有什么区别

基本数据类型,如int,float,double,boolean,char,byte,不具备对象的特征,不能调用方法。

装箱:将基本类型转换成包装类对象 拆箱:将包装类对象转换成基本类型的值

java为什么要引入自动装箱和拆箱的功能?主要是用于java集合中,List list=new ArrayList();

list集合如果要放整数的话,只能放对象,不能放基本类型,因此需要将整数自动装箱成对象。

实现原理:javac编译器的语法糖,底层是通过Integer.valueOf()和Integer.intValue()方法实现。

区别:

​ Integer是int的包装类,int则是java的一种基本数据类型 ​ Integer变量必须实例化后才能使用,而int变量不需要 ​ Integer实际是对象的引用,当new一个Integer时,实际上是生成一个指针指向此对象;而int则是直接存储数据值 ​Integer的默认值是null,int的默认值是0

如果一个包装类为null,那么可以拆箱成基本数据类型吗

不可以。如果一个包装类(如 Integer, Double, Character 等)为 null,尝试将其拆箱(自动转换)成对应的基本数据类型(如 int, double, char 等)时,会抛出一个 NullPointerException

拆箱实质上是调用包装类的方法(如 Integer.intValue())来获取基本类型的值。如果包装类的对象为 null,调用其任何方法都会引发空指针异常。这是因为在Java中,通过一个 null 引用访问方法或字段都会抛出此异常

==和equals区别

==

​ 如果比较的是基本数据类型,那么比较的是变量的值

​ 如果比较的是引用数据类型,那么比较的是地址值(两个对象是否指向同一块内存)

equals

​ 如果没重写equals方法比较的是两个对象的地址值

​ 如果重写了equals方法后我们往往比较的是对象中的属性的内容

​ equals方法是从Object类中继承的,默认的实现就是使用==

重写equals为什么要重写hashcode

确保相等的对象具有相等的hashCode值。

String能被继承吗 为什么用final修饰

不能被继承,因为String类有final修饰符,而final修饰的类是不能被继承的。

String 类是最常用的类之一,为了效率,禁止被继承和重写。

为了安全。String 类中有native关键字修饰的调用系统级别的本地方法,调用了操作系统的 API,如果方法可以重写,可能被植入恶意代码,破坏程序。Java 的安全性也体现在这里。

String buffer和String builder区别

StringBuffer 与 StringBuilder 中的方法和功能完全是等价的,只是StringBuffer 中的方法大都采用了 synchronized 关键字进行修饰,因此是线程安全的,而 StringBuilder 没有这个修饰,可以被认为是线程不安全的。

在单线程程序下,StringBuilder效率更快,因为它不需要加锁,不具备多线程安全而StringBuffer则每次都需要判断锁,效率相对更低

final、finally、finalize

final:修饰符(关键字)有三种用法:修饰类、变量和方法。修饰类时,意味着它不能再派生出新的子类,即不能被继承,因此它和abstract是反义词。修饰变量时,该变量使用中不被改变,必须在声明时给定初值,在引用中只能读取不可修改,即为常量。修饰方法时,也同样只能使用,不能在子类中被重写。

finally:通常放在try…catch的后面构造最终执行代码块,这就意味着程序无论正常执行还是发生异常,这里的代码只要JVM不关闭都能执行,可以将释放外部资源的代码写在finally块中。

finalize:Object类中定义的方法,Java中允许使用finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在销毁对象时调用的,通过重写finalize() 方法可以整理系统资源或者执行其他清理工作。

Object中有哪些方法

protected Object clone()--->创建并返回此对象的一个副本。

boolean equals(Object obj)--->指示某个其他对象是否与此对象“相等

protected void finalize()--->当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。

Class<? extendsObject> getClass()--->返回一个对象的运行时类。

int hashCode()--->返回该对象的哈希码值。

void notify()--->唤醒在此对象监视器上等待的单个线程。

void notifyAll()--->唤醒在此对象监视器上等待的所有线程。

String toString()--->返回该对象的字符串表示。

void wait()--->导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法。

void wait(long timeout)--->导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll()方法,或者超过指定的时间量。

void wait(long timeout, int nanos)--->导致当前的线程等待,直到其他线程调用此对象的 notify()

synchronized底层实现是什么 lock底层是什么 有什么区别

Synchronized原理:

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。

代码块的同步是利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。

Lock原理:

​ Lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)

​ Lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。

​ Lock释放锁的过程:修改状态值,调整等待链表。

​ Lock大量使用CAS+自旋。因此根据CAS特性,lock建议使用在低锁冲突的情况下。

Lock与synchronized的区别:

① synchronized是关键字,lock是java类,默认是不公平锁(源码)。 ② synchronized适合少量同步代码,lock适合大量同步代码。 ③ synchronized会自动释放锁,lock必须放在finally中手工unlock释放锁,不然容易死锁。

了解volatile关键字吗

volatile是Java提供的最轻量级的同步机制,保证了共享变量的可见性,被volatile关键字修饰的变量,如果值发生了变化,其他线程立刻可见,避免出现脏读现象。 volatile禁止了指令重排,可以保证程序执行的有序性,但是由于禁止了指令重排,所以JVM相关的优化没了,效率会偏弱

synchronized和volatile有什么区别

volatile本质是告诉JVM当前变量在寄存器中的值是不确定的,需要从主存中读取,synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

volatile仅能用在变量级别,而synchronized可以使用在变量、方法、类级别。

volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。

volatile不会造成线程阻塞,synchronized可能会造成线程阻塞。

volatile标记的变量不会被编译器优化,synchronized标记的变量可以被编译器优化。

认识ReentrantLock吗

ReentrantLock是一种基于AQS框架的应用实现,是JDK中的一种线程并发访问的一种手段,它的功能类似与Synchronized,但是又不等于Synchronized,是一种互斥锁

ReentrantLock和synchronized的区别

  • synchronized是JVM层次的锁, reentrantLock是jdk层次的锁
  • synchronized的锁状态是无法在代码中直接判断的,但是reentrantLock可以通过ReentrantLock.isLocked()判断
  • synchronized是非公平锁,reentrantLock可以是公平也可以是非公平
  • synchronized是不可以被中断的,而reentrantLock.lockInterruptibly方法是可以中断的
  • 在发生异常时,synchronized会自动释放锁,而reentrantLock需要开发者在finally块显示释放
  • reentrantLock获取锁的形式有多种:如立即返回是否成功的tryLock(),以及等待时长的获取,更加灵活
  • synchronized在特定情况下对于已经在等待的线程,是后来的线程获取锁,而reentrantLock对于已经在等待的线程,是先来的线程获取锁

讲讲reentlock支持可重入锁特性的源码是怎么设计的?

可重入锁:可重入锁是一种支持重进入的锁机制。重进入是指一个线程在持有锁的情况下,可以再次获取相同的锁而不会被阻塞。可重入锁允许一个线程反复获得该锁,避免了死锁的发生,同时也提高了代码的简洁性和可读性。

讲讲reentlock支持区分公平和非公平特性的源码是怎么设计的?

说一下集合体系

集合一共分为两部分:Collection(单列集合)每个元素(数据)只包含一个值。

Map(双列集合)每个元素包含两个值(键值对)

ArrayList和LinkedList的区别

  • ArrayList是实现了基于动态数组的数据结构,LinkedList基于链表的数据结构。
  • 对于随机访问get和set,ArrayList效率优于LinkedList,因为LinkedList要移动指针。
  • 对于新增和删除操作add和remove,LinkedList比较占优势,因为ArrayList要移动数据。 这一点要看实际情况的。若只对单条数据插入或删除,ArrayList的速度反而优于LinkedList。但若是批量随机的插入删除数据,LinkedList的速度大大优于ArrayList. 因为ArrayList每插入一条数据,要移动插入点及之后的所有数据。

使用ArrayList的时候需要注意什么

  • 当添加元素超出当前容量时,ArrayList会自动增长(一般是当前容量的1.5倍)。这个操作涉及到数组复制,可能会影响性能。ArrayList的初始容量是10,但是可以通过构造函数指定一个不同的容量。如果预知将要存储大量元素,指定一个较大的初始容量可以减少动态扩容的次数,提高性能。合理预估数据规模可以减少扩容操作。
  • ArrayList不是线程安全的。如果在多线程环境中共享ArrayList实例,建议使用Vector或者Collections.synchronizedList方法来同步。
  • ArrayList允许添加null元素。在使用时需要注意空指针异常。
  • 在迭代ArrayList时直接使用for循环删除元素可能会导致ConcurrentModificationException异常。推荐使用Iteratorremove()方法进行安全删除。

HashMap底层是 数组+链表+红黑树,为什么要用这几类结构

数组 Node<K,V>[] table ,哈希表,根据对象的key的hash值进行在数组里面是哪个节点

链表的作用是解决hash冲突,将hash值取模之后的对象存在一个链表放在hash值对应的槽位

红黑树 JDK8使用红黑树来替代超过8个节点的链表,主要是查询性能的提升,从原来的O(n)到O(logn), 通过hash碰撞,让HashMap不断产生碰撞,那么相同的key的位置的链表就会不断增长,当对这个Hashmap的相应位置进行查询的时候,就会循环遍历这个超级大的链表,性能就会下降,所以改用红黑树

HashMap的扩容机制

元素个数 > 数组长度 * 负载因子 例如 16 * 0.75 = 12,当元素超过12个时就会扩容。 链表长度大于8并且表长小于64,也会扩容

jdk1.7: 会生成一个新table,重新计算每个节点放进新table,因为是头插法,在线程不安全的时候,可能会出现闭环和数据丢失。 jdk1.8: 会生成一个新table,新位置只需要看(e.hash & oldCap)结果是0还是1,0就放在旧下标,1就是旧下标+旧数组长度。避免了对每个节点进行hash计算,大大提高了效率。e.hash是数组的hash值,,oldCap是旧数组的长度。

HashMap和HashTable区别

  • 线程安全性不同:HashMap是线程不安全的,HashTable是线程安全的,其中的方法是Synchronized,在多线程并发的情况下,可以直接使用HashTable,但是使用HashMap时必须自己增加同步处理。

  • 是否提供contains方法:HashMap只有containsValue和containsKey方法;HashTable有contains、containsKey和containsValue三个方法,其中contains和containsValue方法功能相同。

  • key和value是否允许null值:Hashtable中,key和value都不允许出现null值。HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。

  • 数组初始化和扩容机制:HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。

ConcurrentHashMap底层原理

jdk1.7: 采用分段锁,是由Segment(继承ReentrantLock:可重入锁,默认是16,并发度是16)和HashEntry内部类组成,每一个Segment(锁)对应1个HashEntry(key,value)数组,数组之间互不影响,实现了并发访问。 jdk1.8: 抛弃分段锁,采用CAS+synchronized实现更加细粒度的锁,Node数组+链表+红黑树结构。只要锁住链表的头节点(树的根节点),就不会影响其他数组的读写,提高了并发度。

了解ConcurrentHashMap吗 为什么性能比HashTable高

​ ConcurrentHashMap是线程安全的Map容器,JDK8之前,ConcurrentHashMap使用锁分段技术,将数据分成一段段存储,每个数据段配置一把锁,即segment类,这个类继承ReentrantLock来保证线程安全,JKD8的版本取消Segment这个分段锁数据结构,底层也是使用Node数组+链表+红黑树,从而实现对每一段数据就行加锁,也减少了并发冲突的概率。

​ hashtable类基本上所有的方法都是采用synchronized进行线程安全控制,高并发情况下效率就降低 ,ConcurrentHashMap是采用了分段锁的思想提高性能,锁粒度更细化

常见并发容器

在Java中,提供了一系列的并发容器来支持多线程环境下的数据操作,这些容器提供了线程安全的操作,以便在并发应用中使用。以下是一些常见的并发容器的总结:

  • ConcurrentHashMap:这是一个线程安全的哈希表实现,支持高效的并发读写操作。它使用分段锁技术来提高并发性能。
  • ConcurrentSkipListMap:基于跳表(Skip List)实现的线程安全Map接口,支持高效的有序访问。它适用于读多写少的场景。
  • ConcurrentLinkedQueue:基于链表实现的线程安全队列,支持高效的并发插入和移除操作。它适用于高吞吐量的场景。
  • ConcurrentLinkedDeque:基于链表实现的线程安全双端队列,支持高效的并发插入和移除操作。它适用于需要频繁进行入队和出队操作的场景。
  • ArrayBlockingQueue:基于数组实现的线程安全阻塞队列,支持有界队列和定长队列两种类型。它适用于生产者-消费者模型。
  • LinkedBlockingQueue:基于链表实现的线程安全阻塞队列,支持有界队列和定长队列两种类型。它也适用于生产者-消费者模型。
  • PriorityBlockingQueue:基于优先级队列实现的线程安全阻塞队列,支持按照元素的优先级进行出队操作。它适用于需要处理高优先级任务的场景。

Java中有几种类型的流

1.字节流(Byte Streams):

  这些流以字节为单位进行操作,主要用于处理二进制数据。InputStream和OutputStream是字节流的基本类。

2.字符流(Character Streams):

  这些流以字符为单位进行操作,主要用于处理文本数据。Reader和Writer是字符流的基本类。

……

谈谈你对反射的理解

反射机制

​ Java 反射(Reflection)是一个强大的特性,它允许程序在运行时查询、访问和修改类、接口、字段和方法的信息。反射提供了一种动态地操作类的能力,这在很多框架和库中被广泛使用,例如Spring框架的依赖注入。

​ Java的反射机制的实现要借助于4个类:class,Constructor,Field,Method;其中class代表的时类对 象,Constructor-类的构造器对象,Field-类的属性对象,Method-类的方法对象。通过这四个对象我们可以粗略的看到一个类的各个组成部分。

Java 反射机制提供功能

  1. 获取 Class 对象

  2. 创建对象

  3. 访问字段

  4. 调用方法

  5. 获取构造函数

  6. 获取接口和父类

什么是 java 序列化,如何实现 java 序列化和反序列化

序列化是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。序列化是为了解决在对对象流进行读写操作时所引发的问题。

序 列 化 的 实 现 : 将 需 要 被 序 列 化 的 类 实 现 Serializable 接 口 , 该 接 口 没 有 需 要 实 现 的 方 法 , implements Serializable 只是为了标注该对象是可被序列化的,然后使用一个输出流(如:FileOutputStream)来构造一个ObjectOutputStream(对象流)对象,接着,使用 ObjectOutputStream 对象的 writeObject(Object obj)方法就可以将参数为 obj 的对象写出(即保存其状态),要恢复的话则用输入流。

在Java中,反序列化是指将序列化的对象从字节流恢复成原来的对象的过程。要进行反序列化,你需要使用ObjectInputStream类。

Http 常见的状态码

200 OK //客户端请求成功 301 Permanently Moved (永久移除),请求的 URL 已移走。Response 中应该包含一个 Location URL, 说明资源现在所处的位置 302 Temporarily Moved 临时重定向 400 Bad Request //客户端请求有语法错误,不能被服务器所理解 401 Unauthorized //请求未经授权,这个状态代码必须和 WWW-Authenticate 报头域一起使用 403 Forbidden //服务器收到请求,但是拒绝提供服务 404 Not Found //请求资源不存在,eg:输入了错误的 URL 500 Internal Server Error //服务器发生不可预期的错误 503 Server Unavailable //服务器当前不能处理客户端的请求,一段时间后可能恢复正常

Cookie 是 web 服务器发送给浏览器的一块信息,浏览器会在本地一个文件中给每个 web 服务器存储 cookie。以后浏览器再给特定的 web 服务器发送请求时,同时会发送所有为该服务器存储的 cookie

Session 是存储在 web 服务器端的一块信息。session 对象存储特定用户会话所需的属性及配置信息。当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去

Cookie 和session 的不同点

  • 无论客户端做怎样的设置,session 都能够正常工作。当客户端禁用 cookie 时将无法使用 cookie

  • 在存储的数据量方面:session 能够存储任意的java对象,cookie 只能存储 String 类型的对象‌

Cookie、Session和Token的区别

session 即会话,是一种持久网络协议,起到了在用户端和服务器端创建关联,从而交换数据包的作用。

cookie 是“小型文本文件”,是某些网站为了辨别用户身份,进行 session 跟踪而储存在用户本地终端上的数据(通常经过加密),由用户客户端计算机暂时或永久保存的信息。

token 在计算机身份认证中是令牌(临时)的意思,在词法分析中是标记的意思。一般作为邀请、登录系统使用。

多线程适合应用在什么场景

并发处理:在服务端应用程序,如Web服务器或数据库服务器中,多线程可以同时处理多个客户端的请求。这种方式可以显著提高服务器的响应时间和吞吐量。

资源密集型任务:对于需要大量计算的应用,如科学计算、图像处理或视频编码等,多线程可以充分利用多核处理器的计算能力,通过并行处理提高执行速度。

异步编程:在桌面应用或移动应用中,多线程用于执行后台任务,如文件下载、数据加载等,同时保持用户界面的响应性和流畅性。

实时系统:在需要快速响应的实时系统中,如交易系统、游戏编程或机器人控制,多线程用于隔离任务,确保高优先级任务如用户输入或关键监控能够快速执行。

性能优化:应用程序可以通过多线程将任务分配到多个核上执行,从而减少等待时间和提高应用性能。例如,在一个数据处理应用中,可以将数据加载、处理和保存各分配到不同的线程,使得每个部分可以并行工作。

等待操作优化:在涉及多个独立的I/O操作时(如网络请求或磁盘操作),多线程可以并行发起这些操作,大幅减少总的等待时间。每个线程处理一个操作,其余线程不需等待操作完成即可继续执行。

用户界面和工作线程分离:在许多应用程序中,一个专门的用户界面(UI)线程负责渲染界面和响应用户交互,其他后台线程处理耗时任务,这样可以防止耗时操作阻塞UI线程,影响用户体验。

如何使用两个线程组合打印Helloworld

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class HelloWorldThreads {
public static void main(String[] args) {
Object lock = new Object();

// 创建第一个线程打印 "Hello"
Thread thread1 = new Thread(() -> {
synchronized (lock) {
System.out.print("Hello");
// 通知另一个线程可以开始执行
lock.notify();
}
});

// 创建第二个线程打印 "World"
Thread thread2 = new Thread(() -> {
synchronized (lock) {
try {
// 等待第一个线程先打印 "Hello"
lock.wait();
System.out.print("World");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

// 启动线程
thread2.start();
thread1.start();
}
}

线程的状态转换有什么(生命周期)

  • 新建状态(New) :线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。
  • 就绪状态(Runnable): 也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。
  • 运行状态(Running):线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。
  • 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
    • 等待阻塞 -- 通过调用线程的wait()方法,让线程等待某工作的完成。
    • 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
    • 其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理
    • 完毕时,线程重新转入就绪状态。
  • 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

什么是线程池,线程池有哪些(创建)

线程池就是事先将多个线程对象放到一个容器中,当使用的时候就不用 new 线程而是直接去池中拿线程即可,节省了开辟子线程的时间,提高的代码执行效率

①ExecutorService executor = Executors.newCachedThreadPool(): 创建一个缓存线程池,灵活回收线程,任务过多,会oom。 ②ExecutorService executor = Executors.newFixedThreadPool(): 创建一个指定线程数量的线程池。提高了线程池的效率和线程的创建的开销,等待队列可能堆积大量请求,导致oom。 ③ExecutorService executor = Executors.newSingleThreadPool(): 创建一个单线程,保证线程的有序,出现异常再次创建,速度没那么快。 ④ExecutorService executor = Executors.newScheduleThreadPool(): 创建一个定长的线程池,支持定时及周期性任务执行。

为什么要使用线程池

线程池做的工作主要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。

主要特点:线程复用;控制最大并发数:管理线程。

第一:降低资源消耗。通过重复利用己创建的线程降低线程创建和销毁造成的消耗。

第二:提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。

第三:提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进 行统一的分配,调优和监控

由你设计一个动态线程池,你会怎么设计?

线程池底层工作原理

第一步:线程池刚创建的时候,里面没有任何线程,等到有任务过来的时候才会创建线程。当然也可以调用 prestartAllCoreThreads() 或者 prestartCoreThread() 方法预创建corePoolSize个线程 第二步:调用execute()提交一个任务时,如果当前的工作线程数<corePoolSize,直接创建新的线程执行这个任务 第三步:如果当时工作线程数量>=corePoolSize,会将任务放入任务队列中缓存 第四步:如果队列已满,并且线程池中工作线程的数量<maximumPoolSize,还是会创建线程执行这个任务 第五步:如果队列已满,并且线程池中的线程已达到maximumPoolSize,这个时候会执行拒绝策略,JAVA线程池默认的策略是AbortPolicy,即抛出RejectedExecutionException异常

ThreadPoolExecutor对象有哪些参数

  • corePoolSize:核心线程数, 在ThreadPoolExecutor中有一个与它相关的配置:allowCoreThreadTimeOut(默认为false),当allowCoreThreadTimeOut为false时,核心线程会一直存活,哪怕是一直空闲着。而当allowCoreThreadTimeOut为true时核心线程空闲时间超过keepAliveTime时会被回收。

  • maximumPoolSize:最大线程数 线程池能容纳的最大线程数,当线程池中的线程达到最大时,此时添加任务将会采用拒绝策略,默认的拒绝策略是抛出一个运行时错误(RejectedExecutionException)。值得一提的是,当初始化时用的工作队列为LinkedBlockingDeque时,这个值将无效。

  • keepAliveTime:存活时间, 当非核心空闲超过这个时间将被回收,同时空闲核心线程是否回收受allowCoreThreadTimeOut影响。

  • unit:keepAliveTime的单位。

  • workQueue:任务队列 常用有三种队列,即SynchronousQueue,LinkedBlockingDeque(无界队列),ArrayBlockingQueue(有界队列)。

  • threadFactory:线程工厂, ThreadFactory是一个接口,用来创建worker。通过线程工厂可以对线程的一些属性进行定制。默认直接新建线程。

  • RejectedExecutionHandler:拒绝策略 也是一个接口,只有一个方法,当线程池中的资源已经全部使用,添加新线程被拒绝时,会调用RejectedExecutionHandler的rejectedExecution法。默认是抛出一个运行时异常。

怎么设定核心线程数和最大线程数

需要分析线程池执行的任务的特性: CPU 密集型还是 IO 密集型

  • CPU密集型任务 尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,只能增加上下文切换的次数,因此会带来额外的开销。

  • IO密集型任务 可以使用稍大的线程池,一般为2*CPU核心数+1。 因为IO操作不占用CPU,不要让CPU闲下来,应加大线程数量,因此可以让CPU在等待IO的时候去处理别的任务,充分利用CPU时间。

线程池四大拒绝策略

①new ThreadPoolExecutor.AbortPolicy(): 添加线程池被拒绝,会抛出异常(默认策略)。 ②new ThreadPoolExecutor.CallerRunsPolicy(): 添加线程池被拒绝,不会放弃任务,也不会抛出异常,会让调用者线程去执行这个任务(就是不会使用线程池里的线程去执行任务,会让调用线程池的线程去执行)。 ③new ThreadPoolExecutor.DiscardPolicy(): 添加线程池被拒绝,丢掉任务,不抛异常。 ④new ThreadPoolExecutor.DiscardOldestPolicy(): 添加线程池被拒绝,会把线程池队列中等待最久的任务放弃,把拒绝任务放进去。

ThreadLocal是什么

ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。

ThreadLocal的低层数据结构是什么

ThreadLocal是作为当前线程中属性ThreadLocalMap集合中的某一个Entry的key值Entry(threadlocl,value),虽然不同的线程之间threadlocal这个key值是一样,但是不同的线程所拥有的ThreadLocalMap是独一无二的,也就是不同的线程间同一个ThreadLocal(key)对应存储的值(value)不一样,从而到达了线程间变量隔离的目的,但是在同一个线程中这个value变量地址是一样的。 注意ThreadLocal对象是一个「弱引用」。

1、每个Thread线程内部都有一个Map(ThreadLocalMap)

2、Map里面存储ThreadLocal对象(key)和线程的变量副本(value)

3、Thread内部的Map是由ThreadLocal维护的,由ThreadLocal负责向map获取和设置线程的变量值。

4、对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离互不干扰。

ThreadLocal的使用场景

ThreadLocal 适用于如下两种场景:1、每个线程需要有自己单独的实例;2、实例需要在多个方法中共享,但不希望被多线程共享。具体如下:

1、存储用户Session(不同线程获取到的用户信息不一样)

2、数据库连接,处理数据库事务

3、数据跨层传递

4、Spring使用ThreadLocal解决线程安全问题

如何正确使用ThreadLocal

Entry将ThreadLocal作为Key,值作为value保存,它继承自弱引用, 如果使用完ThreadLocal不进行remove的话会造成内存泄漏。

1、将ThreadLocal变量定义成private static的,这样的话ThreadLocal的生命周期就更长,由于一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,防止内存泄露

2、每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

ThreadLocal为什么不将key设置为强引用

如果key设计成强引用且没有手动remove(),ThreadLocal ref被回收了,但是因为threadLocalMap的Entry强引用了threadLocal(key就是threadLocal), 造成ThreadLocal无法被回收。

当前线程始终有强引用链CurrentThread Ref → CurrentThread →Map(ThreadLocalMap)-> entry,Entry就不会被回收( Entry中包括了ThreadLocal实例和value),导致Entry内存泄漏,也就是说ThreadLocalMap中的key使用了强引用是无法完全避免内存泄漏的

弱引用比强引用可以多一层保障弱引用的 ThreadLocal 会被回收,对应value在下一次 ThreadLocaI 调用 get()/set()/remove() 中的任一方法的时候会被清除,从而避免内存泄漏。

ThreadLocal中什么东西在栈上?什么东西在堆上

什么是CAS锁

在并发编程中,CAS(Compare And Swap)锁是一种乐观锁机制,用于实现多线程之间的同步。CAS操作包括三个步骤:读取内存值、比较内存值与预期值、如果相等则更新内存值。CAS锁可以有效地解决传统锁机制中的性能问题和死锁问题,是并发编程中常用的同步手段之一。

CAS操作主要包括以下三个步骤:

  1. 读取内存值:首先从内存中读取需要操作的变量的当前值。
  2. 比较内存值与预期值:将读取到的内存值与预期值进行比较,如果相等,则执行更新操作;否则不做任何操作。
  3. 更新内存值:如果比较结果为相等,则将内存值更新为新值,否则不做任何操作。

Atomic原子类了解多少 原理是什么

基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean:布尔型原子类 数组类型

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray:引用类型数组原子类

引用类型

  • AtomicReference:引用类型原子类
  • AtomicStampedReference:原子更新引用类型里的字段原子类
  • AtomicMarkableReference :原子更新带有标记位的引用类型
  • AtomicIntegerFieldUpdater:原子更新整形字段的更新器
  • AtomicLongFieldUpdater:原子更新长整形字段的更新器
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,以及解决使用 CAS 进行原子更新时可能出现的 ABA 问题

AtomicInteger 类利用 CAS (Compare and Swap) + volatile + native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 的原理,是拿期望值和原本的值作比较,如果相同,则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是个本地方法,这个方法是用来拿“原值”的内存

Java死锁如何避免

造成死锁的几个原因

1.一个资源每次只能被一个线程使用

2.一个线程在阻塞等待某个资源时,不释放已占有资源

3.一个线程已经获得的资源,在未使用完之前,不能被强行剥夺

4.若干线程形成头尾相接的循环等待资源关系

这是造成死锁必须要达到的4个条件,如果要避免死锁,只需要不满足其中某一个条件即可。而其中前3个条件是作为锁要符合的条件,所以要避免死锁就需要打破第4个条件,不出现循环等待锁的关系。

在开发过程中

1.要注意加锁顺序,保证每个线程按同样的顺序进行加锁

2.要注意加锁时限,可以针对锁设置一个超时时间

3.要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决

造成死锁的四个必要条件

互斥: 当资源被一个线程占用时,别的线程不能使用。 不可抢占: 进程阻塞时,对占用的资源不释放。 不剥夺: 进程获得资源未使用完,不能被强行剥夺。 循环等待: 若干进程之间形成头尾相连的循环等待资源关系。

JVM内存分哪几个区,每个区的作用是什么

JVM内存主要分为以下几个区域,每个区域的作用如下**‌:

1.方法区

  • 主要用来存储已被虚拟机加载的类的信息、常量、静态变量和即时编译器编译后的代码等数据。

  • 该区域是被线程共享的。

  • 方法区里有一个运行时常量池,用于存放静态编译产生的字面量和符号引用。该常量池具有动态性,即常量并不一定是编译时确定,运行时生成的常量也会存在这个常量池中。

2.虚拟机栈

  • 为Java方法服务,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口等信息。
  • 虚拟机栈是线程私有的,它的生命周期与线程相同。
  • 局部变量表存放的是基本数据类型、returnAddress类型(指向一条字节码指令的地址)和对象引用。

3.本地方法栈

  • 与虚拟机栈类似,但为Native方法服务。
  • 本地方法栈也是线程私有的。

4.堆

  • 所有线程所共享的一块内存,几乎所有的对象实例都在这里创建。
  • 该区域经常发生垃圾回收操作。

5.程序计数器:

  • 内存空间小,字节码解释器工作时通过改变这个计数值可以选取下一条需要执行的字节码指令。
  • 分支、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内存区域是唯一一个Java虚拟机规范没有规定任何OOM情况的区域。

这些区域共同协作,使得Java虚拟机能够有效地管理内存,支持Java程序的运行和

Java中垃圾收集的方法有哪些

在Java中,垃圾收集的方法主要包括标记-清除算法、复制算法、标记整理算法(也称为标记压缩算法)以及分代垃圾回收算法。这些算法各有特点,适用于不同的内存管理需求。

  1. 标记-清除算法:这是最基础的垃圾回收算法,分为两个阶段:标记阶段和清除阶段。在标记阶段,垃圾收集器从根对象(如栈中的引用)开始遍历所有可达的对象,并将其标记为存活对象。在清除阶段,垃圾收集器扫描堆内存,清除未被标记的对象,即认为是垃圾对象,可以进行回收。这种算法实现简单,但会导致内存碎片化。

  2. 复制算法:这种算法将内存划分为两个等大小的区域,在垃圾收集时,将存活的对象复制到一个区域中,然后将另一个区域的内存一次性清理掉。这种算法有效解决了内存碎片问题,但内存使用效率不高,因为只有一半的内存可用于数据存储。

  3. 标记整理算法(标记压缩算法):此算法在标记阶段与标记-清除算法相同,但在清除阶段之后,它会将所有存活的对象往一端移动,整理内存空间。这种算法既避免了内存碎片问题,又提高了内存使用效率。

  4. 分代垃圾回收算法:这是一种综合了上述几种算法的策略,它将堆内存划分为不同的代(如新生代、老年代等),根据对象存活周期的不同采用不同的回收策略。新生代对象使用复制算法,老年代对象则可能使用标记-清除或标记整理算法。这种算法结合了各种算法的优点,提高了垃圾回收的效率

如何判断一个对象是否存活(或者GC对象的判定方法)

  • 引用计数法:所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收.

    引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象A引用对象B,对象B又引用者对象A,那么此时A,B对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。

  • 可达性算法(引用链法):该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。在java中可以作为GC Roots的对象有以下几种:虚拟机栈中引用的对象、方法区类静态属性引用的对象、方法区常量池引用的对象、本地方法栈JNI引用的对象。

什么情况下会产生StackOverflowError(栈溢出)和OutOfMemoryError(堆溢出)怎么排查

引发 StackOverFlowError 的常见原因有以下几种

  • 无限递归循环调用(最常见)

  • 执行了大量方法,导致线程栈空间耗尽

  • 方法内声明了海量的局部变量

  • native 代码有栈上分配的逻辑,并且要求的内存还不小,比如 java.net.SocketInputStream.read0 会在栈上要求分配一个 64KB 的缓存(64位 Linux)。

引发 OutOfMemoryError的常见原因有以下几种

  • 内存中加载的数据量过于庞大,如一次从数据库取出过多数据

  • 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收

  • 代码中存在死循环或循环产生过多重复的对象实体

  • 启动参数内存值设定的过小

排查:可以通过jvisualvm进行内存快照分析

Java类的加载过程

1、 加载:JVM 通过类的全限定名来查找类的字节码文件(.class 文件)并将其加载到内存中。

2、 链接

  • 验证:确保加载的类信息符合JVM的要求。

  • 准备:为类分配内存,并初始化静态变量。

  • 解析:将类中的符号引用转换为直接引用。

3、初始化:为类静态变量赋予正确的初始值,并执行静态代码块。

什么是类加载器,类加载器有哪些

类加载器就是把类文件加载到虚拟机中,也就是说通过一个类的全限定名来获取描述该类的二进制字节流。

主要有以下四种类加载器

  • 启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用

  • 扩展类加载器(extension class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类

  • 系统类加载器(system class loader)也叫应用类加载器:它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它

  • 用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现

简述java内存分配与回收策略以及Minor GC和Major GC(full GC)

内存分配 栈区:栈分为java虚拟机栈和本地方法栈

堆区:堆被所有线程共享区域,在虚拟机启动时创建,唯一目的存放对象实例。堆区是gc的主要区域,通常情况下分为两个区块年轻代和年老代。更细一点年轻代又分为Eden区,主要放新创建对象,From survivor 和 To survivor 保存gc后幸存下的对象,默认情况下各自占比 8:1:1。

方法区:被所有线程共享区域,用于存放已被虚拟机加载的类信息,常量,静态变量等数据。被Java虚拟机描述为堆的一个逻辑部分。习惯是也叫它永久代(permanment generation)

程序计数器:当前线程所执行的行号指示器。通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。线程私有的。

回收策略以及Minor GC和Major GC 对象优先在堆的Eden区分配 大对象直接进入老年代 长期存活的对象将直接进入老年代 当Eden区没有足够的空间进行分配时,虚拟机会执行一次Minor GC.Minor GC通常发生在新生代的Eden区,在这个区的对象生存期短,往往发生GC的频率较高,回收速度比较快;Full Gc/Major GC 发生在老年代,一般情况下,触发老年代GC的时候不会触发Minor GC,但是通过配置,可以在Full GC之前进行一次Minor GC这样可以加快老年代的回收速度。

NIO和IO的区别

IO(阻塞IO)

  • 流式处理:Java IO 基于流模型,数据一个接一个地处理,因此它们支持一次读取或写入一个字节。
  • 阻塞操作:IO 在数据读取过程中会阻塞,如果没有数据可读,输入流会持续等待。同样,输出流在写入数据时如果无法立即写入也会阻塞。
  • 面向连接:每个数据流都直接与一个特定的数据源(如文件、网络套接字等)连接。

NIO(非阻塞IO)

  • 缓冲区操作:NIO 是基于通道(Channel)和缓冲区(Buffer)的,数据读取到一个缓冲区,处理缓冲区中的数据。这支持批量读写操作。
  • 非阻塞操作:NIO 支持非阻塞模式,这意味着线程可以请求读写操作并立即继续执行,不必等待数据完全读取或写入。
  • 选择器:NIO 有选择器(Selector)的概念,允许一个单独的线程来监视多个输入通道,可以检测哪个通道已经准备好可以进行读写,这对于实现多路复用非常有效。

NIO和AIO的区别

  • 并发模型‌:
    • AIO‌ 提供了异步非阻塞的IO操作,通过回调函数来通知IO操作的完成。在AIO中,当进行读写等操作时,可以注册一个回调函数,然后线程就可以继续处理其他任务,直到IO操作完成时会调用这个回调函数。
    • NIO‌ 实现了非阻塞IO,通过多路复用器(Selector)轮询注册的Channel来进行IO操作。在进行读写时,线程并不需要等待操作完成就可以继续执行其他任务,但需要主动查询每个Channel的状态来确定是否有IO操作可以处理。
  • API‌:
  • AIO‌ 中使用了AsynchronousFileChannel、Future和CompletionHandler等类来实现异步IO操作。
  • NIO‌ 的核心是Channel、Buffer和Selector三个抽象。这些API允许程序在不阻塞线程的情况下进行数据传输。
  • 适用场景‌:
  • AIO‌ 更适合于那些需要大量并发处理IO操作,同时又希望最小化线程使用的场景。因为真正的异步操作可以在少量线程甚至单个线程中处理大量的IO操作。
  • NIO‌ 适合处理大量短连接,如聊天服务器或弹幕系统,其中连接数目多且每个连接上的操作比较轻量。

NIO和AIO中的通道是什么 与普通IO有什么区别

NIO通道(Channel)

  1. 概念
    • NIO通道类似于传统的I/O流,但它们可以进行双向通信,即可以读也可以写。通道与流的最大不同是通道总是从缓冲区读取数据或将数据写入缓冲区。
  2. 非阻塞性
    • NIO支持非阻塞模式,这意味着线程可以请求读写操作,而不需要等待操作完成。这允许一个线程管理多个输入和输出通道(通过选择器),提高应用程序的性能。
  3. 主要类型
    • FileChannel:从文件中读写数据。
    • SocketChannelServerSocketChannelDatagramChannel:分别用于TCP和UDP网络通信。

AIO通道(AsynchronousChannel)

  1. 概念
    • AIO引入了完全异步的通道,允许I/O操作完成后通过回调通知应用程序。这种方式不需要通道处于打开状态即可处理其他任务,从而提高资源使用效率。
  2. 异步操作
    • AIO的通道提供了一种机制,操作系统可以在I/O操作完成时通知应用程序,应用程序不需要在操作完成前阻塞或轮询检查操作状态。
  3. 主要类型
    • AsynchronousFileChannel:用于文件I/O操作。
    • AsynchronousSocketChannelAsynchronousServerSocketChannel:用于TCP网络通信,没有为UDP提供特定的异步通道。

与传统I/O流的区别

  • 方向性
    • 传统的I/O流是单向的,即InputStream仅用于读取数据,OutputStream仅用于写入数据。而NIO和AIO的通道是双向的。
  • 阻塞与非阻塞/异步
    • 传统的I/O流是阻塞的;即在文件读取或写入操作完成之前,应用程序会被挂起。
    • NIO提供非阻塞模式,允许进行更加灵活的数据处理,如在一个线程中同时处理多个通道的数据。
    • AIO则是完全的异步,应用程序可以在操作提交后继续执行,操作系统完成I/O操作后回调应用程序。
  • 性能和资源管理
    • NIO和AIO通过使用缓冲区和系统的更直接的数据路径减少了数据处理的开销,从而提高了性能。
    • 传统I/O由于其阻塞模型在处理大量数据或高并发请求时可能会成为性能瓶颈。

Java框架

谈谈你对Spring的理解

Spring 是一个开源框架,为简化企业级应用开发而生。Spring 可以是使简单的JavaBean 实现以前只有EJB 才能实现的功能。Spring 是一个 IOC 和 AOP 容器框架。

Spring 容器的主要核心是:

控制反转(IOC),传统的 java 开发模式中,当需要一个对象时,我们会自己使用 new 或者 getInstance 等直接或者间接调用构造方法创建一个对象。而在 spring 开发模式中,spring 容器使用了工厂模式为我们创建了所需要的对象,不需要我们自己创建了,直接调用spring 提供的对象就可以了,这是控制反转的思想。

依赖注入(DI),spring 使用 javaBean 对象的 set 方法或者带参数的构造方法为我们在创建所需对象时将其属性自动设置所需要的值的过程,就是依赖注入的思想。

面向切面编程(AOP),在面向对象编程(oop)思想中,我们将事物纵向抽成一个个的对象。而在面向切面编程中,我们将一个个的对象某些类似的方面横向抽成一个切面,对这个切面进行一些如权限控制、事物管理,记录日志等公用操作处理的过程就是面向切面编程的思想。AOP 底层是动态代理,如果是接口采用 JDK 动态代理,如果是类采用CGLIB 方式实现动态代理。

AOP在Spring中哪里进行应用到

  1. 事务管理:AOP可以用来管理数据库事务。通过在方法上添加事务注解或者使用tx命名空间进行配置,Spring可以在方法调用前开启事务,在方法调用后提交或回滚事务,从而实现对事务的控制。
  2. 缓存处理:AOP可以用来处理缓存。通过在方法上添加缓存注解,如@Cacheable、@CachePut或@CacheEvict,Spring可以自动管理缓存的读写,从而提高系统性能。
  3. 异常处理:AOP可以用来处理异常。通过在方法或整个类上添加异常处理注解,如@ExceptionHandler,Spring可以捕获方法中抛出的异常,并进行相应的处理,比如记录日志、返回错误信息等。
  4. 日志记录:AOP可以用来记录日志。通过在方法上添加日志记录的注解,如@Loggable,Spring可以在方法执行前后自动记录方法的调用信息、参数、返回值等,方便系统的监控和排查问题。
  5. 安全控制:AOP可以用来进行安全控制。通过在方法上添加权限验证的注解,如@PreAuthorize,Spring可以在方法调用前进行用户身份验证和权限检查,从而控制用户对方法的访问权限。
  6. 性能监控:AOP可以用来监控系统的性能。通过在方法上添加性能监控的注解,如@Profile,Spring可以统计方法的执行时间、调用次数等信息,从而进行性能优化和瓶颈分析。

Bean的生命周期、注入方式

Spring中bean的生命周期简要概括如下:

  • 实例化(Instantiation): Spring通过反射或者通过工厂方法创建bean的实例。
  • 属性赋值(Populate Properties): Spring调用Bean对应的setXxx()方法、或者构造方法给相应的属性赋值。
  • 初始化(Initialization): 调用初始化方法,进行初始化, 初始化方法是通过init-method来指定的。
  • 使用(In Use): bean可以被应用程序使用了。
  • 销毁(Destruction): 如果bean实现了DisposableBean接口,会调用destroy()方法。同样,如果在bean定义中指定了destroy-method属性,会调用指定的销毁方法。以下是一个简单的Spring bean的定义和生命周期的代码示例:

装配bean是发生在编译过程还是运行过程?

装配bean主要发生在运行过程。‌

具体来说,bean的装配涉及到读取配置文件、实例化bean以及填充bean属性等步骤,这些操作都是在程序启动或运行时进行的‌。Spring通过读取配置文件来了解如何创建和配置bean,然后在运行时实例化这些bean,并将它们注入到应用程序中‌。此外,Spring提供了两种主要的bean创建方式:通过ApplicationContext创建和通过BeanFactory创建。通过ApplicationContext创建bean时,bean在创建spring容器时就已经创建好并放在了容器中,这种方式实现了bean的预加载,提高了程序运行效率,但可能会消耗更多的内存‌。而通过BeanFactory创建bean时,bean是在需要使用时才被创建,这种方式可以节约内存,但可能会增加程序的运行时间‌。这两种方式都发生在运行过程中,而不是编译过程中。

简单的谈一下SpringMVC的工作流程

用户发送请求至前端控制器DispatcherServlet

DispatcherServlet收到请求调用HandlerMapping处理器映射器

处理器映射器找到具体的处理器,生成处理器对象及处理器拦截器(如果有则生成)一并返回给DispatcherServlet。

DispatcherServlet调用HandlerAdapter处理器适配器

HandlerAdapter经过适配调用具体的处理器(Controller,也叫后端控制器)。

Controller执行完成返回ModelAndView

HandlerAdapter将controller执行结果ModelAndView返回给DispatcherServlet

DispatcherServlet将ModelAndView传给ViewReslover视图解析器

ViewReslover解析后返回具体View

DispatcherServlet根据View进行渲染视图(即将模型数据填充至视图中)。

DispatcherServlet响应用户

说出Spring或者SpringMVC中常用的5个注解

@Component 基本注解,标识一个受Spring管理的组件

@Controller 标识为一个表示层的组件

@Service 标识为一个业务层的组件

@Repository 标识为一个持久层的组件

@Autowired 自动装配

@Qualifier("") 具体指定要装配的组件的id值

@RequestMapping() 完成请求映射

@PathVariable 映射请求URL中占位符到请求处理方法的形参

@Autowire和@Resource的区别

  1. @Autowired
  • 来源:@Autowired 是 Spring 框架中的注解。
  • 注入方式:@Autowired 是基于类型(by type)进行依赖注入的。Spring 会根据类型查找匹配的 Bean,如果有多个同类型的 Bean,可以结合 @Qualifier 注解或者 @Primary 注解来指定具体要注入的 Bean。
  1. @Resource
  • 来源:@Resource 是 Java EE 中的 JSR-250 规范中的注解,由 Java 自带,不依赖于 Spring 框架。
  • 注入方式:@Resource 是基于名称(by name)优先进行注入的,如果没有匹配的名称,再按照类型(by type)进行注入。

@Async 的实现原理是什么

  1. 线程池:Spring 默认使用 SimpleAsyncTaskExecutor 作为任务执行器,它会为每个异步任务创建一个新的线程。但是,你也可以配置自己的线程池,例如 ThreadPoolTaskExecutor,以便更好地控制并发线程的数量和资源消耗。
  2. 代理对象:当一个类被标注为 @Async 时,Spring 会为其创建一个代理对象。这个代理对象实现了相同的接口,并将实际的方法调用委托给原始对象。同时,代理对象还会处理异步调用的逻辑,如将任务提交到线程池中执行。
  3. 拦截器:在代理对象中,Spring 使用了 AOP(面向切面编程)技术来实现异步调用。具体来说,它使用了 AsyncAnnotationBeanPostProcessor 来扫描带有 @Async 注解的类,并为这些类生成代理对象。在这个过程中,它会创建一个拦截器链,其中包括 AsyncAnnotationAdvisor,这个拦截器负责处理带有 @Async 注解的方法调用。
  4. 异步执行器:当一个带有 @Async 注解的方法被调用时,拦截器会将任务封装成一个 AsyncTask 对象,并将其提交给 AsyncUncaughtExceptionHandler 进行处理。然后,AsyncUncaughtExceptionHandler 会将任务提交给线程池中的线程执行。
  5. 回调机制:为了能够在异步任务完成后获取结果或处理异常,Spring 提供了 Future 接口。当你调用一个异步方法时,它会返回一个 Future 对象,你可以通过这个对象来检查任务是否完成、获取结果或者处理异常。

总结一下,@Async 的实现原理是通过 AOP 技术和线程池来实现异步调用。它首先创建一个代理对象,然后在代理对象中处理异步调用的逻辑,包括将任务提交给线程池并返回一个 Future 对象。这样,你就可以在不阻塞当前线程的情况下执行耗时操作,从而提高应用程序的性能。

Spring中常用的设计模式

代理模式——spring 中两种代理方式,若目标对象实现了若干接口,spring 使用jdk 的java.lang.reflect.Proxy类代理。若目标兑现没有实现任何接口,spring 使用 CGLIB 库生成目标类的子类。

单例模式——在 spring 的配置文件中设置 bean 默认为单例模式。

模板方式模式——用来解决代码重复的问题。

工厂模式——在工厂模式中,我们在创建对象时不会对客户端暴露创建逻辑,并且是通过使用同一个接口来指向新创建的对象。Spring 中使用 beanFactory 来创建对象的实例。

简述SpringMVC中如何返回JSON数据

Step1:在项目中加入json转换的依赖,例如jackson,fastjson,gson等

Step2:在请求处理方法中将返回值改为具体返回的数据的类型, 例如数据的集合类List

Step3:在请求处理方法上使用@ResponseBody注解

Spring的三级缓存

  • singletonObject:一级缓存,存放完全实例化且属性赋值完成的 Bean ,可以直接使用
  • earlySingletonObject:二级缓存,存放早期 Bean 的引用,尚未装配属性的 Bean
  • singletonFactories:三级缓存,存放实例化完成的 Bean 工厂

通过三级缓存可以解决循环依赖问题。

请描述一下Spring 的事务管理

声明式事务管理的定义:用在 Spring 配置文件中声明式的处理事务来代替代码式的处理事务。这样的好处是,事务管理不侵入开发的组件,具体来说,业务逻辑对象就不会意识到正在事务管理之中,事实上也应该如此,因为事务管理是属于系统层面的服务,而不是业务逻辑的一部分,如果想要改变事务管理策划的话,也只需要在定义文件中重新配置即可,这样维护起来极其方便。

  • 基于 TransactionInterceptor 的声明式事务管理:两个次要的属性: transactionManager,用来指定一个事务治理器, 并将具体事务相关的操作请托给它; 其他一个是 Properties 类型的transactionAttributes 属性,该属性的每一个键值对中,键指定的是方法名,方法名可以行使通配符, 而值就是表现呼应方法的所运用的事务属性。

  • 基于 @Transactional 的声明式事务管理:Spring 2.x 还引入了基于 Annotation 的体式格式,具体次要触及@Transactional 标注。@Transactional 可以浸染于接口、接口方法、类和类方法上。算作用于类上时,该类的一切public 方法将都具有该类型的事务属性。

  • 编程式事物管理的定义:在代码中显式挪用 beginTransaction()、commit()、rollback()等事务治理相关的方法, 这就是编程式事务管理。Spring 对事物的编程式管理有基于底层 API 的编程式管理和基于 TransactionTemplate 的编程式事务管理两种方式。

MyBatis中 #{}和${}的区别是什么

#{}是预编译处理,${}是字符串替换;

Mybatis在处理#{}时,会将sql中的#{}替换为?号,调用PreparedStatement的set方法来赋值;

Mybatis在处理\({}时,就是把\){}替换成变量的值;

使用#{}可以有效的防止SQL注入,提高系统安全性。

Mybatis 中一级缓存与二级缓存

MyBatis的缓存分为一级缓存和 二级缓存。

一级缓存是SqlSession级别的缓存,默认开启。

二级缓存是NameSpace级别(Mapper)的缓存,多个SqlSession可以共享,使用时需要进行配置开启。

缓存的查找顺序:二级缓存 => 一级缓存 => 数据库

MySQL

SQL语句执行顺序

SQL Select 语句完整的执行顺序:

(1)from 子句组装来自不同数据源的数据;

(2)where 子句基于指定的条件对记录行进行筛选;

(3)group by 子句将数据划分为多个分组;

(4)使用聚集函数进行计算;

(5)使用 having 子句筛选分组;

(6)计算所有的表达式;

(7)select 的字段;

(8)使用order by 对结果集进行排序。

char和varchar的区别

  • char设置多少长度就是多少长度,varchar可以改变长度,所以char的空间利用率不如varchar的空间利用率高。

  • 因为长度固定,所以存取速度要比varchar快。

  • char适用于固定长度的字符串,比如身份证号、手机号等,varchar适用于不固定的字符串。

什么是索引

‌‌数据库索引是一种数据结构,用于加快数据库查询的速度和性能。‌ 索引是对数据库表中一列或多列的值进行排序的一种存储结构,它是某个表中一列或若干列值的集合和相应的指向表中物理标识这些值的数据页的逻辑指针清单

哪些情况索引会失效

(1)where 后面使用函数

(2)使用 or 条件

(3)like查询用%开头,索引失效

(4)索引列参与计算,索引失效

(5)违背最左匹配原则,索引失效

(6)索引字段发生类型转换,索引失效

三大范式

  • 第一范式:每一列属性(字段)不可分割的,字段必须保证原子性,两列的属性值相近或者一样的,尽量合并到一列或者分表,确保数据不冗余
  • 第二范式:每一行的数据只能与其中一行有关 即 主键 一行数据只能做一件事情或者表达一个意思,只要数据出现重复,就要进行表的拆分
  • 第三范式:数据不能存在传递关系,每个属性都跟主键有直接关联而不是间接关联

为什么使用B+树不用B树

  • B树只适合随机检索,而B+树同时支持随机检索和顺序检索(因为叶子节点相当于链表,保存索引值都是有序的)。 顺序检索: 按照序列顺序遍历比较找到给定值。 随机检索: 不断从序列中随机抽取数据进行比较,最终找到结果。

  • 减少了磁盘IO,提高空间利用率: 因为B+树非叶子节点不会存放数据,只有索引值,所以非叶子节点可以保存更多的索引值,这样B+树就可以更矮,减少IO次数。

  • B+树适合范围查找: 这才是关键,因为数据库大部分都是范围查找,B+树的叶子节点是有序链表,直接遍历就行,而B树的范围查找可能两个节点距离很远,只能通过中序遍历去查找,所以使用B+树更合适。

聚簇索引与非聚簇索引区别

都是B+树的数据结构

聚簇索引:将数据存储与索引放到了一块、并且是按照一定的顺序组织的,找到索引也就找到了数据,数据的物理存放顺序与索引顺序是一致的,即:只要索引是相邻的,那么对应的数据一定也是相邻地存放在磁盘上的

非聚簇索引叶子节点不存储数据、存储的是数据行地址,也就是说根据索引查找到数据行的位置再取磁盘查找数据,这个就有点类似一本书的目录,比如我们要找第三章第一节,那我们先在这个目录里面找,找到对应的页码后再去对应的页码看文章。

优势:

1、查询通过聚簇索引可以直接获取数据,相比非聚簇索引需要第二次查询(非覆盖索引的情况下)效率要高

2、聚簇索引对于范围查询的效率很高,因为其数据是按照大小排列的

3、聚簇索引适合用在排序的场合,非聚簇索引不适合

劣势;

1、维护索引很昂贵,特别是插入新行或者主键被更新导至要分页(pagesplit)的时候。建议在大量插入新行后,选在负载较低的时间段,通过OPTIMIZETABLE优化表,因为必须被移动的行数据可能造成碎片。使用独享表空间可以弱化碎片

2、表因为使用uuId(随机ID)作为主键,使数据存储稀疏,这就会出现聚簇索引有可能有比全表扫面更慢,所以建议使用int的auto_increment作为主键

3、如果主键比较大的话,那辅助索引将会变的更大,因为辅助索引的叶子存储的是主键值,过长的主键值,会导致非叶子节点占用占用更多的物理空间

MyISAM和InnoDB的区别

MyISAM InnoDB
事务 不支持 支持
表锁 表锁、行锁
文件存储 3个 1个
外键 不支持 支持
索引 非聚簇索引 聚簇索引

我们一般什么时候不用InnoDB

在读写比率小于1%的情况下,可以考虑不使用InnoDB存储引擎:InnoDB存储引擎是MySQL中最常用的存储引擎之一,它支持事务处理、行级锁定和外键约束,适用于需要高并发访问和数据一致性的应用场景。然而,如果在读写比率非常低的情况下,即读写操作非常少,考虑到性能和资源利用的效率,可能会考虑使用其他存储引擎,如MyISAM。‌

悲观锁和乐观锁的怎么实现

悲观锁:select...for update是MySQL提供的实现悲观锁的方式。

乐观锁:乐观锁相对悲观锁而言,它认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回错误信息,让用户决定如何去做。

利用数据版本号(version)机制是乐观锁最常用的一种实现方式。一般通过为数据库表增加一个数字类型的 “version” 字段,当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值+1。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据,返回更新失败。

最左匹配原则

‌最左匹配原则‌是指在使用‌联合索引进行查询时,‌MySQL会优先使用最左边的列进行匹配,然后再依次向右匹配。如果遇到范围查询(如>、<、between、like),则会停止匹配。这是因为索引的底层结构是‌B+树,B+树的节点存储索引顺序是从左向右存储,检索匹配时也需要满足自左向右匹配。

MySQL事务的基本要素

  • 原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位
  • 一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。
  • 隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。
  • 持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。

事务的并发问题

  • 脏读: 也叫"读未提交",顾名思义,就是某一事务A读取到了事务B未提交的数据。事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据。
  • 不可重复读: 在一个事务内,多次读取同一个数据,却返回了不同的结果。实际上,这是因为在该事务间隔读取数据的期间,有其他事务对这段数据进行了修改,并且已经提交,就会发生不可重复读事故。事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果不一致。
  • 幻读: 在同一个事务中,第一次读取到结果集和第二次读取到的结果集不同。像幻觉一样所以叫幻读。系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。

如何解决脏读、幻读、不可重复读

  • 脏读: 隔离级别为 读提交、可重复读、串行化可以解决脏读
  • 不可重复读:隔离级别为可重复读、串行化可以解决不可重复读
  • 幻读:隔离级别为串行化可以解决幻读、通过MVCC + 区间锁可以解决幻读

事务的隔离级别

事务的隔离级别?

  • read uncommited(读取未提交内容): 在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。读取未提交的数据,也被称之为脏读(Dirty Read)
  • read committed(读取提交内容): 这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。一个事务只能看见已经提交事务所做的改变。可解决脏读
  • repeatable read(可重读): 这是MySQL的默认事务隔离级别,同一事务的多个实例在并发读取数据时,会看到同样的数据。不过理论上,这会导致另一个棘手的问题:幻读(Phantom Read)。可解决脏读、不可重复读
  • serializable(可串行化) : 这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。可解决脏读、不可重复读、幻读。

可重复读如何实现

使用的的一种叫MVCC的控制方式 ,即Mutil-Version Concurrency Control,多版本并发控制,类似于乐观锁的一种实现方式

InnoDB引擎 在表的每行记录后面保存两个隐藏的列来,分别保存了这个行的创建时间和行的删除时间。这里存储的并不是实际的时间值,而是系统版本号,当数据被修改时,版本号会加1

InnoDB 里面每个事务都有一个唯一的事务 ID,叫作 transaction id。它在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

每条记录在更新的时候都会同时记录一条 undo log,这条 log 就会记录上当前事务的 transaction id,记为 row trx_id。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

此时如果其他写事务修改了这条数据,那么这条数据的版本号就会加1,从而比当前读事务的版本号高,读事务自然而然的就读不到更新后的数据

串行化如何实现

读加共享锁,写加排他锁,读写互斥。如果有未提交的事务正在修改某些行,所有select这些行的语句都会阻塞。

共享锁和排他锁

  • 共享锁【S锁】 又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。

  • 排他锁【X锁】 又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。

什么是快照读和当前读

快照读,也称为一致性非锁定读,它不会锁定任何行记录,而是基于一个快照来读取数据。这个快照是在事务开始时创建的,因此在事务执行期间,即使其他事务修改了数据,当前事务看到的仍然是事务开始时的数据状态。快照读的实现主要依赖于InnoDB的MVCC机制。

当前读也被称为锁定读,它会对读取的行记录加锁,以保证在事务进行期间,读取的数据不会被其他事务修改。这种读操作会实时反映数据库的最新状态,因此被称为“当前”读。

MVCC是什么

MVCC是多版本并发控制,它是通过读取历史版本的数据,来降低并发事务冲突,从而提高并发性能的一种机制。它的实现依赖于隐式字段、undo日志、快照读&当前读、Read View。

实现原理由四个东西保证,他们是

undo日志:事务未提交的时候,修改数据的镜像(修改前的旧版本),存到undo日志里。以便事务回滚时,恢复旧版本数据,撤销未提交事务数据对数据库的影响。

readView:事务进行快照读时动态生成产生的视图,记录了当前系统中活跃的事务id,控制哪个历史版本对当前事务可见

DB_TRX_ID,记录每一行最近一次修改(修改/更新)它的事务ID,大小为6字节;

DB_ROLL_PTR,这个隐藏列就相当于一个指针,指向回滚段的undo日志,大小为7字节;

现在我们有张表用uuid建表,有张表用自增id建表,1kw行记录,添加数据的效率谁更高?为什么?

‌添加数据的效率自增ID更高。自增ID的优点之一是效率高,这是因为自增ID是按顺序递增的,这种顺序性有助于提高插入和查询的效率。相比之下,UUID虽然在全球范围内保证了唯一性,但它的存储空间大,且由于是随机生成的,不具有顺序性,导致索引效率较低,从而影响查询效率。此外,自增ID作为主键或索引列,能够提高查询效率,而UUID的随机性则降低了这一点。

在分布式环境下,自增ID和UUID各有其适用场景。自增ID不适用于分布式系统,因为多个节点生成的自增ID可能会冲突,需要额外的处理机制。而UUID提供了全球唯一性,避免了重复的问题,尽管它的存储空间较大且索引效率较低。雪花算法ID则在分布式环境下生成唯一的ID,满足分布式环境的需求,但同样面临存储空间较大和索引效率较低的问题。

一张表是uuid作为主键,一张表是自增主键,谁的查询效率高?为什么?

‌‌自增主键的查询效率更高。‌ 自增主键的查询效率通常高于‌UUID主键,因为自增主键的数据类型是整数,而UUID是字符串。整数类型的处理速度比字符串快,因此在查询时,自增主键的效率更高。‌

自增主键的查询效率高的原因主要有以下几点:‌

  • 存储和查询性能‌:自增主键的数据类型通常是整数,而UUID是字符串。整数类型的处理速度比字符串快,因此在存储和查询时,自增主键的效率更高。‌
  • 索引效率‌:自增主键的索引效率更高,因为整数类型的索引在数据库中更容易进行物理排序和快速查找。‌
  • 数据排序和插入顺序‌:自增主键的插入顺序是连续的,而UUID是无序的。连续的数据插入顺序可以减少数据库的IO操作,提高查询效率。

Redis

什么是大key 什么是热key

‌大key‌指的是在Redis数据库中占用较多内存空间的键值对。大key通常指的是一个键中包含了大量的数据,使得该键对应值的占用的内存超出了正常范围。大key的判断标准并不是固定的,而是相对于Redis实例的可用内存而言。当一个键的大小超出了Redis实例可用内存时,或者对一个key的操作所需的时间过长,导致性能下降或影响其他请求的处理速度,也可以说这个key是大key。

‌热key‌则指的是频繁访问的键,即在某一段时间内被频繁访问的key。热key的判断标准是基于被请求的频率,没有固定的经验值。当一个key的访问次数显著高于其他key,导致系统稳定性变差,可以定义为热key。热key的频繁访问会造成Redis的CPU占用率过高,导致响应时间延长或请求阻塞,从而可能造成系统崩溃。

什么是布隆过滤器

布隆过滤器通过使用多个哈希函数将元素映射到位数组中的特定位置,并将这些位置的值设置为1。查询时,如果所有通过哈希函数计算出的位置的值都是1,则认为元素可能存在于集合中;如果任何一个位置的值是0,则认为元素不存在。

布隆过滤器在Redis中的使用

一、黑白名单校验、识别垃圾邮件

发现存在黑名单中的,就执行特定操作。比如:识别垃圾邮件,只要是邮箱在黑名单中的邮件,就识别为垃圾邮件。

假设黑名单的数量是数以亿计的,存放起来就是非常耗费存储空间的,布隆过滤器则是一个较好的解决方案。

把所有黑名单都放在布隆过滤器中,在收到邮件时,判断邮件地址是否在布隆过滤器中即可。

二、解决缓存穿透问题

把已存在数据的key存在布隆过滤器中,相当于redis前面挡着一个布隆过滤器。

当有新的请求时,先到布隆过滤器中查询是否存在:

如果布隆过滤器中不存在该条数据则直接返回;

如果布隆过滤器中已存在,才去查询缓存redis,如果redis里没查询到则再查询Mysql数据库

布隆过滤器的优缺点

  • 布隆过滤器的优点:

1、支持海量数据场景下高效判断元素是否存在

2、布隆过滤器存储空间小,并且节省空间,不存储数据本身,仅存储hash结果取模运算后的位标记

3、不存储数据本身,比较适合某些保密场景

  • 布隆过滤器的缺点:

1、不存储数据本身,所以只能添加但不可删除,因为删掉元素会导致误判率增加

2、由于存在hash碰撞,匹配结果如果是“存在于过滤器中”,实际不一定存在

3、当容量快满时,hash碰撞的概率变大,插入、查询的错误率也就随之增加了

## Redis常见数据类型

类型 说明
string 字符串(一个字符串类型最大存储容量为512M)
list 可以重复的集合
set 不可以重复的集合
hash 类似于Map<String,String>
zset(sorted set) 带分数的set

ZSet如何实现

zset有两种不同的实现方式:紧凑列表和跳表。在元素较少或总体元素占用空间较少时,使用紧凑列表实现。在不符合使用紧凑列表的条件时,使用字典hash+跳表skiplist实现。

  • 紧凑列表是由一个双向链表来实现的。

  • 跳表(SkipList):增加了向前指针的链表叫作跳表。跳表全称叫做跳跃表,简称跳表。跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。

跳表为什么时间复杂度是log(N)

跳表(Skip List)是一种可以用于快速查找、插入和删除的数据结构,它通过构建多级索引来提高查询效率。这种数据结构允许在平均情况下以O(log⁡n)O(logn)的时间复杂度进行查找、插入和删除操作,其中nn是元素数量。跳表的实现基于链表,但通过增加多级索引,实现了类似二分查找的效果,从而大大提高了查找效率。

Redis为什么快

1)完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)

2)数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的

3)采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗

4)使用I/O多路复用模型,非阻塞IO

5)使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求

Redis为什么是单线程的

官方FAQ表示,因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了Redis利用队列技术将并发访问变为串行访问

1)绝大部分请求是纯粹的内存操作

2)采用单线程,避免了不必要的上下文切换和竞争条件

Redis服务器的的内存是多大

配置文件中设置redis内存的参数:

该参数如果不设置或者设置为0,则redis默认的内存大小为:

32位下默认是3G

64位下不受限制

一般推荐Redis设置内存为最大物理内存的四分之三,也就是0.75

Redis提供了哪几种持久化方式

RDB持久化方式能够在指定的时间间隔能对你的数据进行快照存储。

AOF持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF命令以redis协议追加保存每次写的操作到文件末尾.Redis还能对AOF文件进行后台重写,使得AOF文件的体积不至于过大。

你也可以同时开启两种持久化方式,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。

AOF持久化的时间有哪些?

Redis 的 AOF(Append Only File)持久化机制中涉及到同步到磁盘的时间配置,这决定了数据被实际写入硬盘的频率。AOF 持久化的主要时间配置如下:

  1. always
    • 每当Redis执行一个写命令后,它会立即将命令写入AOF文件,并且确保该命令写入硬盘(通过fsync调用)。这提供了最高级别的数据安全性,因为几乎可以保证不会丢失任何写入的数据。
    • 但这种模式的缺点是性能影响较大,因为每次写入操作都需要进行磁盘同步,这可能导致显著的延迟和吞吐量降低。
  2. everysec(默认设置):
    • 在这种模式下,Redis会每秒钟执行一次fsync,将所有新的写命令从操作系统的文件系统缓冲区同步到硬盘。
    • 这种设置是性能和数据安全性之间的折衷,能够确保在发生故障时最多丢失一秒钟的数据,同时对性能的影响相对较小。
  3. no
    • 在这种配置下,Redis不会主动进行fsync操作。相反,它依赖于操作系统的缓冲区策略来决定何时将数据写入磁盘。
    • 这种模式可以提供最高的性能,因为它几乎不增加任何与磁盘同步相关的延迟。然而,这也意味着在系统崩溃的情况下,数据丢失的风险最高,因为自上次操作系统执行fsync以来的所有写命令都可能丢失。

你觉得AOF和RDB混合持久化会丢数据吗?会在哪个范围丢数据,为什么?

使用AOFRDB混合持久化模式时,Redis可以减少数据丢失的风险,但在某些情况下仍然可能会丢失数据。主要原因和丢失数据的范围通常与以下因素有关:

持久化配置

  • RDB:在Redis服务器意外重启的情况下,如果使用RDB,那么自从上一次快照之后的所有数据变更都可能会丢失,因为RDB是在配置的时间间隔中周期性保存快照的。
  • AOF:在默认配置下,AOF可能配置为每秒写入磁盘(通常使用appendfsync everysec)。这意味着在极端情况下,如果服务器在写入后的一秒内发生故障,最近一秒内的数据可能会丢失。

AOF 重写

  • AOF重写期间(这是一个优化过程,用于减小AOF文件的大小),如果在重写的最后阶段或同步到磁盘之前服务器失败,那么在这个短时间内的数据可能会丢失。

一个redis实例一般会丢失多少数据,这个数量级是多少?

AOF(Append Only File)持久化

  • appendfsync always:理论上,这种配置几乎不会丢失数据,因为每个写操作都会即刻同步到硬盘。但这会严重影响性能。
  • appendfsync everysec(默认设置):这种配置下,在极端情况下可能会丢失最近一秒内的数据。这是一种平衡性能和数据安全的常用配置。

RDB(Redis Database File)持久化

  • RDB持久化是周期性的快照,因此数据丢失的范围可能从几分钟到几小时不等,具体取决于快照的频率。例如,如果快照每30分钟进行一次,最坏情况下可能丢失接近30分钟的数据。

对于大多数使用默认AOF配置(everysec)的Redis实例,数据丢失的数量级通常是秒级的,也就是说,可能丢失最后一秒钟的写操作。在这种情况下,丢失的具体数据量将取决于该秒内完成的写操作数量和大小。

Redis有事务吗

Redis是有事务的,redis中的事务是一组命令的集合,这组命令要么都执行,要不都不执行,

redis事务的实现,需要用到MULTI(事务的开始)和EXEC(事务的结束)命令 ;

Redis数据和MySQL数据库的一致性如何实现

一、 延时双删策略

在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。具体步骤是:

1)先删除缓存

2)再写数据库

3)休眠500毫秒(根据具体的业务时间来定)

4)再次删除缓存。

那么,这个500毫秒怎么确定的,具体该休眠多久呢?

需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

当然,这种策略还要考虑 redis 和数据库主从同步的耗时。最后的写数据的休眠时间:则在读数据业务逻辑的耗时的基础上,加上几百ms即可。比如:休眠1秒。

二、设置缓存的过期时间

从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存

结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致,而且又增加了写请求的耗时。

缓存击穿,缓存穿透,缓存雪崩的原因和解决方案

  1. 缓存穿透:

是指查询一个不存在的数据,由于缓存无法命中,将去查询数据库,但是数据库也无此记录,并且出于容错考虑,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。

解决方案:空结果也进行缓存,可以设置一个空对象,但它的过期时间会很短,最长不超过五分钟。 或者用布隆过滤器也可以解决,Redisson框架中有布隆过滤器。

  1. 缓存雪崩:

是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

解决方案:原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

  1. 缓存击穿

是指对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:如果这个key在大量请求同时进来之前正好失效,那么所有对这个key的数据查询都落到DB,我们称为缓存击穿。

解决方案:在分布式的环境下,应使用分布式锁来解决,分布式锁的实现方案有多种,比如使用Redis的setnx、使用Zookeeper的临时顺序节点等来实现

哨兵模式是什么样的

如果Master异常,则会进行Master-Slave切换,将其中一Slae作为Master,将之前的Master作为Slave

下线:

①主观下线:Subjectively Down,简称 SDOWN,指的是当前 Sentinel 实例对某个redis服务器做出的下线判断。

②客观下线:Objectively Down, 简称 ODOWN,指的是多个 Sentinel 实例在对Master Server做出 SDOWN 判断,并且通过 SENTINEL is-master-down-by-addr 命令互相交流之后,得出的Master Server下线判断,然后开启failover.

工作原理:

(1)每个Sentinel以每秒钟一次的频率向它所知的Master,Slave以及其他 Sentinel 实例发送一个 PING 命令 ;

(2)如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel 标记为主观下线;

(3)如果一个Master被标记为主观下线,则正在监视这个Master的所有 Sentinel 要以每秒一次的频率确认Master的确进入了主观下线状态;

(4)当有足够数量的 Sentinel(大于等于配置文件指定的值)在指定的时间范围内确认Master的确进入了主观下线状态, 则Master会被标记为客观下线 ;

(5)在一般情况下, 每个 Sentinel 会以每 10 秒一次的频率向它已知的所有Master,Slave发送 INFO 命令

(6)当Master被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令的频率会从 10 秒一次改为每秒一次 ;

(7)若没有足够数量的 Sentinel 同意 Master 已经下线, Master 的客观下线状态就会被移除;

若 Master 重新向 Sentinel 的 PING 命令返回有效回复, Master 的主观下线状态就会被移除;

Redis常见性能问题和解决方案

  1. Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件

  2. 如果数据比较重要,某个Slave开启AOF备份数据,策略设置为每秒同步一次

  3. 为了主从复制的速度和连接的稳定性,Master和Slave最好在同一个局域网内

  4. 尽量避免在压力很大的主库上增加从库

  5. 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3...

这样的结构方便解决单点故障问题,实现Slave对Master的替换。如果Master挂了,可以立刻启用Slave1做Master,其他不变。

MySQL里有大量数据,如何保证Redis中的数据都是热点数据

Redis内存淘汰策略

redis内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。

数据淘汰策略:

noeviction:返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但DEL和几个例外)

allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。

volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。

allkeys-random: 回收随机的键使得新添加的数据有空间存放。

volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。

volatile-ttl: 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放。

一致性哈希

在 Redis 中使用一致性哈希(Consistent Hashing)通常与 Redis Cluster 或分布式缓存系统相关。一致性哈希是一种特殊的哈希算法,其设计目的是在分布式系统中最小化节点增加或移除时对系统的影响。它通过将数据和节点映射到同一个哈希环上来实现数据分布的平衡和动态变化的管理。

一致性哈希的核心概念

  1. 哈希环
    • 一致性哈希将哈希值的范围(通常是一个大的整数)形象化为一个环形空间(称为哈希环)。每个节点和数据项都通过哈希函数映射到这个环上的某个位置。
  2. 数据分配
    • 数据(或键)通过其哈希值被映射到环上。从某个数据项的位置开始,沿环顺时针找到的第一个节点是该数据项所属的节点。
  3. 节点的动态加入和移除
    • 当新增或移除节点时,一致性哈希只影响环上该节点附近的数据项,而不会影响整个系统的数据分布。这显著减少了因节点变动而需要重新分配的数据量。
  4. 虚拟节点
    • 为了使节点间的数据分布更加均匀,一致性哈希引入了虚拟节点的概念。实际节点可以在哈希环上有多个虚拟节点(或哈希值)。这样可以减少节点的加入或移除对数据均匀性的影响。

Redis 中的一致性哈希

在 Redis Cluster 中,一致性哈希的概念被应用来管理多个节点间的数据分布:

  • 分片(Sharding):Redis Cluster 通过分片来分散数据。每个键通过哈希函数映射到一个 16384 大小的槽(slot)中,这些槽被分配到不同的节点上。
  • 槽的重新分配:当节点增加或移除时,相关的槽会被重新分配到其他节点上。这种槽的概念与一致性哈希中的虚拟节点类似,但实现方式略有不同。

说说Redis哈希槽的概念

Redis集群没有使用一致性hash,而是引入了哈希槽的概念,Redis集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽。

Redis在项目中的应用

Redis一般来说在项目中有几方面的应用

  1. 作为缓存,将热点数据进行缓存,减少和数据库的交互,提高系统的效率
  2. 作为分布式锁的解决方案,解决缓存击穿等问题
  3. 作为消息队列,使用Redis的发布订阅功能进行消息的发布和订阅

如何利用Redis实现分布式锁

可以通过Redission来实现

Redission 在实现分布式锁时主要依赖于 Redis 的 SET 命令的特殊参数:

  • NX —— 仅当键不存在时设置键。
  • PX —— 键值对的生存时间(以毫秒为单位),即自动过期时间。

通过结合这些参数,Redission 可以安全地创建一个锁。如果 SET 操作返回 OK,则表示锁设置成功;如果返回 null(或其他非 OK 值),则表示锁当前被另一个进程持有。

Redission 锁的核心机制

  1. 锁的获取
    • Redission 通过发送一个带 NXPX 参数的 SET 命令尝试设置一个 Redis 键。如果命令成功执行,那么当前 Redission 客户端成功获取了锁。
    • 锁的键通常包括锁的名称和一些附加的元数据,如锁的 UUID,这可以确保锁的释放是由持有锁的那个客户端来执行。
  2. 锁的自动续期
    • 为防止在持有锁的过程中因执行时间过长而自动释放锁,Redission 实现了一个“看门狗”机制,自动续期锁的过期时间。默认情况下,这个看门狗每隔一定时间(例如30秒)就会重新设置锁的过期时间,直到锁被释放。
  3. 锁的释放
    • 当 Redission 客户端完成其临界区的代码执行后,它将发送一个 Lua 脚本到 Redis。这个 Lua 脚本首先检查锁(Redis 键)是否由当前客户端持有(通过比较 UUID 或其他标识),然后才执行删除操作,这保证了只有锁的持有者才能释放锁。

计算机网络

Http的工作流程

  1. 客户端进行DNS域名解析,得到对应的IP地址根据这个IP地址,找到对应的服务器建立TCP连接(三次握手)
  2. 建立TCP连接后发起HTTP请求(一个完整的http请求报文)
  3. 服务器响应HTTP请求,客户端得到html代码
  4. 客户端解析html代码,用html代码中的资源(如 js、css、图片等等)渲染页面。
  5. 服务器关闭TCP连接(四次挥手)

Https的工作流程

  1. 客户端发起HTTPS请求,连接到服务器的443端口。
  2. 服务器必须要有一套数字证书(证书内容有公钥、证书颁发机构、失效日期等)。
  3. 服务器将自己的数字证书发送给客户端(公钥在证书里面,私钥由服务器持有)。
  4. 客户端收到数字证书之后,会验证证书的合法性。如果证书验证通过,就会生成一个随机的对称密钥,用证书的公钥加密。
  5. 客户端将公钥加密后的密钥发送到服务器。
  6. 服务器接收到客户端发来的密文密钥之后,用自己之前保留的私钥对其进行非对称解密,解密之后就得到客户端的密钥,然后用客户端密钥对返回数据进行对称加密,这样子传输的数据都是密文啦。
  7. 服务器将加密后的密文返回到客户端。
  8. 客户端收到后,用自己的密钥对其进行对称解密,就能得到服务器返回的数据。

RabbitMQ

什么是RabbitMQ

RabbitMQ是一个开源的消息队列系统,它使用AMQP(高级消息队列协议)标准。RabbitMQ的主要目标是提供可靠的消息传递,确保消息的可靠性和顺序性,同时提供灵活的路由和消息确认机制。

RabbitMQ基于AMQP协议工作,它通过消息的发布/订阅模式实现消息的传递。主要包括三个角色:生产者(Producer)、交换机(Exchange)和队列(Queue)。生产者负责生成消息,交换机负责接收生产者的消息并根据指定的规则路由到队列,而队列则负责存储这些消息,等待消费者(Consumer)来消费。

解耦、异步、削峰是什么?

解耦:A 系统发送数据到 BCD 三个系统,通过接口调用发送。如果 E 系统也要这个数据呢?那如果 C 系统现在不需要了呢?A 系统负责人几乎崩溃…A 系统跟其它各种乱七八糟的系统严重耦合, A 系统产生一条比较关键的数据,很多系统都需要 A 系统将这个数据发送过来。如果使用 MQ,A系统产生一条数据,发送到 MQ 里面去,哪个系统需要数据自己去 MQ 里面消费。如果新系统需要数据,直接从 MQ 里消费即可;如果某个系统不需要这条数据了,就取消对 MQ 消息的消费即可。这样下来,A 系统压根儿不需要去考虑要给谁发送数据,不需要维护这个代码,也不需要考虑人家是否调用成功、失败超时等情况。

就是一个系统或者一个模块,调用了多个系统或者模块,互相之间的调用很复杂,维护起来很麻烦。但是其实这个调用是不需要直接同步调用接口的,如果用 MQ 给它异步化解耦。

异步:A 系统接收一个请求,需要在自己本地写库,还需要在 BCD 三个系统写库,自己本地写库要 3ms,BCD 三个系统分别写库要 300ms、450ms、200ms。最终请求总延时是 3 + 300 + 450 + 200 = 953ms,接近 1s,用户感觉搞个什么东西,慢死了慢死了。用户通过浏览器发起请求。如果使用 MQ,那么 A 系统连续发送 3 条消息到 MQ 队列中,假如耗时 5ms,A 系统从接受一个请求到返回响应给用户,总时长是 3 + 5 = 8ms。

削峰:减少高峰时期对服务器压力。

消息队列有什么缺点

  • 系统可用性降低

本来系统运行好好的,现在你非要加入个消息队列进去,那消息队列挂了,你的系统不是呵呵了。因此,系统可用性会降低;

  • 系统复杂度提高

加入了消息队列,要多考虑很多方面的问题,比如:一致性问题、如何保证消息不被重复消费、如何保证消息可靠性传输等。因此,需要考虑的东西更多,复杂性增大。

  • 一致性问题

A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,咋整?你这数据就不一致了。

所以消息队列实际是一种非常复杂的架构,你引入它有很多好处,但是也得针对它带来的坏处做各种额外的技术方案和架构来规避掉,做好之后,你会发现,妈呀,系统复杂度提升了一个数量级,也许是复杂了 10 倍。但是关键时刻,用,还是得用的。

RabbitMQ的使用场景

  • 服务间异步通信
  • 顺序消费
  • 定时任务
  • 请求削峰

RabbitMQ基本概念

  • Broker:简单来说就是消息队列服务器实体
  • Exchange:消息交换机,它指定消息按什么规则,路由到哪个队列
  • Queue:消息队列载体,每个消息都会被投入到一个或多个队列
  • Binding:绑定,它的作用就是把exchange和queue按照路由规则绑定起来
  • Routing Key:路由关键字,exchange根据这个关键字进行消息投递
  • VHost:vhost 可以理解为虚拟 broker ,即 mini-RabbitMQ server。其内部均含有独立的queue、exchange 和 binding 等,但最最重要的是,其拥有独立的权限系统,可以做到 vhost 范围的用户控制。当然,从 RabbitMQ 的全局角度,vhost 可以作为不同权限隔离的手段(一个典型的例子就是不同的应用可以跑在不同的 vhost 中)。
  • Producer:消息生产者,就是投递消息的程序
  • Consumer:消息消费者,就是接受消息的程序
  • Channel:消息通道,在客户端的每个连接里,可建立多个channel,每个channel代表一个会话任务

由Exchange、Queue、RoutingKey三个才能决定一个从Exchange到Queue的唯一的线路。

RabbitMQ的工作模式

  • simple模式(即最简单的收发模式)

消息产生消息,将消息放入队列

消息的消费者(consumer) 监听 消息队列,如果队列中有消息,就消费掉,消息被拿走后,自动从队列中删除(隐患:消息可能没有被消费者正确处理,已经从队列中消失了,造成消息的丢失,这里可以设置成手动的ack,但如果设置成手动ack,处理完后要及时发送ack消息给队列,否则会造成内存溢出)。

  • work工作模式(资源的竞争)

消息产生者将消息放入队列消费者可以有多个,消费者1,消费者2同时监听同一个队列,消息被消费。C1 C2共同争抢当前的消息队列内容,谁先拿到谁负责消费消息(隐患:高并发情况下,默认会产生某一个消息被多个消费者共同使用,可以设置一个开关(syncronize) 保证一条消息只能被一个消费者使用)。

  • publish/subscribe发布订阅(共享资源)

每个消费者监听自己的队列;生产者将消息发给broker,由交换机将消息转发到绑定此交换机的每个队列,每个绑定交换机的队列都将接收到消息。

  • routing路由模式

消息生产者将消息发送给交换机按照路由判断,路由是字符串(info) 当前产生的消息携带路由字符(对象的方法),交换机根据路由的key,只能匹配上路由key对应的消息队列,对应的消费者才能消费消息;

根据业务功能定义路由字符串

从系统的代码逻辑中获取对应的功能字符串,将消息任务扔到对应的队列中。

业务场景:error 通知;EXCEPTION;错误通知的功能;传统意义的错误通知;客户通知;利用key路由,可以将程序中的错误封装成消息传入到消息队列中,开发者可以自定义消费者,实时接收错误;

如何保证RabbitMQ消息的顺序性?

拆分多个 queue(消息队列),每个 queue(消息队列) 一个 consumer(消费者),就是多一些 queue(消息队列)而已,确实是麻烦点;或者就一个 queue (消息队列)但是对应一个 consumer(消费者),然后这个 consumer(消费者)内部用内存队列做排队,然后分发给底层不同的 worker 来处理。

消息如何分发?

若该队列至少有一个消费者订阅,消息将以循环(round-robin)的方式发送给消费者。每条消息只会分发给一个订阅的消费者(前提是消费者能够正常处理消息并进行确认)。通过路由可实现多消费的功能。

消息怎么路由?

消息提供方->路由->一至多个队列消息发布到交换器时,消息将拥有一个路由键(routing key),在消息创建时设定。通过队列路由键,可以把队列绑定到交换器上。消息到达交换器后, RabbitMQ 会将消息的路由键与队列的路由键进行匹配(针对不同的交换器有不同的路由规则);

常用的交换器主要分为一下三种:

fanout:如果交换器收到消息,将会广播到所有绑定的队列上

direct:如果路由键完全匹配,消息就被投递到相应的队列

topic:可以使来自不同源头的消息能够到达同一个队列。 使用 topic 交换器时,可以使用通配符

消息基于什么传输?

由于 TCP 连接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈。RabbitMQ使用信道的方式来传输数据。信道是建立在真实的 TCP 连接内的虚拟连接,且每条 TCP 连接上的信道数量没有限制。

如何保证消息不被重复消费?或者说,如何保证消息消费时的幂等性?

RabbitMQ可以通过以下方式来保证幂等性:

  • 每个消息用一个唯一标识来区分:消费前先判断标识有没有被消费过,若已消费则不再消费

  • 利用数据库的乐观锁机制:执行更新操作前先去数据库查询version,然后执行更新语句,以version作为条件

  • 使用Redis的命令:Redis中的set命令天然支持幂等,消息消费时,只需要用set命令来判断消息是否被消费过即可

  • 全局唯一ID + Redis:生产者在发送消息时,为每条消息设置一个全局唯一的messageId,消费者拿到消息后,使用setnx命令,将messageId作为key放到redis中:setnx (messageId,1),若返回1,说明之前没有消费过,正常消费;若返回0,说明这条消息之前已消费过,抛弃

以上就是RabbitMQ保证幂等性的主要方式。但需要注意的是,这种方式需要在生产者端进行一定的控制,以确保同一个订单号的消息被发送到同一个队列中。同时,消费者端也需要进行相应的处理,以确保消息的顺序消费

如何确保消息正确地发送至 RabbitMQ? 如何确保消息接收方消费了消息?

  • 发送方确认模式

将信道设置成 confirm 模式(发送方确认模式),则所有在信道上发布的消息都会被指派一个唯一的 ID。 一旦消息被投递到目的队列后,或者消息被写入磁盘后(可持久化的消息),信道会发送一个确认给生产者(包含消息唯一 ID)。 如果 RabbitMQ 发生内部错误从而导致消息丢失,会发送一条 nack(notacknowledged,未确认)消息。 发送方确认模式是异步的,生产者应用程序在等待确认的同时,可以继续发送消息。当确认消息到达生产者应用程序,生产者应用程序的回调方法就会被触发来处理确认消息。

  • 接收方确认机制

消费者接收每一条消息后都必须进行确认(消息接收和消息确认是两个不同操作)。只有消费者确认了消息,RabbitMQ 才能安全地把消息从队列中删除。 这里并没有用到超时机制,RabbitMQ 仅通过 Consumer 的连接中断来确认是否需要重新发送消息。也就是说,只要连接不中断,RabbitMQ 给了 Consumer 足够长的时间来处理消息。保证数据的最终一致性;

  • 下面罗列几种特殊情况

如果消费者接收到消息,在确认之前断开了连接或取消订阅,RabbitMQ 会认为消息没有被分发,然后重新分发给下一个订阅的消费者。(可能存在消息重复消费的隐患,需要去重)。

如果消费者接收到消息却没有确认消息,连接也未断开,则 RabbitMQ 认为该消费者繁忙,将不会给该消费者分发更多的消息。

如何保证RabbitMQ消息的可靠传输?

消息不可靠的情况可能是消息丢失,劫持等原因;

丢失又分为:生产者丢失消息、消息列表丢失消息、消费者丢失消息;

  • 生产者丢失消息

从生产者弄丢数据这个角度来看,RabbitMQ提供transaction和confirm模式来确保生产者不丢消息;transaction机制就是说:发送消息前,开启事务(channel.txSelect()),然后发送消息,如果发送过程中出现什么异常,事务就会回滚(channel.txRollback()),如果发送成功则提交事务(channel.txCommit())。然而,这种方式有个缺点:吞吐量下降;

confirm模式用的居多:一旦channel进入confirm模式,所有在该信道上发布的消息都将会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后;rabbitMQ就会发送一个ACK给生产者(包含消息的唯一ID),这就使得生产者知道消息已经正确到达目的队列了;

如果rabbitMQ没能处理该消息,则会发送一个Nack消息给你,你可以进行重试操作。

  • 消息队列丢数据:消息持久化。

处理消息队列丢数据的情况,一般是开启持久化磁盘的配置。

这个持久化配置可以和confirm机制配合使用,你可以在消息持久化磁盘后,再给生产者发送一个Ack信号。

这样,如果消息持久化磁盘之前,rabbitMQ阵亡了,那么生产者收不到Ack信号,生产者会自动重发。

那么如何持久化呢?这里顺便说一下吧,其实也很容易,就下面两步

将queue的持久化标识durable设置为true,则代表是一个持久的队列

发送消息的时候将deliveryMode=2,这样设置以后,即使rabbitMQ挂了,重启后也能恢复数据

  • 消费者丢失消息:

消费者丢数据一般是因为采用了自动确认消息模式,改为手动确认消息即可!

消费者在收到消息之后,处理消息之前,会自动回复RabbitMQ已收到消息;如果这时处理消息失败,就会丢失该消息;

解决方案:处理消息成功后,手动回复确认消息。

为什么不应该对所有的 message 都使用持久化机制?

首先,必然导致性能的下降,因为写磁盘比写 RAM 慢的多,message 的吞吐量可能有 10 倍的差距。

其次,message 的持久化机制用在 RabbitMQ 的内置 cluster 方案时会出现“坑爹”问题。矛盾点在于,若 message 设置了 persistent 属性,但 queue 未设置 durable 属性,那么当该 queue 的owner node 出现异常后,在未重建该 queue 前,发往该 queue 的 message 将被 blackholed ;若 message 设置了 persistent 属性,同时 queue 也设置了 durable 属性,那么当 queue 的owner node 异常且无法重启的情况下,则该 queue 无法在其他 node 上重建,只能等待其owner node 重启后,才能恢复该 queue 的使用,而在这段时间内发送给该 queue 的 message将被 blackholed 。

所以,是否要对 message 进行持久化,需要综合考虑性能需要,以及可能遇到的问题。若想达到100,000 条/秒以上的消息吞吐量(单 RabbitMQ 服务器),则要么使用其他的方式来确保message 的可靠 delivery ,要么使用非常快速的存储系统以支持全持久化(例如使用 SSD)。另外一种处理原则是:仅对关键消息作持久化处理(根据业务重要程度),且应该保证关键消息的量不会导致性能瓶颈。

RabbitMQ 的集群如何保证高可用的?

  • 单机模式

    就是 Demo 级别的,一般就是你本地启动了玩玩儿的,没人生产用单机模式。

  • 普通集群模式:

意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。

你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。

  • 镜像集群模式:

这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。

这样的好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。

如何处理高并发场景下的性能问题?

RabbitMQ通过提供集群功能来处理高并发场景下的性能问题。通过将多个节点组成一个集群,可以实现负载均衡和分发,提高系统的整体处理能力。同时,还可以通过调整各种参数来优化性能,如调整消息的持久化设置、调整交换机和队列的匹配规则等。

如何实现消息的优先级处理?

RabbitMQ支持消息的优先级处理。可以通过设置消息的优先级字段来实现,优先级高的消息会优先被消费者消费。此外,还可以通过使用优先级队列来实现更细粒度的优先级控制。

如何在RabbitMQ中实现延迟队列?

RabbitMQ通过插件支持延迟队列的实现。延迟队列是指将消息放入队列中等待一段时间后再进行处理。要实现延迟队列,可以使用RabbitMQ的插件“rabbitmq_delayed_message_exchange”,它提供了一个延迟交换器(Delayed Message Exchange),可以将消息路由到指定的队列中等待指定的延迟时间。

如何在RabbitMQ中实现优先级队列?

RabbitMQ通过插件支持优先级队列的实现。优先级队列是指根据消息的优先级进行排序和处理的队列。要实现优先级队列,可以使用RabbitMQ的插件“rabbitmq_priority_queue”,它提供了一个优先级队列交换机(Priority Queue Exchange),可以将具有不同优先级的消息路由到不同的队列中。

如何在RabbitMQ中实现死信队列?

RabbitMQ通过插件支持死信队列的实现。死信队列是指当消息无法被成功消费或处理时,将其放入指定的死信队列中。要实现死信队列,可以使用RabbitMQ的插件“rabbitmq_dead_letter_exchange”,它提供了一个死信交换机(Dead Letter Exchange),可以将无法被处理的消息路由到指定的死信队列中。 itMQ通过插件支持优先级队列的实现。优先级队列是指根据消息的优先级进行排序和处理的队列。要实现优先级队列,可以使用RabbitMQ的插件“rabbitmq_priority_queue”,它提供了一个优先级队列交换机(Priority Queue Exchange),可以将具有不同优先级的消息路由到不同的队列中。

项目

在IO中做了什么优化

1、加入了AIO提升IO性能。

2、使用了缓冲池技术,缓冲池的主要好处之一是提高内存使用效率。通过重复使用同一组固定的 ByteBuffer 实例,可以减少频繁的内存分配和回收,特别是对于大型缓冲区。这不仅有助于减少垃圾收集(GC)的压力,还可以提升整体的应用性能,尤其是在处理大文件或大量文件时。

3、加入了RabbitMQ,使用消息队列将文件合并的操作进行异步处理。

缓冲池

我自己编写了一个缓冲池,主要结构使用一个双端队列进行实现,有需要缓冲ByteBuffer的时候直接从池子中拿就可以了。并且编写一个配置类将这个缓冲池作为Bean给Spring来管理,并通过依赖注入的方式在需要的地方注入这个Bean。并且在对双端队列进行操作的时候,为了线程安全,加入了synchronized上锁保证安全。

项目中使用了Radis什么数据结构

主要是String类型,保存一些用户信息或者存储空间大小等等。

项目中运用到了锁吗

例如同时有用户对同一个文件进行了上传、下载或者修改的操作,那么会出现各种错误,所以就需要上锁。其中使用了ReentrantLock()。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!