优雅又实用的 Java 代码优化技巧
接上一篇内容,继续分享 Java 代码优化技巧
本篇内容预览:
-
规范日志打印
-
规范异常处理
-
统一异常处理
-
使用 try-with-resource
-
关闭资源
-
不要把异常定义为静态变量
-
其他异常处理注意事项
-
接口不要直接返回数据库对象
-
统一接口返回值
-
远程调用设置超时时间
-
正确使用线程池
-
敏感数据处理
规范日志打印
1、不要随意打印日志,确保自己打印的日志是后面能用到的。
打印太多无用的日志不光影响问题排查,还会影响性能,加重磁盘负担。
2、打印日志中的敏感数据比如身份证号、电话号、密码需要进行脱敏。相关阅读:Spring Boot 3 步完成日志脱敏,简单实用!!
3、选择合适的日志打印级别。最常用的日志级别有四个:DEBUG、INFO、WARN、ERROR。
-
DEBUG(调试):开发调试日志,主要开发人员开发调试过程中使用,生产环境禁止输出 DEBUG 日志。
-
INFO(通知):正常的系统运行信息,一些外部接口的日志,通常用于排查问题使用。
-
WARN(警告):警告日志,提示系统某个模块可能存在问题,但对系统的正常运行没有影响。
-
ERROR(错误):错误日志,提示系统某个模块可能存在比较严重的问题,会影响系统的正常运行。
4、生产环境禁止输出 DEBUG 日志,避免打印的日志过多(DEBUG 日志非常多)。
5、应用中不可直接使用日志系统(Log4j、Logback)中的 API,而应依赖使用日志框架 SLF4J 中的 API,使用门面模式的日志框架,有利于维护和各个类的日志处理方式统一。
Spring Boot 应用程序可以直接使用内置的日志框架 Logback,Logback 就是按照 SLF4J API 标准实现的。
6、异常日志需要打印完整的异常信息。
反例:
try { //读文件操作 readFile();} catch (IOException e) { // 只保留了异常消息,栈没有记录 log.error("文件读取错误, {}", e.getMessage());}
正例:
try { //读文件操作 readFile();} catch (IOException e) { log.error("文件读取错误", e);}
7、避免层层打印日志。
举个例子:method1 调用 method2,method2 出现 error 并打印 error 日志,method1 也打印了 error 日志,等同于一个错误日志打印了 2 遍。
8、不要打印日志后又将异常抛出。
反例:
try { ...} catch (IllegalArgumentException e) { log.error("出现异常啦", e); throw e;}
在日志中会对抛出的一个异常打印多条错误信息。
正例:
try { ...} catch (IllegalArgumentException e) { log.error("出现异常啦", e);}// 或者包装成自定义异常之后抛出try { ...} catch (IllegalArgumentException e) { throw new MyBusinessException("一段对异常的描述信息.", e);}
规范异常处理
阿里巴巴 Java 异常处理规约如下:
阿里巴巴 Java 开发手册
19/38
二、异常日志
(一)异常处理
1. 【强制】Java 类库中定义的可以通过预检查方式规避的 RuntimeException 异常不应该通过
catch 的方式来处理,比如:NullPointerException,IndexOutOfBoundsException 等等。
说明:无法通过预检查的异常除外,比如,在解析字符串形式的数字时,不得不通过 catch
NumberFormatException 来实现。
正例:if (obj != null) {...}
反例:try { obj.method(); } catch (NullPointerException e) {…}
2. 【强制】异常不要用来做流程控制,条件控制。
说明:异常设计的初衷是解决程序运行中的各种意外情况,且异常的处理效率比条件判断方式
要低很多。
3. 【强制】catch 时请分清稳定代码和非稳定代码,稳定代码指的是无论如何不会出错的代码。
对于非稳定代码的 catch 尽可能进行区分异常类型,再做对应的异常处理。
说明:对大段代码进行 try-catch,使程序无法根据不同的异常做出正确的应激反应,也不利
于定位问题,这是一种不负责任的表现。
正例:用户注册的场景中,如果用户输入非法字符,或用户名称已存在,或用户输入密码过于
简单,在程序上作出分门别类的判断,并提示给用户。
4. 【强制】捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之,如果不想处理它,请
将该异常抛给它的调用者。最外层的业务使用者,必须处理异常,将其转化为用户可以理解的
内容。
5. 【强制】有 try 块放到了事务代码中,catch 异常后,如果需要回滚事务,一定要注意手动回
滚事务。
6. 【强制】finally 块必须对资源对象、流对象进行关闭,有异常也要做 try-catch。
说明:如果 JDK7 及以上,可以使用 try-with-resources 方式。
7. 【强制】不要在 finally 块中使用 return。
说明:finally 块中的 return 返回后方法结束执行,不会再执行 try 块中的 return 语句。
8. 【强制】捕获异常与抛异常,必须是完全匹配,或者捕获异常是抛异常的父类。
说明:如果预期对方抛的是绣球,实际接到的是铅球,就会产生意外情况。
9. 【推荐】方法的返回值可以为 null,不强制返回空集合,或者空对象等,必须添加注释充分
说明什么情况下会返回 null 值。
说明:本手册明确防止 NPE 是调用者的责任。即使被调用方法返回空集合或者空对象,对调用阿里巴巴 Java 开发手册
者来说,也并非高枕无忧,必须考虑到远程调用失败、序列化失败、运行时异常等场景返回
null 的情况。
10. 【推荐】防止 NPE,是程序员的基本修养,注意 NPE 产生的场景:
1)返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE。
反例:public int f() { return Integer 对象}, 如果为 null,自动解箱抛 NPE。
2) 数据库的查询结果可能为 null。
3) 集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null。
4) 远程调用返回对象时,一律要求进行空指针判断,防止 NPE。
5) 对于 Session 中获取的数据,建议 NPE 检查,避免空指针。
6) 级联调用 obj.getA().getB().getC();一连串调用,易产生 NPE。
正例:使用 JDK8 的 Optional 类来防止 NPE 问题。
11. 【推荐】定义时区分 unchecked / checked 异常,避免直接抛出 new RuntimeException(),
更不允许抛出 Exception 或者 Throwable,应使用有业务含义的自定义异常。推荐业界已定义
过的自定义异常,如:DAOException / ServiceException 等。
12. 【参考】对于公司外的 http/api 开放接口必须使用“错误码”;而应用内部推荐异常抛出;
跨应用间 RPC 调用优先考虑使用 Result 方式,封装 isSuccess()方法、“错误码”、“错误简
短信息”。
说明:关于 RPC 方法返回方式使用 Result 方式的理由:
1)使用抛异常返回方式,调用方如果没有捕获到就会产生运行时错误。
2)如果不加栈信息,只是 new 自定义异常,加入自己的理解的 error message,对于调用
端解决问题的帮助不会太多。如果加了栈信息,在频繁调用出错的情况下,数据序列化和传输
的性能损耗也是问题。
13. 【参考】避免出现重复的代码(Don’t Repeat Yourself),即 DRY 原则。
说明:随意复制和粘贴代码,必然会导致代码的重复,在以后需要修改时,需要修改所有的副
本,容易遗漏。必要时抽取共性方法,或者抽象公共类,甚至是组件化。
正例:一个类中有多个 public 方法,都需要进行数行相同的参数校验操作,这个时候请抽取:
private boolean checkParam(DTO dto) {...}
统一异常处理
所有的异常都应该由最上层捕获并处理,这样代码更简洁,还可以避免重复输出异常日志。 如果我们都在业务代码中使用try-catch
或者try-catch-finally
处理的话,就会让业务代码中冗余太多异常处理的逻辑,对于同样的异常我们还需要重复编写代码处理,还可能会导致重复输出异常日志。这样的话,代码可维护性、可阅读性都非常差。
Spring Boot 应用程序可以借助 @RestControllerAdvice
和 @ExceptionHandler
实现全局统一异常处理。
@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result businessExceptionHandler(HttpServletRequest request, BusinessException e){ ... return Result.faild(e.getCode(), e.getMessage()); } ...}
使用 try-with-resource 关闭资源
-
适用范围(资源的定义): 任何实现
java.lang.AutoCloseable
或者java.io.Closeable
的对象 -
关闭资源和 finally 块的执行顺序: 在
try-with-resources
语句中,任何 catch 或 finally 块在声明的资源关闭后运行
《Effective Java》中明确指出:
面对必须要关闭的资源,我们总是应该优先使用
try-with-resources
而不是try-finally
。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources
语句让我们更容易编写必须要关闭的资源的代码,若采用try-finally
则几乎做不到这点。
Java 中类似于InputStream
、OutputStream
、Scanner
、PrintWriter
等的资源都需要我们调用close()
方法来手动关闭,一般情况下我们都是通过try-catch-finally
语句来实现这个需求,如下:
//读取文本文件的内容Scanner scanner = null;try { scanner = new Scanner(new File("D://read.txt")); while (scanner.hasNext()) { System.out.println(scanner.nextLine()); }} catch (FileNotFoundException e) { e.printStackTrace();} finally { if (scanner != null) { scanner.close(); }}
使用 Java 7 之后的 try-with-resources
语句改造上面的代码:
try (Scanner scanner = new Scanner(new File("test.txt"))) { while (scanner.hasNext()) { System.out.println(scanner.nextLine()); }} catch (FileNotFoundException fnfe) { fnfe.printStackTrace();}
当然多个资源需要关闭的时候,使用 try-with-resources
实现起来也非常简单,如果你还是用try-catch-finally
可能会带来很多问题。
通过使用分号分隔,可以在try-with-resources
块中声明多个资源。
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt"))); BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) { int b; while ((b = bin.read()) != -1) { bout.write(b); }}catch (IOException e) { e.printStackTrace();}
不要把异常定义为静态变量
不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
// 错误做法public class Exceptions { public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在", 3001);...}
其他异常处理注意事项
-
抛出完整具体的异常信息(避免
throw new BIZException(e.getMessage()
这种形式的异常抛出),尽量自定义异常,而不是直接使用RuntimeException
或Exception
。 -
优先捕获具体的异常类型。
-
捕获了异常之后一定要处理,避免直接吃掉异常。
-
......
接口不要直接返回数据库对象
接口不要直接返回数据库对象(也就是 DO),数据库对象包含类中所有的属性。
// 错误做法public UserDO getUser(Long userId) { return userService.getUser(userId);}
原因:
-
如果数据库查询不做字段限制,会导致接口数据庞大,浪费用户的宝贵流量。
-
如果数据库查询不做字段限制,容易把敏感字段暴露给接口,导致出现数据的安全问题。
-
如果修改数据库对象的定义,接口返回的数据紧跟着也要改变,不利于维护。
建议的做法是单独定义一个类比如 VO(可以看作是接口返回给前端展示的对象数据)来对接口返回的数据进行筛选,甚至是封装和组合。
public UserVo getUser(Long userId) { UserDO userDO = userService.getUser(userId); UserVO userVO = new UserVO(); BeanUtils.copyProperties(userDO, userVO);//演示使用 return userVO;}
统一接口返回值
接口返回的数据一定要统一格式,遮掩更方面对接前端开发的同学以及其他调用该接口的开发。
通常来说,下面这些信息是必备的:
-
状态码和状态信息:可以通过枚举定义状态码和状态信息。状态码标识请求的结果,状态信息属于提示信息,提示成功信息或者错误信息。
-
请求数据:请求该接口实际要返回的数据比如用户信息、文章列表。
public enum ResultEnum implements IResult { SUCCESS(2001, "接口调用成功"), VALIDATE_FAILED(2002, "参数校验失败"), COMMON_FAILED(2003, "接口调用失败"), FORBIDDEN(2004, "没有权限访问资源"); private Integer code; private String message; ...}public class Result { private Integer code; private String message; private T data; ... public static Result success(T data) { return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data); } public static Result<?> failed() { return new Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null); } ...}
对于 Spring Boot 项目来说,可以使用 @RestControllerAdvice
注解+ ResponseBodyAdvic
接口统一处理接口返回值,实现代码无侵入。篇幅问题这里就不贴具体实现代码了,比较简单,具体实现方式可以参考这篇文章:Spring Boot 无侵入式 实现 API 接口统一 JSON 格式返回[6] 。
需要注意的是,这种方式在 Spring Cloud OpenFeign 的继承模式下是有侵入性,解决办法见:SpringBoot 无侵入式 API 接口统一格式返回,在 Spring Cloud OpenFeign 继承模式具有了侵入性[7] 。
实际项目中,其实使用比较多的还是下面这种比较直接的方式:
public class PostController { @GetMapping("/list") public R<List<SysPost>> getPosts() { ... return R.ok(posts); }}
上面介绍的无侵入的方式,一般改造旧项目的时候用的比较多。
远程调用设置超时时间
开发过程中,第三方接口调用、RPC 调用以及服务之间的调用建议设置一个超时时间。
我们平时接触到的超时可以简单分为下面 2 种:
-
连接超时(ConnectTimeout) :客户端与服务端建立连接的最长等待时间。
-
读取超时(ReadTimeout) :客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。
一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时。
如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。
我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。
正确使用线程池
10 个使用线程池的注意事项:
-
线程池必须手动通过
ThreadPoolExecutor
的构造函数来声明,避免使用Executors
类创建线程池,会有 OOM 风险。 -
监测线程池运行状态。
-
建议不同类别的业务用不同的线程池。
-
别忘记给线程池命名。
-
正确配置线程池参数。
-
别忘记关闭线程池。
-
线程池尽量不要放耗时任务。
-
避免重复创建线程池。
-
使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)
-
线程池和
ThreadLocal
共用,可能会导致线程从ThreadLocal
获取到的是旧值/脏数据。
敏感数据处理
-
返回前端的敏感数据比如身份证号、电话、地址信息要根据业务需求进行脱敏处理,示例:
163****892
。 -
保存在数据库中的密码需要加盐之后使用哈希算法(比如 BCrypt)进行加密。
-
保存在数据库中的银行卡号、身份号这类敏感数据需要使用对称加密算法(比如 AES)保存。
-
网络传输的敏感数据比如银行卡号、身份号需要用 HTTPS + 非对称加密算法(如 RSA)来保证传输数据的安全性。
-
对于密码找回功能,不能明文存储用户密码。可以采用重置密码的方式,让用户通过验证身份后重新设置密码。
-
在代码中不应该明文写入密钥、口令等敏感信息。可以采用配置文件、环境变量等方式来动态加载这些信息。
-
定期更新敏感数据的加密算法和密钥,以保证加密算法和密钥的安全性和有效性。
参考资料
[1]
美团技术团队:如何优雅地记录操作日志?:
[2]
一个较重的代码坏味:“炫技式”的单行代码:
[3]
Replace Conditional Logic with Strategy Pattern - IDEA:
www.jetbrains.com/help/idea/r…
[4]
聊一聊责任链模式:
[5]
21 | 代码重复:搞定代码重复的三个绝招 - Java 业务开发常见错误 100 例 :
time.geekbang.org/column/arti…
[6]
Spring Boot 无侵入式 实现 API 接口统一 JSON 格式返回:
[7]
SpringBoot 无侵入式 API 接口统一格式返回,在 Spring Cloud OpenFeign 继承模式具有了侵入性:
[8]
超时&重试详解:
[9]
10 个线程池最佳实践和坑!:
转载自:https://juejin.cn/post/7233961132540428349