面试八股文

Java 中的 ConcurrentHashMap 1.7 和 1.8 有什么区别

这两个版本的区别重点在锁的粒度

ConcurrentHashMap 1.7

Java 7 里使用的是分段锁 (Segment),底层依旧是数组,将数组分成 16 个 Segment,每个 Segment 下都有一个 HashMap 和 一个 ReentrantLock

不同线程访问不同 Segment 并不会触发锁,多线程访问同一个才会竞争,理论并发数默认 16

ConcurrentHashMap 1.8

Java 8 里去除了 Segment,采用跟 HashMap 相同的数据结构,数组 + 链表 + 红黑树 ,并且将 ReentrantLock 换成了 Synchronized

CAS

CAS 能够保证无锁操作下的原子性,具体是三个参数:地址(V)、原本值(A)、修改值(B)

  • 线程先读取内存地址(V)获取值,记录到原本值(A)里

  • 当线程准备更新地址(V)时,会先检查地址(V)当前值是否为记录的原本值(A)

    • 当前值和记录的原本值(A)相同,说明其他线程没有操作过,直接将地址值修改为修改值(B)

    • 当前值和记录的原本值(A)不同,说明其他线程修改过 V 的值,当前线程更新失败,通常尝试自旋或者放弃

1.8 插入

插入时采用 CAS 插入方法,冲突才会使用 Synchronized,但只对链表/红黑树的头节点上锁

因为锁住了头节点基本等于锁住了整个链表/红黑树,其他线程依旧可以对其他 bucket 进行操作,并发度大大提升

扩容的区别

Java 7 中的扩容
  • 基于 SegmentConcurrentHashMap 是由多个 Segment 组成的,每个 Segment 里面都包含着一个 HashMap,但 Segment 的数量并不会扩容,扩容的是 Segement 内的 HashMap,当 HashMap 达到扩容因子时,会单独为这个 Segment 进行扩容,并不会对整个 ConcurrentHashMap 进行扩容
  • 扩容过程:每个 Segment 维护自己的扩容因子,当 Segment 里面的元素超过阈值时对该 Segment 进行扩容,扩容过程跟 HashMap 一致,不会影响整个 ConcurrentHashMap
Java 8 中的扩容
  • 全局扩容:整个 ConcurrentHashMap 的元素总数超过阈值时,整个 ConcurrentHashMap 进行扩容
  • 基于CAS进行扩容ConcurrentHashMap 扩容跟 HashMap 基本一致,但是加上了 CAS 操作确保了线程安全。在扩容时,多个线程可以同时帮助扩容
  • 渐进式扩容:在 JDK 1.8 中引入了渐进式扩容,扩容时并不是一次将所有数据重新分配,而是多个线程共同参与,逐步迁移旧数据到新数组中,减低扩容开销。
    • 扩容时先把 transferIndex 待迁移的旧数组下标范围标记 设为旧数组长度,线程通过 CAS “抢占” 一段下标范围(比如从 transferIndex 取 16 个下标);
    • 线程迁移自己抢占的下标范围内的元素,迁移完后再 CAS 抢占新的范围,直到所有下标迁移完成;
    • “分段抢占、并行迁移”,避免单线程一次性迁移所有数据的开销。
    • 简单来说就算维护一个 transferIndex,线程循环 CAS 争夺下标,如果下标已经分配完了说明已经扩容完成

Java中的HashMap的原理

说白了 HashMap 底层就是一个数组,然后组合了链表红黑树

怎么寻址

当你存一个 Key-Value 的时候会计算出 keyhashcode,然后用哈希计算公式 keyhashcode % (table.length - 1) 算出需要存放的位置

处理哈希冲突

按照上面说的寻址我们会发现,有概率两个元素用公式算出来的结果是一样的,这就是我们常说的哈希冲突,HashMap 里也设定好了解决方法:

  • 链表:Java 7 及以前是把他们放在一起,用链表装着
    • 在 java 7 及以前链表使用的是头插法,即每次出现哈希冲突时,新的节点会插入到链表的头节点,老节点以此往后,在多线程环境下,头插法可能导致链表形成环,特别是在并发扩容时 rehashing 。当多个线程同时执行 put() 操作时,如果线程 A 正在进行头插,线程 B 也在同一时刻操作链表,可能导致链表结构出现环路,从而引发死循环,最终导致程序卡死或无限循环。
    • 但在 java 8 的时候改为尾插法,即新的节点插入到链表的尾部,保持链表的流畅度
  • 红黑树
    • Java 8 后做了优化,如果同一个下标的元素太多了(超过八个),则将链表转换成红黑树
    • 红黑树是一个自动平衡二叉树,能够将最坏情况下的查找复杂度从O(n) 变成 O(logn)
    • 如果数中的数量低于6,红黑树将会转换回链表,以减少不必要的开销

扩容机制

HashMap 默认长度是16,HashMap扩容因子(默认是0.75),意思是当元素超过 长度 * 0.75 时将会自动扩容。

默认长度是 16,扩容因子是 0.75,这个组合是性能和空间之间找到的平衡。如果扩容因子过高,虽然空间利用更高了,但是更容易出现哈希冲突,影响效率;如果扩容因子过低,哈希冲突出现概率减少,但是空间利用率低。

扩容就算将容量翻倍,然后把位置重新计算一遍,放进新的数组。

HashCode() 和 equals() 的重要性

HashMapkey 必须实现 HashCode()equals() 方法。

Prompt、Agent、MCP是什么

Prompt

UserPrompt

23年 OpenAI 刚发布 ChatGPT 的时候,AI看起来还是一个聊天框,我们通过聊天框发送的消息,然后AI模型生成一个回复,我们发的信息就叫用户提示词(UserPrompt)

SystemPrompt

但是现实生活中,问每个人同一句话都可能得到不同的回复。比如我说:我饿了;爷爷奶奶可能会说:要不要煮点东西给你吃;朋友会说:别饿;女朋友会说:滚一边去,我也饿。但是AI没有这样的人设,所以只能给出一个通用的回复:饿了就吃饭。

于是我们就希望给AI也加上一个人设,最直接的方法就是把人设信息和用户要说的话打包成一条 UserPrompt 发过去,比如你说:你是我朋友,我饿了;然后AI就会回答:别饿。

但问题是,你扮演我的朋友这句话不是我们真正想说的内容,每次都要提及显得麻烦,于是我们将人设信息单独拎了出来,放在另一个Prompt里面,这就是系统提示词(SystemPrompt),像角色、性格、背景知识、语气等等这些不是直接由用户说出来的内容都可以放在 SystemPrompt 里。

每次用户发送 UserPrompt 的时候,系统会自动把 SystemPrompt 也一起发给AI模型,这样整个对话就显得更加自然了。

Agent

上面说了这么多,说到底,AI还是一个聊天机器人,你问一个问题,他也只能给你一个回复,告诉你怎么做,实际动手的还是自己,如何让AI来帮我们完成,第一个做出尝试的是 AutoGPT。

什么是循环依赖

什么是循环依赖

现在我们有两个类 ClassA 与 ClassB,但他们互相引用,直接或间接依赖对方,例如例如A类里有B的对象,B类中又有A的对象

public class ClassA {
    private ClassB classB;
    // 构造方法、getter 和 setter 等
}

public class ClassB {
    private ClassA classA;
    // 构造方法、getter 和 setter 等
}

这种依赖不仅限于出现在类上,也可能会出现在包上或者模块上

  • **出现在包层面的危害:

    • 增加了代码的耦合度,降低了代码的可维护性和可读性
    • 在编译时可能会出现编译顺序的问题,难以确定先编译哪个包。
    • 在后续的开发和维护过程中,增加了维护的难度和风险。
  • **出现在模块层面的危害:

    • 会导致模块之间的边界变得模糊,无法清晰地划分模块的职责。在构建和部署项目时,可能会出现循环加载的问题,影响项目的启动效率和运行性能。

Spring中的循环依赖

当两个或多个 Bean 之间存在构造器注入的循环依赖时,Spring 无法解决这种依赖关系。例如,BeanA 的构造器需要 BeanB,而 BeanB 的构造器又需要 BeanA。Spring 容器在创建这些 Bean 的过程中会陷入死循环,无法确定先创建哪个 Bean,从而抛出 BeanCurrentlyInCreationExceptionBeanCurrentlyInCreationException 等异常。

什么是消息队列(Kafka)

模拟场景

假如现在我需要维护两个服务A和B,B服务每秒能处理100个消息,但A服务每秒能发两百个消息

结果我们也能想到,B服务非常容器就爆炸了,聪明的我一定能想到,我在B里面加一个队列来存放A发来的消息,用offset偏移量来记录消息的位置,B服务看能力来处理消息,不断更新offset值

但这又产生了一个新问题,来不及处理的消息放在内存里,如果在服务B里面的消息没有处理完的情况下B服务关机或者重启了,里面的消息就全部丢失了,但聪明的我肯定还会想到,那我们把队列拉出来单独开一个进程不就行了嘛

这就是消息队列的来源,像A服务一样发数据到队列里就是生产者,B服务这样处理数据的就是消费者

优化

  • 高性能 简单来说就是软件不行加硬件,消费者太慢了就加消费者,生产者慢就加生产者,但这听着感觉还是不对,如果多个生产者和消费者同时争抢同一个消息队列,抢不到就等怎么办

    解决方法: 对消息进行分类,每一类是一个topic,按照topic数量,生产者将消息分发给不同的消息队列里面,一个消费者需要订阅不同的队列来获取消息,但这样还是会出现一个topic里消息还是很多,我们还可以把单个topic拆成多个partition分区,每个消费者负责一个partition分区

  • 高扩展

    随着partition变多,如果partition都在同一台机器上可能会导致单机CPU内存负载过高,影响系统整体效率

    解决方法: 将partition分散部署到多台机器上,每一台机器就是一个broker,我们可以通过增加broker来缓解机器CPU负载过高带来的性能问题

  • 高可用

    如果其中一个broker挂了,那里面所有partition的消息也会跟着消失

    解决方法: 给partition多加几个副本,统称为replicas,将他们分为leader和follower,leader负责应对生产者和消费者的读写请求,follower负责同步leader的消息,将leader和follower分散到不同的broker里,这样就算leader所在的broker挂了也不会影响到follower所在的broker,并且还能重新选举一个leader partition顶上

  • 持久化和过期策略

    假设全部broker都挂了,那所以的partition的消息不都丢失了吗

    解决方法: 所以我们不能光将数据放在内存里,还需要持久化到磁盘上,但问题又来了,磁盘是有限的,一直往里面写数据迟早得炸,所以还需要给数据加上保留策略retention policy,比如磁盘数据超过一定大小或者数据放置超过一定时间就会被清理掉

redis主从复制的理解

主从复制本质上就是从一台服务器master上的数据拷贝到另一台服务器slave上,数据的复制是单向的,只能由主节点到从节点,redis里提供了全量复制和增量复制两种方法:

  • 全量复制:一般用于slave新构建的时候,slave会向master发送全量复制请求,然后master会拷贝当前数据快照给slave,slave丢弃旧的数据来加载新的数据,但需要注意redis并没有采用强一致性,所以会出现数据同步延迟导致数据不一致问题

  • 增量复制:当master节点收到数据改动,master会把变更的数据同步给所有的slave节点,主要原理是master和slave会共同维护一个偏移量offset,用来表示master向slave传递的字节数量,每一次进行增量数据的传递,offset都会对应增加数量

主从连接后master 接收命令,判断runid是否匹配,判定offset是否在复制缓冲区中,runid和offset有一个不满足,执行全量复制

心跳机制:进入命令传播阶段候,master与slave间需要进行信息交换,使用心跳机制进行维护,实现双方连接保持在线

  • master会去ping slave,默认每十秒一次,来获取slave最后一次连接时间间隔,一般在0或1为正常
  • slave会用REPLCONF ACK {offset},每一秒一次,汇报slave自己的复制偏移量,获取最新的数据变更指令以及判断master是否在线

ArrayList 和 LinkedList 的区别

  • ArrayList基于动态数组实现的非线程安全的集合;LinkedList基于链表实现的非线程安全的集合。

  • 对于随机index访问的get和set方法,一般ArrayList的速度要优于LinkedList。因为ArrayList直接通过数组下标直接找到元素;LinkedList要移动指针遍历每个元素直到找到为止。

  • 新增和删除元素,一般LinkedList的速度要优于ArrayList。因为ArrayList在新增和删除元素时,可能扩容和复制数组;LinkedList实例化对象需要时间外,只需要修改指针即可。

  • LinkedList集合不支持 高效的随机随机访问(RandomAccess)

  • ArrayList的空间浪费主要体现在在list列表的结尾预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗相当的空间

Kafka如何避免重复消费问题

首先,kafka的块上会储存offset标记,kafka消费者通过offset标记来维护已经消费的数据,消费者每消费完一批数据时会更新offset值,来避免重复消费问题。

默认情况,消费完以后会自动提交offset值避免重复消费,Kafka消费端自动提交的逻辑中默认了5秒的间隔,所以在consumer的消费过程中,如果5秒内被强行kill了或者宕机导致offset没有提交,会导致重复消费问题。

在Kafka里有一种叫Partition Balance机制,就是把多个消费区都负载均衡给consumer消费者,如果消费者在默认的五分钟内没有处理完里面的消费,就会触发ReBalance机制导致offset提交失败,在重启ReBalance后,消费端还是会从之前没有提交offset的位置开始去消费,从而导致重复消费问题,如何去解决也有很多方法:

  1. 提高消费端的处理性能,避免触发Balance,例如:
    1. 比如采用异步的方法来处理消息,缩短单个信息消费的时长
    2. 调整消费处理的超时时间
    3. 减少一次性从区中获取数据的条数
  2. 针对信息生产md5然后保存在MySQL或者redis里,在处理消息前先去MySQL或者redis里判断是否消费过

什么是SpringMVC

SpringMVC 是属于Spring Framework生态里面的一个模块,是在servlet的基础上构建并且使用了MVC模式涉及的web框架,目的是为了去简化传统的servlet+JSP模式下的web开发方式。 其次Spring MVC的架构设计是对Javaweb里面的mvc框架模式做了一些增强和扩展,主要体现在几个方面 :

  1. 把传统MVC框架里面的Controller控制器做了拆分,分为了前端控制器DispatcherServlet和后端控制器Controller
  2. 把model模型拆分成业务层service和数据访问层Repository
  3. 在视图层,可以支持不同的视图,比如Freemark、velocity、JSP等

所以,SpringMVC就是为了MVC模式设计的,因此在开发MVC应用时会更加方便灵活

SpringMVC整体工作流程:

  1. 浏览器请求首先经过核心控制器DispatherServlet,把请求分发到对应的Controller里面
  2. 然后等Controller调用业务逻辑进行处理完后返回Model And View
  3. DispatcherServlet去寻找一个或多个ViewResolver视图解析器,找到Model And View指定的视图并且把数据展示到客户端

JVM垃圾回收器

**Java分配对象的过程以及新生代和老年代划分的目的

创建一个新的对象实例时,jvm首先会在堆内存分配内存空间,大部分情况下,新对象都会分配到新生代的Eden区,新生代有三个区,一个Eden区和两个survivor区,当Eden区满了以后会进行Minor GC(新生代GC,是指新生代的垃圾收集,一般Eden区满了就执行,非常频繁,回收速度快),在GC的过程中存活的对象会在两个survivor区中进行转移和交换,经过多次GC任然存活的对象会被放入老年代,老年代主要用于存储长期存活的对象或者是大对象。

新生代中如果有比较大的对象比如数组list那些,会直接放入老年代里面。

划分新生代和老年代的主要目的有两个:

  • 新生代采用的是一种简单高效的复制算法进行垃圾回收,可以快速完成垃圾回收减少暂停时间,因为大部分对象都是暂时存在的,所有这种策略能够有效处理大量短暂对象的分配和回收
  • 可以针对不同的对象采取不同的回收策略,新生代频繁回收,老年代较少回收,可以减少full GC(全面垃圾收集,清理新生代与老年代以及方法区,full GC通常比Minor GC慢很多,因为full GC涉及到整个栈的回收,并且在GC期间,应用程序的所有线程都会暂停)的频率,提升系统的整体性能

**标记清除、复制和标记压缩三种垃圾回收算法的基本原理

标记清除算法: 遍历所有可达对象标记为存活的状态,然后遍历堆内存,把没有标记的对象全部视为垃圾进行清理

  • 优点 简单,不需要额外的内存空间
  • 缺点 会产生大量的内存碎片,而且效率很低

复制算法: 把内存分为两个相等的区域,每次只使用其中一个区域,当这个区域满了以后,把存活的对象复制到另一个区域中并且清除原区域的所有对象

  • 优点 每次垃圾回收后内存都是连续的,不存在内存碎片
  • 缺点 需要额外的占用内存空间,并且对象频繁复制导致效率问题

标记压缩算法: 先标记所有可达对象,然后把存活的对象向一端移动,然后直接清理边界外的内存区域,从而消除碎片 ![[Pasted image 20250513172753.png]]

  • 优点 解决标记清除算法所造成的内存碎片问题,相对复制算法减少了内存空间占用
  • 缺点 复杂度高,而且执行效率相对比较低,特别是压缩阶段需要移动对象,可能会引起程序暂停的时间较长

serial、Parallel、CMS和G1垃圾回收器的主要特点

  • serial GC是串行垃圾回收器,比较适用于单核处理器或者对响应时间要求不高的场景

  • Parallel基于多线程并行垃圾回收器,适合高吞吐量的服务器应用或者CPU核心数较多的服务器坏境

  • CMS也是并且垃圾回收,但是他会把垃圾回收分为四个阶段,尽可能减少了STW(系统在执行特定操作时需暂停所有应用程序线程)的时间,比较适用于高交互性的应用,比如web服务器,以及对停顿时间有严格要求但是对吞吐量比较宽松的场景

  • G1把堆内存划分了多个大小相等的区域,每个区域都可以独立作为新生代和老年代的一部分,通过并行和并发实现垃圾回收,从而减少停顿时间,另外还能根据目标停顿时间来动态调整垃圾回收策略,来满足不同需求,适合低延迟和可预测的垃圾回收停顿时间的应用,比如大规模分布式系统、在线交易系统

SpringApplication.run 执行后的四个阶段

四阶段分别为:服务构建、环境准备、容器创建和填充容器

服务构建

  • 首先把传入的资源加载器、主方法类记录到内存中,然后逐一判断对应的服务类是否存在来确定web服务的类型
    • 默认是基于servlet的web服务,如tomcat,还有响应式非阻塞服务reactive,如spring-webflux,还有什么都不是的none
  • 确定完选择哪个web服务后就是加载初始化类了,会去读取META-INF/spring.factories文件中的注册初始化、上下文初始化和监听器这三个配置
  • 最后是通过运行栈stackTrace判断main方法所在类

环境准备

  • 先new一个启动上下文 bootstrapContext,然后调用启动注册初始化器中的初始化方法 initialize,但由于没有没默认的初始化器,所以也没初始化什么(这个可以靠手动添加)
  • java.awt.headless 设置为 true,表示缺少显示器、键盘等输出设备也能正常启动
  • 然后启动运行监听器,同时发布启动事件,获取并加载springboot工程配置文件中监听器,就可以做到通过监听事件在启动的流程中加入自定义逻辑
  • 接下来就是组装启动参数,例如根据不同的web服务构造不同的环境(默认是servlet)、坏境变量、jvm系统属性等,把这些信息加载到一个内存集合中,后续调用就无需重新加载了

容器创建

  • 根据服务类型创建容器(默认servlet)注解配置的servlet-web服务容器
    • 存放和生产bean实例的Bean工厂
    • 用来解析 @Component@ComponentScan 等注解的配置类后处理器
    • 用来解析 @AutoWired@Value等注解的自动注解bean处理器
  • 对容器中的部分属性进行初始化

填充容器

  • 生产自身提供或者自定义的所有Bean对象,放入容器创建步骤中创建好的容器中,这个过程也叫做自动装配
  • 构造启动web服务器
  • 回调自定义实现的 Runner 接口,来处理执行后定制化的需求

SpringBoot启动流程

首先需要一个加了 @SpringBootApplication 注解的启动类,这个注解本质上就是由 @EnableAutoConfiguration@SpringBootConfiguration@ComponentScanner 连起来构成。

  • @EnableAutoConfiguration 的作用是在启动时自动加载一个类,这个类会将所有符合条件的 @Configuration 配置都进行加载,如果启动类中不需要添加配置内容,也不需要扫描路径,可以将 @SpringBootApplication 换成 @EnableAutoConfiguration

  • @SpringBootConfiguration 等同于 @Configuration,就是将这个类标记为配置类,会被加载到容器中

  • @ComponentScanner 就是自动扫描并加载所有符合条件的 Bean

注解完成后,运行的起点就是 SpringApplication.run(类名.class, args),在 run 开始执行后会经历四个阶段:服务构建、环境准备、容器创建和填充容器

MySQL事物的原理是什么

MySQL满足ACID的特性,所以MySQL事物的原理就是innodb是如何去实现ACID的特性。

首先A就是原子性,就是要保证DML数据库操作语言要么都成功,要么都失败,都成功好理解,如果都失败就意味着要把原本执行的操作都回滚,所以innodb里面设计了一个undo log表,在事物执行的过程中把执行数据的快照保存在undo log表里,例如执行一个insert语句,在undo log表里就存储一个delete语句,一旦出现错误就直接读取undo log执行反向操作就行了。

其次就是C一致性,表示数据的约束没有得到破坏,这个更多是依靠业务层的保障,数据库里面也提供了像主键约束,唯一约束,字段长度约束等。

I是隔离性,多个并行事物对同一个数据进行操作如何去避免多个事物的干扰导致数据混乱。innodb里面实现了SQL92的标注,提供了四个隔离级别的实现,分别是未提交读、已提交读、可重复读以及串行化。innodb默认实现的是可重复读,并且使用了MVCC解决了脏读和不可重复读的问题,然后使用了行锁或者表锁的方式来解决幻读的问题。

D是持久性,也就是说事物提交后的数据一定是永久化保留,不能因为数据库宕机或者其他原因导致数据变更的失效。理论上说事物提交后直接放在磁盘保存就好了,但是因为随机磁盘IO的效率确实很低,所以innodb设计了Buffer pool缓冲区来进行优化,数据更新的时候先更新缓冲区,然后在合适的时间持久化到磁盘里。但是在这个过程中可能会因为数据库宕机导致数据丢失,因此innodb引入了redo log文件,这个文件存储了数据库变更后的值,我们通过事物进行数据更改的时候,除了修改内存缓冲区里的数据以外,还会被本次修改的值追加到redo log里面,当事物提交的时候直接把redo log里面的日志刷新到磁盘里面进行持久化,一旦数据库宕机在MySQL重启以后可以直接用redo log里面保存的重写日志读取再执行一遍。

因此认为,MySQL事物的原理就是innodb如何实现ACID的特性,用到了MVCC、行锁、表锁、缓冲区、redo log和undo log来实现。