1、Java 基础
1、面向对象和面向过程的区别?
面向对象和面向过程都是软件开发的一种设计思想 面向过程:按照步骤去解决问题 面向对象:将一个事物的属性和行为抽象为一个类,通过创建这个类的对象去调用它的属性和方法就是面向对象
2、什么是面向对象?
将一个事物的属性和行为抽象为一个类,通过创建这个类的对象去调用它的属性和方法就是面向对象 1、封装:将一个事务的属性和行为封装进一个类中,让外部该访问的进行访问,不该访问的不允许访问,具体是通过权限修饰符实现的;常见的做法就是对类的属性进行私有化,并提供 getter、setter 方法,当然也有一些属性没有提供 getter、setter 方法,比如 String 中的字符数组、ArrayList中的对象数组、HashSet 中的 HashMap 等,这些仅对内部提供用来保存数据的一种结构;当然还有单例设计默认的应用,将类的构造器私有化,让外部不能直接创建类的对象,只能根据对外提供的方法获取类中创建的对象。
2、继承:在原有类的基础上扩展出新的类,新的类即子类它拥有原有类即父类的所有的信息,可以提高代码的利用率,对功能进行复用,并且子类还可以定义新的属性和方法,在父类的基础上进行增强,也可以根据自身场景对父类的方法进行重写。
3、多态:使用父类或者接口声明的变量在程序编译期间并不能确定它所指向的对象究竟哪个类型的对象,通过这个变量调用的方法也不能确定这个方法是谁的方法,只有在程序运行期间才能确定。我们通常会为了降低代码的耦合性,在给类声明成员属性或者在方法的参数位置都会写父类或者接口类型。
3、成员变量和局部变量的区别
1、从声明形式上来看:成员变量能够被权限修饰符、static、volatile 等关键字修饰,而局部变量不能,他们都可以被 final 修饰
2、从存储位置上来看:成员变量又分为类变量和实例变量,类变量属于类,它是在链接过程的准备阶段在方法区中分配内存;而实例变量是在对象被创建的时候在堆空间中分配内存;局部变量它是存储在虚拟机栈栈桢的局部变量表中
3、从生命周期上来看:类变量是随着类的加载而加载;实例变量是随着对象的创建而创建,随着对象的回收而销毁;局部变量是随着方法的入栈而创建,随着方法的出栈而销毁
4、什么是值传递和引用传递
Java 中只有值传递,没有引用传递;Java 中对于基本数据类型的变量,传递的是变量值;对于引用数据类型的变量,传递而就是这个变量所对应的对象在堆内存中的内存地址值
5、Object 类中常用的方法
1、toString() :返回该对象的字符串表示
2、equals():判断两个对象是否相等
3、hashCode():返回该对象的哈希码
4、wait():如果该对象被作为锁对象时,调用该方法会导致当前线程处于等待状态,必须等其他线程调用该对象的 notify() 或者 notifyAll() 方法将该线程唤醒,该线程才会进入 Runnable 状态,等待该线程获得 CPU 时间片后才会继续执行
5、notify():从该对象锁的等待队列中随机唤醒一个线程
6、clone():返回该对象的一个副本
7、finalize():当该对象被回收之前被执行
6、接口与抽象类的区别
1、从定义方式上来看,接口是通过 @Interface 来声明的;抽象类是通过 abstract class 来声明的
2、接口是被实现的,抽象类是被继承的
3、接口可以继承多个接口、抽象类只能继承单个类
4、一个类可以实现多个接口,但是只能继承一个抽象类
5、接口中默认并且只支持定义常量,而抽象类和普通的类一样,可以任意定义常量和变量
6、接口中的方法默认是 public 的,并且接口中只支持 public、default 方法;而抽象类中可以声明其他权限修饰符修饰的方法
7、重载与重写的区别
1、重载:在一个类中,对方法名相同,参数列表不同的方法的方法体内容做出不同的定义就是重载,它与方法的权限修饰符、方法返回值类型、方法是否抛出异常无关
2、重写:是指子类对父类或者实现的接口中方法名相同、参数列表也相同的成员方法的方法体中的内容进行修改就是重写;如果父类中的方法被修饰为private那么,该方法不能被子类所重写;子类在重写方法的时候,必须满足:方法的访问权限必须大于或者等于父类方法的访问权限,方法的返回值类型必须小于或等于父类方法返回值类型,方法所抛出的异常类型必须小于或等于父类方法抛出的异常类型
8、equals() 和 == 的区别
1、== 是 Java 中提供的一个特殊标识,两个基本数据类型的变量通过 == 进行比较时,比较的是两个变量的值是否相等;而两个引用数据类型的变量通过 == 进行比较时,比较的是这两个变量所对应的对象在内存空间中的地址值是否相等
2、equals 是 Object 类中的一个成员方法,该方法中也是通过 == 进行比较的,而 Object 它作为所有类的基类,所有的类都隐式地继承了 Object,都拥有这个 equals 方法;如果这个类没有重写 equals 方法的话,那么两个引用数据类型的变量在通过 equals 进行比较时,内部还是通过 == 进行比较的,比较的是这两个变量所对应的对象在内存空间中的地址值是否相等;而如果这个类重写了 equals 方法,那么两个引用数据类型的变量通过 equals进行比较时,比较的规则是由重写的规则决定的;而基本数据类型的变量之间是不能通过 equals 方法进行比较的,因为基本数据类型不是类,没有继承自 Object,没有 equals 方法,但是 Java 中提供了基本数据类型所对应的包装类,而这些包装类又隐式的继承了 Object,并且他们都重写了 equals方法,包装类的对象在通过 equals 方法进行比较时,比较的是包装类所代表的值是否相等。
9、StringBuffer、StringBuilder、String 的区别
1、可变和不可变:String 是一个被 final 关键字修饰的类,不能被继承,它里面用来保存字符的字符数组也是被 final 关键字修饰的,不会被篡改,并且 String 中并没有提供对字符数组中的元素进行修改的方法,所以它是不可变的;而 StringBuffer、StringBuilder 他两的父类都是AbstractStringBuilder,该类里面也是通过一个字符数组来保存字符的,但是并没有被 final 修饰,并且该类中也提供了对该字符数组进行修改的方法,所以 StringBuffer、StringBuilder 是可变的。
2、从效率和线程安全上来看:String 类型的变量它在程序编译期间就已经确定了,不可被修改,所以它是线程安全的,但是每次对 String 进行修改,都会创建一个新的对象,所以如果 String 不适合应用在频发修改的场景;而 StringBuffer 它在重写父类方法的时候,是通过在方法上添加 synchronized来保证线程安全的,但是正是因为它加上了 synchronized,所以它的并发能力较差;而 StringBuilder ,它在重写父类方法的时候,还是通过super 来调用的父类的方法,由于它没有加锁,所以它不是线程安全的,当然正是因为它没有加锁,所以它的并发性能比 StringBuffer 要高
10、常量池的分类
1、Class 常量池:在通过 javac 编译后产生的 .class 二进制字节码文件中存放了编译期间生成的各种字面量和符号引用,常量池中的内容会在类加载后存放到运行时常量池中
2、运行时常量池:JVM 为每一个已经加载的类或者接口都会维护一个常量池,池中的数据项像数组一样,是通过索引进行访问的,它包含了编译期间产生的数值字面量和运行期间解析后产生的方法或者字段引用。
11、常见的异常类型
1、java.lang.StackOverFlowError:栈溢出异常
2、java.lang.OutOfMemoryError:内存溢出异常
3、java.util.CurrentModificationException:并发修改异常
4、java.lang.InterruptedException:中断异常
5、NullPointerException:空指针异常
6、IndexOutOfBoundsException:索引下标越界异常
7、IOException:IO 异常
12、throw和throws的区别
1、throw 是在方法内部,抛出一个异常对象
2、throws 是在方法的参数列表后面抛出一个该方法可能会产生的异常类型
13、ArrayList、Vector 的区别
1、ArrayList 在使用空参构造器创建对象时,内部会创建一个长度为 0 的空数组,当第一次向集合中添加元素的时候,会将数组扩容为 10, 之后每次扩容都是扩容为原来的 1.5 倍
2、Vector 在使用空参构造器创建对象时,内部会创建一个长度为 10 的数组,之后每次扩容都会扩容为原来的 2倍
14、ArrayList、LinkedList 的区别
1、数据结构不同:ArrayList 底层采用的是动态数组来存储元素的,而 LinkedList 底层是采用双向链表来存储元素的
2、容量大小不同:ArrayList 的最大容量为 Integer 的最大值,而 LinkedList 只要内存足够,就可以无限存储
3、操作元素的效率不同:由于他俩的数据结构不同,一个是数组,一个是链表,所以最终导致他俩操作元素效率不同的原因就是数组和链表操作元素效率的不同,数组它在内存空间中是连续存储的,并且 ArrayList 还支持随机访问,所以 ArrayList 它查找元素的效率很高,时间 复杂度为 O(1),而 LinkedList 它在查找元素时,是从头结点开始遍历直到找到元素为止,它的时间复杂度为 O(n),效率低;而ArrayList在进行插入和删除元素时,需要移动后面的元素,时间复杂度为 O(n),效率低,LinkedList 它在插入和删除的时候,只需要改变节点的前驱节点和后继节点的指向就行了,时间复杂度为 O(1),效率高。综合分析后,可以知道,ArrayList 在查找元素上的效率很高,在插入和删除元素时效率低;而 LinkedList 在查找元素上的效率低,在删除和插入时的效率高。
15、HashMap、HashSet 的区别
1、HashMap 是 Map 接口的一个子实现类,用来保存一组具有映射关系的键值对;而HashSet 是 Set 接口的一个子实现类,它是用来保存一组无序且不重复的元素。
2、HashSet 的底层就是 HashMap,它内部维护了一个 HashSet 类型的 map 成员变量,当 HashSet 添加元素的时候,就是向 HashMap 中添加元素,它是将要添加的元素作为 HashMap 的 key,然后以一个成员常量作为占位为 value,然后保存到 HashMap 中;而正是因为HashMap 的 key 是无序且唯一的,所以 HashSet 保存的元素是无序且唯一的。
16、HashMap、HashTable 的区别
1、父类不同:HashMap 的父类是 AbstractMap;HashTable 的父类是 Dictionary
2、数据结构不同:HashMap 的底层是采用的数组+链表/红黑树;HashTable 的底层采用的是数组+链表
3、存储的类型不同:HashMap 是通过 Node 进行存储的;HashTable 是通过 Entry 进行存储的,虽然类型不同,但是里面的东西都一样: key、value、hash、Next
4、初始化容量不同:第一次创建 HashMap 时,没有对哈希表进行初始化,当第一次添加元素的时候才会初始化,初始化容量为 16;第一次创建HashTable 时,会创建一个长度为 11 的哈希表
5、添加元素的方式不同
6、扩容方式不同:HashMap 扩容后的大小为原来的 2 倍;HashTable 扩容后的大小为原来的 2n+1 倍
7、线程是否安全不同:HashMap 是线程不安全的,HashTable 是通过 synchronized 来保证线程安全的
8、对空 key、value 的支持不同:HashMap 的 key可以为 null,但是只能有一个,value 可以为 null,可以有多个;HashTable 的 key不能为 null,value可以有多个 null
9、计算 hash 的方式不同
补充(HashTable) :
1、put():
先判断 value 是否为 null,如果是,直接抛出空指向异常
计算出 key 的 hashCode
通过 hashCode 得到 key 要存的数组的下标位置
再得到该位置的元素
当该下标的 Entry 不为 null 时,则对链表进行遍历
- 在遍历的过程中,如果存在 key 相等的节点,则替换这个节点的值,并返回旧值
如果数组下标对应的节点为空,或者遍历链表后发现没有和该 key 相等的节点,则执行插入操作
判断是否需要扩容
- 扩容后,重新计算该 key 在扩容后 table 里的下标
采用头插的方式插入,index 位置的节点为新节点的 next 节点,将新节点替换 index 位置节点
17、HashMap 的工作原理是什么?
HashMap 的数据结构是数组+链表/红黑树;底层是通过一个 Node 类型的数组来实现的,每个 Node 又是一个链表,它包含了:key、value、hash、Next,
put():首先会先拿到哈希表,然后判断哈希表是否为null,或者哈希表的长度为0,如果是的话,会通过 resize() 方法进行扩容,当第一次添加元素的时候会进行哈希表的初始化;否则会根据 key 的哈希值计算出哈希表的索引位置,如果该位置中没有元素的话,会根据要添加的 key、value 创建出 Node ,然后添加到该位置;如果当前位置有元素的话,判断他两的 hash 值是否相等,如果相等的话,再通过 equals() 方法进行比较,如果为 true,说明 key相等,就用新添加的值将原来的值覆盖;否则的话,判断该位置的元素类型是否是红黑树,如果是红黑树的话,向红黑树中添加该元素;否则就说明该元素为链表结构,那么就以当前位置的元素为头结点,变量所在的链表,将该元素添加到链表的末尾,如果链表的长度达到了树化的阈值 8,那么就通过 treeIfBin()方法将链表转为红黑树,当然在该方法内部,还会判断哈希表的长度是否达到了树化的阈值 64 ,如果达到了就转为红黑树,如果没有达到,就对哈希表进行扩容,会扩容为原来的两倍;当然如果在链表遍历的过程中发现 key 相等的话,那么会对原来值进行覆盖,并返回原来的值。
18、Hash 表链表超长以后转化成红黑树,为什么会使用红黑树?
因为链表在查找元素时,是从头结点开始遍历,直到要找到目标元素为止,时间复杂度为 O(n),所以当链表超长以后,它的查找效率就很低;所以就考虑到使用二叉树,二叉树在查找元素时使用的是一种折半查找的思想,但是由于普通的二叉树不稳定,在一些极端的情况下,可能会出现数据倾斜退化为链表,所以为了防止出现数据倾斜,可以考虑平衡二叉树,但是平衡二叉树它对树得高度差要求很严格,导致进行树旋转的频率很高,所以可以使用红黑树,红黑树也是平衡二叉树的一种,它对平衡树得高度差进行了调整,降低了树的旋转频率,所以使用红黑树它既可以保证拥有较高的查找效率,也可以保证较高的插入和删除效率。
19、你认为 HashMap 可不可以不使用链表,而直接使用红黑树,或者二叉搜索树、AVL树?
我认为 HashMap 一开始没有直接使用红黑树,是对时间和空间的折中考虑,如果一开始就使用红黑树,那么在 Hash 冲突比较小的情况下,对时间的效率并没有多大的提升,并且在 put 的时候效率还会降低,因为 put 的时候,可能还需要进行树的旋转;另外在空间上的话,使用红黑树又需要多维护一个指针。所以使用 HashMap 可以避免在极端情况下树的退化,还可以兼顾查询和修改的效率
20、final、finally、finalize
1、final: 修饰类:类不能被继承 修饰变量:变量的值不能被修改 修饰方法:方法不能被子类重写
2、finally: 是 try-catch-finally 结构中的一部分,try 可以与 catch、finally组合使用,finally是在该结构中的最后执行的,可以看做是一种兜底操作, 可以用来执行数据库连接、线程等系统资源的关闭,但是并不是说 finally 中的代码一定会被执行,如果所在的程序线程死亡、停电、调用 System.exit()都会导致 finally 中的代码不被执行。
3、finalize:是在对象被回收前执行的
21、说一下有哪些 IO 类
1、按输入方式分:输入流、输出流 2、按类型分:字符流、字节流 3、按功能分:节点流、处理流
InputStream、OutputStream、Reader、Writer FileInputStream、FileOutputStream、FileReader、FileWriter BufferInputStream、BufferOutputStream
22、深拷贝与浅拷贝
深拷贝和浅拷贝都是指对对象的拷贝,由于对象的属性可以是基本数据类型、引用数据类型
浅拷贝是指:只会拷贝基本数据类型变量的值,以及实例变量的引用地址值 深拷贝是指:既会拷贝基本数据类型变量的值,还会根据实例变量在内存中的对象,复制出另外的一个对象,两个对象不是同一个
23、红黑树的特点
1、节点不是红色就是黑色
2、根节点是黑色、叶子节点也是黑色
3、叶子节点不存储数据
4、红色节点不连续
5、对于任意一个节点,从该节点该各个叶子节点的路径上都有相同数量的黑色节点
24、什么是反射
反射就是对于任意一个类,都可以知道这个类中的所有属性和方法,对于任意一个对象,都可以获取和调用对象的属性和方法;而这种动态获取类和对象的信息以及动态调用对象的属性和方法的过程就是反射;它是通过运行时类 Class 对象,对类进行解刨,把类的各个组成部分,比如属性、构造器、方法等信息映射成一个对象,通过反射,我们可以获取任意类的运行时类对象,然后通过运行时类对象可以查看这个类的信息、创建类的实例对象,调用实例对象的方法、创建对象的代理对象等。而反射在实际的开发中应用的也很多,比如要获取一个数据库的连接,那么就需要先通过反射加载数据库的驱动程序,还有一些框架支持xml、注解配置,而从xml、注解中得到的是一个类的全类名,那么就需要通过反射进行实例化,还可以实现面向切面编程(AOP),在程序运行的过程中创建代理对象,从而对目标方法进行增强。
2、JUC
1、多线程
1、线程的状态
1、NEW:新建
2、RUNNABLE:可运行的
3、BLOCKED:阻塞
4、WAITING:等待
5、TIMED_WAITING:超时等待
6、TERMINATED:终结
2、进程、线程、管程
1、进程:指的是正在运行的应用程序,是系统进行资源分配和调度的基本单位
2、线程:用来执行进程中的每个任务的单位,是程序运行的最小单元
3、管程:监视器(Monitor),也就是锁
3、并行、并发
1、并行:多个线程同时执行操作多个资源
2、并发:多个线程同时操作一个资源
4、同步、异步
同步和异步是指访问数据的机制
1、同步:主动请求数据并等待IO操作完成之后才能继续处理其他的任务
2、异步:主动请求数据后可以继续处理其他任务,之后会收到IO操作完成的通知
5、唤醒线程的方式
1、使用 Object 中的 wait() 方法让线程等待,使用 Object 中的 notify() 方法让线程唤醒
2、使用 JUC 包中的 Condition 的 await() 方法让线程等待,使用 signal() 方法让线程唤醒
3、LockSupport 类可以阻塞当前线程以及唤醒指定被阻塞的线程
6、sleep 和 wait 的区别
(1)sleep 是 Thread 类中的一个静态方法,wait 是Object 中的一个成员方法;
(2)sleep 在使用时,必须要指定时间,而 wait 如果不指定时间,则需要使用 notify/notifyAll 来唤醒;
(3)sleep 在任何地方都能睡,而 wait 必须在 synchronized 内才能等;
(4)sleep 不会释放锁,而 wait 会自动释放锁
7、创建线程的方式有哪几种
1、new Thread()
2、实现 Runnable 接口
3、实现 Callable 接口
4、使用线程池
8、Callable 与 Runnable 的区别
1、重写的方法不同:call() 、run()
2、call() 有返回值、run() 没有返回值
3、call() 可以抛出异常、run() 不能抛出异常
4、通过 FutureTask.get() 方法获取 call() 方法的返回结果时会阻塞
9、如何进行线程间通信?
1、synchronized:wait()、notify()、notifyAll()
2、Lock-Condition:await()、signal()
3、LockSupport:park()、unpark()
10、如何中断一个线程?
1、interrupt():实例方法;设置线程的中断状态为 true,不会停止线程
2、interrupted():静态方法:Thread.interrupted();返回当前线程的中断状态、将当前线程的中断装填设置为 flase
3、isInterrupted():实例方法;判断当前线程是否被中断
注意:如果线程处于被阻塞状态(sleep、wait、join),那么在别的线程中调用当前线程对象的 interrupt 方法,该线程会立即退出被阻塞状态,并抛出一个 InterruptedException 异常
11、LockSupport 是什么?
1、是一个并发编程的工具类
2、主要是为了阻塞和唤醒线程用的
3、它可以在不获取某个对象锁的情况下锁住线程
4、可以唤醒指定线程
原理:依赖 Unsafe 类实现的,LockSupport 和每个使用它的线程都与一个许可(permit)关联,permit 相当于 1、0 的开关,默认是 0,调用一次 unpark 就加 1 变成 1,调用一次 park 会消费 permit,将 1 变成 0,同时 park 立即返回,再次调用 park 会变成 block(因为 permit 为 0 了,会阻塞在这里,直到 permit 变为 1),这时调用 unpark 会把 permit 置为 1。每个线程都有一个相关的 permit,permit 最多只有一个,重复调用 unpark 也不会积累。
2、线程池
1、什么是线程池?线程池有哪些?
线程池就是事先将多个线程对象放到一个容器中,当使用的时候就不用 new 线程而是直接去池中拿线程即可,节省了开辟子线程的时间,提高的代码执行效率
在 JDK 的 java.util.concurrent.Executors 中提供了生成多种线程池的静态方法。
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
ExecutorService newFixedThreadPool = Executors.newFixedThreadPool(4);
ScheduledExecutorService newScheduledThreadPool = Executors.newScheduledThreadPool(4);
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
然后调用他们的 execute 方法即可。
1、newFixedThreadPool
创建拥有固定的线程数的线程池,使用的阻塞队列:LinkedBlockingQueue(默认长度为Integer.MAX_VALUE)。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
2、newSingleThreadExecutor
创建只有一个线程的线程池,使用的阻塞队列:LinkedBlockingQueue(默认长度为Integer.MAX_VALUE)
3、newCachedThreadPool
创建可扩容的线程池,核心线程数为0,最大线程数为Integer.MAX_VALUE;使用的阻塞队列:SynchronousQueue(单个元素的队列)
在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统瘫痪。
4、newScheduleThreadPool
创建一个定长的线程池,而且支持定时的以及周期性的任务执行。例如延迟3秒执行。
这4种线程池底层 全部是ThreadPoolExecutor对象的实现,阿里规范手册中规定线程池采用ThreadPoolExecutor自定义的,实际开发也是。
2、为什么要使用线程池
1、线程也是系统资源的一种,频繁地创建和销毁线程会消耗系统资源,可以通过线程池对线程进行复用
2、创建线程的这个操作比较耗时,使用线程池可以提高响应速度,当任务到达时,可以不需要等待线程创建而直接执行
3、为了提高系统的稳定性,对线程进行统一的管理
3、线程池的核心参数
ThreadPoolExecutor pool = new ThreadPoolExecutor(3, // 1、常驻核心线程数
5, // 2、最大线程数
10, // 3、保持活跃时间
TimeUnit.MINUTES, // 4、时间单位
new LinkedBlockingQueue<>(10), // 5、阻塞队列
Executors.defaultThreadFactory(), // 6、线程工厂
new ThreadPoolExecutor.AbortPolicy()); // 7、拒绝策略
4、拒绝策略
1、AbortPolicy(默认的拒绝策略):
当请求任务数大于最大线程数+阻塞队列的时候,会:java.util.concurrent.RejectedExecutionException
2、CallerRunsPolicy:
当请求任务数大于最大线程数+阻塞队列的时候,超出的任务会返回给调用者让调用者执行
3、DiscardOldestPolicy:
当请求任务数大于最大线程数+阻塞队列的时候,会丢弃阻塞队列中等待最久的任务
4、DiscardPolicy:
当请求任务数大于最大线程数+阻塞队列的时候,会丢弃新来的任务
5、大小如何设置
- 需要分析线程池执行的任务的特性: CPU 密集型还是 IO 密集型
- 每个任务执行的平均时长大概是多少,这个任务的执行时长可能还跟任务处理逻辑是否涉及到网络传输以及底层系统资源依赖有关系
如果是 CPU 密集型,主要是执行计算任务,响应时间很快,cpu 一直在运行,这种任务 cpu的利用率很高,那么线程数的配置应该根据 CPU 核心数来决定,CPU 核心数=最大同时执行线程数,加入 CPU 核心数为 4,那么服务器最多能同时执行 4 个线程。过多的线程会导致上下文切换反而使得效率降低。那线程池的最大线程数可以配置为 cpu 核心数+1 如果是 IO 密集型,主要是进行 IO 操作,执行 IO 操作的时间较长,这是 cpu 出于空闲状态,导致 cpu 的利用率不高,这种情况下可以增加线程池的大小。这种情况下可以结合线程的等待时长来做判断,等待时间越高,那么线程数也相对越多。一般可以配置 cpu 核心数的 2 倍。
一个公式:线程池设定最佳线程数目 = ((线程池设定的线程等待时间+线程 CPU 时间)/ 线程 CPU 时间 )* CPU 数目
这个公式的线程 cpu 时间是预估的程序单个线程在 cpu 上运行的时间(通常使用 loadrunner测试大量运行次数求出平均值)
6、线程池底层工作原理
1、在创建了线程池后,开始等待请求。
2、当调用 execute() 方法添加一个请求任务时,线程池会做出如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这个时候队列满了且正在运行的线程数量还小于 maxnumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了且正在运行的线程数量大于或等于 maxnumPoolSize,那么线程池会启动饱和拒绝策略来执行。
3、当一个线程完成任务时,它会从队列中取下一个任务来执行。
4、当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:
- 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
- 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
7、为什么不用 Executor 创建线程池?
1、阿里巴巴开发手册中明确指定不允许使用 Executor 创建线程池,而是通过 ThreadPoolExecutor 创建线程池;
2、FixedThreadPool 和 SingleThreaPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM;
3、CachedThreaPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM;
3、锁
1、synchronized 和 lock 的区别
1、首先synchronized是java内置关键字;Lock是个 java.util.concurrent 包中的一个接口;
2、synchronized会自动释放锁;Lock需在finally中手工释放锁(unlock()方法释放锁);
3、synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可判断、可公平(两者皆可);
4、锁是一个对象,并且锁的信息保存在了对象中;Lock 是通过 int 类型的 state 标识;
5、synchronized 有锁升级;Lock 没有锁升级;
6、synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁;
7、用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了;
8、Lock锁适合大量同步的代码的同步问题,synchronized锁适合代码少量的同步问题。
2、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的区别:
- Lock的加锁和解锁都是由java代码配合native方法(调用操作系统的相关方法)实现的,而synchronize的加锁和解锁的过程是由JVM管理的
- 当一个线程使用synchronize获取锁时,若锁被其他线程占用着,那么当前只能被阻塞,直到成功获取锁。而Lock则提供超时锁和可中断等更加灵活的方式,在未能获取锁的 条件下提供一种退出的机制。
- 一个锁内部可以有多个Condition实例,即有多路条件队列,而synchronize只有一路条件队列;同样Condition也提供灵活的阻塞方式,在未获得通知之前可以通过中断线程以 及设置等待时限等方式退出条件队列。
- synchronize对线程的同步仅提供独占模式,而Lock即可以提供独占模式,也可以提供共享模式
3、synchronized 锁升级的流程
synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。 锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
4、什么是偏向锁、轻量级锁、重量级锁?
- 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
- 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
- 重量级锁:有实际竞争,且锁竞争时间长。
5、什么是乐观锁、什么是悲观锁?
乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
乐观锁一般有两种实现方式:
- 采用版本号机制
- CAS(Compare-and-Swap,即比较并替换)算法实现
悲观锁:认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
synchronized关键字和Lock的实现类都是悲观锁
适合写操作多的场景,先加锁可以保证写操作时数据正确
6、什么是公平锁、什么是非公平锁?
公平锁:公平锁就是系统会根据线程请求锁的先后顺序来决定获取锁的先后顺序;所以公平锁的一个特点就是它不会产生锁饥饿问题,因为只要你排了队,最终还是会可以等待资源的。
非公平锁:非公平锁就是优先请求到锁的线程并不一定被系统优先分配获取到锁。synchronized 是由 JVM 内部来控制的,是非公平锁;ReentrantLock 默认是非公平锁,不过可以设置它为公平锁
7、为什么会有公平锁、非公平锁?为什么默认是非公平的?
1、恢复挂起的线程到真正获取到锁是有时间差的,而这个时间差在 CPU 的角度来看,这个时间差存在的还是很明显的,所以非公平锁能更充分的利用 CPU 的时间片,尽量减少 CPU 空闲状态时间。
2、使用多线程需要考虑线程切换的开销,当采用非公平锁时,当 1 个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就能减少线程的开销。
8、使用非公平锁会有什么问题?
使用非公平锁就可能导致某些线程在长时间排队也没有机会获取到锁,从而产生锁饥饿问题
9、什么时候使用公平锁?什么时候使用非公平锁?
如果为了更高的吞吐量,那么使用非公平锁比较合适,因为节省了很多线程切换时间,这样吞吐量就自然上去了;否则就使用非公平锁,大家公平使用
10、什么是可重入锁?
同一个线程在外层方法获取到锁之后,再进入该线程的内层方法会自动获取锁(前提:锁对象必须是同一个对象),不会因为之前已经获取过还没释放而阻塞。
synchronized、ReentrantLock 都是可重入锁,可重入锁的一个优点就是可以一定程度上避免死锁。
11、synchronized 是如何实现可重入的?
每一个锁对象都拥有一个锁计数器和一个指向持有该锁的线程的指针,当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有,Java 虚拟机会将锁对象的持有线程设置为当前线程,并且将其计数器加 1;在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直到持有线程释放该锁;当执行 monitorexit 时,Java 虚拟机则需要将锁对象的计数器减 1,计数器为 0 代表锁已被释放。
12、什么是死锁?
死锁就是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将没有办法继续推进下去,如果系统资源充足,进行的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。
13、死锁产生的原因?
1、系统资源不足
2、进行运行推进的顺序不合适
3、资源分配不当
14、如何排查死锁?
方式1:
1、先使用 jps -l 查看当前的进程
2、使用 jstack 进程编号 查看当前进程是否发生死锁
方式2:
使用 JDK 自带的 jconsole 图形化监控中心检查是否发生死锁
4、并发容器
1、常见的线程安全的并发容器有哪些?
CopyOnWriteArrayList、CopyOnWriteArraySet:采用写时复制 + ReentrantLock 来实现线程安全
ConcurrentHashMap:采用 synchronized + CAS + volatile 来保证线程安全
2、什么是写时复制?
CopyOnWrite 容器就是一个写时复制的容器。向一个容器中添加元素的时候,不直接往当前容器 Object[] 添加,而是先将当前容器 Object[] 进行 复制,复制出一个新的容器 Object[] newElements ,然后向新的容器 Object[] newElements 里添加元素。添加元素后,再将原容器的引用指向新的容器 setArray(newElements)。这样做的好处就是可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。
3、ConcurrentHashMap 是如何实现线程安全的?
1)JDK1.7:
是通过 ReentrantLock、Segment 数组、HashEntry 数组以及 Unsafe 类来保证线程安全的。它是由一个 Segment 数组和多个 HashEntry数组组成的,每一个 Segment 元素中存储的是 HashEntry 数组 + 链表,而且每个 Segment 均继承自可重入锁 ReentrantLock,也就带有了锁的功能,当线程执行 put 的时候,只锁住对应的那个 Segment 对象,对其他的 Segment 的 get、put 互不干扰,这样子就提升了效率,做到了线程安全。在 put 方法中进行了两次 Hash 计算去定位数据的存储位置,尽可能的减少哈希冲突,然后再根据 Hash 值以 Unsafe 调用方式,直接获取相应的 Segment,最终将数据添加到容器中是由 Segment 对象的 put 方法来完成。而由于 Segment 对象本身就是一把锁,所以在新增数据的时候,相应的 Segment 对象块是被锁住的,其他献策和国内并不能操作这个 Segment 对象,这样就保证了数据的安全性。在扩容时也是这样的,它扩容只是针对 Segment 对象中的 HashEntry 数组进行扩容,因为 Segment 对象是一把锁,所以在rehash 的过程中,其他线程无法对 Segment 的 hash 表进行操作,这就解决了 HashMap 中 put 数据引起的闭环问题。
2)JDK1.8:
放弃了 HashEntry 结构,而是采用了跟 HashMap 结构非常类似的 Node 数组 + 链表(当链表长度大于 8 时会考虑转为红黑树)的结构来实现的,并发控制采用 synchronized + CAS 来确保安全性。它将锁的粒度控制在了每个数组的 Node 头结点上,所以它的并发性能要比 1.7 版本的并发性能更高,正是由于它的粒度更细,所以它在选取锁的时候就没有再使用 ReentrantLock 了,而是使用synchronized ,在粗粒度加锁中 ReentrantLock 可能通过 Condition 来控制各个边界,更加灵活,但是在细粒度中,Condition的优势就没有了,所以使用内置的 synchronized 并不比 ReentrantLock 效果差,从另外的一种角度上来看,synchronized 它作为内置的关键字,并且在 1.6 的时候还对synchronized 进行了一些优化,比如锁升级,所以官方可能是考虑后面还会在 synchronized上进行优化。
4、ConcurrentHashMap 可以使用 ReentrantLock 作为锁吗?
理论上来件的话应该是可以的,但是我认为这个 synchronized 关键字会更好一点吧,因为在 1.6 之后对synchronized 关键字也进行了一些优化,它里面引入了偏向锁、轻量级锁、重量级锁,那么这些在 ReentrantLock 中是没有的,并且随着 JDK 版本的这个升级,这个 synchronized 也在进一步的进行优化,因为这个 ReentrantLock 是使用 Java 代码来实现的,所以在之后的话也很难有特别大的一种提升空间,所以的话,让我选的话,我会优先选择 synchronized,然后再考虑 ReentrantLock。
5、补充
1、谈谈你对 volatile 的理解
volatile 是 Java 虚拟机提供的轻量级的同步机制,是基本上遵守了JMM的规范,主要是保证可见性和禁止指令重排,但是它并不保证原子性
2、什么是 JVMM?
Java 内存模型:是一个规则或者规范;通过规范定制了程序中各个变量(包括实例变量、类变量、数组对象)的访问方式。
JMM 的内存语义:
1、当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
2、当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
3、所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取
3、什么是内存屏障?
内存屏障:是一种屏障指令,它使得 CPU 或编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束;也叫内存栅栏或栅栏指令
能干嘛:
- 阻止屏障两边的指令重排序
- 写数据时加入屏障,强制将线程私有工作内存的数据刷回主物理内存
- 读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取最新数据
4、CAS 是什么?
Compare And Swap 的缩写,即比较并交换,它包含三个操作数:内存位置、预期原值、更新值
而CAS它本质上是一条CPU的原子指令(汇编指令),不会造成所谓的数据不一致问题,Unsafe提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg。
执行cmpxchg指令的时候,会判断当前系统是否为多核系统,如果是就给总线加锁,只有一个线程会对总线加锁成功,加锁成功之后会执行cas操作,也就是说CAS的原子性实际上是CPU实现的, 其实在这一点上还是有排他锁的,只是比起用synchronized, 这里的排他时间要短的多, 所以在多线程情况下性能会比较好。
在进行 CAS 操作的时候:
- 会将内存位置的值与预期原值比较
- 如果匹配,那么处理器将会自动将该位置的值更新为新值
- 如果不匹配,处理器将不做任何操作
- 多个线程同时执行 CAS 操作只有一个会成功
5、CAS 的底层原理是什么?
Unsafe 是 CAS 的核心类,由于 Java 方法没有办法直接访问底层系统,需要通过本地方法来方位,而 Unsafe 类可以直接操作特定内存的数据,Unsafe 类中的所有方法都是 native 修饰的,也就是说 Unsafe 类中的方法都直接调用操作系统底层资源执行相应任务。
6、CAS 有什么缺点?
1、ABA 问题:
在并发环境下,假设初识条件是 A,去修改数据时发现是 A 就会执行修改;但是看到的虽然是 A,中间可能发生了 A 变 B。但又变回 A 的情况,此时即使数据修改成功了,但也有可能会出现问题。
解决方案:通过 AtomicStampedReference 添加版本号,即使值相同也可以通过版本号来识别
atomicStampedReference.compareAndSet(预期原值,更新值,预期版本号,更新成功后的版本号)
2、自旋时间过长:
如果条件一直不满足,CAS 会持续自旋,浪费 CPU 资源
解决方案:限制自旋次数,采用自适应自旋
3、只能保证一个变量操作的原子性:
普通的 CAS 只能比较一个值并保证原子性,如果要修改一个对象,而这个对象中有多个属性,那么此时就无法保证该操作的原子性
解决方案:通过 AtomicReference 实现
7、原子类的原理
AtomicInteger 类利用 CAS (Compare and Swap) + volatile + native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
CAS 的原理,是拿期望值和原本的值作比较,如果相同,则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是个本地方法,这个方法是用来拿“原值”的内存地址,返回值是 valueOffset;另外,value 是一个 volatile 变量,因此 JVM 总是可以保证任意时刻的任何线程总能拿到该变量的最新值。
8、ThreadLocal 的原理
ThreadLocal 的 set() 方法:
组成:每一个线程都有属于自己的一个 ThreadLocal.ThreadLocalMap,ThreadLocalMap 中又维护了一个 Entry 数组,Entry 又继承了 WeakReference 、WeakReference 又继承了 Reference;在向 ThreadLocal 中添加元素的时候,其实就是向当前线程的 ThreadLocalMap 中的 Entry 数组添加一个 Entry,要添加的值就是 Entry 的一个成员变量,并且会把当前的 ThreadLocal 对象赋值给 Reference 类中的 referent 变量
先获取到当前线程
根据当前线程获取当前线程的 ThreadLocal.ThreadLocalMap (因为一个线程即 Thread 都有一份属于自己的 ThreadLocal.ThreadLocalMap 成员变量)
如果这个线程的 ThreadLocalMap 不为 null 的话,会以 ThreadLocal 对象为 key,以要添加的值为 value,添加到 当前线程的 ThreadLocalMap 的 Entry[] 中
否则,即当前线程的 ThreadLocalMap 为 null 的话,会创建一个 ThreadLocalMap
根据初始化容量创建一个长度为 16 的 Entry 数组
然后根据传进来的 ThreadLocal、value 创建一个 Entry 对象,并将该 Entry 对象添加到新创建的 Entry 数组中
- 由于 Entry 继承了 WeakReference,并且在使用 Entry 的构造器创建对象的时候,内部又将 ThreadLocal 作为参数通过 super 调用 WeakReference 的构造器,而 WeakReference 又继承了 Reference,并且在 WeakReference 中又通过 super 调用了 Reference 的构造器,将 ThreadLocal 赋值给 Reference 的 referent 成员属性。
9、了解 ThreadLocal 的内存泄漏吗?
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用引用他,那么系统gc的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话(比如正好用在线程池),这些key为null的Entry的value就会一直存在一条强引用链
虽然弱引用,保证了key指向的ThreadLocal对象能被及时回收,但是v指向的value对象是需要ThreadLocalMap调用get、set时发现key为null时才会去回收整个entry、value,因此弱引用不能100%保证内存不泄露。我们要在不使用某个ThreadLocal对象后,手动调用remoev方法来删除它,尤其是在线程池中,不仅仅是内存泄露的问题,因为线程池中的线程是重复使用的,意味着这个线程的ThreadLocalMap对象也是重复使用的,如果我们不手动调用remove方法,那么后面的线程就有可能获取到上个线程遗留下来的value值,造成bug。
10、AQS 是什么?
AQS 的全称是 AbstractQueueSynchronizer ,翻译过来就是抽象的队列同步器,它是用来构建锁以及同步器组件的基础框架,具体是通过先进先出的队列资源获取线程的排队工作,并通过一个 int 类型的变量来表示持有锁的状态
1、AQS 框架中的产品:
- CountDownLatch(等待事件)
- CyclicBarrier
- Semaphore(等待线程)
- ReentrantLock
- ReentrantReadWriteLock:锁降级次序:获取写锁、获取读锁、释放写锁(写锁降级为读锁)
2、两种模式:
- 独占模式:同一时间只能有一个线程获得锁,锁的状态只有 0 和 1 两种情况
- 共享模式:同一时间可以有多个线程获得锁,锁的状态大于或等于 0
3、组成:
queue
state:被 volatile 修饰,用来记录锁的状态
线程对象
Node:
- 线程
- 等待状态
- 前驱节点
- 后继节点
- 共享Node
- 独占Node
11、AQS 能干嘛?
使用 AQS 能简单且高效地构造出应用广泛的大量的同步器
12、ReentrantLock 的原理?
ReentrantLock 的内部类 Sync 继承了 AbstractQueuedSynchronizer
ReentrantLock 的内部类 NonfairSync 继承了 Sync
ReentrantLock 的内部类 FairSync 继承了 Sync、
非公平锁与公平锁的差异就在于在获取锁的时候,少了一个是否需要进行排队的判断
- 公平锁:公平锁讲究先来先到,线程在获取锁时,如果这个锁的等待队列中已经有线程在等待,那么当前线程就会进入等待队列中
- 非公平锁:不管是否有等待队列,如果可以获取锁,则立刻占用锁对象,也就是说队列的第一个排队线程在 unpark(),之后还是需要竞争锁(存在线程竞争的情况下)
13、读写锁的意义和特点?
读写锁并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的。
大多数实际场景是 "读/读" 线程间并不存在互斥关系,只有 "读/写" 线程或 "写/写" 线程间的操作需要互斥的
一个 ReentrantReadWriteLock 同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁;也就是只有在读多写少的情况下,读写锁才能具有较高的性能体现。
支持读并发
14、锁是如何进行降级的?
ReentrantReadWriteLock:锁降级次序:获取写锁、获取读锁、释放写锁(写锁降级为读锁)
15、StampedLock 是什么?
是 JDK1.8 新增的一个读写锁,同时也是对 JDK1.5 中的读写锁 ReentrantReadWriteLock 的优化
ReentrantReadWriteLock 实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难,假设有 1000 个线程,999 个 读, 1 个写,有可能 999 个读取线程长时间抢到了锁,那 1 个写线程就会因为一直存在读锁而无法获得写锁,导致没有机会写(锁饥饿)
16、如何缓解锁饥饿?
在创建的时候,将读写锁设置成公平锁,但是会降低系统的吞吐量
17、StampedLock 的特点是什么?
StampedLock 采取的是乐观锁,当一个线程获取锁之后,其他线程尝试获取写锁时不会阻塞,它通过一个 stamp 记录锁的状态,当 stamp 返回 0 时,表示线程获取锁失败,并且当释放锁或者转换锁的时候,都要传入最初获取的 stamp 值,所以在获取乐观读锁后,还需要对结果进行校验
1、特点:
- 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为零表示获取失败,其余都表示成功;
- 所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致;
- StampedLock是不可重入的,危险(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)
2、访问模式:
- Reading(读模式):功能和ReentrantReadWriteLock的读锁类似
- Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
- Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式
18、StampedLock 的缺点是什么?
StampedLock 不支持重入,没有Re开头
StampedLock 的悲观读锁和写锁都不支持条件变量(Condition),这个也需要注意。
使用 StampedLock一定不要调用中断操作,即不要调用interrupt() 方法
- 如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly()和写锁writeLockInterruptibly()
3、MySQL
1、什么是事务
在一次 SQL 连接会话过程中执行的所有 SQL,要么都成功,要么都失败
2、事务的四大特性
1、原子性(Atomicity) :事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位【undo 日志】
2、一致性(Consistency) :事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。【undo 日志】
3、隔离性(Isolation) :同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。【锁】
4、持久性(Durability) :事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。【redo 日志】
3、事务的隔离级别
读数据一致性及允许的并发副作用隔离级别 读数据一致性 脏读 不可重复读 幻读 读未提交(Read uncommitted) 最低级别,只能保证不读取物理上损坏的数据 是 是 是 读已提交(Read committed) 语句级 否 是 是 可重复读(Repeatable read) 事务级 否 否 是 可序列化(Serializable) 最高级别,事务级 否 否 否
4、脏读、不可重复读、幻读
1、脏读
一个事务读取到其他事务未提交的数据
2、不可重复读
读取到已提交事务的数据,而两次读取过程中数据被别人修改过了,导致两次读取的结果不一致
3、幻读
一个事务读取到另一个事务新提交的新数据,导致两次读取数据不一致
在Innodb引擎下MySQL里执行的事务,默认情况下不会发生脏读、不可重复读和幻读的问题
5、MySQL 的逻辑架构
1、连接层:建立 TCP 连接,三次握手建立连接成功后,进行身份认证、权限获取,然后分配一个线程专门与这个客户端进行交互
2、服务层:
- 通过 SQL 接口接收 SQL 命令;
- 通过解析器对 SQL 进行语法分析、语义分析生成语法树;
- 通过查询优化器进行优化,生成一份查询计划
3、引擎层:根据查询计划,去磁盘中查询数据
4、存储层:存储数据
6、InnoDB 和 MyISAM 的区别
对比项 MyISAM InnoDB 主外键 不支持 支持 事务 不支持 支持 行表锁 表锁(不支持高并发) 行锁(支持高并发) 缓存 只缓存索引,不缓存真实数据 不仅缓存索引还要缓存真实数据,对内存要求较高,而且内存大小对性能有决定性的影响 表空间 小 大 关注点 查询性能 事务
7、MySQL 的索引是什么?
索引就是一个排好序的可以快速查找的一个B+树数据结构
8、InnoDB 为什么要使用 B+ 树?
如果 InnoDB 不使用 B+ 树,首先,如果使用数组进行存储的话,由于数组中的元素在内存空间上是连续的,并且支持随机访问,所以查找或修改指定下标元素的时间复杂度为 O(1),效率很高,但是如果要插入和删除数组中的某个元素的时候,就需要移动数组中的元素,所以它在插入和删除指定下标元素的时间复杂度为 O(n),这样分析下来,数组的查询和修改效率高但是插入和删除效率却很低,性能不稳定,所以不能使用数组;
如果使用 Hash 表存储元素的话,由于 Hash 表它的增删改查的时间复杂度均为 O(1),但是 Hash 表存储的元素是无序的,无法对元素进行排序和范围查询,并且 InnoDB 也并不支持 Hash 表来存储索引,所以不能使用 Hash 表;
如果使用普通二叉树的话,由于二叉树查找元素时使用二分查找法,所以它在进行查找方面的时间复杂度为 O(logn),但是由于普通二叉树可能会出现数据倾斜从而导致普通二叉树退化为链表,所以不能使用普通二叉树;
如果使用平衡二叉树的话,由于平衡二叉树能够通过旋转的方式来防止出现数据倾斜,所以它的性能稳定,平均访问、搜索、插入、删除的时间复杂度均为 O(logn),但是平衡二叉树的深度会很容易随着元素的增长而增长,导致我们查询叶子节点的次数增多,而树的深度又决定了对磁盘的IO次数,所以不能使用平衡二叉树;
如果使用B树的话,就可以降低树的高度,从而减少磁盘IO次数;而 InnoDB 使用的是 B+ 树,是因为 B+ 树相较于 B 树而言,B+ 树的所有数据都存储在叶子节点上,非叶子节点上仅存储键值,而B树不仅叶子节点上可以存储数据,它的非叶子节点上也可以存储数据,但是由于InnoDB中的磁盘页的默认大小是16kB,空间有限,为了能够在一个磁盘页中存储更多的键值,从而再次降低树的深度减少磁盘IO,这样一来数据查询的效率也会提高,并且B+树的所有数据均存储在叶子节点,而且数据是按照顺序以链表的方式连接着的,所以使用B+树可以让 InnoDB 更高效地进行范围查找、排序查找、分组查找以及去重查找。
9、索引的优点
1、提高数据检索的效率,降低数据库的IO成本;
2、降低数据排序的成本,降低了CPU的消耗
10、索引的缺点
1、创建索引和维护索引需要耗费时间,并且随着数据量的增加,所耗费的时间也会增加
2、索引需要占磁盘空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,存储在磁盘上,如果有大量的索引,索引文件就可能比数据文件更快达到最大文件尺寸
3、虽然索引大大提高了查询速度,同时却会降低更新表的速度。当对表中的数据进行增加、删除和修改的时候,索引也要动态地维护,这样就降低了数据的维护速度。
11、什么情况下会导致索引失效
1、没有遵循最左前缀原则
2、主键的插入顺序不是有序的
3、使用计算、函数、类型转换
4、范围条件右边的列
5、使用不等于失效
6、is null 可以使用索引,is not null 无法使用索引
7、like 以通配符 % 开头
8、or 前后存在非索引的列
9、字符串不加单引号
注意:5、8 可以使用覆盖索引的方式来进行处理
【优化口诀】select * 前提下
全职匹配我最爱,最左前缀要遵守; 带头大哥不能死,中间兄弟不能断; 索引列上少计算,范围之后全失效; LIKE百分写最右,覆盖索引不写*; 不等空值还有OR,索引影响要注意; VAR引号不可丢, SQL优化有诀窍。
12、InnoDB 的行锁到底锁的是什么?
InnoDB的行锁,是通过锁住索引来实现的,如果加锁查询的时候没有使用到索引,会将整个聚簇索引都锁住,相当于锁表了。
命中索引锁行,没有命中锁表,问题会扩大化,小心
13、如何查看死锁?
14、如何查看死锁
1、查看表被锁状态和结束死锁:
use 数据库名; // 切换到具体的数据库 show engine innodb status; // 查询数据库是否发生死锁 2、查看数据表被锁状态
show open table where In_use>0;3、分析锁表的SQL,通过SQL日志,分析相应SQL,给表加索引,常用字段加索引,表关联字段加索引等方式对SQL进行优化
4、查看运行的所有事务
SELECT * FROM information_schema.innodb_trx; kill 线程id # 线程id就是查询出来的 trx_mysql_thread_id5、查询进程
show processlist;
14、有没有设计过数据库表,怎么设计的?
15、聚簇索引与非聚簇索引有什么区别
聚簇索引一个表只能有一个,聚簇索引是将表的数据行都存放在索引树的叶子节点上,InnoDB的聚簇索引实际上是将索引和数据保存在同一个B+Tree中,InnoDB通过主键聚集数据,如果没有定义主键,InnoDB会选择一个唯一且非空的索引,如果没有这样的索引,InnoDB会隐式定义一个主键来作为聚簇索引。
非聚簇索引的叶子节点中保存的不是指向数据行的物理指针,而是行的主键值,当通过非聚簇索引查找数据行的时候,存储引擎需要在非聚簇索引中找到相应的叶子节点,获得行的主键值,然后使用主键去聚簇索引中查找数据行,这就需要两次B+Tree查找。
都是B+树的数据结构
聚簇索引:将数据存储与索引放到了一块、并且是按照一定的顺序组织的,找到索引也就找到了数据,数据的物理存放顺序与索引顺序是一致的,即:只要索引是相邻的,那么对应的数据一定也是相邻地存放在磁盘上的
非聚簇索引叶子节点不存储数据、存储的是数据行地址,也就是说根据索引查找到数据行的位置再取磁盘查找数据,这个就有点类似一本书的目录,比如我们要找第三章第一节,那我们先在这个目录里面找,找到对应的页码后再去对应的页码看文章。
优势:
1、查询通过聚簇索引可以直接获取数据,相比非聚簇索引需要第二次查询(非覆盖索引的情况下)效率要高
2、聚簇索引对于范围查询的效率很高,因为其数据是按照大小排列的
3、聚簇索引适合用在排序的场合,非聚簇索引不适合
16、如何处理慢查询
先根据慢查询日志,定位到是哪些 SQL 的时间超过了预期时间,然后分析是不是查询了多余的字段、是不是查询的数据量太大了,查询条件是否命中索引?
如果是查询了多余的字段的话,就在查询的时候,只查询能用得到的字段;如果是表中的数据量太大的话,就需要考虑是否需要进行横向或者纵向的分表,如果没有命中索引的话,就要看他是为什么没有命中索引,就比如说看他有没有满足最左前缀原则、条件字段是否进行了计算、使用了函数、是否进行了范围查询、在使用模糊匹配的时候是不是将%写在了开头、要查询的字段是否使用到了覆盖索引、使用 or 进行条件查询时,左右两边是否有非索引字段。
17、MySQL 的主从复制原理
关键:
1、核心:两个日志、三个线程
2、两个日志:二进制日志、中继日志
3、三个线程:I/O、dump、sql
原理:MySQL主从复制是一个异步的复制过程,主库发送更新事件到从库,从库读取更新记录,并执行更新记录,使得从库的内容与主库保持一致
对于每一个主从复制的连接,都有三个线程。主库会为每一个连接的从库创建一个 binlog 输出线程,而每一个从库都有他自己的 I/O 线程和 SQL 线程。
binlog 日志:主库中保存所有更新事件日志的二进制文件,它会在数据库服务启动的一刻起,保存数据库所有变更记录(数据库结构和内容)的文件。在主库中,只要有更新事件出现,就会被依次地写入到 binlog 中,之后会推送到从库作为从库进行复制的数据源。
binlog 输出线程:每当有从库连接到主库时,主库都会创建一个线程然后发送 binlog 内容到从库
从库 I/O 线程:当 start slave 语句在从库开始执行后,从库会创建一个 I/O 线程,该线程会连接到主库,然后接收主库的 binlog 输出线程传来的更新事件保存到自己的中继日志
从库SQL 线程:从中继日志中读取更新事件,进行执行,完成从库数据的同步
18、MySQL 的优化你了解多少?
19、B+Tree 的执行流程
20、数据库优化怎么做的,除了索引还有什么
21、索引的分类,索引是什么,索引的作用
22、索引失效的情况?explain 分析 SQL 时有哪些字段?通过什么字段能够看出有没有使用到索引?
23、交集用哪个关键字?并集呢?左关联呢?
24、union 和 union all 的区别
25、新建表的时候,主键是怎么设置的,语法是什么?
26、唯一索引和主键索引的区别?
27、MySQL 怎么排序
28、如何只差一条记录
29、MySQL联表查询如何优化
30、MySQL 有哪些主键
31、MySQL 查询罪行日期的数据记录该怎么写(只返回一条)
32、如何查询某一段日期中商品的库存(日期是如何处理的)
33、日期类型字段的拼接、类型转换、拆分
34、你优化过哪些 SQL
35、分库分表的主键是怎么生成的?由什么来作为分库分表的主键的?
36、了解过 MySQL的聚簇索引嘛
37、对于聚簇索引的主键,为什么要考虑使用雪花算法
38、UUID 和通过雪花算法生成的主键有什么区别
39、Mycat 和 Sharding JDBC 的区别
40、哪些字段会考虑使用索引
41、为什么索引这么快
42、MySQL 分库分表之后有什么问题
43、分库分表之后在进行 join 时是如何处理的
44、根据执行计划如何进行分析,type 都有哪些
45、哪些情况下会导致全表扫描
46、给表加索引时需要注意哪些
47、介绍几个SQL优化的方式
48、当要插叙一个超过百万数据的表中数据的个数的时候,使用 count 效率很低,有什么解决方式
4、Redis
1、简单介绍一下 Redis
Redis 是一个 key-value 类型的非关系型数据库,基于内存也可持久化的数据库,相对于关系型数据库(数据主要存在硬盘中),性能高,因此我们一般用redis来做缓存使用;并且redis支持丰富的数据类型,比较容易解决各种问题
类型 底层数据结构/介绍 使用场景 string 简单动态字符串(Simple Dynamic string 缩写SDS).是可以修改的字符串,内部结构实现上类似java中的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配.需要注意的是字符串最大长度为512M 项目中我们主要利用单点登录中的token用string类型来存储;商品详情 hash Hash类型第一段数据结构有两种:ziplist(压缩列表),hashtable(哈希表).当field-value长度较短且个数较少时,使用ziplist,否则使用HashTable Hash类型中的key是string类型,value又是一个map(key-value),针对这种数据特性,比较适合存储对象,在我们项目中由于购物车是用redis来存储的,因此选择redis的散列(hash)来存储; list 单键多值,底层是快速双向链表quicklist,在列表元素较少的情况下会使用一块连续的内存存储,结构为ziplist即压缩列表,它将所有的元素紧挨着一起存储,分配的是一块连续的内存.当数据量比较多的时候才会改成quicklist,因为普通的链表需要的附加指针空间太大,浪费空间,eg:列表中存储的只是int类型的数据,结构上还需要两个额外的指针prev与next.redis将链表和ziplist组合起来组成quicklist,即将多个ziplist使用双向指针串起来使用,既满足了快速插入删除性能,又不会出现太大的空间冗余 List类型是按照插入顺序的字符串链表(双向链表),主要命令是LPOP和RPUSH,能够支持反向查找和遍历,如果使用的话主要存储商品评论列表,key是该商品的ID,value是商品评论信息列表;消息队列 set set数据结构是dict字典,字典是用哈希表实现的,java中HashSet内部使用的是HashMap,只不过所有的value都指向同一个对象.Redis中的set也是一样的,他的内部结构也使用hash结构,所有的value都指向同一个内部值 可以基于 Set 玩儿交集、并集、差集的操作,比如交集吧,我们可以把两个人的好友列表整一个交集,看看俩人的共同好友是谁? zset zset底层使用了两个数据结构 (1)hash,hash的作用就是关联元素value和权重score,保障元素value的唯一性,可以通过元素value找到对应的score的值 (2)跳跃表,跳跃表的目的在于给元素value排序,根据score的范围获取元素列表 zset(sorted set)类型和set类型基本是一致的,不同的是zset这种类型会给每个元素关联一个double类型的分数(score),这样就可以为成员排序,并且插入是有序的。这种数据类型如果使用的话主要用来统计商品的销售排行榜,比如:items:sellsort 10 1001 20 1002 这个代表编号是1001的商品销售数量为10,编号为1002的商品销售数量为20/附件的人 Bitmaps bitmaps可以实现对位的操作,节约内存空间(前提是数据量大) (1) bigmaps本身不是一种数据类型,实际上是字符串,但是它可以对字符串进行位运算 (2)bitmaps单独提供了一套命令,可以把bitmaps理解成一个以位为单位的数组,数组的每一个单元只能存储0或者1,数组的小标在bitmaps中叫做偏移量 统计网站活跃用户 解决redis随机穿透攻击 HyperLogLog 统计uv,独立ip数,搜索记录数等需要去重和计数的问题如何解决?这种求集合中不重复元素个数的问题成为基数问题.常规的解决办法包括mysql中的distinct与redis的hash set等方案精确计算,但是随着数据不断增加,导致空间越来越大,对于非常大的数据集是不切实际的.该数据类型可以通过降低精度来平衡存储空间,是用来做基数(集合中不重复元素的个数)统计的算法 计算基数的应用场景 网站的uv 固定IP数 搜索记录数 Geospatial 地理信息的缩写,该类型,就是元素的二维坐标,在地图上基数经纬度,redis基于该类型,提供了经纬度设置,查询,范围查询,距离查询 经纬度hash等常见操作
2、单线程的redis为什么读写速度快?
1、因为 Redis 是基于内存操作的,数据存在内存中,类似于 HashMap,HashMap 的优点就是查找和操作的时间复杂度都是 O(1);
2、数据结构简单,对数据操作也简单,Redis 中的数据结构是经过专门设计的;
3、采用单线程,避免了不必要的上下文切换和竞争条件;
4、使用多路 I/O 复用模型,是非阻塞 IO;
3、redis为什么是单线程的?
Redis 官方表示,因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,而 Redis 的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了,Redis是利用队列技术将并发访问变为串行访问;采用单线程,可以避免不必要的上下文切换和竞争条件。
4、redis服务器的的内存是多大?
可以在 redis.conf 文件中设置 redis 的最大内存参数:maxmemory,如果这个参数不设置或者设置为0,则 redis 的默认内存大小在 32 位系统中是 3G,而在 64 位系统中不受限制;一般推荐设置 Redis 的内存为最大为物理内存的四分之三,如果是临时设置内存大小的话,可以通过 config set maxmemory 内存大小,它会在服务器重启后失效;可以通过 config get maxmemory 获取当前内存大小(字节类型)
5、为什么 Redis 的操作是原子性的?它是怎么保证原子性?
对于 Redis 而言,命令的原子性指的是:一个操作的不可再分,操作要么执行,要么不执行;
Redis 的操作之所以是原子性的,是因为 Redis 是单线程的,并且Redis 本身提供的所有 API 都是原子操作;
并且Redis 中的事可以保证批量操作的原子性。
多个命令在并发中也是原子性的吗?
不一定;
所以可以将 get 和 set 改成单命令操作:incr;
使用 Redis 的事务,或者使用 Redis+Lua 脚本的方式实现。
6、Redis 有事务吗?
Redis 是有事务的,Redis 中的事务是一组命令的集合,这组命令要么都执行,要么都不执行,Redis 的事务需要使用 multi 开启事务,通过 exec 来结束事务;
当输入 multi 后,服务器返回 Ok 表示事务开启成功,然后依次输入需要在本次事务中执行的所有命令,每次输入一个命令服务器并不会马上执行,而是返回 "queued",这表示命令已经被服务器接收并且暂时保存起来,最后输入 exec,本次事务中的所有命令才会被依次执行。
Redis 的事务除了保证所有命令要么全部执行,要么全部都不执行外,还能保证一个事务中的命令依次执行而不被其他命令插队,同时,Redis 的事务是不支持回滚的。
7、缓存击穿、缓存穿透、缓存雪崩的原因和解决方案?(或者说使用缓存的过程中有没有遇到什么问题,怎么解决的)
缓存穿透:
概述:指查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。
解决方案:
1、查询返回的数据为空,仍把这个空结果进行缓存,但过期时间会比较短
2、布隆过滤器:将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对DB的查询
缓存击穿:
概述:对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,
这个时候大并发的请求可能会瞬间把 DB 压垮。
解决方案:
1、使用互斥锁:当缓存失效时,不立即去load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时再进行 load db的操作并回设缓存,否则重试get缓存的方法
2、永远不过期:不要对这个key设置过期时间
缓存雪崩:
概述:设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB 瞬时压力过重雪崩。与缓存击穿的区别:雪崩是很多key,击穿是某一个key缓存。
解决方案:
将缓存失效时间分散开,比如可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
8、Redis 的持久化
类型 介绍 优点 缺点 RDB 默认持久化机制是按照一定的时间将内存中的数据以快照的形式保存到硬盘中 数据恢复速度快 可能丢失少量新数据持久化时存储数据效率低 AOF 是将Redis的每一次操作都写入到单独的日志文件中,当重启redis会重新从持久化的的日志中恢复数据 持久化效率高 不会丢失数据 恢复数据时效率低aof中记录的所有命令进行重放,效率低 混合模式(RDB+AOF) 将RDB和AOF混合一起使用,在使用混合模式时,所有的数据操作也是保存在AOF当中,当进行恢复文件的时候,会将原有的AOF删除,并且将其中的数据全部以快照的形式保存至RDB文件当中 持久化效率高保证数据的安全性不会丢失数据且恢复的速度快 RDB:
- 它能够在指定的时间间隔内对内存中的数据进行快照存储,而这个存储的过程是由 Redis 创建的子进程来完成的,在整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能,子进程会先将数据写入到某个临时文件中,等持久化过程都结束了,再用这个临时文件替换上次持久化好的文件
- 它的保存策略是通过 save n m 来实现的,n 指的是 n 秒内,m 表示至少有 m 个值发生变化,比如 save 300 10,就表示在 300 秒内如果有 10 个值发生了变化,就保存一次
AOF:
- 它是以日志形式记录每个更新操作,它会在 Redis 重新启动时读取这个文件,然后执行新建、修改数据的命令来恢复数据
- Redis还能对 AOF 文件进行后台重写,所以 AOF文件的体积不会过大
- 三种保存策略:
- appendfsync always,它指的是每次产生一条新的修改数据的命令都执行保存操作,虽然效率低,但是安全
- appendfsync eversec,它表示每秒执行一次保存操作,但是如果在这秒内有操作没有保持时发生了断点,还是会丢失1 秒钟的数据;
- appendfsync no,它表示从不保存,而是将数据交给操作系统来处理,这样虽然快,但是不安全,
- 所以 Redis 推荐并且默认的方式就是每秒 fsync 一次,这种 fsync 策略可以兼顾速度和安全性,但是缺点是比 RDB 更占磁盘空间,恢复备份速度更慢,每次读写都同步的话,有一定的性能压力,并且存在个别 bug,会导致数据不能恢复。
9、Redis中数据的删除策略
1、定时删除:用一个定时器来负责监视 key ,过期则自动删除。虽然内存及时释放,但是十分消耗 CPU 资源。在大并发请求下,CPU 要将时间应用在处理请求,而不是删除 key ,因此没有采用这一策略
2、惰性删除:设置该 key 过期时间后,我们不去管它,当需要该 key 时,我们在检查其是否过期,如果过期,我们就删掉它
3、定期删除:每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机键
进行检查,并删除其中的过期键)
10、Redis 中数据的内存淘汰机制
noeviction # 不删除任何数据,内存不足直接报错(默认策略) volatile-lru # 挑选最近最久使用的数据淘汰 volatile-lfu # 挑选最近最少使用数据淘汰 volatile-ttl # 挑选将要过期的数据淘汰 volatile-random # 任意选择数据淘汰 allkeys-lru # 挑选最近最少使用的数据淘汰 allkeys-lfu # 挑选最近使用次数最少的数据淘汰 allkeys-random # 任意选择数据淘汰,相当于随机
11、做过redis的集群吗?你们做集群的时候搭建了几台,都是怎么搭建的?
运维搭建的,不清楚
12、说说Redis哈希槽的概念?
Redis集群没有使用一致性hash,而是引入了哈希槽的概念,Redis集群有16384个哈希槽,每个key通 过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽。
13、你对redis的哨兵机制了解多少
哨兵模式是 Redis 可用性的解决方案;它是由一个或多个 Sentinel 实例构成的 Sentinel 系统;
如果Master异常,则会进行Master-Slave切换,将其中一Slae作为Master,将之前的Master作为Slave
工作原理:
- 首先每个 Sentinel 以每秒钟一次的频率向它关联着的 Master 以及其他 Sentinel实例发送一个 ping 命令;
- 如果一个实例最后一次有效回复 ping 命令的时间超过下线时间时,那么这个实例就会被 Sentinel 标记为主观下线;
- 如果一个 Master 被标记为主观下线,则正在监听这个 Master 的所有 Sentinel 要以每秒一次的频率确认 Master 的确进入了主观下线状态;
- 当有足够数量的 Sentinel 在指定时间范围内确认 Master 的确进入了主观下线状态,则 Master会被标记为客观下线;
- 在一般情况下,每个 Sentinel 会以每 10 秒一次的频率向它已知的所有 Master、Sentinel 发送 info 命令;
- 当 Master 被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Sentinel发送 info 命令的频率会从 10 秒一次改为每秒一次;
- 若没有足够数量的 Sentinel 同意 Master 已经下线,Master 的客观下线状态就会被移除;
- 若 Master 重新向 Sentinel 的 ping 命令返回有效回复,Master 的主观下线状态就会被移除。
14、redis缓存与mysql数据库之间的数据一致性问题?
延时双删+设置缓存过期时间
5、RabbitMQ
6、Spring
1、说说你对Spring的理解?
1、Spring是一个为了简化企业级应用开发且非侵入式的轻量级开源框架。Spring主要有两个强大的功能:IOC、AOP
① 控制反转(IOC):通过工厂模式将我们之前自己手动创建对象的这一过程交给Spring去执行,将创建后的对象都存放到Spring容器中,并进行管理;我们使用时不需要自己去创建,直接从Spring容器中获取对象即可
② 依赖注入(DI):Spring使用Java Bean对象的Set方法或者带参数的构造方法在我们创建对象时,自动从Spring容器中获取所需对象并设置给当前对象的属性。
③ 面向切面编程(AOP):通过代理模式,在程序运行期间动态地将某段代码切入到指定方法的指定位置进行运行的一种编程方式,可以用来做权限验证,事务管理,记录日志等
2、在Spring的容器中管理的都是JavaBean对象,Spring 的 IOC 容器主要有两个,一个是 BeanFactory,还有一个是ApplicationContext,不过我们经常使用的是 ApplicationContext。
2、在Spring中有几种配置Bean的方式?
1、基于XML的配置
2、基于注解的配置
3、基于Java的配置
3、BeanFactory和ApplicationContext有什么区别?
首先,BeanFactory 和 ApplicationContext 都是 Spring 的两大核心接口,都可以当做 Spring 的容器。
BeanFactory 是 Spring 框架的基础设施,面向 Spring 本身;而 ApplicationContext 面向使用 Spring 框架的开发者,并且Spring官网也是推荐我们使用 ApplicationContext 的(比如可以通过实现 ApplicationContextAware 接口,继承 ApplicationObjectSupport、继承WebApplicationObjectSupport、通过WebApplicationContextUtils 都可以获取到 ApplicationContext)
1、功能上的区别:
BeanFactory 是 Spring 中最底层的接口,是 IOC 的核心,它包含了各种 Bean 的定义、加载、实例化、依赖注入和生命周期的管理,具有 IOC 最基本的功能;而 ApplicationContext 接口是 BeanFactory 的一个子接口,具有 BeanFactory 的所有功能,同时还继承了EnvironmentCapable、ListableBeanFactory、MessageSource、ApplicationEventPublisher、ResourcePatternResolver 接口,功能更加丰富。
2、加载方式的区别:
BeanFactory 是延时加载,也就是说在容器启动时不会实例化 bean,而是在需要使用的时候,才会对该 bean 进行加载实例化;而ApplicationContext 是在容器启动的时候,一次性创建所有的 bean,所以运行的时候速度相对于 BeanFactory 来说比较快;正是因为加载方式的不同,导致 BeanFactory 无法提前发现 Spring存在的配置问题。(如果 bean 的某个属性没有注入,BeanFactory 加载不会抛出异常,直到第一次调用 getBean() 方法时才会抛出异常)但是ApplicationContext 在容器启动时就可以发现 Spring 存在的配置问题,因为它是一次性加载的,有利于检测依赖属性是否注入(同样也是因为它一次性加载的原因,导致占用内存空间,当 bean 较多时,影响程序启动的速度)。
3、创建方式的区别:
BeanFactory 是以编程的方式创建的,而 ApplicationContext 是以声明的方式创建的。
4、注册方式的区别:
BeanFactory 和 ApplicationContext 都支持 BeanPostProcessor、BeanFactoryPostProcessor 的使用,而 BeanFactory 是需要手动注册的,ApplicationContext 是自动注册的。
4、Spring框架中的单例bean是线程安全的吗?
不是线程安全的,当多用户同时请求一个服务时,容器会给每一个请求分配一个线程,这时多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单例对象状态的修改(比如修改成员属性),则必须考虑线程同步问题。
Spring框架并没有对单例bean进行任何多线程的封装处理。关于单例bean的线程安全和并发问题需要开发者自行去搞定。但实际上,大部分的Spring bean并没有可变的状态(比如Service类和DAO类),所以在某种程度上说Spring的单例bean是线程安全的。
如果bean有多种状态的话(比如 View Model对象),就需要自行保证线程安全。最浅显的解决办法就是将多态bean的作用域由“singleton”变更为“prototype”。
5、Spring框架中有哪些不同类型的事件?
Spring 提供了以下5种标准的事件:
1、上下文更新事件(ContextRefreshedEvent):在调用ConfigurableApplicationContext 接口中的refresh()方法时被触发。
2、上下文开始事件(ContextStartedEvent):当容器调用ConfigurableApplicationContext的start()方法开始/重新开始容器时触发该事件。
3、上下文停止事件(ContextStoppedEvent):当容器调用ConfigurableApplicationContext的stop()方法停止容器时触发该事件。
4、上下文关闭事件(ContextClosedEvent):当ApplicationContext被关闭时触发该事件。容器被关闭时,其管理的所有单例Bean都被销毁。
5、请求处理事件(RequestHandledEvent):在Web应用中,当一个http请求(request)结束触发该事件。
6、请解释一下Spring框架有哪些自动装配模式,它们之间有何区别?
1、no:这是 Spring 框架的默认设置,在该设置下自动装配是关闭的,开发者需要自行在 bean 定义中用标签明确的设置依赖关系 。
2、byName:该选项可以根据bean名称设置依赖关系 。 当向一个bean中自动装配一个属性时,容器将根据bean的名称自动在在配置文件中查询一个匹配的bean。 如果找到的话,就装配这个属性,如果没找到的话就报错 。
3、byType:该选项可以根据 bean 类型设置依赖关系 。 当向一个 bean 中自动装配一个属性时,容器将根据 bean 的类型自动在在配置文件中查询一个匹配的 bean。 如果找到的话,就装配这个属性,如果没找到的话就报错 。
4、constructor :构造器的自动装配和byType模式类似,但是仅仅适用于与有构造器相同参数的bean,如果在容器中没有找到与构造器参数类型一致的bean ,那么将会抛出异常 。
5、default:该模式自动探测使用构造器自动装配或者byType自动装配 。 首先会尝试找合适的带参数的构造器,如果找到的话就是用构造器自动装配,如果在bean内部没有找到相应的构造器或者是无参构造器,容器就会自动选择 byTpe 的自动装配方式 。
7、解释一下Spring AOP里面的几个名词?
1、连接点(Join point):指程序运行过程中所执行的方法。在Spring AOP中,一个连接点总代表一个方法的执行。
2、切入点(Pointcut):被拦截用于做增强的那些连接点。
3、通知(Advice):指要在切入点(Join Point)上执行的动作,即增强的逻辑,比如权限校验和、日志记录等。
4、切面(Aspect):被抽取出来的公共模块,可以用来横切多个对象。Aspect切面可以看成 Pointcut切点和 Advice通知的结合,一个切面可以由多个切点和通知组成。
5、目标对象(Target):包含连接点的对象,也称作被通知(Advice)的对象。 由于Spring AOP是通过动态代理实现的,所以这个对象永远是一个代理对象。
6、织入(Weaving):通过动态代理,在目标对象(Target)的方法(即切入点)中执行增强逻辑(Advice)的过程。
7、引入(Introduction):添加额外的方法或者字段到被通知的类。Spring允许引入新的接口(以及对应的实现)到任何被代理的对象。
8、Spring中AOP的底层是怎么实现的?
AOP底层为动态代理,AOP指的是:在程序运行期间动态地将某段代码切入到指定方法指定位置进行运行的编程方式。在面向切面编程中,我们将一个个的对象某些类似的方面横向抽取为一个切面,对这个切面进行一些如权限控制、事物管理,记录日志等公用操作处理的过程就是面向切面编程的思想。AOP 底层是动态代理,如果是接口采用JDK 动态代理,如果是类采用CGLIB 实现动态代理
由于我们开发中通常使用的是 AspectJ 的AOP,所以,我就以AspectJ 来进行说明,在使用 AspectJ 的 AOP 的功能的时候,可以通过注解也可以通过 xml 方式开启,由于我们平时中都是以 SpringBoot 注解进行开发的,所以,我就以注解方式来进行说明。
首先需要在配置类上添加 @EnableAspectJ 注解,这个注解呢它又会通过 @Import 注解向容器中导入一个 AspectJAutoProxyRegistrar 组件,而这个组件又会让 Spring 容器中注册一个 AnnotationAwareAspectJAutoProxyCreator 这样一个基于注解驱动的 AspectJ 自动代理创建器的 bean;而这个 AnnotationAwareAspectJAutoProxyCreator 它又间接地继承自 AbstractAutoProxyCreator,AbstractAutoProxyCreator 又实现了 两个接口,一个是 BeanFactoryAware 接口,另外一个就是 SmartInstantiationAwareBeanPostProcessor,而 SmartInstantiationAwareBeanPostProcessor 又继承了 InstantiationAwareBeanPostProcessor 接口,InstantiationAwareBeanPostProcessor 接口又继承了 BeanPostProcessor 接口,这三个接口都会在 Bean 的不同生命周期执行不同的方法,BeanPostProcessor 中有一个 postProcessAfterInitialization() 方法,该方法是在 bean 初始化之后执行的,SmartInstantiationAwareBeanPostProcessor中有 getEarlyBeanReference 方法,该方法会结合 Spring 的第三级缓存 singletonFactories 解决代理对象的循环依赖问题 ,而 AbstractAutoProxyCreator 重写了这两个方法,这两个方法都会返回要创建对象的代理对象,所以在 Spring 容器一启动的时候,会先把 AnnotationAwareAspectJAutoProxyCreator 的 bean 放入 Spring 容器中,之后有对象进行创建的时候,AnnotationAwareAspectJAutoProxyCreator 的方法就会被执行,然后根据要代理的对象创建代理对象,创建代理对象的方式有两种,一种是有接口的情况下,会通过 JDK 动态代理创建代理对象,还有一种是没有接口时会通过 Cglib 动态代理创建代理对象。所以之后我们从 Spring 容器中获取的对象为该对象的代理对象,通过代理对象执行目标方法时,会被拦截方法所拦截,在该方法中,会获取到目标方法的所对应的拦截器,根据拦截器的执行顺序通过责任链模式进行执行,最终完成 AOP 功能。
9、Spring如何管理事务的?
Spring事务管理主要包括3个接口,Spring事务主要由以下三个共同完成的:
1、PlatformTransactionManager:事务管理器,主要用于平台相关事务的管理。
主要包括三个方法:
① commit:事务提交。
② rollback:事务回滚。
③ getTransaction:获取事务状态。
2、TransacitonDefinition:事务定义信息,用来定义事务相关属性,给事务管理器PlatformTransactionManager使用
主要包含的方法:
① getIsolationLevel:获取隔离级别。
② getPropagationBehavior:获取传播行为。
③ getTimeout获取超时时间。
④ isReadOnly:是否只读(保存、更新、删除时属性变为false--可读写,查询时为true--只读)
3、TransationStatus:事务具体运行状态,事务管理过程中,每个时间点事务的状态信息。
主要包含的方法:
① hasSavepoint():返回这个事务内部是否包含一个保存点。
② isCompleted():返回该事务是否已完成,也就是说,是否已经提交或回滚。
③ isNewTransaction():判断当前事务是否是一个新事务。
10、Spring事务什么情况下会失效?
1、数据库引擎不支持事务:这里以 MySQL为例,其MyISAM引擎是不支持事务操作的,InnoDB才是支持事务的引擎,一般要支持事务都会使用 InnoDB。
2、bean没有被Spring 管理
3、方法不是public的:@Transactional只能用于public的方法上,否则事务会失效。
4、自身调用问题
类中没有加@Transactional 注解的方法调用了加了 @Transactional 注解的方法,从而导致加了 @Transactional 注解的方法的事务失效;
解决办法:使用AopContext.currentProxy() 获取代理对象,通过代理对象调用方法
5、数据源没有配置事务管理器
6、异常在方法内部通过try...catch处理掉了
7、异常类型错误:事务默认回滚的是:RuntimeException
11、请解释一下Spring Bean的生命周期?
首先会进行 Bean 的实例化,通过反射创建 Bean 对象,然后对 Bean 对象进行属性赋值,接着进行 Bean 的初始化,而在初始化过程中,会先判断当前 Bean 有没有实现相应的 Aware 接口,比如 BeanNameAware、BeanFactoryAware、ApplicationContextAware 等,如果有,则执行相应的方法;接着会调用 Bean 的后置处理器中的 postProcessBeforeInitialization(),然后调用初始化方法,进行初始化,初始化方法可以通过 init-method 或者 @PostConstruct 注解或者实现 InitializingBean 接口,重写afterPropertiesSet() 方法来指定;完了之后,又会执行 Bean 的后置处理器中的 postProcessAfterInitialization() 方法,然后又会判断当前对象是单例还是多例,如果是单例则放入单例缓存池中,如果是多例则返回,接着 Bean 对象被使用,当 Bean 不再需要或者 IOC 容器关闭时,会经过清理阶段,如果 Bean 实现了 DisposableBean 这个接口,那么就会调用其重写的 destroy() 方法,最后,如果这个 Bean 在 Spring 的配置中配置了 destroy-method 属性,指定了销毁的方法时,会自动调用该方法。
12、Spring中的AOP是在bean生命周期的哪一步实现的?
在初始化阶段中执行完初始化方法之后
13、什么是Spring 循环依赖
BeanA 对象的创建依赖于 BeanB ,而 BeanB 对象的创建也依赖于 BeanA,这样就造成了死循环,如果不做处理的话就会造成栈溢出。
14、Spring 的三级缓存
| 名称 | 对象名 | 含义 |
|---|---|---|
| 一级缓存 | singletonObjects | 存放已经经历了完成实例化、属性赋值、初始化的 Bean对象 |
| 二级缓存 | earlySingletonObjects | 存放早期暴露出来的 Bean 对象,Bean 的生命周期未结束(属性还未完成填充、未完成初始化) |
| 三级缓存 | singletonFactories | 存放可以生成 Bean 的工厂(ObjectFactory) |
15、Spring如何解决循环依赖的?
Spring 中提供了三个map集合用来缓存不同时期的 Bean,分别是一级缓存:singletonObjects,它存储的是完整的 Bean,也就是完成了 Bean 的实例化、属性赋值、初始化;二级缓存:earlyBeanSingletonObjects,它存储的是早期暴露出来的 Bean,虽然完成了 Bean 的实例化,但是还没有进行属性赋值和初始化;三级缓存:singletonFactories,它存储的是根据实例化后的 Bean 构造出来的 ObjectFactory,根据ObjectFactory 可以获取到早期的 Bean 。当 A 中依赖了 B,而 B 又依赖了 A 时,会产生循环依赖;当容器启动的时候会去实例化 A 和 B,首先会先从一级缓存中获取 BeanA,由于是第一次创建,所以会发现获取不到,那么就会将 BeanA 添加到正在创建的集合中,然后进行 BeanA 的实例化,在进行属性赋值之前,会根据实例化完成的 BeanA 构造一个对应的 ObjectFactory,将 ObjectFactory 添加到三级缓存 singletonFactories 中,然后再进行 BeanA 的属性赋值,此时会去一级缓存中获取 BeanB,由于是第一次创建,发现获取不到,那么就会将 BeanB 添加到正在创建的集合中,然后进行 BeanB 的实例化,在进行属性赋值之前,会根据实例化完成的 BeanB 构造一个对应的 ObjectFactory,将 ObjectFactory 添加到 singletonFactories 三级缓存中,然后再进行 BeanB 的属性赋值,此时会去一级缓存中获取 BeanA,而一级缓存中获取不到,由于 BeanA 是正在创建的 Bean,所以又会去二级缓存中获取,二级缓存也获取不到,然后就去三级缓存中获取,在三级缓存中通过 ObjectFactory 获取到了早期的 BeanA 对象,然后把早期的 BeanA 对象放入到二级缓存中,将三级缓存中的 ObjectFactory 移除,把获取到的早期的 BeanA 对象返回,BeanB 根据获取的 BeanA 进行属性赋值、初始化,完了之后把 BeanB 从正在创建的集合中移除,然后把 BeanB 添加到已经创建好的集合中,然后再把三级缓存中 BeanB 所对应的 ObjectFactory 移除,把完整的 BeanB 添加到 singletonObjects 一级缓存中,将 BeanB 返回,BeanA 获取到创建完成的 BeanB 后,完成属性赋值和初始化,再把 BeanA 从正在创建的集合中移除,将 BeanA 添加到已经创建的集合中,最后会把二级缓存中早期的 BeanA 移除,将完整的BeanA 添加到一级缓存中,然后把 BeanA 返回,这样就解决了循环依赖。
16、三级缓存的作用:
Spring 的三级缓存的作用就是为了防止在代理对象出现循环依赖时候,导致在给B的A属性进行依赖时注入的A不是A的代理对象,而是A本身,但是最终单例池中存放的是 A 的代理对象,两个对象不是同一个。所以在这种场景下,二级缓存就解决不了,那么这个时候 Spring 就利用第三级缓存 singletonFactories 解决了这个问题。singletonFactories 中存的是根据实例化后的 Bean 构建的 ObjectFactory ,而这个 ObjectFactory 能获取到 早期的 Bean,当调用 getObject() 方法的时候,就会调用 getEarlyBeanReference() ,而在 AOP场景下,会向容器中注入 AnnotationAwareAspectJAutoProxyCreator ,它的父类 AbstractAutoProxyCreator 又重写了 SmartInstantiationAwareBeanPostProcessor 的 getEarlyBeanReference(),所以就会在 getEarlyBeanReference() 方法中创建指定对象的代理对象。当通过三级缓存中的 ObjectFactory 获取对象时,获取的就是 bean 的代理对象,然后将获取到的代理对象放入到二级缓存中,之后会把三级缓存中对应的的 ObjectFactory 移除,所以当 BeanB 拿到 BeanA 的代理对象之后,将代理对象 BeanA 设置给 BeanB ,那么 BeanA 在对象初始化的时候,发现 BeanA 已经创建了代理对象了,此时就不会再次创建代理对象了,而是将代理对象 A 直接返回,这样一来就保证了 BeanB 的 A 属性和容器中一级缓存中缓存的 Bean 对象都是 AOP 代理之后的同一个代理对象了。
17、只有一级缓存和三级缓存是否可行?
如果没有 AOP 的话确实可以实现一级和三级缓存来解决循环依赖的问题,但是如果加上 AOP 的话,在AOP 场景下,Spring 容器中会注入一个 AnnotationAwareAspectJAutoProxyCreator 这个组件,而这个组件的父类 AbstractAutoProxyCreator 重写了 SmartInstantiationAwareBeanPostProcessor 的 getEarlyBeanReference() 方法,这个方法会返回指定对象的代理对象,而在三级缓存中调用 ObjectFactory 的 getObject() 方法的时候,会调用 getEarlyBeanReference() 方法,此时就会调用 AnnotationAwareAspectJAutoProxy 中重写的 getEarlyBeanReference() 方法,所以获取的对象其实是该对象的代理对象,而如果没有二级缓存的话,就会导致获取的这个代理对象没有地方放,从而导致每次获取时都会产生一个新的代理对象,所以为了保证始终只有一个代理对象,就需要二级缓存来保存这个代理对象。
18、构造方法出现了循环依赖怎么解决?
在构造参数前面加了@Lazy注解之后, 就不会真正的注入真实对象, 该注入对象会被延迟加载 , 此时注入的是一个代理对象 。
19、为什么构造器注入属性无法解决循环依赖问题
这是因为 Spring 中 Bean 的创建过程为先实例化,再进行属性赋值,然后再执行初始化;当使用构造器注入属性的时候,是在该对象进行实例化的时候执行的,但是实例化没有完成,就没有可以暴露的早期 Bean 对象供依赖的那个对象进行实例化,所以就会陷入死循环的状态。
20、一级缓存能不能解决循环依赖问题
不能,因为这三个级别的缓存中存储的对象时有区别的,一级缓存中存的是完整的对象,该对象完成了 Bean 的实例化、属性赋值和初始化;二级缓存存储的是早期的对象,也就是虽然进行了实例化,但是还没有完成属性赋值和初始化的;三级缓存中存储的是根据实例化后的 Bean 构造的 ObjectFactory,可以根据 ObjectFactory 可以获取到早期的 Bean 对象;如果只有一级缓存,那么有可能在多线程并发环境下,获取到实例化但还没有完成属性赋值和初始化的对象,此时就会出现问题。
21、二级缓存能不能解决循环依赖问题
答法同上面:三级缓存的作用一样
22、Spring事务的实现方式以及原理?
Spring 支持编程式事务管理和声明式事务管理;因为编程式事务管理需要通过 TransactionTemplate 来进行实现,而这种方式对业务代码有侵入性,因此在实际开发中很少用到;而声明式事务管理是通过 AOP 实现的,就是执行目标方法的时候,对目标方法进行拦截,将事务处理的功能织入到拦截的方法中,在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况进行提交或者回滚。而声明式事务最大的优点在于不需要在业务逻辑代码中编写事务管理的代码,只需要在配置文件中进行相应的事务配置或者通过@Transactional 注解就可以实现事务管理
23、Spring 用到了哪些设计模式
1、简单工厂模式:BeanFactory 就是简单工厂模式的体现,根据传入一个唯一标识来获得 bean 对象。
2、工厂方法模式:FactoryBean 就是典型的工厂方法模式。Spring 在使用 getBean() 调用获得该 Bean 时,会自动调用该 Bean 的 getObject() 方法,每个 Bean 都会对应一个 FactoryBean,如 SqlSessionFactory 对应 SqlSessionFactoryBean。
3、单例模式:一个类仅有一个实例,Spring 创建 Bean 实例默认是单例的,当然也可以设置它的 prototype 来该为非单例。
4、适配器模式:SpringMVC 中的适配器 HandlerAdapter;
5、代理模式:Spring 中的 AOP 使用了动态代理,有两种方式:JDK 动态代理、Cglib 动态代理
6、观察者模式:Spring 中的 ApplicationListener
7、模板方法:Spring 容器启动过程中执行 refresh() 方法
9、责任链模式:AOP 在执行目标方法时,执行拦截器链;还有 SpringMVC 中的拦截器
24、IOC 容器的初始化流程
1、根据 ResourceLoader 资源加载器将 Bean 的配置信息加载进来(可以是注解、xml、网络等);
2、通过 BeanDefinitionReader 将配置信息解析成对应的 BeanDefinition;
3、将 BeanDefinition 保存到 DefaultListableBeanFactory 的 beanDefinitionMap 中;
4、注册主配置类
5、refresh()
- 准备上下文环境
- 获取准备好的容器
- 给工厂里面设置好必要的工具(el表达式解析器、资源解析器、基本的后置处理器等)
- 后置处理 bean 工厂(子类进行实现)
- 执行工厂的后置增强
- 注册 bean 的后置处理器
- 初始化国际组件
- 初始化事件多播器
- 留给子类继续增强处理逻辑
- 注册监听器
- 完成工厂初始化
- 最后的一些清理、事件发送等
25、BeanFactory 和 FactoryBean 的区别
BeanFactory 是 Spring 的容器,它包含了 Bean 的定义、加载、实例化、依赖注入、生命周期管理;FactoryBean 是用来创建比较复杂的 Bean,可以通过实现 FactoryBean 重写 getObject() 方法,在 getObject() 方法中向 Spring 容器注入组件,而通过 getBean() 方法从 Spring 容器获取的对象不是 FactoryBean 对象,而是在 getObject() 方法中返回的对象,相当于是 getObejct() 方法代理了 getBean() 方法,如果想得到 FactoryBean 对象,必须使用 "&" + beanName 的方式获取。
7、SpringMVC
8、SpringBoot
9、SpringCloud
1、GateWay
1、作用:负载均衡、请求过滤、统一鉴权、全局熔断
2、原理:断言、路由、过滤器
3、执行流程:客户端向网关发出请求,那么网关会根据处理器映射器对请求进行匹配,如果匹配成功的话,网关的web处理器会将请求转发给对应的服务,在此期间呢,可以通过配置网关的过滤器对请求进行过滤
2、OpenFeign
Feign 是通过 Ribbon 进行的负载均衡
1、Feign 的执行原理:
- SpringCloud 会为每一个 FeignClient 生成一个代理对象
- 代理对象就可以根据类和方法上的注解,获取到要进行调用的服务名和路由
- Feign 会从注册中心获取指定服务名的所有真实地址
- 利用负载均衡策略选择一个最佳地址,利用 RestTemplate 进行调用
- 等待调用返回结果
2、OpenFeign 每次都是从注册中心中获取地址的吗?
OpenFeign不是每次都需要从注册中心获取地址,因为 OpenFeign 会维护一个服务与地址的关系清单 List,当我们使用 Nacos 注册中心时,Nacos 会将每次变化的数据实时推送给 SpringCloud,那么 OpenFeign 就会将 List 里面的数据进行更换,所以我们每次进行服务调用时,都能保证使用的服务清单是最新的。
3、如何解决 OpenFeign 远程调用时请求头丢失的问题?
其实出现这类问题的原因是因为在使用 OpenFeign 进行远程调用的时候,OpenFeign 是通过 JDK 动态代理创建的代理对象进行调用的,这个代理对象是在 ReflectiveFeign 类中声明的,里面是通过一个 map 保存了目标方法对应的方法处理器 MethodHandler,而这个方法处理器默认是同步方法处理器,也就是说使用 OpenFeign 进行远程调用时它是一个同步的过程。它会根据目标方法的参数构建一个请求模板,这个请求模板里面就保存了定义的请求路径、请求方式,也就是说,在 Feign 进行远程过程调用的时候,它会根据原来的请求重新构建一个新的请求,并且也可以在 Feign 的远程调用方法的参数位置,通过 Request.Opetions 来给新创建的请求设置一些参数,比如超时时间、重试次数等,而这里进行的配置,它的优先级要高于全局配置的优先级,也就是说,如果某个请求需要定制化处理的时候,就可以通过这个机制来实现局部远程过程调用的参数配置。而这个请求模板中没没有原来请求的请求头等信息,所以就造成了在服务方接收的请求中并没有原先请求的请求头等信息。
由于在通过请求模板生成新的请求之前,会被请求拦截器所拦截,执行请求拦截器中的 apply() 方法,这个方法可以获取到请求模板。所以就可以给容器中添加一个请求拦截器,获取到请求模板,然后给请求模板的请求头中添加上原来请求的请求头信息。但是这就必须要考虑一个问题,就是我要给请求模板中添加的请求头信息必须是我之前对应的请求的请求头信息,又考虑到 Tomcat 会根据每次收到的请求都独立分配一个线程去进行处理,所以我们就可以将请求放在当前线程间共享的地方,第一种方案就是使用 ConcurrentHashMap,但是需要在每次使用完成之后手动将请求移除,否则可能会导致内存溢出;第二种方案就是使用专门用于这个场景下的 ThreadLocal,但是也要在使用完成后手动将请求移除,否则可能会导致内存泄漏;第三中方案就是使用 SpringMVC 专门提供的 RequestContextHolder,通过它就可以获取到当前线程的请求。
3、Nacos
1、作用:注册中心、配置中心
2、项目中如何使用:根据每一个微服务配置一个自己的命名空间,在命名空间中,又根据开发环境进行不同的分组,每个分组里面又会根据不同场景的配置提供不同的配置文件
3、服务与服务之间的调用是否会经过网关:默认情况下是不会经过网关的。Feign 会从注册中心中根据调用的服务名查询出所在的服务地址,根据负载均衡算法进行远程调用,不过也可以将服务名设置为网关的服务名,强行让他经过网关,不过没那必要
4、Sentinel
1、作用:流量控制、熔断降级
2、运行指标:QPS、线程数
3、控制效果:直接限流(快速失败)、热启动、匀速排队(排队等待)
5、Sleuth
1、作用:
- 根据链路追踪可以清除地看出一个请求都经过了哪些服务、服务之间的调用关系
- 结合 zipkin 进行可视化观察
- 分析服务调用的耗时情况
- 进行链路优化
电商项目
项目架构:
运店M通用
积分订结促商用
分文日、短消位
评咨订、促商会
统设分运结登店
权秒商购、支检资楼
解释:
运营后台API、店铺API、MQ 消费者、通用API、用户API
积分中心、分销中心、订单中心、结算中心、促销中心、商家中心、用户中心
分享服务、文件服务、日志服务、短信服务、消息服务、位置服务
评论模块、咨询模块、订单模块、促销模块、商品模块、会员模块
统计模块、设置模块、分销模块、运营模块、结算模块、登录模块、店铺模块
权限模块、秒杀模块、商品详情模块、购物车模块、支付模块、检索模块、资源模块、楼层维修模块
1、后台管理
1、基本概念
- SPU:是商品信息聚合的最小单位,比如 IPhone14、IPhone14 pro、IPhone 14 Pro Max
- SKU:作为库存进出和真正售卖时的具体商品,比如 IPhone 14 + 紫色 + 128G
- 销售属性:决定商品销售价格的属性,比如:内存 、颜色、处理器等
- 平台属性:商品的一些其他额外的属性,会在规格与包装中展示
2、业务话术
我们项目是一个 B2C 模式的在线商城,而商品管理是我们电商后台系统的一个核心功能模块,主要的功能有:
-
对商品分类、商品品牌、平台属性以及销售属性进行管理
-
具有相应权限的管理人员对商品的添加、上下架、修改、删除、批量操作、模糊查询、商品审核等功能
-
对于 SPU 的话,需要保存 SPU 所对应的图片,而我们采用的是 Minio 进行的私有存储,通过单独部署 Minion 服务器,一定程度上可以缓解我们自身服务器的并发压力
-
在对商品进行添加时,默认添加后的商品的上下架状态为下架状态,我们会有单独的上下架功能
-
上架:
- 将 SKU 信息添加 Redis 进行缓存
- 将 SKU 信息保存到 ES 用于检索
- 修改上下架状态:0 未上架、1 已上架、2 已下架
-
下架:相反
-
-
商品审核是通过人工审核的方式对商品进行审核
2、商品详情
1、这个模块的设计
考虑到商品详情这个页面的格式相对固定,所以前端采用了Thymeleaf 来做页面静态化 ,并且这部分的查询频率非常高,所以会考虑将这部分的数据存储到 Redis 中,从而提高请求的响应速度,提高用户的体验。
但是使用缓存的话,就需要考虑分布式情况下缓存可能出现的问题,像缓存穿透、缓存击穿、缓存雪崩以及数据一致性,缓存穿透其实就是大量并发请求去访问一个数据库中不存在的数据,如果我们没有将这个不存在的数据保存在缓存的话,那么这种情况下缓存就失去了作用,导致大量并发请求都去访问数据库,从而导致数据库的压力增大,对于一些正常的请求无法处理,甚至导致数据库崩溃。可以通过空值缓存解决缓存穿透,又考虑到后面这个数据可能会被添加到数据库,并且在使用缓存时,我们通常都会给要缓存的数据加上过期时间,所以可以给空值缓存加上过期时间。同时又考虑到了如果有人恶意通过同时访问大量数据库中不存在的数据时,对数据库进行恶意攻击,所以我们会考虑在回源之前先通过布隆过滤器判断一下,将恶意访问请求过滤掉;缓存击穿就是大量并发请求都去访问一个设置了过期时间的热点数据,但是在访问的时候,这个热点数据刚好在缓存中过期了,就会导致后续大量请求都去访问数据库;可以通过加分布式锁来解决;缓存雪崩就是大量并发请求在访问多个数据的时候,这些数据同时过期,导致都去访问数据库,可以通过在给缓存中缓存的时候加上过期时间;而在分布式情况下,如果数据库中的数据被修改了,而缓存中的数据没有得到及时更新,那么在一些场景下可能会产生一些问题,这就是数据不一致的情况,第一种方式是采用双写模式,先写数据库,再写缓存,但是这种模式会有一个问题,如果有两个请求,一个读请求先读到了原来的数据,而另一个写请求又要修改这个数据,结果由于读请求在往缓存中写的过程中出现了网络卡顿,导致写请求在写完数据后,很快就把修改后的数据写入了缓存,而过了一段时间,读请求才将原来的数据写到了缓存中,导致缓存中的数据是错数据。第二种方式是采用失效模式,先写数据库,再删缓存,后面请求要获取数据的时候,发现缓存中没有,那么会去查数据库,然后把数据同步到缓存中;但是这种方式也有问题,和第一种方式遇到的情况差不多,也是有可能会遇到读请求延迟,而导致缓存错误数据。第三种方式就是使用延时双删+设置过期时间,在失效模式的基础上,设置过期时间,并根据业务情况在一段时间以后再执行一次删除。
所以在查询这个商品详情的流程就会是:用户点击一个商品后,前端会根据商品的 skuId 获取商品详情,请求先到达网关,然后网关通过断言后将请求路由给 web 服务,然后 web 服务去远程调用商品详情服务,在商品详情服务中,会通过 skuId 先从 Redis 中获取,如果Redis中有,直接返回,如果Redis中没有,再去布隆过滤器中进行判断,如果布隆过滤器中没有,说明这个商品不存在,直接返回异常,如果布隆过滤器说有,那么再尝试加分布式锁,如果锁获取成功,那么再根据 skuId 进行回源,如果获取锁失败,那么就等一段时间然后去Redis中查,将查询的结果返回。如果数据库中也没有,那么就以key为 skuId,value 为 null 添加到 redis 中并设置过期时间,来进行空值缓存,然后把数据返回,返回之后把分布式锁释放。
而在进行回源查数据的时候,由于商品详情页中需要的数据很多,比如商品的基本信息、spu的描述信息、spu的规格参数、营销系统中的优惠信息、库存系统中的库存信息等,如果使用传统的同步方式想要获取这些数据的话,就需要在商品详情模块接口中依次远程调用其他服务来获取数据,但是这样做的话,就会导致必须等到上一个查询执行完成,我下一个查询才能继续,那么这样做的结果就会导致请求响应时间很长,影响用户的体验。所以就考虑使用多线程来同时调用远程服务,以提高查询速度,不过这样的话就还有一个问题,就是我在查询其他数据的时候,可能会需要使用到某个查询的结果作为条件去进行查询,如果使用传统的多线程通信的话,不太方便,所以最终就采用的是使用 CompletableFuture 进行异步编排,按照实际的执行顺序最大化提高查询的速度,等到所有的查询都执行完成后,将数据返回。
但随着业务中缓存和分布式锁的加入,业务代码就变得复杂起来,因为除了要考虑业务逻辑本身,还需要考虑缓存和分布式锁的问题,这样就增加了程序的开发量和开发难度,而查缓存、查布隆过滤器、加分布式锁、回源、添加缓存、释放分布式锁、返回数据这一套流程都非常的固定,类似于事务,而声明式事务就是用了 AOP 的思想来实现的,所以后面对代码进行优化的时候,就通过 AOP 来完成这一套逻辑,实现非侵入式的业务逻辑增强,同时也利用了 SpringBoot 的自动装配原理,自定义一个专门用来进行缓存+分布式锁的场景启动器,如果哪些模块需要使用到这块的增强的话,只需要引入这个场景启动器即可。
而这个AOP增强,我是通过定义一个注解,然后对使用了这个注解的方法进行环绕通知来完成的,在环绕通知上,通过@Around注解的@annotation属性,指定我要进行环绕增强的方法,然后在方法的参数位置添加 joinPoint 参数,在环绕通知内部,根据 joinPoint 获取到目标方法的自定义注解,然后获取到设置的注解值,通过注解值去执行之前那一套流程。
商品详情页
用户点击某个商品
请求先到达网关
网关将请求路由到 web 服务
web 服务远程调用商品详情服务
商品详情服务先根据 skuId 去缓存中查
判断是否存在
存在:返回
不存在
判断布隆过滤器中是否存在
没有:抛异常
有
判断是否获取到分布式锁
没有:等一段时间去查 Redis,然后返回
获取到了
回源
判断数据库中是否为 null
- 是:放入缓存+设置过期时间,抛异常
- 否:放入缓存+设置过期时间,返回,释放锁
2、线程池问题
1、为什么要使用线程池
1、线程也是系统资源的一种,频繁地创建和销毁线程会消耗系统资源,可以通过线程池对线程进行复用
2、创建线程的这个操作比较耗时,使用线程池可以提高响应速度,当任务到达时,可以不需要等待线程创建而直接执行
3、为了提高系统的稳定性,对线程进行统一的管理
2、如何创建线程池
1、newFixedThreadPool
创建拥有固定的线程数的线程池,使用的阻塞队列:LinkedBlockingQueue(默认长度为Integer.MAX_VALUE)。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
2、newSingleThreadExecutor
创建只有一个线程的线程池,使用的阻塞队列:LinkedBlockingQueue(默认长度为Integer.MAX_VALUE)
3、newCachedThreadPool
创建可扩容的线程池,核心线程数为0,最大线程数为Integer.MAX_VALUE;使用的阻塞队列:SynchronousQueue(单个元素的队列)
在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统瘫痪。
4、newScheduleThreadPool
创建一个定长的线程池,而且支持定时的以及周期性的任务执行。例如延迟3秒执行。
这4种线程池底层 全部是ThreadPoolExecutor对象的实现,阿里规范手册中规定线程池采用ThreadPoolExecutor自定义的,实际开发也是。
3、创建线程池的参数分别是什么
ThreadPoolExecutor pool = new ThreadPoolExecutor(3, // 1、常驻核心线程数 5, // 2、最大线程数 10, // 3、保持活跃时间 TimeUnit.MINUTES, // 4、时间单位 new LinkedBlockingQueue<>(10), // 5、阻塞队列 Executors.defaultThreadFactory(), // 6、线程工厂 new ThreadPoolExecutor.AbortPolicy()); // 7、拒绝策略4、如何设置参数
- 核心线程数:CPU + n
- 最大线程数:大于核心线程数
- 阻塞队列:ArrayBlockingQueue(会产生空间碎片)、LinkedBlockingQueue
- 拒绝策略:AbortPolicy(拒绝并抛异常)
5、创建线程池可以使用哪些拒绝策略
1、AbortPolicy(默认的拒绝策略):
当请求任务数大于最大线程数+阻塞队列的时候,会:java.util.concurrent.RejectedExecutionException
2、CallerRunsPolicy:
当请求任务数大于最大线程数+阻塞队列的时候,超出的任务会返回给调用者让调用者执行
3、DiscardOldestPolicy:
当请求任务数大于最大线程数+阻塞队列的时候,会丢弃阻塞队列中等待最久的任务
4、DiscardPolicy:
当请求任务数大于最大线程数+阻塞队列的时候,会丢弃新来的任务
7、线程池的执行原理
1、在创建了线程池后,开始等待请求。
2、当调用 execute() 方法添加一个请求任务时,线程池会做出如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这个时候队列满了且正在运行的线程数量还小于 maxnumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了且正在运行的线程数量大于或等于 maxnumPoolSize,那么线程池会启动饱和拒绝策略来执行。
3、当一个线程完成任务时,它会从队列中取下一个任务来执行。
4、当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:
- 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
- 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
9、线程都有哪几种状态
1、NEW:新建
2、RUNNABLE:可运行的
3、BLOCKED:阻塞
4、WAITING:等待
5、TIMED_WAITING:超时等待
6、TERMINATED:终结
10、多线程买票
11、多线程间通信
12、多线程间有序通信
13、线程安全问题如何产生?如何解决?
在多线程情况下,多个线程去操作共享资源
解决方式:锁
14、死锁如何产生?如何避免死锁?
根本原因:两个及以上的线程互相等待对方锁不放弃,产生锁的嵌套
避免死锁:注意线程和方法的执行顺序,避免锁的嵌套
15、举例说明线程不安全的案例
1、集合框架
并发修改异常:List、Map
- fail-fast fail-safe 安全机制 并发修改异常的抛出就是为了该机制
- Map 其他问题:数据丢失/数据覆盖问题
ThreadLocal
3、自定义环绕通知
1、谈谈你对 AOP 的理解:AOP 就是面向切面编程,它是指在不修改原来代码的情况下,程序运行期间动态的将某段代码切入到指定方法的指定位置并进行运行的一种编程方式,从而对原来逻辑代码进行增强的同时又对原来的代码没有侵入性。
2、项目中 AOP 的应用:
定义注解
定义切面
定义环绕通知方法
- 返回值是 Object
- 参数为 JoinPoint 获取目标对象
- 手动抛出 Throwable 异常
3、购物车
1、这个模块的设计
当时产品那边提的需求是实现普通购物车的查询、添加、修改商品数量、删除商品,而且需要展示商品的优惠信息以及购物车降价的变化、购物项的选中状态,并且让用户在未登录的情况下,也可以使用临时购物车,如果要结算时,跳转到登录页上让用户登录,登录完成后,将临时购物车中的所有商品要合并到用户自己的购物车中,然后把原来的临时购物车清空,当用户在登录状态下点击去结算时,需要根据购物车中的数据跳转到订单确认页,后来又添加了一个需求,就是购物车中的商品品类数量不得超过一百件,每个商品的数量不得超过两百件。
所以我们当时是通过 Redis 中的 Hash 结构来实现的,key 就是用户 id,value 就是一个 hash,而 hash 的 key 就是商品的 skuId,value 就是购物车项。由于要实现用户在未登录的情况下也能使用购物车的这个需求,所以前端会给未登录的用户分配一个临时用户 id。当用户在商品详情页点击加入购物车时,会向后端发送请求,请求中就包含了商品的 skuId、商品数量,并且前端会将生成的临时 id 放在 Cookie 或者请求头中,如果用户登录着的话,也会在 Cookie 中将用户的 token 带上,然后在请求到达网关时,网关会进行 id 透传,将 Cookie 中的临时 id、以及根据 token 从 Redis 中获取的用户 id添加到请求头中,然后网关会将请求转发给 web 服务,web服务去调用购物车服务,购物车服务会先从 RequestContextHolder 中拿到用户 id 以及临时 id ,然后根据用户 id 以及临时 id 来决定 Redis 中购物车对应的缓存键,如果用户 id 存在的话,说明用户是登录状态,那么就使用用户 id 作为缓存键,否则就使用临时 id 作为缓存键,确定缓存键后再进行往购物中添加该商品的操作,首先会根据 skuId 判断该商品在购物车中是否已经存在,如果存在,执行修改操作;如果不存在,执行新增操作。而新增的话,需要先获取该购物车中原来商品品类的数量,然后判断新增后是否会超过我们规定的购物车商品品类数量的最大值100,如果超限了,则抛出异常,如果没有超限,再根据 skuId 远程调用商品服务获取商品信息,再判断添加后的商品数量是否超限200,如果超限了,则抛出异常,如果没有超限的话,再远程调用优惠服务获取商品的优惠信息,然后将查到的商品信息和优惠信息转化为购物车中的购物项,然后保存到 Redis 当中。如果是修改,判断添加后的商品数量是否超出 200,如果超出则抛异常,如果没有超出,再根据商品的 skuId,远程调用商品服务,获取商品的实时价格,再远程调用优惠服务获取商品的优惠信息,然后从购物车中获取原来的购物项,修改后同步到 Redis 当中,最后对于未登录用户,设置临时购物车的过期时间,我们定的是半年,然后返回商品信息。
然后对于合并购物车的这个需求,一开始是想着当用户登录时进行合并购物车,但是又发现这样做的话其实是没有必要的,考虑到用户实际使用的习惯,大部分人登录完成后都不会去查购物车信息,并且如果将合并购物车这个操作放在了用户登录时进行,那么如果购物车中的数量比较多的话,就会影响用户的登录速度,影响用户体验,所以,最终考虑将合并购物车这个操作放在用户在登录状态时手动点击购物车时进行购物车合并。当用户点击购物车列表时,会跳转到购物页面,然后发送异步请求到网关,网关进行 id 透传,然后将请求路由转发到购物车服务,购物车服务会根据透传过来的id,来决定使用哪个缓存键,如果用户 id 存在,说明用户是登录状态,那么就使用 用户 id 作为缓存键,进行购物车合并,如果用户 id 不存在,说明用户还未登录,那么就不进行购物车合并,直接根据临时 id 获取购物车中的购物项列表,然后将购物项列表返回。而合并购物车之前,还需要判断临时 id 是否存在,如果不存在的话,就不需要进行合并了,如果存在的话,先根据临时 id 获取临时购物车,如果临时购物车不存在获取临时购物车没有购物项,也就不进行合并,否则根据用户 id,获取用户的购物车,然后遍历临时购物车,获取临时购物车的购物项的 skuId、商品数量,调用添加购物车的方法进行合并,每合并一次,就删除一个对应的临时购物车的购物项。合并完成之后,再通过用户 id,去 Redis 中查用户购物车的购物项列表返回。
点击去结算,进入订单结算页:用户点击去结算后,请求经过网关,将请求转发给 web 服务,web 服务去调用订单服务,订单服务会根据透传过来的 userId,去 Redis 中获取该用户所对应的购物车,然后遍历购物车中的每个购物项去商品服务获取每个的实时价格以及库存系统中验该商品的库存是否足够,最后购物车列表转换为要给前端转换的VO,并计算并设置商品的总金额、去用户模块获取用户的收获地址并设置到VO,然后生成一个订单流水号设置到 VO,最后将 VO 返回。
1、添加商品到购物车
用户点击加入购物车
向后端发送请求(skuId、skuNum),请求先到网关
网关将请求路由到 web 服务
web 服务远程调用购物车服务
先从 RequestContextHolder 中获取 userId、userTempId
判断用户是否登录
(登录用 userId,未登录用 userTempId)确定用哪个缓存键
给购物车中添加商品
根据 skuId 判断是否存在
存在(修改)
判断添加后的数量是否超限
- 是:抛异常
- 否:根据 skuId,调用商品服务,获取实时价格、库存、优惠、更新同步到 Redis
不存在(添加)
判断新增后商品的品类数量是否超限
是:抛异常
否:判断添加后的商品数量是否超限
- 是:抛异常
- 否:根据 skuId 远程调用商品服务获取商品信息,远程调用营销服务获取优惠信息,远程调用库存系统,获取库存量, 保存到 Redis
2、合并购物车
点击购物车列表
跳转到购物车页面
异步请求先到网关
网关进行 id 透传,然后将请求路由给购物车服务
决定使用哪个缓存键
进行合并
判断是否登录
否:不合并,根据临时id获取购物车列表,返回
是:判断临时 id 是否存在
不存在:不需要合并
存在:
根据临时 id 获取临时购物车
判断临时购物车是否存在,有没有购物项
没有:不合并
有:
- 根据用户id获取用户的购物车
- 遍历临时购物车,调用添加进行合并
- 每合并一次,就删除一个临时购物车的购物项
- 合并完后,通过用户 id,从 Redis 中查用户的购物车列表返回
2、遇到的问题
1、使用 OpenFeign 进行远程调用时,发现被调用方获取不到原来请求中的请求头信息。
web 服务在远程调用购物车服务时,在购物车服务中从请求头中获取不到透传的用户 id 和临时 id,最后在进行 debug 的过程中发现是使用 OpenFeign 在进行远程调用时,会使用 JDK 动态代理为我们自定义的 FeignClient 接口创建代理对象,而我们正是通过这个代理对象来进行远程调用的,这个代理对象是在 ReflectiveFeign 类中声明的,里面通过一个 map 保存了目标方法对应的方法处理器 MethodHandler,,而这个方法处理器默认是同步方法处理器,也就是说,Feign 在进行远程过程调用时,是一个同步阻塞的过程,它会根据目标方法的参数构建一个请求模板,这个请求模板里面就保存了定义的请求路径、请求方式,也就是说,在 Feign 进行远程过程调用的时候,它会根据原来的请求重新构建一个新的请求,并且也可以在 Feign 的远程调用方法的参数位置,通过 Request.Opetions 来给新创建的请求设置一些参数,比如超时时间、重试次数等,而这里进行的配置,它的优先级是要高于全局配置的优先级,也就是说,如果某个请求需要定制化处理的时候,就可以通过这个机制,来实现局部远程调用的参数配置,但是这里发现新的请求模板中并没有原来请求中的请求头等信息。而在 invoke() 方法中,可以看到 Feign 就是在这通过循环的方式,不断进行远程过程调用的,这个调用的操作是放在 try-catch 代码块中执行的,它会捕获重试异常,当我们自定义的重试器在指定时间抛出异常时,catch 代码块就会捕获到,从而通过 throw 异常对象的方式来终止循环调用。那么它在执行远程调用之前,会被 RequestInterceptor 拦截器链所拦截,通过拦截器的 apply() 方法可以给请求模板中添加信息,所以第二种方式就是自己给容器中放一个 RequestInterceptor 拦截器,在拦截器中获取到原来请求头中的用户 id,然后设置给请求模板,那么之后 OpenFeign 通过请求模板创建的新的请求就可以在请求头中带上我们的用户 id。
2、在配置类中通过@Bean 给容器中添加 RequestInterceptor 拦截器时,需要原来的请求
所以在接口处可以将原来的请求添加到一个 map 中,然后在配置类中从 map 中获取这个原来的请求,将原来的请求中的请求头添加给要创建新请求的请求模板。由于当一个请求访问 web 服务器时,web 服务器会给每一个请求分配一个线程去进行处理,所以考虑到需要在多线程情况下保证线程安全,可以使用 ThreadLocal,当然也可以使用 ConcurrentHashMap 来保存原来的请求,不过都需要在使用完成之后,手动将原来的请求移除,否则可能会导致内存泄漏,从而造成OOM,但是后来听同事说不用这么麻烦,因为 SpringMVC 已经提供类这类场景的工具类,通过 RequestContextHolder 就可以获取到当前线程的请求,所以最后是将代码优化为在配置类中通过 @Bean 注解往容器中添加一个 RequestInterceptor 请求拦截器,在这个拦截器的 apply() 方法中,通过 RequestContextHolder 获取到当前线程的请求,然后将原来请求的请求头中的用户 id 和临时 id 添加到新请求的请求模板中。
3、常见问题
1、你们购物车数据存储采用的是什么?
通过 Redis 的 hash 结构来存储的;key 是用户id、value 就是一个 hash,hash 的 key 是商品的 skuId,value 是购物车的购物项。
2、那么为什么你的购物车存储信息,不采用 MySQL 数据库作为存储,而是采用 Redis 作为存储?
如果使用 MySQL 作为存储的话,它的操作效率比不上 Redis,并且考虑到购物车是允许用户在未登录状态下使用的,因此将会面临大量的读操作,而通过我们日常对购物车的使用,知道购物车中的商品数量、选中状态是经常发生变化的,所以它的读操作也很频繁,如果单独使用 MySQL 的话,效率跟不上;我们当时也考虑过同时使用 MySQL 和 Redis 缓存,但是考虑到将 Redis 作为缓存的话,又需要考虑一些列的分布式缓存问题,尤其是缓存与数据库间的一致性问题,如果数据不一致,那么业务中从缓存中读取的数据不是最新数据的话,可能会出现一些严重的问题,所以最后我们是使用 Redis 实现的购物车数据存储。
3、你们用的是 Redis 的哪种缓存模式?
只读缓存
4、既然使用的是 Redis ,那么你们是如何保证数据的持久化呢?
我们使用的是 AOF+RDB,它是以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间内的所有命令操作。这样一来,快照就不用频繁执行,而且 AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有的操作,因此也就不会出现文件过大的情况,这样也能避免重写开销。使用 AOF+RDB 既能有 RDB 文件快速恢复的优点,同时也具备 AOF 只记录操作命令的简单优势。
5、为什么使用 Hash 结构来做存储?
因为考虑到我们的购物车的数据它其实包含两个部分,一个是用户id,还有一个是购物车,如果使用 String 或者 List 的话,那么就需要先遍历集合,然后对集合中的每一个属性进行修改,最后重新返回这个修改后的集合,那样就有些不合适,而这种场景下,显然使用 map 更加合适,准确来说是一个双层 map,外层 map 的key,对应着用户 id,value,对应着购物车,而value 也就是购物车,它作为内层的 map,key 就是购物项的 skuId,value 就是对应的购物项,使用这种结构,在修改时会非常方便。
5、购物车未登录有过期时间吗?已登录有吗?
未登录:半年
已登录:永久
6、添加购物车会减库存吗?
不会,减库存是在支付成功后,进行库存锁定。
7、进入购物车列表的时候,会验库存、验价格吗?
都会
8、购物车里的商品价格变换了有提示吗?库存无货了有提示吗?
有
4、订单
订单确认页的数据:
收获地址
支付方式
购物车中选中商品信息
- 最新价格
- 是否有货
数量
总价格
流水号;
1、提交订单
用户点击提交订单
请求到达网关
网关将请求路由给订单服务
- 验证令牌
- 验证库存
- 验证价格
- 保存订单到数据库,生成一个订单号
- 发送订单创建成功的消息给消息队列的延迟队列,延迟队列会保存消息45分钟,时间一到,就会把消息放入死信队列,消费者监听死信队列里面的消息进行幂等性关单操作,如果关单失败,会将消息重新放入到死信队列进行重试,并通过 Redis 实现一个计数器,每次重试都加一,加到10之后,将消息保存到数据库,进行人工处理,然后把 Redis 中的计数器移除。
清空购物车中选中的商品
2、订单支付流程
用户点击支付宝进行支付
请求到达网关
网关将请求路由给支付服务
支付服务用支付宝客户端给支付宝发送支付请求
支付宝将二维码收银台页面返回支付服务
支付服务将收银台页面返回给前端
用户扫描或登录进行支付
支付宝根据回调接口进行回调
同步接口:重定向到支付成功页面
异步接口:发送支付成功的消息到消息队列
订单支付成功监听器监听消费消息进行幂等性保存支付信息,修改订单状态为已支付
给消息队列发送扣减库存消息
库存系统扣库存,给消息队列发送修改订单状态消息
- 订单服务去监听消费这个消息,修改订单状态为待发货
\