在酒旅领域,大多数人首先想到的是携程与飞猪,其中携程更是占据着最大的市场份额。然而,在这两大巨头之外,同程旅行(前身为同程艺龙)依然稳稳地占据着一席之地。
我对同程的印象,还停留在六年前校招季的初次接触,当时感觉这家公司整体还是不错的。然而,随着市场的风云变幻,我一度以为它已在激烈的竞争中逐渐沉寂。
看到有球友面试同程,加上前段时间同程放出了还不错的业绩报告,我才知道这家公司目前依然发展的还不错。
这家公司的技术面试一般由笔试+技术面试两轮+HR 面组成,整体难度一般,但对学历相对包容,二本和双非也可投递试试。
根据前几届的同学反馈来看,同程会要求提前实习且可能会卡转正。另外,校招薪资整体处于行业中游水平。以 24 届为例,苏州 Java 岗位的薪酬范围大致在(12-14k) * 15 薪。
同程旅行的校招在前几天也已经开始了,这里给大家分享一位球友的同程校招一面面经,大家感受一下难度如何。
概览:
HashMap 是否线程安全?不安全的话用什么?
HashMap不是线程安全的。在多线程环境下对HashMap进行并发写操作,可能会导致两种主要问题:
数据丢失:并发
put操作可能导致一个线程的写入被另一个线程覆盖。无限循环:在 JDK 7 及以前的版本中,并发扩容时,由于头插法可能导致链表形成环,从而在
get操作时引发无限循环,CPU 飙升至 100%。
ConcurrentHashMap是HashMap的线程安全版本。这意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的HashMap多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!
ConcurrentHashMap 是如何保证线程安全的?
一句话总结:JDK 1.7 采用Segment分段锁来保证安全,Segment是继承自ReentrantLock。JDK1.8 放弃了Segment分段锁的设计,采用Node + CAS + synchronized保证线程安全,锁粒度更细,synchronized只锁定当前链表或红黑二叉树的首节点。
下面是详细介绍。
JDK1.8 之前
Java7 ConcurrentHashMap 存储结构
首先将数据分为一段一段(这个“段”就是Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。
Segment继承了ReentrantLock,所以Segment是一种可重入锁,扮演锁的角色。HashEntry用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable { }一个ConcurrentHashMap里包含一个Segment数组,Segment的个数一旦初始化就不能改变。Segment数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
Segment的结构和HashMap类似,是一种数组和链表结构,一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得对应的Segment的锁。也就是说,对同一Segment的并发写入会被阻塞,不同Segment的写入是可以并发执行的。
JDK1.8 之后
Java8 ConcurrentHashMap 存储结构
Java 8 几乎完全重写了ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。
ConcurrentHashMap取消了Segment分段锁,采用Node + CAS + synchronized来保证并发安全。数据结构跟HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
AQS, ReentrantLock, synchronized 的区别
synchronized是 Java 关键字,由 JVM 层面实现。简单易用,编译器会自动加锁和释放锁。默认是非公平锁,且是悲观锁。功能相对简单,无法中断、无法设置超时。
ReentrantLock是 JDK 层面实现的(也就是 API 层面),需要lock()和unlock()方法配合try/finally语句块来完成。默认非公平,但可配置为公平锁。功能非常强大,支持可中断获取锁、可超时获取锁、可尝试获取锁,并且可以绑定多个Condition实现精准唤醒。
AQS (AbstractQueuedSynchronizer,抽象队列同步器)是从 JDK1.5 开始提供的 Java 并发核心组件。
AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如可重入锁(ReentrantLock)、信号量(Semaphore)和倒计时器(CountDownLatch)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。简单来说,AQS 是一个抽象类,为同步器提供了通用的执行框架。它定义了资源获取和释放的通用流程,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的基础“底座”,而同步器则是基于 AQS 实现的具体“应用”。
ThreadLocal 能说多少说多少
ThreadLocal提供了一种线程内部的局部变量机制,它可以在同一个线程的执行周期内,让不同的方法都能方便地访问到同一个变量,而不需要通过参数层层传递。
最终的变量是放在了当前线程的ThreadLocalMap中,并不是存在ThreadLocal上,ThreadLocal可以理解为只是ThreadLocalMap的封装,传递了变量值。ThrealLocal类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。
ThreadLocal数据结构如下图所示:
ThreadLocal 数据结构
在线程池中使用ThreadLocal时,要特别小心内存泄漏。当ThreadLocal实例失去强引用后,其对应的 value 仍然存在于ThreadLocalMap中,因为Entry对象强引用了它。如果线程持续存活(例如线程池中的线程),ThreadLocalMap也会一直存在,导致 key 为null的 entry 无法被垃圾回收,即会造成内存泄漏。
如何避免内存泄漏的发生?
在使用完
ThreadLocal后,务必调用remove()方法。 这是最安全和最推荐的做法。remove()方法会从ThreadLocalMap中显式地移除对应的 entry,彻底解决内存泄漏的风险。 即使将ThreadLocal定义为static final,也强烈建议在每次使用后调用remove()。在线程池等线程复用的场景下,使用
try-finally块可以确保即使发生异常,remove()方法也一定会被执行。
主线程的 ThreadLocal 访问子线程的变量,怎么处理?
由于ThreadLocal的变量值存放在Thread里,而父子线程属于不同的Thread的。因此在异步场景下,父子线程的ThreadLocal值无法进行传递。
如果想要在异步场景下传递ThreadLocal值,有两种解决方案:
InheritableThreadLocal:InheritableThreadLocal是 JDK1.2 提供的工具,继承自ThreadLocal。使用InheritableThreadLocal时,会在创建子线程时,令子线程继承父线程中的ThreadLocal值,但是无法支持线程池场景下的ThreadLocal值传递。TransmittableThreadLocal:TransmittableThreadLocal(简称 TTL) 是阿里巴巴开源的工具类,继承并加强了InheritableThreadLocal类,可以在线程池的场景下支持ThreadLocal值传递。项目地址:https://github.com/alibaba/transmittable-thread-local。
MySQL 的索引数据结构
在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构。
B+树的优势:
矮胖的树形结构:B+树是多路平衡查找树。它的非叶子节点只存储键(索引),不存储数据,这使得每个节点可以容纳成百上千个键。因此,即使存储上亿条数据,B+树的高度通常也只有 3-4 层,极大地减少了磁盘 I/O 次数。
所有数据都在叶子节点:所有数据记录都存储在叶子节点上,这使得查询性能非常稳定,任何查询都需要从根节点走到叶子节点。
有序的叶子节点链表:B+树的叶子节点之间通过一个双向链表连接,并且是有序的。这个特性使得它在进行范围查询时极其高效,只需要定位到范围的起始点,然后沿着链表顺序遍历即可。
更适合磁盘存储:B+树的节点大小被设计为与磁盘页(Page)的大小相近,可以充分利用磁盘的预读特性,一次 I/O 就能加载一个完整的节点到内存中。
时间复杂度怎么计算?
时间复杂度是用来衡量一个算法执行时间随数据规模(n)增长而变化的趋势,它描述的是一种增长率,而不是精确的执行时间。我们通常使用大 O 表示法来表示。
一个算法的时间复杂度,通常由嵌套最深、执行次数最多的那段代码决定。总复杂度等于量级最大的那段代码的复杂度,例如 O(n) + O(n^2) = O(n^2)。嵌套代码的复杂度等于内外代码复杂度的乘积。一个循环嵌套另一个循环,就是 O(n * n) = O(n^2)。
RAG 技术
RAG,全称是检索增强生成 (Retrieval-Augmented Generation)。它是一种将外部知识库与大语言模型 (LLM) 相结合的技术框架,旨在解决大模型存在的这些痛点:
知识的实时性问题:大模型的知识是截止到它训练时的,无法获取最新的信息(比如今天的热点新闻)。
幻觉问题:当被问到其知识范围外或专业性极强的问题时,大模型可能会编造答案。
私有领域知识问题:大模型不知道你公司的内部文档、产品手册等私有知识。
RAG 过程分为两个不同阶段:索引和检索。
在索引阶段,文档会进行预处理,以便在检索阶段实现高效搜索。该阶段通常包括以下步骤:
输入文档:文档是需要被处理的内容来源,可能是文本文件、PDF、网页、数据库记录等。
清理文档:对文档进行去噪处理,移除无用内容(如 HTML 标签、特殊字符)。
增强文档:利用附加数据和元数据(如时间戳、分类标签)为文档片段提供更多上下文信息。
文档拆分:通过文本分割器(Text Splitter)将文档拆分为较小的文本片段(Segments),以适应检索和生成的上下文长度限制(例如 GPT 的 token 限制)。
生成嵌入:通过嵌入模型(如 Sentence-BERT 或 OpenAI Embedding)将每个片段转换为向量表示,以捕捉其语义信息。
存储到向量数据库:将生成的嵌入和对应的元数据存储在嵌入存储库(如 Faiss、Annoy、Pinecone、Weaviate 或本地向量数据库)。
索引过程通常是离线完成的,例如通过定时任务(如每周末更新文档)进行重新索引。对于动态需求,例如用户上传文档的场景,索引可以在线完成,并集成到主应用程序中。
索引阶段的简化流程图如下(图源 langchain4j 官方文档):
检索通常在线进行的,当用户提交一个问题时,系统会使用已索引的文档来回答问题。该阶段通常包括以下步骤:
接收请求:接收用户的自然语言查询(Query),例如一个问题或任务描述。
查询向量化:使用嵌入模型(Embedding Model)将用户查询转换为语义向量表示(Query Embedding),以捕捉查询的语义信息。
信息检索 (R):在嵌入存储(Embedding Store)中,通过语义相似性搜索找到与查询向量最相关的文档片段(Relevant Segments)。
生成增强 (A):将检索到的相关片段(Relevant Segments)和原始查询作为上下文输入给 LLM,并使用合适的提示词引导 LLM 基于检索到的信息回答问题。
输出生成 (G):LLM 生成最终答案。
结果反馈(可选):如果用户对生成的结果不满意,可以允许用户提供反馈,通过调整提示词或检索方式优化生成效果。在某些实现中,支持多轮交互,进一步完善回答。
检索阶段的简化流程图如下:
用 SpringAI 做了那些事
Spring AI 为整个 RAG 流程提供了接近一站式的解决方案。从文档加载、切分,到向量化、存储、检索,再到与大模型的交互,都有非常好的抽象和封装,让我可以不用关心底层具体模型的 API 差异,专注于业务逻辑的实现,开发效率大大提升。
另外,Spring AI 的 Function Calling 机制,优雅地打通了 AI 的自然语言理解能力和我们后端系统的业务能力,让 AI 真正成为了我们业务系统的一个智能入口。
SQL 优化你的思路和出发点
回答这个问题的核心是先提到开启慢查询日志和使用 EXPLAIN 进行执行计划分析。
慢查询日志捕获那些执行时间超过阈值的 SQL 语句,这是发现问题的起点。拿到慢 SQL 后,用 EXPLAIN 关键字分析这条 SQL 的执行计划,分析原因。
基于 EXPLAIN 的分析结果,进行针对性优化。比较常见的 SQL 优化手段如下:
索引优化(最常用)
避免
SELECT *深度分页优化
尽量避免多表做 join
选择合适的字段类型
......