
1. 老生常谈背后的两个核心问题在Java开发这行混久了面试题翻来覆去就那么几个其中Statement和PreparedStatement的区别绝对是出场率最高的题目之一。入职笔试考、电话面试问、领导闲聊也可能突然冒一句“你觉得PreparedStatement好在哪”搞得跟暗号接头似的。但说实话这问题真要往深了聊能考出一个人是背了八股文还是真的在项目里扎扎实实写过SQL。先明确一下这题的基本盘这两个东西都是JDBC里用来向数据库发送SQL语句的接口。Statement负责把一个完整的SQL字符串打包发给数据库执行PreparedStatement则是先给数据库一个带有占位符的SQL骨架让数据库提前“理解”这条SQL的结构之后再传入具体的参数值。本质上看PreparedStatement走的是“预编译”这条路Statement则是“裸发”模式。但这只是教科书上的标准答案实际项目中该怎么选、二者在性能和安全上到底有多大差距、有没有坑才是真正值得琢磨的事。这篇文章适合谁看刚入行不久、准备面试的后端开发写了不少CRUD但从未深究过JDBC底层行为的中级工程师以及负责老系统维护、时不时要处理SQL性能问题的朋友。我会把原理、实践、排查经验都过一遍争取让你看完之后不仅能应付面试更能在真实项目里做出正确的技术选型。2. 从执行机制到性能差异为什么说PreparedStatement是“预编译选手”2.1 Statement的执行链路每次都是“从头再来”要理解PreparedStatement的价值先得看清楚Statement到底是咋工作的。假设我现在写一个最普通的查询Statement stmt connection.createStatement(); ResultSet rs stmt.executeQuery(SELECT user_id, user_name FROM t_user WHERE user_id 1001);这一行代码发到数据库数据库端实际上经历了词法分析、语法解析、语义检查、生成执行计划、执行、返回结果。每一次executeQuery调用无论SQL内容是否相同数据库都得把这套流程从头走一遍。这意味着什么如果你在循环里执行一万次查询只是where条件里的user_id在变数据库就会把同样的SQL解析一万次、生成一万次执行计划。听起来很浪费但Statement也没办法因为它每次收到的都是“一整条完整字符串”数据库根本不知道你下一条发过来的SQL跟这次是不是结构一致。就好比你每次去食堂打饭都得重新告诉师傅你要什么菜师傅每次都得重新开火炒一遍哪怕你连续三天点的都是西红柿炒蛋。这种情况下Statement唯一的优势就是写起来极其简单不需要任何预编译的概念拿起来就用。早期的一些简单工具类、一次性脚本用Statement确实图个省事。但一旦到了高并发、重复执行的业务场景这个“从头再来”的劣势会被无限放大数据库CPU烧在了解析和计划生成上真正查数据的时间反而占比变小。2.2 PreparedStatement的预编译链路骨架先行参数后填再来看PreparedStatement的操作方式PreparedStatement pstmt connection.prepareStatement( SELECT user_id, user_name FROM t_user WHERE user_id ? ); pstmt.setInt(1, 1001); ResultSet rs pstmt.executeQuery();这里关键区别在于SQL字符串在prepareStatement这一行就已经发给数据库了。数据库拿到的是一个带?占位符的SQL骨架它可以立即执行语法检查和语义检查生成执行计划并把“查某张表、按某个id过滤”这个操作结构缓存起来。之后你再setInt、executeQuery数据库只需要把参数套进去按照已生成好的执行计划跑一遍就行。打个比方PreparedStatement相当于你先跟食堂师傅说好“我以后每天都要西红柿炒蛋做法按你们最拿手的来”师傅先把配菜切好、油温调好之后每天你来直接报个“多加饭”饭就能很快端上来。如果哪天你想改成鱼香肉丝那没办法得重新走一遍“预定”流程。这也对应了PreparedStatement的一个特性它的预编译效果只有在SQL结构相同、仅参数变化时才能充分发挥。我在实际项目里见过一种情况有人把表名也拼进PreparedStatement的SQL里比如SELECT * FROM tableName WHERE id ?然后告诉我说他用了PreparedStatement所以性能很好。这种用法要分两面看SQL结构如果只变表名但语句形态一样数据库端可能还能命中一些缓存但PreparedStatement在JDBC层的预编译机制就已经被绕掉一大半了因为从JDBC驱动视角看你prepare的SQL字符串变了就相当于一条全新的语句。至于安全性更是跟预编译不搭边表名拼接一样可能被注入只是注入难度比整段拼接高一些而已。这一点后面讲注入的时候再细说。2.3 服务端预编译与客户端预编译不同数据库的行为差异这里有一个很容易被忽略的细节JDBC里的prepareStatement并不保证一定走服务端预编译具体行为取决于驱动类型和连接参数的配置。以MySQL为例驱动在这块的表现就很典型。MySQL的JDBC驱动有一个非常重要的连接参数useServerPrepStmts。默认情况下这个参数是false也就是说你调用connection.prepareStatement()驱动确实会在客户端解析一下SQL、处理好占位符但并不会把SQL骨架真的发送到MySQL服务端去做预编译。这种情况下PreparedStatement相比Statement主要优势就只剩下“参数化”带来的SQL注入防护性能层面跟Statement拉不开太大差距。只有当你在JDBC连接串里显式加上useServerPrepStmtstrue驱动才会把带占位符的SQL发给MySQL服务端让服务端执行真正的预编译和计划缓存。同时这个参数一般配合cachePrepStmtstrue一起使用后者会在客户端缓存已预编译的PreparedStatement对象避免重复prepare带来的网络开销。jdbc:mysql://localhost:3306/mydb?useServerPrepStmtstruecachePrepStmtstrueprepStmtCacheSize250prepStmtCacheSqlLimit2048这里的prepStmtCacheSize是客户端缓存预编译语句的条数prepStmtCacheSqlLimit是指定单条SQL的最大长度超过长度的SQL不做缓存。我建议生产环境把这几个参数都配上尤其是那种ORM框架大量使用PreparedStatement的场景收益非常明显。Oracle这边不太一样Oracle JDBC驱动的prepareStatement默认就会走服务端预编译因为它本身就有游标Cursor机制通过setPoolable(true)还可以进一步提示连接池保留已解析的游标。但代价是Oracle服务端预编译会占用数据库的游标资源如果应用里创建的PreparedStatement过多又没及时关闭很容易把数据库的游标耗尽报出ORA-01000: maximum open cursors exceeded。这个错我当年排查过一晚上最后发现就是代码里有个循环创建PreparedStatement但只在最后关闭了一次游标全泄漏了。我的建议是不管用哪种数据库连接串参数和驱动文档一定要认真看尤其是在Java这种生态里很多时候不是技术不行而是配置没到位。3. SQL注入防线与代码可读性PreparedStatement的核心竞争力3.1 字符串拼接SQL为什么危险如果说性能是PreparedStatement的“加分项”那SQL注入防护就是它的“保底项”。很多教科书会把SQL注入说得神乎其神但落到代码层面问题往往就出在一行简单的“”号拼接上。String sql SELECT * FROM t_user WHERE user_name userName AND password password ; Statement stmt connection.createStatement(); ResultSet rs stmt.executeQuery(sql);这段代码表面上逻辑没什么问题但假如用户在用户名的输入框里填了下面这串字符admin --最终拼出来的SQL就变成了SELECT * FROM t_user WHERE user_name admin -- AND password 在MySQL里--后面跟一个空格表示注释这意味着后面的密码校验条件直接被注释掉了。攻击者只需要知道一个用户名不需要密码就能直接登录甚至可以用 OR 11 --这种操作把整张表的数据都拉出来。如果业务系统里有删除、更新操作把同样的套路用在拼SQL上后果就更不堪设想了。那PreparedStatement是怎么解决的占位符?在PreparedStatement里是参数占位不是SQL文本的一部分。驱动在发送参数值时会做类型转换和转义处理把用户输入的、--、;这些特殊字符当成普通文本处理而不是SQL语法的一部分。说白了用户的输入没有机会参与到SQL的“结构”里只能作为“值”存在注入自然就不成立了。3.2 参数化查询的正确姿势既然说到了安全就顺便把参数化查询的正确姿势一起捋清楚。除了setString、setInt这些常规方法JDBC还提供了setObject、setDate、setTimestamp等一堆重载方法。有人图省事不管什么类型都用setString往里塞这在很多数据库上能跑通但有几个问题一是类型不匹配可能导致隐式转换比如在Oracle里数字类型的字段你用字符串参数去匹配数据库可能会走TO_NUMBER()转换导致索引失效查询性能直线下降。二是setString传null时驱动需要知道目标字段的类型才能正确构造绑定变量如果传入的null是Types.NULL部分数据库会报“无效的列类型”错。所以我的建议是参数类型尽量跟数据库字段类型一一对应PreparedStatement pstmt connection.prepareStatement( UPDATE t_order SET pay_time ? WHERE order_id ? AND pay_status ? ); pstmt.setTimestamp(1, new Timestamp(System.currentTimeMillis())); pstmt.setLong(2, orderId); pstmt.setInt(3, PayStatusEnum.PAID.getCode()); pstmt.executeUpdate();另外一个跟占位符相关的坑是?只能用在“值”的位置不能用在表名、列名、排序关键字比如ORDER BY ?等结构位置。如果有人告诉你说“我用了PreparedStatement传表名所以很安全”这是严重的误解。表名本身属于SQL结构驱动没有办法在不破坏SQL结构的情况下把它参数化。处理这种动态表名、动态排序的场景正确的做法是使用白名单校验把允许出现的表名、列名、排序方向枚举出来代码里只传枚举值的索引或名称从根上杜绝注入。3.3 代码可读性和维护性一个容易被忽视的优势聊完了安全和性能还有一个经常被低估的点PreparedStatement的代码可读性和维护性比Statement强太多了。不信你对比一下这两种写法Statement的写法String sql SELECT * FROM t_product WHERE category_id categoryId AND status status ORDER BY create_time DESC;一旦SQL变长、参数变多这行字符串拼接就会变成一团乱麻一个引号漏了、一个加号少了编译器只当作字符串来处理连最基本的SQL语法检查都过不了必须得等到运行时报错才能发现。调试起来更是噩梦得先把拼接后的完整SQL打出来再拿着放大镜找哪里少了单引号。PreparedStatement的写法String sql SELECT * FROM t_product WHERE category_id ? AND status ? ORDER BY create_time DESC; PreparedStatement pstmt connection.prepareStatement(sql); pstmt.setString(1, categoryId); pstmt.setInt(2, status);SQL骨架清晰可见参数位置一目了然维护的人不需要去解析字符串拼接逻辑改起来也放心。特别是在团队协作里代码的可维护性往往比那一点微小的性能差异重要得多。我见过不少老项目里SQL拼接逻辑散落在各个Service方法里后来需求变更要加一个筛选条件得小心翼翼地找到拼接位置、核对引号改完还要担心是不是影响了其他分支的逻辑。这种项目维护起来真的像走钢丝换谁接手都想重写。4. 性能对比实测与参数选择数据不会骗人4.1 做个不严谨但很直观的实验对比理论讲了这么多不如直接跑个实验看看。我这边用一个本地的MySQL 8.0实例测试表t_user里有10万条数据分别用Statement和PreparedStatement执行一万次“按主键ID查询”的操作对比两者在开启和关闭服务端预编译情况下的耗时。先看Statement的循环查询long start System.currentTimeMillis(); try (Connection conn dataSource.getConnection(); Statement stmt conn.createStatement()) { for (int i 1; i 10000; i) { try (ResultSet rs stmt.executeQuery(SELECT user_name FROM t_user WHERE id i)) { while (rs.next()) { // 消费结果集 } } } } System.out.println(Statement 耗时: (System.currentTimeMillis() - start) ms);再看PreparedStatement的循环查询long start System.currentTimeMillis(); try (Connection conn dataSource.getConnection(); PreparedStatement pstmt conn.prepareStatement(SELECT user_name FROM t_user WHERE id ?)) { for (int i 1; i 10000; i) { pstmt.setInt(1, i); try (ResultSet rs pstmt.executeQuery()) { while (rs.next()) { // 消费结果集 } } } } System.out.println(PreparedStatement 耗时: (System.currentTimeMillis() - start) ms);注意上面这段PreparedStatement的代码里prepareStatement是在循环外执行的也就是说SQL骨架只提交了一次后面一万次循环只是在重复绑定参数和取结果。这是PreparedStatement性能优势能够发挥出来的前提。在我这台机器上跑出来的结果大致是Statement一万次耗时约5.8秒PreparedStatement开启服务端预编译一万次耗时约2.9秒差距接近一倍。如果把连接串上的useServerPrepStmtsfalse即不开启服务端预编译PreparedStatement的耗时大概在5秒多略好于Statement但优势不那么明显。这也验证了前面说的想用PreparedStatement拿性能红利数据库侧预编译一定得打开光靠JDBC客户端那层是不够的。4.2 预编译的开销量化为什么说执行次数越多越划算有人说PreparedStatement第一次执行很慢确实如此。因为prepareStatement这一下数据库要做解析、做执行计划这个开销比直接执行一条没有预编译的SQL要高一些。但关键在后面的重复执行。数据库端对一条SQL做完整解析的成本大致包括网络传输SQL文本、词法分析、语法分析、语义分析、逻辑优化、物理优化、生成执行计划。一条常规的查询SQL这些步骤加起来大约需要几百微秒到几毫秒不等具体取决于SQL的复杂度和数据库负载。而PreparedStatement在预编译完成之后每次执行只用传输参数值数据库直接套用已有的执行计划单次执行可能就省下了几毫秒甚至更多的解析开销。我们做一个粗略的量化假设一条SQL的解析成本是1.5毫秒执行本身是0.5毫秒。用Statement执行1000次总耗时大约是2000毫秒用PreparedStatement第一次预编译加执行大概2.5毫秒之后999次每次执行只需要0.5毫秒左右总计约502毫秒。执行次数越多省下的时间越明显。这就是为什么在批量插入、批量更新的场景里PreparedStatement配合addBatch()和executeBatch()效果会更加炸裂。try (PreparedStatement pstmt conn.prepareStatement( INSERT INTO t_order_log (order_id, log_type, content) VALUES (?, ?, ?))) { for (OrderLog log : logList) { pstmt.setLong(1, log.getOrderId()); pstmt.setInt(2, log.getLogType()); pstmt.setString(3, log.getContent()); pstmt.addBatch(); } pstmt.executeBatch(); }注意批量操作时有两点值得留意。第一MySQL的JDBC驱动默认是rewriteBatchedStatementsfalse这会导致addBatch的语句被逐条发送执行批量效果大打折扣。在连接串上加rewriteBatchedStatementstrue驱动会把多条INSERT重写成一条多VALUES的语句一次发出性能提升极其显著我实测过批量插入几千条数据开启后耗时能减少一个数量级。第二批量处理的SQL结构必须完全一致只是参数不同这正是PreparedStatement最擅长的场景。要是每条SQL结构都不一样那不叫批量那只是循环执行而已。4.3 连接池形态下的参数选择实际项目里我们一般不会直接DriverManager.getConnection()去连数据库而是用HikariCP、Druid这类连接池。连接参数是在连接池配置里统一管理的所以前面说的useServerPrepStmts、cachePrepStmts、rewriteBatchedStatements这些参数得加到连接池的JDBC URL上。以HikariCP为例HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/mydb?useServerPrepStmtstruecachePrepStmtstrueprepStmtCacheSize250prepStmtCacheSqlLimit2048rewriteBatchedStatementstrue); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(20); config.setMinimumIdle(5);这里有个容易踩的坑cachePrepStmtstrue缓存的是PreparedStatement对象但这个缓存跟连接是绑定的。如果业务SQL数量特别多比如ORM框架自动生成各种查询组合缓存大小设置太小会导致频繁的预编译和缓存淘汰反而增加开销设置太大又会占用JVM堆内存。我的经验是常规的Web系统prepStmtCacheSize设成100到500之间够用如果系统里动态生成的SQL组合特别多优先考虑从代码层面收敛SQL数量而不是无限调大缓存。连接池本身也有一个跟预编译相关的参数Druid里叫PreparedStatementCache默认是开启的配置项是poolPreparedStatementstrue。但需要注意Druid的PSCache和MySQL驱动的客户端缓存是两层叠加的如果两层都开大内存损耗会比较可观。我用Druid的时候一般会把poolPreparedStatements设为true同时maxPoolPreparedStatementPerConnectionSize设为20到50避免过度膨胀。5. 实操演示与完整对比从创建到执行的关键细节5.1 PreparedStatement完整生命周期代码拆解说了一堆理论和配置还是得回到代码本身把PreparedStatement从创建到执行到关闭的完整生命周期走一遍。public User getUserById(Connection conn, Long userId) throws SQLException { String sql SELECT id, user_name, email, create_time FROM t_user WHERE id ?; try (PreparedStatement pstmt conn.prepareStatement(sql)) { pstmt.setLong(1, userId); try (ResultSet rs pstmt.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setUserName(rs.getString(user_name)); user.setEmail(rs.getString(email)); user.setCreateTime(rs.getTimestamp(create_time)); return user; } } } return null; }这里用的try-with-resources是Java 7之后的推荐写法pstmt和rs都会在代码块结束后自动关闭不用手动写finally块去关资源可以少写很多样板代码也能避免忘记关闭导致的连接泄漏。有几个执行方法需要区分清楚executeQuery()专门执行SELECT语句返回ResultSet结果集。executeUpdate()执行INSERT、UPDATE、DELETE这类DML语句返回受影响的行数。execute()执行任意SQL返回值是布尔值表示是否返回了ResultSet一般用于DDL语句或者不确定SQL类型的情况。很多新手容易把executeQuery()用来执行UPDATE然后莫名其妙报错也有人拿到execute()的返回值不知道怎么处理。这些东西在实际工作里很常见多写几次自然就熟了。5.2 Statement适用的少数场景既然题目是“区别”也得把Statement的适用场景说清楚不能一棍子打死说它没用。虽然PreparedStatement在绝大多数场景下都是更好的选择但确实存在一些情况用Statement反而更合适。DDL语句就是一个典型。像CREATE TABLE、ALTER TABLE、DROP INDEX这些语句不存在参数绑定的概念也没有重复执行的性能需求使用Statement直接执行更直观高效。而且JDBC规范里很多驱动对PreparedStatement执行DDL的支持并不完善反而可能在预编译环节出些幺蛾子。另外一个场景是数据库对象的名称动态变化时比如根据不同的租户拼接不同的表名前提是表名经过白名单校验或者执行一些简单的、一次性的事务脚本用Statement反而少一层不必要的“预编译”。但要注意这类场景里的SQL如果涉及用户输入还是得做严格的校验和转义不然该出的问题一个都跑不了。5.3 用表格把区别一次说清楚为了方便记忆和复习我把两者的核心区别整理成一个表面试前可以拿着这个表快速过一遍对比维度StatementPreparedStatementSQL执行方式每次执行完整SQL文本SQL骨架预编译参数后绑定解析与执行计划每次执行都重新解析预编译后复用以生成的执行计划SQL注入风险高字符串拼接容易被注入低参数值自动转义性能重复执行较差较好配合服务端预编译效果更佳性能一次性执行较好省去预编译开销较差多一次预编译步骤代码可读性差长SQL拼接难维护好SQL骨架和参数分离批量执行支持但效率低支持且效率高配合addBatch适用场景DDL、一次性脚本、白名单校验后的动态SQL业务系统的查询、更新、批量操作5.4 面向对象的表达ORM框架里PreparedStatement的影子现在大部分项目都会用MyBatis、Hibernate这类ORM框架很多同学写代码时可能都没直接见过PreparedStatement但这并不代表它不存在。以MyBatis为例框架底层把XML里写的SQL映射成BoundSql然后交给JDBC执行时默认就是使用PreparedStatement来完成的。你在XML里写的#{}占位符最终会被替换成?由PreparedStatement绑定参数如果写的${}那MyBatis直接把值拼进SQL字符串走的就是Statement的路线这也是为什么所有MyBatis规范都反复强调“能用#{}就不要用${}”。理解了这一点你在ORM框架里的很多“魔法”行为就变得透明了。比如为什么MyBatis的Mapper方法传个List进去用foreach批量插入时SQL日志里会看到一串?为什么动态SQL某些写法会导致预编译失效、查询性能下降。归根到底都是PreparedStatement的行为在背后兜着。至于HikariCP、Druid这些连接池也都是建立在JDBC的PreparedStatement之上的连接池复用连接PSCache缓存PreparedStatement一层套一层把性能和安全都拉满。6. 常见报错与排查技巧从错误信息反推问题根因6.1 占位符与参数数量不匹配最经典的低级错误就是SQL里的?数量和setXxx调用的数量对不上。比如写了一条SQL有三个问号结果代码里只set了两个参数运行时会直接报错Parameter index out of range (2 number of parameters, which is 1)这种错一般不会出现在编译阶段而是运行到那一行才爆出来。排查方法很简单把SQL和参数的对应关系一一核对清楚。我建议写SQL的时候尽量保持格式清晰一个条件占一行setXxx的顺序和占位符顺序严格一致。另外要注意SQL注释里的问号也可能被部分数据库驱动当成占位符处理导致参数数量莫名其妙多出来这种坑尤其隐蔽。我早年排查过一个问题SQL带了一大段解释性注释注释里写了“是否忽略”结果驱动把那个中文问号也解析成了绑定参数整整排查了一个下午。从那以后我在SQL里写注释都带着英文标点或者干脆不写问号。6.2 SQL语法错误定位技巧前面提到Statement的特点就是SQL必须等到运行时才被数据库解析所以一段拼接出来的SQL如果有语法问题往往是运行到那个分支才报错。PreparedStatement同样会碰到SQL语法错误但报错位置和排查思路有所不同。来看一个常见的报错You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ? at line 1这种报错看着像是占位符引起的但实际上往往是因为SQL里用了不允许占位符的位置比如把?放在了表名的位置PreparedStatement pstmt conn.prepareStatement(SELECT * FROM ? WHERE id 1);表名不属于值绑定位置数据库根本没有办法把?解释成表名于是直接报语法错误。类似的还有ORDER BY ?、LIMIT ?, ?中的LIMIT在某些数据库下需要整数而不是绑定参数。遇到这类问题先确认占位符的位置是否合法再检查SQL本身有没有拼写错误。为了快速定位可以把PreparedStatement里最终要执行的SQL在调试模式下打印出来但注意一定要打印“参数替换后”的完整SQL不要只打印骨架SQL否则你会拿着一条带问号的SQL去数据库里执行永远复现不了错误。这里顺带说一个很多程序员容易忽略的调试技巧JDBC驱动通常支持通过Statement的实现类拿到真实执行信息比如MySQL驱动里可以开启profileSQLtrue参数在日志里输出实际发送到数据库的SQL和参数对排查这类语法问题非常有帮助。6.3 与热词相关的报错场景解读在整理这个话题的时候我看到有几位同学在搜索关联到了一些报错比如error while compiling statement: failed: semantic exception error10025、no viable statement for input、syntaxerror: multiple statements found while compiling a single statement。这些报错本身不完全属于JDBC层但它们背后有一个共同的排查逻辑数据库在“编译”或“解析”SQL时发现SQL文本不符合语法规范或者包含了不应该出现的内容。如果你在面向Hive、Spark SQL这类大数据组件写SQLsemantic exception这类报错通常意味着SQL逻辑本身有语义级别的问题比如字段不存在、表名错了、类型不匹配。no viable statement for input则常见于SQL文本开头有多余字符、未闭合括号、或者混入了数据库不认识的符号。至于multiple statements found while compiling a single statement说明一条“SQL语句”里包含了多个分号分隔的语句这种在多语句执行受限的接口上经常发生。这时候的排查思路其实是一致的先看看生成的SQL长什么样再做针对性的修正。这种场景下PreparedStatement反而帮不上太多忙因为它解决的是“参数绑定”层面的问题不是“SQL语义”层面的问题。该排查的还是得从SQL文本本身入手。我的经验是任何一个跟“编译失败”相关的报错第一动作都是把最终执行的SQL完整打印出来人眼过一遍比什么高深技巧都管用。6.4 PreparedStatement使用中的资源泄漏问题还有一个实战中特别常见的坑PreparedStatement没有正确关闭导致连接池连接耗尽。很多同学知道Connection要关却忽略了Statement和ResultSet也要关。如果在循环里反复创建PreparedStatement而不关闭连接池的连接会被占满应用表现为“假死”跑着跑着突然所有请求都卡在获取连接上。使用try-with-resources是最省心的解法Connection、PreparedStatement、ResultSet全部声明在try的括号里退出时按逆序自动关闭。如果项目里还停留在手动finally关闭的老写法建议尽早统一改成try-with-resources代码干净也不容易漏。对于排查连接泄漏Druid连接池可以通过起一个监控页面查看活跃连接数、SQL执行统计HikariCP可以通过配置leakDetectionThreshold参数在连接泄漏超过设定阈值时打警告日志。我习惯把leakDetectionThreshold设置为60000毫秒也就是连接被占用超过一分钟却没归还就触发泄漏检测日志这样就能在连接池被打爆之前及早发现代码里的资源关闭问题。7. 实际项目中的最终建议把该说的原理和坑都过了一遍最后落地到项目里我会这样总结自己的选择习惯。业务系统中99%的SQL操作我都优先选择PreparedStatement不管是简单查询、复杂更新还是批量插入。原因很简单安全上参数化查询能堵住SQL注入这条最危险的路性能上合理开启服务端预编译和缓存之后确实能省下可观的解析开销维护上SQL骨架和参数分离让代码更清晰对团队协作更友好。这三个理由随便拎出来一个都足够帮你说服那些嫌PreparedStatement麻烦的同事。但也要承认确实存在某些场景里Statement反而是更合理的选择。比如一条执行一次就再也不重复的DDL或者经过严格白名单校验后的动态表名查询用Statement可以少一层预编译开销。关键在于你得知道自己为什么选它而不是因为偷懒或者不熟悉而随手用了Statement。最后讲一个我自己的习惯在同一个类里如果有多条SQL都是相同结构、只是参数不同我会把PreparedStatement的创建放到循环外面复用同一个pstmt对象配合clearParameters()方法清除上一次的绑定值。比如批量更新一批用户的积分String sql UPDATE t_user SET points points ? WHERE user_id ?; try (PreparedStatement pstmt conn.prepareStatement(sql)) { for (UserPoints update : updateList) { pstmt.setInt(1, update.getDeltaPoints()); pstmt.setLong(2, update.getUserId()); pstmt.addBatch(); if (updateList.indexOf(update) % 500 0) { pstmt.executeBatch(); } } pstmt.executeBatch(); }这段代码里prepareStatement只调用了一次参数在循环里反复绑定和累加批次每500条真正执行一次批量提交既保证了性能又控制了单批次的数据量避免一次性组装过多SQL把数据库和网络都压垮。这种写法在一些老项目里并不多见因为很多同事习惯了在循环体里创建Statement完全没有“复用预编译对象”的意识。说白了PreparedStatement这层优势得靠编码习惯才能兑现成实际的性能提升。