• Java 中代码优化的 30 个小技巧(下)


    21 防止死循环

    有些小伙伴看到这个标题,可能会感到有点意外,代码中不是应该避免死循环吗?为啥还是会产生死循环?

    殊不知有些死循环是我们自己写的,例如下面这段代码:

    1. while(true) {
    2. if(condition) {
    3. break;
    4. }
    5. System.out.println("do samething");
    6. }

    这里使用了 while(true) 的循环调用,这种写法在 CAS 自旋锁中使用比较多。

    当满足 condition 等于 true 的时候,则自动退出该循环。

    如果 condition 条件非常复杂,一旦出现判断不正确,或者少写了一些逻辑判断,就可能在某些场景下出现死循环的问题。

    出现死循环,大概率是开发人员人为的 bug 导致的,不过这种情况很容易被测出来。

    还有一种隐藏的比较深的死循环,是由于代码写的不太严谨导致的。如果用正常数据,可能测不出问题,但一旦出现异常数据,就会立即出现死循环。

    其实,还有另一种死循环无限递归。

    如果想要打印某个分类的所有父分类,可以用类似这样的递归方法实现:

    1. public void printCategory(Category category) {
    2. if(category == null
    3. || category.getParentId() == null) {
    4. return;
    5. }
    6. System.out.println("父分类名称:"+ category.getName());
    7. Category parent = categoryMapper.getCategoryById(category.getParentId());
    8. printCategory(parent);
    9. }

    正常情况下,这段代码是没有问题的。

    但如果某次有人误操作,把某个分类的 parentId 指向了它自己,这样就会出现无限递归的情况。导致接口一直不能返回数据,最终会发生堆栈溢出

    建议写递归方法时,设定一个递归的深度,比如:分类最大等级有4级,则深度可以设置为4。然后在递归方法中做判断,如果深度大于4时,则自动返回,这样就能避免无限循环的情况。

    22 注意 BigDecimal 的坑

    通常我们会把一些小数类型的字段(比如金额),定义成 BigDecimal,而不是 Double,避免丢失精度问题。

    使用 Double 时可能会有这种场景:

    1. double amount1 = 0.02;
    2. double amount2 = 0.03;
    3. System.out.println(amount2 - amount1);

    正常情况下预计 amount2 - amount1 应该等于 0.01。

    但是执行结果,却为:

    0.009999999999999998

    实际结果小于预计结果。

    Double 类型的两个参数相减会转换成二进制,因为 Double 有效位数为 16 位这就会出现存储小数位数不够的情况,这种情况下就会出现误差。

    常识告诉我们使用 BigDecimal 能避免丢失精度。

    但是使用 BigDecimal 能避免丢失精度吗?

    答案是否定的。

    为什么?

    1. BigDecimal amount1 = new BigDecimal(0.02);
    2. BigDecimal amount2 = new BigDecimal(0.03);
    3. System.out.println(amount2.subtract(amount1));

    这个例子中定义了两个 BigDecimal 类型参数,使用构造函数初始化数据,然后打印两个参数相减后的值。

    结果:

    0.0099999999999999984734433411404097569175064563751220703125

    不科学呀,为啥还是丢失精度了?

    JDK 中 BigDecimal 的构造方法上有这样一段描述:

    大致的意思是,此构造函数的结果可能不可预测,可能会出现创建时为 0.1,但实际是 0.1000000000000000055511151231257827021181583404541015625 的情况。

    由此可见,使用 BigDecimal 构造函数初始化对象,也会丢失精度。

    那么,如何才能不丢失精度呢?

    1. BigDecimal amount1 = new BigDecimal(Double.toString(0.02));
    2. BigDecimal amount2 = new BigDecimal(Double.toString(0.03));
    3. System.out.println(amount2.subtract(amount1));

    我们可以使用 Double.toString 方法,对 double 类型的小数进行转换,这样能保证精度不丢失。

    其实,还有更好的办法:

    1. BigDecimal amount1 = BigDecimal.valueOf(0.02);
    2. BigDecimal amount2 = BigDecimal.valueOf(0.03);
    3. System.out.println(amount2.subtract(amount1));

    使用 BigDecimal.valueOf 方法初始化 BigDecimal 类型参数,也能保证精度不丢失。在新版的阿里巴巴开发手册中,也推荐使用这种方式创建 BigDecimal 参数。

    23 尽可能复用代码

    Ctrl + C 和 Ctrl + V 可能是程序员使用最多的快捷键了。

    没错,我们是大自然的搬运工。哈哈哈。

    在项目初期,我们使用这种工作模式,确实可以提高一些工作效率,可以少写(实际上是少敲)很多代码。

    但它带来的问题是,会出现大量的代码重复。例如:

    1. @Service
    2. @Slf4j
    3. public class TestService1 {
    4. public void test1() {
    5. addLog("test1");
    6. }
    7. private void addLog(String info) {
    8. if (log.isInfoEnabled()) {
    9. log.info("info:{}", info);
    10. }
    11. }
    12. }
    13. @Service
    14. @Slf4j
    15. public class TestService2 {
    16. public void test2() {
    17. addLog("test2");
    18. }
    19. private void addLog(String info) {
    20. if (log.isInfoEnabled()) {
    21. log.info("info:{}", info);
    22. }
    23. }
    24. }
    25. @Service
    26. @Slf4j
    27. public class TestService3 {
    28. public void test3() {
    29. addLog("test3");
    30. }
    31. private void addLog(String info) {
    32. if (log.isInfoEnabled()) {
    33. log.info("info:{}", info);
    34. }
    35. }
    36. }

    在 TestService1、TestService2、TestService3 类中,都有一个 addLog 方法用于添加日志。

    本来该功能用得好好的,直到有一天,线上出现了一个事故服务器磁盘满了。

    原因是打印的日志太多,记了很多没必要的日志,比如查询接口的所有返回值,大对象的具体打印等。

    没办法,只能将 addLog 方法改成只记录 debug 日志。

    于是乎,你需要全文搜索,addLog 方法去修改,改成如下代码:

    1. private void addLog(String info) {
    2. if (log.isDebugEnabled()) {
    3. log.debug("debug:{}", info);
    4. }
    5. }

    这里是有三个类中需要修改这段代码,但如果实际工作中有三十个、三百个类需要修改,会让你非常痛苦。改错了,或者改漏了,都会埋下隐患,把自己坑了。

    为何不把这种功能的代码提取出来,放到某个工具类中呢?

    1. @Slf4j
    2. public class LogUtil {
    3. private LogUtil() {
    4. throw new RuntimeException("初始化失败");
    5. }
    6. public static void addLog(String info) {
    7. if (log.isDebugEnabled()) {
    8. log.debug("debug:{}", info);
    9. }
    10. }
    11. }

    然后,在其他的地方,只需要调用。

    1. @Service
    2. @Slf4j
    3. public class TestService1 {
    4. public void test1() {
    5. LogUtil.addLog("test1");
    6. }
    7. }

    如果哪天 addLog 的逻辑又要改了,只需要修改 LogUtil 类的 addLog 方法即可。你可以自信满满的修改,不需要再小心翼翼了。

    我们写的代码,绝大多数是可维护性的代码,而非一次性的。所以,建议在写代码的过程中,如果出现重复的代码,尽量提取成公共方法。千万别因为项目初期一时的爽快,而给项目埋下隐患,后面的维护成本可能会非常高。

    24 foreach 循环中不 remove 元素

    我们知道在 Java 中,循环有很多种写法,比如 while、for、foreach 等。

    1. public class Test2 {
    2. public static void main(String[] args) {
    3. List list = Lists.newArrayList("a","b","c");
    4. for (String temp : list) {
    5. if ("c".equals(temp)) {
    6. list.remove(temp);
    7. }
    8. }
    9. System.out.println(list);
    10. }
    11. }

    执行结果:

    1. Exception in thread "main" java.util.ConcurrentModificationException
    2. at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    3. at java.util.ArrayList$Itr.next(ArrayList.java:851)
    4. at com.sue.jump.service.test1.Test2.main(Test2.java:24)

    这种在 foreach 循环中调用 remove 方法删除元素,可能会报 ConcurrentModificationException 异常。

    如果想在遍历集合时,删除其中的元素,可以用 for 循环,例如:

    1. public class Test2 {
    2. public static void main(String[] args) {
    3. List list = Lists.newArrayList("a","b","c");
    4. for (int i = 0; i < list.size(); i++) {
    5. String temp = list.get(i);
    6. if ("c".equals(temp)) {
    7. list.remove(temp);
    8. }
    9. }
    10. System.out.println(list);
    11. }
    12. }

    执行结果:

    [a, b]

    25 避免随意打印日志

    在我们写代码的时候,打印日志是必不可少的工作之一。

    因为日志可以帮我们快速定位问题,判断代码当时真正的执行逻辑。

    但打印日志的时候也需要注意,不是说任何时候都要打印日志,比如:

    1. @PostMapping("/query")
    2. public List query(@RequestBody List ids) {
    3. log.info("request params:{}", ids);
    4. List userList = userService.query(ids);
    5. log.info("response:{}", userList);
    6. return userList;
    7. }

    对于有些查询接口,在日志中打印出了请求参数和接口返回值。

    咋一看没啥问题。

    但如果ids中传入值非常多,比如有 1000 个。而该接口被调用的频次又很高,一下子就会打印大量的日志,用不了多久就可能把磁盘空间打满。

    如果真的想打印这些日志该怎么办?

    1. @PostMapping("/query")
    2. public List query(@RequestBody List ids) {
    3. if (log.isDebugEnabled()) {
    4. log.debug("request params:{}", ids);
    5. }
    6. List userList = userService.query(ids);
    7. if (log.isDebugEnabled()) {
    8. log.debug("response:{}", userList);
    9. }
    10. return userList;
    11. }

    使用 isDebugEnabled 判断一下,如果当前的日志级别是 debug 才打印日志。生产环境默认日志级别是 info,在有些紧急情况下,把某个接口或者方法的日志级别改成 debug,打印完我们需要的日志后,又调整回去。

    方便我们定位问题,又不会产生大量的垃圾日志,一举两得。

    26 比较时把常量写前面

    在比较两个参数值是否相等时,通常我们会使用 == 号,或者 equals 方法。

    我在第 15 个技巧中说过,使用 == 号比较两个值是否相等时,可能会存在问题,建议使用 equals 方法做比较。

    反例:

    1. if(user.getName().equals("苏三")) {
    2. System.out.println("找到:"+user.getName());
    3. }

    在上面这段代码中,如果 user 对象,或者 user.getName() 方法返回值为 null,则都报 NullPointerException 异常。

    那么,如何避免空指针异常呢?

    正例:

    1. private static final String FOUND_NAME = "苏三";
    2. ...
    3. if(null == user) {
    4. return;
    5. }
    6. if(FOUND_NAME.equals(user.getName())) {
    7. System.out.println("找到:"+user.getName());
    8. }

    在使用 equals 做比较时,尽量将常量写在前面,即 equals 方法的左边。

    这样即使 user.getName() 返回的数据为 null,equals 方法会直接返回 false,而不再是报空指针异常。

    27 名称要见名知意

    Java 中没有强制规定参数、方法、类或者包名该怎么起名。但如果我们没有养成良好的起名习惯,随意起名的话,可能会出现很多奇怪的代码。

    27.1 有意义的参数名

    有时候,我们写代码时为了省事(可以少敲几个字母),参数名起得越简单越好。假如同事 A 写的代码如下:

    1. int a = 1;
    2. int b = 2;
    3. String c = "abc";
    4. boolean b = false;

    一段时间之后,同事 A 离职了,同事 B 接手了这段代码。

    他此时一脸懵逼,a 是什么意思,b 又是什么意思,还有 c.. .然后心里一万匹草泥马。

    给参数起一个有意义的名字,是非常重要的事情,避免给自己或者别人埋坑。

    正解:

    1. int supplierCount = 1;
    2. int purchaserCount = 2;
    3. String userName = "abc";
    4. boolean hasSuccess = false;

    27.2 见名知意

    光起有意义的参数名还不够,我们不能就这点追求。我们起的参数名称最好能够见名知意,不然就会出现这样的情况:

    1. String yongHuMing = "苏三";
    2. String 用户Name = "苏三";
    3. String su3 = "苏三";
    4. String suThree = "苏三";

    这几种参数名看起来是不是有点怪怪的?

    为啥不定义成国际上通用的(地球人都能看懂)英文单词呢?

    1. String userName = "苏三";
    2. String susan = "苏三";

    上面的这两个参数名,基本上大家都能看懂,减少了好多沟通成本。

    所以建议在定义不管是参数名、方法名、类名时,优先使用国际上通用的英文单词,更简单直观,减少沟通成本。少用汉子、拼音,或者数字定义名称。

    27.3 参数名风格一致

    参数名其实有多种风格,列如:

    1. //字母全小写
    2. int suppliercount = 1;
    3. //字母全大写
    4. int SUPPLIERCOUNT = 1;
    5. //小写字母 + 下划线
    6. int supplier_count = 1;
    7. //大写字母 + 下划线
    8. int SUPPLIER_COUNT = 1;
    9. //驼峰标识
    10. int supplierCount = 1;

    如果某个类中定义了多种风格的参数名称,看起来是不是有点杂乱无章?

    所以建议类的成员变量、局部变量和方法参数使用 supplierCount,这种驼峰风格,即:第一个字母小写,后面的每个单词首字母大写。例如:

    int supplierCount = 1;

    此外,为了好做区分,静态常量建议使用 SUPPLIER_COUNT,即大写字母 + 下划线分隔的参数名。例如:

    1. private static final int SUPPLIER_COUNT = 1;

    28 SimpleDateFormat 线程不安全

    在 Java8 之前,我们对时间的格式化处理,一般都是用的 SimpleDateFormat 类实现的。

    例如:

    1. @Service
    2. public class SimpleDateFormatService {
    3. public Date time(String time) throws ParseException {
    4. SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    5. return dateFormat.parse(time);
    6. }
    7. }

    如果你真的这样写,是没问题的。

    就怕哪天抽风,你觉得 dateFormat 是一段固定的代码,应该要把它抽取成常量。

    于是把代码改成下面的这样:

    1. @Service
    2. public class SimpleDateFormatService {
    3. private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    4. public Date time(String time) throws ParseException {
    5. return dateFormat.parse(time);
    6. }
    7. }

    dateFormat 对象被定义成了静态常量,这样就能被所有对象共用。

    如果只有一个线程调用 time 方法,也不会出现问题。

    但 Serivce 类的方法,往往是被 Controller 类调用的,而 Controller 类的接口方法,则会被 Tomcat 的线程池调用。换句话说,可能会出现多个线程调用同一个 Controller 类的同一个方法,也就是会出现多个线程会同时调用 time 方法。

    而 time 方法会调用 SimpleDateFormat 类的 parse 方法:

    1. @Override
    2. public Date parse(String text, ParsePosition pos) {
    3. ...
    4. Date parsedDate;
    5. try {
    6. parsedDate = calb.establish(calendar).getTime();
    7. ...
    8. } catch (IllegalArgumentException e) {
    9. pos.errorIndex = start;
    10. pos.index = oldStart;
    11. return null;
    12. }
    13. return parsedDate;
    14. }

    该方法会调用 establish 方法:

    1. Calendar establish(Calendar cal) {
    2. ...
    3. //1.清空数据
    4. cal.clear();
    5. //2.设置时间
    6. cal.set(...);
    7. //3.返回
    8. return cal;
    9. }

    其中的步骤 1、2、3 是非原子操作。

    但如果 cal 对象是局部变量还好,坏就坏在 parse 方法调用 establish 方法时,传入的 calendar 是 SimpleDateFormat 类的父类 DateFormat 的成员变量:

    1. public abstract class DateFormat extends Forma {
    2. ....
    3. protected Calendar calendar;
    4. ...
    5. }

    这样就可能会出现多个线程,同时修改同一个对象即 dateFormat,它的同一个成员变量即 Calendar 值的情况。

    这样可能会出现,某个线程设置好了时间,又被其他的线程修改了,从而出现时间错误的情况。

    那么,如何解决这个问题呢?

    • SimpleDateFormat 类的对象不要定义成静态的,可以改成方法的局部变量。

    • 使用 ThreadLocal 保存 SimpleDateFormat 类的数据。

    • 使用Java8 的 DateTimeFormatter 类。

    29 少用 Executors 创建线程池

    我们都知道 JDK5 之后,提供了 ThreadPoolExecutor 类,用它可以自定义线程池。

    线程池的好处有很多,下面主要说说这 3 个方面。

    • 降低资源消耗:避免了频繁的创建线程和销毁线程,可以直接复用已有线程。而我们都知道,创建线程是非常耗时的操作。

    • 提供速度:任务过来之后,因为线程已存在,可以拿来直接使用。

    • 提高线程的可管理性:线程是非常宝贵的资源,如果创建过多的线程,不仅会消耗系统资源,甚至会影响系统的稳定。使用线程池,可以非常方便的创建、管理和监控线程。

    当然 JDK 为了我们使用更便捷,专门提供了 Executors 类,给我们快速创建线程池。

    该类中包含了很多静态方法:

    • newCachedThreadPool:创建一个可缓冲的线程,如果线程池大小超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

    • newFixedThreadPool:创建一个固定大小的线程池,如果任务数量超过线程池大小,则将多余的任务放到队列中。

    • newScheduledThreadPool:创建一个固定大小,并且能执行定时周期任务的线程池。

    • newSingleThreadExecutor:创建只有一个线程的线程池,保证所有的任务安装顺序执行。

    在高并发的场景下,如果大家使用这些静态方法创建线程池,会有一些问题。那么,我们一起看看有哪些问题?

    • newFixedThreadPool:允许请求的队列长度是 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

    • newSingleThreadExecutor:允许请求的队列长度是 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

    • newCachedThreadPool:允许创建的线程数是 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

    那我们该怎办呢?

    优先推荐使用 ThreadPoolExecutor 类,我们自定义线程池。

    具体代码如下:

    1. ExecutorService threadPool = new ThreadPoolExecutor(
    2. 8, //corePoolSize线程池中核心线程数
    3. 10, //maximumPoolSize 线程池中最大线程数
    4. 60, //线程池中线程的最大空闲时间,超过这个时间空闲线程将被回收
    5. TimeUnit.SECONDS,//时间单位
    6. new ArrayBlockingQueue(500), //队列
    7. new ThreadPoolExecutor.CallerRunsPolicy()); //拒绝策略

    顺便说一下,如果是一些低并发场景,使用 Executors 类创建线程池也未尝不可,也不能完全一棍子打死。在这些低并发场景下,很难出现 OOM 问题,所以我们需要根据实际业务场景选择。

    30 Arrays.asList 转换的集合别修改

    在我们日常工作中,经常需要把数组转换成 List 集合。

    因为数组的长度是固定的,不太好扩容,而 List 的长度是可变的,它的长度会根据元素的数量动态扩容。

    在 JDK 的 Arrays 类中提供了asList 方法,可以把数组转换成 List。

    正例:

    1. String [] array = new String [] {"a","b","c"};
    2. List list = Arrays.asList(array);
    3. for (String str : list) {
    4. System.out.println(str);
    5. }

    在这个例子中,使用 Arrays.asList 方法将 array 数组,直接转换成了 list。然后在 for 循环中遍历 list,打印出它里面的元素。

    如果转换后的 list,只是使用,没新增或修改元素,不会有问题。

    反例:

    1. String[] array = new String[]{"a", "b", "c"};
    2. List list = Arrays.asList(array);
    3. list.add("d");
    4. for (String str : list) {
    5. System.out.println(str);
    6. }

    执行结果:

    1. Exception in thread "main" java.lang.UnsupportedOperationException
    2. at java.util.AbstractList.add(AbstractList.java:148)
    3. at java.util.AbstractList.add(AbstractList.java:108)
    4. at com.sue.jump.service.test1.Test2.main(Test2.java:24)

    会直接报 UnsupportedOperationException 异常。

    为什么呢?

    答:使用 Arrays.asList 方法转换后的 ArrayList,是 Arrays 类的内部类,并非 java.util 包下我们常用的 ArrayList。

    Arrays 类的内部 ArrayList 类,它没有实现父类的 add 和 remove 方法,用的是父类 AbstractList 的默认实现。

    我们看看 AbstractList 是如何实现的:

    1. public void add(int index, E element) {
    2. throw new UnsupportedOperationException();
    3. }
    4. public E remove(int index) {
    5. throw new UnsupportedOperationException();
    6. }

    该类的 add 和 remove 方法直接抛异常了,因此调用 Arrays 类的内部 ArrayList 类的 add 和 remove 方法,同样会抛异常。

    说实话,Java 代码优化是一个比较大的话题,它里面可以优化的点非常多,我没办法一一列举完。在这里只能抛砖引玉,介绍一下比较常见的知识点,更全面的内容,需要小伙伴们自己去思考和探索。

    31. 相关文章

    一、Java 中代码优化的 30 个小技巧(上)

    二、Java 中代码优化的 30 个小技巧(中)

    三、Java 中代码优化的 30 个小技巧(下)

  • 相关阅读:
    爱上开源之golang入门至实战-使用IDE开发Golang
    C++:重载
    机器学习第五课--广告点击率预测项目以及特征选择的介绍
    vue3后台管理框架之基础配置
    (十四)OpenCV中的自带颜色表操作cv::LUT
    Linux的权限
    CVBS、VGA、HDMI、MIPI等8种视频接口详解
    你该用什么的美剧学英语?
    [C语言] 自制的贪吃蛇游戏
    python:日期时间处理
  • 原文地址:https://blog.csdn.net/qq_37284798/article/details/127724889