后端面试八股文
2022-10-21 09:23:20 1 举报
AI智能生成
java后端面试题,持续更新……欢迎大家来补充,纠错,相互学习
作者其他创作
大纲/内容
后端性能优化
缓存化
sql优化
服务化
服务拆分
逻辑优化
异步化
线程池
注意核心参数的设置
通过监控工具观测实际创建、活跃、空闲线程数
结合CPU、内存的使用率情况做线程池优化
消息队列
NIO实现异步化
RPC框架
Servlet
搜索引擎
硬件升级
分布式事务
产生的原因
数据库分库分表
微服务化
解决方案
刚性事务——强一致性
2PC:两阶段提交
提交事务请求
执行事务请求
3PC:改进,增加超时机制
柔性事务——最终一致性
消息发送一致性
业务接口幂等性
原因:消息没有及时ACK确认
解决:根据唯一标识查询消息是否被处理、或者根据消费日志表,来维护消息消费记录
保证最终一致性的模式
可查询模式
补偿模式
最大努力通知模式
缓存、mysql双写一致
需求起因
并发访问,使用redis做缓冲操作,让请求先访问到redis
缓存和数据库一致性解决方案
延时双删策略
在写库前后都进行redis删除,设置合理的超时时间
合理的超时时间
考虑redis和数据主从同步的耗时
确保读请求结束,在读数据逻辑的耗时上,加个几百毫秒
设置缓存过期时间
保证最终一致性
弊端
结合双删策略+缓存超时设置
最差的情况是在超时时间内数据存在不一致,增加写请求耗时
异步更新缓存(基于订阅binlog的同步机制)
mysql增量订阅消费+消息队列+增量数据更新到redis
mysql数据库性能瓶颈
CPU
非索引字段查询
CPU运算操作
单表数量过大,超过1个亿。查询时遍历的层次太深或扫描行太多。sql效率低
IO
读
热点数据多,数据缓存放不下,查询时产生大量磁盘IO,查询速度慢
分库
水平分表
写
写入频繁,产生大量连接
分担压力,分库
如何排查OOM
查询服务器运行日志,捕捉内存溢出异常
jstat查看监控JVM的内存和GC情况,评估问题大概出现在什么区域
使用MAT工具载入dump文件,分析大对象占用情况
ThreadLocal
线程本地变量,线程隔离
原理
每个线程都有ThreadLocalMap变量
ThreadLocalMap是ThreadLocal的内部类
key是ThreadLocal(弱引用)
值是对应线程变量副本
线程可以有多个本地变量,存放在ThreadLocalMap
内存泄漏
条件
threadLocal 被回收 && 线程复用 && 没有调用get/set/remove方法
为什么
ThreadLocal被回收后,ThreadLocalMap的key没有指向,而value的指向Object还一直存在
为什么不把Thread作为ThreadLocalMap的key
如果把所有线程的本地变量都放到一个map,所有线程都去访问map时,性能会下降
一个线程会有多个变量,如果线程作为key,无法set多个变量
map的生命周期无法维护,因为多个线程都用这个map,就无法销毁map。如果是Thread去维护map,随着Thread执行完毕,map也会被销毁
Full GC触发条件
调用System.gc()
只是建议虚拟机执行Full GC,但是虚拟机不一定真正执行
不建议使用
老年代空间不足
空间分配担保失败
采用复制算法的minor gc 需要老年代的内存空间做担保,如果担保失败会执行一次Full gc
什么是JMM,java内存模型
目的是为了屏蔽系统平台、硬件的差异,使得访问内存的结果都一致。主要是解决了多线程环境中,访问共享资源时出现的可见性、原子性、指令重排问题
内容
主内存和工作内存
happens before原则--可见性
vloatile关键字修饰的变量,写操作happens before于读操作
A线程创建B线程,A在创建B之前的操作happens before于创建B的操作
A线程join B线程,join之前的操作happens before于B操作
按照代码顺序,前面的代码happens before于后面的代码
线程池
为什么使用
减少资源消耗
提高系统响应效率
提高线程统一管理性
执行原理
线程池里存活的线程数小于核心线程数
对于一个新提交的任务,线程池会创建一个线程去处理任务
线程池里存活的线程数小于等于核心线程数时
线程池里的线程会一直存活
没有任务时,会一直阻塞,等待任务队列的任务来执行
线程数等于核心线程数,对于新提交的任务,会被放进workQueue排队等待执行
任务队列满了,再来新的任务,会创建新的线程处理新的任务
线程数达到maximumPoolsize,不再创建线程
如果还有新的任务,则采用拒绝策略进行处理
参数有哪些
核心线程数
最大线程数
阻塞队列
存储等待运行的任务
工作线程空闲后存活时间
时间单位
线程工厂
拒绝策略
AbortPolicy-默认策略
直接抛出RejectExecutionException
DiscardPolicy-不处理直接丢弃
DiscardOldestPolicy-抛弃等待队列队首任务
CallerRunPolicy-由调用线程处理
线程池大小怎么设置
CPU密集型
这种任务主要消耗CPU资源
可将线程数设置为N(CPU核心数)+1
多出来的一个是为了防止某些原因导致的线程阻塞带来的影响。
IO操作
线程sleep
等待锁
一旦某个线程被阻塞,释放CPU资源,多出来的线程就可以充分利用CPU空闲时间
IO密集型
系统大部分时间在处理IO操作,此时线程可能会被阻塞,释放CPU资源
因此IO密集型的应用中,可以多配置一些线程,将CPU交出给其他线程使用
计算方法
最佳线程数 = CPU核心数 *(1 / CPU利用率)= CPU核心数 * (1 + (IO耗时/CPU耗时))
一般设置为2N
线程池类型有哪些,适用场景
FixedThreadPool
固定线程池数的线程池
使用无界队列LinkedBlockingQueue(队列容量是Integer.MAX_VALUE)
没有拒绝策略
适用场景
适用于处理CPU密集型任务,适合执行长期的任务
FixedThreadPool不会拒绝任务,在任务多时会导致OOM
SingleThreadExecutor
只有1个线程的线程池
使用无界队列LinkedBlockingQueue
线程池只有1个运行的线程,新来的任务放入工作队列
线程处理完任务就循环从队列获取任务执行,保证顺序的执行各个任务
适用场景
适用于串行执行任务的场景,一个任务一个任务执行
任务较多时会导致OOM
CacheThreadPool
根据需要创建新线程的线程池
允许创建的线程数量为Integer.MAX_VALUE
如果主线程提交任务的速度高于线程处理任务的速度时,CacheThreadPool会不断创建线程,极端情况下,会导致耗尽cpu和内存资源
使用没有容量的SynchronousQueue作为线程池工作队列,当线程池有空闲线程时,提交的任务会被空闲线程处理,否则会创建新的线程处理任务
适用场景
用于并发执行大量短期的小任务
可能会创建大量线程,导致OOM
ScheduledThreadPool
给定的延迟后运行任务或定期执行任务,实际项目基本不会用
适用场景
周期性执行任务的场景,需要限制线程数量的场景
如何保证核心线程不被销毁
线程池内的线程被包装成一个一个的worker
worker会获取任务不停执行
获取worker中的第一个任务
如果第一个任务不为空则执行具体流程
第一个任务为空,则while循环,从阻塞队列中获取任务
调用take获取任务,如果没有任务,则会阻塞该线程
线程的生命周期
创建
就绪
运行
阻塞
等待阻塞(o.wait -> 等待队列)
同步阻塞(lock -> 锁池)
其它阻塞(sleep/join)
死亡
线程死锁
两个或两个以上线程在执行过程中,因争夺资源而造成的一种互相等待的现象
解决
超时释放
占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放
有序竞争
一次性申请所有资源
避免线程占有资源时等待其他资源
阻塞队列
举例
ArrayBlockingQueue
底层是数组
有界阻塞队列
LinkedBlockingQueue
单向链表实现的有界阻塞队列
默认和最大长度是Integer的最大值
SynchronousQueue
不存储元素的阻塞队列
适合传递性场景
ReentrantLock如何实现可重入性的
内部自定义了同步器sync
加锁的时候通过CAS算法,将线程对象放到一个双向链表中
每次获取锁的时候,检查当前维护的那个线程ID是否一致,如果一致,同步状态+1
表示锁被当前线程获取多次
CyclicBarrier和CountDownLatch的区别
都实现线程之间的等待
CountDownLatch是某个线程等待其他线程执行完后再执行
CyclicBarrier是多个线程互相等待,等到达某一个状态,同时执行
CyclicBarrier可以重置次数(使用reset方法),可以处理更为复杂的业务场景,CountDownLatch不行
双亲委派
java有三种加载器
启动类加载器
扩展类加载器
应用程序类加载器
流程
一个类加载器收到一个类的加载请求
首先不会自己尝试去加载
而是去查缓存,是否加载过,没有的话,则把请求委派给父类加载器
父类加载器反馈自己无法完成这个加载请求是,子加载器才会自己尝试去加载
目的
避免重复加载相同字节码
安全性
避免自己写的类替换java核心类
类实例化顺序
静态代码块
父类
当前类
父类普通代码块
父类的构造函数
当前类普通代码块
当前类的构造函数
HashMap
扩容
为什么扩容
可以提升查询效率
扩容机制
当前数组是否为空
是
确定数组长度
旧阈值是否为0
是(调用无参构造函数)
长度默认是16
阈值默认是12(16 * 0.75)
否(调用有参构造函数)
长度为指定长度,最接近2的次幂的值
阈值是长度的0.75倍
创建新数组
返回新数组
不是,正常扩容
确定数组长度
旧数组长度是否等于最大设定长度
是
阈值设置为Integer的最大值,返回旧数组
否
创建长度为原长度的2倍
阈值设置为旧阈值的2倍
创建新数组
table指向新数组
遍历旧数组,迁移数据
当前节点的下一个节点是空
旧位置只有1个节点
计算新位置,(e.hash & newCap - 1)
当前节点有下一个节点
树结构
链表
遍历链表
e.hash & oldCap
0,低位
1,高位
低位链表,把链表头结点指向原位置
高位链表,把链表头结点指向新索引
put流程
如果table没有初始化,就先初始化
使用hash算法计算key的索引
如果索引处没有存在元素,直接插入元素
如果已经有元素
头结点是链表
遍历到队尾插入
头结点是红黑树
按照红黑树结构插入
链表数量是否大于8,且数组长度大于64
链表转红黑树
检查是否需要扩容
阈值为数组长度的0.75
为什么HashMap会选择使用链表+红黑树
因为红黑树需要进行左旋、右旋变色进行平衡
单链表不需要
所以当元素个数少于8时,链表查询性能是可以保证的
而当元素个数大于8,且数组长度大于64时,会采用红黑树
因为红黑树查询时间复杂度为O(logn)
链表为O(n)
只有当n较大时,红黑树查询效率才会高
HashMap长度为什么是2的幂次方
一般用什么作为HashMap的key
Integer或者是String
因为它们都是不可变类型的,一创建之后,hashCode就会缓存起来
无需重新计算hash值
获取对象的时候需要equals、hashCode方法,Integer和String都已重写
无需自己重写方法
HashMap为什么线程不安全
1.7
扩容的时候,使用头插法,多线程可能会导致环形链表,形成死循环
1.8
多线程会出现数据覆盖的情况
HashSet原理
基于HashMap实现
放入HashSet中的元素实际上有HashMap的key保存
value存储一个静态的Object对象
HashSet、LinkedHashSet和TreeSet区别
HashSet是Set接口的主要实现类,HashSet底层是HashMap,线程不安全,可以存储null值
LinkedHashSet是HashSet的子类,能够按照添加的顺序遍历
TreeSet底层使用红黑树,能够按照添加元素的顺序进行遍历,排序方式可以自定义
redis
redis是单线程还是多线程
redis6.0之前的单线程指的是网络IO和键值对读写是由一个线程完成的‘
redis6.0引入的多线程指的是网络请求过程采用多线程,而键值对读写命令仍然是单线程处理的
也就是只有网络请求模块和数据操作模块是单线程的
持久化、集群数据同步都是由额外的线程执行
ConcurrentHashMap
CAS和Synchronized保证并发安全
Synchronized只锁定当前链表或红黑二叉树的首节点
volatile修饰数组和指针next
put流程
如果没有初始化就先进行初始化
使用hash算法计算key的位置
如果这个位置为空,直接CAS插入,如果不为空,取出这个节点
取出的节点hash值是MOVED(-1)的话,表示当前正在对这个数组进行扩容,当前线程帮助去复制
节点不为空,也不在扩容,通过synchronized加锁,添加操作
链表遍历到队尾插入或覆盖相同key
红黑树按照红黑树结构插入
链表阈值大于8
数组长度
大于64
转换红黑树
小于64
扩容
添加成功,检查是否需要扩容
扩容
设置步长,表示一个线程处理的数组长度
最小值是16
在一个步长范围内只有一个线程会对其进行复制移动操作
synchronized底层实现原理
作用范围
普通方法
锁对象实例(this)
证明
例子1:
两个普通方法都加锁,声明对象,用两个线程分别调用这个对象的两个方法
结果:必须1个方法执行完,才会执行下一个方法。证明是普通方法是锁住了调用对象
例子2:
1个普通方法加锁,声明两个对象,两个线程分别两个对象调普通方法
结果:两个线程交替执行
静态方法
锁class实例
class类信息存储在永久代,是线程共享的
因此静态方法锁相当于全局锁
锁所有调用该方法的线程
代码块
锁传入synchronized的对象实例
底层实现原理
无论是修饰方法还是代码块,实际上都是锁实例
实例对象的对象头有Mark word信息,记录对象关于锁的信息
每个对象都有一个monitor对象,加锁就是在竞争monitor对象
monitor(监视器)对象存储这当前持有锁的线程以及等待锁的线程队列
代码块加锁是在前后分别加上monitor enter和monitor exit指令,方法加锁则是通过ACC_SYNCHROINZE关键字来标识
锁升级
无锁
偏向锁
在对象头,mark word中标记线程id
只有1个线程进入临界区
轻锁
CAS
多个线程交替进入临界区
重量级锁
多个线程同时进入临界区
AQS
抽象队列同步器
实现
阻塞线程和唤醒线程-CLH队列
CLH队列,虚拟的双向队列
AQS将每条请求共享资源的线程封装成一个CLH锁队列的一个节点,实现锁的分配
同步状态
CAS对该同步状态进行原子操作修改值
资源共享方式
独占
公平锁
非公平锁
共享
CAS存在的问题
只能保证一个共享变量的原子操作
循环时间长,开销大
会出现ABA问题
解决
加时间戳
加版本号
ReentrantLock和synchronized的区别
使用synchronized实现同步,同步代码执行完可以自动释放锁。ReentrantLock需要手动释放锁
synchronized是非公平锁,ReentrantLock可以是设置公平锁
synchronized获取锁不可中断,ReentrantLock上等待获取锁的线程可以中断
ReentrantLock可以设置超时等待锁。到截止时间未获取到锁则返回
ReentrantLock可以尝试非阻塞获取锁,调用该方法立刻返回,获取到锁返回true,获取不到返回false
接口设计原则
优雅的设计一个接口,需要考虑哪些方面?安全性如何保证
数据有效性
常规性校验
必填字段、长度校验、类型校验、格式校验
业务校验
根据实际业务而定
幂等设计(通过幂等性防止重复操作)
业务层
同一个永不不重复下单,MQ不重复消费
请求层
多次执行结果都是一致的
本质
分布式锁
分布式环境下,锁定全局唯一资源,使用请求串行化、实际表现为互斥锁、防止重复、解决幂等
安全性
加密方式
对称加密
非对称加密
数据签名
时间戳
JVM内存结构
线程共享
堆
存放对象实例
垃圾收集器主要区域
方法区
用于存储已被虚拟机加载的类信息、常量、静态变量等数据
垃圾回收主要目标是对常量池的回收和类的卸载
线程私有
程序计数器
记录当前虚拟机正在执行的线程指令地址
通过它实现代码流程控制
顺序执行
选择
循环
异常处理
多线程下,程序计数器用于记录当前线程执行的位置
虚拟机栈
执行java方法
本地方法栈
执行本地方法(C、C++)
内存分配策略
对象优先在Eden分配
新生代
当新生代空间不够,出发minor GC
大对象直接进入老年代
大对象需要连续内存空间的对象
如长字符串和大数组
长期存活的对象进入老年代
对象经过1次minor GC仍然存活,年龄增加1岁
年龄到达15岁,进入老年代
动态对象年龄判断
如果survivor中相同年龄所有对象大小的总和大于survivor空间的一半
则年龄大于或等于该年龄的对象可直接进入老年代
GC ROOT
虚拟机栈
栈中引用的对象
本地方法栈
Native方法引用的对象
方法区
类静态属性引用的对象
常量引用的对象
类加载过程
是什么
将类的class文件中的二进制数据读入到内存中
将其放在运行时数据区的方法区内
然后在堆区创建一个此类的对象
通过这个对象可以访问到方法区对应的类信息
编译
将源码文件编译成JVM可以解释的class文件
加载
双亲委派发生的阶段
查找并加载类的二进制数据
在JVM堆中创建一个Class类对象
将类相关信息存储在JVM方法区
对class信息进行验证
验证是否符合java规范和JVM规范
为类变量分配内存空间并对其赋默认值
为静态变量赋予正确的初始值
收集class静态变量、静态代码块、静态方法至普通方法
执行构造方法
解释
把字节码转为操作系统可识别的执行指令
执行
调用系统硬件执行最终的程序指令
0 条评论
下一页