文章目录
- Java集合篇
- Java线程篇
- 创建线程有几种方式?
- Runnable和Callable有啥区别?
- 线程包括那些状态?状态之间如何变化的?
- 如何保证t1,t2,t3顺序输出?
- notify()和notifyall()方法有啥区别?
- wait()和slepp()方法有啥区别?
- sychronized的底层原理
- Monitor实现的锁是重量级锁,你了解锁升级的过程吗?
- JMM内存模型
- 能说说什么是CAS吗?
- 刚刚提到了乐观锁和悲观锁,能说说吗?
- 请谈谈volatile的理解
- 什么是AQS?
- sychronized和Lock有什么区别?
- 死锁的条件及如何诊断?
- 导致线程安全的问题是啥,如何解决?
- 能说说线程池的核心参数及执行原理吗?
- 为什么不建议用Executors创建线程?
- 说说你对ThreadLocal的理解
- Java JVM篇
- Spring篇
- SpringBoot篇
- Mybatis篇
- SpringCloud篇
- Redis篇
- MySQL篇
Java集合篇
数组知识
数组是一种连续的内存空间存储相同数据类型的线性数据结构。
数组利用下标获取元素——寻找地址的公式:a[i] = baseAddress + i * dataTypeSize
baseAddress: 数组的首地址
dataTypeSize: 代表数组中元素类型的大小,int类型的数据dataTypeSize=4个字节
ArrayList底层实现原理
ArrayList底层是基于动态数据实现的,它的初始容量为0,当第一次添加数据的时候才会初始化容量为10。
数组扩容
ArrayList在进行扩容的时候是元数组容量的1.5倍,每次扩容的时候需要创建一个新的数据(新数组的容量就是老数组的1.5倍),然后将老数组的数据拷贝到新数组中,然后将对象的引用地址指向新数组。
数组添加元素
ArrayList在添加数据的时候首先回去计算数组的容量,如果当前数组已使用的长度 + 1后大于当前数组的长度,那么就调用grow方法进行数组扩容(原数组的1.5倍),确保新增的数据有地方存储后则将新元素添加到位于size的位置上。
ArrayList和LinkedList有什么区别
从底层实现来说:ArrayList基于动态数组实现,LinkedList基于链表(双向)实现
从查询的效率上来说:因为ArrayList基于动态数组实现的所以能使用查找地址的公式查询到数组下标的地址位置,从而快速的找到元素,而LinkedList基于链表实现的不能用公式查找,从而查询速度ArrayList比LinkedList快
从新增删除的效率来说:ArrayList在新增元素需要考虑扩容的问题,如果需要扩容则需要创建一个新的数组且需要把老的数据拷贝到新的数组中,如果插入到指定位置的话还涉及数组元素的移动。而LinkedList则不需要,只需要找一个地址把元素存储,然后把指针连接上即可。ArrayList删除元素需要移动数组下标,LinkedList只需要断掉两边的指向就行。总的来说ArrayList增删的效率没有LinkedList高。
HashMap底层实现
HashMap在1.8之前基于数组+链表实现的,当我们添加一个元素的时候会拿着key利用hashCode计算出数组的下标然后存储到链表中,如果hash出同样的值会出现两种情况,第一是key相同就把原来的值覆盖了,如果key不一样,则把元素插到链表中(1.7前使用的是头插法,1.7后是尾插法)。1.7以后的话是数组+链表或者数组+红黑树实现的,链表的长度大于8的时候且数组的长度大于64的时候就会转为红黑树。当红黑树的节点小于等于6的时候就会退变成链表。
HashMap的put方法的具体流程
首先判断数组table是否为空或者null,如果是,则执行resize()方法进行初始化,然后根据key计算出hash值得到数组的索引,判断该 索引下标 == null,是的话就直接添加该元素,如果不为null的话:首先判断key是否存在,存在就直接覆盖value,如果不是需要判断是否是红黑树,如果是则在树中插入该元素,如果不是则在链表的尾部插入该元素,然后会判断链表的长度是否大于8且数组的长度大于64,是的话就转为红黑树然后在红黑树中执行插入操作,遍历的过程中如果发现key已存在则会覆盖之前的数据。插入成功后会判断实际存在的键值对数量size是否大于最大容量(数组的长度*0.75),如果超过则会进行扩容处理。原理和数组的扩容原理是一样的。
HashMap的扩容机制
在JDK1.7中,hashmap的扩容机制有以下特点:
hashmap的初始容量默认是16,负载因子默认是0.75,阈值默认是12(16*0.75)。
hashmap的容量必须是2的幂次方,这样可以保证哈希值和数组长度取模时只需要进行位运算,提高效率。
当hashmap中的元素个数超过阈值时,就会触发扩容,新的容量是原来的2倍,新的阈值也是原来的2倍。
在扩容过程中,hashmap会遍历原来的数组中的每个链表,并将链表中的每个节点重新计算哈希值,找到新数组中对应的位置,以头插法插入到新链表中。这样做可能会导致链表反转和多线程环境下的死循环问题。
在JDK1.8中,hashmap的扩容机制有以下改进:
hashmap在第一次调用put方法时才会初始化数组,而不是在创建对象时就初始化。
hashmap在初始化或扩容时,会根据指定或默认的容量找到不小于该容量的2的幂次方,并将其赋值给阈值。然后在第一次调用put方法时,会将阈值赋值给数组长度,并让新的阈值等于数组长度乘以负载因子。
在扩容过程中,hashmap不需要重新计算节点的哈希值,而是根据哈希值最高位判断节点在新数组中的位置,要么在原位置,要么在原长度加上原位置处。
在扩容过程中,hashmap会正序遍历原来的数组,并保持链表中节点的相对顺序不变。
如果某个链表中的节点数超过8个,并且数组长度大于等于64,则会将链表转化为红黑树,提高查找效率。
Java线程篇
创建线程有几种方式?
- 继承Thread类,重写run方法,调用start方法开启线程。
- 实现runbable接口,重写run方法,调用start方法开启线程。
- 实现Callable接口重写call方法,利用FutureTask类确认线程返回值,然后传入到Thread里面调用start方法开启线程。
- 利用线程创建线程。
Runnable和Callable有啥区别?
- Runnable接口的run方法没有返回值
- Callable接口的call方法有返回值,需要用FutrueTask获取结果
- Callable接口的call方法允许抛出异常,而Runnable接口的run方法异常只能在内部消化,无法向上抛出。
线程包括那些状态?状态之间如何变化的?
Java中线程有6个状态,可以参考Thread类中的枚举类State类,分别有:
- NEW——新建状态(如new Thread() 对象)
- RUNNABLE——可执行状态【就绪(没有获得cpu执行权)、运行(有cpu的执行权)】
- BLOCAKED——阻塞状态(一般出现在加了锁的代码,然后没有竞争到锁)
- WAITING——等待状态(调用了wait方法)
- TIMED_WAITING——计时等待 (调用了带参数的sleep方法)
- TERMINATE——死亡状态(线程执行完、出现异常未捕捉处理)
线程之间的转变
- 当我们new了一个线程对象时,此时这个线程就是新建状态(NEW),当调用了start方法后这个线程就会变成可执行状态(RUNNABLE),这时候得看这个线程有没有获取到cpu的执行权,如果没有获取到此时这个线程处就绪状态,当获取到cpu的执行权时,就是运行状态,期间线程支持执行完后,线程的状态会变为死亡状态(TERMINATE)。
- 如果线程有cpu的执行权但是没有竞争到锁(synchronized或lock)就会进入到阻塞状态(BLOCAKED),再次获取到锁则会切换可执行状态(RUNNABLE)。
- 如果线程调用了wait()方法就会进入到等待状态(WAITING)需要其他的线程调用notify()或者notifyAll()、interrupt()唤醒后可切换为可执行状态(RUNNABLE)。
- 如果线程调用了sleep(millis)、join(millis) 或 wait(millis)方法则会进入计时等待状态(TIMED_WAITING),时间到了后可切换为可执行状态(RUNNABLE)。
如何保证t1,t2,t3顺序输出?
利用join()方法即可达到效果,join方法是一个阻塞的方法,调用该方法后会进入到计时等待(TIMED_WAITING)
public static void main(String[] args) {
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("3 ==========");
}
};
Thread t2 = new Thread() {
@Override
public void run() {
try {
// 需要等t1线程执行完才能执行下面的代码
t1.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("2 ==========");
}
};
Thread t3 = new Thread() {
@Override
public void run() {
try {
// 需要等t2线程执行完才能执行下面的代码
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("1 ==========");
}
};
thread1.start();
thread2.start();
thread3.start();
}
notify()和notifyall()方法有啥区别?
notify唤醒的时随机一个调用了wait()的方法线程,而notifyall()是唤醒所有调用了wait()的方法的线程。
wait()和slepp()方法有啥区别?
共同点:
wait()、wait(s) 和slepp(s) 方法的效果都会让线程放弃cpu的执行权进入到阻塞状态。
都可以被interrupt()打断方法唤醒。
不同点:
1、方法所属不同,wait()方法属于Object对象的成员方法,每个对象都有的,而sleep()方法是Thread类的静态方法。
2、醒来的时机不同,无参的wait()方法如果不调用interrupt(),notify(),notifyall()方法的话是不会被唤醒的,会一直等待下去,有参的wait()和sleep()方法都会到相应的时间醒来。
3、锁的特性不同,wait方法调用必须获取wait对象的锁(wait()方法必须配合synchronized使用,否则会报错),而sleep()方法没有这个限制。wait方法调用后会释放锁,而sleep方法则不会释放锁。
// wait()方法必须配合synchronized使用,否则会报错
Object obj = new Object();
synchronized (obj) {
obj.wait();
}
sychronized的底层原理
Synchronized锁采用的是互斥的方式让同一时刻只有一个线程获取到锁,它的底层由monitor实现的,monitor是由jvm提供的,线程获取锁需要关联上monitor,monitor内部由三个属性分别是:owner、entrylist、waitset。
- owner:用来关联获取锁的线程,只能关联一个线程
- entrylist:处于阻塞状态的线程就会存入到entrylist中。
- waitset:调用了wait()方法会进入到waitset中。
多线程访问同步代码块的流程:
当多个线程访问同步代码块的时候,首先会进入到entrylist中(不会排队等待),其中有一个线程获取锁标记后会于owner关联上,并且在monitor的计数器上 +1操作,表示锁定了,其他线程继续在entrylist中阻塞。若执行的线程调用了wait()方法那么monitor的计数器会执行 -1操作,如果计算器为0,就会将owner设置为null,表示放弃锁了,该线程会进入到waitset中阻塞。若执行的线程调用了notify()、notifyall()方法后waitset中的线程会被唤醒进入到entrylist中阻塞。等待获取锁标记,期间其他的线程也会竞争锁。若该线程正常的执行结束,monitor中的计数器也会执行 -1操作,当计数器为0,owner就会设置为null。
Monitor实现的锁是重量级锁,你了解锁升级的过程吗?
synchronized的锁有三种分别是:偏向锁,轻量级锁,重量级锁。
- 偏向锁:在很长一段时间内都只被一个线程持有锁,那么锁就是偏向锁,在第一次获得锁的时候会有一个CAS的操作,之后该线程再次获取锁无需CAS操作,只要判断对象markword中是否是自己的线程ID即可,减少了CAS操作的开销。
- 轻量级锁:如果有少量的线程竞争锁资源或者多个线程加锁的时间是错开的,这时候就会把偏向锁改为轻量级锁,轻量级锁修改了对象头的锁标记,每次修改都是CAS操作,保证原子性。轻量级锁有个自旋的概念。自旋锁:当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。优点是减少了CPU上下文的切换。
- 重量级锁:当发现自旋的次数过多的时候锁就会升级为重量级锁,该锁由Monitor实现的,重量级锁会涉及CPU上下文的切换,成本较高。
JMM内存模型
- JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)。
- 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存。
能说说什么是CAS吗?
CAS 也叫比较并交换,它体现的是一种乐观锁的思想,在无锁的情况下保证了线程操作数据的原子性。在操作共享变量的时候使用自旋的方式,如果发现工作内存中的值和主内存中的值不一致,会自旋再次去主内存中拿取值到工作内存中,等主内存和工作内存中的值一致了,在进行修改操作。CAS底层调用了操作系统的方法。
刚刚提到了乐观锁和悲观锁,能说说吗?
CAS就是基于乐观锁的思想,最乐观的估计,觉得每次都不会有线程来修改共享变量,就算被改了,我自旋再去主内存中拿取一次数据,再次对比后修改数据即可。
synchronized就是基于悲观锁的思想,最悲观的估计,觉得每次都有线程来修改共享变量,所以我上锁其他线程都不能改,等我修改完后你们才能来修改值。
请谈谈volatile的理解
volatile关键字能保证可见性和有序性,不能保证原子性。
可见性:
在JVM中有一个JIT(即时编译器)给代码做了优化,比如while(!stop),stop为true,会被优化为while(true),导致变量的可见性没了。解决方案如下:
- 可以在程序的vm参数中配置-Xint禁用掉即时编译器,但是这种不推荐,别的程序还需要使用。
- 用volatile关键字修饰变量,这就是在告诉jit不要对volatile修饰的变量做优化
有序性:
防止指令重排
什么是AQS?
- 是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的。
- AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程。
- 在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是O(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源。
- 在对state修改的时候使用的cas操作,保证多个线程修改的情况下原子性。
sychronized和Lock有什么区别?
-
语法层面
synchronized是关键字,可修饰在方法、代码块里面,底层使用c++实现的
Lock是接口,由jdk提供的,用java实现
使用synchronized退出同步代码块锁会自动释放,Lock需要手动解锁
-
功能层面
二者都属于悲观锁,都具有互斥、同步、可重入的功能
Lock还可以是公平锁,还提供了可打断、可超时、多条件变量的功能
Lock有适合不同场景的实现,如ReentrantLock,ReentrantReadWriteLock(读写锁)
-
性能层面
在没有啥竞争的情况下synchronized做了很多的优化:偏向锁,轻量级锁
在竞争激烈的情况下Lock实现的锁性能更好。
死锁的条件及如何诊断?
导致线程安全的问题是啥,如何解决?
能说说线程池的核心参数及执行原理吗?
核心参数
corePoolSize:核心线程数。
maximumPoolSize: 最大线程数(核心线程数+ 空闲线程数)。
keepAliveTime: 空闲线程存活时间,生存时间内没有新任务,次线程资源会释放。
TimeUnit: 空闲线程的时间单位(秒、毫秒等)。
workQueue:阻塞队列,当核心线程不够用后,新来的任务会添加到此队列中,队列满了会触发空闲线程来处理任务。
threadFactory:线程工厂,可以定制线程对象的创建比如设置线程的名称,是否是守护线程等。
hander:拒绝策略,当工作队列满了后就会触发拒绝策略。拒绝策略有四种:
1.AbortPolicy(默认):丢弃任务并抛出RejectedExecutionException异常。
2.DiscardPolicy:丢弃任务,但是不抛出异常。可能导致无法发现系统的异常状态。
3.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。
4.CallerRunsPolicy:由调用线程处理该任务
执行原理:
1、任务提交后首先会检查核心线程是否已满,如果没有满就会 创建核心线程或者使用已经创建好的核心线程去执行,核心线程如果满了就会添加到阻塞队列中。
2、核心线程如果满了会判断阻塞队列是否满了,如果没有满,那么就让工作线程执行即可,如果阻塞队列也满了,会检查线程数(核心线程+空闲线程)是否小于最大的线程数,如果小于则会创建空闲线程来执行任务。如果线程数不小于最大线程数,则会触发拒绝策略。(如果核心线程和空闲线程都处于空闲状态,会去检查阻塞队列是否有任务需要执行,有的话就会使用核心线程或者空闲线程去执行任务)
3、当核心线程和空闲线程都满了这时候又来了新任务,就会触发拒绝策略了。
为什么不建议用Executors创建线程?
阿里规范里面强制不能使用Executors去创建线程池推荐使用ThreadPollExecutor的方式去创建。Executors返回的线程池对象弊端如下:
-
FixedThreadPool和SingleThreadPool:
允许请求队列的长度为Integer.MAX_VALUE,可能会导致堆积大量的请求,导致OOM。
-
CachedThreadPool:
允许创建的线程数量为Integer.MAX_VALUE,可能会导致创建大量的线程,导致OOM
说说你对ThreadLocal的理解
ThreadLocal即本地线程变量,它的key是线程本身,value就是用户自己设置的业务值,该变量对其他线程而言是隔离的。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。
ThreadLocal是利用ThreadLocalMap实现的,需要注意的ThreadLocal中并没有用到,而是在每个Thread内部都有一个ThreadLocalMap
,即每个线程都有一个属于自己的ThreadLocalMap
。并发多线程场景下,每个线程Thread
,在往ThreadLocal
里设置值的时候,都是往自己的ThreadLocalMap
里存,读也是以某个ThreadLocal
作为引用,在自己的map
里找对应的key
,从而可以实现了线程隔离。
Java JVM篇
JVM内存模型
VM 分为堆区和虚拟机栈,本地方法栈,还有方法区(1.7后叫元空间,元空间用的是直接内存),程序计数器。
线程私有的有:虚拟机栈、本地方法栈、程序计数器。
线程共享的有:堆区、方法区或者叫元空间。
- 堆区: java内存最大的一块,所有对象实例、数组都存放在java堆,GC回收的地方,线程共享
- 虚拟机栈: 存放基本数据类型、对象的引用、方法出口等,线程私有
- 本地方法栈: java内存最大的一块,所有对象实例、数组都存放在java堆,GC回收的地方,线程私有。
- 方法区: 存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据等。(即永久带),回收目标主要是常量池的回收和类型的卸载,各线程共享,1.7后取消了方法区,改叫元空间。
- 元空间: 不在JVM中用的是直接内存,和方法区的功能是一样的。
- 程序计数器: 当前线程所执行的字节码的行号指示器,用于记录正在执行的虚拟机字节指令地址,线程私有。
JVM中一次完整的GC流程是怎样的,对象如何晋升到老年代?
Java堆中分两个区域 老年代 + 新生代
,在新生代中有分Eden + 幸存者区
,幸存者区又分为S0 + S1
。Eden区和幸存者区的比例关系为8:1:1。
- 当我们new一个对象,这个对象首先会被分配到Eden区,当 Eden 区的空间满了, Java虚拟机会触发一次 Minor GC(会暂停线程,俗称STW,耗时较短),以收集新生代的垃圾(利用可达性分析算法,标识出垃圾),存活下来的对象,则会转移到幸存者区并且标记对象的年龄+1 。如果是大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)会直接进入老年态。
- 幸存者区采用的是复制算法,在进行Minor GC时候把存活的对象从一端移动到另一端,每一次Minor GC存活下来的对象年龄都会+1,若年龄超过一定限制(15),则被晋升到老年态。即长期存活的对象进入老年态。
- 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代和年老代,Full GC代价比较大,会暂停所有的线程只留下GC线程,耗时也较长。要避免Full GC。
- Major GC 发生在老年代的GC,清理老年区,经常会伴随至少一次Minor GC,比Minor GC慢10倍以上
如和判断一个对象是否存活?(或者 GC 对象的判定方法)
-
引用计数法:
所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收.
引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象 A 引用对象 B,对象B 又引用者对象 A,那么此时 A,B 对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。 -
可达性算法(引用链法):
该算法的思想是:从一个被称为 GC Roots的对象开始向下搜索,能找到的对象都是存活对象,反之则说明其他的对象是垃圾对象。
在 java 中可以作为 GC Roots 的对象有以下几种:1、在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
2、在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
3、在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
4、本地方法栈中 (Native方法)引用的对象。
JAVA中GC算法有哪些?
-
标记-清除:
这是垃圾收集算法中最基础的,根据名字就可以知道,利用可达性分析算法标记出“垃圾对象”,然后清除未被标记的对象,即“垃圾”。具体来说,标记清除算法包含以下两个阶段:标记阶段:从根对象开始遍历所有可达对象,并将其标记为“非垃圾”。
清除阶段:扫描整个堆,将未被标记的对象进行回收(或者放入空闲链表等待下一次分配)。通过这种方式,标记清除算法可以有效地回收不再使用的内存空间,提高程序的内存利用效率。这种方法很简单,但是会有两个主要问题:
1.效率不高,标记和清除的效率都很低;
2.会产生大量不连续的内存碎片,导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次 GC 动作。 -
复制算法:
为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清楚完第一块内存,再将第二块上的对象复制到第一块。但是这种方式,内存的代价太高,每次基本上都要浪费一般的内存。于是将该算法进行了改进,内存区域不再是按照 1:1 去划分,而是将内存划分为8:1:1 三部分,较大那份内存交 Eden 区,其余是两块较小的内存区叫 Survior 区。
每次都会优先使用 Eden 区,若 Eden 区满,就将对象复制到第二块内存区上,然后清除 Eden 区,如果此时存活的对象太多,以至于 Survivor 不够时,会将这些对象通过分配担保机制复制到老年代中。(java 堆又分为新生代和老年代)
-
标记-整理:
该算法主要是为了解决标记-清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。
什么是类加载器,类加载器有哪些?
实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。在Java中有4中常见的加载器:
-
启动类加载器(BootstrapClassLoader)用来加载java核心类库(例如java.*包中的类),无法被java程序直接引用,它的加载目录为:
用户jdk环境的/jre/lib
目录。 -
扩展类加载器(ExtensionsClassLoader):它用来加载 Java 的扩展库,Java 虚拟机的实现会提供 一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类,它的加载目录为:
用户jdk环境的/jre/lib/ext
目录 -
应用程序类加载器(ApplicationClassLoader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类,也就是我们项目中的类都有这个加载器加载。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。
-
用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现。
能说说类的双亲委派机制吗?
一个类加载器收到了类加载请求,它首先不会自动去尝试加载这个类,而是把这个类委托给父类加载器去完成,每一层依次这样,因此所有的加载请求都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成该加载请求(找不到所需的类)时,这个时候子加载器才会尝试自己去加载,这个过程就是双亲委派机制!
作用:
- 避免类的重复加载。
- 保护程序的安全性,防止Java核心的API被修改。
Spring篇
Spring中的bean是线程安全的吗?
Spring本身是没有对Bean做线程安全处理的,所以:如果Bean本身是无状态的,那么这个Bean就是线程安全的反之是有状态的,那么这个Bean就是线程不安全的。
另外,Bean线程安不安全和Bean的作用域是没有关系的,Bean的作用域只是表示Bean的生命周期范围,对于任何周期的Bean都是一个对象,这个对象安不安全还是得看对象本身。
Spring容器启动流程是怎样的?
-
首先会根据xml配置文件或者Spring配置类扫描包路径下带有@Component注解的类然后包装成为BeanDefinition对象,并存在⼀个Map中。
-
然后筛选出所有非懒加载的单例BeanDefinition进⾏创建Bean。对于多例Bean不需要在启动过程中去进⾏创建,会每次获取Bean时利⽤BeanDefinition去创建。
-
然后利用BeanDefinition创建Bean的过程就是Bean的生命周期里面包扩如下步骤:
-
实例化:
在进行实例化之前会进行构造推断,如果只有默认构造使用默认构造,如果有默认构造和其他有参构造方法,选择无参构造,如果只有有参构造会报错,也可以自己指定使用哪个构造方法(在构造方法上加@Autowired)。然后根据构造方法得到原始对象。
-
属性填充(@Autowired自动注入):
对于加了@Autowired、@Value、@Resource的注解进行属性填充。
-
回调Aware:
判断对象是否实现了 xxxAware 接口,然后将相关的 xxxAware实例注入给 Bean,常见的Aware接口有:BeanNameAware、BeanFactoryAware、ApplicationContextAware、MessageSourceAware、ResourceLoaderAware、ServletContextAware。
- ApplicationContextAware:通过实现该接口,Bean可以获取对ApplicationContext(应用程序上下文)的引用。这使得Bean能够在运行时访问Spring容器的各种功能,如获取其他Bean、发布事件等。
- BeanFactoryAware:通过实现该接口,Bean可以获取对BeanFactory(Bean工厂)的引用。这使得Bean能够在需要时访问BeanFactory的功能,如获取其他Bean、注册Bean等。
- BeanNameAware:通过实现该接口,Bean可以获取自身在容器中的名称。这使得Bean能够在需要时获取自己的名称,并进行一些自定义的处理。
- MessageSourceAware:通过实现该接口,Bean可以获取对MessageSource(消息源)的引用。这使得Bean能够在需要时获取国际化的消息,并进行多语言处理。
- ResourceLoaderAware:通过实现该接口,Bean可以获取对ResourceLoader(资源加载器)的引用。这使得Bean能够在需要时加载各种资源,如文件、URL等。
- ServletContextAware:通过实现该接口,Bean可以获取对ServletContext(Servlet上下文)的引用。这使得Bean能够在需要时访问Servlet容器的功能,如获取上下文路径、获取初始化参数等。
-
初始化前(BeanPostProcessor: before):
实现了BeanPostProcessor接口,将调用postProcessBeforeInitialzation()方法实现初始化前功能。
-
初始化(InitializingBean):
实现了InitializingBean接口,将调用afterPropertiesSet()方法实现初始化功能。如果还使用到了
@PostConstruct
,执行完afterPropertiesSet()方法会还会执行@PostConstruct
标注的方法,一般推荐使用@PostConstruct
注解,因为它是Java EE标准的注解,更具可读性和可移植性。同时,使用@PostConstruct
注解还可以避免对Spring特定接口的依赖。 -
初始化后(BeanPostProcessor: after):
实现BeanPostProcessor接口,将调用postProcessAfterInitialzation()方法实现初始化后功能,AOP就是在这个阶段完成的,这时候Bean就可以被应用系统使用了。
-
销毁:
在Spring容器关闭的时候,会去销毁所有的单例Bean如加了@PreDestroy注解,或者在xml配置文件中指定了销毁的方法。
-
-
单例Bean创建完了之后,Spring会发布⼀个容器启动事件:实现ApplicationListener接口,并指定了泛型为ContextRefreshedEvent。这表示我们监听的是Spring容器启动完成后的事件。
@Component public class ContainerStartedEventListener implements ApplicationListener<ContextRefreshedEvent> { @Override public void onApplicationEvent(ContextRefreshedEvent event) { // 在容器启动完成后执行的逻辑 System.out.println("容器启动完成,执行一些特定的逻辑"); // 在这里可以进行一些初始化操作,如加载配置文件、注册Bean等 } }
-
Spring启动结束。
三级缓存如何解决循环依赖的?
Spring中的事务是如何实现的?
Spring的事务是基于AOP机制+数据库事务
实现的。
- 首先对于使用了@Transactional注解的Bean,为期创建动态代理对象。
- 当代理对象调用带有@Transactional标识的方法时,会利用事务管理器创建一个数据库的连接,并且修改数据库连接的autocommit(自动提交)属性为false,这是实现Spring事务的重要一步。
- 然后执行方法里面的SQL
- 执行完方法后如果没有异常就提交事务,如果出现了异常切需要回滚的话就回滚事务,否则依然会提交事务。
Spring事务的隔离级别对应的就是数据库的隔离级别,Spring事务的传播机制是基于数据库连接来做的,⼀个数据库连接⼀个事务,如果传播机制配置为需要新开⼀个事务,那么实际上就是先建⽴⼀个数据库连接,在此新数据库连接上执⾏sql
Spring中什么时候@Transactional会失效
- 因为Spring事务是基于代理来实现的,所以某个加了@Transactional的⽅法只有是被代理对象调⽤时,那么这个注解才会⽣效,所以如果是被代理对象来调⽤这个⽅法,那么@Transactional是不会失效的。
- 同时如果某个⽅法是private的,那么@Transactional也会失效,因为底层cglib是基于⽗⼦类来实现的,⼦类是不能重载⽗类的private⽅法的,所以⽆法很好的利⽤代理,也会导致@Transactianal失效。
- 如果在方法类自己使用try-cache把异常处理掉了,也会导致事务失效。
SpringMVC的执行流程
返回JSP页面的请求流程:
- 首先一个请求进来先到到DispatchServlet,如:http://xxx/user/getUserByid/1。
- DispatchServlet接受到请求后会调用HandleMapping(处理器映射器)。
- HandleMapping根据请求找到具体的处理器,然后生成处理器对象,如果又拦截器则会一并生成处理器拦截器,然后返回给DispatchServlet。
- DIspatchServlet再调用HandleAdapter(处理器适配器),HandleAdapter主要用来处理请求参数及返回值。
- HandleAdapter再去找对应的处理器(Handle/Controller)去执行,执行完后得到了ModAndView对象。然后返回给DIspatchServlet。
- DIspatchServlet得到了ModAndView后交给ViewResolver(视图解析器)
- ViewResolver解析后返回具体的View(视图)然后返回给DispatchServlet。
- DispatchServlet再根据View进行渲染视图(把后台的数据填充到视图jsp中)然后返回给前端。
返回JSON的请求流程:
- 首先一个请求进来先到到DispatchServlet,如:http://xxx/user/getUserByid/1。
- DispatchServlet接受到请求后会调用HandleMapping(处理器映射器)。
- HandleMapping根据请求找到具体的处理器,然后生成处理器对象,如果又拦截器则会一并生成处理器拦截器,然后返回给DispatchServlet。
- DIspatchServlet再调用HandleAdapter(处理器适配器),HandleAdapter主要用来处理请求参数及返回值。
- HandleAdapter再去找对应的处理器(Handle/Controller)去执行,判断是否加了@ResponseBody,是则通过HttpMessageConverter转换结果为JSON,然后响应给前端。
SpringBoot篇
SpringBoot的常用注解有哪些及作用?
- @SpringBootApplication注解:这个注解标识了⼀个SpringBoot⼯程,它实际上是另外三个注解
的组合,这三个注解是:
-
@SpringBootConfiguration:这个注解实际就是⼀个@Configuration,表示启动类也是⼀个配置类
-
@EnableAutoConfiguration:向Spring容器中导⼊了⼀个Selector,⽤来加载ClassPath下SpringFactories中所定义的⾃动配置类,将这些⾃动加载为配置Bean
-
@ComponentScan:标识扫描路径,因为默认是没有配置实际扫描路径,所以SpringBoot扫描的路径是启动类所在的当前⽬录
- @Bean注解:⽤来定义Bean,类似于XML中的标签,Spring在启动时,会对加了@Bean注
解的⽅法进⾏解析,将⽅法的名字做为beanName,并通过执⾏⽅法得到bean对象
- @Controller、@Service、@ResponseBody、@Autowired都可以说
SpringBoot自动装配原理。
首先从main方法开始,调用了SpringApplication.run()方法。传入了两个参数,一个是启动类class,一个是main方法的args参数。
然后执行run方法,会判断启动类上面是否加了@SpringBootApplication注解,如果没有加启动会报错。如果加了就会启动应用程序。
@SpringBootApplication注解主要由三个子注解组成:
-
@SpringBootConfiguration
@SpringBootConfiguration内部就是一个@Configuration注解也就是说这个类是一个配置类。
-
@ComponentScan
这个注解的作用就是扫描当前启动类及子包下的所有的Bean对象
-
@EnableAutoConfiguration
这个注解帮我们实现了自动装配。里面又两个核心注解:
-
@AutoConfigurationPackage
这个注解的作用说白了就是将主配置类(@SpringBootApplication标注的类)所在包以及子包里面的所有组件扫描并加载到spring的容器中,这也就是为什么我们在利用springboot进行开发的时候,无论是Controller还是Service的路径都是与主配置类同级或者次级的原因
-
@Import(AutoConfigurationImportSelector.class)
这个注解就是将需要自动装配的类以全类名的方式返回,它会去执行selectImports()方法,这个方法会去查找所有jar包里面META-INF路径下的spring.factories文件,这个文件里面的数据是以键=值的方式存储,然后解析这些文件,找出以EnableAutoConfiguration为键的所有值,以列表的方式返回,然后Spring就会去加载这些自动配置类,当然也不是全部都加载,需要看这些自动配置类上面是否满足各自的条件才会去加载。
-
以上就是SpringBoot自动装配的原理,我们需要自定义SpringBoot的Start也是遵循这个规则实现的。
Mybatis篇
Mybatis执行流程?
- 构建会话工厂,就是调用SqlSessionFactory.build()创建。这时候会去读取mybatis的全局配置文件mybatis-config.xml,或者使用Spring配置类的方式,然后会new一个Configuration对象,Configuration是一个全局唯一的对象,整个应用要注册东西拿取注册的东西,解析mapper语句的结果,注册拦截器,创建四大对象的方法都在里面。最后就得到了DefaultSqlSession。Configuration包括了二级缓存的开关、驼峰映射开关、log的实现、MapperRegistry (mapper.xml被解析时就放在这个mapperRegistry注册中心里面的knownMappers(key是命名空间反射的class类,value是直接new的MapperProxyFactory对象))、拦截器链,类型转换器注册中心,别名注册中心,Map<String, MappedStatement>(mapper.xml中的每一个增删改查标签被解析后,就封装成MappedStatement保存在这个里面)
- 这时候可以利用会话工厂创建会话了,调用sqlSessionFactory.openssion()即可得到SqlSession会话。里面包括了创建Transaction(事务管理),Executor执行器。
- SqlSession利用executor执行器执行SQL,同是Mybatis的查询缓存也是executor维护的。
- executor利用Configuration中的Map<String,MappedStatement>,从中找到对应的方法的sql从而执行SQL语句。
- executor处理的过程中到了StatementHandler处理sql的预编译及设置参数等相关的工作。期间利用ParameterHandler设置预编译的参数,利用ResultHandler处理结果集,这两个都需要利用TypeHandler完成数据库类型和java bean的转换。
- 将最后处理的结果返回。
Mybatis延迟加载的原理
延迟加载:假设存在用户、订单两张表,可以查询用户(User)及用户对应的订单(Order)列表(一对多);用户信息作为主体,而订单信息不是立即需要获取到的情况下,MyBatis提供延迟加载的策略,发送SQL执行语句时,只查询用户信息,当需要使用到订单信息时,即user.getOrderList()时,才会发送获取订单信息的SQL查询订单信息 (需要用到对应的信息时,才执行相关SQL);
MyBatis延迟加载原理:必须是一对多或者一对一才能使用延迟加载。本质是通过动态代理的形式,创建了目标对象(User)的代理对象,拦截了对象的getting方法,在执行getting方法时,进入拦截器的invoke方法,当发现需要延迟加载时(判断属性是否为空),会把之前存放好的SQL语句进行执行(sql标签中的一对一或一对多语句),然后调用对象(User)调用set方法存值,然后调用对象本身的get方法取值。
你了解Mybatis中的一级缓存和二级缓存吗?
- 一级缓存是 SqlSession 级别的缓存。在操作数据库时需要构造 SqlSession 对象,在对象中有一个数据结构(HashMap)用于存储缓存数据。不同的是 SqlSession 之间的缓存数据区(HashMap)是互相不影响,默认就是开启的,也不能关闭。
- 二级缓存是 Mapper 级别的缓存(基于命名空间来实现的),多个 SqlSession 去操作同一个 Mapper 的 sql 语句,多个 SqlSession 可以共用二级缓存,二级缓存是跨 SqlSession 的
不管是一级缓存还是二级缓存涉及到增删改都会把缓存删除掉。
SpringCloud篇
解释下CAP定理
一个分布式系统有三个指标分别是 Consistency(一致性),Availability(可用性),Partition toleranc(分区容错性)。
- 一致性(Consistency):一致性指的是所有数据副本在同一时间内是一致的,也就是说,任何时候读取任何数据,不管从哪个节点读取,都应该返回最新的写入结果。当数据在一个节点上更新后,其他所有节点上的这份数据也会立即同步数据,同步数据期间可能不能对外提供服务。
- 可用性(Availability):可用性意味着每个请求都能够接收到一个响应,不论响应是成功还是失败。在分布式系统中,只要系统能够继续提供服务,即使部分节点失效或无法通信,系统也被认为是具有可用性的。
- 分区容忍性(Partition tolerance):分区容忍性指的是系统能够容忍网络分区,在网络通信出现故障时,仍然能够继续提供服务。比如主节点挂了,需要有其他的节点替代主节点然后继续对外提供服务。
分布式系统节点之间肯定是需要网络连接,所以分区容错性必须要存在。在满足了分区容错的基础上,如果出现了主节点挂掉,其他节点代替主节点,期间就会面临两个选择,保证一致性还是保证可用性,他们不能同时存在,如果要保证一致性,在数据进行同步的时候就不能对外提供服务,就失去了可用性,同理在保证了可用性的情况下,就无法保证数据的一致性因为期间可能数据还没同步过来,你就对外提供服务了,而这个响应回去的数据可能不是用户想要的数据,所以分布式系统要么只能满足CP,要么只能满足AP。
什么是BASE理论
BASE理论是对CAP的一种解决思路,包含了三个思想:
Basically Available(基本可用):分布式系统再出现故障是,允许损失部分性能,保证核心可用。
Soft State(软状态):在一定时间内,允许出现临时不一致的状态。
Eventually Consistent(最终一致):虽然无法保证强一致性,但是在软状态结束后最终会达到数据一致。
SpringCloud有哪些组件?
-
服务注册与发现:Eureka、Nacos、ZooKeeper
在以前的分布式中服务与服务之间调用用的是RestTemplate来调用的,他需要传如被调用方的IP、端口、及请求的路径,如果服务多起来后,就需要人来的维护这些真实地址,到时候容易搞混淆,而且一般分布式的服务可能需要高并发的支撑,所以需要负载均衡,自己实现负载均衡也比较麻烦,如果服务的状态也不知道,基于这些痛点就引出了服务注册与发现的组件。市面上常见的服务注册与发现组件有Eureka、Nacos、ZooKeeper。
Eureka:
Eureka是Netflix开发的组件,可以完成服务注册与发现,及服务状态的监测。Eureka采用的是AP的模式(A:可用性,P:分区容错性).Eureka包含两个组件:Eureka Server和Eureka Client。服务启动后向Eureka注册,Eureka Server会将注册信息向其他Eureka Server进行同步,当服务消费者要调用服务提供者,则向服务注册中心获取服务提供者地址,然后会将服务提供者地址缓存在本地,下次再调用时,则直接从本地缓存中取,完成一次调用。
Eureka 的集群中,它是去中心化结构,只要有一台 Eureka 还在,就能保证注册服务可用,只不过查到的信息可能不是最新的(不保证强一致性)。最终还是一致的。
Eureka有一种自我保护机制,如果在15mins内超过85%的节点都没有正常的ping心跳,那么Eureka就认定客户端和注册中心出现了网络故障。
Nacos:
Nacos阿里的一个开源产品,是针对微服务架构中的服务发现、配置管理、服务治理的综合型解决方案。除了能完成服务注册与发现,及服务状态的监测外,还能实现实现配置中心的功能。Nacos支持CP或AP模式。可在配置中配置。
-
负载均衡:Ribbon、LoadBalancer
负载均衡主要的作用就是将用户的请求平摊的分配到多个服务上,从而达到系统的HA(高可用)负载均衡策略有:轮询,随机,权重、最少连接数,或者能自己实现IRule接口实现自己的负载均衡策略。
-
服务调用:Feign、OpenFeign、Dubbo RPC
Feign、OpenFeign、Dubbo RPC都是用来完成服务与服务之间的远程调用的,可以让调用服务和调用本地方法一样简单。Feign、OpenFeign都是基于http传输协议的(适合短连接),Dubbo 则是基于RPC协议(适合长连接)
-
服务网关:Gateway
API网关为微服务架构的系统提供简单、有效且统一的API路由管理,作为系统的统一入口,提供内部服务的路由中转,给客户端提供统一的服务,可以实现一些和业务没有耦合的公用逻辑,主要功能包含认证、鉴权、路由转发、安全策略、防刷、流量控制、监控日志等。核心的概念有:
路由(route)
路由是网关中最基础的部分,路由信息包括一个ID、一个目的URI、一组断言工厂、一组Filter组成。
断言(predicates)
路由断言,用于判断请求是否符合要求,符合则转发到路由目的地,有路径匹配,请求头匹配
过滤器(Filter)
-
配置中心:SpringCloud Config、Nacos Config
-
服务熔断:Hystrix、Sentinel
-
分布式事务:Seata
-
链路追踪:Skywalking
Ribbon负载均衡流程
首先服务消费者发起请求到Ribbon(负载均衡器),Ribbon会先去注册中心拉去服务列表,然后注册中心会将可用的服务列表返回给Ribbon,Ribbon就能根据这些服务器列表进行负载均衡算法去请求服务提供者的接口了(比如随机,轮询等)。
Ribbon 负载均衡策略有那些
- **RoundRobinRule:**简单的轮询服务器列表请求服务器。
- **RandomRule:**随机一个服务器器作为请求。
- **WeightedResponseTimeRule:**权重响应时间,服务器响应时间越快权重越大,被选中的机率就越高。
- **ZoneAvoidanceRule:**以区域可用的服务器为基础进行服务器选择,根据区域对服务器进行分类后,然后再对区域内的多个服务器做轮询。
如何自定义负载均衡策略
首先需要实现IRule接口,重写choose()方法,然后在choose()方法里面编写我们自己需要的负载均衡策略。
public class MyRule implements IRule {
private ILoadBalancer loadBalancer;
@Override
public Server choose(Object o) {
// 编写自己的负载均衡逻辑
}
@Override
public void setLoadBalancer(ILoadBalancer iLoadBalancer) {
this.loadBalancer = iLoadBalancer;
}
@Override
public ILoadBalancer getLoadBalancer() {
return loadBalancer;
}
}
然后编写配置类,把自定义的负载均衡策略交给spring管理,这种方式是全局生效的
@Configuration
public class MyRibbonConfiguration {
@Bean
public IRule getRule() {
return new MyRule();
}
}
亦或者使用局部生效的方式,在某个服务下的配置文件下添加如下配置:
userservice:
riboon:
NFLoadBalancerRuleClassName: 我们自定义的负载均衡类的路径
什么是服务雪崩,如何解决
当一个服务不可用,然后导致一系列的服务都不可用了,这个就是微服务的雪崩效应,比如A服务调用了B服务,B服务调用了C服务,这个时候C服务出现了问题,导致请求无法得到处理,这时候所有的请求都在C服务上面堆积,导致C服务不可用了,C服务不可用后又导致B服务请求堆积,这样以此类推后面所有的服务都不可用。
解决方案:使用Sentinel的服务熔断降级处理
- **流控:**在Sentinel中,可配置流控的规则,比如每秒钟访问不能超过10次,超过10次就会被流控,期间不能访问业务方法,执行流控规则比如提示该用户不能在一秒钟内请求多次,过一段时间后恢复访问。流控是为了不被突发的请求压垮服务器,当方法出现了问题后会进行熔断处理。
- **服务熔断:**应对微服务雪崩效应的一种链路保护机制,类似家里的保险丝,熔断后会执行降级方法。服务熔断中有三个状态:
- close:关闭,熔断器默认为关闭状态(初始状态)
- open:全开, 受下游服务请求异常,达到阈值后,启动熔断器,此时熔断器会打开。判断是否熔断的策略有三种:1、响应时间,2、异常比例,3、异常数。
- half-open: 半开,此时允许一定量的服务请求通过,如果通过的请求都调用成功,则判定服务恢复,关闭断路器
- **服务降级:**服务降级是服务自我保护的一种方式,在程序在出现问题时,仍能保证有限功能可用的一种机制,是一种兜底的方式。比如我们C服务里面的save()方法出现了异常、线程池不够用了、调用超时,我们就对这个方法进行服务降级处理(这时候就不会调用业务方法了,直接执行我们定义的降级逻辑),比如提示网络不可用请稍后再试。确保这个请求能正常的返回,不会影响到其他的方法。
微服务为什么需要监控?
**问题定位:**微服务中调用链路如果过长,再当众多的服务中,如果某个微服务挂了需要快速的定位到问题。
**性能分析:**如果再一次请求中,请求的速度很慢,这个请求涉及到的调用链路又很长,需要定位出到底是哪个微服务请求的时间过长。
**服务关系:**在微服务和微服务之间都会相互的调用,当太多微服务的时候,他们之间的关系就不能理清楚了
**服务告警:**当服务出现问题后,或者服务快要出问题了,需要进行告警,提醒研发人员服务可能出现问题或者已经出现问题了,需要解决。
skywalking就提供了完善的链路追踪能力,是apache的顶级项目,他能以图形界面的方式展现出服务之间的关系,微服务之间的调用关系,能定位出那些微服务不健康,那些请求比较慢,能定义告警规则进行短信之类的提醒。
出现了分布式事务如何解决
项目中采用的Seata解决分布式事务问题,Seata事务管理器中有三个重要的角色:
-
TC-事务协调者:维护全局和分支事务的状态,协调全局事务的提交或者回滚,需要单独部署。
-
TM-事务管理器:定义全局事务的范围,开始全局事务,提交或者回滚全局事务。
-
RM-资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,驱动分支事务的提交或者回滚。
Seata中有三个模式:XA、AT、TCC
XA模式工作流程:
第一阶段:由TM开启全局事务,然后调用分支事务,RM收到命令将分支事务注册到TC上,由RM执行业务sql,执行完成向TC报告事务状态。
第二阶段:分支事务都执行完成后,TM向TC发起全局事务的提交或者回滚,TC检查分支事务的状态,如果全部成功,那么告诉RM可以提交事务了,如果有分支事务失败了,RM就会回滚事务。
AT模式工作流程:
第一阶段:由TM开启全局事务,然后调用分支事务,RM收到命令将分支事务注册到TC上,由RM执行业务sql后并提交,执行完成向TC报告事务状态。
第二阶段:分支事务都执行完成且提交后,TM向TC发起全局事务的提交或者回滚,TC检查分支事务的状态,如果全部成功,那么告诉RM可以删除undolog中的log文件了,如果有分支事务失败了,RM就会去undolog中拿到之前的版本,然后恢复数据。
TCC工作流程
分布式系统接口幂等性如何设计
幂等的意思是:多次调用接口,不会改变业务状态,可以保证多次请求的结果和单次的结果是一致的。查询和删除是天然幂等的,只有插入和更新会出现幂等问题。
解决幂等问题有三种方案:
使用数据库的唯一索引
有唯一索引在只能插入一条一模一样的数据
分布式锁
基于分布式锁实现就是同一时间多次请求来了,只让一个请求去处理,其他的请求都失败返回,这种方案需要注意锁的粒度,不然会影响性能。
@Service
public class LockService {
@Autowired
private RedissonClient redissonClient;
public boolean tryLock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试在给定时间内获取锁,这里设置为30秒
return lock.tryLock(30, 10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
public void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
@RestController
public class IdempotentOperationController {
@Autowired
private LockService lockService;
@PostMapping("/execute_secure_operation")
public String executeSecureOperation(@RequestBody String token) {
String lockKey = "lock:" + token;
try {
if (lockService.tryLock(lockKey)) {
// 执行操作前检查Token的有效性等
// 这里执行具体的操作
return doActualOperation();
} else {
return "Operation is in progress.";
}
} finally {
lockService.unlock(lockKey);
}
}
private String doActualOperation() {
// 执行实际操作
return "Secure Operation executed.";
}
}
redis + token
首先用Redis来保存Token。客户端通过GET /request_token
请求一个新的Token,服务器生成一个UUID作为token,并将其存储在Redis中,前台也保存这个token。然后,客户端在执行操作时,需要将该Token一同发送到服务器。服务器检查token是否存在,如果存在,则删除Token并执行操作;如果不存在,则返回错误。例如第一个请求拿到token执行完业务后把token也删除了,后面的请求带着token来执行业务,此时Redis中没有token了,业务就不会执行了。
@Component
public class TokenStore {
private final RedissonClient redissonClient;
public TokenStore(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
public void put(String token) {
RBucket<String> bucket = redissonClient.getBucket(token);
bucket.set("true", 10, TimeUnit.MINUTES); // 示例中设置了10分钟后过期
}
public boolean get(String token) {
RBucket<String> bucket = redissonClient.getBucket(token);
return bucket.isExists();
}
public void remove(String token) {
RBucket<String> bucket = redissonClient.getBucket(token);
bucket.delete();
}
}
@RestController
public class IdempotentController {
private final TokenStore tokenStore;
public IdempotentController(TokenStore tokenStore) {
this.tokenStore = tokenStore;
}
@GetMapping("/request_token")
public String requestToken() {
String token = UUID.randomUUID().toString();
tokenStore.put(token);
return token;
}
@PostMapping("/execute_operation")
public String executeOperation(@RequestBody String token) {
if (!tokenStore.get(token)) {
return "Operation already executed or token is invalid.";
}
tokenStore.remove(token);
return doActualOperation();
}
private String doActualOperation() {
// 这里应该是实际的操作
return "Operation executed.";
}
}
Redis篇
Redis 的数据类型有那些?
string
最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M
。常用的应用场景有:
- 做缓存,可以缓存json对象,token等。
- 简单的计数器,如文章的点击数量库存数量等。
- 分布式锁,主要使用setnx这个api
list
List是简单的字符串列表,可以从列表头部或尾部插入数据。使用的是压缩列表或者双向链表。常见命令是lpush ,Rpop,左进右出。常用的应用场景有:
- 首页列表的展示,比如商品列表,适用于数据量少、分页展示,高并发,请求量大。
- 消息队列,list数据是有序的可保证消息的有序性,可以用LPush和RPop左推右出,想要阻塞效果使用BRPOP命令阻塞式读取
hash
Hash 是一个键值对(key - value)集合,类似于java中的map,其中 value 的形式如: value=[{field1,value1},...{fieldN,valueN}]
。Hash 特别适合用于存储对象。Hash的应用场景有:
- 缓存对象,与string不同的是hash缓存的对象适合频繁修改的
- 实现购物车
set
Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。底层用了整数集合或哈希表。常用的应用场景有:
- 黑名单过滤,黑名单数据可以放在redis的set集合中。 可以sismember命令来判断在不在黑名单。
- 共同关注,常用在首页展示栏中,好友推荐、文章推荐、商品推荐,set可以两个集合取交集,A关注的用户与B关注的用户 取交集,则可以得到两个人的共同关注。也可以实现向A推荐B关注的用户。
- 抽奖,存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。
zSet
ZSet有序且不重复,底层使用了跳跃表。多了一个排序属性 score(分值),按照分值排序。跳跃表是一种特殊的有序链表,通过抛硬币方式,向上生成链表指向下一层,减少数据比较,使数据更快速找到定位。实现场景:
- 热搜上排行榜如何实现?按照热度统计 每小时、天、周、月的排行情况。假设排行榜热度按照 热度= 转发数+点赞数+评论数,首先以每小时为单位,计算每小时的热度,key可以为当前时间戳 /1000/60/60=小时key,score为热度。那么,按天的热度则为24个小时ZSet的合并。
BitMap
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。使用场景:
签到统计
Redis缓存穿透,击穿,雪崩,及解决方案?
缓存穿透:
指的是查询一个不存在的数据,从redis中查不到然后就会去mysql中查询,导致每次请求都查询数据库。
- 可以利用缓存空数据来解决这个问题,但是存在一定的问题,不存在的key浪费空间,而且可能这个key以后就存在了,导致数据不一致。
- 还可以利用布隆过滤器区解决,布隆过滤器判定不存在的key那就一定不存在,存在的key不一定存在。
缓存击穿 :
给热点的key设置了过期时间,当key过期的时候,刚好大量的请求来了,这些请求发现redis中没有数据就直接打到了数据库,导致数据库挂了。
- 可以利用互斥锁来解决,当发现redis中数据不存在时只让其一个线程去数据库查询数据,其他的线程自旋,这种方法性能比较差,是强一至的。
- 还能使用逻辑过期解决,key设置不过期,在数据里面加一个逻辑过期的字段,每次拿取数据的时候检查是否过期,过期了就更新数据。这个方案不能保证数据绝对一致,但是性能好,高可用。
缓存雪崩:
缓存雪崩就是同一时间大面积的热点key都失效了,导致并发都打到了数据库,从而压垮数据库。或者redis服务器宕机了
1、提高redis服务的高可用(添加redis集群)
2、将这些热点key的时间错开失效,比如在原来的过期时间上加个1~5分钟的随机值,这样过期时间的重复率就减低了。
3、给缓存的业务添加降级限流的策略(nginx,sentinel)
Redis如何保证双写一致?
延时双删,先删除缓存中的数据,然后更新数据库,最后延时n秒,再次删除缓存。有弊端,不好控制延时的时间,会有脏数据。
(分布式锁)使用分布式锁锁住资源,一个个来操作,能保证数据的强一致性但是效率太低
(强一致)使用redisson实现的读写锁,在读的时候添加共享锁,可以保证读读不互斥,读写互斥,当我们要更新数据的时候加排它锁,它是读写,读读都互斥,这样可以保证写数据的同时不会让其他线程读取数据,避免读到脏数据,这里需要注意就是获取数据的方法和更新数据的方法加的锁要是同一把锁才行。
(最终一致)canal或者mq保证最终一致,mq的话就是数据更新了发一份到mq中,自己写代码去处理,canal原理是伪装成为msql的一个从节点,数据更新后canal会读取mysql的bin log文件,然后再客户端获取到数据更新到缓存中。
Redis持久化策略?
RDB:redis数据快照,就是把内存的数据记录到磁盘中生成一个rdb文件,当redis发生故障重启后可根据rdb文件恢复数据。
AOF:redis命令日志,类似于mysql的binlog,记录redis的写操作命令,针对同一个key的多次写操作,他会记录最后一个写操作命令减少aof的文件大小。
Redis数据过期策略、淘汰策略?
1、过期策略
惰性删除:设置过期时间后,当key过期了Redis不会立马删除改key,它会等到使用到这个key时候再检查这个key有没有过期,过期了就删除,如果改key一直不被使用,那么就会一直存在内存中,内存永远不会释放。
定期删除:每隔一段时间会抽取一定数量的key进行检查,然后删除过期的key。定期清理有两种模式:SLOW模式:基于定时任务,执行频率默认为10hz,每次不能超过25ms,可以通过配置文件进行修改。FAST模式:执行频率不固定,但是两次的间隔不能低于2ms,每次耗时不能超过1ms。
Redis数据过期策略是两种结合使用的
2、淘汰策略
当Redis内存不够的时候,此时再往Redis中添加数据就会触发Redis的数据淘汰策略。Redis中支持8中淘汰策略删除key:
noeviction:不淘汰任何key,再次添加数据会报错
volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,越小的优先淘汰
allkeys-random:从所有的key中随机淘汰
volatile-random:从设置了TTL的key中随机淘汰
allkeys-lru:对于全体的key基于lru算法淘汰
volatile-lru:对于设置了TTL的key基于lru算法淘汰
allkeys-lfu:对于全体的key基于lfu算法淘汰
volatile-lfu:对于设置了TTL的key基于lfu算法淘汰
lru算法:当前时间减去访问时间,值越大的越优先淘汰,即访问时间越靠前的越先淘汰
lfu算法:最少频率使用,key的访问频率越低越线被淘汰。
MySQL篇
什么是索引
索引是一种帮助MySQL高效查询的数据结构(有序),主要作用就是提高数据的检索效率,降低数据库的IO成本,减少全盘扫描。
创建索引原则有哪些?
1、针对数据量较大,且查询比较频繁的表建立索引(单表超过10w数据)。
2、针对于常作为查询条件、排序、分组操作的字段建立索引。
3、尽量选择区分度高的字段作为索引,尽量创建唯一索引,区分度越高索引效率越高。
4、如果是字符串类型字段,字段较长建立创建前缀索引。
5、尽量使用联合索引,减少单列索引,查询时联合索引可以引起索引覆盖,避免回表操作,提高查询的效率。
6、要控制索引的数量,索引越多,维护成本越大,影响增删改效率。
7、如果索引列不能存储NULL值,建议使用NOT NULL约束它,当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。
索引失效的情况
1、违反了最左前缀原则。
2、使用了范围查询右边的列索引会失效。
3、模糊查询以%开头会导致索引失效。
4、在索引列上面进行运算操作索引会失效。
5、字符串不加单引号会导致索引失效(可能会发生类型转换导致失效)。
覆盖索引
覆盖索引指的是查询中使用到了索引,并且所需要返回的列,在该索引中能全部找得到。
聚簇索引(聚集索引)、非聚簇索引(二级索引)
聚簇索引:数据和索引放在一起,保存整行的数据,有且只有一个
非聚簇索引:数据和索引分开存储,保存的是该数据的主键,可存在多个
什么是回表查询
通过二级索引找到对应的主键,再根据主键去聚簇索引中查询到整行的数据,这个过程就叫回表查询。
如何定位慢查询
1、利用Skywalking 界面化查看一个请求耗时了多久也能查询出sql执行了多久,从而定位出慢查询。
2、MySQL自带慢查询日志:记录了执行时间超过了指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志,需要在my.cnf文件中配置,一般都是调试阶段开启,生产环境关闭
# 开启慢日志查询
slow_query_log=1
# 设置慢日志查询时间为2秒,执行时间超过两秒就会记录日志
log_query_time=2
MySQL超大分页处理
在数据量比较大的时候,如果进行limit分页查询,在查询时,越往后分页效率越低。
limit 分页查询耗时比较
mysql> select * from tb_user limit 0,10;
10 rows in set (0.00 sec)
mysql> select * from tb_user limit 9000000,10;
10 rows in set (11.05 sec)
因为在进行分页查询时,如果执行limit 9000000,10 此时MySQL需要排序前9000010记录,但是仅仅返回9000000~90000010的记录,其他的丢弃,查询代价太大了。
优化思路:利用覆盖索引加子查询的方式进行优化,利用覆盖索引先排序分页查询出所需的id 在关联查询,利用聚簇索引查询出整行的数据,从而达到优化的效果
select *
from
tb_user u1
join
(select id from tb_user order by id limit 9000000, 10) u2 on u1.id = u2.id
SQL优化
表设计优化:
可参考《阿里巴巴开发手册:嵩山版》如选择合适的字段类型,有关联得表有时候需要冗余字段,不要什么都放在一个表里面。
SQL语句优化
- select语句务必指明要查询的字段,避免select *。
- sql语句避免造成索引失效的写法。
- 尽量使用union all 代替union,因为union会多一次过滤效率低。
- 避免where后面对字段进行运算操作。
- 使用join时能用inner join就不要用left join或right join,以小标驱动大表,内连接会对两个表进行优化,优先会把小表为驱动,left join或right join则不会调整你编写sql的顺序。
主从复制、读写分离
如果数据库使用的场景是读多写少的情况就可以采用读写分离的架构,当读操作来的时候回去从库,写操作则会去主库,不要让数据的写入影响查询,读写分离可提升查询的效率。
MySQL事务特性(ACID)
事务是一组操作的组合,它是一组不可分割的最小操作单元。它有四个特性:
- 原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功要么全部失败。
- 一致性(Consistency):事务完成时,必须所有的数据状态保持一致(a转账给b,a账户扣多少,b账户就要加多少,不能多也不能少)。
- 隔离性(Isolation):事务于事务之间不能相互影响。
- 持久性(Durability):事务一旦提交或者回滚,它对数据库数据的影响都是永久的。
MySQL并发事务带来的问题
- 脏读:一个事务读取到了另外一个事务没有提交的数据。
- 不可重复读:同一个事务内查询同一条数据,两次查询结果不一致
- 幻读:一个事务按照条件查询数据时,没有对应数据行,但是在插入数据时发现数据行已存在,再次查询数据又发现没有该数据行,就好像出现了幻觉。
MySQL事务隔离级别
- 读未提交(READ_UNCOMMITTED):脏读、不可重复读、幻读均不能解决
- 读已提交(READ_COMMITTED):能解决脏读。不可重复读、幻读不能解决
- 可重复度(REPEATABLE_READ 默认隔离级别):能解决脏读、不可重复读。幻读不能解决
- 串行化(SERIALIZABLE 效率低下):脏读、不可重复读、幻读均能解决
解释下MVCC
MVCC多版本并发控制,指维护一个数据的多个版本,使其读写操作没有冲突。它的实现主要依赖数据库记录中的隐藏字段、undo log日志,readView。
隐藏字段:
- trx_id(事务id),记录每一次操作的事务id,自增长的,每次改变数据就会+1
- roll_pointer(回滚指针),指向上一个版本的事务版本地址,需要配合undo log的版本链使用
undo log:
- 回滚日志,存储老版本数据
- 版本链,多个事务并行操作某一行记录,记录下不同事务修改的数据版本,通过roll_pointer指针形成一个链表
ReadView
解决一个事务查询选择版本的问题,根据ReadView的匹配规则和当前的一些事务id判断改访问哪个版本的数据,不同的隔离级别快照读是不一样的,最终访问的结果也不一样。
RC:每一次执行快照读都会生成ReadView
RR:仅在事务中第一次执行快照读时生成ReadView,后续复用
MySQL主从同步原理
binlog日志文件记录的是DDL语句和DML语句
- 主库在事务提交的时候会把数据变更记录在binlog文件中。
- 从库读取主库的binlog文件写入到从库的中继日志relay log文件中。
- slave重做中继日志中的事件,将改变反映它自己的数据。