友邦咨询(成都)- Java开发 二面 面经
1. 详细介绍一下你最有成就感的项目,技术架构是怎样的?
我参与开发过一个在线教育平台的后端系统。这个项目采用微服务架构,主要技术栈包括:
技术架构:
- 后端框架:Spring Boot + Spring Cloud
- 服务注册与发现:Nacos
- 网关:Spring Cloud Gateway
- 负载均衡:Ribbon
- 熔断降级:Sentinel
- 远程调用:OpenFeign
- 数据库:MySQL 8.0(主从复制)+ Redis
- 消息队列:RabbitMQ
- 对象存储:阿里云OSS
- 搜索引擎:Elasticsearch
我主要负责课程管理模块和订单支付模块。课程管理涉及课程的增删改查、分类管理、课程搜索等功能。订单支付模块对接了支付宝和微信支付,需要处理订单创建、支付回调、订单状态更新等流程。
最有挑战的是处理支付回调的幂等性问题,因为第三方支付平台可能会重复发送回调通知。我使用Redis的分布式锁和订单状态机来保证幂等性,确保订单不会被重复处理。
这个项目让我对微服务架构有了深入理解,也积累了处理分布式场景下常见问题的经验。
2. HashMap的底层实现原理,JDK 1.7和1.8有什么区别?
HashMap底层是数组+链表+红黑树的结构。
基本原理:
- 通过key的hashCode计算hash值,再通过hash值确定在数组中的位置
- 如果该位置没有元素,直接放入
- 如果有元素(hash冲突),采用链表或红黑树存储
- 当链表长度超过8且数组长度大于64时,链表转为红黑树
- 当红黑树节点少于6时,退化为链表
JDK 1.7和1.8的主要区别:
数据结构:1.7是数组+链表,1.8引入了红黑树,优化了hash冲突严重时的查询性能,从O(n)提升到O(log n)。
插入方式:1.7使用头插法,多线程环境下扩容可能形成环形链表导致死循环;1.8使用尾插法,避免了这个问题。
扩容时机:1.7是先扩容再插入,1.8是先插入再扩容。
hash算法:1.8的hash算法更简单,只做了一次异或运算,性能更好。
扩容优化:1.7需要重新计算所有元素的位置;1.8通过高位运算,元素要么在原位置,要么在原位置+旧容量的位置,不需要重新计算hash。
3. ConcurrentHashMap的实现原理,1.7和1.8有什么区别?
ConcurrentHashMap是线程安全的HashMap,性能比Hashtable好很多。
JDK 1.7实现:
- 采用分段锁(Segment)机制,每个Segment继承自ReentrantLock
- 默认16个Segment,理论上支持16个线程并发写
- 每个Segment内部是一个类似HashMap的结构
- 读操作不加锁(使用volatile保证可见性),写操作只锁当前Segment
- size()方法需要遍历所有Segment,先尝试不加锁统计,如果发现有修改则加锁重新统计
JDK 1.8实现:
- 取消了Segment分段锁,采用CAS + synchronized实现更细粒度的锁
- 锁的粒度是数组的每个位置(Node),并发度更高
- 数据结构改为数组+链表+红黑树,与HashMap 1.8一致
- 使用CAS操作进行无锁的插入和更新
- 只有在hash冲突时才使用synchronized锁住链表或红黑树的头节点
- size()方法使用baseCount + counterCells数组来统计,性能更好
1.8的实现更加高效,锁的粒度更细,并发性能更好,代码也更简洁。
4. 说说MySQL的索引优化策略和最佳实践
索引优化策略:
选择合适的字段建索引:
- where、order by、group by、join on的字段
- 区分度高的字段(重复值少)
- 字段长度小的优先
联合索引设计:
- 遵循最左前缀原则
- 区分度高的字段放在前面
- 范围查询的字段放在最后
避免索引失效:
- 不在索引列上使用函数或表达式
- 避免隐式类型转换
- like查询不以%开头
- 避免使用!=、<>、not in
- 注意or条件,确保所有字段都有索引
覆盖索引:查询的字段都在索引中,避免回表,性能最优。
索引下推:MySQL 5.6引入,在索引遍历过程中就过滤掉不符合条件的记录,减少回表次数。
前缀索引:对于长字符串字段,可以只索引前几个字符,节省空间。
索引维护:
- 定期分析表(ANALYZE TABLE)更新索引统计信息
- 删除冗余和重复的索引
- 监控慢查询日志,针对性优化
最佳实践:
- 单表索引数量不要太多(一般不超过5个),影响写入性能
- 小表不需要建索引,全表扫描更快
- 对于频繁更新的字段,谨慎建索引
- 使用EXPLAIN分析执行计划,确保索引生效
5. 介绍一下Spring Bean的生命周期
Spring Bean的生命周期主要包括以下阶段:
实例化(Instantiation):
- Spring容器通过反射调用Bean的构造方法创建对象
属性赋值(Populate):
- 通过依赖注入,为Bean的属性赋值
- 包括@Autowired、@Value等注解的处理
初始化前(BeanPostProcessor.postProcessBeforeInitialization):
- 执行BeanPostProcessor的前置处理方法
- 比如@PostConstruct注解的方法就在这个阶段执行
初始化(Initialization):
- 如果Bean实现了InitializingBean接口,调用afterPropertiesSet方法
- 如果配置了init-method,调用指定的初始化方法
初始化后(BeanPostProcessor.postProcessAfterInitialization):
- 执行BeanPostProcessor的后置处理方法
- AOP代理就是在这个阶段创建的
使用(In Use):
- Bean已经准备就绪,可以被应用程序使用
销毁前(@PreDestroy):
- 容器关闭前,执行@PreDestroy注解的方法
销毁(Destruction):
- 如果Bean实现了DisposableBean接口,调用destroy方法
- 如果配置了destroy-method,调用指定的销毁方法
关键扩展点:
- BeanFactoryPostProcessor:在Bean实例化之前修改Bean定义
- BeanPostProcessor:在Bean初始化前后进行增强
- Aware接口:让Bean感知到Spring容器(如BeanNameAware、ApplicationContextAware)
了解Bean的生命周期有助于在合适的时机进行扩展和定制。
6. 分布式锁有哪些实现方式?各有什么优缺点?
常见的分布式锁实现方式:
基于Redis实现:
方案一:SETNX + EXPIRE
- 使用SETNX设置key,成功则获得锁
- 设置过期时间防止死锁
- 问题:SETNX和EXPIRE不是原子操作,可能导致死锁
方案二:SET key value NX EX seconds
- Redis 2.6.12后支持,原子操作
- value设置为唯一标识(如UUID),释放锁时校验,防止误删
- 使用Lua脚本保证释放锁的原子性
- 问题:主从复制异步,主节点宕机可能导致锁丢失
方案三:Redisson
- 封装了分布式锁的实现,支持可重入锁
- 使用watchdog机制自动续期,防止业务执行时间过长导致锁过期
- 支持RedLock算法,解决主从切换问题
- 推荐使用
基于Zookeeper实现:
- 利用临时顺序节点实现
- 客户端创建临时顺序节点,序号最小的获得锁
- 其他客户端监听前一个节点,前一个节点删除时获得锁
- 优点:可靠性高,不会因为网络问题导致锁丢失;支持阻塞等待
- 缺点:性能不如Redis;依赖Zookeeper集群
基于数据库实现:
- 方案一:使用唯一索引,插入成功则获得锁
- 方案二:使用for update行锁
- 优点:实现简单,不需要额外组件
- 缺点:性能差,不适合高并发场景;可能有死锁风险
选择建议:
- 性能要求高:Redis + Redisson
- 可靠性要求高:Zookeeper
- 简单场景:数据库实现
7. 如何保证消息队列的消息不丢失?
消息丢失可能发生在三个阶段,需要分别处理:
生产者端丢失:
问题:消息发送到MQ前,网络故障或MQ宕机导致消息丢失。
解决方案:
- 开启生产者确认机制(Publisher Confirm)
- RabbitMQ:使用confirm模式或事务模式
- R
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
Java面试圣经,带你练透java圣经