JavaSE 商业场景训练营
JavaSE 不是语法清单。真正做后端系统时,JavaSE 会出现在每一个细节里:用集合去重和映射,用泛型保证类型安全,用反射支撑框架,用 IO/NIO 处理文件和网络,用线程池控制并发,用 JVM 工具排查 OOM、GC 和线程堆积。
一句话主线:
JavaSE 训练的目标是把“语法点”变成“项目能力”:知道什么时候用、为什么这么设计、底层怎么工作、不理解会出什么生产事故。
训练目标
学完本页,你要能做到:
- 用 Java 对医疗数据采集记录做去重、分组、映射和校验。
- 解释
equals/hashCode、HashMap、ArrayList、泛型擦除在项目里的后果。 - 写出反射读取注解并做字段脱敏的 Demo,理解框架为什么离不开反射。
- 解释大文件导入为什么不能一次性读入内存,BIO、NIO、Buffer、Channel 解决什么问题。
- 设计采集任务线程池,知道参数、队列、拒绝策略、下游容量和监控指标。
- 讲清 JDK7 和 JDK8 在集合、接口、Lambda、Stream、日期时间、并发上的关键差异。
- 用线程栈、GC 日志、heap dump、对象直方图排查 JavaSE 层生产问题。
商业场景一:采集记录去重和映射
医疗数据采集常见需求:同一个患者、同一次就诊、同一份报告不能重复入库。最自然的做法是定义业务 Key,再用 HashSet 或 HashMap 去重。
flowchart TD
A["读取采集记录"] --> B["构造业务 Key"]
B --> C["放入 HashSet"]
C --> D{"是否已存在"}
D -- "存在" --> E["跳过重复记录"]
D -- "不存在" --> F["写入待入库列表"]
F --> G["批量入库"]Demo:
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class CollectDeduplicateDemo {
static class VisitKey {
private final String patientId;
private final String visitNo;
private final String reportNo;
VisitKey(String patientId, String visitNo, String reportNo) {
this.patientId = patientId;
this.visitNo = visitNo;
this.reportNo = reportNo;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof VisitKey)) return false;
VisitKey that = (VisitKey) o;
return Objects.equals(patientId, that.patientId)
&& Objects.equals(visitNo, that.visitNo)
&& Objects.equals(reportNo, that.reportNo);
}
@Override
public int hashCode() {
return Objects.hash(patientId, visitNo, reportNo);
}
}
static class CollectRecord {
String patientId;
String visitNo;
String reportNo;
VisitKey key() {
return new VisitKey(patientId, visitNo, reportNo);
}
}
public static int countNewRecords(List<CollectRecord> records) {
Set<VisitKey> seen = new HashSet<>();
int newCount = 0;
for (CollectRecord record : records) {
if (seen.add(record.key())) {
newCount++;
}
}
return newCount;
}
}为什么必须同时重写 equals 和 hashCode:
flowchart TD
A["HashSet.add"] --> B["调用 hashCode 定位桶"]
B --> C["桶中已有节点"]
C --> D["调用 equals 判断是否相同业务 Key"]
D --> E{"equals 是否相等"}
E -- "相等" --> F["认为重复"]
E -- "不相等" --> G["加入集合"]如果只重写 equals 不重写 hashCode,两个业务上相同的 Key 可能落到不同桶里,HashSet 根本不会在同一个桶里比较它们,去重就会失败。
商业场景二:泛型让接口返回更安全
没有泛型时,集合里什么都能放,取出来要强转,错误可能到运行时才暴露。
错误写法:
List records = new ArrayList();
records.add("DATASET_001");
records.add(100);
String code = (String) records.get(1); // 运行期 ClassCastException泛型写法:
List<String> assetCodes = new ArrayList<>();
assetCodes.add("DATASET_001");
// assetCodes.add(100); // 编译期直接报错泛型的核心价值是把类型错误提前到编译期。它不是运行期给每个对象都保存完整类型参数,Java 泛型主要通过类型擦除实现。
flowchart TD
A["源码 List<String>"] --> B["编译器检查只能放 String"]
B --> C["擦除为原始 List"]
C --> D["必要位置插入强转"]
D --> E["运行期执行"]为什么框架还能知道一些泛型信息?因为字段、方法签名、父类泛型等元信息可能保存在 class 文件的 Signature 属性中,框架可以通过反射读取。但普通对象本身通常不知道 List<String> 里的 String。
商业场景三:用反射和注解做字段脱敏
医疗数据里患者姓名、手机号、身份证号都属于敏感信息。可以用注解标记字段,再通过反射统一脱敏。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Sensitive {
}
class PatientView {
@Sensitive
private String patientName;
@Sensitive
private String idCard;
private String deptName;
PatientView(String patientName, String idCard, String deptName) {
this.patientName = patientName;
this.idCard = idCard;
this.deptName = deptName;
}
}
public class DesensitizeDemo {
public static void desensitize(Object target) throws IllegalAccessException {
Class<?> clazz = target.getClass();
for (Field field : clazz.getDeclaredFields()) {
if (!field.isAnnotationPresent(Sensitive.class)) {
continue;
}
field.setAccessible(true);
Object value = field.get(target);
if (value instanceof String) {
field.set(target, mask((String) value));
}
}
}
private static String mask(String value) {
if (value == null || value.length() <= 1) {
return "*";
}
return value.charAt(0) + "***";
}
}反射流程:
flowchart TD
A["拿到对象"] --> B["target.getClass 获取 Class"]
B --> C["getDeclaredFields 获取字段"]
C --> D{"字段是否有 @Sensitive"}
D -- "有" --> E["setAccessible 打开访问"]
E --> F["读取字段值"]
F --> G["脱敏后写回"]
D -- "没有" --> H["跳过"]反射的风险是运行期才发现方法名、字段名、权限问题,所以框架会缓存元信息,并在启动阶段尽量提前校验。
商业场景四:大文件导入为什么不能一次性读
数据采集平台经常导入 CSV、Excel、日志文件。初学者常写:
byte[] bytes = Files.readAllBytes(path);小文件可以,大文件会把内容一次性放进堆里,容易 OOM。更安全的方式是缓冲分块读取。
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
int batchSize = 0;
while ((line = reader.readLine()) != null) {
batchSize++;
if (batchSize >= 1000) {
// 批量入库并清空批次
batchSize = 0;
}
}
}IO 排查流程:
flowchart TD
A["文件导入慢或 OOM"] --> B{"是否一次性读入内存"}
B -- "是" --> C["改成流式读取和分批处理"]
B -- "否" --> D{"是否卡在磁盘或网络 IO"}
D -- "是" --> E["看线程栈、磁盘、网络、下游接口"]
D -- "否" --> F["检查解析逻辑、批量入库和锁竞争"]BIO 是一个线程阻塞等待一个连接或文件操作,模型简单但线程容易被阻塞。NIO 用 Buffer、Channel、Selector 支持多路复用,更适合大量连接场景;但普通文件批处理不一定非要 NIO,先把流式读取、编码、缓冲和批量处理做好更重要。
商业场景五:采集任务线程池设计
采集任务通常要调用外部接口、解析数据、写数据库、发 MQ。不能无限 new Thread,否则线程数失控、数据库连接被打满、下游接口被压垮。
flowchart TD
A["提交采集任务"] --> B{"核心线程是否未满"}
B -- "是" --> C["创建核心线程执行"]
B -- "否" --> D{"队列是否未满"}
D -- "是" --> E["任务进入有界队列"]
D -- "否" --> F{"最大线程是否未满"}
F -- "是" --> G["创建非核心线程"]
F -- "否" --> H["执行拒绝策略"]Demo:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("collect-worker-" + thread.getId());
return thread;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);参数不是套公式,要结合下游容量:
| 参数 | 怎么想 |
|---|---|
| 核心线程数 | 平时稳定处理能力 |
| 最大线程数 | 突发时允许的上限 |
| 队列容量 | 可接受的等待量,必须有界 |
| 拒绝策略 | 系统过载时如何保护自己 |
| 线程名 | 方便 jstack 和日志排查 |
如果数据库连接池只有 20,线程池开 200 并不会让系统更快,只会让大量线程等待数据库连接,甚至拖垮数据库。
商业场景六:JDK7 和 JDK8 怎么影响写法
企业项目里 JDK7、JDK8 仍然常见。学习 JavaSE 不能只看 JDK21。
| 能力 | JDK7 写法 | JDK8 变化 |
|---|---|---|
| 资源关闭 | try-finally 或 try-with-resources | 延续 try-with-resources |
| 集合遍历 | for 循环、增强 for | Lambda、Stream |
| 接口 | 只能抽象方法和常量 | default/static 方法 |
| 日期时间 | Date、Calendar | java.time |
| 异步编排 | Future | CompletableFuture |
| HashMap | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| ConcurrentHashMap | Segment 分段锁 | CAS + synchronized 桶锁 |
JDK8 Stream 示例:
Map<String, Long> countByDept = records.stream()
.collect(Collectors.groupingBy(
CollectRecord::getDeptCode,
Collectors.counting()
));Stream 不一定比 for 快,它的价值是表达数据处理流水线。复杂业务、需要提前退出、需要清晰异常处理时,普通 for 循环可能更易读。
生产排查闭环
flowchart TD
A["Java 服务异常"] --> B{"表现是什么"}
B -- "OOM" --> C["看 GC 日志、dump、对象直方图"]
B -- "CPU 高" --> D["top 定位线程,jstack 看热点"]
B -- "接口慢" --> E["拆线程池、DB、HTTP、锁和 GC"]
B -- "数据错" --> F["查集合去重、并发写、equals/hashCode"]
B -- "任务堆积" --> G["看线程池 active、queue、拒绝、下游耗时"]
C --> H["定位根因并补监控"]
D --> H
E --> H
F --> H
G --> H常见根因:
| 现象 | JavaSE 根因 |
|---|---|
| 去重失败 | equals/hashCode 写错,Key 可变 |
| 堆 OOM | 大集合无限增长、大文件一次性读入、缓存不清理 |
| 线程数暴涨 | 无限创建线程、线程池最大线程过大 |
| CPU 高 | 死循环、频繁 GC、锁竞争、自旋 |
| 请求串用户 | ThreadLocal 在线程池中未清理 |
| 反射报错 | 字段名变更、访问权限、模块限制 |
面试标准回答
JavaSE 为什么是后端地基?
JavaSE 提供了类型系统、面向对象、集合、泛型、异常、反射、IO/NIO、并发和 JVM 能力。Spring、MyBatis、Netty、MQ 客户端、数据库驱动都建立在这些能力上。学 JavaSE 不能只会语法,还要知道集合为什么这么设计、线程池为什么不能乱配、反射为什么支撑框架、OOM 和线程堆积怎么排查。
JDK7 和 JDK8 最重要的差异是什么?
JDK8 引入 Lambda、Stream、Optional、接口默认方法、java.time、CompletableFuture 等现代写法;集合和并发实现也有重要变化,比如 HashMap 冲突严重时会树化,ConcurrentHashMap 从 JDK7 的 Segment 分段锁演进为 JDK8 的 CAS + 桶级 synchronized。企业项目中 JDK7/8 都要懂,因为很多老系统仍运行在 JDK8,面试也常问它们的区别。
JavaSE 线上问题怎么排查?
先按表现分类:OOM 看 GC 日志、heap dump、对象直方图和引用链;CPU 高看线程 CPU 和 jstack;接口慢拆线程池、数据库、HTTP、锁和 GC;数据错看集合、并发写、equals/hashCode;任务堆积看线程池 active、queue、拒绝次数和下游耗时。不要只看业务异常,要结合 JVM、线程和依赖指标。
关联知识点
| 知识点 | 继续学习 |
|---|---|
| JavaSE 主线 | JavaSE 从零到生产级掌握 |
| Java 基础 | Java 基础 |
| 集合框架 | 集合框架 |
| HashMap | HashMap 全过程原理 |
| 泛型 | 泛型全过程原理 |
| 反射 | 反射全过程原理 |
| IO/NIO | IO 与 NIO |
| 线程池 | 线程池 |
| JVM 排查 | JVM 排查工具 |
| 面试 | JavaSE 面试知识点 |
