简介:这份亚信笔试题整理面向准备亚信技术岗笔试的Java学习者,聚焦校招与社招中常见的编程基础、网络通信和框架理解考点。内容围绕四道选做题展开:Socket通讯例程要求补全服务端与客户端代码,考察服务端套接字监听、客户端连接、缓冲读取与输出流使用以及多线程处理;Singleton模式题目通过静态变量初始化顺序推断程序输出,检验面向对象与单例实现理解;另含用UML图描述MVC框架、谈谈Hibernate理解及优劣势等开放题。资源以1个doc文档交付,压缩包约32KB,文件数量精简,便于直接查阅、打印和考前复习。已有2443人学习,说明这份材料在同类笔试资料中具备一定参考热度。读者可借此熟悉题目问法、代码填空思路与运行结果分析,并对照Java网络编程、多线程、面向对象设计等知识点查漏补缺,适合笔试前快速梳理考点与自测。
1. 从一份四选二的 Java 笔试卷看真本事:Socket、单例、MVC、Hibernate 的落地价值
拿到一份「四道题任选两道」的 Java 笔试卷,多数人第一反应是挑软柿子捏。但这套题的真正价值不在应试,而在于它把四个技术点摆在一起:Socket 多线程通讯、单例初始化顺序、MVC 分层、Hibernate 持久化。这四个点分别对应 Java 后端工程师日常的四类活:网络 IO、对象生命周期、架构分层、ORM 选型。我把这套题当成一份复盘清单重新做了一遍,发现每道题往下挖都有一套实操细节,不是背答案能糊过去的。下面按「题目本身怎么解 → 代码怎么写 → 容易翻车在哪」的节奏拆,适合准备笔试的应届生,也适合工作两三年想补基础的人对着自查。
2. Socket 多线程例程:填空、补全与线程模型的真实边界
2.1 三个空不是猜出来的,是从 API 语义反推的
题目给的服务器端骨架里留了三处空缺,涉及端口号、ServerSocket接收返回值、以及线程启动方式。这类填空题的解法不是背答案,而是从每个 API 的输入输出类型往回推。
第一空在new ServerSocket(____),候选值里 80、800、8000、80000。端口取值范围是 0~65535,80000 直接越界;80 是特权端口,普通用户进程绑定会被操作系统拒绝;800 虽然合法但低于 1024 同样属于特权段。所以唯一稳妥的是 8000。这里有个血泪经验:我见过有人在容器里试 80 端口跑不通,查了半天以为是防火墙,其实是非 root 进程无权绑定。
第二空是new MultiUser(______),参数类型是java.net.Socket。ServerSocket本身不是Socket实例,server.open()、server.listen()这两个方法在 Java 里根本不存在,属于干扰项。正确写法只有server.accept(),它阻塞等待客户端接入,返回一个已连接的Socket。
第三空是线程启动,mu.do()、mu.go()都不存在;mu.run()会以普通方法调用执行,等于把run()里的死循环塞进主线程,while(true)一进就出不来,后续的accept()永远执行不到,服务端只能处理第一个连接。正确写法必须是mu.start(),由 JVM 分配新线程执行run()。
提示:
MultiUser继承了Thread,但run()里包着while(true),每个连接占一个线程。这种「一连接一线程」模型在几十个并发时不明显,到几百个就开始暴露问题了。
2.2 把两端代码补完整并跑通
光填空不够,实际要能跑起来。下面是我补全后的服务端,加了必要的异常处理和资源管理:
import java.net.*; import java.io.*; public class MultiUser extends Thread { private Socket client; public MultiUser(Socket c) { this.client = c; } @Override public void run() { BufferedReader in = null; PrintWriter out = null; try { // setSoTimeout 防止 readLine 永久阻塞在死连接上 client.setSoTimeout(30000); in = new BufferedReader(new InputStreamReader(client.getInputStream(), "UTF-8")); out = new PrintWriter(new OutputStreamWriter(client.getOutputStream(), "UTF-8"), true); String str; while ((str = in.readLine()) != null) { System.out.println("recv: " + str); out.println("has receive...."); if ("end".equals(str)) { break; } } } catch (SocketTimeoutException e) { System.out.println("client idle timeout: " + client.getRemoteSocketAddress()); } catch (IOException e) { e.printStackTrace(); } finally { // 逆序关闭,先关流再关 socket try { if (in != null) in.close(); } catch (IOException ignored) {} try { if (out != null) out.close(); } catch (IOException ignored) {} try { if (client != null) client.close(); } catch (IOException ignored) {} } } public static void main(String[] args) throws IOException { ServerSocket server = new ServerSocket(8000); System.out.println("listening on 8000"); while (true) { Socket socket = server.accept(); // 阻塞等待连接 MultiUser mu = new MultiUser(socket); mu.start(); // 必须 start,不是 run } } }改动点逐个说明。setSoTimeout(30000)是防僵尸连接的关键,原题代码在客户端异常断网时readLine()会一直挂着,线程永远不释放;30 秒无数据就抛SocketTimeoutException,进finally走清理。PrintWriter构造时第二个参数true是自动 flush,原来代码里手动out.flush()也能用,但少写一处就丢消息,这个坑在调试时特别隐蔽。InputStreamReader显式指定 UTF-8,不指定就吃平台默认编码,Windows 上默认 GBK,和 Linux 端互传中文直接乱码。
还有一个细节:原题的while(true)+in.readLine()没有判空。客户端断开时readLine()返回null而不是抛异常,不判空就会拿null去equals,触发NullPointerException,而原题catch块是空的,异常被静默吞掉,表现为「服务端没反应但也没报错」,这是最典型的黑匣子场景。
2.3 客户端代码与协议约定
客户端补8000端口,跑得通还要注意读写配对:
import java.net.*; import java.io.*; public class Client { public static void main(String[] args) throws Exception { Socket server = new Socket(InetAddress.getLocalHost(), 8000); BufferedReader in = new BufferedReader( new InputStreamReader(server.getInputStream(), "UTF-8")); PrintWriter out = new PrintWriter( new OutputStreamWriter(server.getOutputStream(), "UTF-8"), true); BufferedReader wt = new BufferedReader(new InputStreamReader(System.in, "UTF-8")); String str; while ((str = wt.readLine()) != null) { out.println(str); // 发一行 if ("end".equals(str)) break; // 结束标记不再等服务端回包 System.out.println(in.readLine()); // 必须等一行回来,读发严格交替 } server.close(); } }这里有个协议层的隐性约束必须说清。服务端每收一行回一行,客户端每发一行读一行,是严格的一问一答;如果客户端连发两行再读,服务端回的两行会堆在 socket 缓冲区里,第一行被读走,第二行还在缓冲区等待下一次readLine(),逻辑上不算错,但如果客户端某次只发不读,缓冲区写满后PrintWriter的println不报错也不阻塞(PrintWriter会把异常吞成内部标志位),消息就悄悄丢了。这也是为什么我在服务端加了自动 flush,客户端用out.println后立即读,保持节奏一致。
InetAddress.getLocalHost()在部分机器上因主机名解析慢会卡几秒,如果只是本地测试,直接换成new InetSocketAddress("127.0.0.1", 8000)更干脆。三个空填完、两端跑通、中文不乱码、断开有日志,这道题才算真正做完。
3. 单例初始化顺序:一道题吃透类加载的静态变量陷阱
3.1 输出结果是 1 和 0,为什么
这道题是笔试卷里区分度最高的一题,答案counter1=1、counter2=0。要解释清楚,得按类加载时静态成员的初始化顺序逐行走。
类加载的准备阶段,JVM 给所有静态字段分配内存并赋零值:obj=null、counter1=0、counter2=0。进入初始化阶段后,按源码书写顺序执行静态赋值和静态代码块:
第一句private static Singleton obj = new Singleton();,此时触发new Singleton(),构造函数里执行counter1++,counter1从 0 变 1;counter2++,counter2从 0 变 1。构造完成后obj指向实例。
第二句public static int counter1;只有声明没有赋值,在 Java 里声明式静态字段不产生额外的赋值动作,所以counter1保持 1。
第三句public static int counter2 = 0;是带初始值的显式赋值,此刻把已经变成 1 的counter2重新赋回 0。
所以最终counter1=1、counter2=0。核心在于:构造函数的副作用发生在counter2显式赋值之前,被后者覆盖了。如果counter2的声明挪到obj之前,结果就变成 1 和 1。
3.2 用可验证的方式证实结论
单调结论容易记混,写个变体跑一遍最实在:
public class SingletonProbe { // 情况 A:obj 在 counter2 之前声明(与笔试题一致) private static SingletonProbe obj = new SingletonProbe(); public static int counter1; public static int counter2 = 0; private static int counter3 = 5; private SingletonProbe() { counter1++; counter2++; counter3++; } public static void main(String[] args) { System.out.println("counter1=" + counter1); // 1 System.out.println("counter2=" + counter2); // 0 System.out.println("counter3=" + counter3); // 5,同样被显式赋值覆盖 } }注意counter3:构造函数里自增到 6,但声明=5在其后执行,又被压回 5。这证明被覆盖的不是counter2这一特例,而是所有「声明位置在构造触发之后且有显式初始值」的静态字段。另一个佐证方法是把obj的初始化挪到静态代码块里,用static { obj = new SingletonProbe(); }写在counter2后面,此时counter1和counter2都会是 1,因为赋值顺序变了。
3.3 生产代码里应该怎么写单例
笔试题考的是顺序,工程上更该关心单例怎么写得不出幺蛾子。几种常见写法的边界如下:
| 写法 | 惰性加载 | 线程安全 | 防反射破坏 | 适用场景 |
|---|---|---|---|---|
| 饿汉式静态常量 | 否 | 是 | 否 | 实例轻、启动即用 |
| 静态内部类 | 是 | 是 | 否 | 大多数常规场景 |
| 双重检查锁 + volatile | 是 | 是 | 否 | 需要延迟初始化的重对象 |
| 枚举单例 | 否 | 是 | 是 | 需要防御序列化和反射 |
静态内部类是我一般会首选的写法,因为类加载机制天然保证线程安全,且不占启动时间。双重检查锁必须给实例字段加volatile,否则指令重排可能让另一个线程拿到「已分配但未初始化完成」的半成品对象,这是新手最容易漏的一步。枚举单例虽然写法最简,但它是饿汉,如果构造过程依赖运行期参数就不合适。原题那种在构造里做自增的写法,本质是把初始化逻辑塞进了构造器,一旦有多份静态字段声明,就必然踩顺序坑。
4. MVC 与 Hibernate:把「谈谈理解」变成能落地的判断标准
4.1 MVC 的 UML 关系与层间约束
第三题要求画 UML 或谈理解。只说「模型、视图、控制器分离」没有信息量,能落到类关系才算答到位。UML 里通常是这样一组关系:Controller聚合一个或多个Model,Controller与View双向关联(View 把用户事件转给 Controller,Controller 更新 View),View按需读取Model的展示数据,Model不持有 View 和 Controller 的引用。
这个「Model 不知道 View 存在」的单向依赖是整个模式的价值所在。放到 Web 项目里,Controller 是一层路由加参数校验,Model 拆成实体、服务、仓储,View 是模板或 JSON 序列化层。真正的分界线不是目录结构怎么分,而是改动传播方向:改数据库字段只动 Model 层,改页面只动 View 层,如果一次改动要同时在三个层里动,说明分层已经失效了。
一个常见误用是把业务计算写进 Controller。Controller 只该做三件事:接参、调服务、返回结果。业务规则进 Model 层,这样同一份逻辑才能被定时任务、消息消费者、单元测试复用。另一个误用是让 View 直接查数据库,这在早期的 JSP 页面里极其普遍,后果是 SQL 散落在模板里,改一个查询条件要全局搜索。
注意:MVC 的「C」不承担状态管理。用户的会话状态应该放在 Session 或令牌里,Controller 对象本身在多数 Web 框架里是单例的,往 Controller 字段里塞请求级数据,并发时必然串数据。
4.2 Hibernate 的适用面与代价
第四题考 Hibernate。它的核心价值是把对象与表的映射、级联操作、事务边界从手写 SQL 里抽出来,让业务代码面向对象。优势很具体:一是跨数据库方言,换数据库只需换方言配置;二是延迟加载让关联查询不必一次拉全,避免笛卡尔积爆数据;三是一级缓存和脏检查让同一事务内多次取同一实体不重复查库,提交时自动生成 UPDATE。
不足同样具体,而且这些不足在面试里比优势更能拉开差距。第一,N+1 查询是常态陷阱:查 100 个订单,循环里访问每个订单的明细,就产生 1 次主查询加 100 次子查询,不用join fetch或批量抓取就救不回来。第二,复杂报表查询用 HQL 写起来极其别扭,多表聚合、窗口函数往往直接退回原生 SQL 更省事。第三,批量写入不适合走 ORM,一万条save会产生一万次 INSERT 和随之而来的内存膨胀,用 JDBC 批处理或框架的批量模式才合理。第四,延迟加载在序列化时会抛异常,实体出了事务范围再访问懒加载字段就报LazyInitializationException,这个报错几乎每个用 Hibernate 的人都撞过。
选型上我的判断很朴素:领域模型相对稳定、以增删改查为主体、团队愿意维护映射元数据,用 ORM 划算;偏报表、偏批量、SQL 需要精细调优的系统,直接把 ORM 当主角会绑手绑脚,混合使用更现实——OLTP 走 ORM,OLAP 走手写 SQL。
4.3 一次真实的性能排查路径
假设接口变慢,怀疑 ORM 层,我会按下面的顺序看:
# 打开 SQL 输出,观察语句数量是否符合预期 # application.properties 示例 spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # 同时开启统计,定位慢语句 spring.jpa.properties.hibernate.generate_statistics=true参数说明:show-sql只打印语句,量大时影响吞吐,只在排查期开启;format_sql让语句可读;generate_statistics会输出查询次数、命中缓存次数、执行耗时,是判断 N+1 最直接的证据。
拿到日志后先数语句条数。一个列表接口如果打印了上百条 SELECT,基本就是 N+1。解决办法是在查询里显式指定关联抓取方式,或用二级缓存覆盖高频只读数据。如果语句不多但单条慢,看执行计划,补索引或改写查询。如果语句数和耗时都正常但接口仍慢,问题多半不在 ORM,转去看连接池命中率和垃圾回收。
数据库侧也要有一道保险:spring.jpa.open-in-view在多数新项目里建议关掉,开着会让 Session 拖延到视图渲染结束,连接被长期占用,高并发下连接池很快见底,这也是很多人第一次上线才发现的坑。关掉后必须在服务层把数据取全再返回,虽然多写几行,但边界清晰。
5. 笔试卷之外的复现技巧:自测脚本、边界用例与长期习惯
把四道题当成一套可执行的验收清单,收获比背答案大得多。我后来给自己定了个习惯,每做完一道题就补一份能在本地跑通的验证材料。
Socket 那道题,我一般写个并发压测小脚本,用 shell 起十个客户端同时连接,观察服务端线程是否正常回收:
# 并发 10 个客户端,每个发 3 条消息 for i in $(seq 1 10); do ( echo "hello-$i"; echo "ping-$i"; echo "end" ) | java -cp . Client & done wait echo "all client done"这一步能暴露两个问题:如果服务端线程上线后不下降,说明连接没被close或超时没生效;如果输出出现乱序交叉,说明可考虑把响应逻辑改成「按连接隔离输出」,在PrintWriter上用synchronized或每连接独立缓冲。这个脚本我跑过几次,最有用的是它把「看起来能跑」和「断开后能恢复」区分开了。
单例那道题,我会写一个断言版本的小测试,把counter1、counter2、counter3的期望值都断言一遍,再把obj的声明位置前后挪动、跑对照实验。这种 A/B 对照比死记结论可靠,因为记忆会骗人,执行顺序不会。
MVC 和 Hibernate 属于论述题,没法用代码一键验证,但可以落地成检查表:一个请求进来,从 Controller 到服务到仓储,每一跳都该只做本职的事;一条 SQL 从数据库慢日志里捞出来,能对应到哪段业务代码。这两件事做顺了,答题时的「理解」才是真的。
我在复盘这套题上翻过一次车:早期把单例结论记成「都是 1」,面试被追问为什么counter2是 0 时卡住了。从那以后我每次遇到静态初始化相关的逻辑,都强制先把「声明顺序 + 零值→赋值」这两步在纸上写一遍再下结论,而不是凭印象答。希望帮到你。
本文还有配套的精品资源,点击获取