这套卷子我在准备春招的时候完整复盘过好几遍。贝壳找房的Java笔试卷和市面上大多数互联网公司的卷子不太一样,它的技术栈重心非常鲜明:房产交易链路长、业务状态多、房源数据并发读写的压力大,所以考的都是“你能不能撑住一套高复杂度的业务系统”这种问题,而不是让你炫技写一段花哨算法。2023年春招这套Java工程师笔试卷2,题型覆盖面很扎实,我把每个模块的考察逻辑、我做题时的思路、以及整理出来的扩展知识点全部拆在这里。准备春招的同学,不管目标是不是贝壳,这套卷子背后的考点体系都值得认真过一遍。
1. 题型结构与考察重心:贝壳这场笔试到底想筛选什么人
1.1 整张卷子的模块划分
我把整张卷子回看了一遍,大致可以分成五个模块:Java语言基础、集合与并发、Spring与微服务、数据库与缓存、以及一道综合性系统设计题。每个模块的题目数量和难度都不是平均分配的。语言基础题偏多但绝大多数是送分题,集合与并发次之,但挖得比较深,数据库与缓存是重头戏,设计题单独占一题且直接影响综合评价。
这个结构其实反映了贝壳这类业务型公司的真实用人逻辑:语言基础是底线,集合并发是日常开发的核心能力,数据库与缓存决定了你能不能在高流量场景下写好代码,而系统设计题是区分“会写代码”和“能独立扛业务”的人的分水岭。
1.2 与其他公司笔试卷的横向对比
之前我也刷过一些纯互联网公司的笔试卷,有些卷子把大量分值压在算法题上,比如动态规划、贪心、复杂图论,两道写不出来基本就挂了。但贝壳这套卷子整体算法难度属于中等偏上,不会刻意出偏题怪题,反而更看重工程基础。题目里出现的并发场景、缓存失效、事务边界,几乎都能在贝壳实际的房源检索、看房预约、交易单流转场景中找到原型。
这给我的启发是:准备这类公司笔试,不能只顾着刷LeetCode,还要把SSM/Spring Boot、MySQL调优、Redis缓存这些在实际业务里高频使用的技术整理成自己的知识体系。
2. Java语言基础:送分题里的“陷阱层”与边界问题
2.1 字符串与包装类:考的不是语法,是JVM细节
第一类题是看起来人畜无害的送分题,但里面藏着JVM层面的细节。比如原卷里有一道很经典的题:String str = new String("abc")这行代码创建了几个对象?很多人脱口而出“一个”,正确答案是两个——一个在堆里的String对象,一个在字符串常量池里的“abc”字面量对象。如果常量池中已存在“abc”,则只创建一个堆对象。
我当时做这道题的时候专门在草稿纸上推了一遍内存布局,最后还是选了“分情况讨论”。这种题考的不是会不会背答案,而是你能不能把字符串常量池、堆、栈之间的关系说清楚。
还有一道Integer缓存的题,问Integer a = 127; Integer b = 127; a == b的结果,以及换成128会怎样。这个考点本质是Integer内部缓存区间[-128, 127],在这个范围内会直接返回缓存对象,超出范围则new新对象,所以用==比较就会出现“同值不同对象”的情况。实际开发中我一般建议用equals比较包装类,别用==去赌缓存区间。
Integer a = 127; Integer b = 127; System.out.println(a == b); // true,走缓存 Integer c = 128; Integer d = 128; System.out.println(c == d); // false,超出缓存区间这类题在房产交易系统里的意义是:房源信息、带看记录、价格数值往往以包装类形式在RPC层传输,一不留神就会在比较逻辑上埋坑。面向对象编程Java的核心之一就是类型系统,这种细节值得反复琢磨。
2.2 异常体系:受检异常与运行时异常的分寸
这套卷子有一道异常设计题:业务校验不通过时,自定义异常应该继承Exception还是RuntimeException?我印象里不少同学选了Exception,理由是“受检异常强制调用方处理,更安全”。但放在Spring Boot工程里,正确答案是RuntimeException。
原因要落到Spring事务机制上:Spring默认只在抛出RuntimeException或Error时回滚事务,如果抛的是受检异常且没有手动声明@Transactional(rollbackFor = Exception.class),事务不会回滚,数据就会停留在半完成状态。我在实际项目里就踩过一次:状态流转失败的异常继承了Exception,结果订单状态改了但明细没插进去,问题排查到凌晨才发现是事务没回滚。
提示:自定义业务异常统一继承
RuntimeException,配合@ControllerAdvice全局异常处理,在工程上是更省心的做法。
2.3 枚举、Lambda、泛型:看起来用到但总容易写错
枚举在笔试里不是只考“定义几个常量”,而是考能不能用枚举做状态机。贝壳的业务里房源状态、订单状态都有大量流转逻辑,用枚举管理状态比散落一堆字符串常量健壮得多。原卷中有一道题是给房源上下架状态建模,我给出的方案是枚举里放状态码和描述,再写一个nextStatus方法来处理合法流转。
public enum HouseStatus { OFF_SHELF(0, "已下架"), ON_SHELF(1, "已上架"), LOCKED(2, "已锁定"); private final int code; private final String desc; HouseStatus(int code, String desc) { this.code = code; this.desc = desc; } }Lambda和函数式接口也考了一道,核心是list.stream().filter().map().collect()链式调用,以及方法引用写法。泛型考了擦除机制:List<String>和List<Integer>在运行时是同一个Class,所以不能用list instanceof List<String>这种写法,要么用instanceof List<?>,要么通过元素类型做判断。这些知识点单独看都不难,但组合在一起确实能区分出“背过八股文”和“真写过代码”的人。
3. 集合框架与并发编程:房产交易系统里最硬的两块基本功
3.1 HashMap到底考到什么深度才算过关
HashMap在Java面试里被问到的概率接近百分之百,这套卷子也不例外。但它的考法不是“HashMap的底层结构是什么”这种教科书题,而是从使用场景出发,一层层追问。
第一层:HashMap的底层结构,数组+链表+红黑树,JDK 8在链表长度超过8且数组容量大于等于64时,链表转红黑树,为什么转红黑树?因为链表查找是O(n),红黑树是O(log n)。为什么临界值是8?这是泊松分布下的一个工程权衡,源码注释里给出的是负载因子0.75时,链表长度达到8的概率已经非常低。
第二层:扩容机制。默认容量16,负载因子0.75,当size超过容量 * 负载因子时触发扩容,容量翻倍。JDK 8在扩容时引入了尾插法,避免了JDK 7头插法在并发场景下可能出现环的问题。所以即使HashMap不是线程安全的,JDK 8的修改也降低了并发扩容时的破坏概率。
第三层:改写equals后为什么必须改写hashCode。两个对象equals相等时hashCode必须相等,否则HashMap在查找时会定位到不同的桶,导致containsKey失效。这道题我答的时候补了一个实际场景:房源对象作为key放入Map时,如果只用房源ID做equals但hashCode用了所有字段,就会频繁出现“找不到key”的诡异问题。
3.2 线程安全集合的演进逻辑
下面一道是集合并发安全的选型题。Hashtable为什么会被淘汰?因为它把整张表锁住,所有读写串行化,并发度太低。Collections.synchronizedMap只是在方法级别加synchronized,本质也是一把大锁。
ConcurrentHashMap是标准答案,但JDK 7和JDK 8的实现差异要说清楚。JDK 7用的是分段锁,把数据分成一段一段,默认16段,锁粒度是段级别。JDK 8抛弃了分段锁,改用CAS +synchronized锁定数组中的每个桶头节点,锁粒度更细,并发度更高。源码里putVal方法先用CAS尝试插入空桶,如果桶不为空再锁住头节点。
我在做这道题时顺手补了一句:读多写少的缓存场景里,CopyOnWriteArrayList和CopyOnWriteArraySet也值得考虑,它们的写操作会复制底层数组,适合读远多于写的场景,比如黑白名单、系统配置列表,但不适合写频繁的库存类数据。
3.3 线程池与锁:并发题的答题框架
线程池几乎是必考项。参数要能完整背出来:corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime空闲线程存活时间、workQueue任务队列、threadFactory线程工厂、handler拒绝策略。
更重要的是执行流程:新任务进来,先判断核心线程是否已满;没满直接创建核心线程执行;满了就进阻塞队列;队列也满了再创建非核心线程直到最大线程数;达到最大线程数后触发拒绝策略。四种拒绝策略里,AbortPolicy直接抛异常,CallerRunsPolicy让调用线程自己执行,DiscardPolicy和DiscardOldestPolicy会静默丢弃,实际项目中我一般用CallerRunsPolicy做降级,至少任务不会丢。
volatile和synchronized也考了一题。volatile保证可见性和有序性,但不保证原子性,适合状态标志位;synchronized是互斥锁,能同时保证可见性、有序性和原子性。这里最好再补一句JMM的知识,说明线程操作共享变量时先拷贝到工作内存,volatile通过内存屏障强制刷新主内存,这就是为什么多线程环境下不加volatile会出现“读不到最新值”的情况。
并发场景题我额外补充一个高频考法:多个线程同时扣减一个库存变量,怎么保证不超卖?除了加锁,也可以用AtomicInteger的CAS自旋,或者LongAdder在极高并发下减少竞争。这套卷子虽然没有直接考这三者对比,但我在复盘时默认把它当成了并发模块的加分内容。
提示:写线程池相关的编程题时,建议把拒绝策略线程工厂都配上,不要只传一个线程数量,这样能展示你对线程池完整生命周期的理解。
4. Spring Boot与微服务:房源系统的框架层考点
4.1 IOC、AOP在笔试里的落地考法
Spring的IOC不是只考“控制反转是什么”,而是考你对Bean生命周期的熟悉程度。执行顺序大致是:实例化 -> 属性填充 -> Aware接口回调 -> BeanPostProcessor前置处理 -> 初始化方法 -> BeanPostProcessor后置处理。AOP代理就在BeanPostProcessor后置处理这个阶段生成,所以一个Bean最终放进容器里的可能是代理对象。
AOP最常见的考法是动态代理的两种方式:JDK动态代理要求目标类实现接口,基于反射生成代理类;CGLIB则直接生成目标类的子类,不要求接口。Spring Boot 2.x之后默认使用CGLIB代理,很多生产问题都出在这里——如果某个类被代理后,内部自调用会导致切面失效。
比如一个Service里有两个方法,方法A内部直接调用了同类的方法B,而方法B上有@Transactional或自定义日志注解,调用方只通过接口调了方法A,那方法B上的事务注解不会生效。原因是AOP代理只对方法A生效,方法A内部的this.methodB()是调用原始对象,不是代理对象。解法是在方法A里注入自身代理对象,或者把方法B拆到另一个Service里。
4.2 Spring Boot自动配置与启动流程
自动配置是一个高频考点。@SpringBootApplication是@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan三个注解的组合。@EnableAutoConfiguration会通过AutoConfigurationImportSelector加载META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中的配置类,再配合@ConditionalOnClass、@ConditionalOnMissingBean等条件注解决定是否生效。
原卷里有一道题是问“为什么Spring Boot能自动帮我们配好数据源”。我当时答的是:如果classpath下存在DataSource类且没有自定义DataSourceBean,自动配置会创建HikariCP连接池,并读取spring.datasource.*配置。所以想让某个自动配置失效,可以加一个自定义Bean覆盖,也可以用exclude属性排除。
4.3 微服务拆分与服务治理
贝壳的业务体系里,房源、楼栋、户型、经纪人、带看、合同、支付这些模块天然适合微服务化。卷子里有一道问服务拆分的题,不是在问用什么框架,而是问“你会按什么原则拆”。我的回答是:按业务域拆、按变更频率拆、按团队归属拆;同时避免过度拆分,一个3人小团队维护十几个微服务只会带来灾难。
服务治理相关考点包括注册中心选型、配置中心、负载均衡、熔断降级。注册中心我重点准备了Nacos和Eureka的区别:Nacos支持AP和CP两种模式切换,Eureka只保证AP;Nacos同时具备配置中心能力,生态更贴近Spring Cloud Alibaba。熔断这块,Sentinel和Hystrix的对比也常考,核心是计数窗口、滑动窗口、信号量隔离这些底层机制。
5. 数据库、缓存与分布式:高并发读写场景下的必答题
5.1 MySQL索引是必考,不能只会背B+树
数据库题在这套卷子里的分量很高。第一梯队就是索引,问法通常是:为什么不选哈希索引、为什么不选二叉树,而要选B+树?
B+树的优势要能从磁盘IO角度讲:树高矮(三层B+树能存千万级数据),叶子节点用双向链表串联,非常适合范围查询和排序;节点大小和操作系统的页对齐,一次IO能加载更多索引项。哈希索引只适合等值查询,范围查询直接失效。
然后是最左前缀原则。我复盘时专门找了一道联合索引题:(city, district, community)这个索引,哪些查询能走索引?city、city + district、city + district + community能走;district + community走不了,因为跳过了最左列。还要注意范围查询会让右边的列失效,比如city = "北京" AND district > "朝阳" AND community = "望京SOHO",community就不会走索引。
回表和覆盖索引也是一对高频组合:非聚簇索引查到主键,再用主键回表查完整行,这就是回表;如果索引本身就包含了查询需要的所有字段,就是覆盖索引,可以避免回表,性能翻倍。实际优化时,可以用explain看type和Extra列,出现了Using filesort或Using temporary基本就要动手优化SQL了。
5.2 Redis缓存穿透、击穿、雪崩
这三座大山是缓存题的常青树。穿透是查一个必然不存在的数据,请求直接打到数据库,解法有三种:缓存空对象(设置短过期时间)、布隆过滤器前置拦截、参数合法性校验。房产搜索里用户输入一个不存在的房源ID,布隆过滤器就能在缓存和数据库之前把它拦掉。
击穿是某个热点key瞬间失效,大量请求同时打到数据库。解法:热点key不设置过期时间或设置长过期时间、用互斥锁让只有一个请求去重建缓存、逻辑过期方案。雪崩是大量key在同一时间集体失效,或者Redis节点宕机。解法:过期时间加随机值、多级缓存、Redis高可用集群。
缓存与数据库的一致性也考了一个经典场景:先更新数据库还是先删缓存?我给出的结论是“先更新数据库,再删除缓存”,配合延迟双删或者订阅binlog异步删除兜底。这里要注意,强烈不建议先删缓存再更新数据库,因为删缓存后一有读请求打到数据库读旧数据,又把手里的旧值写回缓存,缓存就永远是脏的了。
5.3 分布式事务与消息队列
房产交易里下单、锁房、创建合同是一个典型分布式事务场景,因为订单服务、房源服务、合同服务分属不同库。2PC的性能太差不适合在线交易,TCC的落地成本高,比较常用的是可靠消息最终一致性方案。
消息队列还有一个核心考点是幂等:消费者收到重复消息时,不能重复扣款、重复锁房。解法是消费端做幂等表,用消息唯一ID去重,或者利用业务主键唯一约束来防重。顺序消息也是高频难点,比如一个房源的上下架消息必须严格有序,否则先上架后下架的两次消息如果乱序,房源状态就错了。RocketMQ支持队列级别的顺序消息,Kafka则要靠单分区加按业务ID做key来保证分区内有序。
做完这套卷子后,我把这几个知识点在项目里还原了一遍,发现分布式事务的核心不是选一个中间件,而是要想清楚每个环节失败后的补偿路径。这部分内容在正常简历项目描述里很难写清楚,但笔试时能答完整就是加分项。
5.4 分布式锁的实现与坑
分布式锁考的是多实例下互斥。Redis方案用SET key value NX EX seconds保证原子性,正确的解锁用Lua脚本保证“判断是不是自己的锁”和“删除锁”两步原子执行。Redisson的看门狗机制可以自动续期,避免业务执行时间超过锁过期时间导致提前释放。
这套卷子没有直接考代码实现,但我复盘的时候把它当成了必会代码,因为跟后面的设计题强相关。选型的结论是:小规模场景用Redis锁就够了;如果对强一致性要求很高,用ZooKeeper临时顺序节点,因为ZooKeeper在节点删除时会主动通知客户端释放锁,避免了Redis锁过期时间不好控制的缺陷。
// 用RedisTemplate手工实现分布式锁的加锁与解锁 String lockKey = "stock:1001:lock"; String requestId = UUID.randomUUID().toString(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 业务逻辑 } finally { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), List.of(lockKey), requestId); } }6. 系统设计题:从“小区房源浏览”到“高峰秒杀”的答题思路
6.1 一道典型设计题的完整推演
这套卷子的压轴题是一个贴合贝壳业务的场景:某热门新盘开盘,放出100套房源,大量用户同时在线抢看房资格,请设计这个系统。不夸张地说,这个题的答案几乎能看出一个人是“接口仔”还是“架构师”。
我当时的回答分几步走。第一步先澄清需求边界:房就100套,目标用户量是十万级还是百万级?抢到资格后需要在多长时间内确认?这些问题如果不问清楚就直接开方案,基本会被判低分。
第二步是容量估算:假设10万用户同时在线,峰值QPS按总用户量的20%计算,压到2万QPS,这就是一个中高并发系统,需要缓存、异步、限流三者配合。
第三步是整体架构:网关层做全局限流和用户身份校验,Nginx、Sentinel都行;应用层用Redis预扣库存,防止超卖;数据库层只保存最终结果;确认资格时引入MQ削峰,让用户提交的请求先进入队列,再由消费者平滑地写库。
第四步是防重与幂等:用户重复提交时前端置灰,后端用请求唯一ID做幂等,Redis里以用户ID为维度加分布式锁,防止同一个人把多个看房资格都锁走。
最后一步是容错和扩展:Redis宕机怎么办?本地缓存兜底加限流;MQ积压怎么办?监控告警加消费者扩容。整个答案是有完整链路和取舍依据的,而不是堆一堆中间件名词。
6.2 设计题的评分标准和表达技巧
复盘过几套大厂设计题之后,我发现评分维度基本是五个:需求澄清、容量估算、架构合理度、核心流程完整性、扩展性与容错性。表达顺序很重要,建议按“需求 -> 估算 -> 架构 -> 核心流程 -> 容错”来讲,不要一上来就画一张包含十几个组件的架构图。
设计题的高分关键不是用了多高级的中间件,而是每一步都能解释“为什么”。比如为什么用Redis而不是数据库直接扛?因为2万QPS对MySQL来说已经非常危险,而Redis单实例就能扛十万级QPS。为什么用MQ?因为用户确认资格这个动作不需要立刻完成,可以异步化,既能削峰又能解耦。这些理由比名词本身值钱得多。
7. 复盘总结:刷题之外,这些经验和认知才是最重要的
7.1 我在复盘过程中发现的常见盲区
整套卷子刷完后,我总结了几个反复出现的盲区,也是绝大多数Java求职者最容易翻车的地方。
第一个盲区是“会背概念但不会解释取舍”。比如能背出B+树的所有优点,但说不出为什么不选跳表;能背出ConcurrentHashMap在JDK 8用了CAS加synchronized,但说不清为什么锁粒度更细了还要用synchronized而不是全CAS。面试官想听的不是定义,而是你脑子里有没有一张完整的知识地图。
第二个盲区是“不懂业务场景”。同样的知识点,放在纯内存计算里和放在房产交易链路里,考点完全不同。贝壳这套卷子大量题目隐含了业务背景,说明他们更看重候选人把技术应用到具体场景的能力。我建议准备笔试时多做一步:每个核心题目反推一个业务场景,再用技术方案去解决它。
第三个盲区是“复习没有主线”。Java基础、集合、并发、JVM、Spring、MySQL、Redis、分布式、设计题,这九个模块是有依赖关系的。我建议按“语言基础 -> 集合 -> 并发 -> JVM -> Spring -> MySQL -> Redis -> 分布式 -> 设计题”的顺序复习,前面不扎实,后面一定会卡壳。
7.2 给春招人的备考建议
如果距离笔试还有两到三周,我建议把时间切成三块。第一块是快速过核心八股文,把高频考点整理成自己的知识图谱,重点标注那些“能展开三层追问”的知识点;第二块是刷真题,每套题做完不要急着做下一套,把错题对应的知识盲区重新补一遍,形成错题本;第三块是动手写代码,笔试里的编程题不能只讲思路,一定要落实到IDE里跑一遍,尤其是线程池、分布式锁、并发扣库存这类代码,光看是不行的。
有一件事我不太推荐:疯狂背题。贝壳这种业务型公司的笔试,题目本身重复出现的概率不高,但核心考点高度一致。你把HashMap的源码读透、能把B+树的每一层为什么说清楚、能徒手写一个Redis分布式锁,比背一百道“HashMap和Hashtable区别”有用得多。
7.3 最后分享一个笔试实战的小技巧
最后说一个我在整理这套卷子时特别有体会的细节。笔试的时候时间非常紧张,选填题不要恋战,一道概念题如果超过两分钟还犹豫不决,先标记跳过,把时间留给后面的编程题和设计题。因为设计题的分数权重极高,而且能展示整体架构能力,前面丢几分完全可以在后面找回来。
还有一个很多人会忽略的点:编程题哪怕不是最优解,也一定要把核心思路和算法注释写在代码上方。阅卷人能看到你的思路,只要你方向对,即使实现有小Bug也会给不少步骤分。我朋友当时就是因为一道并发题想到了CAS但没写完,只写了注释,最终依然过了笔试。别让“差一点写完”变成“连思路都没留”。