一、Java 核心基础与进阶
1、我们知道 Java 中存在 “值传递” 和 “引用传递” 的说法,你能结合具体例子,说明 Java 到底是值传递还是引用传递吗?这背后涉及到 JVM 中哪些内存区域的交互?
Java中只有值传递,不存在引用传递。判断传递方式的核心在于:传递的是变量的副本,而非变量本身。区别在于副本的内容是基本类型的值还是对象引用的地址。
案例1:传递基本类型(int)
public static void main(String[] args) {int a = 10;change(a);System.out.println(a); // 输出 10,未被修改
}
private static void change(int num) {num = 20; // 操作的是副本 num,与原变量 a 无关
}
在上述案例中,变量a是基本类型,在虚拟机栈的局部变量表中,传递时将a的值复制一份给参数num,二者在栈中是独立的内存空间,修改num不影响a。
案例2:传递引用类型
class User {String name;public User(String name) { this.name = name; }
}
public static void main(String[] args) {User user = new User("张三");changeUser(user);System.out.println(user.name); // 输出 "李四",被修改replaceUser(user);System.out.println(user.name); // 仍输出 "李四",未被替换
}
private static void changeUser(User u) {u.name = "李四"; // 操作副本 u 指向的堆中对象(与原 user 指向同一对象)
}
private static void replaceUser(User u) {u = new User("王五"); // 副本 u 指向新对象,与原 user 无关
}
在上述案例中,先是new了一个user对象,存储在虚拟机栈的局部变量表,指向堆内存中新建的user对象,调用changeUser方法时,传递的是user的副本,即堆中对象的地址,形参u与user指向同一堆对象,因此改变u.name会影响源对象,而调用replaceUser时,形参u被赋值为新对象的地址,但原user的地址未变,因此源对象不受影响。
2、HashMap 是日常开发中最常用的集合之一,你能详细说说 JDK 1.7 和 JDK 1.8 中 HashMap 的实现差异吗?比如哈希冲突解决方式、扩容机制、线程安全性问题,以及为什么 JDK 1.8 要将链表转红黑树的阈值设为 8?
hashmap的核心是“数组+链表/红黑树”的哈希表结构,jdk1.8针对性能问题做了大幅优化,具体差异是:
1)底层结构:jdk1.7使用的是数组+链表的头插法,jdk1.8使用的是数组+链表+红黑树的尾插法
2)哈希冲突解决:jdk1.7仅链表,使用的是拉链法,jdk1.8是当链表长度<=7时用链表,>8转红黑树。
3)扩容机制:jdk1.7扩容时重新计算所有元素哈希值,jdk1.8对于每个节点,根据哈希值与原容量的与运算结果,若结果为0,则节点位置不变,若结果不为0,则节点在新数组中的索引=原索引+原容量。
4)线程安全性:jdk1.7并发扩容时会出现“链表环”,可能导致死循环,而jdk1.8使用尾插法避免链表环,但仍非线程安全。
5)初始容量计算:jdk1.7构造时直接初始化数组,jdk1.8则延迟初始化,首次put时才创建数组。
那为什么链表转红黑树的阈值设为8呢?
1)从概率角度上讲,hashmap中链表长度遵循泊松分布,链表长度达到8的概念极低,说明此时哈希冲突已非常严重,链表查询效率会急剧下降。
2)从性能权衡的角度上讲,红黑树的插入和删除复杂度是O(logN),但维护红黑树的开销比链表大,若阈值设的太小,会频繁触发树化,增加开销,设的太大则链表查询耗时过长。
3)反向阈值:当链表长度因删除元素缩短至6时,红黑树会转回链表,避免因频繁波动导致反复树化或链化。
3、线程池是并发编程的核心组件,你在项目中是如何配置线程池参数的?请结合具体场景说明你的设计思路,以及如何避免线程池的常见问题?
线程池的核心参数主要包括:核心线程数(corePoolSize),最大线程数(maximumPoolSize),任务队列(workQueue),拒绝策略(rejectedExecutionHandle)等。
如何配置参数主要看业务场景,下面举两个例子:
1)复杂计算,排序类场景:此场景特点是任务主要消耗CPU资源,线程空闲时间极少,一般配置规则是 核心线程数=CPU核心数+1(这里+1是为了避免CPU空闲,若某个线程因页缺失等短暂阻塞,可立即有线程补位),最大线程数和核心线程数保持一致,避免创建临时线程,减少上下文切换导致的开销。
2)数据库查询,RPC调用,文件读写等场景:此场景特点是任务大部分时间都在等待IO完成,线程空闲时间长,一般配置规则是 核心线程数=2*CPU核心数,最大线程数可设为核心线程数的2~4倍。
任务队列的选择优先使用有界队列(ArrayBlockingQueue),避免用无界队列导致任务无限堆积,最终触发OOM。
有界队列(ArrayBlockingQueue):基于数组实现的有界阻塞队列,特点是容量固定,创建时必须指定大小,支持公平和非公平的访问策略,适用于任务数量已知,需要控制队列大小的场景。
无界队列(LinkedBlockingQueue):基于链表实现的阻塞队列,特点是可指定容量,默认容量为Integer的最大值,近似无界,吞吐量通常高于有界队列,适用于任务数量不确定,需要较大缓存空间的场景。
拒绝策略可从CallerRunsPolicy,DiscardOldestPolicy两者中选,核心业务,例如订单支付等可以使用CallerRunsPolicy,避免任务丢失,非核心业务,如日志收集,可使用DiscardOldestPolicy,丢弃最旧的的任务或者自定义策略,禁止使用AbortPolicy(默认,直接抛异常),会导致业务中断。
AbortPolicy(默认策略):当任务队列已满且线程池中的线程数达到最大线程数时,直接抛出
RejectedExecutionException
异常CallerRunsPolicy(调用者运行策略):让提交任务的线程自己执行该任务
DiscardPolicy(丢弃策略):直接丢弃新提交的任务,不做任何处理也不抛出异常
DiscardOldestPolicy(丢弃最旧任务策略):丢弃队列中最旧的任务,再次尝试提交新任务
如何避免线程泄露?
当任务执行时无限阻塞,导致线程长期被占用,无法回收的情况出现时,可以给任务设置超时时间,并使用有界队列+监控的方式,当队列超过阈值则告警,定期检测空闲线程,设置allowCoreThreadTimeOut为true让核心线程超时回收。
如何避免队列积压?
监控队列使用率,当超过80%时,自动扩容线程池(动态调整最大线程数)或降级非核心业务。
4、谈谈你对 Java 内存模型(JMM)的理解?它解决了什么问题?volatile 关键字的作用是什么?它能保证原子性吗?为什么?
JMM是一种抽象的模型,用来屏蔽各种硬件和操作系统的内存访问差异,java内存模型定义了线程和主内存之间的抽象关系,线程之间的共享变量存在主内存中,每一个线程都有一个私有的本地内存,本地内存存储了该线程以读写共享变量的副本。
JMM主要解决了多线程场景下因CPU缓存,指令重排导致的可见性,原子性,有序性等问题。
可见性:一个线程修改的变量,其他线程能立即看到
有序性:一个操作不可中断,要么全部执行,要么全部不执行
有序性:程序执行顺序与代码顺序一致
JMM通过内存屏障实现上述特性,禁止指令重排序,强制缓存刷新,确保线程间变量交互符合预期。
volatile的作用和局限性
volatile是轻量级同步机制,仅保证可见性和有序性,不保证原子性。
可见性是指当一个共享变量被线程更改,其他线程会立即感知到,这里的原理是因为当一个变量被volatile修饰后,线程获取时不会从自己的本地内存中取,而是直接去主内存中取,每次修改完后也会及时同步到主内存中。有序性是指,操作被volatile修饰后的变量,不会被重排序。
不保证原子性:
private volatile int i = 0;
// 多线程执行 increment(),最终结果可能小于 10000
public void increment() {i++; // 非原子操作,分“读 i -> i+1 -> 写回 i”三步
}
即使i时volatile,多线程同时读i时,可能都拿到相同的值,各自加1后写回主内存,最终结果为101,而非102。解决原子性需要用到synchronized
或 AtomicInteger
(CAS 机制)。
5、垃圾回收(GC)是 JVM 性能优化的重点,你了解哪些常见的垃圾收集器?在项目中如何判断当前 GC 策略是否合理?如果出现 Full GC 频繁,你的排查步骤是什么?
常见的垃圾收集器:
Serial GC(串行收集器):单线程执行垃圾收集,收集时会暂停所有用户线程(STW,stop-the-world),优势是实现简单,内存占用小,适合单线程环境或客户端应用,这个收集器清理效率很高,但是在多核场景下无法有效利用cpu资源。
Parallel GC(并行收集器):多线程执行垃圾收集,仍会产生STW,但效率比Serial GC高。适合CPU核心数较多的场景,吞吐量优先。
CMS(并发标记清除收集器):号称停顿时间最短的收集器,以低延迟为目标,大部分工作与用户线程并发执行,减少STW时间,核心流程是:初始标记(STW)-> 并发标记 -> 重新标记(STW)-> 并发清除。它的优势在于STW时间短,适合响应时间敏感的应用。
G1(垃圾优先收集器):这个收集器是基于标记复制算法的,以一条线程去标记GCRoots可达的对象,此过程很快,需要停顿,然后再启动一个线程去做可达性分析,这个速度很慢,然后使用多条线程去回收废弃对象,也需要停顿。适用于大内存应用,对延迟和吞吐量都有要求的服务(如大型分布式系统)。
ZGC:jdk11引入的低延迟收集器,支持TB级别的大堆,STW时间极短,几乎不影响吞吐量,STW时间和堆大小无关,并支持并发压缩,无内存碎片,适合超大堆场景。适用于大型分布式系统,需要处理海量数据且堆延迟敏感的应用。
Shenandoah GC:jdk12引入,目标是低延迟,大堆支持,适用场景与ZGC类似。
如何判断当前 GC 策略是否合理?
1)full GC频率:生产环境应控制在1次/天,若频繁触发,说明内存配置或者对象生命周期有问题
2)GC停顿时间:YGC(年轻代GC):停顿时间应<100ms,频率可接受(如1次/分钟),full gc停顿时间应<1s ,否则会影响用户体验(如接口超时)
3)内存使用率:老年代内存使用率长期 > 80%,容易触发full gc,年轻代内存不足会导致YGC频繁
Full GC排查步骤
1)查看GC日志:分析full gc触发原因
2)分析内存泄漏:是否有大对象长期存活未被回收
3)检查jvm参数:老年代大小是否合理,元空间是否不足
4)优化对象生命周期:避免创建大量临时大对象,尽量复用对象,并及时释放
二、主流框架与中间件
1、Spring 是 Java 生态的基石,你能说说 Spring IoC 容器的初始化流程吗?Bean 的生命周期中,“初始化前”“初始化后” 有哪些扩展点?你在项目中用过哪些扩展点解决实际问题?
IOC容器的初始化流程:
主要分为bean加载和bean实例化两个阶段,核心步骤:
1)资源定位:通过ResourceLoader加载配置文件,将其转化为Resource对象
2)bean定义加载和解析:用beanDefinitionReader解析Resource中的bean标签或注解,将配置信息封装为BeanDefinition(包含类名,属性,依赖等)。
3)Bean定义注册:将BeanDefinition注册到BeanDefinitionRegistry中
4)Bean实例化:从BeanDefinitionRegistry中获取BeanDefinition,通过反射创建bean实例
5)bean初始化:依赖注入,通过AutowiredAnnotationBeanPostProcessor
完成属性注入,并执行InitializingBean
接口的afterPropertiesSet()
或自定义 init-method
。
6)bean缓存:将初始化完成的bean放入单例池,后续直接从缓存获取。
bean生命周期的扩展点
1)BeanPostProcessor:bean实例化后,初始化前后调用,可用于aop代理或者字段加解密
2)InitializingBean:bean依赖注入完成后调用,可用于初始化资源,例如创建数据库连接池或者启动定时任务等
3)DisposableBean:容器关闭时调用,可用于释放资源,例如关闭连接池,停止线程池等
4)BeanFactoryPostProcessor:bean定义注册后,实例化前调用,可用于动态修改bean配置,如根据环境变量调整数据源URL
用 BeanPostProcessor
实现接口日志打印:
@Component
public class LogBeanPostProcessor implements BeanPostProcessor {// Bean 初始化前调用@Overridepublic Object postProcessBeforeInitialization(Object bean, String beanName) {if (bean instanceof OrderService) {System.out.println("OrderService 实例化完成,准备初始化");}return bean;}// Bean 初始化后调用(若有 AOP 代理,此时 bean 已是代理对象)@Overridepublic Object postProcessAfterInitialization(Object bean, String beanName) {if (bean instanceof OrderService) {System.out.println("OrderService 初始化完成,可使用");}return bean;}
}
2、Spring 事务的传播机制和隔离级别分别有哪些?请举例说明 “事务传播机制 PROPAGATION_REQUIRES_NEW” 和 “PROPAGATION_NESTED” 的区别,以及实际项目中如何避免事务失效的问题?
传播机制:
REQUIRED(默认值):如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新事务
SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式执行
MANDATORY:必须在一个已存在的事务中执行,如果当前没有事务,则抛出异常
REQUIRES_NEW:无论当前是否存在事务,都创建一个新事务,如果当前存在事务,则将当前事务挂起
NOT_SUPPORTED:以非事务的方式执行,如果当前存在事务,则将事务暂停
NEVER:以非事务方式执行;如果当前存在事务,则抛出异常
NESTED:如果当前存在事务,则在嵌套事务中执行,如果当前没有事务,则创建新事务
REQUIRES_NEW的特点是子事务和父事务完全独立,子事务回滚和提交不会影响父事务,例如订单创建作为父事务,调用日志记录作为子事务,即使日志记录失败回滚,订单创建仍可正常提交。
NESTED的特点是子事务依赖父事务,子事务回滚仅回滚自身,父事务回滚会连带子事务回滚,例如订单创建为父事务,包含库存扣减的子事务,库存扣减失败,订单创建可选择记录执行或者回滚,若订单创建回滚,库存扣减也会回滚。
隔离级别:
DEFAULT(默认值):默认值为READ_COMMITTED。
READ_UNCOMMITTED(读未提交):允许事务读取其他事务未提交的数据,可能出现脏读
READ_COMMITTED(读已提交):事务只能读取其他事务已经提交的数据,可以避免脏读,但是可能出现不可重复读
REPEATABLE_READ(可重复读):保证同一事务内多次读取同一数据的结果一致,可以避免脏读和不可重复读,但是会出现幻读
SERIALIZABLE(序列化读):最高隔离级别,强制事务串行执行,避免所有并发问题。
如何解决事务失效?
1)非public方法:@Transactional此注解仅对public方法生效,因为spring动态代理默认不拦截非public方法,解决办法就是将方法改为public,或者自定义AOP拦截非public方法
2)方法自调用:同一个类中,无事务方法调用有事务方法,因未经spring代理,事务不生效
示例:
@Service
public class OrderService {public void createOrder() {// 自调用,无事务doCreate(); }@Transactionalpublic void doCreate() { ... }
}
解决办法为通过AopContext.currentProxy()获取代理对象调用方法,或者将上述的doCreate方法拆分到另一服务类
3)异常被捕获:方法内捕获异常未抛出,spring无法感知异常,不会触发回滚。解决方法是捕获异常后手动抛出RunTimeException,或者通过TransactionStatus.setRollbackOnly()强制回滚
4)错误的异常类型:@Transactional
默认仅回滚RunTimeException和Error,不回滚checked异常(如IOException),解决办法是指定异常类型,例如:@Transactional(rollbackFor = Exception.class)
3、MyBatis 中,#{} 和 ${} 的区别是什么?为什么说 #{} 能防止 SQL 注入?MyBatis 的一级缓存和二级缓存分别是什么?在分布式项目中使用二级缓存需要注意什么问题?
#{}和${}的区别
1)#{}是参数占位符,${}时字符串替换
2)#{}是基于JDBCPreparedStatement的预编译SQL,${}是基于JDBCStatement,直接替换字符串
3)#{}无风险,参数会被视为“值”,自动加引号,${}有风险,参数会被视为sql片段,由sql注入的风险
4)#{}适用于传递参数,${}适用于传递sql片段
为什么#{}能够防止sql注入?
例如执行 select * from user where name = #{name}时,若参数name为“‘张三’ or ‘1’ = ‘1’ ”,mybatis会将其预编译为
select * from user where name = ?
执行时参数被当做字符串值传入,最终sql为
select * from user where name = '\'张三\' or \'1\'=\'1\''
不会被解析为sql逻辑,因此避免注入。
mybatis缓存机制
一级缓存:作用范围在用一个SqlSession内,执行相同的SQL,会从缓存中获取结果,避免重复查询数据库,当SqlSession关闭,或者执行insert、update,delete会清空一级缓存
二级缓存:作用范围是同一个SqlSessionFactory下的所有SqlSession,按照Mapper接口隔离,不同Mapper的缓存互不干扰;可通过全局配置 mybatis.configuration.cache-enabled=true
(默认开启),或者在Mapper接口添加@CacheNamespace注解,使用二级缓存需要注意的是在分布式场景下,二级缓存的对象需要实现Serializable
序列化接口,因为缓存会将对象序列化到磁盘或内存,其次是一致性问题,多个服务节点的二级缓存独立,某个节点修改数据后,其他节点缓存未更新,导致数据不一致。所以分布式场景下不建议使用二级缓存,改用Redis分布式缓存,如果必须使用,需要通过mq发消息通知其他节点清空缓存
4、分布式系统中经常用到消息队列,你在项目中为什么选择某款消息队列?如何保证消息的 可靠性 和 不重复消费?如果消息队列出现消息堆积,你的处理方案是什么?
特性 | RabbitMQ | Kafka | RocketMQ | Pulsar | ActiveMQ |
---|---|---|---|---|---|
开发语言 | Erlang | Scala/Java | Java | Java/C++ | Java |
核心定位 | 企业级消息中间件(侧重灵活路由、可靠性) | 分布式流处理平台(侧重高吞吐、日志 / 大数据) | 分布式消息中间件(侧重金融级可靠性、事务) | 云原生流处理平台(融合队列 + 流处理) | 传统企业级消息中间件(功能全面但笨重) |
可靠性 | 高(支持持久化、镜像队列、DLQ) | 中高(分区副本机制,默认 3 副本) | 高(主从同步、事务消息、DLQ) | 高(分层存储、多副本、跨地域复制) | 高(支持多种持久化方案) |
性能(吞吐量) | 中(万级 TPS) | 极高(十万级 TPS,支持批量读写) | 高(十万级 TPS) | 极高(与 Kafka 相当,支持分层存储优化) | 中(万级 TPS) |
延迟 | 低(毫秒级) | 中(毫秒级,批量场景下略高) | 低(毫秒级) | 低(毫秒级,流处理场景更优) | 中(毫秒级,复杂场景下延迟波动大) |
扩展性 | 中(集群扩展需手动配置,上限较低) | 高(横向扩展简单,支持上千节点集群) | 高(支持 Namesrv 水平扩展,集群能力强) | 极高(云原生设计,支持动态扩缩容) | 中(扩展复杂,不适合超大规模集群) |
关键功能 | 灵活路由(交换机模式)、延迟队列、镜像队列 | 流处理(Kafka Streams)、日志聚合、分区副本 | 事务消息、定时消息、消息回溯、批量消息 | 队列 + 流处理融合、分层存储(冷热数据)、多租户 | 支持多种协议(JMS、AMQP、MQTT 等) |
运维成本 | 中(Erlang 调试复杂,社区活跃) | 低(部署简单,生态工具丰富) | 中(需关注 JVM 调优,文档完善) | 中(云原生部署友好,学习曲线略陡) | 高(配置复杂,版本迭代慢) |
生态兼容性 | 好(支持多语言,适配 Spring、K8s) | 极好(大数据生态核心,适配 Flink/Spark) | 好(适配 Spring Cloud,金融场景工具全) | 好(云原生生态,适配 K8s、Flink) | 一般(传统生态,对新架构支持弱) |
适用场景 | 微服务通信、订单通知、延迟任务 | 日志采集、用户行为分析、大数据流处理 | 金融交易、分布式事务、高可靠业务 | 云原生架构、混合队列 / 流处理场景、多租户 | 传统企业内部系统、协议多样化场景 |
开源 / 商业 | 开源(商业版为 RabbitMQ Cluster) | 开源(商业版为 Confluent Platform) | 开源(商业版为阿里云 ONS) | 开源(商业版为 StreamNative) | 开源(商业版为 ActiveMQ Artemis) |
可靠性保证
从生产端,Broker端,消费者端分别分析:
生产端:开启消息确认,消息到达Broker后返回一条确认消息;失败重试,设置对应的重试次数,避免网络抖动导致的临时失败;本地消息表,核心业务用本地事务表+消息表双写,确保业务与消息发送原子性
Broker端:同步刷盘,消息写入后立即刷盘,避免Broker宕机导致内存中消息丢失;主从复制,开启Broker主从架构,主节点宕机后从节点接管,避免单点故障
消费者端:手动确认,关闭自动确认,确保消息消费完成后再确认,避免消费失败导致消息丢失
不重复消费保证
消息重复的原因可能有:生产者重试,Broker重试,消费者确认超时等,解决核心是“业务层面去重”
方案一:唯一消息ID+幂等表
生产者发送消息时,生成全局唯一ID放入消息头,消费者消费前,先查询数据库幂等表,如果已存在则跳过消费。如果不存在,消费后插入幂等表。
方案二:业务唯一键
利用业务自身的唯一键去重,例如订单ID,消费时先查询订单是否已存在,存在则不处理
消息堆积处理
消息堆积的核心原因可能是 消费速度 < 生产速度,解决思路分紧急处理和长期优化
紧急处理:扩容消费者,增加消费者实例数;或者创建临时消费队列,创建临时主题,将堆积消息分流到临时主题,用多个消费者组并行消费
长期优化:可简化消费流程,异步处理非核心步骤;或者调整Broker配置,增大Broker消息存储上限,避免因磁盘满导致消息丢弃;优化Broker刷盘策略。
5、分布式缓存Redis是提升系统性能的关键,你在项目中用 Redis 做过哪些场景?如何解决 Redis 的 “缓存穿透、缓存击穿、缓存雪崩” 问题?Redis 集群的架构原理是什么?
Redis核心应用场景
缓存:可以缓存热点数据,降低数据库压力,提升接口QPS
分布式锁:用SET key value NX EX 10命令实现分布式锁,解决并发抢单,库存超卖问题
计数器:用INCR命令实现点赞数,阅读量统计
限流器:用Redis+Lua脚本实现接口限流,防止恶意请求击垮服务
消息队列:用List结构的Lpush实现简单的消息队列
缓存的三大问题
缓存穿透:查询不存在的数据(如ID = -1),缓存和数据库都不命中,请求直达数据,解决方案是布隆过滤器或者空值缓存
缓存击穿:热点key过期瞬间,大量请求直达数据库,可使用互斥锁,查询数据库时枷锁,只让一个线程更新缓存,其他线程等待;热点key永不过期
缓存雪崩:大量key同时过期,或Redis集群宕机,请求全部到数据库,可使用过期时间加随机值,redis集群,或者服务降级等方案
Redis集群架构
主从架构:由1个主节点+N个从节点组成,主节点负责写操作,从节点负责读操作,从节点通过复制同步主节点数据,实现数据备份,缺点是主节点宕机后,需手动切换从节点为主节点,无法自动故障转移
哨兵架构:由主从架构+哨兵节点(通常3个)组成,可以监控主从节点状态,当主节点宕机时,自动选举从节点升级为主节点,并通知客户端新的主节点地址,缺点是无法解决数据分片问题,单主节点内存受限
Cluster集群架构:由多个主从节点组成,主节点之间通过Gossip协议通信,支持数据分片及故障转移,适用于海量数据,高并发读写的分布式场景