面试题整理
约 7333 字大约 24 分钟
2026-01-07
Java基础
⭐️Object类中有哪些方法?
在Java中,Object类是所有类的根类,它定义了所有对象共有的基础方法。
toString():返回对象字符串标识,默认是:类名+@+hashcode值;equals():判断两个对象是否相等(默认比较对象的地址);如果重写了equals()方法必须重写hashcode()方法来保证哈希表的正常工作;hashcode():返回对象hashcode的值,用于哈希表等数据结构;同一个对象多次执行hashcode()方法得到的结果要是相等的,当equals()返回为true的时候,hashcode()返回值也必须为true;getClass():返回对象在运行时的Class对象;clone():创建并返回对象的副本;需要实现Cloneable接口,否则会抛出CloneNotSupportedException异常;finialiize():垃圾谁后期在回收对象前调用的方法(JDK9标记为废弃方法);wait():使当前线程等待,直到其它线程调用notify()或notifyAll();notify():唤醒等待该对象锁的线程(单个或全部);
⭐️为什么在Java中需要设置一个Object类作为所有类的父类?
统一类型系统的基础
建立所有类的共同祖先,实现“万物皆对象”的面向对象理念,为反向、反射等机制提供统一的类型操作基础;
定义对象的通用行为
- 对象标识,equals()方法和hashcode()确保对象比较和哈希存储之间的一致性;
- 字符串表示:提供对象的可读描述;
- 线程间协作:wait()和notify()方法支持基本的线程同步;
- 运行时类型:getClass()方法允许程序在运行时识别对象类型;
支持通用编程
- 允许编写可处理任意类型对象的通用代码;
- 为容器类(如集合、映射)提供统一的操作接口,实现多态;
简化设计语言
单根集成避免了多根继承的复杂性,降低了编译器与虚拟机的实现难度,确保类型转换、内存管理等行为一致;
⭐️说说Java中反射机制以及它的原理
反射机制是指在程序运行状态中,对于任意一个类,能够直到这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性。这种动态获取信息以及动态调用对象方法的功能称为Java的反射机制。
反射的核心基于JVM的类记载机制和Class对象:
- 类加载:当程序运行时,
.java文件会被编译成.class字节码文件。当首次使用一个类时(如创建对象,访问静态成员),Java类加载器会将.class文件加载到JVM内存中; - 生成class对象:JVM在加载一个类后,会在堆内存中创建一个与之对应的
java.lang.Class对象。这个Class对象包含了该类的所有信息,类名、方法、字段、构造器和注解等; - 反射入口:反射API(如
Class.forName()、obj.getClass()或String.class)就是用来获取这个Class对象的。一旦拿到了这个Class对象,就可以通过它提供的一系列方法来操作这个类;
getDeclaredFields():获取所有声明的字段(Field对象)getDeclaredMethods():获取所有声明的方法(Method对象)getDeclaredConstructors():获取所有声明的构造器(Constructor对象)newInstance():创建实例invoke():调用方法
反射的优缺点:
- 优点
- 灵活性极高:突破了封装的限制,实现了动态性,让代码更通用,更灵活;
- 是各种框架的基石:Spring框架中IOC(利用反射类创建原始对象的实例)、Mybatis框架中将一行数据映射到一个Java对象(通过反射获取类的字段信息,并将查询结果集的数据设置到对应对象的属性中);
- 缺点
- 性能开销:反射比直接Java代码操作慢,因为它设计动态类型解析和方法调用检查。不过在在线JVM中,这种开销于多数应用场景来说是可以接受的。
- 安全限制:反射可以无视权限修饰符(通过
setAccessible(true))操作私有成员,破坏了封装性,可能带来安全隐患。 - 内部暴露:使用反射可能会接触到一些不鼓励使用的内部API,导致代码兼容性变差;
- 代码复杂性:反射代码比同等功能的代码更加冗长、更难理解和调试;
⭐️线程中的状态
在Java中线程的状态Thread.State枚举类定义,共包括下面6种状态:
- NEW(新建):线程被创建但尚未启动;
- RUNNABLE(可运行):线程正在JVM中执行,可能正在运行,也可能等待CPU的调度;
- BLOCKED(阻塞):线程等待获取监视器锁,仅在其他线程释放锁后才有机会恢复;
- WAITING(等待):线程无限等待其它线程的特定操作(如通知或中断);
- TIMED_WAITTING(超时等待):与WAITING方式类似,但设置了最大等待时间;
- TERMINATED(终止):线程已执行完毕或因异常退出,不可再次启动;
stateDiagram-v2
[*] --> NEW
NEW --> RUNNABLE : start()
RUNNABLE --> TERMINATED : run()结束
RUNNABLE --> BLOCKED : 竞争锁失败
BLOCKED --> RUNNABLE : 获取到锁
RUNNABLE --> WAITING : wait()/join()
WAITING --> RUNNABLE : notify()/notifyAll()
RUNNABLE --> TIMED_WAITING : sleep()/wait(timeout)
TIMED_WAITING --> RUNNABLE : 超时或唤醒⭐关于ForkJoin线程你了解多少,能详细说下它的特点吗?
ForkJoinPool是JDK7引入的一种支持分治的线程池,核心特征的是:工作窃取。它的典型场景是:
- 将大任务拆成多个小任务
- 小任务可以并行执行
- 最终结果合并
它的具体特征包含有:
- 每个线程都有一个自己的本地任务队列,这个本地任务队列时一个双端队列;
- 每个线程会从双端队列的头部获取任务来执行;
- 当前线程空闲的时候,它会从其他线程的工作队列的尾部来窃取一个任务执行;
ForkJoin是一种基于工作窃取算法的并行执行框架,适合分治型、CPU密集型任务。它通过为每个工作线程维护独立的双端队列,并允许空闲线程从其他线程队列的尾部窃取任务,实现负载均衡和高CPU利用率。Java中的CompletableFuture和parallelStream中默认的线程池都是FrokJoin线程池。
FrokJoin在窃取任务的时候,并不会固定选择某一个队列,而是通过伪随机算法在其他工作线程的WorkQueue中进行轮询尝试,从队列的尾部窃取粪污,以降低竞争并实现负载均衡。
Java集合类
HashMap
⭐请简单描述下HashMap?
HashMap是一个存储KV键值对的集合,它是线程不安全的。其中Key不允许重复,且仅允许一个Key的值为NULL,允许多个Value的值为NULL。
在JDK1.7中HashMap的数据结构是:数组+链表
在JDK1.8中HashMap的数据结构是:数组+链表+红黑树
在JDK1.8中引入红黑树的目的是解决在极端的场景下,链表的长度太长,导致整体的查询性能从O(1)降低到O(n),n为链表的长度。
在HashMap中确定一个元素在数组中的下标主要有两步:
- 计算Key的hascode值,并将hashcode值与高16位进行异或运算得到这个Key的hash值;
- 将这个Key的hash值与数组长度减一进行与操作得到的结果就是这个Key在数组中的下标;
在向HashMap添加元素的过程中,如果两个Key计算的hash值是相等的,也就意味着发生了Hash碰撞。HashMap解决Hash碰撞的方法就是,将两个元素以链表的形式存储在数组对应的下标上。
当Hash碰撞的概率较大的时候,会导致链表的长度较长,当链表的长度为大于等于8且数组的长度大于等于64的时候,就会触发树化。树化会将链表转成红黑树,转成红黑树的目的主要是提高HashMap的查询性能。
同理,在删除HashMap中的元素时,红黑树中节点的个数小于等于6的时候,则会进行反树化。反树化就是将红黑树转成链表,转成链表也是为了提高HashMap的查询性能。
当HashMap中数组的长度超过了阈值,也就是数组的长度乘以扩容因子,就会触发数组的扩容。扩容的时候会创建一个新的数组,新数组的长度为旧数组的2倍。然后重新计算每一个元素的下标,一般有两种情况:保持原来的下标不变;当前下标+旧数组的长度。最后将元素从就旧数组中转移到新数组中完成扩容。
⭐HashMap为了保证迭代器的一致性采取了fail-fast机制
为了避免在多线程的环境下,对于HashMap的迭代出现线程不安全的问题,它引入的fail-fast的机制。主要是利用一个modCount属性来记录HashMap被修改的次数。
当我们通过Iterator来遍历HashMap的时候,它首先会记录HashMap当前的modCount,在每次调用nextNode()方法的时候,会比较当前modCount的值是否与开始遍历的时modCount的值保持一致,如果是的话代表没有其它的线程对他进行修改,反之,则代表有线程对他进行修改。如果发现有线程对它进行修改,还会抛出一个异常ConcurrentModificationException。
如果不采用fail-fast机制,那么就需要在迭代的时候对新增操作和修改操作加锁。首先锁的粒度就比较关键,如果锁定的是整个HashMap,那么会导致HashMap的性能很差;如果锁定的是某个桶,则需要很多把锁,也会导致更多的内存开销。
其次HashMap本身设计目标就是高性能,为达到最好的性能,它放弃了多线程下线程安全的保证,我觉的这是设计的初衷把。所以,采用fail-fast机制能够提高HashMap的性能的同时,也能保障在对HashMap进行迭代的时候不出现错误。
⭐HashMap中put()方法的逻辑?
HashMap中put()方法的源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node < K, V > [] tab;
Node < K, V > p;
int n, i;
// 判断当前数组是否为null或数组的长度为0,则表示首次添加元素
if ((tab = table) == null || (n = tab.length) == 0)
// 对数组进行初始化
n = (tab = resize()).length;
// 利用Key的hashcode值与数组长度减1进行与操作找到Key要插入到数组中的下标,如果要插入的下标中内容为NULL
if ((p = tab[i = (n - 1) & hash]) == null)
// 直接新建一个节点,将当前节点放入数组中对应下标中
tab[i] = newNode(hash, key, value, null);
else {
// 如果数组中对应下标中给的内容不为NULL
Node < K, V > e;
K k;
// 判断数组中对应下标中元素的Key的hash值是否与待插入的Key相等,如果相等的话,就将对应的节点保存在e中
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果此时数组中的元素已经是红黑树了,就执行红黑树的put插入的逻辑
else if (p instanceof TreeNode)
e = ((TreeNode < K, V > ) p).putTreeVal(this, tab, hash, key, value);
// 反之当前数组中的元素是链表,就执行链表的put插入的逻辑
else {
// 这里是进行一个死循环
for (int binCount = 0;; ++binCount) {
// 是否遍历到了链表的最后一个元素
if ((e = p.next) == null) {
// 将当前Key加入到链表的尾部
p.next = newNode(hash, key, value, null);
// binCount代表的是链表中元素的个数(是否满足树化的阈值)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 将链表转成红黑树
treeifyBin(tab, hash);
break;
}
// 判断当前节点hash值与待插入的Key的hash值是否相等,如果相等的话继续判断两个Key是否相等
// 如果两个Key也相等,那么说我们这次put操作是一次修改操作,所以将当前节点保存到e中
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果e==null代表当前put是一次新增操作,该节点已经加入到链表的尾部了,不需要进行额外的操作了
// 如果e!=null代表当前put是一次修改操作,则需要节点的中的值进行修改
if (e != null) {
V oldValue = e.value;
// 将当前节点中的value替换为新的value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回当旧的值
return oldValue;
}
}
// 如果这个put操作是新增操作才会走到这里,否则会会在上面进行return
// 将HashMap中元素的数量加1
++modCount;
// 将HashMap中元素的数量加1,并判断是否超过了数组容量的阈值
if (++size > threshold)
// 如果当前元素的数量超过了数组的阈值,就执行数组的扩容
resize();
afterNodeInsertion(evict);
// 如果是新增操作的话就返回null
return null;
}HashMap中put()方法的逻辑:
如果当前数组为NULL或数组的长度为0,则代表首次插入元素,那么需要先对数组进行初始化。初始化的时候,会将数组的长度设置为16。
利用的Key的hashcode值与数组长度减一进行与操作得到Key在数组中的下标;
如果对应的下标位置上没有元素,则将当前元素直接放入到数组对应的下标上;
如果对应的下标位置上有元素,则需要进一步的判断:
1️⃣首先判断数组中元素(链表的头节点或红黑树的根节点)的Key是否就是这次要插入的Key,如果是的话会将对应的节点保存在变量e中;
2️⃣其次判断数组中对应下标的元素的类型是链表还是红黑树,就遍历红黑树,同时将当前元素存储在变量e中;
3️⃣如果当前数组中对应下标处的元素类型是链表,就遍历整个链表。如果在链表中找到了对应的Key,就将当前元素存储在变量e中,反之则将当前元素插入到链表的尾部,同时变量e的值被设置为null;
判断当前元素e的值是否为null:
1️⃣如果当前元素e的值不为null,则表示本次是修改操作,对于修改操作而言,我们需要用新的值来替换当前元素中旧的值;
2️⃣如果当前元素e的值为null,则表示本次是新增操作,此时HashMap记录元素数量的属性加1,同时HashMap的修改次数也加1;最后判断,HashMap中元素的数量是否超过了数组的阈值,如果是,则执行数组的扩容,反之就返回NULL值。
ConcurrentHashMap
⭐介绍下ConcurrentHashMap
ConcurrentHashMap它是一个存储KV键值对的集合,与HashMap相比它是线程安全的。
在JDK1.7中ConcurrentHashMap采用分段锁的设计思想,它将整个哈希表分割成多个独立的段,每个段相当于一个独立的小型哈希表并拥有自己的锁。默认情况下会分为16个段,表示最多支持16个线程的并发。每个段内部维护着一个数组,采用链表的形式解决哈希冲突。读操作通常不需要加锁,通过volatile关键字来保证内存可见性,而写操作则需要获取对应分段的锁,不同段之间的操作完全可以并发执行。
在JDK1.8中ConcurrentHashMap进行了彻底的重构,放弃了分段锁架构。采用CAS+Synchronized方案来保证线程安全。它的数据结构为数组+链表+红黑树,每次要执行put操作的时候,它会对单个桶中元素进行加锁。如果桶中元素类型是链表,就是对链表的头节点加锁,如果桶中元素是红黑树,就对红黑树的根节点加锁。对于初始化和扩容等全局操作的线程安全,则是通过CAS来操作一个全局变量来保证。
与HashMap的另外一个区别是,在扩容的时候可以允许多个线程来一起进行扩容。通过transferIndex变量来分配迁移区间,每个线程只会一部分的桶进行数据迁移,迁移完成的桶会被ForwardingNode标记。
⭐简单介绍下ConcurrentHashMap中元素数量的计算方式
在ConcurrentHashMap中可以通过size()方法获取元素的数量,但是它获取到的是近似的元素数量。
在ConcurrentHashMap中有一个baseCount和counterCell的数组,当竞争不激烈的时候,会通过CAS的方式直接修改baseCount的值。
当通过CAS操作baseCount的值失败的时候,证明有线程之间发生了竞争,此时会在counterCell数组中进行计数。
如果counterCell数组还没有创建,则会先对counterCell数组进行初始化。然后利用线程探针值来对conterCell数组的长度进行取模,得到这个线程的这一次计数的下标,最后将对应下标的值进行加1。
当我们调用size()方法的时候,它会先读取baseCount的值并对countCell中所有元素进行求和,最终将两者值相加得到元素的数量。
因为在size()方法中并没有加锁,所以我们在读取baseCount和对countCell求和的过程中都是会出现其它线程修改了baseCount的值或countCell数组中某个下标的值,因此我们利用size()方法得到的元素数量是一个近似值,而不是准确的值。
CopyOnWriteArrayList
⭐简单描述下CopyOnWriteArrayList
如果我们在多线程环境下使用ArrayList的时候会出现线程不安全的问题,一般而言,我们有三种解决策略:
- 使用Vector
- 使用Collections.synchronizedList()方法
- 使用CopyOnWriteArrayList
与前面两种方式不同,CopyOnArrayList利用的是写时复制的方式来保证线程安全。
对于读操作而言,它是完全无锁的,多个线程可以直接访问当前数组,读取的性能非常高。
对于写操作而言,包括添加、修改、删除等操作,都会先去获取独占锁,然后复制当前整个数组,在新数组上完成修改操作,最后用新数组替换旧数组。这个过程保证了写操作的安全性,但是代价是开销非常大,并且会产生大量的内存复制。
在CopyOnWriteArrayList中读操作是整个过程都是不加锁,但是会出现不同的线程同时读取的时候可能读取到的值不同。它利用volatile关键字来修改数组,保证在写线程完成引用替换后,读线程可以立即看到最新的数组,所以多个线程并发读取的时候,可能读取到的值是不同的。
CopyOnWriteArrayList适合于读多写少的场景,对于读操作而言完全无锁,支持多个线程并发读取,性能极好;对于写操作而言,每次都需要复制整个数组,开销较大,不适合写多的场景。
中间件
Redis
⭐Redis的集群模式
主从模式
一个主节点(Master)和一个或多个从节点(Slave),主节点负责处理写操作和读操作,而从节点复制主节点的数据,并且只能处理读操作。
当主节点发送故障时,可以将一个从节点升级为主节点,实现故障转移(需要手动实现)。
适合场景:简单易用,适用于都多写少的场景,它提供了数据备份的功能,并且可以有很好的扩展性,只要增加更多的从节点,就能让整个集群的读能力不断提升。
哨兵模式
哨兵模式是在主从复制的基础上加入了哨兵节点。哨兵节点是一种特殊的Redis节点,用于监控主节点和从节点的状态。当主节点发生故障时,哨兵节点可以自动进入故障转移,选择一个合适的从节点升级为主节点,并通知从节点和应用程序进行更新。
哨兵及诶单定期向所有主节点和从节点发送PING命令,如果在指定时间内未收到PONG响应,哨兵节点会将该节点标记为主观下线。如果一个主节点被多数哨兵标记为主观下线,那么它将被标记为客观下线。
主节点被标记为客观下线时,哨兵节点会触发故障转移过程。它会从所有的健康的从节点中选举一个新的主节点,并将所有从节点切换到新主节点,实现自动故障转移。同时,哨兵节点会更新所有客户端的配置,指向新的主节点。
哨兵节点会通过发布订阅功能来通知客户端有关主节点状态变化的消息。客户端收到消息后,会更新配置,将新的主节点信息应用于连接池,从而使客户端可以继续与新的主节点进行交互。
cluster模式
它将数据自动分片到多个节点上,每个节点负责一部分的数据。Redis Cluster采用主从复制模式来提高可用性,每个分片都有一个主节点和多个从节点。主节点负责处理写操作,而从节点负责主节点的数据并处理读请求。
⭐Redis的数据分片规则
在Redis的Cluster集群模式中,使用哈希槽的方式来进行数据分片。Redis Cluster将整个数据集划分为16384(214)个槽,每个槽都有一个编号,集群的每个节点可以负责多个hash槽。客户端访问数据时,先根据Key计算出对应的槽位,然根据槽编号找到负责该槽的节点,向该节点发送请求。
Redis分片算法:利用CRC16算法对Key进行计算,然后将计算的结果对于16384进行取模,找到这个Key所在的槽位。
⭐Redis为什么这么快?
基于内存
Redis是一种基于内存的数据库,数据存储在内存中,数据的读写速度非常快,因为内存访问速度比硬盘访问速度快得多。
单线程模型
Redis使用单线程模型,这意味着它的所有操作都是在一个线程内完成的,不需要进行线程切换和上下文切换,这大大提高了Redis的运行效率和响应速度;
多路复用I/O模型
Redis在单线程基础上,采用了I/O多路复用技术,实现了单个线程同时处理多个客户端连接的能力,从而提高Redis的并发性能;
高效的数据结构
Redis体提供了多种高效的数据结构,如哈希表、有序集合、列表等,这些数据结构都被实现得非常高效,能够在O(1)的时间复杂度内完成数据读写操作,这也是Redis能够快速处理数据请求的重要因素之一;
多线程的引入
在Redis 6.0中,为了进一步提升IO的性能,引入多线程机制。采用多线程,使得网络处理的请求并发进行,就可以大大的提升性能。多线程除了可以减少由于网络I/O等待造成的影响,还可以充分利用CPU的多核优势。
⭐Redis的多线程
Redis6.0中的多线程,也只是针对处理网络请求过程采用了多线程,而数据的读写命令仍然是单线程处理的。
假如现在有很多个客户端连接到Redis的时候,多个客户端同时发送了命令,因为Redis是单线程的,所以它只能一个一个I/O连接的轮询处理。为了提高这部分性能,Redis在6.0的时候升级成了多线程,也就是用多个线程来处理客户端发送过来的请求。当前面的多线程在把对应的I/O通道中的数据读取完毕后,交给具体执行命令的线程(单线程)去执行就好了。
RocketMQ
⭐RocketMQ中事务消息的原理是什么
RocketMQ事务消息的核心思想是基于两阶段(2PC)和消息回查机制,核心是确保消息发送与本地事务的原子性一致,通过半消息(Half Message)在特定的Topic(RMQ_SYS_TRANS_HALF_TOPIC)暂存,本地事务执行后,生产者提交COMMIT或ROLLBACK二次确保,若二次确认失败或未知,Broker会定期回查并根据结果处理半消息。
在RocketMQ中事务消息的主要是通过两个队列来实现的:
- RMQ_SYS_TRANS_HALF_TOPIC:存储半消息的队列,在接受到COMMIT指令的时候会将该队列中的半消息投递到目标队列中;
- RMQ_SYS_TRANS_OP_HALF_TOPIC:存储事务操作记录和状态;
RMQ_SYS_TRANS_OP_HALF_TOPIC队列的作用就是在RocketMQ恢复的时候,可以根据操作记录定位到当前需要从哪个事务消息开始处理。
⭐RocketMQ持久化的方式是什么?
RocketMQ的持久化采用混合存储架构,结合了顺序写+内存映射+文件索引的技术,实现了高性能、高可靠的消息存储。
RocketMQ的持久化主要涉及到三种文件:
- CommitLog(提交日志):所有消息主题内容存储文件,顺序写入,所有Topic消息混合存储;
- ConsumeQueue(消费队列):消息的逻辑队列索引文件,按照Topic和Queue维度组织,存储消息在CommitLog中的位置;
- IndexFile(索引文件):提供消息Key的查询功能,基于Hash索引,支持按Message Key或时间范围查询;
RocketMQ的刷盘策略分为两种:
- 同步刷盘:每条消息都强制刷新到磁盘后才返回;
- 异步刷盘:消息写入到操作系统缓存后立即返回,由后台线程定期刷盘;
数据清理策略:
- 磁盘使用率超过阈值(默认值75%);
- 文件过期时间达到(默认72小时)
- CommitLog文件不再被任何的ConsumeQueue引用;
⭐详细描述下RocketMQ中的零拷贝技术
首先在RocketMQ中消息会被持久化在CommitLog文件中,消费者如果需要从RocketMQ读取消息也是需要从CommitLog中来找到对应内容。
RocketMQ的零拷贝技术是基于OS PageCache + Java NIO的sendffile / mmap 组合
- OS PageCache是操作系统在内核中维护的文件数据缓存,以页为单位缓存磁盘内容。它通过延迟写、合并IO和预读机制大幅度减少磁盘IO的次数,是mmap、sendfile等零拷贝技术的基础,也是RocketMQ 和 Kafka 实现高吞吐磁盘与网络IO的关键;
- MMAP是将磁盘文件映射到进程虚拟内存空间的机制,使应用可以像操作内存一样操作文件,底层实际读写的是OS PageCache。RocketMQ通过MappedFile对MMAP进行封装,将CommitLog、ConsumeQueue、IndexFiled等核心存储文件映射到内存,实现顺序写、高吞吐、低CPU消耗的消息持久化,是其高性能存储体系的基础;
- sendfile是操作系统提供的零拷贝机制,用于将文件内容直接从内核PageCache发送到网络Socket,避免数据进入用户态。RocketMQ在消息消费和主从复制中,通过Java NIO的FileChannel.transferTo 调用sendfile,将CommitLog中的消息高效地发送给Consumer,从而显著降低Broker的CPU消耗并提升吞吐量;