mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
450
极客时间专栏/Java业务开发常见错误100例/代码篇/01 | 使用了并发工具类库,线程安全就高枕无忧了吗?.md
Normal file
450
极客时间专栏/Java业务开发常见错误100例/代码篇/01 | 使用了并发工具类库,线程安全就高枕无忧了吗?.md
Normal file
@@ -0,0 +1,450 @@
|
||||
<audio id="audio" title="01 | 使用了并发工具类库,线程安全就高枕无忧了吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/ed/5fdcdac948fa08ba18ebb2a93dc1b9ed.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。作为课程的第一讲,我今天要和你聊聊使用并发工具类库相关的话题。
|
||||
|
||||
在代码审核讨论的时候,我们有时会听到有关线程安全和并发工具的一些片面的观点和结论,比如“把HashMap改为ConcurrentHashMap,就可以解决并发问题了呀”“要不我们试试无锁的CopyOnWriteArrayList吧,性能更好”。事实上,这些说法都不太准确。
|
||||
|
||||
的确,为了方便开发者进行多线程编程,现代编程语言会提供各种并发工具类。但如果我们没有充分了解它们的使用场景、解决的问题,以及最佳实践的话,盲目使用就可能会导致一些坑,小则损失性能,大则无法确保多线程情况下业务逻辑的正确性。
|
||||
|
||||
我需要先说明下,这里的并发工具类是指用来解决多线程环境下并发问题的工具类库。一般而言并发工具包括同步器和容器两大类,业务代码中使用并发容器的情况会多一些,我今天分享的例子也会侧重并发容器。
|
||||
|
||||
接下来,我们就看看在使用并发工具时,最常遇到哪些坑,以及如何解决、避免这些坑吧。
|
||||
|
||||
## 没有意识到线程重用导致用户信息错乱的Bug
|
||||
|
||||
之前有业务同学和我反馈,在生产上遇到一个诡异的问题,有时获取到的用户信息是别人的。查看代码后,我发现他使用了ThreadLocal来缓存获取到的用户信息。
|
||||
|
||||
我们知道,ThreadLocal适用于变量在线程间隔离,而在方法或类间共享的场景。如果用户信息的获取比较昂贵(比如从数据库查询用户信息),那么在ThreadLocal中缓存数据是比较合适的做法。但,这么做为什么会出现用户信息错乱的Bug呢?
|
||||
|
||||
我们看一个具体的案例吧。
|
||||
|
||||
使用Spring Boot创建一个Web应用程序,使用ThreadLocal存放一个Integer的值,来暂且代表需要在线程中保存的用户信息,这个值初始是null。在业务逻辑中,我先从ThreadLocal获取一次值,然后把外部传入的参数设置到ThreadLocal中,来模拟从当前上下文获取到用户信息的逻辑,随后再获取一次值,最后输出两次获得的值和线程名称。
|
||||
|
||||
```
|
||||
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,符合预期:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/30/4b8f38415d03423132c7a3608ebe2430.png" alt="">
|
||||
|
||||
随后用户2来请求接口,这次就出现了Bug,第一和第二次获取到用户ID分别是1和2,显然第一次获取到了用户1的信息,原因就是Tomcat的线程池重用了线程。从图中可以看到,两次请求的线程都是同一个线程:http-nio-8080-exec-1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/db/a9ccd42716d807687b3acff9a0baf2db.png" alt="">
|
||||
|
||||
这个例子告诉我们,在写业务代码时,首先要理解代码会跑在什么线程上:
|
||||
|
||||
- 我们可能会抱怨学多线程没用,因为代码里没有开启使用多线程。但其实,可能只是我们没有意识到,在Tomcat这种Web服务器下跑的业务代码,本来就运行在一个多线程环境(否则接口也不可能支持这么高的并发),**并不能认为没有显式开启多线程就不会有线程安全问题**。
|
||||
- 因为线程的创建比较昂贵,所以Web服务器往往会使用线程池来处理请求,这就意味着线程会被重用。这时,**使用类似ThreadLocal工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据**。如果在代码中使用了自定义的线程池,也同样会遇到这个问题。
|
||||
|
||||
理解了这个知识点后,我们修正这段代码的方案是,在代码的finally代码块中,显式清除ThreadLocal中的数据。这样一来,新的请求过来即使使用了之前的线程也不会获取到错误的用户信息了。修正后的代码如下:
|
||||
|
||||
```
|
||||
@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();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重新运行程序可以验证,再也不会出现第一次查询用户信息查询到之前用户请求的Bug:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/cc/0dfe40fca441b58d491fc799d120a7cc.png" alt="">
|
||||
|
||||
ThreadLocal是利用独占资源的方式,来解决线程安全问题,那如果我们确实需要有资源在线程之间共享,应该怎么办呢?这时,我们可能就需要用到线程安全的容器了。
|
||||
|
||||
## 使用了线程安全的并发工具,并不代表解决了所有线程安全问题
|
||||
|
||||
JDK 1.5后推出的ConcurrentHashMap,是一个高性能的线程安全的哈希表容器。“线程安全”这四个字特别容易让人误解,因为**ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的。**
|
||||
|
||||
我在相当多的业务代码中看到过这个误区,比如下面这个场景。有一个含900个元素的Map,现在再补充100个元素进去,这个补充操作由10个线程并发进行。开发人员误以为使用了ConcurrentHashMap就不会有线程安全问题,于是不加思索地写出了下面的代码:在每一个线程的代码逻辑中先通过size方法拿到当前元素数量,计算ConcurrentHashMap目前还需要补充多少元素,并在日志中输出了这个值,然后通过putAll方法把缺少的元素添加进去。
|
||||
|
||||
为方便观察问题,我们输出了这个Map一开始和最后的元素个数。
|
||||
|
||||
```
|
||||
//线程个数
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
访问接口后程序输出的日志内容如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/70/2eaf5cd1b910b2678aca15fee6144070.png" alt="">
|
||||
|
||||
从日志中可以看到:
|
||||
|
||||
- 初始大小900符合预期,还需要填充100个元素。
|
||||
- worker1线程查询到当前需要填充的元素为36,竟然还不是100的倍数。
|
||||
- worker13线程查询到需要填充的元素数是负的,显然已经过度填充了。
|
||||
- 最后HashMap的总项目数是1536,显然不符合填充满1000的预期。
|
||||
|
||||
针对这个场景,我们可以举一个形象的例子。ConcurrentHashMap就像是一个大篮子,现在这个篮子里有900个桔子,我们期望把这个篮子装满1000个桔子,也就是再装100个桔子。有10个工人来干这件事儿,大家先后到岗后会计算还需要补多少个桔子进去,最后把桔子装入篮子。
|
||||
|
||||
ConcurrentHashMap这个篮子本身,可以确保多个工人在装东西进去时,不会相互影响干扰,但无法确保工人A看到还需要装100个桔子但是还未装的时候,工人B就看不到篮子中的桔子数量。更值得注意的是,你往这个篮子装100个桔子的操作不是原子性的,在别人看来可能会有一个瞬间篮子里有964个桔子,还需要补36个桔子。
|
||||
|
||||
回到ConcurrentHashMap,我们需要注意**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";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重新调用接口,程序的日志输出结果符合预期:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/b8/1151b5b87f27073725060b76c56d95b8.png" alt="">
|
||||
|
||||
可以看到,只有一个线程查询到了需要补100个元素,其他9个线程查询到不需要补元素,最后Map大小为1000。
|
||||
|
||||
到了这里,你可能又要问了,使用ConcurrentHashMap全程加锁,还不如使用普通的HashMap呢。
|
||||
|
||||
其实不完全是这样。
|
||||
|
||||
ConcurrentHashMap提供了一些原子性的简单复合逻辑方法,用好这些方法就可以发挥其威力。这就引申出代码中常见的另一个问题:在使用一些类库提供的高级工具类时,开发人员可能还是按照旧的方式去使用这些新类,因为没有使用其特性,所以无法发挥其威力。
|
||||
|
||||
## 没有充分了解并发工具的特性,从而无法发挥其威力
|
||||
|
||||
我们来看一个使用Map来统计Key出现次数的场景吧,这个逻辑在业务代码中非常常见。
|
||||
|
||||
- 使用ConcurrentHashMap来统计,Key的范围是10。
|
||||
- 使用最多10个并发,循环操作1000万次,每次操作累加随机的Key。
|
||||
- 如果Key不存在的话,首次设置值为1。
|
||||
|
||||
代码如下:
|
||||
|
||||
```
|
||||
//循环次数
|
||||
private static int LOOP_COUNT = 10000000;
|
||||
//线程数量
|
||||
private static int THREAD_COUNT = 10;
|
||||
//元素数量
|
||||
private static int ITEM_COUNT = 10;
|
||||
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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们吸取之前的教训,直接通过锁的方式锁住Map,然后做判断、读取现在的累计值、加1、保存累加后值的逻辑。这段代码在功能上没有问题,但无法充分发挥ConcurrentHashMap的威力,改进后的代码如下:
|
||||
|
||||
```
|
||||
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来进行线程安全计数
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段改进后的代码中,我们巧妙利用了下面两点:
|
||||
|
||||
- 使用ConcurrentHashMap的原子性方法computeIfAbsent来做复合逻辑操作,判断Key是否存在Value,如果不存在则把Lambda表达式运行后的结果放入Map作为Value,也就是新创建一个LongAdder对象,最后返回Value。
|
||||
- 由于computeIfAbsent方法返回的Value是LongAdder,是一个线程安全的累加器,因此可以直接调用其increment方法进行累加。
|
||||
|
||||
**这样在确保线程安全的情况下达到极致性能,把之前7行代码替换为了1行。**
|
||||
|
||||
我们通过一个简单的测试比较一下修改前后两段代码的性能:
|
||||
|
||||
```
|
||||
@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";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段测试代码并无特殊之处,使用StopWatch来测试两段代码的性能,最后跟了一个断言判断Map中元素的个数以及所有Value的和,是否符合预期来校验代码的正确性。测试结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/3a/751d484ecd8c3114c15588e7fff3263a.png" alt="">
|
||||
|
||||
可以看到,**优化后的代码,相比使用锁来操作ConcurrentHashMap的方式,性能提升了10倍**。
|
||||
|
||||
你可能会问,computeIfAbsent为什么如此高效呢?
|
||||
|
||||
答案就在源码最核心的部分,也就是Java自带的Unsafe实现的CAS。它在虚拟机层面确保了写入数据的原子性,比加锁的效率高得多:
|
||||
|
||||
```
|
||||
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
|
||||
Node<K,V> c, Node<K,V> v) {
|
||||
return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
像ConcurrentHashMap这样的高级并发工具的确提供了一些高级API,只有充分了解其特性才能最大化其威力,而不能因为其足够高级、酷炫盲目使用。
|
||||
|
||||
## 没有认清并发工具的使用场景,因而导致性能问题
|
||||
|
||||
除了ConcurrentHashMap这样通用的并发工具类之外,我们的工具包中还有些针对特殊场景实现的生面孔。一般来说,针对通用场景的通用解决方案,在所有场景下性能都还可以,属于“万金油”;而针对特殊场景的特殊实现,会有比通用解决方案更高的性能,但一定要在它针对的场景下使用,否则可能会产生性能问题甚至是Bug。
|
||||
|
||||
之前在排查一个生产性能问题时,我们发现一段简单的非数据库操作的业务逻辑,消耗了超出预期的时间,在修改数据时操作本地缓存比回写数据库慢许多。查看代码发现,开发同学使用了CopyOnWriteArrayList来缓存大量的数据,而数据变化又比较频繁。
|
||||
|
||||
CopyOnWrite是一个时髦的技术,不管是Linux还是Redis都会用到。**在Java中,CopyOnWriteArrayList虽然是一个线程安全的ArrayList,但因为其实现方式是,每次修改数据时都会复制一份数据出来,所以有明显的适用场景,即读多写少或者说希望无锁读的场景。**
|
||||
|
||||
如果我们要使用CopyOnWriteArrayList,那一定是因为场景需要而不是因为足够酷炫。如果读写比例均衡或者有大量写操作的话,使用CopyOnWriteArrayList的性能会非常糟糕。
|
||||
|
||||
我们写一段测试代码,来比较下使用CopyOnWriteArrayList和普通加锁方式ArrayList的读写性能吧。在这段代码中我们针对并发读和并发写分别写了一个测试方法,测试两者一定次数的写或读操作的耗时。
|
||||
|
||||
```
|
||||
//测试并发写的性能
|
||||
@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慢一百倍**:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/b4/9789fe2019a1267b7883606b60e498b4.png" alt="">
|
||||
|
||||
而在大量读的场景下(100万次get操作),CopyOnWriteArray又比同步的ArrayList快五倍以上:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/36/30ba652fb3295c58b03f51de0a132436.png" alt="">
|
||||
|
||||
你可能会问,为何在大量写的场景下,CopyOnWriteArrayList会这么慢呢?
|
||||
|
||||
答案就在源码中。以add方法为例,每次add时,都会用Arrays.copyOf创建一个新数组,频繁add时内存的申请释放消耗会很大:
|
||||
|
||||
```
|
||||
/**
|
||||
* Appends the specified element to the end of this list.
|
||||
*
|
||||
* @param e element to be appended to this list
|
||||
* @return {@code true} (as specified by {@link Collection#add})
|
||||
*/
|
||||
public boolean add(E e) {
|
||||
synchronized (lock) {
|
||||
Object[] elements = getArray();
|
||||
int len = elements.length;
|
||||
Object[] newElements = Arrays.copyOf(elements, len + 1);
|
||||
newElements[len] = e;
|
||||
setArray(newElements);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我主要与你分享了,开发人员使用并发工具来解决线程安全问题时容易犯的四类错。
|
||||
|
||||
一是,只知道使用并发工具,但并不清楚当前线程的来龙去脉,解决多线程问题却不了解线程。比如,使用ThreadLocal来缓存数据,以为ThreadLocal在线程之间做了隔离不会有线程安全问题,没想到线程重用导致数据串了。请务必记得,在业务逻辑结束之前清理ThreadLocal中的数据。
|
||||
|
||||
二是,误以为使用了并发工具就可以解决一切线程安全问题,期望通过把线程不安全的类替换为线程安全的类来一键解决问题。比如,认为使用了ConcurrentHashMap就可以解决线程安全问题,没对复合逻辑加锁导致业务逻辑错误。如果你希望在一整段业务逻辑中,对容器的操作都保持整体一致性的话,需要加锁处理。
|
||||
|
||||
三是,没有充分了解并发工具的特性,还是按照老方式使用新工具导致无法发挥其性能。比如,使用了ConcurrentHashMap,但没有充分利用其提供的基于CAS安全的方法,还是使用锁的方式来实现逻辑。你可以阅读一下[ConcurrentHashMap的文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html),看一下相关原子性操作API是否可以满足业务需求,如果可以则优先考虑使用。
|
||||
|
||||
四是,没有了解清楚工具的适用场景,在不合适的场景下使用了错误的工具导致性能更差。比如,没有理解CopyOnWriteArrayList的适用场景,把它用在了读写均衡或者大量写操作的场景下,导致性能问题。对于这种场景,你可以考虑是用普通的List。
|
||||
|
||||
其实,这四类坑之所以容易踩到,原因可以归结为,我们在使用并发工具的时候,并没有充分理解其可能存在的问题、适用场景等。所以最后,**我还要和你分享两点建议**:
|
||||
|
||||
1. 一定要认真阅读官方文档(比如Oracle JDK文档)。充分阅读官方文档,理解工具的适用场景及其API的用法,并做一些小实验。了解之后再去使用,就可以避免大部分坑。
|
||||
1. 如果你的代码运行在多线程环境下,那么就会有并发问题,并发问题不那么容易重现,可能需要使用压力测试模拟并发场景,来发现其中的Bug或性能问题。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 今天我们多次用到了ThreadLocalRandom,你觉得是否可以把它的实例设置到静态变量中,在多线程情况下重用呢?
|
||||
1. ConcurrentHashMap还提供了putIfAbsent方法,你能否通过查阅[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html),说说computeIfAbsent和putIfAbsent方法的区别?
|
||||
|
||||
你在使用并发工具时,还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
372
极客时间专栏/Java业务开发常见错误100例/代码篇/02 | 代码加锁:不要让“锁”事成为烦心事.md
Normal file
372
极客时间专栏/Java业务开发常见错误100例/代码篇/02 | 代码加锁:不要让“锁”事成为烦心事.md
Normal file
@@ -0,0 +1,372 @@
|
||||
<audio id="audio" title="02 | 代码加锁:不要让“锁”事成为烦心事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/68/af92a2dc5157227187e97fcd4c971f68.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
在上一讲中,我与你介绍了使用并发容器等工具解决线程安全的误区。今天,我们来看看解决线程安全问题的另一种重要手段——锁,在使用上比较容易犯哪些错。
|
||||
|
||||
我先和你分享一个有趣的案例吧。有一天,一位同学在群里说“见鬼了,疑似遇到了一个JVM的Bug”,我们都很好奇是什么Bug。
|
||||
|
||||
于是,他贴出了这样一段代码:在一个类里有两个int类型的字段a和b,有一个add方法循环1万次对a和b进行++操作,有另一个compare方法,同样循环1万次判断a是否小于b,条件成立就打印a和b的值,并判断a>b是否成立。
|
||||
|
||||
```
|
||||
@Slf4j
|
||||
public class Interesting {
|
||||
|
||||
volatile int a = 1;
|
||||
volatile int b = 1;
|
||||
|
||||
public void add() {
|
||||
log.info("add start");
|
||||
for (int i = 0; i < 10000; i++) {
|
||||
a++;
|
||||
b++;
|
||||
}
|
||||
log.info("add done");
|
||||
}
|
||||
|
||||
public void compare() {
|
||||
log.info("compare start");
|
||||
for (int i = 0; i < 10000; i++) {
|
||||
//a始终等于b吗?
|
||||
if (a < b) {
|
||||
log.info("a:{},b:{},{}", a, b, a > b);
|
||||
//最后的a>b应该始终是false吗?
|
||||
}
|
||||
}
|
||||
log.info("compare done");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
他起了两个线程来分别执行add和compare方法:
|
||||
|
||||
```
|
||||
Interesting interesting = new Interesting();
|
||||
new Thread(() -> interesting.add()).start();
|
||||
new Thread(() -> interesting.compare()).start();
|
||||
|
||||
```
|
||||
|
||||
按道理,a和b同样进行累加操作,应该始终相等,compare中的第一次判断应该始终不会成立,不会输出任何日志。但,执行代码后发现不但输出了日志,而且更诡异的是,compare方法在判断a<b成立的情况下还输出了a>b也成立:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/1d/9ec61aada64ac6d38681dd199c0ee61d.png" alt="">
|
||||
|
||||
群里一位同学看到这个问题笑了,说:“这哪是JVM的Bug,分明是线程安全问题嘛。很明显,你这是在操作两个字段a和b,有线程安全问题,应该为add方法加上锁,确保a和b的++是原子性的,就不会错乱了。”随后,他为add方法加上了锁:
|
||||
|
||||
```
|
||||
public synchronized void add()
|
||||
|
||||
```
|
||||
|
||||
但,加锁后问题并没有解决。
|
||||
|
||||
我们来仔细想一下,为什么锁可以解决线程安全问题呢。因为只有一个线程可以拿到锁,所以加锁后的代码中的资源操作是线程安全的。但是,**这个案例中的add方法始终只有一个线程在操作,显然只为add方法加锁是没用的**。
|
||||
|
||||
之所以出现这种错乱,是因为两个线程是交错执行add和compare方法中的业务逻辑,而且这些业务逻辑不是原子性的:a++和b++操作中可以穿插在compare方法的比较代码中;更需要注意的是,a<b这种比较操作在字节码层面是加载a、加载b和比较三步,代码虽然是一行但也不是原子性的。
|
||||
|
||||
所以,正确的做法应该是,为add和compare都加上方法锁,确保add方法执行时,compare无法读取a和b:
|
||||
|
||||
```
|
||||
public synchronized void add()
|
||||
public synchronized void compare()
|
||||
|
||||
```
|
||||
|
||||
所以,使用锁解决问题之前一定要理清楚,我们要保护的是什么逻辑,多线程执行的情况又是怎样的。
|
||||
|
||||
## 加锁前要清楚锁和被保护的对象是不是一个层面的
|
||||
|
||||
除了没有分析清线程、业务逻辑和锁三者之间的关系随意添加无效的方法锁外,还有一种比较常见的错误是,没有理清楚锁和要保护的对象是否是一个层面的。
|
||||
|
||||
我们知道**静态字段属于类,类级别的锁才能保护;而非静态字段属于类实例,实例级别的锁就可以保护。**
|
||||
|
||||
先看看这段代码有什么问题:在类Data中定义了一个静态的int字段counter和一个非静态的wrong方法,实现counter字段的累加操作。
|
||||
|
||||
```
|
||||
class Data {
|
||||
@Getter
|
||||
private static int counter = 0;
|
||||
|
||||
public static int reset() {
|
||||
counter = 0;
|
||||
return counter;
|
||||
}
|
||||
|
||||
public synchronized void 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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因为默认运行100万次,所以执行后应该输出100万,但页面输出的是639242:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/0b/777f520e9d0be89b66e814d3e7c1a30b.png" alt="">
|
||||
|
||||
我们来分析下为什么会出现这个问题吧。
|
||||
|
||||
在非静态的wrong方法上加锁,只能确保多个线程无法执行同一个实例的wrong方法,却不能保证不会执行不同实例的wrong方法。而静态的counter在多个实例中共享,所以必然会出现线程安全问题。
|
||||
|
||||
理清思路后,修正方法就很清晰了:同样在类中定义一个Object类型的静态字段,在操作counter之前对这个字段加锁。
|
||||
|
||||
```
|
||||
class Data {
|
||||
@Getter
|
||||
private static int counter = 0;
|
||||
private static Object locker = new Object();
|
||||
|
||||
public void right() {
|
||||
synchronized (locker) {
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可能要问了,把wrong方法定义为静态不就可以了,这个时候锁是类级别的。可以是可以,但我们不可能为了解决线程安全问题改变代码结构,把实例方法改为静态方法。
|
||||
|
||||
感兴趣的同学还可以从字节码以及JVM的层面继续探索一下,代码块级别的synchronized和方法上标记synchronized关键字,在实现上有什么区别。
|
||||
|
||||
## 加锁要考虑锁的粒度和场景问题
|
||||
|
||||
在方法上加synchronized关键字实现加锁确实简单,也因此我曾看到一些业务代码中几乎所有方法都加了synchronized,但这种滥用synchronized的做法:
|
||||
|
||||
- 一是,没必要。通常情况下60%的业务代码是三层架构,数据经过无状态的Controller、Service、Repository流转到数据库,没必要使用synchronized来保护什么数据。
|
||||
- 二是,可能会极大地降低性能。使用Spring框架时,默认情况下Controller、Service、Repository是单例的,加上synchronized会导致整个程序几乎就只能支持单线程,造成极大的性能问题。
|
||||
|
||||
**即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。**
|
||||
|
||||
比如,在业务代码中,有一个ArrayList因为会被多个线程操作而需要保护,又有一段比较耗时的操作(代码中的slow方法)不涉及线程安全问题,应该如何加锁呢?
|
||||
|
||||
错误的做法是,给整段业务逻辑加锁,把slow方法和操作ArrayList的代码同时纳入synchronized代码块;更合适的做法是,把加锁的粒度降到最低,只在操作ArrayList的时候给这个ArrayList加锁。
|
||||
|
||||
```
|
||||
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秒。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/43/1cb278c010719ee00d988dbb2a42c543.png" alt="">
|
||||
|
||||
**如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。**
|
||||
|
||||
一般业务代码中,很少需要进一步考虑这两种更细粒度的锁,所以我只和你分享几个大概的结论,你可以根据自己的需求来考虑是否有必要进一步优化:
|
||||
|
||||
- 对于读写比例差异明显的场景,考虑使用ReentrantReadWriteLock细化区分读写锁,来提高性能。
|
||||
- 如果你的JDK版本高于1.8、共享资源的冲突概率也没那么大的话,考虑使用StampedLock的乐观读的特性,进一步提高性能。
|
||||
- JDK里ReentrantLock和ReentrantReadWriteLock都提供了公平锁的版本,在没有明确需求的情况下不要轻易开启公平锁特性,在任务很轻的情况下开启公平锁可能会让性能下降上百倍。
|
||||
|
||||
## 多把锁要小心死锁问题
|
||||
|
||||
刚才我们聊到锁的粒度够用就好,这就意味着我们的程序逻辑中有时会存在一些细粒度的锁。但一个业务逻辑如果涉及多把锁,容易产生死锁问题。
|
||||
|
||||
之前我遇到过这样一个案例:下单操作需要锁定订单中多个商品的库存,拿到所有商品的锁之后进行下单扣减库存操作,全部操作完成之后释放所有的锁。代码上线后发现,下单失败概率很高,失败后需要用户重新下单,极大影响了用户体验,还影响到了销量。
|
||||
|
||||
经排查发现是死锁引起的问题,背后原因是扣减库存的顺序不同,导致并发的情况下多个线程可能相互持有部分商品的锁,又等待其他线程释放另一部分商品的锁,于是出现了死锁问题。
|
||||
|
||||
接下来,我们剖析一下核心的业务代码。
|
||||
|
||||
首先,定义一个商品类型,包含商品名、库存剩余和商品的库存锁三个属性,每一种商品默认库存1000个;然后,初始化10个这样的商品对象来模拟商品清单:
|
||||
|
||||
```
|
||||
@Data
|
||||
@RequiredArgsConstructor
|
||||
static class Item {
|
||||
final String name; //商品名
|
||||
int remaining = 1000; //库存剩余
|
||||
@ToString.Exclude //ToString不包含这个字段
|
||||
ReentrantLock lock = new ReentrantLock();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
随后,写一个方法模拟在购物车进行商品选购,每次从商品清单(items字段)中随机选购三个商品(为了逻辑简单,我们不考虑每次选购多个同类商品的逻辑,购物车中不体现商品数量):
|
||||
|
||||
```
|
||||
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());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下单代码如下:先声明一个List来保存所有获得的锁,然后遍历购物车中的商品依次尝试获得商品的锁,最长等待10秒,获得全部锁之后再扣减库存;如果有无法获得锁的情况则解锁之前获得的所有锁,返回false下单失败。
|
||||
|
||||
```
|
||||
private boolean createOrder(List<Item> order) {
|
||||
//存放所有获得的锁
|
||||
List<ReentrantLock> locks = new ArrayList<>();
|
||||
|
||||
for (Item item : order) {
|
||||
try {
|
||||
//获得锁10秒超时
|
||||
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次创建购物车和下单操作,最后通过日志输出成功的下单次数、总剩余的商品个数、100次下单耗时,以及下单完成后的商品库存明细:
|
||||
|
||||
```
|
||||
@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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行程序,输出如下日志:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/05/141a5ed915e08e50c0f6b066bea36e05.png" alt="">
|
||||
|
||||
可以看到,100次下单操作成功了65次,10种商品总计10000件,库存总计为9805,消耗了195件符合预期(65次下单成功,每次下单包含三件商品),总耗时50秒。
|
||||
|
||||
为什么会这样呢?
|
||||
|
||||
使用JDK自带的VisualVM工具来跟踪一下,重新执行方法后不久就可以看到,线程Tab中提示了死锁问题,根据提示点击右侧线程Dump按钮进行线程抓取操作:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/ce/ff24ac10bd0635ef4bf5987038b622ce.png" alt="">
|
||||
|
||||
查看抓取出的线程栈,在页面中部可以看到如下日志:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/42/c32cb32eb5433aae3b392738a80bca42.png" alt="">
|
||||
|
||||
显然,**是出现了死锁,线程4在等待的一个锁被线程3持有,线程3在等待的另一把锁被线程4持有**。
|
||||
|
||||
那为什么会有死锁问题呢?
|
||||
|
||||
我们仔细回忆一下购物车添加商品的逻辑,随机添加了三种商品,假设一个购物车中的商品是item1和item2,另一个购物车中的商品是item2和item1,一个线程先获取到了item1的锁,同时另一个线程获取到了item2的锁,然后两个线程接下来要分别获取item2和item1的锁,这个时候锁已经被对方获取了,只能相互等待一直到10秒超时。
|
||||
|
||||
其实,避免死锁的方案很简单,**为购物车中的商品排一下序,让所有的线程一定是先获取item1的锁然后获取item2的锁,就不会有问题了**。所以,我只需要修改一行代码,对createCart获得的购物车按照商品名进行排序即可:
|
||||
|
||||
```
|
||||
@GetMapping("right")
|
||||
public long right() {
|
||||
...
|
||||
.
|
||||
long success = IntStream.rangeClosed(1, 100).parallel()
|
||||
.mapToObj(i -> {
|
||||
List<Item> cart = createCart().stream()
|
||||
.sorted(Comparator.comparing(Item::getName))
|
||||
.collect(Collectors.toList());
|
||||
return createOrder(cart);
|
||||
})
|
||||
.filter(result -> result)
|
||||
.count();
|
||||
...
|
||||
return success;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试一下right方法,不管执行多少次都是100次成功下单,而且性能相当高,达到了3000以上的TPS:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/e4/a41d077eeecc8b922503409d13a465e4.png" alt="">
|
||||
|
||||
这个案例中,虽然产生了死锁问题,但因为尝试获取锁的操作并不是无限阻塞的,所以没有造成永久死锁,之后的改进就是避免循环等待,通过对购物车的商品进行排序来实现有顺序的加锁,避免循环等待。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
我们一起总结回顾下,使用锁来解决多线程情况下线程安全问题的坑吧。
|
||||
|
||||
第一,使用synchronized加锁虽然简单,但我们首先要弄清楚共享资源是类还是实例级别的、会被哪些线程操作,synchronized关联的锁对象或方法又是什么范围的。
|
||||
|
||||
第二,加锁尽可能要考虑粒度和场景,锁保护的代码意味着无法进行多线程操作。对于Web类型的天然多线程项目,对方法进行大范围加锁会显著降级并发能力,要考虑尽可能地只为必要的代码块加锁,降低锁的粒度;而对于要求超高性能的业务,还要细化考虑锁的读写场景,以及悲观优先还是乐观优先,尽可能针对明确场景精细化加锁方案,可以在适当的场景下考虑使用ReentrantReadWriteLock、StampedLock等高级的锁工具类。
|
||||
|
||||
第三,业务逻辑中有多把锁时要考虑死锁问题,通常的规避方案是,避免无限等待和循环等待。
|
||||
|
||||
此外,**如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行**。
|
||||
|
||||
为演示方便,今天的案例是在Controller的逻辑中开新的线程或使用线程池进行并发模拟,我们当然可以意识到哪些对象是并发操作的。但对于Web应用程序的天然多线程场景,你可能更容易忽略这点,并且也可能因为误用锁降低应用整体的吞吐量。**如果你的业务代码涉及复杂的锁操作,强烈建议Mock相关外部接口或数据库操作后对应用代码进行压测,通过压测排除锁误用带来的性能问题和死锁问题**。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 本文开头的例子里,变量a、b都使用了volatile关键字,你知道原因吗?我之前遇到过这样一个坑:我们开启了一个线程无限循环来跑一些任务,有一个bool类型的变量来控制循环的退出,默认为true代表执行,一段时间后主线程将这个变量设置为了false。如果这个变量不是volatile修饰的,子线程可以退出吗?你能否解释其中的原因呢?
|
||||
1. 文末我们又提了两个坑,一是加锁和释放没有配对的问题,二是锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两种问题吗?
|
||||
|
||||
在使用锁的过程中,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
409
极客时间专栏/Java业务开发常见错误100例/代码篇/03 | 线程池:业务代码最常用也最容易犯错的组件.md
Normal file
409
极客时间专栏/Java业务开发常见错误100例/代码篇/03 | 线程池:业务代码最常用也最容易犯错的组件.md
Normal file
@@ -0,0 +1,409 @@
|
||||
<audio id="audio" title="03 | 线程池:业务代码最常用也最容易犯错的组件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/51/1d1be5eea91a86f842844c6fc6471551.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来讲讲使用线程池需要注意的一些问题。
|
||||
|
||||
在程序中,我们会用各种池化技术来缓存创建昂贵的对象,比如线程池、连接池、内存池。一般是预先创建一些对象放入池中,使用的时候直接取出使用,用完归还以便复用,还会通过一定的策略调整池中缓存对象的数量,实现池的动态伸缩。
|
||||
|
||||
由于线程的创建比较昂贵,随意、没有控制地创建大量线程会造成性能问题,因此短平快的任务一般考虑使用线程池来处理,而不是直接创建线程。
|
||||
|
||||
今天,我们就针对线程池这个话题展开讨论,通过三个生产事故,来看看使用线程池应该注意些什么。
|
||||
|
||||
## 线程池的声明需要手动进行
|
||||
|
||||
Java中的Executors类定义了一些快捷的工具方法,来帮助我们快速创建线程池。《阿里巴巴Java开发手册》中提到,禁止使用这些方法来创建线程池,而应该手动new ThreadPoolExecutor来创建线程池。这一条规则的背后,是大量血淋淋的生产事故,最典型的就是newFixedThreadPool和newCachedThreadPool,可能因为资源耗尽导致OOM问题。
|
||||
|
||||
首先,我们来看一下newFixedThreadPool为什么可能会出现OOM的问题。
|
||||
|
||||
我们写一段测试代码,来初始化一个单线程的FixedThreadPool,循环1亿次向线程池提交任务,每个任务都会创建一个比较大的字符串然后休眠一小时:
|
||||
|
||||
```
|
||||
@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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行程序后不久,日志中就出现了如下OOM:
|
||||
|
||||
```
|
||||
Exception in thread "http-nio-45678-ClientPoller" java.lang.OutOfMemoryError: GC overhead limit exceeded
|
||||
|
||||
```
|
||||
|
||||
翻看newFixedThreadPool方法的源码不难发现,线程池的工作队列直接new了一个LinkedBlockingQueue,**而默认构造方法的LinkedBlockingQueue是一个Integer.MAX_VALUE长度的队列,可以认为是无界的**:
|
||||
|
||||
```
|
||||
public static ExecutorService newFixedThreadPool(int nThreads) {
|
||||
return new ThreadPoolExecutor(nThreads, nThreads,
|
||||
0L, TimeUnit.MILLISECONDS,
|
||||
new LinkedBlockingQueue<Runnable>());
|
||||
}
|
||||
|
||||
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
|
||||
implements BlockingQueue<E>, java.io.Serializable {
|
||||
...
|
||||
|
||||
|
||||
/**
|
||||
* Creates a {@code LinkedBlockingQueue} with a capacity of
|
||||
* {@link Integer#MAX_VALUE}.
|
||||
*/
|
||||
public LinkedBlockingQueue() {
|
||||
this(Integer.MAX_VALUE);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
虽然使用newFixedThreadPool可以把工作线程控制在固定的数量上,但任务队列是无界的。如果任务较多并且执行较慢的话,队列可能会快速积压,撑爆内存导致OOM。
|
||||
|
||||
我们再把刚才的例子稍微改一下,改为使用newCachedThreadPool方法来获得线程池。程序运行不久后,同样看到了如下OOM异常:
|
||||
|
||||
```
|
||||
[11:30:30.487] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.OutOfMemoryError: unable to create new native thread] with root cause
|
||||
java.lang.OutOfMemoryError: unable to create new native thread
|
||||
|
||||
```
|
||||
|
||||
从日志中可以看到,这次OOM的原因是无法创建线程,翻看newCachedThreadPool的源码可以看到,**这种线程池的最大线程数是Integer.MAX_VALUE,可以认为是没有上限的,而其工作队列SynchronousQueue是一个没有存储空间的阻塞队列**。这意味着,只要有请求到来,就必须找到一条工作线程来处理,如果当前没有空闲的线程就再创建一条新的。
|
||||
|
||||
由于我们的任务需要1小时才能执行完成,大量的任务进来后会创建大量的线程。我们知道线程是需要分配一定的内存空间作为线程栈的,比如1MB,因此无限制创建线程必然会导致OOM:
|
||||
|
||||
```
|
||||
public static ExecutorService newCachedThreadPool() {
|
||||
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
|
||||
60L, TimeUnit.SECONDS,
|
||||
new SynchronousQueue<Runnable>());
|
||||
|
||||
```
|
||||
|
||||
其实,大部分Java开发同学知道这两种线程池的特性,只是抱有侥幸心理,觉得只是使用线程池做一些轻量级的任务,不可能造成队列积压或开启大量线程。
|
||||
|
||||
但,现实往往是残酷的。我之前就遇到过这么一个事故:用户注册后,我们调用一个外部服务去发送短信,发送短信接口正常时可以在100毫秒内响应,TPS 100的注册量,CachedThreadPool能稳定在占用10个左右线程的情况下满足需求。在某个时间点,外部短信服务不可用了,我们调用这个服务的超时又特别长,比如1分钟,1分钟可能就进来了6000用户,产生6000个发送短信的任务,需要6000个线程,没多久就因为无法创建线程导致了OOM,整个应用程序崩溃。
|
||||
|
||||
因此,**我同样不建议使用Executors提供的两种快捷的线程池,原因如下**:
|
||||
|
||||
- 我们需要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。
|
||||
- 任何时候,都应该为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量CPU、线程执行出现异常等问题时,我们往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题。
|
||||
|
||||
除了建议手动声明线程池以外,我还建议**用一些监控手段来观察线程池的状态**。线程池这个组件往往会表现得任劳任怨、默默无闻,除非是出现了拒绝策略,否则压力再大都不会抛出一个异常。如果我们能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。
|
||||
|
||||
## 线程池线程管理策略详解
|
||||
|
||||
在之前的Demo中,我们用一个printStats方法实现了最简陋的监控,每秒输出一次线程池的基本内部信息,包括线程数、活跃线程数、完成了多少任务,以及队列中还有多少积压任务等信息:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们就利用这个方法来观察一下线程池的基本特性吧。
|
||||
|
||||
首先,自定义一个线程池。这个线程池具有2个核心线程、5个最大线程、使用容量为10的ArrayBlockingQueue阻塞队列作为工作队列,使用默认的AbortPolicy拒绝策略,也就是任务添加到线程池失败会抛出RejectedExecutionException。此外,我们借助了Jodd类库的ThreadFactoryBuilder方法来构造一个线程工厂,实现线程池线程的自定义命名。
|
||||
|
||||
然后,我们写一段测试代码来观察线程池管理线程的策略。测试代码的逻辑为,每次间隔1秒向线程池提交任务,循环20次,每个任务需要10秒才能执行完成,代码如下:
|
||||
|
||||
```
|
||||
@GetMapping("right")
|
||||
public int right() throws InterruptedException {
|
||||
//使用一个计数器跟踪完成的任务数
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
//创建一个具有2个核心线程、5个最大线程,使用容量为10的ArrayBlockingQueue阻塞队列作为工作队列的线程池,使用默认的AbortPolicy拒绝策略
|
||||
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
|
||||
2, 5,
|
||||
5, TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(10),
|
||||
new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get(),
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
|
||||
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);
|
||||
//每个任务耗时10秒
|
||||
try {
|
||||
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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
60秒后页面输出了17,有3次提交失败了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/2c/4b820e0b24ce0deefbf2dd7af295c32c.png" alt="">
|
||||
|
||||
并且日志中也出现了3次类似的错误信息:
|
||||
|
||||
```
|
||||
[14:24:52.879] [http-nio-45678-exec-1] [ERROR] [.t.c.t.demo1.ThreadPoolOOMController:103 ] - error submitting task 18
|
||||
java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@163a2dec rejected from java.util.concurrent.ThreadPoolExecutor@18061ad2[Running, pool size = 5, active threads = 5, queued tasks = 10, completed tasks = 2]
|
||||
|
||||
```
|
||||
|
||||
我们把printStats方法打印出的日志绘制成图表,得出如下曲线:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/1e/d819035f60bf1c0022a98051d50e031e.png" alt="">
|
||||
|
||||
**至此,我们可以总结出线程池默认的工作行为**:
|
||||
|
||||
- 不会初始化corePoolSize个线程,有任务来了才创建工作线程;
|
||||
- 当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;
|
||||
- 当工作队列满了后扩容线程池,一直到线程个数达到maximumPoolSize为止;
|
||||
- 如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理;
|
||||
- 当线程数大于核心线程数时,线程等待keepAliveTime后还是没有任务需要处理的话,收缩线程到核心线程数。
|
||||
|
||||
了解这个策略,有助于我们根据实际的容量规划需求,为线程池设置合适的初始化参数。当然,我们也可以通过一些手段来改变这些默认工作行为,比如:
|
||||
|
||||
- 声明线程池后立即调用prestartAllCoreThreads方法,来启动所有核心线程;
|
||||
- 传入true给allowCoreThreadTimeOut方法,来让线程池在空闲的时候同样回收核心线程。
|
||||
|
||||
不知道你有没有想过:Java线程池是先用工作队列来存放来不及处理的任务,满了之后再扩容线程池。当我们的工作队列设置得很大时,最大线程数这个参数显得没有意义,因为队列很难满,或者到满的时候再去扩容线程池已经于事无补了。
|
||||
|
||||
那么,**我们有没有办法让线程池****更激进一点,优先开启更多的线程,而把队列当成一个后备方案呢?**比如我们这个例子,任务执行得很慢,需要10秒,如果线程池可以优先扩容到5个最大线程,那么这些任务最终都可以完成,而不会因为线程池扩容过晚导致慢任务来不及处理。
|
||||
|
||||
限于篇幅,这里我只给你一个大致思路:
|
||||
|
||||
1. 由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们是否可以重写队列的offer方法,造成这个队列已满的假象呢?
|
||||
1. 由于我们Hack了队列,在达到了最大线程后势必会触发拒绝策略,那么能否实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列呢?
|
||||
|
||||
接下来,就请你动手试试看如何实现这样一个“弹性”线程池吧。Tomcat线程池也实现了类似的效果,可供你借鉴。
|
||||
|
||||
## 务必确认清楚线程池本身是不是复用的
|
||||
|
||||
不久之前我遇到了这样一个事故:某项目生产环境时不时有报警提示线程数过多,超过2000个,收到报警后查看监控发现,瞬时线程数比较多但过一会儿又会降下来,线程数抖动很厉害,而应用的访问量变化不大。
|
||||
|
||||
为了定位问题,我们在线程数比较高的时候进行线程栈抓取,抓取后发现内存中有1000多个自定义线程池。一般而言,线程池肯定是复用的,有5个以内的线程池都可以认为正常,而1000多个线程池肯定不正常。
|
||||
|
||||
在项目代码里,我们没有搜到声明线程池的地方,搜索execute关键字后定位到,原来是业务代码调用了一个类库来获得线程池,类似如下的业务代码:调用ThreadPoolHelper的getThreadPool方法来获得线程池,然后提交数个任务到线程池处理,看不出什么异常。
|
||||
|
||||
```
|
||||
@GetMapping("wrong")
|
||||
public String wrong() throws InterruptedException {
|
||||
ThreadPoolExecutor threadPool = ThreadPoolHelper.getThreadPool();
|
||||
IntStream.rangeClosed(1, 10).forEach(i -> {
|
||||
threadPool.execute(() -> {
|
||||
...
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(1);
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
});
|
||||
});
|
||||
return "OK";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,来到ThreadPoolHelper的实现让人大跌眼镜,**getThreadPool方法居然是每次都使用Executors.newCachedThreadPool来创建一个线程池**。
|
||||
|
||||
```
|
||||
class ThreadPoolHelper {
|
||||
public static ThreadPoolExecutor getThreadPool() {
|
||||
//线程池没有复用
|
||||
return (ThreadPoolExecutor) Executors.newCachedThreadPool();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过上一小节的学习,我们可以想到newCachedThreadPool会在需要时创建必要多的线程,业务代码的一次业务操作会向线程池提交多个慢任务,这样执行一次业务操作就会开启多个线程。如果业务操作并发量较大的话,的确有可能一下子开启几千个线程。
|
||||
|
||||
那,为什么我们能在监控中看到线程数量会下降,而不会撑爆内存呢?
|
||||
|
||||
回到newCachedThreadPool的定义就会发现,它的核心线程数是0,而keepAliveTime是60秒,也就是在60秒之后所有的线程都是可以回收的。好吧,就因为这个特性,我们的业务程序死得没太难看。
|
||||
|
||||
要修复这个Bug也很简单,使用一个静态字段来存放线程池的引用,返回线程池的代码直接返回这个静态字段即可。这里一定要记得我们的最佳实践,手动创建线程池。修复后的ThreadPoolHelper类如下:
|
||||
|
||||
```
|
||||
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 getRightThreadPool() {
|
||||
return threadPoolExecutor;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 需要仔细斟酌线程池的混用策略
|
||||
|
||||
线程池的意义在于复用,那这是不是意味着程序应该始终使用一个线程池呢?
|
||||
|
||||
当然不是。通过第一小节的学习我们知道,**要根据任务的“轻重缓急”来指定线程池的核心参数,包括线程数、回收策略和任务队列**:
|
||||
|
||||
- 对于执行比较慢、数量不大的IO任务,或许要考虑更多的线程数,而不需要太大的队列。
|
||||
- 而对于吞吐量较大的计算型任务,线程数量不宜过多,可以是CPU核数或核数*2(理由是,线程一定调度到某个CPU进行执行,如果任务本身是CPU绑定的任务,那么过多的线程只会增加线程切换的开销,并不能提升吞吐量),但可能需要较长的队列来做缓冲。
|
||||
|
||||
之前我也遇到过这么一个问题,业务代码使用了线程池异步处理一些内存中的数据,但通过监控发现处理得非常慢,整个处理过程都是内存中的计算不涉及IO操作,也需要数秒的处理时间,应用程序CPU占用也不是特别高,有点不可思议。
|
||||
|
||||
经排查发现,业务代码使用的线程池,还被一个后台的文件批处理任务用到了。
|
||||
|
||||
或许是够用就好的原则,这个线程池只有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());
|
||||
|
||||
```
|
||||
|
||||
这里,我们模拟一下文件批处理的代码,在程序启动后通过一个线程开启死循环逻辑,不断向线程池提交任务,任务的逻辑是向一个文件中写入大量的数据:
|
||||
|
||||
```
|
||||
@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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以想象到,这个线程池中的2个线程任务是相当重的。通过printStats方法打印出的日志,我们观察下线程池的负担:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/55/49c132595db60f109530e0dec55ccd55.png" alt="">
|
||||
|
||||
可以看到,**线程池的2个线程始终处于活跃状态,队列也基本处于打满状态。**因为开启了CallerRunsPolicy拒绝处理策略,所以当线程满载队列也满的情况下,任务会在提交任务的线程,或者说调用execute方法的线程执行,也就是说不能认为提交到线程池的任务就一定是异步处理的。如果使用了CallerRunsPolicy策略,那么有可能异步任务变为同步执行。从日志的第四行也可以看到这点。这也是这个拒绝策略比较特别的原因。
|
||||
|
||||
不知道写代码的同学为什么设置这个策略,或许是测试时发现线程池因为任务处理不过来出现了异常,而又不希望线程池丢弃任务,所以最终选择了这样的拒绝策略。不管怎样,这些日志足以说明线程池是饱和状态。
|
||||
|
||||
可以想象到,业务代码复用这样的线程池来做内存计算,命运一定是悲惨的。我们写一段代码测试下,向线程池提交一个简单的任务,这个任务只是休眠10毫秒没有其他逻辑:
|
||||
|
||||
```
|
||||
private Callable<Integer> calcTask() {
|
||||
return () -> {
|
||||
TimeUnit.MILLISECONDS.sleep(10);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
|
||||
@GetMapping("wrong")
|
||||
public int wrong() throws ExecutionException, InterruptedException {
|
||||
return threadPool.submit(calcTask()).get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们使用wrk工具对这个接口进行一个简单的压测,可以看到TPS为75,性能的确非常差。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/07/989f7ab383e59e21751adb77a9b53507.png" alt="">
|
||||
|
||||
细想一下,问题其实没有这么简单。因为原来执行IO任务的线程池使用的是CallerRunsPolicy策略,所以直接使用这个线程池进行异步计算的话,**当线程池饱和的时候,计算任务会在执行Web请求的Tomcat线程执行,这时就会进一步影响到其他同步处理的线程,甚至造成整个应用程序崩溃**。
|
||||
|
||||
解决方案很简单,使用独立的线程池来做这样的“计算任务”即可。计算任务打了双引号,是因为我们的模拟代码执行的是休眠操作,并不属于CPU绑定的操作,更类似IO绑定的操作,如果线程池线程数设置太小会限制吞吐能力:
|
||||
|
||||
```
|
||||
private static ThreadPoolExecutor asyncCalcThreadPool = new ThreadPoolExecutor(
|
||||
200, 200,
|
||||
1, TimeUnit.HOURS,
|
||||
new ArrayBlockingQueue<>(1000),
|
||||
new ThreadFactoryBuilder().setNameFormat("asynccalc-threadpool-%d").get());
|
||||
|
||||
|
||||
@GetMapping("right")
|
||||
public int right() throws ExecutionException, InterruptedException {
|
||||
return asyncCalcThreadPool.submit(calcTask()).get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用单独的线程池改造代码后再来测试一下性能,TPS提高到了1727:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/06/c21eed38ccd18758d38745dd09496a06.png" alt="">
|
||||
|
||||
可以看到,盲目复用线程池混用线程的问题在于,别人定义的线程池属性不一定适合你的任务,而且混用会相互干扰。这就好比,我们往往会用虚拟化技术来实现资源的隔离,而不是让所有应用程序都直接使用物理机。
|
||||
|
||||
就线程池混用问题,我想再和你补充一个坑:**Java 8的parallel stream功能,可以让我们很方便地并行处理集合中的元素,其背后是共享同一个ForkJoinPool,默认并行度是CPU核数-1**。对于CPU绑定的任务来说,使用这样的配置比较合适,但如果集合操作涉及同步IO操作的话(比如数据库操作、外部服务调用等),建议自定义一个ForkJoinPool(或普通线程池)。你可以参考[第一讲](https://time.geekbang.org/column/article/209494)的相关Demo。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
线程池管理着线程,线程又属于宝贵的资源,有许多应用程序的性能问题都来自线程池的配置和使用不当。在今天的学习中,我通过三个和线程池相关的生产事故,和你分享了使用线程池的几个最佳实践。
|
||||
|
||||
第一,Executors类提供的一些快捷声明线程池的方法虽然简单,但隐藏了线程池的参数细节。因此,使用线程池时,我们一定要根据场景和需求配置合理的线程数、任务队列、拒绝策略、线程回收策略,并对线程进行明确的命名方便排查问题。
|
||||
|
||||
第二,既然使用了线程池就需要确保线程池是在复用的,每次new一个线程池出来可能比不用线程池还糟糕。如果你没有直接声明线程池而是使用其他同学提供的类库来获得一个线程池,请务必查看源码,以确认线程池的实例化方式和配置是符合预期的。
|
||||
|
||||
第三,复用线程池不代表应用程序始终使用同一个线程池,我们应该根据任务的性质来选用不同的线程池。特别注意IO绑定的任务和CPU绑定的任务对于线程池属性的偏好,如果希望减少任务间的相互干扰,考虑按需使用隔离的线程池。
|
||||
|
||||
最后我想强调的是,**线程池作为应用程序内部的核心组件往往缺乏监控**(如果你使用类似RabbitMQ这样的MQ中间件,运维同学一般会帮我们做好中间件监控),往往到程序崩溃后才发现线程池的问题,很被动。在设计篇中我们会重新谈及这个问题及其解决方案。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 在第一节中我们提到,或许一个激进创建线程的弹性线程池更符合我们的需求,你能给出相关的实现吗?实现后再测试一下,是否所有的任务都可以正常处理完成呢?
|
||||
1. 在第二节中,我们改进了ThreadPoolHelper使其能够返回复用的线程池。如果我们不小心每次都创建了这样一个自定义的线程池(10核心线程,50最大线程,2秒回收的),反复执行测试接口线程,最终可以被回收吗?会出现OOM问题吗?
|
||||
|
||||
你还遇到过线程池相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
498
极客时间专栏/Java业务开发常见错误100例/代码篇/04 | 连接池:别让连接池帮了倒忙.md
Normal file
498
极客时间专栏/Java业务开发常见错误100例/代码篇/04 | 连接池:别让连接池帮了倒忙.md
Normal file
@@ -0,0 +1,498 @@
|
||||
<audio id="audio" title="04 | 连接池:别让连接池帮了倒忙" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/16/3de8fa9752b9c1daa08ce42ae15d3b16.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我们来聊聊使用连接池需要注意的问题。
|
||||
|
||||
在上一讲,我们学习了使用线程池需要注意的问题。今天,我再与你说说另一种很重要的池化技术,即连接池。
|
||||
|
||||
我先和你说说连接池的结构。连接池一般对外提供获得连接、归还连接的接口给客户端使用,并暴露最小空闲连接数、最大连接数等可配置参数,在内部则实现连接建立、连接心跳保持、连接管理、空闲连接回收、连接可用性检测等功能。连接池的结构示意图,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/7e/1685d9db2602e1de8483de171af6fd7e.png" alt="">
|
||||
|
||||
业务项目中经常会用到的连接池,主要是数据库连接池、Redis连接池和HTTP连接池。所以,今天我就以这三种连接池为例,和你聊聊使用和配置连接池容易出错的地方。
|
||||
|
||||
## 注意鉴别客户端SDK是否基于连接池
|
||||
|
||||
在使用三方客户端进行网络通信时,我们首先要确定客户端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时,一定要先查看官方文档了解其最佳实践,或是在类似Stackoverflow的网站搜索XXX threadsafe/singleton字样看看大家的回复,也可以一层一层往下看源码,直到定位到原始Socket来判断Socket和客户端API的对应关系。
|
||||
|
||||
明确了SDK连接池的实现方式后,我们就大概知道了使用SDK的最佳实践:
|
||||
|
||||
- 如果是分离方式,那么连接池本身一般是线程安全的,可以复用。每次使用需要从连接池获取连接,使用后归还,归还的工作由使用者负责。
|
||||
- 如果是内置连接池,SDK会负责连接的获取和归还,使用的时候直接复用客户端。
|
||||
- 如果SDK没有实现连接池(大多数中间件、数据库的客户端SDK都会支持连接池),那通常不是线程安全的,而且短连接的方式性能不会很高,使用的时候需要考虑是否自己封装一个连接池。
|
||||
|
||||
接下来,我就以Java中用于操作Redis最常见的库Jedis为例,从源码角度分析下Jedis类到底属于哪种类型的API,直接在多线程环境下复用一个连接会产生什么问题,以及如何用最佳实践来修复这个问题。
|
||||
|
||||
首先,向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");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,启动两个线程,共享操作同一个Jedis实例,每一个线程循环1000次,分别读取Key为a和b的Value,判断是否分别为1和2:
|
||||
|
||||
```
|
||||
Jedis jedis = new Jedis("127.0.0.1", 6379);
|
||||
new Thread(() -> {
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
String result = jedis.get("a");
|
||||
if (!result.equals("1")) {
|
||||
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 (!result.equals("2")) {
|
||||
log.warn("Expect b to be 2 but found {}", result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
TimeUnit.SECONDS.sleep(5);
|
||||
|
||||
```
|
||||
|
||||
执行程序多次,可以看到日志中出现了各种奇怪的异常信息,有的是读取Key为b的Value读取到了1,有的是流非正常结束,还有的是连接关闭异常:
|
||||
|
||||
```
|
||||
//错误1
|
||||
[14:56:19.069] [Thread-28] [WARN ] [.t.c.c.redis.JedisMisreuseController:45 ] - Expect b to be 2 but found 1
|
||||
//错误2
|
||||
redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
|
||||
at redis.clients.jedis.util.RedisInputStream.ensureFill(RedisInputStream.java:202)
|
||||
at redis.clients.jedis.util.RedisInputStream.readLine(RedisInputStream.java:50)
|
||||
at redis.clients.jedis.Protocol.processError(Protocol.java:114)
|
||||
at redis.clients.jedis.Protocol.process(Protocol.java:166)
|
||||
at redis.clients.jedis.Protocol.read(Protocol.java:220)
|
||||
at redis.clients.jedis.Connection.readProtocolWithCheckingBroken(Connection.java:318)
|
||||
at redis.clients.jedis.Connection.getBinaryBulkReply(Connection.java:255)
|
||||
at redis.clients.jedis.Connection.getBulkReply(Connection.java:245)
|
||||
at redis.clients.jedis.Jedis.get(Jedis.java:181)
|
||||
at org.geekbang.time.commonmistakes.connectionpool.redis.JedisMisreuseController.lambda$wrong$1(JedisMisreuseController.java:43)
|
||||
at java.lang.Thread.run(Thread.java:748)
|
||||
//错误3
|
||||
java.io.IOException: Socket Closed
|
||||
at java.net.AbstractPlainSocketImpl.getOutputStream(AbstractPlainSocketImpl.java:440)
|
||||
at java.net.Socket$3.run(Socket.java:954)
|
||||
at java.net.Socket$3.run(Socket.java:952)
|
||||
at java.security.AccessController.doPrivileged(Native Method)
|
||||
at java.net.Socket.getOutputStream(Socket.java:951)
|
||||
at redis.clients.jedis.Connection.connect(Connection.java:200)
|
||||
... 7 more
|
||||
|
||||
```
|
||||
|
||||
让我们分析一下Jedis类的源码,搞清楚其中缘由吧。
|
||||
|
||||
```
|
||||
public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands,
|
||||
AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands, ModuleCommands {
|
||||
}
|
||||
public class BinaryJedis implements BasicCommands, BinaryJedisCommands, MultiKeyBinaryCommands,
|
||||
AdvancedBinaryJedisCommands, BinaryScriptingCommands, Closeable {
|
||||
protected Client client = null;
|
||||
...
|
||||
}
|
||||
|
||||
public class Client extends BinaryClient implements Commands {
|
||||
}
|
||||
public class BinaryClient extends Connection {
|
||||
}
|
||||
public class Connection implements Closeable {
|
||||
private Socket socket;
|
||||
private RedisOutputStream outputStream;
|
||||
private RedisInputStream inputStream;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,Jedis继承了BinaryJedis,BinaryJedis中保存了单个Client的实例,Client最终继承了Connection,Connection中保存了单个Socket的实例,和Socket对应的两个读写流。因此,一个Jedis对应一个Socket连接。类图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/0f/e72120b1f6daf4a951e75c05b9191a0f.png" alt="">
|
||||
|
||||
BinaryClient封装了各种Redis命令,其最终会调用基类Connection的方法,使用Protocol类发送命令。看一下Protocol类的sendCommand方法的源码,可以发现其发送命令时是直接操作RedisOutputStream写入字节。
|
||||
|
||||
我们在多线程环境下复用Jedis对象,其实就是在复用RedisOutputStream。**如果多个线程在执行操作,那么既无法确保整条命令以一个原子操作写入Socket,也无法确保写入后、读取前没有其他数据写到远端**:
|
||||
|
||||
```
|
||||
private static void sendCommand(final RedisOutputStream os, final byte[] command,
|
||||
final byte[]... args) {
|
||||
try {
|
||||
os.write(ASTERISK_BYTE);
|
||||
os.writeIntCrLf(args.length + 1);
|
||||
os.write(DOLLAR_BYTE);
|
||||
os.writeIntCrLf(command.length);
|
||||
os.write(command);
|
||||
os.writeCrLf();
|
||||
|
||||
|
||||
for (final byte[] arg : args) {
|
||||
os.write(DOLLAR_BYTE);
|
||||
os.writeIntCrLf(arg.length);
|
||||
os.write(arg);
|
||||
os.writeCrLf();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new JedisConnectionException(e);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这里我们也可以理解了,为啥多线程情况下使用Jedis对象操作Redis会出现各种奇怪的问题。
|
||||
|
||||
比如,写操作互相干扰,多条命令相互穿插的话,必然不是合法的Redis命令,那么Redis会关闭客户端连接,导致连接断开;又比如,线程1和2先后写入了get a和get b操作的请求,Redis也返回了值1和2,但是线程2先读取了数据1就会出现数据错乱的问题。
|
||||
|
||||
修复方式是,使用Jedis提供的另一个线程安全的类JedisPool来获得Jedis的实例。JedisPool可以声明为static在多个线程之间共享,扮演连接池的角色。使用时,按需使用try-with-resources模式从JedisPool获得和归还Jedis实例。
|
||||
|
||||
```
|
||||
private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
|
||||
|
||||
new Thread(() -> {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
String result = jedis.get("a");
|
||||
if (!result.equals("1")) {
|
||||
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 (!result.equals("2")) {
|
||||
log.warn("Expect b to be 2 but found {}", result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
|
||||
```
|
||||
|
||||
这样修复后,代码不再有线程安全问题了。此外,我们最好通过shutdownhook,在程序退出之前关闭JedisPool:
|
||||
|
||||
```
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
jedisPool.close();
|
||||
}));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看一下Jedis类close方法的实现可以发现,如果Jedis是从连接池获取的话,那么close方法会调用连接池的return方法归还连接:
|
||||
|
||||
```
|
||||
public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands,
|
||||
AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands, ModuleCommands {
|
||||
protected JedisPoolAbstract dataSource = null;
|
||||
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (dataSource != null) {
|
||||
JedisPoolAbstract pool = this.dataSource;
|
||||
this.dataSource = null;
|
||||
if (client.isBroken()) {
|
||||
pool.returnBrokenResource(this);
|
||||
} else {
|
||||
pool.returnResource(this);
|
||||
}
|
||||
} else {
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果不是,则直接关闭连接,其最终调用Connection类的disconnect方法来关闭TCP连接:
|
||||
|
||||
```
|
||||
public void disconnect() {
|
||||
if (isConnected()) {
|
||||
try {
|
||||
outputStream.flush();
|
||||
socket.close();
|
||||
} catch (IOException ex) {
|
||||
broken = true;
|
||||
throw new JedisConnectionException(ex);
|
||||
} finally {
|
||||
IOUtils.closeQuietly(socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,Jedis可以独立使用,也可以配合连接池使用,这个连接池就是JedisPool。我们再看看JedisPool的实现。
|
||||
|
||||
```
|
||||
public class JedisPool extends JedisPoolAbstract {
|
||||
@Override
|
||||
public Jedis getResource() {
|
||||
Jedis jedis = super.getResource();
|
||||
jedis.setDataSource(this);
|
||||
return jedis;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void returnResource(final Jedis resource) {
|
||||
if (resource != null) {
|
||||
try {
|
||||
resource.resetState();
|
||||
returnResourceObject(resource);
|
||||
} catch (Exception e) {
|
||||
returnBrokenResource(resource);
|
||||
throw new JedisException("Resource is returned to the pool as broken", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class JedisPoolAbstract extends Pool<Jedis> {
|
||||
}
|
||||
|
||||
public abstract class Pool<T> implements Closeable {
|
||||
protected GenericObjectPool<T> internalPool;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
JedisPool的getResource方法在拿到Jedis对象后,将自己设置为了连接池。连接池JedisPool,继承了JedisPoolAbstract,而后者继承了抽象类Pool,Pool内部维护了Apache Common的通用池GenericObjectPool。JedisPool的连接池就是基于GenericObjectPool的。
|
||||
|
||||
看到这里我们了解了,Jedis的API实现是我们说的三种类型中的第一种,也就是连接池和连接分离的API,JedisPool是线程安全的连接池,Jedis是非线程安全的单一连接。知道了原理之后,我们再使用Jedis就胸有成竹了。
|
||||
|
||||
## 使用连接池务必确保复用
|
||||
|
||||
在介绍[线程池](https://time.geekbang.org/column/article/210337)的时候我们强调过,**池一定是用来复用的,否则其使用代价会比每次创建单一对象更大。对连接池来说更是如此,原因如下:**
|
||||
|
||||
- 创建连接池的时候很可能一次性创建了多个连接,大多数连接池考虑到性能,会在初始化的时候维护一定数量的最小连接(毕竟初始化连接池的过程一般是一次性的),可以直接使用。如果每次使用连接池都按需创建连接池,那么很可能你只用到一个连接,但是创建了N个连接。
|
||||
- 连接池一般会有一些管理模块,也就是连接池的结构示意图中的绿色部分。举个例子,大多数的连接池都有闲置超时的概念。连接池会检测连接的闲置时间,定期回收闲置的连接,把活跃连接数降到最低(闲置)连接的配置值,减轻服务端的压力。一般情况下,闲置连接由独立线程管理,启动了空闲检测的连接池相当于还会启动一个线程。此外,有些连接池还需要独立线程负责连接保活等功能。因此,启动一个连接池相当于启动了N个线程。
|
||||
|
||||
除了使用代价,连接池不释放,还可能会引起线程泄露。接下来,我就以Apache HttpClient为例,和你说说连接池不复用的问题。
|
||||
|
||||
首先,创建一个CloseableHttpClient,设置使用PoolingHttpClientConnectionManager连接池并启用空闲连接驱逐策略,最大空闲时间为60秒,然后使用这个连接来请求一个会返回OK字符串的服务端接口:
|
||||
|
||||
```
|
||||
@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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
访问这个接口几次后查看应用线程情况,可以看到有大量叫作Connection evictor的线程,且这些线程不会销毁:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/10/33a2389c20653e97b8157897d06c1510.png" alt="">
|
||||
|
||||
对这个接口进行几秒的压测(压测使用wrk,1个并发1个连接)可以看到,已经建立了三千多个TCP连接到45678端口(其中有1个是压测客户端到Tomcat的连接,大部分都是HttpClient到Tomcat的连接):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/f2/54a71ee9a7bbbd5e121b12fe6289aff2.png" alt="">
|
||||
|
||||
好在有了空闲连接回收的策略,60秒之后连接处于CLOSE_WAIT状态,最终彻底关闭。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/77/8ea5f53e6510d76cf447c23fb15daa77.png" alt="">
|
||||
|
||||
这2点证明,CloseableHttpClient属于第二种模式,即内部带有连接池的API,其背后是连接池,最佳实践一定是复用。
|
||||
|
||||
复用方式很简单,你可以把CloseableHttpClient声明为static,只创建一次,并且在JVM关闭之前通过addShutdownHook钩子关闭连接池,在使用的时候直接使用CloseableHttpClient即可,无需每次都创建。
|
||||
|
||||
首先,定义一个right接口来实现服务端接口调用:
|
||||
|
||||
```
|
||||
private static CloseableHttpClient httpClient = null;
|
||||
static {
|
||||
//当然,也可以把CloseableHttpClient定义为Bean,然后在@PreDestroy标记的方法内close这个HttpClient
|
||||
httpClient = HttpClients.custom().setMaxConnPerRoute(1).setMaxConnTotal(1).evictIdleConnections(60, TimeUnit.SECONDS).build();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
try {
|
||||
httpClient.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,重新定义一个wrong2接口,修复之前按需创建CloseableHttpClient的代码,每次用完之后确保连接池可以关闭:
|
||||
|
||||
```
|
||||
@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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用wrk对wrong2和right两个接口分别压测60秒,可以看到两种使用方式性能上的差异,每次创建连接池的QPS是337,而复用连接池的QPS是2022:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/2d/b79fb99cf8a5c3a17e60b0850544472d.png" alt="">
|
||||
|
||||
如此大的性能差异显然是因为TCP连接的复用。你可能注意到了,刚才定义连接池时,我将最大连接数设置为1。所以,复用连接池方式复用的始终应该是同一个连接,而新建连接池方式应该是每次都会创建新的TCP连接。
|
||||
|
||||
接下来,我们通过网络抓包工具Wireshark来证实这一点。
|
||||
|
||||
如果调用wrong2接口每次创建新的连接池来发起HTTP请求,从Wireshark可以看到,每次请求服务端45678的客户端端口都是新的。这里我发起了三次请求,程序通过HttpClient访问服务端45678的客户端端口号,分别是51677、51679和51681:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/35/7b8f651755cef0c05ecb08727d315e35.png" alt="">
|
||||
|
||||
也就是说,每次都是新的TCP连接,放开HTTP这个过滤条件也可以看到完整的TCP握手、挥手的过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/0d/4815c0edd21d5bf0cae8c0c3e578960d.png" alt="">
|
||||
|
||||
而复用连接池方式的接口right的表现就完全不同了。可以看到,第二次HTTP请求#41的客户端端口61468和第一次连接#23的端口是一样的,Wireshark也提示了整个TCP会话中,当前#41请求是第二次请求,前一次是#23,后面一次是#75:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/2c/2cbada9be98ce33321b29d38adb09f2c.png" alt="">
|
||||
|
||||
只有TCP连接闲置超过60秒后才会断开,连接池会新建连接。你可以尝试通过Wireshark观察这一过程。
|
||||
|
||||
接下来,我们就继续聊聊连接池的配置问题。
|
||||
|
||||
## 连接池的配置不是一成不变的
|
||||
|
||||
为方便根据容量规划设置连接处的属性,连接池提供了许多参数,包括最小(闲置)连接、最大连接、闲置连接生存时间、连接生存时间等。其中,最重要的参数是最大连接数,它决定了连接池能使用的连接数量上限,达到上限后,新来的请求需要等待其他请求释放连接。
|
||||
|
||||
但,**最大连接数不是设置得越大越好**。如果设置得太大,不仅仅是客户端需要耗费过多的资源维护连接,更重要的是由于服务端对应的是多个客户端,每一个客户端都保持大量的连接,会给服务端带来更大的压力。这个压力又不仅仅是内存压力,可以想一下如果服务端的网络模型是一个TCP连接一个线程,那么几千个连接意味着几千个线程,如此多的线程会造成大量的线程切换开销。
|
||||
|
||||
当然,**连接池最大连接数设置得太小,很可能会因为获取连接的等待时间太长,导致吞吐量低下,甚至超时无法获取连接**。
|
||||
|
||||
接下来,我们就模拟下压力增大导致数据库连接池打满的情况,来实践下如何确认连接池的使用情况,以及有针对性地进行参数优化。
|
||||
|
||||
首先,定义一个用户注册方法,通过@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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
随后,修改配置文件启用register-mbeans,使Hikari连接池能通过JMX MBean注册连接池相关统计信息,方便观察连接池:
|
||||
|
||||
```
|
||||
spring.datasource.hikari.register-mbeans=true
|
||||
|
||||
```
|
||||
|
||||
启动程序并通过JConsole连接进程后,可以看到默认情况下最大连接数为10:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/94/7b8e5aff5a3ef6ade1d8027c20c92f94.png" alt="">
|
||||
|
||||
使用wrk对应用进行压测,可以看到连接数一下子从0到了10,有20个线程在等待获取连接:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/ef/b22169b8d8bbfabbb8b93ece11a1f9ef.png" alt="">
|
||||
|
||||
不久就出现了无法获取数据库连接的异常,如下所示:
|
||||
|
||||
```
|
||||
[15:37:56.156] [http-nio-45678-exec-15] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DataAccessResourceFailureException: unable to obtain isolated JDBC connection; nested exception is org.hibernate.exception.JDBCConnectionException: unable to obtain isolated JDBC connection] with root cause
|
||||
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
|
||||
|
||||
```
|
||||
|
||||
从异常信息中可以看到,数据库连接池是HikariPool,解决方式很简单,修改一下配置文件,调整数据库连接池最大连接参数到50即可。
|
||||
|
||||
```
|
||||
spring.datasource.hikari.maximum-pool-size=50
|
||||
|
||||
```
|
||||
|
||||
然后,再观察一下这个参数是否适合当前压力,满足需求的同时也不占用过多资源。从监控来看这个调整是合理的,有一半的富余资源,再也没有线程需要等待连接了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/31/d24f23f05d49378a10a857cd8b9ef031.png" alt="">
|
||||
|
||||
在这个Demo里,我知道压测大概能对应使用25左右的并发连接,所以直接把连接池最大连接设置为了50。在真实情况下,只要数据库可以承受,你可以选择在遇到连接超限的时候先设置一个足够大的连接数,然后观察最终应用的并发,再按照实际并发数留出一半的余量来设置最终的最大连接。
|
||||
|
||||
其实,看到错误日志后再调整已经有点儿晚了。更合适的做法是,**对类似数据库连接池的重要资源进行持续检测,并设置一半的使用量作为报警阈值,出现预警后及时扩容**。
|
||||
|
||||
在这里我是为了演示,才通过JConsole查看参数配置后的效果,生产上需要把相关数据对接到指标监控体系中持续监测。
|
||||
|
||||
**这里要强调的是,修改配置参数务必验证是否生效,并且在监控系统中确认参数是否生效、是否合理。之所以要“强调”,是因为这里有坑**。
|
||||
|
||||
我之前就遇到过这样一个事故。应用准备针对大促活动进行扩容,把数据库配置文件中Druid连接池最大连接数maxActive从50提高到了100,修改后并没有通过监控验证,结果大促当天应用因为连接池连接数不够爆了。
|
||||
|
||||
经排查发现,当时修改的连接数并没有生效。原因是,应用虽然一开始使用的是Druid连接池,但后来框架升级了,把连接池替换为了Hikari实现,原来的那些配置其实都是无效的,修改后的参数配置当然也不会生效。
|
||||
|
||||
所以说,对连接池进行调参,一定要眼见为实。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我以三种业务代码最常用的Redis连接池、HTTP连接池、数据库连接池为例,和你探讨了有关连接池实现方式、使用姿势和参数配置的三大问题。
|
||||
|
||||
客户端SDK实现连接池的方式,包括池和连接分离、内部带有连接池和非连接池三种。要正确使用连接池,就必须首先鉴别连接池的实现方式。比如,Jedis的API实现的是池和连接分离的方式,而Apache HttpClient是内置连接池的API。
|
||||
|
||||
对于使用姿势其实就是两点,一是确保连接池是复用的,二是尽可能在程序退出之前显式关闭连接池释放资源。连接池设计的初衷就是为了保持一定量的连接,这样连接可以随取随用。从连接池获取连接虽然很快,但连接池的初始化会比较慢,需要做一些管理模块的初始化以及初始最小闲置连接。一旦连接池不是复用的,那么其性能会比随时创建单一连接更差。
|
||||
|
||||
最后,连接池参数配置中,最重要的是最大连接数,许多高并发应用往往因为最大连接数不够导致性能问题。但,最大连接数不是设置得越大越好,够用就好。需要注意的是,针对数据库连接池、HTTP连接池、Redis连接池等重要连接池,务必建立完善的监控和报警机制,根据容量规划及时调整参数配置。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 有了连接池之后,获取连接是从连接池获取,没有足够连接时连接池会创建连接。这时,获取连接操作往往有两个超时时间:一个是从连接池获取连接的最长等待时间,通常叫作请求连接超时connectRequestTimeout或连接等待超时connectWaitTimeout;一个是连接池新建TCP连接三次握手的连接超时,通常叫作连接超时connectTimeout。针对JedisPool、Apache HttpClient和Hikari数据库连接池,你知道如何设置这2个参数吗?
|
||||
1. 对于带有连接池的SDK的使用姿势,最主要的是鉴别其内部是否实现了连接池,如果实现了连接池要尽量复用Client。对于NoSQL中的MongoDB来说,使用MongoDB Java驱动时,MongoClient类应该是每次都创建还是复用呢?你能否在[官方文档](https://mongodb.github.io/mongo-java-driver/3.12/)中找到答案呢?
|
||||
|
||||
关于连接池,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
558
极客时间专栏/Java业务开发常见错误100例/代码篇/05 | HTTP调用:你考虑到超时、重试、并发了吗?.md
Normal file
558
极客时间专栏/Java业务开发常见错误100例/代码篇/05 | HTTP调用:你考虑到超时、重试、并发了吗?.md
Normal file
@@ -0,0 +1,558 @@
|
||||
<audio id="audio" title="05 | HTTP调用:你考虑到超时、重试、并发了吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/ad/1ceed88afba883f8aa305d4a6e9e18ad.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我们一起聊聊进行HTTP调用需要注意的超时、重试、并发等问题。
|
||||
|
||||
与执行本地方法不同,进行HTTP调用本质上是通过HTTP协议进行一次网络请求。网络请求必然有超时的可能性,因此我们必须考虑到这三点:
|
||||
|
||||
- 首先,框架设置的默认超时是否合理;
|
||||
- 其次,考虑到网络的不稳定,超时后的请求重试是一个不错的选择,但需要考虑服务端接口的幂等性设计是否允许我们重试;
|
||||
- 最后,需要考虑框架是否会像浏览器那样限制并发连接数,以免在服务并发很大的情况下,HTTP调用的并发数限制成为瓶颈。
|
||||
|
||||
Spring Cloud是Java微服务架构的代表性框架。如果使用Spring Cloud进行微服务开发,就会使用Feign进行声明式的服务调用。如果不使用Spring Cloud,而直接使用Spring Boot进行微服务开发的话,可能会直接使用Java中最常用的HTTP客户端Apache HttpClient进行服务调用。
|
||||
|
||||
接下来,我们就看看使用Feign和Apache HttpClient进行HTTP接口调用时,可能会遇到的超时、重试和并发方面的坑。
|
||||
|
||||
## 配置连接超时和读取超时参数的学问
|
||||
|
||||
对于HTTP调用,虽然应用层走的是HTTP协议,但网络层面始终是TCP/IP协议。TCP/IP是面向连接的协议,在传输数据之前需要建立连接。几乎所有的网络框架都会提供这么两个超时参数:
|
||||
|
||||
- 连接超时参数ConnectTimeout,让用户配置建连阶段的最长等待时间;
|
||||
- 读取超时参数ReadTimeout,用来控制从Socket上读取数据的最长等待时间。
|
||||
|
||||
这两个参数看似是网络层偏底层的配置参数,不足以引起开发同学的重视。但,正确理解和配置这两个参数,对业务应用特别重要,毕竟超时不是单方面的事情,需要客户端和服务端对超时有一致的估计,协同配合方能平衡吞吐量和错误率。
|
||||
|
||||
**连接超时参数和连接超时的误区有这么两个:**
|
||||
|
||||
- **连接超时配置得特别长,比如60秒。**一般来说,TCP三次握手建立连接需要的时间非常短,通常在毫秒级最多到秒级,不可能需要十几秒甚至几十秒。如果很久都无法建连,很可能是网络或防火墙配置的问题。这种情况下,如果几秒连接不上,那么可能永远也连接不上。因此,设置特别长的连接超时意义不大,将其配置得短一些(比如1~5秒)即可。如果是纯内网调用的话,这个参数可以设置得更短,在下游服务离线无法连接的时候,可以快速失败。
|
||||
- **排查连接超时问题,却没理清连的是哪里。**通常情况下,我们的服务会有多个节点,如果别的客户端通过客户端负载均衡技术来连接服务端,那么客户端和服务端会直接建立连接,此时出现连接超时大概率是服务端的问题;而如果服务端通过类似Nginx的反向代理来负载均衡,客户端连接的其实是Nginx,而不是服务端,此时出现连接超时应该排查Nginx。
|
||||
|
||||
**读取超时参数和读取超时则会有更多的误区,我将其归纳为如下三个。**
|
||||
|
||||
**第一个误区:**认为出现了读取超时,服务端的执行就会中断。
|
||||
|
||||
我们来简单测试下。定义一个client接口,内部通过HttpClient调用服务端接口server,客户端读取超时2秒,服务端接口执行耗时5秒。
|
||||
|
||||
```
|
||||
@RestController
|
||||
@RequestMapping("clientreadtimeout")
|
||||
@Slf4j
|
||||
public class ClientReadTimeoutController {
|
||||
private String getResponse(String url, int connectTimeout, int readTimeout) throws IOException {
|
||||
return Request.Get("http://localhost:45678/clientreadtimeout" + url)
|
||||
.connectTimeout(connectTimeout)
|
||||
.socketTimeout(readTimeout)
|
||||
.execute()
|
||||
.returnContent()
|
||||
.asString();
|
||||
}
|
||||
|
||||
@GetMapping("client")
|
||||
public String client() throws IOException {
|
||||
log.info("client1 called");
|
||||
//服务端5s超时,客户端读取超时2秒
|
||||
return getResponse("/server?timeout=5000", 1000, 2000);
|
||||
}
|
||||
|
||||
@GetMapping("server")
|
||||
public void server(@RequestParam("timeout") int timeout) throws InterruptedException {
|
||||
log.info("server called");
|
||||
TimeUnit.MILLISECONDS.sleep(timeout);
|
||||
log.info("Done");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调用client接口后,从日志中可以看到,客户端2秒后出现了SocketTimeoutException,原因是读取超时,服务端却丝毫没受影响在3秒后执行完成。
|
||||
|
||||
```
|
||||
[11:35:11.943] [http-nio-45678-exec-1] [INFO ] [.t.c.c.d.ClientReadTimeoutController:29 ] - client1 called
|
||||
[11:35:12.032] [http-nio-45678-exec-2] [INFO ] [.t.c.c.d.ClientReadTimeoutController:36 ] - server called
|
||||
[11:35:14.042] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
|
||||
java.net.SocketTimeoutException: Read timed out
|
||||
at java.net.SocketInputStream.socketRead0(Native Method)
|
||||
...
|
||||
[11:35:17.036] [http-nio-45678-exec-2] [INFO ] [.t.c.c.d.ClientReadTimeoutController:38 ] - Done
|
||||
|
||||
```
|
||||
|
||||
我们知道,类似Tomcat的Web服务器都是把服务端请求提交到线程池处理的,只要服务端收到了请求,网络层面的超时和断开便不会影响服务端的执行。因此,出现读取超时不能随意假设服务端的处理情况,需要根据业务状态考虑如何进行后续处理。
|
||||
|
||||
**第二个误区:**认为读取超时只是Socket网络层面的概念,是数据传输的最长耗时,故将其配置得非常短,比如100毫秒。
|
||||
|
||||
其实,发生了读取超时,网络层面无法区分是服务端没有把数据返回给客户端,还是数据在网络上耗时较久或丢包。
|
||||
|
||||
但,因为TCP是先建立连接后传输数据,对于网络情况不是特别糟糕的服务调用,通常可以认为出现连接超时是网络问题或服务不在线,而出现读取超时是服务处理超时。确切地说,读取超时指的是,向Socket写入数据后,我们等到Socket返回数据的超时时间,其中包含的时间或者说绝大部分的时间,是服务端处理业务逻辑的时间。
|
||||
|
||||
**第三个误区:**认为超时时间越长任务接口成功率就越高,将读取超时参数配置得太长。
|
||||
|
||||
进行HTTP请求一般是需要获得结果的,属于同步调用。如果超时时间很长,在等待服务端返回数据的同时,客户端线程(通常是Tomcat线程)也在等待,当下游服务出现大量超时的时候,程序可能也会受到拖累创建大量线程,最终崩溃。
|
||||
|
||||
对定时任务或异步任务来说,读取超时配置得长些问题不大。但面向用户响应的请求或是微服务短平快的同步接口调用,并发量一般较大,我们应该设置一个较短的读取超时时间,以防止被下游服务拖慢,通常不会设置超过30秒的读取超时。
|
||||
|
||||
你可能会说,如果把读取超时设置为2秒,服务端接口需要3秒,岂不是永远都拿不到执行结果了?的确是这样,因此设置读取超时一定要根据实际情况,过长可能会让下游抖动影响到自己,过短又可能影响成功率。甚至,有些时候我们还要根据下游服务的SLA,为不同的服务端接口设置不同的客户端读取超时。
|
||||
|
||||
## Feign和Ribbon配合使用,你知道怎么配置超时吗?
|
||||
|
||||
刚才我强调了根据自己的需求配置连接超时和读取超时的重要性,你是否尝试过为Spring Cloud的Feign配置超时参数呢,有没有被网上的各种资料绕晕呢?
|
||||
|
||||
在我看来,为Feign配置超时参数的复杂之处在于,Feign自己有两个超时参数,它使用的负载均衡组件Ribbon本身还有相关配置。那么,这些配置的优先级是怎样的,又哪些什么坑呢?接下来,我们做一些实验吧。
|
||||
|
||||
为测试服务端的超时,假设有这么一个服务端接口,什么都不干只休眠10分钟:
|
||||
|
||||
```
|
||||
@PostMapping("/server")
|
||||
public void server() throws InterruptedException {
|
||||
TimeUnit.MINUTES.sleep(10);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,定义一个Feign来调用这个接口:
|
||||
|
||||
```
|
||||
@FeignClient(name = "clientsdk")
|
||||
public interface Client {
|
||||
@PostMapping("/feignandribbon/server")
|
||||
void server();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,通过Feign Client进行接口调用:
|
||||
|
||||
```
|
||||
@GetMapping("client")
|
||||
public void timeout() {
|
||||
long begin=System.currentTimeMillis();
|
||||
try{
|
||||
client.server();
|
||||
}catch (Exception ex){
|
||||
log.warn("执行耗时:{}ms 错误:{}", System.currentTimeMillis() - begin, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在配置文件仅指定服务端地址的情况下:
|
||||
|
||||
```
|
||||
clientsdk.ribbon.listOfServers=localhost:45678
|
||||
|
||||
```
|
||||
|
||||
得到如下输出:
|
||||
|
||||
```
|
||||
[15:40:16.094] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时:1007ms 错误:Read timed out executing POST http://clientsdk/feignandribbon/server
|
||||
|
||||
```
|
||||
|
||||
从这个输出中,我们可以得到**结论一,默认情况下Feign的读取超时是1秒,如此短的读取超时算是坑点一**。
|
||||
|
||||
我们来分析一下源码。打开RibbonClientConfiguration类后,会看到DefaultClientConfigImpl被创建出来之后,ReadTimeout和ConnectTimeout被设置为1s:
|
||||
|
||||
```
|
||||
/**
|
||||
* Ribbon client default connect timeout.
|
||||
*/
|
||||
public static final int DEFAULT_CONNECT_TIMEOUT = 1000;
|
||||
|
||||
/**
|
||||
* Ribbon client default read timeout.
|
||||
*/
|
||||
public static final int DEFAULT_READ_TIMEOUT = 1000;
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public IClientConfig ribbonClientConfig() {
|
||||
DefaultClientConfigImpl config = new DefaultClientConfigImpl();
|
||||
config.loadProperties(this.name);
|
||||
config.set(CommonClientConfigKey.ConnectTimeout, DEFAULT_CONNECT_TIMEOUT);
|
||||
config.set(CommonClientConfigKey.ReadTimeout, DEFAULT_READ_TIMEOUT);
|
||||
config.set(CommonClientConfigKey.GZipPayload, DEFAULT_GZIP_PAYLOAD);
|
||||
return config;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果要修改Feign客户端默认的两个全局超时时间,你可以设置feign.client.config.default.readTimeout和feign.client.config.default.connectTimeout参数:
|
||||
|
||||
```
|
||||
feign.client.config.default.readTimeout=3000
|
||||
feign.client.config.default.connectTimeout=3000
|
||||
|
||||
```
|
||||
|
||||
修改配置后重试,得到如下日志:
|
||||
|
||||
```
|
||||
[15:43:39.955] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时:3006ms 错误:Read timed out executing POST http://clientsdk/feignandribbon/server
|
||||
|
||||
```
|
||||
|
||||
可见,3秒读取超时生效了。注意:这里有一个大坑,如果你希望只修改读取超时,可能会只配置这么一行:
|
||||
|
||||
```
|
||||
feign.client.config.default.readTimeout=3000
|
||||
|
||||
```
|
||||
|
||||
测试一下你就会发现,这样的配置是无法生效的!
|
||||
|
||||
**结论二,也是坑点二,如果要配置Feign的读取超时,就必须同时配置连接超时,才能生效**。
|
||||
|
||||
打开FeignClientFactoryBean可以看到,只有同时设置ConnectTimeout和ReadTimeout,Request.Options才会被覆盖:
|
||||
|
||||
```
|
||||
if (config.getConnectTimeout() != null && config.getReadTimeout() != null) {
|
||||
builder.options(new Request.Options(config.getConnectTimeout(),
|
||||
config.getReadTimeout()));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
更进一步,如果你希望针对单独的Feign Client设置超时时间,可以把default替换为Client的name:
|
||||
|
||||
```
|
||||
feign.client.config.default.readTimeout=3000
|
||||
feign.client.config.default.connectTimeout=3000
|
||||
feign.client.config.clientsdk.readTimeout=2000
|
||||
feign.client.config.clientsdk.connectTimeout=2000
|
||||
|
||||
```
|
||||
|
||||
可以得出**结论三,单独的超时可以覆盖全局超时,这符合预期,不算坑**:
|
||||
|
||||
```
|
||||
[15:45:51.708] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时:2006ms 错误:Read timed out executing POST http://clientsdk/feignandribbon/server
|
||||
|
||||
```
|
||||
|
||||
**结论四,除了可以配置Feign,也可以配置Ribbon组件的参数来修改两个超时时间。这里的坑点三是,参数首字母要大写,和Feign的配置不同**。
|
||||
|
||||
```
|
||||
ribbon.ReadTimeout=4000
|
||||
ribbon.ConnectTimeout=4000
|
||||
|
||||
```
|
||||
|
||||
可以通过日志证明参数生效:
|
||||
|
||||
```
|
||||
[15:55:18.019] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时:4003ms 错误:Read timed out executing POST http://clientsdk/feignandribbon/server
|
||||
|
||||
```
|
||||
|
||||
最后,我们来看看同时配置Feign和Ribbon的参数,最终谁会生效?如下代码的参数配置:
|
||||
|
||||
```
|
||||
clientsdk.ribbon.listOfServers=localhost:45678
|
||||
feign.client.config.default.readTimeout=3000
|
||||
feign.client.config.default.connectTimeout=3000
|
||||
ribbon.ReadTimeout=4000
|
||||
ribbon.ConnectTimeout=4000
|
||||
|
||||
```
|
||||
|
||||
日志输出证明,最终生效的是Feign的超时:
|
||||
|
||||
```
|
||||
[16:01:19.972] [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 执行耗时:3006ms 错误:Read timed out executing POST http://clientsdk/feignandribbon/server
|
||||
|
||||
```
|
||||
|
||||
**结论五,同时配置Feign和Ribbon的超时,以Feign为准**。这有点反直觉,因为Ribbon更底层所以你会觉得后者的配置会生效,但其实不是这样的。
|
||||
|
||||
在LoadBalancerFeignClient源码中可以看到,如果Request.Options不是默认值,就会创建一个FeignOptionsClientConfig代替原来Ribbon的DefaultClientConfigImpl,导致Ribbon的配置被Feign覆盖:
|
||||
|
||||
```
|
||||
IClientConfig getClientConfig(Request.Options options, String clientName) {
|
||||
IClientConfig requestConfig;
|
||||
if (options == DEFAULT_OPTIONS) {
|
||||
requestConfig = this.clientFactory.getClientConfig(clientName);
|
||||
}
|
||||
else {
|
||||
requestConfig = new FeignOptionsClientConfig(options);
|
||||
}
|
||||
return requestConfig;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但如果这么配置最终生效的还是Ribbon的超时(4秒),这容易让人产生Ribbon覆盖了Feign的错觉,其实这还是因为坑二所致,单独配置Feign的读取超时并不能生效:
|
||||
|
||||
```
|
||||
clientsdk.ribbon.listOfServers=localhost:45678
|
||||
feign.client.config.default.readTimeout=3000
|
||||
feign.client.config.clientsdk.readTimeout=2000
|
||||
ribbon.ReadTimeout=4000
|
||||
|
||||
```
|
||||
|
||||
## 你是否知道Ribbon会自动重试请求呢?
|
||||
|
||||
一些HTTP客户端往往会内置一些重试策略,其初衷是好的,毕竟因为网络问题导致丢包虽然频繁但持续时间短,往往重试下第二次就能成功,但一定要小心这种自作主张是否符合我们的预期。
|
||||
|
||||
之前遇到过一个短信重复发送的问题,但短信服务的调用方用户服务,反复确认代码里没有重试逻辑。那问题究竟出在哪里了?我们来重现一下这个案例。
|
||||
|
||||
首先,定义一个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 {
|
||||
//输出调用参数后休眠2秒
|
||||
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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在45678和45679两个端口上分别启动服务端,然后访问45678的客户端接口进行测试。因为客户端和服务端控制器在一个应用中,所以45678同时扮演了客户端和服务端的角色。
|
||||
|
||||
在45678日志中可以看到,29秒时客户端收到请求开始调用服务端接口发短信,同时服务端收到了请求,2秒后(注意对比第一条日志和第三条日志)客户端输出了读取超时的错误信息:
|
||||
|
||||
```
|
||||
[12:49:29.020] [http-nio-45678-exec-4] [INFO ] [c.d.RibbonRetryIssueClientController:23 ] - client is called
|
||||
[12:49:29.026] [http-nio-45678-exec-5] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45678/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418
|
||||
[12:49:31.029] [http-nio-45678-exec-4] [ERROR] [c.d.RibbonRetryIssueClientController:27 ] - send sms failed : Read timed out executing GET http://SmsClient/ribbonretryissueserver/sms?mobile=13600000000&message=a2aa1b32-a044-40e9-8950-7f0189582418
|
||||
|
||||
```
|
||||
|
||||
而在另一个服务端45679的日志中还可以看到一条请求,30秒时收到请求,也就是客户端接口调用后的1秒:
|
||||
|
||||
```
|
||||
[12:49:30.029] [http-nio-45679-exec-2] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45679/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418
|
||||
|
||||
```
|
||||
|
||||
客户端接口被调用的日志只输出了一次,而服务端的日志输出了两次。虽然Feign的默认读取超时时间是1秒,但客户端2秒后才出现超时错误。**显然,这说明客户端自作主张进行了一次重试,导致短信重复发送。**
|
||||
|
||||
翻看Ribbon的源码可以发现,MaxAutoRetriesNextServer参数默认为1,也就是Get请求在某个服务端节点出现问题(比如读取超时)时,Ribbon会自动重试一次:
|
||||
|
||||
```
|
||||
// DefaultClientConfigImpl
|
||||
public static final int DEFAULT_MAX_AUTO_RETRIES_NEXT_SERVER = 1;
|
||||
public static final int DEFAULT_MAX_AUTO_RETRIES = 0;
|
||||
|
||||
// RibbonLoadBalancedRetryPolicy
|
||||
public boolean canRetry(LoadBalancedRetryContext context) {
|
||||
HttpMethod method = context.getRequest().getMethod();
|
||||
return HttpMethod.GET == method || lbContext.isOkToRetryOnAllOperations();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canRetrySameServer(LoadBalancedRetryContext context) {
|
||||
return sameServerCount < lbContext.getRetryHandler().getMaxRetriesOnSameServer()
|
||||
&& canRetry(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canRetryNextServer(LoadBalancedRetryContext context) {
|
||||
// this will be called after a failure occurs and we increment the counter
|
||||
// so we check that the count is less than or equals to too make sure
|
||||
// we try the next server the right number of times
|
||||
return nextServerCount <= lbContext.getRetryHandler().getMaxRetriesOnNextServer()
|
||||
&& canRetry(context);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
解决办法有两个:
|
||||
|
||||
- 一是,把发短信接口从Get改为Post。其实,这里还有一个API设计问题,有状态的API接口不应该定义为Get。根据HTTP协议的规范,Get请求用于数据查询,而Post才是把数据提交到服务端用于修改或新增。选择Get还是Post的依据,应该是API的行为,而不是参数大小。**这里的一个误区是,Get请求的参数包含在Url QueryString中,会受浏览器长度限制,所以一些同学会选择使用JSON以Post提交大参数,使用Get提交小参数。**
|
||||
- 二是,将MaxAutoRetriesNextServer参数配置为0,禁用服务调用失败后在下一个服务端节点的自动重试。在配置文件中添加一行即可:
|
||||
|
||||
```
|
||||
ribbon.MaxAutoRetriesNextServer=0
|
||||
|
||||
```
|
||||
|
||||
看到这里,你觉得问题出在用户服务还是短信服务呢?
|
||||
|
||||
在我看来,双方都有问题。就像之前说的,Get请求应该是无状态或者幂等的,短信接口可以设计为支持幂等调用的;而用户服务的开发同学,如果对Ribbon的重试机制有所了解的话,或许就能在排查问题上少走些弯路。
|
||||
|
||||
## 并发限制了爬虫的抓取能力
|
||||
|
||||
除了超时和重试的坑,进行HTTP请求调用还有一个常见的问题是,并发数的限制导致程序的处理能力上不去。
|
||||
|
||||
我之前遇到过一个爬虫项目,整体爬取数据的效率很低,增加线程池数量也无济于事,只能堆更多的机器做分布式的爬虫。现在,我们就来模拟下这个场景,看看问题出在了哪里。
|
||||
|
||||
假设要爬取的服务端是这样的一个简单实现,休眠1秒返回数字1:
|
||||
|
||||
```
|
||||
@GetMapping("server")
|
||||
public int server() throws InterruptedException {
|
||||
TimeUnit.SECONDS.sleep(1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
爬虫需要多次调用这个接口进行数据抓取,为了确保线程池不是并发的瓶颈,我们使用一个没有线程上限的newCachedThreadPool作为爬取任务的线程池(再次强调,除非你非常清楚自己的需求,否则一般不要使用没有线程数量上限的线程池),然后使用HttpClient实现HTTP请求,把请求任务循环提交到线程池处理,最后等待所有任务执行完成后输出执行耗时:
|
||||
|
||||
```
|
||||
private int sendRequest(int count, Supplier<CloseableHttpClient> client) throws InterruptedException {
|
||||
//用于计数发送的请求个数
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
//使用HttpClient从server接口查询数据的任务提交到线程池并行处理
|
||||
ExecutorService threadPool = Executors.newCachedThreadPool();
|
||||
long begin = System.currentTimeMillis();
|
||||
IntStream.rangeClosed(1, count).forEach(i -> {
|
||||
threadPool.execute(() -> {
|
||||
try (CloseableHttpResponse response = client.get().execute(new HttpGet("http://127.0.0.1:45678/routelimit/server"))) {
|
||||
atomicInteger.addAndGet(Integer.parseInt(EntityUtils.toString(response.getEntity())));
|
||||
} catch (Exception ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
});
|
||||
});
|
||||
//等到count个任务全部执行完毕
|
||||
threadPool.shutdown();
|
||||
threadPool.awaitTermination(1, TimeUnit.HOURS);
|
||||
log.info("发送 {} 次请求,耗时 {} ms", atomicInteger.get(), System.currentTimeMillis() - begin);
|
||||
return atomicInteger.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,使用默认的PoolingHttpClientConnectionManager构造的CloseableHttpClient,测试一下爬取10次的耗时:
|
||||
|
||||
```
|
||||
static CloseableHttpClient httpClient1;
|
||||
|
||||
static {
|
||||
httpClient1 = HttpClients.custom().setConnectionManager(new PoolingHttpClientConnectionManager()).build();
|
||||
}
|
||||
|
||||
@GetMapping("wrong")
|
||||
public int wrong(@RequestParam(value = "count", defaultValue = "10") int count) throws InterruptedException {
|
||||
return sendRequest(count, () -> httpClient1);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
虽然一个请求需要1秒执行完成,但我们的线程池是可以扩张使用任意数量线程的。按道理说,10个请求并发处理的时间基本相当于1个请求的处理时间,也就是1秒,但日志中显示实际耗时5秒:
|
||||
|
||||
```
|
||||
[12:48:48.122] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.h.r.RouteLimitController :54 ] - 发送 10 次请求,耗时 5265 ms
|
||||
|
||||
```
|
||||
|
||||
查看PoolingHttpClientConnectionManager源码,可以注意到有两个重要参数:
|
||||
|
||||
- **defaultMaxPerRoute=2,也就是同一个主机/域名的最大并发请求数为2。我们的爬虫需要10个并发,显然是默认值太小限制了爬虫的效率。**
|
||||
- maxTotal=20,也就是所有主机整体最大并发为20,这也是HttpClient整体的并发度。目前,我们请求数是10最大并发是10,20不会成为瓶颈。举一个例子,使用同一个HttpClient访问10个域名,defaultMaxPerRoute设置为10,为确保每一个域名都能达到10并发,需要把maxTotal设置为100。
|
||||
|
||||
```
|
||||
public PoolingHttpClientConnectionManager(
|
||||
final HttpClientConnectionOperator httpClientConnectionOperator,
|
||||
final HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection> connFactory,
|
||||
final long timeToLive, final TimeUnit timeUnit) {
|
||||
...
|
||||
this.pool = new CPool(new InternalConnectionFactory(
|
||||
this.configData, connFactory), 2, 20, timeToLive, timeUnit);
|
||||
...
|
||||
}
|
||||
|
||||
public CPool(
|
||||
final ConnFactory<HttpRoute, ManagedHttpClientConnection> connFactory,
|
||||
final int defaultMaxPerRoute, final int maxTotal,
|
||||
final long timeToLive, final TimeUnit timeUnit) {
|
||||
...
|
||||
}}
|
||||
|
||||
```
|
||||
|
||||
HttpClient是Java非常常用的HTTP客户端,这个问题经常出现。你可能会问,为什么默认值限制得这么小。
|
||||
|
||||
其实,这不能完全怪HttpClient,很多早期的浏览器也限制了同一个域名两个并发请求。对于同一个域名并发连接的限制,其实是HTTP 1.1协议要求的,[这里](http://w3.org/Protocols/rfc2616/rfc2616-sec8.html#sec8.1.4)有这么一段话:
|
||||
|
||||
```
|
||||
Clients that use persistent connections SHOULD limit the number of simultaneous connections that they maintain to a given server. A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy. A proxy SHOULD use up to 2*N connections to another server or proxy, where N is the number of simultaneously active users. These guidelines are intended to improve HTTP response times and avoid congestion.
|
||||
|
||||
```
|
||||
|
||||
HTTP 1.1协议是20年前制定的,现在HTTP服务器的能力强很多了,所以有些新的浏览器没有完全遵从2并发这个限制,放开并发数到了8甚至更大。如果需要通过HTTP客户端发起大量并发请求,不管使用什么客户端,请务必确认客户端的实现默认的并发度是否满足需求。
|
||||
|
||||
既然知道了问题所在,我们就尝试声明一个新的HttpClient放开相关限制,设置maxPerRoute为50、maxTotal为100,然后修改一下刚才的wrong方法,使用新的客户端进行测试:
|
||||
|
||||
```
|
||||
httpClient2 = HttpClients.custom().setMaxConnPerRoute(10).setMaxConnTotal(20).build();
|
||||
|
||||
```
|
||||
|
||||
输出如下,10次请求在1秒左右执行完成。可以看到,因为放开了一个Host 2个并发的默认限制,爬虫效率得到了大幅提升:
|
||||
|
||||
```
|
||||
[12:58:11.333] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.h.r.RouteLimitController :54 ] - 发送 10 次请求,耗时 1023 ms
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我和你分享了HTTP调用最常遇到的超时、重试和并发问题。
|
||||
|
||||
连接超时代表建立TCP连接的时间,读取超时代表了等待远端返回数据的时间,也包括远端程序处理的时间。在解决连接超时问题时,我们要搞清楚连的是谁;在遇到读取超时问题的时候,我们要综合考虑下游服务的服务标准和自己的服务标准,设置合适的读取超时时间。此外,在使用诸如Spring Cloud Feign等框架时务必确认,连接和读取超时参数的配置是否正确生效。
|
||||
|
||||
对于重试,因为HTTP协议认为Get请求是数据查询操作,是无状态的,又考虑到网络出现丢包是比较常见的事情,有些HTTP客户端或代理服务器会自动重试Get/Head请求。如果你的接口设计不支持幂等,需要关闭自动重试。但,更好的解决方案是,[遵从HTTP协议](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html)的建议来使用合适的HTTP方法。
|
||||
|
||||
最后我们看到,包括HttpClient在内的HTTP客户端以及浏览器,都会限制客户端调用的最大并发数。如果你的客户端有比较大的请求调用并发,比如做爬虫,或是扮演类似代理的角色,又或者是程序本身并发较高,如此小的默认值很容易成为吞吐量的瓶颈,需要及时调整。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 第一节中我们强调了要注意连接超时和读取超时参数的配置,大多数的HTTP客户端也都有这两个参数。有读就有写,但为什么我们很少看到“写入超时”的概念呢?
|
||||
1. 除了Ribbon的AutoRetriesNextServer重试机制,Nginx也有类似的重试功能。你了解Nginx相关的配置吗?
|
||||
|
||||
针对HTTP调用,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
@@ -0,0 +1,514 @@
|
||||
<audio id="audio" title="06 | 20%的业务代码的Spring声明式事务,可能都没处理正确" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/2e/23c8957e2bcc2e490cc4b8e1ef6b792e.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来和你聊聊业务代码中与数据库事务相关的坑。
|
||||
|
||||
Spring针对Java Transaction API (JTA)、JDBC、Hibernate和Java Persistence API (JPA)等事务API,实现了一致的编程模型,而Spring的声明式事务功能更是提供了极其方便的事务配置方式,配合Spring Boot的自动配置,大多数Spring Boot项目只需要在方法上标记@Transactional注解,即可一键开启方法的事务性配置。
|
||||
|
||||
据我观察,大多数业务开发同学都有事务的概念,也知道如果整体考虑多个数据库操作要么成功要么失败时,需要通过数据库事务来实现多个操作的一致性和原子性。但,在使用上大多仅限于为方法标记@Transactional,不会去关注事务是否有效、出错后事务是否正确回滚,也不会考虑复杂的业务代码中涉及多个子业务逻辑时,怎么正确处理事务。
|
||||
|
||||
事务没有被正确处理,一般来说不会过于影响正常流程,也不容易在测试阶段被发现。但当系统越来越复杂、压力越来越大之后,就会带来大量的数据不一致问题,随后就是大量的人工介入查看和修复数据。
|
||||
|
||||
所以说,一个成熟的业务系统和一个基本可用能完成功能的业务系统,在事务处理细节上的差异非常大。要确保事务的配置符合业务功能的需求,往往不仅仅是技术问题,还涉及产品流程和架构设计的问题。今天这一讲的标题“20%的业务代码的Spring声明式事务,可能都没处理正确”中,20%这个数字在我看来还是比较保守的。
|
||||
|
||||
我今天要分享的内容,就是帮助你在技术问题上理清思路,避免因为事务处理不当让业务逻辑的实现产生大量偶发Bug。
|
||||
|
||||
## 小心Spring的事务可能没有生效
|
||||
|
||||
在使用@Transactional注解开启声明式事务时, 第一个最容易忽略的问题是,很可能事务并没有生效。
|
||||
|
||||
实现下面的Demo需要一些基础类,首先定义一个具有ID和姓名属性的UserEntity,也就是一个包含两个字段的用户表:
|
||||
|
||||
```
|
||||
@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,新增一个根据用户名查询所有数据的方法:
|
||||
|
||||
```
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<UserEntity, Long> {
|
||||
List<UserEntity> findByName(String name);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
定义一个UserService类,负责业务逻辑处理。如果不清楚@Transactional的实现方式,只考虑代码逻辑的话,这段代码看起来没有问题。
|
||||
|
||||
定义一个入口方法createUserWrong1来调用另一个私有方法createUserPrivate,私有方法上标记了@Transactional注解。当传入的用户名包含test关键字时判断为用户名不合法,抛出异常,让用户创建操作失败,期望事务可以回滚:
|
||||
|
||||
```
|
||||
@Service
|
||||
@Slf4j
|
||||
public class UserService {
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
//一个公共方法供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();
|
||||
}
|
||||
|
||||
//标记了@Transactional的private方法
|
||||
@Transactional
|
||||
private void createUserPrivate(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();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面是Controller的实现,只是调用一下刚才定义的UserService中的入口方法createUserWrong1。
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
|
||||
@GetMapping("wrong1")
|
||||
public int wrong1(@RequestParam("name") String name) {
|
||||
return userService.createUserWrong1(name);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调用接口后发现,即便用户名不合法,用户也能创建成功。刷新浏览器,多次发现有十几个的非法用户注册。
|
||||
|
||||
这里给出@Transactional生效原则1,**除非特殊配置(比如使用AspectJ静态织入实现AOP),否则只有定义在public方法上的@Transactional才能生效**。原因是,Spring默认通过动态代理的方式实现AOP,对目标方法进行增强,private方法无法代理到,Spring自然也无法动态增强事务处理逻辑。
|
||||
|
||||
你可能会说,修复方式很简单,把标记了事务注解的createUserPrivate方法改为public即可。在UserService中再建一个入口方法createUserWrong2,来调用这个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();
|
||||
}
|
||||
|
||||
//标记了@Transactional的public方法
|
||||
@Transactional
|
||||
public void createUserPublic(UserEntity entity) {
|
||||
userRepository.save(entity);
|
||||
if (entity.getName().contains("test"))
|
||||
throw new RuntimeException("invalid username!");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试发现,调用新的createUserWrong2方法事务同样不生效。这里,我给出@Transactional生效原则2,**必须通过代理过的类从外部调用目标方法才能生效**。
|
||||
|
||||
Spring通过AOP技术对方法进行增强,要调用增强过的方法必然是调用代理后的对象。我们尝试修改下UserService的代码,注入一个self,然后再通过self实例调用标记有@Transactional注解的createUserPublic方法。设置断点可以看到,self是由Spring通过CGLIB方式增强过的类:
|
||||
|
||||
- CGLIB通过继承方式实现代理类,private方法在子类不可见,自然也就无法进行事务增强;
|
||||
- this指针代表对象自己,Spring不可能注入this,所以通过this访问方法必然不是代理。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/6c/b077c033fa394353309fbb4f8368e46c.png" alt="">
|
||||
|
||||
把this改为self后测试发现,在Controller中调用createUserRight方法可以验证事务是生效的,非法的用户注册操作可以回滚。
|
||||
|
||||
虽然在UserService内部注入自己调用自己的createUserPublic可以正确实现事务,但更合理的实现方式是,让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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再通过一张图来回顾下this自调用、通过self调用,以及在Controller中调用UserService三种实现的区别:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/70/c43ea620b0b611ae194f8438506d7570.png" alt="">
|
||||
|
||||
通过this自调用,没有机会走到Spring的代理类;后两种改进方案调用的是Spring注入的UserService,通过代理调用才有机会对createUserPublic方法进行动态增强。
|
||||
|
||||
这里,我还有一个小技巧,**强烈建议你在开发时打开相关的Debug日志,以方便了解Spring事务实现的细节,并及时判断事务的执行情况**。
|
||||
|
||||
我们的Demo代码使用JPA进行数据库访问,可以这么开启Debug日志:
|
||||
|
||||
```
|
||||
logging.level.org.springframework.orm.jpa=DEBUG
|
||||
|
||||
```
|
||||
|
||||
开启日志后,我们再比较下在UserService中通过this调用和在Controller中通过注入的UserService Bean调用createUserPublic区别。很明显,this调用因为没有走代理,事务没有在createUserPublic方法上生效,只在Repository的save方法层面生效:
|
||||
|
||||
```
|
||||
//在UserService中通过this调用public的createUserPublic
|
||||
[10:10:19.913] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
//在Controller中通过注入的UserService Bean调用createUserPublic
|
||||
[10:10:47.750] [http-nio-45678-exec-6] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo1.UserService.createUserPublic]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
|
||||
```
|
||||
|
||||
你可能还会考虑一个问题,这种实现在Controller里处理了异常显得有点繁琐,还不如直接把createUserWrong2方法加上@Transactional注解,然后在Controller中直接调用这个方法。这样一来,既能从外部(Controller中)调用UserService中的方法,方法又是public的能够被动态代理AOP增强。
|
||||
|
||||
你可以试一下这种方法,但很容易就会踩第二个坑,即因为没有正确处理异常,导致事务即便生效也不一定能回滚。
|
||||
|
||||
## 事务即便生效也不一定能回滚
|
||||
|
||||
通过AOP实现事务处理可以理解为,使用try…catch…来包裹标记了@Transactional注解的方法,**当方法出现了异常并且满足一定条件的时候**,在catch里面我们可以设置事务回滚,没有异常则直接提交事务。
|
||||
|
||||
这里的“一定条件”,主要包括两点。
|
||||
|
||||
第一,**只有异常传播出了标记了@Transactional注解的方法,事务才能回滚**。在Spring的TransactionAspectSupport里有个 invokeWithinTransaction方法,里面就是处理事务的逻辑。可以看到,只有捕获到异常才能进行后续事务处理:
|
||||
|
||||
```
|
||||
try {
|
||||
// This is an around advice: Invoke the next interceptor in the chain.
|
||||
// This will normally result in a target object being invoked.
|
||||
retVal = invocation.proceedWithInvocation();
|
||||
}
|
||||
catch (Throwable ex) {
|
||||
// target invocation exception
|
||||
completeTransactionAfterThrowing(txInfo, ex);
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
cleanupTransactionInfo(txInfo);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二,**默认情况下,出现RuntimeException(非受检异常)或Error的时候,Spring才会回滚事务**。
|
||||
|
||||
打开Spring的DefaultTransactionAttribute类能看到如下代码块,可以发现相关证据,通过注释也能看到Spring这么做的原因,大概的意思是受检异常一般是业务异常,或者说是类似另一种方法的返回值,出现这样的异常可能业务还能完成,所以不会主动回滚;而Error或RuntimeException代表了非预期的结果,应该回滚:
|
||||
|
||||
```
|
||||
/**
|
||||
* The default behavior is as with EJB: rollback on unchecked exception
|
||||
* ({@link RuntimeException}), assuming an unexpected outcome outside of any
|
||||
* business rules. Additionally, we also attempt to rollback on {@link Error} which
|
||||
* is clearly an unexpected outcome as well. By contrast, a checked exception is
|
||||
* considered a business exception and therefore a regular expected outcome of the
|
||||
* transactional business method, i.e. a kind of alternative return value which
|
||||
* still allows for regular completion of resource operations.
|
||||
* <p>This is largely consistent with TransactionTemplate's default behavior,
|
||||
* except that TransactionTemplate also rolls back on undeclared checked exceptions
|
||||
* (a corner case). For declarative transactions, we expect checked exceptions to be
|
||||
* intentionally declared as business exceptions, leading to a commit by default.
|
||||
* @see org.springframework.transaction.support.TransactionTemplate#execute
|
||||
*/
|
||||
@Override
|
||||
public boolean rollbackOn(Throwable ex) {
|
||||
return (ex instanceof RuntimeException || ex instanceof Error);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我和你分享2个反例。
|
||||
|
||||
重新实现一下UserService中的注册用户操作:
|
||||
|
||||
- 在createUserWrong1方法中会抛出一个RuntimeException,但由于方法内catch了所有异常,异常无法从方法传播出去,事务自然无法回滚。
|
||||
- 在createUserWrong2方法中,注册用户的同时会有一次otherTask文件读取操作,如果文件读取失败,我们希望用户注册的数据库操作回滚。虽然这里没有捕获异常,但因为otherTask方法抛出的是受检异常,createUserWrong2传播出去的也是受检异常,事务同样不会回滚。
|
||||
|
||||
```
|
||||
@Service
|
||||
@Slf4j
|
||||
public class UserService {
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
//异常无法传播出方法,导致事务无法回滚
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
//即使出了受检异常也无法让事务回滚
|
||||
@Transactional
|
||||
public void createUserWrong2(String name) throws IOException {
|
||||
userRepository.save(new UserEntity(name));
|
||||
otherTask();
|
||||
}
|
||||
|
||||
//因为文件不存在,一定会抛出一个IOException
|
||||
private void otherTask() throws IOException {
|
||||
Files.readAllLines(Paths.get("file-that-not-exist"));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Controller中的实现,仅仅是调用UserService的createUserWrong1和createUserWrong2方法,这里就贴出实现了。这2个方法的实现和调用,虽然完全避开了事务不生效的坑,但因为异常处理不当,导致程序没有如我们期望的文件操作出现异常时回滚事务。
|
||||
|
||||
现在,我们来看下修复方式,以及如何通过日志来验证是否修复成功。针对这2种情况,对应的修复方法如下。
|
||||
|
||||
第一,如果你希望自己捕获异常进行处理的话,也没关系,可以手动设置让当前事务处于回滚状态:
|
||||
|
||||
```
|
||||
@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();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行后可以在日志中看到Rolling back字样,确认事务回滚了。同时,我们还注意到“Transactional code has requested rollback”的提示,表明手动请求回滚:
|
||||
|
||||
```
|
||||
[22:14:49.352] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :698 ] - Transactional code has requested rollback
|
||||
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
|
||||
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1906719643<open>)]
|
||||
|
||||
```
|
||||
|
||||
第二,在注解中声明,期望遇到所有的Exception都回滚事务(来突破默认不回滚受检异常的限制):
|
||||
|
||||
```
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void createUserRight2(String name) throws IOException {
|
||||
userRepository.save(new UserEntity(name));
|
||||
otherTask();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行后,同样可以在日志中看到回滚的提示:
|
||||
|
||||
```
|
||||
[22:10:47.980] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
|
||||
[22:10:47.981] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1419329213<open>)]
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,我们展现的是一个复杂的业务逻辑,其中有数据库操作、IO操作,在IO操作出现问题时希望让数据库事务也回滚,以确保逻辑的一致性。在有些业务逻辑中,可能会包含多次数据库操作,我们不一定希望将两次操作作为一个事务来处理,这时候就需要仔细考虑事务传播的配置了,否则也可能踩坑。
|
||||
|
||||
## 请确认事务传播配置是否符合自己的业务逻辑
|
||||
|
||||
有这么一个场景:一个用户注册的操作,会插入一个主用户到用户表,还会注册一个关联的子用户。我们希望将子用户注册的数据库操作作为一个独立事务来处理,即使失败也不会影响主流程,即不影响主用户的注册。
|
||||
|
||||
接下来,我们模拟一个实现类似业务逻辑的UserService:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private SubUserService subUserService;
|
||||
|
||||
@Transactional
|
||||
public void createUserWrong(UserEntity entity) {
|
||||
createMainUser(entity);
|
||||
subUserService.createSubUserWithExceptionWrong(entity);
|
||||
}
|
||||
|
||||
private void createMainUser(UserEntity entity) {
|
||||
userRepository.save(entity);
|
||||
log.info("createMainUser finish");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
SubUserService的createSubUserWithExceptionWrong实现正如其名,因为最后我们抛出了一个运行时异常,错误原因是用户状态无效,所以子用户的注册肯定是失败的。我们期望子用户的注册作为一个事务单独回滚,不影响主用户的注册,这样的逻辑可以实现吗?
|
||||
|
||||
```
|
||||
@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");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们在Controller里实现一段测试代码,调用UserService:
|
||||
|
||||
```
|
||||
@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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调用后可以在日志中发现如下信息,很明显事务回滚了,最后Controller打出了创建子用户抛出的运行时异常:
|
||||
|
||||
```
|
||||
[22:50:42.866] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(103972212<open>)]
|
||||
[22:50:42.869] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :620 ] - Closing JPA EntityManager [SessionImpl(103972212<open>)] after transaction
|
||||
[22:50:42.869] [http-nio-45678-exec-8] [ERROR] [t.d.TransactionPropagationController:23 ] - createUserWrong failed, reason:invalid status
|
||||
|
||||
```
|
||||
|
||||
你马上就会意识到,不对呀,因为运行时异常逃出了@Transactional注解标记的createUserWrong方法,Spring当然会回滚事务了。如果我们希望主方法不回滚,应该把子方法抛出的异常捕获了。
|
||||
|
||||
也就是这么改,把subUserService.createSubUserWithExceptionWrong包裹上catch,这样外层主方法就不会出现异常了:
|
||||
|
||||
```
|
||||
@Transactional
|
||||
public void createUserWrong2(UserEntity entity) {
|
||||
createMainUser(entity);
|
||||
try{
|
||||
subUserService.createSubUserWithExceptionWrong(entity);
|
||||
} catch (Exception ex) {
|
||||
// 虽然捕获了异常,但是因为没有开启新事务,而当前事务因为异常已经被标记为rollback了,所以最终还是会回滚。
|
||||
log.error("create sub user error:{}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行程序后可以看到如下日志:
|
||||
|
||||
```
|
||||
[22:57:21.722] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserWrong2]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
[22:57:21.739] [http-nio-45678-exec-3] [INFO ] [t.c.transaction.demo3.SubUserService:19 ] - createSubUserWithExceptionWrong start
|
||||
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :356 ] - Found thread-bound EntityManager [SessionImpl(1794007607<open>)] for JPA transaction
|
||||
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :471 ] - Participating in existing transaction
|
||||
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :843 ] - Participating transaction failed - marking existing transaction as rollback-only
|
||||
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :580 ] - Setting JPA transaction on EntityManager [SessionImpl(1794007607<open>)] rollback-only
|
||||
[22:57:21.740] [http-nio-45678-exec-3] [ERROR] [.g.t.c.transaction.demo3.UserService:37 ] - create sub user error:invalid status
|
||||
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :741 ] - Initiating transaction commit
|
||||
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :529 ] - Committing JPA transaction on EntityManager [SessionImpl(1794007607<open>)]
|
||||
[22:57:21.743] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :620 ] - Closing JPA EntityManager [SessionImpl(1794007607<open>)] after transaction
|
||||
[22:57:21.743] [http-nio-45678-exec-3] [ERROR] [t.d.TransactionPropagationController:33 ] - createUserWrong2 failed, reason:Transaction silently rolled back because it has been marked as rollback-only
|
||||
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
需要注意以下几点:
|
||||
|
||||
- 如第1行所示,对createUserWrong2方法开启了异常处理;
|
||||
- 如第5行所示,子方法因为出现了运行时异常,标记当前事务为回滚;
|
||||
- 如第7行所示,主方法的确捕获了异常打印出了create sub user error字样;
|
||||
- 如第9行所示,主方法提交了事务;
|
||||
- 奇怪的是,如第11行和12行所示,**Controller里出现了一个UnexpectedRollbackException,异常描述提示最终这个事务回滚了,而且是静默回滚的**。之所以说是静默,是因为createUserWrong2方法本身并没有出异常,只不过提交后发现子方法已经把当前事务设置为了回滚,无法完成提交。
|
||||
|
||||
这挺反直觉的。**我们之前说,出了异常事务不一定回滚,这里说的却是不出异常,事务也不一定可以提交**。原因是,主方法注册主用户的逻辑和子方法注册子用户的逻辑是同一个事务,子逻辑标记了事务需要回滚,主逻辑自然也不能提交了。
|
||||
|
||||
看到这里,修复方式就很明确了,想办法让子逻辑在独立事务中运行,也就是改一下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");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
主方法没什么变化,同样需要捕获异常,防止异常漏出去导致主事务回滚,重新命名为createUserRight:
|
||||
|
||||
```
|
||||
@Transactional
|
||||
public void createUserRight(UserEntity entity) {
|
||||
createMainUser(entity);
|
||||
try{
|
||||
subUserService.createSubUserWithExceptionRight(entity);
|
||||
} catch (Exception ex) {
|
||||
// 捕获异常,防止主方法回滚
|
||||
log.error("create sub user error:{}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
改造后,重新运行程序可以看到如下的关键日志:
|
||||
|
||||
- 第1行日志提示我们针对createUserRight方法开启了主方法的事务;
|
||||
- 第2行日志提示创建主用户完成;
|
||||
- 第3行日志可以看到主事务挂起了,开启了一个新的事务,针对createSubUserWithExceptionRight方案,也就是我们的创建子用户的逻辑;
|
||||
- 第4行日志提示子方法事务回滚;
|
||||
- 第5行日志提示子方法事务完成,继续主方法之前挂起的事务;
|
||||
- 第6行日志提示主方法捕获到了子方法的异常;
|
||||
- 第8行日志提示主方法的事务提交了,随后我们在Controller里没看到静默回滚的异常。
|
||||
|
||||
```
|
||||
[23:17:20.935] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserRight]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
[23:17:21.079] [http-nio-45678-exec-1] [INFO ] [.g.t.c.transaction.demo3.UserService:55 ] - createMainUser finish
|
||||
[23:17:21.082] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :420 ] - Suspending current transaction, creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.SubUserService.createSubUserWithExceptionRight]
|
||||
[23:17:21.153] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
|
||||
[23:17:21.160] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :1009] - Resuming suspended transaction after completion of inner transaction
|
||||
[23:17:21.161] [http-nio-45678-exec-1] [ERROR] [.g.t.c.transaction.demo3.UserService:49 ] - create sub user error:invalid status
|
||||
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :741 ] - Initiating transaction commit
|
||||
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :529 ] - Committing JPA transaction on EntityManager [SessionImpl(396441411<open>)]
|
||||
|
||||
```
|
||||
|
||||
运行测试程序看到如下结果,getUserCount得到的用户数量为1,代表只有一个用户也就是主用户注册完成了,符合预期:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/f8/3bd9c32b5144025f1a2de5b4ec436ff8.png" alt="">
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我针对业务代码中最常见的使用数据库事务的方式,即Spring声明式事务,与你总结了使用上可能遇到的三类坑,包括:
|
||||
|
||||
第一,因为配置不正确,导致方法上的事务没生效。我们务必确认调用@Transactional注解标记的方法是public的,并且是通过Spring注入的Bean进行调用的。
|
||||
|
||||
第二,因为异常处理不正确,导致事务虽然生效但出现异常时没回滚。Spring默认只会对标记@Transactional注解的方法出现了RuntimeException和Error的时候回滚,如果我们的方法捕获了异常,那么需要通过手动编码处理事务回滚。如果希望Spring针对其他异常也可以回滚,那么可以相应配置@Transactional注解的rollbackFor和noRollbackFor属性来覆盖其默认设置。
|
||||
|
||||
第三,如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么我们需要考虑进一步细化配置事务传播方式,也就是@Transactional注解的Propagation属性。
|
||||
|
||||
可见,正确配置事务可以提高业务项目的健壮性。但,又因为健壮性问题往往体现在异常情况或一些细节处理上,很难在主流程的运行和测试中发现,导致业务代码的事务处理逻辑往往容易被忽略,因此**我在代码审查环节一直很关注事务是否正确处理**。
|
||||
|
||||
如果你无法确认事务是否真正生效,是否按照预期的逻辑进行,可以尝试打开Spring的部分Debug日志,通过事务的运作细节来验证。也建议你在单元测试时尽量覆盖多的异常场景,这样在重构时,也能及时发现因为方法的调用方式、异常处理逻辑的调整,导致的事务失效问题。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 考虑到Demo的简洁,文中所有数据访问使用的都是Spring Data JPA。国内大多数互联网业务项目是使用MyBatis进行数据访问的,使用MyBatis配合Spring的声明式事务也同样需要注意文中提到的这些点。你可以尝试把今天的Demo改为MyBatis做数据访问实现,看看日志中是否可以体现出这些坑。
|
||||
1. 在第一节中我们提到,如果要针对private方法启用事务,动态代理方式的AOP不可行,需要使用静态织入方式的AOP,也就是在编译期间织入事务增强代码,可以配置Spring框架使用AspectJ来实现AOP。你能否参阅Spring的文档“[Using @Transactional with AspectJ](https://docs.spring.io/spring/docs/current/spring-framework-reference/data-access.html#transaction-declarative-aspectj)”试试呢?注意:AspectJ配合lombok使用,还可能会踩一些坑。
|
||||
|
||||
有关数据库事务,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
388
极客时间专栏/Java业务开发常见错误100例/代码篇/07 | 数据库索引:索引并不是万能药.md
Normal file
388
极客时间专栏/Java业务开发常见错误100例/代码篇/07 | 数据库索引:索引并不是万能药.md
Normal file
@@ -0,0 +1,388 @@
|
||||
<audio id="audio" title="07 | 数据库索引:索引并不是万能药" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/34/943daa98fdac471ac0330e533b799934.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我要和你分享的主题是,数据库的索引并不是万能药。
|
||||
|
||||
几乎所有的业务项目都会涉及数据存储,虽然当前各种NoSQL和文件系统大行其道,但MySQL等关系型数据库因为满足ACID、可靠性高、对开发友好等特点,仍然最常被用于存储重要数据。在关系型数据库中,索引是优化查询性能的重要手段。
|
||||
|
||||
为此,我经常看到一些同学一遇到查询性能问题,就盲目要求运维或DBA给数据表相关字段创建大量索引。显然,这种想法是错误的。今天,我们就以MySQL为例来深入理解下索引的原理,以及相关误区。
|
||||
|
||||
## InnoDB是如何存储数据的?
|
||||
|
||||
MySQL把数据存储和查询操作抽象成了存储引擎,不同的存储引擎,对数据的存储和读取方式各不相同。MySQL支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为支持事务,我们最常使用的是InnoDB。为方便理解下面的内容,我先和你简单说说InnoDB是如何存储数据的。
|
||||
|
||||
虽然数据保存在磁盘中,但其处理是在内存中进行的。为了减少磁盘随机读取次数,InnoDB采用页而不是行的粒度来保存数据,即数据被分成若干页,以页为单位保存在磁盘中。InnoDB的页大小,一般是16KB。
|
||||
|
||||
各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数据页中有一个页目录,方便按照主键查询记录。数据页的结构如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/d1/1302b4a8d877609486c9a9eed2d8d8d1.png" alt="">
|
||||
|
||||
页目录通过槽把记录分成不同的小组,每个小组有若干条记录。如图所示,记录中最前面的小方块中的数字,代表的是当前分组的记录条数,最小和最大的槽指向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号记录。
|
||||
|
||||
理解了InnoDB存储数据的原理后,我们就可以继续学习MySQL索引相关的原理和坑了。
|
||||
|
||||
## 聚簇索引和二级索引
|
||||
|
||||
说到索引,页目录就是最简单的索引,是通过对记录进行一级分组来降低搜索的时间复杂度。但,这样能够降低的时间复杂度数量级,非常有限。当有无数个数据页来存储表数据的时候,我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。
|
||||
|
||||
为了解决这个问题,InnoDB引入了B+树。如下图所示,B+树是一棵倒过来的树:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/65/e76adf029e63a045e05956039f81f265.png" alt="">
|
||||
|
||||
B+树的特点包括:
|
||||
|
||||
- 最底层的节点叫作叶子节点,用来存放数据;
|
||||
- 其他上层节点叫作非叶子节点,仅用来存放目录项,作为索引;
|
||||
- 非叶子节点分为不同层次,通过分层来降低每一层的搜索量;
|
||||
- 所有节点按照索引键大小排序,构成一个双向链表,加速范围查找。
|
||||
|
||||
因此,InnoDB使用B+树,既可以保存实际数据,也可以加速数据搜索,这就是聚簇索引。如果把上图叶子节点下面方块中的省略号看作实际数据的话,那么它就是聚簇索引的示意图。**由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个**。
|
||||
|
||||
InnoDB会自动使用主键(唯一定义一条记录的单个或多个字段)作为聚簇索引的索引键(如果没有主键,就选择第一个不包含NULL值的唯一列)。上图方框中的数字代表了索引键的值,对聚簇索引而言一般就是主键。
|
||||
|
||||
我们再看看B+树如何实现快速查找主键。比如,我们要搜索PK=4的数据,通过根节点中的索引可以知道数据在第一个记录指向的2号页中,通过2号页的索引又可以知道数据在5号页,5号页就是实际的数据页,然后再通过二分法查找页目录马上可以找到记录的指针。
|
||||
|
||||
为了实现非主键字段的快速搜索,就引出了二级索引,也叫作非聚簇索引、辅助索引。二级索引,也是利用的B+树的数据结构,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/3a/4be8f22d993bd92878209d00a1264b3a.png" alt="">
|
||||
|
||||
这次二级索引的叶子节点中保存的不是实际数据,而是主键,获得主键值后去聚簇索引中获得数据行。这个过程就叫作回表。
|
||||
|
||||
举个例子,有个索引是针对用户名字段创建的,索引记录上面方块中的字母是用户名,按照顺序形成链表。如果我们要搜索用户名为b的数据,经过两次定位可以得出在#5数据页中,查出所有的主键为7和6,再拿着这两个主键继续使用聚簇索引进行两次回表得到完整数据。
|
||||
|
||||
## 考虑额外创建二级索引的代价
|
||||
|
||||
创建二级索引的代价,主要表现在维护代价、空间代价和回表代价三个方面。接下来,我就与你仔细分析下吧。
|
||||
|
||||
**首先是维护代价**。创建N个二级索引,就需要再创建N棵B+树,新增数据时不仅要修改聚簇索引,还需要修改这N个二级索引。
|
||||
|
||||
我们通过实验测试一下创建索引的代价。假设有一个person表,有主键ID,以及name、score、create_time三个字段:
|
||||
|
||||
```
|
||||
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`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
```
|
||||
|
||||
通过下面的存储过程循环创建10万条测试数据,我的机器的耗时是140秒(本文的例子均在MySQL 5.7.26中执行):
|
||||
|
||||
```
|
||||
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));
|
||||
set c_id=c_id+1;
|
||||
end while;
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
如果再创建两个索引,一个是name和score构成的联合索引,另一个是单一列create_time的索引,那么创建10万条记录的耗时提高到154秒:
|
||||
|
||||
```
|
||||
KEY `name_score` (`name`,`score`) USING BTREE,
|
||||
KEY `create_time` (`create_time`) USING BTREE
|
||||
|
||||
```
|
||||
|
||||
这里,我再额外提一下,页中的记录都是按照索引值从小到大的顺序存放的,新增记录就需要往页中插入数据,现有的页满了就需要新创建一个页,把现有页的部分数据移过去,这就是页分裂;如果删除了许多数据使得页比较空闲,还需要进行页合并。页分裂和合并,都会有IO代价,并且可能在操作过程中产生死锁。
|
||||
|
||||
你可以查看[这个文档](https://dev.mysql.com/doc/refman/5.7/en/index-page-merge-threshold.html),以进一步了解如何设置合理的合并阈值,来平衡页的空闲率和因为再次页分裂产生的代价。
|
||||
|
||||
**其次是空间代价**。虽然二级索引不保存原始数据,但要保存索引列的数据,所以会占用更多的空间。比如,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'
|
||||
|
||||
```
|
||||
|
||||
执行计划如下,可以发现:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/21/f380ee99efb997a8520d16f5433f7e21.png" alt="">
|
||||
|
||||
- key字段代表实际走的是哪个索引,其值是name_score,说明走的是name_score这个索引。
|
||||
- type字段代表了访问表的方式,其值ref说明是二级索引等值匹配,符合我们的查询。
|
||||
|
||||
把SQL中的*修改为NAME和SCORE,也就是SELECT name_score联合索引包含的两列:
|
||||
|
||||
```
|
||||
EXPLAIN SELECT NAME,SCORE FROM person WHERE NAME='name1'
|
||||
|
||||
```
|
||||
|
||||
再来看看执行计划:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/e7/88809b6f547238596d141eab27f3d7e7.png" alt="">
|
||||
|
||||
可以看到,Extra列多了一行Using index的提示,证明这次查询直接查的是二级索引,免去了回表。
|
||||
|
||||
原因很简单,联合索引中其实保存了多个索引列的值,对于页中的记录先按照字段1排序,如果相同再按照字段2排序,如图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/72/803c3e6a8df1d6031db70473dc948472.png" alt="">
|
||||
|
||||
图中,叶子节点每一条记录的第一和第二个方块是索引列的数据,第三个方块是记录的主键。如果我们需要查询的是索引列索引或联合索引能覆盖的数据,那么查询索引本身已经“覆盖”了需要的数据,不再需要回表查询。因此,这种情况也叫作**索引覆盖**。我会在最后一小节介绍如何查看不同查询的成本,和你一起看看索引覆盖和索引查询后回表的代价差异。
|
||||
|
||||
最后,我和你总结下关于索引开销的最佳实践吧。
|
||||
|
||||
第一,无需一开始就建立索引,可以等到业务场景明确后,或者是数据量超过1万、查询变慢后,再针对需要查询、排序或分组的字段创建索引。创建索引后可以使用EXPLAIN命令,确认查询是否可以使用索引。我会在下一小节展开说明。
|
||||
|
||||
第二,尽量索引轻量级的字段,比如能索引int字段就不要索引varchar字段。索引字段也可以是部分前缀,在创建的时候指定字段索引长度。针对长文本的搜索,可以考虑使用Elasticsearch等专门用于文本搜索的索引数据库。
|
||||
|
||||
第三,尽量不要在SQL语句中SELECT *,而是SELECT必要的字段,甚至可以考虑使用联合索引来包含我们要搜索的字段,既能实现索引加速,又可以避免回表的开销。
|
||||
|
||||
## 不是所有针对索引列的查询都能用上索引
|
||||
|
||||
在上一个案例中,我创建了一个name+score的联合索引,仅搜索name时就能够用上这个联合索引。这就引出两个问题:
|
||||
|
||||
- 是不是建了索引一定可以用上?
|
||||
- 怎么选择创建联合索引还是多个独立索引?
|
||||
|
||||
首先,我们通过几个案例来分析一下索引失效的情况。
|
||||
|
||||
第一,**索引只能匹配列前缀**。比如下面的LIKE语句,搜索name后缀为name123的用户无法走索引,执行计划的type=ALL代表了全表扫描:
|
||||
|
||||
```
|
||||
EXPLAIN SELECT * FROM person WHERE NAME LIKE '%name123' LIMIT 100
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/c9/e1033c6534938f8381fce051fb8ef8c9.png" alt="">
|
||||
|
||||
把百分号放到后面走前缀匹配,type=range表示走索引扫描,key=name_score看到实际走了name_score索引:
|
||||
|
||||
```
|
||||
EXPLAIN SELECT * FROM person WHERE NAME LIKE 'name123%' LIMIT 100
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/5a/95074c69e68039738046fd4275c4d85a.png" alt="">
|
||||
|
||||
原因很简单,索引B+树中行数据按照索引值排序,只能根据前缀进行比较。如果要按照后缀搜索也希望走索引的话,并且永远只是按照后缀搜索的话,可以把数据反过来存,用的时候再倒过来。
|
||||
|
||||
第二,**条件涉及函数操作无法走索引**。比如搜索条件用到了LENGTH函数,肯定无法走索引:
|
||||
|
||||
```
|
||||
EXPLAIN SELECT * FROM person WHERE LENGTH(NAME)=7
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/08/f1eadcdd35b96c9f982115e528ee6808.png" alt="">
|
||||
|
||||
同样的原因,索引保存的是索引列的原始值,而不是经过函数计算后的值。如果需要针对函数调用走数据库索引的话,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。
|
||||
|
||||
第三,**联合索引只能匹配左边的列**。也就是说,虽然对name和score建了联合索引,但是仅按照score列搜索无法走索引:
|
||||
|
||||
```
|
||||
EXPLAIN SELECT * FROM person WHERE SCORE>45678
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/e3/0d3d1a4ad0ae545f0264be3de781e0e3.png" alt="">
|
||||
|
||||
原因也很简单,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索,肯定无法走索引。尝试把搜索条件加入name列,可以看到走了name_score索引:
|
||||
|
||||
```
|
||||
EXPLAIN SELECT * FROM person WHERE SCORE>45678 AND NAME LIKE 'NAME45%'
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/17/77c946fcf49059d40673cf6075119d17.png" alt="">
|
||||
|
||||
需要注意的是,因为有查询优化器,所以name作为WHERE子句的第几个条件并不是很重要。
|
||||
|
||||
现在回到最开始的两个问题。
|
||||
|
||||
- 是不是建了索引一定可以用上?并不是,只有当查询能符合索引存储的实际结构时,才能用上。这里,我只给出了三个肯定用不上索引的反例。其实,有的时候即使可以走索引,MySQL也不一定会选择使用索引。我会在下一小节展开这一点。
|
||||
- 怎么选择建联合索引还是多个独立索引?如果你的搜索条件经常会使用多个字段进行搜索,那么可以考虑针对这几个字段建联合索引;同时,针对多字段建立联合索引,使用索引覆盖的可能更大。如果只会查询单个字段,可以考虑建单独的索引,毕竟联合索引保存了不必要字段也有成本。
|
||||
|
||||
## 数据库基于成本决定是否走索引
|
||||
|
||||
通过前面的案例,我们可以看到,查询数据可以直接在聚簇索引上进行全表扫描,也可以走二级索引扫描后到聚簇索引回表。看到这里,你不禁要问了,MySQL到底是怎么确定走哪种方案的呢。
|
||||
|
||||
其实,MySQL在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走哪个执行计划。
|
||||
|
||||
这里的成本,包括IO成本和CPU成本:
|
||||
|
||||
- IO成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的IO成本常数是1(也就是读取1个页成本是1)。
|
||||
- CPU成本,是检测数据是否满足条件和排序等CPU操作的成本。默认情况下,检测记录的成本是0.2。
|
||||
|
||||
基于此,我们分析下全表扫描的成本。
|
||||
|
||||
全表扫描,就是把聚簇索引中的记录依次和给定的搜索条件做比较,把符合搜索条件的记录加入结果集的过程。那么,要计算全表扫描的代价需要两个信息:
|
||||
|
||||
- 聚簇索引占用的页面数,用来计算读取数据的IO成本;
|
||||
- 表中的记录数,用来计算搜索的CPU成本。
|
||||
|
||||
那么,MySQL是实时统计这些信息的吗?其实并不是,MySQL维护了表的统计信息,可以使用下面的命令查看:
|
||||
|
||||
```
|
||||
SHOW TABLE STATUS LIKE 'person'
|
||||
|
||||
```
|
||||
|
||||
输出如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/32/5554af3acf1005fac1b6a494b6578732.png" alt="">
|
||||
|
||||
可以看到:
|
||||
|
||||
- 总行数是100086行(之前EXPLAIN时,也看到rows为100086)。你可能说,person表不是有10万行记录吗,为什么这里多了86行?其实,MySQL的统计信息是一个估算,其统计方式比较复杂我就不再展开了。但不妨碍我们根据这个值估算CPU成本,是100086*0.2=20017左右。
|
||||
- 数据长度是4734976字节。对于InnoDB来说,这就是聚簇索引占用的空间,等于聚簇索引的页面数量*每个页面的大小。InnoDB每个页面的大小是16KB,大概计算出页面数量是289,因此IO成本是289左右。
|
||||
|
||||
所以,全表扫描的总成本是20306左右。
|
||||
|
||||
接下来,我还是用person表这个例子,和你分析下MySQL如何基于成本来制定执行计划。现在,我要用下面的SQL查询name>‘name84059’ AND create_time>‘2020-01-24 05:00:00’
|
||||
|
||||
```
|
||||
EXPLAIN SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00'
|
||||
|
||||
```
|
||||
|
||||
其执行计划是全表扫描:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/a8/54c6e60d390b54d5e1ae1e8bc2451fa8.png" alt="">
|
||||
|
||||
只要把create_time条件中的5点改为6点就变为走索引了,并且走的是create_time索引而不是name_score联合索引:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/aa/2501093bce47944d4a9c2b090c2f8baa.png" alt="">
|
||||
|
||||
**我们可以得到两个结论:**
|
||||
|
||||
- MySQL选择索引,并不是按照WHERE条件中列的顺序进行的;
|
||||
- 即便列有索引,甚至有多个可能的索引方案,MySQL也可能不走索引。
|
||||
|
||||
其原因就是,MySQL并不是猜拳决定是否走索引的,而是根据成本来判断的。虽然表的统计信息不完全准确,但足够用于策略的判断了。
|
||||
|
||||
不过,有时会因为统计信息的不准确或成本估算的问题,实际开销会和MySQL统计出来的差距较大,导致MySQL选择错误的索引或是直接选择走全表扫描,这个时候就需要人工干预,使用强制索引了。比如,像这样强制走name_score索引:
|
||||
|
||||
```
|
||||
EXPLAIN SELECT * FROM person FORCE INDEX(name_score) WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00'
|
||||
|
||||
```
|
||||
|
||||
我们介绍了MySQL会根据成本选择执行计划,也通过EXPLAIN知道了优化器最终会选择怎样的执行计划,但MySQL如何制定执行计划始终是一个黑盒。那么,有没有什么办法可以了解各种执行计划的成本,以及MySQL做出选择的依据呢?
|
||||
|
||||
在MySQL 5.6及之后的版本中,我们可以使用optimizer trace功能查看优化器生成执行计划的整个过程。有了这个功能,我们不仅可以了解优化器的选择过程,更可以了解每一个执行环节的成本,然后依靠这些信息进一步优化查询。
|
||||
|
||||
如下代码所示,打开optimizer_trace后,再执行SQL就可以查询information_schema.OPTIMIZER_TRACE表查看执行计划了,最后可以关闭optimizer_trace功能:
|
||||
|
||||
```
|
||||
SET optimizer_trace="enabled=on";
|
||||
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';
|
||||
SELECT * FROM information_schema.OPTIMIZER_TRACE;
|
||||
SET optimizer_trace="enabled=off";
|
||||
|
||||
```
|
||||
|
||||
对于按照create_time>'2020-01-24 05:00:00’条件走全表扫描的SQL,我从OPTIMIZER_TRACE的执行结果中,摘出了几个重要片段来重点分析:
|
||||
|
||||
- 使用name_score对name84059<name条件进行索引扫描需要扫描25362行,成本是30435,因此最终没有选择这个方案。这里的30435是查询二级索引的IO成本和CPU成本之和,再加上回表查询聚簇索引的IO成本和CPU成本之和,我就不再具体分析了:
|
||||
|
||||
```
|
||||
{
|
||||
"index": "name_score",
|
||||
"ranges": [
|
||||
"name84059 < name"
|
||||
],
|
||||
"rows": 25362,
|
||||
"cost": 30435,
|
||||
"chosen": false,
|
||||
"cause": "cost"
|
||||
},
|
||||
|
||||
```
|
||||
|
||||
- 使用create_time进行索引扫描需要扫描23758行,成本是28511,同样因为成本原因没有选择这个方案:
|
||||
|
||||
```
|
||||
{
|
||||
"index": "create_time",
|
||||
"ranges": [
|
||||
"0x5e2a79d0 < create_time"
|
||||
],
|
||||
"rows": 23758,
|
||||
"cost": 28511,
|
||||
"chosen": false,
|
||||
"cause": "cost"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 最终选择了全表扫描方式作为执行计划。可以看到,全表扫描100086条记录的成本是20306,和我们之前计算的一致,显然是小于其他两个方案的28511和30435:
|
||||
|
||||
```
|
||||
{
|
||||
"considered_execution_plans": [{
|
||||
"table": "`person`",
|
||||
"best_access_path": {
|
||||
"considered_access_paths": [{
|
||||
"rows_to_scan": 100086,
|
||||
"access_type": "scan",
|
||||
"resulting_rows": 100086,
|
||||
"cost": 20306,
|
||||
"chosen": true
|
||||
}]
|
||||
},
|
||||
"rows_for_plan": 100086,
|
||||
"cost_for_plan": 20306,
|
||||
"chosen": true
|
||||
}]
|
||||
},
|
||||
|
||||
```
|
||||
|
||||
把SQL中的create_time条件从05:00改为06:00,再次分析OPTIMIZER_TRACE可以看到,这次执行计划选择的是走create_time索引。因为是查询更晚时间的数据,走create_time索引需要扫描的行数从23758减少到了16588。这次走这个索引的成本19907小于全表扫描的20306,更小于走name_score索引的30435:
|
||||
|
||||
```
|
||||
{
|
||||
"index": "create_time",
|
||||
"ranges": [
|
||||
"0x5e2a87e0 < create_time"
|
||||
],
|
||||
"rows": 16588,
|
||||
"cost": 19907,
|
||||
"chosen": true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有关optimizer trace的更多信息,你可以参考[MySQL的文档](https://dev.mysql.com/doc/internals/en/optimizer-tracing.html)。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我先和你分析了MySQL InnoDB存储引擎页、聚簇索引和二级索引的结构,然后分析了关于索引的两个误区。
|
||||
|
||||
第一个误区是,考虑到索引的维护代价、空间占用和查询时回表的代价,不能认为索引越多越好。索引一定是按需创建的,并且要尽可能确保足够轻量。一旦创建了多字段的联合索引,我们要考虑尽可能利用索引本身完成数据查询,减少回表的成本。
|
||||
|
||||
第二个误区是,不能认为建了索引就一定有效,对于后缀的匹配查询、查询中不包含联合索引的第一列、查询条件涉及函数计算等情况无法使用索引。此外,即使SQL本身符合索引的使用条件,MySQL也会通过评估各种查询方式的代价,来决定是否走索引,以及走哪个索引。
|
||||
|
||||
因此,在尝试通过索引进行SQL性能优化的时候,务必通过执行计划或实际的效果来确认索引是否能有效改善性能问题,否则增加了索引不但没解决性能问题,还增加了数据库增删改的负担。如果对EXPLAIN给出的执行计划有疑问的话,你还可以利用optimizer_trace查看详细的执行计划做进一步分析。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 在介绍二级索引代价时,我们通过EXPLAIN命令看到了索引覆盖和回表的两种情况。你能用optimizer trace来分析一下这两种情况的成本差异吗?
|
||||
1. 索引除了可以用于加速搜索外,还可以在排序时发挥作用,你能通过EXPLAIN来证明吗?你知道,在什么情况下针对排序索引会失效吗?
|
||||
|
||||
针对数据库索引,你还有什么心得吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
685
极客时间专栏/Java业务开发常见错误100例/代码篇/08 | 判等问题:程序里如何确定你就是你?.md
Normal file
685
极客时间专栏/Java业务开发常见错误100例/代码篇/08 | 判等问题:程序里如何确定你就是你?.md
Normal file
@@ -0,0 +1,685 @@
|
||||
<audio id="audio" title="08 | 判等问题:程序里如何确定你就是你?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/20/f04eaa79bec7e05e5f26e85a22403e20.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来和你聊聊程序里的判等问题。
|
||||
|
||||
你可能会说,判等不就是一行代码的事情吗,有什么好说的。但,这一行代码如果处理不当,不仅会出现Bug,还可能会引起内存泄露等问题。涉及判等的Bug,即使是使用==这种错误的判等方式,也不是所有时候都会出问题。所以类似的判等问题不太容易发现,可能会被隐藏很久。
|
||||
|
||||
今天,我就equals、compareTo和Java的数值缓存、字符串驻留等问题展开讨论,希望你可以理解其原理,彻底消除业务代码中的相关Bug。
|
||||
|
||||
## 注意equals和==的区别
|
||||
|
||||
在业务代码中,我们通常使用equals或== 进行判等操作。equals是方法而==是操作符,它们的使用是有区别的:
|
||||
|
||||
- 对基本类型,比如int、long,进行判等,只能使用==,比较的是直接值。因为基本类型的值就是其数值。
|
||||
- 对引用类型,比如Integer、Long和String,进行判等,需要使用equals进行内容判等。因为引用类型的直接值是指针,使用==的话,比较的是指针,也就是两个对象在内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容。
|
||||
|
||||
这就引出了我们必须必须要知道的第一个结论:**比较值的内容,除了基本类型只能使用==外,其他类型都需要使用equals**。
|
||||
|
||||
在开篇我提到了,即使使用==对Integer或String进行判等,有些时候也能得到正确结果。这又是为什么呢?
|
||||
|
||||
我们用下面的测试用例深入研究下:
|
||||
|
||||
- 使用==对两个值为127的直接赋值的Integer对象判等;
|
||||
- 使用==对两个值为128的直接赋值的Integer对象判等;
|
||||
- 使用==对一个值为127的直接赋值的Integer和另一个通过new Integer声明的值为127的对象判等;
|
||||
- 使用==对两个通过new Integer声明的值为127的对象判等;
|
||||
- 使用==对一个值为128的直接赋值的Integer对象和另一个值为128的int基本类型判等。
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
通过运行结果可以看到,虽然看起来永远是在对127和127、128和128判等,但==却没有永远给我们true的答复。原因是什么呢?
|
||||
|
||||
第一个案例中,编译器会把Integer a = 127转换为Integer.valueOf(127)。查看源码可以发现,这个**转换在内部其实做了缓存,使得两个Integer指向同一个对象**,所以==返回true。
|
||||
|
||||
```
|
||||
public static Integer valueOf(int i) {
|
||||
if (i >= IntegerCache.low && i <= IntegerCache.high)
|
||||
return IntegerCache.cache[i + (-IntegerCache.low)];
|
||||
return new Integer(i);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二个案例中,之所以同样的代码128就返回false的原因是,默认情况下会缓存[-128, 127]的数值,而128处于这个区间之外。设置JVM参数加上-XX:AutoBoxCacheMax=1000再试试,是不是就返回true了呢?
|
||||
|
||||
```
|
||||
private static class IntegerCache {
|
||||
static final int low = -128;
|
||||
static final int high;
|
||||
|
||||
|
||||
static {
|
||||
// high value may be configured by property
|
||||
int h = 127;
|
||||
String integerCacheHighPropValue =
|
||||
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
|
||||
if (integerCacheHighPropValue != null) {
|
||||
try {
|
||||
int i = parseInt(integerCacheHighPropValue);
|
||||
i = Math.max(i, 127);
|
||||
// Maximum array size is Integer.MAX_VALUE
|
||||
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
|
||||
} catch( NumberFormatException nfe) {
|
||||
// If the property cannot be parsed into an int, ignore it.
|
||||
}
|
||||
}
|
||||
high = h;
|
||||
|
||||
|
||||
cache = new Integer[(high - low) + 1];
|
||||
int j = low;
|
||||
for(int k = 0; k < cache.length; k++)
|
||||
cache[k] = new Integer(j++);
|
||||
|
||||
|
||||
// range [-128, 127] must be interned (JLS7 5.1.7)
|
||||
assert IntegerCache.high >= 127;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第三和第四个案例中,New出来的Integer始终是不走缓存的新对象。比较两个新对象,或者比较一个新对象和一个来自缓存的对象,结果肯定不是相同的对象,因此返回false。
|
||||
|
||||
第五个案例中,我们把装箱的Integer和基本类型int比较,前者会先拆箱再比较,比较的肯定是数值而不是引用,因此返回true。
|
||||
|
||||
看到这里,对于Integer什么时候是相同对象什么时候是不同对象,就很清楚了吧。但知道这些其实意义不大,因为在大多数时候,我们并不关心Integer对象是否是同一个,**只需要记得比较Integer的值请使用equals,而不是==**(对于基本类型int的比较当然只能使用==)。
|
||||
|
||||
其实,我们应该都知道这个原则,只是有的时候特别容易忽略。以我之前遇到过的一个生产事故为例,有这么一个枚举定义了订单状态和对于状态的描述:
|
||||
|
||||
```
|
||||
enum StatusEnum {
|
||||
CREATED(1000, "已创建"),
|
||||
PAID(1001, "已支付"),
|
||||
DELIVERED(1002, "已送到"),
|
||||
FINISHED(1003, "已完成");
|
||||
|
||||
private final Integer status; //注意这里的Integer
|
||||
private final String desc;
|
||||
|
||||
StatusEnum(Integer status, String desc) {
|
||||
this.status = status;
|
||||
this.desc = desc;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在业务代码中,开发同学使用了==对枚举和入参OrderQuery中的status属性进行判等:
|
||||
|
||||
```
|
||||
@Data
|
||||
public class OrderQuery {
|
||||
private Integer status;
|
||||
private String name;
|
||||
}
|
||||
|
||||
@PostMapping("enumcompare")
|
||||
public void enumcompare(@RequestBody OrderQuery orderQuery){
|
||||
StatusEnum statusEnum = StatusEnum.DELIVERED;
|
||||
log.info("orderQuery:{} statusEnum:{} result:{}", orderQuery, statusEnum, statusEnum.status == orderQuery.getStatus());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**因为枚举和入参OrderQuery中的status都是包装类型,所以通过==判等肯定是有问题的。只是这个问题比较隐晦**,究其原因在于:
|
||||
|
||||
- 只看枚举的定义CREATED(1000, “已创建”),容易让人误解status值是基本类型;
|
||||
- 因为有Integer缓存机制的存在,所以使用==判等并不是所有情况下都有问题。在这次事故中,订单状态的值从100开始增长,程序一开始不出问题,直到订单状态超过127后才出现Bug。
|
||||
|
||||
在了解清楚为什么Integer使用==判等有时候也有效的原因之后,我们再来看看为什么String也有这个问题。我们使用几个用例来测试下:
|
||||
|
||||
- 对两个直接声明的值都为1的String使用==判等;
|
||||
- 对两个new出来的值都为2的String使用==判等;
|
||||
- 对两个new出来的值都为3的String先进行intern操作,再使用==判等;
|
||||
- 对两个new出来的值都为4的String通过equals判等。
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
在分析这个结果之前,我先和你说说Java的字符串常量池机制。首先要明确的是其设计初衷是节省内存。当代码中出现双引号形式创建字符串对象时,JVM会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化。
|
||||
|
||||
再回到刚才的例子,再来分析一下运行结果:
|
||||
|
||||
- 第一个案例返回true,因为Java的字符串驻留机制,直接使用双引号声明出来的两个String对象指向常量池中的相同字符串。
|
||||
- 第二个案例,new出来的两个String是不同对象,引用当然不同,所以得到false的结果。
|
||||
- 第三个案例,使用String提供的intern方法也会走常量池机制,所以同样能得到true。
|
||||
- 第四个案例,通过equals对值内容判等,是正确的处理方式,当然会得到true。
|
||||
|
||||
**虽然使用new声明的字符串调用intern方法,也可以让字符串进行驻留,但在业务代码中滥用intern,可能会产生性能问题**。
|
||||
|
||||
写代码测试一下,通过循环把1到1000万之间的数字以字符串形式intern后,存入一个List:
|
||||
|
||||
```
|
||||
List<String> list = new ArrayList<>();
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在启动程序时设置JVM参数-XX:+PrintStringTableStatistic,程序退出时可以打印出字符串常量表的统计信息。调用接口后关闭程序,输出如下:
|
||||
|
||||
```
|
||||
[11:01:57.770] [http-nio-45678-exec-2] [INFO ] [.t.c.e.d.IntAndStringEqualController:54 ] - size:10000000 took:44907
|
||||
StringTable statistics:
|
||||
Number of buckets : 60013 = 480104 bytes, avg 8.000
|
||||
Number of entries : 10030230 = 240725520 bytes, avg 24.000
|
||||
Number of literals : 10030230 = 563005568 bytes, avg 56.131
|
||||
Total footprint : = 804211192 bytes
|
||||
Average bucket size : 167.134
|
||||
Variance of bucket size : 55.808
|
||||
Std. dev. of bucket size: 7.471
|
||||
Maximum bucket size : 198
|
||||
|
||||
```
|
||||
|
||||
可以看到,1000万次intern操作耗时居然超过了44秒。
|
||||
|
||||
其实,原因在于字符串常量池是一个固定容量的Map。如果容量太小(Number of buckets=60013)、字符串太多(1000万个字符串),那么每一个桶中的字符串数量会非常多,所以搜索起来就很慢。输出结果中的Average bucket size=167,代表了Map中桶的平均长度是167。
|
||||
|
||||
解决方式是,设置JVM参数-XX:StringTableSize,指定更多的桶。设置-XX:StringTableSize=10000000后,重启应用:
|
||||
|
||||
```
|
||||
[11:09:04.475] [http-nio-45678-exec-1] [INFO ] [.t.c.e.d.IntAndStringEqualController:54 ] - size:10000000 took:5557
|
||||
StringTable statistics:
|
||||
Number of buckets : 10000000 = 80000000 bytes, avg 8.000
|
||||
Number of entries : 10030156 = 240723744 bytes, avg 24.000
|
||||
Number of literals : 10030156 = 562999472 bytes, avg 56.131
|
||||
Total footprint : = 883723216 bytes
|
||||
Average bucket size : 1.003
|
||||
Variance of bucket size : 1.587
|
||||
Std. dev. of bucket size: 1.260
|
||||
Maximum bucket size : 10
|
||||
|
||||
```
|
||||
|
||||
可以看到,1000万次调用耗时只有5.5秒,Average bucket size降到了1,效果明显。
|
||||
|
||||
好了,是时候给出第二原则了:**没事别轻易用intern,如果要用一定要注意控制驻留的字符串的数量,并留意常量表的各项指标**。
|
||||
|
||||
## 实现一个equals没有这么简单
|
||||
|
||||
如果看过Object类源码,你可能就知道,equals的实现其实是比较对象引用:
|
||||
|
||||
```
|
||||
public boolean equals(Object obj) {
|
||||
return (this == obj);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
之所以Integer或String能通过equals实现内容判等,是因为它们都重写了这个方法。比如,String的equals的实现:
|
||||
|
||||
```
|
||||
public boolean equals(Object anObject) {
|
||||
if (this == anObject) {
|
||||
return true;
|
||||
}
|
||||
if (anObject instanceof String) {
|
||||
String anotherString = (String)anObject;
|
||||
int n = value.length;
|
||||
if (n == anotherString.value.length) {
|
||||
char v1[] = value;
|
||||
char v2[] = anotherString.value;
|
||||
int i = 0;
|
||||
while (n-- != 0) {
|
||||
if (v1[i] != v2[i])
|
||||
return false;
|
||||
i++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于自定义类型,如果不重写equals的话,默认就是使用Object基类的按引用的比较方式。我们写一个自定义类测试一下。
|
||||
|
||||
假设有这样一个描述点的类Point,有x、y和描述三个属性:
|
||||
|
||||
```
|
||||
class Point {
|
||||
private int x;
|
||||
private int y;
|
||||
private final String desc;
|
||||
|
||||
public Point(int x, int y, String desc) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.desc = desc;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
定义三个点p1、p2和p3,其中p1和p2的描述属性不同,p1和p3的三个属性完全相同,并写一段代码测试一下默认行为:
|
||||
|
||||
```
|
||||
Point p1 = new Point(1, 2, "a");
|
||||
Point p2 = new Point(1, 2, "b");
|
||||
Point p3 = new Point(1, 2, "a");
|
||||
log.info("p1.equals(p2) ? {}", p1.equals(p2));
|
||||
log.info("p1.equals(p3) ? {}", p1.equals(p3));
|
||||
|
||||
```
|
||||
|
||||
通过equals方法比较p1和p2、p1和p3均得到false,原因正如刚才所说,我们并没有为Point类实现自定义的equals方法,Object超类中的equals默认使用==判等,比较的是对象的引用。
|
||||
|
||||
我们期望的逻辑是,只要x和y这2个属性一致就代表是同一个点,所以写出了如下的改进代码,重写equals方法,把参数中的Object转换为Point比较其x和y属性:
|
||||
|
||||
```
|
||||
class PointWrong {
|
||||
private int x;
|
||||
private int y;
|
||||
private final String desc;
|
||||
|
||||
public PointWrong(int x, int y, String desc) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
PointWrong that = (PointWrong) o;
|
||||
return x == that.x && y == that.y;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
为测试改进后的Point是否可以满足需求,我们定义了三个用例:
|
||||
|
||||
- 比较一个Point对象和null;
|
||||
- 比较一个Object对象和一个Point对象;
|
||||
- 比较两个x和y属性值相同的Point对象。
|
||||
|
||||
```
|
||||
PointWrong p1 = new PointWrong(1, 2, "a");
|
||||
try {
|
||||
log.info("p1.equals(null) ? {}", p1.equals(null));
|
||||
} catch (Exception ex) {
|
||||
log.error(ex.getMessage());
|
||||
}
|
||||
|
||||
Object o = new Object();
|
||||
try {
|
||||
log.info("p1.equals(expression) ? {}", p1.equals(o));
|
||||
} catch (Exception ex) {
|
||||
log.error(ex.getMessage());
|
||||
}
|
||||
|
||||
PointWrong p2 = new PointWrong(1, 2, "b");
|
||||
log.info("p1.equals(p2) ? {}", p1.equals(p2));
|
||||
|
||||
```
|
||||
|
||||
通过日志中的结果可以看到,第一次比较出现了空指针异常,第二次比较出现了类型转换异常,第三次比较符合预期输出了true。
|
||||
|
||||
```
|
||||
[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:32 ] - java.lang.NullPointerException
|
||||
[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:39 ] - java.lang.ClassCastException: java.lang.Object cannot be cast to org.geekbang.time.commonmistakes.equals.demo1.EqualityMethodController$PointWrong
|
||||
[17:54:39.120] [http-nio-45678-exec-1] [INFO ] [t.c.e.demo1.EqualityMethodController:43 ] - p1.equals(p2) ? true
|
||||
|
||||
```
|
||||
|
||||
**通过这些失效的用例,我们大概可以总结出实现一个更好的equals应该注意的点:**
|
||||
|
||||
- 考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回true;
|
||||
- 需要对另一方进行判空,空对象和自身进行比较,结果一定是fasle;
|
||||
- 需要判断两个对象的类型,如果类型都不同,那么直接返回false;
|
||||
- 确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。
|
||||
|
||||
修复和改进后的equals方法如下:
|
||||
|
||||
```
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
PointRight that = (PointRight) o;
|
||||
return x == that.x && y == that.y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
改进后的equals看起来完美了,但还没完。我们继续往下看。
|
||||
|
||||
## hashCode和equals要配对实现
|
||||
|
||||
我们来试试下面这个用例,定义两个x和y属性值完全一致的Point对象p1和p2,把p1加入HashSet,然后判断这个Set中是否存在p2:
|
||||
|
||||
```
|
||||
PointWrong p1 = new PointWrong(1, 2, "a");
|
||||
PointWrong p2 = new PointWrong(1, 2, "b");
|
||||
|
||||
HashSet<PointWrong> points = new HashSet<>();
|
||||
points.add(p1);
|
||||
log.info("points.contains(p2) ? {}", points.contains(p2));
|
||||
|
||||
```
|
||||
|
||||
按照改进后的equals方法,这2个对象可以认为是同一个,Set中已经存在了p1就应该包含p2,但结果却是false。
|
||||
|
||||
出现这个Bug的原因是,散列表需要使用hashCode来定位元素放到哪个桶。如果自定义对象没有实现自定义的hashCode方法,就会使用Object超类的默认实现,**得到的两个hashCode是不同的,导致无法满足需求**。
|
||||
|
||||
要自定义hashCode,我们可以直接使用Objects.hash方法来实现,改进后的Point类如下:
|
||||
|
||||
```
|
||||
class PointRight {
|
||||
private final int x;
|
||||
private final int y;
|
||||
private final String desc;
|
||||
...
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
...
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
改进equals和hashCode后,再测试下之前的四个用例,结果全部符合预期。
|
||||
|
||||
```
|
||||
[18:25:23.091] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:54 ] - p1.equals(null) ? false
|
||||
[18:25:23.093] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:61 ] - p1.equals(expression) ? false
|
||||
[18:25:23.094] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:67 ] - p1.equals(p2) ? true
|
||||
[18:25:23.094] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:71 ] - points.contains(p2) ? true
|
||||
|
||||
```
|
||||
|
||||
看到这里,你可能会觉得自己实现equals和hashCode很麻烦,实现equals有很多注意点而且代码量很大。不过,实现这两个方法也有简单的方式,一是后面要讲到的Lombok方法,二是使用IDE的代码生成功能。IDEA的类代码快捷生成菜单支持的功能如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/36/944fe3549e4c24936e9837d0bf1e3936.jpg" alt="">
|
||||
|
||||
## 注意compareTo和equals的逻辑一致性
|
||||
|
||||
除了自定义类型需要确保equals和hashCode要逻辑一致外,还有一个更容易被忽略的问题,即compareTo同样需要和equals确保逻辑一致性。
|
||||
|
||||
我之前遇到过这么一个问题,代码里本来使用了ArrayList的indexOf方法进行元素搜索,但是一位好心的开发同学觉得逐一比较的时间复杂度是O(n),效率太低了,于是改为了排序后通过Collections.binarySearch方法进行搜索,实现了O(log n)的时间复杂度。没想到,这么一改却出现了Bug。
|
||||
|
||||
我们来重现下这个问题。首先,定义一个Student类,有id和name两个属性,并实现了一个Comparable接口来返回两个id的值:
|
||||
|
||||
```
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
class Student implements Comparable<Student>{
|
||||
private int id;
|
||||
private String name;
|
||||
|
||||
@Override
|
||||
public int compareTo(Student other) {
|
||||
int result = Integer.compare(other.id, id);
|
||||
if (result==0)
|
||||
log.info("this {} == other {}", this, other);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,写一段测试代码分别通过indexOf方法和Collections.binarySearch方法进行搜索。列表中我们存放了两个学生,第一个学生id是1叫zhang,第二个学生id是2叫wang,搜索这个列表是否存在一个id是2叫li的学生:
|
||||
|
||||
```
|
||||
@GetMapping("wrong")
|
||||
public void wrong(){
|
||||
|
||||
List<Student> list = new ArrayList<>();
|
||||
list.add(new Student(1, "zhang"));
|
||||
list.add(new Student(2, "wang"));
|
||||
Student student = new Student(2, "li");
|
||||
|
||||
log.info("ArrayList.indexOf");
|
||||
int index1 = list.indexOf(student);
|
||||
Collections.sort(list);
|
||||
log.info("Collections.binarySearch");
|
||||
int index2 = Collections.binarySearch(list, student);
|
||||
|
||||
log.info("index1 = " + index1);
|
||||
log.info("index2 = " + index2);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码输出的日志如下:
|
||||
|
||||
```
|
||||
[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:28 ] - ArrayList.indexOf
|
||||
[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:31 ] - Collections.binarySearch
|
||||
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:67 ] - this CompareToController.Student(id=2, name=wang) == other CompareToController.Student(id=2, name=li)
|
||||
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:34 ] - index1 = -1
|
||||
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:35 ] - index2 = 1
|
||||
|
||||
```
|
||||
|
||||
我们注意到如下几点:
|
||||
|
||||
- **binarySearch方法内部调用了元素的compareTo方法进行比较**;
|
||||
- indexOf的结果没问题,列表中搜索不到id为2、name是li的学生;
|
||||
- binarySearch返回了索引1,代表搜索到的结果是id为2,name是wang的学生。
|
||||
|
||||
修复方式很简单,确保compareTo的比较逻辑和equals的实现一致即可。重新实现一下Student类,通过Comparator.comparing这个便捷的方法来实现两个字段的比较:
|
||||
|
||||
```
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
class StudentRight implements Comparable<StudentRight>{
|
||||
private int id;
|
||||
private String name;
|
||||
|
||||
@Override
|
||||
public int compareTo(StudentRight other) {
|
||||
return Comparator.comparing(StudentRight::getName)
|
||||
.thenComparingInt(StudentRight::getId)
|
||||
.compare(this, other);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其实,这个问题容易被忽略的原因在于两方面:
|
||||
|
||||
- 一是,我们使用了Lombok的@Data标记了Student,@Data注解(详见[这里](https://projectlombok.org/features/Data))其实包含了@EqualsAndHashCode注解(详见[这里](https://projectlombok.org/features/EqualsAndHashCode))的作用,也就是默认情况下使用类型所有的字段(不包括static和transient字段)参与到equals和hashCode方法的实现中。因为这两个方法的实现不是我们自己实现的,所以容易忽略其逻辑。
|
||||
- 二是,compareTo方法需要返回数值,作为排序的依据,容易让人使用数值类型的字段随意实现。
|
||||
|
||||
我再强调下,**对于自定义的类型,如果要实现Comparable,请记得equals、hashCode、compareTo三者逻辑一致**。
|
||||
|
||||
## 小心Lombok生成代码的“坑”
|
||||
|
||||
Lombok的@Data注解会帮我们实现equals和hashcode方法,但是有继承关系时,Lombok自动生成的方法可能就不是我们期望的了。
|
||||
|
||||
我们先来研究一下其实现:定义一个Person类型,包含姓名和身份证两个字段:
|
||||
|
||||
```
|
||||
@Data
|
||||
class Person {
|
||||
private String name;
|
||||
private String identity;
|
||||
|
||||
public Person(String name, String identity) {
|
||||
this.name = name;
|
||||
this.identity = identity;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于身份证相同、姓名不同的两个Person对象:
|
||||
|
||||
```
|
||||
Person person1 = new Person("zhuye","001");
|
||||
Person person2 = new Person("Joseph","001");
|
||||
log.info("person1.equals(person2) ? {}", person1.equals(person2));
|
||||
|
||||
```
|
||||
|
||||
使用equals判等会得到false。如果你希望只要身份证一致就认为是同一个人的话,可以使用@EqualsAndHashCode.Exclude注解来修饰name字段,从equals和hashCode的实现中排除name字段:
|
||||
|
||||
```
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String name;
|
||||
|
||||
```
|
||||
|
||||
修改后得到true。打开编译后的代码可以看到,Lombok为Person生成的equals方法的实现,确实只包含了identity属性:
|
||||
|
||||
```
|
||||
public boolean equals(final Object o) {
|
||||
if (o == this) {
|
||||
return true;
|
||||
} else if (!(o instanceof LombokEquealsController.Person)) {
|
||||
return false;
|
||||
} else {
|
||||
LombokEquealsController.Person other = (LombokEquealsController.Person)o;
|
||||
if (!other.canEqual(this)) {
|
||||
return false;
|
||||
} else {
|
||||
Object this$identity = this.getIdentity();
|
||||
Object other$identity = other.getIdentity();
|
||||
if (this$identity == null) {
|
||||
if (other$identity != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!this$identity.equals(other$identity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但到这里还没完,如果类型之间有继承,Lombok会怎么处理子类的equals和hashCode呢?我们来测试一下,写一个Employee类继承Person,并新定义一个公司属性:
|
||||
|
||||
```
|
||||
@Data
|
||||
class Employee extends Person {
|
||||
|
||||
private String company;
|
||||
public Employee(String name, String identity, String company) {
|
||||
super(name, identity);
|
||||
this.company = company;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在如下的测试代码中,声明两个Employee实例,它们具有相同的公司名称,但姓名和身份证均不同:
|
||||
|
||||
```
|
||||
Employee employee1 = new Employee("zhuye","001", "bkjk.com");
|
||||
Employee employee2 = new Employee("Joseph","002", "bkjk.com");
|
||||
log.info("employee1.equals(employee2) ? {}", employee1.equals(employee2));
|
||||
|
||||
```
|
||||
|
||||
很遗憾,结果是true,显然是没有考虑父类的属性,而认为这两个员工是同一人,**说明@EqualsAndHashCode默认实现没有使用父类属性。**
|
||||
|
||||
为解决这个问题,我们可以手动设置callSuper开关为true,来覆盖这种默认行为:
|
||||
|
||||
```
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
class Employee extends Person {
|
||||
|
||||
```
|
||||
|
||||
修改后的代码,实现了同时以子类的属性company加上父类中的属性identity,作为equals和hashCode方法的实现条件(实现上其实是调用了父类的equals和hashCode)。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
现在,我们来回顾下对象判等和比较的重点内容吧。
|
||||
|
||||
首先,我们要注意equals和== 的区别。业务代码中进行内容的比较,针对基本类型只能使用==,针对Integer、String在内的引用类型,需要使用equals。Integer和String的坑在于,使用==判等有时也能获得正确结果。
|
||||
|
||||
其次,对于自定义类型,如果类型需要参与判等,那么务必同时实现equals和hashCode方法,并确保逻辑一致。如果希望快速实现equals、hashCode方法,我们可以借助IDE的代码生成功能,或使用Lombok来生成。如果类型也要参与比较,那么compareTo方法的逻辑同样需要和equals、hashCode方法一致。
|
||||
|
||||
最后,Lombok的@EqualsAndHashCode注解实现equals和hashCode的时候,默认使用类型所有非static、非transient的字段,且不考虑父类。如果希望改变这种默认行为,可以使用@EqualsAndHashCode.Exclude排除一些字段,并设置callSuper = true来让子类的equals和hashCode调用父类的相应方法。
|
||||
|
||||
在比较枚举值和POJO参数值的例子中,我们还可以注意到,使用==来判断两个包装类型的低级错误,确实容易被忽略。所以,**我建议你在IDE中安装阿里巴巴的Java规约插件**(详见[这里](https://github.com/alibaba/p3c)),来及时提示我们这类低级错误:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/c3/fe020d747a35cec23e5d92c1277d02c3.png" alt="">
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 在实现equals时,我是先通过getClass方法判断两个对象的类型,你可能会想到还可以使用instanceof来判断。你能说说这两种实现方式的区别吗?
|
||||
1. 在第三节的例子中,我演示了可以通过HashSet的contains方法判断元素是否在HashSet中,同样是Set的TreeSet其contains方法和HashSet有什么区别吗?
|
||||
|
||||
有关对象判等、比较,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
373
极客时间专栏/Java业务开发常见错误100例/代码篇/09 | 数值计算:注意精度、舍入和溢出问题.md
Normal file
373
极客时间专栏/Java业务开发常见错误100例/代码篇/09 | 数值计算:注意精度、舍入和溢出问题.md
Normal file
@@ -0,0 +1,373 @@
|
||||
<audio id="audio" title="09 | 数值计算:注意精度、舍入和溢出问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/33/48b41f8dda454ee64d25947bebef7233.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我要和你说说数值计算的精度、舍入和溢出问题。
|
||||
|
||||
之所以要单独分享数值计算,是因为很多时候我们习惯的或者说认为理所当然的计算,在计算器或计算机看来并不是那么回事儿。就比如前段时间爆出的一条新闻,说是手机计算器把10%+10%算成了0.11而不是0.2。
|
||||
|
||||
出现这种问题的原因在于,国外的计算程序使用的是单步计算法。在单步计算法中,a+b%代表的是a*(1+b%)。所以,手机计算器计算10%+10%时,其实计算的是10%*(1+10%),所以得到的是0.11而不是0.2。
|
||||
|
||||
在我看来,计算器或计算机会得到反直觉的计算结果的原因,可以归结为:
|
||||
|
||||
- 在人看来,浮点数只是具有小数点的数字,0.1和1都是一样精确的数字。但,计算机其实无法精确保存浮点数,因此浮点数的计算结果也不可能精确。
|
||||
- 在人看来,一个超大的数字只是位数多一点而已,多写几个1并不会让大脑死机。但,计算机是把数值保存在了变量中,不同类型的数值变量能保存的数值范围不同,当数值超过类型能表达的数值上限则会发生溢出问题。
|
||||
|
||||
接下来,我们就具体看看这些问题吧。
|
||||
|
||||
## “危险”的Double
|
||||
|
||||
我们先从简单的反直觉的四则运算看起。对几个简单的浮点数进行加减乘除运算:
|
||||
|
||||
```
|
||||
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+0.2输出的不是0.3而是0.30000000000000004;再比如,对2.15-1.10和1.05判等,结果判等不成立。
|
||||
|
||||
出现这种问题的主要原因是,计算机是以二进制存储数值的,浮点数也不例外。Java采用了[IEEE 754标准](https://en.wikipedia.org/wiki/IEEE_754)实现浮点数的表达和运算,你可以通过[这里](http://www.binaryconvert.com/)查看数值转化为二进制的结果。
|
||||
|
||||
比如,0.1的二进制表示为0.0 0011 0011 0011… (0011 无限循环),再转换为十进制就是0.1000000000000000055511151231257827021181583404541015625。**对于计算机而言,0.1无法精确表达,这是浮点数计算造成精度损失的根源。**
|
||||
|
||||
你可能会说,以0.1为例,其十进制和二进制间转换后相差非常小,不会对计算产生什么影响。但,所谓积土成山,如果大量使用double来作大量的金钱计算,最终损失的精度就是大量的资金出入。比如,每天有一百万次交易,每次交易都差一分钱,一个月下来就差30万。这就不是小事儿了。那,如何解决这个问题呢?
|
||||
|
||||
我们大都听说过BigDecimal类型,浮点数精确表达和运算的场景,一定要使用这个类型。不过,在使用BigDecimal时有几个坑需要避开。我们用BigDecimal把之前的四则运算改一下:
|
||||
|
||||
```
|
||||
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**:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
到这里,你可能会继续问,不能调用BigDecimal传入Double的构造方法,但手头只有一个Double,如何转换为精确表达的BigDecimal呢?
|
||||
|
||||
我们试试用Double.toString把double转换为字符串,看看行不行?
|
||||
|
||||
```
|
||||
System.out.println(new BigDecimal("4.015").multiply(new BigDecimal(Double.toString(100))));
|
||||
|
||||
```
|
||||
|
||||
输出为401.5000。与上面字符串初始化100和4.015相乘得到的结果401.500相比,这里为什么多了1个0呢?原因就是,BigDecimal有scale和precision的概念,scale表示小数点右边的位数,而precision表示精度,也就是有效数字的长度。
|
||||
|
||||
调试一下可以发现,new BigDecimal(Double.toString(100))得到的BigDecimal的scale=1、precision=4;而new BigDecimal(“100”)得到的BigDecimal的scale=0、precision=3。对于BigDecimal乘法操作,返回值的scale是两个数的scale相加。所以,初始化100的两种不同方式,导致最后结果的scale分别是4和3:
|
||||
|
||||
```
|
||||
private static void testScale() {
|
||||
BigDecimal bigDecimal1 = new BigDecimal("100");
|
||||
BigDecimal bigDecimal2 = new BigDecimal(String.valueOf(100d));
|
||||
BigDecimal bigDecimal3 = new BigDecimal(String.valueOf(100));
|
||||
BigDecimal bigDecimal4 = BigDecimal.valueOf(100d);
|
||||
BigDecimal bigDecimal5 = new BigDecimal(Double.toString(100));
|
||||
|
||||
print(bigDecimal1); //scale 0 precision 3 result 401.500
|
||||
print(bigDecimal2); //scale 1 precision 4 result 401.5000
|
||||
print(bigDecimal3); //scale 0 precision 3 result 401.500
|
||||
print(bigDecimal4); //scale 1 precision 4 result 401.5000
|
||||
print(bigDecimal5); //scale 1 precision 4 result 401.5000
|
||||
}
|
||||
|
||||
private static void print(BigDecimal bigDecimal) {
|
||||
log.info("scale {} precision {} result {}", bigDecimal.scale(), bigDecimal.precision(), bigDecimal.multiply(new BigDecimal("4.015")));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
BigDecimal的toString方法得到的字符串和scale相关,又会引出了另一个问题:对于浮点数的字符串形式输出和格式化,我们应该考虑显式进行,通过格式化表达式或格式化工具来明确小数位数和舍入方式。接下来,我们就聊聊浮点数舍入和格式化。
|
||||
|
||||
## 考虑浮点数舍入和格式化的方式
|
||||
|
||||
除了使用Double保存浮点数可能带来精度问题外,更匪夷所思的是这种精度问题,加上String.format的格式化舍入方式,可能得到让人摸不着头脑的结果。
|
||||
|
||||
我们看一个例子吧。首先用double和float初始化两个3.35的浮点数,然后通过String.format使用%.1f来格式化这2个数字:
|
||||
|
||||
```
|
||||
double num1 = 3.35;
|
||||
float num2 = 3.35f;
|
||||
System.out.println(String.format("%.1f", num1));//四舍五入
|
||||
System.out.println(String.format("%.1f", num2));
|
||||
|
||||
```
|
||||
|
||||
得到的结果居然是3.4和3.3。
|
||||
|
||||
这就是由精度问题和舍入方式共同导致的,double和float的3.35其实相当于3.350xxx和3.349xxx:
|
||||
|
||||
```
|
||||
3.350000000000000088817841970012523233890533447265625
|
||||
3.349999904632568359375
|
||||
|
||||
```
|
||||
|
||||
String.format采用四舍五入的方式进行舍入,取1位小数,double的3.350四舍五入为3.4,而float的3.349四舍五入为3.3。
|
||||
|
||||
**我们看一下Formatter类的相关源码,可以发现使用的舍入模式是HALF_UP**(代码第11行):
|
||||
|
||||
```
|
||||
else if (c == Conversion.DECIMAL_FLOAT) {
|
||||
// Create a new BigDecimal with the desired precision.
|
||||
int prec = (precision == -1 ? 6 : precision);
|
||||
int scale = value.scale();
|
||||
|
||||
if (scale > prec) {
|
||||
// more "scale" digits than the requested "precision"
|
||||
int compPrec = value.precision();
|
||||
if (compPrec <= scale) {
|
||||
// case of 0.xxxxxx
|
||||
value = value.setScale(prec, RoundingMode.HALF_UP);
|
||||
} else {
|
||||
compPrec -= (scale - prec);
|
||||
value = new BigDecimal(value.unscaledValue(),
|
||||
scale,
|
||||
new MathContext(compPrec));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们希望使用其他舍入方式来格式化字符串的话,可以设置DecimalFormat,如下代码所示:
|
||||
|
||||
```
|
||||
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进行。**
|
||||
|
||||
比如下面这段代码,使用BigDecimal来格式化数字3.35,分别使用向下舍入和四舍五入方式取1位小数进行格式化:
|
||||
|
||||
```
|
||||
BigDecimal num1 = new BigDecimal("3.35");
|
||||
BigDecimal num2 = num1.setScale(1, BigDecimal.ROUND_DOWN);
|
||||
System.out.println(num2);
|
||||
BigDecimal num3 = num1.setScale(1, BigDecimal.ROUND_HALF_UP);
|
||||
System.out.println(num3);
|
||||
|
||||
```
|
||||
|
||||
这次得到的结果是3.3和3.4,符合预期。
|
||||
|
||||
## 用equals做判等,就一定是对的吗?
|
||||
|
||||
现在我们知道了,应该使用BigDecimal来进行浮点数的表示、计算、格式化。在上一讲介绍[判等问题](https://time.geekbang.org/column/article/213604)时,我提到一个原则:包装类的比较要通过equals进行,而不能使用==。那么,使用equals方法对两个BigDecimal判等,一定能得到我们想要的结果吗?
|
||||
|
||||
我们来看下面的例子。使用equals方法比较1.0和1这两个BigDecimal:
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
/**
|
||||
* Compares this {@code BigDecimal} with the specified
|
||||
* {@code Object} for equality. Unlike {@link
|
||||
* #compareTo(BigDecimal) compareTo}, this method considers two
|
||||
* {@code BigDecimal} objects equal only if they are equal in
|
||||
* value and scale (thus 2.0 is not equal to 2.00 when compared by
|
||||
* this method).
|
||||
*
|
||||
* @param x {@code Object} to which this {@code BigDecimal} is
|
||||
* to be compared.
|
||||
* @return {@code true} if and only if the specified {@code Object} is a
|
||||
* {@code BigDecimal} whose value and scale are equal to this
|
||||
* {@code BigDecimal}'s.
|
||||
* @see #compareTo(java.math.BigDecimal)
|
||||
* @see #hashCode
|
||||
*/
|
||||
@Override
|
||||
public boolean equals(Object x)
|
||||
|
||||
```
|
||||
|
||||
**如果我们希望只比较BigDecimal的value,可以使用compareTo方法**,修改后代码如下:
|
||||
|
||||
```
|
||||
System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1"))==0);
|
||||
|
||||
```
|
||||
|
||||
学过上一讲,你可能会意识到BigDecimal的equals和hashCode方法会同时考虑value和scale,如果结合HashSet或HashMap使用的话就可能会出现麻烦。比如,我们把值为1.0的BigDecimal加入HashSet,然后判断其是否存在值为1的BigDecimal,得到的结果是false:
|
||||
|
||||
```
|
||||
Set<BigDecimal> hashSet1 = new HashSet<>();
|
||||
hashSet1.add(new BigDecimal("1.0"));
|
||||
System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false
|
||||
|
||||
```
|
||||
|
||||
解决这个问题的办法有两个:
|
||||
|
||||
- 第一个方法是,使用TreeSet替换HashSet。TreeSet不使用hashCode方法,也不使用equals比较元素,而是使用compareTo方法,所以不会有问题。
|
||||
|
||||
```
|
||||
Set<BigDecimal> treeSet = new TreeSet<>();
|
||||
treeSet.add(new BigDecimal("1.0"));
|
||||
System.out.println(treeSet.contains(new BigDecimal("1")));//返回true
|
||||
|
||||
```
|
||||
|
||||
- 第二个方法是,把BigDecimal存入HashSet或HashMap前,先使用stripTrailingZeros方法去掉尾部的零,比较的时候也去掉尾部的0,确保value相同的BigDecimal,scale也是一致的:
|
||||
|
||||
```
|
||||
Set<BigDecimal> hashSet2 = new HashSet<>();
|
||||
hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
|
||||
System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZeros()));//返回true
|
||||
|
||||
```
|
||||
|
||||
## 小心数值溢出问题
|
||||
|
||||
数值计算还有一个要小心的点是溢出,不管是int还是long,所有的基本数值类型都有超出表达范围的可能性。
|
||||
|
||||
比如,对Long的最大值进行+1操作:
|
||||
|
||||
```
|
||||
long l = Long.MAX_VALUE;
|
||||
System.out.println(l + 1);
|
||||
System.out.println(l + 1 == Long.MIN_VALUE);
|
||||
|
||||
```
|
||||
|
||||
输出结果是一个负数,因为Long的最大值+1变为了Long的最小值:
|
||||
|
||||
```
|
||||
-9223372036854775808
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
**显然这是发生了溢出,而且是默默地溢出,并没有任何异常**。这类问题非常容易被忽略,改进方式有下面2种。
|
||||
|
||||
方法一是,考虑使用Math类的addExact、subtractExact等xxExact方法进行数值运算,这些方法可以在数值溢出时主动抛出异常。我们来测试一下,使用Math.addExact对Long最大值做+1操作:
|
||||
|
||||
```
|
||||
try {
|
||||
long l = Long.MAX_VALUE;
|
||||
System.out.println(Math.addExact(l, 1));
|
||||
} catch (Exception ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行后,可以得到ArithmeticException,这是一个RuntimeException:
|
||||
|
||||
```
|
||||
java.lang.ArithmeticException: long overflow
|
||||
at java.lang.Math.addExact(Math.java:809)
|
||||
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.right2(CommonMistakesApplication.java:25)
|
||||
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.main(CommonMistakesApplication.java:13)
|
||||
|
||||
```
|
||||
|
||||
方法二是,使用大数类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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出结果如下:
|
||||
|
||||
```
|
||||
9223372036854775808
|
||||
java.lang.ArithmeticException: BigInteger out of long range
|
||||
at java.math.BigInteger.longValueExact(BigInteger.java:4632)
|
||||
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.right1(CommonMistakesApplication.java:37)
|
||||
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.main(CommonMistakesApplication.java:11)
|
||||
|
||||
```
|
||||
|
||||
可以看到,通过BigInteger对Long的最大值加1一点问题都没有,当尝试把结果转换为Long类型时,则会提示BigInteger out of long range。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我与你分享了浮点数的表示、计算、舍入和格式化、溢出等涉及的一些坑。
|
||||
|
||||
第一,切记,要精确表示浮点数应该使用BigDecimal。并且,使用BigDecimal的Double入参的构造方法同样存在精度丢失问题,应该使用String入参的构造方法或者BigDecimal.valueOf方法来初始化。
|
||||
|
||||
第二,对浮点数做精确计算,参与计算的各种数值应该始终使用BigDecimal,所有的计算都要通过BigDecimal的方法进行,切勿只是让BigDecimal来走过场。任何一个环节出现精度损失,最后的计算结果可能都会出现误差。
|
||||
|
||||
第三,对于浮点数的格式化,如果使用String.format的话,需要认识到它使用的是四舍五入,可以考虑使用DecimalFormat来明确指定舍入方式。但考虑到精度问题,我更建议使用BigDecimal来表示浮点数,并使用其setScale方法指定舍入的位数和方式。
|
||||
|
||||
第四,进行数值运算时要小心溢出问题,虽然溢出后不会出现异常,但得到的计算结果是完全错误的。我们考虑使用Math.xxxExact方法来进行运算,在溢出时能抛出异常,更建议对于可能会出现溢出的大数运算使用BigInteger类。
|
||||
|
||||
总之,对于金融、科学计算等场景,请尽可能使用BigDecimal和BigInteger,避免由精度和溢出问题引发难以发现,但影响重大的Bug。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. [BigDecimal](https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html)提供了8种舍入模式,你能通过一些例子说说它们的区别吗?
|
||||
1. 数据库(比如MySQL)中的浮点数和整型数字,你知道应该怎样定义吗?又如何实现浮点数的准确计算呢?
|
||||
|
||||
针对数值运算,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
559
极客时间专栏/Java业务开发常见错误100例/代码篇/10 | 集合类:坑满地的List列表操作.md
Normal file
559
极客时间专栏/Java业务开发常见错误100例/代码篇/10 | 集合类:坑满地的List列表操作.md
Normal file
@@ -0,0 +1,559 @@
|
||||
<audio id="audio" title="10 | 集合类:坑满地的List列表操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/c7/b5b350e25bdf6e625b4ee039f4c014c7.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来和你说说List列表操作有哪些坑。
|
||||
|
||||
Pascal之父尼克劳斯 · 维尔特(Niklaus Wirth),曾提出一个著名公式“程序=数据结构+算法”。由此可见,数据结构的重要性。常见的数据结构包括List、Set、Map、Queue、Tree、Graph、Stack等,其中List、Set、Map、Queue可以从广义上统称为集合类数据结构。
|
||||
|
||||
现代编程语言一般都会提供各种数据结构的实现,供我们开箱即用。Java也是一样,比如提供了集合类的各种实现。Java的集合类包括Map和Collection两大类。Collection包括List、Set和Queue三个小类,其中List列表集合是最重要也是所有业务代码都会用到的。所以,今天我会重点介绍List的内容,而不会集中介绍Map以及Collection中其他小类的坑。
|
||||
|
||||
今天,我们就从把数组转换为List集合、对List进行切片操作、List搜索的性能问题等几个方面着手,来聊聊其中最可能遇到的一些坑。
|
||||
|
||||
## 使用Arrays.asList把数据转换为List的三个坑
|
||||
|
||||
Java 8中Stream流式处理的各种功能,大大减少了集合类各种操作(投影、过滤、转换)的代码量。所以,在业务开发中,我们常常会把原始的数组转换为List类数据结构,来继续展开各种Stream操作。
|
||||
|
||||
你可能也想到了,使用Arrays.asList方法可以把数组一键转换为List,但其实没这么简单。接下来,就让我们看看其中的缘由,以及使用Arrays.asList把数组转换为List的几个坑。
|
||||
|
||||
在如下代码中,我们初始化三个数字的int[]数组,然后使用Arrays.asList把数组转换为List:
|
||||
|
||||
```
|
||||
int[] arr = {1, 2, 3};
|
||||
List list = Arrays.asList(arr);
|
||||
log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());
|
||||
|
||||
```
|
||||
|
||||
但,这样初始化的List并不是我们期望的包含3个数字的List。通过日志可以发现,这个List包含的其实是一个int数组,整个List的元素个数是1,元素类型是整数数组。
|
||||
|
||||
```
|
||||
12:50:39.445 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - list:[[I@1c53fd30] size:1 class:class [I
|
||||
|
||||
```
|
||||
|
||||
其原因是,只能是把int装箱为Integer,不可能把int数组装箱为Integer数组。我们知道,Arrays.asList方法传入的是一个泛型T类型可变参数,最终int数组整体作为了一个对象成为了泛型类型T:
|
||||
|
||||
```
|
||||
public static <T> List<T> asList(T... a) {
|
||||
return new ArrayList<>(a);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
直接遍历这样的List必然会出现Bug,修复方式有两种,如果使用Java8以上版本可以使用Arrays.stream方法来转换,否则可以把int数组声明为包装类型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());
|
||||
|
||||
```
|
||||
|
||||
修复后的代码得到如下日志,可以看到List具有三个元素,元素类型是Integer:
|
||||
|
||||
```
|
||||
13:10:57.373 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - list:[1, 2, 3] size:3 class:class java.lang.Integer
|
||||
|
||||
```
|
||||
|
||||
可以看到第一个坑是,**不能直接使用Arrays.asList来转换基本类型数组**。那么,我们获得了正确的List,是不是就可以像普通的List那样使用了呢?我们继续往下看。
|
||||
|
||||
把三个字符串1、2、3构成的字符串数组,使用Arrays.asList转换为List后,将原始字符串数组的第二个字符修改为4,然后为List增加一个字符串5,最后数组和List会是怎样呢?
|
||||
|
||||
```
|
||||
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);
|
||||
|
||||
```
|
||||
|
||||
可以看到,日志里有一个UnsupportedOperationException,为List新增字符串5的操作失败了,而且把原始数组的第二个元素从2修改为4后,asList获得的List中的第二个元素也被修改为4了:
|
||||
|
||||
```
|
||||
java.lang.UnsupportedOperationException
|
||||
at java.util.AbstractList.add(AbstractList.java:148)
|
||||
at java.util.AbstractList.add(AbstractList.java:108)
|
||||
at org.geekbang.time.commonmistakes.collection.aslist.AsListApplication.wrong2(AsListApplication.java:41)
|
||||
at org.geekbang.time.commonmistakes.collection.aslist.AsListApplication.main(AsListApplication.java:15)
|
||||
13:15:34.699 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - arr:[1, 4, 3] list:[1, 4, 3]
|
||||
|
||||
```
|
||||
|
||||
这里,又引出了两个坑。
|
||||
|
||||
第二个坑,**Arrays.asList返回的List不支持增删操作。**Arrays.asList返回的List并不是我们期望的java.util.ArrayList,而是Arrays的内部类ArrayList。ArrayList内部类继承自AbstractList类,并没有覆写父类的add方法,而父类中add方法的实现,就是抛出UnsupportedOperationException。相关源码如下所示:
|
||||
|
||||
```
|
||||
public static <T> List<T> asList(T... a) {
|
||||
return new ArrayList<>(a);
|
||||
}
|
||||
|
||||
private static class ArrayList<E> extends AbstractList<E>
|
||||
implements RandomAccess, java.io.Serializable
|
||||
{
|
||||
private final E[] a;
|
||||
|
||||
|
||||
ArrayList(E[] array) {
|
||||
a = Objects.requireNonNull(array);
|
||||
}
|
||||
...
|
||||
|
||||
@Override
|
||||
public E set(int index, E element) {
|
||||
E oldValue = a[index];
|
||||
a[index] = element;
|
||||
return oldValue;
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
|
||||
...
|
||||
public void add(int index, E element) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第三个坑,**对原始数组的修改会影响到我们获得的那个List**。看一下ArrayList的实现,可以发现ArrayList其实是直接使用了原始的数组。所以,我们要特别小心,把通过Arrays.asList获得的List交给其他方法处理,很容易因为共享了数组,相互修改产生Bug。
|
||||
|
||||
修复方式比较简单,重新new一个ArrayList初始化Arrays.asList返回的List即可:
|
||||
|
||||
```
|
||||
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);
|
||||
|
||||
```
|
||||
|
||||
修改后的代码实现了原始数组和List的“解耦”,不再相互影响。同时,因为操作的是真正的ArrayList,add也不再出错:
|
||||
|
||||
```
|
||||
13:34:50.829 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - arr:[1, 4, 3] list:[1, 2, 3, 5]
|
||||
|
||||
```
|
||||
|
||||
## 使用List.subList进行切片操作居然会导致OOM?
|
||||
|
||||
业务开发时常常要对List做切片处理,即取出其中部分元素构成一个新的List,我们通常会想到使用List.subList方法。但,和Arrays.asList的问题类似,List.subList返回的子List不是一个普通的ArrayList。这个子List可以认为是原始List的视图,会和原始List相互影响。如果不注意,很可能会因此产生OOM问题。接下来,我们就一起分析下其中的坑。
|
||||
|
||||
如下代码所示,定义一个名为data的静态List来存放Integer的List,也就是说data的成员本身是包含了多个数字的List。循环1000次,每次都从一个具有10万个Integer的List中,使用subList方法获得一个只包含一个数字的子List,并把这个子List加入data变量:
|
||||
|
||||
```
|
||||
private static List<List<Integer>> data = new ArrayList<>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可能会觉得,这个data变量里面最终保存的只是1000个具有1个元素的List,不会占用很大空间,但程序运行不久就出现了OOM:
|
||||
|
||||
```
|
||||
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
|
||||
at java.util.Arrays.copyOf(Arrays.java:3181)
|
||||
at java.util.ArrayList.grow(ArrayList.java:265)
|
||||
|
||||
```
|
||||
|
||||
**出现OOM的原因是,循环中的1000个具有10万个元素的List始终得不到回收,因为它始终被subList方法返回的List强引用。**那么,返回的子List为什么会强引用原始的List,它们又有什么关系呢?我们再继续做实验观察一下这个子List的特性。
|
||||
|
||||
首先初始化一个包含数字1到10的ArrayList,然后通过调用subList方法取出2、3、4;随后删除这个SubList中的元素数字3,并打印原始的ArrayList;最后为原始的ArrayList增加一个元素数字0,遍历SubList输出所有元素:
|
||||
|
||||
```
|
||||
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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码运行后得到如下输出:
|
||||
|
||||
```
|
||||
[2, 3, 4]
|
||||
[1, 2, 4, 5, 6, 7, 8, 9, 10]
|
||||
java.util.ConcurrentModificationException
|
||||
at java.util.ArrayList$SubList.checkForComodification(ArrayList.java:1239)
|
||||
at java.util.ArrayList$SubList.listIterator(ArrayList.java:1099)
|
||||
at java.util.AbstractList.listIterator(AbstractList.java:299)
|
||||
at java.util.ArrayList$SubList.iterator(ArrayList.java:1095)
|
||||
at java.lang.Iterable.forEach(Iterable.java:74)
|
||||
|
||||
```
|
||||
|
||||
可以看到两个现象:
|
||||
|
||||
- 原始List中数字3被删除了,说明删除子List中的元素影响到了原始List;
|
||||
- 尝试为原始List增加数字0之后再遍历子List,会出现ConcurrentModificationException。
|
||||
|
||||
我们分析下ArrayList的源码,看看为什么会是这样。
|
||||
|
||||
```
|
||||
public class ArrayList<E> extends AbstractList<E>
|
||||
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
|
||||
{
|
||||
protected transient int modCount = 0;
|
||||
private void ensureExplicitCapacity(int minCapacity) {
|
||||
modCount++;
|
||||
// overflow-conscious code
|
||||
if (minCapacity - elementData.length > 0)
|
||||
grow(minCapacity);
|
||||
}
|
||||
public void add(int index, E element) {
|
||||
rangeCheckForAdd(index);
|
||||
|
||||
ensureCapacityInternal(size + 1); // Increments modCount!!
|
||||
System.arraycopy(elementData, index, elementData, index + 1,
|
||||
size - index);
|
||||
elementData[index] = element;
|
||||
size++;
|
||||
}
|
||||
|
||||
public List<E> subList(int fromIndex, int toIndex) {
|
||||
subListRangeCheck(fromIndex, toIndex, size);
|
||||
return new SubList(this, offset, fromIndex, toIndex);
|
||||
}
|
||||
|
||||
private class SubList extends AbstractList<E> implements RandomAccess {
|
||||
private final AbstractList<E> parent;
|
||||
private final int parentOffset;
|
||||
private final int offset;
|
||||
int size;
|
||||
|
||||
SubList(AbstractList<E> parent,
|
||||
int offset, int fromIndex, int toIndex) {
|
||||
this.parent = parent;
|
||||
this.parentOffset = fromIndex;
|
||||
this.offset = offset + fromIndex;
|
||||
this.size = toIndex - fromIndex;
|
||||
this.modCount = ArrayList.this.modCount;
|
||||
}
|
||||
|
||||
public E set(int index, E element) {
|
||||
rangeCheck(index);
|
||||
checkForComodification();
|
||||
return l.set(index+offset, element);
|
||||
}
|
||||
|
||||
public ListIterator<E> listIterator(final int index) {
|
||||
checkForComodification();
|
||||
...
|
||||
}
|
||||
|
||||
private void checkForComodification() {
|
||||
if (ArrayList.this.modCount != this.modCount)
|
||||
throw new ConcurrentModificationException();
|
||||
}
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第一,ArrayList维护了一个叫作modCount的字段,表示集合结构性修改的次数。所谓结构性修改,指的是影响List大小的修改,所以add操作必然会改变modCount的值。
|
||||
|
||||
第二,分析第21到24行的subList方法可以看到,获得的List其实是**内部类SubList**,并不是普通的ArrayList,在初始化的时候传入了this。
|
||||
|
||||
第三,分析第26到39行代码可以发现,这个SubList中的parent字段就是原始的List。SubList初始化的时候,并没有把原始List中的元素复制到独立的变量中保存。我们可以认为SubList是原始List的视图,并不是独立的List。双方对元素的修改会相互影响,而且SubList强引用了原始的List,所以大量保存这样的SubList会导致OOM。
|
||||
|
||||
第四,分析第47到55行代码可以发现,遍历SubList的时候会先获得迭代器,比较原始ArrayList modCount的值和SubList当前modCount的值。获得了SubList后,我们为原始List新增了一个元素修改了其modCount,所以判等失败抛出ConcurrentModificationException异常。
|
||||
|
||||
既然SubList相当于原始List的视图,那么避免相互影响的修复方式有两种:
|
||||
|
||||
- 一种是,不直接使用subList方法返回的SubList,而是重新使用new ArrayList,在构造方法传入SubList,来构建一个独立的ArrayList;
|
||||
- 另一种是,对于Java 8使用Stream的skip和limit API来跳过流中的元素,以及限制流中元素的个数,同样可以达到SubList切片的目的。
|
||||
|
||||
```
|
||||
//方式一:
|
||||
List<Integer> subList = new ArrayList<>(list.subList(1, 4));
|
||||
|
||||
//方式二:
|
||||
List<Integer> subList = list.stream().skip(1).limit(3).collect(Collectors.toList());
|
||||
|
||||
```
|
||||
|
||||
修复后代码输出如下:
|
||||
|
||||
```
|
||||
[2, 3, 4]
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
2
|
||||
4
|
||||
|
||||
```
|
||||
|
||||
可以看到,删除SubList的元素不再影响原始List,而对原始List的修改也不会再出现List迭代异常。
|
||||
|
||||
## 一定要让合适的数据结构做合适的事情
|
||||
|
||||
在介绍[并发工具](https://time.geekbang.org/column/article/209494)时,我提到要根据业务场景选择合适的并发工具或容器。在使用List集合类的时候,不注意使用场景也会遇见两个常见误区。
|
||||
|
||||
**第一个误区是,使用数据结构不考虑平衡时间和空间**。
|
||||
|
||||
首先,定义一个只有一个int类型订单号字段的Order类:
|
||||
|
||||
```
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
static class Order {
|
||||
private int orderId;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,定义一个包含elementCount和loopCount两个参数的listSearch方法,初始化一个具有elementCount个订单对象的ArrayList,循环loopCount次搜索这个ArrayList,每次随机搜索一个订单号:
|
||||
|
||||
```
|
||||
private static Object listSearch(int elementCount, int loopCount) {
|
||||
List<Order> list = IntStream.rangeClosed(1, elementCount).mapToObj(i -> new Order(i)).collect(Collectors.toList());
|
||||
IntStream.rangeClosed(1, loopCount).forEach(i -> {
|
||||
int search = ThreadLocalRandom.current().nextInt(elementCount);
|
||||
Order result = list.stream().filter(order -> order.getOrderId() == search).findFirst().orElse(null);
|
||||
Assert.assertTrue(result != null && result.getOrderId() == search);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
随后,定义另一个mapSearch方法,从一个具有elementCount个元素的Map中循环loopCount次查找随机订单号。Map的Key是订单号,Value是订单对象:
|
||||
|
||||
```
|
||||
private static Object mapSearch(int elementCount, int loopCount) {
|
||||
Map<Integer, Order> map = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toMap(Function.identity(), i -> new Order(i)));
|
||||
IntStream.rangeClosed(1, loopCount).forEach(i -> {
|
||||
int search = ThreadLocalRandom.current().nextInt(elementCount);
|
||||
Order result = map.get(search);
|
||||
Assert.assertTrue(result != null && result.getOrderId() == search);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们知道,搜索ArrayList的时间复杂度是O(n),而HashMap的get操作的时间复杂度是O(1)。**所以,要对大List进行单值搜索的话,可以考虑使用HashMap,其中Key是要搜索的值,Value是原始对象,会比使用ArrayList有非常明显的性能优势。**
|
||||
|
||||
如下代码所示,对100万个元素的ArrayList和HashMap,分别调用listSearch和mapSearch方法进行1000次搜索:
|
||||
|
||||
```
|
||||
int elementCount = 1000000;
|
||||
int loopCount = 1000;
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
stopWatch.start("listSearch");
|
||||
Object list = listSearch(elementCount, loopCount);
|
||||
System.out.println(ObjectSizeCalculator.getObjectSize(list));
|
||||
stopWatch.stop();
|
||||
stopWatch.start("mapSearch");
|
||||
Object map = mapSearch(elementCount, loopCount);
|
||||
stopWatch.stop();
|
||||
System.out.println(ObjectSizeCalculator.getObjectSize(map));
|
||||
System.out.println(stopWatch.prettyPrint());
|
||||
|
||||
```
|
||||
|
||||
可以看到,仅仅是1000次搜索,listSearch方法耗时3.3秒,而mapSearch耗时仅仅108毫秒。
|
||||
|
||||
```
|
||||
20861992
|
||||
72388672
|
||||
StopWatch '': running time = 3506699764 ns
|
||||
---------------------------------------------
|
||||
ns % Task name
|
||||
---------------------------------------------
|
||||
3398413176 097% listSearch
|
||||
108286588 003% mapSearch
|
||||
|
||||
```
|
||||
|
||||
即使我们要搜索的不是单值而是条件区间,也可以尝试使用HashMap来进行“搜索性能优化”。如果你的条件区间是固定的话,可以提前把HashMap按照条件区间进行分组,Key就是不同的区间。
|
||||
|
||||
的确,如果业务代码中有频繁的大ArrayList搜索,使用HashMap性能会好很多。类似,如果要对大ArrayList进行去重操作,也不建议使用contains方法,而是可以考虑使用HashSet进行去重。说到这里,还有一个问题,使用HashMap是否会牺牲空间呢?
|
||||
|
||||
为此,我们使用ObjectSizeCalculator工具打印ArrayList和HashMap的内存占用,可以看到ArrayList占用内存21M,而HashMap占用的内存达到了72M,是List的三倍多。进一步使用MAT工具分析堆可以再次证明,ArrayList在内存占用上性价比很高,77%是实际的数据(如第1个图所示,16000000/20861992),**而HashMap的“含金量”只有22%**(如第2个图所示,16000000/72386640)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/24/1e8492040dd4b1af6114a6eeba06e524.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/c7/53d53e3ce2efcb081f8d9fa496cb8ec7.png" alt="">
|
||||
|
||||
所以,在应用内存吃紧的情况下,我们需要考虑是否值得使用更多的内存消耗来换取更高的性能。这里我们看到的是平衡的艺术,空间换时间,还是时间换空间,只考虑任何一个方面都是不对的。
|
||||
|
||||
**第二个误区是,过于迷信教科书的大O时间复杂度**。
|
||||
|
||||
数据结构中要实现一个列表,有基于连续存储的数组和基于指针串联的链表两种方式。在Java中,有代表性的实现是ArrayList和LinkedList,前者背后的数据结构是数组,后者则是(双向)链表。
|
||||
|
||||
在选择数据结构的时候,我们通常会考虑每种数据结构不同操作的时间复杂度,以及使用场景两个因素。查看[这里](https://www.bigocheatsheet.com/),你可以看到数组和链表大O时间复杂度的显著差异:
|
||||
|
||||
- 对于数组,随机元素访问的时间复杂度是O(1),元素插入操作是O(n);
|
||||
- 对于链表,随机元素访问的时间复杂度是O(n),元素插入操作是O(1)。
|
||||
|
||||
那么,在大量的元素插入、很少的随机访问的业务场景下,是不是就应该使用LinkedList呢?接下来,我们写一段代码测试下两者随机访问和插入的性能吧。
|
||||
|
||||
定义四个参数一致的方法,分别对元素个数为elementCount的LinkedList和ArrayList,循环loopCount次,进行随机访问和增加元素到随机位置的操作:
|
||||
|
||||
```
|
||||
//LinkedList访问
|
||||
private static void linkedListGet(int elementCount, int loopCount) {
|
||||
List<Integer> list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(LinkedList::new));
|
||||
IntStream.rangeClosed(1, loopCount).forEach(i -> list.get(ThreadLocalRandom.current().nextInt(elementCount)));
|
||||
}
|
||||
|
||||
//ArrayList访问
|
||||
private static void arrayListGet(int elementCount, int loopCount) {
|
||||
List<Integer> list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(ArrayList::new));
|
||||
IntStream.rangeClosed(1, loopCount).forEach(i -> list.get(ThreadLocalRandom.current().nextInt(elementCount)));
|
||||
}
|
||||
|
||||
//LinkedList插入
|
||||
private static void linkedListAdd(int elementCount, int loopCount) {
|
||||
List<Integer> list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(LinkedList::new));
|
||||
IntStream.rangeClosed(1, loopCount).forEach(i -> list.add(ThreadLocalRandom.current().nextInt(elementCount),1));
|
||||
}
|
||||
|
||||
//ArrayList插入
|
||||
private static void arrayListAdd(int elementCount, int loopCount) {
|
||||
List<Integer> list = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(ArrayList::new));
|
||||
IntStream.rangeClosed(1, loopCount).forEach(i -> list.add(ThreadLocalRandom.current().nextInt(elementCount),1));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试代码如下,10万个元素,循环10万次:
|
||||
|
||||
```
|
||||
int elementCount = 100000;
|
||||
int loopCount = 100000;
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
stopWatch.start("linkedListGet");
|
||||
linkedListGet(elementCount, loopCount);
|
||||
stopWatch.stop();
|
||||
stopWatch.start("arrayListGet");
|
||||
arrayListGet(elementCount, loopCount);
|
||||
stopWatch.stop();
|
||||
System.out.println(stopWatch.prettyPrint());
|
||||
|
||||
|
||||
StopWatch stopWatch2 = new StopWatch();
|
||||
stopWatch2.start("linkedListAdd");
|
||||
linkedListAdd(elementCount, loopCount);
|
||||
stopWatch2.stop();
|
||||
stopWatch2.start("arrayListAdd");
|
||||
arrayListAdd(elementCount, loopCount);
|
||||
stopWatch2.stop();
|
||||
System.out.println(stopWatch2.prettyPrint());
|
||||
|
||||
```
|
||||
|
||||
运行结果可能会让你大跌眼镜。在随机访问方面,我们看到了ArrayList的绝对优势,耗时只有11毫秒,而LinkedList耗时6.6秒,这符合上面我们所说的时间复杂度;**但,随机插入操作居然也是LinkedList落败,耗时9.3秒,ArrayList只要1.5秒**:
|
||||
|
||||
```
|
||||
---------------------------------------------
|
||||
ns % Task name
|
||||
---------------------------------------------
|
||||
6604199591 100% linkedListGet
|
||||
011494583 000% arrayListGet
|
||||
|
||||
|
||||
StopWatch '': running time = 10729378832 ns
|
||||
---------------------------------------------
|
||||
ns % Task name
|
||||
---------------------------------------------
|
||||
9253355484 086% linkedListAdd
|
||||
1476023348 014% arrayListAdd
|
||||
|
||||
```
|
||||
|
||||
翻看LinkedList源码发现,插入操作的时间复杂度是O(1)的前提是,你已经有了那个要插入节点的指针。但,在实现的时候,我们需要先通过循环获取到那个节点的Node,然后再执行插入操作。前者也是有开销的,不可能只考虑插入操作本身的代价:
|
||||
|
||||
```
|
||||
public void add(int index, E element) {
|
||||
checkPositionIndex(index);
|
||||
|
||||
if (index == size)
|
||||
linkLast(element);
|
||||
else
|
||||
linkBefore(element, node(index));
|
||||
}
|
||||
|
||||
Node<E> node(int index) {
|
||||
// assert isElementIndex(index);
|
||||
|
||||
if (index < (size >> 1)) {
|
||||
Node<E> x = first;
|
||||
for (int i = 0; i < index; i++)
|
||||
x = x.next;
|
||||
return x;
|
||||
} else {
|
||||
Node<E> x = last;
|
||||
for (int i = size - 1; i > index; i--)
|
||||
x = x.prev;
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以,对于插入操作,LinkedList的时间复杂度其实也是O(n)。继续做更多实验的话你会发现,在各种常用场景下,LinkedList几乎都不能在性能上胜出ArrayList。
|
||||
|
||||
讽刺的是,LinkedList的作者约书亚 · 布洛克(Josh Bloch),在其推特上回复别人时说,虽然LinkedList是我写的但我从来不用,有谁会真的用吗?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/cc/122a469eb03f16ab61d893ec57b34acc.png" alt="">
|
||||
|
||||
这告诉我们,任何东西理论上和实际上是有差距的,请勿迷信教科书的理论,最好在下定论之前实际测试一下。抛开算法层面不谈,由于CPU缓存、内存连续性等问题,链表这种数据结构的实现方式对性能并不友好,即使在它最擅长的场景都不一定可以发挥威力。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我分享了若干和List列表相关的错误案例,基本都是由“想当然”导致的。
|
||||
|
||||
第一,想当然认为,Arrays.asList和List.subList得到的List是普通的、独立的ArrayList,在使用时出现各种奇怪的问题。
|
||||
|
||||
- Arrays.asList得到的是Arrays的内部类ArrayList,List.subList得到的是ArrayList的内部类SubList,不能把这两个内部类转换为ArrayList使用。
|
||||
- Arrays.asList直接使用了原始数组,可以认为是共享“存储”,而且不支持增删元素;List.subList直接引用了原始的List,也可以认为是共享“存储”,而且对原始List直接进行结构性修改会导致SubList出现异常。
|
||||
- 对Arrays.asList和List.subList容易忽略的是,新的List持有了原始数据的引用,可能会导致原始数据也无法GC的问题,最终导致OOM。
|
||||
|
||||
第二,想当然认为,Arrays.asList一定可以把所有数组转换为正确的List。当传入基本类型数组的时候,List的元素是数组本身,而不是数组中的元素。
|
||||
|
||||
第三,想当然认为,内存中任何集合的搜索都是很快的,结果在搜索超大ArrayList的时候遇到性能问题。我们考虑利用HashMap哈希表随机查找的时间复杂度为O(1)这个特性来优化性能,不过也要考虑HashMap存储空间上的代价,要平衡时间和空间。
|
||||
|
||||
第四,想当然认为,链表适合元素增删的场景,选用LinkedList作为数据结构。在真实场景中读写增删一般是平衡的,而且增删不可能只是对头尾对象进行操作,可能在90%的情况下都得不到性能增益,建议使用之前通过性能测试评估一下。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
最后,我给你留下与ArrayList在删除元素方面的坑有关的两个思考题吧。
|
||||
|
||||
1. 调用类型是Integer的ArrayList的remove方法删除元素,传入一个Integer包装类的数字和传入一个int基本类型的数字,结果一样吗?
|
||||
1. 循环遍历List,调用remove方法删除元素,往往会遇到ConcurrentModificationException异常,原因是什么,修复方式又是什么呢?
|
||||
|
||||
你还遇到过与集合类相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
450
极客时间专栏/Java业务开发常见错误100例/代码篇/11 | 空值处理:分不清楚的null和恼人的空指针.md
Normal file
450
极客时间专栏/Java业务开发常见错误100例/代码篇/11 | 空值处理:分不清楚的null和恼人的空指针.md
Normal file
@@ -0,0 +1,450 @@
|
||||
<audio id="audio" title="11 | 空值处理:分不清楚的null和恼人的空指针" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/a5/e1b83e296fe174074b0db02ec34723a5.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我要和你分享的主题是,空值处理:分不清楚的null和恼人的空指针。
|
||||
|
||||
有一天我收到一条短信,内容是“尊敬的null你好,XXX”。当时我就笑了,这是程序员都能Get的笑点,程序没有获取到我的姓名,然后把空格式化为了null。很明显,这是没处理好null。哪怕把null替换为贵宾、顾客,也不会引发这样的笑话。
|
||||
|
||||
程序中的变量是null,就意味着它没有引用指向或者说没有指针。这时,我们对这个变量进行任何操作,都必然会引发空指针异常,在Java中就是NullPointerException。那么,空指针异常容易在哪些情况下出现,又应该如何修复呢?
|
||||
|
||||
空指针异常虽然恼人但好在容易定位,更麻烦的是要弄清楚null的含义。比如,客户端给服务端的一个数据是null,那么其意图到底是给一个空值,还是没提供值呢?再比如,数据库中字段的NULL值,是否有特殊的含义呢,针对数据库中的NULL值,写SQL需要特别注意什么呢?
|
||||
|
||||
今天,就让我们带着这些问题开始null的踩坑之旅吧。
|
||||
|
||||
## 修复和定位恼人的空指针问题
|
||||
|
||||
**NullPointerException是Java代码中最常见的异常,我将其最可能出现的场景归为以下5种**:
|
||||
|
||||
- 参数值是Integer等包装类型,使用时因为自动拆箱出现了空指针异常;
|
||||
- 字符串比较出现空指针异常;
|
||||
- 诸如ConcurrentHashMap这样的容器不支持Key和Value为null,强行put null的Key或Value会出现空指针异常;
|
||||
- A对象包含了B,在通过A对象的字段获得B之后,没有对字段判空就级联调用B的方法出现空指针异常;
|
||||
- 方法或远程服务返回的List不是空而是null,没有进行判空就直接调用List的方法出现空指针异常。
|
||||
|
||||
为模拟说明这5种场景,我写了一个wrongMethod方法,并用一个wrong方法来调用它。wrong方法的入参test是一个由0和1构成的、长度为4的字符串,第几位设置为1就代表第几个参数为null,用来控制wrongMethod方法的4个入参,以模拟各种空指针情况:
|
||||
|
||||
```
|
||||
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("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();
|
||||
}
|
||||
|
||||
class FooService {
|
||||
@Getter
|
||||
private BarService barService;
|
||||
|
||||
}
|
||||
|
||||
class BarService {
|
||||
String bar() {
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
很明显,这个案例出现空指针异常是因为变量是一个空指针,尝试获得变量的值或访问变量的成员会获得空指针异常。但,这个异常的定位比较麻烦。
|
||||
|
||||
在测试方法wrongMethod中,我们通过一行日志记录的操作,在一行代码中模拟了4处空指针异常:
|
||||
|
||||
- 对入参Integer i进行+1操作;
|
||||
- 对入参String s进行比较操作,判断内容是否等于"OK";
|
||||
- 对入参String s和入参String t进行比较操作,判断两者是否相等;
|
||||
- 对new出来的ConcurrentHashMap进行put操作,Key和Value都设置为null。
|
||||
|
||||
输出的异常信息如下:
|
||||
|
||||
```
|
||||
java.lang.NullPointerException: null
|
||||
at org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController.wrongMethod(AvoidNullPointerExceptionController.java:37)
|
||||
at org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController.wrong(AvoidNullPointerExceptionController.java:20)
|
||||
|
||||
```
|
||||
|
||||
这段信息确实提示了这行代码出现了空指针异常,但我们很难定位出到底是哪里出现了空指针,可能是把入参Integer拆箱为int的时候出现的,也可能是入参的两个字符串任意一个为null,也可能是因为把null加入了ConcurrentHashMap。
|
||||
|
||||
你可能会想到,要排查这样的问题,只要设置一个断点看一下入参即可。但,在真实的业务场景中,空指针问题往往是在特定的入参和代码分支下才会出现,本地难以重现。如果要排查生产上出现的空指针问题,设置代码断点不现实,通常是要么把代码进行拆分,要么增加更多的日志,但都比较麻烦。
|
||||
|
||||
在这里,我推荐使用阿里开源的Java故障诊断神器[Arthas](https://alibaba.github.io/arthas/)。Arthas简单易用功能强大,可以定位出大多数的Java生产问题。
|
||||
|
||||
接下来,我就和你演示下如何在30秒内知道wrongMethod方法的入参,从而定位到空指针到底是哪个入参引起的。如下截图中有三个红框,我先和你分析第二和第三个红框:
|
||||
|
||||
- 第二个红框表示,Arthas启动后被附加到了JVM进程;
|
||||
- 第三个红框表示,通过watch命令监控wrongMethod方法的入参。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/6b/e2d39e5da91a8258c5aab3691e515c6b.png" alt="">
|
||||
|
||||
watch命令的参数包括类名表达式、方法表达式和观察表达式。这里,我们设置观察类为AvoidNullPointerExceptionController,观察方法为wrongMethod,观察表达式为params表示观察入参:
|
||||
|
||||
```
|
||||
watch org.geekbang.time.commonmistakes.nullvalue.demo2.AvoidNullPointerExceptionController wrongMethod params
|
||||
|
||||
```
|
||||
|
||||
开启watch后,执行2次wrong方法分别设置test入参为1111和1101,也就是第一次传入wrongMethod的4个参数都为null,第二次传入的第1、2和4个参数为null。
|
||||
|
||||
配合图中第一和第四个红框可以看到,第二次调用时,第三个参数是字符串OK其他参数是null,Archas正确输出了方法的所有入参,这样我们很容易就能定位到空指针的问题了。
|
||||
|
||||
到这里,如果是简单的业务逻辑的话,你就可以定位到空指针异常了;如果是分支复杂的业务逻辑,你需要再借助stack命令来查看wrongMethod方法的调用栈,并配合watch命令查看各方法的入参,就可以很方便地定位到空指针的根源了。
|
||||
|
||||
下图演示了通过stack命令观察wrongMethod的调用路径:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/ef/6c9ac7f4345936ece0b0d31c1ad974ef.png" alt="">
|
||||
|
||||
如果你想了解Arthas各种命令的详细使用方法,可以[点击](https://alibaba.github.io/arthas/commands.html)这里查看。
|
||||
|
||||
接下来,我们看看如何修复上面出现的5种空指针异常。
|
||||
|
||||
其实,对于任何空指针异常的处理,最直白的方式是先判空后操作。不过,这只能让异常不再出现,我们还是要找到程序逻辑中出现的空指针究竟是来源于入参还是Bug:
|
||||
|
||||
- 如果是来源于入参,还要进一步分析入参是否合理等;
|
||||
- 如果是来源于Bug,那空指针不一定是纯粹的程序Bug,可能还涉及业务属性和接口调用规范等。
|
||||
|
||||
在这里,因为是Demo,所以我们只考虑纯粹的空指针判空这种修复方式。如果要先判空后处理,大多数人会想到使用if-else代码块。但,这种方式既增加代码量又会降低易读性,我们可以尝试利用Java 8的Optional类来消除这样的if-else逻辑,使用一行代码进行判空和处理。
|
||||
|
||||
修复思路如下:
|
||||
|
||||
- 对于Integer的判空,可以使用Optional.ofNullable来构造一个Optional<integer>,然后使用orElse(0)把null替换为默认值再进行+1操作。</integer>
|
||||
- 对于String和字面量的比较,可以把字面量放在前面,比如"OK".equals(s),这样即使s是null也不会出现空指针异常;而对于两个可能为null的字符串变量的equals比较,可以使用Objects.equals,它会做判空处理。
|
||||
- 对于ConcurrentHashMap,既然其Key和Value都不支持null,修复方式就是不要把null存进去。HashMap的Key和Value可以存入null,而ConcurrentHashMap看似是HashMap的线程安全版本,却不支持null值的Key和Value,这是容易产生误区的一个地方。
|
||||
- 对于类似fooService.getBarService().bar().equals(“OK”)的级联调用,需要判空的地方有很多,包括fooService、getBarService()方法的返回值,以及bar方法返回的字符串。如果使用if-else来判空的话可能需要好几行代码,但使用Optional的话一行代码就够了。
|
||||
- 对于rightMethod返回的List<string>,由于不能确认其是否为null,所以在调用size方法获得列表大小之前,同样可以使用Optional.ofNullable包装一下返回值,然后通过.orElse(Collections.emptyList())实现在List为null的时候获得一个空的List,最后再调用size方法。</string>
|
||||
|
||||
```
|
||||
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<>();
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过修复后,调用right方法传入1111,也就是给rightMethod的4个参数都设置为null,日志中也看不到任何空指针异常了:
|
||||
|
||||
```
|
||||
[21:43:40.619] [http-nio-45678-exec-2] [INFO ] [.AvoidNullPointerExceptionController:45 ] - result 1 false true null
|
||||
|
||||
```
|
||||
|
||||
但是,如果我们修改right方法入参为0000,即传给rightMethod方法的4个参数都不可能是null,最后日志中也无法出现OK字样。这又是为什么呢,BarService的bar方法不是返回了OK字符串吗?
|
||||
|
||||
我们还是用Arthas来定位问题,使用watch命令来观察方法rightMethod的入参,-x参数设置为2代表参数打印的深度为2层:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/82/0ce3c96788f243791cbd512aecfa6382.png" alt="">
|
||||
|
||||
可以看到,FooService中的barService字段为null,这样也就可以理解为什么最终出现这个Bug了。
|
||||
|
||||
这又引申出一个问题,**使用判空方式或Optional方式来避免出现空指针异常,不一定是解决问题的最好方式,空指针没出现可能隐藏了更深的Bug**。因此,解决空指针异常,还是要真正case by case地定位分析案例,然后再去做判空处理,而处理时也并不只是判断非空然后进行正常业务流程这么简单,同样需要考虑为空的时候是应该出异常、设默认值还是记录日志等。
|
||||
|
||||
## POJO中属性的null到底代表了什么?
|
||||
|
||||
在我看来,相比判空避免空指针异常,更容易出错的是null的定位问题。对程序来说,null就是指针没有任何指向,而结合业务逻辑情况就复杂得多,我们需要考虑:
|
||||
|
||||
- DTO中字段的null到底意味着什么?是客户端没有传给我们这个信息吗?
|
||||
- 既然空指针问题很讨厌,那么DTO中的字段要设置默认值么?
|
||||
- 如果数据库实体中的字段有null,那么通过数据访问框架保存数据是否会覆盖数据库中的既有数据?
|
||||
|
||||
如果不能明确地回答这些问题,那么写出的程序逻辑很可能会混乱不堪。接下来,我们看一个实际案例吧。
|
||||
|
||||
有一个User的POJO,同时扮演DTO和数据库Entity角色,包含用户ID、姓名、昵称、年龄、注册时间等属性:
|
||||
|
||||
```
|
||||
@Data
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = IDENTITY)
|
||||
private Long id;
|
||||
private String name;
|
||||
private String nickname;
|
||||
private Integer age;
|
||||
private Date createDate = new Date();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有一个Post接口用于更新用户数据,更新逻辑非常简单,根据用户姓名自动设置一个昵称,昵称的规则是“用户类型+姓名”,然后直接把客户端在RequestBody中使用JSON传过来的User对象通过JPA更新到数据库中,最后返回保存到数据库的数据。
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@PostMapping("wrong")
|
||||
public User wrong(@RequestBody User user) {
|
||||
user.setNickname(String.format("guest%s", user.getName()));
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,在数据库中初始化一个用户,age=36、name=zhuye、create_date=2020年1月4日、nickname是NULL:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/67/de1bcb580ea63505a8e093c51c4cd567.png" alt="">
|
||||
|
||||
然后,使用cURL测试一下用户信息更新接口Post,传入一个id=1、name=null的JSON字符串,期望把ID为1的用户姓名设置为空:
|
||||
|
||||
```
|
||||
curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "name":null}' http://localhost:45678/pojonull/wrong
|
||||
|
||||
{"id":1,"name":null,"nickname":"guestnull","age":null,"createDate":"2020-01-05T02:01:03.784+0000"}%
|
||||
|
||||
```
|
||||
|
||||
接口返回的结果和数据库中记录一致:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/fd/af9c07a63ba837683ad059a6afcceafd.png" alt="">
|
||||
|
||||
可以看到,这里存在如下三个问题:
|
||||
|
||||
- 调用方只希望重置用户名,但age也被设置为了null;
|
||||
- nickname是用户类型加姓名,name重置为null的话,访客用户的昵称应该是guest,而不是guestnull,重现了文首提到的那个笑点;
|
||||
- 用户的创建时间原来是1月4日,更新了用户信息后变为了1月5日。
|
||||
|
||||
归根结底,这是如下5个方面的问题:
|
||||
|
||||
- 明确DTO中null的含义。**对于JSON到DTO的反序列化过程,null的表达是有歧义的,客户端不传某个属性,或者传null,这个属性在DTO中都是null。**但,对于用户信息更新操作,不传意味着客户端不需要更新这个属性,维持数据库原先的值;传了null,意味着客户端希望重置这个属性。因为Java中的null就是没有这个数据,无法区分这两种表达,所以本例中的age属性也被设置为了null,或许我们可以借助Optional来解决这个问题。
|
||||
- **POJO中的字段有默认值。如果客户端不传值,就会赋值为默认值,导致创建时间也被更新到了数据库中。**
|
||||
- **注意字符串格式化时可能会把null值格式化为null字符串。**比如昵称的设置,我们只是进行了简单的字符串格式化,存入数据库变为了guestnull。显然,这是不合理的,也是开头我们说的笑话的来源,还需要进行判断。
|
||||
- **DTO和Entity共用了一个POJO**。对于用户昵称的设置是程序控制的,我们不应该把它们暴露在DTO中,否则很容易把客户端随意设置的值更新到数据库中。此外,创建时间最好让数据库设置为当前时间,不用程序控制,可以通过在字段上设置columnDefinition来实现。
|
||||
- **数据库字段允许保存null,会进一步增加出错的可能性和复杂度**。因为如果数据真正落地的时候也支持NULL的话,可能就有NULL、空字符串和字符串null三种状态。这一点我会在下一小节展开。如果所有属性都有默认值,问题会简单一点。
|
||||
|
||||
按照这个思路,我们对DTO和Entity进行拆分,修改后代码如下所示:
|
||||
|
||||
- UserDto中只保留id、name和age三个属性,且name和age使用Optional来包装,以区分客户端不传数据还是故意传null。
|
||||
- 在UserEntity的字段上使用@Column注解,把数据库字段name、nickname、age和createDate都设置为NOT NULL,并设置createDate的默认值为CURRENT_TIMESTAMP,由数据库来生成创建时间。
|
||||
- 使用Hibernate的@DynamicUpdate注解实现更新SQL的动态生成,实现只更新修改后的字段,不过需要先查询一次实体,让Hibernate可以“跟踪”实体属性的当前状态,以确保有效。
|
||||
|
||||
```
|
||||
@Data
|
||||
public class UserDto {
|
||||
private Long id;
|
||||
private Optional<String> name;
|
||||
private Optional<Integer> age;
|
||||
;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@DynamicUpdate
|
||||
public class UserEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = IDENTITY)
|
||||
private Long id;
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
@Column(nullable = false)
|
||||
private String nickname;
|
||||
@Column(nullable = false)
|
||||
private Integer age;
|
||||
@Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
||||
private Date createDate;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在重构了DTO和Entity后,我们重新定义一个right接口,以便对更新操作进行更精细化的处理。首先是参数校验:
|
||||
|
||||
- 对传入的UserDto和ID属性先判空,如果为空直接抛出IllegalArgumentException。
|
||||
- 根据id从数据库中查询出实体后进行判空,如果为空直接抛出IllegalArgumentException。
|
||||
|
||||
然后,由于DTO中已经巧妙使用了Optional来区分客户端不传值和传null值,那么业务逻辑实现上就可以按照客户端的意图来分别实现逻辑。如果不传值,那么Optional本身为null,直接跳过Entity字段的更新即可,这样动态生成的SQL就不会包含这个列;如果传了值,那么进一步判断传的是不是null。
|
||||
|
||||
下面,我们根据业务需要分别对姓名、年龄和昵称进行更新:
|
||||
|
||||
- 对于姓名,我们认为客户端传null是希望把姓名重置为空,允许这样的操作,使用Optional的orElse方法一键把空转换为空字符串即可。
|
||||
- 对于年龄,我们认为如果客户端希望更新年龄就必须传一个有效的年龄,年龄不存在重置操作,可以使用Optional的orElseThrow方法在值为空的时候抛出IllegalArgumentException。
|
||||
- 对于昵称,因为数据库中姓名不可能为null,所以可以放心地把昵称设置为guest加上数据库取出来的姓名。
|
||||
|
||||
```
|
||||
@PostMapping("right")
|
||||
public UserEntity right(@RequestBody UserDto user) {
|
||||
if (user == null || user.getId() == null)
|
||||
throw new IllegalArgumentException("用户Id不能为空");
|
||||
|
||||
UserEntity userEntity = userEntityRepository.findById(user.getId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("用户不存在"));
|
||||
|
||||
if (user.getName() != null) {
|
||||
userEntity.setName(user.getName().orElse(""));
|
||||
}
|
||||
userEntity.setNickname("guest" + userEntity.getName());
|
||||
if (user.getAge() != null) {
|
||||
userEntity.setAge(user.getAge().orElseThrow(() -> new IllegalArgumentException("年龄不能为空")));
|
||||
}
|
||||
return userEntityRepository.save(userEntity);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
假设数据库中已经有这么一条记录,id=1、age=36、create_date=2020年1月4日、name=zhuye、nickname=guestzhuye:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/47/5f1d46ea87f37a570b32f94ac44ca947.png" alt="">
|
||||
|
||||
使用相同的参数调用right接口,再来试试是否解决了所有问题。传入一个id=1、name=null的JSON字符串,期望把id为1的用户姓名设置为空:
|
||||
|
||||
```
|
||||
curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "name":null}' http://localhost:45678/pojonull/right
|
||||
|
||||
{"id":1,"name":"","nickname":"guest","age":36,"createDate":"2020-01-04T11:09:20.000+0000"}%
|
||||
|
||||
```
|
||||
|
||||
结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/4a/a68db8e14e7dca3ff9b22e2348272a4a.png" alt="">
|
||||
|
||||
可以看到,right接口完美实现了仅重置name属性的操作,昵称也不再有null字符串,年龄和创建时间字段也没被修改。
|
||||
|
||||
通过日志可以看到,Hibernate生成的SQL语句只更新了name和nickname两个字段:
|
||||
|
||||
```
|
||||
Hibernate: update user_entity set name=?, nickname=? where id=?
|
||||
|
||||
```
|
||||
|
||||
接下来,为了测试使用Optional是否可以有效区分JSON中没传属性还是传了null,我们在JSON中设置了一个null的age,结果是正确得到了年龄不能为空的错误提示:
|
||||
|
||||
```
|
||||
curl -H "Content-Type:application/json" -X POST -d '{ "id":1, "age":null}' http://localhost:45678/pojonull/right
|
||||
|
||||
{"timestamp":"2020-01-05T03:14:40.324+0000","status":500,"error":"Internal Server Error","message":"年龄不能为空","path":"/pojonull/right"}%
|
||||
|
||||
```
|
||||
|
||||
## 小心MySQL中有关NULL的三个坑
|
||||
|
||||
前面提到,数据库表字段允许存NULL除了会让我们困惑外,还容易有坑。这里我会结合NULL字段,和你着重说明sum函数、count函数,以及NULL值条件可能踩的坑。
|
||||
|
||||
为方便演示,首先定义一个只有id和score两个字段的实体:
|
||||
|
||||
```
|
||||
@Entity
|
||||
@Data
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = IDENTITY)
|
||||
private Long id;
|
||||
private Long score;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
程序启动的时候,往实体初始化一条数据,其id是自增列自动设置的1,score是NULL:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
userRepository.save(new User());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,测试下面三个用例,来看看结合数据库中的null值可能会出现的坑:
|
||||
|
||||
- 通过sum函数统计一个只有NULL值的列的总和,比如SUM(score);
|
||||
- select记录数量,count使用一个允许NULL的字段,比如COUNT(score);
|
||||
- 使用=NULL条件查询字段值为NULL的记录,比如score=null条件。
|
||||
|
||||
```
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
@Query(nativeQuery=true,value = "SELECT SUM(score) FROM `user`")
|
||||
Long wrong1();
|
||||
@Query(nativeQuery = true, value = "SELECT COUNT(score) FROM `user`")
|
||||
Long wrong2();
|
||||
@Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score=null")
|
||||
List<User> wrong3();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
得到的结果,分别是null、0和空List:
|
||||
|
||||
```
|
||||
[11:38:50.137] [http-nio-45678-exec-1] [INFO ] [t.c.nullvalue.demo3.DbNullController:26 ] - result: null 0 []
|
||||
|
||||
```
|
||||
|
||||
显然,这三条SQL语句的执行结果和我们的期望不同:
|
||||
|
||||
- 虽然记录的score都是NULL,但sum的结果应该是0才对;
|
||||
- 虽然这条记录的score是NULL,但记录总数应该是1才对;
|
||||
- 使用=NULL并没有查询到id=1的记录,查询条件失效。
|
||||
|
||||
原因是:
|
||||
|
||||
- **MySQL中sum函数没统计到任何记录时,会返回null而不是0**,可以使用IFNULL函数把null转换为0;
|
||||
- **MySQL中count字段不统计null值**,COUNT(*)才是统计所有记录数量的正确方式。
|
||||
- **MySQL中使用诸如=、<、>这样的算数比较操作符比较NULL的结果总是NULL**,这种比较就显得没有任何意义,需要使用IS NULL、IS NOT NULL或 ISNULL()函数来比较。
|
||||
|
||||
修改一下SQL:
|
||||
|
||||
```
|
||||
@Query(nativeQuery = true, value = "SELECT IFNULL(SUM(score),0) FROM `user`")
|
||||
Long right1();
|
||||
@Query(nativeQuery = true, value = "SELECT COUNT(*) FROM `user`")
|
||||
Long right2();
|
||||
@Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score IS NULL")
|
||||
List<User> right3();
|
||||
|
||||
```
|
||||
|
||||
可以得到三个正确结果,分别为0、1、[User(id=1, score=null)] :
|
||||
|
||||
```
|
||||
[14:50:35.768] [http-nio-45678-exec-1] [INFO ] [t.c.nullvalue.demo3.DbNullController:31 ] - result: 0 1 [User(id=1, score=null)]
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我和你讨论了做好空值处理需要注意的几个问题。
|
||||
|
||||
我首先总结了业务代码中5种最容易出现空指针异常的写法,以及相应的修复方式。针对判空,通过Optional配合Stream可以避免大多数冗长的if-else判空逻辑,实现一行代码优雅判空。另外,要定位和修复空指针异常,除了可以通过增加日志进行排查外,在生产上使用Arthas来查看方法的调用栈和入参会更快捷。
|
||||
|
||||
在我看来,业务系统最基本的标准是不能出现未处理的空指针异常,因为它往往代表了业务逻辑的中断,所以我建议每天查询一次生产日志来排查空指针异常,有条件的话建议订阅空指针异常报警,以便及时发现及时处理。
|
||||
|
||||
POJO中字段的null定位,从服务端的角度往往很难分清楚,到底是客户端希望忽略这个字段还是有意传了null,因此我们尝试用Optional<t>类来区分null的定位。同时,为避免把空值更新到数据库中,可以实现动态SQL,只更新必要的字段。</t>
|
||||
|
||||
最后,我分享了数据库字段使用NULL可能会带来的三个坑(包括sum函数、count函数,以及NULL值条件),以及解决方式。
|
||||
|
||||
总结来讲,null的正确处理以及避免空指针异常,绝不是判空这么简单,还要根据业务属性从前到后仔细考虑,客户端传入的null代表了什么,出现了null是否允许使用默认值替代,入库的时候应该传入null还是空值,并确保整个逻辑处理的一致性,才能尽量避免Bug。
|
||||
|
||||
为处理好null,作为客户端的开发者,需要和服务端对齐字段null的含义以及降级逻辑;而作为服务端的开发者,需要对入参进行前置判断,提前挡掉服务端不可接受的空值,同时在整个业务逻辑过程中进行完善的空值处理。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. ConcurrentHashMap的Key和Value都不能为null,而HashMap却可以,你知道这么设计的原因是什么吗?TreeMap、Hashtable等Map的Key和Value是否支持null呢?
|
||||
1. 对于Hibernate框架可以使用@DynamicUpdate注解实现字段的动态更新,对于MyBatis框架如何实现类似的动态SQL功能,实现插入和修改SQL只包含POJO中的非空字段?
|
||||
|
||||
关于程序和数据库中的null、空指针问题,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
592
极客时间专栏/Java业务开发常见错误100例/代码篇/12 | 异常处理:别让自己在出问题的时候变为瞎子.md
Normal file
592
极客时间专栏/Java业务开发常见错误100例/代码篇/12 | 异常处理:别让自己在出问题的时候变为瞎子.md
Normal file
@@ -0,0 +1,592 @@
|
||||
<audio id="audio" title="12 | 异常处理:别让自己在出问题的时候变为瞎子" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/42/8b3b488163b0f5f71dc823a0982b4b42.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来和你聊聊异常处理容易踩的坑。
|
||||
|
||||
应用程序避免不了出异常,捕获和处理异常是考验编程功力的一个精细活。一些业务项目中,我曾看到开发同学在开发业务逻辑时不考虑任何异常处理,项目接近完成时再采用“流水线”的方式进行异常处理,也就是统一为所有方法打上try…catch…捕获所有异常记录日志,有些技巧的同学可能会使用AOP来进行类似的“统一异常处理”。
|
||||
|
||||
其实,这种处理异常的方式非常不可取。那么今天,我就和你分享下不可取的原因、与异常处理相关的坑和最佳实践。
|
||||
|
||||
## 捕获和处理异常容易犯的错
|
||||
|
||||
“统一异常处理”方式正是我要说的第一个错:**不在业务代码层面考虑异常处理,仅在框架层面粗犷捕获和处理异常**。
|
||||
|
||||
为了理解错在何处,我们先来看看大多数业务应用都采用的三层架构:
|
||||
|
||||
- Controller层负责信息收集、参数校验、转换服务层处理的数据适配前端,轻业务逻辑;
|
||||
- Service层负责核心业务逻辑,包括各种外部服务调用、访问数据库、缓存处理、消息处理等;
|
||||
- Repository层负责数据访问实现,一般没有业务逻辑。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2f/61/2f2cfbd86efd3bc140400bcaf2985361.png" alt="">
|
||||
|
||||
每层架构的工作性质不同,且从业务性质上异常可能分为业务异常和系统异常两大类,这就决定了很难进行统一的异常处理。我们从底向上看一下三层架构:
|
||||
|
||||
- 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返回给调用方:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/84/c215e78f1b23583393649fa89efe9f84.png" alt="">
|
||||
|
||||
要做得更好,你可以把相关出入参、用户信息在脱敏后记录到日志中,方便出现问题时根据上下文进一步排查。
|
||||
|
||||
第二个错,**捕获了异常后直接生吞**。在任何时候,我们捕获了异常都不应该生吞,也就是直接丢弃异常不记录、不抛出。这样的处理方式还不如不捕获异常,因为被生吞掉的异常一旦导致Bug,就很难在程序中找到蛛丝马迹,使得Bug排查工作难上加难。
|
||||
|
||||
通常情况下,生吞异常的原因,可能是不希望自己的方法抛出受检异常,只是为了把异常“处理掉”而捕获并生吞异常,也可能是想当然地认为异常并不重要或不可能产生。但不管是什么原因,不管是你认为多么不重要的异常,都不应该生吞,哪怕是一个日志也好。
|
||||
|
||||
第三个错,**丢弃异常的原始信息**。我们来看两个不太合适的异常处理方式,虽然没有完全生吞异常,但也丢失了宝贵的异常信息。
|
||||
|
||||
比如有这么一个会抛出受检异常的方法readFile:
|
||||
|
||||
```
|
||||
private void readFile() throws IOException {
|
||||
Files.readAllLines(Paths.get("a_file"));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
像这样调用readFile方法,捕获异常后,完全不记录原始异常,直接抛出一个转换后异常,导致出了问题不知道IOException具体是哪里引起的:
|
||||
|
||||
```
|
||||
@GetMapping("wrong1")
|
||||
public void wrong1(){
|
||||
try {
|
||||
readFile();
|
||||
} catch (IOException e) {
|
||||
//原始异常信息丢失
|
||||
throw new RuntimeException("系统忙请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或者是这样,只记录了异常消息,却丢失了异常的类型、栈等重要信息:
|
||||
|
||||
```
|
||||
catch (IOException e) {
|
||||
//只保留了异常消息,栈没有记录
|
||||
log.error("文件读取错误, {}", e.getMessage());
|
||||
throw new RuntimeException("系统忙请稍后再试");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
留下的日志是这样的,看完一脸茫然,只知道文件读取错误的文件名,至于为什么读取错误、是不存在还是没权限,完全不知道。
|
||||
|
||||
```
|
||||
[12:57:19.746] [http-nio-45678-exec-1] [ERROR] [.g.t.c.e.d.HandleExceptionController:35 ] - 文件读取错误, a_file
|
||||
|
||||
```
|
||||
|
||||
这两种处理方式都不太合理,可以改为如下方式:
|
||||
|
||||
```
|
||||
catch (IOException e) {
|
||||
log.error("文件读取错误", e);
|
||||
throw new RuntimeException("系统忙请稍后再试");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或者,把原始异常作为转换后新异常的cause,原始异常信息同样不会丢:
|
||||
|
||||
```
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException("系统忙请稍后再试", e);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其实,JDK内部也会犯类似的错。之前我遇到一个使用JDK10的应用偶发启动失败的案例,日志中可以看到出现类似的错误信息:
|
||||
|
||||
```
|
||||
Caused by: java.lang.SecurityException: Couldn't parse jurisdiction policy files in: unlimited
|
||||
at java.base/javax.crypto.JceSecurity.setupJurisdictionPolicies(JceSecurity.java:355)
|
||||
at java.base/javax.crypto.JceSecurity.access$000(JceSecurity.java:73)
|
||||
at java.base/javax.crypto.JceSecurity$1.run(JceSecurity.java:109)
|
||||
at java.base/javax.crypto.JceSecurity$1.run(JceSecurity.java:106)
|
||||
at java.base/java.security.AccessController.doPrivileged(Native Method)
|
||||
at java.base/javax.crypto.JceSecurity.<clinit>(JceSecurity.java:105)
|
||||
... 20 more
|
||||
|
||||
```
|
||||
|
||||
查看JDK JceSecurity类setupJurisdictionPolicies方法源码,发现异常e没有记录,也没有作为新抛出异常的cause,当时读取文件具体出现什么异常(权限问题又或是IO问题)可能永远都无法知道了,对问题定位造成了很大困扰:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/65/b8b581f6130211a19ed66490dbcd0465.png" alt="">
|
||||
|
||||
第四个错,**抛出异常时不指定任何消息**。我见过一些代码中的偷懒做法,直接抛出没有message的异常:
|
||||
|
||||
```
|
||||
throw new RuntimeException();
|
||||
|
||||
```
|
||||
|
||||
这么写的同学可能觉得永远不会走到这个逻辑,永远不会出现这样的异常。但,这样的异常却出现了,被ExceptionHandler拦截到后输出了下面的日志信息:
|
||||
|
||||
```
|
||||
[13:25:18.031] [http-nio-45678-exec-3] [ERROR] [c.e.d.RestControllerExceptionHandler:24 ] - 访问 /handleexception/wrong3 -> org.geekbang.time.commonmistakes.exception.demo1.HandleExceptionController#wrong3(String) 出现系统异常!
|
||||
java.lang.RuntimeException: null
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
这里的null非常容易引起误解。按照空指针问题排查半天才发现,其实是异常的message为空。
|
||||
|
||||
总之,如果你捕获了异常打算处理的话,**除了通过日志正确记录异常原始信息外,通常还有三种处理模式**:
|
||||
|
||||
- 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过cause关联老异常。
|
||||
- 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更严重,需要考虑当前情况是否适合重试。
|
||||
- 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。
|
||||
|
||||
以上,就是通过catch捕获处理异常的一些最佳实践。
|
||||
|
||||
## 小心finally中的异常
|
||||
|
||||
有些时候,我们希望不管是否遇到异常,逻辑完成后都要释放资源,这时可以使用finally代码块而跳过使用catch代码块。
|
||||
|
||||
但要千万小心finally代码块中的异常,因为资源释放处理等收尾操作同样也可能出现异常。比如下面这段代码,我们在finally中抛出一个异常:
|
||||
|
||||
```
|
||||
@GetMapping("wrong")
|
||||
public void wrong() {
|
||||
try {
|
||||
log.info("try");
|
||||
//异常丢失
|
||||
throw new RuntimeException("try");
|
||||
} finally {
|
||||
log.info("finally");
|
||||
throw new RuntimeException("finally");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后在日志中只能看到finally中的异常,**虽然try中的逻辑出现了异常,但却被finally中的异常覆盖了**。这是非常危险的,特别是finally中出现的异常是偶发的,就会在部分时候覆盖try中的异常,让问题更不明显:
|
||||
|
||||
```
|
||||
[13:34:42.247] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: finally] with root cause
|
||||
java.lang.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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行方法可以得到如下异常信息,其中同时包含了主异常和被屏蔽的异常:
|
||||
|
||||
```
|
||||
java.lang.RuntimeException: try
|
||||
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:69)
|
||||
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
|
||||
...
|
||||
Suppressed: java.lang.RuntimeException: finally
|
||||
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:75)
|
||||
... 54 common frames omitted
|
||||
|
||||
```
|
||||
|
||||
其实这正是try-with-resources语句的做法,对于实现了AutoCloseable接口的资源,建议使用try-with-resources来释放资源,否则也可能会产生刚才提到的,释放资源时出现的异常覆盖主异常的问题。比如如下我们定义一个测试资源,其read和close方法都会抛出异常:
|
||||
|
||||
```
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用传统的try-finally语句,在try中调用read方法,在finally中调用close方法:
|
||||
|
||||
```
|
||||
@GetMapping("useresourcewrong")
|
||||
public void useresourcewrong() throws Exception {
|
||||
TestResource testResource = new TestResource();
|
||||
try {
|
||||
testResource.read();
|
||||
} finally {
|
||||
testResource.close();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,同样出现了finally中的异常覆盖了try中异常的问题:
|
||||
|
||||
```
|
||||
java.lang.Exception: close error
|
||||
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
|
||||
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourcewrong(FinallyIssueController.java:27)
|
||||
|
||||
```
|
||||
|
||||
而改为try-with-resources模式之后:
|
||||
|
||||
```
|
||||
@GetMapping("useresourceright")
|
||||
public void useresourceright() throws Exception {
|
||||
try (TestResource testResource = new TestResource()){
|
||||
testResource.read();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
try和finally中的异常信息都可以得到保留:
|
||||
|
||||
```
|
||||
java.lang.Exception: read error
|
||||
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.read(TestResource.java:6)
|
||||
...
|
||||
Suppressed: java.lang.Exception: close error
|
||||
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
|
||||
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourceright(FinallyIssueController.java:35)
|
||||
... 54 common frames omitted
|
||||
|
||||
```
|
||||
|
||||
## 千万别把异常定义为静态变量
|
||||
|
||||
既然我们通常会自定义一个业务异常类型,来包含更多的异常信息,比如异常错误码、友好的错误提示等,那就需要在业务逻辑各处,手动抛出各种业务异常来返回指定的错误码描述(比如对于下单操作,用户不存在返回2001,商品缺货返回2002等)。
|
||||
|
||||
对于这些异常的错误代码和消息,我们期望能够统一管理,而不是散落在程序各处定义。这个想法很好,但稍有不慎就可能会出现把异常定义为静态变量的坑。
|
||||
|
||||
我在救火排查某项目生产问题时,遇到了一件非常诡异的事情:我发现异常堆信息显示的方法调用路径,在当前入参的情况下根本不可能产生,项目的业务逻辑又很复杂,就始终没往异常信息是错的这方面想,总觉得是因为某个分支流程导致业务没有按照期望的流程进行。
|
||||
|
||||
**经过艰难的排查,最终定位到原因是把异常定义为了静态变量,导致异常栈信息错乱**,类似于定义一个Exceptions类来汇总所有的异常,把异常存放在静态字段中:
|
||||
|
||||
```
|
||||
public class Exceptions {
|
||||
public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在", 3001);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
把异常定义为静态变量会导致异常信息固化,这就和异常的栈一定是需要根据当前调用来动态获取相矛盾。
|
||||
|
||||
我们写段代码来模拟下这个问题:定义两个方法createOrderWrong和cancelOrderWrong方法,它们内部都会通过Exceptions类来获得一个订单不存在的异常;先后调用两个方法,然后抛出。
|
||||
|
||||
```
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
private void createOrderWrong() {
|
||||
//这里有问题
|
||||
throw Exceptions.ORDEREXISTS;
|
||||
}
|
||||
|
||||
private void cancelOrderWrong() {
|
||||
//这里有问题
|
||||
throw Exceptions.ORDEREXISTS;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行程序后看到如下日志,cancelOrder got error的提示对应了createOrderWrong方法。显然,cancelOrderWrong方法在出错后抛出的异常,其实是createOrderWrong方法出错的异常:
|
||||
|
||||
```
|
||||
[14:05:25.782] [http-nio-45678-exec-1] [ERROR] [.c.e.d.PredefinedExceptionController:25 ] - cancelOrder got error
|
||||
org.geekbang.time.commonmistakes.exception.demo2.BusinessException: 订单已经存在
|
||||
at org.geekbang.time.commonmistakes.exception.demo2.Exceptions.<clinit>(Exceptions.java:5)
|
||||
at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.createOrderWrong(PredefinedExceptionController.java:50)
|
||||
at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.wrong(PredefinedExceptionController.java:18)
|
||||
|
||||
```
|
||||
|
||||
修复方式很简单,改一下Exceptions类的实现,通过不同的方法把每一种异常都new出来抛出即可:
|
||||
|
||||
```
|
||||
public class Exceptions {
|
||||
public static BusinessException orderExists(){
|
||||
return new BusinessException("订单已经存在", 3001);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 提交线程池的任务出了异常会怎么样?
|
||||
|
||||
在[第3讲](https://time.geekbang.org/column/article/210337)介绍线程池时我提到,线程池常用作异步处理或并行处理。那么,把任务提交到线程池处理,任务本身出现异常时会怎样呢?
|
||||
|
||||
我们来看一个例子:提交10个任务到线程池异步处理,第5个任务抛出一个RuntimeException,每个任务完成后都会输出一行日志:
|
||||
|
||||
```
|
||||
@GetMapping("execute")
|
||||
public void execute() throws InterruptedException {
|
||||
|
||||
String prefix = "test";
|
||||
ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix+"%d").get());
|
||||
//提交10个任务到线程池处理,第5个任务会抛出运行时异常
|
||||
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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
观察日志可以发现两点:
|
||||
|
||||
```
|
||||
...
|
||||
[14:33:55.990] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 4
|
||||
Exception in thread "test0" java.lang.RuntimeException: error
|
||||
at org.geekbang.time.commonmistakes.exception.demo3.ThreadPoolAndExceptionController.lambda$null$0(ThreadPoolAndExceptionController.java:25)
|
||||
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
|
||||
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
|
||||
at java.lang.Thread.run(Thread.java:748)
|
||||
[14:33:55.990] [test1] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 6
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
- 任务1到4所在的线程是test0,任务6开始运行在线程test1。由于我的线程池通过线程工厂为线程使用统一的前缀test加上计数器进行命名,因此**从线程名的改变可以知道因为异常的抛出老线程退出了,线程池只能重新创建一个线程**。如果每个异步任务都以异常结束,那么线程池可能完全起不到线程重用的作用。
|
||||
- 因为没有手动捕获异常进行处理,ThreadGroup帮我们进行了未捕获异常的默认处理,向标准错误输出打印了出现异常的线程名称和异常信息。**显然,这种没有以统一的错误日志格式记录错误信息打印出来的形式,对生产级代码是不合适的**,ThreadGroup的相关源码如下所示:
|
||||
|
||||
```
|
||||
public void uncaughtException(Thread t, Throwable e) {
|
||||
if (parent != null) {
|
||||
parent.uncaughtException(t, e);
|
||||
} else {
|
||||
Thread.UncaughtExceptionHandler ueh =
|
||||
Thread.getDefaultUncaughtExceptionHandler();
|
||||
if (ueh != null) {
|
||||
ueh.uncaughtException(t, e);
|
||||
} else if (!(e instanceof ThreadDeath)) {
|
||||
System.err.print("Exception in thread \""
|
||||
+ t.getName() + "\" ");
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
修复方式有2步:
|
||||
|
||||
1. 以execute方法提交到线程池的异步任务,最好在任务内部做好异常处理;
|
||||
1. 设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序:
|
||||
|
||||
```
|
||||
new ThreadFactoryBuilder()
|
||||
.setNameFormat(prefix+"%d")
|
||||
.setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
|
||||
.get()
|
||||
|
||||
```
|
||||
|
||||
或者设置全局的默认未捕获异常处理程序:
|
||||
|
||||
```
|
||||
static {
|
||||
Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-> log.error("Thread {} got exception", thread, throwable));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过线程池ExecutorService的execute方法提交任务到线程池处理,如果出现异常会导致线程退出,控制台输出中可以看到异常信息。那么,把execute方法改为submit,线程还会退出吗,异常还能被处理程序捕获到吗?
|
||||
|
||||
**修改代码后重新执行程序可以看到如下日志,说明线程没退出,异常也没记录被生吞了:**
|
||||
|
||||
```
|
||||
[15:44:33.769] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 1
|
||||
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 2
|
||||
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 3
|
||||
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 4
|
||||
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 6
|
||||
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 7
|
||||
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 8
|
||||
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 9
|
||||
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 10
|
||||
|
||||
```
|
||||
|
||||
为什么会这样呢?
|
||||
|
||||
查看FutureTask源码可以发现,在执行任务出现异常之后,异常存到了一个outcome字段中,只有在调用get方法获取FutureTask结果的时候,才会以ExecutionException的形式重新抛出异常:
|
||||
|
||||
```
|
||||
public void run() {
|
||||
...
|
||||
try {
|
||||
Callable<V> c = callable;
|
||||
if (c != null && state == NEW) {
|
||||
V result;
|
||||
boolean ran;
|
||||
try {
|
||||
result = c.call();
|
||||
ran = true;
|
||||
} catch (Throwable ex) {
|
||||
result = null;
|
||||
ran = false;
|
||||
setException(ex);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
protected void setException(Throwable t) {
|
||||
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
|
||||
outcome = t;
|
||||
UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
|
||||
finishCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
public V get() throws InterruptedException, ExecutionException {
|
||||
int s = state;
|
||||
if (s <= COMPLETING)
|
||||
s = awaitDone(false, 0L);
|
||||
return report(s);
|
||||
}
|
||||
|
||||
private V report(int s) throws ExecutionException {
|
||||
Object x = outcome;
|
||||
if (s == NORMAL)
|
||||
return (V)x;
|
||||
if (s >= CANCELLED)
|
||||
throw new CancellationException();
|
||||
throw new ExecutionException((Throwable)x);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
修改后的代码如下所示,我们把submit返回的Future放到了List中,随后遍历List来捕获所有任务的异常。这么做确实合乎情理。既然是以submit方式来提交任务,那么我们应该关心任务的执行结果,否则应该以execute来提交任务:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
执行这段程序可以看到如下的日志输出:
|
||||
|
||||
```
|
||||
[15:44:13.543] [http-nio-45678-exec-1] [ERROR] [e.d.ThreadPoolAndExceptionController:69 ] - Got exception
|
||||
java.util.concurrent.ExecutionException: java.lang.RuntimeException: error
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
在今天的文章中,我介绍了处理异常容易犯的几个错和最佳实践。
|
||||
|
||||
第一,注意捕获和处理异常的最佳实践。首先,不应该用AOP对所有方法进行统一异常处理,异常要么不捕获不处理,要么根据不同的业务逻辑、不同的异常类型进行精细化、针对性处理;其次,处理异常应该杜绝生吞,并确保异常栈信息得到保留;最后,如果需要重新抛出异常的话,请使用具有意义的异常类型和异常消息。
|
||||
|
||||
第二,务必小心finally代码块中资源回收逻辑,确保finally代码块不出现异常,内部把异常处理完毕,避免finally中的异常覆盖try中的异常;或者考虑使用addSuppressed方法把finally中的异常附加到try中的异常上,确保主异常信息不丢失。此外,使用实现了AutoCloseable接口的资源,务必使用try-with-resources模式来使用资源,确保资源可以正确释放,也同时确保异常可以正确处理。
|
||||
|
||||
第三,虽然在统一的地方定义收口所有的业务异常是一个不错的实践,但务必确保异常是每次new出来的,而不能使用一个预先定义的static字段存放异常,否则可能会引起栈信息的错乱。
|
||||
|
||||
第四,确保正确处理了线程池中任务的异常,如果任务通过execute提交,那么出现异常会导致线程退出,大量的异常会导致线程重复创建引起性能问题,我们应该尽可能确保任务不出异常,同时设置默认的未捕获异常处理程序来兜底;如果任务通过submit提交意味着我们关心任务的执行结果,应该通过拿到的Future调用其get方法来获得任务运行结果和可能出现的异常,否则异常可能就被生吞了。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 关于在finally代码块中抛出异常的坑,如果在finally代码块中返回值,你觉得程序会以try或catch中返回值为准,还是以finally中的返回值为准呢?
|
||||
1. 对于手动抛出的异常,不建议直接使用Exception或RuntimeException,通常建议复用JDK中的一些标准异常,比如[IllegalArgumentException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalArgumentException.html)、[IllegalStateException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalStateException.html)、[UnsupportedOperationException](https://docs.oracle.com/javase/8/docs/api/java/lang/UnsupportedOperationException.html),你能说说它们的适用场景,并列出更多常用异常吗?
|
||||
|
||||
不知道针对异常处理,你还遇到过什么坑,还有什么最佳实践的心得吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
637
极客时间专栏/Java业务开发常见错误100例/代码篇/13 | 日志:日志记录真没你想象的那么简单.md
Normal file
637
极客时间专栏/Java业务开发常见错误100例/代码篇/13 | 日志:日志记录真没你想象的那么简单.md
Normal file
@@ -0,0 +1,637 @@
|
||||
<audio id="audio" title="13 | 日志:日志记录真没你想象的那么简单" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/e5/83c4a643db07af639fd5c4476632d1e5.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我和你分享的是,记录日志可能会踩的坑。
|
||||
|
||||
一些同学可能要说了,记录日志还不简单,无非是几个常用的API方法,比如debug、info、warn、error;但我就见过不少坑都是记录日志引起的,容易出错主要在于三个方面:
|
||||
|
||||
- 日志框架众多,不同的类库可能会使用不同的日志框架,如何兼容是一个问题。
|
||||
- 配置复杂且容易出错。日志配置文件通常很复杂,因此有些开发同学会从其他项目或者网络上复制一份配置文件,但却不知道如何修改,甚至是胡乱修改,造成很多问题。比如,重复记录日志的问题、同步日志的性能问题、异步记录的错误配置问题。
|
||||
- 日志记录本身就有些误区,比如没考虑到日志内容获取的代价、胡乱使用日志级别等。
|
||||
|
||||
Logback、Log4j、Log4j2、commons-logging、JDK自带的java.util.logging等,都是Java体系的日志框架,确实非常多。而不同的类库,还可能选择使用不同的日志框架。这样一来,日志的统一管理就变得非常困难。为了解决这个问题,就有了SLF4J(Simple Logging Facade For Java),如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/fe/97fcd8b55e5288c0e9954f070f1008fe.png" alt="">
|
||||
|
||||
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也是同样的道理。**
|
||||
|
||||
虽然图中有4个灰色的日志实现框架,但我看到的业务系统使用最广泛的是Logback和Log4j,它们是同一人开发的。Logback可以认为是Log4j的改进版本,我更推荐使用。所以,关于日志框架配置的案例,我都会围绕Logback展开。
|
||||
|
||||
Spring Boot是目前最流行的Java框架,它的日志框架也用的是Logback。那,为什么我们没有手动引入Logback的包,就可以直接使用Logback了呢?
|
||||
|
||||
查看Spring Boot的Maven依赖树,可以发现spring-boot-starter模块依赖了spring-boot-starter-logging模块,而spring-boot-starter-logging模块又帮我们自动引入了logback-classic(包含了SLF4J和Logback日志框架)和SLF4J的一些适配器。其中,log4j-to-slf4j用于实现Log4j2 API到SLF4J的桥接,jul-to-slf4j则是实现java.util.logging API到SLF4J的桥接:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/e6/4c44672d280b8a30be777b78de6014e6.png" alt="">
|
||||
|
||||
接下来,我就用几个实际的案例和你说说日志配置和记录这两大问题,顺便以Logback为例复习一下常见的日志配置。
|
||||
|
||||
## 为什么我的日志会重复记录?
|
||||
|
||||
日志重复记录在业务上非常常见,不但给查看日志和统计工作带来不必要的麻烦,还会增加磁盘和日志收集系统的负担。接下来,我和你分享两个重复记录的案例,同时帮助你梳理Logback配置的基本结构。
|
||||
|
||||
**第一个案例是,logger配置继承关系导致日志重复记录**。首先,定义一个方法实现debug、info、warn和error四种日志的记录:
|
||||
|
||||
```
|
||||
@Log4j2
|
||||
@RequestMapping("logging")
|
||||
@RestController
|
||||
public class LoggingController {
|
||||
@GetMapping("log")
|
||||
public void log() {
|
||||
log.debug("debug");
|
||||
log.info("info");
|
||||
log.warn("warn");
|
||||
log.error("error");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,使用下面的Logback配置:
|
||||
|
||||
- 第11和12行设置了全局的日志级别为INFO,日志输出使用CONSOLE Appender。
|
||||
- 第3到7行,首先将CONSOLE Appender定义为ConsoleAppender,也就是把日志输出到控制台(System.out/System.err);然后通过PatternLayout定义了日志的输出格式。关于格式化字符串的各种使用方式,你可以进一步查阅[官方文档](http://logback.qos.ch/manual/layouts.html#conversionWord)。
|
||||
- 第8到10行实现了一个Logger配置,将应用包的日志级别设置为DEBUG、日志输出同样使用CONSOLE Appender。
|
||||
|
||||
```
|
||||
<?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">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</logger>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
|
||||
```
|
||||
|
||||
这段配置看起来没啥问题,但执行方法后出现了日志重复记录的问题:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/15/2c6f45bbbe06c1ed26b514e7ac873b15.png" alt="">
|
||||
|
||||
从配置文件的第9和12行可以看到,CONSOLE这个Appender同时挂载到了两个Logger上,一个是我们定义的<logger>,一个是<root>,由于我们定义的<logger>继承自<root>,**所以同一条日志既会通过logger记录,也会发送到root记录,因此应用package下的日志出现了重复记录。**
|
||||
|
||||
后来我了解到,这个同学如此配置的初衷是实现自定义的logger配置,让应用内的日志暂时开启DEBUG级别的日志记录。其实,他完全不需要重复挂载Appender,去掉<logger>下挂载的Appender即可:
|
||||
|
||||
```
|
||||
<logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG"/>
|
||||
|
||||
```
|
||||
|
||||
如果自定义的<logger>需要把日志输出到不同的Appender,比如将应用的日志输出到文件app.log、把其他框架的日志输出到控制台,可以设置<logger>的additivity属性为false,这样就不会继承<root>的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造成日志重复记录。**
|
||||
|
||||
一般互联网公司都会使用ELK三件套来统一收集日志,有一次我们发现Kibana上展示的日志有部分重复,一直怀疑是Logstash配置错误,但最后发现还是Logback的配置错误引起的。
|
||||
|
||||
这个项目的日志是这样配置的:在记录日志到控制台的同时,把日志记录按照不同的级别记录到两个文件中:
|
||||
|
||||
```
|
||||
<?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>
|
||||
</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>
|
||||
|
||||
```
|
||||
|
||||
这个配置文件比较长,我带着你一段一段地看:
|
||||
|
||||
- 第31到35行定义的root引用了三个Appender。
|
||||
- 第5到9行是第一个ConsoleAppender,用于把所有日志输出到控制台。
|
||||
- 第10到19行定义了一个FileAppender,用于记录文件日志,并定义了文件名、记录日志的格式和编码等信息。最关键的是,第12到14行定义的LevelFilter过滤日志,将过滤级别设置为INFO,目的是希望_info.log文件中可以记录INFO级别的日志。
|
||||
- 第20到30行定义了一个类似的FileAppender,并使用ThresholdFilter来过滤日志,过滤级别设置为WARN,目的是把WARN以上级别的日志记录到另一个_error.log文件中。
|
||||
|
||||
运行一下测试程序:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/4e/e940f1310e70b65ff716dc81c9901d4e.png" alt="">
|
||||
|
||||
可以看到,_info.log中包含了INFO、WARN和ERROR三个级别的日志,不符合我们的预期;error.log包含了WARN和ERROR两个级别的日志。因此,造成了日志的重复收集。
|
||||
|
||||
你可能会问,这么明显的日志重复为什么没有及时发现?一些公司使用自动化的ELK方案收集日志,日志会同时输出到控制台和文件,开发人员在本机测试时不太会关心文件中记录的日志,而在测试和生产环境又因为开发人员没有服务器访问权限,所以原始日志文件中的重复问题并不容易发现。
|
||||
|
||||
为了分析日志重复的原因,我们来复习一下ThresholdFilter和LevelFilter的配置方式。
|
||||
|
||||
分析ThresholdFilter的源码发现,当日志级别大于等于配置的级别时返回NEUTRAL,继续调用过滤器链上的下一个过滤器;否则,返回DENY直接拒绝记录日志:
|
||||
|
||||
```
|
||||
public class ThresholdFilter extends Filter<ILoggingEvent> {
|
||||
public FilterReply decide(ILoggingEvent event) {
|
||||
if (!isStarted()) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
|
||||
if (event.getLevel().isGreaterOrEqual(level)) {
|
||||
return FilterReply.NEUTRAL;
|
||||
} else {
|
||||
return FilterReply.DENY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个案例中,把ThresholdFilter设置为WARN,可以记录WARN和ERROR级别的日志。
|
||||
|
||||
LevelFilter用来比较日志级别,然后进行相应处理:如果匹配就调用onMatch定义的处理方式,默认是交给下一个过滤器处理(AbstractMatcherFilter基类中定义的默认值);否则,调用onMismatch定义的处理方式,默认也是交给下一个过滤器处理。
|
||||
|
||||
```
|
||||
public class LevelFilter extends AbstractMatcherFilter<ILoggingEvent> {
|
||||
public FilterReply decide(ILoggingEvent event) {
|
||||
if (!isStarted()) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
|
||||
|
||||
if (event.getLevel().equals(level)) {
|
||||
return onMatch;
|
||||
} else {
|
||||
return onMismatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class AbstractMatcherFilter<E> extends Filter<E> {
|
||||
protected FilterReply onMatch = FilterReply.NEUTRAL;
|
||||
protected FilterReply onMismatch = FilterReply.NEUTRAL;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
和ThresholdFilter不同的是,LevelFilter仅仅配置level是无法真正起作用的。**由于没有配置onMatch和onMismatch属性,所以相当于这个过滤器是无用的,导致INFO以上级别的日志都记录了。**
|
||||
|
||||
定位到问题后,修改方式就很明显了:配置LevelFilter的onMatch属性为ACCEPT,表示接收INFO级别的日志;配置onMismatch属性为DENY,表示除了INFO级别都不记录:
|
||||
|
||||
```
|
||||
<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>
|
||||
...
|
||||
</appender>
|
||||
|
||||
```
|
||||
|
||||
这样修改后,_info.log文件中只会有INFO级别的日志,不会出现日志重复的问题了。
|
||||
|
||||
## 使用异步日志改善性能的坑
|
||||
|
||||
掌握了把日志输出到文件中的方法后,我们接下来面临的问题是,如何避免日志记录成为应用的性能瓶颈。这可以帮助我们解决,磁盘(比如机械磁盘)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>
|
||||
<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>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="FILE"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
|
||||
```
|
||||
|
||||
不知道你有没有注意到,这段代码中有个EvaluatorFilter(求值过滤器),用于判断日志是否符合某个条件。
|
||||
|
||||
在后续的测试代码中,我们会把大量日志输出到文件中,日志文件会非常大,如果性能测试结果也混在其中的话,就很难找到那条日志。所以,这里我们使用EvaluatorFilter对日志按照标记进行过滤,并将过滤出的日志单独输出到控制台上。在这个案例中,我们给输出测试结果的那条日志上做了time标记。
|
||||
|
||||
配合使用标记和EvaluatorFilter,实现日志的按标签过滤,是一个不错的小技巧。
|
||||
|
||||
如下测试代码中,实现了记录指定次数的大日志,每条日志包含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秒:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/52/7ee5152dedcbb585f23db49571bacc52.png" alt="">
|
||||
|
||||
对于只记录文件日志的代码了来说,这个耗时挺长的。为了分析其中原因,我们需要分析下FileAppender的源码。
|
||||
|
||||
FileAppender继承自OutputStreamAppender,查看OutputStreamAppender源码的第30到33行发现,**在追加日志的时候,是直接把日志写入OutputStream中,属于同步记录日志:**
|
||||
|
||||
```
|
||||
public class OutputStreamAppender<E> extends UnsynchronizedAppenderBase<E> {
|
||||
private OutputStream outputStream;
|
||||
boolean immediateFlush = true;
|
||||
@Override
|
||||
protected void append(E eventObject) {
|
||||
if (!isStarted()) {
|
||||
return;
|
||||
}
|
||||
subAppend(eventObject);
|
||||
}
|
||||
|
||||
protected void subAppend(E event) {
|
||||
if (!isStarted()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
//编码LoggingEvent
|
||||
byte[] byteArray = this.encoder.encode(event);
|
||||
//写字节流
|
||||
writeBytes(byteArray);
|
||||
} catch (IOException ioe) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
private void writeBytes(byte[] byteArray) throws IOException {
|
||||
if(byteArray == null || byteArray.length == 0)
|
||||
return;
|
||||
|
||||
lock.lock();
|
||||
try {
|
||||
//这个OutputStream其实是一个ResilientFileOutputStream,其内部使用的是带缓冲的BufferedOutputStream
|
||||
this.outputStream.write(byteArray);
|
||||
if (immediateFlush) {
|
||||
this.outputStream.flush();//刷入OS
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
分析到这里,我们就明白为什么日志大量写入时会耗时这么久了。那,有没有办法实现大量日志写入时,不会过多影响业务逻辑执行耗时,影响吞吐量呢?
|
||||
|
||||
办法当然有了,使用Logback提供的AsyncAppender即可实现异步的日志记录。AsyncAppende类似装饰模式,也就是在不改变类原有基本功能的情况下为其增添新功能。这样,我们就可以把AsyncAppender附加在其他的Appender上,将其变为异步的。
|
||||
|
||||
定义一个异步Appender ASYNCFILE,包装之前的同步文件日志记录的FileAppender,就可以实现异步记录日志到文件:
|
||||
|
||||
```
|
||||
<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>
|
||||
|
||||
```
|
||||
|
||||
测试一下可以发现,记录1000次日志和10000次日志的调用耗时,分别是735毫秒和668毫秒:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/e1/98d1633d83734f9b8f08c3334b403ce1.png" alt="">
|
||||
|
||||
性能居然这么好,你觉得其中有什么问题吗?异步日志真的如此神奇和万能吗?当然不是,因为这样并没有记录下所有日志。**我之前就遇到过很多关于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条日志,而且日志的行号变为了一个问号。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/70/5fe1562b437b8672e4b1e9e463a24570.png" alt="">
|
||||
|
||||
出现这个问题的原因在于,AsyncAppender提供了一些配置参数,而我们没用对。我们结合相关源码分析一下:
|
||||
|
||||
- includeCallerData用于控制是否收集调用方数据,默认是false,此时方法行号、方法名等信息将不能显示(源码第2行以及7到11行)。
|
||||
- queueSize用于控制阻塞队列大小,使用的ArrayBlockingQueue阻塞队列(源码第15到17行),默认大小是256,即内存中最多保存256条日志。
|
||||
- discardingThreshold是控制丢弃日志的阈值,主要是防止队列满后阻塞。默认情况下,队列剩余量低于队列长度的20%,就会丢弃TRACE、DEBUG和INFO级别的日志。(参见源码第3到6行、18到19行、26到27行、33到34行、40到42行)
|
||||
- neverBlock用于控制队列满的时候,加入的数据是否直接丢弃,不会阻塞等待,默认是false(源码第44到68行)。这里需要注意一下offer方法和put方法的区别,当队列满的时候offer方法不阻塞,而put方法会阻塞;neverBlock为true时,使用offer方法。
|
||||
|
||||
```
|
||||
public class AsyncAppender extends AsyncAppenderBase<ILoggingEvent> {
|
||||
boolean includeCallerData = false;//是否收集调用方数据
|
||||
protected boolean isDiscardable(ILoggingEvent event) {
|
||||
Level level = event.getLevel();
|
||||
return level.toInt() <= Level.INFO_INT;//丢弃<=INFO级别的日志
|
||||
}
|
||||
protected void preprocess(ILoggingEvent eventObject) {
|
||||
eventObject.prepareForDeferredProcessing();
|
||||
if (includeCallerData)
|
||||
eventObject.getCallerData();
|
||||
}
|
||||
}
|
||||
public class AsyncAppenderBase<E> extends UnsynchronizedAppenderBase<E> implements AppenderAttachable<E> {
|
||||
|
||||
BlockingQueue<E> blockingQueue;//异步日志的关键,阻塞队列
|
||||
public static final int DEFAULT_QUEUE_SIZE = 256;//默认队列大小
|
||||
int queueSize = DEFAULT_QUEUE_SIZE;
|
||||
static final int UNDEFINED = -1;
|
||||
int discardingThreshold = UNDEFINED;
|
||||
boolean neverBlock = false;//控制队列满的时候加入数据时是否直接丢弃,不会阻塞等待
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
...
|
||||
blockingQueue = new ArrayBlockingQueue<E>(queueSize);
|
||||
if (discardingThreshold == UNDEFINED)
|
||||
discardingThreshold = queueSize / 5;//默认丢弃阈值是队列剩余量低于队列长度的20%,参见isQueueBelowDiscardingThreshold方法
|
||||
...
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void append(E eventObject) {
|
||||
if (isQueueBelowDiscardingThreshold() && isDiscardable(eventObject)) { //判断是否可以丢数据
|
||||
return;
|
||||
}
|
||||
preprocess(eventObject);
|
||||
put(eventObject);
|
||||
}
|
||||
|
||||
private boolean isQueueBelowDiscardingThreshold() {
|
||||
return (blockingQueue.remainingCapacity() < discardingThreshold);
|
||||
}
|
||||
|
||||
private void put(E eventObject) {
|
||||
if (neverBlock) { //根据neverBlock决定使用不阻塞的offer还是阻塞的put方法
|
||||
blockingQueue.offer(eventObject);
|
||||
} else {
|
||||
putUninterruptibly(eventObject);
|
||||
}
|
||||
}
|
||||
//以阻塞方式添加数据到队列
|
||||
private void putUninterruptibly(E eventObject) {
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (true) {
|
||||
try {
|
||||
blockingQueue.put(eventObject);
|
||||
break;
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到默认队列大小为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。
|
||||
|
||||
以上就是日志配置最常见的两个误区了。接下来,我们再看一个日志记录本身的误区。
|
||||
|
||||
## 使用日志占位符就不需要进行日志级别判断了?
|
||||
|
||||
不知道你有没有听人说过:SLF4J的{}占位符语法,到真正记录日志时才会获取实际参数,因此解决了日志数据获取的性能问题。你觉得,这种说法对吗?
|
||||
|
||||
为了验证这个问题,我们写一段测试代码:有一个slowString方法,返回结果耗时1秒:
|
||||
|
||||
```
|
||||
private String slowString(String s) {
|
||||
System.out.println("slowString called via " + s);
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(1);
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
return "OK";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们记录DEBUG日志,并设置只记录>=INFO级别的日志,程序是否也会耗时1秒呢?我们使用三种方法来测试:
|
||||
|
||||
- 拼接字符串方式记录slowString;
|
||||
- 使用占位符方式记录slowString;
|
||||
- 先判断日志级别是否启用DEBUG。
|
||||
|
||||
```
|
||||
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();
|
||||
|
||||
```
|
||||
|
||||
可以看到,前两种方式都调用了slowString方法,所以耗时都是1秒:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/83/fbaac87cad19b2136e6f9f99bbc43183.png" alt="">
|
||||
|
||||
使用占位符方式记录slowString的方式,同样需要耗时1秒,是因为这种方式虽然允许我们传入Object,不用拼接字符串,但也只是延迟(如果日志不记录那么就是省去)了日志参数对象.toString()和字符串拼接的耗时。
|
||||
|
||||
在这个案例中,除非事先判断日志级别,否则必然会调用slowString方法。**回到之前提的问题,使用{}占位符语法不能通过延迟参数值获取,来解决日志数据获取的性能问题。**
|
||||
|
||||
除了事先判断日志级别,我们还可以通过lambda表达式进行延迟参数内容获取。但,SLF4J的API还不支持lambda,因此需要使用Log4j2日志API,把Lombok的@Slf4j注解替换为@Log4j2注解,这样就可以提供一个lambda表达式作为提供参数数据的方法:
|
||||
|
||||
```
|
||||
@Log4j2
|
||||
public class LoggingController {
|
||||
...
|
||||
log.debug("debug4:{}", ()->slowString("debug4"));
|
||||
|
||||
```
|
||||
|
||||
像这样调用debug方法,签名是Supplier<?>,参数会延迟到真正需要记录日志时再获取:
|
||||
|
||||
```
|
||||
void debug(String message, Supplier<?>... paramSuppliers);
|
||||
|
||||
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message,
|
||||
final Supplier<?>... paramSuppliers) {
|
||||
if (isEnabled(level, marker, message)) {
|
||||
logMessage(fqcn, level, marker, message, paramSuppliers);
|
||||
}
|
||||
}
|
||||
protected void logMessage(final String fqcn, final Level level, final Marker marker, final String message,
|
||||
final Supplier<?>... paramSuppliers) {
|
||||
final Message msg = messageFactory.newMessage(message, LambdaUtil.getAll(paramSuppliers));
|
||||
logMessageSafely(fqcn, level, marker, msg, msg.getThrowable());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
修改后再次运行测试,可以看到这次debug4并不会调用slowString方法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/ab/6c44d97b24fa51ec249759cb62828aab.png" alt="">
|
||||
|
||||
其实,我们只是换成了Log4j2 API,真正的日志记录还是走的Logback框架。没错,这就是SLF4J适配的一个好处。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
我将记录日志的坑,总结为框架使用配置和记录本身两个方面。
|
||||
|
||||
Java的日志框架众多,SLF4J实现了这些框架记录日志的统一。在使用SLF4J时,我们需要理清楚其桥接API和绑定这两个模块。如果程序启动时出现SLF4J的错误提示,那很可能是配置出现了问题,可以使用Maven的dependency:tree命令梳理依赖关系。
|
||||
|
||||
Logback是Java最常用的日志框架,其配置比较复杂,你可以参考官方文档中关于Appender、Layout、Filter的配置,切记不要随意从其他地方复制别人的配置,避免出现错误或与当前需求不符。
|
||||
|
||||
使用异步日志解决性能问题,是用空间换时间。但空间毕竟有限,当空间满了之后,我们要考虑是阻塞等待,还是丢弃日志。如果更希望不丢弃重要日志,那么选择阻塞等待;如果更希望程序不要因为日志记录而阻塞,那么就需要丢弃日志。
|
||||
|
||||
最后,我强调的是,日志框架提供的参数化日志记录方式不能完全取代日志级别的判断。如果你的日志量很大,获取日志参数代价也很大,就要进行相应日志级别的判断,避免不记录日志也要花费时间获取日志参数的问题。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 在第一小节的案例中,我们把INFO级别的日志存放到_info.log中,把WARN和ERROR级别的日志存放到_error.log中。如果现在要把INFO和WARN级别的日志存放到_info.log中,把ERROR日志存放到_error.log中,应该如何配置Logback呢?
|
||||
1. 生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理,以避免单个文件太大,同时保留一定天数的历史日志,你知道如何配置吗?可以在[官方文档](http://logback.qos.ch/manual/appenders.html#RollingFileAppender)找到答案。
|
||||
|
||||
针对日志记录和配置,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
450
极客时间专栏/Java业务开发常见错误100例/代码篇/14 | 文件IO:实现高效正确的文件读写并非易事.md
Normal file
450
极客时间专栏/Java业务开发常见错误100例/代码篇/14 | 文件IO:实现高效正确的文件读写并非易事.md
Normal file
@@ -0,0 +1,450 @@
|
||||
<audio id="audio" title="14 | 文件IO:实现高效正确的文件读写并非易事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/49/965eeca1fbb99db6f2df80c30d845649.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我们来聊聊如何实现高效、正确的文件操作。
|
||||
|
||||
随着数据库系统的成熟和普及,需要直接做文件IO操作的需求越来越少,这就导致我们对相关API不够熟悉,以至于遇到类似文件导出、三方文件对账等需求时,只能临时抱佛脚,随意搜索一些代码完成需求,出现性能问题或者Bug后不知从何处入手。
|
||||
|
||||
今天这篇文章,我就会从字符编码、缓冲区和文件句柄释放这3个常见问题出发,和你分享如何解决与文件操作相关的性能问题或者Bug。如果你对文件操作相关的API不够熟悉,可以查看[Oracle官网的介绍](https://docs.oracle.com/javase/tutorial/essential/io/)。
|
||||
|
||||
## 文件读写需要确保字符编码一致
|
||||
|
||||
有一个项目需要读取三方的对账文件定时对账,原先一直是单机处理的,没什么问题。后来为了提升性能,使用双节点同时处理对账,每一个节点处理部分对账数据,但新增的节点在处理文件中中文的时候总是读取到乱码。
|
||||
|
||||
程序代码都是一致的,为什么老节点就不会有问题呢?我们知道,这很可能是写代码时没有注意编码问题导致的。接下来,我们就分析下这个问题吧。
|
||||
|
||||
为模拟这个场景,我们使用GBK编码把“你好hi”写入一个名为hello.txt的文本文件,然后直接以字节数组形式读取文件内容,转换为十六进制字符串输出到日志中:
|
||||
|
||||
```
|
||||
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());
|
||||
|
||||
```
|
||||
|
||||
输出如下:
|
||||
|
||||
```
|
||||
13:06:28.955 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - bytes:C4E3BAC36869
|
||||
|
||||
```
|
||||
|
||||
虽然我们打开文本文件时看到的是“你好hi”,但不管是什么文字,计算机中都是按照一定的规则将其以二进制保存的。这个规则就是字符集,字符集枚举了所有支持的字符映射成二进制的映射表。在处理文件读写的时候,如果是在字节层面进行操作,那么不会涉及字符编码问题;而如果需要在字符层面进行读写的话,就需要明确字符的编码方式也就是字符集了。
|
||||
|
||||
当时出现问题的文件读取代码是这样的:
|
||||
|
||||
```
|
||||
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);
|
||||
|
||||
```
|
||||
|
||||
可以看到,是使用了FileReader类以字符方式进行文件读取,日志中读取出来的“你好”变为了乱码:
|
||||
|
||||
```
|
||||
13:06:28.961 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - result:<3A><><EFBFBD>hi
|
||||
|
||||
```
|
||||
|
||||
显然,这里并没有指定以什么字符集来读取文件中的字符。查看[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/io/FileReader.html)可以发现,**FileReader是以当前机器的默认字符集来读取文件的**,如果希望指定字符集的话,需要直接使用InputStreamReader和FileInputStream。
|
||||
|
||||
到这里我们就明白了,FileReader虽然方便但因为使用了默认字符集对环境产生了依赖,这就是为什么老的机器上程序可以正常运作,在新节点上读取中文时却产生了乱码。
|
||||
|
||||
那,怎么确定当前机器的默认字符集呢?写一段代码输出当前机器的默认字符集,以及UTF-8方式编码的“你好hi”的十六进制字符串:
|
||||
|
||||
```
|
||||
log.info("charset: {}", Charset.defaultCharset());
|
||||
Files.write(Paths.get("hello2.txt"), "你好hi".getBytes(Charsets.UTF_8));
|
||||
log.info("bytes:{}", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello2.txt"))).toUpperCase());
|
||||
|
||||
```
|
||||
|
||||
输出结果如下:
|
||||
|
||||
```
|
||||
13:06:28.961 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - charset: UTF-8
|
||||
13:06:28.962 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - bytes:E4BDA0E5A5BD6869
|
||||
|
||||
```
|
||||
|
||||
可以看到,当前机器默认字符集是UTF-8,当然无法读取GBK编码的汉字。UTF-8编码的“你好”的十六进制是E4BDA0E5A5BD,每一个汉字需要三个字节;而GBK编码的汉字,每一个汉字两个字节。字节长度都不一样,以GBK编码后保存的汉字,以UTF8进行解码读取,必然不会成功。
|
||||
|
||||
定位到问题后,修复就很简单了。按照文档所说,直接使用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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从日志中看到,修复后的代码正确读取到了“你好Hi”。
|
||||
|
||||
```
|
||||
13:06:28.963 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - result: 你好hi
|
||||
|
||||
```
|
||||
|
||||
如果你觉得这种方式比较麻烦的话,使用JDK1.7推出的Files类的readAllLines方法,可以很方便地用一行代码完成文件内容读取:
|
||||
|
||||
```
|
||||
log.info("result: {}", Files.readAllLines(Paths.get("hello.txt"), Charset.forName("GBK")).stream().findFirst().orElse(""));
|
||||
|
||||
```
|
||||
|
||||
**但这种方式有个问题是,读取超出内存大小的大文件时会出现OOM**。为什么呢?
|
||||
|
||||
打开readAllLines方法的源码可以看到,readAllLines读取文件所有内容后,放到一个List<String>中返回,如果内存无法容纳这个List,就会OOM:
|
||||
|
||||
```
|
||||
public static List<String> readAllLines(Path path, Charset cs) throws IOException {
|
||||
try (BufferedReader reader = newBufferedReader(path, cs)) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (;;) {
|
||||
String line = reader.readLine();
|
||||
if (line == null)
|
||||
break;
|
||||
result.add(line);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那么,有没有办法实现按需的流式读取呢?比如,需要消费某行数据时再读取,而不是把整个文件一次性读取到内存?
|
||||
|
||||
当然有,解决方案就是File类的lines方法。接下来,我就与你说说使用lines方法时需要注意的一些问题。
|
||||
|
||||
## 使用Files类静态方法进行文件操作注意释放文件句柄
|
||||
|
||||
与readAllLines方法返回List<String>不同,lines方法返回的是Stream<String>。这,使得我们在需要时可以不断读取、使用文件中的内容,而不是一次性地把所有内容都读取到内存中,因此避免了OOM。
|
||||
|
||||
接下来,我通过一段代码测试一下。我们尝试读取一个1亿1万行的文件,文件占用磁盘空间超过4GB。如果使用-Xmx512m -Xms512m启动JVM控制最大堆内存为512M的话,肯定无法一次性读取这样的大文件,但通过Files.lines方法就没问题。
|
||||
|
||||
在下面的代码中,首先输出这个文件的大小,然后计算读取20万行数据和200万行数据的耗时差异,最后逐行读取文件,统计文件的总行数:
|
||||
|
||||
```
|
||||
//输出文件大小
|
||||
log.info("file size:{}", Files.size(Paths.get("test.txt")));
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
stopWatch.start("read 200000 lines");
|
||||
//使用Files.lines方法读取20万行数据
|
||||
log.info("lines {}", Files.lines(Paths.get("test.txt")).limit(200000).collect(Collectors.toList()).size());
|
||||
stopWatch.stop();
|
||||
stopWatch.start("read 2000000 lines");
|
||||
//使用Files.lines方法读取200万行数据
|
||||
log.info("lines {}", Files.lines(Paths.get("test.txt")).limit(2000000).collect(Collectors.toList()).size());
|
||||
stopWatch.stop();
|
||||
log.info(stopWatch.prettyPrint());
|
||||
AtomicLong atomicLong = new AtomicLong();
|
||||
//使用Files.lines方法统计文件总行数
|
||||
Files.lines(Paths.get("test.txt")).forEach(line->atomicLong.incrementAndGet());
|
||||
log.info("total lines {}", atomicLong.get());
|
||||
|
||||
```
|
||||
|
||||
输出结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/77/29ee0fd687642ed87badaa96f9bdfd77.png" alt="">
|
||||
|
||||
可以看到,实现了全文件的读取、统计了整个文件的行数,并没有出现OOM;读取200万行数据耗时760ms,读取20万行数据仅需267ms。这些都可以说明,File.lines方法并不是一次性读取整个文件的,而是按需读取。
|
||||
|
||||
到这里,你觉得这段代码有什么问题吗?
|
||||
|
||||
问题在于读取完文件后没有关闭。我们通常会认为静态方法的调用不涉及资源释放,因为方法调用结束自然代表资源使用完成,由API释放资源,但对于Files类的一些返回Stream的方法并不是这样。这,是一个很容易被忽略的严重问题。
|
||||
|
||||
我就曾遇到过一个案例:程序在生产上运行一段时间后就会出现too many files的错误,我们想当然地认为是OS设置的最大文件句柄太小了,就让运维放开这个限制,但放开后还是会出现这样的问题。经排查发现,其实是文件句柄没有释放导致的,问题就出在Files.lines方法上。
|
||||
|
||||
我们来重现一下这个问题,随便写入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);
|
||||
|
||||
```
|
||||
|
||||
然后使用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());
|
||||
|
||||
```
|
||||
|
||||
运行后马上可以在日志中看到如下错误:
|
||||
|
||||
```
|
||||
java.nio.file.FileSystemException: demo.txt: Too many open files
|
||||
at sun.nio.fs.UnixException.translateToIOException(UnixException.java:91)
|
||||
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102)
|
||||
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:107)
|
||||
|
||||
```
|
||||
|
||||
使用lsof命令查看进程打开的文件,可以看到打开了1万多个demo.txt:
|
||||
|
||||
```
|
||||
lsof -p 63937
|
||||
...
|
||||
java 63902 zhuye *238r REG 1,4 370 12934160647 /Users/zhuye/Documents/common-mistakes/demo.txt
|
||||
java 63902 zhuye *239r REG 1,4 370 12934160647 /Users/zhuye/Documents/common-mistakes/demo.txt
|
||||
...
|
||||
|
||||
lsof -p 63937 | grep demo.txt | wc -l
|
||||
10007
|
||||
|
||||
```
|
||||
|
||||
**其实,在[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/nio/file/Files.html)中有提到,注意使用try-with-resources方式来配合,确保流的close方法可以调用释放资源。**
|
||||
|
||||
这也很容易理解,使用流式处理,如果不显式地告诉程序什么时候用完了流,程序又如何知道呢,它也不能帮我们做主何时关闭文件。
|
||||
|
||||
修复方式很简单,使用try来包裹Stream即可:
|
||||
|
||||
```
|
||||
LongAdder longAdder = new LongAdder();
|
||||
IntStream.rangeClosed(1, 1000000).forEach(i -> {
|
||||
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());
|
||||
|
||||
```
|
||||
|
||||
修改后的代码不再出现错误日志,因为读取了100万次包含10行数据的文件,所以最终正确输出了1000万:
|
||||
|
||||
```
|
||||
14:19:29.410 [main] INFO org.geekbang.time.commonmistakes.io.demo2.FilesStreamOperationNeedCloseApplication - total : 10000000
|
||||
|
||||
```
|
||||
|
||||
查看lines方法源码可以发现,Stream的close注册了一个回调,来关闭BufferedReader进行资源释放:
|
||||
|
||||
```
|
||||
public static Stream<String> lines(Path path, Charset cs) throws IOException {
|
||||
BufferedReader br = Files.newBufferedReader(path, cs);
|
||||
try {
|
||||
return br.lines().onClose(asUncheckedRunnable(br));
|
||||
} catch (Error|RuntimeException e) {
|
||||
try {
|
||||
br.close();
|
||||
} catch (IOException ex) {
|
||||
try {
|
||||
e.addSuppressed(ex);
|
||||
} catch (Throwable ignore) {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static Runnable asUncheckedRunnable(Closeable c) {
|
||||
return () -> {
|
||||
try {
|
||||
c.close();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从命名上可以看出,使用BufferedReader进行字符流读取时,用到了缓冲。这里缓冲Buffer的意思是,使用一块内存区域作为直接操作的中转。
|
||||
|
||||
比如,读取文件操作就是一次性读取一大块数据(比如8KB)到缓冲区,后续的读取可以直接从缓冲区返回数据,而不是每次都直接对应文件IO。写操作也是类似。如果每次写几十字节到文件都对应一次IO操作,那么写一个几百兆的大文件可能就需要千万次的IO操作,耗时会非常久。
|
||||
|
||||
接下来,我就通过几个实验,和你说明使用缓冲Buffer的重要性,并对比下不同使用方式的文件读写性能,来帮助你用对、用好Buffer。
|
||||
|
||||
## 注意读写文件要考虑设置缓冲区
|
||||
|
||||
我曾遇到过这么一个案例,一段先进行文件读入再简单处理后写入另一个文件的业务代码,由于开发人员使用了单字节的读取写入方式,导致执行得巨慢,业务量上来后需要数小时才能完成。
|
||||
|
||||
我们来模拟一下相关实现。创建一个文件随机写入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);
|
||||
|
||||
```
|
||||
|
||||
当时开发人员写的文件处理代码大概是这样的:使用FileInputStream获得一个文件输入流,然后调用其read方法每次读取一个字节,最后通过一个FileOutputStream文件输出流把处理后的结果写入另一个文件。
|
||||
|
||||
为了简化逻辑便于理解,这里我们不对数据进行处理,直接把原文件数据写入目标文件,相当于文件复制:
|
||||
|
||||
```
|
||||
private static void perByteOperation() throws IOException {
|
||||
try (FileInputStream fileInputStream = new FileInputStream("src.txt");
|
||||
FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
|
||||
int i;
|
||||
while ((i = fileInputStream.read()) != -1) {
|
||||
fileOutputStream.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样的实现,复制一个35MB的文件居然耗时190秒。
|
||||
|
||||
**显然,每读取一个字节、每写入一个字节都进行一次IO操作,代价太大了**。解决方案就是,考虑使用缓冲区作为过渡,一次性从原文件读取一定数量的数据到缓冲区,一次性写入一定数量的数据到目标文件。
|
||||
|
||||
改良后,使用100字节作为缓冲区,使用FileInputStream的byte[]的重载来一次性读取一定字节的数据,同时使用FileOutputStream的byte[]的重载实现一次性从缓冲区写入一定字节的数据到文件:
|
||||
|
||||
```
|
||||
private static void bufferOperationWith100Buffer() throws IOException {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
仅仅使用了100个字节的缓冲区作为过渡,完成35M文件的复制耗时缩短到了26秒,是无缓冲时性能的7倍;如果把缓冲区放大到1000字节,耗时可以进一步缩短到342毫秒。可以看到,**在进行文件IO处理的时候,使用合适的缓冲区可以明显提高性能**。
|
||||
|
||||
你可能会说,实现文件读写还要自己new一个缓冲区出来,太麻烦了,不是有一个BufferedInputStream和BufferedOutputStream可以实现输入输出流的缓冲处理吗?
|
||||
|
||||
是的,它们在内部实现了一个默认8KB大小的缓冲区。但是,在使用BufferedInputStream和BufferedOutputStream时,我还是建议你再使用一个缓冲进行读写,不要因为它们实现了内部缓冲就进行逐字节的操作。
|
||||
|
||||
接下来,我写一段代码比较下使用下面三种方式读写一个字节的性能:
|
||||
|
||||
- 直接使用BufferedInputStream和BufferedOutputStream;
|
||||
- 额外使用一个8KB缓冲,使用BufferedInputStream和BufferedOutputStream;
|
||||
- 直接使用FileInputStream和FileOutputStream,再使用一个8KB的缓冲。
|
||||
|
||||
```
|
||||
//使用BufferedInputStream和BufferedOutputStream
|
||||
private static void bufferedStreamByteOperation() throws IOException {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
//额外使用一个8KB缓冲,再使用BufferedInputStream和BufferedOutputStream
|
||||
private static void bufferedStreamBufferOperation() throws IOException {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
//直接使用FileInputStream和FileOutputStream,再使用一个8KB的缓冲
|
||||
private static void largerBufferOperation() throws IOException {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
结果如下:
|
||||
|
||||
```
|
||||
---------------------------------------------
|
||||
ns % Task name
|
||||
---------------------------------------------
|
||||
1424649223 086% bufferedStreamByteOperation
|
||||
117807808 007% bufferedStreamBufferOperation
|
||||
112153174 007% largerBufferOperation
|
||||
|
||||
```
|
||||
|
||||
可以看到,第一种方式虽然使用了缓冲流,但逐字节的操作因为方法调用次数实在太多还是慢,耗时1.4秒;后面两种方式的性能差不多,耗时110毫秒左右。虽然第三种方式没有使用缓冲流,但使用了8KB大小的缓冲区,和缓冲流默认的缓冲区大小相同。
|
||||
|
||||
看到这里,你可能会疑惑了,既然这样使用BufferedInputStream和BufferedOutputStream有什么意义呢?
|
||||
|
||||
其实,这里我是为了演示所以示例三使用了固定大小的缓冲区,但在实际代码中每次需要读取的字节数很可能不是固定的,有的时候读取几个字节,有的时候读取几百字节,这个时候有一个固定大小较大的缓冲,也就是使用BufferedInputStream和BufferedOutputStream做为后备的稳定的二次缓冲,就非常有意义了。
|
||||
|
||||
最后我要补充说明的是,对于类似的文件复制操作,如果希望有更高性能,可以使用FileChannel的transfreTo方法进行流的复制。在一些操作系统(比如高版本的Linux和UNIX)上可以实现DMA(直接内存访问),也就是数据从磁盘经过总线直接发送到目标文件,无需经过内存和CPU进行数据中转:
|
||||
|
||||
```
|
||||
private static void fileChannelOperation() throws IOException {
|
||||
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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以通过[这篇文章](https://developer.ibm.com/articles/j-zerocopy/),了解transferTo方法的更多细节。
|
||||
|
||||
在测试FileChannel性能的同时,我再运行一下这一小节中的所有实现,比较一下读写35MB文件的耗时。
|
||||
|
||||
```
|
||||
---------------------------------------------
|
||||
ns % Task name
|
||||
---------------------------------------------
|
||||
183673362265 098% perByteOperation
|
||||
2034504694 001% bufferOperationWith100Buffer
|
||||
749967898 000% bufferedStreamByteOperation
|
||||
110602155 000% bufferedStreamBufferOperation
|
||||
114542834 000% largerBufferOperation
|
||||
050068602 000% fileChannelOperation
|
||||
|
||||
```
|
||||
|
||||
可以看到,最慢的是单字节读写文件流的方式,耗时183秒,最快的是FileChannel.transferTo方式进行流转发的方式,耗时50毫秒。两者耗时相差达到3600倍!
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我通过三个案例和你分享了文件读写操作中最重要的几个方面。
|
||||
|
||||
第一,如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致的,否则可能产生乱码。
|
||||
|
||||
第二,使用Files类的一些流式处理操作,注意使用try-with-resources包装Stream,确保底层文件资源可以释放,避免产生too many open files的问题。
|
||||
|
||||
第三,进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批量读写减少IO次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream,追求极限性能的话可以考虑使用FileChannel进行流转发。
|
||||
|
||||
最后我要强调的是,文件操作因为涉及操作系统和文件系统的实现,JDK并不能确保所有IO API在所有平台的逻辑一致性,代码迁移到新的操作系统或文件系统时,要重新进行功能测试和性能测试。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. Files.lines方法进行流式处理,需要使用try-with-resources进行资源释放。那么,使用Files类中其他返回Stream包装对象的方法进行流式处理,比如newDirectoryStream方法返回DirectoryStream<Path>,list、walk和find方法返回Stream<Path>,也同样有资源释放问题吗?
|
||||
1. Java的File类和Files类提供的文件复制、重命名、删除等操作,是原子性的吗?
|
||||
|
||||
对于文件操作,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
773
极客时间专栏/Java业务开发常见错误100例/代码篇/15 | 序列化:一来一回你还是原来的你吗?.md
Normal file
773
极客时间专栏/Java业务开发常见错误100例/代码篇/15 | 序列化:一来一回你还是原来的你吗?.md
Normal file
@@ -0,0 +1,773 @@
|
||||
<audio id="audio" title="15 | 序列化:一来一回你还是原来的你吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/f6/f22ac73a565ee8dfcb28a99f300629f6.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来和你聊聊序列化相关的坑和最佳实践。
|
||||
|
||||
序列化是把对象转换为字节流的过程,以方便传输或存储。反序列化,则是反过来把字节流转换为对象的过程。在介绍[文件IO](https://time.geekbang.org/column/article/223051)的时候,我提到字符编码是把字符转换为二进制的过程,至于怎么转换需要由字符集制定规则。同样地,对象的序列化和反序列化,也需要由序列化算法制定规则。
|
||||
|
||||
关于序列化算法,几年前常用的有JDK(Java)序列化、XML序列化等,但前者不能跨语言,后者性能较差(时间空间开销大);现在RESTful应用最常用的是JSON序列化,追求性能的RPC框架(比如gRPC)使用protobuf序列化,这2种方法都是跨语言的,而且性能不错,应用广泛。
|
||||
|
||||
在架构设计阶段,我们可能会重点关注算法选型,在性能、易用性和跨平台性等中权衡,不过这里的坑比较少。通常情况下,序列化问题常见的坑会集中在业务场景中,比如Redis、参数和响应序列化反序列化。
|
||||
|
||||
今天,我们就一起聊聊开发中序列化常见的一些坑吧。
|
||||
|
||||
## 序列化和反序列化需要确保算法一致
|
||||
|
||||
业务代码中涉及序列化时,很重要的一点是要确保序列化和反序列化的算法一致性。有一次我要排查缓存命中率问题,需要运维同学帮忙拉取Redis中的Key,结果他反馈Redis中存的都是乱码,怀疑Redis被攻击了。其实呢,这个问题就是序列化算法导致的,我们来看下吧。
|
||||
|
||||
在这个案例中,开发同学使用RedisTemplate来操作Redis进行数据缓存。因为相比于Jedis,使用Spring提供的RedisTemplate操作Redis,除了无需考虑连接池、更方便外,还可以与Spring Cache等其他组件无缝整合。如果使用Spring Boot的话,无需任何配置就可以直接使用。
|
||||
|
||||
数据(包含Key和Value)要保存到Redis,需要经过序列化算法来序列化成字符串。虽然Redis支持多种数据结构,比如Hash,但其每一个field的Value还是字符串。如果Value本身也是字符串的话,能否有便捷的方式来使用RedisTemplate,而无需考虑序列化呢?
|
||||
|
||||
其实是有的,那就是StringRedisTemplate。
|
||||
|
||||
那StringRedisTemplate和RedisTemplate的区别是什么呢?开头提到的乱码又是怎么回事呢?带着这些问题让我们来研究一下吧。
|
||||
|
||||
写一段测试代码,在应用初始化完成后向Redis设置两组数据,第一次使用RedisTemplate设置Key为redisTemplate、Value为User对象,第二次使用StringRedisTemplate设置Key为stringRedisTemplate、Value为JSON序列化后的User对象:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private RedisTemplate redisTemplate;
|
||||
@Autowired
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@PostConstruct
|
||||
public void init() throws JsonProcessingException {
|
||||
redisTemplate.opsForValue().set("redisTemplate", new User("zhuye", 36));
|
||||
stringRedisTemplate.opsForValue().set("stringRedisTemplate", objectMapper.writeValueAsString(new User("zhuye", 36)));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你认为,StringRedisTemplate和RedisTemplate的区别,无非是读取的Value是String和Object,那就大错特错了,因为使用这两种方式存取的数据完全无法通用。
|
||||
|
||||
我们做个小实验,通过RedisTemplate读取Key为stringRedisTemplate的Value,使用StringRedisTemplate读取Key为redisTemplate的Value:
|
||||
|
||||
```
|
||||
log.info("redisTemplate get {}", redisTemplate.opsForValue().get("stringRedisTemplate"));
|
||||
log.info("stringRedisTemplate get {}", stringRedisTemplate.opsForValue().get("redisTemplate"));
|
||||
|
||||
```
|
||||
|
||||
结果是,两次都无法读取到Value:
|
||||
|
||||
```
|
||||
[11:49:38.478] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:38 ] - redisTemplate get null
|
||||
[11:49:38.481] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:39 ] - stringRedisTemplate get null
|
||||
|
||||
```
|
||||
|
||||
通过redis-cli客户端工具连接到Redis,你会发现根本就没有叫作redisTemplate的Key,所以StringRedisTemplate无法查到数据:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/35/0a86608821f52833e3ffaecb69945635.png" alt="">
|
||||
|
||||
查看RedisTemplate的源码发现,默认情况下RedisTemplate针对Key和Value使用了JDK序列化:
|
||||
|
||||
```
|
||||
public void afterPropertiesSet() {
|
||||
...
|
||||
if (defaultSerializer == null) {
|
||||
defaultSerializer = new JdkSerializationRedisSerializer(
|
||||
classLoader != null ? classLoader : this.getClass().getClassLoader());
|
||||
}
|
||||
if (enableDefaultSerializer) {
|
||||
if (keySerializer == null) {
|
||||
keySerializer = defaultSerializer;
|
||||
defaultUsed = true;
|
||||
}
|
||||
if (valueSerializer == null) {
|
||||
valueSerializer = defaultSerializer;
|
||||
defaultUsed = true;
|
||||
}
|
||||
if (hashKeySerializer == null) {
|
||||
hashKeySerializer = defaultSerializer;
|
||||
defaultUsed = true;
|
||||
}
|
||||
if (hashValueSerializer == null) {
|
||||
hashValueSerializer = defaultSerializer;
|
||||
defaultUsed = true;
|
||||
}
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**redis-cli看到的类似一串乱码的"\xac\xed\x00\x05t\x00\rredisTemplate"字符串,其实就是字符串redisTemplate经过JDK序列化后的结果**。这就回答了之前提到的乱码问题。而RedisTemplate尝试读取Key为stringRedisTemplate数据时,也会对这个字符串进行JDK序列化处理,所以同样无法读取到数据。
|
||||
|
||||
而StringRedisTemplate对于Key和Value,使用的是String序列化方式,Key和Value只能是String:
|
||||
|
||||
```
|
||||
public class StringRedisTemplate extends RedisTemplate<String, String> {
|
||||
public StringRedisTemplate() {
|
||||
setKeySerializer(RedisSerializer.string());
|
||||
setValueSerializer(RedisSerializer.string());
|
||||
setHashKeySerializer(RedisSerializer.string());
|
||||
setHashValueSerializer(RedisSerializer.string());
|
||||
}
|
||||
}
|
||||
|
||||
public class StringRedisSerializer implements RedisSerializer<String> {
|
||||
@Override
|
||||
public String deserialize(@Nullable byte[] bytes) {
|
||||
return (bytes == null ? null : new String(bytes, charset));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] serialize(@Nullable String string) {
|
||||
return (string == null ? null : string.getBytes(charset));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这里,我们应该知道RedisTemplate和StringRedisTemplate保存的数据无法通用。修复方式就是,让它们读取自己存的数据:
|
||||
|
||||
- 使用RedisTemplate读出的数据,由于是Object类型的,使用时可以先强制转换为User类型;
|
||||
- 使用StringRedisTemplate读取出的字符串,需要手动将JSON反序列化为User类型。
|
||||
|
||||
```
|
||||
//使用RedisTemplate获取Value,无需反序列化就可以拿到实际对象,虽然方便,但是Redis中保存的Key和Value不易读
|
||||
User userFromRedisTemplate = (User) redisTemplate.opsForValue().get("redisTemplate");
|
||||
log.info("redisTemplate get {}", userFromRedisTemplate);
|
||||
|
||||
//使用StringRedisTemplate,虽然Key正常,但是Value存取需要手动序列化成字符串
|
||||
User userFromStringRedisTemplate = objectMapper.readValue(stringRedisTemplate.opsForValue().get("stringRedisTemplate"), User.class);
|
||||
log.info("stringRedisTemplate get {}", userFromStringRedisTemplate);
|
||||
|
||||
```
|
||||
|
||||
这样就可以得到正确输出:
|
||||
|
||||
```
|
||||
[13:32:09.087] [http-nio-45678-exec-6] [INFO ] [.t.c.s.demo1.RedisTemplateController:45 ] - redisTemplate get User(name=zhuye, age=36)
|
||||
[13:32:09.092] [http-nio-45678-exec-6] [INFO ] [.t.c.s.demo1.RedisTemplateController:47 ] - stringRedisTemplate get User(name=zhuye, age=36)
|
||||
|
||||
```
|
||||
|
||||
看到这里你可能会说,使用RedisTemplate获取Value虽然方便,但是Key和Value不易读;而使用StringRedisTemplate虽然Key是普通字符串,但是Value存取需要手动序列化成字符串,有没有两全其美的方式呢?
|
||||
|
||||
当然有,自己定义RedisTemplate的Key和Value的序列化方式即可:Key的序列化使用RedisSerializer.string()(也就是StringRedisSerializer方式)实现字符串序列化,而Value的序列化使用Jackson2JsonRedisSerializer:
|
||||
|
||||
```
|
||||
@Bean
|
||||
public <T> RedisTemplate<String, T> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
|
||||
RedisTemplate<String, T> redisTemplate = new RedisTemplate<>();
|
||||
redisTemplate.setConnectionFactory(redisConnectionFactory);
|
||||
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
|
||||
redisTemplate.setKeySerializer(RedisSerializer.string());
|
||||
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
|
||||
redisTemplate.setHashKeySerializer(RedisSerializer.string());
|
||||
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
|
||||
redisTemplate.afterPropertiesSet();
|
||||
return redisTemplate;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
写代码测试一下存取,直接注入类型为RedisTemplate<String, User>的userRedisTemplate字段,然后在right2方法中,使用注入的userRedisTemplate存入一个User对象,再分别使用userRedisTemplate和StringRedisTemplate取出这个对象:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private RedisTemplate<String, User> userRedisTemplate;
|
||||
|
||||
@GetMapping("right2")
|
||||
public void right2() {
|
||||
User user = new User("zhuye", 36);
|
||||
userRedisTemplate.opsForValue().set(user.getName(), user);
|
||||
Object userFromRedis = userRedisTemplate.opsForValue().get(user.getName());
|
||||
log.info("userRedisTemplate get {} {}", userFromRedis, userFromRedis.getClass());
|
||||
log.info("stringRedisTemplate get {}", stringRedisTemplate.opsForValue().get(user.getName()));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
乍一看没啥问题,StringRedisTemplate成功查出了我们存入的数据:
|
||||
|
||||
```
|
||||
[14:07:41.315] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:55 ] - userRedisTemplate get {name=zhuye, age=36} class java.util.LinkedHashMap
|
||||
[14:07:41.318] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:56 ] - stringRedisTemplate get {"name":"zhuye","age":36}
|
||||
|
||||
```
|
||||
|
||||
Redis里也可以查到Key是纯字符串,Value是JSON序列化后的User对象:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/cc/ac20bd2117053fafee390bbb6ce1eccc.png" alt="">
|
||||
|
||||
但值得注意的是,这里有一个坑。**第一行的日志输出显示,userRedisTemplate获取到的Value,是LinkedHashMap类型的**,完全不是泛型的RedisTemplate设置的User类型。
|
||||
|
||||
如果我们把代码里从Redis中获取到的Value变量类型由Object改为User,编译不会出现问题,但会出现ClassCastException:
|
||||
|
||||
```
|
||||
java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to org.geekbang.time.commonmistakes.serialization.demo1.User
|
||||
|
||||
```
|
||||
|
||||
修复方式是,修改自定义RestTemplate的代码,把new出来的Jackson2JsonRedisSerializer设置一个自定义的ObjectMapper,启用activateDefaultTyping方法把类型信息作为属性写入序列化后的数据中(当然了,你也可以调整JsonTypeInfo.As枚举以其他形式保存类型信息):
|
||||
|
||||
```
|
||||
...
|
||||
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
//把类型信息作为属性写入Value
|
||||
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
|
||||
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
或者,直接使用RedisSerializer.json()快捷方法,它内部使用的GenericJackson2JsonRedisSerializer直接设置了把类型作为属性保存到Value中:
|
||||
|
||||
```
|
||||
redisTemplate.setKeySerializer(RedisSerializer.string());
|
||||
redisTemplate.setValueSerializer(RedisSerializer.json());
|
||||
redisTemplate.setHashKeySerializer(RedisSerializer.string());
|
||||
redisTemplate.setHashValueSerializer(RedisSerializer.json());
|
||||
|
||||
```
|
||||
|
||||
重启程序调用right2方法进行测试,可以看到,从自定义的RedisTemplate中获取到的Value是User类型的(第一行日志),而且Redis中实际保存的Value包含了类型完全限定名(第二行日志):
|
||||
|
||||
```
|
||||
[15:10:50.396] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:55 ] - userRedisTemplate get User(name=zhuye, age=36) class org.geekbang.time.commonmistakes.serialization.demo1.User
|
||||
[15:10:50.399] [http-nio-45678-exec-1] [INFO ] [.t.c.s.demo1.RedisTemplateController:56 ] - stringRedisTemplate get ["org.geekbang.time.commonmistakes.serialization.demo1.User",{"name":"zhuye","age":36}]
|
||||
|
||||
```
|
||||
|
||||
因此,反序列化时可以直接得到User类型的Value。
|
||||
|
||||
通过对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类型。如果希望直接获取真实的数据类型,你可以启用Jackson ObjectMapper的activateDefaultTyping方法,把类型信息一起序列化保存在Value中。
|
||||
- 如果希望Value以JSON保存并带上类型信息,更简单的方式是,直接使用RedisSerializer.json()快捷方法来获取序列化器。
|
||||
|
||||
## 注意Jackson JSON反序列化对额外字段的处理
|
||||
|
||||
前面我提到,通过设置JSON序列化工具Jackson的activateDefaultTyping方法,可以在序列化数据时写入对象类型。其实,Jackson还有很多参数可以控制序列化和反序列化,是一个功能强大而完善的序列化工具。因此,很多框架都将Jackson作为JDK序列化工具,比如Spring Web。但也正是这个原因,我们使用时要小心各个参数的配置。
|
||||
|
||||
比如,在开发Spring Web应用程序时,如果自定义了ObjectMapper,并把它注册成了Bean,那很可能会导致Spring Web使用的ObjectMapper也被替换,导致Bug。
|
||||
|
||||
我们来看一个案例。程序一开始是正常的,某一天开发同学希望修改一下ObjectMapper的行为,让枚举序列化为索引值而不是字符串值,比如默认情况下序列化一个Color枚举中的Color.BLUE会得到字符串BLUE:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@GetMapping("test")
|
||||
public void test() throws JsonProcessingException {
|
||||
log.info("color:{}", objectMapper.writeValueAsString(Color.BLUE));
|
||||
}
|
||||
|
||||
enum Color {
|
||||
RED, BLUE
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
于是,这位同学就重新定义了一个ObjectMapper Bean,开启了WRITE_ENUMS_USING_INDEX功能特性:
|
||||
|
||||
```
|
||||
@Bean
|
||||
public ObjectMapper objectMapper(){
|
||||
ObjectMapper objectMapper=new ObjectMapper();
|
||||
objectMapper.configure(SerializationFeature.WRITE_ENUMS_USING_INDEX,true);
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
开启这个特性后,Color.BLUE枚举序列化成索引值1:
|
||||
|
||||
```
|
||||
[16:11:37.382] [http-nio-45678-exec-1] [INFO ] [c.s.d.JsonIgnorePropertiesController:19 ] - color:1
|
||||
|
||||
```
|
||||
|
||||
修改后处理枚举序列化的逻辑是满足了要求,但线上爆出了大量400错误,日志中也出现了很多UnrecognizedPropertyException:
|
||||
|
||||
```
|
||||
JSON parse error: Unrecognized field \"ver\" (class org.geekbang.time.commonmistakes.serialization.demo4.UserWrong), not marked as ignorable; nested exception is com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field \"version\" (class org.geekbang.time.commonmistakes.serialization.demo4.UserWrong), not marked as ignorable (one known property: \"name\"])\n at [Source: (PushbackInputStream); line: 1, column: 22] (through reference chain: org.geekbang.time.commonmistakes.serialization.demo4.UserWrong[\"ver\"])
|
||||
|
||||
```
|
||||
|
||||
从异常信息中可以看到,这是因为反序列化的时候,原始数据多了一个version属性。进一步分析发现,我们使用了UserWrong类型作为Web控制器wrong方法的入参,其中只有一个name属性:
|
||||
|
||||
```
|
||||
@Data
|
||||
public class UserWrong {
|
||||
private String name;
|
||||
}
|
||||
|
||||
@PostMapping("wrong")
|
||||
public UserWrong wrong(@RequestBody UserWrong user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而客户端实际传过来的数据多了一个version属性。那,为什么之前没这个问题呢?
|
||||
|
||||
问题就出在,**自定义ObjectMapper启用WRITE_ENUMS_USING_INDEX序列化功能特性时,覆盖了Spring Boot自动创建的ObjectMapper**;而这个自动创建的ObjectMapper设置过FAIL_ON_UNKNOWN_PROPERTIES反序列化特性为false,以确保出现未知字段时不要抛出异常。源码如下:
|
||||
|
||||
```
|
||||
public MappingJackson2HttpMessageConverter() {
|
||||
this(Jackson2ObjectMapperBuilder.json().build());
|
||||
}
|
||||
|
||||
|
||||
public class Jackson2ObjectMapperBuilder {
|
||||
|
||||
...
|
||||
|
||||
private void customizeDefaultFeatures(ObjectMapper objectMapper) {
|
||||
if (!this.features.containsKey(MapperFeature.DEFAULT_VIEW_INCLUSION)) {
|
||||
configureFeature(objectMapper, MapperFeature.DEFAULT_VIEW_INCLUSION, false);
|
||||
}
|
||||
if (!this.features.containsKey(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) {
|
||||
configureFeature(objectMapper, DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要修复这个问题,有三种方式:
|
||||
|
||||
- 第一种,同样禁用自定义的ObjectMapper的FAIL_ON_UNKNOWN_PROPERTIES:
|
||||
|
||||
```
|
||||
@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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 第二种,设置自定义类型,加上@JsonIgnoreProperties注解,开启ignoreUnknown属性,以实现反序列化时忽略额外的数据:
|
||||
|
||||
```
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class UserRight {
|
||||
private String name;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 第三种,不要自定义ObjectMapper,而是直接在配置文件设置相关参数,来修改Spring默认的ObjectMapper的功能。比如,直接在配置文件启用把枚举序列化为索引号:
|
||||
|
||||
```
|
||||
spring.jackson.serialization.write_enums_using_index=true
|
||||
|
||||
```
|
||||
|
||||
或者可以直接定义Jackson2ObjectMapperBuilderCustomizer Bean来启用新特性:
|
||||
|
||||
```
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer customizer(){
|
||||
return builder -> builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_INDEX);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个案例告诉我们两点:
|
||||
|
||||
- Jackson针对序列化和反序列化有大量的细节功能特性,我们可以参考Jackson官方文档来了解这些特性,详见[SerializationFeature](https://fasterxml.github.io/jackson-databind/javadoc/2.10/com/fasterxml/jackson/databind/SerializationFeature.html)、[DeserializationFeature](https://fasterxml.github.io/jackson-databind/javadoc/2.10/com/fasterxml/jackson/databind/DeserializationFeature.html)和[MapperFeature](https://fasterxml.github.io/jackson-databind/javadoc/2.10/com/fasterxml/jackson/databind/MapperFeature.html)。
|
||||
- 忽略多余字段,是我们写业务代码时最容易遇到的一个配置项。Spring Boot在自动配置时贴心地做了全局设置。如果需要设置更多的特性,可以直接修改配置文件spring.jackson.**或设置Jackson2ObjectMapperBuilderCustomizer回调接口,来启用更多设置,无需重新定义ObjectMapper Bean。
|
||||
|
||||
## 反序列化时要小心类的构造方法
|
||||
|
||||
使用Jackson反序列化时,除了要注意忽略额外字段的问题外,还要小心类的构造方法。我们看一个实际的踩坑案例吧。
|
||||
|
||||
有一个APIResult类包装了REST接口的返回体(作为Web控制器的出参),其中boolean类型的success字段代表是否处理成功、int类型的code字段代表处理状态码。
|
||||
|
||||
开始时,在返回APIResult的时候每次都根据code来设置success。如果code是2000,那么success是true,否则是false。后来为了减少重复代码,把这个逻辑放到了APIResult类的构造方法中处理:
|
||||
|
||||
```
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过改动后发现,即使code为2000,返回APIResult的success也是false。比如,我们反序列化两次APIResult,一次使用code==1234,一次使用code==2000:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
ObjectMapper objectMapper;
|
||||
|
||||
@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));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
日志输出如下:
|
||||
|
||||
```
|
||||
[17:36:14.591] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:20 ] - result :APIResultWrong(success=false, code=1234)
|
||||
[17:36:14.591] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:21 ] - result :APIResultWrong(success=false, code=2000)
|
||||
|
||||
```
|
||||
|
||||
可以看到,两次的APIResult的success字段都是false。
|
||||
|
||||
出现这个问题的原因是,**默认情况下,在反序列化的时候,Jackson框架只会调用无参构造方法创建对象**。如果走自定义的构造方法创建对象,需要通过@JsonCreator来指定构造方法,并通过@JsonProperty设置构造方法中参数对应的JSON属性名:
|
||||
|
||||
```
|
||||
@Data
|
||||
public class APIResultRight {
|
||||
...
|
||||
|
||||
@JsonCreator
|
||||
public APIResultRight(@JsonProperty("code") int code) {
|
||||
this.code = code;
|
||||
if (code == 2000) success = true;
|
||||
else success = false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重新运行程序,可以得到正确输出:
|
||||
|
||||
```
|
||||
[17:41:23.188] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:26 ] - result :APIResultRight(success=false, code=1234)
|
||||
[17:41:23.188] [http-nio-45678-exec-1] [INFO ] [DeserializationConstructorController:27 ] - result :APIResultRight(success=true, code=2000)
|
||||
|
||||
```
|
||||
|
||||
可以看到,这次传入code==2000时,success可以设置为true。
|
||||
|
||||
## 枚举作为API接口参数或返回值的两个大坑
|
||||
|
||||
在前面的例子中,我演示了如何把枚举序列化为索引值。但对于枚举,我建议尽量在程序内部使用,而不是作为API接口的参数或返回值,原因是枚举涉及序列化和反序列化时会有两个大坑。
|
||||
|
||||
**第一个坑是,客户端和服务端的枚举定义不一致时,会出异常。**比如,客户端版本的枚举定义了4个枚举值:
|
||||
|
||||
```
|
||||
@Getter
|
||||
enum StatusEnumClient {
|
||||
CREATED(1, "已创建"),
|
||||
PAID(2, "已支付"),
|
||||
DELIVERED(3, "已送到"),
|
||||
FINISHED(4, "已完成");
|
||||
|
||||
private final int status;
|
||||
private final String desc;
|
||||
|
||||
StatusEnumClient(Integer status, String desc) {
|
||||
this.status = status;
|
||||
this.desc = desc;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
服务端定义了5个枚举值:
|
||||
|
||||
```
|
||||
@Getter
|
||||
enum StatusEnumServer {
|
||||
...
|
||||
CANCELED(5, "已取消");
|
||||
|
||||
private final int status;
|
||||
private final String desc;
|
||||
|
||||
StatusEnumServer(Integer status, String desc) {
|
||||
this.status = status;
|
||||
this.desc = desc;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
写代码测试一下,使用RestTemplate来发起请求,让服务端返回客户端不存在的枚举值:
|
||||
|
||||
```
|
||||
@GetMapping("getOrderStatusClient")
|
||||
public void getOrderStatusClient() {
|
||||
StatusEnumClient result = restTemplate.getForObject("http://localhost:45678/enumusedinapi/getOrderStatus", StatusEnumClient.class);
|
||||
log.info("result {}", result);
|
||||
}
|
||||
|
||||
@GetMapping("getOrderStatus")
|
||||
public StatusEnumServer getOrderStatus() {
|
||||
return StatusEnumServer.CANCELED;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
访问接口会出现如下异常信息,提示在枚举StatusEnumClient中找不到CANCELED:
|
||||
|
||||
```
|
||||
JSON parse error: Cannot deserialize value of type `org.geekbang.time.commonmistakes.enums.enumusedinapi.StatusEnumClient` from String "CANCELED": not one of the values accepted for Enum class: [CREATED, FINISHED, DELIVERED, PAID];
|
||||
|
||||
```
|
||||
|
||||
要解决这个问题,可以开启Jackson的read_unknown_enum_values_using_default_value反序列化特性,也就是在枚举值未知的时候使用默认值:
|
||||
|
||||
```
|
||||
spring.jackson.deserialization.read_unknown_enum_values_using_default_value=true
|
||||
|
||||
```
|
||||
|
||||
并为枚举添加一个默认值,使用@JsonEnumDefaultValue注解注释:
|
||||
|
||||
```
|
||||
@JsonEnumDefaultValue
|
||||
UNKNOWN(-1, "未知");
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,这个枚举值一定是添加在客户端StatusEnumClient中的,因为反序列化使用的是客户端枚举。
|
||||
|
||||
这里还有一个小坑是,仅仅这样配置还不能让RestTemplate生效这个反序列化特性,还需要配置RestTemplate,来使用Spring Boot的MappingJackson2HttpMessageConverter才行:
|
||||
|
||||
```
|
||||
@Bean
|
||||
public RestTemplate restTemplate(MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter) {
|
||||
return new RestTemplateBuilder()
|
||||
.additionalMessageConverters(mappingJackson2HttpMessageConverter)
|
||||
.build();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,请求接口可以返回默认值了:
|
||||
|
||||
```
|
||||
[21:49:03.887] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:25 ] - result UNKNOWN
|
||||
|
||||
```
|
||||
|
||||
**第二个坑,也是更大的坑,枚举序列化反序列化实现自定义的字段非常麻烦,会涉及Jackson的Bug**。比如,下面这个接口,传入枚举List,为List增加一个CENCELED枚举值然后返回:
|
||||
|
||||
```
|
||||
@PostMapping("queryOrdersByStatusList")
|
||||
public List<StatusEnumServer> queryOrdersByStatus(@RequestBody List<StatusEnumServer> enumServers) {
|
||||
enumServers.add(StatusEnumServer.CANCELED);
|
||||
return enumServers;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们希望根据枚举的Desc字段来序列化,传入“已送到”作为入参:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/26/50a81c64fef9f9f3a8f57bccaaad5226.png" alt="">
|
||||
|
||||
会得到异常,提示“已送到”不是正确的枚举值:
|
||||
|
||||
```
|
||||
JSON parse error: Cannot deserialize value of type `org.geekbang.time.commonmistakes.enums.enumusedinapi.StatusEnumServer` from String "已送到": not one of the values accepted for Enum class: [CREATED, CANCELED, FINISHED, DELIVERED, PAID]
|
||||
|
||||
```
|
||||
|
||||
显然,这里反序列化使用的是枚举的name,序列化也是一样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/43/0455db08f97feb2382be6e3c8329da43.png" alt="">
|
||||
|
||||
你可能也知道,要让枚举的序列化和反序列化走desc字段,可以在字段上加@JsonValue注解,修改StatusEnumServer和StatusEnumClient:
|
||||
|
||||
```
|
||||
@JsonValue
|
||||
private final String desc;
|
||||
|
||||
```
|
||||
|
||||
然后再尝试下,果然可以用desc作为入参了,而且出参也使用了枚举的desc:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/2b/08acd86f0cabd174d45f6319bffa802b.png" alt="">
|
||||
|
||||
但是,如果你认为这样就完美解决问题了,那就大错特错了。你可以再尝试把@JsonValue注解加在int类型的status字段上,也就是希望序列化反序列化走status字段:
|
||||
|
||||
```
|
||||
@JsonValue
|
||||
private final int status;
|
||||
|
||||
```
|
||||
|
||||
写一个客户端测试一下,传入CREATED和PAID两个枚举值:
|
||||
|
||||
```
|
||||
@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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
请求接口可以看到,传入的是CREATED和PAID,返回的居然是DELIVERED和FINISHED。果然如标题所说,一来一回你已不是原来的你:
|
||||
|
||||
```
|
||||
[22:03:03.579] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:34 ] - result [DELIVERED, FINISHED, UNKNOWN]
|
||||
|
||||
```
|
||||
|
||||
出现这个问题的原因是,**序列化走了status的值,而反序列化并没有根据status来,还是使用了枚举的ordinal()索引值**。这是Jackson[至今(2.10)没有解决的Bug](https://github.com/FasterXML/jackson-databind/issues/1850),应该会在2.11解决。
|
||||
|
||||
如下图所示,我们调用服务端接口,传入一个不存在的status值0,也能反序列化成功,最后服务端的返回是1:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/bd/ac6cd2c0957d1654fced53e99eb556bd.png" alt="">
|
||||
|
||||
有一个解决办法是,设置@JsonCreator来强制反序列化时使用自定义的工厂方法,可以实现使用枚举的status字段来取值。我们把这段代码加在StatusEnumServer枚举类中:
|
||||
|
||||
```
|
||||
@JsonCreator
|
||||
public static StatusEnumServer parse(Object o) {
|
||||
return Arrays.stream(StatusEnumServer.values()).filter(value->o.equals(value.status)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要特别注意的是,我们同样要为StatusEnumClient也添加相应的方法。因为除了服务端接口接收StatusEnumServer参数涉及一次反序列化外,从服务端返回值转换为List还会有一次反序列化:
|
||||
|
||||
```
|
||||
@JsonCreator
|
||||
public static StatusEnumClient parse(Object o) {
|
||||
return Arrays.stream(StatusEnumClient.values()).filter(value->o.equals(value.status)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重新调用接口发现,虽然结果正确了,但是服务端不存在的枚举值CANCELED被设置为了null,而不是@JsonEnumDefaultValue设置的UNKNOWN。
|
||||
|
||||
这个问题,我们之前已经通过设置@JsonEnumDefaultValue注解解决了,但现在又出现了:
|
||||
|
||||
```
|
||||
[22:20:13.727] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:34 ] - result [CREATED, PAID, null]
|
||||
|
||||
```
|
||||
|
||||
原因也很简单,我们自定义的parse方法实现的是找不到枚举值时返回null。
|
||||
|
||||
为彻底解决这个问题,并避免通过@JsonCreator在枚举中自定义一个非常复杂的工厂方法,我们可以实现一个自定义的反序列化器。这段代码比较复杂,我特意加了详细的注释:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,把这个自定义反序列化器注册到Jackson中:
|
||||
|
||||
```
|
||||
@Bean
|
||||
public Module enumModule() {
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addDeserializer(Enum.class, new EnumDeserializer());
|
||||
return module;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二个大坑终于被完美地解决了:
|
||||
|
||||
```
|
||||
[22:32:28.327] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.e.e.EnumUsedInAPIController:34 ] - result [CREATED, PAID, UNKNOWN]
|
||||
|
||||
```
|
||||
|
||||
这样做,虽然解决了序列化反序列化使用枚举中自定义字段的问题,也解决了找不到枚举值时使用默认值的问题,但解决方案很复杂。因此,我还是建议在DTO中直接使用int或String等简单的数据类型,而不是使用枚举再配合各种复杂的序列化配置,来实现枚举到枚举中字段的映射,会更加清晰明了。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我基于Redis和Web API的入参和出参两个场景,和你介绍了序列化和反序列化时需要避开的几个坑。
|
||||
|
||||
第一,要确保序列化和反序列化算法的一致性。因为,不同序列化算法输出必定不同,要正确处理序列化后的数据就要使用相同的反序列化算法。
|
||||
|
||||
第二,Jackson有大量的序列化和反序列化特性,可以用来微调序列化和反序列化的细节。需要注意的是,如果自定义ObjectMapper的Bean,小心不要和Spring Boot自动配置的Bean冲突。
|
||||
|
||||
第三,在调试序列化反序列化问题时,我们一定要捋清楚三点:是哪个组件在做序列化反序列化、整个过程有几次序列化反序列化,以及目前到底是序列化还是反序列化。
|
||||
|
||||
第四,对于反序列化默认情况下,框架调用的是无参构造方法,如果要调用自定义的有参构造方法,那么需要告知框架如何调用。更合理的方式是,对于需要序列化的POJO考虑尽量不要自定义构造方法。
|
||||
|
||||
第五,枚举不建议定义在DTO中跨服务传输,因为会有版本问题,并且涉及序列化反序列化时会很复杂,容易出错。因此,我只建议在程序内部使用枚举。
|
||||
|
||||
最后还有一点需要注意,如果需要跨平台使用序列化的数据,那么除了两端使用的算法要一致外,还可能会遇到不同语言对数据类型的兼容问题。这,也是经常踩坑的一个地方。如果你有相关需求,可以多做实验、多测试。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 在讨论Redis序列化方式的时候,我们自定义了RedisTemplate,让Key使用String序列化、让Value使用JSON序列化,从而使Redis获得的Value可以直接转换为需要的对象类型。那么,使用RedisTemplate<String, Long>能否存取Value是Long的数据呢?这其中有什么坑吗?
|
||||
1. 你可以看一下Jackson2ObjectMapperBuilder类源码的实现(注意configure方法),分析一下其除了关闭FAIL_ON_UNKNOWN_PROPERTIES外,还做了什么吗?
|
||||
|
||||
关于序列化和反序列化,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
|
||||
583
极客时间专栏/Java业务开发常见错误100例/代码篇/16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑.md
Normal file
583
极客时间专栏/Java业务开发常见错误100例/代码篇/16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑.md
Normal file
@@ -0,0 +1,583 @@
|
||||
<audio id="audio" title="16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9d/65/9dc24d400103e60d9f3f300d3b9fa565.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我来和你说说恼人的时间错乱问题。
|
||||
|
||||
在Java 8之前,我们处理日期时间需求时,使用Date、Calender和SimpleDateFormat,来声明时间戳、使用日历处理日期和格式化解析日期时间。但是,这些类的API的缺点比较明显,比如可读性差、易用性差、使用起来冗余繁琐,还有线程安全问题。
|
||||
|
||||
因此,Java 8推出了新的日期时间类。每一个类功能明确清晰、类之间协作简单、API定义清晰不踩坑,API功能强大无需借助外部工具类即可完成操作,并且线程安全。
|
||||
|
||||
但是,Java 8刚推出的时候,诸如序列化、数据访问等类库都还不支持Java 8的日期时间类型,需要在新老类中来回转换。比如,在业务逻辑层使用LocalDateTime,存入数据库或者返回前端的时候还要切换回Date。因此,很多同学还是选择使用老的日期时间类。
|
||||
|
||||
现在几年时间过去了,几乎所有的类库都支持了新日期时间类型,使用起来也不会有来回切换等问题了。但,很多代码中因为还是用的遗留的日期时间类,因此出现了很多时间错乱的错误实践。比如,试图通过随意修改时区,使读取到的数据匹配当前时钟;再比如,试图直接对读取到的数据做加、减几个小时的操作,来“修正数据”。
|
||||
|
||||
今天,我就重点与你分析下时间错乱问题背后的原因,看看使用遗留的日期时间类,来处理日期时间初始化、格式化、解析、计算等可能会遇到的问题,以及如何使用新日期时间类来解决。
|
||||
|
||||
## 初始化日期时间
|
||||
|
||||
我们先从日期时间的初始化看起。如果要初始化一个2019年12月31日11点12分13秒这样的时间,可以使用下面的两行代码吗?
|
||||
|
||||
```
|
||||
Date date = new Date(2019, 12, 31, 11, 12, 13);
|
||||
System.out.println(date);
|
||||
|
||||
```
|
||||
|
||||
可以看到,输出的时间是3029年1月31日11点12分13秒:
|
||||
|
||||
```
|
||||
Sat Jan 31 11:12:13 CST 3920
|
||||
|
||||
```
|
||||
|
||||
相信看到这里,你会说这是新手才会犯的低级错误:年应该是和1900的差值,月应该是从0到11而不是从1到12。
|
||||
|
||||
```
|
||||
Date date = new Date(2019 - 1900, 11, 31, 11, 12, 13);
|
||||
|
||||
```
|
||||
|
||||
你说的没错,但更重要的问题是,当有国际化需求时,需要使用Calendar类来初始化时间。
|
||||
|
||||
使用Calendar改造之后,初始化时年参数直接使用当前年即可,不过月需要注意是从0到11。当然,你也可以直接使用Calendar.DECEMBER来初始化月份,更不容易犯错。为了说明时区的问题,我分别使用当前时区和纽约时区初始化了两次相同的日期:
|
||||
|
||||
```
|
||||
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());
|
||||
|
||||
```
|
||||
|
||||
输出显示了两个时间,说明时区产生了作用。但,我们更习惯年/月/日 时:分:秒这样的日期时间格式,对现在输出的日期格式还不满意:
|
||||
|
||||
```
|
||||
Tue Dec 31 11:12:13 CST 2019
|
||||
Wed Jan 01 00:12:13 CST 2020
|
||||
|
||||
```
|
||||
|
||||
那,时区的问题是怎么回事,又怎么格式化需要输出的日期时间呢?接下来,我就与你逐一分析下这两个问题。
|
||||
|
||||
## “恼人”的时区问题
|
||||
|
||||
我们知道,全球有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().getRawOffset()/3600000);
|
||||
|
||||
```
|
||||
|
||||
我得到的是1970年1月1日8点。因为我机器当前的时区是中国上海,相比UTC时差+8小时:
|
||||
|
||||
```
|
||||
Thu Jan 01 08:00:00 CST 1970
|
||||
Asia/Shanghai:8
|
||||
|
||||
```
|
||||
|
||||
对于国际化(世界各国的人都在使用)的项目,处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:
|
||||
|
||||
- 方式一,以UTC保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一时间。我们通常说的时间戳,或Java中的Date类就是用的这种方式,这也是推荐的方式。
|
||||
- 方式二,以字面量保存,比如年/月/日 时:分:秒,一定要同时保存时区信息。只有有了时区信息,我们才能知道这个字面量时间真正的时间点,否则它只是一个给人看的时间表示,只在当前时区有意义。Calendar是有时区概念的,所以我们通过不同的时区初始化Calendar,得到了不同的时间。
|
||||
|
||||
正确保存日期时间之后,就是正确展示,即我们要使用正确的时区,把时间点展示为符合当前时区的时间表示。到这里,我们就能理解为什么会有所谓的“时间错乱”问题了。接下来,我再通过实际案例分析一下,从字面量解析成时间和从时间格式化为字面量这两类问题。
|
||||
|
||||
**第一类是**,对于同一个时间表示,比如2020-01-02 22:00:00,不同时区的人转换成Date会得到不同的时间(时间戳):
|
||||
|
||||
```
|
||||
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());
|
||||
//纽约时区解析时间表示
|
||||
inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
|
||||
Date date2 = inputFormat.parse(stringDate);
|
||||
System.out.println(date2 + ":" + date2.getTime());
|
||||
|
||||
```
|
||||
|
||||
可以看到,把2020-01-02 22:00:00这样的时间表示,对于当前的上海时区和纽约时区,转化为UTC时间戳是不同的时间:
|
||||
|
||||
```
|
||||
Thu Jan 02 22:00:00 CST 2020:1577973600000
|
||||
Fri Jan 03 11:00:00 CST 2020:1578020400000
|
||||
|
||||
```
|
||||
|
||||
这正是UTC的意义,并不是时间错乱。对于同一个本地时间的表示,不同时区的人解析得到的UTC时间一定是不同的,反过来不同的本地时间可能对应同一个UTC。
|
||||
|
||||
**第二类问题是**,格式化后出现的错乱,即同一个Date,在不同的时区下格式化得到不同的时间表示。比如,在我的当前时区和纽约时区格式化2020-01-02 22:00:00:
|
||||
|
||||
```
|
||||
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));
|
||||
//纽约时区格式化输出
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"));
|
||||
System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss Z]").format(date));
|
||||
|
||||
```
|
||||
|
||||
输出如下,我当前时区的Offset(时差)是+8小时,对于-5小时的纽约,晚上10点对应早上9点:
|
||||
|
||||
```
|
||||
[2020-01-02 22:00:00 +0800]
|
||||
[2020-01-02 09:00:00 -0500]
|
||||
|
||||
```
|
||||
|
||||
因此,有些时候数据库中相同的时间,由于服务器的时区设置不同,读取到的时间表示不同。这,不是时间错乱,正是时区发挥了作用,因为UTC时间需要根据当前时区解析为正确的本地时间。
|
||||
|
||||
所以,**要正确处理时区,在于存进去和读出来两方面**:存的时候,需要使用正确的当前时区来保存,这样UTC时间才会正确;读的时候,也只有正确设置本地时区,才能把UTC时间转换为正确的当地时间。
|
||||
|
||||
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方法直接设置格式化使用的时区。最后,分别以上海、纽约和东京三个时区来格式化这个时间输出:
|
||||
|
||||
```
|
||||
//一个时间表示
|
||||
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方法直接设置格式化使用的时区
|
||||
DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z");
|
||||
System.out.println(timeZoneSH.getId() + outputFormat.withZone(timeZoneSH).format(date));
|
||||
System.out.println(timeZoneNY.getId() + outputFormat.withZone(timeZoneNY).format(date));
|
||||
System.out.println(timeZoneJST.getId() + outputFormat.withZone(timeZoneJST).format(date));
|
||||
|
||||
```
|
||||
|
||||
可以看到,相同的时区,经过解析存进去和读出来的时间表示是一样的(比如最后一行);而对于不同的时区,比如上海和纽约,最后输出的本地时间不同。+9小时时区的晚上10点,对于上海是+8小时,所以上海本地时间是晚上9点;而对于纽约是-5小时,差14小时,所以是早上8点:
|
||||
|
||||
```
|
||||
Asia/Shanghai2020-01-02 21:00:00 +0800
|
||||
America/New_York2020-01-02 08:00:00 -0500
|
||||
+09:002020-01-02 22:00:00 +0900
|
||||
|
||||
```
|
||||
|
||||
到这里,我来小结下。要正确处理国际化时间问题,我推荐使用Java 8的日期时间类,即使用ZonedDateTime保存时间,然后使用设置了ZoneId的DateTimeFormatter配合ZonedDateTime进行时间格式化得到本地时间表示。这样的划分十分清晰、细化,也不容易出错。
|
||||
|
||||
接下来,我们继续看看对于日期时间的格式化和解析,使用遗留的SimpleDateFormat,会遇到哪些问题。
|
||||
|
||||
## 日期时间格式化和解析
|
||||
|
||||
每到年底,就有很多开发同学踩时间格式化的坑,比如“这明明是一个2019年的日期,**怎么使用SimpleDateFormat格式化后就提前跨年了**”。我们来重现一下这个问题。
|
||||
|
||||
初始化一个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());
|
||||
|
||||
```
|
||||
|
||||
得到的输出却是2020年12月29日:
|
||||
|
||||
```
|
||||
defaultLocale:zh_CN
|
||||
格式化: 2020-12-29
|
||||
weekYear:2020
|
||||
firstDayOfWeek:1
|
||||
minimalDaysInFirstWeek:1
|
||||
|
||||
```
|
||||
|
||||
出现这个问题的原因在于,这位同学混淆了SimpleDateFormat的各种格式化模式。JDK的[文档](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)中有说明:小写y是年,而大写Y是week year,也就是所在的周属于哪一年。
|
||||
|
||||
一年第一周的判断方式是,从getFirstDayOfWeek()开始,完整的7天,并且包含那一年至少getMinimalDaysInFirstWeek()天。这个计算方式和区域相关,对于当前zh_CN区域来说,2020年第一周的条件是,从周日开始的完整7天,2020年包含1天即可。显然,2019年12月29日周日到2020年1月4日周六是2020年第一周,得出的week year就是2020年。
|
||||
|
||||
如果把区域改为法国:
|
||||
|
||||
```
|
||||
Locale.setDefault(Locale.FRANCE);
|
||||
|
||||
```
|
||||
|
||||
那么week yeay就还是2019年,因为一周的第一天从周一开始算,2020年的第一周是2019年12月30日周一开始,29日还是属于去年:
|
||||
|
||||
```
|
||||
defaultLocale:fr_FR
|
||||
格式化: 2019-12-29
|
||||
weekYear:2019
|
||||
firstDayOfWeek:2
|
||||
minimalDaysInFirstWeek:4
|
||||
|
||||
```
|
||||
|
||||
这个案例告诉我们,没有特殊需求,针对年份的日期格式化,应该一律使用 “y” 而非 “Y”。
|
||||
|
||||
除了格式化表达式容易踩坑外,SimpleDateFormat还有两个著名的坑。
|
||||
|
||||
第一个坑是,**定义的static的SimpleDateFormat可能会出现线程安全问题。**比如像这样,使用一个100线程的线程池,循环20次把时间格式化任务提交到线程池处理,每个任务中又循环10次解析2020-01-01 11:12:13这样一个时间表示:
|
||||
|
||||
```
|
||||
ExecutorService threadPool = Executors.newFixedThreadPool(100);
|
||||
for (int i = 0; i < 20; i++) {
|
||||
//提交20个并发解析时间的任务到线程池,模拟并发环境
|
||||
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);
|
||||
|
||||
```
|
||||
|
||||
运行程序后大量报错,且没有报错的输出结果也不正常,比如2020年解析成了1212年:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/27/3ee2e923b3cf4e13722b7b0773de1b27.png" alt="">
|
||||
|
||||
SimpleDateFormat的作用是定义解析和格式化日期时间的模式。这,看起来这是一次性的工作,应该复用,但它的解析和格式化操作是非线程安全的。我们来分析一下相关源码:
|
||||
|
||||
- SimpleDateFormat继承了DateFormat,DateFormat有一个字段Calendar;
|
||||
- SimpleDateFormat的parse方法调用CalendarBuilder的establish方法,来构建Calendar;
|
||||
- establish方法内部先清空Calendar再构建Calendar,整个操作没有加锁。
|
||||
|
||||
显然,如果多线程池调用parse方法,也就意味着多线程在并发操作一个Calendar,可能会产生一个线程还没来得及处理Calendar就被另一个线程清空了的情况:
|
||||
|
||||
```
|
||||
public abstract class DateFormat extends Format {
|
||||
protected Calendar calendar;
|
||||
}
|
||||
public class SimpleDateFormat extends DateFormat {
|
||||
@Override
|
||||
public Date parse(String text, ParsePosition pos)
|
||||
{
|
||||
CalendarBuilder calb = new CalendarBuilder();
|
||||
parsedDate = calb.establish(calendar).getTime();
|
||||
return parsedDate;
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarBuilder {
|
||||
Calendar establish(Calendar cal) {
|
||||
...
|
||||
cal.clear();//清空
|
||||
|
||||
for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
|
||||
for (int index = 0; index <= maxFieldIndex; index++) {
|
||||
if (field[index] == stamp) {
|
||||
cal.set(index, field[MAX_FIELD + index]);//构建
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cal;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
format方法也类似,你可以自己分析。因此只能在同一个线程复用SimpleDateFormat,比较好的解决方式是,通过ThreadLocal来存放SimpleDateFormat:
|
||||
|
||||
```
|
||||
private static ThreadLocal<SimpleDateFormat> threadSafeSimpleDateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
|
||||
|
||||
```
|
||||
|
||||
第二个坑是,**当需要解析的字符串和格式不匹配的时候,SimpleDateFormat表现得很宽容**,还是能得到结果。比如,我们期望使用yyyyMM来解析20160901字符串:
|
||||
|
||||
```
|
||||
String dateString = "20160901";
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMM");
|
||||
System.out.println("result:" + dateFormat.parse(dateString));
|
||||
|
||||
```
|
||||
|
||||
居然输出了2091年1月1日,原因是把0901当成了月份,相当于75年:
|
||||
|
||||
```
|
||||
result:Mon Jan 01 00:00:00 CST 2091
|
||||
|
||||
```
|
||||
|
||||
对于SimpleDateFormat的这三个坑,我们使用Java 8中的DateTimeFormatter就可以避过去。首先,使用DateTimeFormatterBuilder来定义格式化字符串,不用去记忆使用大写的Y还是小写的Y,大写的M还是小写的m:
|
||||
|
||||
```
|
||||
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();
|
||||
|
||||
```
|
||||
|
||||
其次,DateTimeFormatter是线程安全的,可以定义为static使用;最后,DateTimeFormatter的解析比较严格,需要解析的字符串和格式不匹配时,会直接报错,而不会把0901解析为月份。我们测试一下:
|
||||
|
||||
```
|
||||
//使用刚才定义的DateTimeFormatterBuilder构建的DateTimeFormatter来解析这个时间
|
||||
LocalDateTime localDateTime = LocalDateTime.parse("2020/1/2 12:34:56.789", dateTimeFormatter);
|
||||
//解析成功
|
||||
System.out.println(localDateTime.format(dateTimeFormatter));
|
||||
//使用yyyyMM格式解析20160901是否可以成功呢?
|
||||
String dt = "20160901";
|
||||
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMM");
|
||||
System.out.println("result:" + dateTimeFormatter.parse(dt));
|
||||
|
||||
```
|
||||
|
||||
输出日志如下:
|
||||
|
||||
```
|
||||
2020/1/2 12:34:56.789
|
||||
Exception in thread "main" java.time.format.DateTimeParseException: Text '20160901' could not be parsed at index 0
|
||||
at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1949)
|
||||
at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1777)
|
||||
at org.geekbang.time.commonmistakes.datetime.dateformat.CommonMistakesApplication.better(CommonMistakesApplication.java:80)
|
||||
at org.geekbang.time.commonmistakes.datetime.dateformat.CommonMistakesApplication.main(CommonMistakesApplication.java:41)
|
||||
|
||||
```
|
||||
|
||||
到这里我们可以发现,使用Java 8中的DateTimeFormatter进行日期时间的格式化和解析,显然更让人放心。那么,对于日期时间的运算,使用Java 8中的日期时间类会不会更简单呢?
|
||||
|
||||
## 日期时间的计算
|
||||
|
||||
关于日期时间的计算,我先和你说一个常踩的坑。有些同学喜欢直接使用时间戳进行时间计算,比如希望得到当前时间之后30天的时间,会这么写代码:直接把new Date().getTime方法得到的时间戳加30天对应的毫秒数,也就是30天*1000毫秒*3600秒*24小时:
|
||||
|
||||
```
|
||||
Date today = new Date();
|
||||
Date nextMonth = new Date(today.getTime() + 30 * 1000 * 60 * 60 * 24);
|
||||
System.out.println(today);
|
||||
System.out.println(nextMonth);
|
||||
|
||||
```
|
||||
|
||||
得到的日期居然比当前日期还要早,根本不是晚30天的时间:
|
||||
|
||||
```
|
||||
Sat Feb 01 14:17:41 CST 2020
|
||||
Sun Jan 12 21:14:54 CST 2020
|
||||
|
||||
```
|
||||
|
||||
出现这个问题,**其实是因为int发生了溢出**。修复方式就是把30改为30L,让其成为一个long:
|
||||
|
||||
```
|
||||
Date today = new Date();
|
||||
Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
|
||||
System.out.println(today);
|
||||
System.out.println(nextMonth);
|
||||
|
||||
```
|
||||
|
||||
这样就可以得到正确结果了:
|
||||
|
||||
```
|
||||
Sat Feb 01 14:17:41 CST 2020
|
||||
Mon Mar 02 14:17:41 CST 2020
|
||||
|
||||
```
|
||||
|
||||
不难发现,手动在时间戳上进行计算操作的方式非常容易出错。对于Java 8之前的代码,我更建议使用Calendar:
|
||||
|
||||
```
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTime(new Date());
|
||||
c.add(Calendar.DAY_OF_MONTH, 30);
|
||||
System.out.println(c.getTime());
|
||||
|
||||
```
|
||||
|
||||
使用Java 8的日期时间类型,可以直接进行各种计算,更加简洁和方便:
|
||||
|
||||
```
|
||||
LocalDateTime localDateTime = LocalDateTime.now();
|
||||
System.out.println(localDateTime.plusDays(30));
|
||||
|
||||
```
|
||||
|
||||
并且,**对日期时间做计算操作,Java 8日期时间API会比Calendar功能强大很多**。
|
||||
|
||||
第一,可以使用各种minus和plus方法直接对日期进行加减操作,比如如下代码实现了减一天和加一天,以及减一个月和加一个月:
|
||||
|
||||
```
|
||||
System.out.println("//测试操作日期");
|
||||
System.out.println(LocalDate.now()
|
||||
.minus(Period.ofDays(1))
|
||||
.plus(1, ChronoUnit.DAYS)
|
||||
.minusMonths(1)
|
||||
.plus(Period.ofMonths(1)));
|
||||
|
||||
```
|
||||
|
||||
可以得到:
|
||||
|
||||
```
|
||||
//测试操作日期
|
||||
2020-02-01
|
||||
|
||||
```
|
||||
|
||||
第二,还可以通过with方法进行快捷时间调节,比如:
|
||||
|
||||
- 使用TemporalAdjusters.firstDayOfMonth得到当前月的第一天;
|
||||
- 使用TemporalAdjusters.firstDayOfYear()得到当前年的第一天;
|
||||
- 使用TemporalAdjusters.previous(DayOfWeek.SATURDAY)得到上一个周六;
|
||||
- 使用TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)得到本月最后一个周五。
|
||||
|
||||
```
|
||||
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)));
|
||||
|
||||
```
|
||||
|
||||
输出如下:
|
||||
|
||||
```
|
||||
//本月的第一天
|
||||
2020-02-01
|
||||
//今年的程序员日
|
||||
2020-09-12
|
||||
//今天之前的一个周六
|
||||
2020-01-25
|
||||
//本月最后一个工作日
|
||||
2020-02-28
|
||||
|
||||
```
|
||||
|
||||
第三,可以直接使用lambda表达式进行自定义的时间调整。比如,为当前时间增加100天以内的随机天数:
|
||||
|
||||
```
|
||||
System.out.println(LocalDate.now().with(temporal -> temporal.plus(ThreadLocalRandom.current().nextInt(100), ChronoUnit.DAYS)));
|
||||
|
||||
```
|
||||
|
||||
得到:
|
||||
|
||||
```
|
||||
2020-03-15
|
||||
|
||||
```
|
||||
|
||||
除了计算外,还可以判断日期是否符合某个条件。比如,自定义函数,判断指定日期是否是家庭成员的生日:
|
||||
|
||||
```
|
||||
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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,使用query方法查询是否匹配条件:
|
||||
|
||||
```
|
||||
System.out.println("//查询是否是今天要举办生日");
|
||||
System.out.println(LocalDate.now().query(CommonMistakesApplication::isFamilyBirthday));
|
||||
|
||||
```
|
||||
|
||||
使用Java 8操作和计算日期时间虽然方便,但计算两个日期差时可能会踩坑:**Java 8中有一个专门的类Period定义了日期间隔,通过Period.between得到了两个LocalDate的差,返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天,直接调用Period的getDays()方法得到的只是最后的“零几天”,而不是算总的间隔天数**。
|
||||
|
||||
比如,计算2019年12月12日和2019年10月1日的日期间隔,很明显日期差是2个月零11天,但获取getDays方法得到的结果只是11天,而不是72天:
|
||||
|
||||
```
|
||||
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));
|
||||
|
||||
```
|
||||
|
||||
可以使用ChronoUnit.DAYS.between解决这个问题:
|
||||
|
||||
```
|
||||
//计算日期差
|
||||
11
|
||||
P2M11D
|
||||
72
|
||||
|
||||
```
|
||||
|
||||
从日期时间的时区到格式化再到计算,你是不是体会到Java 8日期时间类的强大了呢?
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我和你一起看了日期时间的初始化、时区、格式化、解析和计算的问题。我们看到,使用Java 8中的日期时间包Java.time的类进行各种操作,会比使用遗留的Date、Calender和SimpleDateFormat更简单、清晰,功能也更丰富、坑也比较少。
|
||||
|
||||
如果有条件的话,我还是建议全面改为使用Java 8的日期时间类型。我把Java 8前后的日期时间类型,汇总到了一张思维导图上,图中箭头代表的是新老类型在概念上等价的类型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/22/33/225d00087f500dbdf5e666e58ead1433.png" alt="">
|
||||
|
||||
这里有个误区是,认为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());
|
||||
Date out = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
|
||||
```
|
||||
|
||||
很多同学说使用新API很麻烦,还需要考虑时区的概念,一点都不简洁。但我通过这篇文章要和你说的是,并不是因为API需要设计得这么繁琐,而是UTC时间要变为当地时间,必须考虑时区。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 我今天多次强调Date是一个时间戳,是UTC时间、没有时区概念,为什么调用其toString方法会输出类似CST之类的时区字样呢?
|
||||
1. 日期时间数据始终要保存到数据库中,MySQL中有两种数据类型datetime和timestamp可以用来保存日期时间。你能说说它们的区别吗,它们是否包含时区信息呢?
|
||||
|
||||
对于日期和时间,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
447
极客时间专栏/Java业务开发常见错误100例/代码篇/17 | 别以为“自动挡”就不可能出现OOM.md
Normal file
447
极客时间专栏/Java业务开发常见错误100例/代码篇/17 | 别以为“自动挡”就不可能出现OOM.md
Normal file
@@ -0,0 +1,447 @@
|
||||
<audio id="audio" title="17 | 别以为“自动挡”就不可能出现OOM" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/99/b4/99f0029fc267d5385ffc6a6828d7b9b4.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我要和你分享的主题是,别以为“自动挡”就不可能出现OOM。
|
||||
|
||||
这里的“自动挡”,是我对Java自动垃圾收集器的戏称。的确,经过这么多年的发展,Java的垃圾收集器已经非常成熟了。有了自动垃圾收集器,绝大多数情况下我们写程序时可以专注于业务逻辑,无需过多考虑对象的分配和释放,一般也不会出现OOM。
|
||||
|
||||
但,内存空间始终是有限的,Java的几大内存区域始终都有OOM的可能。相应地,Java程序的常见OOM类型,可以分为堆内存的OOM、栈OOM、元空间OOM、直接内存OOM等。几乎每一种OOM都可以使用几行代码模拟,市面上也有很多资料在堆、元空间、直接内存中分配超大对象或是无限分配对象,尝试创建无限个线程或是进行方法无限递归调用来模拟。
|
||||
|
||||
但值得注意的是,我们的业务代码并不会这么干。所以今天,我会从内存分配意识的角度通过一些案例,展示业务代码中可能导致OOM的一些坑。这些坑,或是因为我们意识不到对象的分配,或是因为不合理的资源使用,或是没有控制缓存的数据量等。
|
||||
|
||||
在[第3讲](https://time.geekbang.org/column/article/210337)介绍线程时,我们已经看到了两种OOM的情况,一是因为使用无界队列导致的堆OOM,二是因为使用没有最大线程数量限制的线程池导致无限创建线程的OOM。接下来,我们再一起看看,在写业务代码的过程中,还有哪些意识上的疏忽可能会导致OOM。
|
||||
|
||||
## 太多份相同的对象导致OOM
|
||||
|
||||
我要分享的第一个案例是这样的。有一个项目在内存中缓存了全量用户数据,在搜索用户时可以直接从缓存中返回用户信息。现在为了改善用户体验,需要实现输入部分用户名自动在下拉框提示补全用户名的功能(也就是所谓的自动完成功能)。
|
||||
|
||||
在[第10讲](https://time.geekbang.org/column/article/216778)介绍集合时,我提到对于这种快速检索的需求,最好使用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,存放的是所有相同的用户名索引,以及对应的用户信息:
|
||||
|
||||
```
|
||||
//自动完成的索引,Key是用户输入的部分用户名,Value是对应的用户数据
|
||||
private ConcurrentHashMap<String, List<UserDTO>> autoCompleteIndex = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@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,除了有用户名,我们还加入了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(""));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行程序后,日志输出如下:
|
||||
|
||||
```
|
||||
[11:11:22.982] [main] [INFO ] [.t.c.o.d.UsernameAutoCompleteService:37 ] - autoCompleteIndex size:26838 count:60000
|
||||
|
||||
```
|
||||
|
||||
可以看到,一共有26838个索引(也就是所有用户名的1位、2位一直到6位有26838个组合),HashMap的Value,也就是List<userdto>一共有1万个用户*6=6万个UserDTO对象。</userdto>
|
||||
|
||||
使用内存分析工具MAT打开堆dump发现,6万个UserDTO占用了约1.2GB的内存:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/d2/d17fdb7d5123566312f7d3888ef82bd2.png" alt="">
|
||||
|
||||
看到这里发现,**虽然真正的用户只有1万个,但因为使用部分用户名作为索引的Key,导致缓存的Key有26838个,缓存的用户信息多达6万个**。如果我们的用户名不是6位而是10位、20位,那么缓存的用户信息可能就是10万、20万个,必然会产生堆OOM。
|
||||
|
||||
尝试调大用户名的最大长度,重启程序可以看到类似如下的错误:
|
||||
|
||||
```
|
||||
[17:30:29.858] [main] [ERROR] [ringframework.boot.SpringApplication:826 ] - Application run failed
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'usernameAutoCompleteService': Invocation of init method failed; nested exception is java.lang.OutOfMemoryError: Java heap space
|
||||
|
||||
```
|
||||
|
||||
我们可能会想当然地认为,数据库中有1万个用户,内存中也应该只有1万个UserDTO对象,但实现的时候每次都会new出来UserDTO加入缓存,当然在内存中都是新对象。在实际的项目中,用户信息的缓存可能是随着用户输入增量缓存的,而不是像这个案例一样在程序初始化的时候全量缓存,所以问题暴露得不会这么早。
|
||||
|
||||
知道原因后,解决起来就比较简单了。把所有UserDTO先加入HashSet中,因为UserDTO以name来标识唯一性,所以重复用户名会被过滤掉,最终加入HashSet的UserDTO就不足1万个。
|
||||
|
||||
有了HashSet来缓存所有可能的UserDTO信息,我们再构建自动完成索引autoCompleteIndex这个HashMap时,就可以直接从HashSet获取所有用户信息来构建了。这样一来,同一个用户名前缀的不同组合(比如用户名为abc的用户,a、ab和abc三个Key)关联到UserDTO是同一份:
|
||||
|
||||
```
|
||||
@PostConstruct
|
||||
public void right() {
|
||||
...
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再次分析堆内存,可以看到UserDTO只有9945份,总共占用的内存不到200M。这才是我们真正想要的结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/52/34a0fc90ac8be7a20cb295c14f06d752.png" alt="">
|
||||
|
||||
修复后的程序,不仅相同的UserDTO只有一份,总副本数变为了原来的六分之一;而且因为HashSet的去重特性,双重节约了内存。
|
||||
|
||||
值得注意的是,我们虽然清楚数据总量,但却忽略了每一份数据在内存中可能有多份。我之前还遇到一个案例,一个后台程序需要从数据库加载大量信息用于数据导出,这些数据在数据库中占用100M内存,但是1GB的JVM堆却无法完成导出操作。
|
||||
|
||||
我来和你分析下原因吧。100M的数据加载到程序内存中,变为Java的数据结构就已经占用了200M堆内存;这些数据经过JDBC、MyBatis等框架其实是加载了2份,然后领域模型、DTO再进行转换可能又加载了2次;最终,占用的内存达到了200M*4=800M。
|
||||
|
||||
所以,**在进行容量评估时,我们不能认为一份数据在程序内存中也是一份**。
|
||||
|
||||
## 使用WeakHashMap不等于不会OOM
|
||||
|
||||
对于上一节实现快速检索的案例,为了防止缓存中堆积大量数据导致OOM,一些同学可能会想到使用WeakHashMap作为缓存容器。
|
||||
|
||||
WeakHashMap的特点是Key在哈希表内部是弱引用的,当没有强引用指向这个Key之后,Entry会被GC,即使我们无限往WeakHashMap加入数据,只要Key不再使用,也就不会OOM。
|
||||
|
||||
说到了强引用和弱引用,我先和你回顾下Java中引用类型和垃圾回收的关系:
|
||||
|
||||
- 垃圾回收器不会回收有强引用的对象;
|
||||
- 在内存充足时,垃圾回收器不会回收具有软引用的对象;
|
||||
- 垃圾回收器只要扫描到了具有弱引用的对象就会回收,WeakHashMap就是利用了这个特点。
|
||||
|
||||
不过,我要和你分享的第二个案例,恰巧就是不久前我遇到的一个使用WeakHashMap却最终OOM的案例。我们暂且不论使用WeakHashMap作为缓存是否合适,先分析一下这个OOM问题。
|
||||
|
||||
声明一个Key是User类型、Value是UserProfile类型的WeakHashMap,作为用户数据缓存,往其中添加200万个Entry,然后使用ScheduledThreadPoolExecutor发起一个定时任务,每隔1秒输出缓存中的Entry个数:
|
||||
|
||||
```
|
||||
private Map<User, UserProfile> cache = new WeakHashMap<>();
|
||||
|
||||
@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);
|
||||
cache.put(user, new UserProfile(user, "location" + i));
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行程序后日志如下:
|
||||
|
||||
```
|
||||
[10:30:28.509] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29 ] - cache size:2000000
|
||||
[10:30:29.507] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29 ] - cache size:2000000
|
||||
[10:30:30.509] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29 ] - cache size:2000000
|
||||
|
||||
```
|
||||
|
||||
可以看到,输出的cache size始终是200万,即使我们通过jvisualvm进行手动GC还是这样。这就说明,这些Entry无法通过GC回收。如果你把200万改为1000万,就可以在日志中看到如下的OOM错误:
|
||||
|
||||
```
|
||||
Exception in thread "http-nio-45678-exec-1" java.lang.OutOfMemoryError: GC overhead limit exceeded
|
||||
Exception in thread "Catalina-utility-2" java.lang.OutOfMemoryError: GC overhead limit exceeded
|
||||
|
||||
```
|
||||
|
||||
我们来分析一下这个问题。进行堆转储后可以看到,堆内存中有200万个UserProfie和User:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/e9/b9bb8ef163a07a8da92e6e66a6dd55e9.png" alt="">
|
||||
|
||||
如下是User和UserProfile类的定义,需要注意的是,WeakHashMap的Key是User对象,而其Value是UserProfile对象,持有了User的引用:
|
||||
|
||||
```
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
class User {
|
||||
private String name;
|
||||
}
|
||||
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class UserProfile {
|
||||
private User user;
|
||||
private String location;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
没错,这就是问题的所在。分析一下WeakHashMap的源码,你会发现WeakHashMap和HashMap的最大区别,是Entry对象的实现。接下来,我们暂且忽略HashMap的实现,来看下Entry对象:
|
||||
|
||||
```
|
||||
private static class Entry<K,V> extends WeakReference<Object> ...
|
||||
/**
|
||||
* Creates new entry.
|
||||
*/
|
||||
Entry(Object key, V value,
|
||||
ReferenceQueue<Object> queue,
|
||||
int hash, Entry<K,V> next) {
|
||||
super(key, queue);
|
||||
this.value = value;
|
||||
this.hash = hash;
|
||||
this.next = next;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Entry对象继承了WeakReference,Entry的构造函数调用了super (key,queue),这是父类的构造函数。其中,key是我们执行put方法时的key;queue是一个ReferenceQueue。如果你了解Java的引用就会知道,被GC的对象会被丢进这个queue里面。
|
||||
|
||||
再来看看对象被丢进queue后是如何被销毁的:
|
||||
|
||||
```
|
||||
public V get(Object key) {
|
||||
Object k = maskNull(key);
|
||||
int h = hash(k);
|
||||
Entry<K,V>[] tab = getTable();
|
||||
int index = indexFor(h, tab.length);
|
||||
Entry<K,V> e = tab[index];
|
||||
while (e != null) {
|
||||
if (e.hash == h && eq(k, e.get()))
|
||||
return e.value;
|
||||
e = e.next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Entry<K,V>[] getTable() {
|
||||
expungeStaleEntries();
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expunges stale entries from the table.
|
||||
*/
|
||||
private void expungeStaleEntries() {
|
||||
for (Object x; (x = queue.poll()) != null; ) {
|
||||
synchronized (queue) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Entry<K,V> e = (Entry<K,V>) x;
|
||||
int i = indexFor(e.hash, table.length);
|
||||
|
||||
Entry<K,V> prev = table[i];
|
||||
Entry<K,V> p = prev;
|
||||
while (p != null) {
|
||||
Entry<K,V> next = p.next;
|
||||
if (p == e) {
|
||||
if (prev == e)
|
||||
table[i] = next;
|
||||
else
|
||||
prev.next = next;
|
||||
// Must not null out e.next;
|
||||
// stale entries may be in use by a HashIterator
|
||||
e.value = null; // Help GC
|
||||
size--;
|
||||
break;
|
||||
}
|
||||
prev = p;
|
||||
`` p = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从源码中可以看到,每次调用get、put、size等方法时,都会从queue里拿出所有已经被GC掉的key并删除对应的Entry对象。我们再来回顾下这个逻辑:
|
||||
|
||||
- put一个对象进Map时,它的key会被封装成弱引用对象;
|
||||
- 发生GC时,弱引用的key被发现并放入queue;
|
||||
- 调用get等方法时,扫描queue删除key,以及包含key和value的Entry对象。
|
||||
|
||||
**WeakHashMap的Key虽然是弱引用,但是其Value却持有Key中对象的强引用,Value被Entry引用,Entry被WeakHashMap引用,最终导致Key无法回收**。解决方案就是让Value变为弱引用,使用WeakReference来包装UserProfile即可:
|
||||
|
||||
```
|
||||
private Map<User, WeakReference<UserProfile>> cache2 = new WeakHashMap<>();
|
||||
|
||||
@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);
|
||||
//这次,我们使用弱引用来包装UserProfile
|
||||
cache2.put(user, new WeakReference(new UserProfile(user, "location" + i)));
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重新运行程序,从日志中观察到cache size不再是固定的200万,而是在不断减少,甚至在手动GC后所有的Entry都被回收了:
|
||||
|
||||
```
|
||||
[10:40:05.792] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:1367402
|
||||
[10:40:05.795] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:1367846
|
||||
[10:40:06.773] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:549551
|
||||
...
|
||||
[10:40:20.742] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:549551
|
||||
[10:40:22.862] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:547937
|
||||
[10:40:22.865] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:542134
|
||||
[10:40:23.779] [pool-3-thread-1] [INFO ]
|
||||
//手动进行GC
|
||||
[t.c.o.demo3.WeakHashMapOOMController:40 ] - cache size:0
|
||||
|
||||
```
|
||||
|
||||
当然,还有一种办法就是,让Value也就是UserProfile不再引用Key,而是重新new出一个新的User对象赋值给UserProfile:
|
||||
|
||||
```
|
||||
@GetMapping("right2")
|
||||
public void right2() {
|
||||
String userName = "zhuye";
|
||||
...
|
||||
User user = new User(userName + i);
|
||||
cache.put(user, new UserProfile(new User(user.getName()), "location" + i));
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
此外,Spring提供的[ConcurrentReferenceHashMap](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/ConcurrentReferenceHashMap.html)类可以使用弱引用、软引用做缓存,Key和Value同时被软引用或弱引用包装,也能解决相互引用导致的数据不能释放问题。与WeakHashMap相比,ConcurrentReferenceHashMap不但性能更好,还可以确保线程安全。你可以自己做实验测试下。
|
||||
|
||||
## Tomcat参数配置不合理导致OOM
|
||||
|
||||
我们再来看看第三个案例。有一次运维同学反馈,有个应用在业务量大的情况下会出现假死,日志中也有大量OOM异常:
|
||||
|
||||
```
|
||||
[13:18:17.597] [http-nio-45678-exec-70] [ERROR] [ache.coyote.http11.Http11NioProtocol:175 ] - Failed to complete processing of a request
|
||||
java.lang.OutOfMemoryError: Java heap space
|
||||
|
||||
```
|
||||
|
||||
于是,我让运维同学进行生产堆Dump。通过MAT打开dump文件后,我们一眼就看到OOM的原因是,有接近1.7GB的byte数组分配,而JVM进程的最大堆内存我们只配置了2GB:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/ee/0b310e7da83f272afdf51b345d8057ee.png" alt="">
|
||||
|
||||
通过查看引用可以发现,大量引用都是Tomcat的工作线程。大部分工作线程都分配了两个10M左右的数组,100个左右工作线程吃满了内存。第一个红框是Http11InputBuffer,其buffer大小是10008192字节;而第二个红框的Http11OutputBuffer的buffer,正好占用10000000字节:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/12/53546299958a4fecef5fd473a0579012.png" alt="">
|
||||
|
||||
我们先来看看第一个Http11InputBuffer为什么会占用这么多内存。查看Http11InputBuffer类的init方法注意到,其中一个初始化方法会分配headerBufferSize+readBuffer大小的内存:
|
||||
|
||||
```
|
||||
void init(SocketWrapperBase<?> socketWrapper) {
|
||||
|
||||
wrapper = socketWrapper;
|
||||
wrapper.setAppReadBufHandler(this);
|
||||
|
||||
int bufLength = headerBufferSize +
|
||||
wrapper.getSocketBufferHandler().getReadBuffer().capacity();
|
||||
if (byteBuffer == null || byteBuffer.capacity() < bufLength) {
|
||||
byteBuffer = ByteBuffer.allocate(bufLength);
|
||||
byteBuffer.position(0).limit(0);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在[Tomcat文档](https://tomcat.apache.org/tomcat-8.0-doc/config/http.html)中有提到,这个Socket的读缓冲,也就是readBuffer默认是8192字节。显然,问题出在了headerBufferSize上:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/68/0c14d6aff749d74b3ee0e159e4552168.png" alt="">
|
||||
|
||||
向上追溯初始化Http11InputBuffer的Http11Processor类,可以看到,传入的headerBufferSize配置的是MaxHttpHeaderSize:
|
||||
|
||||
```
|
||||
inputBuffer = new Http11InputBuffer(request, protocol.getMaxHttpHeaderSize(),
|
||||
protocol.getRejectIllegalHeaderName(), httpParser);
|
||||
|
||||
```
|
||||
|
||||
Http11OutputBuffer中的buffer正好占用了10000000字节,这又是为什么?通过Http11OutputBuffer的构造方法,可以看到它是直接根据headerBufferSize分配了固定大小的headerBuffer:
|
||||
|
||||
```
|
||||
protected Http11OutputBuffer(Response response, int headerBufferSize){
|
||||
...
|
||||
headerBuffer = ByteBuffer.allocate(headerBufferSize);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那么我们就可以想到,一定是应用把Tomcat头相关的参数配置为10000000了,使得每一个请求对于Request和Response都占用了20M内存,最终在并发较多的情况下引起了OOM。
|
||||
|
||||
果不其然,查看项目代码发现配置文件中有这样的配置项:
|
||||
|
||||
```
|
||||
server.max-http-header-size=10000000
|
||||
|
||||
```
|
||||
|
||||
翻看源码提交记录可以看到,当时开发同学遇到了这样的异常:
|
||||
|
||||
```
|
||||
java.lang.IllegalArgumentException: Request header is too large
|
||||
|
||||
```
|
||||
|
||||
于是他就到网上搜索了一下解决方案,随意将server.max-http-header-size修改为了一个超大值,期望永远不会再出现类似问题。但,没想到这个修改却引起了这么大的问题。把这个参数改为比较合适的20000再进行压测,我们就可以发现应用的各项指标都比较稳定。
|
||||
|
||||
这个案例告诉我们,**一定要根据实际需求来修改参数配置,可以考虑预留2到5倍的量。容量类的参数背后往往代表了资源,设置超大的参数就有可能占用不必要的资源,在并发量大的时候因为资源大量分配导致OOM**。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我从内存分配意识的角度和你分享了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留下第一问题现场**。对于JDK8,你可以这么设置:
|
||||
|
||||
```
|
||||
XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=. -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
|
||||
|
||||
```
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. Spring的ConcurrentReferenceHashMap,针对Key和Value支持软引用和弱引用两种方式。你觉得哪种方式更适合做缓存呢?
|
||||
1. 当我们需要动态执行一些表达式时,可以使用Groovy动态语言实现:new出一个GroovyShell类,然后调用evaluate方法动态执行脚本。这种方式的问题是,会重复产生大量的类,增加Metaspace区的GC负担,有可能会引起OOM。你知道如何避免这个问题吗?
|
||||
|
||||
针对OOM或内存泄露,你还遇到过什么案例吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
453
极客时间专栏/Java业务开发常见错误100例/代码篇/18 | 当反射、注解和泛型遇到OOP时,会有哪些坑?.md
Normal file
453
极客时间专栏/Java业务开发常见错误100例/代码篇/18 | 当反射、注解和泛型遇到OOP时,会有哪些坑?.md
Normal file
@@ -0,0 +1,453 @@
|
||||
<audio id="audio" title="18 | 当反射、注解和泛型遇到OOP时,会有哪些坑?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/4d/fa9bd7ef5d3156e34aa86eaec1fe0f4d.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我们聊聊Java高级特性的话题,看看反射、注解和泛型遇到重载和继承时可能会产生的坑。
|
||||
|
||||
你可能说,业务项目中几乎都是增删改查,用到反射、注解和泛型这些高级特性的机会少之又少,没啥好学的。但我要说的是,只有学好、用好这些高级特性,才能开发出更简洁易读的代码,而且几乎所有的框架都使用了这三大高级特性。比如,要减少重复代码,就得用到反射和注解(详见第21讲)。
|
||||
|
||||
如果你从来没用过反射、注解和泛型,可以先通过官网有一个大概了解:
|
||||
|
||||
- [Java Reflection API](https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/index.html) & [Reflection Tutorials](https://docs.oracle.com/javase/tutorial/reflect/index.html);
|
||||
- [Annotations](https://docs.oracle.com/javase/8/docs/technotes/guides/language/annotations.html) & [Lesson: Annotations](https://docs.oracle.com/javase/tutorial/java/annotations/index.html);
|
||||
- [Generics](https://docs.oracle.com/javase/8/docs/technotes/guides/language/generics.html) & [Lesson: Generics](https://docs.oracle.com/javase/tutorial/java/generics/index.html)。
|
||||
|
||||
接下来,我们就通过几个案例,看看这三大特性结合OOP使用时会有哪些坑吧。
|
||||
|
||||
## 反射调用方法不是以传参决定重载
|
||||
|
||||
反射的功能包括,在运行时动态获取类和类成员定义,以及动态读取属性调用方法。也就是说,针对类动态调用方法,不管类中字段和方法怎么变动,我们都可以用相同的规则来读取信息和执行方法。因此,几乎所有的ORM(对象关系映射)、对象映射、MVC框架都使用了反射。
|
||||
|
||||
反射的起点是Class类,Class类提供了各种方法帮我们查询它的信息。你可以通过这个[文档](https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html),了解每一个方法的作用。
|
||||
|
||||
接下来,我们先看一个反射调用方法遇到重载的坑:有两个叫age的方法,入参分别是基本类型int和包装类型Integer。
|
||||
|
||||
```
|
||||
@Slf4j
|
||||
public class ReflectionIssueApplication {
|
||||
private void age(int age) {
|
||||
log.info("int age = {}", age);
|
||||
}
|
||||
|
||||
private void age(Integer age) {
|
||||
log.info("Integer age = {}", age);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果不通过反射调用,走哪个重载方法很清晰,比如传入36走int参数的重载方法,传入Integer.valueOf(“36”)走Integer重载:
|
||||
|
||||
```
|
||||
ReflectionIssueApplication application = new ReflectionIssueApplication();
|
||||
application.age(36);
|
||||
application.age(Integer.valueOf("36"));
|
||||
|
||||
```
|
||||
|
||||
**但使用反射时的误区是,认为反射调用方法还是根据入参确定方法重载**。比如,使用getDeclaredMethod来获取age方法,然后传入Integer.valueOf(“36”):
|
||||
|
||||
```
|
||||
getClass().getDeclaredMethod("age", Integer.TYPE).invoke(this, Integer.valueOf("36"));
|
||||
|
||||
```
|
||||
|
||||
输出的日志证明,走的是int重载方法:
|
||||
|
||||
```
|
||||
14:23:09.801 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - int age = 36
|
||||
|
||||
```
|
||||
|
||||
其实,要通过反射进行方法调用,第一步就是通过方法签名来确定方法。具体到这个案例,getDeclaredMethod传入的参数类型Integer.TYPE代表的是int,所以实际执行方法时无论传的是包装类型还是基本类型,都会调用int入参的age方法。
|
||||
|
||||
把Integer.TYPE改为Integer.class,执行的参数类型就是包装类型的Integer。这时,无论传入的是Integer.valueOf(“36”)还是基本类型的36:
|
||||
|
||||
```
|
||||
getClass().getDeclaredMethod("age", Integer.class).invoke(this, Integer.valueOf("36"));
|
||||
getClass().getDeclaredMethod("age", Integer.class).invoke(this, 36);
|
||||
|
||||
```
|
||||
|
||||
都会调用Integer为入参的age方法:
|
||||
|
||||
```
|
||||
14:25:18.028 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - Integer age = 36
|
||||
14:25:18.029 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - Integer age = 36
|
||||
|
||||
```
|
||||
|
||||
现在我们非常清楚了,反射调用方法,是以反射获取方法时传入的方法名称和参数类型来确定调用方法的。接下来,我们再来看一下反射、泛型擦除和继承结合在一起会碰撞出什么坑。
|
||||
|
||||
## 泛型经过类型擦除多出桥接方法的坑
|
||||
|
||||
泛型是一种风格或范式,一般用于强类型程序设计语言,允许开发者使用类型参数替代明确的类型,实例化时再指明具体的类型。它是代码重用的有效手段,允许把一套代码应用到多种数据类型上,避免针对每一种数据类型实现重复的代码。
|
||||
|
||||
Java 编译器对泛型应用了强大的类型检测,如果代码违反了类型安全就会报错,可以在编译时暴露大多数泛型的编码错误。但总有一部分编码错误,比如泛型类型擦除的坑,在运行时才会暴露。接下来,我就和你分享一个案例吧。
|
||||
|
||||
有一个项目希望在类字段内容变动时记录日志,于是开发同学就想到定义一个泛型父类,并在父类中定义一个统一的日志记录方法,子类可以通过继承重用这个方法。代码上线后业务没啥问题,但总是出现日志重复记录的问题。开始时,我们怀疑是日志框架的问题,排查到最后才发现是泛型的问题,反复修改多次才解决了这个问题。
|
||||
|
||||
父类是这样的:有一个泛型占位符T;有一个AtomicInteger计数器,用来记录value字段更新的次数,其中value字段是泛型T类型的,setValue方法每次为value赋值时对计数器进行+1操作。我重写了toString方法,输出value字段的值和计数器的值:
|
||||
|
||||
```
|
||||
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) {
|
||||
this.value = value;
|
||||
updateCount.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
子类Child1的实现是这样的:继承父类,但没有提供父类泛型参数;定义了一个参数为String的setValue方法,通过super.setValue调用父类方法实现日志记录。我们也能明白,开发同学这么设计是希望覆盖父类的setValue实现:
|
||||
|
||||
```
|
||||
class Child1 extends Parent {
|
||||
public void setValue(String value) {
|
||||
System.out.println("Child1.setValue called");
|
||||
super.setValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在实现的时候,子类方法的调用是通过反射进行的。实例化Child1类型后,通过getClass().getMethods方法获得所有的方法;然后按照方法名过滤出setValue方法进行调用,传入字符串test作为参数:
|
||||
|
||||
```
|
||||
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());
|
||||
|
||||
```
|
||||
|
||||
运行代码后可以看到,虽然Parent的value字段正确设置了test,但父类的setValue方法调用了两次,计数器也显示2而不是1:
|
||||
|
||||
```
|
||||
Child1.setValue called
|
||||
Parent.setValue called
|
||||
Parent.setValue called
|
||||
value: test updateCount: 2
|
||||
|
||||
```
|
||||
|
||||
显然,两次Parent的setValue方法调用,是因为getMethods方法找到了两个名为setValue的方法,分别是父类和子类的setValue方法。
|
||||
|
||||
这个案例中,子类方法重写父类方法失败的原因,包括两方面:
|
||||
|
||||
- 一是,子类没有指定String泛型参数,父类的泛型方法setValue(T value)在泛型擦除后是setValue(Object value),子类中入参是String的setValue方法被当作了新方法;
|
||||
- 二是,**子类的setValue方法没有增加@Override注解,因此编译器没能检测到重写失败的问题。这就说明,重写子类方法时,标记@Override是一个好习惯**。
|
||||
|
||||
但是,开发同学认为问题出在反射API使用不当,却没意识到重写失败。他查文档后发现,getMethods方法能获得当前类和父类的所有public方法,而getDeclaredMethods只能获得当前类所有的public、protected、package和private方法。
|
||||
|
||||
于是,他就用getDeclaredMethods替代了getMethods:
|
||||
|
||||
```
|
||||
Arrays.stream(child1.getClass().getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("setValue"))
|
||||
.forEach(method -> {
|
||||
try {
|
||||
method.invoke(child1, "test");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
这样虽然能解决重复记录日志的问题,但没有解决子类方法重写父类方法失败的问题,得到如下输出:
|
||||
|
||||
```
|
||||
Child1.setValue called
|
||||
Parent.setValue called
|
||||
value: test updateCount: 1
|
||||
|
||||
```
|
||||
|
||||
其实这治标不治本,其他人使用Child1时还是会发现有两个setValue方法,非常容易让人困惑。
|
||||
|
||||
幸好,架构师在修复上线前发现了这个问题,让开发同学重新实现了Child2,继承Parent的时候提供了String作为泛型T类型,并使用@Override关键字注释了setValue方法,实现了真正有效的方法重写:
|
||||
|
||||
```
|
||||
class Child2 extends Parent<String> {
|
||||
@Override
|
||||
public void setValue(String value) {
|
||||
System.out.println("Child2.setValue called");
|
||||
super.setValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但很可惜,修复代码上线后,还是出现了日志重复记录:
|
||||
|
||||
```
|
||||
Child2.setValue called
|
||||
Parent.setValue called
|
||||
Child2.setValue called
|
||||
Parent.setValue called
|
||||
value: test updateCount: 2
|
||||
|
||||
```
|
||||
|
||||
可以看到,这次是Child2类的setValue方法被调用了两次。开发同学惊讶地说,肯定是反射出Bug了,通过getDeclaredMethods查找到的方法一定是来自Child2类本身;而且,怎么看Child2类中也只有一个setValue方法,为什么还会重复呢?
|
||||
|
||||
调试一下可以发现,Child2类其实有2个setValue方法,入参分别是String和Object。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/b8/81116d6f11440f92757e4fe775df71b8.png" alt="">
|
||||
|
||||
如果不通过反射来调用方法,我们确实很难发现这个问题。**其实,这就是泛型类型擦除导致的问题**。我们来分析一下。
|
||||
|
||||
我们知道,Java的泛型类型在编译后擦除为Object。虽然子类指定了父类泛型T类型是String,但编译后T会被擦除成为Object,所以父类setValue方法的入参是Object,value也是Object。如果子类Child2的setValue方法要覆盖父类的setValue方法,那入参也必须是Object。所以,编译器会为我们生成一个所谓的bridge桥接方法,你可以使用javap命令来反编译编译后的Child2类的class字节码:
|
||||
|
||||
```
|
||||
javap -c /Users/zhuye/Documents/common-mistakes/target/classes/org/geekbang/time/commonmistakes/advancedfeatures/demo3/Child2.class
|
||||
Compiled from "GenericAndInheritanceApplication.java"
|
||||
class org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2 extends org.geekbang.time.commonmistakes.advancedfeatures.demo3.Parent<java.lang.String> {
|
||||
org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2();
|
||||
Code:
|
||||
0: aload_0
|
||||
1: invokespecial #1 // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent."<init>":()V
|
||||
4: return
|
||||
|
||||
|
||||
public void setValue(java.lang.String);
|
||||
Code:
|
||||
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
|
||||
3: ldc #3 // String Child2.setValue called
|
||||
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
|
||||
8: aload_0
|
||||
9: aload_1
|
||||
10: invokespecial #5 // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent.setValue:(Ljava/lang/Object;)V
|
||||
13: return
|
||||
|
||||
|
||||
public void setValue(java.lang.Object);
|
||||
Code:
|
||||
0: aload_0
|
||||
1: aload_1
|
||||
2: checkcast #6 // class java/lang/String
|
||||
5: invokevirtual #7 // Method setValue:(Ljava/lang/String;)V
|
||||
8: return
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,入参为Object的setValue方法在内部调用了入参为String的setValue方法(第27行),也就是代码里实现的那个方法。如果编译器没有帮我们实现这个桥接方法,那么Child2子类重写的是父类经过泛型类型擦除后、入参是Object的setValue方法。这两个方法的参数,一个是String一个是Object,明显不符合Java的语义:
|
||||
|
||||
```
|
||||
class Parent {
|
||||
|
||||
AtomicInteger updateCount = new AtomicInteger();
|
||||
private Object value;
|
||||
public void setValue(Object value) {
|
||||
System.out.println("Parent.setValue called");
|
||||
this.value = value;
|
||||
updateCount.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
class Child2 extends Parent {
|
||||
@Override
|
||||
public void setValue(String value) {
|
||||
System.out.println("Child2.setValue called");
|
||||
super.setValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用jclasslib工具打开Child2类,同样可以看到入参为Object的桥接方法上标记了public + synthetic + bridge三个属性。synthetic代表由编译器生成的不可见代码,bridge代表这是泛型类型擦除后生成的桥接代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b5/08/b5e30fb0ade19d71cd7fad1730e85808.png" alt="">
|
||||
|
||||
知道这个问题之后,修改方式就明朗了,可以使用method的isBridge方法,来判断方法是不是桥接方法:
|
||||
|
||||
- 通过getDeclaredMethods方法获取到所有方法后,必须同时根据方法名setValue和非isBridge两个条件过滤,才能实现唯一过滤;
|
||||
- 使用Stream时,如果希望只匹配0或1项的话,可以考虑配合ifPresent来使用findFirst方法。
|
||||
|
||||
修复代码如下:
|
||||
|
||||
```
|
||||
Arrays.stream(child2.getClass().getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("setValue") && !method.isBridge())
|
||||
.findFirst().ifPresent(method -> {
|
||||
try {
|
||||
method.invoke(chi2, "test");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
这样就可以得到正确输出了:
|
||||
|
||||
```
|
||||
Child2.setValue called
|
||||
Parent.setValue called
|
||||
value: test updateCount: 1
|
||||
|
||||
```
|
||||
|
||||
**最后小结下,使用反射查询类方法清单时,我们要注意两点**:
|
||||
|
||||
- getMethods和getDeclaredMethods是有区别的,前者可以查询到父类方法,后者只能查询到当前类。
|
||||
- 反射进行方法调用要注意过滤桥接方法。
|
||||
|
||||
## 注解可以继承吗?
|
||||
|
||||
注解可以为Java代码提供元数据,各种框架也都会利用注解来暴露功能,比如Spring框架中的@Service、@Controller、@Bean注解,Spring Boot的@SpringBootApplication注解。
|
||||
|
||||
框架可以通过类或方法等元素上标记的注解,来了解它们的功能或特性,并以此来启用或执行相应的功能。通过注解而不是API调用来配置框架,属于声明式交互,可以简化框架的配置工作,也可以和框架解耦。
|
||||
|
||||
开发同学可能会认为,类继承后,类的注解也可以继承,子类重写父类方法后,父类方法上的注解也能作用于子类,但这些观点其实是错误或者说是不全面的。我们来验证下吧。
|
||||
|
||||
首先,定义一个包含value属性的MyAnnotation注解,可以标记在方法或类上:
|
||||
|
||||
```
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface MyAnnotation {
|
||||
String value();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,定义一个标记了@MyAnnotation注解的父类Parent,设置value为Class字符串;同时这个类的foo方法也标记了@MyAnnotation注解,设置value为Method字符串。接下来,定义一个子类Child继承Parent父类,并重写父类的foo方法,子类的foo方法和类上都没有@MyAnnotation注解。
|
||||
|
||||
```
|
||||
@MyAnnotation(value = "Class")
|
||||
@Slf4j
|
||||
static class Parent {
|
||||
|
||||
@MyAnnotation(value = "Method")
|
||||
public void foo() {
|
||||
}
|
||||
}
|
||||
|
||||
@Slf4j
|
||||
static class Child extends Parent {
|
||||
@Override
|
||||
public void foo() {
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再接下来,通过反射分别获取Parent和Child的类和方法的注解信息,并输出注解的value属性的值(如果注解不存在则输出空字符串):
|
||||
|
||||
```
|
||||
private static String getAnnotationValue(MyAnnotation annotation) {
|
||||
if (annotation == null) return "";
|
||||
return annotation.value();
|
||||
}
|
||||
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出如下:
|
||||
|
||||
```
|
||||
17:34:25.495 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentClass:Class
|
||||
17:34:25.501 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentMethod:Method
|
||||
17:34:25.504 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:
|
||||
17:34:25.504 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:
|
||||
|
||||
```
|
||||
|
||||
可以看到,父类的类和方法上的注解都可以正确获得,但是子类的类和方法却不能。这说明,**子类以及子类的方法,无法自动继承父类和父类方法上的注解**。
|
||||
|
||||
如果你详细了解过注解应该知道,在注解上标记@Inherited元注解可以实现注解的继承。那么,把@MyAnnotation注解标记了@Inherited,就可以一键解决问题了吗?
|
||||
|
||||
```
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Inherited
|
||||
public @interface MyAnnotation {
|
||||
String value();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重新运行代码输出如下:
|
||||
|
||||
```
|
||||
17:44:54.831 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentClass:Class
|
||||
17:44:54.837 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentMethod:Method
|
||||
17:44:54.838 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:Class
|
||||
17:44:54.838 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:
|
||||
|
||||
```
|
||||
|
||||
可以看到,子类可以获得父类上的注解;子类foo方法虽然是重写父类方法,并且注解本身也支持继承,但还是无法获得方法上的注解。
|
||||
|
||||
如果你再仔细阅读一下[@Inherited的文档](https://docs.oracle.com/javase/8/docs/api/java/lang/annotation/Inherited.html)就会发现,@Inherited只能实现类上的注解继承。要想实现方法上注解的继承,你可以通过反射在继承链上找到方法上的注解。但,这样实现起来很繁琐,而且需要考虑桥接方法。
|
||||
|
||||
好在Spring提供了AnnotatedElementUtils类,来方便我们处理注解的继承问题。这个类的findMergedAnnotation工具方法,可以帮助我们找出父类和接口、父类方法和接口方法上的注解,并可以处理桥接方法,实现一键找到继承链的注解:
|
||||
|
||||
```
|
||||
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)));
|
||||
|
||||
```
|
||||
|
||||
修改后,可以得到如下输出:
|
||||
|
||||
```
|
||||
17:47:30.058 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:Class
|
||||
17:47:30.059 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:Method
|
||||
|
||||
```
|
||||
|
||||
可以看到,子类foo方法也获得了父类方法上的注解。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我和你分享了使用Java反射、注解和泛型高级特性配合OOP时,可能会遇到的一些坑。
|
||||
|
||||
第一,反射调用方法并不是通过调用时的传参确定方法重载,而是在获取方法的时候通过方法名和参数类型来确定的。遇到方法有包装类型和基本类型重载的时候,你需要特别注意这一点。
|
||||
|
||||
第二,反射获取类成员,需要注意getXXX和getDeclaredXXX方法的区别,其中XXX包括Methods、Fields、Constructors、Annotations。这两类方法,针对不同的成员类型XXX和对象,在实现上都有一些细节差异,详情请查看[官方文档](https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html)。今天提到的getDeclaredMethods方法无法获得父类定义的方法,而getMethods方法可以,只是差异之一,不能适用于所有的XXX。
|
||||
|
||||
第三,泛型因为类型擦除会导致泛型方法T占位符被替换为Object,子类如果使用具体类型覆盖父类实现,编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义,又满足子类实现的方法有具体的类型。使用反射来获取方法清单时,你需要特别注意这一点。
|
||||
|
||||
第四,自定义注解可以通过标记元注解@Inherited实现注解的继承,不过这只适用于类。如果要继承定义在接口或方法上的注解,可以使用Spring的工具类AnnotatedElementUtils,并注意各种getXXX方法和findXXX方法的区别,详情查看[Spring的文档](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/core/annotation/AnnotatedElementUtils.html)。
|
||||
|
||||
最后,我要说的是。编译后的代码和原始代码并不完全一致,编译器可能会做一些优化,加上还有诸如AspectJ等编译时增强框架,使用反射动态获取类型的元数据可能会和我们编写的源码有差异,这点需要特别注意。你可以在反射中多写断言,遇到非预期的情况直接抛异常,避免通过反射实现的业务逻辑不符合预期。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 泛型类型擦除后会生成一个bridge方法,这个方法同时又是synthetic方法。除了泛型类型擦除,你知道还有什么情况编译器会生成synthetic方法吗?
|
||||
1. 关于注解继承问题,你觉得Spring的常用注解@Service、@Controller是否支持继承呢?
|
||||
|
||||
你还遇到过与Java高级特性相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
541
极客时间专栏/Java业务开发常见错误100例/代码篇/19 | Spring框架:IoC和AOP是扩展的核心.md
Normal file
541
极客时间专栏/Java业务开发常见错误100例/代码篇/19 | Spring框架:IoC和AOP是扩展的核心.md
Normal file
@@ -0,0 +1,541 @@
|
||||
<audio id="audio" title="19 | Spring框架:IoC和AOP是扩展的核心" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/eb/b1af6cf12f64ca0d2e64de93f69803eb.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我们来聊聊Spring框架中的IoC和AOP,及其容易出错的地方。
|
||||
|
||||
熟悉Java的同学都知道,Spring的家族庞大,常用的模块就有Spring Data、Spring Security、Spring Boot、Spring Cloud等。其实呢,Spring体系虽然庞大,但都是围绕Spring Core展开的,而Spring Core中最核心的就是IoC(控制反转)和AOP(面向切面编程)。
|
||||
|
||||
概括地说,IoC和AOP的初衷是解耦和扩展。理解这两个核心技术,就可以让你的代码变得更灵活、可随时替换,以及业务组件间更解耦。在接下来的两讲中,我会与你深入剖析几个案例,带你绕过业务中通过Spring实现IoC和AOP相关的坑。
|
||||
|
||||
为了便于理解这两讲中的案例,我们先回顾下IoC和AOP的基础知识。
|
||||
|
||||
IoC,其实就是一种设计思想。使用Spring来实现IoC,意味着将你设计好的对象交给Spring容器控制,而不是直接在对象内部控制。那,为什么要让容器来管理对象呢?或许你能想到的是,使用IoC方便、可以实现解耦。但在我看来,相比于这两个原因,更重要的是IoC带来了更多的可能性。
|
||||
|
||||
如果以容器为依托来管理所有的框架、业务对象,我们不仅可以无侵入地调整对象的关系,还可以无侵入地随时调整对象的属性,甚至是实现对象的替换。这就使得框架开发者在程序背后实现一些扩展不再是问题,带来的可能性是无限的。比如我们要监控的对象如果是Bean,实现就会非常简单。所以,这套容器体系,不仅被Spring Core和Spring Boot大量依赖,还实现了一些外部框架和Spring的无缝整合。
|
||||
|
||||
AOP,体现了松耦合、高内聚的精髓,在切面集中实现横切关注点(缓存、权限、日志等),然后通过切点配置把代码注入合适的地方。切面、切点、增强、连接点,是AOP中非常重要的概念,也是我们这两讲会大量提及的。
|
||||
|
||||
为方便理解,我们把Spring AOP技术看作为蛋糕做奶油夹层的工序。如果我们希望找到一个合适的地方把奶油注入蛋糕胚子中,那应该如何指导工人完成操作呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/db/c71f2ec73901f7bcaa8332f237dfeddb.png" alt="">
|
||||
|
||||
- 首先,我们要提醒他,只能往蛋糕胚子里面加奶油,而不能上面或下面加奶油。这就是连接点(Join point),对于Spring AOP来说,连接点就是方法执行。
|
||||
- 然后,我们要告诉他,在什么点切开蛋糕加奶油。比如,可以在蛋糕坯子中间加入一层奶油,在中间切一次;也可以在中间加两层奶油,在1/3和2/3的地方切两次。这就是切点(Pointcut),Spring AOP中默认使用AspectJ查询表达式,通过在连接点运行查询表达式来匹配切入点。
|
||||
- 接下来也是最重要的,我们要告诉他,切开蛋糕后要做什么,也就是加入奶油。这就是增强(Advice),也叫作通知,定义了切入切点后增强的方式,包括前、后、环绕等。Spring AOP中,把增强定义为拦截器。
|
||||
- 最后,我们要告诉他,找到蛋糕胚子中要加奶油的地方并加入奶油。为蛋糕做奶油夹层的操作,对Spring AOP来说就是切面(Aspect),也叫作方面。切面=切点+增强。
|
||||
|
||||
好了,理解了这几个核心概念,我们就可以继续分析案例了。
|
||||
|
||||
我要首先说明的是,Spring相关问题的问题比较复杂,一方面是Spring提供的IoC和AOP本就灵活,另一方面Spring Boot的自动装配、Spring Cloud复杂的模块会让问题排查变得更复杂。因此,今天这一讲,我会带你先打好基础,通过两个案例来重点聊聊IoC和AOP;然后,我会在下一讲中与你分享Spring相关的坑。
|
||||
|
||||
## 单例的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
|
||||
public void say() {
|
||||
super.say();
|
||||
log.info("bye");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
许多开发同学认为,@Service注解的意义在于,能通过@Autowired注解让Spring自动注入对象,就比如可以直接使用注入的List<sayservice>获取到SayHello和SayBye,而没想过类的生命周期:</sayservice>
|
||||
|
||||
```
|
||||
@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)
|
||||
|
||||
```
|
||||
|
||||
但,上线后还是出现了内存泄漏,证明修改是无效的。
|
||||
|
||||
从日志可以看到,第一次调用和第二次调用的时候,SayBye对象都是4c0bfe9e,SayHello也是一样的问题。从日志第7到10行还可以看到,第二次调用后List的元素个数变为了2,说明父类SayService维护的List在不断增长,不断调用必然出现OOM:
|
||||
|
||||
```
|
||||
[15:01:09.349] [http-nio-45678-exec-1] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
|
||||
[15:01:09.401] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@4c0bfe9e size:1
|
||||
[15:01:09.402] [http-nio-45678-exec-1] [INFO ] [t.commonmistakes.spring.demo1.SayBye:16 ] - bye
|
||||
[15:01:09.469] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@490fbeaa size:1
|
||||
[15:01:09.469] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
|
||||
[15:01:15.167] [http-nio-45678-exec-2] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
|
||||
[15:01:15.197] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@4c0bfe9e size:2
|
||||
[15:01:15.198] [http-nio-45678-exec-2] [INFO ] [t.commonmistakes.spring.demo1.SayBye:16 ] - bye
|
||||
[15:01:15.224] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@490fbeaa size:2
|
||||
[15:01:15.224] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
|
||||
|
||||
```
|
||||
|
||||
这就引出了单例的Bean如何注入Prototype的Bean这个问题。Controller标记了@RestController注解,而@RestController注解=@Controller注解+@ResponseBody注解,又因为@Controller标记了@Component元注解,所以@RestController注解其实也是一个Spring Bean:
|
||||
|
||||
```
|
||||
//@RestController注解=@Controller注解+@ResponseBody注解@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Controller
|
||||
@ResponseBody
|
||||
public @interface RestController {}
|
||||
|
||||
//@Controller又标记了@Component元注解
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Component
|
||||
public @interface Controller {}
|
||||
|
||||
```
|
||||
|
||||
**Bean默认是单例的,所以单例的Controller注入的Service也是一次性创建的,即使Service本身标识了prototype的范围也没用。**
|
||||
|
||||
修复方式是,让Service以代理方式注入。这样虽然Controller本身是单例的,但每次都能从代理获取Service。这样一来,prototype范围的配置才能真正生效:
|
||||
|
||||
```
|
||||
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
|
||||
|
||||
```
|
||||
|
||||
通过日志可以确认这种修复方式有效:
|
||||
|
||||
```
|
||||
[15:08:42.649] [http-nio-45678-exec-1] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
|
||||
[15:08:42.747] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@3fa64743 size:1
|
||||
[15:08:42.747] [http-nio-45678-exec-1] [INFO ] [t.commonmistakes.spring.demo1.SayBye:17 ] - bye
|
||||
[15:08:42.871] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@2f0b779 size:1
|
||||
[15:08:42.872] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
|
||||
[15:08:42.932] [http-nio-45678-exec-2] [INFO ] [.s.d.BeanSingletonAndOrderController:22 ] - ====================
|
||||
[15:08:42.991] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayBye@7319b18e size:1
|
||||
[15:08:42.992] [http-nio-45678-exec-2] [INFO ] [t.commonmistakes.spring.demo1.SayBye:17 ] - bye
|
||||
[15:08:43.046] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayService :19 ] - I'm org.geekbang.time.commonmistakes.spring.demo1.SayHello@77262b35 size:1
|
||||
[15:08:43.046] [http-nio-45678-exec-2] [INFO ] [o.g.t.c.spring.demo1.SayHello :17 ] - hello
|
||||
|
||||
```
|
||||
|
||||
调试一下也可以发现,注入的Service都是Spring生成的代理类:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/30/a95f7a5f3a576b3b426c7c5625b29230.png" alt="">
|
||||
|
||||
当然,如果不希望走代理的话还有一种方式是,每次直接从ApplicationContext中获取Bean:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
@GetMapping("test2")
|
||||
public void test2() {
|
||||
applicationContext.getBeansOfType(SayService.class).values().forEach(SayService::say);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果细心的话,你可以发现另一个潜在的问题。这里Spring注入的SayService的List,第一个元素是SayBye,第二个元素是SayHello。但,我们更希望的是先执行Hello再执行Bye,所以注入一个List Bean时,需要进一步考虑Bean的顺序或者说优先级。
|
||||
|
||||
大多数情况下顺序并不是那么重要,但对于AOP,顺序可能会引发致命问题。我们继续往下看这个问题吧。
|
||||
|
||||
## 监控切面因为顺序问题导致Spring事务失效
|
||||
|
||||
实现横切关注点,是AOP非常常见的一个应用。我曾看到过一个不错的AOP实践,通过AOP实现了一个整合日志记录、异常处理和方法耗时打点为一体的统一切面。但后来发现,使用了AOP切面后,这个应用的声明式事务处理居然都是无效的。你可以先回顾下[第6讲](https://time.geekbang.org/column/article/213295)中提到的,Spring事务失效的几种可能性。
|
||||
|
||||
现在我们来看下这个案例,分析下AOP实现的监控组件和事务失效有什么关系,以及通过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
|
||||
public class MetricsAspect {
|
||||
//让Spring帮我们注入ObjectMapper,以方便通过JSON序列化来记录方法入参和出参
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
//实现一个返回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)));
|
||||
public static <T> T getDefaultValue(Class<T> clazz) {
|
||||
return (T) DEFAULT_VALUES.get(clazz);
|
||||
}
|
||||
|
||||
//@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();
|
||||
Metrics metrics = signature.getMethod().getAnnotation(Metrics.class);
|
||||
|
||||
String name = String.format("【%s】【%s】", signature.getDeclaringType().toString(), signature.toLongString());
|
||||
//因为需要默认对所有@RestController标记的Web控制器实现@Metrics注解的功能,在这种情况下方法上必然是没有@Metrics注解的,我们需要获取一个默认注解。虽然可以手动实例化一个@Metrics注解的实例出来,但为了节省代码行数,我们通过在一个内部类上定义@Metrics注解方式,然后通过反射获取注解的小技巧,来获得一个默认的@Metrics注解的实例
|
||||
if (metrics == null) {
|
||||
@Metrics
|
||||
final class c {}
|
||||
metrics = c.class.getAnnotation(Metrics.class);
|
||||
}
|
||||
//尝试从请求上下文(如果有的话)获得请求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())));
|
||||
//实现连接点方法的执行,以及成功失败的打点,出现异常的时候还会记录日志
|
||||
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的功能。
|
||||
|
||||
其中,Service中实现创建用户的时候做了事务处理,当用户名包含test字样时会抛出异常,导致事务回滚。同时,我们为Service中的createUser标记了@Metrics注解。这样一来,我们还可以手动为类或方法标记@Metrics注解,实现Controller之外的其他组件的自动监控。
|
||||
|
||||
```
|
||||
@Slf4j
|
||||
@RestController //自动进行监控
|
||||
@RequestMapping("metricstest")
|
||||
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
|
||||
public class UserService {
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
@Transactional
|
||||
@Metrics //启用方法监控
|
||||
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);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用用户名“test”测试一下注册功能:
|
||||
|
||||
```
|
||||
[16:27:52.586] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :85 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的参数是:【["test"]】
|
||||
[16:27:52.590] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :85 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 的参数是:【[{"id":null,"name":"test"}]】
|
||||
[16:27:52.609] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :96 ] - 【失败打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 失败,耗时:19 ms
|
||||
[16:27:52.610] [http-nio-45678-exec-3] [ERROR] [o.g.t.c.spring.demo2.MetricsAspect :98 ] - 【异常日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 出现异常!
|
||||
java.lang.RuntimeException: invalid username!
|
||||
at org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(UserService.java:18)
|
||||
at org.geekbang.time.commonmistakes.spring.demo2.UserService$$FastClassBySpringCGLIB$$9eec91f.invoke(<generated>)
|
||||
[16:27:52.614] [http-nio-45678-exec-3] [ERROR] [g.t.c.spring.demo2.MetricsController:21 ] - create user failed because invalid username!
|
||||
[16:27:52.617] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :93 ] - 【成功打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 成功,耗时:31 ms
|
||||
[16:27:52.618] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :108 ] - 【出参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的返回是:【0】
|
||||
|
||||
```
|
||||
|
||||
看起来这个切面很不错,日志中打出了整个调用的出入参、方法耗时:
|
||||
|
||||
- 第1、8、9和10行分别是Controller方法的入参日志、调用Service方法出错后记录的错误信息、成功执行的打点和出参日志。因为Controller方法内部进行了try-catch处理,所以其方法最终是成功执行的。出参日志中显示最后查询到的用户数量是0,表示用户创建实际是失败的。
|
||||
- 第2、3和4~7行分别是Service方法的入参日志、失败打点和异常日志。正是因为Service方法的异常抛到了Controller,所以整个方法才能被@Transactional声明式事务回滚。在这里,MetricsAspect捕获了异常又重新抛出,记录了异常的同时又不影响事务回滚。
|
||||
|
||||
一段时间后,开发同学觉得默认的@Metrics配置有点不合适,希望进行两个调整:
|
||||
|
||||
- 对于Controller的自动打点,不要自动记录入参和出参日志,否则日志量太大;
|
||||
- 对于Service中的方法,最好可以自动捕获异常。
|
||||
|
||||
于是,他就为MetricsController手动加上了@Metrics注解,设置logParameters和logReturn为false;然后为Service中的createUser方法的@Metrics注解,设置了ignoreException属性为true:
|
||||
|
||||
```
|
||||
@Metrics(logParameters = false, logReturn = false) //改动点1
|
||||
public class MetricsController {
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class UserService {
|
||||
@Transactional
|
||||
@Metrics(ignoreException = true) //改动点2
|
||||
public void createUser(UserEntity entity) {
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
代码上线后发现日志量并没有减少,更要命的是事务回滚失效了,从输出看到最后查询到了名为test的用户:
|
||||
|
||||
```
|
||||
[17:01:16.549] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :75 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的参数是:【["test"]】
|
||||
[17:01:16.670] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :75 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 的参数是:【[{"id":null,"name":"test"}]】
|
||||
[17:01:16.885] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :86 ] - 【失败打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 失败,耗时:211 ms
|
||||
[17:01:16.899] [http-nio-45678-exec-1] [ERROR] [o.g.t.c.spring.demo2.MetricsAspect :88 ] - 【异常日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 出现异常!
|
||||
java.lang.RuntimeException: invalid username!
|
||||
at org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(UserService.java:18)
|
||||
at org.geekbang.time.commonmistakes.spring.demo2.UserService$$FastClassBySpringCGLIB$$9eec91f.invoke(<generated>)
|
||||
[17:01:16.902] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :98 ] - 【出参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.UserService】【public void org.geekbang.time.commonmistakes.spring.demo2.UserService.createUser(org.geekbang.time.commonmistakes.spring.demo2.UserEntity)】【http://localhost:45678/metricstest/transaction】 的返回是:【null】
|
||||
[17:01:17.466] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :83 ] - 【成功打点】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 成功,耗时:915 ms
|
||||
[17:01:17.467] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo2.MetricsAspect :98 ] - 【出参日志】调用 【class org.geekbang.time.commonmistakes.spring.demo2.MetricsController】【public int org.geekbang.time.commonmistakes.spring.demo2.MetricsController.transaction(java.lang.String)】【http://localhost:45678/metricstest/transaction】 的返回是:【1】
|
||||
|
||||
```
|
||||
|
||||
在介绍[数据库事务](https://time.geekbang.org/column/article/213295)时,我们分析了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的最大值。其实,**值越大优先级反而越低,这点比较反直觉**:
|
||||
|
||||
```
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
|
||||
@Documented
|
||||
public @interface Order {
|
||||
|
||||
int value() default Ordered.LOWEST_PRECEDENCE;
|
||||
|
||||
}
|
||||
public interface Ordered {
|
||||
int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
|
||||
int LOWEST_PRECEDENCE = Integer.MAX_VALUE;
|
||||
int getOrder();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再通过一个例子,来理解下增强的执行顺序。新建一个TestAspectWithOrder10切面,通过@Order注解设置优先级为10,在内部定义@Before、@After、@Around三类增强,三个增强的逻辑只是简单的日志输出,切点是TestController所有方法;然后再定义一个类似的TestAspectWithOrder20切面,设置优先级为20:
|
||||
|
||||
```
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(10)
|
||||
@Slf4j
|
||||
public class TestAspectWithOrder10 {
|
||||
@Before("execution(* org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.*(..))")
|
||||
public void before(JoinPoint joinPoint) throws Throwable {
|
||||
log.info("TestAspectWithOrder10 @Before");
|
||||
}
|
||||
@After("execution(* org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.*(..))")
|
||||
public void after(JoinPoint joinPoint) throws Throwable {
|
||||
log.info("TestAspectWithOrder10 @After");
|
||||
}
|
||||
@Around("execution(* org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.*(..))")
|
||||
public Object around(ProceedingJoinPoint pjp) throws Throwable {
|
||||
log.info("TestAspectWithOrder10 @Around before");
|
||||
Object o = pjp.proceed();
|
||||
log.info("TestAspectWithOrder10 @Around after");
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(20)
|
||||
@Slf4j
|
||||
public class TestAspectWithOrder20 {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调用TestController的方法后,通过日志输出可以看到,增强执行顺序符合切面执行顺序的三个规则:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/3e/3c687829083abebe1d6e347f5766903e.png" alt="">
|
||||
|
||||
因为Spring的事务管理也是基于AOP的,默认情况下优先级最低也就是会先执行出操作,但是自定义切面MetricsAspect也同样是最低优先级,这个时候就可能出现问题:如果出操作先执行捕获了异常,那么Spring的事务处理就会因为无法捕获到异常导致无法回滚事务。
|
||||
|
||||
解决方式是,明确MetricsAspect的优先级,可以设置为最高优先级,也就是最先执行入操作最后执行出操作:
|
||||
|
||||
```
|
||||
//将MetricsAspect这个Bean的优先级设置为最高
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class MetricsAspect {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
此外,**我们要知道切入的连接点是方法,注解定义在类上是无法直接从方法上获取到注解的**。修复方式是,改为优先从方法获取,如果获取不到再从类获取,如果还是获取不到再使用默认的注解:
|
||||
|
||||
```
|
||||
Metrics metrics = signature.getMethod().getAnnotation(Metrics.class);
|
||||
if (metrics == null) {
|
||||
metrics = signature.getMethod().getDeclaringClass().getAnnotation(Metrics.class);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过这2处修改,事务终于又可以回滚了,并且Controller的监控日志也不再出现入参、出参信息。
|
||||
|
||||
我再总结下这个案例。利用反射+注解+Spring AOP实现统一的横切日志关注点时,我们遇到的Spring事务失效问题,是由自定义的切面执行顺序引起的。这也让我们认识到,因为Spring内部大量利用IoC和AOP实现了各种组件,当使用IoC和AOP时,一定要考虑是否会影响其他内部组件。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我通过2个案例和你分享了Spring IoC和AOP的基本概念,以及三个比较容易出错的点。
|
||||
|
||||
第一,让Spring容器管理对象,要考虑对象默认的Scope单例是否适合,对于有状态的类型,单例可能产生内存泄露问题。
|
||||
|
||||
第二,如果要为单例的Bean注入Prototype的Bean,绝不是仅仅修改Scope属性这么简单。由于单例的Bean在容器启动时就会完成一次性初始化。最简单的解决方案是,把Prototype的Bean设置为通过代理注入,也就是设置proxyMode属性为TARGET_CLASS。
|
||||
|
||||
第三,如果一组相同类型的Bean是有顺序的,需要明确使用@Order注解来设置顺序。你可以再回顾下,两个不同优先级切面中@Before、@After和@Around三种增强的执行顺序,是什么样的。
|
||||
|
||||
最后我要说的是,文内第二个案例是一个完整的统一日志监控案例,继续修改就可以实现一个完善的、生产级的方法调用监控平台。这些修改主要是两方面:把日志打点,改为对接Metrics监控系统;把各种功能的监控开关,从注解属性获取改为通过配置系统实时获取。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 除了通过@Autowired注入Bean外,还可以使用@Inject或@Resource来注入Bean。你知道这三种方式的区别是什么吗?
|
||||
1. 当Bean产生循环依赖时,比如BeanA的构造方法依赖BeanB作为成员需要注入,BeanB也依赖BeanA,你觉得会出现什么问题呢?又有哪些解决方式呢?
|
||||
|
||||
在下一讲中,我会继续与你探讨Spring核心的其他问题。我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
654
极客时间专栏/Java业务开发常见错误100例/代码篇/20 | Spring框架:框架帮我们做了很多工作也带来了复杂度.md
Normal file
654
极客时间专栏/Java业务开发常见错误100例/代码篇/20 | Spring框架:框架帮我们做了很多工作也带来了复杂度.md
Normal file
@@ -0,0 +1,654 @@
|
||||
<audio id="audio" title="20 | Spring框架:框架帮我们做了很多工作也带来了复杂度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/d6/e1fd22aa528fd1a2971d5e5e2199a7d6.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。今天,我们聊聊Spring框架给业务代码带来的复杂度,以及与之相关的坑。
|
||||
|
||||
在上一讲,通过AOP实现统一的监控组件的案例,我们看到了IoC和AOP配合使用的威力:当对象由Spring容器管理成为Bean之后,我们不但可以通过容器管理配置Bean的属性,还可以方便地对感兴趣的方法做AOP。
|
||||
|
||||
不过,前提是对象必须是Bean。你可能会觉得这个结论很明显,也很容易理解啊。但就和上一讲提到的Bean默认是单例一样,理解起来简单,实践的时候却非常容易踩坑。其中原因,一方面是,理解Spring的体系结构和使用方式有一定曲线;另一方面是,Spring多年发展堆积起来的内部结构非常复杂,这也是更重要的原因。
|
||||
|
||||
在我看来,Spring框架内部的复杂度主要表现为三点:
|
||||
|
||||
- 第一,Spring框架借助IoC和AOP的功能,实现了修改、拦截Bean的定义和实例的灵活性,因此真正执行的代码流程并不是串行的。
|
||||
- 第二,Spring Boot根据当前依赖情况实现了自动配置,虽然省去了手动配置的麻烦,但也因此多了一些黑盒、提升了复杂度。
|
||||
- 第三,Spring Cloud模块多版本也多,Spring Boot 1.x和2.x的区别也很大。如果要对Spring Cloud或Spring Boot进行二次开发的话,考虑兼容性的成本会很高。
|
||||
|
||||
今天,我们就通过配置AOP切入Spring Cloud Feign组件失败、Spring Boot程序的文件配置被覆盖这两个案例,感受一下Spring的复杂度。我希望这一讲的内容,能帮助你面对Spring这个复杂框架出现的问题时,可以非常自信地找到解决方案。
|
||||
|
||||
## 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.spring.demo4.feign")
|
||||
public class Config {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过Feign调用服务后可以看到日志中有输出,的确实现了feign.Client的切入,切入的是execute方法:
|
||||
|
||||
```
|
||||
[15:48:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect :20 ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://client/feignaop/server HTTP/1.1
|
||||
|
||||
Binary data, feign.Request$Options@5c16561a]
|
||||
|
||||
```
|
||||
|
||||
一开始这个项目使用的是客户端的负载均衡,也就是让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的调用了。
|
||||
|
||||
为了还原这个场景,我写了一段代码,定义两个方法分别通过Client和ClientWithUrl这两个Feign进行接口调用:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private Client client;
|
||||
|
||||
@Autowired
|
||||
private ClientWithUrl clientWithUrl;
|
||||
|
||||
@GetMapping("client")
|
||||
public String client() {
|
||||
return client.api();
|
||||
}
|
||||
|
||||
@GetMapping("clientWithUrl")
|
||||
public String clientWithUrl() {
|
||||
return clientWithUrl.api();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,调用Client后AOP有日志输出,调用ClientWithUrl后却没有:
|
||||
|
||||
```
|
||||
[15:50:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect :20 ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://client/feignaop/server HTTP/1.1
|
||||
|
||||
Binary data, feign.Request$Options@5c16561
|
||||
|
||||
```
|
||||
|
||||
这就很费解了。难道为Feign指定了URL,其实现就不是feign.Clinet了吗?
|
||||
|
||||
要明白原因,我们需要分析一下FeignClient的创建过程,也就是分析FeignClientFactoryBean类的getTarget方法。源码第4行有一个if判断,当URL没有内容也就是为空或者不配置时调用loadBalance方法,在其内部通过FeignContext从容器获取feign.Client的实例:
|
||||
|
||||
```
|
||||
<T> T getTarget() {
|
||||
FeignContext context = this.applicationContext.getBean(FeignContext.class);
|
||||
Feign.Builder builder = feign(context);
|
||||
if (!StringUtils.hasText(this.url)) {
|
||||
...
|
||||
return (T) loadBalance(builder, context,
|
||||
new HardCodedTarget<>(this.type, this.name, this.url));
|
||||
}
|
||||
...
|
||||
String url = this.url + cleanPath();
|
||||
Client client = getOptional(context, Client.class);
|
||||
if (client != null) {
|
||||
if (client instanceof LoadBalancerFeignClient) {
|
||||
// not load balancing because we have a url,
|
||||
// but ribbon is on the classpath, so unwrap
|
||||
client = ((LoadBalancerFeignClient) client).getDelegate();
|
||||
}
|
||||
builder.client(client);
|
||||
}
|
||||
...
|
||||
}
|
||||
protected <T> T loadBalance(Feign.Builder builder, FeignContext context,
|
||||
HardCodedTarget<T> target) {
|
||||
Client client = getOptional(context, Client.class);
|
||||
if (client != null) {
|
||||
builder.client(client);
|
||||
Targeter targeter = get(context, Targeter.class);
|
||||
return targeter.target(this, builder, context, target);
|
||||
}
|
||||
...
|
||||
}
|
||||
protected <T> T getOptional(FeignContext context, Class<T> type) {
|
||||
return context.getInstance(this.contextId, type);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调试一下可以看到,client是LoadBalanceFeignClient,已经是经过代理增强的,明显是一个Bean:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/fd/0510e28cd764aaf7f1b4b4ca03049ffd.png" alt="">
|
||||
|
||||
所以,没有指定URL的@FeignClient对应的LoadBalanceFeignClient,是可以通过feign.Client切入的。
|
||||
|
||||
在我们上面贴出来的源码的16行可以看到,当URL不为空的时候,client设置为了LoadBalanceFeignClient的delegate属性。其原因注释中有提到,因为有了URL就不需要客户端负载均衡了,但因为Ribbon在classpath中,所以需要从LoadBalanceFeignClient提取出真正的Client。断点调试下可以看到,这时client是一个ApacheHttpClient:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/30/1b872a900be7327f74bc09bde4c54230.png" alt="">
|
||||
|
||||
那么,这个ApacheHttpClient是从哪里来的呢?这里,我教你一个小技巧:如果你希望知道一个类是怎样调用栈初始化的,可以在构造方法中设置一个断点进行调试。这样,你就可以在IDE的栈窗口看到整个方法调用栈,然后点击每一个栈帧看到整个过程。
|
||||
|
||||
用这种方式,我们可以看到,是HttpClientFeignLoadBalancedConfiguration类实例化的ApacheHttpClient:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/9a/7b712acf6d7062ae82f1fd04b954ff9a.png" alt="">
|
||||
|
||||
进一步查看HttpClientFeignLoadBalancedConfiguration的源码可以发现,LoadBalancerFeignClient这个Bean在实例化的时候,new出来一个ApacheHttpClient作为delegate放到了LoadBalancerFeignClient中:
|
||||
|
||||
```
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(Client.class)
|
||||
public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory,
|
||||
SpringClientFactory clientFactory, HttpClient httpClient) {
|
||||
ApacheHttpClient delegate = new ApacheHttpClient(httpClient);
|
||||
return new LoadBalancerFeignClient(delegate, cachingFactory, clientFactory);
|
||||
}
|
||||
|
||||
public LoadBalancerFeignClient(Client delegate,
|
||||
CachingSpringLoadBalancerFactory lbClientFactory,
|
||||
SpringClientFactory clientFactory) {
|
||||
this.delegate = delegate;
|
||||
this.lbClientFactory = lbClientFactory;
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
显然,ApacheHttpClient是new出来的,并不是Bean,而LoadBalancerFeignClient是一个Bean。
|
||||
|
||||
有了这个信息,我们再来捋一下,为什么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+)无法切入。
|
||||
|
||||
那,如何解决这个问题呢?有一位同学提出,修改一下切点表达式,通过@FeignClient注解来切:
|
||||
|
||||
```
|
||||
@Before("@within(org.springframework.cloud.openfeign.FeignClient)")
|
||||
public void before(JoinPoint pjp){
|
||||
log.info("@within(org.springframework.cloud.openfeign.FeignClient) pjp {}, args:{}", pjp, pjp.getArgs());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
修改后通过日志看到,AOP的确切成功了:
|
||||
|
||||
```
|
||||
[15:53:39.093] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo4.Wrong2Aspect :17 ] - @within(org.springframework.cloud.openfeign.FeignClient) pjp execution(String org.geekbang.time.commonmistakes.spring.demo4.feign.ClientWithUrl.api()), args:[]
|
||||
|
||||
```
|
||||
|
||||
但仔细一看就会发现,**这次切入的是ClientWithUrl接口的API方法,并不是client.Feign接口的execute方法,显然不符合预期**。
|
||||
|
||||
这位同学犯的错误是,没有弄清楚真正希望切的是什么对象。@FeignClient注解标记在Feign Client接口上,所以切的是Feign定义的接口,也就是每一个实际的API接口。而通过feign.Client接口切的是客户端实现类,切到的是通用的、执行所有Feign调用的execute方法。
|
||||
|
||||
那么问题来了,ApacheHttpClient不是Bean无法切入,切Feign接口本身又不符合要求。怎么办呢?
|
||||
|
||||
经过一番研究发现,ApacheHttpClient其实有机会独立成为Bean。查看HttpClientFeignConfiguration的源码可以发现,当没有ILoadBalancer类型的时候,自动装配会把ApacheHttpClient设置为Bean。
|
||||
|
||||
这么做的原因很明确,如果我们不希望做客户端负载均衡的话,应该不会引用Ribbon组件的依赖,自然没有LoadBalancerFeignClient,只有ApacheHttpClient:
|
||||
|
||||
```
|
||||
@Configuration
|
||||
@ConditionalOnClass(ApacheHttpClient.class)
|
||||
@ConditionalOnMissingClass("com.netflix.loadbalancer.ILoadBalancer")
|
||||
@ConditionalOnMissingBean(CloseableHttpClient.class)
|
||||
@ConditionalOnProperty(value = "feign.httpclient.enabled", matchIfMissing = true)
|
||||
protected static class HttpClientFeignConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(Client.class)
|
||||
public Client feignClient(HttpClient httpClient) {
|
||||
return new ApacheHttpClient(httpClient);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那,把pom.xml中的ribbon模块注释之后,是不是可以解决问题呢?
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
|
||||
</dependency>
|
||||
|
||||
```
|
||||
|
||||
但,问题并没解决,启动出错误了:
|
||||
|
||||
```
|
||||
Caused by: java.lang.IllegalArgumentException: Cannot subclass final class feign.httpclient.ApacheHttpClient
|
||||
at org.springframework.cglib.proxy.Enhancer.generateClass(Enhancer.java:657)
|
||||
at org.springframework.cglib.core.DefaultGeneratorStrategy.generate(DefaultGeneratorStrategy.java:25)
|
||||
|
||||
```
|
||||
|
||||
这里,又涉及了Spring实现动态代理的两种方式:
|
||||
|
||||
- JDK动态代理,通过反射实现,只支持对实现接口的类进行代理;
|
||||
- CGLIB动态字节码注入方式,通过继承实现代理,没有这个限制。
|
||||
|
||||
**Spring Boot 2.x默认使用CGLIB的方式,但通过继承实现代理有个问题是,无法继承final的类。因为,ApacheHttpClient类就是定义为了final**:
|
||||
|
||||
```
|
||||
public final class ApacheHttpClient implements Client {
|
||||
|
||||
```
|
||||
|
||||
为解决这个问题,我们把配置参数proxy-target-class的值修改为false,以切换到使用JDK动态代理的方式:
|
||||
|
||||
```
|
||||
spring.aop.proxy-target-class=false
|
||||
|
||||
```
|
||||
|
||||
修改后执行clientWithUrl接口可以看到,通过within(feign.Client+)方式可以切入feign.Client子类了。以下日志显示了@within和within的两次切入:
|
||||
|
||||
```
|
||||
[16:29:55.303] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.Wrong2Aspect :16 ] - @within(org.springframework.cloud.openfeign.FeignClient) pjp execution(String org.geekbang.time.commonmistakes.spring.demo4.feign.ClientWithUrl.api()), args:[]
|
||||
[16:29:55.310] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect :15 ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://localhost:45678/feignaop/server HTTP/1.1
|
||||
|
||||
|
||||
Binary data, feign.Request$Options@387550b0]
|
||||
|
||||
```
|
||||
|
||||
这下我们就明白了,Spring Cloud使用了自动装配来根据依赖装配组件,组件是否成为Bean决定了AOP是否可以切入,在尝试通过AOP切入Spring Bean的时候要注意。
|
||||
|
||||
加上上一讲的两个案例,我就把IoC和AOP相关的坑点和你说清楚了。除此之外,我们在业务开发时,还有一个绕不开的点是,Spring程序的配置问题。接下来,我们就具体看看吧。
|
||||
|
||||
## Spring程序配置的优先级问题
|
||||
|
||||
我们知道,通过配置文件application.properties,可以实现Spring Boot应用程序的参数配置。但我们可能不知道的是,Spring程序配置是有优先级的,即当两个不同的配置源包含相同的配置项时,其中一个配置项很可能会被覆盖掉。这,也是为什么我们会遇到些看似诡异的配置失效问题。
|
||||
|
||||
我们来通过一个实际案例,研究下配置源以及配置源的优先级问题。
|
||||
|
||||
对于Spring Boot应用程序,一般我们会通过设置management.server.port参数,来暴露独立的actuator管理端口。这样做更安全,也更方便监控系统统一监控程序是否健康。
|
||||
|
||||
```
|
||||
management.server.port=45679
|
||||
|
||||
```
|
||||
|
||||
有一天程序重新发布后,监控系统显示程序离线。但排查下来发现,程序是正常工作的,只是actuator管理端口的端口号被改了,不是配置文件中定义的45679了。
|
||||
|
||||
后来发现,运维同学在服务器上定义了两个环境变量MANAGEMENT_SERVER_IP和MANAGEMENT_SERVER_PORT,目的是方便监控Agent把监控数据上报到统一的管理服务上:
|
||||
|
||||
```
|
||||
MANAGEMENT_SERVER_IP=192.168.0.2
|
||||
MANAGEMENT_SERVER_PORT=12345
|
||||
|
||||
```
|
||||
|
||||
问题就是出在这里。MANAGEMENT_SERVER_PORT覆盖了配置文件中的management.server.port,修改了应用程序本身的端口。当然,监控系统也就无法通过老的管理端口访问到应用的health端口了。如下图所示,actuator的端口号变成了12345:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/e6/b287b7ad823a39bb604fa69e02c720e6.png" alt="">
|
||||
|
||||
到这里坑还没完,为了方便用户登录,需要在页面上显示默认的管理员用户名,于是开发同学在配置文件中定义了一个user.name属性,并设置为defaultadminname:
|
||||
|
||||
```
|
||||
user.name=defaultadminname
|
||||
|
||||
```
|
||||
|
||||
后来发现,程序读取出来的用户名根本就不是配置文件中定义的。这,又是咋回事?
|
||||
|
||||
带着这个问题,以及之前环境变量覆盖配置文件配置的问题,我们写段代码看看,从Spring中到底能读取到几个management.server.port和user.name配置项。
|
||||
|
||||
要想查询Spring中所有的配置,我们需要以环境Environment接口为入口。接下来,我就与你说说Spring通过环境Environment抽象出的Property和Profile:
|
||||
|
||||
- 针对Property,又抽象出各种PropertySource类代表配置源。一个环境下可能有多个配置源,每个配置源中有诸多配置项。在查询配置信息时,需要按照配置源优先级进行查询。
|
||||
- Profile定义了场景的概念。通常,我们会定义类似dev、test、stage和prod等环境作为不同的Profile,用于按照场景对Bean进行逻辑归属。同时,Profile和配置文件也有关系,每个环境都有独立的配置文件,但我们只会激活某一个环境来生效特定环境的配置文件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/c0/2c68da94d31182cad34c965f878196c0.png" alt="">
|
||||
|
||||
接下来,我们重点看看Property的查询过程。
|
||||
|
||||
对于非Web应用,Spring对于Environment接口的实现是StandardEnvironment类。我们通过Spring注入StandardEnvironment后循环getPropertySources获得的PropertySource,来查询所有的PropertySource中key是user.name或management.server.port的属性值;然后遍历getPropertySources方法,获得所有配置源并打印出来:
|
||||
|
||||
```
|
||||
@Autowired
|
||||
private StandardEnvironment env;
|
||||
@PostConstruct
|
||||
public void init(){
|
||||
Arrays.asList("user.name", "management.server.port").forEach(key -> {
|
||||
env.getPropertySources().forEach(propertySource -> {
|
||||
if (propertySource.containsProperty(key)) {
|
||||
log.info("{} -> {} 实际取值:{}", propertySource, propertySource.getProperty(key), env.getProperty(key));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
System.out.println("配置优先级:");
|
||||
env.getPropertySources().stream().forEach(System.out::println);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们研究下输出的日志:
|
||||
|
||||
```
|
||||
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : ConfigurationPropertySourcesPropertySource {name='configurationProperties'} -> zhuye 实际取值:zhuye
|
||||
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : PropertiesPropertySource {name='systemProperties'} -> zhuye 实际取值:zhuye
|
||||
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : OriginTrackedMapPropertySource {name='applicationConfig: [classpath:/application.properties]'} -> defaultadminname 实际取值:zhuye
|
||||
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : ConfigurationPropertySourcesPropertySource {name='configurationProperties'} -> 12345 实际取值:12345
|
||||
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : OriginAwareSystemEnvironmentPropertySource {name=''} -> 12345 实际取值:12345
|
||||
2020-01-15 16:08:34.054 INFO 40123 --- [ main] o.g.t.c.s.d.CommonMistakesApplication : OriginTrackedMapPropertySource {name='applicationConfig: [classpath:/application.properties]'} -> 45679 实际取值:12345
|
||||
配置优先级:
|
||||
ConfigurationPropertySourcesPropertySource {name='configurationProperties'}
|
||||
StubPropertySource {name='servletConfigInitParams'}
|
||||
ServletContextPropertySource {name='servletContextInitParams'}
|
||||
PropertiesPropertySource {name='systemProperties'}
|
||||
OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}
|
||||
RandomValuePropertySource {name='random'}
|
||||
OriginTrackedMapPropertySource {name='applicationConfig: [classpath:/application.properties]'}
|
||||
MapPropertySource {name='springCloudClientHostInfo'}
|
||||
MapPropertySource {name='defaultProperties'}
|
||||
|
||||
```
|
||||
|
||||
- 有三处定义了user.name:第一个是configurationProperties,值是zhuye;第二个是systemProperties,代表系统配置,值是zhuye;第三个是applicationConfig,也就是我们的配置文件,值是配置文件中定义的defaultadminname。
|
||||
- 同样地,也有三处定义了management.server.port:第一个是configurationProperties,值是12345;第二个是systemEnvironment代表系统环境,值是12345;第三个是applicationConfig,也就是我们的配置文件,值是配置文件中定义的45679。
|
||||
- 第7到16行的输出显示,Spring中有9个配置源,值得关注是ConfigurationPropertySourcesPropertySource、PropertiesPropertySource、OriginAwareSystemEnvironmentPropertySource和我们的配置文件。
|
||||
|
||||
那么,Spring真的是按这个顺序查询配置吗?最前面的configurationProperties,又是什么?为了回答这2个问题,我们需要分析下源码。我先说明下,下面源码分析的逻辑有些复杂,你可以结合着下面的整体流程图来理解:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/f9/3e6dc6456f6d1354da58fb260775c0f9.png" alt="">
|
||||
|
||||
Demo中注入的StandardEnvironment,继承的是AbstractEnvironment(图中紫色类)。AbstractEnvironment的源码如下:
|
||||
|
||||
```
|
||||
public abstract class AbstractEnvironment implements ConfigurableEnvironment {
|
||||
private final MutablePropertySources propertySources = new MutablePropertySources();
|
||||
private final ConfigurablePropertyResolver propertyResolver =
|
||||
new PropertySourcesPropertyResolver(this.propertySources);
|
||||
|
||||
public String getProperty(String key) {
|
||||
return this.propertyResolver.getProperty(key);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到:
|
||||
|
||||
- MutablePropertySources类型的字段propertySources,看起来代表了所有配置源;
|
||||
- getProperty方法,通过PropertySourcesPropertyResolver类进行查询配置;
|
||||
- 实例化PropertySourcesPropertyResolver的时候,传入了当前的MutablePropertySources。
|
||||
|
||||
接下来,我们继续分析MutablePropertySources和PropertySourcesPropertyResolver。先看看MutablePropertySources的源码(图中蓝色类):
|
||||
|
||||
```
|
||||
public class MutablePropertySources implements PropertySources {
|
||||
|
||||
private final List<PropertySource<?>> propertySourceList = new CopyOnWriteArrayList<>();
|
||||
|
||||
public void addFirst(PropertySource<?> propertySource) {
|
||||
removeIfPresent(propertySource);
|
||||
this.propertySourceList.add(0, propertySource);
|
||||
}
|
||||
public void addLast(PropertySource<?> propertySource) {
|
||||
removeIfPresent(propertySource);
|
||||
this.propertySourceList.add(propertySource);
|
||||
}
|
||||
public void addBefore(String relativePropertySourceName, PropertySource<?> propertySource) {
|
||||
...
|
||||
int index = assertPresentAndGetIndex(relativePropertySourceName);
|
||||
addAtIndex(index, propertySource);
|
||||
}
|
||||
public void addAfter(String relativePropertySourceName, PropertySource<?> propertySource) {
|
||||
...
|
||||
int index = assertPresentAndGetIndex(relativePropertySourceName);
|
||||
addAtIndex(index + 1, propertySource);
|
||||
}
|
||||
private void addAtIndex(int index, PropertySource<?> propertySource) {
|
||||
removeIfPresent(propertySource);
|
||||
this.propertySourceList.add(index, propertySource);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以发现:
|
||||
|
||||
- propertySourceList字段用来真正保存PropertySource的List,且这个List是一个CopyOnWriteArrayList。
|
||||
- 类中定义了addFirst、addLast、addBefore、addAfter等方法,来精确控制PropertySource加入propertySourceList的顺序。这也说明了顺序的重要性。
|
||||
|
||||
继续看下PropertySourcesPropertyResolver(图中绿色类)的源码,找到真正查询配置的方法getProperty。
|
||||
|
||||
这里,我们重点看一下第9行代码:遍历的propertySources是PropertySourcesPropertyResolver构造方法传入的,再结合AbstractEnvironment的源码可以发现,这个propertySources正是AbstractEnvironment中的MutablePropertySources对象。遍历时,如果发现配置源中有对应的Key值,则使用这个值。因此,MutablePropertySources中配置源的次序尤为重要。
|
||||
|
||||
```
|
||||
public class PropertySourcesPropertyResolver extends AbstractPropertyResolver {
|
||||
private final PropertySources propertySources;
|
||||
public PropertySourcesPropertyResolver(@Nullable PropertySources propertySources) {
|
||||
this.propertySources = propertySources;
|
||||
}
|
||||
|
||||
protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
|
||||
if (this.propertySources != null) {
|
||||
for (PropertySource<?> propertySource : this.propertySources) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Searching for key '" + key + "' in PropertySource '" +
|
||||
propertySource.getName() + "'");
|
||||
}
|
||||
Object value = propertySource.getProperty(key);
|
||||
if (value != null) {
|
||||
if (resolveNestedPlaceholders && value instanceof String) {
|
||||
value = resolveNestedPlaceholders((String) value);
|
||||
}
|
||||
logKeyFound(key, propertySource, value);
|
||||
return convertValueIfNecessary(value, targetValueType);
|
||||
}
|
||||
}
|
||||
}
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
回到之前的问题,在查询所有配置源的时候,我们注意到处在第一位的是ConfigurationPropertySourcesPropertySource,这是什么呢?
|
||||
|
||||
其实,它不是一个实际存在的配置源,扮演的是一个代理的角色。但通过调试你会发现,我们获取的值竟然是由它提供并且返回的,且没有循环遍历后面的PropertySource:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/fb/7380c93e743e3fc41d8cc58b77895bfb.png" alt="">
|
||||
|
||||
继续查看ConfigurationPropertySourcesPropertySource(图中红色类)的源码可以发现,getProperty方法其实是通过findConfigurationProperty方法查询配置的。如第25行代码所示,这其实还是在遍历所有的配置源:
|
||||
|
||||
```
|
||||
class ConfigurationPropertySourcesPropertySource extends PropertySource<Iterable<ConfigurationPropertySource>>
|
||||
implements OriginLookup<String> {
|
||||
|
||||
ConfigurationPropertySourcesPropertySource(String name, Iterable<ConfigurationPropertySource> source) {
|
||||
super(name, source);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getProperty(String name) {
|
||||
ConfigurationProperty configurationProperty = findConfigurationProperty(name);
|
||||
return (configurationProperty != null) ? configurationProperty.getValue() : null;
|
||||
}
|
||||
private ConfigurationProperty findConfigurationProperty(String name) {
|
||||
try {
|
||||
return findConfigurationProperty(ConfigurationPropertyName.of(name, true));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
private ConfigurationProperty findConfigurationProperty(ConfigurationPropertyName name) {
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
for (ConfigurationPropertySource configurationPropertySource : getSource()) {
|
||||
ConfigurationProperty configurationProperty = configurationPropertySource.getConfigurationProperty(name);
|
||||
if (configurationProperty != null) {
|
||||
return configurationProperty;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调试可以发现,这个循环遍历(getSource()的结果)的配置源,其实是SpringConfigurationPropertySources(图中黄色类),其中包含的配置源列表就是之前看到的9个配置源,而第一个就是ConfigurationPropertySourcesPropertySource。看到这里,我们的第一感觉是会不会产生死循环,它在遍历的时候怎么排除自己呢?
|
||||
|
||||
同时观察configurationProperty可以看到,这个ConfigurationProperty其实类似代理的角色,实际配置是从系统属性中获得的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/0a/9551d5b5acada84262b7ddeae989750a.png" alt="">
|
||||
|
||||
继续查看SpringConfigurationPropertySources可以发现,它返回的迭代器是内部类SourcesIterator,在fetchNext方法获取下一个项时,通过isIgnored方法排除了ConfigurationPropertySourcesPropertySource(源码第38行):
|
||||
|
||||
```
|
||||
class SpringConfigurationPropertySources implements Iterable<ConfigurationPropertySource> {
|
||||
|
||||
private final Iterable<PropertySource<?>> sources;
|
||||
private final Map<PropertySource<?>, ConfigurationPropertySource> cache = new ConcurrentReferenceHashMap<>(16,
|
||||
ReferenceType.SOFT);
|
||||
|
||||
SpringConfigurationPropertySources(Iterable<PropertySource<?>> sources) {
|
||||
Assert.notNull(sources, "Sources must not be null");
|
||||
this.sources = sources;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<ConfigurationPropertySource> iterator() {
|
||||
return new SourcesIterator(this.sources.iterator(), this::adapt);
|
||||
}
|
||||
|
||||
private static class SourcesIterator implements Iterator<ConfigurationPropertySource> {
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return fetchNext() != null;
|
||||
}
|
||||
|
||||
private ConfigurationPropertySource fetchNext() {
|
||||
if (this.next == null) {
|
||||
if (this.iterators.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (!this.iterators.peek().hasNext()) {
|
||||
this.iterators.pop();
|
||||
return fetchNext();
|
||||
}
|
||||
PropertySource<?> candidate = this.iterators.peek().next();
|
||||
if (candidate.getSource() instanceof ConfigurableEnvironment) {
|
||||
push((ConfigurableEnvironment) candidate.getSource());
|
||||
return fetchNext();
|
||||
}
|
||||
if (isIgnored(candidate)) {
|
||||
return fetchNext();
|
||||
}
|
||||
this.next = this.adapter.apply(candidate);
|
||||
}
|
||||
return this.next;
|
||||
}
|
||||
|
||||
|
||||
private void push(ConfigurableEnvironment environment) {
|
||||
this.iterators.push(environment.getPropertySources().iterator());
|
||||
}
|
||||
|
||||
|
||||
private boolean isIgnored(PropertySource<?> candidate) {
|
||||
return (candidate instanceof StubPropertySource
|
||||
|| candidate instanceof ConfigurationPropertySourcesPropertySource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们已经了解了ConfigurationPropertySourcesPropertySource是所有配置源中的第一个,实现了对PropertySourcesPropertyResolver中遍历逻辑的“劫持”,并且知道了其遍历逻辑。最后一个问题是,它如何让自己成为第一个配置源呢?
|
||||
|
||||
再次运用之前我们学到的那个小技巧,来查看实例化ConfigurationPropertySourcesPropertySource的地方:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/5d/f43c15a2f491d88a0383023a42cebd5d.png" alt="">
|
||||
|
||||
可以看到,ConfigurationPropertySourcesPropertySource类是由ConfigurationPropertySources的attach方法实例化的。查阅源码可以发现,这个方法的确从环境中获得了原始的MutablePropertySources,把自己加入成为一个元素:
|
||||
|
||||
```
|
||||
public final class ConfigurationPropertySources {
|
||||
public static void attach(Environment environment) {
|
||||
MutablePropertySources sources = ((ConfigurableEnvironment) environment).getPropertySources();
|
||||
PropertySource<?> attached = sources.get(ATTACHED_PROPERTY_SOURCE_NAME);
|
||||
if (attached == null) {
|
||||
sources.addFirst(new ConfigurationPropertySourcesPropertySource(ATTACHED_PROPERTY_SOURCE_NAME,
|
||||
new SpringConfigurationPropertySources(sources)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而这个attach方法,是Spring应用程序启动时准备环境的时候调用的。在SpringApplication的run方法中调用了prepareEnvironment方法,然后又调用了ConfigurationPropertySources.attach方法:
|
||||
|
||||
```
|
||||
public class SpringApplication {
|
||||
|
||||
public ConfigurableApplicationContext run(String... args) {
|
||||
...
|
||||
try {
|
||||
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
|
||||
ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
|
||||
...
|
||||
}
|
||||
private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
|
||||
ApplicationArguments applicationArguments) {
|
||||
...
|
||||
ConfigurationPropertySources.attach(environment);
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这里你是否彻底理清楚Spring劫持PropertySourcesPropertyResolver的实现方式,以及配置源有优先级的原因了呢?如果你想知道Spring各种预定义的配置源的优先级,可以参考[官方文档](https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config)。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我用两个业务开发中的实际案例,带你进一步学习了Spring的AOP和配置优先级这两大知识点。现在,你应该也感受到Spring实现的复杂度了。
|
||||
|
||||
对于AOP切Feign的案例,我们在实现功能时走了一些弯路。Spring Cloud会使用Spring Boot的特性,根据当前引入包的情况做各种自动装配。如果我们要扩展Spring的组件,那么只有清晰了解Spring自动装配的运作方式,才能鉴别运行时对象在Spring容器中的情况,不能想当然认为代码中能看到的所有Spring的类都是Bean。
|
||||
|
||||
对于配置优先级的案例,分析配置源优先级时,如果我们以为看到PropertySourcesPropertyResolver就看到了真相,后续进行扩展开发时就可能会踩坑。我们一定要注意,**分析Spring源码时,你看到的表象不一定是实际运行时的情况,还需要借助日志或调试工具来理清整个过程**。如果没有调试工具,你可以借助[第11讲](https://time.geekbang.org/column/article/216830)用到的Arthas,来分析代码调用路径。
|
||||
|
||||
今天用到的代码,我都放在了GitHub上,你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
|
||||
|
||||
## 思考与讨论
|
||||
|
||||
1. 除了我们这两讲用到execution、within、@within、@annotation四个指示器外,Spring AOP还支持this、target、args、@target、@args。你能说说后面五种指示器的作用吗?
|
||||
1. Spring的Environment中的PropertySources属性可以包含多个PropertySource,越往前优先级越高。那,我们能否利用这个特点实现配置文件中属性值的自动赋值呢?比如,我们可以定义%%MYSQL.URL%%、%%MYSQL.USERNAME%%和%%MYSQL.PASSWORD%%,分别代表数据库连接字符串、用户名和密码。在配置数据源时,我们只要设置其值为占位符,框架就可以自动根据当前应用程序名application.name,统一把占位符替换为真实的数据库信息。这样,生产的数据库信息就不需要放在配置文件中了,会更安全。
|
||||
|
||||
关于Spring Core、Spring Boot和Spring Cloud,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
415
极客时间专栏/Java业务开发常见错误100例/代码篇/答疑篇:代码篇思考题集锦(一).md
Normal file
415
极客时间专栏/Java业务开发常见错误100例/代码篇/答疑篇:代码篇思考题集锦(一).md
Normal file
@@ -0,0 +1,415 @@
|
||||
<audio id="audio" title="答疑篇:代码篇思考题集锦(一)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/a7/1affbd7373c56ab8fe904e2e7045a0a7.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
在回复《Java 业务开发常见错误100例》这门课留言的过程中,我看到有些同学特别想看一看咱们这个课程所有思考题的答案。因此呢,我特地将这个课程涉及的思考题进行了梳理,把其中的67个问题的答案或者说解题思路,详细地写了出来,并整理成了一个“答疑篇”模块。
|
||||
|
||||
我把这些问题拆分为了6篇分别更新,你可以根据自己的时间来学习,以保证学习效果。你可以通过这些回答,再来回顾下这些知识点,以求温故而知新;同时,你也可以对照着我的回答,对比下自己的解题思路,看看有没有什么不一样的地方,并留言给我。
|
||||
|
||||
今天是答疑篇的第一讲,我们一起来分析下咱们这门课前6讲的课后思考题。这些题目涉及了并发工具、代码加锁、线程池、连接池、HTTP调用和Spring声明式事务的12道思考题。
|
||||
|
||||
接下来,我们就一一具体分析吧。
|
||||
|
||||
### [01 | 使用了并发工具类库,线程安全就高枕无忧了吗?](https://time.geekbang.org/column/article/209494)
|
||||
|
||||
**问题1:**ThreadLocalRandom是Java 7引入的一个生成随机数的类。你觉得可以把它的实例设置到静态变量中,在多线程情况下重用吗?
|
||||
|
||||
答:不能。
|
||||
|
||||
[ThreadLocalRandom文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadLocalRandom.html)里有这么一条:
|
||||
|
||||
>
|
||||
Usages of this class should typically be of the form: ThreadLocalRandom.current().nextX(…) (where X is Int, Long, etc). When all usages are of this form, it is never possible to accidently share a ThreadLocalRandom across multiple threads.
|
||||
|
||||
|
||||
那为什么规定要ThreadLocalRandom.current().nextX(…)这样来使用呢?我来分析下原因吧。
|
||||
|
||||
current()的时候初始化一个初始化种子到线程,每次nextseed再使用之前的种子生成新的种子:
|
||||
|
||||
```
|
||||
UNSAFE.putLong(t = Thread.currentThread(), SEED, r = UNSAFE.getLong(t, SEED) + GAMMA);
|
||||
|
||||
```
|
||||
|
||||
如果你通过主线程调用一次current生成一个ThreadLocalRandom的实例保存起来,那么其它线程来获取种子的时候必然取不到初始种子,必须是每一个线程自己用的时候初始化一个种子到线程。你可以在nextSeed方法设置一个断点来测试:
|
||||
|
||||
```
|
||||
UNSAFE.getLong(Thread.currentThread(),SEED);
|
||||
|
||||
```
|
||||
|
||||
**问题2:**ConcurrentHashMap还提供了putIfAbsent方法,你能否通过查阅[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html),说说computeIfAbsent和putIfAbsent方法的区别?
|
||||
|
||||
答:computeIfAbsent和putIfAbsent这两个方法,都是判断值不存在的时候为Map进行赋值的原子方法,它们的区别具体包括以下三个方面:
|
||||
|
||||
1. 当Key存在的时候,如果Value的获取比较昂贵的话,putIfAbsent方法就会白白浪费时间在获取这个昂贵的Value上(这个点特别注意),而computeIfAbsent则会因为传入的是Lambda表达式而不是实际值不会有这个问题。
|
||||
1. Key不存在的时候,putIfAbsent会返回null,这时候我们要小心空指针;而computeIfAbsent会返回计算后的值,不存在空指针的问题。
|
||||
1. 当Key不存在的时候,putIfAbsent允许put null进去,而computeIfAbsent不能(当然了,此条针对HashMap,ConcurrentHashMap不允许put null value进去)。
|
||||
|
||||
我写了一段代码来证明这三点,你可以点击[这里](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/concurrenttool/ciavspia/CommonMistakesApplication.java)的GitHub链接查看。
|
||||
|
||||
### [02 | 代码加锁:不要让“锁”事成为烦心事](https://time.geekbang.org/column/article/209520)
|
||||
|
||||
**问题1:**在这一讲开头的例子里,我们为变量a、b都使用了volatile关键字进行修饰,你知道volatile关键字的作用吗?我之前遇到过这样一个坑:我们开启了一个线程无限循环来跑一些任务,有一个bool类型的变量来控制循环的退出,默认为true代表执行,一段时间后主线程将这个变量设置为了false。如果这个变量不是volatile修饰的,子线程可以退出吗?你能否解释其中的原因呢?
|
||||
|
||||
答:不能退出。比如下面的代码,3秒后另一个线程把b设置为false,但是主线程无法退出:
|
||||
|
||||
```
|
||||
private static boolean b = true;
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
new Thread(()->{
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(3);
|
||||
} catch (InterruptedException e) { }
|
||||
b =false;
|
||||
}).start();
|
||||
while (b) {
|
||||
TimeUnit.MILLISECONDS.sleep(0);
|
||||
}
|
||||
System.out.println("done");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其实,这是可见性的问题。
|
||||
|
||||
虽然另一个线程把b设置为了false,但是这个字段在CPU缓存中,另一个线程(主线程)还是读不到最新的值。使用volatile关键字,可以让数据刷新到主内存中去。准确来说,让数据刷新到主内存中去是两件事情:
|
||||
|
||||
1. 将当前处理器缓存行的数据,写回到系统内存;
|
||||
1. 这个写回内存的操作会导致其他CPU里缓存了该内存地址的数据变为无效。
|
||||
|
||||
当然,使用AtomicBoolean等关键字来修改变量b也行。但相比volatile来说,AtomicBoolean等关键字除了确保可见性,还提供了CAS方法,具有更多的功能,在本例的场景中用不到。
|
||||
|
||||
**问题2:**关于代码加锁还有两个坑,一是加锁和释放没有配对的问题,二是分布式锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两个问题吗?
|
||||
|
||||
答:针对加解锁没有配对的问题,我们可以用一些代码质量工具或代码扫描工具(比如Sonar)来帮助排查。这个问题在编码阶段就能发现。
|
||||
|
||||
针对分布式锁超时自动释放问题,可以参考Redisson的RedissonLock的[锁续期机制](https://github.com/redisson/redisson/blob/e11c1e14ba50bc5938184fb96d9b72782e591df7/redisson/src/main/java/org/redisson/RedissonLock.java#L265)。锁续期是每次续一段时间,比如30秒,然后10秒执行一次续期。虽然是无限次续期,但即使客户端崩溃了也没关系,不会无限期占用锁,因为崩溃后无法自动续期自然最终会超时。
|
||||
|
||||
### [03 | 线程池:业务代码最常用也最容易犯错的组件](https://time.geekbang.org/column/article/210337)
|
||||
|
||||
**问题1:**在讲线程池的管理策略时我们提到,或许一个激进创建线程的弹性线程池更符合我们的需求,你能给出相关的实现吗?实现后再测试一下,是否所有的任务都可以正常处理完成呢?
|
||||
|
||||
答:我们按照文中提到的两个思路来实现一下激进线程池:
|
||||
|
||||
1. 由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们可以重写队列的 offer 方法,造成这个队列已满的假象;
|
||||
1. 由于我们 Hack 了队列,在达到了最大线程后势必会触发拒绝策略,那么我们还需要实现一个自定义的拒绝策略处理程序,这个时候再把任务真正插入队列。
|
||||
|
||||
完整的实现代码以及相应的测试代码如下:
|
||||
|
||||
```
|
||||
@GetMapping("better")
|
||||
public int better() throws InterruptedException {
|
||||
//这里开始是激进线程池的实现
|
||||
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>(10) {
|
||||
@Override
|
||||
public boolean offer(Runnable e) {
|
||||
//先返回false,造成队列满的假象,让线程池优先扩容
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
|
||||
2, 5,
|
||||
5, TimeUnit.SECONDS,
|
||||
queue, new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get(), (r, executor) -> {
|
||||
try {
|
||||
//等出现拒绝后再加入队列
|
||||
//如果希望队列满了阻塞线程而不是抛出异常,那么可以注释掉下面三行代码,修改为executor.getQueue().put(r);
|
||||
if (!executor.getQueue().offer(r, 0, TimeUnit.SECONDS)) {
|
||||
throw new RejectedExecutionException("ThreadPool queue full, failed to offer " + r.toString());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
});
|
||||
//激进线程池实现结束
|
||||
|
||||
printStats(threadPool);
|
||||
//每秒提交一个任务,每个任务耗时10秒执行完成,一共提交20个任务
|
||||
|
||||
//任务编号计数器
|
||||
AtomicInteger atomicInteger = new AtomicInteger();
|
||||
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用这个激进的线程池可以处理完这20个任务,因为我们优先开启了更多线程来处理任务。
|
||||
|
||||
```
|
||||
[10:57:16.092] [demo-threadpool-4] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:157 ] - 20 finished
|
||||
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:22 ] - =========================
|
||||
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:23 ] - Pool Size: 5
|
||||
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:24 ] - Active Threads: 0
|
||||
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:25 ] - Number of Tasks Completed: 20
|
||||
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:26 ] - Number of Tasks in Queue: 0
|
||||
[10:57:17.062] [pool-8-thread-1] [INFO ] [o.g.t.c.t.t.ThreadPoolOOMController:28 ] - =========================
|
||||
|
||||
```
|
||||
|
||||
**问题2:**在讲“务必确认清楚线程池本身是不是复用”时,我们改进了ThreadPoolHelper使其能够返回复用的线程池。如果我们不小心每次都创建了这样一个自定义的线程池(10核心线程,50最大线程,2秒回收的),反复执行测试接口线程,最终可以被回收吗?会出现OOM问题吗?
|
||||
|
||||
答:会因为创建过多线程导致OOM,因为默认情况下核心线程不会回收,并且ThreadPoolExecutor也回收不了。
|
||||
|
||||
我们可以看看它的源码,工作线程Worker是内部类,只要它活着,换句话说就是线程在跑,就会阻止ThreadPoolExecutor回收:
|
||||
|
||||
```
|
||||
public class ThreadPoolExecutor extends AbstractExecutorService {
|
||||
private final class Worker
|
||||
extends AbstractQueuedSynchronizer
|
||||
implements Runnable
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因此,我们不能认为ThreadPoolExecutor没有引用,就能回收。
|
||||
|
||||
### [04 | 连接池:别让连接池帮了倒忙](https://time.geekbang.org/column/article/211388)
|
||||
|
||||
**问题1:**有了连接池之后,获取连接是从连接池获取,没有足够连接时连接池会创建连接。这时,获取连接操作往往有两个超时时间:一个是从连接池获取连接的最长等待时间,通常叫作请求连接超时connectRequestTimeout,或连接等待超时connectWaitTimeout;一个是连接池新建TCP连接三次握手的连接超时,通常叫作连接超时connectTimeout。针对JedisPool、Apache HttpClient和Hikari数据库连接池,你知道如何设置这2个参数吗?
|
||||
|
||||
答:假设我们希望设置连接超时5s、请求连接超时10s,下面我来演示下,如何配置Hikari、Jedis和HttpClient的两个超时参数。
|
||||
|
||||
针对Hikari,设置两个超时时间的方式,是修改数据库连接字符串中的connectTimeout属性和配置文件中的hikari配置的connection-timeout:
|
||||
|
||||
```
|
||||
spring.datasource.hikari.connection-timeout=10000
|
||||
|
||||
spring.datasource.url=jdbc:mysql://localhost:6657/common_mistakes?connectTimeout=5000&characterEncoding=UTF-8&useSSL=false&rewriteBatchedStatements=true
|
||||
|
||||
```
|
||||
|
||||
针对Jedis,是设置JedisPoolConfig的MaxWaitMillis属性和设置创建JedisPool时的timeout属性:
|
||||
|
||||
```
|
||||
JedisPoolConfig config = new JedisPoolConfig();
|
||||
config.setMaxWaitMillis(10000);
|
||||
try (JedisPool jedisPool = new JedisPool(config, "127.0.0.1", 6379, 5000);
|
||||
Jedis jedis = jedisPool.getResource()) {
|
||||
return jedis.set("test", "test");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
针对HttpClient,是设置RequestConfig的ConnectionRequestTimeout和ConnectTimeout属性:
|
||||
|
||||
```
|
||||
RequestConfig requestConfig = RequestConfig.custom()
|
||||
.setConnectTimeout(5000)
|
||||
.setConnectionRequestTimeout(10000)
|
||||
.build();
|
||||
HttpGet httpGet = new HttpGet("http://127.0.0.1:45678/twotimeoutconfig/test");
|
||||
httpGet.setConfig(requestConfig);
|
||||
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
|
||||
return EntityUtils.toString(response.getEntity());
|
||||
} catch (Exception ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
|
||||
```
|
||||
|
||||
也可以直接参考我放在[GitHub](https://github.com/JosephZhu1983/java-common-mistakes/tree/master/src/main/java/org/geekbang/time/commonmistakes/connectionpool/twotimeoutconfig)上的源码。
|
||||
|
||||
**问题2:**对于带有连接池的SDK的使用姿势,最主要的是鉴别其内部是否实现了连接池,如果实现了连接池要尽量复用Client。对于NoSQL中的MongoDB来说,使用MongoDB Java驱动时,MongoClient类应该是每次都创建还是复用呢?你能否在[官方文档](https://mongodb.github.io/mongo-java-driver/3.12/)中找到答案呢?
|
||||
|
||||
答:官方文档里有这么一段话:
|
||||
|
||||
>
|
||||
<p>Typically you only create one MongoClient instance for a given MongoDB deployment (e.g. standalone, replica set, or a sharded cluster) and use it across your application. However, if you do create multiple instances:<br>
|
||||
All resource usage limits (e.g. max connections, etc.) apply per MongoClient instance.<br>
|
||||
To dispose of an instance, call MongoClient.close() to clean up resources.</p>
|
||||
|
||||
|
||||
MongoClient类应该尽可能复用(一个MongoDB部署只使用一个MongoClient),不过复用不等于在任何情况下就只用一个。正如文档里所说,每一个MongoClient示例有自己独立的资源限制。
|
||||
|
||||
### [05 | HTTP调用:你考虑到超时、重试、并发了吗?](https://time.geekbang.org/column/article/213273)
|
||||
|
||||
**问题1:**在“配置连接超时和读取超时参数的学问”这一节中,我们强调了要注意连接超时和读取超时参数的配置,大多数的HTTP客户端也都有这两个参数。有读就有写,但为什么我们很少看到“写入超时”的概念呢?
|
||||
|
||||
答:其实写入操作只是将数据写入TCP的发送缓冲区,已经发送到网络的数据依然需要暂存在发送缓冲区中,只有收到对方的ack后,操作系统内核才从缓冲区中清除这一部分数据,为后续发送数据腾出空间。
|
||||
|
||||
如果接收端从socket读取数据的速度太慢,可能会导致发送端发送缓冲区满,导致写入操作阻塞,产生写入超时。但是,因为有滑动窗口的控制,通常不太容易发生发送缓冲区满导致写入超时的情况。相反,读取超时包含了服务端处理数据执行业务逻辑的时间,所以读取超时是比较容易发生的。
|
||||
|
||||
这也就是为什么我们一般都会比较重视读取超时而不是写入超时的原因了。
|
||||
|
||||
**问题2:**除了Ribbon的AutoRetriesNextServer重试机制,Nginx也有类似的重试功能。你了解Nginx相关的配置吗?
|
||||
|
||||
答:关于Nginx的重试功能,你可以参考[这里](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_next_upstream),了解下Nginx的proxy_next_upstream配置。
|
||||
|
||||
proxy_next_upstream,用于指定在什么情况下Nginx会将请求转移到其他服务器上。其默认值是proxy_next_upstream error timeout,即发生网络错误以及超时,才会重试其他服务器。也就是说,默认情况下,服务返回500状态码是不会重试的。
|
||||
|
||||
如果我们想在请求返回500状态码时也进行重试,可以配置:
|
||||
|
||||
```
|
||||
proxy_next_upstream error timeout http_500;
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,proxy_next_upstream配置中有一个选项non_idempotent,一定要小心开启。通常情况下,如果请求使用非等幂方法(POST、PATCH),请求失败后不会再到其他服务器进行重试。但是,加上non_idempotent这个选项后,即使是非幂等请求类型(例如POST请求),发生错误后也会重试。
|
||||
|
||||
### [06 | 20%的业务代码的Spring声明式事务,可能都没处理正确](https://time.geekbang.org/column/article/213295)
|
||||
|
||||
**问题1:**考虑到Demo的简洁,这一讲中所有数据访问使用的都是Spring Data JPA。国内大多数互联网业务项目是使用MyBatis进行数据访问的,使用MyBatis配合Spring的声明式事务也同样需要注意这一讲中提到的这些点。你可以尝试把今天的Demo改为MyBatis做数据访问实现,看看日志中是否可以体现出这些坑?
|
||||
|
||||
答:使用mybatis-spring-boot-starter无需做任何配置,即可使MyBatis整合Spring的声明式事务。在GitHub上的课程[源码](https://github.com/JosephZhu1983/java-common-mistakes/tree/master/src/main/java/org/geekbang/time/commonmistakes/transaction/nested)中,我更新了一个使用MyBatis配套嵌套事务的例子,实现的效果是主方法出现异常,子方法的嵌套事务也会回滚。
|
||||
|
||||
我来和你解释下这个例子中的核心代码:
|
||||
|
||||
```
|
||||
@Transactional
|
||||
public void createUser(String name) {
|
||||
createMainUser(name);
|
||||
try {
|
||||
subUserService.createSubUser(name);
|
||||
} catch (Exception ex) {
|
||||
log.error("create sub user error:{}", ex.getMessage());
|
||||
}
|
||||
//如果createSubUser是NESTED模式,这里抛出异常会导致嵌套事务无法“提交”
|
||||
throw new RuntimeException("create main user error");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
子方法使用了NESTED事务传播模式:
|
||||
|
||||
```
|
||||
@Transactional(propagation = Propagation.NESTED)
|
||||
public void createSubUser(String name) {
|
||||
userDataMapper.insert(name, "sub");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行日志如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/0e/cda8d69f99c0063046a085a39d520c0e.png" alt="">
|
||||
|
||||
每个NESTED事务执行前,会将当前操作保存下来,叫做savepoint(保存点)。NESTED事务在外部事务提交以后自己才会提交,如果当前NESTED事务执行失败,则回滚到之前的保存点。
|
||||
|
||||
**问题2:**在讲“小心 Spring 的事务可能没有生效”时我们提到,如果要针对private方法启用事务,动态代理方式的AOP不可行,需要使用静态织入方式的AOP,也就是在编译期间织入事务增强代码,可以配置Spring框架使用AspectJ来实现AOP。你能否参阅Spring的文档“[Using @Transactional with AspectJ](https://docs.spring.io/spring/docs/current/spring-framework-reference/data-access.html#transaction-declarative-aspectj)”试试呢?注意:AspectJ配合lombok使用,还可能会踩一些坑。
|
||||
|
||||
答:我们需要加入aspectj的依赖和配置aspectj-maven-plugin插件,并且需要设置Spring开启AspectJ事务管理模式。具体的实现方式,包括如下4步。
|
||||
|
||||
第一步,引入spring-aspects依赖:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-aspects</artifactId>
|
||||
</dependency>
|
||||
|
||||
```
|
||||
|
||||
第二步,加入lombok和aspectj插件:
|
||||
|
||||
```
|
||||
<plugin>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok-maven-plugin</artifactId>
|
||||
<version>1.18.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>generate-sources</phase>
|
||||
<goals>
|
||||
<goal>delombok</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<addOutputDirectory>false</addOutputDirectory>
|
||||
<sourceDirectory>src/main/java</sourceDirectory>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>aspectj-maven-plugin</artifactId>
|
||||
<version>1.10</version>
|
||||
<configuration>
|
||||
<complianceLevel>1.8</complianceLevel>
|
||||
<source>1.8</source>
|
||||
<aspectLibraries>
|
||||
<aspectLibrary>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-aspects</artifactId>
|
||||
</aspectLibrary>
|
||||
</aspectLibraries>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
```
|
||||
|
||||
使用delombok插件的目的是,把代码中的Lombok注解先编译为代码,这样AspectJ编译不会有问题,同时需要设置<build>中的sourceDirectory为delombok目录:</build>
|
||||
|
||||
```
|
||||
<sourceDirectory>${project.build.directory}/generated-sources/delombok</sourceDirectory>
|
||||
|
||||
```
|
||||
|
||||
第三步,设置@EnableTransactionManagement注解,开启事务管理走AspectJ模式:
|
||||
|
||||
```
|
||||
@SpringBootApplication
|
||||
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
|
||||
public class CommonMistakesApplication {
|
||||
|
||||
```
|
||||
|
||||
第四步,使用Maven编译项目,编译后查看createUserPrivate方法的源码,可以发现AspectJ帮我们做编译时织入(Compile Time Weaving):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/1d/11da9146b324e47fcd96631d47db961d.png" alt="">
|
||||
|
||||
运行程序,观察日志可以发现createUserPrivate(私有)方法同样应用了事务,出异常后事务回滚:
|
||||
|
||||
```
|
||||
[14:21:39.155] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.transactionproxyfailed.UserService.createUserPrivate]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
[14:21:39.155] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:393 ] - Opened new EntityManager [SessionImpl(1087443072<open>)] for JPA transaction
|
||||
[14:21:39.158] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:421 ] - Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@4e16e6ea]
|
||||
[14:21:39.159] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:356 ] - Found thread-bound EntityManager [SessionImpl(1087443072<open>)] for JPA transaction
|
||||
[14:21:39.159] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:471 ] - Participating in existing transaction
|
||||
[14:21:39.173] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:834 ] - Initiating transaction rollback
|
||||
[14:21:39.173] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1087443072<open>)]
|
||||
[14:21:39.176] [http-nio-45678-exec-2] [DEBUG] [o.s.orm.jpa.JpaTransactionManager:620 ] - Closing JPA EntityManager [SessionImpl(1087443072<open>)] after transaction
|
||||
[14:21:39.176] [http-nio-45678-exec-2] [ERROR] [o.g.t.c.t.t.UserService:28 ] - create user failed because invalid username!
|
||||
[14:21:39.177] [http-nio-45678-exec-2] [DEBUG] [o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler:305 ] - Creating new EntityManager for shared EntityManager invocation
|
||||
|
||||
```
|
||||
|
||||
以上,就是咱们这门课前6讲的思考题答案了。
|
||||
|
||||
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
498
极客时间专栏/Java业务开发常见错误100例/代码篇/答疑篇:代码篇思考题集锦(三).md
Normal file
498
极客时间专栏/Java业务开发常见错误100例/代码篇/答疑篇:代码篇思考题集锦(三).md
Normal file
@@ -0,0 +1,498 @@
|
||||
<audio id="audio" title="答疑篇:代码篇思考题集锦(三)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/92/47d7c5a60208e19a8d718e56c11d7292.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
今天,我们继续一起分析这门课第13~20讲的课后思考题。这些题目涉及了日志、文件IO、序列化、Java 8日期时间类、OOM、Java高级特性(反射、注解和泛型)和Spring框架的16道问题。
|
||||
|
||||
接下来,我们就一一具体分析吧。
|
||||
|
||||
### [13 | 日志:日志记录真没你想象的那么简单](https://time.geekbang.org/column/article/220307)
|
||||
|
||||
**问题1:**在讲“为什么我的日志会重复记录?”的案例时,我们把INFO级别的日志存放到_info.log中,把WARN和ERROR级别的日志存放到_error.log中。如果现在要把INFO和WARN级别的日志存放到_info.log中,把ERROR日志存放到_error.log中,应该如何配置Logback呢?
|
||||
|
||||
答:要实现这个配置有两种方式,分别是:直接使用EvaluatorFilter和自定义一个Filter。我们分别看一下。
|
||||
|
||||
第一种方式是,直接使用logback自带的EvaluatorFilter:
|
||||
|
||||
```
|
||||
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
|
||||
<evaluator class="ch.qos.logback.classic.boolex.GEventEvaluator">
|
||||
<expression>
|
||||
e.level.toInt() == WARN.toInt() || e.level.toInt() == INFO.toInt()
|
||||
</expression>
|
||||
</evaluator>
|
||||
<OnMismatch>DENY</OnMismatch>
|
||||
<OnMatch>NEUTRAL</OnMatch>
|
||||
</filter>
|
||||
|
||||
```
|
||||
|
||||
第二种方式是,自定义一个Filter,实现解析配置中的“|”字符分割的多个Level:
|
||||
|
||||
```
|
||||
public class MultipleLevelsFilter extends Filter<ILoggingEvent> {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private String levels;
|
||||
private List<Integer> levelList;
|
||||
|
||||
@Override
|
||||
public FilterReply decide(ILoggingEvent event) {
|
||||
|
||||
if (levelList == null && !StringUtils.isEmpty(levels)) {
|
||||
//把由|分割的多个Level转换为List<Integer>
|
||||
levelList = Arrays.asList(levels.split("\\|")).stream()
|
||||
.map(item -> Level.valueOf(item))
|
||||
.map(level -> level.toInt())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
//如果levelList包含当前日志的级别,则接收否则拒绝
|
||||
if (levelList.contains(event.getLevel().toInt()))
|
||||
return FilterReply.ACCEPT;
|
||||
else
|
||||
return FilterReply.DENY;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,在配置文件中使用这个MultipleLevelsFilter就可以了(完整的配置代码参考[这里](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/logging/duplicate/multiplelevelsfilter.xml)):
|
||||
|
||||
```
|
||||
<filter class="org.geekbang.time.commonmistakes.logging.duplicate.MultipleLevelsFilter">
|
||||
<levels>INFO|WARN</levels>
|
||||
</filter>
|
||||
|
||||
```
|
||||
|
||||
**问题2:**生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理,以避免单个文件太大,同时保留一定天数的历史日志,你知道如何配置吗?可以在[官方文档](http://logback.qos.ch/manual/appenders.html#RollingFileAppender)找到答案。
|
||||
|
||||
答:参考配置如下,使用SizeAndTimeBasedRollingPolicy来实现按照文件大小和历史文件保留天数,进行文件分割和归档:
|
||||
|
||||
```
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!--日志文件保留天数-->
|
||||
<MaxHistory>30</MaxHistory>
|
||||
<!--日志文件最大的大小-->
|
||||
<MaxFileSize>100MB</MaxFileSize>
|
||||
<!--日志整体最大
|
||||
可选的totalSizeCap属性控制所有归档文件的总大小。当超过总大小上限时,将异步删除最旧的存档。
|
||||
totalSizeCap属性也需要设置maxHistory属性。此外,“最大历史”限制总是首先应用,“总大小上限”限制其次应用。
|
||||
-->
|
||||
<totalSizeCap>10GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
|
||||
```
|
||||
|
||||
### [14 | 文件IO:实现高效正确的文件读写并非易事](https://time.geekbang.org/column/article/223051)
|
||||
|
||||
**问题1:**Files.lines方法进行流式处理,需要使用try-with-resources进行资源释放。那么,使用Files类中其他返回Stream包装对象的方法进行流式处理,比如newDirectoryStream方法返回DirectoryStream<path>,list、walk和find方法返回Stream<path>,也同样有资源释放问题吗?</path></path>
|
||||
|
||||
答:使用Files类中其他返回Stream包装对象的方法进行流式处理,也同样会有资源释放问题。
|
||||
|
||||
因为,这些接口都需要使用try-with-resources模式来释放。正如文中所说,如果不显式释放,那么可能因为底层资源没有及时关闭造成资源泄露。
|
||||
|
||||
**问题2:**Java的File类和Files类提供的文件复制、重命名、删除等操作,是原子性的吗?
|
||||
|
||||
答:Java的File和Files类的文件复制、重命名、删除等操作,都不是原子性的。原因是,文件类操作基本都是调用操作系统本身的API,一般来说这些文件API并不像数据库有事务机制(也很难办到),即使有也很可能有平台差异性。
|
||||
|
||||
比如,File.renameTo方法的文档中提到:
|
||||
|
||||
>
|
||||
Many aspects of the behavior of this method are inherently platform-dependent: The rename operation might not be able to move a file from one filesystem to another, it might not be atomic, and it might not succeed if a file with the destination abstract pathname already exists. The return value should always be checked to make sure that the rename operation was successful.
|
||||
|
||||
|
||||
又比如,Files.copy方法的文档中提到:
|
||||
|
||||
>
|
||||
Copying a file is not an atomic operation. If an IOException is thrown, then it is possible that the target file is incomplete or some of its file attributes have not been copied from the source file. When the REPLACE_EXISTING option is specified and the target file exists, then the target file is replaced. The check for the existence of the file and the creation of the new file may not be atomic with respect to other file system activities.
|
||||
|
||||
|
||||
### [15 | 序列化:一来一回你还是原来的你吗?](https://time.geekbang.org/column/article/223111)
|
||||
|
||||
**问题1:**在讨论Redis序列化方式的时候,我们自定义了RedisTemplate,让Key使用String序列化、让Value使用JSON序列化,从而使Redis获得的Value可以直接转换为需要的对象类型。那么,使用RedisTemplate<String, Long>能否存取Value是Long的数据呢?这其中有什么坑吗?
|
||||
|
||||
答:使用RedisTemplate<String, Long>,不一定能存取Value是Long的数据。在Integer区间内返回的是Integer,超过这个区间返回Long。测试代码如下:
|
||||
|
||||
```
|
||||
@GetMapping("wrong2")
|
||||
public void wrong2() {
|
||||
String key = "testCounter";
|
||||
//测试一下设置在Integer范围内的值
|
||||
countRedisTemplate.opsForValue().set(key, 1L);
|
||||
log.info("{} {}", countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
|
||||
Long l1 = getLongFromRedis(key);
|
||||
//测试一下设置超过Integer范围的值
|
||||
countRedisTemplate.opsForValue().set(key, Integer.MAX_VALUE + 1L);
|
||||
log.info("{} {}", countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
|
||||
//使用getLongFromRedis转换后的值必定是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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
会得到如下输出:
|
||||
|
||||
```
|
||||
1 false
|
||||
2147483648 true
|
||||
1 2147483648
|
||||
|
||||
```
|
||||
|
||||
可以看到,值设置1的时候类型不是Long,设置2147483648的时候是Long。也就是使用RedisTemplate<String, Long>不一定就代表获取的到的Value是Long。
|
||||
|
||||
所以,这边我写了一个getLongFromRedis方法来做转换避免出错,判断当值是Integer的时候转换为Long。
|
||||
|
||||
**问题2:**你可以看一下Jackson2ObjectMapperBuilder类源码的实现(注意configure方法),分析一下其除了关闭FAIL_ON_UNKNOWN_PROPERTIES外,还做了什么吗?
|
||||
|
||||
答:除了关闭FAIL_ON_UNKNOWN_PROPERTIES外,Jackson2ObjectMapperBuilder类源码还主要做了以下两方面的事儿。
|
||||
|
||||
第一,设置Jackson的一些默认值,比如:
|
||||
|
||||
- MapperFeature.DEFAULT_VIEW_INCLUSION设置为禁用;
|
||||
- DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES设置为禁用。
|
||||
|
||||
第二,自动注册classpath中存在的一些jackson模块,比如:
|
||||
|
||||
- jackson-datatype-jdk8,支持JDK8的一些类型,比如Optional;
|
||||
- jackson-datatype-jsr310, 支持JDK8的日期时间一些类型。
|
||||
- jackson-datatype-joda,支持Joda-Time类型。
|
||||
- jackson-module-kotlin,支持Kotlin。
|
||||
|
||||
### [16 | 用好Java 8的日期时间类,少踩一些“老三样”的坑](https://time.geekbang.org/column/article/224240)
|
||||
|
||||
**问题1:**在这一讲中,我多次强调了Date是一个时间戳,是UTC时间、没有时区概念。那,为什么调用其toString方法,会输出类似CST之类的时区字样呢?
|
||||
|
||||
答:关于这个问题,参考toString中的相关源码,你可以看到会获取当前时区(取不到则显示GMT)进行格式化:
|
||||
|
||||
```
|
||||
public String toString() {
|
||||
BaseCalendar.Date date = normalize();
|
||||
...
|
||||
TimeZone zi = date.getZone();
|
||||
if (zi != null) {
|
||||
sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US)); // zzz
|
||||
} else {
|
||||
sb.append("GMT");
|
||||
}
|
||||
sb.append(' ').append(date.getYear()); // yyyy
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private final BaseCalendar.Date normalize() {
|
||||
if (cdate == null) {
|
||||
BaseCalendar cal = getCalendarSystem(fastTime);
|
||||
cdate = (BaseCalendar.Date) cal.getCalendarDate(fastTime,
|
||||
TimeZone.getDefaultRef());
|
||||
return cdate;
|
||||
}
|
||||
// Normalize cdate with the TimeZone in cdate first. This is
|
||||
// required for the compatible behavior.
|
||||
if (!cdate.isNormalized()) {
|
||||
cdate = normalize(cdate);
|
||||
}
|
||||
// If the default TimeZone has changed, then recalculate the
|
||||
// fields with the new TimeZone.
|
||||
TimeZone tz = TimeZone.getDefaultRef();
|
||||
if (tz != cdate.getZone()) {
|
||||
cdate.setZone(tz);
|
||||
CalendarSystem cal = getCalendarSystem(cdate);
|
||||
cal.getCalendarDate(fastTime, cdate);
|
||||
}
|
||||
return cdate;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其实说白了,这里显示的时区仅仅用于呈现,并不代表Date类内置了时区信息。
|
||||
|
||||
**问题2:**日期时间数据始终要保存到数据库中,MySQL中有两种数据类型datetime和timestamp可以用来保存日期时间。你能说说它们的区别吗,它们是否包含时区信息呢?
|
||||
|
||||
答:datetime和timestamp的区别,主要体现在占用空间、表示的时间范围和时区三个方面。
|
||||
|
||||
- 占用空间:datetime占用8字节;timestamp占用4字节。
|
||||
- 表示的时间范围:datetime表示的范围是从“1000-01-01 00:00:00.000000”到“9999-12-31 23:59:59.999999”;timestamp表示的范围是从“1970-01-01 00:00:01.000000”到“2038-01-19 03:14:07.999999”。
|
||||
- 时区:timestamp保存的时候根据当前时区转换为UTC,查询的时候再根据当前时区从UTC转回来;而datetime就是一个死的字符串时间(仅仅对MySQL本身而言)表示。
|
||||
|
||||
需要注意的是,我们说datetime不包含时区是固定的时间表示,仅仅是指MySQL本身。使用timestamp,需要考虑Java进程的时区和MySQL连接的时区。而使用datetime类型,则只需要考虑Java进程的时区(因为MySQL datetime没有时区信息了,JDBC时间戳转换成MySQL datetime,会根据MySQL的serverTimezone做一次转换)。
|
||||
|
||||
如果你的项目有国际化需求,我推荐使用时间戳,并且要确保你的应用服务器和数据库服务器设置了正确的匹配当地时区的时区配置。
|
||||
|
||||
其实,即便你的项目没有国际化需求,至少是应用服务器和数据库服务器设置一致的时区,也是需要的。
|
||||
|
||||
### [17 | 别以为“自动挡”就不可能出现OOM](https://time.geekbang.org/column/article/224784)
|
||||
|
||||
**问题1:**Spring的ConcurrentReferenceHashMap,针对Key和Value支持软引用和弱引用两种方式。你觉得哪种方式更适合做缓存呢?
|
||||
|
||||
答:软引用和弱引用的区别在于:若一个对象是弱引用可达,无论当前内存是否充足它都会被回收,而软引用可达的对象在内存不充足时才会被回收。因此,软引用要比弱引用“强”一些。
|
||||
|
||||
那么,使用弱引用作为缓存就会让缓存的生命周期过短,所以软引用更适合作为缓存。
|
||||
|
||||
**问题2:**当我们需要动态执行一些表达式时,可以使用Groovy动态语言实现:new出一个GroovyShell类,然后调用evaluate方法动态执行脚本。这种方式的问题是,会重复产生大量的类,增加Metaspace区的GC负担,有可能会引起OOM。你知道如何避免这个问题吗?
|
||||
|
||||
答:调用evaluate方法动态执行脚本会产生大量的类,要避免可能因此导致的OOM问题,我们可以把脚本包装为一个函数,先调用parse函数来得到Script对象,然后缓存起来,以后直接使用invokeMethod方法调用这个函数即可:
|
||||
|
||||
```
|
||||
private Object rightGroovy(String script, String method, Object... args) {
|
||||
Script scriptObject;
|
||||
|
||||
if (SCRIPT_CACHE.containsKey(script)) {
|
||||
//如果脚本已经生成过Script则直接使用
|
||||
scriptObject = SCRIPT_CACHE.get(script);
|
||||
} else {
|
||||
//否则把脚本解析为Script
|
||||
scriptObject = shell.parse(script);
|
||||
SCRIPT_CACHE.put(script, scriptObject);
|
||||
}
|
||||
return scriptObject.invokeMethod(method, args);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我在源码中提供了一个[测试程序](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/oom/groovyoom/GroovyOOMController.java),你可以直接去看一下。
|
||||
|
||||
### [18 | 当反射、注解和泛型遇到OOP时,会有哪些坑?](https://time.geekbang.org/column/article/225596)
|
||||
|
||||
**问题1:**泛型类型擦除后会生成一个bridge方法,这个方法同时又是synthetic方法。除了泛型类型擦除,你知道还有什么情况编译器会生成synthetic方法吗?
|
||||
|
||||
答:Synthetic方法是编译器自动生成的方法(在源码中不出现)。除了文中提到的泛型类型擦除外,Synthetic方法还可能出现的一个比较常见的场景,是内部类和顶层类需要相互访问对方的private字段或方法的时候。
|
||||
|
||||
编译后的内部类和普通类没有区别,遵循private字段或方法对外部类不可见的原则,但语法上内部类和顶层类的私有字段需要可以相互访问。为了解决这个矛盾,编译器就只能生成桥接方法,也就是Synthetic方法,来把private成员转换为package级别的访问限制。
|
||||
|
||||
比如如下代码,InnerClassApplication类的test方法需要访问内部类MyInnerClass类的私有字段name,而内部类MyInnerClass类的test方法需要访问外部类InnerClassApplication类的私有字段gender。
|
||||
|
||||
```
|
||||
public class InnerClassApplication {
|
||||
|
||||
private String gender = "male";
|
||||
public static void main(String[] args) throws Exception {
|
||||
InnerClassApplication application = new InnerClassApplication();
|
||||
application.test();
|
||||
}
|
||||
|
||||
private void test(){
|
||||
MyInnerClass myInnerClass = new MyInnerClass();
|
||||
System.out.println(myInnerClass.name);
|
||||
myInnerClass.test();
|
||||
}
|
||||
|
||||
class MyInnerClass {
|
||||
private String name = "zhuye";
|
||||
void test(){
|
||||
System.out.println(gender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
编译器会为InnerClassApplication和MyInnerClass都生成桥接方法。
|
||||
|
||||
如下图所示,InnerClassApplication的test方法,其实调用的是内部类的access$000静态方法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/66/93a0fd1feb705be9fd63c3b963943c66.png" alt="">
|
||||
|
||||
这个access$000方法是Synthetic方法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/f0/2aa967cfbd7832d0893605c4249363f0.png" alt="">
|
||||
|
||||
而Synthetic方法的实现转接调用了内部类的name字段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/3d/064809b7fba7dc34f5c955a1a7dbf33d.png" alt="">
|
||||
|
||||
反过来,内部类的test方法也是通过外部类InnerClassApplication类的桥接方法access$100调用到其私有字段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/eb/9e/ebefeeda2de626ca8cbdf5388763669e.png" alt="">
|
||||
|
||||
**问题2:**关于注解继承问题,你觉得Spring的常用注解@Service、@Controller是否支持继承呢?
|
||||
|
||||
答:Spring的常用注解@Service、@Controller,不支持继承。这些注解只支持放到具体的(非接口非抽象)顶层类上(来让它们成为Bean),如果支持继承会非常不灵活而且容易出错。
|
||||
|
||||
### [19 | Spring框架:IoC和AOP是扩展的核心](https://time.geekbang.org/column/article/227917)
|
||||
|
||||
**问题1:**除了通过@Autowired注入Bean外,还可以使用@Inject或@Resource来注入Bean。你知道这三种方式的区别是什么吗?
|
||||
|
||||
答:我们先说一下使用@Autowired、@Inject和@Resource这三种注解注入Bean的方式:
|
||||
|
||||
- @Autowired,是Spring的注解,优先按照类型注入。当无法确定具体注入类型的时候,可以通过@Qualifier注解指定Bean名称。
|
||||
- @Inject:是JSR330规范的实现,也是根据类型进行自动装配的,这一点和@Autowired类似。如果需要按名称进行装配,则需要配合使用@Named。@Autowired和@Inject的区别在于,前者可以使用required=false允许注入null,后者允许注入一个Provider实现延迟注入。
|
||||
- @Resource:JSR250规范的实现,如果不指定name优先根据名称进行匹配(然后才是类型),如果指定name则仅根据名称匹配。
|
||||
|
||||
**问题2:**当Bean产生循环依赖时,比如BeanA的构造方法依赖BeanB作为成员需要注入,BeanB也依赖BeanA,你觉得会出现什么问题呢?又有哪些解决方式呢?
|
||||
|
||||
答:Bean产生循环依赖,主要包括两种情况:一种是注入属性或字段涉及循环依赖,另一种是构造方法注入涉及循环依赖。接下来,我分别和你讲一讲。
|
||||
|
||||
第一种,注入属性或字段涉及循环依赖,比如TestA和TestB相互依赖:
|
||||
|
||||
```
|
||||
@Component
|
||||
public class TestA {
|
||||
@Autowired
|
||||
@Getter
|
||||
private TestB testB;
|
||||
}
|
||||
|
||||
@Component
|
||||
public class TestB {
|
||||
@Autowired
|
||||
@Getter
|
||||
private TestA testA;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
针对这个问题,Spring内部通过三个Map的方式解决了这个问题,不会出错。基本原理是,因为循环依赖,所以实例的初始化无法一次到位,需要分步进行:
|
||||
|
||||
1. 创建A(仅仅实例化,不注入依赖);
|
||||
1. 创建B(仅仅实例化,不注入依赖);
|
||||
1. 为B注入A(此时B已健全);
|
||||
1. 为A注入B(此时A也健全)。
|
||||
|
||||
网上有很多相关的分析,我找了[一篇比较详细的](https://cloud.tencent.com/developer/article/1497692),可供你参考。
|
||||
|
||||
第二种,构造方法注入涉及循环依赖。遇到这种情况的话,程序无法启动,比如TestC和TestD的相互依赖:
|
||||
|
||||
```
|
||||
@Component
|
||||
public class TestC {
|
||||
@Getter
|
||||
private TestD testD;
|
||||
|
||||
@Autowired
|
||||
public TestC(TestD testD) {
|
||||
this.testD = testD;
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
public class TestD {
|
||||
@Getter
|
||||
private TestC testC;
|
||||
|
||||
@Autowired
|
||||
public TestD(TestC testC) {
|
||||
this.testC = testC;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种循环依赖的主要解决方式,有2种:
|
||||
|
||||
- 改为属性或字段注入;
|
||||
- 使用@Lazy延迟注入。比如如下代码:
|
||||
|
||||
```
|
||||
@Component
|
||||
public class TestC {
|
||||
@Getter
|
||||
private TestD testD;
|
||||
|
||||
@Autowired
|
||||
public TestC(@Lazy TestD testD) {
|
||||
this.testD = testD;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其实,这种@Lazy方式注入的就不是实际的类型了,而是代理类,获取的时候通过代理去拿值(实例化)。所以,它可以解决循环依赖无法实例化的问题。
|
||||
|
||||
### [20 | Spring框架:框架帮我们做了很多工作也带来了复杂度](https://time.geekbang.org/column/article/227918)
|
||||
|
||||
**问题1:**除了Spring框架这两讲涉及的execution、within、@within、@annotation 四个指示器外,Spring AOP 还支持 this、target、args、@target、@args。你能说说后面五种指示器的作用吗?
|
||||
|
||||
答:关于这些指示器的作用,你可以参考[官方文档](https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop-pointcuts-designators),文档里已经写的很清晰。
|
||||
|
||||
总结一下,按照使用场景,建议使用下面这些指示器:
|
||||
|
||||
- 针对方法签名,使用execution;
|
||||
- 针对类型匹配,使用within(匹配类型)、this(匹配代理类实例)、target(匹配代理背后的目标类实例)、args(匹配参数);
|
||||
- 针对注解匹配,使用@annotation(使用指定注解标注的方法)、@target(使用指定注解标注的类)、@args(使用指定注解标注的类作为某个方法的参数)。
|
||||
|
||||
你可能会问,@within怎么没有呢?
|
||||
|
||||
其实,对于Spring默认的基于动态代理或CGLIB的AOP,因为切点只能是方法,使用@within和@target指示器并无区别;但需要注意如果切换到AspectJ,那么使用@within和@target这两个指示器的行为就会有所区别了,@within会切入更多的成员的访问(比如静态构造方法、字段访问),一般而言使用@target指示器即可。
|
||||
|
||||
**问题2:**Spring 的 Environment 中的 PropertySources 属性可以包含多个 PropertySource,越往前优先级越高。那,我们能否利用这个特点实现配置文件中属性值的自动赋值呢?比如,我们可以定义 %%MYSQL.URL%%、%%MYSQL.USERNAME%% 和 %%MYSQL.PASSWORD%%,分别代表数据库连接字符串、用户名和密码。在配置数据源时,我们只要设置其值为占位符,框架就可以自动根据当前应用程序名 application.name,统一把占位符替换为真实的数据库信息。这样,生产的数据库信息就不需要放在配置文件中了,会更安全。
|
||||
|
||||
答:我们利用PropertySource具有优先级的特点,实现配置文件中属性值的自动赋值。主要逻辑是,遍历现在的属性值,找出能匹配到占位符的属性,并把这些属性的值替换为实际的数据库信息,然后再把这些替换后的属性值构成新的PropertiesPropertySource,加入PropertySources的第一个。这样,我们这个PropertiesPropertySource中的值就可以生效了。
|
||||
|
||||
主要源码如下:
|
||||
|
||||
```
|
||||
public static void main(String[] args) {
|
||||
Utils.loadPropertySource(CommonMistakesApplication.class, "db.properties");
|
||||
new SpringApplicationBuilder()
|
||||
.sources(CommonMistakesApplication.class)
|
||||
.initializers(context -> initDbUrl(context.getEnvironment()))
|
||||
.run(args);
|
||||
}
|
||||
private static final String MYSQL_URL_PLACEHOLDER = "%%MYSQL.URL%%";
|
||||
private static final String MYSQL_USERNAME_PLACEHOLDER = "%%MYSQL.USERNAME%%";
|
||||
private static final String MYSQL_PASSWORD_PLACEHOLDER = "%%MYSQL.PASSWORD%%";
|
||||
private static void initDbUrl(ConfigurableEnvironment env) {
|
||||
|
||||
String dataSourceUrl = env.getProperty("spring.datasource.url");
|
||||
String username = env.getProperty("spring.datasource.username");
|
||||
String password = env.getProperty("spring.datasource.password");
|
||||
|
||||
if (dataSourceUrl != null && !dataSourceUrl.contains(MYSQL_URL_PLACEHOLDER))
|
||||
throw new IllegalArgumentException("请使用占位符" + MYSQL_URL_PLACEHOLDER + "来替换数据库URL配置!");
|
||||
if (username != null && !username.contains(MYSQL_USERNAME_PLACEHOLDER))
|
||||
throw new IllegalArgumentException("请使用占位符" + MYSQL_USERNAME_PLACEHOLDER + "来替换数据库账号配置!");
|
||||
if (password != null && !password.contains(MYSQL_PASSWORD_PLACEHOLDER))
|
||||
throw new IllegalArgumentException("请使用占位符" + MYSQL_PASSWORD_PLACEHOLDER + "来替换数据库密码配置!");
|
||||
|
||||
//这里我把值写死了,实际应用中可以从外部服务来获取
|
||||
Map<String, String> property = new HashMap<>();
|
||||
property.put(MYSQL_URL_PLACEHOLDER, "jdbc:mysql://localhost:6657/common_mistakes?characterEncoding=UTF-8&useSSL=false");
|
||||
property.put(MYSQL_USERNAME_PLACEHOLDER, "root");
|
||||
property.put(MYSQL_PASSWORD_PLACEHOLDER, "kIo9u7Oi0eg");
|
||||
//保存修改后的配置属性
|
||||
Properties modifiedProps = new Properties();
|
||||
//遍历现在的属性值,找出能匹配到占位符的属性,并把这些属性的值替换为实际的数据库信息
|
||||
StreamSupport.stream(env.getPropertySources().spliterator(), false)
|
||||
.filter(ps -> ps instanceof EnumerablePropertySource)
|
||||
.map(ps -> ((EnumerablePropertySource) ps).getPropertyNames())
|
||||
.flatMap(Arrays::stream)
|
||||
.forEach(propKey -> {
|
||||
String propValue = env.getProperty(propKey);
|
||||
property.entrySet().forEach(item -> {
|
||||
//如果原先配置的属性值包含我们定义的占位符
|
||||
if (propValue.contains(item.getKey())) {
|
||||
//那么就把实际的配置信息加入modifiedProps
|
||||
modifiedProps.put(propKey, propValue.replaceAll(item.getKey(), item.getValue()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!modifiedProps.isEmpty()) {
|
||||
log.info("modifiedProps: {}", modifiedProps);
|
||||
env.getPropertySources().addFirst(new PropertiesPropertySource("mysql", modifiedProps));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我在GitHub上第20讲对应的源码中更新了我的实现,你可以点击[这里](https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/springpart2/custompropertysource/CommonMistakesApplication.java)查看。有一些同学会问,这么做的意义到底在于什么,为何不直接使用类似Apollo这样的配置框架呢?
|
||||
|
||||
其实,我们的目的就是不希望让开发人员手动配置数据库信息,希望程序启动的时候自动替换占位符实现自动配置(从CMDB直接拿着应用程序ID来换取对应的数据库信息。你可能会问了,一个应用程序ID对应多个数据库怎么办?其实,一般对于微服务系统来说,一个应用就应该对应一个数据库)。这样一来,除了程序其他人都不会接触到生产的数据库信息,会更安全。
|
||||
|
||||
以上,就是咱们这门课的第13~20讲的思考题答案了。
|
||||
|
||||
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
316
极客时间专栏/Java业务开发常见错误100例/代码篇/答疑篇:代码篇思考题集锦(二).md
Normal file
316
极客时间专栏/Java业务开发常见错误100例/代码篇/答疑篇:代码篇思考题集锦(二).md
Normal file
@@ -0,0 +1,316 @@
|
||||
<audio id="audio" title="答疑篇:代码篇思考题集锦(二)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/20/f8/20ff92d160e92fb43de58a4d197d00f8.mp3"></audio>
|
||||
|
||||
你好,我是朱晔。
|
||||
|
||||
今天,我们继续一起分析这门课第7~12讲的课后思考题。这些题目涉及了数据库索引、判等问题、数值计算、集合类、空值处理和异常处理的12道问题。
|
||||
|
||||
接下来,我们就一一具体分析吧。
|
||||
|
||||
### [07 | 数据库索引:索引并不是万能药](https://time.geekbang.org/column/article/213342)
|
||||
|
||||
**问题1:**在介绍二级索引代价时,我们通过EXPLAIN命令看到了索引覆盖和回表的两种情况。你能用optimizer trace来分析一下这两种情况的成本差异吗?
|
||||
|
||||
答:如下代码所示,打开optimizer_trace后,再执行SQL就可以查询information_schema.OPTIMIZER_TRACE表查看执行计划了,最后可以关闭optimizer_trace功能:
|
||||
|
||||
```
|
||||
SET optimizer_trace="enabled=on";
|
||||
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';
|
||||
SELECT * FROM information_schema.OPTIMIZER_TRACE;
|
||||
SET optimizer_trace="enabled=off";
|
||||
|
||||
```
|
||||
|
||||
假设我们为表person的NAME和SCORE列建了联合索引,那么下面第二条语句应该可以走索引覆盖,而第一条语句需要回表:
|
||||
|
||||
```
|
||||
explain select * from person where NAME='name1';
|
||||
explain select NAME,SCORE from person where NAME='name1';
|
||||
|
||||
```
|
||||
|
||||
通过观察OPTIMIZER_TRACE的输出可以看到,索引覆盖(index_only=true)的成本是1.21而回表查询(index_only=false)的是2.21,也就是索引覆盖节省了回表的成本1。
|
||||
|
||||
索引覆盖:
|
||||
|
||||
```
|
||||
analyzing_range_alternatives": {
|
||||
"range_scan_alternatives": [
|
||||
{
|
||||
"index": "name_score",
|
||||
"ranges": [
|
||||
"name1 <= name <= name1"
|
||||
] /* ranges */,
|
||||
"index_dives_for_eq_ranges": true,
|
||||
"rowid_ordered": false,
|
||||
"using_mrr": false,
|
||||
"index_only": true,
|
||||
"rows": 1,
|
||||
"cost": 1.21,
|
||||
"chosen": true
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
回表:
|
||||
|
||||
```
|
||||
"range_scan_alternatives": [
|
||||
{
|
||||
"index": "name_score",
|
||||
"ranges": [
|
||||
"name1 <= name <= name1"
|
||||
] /* ranges */,
|
||||
"index_dives_for_eq_ranges": true,
|
||||
"rowid_ordered": false,
|
||||
"using_mrr": false,
|
||||
"index_only": false,
|
||||
"rows": 1,
|
||||
"cost": 2.21,
|
||||
"chosen": true
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
**问题2:**索引除了可以用于加速搜索外,还可以在排序时发挥作用,你能通过EXPLAIN来证明吗?你知道,针对排序在什么情况下,索引会失效吗?
|
||||
|
||||
答:排序使用到索引,在执行计划中的体现就是key这一列。如果没有用到索引,会在Extra中看到Using filesort,代表使用了内存或磁盘进行排序。而具体走内存还是磁盘,是由sort_buffer_size和排序数据大小决定的。
|
||||
|
||||
排序无法使用到索引的情况有:
|
||||
|
||||
- 对于使用联合索引进行排序的场景,多个字段排序ASC和DESC混用;
|
||||
- a+b作为联合索引,按照a范围查询后按照b排序;
|
||||
- 排序列涉及到的多个字段不属于同一个联合索引;
|
||||
- 排序列使用了表达式。
|
||||
|
||||
其实,这些原因都和索引的结构有关。你可以再有针对性地复习下[第07讲](https://time.geekbang.org/column/article/213342)的聚簇索引和二级索引部分。
|
||||
|
||||
### [08 | 判等问题:程序里如何确定你就是你?](https://time.geekbang.org/column/article/213604)
|
||||
|
||||
**问题1:**在实现equals时,我是先通过getClass方法判断两个对象的类型,你可能会想到还可以使用instanceof来判断。你能说说这两种实现方式的区别吗?
|
||||
|
||||
答:事实上,使用getClass和instanceof这两种方案都是可以判断对象类型的。它们的区别就是,getClass限制了这两个对象只能属于同一个类,而instanceof却允许两个对象是同一个类或其子类。
|
||||
|
||||
正是因为这种区别,不同的人对这两种方案有不同的喜好,争论也很多。在我看来,你只需要根据自己的要求去选择。补充说明一下,Lombok使用的是instanceof的方案。
|
||||
|
||||
**问题2:**在“hashCode 和 equals 要配对实现”这一节的例子中,我演示了可以通过HashSet的contains方法判断元素是否在HashSet中。那同样是Set的TreeSet,其contains方法和HashSet的contains方法有什么区别吗?
|
||||
|
||||
答:HashSet基于HashMap,数据结构是哈希表。所以,HashSet的contains方法,其实就是根据hashcode和equals去判断相等的。
|
||||
|
||||
TreeSet基于TreeMap,数据结构是红黑树。所以,TreeSet的contains方法,其实就是根据compareTo去判断相等的。
|
||||
|
||||
### [09 | 数值计算:注意精度、舍入和溢出问题](https://time.geekbang.org/column/article/213796)
|
||||
|
||||
**问题1:**[BigDecimal](https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html)提供了8种舍入模式,你能通过一些例子说说它们的区别吗?
|
||||
|
||||
答:@Darren同学的留言非常全面,梳理得也非常清楚了。这里,我对他的留言稍加修改,就是这个问题的答案了。
|
||||
|
||||
第一种,ROUND_UP,舍入远离零的舍入模式,在丢弃非零部分之前始终增加数字(始终对非零舍弃部分前面的数字加1)。 需要注意的是,此舍入模式始终不会减少原始值。
|
||||
|
||||
第二种,ROUND_DOWN,接近零的舍入模式,在丢弃某部分之前始终不增加数字(从不对舍弃部分前面的数字加1,即截断)。 需要注意的是,此舍入模式始终不会增加原始值。
|
||||
|
||||
第三种,ROUND_CEILING,接近正无穷大的舍入模式。 如果 BigDecimal 为正,则舍入行为与 ROUND_UP 相同; 如果为负,则舍入行为与 ROUND_DOWN 相同。 需要注意的是,此舍入模式始终不会减少原始值。
|
||||
|
||||
第四种,ROUND_FLOOR,接近负无穷大的舍入模式。 如果 BigDecimal 为正,则舍入行为与 ROUND_DOWN 相同; 如果为负,则舍入行为与 ROUND_UP 相同。 需要注意的是,此舍入模式始终不会增加原始值。
|
||||
|
||||
第五种,ROUND_HALF_UP,向“最接近的”数字舍入。如果舍弃部分 >= 0.5,则舍入行为与 ROUND_UP 相同;否则,舍入行为与 ROUND_DOWN 相同。 需要注意的是,这是我们大多数人在小学时就学过的舍入模式(四舍五入)。
|
||||
|
||||
第六种,ROUND_HALF_DOWN,向“最接近的”数字舍入。如果舍弃部分 > 0.5,则舍入行为与 ROUND_UP 相同;否则,舍入行为与 ROUND_DOWN 相同(五舍六入)。
|
||||
|
||||
第七种,ROUND_HALF_EVEN,向“最接近的”数字舍入。这种算法叫做银行家算法,具体规则是,四舍六入,五则看前一位,如果是偶数舍入,如果是奇数进位,比如5.5 -> 6,2.5 -> 2。
|
||||
|
||||
第八种,ROUND_UNNECESSARY,假设请求的操作具有精确的结果,也就是不需要进行舍入。如果计算结果产生不精确的结果,则抛出ArithmeticException。
|
||||
|
||||
**问题2:**数据库(比如MySQL)中的浮点数和整型数字,你知道应该怎样定义吗?又如何实现浮点数的准确计算呢?
|
||||
|
||||
答:MySQL中的整数根据能表示的范围有TINYINT、SMALLINT、MEDIUMINT、INTEGER、BIGINT等类型,浮点数包括单精度浮点数FLOAT和双精度浮点数DOUBLE和Java中的float/double一样,同样有精度问题。
|
||||
|
||||
要解决精度问题,主要有两个办法:
|
||||
|
||||
- 第一,使用DECIMAL类型(和那些INT类型一样,都属于严格数值数据类型),比如DECIMAL(13, 2)或DECIMAL(13, 4)。
|
||||
- 第二,使用整数保存到分,这种方式容易出错,万一读的时候忘记/100或者是存的时候忘记*100,可能会引起重大问题。当然了,我们也可以考虑将整数和小数分开保存到两个整数字段。
|
||||
|
||||
### [10 | 集合类:坑满地的List列表操作](https://time.geekbang.org/column/article/216778)
|
||||
|
||||
**问题1:**调用类型是Integer的ArrayList的remove方法删除元素,传入一个Integer包装类的数字和传入一个int基本类型的数字,结果一样吗?
|
||||
|
||||
答:传int基本类型的remove方法是按索引值移除,返回移除的值;传Integer包装类的remove方法是按值移除,返回列表移除项目之前是否包含这个值(是否移除成功)。
|
||||
|
||||
为了验证两个remove方法重载的区别,我们写一段测试代码比较一下:
|
||||
|
||||
```
|
||||
private static void removeByIndex(int index) {
|
||||
List<Integer> list =
|
||||
IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toCollection(ArrayList::new));
|
||||
System.out.println(list.remove(index));
|
||||
System.out.println(list);
|
||||
}
|
||||
private static void removeByValue(Integer index) {
|
||||
List<Integer> list =
|
||||
IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toCollection(ArrayList::new));
|
||||
System.out.println(list.remove(index));
|
||||
System.out.println(list);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试一下removeByIndex(4),通过输出可以看到第五项被移除了,返回5:
|
||||
|
||||
```
|
||||
5
|
||||
[1, 2, 3, 4, 6, 7, 8, 9, 10]
|
||||
|
||||
```
|
||||
|
||||
而调用removeByValue(Integer.valueOf(4)),通过输出可以看到值4被移除了,返回true:
|
||||
|
||||
```
|
||||
true
|
||||
[1, 2, 3, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
```
|
||||
|
||||
**问题2:**循环遍历List,调用remove方法删除元素,往往会遇到ConcurrentModificationException,原因是什么,修复方式又是什么呢?
|
||||
|
||||
答:原因是,remove的时候会改变modCount,通过迭代器遍历就会触发ConcurrentModificationException。我们看下ArrayList类内部迭代器的相关源码:
|
||||
|
||||
```
|
||||
public E next() {
|
||||
checkForComodification();
|
||||
int i = cursor;
|
||||
if (i >= size)
|
||||
throw new NoSuchElementException();
|
||||
Object[] elementData = ArrayList.this.elementData;
|
||||
if (i >= elementData.length)
|
||||
throw new ConcurrentModificationException();
|
||||
cursor = i + 1;
|
||||
return (E) elementData[lastRet = i];
|
||||
}
|
||||
|
||||
final void checkForComodification() {
|
||||
if (modCount != expectedModCount)
|
||||
throw new ConcurrentModificationException();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要修复这个问题,有以下两种解决方案。
|
||||
|
||||
第一种,通过ArrayList的迭代器remove。迭代器的remove方法会维护一个expectedModCount,使其与 ArrayList 的modCount保持一致:
|
||||
|
||||
```
|
||||
List<String> list =
|
||||
IntStream.rangeClosed(1, 10).mapToObj(String::valueOf).collect(Collectors.toCollection(ArrayList::new));
|
||||
for (Iterator<String> iterator = list.iterator(); iterator.hasNext(); ) {
|
||||
String next = iterator.next();
|
||||
if ("2".equals(next)) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
System.out.println(list);
|
||||
|
||||
```
|
||||
|
||||
第二种,直接使用removeIf方法,其内部使用了迭代器的remove方法:
|
||||
|
||||
```
|
||||
List<String> list =
|
||||
IntStream.rangeClosed(1, 10).mapToObj(String::valueOf).collect(Collectors.toCollection(ArrayList::new));
|
||||
list.removeIf(item -> item.equals("2"));
|
||||
System.out.println(list);
|
||||
|
||||
```
|
||||
|
||||
### [11 | 空值处理:分不清楚的null和恼人的空指针](https://time.geekbang.org/column/article/216830)
|
||||
|
||||
**问题1:**ConcurrentHashMap的Key和Value都不能为null,而HashMap却可以,你知道这么设计的原因是什么吗?TreeMap、Hashtable等Map的Key和Value是否支持null呢?
|
||||
|
||||
答:原因正如ConcurrentHashMap的作者所说:
|
||||
|
||||
>
|
||||
The main reason that nulls aren’t allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can’t be accommodated. The main one is that if map.get(key) returns null, you can’t detect whether the key explicitly maps to null vs the key isn’t mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.
|
||||
|
||||
|
||||
如果Value为null会增加二义性,也就是说多线程情况下map.get(key)返回null,我们无法区分Value原本就是null还是Key没有映射,Key也是类似的原因。此外,我也更同意他的观点,就是普通的Map允许null是否是一个正确的做法,也值得商榷,因为这会增加犯错的可能性。
|
||||
|
||||
Hashtable也是线程安全的,所以Key和Value不可以是null。
|
||||
|
||||
TreeMap是线程不安全的,但是因为需要排序,需要进行key的compareTo方法,所以Key不能是null,而Value可以是null。
|
||||
|
||||
**问题2:**对于Hibernate框架,我们可以使用@DynamicUpdate注解实现字段的动态更新。那么,对于MyBatis框架来说,要如何实现类似的动态SQL功能,实现插入和修改SQL只包含POJO中的非空字段呢?
|
||||
|
||||
答:MyBatis可以通过动态SQL实现:
|
||||
|
||||
```
|
||||
<select id="findUser" resultType="User">
|
||||
SELECT * FROM USER
|
||||
WHERE 1=1
|
||||
<if test="name != null">
|
||||
AND name like #{name}
|
||||
</if>
|
||||
<if test="email != null">
|
||||
AND email = #{email}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
```
|
||||
|
||||
如果使用MyBatisPlus的话,实现类似的动态SQL功能会更方便。我们可以直接在字段上加@TableField注解来实现,可以设置insertStrategy、updateStrategy、whereStrategy属性。关于这三个属性的使用方式,你可以参考如下源码,或是[这里](https://mp.baomidou.com/guide/annotation.html#tablefield)的官方文档:
|
||||
|
||||
```
|
||||
/**
|
||||
* 字段验证策略之 insert: 当insert操作时,该字段拼接insert语句时的策略
|
||||
* IGNORED: 直接拼接 insert into table_a(column) values (#{columnProperty});
|
||||
* NOT_NULL: insert into table_a(<if test="columnProperty != null">column</if>) values (<if test="columnProperty != null">#{columnProperty}</if>)
|
||||
* NOT_EMPTY: insert into table_a(<if test="columnProperty != null and columnProperty!=''">column</if>) values (<if test="columnProperty != null and columnProperty!=''">#{columnProperty}</if>)
|
||||
*
|
||||
* @since 3.1.2
|
||||
*/
|
||||
FieldStrategy insertStrategy() default FieldStrategy.DEFAULT;
|
||||
|
||||
|
||||
/**
|
||||
* 字段验证策略之 update: 当更新操作时,该字段拼接set语句时的策略
|
||||
* IGNORED: 直接拼接 update table_a set column=#{columnProperty}, 属性为null/空string都会被set进去
|
||||
* NOT_NULL: update table_a set <if test="columnProperty != null">column=#{columnProperty}</if>
|
||||
* NOT_EMPTY: update table_a set <if test="columnProperty != null and columnProperty!=''">column=#{columnProperty}</if>
|
||||
*
|
||||
* @since 3.1.2
|
||||
*/
|
||||
FieldStrategy updateStrategy() default FieldStrategy.DEFAULT;
|
||||
|
||||
|
||||
/**
|
||||
* 字段验证策略之 where: 表示该字段在拼接where条件时的策略
|
||||
* IGNORED: 直接拼接 column=#{columnProperty}
|
||||
* NOT_NULL: <if test="columnProperty != null">column=#{columnProperty}</if>
|
||||
* NOT_EMPTY: <if test="columnProperty != null and columnProperty!=''">column=#{columnProperty}</if>
|
||||
*
|
||||
* @since 3.1.2
|
||||
*/
|
||||
FieldStrategy whereStrategy() default FieldStrategy.DEFAULT;
|
||||
|
||||
```
|
||||
|
||||
### [12 | 异常处理:别让自己在出问题的时候变为瞎子](https://time.geekbang.org/column/article/220230)
|
||||
|
||||
**问题1:**关于在finally代码块中抛出异常的坑,如果在finally代码块中返回值,你觉得程序会以try或catch中的返回值为准,还是以finally中的返回值为准呢?
|
||||
|
||||
答:以finally中的返回值为准。
|
||||
|
||||
从语义上来说,finally是做方法收尾资源释放处理的,我们不建议在finally中有return,这样逻辑会很混乱。这是因为,实现上finally中的代码块会被复制多份,分别放到try和catch调用return和throw异常之前,所以finally中如果有返回值,会覆盖try中的返回值。
|
||||
|
||||
**问题2:**对于手动抛出的异常,不建议直接使用Exception或RuntimeException,通常建议复用JDK中的一些标准异常,比如[IllegalArgumentException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalArgumentException.html)、[IllegalStateException](https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalStateException.html)、[UnsupportedOperationException](https://docs.oracle.com/javase/8/docs/api/java/lang/UnsupportedOperationException.html)。你能说说它们的适用场景,并列出更多常见的可重用标准异常吗?
|
||||
|
||||
答:我们先分别看看IllegalArgumentException、IllegalStateException、UnsupportedOperationException这三种异常的适用场景。
|
||||
|
||||
- IllegalArgumentException:参数不合法异常,适用于传入的参数不符合方法要求的场景。
|
||||
- IllegalStateException:状态不合法异常,适用于状态机的状态的无效转换,当前逻辑的执行状态不适合进行相应操作等场景。
|
||||
- UnsupportedOperationException:操作不支持异常,适用于某个操作在实现或环境下不支持的场景。
|
||||
|
||||
还可以重用的异常有IndexOutOfBoundsException、NullPointerException、ConcurrentModificationException等。
|
||||
|
||||
以上,就是咱们这门课第7~12讲的思考题答案了。
|
||||
|
||||
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
|
||||
Reference in New Issue
Block a user