Java项目启动报NullPointer,5个最佳实践救你命
刚接手的Java老项目,复制了同事的启动代码,结果控制台一片红,满屏NullPointerException。你盯着报错信息看了十分钟,连错在哪一行都不知道,更别提怎么改了。这种“代码能跑在我机器上,换个环境就崩”的噩梦,每个Java开发者都经历过。
别慌。今天不讲深奥理论,只聊那些让你抓狂的报错背后,到底藏着什么坑。我会把最常见的几个坑拆开揉碎,告诉你为什么错、怎么改、以后怎么防。这些经验,是从无数个通宵调试里攒出来的,希望能帮你少走点弯路。
坑一:依赖冲突导致类加载失败
现象:项目能编译通过,但一启动就报ClassNotFoundException或NoClassDefFoundError。明明pom.xml里写了依赖,为什么找不到类?
根本原因:Maven依赖传递机制。A项目依赖B,B依赖C,但A又直接依赖了C的另一个版本。Maven默认选择“最近优先”原则,可能导致加载了不兼容的版本。比如Spring Boot 2.x和3.x的包路径都变了,混用直接崩。
错误写法:
<dependency><groupId>org.springframework</groupId><artifactId>spring-core</artifactId><version>5.3.20</version>
</dependency>
<dependency><groupId>org.springframework</groupId><artifactId>spring-web</artifactId><version>6.0.1</version>
</dependency>
这段代码看似没问题,但Spring 6.0.1要求Java 17+,而Spring 5.3.20基于Java 8设计。混用会导致方法签名不匹配,运行时直接抛异常。
正确写法:
<dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>3.1.0</version><type>pom</type><scope>import</scope></dependency></dependencies>
</dependencyManagement>
用Spring Boot的BOM统一管理版本。所有Spring组件版本由BOM锁定,避免手动指定版本造成冲突。这是Spring官方源码仓库里明确推荐的依赖管理方式,比手动对齐版本可靠得多。
复现与修复:
- 运行
mvn dependency:tree查看依赖树 - 找到冲突的库,用
-Dverbose参数查看排除信息 - 在冲突依赖上添加
<exclusions>标签排除旧版本 - 重新打包测试
规避建议:新项目统一用Spring Boot Starter,老项目迁移时先跑dependency:tree检查。别相信IDE的自动解析,Maven的依赖仲裁规则比想象中复杂。
坑二:时区处理导致数据错位
现象:用户在北京下单,数据库里存的时间比实际晚8小时。或者反过来,比实际早8小时。客服打电话来投诉,你查日志发现时间对不上。
根本原因:JVM默认时区是服务器所在时区,而数据库连接可能用了不同时区。java.util.Date不存储时区信息,只存时间戳,转换时依赖JVM默认时区,跨环境部署必然出错。
错误写法:
Date now = new Date();
String sql = "INSERT INTO orders (created_at) VALUES (" + now + ")";
直接拼接Date对象,JVM会调用toString()方法,格式不固定,且依赖默认时区。在UTC服务器上跑,存进去的就是UTC时间,北京时间用户看到的就是错乱数据。
正确写法:
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
String sql = "INSERT INTO orders (created_at) VALUES (?)";
preparedStatement.setObject(1, now.toLocalDateTime());
明确指定时区,用LocalDateTime存入数据库(假设数据库列是DATETIME类型),彻底摆脱JVM默认时区的干扰。这是Java 8时间API的设计初衷,也是Spring官方文档里反复强调的最佳实践。
复现与修复:
- 检查数据库列类型,确认是
TIMESTAMP还是DATETIME TIMESTAMP会按时区转换,DATETIME不会,选错直接踩坑- 在连接串里显式指定时区:
jdbc:mysql://host:3306/db?serverTimezone=Asia/Shanghai - 统一所有环境用同一时区配置,别靠JVM默认值
规避建议:新项目一律用LocalDateTime+ZonedDateTime,别再用Date。如果必须用Date,永远手动指定时区。数据库设计时,DATETIME存业务时间,TIMESTAMP存系统时间,分开管理。
坑三:线程池参数配置不当导致OOM
现象:系统运行几天后内存飙升,最终OOM。看堆栈全是Thread对象,但业务逻辑里没看到哪里创建了线程。
根本原因:每次请求都new Thread(),或者用了Executors.newFixedThreadPool()这类便捷方法。这些方法创建的线程池队列是无界的(LinkedBlockingQueue默认容量Integer.MAX_VALUE),任务堆积直接撑爆内存。
错误写法:
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> {// 业务逻辑
});
看起来简洁,但队列无界。如果任务处理速度跟不上提交速度,队列会无限增长,每个任务对象都占内存,最终OOM。
正确写法:
ThreadPoolExecutor pool = new ThreadPoolExecutor(10, 20,60L, TimeUnit.SECONDS,new LinkedBlockingQueue<>(100),new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),new ThreadPoolExecutor.CallerRunsPolicy()
);
明确指定核心线程数、最大线程数、队列容量、拒绝策略。队列有界,超过容量时触发拒绝策略,而不是无限堆积。这是阿里巴巴Java开发手册里强制要求的写法,也是生产环境避免OOM的基本功。
复现与修复:
- 用JVisualVM或Arthas监控线程数变化
- 发现线程数持续增长,定位到创建线程的代码
- 替换为有界队列的
ThreadPoolExecutor - 设置合理的拒绝策略,
CallerRunsPolicy让调用线程自己执行,起到背压作用
规避建议:永远别用Executors的便捷方法创建线程池。每个业务场景单独配置线程池,别共用。线程池参数不是拍脑袋定的,要压测后调整。核心线程数参考CPU核数,队列容量参考业务峰值。
坑四:SQL注入与慢查询隐患
现象:系统突然变慢,数据库CPU 100%,连接池耗尽。查慢查询日志,发现一堆全表扫描的SQL。
根本原因:字符串拼接SQL,用户输入直接进SQL语句。要么被注入,要么走了全表扫描。LIKE '%keyword%'这种写法,索引直接失效。
错误写法:
String sql = "SELECT * FROM users WHERE name LIKE '%" + keyword + "%'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);
字符串拼接,keyword如果是'; DROP TABLE users; --,直接删库。就算没注入,LIKE '%keyword%'前缀模糊查询,B+树索引无法使用,全表扫描。
正确写法:
String sql = "SELECT * FROM users WHERE name LIKE ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, "%" + keyword + "%");
ResultSet rs = ps.executeQuery();
参数化查询防注入。但LIKE '%keyword%'还是慢,要加全文索引或用搜索引擎。如果必须前缀模糊,改成LIKE 'keyword%',索引就能用上。
复现与修复:
- 开启MySQL慢查询日志,
long_query_time=1 - 用
EXPLAIN分析SQL执行计划,看type列是不是ALL - 优化SQL,能走索引就走索引
- 高频模糊查询场景,考虑Elasticsearch
规避建议:所有SQL用PreparedStatement,别用字符串拼接。LIKE查询谨慎使用,优先精确匹配或前缀匹配。复杂查询场景,评估是否需要搜索引擎。数据库索引不是越多越好,要结合实际查询模式设计。
坑五:异常吞掉导致问题难追踪
现象:系统报错日志里全是Exception in thread "main" java.lang.Exception,没堆栈,没上下文,不知道哪里出的问题。
根本原因:catch(Exception e) { e.printStackTrace(); }或者catch(Exception e) { }。前者打印到控制台,生产环境控制台没人看;后者直接吞掉,连日志都没有。异常没带上下文,排查时只能靠猜。
错误写法:
try {processOrder(orderId);
} catch (Exception e) {e.printStackTrace();
}
printStackTrace输出到System.err,生产环境通常重定向到/dev/null,等于没打。就算能看到,也没说哪个订单、哪个环节出错,排查时抓瞎。
正确写法:
try {processOrder(orderId);
} catch (Exception e) {log.error("处理订单失败, orderId={}, 原因={}", orderId, e.getMessage(), e);throw new BusinessException("订单处理异常", e);
}
用SLF4J日志框架,记录关键业务参数(orderId),带完整堆栈(第三个参数传e)。业务层捕获后包装成业务异常,向上传递,由统一异常处理器记录并返回友好提示。这是Spring Boot异常处理的标准做法,也是生产环境可观测性的基础。
复现与修复:
- 全局搜索
e.printStackTrace()和空catch块 - 替换为SLF4J日志,带上业务关键参数
- 配置logback,生产环境INFO级别,异常ERROR级别
- 接入ELK或云日志服务,方便检索和分析
规避建议:禁止空catch块,代码审查时重点检查。日志要带上下文,没上下文的日志等于没打。异常处理要分层,底层记详细,上层给友好提示。别怕日志量大,排查问题时,少一行日志可能多查两小时。
这几个坑,覆盖了依赖管理、时间处理、线程池、SQL、异常处理五个高频场景。每个坑背后,都是无数个生产事故的教训。Java生态成熟,但坑也不少,关键在于理解底层机制,别靠记忆和运气写代码。
你更常用哪种写法?评论区交流。