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