有些小伙伴看到这个标题,可能会感到有点意外,代码中不是应该避免死循环吗?为啥还是会产生死循环?
殊不知有些死循环是我们自己写的,例如下面这段代码:
-
- 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时,则自动返回,这样就能避免无限循环的情况。
通常我们会把一些小数类型的字段(比如金额),定义成 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 参数。
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 方法即可。你可以自信满满的修改,不需要再小心翼翼了。
我们写的代码,绝大多数是可维护性的代码,而非一次性的。所以,建议在写代码的过程中,如果出现重复的代码,尽量提取成公共方法。千万别因为项目初期一时的爽快,而给项目埋下隐患,后面的维护成本可能会非常高。
我们知道在 Java 中,循环有很多种写法,比如 while、for、foreach 等。
-
- public class Test2 {
- public static void main(String[] args) {
- List
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
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]
在我们写代码的时候,打印日志是必不可少的工作之一。
因为日志可以帮我们快速定位问题,判断代码当时真正的执行逻辑。
但打印日志的时候也需要注意,不是说任何时候都要打印日志,比如:
-
- @PostMapping("/query")
- public List
query(@RequestBody List ids) { - log.info("request params:{}", ids);
- List
userList = userService.query(ids); - log.info("response:{}", userList);
- return userList;
- }
对于有些查询接口,在日志中打印出了请求参数和接口返回值。
咋一看没啥问题。
但如果ids中传入值非常多,比如有 1000 个。而该接口被调用的频次又很高,一下子就会打印大量的日志,用不了多久就可能把磁盘空间打满。
如果真的想打印这些日志该怎么办?
-
- @PostMapping("/query")
- public List
query(@RequestBody List ids) { - if (log.isDebugEnabled()) {
- log.debug("request params:{}", ids);
- }
-
- List
userList = userService.query(ids); - if (log.isDebugEnabled()) {
- log.debug("response:{}", userList);
- }
-
- return userList;
- }
使用 isDebugEnabled 判断一下,如果当前的日志级别是 debug 才打印日志。生产环境默认日志级别是 info,在有些紧急情况下,把某个接口或者方法的日志级别改成 debug,打印完我们需要的日志后,又调整回去。
方便我们定位问题,又不会产生大量的垃圾日志,一举两得。
在比较两个参数值是否相等时,通常我们会使用 == 号,或者 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,而不再是报空指针异常。
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;
在 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 类。
我们都知道 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 问题,所以我们需要根据实际业务场景选择。
在我们日常工作中,经常需要把数组转换成 List 集合。
因为数组的长度是固定的,不太好扩容,而 List 的长度是可变的,它的长度会根据元素的数量动态扩容。
在 JDK 的 Arrays 类中提供了asList 方法,可以把数组转换成 List。
正例:
-
- String [] array = new String [] {"a","b","c"};
- List
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
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 代码优化是一个比较大的话题,它里面可以优化的点非常多,我没办法一一列举完。在这里只能抛砖引玉,介绍一下比较常见的知识点,更全面的内容,需要小伙伴们自己去思考和探索。