Java重点原理精炼(免费版)
👏作者简介:大家好,我是 枫度柚子🍁,Java摆烂选手,很高兴认识大家 👀
📕B站: 枫吹过的柚 🍁
📕版本说明: 付费版本可以找UP主私聊,免费版本不再持续更新
🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
🛰微信群: 加微信 QaQ-linv
🐧QQ群: 995832569
🔑更新时间: 2024-03-08
目录
文章目录
- Java重点原理精炼(免费版)
- 目录
- 简历JD参考
- Java基础
- 集合
- 多线程
- 锁机制
- 锁机制引入
- 乐观锁和悲观锁的区别
- 死锁、活锁、饥饿的区别
- Java死锁如何避免
- 常见的原子类
- 什么是CAS
- 谈一下volatile
- Monitor机制
- 管程是如何保证同步和互斥的
- 管程中的Mesa模型和Hoare模型
- 谈一下synchronized
- synchronized的锁变化
- synchronized锁粗化、锁消除
- 什么是AQS
- JUC可重入锁ReentrantLock
- JUC共享锁Semaphore
- JUC闭锁CountDownLatch
- JUC循环屏障CyclicBarrier
- JUC读写锁ReentrantReadWriteLock
- 保证三个线程同时执行
- 并发情况下三个线程依次执行
- 三个线程有序交错执行
- 两个线程交替打印奇偶数
- JMM
- JVM
- MySQL
- Oracle
- Redis
- Netty
- Zookeeper
- RabbitMQ
- Kafka
- RocketMQ
- Spring
- Spring MVC
- Spring Boot
- Spring Cloud
- 场景题
简历JD参考
- 深入理解volatile、synchronized、Lock、ConcurrentHashMap、线程池等并发编程技术;
- 深入理解JVM、JMM,熟悉内存模型、类加载、垃圾收集器、GC算法等、具有生产JVM调优经验;
- 熟练使用Redis,熟悉其数据类型、持久化、主从复制、集群、缓存一致性等,对分布式锁实现有一定的理解;
- 熟练使用MySQL,熟悉底层数据结构、索引、事务、日志等,具有生产慢SQL调优经验;
- 熟悉Zookeeper,熟悉节点类型、ZAB协议以及分布式锁实现等;
- 熟悉Netty,对Rceator模型、零拷贝、粘/拆包具有一定的理解;
- 熟练使用RabbitMQ、Kafka、RocketMQ,熟悉基础架构、副本同步机制、持久化机制、零拷贝以及常见问题解决方案;
- 熟悉Spring,对IOC、AOP、Bean生命周期、循环依赖等;
- 熟悉Spring Boot,对常用注解、自动装配原理、Jar启动流程、自定义Starter有一定的理解;
- 熟练使用Spring Cloud,对常用组件、分布式事务具有一定的理解;
- 熟悉常规的设计模式,熟悉责任链、工厂、策略等,具有项目落地实践经历;
Java基础
面向过程与面向对象的区别
- 面向过程可以理解为按步骤处理问题
- 面向对象可以理解为将具体事务存在的属性、行为抽象出来,然后去进行实现
什么是值传递和引用传递
- 值传递是对基本数据类型而言,只进行值的拷贝,不会影响原变量
- 引用传递是对对象而言,不是传递对象的值,而是传递对象的地址,如果副本改变会影响到原对象
String为什么是不可变的
- String内部维护的是private final char/byte数组,不可变线程安全
- 好处
- 防止被恶意篡改
- 作为HashMap的key可以保证不可变性
- 可以实现字符串常量池,在Java中,创建字符串对象的方式
- 通过字符串常量进行创建
- 在字符串常量池判断是否存在,如果存在就返回,不存在就在字符串常量池创建后返回
- 通过new字符串对象进行创建
- 在字符串常量池中判断是否存在,如果不存在就创建,再判断堆中是否存在,如果不存在就创建,然后返回该对象,总之要保证字符串常量池和堆中都有该对象
- 通过字符串常量进行创建
- 好处
String、StringBuilder、StringBuffer的区别
- String内部维护的是private final char/byte数组,不可变线程安全
- StringBuilder可变,线程不安全,可以使用append进行拼接字符串
- StringBuffer可变,通过synchronized来保证线程安全,操作和StringBuilder一样
- synchronized对于出现在循环中会进行锁粗化,会将锁的范围扩展到整个操作,从而避免频繁进行锁操作造成性能开销
深拷贝和浅拷贝
- 浅拷贝就是副本对象和原对象都指向同一块内存空间,副本对象的改变会影响到原对象
- 深拷贝就是副本对象和原对象不指向通一块内存空间,会重新开辟一块内存空间给副本对象进行指向
equals和==的区别
- ==对于基本类型比较的是值,但是引用类型比较的是内存地址
- equals取决于子类是否重写,默认和==等价
两个对象的hashcode相同,equals一定相同吗
- 一般推荐重写equals方法的同时也要重写hashcode方法
- 两个对象的hashcode相同,equals可能相同
- 两个对象equals相同,hashcode一定相同
final、finally、finalize的区别
- final修饰的类不能被继承、属性是常量而且必须初始化、修饰的方法不能被重写
- finally用于异常处理,finally代码块中的内容一定会执行
- finalize是Object类的一个方法,当我们调用system.gc()时会调用,也可以使用它在GC时自救一次
- 重写finalize方法的对象会被加入到一个unfinalized链表中,首次GC时不会回收该对象,调用完finalize后,然后从unfinalized链表剔除,此时对象可以进行自救,将自己重新加入到引用链上,下一次GC时会进行回收
集合
集合引入
- 概念前提
- ArrayList的数组默认长度10,1.5倍数组扩容,扩容通过开辟新数组进行转移老数组元素
- HashMap、ConcurrentHashMap的数组默认长度16,2倍数组扩容,扩容通过开辟新数组进行转移老数组元素
- 核心思想
- 读写分离
- CopyOnWriteArrayList底层实现、处理快速失败
- 分散热点
- ConcurrentHashMap底层addCount计算
- 索引优化
- HashMap/ConcurrentHashMap 从数组->链表->红黑树
- 读写分离
- 考察重点
- 集合底层实现以及设计思想
- 注意点
- 对于存放大量元素到ArrayList 或 HashMap中时,建议提前初始化好容量避免扩容导致性能损耗
ArrayList和LinkedList的区别
ArrayList基于数组实现,LinkedList基于链表实现
ArryList适合随机查找,LinkedList适合增删,时间复杂度不同
ArrayList和LinkedList都是基于List接口实现的,LinkedList还实现了Deque接口,可以当做队列使用
快速失败(fast fail)
- 快速失败是Java基于多线程操作同一集合的保护机制
- 解决办法
- 可以使用Colletions.synchronizedList方法处理集合 或 在修改集合的地方用synchronized进行修饰,但是性能较低
- 使用CopyOnWriteArrayList代替List,采用读写分离的方式,写时会新拷贝一份数组,对新数组进行操作,操作完之后,再将引用指向新数组
CopyOnWriteArrayList底层实现原理
- CopyOnWriteArrayList是基于数组实现的,采用多写分离的方式来应对多线程的读写操作,当对内部数组进行操作时,会拷贝一份出来用于写,写操作时会进行加锁避免并发写入导致数据丢失,而读依旧在旧数组进行,当操作完之后,再将引用重新指向新数组完成整个操作
- CopyOnWriteArrayList适合读多写少的场景,但是比较吃内存,并且对于实时性要求很高场景不太适用因为可能读到旧数据
HashMap在JDK 7和JDK 8的区别
- HashMap在JDK 7采用的是 数组 + 链表 的方式,由于HashMap的key是基于hash值计算后放到对应的数组槽位的,可能会出现hash碰撞,所以在每个槽位采用链表头插法进行存放元素,如果容量不足会先扩容
- HashMap在JDK 8采用的是 数组 + 链表/红黑树 的方式,基于JDK 7的基础上为了解决链表过长导致查询效率降低以及头插法导致循环链表从而死循环造成CPU满载的问题,所以JDK 8采用了尾插法
HashMap的put流程
- 首先基于hash值计算得到数组下标
- 如果下标元素为空,在JDK 7下会将K-V封装成Entry对象,在JDK 8下会封装成Node对象,然后放到数组对应的槽位
- 如果下标元素不为空
- 在JDK 7下会先判断是否需要扩容,如果需要就优先扩容,然后采用头插法插入当前槽位的链表上,如果Key相同就更新Value
- 在JDK 8下不会先判断是否需要扩容,而是先判断节点是链表节点还是红黑树节点,最后再看扩容
- 如果是链表节点,将K-V封装成链表节点,采用尾插法插入当前槽位的链表上,如果Key相同就更新Value,当数组长度达到64且链表长度达到8就会将链表转化为红黑树来提升查询速度
- 如果是红黑树节点,将K-V封装成红黑树节点放到红黑树上,如果Key相同就更新Value,当红黑树节点数达到6时会退化成链表
HashMap的get流程
- 基于hash值计算得到数组的下标,然后遍历每个槽位(链表/红黑树)通过equals方法进行查找Key相同的元素
HashMap的扩容流程
- HashMap的扩容实质上就是数组的扩容,会重新开辟一份2倍原数组容量的数组将老数组的元素转移过来
- 在JDK 7下会将老数组中的元素重新进行hash值计算得到新数组的下标,然后放到对应数组的槽位的链表上,扩容的目的主要是为了解决链表过长的问题
- 在JDK 8下也会将老数组中元素的数组下标重新计算
- 如果是红黑树对应的槽位,会通过低位和高位(依旧是老位置的元素和新位置的元素)拆分成两个子链表存放到不同的槽位,当数组长度达到64且链表长度达到8时转化为红黑树
- 如果是链表对应的槽位,操作和JDK 7的一样
ConcurrentHashMap在JDK 7和JDK 8的区别
- 在JDK 7下,采用分段锁的方式来保证线程安全,内部维护了一个segment数组,每个segment都包含了一个HashEntry数组,相当于一个小的HashMap提供了get、put方法,并且segment都实现了ReentrantLock,put元素时通过ReentrantLock加锁
- 在JDK 8下,采用synchronized来保证线程安全的,内部维护一个Node数组,put元素时对Node节点进行加锁,相当于JDK 7减小了锁的粒度并且节省了内存
ConcurrentHashMap的put流程
详概
在JDK 7下
- 基于hash值计算得到数组对应的segment,如果当前segment不存在锁就进行加锁,然后在对应的HashEntry数组中进行put操作,操作完之后进行释放锁
在JDK 8下
基于hash值计算得到数组对应的槽位
- 判断数组是否已经初始化,如果没有初始化,会Thread.yield保证数组先初始化
如果数组的槽位不存在元素就通过 CAS + 自旋 保证元素插入成功
- 判断需要扩容,如果需要就进行扩容
如果在无需数组初始化、扩容而是存在hash冲突的情况下采用synchronized对Node节点加锁进行put,对应的槽位可能是链表或红黑树,如果链表长度达到8会转化为红黑树
简概
- 在JDK 7,基于segment数组,segment继承于ReentrantLock,每次put加锁,保证元素存入
- 在JDK 8,数组槽位为空时,采用 CAS + 自旋 保证存入,如果出现hash冲突,采用synchronized对Node加锁保证链表节点/红黑树节点存入
ConcurrentHashMap扩容机制
ConcurrentHashMap的扩容实质上也是数组的扩容,会重新开辟一份2倍原数组容量的数组将老数组的元素转移过来
在JDK 7下基于分段锁实现的,每个segment都维护了一个小HashMap,所以每个segment内部会进行扩容,操作和HashMap扩容一样
在JDK 8下倒序遍历数组的槽位,每经过一个槽位就通过synchronized对Node节点进行加锁,根据不同槽位类型(链表/红黑树)执行拷贝方法,对于红黑树会通过低位和高位(依旧是老位置的元素和新位置的元素)拆分成两个子链表存放到不同的槽位
ConcurrentHashMap的size方法
- 对于ConcurrentHashMap不能在put元素时进行加锁统计元素,这样会影响put的效率
- ConcurrentHashMap的size方法通过 baseCount + CountCell数组 的方式进行统计
- 多个线程会先通过CAS去进行baseCount累加,如果其中一个线程累加失败,会将其热点值的计算打散到CountCell数组l的各个槽位,每个线程对应一个槽位进行累加即可,最终结果 = baseCount + CountCell数组各个槽位值
- 站在size计算的角度上看是线程安全的,但是从全局角度看并不是,因为put方法和size方法之间的数据并不是一致的
多线程
多线程引入
- 考察重点
- 线程的使用以及线程池调优
线程、进程、协程的区别
- 进程是操作系统资源分配的最小单位
- 进程的通信方式
- 管道
- 管道分为匿名管道与命名管道,匿名管道用于父子进程通信,命名管道支持各种进程通信
- 信号
- 信号是软件层面对中断机制的模拟
- 消息队列
- 克服信号量有限的弊端,具有读、写能力
- 共享内存
- 通过多个进程读取公共内存进行通信,比如分布式锁
- socket
- 用于网络中不同机器的进程通信
- 管道
- 进程的通信方式
- 线程是操作系统任务调度和执行的最小单位,归属于进程
- 线程的通信方式
- 通过volatile来保证可见性,但是volatile常用于单写多读的场景
- 线程的通信方式
- 协程是存在于用户态轻量级线程,通过减少上下文切换频率提升并发性能
线程的生命周期
- 新建(new)
- new一个线程对象
- 可运行(runnable)
- 当线程调用start方法
- 运行(running)
- 处于可运行状态下的线程获取到时间片执行
- 阻塞(blocked)
- 线程因为某些原因进入阻塞状态,必须等待条件满足才会继续执行
- 终止(terminated)
- 线程执行结束或异常退出
什么是上下文切换
- 上下文切换主要是CPU从一个线程切换到另外一个线程,切换前会保存上一个线程的操作状态以便于下次继续使用
- 上下文切换发生于内核态,频繁的从用户态切换到内核态会耗费性能
什么是并发线程安全问题
- 多个线程操作共享资源但是由于缺少同步措施导致达不到预期结果
- 如何保证线程安全
- 可以使用JVM内置锁synchronzied、JUC提供的Lock
- 如何保证线程安全
run和start的区别
- run方法只是对象的方法,并不会去创建线程
- start方法会从用户态切换到内核态进行创建线程,创建完成后回调run方法
线程常用方法
- sleep
- 可以指定时间,让线程短暂休眠,通过interrupt可以打断休眠状态,如果抛出中断异常会清除中断标志
- sleep(0) 等价于 yield
- yield
- 上下文切换,会让出CPU资源
- join
- 会让join线程先执行,主线程暂停
- join底层会一直检查join线程是否存活,如果存活会让其他线程继续等待
- stop
- stop不推荐使用,因为会强制终止未执行完的线程
什么是线程中断
- 线程中断就是通过中断信号去告诉线程你可以停止,但是线程并不会立刻停止,如果收到了信号,可以自定义终止逻辑,相对于stop直接强制线程终止优雅很多
- 线程中断的方法
- interrupt
- 给线程加上中断标志,不会终止线程
- interrupted
- 判断是否有中断标志,有会清除
- isInterrupted
- 判断是否有中断标志,但不会清除
- interrupt
- 线程中断的方法
- 可以通过while循环判断isInterrupted和共享变量来处理中断,但是要注意如果有用sleep可以通过interrupt把中断标志位加上
线程的创建方式有哪些
- 线程创建的方式实际只有一种,都是通过new Thread创建,然后通过调用start方法去内核态创建线程,完成后回调run方法
- 通常说的创建方式
- 继承Thread类,重写run方法
- 实现Runnable接口,重写run方法
- 实现Callable接口,重写call方法,通过FutureTask创建来获取执行的结果
- 通过线程池创建
为什么要使用线程池
- 线程的创建和销毁都需要进行用户态和内核态的切换,当new Thread时会通过start去内核态创建线程,完成后回调run方法,使用线程池可以减少切换频率
- 线程池的使用方便管理线程,通过复用线程减少性能开销,以及提高执行任务的响应速度
线程池的核心参数
- 核心线程数、最大线程数、最大空闲时间、最大空闲时间单位、阻塞队列、线程工厂、拒绝策略
- 核心线程数 就是 线程池中保持活动状态的最小线程数,即使这些线程处于空闲状态,它们也不会被回收
- 最大线程数 就是 线程池中允许存在的最大线程数,当任务数量超过核心线程数且任务队列已满时,线程池会创建新的线程来处理任务,直到达到最大线程数
线程池的工作流程
- 当任务到达时,线程池首先会先创建核心线程去执行任务,如果核心线程创建满了,会先复用空闲的核心线程,如果核心线程都在跑任务,此时新的任务会被安排进入到阻塞队列中等待,如果阻塞队列也满了,就会创建非核心线程去执行任务,直到达到最大线程数,如果三者都满了,就会走拒绝策略
- 拒绝策略
- 拒绝执行,抛异常(AbortPoliy)
- 拒绝执行(DiscardPolicy)
- 使用主线程执行任务(CallerRunsPolicy)
- 丢弃队列中最老任务,尝试执行新任务(DiscardOldestPolicy)
- 拒绝策略
常见的阻塞队列
- ArrayBlockingQueue
- 基于环形数组实现的有界阻塞队列,不需指定初始容量,按照先进先出的顺存储元素,并且在队列满时会阻塞写入操作,直到有空间可用,同样在队列为空时,读取操作会被阻塞,直到有元素可用
- LinkedBlockingQueue
- 基链表实现的可选有界或无界塞队列,它与ArrayBlockingQueue类似,但是没有固定的容量限制,当队列为空时,读取操作会被阻塞,直到有元素可用,当队列满时,写入操作会被阻塞,直到有空间可用
- SynchronousQueue
- 一个特殊的阻塞队列,它不存储元素,它的每个插入操作必须等待另一个线程的对应移除操作,反之亦然,SynchronousQueue可以用于线程间的数据交换
- PriorityBlockingQueue
- 基于优先级堆实现的无界阻塞队列,它可以按照元素的优先级进行排序,并且在插入和移除操作时保持有序,当队列为空时,读取操作会被阻塞,直到有元素可用
- DelayQueue
- 基于优先级堆实现的延迟阻塞队列,它可以存储实现了Delayed接口的元素,每个元素都有一个过期时间,只有在元素过期后,才能从队列中取出。当队列为空时,读取操作会被阻塞,直到有元素过期
线程池的类型有哪些
- 常见的线程池
- SingleThreadPool
- 核心线程与最大线程数为1,非核心线程存活时间为0,采用LinkedBlockingQueue作为阻塞队列
- 比较适合单线程串行任务
- FixedThreadPool
- 核心线程与最大线程数为n,非核心线程存活时间为0,采用LinkedBlockingQueue作为阻塞队列
- 比较适合CPU密集型任务
- CachedThreadPool
- 核心线程数为0,最大线程数为n,非核心线程存活时间为60s,采用SynchronousQueue作为阻塞队列
- 比较适合传递性任务
- ScheduledThreadPool
- 用来定期执行任务
- SingleThreadPool
线程池调优
参数说明
- QPS是每秒的访问数
- TP99是99%网络请求需要的最低耗时
- Buffer是还能给到的线程数
调优思路
- 线程池调优主要要根据实际业务场景去进行调整,一般调整参数主要是核心线程数、最大线程数、阻塞队列以及拒绝策略
- 如何设置核心线程数,可以从响应时间和吞吐量敏感度考虑
- 对于C端场景需要考虑响应时间,对于批量调用下游服务的场景,可以合理配置核心线程数让任务尽早的执行完
- hystrix官网建议,核心线程数 = 下游任务QPS * 下游任务TP99 + 冗余线程数Buffer
- 对于B端场景需要考虑吞吐量,对于使用MQ、Job进行任务处理时,由于不考虑立刻响应,核心线程数够用即可,可以让尽可能多的任务阻塞在队列中,少占用CPU资源
- 对于C端场景需要考虑响应时间,对于批量调用下游服务的场景,可以合理配置核心线程数让任务尽早的执行完
- 对于最大线程数,通常情况下不要超过CPU核心数,因为超过了可能达不到提高系统的并发能力,反而会增加线程切换的开销,可以根据系统的负载情况和任务的处理时间,可以预估出系统需要的最大线程数
- 如何设置核心线程数,可以从响应时间和吞吐量敏感度考虑
- 另外对于线程池也需要考虑加监控(比如告警),监控任务执行时间、活跃线程数、队列中任务数等,用于上线评估,最好可以核心线程数可配置化来应对大促,对于拒绝策略可以根据业务、监控情况来设计
- 对于线程池的监控,执行任务的时候可以将当前线程池对象的参数打印出来,然后通过三方平台将日志解析出来,通过数据看板的形式来监控,如果发现问题,就及时告警出来
- 另外可以参考美团动态线程池的设计思想,通过JDK提供的几个核心的设置方法通过不同策略去进行动态调整
- 线程池调优主要要根据实际业务场景去进行调整,一般调整参数主要是核心线程数、最大线程数、阻塞队列以及拒绝策略
参数参考
- C端
- 10核心线程数、20最大线程数、最大空闲时间60s、采用2000容量的LinkedBlockingDeque
- B端MQ、Job
- 1核心线程数、2最大线程数、最大空闲时间60s,采用200000容量的LinkedBlockingDeque
- C端
ThreadLocal实现原理
- ThreadLocal主要是保存线程变量副本的,一般可以用来缓存用户信息、连接等,通过保证每个线程变量隔离来实现线程安全
- ThreadLocal会对线程维护一个以ThreadLocal作为Key的Map(ThreadLocalMap,Value对应的是当前线程变量的副本,Key是弱引用所以GC时会回收,但是Value是强引用,所以如果不手动清除,Value会一直霸占老年代空间导致内存泄露(ML)
- 对于ThreadLocal只能保证线程内部的通信,如果想要进行父子线程通信,可以使用InheritableThreadLocal,它实现原理是父线程在创建子线程时会将当前的变量副本拷贝一份给子线程,但是InheritableThreadLocal遇到线程池就不能完成父子通信了,因为线程池一旦创建完线程就会进行复用,所以可以使用TransmittableThreadLocal,它实现原理就是在线程池中不管线程是否是新建都会在调用时抓取父线程的上下文给子线程
- 另外ThreadLocal内部ThreadLocalMap使用线性探测法实现的哈希表,所以面临大量线程绑定数据时性能较低,所以Netty引出了FastThreadLocal,底层直接通过更大数组空间的开辟,通过数组索引进行定位,避免线性探测,采用用空间换时间,并且FastThreadLocal在任务执行完之后会对Value清除避免内存泄露
- 哈希冲突的解决方法
- 拉链法
- 红黑树
- 线性探测法
- 寻找最靠近的下一个空槽
- 哈希冲突的解决方法
锁机制
锁机制引入
- synchronized锁概念前提
偏向锁
- 偏向锁是为了减少无竞争情况下的解锁和重加锁操作而设计的
- 当一个线程首次访问同步代码块时,它会在对象头(MarkWord)中记录下当前线程的ID,这样下次这个线程再访问该同步代码块时,只需要检查对象头中的线程ID是否与当前线程ID相同,如果相同则无需进行CAS操作获取锁,从而减少了用户态和内核态之间的切换,提高了性能
轻量级锁
- 当多个线程竞争同一个锁时,偏向锁会升级为轻量级锁,轻量级锁不会使线程进入阻塞状态,而是通过自旋的方式等待锁释放
- 自旋就是线程在一个循环中不断检查锁是否被释放
- 如果锁被释放了,线程就可以立即获取锁并执行同步代码块
- 由于自旋不会使线程进入阻塞状态,因此避免了从用户态切换到内核态的开销
自适应自旋
- 在轻量级锁的基础上,JVM 还引入了自适应自旋技术,自适应自旋会根据之前线程自旋获取锁的成功率来动态调整自旋的次数
- 如果一个线程之前自旋获取锁的成功率很高,那么 JVM 就会增加它的自旋次数,反之则会减少自旋次数。这样可以避免不必要的自旋,进一步提高性能
重量级锁
- 当线程竞争非常激烈时,轻量级锁可能会升级为重量级锁
- 重量级锁会使线程进入阻塞状态,并放入锁等待队列中
- 当一个线程释放锁时,它会从锁等待队列中唤醒一个阻塞的线程来获取锁
- 由于线程进入阻塞状态需要切换到内核态,并涉及到线程调度等操作,因此重量级锁的性能开销较大
乐观锁和悲观锁的区别
- 乐观锁 就是每次拿数据时认为别人不会修改,所以不会加锁,但是修改数据时会判断有没有人去修改这条数据,一般情况下乐观锁会配合版本机制一起,比如AtomicStampedReference,主要是为了避免ABA问题
- ABA问题 就是当多个线程去修改共享变量时,某个线程将共享变量从A修改到B,又立即修改为A,但是其它线程并没有感知到,依旧修改成了B
- 悲观锁 就是每次拿数据时认为别人一定为修改,所以会加锁,下个人想来操作必须等待前面的人释放锁,比如synchronzied
死锁、活锁、饥饿的区别
- 死锁 就是两个及以上线程在执行过程因竞争资源而相互等待,无法自行解开
- 可以理解为循环等待
- 活锁 就是执行过程没有阻塞,但是因为某种情况一直重试,不断地改变状态,可以自行解开
- 可以理解为一人走一步,直到尽头才停止
- 饥饿 就是优先级高的线程一直霸占CPU,导致优先级低的线程无法执行而阻塞
Java死锁如何避免
- 避免使用嵌套锁,保证按顺序获取锁,并且锁设置超时时间以及注意死锁的检查,能在第一时间处理
常见的原子类
- 对于多线程操作变量自增的场景,我们可以使用AtomicInteger、AtomicLong,它们内部维护了一个共享资源state,通过自旋CAS来维护state,但是当线程过多时,会导致CPU满载,所以应对这个问题可以采用分散热点的方式将热点值分散出去,所以引出了LongAddr,它通过将共享资源分散到Cells数组的各个槽位,每个线程针对自己的槽位进行CAS操作即可,最终只要将各个槽位的值累加起来就是结果了,但是它不能实时获取值,另外JUC下还提供了LongAccumulator实现本质一样,可以进行自定义计算过程,对于CAS操作都会存在ABA问题,如果想处理该问题可以使用AtomicStampedReference,它每次修改时都会用stamped版本号进行判断
什么是CAS
- CAS属于乐观锁,就是将预期值与内存中的值进行比较,如果相等就修改,同样的与乐观锁一样会出现ABA问题,所以会配合版本机制一起使用
- ABA问题 就是当多个线程去修改共享变量时,某个线程将共享变量从A修改到B,又立即修改为A,但是其它线程并没有感知到,依旧修改成了B
- 对于解决ABA问题,可以参考AtomicStampedReference,每次写时都会用stamped版本号进行判断,或者MySQL中的乐观锁实现,表中额外维护一个版本号version,每次修改时去判断一下
- 为了保证数据能写成功,CAS会配合自旋一起使用,所以当线程过多时,会导致CPU满载,为了避免这个问题,可以参考synchronized的自适应自旋,自旋一定时间之后阻塞
- CAS从源码层面看,主要是通过并发的三大特性来考虑的
- 原子性
- 多个操作要么全部执行成功,要么全部执行失败
- CAS的原子性在单核处理器下依赖cmpxchgl指令保证,而在多核处理器下需要配合lock前缀修饰才能保证,也就是lock cmpxchgl
- lock前缀的三个特性
- 保证后续指令的原子性
- 缓存行锁定,线程去修改自己本地的变量副本时,会通过缓存行锁定将新值刷回主存
- 触发MESI协议,可以有效的解决可见性问题
- 基于写失效的缓存一致性协议
- lock前缀的三个特性
- 有序性
- 通过C++的volatile关键字保证的,它具备禁止重排序以及防止代码被优化的功能
- 可见性
- 基于lock前缀保证的,但是JUC包下的原子类不能保证get和set原子性,需要通过volatile修饰共享资源state来保证可见性
- 原子性
谈一下volatile
- volatile主要是围绕并发的三大特性展开的
- 原子性
- volatile只能保证单一变量的原子性,但是不能保证复合操作的原子性,比如对象创建,因为对象创建的过程为对象分配内存、初始化变量、变量指向内存,再比如自增,它分为getstatic、iadd、putstatic
- 在32位机器下,long和double类型都占用64字节,需要分高低位将值读取到CPU,所以需要加volatile关键字才能保证原子性,而64位机器不需要
- 可见性
- volatile修饰的变量只要被修改,其他线程能感知到
- volatile的可见性主要是依赖lock前缀以及触发MESI协议保证的
- lock前缀的三个特性
- 保证后续指令的原子性
- 缓存行锁定,线程去修改自己本地的变量副本时,会通过缓存行锁定将新值刷回主存
- 触发MESI协议,可以有效的解决可见性问题
- 基于写失效的缓存一致性协议
- MESI协议,M代表已修改、E代表独占、S代表共享、I代表失效
- 主存中维护了共享资源
- 线程A将共享资源读(read)出来,然后加载(load)到自己本地内存中赋值给变量副本,此时处于独占状态,线程A会使用(use)这个变量副本进行操作,当线程B也去操作这个共享资源时,此时处于共享状态
- 当线程A想去修改共享资源时,会将新值分配(assign)给自己本地内存的变量副本,然后处于已修改状态,然后刷(store)到主存中,最后回写(write)赋值给主存中的共享资源,此时线程B感知到共享资源变化了,然后将自己的变量副本失效,处于失效状态
- lock前缀的三个特性
- 有序性
- volatile的有序性在JVM层面通过内存屏障禁止指令重排序来保障的,但是底层本质是靠C++关键字volatile保证的,它具备禁止指令重排序以及防止代码被优化的功能
- 原子性
- 另外volatile存在伪共享问题
- voltile的伪共享主要是发生在多个volatile修饰的共享变量在同一个缓存行中,如果频繁缓存行失效会影响到别的共享变量,而缓存行占用64字节
- 对于这个问题的解决方案就是将volatile修饰的共享变量进行隔离,所以可以采用填充缓存行的方式,将每个volatile修饰的共享变量单独放在一个缓存行中
- 可以手动添加变量
- 可以使用Java提供的注解@Contended并配置相关参数
- volatile一般适合一个线程写多个线程读的场景,如果多线程频繁读、写操作使用锁比较合适
Monitor机制
- Monitor就是管程,管程就是管理共享变量以及对应操作的过程来支持并发
管程是如何保证同步和互斥的
- 互斥是交给编译器处
- 同步主要是通过条件队列和等待唤醒机制保证的
管程中的Mesa模型和Hoare模型
- Mesa模型 是后面的线程先执行,执行完之后去通知前面等待的线程自己去抢,类似于一个栈结构,属于非公平
- 在管程里维护着共享变量和共享变量的操作,管程外只有一个入口,当多个线程去竞争进入的时候,只有一个线程能能进入,其他没有抢到机会的线程会被安排到入口的同步队列中,进入管理内部的线程会与条件变量匹配,如果不匹配就会被安排进入条件队列中,而条件变量和条件队列是用来保证线程之间同步问题的
- Hoare模型 是先来的线程先执行,执行完再通知后面等待线程去执行,类似于队列结构,属于公平
谈一下synchronized
- synchronized是Java的内置锁,属于悲观锁,悲观锁就是每次读写的时候都会加锁,想要用必须等待前面的线程解锁
- 在JDK 6之前是基于Monitor机制实现的,依赖于底层互斥原语Mutex,属于重量级锁,性能较低
- 在JDK 6之后迫于JUC包的压力,对synchronzied进行了优化,引入了偏向锁、轻量级锁等机制,synchronized为了避免直接从用户态切换到内核态park线程,所以在单一线程的情况下默认开启偏向锁,在线程交替执行的时候会使用轻量级锁,偏向锁和轻量级锁操作都是基于用户态的对象MarkWord,也就表示这两种情况下加解锁不需要切换到内核态,性能几乎无损耗,如果竞争相对激烈,轻量级锁优先采用自适应自旋来避免park,如果实在不行,才会启动重量级锁,park让Mutex互斥量来进行,如今性能基本与JUC持平
- 对于synchronized只支持非公平,唤醒策略是关键
- 对于竞争的线程会存放在_cxq栈中,wait阻塞的线程会进入_WaitSet中,有候选资格的线程会从_cxq中或_WaitSet中挑选出来进入_EntryList,当线程释放锁时,默认策略是如果_EntryList为空,会将_cxq的所有元素转移到_EntryList,然后唤醒首个线程,另外其它策略还有_cxq插入到_EntryList头部或者尾部,而在_EntryList中的线程调用wait时,会重新安排进入到_WaitSet中,从整体策略上来看,可能导致线程获取锁的速度不均,某些线程可能更快获得锁,而其他线程可能需要等待更长时间,所以是非公平的
- synchronized的用法
- 对类对象加锁
- synchronized(A.class)
- synchronized static a()
- 对类实例对象加锁
- synchronized(this)
- synchronized(obj)
- synchornized a()
- 对类对象加锁
- synchonirzd如何保证并发的三大特性的
- 原子性
- 原子性是通过加锁和释放锁保证的
- 有序性、可见性
- 通过在加锁和解锁过程中使用内存屏障来确保共享变量的变化对所有线程都是可见的以及防止指令重排序
- 加锁
- load屏障 + acquire屏障
- 解锁
- store屏障 + release屏障
- 加锁
- 通过在加锁和解锁过程中使用内存屏障来确保共享变量的变化对所有线程都是可见的以及防止指令重排序
- 原子性
- 从字节码层面看synchronized
- 同步代码块
- 通过monitorenter和两个monitorexit实现的,对于第一个monitorexit处理正常返回,第二个monitorexit处理异常情况自动释放锁,所以synchronized不像Lock那样需要自己释放锁,不过也可以通过Unsafe类去手动控制synchronized,但是不推荐
- synchronized的可重入的实现
- synchronized会维护一个计数器,当monitorenter时计数器 +1,monitorexit时计数器 -1
- 同步方法
- 通过acc_synchronized访问标志实现
- 同步代码块
synchronized的锁变化
- 首先我们先判断有没有禁用偏向锁或者不满足延迟偏向条件,如果没有禁用偏向锁以及满足延迟偏向条件,那么就开始延迟偏向4s,然后创建锁对象,对于此时处于匿名偏向状态,当在同步代码块中时,会通过CAS将当前线程ID设置到锁对象的MarkWord中去
- 当处于偏向锁时,调用hashCode方法会撤销为无锁状态,因为偏向锁没有存放hashcode的位置,无锁状态可以存放
- 当处于偏向锁时,在同步代码块中调用notify方法会撤销为轻量级锁
- 当处于偏向锁时,在同步代码块中调用hashCode方法以及wait方法会撤销为重量级锁
- 当处于偏向锁时,偏向锁可能会重偏向为匿名偏向状态
- 当我们禁用偏向锁或者不满足延迟偏向条件时,先创建锁状态此时为无锁状态
- 当发生轻微竞争时,无锁会升级为轻量级锁,CAS修改锁对象中的MarkWord并且拷贝一份MarkWord到栈中的锁记录里
- 当处于轻量级锁时,如果发生激烈竞争会膨胀为重量级锁,创建Monitor对象,CAS修改MarkWord
- 当处于无锁状态时,如果碰到激烈竞争,会创建Monitor对象,CAS修改MarkWord
- 重量级锁可以解锁到无锁状态
- 轻量级锁也可以解锁到无锁状态
synchronized锁粗化、锁消除
- 锁粗化
- 比如锁对象出现在循环体中,反复加锁释放锁,即使没有出现线程竞争也会导致性能下降,当JVM检测到这种操作,会将锁的范围扩大到循环体外
- 锁消除
- 比如在单线程情况加锁,不存在竞争,JVM会清除锁
什么是AQS
- AQS(AbstractQueuedSynchronizer)是JUC包下的线程同步框架,JUC包下的许多工具都是基于AQS实现的,内部维护了一个volatile修饰的共享变量state和一个双向链表的同步队列,state可用作锁标识,没有抢到锁的线程就会被安排进入同步队列中等待,对于可重入性,可以对state进行加减来记录重入次数
JUC可重入锁ReentrantLock
ReentrantLock是基于AQS实现的独占锁(可重入锁),可以用来保证线程安全以及线程同步操作,相对于synchronized的功能更灵活
支持公平锁、非公平锁,默认非公平锁
锁的可控性,可以自行把控加解锁的时机,但是异常时记得在finally显示释放锁
ReentrantLock的源码流程
- ReentrantLock当多个线程去抢占锁state时,为了保证只有一个线程能获取到state,抢到锁的线程会先将state进行独占也就是state从置为1,没有抢到锁的线程会依次被安排进入同步队列中等待,当前面的线程释放锁时,会将state从1置为0,而在同步队列中的线程会被唤醒去竞争
JUC共享锁Semaphore
- Semaphore(信号量)是基于AQS实现的共享锁,一般用来限流操作,可以支持非公平和公平,但是不支持可重入
- Semaphore的源码流程
- Semaphore当多个线程去使用可用资源state时,用一次少一次,当可用资源为0时,后续的线程会被安排进入同步队列中等待,一旦可用资源充足,在同步队列中的线程会被持续唤醒出队去竞争可用资源
JUC闭锁CountDownLatch
- CountDownLatch(闭锁)是基于AQS实现的共享锁,可以用来模拟并发(让多个线程等待)、多线程处理数据合并结果(让单(主)线程等待,利用join也可)
- CountDownLatch的源码流程
- CountDownLatch内部会维护一个计数器state,当调用await时会判断state是否为0,如果不为0就会让线程阻塞等待,当调用countDown时,state计数减少,当state减少到0时会唤醒所有被await的线程
JUC循环屏障CyclicBarrier
- CyclicBarrier(循环屏障)是基于AQS和Condition条件队列实现的,比较适合多线程处理数据合并结果的场景,相对于CountDownLatch支持重算的功能,也就是当所有线程到达屏障之后会重设屏障来进行下一轮
- CyclicBarrier的源码流程
- CyclicBarrier内部维护一个计数器count和计数器副本parties,全局会加锁,当count大于0时(所有线程没有到达屏障时),线程会被安排进入条件队列中阻塞等待,当count为0时(所有线程都到达屏障时),此时会加锁然唤醒条件队列中的线程进入到同步队列中,然后通过计数器副本parties重置count,当所有线程进入同步队列时释放锁,最后在唤醒同步队列中的线程出队
JUC读写锁ReentrantReadWriteLock
- ReentrantReadWriteLock(读写锁)比较适合读多写少的场景
- ReentrantReadWriteLock的底层实现主要是将int类型变量的32位拆分为高低16位,高16位用来表示读锁,低16位来表示写锁,对于读锁属于共享锁,写锁属于独占锁,所以对于写锁的可重入次数只要通过低16位的移位操作就能完成,但是ReentrantReadWriteLock并没有这么处理,内部通过维护首个线程的可重入次数,而其余线程通过ThreadLocal进行存储
- 首个线程变量副本 firstReader 可重入次数 firstReaderHoldCount
- 其余线程 ThreadLocal 可重入次数 ThreadLocal # HoldCounter
- 对于读写锁的使用要注意写锁可以降级为读锁,但是读锁不能升级为写锁,所以一般要保证写锁在读锁之前(写锁、读锁、释放写锁、释放读锁),这样可以避免脏读(事务A读取到事务B尚未提交的数据)
- 读写锁属于悲观读(读时不能写),所以想要提升读取性能可以采用乐观读,比如StampedLock通过CAS来进行操作,但是操作较为复杂,不推荐
保证三个线程同时执行
- 可以使用CountDownLatch让三个线程等待然后一起执行
- 创建一个CountDownLatch
- 创建线程A,a.await
- 创建线程B,b.await
- 创建线程C,c.await
- countDown
- 可以使用CyclicBarrier让三个线程都到达屏障时再一起执行
public class 三个线程同时执行 { static CountDownLatch countDownLatch = new CountDownLatch(1); public static void main(String[] args) { new Thread(() -> { try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("线程A执行,执行时间:" + System.currentTimeMillis()); }).start(); new Thread(() -> { try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("线程B执行,执行时间:" + System.currentTimeMillis()); }).start(); new Thread(() -> { try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("线程C执行,执行时间:" + System.currentTimeMillis()); }).start(); countDownLatch.countDown(); } }
public class 三个线程同时执行 { static CyclicBarrier cyclicBarrier = new CyclicBarrier(3); public static void main(String[] args) { new Thread(() -> { try { cyclicBarrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } System.out.println("线程A执行,执行时间:" + System.currentTimeMillis()); }).start(); new Thread(() -> { try { cyclicBarrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } System.out.println("线程B执行,执行时间:" + System.currentTimeMillis()); }).start(); new Thread(() -> { try { cyclicBarrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } System.out.println("线程C执行,执行时间:" + System.currentTimeMillis()); }).start(); } }
并发情况下三个线程依次执行
- 可以使用volatile修饰共享变量对三个线程可见,通过计数的方式实现依次执行
- 可以使用CountDownLatch让单个线程等待,类似倒计时的方式
- 创建三个CountDownLatch分别是aLatch、bLatch、cLatch
- 创建线程A,aLatch.await、bLatch.countDown
- 创建线程B,bLatch.await、cLatch.countDown
- 创建线程C,cLatch.await
- aLatch.countDown
- 可以使用让三个线程顺序join
- 创建线程A
- 创建线程B,A.join
- 创建线程C,B.join
public class 并发情况下三个线程依次执行 { private static volatile int count = 1; public static void main(String[] args) { new Thread(() -> { if (count == 1) { System.out.println("A"); } count = 2; }).start(); new Thread(() -> { if (count == 2) { System.out.println("B"); } count = 3; }).start(); new Thread(() -> { if (count == 3) { System.out.println("C"); } }).start(); } }
public class 并发情况下三个线程依次执行 { public static void main(String[] args) { CountDownLatch aLatch = new CountDownLatch(1); CountDownLatch bLatch = new CountDownLatch(1); CountDownLatch cLatch = new CountDownLatch(1); new Thread(() -> { try { aLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("A"); bLatch.countDown(); }).start(); new Thread(() -> { try { bLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("B"); cLatch.countDown(); }).start(); new Thread(() -> { try { cLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("C"); }).start(); aLatch.countDown(); } }
public class 并发情况下三个线程依次执行 { public static void main(String[] args) throws InterruptedException { Thread a = new Thread(() -> { System.out.println("A"); }); Thread b = new Thread(() -> { try { a.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("B"); }); b.join(); Thread c = new Thread(() -> { try { b.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("C"); }); a.start(); b.start(); c.start(); } }
三个线程有序交错执行
可以使用ReentrantLock和3个Condition来实现
- 创建独占锁ReentrantLock、3个Condition分别是conditionA、conditionB、conditionC
- 创建线程A后加锁,conditionA.await、conditionC.await,释放锁
- 创建线程B后加锁,conditionA.singnal、conditionB.await、conditionC.signal,释放锁
- 创建线程C后加锁,conditionB.singnal,释放锁
public class 三个线程有序交错执行 { private static Lock lock = new ReentrantLock(); private static Condition conditionA = lock.newCondition(); private static Condition conditionB = lock.newCondition(); private static Condition conditionC = lock.newCondition(); public static void main(String[] args) { new Thread(() -> { System.out.print("A"); lock.lock(); try { conditionA.await(); conditionC.await(); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { lock.unlock(); } }).start(); new Thread(() -> { lock.lock(); try { conditionA.signal(); conditionB.await(); conditionC.signal(); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { lock.unlock(); } System.out.print("B"); }).start(); new Thread(() -> { lock.lock(); try { conditionB.signal(); } finally { lock.unlock(); } System.out.print("C"); }).start(); } }
两个线程交替打印奇偶数
互斥锁保证
- synchronized/ReentrantLock
- 创建两个线程,一个线程负责打印奇数,另一个线程打印偶数,两个线程竞争同一个对象锁,每次打印一个数字后释放锁,然后另一个线程拿到锁打印下一个数字
public class 两个线程交替打印奇偶数 { private static int count = 0; private static final Object lock = new Object(); public static void main(String[] args) { new Thread(() -> { while (count < 100) { synchronized (lock) { if ((count & 1) == 0) { System.out.println("偶数: " + count++); } } } }).start(); new Thread(() -> { while (count < 100) { synchronized (lock) { if ((count & 1) == 1) { System.out.println("奇数: " + count++); } } } }).start(); } }
JMM
谈一下Java的共享内存模型JMM
- JMM为了屏蔽各种硬件和系统差异来实现Java并发以及告诉我们如何实现线程通信主要围绕并发的三大特性展开的
- 对于多线程操作共享资源的流程
- 线程从主存将共享资源读(read)出来,然后加载(load)到自己本地内存赋值给变量副本,然后线程会使用(use)这个变量副本进行操作,当先需要修改共享资源的时候,首先会分配(assign)新值给变量副本,然后将新值刷(store)到主存中,最后回写(write)赋值给主存中的共享资源
- 从操作流程上,其他线程并不能立刻感知到共享资源的变化,需要等到某个时间点才能发生,比如上下文切换,所以JMM并不能保证共享资源的实时性,所以需要基于写失效的缓存一致性协议MESI来保证实时可见性
- MESI协议,M代表已修改、E代表独占、S代表共享、I代表失效
- 主存中维护了共享资源
- 线程A将共享资源读(read)出来,然后加载(load)到自己本地内存中赋值给变量副本,此时处于独占状态,线程A会使用(use)这个变量副本进行操作,当线程B也去操作这个共享资源时,此时处于共享状态
- 当线程A想去修改共享资源时,会将新值分配(assign)给自己本地内存的变量副本,然后处于已修改状态,然后刷(store)到主存中,最后回写(write)赋值给主存中的共享资源,此时线程B感知到共享资源变化了,然后将自己的变量副本失效,处于失效状态
- MESI协议,M代表已修改、E代表独占、S代表共享、I代表失效
JVM
JVM引入
类加载器有哪些
- 引导类加载器(BootStrap)
- 加载jre/lib目录下的包
- 拓展类加载器(Ext)
- 加载jre/lib/ext目录下的包
- 应用程序类加载器(App)
- 加载用户路径下的包
- 自定义类加载器
- 加载自定义路径下的包
什么是双亲委派机制
- 双亲委派机制是一个自上而下的类加载过程,首先会委托父加载器去加载目标类,如果能找到目标类就加载,如果找不到就继续委托它的父加载器去加载,如果父加载器都加载不了,就自己去加载
为什么要有双亲委派机制
- 首先自上而下的过程可以避免重复类加载
- 可以实现安全机制防止核心类被篡改
如何打破双亲委派机制
- 可以继承ClassLoader,重写loadClass以及findClass方法
- loadClass主要使用来加载类的,findClass主要是用来寻找目标类的
Tomcat为什么要打破双亲委派机制
- 从隔离性上讲,相同类库的不同版本需要进行隔离防止冲突,另外Tomcat自己的类库也需要隔离
- 从共享性上讲,保证相同版本之间类库可以进行共享,避免重复加载的性能开销
- 从热部署上讲,每个jsp对应一个类加载器,方便实现热部署
- 实现热部署的方式可以开启一个后台线程实时监听指定路径下jsp文件的变动,如果有变动就会将原先的类加载器置为null,等待GC去回收,然后使用新的类加载器去加载
JVM类加载过程
- 类加载过程 主要是 加载、验证、准备、解析、初始化
- 加载
- 将字节码文件加载到运行时数据区的方法区中
- 验证
- 对字节码的规范进行一些校验,比如魔数。主次版本号等
- 准备
- 将类中的静态变量分配内存并且赋默认值,比如boolean类型的赋false
- final修饰的static变量在编译时已经赋预期值
- 将类中的静态变量分配内存并且赋默认值,比如boolean类型的赋false
- 解析
- 将符号引用转变为直接引用
- 符号引用是在字节码文件中以字符串形式表示的
- 直接引用则是指向内存中的实际对象或函数的指针
- 将符号引用转变为直接引用
- 初始化
- 对类中的静态变量赋预期值以及执行静态代码块
- 加载
JVM的重要组成部分和作用
- JVM的组成部分主要分为两个子系统和两个组件
- 两个子系统 就是 类加载子系统 和 字节码执行引擎
- 类加载子系统 主要是负责将字节码文件加载到JVM内存中去
- 字节码执行引擎 主要是负责GC、执行字节码文件中的指令以及修改程序计数器中的值等
- 两个组件 就是 本地接口 和 运行时数据区
- 本地接口 主要是与本地库打交道,都知道JVM是C++写的,所以需要一些本地库的支持
- 运行时数据区 就是我们常说的JVM内存,主要分为线程共享和线程独享两大块
- 线程共享 有 堆 和 方法区
- 堆
- 一般new出来的对象都会存放在堆中
- 方法区(元空间)
- 方法区在JDK 7时处于堆中,在JDK 8中,方法区叫元空间,从堆中移除而放到直接内存中,主要是因为直接内存对IO操作具有更高的性能并且减少中间步骤的开销(虚拟内存到直接内存的重复开销)
- 堆
- 线程独享 有 栈、本地方法栈 和程序计数器
- 栈
- 栈主要是存放栈帧的,一个方法对应一个栈帧,栈帧主要分为局部变量表、操作数栈、方法出口、动态链接
- 局部变量表
- 局部变量表 类似于一个table,存放编译期间的变量或对象的内存地址
- 在编译期间,this会作为隐式参数放在局部变量表的第一位
- 局部变量表 类似于一个table,存放编译期间的变量或对象的内存地址
- 操作数栈
- 操作数栈 主要是用来进行一些操作数运算,运算完之后再赋值到局部变量表
- 方法出口
- 方法出口 就是通过它进行定位方法被调用的位置
- 动态链接
- 动态链接 就是引用类型变量与堆中实际对象的关联关系,主要是用来定位堆中实际对象的
- 局部变量表
- 栈主要是存放栈帧的,一个方法对应一个栈帧,栈帧主要分为局部变量表、操作数栈、方法出口、动态链接
- 本地方法栈 与 栈基本一致,只不过是用来处理本地方法
- 程序计数器 相当于代码执行位置的标识
- 栈
- 线程共享 有 堆 和 方法区
- 两个子系统 就是 类加载子系统 和 字节码执行引擎
- 整体流程就是 类加载子系统会将字节码文件加载到JVM内存中,而字节码文件不能直接被操作系统识别,所以需要通过字节码执行引擎去解析成操作系统能识别的指令,然后将指令交给CPU去执行,而这个过程需要本地接口与本地库的交互
对象的创建过程
- 对象的创建过程 是 类加载检查、分配内存、初始化零值、设置对象头、执行<init>方法
- 类加载检查
- 类加载检查 就是当JVM遇到一个new指令时,会先检查这个类的符号引用在不在常量池中,如果能找到就会进行类加载检查,如果没有就会进行类加载过程
- 分配内存
- 分配内存 就是经过类加载检查之后,对象占用的内存也能确定下来,会在堆上划分一块内存空间给对象使用,划分内存的方式主要有指针碰撞和空闲列表
- 指针碰撞
- 指针碰撞 就是针对于内存规整的情况而言的,会将用过的内存和空闲的内存用指针中间用指针隔开,当需要分配内存时,只要将指针往空闲区域移动就行
- 空闲列表
- 空闲列表 就是针对于内存不规整的情况而言的,会用一个列表记录空闲的内存地址,当需要分配内存时,会从列表中找一块能存下的,然后更新列表记录
- 指针碰撞
- JVM一般情况下采用CAS + 失败重试进行分配内存,为了避免并发分配竞争问题,每个线程在JVM启动时都会在Eden区分配一块TLAB内存(线程本地分配缓冲)用于分配内存,如果TLAB内存不足只能在Eden区进行分配
- 分配内存 就是经过类加载检查之后,对象占用的内存也能确定下来,会在堆上划分一块内存空间给对象使用,划分内存的方式主要有指针碰撞和空闲列表
- 初始化零值
- 初始化零值 就是当对象分配内存之后,会给分配到的内存空间赋零值
- 设置对象头
- 设置对象头 就是对 对象头 设置相关参数
- 对象的布局 主要有 对象头、实例数据和对齐填充
- 对象头 的布局主要有MarkWord、Klass Pointer、数组长度
- MarkWord 主要是存放运行时数据,比如hash、GC分代年龄等,32位占用4字节,64位占用8字节
- Klass Pointer 就是指向类元数据信息的指针,32位战友4字节,64位占用8字节,开启指针压缩占用4字节
- 数组长度 是只有数组对象才会有,占用4字节
- 实例数据 就是存放类的属性信息
- 对齐填充 就是保证对象占用空间位8的倍数,通过内存对齐填充后,CPU的内存访问效率会大大提升以及规避操作系统之间的差异
- 对象头 的布局主要有MarkWord、Klass Pointer、数组长度
- 对象的布局 主要有 对象头、实例数据和对齐填充
- 通过 对象的布局 可以计算出Object对象占用16字节
- calc = (Mark Word-64位) + (Klass Pointer + 开启指针压缩) + 对齐填充 = 8 byte + 4 byte + 4byte = 16 byte
- calc = (Mark Word-64位) + (Klass Pointer + 关闭指针压缩) = 8 byte + 8 byte = 16 byte
- 设置对象头 就是对 对象头 设置相关参数
- 执行<init>方法
- 设置完对象头之后,会执行<init>方法按照预期值对内存空间进行初始化,然后执行构造方法
- 类加载检查
什么是栈上分配
- 一般new出来的对象都是放在堆上的,当对象没有被引用时会被GC回收,当对象创建过多时,GC会有一定压力,所以为了避免临时对象直接分配到堆上,JVM可以通过逃逸分析,将不被外部引用的对象不进行创建,采用标量替换的方式直接分配到栈上,这样可以随着栈帧的出栈而销毁,减轻GC压力
- 逃逸分析 可以理解为 对象被return就代表逃逸
- 标量替换 可以理解为 将对象的成员变量拆出来,标识是哪个对象的
垃圾收集算法有哪些
对于哪些是垃圾的算法(what)
- 引用计数法
- 每个对象都会维护一个计数器,当有被引用的时候,计数器会 +1,不被引用时会减 -1,但是无法处理循环引用的问题
- 可达性分析算法
- 从GC Root出发,通过引用对象图,标记出所有非垃圾对象,清除未标记的
- GC Root可以是 静态属性引用的对象、常量引用的对象、被同步锁(synchronized)持有的对象等,比如Spring中的Bean对象
- 从GC Root出发,通过引用对象图,标记出所有非垃圾对象,清除未标记的
- 引用计数法
对于垃圾怎么清理(how)
标记复制算法
- 标记复制算法 就是将内存分为大小相等的两块,一块用来存,一块空着,当进行GC时,将存活的对象移到空的那块,清空用过的那块
- 标记复制算法比较浪费内存,一般用在年轻代
标记清除算法
- 标记清除算法 就是标记出非垃圾对象,清除未标记的对象
- 标记清除算法 当内存空间较大时,标记会非常耗时,并且会出现大量内存碎片,一般用在老年代
标记整理算法
- 标记整理算法 就是将存活的对象存放在一端,清除端以外的对象
- 标记整理算法 可以解决内存碎片的问题,但是会涉及大量对象的移动,一般用在老年代
分代理论
- 分代理论 就是根据不同分代的特点选择合适的垃圾收集器
什么是STW
- STW 就是stop the world,会暂停所有用户线程来进行GC,对于暂停的时间与垃圾收集器有关
- 年轻代的Minor GC(Young GC) 几乎无感觉
- 老年代的Full GC可能会感觉到卡顿一下
为什么要进行STW
- STW 主要是为了更好的维护引用对象图来进行GC,如果不进行STW,用户线程还在进行着,可能一些对象还没来得及被标记就被GC了,这个是比较严重的问题
对象的分配策略
- 对象优先在Eden区进行分配
- 新创建的对象会被分配到Eden区,当Eden区内存不足时,会触发Minor GC
- 每次Minor GC后,存活的对象会被移动到Survivor区的一块,在下一次Minor GC时,Eden区存活的对象 以及 Survivor区存活的对象 会被移动到Survivor区的另外一块,同时对象的分代年龄会 +1,当对象分代年龄达到一定次数,会被安排进入到老年代,老年代使用空间达到阈值会进行Full GC,如果回收不动就会内存溢出(OOM)
- 分代年龄默认15次,CMS为6次
- Mark Word中 存放分代年龄占用4字节,所以最大是15次
- 大对象直接进入老年代
- 复制算法 对大对象的来回复制会带来性能开销,所以JVM可以设置一个阈值,当新创建的对象大小超过这个阈值时,会直接分配到老年代
- 长期存活的对象进入老年代
- 除了基于分代年龄的对象晋升策略外,JVM还引入了一个 动态年龄判断机制 来避免频繁的Minor GC
- 对象动态年龄判断机制
- 经过Minor GC后,在Survivor区存活的前N代的对象的内存总和超过Survivor区内存的一半时,会直接将N代以及N代以上的对象直接移动到老年代,这样做的目的是尽可能将长期存活的对象直接进入到老年代,避免频繁的GC
- 对象动态年龄判断机制
- 除了基于分代年龄的对象晋升策略外,JVM还引入了一个 动态年龄判断机制 来避免频繁的Minor GC
- 老年代空间分配担配机制
- 年轻代每次Minor GC之前会计算老年代剩余可用空间,如果老年代剩余可用空间小于年轻代里所有对象的内存就会触发Minor GC,如果不小于就会看有没有配置担保参数,如果配置了担保参数,就会看老年代剩余可用空间是否小于历史上每一次Minor GC进入老年代的对象的内存大小,如果小于就会触发Full GC,如果不小于就会触发Minor GC,倘若前面没有担保参数就会触发Ful lGC
如何判断对象是否存活/如何判断对象可以被回收
- 引用计数法
- 每个对象都会维护一个计数器,当有被引用的时候,计数器会 +1,不被引用时会 -1,但是无法处理循环引用的问题
- 可达性分析算法
- 从GC Root出发,通过引用对象图,标记出所有非垃圾对象,清除未标记的
- GC Root可以是 静态属性引用的对象、常量引用的对象、被同步锁(synchronized)持有的对象等,比如Spring中的Bean对象
- 从GC Root出发,通过引用对象图,标记出所有非垃圾对象,清除未标记的
什么情况下类会被卸载
- 这个类的所有对象实例都回收了
- 这个类的java.lang.Class对象不被外部引用了,也就是不能通过反射进行访问这个类了
- 这个类的类加载器被回收了
引用类型有哪几种
- 强引用
- 一般new出来的对象就是强引用,即使内存不足,GC都不会回收
- 软引用
- 通过SoftReference包装的类,在内存溢出(OOM)前会被回收,可以用作高速缓存使用
- 弱引用
- 通过WeakRefrence包装的,无论内存是否充足,GC都会回收
- 虚引用
- 幽灵引用一般不使用
常见的垃圾收集器
- Serial 、Serial Old
- Serial 和 Serial Old 是 单线程垃圾收集器,在GC时,只允许一个线程进行
- Serial 用在年轻代采用的是 复制算法、Serial Old 用在老年代采用的是 标记整理算法
- 在单核处理器的情况下,简单高效,但是多核处理器下无法发挥多核的性能不推荐使用,适合 100M以内 内存
- Parallel、Parallel Old
- Parallel 和 Parallel Old 是 多线程垃圾收集器,是serial系列的多线程版本
- Parallel 用在年轻代采用的是 复制算法,Parallel Old采用的是 标记整理算法
- 关注点在于吞吐量,比较适合CPU密集型场景,一般 4G以下 内存推荐使用
- ParNew 、CMS
- ParNew 与 Parallel类似,只是为了配合 CMS 才出现的
- ParNew 用在年轻代采用的是 复制算法, CMS 用在老年代采用的是 标记清除算法
- CMS关注点是最大停顿时间,也就是用户的体验度,比较适合 4~8G 内存的情况使用
- CMS的运作步骤
- 初始标记
- STW,从GC Root出发,只标记直接引用对象(不包含内部成员变量相关的间接引用对象)
- 并发标记
- 从GC Root的直接引用对象出发,遍历整个对象图进行标记,耗时较长,由于用户线程和GC线程都在运行着,所以会有多标、漏标的问题
- 多标
- 多标 就是本应该是垃圾对象,但是由于用户线程还在运行,所以没来及去清除标记
- 标记的是非垃圾对象
- 多标 就是本应该是垃圾对象,但是由于用户线程还在运行,所以没来及去清除标记
- 漏标
- 漏标 就是 新来的对象引用了GC Root链上的对象,但是由于用户线程还在运行,没来得及标记为非垃圾,被GC误清除
- 漏标 的处理方案主要是 三色标记算法,主要分为 增量更新 和 原始快照
- 三色标记主要是分为三种颜色,分别是黑色、白色、灰色
- 黑色对象 表示 当前对象的引用对象图都扫描完了
- 灰色对象 表示 当前对象的引用对象图只扫描了一部分
- 白色对象 表示 当前对象的引用对象图没扫描
- 增量更新 是通过 记录下黑色对象新增的白色对象引用关系,将黑色对象回退到灰色对象,重新深度扫描一次
- 原始快照 是通过 记录下灰色对象删除的白色对象的引用关系,以灰色对象为根简单扫描一下,将白色对象标记为黑色对象,当作浮动垃圾处理,等待下一轮GC
- 浮动垃圾
- 浮动垃圾 就是在 并发标记 和 并发清理 阶段产生的垃圾,对GC最终效果影响不大,只要等待下一轮GC处理就行
- 浮动垃圾
- 三色标记主要是分为三种颜色,分别是黑色、白色、灰色
- 多标
- 从GC Root的直接引用对象出发,遍历整个对象图进行标记,耗时较长,由于用户线程和GC线程都在运行着,所以会有多标、漏标的问题
- 重新标记
- STW,对 并发标记 过程中产生状态改变的对象进行修正,这里对于 漏标 的问题采用的是 三色标记算法 中的 增量更新 来做的重新标记
- 并发清理
- 对未标记的对象进行清理,这里因为没有进行STW,所以对于新增对象会被标记为黑色对象
- 并发重置
- 将对象的标记位进行重置,进行下一轮GC
- 初始标记
- G1
- G1 跟以往的垃圾收集器有点不同,它对于分代的概念不是物理分代而是逻辑分代了,它将堆默认分成了2048个region,每个region在每次GC结束后都会有不同的角色,并且相对于以往的大对象,它也有专门的一个 Humogous区 来存放,倘若一个 Humogous区 放不下大对象会用连续的几个region来存放,对于G1从region来看采用的是复制算法,但是从整体上来看是标记整理算法,G1 和 CMS 的出发点一样,但是 G1 比 CMS 更加先进,可控的最大停顿时间,一般建议需要500ms以内停顿或者内存超过8G的可以去使用
- G1的运作步骤
- 初始标记 和 CMS的初始标记 一样
- 并发标记 和 CMS的并发标记 一样
- 最终标记 和 CMS的重新标记 一样,但是这里对于 漏标 的对象采用 原始快照 的方式进行处理
- 筛选回收
- STW对未标记的region进行清理,此时会将每个region区域回收价值和成本进行排序,根据用户的预期停顿时间进行比较来选择合适的回收方式,此时并不会把所有垃圾对象进行回收,因为考虑到预期停顿时间,所以只会回收接近于这个时间的region,剩余的region等待下一轮GC进行回收
JVM如何处理跨代引用
- 跨代引用处理涉及到卡页和卡表的概念
- 卡页,JVM将老年代堆分成以512kb的一块块内存空间,每块内存会存很多对象
- 卡表,JVM会在年轻代的Eden区维护一个bitmap,用来记录卡页是否存在跨代引用
- 倘若1号卡页存在跨代引用,那么就会将bimap的偏移量为1的位置设置为1,表示存在跨代引用关系,此时会将1号卡页的所有对象加入到GC Root的扫描范围中去
JVM常用调参
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
- -Xms
- 设置初始堆内存大小
- -Xmn
- 设置最大堆内存大小
- -Xss
- 设置每个线程的栈的大小
- -XX:MetaspaceSize
- 设置元空间的初始大小
- -XX:MaxMetaspaceSize
- 设置元空间的最大大小
- -XX:SurvivorRatio
- Eden区的大小是一个Survivor区的N倍(默认8倍)
- -XX:MaxTenuringThreshold
- 设置对象在Survivor区中的最大存活次数
- -XX:PretenureSizeThreshold
- 设置对象直接进入老年代的阈值(大对象)
JVM故障分析
- 对于 还在正常运行的系统
- 可以使用jmap去查看JVM中各个区域的使用情况
- 假如有内存激增的情况,可以定位到哪个地方对象创建比较多
- 可以使用jstack去查看线程的运行情况
- 比如有哪些线程阻塞、是否产生了死锁
- 可以使用jstat查看垃圾回收的情况
- 假如Full GC比较频繁,就得进行GC调优了
- 对于各个命令的结果,可以直接通过jvisualvm工具查看分析
- 观察Full GC的频率,如果Full GC频繁但是没有出现OOM,那么表示Full GC实际上回收了很多对象,所以这些对象最好是能在MinorGC的时候进行回收,避免直接进入老年代,对于这种情况,要考虑存活时间不长的对象是不是大对象,对于大对象尽早的让它们进入老年代,避免由于复制算法带来的性能问题,尝试加大年轻代的大小,让朝生夕死的对象在Minor GC的时候被回收,如果Full GC减少,说明调优有效
- 同时观察占用CPU最多的线程,定位具体的方法,优化这个方法的执行,看是否能避免某些对象的过多创建,从而节省内存
- 可以使用jmap去查看JVM中各个区域的使用情况
- 对于 已经发生OOM的系统
- 一般生产系统中都会配置发生OOM的时候生成dump文件,我们可以通过jvisualvm进行分析,找出dump文件异常的地方比如异常的线程、对象,定位到具体的代码位置,然后进行详细分析
- 对于 CPU飙升 或 死锁
- 通过top去定位CPU占用最多的进程
- 通过占用率最高的进程查看之下的所有线程CPU占用率
- 通过jstack找到飙升CPU对应线程ID下的堆栈信息
- 查看对应的堆栈信息找出可能存在问题的代码
- 对于 内存飙升
- 通过jmap去找出创建最多的实例
Arthas常用指令
- trace 方法内部调用路径,并输出方法路径上的每个节点上耗时
- trace linc.cool.Main main -n 5 --skipJDKMethod false
- watch 函数执行数据观测
- watch linc.cool.Main main ‘{params,returnObj,throwExp}’ -n 5 -x 3
内存溢出和内存泄露的区别
- 内存溢出(OOM)
- 内存泄露达到一定程度会导致内存溢出,但是内存溢出也可能是大对象导致的
- 内存泄露(ML)
- 对象无法得到及时回收,到最后持续占用内存空间,造成内存空间的浪费,内存泄露一般是强引用才会出现问题
常量池的分类
- Class常量池
- 存放编译期间的字面量以及符号引用
- 运行时常量池
- 在JVM运行期间,会将字符串常量池的静态数据加载到运行时数据区的方法区中
- 全局常量池
- 存放的是字符串的引用值,JVM只有一份
- 字符串常量池
- 类似于缓存池,会缓存字符串
- 在Java中,创建字符串对象的方式
- 通过字符串常量进行创建
- 会尝试在字符串常量池中查找,如果找到就返回其引用,否则在常量池中创建并返回引用
- 通过new字符串对象进行创建
- 无论字符串常量池中是否存在,都会在堆内存中创建一个新的字符串对象,并返回对它的引用
- 通过字符串常量进行创建
- 在Java中,创建字符串对象的方式
- 类似于缓存池,会缓存字符串
类实例化的顺序
- 静态变量->静态代码块->普通变量->普通代码块->构造方法
- 具体流程
- 静态变量
- 在类加载到JVM时会被初始化一次
- 静态代码块
- 在类加载到JVM时会被初始化一次
- 普通变量
- 每次类对象实例化时会初始化
- 普通代码块
- 每次类对象实例化时,构造方法之前
- 构造方法
- 每次类对象实例化时,都会通过构造方法初始化对象
- 静态变量
- 具体流程
MySQL
MySQL引入
事务的四大特性(ACID)
- 原子性
- 要么全部成功,要么全部失败
- 一致性
- 数据在事务的开始到结束的过程中要保持一致
- 隔离性
- 每个事务要互相隔离
- 持久性
- 事务完成后数据会被持久化下来
脏写、脏读、不可重复读、幻读
- 脏写
- 操作同一数据时,一个事务的修改被另外一个事务的修改覆盖
- 脏读
- 一个事务读取了另一个事务尚未提交的数据时
- 不可重复读
- 同一事务下,由于其他事务的修改导致同一数据被读取两次的结果不一致
- 幻读
- 一个事务读取到另外一个事务已提交的新增数据
事务的隔离级别
常见的索引结构
- 二叉树
- 每个节点最多只有两个子节点
- 左节点值小于根值,右节点大于根值
- 左节点值小于右节点值
- 红黑树
- 节点非红即黑,根为黑,红的子节点为黑,叶子节点都为黑
- 任一节点到达每个叶子节点相同路径包含相同数量的黑节点
- 哈希表
- 本质就是一个数组,通过将key进行hash计算出数组的下标进行存放元素,当遇到hash碰撞的时候可以采用链表进行存储
- 对于拿元素的时候,将key进行hash计算定位到元素存放的槽位然后去查找
- 哈希表比较适合=、in等值查询,效率很高,但是不支持范围查询而且会占用更多的空间
- B-Tree
- 叶子节点具有相同深度,叶子节点的指针为空
- 所有索引元素不重复
- 节点是从左到右递增排列
- B+Tree
- 叶子节点之间具有指针连接,提升区间访问效率
- 非叶子节点不存储数据只存储索引并且冗余上一层节点,可以存放更多索引
- 叶子节点包含所有索引字段
- 节点是从左到右递增排列
MySQL的索引数据结构
- MySQL的索引数据结构通常采用的是 B+Tree
- B+Tree的特点
- 叶子节点之间具有指针连接,可以用来提升区间访问效率
- 非叶子节点只存储索引不存储数据,这样可以放更多的索引来减少树的高度,提升查询效率,其中会冗余上一层节点
- 节点排列是有序的,所以内部采用二分查找更高效
- B+Tree的特点
- 对于MySQL不采用二叉树、红黑树、B-Tree以及哈希表作为索引数据结构
- 对于树形结构无疑就是树的高度问题,树太高,会导致频繁IO操作,导致性能降低
- 另外B-Tree的节点不能存放更多的索引并且不能支持区间访问所以也不会选择
- 而哈希表由于通过hash运算来进行定位数据,所以只适合in、=相关的场景,不支持范围查询
为什么MySQL要用B+Tree而不用跳表
- 跳表 主要是通过在有序链表上增加多级索引来实现快速查找的,但是数据分布不均匀,导致读取效率较低,并且IO访问频率不可控,主要是因为节点到节点之间的路劲长度不可控
- B+Tree 相对于 跳表 而言,数据分布较集中,方便读取,并且树的高度是固定的,所以磁盘IO的频率也可控,另外区间访问效率比跳表的效率高
聚簇索引和非聚簇索引
- 聚簇索引 就是叶子节点存储索引和数据,InnoDB就属于聚簇索引
- 非聚索引 就是叶子节点存储索引和磁盘地址,通过磁盘地址找到存储的数据,需要回表操作所以效率比聚簇索引慢,MylSAM就属于非聚簇索引
InnoDB和MylSAM的区别
- InnoDB支持事务,支持事务的四种隔离级别,MylSAM不支持事务,但是每次查询都是原子的
- InnoDB支持外键,MylSAM不支持外键
- InnoDB属于聚簇索引,MylSAM属于非聚簇索引
- InnoDB支持行锁粒度更小,但是可能因为范围而锁表,MylSAM支持表锁,每次操作都会锁表
- InnoDB不存储总行数,MylSAM存储总行数
- InnoDB适合增删改场景,MylSAM适合大量的查询
为什么非主键索引的叶子节点的数据存储的是主键ID
- 主要是为了数据一致性以及维护成本考虑,如果二级索引树存储的是完整数据,就需要维护两棵树的数据(主键索引 + 二级索引),所以二级索引叶子节点存储的数据为主键ID,可以通过回表的形式,减少维护成本
为什么建议InnoDB必须设置主键
- InnoDB会优先使用主键作为索引列,如果没有设置主键,就会从数据列中存在唯一值的列作为索引列,如果都没有,就会自己单独维护一个隐藏列作为索引列,这样是比较耗费性能的
为什么推荐使用自增整型作为主键而不是UUID
- 整型占用的磁盘空间较少,方便读取,另外自增主键可以吻合索引的有序性,避免叶子节点频繁断裂来维护有序性
MySQL的执行计划怎么看
- MySQL的执行计划可以通过explain关键字去查看,主要看type、key_len、extra
- type 会显示关联类型,一般优化到range就可以了
- system > const > eq_ref > ref > range > index > all
- key_len 会显示索引列占用的字节数,可以辅助判断走了哪些索引列
- extra 会显示解析查询的额外信息,比如临时表、文件排序等
- type 会显示关联类型,一般优化到range就可以了
- 对于索引优化,当遇到type为all时,说明我们需要添加索引或优化索引,对于整个优化过程要多次使用explain去分析
索引什么时候会失效
- 对于组合索引 没有走 左前缀原则 会导致索引失效
- 进行like模糊查询时,%放在索引字段之前走不了索引下推会导致索引失效,倘若数据量过大,索引下推会失效
- 查询条件的类型为字符串时,没有加单引号,可能因为类型不同导致索引失效
- 对索引列进行计算会导致索引失效
- 查询条件使用or连接会导致索引失效
- 判断索引列不等于某个值时会导致索引失效
索引优化原则
注意点
不要基于使用频率较低的列加索引
组合索引列、排序要遵循 左前缀原则
where 和 order by冲突时优先使用where,能用where过滤就不要使用having
- 不要对索引列进行运算,比如使用函数
- MySQL 8/Oracle支持函数索引,但是数据量过大时,索引列维护成本较高
- 不要对索引列进行运算,比如使用函数
不要用or连接索引列
- 对于like的使用,尽量使用右模糊匹配,让索引可以走索引下推
- %A%、%A不走索引
- A%一般情况下可以走索引下推,但是数据量过大时会导致失效
- 对于like的使用,尽量使用右模糊匹配,让索引可以走索引下推
索引列判空,比如is null、is not null不走索引
join要做到小表驱动大表
- left join 左小右大
- right join 右小左大
- inner join 优化器会处理
in、exists要做到小表驱动大表
- in 适合 子表小于主表的,exists反之
索引列建议都设置为not null,可以节省1字节
对于获取总数count优先使用count(*) 或 count(1),如果数据量过大建议采用ES进行记录
- 倘若count字段有索引,count(*) = count(1) > count(字段) > count(主键)
- count(字段)效率比count(主键)好的原因是走了覆盖索引,二级索引树比主键索引树数据量小
- 倘若count字段无索引,count(*) = count(1) > count(主键) > count(字段)
- 倘若count字段有索引,count(*) = count(1) > count(字段) > count(主键)
频繁增删改的字段不要加索引,数据量一大维护成本会很高
长字符串可以采纳左20字符作为索引,满足90%场景
查询字段时,能采用覆盖索引的地方就去使用,避免查全部字段
流程
- 根据业务完成sql代码之后,可以先评估有哪些字段需要索引,尽量使用组合索引
- 后续根据线上 skywalking/grafana/监控告警/耗时日志 去观察接口以及定位慢SQL,再通过explain工具以及DBA建议进行优化,一般情况下都能处理,如果索引优化已经满足不了业务场景了,可以使用ES作为查询工具(将数据的关键字段 通过MQ + canal/ogg 同步到ES)
SQL的执行流程
- 客户端 通过 连接器 进行权限验证,老版本的先去判断有没有开启缓存,如果开启了,先从缓存中查询数据返回,如果没有开启,就通过 词法分析器去 进行词法分析,然后通过 优化器 去进行SQL优化,然后再通过 执行器 去选择 存储引擎 去进行SQL执行
- 客户端 -> (缓存) -> 词法分析器 -> 优化器 -> 执行器 -> 存储引擎
MySQL的三个日志
- undolog
- 数据的快照日志,可以用于数据回滚
- 基于MVCC机制实现,通过创建多个版本的数据,从而使并发事务互不影响,对于每个事务都会对应自己的版本链,读写操作都是基于各自的版本去进行
- redolog
- InnoDB级别的,主要用于BufferPool数据恢复的
- binlong
- 会记录所有操作的日志,可用于数据归档
Undo的MVCC机制
- 通过 undo日志版本链 + read_view一致性视图
- 每个事务都会对应一个undo日志版本链
- 在RR级别,当事务开始时会生成read_view,并且在事务期间并不会改变,通过规则匹配,保证多次读取相同数据时是同一份快照
- 在RC级别,当每个读操作开始时会生成read_view,通过规则匹配,可能会看到不同的数据快照
- 当事务需要进行修改时,会通过read_view找到最新的版本,基于该版本修改之后,再往undo日志版本链新增一个版本
InnoDB的BufferPool缓存机制
- 对于数据库的操作都是直接操作BufferPool完成的
- 当事务开始时,InnoDB 会将所需的数据页加载到 BufferPool 中,然后记录 undolog,如果事务失败会通过 undolog 进行事务回滚更新到 BufferPool,然后顺序写 redolog,用于服务重启时对 BufferPool 进行数据恢复,对于事务的所有操作会记录到 binlog 中,用于数据归档,最终 redolog 和 binlog 的数据会保持基本一致
线上百万数据如何添加索引
- 对于线上百万数据直接添加索引会导致锁表,所以不建议直接对百万数据加索引,可以这么操作
- 导出原表数据
- 创建与原表结构一致的新表,先再新表上添加索引
- 将原表的数据导入新表
- 修改新表的表名为原表名
MySQL中的锁有哪些
- 基于锁的属性分为独占锁和共享锁
- 独占锁(写锁)
- 当一个事务给数据加上写锁的时候,其他请求不允许再为数据加任何锁,想要进行操作需要等写锁释放之后
- 独占锁的目的是在修改数据的时候不允许其他人同时进行读和修改,避免了脏写和脏读的问题
- 共享锁(读锁)
- 当一个事务给数据加上读锁的时候,其他事务只能对数据加读锁,而不能加写锁,直到读锁都释放完之后才能允许其他事务加写锁
- 共享锁的目的支持并发读,但是读时不允许修改,避免不可重复读的问题
- 独占锁(写锁)
- 基于锁的粒度可以分为表锁、行锁、记录锁、间隙锁、临键锁
- 表锁
- 每次操作都会锁住整张表,粒度大,加锁简单,容易冲突,并发度低,一般用于数据迁移
- 行锁
- 每次操作会锁住表的某行或多行记录,粒度小,加锁比表锁复杂,不容易冲突,支持更高的并发
- 行锁是建立在索引的基础上的,没有索引或者索引失效时,InnoDB 的行锁变表锁,比如普通索引数据重复率过高会导致索引失效,行锁会升级为表锁
- 记录锁
- 行锁的一种,每次操作会锁住表的某行记录,避免不可重复读的问题以及脏读问题
- 比如 select … for update
- 行锁的一种,每次操作会锁住表的某行记录,避免不可重复读的问题以及脏读问题
- 间隙锁
- 行锁的一种,锁住的是两个值之间的空隙,为了解决幻读问题
- 比如 (1,10)之间,如果存在事务未提交,InnoDB就不能让在之间插入数据
- 间隙锁在RR级别才会生效,在RC级别会失效
- 行锁的一种,锁住的是两个值之间的空隙,为了解决幻读问题
- 临键锁
- 行锁的一种,基于记录锁和间隙锁实现的,包含自身,加了临键锁在范围内的数据不能被修改,可以避免不可重复读、脏读、幻读等问题
- 比如 [1,10)之间,如果存在事务未提交,InnoDB就不能让在之间插入数据
- 行锁的一种,基于记录锁和间隙锁实现的,包含自身,加了临键锁在范围内的数据不能被修改,可以避免不可重复读、脏读、幻读等问题
- 表锁
分库分表
- 分库分表主要是为了解决数据量过大而导致性能降低的问题,可以通过分库或分表来提升数据库的性能,在阿里规范中有说道数据量达到500w或者占用磁盘空间达到2G就需要进行分库分表,常见的分库分表组件有ShardingSphere和MyCat,对于数据分片分为垂直分片和水平分片
- 垂直分片主要是从业务角度将表中数据拆分到不同的库中来解决数据文件过大的问题,但是本质上不能解决数据查询效率慢的问题
- 水平分片主要是从数据库角度出发将表中数据拆分到多个表或库中,能解决数据查询效率慢的问题,但是存在很多问题如分布式事务
- 对于分片策略主要有取模、按范围、按时间
- 取模就是对某个值进行取模,数据分配比较均匀,但是扩容不方便
- 按范围就是指定一个区间进行分片,数据分配不均匀,但是扩容方便
- 按时间就是将数据再某时间的冷热情况进行分片,方便热数据区分
- 分库分表的执行流程为SQL解析->查询优化->SQL路由->SQL改写->SQL执行->结果归并
- 分库分表会遇到很多问题比如事务一致性问题、跨节点关联问题、跨节点分页以及排序问题、主键避重、公共表处理
- 事务一致性问题就是原本单库能通过事务机制保证数据一致性,但是多库下会将数据分散到不同的库中,存在分布式事务的问题
- 跨节点关联问题就是多库下会将数据分散到不同的库中就无法进行关联查询了,这时可能就需要考虑先单库关联后再汇总
- 跨节点分页以及排序问题就是数据分散到不同的库中就无法进行一起分页和排序,这时可能就需要考虑单库分页和排序后再汇总分页排序一次,会占用大量内存
- 主键避重就是单库下可以通过自增ID保证主键唯一,但多机会出现重复,这时可以使用分布式ID去解决,比如雪花算法
- 公共表处理就是多库下对于一些基本变化不大的表都需要进行数据维护工作
Oracle
Oracle的执行计划怎么看
- 可以借助DBeaver的解释执行计划查看,或者通过 explain plan for
- explain plan for 语句; select * from table(dbms_xplan.display);
- Oracle的语句执行顺序遵循树的后续遍历(左右根),所以 缩进最里面的最先执行,缩进相同的先上后下
- 对于优化原则与MySQL的基本上差不多,我们可以通过Index Scan 和 扫描成本来分析如何进行优化
Redis
Redis的数据类型
- Redis常见的数据类型
string
- string 主要是用来存储字符串,底层是基于 动态字符串sds 实现的,sds 通过动态调整长度来节省内存
- 应用场景
- 分布式session
- 分布式锁
- 常用指令
- set、get、setnx、setex …
hash
- hash 类似于 Map,底层采用两种方式来实现,当数据量较少并且元素占用内存少(小整数或短字符串)时,采用 ziplist(压缩列表),反之采用 hashtable(哈希表),两种方式都是为了节省内存
- ziplist 是一个连续的内存空间,通过紧凑的存储来节省空间
- hashtable 是基于dict(字典)实现,采用拉链法解决hash冲突
- 应用场景
- 实现购物车
- 常用指令
- hset、hget、hgetall、hdel、hincrby …
- hash 类似于 Map,底层采用两种方式来实现,当数据量较少并且元素占用内存少(小整数或短字符串)时,采用 ziplist(压缩列表),反之采用 hashtable(哈希表),两种方式都是为了节省内存
list
- list 是一个有序可重复集合,底层采用两种方式实现,当数据量较少并且元素占用内存少(小整数或短字符串)时,采用 ziplist(压缩列表),反之采用 quicklist(快速列表),两种方式都是为了节省内存
- ziplist 是一个连续的内存空间,通过紧凑的存储来节省空间
- quicklist 是基于 ziplist 和 双向链表 实现的,可以在节省空间的同时保证高效增删
- 应用场景
- 栈(lpush + lpop)
- 队列(lpush + rpop)
- 阻塞队列(lpush + brpop)
- 发布订阅
- 常用指令
- lpush、lpop、rpush、rpop、blpop、brpop、lrange …
- list 是一个有序可重复集合,底层采用两种方式实现,当数据量较少并且元素占用内存少(小整数或短字符串)时,采用 ziplist(压缩列表),反之采用 quicklist(快速列表),两种方式都是为了节省内存
set
- set 是一个无序不可重复集合,底层采用两种方式实现,当数据量较少且元素为整数时,采用 intset(整数集合),反之采用 hashtable(哈希表),两种方式都是为了节省内存
- intset 是一个有序的整数数组,通过紧凑的存储来节省空间
- hashtable 是基于dict(字典)实现,采用拉链法解决hash冲突
- 应用场景
- 抽奖(srandmember)
- 点赞收藏关注(sadd)
- 共同关注(sinter)
- 可能认识的人(sdiff)
- 常用指令
- sadd、srem、smembers、scard、srandmember、sismember、spop、sinter、sunion、sdiff、sinterstore、sdiffstore
- set 是一个无序不可重复集合,底层采用两种方式实现,当数据量较少且元素为整数时,采用 intset(整数集合),反之采用 hashtable(哈希表),两种方式都是为了节省内存
zset(sorted set)
- zset 是一个有序不可重复集合,底层采用两种方式实现,当数据量较少并且元素占用内存少(小整数或短字符串),采用ziplist(压缩列表),反之采用skiplist(跳表) + dict(字典),两种方式都是为了节省内存,另外skiplist主要是为了提升score查询效率
- ziplist 是一个连续的内存空间,通过紧凑的存储来节省空间
- skiplist 是一个有序链表配上多级索引,通过多级索引位置的跳转来实现快速查找元素,主要用于按照分值对元素进行排序,同样也支持范围查询
- 跳表如何定位元素
- 每隔一个元素建立一个索引,通过建立多个索引,利用一次索引定位到需要查询的元素,如果觉得慢,可以在一级索引的基础上建立二级索引,依次类推,在多级索引之间来回转跳实现快速定位,当数据量特别大的时候,查找时间复杂度为O(logN),因为它本身的思想就类似二分查找
- 跳表如何定位元素
- 应用场景
- 排行榜(zrange/zreverange/zunionscore)
- 常用指令
- zadd、zrem、zsore、zincrby、zrange、zreverange、zrangebyscore、zreverangescore、zunionscore、zinterscore
- zset 是一个有序不可重复集合,底层采用两种方式实现,当数据量较少并且元素占用内存少(小整数或短字符串),采用ziplist(压缩列表),反之采用skiplist(跳表) + dict(字典),两种方式都是为了节省内存,另外skiplist主要是为了提升score查询效率
bitmap
- bitmap是一个位图
- 应用场景
- 月打卡、月活跃
- 月打卡可以通过将 当前第几天 作为 偏移量,如果打卡对应的位置为1,反之为0
- 布隆过滤器
- 月打卡、月活跃
stream
- stream 是参考kafka设计的消息队列,支持持久化,适合小基数的消息队列场景
谈一下布隆过滤器
- 布隆过滤器 是基于bitmap实现的,主要用于粗略的数据过滤
- 添加数据时,经过hash运算得到对应的 bit位,将该 bit位 置为1
- bit位为1 表示可能存在
- bit位为0 表示一定不存在
- 添加数据时,经过hash运算得到对应的 bit位,将该 bit位 置为1
Redis为什么这么快
- Redis快的原因
- 基于内存的读写操作,没有磁盘IO的性能开销
- 采用单线程去处理网络IO请求以及指令的执行,避免多线程的竞争以及上下文切换的开销
- 采用IO多路复用epoll机制,让单线程去处理多个请求,当有操作时会立刻通知去处理
- 对于每种数据类型都进行了优化,比如使用ziplist来节省空间以及skiplist来提升查找效率
Redis为什么要引入多线程
- Redis引入多线程主要想发挥多核处理器的能力,处理在大数据量下网络IO读写速度慢的问题,但是指令的执行依旧采用的是单线程
什么是IO多路复用
- IO多路复用 是一种同步IO模型,允许单线程去同时监听多个文件描述符,一旦文件描述符就绪就会通知程序去处理
- 常见的有select、poll、epoll
- select
- 基于数组实现,每次调用都进行遍历,最大连接数有上限
- poll
- 通过链表实现,每次调用都进行遍历,最大连接数无上限
- epoll
- 通过哈希表实现,通过事件通知,每当有IO事件就绪,系统注册的回调函数就会被调用,最大连接数无上限
- select
- 在选择三者上需要根据实际场景进行选择,表面上看epoll性能最好,但是如果在连接数少并且活跃度较低时,select和poll的性能最好,低效的原因主要在轮询遍历上,所以也得视情况看
Redis的Reactor模型
Reactor模型有三种
单线程模式
- 一个线程负责多个事件处理,当连接数过多时会造成性能瓶颈,适用于连接数较少、复杂度较低的场景
多线程模式(单线程、工作线程池)
- 在单线程Reactor模式的基础上,将业务处理部分交给了线程池,提升并发能力,但是要注意线程安全
主从多线程模式
- 将整体拆分为主、从Reactor,主Reactor负责监听连接事件,将事件分发给从Reactor去处理,从Reactor负责与客户端读写操作,充分利用多核CPU,提升并发能力
- 分为主、从两个部分,主负责收、转发事件给从处理
- 将整体拆分为主、从Reactor,主Reactor负责监听连接事件,将事件分发给从Reactor去处理,从Reactor负责与客户端读写操作,充分利用多核CPU,提升并发能力
Redis采用的是单线程模式
Redis如何获取所有key
- 在数据量较少的情况下,可以使用 keys
- 在数据量较大的情况下,使用 keys 会阻塞单线程影响其它指令的执行,所以当遇到这个情况时,可以使用 渐进式scan 的方式去扫描所有key,扫描的可能有重复元素,此时需要用set集合处理下
Redis持久化有哪几种方式
- Redis的持久化机制有RDB和AOF
- RDB是通过快照的方式进行持久化的,也就是通过快照触发条件将内存中的数据写到rdb文件中,比如执行bgsave命令触发异步快照,执行save命令触发同步快照,同步快照会阻塞客户端的执行命令
- 执行写命令时,fork主线程会开启一个子线程将内存中的数据写入一个临时文件,当持久化完成后,再替换旧的rdb文件
- AOF是一种接近实时的方式,每次执行的命令都会写入到aof文件中
- RDB是通过快照的方式进行持久化的,也就是通过快照触发条件将内存中的数据写到rdb文件中,比如执行bgsave命令触发异步快照,执行save命令触发同步快照,同步快照会阻塞客户端的执行命令
- 我们很少使用RDB作为持久化方式,因为这种方式很容易丢数据(正在进行RDB持久化的数据可能还没有完全写入到硬盘上,这就会导致未持久化的数据丢失),通常会选择AOF,但是AOF持久化速度比RDB慢很多,所以Redis为了处理这个问题,提供了混合持久化的方式,RDB作全量持久化,AOF作增量持久化
Redis持久化方式如何选择
- 如果数据不敏感,可以不开启持久化
- 如果数据比较重要并且允许几分钟的数据丢失,可以使用RDB
- 如果作为内存数据,建议都开启RDB和AOF这两种方式,优先会从aof文件中进行数据恢复,因为aof文件数据更完整
Redis主从复制(同步)原理
- 从节点每次都会向主节点发送pysnc同步指令,主节点对于首次连接,会开启bgsave子线程将内存中的全量数据生成rdb文件,在持久化过程中,如果从节点有新的写命令会缓存下来,当持久化结束之后,主节点会将rdb文件同步给每个从节点,如果同步期间存在网络波动,从节点会进行重连,恢复之后会继续同步
Redis怎么实现高可用
- 主从模式
- 主从模式 主要是采用了读写分离的方式,主节点负责读写,从节点负责读,如果主节点挂了,需要人工去将从节点晋升为主节点,同时还要告知应用方
- 主从复制过程
- 从节点每次都会向主节点发送pysnc同步指令,主节点对于首次连接,会开启bgsave子线程将内存中的全量数据生成rdb文件,在持久化过程中,如果从节点有新的写命令会缓存下来,当持久化结束之后,主节点会将rdb文件同步给每个从节点,如果同步期间存在网络波动,从节点会进行重连,恢复之后会继续同步
- 主从复制是异步的,存在主从数据不一致的问题,所以要保证网络畅通、监控主从复制情况以及使用数据过期策略
- 网络波动导致从节点同步数据过慢
- 从节点执行阻塞的命令导致数据获取不一致
- 由于一主多从会导致主节点压力过大(主从复制风暴),所以可以考虑 主-从-从 形式将压力分摊出去
- 主从复制风暴 就是 主从复制过程中,由于某种原因导致从节点重新连接到主节点时,大量的数据需要被同步,从而导致网络拥塞和性能下降的情况
- 哨兵模式
- 主从模式 不能进行故障转移工作,需要人工将从节点晋升为主节点,所以引出了哨兵模式,哨兵不进行读写,只负责监控、通知以及选主
- 哨兵选主过程
- 在运行期间,每个哨兵节点每秒都会ping下主节点,如果ping不通,主节点就会被标记为主观下线,如果过半以上的哨兵节点,都认为主节点主观下线,那么主节点就会被标记为客观下线
- 每个发现主节点客观下线的哨兵节点,都会优先推选自己去进行故障转移,而超过一半以上选票的哨兵,最终会去负责主从切换的工作
- Redis Cluster集群模式
- 哨兵模式 在进行故障转移的过程中可能因为网络问题导致无法选主,并且只有一个主节点无法实现高并发,所以引出了Redis Cluster集群模式
- Redis Cluster集群模式 是由多主多从组成的,至少要满足 2N+1 个主节点(过半选举机制),可以自我选主以及故障转移工作
- Redis Cluster会将数据分散到16384各槽位中,每个节点管理一部分数据,当客户端连接集群时,会缓存槽位信息用来定位目标节点,并且会有定位纠错机制,当发现定位异常时,会进行重定位,并且将槽位信息重新缓存一份
- 集群节点之间的通信是采用的gossip协议,由于数据是分散存储的,时效性较弱
- Redis Cluster集群模式存在数据倾斜问题,可以采用本地缓存、对key进行分片处理或对大key进行拆分
- Redis Cluster集群主从选举过程
- 当从节点发现主节点挂了,会优先推选自己,然后告知剩余存活的主节点希望得到支持,当某个从节点收到过半以上主节点的反馈之后,就会成为新的主节点,然后告知集群的其它节点
- 可能会出现两个从节点得到的票数一致,此时只要错开时间重新选举即可
哨兵选主过程
- 在运行期间,每个哨兵节点每秒都会ping下主节点,如果ping不通,主节点就会被标记为主观下线,如果过半以上的哨兵节点,都认为主节点主观下线,那么主节点就会被标记为客观下线
- 每个发现主节点客观下线的哨兵节点,都会优先推选自己去进行故障转移,而超过一半以上选票的哨兵,最终会去负责主从切换的工作
Redis Cluster主从选举过程
- 当从节点发现主节点挂了,会优先推选自己,然后告知剩余存活的主节点希望得到支持,当某个从节点收到过半以上主节点的反馈之后,就会成为新的主节点,然后告知集群的其它节点
- 可能会出现两个从节点得到的票数一致,此时只要错开时间重新选举即可
主从选举的脑裂问题
- 主从切换后,新的主节点会先从原主节点全量同步数据,同步完后会清空原主节点的数据,加载新主节点发来的rdb文件,主从切换期间产生的数据会丢失,对于脑裂问题可以采用过半选举机制解决
缓存雪崩、缓存击穿、缓存穿透
- 缓存雪崩 就是由于大量的key在同一时间失效,导致流量直接打到数据库,导致数据库挂了
- 解决方案
- 可以将key的过期时间设置随机值,避免同一时间过期
- 并发量不多的时候可以采用加锁排队
- 给每一个缓存数据加一个缓存标记来记录缓存是否失效,如果失效就更新
- 解决方案
- 缓存击穿 就是大量用户访问某个key时,这个key刚好失效,导致流量直接打到数据库,导致数据库挂了
- 解决方案
- 设置热点数据永不过期
- 加互斥锁
- 解决方案
- 缓存穿透 就是用户频繁使用缓存和数据库中不存在的数据进行访问,导致流量直接打到数据库,导致数据库挂了
- 解决方案
- 接口层增加校验
- 如果缓存中不存在该值,就缓存空值到缓存中
- 使用布隆过滤器,布隆过滤器是一个位图,如果它说不存在就一定不存在,如果说存在只能是可能存在,可以将可能存在的key放入bitmap进行过滤
- 解决方案
热点缓存并发重建
- 冷数据突然变为热数据,当处于高并发场景下,重建缓存不是短时间能完成的,所以为了减少重建缓存的次数可以使用DCL机制
- 可以先查询一次如果有缓存就返回,如果没有就加锁,在加锁后再查询一次,如果有就直接返回,如果没有就进行重建工作,多次查询为了保证当有线程已经完成了重建工作,而其他线程无需多次进行缓存重建
- 对于缓存重建可能因为突发性热点访问导致系统压力暴增,所以需要提升系统承受的并发量,可以使用 串行变并行 的思想来解决,让多个线程尝试获取锁一段时间,倘若缓存已重建好就能让多个线程同时拿到缓存返回
数据库和缓存双写不一致
延时双删,先删除缓存,再写入数据库,延时500ms,再删除缓存
- 为什么要延时500ms
- 为了我们在第二次删除缓存之前,能完成数据库的更新操作,保证数据库的值最新
- 为什么要两次删除缓存
- 第一次删除缓存是为了更新数据,保证数据库的值是最新,第二次是为了保证拿到缓存数据是最新的
- 如果不进行第二次删除缓存,可能查到的是未修改的缓存数据,进行第二次删除之后,会从数据库中重新查,保证了数据的一致性
- 为什么要延时500ms
使用Redisson的读写锁,实现机制和ReentrantReadWriteLock一致
使用canal监听binlog及时去更新缓存
Redis分布式锁实现
- 通过Redis来实现分布式锁可以分为几步考虑
- setnx 来实现分布式锁,服务宕机后无法处理死锁问题,需要一个过期时间
- setnx 和 setex 组合使用,但是这样的操作不是原子的
- setnx … ex 组合使用基本解决了分布式锁的问题,但是不能进行锁续命,虽然可以通过lua脚本去实现,但是自己实现的可能不完善,所以最终还是选择reddison
- redisson的实现原理
- 当一个线程尝试获取锁时,抢到锁的可以去执行业务,会有一个后台线程,定期去锁续命,保证业务能执行完,而没抢到锁的被安排入队等待
- 如果持有锁的线程在执行业务时出现问题,会检测到锁没释放,并在锁过期后自动删除它,然后允许队列中的下一个线程去抢锁
- redisson的实现原理
- 在实际生产中,Redis是采用Cluster模式去存放锁的,但是这种场景可能会由于数据一致性的问题,导致出现持有多把锁的情况
- 线程A在master获取锁之后,master在同步数据到slave时,master突然宕机(此时数据还没有同步到slave),然后slave会自动选举成为新的master,此时线程B获取锁,结果成功了,这样会造成多个线程获取同一把锁
- 对于这个问题可以借助RedLock去处理,原理就是过半以上的节点解锁成功才算成功,但是这样违背了AP机制,对于这种强一致性的情况建议使用Zookeeper的分布式锁
- 对于分布式锁的key要进行业务隔离,避免误删除
- 另外不能忘记处理异常情况下锁释放的问题,所以要在finally里释放锁
过期键的删除策略
- 惰性删除
- 只有当访问一个key的时候,才会判断的当前key是否已过期,已过期就会删除
- 这个策略可以节省CPU资源,但是占用内存,可能因为大量的key不被再次访问,导致一直不清除从而占用内存
- 定期删除
- 每隔一段时间会扫描一定数量的过期key,并且清除已过期的key
- 这个策略属于折中方案,可以有效的平衡CPU资源以及内存资源
- 强制删除
- 当已使用内存超过Redis最大允许内存,会触发内存淘汰策略
- Redis中同时使用了惰性删除和定期删除
内存淘汰策略有哪些
默认策略noeviction,当内存不足以容纳新写入数据时,新写入操作会报错
针对设置过期时间的key
volatile-lru,按照LRU算法删除
volatile-lfu,按照LFU算法删除
volatile-radom,随机删除
volatile-ttl,按过期时间顺序删除
针对所有key
- allkeys-random,随机删除
- allkeys-lru,按照LRU算法删除
- allkeys-lfu,按照LFU算法删除
Netty
同步和异步的区别
- 同步就是调用方需要主动等待结果的返回
- 异步就是调用方不主动等待结果的返回,而是通过通知或回调的方式间接得到结果
阻塞和非阻塞的区别
- 阻塞就是把当前线程挂起,直到结果返回才会恢复运行
- 非阻塞就是得到结果之前,可以进行其它操作而不会阻塞当前线程
TCP粘包/拆包
- TCP的传输方式如果遇到大的数据会进行拆包操作,对于小的数据会进行粘包
- 当传输的数据在缓冲区放不下时,进行拆包
- 当传输的数据在缓冲区可以放多个时,进行粘包
- 粘包与拆包会给传输带来问题,所以可以采用消息定长或特殊字符间隔来处理
常见的IO模型
- 常见的IO模型有BIO、NIO、AIO、IO复用
- BIO 就是常规的IO操作,同步阻塞,适合少请求的场景
- NIO 就是单线程能处理多个请求,当有事件发生会通知去处理,同步非阻塞,适合大量并发请求的场景
- AIO 就是NIO的升级版,通过操作系统的回调通知去让线程处理事件,异步非阻塞,适合超大并发请求的场景
- IO复用 就是基于select、poll、epoll机制去实现,适合大量并发连接的场景
- select
- 基于数组实现,每次调用都进行遍历,最大连接数有上限
- poll
- 通过链表实现,每次调用都进行遍历,最大连接数无上限
- epoll
- 通过哈希表实现,通过事件通知,每当有IO事件就绪,系统注册的回调函数就会被调用,最大连接数无上限
- 在选择三者上需要根据实际场景进行选择,表面上看epoll性能最好,但是如果在连接数少并且活跃度较低时,select和poll的性能最好,低效的原因主要在轮询遍历上,所以也得视情况看
- select
Netty有哪些核心组件
- Channel 主要负责网络操作,比如连接、IO读写操作
- Bootstrap/ServerBootstrap 分别是客户端启动类、服务端启动类
- EventLoop 配合Channel处理IO操作
- ChannelHandler 主要负责处理各种事件,比如数据处理
- ChannelPipeline 相当于存放ChannelHandler的容器,每个Channel会绑定一个
- ByteBuf 是Netty自己字节容器,用于网络数据读写
谈谈你对Netty中Pipeline工作原理理解
- Pipeline是一个双向链表
- 在Netty中,一个Channel会绑定一个Pipeline(ChannelPipeline),同时会将多个ChannelHandler存放到Pipeline,当需要读写操作的时候,会从Pipeline中找对应的ChannelHandler去处理
Netty中提供了哪些线程模型
- 对于线程模型,Netty是基于Reactor模型实现的
- Reactor模型 分为 单线程模式、多线程模式以及主从多线程模式
- 单线程模式 就是一个线程负责多个事件处理
- 多线程模式 就是基于线程池的方式,复用线程处理事件
- 主从多线程模式 就是分为主、从两个,主负责监听以及分发事件,从负责处理事件
- Reactor模型 分为 单线程模式、多线程模式以及主从多线程模式
- 在Netty中提供了Reator的单线程模式和多线程模式,对于IO操作都是交给EventLoop线程完成,然后通过EventLoopGroup去管理EventLoop线程来实现资源复用
Netty是如何实现零拷贝的
- 零拷贝 就是不要将数据来回拷贝来提升网络传输速度的技术
- 零拷贝 可以通过DMA(直接内存访问)方式来减少用户态与内核态的交互过程,常见的有mmap和sendfile
- mmap 就是通过文件位置与进程地址空间建立映射关系,程序可以直接访问文件内容
- sendfile 就是直接向内核态发送sendfile指令来进行网络传输
- Netty对于零拷贝的实现
- 基于直接内存操作,数据的传输都基于直接内存操作
- 合并多个缓冲数据进行传输,减少拷贝
- 通过缓冲数据传输到管道,减少拷贝
Zookeeper
Zookeeper的节点类型
- 持久节点 就是数据会被持久化下来
- 持久顺序节点 就是基于持久节点的基础上,增加了有序
- 临时节点 就是数据不会被持久化下来
- 临时顺序节点 就是基于临时节点的基础上,增加了有序
- 容器节点 就是可以有很多子子节点,但是如果没子节点,会定时检查去删除
- TTL 就是带过期时间的节点
Zookeeper分布式锁实现
- Zookeeper的分布式锁实现可以分为非公平锁和公平锁,因为基于CP机制,所以适合并发不高的场景
- 非公平锁 就是 多个线程同时去竞争创建临时节点,未竞争到的线程都会去监听那个临时节点,一旦节点被删除,那些线程又都会去竞争
- 非公平锁 出现惊群效应,也就是同一时间大量请求去竞争,导致服务压力突增
- 公平锁 就是先会去创建一个容器节点,需要获取锁的线程会去这个容器节点下创建临时顺序节点,每个节点都会去监听它前面的兄弟节点,一旦线程释放锁,会按照节点创建顺序选择下一个节点去获取锁,保证公平性
- 容器节点会定期检查有无子节点,如果没有会删除,可以有效避免手动删除的操作
- 非公平锁 就是 多个线程同时去竞争创建临时节点,未竞争到的线程都会去监听那个临时节点,一旦节点被删除,那些线程又都会去竞争
Zookeeper的Watcher机制
- 允许客户端注册一个Watcher监听,当节点发生变化会通知客户端,但是通知是一次性的,一旦触发,对应的监听也会被立即移除
Zookeeper的leader选举过程
- 默认投票给自己,优先选择zxid大的为leader,因为zxid大的节点数据理论上是最新的,如果zxid一致,那么会选择myid大的为leader,当节点选票过半则选举成功
- myid: 节点的唯一标识,手动设置
- zxid: 当前节点中最大(新)的事务id
Zookeeper主备之间的数据同步是同步还是异步的
- Zookeeper主备之间的数据同步是异步复制
- 当主节点接收到写请求后,会将数据变更记录到事务日志中,并异步地将这些变更发送给备节点,备节点在接收到变更后,会将其应用到自己的数据副本中,从而实现数据的同步
- 这种异步复制的方式可以提高系统的性能和吞吐量,但也可能导致主备之间存在一定的数据延迟
谈一下ZAB协议(原子广播协议)
- ZAB协议 是Paxos算法的一种简化实现,包括消息广播 和 崩溃恢复两种模式
- 消息广播 就是类似于2PC过程,主节点接收数据,然后广播给从节点,并等待超过半数的从节点反馈后再统一提交
- 崩溃恢复 就是在执行过程中发生了故障(比如从节点没反馈就挂了、主节点提交了但是从节点没提交),能确保数据的一致性,ZAB的过半选举机制,通过最新zxid来确保新主节点有相对最新的数据
ZAB和Paxos算法的联系与区别
- 相同点
- 存在类似于2PC的操作,主节点负责写,由主节点协调其它从节点运行
- 主节点会等待过半以上从节点反馈之后,再进行提案提交
- 每个提案中都包含一个周期值
- 不同点
- ZAB是用来构建高可用分布式主备系统的
- Paxos是用来构建分布式一致性状态机系统的
RabbitMQ
RabbitMQ引入
RabbitMQ的工作队列模式
- RabbitMQ的工作队列模式主要有简单模式、工作队列模式、发布/订阅模式、路由模式、主题模式、RPC(不使用)
- 简单模式 就是 一个生产者对一个消费者
- 工作队列模式 就是 一个生产者对多个消费者
- 发布/订阅模式 就是 生产者广播消息给多个消费者
- 路由模式 就是生产者通过某个key传输给需要的消费者
- 主题模式 就是跟路由模式差不多,但是匹配规则更多
RabbitMQ的死信队列和延迟队列
- RabbitMQ提供了死信队列的支持,当队列中消息达到限制、消息被拒、消息超过存活时间会成为死信息,安排进入死信队列
- 对于延迟队列,可以基于TTL(存活时间) + 死信队列的形式去实现
RabbitMQ如何避免消息重复消费(RabbitMQ如何保证消息幂等性)
- 可以基于Redis + 业务唯一ID来解决
- 消费前判断Redis中是否存在,如果存在就跳过,如果不存在就存入Redis
- 比如使用分布式锁进行幂等性处理
- 消费前判断Redis中是否存在,如果存在就跳过,如果不存在就存入Redis
RabbitMQ如何保证消息有序
- 只能单消息 + 单队列的方式去解决,对于多队列处理多消息的场景并没有很好的方案
RabbitMQ如何处理消息堆积
- 生产端
- 保证生产速度与消费速度基本持平
- 消费端
- 消费端单机消费消息的速度肯定是相对慢的,所以可以增加消费者来提升消费速度
- 服务端
- 提升服务器的配置来提升吞吐量
RabbitMQ如何保证消息不丢失
- 对于消息丢失的场景主要发生在 生产端到Broker端、Broker端持久化、Broker端到消费端
- 生产端到Broker端 不丢失
- 生产端发往Broker端的确认策略有3种
- 同步确认
- 同步操作,必须等待Broker端确认之后才能继续发
- 批量同步确认
- 同步操作,必须等待一批消息被Broker端确认之后才能继续发
- 异步确认
- 异步操作,当Broker端确认之后,生产端可以通过回调的方式来确认消息是否发送成功
- 同步确认
- 可以通过生产端多次确认来确保消息发到Broker端
- 可以通过手动事务,当遇到业务异常,手动进行事务回滚,但是会阻塞消息导致吞吐量下降,造成MQ性能瓶颈
- 生产端发往Broker端的确认策略有3种
- Broker端持久化
- RabbitMQ集群一般采用的是普通集群模式,数据存储是分散开的,并且节点间不会主动同步数据,所以想要消息不丢失,可以采用镜像集群模式,开启后节点间会主动同步数据,这样造成数据丢失的可能性就降低了很多
- 对于数据存盘可以采用持久化队列
- Broker端到消费端
- 对于RabbitMQ消费端提供了两种应答机制
- 自动应答
- 自动应答就是消息发送过来,消费者会自动消费,假如业务异常会进行多次重试,这样消息基本不会丢失,但是需要解决消息幂等性问题
- 手动应答
- 手动应答就是消息发送过来,需要我们自己根据业务处理完之后再进行确认消费
- 针对比较重要的消息,可以采用兜底机制,可以提前配上死信,当消费失败之后进行日志记录,如果出现死信可以及时告警通知,通过日志记录进行人工分析与处理
- 自动应答
- 对于RabbitMQ消费端提供了两种应答机制
RabbitMQ如何保证消息可靠性传输
- 可靠性传输的含义就是消息不能多也不能少
- 消息不能多 就是消息要保证幂等性
- 可以基于Redis + 业务唯一ID来解决
- 消费前判断Redis中是否存在,如果存在就跳过,如果不存在就存入Redis
- 比如使用分布式锁进行幂等性处理
- 消费前判断Redis中是否存在,如果存在就跳过,如果不存在就存入Redis
- 可以基于Redis + 业务唯一ID来解决
- 消息不能少 就是消息要保证不丢失
- 对于消息丢失的场景主要发生在 生产端到Broker端、Broker端持久化、Broker端到消费端
- 保证 生产端到Broker端 不丢失
- 生产端发往Broker端的确认策略有3种
- 同步确认
- 同步操作,必须等待Broker端确认之后才能继续发
- 批量同步确认
- 同步操作,必须等待一批消息被Broker端确认之后才能继续发
- 异步确认
- 异步操作,当Broker端确认之后,生产端可以通过回调的方式来确认消息是否发送成功
- 同步确认
- 可以通过生产端多次确认来确保消息发到Broker端
- 可以通过手动事务,当遇到业务异常,手动进行事务回滚,但是会阻塞消息导致吞吐量下降,造成MQ性能瓶颈
- 生产端发往Broker端的确认策略有3种
- 保证 Broker端持久化 不丢失
- RabbitMQ集群一般采用的是普通集群模式,数据存储是分散开的,并且节点间不会主动同步数据,所以想要消息不丢失,可以采用镜像集群模式,开启后节点间会主动同步数据,这样造成数据丢失的可能性就降低了很多
- 对于数据存盘可以采用持久化队列
- 保证 Broker端到消费端 不丢失
- 对于RabbitMQ消费端提供了两种应答机制
- 自动应答
- 自动应答就是消息发送过来,消费者会自动消费,假如业务异常会进行多次重试,这样消息基本不会丢失,但是需要解决消息幂等性问题
- 手动应答
- 手动应答就是消息发送过来,需要我们自己根据业务处理完之后再进行确认消费
- 针对比较重要的消息,可以采用兜底机制,可以提前配上死信,当消费失败之后进行日志记录,如果出现死信可以及时告警通知,通过日志记录进行人工分析与处理
- 自动应答
- 对于RabbitMQ消费端提供了两种应答机制
- 消息不能多 就是消息要保证幂等性
Kafka
Kafka引入
Kafka的设计
Kafka的主要组件
Broker
- 一个Broker对应一个Kafka节点,多个Broker可以组成Kafka集群
Topic
- Kafka根据主题进行消息分类,生产端发送消息时需要指定Topic
Producer
- 用于向Broker发送消息
Consumer
- 用于从Broker中读取消息
Consmer Group
- 每个消费者属于一个特定消费
- 一条消息可以被不同的消费组消费,但是一个消费组中只能有一个消费者去消费这个消息
Partition
- 一个Topic下可以有多个分区,每个分区内的消息是有序的
Zookeeper
- 主要维护Kafka的元数据信息
Kafka高性能的原因(Kafka为什么这么快)
- Kafka高性能的原因主要是文件存储结构、顺序写磁盘以及数据传输的零拷贝
- 文件存储结构
- 一个Topic的消息采用多个分区进行存储,可以并行读取,加快读取的速度
- 采用稀疏索引索引结构,可以加快日志文件的检索速度
- 顺序写磁盘
- Kafka对于每个日志文件都提前申请好一块连续的磁盘空间,通过顺序写磁盘,加快写入速度
- 顺序写磁盘 就是通过在同一文件中进行追加,效率很高
- 随机写 就是寻找磁盘的空闲空间进行写入,而磁盘的空闲空间可能不是连续的,效率较低
- Kafka对于每个日志文件都提前申请好一块连续的磁盘空间,通过顺序写磁盘,加快写入速度
- 数据传输的零拷贝
- 零拷贝 就是通过避免来回拷贝提升文件传输速度,也就是减少用户态与内核态的拷贝次数
- Kafka在生产端发送消息到Broker端的过程中采用mmap,在Broker发送消息给消费端采用sendfile
- mmap 就是通过文件位置与进程地址空间建立映射关系,程序可以直接访问文件内容
- sendfile 就是直接向内核态发送sendfile指令来进行网络传输
- 相对于RocketMQ采用的都是mmap,主要是因为它支持处理消息的顺序以及消息过滤,因为sendfile只处理内核态,无法进入用户态进行数据的处理
- 文件存储结构
Kafka主备之间采用的是同步复制还是异步复制
- 具体取决于生产端到Broker端的确认策略acks
- acks = 0
- 生产端发送消息后不需要等待任何确认,属于异步复制
- 这种情况下,生产端将消息发送到Leader副本,并立即返回成功,而不会等待任何副本的确认,这样可以获得更高的吞吐量,但可能会导致消息丢失
- acks = 1
- 生产端发送消息后需要等待Leader副本的确认,属于半同步复制
- 生产者将消息发送到Leader分区,并等待Leader分区的确认后返回成功,只要Leader分区确认接收到消息,就认为消息已经成功写入Kafka,但不需要等待其他副本的确认,这样可以在一定程度上保证数据的可靠性
- 生产端发送消息后需要等待Leader副本的确认,属于半同步复制
- acks = -1 || all
- 生产者发送消息后需要等待所有副本的确认,属于同步复制
- 这种情况下,生产者将消息发送到Leader分区,并等待Leader分区以及所有副本的确认后返回成功
- 只有当所有副本都确认接收到消息后,才认为消息已经成功写入Kafka。这样可以最大程度地保证数据的可靠性,但会增加延迟
- acks = 0
Kafka的Broker持久化机制
关键词
- 分段存储、消息的offset、日志压缩、顺序写磁盘、副本机制、复制机制
设计思路
- Kafka的消息会被发布到不同Topic下,对应的每个Topic会被拆分成多个分区(top + 分区号)进行存储,而每个分区其实就是一个文件夹,在文件夹内会将日志采用分段存储,对于每个段的日志大小都是一致的(固定最大1G),而消息在日志内部会按照offset排列,并且日志文件会进行压缩,从而减少磁盘空间占用,对于不需要的消息,Kafka也支持通过参数设置过期时间,基于过期时间去进行定时删除
- 对于采用分段存储,有助于并行读取消息
- 对于日志文件固定最大1G,可以方便消息刷到内存
- 对于消息的offset,消费端可以通过指定offset去拉取感兴趣的消息,不会影响到别的消费者
- 对于Kafka日志数据的持久化,Kafka会为每个日志文件提前分配好连续的磁盘空间,采用顺序写磁盘的方式,来提升写速度(提升写入吞吐量)
- 对于Kafka的每个分区,在不同的Broker端存在副本,每个副本都可支持读写操作,这样实现了高可用性以及容错性
- 对于多个副本中存在一个leader副本(主副本),其余都是follower副本(备份副本),leader负责处理所有读写,follower负责同步leader副本的数据,leader与副本通过复制机制来保证数据一致性
- 复制机制 就是 leader负责将写入的数据同步给follower,对于是副本的同步是全量还是过半可以基于配置来决定
- 如果需要全量同步可以生产端配置确认策略为acks = -1 || all,其次就是复制因子 > 最小ISR副本数(repliacation.factor > min.insync.replicas),考虑到Kafka的吞吐量,其实一定程度上过半就算同步成功了
- 复制机制 不能保证强一致性,但是能保证最终一致性,因为消息被复制到所有follower之前,leader和follower之间可能存在一段时间的数据不一致,但是当leader分区发生故障时,Kafka会自动选举一个新的leader,并确保所有的follower分区都追赶上新的leader分区的进度
- 如果读写请求,发往leader副本会直接处理,如果发往followe副本会转交给leader去处理
- 当leader副本发生故障时,Kafka会选举一个新的leader副本,从而保证了服务的高可用性
- 复制机制 就是 leader负责将写入的数据同步给follower,对于是副本的同步是全量还是过半可以基于配置来决定
- 对于多个副本中存在一个leader副本(主副本),其余都是follower副本(备份副本),leader负责处理所有读写,follower负责同步leader副本的数据,leader与副本通过复制机制来保证数据一致性
- 对于Kafka分区日志文件整体,采用稀疏索引索引结构,可以加快日志文件的检索速度
- Kafka的消息会被发布到不同Topic下,对应的每个Topic会被拆分成多个分区(top + 分区号)进行存储,而每个分区其实就是一个文件夹,在文件夹内会将日志采用分段存储,对于每个段的日志大小都是一致的(固定最大1G),而消息在日志内部会按照offset排列,并且日志文件会进行压缩,从而减少磁盘空间占用,对于不需要的消息,Kafka也支持通过参数设置过期时间,基于过期时间去进行定时删除
Kafka什么情况会丢消息
- 生产者发送失败
- 当生产者发送消息到Kafka时,如果发生网络故障或其他错误,导致消息无法成功发送到Kafka集群,那么这些消息可能会丢失
- 消费者提交失败
- 消费者在消费消息后,需要将消费的偏移量提交到Kafka集群。如果在提交偏移量之前发生故障,那么消费者可能会重新消费一些消息,从而导致消息丢失
- 消费者处理失败
- 如果消费者在处理消息时发生错误,并且没有进行适当的错误处理和重试机制,那么这些消息可能会被丢弃
- 副本同步失败
- Kafka使用副本机制来提供数据冗余和容错性,如果副本同步失败,例如由于网络故障或硬件故障,那么消息可能会丢失
- Kafka主备数据同步在遇到脑裂问题时可能导致消息丢失
- 脑裂是指主备节点之间的网络分区,导致它们无法正常通信
- 当发生脑裂时,主节点和备节点可能会同时认为自己是有效的主节点,导致数据同步的不一致性
- 在这种情况下,如果消息被写入了备节点但未被同步到主节点,或者消息被写入了主节点但未被同步到备节点,就会导致消息丢失
- 为了避免脑裂导致的消息丢失
- 使用适当的网络配置和硬件设备,以减少网络分区的风险
- 配置Kafka集群的复制因子(replication factor)为大于等于3,确保有足够的备份节点来保证数据的可靠性
- 配置Kafka的ISR机制,确保只有与主节点保持同步的备节点才能参与数据同步
- 合理设置Kafka的参数,如acks参数和min.insync.replicas参数,以确保数据同步的可靠性和一致性
- Kafka主备数据同步在遇到脑裂问题时可能导致消息丢失
- 如果主节点在更新LEO之前挂掉了,从节点可能会丢失HW之后的消息
- 这是因为HW表示已经被所有副本成功复制的消息的位置,而LEO表示主节点当前写入的最后一条消息的位置,当主节点挂掉后,从节点会尝试选举新的主节点,并从新的主节点处获取消息,但是,如果新的主节点还没有来得及同步HW之后的消息,那么从节点就无法获取到这消息,导致消息丢失
- 为了解决这个问题,可以采取以下几种方式
- 增加副本数
- 增加副本数可以提高数据的冗余度,减少消息丢失的可能性
- 设置ISR策略
- 通过设置ISR策略,可以确保只有已经同步到HW的副本才能成为新的主节点
- 启用ACK机制
- 在生产者发送消息时,可以设置ACK机制来确保消息被成功写入到Kafka中
- 增加副本数
- Kafka使用副本机制来提供数据冗余和容错性,如果副本同步失败,例如由于网络故障或硬件故障,那么消息可能会丢失
- 消息过期
- Kafka允许设置消息的过期时间,如果消息在过期时间之前没有被消费者消费,那么这些消息将被视为过期并被丢弃
Kafka生产端的发送模式
- Kafka生产端的发送模式有发送即忘、同步和异步
- 发送即忘 就是不关心消息是否到达,这种方式无法保证消息可靠性
- 同步 就是只有消费端收到了才能发送下一条
- 异步 就是通过回调的方式去处理消费端的响应
Kafka消费端的消费模式
- 一般消费模式有两种 推模式 和 拉模式
- Kafka只支持拉模式,消费者主动向Broker不断轮询拉取消息,可以自己把控消费的速度与方式
- Kafka不采取推模式的原因主要是每个消费端的消费速度不一样,无法把控Broker端向消费端推送消息的频率
Kafka的消费乱序
- 如果发送端配置重试机制,Kafka不会等之前那条消息完全发送成功才去发送下一条消息,这样可能会出现发送了1、2、3三条消息,第1条消息超时了,后面2条消息发送成功,然后再重试发送第1条消息,这时Broker端分区存入的消息顺序为2、3、1,所以是否需要配置重试机制得根据业务去定,当然也可以用同步发送的模式去发送并且acks≠0,这样也能保证消息从发送到消费是全链路有序的
Kafka副本Leader选举机制
- 控制器(Controller)感知到分区leader副本所在的Broker节点挂了,满足AR排序最靠前并且在ISR中的会成为新的leader,一旦选举出来控制器会告知其他Broker节点
- AR 就是分区中所有副本(存活的 + 不存活的)
- ISR 就是在AR中并且存活的副本
Kafka消费者的重平衡(Rebalance)机制
- 如果消费组里的消费者成员数量发生变化了,Kafka会触发一个再平衡过程,会重新给每个消费者分配分区来保证平衡
- 当某个消费者挂了,Kafak会将之前分配给它的分区转交给其它的消费者去处理,当这个消费者恢复了,又把之前的分区还给它
- 当同一个消费组里有3个消费者,分配4个分区时,重平衡至少要保证每个消费者有1个分区
- 触发消费者Rebalance机制的情况
- 消费者组里消费者变化
- 动态给Topic增加分区
- 消费组订阅了更多的Topic
分区故障恢复机制
参数
- LEO: 每个分区的最后一个offset
- HW: 一组分区中最小的LEO
每个follower副本都会维护自己的offset以及计算好LEO反馈给leader副本,leader副本会通过follower副本反馈的LEO计算出HW值,然后再同步给所有follower,对于HW之前的说明是已经完成同步的,之后的说明可能是已经丢失的
leader副本出现故障
- 会从ISR中选举出新的leader,所有follower按照新leader的HW值进行数据同步
- 可能出现新leader的LEO值小于旧leader的LEO值,此时所有follower只能删除高于HW值的部分
- 会从ISR中选举出新的leader,所有follower按照新leader的HW值进行数据同步
follower副本出现故障
- follower出现故障不会影响写入,但是会从ISR中剔除,当恢复工作时,根据HW值进行数据恢复之后才会重新加入ISR中
对于每个Broker中记录的HW值,每次发生leader变更时,都会维护一个递增的epoch号以及当前分区写入的首个消息offset,并且持久化到文件中,其它follower会同步该文件,当出现leader变更时,follower就可以直接依赖最新的epoch去判断拉取消息的起点,避免使用自身的HW值去判断
Kafka的消息幂等性3种语义的实现
生产端发往Broker端的确认策略有3种
acks = 0 就是不需要等任何副本确认收到,就可以继续发消息
acks = 1 就是只需要等leader副本确认写入完成,才可以继续发消息
acks = - 1 || all 就是需要等待所有副本都写入完成,才可以继续发消息
消息幂等性3种语义
at most once(最多收到一次)
- acks = 0
- Kafka默认实现
at least lonce(至少收到一次)
- acks = -1 || all
exactly once(只收到一次)
- at least once 加上消费端增加幂等性处理,也可以使用Kafka生产者的幂等性来实现,
- 因为生产端重试导致消息重复发送,Kafka的幂等性可以保证重复发送的消息只接受一次,只需要在生产端参数开启即可
- at least once 加上消费端增加幂等性处理,也可以使用Kafka生产者的幂等性来实现,
Kafka如何保证消息不丢失
对于跨网络的节点可能会丢消息,因为MQ存盘都会先写入OS的PageCache中,然后再让OS进行异步刷盘,如果缓存中的数据未及时写入硬盘就会导致消息丢失
对于Kafka如何保证消息不丢失,主要涉及 生产端到Broker端、Broker端持久化、Broker端到消费端
- 保证 生产端到Broker端 不丢失
- 生产端发往Broker端的确认策略有3种
- acks = 0 就是不需要等任何副本确认收到,就可以继续发消息
- acks = 1 就是只需要等leader副本确认写入完成,才可以继续发消息
- acks = - 1 || all 就是需要等待所有副本都写入完成,才可以继续发消息
- 生产端只要配置acks = -1 || all即可,当生产端发送完消息后,可以通过Broker的反馈去判断是否发送成功以及是否需要重发
- 生产端发往Broker端的确认策略有3种
- 保证 Broker端持久化 不丢失
- Kafka的消息写入是先写入操作系统的页缓存再刷到磁盘,Kafka不支持单条消息的同步处理,所以我们需要合理的调整刷盘频率,并且多配置点副本防止单点,另外将分区能均匀的分配到不同的Broker,保证服务可用
- 保证 Broker端到消费端 不丢失
- 消费端有自己的重试机制,正常情况下不会丢失消息
- 消费端每次拉取一批消息,处理完之后向Broker端提交消费到的offset,如果Broker端没有接到反馈,会向消费者组的另外一个消费者重推消息,保证消息不丢失
- 对于消息offset要采用手动提交,避免自动提交
- 消费端唯一要注意的是避免异步处理业务,如果业务异步处理发生了异常,而offset却已经提交了,反而导致了消息丢失
- 消费端有自己的重试机制,正常情况下不会丢失消息
- 保证 生产端到Broker端 不丢失
Kafka如何保证消息有序
- 首先要保证分区消息的有序
- 生产端将同一类型的消息发到同一个分区,也就是保证业务消息有序
- 一个topic配置一个分区,通过牺牲吞吐量来保证有序
- 在保证分区消息有序之后,再保证消费端消费有序
- 由于Kafka消费端拉取消息时并行拉取多个分区的,无法实现串行消费,所以想要保证全局有序,消费组里只能配置一个消费者,也就是一个topic、一个分区、一个消费者,但是这种牺牲了Kafka的性能
- 一般情况下,可以将收集到的消息根据不同的业务存放到对应的内存队列中,然后进行排序后再使用
- 如果非要使用顺序消息,可以使用RocketMQ,基于RocketMQ采用锁队列的方式,可以直接实现全局有序
- 一个topic只能有一个MessageQueue被消费(默认4个)
Kafka如何处理消息堆积
- Kafka是允许一定消息的堆积
- 如果消费端处理的速度过慢导致积压
- 可以增加topic的分区数,同时也要增加同比例的消费者去消费
- 如果想要保证分区之间数据分布均衡,可以新开一个topic并且配置更多的分区,后续的消息都发往这个新开的topic,然后再消费
- 如果是消费端处理异常导致消费堆积,此时影响到了程序正常运行
- 可以采用降级方案,新开一个消费者将topic的消息转发到其他队列(类似于死信队列),然后再去分析以及处理
- 如果消费端处理的速度过慢导致积压
谈谈对Kafka零拷贝的理解
- 零拷贝 就是通过避免来回拷贝提升文件传输速度,也就是减少用户态与内核态的拷贝次数
- Kafka在生产端发送消息到Broker端的过程中采用mmap,在Broker发送消息给消费端采用sendfile
- mmap 就是通过文件位置与进程地址空间建立映射关系,程序可以直接访问文件内容
- sendfile 就是直接向内核态发送sendfile指令来进行网络传输
- 相对于RocketMQ采用的都是mmap,主要是因为它支持处理消息的顺序以及消息过滤,因为sendfile只处理内核态,无法进入用户态进行数据的处理
Kafka的延迟消息
- Kafka延迟消息的实现方式
- 可以按照不同的延迟时间段定义好topic,将消息发往指定的topic中,通过定时任务轮询这些topic,如果时间到了就消息转发到具体业务的topic中
- Kafka对于时间粒度较小时会导致服务压力增加,并且发送的延迟消息会有一定的延迟性
Kafka生产端发多条消息,一半成功一半失败怎么办
重试机制
- 可以在发送失败后进行重试,直到消息发送成功或达到最大重试次数
- 可以设置一个重试次数的上限,如果超过了这个次数仍然发送失败,可以将失败的消息记录下来,以便后续处理
异步处理
- 可以将发送消息的过程异步化,即不等待消息发送的结果立即返回,而是将消息放入一个消息队列中,由后台的线程或者其他进程来负责发送
- 这样可以提高发送消息的吞吐量,并且可以通过监控异步发送的结果来处理发送失败的消息
分批发送
- 以将要发送的消息分成多个批次进行发送,每次发送一部分消息
- 如果某个批次中的消息发送失败,可以只重试该批次中的失败消息,而不需要重新发送整个批次
错误处理
- 对于发送失败的消息,可以将其记录下来,并进行错误处理
- 可以将失败的消息保存到数据库或者文件中,以便后续进行补发或者人工处理
使用Kafka的生产端事务机制
Kafka的事务
Kafka的事务主要是保障一次发送多条消息的事务一致性,要么同时成功,要么同时失败,并不是分布式事务
Kafka事务的原子性是通过2PC两阶段提交来实现的
- 在第一阶段,生产者将消息发送到Broker,并等待确认消息已写入磁盘
- 在第二阶段,生产者提交事务,Broker将消息标记为已提交,如果有错误发生,生产者可以回滚事务,Broker将消息标记为未提交,只有当所有参与者都提交事务后,Broker才会将消息永久保存
RocketMQ
RocketMQ引入
RocketMQ的设计
- RocketMQ的主要组件
- NameServer
- 主要是用于服务注册以及Broker路由
- Broker
- 实际处理消息存储、转发的服务
- Producer
- 发送消息到Broker端
- Consumer
- 接收Broker端推送的消息或主动从Broker端拉取消息
- Topic
- RocketMQ根据主题进行消息分类,生产端发送消息时需要指定主题
- MessageQueue
- 相当于Topic的分区,一个Topic默认4个MessageQueue
- NameServer
RocketMQ为什么要放弃Zookeeper
- RocketMQ主要是保障最终一致性,它只需要一个轻量级的元数据服务就行了,而Zookeeper是强一致性的解决方案,并且少使用一个中间件可以减少维护成本
RocketMQ的消息模型
- 顺序消息
- 顺序消息只能保证局部消息有序,不能保证全局有序,实现全局有序 可以 生产端将一批消息有序发往MessageQueue,消费端通过锁队列的方式,每次只拿一个MessageQueue里的消息
- 广播消息
- 广播消息并没有特定的消费者,因为这涉及到消费者的集群消费模式,默认是集群模式
- 集群模式
- 一个消息只会被一个消费组中的一个消费者处理一次
- Broker端会给每个消费者组维护一个统一的offset来保证同一个消费组内只会被消费一次
- 一个消息只会被一个消费组中的一个消费者处理一次
- 广播模式
- 一个消息会被推送给所有消费者消费,不再关心消费组
- Broker端只管推消息,消费端自己维护offset
- 一个消息会被推送给所有消费者消费,不再关心消费组
- 集群模式
- 广播消息并没有特定的消费者,因为这涉及到消费者的集群消费模式,默认是集群模式
- 延迟消息
- 默认提供了18个延迟级别,延迟消息的难点其实是性能,需要不断进⾏定时轮询,全部扫描所有消息是不可能的
- 批量消息
- 只能对同一topic下的消息进行批量发送,不支持延迟消息,以及批量消息的大小不超过1MB,超过了需要自行拆分
- 过滤消息
- 消费端可以通过一定规则匹配topic下需要的消息,支持简单过滤以及SQL过滤
- 消息过滤在消费者端和Broker端都可以做,消费者端进行过滤可以保障消息过滤的可控性,而Broker端过滤可以减少不必要数据的网络IO(只把消费者端需要的消息发送出去就行)
- 事务消息
- 通过事务消息可以确保上下游的数据一致性
- 实现思路
- 生产者端将消息发往MQ服务,MQ服务将消息持久化后,向生产端反馈已收到,此时消息为半消息(半事务消息状态)
- 生产端执行完本地事务后,会将执行结果向MQ服务进行二次确认,判断是否提交或回滚
- 如果提交,MQ服务将半消息标记为可投递,然后转发给消费端
- 如果回滚,MQ服务会将半消息删除
- 如果MQ服务没有收到二次确认,会对生产端进行消息回查,查看事务执行结果继续进行二次确认
谈谈你对RocketMQ分布式事务原理的理解
- 分布式事务 就是在分布式环境下,需要保证不同服务的数据一致性
- 分布式事务的实现方式可以基于2PC两阶段提交
- 准备阶段,协调者通知参与者准备提交各自的事务
- 提交阶段,参与者反馈,协调者通过反馈去决定执行事务提交或回滚
- RocketMQ分布式事务也是基于2PC实现的,实现思路
- 生产端将消息发往MQ服务,MQ服务将消息持久化后,向生产端反馈已收到,此时消息为半消息
- 生产端执行完本地事务后,会将执行结果向MQ服务进行二次确认,判断是否提交或回滚
- 如果提交,MQ服务将半消息标记为可投递,然后转发给消费端
- 如果回滚,MQ服务会将半消息删除
- 如果MQ服务没有收到二次确认,会对生产端进行消息回查,查看事务执行结果继续进行二次确认
RocketMQ生产端的发送模式
RocketMQ生产端有3种发送模式
- 同步发送
必须等到Broker反馈之后才能继续发,安全性最高但发消息最慢
单向发送
- 不管消息是否发成功都能继续发,所以吞吐量最高,但是安全性低,容易丢消息
异步发送
- 发送消息的同时回注册一个回调去处理响应,安全性低,容易丢消息
- 同步发送
RocketMQ消费端的消费模式
- RocketMQ消费模式支持 推模式 和 拉模式
- 推模式模式简单易用,有较好的实时性,但Broker压力较大
- 拉模式对于消费端可以更好的把控,Broker压力较小,需要手动指定offset
RocketMQ的消息确认机制
- 生产端采用消息确认多次重试的机制来保证消息能发送到MQ
- 3种发送消息的方式
同步发送
- 必须等到Broker反馈之后才能继续发,安全性最高但发消息最慢
单向发送
- 不管消息是否发成功都能继续发,所以吞吐量最高,但是安全性低,容易丢消息
异步发送
- 发送消息的同时回注册一个回调去处理响应,安全性低,容易丢消息
- 3种发送消息的方式
- 消费者端采⽤状态确认机制保证消费者⼀定能正常处理对应的消息
- Broker会通过记录重试次数,为了不影响topic下其它正常的消息,会给每个消费组设计对应的重试topic,在消息重试时,会将原topic的消息移动到对应的重试topic中去,当重试达到一定阈值会将失败的消息推入到死信topic中
- 消费者组由多个消费者实例组成,Broker只需要向某一个实例推送消息即可,保障消息重试机制正常运行,并且Broker只通过消费者返回的状态来判断是否处理成功,但是业务执行是否正确是无法知道的
- 消费者也可以⾃⾏指定起始消费位点
- Broker通过消费者返回的状态来推进消费者组对应的消息offset,虽然offset是Broker来维护,但是消费者可以自己指定offset进行消费
RocketMQ的持久化机制
- 当消息发往Broker端时,RocketMQ会将消息顺序写入CommitLog文件中,CommitLog文件由多个文件组成,以首条消息的offset作为文件名,并且文件固定最大1G,相对于Kafka还需要定位分区文件(top + 分区号)才能进行写入,减少了定位目标文件的时间,所以Kafka不适合过多Topic的场景
- 对于采用分段存储,有助于并行读取消息
- 对于消息固定最大1G,可以方便消息刷到内存
- 对于消息的offset,消费端可以通过指定offset去拉取感兴趣的消息,不会影响到别的消费者
- RocketMQ提供了消息索引机制,通过索引可以快速定位消息
- 消费端可以通过ComsumerQueue文件中的消息索引定位需要的消息记录,一个MessageQueue对应一个文件,ComsumerQueue会记录当前MessageQueue被消费者组消费到哪个CommitLog
- 消费端可以通过Index文件进行key或时间区间的消息检索,Index文件主要是以时间戳命名,所以可以用于时间区间的检索,其次它的结构与hash类似,每个槽位对应一条索引链(链表),槽位的值对应最新的索引号
- RocketMQ采用了异步刷盘和定期刷盘相结合的方式来提高写入性能,另外刷盘策略有同步和异步两种
- 异步刷盘是指消息首先写入内存缓冲区,然后由后台线程定期将内存中的数据刷写到磁盘
- 定期刷盘是指RocketMQ会定期触发刷盘操作,将内存中的数据刷写到磁盘
- 另外RocketMQ的Broker端采用一主多从高可用模式,主负责写,从负责备份,如果主出现故障,从节点会自动切换为主节点
RocketMQ的过期文件删除机制
- 消息既然需要持久化也需要对应的过期删除机制
- 如何判断过期文件
- RocketMQ中CommitLog文件和ComsumerQueue文件都是以偏移量命名,对于非当前写的文件如果超过了一定的保留时间会被认定为过期文件,随时都可以删除,所以对于RocketMQ的消息堆积也是有一定时间的,从而也会由于消息未消费导致消息丢失
- 何时删除过期文件
- RocketMQ中默认凌晨4点执行定时任务进行文件扫描,触发过期文件删除操作,如果磁盘空间不充足也会触发,所以官方建议Broker的磁盘空间不能少于4G
RocketMQ的数据刷盘机制
- 同步刷盘
- 边写边存盘
- 消息写入操作系统的页缓存后通知刷盘线程进行刷盘,刷盘成功之后唤醒等待的线程以及消息写成功的状态,保证了数据一定刷盘成功,吞吐量较小
- 边写边存盘
- 异步刷盘
- 先写后续再刷盘
- 消息可能只是写入操作系统的页缓存,就返回写入成功,但是会等待积累到一定程度去进行刷盘,保证了响应速度,但是容易丢数据
- 先写后续再刷盘
RocketMQ的主从复制原理
- 同步复制
- master和slave的数据都写入成功之后才进行反馈,如果master故障,slave仍有数据备份,方便数据恢复,但是可能因为数据写入延迟降低了吞吐量
- 异步复制
- 保证master写入成功就进行反馈,再通过异步同步数据到slave,如果master出现故障会导致slave无法同步数据导致数据丢失
RocketMQ如何保证消息不丢失
对于跨网络的节点可能会丢消息,因为MQ存盘都会先写入OS的PageCache中,然后再让OS进行异步刷盘,如果缓存中的数据未及时写入硬盘就会导致消息丢失
对于RocketMQ如何保证消息不丢失,主要涉及 生产端到Broker端、Broker端持久化、Broker端到消费端
生产端到Broker端
RocketMQ生产端有3种发送模式
同步发送
- 必须等到Broker反馈之后才能继续发,安全性最高但发消息最慢
单向发送
- 不管消息是否发成功都能继续发,所以吞吐量最高,但是安全性低,容易丢消息
异步发送
- 发送消息的同时回注册一个回调去处理响应,安全性低,容易丢消息
生产端可以采用同步发送,根据Broker的反馈判断是否继续发送以及重试
可以使用事务消息
Broker端持久化
- 采用同步刷盘以及2PC两阶段提交,来保证同步时不会丢消息,异步刷盘一断电就会丢消息
Broker端到消费端
- 采用同步消费机制,不要使用异步消费机制
- 在同步消费情况下,消费完消息之后再去给Broker端反馈,然后Broker端会去维护消息偏移量,如果消费失败可以进行一定次数的重试
- 在异步消费情况下,消费完消息的同时也会向Broker端反馈,然后Broker端会去维护消息偏移量,如果处理失败了,不会进行重试因为偏移量已经变更
- 采用同步消费机制,不要使用异步消费机制
另外RocketMQ服务需要有降级方案,对于RocketMQ来说,NameServer挂了,本身就无法保证消息不丢失了,所以应对这种场景,我们可以使用服务降级方案,将消息暂存到Redis、文件或内存中,等MQ服务恢复之后再将消息转移过去
NameServer挂了如何保证消息不丢失
- NameServer在RocketMQ中扮演路由中心的角色,提供Broker的路由功能,所有MQ都需要路由中心的功能
- 在Kafka中使用ZK和作为核心控制器的Broker一起来提供路由服务
- 在RabbitMQ中是由每一个Broker来提供路由服务的
- 在RockertMQ中单独抽取服务作为路由中心
- 对于RocketMQ来说,NameServer挂了,本身就无法保证消息不丢失了,所以应对这种场景,我们可以使用服务降级方案,将消息暂存到Redis、文件或内存中,等MQ服务恢复之后再将消息转移过去
RocketMQ如何保证消息有序
MQ的顺序包含局部有序和全局有序
局部有序:(只保证一部分消息链路消费有序)
- 生产端可以通过消息选择器指定发送到某个MessageQueue,从而保证局部有序
全局有序:(整个消息链路严格按照先进先出的顺序进行消费)
- 要保证全局有序就必须牺牲吞吐量,也就是一个topic只能有一个MessageQueue被消费(默认4个),可以通过锁队列的方式进行消费,保证全局有序
RocketMQ如何处理消息堆积
对于Kafka和RocketMQ消息积压并不会对性能有太大的影响,但是对于RabbitMQ就会导致性能直线下降
如何确定RocketMQ有大量的消息积压
- 对于RocketMQ可以通过控制台查看消息的积压情况
如何处理大量积压的消息
- 通过增加消费者去加快消费
- 如果Topic下的MessageQueue配置充足,那每个消费者会分配多个MessageQueue进行消费,所以可以增加消费者数加快消息消费,
- 如果消费者数 = MessageQueue数,此时增加额外的消费者效果是没有的,此时可以通过新建一个新的Topic配置足够的MessageQueue,将旧Topic中的消息转移到新Topic中,并且指定对应数量的消费者去平摊新Topic的MessageQueue去进行消费,之后再根据情况恢复原有情况
- Topic_A -> Consumer_A => Topic_A -> Consumer_A -> Topic_B -> Consumer_B
- 通过增加消费者去加快消费
谈谈对RocketMQ零拷贝的理解
- 零拷贝 就是通过避免来回拷贝提升文件传输速度,也就是减少用户态与内核态的拷贝次数
- Kafka在生产端发送消息到Broker端的过程中采用mmap,在Broker发送消息给消费端采用sendfile
- mmap 就是通过文件位置与进程地址空间建立映射关系,程序可以直接访问文件内容
- sendfile 就是直接向内核态发送sendfile指令来进行网络传输
- 相对于RocketMQ采用的都是mmap,主要是因为它支持处理消息的顺序以及消息过滤,因为sendfile只处理内核态,无法进入用户态进行数据的处理
Spring
谈一下IOC
- IOC主要是 IOC容器、控制反转以及依赖注入
- IOC容器 就是Spring通过包扫描将相关的Bean封装成BeanDefinition存放到BeanDefinitionMap,需要的对象可以通过这个Map拿出BeanDefinition进行反射创建返回
- 控制反转 就是对象主动创建变为被动创建,全部交给BeanFactory统一创建
- 依赖注入 就是IOC容器会将对象的依赖关系进行动态注入
谈一下AOP
- AOP就是面向切面编程,跟OOP面向过程编程相对,AOP一般用于将公共逻辑和业务逻辑进行拆分,可以减少代码间的耦合性
- AOP的实现方式主要有基于CGLIB动态代理和基于JDK动态代理
- 基于CGLIB动态代理是基于父子类实现的,主要是通过被代理的类生成一个代理子类,代理子类重写父类方法,并且将被代理类赋值给内部属性target,当执行完切面逻辑后,通过target执行被代理类方法
- 基于JDK动态代理是基于接口实现的,实现InvocationHandler和Proxy接口就行
- AOP在我们业务中应用场景主要有日志处理、限流处理、事务、异步、缓存等
ApplicationContext和BeanFactory的区别
BeanFactory 是Spring专门用来生成Bean、管理Bean的
ApplicationContext继承于BeanFactory,所以具有BeanFactory所有功能,并且还集成了国际化、系统环境变量等功能
Spring启动流程
- 简单来说,首先会初始化reader和scanner,然后扫描指定包路径下的class文件并封装成BeanDefinition存放到BeanDefinitionMap,然后调用refresh方法进行刷新容器,其中会进行一些初始化工作,比如Bean工厂初始化预处理、Bean工厂后置处理、国际化初始化等
Bean的生命周期
- 简单来说,Bean的生命周期主要分为 实例化、填充属性、处理Aware接口、BeanPostProcessor前置处理、初始化、BeanPostProcessor后置处理、销毁
- Bean工厂会根据Bean信息创建Bean实例,然后将定义的信息填充到实例中,检测Bean是否实现了Aware接口,并调用对应的方法
- 初始化前会进行BeanPostProcessor前置处理,然后可以通过InitializingBean或者init-method进行初始化Bean,然后初始化后会进行BeanPostProcessor后置处理,最后可以通过destroy-method或DisposableBean进行销毁Bean
Bean的作用域
- singleton
- 每次注入只会创建一次
- prototype
- 每次注入都会创建一次
- request
- 每次请求都会创建一次
- session
- 每个session内只会创建一次,随着session过期
- global_session
- 全局session只会创建一次,随着全局session过期
Bean如何保证线程安全
- Spring中的Bean不是线程安全的,如果想要保证线程安全可以从Bean的作用域来看
- 对于prototype、request作用域每次都会创建一个新对象,线程安全
- 对于singleton,绝大多数情况下,Bean是属于无状态的,不存在线程安全的问题
- 所以如果要保证线程安全,可以将Bean作用域改成prototype、request,或者使用ThreadLocal来保存线程变量副本,来保证线程安全
循环依赖如何解决
- 循环依赖就是多个对象之间存在属性相互依赖的问题,也就是先有鸡还是先有蛋的问题
- 对于如何解决循环依赖,Spring中可以通过 @Lazy 解决构造方法造成的循环依赖问题,而另外得谈到Spring中的三级缓存解决循环依赖,它的一级缓存singletonObjects用来存放完整生命周期的对象,二级缓存earlySingletonObjects用来存放初始化的半成品对象,其实一级缓存和二级缓存已经可以解决正常循环依赖的问题了,但是考虑到AOP以及Spring的整个设计,引入了三级缓存singletonFactories来处理AOP,三级缓存存放的是ObjectFactory,当发现产生循环依赖时就会通过ObjectFactory创建动态代理类,提前进行AOP,对应的一级缓存singletonObjects存放的是代理对象
事务传播行为
- PROPAGATION_REQUIRED
- 默认传播行为,如果当前不存在事务就创建新事务,否则就加入当前事务
- PROPAGATION_SUPPORTS
- 如果当前不存在事务就以非事务运行,否则就加入当前事务
- PROPAGATION_MANDATORY
- 如果当前不存在事务就报错,否则就加入当前事务
- PROPAGATION_REQUIRES_NEW
- 每次都会创建新事务
- PROPAGATION_NOT_SUPPORTED
- 如果当前存在事务,就将当前事务挂起,否则以非事务运行
- PROPAGATION_NEVER
- 如果当前存在事务就报错,否则以非事务运行
- PROPAGATION_NESTED
- 如果当前存在事务,就嵌套事务内运行,如果不存在事务就按照PROPAGATION_REQUIRED运行
Spring如何处理事务
- Spring中提供了 编程式事务 和 声明式事务
- 编程式事务 就是使用TransactionTemplate,可以很好的控制事务粒度
- 声明式事务 就是基于AOP实现的注解@Transactional,只能应用于方法,不能很好的控制事务粒度
谈一下Spring事务机制
- Spring中的事务机制主要是通过数据库事务和AOP进行实现的
- Spring会为加了@Transactional的Bean生成一个代理对象作为Bean,当调用代理对象的方法时,整个事务中如果存在异常就会触发事务回滚,反之提交,注意不要自己try、catch异常不抛出,会导致事务失效,而整个事务的隔离级别与数据库的一致,传播行为是Spring自己实现的,一个数据库连接一个事务
@Transactional事务失效
- @Transactional 是基于AOP实现也就是基于CGLIB动态代理实现的,如果想要事务生效,首先需要让方法是公开才能重写CGLIB父类,其次就是需要让代理对象去执行方法才会生效
- 常见事务失效的场景
- 异常被捕获并处理
- 事务方法内部调用其他事务方法
- 默认情况下,被调用的方法将会在一个新的事务中执行,如果希望被调用的方法在当前事务中执行,可以使用Propagation.REQUIRED属性来指定
- 访问权限问题
Spring中的设计模式有哪些
简单工厂
由一个工厂类根据传入的参数,动态决定应该创建哪个实例
Spring中的BeanFactory就是简单工厂模式的体现,根据传入一个唯一的标识来获得Bean对象,但是否是
在传入参数后创建还是传入参数前创建这个要根据具体情况来定
工厂方法
- 实现了FactoryBean接口的bean是一类叫做factory的bean,spring会在使用getBean()调用获得该bean时,会自动调用该bean的getObject()方法,所以返回的不是factory这个bean,而是这个bean.getOjbect()方法的返回值
单例模式
- 保证一个类仅有一个实例,并提供一个访问它的全局访问点
- Spring中的单例模式完成了后半句话,即提供了全局的访问点BeanFactory,但没有从构造器级别去控制单例,这是因为spring管理的是任意的java对象
适配器模式
- Spring定义了一个适配接口,使得每一种Controller有一种对应的适配器实现类,让适配器代替controller执行相应的方法,这样在扩展Controller时,只需要增加一个适配器类就完成了Spring MVC的扩展了
装饰器模式
- 动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator模式相比生成子类更为灵活
- Spring中用到的包装器模式在类名上有两种表现
- 一种是类名中含有Wrapper,另一种是类名中含有Decorator
动态代理
- 切面在应用运行的时刻被织入,一般情况下,在织入切面时,AOP容器会为目标对象创建动态的创建一个代理对象,Spring就是这样织入切面的
观察者模式
- Spring的事件驱动模型使用的是观察者模式 ,Spring中Observer模式常用的地方是Listener的实现
策略模式
- Spring框架的资源访问Resource接口。该接口提供了更强的资源访问能力,Spring 框架本身大量使用了Resource 接口来访问底层资源
模板方法
- 父类定义了骨架(调用哪些方法及顺序),某些特定方法由子类实现
- refresh方法
@Autowired和@Resource的区别
- @Resouce在没有指定别名的情况下,@Autowired和@Resource都是先byType再byName
- @Resouce在指定别名的情况下是先byName再byType
- 它们注入的逻辑基本一致,@Resource在CommonAnnotationBeanPostProcessor中实现的,@Autowired在AutowiredAnnotationBeanPostProcessor中实现的
Spring MVC
Spring MVC请求流程
- 用户发送请求到前端控制器DispatcherServlet
- DispatcherServlet收到请求调用HandlerMapping处理映射器
- 处理映射器根据请求URI找到具体的处理器生成处理器和处理器拦截器并且返回给DispatcherServlet
- DispatcherServlet调用HandlerAdapter处理器适配器
- HandlerAdapter经过适配器调用具体的页面控制器(Controller)
- 页面控制器(Controller)执行完返回ModelAndView
- HandlerAdapter将ModelAndView返回给DispatcherServlet
- DispatcherServlet将ModelAndView传给ViewResolver视图解析器
- ViewResolver解析后返回给具体的View
- DispatcherServlet根据View进行渲染视图也就是将模型数据填充到视图中
- DispatcherServlet响应用户
Spring和Spring MVC为什么需要父子容器
- 从实现层面来说不用父子容器也是可以实现功能的,因为SpringBoot就没有使用父子容器
- 父子容器主要作用应该就是Spring为了划分框架界限去实现单一职责,Service、Dao交给Spring来管理,Controller交给Spring MVC来管理
- 父容器Controller无法直接访问子容器Service,而子容器Service可以访问父容器Controller
- 方便子容器的切换,如果现在我们想从Spring MVC替换成Struts,只需要更换对应的配置文件就行
- 父子容器也节省重复Bean的创建
是否可以把所有Bean都通过Spring容器来管理
- 不可以,因为这样会导致请求接口的时候产生404
- 如果所有的Bean都交给了父容器,Spring MVC初始化HandlerMethods的时候无法根据Controller的handler方法注册HandlerMethod,并没有去查找父容器的Bean,也就是无法根据请求URI获取到HandlerMethod来进行匹配
是否可以把我们所需的Bean都放入Spring MVC子容器里面来管理
可以,因为父容器的体现无非就是为了获取子容器不包含的Bean,如果全部包含在子容器完全用不到父容器了,所以是可以将Bean全部放在Spring MVC的子容器里来管理
虽然可以这么做但是不推荐这么做,如果项目里有用到事务或者AOP需要把这些配置也要配置到SpringMVC子容器的配置文件中,不然一部分配置在父容器一部分在子容器,这样可能导致功能不生效
Spring Boot
Spring Boot常用注解
- @SpringBootApplication
- 这个注解标识了一个SpringBoot工程,它实际上是另外三个注解的组合
- @SpringBootConfiguration
- 这个注解实际就是一个@Configuration,表示启动类也是一个配置类
- @EnableAutoConfiguration
- 向Spring容器中导入一个Selector用来加载classpath下spring.factories中定义的自动配置类,将这些自动加载为配置Bean
- @ComponentScan
- 表示扫描路径,因为默认是没有配置实际扫描路径的,所以SpringBoot扫描的路径是启动类所在的当前目录
- @SpringBootConfiguration
- 这个注解标识了一个SpringBoot工程,它实际上是另外三个注解的组合
- @Bean
- 用来定义Bean,类似于XML中<bean>,Spring在启动的时候会对这些@Bean注解的方法进行解析,将方法的名字作为beanName,并通过执行方法得到Bean对象
Spring Boot自动装配原理
- Spring Boot通过import导入DeferredImportSelector,将其放在最后进行加载,为了方便定制我们自己的Bean,然后去扫描所有jar包下的spring.factories文件,把其中所有需要自动装配的类的全类名放到一个list,然后进行排序后交给Spring,Spring会将它们封装成BeanDefinition,放到BeanDefinitionMap中去,最后Spring就能管理到这些Bean
Jar启动流程
通过spring-boot-plugin生成MANIFEST文件,其中main-class指定了运行java -jar的主程序,把依赖的jar文件打包在了Fat Jar
当我们执行指令java -jar,它就会去运行JarLauncher
所有依赖的jar文件都在/BOOT-INF/lib目录下以及所有依赖的class文件在BOO-INF/classes目录下
JarLauncher会根据路径去加载jar和class,加载之后,会去找到Start-Class然后使用反射去调用本地应用程序的Main方法
如何自定义Starter
- 创建自定义Starter项目
- 创建@Configuration配置类,还可以添加一些@ConditionOnXXX的注解控制配置的生效条件,结合XXXProperties 获取配置信息 等
- 重点在 resources/META-INF 下创建文件 spring.factories , 并将需要生效的配置类设置到EnableAutoConfiguration中
SpringBoot是如何启动Tomcat的
⾸先SpringBoot在启动时会先创建⼀个Spring容器
在创建Spring容器过程中,会利⽤@ConditionalOnClass技术来判断当前classpath中是否存在Tomcat依赖,如果存在则会⽣成⼀个启动Tomcat的Bean
Spring容器创建完之后,就会获取启动Tomcat的Bean,并创建Tomcat对象,并绑定端⼝等,然后启动Tomcat
Spring Cloud
CAP理论
- 一致性©
- 在分布式环境下,系统对某数据写操作之后仍能保证多个节点访问到相同的此数据
- 可用性(A):
- 每次访问都能成功响应,但不能保证获取实时数据
- 分区容错性§
- 在分布式环境下,能保证在非网络问题故障下稳定运行,仍能保证CP
Base理论
Base理论 就是在分布式环境下,要保证 基本可用、软状态以及最终一致性
基本可用
- 在分布式系统出现故障的情况下,允许损失一定的可用性来保证基本可用,比如通过服务降级、页面降级等措施
软状态
- 在分布式系统允许出现不影响系统可用性的写数据延迟
最终一致性
- 数据备份节点经过一段时间达到一致性
Base理论就是对于CAP中C和A的权衡,我们无法保证强一致,但是能通过适当的方式保证最终一致性
- 弱一致性
- 系统中对于某数据的写操作后,无法保证在不同时间段读取到一致的数据
- 最终一致性
- 系统在保证没有新的写操作的情况下,读取到的数据是一致的最新数据
- 顺序一致性
- 任何一次读都能读到最近一次写的数据,并且新的写入是建立在已经达成同步的基础上的
- 弱一致性
Spring Cloud核心组件以及作用
- Eureka: 注册中心
- Nacos: 注册中心、配置中心
- Consul: 注册中心、配置中心
- Spring Cloud Config: 配置中心
- Feign/OpenFeign: RPC调用
- Kong: 服务网关
- Zuul: 服务网关
- Spring Cloud Gateway: 服务网关
- Ribbon: 负载均衡
- Spring Cloud Sleuth: 链路追踪
- Zipkin: 链路追踪
- Seata: 分布式事务
- Dubbo: RPC调用
- Sentinel: 服务熔断
- Hystrix: 服务熔断
Eureka的数据同步原理
- Eureka是一个服务注册中心,对于集群之间的数据同步,采用对等复制的方式,也就是不存在主从之分,任何一个节点都可以接收和写入,一旦有一个节点数据发生变更,就会直接同步到其他节点上
- Eureka这种无中心化节点的数据同步,需要考虑数据同步死循环的问题,也就是需要区分数据来源(属于客户端传来的数据还是集群中其他节点发来的同步数据),Eureka通过时间戳的标记来区分
- Eureka从数据同步上来看采用AP机制,对数据不提供强一致性保障
谈一下分布式事务
本地事务 就是对于操作单一数据库的场景下的事务,ACIO特性是数据库直接支持的
分布式事务 就是在分布式环境下,需要保证不同服务的数据一致性,一般用于跨库事务、跨服务调用
分布式事务的实现方式
- 基于2PC两阶段提交
- 准备阶段,协调者通知参与者准备提交各自的事务
- 提交阶段,参与者反馈,协调者通过反馈去决定执行事务提交或回滚
- 基于3PC三阶段提交
- 3PC提交建立在2PC提交的基础上,将准备阶段拆分两步并且引入超时机制
- 询问阶段,协调者向参与者询问是否都能提交
- 预提交阶段,参与者能提交就先提交并且向协调者反馈,如果不能就放弃
- 提交阶段,协调者处理最终事务提交
- 基于2PC两阶段提交
分布式事务存在的问题
- 在准备阶段会出现同步阻塞,第一时间收到通知的服务会锁定资源,直到提交才会释放
- 容易出现单点故障,协调者一旦挂了,参与者都会阻塞,提别是提交阶段
- 可能造成数据不一致,如果提交阶段出现问题,可能导致一部分参与者的数据不一致
对于分布式事务,在实际生产中可以异常落表定时自动巡检、基于RocketMQ事务消息、基于Seata
- 异常落表定时自动巡检
- 当分布式事务执行失败时,将失败的信息记录到一张特殊的表中,然后通过定时任务去巡检这张表,对失败的事务进行重试或其他补偿操作
- 基于RocketMQ事务消息
- RocketMQ支持事务消息,先发送一条半事务的消息,待本地事务执行成功后再提交这条消息,如果失败则回滚
- 基于Seata
- Seata支持多种2PC模式
- 异常落表定时自动巡检
谈谈对Seata的理解
- Seata是用于处理分布式事务的解决方案,提供了AT、TCC、SAGA和XA事务模式,AT模式首推
- AT模式的核心是对业务无侵入的
- 第一阶段,会将业务数据以及undo日志(回滚日志)进行本地事务提交,释放本地锁以及连接
- 第二阶段,如果提交,会将undo日志删除,如果回滚,会通过undo日志反向解析SQL语句进行回滚
- XA模式是一个强一致性的2PC模式
- 每个分支的事务必须都完成之后,才进行提交
- TCC模式对业务有侵入性的2PC模式
- 每个分支的事务都需要具备自己的两阶段,适用于复杂业务场景
- try阶段 用于进行业务的检查,预留必要的业务资源
- confirm阶段 用于具体业务的实现
- cancel阶段 用于事务执行失败之后,释放try阶段预留的资源
- 每个分支的事务都需要具备自己的两阶段,适用于复杂业务场景
- SAGA模式是基于状态机引擎的2PC模式
- 将长事务拆分为多个本地子事务以及相应的补偿操作来保证数据一致性
- AT模式的核心是对业务无侵入的
场景题
网络调用,串行是10s,修改为并行后能压缩到1s,此时CPU飙升,如何平衡这个点,线程池是怎么设置,qps高的情况下,线程数少就会导致排队出现io爆炸
- 这种事件一般线程池参数已经解决不了问题了
- 此时从业务角度判断,比如是否能缓存,哪些接口能缓存,哪些接口能拆异步,能否从用户群体把接口拆分,例如新用户全部调用,活跃用户只调两三个,能否接口后置,不阻塞核心流程等等
网站首页公告,假设公告id是连续,不间断的,要求使用Redis存储,并且根据评论的时间排序,而且还有分页的功能,请给出你的解决方式,从存储方式和查询方式分析,以及分页怎么获得总数据数,分页怎么分
- 存储方式: zset
- zset(公告key,评论的时间戳,公告信息)
- 查询方式
- 分页
- zrevrange/zrange
- 获取记录总数
- zcard
- 分页
某个文章的评论,要求使用Redis存储,并且根据评论的时间排序,而且还有分页的功能,请给出你的解决方式,从存储方式和查询方式分析,以及分页怎么获得总数据数,分页怎么分
- 存储方式: zset
- zset(公告key,评论的时间戳,公告信息)
- 查询方式
- 分页
- 加载第一页数据: zrevrange/zrange
- 加载第一页后数据: zrevrangebysocre/zrangebysocre
- 获取记录总数
- zcard
- 分页