JAVA代码优化,接口优化,SQL优化 (小技巧)(三)

简介: JAVA代码优化,接口优化,SQL优化 (小技巧)(三)

21.防止死循环

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


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

while(true) {
    if(condition) {
        break;
    }
    System.out.println("do samething");
}


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


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


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


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


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


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


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


public void printCategory(Category category) {
  if(category == null 
      || category.getParentId() == null) {
     return;
  } 
  System.out.println("父分类名称:"+ category.getName());
  Category parent = categoryMapper.getCategoryById(category.getParentId());
  printCategory(parent);
}


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


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


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


22.注意BigDecimal的坑

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


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

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


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


但是执行结果,却为:

0.009999999999999998

实际结果小于预计结果。


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


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


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


答案是否定的。


为什么?

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


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


结果:

0.0099999999999999984734433411404097569175064563751220703125

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


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



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


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


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

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


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


其实,还有更好的办法:

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


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


23.尽可能复用代码

ctrl + c 和 ctrl + v可能是程序员使用最多的快捷键了。


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


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


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

@Service
@Slf4j
public class TestService1 {
    public void test1()  {
        addLog("test1");
    }
    private void addLog(String info) {
        if (log.isInfoEnabled()) {
            log.info("info:{}", info);
        }
    }
    }
@Service
@Slf4j
public class TestService2 {
    public void test2()  {
        addLog("test2");
    }
    private void addLog(String info) {
        if (log.isInfoEnabled()) {
            log.info("info:{}", info);
        }
    }
}
@Service
@Slf4j
public class TestService3 {
    public void test3()  {
        addLog("test3");
    }
    private void addLog(String info) {
        if (log.isInfoEnabled()) {
            log.info("info:{}", info);
        }
    }
}


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


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


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


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


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

private void addLog(String info) {
    if (log.isDebugEnabled()) {
        log.debug("debug:{}", info);
    }
}


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


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

@Slf4j
public class LogUtil {
    private LogUtil() {
        throw new RuntimeException("初始化失败");
    }
    public static void addLog(String info) {
        if (log.isDebugEnabled()) {
            log.debug("debug:{}", info);
        }
    }
}


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

@Service
@Slf4j
public class TestService1 {
    public void test1()  {
        LogUtil.addLog("test1");
    }
}


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


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


24.foreach循环中不remove元素

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

public class Test2 {
    public static void main(String[] args) {
        List<String> list = Lists.newArrayList("a","b","c");
        for (String temp : list) {
            if ("c".equals(temp)) {
                list.remove(temp);
            }
        }
        System.out.println(list);
    }
}


执行结果:

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


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


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

public class Test2 {
    public static void main(String[] args) {
        List<String> list = Lists.newArrayList("a","b","c");
        for (int i = 0; i < list.size(); i++) {
            String temp = list.get(i);
            if ("c".equals(temp)) {
                list.remove(temp);
            }
        }
        System.out.println(list);
    }
}


执行结果:

[a, b]


25.避免随意打印日志

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


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


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

@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    log.info("request params:{}", ids);
    List<User> userList = userService.query(ids);
    log.info("response:{}", userList);
    return userList;
}


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


咋一看没啥问题。


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


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

@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    if (log.isDebugEnabled()) {
        log.debug("request params:{}", ids);
    }
    List<User> userList = userService.query(ids);
    if (log.isDebugEnabled()) {
        log.debug("response:{}", userList);
    }
    return userList;
}


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


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


26.比较时把常量写前面

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


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


反例:

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


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


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


正例:

private static final String FOUND_NAME = "苏三";
...
if(null == user) {
  return;
}
if(FOUND_NAME.equals(user.getName())) {
   System.out.println("找到:"+user.getName());
}


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


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


27.名称要见名知意

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


27.1 有意义的参数名

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

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


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


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


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


正解:

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


27.2 见名知意

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

String yongHuMing = "苏三";
String 用户Name = "苏三";
String su3 = "苏三";
String suThree = "苏三";


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


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

String userName = "苏三";
String susan = "苏三";


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


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


27.3 参数名风格一致

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

//字母全小写
int suppliercount = 1;
//字母全大写
int SUPPLIERCOUNT = 1;
//小写字母 + 下划线
int supplier_count = 1;
//大写字母 + 下划线
int SUPPLIER_COUNT = 1;
//驼峰标识
int supplierCount = 1;


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


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

int supplierCount = 1;

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

private static final int SUPPLIER_COUNT = 1;


28.SimpleDateFormat线程不安全

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

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


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


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


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

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


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


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


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


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

@Override
public Date parse(String text, ParsePosition pos) {
    ...
    Date parsedDate;
    try {
        parsedDate = calb.establish(calendar).getTime();
        ...
    } catch (IllegalArgumentException e) {
        pos.errorIndex = start;
        pos.index = oldStart;
        return null;
    }
   return parsedDate;
}


该方法会调用establish方法:

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


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


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

public abstract class DateFormat extends Forma {
    ....
    protected Calendar calendar;
    ...
}


这样就可能会出现多个线程,同时修改同一个对象即: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类,我们自定义线程池。


具体代码如下:

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


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


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

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


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


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


正例:

String [] array = new String [] {"a","b","c"};
List<String> list = Arrays.asList(array);
for (String str : list) {
    System.out.println(str);
}


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


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


反例:

String[] array = new String[]{"a", "b", "c"};
List<String> list = Arrays.asList(array);
list.add("d");
for (String str : list) {
    System.out.println(str);
}


执行结果:

Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
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是如何实现的:

public void add(int index, E element) {
   throw new UnsupportedOperationException();
}
public E remove(int index) {
   throw new UnsupportedOperationException();
}


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


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

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
3天前
|
存储 安全 算法
【JAVA】HashMap扩容性能影响及优化策略
【JAVA】HashMap扩容性能影响及优化策略
|
4天前
|
SQL 关系型数据库 MySQL
【MySQL】SQL优化
【MySQL】SQL优化
|
5天前
|
SQL Java 数据库
java代码中调用dao层查询接口,代码没有返回数据,打印出的sql查出了数据
java代码中调用dao层查询接口,代码没有返回数据,打印出的sql查出了数据
10 1
|
5天前
|
消息中间件 缓存 NoSQL
Java多线程实战-CompletableFuture异步编程优化查询接口响应速度
Java多线程实战-CompletableFuture异步编程优化查询接口响应速度
|
6天前
|
存储 安全 Java
[Java基础面试题] Map 接口相关
[Java基础面试题] Map 接口相关
|
6天前
|
SQL Java 数据库连接
Java从入门到精通:2.3.2数据库编程——了解SQL语言,编写基本查询语句
Java从入门到精通:2.3.2数据库编程——了解SQL语言,编写基本查询语句
|
8天前
|
SQL 分布式计算 资源调度
一文解析 ODPS SQL 任务优化方法原理
本文重点尝试从ODPS SQL的逻辑执行计划和Logview中的执行计划出发,分析日常数据研发过程中各种优化方法背后的原理,覆盖了部分调优方法的分析,从知道怎么优化,到为什么这样优化,以及还能怎样优化。
103456 0
|
11天前
|
监控 算法 Java
《Java 简易速速上手小册》第8章:Java 性能优化(2024 最新版)
《Java 简易速速上手小册》第8章:Java 性能优化(2024 最新版)
22 0
|
11天前
|
Java 开发者
探索 Java 的函数式接口和 Lambda 表达式
【4月更文挑战第19天】Java 中的函数式接口和 Lambda 表达式提供了简洁、灵活的编程方式。函数式接口有且仅有一个抽象方法,用于与 Lambda(一种匿名函数语法)配合,简化代码并增强可读性。Lambda 表达式的优点在于其简洁性和灵活性,常用于事件处理、过滤和排序等场景。使用时注意兼容性和变量作用域,它们能提高代码效率和可维护性。
|
12天前
|
Java
Java接口中可以定义哪些方法?
【4月更文挑战第13天】
14 0
Java接口中可以定义哪些方法?