百味皆苦 java后端开发攻城狮

Java业务错误案例-1

2021-03-16
百味皆苦

1:合理使用并发工具类库

  • 并发工具类是指用来解决多线程环境下并发问题的工具类库。一般而言并发工具包括同步器和容器两大类,业务代码中使用并发容器的情况会多一些

1.1:ThreadLocal线程重用导致信息错乱

  • 问题描述:使用了 ThreadLocal 来缓存获取到的用户信息,有时获取到的用户信息是别人的。
  • ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景。如果用户信息的获取比较昂贵(比如从数据库查询用户信息),那么在 ThreadLocal 中缓存数据是比较合适的做法。
  • 问题案例:使用 Spring Boot 创建一个 Web 应用程序,使用 ThreadLocal 存放一个 Integer 的值,来暂且代表需要在线程中保存的用户信息,这个值初始是 null。在业务逻辑中,我先从ThreadLocal 获取一次值,然后把外部传入的参数设置到 ThreadLocal 中,来模拟从当前上下文获取到用户信息的逻辑,随后再获取一次值,最后输出两次获得的值和线程名称。

错误案例

@RestController
@RequestMapping("threadlocal")
public class ThreadLocalMisuseController {

    private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);

    @GetMapping("wrong")
    public Map wrong(@RequestParam("userId") Integer userId) {
        //设置用户信息之前先查询一次ThreadLocal中的用户信息
        String before  = Thread.currentThread().getName() + ":" + currentUser.get();
        //设置用户信息到ThreadLocal
        currentUser.set(userId);
        //设置用户信息之后再查询一次ThreadLocal中的用户信息
        String after  = Thread.currentThread().getName() + ":" + currentUser.get();
        //汇总输出两次查询结果
        Map result = new HashMap();
        result.put("before", before);
        result.put("after", after);
        return result;
    }
}
  • 按理说,在设置用户信息之前第一次获取的值始终应该是 null,但我们要意识到,程序运行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池
  • 顾名思义,线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息
  • 为了更快地重现这个问题,我在配置文件中设置一下 Tomcat 的参数,把工作线程池最大线程数设置为 1,这样始终是同一个线程在处理请求:server.tomcat.max-threads=1
  • 运行程序后先让用户 1 来请求接口,可以看到第一和第二次获取到用户 ID 分别是 null 和 1,符合预期
  • 用户 2 来请求接口,这次就出现了 Bug,第一和第二次获取到用户 ID 分别是 1 和 2,显然第一次获取到了用户 1 的信息,原因就是 Tomcat 的线程池重用了线程
  • 因为线程的创建比较昂贵,所以 Web 服务器往往会使用线程池来处理请求,这就意味着线程会被重用。这时,使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据。如果在代码中使用了自定义的线程池,也同样会遇到这个问题

正确案例

  • 我们修正这段代码的方案是,在代码的 finally 代码块中,显式清除ThreadLocal 中的数据。这样一来,新的请求过来即使使用了之前的线程也不会获取到错误的用户信息了
@RestController
@RequestMapping("threadlocal")
public class ThreadLocalMisuseController {

    private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);

    @GetMapping("right")
    public Map right(@RequestParam("userId") Integer userId) {
        String before  = Thread.currentThread().getName() + ":" + currentUser.get();
        currentUser.set(userId);
        try {
            String after = Thread.currentThread().getName() + ":" + currentUser.get();
            Map result = new HashMap();
            result.put("before", before);
            result.put("after", after);
            return result;
        } finally {
            //在finally代码块中删除ThreadLocal中的数据,确保数据不串
            currentUser.remove();
        }
    }
}

1.2:CHM保证原子性读写线程安全

  • JDK 1.5 后推出的 ConcurrentHashMap,是一个高性能的线程安全的哈希表容器。因为ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的
  • 问题场景:有一个含 900 个元素的 Map,现在再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。开发人员误以为使用了 ConcurrentHashMap 就不会有线程安全问题,于是不加思索地写出了下面的代码:在每一个线程的代码逻辑中先通过 size 方法拿到当前元素数量,计算 ConcurrentHashMap 目前还需要补充多少元素,并在日志中输出了这个值,然后通过 putAll 方法把缺少的元素添加进去

代码案例

@RestController
@RequestMapping("concurrenthashmapmisuse")
@Slf4j
public class ConcurrentHashMapMisuseController {

    //线程个数
    private static int THREAD_COUNT = 10;
    //总元素数量
    private static int ITEM_COUNT = 1000;

    //帮助方法,用来获得一个指定元素数量模拟数据的ConcurrentHashMap
    private ConcurrentHashMap<String, Long> getData(int count) {
        return LongStream.rangeClosed(1, count)
                .boxed()
                .collect(Collectors.toConcurrentMap(i -> UUID.randomUUID().toString(), Function.identity(),
                        (o1, o2) -> o1, ConcurrentHashMap::new));
    }

    @GetMapping("wrong")
    public String wrong() throws InterruptedException {
        ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
        //初始900个元素
        log.info("init size:{}", concurrentHashMap.size());

        ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
        //使用线程池并发处理逻辑
        forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
            //查询还需要补充多少个元素
            int gap = ITEM_COUNT - concurrentHashMap.size();
            log.info("gap size:{}", gap);
            //补充元素
            concurrentHashMap.putAll(getData(gap));
        }));
        //等待所有任务完成
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);

        //最后元素个数会是1000吗?
        log.info("finish size:{}", concurrentHashMap.size());
        return "OK";
    }
}
  • 日志:
  • image.png

  • ConcurrentHashMap 这个篮子本身,可以确保多个工人在装东西进去时,不会相互影响干扰,但无法确保工人 A 看到还需要装 100 个桔子但是还未装的时候,工人 B 就看不到篮子中的桔子数量。更值得注意的是,你往这个篮子装 100 个桔子的操作不是原子性的,在别人看来可能会有一个瞬间篮子里有 964 个桔子,还需要补 36 个桔子。

  • 我们需要注意 ConcurrentHashMap 对外提供的方法或能力的限制:
    • 使用了 ConcurrentHashMap,不代表对它的多个操作之间的状态是一致的,是没有其他线程在操作它的,如果需要确保需要手动加锁
    • 诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下可能会反映ConcurrentHashMap 的中间状态。因此在并发情况下,这些方法的返回值只能用作参考,而不能用于流程控制。显然,利用 size 方法计算差异值,是一个流程控制
    • 诸如 putAll 这样的聚合方法也不能确保原子性,在 putAll 的过程中去获取数据可能会获取到部分数据
  • 代码的修改方案很简单,整段逻辑加锁即可
    @GetMapping("right")
    public String right() throws InterruptedException {
        ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
        log.info("init size:{}", concurrentHashMap.size());

        ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
        forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
            //下面的这段复合逻辑需要锁一下这个ConcurrentHashMap
            synchronized (concurrentHashMap) {
                int gap = ITEM_COUNT - concurrentHashMap.size();
                log.info("gap size:{}", gap);
                concurrentHashMap.putAll(getData(gap));
            }
        }));
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);

        log.info("finish size:{}", concurrentHashMap.size());
        return "OK";
    }
  • 到了这里,你可能又要问了,使用 ConcurrentHashMap 全程加锁,还不如使用普通的HashMap呢。其实不完全是这样。ConcurrentHashMap 提供了一些原子性的简单复合逻辑方法,用好这些方法就可以发挥其威力。

1.3:充分发挥ConcurrentHashMap性能

  • 使用 Map 来统计 Key 出现次数的场景,这个逻辑在业务代码中非常常见。
  • 使用 ConcurrentHashMap 来统计,Key 的范围是 10。使用最多 10 个并发,循环操作 1000 万次,每次操作累加随机的 Key。如果 Key 不存在的话,首次设置值为 1。
@RestController
@RequestMapping("concurrenthashmapperformance")
@Slf4j
public class ConcurrentHashMapPerformanceController {

    //循环次数
    private static int LOOP_COUNT = 10000000;
    //线程数量
    private static int THREAD_COUNT = 10;
    //元素数量
    private static int ITEM_COUNT = 10;

    /*
    测试normaluse和gooduse方法的性能
    */
    @GetMapping("good")
    public String good() throws InterruptedException {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start("normaluse");
        Map<String, Long> normaluse = normaluse();
        stopWatch.stop();
        //校验元素数量
        Assert.isTrue(normaluse.size() == ITEM_COUNT, "normaluse size error");
        //校验累计总数
        Assert.isTrue(normaluse.entrySet().stream()
                        .mapToLong(item -> item.getValue()).reduce(0, Long::sum) == LOOP_COUNT
                , "normaluse count error");
        stopWatch.start("gooduse");
        Map<String, Long> gooduse = gooduse();
        stopWatch.stop();
        Assert.isTrue(gooduse.size() == ITEM_COUNT, "gooduse size error");
        Assert.isTrue(gooduse.entrySet().stream()
                        .mapToLong(item -> item.getValue())
                        .reduce(0, Long::sum) == LOOP_COUNT
                , "gooduse count error");
        log.info(stopWatch.prettyPrint());
        return "OK";
    }

    /*
    我们吸取之前的教训,直接通过锁的方式锁住 Map,然后做判断、读取现在的累计值、加1、保存累加后值的逻辑。这段代码在功能上没有问题,但无法充分发挥ConcurrentHashMap 的威力,改进后的代码如下:gooduse方法
    */
    private Map<String, Long> normaluse() throws InterruptedException {
        ConcurrentHashMap<String, Long> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
        ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
        forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
                    //获得一个随机的Key
                    String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
                    synchronized (freqs) {
                        if (freqs.containsKey(key)) {
                            //Key存在则+1
                            freqs.put(key, freqs.get(key) + 1);
                        } else {
                            //Key不存在则初始化为1
                            freqs.put(key, 1L);
                        }
                    }
                }
        ));
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
        return freqs;
    }

    /*改进方法
    1:使用 ConcurrentHashMap 的原子性方法 computeIfAbsent 来做复合逻辑操作,判断Key 是否存在 Value,如果不存在则把 Lambda 表达式运行后的结果放入 Map 作为Value,也就是新创建一个 LongAdder 对象,最后返回 Value。
    2:由于 computeIfAbsent 方法返回的 Value 是 LongAdder,是一个线程安全的累加器,因此可以直接调用其 increment 方法进行累加。
    3:这样在确保线程安全的情况下达到极致性能,把之前 7 行代码替换为了 1 行
    */
    private Map<String, Long> gooduse() throws InterruptedException {
        ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
        ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
        forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
                    String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
                    //利用computeIfAbsent()方法来实例化LongAdder,然后利用LongAdder来+1
                    freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
                }
        ));
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
        //因为我们的Value是LongAdder而不是Long,所以需要做一次转换才能返回
        return freqs.entrySet().stream()
                .collect(Collectors.toMap(
                        e -> e.getKey(),
                        e -> e.getValue().longValue())
                );
    }
}
  • 测试结果
  • image.png

  • 可以看到,优化后的代码,相比使用锁来操作 ConcurrentHashMap 的方式,性能提升了 10 倍

  • 你可能会问,computeIfAbsent 为什么如此高效呢?答案就在源码最核心的部分,也就是 Java 自带的 Unsafe 实现的 CAS。它在虚拟机层面确保了写入数据的原子性,比加锁的效率高得多
staticfinal <K,V> booleancasTabAt(Node<K,V>[] tab, int i,Node<K,V> c,Node<K,V> v) 
{
  return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);    
}

1.4:CopyOnWrite读写性能

  • 在 Java 中,CopyOnWriteArrayList 虽然是一个线程安全的 ArrayList,但因为其实现方式是,每次修改数据时都会复制一份数据出来,所以有明显的适用场景,即读多写少或者说希望无锁读的场景

  • 如果我们要使用 CopyOnWriteArrayList,那一定是因为场景需要而不是因为足够酷炫。如果读写比例均衡或者有大量写操作的话,使用 CopyOnWriteArrayList 的性能会非常糟糕

  • 比较使用 CopyOnWriteArrayList 和普通加锁方式 ArrayList的读写性能。针对并发读和并发写分别写了一个测试方法,测试两者一

    定次数的写或读操作的耗时

@RestController
@RequestMapping("copyonwritelistmisuse")
@Slf4j
public class CopyOnWriteListMisuseController {

    //测试并发写的性能
    @GetMapping("write")
    public Map testWrite() {
        List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
        List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
        StopWatch stopWatch = new StopWatch();
        int loopCount = 100000;
        stopWatch.start("Write:copyOnWriteArrayList");
      
        //循环100000次并发往CopyOnWriteArrayList写入随机元素
        IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -> copyOnWriteArrayList.add(ThreadLocalRandom.current().nextInt(loopCount)));
        stopWatch.stop();
        stopWatch.start("Write:synchronizedList");
      
        //循环100000次并发往加锁的ArrayList写入随机元素
        IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -> synchronizedList.add(ThreadLocalRandom.current().nextInt(loopCount)));
        stopWatch.stop();
        log.info(stopWatch.prettyPrint());
        Map result = new HashMap();
        result.put("copyOnWriteArrayList", copyOnWriteArrayList.size());
        result.put("synchronizedList", synchronizedList.size());
        return result;
    }

    //帮助方法用来填充List
    private void addAll(List<Integer> list) {
        list.addAll(IntStream.rangeClosed(1, 1000000).boxed().collect(Collectors.toList()));
    }

    //测试并发读的性能
    @GetMapping("read")
    public Map testRead() {
        //创建两个测试对象
        List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
        List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
      
        //填充数据
        addAll(copyOnWriteArrayList);
        addAll(synchronizedList);
        StopWatch stopWatch = new StopWatch();
        int loopCount = 1000000;
        int count = copyOnWriteArrayList.size();
        stopWatch.start("Read:copyOnWriteArrayList");
      
        //循环1000000次并发从CopyOnWriteArrayList随机查询元素
        IntStream.rangeClosed(1, loopCount).parallel().forEach(__ -> copyOnWriteArrayList.get(ThreadLocalRandom.current().nextInt(count)));
        stopWatch.stop();
        stopWatch.start("Read:synchronizedList");
      
        //循环1000000次并发从加锁的ArrayList随机查询元素
        IntStream.range(0, loopCount).parallel().forEach(__ -> synchronizedList.get(ThreadLocalRandom.current().nextInt(count)));
        stopWatch.stop();
        log.info(stopWatch.prettyPrint());
        Map result = new HashMap();
        result.put("copyOnWriteArrayList", copyOnWriteArrayList.size());
        result.put("synchronizedList", synchronizedList.size());
        return result;
    }
}
  • 运行程序可以看到,大量写的场景(10 万次 add 操作),CopyOnWriteArray 几乎比同步的 ArrayList 慢一百倍

  • 而在大量读的场景下(100 万次 get 操作),CopyOnWriteArray 又比同步的 ArrayList快五倍以上

  • 为何在大量写的场景下,CopyOnWriteArrayList 会这么慢呢?以 add 方法为例,每次 add 时,都会用 Arrays.copyOf 创建一个新数

    组,频繁 add 时内存的申请释放消耗会很大

1.5:思考与讨论

  • 1:今天我们多次用到了 ThreadLocalRandom,你觉得是否可以把它的实例设置到静态变量中,在多线程情况下重用呢?
    • 不能。ThreadLocalRandom的用法是每个线程各用各的,官方文档说ThreadLocalRandom.current().nextX(…)这么用就不会导致在多线程之间共享
  • 2:ConcurrentHashMap 还提供了 putIfAbsent 方法,你能否说说computeIfAbsent 和 putIfAbsent 方法的区别?
    • 他们都是原子操作,都会根据key的存在情况做后续操作,putIfAbsent不会对value处理,computeIfAbsent的第二个参数是Function接口可做的更多

2:代码中的锁问题

2.1:锁和对象的层面

  • 除了没有分析清线程、业务逻辑和锁三者之间的关系随意添加无效的方法锁外,还有一种比较常见的错误是,没有理清楚锁和要保护的对象是否是一个层面的
  • 静态字段属于类,类级别的锁才能保护;而非静态字段属于类实例,实例级别的锁就可以保护
  • 在类 Data 中定义了一个静态的 int 字段 counter 和一个非静态的 wrong 方法,实现 counter 字段的累加操作。
class Data {
    @Getter
    private static int counter = 0;
    //类锁
    private static Object locker = new Object();

    //重置方法
    public static int reset() {
        counter = 0;
        return counter;
    }

    //错误加锁方式
    public synchronized void wrong() {
        counter++;
    }

    //正确加锁方式
    public void right() {
        synchronized (locker) {
            counter++;
        }
    }
}
@RestController
@RequestMapping("lockscope")
@Slf4j
public class LockScopeController {

    /*
    因为默认运行 100 万次,所以执行后应该输出 100 万,但页面输出的是 639242
    在非静态的 wrong 方法上加锁,只能确保多个线程无法执行同一个实例的 wrong 方法,却不能保证不会执行不同实例的 wrong 方法。而静态的 counter 在多个实例中共享,所以必然会出现线程安全问题
    */
    @GetMapping("wrong")
    public int wrong(@RequestParam(value = "count", defaultValue = "1000000") int count) {
        Data.reset();
        //多线程循环一定次数调用Data类不同实例的wrong方法
        IntStream.rangeClosed(1, count).parallel().forEach(i -> new Data().wrong());
        return Data.getCounter();
    }

    @GetMapping("right")
    public int right(@RequestParam(value = "count", defaultValue = "1000000") int count) {
        Data.reset();
        IntStream.rangeClosed(1, count).parallel().forEach(i -> new Data().right());
        return Data.getCounter();
    }

    @GetMapping("wrong2")
    public String wrong2() {
        Interesting interesting = new Interesting();
        new Thread(() -> interesting.add()).start();
        new Thread(() -> interesting.compare()).start();
        return "OK";
    }

    @GetMapping("right2")
    public String right2() {
        Interesting interesting = new Interesting();
        new Thread(() -> interesting.add()).start();
        new Thread(() -> interesting.compareRight()).start();
        return "OK";
    }
}

2.2:考虑加锁粒度和场景

  • 即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁
  • 比如,在业务代码中,有一个 ArrayList 因为会被多个线程操作而需要保护,又有一段比较耗时的操作(代码中的 slow 方法)不涉及线程安全问题,应该如何加锁呢?
  • 错误的做法是,给整段业务逻辑加锁,把 slow 方法和操作 ArrayList 的代码同时纳入synchronized 代码块;
  • 更合适的做法是,把加锁的粒度降到最低,只在操作 ArrayList 的时候给这个 ArrayList 加锁
@RestController
@RequestMapping("lockgranularity")
@Slf4j
public class LockGranularityController {

    private List<Integer> data = new ArrayList<>();

    //不涉及共享资源的慢方法
    private void slow() {
        try {
            TimeUnit.MILLISECONDS.sleep(10);
        } catch (InterruptedException e) {
        }
    }

    //错误的加锁方法
    @GetMapping("wrong")
    public int wrong() {
        long begin = System.currentTimeMillis();
        IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
            //加锁粒度太粗了
            synchronized (this) {
                slow();
                data.add(i);
            }
        });
        log.info("took:{}", System.currentTimeMillis() - begin);
        return data.size();
    }

    //正确的加锁方法
    @GetMapping("right")
    public int right() {
        long begin = System.currentTimeMillis();
        IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
            slow();
            //只对List加锁
            synchronized (data) {
                data.add(i);
            }
        });
        log.info("took:{}", System.currentTimeMillis() - begin);
        return data.size();
    }

}
  • 执行这段代码,同样是 1000 次业务操作,正确加锁的版本耗时 1.4 秒,而对整个业务逻辑加锁的话耗时 11 秒。
  • 如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁
  • 对于读写比例差异明显的场景,考虑使用 ReentrantReadWriteLock 细化区分读写锁,来提高性能
  • 如果你的 JDK 版本高于 1.8、共享资源的冲突概率也没那么大的话,考虑使用StampedLock 的乐观读的特性,进一步提高性能
  • JDK 里 ReentrantLock 和 ReentrantReadWriteLock 都提供了公平锁的版本,在没有明确需求的情况下不要轻易开启公平锁特性,在任务很轻的情况下开启公平锁可能会让性能下降上百倍

2.3:小心死锁

  • 首先,定义一个商品类型,包含商品名、库存剩余和商品的库存锁三个属性,每一种商品默认库存 1000 个;然后,初始化 10 个这样的商品对象来模拟商品清单
  • 随后,写一个方法模拟在购物车进行商品选购,每次从商品清单(items 字段)中随机选购三个商品(为了逻辑简单,我们不考虑每次选购多个同类商品的逻辑,购物车中不体现商品数量)
  • 下单代码如下:先声明一个 List 来保存所有获得的锁,然后遍历购物车中的商品依次尝试获得商品的锁,最长等待 10 秒,获得全部锁之后再扣减库存;如果有无法获得锁的情况则解锁之前获得的所有锁,返回 false 下单失败
  • 写一段代码测试这个下单操作。模拟在多线程情况下进行 100 次创建购物车和下单操作,最后通过日志输出成功的下单次数、总剩余的商品个数、100 次下单耗时,以及下单完成后的商品库存明细
@RestController
@RequestMapping("deadlock")
@Slf4j
public class DeadLockController {

    private ConcurrentHashMap<String, Item> items = new ConcurrentHashMap<>();

    //1:初始化 10 个这样的商品对象来模拟商品清单
    public DeadLockController() {
        IntStream.range(0, 10).forEach(i -> items.put("item" + i, new Item("item" + i)));
    }
  
    //2:创建购物车,随机选三个商品
    private List<Item> createCart() {
        return IntStream.rangeClosed(1, 3)
                .mapToObj(i -> "item" + ThreadLocalRandom.current().nextInt(items.size()))
                .map(name -> items.get(name)).collect(Collectors.toList());
    }

    //3:创建订单
    private boolean createOrder(List<Item> order) {
        //先声明一个 List 来保存所有获得的锁
        List<ReentrantLock> locks = new ArrayList<>();

        for (Item item : order) {
            try {
                //尝试获取锁,等待10s
                if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
                    locks.add(item.lock);
                } else {
                    //只要有一个商品没获取到锁就释放所有商品的锁
                    locks.forEach(ReentrantLock::unlock);
                    return false;
                }
            } catch (InterruptedException e) {
            }
        }
        try {
            //获取到所有商品锁之后再开始扣减库存
            order.forEach(item -> item.remaining--);
        } finally {
            //释放锁
            locks.forEach(ReentrantLock::unlock);
        }
        return true;
    }

/*
执行后发现成功下单次数达不到100次;使用 JDK 自带的 VisualVM 工具来跟踪一下,重新执行方法后不久就可以看到,线程 Tab中提示了死锁问题,根据提示点击右侧线程 Dump 按钮进行线程抓取操作:见代码下方图

发生死锁原因:随机添加了三种商品,假设一个购物车中的商品是 item1 和 item2,另一个购物车中的商品是 item2 和 item1,一个线程先获取到了item1 的锁,同时另一个线程获取到了 item2 的锁,然后两个线程接下来要分别获取item2 和 item1 的锁,这个时候锁已经被对方获取了,只能相互等待一直到 10 秒超时。
*/
    @GetMapping("wrong")
    public long wrong() {
        long begin = System.currentTimeMillis();
        //并发进行100次下单操作,统计成功次数
        long success = IntStream.rangeClosed(1, 100).parallel()
                .mapToObj(i -> {
                    List<Item> cart = createCart();
                    return createOrder(cart);
                })
                .filter(result -> result)
                .count();
        log.info("success:{} totalRemaining:{} took:{}ms items:{}",
                success,
                items.entrySet().stream().map(item -> item.getValue().remaining).reduce(0, Integer::sum),
                System.currentTimeMillis() - begin, items);
        return success;
    }

  /*
  死锁解决方案:为购物车中的商品排一下序,让所有的线程一定是先获取item1 的锁然后获取 item2 的锁,就不会有问题了
  */
    @GetMapping("right")
    public long right() {
        long begin = System.currentTimeMillis();
        long success = IntStream.rangeClosed(1, 100).parallel()
                .mapToObj(i -> {
                    //对createCart 获得的购物车按照商品名进行排序即可
                    List<Item> cart = createCart().stream()
                            .sorted(Comparator.comparing(Item::getName))
                            .collect(Collectors.toList());
                    return createOrder(cart);
                })
                .filter(result -> result)
                .count();
        log.info("success:{} totalRemaining:{} took:{}ms items:{}",
                success,
                items.entrySet().stream().map(item -> item.getValue().remaining).reduce(0, Integer::sum),
                System.currentTimeMillis() - begin, items);
        return success;
    }

  
    //定义商品
    @Data
    @RequiredArgsConstructor
    static class Item {
        final String name; //商品名
        int remaining = 1000; //剩余库存
        @ToString.Exclude   //ToString不包含这个字段
        ReentrantLock lock = new ReentrantLock(); //锁
    }
}
  • 排查死锁: image.png
  • 如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。
  • 如果你的业务代码涉及复杂的锁操作,强烈建议 Mock 相关外部接口或数据库操作后对应用代码进行压测,通过压测排除锁误用带来的性能问题和死锁问题

2.4:思考与讨论

  • 1:开启了一个线程无限循环来跑一些任务,有一个 bool 类型的变量来控制循环的退出,默认为 true 代表执行,一段时间后主线程将这个变量设置为了false。如果这个变量不是 volatile 修饰的,子线程可以退出吗?你能否解释其中的原因呢?

    • 不能退出。必须加volatile或者使用AtomicBoolean/AtomicReference等也行,因为volatile保证了可见性。改完后会强制让工作内存失效。去主存拿。
  • 2:一是加锁和释放没有配对的问题,二是锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两种问题吗?

    • 1.加群解锁没有配对可以用一些代码质量工具协助排插,如Sonar,集成到ide和代码仓库,在编码阶段发现,加上超时自动释放,避免长期占有锁

    • 2.避免超时,单独开一个线程给锁延长有效期。比如设置锁有效期30s,有个线程每隔10s重新设置下锁的有效期。
    • 3.避免重复,业务上增加一个标记是否被处理的字段。或者开一张新表,保存已经处理过的流水号。

3:如何用好线程池

3.1:手动声明线程池

  • Java 中的 Executors 类定义了一些快捷的工具方法,来帮助我们快速创建线程池。
  • 《阿里巴巴 Java 开发手册》中提到,禁止使用这些方法来创建线程池,而应该手动 new ThreadPoolExecutor 来创建线程池。
  • 最典型的就是 newFixedThreadPool 和 newCachedThreadPool,可能因为资源耗尽导致OOM 问题。
@RestController
@RequestMapping("threadpooloom")
@Slf4j
public class ThreadPoolOOMController {

    /*
    线程池监控,打印线程池信息:每秒输出一次线程池的基本内部信息,包括线程数、活跃线程数、完成了多少任务,以及队列中还有多少积压任务等信息
    */
    private void printStats(ThreadPoolExecutor threadPool) {
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
            log.info("=========================");
            log.info("Pool Size: {}", threadPool.getPoolSize());
            log.info("Active Threads: {}", threadPool.getActiveCount());
            log.info("Number of Tasks Completed: {}", threadPool.getCompletedTaskCount());
            log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());

            log.info("=========================");
        }, 0, 1, TimeUnit.SECONDS);
    }

  /*
  1:看一下 newFixedThreadPool 为什么可能会出现 OOM 的问题
  2:初始化一个单线程的 FixedThreadPool,循环 1 亿次向线程池提交任务,每个任务都会创建一个比较大的字符串然后休眠一小时
  3:执行程序后不久,日志中就出现了如下 OOM
  4:翻看 newFixedThreadPool 方法的源码不难发现,线程池的工作队列直接new了一个LinkedBlockingQueue,而默认构造方法的 LinkedBlockingQueue 是一个Integer.MAX_VALUE 长度的队列,可以认为是无界的
  5:虽然使用 newFixedThreadPool 可以把工作线程控制在固定的数量上,但任务队列是无界的。如果任务较多并且执行较慢的话,队列可能会快速积压,撑爆内存导致 OOM。
  */
    @GetMapping("oom1")
    public void oom1() throws InterruptedException {

        ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);
        //打印线程信息
        printStats(threadPool);
        for (int i = 0; i < 100000000; i++) {
            threadPool.execute(() -> {
                String payload = IntStream.rangeClosed(1, 1000000)
                        .mapToObj(__ -> "a")
                        .collect(Collectors.joining("")) + UUID.randomUUID().toString();
                try {
                    TimeUnit.HOURS.sleep(1);
                } catch (InterruptedException e) {
                }
                log.info(payload);
            });
        }

        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);
    }

  /*
    1:改为使用 newCachedThreadPool 方法来获得线程池。程序运行不久后,同样看到了如下 OOM 异常
    2:这次 OOM 的原因是无法创建线程,翻看 newCachedThreadPool 的源码可以看到,这种线程池的最大线程数是 Integer.MAX_VALUE,可以认为是没有上限的,而其工作队列 SynchronousQueue 是一个没有存储空间的阻塞队列
    3:由于我们的任务需要 1 小时才能执行完成,大量的任务进来后会创建大量的线程。我们知道线程是需要分配一定的内存空间作为线程栈的,比如 1MB,因此无限制创建线程必然会导致 OOM
  */
    @GetMapping("oom2")
    public void oom2() throws InterruptedException {

        ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newCachedThreadPool();
        printStats(threadPool);
        for (int i = 0; i < 100000000; i++) {
            threadPool.execute(() -> {
                String payload = UUID.randomUUID().toString();
                try {
                    TimeUnit.HOURS.sleep(1);
                } catch (InterruptedException e) {
                }
                log.info(payload);
            });
        }
        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);
    }

  /*
  观察一下线程池的基本特性
  60 秒后页面输出了 17,有 3 次提交失败了
  */
    @GetMapping("right")
    public int right() throws InterruptedException {
        //使用一个计数器跟踪完成的任务数
        AtomicInteger atomicInteger = new AtomicInteger();
        //创建一个具有2个核心线程、5个最大线程,使用容量为10的ArrayBlockingQueue阻塞队列作为工作队列
        //借助了Jodd 类库的 ThreadFactoryBuilder 方法来构造一个线程工厂,实现线程池线程的自定义命名
        //使用默认的 AbortPolicy 拒绝策略,也就是任务添加到线程池失败会抛出 RejectedExecutionException
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
                2, 5,
                5, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(10),
                new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get(),
                new ThreadPoolExecutor.AbortPolicy());
        //threadPool.allowCoreThreadTimeOut(true);
        printStats(threadPool);
      
        //每隔1秒提交一次,一共提交20次任务
        IntStream.rangeClosed(1, 20).forEach(i -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            int id = atomicInteger.incrementAndGet();
            try {
                threadPool.submit(() -> {
                    log.info("{} started", id);
                    try {
                        //每个任务耗时10秒
                        TimeUnit.SECONDS.sleep(10);
                    } catch (InterruptedException e) {
                    }
                    log.info("{} finished", id);
                });
            } catch (Exception ex) {
                //提交出现异常的话,打印出错信息并为计数器减一
                log.error("error submitting task {}", id, ex);
                atomicInteger.decrementAndGet();
            }
        });

        TimeUnit.SECONDS.sleep(60);
        return atomicInteger.intValue();
    }
}
  • 生产案例:用户注册后,我们调用一个外部服务去发送短信,发送短信接口正常时可以在 100 毫秒内响应,TPS 100 的注册量,

    CachedThreadPool 能稳定在占用 10 个左右线程的情况下满足需求。在某个时间点,外部短信服务不可用了,我们调用这个服务的超时又特别长, 比如 1 分钟,1 分钟可能就进来了 6000 用户,产生 6000 个发送短信的任务,需要 6000 个线程,没多久就因为无法创建线程导致了 OOM,整个应用程序崩溃。

  • 不建议使用 Executors 提供的两种快捷的线程池,原因如下:

    • 需要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。
    • 任何时候,都应该为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量 CPU、线程执行出现异常等问题时,我们往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题。
    • 除了建议手动声明线程池以外,我还建议用一些监控手段来观察线程池的状态。
  • 总结出线程池默认的工作行为:

    • 不会初始化 corePoolSize 个线程,有任务来了才创建工作线程;
    • 当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;
    • 当工作队列满了后扩容线程池,一直到线程个数达到 maximumPoolSize 为止;
    • 如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理;
    • 当线程数大于核心线程数时,线程等待 keepAliveTime 后还是没有任务需要处理的话,收缩线程到核心线程数。
  • 我们也可以通过一些手段来改变这些默认工作行为,比如:

    • 声明线程池后立即调用 prestartAllCoreThreads 方法,来启动所有核心线程
    • 传入 true 给 allowCoreThreadTimeOut 方法,来让线程池在空闲的时候同样回收核心线程。
  • 我们有没有办法让线程池更激进一点,优先开启更多的线程,而把队列当成一个后备方案呢?

    • 由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们是否可以重写队列的 offer 方法,造成这个队列已满的假象呢?
    • 由于我们 Hack 了队列,在达到了最大线程后势必会触发拒绝策略,那么能否实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列呢?

3.2:正确复用线程池

@RestController
@RequestMapping("threadpoolreuse")
@Slf4j
public class ThreadPoolReuseController {

  /*
  
  */
    @GetMapping("wrong")
    public String wrong() throws InterruptedException {
        ThreadPoolExecutor threadPool = ThreadPoolHelper.getThreadPool();
        IntStream.rangeClosed(1, 10).forEach(i -> {
            threadPool.execute(() -> {
                String payload = IntStream.rangeClosed(1, 1000000)
                        .mapToObj(__ -> "a")
                        .collect(Collectors.joining("")) + UUID.randomUUID().toString();
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                }
                log.debug(payload);
            });
        });
        return "OK";
    }

    static class ThreadPoolHelper {
      
        //使用一个静态字段来存放线程池的引用,并手动实现一个线程池
        private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                10, 50,
                2, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1000),
                new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get());

        /*
        每次都创建一个线程池
        业务代码的一次业务操作会向线程池提交多个慢任务,这样执行一次业务操作就会开启多个线程。如果业务操作并发量较大的话,的确有可能一下子开启几千个线程。
        */
        public static ThreadPoolExecutor getThreadPool() {
            return (ThreadPoolExecutor) Executors.newCachedThreadPool();
        }

        //正确返回线程池
        static ThreadPoolExecutor getRightThreadPool() {
            return threadPoolExecutor;
        }
    }

}

3.3:线程池的混用策略

  • 要根据任务的“轻重缓急”来指定线程池的核心参数,包括线程数、回收策略和任务队列:

    • 对于执行比较慢、数量不大的 IO 任务,或许要考虑更多的线程数,而不需要太大的队列

    • 对于吞吐量较大的计算型任务,线程数量不宜过多,可以是 CPU 核数或核数 *2(理由是,线程一定调度到某个 CPU 进行执行,如果任务本身是 CPU 绑定的任务,那么过多的线程只会增加线程切换的开销,并不能提升吞吐量),但可能需要较长的队列来做

      缓冲。

@RestController
@RequestMapping("threadpoolmixuse")
@Slf4j
public class ThreadPoolMixuseController {

    /*
    2 个核心线程,最大线程也是 2,使用了容量为100 的 ArrayBlockingQueue 作为工作队列,使用了 CallerRunsPolicy 拒绝策略
    */
    private static ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
            2, 2,
            1, TimeUnit.HOURS,
            new ArrayBlockingQueue<>(100),
            new ThreadFactoryBuilder().setNameFormat("batchfileprocess-threadpool-%d").get(),
            new ThreadPoolExecutor.CallerRunsPolicy());


    /*
    独立的线程池来做这样的“计算任务”
    */
    private static ThreadPoolExecutor asyncCalcThreadPool = new ThreadPoolExecutor(
            200, 200,
            1, TimeUnit.HOURS,
            new ArrayBlockingQueue<>(1000),
            new ThreadFactoryBuilder().setNameFormat("asynccalc-threadpool-%d").get());

    /*
    打印线程池状态
    */
    private void printStats(ThreadPoolExecutor threadPool) {
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
            log.info("=========================");
            log.info("Pool Size: {}", threadPool.getPoolSize());
            log.info("Active Threads: {}", threadPool.getActiveCount());
            log.info("Number of Tasks Completed: {}", threadPool.getCompletedTaskCount());
            log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());

            log.info("=========================");
        }, 0, 1, TimeUnit.SECONDS);
    }

    /*
    向线程池提交一个简单的任务,这个任务只是休眠 10 毫秒没有其他逻辑
    */
    private Callable<Integer> calcTask() {
        return () -> {
            TimeUnit.MILLISECONDS.sleep(10);
            return 1;
        };
    }

    /*
    我们使用 wrk 工具对这个接口进行一个简单的压测,可以看到 TPS 为 75,性能的确非常差。
    执行 IO 任务的线程池使用的是CallerRunsPolicy 策略,所以直接使用这个线程池进行异步计算的话,当线程池饱和的时候,计算任务会在执行 Web 请求的 Tomcat 线程执行,这时就会进一步影响到其他同步处理的线程,甚至造成整个应用程序崩溃
    */
    @GetMapping("wrong")
    public int wrong() throws ExecutionException, InterruptedException {
        return threadPool.submit(calcTask()).get();
    }

    /*
    使用单独的线程池改造代码后再来测试一下性能,TPS 提高到了 1727
    */
    @GetMapping("right")
    public int right() throws ExecutionException, InterruptedException {
        return asyncCalcThreadPool.submit(calcTask()).get();
    }

    /*
    模拟一下文件批处理的代码,在程序启动后通过一个线程开启死循环逻辑,不断向线程池提交任务,任务的逻辑是向一个文件中写入大量的数据
    */
    @PostConstruct
    public void init() {
        printStats(threadPool);

        new Thread(() -> {
            //模拟需要写入的大量数据
            String payload = IntStream.rangeClosed(1, 1_000_000)
                    .mapToObj(__ -> "a")
                    .collect(Collectors.joining(""));
            while (true) {
                threadPool.execute(() -> {
                    try {
                        //每次都是创建并写入相同的数据到相同的文件
                        Files.write(Paths.get("demo.txt"), Collections.singletonList(LocalTime.now().toString() + ":" + payload), UTF_8, CREATE, TRUNCATE_EXISTING);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    log.info("batch file processing done");
                });
            }
        }).start();
    }

}
  • 补充一个坑:Java 8 的 parallel stream 功能,可以让我们很方便地并行处理集合中的元素,其背后是共享同一个 ForkJoinPool,默认并行度是CPU 核数 -1。对于 CPU 绑定的任务来说,使用这样的配置比较合适,但如果集合操作涉及同步 IO 操作的话(比如数据库操作、外部服务调用等),建议自定义一个ForkJoinPool(或普通线程池)。

3.4:思考与讨论

  • 1:或许一个激进创建线程的弹性线程池更符合我们的需求,你能给出相关的实现吗?实现后再测试一下,是否所有的任务都可以正常处理完成呢?
  • 2:改进了 ThreadPoolHelper 使其能够返回复用的线程池。如果我们不小心每次都创建了这样一个自定义的线程池(10 核心线程,50 最大线程,2 秒回收的),反复执行测试接口线程,最终可以被回收吗?会出现 OOM 问题吗?
    • 不会被回收且很快就会OOM了,因为每次请求都新建线程池,每个线程池的核心数都是10, 虽然自定义线程池设置2秒回收,但是没超过线程池核心数10是不会被回收的, 不间断的请求过来导致创建大量线程,最终OOM。可以设allowCoreThreadTimeOut参数让核心线程也可以回收。

4:如何用好连接池

  • 连接池一般对外提供获得连接、归还连接的接口给客户端使用,并暴露最小空闲连接数、最大连接数等可配置参数,在内部则实现连接建立、连接心跳保持、连接管理、空闲连接回收、连接可用性检测等功能。

image.png

4.1:客户端 SDK 是否基于连接池(jedis为例)

  • 在使用三方客户端进行网络通信时,我们首先要确定客户端 SDK 是否是基于连接池技术实现的
  • TCP 是面向连接的基于字节流的协议:
    • 面向连接,意味着连接需要先创建再使用,创建连接的三次握手有一定开销
    • 基于字节流,意味着字节是发送数据的最小单元,TCP 协议本身无法区分哪几个字节是完整的消息体,也无法感知是否有多个客户端在使用同一个 TCP 连接,TCP 只是一个读写数据的管道。
  • 如果客户端 SDK 没有使用连接池,而直接是 TCP 连接,那么就需要考虑每次建立 TCP 连接的开销,并且因为 TCP 基于字节流,在多线程的情况下对同一连接进行复用,可能会产生线程安全问题。

  • 涉及 TCP 连接的客户端 SDK,对外提供 API 的三种方式:

    • 连接池和连接分离的 API:有一个 XXXPool 类负责连接池实现,先从其获得连接XXXConnection,然后用获得的连接进行服务端请求,完成后使用者需要归还连接。通常,XXXPool 是线程安全的,可以并发获取和归还连接,而 XXXConnection 是非线程

      安全的。对应到连接池的结构示意图中,XXXPool 就是右边连接池那个框,左边的客户端是我们自己的代码

    • 内部带有连接池的 API:对外提供一个 XXXClient 类,通过这个类可以直接进行服务端请求;这个类内部维护了连接池,SDK 使用者无需考虑连接的获取和归还问题。一般而言,XXXClient 是线程安全的。对应到连接池的结构示意图中,整个 API 就是蓝色框包裹的部分

    • 非连接池的 API:一般命名为 XXXConnection,以区分其是基于连接池还是单连接的,而不建议命名为 XXXClient 或直接是 XXX。直接连接方式的 API 基于单一连接,每次使用都需要创建和断开连接,性能一般,且通常不是线程安全的。对应到连接池的结构示意图中,这种形式相当于没有右边连接池那个框,客户端直接连接服务端创建连接

  • 明确了 SDK 连接池的实现方式后,我们就大概知道了使用 SDK 的最佳实践:
    • 如果是分离方式,那么连接池本身一般是线程安全的,可以复用。每次使用需要从连接池获取连接,使用后归还,归还的工作由使用者负责
    • 如果是内置连接池,SDK 会负责连接的获取和归还,使用的时候直接复用客户端
    • 如果 SDK 没有实现连接池(大多数中间件、数据库的客户端 SDK 都会支持连接池),那通常不是线程安全的,而且短连接的方式性能不会很高,使用的时候需要考虑是否自己封装一个连接池
  • 以 Java 中用于操作 Redis 最常见的库 Jedis 为例,从源码角度分析下 Jedis类到底属于哪种类型的 API,直接在多线程环境下复用一个连接会产生什么问题,以及如何用最佳实践来修复这个问题
@RestController
@RequestMapping("jedismisreuse")
@Slf4j
public class JedisMisreuseController {

    private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);

    /*
    向 Redis 初始化 2 组数据,Key=a、Value=1,Key=b、Value=2
    */
    @PostConstruct
    public void init() {
        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            Assert.isTrue("OK".equals(jedis.set("a", "1")), "set a = 1 return OK");
            Assert.isTrue("OK".equals(jedis.set("b", "2")), "set b = 2 return OK");
        }
      
        //通过 shutdownhook,在程序退出之前关闭 JedisPool
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            jedisPool.close();
        }));
    }

    /*
    启动两个线程,共享操作同一个 Jedis 实例,每一个线程循环 1000 次,分别读取Key 为 a 和 b 的 Value,判断是否分别为 1 和 2
    执行程序多次,可以看到日志中出现了各种奇怪的异常信息,有的是读取 Key 为 b 的Value 读取到了 1,有的是流非正常结束,还有的是连接关闭异常
    通过查看jedis源码得知:Jedis 继承了 BinaryJedis,BinaryJedis 中保存了单个 Client 的实例,Client最终继承了 Connection,Connection 中保存了单个 Socket 的实例,和 Socket 对应的两个读写流。因此,一个 Jedis 对应一个 Socket 连接
    BinaryClient 封装了各种 Redis 命令,其最终会调用基类 Connection 的方法,使用Protocol 类发送命令。
    我们在多线程环境下复用 Jedis 对象,其实就是在复用 RedisOutputStream。如果多个线程在执行操作,那么既无法确保整条命令以一个原子操作写入 Socket,也无法确保写入后、读取前没有其他数据写到远端
    */
    @GetMapping("/wrong")
    public void wrong() throws InterruptedException {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                String result = jedis.get("a");
                if (!"1".equals(result)) {
                    log.warn("Expect a to be 1 but found {}", result);
                    return;
                }
            }
        }).start();
        new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                String result = jedis.get("b");
                if (!"2".equals(result)) {
                    log.warn("Expect b to be 2 but found {}", result);
                    return;
                }
            }
        }).start();
        TimeUnit.SECONDS.sleep(5);
    }

  /*
  使用 Jedis 提供的另一个线程安全的类 JedisPool 来获得 Jedis 的实例。
  JedisPool 可以声明为 static 在多个线程之间共享,扮演连接池的角色。使用时,按需使用try-with-resources 模式从 JedisPool 获得和归还 Jedis 实例
  此外,我们最好通过 shutdownhook,在程序退出之前关闭 JedisPool
  看一下 Jedis 类 close 方法的实现可以发现,如果 Jedis 是从连接池获取的话,那么 close方法会调用连接池的 return 方法归还连接
  如果不是,则直接关闭连接,其最终调用 Connection 类的 disconnect 方法来关闭 TCP连接
  */
    @GetMapping("/right")
    public void right() throws InterruptedException {

        new Thread(() -> {
            try (Jedis jedis = jedisPool.getResource()) {
                for (int i = 0; i < 1000; i++) {
                    String result = jedis.get("a");
                    if (!"1".equals(result)) {
                        log.warn("Expect a to be 1 but found {}", result);
                        return;
                    }
                }
            }
        }).start();
        new Thread(() -> {
            try (Jedis jedis = jedisPool.getResource()) {
                for (int i = 0; i < 1000; i++) {
                    String result = jedis.get("b");
                    if (!"2".equals(result)) {
                        log.warn("Expect b to be 2 but found {}", result);
                        return;
                    }
                }
            }
        }).start();
        TimeUnit.SECONDS.sleep(5);

    }

    @GetMapping("timeout")
    public String timeout(@RequestParam("waittimeout") int waittimeout,
                          @RequestParam("conntimeout") int conntimeout) {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(1);
        config.setMaxWaitMillis(waittimeout);
        try (JedisPool jedisPool = new JedisPool(config, "127.0.0.1", 6379, conntimeout);
             Jedis jedis = jedisPool.getResource()) {
            return jedis.set("test", "test");
        }
    }
}
  • 可以看到,Jedis 可以独立使用,也可以配合连接池使用,这个连接池就是 JedisPool
  • JedisPool 的 getResource 方法在拿到 Jedis 对象后,将自己设置为了连接池。连接池JedisPool,继承了 JedisPoolAbstract,而后者继承了抽象类 Pool,Pool 内部维护了Apache Common 的通用池 GenericObjectPool。JedisPool 的连接池就是基于GenericObjectPool 的
  • Jedis 的 API 实现是我们说的三种类型中的第一种,也就是连接池和连接分离的 API,JedisPool 是线程安全的连接池,Jedis 是非线程安全的单一连接

4.2:连接池务必确保复用(HTTPClient为例)

  • 池一定是用来复用的,否则其使用代价会比每次创建单一对象更大
  • 大多数的连接池都有闲置超时的概念。连接池会检测连接的闲置时间,定期回收闲置的连接,把活跃连接数降到最低(闲置)连接的配置值,减轻服务端的压力。一般情况下,闲置连接由独立线程管理,启动了空闲检测的连接池相当于还会启动一个线程。此外,有些连接池还需要独立线程负责连接保活等功能。因此,启动一个连接池相当于启动了 N 个线程。除了使用代价,连接池不释放,还可能会引起线程泄露。
@RestController
@RequestMapping("httpclientnotreuse")
@Slf4j
public class HttpClientNotReuseController {


    private static CloseableHttpClient httpClient = null;

    /*
    复用方式很简单,你可以把 CloseableHttpClient 声明为 static,只创建一次,并且在JVM 关闭之前通过 addShutdownHook 钩子关闭连接池,在使用的时候直接使用CloseableHttpClient 即可,无需每次都创建
    */
    static {
        httpClient = HttpClients.custom().setMaxConnPerRoute(1).setMaxConnTotal(1).evictIdleConnections(60, TimeUnit.SECONDS).build();

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                httpClient.close();
            } catch (IOException ignored) {
            }
        }));
    }

    /*
    创建一个 CloseableHttpClient,设置使用PoolingHttpClientConnectionManager 连接池并启用空闲连接驱逐策略,最大空闲时间为 60 秒,然后使用这个连接来请求一个会返回 OK 字符串的服务端接口
    访问这个接口几次后查看应用线程情况,可以看到有大量叫作 Connection evictor 的线程,且这些线程不会销毁
    好在有了空闲连接回收的策略,60 秒之后连接处于 CLOSE_WAIT 状态,最终彻底关闭
    这 2 点证明,CloseableHttpClient 属于第二种模式,即内部带有连接池的 API,其背后是连接池,最佳实践一定是复用
    */
    @GetMapping("wrong1")
    public String wrong1() {
        CloseableHttpClient client = HttpClients.custom()
                .setConnectionManager(new PoolingHttpClientConnectionManager())
                .evictIdleConnections(60, TimeUnit.SECONDS).build();
        try (CloseableHttpResponse response = client.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) {
            return EntityUtils.toString(response.getEntity());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return null;
    }

    /*
    修复之前按需创建 CloseableHttpClient 的代码,每次用完之后确保连接池可以关闭
    压测 60 秒,每次创建连接池的 QPS 是 337
    每次都是新的 TCP 连接
    */
    @GetMapping("wrong2")
    public String wrong2() {
        try (CloseableHttpClient client = HttpClients.custom()
                .setConnectionManager(new PoolingHttpClientConnectionManager())
                .evictIdleConnections(60, TimeUnit.SECONDS).build();
             CloseableHttpResponse response = client.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) {
            return EntityUtils.toString(response.getEntity());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return null;
    }

    /*
    使用static的httpClient
    压测 60 秒,复用连接池的 QPS 是 2022
    第二次 HTTP 请求 #41的客户端端口 61468 和第一次连接 #23 的端口是一样的
    只有 TCP 连接闲置超过 60 秒后才会断开,连接池会新建连接
    */
    @GetMapping("right")
    public String right() {
        try (CloseableHttpResponse response = httpClient.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) {
            return EntityUtils.toString(response.getEntity());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return null;
    }

    @GetMapping("/test")
    public String test() {
        return "OK";
    }
}

4.3:合理配置连接池参数

  • 为方便根据容量规划设置连接处的属性,连接池提供了许多参数,包括最小(闲置)连接、最大连接、闲置连接生存时间、连接生存时间等;其中,最重要的参数是最大连接数,它决定了连接池能使用的连接数量上限,达到上限后,新来的请求需要等待其他请求释放连接
  • 但,最大连接数不是设置得越大越好。如果设置得太大,不仅仅是客户端需要耗费过多的资源维护连接,更重要的是由于服务端对应的是多个客户端,每一个客户端都保持大量的连接,会给服务端带来更大的压力。这个压力又不仅仅是内存压力,可以想一下如果服务端的网络模型是一个 TCP 连接一个线程,那么几千个连接意味着几千个线程,如此多的线程会造成大量的线程切换开销。
  • 当然,连接池最大连接数设置得太小,很可能会因为获取连接的等待时间太长,导致吞吐量低下,甚至超时无法获取连接。
  • 模拟下压力增大导致数据库连接池打满的情况,来实践下如何确认连接池的使用情况,以及有针对性地进行参数优化。
  • controller
@RestController
@RequestMapping("improperdatasourcepoolsize")
@Slf4j
public class ImproperDataSourcePoolSizeController {
    @Autowired
    private UserService userService;

    @GetMapping("test")
    public Object test() {
        return userService.register();
    }
}
  • service
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    /*
    定义一个用户注册方法,通过 @Transactional 注解为方法开启事务。其中包含了500 毫秒的休眠,一个数据库事务对应一个 TCP 连接,所以 500 多毫秒的时间都会占用数据库连接
    */
    @Transactional
    public User register() {
        User user = new User();
        user.setName("new-user-" + System.currentTimeMillis());
      
        //数据库保存操作
        userRepository.save(user);
        try {
            TimeUnit.MILLISECONDS.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return user;
    }

}
  • 其实,看到错误日志后再调整已经有点儿晚了。更合适的做法是,对类似数据库连接池的重要资源进行持续检测,并设置一半的使用量作为报警阈值,出现预警后及时扩容
  • 这里要强调的是,修改配置参数务必验证是否生效,并且在监控系统中确认参数是否生效、是否合理。之所以要“强调”,是因为这里有坑

5:HTTP调用的超时,重试与并发

5.1:连接超时与读取超时

  • 对于 HTTP 调用,虽然应用层走的是 HTTP 协议,但网络层面始终是 TCP/IP 协议
  • TCP/IP 是面向连接的协议,在传输数据之前需要建立连接。几乎所有的网络框架都会提供这么两个超时参数:
    • 连接超时参数 ConnectTimeout,让用户配置建连阶段的最长等待时间
    • 读取超时参数 ReadTimeout,用来控制从 Socket 上读取数据的最长等待时间
  • 连接超时参数和连接超时的误区有这么两个:
    • 连接超时配置得特别长,比如 60 秒。一般来说,TCP 三次握手建立连接需要的时间非常短,通常在毫秒级最多到秒级,不可能需要十几秒甚至几十秒。因此,设置特别长的连接超时意义不大,将其配置得短一些(比如 1~5秒)即可
    • 排查连接超时问题,却没理清连的是哪里;通常情况下,我们的服务会有多个节点,如果别的客户端通过客户端负载均衡技术来连接服务端,那么客户端和服务端会直接建立连接,此时出现连接超时大概率是服务端的问题;而如果服务端通过类似 Nginx 的反向代理来负载均衡,客户端连接的其实是 Nginx,而不是服务端,此时出现连接超时应该排查 Nginx。
  • 读取超时参数和读取超时则会有更多的误区,我将其归纳为如下三个
    • 第一个误区:认为出现了读取超时,服务端的执行就会中断;我们知道,类似 Tomcat 的 Web 服务器都是把服务端请求提交到线程池处理的,只要服务端收到了请求,网络层面的超时和断开便不会影响服务端的执行。因此,出现读取超时不能随意假设服务端的处理情况,需要根据业务状态考虑如何进行后续处理。
    • 第二个误区:认为读取超时只是 Socket 网络层面的概念,是数据传输的最长耗时,故将其配置得非常短,比如 100 毫秒;因为 TCP 是先建立连接后传输数据,对于网络情况不是特别糟糕的服务调用,通常可以认为出现连接超时是网络问题或服务不在线,而出现读取超时是服务处理超时
    • 第三个误区:认为超时时间越长任务接口成功率就越高,将读取超时参数配置得太长

5.2:Feign和Ribbon配置超时

  • 为 Feign 配置超时参数的复杂之处在于,Feign 自己有两个超时参数,它使用的负载均衡组件 Ribbon 本身还有相关配置。那么,这些配置的优先级是怎样的,又哪些什么坑呢?
  • 默认情况下 Feign 的读取超时是 1 秒,如此短的读取超时算是坑点一
@RestController
@RequestMapping("feignandribbon")
@Slf4j
public class FeignAndRibbonController {

    @Autowired
    private Client client;

    @GetMapping("client")
    public void timeout() {
        long begin = System.currentTimeMillis();
        try{
            client.server();
        } catch (Exception ex) {
            log.warn("执行耗时:{}ms 错误:{}", System.currentTimeMillis() - begin, ex.getMessage());
        }
    }

    @PostMapping("/server")
    public void server() throws InterruptedException {
        TimeUnit.MINUTES.sleep(10);
    }
}
@FeignClient(name = "clientsdk")
public interface Client {
    @PostMapping("/feignandribbon/server")
    void server();
}
  • 如果要修改 Feign 客户端默认的两个全局超时时间,你可以设置
    • feign.client.config.default.readTimeout=3000
    • feign.client.config.default.connectTimeout=3000
  • 结论二,也是坑点二,如果要配置 Feign 的读取超时,就必须同时配置连接超时,才能生效
  • 更进一步,如果你希望针对单独的 Feign Client 设置超时时间,可以把 default 替换为Client 的 name
  • 结论三,单独的超时可以覆盖全局超时,这符合预期,不算坑
  • 结论四,除了可以配置 Feign,也可以配置 Ribbon 组件的参数来修改两个超时时间。这里的坑点三是,参数首字母要大写,和 Feign 的配置不同
    • ribbon.ReadTimeout=4000
    • ribbon.ConnectTimeout=4000
  • 结论五,同时配置 Feign 和 Ribbon 的超时,以 Feign 为准

5.3:ribbon自动重试请求

  • 首先,定义一个 Get 请求的发送短信接口,里面没有任何逻辑,休眠 2 秒模拟耗时:
@RestController
@RequestMapping("ribbonretryissueserver")
@Slf4j
public class RibbonRetryIssueServerController {
    @GetMapping("sms")
    public void sendSmsWrong(@RequestParam("mobile") String mobile, @RequestParam("message") String message, HttpServletRequest request) throws InterruptedException {
        log.info("{} is called, {}=>{}", request.getRequestURL().toString(), mobile, message);
        TimeUnit.SECONDS.sleep(2);
    }

    @PostMapping("sms")
    public void sendSmsRight(@RequestParam("mobile") String mobile, @RequestParam("message") String message, HttpServletRequest request) throws InterruptedException {
        log.info("{} is called, {}=>{}", request.getRequestURL().toString(), mobile, message);
        TimeUnit.SECONDS.sleep(2);
    }
}
  • 配置一个 Feign 供客户端调用:
@FeignClient(name = "SmsClient")
public interface SmsClient {

    @GetMapping("/ribbonretryissueserver/sms")
    void sendSmsWrong(@RequestParam("mobile") String mobile, @RequestParam("message") String message);

    @PostMapping("/ribbonretryissueserver/sms")
    void sendSmsRight(@RequestParam("mobile") String mobile, @RequestParam("message") String message);
}
  • Feign 内部有一个 Ribbon 组件负责客户端负载均衡,通过配置文件设置其调用的服务端为两个节点
    • SmsClient.ribbon.listOfServers=localhost:45679,localhost:45678
  • 写一个客户端接口,通过 Feign 调用服务端
@RestController
@RequestMapping("ribbonretryissueclient")
@Slf4j
public class RibbonRetryIssueClientController {

    @Autowired
    private SmsClient smsClient;

    @GetMapping("wrong")
    public String wrong() {
        log.info("client is called");
        try{
            //通过Feign调用发送短信接口
            smsClient.sendSmsWrong("13600000000", UUID.randomUUID().toString());
        } catch (Exception ex) {
            log.error("send sms failed : {}", ex.getMessage());
        }
        return "done";
    }

    @GetMapping("right")
    public String right() {
        try{
            smsClient.sendSmsRight("13600000000", UUID.randomUUID().toString());
        } catch (Exception ex) {
            log.error("send sms failed : {}", ex.getMessage());
        }
        return "done";
    }

}
  • 客户端接口被调用的日志只输出了一次,而服务端的日志输出了两次。虽然 Feign 的默认读取超时时间是 1 秒,但客户端 2 秒后才出现超时错误。显然,这说明客户端自作主张进行了一次重试,导致短信重复发送
  • 翻看 Ribbon 的源码可以发现,MaxAutoRetriesNextServer 参数默认为 1,也就是 Get请求在某个服务端节点出现问题(比如读取超时)时,Ribbon 会自动重试一次
  • 解决方法有两个:
    • 一是,把发短信接口从 Get 改为 Post
    • 二是,将 MaxAutoRetriesNextServer 参数配置为 0,禁用服务调用失败后在下一个服务端节点的自动重试。
    • ribbon.MaxAutoRetriesNextServer=0

6:数据库事务那些事

  • 针对业务代码中最常见的使用数据库事务的方式,即 Spring 声明式事务,与你总结了使用上可能遇到的三类坑,包括:
    • 第一,因为配置不正确,导致方法上的事务没生效。我们务必确认调用 @Transactional 注解标记的方法是 public 的,并且是通过 Spring 注入的 Bean 进行调用的。
    • 第二,因为异常处理不正确,导致事务虽然生效但出现异常时没回滚。Spring 默认只会对标记 @Transactional 注解的方法出现了 RuntimeException 和 Error 的时候回滚,如果我们的方法捕获了异常,那么需要通过手动编码处理事务回滚。如果希望 Spring 针对其他异常也可以回滚,那么可以相应配置 @Transactional 注解的 rollbackFor 和noRollbackFor 属性来覆盖其默认设置
    • 第三,如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么我们需要考虑进一步细化配置事务传播方式,也就是 @Transactional 注解的Propagation 属性

6.1:事务可能没生效

  • 在使用 @Transactional 注解开启声明式事务时, 第一个最容易忽略的问题是,很可能事务并没有生效
  • 用户表
@Entity
@Data
public class UserEntity {
    @Id
    @GeneratedValue(strategy = AUTO)
    private Long id;
    private String name;

    public UserEntity() {
    }

    public UserEntity(String name) {
        this.name = name;
    }
}
  • Spring JPA 做数据库访问
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
    List<UserEntity> findByName(String name);
}
  • 业务逻辑处理
@Service
@Slf4j
public class UserService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private UserService self;

    @PostConstruct
    public void init() {
        log.info("this {} self {}", this.getClass().getName(), self.getClass().getName());
    }

    //公共方法供Controller调用,内部调用事务性的私有方法
    public int createUserWrong1(String name) {
        try {
            this.createUserPrivate(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }

    //自调用,把标记了事务注解的 createUserPrivate 方法改为 public
    public int createUserWrong2(String name) {
        try {
            this.createUserPublic(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }
  
    /*
    Spring 通过 AOP 技术对方法进行增强,要调用增强过的方法必然是调用代理后的对象。我们尝试修改下 UserService 的代码,注入一个 self,然后再通过 self 实例调用标记有 @Transactional 注解的 createUserPublic 方法。设置断点可以看到,self 是由 Spring 通过 CGLIB 方式增强过的类
    CGLIB 通过继承方式实现代理类,private 方法在子类不可见,自然也就无法进行事务增强;
    this 指针代表对象自己,Spring 不可能注入 this,所以通过 this 访问方法必然不是代理
    */
    public int createUserWrong3(String name) {
        try {
            this.createUserPublic(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }

    //私有方法,创建用户
    @Transactional
    private void createUserPrivate(UserEntity entity) {
        userRepository.save(entity);
        if (entity.getName().contains("test"))
            throw new RuntimeException("invalid username!");
    }

    //公有方法,创建用户;可以传播出异常
    @Transactional
    public void createUserPublic(UserEntity entity) {
        userRepository.save(entity);
        if (entity.getName().contains("test"))
            throw new RuntimeException("invalid username!");
    }

    //重新注入自己
    public int createUserRight(String name) {
        try {
            self.createUserPublic(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }


    public int getUserCount(String name) {
        return userRepository.findByName(name).size();
    }
}
  • controller
@RestController
@RequestMapping("transactionproxyfailed")
@Slf4j
public class TransactionProxyFailedController {

    @Autowired
    private UserService userService;

    /*
    调用接口后发现,即便用户名不合法,用户也能创建成功。刷新浏览器,多次发现有十几个的非法用户注册
    @Transactional 生效原则 1,除非特殊配置(比如使用 AspectJ 静态织入实现AOP),否则只有定义在 public 方法上的 @Transactional 才能生效。原因是,Spring默认通过动态代理的方式实现 AOP,对目标方法进行增强,private 方法无法代理到,Spring 自然也无法动态增强事务处理逻辑。
    */
    @GetMapping("wrong1")
    public int wrong1(@RequestParam("name") String name) {
        return userService.createUserWrong1(name);
    }

    /*
    测试发现,调用新的 createUserWrong2 方法事务同样不生效
    @Transactional 生效原则 2,必须通过代理过的类从外部调用目标方法才能生效。
    */
    @GetMapping("wrong2")
    public int wrong2(@RequestParam("name") String name) {
        return userService.createUserWrong2(name);
    }

    /*
    测试发现,依然不生效,无异常
    通过 this 自调用,没有机会走到 Spring 的代理类
    */
    @GetMapping("wrong3")
    public int wrong3(@RequestParam("name") String name) {
        return userService.createUserWrong3(name);
    }

    /*
    把 this 改为 self 后测试发现,在 Controller 中调用 createUserRight 方法可以验证事务是生效的,非法的用户注册操作可以回滚
    Spring 注入的 UserService,通过代理调用才有机会对 createUserPublic 方法进行动态增强
    */
    @GetMapping("right1")
    public int right1(@RequestParam("name") String name) {
        return userService.createUserRight(name);
    }

    /*
    更合理的实现方式是,让 Controller 直接调用之前定义的 UserService 的 createUserPublic方法,因为注入自己调用自己很奇怪,也不符合分层实现的规范
    */
    @GetMapping("right2")
    public int right2(@RequestParam("name") String name) {
        try {
            userService.createUserPublic(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userService.getUserCount(name);
    }

}
  • 强烈建议你在开发时打开相关的 Debug 日志,以方便了解Spring 事务实现的细节,并及时判断事务的执行情况

6.2:事务生效但不一定回滚

  • 通过 AOP 实现事务处理可以理解为,使用 try…catch…来包裹标记了 @Transactional 注解的方法,当方法出现了异常并且满足一定条件的时候,在 catch 里面我们可以设置事务回滚,没有异常则直接提交事务
  • 第一,只有异常传播出了标记了 @Transactional 注解的方法,事务才能回滚
  • 第二,默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring才会回滚事务。
@Service
@Slf4j
public class UserService {
    @Autowired
    private UserRepository userRepository;

    /*
    抛出一个 RuntimeException,但由于方法内 catch 了所有异常,异常无法从方法传播出去,事务自然无法回滚。
    */
    @Transactional
    public void createUserWrong1(String name) {
        try {
            userRepository.save(new UserEntity(name));
            throw new RuntimeException("error");
        } catch (Exception ex) {
            log.error("create user failed", ex);
        }
    }

    /*
    注册用户的同时会有一次 otherTask 文件读取操作,如果文件读取失败,我们希望用户注册的数据库操作回滚。虽然这里没有捕获异常,但因为 otherTask 方法抛出的是受检异常,createUserWrong2 传播出去的也是受检异常,事务同样不会回滚
    */
    @Transactional
    public void createUserWrong2(String name) throws IOException {
        userRepository.save(new UserEntity(name));
        otherTask();
    }

    //其他任务操作,抛出受检异常;在 IO操作出现问题时希望让数据库事务也回滚,以确保逻辑的一致性
    private void otherTask() throws IOException {
        Files.readAllLines(Paths.get("file-that-not-exist"));
    }

    public int getUserCount(String name) {
        return userRepository.findByName(name).size();
    }


    /*
    如果你希望自己捕获异常进行处理的话,也没关系,可以手动设置让当前事务处于回滚状态
    运行后可以在日志中看到 Rolling back 字样,确认事务回滚了。同时,我们还注意到“Transactional code has requested rollback”的提示,表明手动请求回滚
    */
    @Transactional
    public void createUserRight1(String name) {
        try {
            userRepository.save(new UserEntity(name));
            throw new RuntimeException("error");
        } catch (Exception ex) {
            log.error("create user failed", ex);
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        }
    }

    //DefaultTransactionAttribute
    /*
    在注解中声明,期望遇到所有的 Exception 都回滚事务(来突破默认不回滚受检异常的限制)
    运行后,同样可以在日志中看到回滚的提示
    */
    @Transactional(rollbackFor = Exception.class)
    public void createUserRight2(String name) throws IOException {
        userRepository.save(new UserEntity(name));
        otherTask();
    }

}

6.3:事务传播配置

  • 一个用户注册的操作,会插入一个主用户到用户表,还会注册一个关联的子用户。我们希望将子用户注册的数据库操作作为一个独立事务来处理,即使失败也不会影响主流程,即不影响主用户的注册
  • userService
@Service
@Slf4j
//创建主用户
public class UserService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private SubUserService subUserService;

    //未捕获异常,导致异常逃出了
    @Transactional
    public void createUserWrong(UserEntity entity) {
        //创建主用户
        createMainUser(entity);
        //最后我们抛出了一个运行时异常,错误原因是用户状态无效,所以子用户的注册肯定是失败的
        subUserService.createSubUserWithExceptionWrong(entity);
    }


    //捕获异常
    @Transactional
    public void createUserWrong2(UserEntity entity) {
        createMainUser(entity);
        try {
            subUserService.createSubUserWithExceptionWrong(entity);
        } catch (Exception ex) {
            // 虽然捕获了异常,但是因为没有开启新事务,而当前事务因为异常已经被标记为rollback了,所以最终还是会回滚。
            log.error("create sub user error:{}", ex.getMessage());
        }
    }


    //同样需要捕获异常,防止异常漏出去导致主事务回滚
    @Transactional
    public void createUserRight(UserEntity entity) {
        createMainUser(entity);
        try {
            subUserService.createSubUserWithExceptionRight(entity);
        } catch (Exception ex) {
            // 捕获异常,防止主方法回滚
            log.error("create sub user error:{}", ex.getMessage());
        }
    }

    public int getUserCount(String name) {
        return userRepository.findByName(name).size();
    }
    private void createMainUser(UserEntity entity) {
        userRepository.save(entity);
        log.info("createMainUser finish");
    }
}
  • subUserService
@Service
@Slf4j
//创建子用户
public class SubUserService {

    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void createSubUserWithExceptionWrong(UserEntity entity) {
        log.info("createSubUserWithExceptionWrong start");
        userRepository.save(entity);
        throw new RuntimeException("invalid status");
    }

    /*
    想办法让子逻辑在独立事务中运行,也就是改一下SubUserService 注册子用户的方法,为注解加上 propagation =Propagation.REQUIRES_NEW 来设置 REQUIRES_NEW 方式的事务传播策略,也就是执行到这个方法时需要开启新的事务,并挂起当前事务
    */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createSubUserWithExceptionRight(UserEntity entity) {
        log.info("createSubUserWithExceptionRight start");
        userRepository.save(entity);
        throw new RuntimeException("invalid status");
    }
}
  • controller
@RestController
@RequestMapping("transactionpropagation")
@Slf4j
public class TransactionPropagationController {

    @Autowired
    private UserService userService;

    /*
    调用后可以在日志中发现如下信息,很明显事务回滚了,最后 Controller 打出了创建子用户抛出的运行时异常
    因为运行时异常逃出了 @Transactional 注解标记的createUserWrong 方法,Spring 当然会回滚事务了
    */
    @GetMapping("wrong")
    public int wrong(@RequestParam("name") String name) {
        try {
            userService.createUserWrong(new UserEntity(name));
        } catch (Exception ex) {
            log.error("createUserWrong failed, reason:{}", ex.getMessage());
        }
        return userService.getUserCount(name);
    }

    /*
    对 createUserWrong2 方法开启了异常处理
    子方法因为出现了运行时异常,标记当前事务为回滚
    主方法的确捕获了异常打印出了 create sub user error 字样
    主方法提交了事务
    Controller 里出现了一个UnexpectedRollbackException,异常描述提示最终这个事务回滚了,而且是静默回滚的。之所以说是静默,是因为 createUserWrong2 方法本身并没有出异常,只不过提交后发现子方法已经把当前事务设置为了回滚,无法完成提交
    我们之前说,出了异常事务不一定回滚,这里说的却是不出异常,事务也不一定可以提交。原因是,主方法注册主用户的逻辑和子方法注册子用户的逻辑是同一个事务,子逻辑标记了事务需要回滚,主逻辑自然也不能提交了
    */
    @GetMapping("wrong2")
    public int wrong2(@RequestParam("name") String name) {
        try {
            userService.createUserWrong2(new UserEntity(name));
        } catch (Exception ex) {
            log.error("createUserWrong2 failed, reason:{}", ex.getMessage(), ex);
        }
        return userService.getUserCount(name);
    }

    /*
    针对 createUserRight 方法开启了主方法的事务
    创建主用户完成
    主事务挂起了,开启了一个新的事务,针对createSubUserWithExceptionRight 方案,也就是我们的创建子用户的逻辑
    子方法事务回滚
    子方法事务完成,继续主方法之前挂起的事务
    主方法捕获到了子方法的异常
    主方法的事务提交了,随后我们在 Controller 里没看到静默回滚的异常
    */
    @GetMapping("right")
    public int right(@RequestParam("name") String name) {
        userService.createUserRight(new UserEntity(name));
        return userService.getUserCount(name);
    }
}

6.4 在多线程环境下如何确保事务一致性

问题引入:

public void removeAuthorityModuleSeq(Integer authorityModuleId, IAuthorityService iAuthorityService, IRoleAuthorityService iRoleAuthorityService) {
    //1.查询出当前资源模块下所有资源,查询出来后进行删除
    deleteAuthoritiesOfCurrentAuthorityModule(authorityModuleId, iAuthorityService, iRoleAuthorityService);
    //2.查询出当前资源模块下所有子模块,递归查询,当删除完所有子模块下的资源后,再删除所有子模块,最终删除当前资源模块
    deleteSonAuthorityModuleUnderCurrentAuthorityModule(authorityModuleId, iAuthorityService, iRoleAuthorityService);
    //3.删除当前资源模块
    removeById(authorityModuleId);
}

希望将步骤1和步骤2并行执行,然后确保步骤1和步骤2执行成功后,再执行步骤3,等到步骤3执行完毕后,再提交全部事务,这个需求该如何实现呢?

@Async注解原理简单来说,就是扫描IOC中的bean,给方法上标注有@Async注解的bean进行代理,代理的核心是添加一个MethodInterceptor即AsyncExecutionInterceptor,该方法拦截器负责将方法真正的执行包装为任务,放入线程池中执行。

尝试使用编程式事务来解决这个问题

import
/**
 * 多线程事务一致性管理 <br>
 * 声明式事务管理无法完成,此时我们只能采用初期的编程式事务管理才行
 */
@Component
@RequiredArgsConstructor
public class MultiplyThreadTransactionManager {
    /**
     * 如果是多数据源的情况下,需要指定具体是哪一个数据源
     */
    private final DataSource dataSource;

    /**
     * 执行的是无返回值的任务
     * @param tasks 异步执行的任务列表
     * @param executor 异步执行任务需要用到的线程池,考虑到线程池需要隔离,这里强制要求传
     */
    public void runAsyncButWaitUntilAllDown(List<Runnable> tasks, Executor executor) {
        if(executor==null){
            throw new IllegalArgumentException("线程池不能为空");
        }
        DataSourceTransactionManager transactionManager = getTransactionManager();
        //标记是否发生了异常
        AtomicBoolean ex=new AtomicBoolean();
		//任务列表
        List<CompletableFuture> taskFutureList=new ArrayList<>(tasks.size());
        //事务状态列表
        List<TransactionStatus> transactionStatusList=new ArrayList<>(tasks.size());
        //事务资源列表
        List<TransactionResource> transactionResources=new ArrayList<>(tasks.size());

        tasks.forEach(task->{
            taskFutureList.add(CompletableFuture.runAsync(
                    () -> {
                        try{
                            //1.开启新事务
                            transactionStatusList.add(openNewTransaction(transactionManager));
                            //2.copy事务资源
                         transactionResources.add(TransactionResource.copyTransactionResource());
                            //3.异步任务执行
                            task.run();
                        }catch (Throwable throwable){
                            //打印异常
                            throwable.printStackTrace();
                            //其中某个异步任务执行出现了异常,进行标记
                            ex.set(Boolean.TRUE);
                            //其他任务还没执行的不需要执行了
                            taskFutureList.forEach(completableFuture -> completableFuture.cancel(true));
                        }
                    }
                    , executor)
            );
        });

        try {
            //阻塞直到所有任务全部执行结束---如果有任务被取消,这里会抛出异常滴,需要捕获
            CompletableFuture.allOf(taskFutureList.toArray(new CompletableFuture[]{})).get();
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        //发生了异常则进行回滚操作,否则提交
        if(ex.get()){
            System.out.println("发生异常,全部事务回滚");
            for (int i = 0; i < tasks.size(); i++) {
                transactionResources.get(i).autoWiredTransactionResource();
                transactionManager.rollback(transactionStatusList.get(i));
                transactionResources.get(i).removeTransactionResource();
            }
        }else {
            System.out.println("全部事务正常提交");
            for (int i = 0; i < tasks.size(); i++) {
                transactionResources.get(i).autoWiredTransactionResource();
                transactionManager.commit(transactionStatusList.get(i));
                transactionResources.get(i).removeTransactionResource();
            }
        }
    }

    //开启一个新事务
    private TransactionStatus openNewTransaction(DataSourceTransactionManager transactionManager) {
        //JdbcTransactionManager根据TransactionDefinition信息来进行一些连接属性的设置
        //包括隔离级别和传播行为等
        DefaultTransactionDefinition transactionDef = new DefaultTransactionDefinition();
        //开启一个新事务---此时autocommit已经被设置为了false,并且当前没有事务,这里创建的是一个新事务
        return transactionManager.getTransaction(transactionDef);
    }

    private DataSourceTransactionManager getTransactionManager() {
        return new DataSourceTransactionManager(dataSource);
    }

    /**
     * 保存当前事务资源,用于线程间的事务资源COPY操作
     */
    @Builder
    private static class TransactionResource{
        //事务结束后默认会移除集合中的DataSource作为key关联的资源记录
        private  Map<Object, Object> resources = new HashMap<>();

        //下面五个属性会在事务结束后被自动清理,无需我们手动清理
        private  Set<TransactionSynchronization> synchronizations =new HashSet<>();

        private  String currentTransactionName;

        private Boolean currentTransactionReadOnly;

        private Integer currentTransactionIsolationLevel;

        private Boolean actualTransactionActive;

        public static TransactionResource copyTransactionResource(){
            return TransactionResource.builder()
                    //返回的是不可变集合
                    .resources(TransactionSynchronizationManager.getResourceMap())
                    //如果需要注册事务监听者,这里记得修改--我们这里不需要,就采用默认负责--spring事务内部默认也是这个值
                    .synchronizations(new LinkedHashSet<>())
                    .currentTransactionName(TransactionSynchronizationManager.getCurrentTransactionName())
                    .currentTransactionReadOnly(TransactionSynchronizationManager.isCurrentTransactionReadOnly())
                    .currentTransactionIsolationLevel(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel())
                    .actualTransactionActive(TransactionSynchronizationManager.isActualTransactionActive())
                    .build();
        }

        public void autoWiredTransactionResource(){
             resources.forEach(TransactionSynchronizationManager::bindResource);
             //如果需要注册事务监听者,这里记得修改--我们这里不需要,就采用默认负责--spring事务内部默认也是这个值
             TransactionSynchronizationManager.initSynchronization();
             TransactionSynchronizationManager.setActualTransactionActive(actualTransactionActive);
             TransactionSynchronizationManager.setCurrentTransactionName(currentTransactionName);
             TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(currentTransactionIsolationLevel);
             TransactionSynchronizationManager.setCurrentTransactionReadOnly(currentTransactionReadOnly);
        }

        public void removeTransactionResource() {
            //事务结束后默认会移除集合中的DataSource作为key关联的资源记录
            //DataSource如果重复移除,unbindResource时会因为不存在此key关联的事务资源而报错
            resources.keySet().forEach(key->{
                if(!(key instanceof  DataSource)){
                    TransactionSynchronizationManager.unbindResource(key);
                }
            });
        }
    }
}

测试

@SpringBootTest(classes = UserMain.class)
public class Test {
    @Resource
    private UserMapper userMapper;
    @Resource
    private SignMapper signMapper;
    @Resource
    private MultiplyThreadTransactionManager multiplyThreadTransactionManager;

    @SneakyThrows
    @org.junit.jupiter.api.Test
    public void test(){
        List<Runnable> tasks=new ArrayList<>();

        tasks.add(()->{
                userMapper.deleteById(26);
                throw new RuntimeException("我就要抛出异常!");
        });

        tasks.add(()->{
            signMapper.deleteById(10);
        });

        multiplyThreadTransactionManager.runAsyncButWaitUntilAllDown(tasks, Executors.newCachedThreadPool());
    }
}

事务都进行了回滚,数据库数据没变。

6.5:思考与讨论

  • 改为 MyBatis 做数据访问实现,看看日志中是否可以体现出这些坑

  • 如果要针对 private 方法启用事务,动态代理方式的 AOP 不可行,需要使用静态织入方式的 AOP,也就是在编译期间织入事务增强代码,可以配置Spring 框架使用 AspectJ 来实现 AOP;意:AspectJ 配合 lombok 使用,还可能会踩一些坑

7:数据库索引

  • 几乎所有的业务项目都会涉及数据存储,虽然当前各种 NoSQL 和文件系统大行其道,但MySQL 等关系型数据库因为满足 ACID、可靠性高、对开发友好等特点,仍然最常被用于存储重要数据。在关系型数据库中,索引是优化查询性能的重要手段

7.1:innodb如何存储数据

  • MySQL 把数据存储和查询操作抽象成了存储引擎,不同的存储引擎,对数据的存储和读取方式各不相同
  • MySQL 支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为支持事务,我们最常使用的是 InnoDB
  • 虽然数据保存在磁盘中,但其处理是在内存中进行的。为了减少磁盘随机读取次数,InnoDB 采用页而不是行的粒度来保存数据,即数据被分成若干页,以页为单位保存在磁盘中。InnoDB 的页大小,一般是 16KB
  • 各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数据页中有一个页目录,方便按照主键查询记录
  • image.png
  • 页目录通过槽把记录分成不同的小组,每个小组有若干条记录。如图所示,记录中最前面的小方块中的数字,代表的是当前分组的记录条数,最小和最大的槽指向 2 个特殊的伪记录。有了槽之后,我们按照主键搜索页中记录时,就可以采用二分法快速搜索,无需从最小记录开始遍历整个页中的记录链表
  • 举一个例子,如果要搜索主键(PK)=15 的记录:
    • 先二分得出槽中间位是 (0+6)/2=3,看到其指向的记录是 12<15,所以需要从 #3 槽后继续搜索记录
    • 再使用二分搜索出 #3 槽和 #6 槽的中间位是 (3+6)/2=4.5 取整 4,#4 槽对应的记录是16>15,所以记录一定在 #4 槽中
    • 再从 #3 槽指向的 12 号记录开始向下搜索 3 次,定位到 15 号记录

7.2:聚簇索引和二级索引

  • 页目录就是最简单的索引,是通过对记录进行一级分组来降低搜索的时间复杂度。但,这样能够降低的时间复杂度数量级,非常有限。当有无数个数据页来存储表数据的时候,我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页
  • 为了解决这个问题,InnoDB 引入了 B+ 树
  • image.png
  • B+ 树的特点包括:
    • 最底层的节点叫作叶子节点,用来存放数据
    • 其他上层节点叫作非叶子节点,仅用来存放目录项,作为索引
    • 非叶子节点分为不同层次,通过分层来降低每一层的搜索量
    • 所有节点按照索引键大小排序,构成一个双向链表,加速范围查找
  • InnoDB 使用 B+ 树,既可以保存实际数据(把上图叶子节点下面方块中的省略号看作实际数据),也可以加速数据搜索,这就是聚簇索引
  • 由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个
  • InnoDB 会自动使用主键(唯一定义一条记录的单个或多个字段)作为聚簇索引的索引键(如果没有主键,就选择第一个不包含 NULL 值的唯一列)。上图方框中的数字代表了索引键的值,对聚簇索引而言一般就是主键。
  • 为了实现非主键字段的快速搜索,就引出了二级索引,也叫作非聚簇索引、辅助索引。二级索引,也是利用的 B+ 树的数据结构
  • image.png
  • 这次二级索引的叶子节点中保存的不是实际数据,而是主键,获得主键值后去聚簇索引中获得数据行。这个过程就叫作回表
  • 举个例子,有个索引是针对用户名字段创建的,索引记录上面方块中的字母是用户名,按照顺序形成链表。如果我们要搜索用户名为 b 的数据,经过两次定位可以得出在 #5 数据页中,查出所有的主键为 7 和 6,再拿着这两个主键继续使用聚簇索引进行两次回表得到完整数据。

7.3:创建二级索引的代价

  • 创建二级索引的代价,主要表现在维护代价、空间代价和回表代价三个方面
  • 首先是维护代价。创建 N 个二级索引,就需要再创建 N 棵 B+ 树,新增数据时不仅要修改聚簇索引,还需要修改这 N 个二级索引
  • 通过实验测试一下创建索引的代价
create TABLE `person` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `score` int(11) NOT NULL,
  `create_time` timestamp NOT NULL,
  PRIMARY KEY (`id`),
  KEY `name_score` (`name`,`score`) USING BTREE,
  KEY `create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 通过下面的存储过程循环创建 10 万条测试数据,我的机器的耗时是 140 秒
create DEFINER=`root`@`%` PROCEDURE `insert_person`()
begin
    declare c_id integer default 1;
    while c_id<=100000 do
    insert into person values(c_id, concat('name',c_id), c_id+100, date_sub(NOW(), interval c_id second));
    -- 需要注意,因为使用的是now(),所以对于后续的例子,使用文中的SQL你需要自己调整条件,否则可能看不到文中的效果
    set c_id=c_id+1;
    end while;
end
  • 如果再创建两个索引,一个是 name 和 score 构成的联合索引,另一个是单一列create_time 的索引,那么创建 10 万条记录的耗时提高到 154 秒
  • 页中的记录都是按照索引值从小到大的顺序存放的,新增记录就需要往页中插入数据,现有的页满了就需要新创建一个页,把现有页的部分数据移过去,这就是页分裂;如果删除了许多数据使得页比较空闲,还需要进行页合并。页分裂和合并,都会有 IO 代价,并且可能在操作过程中产生死锁

  • 其次是空间代价。虽然二级索引不保存原始数据,但要保存索引列的数据,所以会占用更多的空间
  • 比如,person 表创建了两个索引后,使用下面的 SQL 查看数据和索引占用的磁盘 **select** DATA_LENGTH, INDEX_LENGTH **from** information_schema.TABLES **where** **TABLE_NAME**='person'; 结果显示,数据本身只占用了 4.7M,而索引占用了 8.4M

  • 最后是回表的代价。二级索引不保存原始数据,通过索引找到主键后需要再查询聚簇索引,才能得到我们要的数据
  • 使用 SELECT * 按照 name 字段查询用户,使用 EXPLAIN查看执行计划:
    • **explain** **select** * **from** person **where** NAME='name1';
    • key 字段代表实际走的是哪个索引,其值是 name_score,说明走的是 name_score 这个索引。
    • type 字段代表了访问表的方式,其值 ref 说明是二级索引等值匹配,符合我们的查询。
  • 把 SQL 中的 * 修改为 NAME 和 SCORE,也就是 SELECT name_score 联合索引包含的两列:
    • explain select NAME,SCORE from person where NAME='name1';
    • Extra 列多了一行 Using index 的提示,证明这次查询直接查的是二级索引,免去了回表。
    • 原因很简单,联合索引中其实保存了多个索引列的值,对于页中的记录先按照字段 1 排序,如果相同再按照字段 2 排序
    • image.png
  • 总结下关于索引开销的最佳实践
    • 第一,无需一开始就建立索引,可以等到业务场景明确后,或者是数据量超过 1 万、查询变慢后,再针对需要查询、排序或分组的字段创建索引。创建索引后可以使用 EXPLAIN 命令,确认查询是否可以使用索引。
    • 第二,尽量索引轻量级的字段,比如能索引 int 字段就不要索引 varchar 字段。索引字段也可以是部分前缀,在创建的时候指定字段索引长度。针对长文本的搜索,可以考虑使用Elasticsearch 等专门用于文本搜索的索引数据库
    • 第三,尽量不要在 SQL 语句中 SELECT *,而是 SELECT 必要的字段,甚至可以考虑使用联合索引来包含我们要搜索的字段,既能实现索引加速,又可以避免回表的开销。

7.4:索引有可能失效

  • 第一,索引只能匹配列前缀。

    • 原因很简单,索引 B+ 树中行数据按照索引值排序,只能根据前缀进行比较。如果要按照后缀搜索也希望走索引的话,并且永远只是按照后缀搜索的话,可以把数据反过来存,用的时候再倒过来
  • 第二,条件涉及函数操作无法走索引。

    • 同样的原因,索引保存的是索引列的原始值,而不是经过函数计算后的值。如果需要针对函数调用走数据库索引的话,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。
  • 第三,联合索引只能匹配左边的列。
    • 原因也很简单,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。
    • 需要注意的是,因为有查询优化器,所以 name 作为 WHERE 子句的第几个条件并不是很重要
  • 是不是建了索引一定可以用上?并不是,只有当查询能符合索引存储的实际结构时,才能用上

  • 怎么选择建联合索引还是多个独立索引?如果你的搜索条件经常会使用多个字段进行搜索,那么可以考虑针对这几个字段建联合索引;同时,针对多字段建立联合索引,使用索引覆盖的可能更大。如果只会查询单个字段,可以考虑建单独的索引,毕竟联合索引

    保存了不必要字段也有成本

7.5:基于成本决定是否走索引

  • MySQL 在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走哪个执行计划
  • 这里的成本,包括 IO 成本和 CPU 成本:
    • IO 成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的 IO 成本常数是 1(也就是读取 1 个页成本是 1)。
    • CPU 成本,是检测数据是否满足条件和排序等 CPU 操作的成本。默认情况下,检测记录的成本是 0.2。
  • 基于此,我们分析下全表扫描的成本
  • 全表扫描,就是把聚簇索引中的记录依次和给定的搜索条件做比较,把符合搜索条件的记录加入结果集的过程。

    • 聚簇索引占用的页面数,用来计算读取数据的 IO 成本
    • 表中的记录数,用来计算搜索的 CPU 成本
  • MySQL 维护了表的统计信息,可以使用下面的命令查看

    • SHOW TABLE STATUS LIKE 'person'
    • Rows:100086;Data_length:4734976
    • 总行数是 100086 行(之前 EXPLAIN 时,也看到 rows 为 100086)。根据这个值估算 CPU成本,是 100086*0.2=20017 左右
    • 数据长度是 4734976 字节。对于 InnoDB 来说,这就是聚簇索引占用的空间,等于聚簇索引的页面数量 * 每个页面的大小。InnoDB 每个页面的大小是 16KB,大概计算出页面数量是 289,因此 IO 成本是 289 左右
    • 所以,全表扫描的总成本是 20306 左右。
  • 分析下 MySQL 如何基于成本来制定执行计划
explain select * from person where NAME >'name84059' and create_time>'2020-01-24 05:00:00'

其执行计划是全表扫描
只要把 create_time 条件中的 5 点改为 6 点就变为走索引了,并且走的是 create_time 索引而不是 name_score 联合索引
  • 我们可以得到两个结论

    • MySQL 选择索引,并不是按照 WHERE 条件中列的顺序进行的;
    • 即便列有索引,甚至有多个可能的索引方案,MySQL 也可能不走索引。
  • 不过,有时会因为统计信息的不准确或成本估算的问题,实际开销会和 MySQL 统计出来的差距较大,导致 MySQL 选择错误的索引或是直接选择走全表扫描,这个时候就需要人工干预,使用强制索引了

8:判等问题

  • equals、compareTo 和 Java 的数值缓存、字符串驻留等问题展开讨论,希望你可以理解其原理,彻底消除业务代码中的相关 Bug。

8.1:equals和==

  • equals 是方法而 == 是操作符,它们的使用是有区别的:

    • 对基本类型,比如 int、long,进行判等,只能使用 ==,比较的是直接值。因为基本类型的值就是其数值
    • 对引用类型,比如 Integer、Long 和 String,进行判等,需要使用 equals 进行内容判等。因为引用类型的直接值是指针,使用 == 的话,比较的是指针,也就是两个对象在内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容
  • 比较值的内容,除了基本类型只能使用 ==外,其他类型都需要使用 equals

@RestController
@Slf4j
@RequestMapping("intandstringequal")
public class IntAndStringEqualController {

    List<String> list = new ArrayList<>();

    /*
    当代码中出现双引号形式创建字符串对象时,JVM 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化
    */
    @GetMapping("stringcompare")
    public void stringcomare() {
        String a = "1";
        String b = "1";
        log.info("\nString a = \"1\";\n" +
                "String b = \"1\";\n" +
                "a == b ? {}", a == b); //true

        String c = new String("2");
        String d = new String("2");
        log.info("\nString c = new String(\"2\");\n" +
                "String d = new String(\"2\");" +
                "c == d ? {}", c == d); //false

        String e = new String("3").intern();
        String f = new String("3").intern();
        log.info("\nString e = new String(\"3\").intern();\n" +
                "String f = new String(\"3\").intern();\n" +
                "e == f ? {}", e == f); //true

        String g = new String("4");
        String h = new String("4");
        log.info("\nString g = new String(\"4\");\n" +
                "String h = new String(\"4\");\n" +
                "g == h ? {}", g.equals(h)); //true
    }

    /*
    虽然使用 new 声明的字符串调用 intern 方法,也可以让字符串进行驻留,但在业务代码中滥用 intern,可能会产生性能问题
    其实,原因在于字符串常量池是一个固定容量的 Map。如果容量太小(Number ofbuckets=60013)、字符串太多(1000 万个字符串),那么每一个桶中的字符串数量会非常多,所以搜索起来就很慢
    解决方式是,设置 JVM 参数 -XX:StringTableSize,指定更多的桶。
    没事别轻易用 intern,如果要用一定要注意控制驻留的字符串的数量,并留意常量表的各项指标
    */
    @GetMapping("internperformance")
    public int internperformance(@RequestParam(value = "size", defaultValue = "10000000") int size) {
        //-XX:+PrintStringTableStatistics
        //-XX:StringTableSize=10000000
        long begin = System.currentTimeMillis();
        list = IntStream.rangeClosed(1, size)
                .mapToObj(i -> String.valueOf(i).intern())
                .collect(Collectors.toList());
        log.info("size:{} took:{}", size, System.currentTimeMillis() - begin);
        return list.size();
    }

    /*
    默认情况下会缓存[-128,127]的数值
    只需要记得比较 Integer 的值请使用 equals,而不是 ==(对于基本类型 int 的比较当然只能使用 ==)
    */
    @GetMapping("intcompare")
    public void intcompare() {

        Integer a = 127; //Integer.valueOf(127)
        Integer b = 127; //Integer.valueOf(127)
        log.info("\nInteger a = 127;\n" +
                "Integer b = 127;\n" +
                "a == b ? {}", a == b);    // true

        Integer c = 128; //Integer.valueOf(128)
        Integer d = 128; //Integer.valueOf(128)
        log.info("\nInteger c = 128;\n" +
                "Integer d = 128;\n" +
                "c == d ? {}", c == d);   //false
        //设置-XX:AutoBoxCacheMax=1000再试试

        Integer e = 127; //Integer.valueOf(127)
        Integer f = new Integer(127); //new instance
        log.info("\nInteger e = 127;\n" +
                "Integer f = new Integer(127);\n" +
                "e == f ? {}", e == f);   //false

        Integer g = new Integer(127); //new instance
        Integer h = new Integer(127); //new instance
        log.info("\nInteger g = new Integer(127);\n" +
                "Integer h = new Integer(127);\n" +
                "g == h ? {}", g == h);  //false

        Integer i = 128; //unbox
        int j = 128;
        log.info("\nInteger i = 128;\n" +
                "int j = 128;\n" +
                "i == j ? {}", i == j); //true

    }
  @PostMapping("enumcompare")
  public void enumcompare(@RequestBody OrderQuery orderQuery) {
      StatusEnum statusEnum = StatusEnum.DELIVERED;
      log.info("orderQuery:{} statusEnum:{} result:{}", orderQuery, statusEnum, statusEnum.status == orderQuery.getStatus());
  }

  enum StatusEnum {
      CREATED(1000, "已创建"),
      PAID(1001, "已支付"),
      DELIVERED(1002, "已送到"),
      FINISHED(1003, "已完成");

      private final Integer status;
      private final String desc;

      StatusEnum(Integer status, String desc) {
          this.status = status;
          this.desc = desc;
      }
  }
   }

8.2:hashcode与equals

  • 重写

8.3:compareTo与equals

  • 比较

8.4:lombok与类加载器的坑

9:数值计算精度、舍入和溢出

9.1:危险的Double

  • 四则运算
public class CommonMistakesApplication {

      public static void main(String[] args) {

          test();
          System.out.println("wrong1");
          wrong1();
          System.out.println("wrong2");
          wrong2();
          System.out.println("right");
          right();
      }

      private static void wrong1() {

          System.out.println(0.1 + 0.2);
          System.out.println(1.0 - 0.8);
          System.out.println(4.015 * 100);
          System.out.println(123.3 / 100);

          double amount1 = 2.15;
          double amount2 = 1.10;

          if (amount1 - amount2 == 1.05)
              System.out.println("OK");
        /*
        0.30000000000000004
        0.19999999999999996
        401.49999999999994
        1.2329999999999999
        计算机是以二进制存储数值的,浮点数也不例外
        对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源
        */
      }

      private static void test() {
          System.out.println(Long.toBinaryString(Double.doubleToRawLongBits(0.1)));
          System.out.println(Long.toBinaryString(Double.doubleToRawLongBits(0.2)));

      }

      private static void wrong2() {
          System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
          System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
          System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
          System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));
        /*
        0.3000000000000000166533453693773481063544750213623046875
        0.1999999999999999555910790149937383830547332763671875
        401.49999999999996802557689079549163579940795898437500
        1.232999999999999971578290569595992565155029296875
        可以看到,运算结果还是不精确,只不过是精度高了而已。
        这里给出浮点数运算避坑第一原则:使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化BigDecimal
        */
      }

      private static void right() {
          System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
          System.out.println(new BigDecimal("1.0").subtract(new BigDecimal("0.8")));
          System.out.println(new BigDecimal("4.015").multiply(new BigDecimal("100")));
          System.out.println(new BigDecimal("123.3").divide(new BigDecimal("100")));
        /*
        0.3
        0.2
        401.500
        1.233
        */

          System.out.println(new BigDecimal("4.015").multiply(new BigDecimal(Double.toString(100))));
          System.out.println(new BigDecimal("4.015").multiply(BigDecimal.valueOf(100)));
        /*
        BigDecimal 有 scale 和 precision 的概念,scale 表示小数点右边的位数,而 precision 表示精度,也就是有效数字的长度
        如果一定要用 Double 来初始化 BigDecimal 的话,可以使用 BigDecimal.valueOf 方法,以确保其表现和字符串形式的构造方法一致
        */

      }
  }

9.2:浮点数舍入与格式化

@SpringBootApplication
public class CommonMistakesApplication {

    public static void main(String[] args) {

        System.out.println("wrong1");
        wrong1();
        System.out.println("wrong2");
        wrong2();
        System.out.println("right");
        right();
    }

    private static void wrong1() {

        double num1 = 3.35;
        float num2 = 3.35f;
        System.out.println(String.format("%.1f", num1));//四舍五入;3.4
        System.out.println(String.format("%.1f", num2));//3.3
      /*
      String.format 采用四舍五入的方式进行舍入,取 1 位小数,double 的 3.350 四舍五入为3.4,而 float 的 3.349 四舍五入为 3.3
      */
    }

    private static void wrong2() {
        double num1 = 3.35;
        float num2 = 3.35f;
        DecimalFormat format = new DecimalFormat("#.##");
        format.setRoundingMode(RoundingMode.DOWN);
        System.out.println(format.format(num1));
        format.setRoundingMode(RoundingMode.DOWN);
        System.out.println(format.format(num2));
      /*
      当我们把这 2 个浮点数向下舍入取 2 位小数时,输出分别是 3.35 和 3.34,还是我们之前说的浮点数无法精确存储的问题
      因此,即使通过 DecimalFormat 来精确控制舍入方式,double 和 float 的问题也可能产生意想不到的结果,
      所以浮点数避坑第二原则:浮点数的字符串格式化也要通过BigDecimal 进行
      */
    }

    private static void right() {
      //分别使用向下舍入和四舍五入方式取 1 位小数进行格式化
        BigDecimal num1 = new BigDecimal("3.35");
        BigDecimal num2 = num1.setScale(1, BigDecimal.ROUND_DOWN);
        System.out.println(num2);//3.3
        BigDecimal num3 = num1.setScale(1, BigDecimal.ROUND_HALF_UP);
        System.out.println(num3);//3.4
    }
}

9.3:判等

  • 使用 equals 方法对两个 BigDecimal 判等,一定能得到我们想要的结果吗?
public class CommonMistakesApplication {

    public static void main(String[] args) {
        wrong();
        right();
        set();
    }

    private static void wrong() {
        System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")));//false
      /*
      BigDecimal 的 equals 方法的注释中说明了原因,equals 比较的是 BigDecimal 的 value 和 scale,1.0 的 scale 是 1,1 的scale 是 0,所以结果一定是 false
      */
    }

    private static void right() {
      //如果我们希望只比较 BigDecimal 的 value,可以使用 compareTo 方法
        System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1")) == 0);
    }

    private static void set() {
        Set<BigDecimal> hashSet1 = new HashSet<>();
        hashSet1.add(new BigDecimal("1.0"));
        System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false

        Set<BigDecimal> hashSet2 = new HashSet<>();
        hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
        System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZeros()));//返回true

        Set<BigDecimal> treeSet = new TreeSet<>();
        treeSet.add(new BigDecimal("1.0"));
        System.out.println(treeSet.contains(new BigDecimal("1")));//返回true
    }

}

9.4:数值溢出

  • 数值计算还有一个要小心的点是溢出,不管是 int 还是 long,所有的基本数值类型都有超出表达范围的可能性
public class CommonMistakesApplication {

    public static void main(String[] args) {

        System.out.println("wrong");
        wrong();
        System.out.println("right1");
        right1();
        System.out.println("right2");
        right2();
    }

    private static void wrong() {
        long l = Long.MAX_VALUE;
        System.out.println(l + 1);
        System.out.println(l + 1 == Long.MIN_VALUE);
    }

    private static void right2() {
      /*
      方法一是,考虑使用 Math 类的 addExact、subtractExact 等 xxExact 方法进行数值运算,这些方法可以在数值溢出时主动抛出异常;执行后,可以得到 ArithmeticException,这是一个 RuntimeException
      */
        try {
            long l = Long.MAX_VALUE;
            System.out.println(Math.addExact(l, 1));
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    private static void right1() {

      /*
      方法二是,使用大数类 BigInteger。BigDecimal 是处理浮点数的专家,而 BigInteger 则是对大数进行科学计算的专家。如下代码,使用 BigInteger 对 Long 最大值进行 +1 操作;如果希望把计算结果转换一个Long 变量的话,可以使用 BigInteger 的 longValueExact 方法,在转换出现溢出时,同样会抛出 ArithmeticException:
      */
        BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
        System.out.println(i.add(BigInteger.ONE).toString());

        try {
            long l = i.add(BigInteger.ONE).longValueExact();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

9.5:思考与讨论

  • 1:BigDecimal提供了 8 种舍入模式,你能通过一些例子说说它们的区别吗?
  • 2:数据库(比如 MySQL)中的浮点数和整型数字,你知道应该怎样定义吗?又如何实现浮点数的准确计算呢?

10:坑满地的List

10.1:Arrays.asList的坑

  @Slf4j
public class AsListApplication {

    public static void main(String[] args) {

        right2();

    }

    private static void wrong1() {
        /*
        这样初始化的 List 并不是我们期望的包含 3 个数字的 List。通过日志可以发现,这个List 包含的其实是一个 int 数组,整个 List 的元素个数是 1,元素类型是整数数组
        其原因是,只能是把 int 装箱为 Integer,不可能把 int 数组装箱为 Integer 数组
        */
        int[] arr = {1, 2, 3};
        List list = Arrays.asList(arr);
        log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());
    }

    private static void right1() {
        /*
        第一个坑是,不能直接使用 Arrays.asList 来转换基本类型数组
        直接遍历这样的 List 必然会出现 Bug,修复方式有两种,如果使用 Java8 以上版本可以使用 Arrays.stream 方法来转换,否则可以把 int 数组声明为包装类型 Integer 数组
        可以看到 List 具有三个元素,元素类型是 Integer
        */
        int[] arr1 = {1, 2, 3};
        List list1 = Arrays.stream(arr1).boxed().collect(Collectors.toList());
        log.info("list:{} size:{} class:{}", list1, list1.size(), list1.get(0).getClass());

        Integer[] arr2 = {1, 2, 3};
        List list2 = Arrays.asList(arr2);
        log.info("list:{} size:{} class:{}", list2, list2.size(), list2.get(0).getClass());
    }
  private static void wrong2() {
      /*
      第二个坑,Arrays.asList 返回的 List 不支持增删操作。
      Arrays.asList 返回的 List 并不是我们期望的 java.util.ArrayList,而是 Arrays 的内部类 ArrayList。
      ArrayList 内部类继承自AbstractList 类,并没有覆写父类的 add 方法,而父类中 add 方法的实现,就是抛出UnsupportedOperationException。
      
      第三个坑,对原始数组的修改会影响到我们获得的那个 List。
      ArrayList 其实是直接使用了原始的数组。所以,我们要特别小心,把通过Arrays.asList 获得的 List 交给其他方法处理,很容易因为共享了数组,相互修改产生Bug。
      */
      String[] arr = {"1", "2", "3"};
      List list = Arrays.asList(arr);
      arr[1] = "4";
      try {
          list.add("5");
      } catch (Exception ex) {
          ex.printStackTrace();
      }
      log.info("arr:{} list:{}", Arrays.toString(arr), list);
  }

  private static void right2() {
      /*
      修复方式比较简单,重新 new 一个 ArrayList 初始化 Arrays.asList 返回的 List 即可
      修改后的代码实现了原始数组和 List 的“解耦”,不再相互影响。同时,因为操作的是真正的 ArrayList,add 也不再出错
      */
      String[] arr = {"1", "2", "3"};
      List list = new ArrayList(Arrays.asList(arr));
      arr[1] = "4";
      try {
          list.add("5");
      } catch (Exception ex) {
          ex.printStackTrace();
      }
      log.info("arr:{} list:{}", Arrays.toString(arr), list);
  }
}

10.2:List.subList的坑

  • 和 Arrays.asList 的问题类似,List.subList 返回的子List 不是一个普通的 ArrayList。这个子 List 可以认为是原始 List 的视图,会和原始 List 相互影响。如果不注意,很可能会因此产生 OOM 问题。
public class SubListApplication {

      private static List<List<Integer>> data = new ArrayList<>();

      public static void main(String[] args) throws InterruptedException {

          oom();
          //wrong();
          //right1();
  //        right2();
          //oomfix();
      }

      /*
      1:你可能会觉得,这个 data 变量里面最终保存的只是 1000 个具有 1 个元素的 List,不会占用很大空间,但程序运行不久就出现了 OOM
      出现 OOM 的原因是,循环中的 1000 个具有 10 万个元素的 List 始终得不到回收,因为它始终被 subList 方法返回的 List 强引用
      */
      private static void oom() {
          for (int i = 0; i < 1000; i++) {
              List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
              data.add(rawList.subList(0, 1));
          }
      }
    
      /*
      2:
      可以看到两个现象:
      原始 List 中数字 3 被删除了,说明删除子 List 中的元素影响到了原始 List
      尝试为原始 List 增加数字 0 之后再遍历子 List,会出现ConcurrentModificationException
      我们分析下 ArrayList 的源码,看看为什么会是这样
      第一,ArrayList 维护了一个叫作 modCount 的字段,表示集合结构性修改的次数。所谓结构性修改,指的是影响 List 大小的修改,所以 add 操作必然会改变 modCount 的值
      第二,分析subList 方法可以看到,获得的 List 其实是内部类 SubList,并不是普通的 ArrayList,在初始化的时候传入了 this
      第三,这个 SubList 中的 parent 字段就是原始的List。SubList 初始化的时候,并没有把原始 List 中的元素复制到独立的变量中保存。我们可以认为 SubList 是原始 List 的视图,并不是独立的 List。双方对元素的修改会相互影响,而且 SubList 强引用了原始的 List,所以大量保存这样的 SubList 会导致 OOM
      第四,遍历 SubList 的时候会先获得迭代器,比较原始ArrayList modCount 的值和 SubList 当前 modCount 的值。获得了 SubList 后,我们为原始 List 新增了一个元素修改了其 modCount,所以判等失败抛出ConcurrentModificationException 异常
      
      */
      private static void wrong() {
          List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
          List<Integer> subList = list.subList(1, 4);
          System.out.println(subList);
          subList.remove(1);
          System.out.println(list);
          list.add(0);
          try {
              subList.forEach(System.out::println);
          } catch (Exception ex) {
              ex.printStackTrace();
          }

      }

      /*
      避免相互影响的修复方式
      一种是,不直接使用 subList 方法返回的 SubList,而是重新使用 new ArrayList,在构造方法传入 SubList,来构建一个独立的 ArrayList
      */
      private static void oomfix() {
          for (int i = 0; i < 1000; i++) {
              List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
              data.add(new ArrayList<>(rawList.subList(0, 1)));
          }
      }

      private static void right1() {
          List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
          List<Integer> subList = new ArrayList<>(list.subList(1, 4));
          System.out.println(subList);
          subList.remove(1);
          System.out.println(list);
          list.add(0);
          subList.forEach(System.out::println);
      }

      /*
      另一种是,对于 Java 8 使用 Stream 的 skip 和 limit API 来跳过流中的元素,以及限制流中元素的个数,同样可以达到 SubList 切片的目的
      */
      private static void right2() {
          List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
          List<Integer> subList = list.stream().skip(1).limit(3).collect(Collectors.toList());
          System.out.println(subList);
          subList.remove(1);
          System.out.println(list);
          list.add(0);
          subList.forEach(System.out::println);
      }
  }

10.3:选择合适的数据结构

  • 搜索 ArrayList 的时间复杂度是 O(n),而 HashMap 的 get 操作的时间复杂度是 O(1)。所以,要对大 List 进行单值搜索的话,可以考虑使用 HashMap,其中 Key 是要搜索的值,Value 是原始对象,会比使用 ArrayList 有非常明显的性能优势
  • 类似,如果要对大 ArrayList 进行去重操作,也不建议使用 contains 方法,而是可以考虑使用HashSet 进行去重
  • 对于数组,随机元素访问的时间复杂度是 O(1),元素插入操作是 O(n);对于链表,随机元素访问的时间复杂度是 O(n),元素插入操作是 O(1);在大量的元素插入、很少的随机访问的业务场景下,是不是就应该使用 LinkedList呢?
    • 在随机访问方面,我们看到了 ArrayList 的绝对优势,耗时只有 11 毫秒,而 LinkedList 耗时 6.6 秒,这符合上面我们所说的时间复杂度;
    • 随机插入操作居然也是 LinkedList 落败,耗时 9.3 秒,ArrayList 只要 1.5 秒;插入操作的时间复杂度是 O(1) 的前提是,你已经有了那个要插入节点的指针。但,在实现的时候,我们需要先通过循环获取到那个节点的 Node,然后再执行插入操作。前者也是有开销的,不可能只考虑插入操作本身的代价;所以,对于插入操作,LinkedList 的时间复杂度其实也是 O(n)。继续做更多实验的话你会发现,在各种常用场景下,LinkedList 几乎都不能在性能上胜出 ArrayList

10.4:思考与讨论

  • 1:调用类型是 Integer 的 ArrayList 的 remove 方法删除元素,传入一个 Integer 包装类的数字和传入一个 int 基本类型的数字,结果一样吗?
  • 2:循环遍历 List,调用 remove 方法删除元素,往往会遇到ConcurrentModificationException 异常,原因是什么,修复方式又是什么呢?

11:null与空指针

  • 空指针异常虽然恼人但好在容易定位,更麻烦的是要弄清楚 null 的含义。比如,客户端给服务端的一个数据是 null,那么其意图到底是给一个空值,还是没提供值呢?再比如,数据库中字段的 NULL 值,是否有特殊的含义呢,针对数据库中的 NULL 值,写 SQL 需要特别注意什么呢?

11.1:定位和修复空指针

  • NullPointerException 是 Java 代码中最常见的异常,我将其最可能出现的场景归为以下5种:

    • 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
    • 字符串比较出现空指针异常;
    • 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的Key 或 Value 会出现空指针异常;
    • A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;
    • 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现空指针异常。

  @RestController
  @RequestMapping("avoidnullpointerexception")
  @Slf4j
  public class AvoidNullPointerExceptionController {
  
      @GetMapping("wrong")
      public int wrong(@RequestParam(value = "test", defaultValue = "1111") String test) {
          return wrongMethod(test.charAt(0) == '1' ? null : new FooService(),
                  test.charAt(1) == '1' ? null : 1,
                  test.charAt(2) == '1' ? null : "OK",
                  test.charAt(3) == '1' ? null : "OK").size();
      }
    
      private List<String> wrongMethod(FooService fooService, Integer i, String s, String t) {
          log.info("result {} {} {} {}", i + 1, s.equals("OK"), s.equals(t),
                  new ConcurrentHashMap<String, String>().put(null, null));
          if (fooService.getBarService().bar().equals("OK"))
              log.info("OK");
          return null;
      }
  
      @GetMapping("right")
      public int right(@RequestParam(value = "test", defaultValue = "1111") String test) {
          return Optional.ofNullable(rightMethod(test.charAt(0) == '1' ? null : new FooService(),
                  test.charAt(1) == '1' ? null : 1,
                  test.charAt(2) == '1' ? null : "OK",
                  test.charAt(3) == '1' ? null : "OK"))
                  .orElse(Collections.emptyList()).size();
      }
  
      private List<String> rightMethod(FooService fooService, Integer i, String s, String t) {
          log.info("result {} {} {} {}", Optional.ofNullable(i).orElse(0) + 1, "OK".equals(s), Objects.equals(s, t), new HashMap<String, String>().put(null, null));
          Optional.ofNullable(fooService)
                  .map(FooService::getBarService)
                  .filter(barService -> "OK".equals(barService.bar()))
                  .ifPresent(result -> log.info("OK"));
          return new ArrayList<>();
      }
  
      class FooService {
          @Getter
          private BarService barService;
  
      }
  
      class BarService {
          String bar() {
              return "OK";
          }
      }
  }

  • 推荐使用阿里开源的 Java 故障诊断神器Arthas。Arthas 简单易用功能强大,可以定位出大多数的 Java 生产问题。

  • 如果是简单的业务逻辑的话,你就可以定位到空指针异常了;如果是分支复杂的业务逻辑,你需要再借助 stack 命令来查看 wrongMethod 方法的调用栈,并配合 watch 命令查看各方法的入参,就可以很方便地定位到空指针的根源了。

  • 我们可以尝试利用 Java 8 的 Optional 类来消除这样的 if-else 逻辑,使用一行代码进行判空和处理

    • 对于 Integer 的判空,可以使用 Optional.ofNullable 来构造一个 Optional,然后使用orElse(0) 把 null 替换为默认值再进行 +1 操作
    • 对于 String 和字面量的比较,可以把字面量放在前面,比如”OK”.equals(s),这样即使s 是 null 也不会出现空指针异常;而对于两个可能为 null 的字符串变量的 equals 比较,可以使用 Objects.equals,它会做判空处理
  • 使用判空方式或 Optional 方式来避免出现空指针异常,不一定是解决问题的最好方式,空指针没出现可能隐藏了更深的 Bug。

11.2:MySQL中的null

  • 结合NULL 字段,和你着重说明 sum 函数、count 函数,以及 NULL 值条件可能踩的坑

  • 首先定义一个只有 id 和 score 两个字段的实体,程序启动的时候,往实体初始化一条数据,其 id 是自增列自动设置的 1,score 是

    NULL

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    /*
    通过 sum 函数统计一个只有 NULL 值的列的总和,比如 SUM(score)
    结果为null
    虽然记录的 score 都是 NULL,但 sum 的结果应该是 0 才对
    原因是:MySQL 中 sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL函数把 null 转换为 0
    */
    @Query(nativeQuery = true, value = "SELECT SUM(score) FROM `user`")
    Long wrong1();

    /*
    select 记录数量,count 使用一个允许 NULL 的字段,比如 COUNT(score)
    结果为0
    虽然这条记录的 score 是 NULL,但记录总数应该是 1 才对
    原因是:MySQL 中 count 字段不统计 null 值,COUNT(*) 才是统计所有记录数量的正确方式
    */
    @Query(nativeQuery = true, value = "SELECT COUNT(score) FROM `user`")
    Long wrong2();

    /*
    使用 =NULL 条件查询字段值为 NULL 的记录,比如 score=null 条件
    结果为空list
    使用 =NULL 并没有查询到 id=1 的记录,查询条件失效
    原因是:MySQL 中 =NULL 并不是判断条件而是赋值,对 NULL 进行判断只能使用 IS NULL 或者 IS NOT NULL
    */
    @Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score=null")
    List<User> wrong3();

    /*
    结果为0
    */
    @Query(nativeQuery = true, value = "SELECT IFNULL(SUM(score),0) FROM `user`")
    Long right1();

    /*
    结果为1
    */
    @Query(nativeQuery = true, value = "SELECT COUNT(*) FROM `user`")
    Long right2();

    /*
    结果为[User(id=1, score=null)] 
    */
    @Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score IS NULL")
    List<User> right3();

}

12:异常处理

  • 一些业务项目中,我曾看到开发同学在开发业务逻辑时不考虑任何异常处理,项目接近完成时再采用“流水线”的方式进行异常处理,也就是统一为所有方法打上 try…catch…捕获所有异常记录日志,有些技巧的同学可能会使用 AOP 来进行类似的“统一异常处理。其实,这种处理异常的方式非常不可取。

12.1:捕获和处理异常的错

  • “统一异常处理”方式正是我要说的第一个错:不在业务代码层面考虑异常处理,仅在框架层面粗犷捕获和处理异常
  • 为了理解错在何处,我们先来看看大多数业务应用都采用的三层架构:

    • Controller 层负责信息收集、参数校验、转换服务层处理的数据适配前端,轻业务逻辑;

    • Service 层负责核心业务逻辑,包括各种外部服务调用、访问数据库、缓存处理、消息处理等;

    • Repository 层负责数据访问实现,一般没有业务逻辑。

  • image.png
  • 每层架构的工作性质不同,且从业务性质上异常可能分为业务异常和系统异常两大类,这就决定了很难进行统一的异常处理。我们从底向上看一下三层架构:

    • Repository 层出现异常或许可以忽略,或许可以降级,或许需要转化为一个友好的异常。如果一律捕获异常仅记录日志,很可能业务逻辑已经出错,而用户和程序本身完全感知不到
    • Service 层往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法自动回滚。此外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常
    • 如果下层异常上升到 Controller 层还是无法处理的话,Controller 层往往会给予用户友好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视同仁。
  • 因此,我不建议在框架层面进行异常的自动、统一处理,尤其不要随意捕获异常。但,框架可以做兜底工作。如果异常上升到最上层逻辑还是无法处理的话,可以以统一的方式进行异常转换,比如通过 @RestControllerAdvice + @ExceptionHandler,来捕获这些“未处理”异常:

    • 对于自定义的业务异常,以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方
    • 对于无法处理的系统异常,以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方
@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {

    private static int GENERIC_SERVER_ERROR_CODE = 2000;
    private static String GENERIC_SERVER_ERROR_MESSAGE = "服务器忙,请稍后再试";

    @ExceptionHandler
    public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
        if (ex instanceof BusinessException) {
            BusinessException exception = (BusinessException) ex;
            log.warn(String.format("访问 %s -> %s 出现业务异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, exception.getCode(), exception.getMessage());
        } else {
            log.error(String.format("访问 %s -> %s 出现系统异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
        }
    }
}
  • 出现运行时系统异常后,异常处理程序会直接把异常转换为 JSON 返回给调用方;要做得更好,你可以把相关出入参、用户信息在脱敏后记录到日志中,方便出现问题时根据上下文进一步排查
  • 第二个错,捕获了异常后直接生吞。在任何时候,我们捕获了异常都不应该生吞,也就是直接丢弃异常不记录、不抛出。这样的处理方式还不如不捕获异常,因为被生吞掉的异常一旦导致 Bug,就很难在程序中找到蛛丝马迹,使得 Bug 排查工作难上加难
  • 第三个错,丢弃异常的原始信息。
@Slf4j
@RestController
@RequestMapping("handleexception")
public class HandleExceptionController {
    @GetMapping("exception")
    public void exception(@RequestParam("business") boolean b) {
        if (b)
            throw new BusinessException("订单不存在", 2001);
        throw new RuntimeException("系统错误");
    }

    @GetMapping("wrong1")
    public void wrong1() {
        try {
            readFile();
        } catch (IOException e) {
            //原始异常信息丢失
            throw new RuntimeException("系统忙请稍后再试");
        }
    }

    @GetMapping("wrong2")
    public void wrong2() {
        try {
            readFile();
        } catch (IOException e) {
            //只保留了异常消息,栈没有记录
            log.error("文件读取错误, {}", e.getMessage());
            throw new RuntimeException("系统忙请稍后再试");
        }
    }

    @GetMapping("wrong3")
    public void wrong3(@RequestParam("orderId") String orderId) {
        try {
            readFile();
        } catch (Exception e) {
            log.error("文件读取错误", e);
            throw new RuntimeException();
        }
    }

    @GetMapping("right1")
    public void right1() {
        try {
            readFile();
        } catch (IOException e) {
            log.error("文件读取错误", e);
            throw new RuntimeException("系统忙请稍后再试");
        }
    }

    @GetMapping("right2")
    public void right2() {
        try {
            readFile();
        } catch (IOException e) {
            throw new RuntimeException("系统忙请稍后再试", e);
        }
    }
  private void readFile() throws IOException {
      Files.readAllLines(Paths.get("a_file"));
  }
}
  • 总之,如果你捕获了异常打算处理的话,除了通过日志正确记录异常原始信息外,通常还有三种处理模式:

  • 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常
  • 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更严重,需要考虑当前情况是否适合重试
  • 恢复,即尝试进行降级处理,或使用默认值来替代原始数据

  • 注意捕获和处理异常的最佳实践。首先,不应该用 AOP 对所有方法进行统一异常处理,异常要么不捕获不处理,要么根据不同的业务逻辑、不同的异常类型进行精细化、针对性处理;其次,处理异常应该杜绝生吞,并确保异常栈信息得到保留;最后,如果需要重新抛出异常的话,请使用具有意义的异常类型和异常消息

12.2:finally中的异常

  • 有些时候,我们希望不管是否遇到异常,逻辑完成后都要释放资源,这时可以使用 finally代码块而跳过使用 catch 代码块;但要千万小心 finally 代码块中的异常,因为资源释放处理等收尾操作同样也可能出现异常。

  • 对于实现了 AutoCloseable 接口的资源,建议使用 try-with-resources 来释放资源,否则也可能会产生刚才提到的,释放资源时出现

    的异常覆盖主异常的问题。

@RestController
  @Slf4j
  @RequestMapping("finallyissue")
  public class FinallyIssueController {

    
      /*
      同样出现了 finally 中的异常覆盖了 try 中异常的问题
      */
      @GetMapping("useresourcewrong")
      public void useresourcewrong() throws Exception {
          TestResource testResource = new TestResource();
          try {
              testResource.read();
          } finally {
              testResource.close();
          }
      }

      /*
      改为 try-with-resources 模式之后:try 和 finally 中的异常信息都可以得到保留
      */
      @GetMapping("useresourceright")
      public void useresourceright() throws Exception {
          try (TestResource testResource = new TestResource()) {
              testResource.read();
          }
      }

      /*
      最后在日志中只能看到 finally 中的异常,虽然 try 中的逻辑出现了异常,但却被 finally中的异常覆盖了。
      */
      @GetMapping("wrong")
      public void wrong() {
          try {
              log.info("try");
              throw new RuntimeException("try");
          } finally {
              log.info("finally");
              throw new RuntimeException("finally");
          }
      }

      /*
      至于异常为什么被覆盖,原因也很简单,因为一个方法无法出现两个异常。修复方式是,finally 代码块自己负责异常捕获和处理
      */
      @GetMapping("right")
      public void right() {
          try {
              log.info("try");
              throw new RuntimeException("try");
          } finally {
              log.info("finally");
              try {
                  throw new RuntimeException("finally");
              } catch (Exception ex) {
                  log.error("finally", ex);
              }
          }
      }

      /*
      或者可以把 try 中的异常作为主异常抛出,使用 addSuppressed 方法把 finally 中的异常附加到主异常上:
      */
      @GetMapping("right2")
      public void right2() throws Exception {
          Exception e = null;
          try {
              log.info("try");
              throw new RuntimeException("try");
          } catch (Exception ex) {
              e = ex;
          } finally {
              log.info("finally");
              try {
                  throw new RuntimeException("finally");
              } catch (Exception ex) {
                  if (e != null) {
                      e.addSuppressed(ex);
                  } else {
                      e = ex;
                  }
              }
          }
          throw e;
      }
  }
  //=====================================================
  public class TestResource implements AutoCloseable {

      public void read() throws Exception {
          throw new Exception("read error");
      }

      @Override
      public void close() throws Exception {
          throw new Exception("close error");
      }
  }
  • 务必小心 finally 代码块中资源回收逻辑,确保 finally 代码块不出现异常,内部把异常处理完毕,避免 finally 中的异常覆盖 try 中的异常;或者考虑使用 addSuppressed 方法把 finally 中的异常附加到 try 中的异常上,确保主异常信息不丢失。此外,使用实现了

    AutoCloseable 接口的资源,务必使用 try-with-resources 模式来使用资源,确保资源可以正确释放,也同时确保异常可以正确处理

12.3:别把异常定义为静态变量

  • 既然我们通常会自定义一个业务异常类型,来包含更多的异常信息,比如异常错误码、友好的错误提示等,那就需要在业务逻辑各处,手动抛出各种业务异常来返回指定的错误码描述(比如对于下单操作,用户不存在返回 2001,商品缺货返回 2002 等)。
  • 对于这些异常的错误代码和消息,我们期望能够统一管理,而不是散落在程序各处定义。
  • 把异常定义为静态变量会导致异常信息固化,这就和异常的栈一定是需要根据当前调用来动态获取相矛盾
@RestController
@Slf4j
@RequestMapping("predefinedexception")
public class PredefinedExceptionController {

    /*
    运行程序后看到如下日志,cancelOrder got error 的提示对应了 createOrderWrong 方法。显然,cancelOrderWrong 方法在出错后抛出的异常,其实是 createOrderWrong 方法出错的异常
    */
    @GetMapping("wrong")
    public void wrong() {
        try {
            createOrderWrong();
        } catch (Exception ex) {
            log.error("createOrder got error", ex);
        }
        try {
            cancelOrderWrong();
        } catch (Exception ex) {
            log.error("cancelOrder got error", ex);
        }
    }

    /*
    修复方式很简单,改一下 Exceptions 类的实现,通过不同的方法把每一种异常都 new 出来抛出即可
    */
    @GetMapping("right")
    public void right() {

        try {
            createOrderRight();
        } catch (Exception ex) {
            log.error("createOrder got error", ex);
        }
        try {
            cancelOrderRight();
        } catch (Exception ex) {
            log.error("cancelOrder got error", ex);
        }
    }

    private void createOrderWrong() {
        throw Exceptions.ORDEREXISTS;
    }

    private void cancelOrderWrong() {
        throw Exceptions.ORDEREXISTS;
    }

    private void createOrderRight() {
        throw Exceptions.orderExists();
    }

    private void cancelOrderRight() {
        throw Exceptions.orderExists();
    }
}

//============================================
public class Exceptions {

    public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在", 3001);

    public static BusinessException orderExists() {
        return new BusinessException("订单已经存在", 3001);
    }
}
//==============================================
public class BusinessException extends RuntimeException {

    private int code;

    public BusinessException(String message, int code) {
        super(message);
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}
  • 虽然在统一的地方定义收口所有的业务异常是一个不错的实践,但务必确保异常是每次 new 出来的,而不能使用一个预先定义的 static 字段存放异常,否则可能会引起栈信息的错乱

12.4:线程池任务异常处理

  • 把任务提交到线程池处理,任务本身出现异常时会怎样呢?
@RestController
@Slf4j
@RequestMapping("threadpoolandexception")
public class ThreadPoolAndExceptionController {

    //设置全局的默认未捕获异常处理程序
    static {
        Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> log.error("Thread {} got exception", thread, throwable));
    }

    /*
    提交 10 个任务到线程池异步处理,第 5 个任务抛出一个RuntimeException,每个任务完成后都会输出一行日志
    任务 1 到 4 所在的线程是 test0,任务 6 开始运行在线程 test1。由于我的线程池通过线程工厂为线程使用统一的前缀 test 加上计数器进行命名,因此从线程名的改变可以知道因为异常的抛出老线程退出了,线程池只能重新创建一个线程。如果每个异步任务都以异常结束,那么线程池可能完全起不到线程重用的作用。
    因为没有手动捕获异常进行处理,ThreadGroup 帮我们进行了未捕获异常的默认处理,向标准错误输出打印了出现异常的线程名称和异常信息。显然,这种没有以统一的错误日志格式记录错误信息打印出来的形式,对生产级代码是不合适的
    */
    @GetMapping("execute")
    public void execute() throws InterruptedException {

        String prefix = "test";
        ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder()
                .setNameFormat(prefix + "%d")
                .setUncaughtExceptionHandler((thread, throwable) -> log.error("ThreadPool {} got exception", thread, throwable))
                .get());
      
        IntStream.rangeClosed(1, 10).forEach(i -> threadPool.execute(() -> {
            if (i == 5) throw new RuntimeException("error");
            log.info("I'm done : {}", i);
        }));

        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);
    }

    /*
    通过线程池 ExecutorService 的 execute 方法提交任务到线程池处理,如果出现异常会导致线程退出,控制台输出中可以看到异常信息。那么,把 execute 方法改为 submit,线程还会退出吗,异常还能被处理程序捕获到吗?
    修改代码后重新执行程序可以看到如下日志,说明线程没退出,异常也没记录被生吞了
    查看 FutureTask 源码可以发现,在执行任务出现异常之后,异常存到了一个 outcome 字段中,只有在调用 get 方法获取 FutureTask 结果的时候,才会以 ExecutionException 的形式重新抛出异常
    */
    @GetMapping("submit")
    public void submit() throws InterruptedException {

        String prefix = "test";
        ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix + "%d").get());
        IntStream.rangeClosed(1, 10).forEach(i -> threadPool.submit(() -> {
            if (i == 5) throw new RuntimeException("error");
            log.info("I'm done : {}", i);
        }));

        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);
    }

    /*
    我们把 submit 返回的 Future 放到了 List 中,随后遍历 List 来捕获所有任务的异常。这么做确实合乎情理。既然是以 submit 方式来提交任务,那么我们应该关心任务的执行结果,否则应该以 execute 来提交任务
    */
    @GetMapping("submitright")
    public void submitRight() throws InterruptedException {

        String prefix = "test";
        ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix + "%d").get());

        List<Future> tasks = IntStream.rangeClosed(1, 10).mapToObj(i -> threadPool.submit(() -> {
            if (i == 5) throw new RuntimeException("error");
            log.info("I'm done : {}", i);
        })).collect(Collectors.toList());

        tasks.forEach(task -> {
            try {
                task.get();
            } catch (Exception e) {
                log.error("Got exception", e);
            }
        });
        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);
    }
}
  • 确保正确处理了线程池中任务的异常,如果任务通过 execute 提交,那么出现异常会导致线程退出,大量的异常会导致线程重复创建引起性能问题,我们应该尽可能确保任务不出异常,同时设置默认的未捕获异常处理程序来兜底;如果任务通过 submit 提交意味着

    我们关心任务的执行结果,应该通过拿到的 Future 调用其 get 方法来获得任务运行结果和可能出现的异常,否则异常可能就被生吞了

12.5:思考与讨论

  • 关于在 finally 代码块中抛出异常的坑,如果在 finally 代码块中返回值,你觉得程序会以 try 或 catch 中返回值为准,还是以 finally 中的返回值为准呢?
    • JVM采用异常表控制try-catch的跳转逻辑;对于finally中的代码块其实是复制到try和catch中的return和throw之前的方式来处理的。
  • 对于手动抛出的异常,不建议直接使用 Exception 或 RuntimeException,通常建议复用 JDK 中的一些标准异常,比如IllegalArgumentException入参错误、IllegalStateException状态错误、UnsupportedOperationException支持操作错误,你能说说它们的适用场景,并列出更多常用异常吗?
    • IllegalArgumentException: 入参错误,比如参数类型int输入string
    • IllegalStateException: 状态错误,比如订单已经支付完成,二次请求支付接口
    • UnsupportedOperationException: 不支持操作错误,比如对一笔不能退款的订单退款。
    • SecurityException: 权限错误,比如未登陆用户调用修改用户信息接口

12.6:全局异常拦截器

import com.google.common.collect.ImmutableMap;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.servlet.http.HttpServletRequest;

/**
 * 全局异常拦截器,AOP
 *
 * @author qinfen
 * @date 2022/07/15
 */
@ControllerAdvice
@Log4j2
public class GlobalDefultExceptionHandler {

    private static final String MESSAGE_KEY = "message";
    private static final String PATH_KEY = "path";

    @ExceptionHandler
    public ResponseEntity badRequestException(Exception ex, HttpServletRequest request) {
        log.error("GlobalDefultExceptionHandler.badRequestException---> error e:", ex);
        //当异常是NullPointerException时有坑!!!没有message,所以ex.getMessage()又会报空指针
        return ResponseEntity.badRequest().body(ImmutableMap.of(
                MESSAGE_KEY, StringUtils.hasLength(ex.getMessage()) ? ex.getMessage() : ex.toString(),
                PATH_KEY, request.getRequestURI()
        ));
    }
}

13:记录日志的坑

  • 日志框架众多,不同的类库可能会使用不同的日志框架,如何兼容是一个问题
  • 配置复杂且容易出错。比如,重复记录日志的问题、同步日志的性能问题、异步记录的错误配置问题
  • 日志记录本身就有些误区,比如没考虑到日志内容获取的代价、胡乱使用日志级别等
  • image.png
  • SLF4J 实现了三种功能:

    • 一是提供了统一的日志门面 API,即图中紫色部分,实现了中立的日志记录 API
    • 二是桥接功能,即图中蓝色部分,用来把各种日志框架的 API(图中绿色部分)桥接到SLF4J API。这样一来,即便你的程序中使用了各种日志 API 记录日志,最终都可以桥接到 SLF4J 门面 API。
    • 三是适配功能,即图中红色部分,可以实现 SLF4J API 和实际日志框架(图中灰色部分)的绑定。SLF4J 只是日志标准,我们还是需要一个实际的日志框架。日志框架本身没有实现 SLF4J API,所以需要有一个前置转换。Logback 就是按照 SLF4J API 标准实现的,因此不需要绑定模块做转换
  • 需要理清楚的是,虽然我们可以使用 log4j-over-slf4j 来实现 Log4j 桥接到 SLF4J,也可以使用 slf4j-log4j12 实现 SLF4J 适配到 Log4j,也把它们画到了一列,但是它不能同时使用它们,否则就会产生死循环。jcl 和 jul 也是同样的道理
  • 业务系统使用最广泛的是 Logback 和Log4j,它们是同一人开发的。Logback 可以认为是 Log4j 的改进版本,我更推荐使用。
  • 指定日志文件
@SpringBootApplication
public class CommonMistakesApplication {

    public static void main(String[] args) {
        System.setProperty("logging.config", "classpath:org/geekbang/time/commonmistakes/logging/async/asyncwrong.xml");
        SpringApplication.run(CommonMistakesApplication.class, args);
    }
}

13.1:日志会重复记录

  • 日志重复记录在业务上非常常见,不但给查看日志和统计工作带来不必要的麻烦,还会增加磁盘和日志收集系统的负担
  • 第一个案例是,logger 配置继承关系导致日志重复记录
@Slf4j
@RequestMapping("logging")
@RestController
public class LoggingController {

    @GetMapping("log")
    public void log() {
        log.debug("debug");
        log.info("info");
        log.warn("warn");
        log.error("error");
    }
}
  • loggerwrong.xml
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <!--首先将 CONSOLE Appender 定义为 ConsoleAppender,也就是把日志输出到控制台(System.out/System.err);然后通过 PatternLayout 定义了日志的输出格式。-->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
        </layout>
    </appender>
    <!--实现了一个 Logger 配置,将应用包的日志级别设置为 DEBUG、日志输出同样使用 CONSOLE Appender-->
    <logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <!--设置了全局的日志级别为 INFO,日志输出使用 CONSOLE Appender-->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
  • 这段配置看起来没啥问题,但执行方法后出现了日志重复记录的问题

  • 从配置文件可以看到,CONSOLE 这个 Appender 同时挂载到了两个Logger 上,一个是我们定义的,一个是,由于我们定义的

    继承自,所以同一条日志既会通过 logger 记录,也会发送到 root 记录,因此应用 package 下的日志出现了重复记录

  • 如此配置的初衷是实现自定义的 logger 配置,让应用内的日志暂时开启 DEBUG 级别的日志记录。其实,他完全不需要重复挂载 Appender,去掉下挂载的 Appender 即可

  • loggerright.xml

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
        </layout>
    </appender>
    <logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG"/>
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
  • 如果自定义的需要把日志输出到不同的 Appender,比如将应用的日志输出到文件app.log、把其他框架的日志输出到控制台,可以设置的 additivity 属性为 false,这样就不会继承的 Appender 了
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>app.log</file>
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
        </encoder>
    </appender>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
		<layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
		</layout>
	</appender>
    <logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG" additivity="false">
        <appender-ref ref="FILE"/>
    </logger>
	<root level="INFO">
		<appender-ref ref="CONSOLE" />
	</root>
</configuration>
  • 第二个案例是,错误配置 LevelFilter 造成日志重复记录
  • 错误日志是这样配置的:在记录日志到控制台的同时,把日志记录按照不同的级别记录到两个文件中
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
	<property name="logDir" value="./logs" />
	<property name="app.name" value="common-mistakes" />
  
  <!--第一个 ConsoleAppender,用于把所有日志输出到控制台-->
	<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
		<layout class="ch.qos.logback.classic.PatternLayout">
			<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
		</layout>
	</appender>

  <!--定义了一个 FileAppender,用于记录文件日志,并定义了文件名、记录日志的格式和编码等信息-->
	<appender name="INFO_FILE" class="ch.qos.logback.core.FileAppender">
		<File>${logDir}/${app.name}_info.log</File>
    <!--定义的 LevelFilter 过滤日志,将过滤级别设置为 INFO,目的是希望 _info.log 文件中可以记录 INFO 级别的日志-->
		<filter class="ch.qos.logback.classic.filter.LevelFilter">
			<level>INFO</level>
		</filter>
		<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
			<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
			<charset>UTF-8</charset>
		</encoder>
	</appender>
  
  <!--定义了一个类似的 FileAppender,并使用 ThresholdFilter 来过滤日志,过滤级别设置为 WARN,目的是把 WARN 以上级别的日志记录到另一个_error.log 文件中-->
	<appender name="ERROR_FILE" class="ch.qos.logback.core.FileAppender">
		<File>${logDir}/${app.name}_error.log</File>
		<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
			<level>WARN</level>
		</filter>
		<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
			<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
			<charset>UTF-8</charset>
		</encoder>
	</appender>
  
  <!--定义的 root 引用了三个 Appender-->
	<root level="INFO">
		<appender-ref ref="CONSOLE" />
		<appender-ref ref="INFO_FILE"/>
		<appender-ref ref="ERROR_FILE"/>
	</root>
</configuration>
  • 运行程序后,可以看到,_info.log 中包含了 INFO、WARN 和 ERROR 三个级别的日志,不符合我们的预期;error.log 包含了 WARN 和 ERROR 两个级别的日志。因此,造成了日志的重复收集。
  • 分析 ThresholdFilter 的源码发现,当日志级别大于等于配置的级别时返回 NEUTRAL,继续调用过滤器链上的下一个过滤器;否则,返回 DENY 直接拒绝记录日志
  • LevelFilter 用来比较日志级别,然后进行相应处理:如果匹配就调用 onMatch 定义的处理方式,默认是交给下一个过滤器处理(AbstractMatcherFilter 基类中定义的默认值);否则,调用 onMismatch 定义的处理方式,默认也是交给下一个过滤器处理
  • 和 ThresholdFilter 不同的是,LevelFilter 仅仅配置 level 是无法真正起作用的。由于没有配置 onMatch 和 onMismatch 属性,所以相当于这个过滤器是无用的,导致 INFO 以上级别的日志都记录了
  • 定位到问题后,修改方式就很明显了:配置 LevelFilter 的 onMatch 属性为 ACCEPT,表示接收 INFO 级别的日志;配置 onMismatch 属性为 DENY,表示除了 INFO 级别都不记录;这样修改后,_info.log 文件中只会有 INFO 级别的日志,不会出现日志重复的问题了
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
	<property name="logDir" value="./logs" />
	<property name="app.name" value="common-mistakes" />
	<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
		<layout class="ch.qos.logback.classic.PatternLayout">
			<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
		</layout>
	</appender>

	<appender name="INFO_FILE" class="ch.qos.logback.core.FileAppender">
		<File>${logDir}/${app.name}_info.log</File>
		<filter class="ch.qos.logback.classic.filter.LevelFilter">
			<level>INFO</level>
			<onMatch>ACCEPT</onMatch>
			<onMismatch>DENY</onMismatch>
		</filter>
		<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
			<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
			<charset>UTF-8</charset>
		</encoder>
	</appender>
	<appender name="ERROR_FILE" class="ch.qos.logback.core.FileAppender">
		<File>${logDir}/${app.name}_error.log</File>
		<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
			<level>WARN</level>
		</filter>
		<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
			<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
			<charset>UTF-8</charset>
		</encoder>
	</appender>

	<root level="INFO">
		<appender-ref ref="CONSOLE" />
		<appender-ref ref="INFO_FILE"/>
		<appender-ref ref="ERROR_FILE"/>
	</root>

</configuration>

13.2:异步日志改善性能的坑

  • 如何避免日志记录成为应用的性能瓶颈。这可以帮助我们解决,磁盘(比如机械磁盘)IO 性能较差、日志量又很大的情况下,如何记录日志的问题
  • 我们先来测试一下,记录日志的性能问题,定义如下的日志配置,一共有两个Appender:FILE 是一个 FileAppender,用于记录所有的日志;CONSOLE 是一个 ConsoleAppender,用于记录带有 time 标记的日志。
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>app.log</file>
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
        </encoder>
    </appender>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
        </layout>
        
        <!--这段代码中有个 EvaluatorFilter(求值过滤器),用于判断日志是否符合某个条件;这里我们使用 EvaluatorFilter 对日志按照标记进行过滤,并将过滤出的日志单独输出到控制台上。-->
        <filter class="ch.qos.logback.core.filter.EvaluatorFilter">
            <evaluator class="ch.qos.logback.classic.boolex.OnMarkerEvaluator">
                <!--我们给输出测试结果的那条日志上做了 time 标记。-->
                <marker>time</marker>
            </evaluator>
            <onMismatch>DENY</onMismatch>
            <onMatch>ACCEPT</onMatch>
        </filter>
    </appender>
    <root level="INFO">
        <appender-ref ref="FILE"/>
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
/*
如下测试代码中,实现了记录指定次数的大日志,每条日志包含 1MB 字节的模拟数据,最后记录一条以 time 为标记的方法执行耗时日志
*/
@GetMapping("performance")
public void performance(@RequestParam(name = "count", defaultValue = "1000") int count) {
        long begin = System.currentTimeMillis();
        String payload = IntStream.rangeClosed(1, 1000000)
                .mapToObj(__ -> "a")
                .collect(Collectors.joining("")) + UUID.randomUUID().toString();
        IntStream.rangeClosed(1, count).forEach(i -> log.info("{} {}", i, payload));
        Marker timeMarker = MarkerFactory.getMarker("time");
        log.info(timeMarker, "took {} ms", System.currentTimeMillis() - begin);
}
  • 执行程序后可以看到,记录 1000 次日志和 10000 次日志的调用耗时,分别是 6.3 秒和44.5 秒;对于只记录文件日志的代码了来说,这个耗时挺长的。
  • FileAppender 继承自 OutputStreamAppender,查看 OutputStreamAppender 源码的第 30 到 33 行发现,在追加日志的时候,是直接把日志写入 OutputStream 中,属于同步记录日志;那,有没有办法实现大量日志写入时,不会过多影响业务逻辑执行耗时,影响吞吐量呢?办法当然有了,使用 Logback 提供的 AsyncAppender 即可实现异步的日志记录
  • AsyncAppende 类似装饰模式,也就是在不改变类原有基本功能的情况下为其增添新功能。这样,我们就可以把 AsyncAppender 附加在其他的 Appender 上,将其变为异步的。
  • 定义一个异步 Appender ASYNCFILE,包装之前的同步文件日志记录的 FileAppender,就可以实现异步记录日志到文件
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>app.log</file>
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
        </encoder>
    </appender>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
        </layout>
        <filter class="ch.qos.logback.core.filter.EvaluatorFilter">
            <evaluator class="ch.qos.logback.classic.boolex.OnMarkerEvaluator">
                <marker>time</marker>
            </evaluator>
            <onMismatch>DENY</onMismatch>
            <onMatch>ACCEPT</onMatch>
        </filter>
    </appender>
    <appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="FILE"/>
    </appender>
    <root level="INFO">
        <appender-ref ref="ASYNCFILE"/>
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
  • 测试一下可以发现,记录 1000 次日志和 10000 次日志的调用耗时,分别是 735 毫秒和668 毫秒
  • 异步日志真的如此神奇和万能吗?当然不是,因为这样并没有记录下所有日志。我之前就遇到过很多关于 AsyncAppender 异步日志的坑,这些坑可以归结为三类:

    • 记录异步日志撑爆内存;
    • 记录异步日志出现日志丢失;
    • 记录异步日志出现阻塞。
  • 为了解释这三种坑,我来模拟一个慢日志记录场景
/*
自定义一个继承自ConsoleAppender 的 MySlowAppender,作为记录到控制台的输出器,写入日志时休眠1 秒
*/
public class MySlowAppender extends ConsoleAppender {
    @Override
    protected void subAppend(Object event) {
        try {
            // 模拟慢日志
            TimeUnit.MILLISECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        super.subAppend(event);
    }
}
  • 然后,在配置文件中使用 AsyncAppender,将 MySlowAppender 包装为异步日志记录
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="CONSOLE" class="org.geekbang.time.commonmistakes.logging.async.MySlowAppender">
		<layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
		</layout>
	</appender>
	<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
		<appender-ref ref="CONSOLE" />
	</appender>
	<root level="INFO">
		<appender-ref ref="ASYNC" />
	</root>
</configuration>
  • 定义一段测试代码,循环记录一定次数的日志,最后输出方法执行耗时:
    @GetMapping("manylog")
    public void manylog(@RequestParam(name = "count", defaultValue = "1000") int count) {
        long begin = System.currentTimeMillis();
        IntStream.rangeClosed(1, count).forEach(i -> log.info("log-{}", i));
        System.out.println("took " + (System.currentTimeMillis() - begin) + " ms");
    }
  • 执行方法后发现,耗时很短但出现了日志丢失:我们要记录 1000 条日志,最终控制台只能搜索到 215 条日志,而且日志的行号变为了一个问号

  • 出现这个问题的原因在于,AsyncAppender 提供了一些配置参数,而我们没用对。我们结合相关源码分析一下:

    • includeCallerData 用于控制是否收集调用方数据,默认是 false,此时方法行号、方法名等信息将不能显示
    • queueSize 用于控制阻塞队列大小,使用的 ArrayBlockingQueue 阻塞队列,默认大小是 256,即内存中最多保存 256 条日志。
    • discardingThreshold 是控制丢弃日志的阈值,主要是防止队列满后阻塞。默认情况下,队列剩余量低于队列长度的 20%,就会丢弃 TRACE、DEBUG 和 INFO 级别的日志。
    • neverBlock 用于控制队列满的时候,加入的数据是否直接丢弃,不会阻塞等待,默认是false。这里需要注意一下 offer 方法和 put 方法的区别,当队列满的时候 offer 方法不阻塞,而 put 方法会阻塞;neverBlock 为 true 时,使用 offer方法。
  • 看到默认队列大小为 256,达到 80% 容量后开始丢弃 <=INFO 级别的日志后,我们就可以理解日志中为什么只有 215 条 INFO 日志了。

  • 我们可以继续分析下异步记录日志出现坑的原因。

    • queueSize 设置得特别大,就可能会导致 OOM。

    • queueSize 设置得比较小(默认值就非常小),且 discardingThreshold 设置为大于 0的值(或者为默认值),队列剩余容量少于 discardingThreshold 的配置就会丢弃<=INFO 的日志。这里的坑点有两个。一是,因为 discardingThreshold 的存在,设置

      queueSize 时容易踩坑。比如,本例中最大日志并发是 1000,即便设置 queueSize 为1000 同样会导致日志丢失。二是,discardingThreshold 参数容易有歧义,它不是百分比,而是日志条数。对于总容量 10000 的队列,如果希望队列剩余容量少于 1000 条的时候丢弃,需要配置为 1000。

    • neverBlock 默认为 false,意味着总可能会出现阻塞。如果 discardingThreshold 为0,那么队列满时再有日志写入就会阻塞;如果 discardingThreshold 不为 0,也只会丢弃 <=INFO 级别的日志,那么出现大量错误日志时,还是会阻塞程序。

  • 可以看出 queueSize、discardingThreshold 和 neverBlock 这三个参数息息相关,务必按需进行设置和取舍,到底是性能为先,还是数据不丢为先:

    • 如果考虑绝对性能为先,那就设置 neverBlock 为 true,永不阻塞。
    • 如果考虑绝对不丢数据为先,那就设置 discardingThreshold 为 0,即使是 <=INFO 的级别日志也不会丢,但最好把 queueSize 设置大一点,毕竟默认的 queueSize 显然太小,太容易阻塞。
    • 如果希望兼顾两者,可以丢弃不重要的日志,把 queueSize 设置大一点,再设置一个合理的 discardingThreshold。
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="CONSOLE" class="org.geekbang.time.commonmistakes.logging.async.MySlowAppender">
		<layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
		</layout>
	</appender>
	<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
		<appender-ref ref="CONSOLE" />
        <includeCallerData>true</includeCallerData>
            <discardingThreshold>200</discardingThreshold>
            <queueSize>1000</queueSize>
            <neverBlock>true</neverBlock>
	</appender>
	<root level="INFO">
		<appender-ref ref="ASYNC" />
	</root>
</configuration>

13.3:占位符与日志级别判断

  • SLF4J 的{}占位符语法,到真正记录日志时才会获取实际参数,因此解决了日志数据获取的性能问题。你觉得,这种说法对吗?
@Log4j2
@RequestMapping("logging")
@RestController
public class LoggingController {

    @GetMapping
    public void index() {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start("debug1");
        log.debug("debug1:" + slowString("debug1"));
        stopWatch.stop();
        stopWatch.start("debug2");
        log.debug("debug2:{}", slowString("debug2"));
        stopWatch.stop();
        stopWatch.start("debug3");
        if (log.isDebugEnabled())
            log.debug("debug3:{}", slowString("debug3"));
        stopWatch.stop();
        stopWatch.start("debug4");
        log.debug("debug4:{}", () -> slowString("debug4"));
        stopWatch.stop();
        log.info(stopWatch.prettyPrint());

    }

    private String slowString(String s) {
        System.out.println("slowString called via " + s);
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
        }
        return "OK";
    }
}
  • 使用{}占位符语法不能通过延迟参数值获取,来解决日志数据获取的性能问题。

  • 日志框架提供的参数化日志记录方式不能完全取代日志级别的判断。如果你的日志量很大,获取日志参数代价也很大,就要进行相应日志级别的判断,避免不记录日志也要花费时间获取日志参数的问题。

13.4:基于traceid的链路日志记录AOP

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * 日志切面
 * 因为方法出现异常时和@ControllerAdvice切面同时触发,要指定顺序@Order,先让全局异常拦截先执行
 *
 * @author qinfen
 * @date 2022/07/15
 */
@Slf4j
@Component
@Aspect
@Order(2)
public class LogAop {


    /**
     * 全局异常处理类全限定名
     */
    private final String exceptionAdviceName = GlobalDefultExceptionHandler.class.getName();
    /**
     * 用来保存一条请求的入参信息
     */
    private static final ThreadLocal<StringBuilder> THREAD_LOCAL = new ThreadLocal<>();


    /**
     * 切点为所有controller方法和全局异常处理方法
     *
     * @author qinfen
     * @date 2022/07/15
     */
    @Pointcut("execution(* com.laosiji.caracehelper.controller..*.*(..)) || execution(* com.laosiji.caracehelper.exception.handle.GlobalDefultExceptionHandler.*(..))")
    public void logAroundPointCut(){}
  
  	/**
    * 以自定义 @WebLog 注解为切点
    */
   @Pointcut("@annotation(com.common.annotation.WebLog) || execution(* com.laosiji.caracehelper.exception.handle.GlobalDefultExceptionHandler.badRequestException(..))")
   public void WebLogPointCut() {
   }

    /**
     * controller层入参日志记录
     */
    //@Around("logAroundPointCut()")
    @Around("WebLogPointCut()")
    public Object intoControllerLog(ProceedingJoinPoint point) throws Throwable {
        
        //获取方法上的注解
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        Method method = methodSignature.getMethod();
        WebLog webLog = method.getAnnotation(WebLog.class);

        // 目标方法所在类全限定名
        String targetName = point.getSignature().getDeclaringType().getName();
        // 目标方法签名
        StringBuilder sb;

        //处理请求异常情况
        if (targetName.equals(exceptionAdviceName)) {
            // 异常处理则从threadLocal中取出入参
            sb = THREAD_LOCAL.get();
            // controller参数解析异常不会进入目标方法, 而直接请求异常处理器
            sb = sb == null ? new StringBuilder(300).append("controller参数解析异常, 未进入controller ") : sb;
        } else {
            //设置traceid
          	String traceId = String.valueOf(UUID.randomUUID());
            MDC.put("traceId", traceId);
          	//如果是分布式系统,还要考虑下游服务,可以把traceid放在请求头中传递过去
          
            // 调用目标方法, 将参数存入到threadLocal,  便于发生异常时取到请求参数
            sb = new StringBuilder(300);
            // 类名+方法名
            String target = point.getSignature().getDeclaringTypeName() + "." + point.getSignature().getName();
            Object[] args = point.getArgs();
            String[] paramsName =  ((MethodSignature) point.getSignature()).getParameterNames();

            if (ObjectUtil.isNotNull(webLog)) {
                sb.append("方法说明:【").append(webLog.description()).append("】");
            }
            if (ObjectUtil.isNotNull(webLog) && webLog.printParam()) {
                sb.append(target).append(" 入参:【");
                if (args != null && paramsName != null && args.length > 0 && paramsName.length > 0) {
                    for (int i = 0; i < paramsName.length; i++) {
                        sb.append(" ").append(paramsName[i]).append(" = ").append(args[i]).append(",");
                    }
                    sb.deleteCharAt(sb.length() - 1);
                }
                sb.append("】");
            }
            THREAD_LOCAL.set(sb);
        }

        // 调用目标方法
        Object result = point.proceed();
        if (ObjectUtil.isNotNull(webLog) && webLog.printResult()) {
            sb.append(" 出参:【").append(JSONUtil.toJsonStr(result)).append("】");
        }
        // 记录日志
        log.info(sb.toString());
        THREAD_LOCAL.remove();
      	
      	//请求结束,清除traceid,防止内存泄露
        MDC.remove("traceId");
        // 调用结果返回
        return result;
    }
}

全局异常拦截器

/**
 * 全局异常拦截器,AOP
 *
 * @author qinfen
 * @date 2022/07/15
 */
@ControllerAdvice
@Log4j2
public class GlobalDefultExceptionHandler {

    private static final String MESSAGE_KEY = "message";
    private static final String PATH_KEY = "path";

    @ExceptionHandler
    public ResponseEntity badRequestException(Exception ex, HttpServletRequest request) {
        log.error("GlobalDefultExceptionHandler.badRequestException---> error e:", ex);
        //当异常是NullPointerException时有坑!!!没有message,所以ex.getMessage()又会报空指针
        return ResponseEntity.badRequest().body(ImmutableMap.of(
                MESSAGE_KEY, StringUtils.hasLength(ex.getMessage()) ? ex.getMessage() : ex.toString(),
                PATH_KEY, request.getRequestURI()
        ));
    }
}

为了方便灵活打印指定方法的日志,自定义日志注解

import java.lang.annotation.*;
 
/**
 * @Description: 自定义日志注解
 */
//什么时候使用该注解,我们定义为运行时
@Retention(RetentionPolicy.RUNTIME)
//注解用于什么地方,我们定义为作用于方法上
@Target({ElementType.METHOD})
//注解是否将包含在 JavaDoc 中
@Documented
public @interface WebLog {
 
    /**
     * 日志描述信息
     *
     * @return
     */
    String description() default "";
    
    /**
     *  是否打印入参
     * @return boolean
     */
    boolean printParam() default true;

    /**
     * 是否打印出参
     * @return boolean
     */
    boolean printResult() default true;
}

在分布式环境中一般使用 ELK 来统一收集日志,但是在并发大时使用日志定位问题还是比较麻烦,由于大量的其他用户/其他线程的日志也一起输出穿行其中导致很难筛选出指定请求的全部相关日志,以及下游线程/服务对应的日志。

MDC(Mapped Diagnostic Context,映射调试上下文)是 log4j 和 logback 提供的一种方便在多线程条件下记录日志的功能。MDC 可以看成是一个与当前线程绑定的Map,可以往其中添加键值对。MDC 中包含的内容可以被同一线程中执行的代码所访问。当前线程的子线程会继承其父线程中的 MDC 的内容。当需要记录日志时,只需要从 MDC 中获取所需的信息即可。MDC 的内容则由程序在适当的时候保存进去。 logback配置文件模板格式添加标识

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
<!--    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>-->
<!--    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>-->

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{traceId}] [%thread] %-5level %logger{20} - [%method,%line] - %msg%n</pattern>
        </layout>
    </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{traceId}] [%thread] %-5level %logger{20} - [%method,%line] - %msg%n</pattern>
            <charset>${FILE_LOG_CHARSET}</charset>
        </encoder>
        <file>${LOG_FILE}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern>
            <cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
            <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize>
            <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap>
            <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-7}</maxHistory>
        </rollingPolicy>
    </appender>

    <if condition='"${logstash.enabled}".contains("true")'>
        <then>
            <springProperty scope="context" name="profile" source="spring.profiles.active" defaultValue="dev"/>
            <springProperty scope="context" name="logstash_host" source="logstash.host"/>
            <springProperty scope="context" name="appname" source="spring.application.name"/>
            <appender name="stash" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
                <destination>${logstash_host}</destination>
                <!-- encoder is required -->
                <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                    <customFields>{"appname":"${appname}","env":"${profile}"}</customFields>
                    <!-- https://github.com/logstash/logstash-logback-encoder#context-fields -->
                    <includeContext>false</includeContext>
                </encoder>
            </appender>
        </then>
    </if>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
        <if condition='"${logstash.enabled}".contains("true")'>
            <then>
                <appender-ref ref="stash"/>
            </then>
        </if>
    </root>
</configuration>

当调用异步方法时,需要把traceid从主线程传递到子线程

定义一个线程池

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

/**
 * MdcThreadPoolConfiguration - 用于链路追踪MDC的线程池\在多线程情况下会将主线程的上下文传递给子线程
 *
 */
@EnableAsync
@Configuration
public class MdcThreadPoolConfiguration {
    private int corePoolSize = 50;
    private int maxPoolSize = 200;
    private int queueCapacity = 1000;
    private int keepAliveSeconds = 300;

    @Bean(name = "threadPoolTaskExecutor")
    public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setMaxPoolSize(maxPoolSize);
        executor.setCorePoolSize(corePoolSize);
        executor.setQueueCapacity(queueCapacity);
        executor.setKeepAliveSeconds(keepAliveSeconds);
        executor.setTaskDecorator(new MdcTaskDecorator());
        // 线程池对拒绝任务(无线程可用)的处理策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}

线程池上下文传递

import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;

import java.util.Map;

/**
 * 任务适配器
 */
public class MdcTaskDecorator implements TaskDecorator {
    /**
     * 使异步线程池获得主线程的上下文
     *
     * @param runnable
     * @return
     */
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> map = MDC.getCopyOfContextMap();
        return () -> {
            try {
                MDC.setContextMap(map);
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

异步方法

@Async("threadPoolTaskExecutor")
public void streamChatCompletion(){
    log.info("this is a info");
    log.error("this is a error");
}

14:高效正确的文件读写

  • 从字符编码、缓冲区和文件句柄释放这 3 个常见问题出发

14.1:文件读写确保编码一致

@Slf4j
public class FileBadEncodingIssueApplication {

    public static void main(String[] args) throws IOException {
        init();
        wrong();
        right1();
        right2();
    }

    /*
    使用 GBK 编码把“你好 hi”写入一个名为 hello.txt 的文本文件,然后直接以字节数组形式读取文件内容,转换为十六进制字符串输出到日志中
    虽然我们打开文本文件时看到的是“你好 hi”,但不管是什么文字,计算机中都是按照一定的规则将其以二进制保存的。这个规则就是字符集,字符集枚举了所有支持的字符映射成二进制的映射表。在处理文件读写的时候,如果是在字节层面进行操作,那么不会涉及字符编码问题;而如果需要在字符层面进行读写的话,就需要明确字符的编码方式也就是字符集了。
    */
    private static void init() throws IOException {
        Files.deleteIfExists(Paths.get("hello.txt"));
        Files.write(Paths.get("hello.txt"), "你好hi".getBytes(Charset.forName("GBK")));
        log.info("bytes:{}", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello.txt"))).toUpperCase());
    }

    /*
    可以看到,是使用了 FileReader 类以字符方式进行文件读取,日志中读取出来的“你好”变为了乱码
    FileReader 是以当前机器的默认字符集来读取文件的,如果希望指定字符集的话,需要直接使用 InputStreamReader 和 FileInputStream
    FileReader 虽然方便但因为使用了默认字符集对环境产生了依赖
    */
    private static void wrong() throws IOException {
        log.info("charset: {}", Charset.defaultCharset());

        char[] chars = new char[10];
        String content = "";
        try (FileReader fileReader = new FileReader("hello.txt")) {
            int count;
            while ((count = fileReader.read(chars)) != -1) {
                content += new String(chars, 0, count);
            }
        }
        log.info("result:{}", content);

        //写一段代码输出当前机器的默认字符集,以及UTF-8 方式编码的“你好 hi”的十六进制字符串
        Files.write(Paths.get("hello2.txt"), "你好hi".getBytes(Charsets.UTF_8));
        log.info("bytes:{}", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello2.txt"))).toUpperCase());

    }
  /*
	直接使用 FileInputStream 拿文件流,然后使用 InputStreamReader 读取字符流,并指定字符集为 GBK
	*/
  private static void right1() throws IOException {

      char[] chars = new char[10];
      String content = "";
      try (FileInputStream fileInputStream = new FileInputStream("hello.txt");
           InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, Charset.forName("GBK"))) {
          int count;
          while ((count = inputStreamReader.read(chars)) != -1) {
              content += new String(chars, 0, count);
          }
      }

      log.info("result: {}", content);
  }

	/*
	如果你觉得这种方式比较麻烦的话,使用 JDK1.7 推出的 Files 类的 readAllLines 方法,可以很方便地用一行代码完成文件内容读取
	但这种方式有个问题是,读取超出内存大小的大文件时会出现 OOM。为什么呢?
	打开 readAllLines 方法的源码可以看到,readAllLines 读取文件所有内容后,放到一个List<String> 中返回,如果内存无法容纳这个 List,就会 OOM
	那么,有没有办法实现按需的流式读取呢?比如,需要消费某行数据时再读取,而不是把整个文件一次性读取到内存?
	解决方案就是 File 类的 lines 方法
	*/
  private static void right2() throws IOException {
      log.info("result: {}", Files.readAllLines(Paths.get("hello.txt"), Charset.forName("GBK")).stream().findFirst().orElse(""));
  }
 }

14.2:Files类静态方法

  • 与 readAllLines 方法返回 List<String> 不同,lines 方法返回的是 Stream<String>。这,使得我们在需要时可以不断读取、使用文件中的内容,而不是一次性地把所有内容都读取到内存中,因此避免了 OOM。
import static java.nio.charset.StandardCharsets.UTF_8;
  import static java.nio.file.StandardOpenOption.CREATE;
  import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;

  @Slf4j
  public class CommonMistakesApplication {

      public static void main(String[] args) throws IOException {
          init();
          //readLargeFileRight();
          //readLargeFileWrong();
          wrong();
      }

      private static void readLargeFileWrong() throws IOException {
          log.info("lines {}", Files.readAllLines(Paths.get("large.txt")).size());
      }

      private static void readLargeFileRight() throws IOException {
          AtomicLong atomicLong = new AtomicLong();
          Files.lines(Paths.get("large.txt")).forEach(line -> atomicLong.incrementAndGet());
          log.info("lines {}", atomicLong.get());
      }

      /*
      首先输出这个文件的大小,然后计算读取 20 万行数据和 200 万行数据的耗时差异,最后逐行读取文件,统计文件的总行数
      可以看到,实现了全文件的读取、统计了整个文件的行数,并没有出现 OOM;读取 200万行数据耗时 760ms,读取 20 万行数据仅需 267ms。这些都可以说明,File.lines 方法并不是一次性读取整个文件的,而是按需读取。
      这段代码是否有问题?有,问题在于读取完文件后没有关闭。见下方wrong方法
      */
      private static void linesTest() throws IOException {
          log.info("file size:{}", Files.size(Paths.get("large.txt")));
          StopWatch stopWatch = new StopWatch();
          stopWatch.start("read 200000 lines");
          log.info("lines {}", Files.lines(Paths.get("large.txt")).limit(200000).collect(Collectors.toList()).size());
          stopWatch.stop();
          stopWatch.start("read 2000000 lines");
          log.info("lines {}", Files.lines(Paths.get("large.txt")).limit(2000000).collect(Collectors.toList()).size());
          stopWatch.stop();
          log.info(stopWatch.prettyPrint());
        
          //使用Files.lines方法统计文件总行数
          AtomicLong atomicLong = new AtomicLong();
          Files.lines(Paths.get("large.txt")).forEach(line->atomicLong.incrementAndGet())log.info("total lines {}", atomicLong.get());
      }

      private static void init() throws IOException {

          String payload = IntStream.rangeClosed(1, 1000)
                  .mapToObj(__ -> "a")
                  .collect(Collectors.joining("")) + UUID.randomUUID().toString();
  //        Files.deleteIfExists(Paths.get("large.txt"));
  //        IntStream.rangeClosed(1, 10).forEach(__ -> {
  //            try {
  //                Files.write(Paths.get("large.txt"),
  //                        IntStream.rangeClosed(1, 500000).mapToObj(i -> payload).collect(Collectors.toList())
  //                        , UTF_8, CREATE, APPEND);
  //            } catch (IOException e) {
  //                e.printStackTrace();
  //            }
  //        });
        
          //随便写入 10 行数据到一个 demo.txt 文件中
          Files.write(Paths.get("demo.txt"),
                  IntStream.rangeClosed(1, 10).mapToObj(i -> UUID.randomUUID().toString()).collect(Collectors.toList())
                  , UTF_8, CREATE, TRUNCATE_EXISTING);
      }

      /*
      运行后马上可以在日志中看到如下错误:Too many open files
      使用 lsof 命令查看进程打开的文件,可以看到打开了 1 万多个 demo.txt
      使用流式处理,如果不显式地告诉程序什么时候用完了流,程序又如何知道呢,它也不能帮我们做主何时关闭文件。修复方式很简单,使用 try 来包裹 Stream 即可
      */
      private static void wrong() {
          //ps aux | grep CommonMistakesApplication
          //lsof -p 63937
        
          //使用 Files.lines 方法读取这个文件 100 万次,每读取一行计数器 +1
          LongAdder longAdder = new LongAdder();
          IntStream.rangeClosed(1, 1000000).forEach(i -> {
              try {
                  Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment());
              } catch (IOException e) {
                  e.printStackTrace();
              }
          });
          log.info("total : {}", longAdder.longValue());
      }

      /*
      修改后的代码不再出现错误日志,因为读取了 100 万次包含 10 行数据的文件,所以最终正确输出了 1000 万
      查看 lines 方法源码可以发现,Stream 的 close 注册了一个回调,来关闭BufferedReader 进行资源释放
      使用 BufferedReader 进行字符流读取时,用到了缓冲。这里缓冲Buffer 的意思是,使用一块内存区域作为直接操作的中转
      比如,读取文件操作就是一次性读取一大块数据(比如 8KB)到缓冲区,后续的读取可以直接从缓冲区返回数据,而不是每次都直接对应文件 IO。写操作也是类似。如果每次写几十字节到文件都对应一次 IO 操作,那么写一个几百兆的大文件可能就需要千万次的 IO 操作,耗时会非常久。
      */
      private static void right() {
          //https://docs.oracle.com/javase/8/docs/api/java/nio/file/Files.html
          LongAdder longAdder = new LongAdder();
          IntStream.rangeClosed(1, 1000000).forEach(i -> {
              try {
                  try (Stream<String> lines = Files.lines(Paths.get("demo.txt"))) {
                      lines.forEach(line -> longAdder.increment());
                  }
              } catch (IOException e) {
                  e.printStackTrace();
              }
          });
          log.info("total : {}", longAdder.longValue());
      }
  }

14.3:注意缓冲区

  • 案例,一段先进行文件读入再简单处理后写入另一个文件的业务代码,由于开发人员使用了单字节的读取写入方式,导致执行得巨慢,业务量上来后需要数小时才能完成。
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardOpenOption.*;

@Slf4j
public class CommonMistakesApplication {

    public static void main(String[] args) throws IOException {

        StopWatch stopWatch = new StopWatch();
        init();
        stopWatch.start("perByteOperation");
        perByteOperation();
        stopWatch.stop();
        stopWatch.start("bufferOperationWith100Buffer");
        bufferOperationWith100Buffer();
        stopWatch.stop();
        stopWatch.start("bufferedStreamByteOperation");
        bufferedStreamByteOperation();
        stopWatch.stop();
        stopWatch.start("bufferedStreamBufferOperation");
        bufferedStreamBufferOperation();
        stopWatch.stop();
        stopWatch.start("largerBufferOperation");
        largerBufferOperation();
        stopWatch.stop();
        stopWatch.start("fileChannelOperation");
        fileChannelOperation();
        stopWatch.stop();
        log.info(stopWatch.prettyPrint());
    }

    private static void init() throws IOException {

        //创建一个文件随机写入 100 万行数据,文件大小在 35MB 左右
        Files.write(Paths.get("src.txt"),
                IntStream.rangeClosed(1, 1000000).mapToObj(i -> UUID.randomUUID().toString()).collect(Collectors.toList())
                , UTF_8, CREATE, TRUNCATE_EXISTING);
    }

    /*
    不对数据进行处理,直接把原文件数据写入目标文件,相当于文件复制
    复制一个 35MB 的文件居然耗时 190 秒
    显然,每读取一个字节、每写入一个字节都进行一次 IO 操作,代价太大了。
    */
    private static void perByteOperation() throws IOException {
        Files.deleteIfExists(Paths.get("dest.txt"));

        try (FileInputStream fileInputStream = new FileInputStream("src.txt");
             FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
            int i;
            while ((i = fileInputStream.read()) != -1) {
                fileOutputStream.write(i);
            }
        }
    }

    /*
    解决方案就是,考虑使用缓冲区作为过渡,一次性从原文件读取一定数量的数据到缓冲区,一次性写入一定数量的数据到目标文件。
    仅仅使用了 100 个字节的缓冲区作为过渡,完成 35M 文件的复制耗时缩短到了 26 秒
    在进行文件 IO 处理的时候,使用合适的缓冲区可以明显提高性能
    */
    private static void bufferOperationWith100Buffer() throws IOException {
        Files.deleteIfExists(Paths.get("dest.txt"));

        try (FileInputStream fileInputStream = new FileInputStream("src.txt");
             FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
            byte[] buffer = new byte[100];
            int len = 0;
            while ((len = fileInputStream.read(buffer)) != -1) {
                fileOutputStream.write(buffer, 0, len);
            }
        }
    }

    //直接使用FileInputStream和FileOutputStream,再使用一个8KB的缓冲
    private static void largerBufferOperation() throws IOException {
        Files.deleteIfExists(Paths.get("dest.txt"));

        try (FileInputStream fileInputStream = new FileInputStream("src.txt");
             FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
            byte[] buffer = new byte[8192];
            int len = 0;
            while ((len = fileInputStream.read(buffer)) != -1) {
                fileOutputStream.write(buffer, 0, len);
            }
        }
    }

    
    //额外使用一个8KB缓冲,再使用BufferedInputStream和BufferedOutputStream
    private static void bufferedStreamBufferOperation() throws IOException {
        Files.deleteIfExists(Paths.get("dest.txt"));

        try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("src.txt"));
             BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream("dest.txt"))) {
            byte[] buffer = new byte[8192];
            int len = 0;
            while ((len = bufferedInputStream.read(buffer)) != -1) {
                bufferedOutputStream.write(buffer, 0, len);
            }
        }
    }

    //使用BufferedInputStream和BufferedOutputStream
    private static void bufferedStreamByteOperation() throws IOException {
        Files.deleteIfExists(Paths.get("dest.txt"));

        try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("src.txt"));
             BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream("dest.txt"))) {
            int i;
            while ((i = bufferedInputStream.read()) != -1) {
                bufferedOutputStream.write(i);
            }
      }
    }

    /*
    对于类似的文件复制操作,如果希望有更高性能,可以使用FileChannel 的 transfreTo 方法进行流的复制。在一些操作系统(比如高版本的 Linux 和UNIX)上可以实现 DMA(直接内存访问),也就是数据从磁盘经过总线直接发送到目标文件,无需经过内存和 CPU 进行数据中转
    */
    private static void fileChannelOperation() throws IOException {
        Files.deleteIfExists(Paths.get("dest.txt"));

        FileChannel in = FileChannel.open(Paths.get("src.txt"), StandardOpenOption.READ);
        FileChannel out = FileChannel.open(Paths.get("dest.txt"), CREATE, WRITE);
        in.transferTo(0, in.size(), out);
    }
}
  • 文件操作因为涉及操作系统和文件系统的实现,JDK 并不能确保所有 IO API 在所有平台的逻辑一致性,代码迁移到新的操作系统或文件系统时,要重新进行功能测试和性能测试。

14.4:思考与讨论

  • Files.lines 方法进行流式处理,需要使用 try-with-resources 进行资源释放。那么,使用 Files 类中其他返回 Stream 包装对象的方法进行流式处理,比如newDirectoryStream 方法返回 DirectoryStream<Path>,list、walk 和 find 方法返回 Stream<Path>,也同样有资源释放问题吗?
    • 都间接实现了autoCloseable接口,所以都可以使用try-with-resources进行释放。
  • Java 的 File 类和 Files 类提供的文件复制、重命名、删除等操作,是原子性的吗?
    • 非原子性,没有锁,也没有异常后的回滚。需要调用方进行事务控制

15:玩好序列化

  • 序列化是把对象转换为字节流的过程,以方便传输或存储。
  • 反序列化,则是反过来把字节流转换为对象的过程。
  • 关于序列化算法,几年前常用的有 JDK(Java)序列化、XML 序列化等,但前者不能跨语言,后者性能较差(时间空间开销大);现在 RESTful 应用最常用的是 JSON 序列化,追求性能的 RPC 框架(比如 gRPC)使用 protobuf 序列化,这 2 种方法都是跨语言的,而且性能不错,应用广泛。
  • 在架构设计阶段,我们可能会重点关注算法选型,在性能、易用性和跨平台性等中权衡,不过这里的坑比较少。通常情况下,序列化问题常见的坑会集中在业务场景中,比如 Redis、参数和响应序列化反序列化。

15.1:确保算法一致

  • 业务代码中涉及序列化时,很重要的一点是要确保序列化和反序列化的算法一致性。
  • 使用 RedisTemplate 来操作 Redis 进行数据缓存。因为相比于Jedis,使用 Spring 提供的 RedisTemplate 操作 Redis,除了无需考虑连接池、更方便外,还可以与 Spring Cache 等其他组件无缝整合。如果使用 Spring Boot 的话,无需任何配置就可以直接使用。
  • 数据(包含 Key 和 Value)要保存到 Redis,需要经过序列化算法来序列化成字符串。虽然 Redis 支持多种数据结构,比如 Hash,但其每一个 field 的 Value 还是字符串。如果Value 本身也是字符串的话,能否有便捷的方式来使用 RedisTemplate,而无需考虑序列化呢?其实是有的,那就是 StringRedisTemplate。
@RestController
@RequestMapping("redistemplate")
@Slf4j
public class RedisTemplateController {

    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private RedisTemplate<String, User> userRedisTemplate;
    @Autowired
    private RedisTemplate<String, Long> countRedisTemplate;  
  /*
  在应用初始化完成后向 Redis 设置两组数据,第一次使用RedisTemplate 设置 Key 为 redisTemplate、Value 为 User 对象,第二次使用StringRedisTemplate 设置 Key 为 stringRedisTemplate、Value 为 JSON 序列化后的User 对象
  */
  @PostConstruct
  public void init() throws JsonProcessingException {
      redisTemplate.opsForValue().set("redisTemplate", new User("zhuye", 36));
      stringRedisTemplate.opsForValue().set("stringRedisTemplate", objectMapper.writeValueAsString(new User("zhuye", 36)));
  }

  /*
  通过 RedisTemplate 读取 Key 为 stringRedisTemplate 的 Value,使用 StringRedisTemplate 读取 Key 为 redisTemplate 的 Value
  结果是,两次都无法读取到 Value
  通过 redis-cli 客户端工具连接到 Redis,你会发现根本就没有叫作 redisTemplate 的Key,所以 StringRedisTemplate 无法查到数据
  查看 RedisTemplate 的源码发现,默认情况下 RedisTemplate 针对 Key 和 Value 使用了JDK 序列化
  redis-cli 看到的类似一串乱码的"\xac\xed\x00\x05t\x00\rredisTemplate"字符串,其实就是字符串 redisTemplate 经过 JDK 序列化后的结果。
  而 RedisTemplate 尝试读取 Key 为 stringRedisTemplate 数据时,也会对这个字符串进行 JDK 序列化处理,所以同样无法读取到数据
  而 StringRedisTemplate 对于 Key 和 Value,使用的是 String 序列化方式,Key 和Value 只能是 String
  到这里我们应该知道 RedisTemplate 和 StringRedisTemplate 保存的数据无法通用。修复方式就是,让它们读取自己存的数据
  */
  @GetMapping("wrong")
  public void wrong() {
      log.info("redisTemplate get {}", redisTemplate.opsForValue().get("stringRedisTemplate"));
      log.info("stringRedisTemplate get {}", stringRedisTemplate.opsForValue().get("redisTemplate"));
  }

  /*
  使用 RedisTemplate 读出的数据,由于是 Object 类型的,使用时可以先强制转换为User 类型
  使用 StringRedisTemplate 读取出的字符串,需要手动将 JSON 反序列化为 User 类型
  这样就可以得到正确输出
  */
  @GetMapping("right")
  public void right() throws JsonProcessingException {
      User userFromRedisTemplate = (User) redisTemplate.opsForValue().get("redisTemplate");
      log.info("redisTemplate get {}", userFromRedisTemplate);
      User userFromStringRedisTemplate = objectMapper.readValue(stringRedisTemplate.opsForValue().get("stringRedisTemplate"), User.class);
      log.info("stringRedisTemplate get {}", userFromStringRedisTemplate);
  }

  /*
  使用 RedisTemplate 获取 Value 虽然方便,但是 Key 和 Value 不易读;而使用 StringRedisTemplate 虽然 Key 是普通字符串,但是 Value 存取需要手动序列化成字符串,有没有两全其美的方式呢?
  当然有,自己定义 RedisTemplate 的 Key 和 Value 的序列化方式即可:Key 的序列化使用 RedisSerializer.string()(也就是 StringRedisSerializer 方式)实现字符串序列化,而Value 的序列化使用 Jackson2JsonRedisSerializer
  StringRedisTemplate 成功查出了我们存入的数据
  Redis 里也可以查到 Key 是纯字符串,Value 是 JSON 序列化后的 User 对象
  但值得注意的是,这里有一个坑。第一行的日志输出显示,userRedisTemplate 获取到的Value,是 LinkedHashMap 类型的,完全不是泛型的 RedisTemplate 设置的 User 类型。
  如果我们把代码里从 Redis 中获取到的 Value 变量类型由 Object 改为 User,编译不会出现问题,但会出现 ClassCastException
  修复方式是,修改自定义 RestTemplate 的代码,把 new 出来的Jackson2JsonRedisSerializer 设置一个自定义的 ObjectMapper,启用activateDefaultTyping 方法把类型信息作为属性写入序列化后的数据中(当然了,你也可以调整 JsonTypeInfo.As 枚举以其他形式保存类型信息)
  或者,直接使用 RedisSerializer.json() 快捷方法,它内部使用的GenericJackson2JsonRedisSerializer 直接设置了把类型作为属性保存到 Value 中
  重启程序调用 right2 方法进行测试,可以看到,从自定义的 RedisTemplate 中获取到的Value 是 User 类型的(第一行日志),而且 Redis 中实际保存的 Value 包含了类型完全限定名(第二行日志)
  因此,反序列化时可以直接得到 User 类型的 Value。
  通过对 RedisTemplate 组件的分析,可以看到,当数据需要序列化后保存时,读写数据使用一致的序列化算法的必要性,否则就像对牛弹琴。
  */
  @GetMapping("right2")
  public void right2() {
      User user = new User("zhuye", 36);
      userRedisTemplate.opsForValue().set(user.getName(), user);
      //Object userFromRedis = userRedisTemplate.opsForValue().get(user.getName());
      User userFromRedis = userRedisTemplate.opsForValue().get(user.getName());
      log.info("userRedisTemplate get {} {}", userFromRedis, userFromRedis.getClass());
      log.info("stringRedisTemplate get {}", stringRedisTemplate.opsForValue().get(user.getName()));
  }

  @GetMapping("wrong2")
  public void wrong2() {
      String key = "testCounter";
      countRedisTemplate.opsForValue().set(key, 1L);
      log.info("{} {}", countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
      Long l1 = getLongFromRedis(key);
      countRedisTemplate.opsForValue().set(key, Integer.MAX_VALUE + 1L);
      log.info("{} {}", countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
      Long l2 = getLongFromRedis(key);
      log.info("{} {}", l1, l2);
  }

  private Long getLongFromRedis(String key) {
      Object o = countRedisTemplate.opsForValue().get(key);
      if (o instanceof Integer) {
          return ((Integer) o).longValue();
      }
      if (o instanceof Long) {
          return (Long) o;
      }
      return null;
  }
 }
  • 自定义序列化

    
    @SpringBootApplication
    public class CommonMistakesApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(CommonMistakesApplication.class, args);
        }
    
        //自定义json序列化规则
        @Bean
        public <T> RedisTemplate<String, T> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
            RedisTemplate<String, T> redisTemplate = new RedisTemplate<>();
            redisTemplate.setConnectionFactory(redisConnectionFactory);
            Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.enable(DeserializationFeature.USE_LONG_FOR_INTS);
            //把类型信息作为属性写入Value
            objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL);
            jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
            redisTemplate.setKeySerializer(RedisSerializer.string());
            redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
            redisTemplate.setHashKeySerializer(RedisSerializer.string());
            redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
            redisTemplate.afterPropertiesSet();
            return redisTemplate;
        }
    
    }
    
  • Spring 提供的 4 种 RedisSerializer(Redis 序列化器):

    • 默认情况下,RedisTemplate 使用 JdkSerializationRedisSerializer,也就是 JDK 序列化,容易产生 Redis 中保存了乱码的错觉。
    • 通常考虑到易读性,可以设置 Key 的序列化器为 StringRedisSerializer。但直接使用RedisSerializer.string(),相当于使用了 UTF_8 编码的 StringRedisSerializer,需要注意字符集问题。
    • 如果希望 Value 也是使用 JSON 序列化的话,可以把 Value 序列化器设置为Jackson2JsonRedisSerializer。默认情况下,不会把类型信息保存在 Value 中,即使我们定义 RedisTemplate 的 Value 泛型为实际类型,查询出的 Value 也只能是LinkedHashMap 类型。如果希望直接获取真实的数据类型,你可以启用 JacksonObjectMapper 的 activateDefaultTyping 方法,把类型信息一起序列化保存在 Value中。
    • 如果希望 Value 以 JSON 保存并带上类型信息,更简单的方式是,直接使用RedisSerializer.json() 快捷方法来获取序列化器。

15.2:反序列化对额外字段的处理

  • 通过设置 JSON 序列化工具 Jackson 的 activateDefaultTyping 方法,可以在序列化数据时写入对象类型。其实,Jackson 还有很多参数可以控制序列化和反序列化,是一个功能强大而完善的序列化工具。
  • 在开发 Spring Web 应用程序时,如果自定义了 ObjectMapper,并把它注册成了Bean,那很可能会导致 Spring Web 使用的 ObjectMapper 也被替换,导致 Bug。
@RestController
@RequestMapping("jsonignoreproperties")
@Slf4j
public class JsonIgnorePropertiesController {

    @Autowired
    private ObjectMapper objectMapper;

    /*
    程序一开始是正常的,某一天开发同学希望修改一下 ObjectMapper的行为,让枚举序列化为索引值而不是字符串值,比如默认情况下序列化一个 Color 枚举中的 Color.BLUE 会得到字符串 BLUE
    于是,这位同学就重新定义了一个 ObjectMapper Bean,开启了WRITE_ENUMS_USING_INDEX 功能特性
    开启这个特性后,Color.BLUE 枚举序列化成索引值 1
    修改后处理枚举序列化的逻辑是满足了要求,但线上爆出了大量 400 错误,日志中也出现了很多 UnrecognizedPropertyException
    
    */
    @GetMapping("test")
    public void test() throws JsonProcessingException {
        log.info("color:{}", objectMapper.writeValueAsString(Color.BLUE));
    }

  
    /*
    从异常信息中可以看到,这是因为反序列化的时候,原始数据多了一个 version 属性。进一步分析发现,我们使用了 UserWrong 类型作为 Web 控制器 wrong 方法的入参,其中只有一个 name 属性
    而客户端实际传过来的数据多了一个 version 属性。那,为什么之前没这个问题呢?
    问题就出在,自定义 ObjectMapper 启用 WRITE_ENUMS_USING_INDEX 序列化功能特性时,覆盖了 Spring Boot 自动创建的 ObjectMapper;而这个自动创建的ObjectMapper 设置过 FAIL_ON_UNKNOWN_PROPERTIES 反序列化特性为 false,以确保出现未知字段时不要抛出异常。
    要修复这个问题,有三种方式:
    第一种,同样禁用自定义的 ObjectMapper 的 FAIL_ON_UNKNOWN_PROPERTIES
    第二种,设置自定义类型,加上 @JsonIgnoreProperties 注解,开启 ignoreUnknown属性,以实现反序列化时忽略额外的数据
    第三种,不要自定义 ObjectMapper,而是直接在配置文件设置相关参数,来修改Spring 默认的 ObjectMapper 的功能。比如,直接在配置文件启用把枚举序列化为索引号
    或者可以直接定义 Jackson2ObjectMapperBuilderCustomizer Bean 来启用新特性
    #spring.jackson.serialization.write_enums_using_index=true
    */
    @PostMapping("wrong")
    public UserWrong wrong(@RequestBody UserWrong user) {
        return user;
    }

    @PostMapping("right")
    public Object right(@RequestBody UserRight user) {
        return user;
    }

    enum Color {
        RED, BLUE
    }
  
    
}
@Data
public class UserWrong{private String name;}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserRight {
    private String name;
}
@SpringBootApplication
public class CommonMistakesApplication {

    public static void main(String[] args) {
        Utils.loadPropertySource(CommonMistakesApplication.class, "jackson.properties");
        SpringApplication.run(CommonMistakesApplication.class, args);
    }

//    @Bean
//    public ObjectMapper objectMapper() {
//        ObjectMapper objectMapper = new ObjectMapper();
//        objectMapper.configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true);
//        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);
//        return objectMapper;
//    }

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customizer() {
        return builder -> builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_INDEX);
    }
}

15.3:反序列化小心类构造方法

  • 案例
/*
APIResult 类包装了 REST 接口的返回体(作为 Web 控制器的出参),其中boolean 类型的 success 字段代表是否处理成功、int 类型的 code 字段代表处理状态码
*/
@Data
public class APIResultWrong {
    private boolean success;
    private int code;

    public APIResultWrong() {
    }

    public APIResultWrong(int code) {
        this.code = code;
        if (code == 2000) success = true;
        else success = false;
    }
}

  @Data
  public class APIResultRight {
      private boolean success;
      private int code;
      public APIResultRight() {
  }

  @JsonCreator
  public APIResultRight(@JsonProperty("code") int code) {
      this.code = code;
      if (code == 2000) success = true;
      else success = false;
  }
}
@RestController
  @RequestMapping("deserializationconstructor")
  @Slf4j
  public class DeserializationConstructorController {
      @Autowired
      ObjectMapper objectMapper;

      /*
      可以看到,两次的 APIResult 的 success 字段都是 false。
      出现这个问题的原因是,默认情况下,在反序列化的时候,Jackson 框架只会调用无参构造方法创建对象。
      如果走自定义的构造方法创建对象,需要通过 @JsonCreator 来指定构造方法,并通过 @JsonProperty 设置构造方法中参数对应的 JSON 属性名
      */
      @GetMapping("wrong")
      public void wrong() throws JsonProcessingException {
          log.info("result :{}", objectMapper.readValue("{\"code\":1234}", APIResultWrong.class));
          log.info("result :{}", objectMapper.readValue("{\"code\":2000}", APIResultWrong.class));
      }

      /*
      重新运行程序,可以得到正确输出
      可以看到,这次传入 code==2000 时,success 可以设置为 true。
      */
      @GetMapping("right")
      public void right() throws JsonProcessingException {
          log.info("result :{}", objectMapper.readValue("{\"code\":1234}", APIResultRight.class));
          log.info("result :{}", objectMapper.readValue("{\"code\":2000}", APIResultRight.class));
      }
  }

15.4:枚举作为API接口参数或返回值坑

  • 对于枚举,建议尽量在程序内部使用,而不是作为 API 接口的参数或返回值,原因是枚举涉及序列化和反序列化时会有

    两个大坑。

  • 第一个坑是,客户端和服务端的枚举定义不一致时,会出异常。

  • 客户端版本的枚举定义了 4 个枚举值

@Getter
enum StatusEnumClient {
    CREATED(1, "已创建"),
    PAID(2, "已支付"),
    DELIVERED(3, "已送到"),
    FINISHED(4, "已完成"),
    @JsonEnumDefaultValue
    UNKNOWN(-1, "未知");

    @JsonValue
    private final int status;
    private final String desc;

    StatusEnumClient(Integer status, String desc) {
        this.status = status;
        this.desc = desc;
    }

    //    @JsonCreator
//    public static StatusEnumClient parse(Object o) {
//        return Arrays.stream(StatusEnumClient.values()).filter(value->o.equals(value.status)).findFirst().orElse(null);
//    }
}
  • 服务端版本的枚举定义了5个枚举值
@Getter
enum StatusEnumServer {
    CREATED(1, "已创建"),
    PAID(2, "已支付"),
    DELIVERED(3, "已送到"),
    FINISHED(4, "已完成"),
    CANCELED(5, "已取消");
    @JsonValue
    private final int status;
    private final String desc;

    StatusEnumServer(Integer status, String desc) {
        this.status = status;
        this.desc = desc;
    }

//    @JsonCreator
//    public static StatusEnumServer parse(Object o) {
//        return Arrays.stream(StatusEnumServer.values()).filter(value->o.equals(value.status)).findFirst().orElse(null);
//    }
}
  • a
@Slf4j
@RequestMapping("enumusedinapi")
@RestController
public class EnumUsedInAPIController {
    @Autowired
    private RestTemplate restTemplate;

    /*
    使用 RestTemplate 来发起请求,让服务端返回客户端不存在的枚举值
    访问接口会出现如下异常信息,提示在枚举 StatusEnumClient 中找不到 CANCELED
    要解决这个问题,可以开启 Jackson 的read_unknown_enum_values_using_default_value 反序列化特性,也就是在枚举值未知的时候使用默认值
    spring.jackson.deserialization.read_unknown_enum_values_using_default_value=true
    并为枚举添加一个默认值,使用 @JsonEnumDefaultValue 注解注释
    仅仅这样配置还不能让 RestTemplate 生效这个反序列化特性,还需要配置 RestTemplate,来使用 Spring Boot 的MappingJackson2HttpMessageConverter 才行
    现在,请求接口可以返回默认值了
    */
    @GetMapping("getOrderStatusClient")
    public void getOrderStatusClient() {
        StatusEnumClient result = restTemplate.getForObject("http://localhost:45678/enumusedinapi/getOrderStatus", StatusEnumClient.class);
        log.info("result {}", result);
    }

  
    /*
    你可以再尝试把@JsonValue 注解加在 int 类型的 status 字段上,也就是希望序列化反序列化走 status 字段
    写一个客户端测试一下,传入 CREATED 和 PAID 两个枚举值
    请求接口可以看到,传入的是 CREATED 和 PAID,返回的居然是 DELIVERED 和FINISHED。
    出现这个问题的原因是,序列化走了 status 的值,而反序列化并没有根据 status 来,还是使用了枚举的 ordinal() 索引值。这是 Jackson截止到2.10版本还未解决的bug
    我们调用服务端接口,传入一个不存在的 status 值 0,也能反序列化成功,最后服务端的返回是 1
    有一个解决办法是,设置 @JsonCreator 来强制反序列化时使用自定义的工厂方法,可以实现使用枚举的 status 字段来取值。要特别注意的是,我们同样要为 StatusEnumClient和StatusEnumServer 添加相应的方法。因为除了服务端接口接收 StatusEnumServer 参数涉及一次反序列化外,从服务端返回值转换为 List 还会有一次反序列化
    重新调用接口发现,虽然结果正确了,但是服务端不存在的枚举值 CANCELED 被设置为了null,而不是 @JsonEnumDefaultValue 设置的 UNKNOWN
    这个问题,我们之前已经通过设置 @JsonEnumDefaultValue 注解解决了,但现在又出现了
    原因也很简单,我们自定义的 parse 方法实现的是找不到枚举值时返回 null
    为彻底解决这个问题,并避免通过 @JsonCreator 在枚举中自定义一个非常复杂的工厂方法,我们可以实现一个自定义的反序列化器EnumDeserializer。然后,把这个自定义反序列化器注册到 Jackson 中
    第二个大坑终于被完美地解决了
    
    */
    @GetMapping("queryOrdersByStatusListClient")
    public void queryOrdersByStatusListClient() {
        List<StatusEnumClient> request = Arrays.asList(StatusEnumClient.CREATED, StatusEnumClient.PAID);
        HttpEntity<List<StatusEnumClient>> entity = new HttpEntity<>(request, new HttpHeaders());
        List<StatusEnumClient> response = restTemplate.exchange("http://localhost:45678/enumusedinapi/queryOrdersByStatusList",
                HttpMethod.POST, entity, new ParameterizedTypeReference<List<StatusEnumClient>>() {
                }).getBody();
        log.info("result {}", response);
    }

    @GetMapping("getOrderStatus")
    public StatusEnumServer getOrderStatus() {
        return StatusEnumServer.CANCELED;
    }

    /*
    传入枚举 List,为 List 增加一个 CENCELED 枚举值然后返回
    发送请求["已送到"],会得到异常,提示“已送到”不是正确的枚举值
    显然,这里反序列化使用的是枚举的 name,序列化也是一样
    发送请求["DELIVERED"],成功返回
    要让枚举的序列化和反序列化走 desc 字段,可以在字段上加 @JsonValue注解,修改 StatusEnumServer 和 StatusEnumClient
    然后再尝试下发送请求["已送到"],果然可以用 desc 作为入参了,而且出参也使用了枚举的 desc
    */
    @PostMapping("queryOrdersByStatusList")
    public List<StatusEnumServer> queryOrdersByStatus(@RequestBody List<StatusEnumServer> enumServers) {
        enumServers.add(StatusEnumServer.CANCELED);
        return enumServers;
    }
}
@SpringBootApplication
public class CommonMistakesApplication {

    public static void main(String[] args) {
        Utils.loadPropertySource(CommonMistakesApplication.class, "jackson.properties");

        SpringApplication.run(CommonMistakesApplication.class, args);
    }

    @Bean
    public RestTemplate restTemplate(MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter) {
        return new RestTemplateBuilder()
                .additionalMessageConverters(mappingJackson2HttpMessageConverter)
                .build();
    }

    @Bean
    public Module enumModule() {
        SimpleModule module = new SimpleModule();
        module.addDeserializer(Enum.class, new EnumDeserializer());
        return module;
    }
}
  • 第二个坑,也是更大的坑,枚举序列化反序列化实现自定义的字段非常麻烦,会涉及Jackson 的 Bug。
class EnumDeserializer extends JsonDeserializer<Enum> implements
        ContextualDeserializer {

    private Class<Enum> targetClass;

    public EnumDeserializer() {
    }

    public EnumDeserializer(Class<Enum> targetClass) {
        this.targetClass = targetClass;
    }

    @Override
    public Enum deserialize(JsonParser p, DeserializationContext ctxt) {
        //找枚举中带有@JsonValue注解的字段,这个字段是我们反序列化的基准字段
        Optional<Field> valueFieldOpt = Arrays.asList(targetClass.getDeclaredFields()).stream()
                .filter(m -> m.isAnnotationPresent(JsonValue.class))
                .findFirst();

        if (valueFieldOpt.isPresent()) {
            Field valueField = valueFieldOpt.get();
            if (!valueField.isAccessible()) {
                valueField.setAccessible(true);
            }
            //遍历枚举项,查找字段的值等于反序列化的字符串的那个枚举项
            return Arrays.stream(targetClass.getEnumConstants()).filter(e -> {
                try {
                    return valueField.get(e).toString().equals(p.getValueAsString());
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
                return false;
            }).findFirst().orElseGet(() -> Arrays.stream(targetClass.getEnumConstants()).filter(e -> {
                //如果找不到,那么就需要寻找默认枚举值来替代,同样遍历所有枚举项,查找@JsonEnumDefaultValue注解标识的枚举项
                try {
                    return targetClass.getField(e.name()).isAnnotationPresent(JsonEnumDefaultValue.class);
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
                return false;
            }).findFirst().orElse(null));
        }
        return null;
    }

    @Override
    public JsonDeserializer<?> createContextual(DeserializationContext ctxt,
                                                BeanProperty property) throws JsonMappingException {
        targetClass = (Class<Enum>) ctxt.getContextualType().getRawClass();
        return new EnumDeserializer(targetClass);
    }
}
  • 这样做,虽然解决了序列化反序列化使用枚举中自定义字段的问题,也解决了找不到枚举值时使用默认值的问题,但解决方案很复杂。因此,还是建议在 DTO 中直接使用 int 或String 等简单的数据类型,而不是使用枚举再配合各种复杂的序列化配置,来实现枚举到枚举中字段的映射,会更加清晰明了

16:Java8的日期时间类

  • 在 Java 8 之前,我们处理日期时间需求时,使用 Date、Calender 和 SimpleDateFormat,来声明时间戳、使用日历处理日期和格式化解析日期时间。
  • 但是,这些类的 API 的缺点比较明显,比如可读性差、易用性差、使用起来冗余繁琐,还有线程安全问题。

16.1:初始化时间日期

public class CommonMistakesApplication {

    public static void main(String[] args) throws Exception {
        wrong();
        right();
        better();
    }

    /*
    初始化一个 2019 年 12 月 31 日 11 点 12 分 13秒这样的时间
    可以看到,输出的时间是 3029 年 1 月 31 日 11 点 12 分 13 秒
    */
    private static void wrong() {
        System.out.println("wrong");
        Date date = new Date(2019, 12, 31, 11, 12, 13);
        System.out.println(date);
    }

    /*
    年应该是和 1900 的差值,月应该是从0 到 11 而不是从 1 到 12
    更重要的问题是,当有国际化需求时,需要使用 Calendar 类来初始化时间
    */
    private static void wrongfix() {
        System.out.println("right");
        Date date = new Date(2019 - 1900, 11, 31, 11, 12, 13);
        System.out.println(date);
    }

  
    /*
    使用 Calendar 改造之后,初始化时年参数直接使用当前年即可,不过月需要注意是从 0到 11。
    当然,你也可以直接使用 Calendar.DECEMBER 来初始化月份,更不容易犯错。
    为了说明时区的问题,我分别使用当前时区和纽约时区初始化了两次相同的日期
    输出显示了两个时间,说明时区产生了作用。但,我们更习惯年 / 月 / 日 时: 分: 秒这样的日期时间格式,对现在输出的日期格式还不满意
    */
    private static void right() {
        System.out.println("right");
        Calendar calendar = Calendar.getInstance();
        calendar.set(2019, 11, 31, 11, 12, 13);
        System.out.println(calendar.getTime());
        Calendar calendar2 = Calendar.getInstance(TimeZone.getTimeZone("America/New_York"));
        calendar2.set(2019, Calendar.DECEMBER, 31, 11, 12, 13);
        System.out.println(calendar2.getTime());

    }

    private static void better() {
        System.out.println("better");
        LocalDateTime localDateTime = LocalDateTime.of(2019, Month.DECEMBER, 31, 11, 12, 13);
        System.out.println(localDateTime);
        ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, ZoneId.of("America/New_York"));
        System.out.println(zonedDateTime);
    }
}

16.2:麻烦的时区问题

  • 我们知道,全球有 24 个时区,同一个时刻不同时区(比如中国上海和美国纽约)的时间是不一样的。
  • 对于需要全球化的项目,如果初始化时间时没有提供时区,那就不是一个真正意义上的时间,只能认为是我看到的当前时间的一个表示。
  • 关于 Date 类,我们要有两点认识:

    • 一是,Date 并无时区问题,世界上任何一台计算机使用 new Date() 初始化得到的时间都一样。因为,Date 中保存的是 UTC 时间,UTC 是以原子钟为基础的统一时间,不以太阳参照计时,并无时区划分。
    • 二是,Date 中保存的是一个时间戳,代表的是从 1970 年 1 月 1 日 0 点(Epoch 时间)到现在的毫秒数。
    • 尝试输出 Date(0):
System.out.println(new Date(0));
System.out.println(TimeZone.getDefault().getID() + ":" + TimeZone.getDefault());

/*
Thu Jan 01 08:00:00 CST 1970
Asia/Shanghai:8
我得到的是 1970 年 1 月 1 日 8 点。因为我机器当前的时区是中国上海,相比 UTC 时差+8 小时
*/
  • 对于国际化(世界各国的人都在使用)的项目,处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:

    • 方式一,以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一时间。我们通常说的时间戳,或 Java 中的 Date 类就是用的这种方式,这也是推荐的方式。
    • 方式二,以字面量保存,比如年 / 月 / 日 时: 分: 秒,一定要同时保存时区信息。只有有了时区信息,我们才能知道这个字面量时间真正的时间点,否则它只是一个给人看的时间表示,只在当前时区有意义。Calendar 是有时区概念的,所以我们通过不同的时区初始化 Calendar,得到了不同的时间。
  • 正确保存日期时间之后,就是正确展示,即我们要使用正确的时区,把时间点展示为符合当前时区的时间表示。

public class CommonMistakesApplication {

    public static void main(String[] args) throws Exception {
        test();
        wrong1();
        wrong2();
        right();
    }

    private static void test() {
        System.out.println("test");
        System.out.println(new Date(0));
        //System.out.println(TimeZone.getDefault().getID() + ":" + TimeZone.getDefault().getRawOffset()/3600/1000);
        //ZoneId.getAvailableZoneIds().forEach(id -> System.out.println(String.format("%s:%s", id, ZonedDateTime.now(ZoneId.of(id)))));
    }

		/*
		第一类是,对于同一个时间表示,比如 2020-01-02 22:00:00,不同时区的人转换成 Date会得到不同的时间(时间戳)
		可以看到,把 2020-01-02 22:00:00 这样的时间表示,对于当前的上海时区和纽约时区,转化为 UTC 时间戳是不同的时间
		这正是 UTC 的意义,并不是时间错乱。对于同一个本地时间的表示,不同时区的人解析得到的 UTC 时间一定是不同的,反过来不同的本地时间可能对应同一个 UTC
		*/
    private static void wrong1() throws ParseException {
        System.out.println("wrong1");
        String stringDate = "2020-01-02 22:00:00";
        //默认时区解析时间表示
        SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Date date1 = inputFormat.parse(stringDate);
        System.out.println(date1 + ":" + date1.getTime());
        //Thu Jan 02 22:00:00 CST 2020:1577973600000
      
        //纽约时区解析时间表示
        inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
        Date date2 = inputFormat.parse(stringDate);
        System.out.println(date2 + ":" + date2.getTime());
        //Fri Jan 03 11:00:00 CST 2020:1578020400000
    }

    /*
    第二类问题是,格式化后出现的错乱,即同一个 Date,在不同的时区下格式化得到不同的时间表示。比如,在我的当前时区和纽约时区格式化 2020-01-02 22:00:00
    输出如下,我当前时区的 Offset(时差)是 +8 小时,对于 -5 小时的纽约,晚上 10 点对应早上 9 点
    因此,有些时候数据库中相同的时间,由于服务器的时区设置不同,读取到的时间表示不同。这,不是时间错乱,正是时区发挥了作用,因为 UTC 时间需要根据当前时区解析为正确的本地时间
    所以,要正确处理时区,在于存进去和读出来两方面:存的时候,需要使用正确的当前时区来保存,这样 UTC 时间才会正确;读的时候,也只有正确设置本地时区,才能把 UTC 时间转换为正确的当地时间。
    */
    private static void wrong2() throws ParseException {
        System.out.println("wrong2");
        String stringDate = "2020-01-02 22:00:00";
        SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        //同一Date
        Date date = inputFormat.parse(stringDate);
      
        //默认时区格式化输出
        System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss Z]").format(date));
        //[2020-01-02 22:00:00 +0800]
      
        //纽约时区格式化输出
        TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"));
        System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss Z]").format(date));
        //[2020-01-02 09:00:00 -0500]
    }

    /*
    Java 8 推出了新的时间日期类 ZoneId、ZoneOffset、LocalDateTime、ZonedDateTime和 DateTimeFormatter
    可以使用 ZoneId.of 来初始化一个标准的时区,也可以使用 ZoneOffset.ofHours 通过一个 offset,来初始化一个具有指定时间差的自定义时区
    对于日期时间表示,LocalDateTime 不带有时区属性,所以命名为本地时区的日期时间;而 ZonedDateTime=LocalDateTime+ZoneId,具有时区属性。因此,LocalDateTime 只能认为是一个时间表示,ZonedDateTime 才是一个有效的时间。在这里我们把 2020-01-02 22:00:00 这个时间表示,使用东京时区来解析得到一个ZonedDateTime。
    使用 DateTimeFormatter 格式化时间的时候,可以直接通过 withZone 方法直接设置格式化使用的时区
    */
    private static void right() {
        System.out.println("right");

        //一个时间表示
        String stringDate = "2020-01-02 22:00:00";
      
        //初始化三个时区
        ZoneId timeZoneSH = ZoneId.of("Asia/Shanghai");
        ZoneId timeZoneNY = ZoneId.of("America/New_York");
        ZoneId timeZoneJST = ZoneOffset.ofHours(9);

        //格式化器
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        ZonedDateTime date = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneJST);

        /*
        使用DateTimeFormatter格式化时间,可以通过withZone方法直接设置格式化使用的时区
        可以看到,相同的时区,经过解析存进去和读出来的时间表示是一样的(比如最后一行);而对于不同的时区,比如上海和纽约,最后输出的本地时间不同。+9 小时时区的晚上 10点,对于上海是 +8 小时,所以上海本地时间是晚上 9 点;而对于纽约是 -5 小时,差 14小时,所以是早上 8 点
        */
        DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z");
        System.out.println(timeZoneSH.getId() + outputFormat.withZone(timeZoneSH).format(date));
        //Asia/Shanghai2020-01-02 21:00:00 +0800
        System.out.println(timeZoneNY.getId() + outputFormat.withZone(timeZoneNY).format(date));
        //America/New_York2020-01-02 08:00:00 -0500
        System.out.println(timeZoneJST.getId() + outputFormat.withZone(timeZoneJST).format(date));
        //+09:002020-01-02 22:00:00 +0900
    }

}
  • 要正确处理国际化时间问题,我推荐使用 Java 8 的日期时间类,即使用 ZonedDateTime 保存时间,然后使用设置了 ZoneId 的 DateTimeFormatter 配合ZonedDateTime 进行时间格式化得到本地时间表示。这样的划分十分清晰、细化,也不容易出错。

16.3:日期时间格式化和解析

  • 每到年底,就有很多开发同学踩时间格式化的坑,比如“这明明是一个 2019 年的日期,怎么使用 SimpleDateFormat 格式化后就提前跨年了”。
public class CommonMistakesApplication {

    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    private static ThreadLocal<SimpleDateFormat> threadSafeSimpleDateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

    /*
    对于 SimpleDateFormat 的这三个坑,我们使用 Java 8 中的 DateTimeFormatter 就可以避过去。首先,使用 DateTimeFormatterBuilder 来定义格式化字符串,不用去记忆使用大写的 Y 还是小写的 Y,大写的 M 还是小写的 m
    DateTimeFormatter 是线程安全的,可以定义为 static 使用
    */
    private static DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
            .appendValue(ChronoField.YEAR)//年
            .appendLiteral("/")
            .appendValue(ChronoField.MONTH_OF_YEAR)//月
            .appendLiteral("/")
            .appendValue(ChronoField.DAY_OF_MONTH)//日
            .appendLiteral(" ")
            .appendValue(ChronoField.HOUR_OF_DAY)//时
            .appendLiteral(":")
            .appendValue(ChronoField.MINUTE_OF_HOUR)//分
            .appendLiteral(":")
            .appendValue(ChronoField.SECOND_OF_MINUTE)//秒
            .appendLiteral(".")
            .appendValue(ChronoField.MILLI_OF_SECOND)//毫秒
            .toFormatter();

    public static void main(String[] args) throws Exception {

        wrong1();
    }

    private static void test() {

        Calendar calendar = Calendar.getInstance();
        calendar.setTime(new Date());
        System.out.println(simpleDateFormat.format(calendar.getTime()));
        System.out.println(dateTimeFormatter.format(LocalDateTime.ofInstant(Instant.now(), ZoneId.systemDefault())));
        System.out.println(dateTimeFormatter.format(LocalDateTime.now()));
    }

    private static void wrong1() throws ParseException {
        //三个问题,YYYY、线程不变安全、不合法格式

        //初始化一个 Calendar,设置日期时间为 2019 年 12 月 29 日,使用大写的 YYYY 来初始化 SimpleDateFormat
        Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
        System.out.println("defaultLocale:" + Locale.getDefault());
        Calendar calendar = Calendar.getInstance();
        calendar.set(2019, Calendar.DECEMBER, 29, 0, 0, 0);
        SimpleDateFormat YYYY = new SimpleDateFormat("YYYY-MM-dd");
        System.out.println("格式化: " + YYYY.format(calendar.getTime()));
        System.out.println("weekYear:" + calendar.getWeekYear());
        System.out.println("firstDayOfWeek:" + calendar.getFirstDayOfWeek());
        System.out.println("minimalDaysInFirstWeek:" + calendar.getMinimalDaysInFirstWeek());
        /*
        defaultLocale:zh_CN
				格式化: 2020-12-29
				weekYear:2020
				firstDayOfWeek:1
				minimalDaysInFirstWeek:1
				原因:
				小写 y 是年,而大写 Y 是 week year,也就是所在的周属于哪一年
				一年第一周的判断方式是,从 getFirstDayOfWeek() 开始,完整的 7 天,并且包含那一年至少 getMinimalDaysInFirstWeek() 天。这个计算方式和区域相关,对于当前 zh_CN 区域来说,2020 年第一周的条件是,从周日开始的完整 7 天,2020 年包含 1 天即可。显然,2019 年 12 月 29 日周日到 2020 年 1 月 4 日周六是 2020 年第一周,得出的 weekyear 就是 2020 年。
				
				如果把区域改为法国:
				Locale.setDefault(Locale.FRANCE);
				结果为:
				defaultLocale:fr_FR
				格式化: 2019-12-29
				weekYear:2019
				firstDayOfWeek:2
				minimalDaysInFirstWeek:4
				那么 week yeay 就还是 2019 年,因为一周的第一天从周一开始算,2020 年的第一周是2019 年 12 月 30 日周一开始,29 日还是属于去年
				这个案例告诉我们,没有特殊需求,针对年份的日期格式化,应该一律使用 “y” 而非“Y”。
        */

      
        /*
        第二个坑是,当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽容,还是能得到结果。比如,我们期望使用 yyyyMM 来解析 20160901 字符串
        居然输出了 2091 年 1 月 1 日,原因是把 0901 当成了月份,相当于 75 年
        */
        String dateString = "20160901";
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMM");
        System.out.println("result:" + dateFormat.parse(dateString));

    }

    private static void wrong1fix() throws ParseException {
        SimpleDateFormat yyyy = new SimpleDateFormat("yyyy-MM-dd");
        Calendar calendar = Calendar.getInstance();
        calendar.set(2019, Calendar.DECEMBER, 29, 23, 24, 25);
        System.out.println("格式化: " + yyyy.format(calendar.getTime()));

        String dateString = "20160901";
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
        System.out.println("result:" + dateFormat.parse(dateString));
    }

    /*
    DateTimeFormatter 的解析比较严格,需要解析的字符串和格式不匹配时,会直接报错,而不会把 0901 解析为月份
    */
    private static void better() {
        //使用刚才定义的DateTimeFormatterBuilder构建的DateTimeFormatter来解析这个时间
        LocalDateTime localDateTime = LocalDateTime.parse("2020/1/2 12:34:56.789", dateTimeFormatter);
        //解析成功
        System.out.println(localDateTime.format(dateTimeFormatter));//2020/1/212:34:56.789

        //使用yyyyMM格式解析20160901是否可以成功呢?
        String dt = "20160901";
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMM");
        System.out.println("result:" + dateTimeFormatter.parse(dt));
    }

    /*
    第一个坑是,定义的 static 的 SimpleDateFormat 可能会出现线程安全问题。
    使用一个 100 线程的线程池,循环 20 次把时间格式化任务提交到线程池处理,每个任务中又循环 10 次解析 2020-01-01 11:12:13 这样一个时间表示
    运行程序后大量报错,且没有报错的输出结果也不正常,比如 2020 年解析成了 1212 年
    SimpleDateFormat 的作用是定义解析和格式化日期时间的模式。这,看起来这是一次性的工作,应该复用,但它的解析和格式化操作是非线程安全的。
    SimpleDateFormat 继承了 DateFormat,DateFormat 有一个字段 Calendar;
    SimpleDateFormat 的 parse 方法调用 CalendarBuilder 的 establish 方法,来构建Calendar;
    establish 方法内部先清空 Calendar 再构建 Calendar,整个操作没有加锁。
    显然,如果多线程池调用 parse 方法,也就意味着多线程在并发操作一个 Calendar,可能会产生一个线程还没来得及处理 Calendar 就被另一个线程清空了的情况
    format 方法也类似,你可以自己分析。因此只能在同一个线程复用 SimpleDateFormat,比较好的解决方式是,通过 ThreadLocal 来存放 SimpleDateFormat
    */
    private static void wrong2() throws InterruptedException {
        ExecutorService threadPool = Executors.newFixedThreadPool(100);

        for (int i = 0; i < 20; i++) {
            threadPool.execute(() -> {
                for (int j = 0; j < 10; j++) {
                    try {
                        System.out.println(simpleDateFormat.parse("2020-01-01 11:12:13"));
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);

    }

    private static void wrong2fix() throws InterruptedException {
        ExecutorService threadPool = Executors.newFixedThreadPool(100);

        for (int i = 0; i < 20; i++) {
            threadPool.execute(() -> {
                for (int j = 0; j < 10; j++) {
                    try {
                        System.out.println(threadSafeSimpleDateFormat.get().parse("2020-01-01 11:12:13"));
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);
    }
}

16.4:日期时间的计算

  • 关于日期时间的计算,我先和你说一个常踩的坑。有些同学喜欢直接使用时间戳进行时间计算,比如希望得到当前时间之后 30 天的时间,会这么写代码:直接把 new Date().getTime 方法得到的时间戳加 30 天对应的毫秒数,也就是 30 天 *1000 毫秒*3600 秒 *24 小时
import static java.time.temporal.ChronoField.DAY_OF_MONTH;
import static java.time.temporal.ChronoField.MONTH_OF_YEAR;

public class CommonMistakesApplication {

    public static void main(String[] args) throws Exception {
        wrong1();
        wrong1fix();
        right();
        better();
        test();
    }

    /*
    直接使用时间戳进行时间计算,希望得到当前时间之后 30 天的时间
    得到的日期居然比当前日期还要早,根本不是晚 30 天的时间
    出现这个问题,其实是因为 int 发生了溢出。修复方式就是把 30 改为 30L,让其成为一个long
    这样就可以得到正确结果了
    */
    private static void wrong1() {
        System.out.println("wrong1");
        Date today = new Date();
        Date nextMonth = new Date(today.getTime() + 30 * 1000 * 60 * 60 * 24);
        //Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
        System.out.println(today); //Sat Feb 01 14:17:41 CST 2020
        System.out.println(nextMonth);  //Sun Jan 12 21:14:54 CST 2020
    }

    private static void wrong1fix() {
        System.out.println(30 * 1000 * 60 * 60 * 24 + " " + (30L * 1000 * 60 * 60 * 24 > Integer.MAX_VALUE));
        System.out.println("wrong1fix");
        Date today = new Date();
        Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
        System.out.println(today);
        System.out.println(nextMonth);

    }

    /*
    对于 Java 8 之前的代码,我更建议使用 Calendar
    */
    private static void right() {
        System.out.println("right");
        Calendar c = Calendar.getInstance();
        c.setTime(new Date());
        c.add(Calendar.DAY_OF_MONTH, 30);
        System.out.println(c.getTime());
    }

    /*
    使用 Java 8 的日期时间类型,可以直接进行各种计算,更加简洁和方便
    */
    private static void better() {
        System.out.println("better");
        LocalDateTime localDateTime = LocalDateTime.now();
        System.out.println(localDateTime.plusDays(30));
    }

    /*
    第一,可以使用各种 minus 和 plus 方法直接对日期进行加减操作,比如如下代码实现了减一天和加一天,以及减一个月和加一个月
    */
    private static void test() {
        System.out.println("//测试操作日期");
        System.out.println(LocalDate.now()
                .minus(Period.ofDays(1))
                .plus(1, ChronoUnit.DAYS)
                .minusMonths(1)
                .plus(Period.ofMonths(1)));

        /*
        Java 8 中有一个专门的类 Period 定义了日期间隔,通过 Period.between 得到了两个 LocalDate 的差,返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天,直接调用Period 的 getDays() 方法得到的只是最后的“零几天”,而不是算总的间隔天数。
        比如,计算 2019 年 12 月 12 日和 2019 年 10 月 1 日的日期间隔,很明显日期差是 2 个月零 11 天,但获取 getDays 方法得到的结果只是 11 天,而不是 72 天
        可以使用 ChronoUnit.DAYS.between 解决这个问题
        */
        System.out.println("//计算日期差");
        LocalDate today = LocalDate.of(2019, 12, 12);
        LocalDate specifyDate = LocalDate.of(2019, 10, 1);
        System.out.println(Period.between(specifyDate, today).getDays());
        System.out.println(Period.between(specifyDate, today));
        System.out.println(ChronoUnit.DAYS.between(specifyDate, today));
      //第二,还可以通过 with 方法进行快捷时间调节
      System.out.println("//本月的第一天");
      System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfMonth()));

      System.out.println("//今年的程序员日");
      System.out.println(LocalDate.now().with(TemporalAdjusters.firstDayOfYear()).plusDays(255));

      System.out.println("//今天之前的一个周六");
      System.out.println(LocalDate.now().with(TemporalAdjusters.previous(DayOfWeek.SATURDAY)));

      System.out.println("//本月最后一个工作日");
      System.out.println(LocalDate.now().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)));
      //第三,可以直接使用 lambda 表达式进行自定义的时间调整。比如,为当前时间增加 100天以内的随机天数
      System.out.println("//自定义逻辑");
      System.out.println(LocalDate.now().with(temporal -> temporal.plus(ThreadLocalRandom.current().nextInt(100), ChronoUnit.DAYS)));
      //然后,使用 query 方法查询是否匹配条件
      System.out.println("//查询是否是今天要举办生日");
      System.out.println(LocalDate.now().query(CommonMistakesApplication::isFamilyBirthday));

  }

  /*
  除了计算外,还可以判断日期是否符合某个条件。比如,自定义函数,判断指定日期是否是家庭成员的生日
  */
  public static Boolean isFamilyBirthday(TemporalAccessor date) {
      int month = date.get(MONTH_OF_YEAR);
      int day = date.get(DAY_OF_MONTH);

      if (month == Month.FEBRUARY.getValue() && day == 17)
          return Boolean.TRUE;
      if (month == Month.SEPTEMBER.getValue() && day == 21)
          return Boolean.TRUE;
      if (month == Month.MAY.getValue() && day == 22)
          return Boolean.TRUE;
      return Boolean.FALSE;
  }
}
  • 等效代替

  • image.png

  • 这里有个误区是,认为 java.util.Date 类似于新 API 中的 LocalDateTime。其实不是,虽然它们都没有时区概念,但 java.util.Date 类是因为使用 UTC 表示,所以没有时区概念,其本质是时间戳;而 LocalDateTime,严格上可以认为是一个日期时间的表示,而不是一个时间点。

  • 因此,在把 Date 转换为 LocalDateTime 的时候,需要通过 Date 的 toInstant 方法得到一个 UTC 时间戳进行转换,并需要提供当前的时区,这样才能把 UTC 时间转换为本地日期时间(的表示)。反过来,把 LocalDateTime 的时间表示转换为 Date 时,也需要提供时区,用于指定是哪个时区的时间表示,也就是先通过 atZone 方法把 LocalDateTime 转换为 ZonedDateTime,然后才能获得 UTC 时间戳

Date in = new Date();
  LocalDateTime ldt = LocalDateTime.ofInstant(in.toInstant(), ZoneId.systemDefault().toInstant());
  Date out = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());

17:OOM与自动垃圾回收器

  • 内存空间始终是有限的,Java 的几大内存区域始终都有 OOM 的可能。相应地,Java程序的常见 OOM 类型,可以分为堆内存的 OOM、栈 OOM、元空间 OOM、直接内存OOM 等。

17.1:重复对象导致OOM

  • 有一个项目在内存中缓存了全量用户数据,在搜索用户时可以直接从缓存中返回用户信息。现在为了改善用户体验,需要实现输入部分用户名自动在下拉框提示补全用户名的功能(也就是所谓的自动完成功能)

  • 对于这种快速检索的需求,最好使用 Map 来实现,会比直接从 List 搜索快得多。为实现这个功能,我们需要一个 HashMap 来存放这些用户数据,Key 是用户姓名索引,Value 是索引下对应的用户列表。举一个例子,如果有两个用户 aa 和 ab,那么 Key 就有

    三个,分别是 a、aa 和 ab。用户输入字母 a 时,就能从 Value 这个 List 中拿到所有字母a 开头的用户,即 aa 和 ab。

  • 在代码中,在数据库中存入 1 万个测试用户,用户名由 a~j 这 6 个字母随机构成,然后把每一个用户名的前 1 个字母、前 2 个字母以此类推直到完整用户名作为 Key 存入缓存中,缓存的 Value 是一个 UserDTO 的 List,存放的是所有相同的用户名索引,以及对应的用户信息

@Service
@Slf4j
public class UsernameAutoCompleteService {

    //自动完成的索引,Key是用户输入的部分用户名,Value是对应的用户数据
    private ConcurrentHashMap<String, List<UserDTO>> autoCompleteIndex = new ConcurrentHashMap<>();

    @Autowired
    private UserRepository userRepository;

    /*
    在数据库中存入 1 万个测试用户,用户名由 a~j 这 6 个字母随机构成,然后把每一个用户名的前 1 个字母、前 2 个字母以此类推直到完整用户名作为 Key 存入缓存中,缓存的 Value 是一个 UserDTO 的 List,存放的是所有相同的用户名索引,以及对应的用户信息
    可以看到,一共有 26838 个索引(也就是所有用户名的 1 位、2 位一直到 6 位有 26838个组合),HashMap 的 Value,也就是 List一共有 1 万个用户 *6=6 万个 UserDTO 对
    使用内存分析工具 MAT 打开堆 dump 发现,6 万个 UserDTO 占用了约 1.2GB 的内存
    在实际的项目中,用户信息的缓存可能是随着用户输入增量缓存的,而不是像这个案例一样在程序初始化的时候全量缓存
    */
    @PostConstruct
    public void wrong() {
        //先保存10000个用户名随机的用户到数据库中
        userRepository.saveAll(LongStream.rangeClosed(1, 10000).mapToObj(i -> new UserEntity(i, randomName())).collect(Collectors.toList()));

        //从数据库加载所有用户
        userRepository.findAll().forEach(userEntity -> {
            int len = userEntity.getName().length();
            //对于每一个用户,对其用户名的前N位进行索引,N可能是1~6六种长度类型
            for (int i = 0; i < len; i++) {
                String key = userEntity.getName().substring(0, i + 1);
                autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
                        .add(new UserDTO(userEntity.getName()));
            }
        });
        log.info("autoCompleteIndex size:{} count:{}", autoCompleteIndex.size(),
                autoCompleteIndex.entrySet().stream().map(item -> item.getValue().size()).reduce(0, Integer::sum));
    }

    /*
    把所有 UserDTO 先加入 HashSet 中,因为UserDTO 以 name 来标识唯一性,所以重复用户名会被过滤掉,最终加入 HashSet 的UserDTO 就不足 1 万个。
    有了 HashSet 来缓存所有可能的 UserDTO 信息,我们再构建自动完成索引autoCompleteIndex 这个 HashMap 时,就可以直接从 HashSet 获取所有用户信息来构建了。这样一来,同一个用户名前缀的不同组合(比如用户名为 abc 的用户,a、ab 和abc 三个 Key)关联到 UserDTO 是同一份
    再次分析堆内存,可以看到 UserDTO 只有 9945 份,总共占用的内存不到 200M。这才是我们真正想要的结果
    修复后的程序,不仅相同的 UserDTO 只有一份,总副本数变为了原来的六分之一;而且因为 HashSet 的去重特性,双重节约了内存。
    */
    //@PostConstruct
    public void right() {
        userRepository.saveAll(LongStream.rangeClosed(1, 10000).mapToObj(i -> new UserEntity(i, randomName())).collect(Collectors.toList()));

        HashSet<UserDTO> cache = userRepository.findAll().stream()
                .map(item -> new UserDTO(item.getName()))
                .collect(Collectors.toCollection(HashSet::new));

        cache.stream().forEach(userDTO -> {
            int len = userDTO.getName().length();
            for (int i = 0; i < len; i++) {
                String key = userDTO.getName().substring(0, i + 1);
                autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
                        .add(userDTO);
            }
        });
        log.info("autoCompleteIndex size:{} count:{}", autoCompleteIndex.size(),
                autoCompleteIndex.entrySet().stream().map(item -> item.getValue().size()).reduce(0, Integer::sum));
    }
  /**
   * 随机生成长度为6的英文名称,字母包含 abcdefghij
   *
   * @return
   */
  private String randomName() {
      return String.valueOf(Character.toChars(ThreadLocalRandom.current().nextInt(10) + 'a')).toUpperCase() +
              String.valueOf(Character.toChars(ThreadLocalRandom.current().nextInt(10) + 'a')) +
              String.valueOf(Character.toChars(ThreadLocalRandom.current().nextInt(10) + 'a')) +
              String.valueOf(Character.toChars(ThreadLocalRandom.current().nextInt(10) + 'a')) +
              String.valueOf(Character.toChars(ThreadLocalRandom.current().nextInt(10) + 'a')) +
              String.valueOf(Character.toChars(ThreadLocalRandom.current().nextInt(10) + 'a'));
  }
}
  @Entity
  @Data
  public class UserEntity {
      @Id
      @GeneratedValue(strategy = IDENTITY)
      private Long id;
      private String name;
    public UserEntity() {
  }

  public UserEntity(Long id, String name) {
      this.id = id;
      this.name = name;
  }
  }
  • 对于每一个用户对象 UserDTO,除了有用户名,我们还加入了 10K 左右的数据模拟其用户信息
@Data
  public class UserDTO {
      private String name;
      @EqualsAndHashCode.Exclude
      private String payload;

      public UserDTO(String name) {
          this.name = name;
          this.payload = IntStream.rangeClosed(1, 10_000)
                  .mapToObj(__ -> "a")
                  .collect(Collectors.joining(""));
      }
  }
  • 一个后台程序需要从数据库加载大量信息用于数据导出,这些数据在数据库中占用 100M 内存,但是 1GB 的 JVM 堆却无法完成导出操作。100M 的数据加载到程序内存中,变为 Java 的数据结构就已经占用了 200M 堆内存;这些数据经过 JDBC、MyBatis 等框架其实是加载了 2 份,然后领域模型、DTO 再进行转换可能又加载了 2 次;最终,占用的内存达到了 200M*4=800M,所以,在进行容量评估时,我们不能认为一份数据在程序内存中也是一份

17.2:WeakHashMap与OOM

  • 为了防止缓存中堆积大量数据导致 OOM,一些同学可能会想到使用 WeakHashMap 作为缓存容器。
  • WeakHashMap 的特点是 Key 在哈希表内部是弱引用的,当没有强引用指向这个 Key 之后,Entry 会被 GC,即使我们无限往 WeakHashMap 加入数据,只要 Key 不再使用,也就不会 OOM。
  • Java 中引用类型和垃圾回收的关系:

    • 垃圾回收器不会回收有强引用的对象;
    • 在内存充足时,垃圾回收器不会回收具有软引用的对象;
    • 垃圾回收器只要扫描到了具有弱引用的对象就会回收,WeakHashMap 就是利用了这个特点。
  • 案例:使用 WeakHashMap 却最终 OOM 的案例
@RestController
@RequestMapping("weakhashmapoom")
@Slf4j
public class WeakHashMapOOMController {
  
    //需要注意的是,WeakHashMap 的 Key 是 User对象,而其 Value 是 UserProfile 对象,持有了 User 的引用
    private Map<User, UserProfile> cache = new WeakHashMap<>();
    private Map<User, WeakReference<UserProfile>> cache2 = new WeakHashMap<>();
    private Map<User, UserProfile> cache3 = new ConcurrentReferenceHashMap<>();

    /*
    声明一个 Key 是 User 类型、Value 是 UserProfile 类型的 WeakHashMap,作为用户数据缓存,往其中添加 200 万个 Entry,然后使用 ScheduledThreadPoolExecutor 发起一个定时任务,每隔 1 秒输出缓存中的 Entry 个数
    可以看到,输出的 cache size 始终是 200 万,即使我们通过 jvisualvm 进行手动 GC 还是这样。这就说明,这些 Entry 无法通过 GC 回收。如果你把 200 万改为 1000 万,就可以在日志中看到OOM 错误
    分析一下 WeakHashMap 的源码,你会发现 WeakHashMap和 HashMap 的最大区别,是 Entry 对象的实现。
    看第一个私有属性cache和下方的类定义
    回顾下这个逻辑:put 一个对象进 Map 时,它的 key 会被封装成弱引用对象;发生 GC 时,弱引用的 key 被发现并放入 queue;调用 get 等方法时,扫描 queue 删除 key,以及包含 key 和 value 的 Entry 对象。
    WeakHashMap 的 Key 虽然是弱引用,但是其 Value 却持有 Key 中对象的强引用,Value 被 Entry 引用,Entry 被 WeakHashMap 引用,最终导致 Key 无法回收。
    解决方案就是让 Value 变为弱引用,使用 WeakReference 来包装 UserProfile 即可;
    */
    @GetMapping("wrong")
    public void wrong() {
        String userName = "zhuye";
        //间隔1秒定时输出缓存中的条目数
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
                () -> log.info("cache size:{}", cache.size()), 1, 1, TimeUnit.SECONDS);
        LongStream.rangeClosed(1, 2000000).forEach(i -> {
            User user = new User(userName + i);
            //这次,我们使用弱引用来包装UserProfile
            cache.put(user, new UserProfile(user, "location" + i));
        });
    }

    /*
    重新运行程序,从日志中观察到 cache size 不再是固定的 200 万,而是在不断减少,甚至在手动 GC 后所有的 Entry 都被回收了
    */
    @GetMapping("right")
    public void right() {
        String userName = "zhuye";
        //间隔1秒定时输出缓存中的条目数
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
                () -> log.info("cache size:{}", cache2.size()), 1, 1, TimeUnit.SECONDS);
        LongStream.rangeClosed(1, 2000000).forEach(i -> {
            User user = new User(userName + i);
            cache2.put(user, new WeakReference(new UserProfile(user, "location" + i)));
        });
    }

    /*
    还有一种办法就是,让 Value 也就是 UserProfile 不再引用 Key,而是重新 new 出一个新的 User 对象赋值给 UserProfile
    */
    @GetMapping("right2")
    public void right2() {
        String userName = "zhuye";
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
                () -> log.info("cache size:{}", cache.size()), 1, 1, TimeUnit.SECONDS);
        LongStream.rangeClosed(1, 2000000).forEach(i -> {
            User user = new User(userName + i);
            cache.put(user, new UserProfile(new User(user.getName()), "location" + i));
        });
    }

    @GetMapping("right3")
    public void right3() {
        String userName = "zhuye";
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
                () -> log.info("cache size:{}", cache3.size()), 1, 1, TimeUnit.SECONDS);
        LongStream.rangeClosed(1, 20000000).forEach(i -> {
            User user = new User(userName + i);
            cache3.put(user, new UserProfile(user, "location" + i));
        });
    }
  
    @Data
  @AllArgsConstructor
  @NoArgsConstructor
  class User {
      private String name;
  }

  @Data
  @AllArgsConstructor
  @NoArgsConstructor
  public class UserProfile {
      private User user;
      private String location;
  }
  }
  • 此外,Spring 提供的ConcurrentReferenceHashMap类可以使用弱引用、软引用做缓存,Key 和 Value 同时被软引用或弱引用包装,也能解决相互引用导致的数据不能释放问题。与 WeakHashMap 相比,ConcurrentReferenceHashMap 不但性能更好,还可以确保线程安全。

17.3:Tomcat参数配置与OOM

  • 有个应用在业务量大的情况下会出现假死,日志中也有大量 OOM 异常;通过 MAT 打开 dump 文件后,我们一眼就看到

    OOM 的原因是,有接近 1.7GB 的 byte 数组分配,而 JVM 进程的最大堆内存我们只配置了 2GB;通过查看引用可以发现,大量引用都是 Tomcat 的工作线程。大部分工作线程都分配了两个 10M 左右的数组,100 个左右工作线程吃满了内存。第一个红框是

    Http11InputBuffer,其 buffer 大小是 10008192 字节;而第二个红框的Http11OutputBuffer 的 buffer,正好占用 10000000 字节

  • 可以想到,一定是应用把 Tomcat 头相关的参数配置为 10000000 了,使得每一个请求对于 Request 和 Response 都占用了 20M 内存,最终在并发较多的情况下引起了 OOM。

  • 查看项目代码发现配置文件中有这样的配置项bad

@RestController
  @RequestMapping("impropermaxheadersize")
  @Slf4j
  public class ImproperMaxHeaderSizeController {
      @Autowired
      private Environment env;

      //wrk -t10 -c100 -d 60s http://localhost:45678/impropermaxheadersize/oom
      //-Xmx1g -Xms1g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.
      //https://tomcat.apache.org/tomcat-8.0-doc/config/http.html
      @GetMapping("oom")
      public String oom() {
          log.info("Got request");
          log.info("server.max-http-header-size={},server.tomcat.max-threads={}",
                  env.getProperty("server.max-http-header-size")
                  , env.getProperty("server.tomcat.max-threads")
          );
          try {
              TimeUnit.MILLISECONDS.sleep(1);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
          return "OK";
      }

  }
@SpringBootApplication
public class CommonMistakesApplicationBad {
    public static void main(String[] args) {
        Utils.loadPropertySource(CommonMistakesApplicationBad.class, "bad.properties");
        SpringApplication.run(CommonMistakesApplicationBad.class, args);
    }
}

@SpringBootApplication
@Slf4j
public class CommonMistakesApplicationGood {
    public static void main(String[] args) {
        Utils.loadPropertySource(CommonMistakesApplicationGood.class, "good.properties");
        SpringApplication.run(CommonMistakesApplicationGood.class, args);
    }

}
#bad
server.max-http-header-size=10000000
server.tomcat.max-threads=400

#good
server.tomcat.max-threads=400
  • 这个案例告诉我们,一定要根据实际需求来修改参数配置,可以考虑预留 2 到 5 倍的量。容量类的参数背后往往代表了资源,设置超大的参数就有可能占用不必要的资源,在并发量大的时候因为资源大量分配导致 OOM。

  • 通常而言,Java 程序的 OOM有如下几种可能:

    • 一是,我们的程序确实需要超出 JVM 配置的内存上限的内存。不管是程序实现的不合理,还是因为各种框架对数据的重复处理、加工和转换,相同的数据在内存中不一定只占用一份空间。针对内存量使用超大的业务逻辑,比如缓存逻辑、文件上传下载和导出逻辑,我们在做容量评估时,可能还需要实际做一下 Dump,而不是进行简单的假设。
    • 二是,出现内存泄露,其实就是我们认为没有用的对象最终会被 GC,但却没有。GC 并不会回收强引用对象,我们可能经常在程序中定义一些容器作为缓存,但如果容器中的数据无限增长,要特别小心最终会导致 OOM。使用 WeakHashMap 是解决这个问题的好办法,但值得注意的是,如果强引用的 Value 有引用 Key,也无法回收 Entry。
    • 三是,不合理的资源需求配置,在业务量小的时候可能不会出现问题,但业务量一大可能很快就会撑爆内存。比如,随意配置 Tomcat 的 max-http-header-size 参数,会导致一个请求使用过多的内存,请求量大的时候出现 OOM。在进行参数配置的时候,我们要认识到,很多限制类参数限制的是背后资源的使用,资源始终是有限的,需要根据实际需求来合理设置参数。
  • 在出现 OOM 之后,也不用过于紧张。我们可以根据错误日志中的异常信息,再结合 jstat 等命令行工具观察内存使用情况,以及程序的 GC 日志,来大致定位出现 OOM 的内存区块和类型。其实,我们遇到的 90% 的 OOM 都是堆 OOM,对 JVM 进程进行堆内存 Dump,或使用 jmap 命令分析对象内存占用排行,一般都可以很容易定位到问题。

  • 建议为生产系统的程序配置 JVM 参数启用详细的 GC 日志,方便观察垃圾收集器的行为,并开启HeapDumpOnOutOfMemoryError,以便在出现 OOM 时能自动Dump 留下第一问题现场。

18:反射注解与泛型

  • 只有学好、用好这些高级特性,才能开发出更简洁易读的代码,而且几乎所有的框架都使用了这三大高级特性。比如,要减少重复代码,就得用到反射和注解

18.1:反射调用方法不是以传参决定重载

  • 反射的功能包括,在运行时动态获取类和类成员定义,以及动态读取属性调用方法。
  • 也就是说,针对类动态调用方法,不管类中字段和方法怎么变动,我们都可以用相同的规则来读取信息和执行方法。因此,几乎所有的 ORM(对象关系映射)、对象映射、MVC 框架都使用了反射。
  • 反射的起点是 Class 类,Class 类提供了各种方法帮我们查询它的信息。
@Slf4j
public class ReflectionIssueApplication {

    /*
    反射调用方法遇到重载的坑:有两个叫 age 的方法,入参分别是基本类型 int 和包装类型 Integer。
    如果不通过反射调用,走哪个重载方法很清晰,比如传入 36 走 int 参数的重载方法,传入Integer.valueOf(“36”) 走 Integer 重载
    */
    public static void main(String[] args) throws Exception {

        ReflectionIssueApplication application = new ReflectionIssueApplication();
        application.wrong();
        application.right();

    }

    private void age(int age) {
        log.info("int age = {}", age);
    }

    private void age(Integer age) {
        log.info("Integer age = {}", age);
    }

    /*
    但使用反射时的误区是,认为反射调用方法还是根据入参确定方法重载。
    输出的日志证明,走的是 int 重载方法
    其实,要通过反射进行方法调用,第一步就是通过方法签名来确定方法。具体到这个案例,getDeclaredMethod 传入的参数类型 Integer.TYPE 代表的是 int,所以实际执行方法时无论传的是包装类型还是基本类型,都会调用 int 入参的 age 方法。
    */
    public void wrong() throws Exception {
        //使用getDeclaredMethod 来获取 age 方法,然后传入 Integer.valueOf(“36”)
        getClass().getDeclaredMethod("age", Integer.TYPE).invoke(this, Integer.valueOf("36"));
    }

    /*
    把 Integer.TYPE 改为 Integer.class,执行的参数类型就是包装类型的 Integer。这时,无论传入的是 Integer.valueOf(“36”) 还是基本类型的 36,都会调用 Integer 为入参的 age 方法
    */
    public void right() throws Exception {
        getClass().getDeclaredMethod("age", Integer.class).invoke(this, Integer.valueOf("36"));
        getClass().getDeclaredMethod("age", Integer.class).invoke(this, 36);
    }
}
  • 现在我们非常清楚了,反射调用方法,是以反射获取方法时传入的方法名称和参数类型来确定调用方法的。

18.2:泛型经过类型擦除多出桥接方法

  • 泛型是一种风格或范式,一般用于强类型程序设计语言,允许开发者使用类型参数替代明确的类型,实例化时再指明具体的类型。
  • 它是代码重用的有效手段,允许把一套代码应用到多种数据类型上,避免针对每一种数据类型实现重复的代码。
  • Java 编译器对泛型应用了强大的类型检测,如果代码违反了类型安全就会报错,可以在编译时暴露大多数泛型的编码错误。但总有一部分编码错误,比如泛型类型擦除的坑,在运行时才会暴露。
  • 有一个项目希望在类字段内容变动时记录日志,于是开发同学就想到定义一个泛型父类,并在父类中定义一个统一的日志记录方法,子类可以通过继承重用这个方法。代码上线后业务没啥问题,但总是出现日志重复记录的问题。开始时,我们怀疑是日志框架的问题,排查到最后才发现是泛型的问题,反复修改多次才解决了这个问题。
public class GenericAndInheritanceApplication {

    public static void main(String[] args) {
        wrong3();
    }

    /*
    在实现的时候,子类方法的调用是通过反射进行的。实例化 Child1 类型后,通过getClass().getMethods 方法获得所有的方法;然后按照方法名过滤出 setValue 方法进行调用,传入字符串 test 作为参数
    运行代码后可以看到,虽然 Parent 的 value 字段正确设置了 test,但父类的 setValue 方法调用了两次,计数器也显示 2 而不是 1
    显然,两次 Parent 的 setValue 方法调用,是因为 getMethods 方法找到了两个名为setValue 的方法,分别是父类和子类的 setValue 方法。
    子类方法重写父类方法失败的原因,包括两方面:
    一是,子类没有指定 String 泛型参数,父类的泛型方法 setValue(T value) 在泛型擦除后是 setValue(Object value),子类中入参是 String 的 setValue 方法被当作了新方法;
    二是,子类的 setValue 方法没有增加 @Override 注解,因此编译器没能检测到重写失败的问题。这就说明,重写子类方法时,标记 @Override 是一个好习惯。
    */
    public static void wrong1() {
        Child1 child1 = new Child1();
        Arrays.stream(child1.getClass().getMethods())
                .filter(method -> method.getName().equals("setValue"))
                .forEach(method -> {
                    try {
                        method.invoke(child1, "test");
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                });
        System.out.println(child1.toString());
    }

    /*
    getMethods 方法能获得当前类和父类的所有 public 方法,而getDeclaredMethods 只能获得当前类所有的 public、protected、package 和 private方法。
    于是,他就用 getDeclaredMethods 替代了 getMethods
    这样虽然能解决重复记录日志的问题,但没有解决子类方法重写父类方法失败的问题
    其实这治标不治本,其他人使用 Child1 时还是会发现有两个 setValue 方法,非常容易让人困惑。
    */
    public static void wrong2() {
        Child1 child1 = new Child1();
        Arrays.stream(child1.getClass().getDeclaredMethods())
                .filter(method -> method.getName().equals("setValue"))
                .forEach(method -> {
                    try {
                        method.invoke(child1, "test");
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                });
        System.out.println(child1.toString());
    }

    public static void wrong3() {
        Child2 child2 = new Child2();
        Arrays.stream(child2.getClass().getDeclaredMethods())
                .filter(method -> method.getName().equals("setValue"))
                .forEach(method -> {
                    try {
                        method.invoke(child2, "test");
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                });
        System.out.println(child2.toString());
    }

    public static void right() {
        Child2 child2 = new Child2();
        //可以使用 method 的 isBridge 方法,来判断方法是不是桥接方法
        Arrays.stream(child2.getClass().getDeclaredMethods())
                .filter(method -> method.getName().equals("setValue") && !method.isBridge())
                .findFirst().ifPresent(method -> {
            try {
                method.invoke(child2, "test");
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        System.out.println(child2.toString());
    }
}

/*
父类是这样的:有一个泛型占位符 T;有一个 AtomicInteger 计数器,用来记录 value 字段更新的次数,其中 value 字段是泛型 T 类型的,setValue 方法每次为 value 赋值时对计数器进行 +1 操作
*/
class Parent<T> {

    //用于记录value更新的次数,模拟日志记录的逻辑
    AtomicInteger updateCount = new AtomicInteger();

    private T value;

    //重写toString,输出值和值更新次数
    @Override
    public String toString() {
        return String.format("value: %s updateCount: %d", value, updateCount.get());
    }

    //设置值
    public void setValue(T value) {
        System.out.println("Parent.setValue called");
        this.value = value;
        updateCount.incrementAndGet();
    }
}

/*
继承父类,但没有提供父类泛型参数;定义了一个参数为String 的 setValue 方法,通过 super.setValue 调用父类方法实现日志记录。
*/
class Child1 extends Parent {

    public void setValue(String value) {
        System.out.println("Child1.setValue called");
        super.setValue(value);
    }
}

/*
重新实现了 Child2,继承 Parent的时候提供了 String 作为泛型 T 类型,并使用 @Override 关键字注释了 setValue 方法,实现了真正有效的方法重写
但很可惜,修复代码上线后,还是出现了日志重复记录
可以看到,这次是 Child2 类的 setValue 方法被调用了两次。
调试一下可以发现,Child2 类其实有 2 个 setValue 方法,入参分别是 String 和Object。
如果不通过反射来调用方法,我们确实很难发现这个问题。其实,这就是泛型类型擦除导致的问题。

Java 的泛型类型在编译后擦除为 Object。虽然子类指定了父类泛型 T 类型是String,但编译后 T 会被擦除成为 Object,所以父类 setValue 方法的入参是 Object,value 也是 Object。如果子类 Child2 的 setValue 方法要覆盖父类的 setValue 方法,那入参也必须是 Object。所以,编译器会为我们生成一个所谓的 bridge 桥接方法

可以使用 method 的 isBridge 方法,来判断方法是不是桥接方法:
通过 getDeclaredMethods 方法获取到所有方法后,必须同时根据方法名 setValue 和非 isBridge 两个条件过滤,才能实现唯一过滤;
使用 Stream 时,如果希望只匹配 0 或 1 项的话,可以考虑配合 ifPresent 来使用findFirst 方法。
*/
class Child2 extends Parent<String> {

    @Override
    public void setValue(String value) {
        System.out.println("Child2.setValue called");
        super.setValue(value);
    }
}
  • 使用反射查询类方法清单时,我们要注意两点:

    • getMethods 和 getDeclaredMethods 是有区别的,前者可以查询到父类方法,后者只能查询到当前类。
    • 反射进行方法调用要注意过滤桥接方法。

18.3:注解可以继承吗

  • 注解可以为 Java 代码提供元数据,各种框架也都会利用注解来暴露功能,比如 Spring 框架中的 @Service、@Controller、@Bean 注解,Spring Boot 的@SpringBootApplication 注解。
  • 框架可以通过类或方法等元素上标记的注解,来了解它们的功能或特性,并以此来启用或执行相应的功能。通过注解而不是 API 调用来配置框架,属于声明式交互,可以简化框架的配置工作,也可以和框架解耦。
  • 可能会认为,类继承后,类的注解也可以继承,子类重写父类方法后,父类方法上的注解也能作用于子类,但这些观点其实是错误或者说是不全面的。
/*
定义一个包含 value 属性的 MyAnnotation 注解,可以标记在方法或类上
在注解上标记 @Inherited 元注解可以实现注解的继承。
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface MyAnnotation {
    String value();
}
@Slf4j
public class AnnotationInheritanceApplication {

    public static void main(String[] args) throws NoSuchMethodException {
        wrong();
        right();
    }

    private static String getAnnotationValue(MyAnnotation annotation) {
        if (annotation == null) return "";
        return annotation.value();
    }

    /*
    通过反射分别获取 Parent 和 Child 的类和方法的注解信息,并输出注解的value 属性的值(如果注解不存在则输出空字符串)
    可以看到,父类的类和方法上的注解都可以正确获得,但是子类的类和方法却不能。这说明,子类以及子类的方法,无法自动继承父类和父类方法上的注解。
    在注解上标记 @Inherited 元注解可以实现注解的继承。
    可以看到,子类可以获得父类类上的注解;子类 foo 方法虽然是重写父类方法,并且注解本身也支持继承,但还是无法获得方法上的注解。
    @Inherited 只能实现类上的注解继承。要想实现方法上注解的继承,你可以通过反射在继承链上找到方法上的注解。但,这样实现起来很繁琐,而且需要考虑桥接方法。
    */
    public static void wrong() throws NoSuchMethodException {
        //获取父类的类和方法上的注解
        Parent parent = new Parent();
        log.info("ParentClass:{}", getAnnotationValue(parent.getClass().getAnnotation(MyAnnotation.class)));
        log.info("ParentMethod:{}", getAnnotationValue(parent.getClass().getMethod("foo").getAnnotation(MyAnnotation.class)));      
      //获取子类的类和方法上的注解
      Child child = new Child();
      log.info("ChildClass:{}", getAnnotationValue(child.getClass().getAnnotation(MyAnnotation.class)));
      log.info("ChildMethod:{}", getAnnotationValue(child.getClass().getMethod("foo").getAnnotation(MyAnnotation.class)));

  }

  /*
  好在 Spring 提供了 AnnotatedElementUtils 类,来方便我们处理注解的继承问题。这个类的 findMergedAnnotation 工具方法,可以帮助我们找出父类和接口、父类方法和接口方法上的注解,并可以处理桥接方法,实现一键找到继承链的注解
  可以看到,子类 foo 方法也获得了父类方法上的注解
  */
  public static void right() throws NoSuchMethodException {
      Parent parent = new Parent();
      log.info("ParentClass:{}", getAnnotationValue(parent.getClass().getAnnotation(MyAnnotation.class)));
      log.info("ParentMethod:{}", getAnnotationValue(parent.getClass().getMethod("foo").getAnnotation(MyAnnotation.class)));

      Child child = new Child();
      log.info("ChildClass:{}", getAnnotationValue(AnnotatedElementUtils.findMergedAnnotation(child.getClass(), MyAnnotation.class)));
      log.info("ChildMethod:{}", getAnnotationValue(AnnotatedElementUtils.findMergedAnnotation(child.getClass().getMethod("foo"), MyAnnotation.class)));

  }
  /*
  定义一个标记了 @MyAnnotation 注解的父类 Parent,设置 value 为 Class 字符串;同时这个类的 foo 方法也标记了 @MyAnnotation 注解,设置 value 为 Method 字符串。
  
  */
  @MyAnnotation(value = "Class")
  @Slf4j
  static class Parent {

      @MyAnnotation(value = "Method")
      public void foo() {
      }
  }

  /*
  定义一个子类 Child 继承 Parent 父类,并重写父类的 foo 方法,子类的foo 方法和类上都没有 @MyAnnotation 注解
  */
  @Slf4j
  static class Child extends Parent {
      @Override
      public void foo() {
      }
  }
}

19:spring中的IOC与AOP

  • Spring 的家族庞大,常用的模块就有 Spring Data、Spring Security、Spring Boot、Spring Cloud 等。其实呢,Spring 体系虽然庞大,但都是围绕 Spring Core 展开的,而 Spring Core 中最核心的就是 IoC(控制反转)和 AOP(面向切面编程)。
  • IoC 和 AOP 的初衷是解耦和扩展。理解这两个核心技术,就可以让你的代码变得更灵活、可随时替换,以及业务组件间更解耦。
  • 如果以容器为依托来管理所有的框架、业务对象,我们不仅可以无侵入地调整对象的关系,还可以无侵入地随时调整对象的属性,甚至是实现对象的替换。这就使得框架开发者在程序背后实现一些扩展不再是问题,带来的可能性是无限的。
  • AOP,体现了松耦合、高内聚的精髓,在切面集中实现横切关注点(缓存、权限、日志等),然后通过切点配置把代码注入合适的地方。切面、切点、增强、连接点,是 AOP 中非常重要的概念

19.1:单例的bean注入prototype的bean

  • 我们虽然知道 Spring 创建的 Bean 默认是单例的,但当 Bean 遇到继承的时候,可能会忽略这一点

  • 分享一个由单例引起内存泄露的案例

  • 架构师一开始定义了这么一个 SayService 抽象类

    /*
      维护了一个类型是 ArrayList 的字段 data,用于保存方法处理的中间数据。每次调用 say 方法都会往 data 加入新数据,可以认为 SayService 是有状态,如果 SayService 是单例的话必然会 OOM
      */
      @Slf4j
      public abstract class SayService {
          List<String> data = new ArrayList<>();
      
          public void say() {
              data.add(IntStream.rangeClosed(1, 1000000)
                      .mapToObj(__ -> "a")
                      .collect(Collectors.joining("")) + UUID.randomUUID().toString());
              log.info("I'm {} size:{}", this, data.size());
          }
      }
    
  • 但实际开发的时候,开发同学没有过多思考就把 SayHello 和 SayBye 类加上了 @Service注解,让它们成为了 Bean,也没有考虑到父类是有状态的

@Service
@Slf4j
public class SayHello extends SayService
{
  @Override
  public void say(){
    super.say();
    log.info("hello");    
  }
}

@Service
@Slf4j
public class SayBye extends SayService{
  @Override
  publicvoidsay(){
    super.say();
    log.info("bye");    
  }
}
  • 许多开发同学认为,@Service 注解的意义在于,能通过 @Autowired 注解让 Spring 自动注入对象,就比如可以直接使用注入的 List

    获取到 SayHello 和 SayBye,而没想过类的生命周期

@Autowired
List<SayService> sayServiceList;

@GetMapping("test")
public void test(){
  log.info("====================");
  sayServiceList.forEach(SayService::say);
}


  • 这一个点非常容易忽略。开发基类的架构师将基类设计为有状态的,但并不知道子类是怎么使用基类的;而开发子类的同学,没多想就直接标记了 @Service,让类成为了 Bean,通过 @Autowired 注解来注入这个服务。但这样设置后,有状态的基类就可能产生内存泄露或线程安全问题。
  • 正确的方式是,在为类标记上 @Service 注解把类型交由容器管理前,首先评估一下类是否有状态,然后为 Bean 设置合适的 Scope
  • 修改,为 SayHello 和 SayBye 两个类都标记了 @Scope 注解,设置了 PROTOTYPE 的生命周期,也就是多例
  • @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
  • 但,上线后还是出现了内存泄漏,证明修改是无效的。
  • 这就引出了单例的 Bean 如何注入 Prototype 的 Bean 这个问题
  • Controller 标记了@RestController 注解,而 @RestController 注解 =@Controller 注解+@ResponseBody 注解,又因为 @Controller 标记了 @Component 元注解,所以@RestController 注解其实也是一个 Spring Bean
  • Bean 默认是单例的,所以单例的 Controller 注入的 Service 也是一次性创建的,即使Service 本身标识了 prototype 的范围也没用。
  • 修复方式是,让 Service 以代理方式注入。这样虽然 Controller 本身是单例的,但每次都能从代理获取 Service。这样一来,prototype 范围的配置才能真正生效
  • @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
  • 当然,如果不希望走代理的话还有一种方式是,每次直接从 ApplicationContext 中获取Bean

    @Slf4j @RestController @RequestMapping(“beansingletonandorder”) public class BeanSingletonAndOrderController {

      @Autowired
      private ApplicationContext applicationContext;     @GetMapping("test2")
    public void test2() {
        log.info("====================");
        applicationContext.getBeansOfType(SayService.class).values().forEach(SayService::say);
    }   }
    
  • 一个潜在的问题。这里 Spring 注入的 SayService 的 List,第一个元素是 SayBye,第二个元素是 SayHello。但,我们更希望的是先执行 Hello 再执行 Bye,所以注入一个 List Bean 时,需要进一步考虑 Bean 的顺序或者说优先级。

19.2:监控切面因顺序导致事务失效

  • 案例:通过AOP 实现了一个整合日志记录、异常处理和方法耗时打点为一体的统一切面
  • 首先,定义一个自定义注解 Metrics,打上了该注解的方法可以实现各种监控功能

    
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.METHOD, ElementType.TYPE})
    public @interface Metrics {
        /**
         * 是否在成功执行方法后打点
         *
         * @return
         */
        boolean recordSuccessMetrics() default true;
    
        /**
         * 是否在执行方法出错时打点
         *
         * @return
         */
        boolean recordFailMetrics() default true;
    
        /**
         * 是否记录请求参数
         *
         * @return
         */
        boolean logParameters() default true;
    
        /**
         * 是否记录返回值
         *
         * @return
         */
        boolean logReturn() default true;
    
        /**
         * 是否记录异常
         *
         * @return
         */
        boolean logException() default true;
    
        /**
         * 是否屏蔽异常返回默认值
         *
         * @return
         */
        boolean ignoreException() default false;
    }
    
  • 然后,实现一个切面完成 Metrics 注解提供的功能。这个切面可以实现标记了@RestController 注解的 Web 控制器的自动切入,如果还需要对更多 Bean 进行切入的话,再自行标记 @Metrics 注解。
@Aspect
@Component
@Slf4j
//将MetricsAspect这个Bean的优先级设置为最高
//@Order(Ordered.HIGHEST_PRECEDENCE)改动2
public class MetricsAspect {
  
    //实现一个返回Java基本类型默认值的工具。其实,你也可以逐一写很多if-else判断类型,然后手动设置其默认值。这里为了减少代码量用了一个小技巧,即通过初始化一个具有1个元素的数组,然后通过获取这个数组的值来获取基本类型默认值
    private static final Map<Class<?>, Object> DEFAULT_VALUES = Stream
            .of(boolean.class, byte.class, char.class, double.class, float.class, int.class, long.class, short.class)
            .collect(toMap(clazz -> (Class<?>) clazz, clazz -> Array.get(Array.newInstance(clazz, 1), 0)));
  
    //让Spring帮我们注入ObjectMapper,以方便通过JSON序列化来记录方法入参和出参
    @Autowired
    private ObjectMapper objectMapper;

    public static <T> T getDefaultValue(Class<T> clazz) {
        return (T) DEFAULT_VALUES.get(clazz);
    }

//    @Pointcut("@annotation(org.geekbang.time.commonmistakes.spring.demo2.Metrics)")
//    public void withMetricsAnnotation() {
//    }

    //@annotation指示器实现对标记了Metrics注解的方法进行匹配
    @Pointcut("within(@org.geekbang.time.commonmistakes.springpart1.aopmetrics.Metrics *)")
    public void withMetricsAnnotation() {
    }

    //within指示器实现了匹配那些类型上标记了@RestController注解的方法
    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
    public void controllerBean() {
    }

    @Around("controllerBean() || withMetricsAnnotation())")
    public Object metrics(ProceedingJoinPoint pjp) throws Throwable {
        //尝试获取当前方法的类名和方法名
        //通过连接点获取方法签名和方法上Metrics注解,并根据方法签名生成日志中要输出的方法定义描述
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        String name = String.format("【%s】【%s】", signature.getDeclaringType().toString(), signature.toLongString());

        //因为需要默认对所有@RestController标记的Web控制器实现@Metrics注解的功能,在这种情况下方法上必然是没有@Metrics注解的,我们需要获取一个默认注解。虽然可以手动实例化一个@Metrics注解的实例出来,但为了节省代码行数,我们通过在一个内部类上定义@Metrics注解方式,然后通过反射获取注解的小技巧,来获得一个默认的@Metrics注解的实例
        Metrics metrics = signature.getMethod().getAnnotation(Metrics.class);
        /*
        if (metrics == null) {
            metrics = signature.getMethod().getDeclaringClass().getAnnotation(Metrics.class);
        }
        改动3*/
      
      
        //对于Controller和Repository,我们需要初始化一个@Metrics注解出来
        if (metrics == null) {
            @Metrics
            final class c {
            }
            metrics = c.class.getAnnotation(Metrics.class);
        }
        //对于Web项目我们可以从上下文中获取到额外的一些信息来丰富我们的日志
        //尝试从请求上下文(如果有的话)获得请求URL,以方便定位问题
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes != null) {
            HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
            if (request != null)
                name += String.format("【%s】", request.getRequestURL().toString());
        }
        //实现的是入参的日志输出
        if (metrics.logParameters())
            log.info(String.format("【入参日志】调用 %s 的参数是:【%s】", name, objectMapper.writeValueAsString(pjp.getArgs())));
        //实现连接点方法的执行,以及成功失败的打点,出现异常的时候还会记录日志
        //这里我们通过日志方式暂时替代了打点的实现,标准的实现是需要把信息对接打点服务,比如Micrometer
        Object returnValue;
        Instant start = Instant.now();
        try {
            returnValue = pjp.proceed();
            if (metrics.recordSuccessMetrics())
                //在生产级代码中,我们应考虑使用类似Micrometer的指标框架,把打点信息记录到时间序列数据库中,实现通过图表来查看方法的调用次数和执行时间,在设计篇我们会重点介绍
                log.info(String.format("【成功打点】调用 %s 成功,耗时:%d ms", name, Duration.between(start, Instant.now()).toMillis()));
        } catch (Exception ex) {
            if (metrics.recordFailMetrics())
                log.info(String.format("【失败打点】调用 %s 失败,耗时:%d ms", name, Duration.between(start, Instant.now()).toMillis()));
            if (metrics.logException())
                log.error(String.format("【异常日志】调用 %s 出现异常!", name), ex);

            //如果忽略异常那么直接返回默认值
            //忽略异常的时候,使用一开始定义的getDefaultValue方法,来获取基本类型的默认值
            if (metrics.ignoreException())
                returnValue = getDefaultValue(signature.getReturnType());
            else
                throw ex;
        }
        //实现了返回值的日志输出
        if (metrics.logReturn())
            log.info(String.format("【出参日志】调用 %s 的返回是:【%s】", name, returnValue));
        return returnValue;
    }
}
  • 接下来,分别定义最简单的 Controller、Service 和 Repository,来测试 MetricsAspect的功能
@Slf4j
@RestController  //自动进行监控
@RequestMapping("metricstest")
//@Metrics(logParameters = false, logReturn = false)改动1
public class MetricsController {
    @Autowired
    private UserService userService;

    @GetMapping("transaction")
    public int transaction(@RequestParam("name") String name) {
        try {
            userService.createUser(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userService.getUserCount(name);
    }
}

@Service
@Slf4j
//@Metrics(ignoreException = true)改动1
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void createUser(UserEntity entity) {
        userRepository.save(entity);
        if (entity.getName().contains("test"))
            throw new RuntimeException("invalid username!");
    }

    public int getUserCount(String name) {
        return userRepository.findByName(name).size();
    }
}

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
    List<UserEntity> findByName(String name);
}
  • 测试后发现,正是因为Service 方法的异常抛到了 Controller,所以整个方法才能被 @Transactional 声明式事务回滚。在这里,MetricsAspect 捕获了异常又重新抛出,记录了异常的同时又不影响事务回滚
  • 一段时间后,觉得默认的 @Metrics 配置有点不合适,希望进行两个调整(见改动1):

    • 对于 Controller 的自动打点,不要自动记录入参和出参日志,否则日志量太大;为 MetricsController 手动加上了 @Metrics 注解,设置 logParameters 和logReturn 为 false;
    • 对于 Service 中的方法,最好可以自动捕获异常。为 Service 中的 createUser 方法的 @Metrics 注解,设置了ignoreException 属性为 true
  • 代码上线后发现日志量并没有减少,更要命的是事务回滚失效了
  • 我们分析了 Spring 通过 TransactionAspectSupport 类实现事务。在 invokeWithinTransaction 方法中设置断点可以发现,在执行 Service 的createUser 方法时,TransactionAspectSupport 并没有捕获到异常,所以自然无法回滚事务。原因就是,异常被 MetricsAspect 吃掉了。
  • 我们知道,切面本身是一个 Bean,Spring 对不同切面增强的执行顺序是由 Bean 优先级决定的,具体规则是:

    • 入操作(Around(连接点执行前)、Before),切面优先级越高,越先执行。一个切面的入操作执行完,才轮到下一切面,所有切面入操作执行完,才开始执行连接点(方法)。
    • 出操作(Around(连接点执行后)、After、AfterReturning、AfterThrowing),切面优先级越低,越先执行。一个切面的出操作执行完,才轮到下一切面,直到返回到调用点。
    • 同一切面的 Around 比 After、Before 先执行。
  • 对于 Bean 可以通过 @Order 注解来设置优先级,查看 @Order 注解和 Ordered 接口源码可以发现,默认情况下 Bean 的优先级为最低优先级,其值是 Integer 的最大值。其实,值越大优先级反而越低,这点比较反直觉
  • 因为 Spring 的事务管理也是基于 AOP 的,默认情况下优先级最低也就是会先执行出操作,但是自定义切面 MetricsAspect 也同样是最低优先级,这个时候就可能出现问题:如果出操作先执行捕获了异常,那么 Spring 的事务处理就会因为无法捕获到异常导致无法回滚事务。
  • 解决方式是,明确 MetricsAspect 的优先级,可以设置为最高优先级,也就是最先执行入操作最后执行出操作(见改动2)
  • 我们要知道切入的连接点是方法,注解定义在类上是无法直接从方法上获取到注解的。修复方式是,改为优先从方法获取,如果获取不到再从类获取,如果还是获取不到再使用默认的注解(见改动3)
  • 经过这 2 处修改,事务终于又可以回滚了,并且 Controller 的监控日志也不再出现入参、出参信息。
  • 利用反射 + 注解 +Spring AOP 实现统一的横切日志关注点时,我们遇到的 Spring 事务失效问题,是由自定义的切面执行顺序引起的。

19.3:思考与讨论

  • 除了通过 @Autowired 注入 Bean 外,还可以使用 @Inject 或 @Resource 来注入Bean。你知道这三种方式的区别是什么吗?
  • 当 Bean 产生循环依赖时,比如 BeanA 的构造方法依赖 BeanB 作为成员需要注入,BeanB 也依赖 BeanA,你觉得会出现什么问题呢?又有哪些解决方式呢?

20:复杂的spring框架

  • Spring 框架内部的复杂度主要表现为三点:
    • 第一,Spring 框架借助 IoC 和 AOP 的功能,实现了修改、拦截 Bean 的定义和实例的灵活性,因此真正执行的代码流程并不是串行的。
    • 第二,Spring Boot 根据当前依赖情况实现了自动配置,虽然省去了手动配置的麻烦,但也因此多了一些黑盒、提升了复杂度。
    • 第三,Spring Cloud 模块多版本也多,Spring Boot 1.x 和 2.x 的区别也很大。如果要对 Spring Cloud 或 Spring Boot 进行二次开发的话,考虑兼容性的成本会很高。

20.1:Feign AOP 切不到的案例

  • 使用 Spring Cloud 做微服务调用,为方便统一处理 Feign,想到了用 AOP 实现,即使用 within 指示器匹配 feign.Client 接口的实现进行 AOP 切入
  • 通过 @Before 注解在执行方法前打印日志,并在代码中定义了一个标记了@FeignClient 注解的 Client 类,让其成为一个 Feign 接口
//测试Feign
@FeignClient(name = "client")
public interface Client{
  @GetMapping("/feignaop/server")
  String api();
}

//AOP切入feign.Client的实现
@Aspect
@Slf4j
@Component
public class WrongAspect {
    @Before("within(feign.Client+)")
    public void before(JoinPoint pjp) {
        log.info("within(feign.Client+) pjp {}, args:{}", pjp, pjp.getArgs());
    }
}

//配置扫描Feign
@Configuration
@EnableFeignClients(basePackages = "org.geekbang.time.commonmistakes.springpart2.aopfeign.feign")
public class Config {
}

//测试Feign
@FeignClient(name = "client")
public interface Client {
    @GetMapping("/feignaop/server")
    String api();
}
  • 通过 Feign 调用服务后可以看到日志中有输出,的确实现了 feign.Client 的切入,切入的是 execute 方法
@Slf4j
@RequestMapping("feignaop")
@RestController
public class FeignAopConntroller {

    @Autowired
    private Client client;

    @Autowired
    private ClientWithUrl clientWithUrl;

    @Autowired
    private ApplicationContext applicationContext;

    @GetMapping("client")
    public String client() {
        return client.api();
    }

    @GetMapping("clientWithUrl")
    public String clientWithUrl() {
        return clientWithUrl.api();
    }

    @GetMapping("server")
    public String server() {
        return "OK";
    }
}
  • 一开始这个项目使用的是客户端的负载均衡,也就是让 Ribbon 来做负载均衡,代码没啥问题。后来因为后端服务通过 Nginx 实现服务端负载均衡,所以开发同学把@FeignClient 的配置设置了 URL 属性,直接通过一个固定 URL 调用后端服务
@FeignClient(name = "anotherClient", url = "http://localhost:45678")
public interface ClientWithUrl {
    @GetMapping("/feignaop/server")
    String api();
}
  • 但这样配置后,之前的 AOP 切面竟然失效了,也就是 within(feign.Client+) 无法切入ClientWithUrl 的调用了;难道为 Feign 指定了 URL,其实现就不是 feign.Clinet 了吗?

  • 分析一下 FeignClient 的创建过程,也就是分析FeignClientFactoryBean 类的 getTarget 方法。当 URL 没有内容也就是为空或者不配置时调用 loadBalance 方法,在其内部通过 FeignContext 从容器获取 feign.Client 的实例,调试一下可以看到,client 是 LoadBalanceFeignClient,已经是经过代理增强的,明显是一个 Bean。所以,没有指定 URL 的 @FeignClient 对应的 LoadBalanceFeignClient,是可以通过feign.Client 切入的

  • 为什么 within(feign.Client+) 无法切入设置过 URL 的@FeignClient ClientWithUrl:

    • 表达式声明的是切入 feign.Client 的实现类。
    • Spring 只能切入由自己管理的 Bean。
    • 虽然 LoadBalancerFeignClient 和 ApacheHttpClient 都是 feign.Client 接口的实现,但是 HttpClientFeignLoadBalancedConfiguration 的自动配置只是把前者定义为 Bean,后者是 new 出来的、作为了 LoadBalancerFeignClient 的 delegate,不是 Bean。
    • 在定义了 FeignClient 的 URL 属性后,我们获取的是 LoadBalancerFeignClient 的delegate,它不是 Bean。
    • 因此,定义了 URL 的 FeignClient 采用 within(feign.Client+) 无法切入。
  • 经过一番研究发现,ApacheHttpClient 其实有机会独立成为 Bean。查看HttpClientFeignConfiguration 的源码可以发现,当没有 ILoadBalancer 类型的时候,自动装配会把 ApacheHttpClient 设置为 Bean。

  • 这么做的原因很明确,如果我们不希望做客户端负载均衡的话,应该不会引用 Ribbon 组件的依赖,自然没有 LoadBalancerFeignClient,只有 ApacheHttpClient

  • 那,把 pom.xml 中的 ribbon 模块注释之后,是不是可以解决问题呢?但,问题并没解决,启动出错误了

  • 这里,又涉及了 Spring 实现动态代理的两种方式:

    • JDK 动态代理,通过反射实现,只支持对实现接口的类进行代理
    • CGLIB 动态字节码注入方式,通过继承实现代理,没有这个限制
  • Spring Boot 2.x 默认使用 CGLIB 的方式,但通过继承实现代理有个问题是,无法继承final 的类。因为,ApacheHttpClient 类就是定义为了 final

  • 为解决这个问题,我们把配置参数 proxy-target-class 的值修改为 false,以切换到使用JDK 动态代理的方式

  • spring.aop.proxy-target-class=false

  • 修改后执行 clientWithUrl 接口可以看到,通过 within(feign.Client+) 方式可以切入feign.Client 子类了。

  • Spring Cloud 使用了自动装配来根据依赖装配组件,组件是否成为Bean 决定了 AOP 是否可以切入,在尝试通过 AOP 切入 Spring Bean 的时候要注意。

20.2:spring程序配置的优先级

  • 通过配置文件 application.properties,可以实现 Spring Boot 应用程序的参数配置。但我们可能不知道的是,Spring 程序配置是有优先级的,即当两个不同的配置源包含相同的配置项时,其中一个配置项很可能会被覆盖掉。
  • 对于 Spring Boot 应用程序,一般我们会通过设置 management.server.port 参数,来暴露独立的 actuator 管理端口。这样做更安全,也更方便监控系统统一监控程序是否健康。
  • management.server.port=45679
  • 。。。。。。

Comments

Content